From 9d93f3f977f2ce7ccd9dc9e66a85c57d0030bdd5 Mon Sep 17 00:00:00 2001 From: Adrian-LSY Date: Tue, 19 Mar 2024 14:12:00 +0800 Subject: [PATCH] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit d3571c399ac854e5ccbf8ec5cce78c0d00312efb Author: Tim Levett Date: Mon Mar 18 16:40:46 2024 -0500 substring should be lowercase (#84682) commit 0606a8e413d4253df7f5a6d0edaecc13c0dedd5a Author: Isabella Siu Date: Mon Mar 18 17:30:59 2024 -0400 CloudWatch: Static labels should use label name (#84611) CloudWatch: static labels should use label name commit 2d1cd82a98e2e38376008529fe704e04cd67cf7a Author: Charandas Date: Mon Mar 18 10:25:30 2024 -0700 K8s: standalone: use Grafana's logger to stream all logs (#84530) --- Co-authored-by: Marcus Efraimsson commit 3ea5c08c88ba13885746fd2289476cdd5a604951 Author: Matthew Jacobson Date: Mon Mar 18 13:04:57 2024 -0400 Alerting: External AM fix parsing basic auth with escape characters (#84681) commit 494d1699805ab603ccd9ef2f6ba7d28caa975088 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Mon Mar 18 18:01:33 2024 +0100 Elasticsearch: Fix legend for alerting, expressions and previously frontend queries (#84485) * Elasticsearch: Fix legend for alerting, expressions and previously frontend queries * Add comment * Update comment commit 296f4219f81a118f505ada7c23864bc913bbad3f Author: Tom Ratcliffe Date: Mon Mar 18 11:08:18 2024 +0000 Add missing `external` link for TextLink commit 8f50ccbb7cdc2f18109dd558b9bb4fda18dd872e Author: Tom Ratcliffe Date: Mon Mar 18 11:07:55 2024 +0000 Fix display of Alert diagram in Safari commit b3e9a6d0b3882746f79a010766f85ef4e4cea90b Author: Tom Ratcliffe Date: Thu Mar 7 16:37:57 2024 +0000 Use Text component more consistently in GettingStarted commit fb2ba574c60ebe56e924db236bc2ec465370e571 Author: Tom Ratcliffe Date: Thu Mar 7 16:17:46 2024 +0000 Convert getting started styles to object syntax commit 30a791d77a3966cc1971cf78403c6f89a8242304 Author: Tom Ratcliffe Date: Thu Mar 7 15:11:09 2024 +0000 Tidy up styling for Getting Started page and use @grafana/ui components commit 2e2a5bca116b109c8d8ad1353e352ad5d7ffdef8 Author: Tom Ratcliffe Date: Thu Mar 7 15:10:29 2024 +0000 Remove unused `showWelcomeHeader` prop commit 83464781bea51e5682d9e6d167d788e9389f95bd Author: Tom Ratcliffe Date: Thu Mar 7 14:33:25 2024 +0000 Remove video from Alert Getting Started page commit 2e6bb6416d8abf61c3099229953c95650c7ef4f2 Author: Leon Sorokin Date: Mon Mar 18 11:20:43 2024 -0500 VizTooltips: Fix position during bottom or right edge initial hover (#84623) commit 818c94f067f2a266a3f7bea41ff476acabe91206 Author: Kyle Brandt Date: Mon Mar 18 12:16:53 2024 -0400 Scopes: (Chore) Fix ScopeDashboard by adding spec (#84675) commit 63e8753aa0ebfc1b3d700dbf560147887c16feca Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Mon Mar 18 12:16:38 2024 -0400 datatrails: integrate dashboard panels with metrics explore (#84521) * feat: integrate dashboard panels with metrics explore - add dashboard panel menu items (in non-scenes dashboard) to open `metric{filters}` entries detected from queries to launch "metrics explorer" drawers for the selected `metric{filter}` * fix: remove OpenEmbeddedTrailEvent * fix: use modal manager dismiss capabilities instead commit b1b65faf0277bf8647d30bb5133096d5ca1763ca Author: Ashley Harrison Date: Mon Mar 18 16:12:00 2024 +0000 Variables: Support static keys in AdHocFiltersVariable (#83157) * initial start * don't use getTagKeysProvider * some cleanup * undo kinds adjustment * simplify * remove async declaration * add description and a couple of unit tests * add transformSaveModelToScene test * add tests for AdHocVariableForm * add tests for AdHocFiltersVariableEditor * update to defaultKeys * fix snapshots * update to 3.13.3 commit 677b765dab286d14eabbf6a5992cdf459f6f4347 Author: Rob <35775181+morrro01@users.noreply.github.com> Date: Mon Mar 18 10:26:22 2024 -0500 NodeGraph: Edge color and stroke-dasharray support (#83855) * Adds color and stroke-dasharray support for node graph edges Adds support for providing color, highlighted color, and visual display of node graph edges as dashed lines via stroke-dasharray. * Updates node graph documentation * Updates documentation Adds default for `highlightedColor` * Update docs/sources/panels-visualizations/visualizations/node-graph/index.md Co-authored-by: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> * Update packages/grafana-data/src/utils/nodeGraph.ts Co-authored-by: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> * Update docs/sources/panels-visualizations/visualizations/node-graph/index.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Removes highlightedColor; deprecates highlighted Per [request](https://github.com/grafana/grafana/pull/83855#issuecomment-1999810826), deprecates `highlighted` in code and documentation, and removes `highlightedColor` as an additional property. `highlighted` will continue to be supported in its original state (makes the edge red), but is superseded if `color` is provided. * Update types.ts Missed a file in my last commit. Removes `highlightedColor` and deprecates `highlighted`. * Add test scenario in test data source --------- Co-authored-by: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Co-authored-by: Andrej Ocenas commit e96836d19eb52f0b764cd50f484976b40a3fb40e Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Mon Mar 18 11:00:31 2024 -0400 datatrails: Remove prefix filter (#84661) * fix: use cascader's clear button * fix: remove the prefix filter from metric select * fix: remove supporting code in metric select scene - For the removed prefix filter * fix: spacing commit 259d4eb6ec1fd1393da43cccf00ecaa6a73de77f Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon Mar 18 14:45:34 2024 +0000 I18n: Download translations from Crowdin (#84664) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> commit 767608f3a60a898062feff86ae9dd0df57bb357e Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Mon Mar 18 10:23:19 2024 -0400 Data trails: use description of data source to shorten label (#84665) fix: use description of data source to shorten label commit aec2ef727a421542873db8d6e7a3573ad721ab76 Author: Kyle Brandt Date: Mon Mar 18 09:49:26 2024 -0400 Prometheus/Scopes: Update to use scopespec type from app (#84593) commit cc6459deaf13e06682e4054ef17dc41b49eea224 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon Mar 18 13:39:05 2024 +0000 I18n: Download translations from Crowdin (#84660) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> commit ebcca970521886668d5613105f0ba5800d45519b Author: Erik Sundell Date: Mon Mar 18 14:26:56 2024 +0100 Annotation query: Render query result in alert box (#83230) * add alert to annotation result * cleanup * add tests * more refactoring * apply pr feedback * change severity * use toHaveAlert matcher commit 5b085976bfc15686c3d4a56057b7f489d0a956c5 Author: Andrej Ocenas Date: Mon Mar 18 14:25:47 2024 +0100 Pyroscope: Fix template variable support (#84477) commit aac2cf0aa5dd21175c7ac1c4bf5fe3df0d5c8c1f Author: Kyle Brandt Date: Mon Mar 18 09:22:28 2024 -0400 Scopes: Update BE API to include object for linking scope to dashboards (#84608) * Add ScopeDashboard --------- Co-authored-by: Todd Treece commit 155e38edfe7aa553e1822297bb41309c35a1a2bf Author: Levente Balogh Date: Mon Mar 18 14:14:51 2024 +0100 Plugin Extensions: Add prop types to component extensions (#84295) * feat: make it possible to specify prop types for component extensions * Update packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts * chore: adapted test case * chore: update betterer * feat: update types for configureComponentExtension() * fix: remove type specifics for `configureExtensionComponent` * Update betterer config --------- Co-authored-by: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Co-authored-by: Darren Janeczek commit fbb6ae35e79d3497207eb2076f8b09dd9680001b Author: Josh Hunt Date: Mon Mar 18 13:00:18 2024 +0000 E2C: Use cloudMigrationIsTarget config (#84654) Use cloudMigrationIsTarget config commit 00f16cd01851196f4029ec1b212d816e3050760d Author: Isabella Siu Date: Mon Mar 18 08:56:57 2024 -0400 CloudWatch Logs: Remove toggle for cloudWatchLogsMonacoEditor (#84414) commit 7aa0ba8c59b449009789348fc30efcb1d51305df Author: Ieva Date: Mon Mar 18 12:52:01 2024 +0000 Teams: Display teams page to team reader if they also have the access to list team permissions (#84650) * display teams to team reader if they also have the access to list team permissions * fix a typo in the docs commit 4ca68925a1ff3d022acbf6260fd9d11fe1573812 Author: Jack Westbrook Date: Mon Mar 18 13:28:24 2024 +0100 Backend: Delete bundled plugin tests (#84646) commit 3e97999ac5a3270798a4fe330557ca49b5eba654 Author: Matias Chomicki Date: Mon Mar 18 13:24:06 2024 +0100 LogRowMessageDisplayedFields: increase rendering performance (#84407) * getAllFields: refactor for improved performance * LogRowMessageDisplayedFields: refactor line construction for performance * AsyncIconButton: refactor to prevent infinite loops commit a7c7a1ffed7c49c38bcc493dee7d221e7bf5cdfe Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Mon Mar 18 13:18:57 2024 +0100 Alerting docs: Fix format issues in recent Slack tutorial (#84651) * Alerting docs: Fix format issues in Slack tutorial * Alerting docs: Include link to Slack docs * Alerting docs: fix Slack `nested-policy` link commit 3297d589c06cfa44e202c4e14790026330404704 Author: Ashley Harrison Date: Mon Mar 18 12:04:43 2024 +0000 ConfirmButton: Stop pointerEvents on the correct element (#84648) stop pointerEvents on the correct element commit 26e1a5887ac4116a5e9c50d35e0132dc81a706d9 Author: Sergej-Vlasov <37613182+Sergej-Vlasov@users.noreply.github.com> Date: Mon Mar 18 14:02:12 2024 +0200 DashboardScene: Reset editIndex on variable delete (#84589) * reset edit index on variable delete * adjust delete variable test * adjust test to be more in line with user flow commit 6241386a96597866ad3adad1a5066b0a5998709f Author: Andre Pereira Date: Mon Mar 18 11:38:17 2024 +0000 Data Trails: Sticky main metric graph (#84389) * WIP * Refactor code a bit so we can sticky the main graph and tabs * Make sure it works in Firefox. Avoid annoying warnings in breakdown tab. Update pin metrics graph label * Small copy change Co-authored-by: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> --------- Co-authored-by: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> commit e394110f446436d2d189caaca5cd60e32558e13f Author: Andres Martinez Gotor Date: Mon Mar 18 12:08:49 2024 +0100 Fix api_plugins_test locally (#84484) commit ed3bdf5502aa21819046d5677847fdd340c31b3b Author: Josh Hunt Date: Mon Mar 18 11:00:43 2024 +0000 I18n: Expose current UI language in @grafana/runtime config (#84457) * I18n: Expose current UI language in Grafana config * fix commit aa03b4393f27af3dae1220365fb9f14a9b668f21 Author: Serge Zaitsev Date: Mon Mar 18 11:45:25 2024 +0100 Chore: Clean up CHANGELOG for 10.4.0 (#84551) clean up changelog for 10.4.0 to remove the items that did not make it into the release commit eb813f2a19bb585486c4cddf390aaf0d316a4620 Author: tonypowa <45235678+tonypowa@users.noreply.github.com> Date: Mon Mar 18 11:24:18 2024 +0100 changes to #84476 (#84638) * removed note shortcode * prettyfied commit 8714b7cd8c9231894681d8221f64c8607648e573 Author: Karl Persson Date: Mon Mar 18 11:15:49 2024 +0100 RolePicker: Don't try to fetch roles for new form (#84630) commit fce78aea2cd7085d1609da306ce0bf775d7aea5c Author: Polina Boneva <13227501+polibb@users.noreply.github.com> Date: Mon Mar 18 11:30:27 2024 +0200 Variables: Multi-select DataSource variables are inconsistently displayed in the Data source picker (#76039) Always show multi-select DataSource(DS) variables in the DS picker, and display a warning in the panel when a DataSource variable holds multiple values and is not being repeated. --------- Co-authored-by: Alexandra Vargas Co-authored-by: Alexa V <239999+axelavargas@users.noreply.github.com> Co-authored-by: Torkel Ödegaard commit f5e83d07a7852039fc9b208c1ce7ff9f92d2c734 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Mon Mar 18 09:17:10 2024 +0000 Tempo: Deprecate old search (#84498) * Add logic to update query from old search to new search i.e. nativeSearch to traceqlSearch * Remove nativeSearch query and transform * Update tracking * Remove nativeSearch from query field * Udpdate gen comment * Fix tests * Add tests * Remove comments * Remove log * Remove log * Update comment * Update ids etc for migratedQuery * Remove old nativeSearch folder * Fix tests, manual testing commit 1de4187a6e0ef3532f9f092327db4e724def8eca Author: Jack Westbrook Date: Mon Mar 18 09:48:19 2024 +0100 Chore: Delete Input Datasource (#83163) * chore(input-datasource): delete bundled plugin for grafana 11 * chore(betterer): refresh results file * chore(yarn): run dedupe to clean up deps * chore(yarn): pin playwright to 1.41.2 to see if CI passes * chore(yarn): pin playwright to 1.42.1 commit 6204f1e847088d3283507985ff4d031fc67f7cc4 Author: Andres Martinez Gotor Date: Mon Mar 18 09:33:22 2024 +0100 Chore: Use SigV4 middleware from aws-sdk (#84462) commit 39b32524e2f6e08512a78d8efa98042d6b048cee Author: Alex Khomenko Date: Sat Mar 16 08:48:17 2024 +0100 AnnotationsEditor: Remove deprecated components (#84538) * AnnotationEditorForm: Remove deprecated components * AnnotationEditor2: Remove deprecated components commit 1714d52f17dc00f12af200bc21e95ba91d601102 Author: Alex Khomenko Date: Sat Mar 16 08:48:05 2024 +0100 Chore: Replace deprecated Form imports (#84537) * SignupInvited: replace Form * Chore: replace Form import * Chore: replace HorizontalGroup * Replace the component in OrgProfile commit 5fa627e207a8cf30c27e885281fc6d73244bb907 Author: Alex Khomenko Date: Sat Mar 16 08:47:39 2024 +0100 Playlist: Remove deprecated components (#84536) commit 89f3b70e174867fed413f66e0262589d1634fe6d Author: Dan Cech Date: Fri Mar 15 19:17:54 2024 -0400 Storage: Add support for listing resource history (#84331) * add support for listing resource history * make watch handle custom label selectors properly * fix tests * Apply suggestions from code review Co-authored-by: Diego Augusto Molina * properly handle special characters in json label matcher * tidy up --------- Co-authored-by: Diego Augusto Molina commit 9c7a5ed506478d444804b15bcd1f99d226880f74 Author: Chris Bedwell Date: Fri Mar 15 22:57:31 2024 +0000 Alerting: Fix infinite re-render when linking to alert redirect page (#84305) fix: move where the fallback array is initialized so not to create an infinite re-render commit d0885ffdaa3b568081f850d40d94d536bab05935 Author: ismail simsek Date: Fri Mar 15 22:35:07 2024 +0100 Chore: Bump the promlib version to v0.0.2 (#84616) bump the promlib version commit 59baa7a4a4ec5a07800d10ad4ad3a2fe7e69da34 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Fri Mar 15 15:27:31 2024 -0400 fix: data trails ignore usage syntax (#84610) commit 08f4aeded18a2d51ad0ce20337bbfa206d8ec1c9 Author: Josh Hunt Date: Fri Mar 15 17:22:39 2024 +0000 E2C: Change permissions for navigating to Cloud Migration (#84594) * allow org admins / settings writers to access e2c * test for org admin specifically commit 97f37b2e6fea4512fc5e77bb298da17788e792dd Author: William Wernert Date: Fri Mar 15 12:59:45 2024 -0400 Alerting: Clamp Loki ASH range query to configured max_query_length (#83986) * Clamp range in loki http client to configured max_query_length Defaults to 721h to match Loki default commit f2628bfad4d62ef6086d0e35bc3dc0ac402ac572 Author: Josh Hunt Date: Fri Mar 15 16:39:13 2024 +0000 Whitelabelling: Override version in UI from config (#84392) * Unify how the version is shown in the UI * use versionString in dashboard help bundles * fix lint * remove comment * fix test types * make test less flakey commit 1ce2ae427fdf11159d37a93be6e09b13735df2e7 Author: Gilles De Mey Date: Fri Mar 15 17:37:11 2024 +0100 Alerting: Query and conditions improvements (#83426) commit f27368195637cf4a557bd17a9ff0f6ac6e4edfdd Author: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Fri Mar 15 09:35:07 2024 -0700 Canvas: Add vertex control to connections (#83653) * Canvas: Add vertex control to connections * Add function for vertex conversion * Add vertex interface * Add future vertex handling * Only show vertices when connection selected * Add vertices to save model * Apply select constraint to first midpoint * Add some infrastructure for vertex edit * Render vertex edit and capture events * Save vertex edit on button release * Handle adding new vertices * Limit number of vertices to 10 * Handle zoom for vertex edit and creation * Rename future to add * Remove more references to future * Remove unsued console log * Clean up styles * Add some clarity for path generation * Add clarity to connections event handling --------- Co-authored-by: nmarrs commit 6d74553653979ccc8838d1168d3a88c7d2a5b211 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri Mar 15 16:22:27 2024 +0000 I18n: Download translations from Crowdin (#84601) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> commit bba1b124483dd557bdc3fa5efe47bc512803552c Author: Matias Chomicki Date: Fri Mar 15 16:24:41 2024 +0100 Logs popover: allow click listeners to run before closing the menu (#84583) * Logs popover: allow click listeners to run before closing the menu * Decrease buffer time commit 200ff7f9b4febfcd7c76e0fefb074648ef16ab3b Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Fri Mar 15 16:23:52 2024 +0100 Alerting docs: fix broken link (#84572) commit 7e5ce8fc200965170f0c5231264874b178dd59b4 Author: Laura Benz <48948963+L-M-K-B@users.noreply.github.com> Date: Fri Mar 15 16:14:11 2024 +0100 I18n: Add milestone automatically to Crowdin PRs (#84253) * refactor: add milestone automatically * refactor: remove step for extracting pr number * refactor: milestone step commit d4b3877eb87b2ce81dd008f434d4afef93205868 Author: Torkel Ödegaard Date: Fri Mar 15 16:05:34 2024 +0100 Table: Fixes migration for hidden columns in angular table (#84579) * Table: Fixes migration for hidden columns in angular table * update commit 4dcbf4e5bb9a540250b9d8aa85c49d16c5bcd67f Author: Torkel Ödegaard Date: Fri Mar 15 16:00:58 2024 +0100 Select: Fixes virtualized select showing empty space above selected value (#84544) * Select: Virtualized select bug replication * Update with fix * remove story * Pin version * Update * Update commit e93ba13f7ab6f083794d9ff5dcf36eef65045629 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Fri Mar 15 11:00:05 2024 -0400 datatrails: style: update buttons to secondary solid format (#84526) * fix: secondary solid buttons for selecting metrics * fix: secondary solid buttons for selecting labels * fix: secondary solid buttons for adding filters commit 85bd116a1071b71d290487112e5532487f256781 Author: Gilles De Mey Date: Fri Mar 15 15:49:05 2024 +0100 Alerting: Fix optional fields requiring validation rule (#84584) fixes #84296 commit 1f13a1481525b40857f31c61320fe4b6edf29d5d Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri Mar 15 15:48:16 2024 +0100 Alerting: Fix wrong use of empty list in times field in the UI (#84179) * Fix wrong use of empty list in times field in the UI * Add tooltip for disable switch * Show disabled badge in mute timings * Disable time ranges when disabling time interval in the UI * PR review comments * remove tooltip for the field as it does not register it correctly * remove wrong code line * Add comment * Address PR review comments commit 4753948262bc77af992409c63ba00d53b638f425 Author: Torkel Ödegaard Date: Fri Mar 15 15:39:04 2024 +0100 DashboardScene: Don't show switch to old dashboard architecture toggle unless you are in dev mode (#84444) * DashboardScene: Don't show switch to old architecture toggle unless you are in dev mode * Update commit d4e802dd4792c9013cf60a1c3b72c737e31eb364 Author: Karl Persson Date: Fri Mar 15 15:08:15 2024 +0100 Authn: Add function to resolve identity from org and namespace id (#84555) * Add function to get the namespaced id * Add function to resolve an identity through authn.Service from org and namespace id * Switch to resolve identity for re-authenticate in another org commit ced09883d378c05a4d06621afa979076cb77b80e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Mar 15 14:08:06 2024 +0000 Update dependency react-hook-form to v7.51.0 (#84582) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 6c8895e349d3726826c4e0a3849bce7a030b89cb Author: Eric Leijonmarck Date: Fri Mar 15 15:00:25 2024 +0100 Service accounts: Same Org fix migration to account for duplicate entries (#84349) * bug: fix migration to account for duplicate entries * refactoring to create test folder for user migrations * fix migration log * added the migration * additional tests * added extSrv tests commit a0b68deae44cd2593a1add38fde09cbcafcb1272 Author: Isabella Siu Date: Fri Mar 15 09:49:53 2024 -0400 Cloudwatch: Remove cloudWatchWildCardDimensionValues feature toggle (#84329) commit 06723b9647f0a1820f87a2a6e923a027f5b721e5 Author: Jack Westbrook Date: Fri Mar 15 14:14:25 2024 +0100 Chore: Pin version of playwright (#84558) chore(playwright): pin version of playwright to prevent CI failures commit fa9f69270736af61f1bd973e269bf54aaef61638 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri Mar 15 14:08:09 2024 +0100 Alerting: Fix AlertsFolderView not showing rules when using nested folders (#84465) * Fix AlertsFolderView not showing rules when using nested folders * Fix tests commit d06e73ac2891279d8802848124f1a5652abc9b9a Author: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Fri Mar 15 09:04:46 2024 -0400 Docs: add alt text (#84532) Added alt text commit 7a28ce7795a4dbd3c281b2a928b07e7b93550f5c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Mar 15 13:04:38 2024 +0000 Update dependency eslint-webpack-plugin to v4.1.0 (#84571) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 15a641a509c033862100bee263de670861841c7c Author: Matias Chomicki Date: Fri Mar 15 13:52:55 2024 +0100 Infinite Scroll: wait for users to reach the top before triggering new requests (#84318) * Infinite Scroll: wait for users to reach the top before triggering new requests * Prettier * Infinite Scroll: control top scrolling via prop * Prettier commit eae9bfe4bc0c6450c93200aa4b16f2bb3847c07d Author: ismail simsek Date: Fri Mar 15 13:37:29 2024 +0100 Chore: Promlib allows extendOptions to be nil (#84463) * use logger from service * allow extendOptions to be nil * Update logger commit 94a9aeaccc8ab65c451688c968b783a433eabc63 Author: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Fri Mar 15 08:36:07 2024 -0400 Docs: fix broken links (#84531) Fixed broken links commit 25ed621aa3d982a245101d2f2ae55a7dbb5717b9 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Mar 15 12:10:17 2024 +0000 Update dependency date-fns to v3.5.0 (#84557) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 1208888bb618774228aefe65884e06316a06e714 Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Fri Mar 15 14:05:27 2024 +0200 Folders: Allow listing folders with write permission (#83527) * Folders: Allow listing folders with write permission * Check for subfolder access if parent does not have * Add test * GetFolders: fix ordering * Apply suggestion from code review commit 39232a07761f862bed150faa85d6169c95493a77 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri Mar 15 12:36:34 2024 +0100 Alerting: Show error message when error is thrown after clicking create alert f… (#84367) Show error message when error is thrown after clicking create alert from panel commit 0acb400248dc66d697b83e89e27d3c22c4c033da Author: Jack Westbrook Date: Fri Mar 15 12:02:27 2024 +0100 Chore: Remove unused rollup plugin dependencies (#84492) * chore(runtime): remove unused rollup-plugin-terser dependency * chore(packages): remove more unused rollup plugins commit a8432aad3dd6830d75b9dcbe9737edfdf21da9e6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Mar 15 10:01:07 2024 +0000 Update dependency @types/node to v20.11.28 commit 500840ab77a0a752c16f5fce97d84686b18db245 Author: tonypowa <45235678+tonypowa@users.noreply.github.com> Date: Fri Mar 15 11:27:11 2024 +0100 alerting docs: slack integration (#84476) * alerting docs: slack integration * added links * removed aliases and formated notes * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * amended links * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * couple of minor corrections * fixing link * ran prettier --------- Co-authored-by: Jack Baldry Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> commit 458d694d78f72a2ffe382af757ce8b3b5681aa73 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Fri Mar 15 11:11:12 2024 +0100 Update GitHub Actions to add `datasource/Parca` label (#84455) commit 6e6d6e368eebaf828bc540f29b742c20f38203fa Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Fri Mar 15 10:06:05 2024 +0000 Scenes: Restrict panel menu options when in edit mode (#84509) commit 9d453d0dcc5b7e57de3238c370cd470d0b78cd4a Author: Will Browne Date: Fri Mar 15 10:58:51 2024 +0100 Plugins: Remove direct featuremgmt.FeatureToggles dependency from plugins config (#84482) commit c13e2483840438f4d4bd9a9921a090be7c8668b8 Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Fri Mar 15 09:58:08 2024 +0000 Scenes: Fix issue with discarding unsaved changes modal in new dashboards (#84369) commit 78d7ebd499ae86529cb51c47a0325ef298ef0f0e Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Fri Mar 15 10:54:00 2024 +0100 Fix GitHub action to add `datasource/Jaeger` label (#84448) commit cc6439d9898295c71bc8ee2d8375f5154dcfa1bd Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Mar 15 09:30:39 2024 +0000 Update dependency @manypkg/get-packages to v2.2.1 commit e5faeb324da3b0cb9ce336f7c7e013883e649d9d Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Fri Mar 15 09:48:29 2024 +0000 Scenes: Make repeat panels respect maxPerRow (#84497) commit ebf455d1072f05bc8c89cdd81842d9c73478d300 Author: Karl Persson Date: Fri Mar 15 10:36:16 2024 +0100 RBAC: Don't refetch permissions when searching for users in authenticated org (#84546) Don't refetch permissions when searching for users in authenticated org commit 25631fd107fecd42c8817ead10a512cdcca2d13d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Mar 15 09:05:19 2024 +0000 Update dependency @grafana/scenes to v3.13.2 commit 05f737b712961ac7171f1d9cc2773460fa81f303 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Mar 14 16:05:19 2024 +0000 Update dependency @types/react to v18.2.66 commit 06b8eb627937c032402a7df05920209cab447a49 Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Fri Mar 15 10:00:51 2024 +0100 Alerting docs: update `Supported data sources` (#84495) * Alerting docs: update `Supported data sources` * Update docs/sources/alerting/fundamentals/alert-rules/_index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> --------- Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> commit ff03cb33f1671a716ad197e85dfe479646c64bfa Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri Mar 15 08:40:46 2024 +0000 Bump actions/upload-artifact from 3 to 4 (#84527) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> commit 759cefd94c135c0bfc34ec4320f9538b2682889d Author: Charandas Date: Fri Mar 15 00:18:11 2024 -0700 ExtSvcAccounts: FIX tests that accidently depended on enterprise (#84535) * ExtSvcAccounts: FIX tests that accidently depended on enterprise * fix commit 9d4504da081ca0d72de41ecfe79c80b3b30a9755 Author: Lucy Chen <140550297+lucychen-grafana@users.noreply.github.com> Date: Fri Mar 15 14:46:53 2024 +0900 Reporting: Update api doc for deprecated old schedule (#84072) update api doc commit 8389ce386255c561117a67981dde2dceb33d8ad2 Author: Nick Richmond <5732000+NWRichmond@users.noreply.github.com> Date: Thu Mar 14 20:49:13 2024 -0400 Prometheus: Fix incorrect baseline in classic histogram (#83863) Co-authored-by: Leon Sorokin commit 34b5303daf699651a4ef2fae83774234f1bf199b Author: Charandas Date: Thu Mar 14 16:33:41 2024 -0700 K8s: file-storage: provide empty for resourceVersion initially (#84523) commit 0fa0cede7515c8a886734b0cae046bab31de9fc7 Author: Dan Cech Date: Thu Mar 14 17:12:20 2024 -0400 Storage: streamline context handling (#84319) streamline context handling commit 2795f9827a8a6e41e6fb6484e999e654fbaa4ea6 Author: Gabriel MABILLE Date: Thu Mar 14 19:11:02 2024 +0100 ExtSvcAccounts: FIX prevent service account deletion (#84502) * ExtSvcAccounts: Fix External Service Accounts Login check Co-authored-by: Karl Persson * Remove service accounts assignments and permissions on delete * Fix first set of tests * Fix second batch of tests * Fix third batch of tests --------- Co-authored-by: Karl Persson commit 827860d4595ac372b0e3ce1058c04cc33978fbe0 Author: Yuri Tseretyan Date: Thu Mar 14 14:03:53 2024 -0400 Alerting: Alerting accesscontrol utilities (#84508) * create fake for accesscontrol.RuleService * make errAuthorizationGeneric public commit 6bc662e53b341504b95564beb74c5361ba8edbee Author: Timur Olzhabayev Date: Thu Mar 14 18:15:36 2024 +0100 Chore: Removing error object from tracking (#84500) Removing error object from tracking commit f727e2187362bb707fc23853f199446de759e6d6 Author: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com> Date: Thu Mar 14 11:10:35 2024 -0500 Docs: Fixed a typo in the Azure config page (#84475) fixed typo, cleaned up some language commit f7d836feedbb78e9df29ae1f24670c98dee6eaa2 Author: Yuri Tseretyan Date: Thu Mar 14 12:04:10 2024 -0400 Alerting: Update rule provisioning service to accept user (#84480) commit 6d19894a7d6c2b30586d3ca0baa5274a0d84adf4 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Mar 14 16:01:58 2024 +0000 Replace dependency rollup-plugin-terser with @rollup/plugin-terser 0.1.0 (#84487) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit ec42b2a361fda0fb5fe317c5641c37deca2342bb Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Thu Mar 14 16:58:18 2024 +0100 Alerting docs: restructure `Introduction` (#84248) * Rename `Data sources` title * Relocate and rename `Introduction/Notification templates` * Rename `alert-rules/alert-instances` to `alert-rules/multi-dimensional-alerts` * Move `fundamentals/high-availability` to `setup/enable-ha` * Fix 404 high-availability alerting link on Setup HA Grafana docs * Move alert manager/contact poitns/notification templates within Notifications * Remove `Alerting on numeric data` * Restructure Introduction v2 * Continue Intro restructuring * Update docs/sources/alerting/fundamentals/alert-rules/_index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Complete contact point TODO * Alias: alertManager * Aliases `annotation-label` + content changes * Aliases to `templating-labels-annotations` * Aliases to `queries-conditions` * Rename `rule-evaluation.md` file * Aliases: `contact points` * Aliases to `message-templating` * Aliases to `alert-rules` * Update links to new URL slugs * Remove duplicated alias * Remove trailing slash for external heading links * Remove trailing slash in heading links to other grafana pages * Change URL directory slug `fundamentals/notifications` * rename title `Configure High Availability` * Content changes * Update docs/sources/alerting/fundamentals/alert-rules/_index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/configure-alert-state-history/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/configure-high-availability/_index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/configure-alert-state-history/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/configure-high-availability/_index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/configure-high-availability/_index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/configure-high-availability/_index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/fundamentals/alert-rules/_index.md Co-authored-by: Jack Baldry * Fix broken link reference * Fix `queries-and-conditions` * Fix `alert-rule-evaluation` ref link * Fix aliases + inline doc comments * Fix broken link --------- Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> Co-authored-by: Jack Baldry commit 321148511bf01d4c52f963fd7044954a31b9e397 Author: Ashley Harrison Date: Thu Mar 14 15:50:44 2024 +0000 Chore: Rewrite `ConfirmButton` (#84402) * convert to function component, clean up + fix a11y * use position: fixed; * fix commit 81a63efab25ed70d9011a947926ed02b1a58975c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Mar 14 17:39:28 2024 +0200 Update dependency rc-cascader to v3.24.0 (#84453) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit fe15bb5f481eca89618d1679c588407999a277c8 Author: Gilles De Mey Date: Thu Mar 14 16:20:23 2024 +0100 Alerting: Update composable_kind.go (#84479) update composable_kind.go commit f36ad469d03ff6c930e049b3d13165048b0705a9 Author: Alexander Zobnin Date: Thu Mar 14 16:17:24 2024 +0100 Access Control: Get global role from request params (#84469) commit da66f560a521d08ebda681ba94d616263ab41121 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Mar 14 14:59:32 2024 +0000 Update dependency rc-tooltip to v6.2.0 (#84454) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 0b033106b07d1574c406d7f432242f2bb0590564 Author: Joao Silva <100691367+JoaoSilvaGrafana@users.noreply.github.com> Date: Thu Mar 14 14:41:54 2024 +0000 Cascader: Add clear button (#84449) commit 73d96b6d1f71aeb97c9ec742df86ba145cc6da9f Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Thu Mar 14 10:39:01 2024 -0400 datatrails: fix: clearly identify unspecified label values (#84474) fix: clearly identify unspecified label values commit 8765c4838990b441f7856c61eca1fb3587c067ea Author: Gilles De Mey Date: Thu Mar 14 15:36:35 2024 +0100 Alerting: Remove legacy alerting (#83671) Removes legacy alerting, so long and thanks for all the fish! 🐟 --------- Co-authored-by: Matthew Jacobson Co-authored-by: Sonia Aguilar Co-authored-by: Armand Grillet Co-authored-by: William Wernert Co-authored-by: Yuri Tseretyan commit f26344e17696b90214e3ee5c2d89d89718281eb0 Author: Alex Khomenko Date: Thu Mar 14 15:26:15 2024 +0100 Share modal: Remove deprecated Form components (#84173) * EmailSharingConfiguration: Remove deprecated component * CreatePublicDashboard: Remove deprecated component * Update imports * Update imports[2] * Fix import commit 9de3d75bea2ca47520508d08a85f1a95e193da79 Author: Yulia Shanyrova Date: Thu Mar 14 15:25:59 2024 +0100 Plugins: Add reportInteraction into plugin search (#84283) add reportInteraction into plugin search commit 336acaf0bf468f0327d2e51f54497f7bd0d86a3f Author: Gilles De Mey Date: Thu Mar 14 15:18:01 2024 +0100 Alerting: Promote new alerting detail view (#84277) commit 8690a42e331cbf0f8b1bb3a162ebeedd474323d7 Author: William Wernert Date: Thu Mar 14 09:58:25 2024 -0400 Alerting: Disallow invalid rule namespace UIDs in provisioning API (#83938) * Disallow invalid rule namespace UIDs in provisioning Reject requests with rules that reference a nonexistent folder or have an empty folder uid commit 8e90e02db2bf9de26766a5c640f8717bfb103fdb Author: Timur Olzhabayev Date: Thu Mar 14 14:49:07 2024 +0100 Chore: Adding log also for cases where datasource UID length is invalid (#84443) * Adding log also for datasource length commit 660fe64bc6c6182c64433dde3df4b36e63d528bf Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu Mar 14 14:37:33 2024 +0200 I18n: Download translations from Crowdin (#84458) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> commit 8d9521fb6d657fd9429edadb611842f3da112145 Author: Karl Persson Date: Thu Mar 14 13:25:28 2024 +0100 Refactor: Email verification (#84393) * Update template names * Add verifier that we can use to start verify process * Use userVerifier when verifying email on update * Add tests --------- Co-authored-by: Ieva commit 38a8bf10f34ac1367fa9aa0301836cb50e735283 Author: Laura Fernández Date: Thu Mar 14 13:06:34 2024 +0100 TimeRangePicker: Show `UTC+00:00` instead of just `UTC` (#84395) commit ec5e18cd93c122f896be2b242f355ebc7fd74f9e Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu Mar 14 11:45:51 2024 +0000 I18n: Download translations from Crowdin (#84435) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> commit 97521ef988f46f9eae5ea1040b706ce5d234229f Author: Gábor Farkas Date: Thu Mar 14 12:37:04 2024 +0100 postgres: improve snapshot-tests for long/wide situations (#84387) commit 0c4e0d0f707ff0d9bdc02d8e1bc466519845498b Author: Piotr Jamróz Date: Thu Mar 14 12:27:18 2024 +0100 Explore: Reorganize useStateSync code (#84281) * Reorganize the code * Simplify syncToURL * Introduce InitState type commit 831ee9ee1696c0aa7a6e4ca022ddfc0ab28b86dc Author: linoman <2051016+linoman@users.noreply.github.com> Date: Thu Mar 14 05:04:45 2024 -0600 samlsettings: add sso settings saml feature flag (#84433) * add feature flag for ssosettings saml configuration * add generated files commit 391d14d091e91301f95d6ce01ba8ca4dd4170b52 Author: Andreas Christou Date: Thu Mar 14 11:00:26 2024 +0000 Chore: Bump update checker interval to 1 day (#84404) * Bump interval to 1hr * 2 hours is better than 1 * Bump further to 1 day commit 917b66bb7a22e59a89000302efc0d879d000c31f Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Thu Mar 14 11:54:04 2024 +0100 Jaeger: Fix flaky test (#84441) commit 3692af05f8fc74de81438e8afb7e9ae804323a9a Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Mar 14 10:46:12 2024 +0000 Update dependency i18next-parser to v8.13.0 (#84437) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 3dc7c4a2e33b4d5cb35d7d639fe4b7d5cfece69d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Mar 14 10:45:38 2024 +0000 Update dependency i18next to v23.10.1 (#84436) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 187d1afb9cb05f814a76bce857d4eb18233399b3 Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu Mar 14 11:25:36 2024 +0100 Bump peter-murray/workflow-application-token-action from 2 to 3 (#84236) Bumps [peter-murray/workflow-application-token-action](https://github.com/peter-murray/workflow-application-token-action) from 2 to 3. - [Release notes](https://github.com/peter-murray/workflow-application-token-action/releases) - [Commits](https://github.com/peter-murray/workflow-application-token-action/compare/v2...v3) --- updated-dependencies: - dependency-name: peter-murray/workflow-application-token-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> commit 33154abcd976365f2f94ef62be971814a25c4a6d Author: Dmitry Filimonov Date: Thu Mar 14 03:00:30 2024 -0700 FlameGraph: adds ability to add context menu items (#81675) * pyroscope: adds ability to add context menu items * moves things around * removes console.log * improvements * Change the extra context button API shape * Add test * lint --------- Co-authored-by: Andrej Ocenas commit 40c2c2d6b8959672bdc087adae61736d9fafcd32 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Mar 14 09:34:32 2024 +0000 Update dependency @grafana/scenes to v3.13.1 commit e6949d98381404b5a4eb48448d81e14e95064ce8 Author: Josh Hunt Date: Thu Mar 14 09:46:18 2024 +0000 E2C: Resources table (#84317) * first pass at mock api and table * finish up resources table * i18n * centre icon: commit 5d23bc48b4cbbbd1d3771cc0b96c06c64d502bae Author: Torkel Ödegaard Date: Thu Mar 14 10:37:26 2024 +0100 DashboardScene: Fixes panel editor padding (#84426) commit 0c472ff00d39a0dda3737b5fb10d4145574b7a4f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Mar 14 11:34:14 2024 +0200 Update dependency eslint-plugin-jsdoc to v48.2.1 (#84427) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 84cbd5fe5cf5ffd2125adc0632feef58e0ead6ff Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Mar 14 11:30:50 2024 +0200 Update dependency eslint-plugin-react to v7.34.0 (#84428) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 5cc92b6202a48be6772379a6c18fa9457b558f92 Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu Mar 14 10:20:24 2024 +0100 Bump frabert/replace-string-action from 2.0 to 2.5 (#84235) Bumps [frabert/replace-string-action](https://github.com/frabert/replace-string-action) from 2.0 to 2.5. - [Release notes](https://github.com/frabert/replace-string-action/releases) - [Commits](https://github.com/frabert/replace-string-action/compare/v2.0...v2.5) --- updated-dependencies: - dependency-name: frabert/replace-string-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> commit 0b806597c316c4a33aca7621043ea68e030d3846 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Mar 14 09:10:59 2024 +0000 Update dependency date-fns to v3.4.0 (#84390) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 1d967072953e760da5b4328c69cc29273ae3333e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Mar 14 10:56:05 2024 +0200 Update dependency eslint-plugin-jest to v27.9.0 (#84425) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit b3f28205a1129945df6f8fcf78f915a73184fdaa Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Mar 14 08:35:41 2024 +0000 Update dependency @grafana/scenes to v3.13.0 (#84405) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit e1d9aa5a7b37113f6ba649d777aeba9331b83ce3 Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Thu Mar 14 09:01:13 2024 +0100 Alerting docs: document HTTP API to create templates (#84055) commit b6a020148b1189dccf061d332c23a88b0e0031c8 Author: Charandas Date: Wed Mar 13 16:54:30 2024 -0700 K8s: disallow MT storage functionality for Aggregator builders (#84408) commit 3696eca280bb239556d9f3ea3ef40188ccd8c5e7 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 13 17:45:47 2024 +0000 Update dependency diff to v5.2.0 (#84391) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 9573c13223a208bd0be670a64d9c612999c5144d Author: Nathan Marrs Date: Wed Mar 13 11:30:12 2024 -0600 Canvas: Add universal data link support (#84142) commit 0fe5b62fa5ab8a29f4db52b18ed4044950e97cc2 Author: Oscar Kilhed Date: Wed Mar 13 18:22:22 2024 +0100 Dashboard scenes: Unlink library panel inside edit mode (#84355) Unlink library panel inside edit mode commit cf6bed7ae54e6976cb14f3f54b275027922753fc Author: Kevin Minehart Date: Wed Mar 13 12:21:08 2024 -0500 Trigger downstream from grafana and not the mirror (#84381) commit 34f9bff9e031cde4e7817611c373dcd9e19b4d75 Author: Sven Grossmann Date: Wed Mar 13 18:14:21 2024 +0100 Loki: Fix null pointer exception in case request returned an error (#84398) Loki: Fix nullpointer in case query returned an error commit d3ef762cb99b0c31e7de1c8056729c1d41dfaf5e Author: David Harris Date: Wed Mar 13 16:42:48 2024 +0000 docs: update angular guidance (#84363) * docs: update angular guidance * Update docs/sources/developers/angular_deprecation/_index.md Co-authored-by: Jack Baldry * Update docs/sources/developers/angular_deprecation/_index.md Co-authored-by: Jack Baldry * update --------- Co-authored-by: Jack Baldry commit 3a2c7d8a99b729949a184bb6069c4e799e08e98d Author: mschaul <56293328+mschaul@users.noreply.github.com> Date: Wed Mar 13 17:28:29 2024 +0100 Transformations: Fix series to rows to work with only one series (#84232) commit f554bc822415844c78917ed05308a09bac713be1 Author: Giuseppe Guerra Date: Wed Mar 13 17:27:44 2024 +0100 Chore: Remove go mod replace directives for otel dependencies (#81628) * Chore: Remove go mod replace directives for otel dependencies * go mod tidy commit 90e0f8cab6c27b535320fd17a55c55e6476ef9dc Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Wed Mar 13 18:24:26 2024 +0200 Allows re-entering edit mode after version restore (#84298) * Allows re-entering edit mode after version restore * refactor * revert to previous commit commit 5c7849417b0c626a6cd2c6b6b0dd4b1bc2420c1c Author: Selene Date: Wed Mar 13 17:05:21 2024 +0100 Schemas: Replace registry generation and github workflow (#83490) * Create small registries for core and composable kinds * Update workflow with new registries * Fix imports in plugin schemas and deleted old registry generation files * Remove verification and maturity * Modify registries and add missing composable information to make schemas in kind-registry work * Add missing aliases * Remove unused templates * Remove kinds verification * Format generated code * Add gen header * Delete unused code and clean path in composable template * Delete kind-registry loader * Delete unused code * Update License link * Update codeowners path * Sort imports * More cleanup * Remove verify-kinds.yml from codeowners * Fix lint * Update composable_kidns * Fix cue extension * Restore verify-kinds to avoid to push outdated kind's registry * Fix composable format * Restore code owners for verify-kinds * Remove verify check commit fd9031ca37adcf10213dd9d3be9217af639b50f4 Author: Alexander Zobnin Date: Wed Mar 13 17:05:03 2024 +0100 Access Control: Get org from request data for authorization (#84359) * Access Control: Get org from request data for authorization * move type to models * Update pkg/services/accesscontrol/middleware.go Co-authored-by: Ieva * refactor * refactor * Fix linter --------- Co-authored-by: Ieva commit d480d3296c20d44aa5268b157c3c02b1787d2785 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 13 15:42:18 2024 +0000 Update d3 (#84383) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 6cc51b21d1d74dd1a6d37109acd22d5d5fac4353 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 13 17:36:04 2024 +0200 Update dependency @grafana/scenes to v3.12.0 (#84385) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit eca1bba01e1ce9777f6dbdf076750878aa96835e Author: Gábor Farkas Date: Wed Mar 13 16:23:50 2024 +0100 postgres: add more tests (to handle zero-rows queries) (#84384) commit 2420c5aa4dc196da9339a0cb575d00b12c447229 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 13 14:20:28 2024 +0000 Update dependency @types/node to v20.11.27 commit 8c7e65f6f48966b40389b40d9be56ea24d6fcc82 Author: Dominik Prokop Date: Wed Mar 13 16:15:40 2024 +0100 Dashboard settings: Allow saving dashboard from JSON model view (#84343) * Dashboard settings: Allow saving dashboard from JSON model view * Dashboard settings: Allow saving dashboard from JSON model view * Review fix commit 447e72fe43b5f65fa32dc21da131dc4f6814163b Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Wed Mar 13 16:03:20 2024 +0100 Docs: Add information about supported Loki versions by Loki data source (#84376) * Docs: Add Loki supported versions * Update commit bc2d7cf8e10277e5bb2d8391cc98bda71dc9f2e1 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 13 15:02:06 2024 +0000 Update dependency xss to v1.0.15 (#84379) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 1e02c831adddc3ec7b01792c4f8ce53bc3c07958 Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Wed Mar 13 14:39:30 2024 +0000 Chore: Bump esbuild and esbuild-loader (#84164) Chore: Bump esbuild and related dependencies commit 2f0784186b6d8600cc64de52f8ebf8db998ff3d3 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Wed Mar 13 14:22:57 2024 +0000 Tempo: Minor update to fix Tempo dev env as new vParquet used (#84348) * Minor update to fix Tempo dev env as new vParquet used * Remove vParquet version commit 1f2e9a544d20f45f42e69499a591e46e6ac2309c Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Wed Mar 13 14:22:20 2024 +0000 Tempo: Remove Loki tab (#84346) * Update docs * Remove loki tab from config settings * Remove loki query field * Remove loki search from ds, resultTransformer, tracking and tests * Cleanup removal of loki search * Remove loki section from query editor docs * Remove search type commit 06b7f6befa14d757dd9659150d4cf380bc6be0a9 Author: Georges Chaudy Date: Wed Mar 13 15:17:26 2024 +0100 k8s: ensure unified storage address is populated from config ini (#84373) fix: ensure unified storage address is populated from config ini commit f2567f5100a81c1f2a8fdca70a39cde62542c4eb Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 13 13:31:39 2024 +0000 Update dependency webpackbar to v6.0.1 commit f5c78e0ad97ce9ecb657cc9ef4d4b7b1885787da Author: Misi Date: Wed Mar 13 14:48:13 2024 +0100 RBAC: Add ActionSettingsRead action to general.auth.config writer (#84366) Add ActionSettingsRead action to general.auth.config writer commit c0d0f13a8d12f3cbf35c8bde4a33521adfe52026 Author: Sergej-Vlasov <37613182+Sergej-Vlasov@users.noreply.github.com> Date: Wed Mar 13 15:41:03 2024 +0200 DashboardScene: Reset query variable query and definition on datasource change (#84347) reset query variable query and definition on datasource change commit c0933fa6bb4038c519466d12bd2e61693e32a4e1 Author: Erik Sundell Date: Wed Mar 13 14:37:28 2024 +0100 Chore: Bump to latest version of plugin-e2e (#84370) bump plugin-e2e and update tests commit 0604ddac13b4f0f313407e6a990647602a9aba96 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 13 15:28:29 2024 +0200 Update dependency nanoid to v5.0.6 (#84372) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 0981403373dba3b8554b06d1c9fb62d8ca0cf233 Author: Gábor Farkas Date: Wed Mar 13 14:23:17 2024 +0100 PostgreSQL: Display correct initial value for tls mode (#84356) commit cd545ecb71816260ef26ccf1085b16b0c994e812 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 13 13:10:37 2024 +0000 Update dependency dompurify to v3.0.9 (#84371) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit d6acb474cfaee5f1ee953b0d082d2ee34700f88c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 13 12:29:03 2024 +0000 Update dependency @types/react-dom to v18.2.22 commit 13f597ef637d4d7e8078621e47f3f16a10b87706 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 13 12:25:28 2024 +0000 Update dependency @glideapps/glide-data-grid to v6.0.3 (#84361) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 7feeb7ce4e6a2003e64fe496c8579ecbc2961409 Author: Alex Khomenko Date: Wed Mar 13 13:01:24 2024 +0100 Cascader: Add id prop (#84240) * Cascader: Add id prop * Remove placeholder commit a89f1c22003b1f68aaf02e4313cd4ac7c9645cf5 Author: Matias Chomicki Date: Wed Mar 13 12:41:18 2024 +0100 Popover Menu: fix issue hiding menu behind scrollable container (#84311) * PopoverMenu: change position to fixed * PopoverMenu: close on empty selectionchange * Popover Menu: do not let menu overflow the window dimensions * Prettier commit 6599fa805d4c33305eb7faa0ed572f927916fee2 Author: Jack Westbrook Date: Wed Mar 13 12:40:09 2024 +0100 Plugins: Always load decoupled frontend assets from builds (#81873) * Wip * Wip * Adapt to load external module * build: remove cloudmonitoring from built_in_plugins, clean up webpack output * chore(plugins): remove decoupled plugins from package.json deps * chore(codeowners): update file for nx.json * revert(webpack): put back path in config * build(frontend): use nx to run prod builds of decoupled plugins with yarn build * style(prometheus): run prettier-write to fix tsconfig.json * style(backend): remove unused subFile.isDistDir * revert(locales): remove formatting changes adding new line at end of files * chore(webpack): clean up dev output * build(nx): make grafana an nx project, bump lerna and nx * build(plugin-configs): move cache directory to node_modules * style(datasource-plugins): add eslint ignore for .gen.ts files * chore(codeowners): add frontend-ops as owner of project.json * build(webpack): add getDecoupledPlugins to automatically ignore when watching * ci(drone): skip nx cache when building frontend packages * style(ci): fix missing trailing comma * Revert "style(ci): fix missing trailing comma" This reverts commit 7520d41576e08c1f44c9bf04117250f7e52bdec5. * Revert "ci(drone): skip nx cache when building frontend packages" This reverts commit 46938883acaefb74d189e8c622eb2a13fd45cdfb. * feat(zipkin): remove from grafana core bundle * chore(npm): bump nx package to latest 18.0.8 * docs(dev-guide): add a note about what yarn start now builds --------- Co-authored-by: Andres Martinez commit 75ea33e0cd7e478803df7852c190b7d998fe60da Author: Selene Date: Wed Mar 13 12:30:05 2024 +0100 Schemas: Delete unused code from the previous refactors (#84254) * Delete unused code from the previous refactors * Sort imports commit 2a1a5145d00bf992ab87e6cbd7cc37466eb31386 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Wed Mar 13 11:49:35 2024 +0100 Elasticsearch: Fix using of individual query time ranges when querying (#84201) * WIP - proof of concept * Update pkg/tsdb/elasticsearch/client/client.go Co-authored-by: Sven Grossmann * update and add test * lint * Fix lint * Bring back logging when creating client --------- Co-authored-by: Sven Grossmann commit 8ed4d5127f8a91aca464ca15409888ee9bb7f384 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Wed Mar 13 11:38:17 2024 +0100 Jaeger: Add tests (#83431) commit 154896b47e32e09b07359e13a787e503206fc7bf Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Wed Mar 13 11:25:28 2024 +0100 Docs: Update documentation for Elasticsearch (#84350) * Docs: Update documentation for Elasticsearch lucene query * Update docs/sources/datasources/elasticsearch/query-editor/index.md Co-authored-by: Sven Grossmann * Update docs/sources/datasources/elasticsearch/query-editor/index.md --------- Co-authored-by: Sven Grossmann commit c6140b989328b50656beebe1ec833185c002fee9 Author: Timur Olzhabayev Date: Wed Mar 13 10:19:04 2024 +0100 Docs: Aligning fallback values with documentation (#83617) aligning fallback values with documentation commit c061cc33cc14f16d956efbb6e089606f05c9adc5 Author: Andres Martinez Gotor Date: Wed Mar 13 10:14:16 2024 +0100 Chore: Use response limit middleware from SDK (#83915) commit ecd6de826a315526ed5fac0ce9e70f9f50e217c8 Author: Gábor Farkas Date: Wed Mar 13 09:52:39 2024 +0100 Postgres: Switch the datasource plugin from lib/pq to pgx (#83768) postgres: switch from lib/pq to pgx commit 2acd48d1c2f86c584d60871cd59d36fd4db95746 Author: Mihai Doarna Date: Wed Mar 13 10:31:17 2024 +0200 SSO: fix mergeSettings() in case the DB contains empty URLs (#84290) * fix mergeSettings() in case the db contains empty strings * use correct github urls in test * overwrite only urls * update comment for mergeSettings() commit 66fa310fbaf541a129b7b9bc5bc29b47eba3d43e Author: linoman <2051016+linoman@users.noreply.github.com> Date: Wed Mar 13 02:14:42 2024 -0600 SAMLSettings: implement settings strategy (#84191) * add strategy and tests * use settings provider service and remove multiple providers strategy * update codeowners file * reload from settings provider commit 9b2058f814b5f6e56598e57c5dd427f2edf033c4 Author: Domas Date: Wed Mar 13 09:23:51 2024 +0200 TracesPanel: Expose focusedSpanLink and createFocusSpanLink as options (#84060) expose focus span link via traces panel options commit e6c20e91bc9ddbcccd0e39339c264f6298842a9f Author: Alex Khomenko Date: Wed Mar 13 07:52:57 2024 +0100 UserInviteForm: Remove deprecated Form components (#84293) * UserInviteForm: Remove deprecated Form components * Use the Form rom core commit 608b40b2a8860c63f37fe5ac542b2a52b7109317 Author: Leon Sorokin Date: Tue Mar 12 17:55:23 2024 -0500 Transforms: Fix field matching in Format time, and add tpl var interpolation (#84239) commit 220fea966b0754596b418d9629561aae00ef0f80 Author: Leon Sorokin Date: Tue Mar 12 17:15:38 2024 -0500 CursorSync: Extract EventBus logic into shared plugin (#84111) commit 3f5526b9158c0d3345e13a0f192e21090676614f Author: Sarah Zinger Date: Tue Mar 12 16:30:54 2024 -0400 Cloudwatch: Fix issue with Grafana Assume Role (#84315) commit 0eee72824c76a4b667cb0c65c3d7b4e0cd6aacd0 Author: Charandas Date: Tue Mar 12 12:58:02 2024 -0700 K8s: omit CABundle until insecure is false (#84323) commit cfc39578945f75532a06a844aedcdae781ca4269 Author: Yuri Tseretyan Date: Tue Mar 12 15:38:21 2024 -0400 Alerting: move store.ErrAlertRuleGroupNotFound to models package (#84308) move ErrAlertRuleGroupNotFound to models to avoid future circular dependencies commit e552e21221d9e958b1f9ff6bb162fe764bd81d8d Author: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Tue Mar 12 15:37:50 2024 -0400 Docs: clarify query formatting for time range variable queries (#84074) * Added time range variable guidance * Reworded * Applied review suggestion commit 9da48a8a48b1d12e73dd04bf50716a221c24c0b8 Author: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Tue Mar 12 13:28:58 2024 -0400 Docs: add table visualization for logs entry to what's new (#84313) Added new entry commit 265200799d9f142622a99f3561e1864f84216ecd Author: Andres Martinez Gotor Date: Tue Mar 12 17:13:23 2024 +0100 Chore: Update grafana-plugin-sdk (#84289) commit e6150a792a21ff77c2660236a0b21401ecbcccbe Author: Gilles De Mey Date: Tue Mar 12 17:04:49 2024 +0100 Alerting: Always omit IDs from all routes when updating notification policy tree (#84304) commit e421ff3e2cee1d6853be98d3d8c18bb9dadaecd0 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Mar 12 15:17:10 2024 +0000 Update dependency webpack-dev-server to v5.0.3 commit 5c96b9085240ba1e4b2b734ed67efd81685e16b7 Author: Aaron Godin Date: Tue Mar 12 10:38:43 2024 -0500 docs: rewrite grafana-com oauth to better align with naming conventions (#84294) * docs: rewrite grafana-com oauth to better align with naming conventions * docs: update links used to grafana-cloud auth page commit 98fb8fad438ce739df3c4e1168ddd55eb0eba2da Author: Ezequiel Victorero Date: Tue Mar 12 12:22:10 2024 -0300 Docs: Update external snapshots cloud docs (#84255) * Docs: Update external snapshots cloud docs * Update docs/sources/dashboards/share-dashboards-panels/index.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> --------- Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> commit 46a35775ae49dc03b86dfc6bb1b08bacad69dbd8 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Mar 12 17:13:14 2024 +0200 Update dependency @types/lodash to v4.17.0 (#84299) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 7c1ad64bb27d59d1b5b37a03d93b8225250ddc17 Author: Dominik Prokop Date: Tue Mar 12 16:12:00 2024 +0100 DashboardScene: Make sure dashboard prompt is shown when navigating away way from an edited dashboard (#84077) DashboardScene: Make sure dashboard prompt is shown when navigating away from an edited dashboard Co-authored-by: Oscar Kilhed commit 22860a79781cf0da6de62fc94f25281625b82a73 Author: ismail simsek Date: Tue Mar 12 16:08:10 2024 +0100 Chore: Use mapUtil from grafana-plugin-sdk-go (#84297) use mapUtil from grafana-plugin-sdk-go commit 6080a9c09401385aed89a9eef2d04f4a937a1423 Author: Joao Silva <100691367+JoaoSilvaGrafana@users.noreply.github.com> Date: Tue Mar 12 15:00:51 2024 +0000 Cascader: Don't lose input value when typing (#84274) commit d63bc6d2e42c16a0e9023012c0daa1c8a31a7e34 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Mar 12 14:53:55 2024 +0000 Update dependency @playwright/test to v1.42.1 (#84292) * Update dependency @playwright/test to v1.42.1 * update playwright image --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit fb172b1b335426ac80695c68aefd0c63795fe75c Author: Matias Chomicki Date: Tue Mar 12 15:53:13 2024 +0100 Explore logs: remove exploreScrollableLogsContainer and track scroll to top (#84291) * exploreScrollableLogsContainer: remove * LogsNavigation: track scroll to top clicks * Add missing dependency to the effect commit f15aa2d30dabbe28a23993b9b050e80575e9d80d Author: Leonard Gram Date: Tue Mar 12 15:44:10 2024 +0100 CloudMigrations: draft of cloud migration tables (#83990) * CloudMigrations: draft of cloud migration tables * naming migration rules * removes feature flag for migration * tweaked db types * removed comment commit 90ff576cb7a78fc5b72b54a23469020915fe07b5 Author: Marie Cruz Date: Tue Mar 12 14:23:45 2024 +0000 docs: slight update to grafana fundamentals (#84287) commit d69b19e431bfe31ff904a48826593e6fa79b7a5b Author: Michael Mandrus <41969079+mmandrus@users.noreply.github.com> Date: Tue Mar 12 10:17:22 2024 -0400 Queries: Improve debug logging of metrics queries (#84048) * just log everything * fix times commit 42d8f1bef9ad7e08ab6760d504ac5203da310a1c Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Tue Mar 12 14:10:13 2024 +0000 Tempo: Update TraceQLStreaming feature toggle stage (#84203) Update TraceQLStreaming feature toggle stage commit bce6a0a987979671fdafbc30e82d7f132dd9b8cc Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Mar 12 14:03:20 2024 +0000 Update dependency @grafana/scenes to v3.12.0 (#84280) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 10dc6c6d75d95f76e49c21329968161519cc0c88 Author: William Wernert Date: Tue Mar 12 10:00:43 2024 -0400 Alerting: Add "Keep Last State" backend functionality (#83940) * Implement keep last state for state transitions * Respect For duration when keeping state * Only keep transition from recording an annotation * Add keep last state option for nodata/error in UI commit fe1ed0a9e1d68043e2cf3380e4381f700484d0fa Author: Sergey Kostrukov Date: Tue Mar 12 06:49:30 2024 -0700 Azure: Add list of clouds in frontend AzureSettings (#84039) Add list of supported clouds in AzureSettings commit ae70cd953d2b372e9a466b5d3e23135a258b998c Author: ismail simsek Date: Tue Mar 12 14:25:32 2024 +0100 Chore: Use the promlib v0.0.1 (#84210) * use the promlib v0.0.1 * add a readme * go mod update * replace promlib * update readme * update * update go work sum * update 2 * update readme commit 0cb9f2bb8d7602547a4f4c1e0350612cfaf3afcf Author: Eric Leijonmarck Date: Tue Mar 12 14:22:13 2024 +0100 Users: Add back check for undefined / null for value for `lastSeenAtAge` in table view (#84265) bug: add check for undefined / null for value commit 6eaab9e57d0f919c7221d68086aef83f2c43cc68 Author: Josh Hunt Date: Tue Mar 12 13:07:23 2024 +0000 E2C: Use ConfirmModal for DisconnectModal (#84279) change disconnect modal to use confirmmodal commit ff62474f9b1757e99b6843593dacc287a113e453 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Mar 12 13:03:21 2024 +0000 Update dependency @grafana/faro-web-sdk to v1.4.2 (#84278) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit b8d4d28f4a7b5068d162f9f2e6908d29adec5a19 Author: Dominik Prokop Date: Tue Mar 12 13:54:55 2024 +0100 DashboardScene: Fix Get Help panel menu for lib panels (#84272) commit 39b682e333fab546d8c4c6fb6fb87d32584e965a Author: Dominik Prokop Date: Tue Mar 12 13:54:47 2024 +0100 DashboardScene: Fix dashboard being restored to initial state after successful save (#84183) * Failing test * Do not restore initial state after edit mode exit when dashboard is not dirty commit 2a7785c262890b6c03d819971dc58e1f5d894e43 Author: Erik Sundell Date: Tue Mar 12 13:48:32 2024 +0100 CI: Allow failed Playwright tests to fail entire build (#84244) * bump plugin-e2e and fix failing test * do not ignore failures * generate trace on every test * force test to fail for debugging purposes * fix broken test * regenerate drone file commit 7348d9cd4746e9006b6790ea51c880ce629843c8 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Mar 12 12:42:24 2024 +0000 Update dependency @grafana/faro-web-sdk to v1.4.2 (#84270) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 297d73a7dffd7a5f36d84f843ab6ba3406da2e64 Author: Josh Michielsen Date: Tue Mar 12 12:36:11 2024 +0000 InfluxDB: Add configuration option for enabling insecure gRPC connections (#83834) * InfluxDB: add configuration option for enabling insecure gRPC connections * fix: add insecureGrpc to InfluxOptions * rename options label 'gRPC' -> 'Connection' Co-authored-by: ismail simsek * update docs: rename options label 'gRPC' -> 'Connection' Co-authored-by: ismail simsek * default insecure connection boolean to false in frontend Co-authored-by: ismail simsek * run prettier:write --------- Co-authored-by: ismail simsek commit 388e0c27f20f52b4aef4d911cfedc466494ec84c Author: Gilles De Mey Date: Tue Mar 12 13:28:37 2024 +0100 Alerting: Allow inserting before or after existing policy (#83704) commit 78478dc235ae836f5463718df4a530b36df5dc74 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Mar 12 14:09:47 2024 +0200 Update dependency @grafana/faro-core to v1.4.2 (#84267) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 2fe9376d126daccdfa66c4542e22ef9ff97f27c6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Mar 12 11:32:59 2024 +0000 Update dependency ol-ext to v4.0.17 commit 4fc4a1c4a89471b811f23ade3126cd8770be8af7 Author: Josh Hunt Date: Tue Mar 12 11:48:18 2024 +0000 Playlists: Fix kiosk mode not activating when starting a playlist (#84262) * Playlists: Fix Kiosk mode not activating when starting a playlist * oops remove debugger commit a883df633e629e1de2b66a8e048086dd442418a1 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Mar 12 09:34:52 2024 +0000 Update dependency @types/react to v18.2.65 commit fa6e4bbcd060d619bd885a5fab610ab562da2281 Author: Victor Gorchilov Date: Tue Mar 12 13:29:02 2024 +0200 Users: Resurrect org picker's search functionality (#78886) [up] return search for org picker commit fbfaf8e003e6f07de698387dca8ce0d509e2c4dc Author: Eric Leijonmarck Date: Tue Mar 12 12:18:33 2024 +0100 OrgUsers: Refactor change `LastSeenAtAge` from '10 years' to 'Never' (#84247) * refactor: change lastseenatage to Never * removed unncessecary fragments commit c45f96e9de603a455c98ecc9a5c04d57bc77cb7e Author: ismail simsek Date: Tue Mar 12 12:07:07 2024 +0100 Chore: Fix feature toggle docs (#84256) update docs commit 59fed9278b0b3c5847ae16dcaff1484cefb6a8b9 Author: Alex Khomenko Date: Tue Mar 12 11:51:58 2024 +0100 Switch: Remove "transparent" prop (#83705) * Switch: Remove transparent prop * Cleanup * Remove redundant prop commit 22d8258e48a870cdccbb53542ecf7d0d511b68c8 Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Tue Mar 12 12:03:24 2024 +0200 Postgres: Allow disabling SNI on SSL-enabled connections (#83892) * Postgres: Allow disabling SNI on SSL-enabled connections * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> --------- Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> commit c2b94429e44db4e785210176f32bba85c08a994a Author: Adam Yeats <16296989+adamyeats@users.noreply.github.com> Date: Tue Mar 12 10:38:16 2024 +0100 Google Cloud Monitor: Fix `res` being accessed after it becomes `nil` in `promql_query.go` (#84223) Fix res being accessed after it becomes nil in promql_query.go commit 3085d53802a99b69c58919de819de75b0a07606f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Mar 12 08:48:30 2024 +0000 Update dependency @types/node to v20.11.26 commit 6e661fa77040183d01e1fddd30c28272bea6fbc5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Mar 12 08:46:11 2024 +0000 Update babel monorepo to v7.24.0 (#84206) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 63f1c3031317fd896926901805fa5b85cf9ef35b Author: Misi Date: Tue Mar 12 09:35:13 2024 +0100 Auth: Set the default org after User login (#83918) * poc * add logger, skip hook when user is not assigned to default org * Add tests, move to hook folder * docs * Skip for OrgId < 1 * Address feedback * Update docs/sources/setup-grafana/configure-grafana/_index.md * lint * Move the hook to org_sync.go * Update pkg/services/authn/authnimpl/sync/org_sync.go * Handle the case when GetUserOrgList returns error --------- Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> Co-authored-by: Karl Persson commit 8c06c0dea7283bc132779fc3ccfde58109110be1 Author: Erik Sundell Date: Tue Mar 12 09:17:41 2024 +0100 Panel edit: Add e2e selectors to input fields (#83246) add selectors for input fields commit 6ea9f0c447c82a60072db86a80de35779618f8f1 Author: Karl Persson Date: Tue Mar 12 09:15:14 2024 +0100 AuthN: Use fetch user sync hook for render keys connected to a user (#84080) * Use fetch user sync hook for render keys connected to a user commit f50624d2574adc485c4130800402c5e7ef1f73ac Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Tue Mar 12 10:11:15 2024 +0200 Scenes: Duplicate library panels (#84159) duplicate library panels commit 298384cea9c041446f3130836c7fdaf8ee82a022 Author: linoman <2051016+linoman@users.noreply.github.com> Date: Tue Mar 12 01:31:52 2024 -0600 Disable skip button when strong password feature is enabled (#84211) commit e33e219a9a1804b99bf05139701c3d63eeb98bb4 Author: Armand Grillet <2117580+armandgrillet@users.noreply.github.com> Date: Tue Mar 12 05:37:41 2024 +0100 Remove legacy alerting docs (#84190) commit da327ce8073ae6f66aa020eda621de1a6495e6be Author: Charandas Date: Mon Mar 11 16:13:14 2024 -0700 K8s: enable insecure to skip server cert validation for now (#84038) commit 6c5e94095dd4783df0a9475337869de226faf3c1 Author: Alexander Weaver Date: Mon Mar 11 15:57:38 2024 -0500 Alerting: Scheduler and registry handle rules by an interface (#84044) * export Evaluation * Export Evaluation * Export RuleVersionAndPauseStatus * export Eval, create interface * Export update and add to interface * Export Stop and Run and add to interface * Registry and scheduler use rule by interface and not concrete type * Update factory to use interface, update tests to work over public API rather than writing to channels directly * Rename map in registry * Rename getOrCreateInfo to not reference a specific implementation * Genericize alertRuleInfoRegistry into ruleRegistry * Rename alertRuleInfo to alertRule * Comments on interface * Update pkg/services/ngalert/schedule/schedule.go Co-authored-by: Jean-Philippe Quéméner --------- Co-authored-by: Jean-Philippe Quéméner commit 0b2640e9ff150fb39587e2a167e1ed2efa3804d7 Author: Oscar Kilhed Date: Mon Mar 11 20:48:27 2024 +0100 Dashboard scenes: Editing library panels. (#83223) * wip * Refactor find panel by key * clean up lint, make isLoading optional * change library panel so that the dashboard key is attached to the panel instead of the library panel * do not reload everything when the library panel is already loaded * Progress on library panel options in options pane * We can skip building the edit scene until we have the library panel loaded * undo changes to findLibraryPanelbyKey, changes not necessary when the panel has the findable id instead of the library panel * fix undo * make sure the save model gets the id from the panel and not the library panel * remove non necessary links and data providers from dummy loading panel * change library panel so that the dashboard key is attached to the panel instead of the library panel * make sure the save model gets the id from the panel and not the library panel * do not reload everything when the library panel is already loaded * Fix merge issue * Clean up * lint cleanup * wip saving * working save * use title from panel model * move library panel api functions * fix issue from merge * Add confirm save modal. Update library panel to response from save request. Add library panel information box to panel options * Better naming * Remove library panel from viz panel state, use sourcePanel.parent instead. Fix edited by time formatting * Add tests for editing library panels * implement changed from review feedback * minor refactor from feedback commit efbcd53119efffd6e9509845f1d54b5369fcac1e Author: David Harris Date: Mon Mar 11 19:17:20 2024 +0000 docs: update angular deprecation notice (#84227) update docs commit 1ffd1cc8f42f432a70a898f59826d78efb99bed8 Author: Dan Cech Date: Mon Mar 11 13:59:54 2024 -0400 Storage: Support listing deleted entities (#84043) * support listing deleted entities * fold listDeleted into List commit ffd0bdafe4848ce043460aa9e3b1da381dde6550 Author: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Mon Mar 11 12:57:51 2024 -0400 Docs: fix broken link (#84103) * Fixed broken link * Removed trailing slash * Ran prettier commit e2cc5e57e59530cf0d7e0f8a978bc434c728b3a4 Author: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Mon Mar 11 12:57:02 2024 -0400 Docs: add missing alt text (#84102) Added missing alt text commit 5ae9cd561c4e241c936d7b4784a49bc04ed9db98 Author: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Mon Mar 11 17:43:55 2024 +0100 Accessibility: Improve HelpModal markup (#83171) * HelpModal: Make more accessible * Remove console.log * Handle custom keys for screen reader * Rewrite using tables * Increase gap * Change order of help categories * HelpModal: Add tabIndex and imrpove sr-only display commit 3449a43ff2aff7dc1e4bf1928de87d496280e92f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Mar 11 16:07:52 2024 +0000 Update react monorepo commit 3fb6319d1b8cae1d7e359572673117cb8268df63 Author: ismail simsek Date: Mon Mar 11 17:22:33 2024 +0100 Prometheus: Introduce prometheus backend library (#83952) * Move files to prometheus-library * refactor core prometheus to use prometheus-library * modify client transport options * mock * have a type * import aliases * rename * call the right method * remove unrelated test from the library * update codeowners * go work sync * update go.work.sum * make swagger-clean && make openapi3-gen * add promlib to makefile * remove clilogger * Export the function * update unit test * add prometheus_test.go * fix mock type * use mapUtil from grafana-plugin-sdk-go commit cfc7ea92daf4b05c2ba84c5a81fb256938271b08 Author: Usman Ahmad Date: Mon Mar 11 17:21:37 2024 +0100 corrected the minor details (#84046) * corrected the minor details Making minor changes after the PR merged on Data sources and Data source administration. https://github.com/grafana/grafana/pull/83712 * Apply suggestions from code review Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Update docs/sources/panels-visualizations/_index.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Ran prettier --------- Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Co-authored-by: Isabel Matwawana commit b6c550c52427cfa43e707af93026f8cf4d4db0c8 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Mar 11 15:32:32 2024 +0000 Update dependency xss to v1.0.15 commit 3bb38d82abea58b9cb9148034ffe18561cf9421f Author: ismail simsek Date: Mon Mar 11 16:49:53 2024 +0100 Chore: Bump grafana-plugin-sdk-go version to v0.214.0 (#84162) * bump grafana-plugin-sdk-go version to v0.214.0 * make swagger-clean && make openapi3-gen commit 7d1ea1c655f166dc99510f9b47dd585f19f9c419 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Mar 11 15:09:29 2024 +0000 Update dependency rudder-sdk-js to v2.48.3 commit 225ac8003c44ab9183a43009829a3e0024516ad1 Author: Will Browne Date: Mon Mar 11 16:28:46 2024 +0100 Plugins: Tidy config struct (#84168) * tidy plugins config usage * fix tests commit 0c6b0188c8fea194310bc78ffa3230f6ae4edbe3 Author: Giordano Ricci Date: Mon Mar 11 15:17:07 2024 +0000 Explore: Remove deprecated `query` option from `splitOpen` (#83973) * Chore: remove deplrecated queries option from splitOpen * make queries option required * use left pane queries when splitting an existing pane commit 9c292d2c3f771c24e393ae08dcd5a26b5405f7c0 Author: Karl Persson Date: Mon Mar 11 15:56:53 2024 +0100 AuthN: Use sync hook to fetch service account (#84078) * Use sync hook to fetch service account commit d8b8a2c2b0c4e56739ae4d77f70b014d7e2fb690 Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Mon Mar 11 14:53:59 2024 +0000 Dashboard: Fix issue where out-of-view shared query panels caused blank dependent panels (#83966) commit dd01743de7d886f09153686744f0bbb936d3b339 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Mar 11 14:35:15 2024 +0000 Update dependency react-virtualized-auto-sizer to v1.0.24 commit b0848c9726df3c0ac085846be2e50bddd4b2fb60 Author: Ashley Harrison Date: Mon Mar 11 14:46:53 2024 +0000 Empty state: Add animation for Grot (#83770) * add animation for grot not found * extract default interval out into constant * improve relative path * extract magic numbers out into constants + throttle properly * better width/height definitions * use consistent background + apply to PageNotFound as well * increase gap in command palette empty state commit d4b43640f325bd6e624e23a735863d81a7f95acc Author: Jack Westbrook Date: Mon Mar 11 15:33:37 2024 +0100 Revert "Update dependency @swc/core to v1.4.6" (#84186) * Revert "Update dependency @swc/core to v1.4.6" This reverts commit 4d7220dbdf751a3dca5e72048233f1aeac324f3e. * chore(renovate): ignore swc/core for time being due to bugs in versions ~1.4.5 commit ad280db89e4eb03ab80e3971565a848d4fdfe86d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Mar 11 14:12:22 2024 +0000 Update dependency postcss-loader to v8.1.1 commit 108202f96cfc1ab393f26c1802dd6a64b4d0b408 Author: Jo Date: Mon Mar 11 15:18:42 2024 +0100 UsageStats: Separate context and threads for usage stats (#83963) * separate context and threads for usage stats * use constants * ignore original context * fix runMetricsFunc * fix collector registration Co-authored-by: Gabriel MABILLE * change background ctx * fix test randomness * Add traces to support bundle collector * Remove unecessay span * Add trace to usagestats api * Close spans * Mv trace to bundle * Change span name * use parent context * fix runtime declare of stats * Fix pointer dereference problem on usage stat func Co-authored-by: Karl Persson Co-authored-by: jguer * fix broken support bundle tests by tracer --------- Co-authored-by: Gabriel MABILLE Co-authored-by: gamab Co-authored-by: Karl Persson commit 5653518e1a2ccd6371158f70adbb7a41a0142181 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Mar 11 13:52:19 2024 +0000 Update dependency ol-ext to v4.0.16 commit 0b55d72fb5698e1ea2cf73eaceae166cd5619daa Author: Karl Persson Date: Mon Mar 11 15:09:44 2024 +0100 FeatureToggles: Add feature toggle for sso email verification (#84184) * FeatureToggles: Add feature toggle for sso email verification * Rename toggle * Fix json commit 876030f7a649be91124e73a3faed8ab1e426278c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Mar 11 13:09:16 2024 +0000 Update dependency msw to v2.2.3 commit c21278397b93eb192ef38e21cd13396be9b5bf36 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon Mar 11 15:17:13 2024 +0200 I18n: Download translations from Crowdin (#84051) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> commit 4272483c54a55d807788c163963b70071343eba4 Author: Karl Persson Date: Mon Mar 11 14:10:03 2024 +0100 Auth: Only call rotate token if we have a session expiry cookie (#84169) Only call rotate token if we have a session expiry cookie commit 0913324668fcab7eac0bc75d6de52704fb647ec0 Author: carrychair <137268757+carrychair@users.noreply.github.com> Date: Mon Mar 11 20:55:18 2024 +0800 Chore: Remove repetitive words (#84132) remove repetitive words Signed-off-by: carrychair commit 2d0fc3ef33934a5569e3a1c23db100e5b6d3ef4e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Mar 11 12:27:09 2024 +0000 Update dependency mini-css-extract-plugin to v2.8.1 commit 8048e1360da23b31c90ca51ff0ec6f09fc2b668e Author: Torkel Ödegaard Date: Mon Mar 11 13:33:48 2024 +0100 Dashboards: Update scenes lib to 3.10 (#84075) * Dashboards: Update scenes lib to 3.10 * Update commit 4a81a0388b8abf4de5c405fd17062b9199c1ebdd Author: Ivan Ortega Alba Date: Mon Mar 11 13:33:32 2024 +0100 Playlist: run on Scenes (#83551) * DashboardScene: Implement playlist controls * Mock the runtime config properly * PlaylistSrv: with state you can subscribe to (#83828) --------- Co-authored-by: Torkel Ödegaard commit 1ab8857e48ea47576c68647a1dc580e78619118e Author: Josh Hunt Date: Mon Mar 11 12:29:44 2024 +0000 E2C: Add cloud migration is_target server config option (#83419) commit fa888af2128b004558b00b204fc72d298f69b0b8 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Mar 11 12:02:58 2024 +0000 Update dependency marked to v12.0.1 commit c950b716ffdcb5ab5f73464dc16d6bed056006e4 Author: Giordano Ricci Date: Mon Mar 11 12:10:06 2024 +0000 Chore: Remove deprecated ExploreQueryFieldProps (#83972) commit 937390b91c4b30a7fa2a836ce1a5bcb8096f56f1 Author: Giordano Ricci Date: Mon Mar 11 12:09:43 2024 +0000 Chore: Remove deprecated exploreId from QueryEditorProps (#83971) commit 01c8b7b6119081fca5cf9814e08fb55b45a5a196 Author: ldomesjo <49636413+ldomesjo@users.noreply.github.com> Date: Mon Mar 11 12:55:21 2024 +0100 Documentation: Updated yaml for influxdb data sources (#84119) * Update _index.md Updated instructions for influx v2 * Added version:SQL to influx v3 example in _index.md commit 275ccf994b0bab9ec6ad817b2fe9f84bc792ac1f Author: Selene Date: Mon Mar 11 12:51:44 2024 +0100 Schemas: Reduce duplicated jenny code (#84061) * Remove jenny_ts_resources and use jenny_ts_types for all cases * Unify TS generated files into one jenny * Add missing imports to versioned files * Update Parca plugin * Fix loki * Use LokiQuery * Fix pyroscope tests * Fix prettier * :unamused: fix default pyroscope value name * Set the LokiQuery * Update Elasticsearch and TestData * Missed files from testdata * Order imports commit bd9f9c9eb0c1540d4d3320a02a0f6db9c4640143 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Mar 11 11:30:30 2024 +0000 Update dependency immer to v10.0.4 commit 5a123bda809dba8eb80cf93c093c63668765d350 Author: Leonard Gram Date: Mon Mar 11 12:40:26 2024 +0100 CloudMigration: wires the service (#84081) commit ffe5c97b2d6af939a984d1084d0cad642f8d3361 Author: Alex Khomenko Date: Mon Mar 11 12:36:36 2024 +0100 Chore: Remove InputControl usage from explore (#83742) * Explore: Remove deprecated Form components from CorrelationTransformationAddModal.tsx * Explore: Remove deprecated Form components from AddToDashboardForm.tsx commit 4fc5c0dc215e6e95dfdc5ee47c361a9471a7bb12 Author: Alex Khomenko Date: Mon Mar 11 12:36:24 2024 +0100 Chore: Remove Form components from TransformationsEditor (#83743) Chore: remove Form components from correlations commit 0280fac0e311d6fcd32e6f17927623095ada61ae Author: Ashley Harrison Date: Mon Mar 11 11:35:59 2024 +0000 DatePickerWithInput: Set zIndex for popover correctly (#84154) set zIndex for popover correctly commit 8206a230617b1376e7b525070be8db05959174ff Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Mon Mar 11 11:27:12 2024 +0000 Scenes/Repeats: Show reduced panel menu for repeat panels (#84085) commit b2b825db69571812a68ab5f9c1de7498261a7d33 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Mar 11 11:02:10 2024 +0000 Update dependency centrifuge to v5.0.2 commit 0a12377a19bb7bb7acf29307f7da447eb0b0726e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Mar 11 10:41:39 2024 +0000 Update dependency autoprefixer to v10.4.18 commit 940d20e115fa442d8f92a30535907a05ca27e71a Author: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Mon Mar 11 11:43:22 2024 +0100 Accessibility: Improve landmark markup (#83576) * Landmark: main * Landmark: add header * Submenu: Move conditional display up * NewsPanel: use h3 as the article label * Use title for article id * Update test showing a false positive * DashboardPage: Expect submenu to not be shown commit 534855d086a6d64c40682a128d9ec1d74a3f932e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Mar 11 10:22:16 2024 +0000 Update dependency @types/node to v20.11.25 commit 9c22a6144e323340cc3ce14acc826953dd113fd0 Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Mon Mar 11 12:34:40 2024 +0200 Scenes: Row controls (#83607) * wip row controls * wip row repeats * wip * wip * row repeat functional * refactor * refactor to reuse RepeatRowSelect2 * refactor + tests * remove comment * refactor commit 87d6bebb9ef27229321dd202498256504642718b Author: Oscar Kilhed Date: Mon Mar 11 11:33:33 2024 +0100 Dashboard scenes: Remove panel menu options that are dashboard editing activities when not in edit mode. (#84156) * remove panel menu options that are dasbhoard editing activities when the dashboard is not in edit mode * remove corresponding keybindings when not in edit mode * add keyboard shortcuts but inactivate them when not in edit mode * Add tests; fix tests commit e8ecbaffc218150a4987ed413e3029f0ef96a13e Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Mon Mar 11 10:26:59 2024 +0000 UX: Update trace to logs tooltip to improve clarity (#83508) * Update tooltip to improve clarity * Update packages/grafana-o11y-ds-frontend/src/TraceToLogs/TraceToLogsSettings.tsx Co-authored-by: Sven Grossmann --------- Co-authored-by: Sven Grossmann commit c5080ca135a186eac7502787f8547b57b8acdbfb Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Mar 11 09:16:39 2024 +0000 Update dependency @types/eslint to v8.56.5 commit 15e358e3b9e59861dcd34f73ef2082a53f931c2b Author: Georges Chaudy Date: Mon Mar 11 10:23:03 2024 +0100 k8s: add support for configuring the gRPC server address (#84006) * k8s: add support for configuring the gRPC server address commit 4d7220dbdf751a3dca5e72048233f1aeac324f3e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Mar 11 08:56:44 2024 +0000 Update dependency @swc/core to v1.4.6 commit 07676ab8a040971bc2f054c52a74f3c4b8680f5d Author: Andreas Christou Date: Mon Mar 11 08:57:42 2024 +0000 Prometheus: Add missing Azure setting (#84094) commit ea84a66ff4d9de5b245087d528a8cd044b033a43 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Mar 8 13:38:12 2024 +0000 Update dependency @emotion/react to v11.11.4 commit 0e7c0d25fe7826d017fb68356d147b2188e24a05 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Mon Mar 11 08:47:56 2024 +0100 Alerting: Add test for creating an alert rule with simplified routing. (#80610) * Add test for creating an alert rule with simplified routing * Fix mocking folders after merging from main (folder uid change) commit 7a741a31bd6b9ef57e700ebd26555973fd41c5c0 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Mon Mar 11 08:47:22 2024 +0100 Alerting: Track when switching from simplified routing to policies routing or vice versa (#83108) Track when switching from simplified routing to policies routing or vice versa commit 57df3b84dce3f4a30f08005161db18fc7aacfbd0 Author: Leon Sorokin Date: Sun Mar 10 22:11:11 2024 -0500 StateTimeline: Treat second time field as state endings (#84130) commit 0b71354c8d22566191132c6bc6241a608077807a Author: Misi Date: Sat Mar 9 19:24:48 2024 +0100 Docs: Improve SSO Settings docs (#83914) * Improve docs * remove trailing slash * Update relref commit d82f3be6f7c3a9f4ea5522eea5dde09e151f41bd Author: Ryan McKinley Date: Fri Mar 8 08:12:59 2024 -0800 QueryService: Use types from sdk (#84029) commit f11b10a10cefcb12e6825f6ed9a20cf77d8dcc39 Author: Leon Sorokin Date: Thu Mar 7 18:10:56 2024 -0600 BarChart: Show tooltip options unconditionally (#84109) commit 21719a6b5bb8e82708d2b12459065c8283a61bfe Author: Yuri Tseretyan Date: Thu Mar 7 16:34:22 2024 -0500 Chore: Fix log message in access control (#84101) commit 7147af6b8e14d66653aa6dec59ab7a2965d9c1d0 Author: Yuri Tseretyan Date: Thu Mar 7 16:01:11 2024 -0500 Alerting: Disable legacy alerting for ever (#83651) * hard disable for legacy alerting * remove alerting section from configuration file * update documentation to not refer to deleted section * remove AlertingEnabled from usage in UA setting parsing commit 8c7090bc110be5b651d02894a050dd33f331a021 Author: Christopher Moyer <35463610+chri2547@users.noreply.github.com> Date: Thu Mar 7 12:53:10 2024 -0600 docs: adds alt text to images where missing (#84028) * adds alt text * makes prettier commit a15e48052f49cee0be0e8432d1ea3b4ce0310099 Author: Lisa <60980933+LisaHJung@users.noreply.github.com> Date: Thu Mar 7 11:42:16 2024 -0700 Embed two visualization videos from the Grafana for Beginners series (#83928) * Embed two visualization videos from Grafana for Beginners series * Implementing Isabel's recommendation on second video placement. * edited introductory sentence to the second video. * Added line between text and video --------- Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> commit 1da78ac8463dff31637b833ff4c8fda7bd75a88a Author: Dominik Prokop Date: Thu Mar 7 18:11:34 2024 +0100 DashboardScene: Allow unlinking a library panel (#83956) * DashboardScene: Allow unlinking a library panel * Betterer * Revert * Review commit f5dab6b5a5ce3c4d6e627b3aeef34171ab1295a1 Author: Gilles De Mey Date: Thu Mar 7 16:41:38 2024 +0100 Alerting: Refactor analytics to use pushMeasurements (#83850) commit 6fdcc6ff189160d13c5735e9dbb6295ca7739b16 Author: linoman <2051016+linoman@users.noreply.github.com> Date: Thu Mar 7 09:01:17 2024 -0600 Password Policy: Add validation labels to Update Password screen (#84052) * add validation labels to update the password screen * address rendering tests * update changePassword for profile screen commit 429ef9559cc35767b9b5e371d9e9786cae926aff Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Thu Mar 7 08:49:37 2024 -0600 FeatureToggles: Allow changing prod env safe feature toggles via URL (#84034) commit 0236053f708e70dd8a7b0040516e2b3b862b7f77 Author: Andreas Christou Date: Thu Mar 7 13:04:37 2024 +0000 Chore: Bump docker image versions (#84033) Bump docker image versions commit d1f8f7774d4dafcdabf881f5f4c913c217e7f1f0 Author: Xavi Lacasa <114113189+volcanonoodle@users.noreply.github.com> Date: Thu Mar 7 13:51:27 2024 +0100 Document `verification_email_max_lifetime_duration` config option (#84057) commit edd18644392ab8970994e81b63ab921ce526edb4 Author: Torkel Ödegaard Date: Thu Mar 7 12:33:30 2024 +0100 AngularMigrate: Auto migrate graph to multiple panels (#83992) * AngularMigrate: Auto migrate graph to multiple panels * add unit test, and histogram migration * add new cases to existing angular migration gdev dashboard * fix stat feature toggle handling so all panels dont turn into stat panels 😅; fix betterer * Use same function when clicking manual migrate button * Update --------- Co-authored-by: nmarrs commit 9c520acf9c6d7fe171515a53aa90eac0330de88e Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Thu Mar 7 12:33:16 2024 +0100 Alerting: Minor changes to UI help text and descriptions (#84023) commit a4acd9d204f3b9560bab2b344dd0e64be41de747 Author: Konrad Lalik Date: Thu Mar 7 12:30:37 2024 +0100 Alerting: Improve alert list panel and alert rules toolbar permissions handling (#83954) * Improve alert list panel and alert rules toolbar permissions handling * Refactor permission checking, add tests * Remove unneccessary act wrapper * Fix test error commit 1181141b401e8763485c89f415ec8f8563467843 Author: Selene Date: Thu Mar 7 11:09:19 2024 +0100 Schemas: Refactor plugin's metadata (#83696) * Remove kinds verification for kind-registry * Read plugin's json information with json library instead of use thema binding * Remove grafanaplugin unification * Don't use kindsys for extract the slot name * Fix IsGroup * Remove all plugindef generation * Refactor schema interfaces * Pushed this change from a different branch by mistake... * Create small plugin definition structure adding additional information for plugins registration * Add some validation checks * Delete unused code * Fix imports lint commit beea7d1c2bc42e90ba4fbe69d3d8b49bea10ff7b Author: Mihai Doarna Date: Thu Mar 7 12:08:58 2024 +0200 Auth: use the scope parameter instead of a hardcoded value in appendUniqueScope() (#84053) use the scope parameter instead of a hardcoded value commit b8d8662bd9406b98220c420e37050f1a7e70a666 Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Thu Mar 7 12:07:35 2024 +0200 Swagger: Re-generate the enterprise specification if enterprise is cloned (#81730) * Swagger: Re-generate the enterprise specification if enterprise is cloned successfully * API change to trigger the swagger CI step execution * Swagger: Silence logs commit 5a727a0b41b72c8d5c3ed3c5938a74558287cc9e Author: Javier Ruiz Date: Thu Mar 7 09:40:43 2024 +0100 [Service map] Send name and namespace separately when going to traces explore (#83840) Send name and namespace separately when going to traces explroe commit e3314f04e4813ae6a86e14a09c475e49a018840d Author: Mihai Doarna Date: Thu Mar 7 10:32:55 2024 +0200 Auth: perform read locking on every exported func from social providers (#83960) perform read locking on every exported func from social providers commit 20d201ca6a85a0f4d9d9143e6019778d03aada4b Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Thu Mar 7 10:24:12 2024 +0200 Scenes: Remove normal and library panels from layout or rows (#83969) * Remove normal/lib panels from layout or rows * refactor commit 8e827afb8c65055d412196d05806a565de646b96 Author: linoman <2051016+linoman@users.noreply.github.com> Date: Thu Mar 7 01:56:48 2024 -0600 Password Policy: Validate strong password upon update (#83959) * add drawer for auth settings * add StrongPasswordField component * Add style to different behaviours * update style for component * add componenet to ChangePasswordForm * pass the event handlers to the child component * add style for label container * expose strong password policy config option to front end * enforce password validation with config option commit 7bc8b27c33f00727458d955ded2d2d1747c5b1c2 Author: linoman <2051016+linoman@users.noreply.github.com> Date: Thu Mar 7 01:54:53 2024 -0600 Update README.md (#84011) commit dbb55f291a79647f06827e6a6f9f51cd51c01506 Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Thu Mar 7 08:42:38 2024 +0100 Alerting docs: update the supported export template functionality (#83816) commit a722b2608afcc5768c2afaa6532d52119fce9135 Author: Erik Sundell Date: Thu Mar 7 07:25:48 2024 +0100 TimeZonePicker: Add e2e selector to change time zone settings button (#83248) add selector to change time zone settings commit bc34874bbbc65cf1bb87fc635004775be5790c14 Author: Erik Sundell Date: Thu Mar 7 07:25:11 2024 +0100 Annotations: Add selector to new query button (#83240) add selector commit d549a3aabb1a0925a987ed24dd5e4bef9657f232 Author: Leon Sorokin Date: Wed Mar 6 19:30:33 2024 -0600 VizTooltips: Heatmap fixes and improvements (#83876) Co-authored-by: Adela Almasan commit 201f5d3ac9ba1e6984c19770965b63f972af62ac Author: Alexander Weaver Date: Wed Mar 6 16:39:23 2024 -0600 Alerting: Extract large closures in ruleRoutine (#84035) * extract notify * extract resetState * move evaluate metrics inside evaluate * split out evaluate commit 7a171fd14a77f050c2f149ac06a30a5551613e58 Author: Alexander Weaver Date: Wed Mar 6 16:08:45 2024 -0600 Regenerate openapidocs at 1.21.8 to match ci (#84037) * Regenerate openapidocs at 1.21.8 to match ci * Adjust trigger to work on the actual outputted files * Also put go.mod and go.sum in the triggers * manually fix * Make an arbitrary change rather than touching the trigger to force a run * Drop all triggers - run all the time * Print diff - taken from @papagian's PR * Manual fixes to swagger doc --------- Co-authored-by: Ryan McKinley commit 948c8c45d686877db25a79424343b423a9574b65 Author: gotjosh Date: Wed Mar 6 20:48:32 2024 +0000 Alerting: Use Alertmanager types extracted into grafana/alerting (#83824) * Alerting: Use Alertmanager types extracted into grafana/alerting We're in the process of exporting all Alertmanager types into grafana/alerting so that they can be imported in the Mimir Alertmanager, without a neeed to import Grafana directly. This change introduces type aliasing for all Alertmanager types based on their 1:1 copy that now live in grafana/alerting. Signed-off-by: gotjosh --------- Signed-off-by: gotjosh commit d5fda0614773d5ed12df53b939eb5e3027df2960 Author: Alexander Weaver Date: Wed Mar 6 13:44:53 2024 -0600 Alerting: Decouple rule routine from scheduler (#84018) * create rule factory for more complicated dep injection into rules * Rules get direct access to metrics, logs, traces utilities, use factory in tests * Use clock internal to rule * Use sender, statemanager, evalfactory directly * evalApplied and stopApplied * use schedulableAlertRules behind interface * loaded metrics reader * 3 relevant config options * Drop unused scheduler parameter * Rename ruleRoutine to run * Update READMED * Handle long parameter lists * remove dead branch commit 8b9bc9a9193c4743a7bbc22f2313aef8546f5356 Author: Leon Sorokin Date: Wed Mar 6 12:50:28 2024 -0600 Histogram: Fix 'combine' option & legend rendering (#84026) commit 61f6c4f84d5532bf2d3b2b8e48138f8131f9e9fa Author: Marcus Efraimsson Date: Wed Mar 6 18:51:16 2024 +0100 Chore: Upgrade grafana-plugin-sdk-go (#84010) Co-authored-by: Ryan McKinley commit 2142efc1a5fee5049a444b2b9a2e827f0137cd43 Author: Dai Nguyen <88277570+ej25a@users.noreply.github.com> Date: Wed Mar 6 11:33:36 2024 -0600 disable_sanitize_html update (#83643) * disable_sanitize_html update Added a note that states this configuration is not available for Grafana Cloud instances. * Update docs/sources/setup-grafana/configure-grafana/_index.md * Update docs/sources/setup-grafana/configure-grafana/_index.md --------- Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> commit e88858edb19b3187283ecabae13b9c45951d5736 Author: Leon Sorokin Date: Wed Mar 6 11:27:46 2024 -0600 Histogram: Fix heuristic for x axis distribution from bucket progression (#83975) commit 7f2e245d0bfc23756f24dd72adedcdeef5dbef8c Author: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Wed Mar 6 16:43:40 2024 +0000 Changelog: Updated changelog for 9.5.17 (#84015) Co-authored-by: grafanabot commit 920d50cb76ab48e8be687fab4e79bee536357c9a Author: Ivan Ortega Alba Date: Wed Mar 6 17:43:19 2024 +0100 Worker: Use CorsWorker to avoid CORS issues (#83976) commit 4bb5915183a8cf96199a24dd730b841c67cb0743 Author: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Wed Mar 6 11:37:07 2024 -0500 Docs: comment in SSO for terraform video (#83978) * Commented in SSO for terraform video * Updated youtube link commit 32480b49aa985e99c4c23fefcf53f66e4783f2bc Author: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Wed Mar 6 18:35:01 2024 +0200 Changelog: Updated changelog for 10.0.12 (#84008) Co-authored-by: grafanabot commit 6287e1f8ed34dd6660e27b4716c5cac72ee0666f Author: linoman <2051016+linoman@users.noreply.github.com> Date: Wed Mar 6 10:27:55 2024 -0600 Auth: Auth Drawer (#83910) * add drawer for auth settings * add auth drawer component * include AuthDrawer component in auth providers page * protect the feature as enterprise only * add unit test commit 6731aacea904f03280dcfff59bc2a840afd2d94e Author: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Wed Mar 6 18:21:58 2024 +0200 Changelog: Updated changelog for 10.1.8 (#84005) Co-authored-by: grafanabot commit 1e6f14c103003e2b0568f339e115604d9f3acca9 Author: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Wed Mar 6 17:55:47 2024 +0200 Changelog: Updated changelog for 10.2.5 (#84001) Co-authored-by: grafanabot commit ce827f951815c941609a89b03c02e30eb748df23 Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Wed Mar 6 16:46:28 2024 +0100 Configure Grafana docs: fix custom configuration file location (#83169) * Configure Grafana docs: fix custom configuration file location * Replace config file with `custom.ini` --------- Co-authored-by: Jack Baldry commit d3207df8b4fb25cb0616f4153bb6d3d5c7ad8859 Author: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Wed Mar 6 15:39:30 2024 +0000 Changelog: Updated changelog for 10.3.4 (#83993) Co-authored-by: grafanabot commit 146ad85a564d21ff40269f0a6325ab938da61397 Author: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Wed Mar 6 15:14:51 2024 +0000 Changelog: Updated changelog for 10.4.0 (#83987) Co-authored-by: grafanabot commit 6a4e0c692ab26f4d4cb99ae615013e0b6e23f90b Author: Josh Hunt Date: Wed Mar 6 15:06:47 2024 +0000 Page: Use browser native scrollbars for the main page content (#82919) * remove custom scroll bars from Page component * make flagged scroller the actual scrolling element, * enable feature flag by default * re-enable the scroll props in Page * rename feature toggle * fix css * only update when deleted * set .scrollbar-view on our scrolling wrapper --------- Co-authored-by: Ryan McKinley commit 0929bf7c00dd7b7a4c17d014455b36ad02f697a0 Author: Kyle Brandt Date: Wed Mar 6 09:57:01 2024 -0500 Prometheus: Remove < and > from Query Builder Label Matcher operations (#83981) They are not valid promql label matcher operations commit 544bff2539f6c37f1a8dabb13bcebea96f68ecee Author: Usman Ahmad Date: Wed Mar 6 15:50:49 2024 +0100 Docs/datasources usman (#83712) * changed tags from oss to enterprise and cloud * added Dashboard Panel example * swapped the all-grafana-umbrella information to the correct page * added minor visibility improvements in steps * made some minor adjustments * added minor improvements * fixed a link * updates links * Apply suggestions from code review thanks for the improved suggestions. looks more better Co-authored-by: Jack Baldry * fixed links * fixed Grafana Enterprise link * run prettier * fixed add a data source links --------- Co-authored-by: Chris Moyer Co-authored-by: Jack Baldry commit 7527d30ec91a3daa80e75c3a139444abb9c9bf08 Author: Tom Ratcliffe Date: Wed Mar 6 14:44:27 2024 +0000 Documentation: Fix link to .nvmrc file in developer guide (#83911) Documentation: Fix link to .nvmrc file commit 1dc6014b105690c8dbff3b4236e2c035d35541c7 Author: Leon Sorokin Date: Wed Mar 6 08:31:54 2024 -0600 Dashboard: Revert descending z-index changes (#83466) Co-authored-by: Torkel Ödegaard commit 316601258a7d7a614f30f1c4293a91090ce32bf1 Author: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Wed Mar 6 09:06:23 2024 -0500 Docs: comment youtube videos back in (#83974) * Commented 4 youtube links back in * Fixed id commit 183aa09eeb4bb9a4134130419cd18add4e38a94e Author: Josh Hunt Date: Wed Mar 6 13:57:11 2024 +0000 Dashboards: Fix scroll position not being restored when leaving panel edit (#83787) * Dashboards: Fix scroll position not being restored when leaving panel edit view * remove mock from tests * remove console log * Remove my debugging stuff, and don't render grid if width is 0 * remove old comment (but retain old, probably unneeded css) * rename ref * fix it not actually working anymore!!! * add e2e tests * jsonnet, i guess commit 6db7eafd7e12fbe91ab6e7126baba1b2913ab496 Author: Giordano Ricci Date: Wed Mar 6 13:00:56 2024 +0000 Explore: Remove plus icon from Add button (#83587) commit d269b4bf0dd74613e1c5ea0b783c90c0f91df2c9 Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Wed Mar 6 14:47:18 2024 +0200 Scenes: Copy/paste library panels (#83962) * Scenes: Copy/paste library panels * more tests commit 2c5b72e8446b045ec8242c2ea8b1690a0b295ff0 Author: Ieva Date: Wed Mar 6 12:40:48 2024 +0000 AuthZ: add headers for IP range AC checks for data source proxy requests (#81662) * add a middleware that appens headers for IP range AC to data source proxy requests * update code * add tests * fix a mistake * add logging * refactor to reuse code * small cleanup * skip the plugins middleware if the header is already set * skip the plugins middleware if the header is already set commit 401265522e584e4e71a1d92d5af311564b1ec33e Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Wed Mar 6 12:32:28 2024 +0100 Logs volume: Add options to specify field to group by (#83823) Logs volume: Add options to specify field to group by in options commit 5950dc3279a6fdeab7d995b12fb5bd56271887a4 Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Wed Mar 6 12:24:02 2024 +0100 Alerting docs: adds top-level landing page tiles (#83825) * Alerting docs: adds top-level landing page tiles * updated description * update frontmatter * ran prettier * Update docs/sources/alerting/_index.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/_index.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/_index.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/_index.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/_index.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/_index.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/_index.md Co-authored-by: Jack Baldry --------- Co-authored-by: Jack Baldry commit 708aeb0682a7a7c74ec2d4ab6648880e0946bd27 Author: Konrad Lalik Date: Wed Mar 6 11:42:28 2024 +0100 Alerting: Pass queryType parameter to the query model in recording rules preview (#83950) commit d767c4f6946b95516b6d70e59987634cab288d65 Author: Jennifer Villa Date: Wed Mar 6 03:33:09 2024 -0600 Remove hanging text block from traces->profiles docs (#83859) Remove hanging text block This is causing the page to render a bit oddly on the website commit e611a736ed8a57e45c737fe6efdfffbe76084cb1 Author: Eric Leijonmarck Date: Wed Mar 6 09:53:58 2024 +0100 Serviceaccounts: Add ability to add samename SA for different orgs (#83893) * add ability to add samename SA for different orgs * Update pkg/services/user/userimpl/user.go * fix tests * refactor name * removed tests * add migration * fix linting commit 2653bd8fabb4b7147f1e90b1f10acca5b8dfe279 Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Wed Mar 6 09:18:59 2024 +0100 Alerting docs: update file provisioning guide (#83924) commit 2c09d863950d56be35e6046673aeb75a80cc67c8 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Wed Mar 6 08:45:07 2024 +0100 Tempo: Fix by operator to support multiple arguments (#83947) commit de563aa39cb90501f911d8a23a6ed9a6265e3c48 Author: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Wed Mar 6 02:14:31 2024 -0500 Docs: fix commented out Slack team names (#83946) commit db13c0839fa6061b6598e8d6749256127577e4d4 Author: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Tue Mar 5 22:02:19 2024 -0500 Docs: What’s new & Upgrade guide 10.4 (#83133) * Updated index pages and added v10.4 breaking changes, upgrade guide, and what's new pages * Added what's new entries * Removed empty headings * Fixed link * Removed duplicate entry and fixed styling * Removed breaking changes page and references to it * Added intro text * Docs: 10.4 technical note for alertingUpgradeDryrunOnStart (#83262) * Docs: 10.4 technical note for alertingUpgradeDryrunOnStart * Apply suggestions from code review Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> * Address PR comments * restarts -> starts --------- Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> * Fixed spelling * Added new entries to What's new * reorder and add intro * Added PagerDuty data source entry * Added entries * Added new entries * Fixed formatting * Fixed page weight and links * Apply suggestion from review Co-authored-by: Mitch Seaman * remove team lbac, move return to previous (#83921) - Remove Team LBAC for Loki (it is Cloud only) - Move Return to Previous into the Alerting section, since it only works for Alerting now - Add a note that data sources are separate from Grafana but included for visibility * Replaced manual note with admonition shortcode * move Return to Previous out of Alerting section * Added youtube links * Commented out youtube videos and removed duplicate video embed --------- Co-authored-by: Matthew Jacobson Co-authored-by: Mitchel Seaman Co-authored-by: Mitch Seaman commit a4b31ed14037ab159707d5e0250c9313f28f2908 Author: Leon Sorokin Date: Tue Mar 5 19:03:50 2024 -0600 BarChart: Improve x=time tick formatting (#83853) commit 5c27d28ba43e87ec12b8538be229fc574d67324a Author: Nathan Marrs Date: Tue Mar 5 16:25:12 2024 -0700 Canvas: Add datalink support to rectangle and ellipse elements (#83870) commit 38a0eab137b7b1a38bc545a9732f03684dc92fd2 Author: Nathan Marrs Date: Tue Mar 5 16:23:10 2024 -0700 Canvas: Fix datalink positioning glitch (#83869) commit 7e4badff1d76b18771464d1c7edf7a5d34519734 Author: Dan Cech Date: Tue Mar 5 16:31:39 2024 -0500 Storage: Use our own key format and support unnamespaced objects (#83929) * use our own key format and support unnamespaced objects * fix tests commit 01fb2cff624e402b927e16f4e04e9fc35c7ab08c Author: Dave Henderson Date: Tue Mar 5 15:24:34 2024 -0500 chore: bump Go to 1.21.8 (#83927) * chore: bump Go to 1.21.8 Signed-off-by: Dave Henderson * bump workflows too Signed-off-by: Dave Henderson --------- Signed-off-by: Dave Henderson commit 3e86a4edc8b8a81225b774adff58b497fc338a5d Author: Ryan McKinley Date: Tue Mar 5 12:20:38 2024 -0800 PanelTitleSearch: Show datasource usage in plugins (#83922) commit cb008657cb02f1184a782231f4f544ac445377ae Author: Kyle Cunningham Date: Tue Mar 5 13:54:31 2024 -0600 Table Panel: Update column filters to use Stack component (#83800) * Update filter list to use stack * Remove dead comments commit f4da9bd09e66090f26c0c949b029616cf484bb70 Author: Kyle Cunningham Date: Tue Mar 5 13:00:41 2024 -0600 Table Panel: Text wrapping fix inspect viewing issues (#83867) * Fix inspect viewing issues * Fix long line wrapping * Prettier * Fix text wrapping * Fix overflowing text commit e916372249833f21ae5c09915a26758a52a87970 Author: Charandas Date: Tue Mar 5 10:34:47 2024 -0800 K8s: bug fixes for file storage to allow for watcher initialization on startup (#83873) --------- Co-authored-by: Todd Treece commit 3f2820a55218e2e3b805b21caacabf9726d11d5e Author: Andreas Christou Date: Tue Mar 5 16:09:42 2024 +0000 Chore: Bump whats new (#83841) Bump whats new commit 9fa9eaab44af088c38df6622274c3f953161778b Author: Dan Cech Date: Tue Mar 5 10:57:32 2024 -0500 Storage: Support get with resourceversion (#83849) support getting old resourceversion, return explicit resource version in list commit b3efb4217e48656f24aacdffd5737595d7361afe Author: Carl Bergquist Date: Tue Mar 5 16:41:19 2024 +0100 Cfg: Adds experimental scope grafana.ini settings (#83174) Signed-off-by: bergquist commit 7f970d48878b85ede6d52c69b5a2251899c71103 Author: Hugo Kiyodi Oshiro Date: Tue Mar 5 16:30:14 2024 +0100 Plugins: Fetch instance provisioned plugins in cloud, to check full installation (#83784) commit 7b4925ea372d310ab57b8cc2cf50ff2b43ff8c79 Author: Dan Cech Date: Tue Mar 5 10:14:38 2024 -0500 Storage: Watch support (#82282) * initial naive implementation * Update pkg/services/store/entity/sqlstash/sql_storage_server.go Co-authored-by: Igor Suleymanov * tidy up * add action column, batch watch events * initial implementation of broadcast-based watcher * fix up watch init * remove batching, it just adds needless complexity * use StreamWatcher * make broadcaster generic * add circular buffer to replay recent events to new watchers * loop within poll until all events are read * add index on entity_history.resource_version to support poller * increment r.Since when we send events to consumer * switch broadcaster and cache to use channels instead of mutexes * cleanup --------- Co-authored-by: Igor Suleymanov commit 71fe675fb789c04c54518960357e1d7a7f7f2735 Author: Lucy Chen <140550297+lucychen-grafana@users.noreply.github.com> Date: Wed Mar 6 00:09:44 2024 +0900 Reporting: Update swagger and openapi doc for deleted deprecated endpoint for Email (#83832) commit 4b0547014ae05d1f6598acc490bdd3fd0d1e5c81 Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Tue Mar 5 14:09:24 2024 +0000 Scenes/LibraryPanels: Fix transformSceneToSaveModel for library panel repeats (#83843) commit 2cce9aa2f7112a868225145712fdd9f458a756a3 Author: ismail simsek Date: Tue Mar 5 14:34:08 2024 +0100 Chore: Move tracing function in influxdb package (#83899) move tracing function in influxdb package commit 73c4b24a5295b4217ddfb0a0eb8dad1fcf29bbe0 Author: Michael Mandrus <41969079+mmandrus@users.noreply.github.com> Date: Tue Mar 5 08:27:44 2024 -0500 Queries: Fix debug logging of metrics queries (#83877) fix log statements commit fdd2c1c59ede444944cd13d0acff06f1ad9fccf2 Author: Levente Balogh Date: Tue Mar 5 14:23:29 2024 +0100 Plugins Catalog: Fix plugin details page initial flickering (#83896) fix: wait for the local and remote fetches to fulfill commit 05865034d7b2549f4819da51fc1c5fa92233e332 Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Tue Mar 5 14:22:03 2024 +0100 Docs: removes note as recovery threshold now in cloud (#83907) commit 9f73fb65cd22b68129d4c39942753b4b443b8479 Author: Sven Grossmann Date: Tue Mar 5 13:04:57 2024 +0100 Loki: Fix permalink timerange when multiple logs share a timestamp (#83847) * Loki: Slide permalink timerange `1ms` to make sure to include line * find previous log with different time * fix test not being agnostic commit a7c06d26f14b2a9fa8faa929a6c9a0c355018429 Author: Ivan Ortega Alba Date: Tue Mar 5 13:01:31 2024 +0100 Dashboards: Add new toggle for dashboard changes out of `dashgpt` toggle (#83897) commit 112c0e7a79c00c7057ef362b6b8a9b7e4471f1b5 Author: Ivan Ortega Alba Date: Tue Mar 5 12:10:46 2024 +0100 Dashboards: Auto-generate get stuck and quick feedback actions doesn't respond (#83879) * Update the component only when the response is fully generated * Fix quick feedback action doesn't respond * Fix history not displaying after the second click * Fix the history that moves when regenerating --------- Co-authored-by: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> commit dc4c539d4626fa96a475401c1bc69d698775b494 Author: ismail simsek Date: Tue Mar 5 11:22:33 2024 +0100 InfluxDB: Fix sql query generation by adding quotes around the identifiers (#83765) * Quote the identifiers * wrap where filter with quotes * fix query generation commit bc7eacfcbdd0680e9ea6401d3c36936ccd5933da Author: Oscar Kilhed Date: Tue Mar 5 10:51:22 2024 +0100 Dashboard Scenes: Add model to library panel inspect json (#83536) * Add model to library panel inspect json * Add missing information from library panel when inspecting its JSON * minor refactor * refactor inspect library panel model to show the model that was fetched from the api * nit: improve comment * fix library panel import path --------- Co-authored-by: Alexandra Vargas commit 019c9618f051e3819be2a2a5fbfd06a3fa5b3aa6 Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Tue Mar 5 10:48:04 2024 +0100 Alerting docs: improve visibility on the distinct options to edit provisioned resources (#83839) Alerting docs: specify the distinct options to edit provisioned resources commit c9ac6dd3e7464278800d2a853b6d19ba55a7854a Author: Oscar Kilhed Date: Tue Mar 5 10:46:03 2024 +0100 Dashboard scenes: debounce name validation when saving dashboards (#83580) * Dashboard scenes: debounce name validation when saving dashboards * add newline commit e930b49db38c1bdb76ca85302217f1e184cf2b26 Author: Yulia Shanyrova Date: Tue Mar 5 09:27:34 2024 +0100 Plugins: Add intraMode 1 into fuzzy search for plugins (#83846) add intraMode 1 into fuzzy search for plugins commit 22074c5026cd2e6fd2a05d6bc2ef255642d32496 Author: Karl Persson Date: Tue Mar 5 08:50:19 2024 +0100 RBAC: add debug log for permission evaluation (#83880) * fix: add debug log when evaluating permissions that includes target permissions commit 9264e2a3bdbebedad152cab8b462d5d2b1f0ca3e Author: Torkel Ödegaard Date: Tue Mar 5 08:49:22 2024 +0100 Table: Custom headerComponent field config option (#83254) * Table: Custom headerComponent field config option * Table custom header component (#83830) * Add tests, fix bug --------- Co-authored-by: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Co-authored-by: Galen commit 1bb38e8f95294de85ee7fd6b03730b2ded68438a Author: Alexander Weaver Date: Mon Mar 4 17:15:55 2024 -0600 Alerting: Move ruleRoutine to be a method on ruleInfo (#83866) * Move ruleRoutine to ruleInfo file * Move tests as well * swap ruleInfo and scheduler parameters on ruleRoutine * Fix linter complaint, receiver name commit c88accdf99d3cc4b04302e055e84bac97194df4b Author: Kyle Cunningham Date: Mon Mar 4 15:31:00 2024 -0600 Transformations: Docs for Group to nested table (#83559) * Start group to nested table docs * Group to nested table docs * Remove dead content * Update docs content * codeincarnate/nested-table-docs/ run formatter * Update punctuation (thanks Isabel 🙏) Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Update wording and structure ((thanks Isabel 🙏) Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * More wording and structure changes (thanks Isabel 🙏) Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Escape backticks * Add imagery * Add generated docs * Language updates Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Language updates Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Language updates Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Update formatting * Rebuild transformation docs --------- Co-authored-by: jev forsberg Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> commit f2a9d0a89db40cc1b414cc3ee3c17575ea2401d6 Author: Alexander Weaver Date: Mon Mar 4 15:15:01 2024 -0600 Alerting: Refactor ruleRoutine to take an entire ruleInfo instance (#83858) * Make stop a real method * ruleRoutine takes a ruleInfo reference directly rather than pieces of it * Fix whitespace commit 3121fce30565af2d84a28527147ecbc351005bc0 Author: Kyle Cunningham Date: Mon Mar 4 14:57:47 2024 -0600 Timeseries to table transformation: Improve Documentation (#83647) * Flesh out timeseries to table docs * Add generated documentation * Update docs * Fix spelling * Fix link formatting Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Update language --------- Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> commit 13f037d617a7b2304c78757afbd0a8869645d7df Author: Kyle Cunningham Date: Mon Mar 4 14:17:20 2024 -0600 Table Panel: Fix condition for showing footer options (#83801) * Fix condition for showing footer options * codeincarnate/table-footer-config/ lint --------- Co-authored-by: jev forsberg commit 52de1a9a33e90358359d541d3cf1c4658f2d5569 Author: Josh Hunt Date: Mon Mar 4 19:18:35 2024 +0000 Change codeowners of library panels to dashboards squad (#83862) * Change codeowners of library panels to dashboards squad * Update CODEOWNERS commit f1000978cba40d75f0172b66a7263416a82b86e0 Author: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Mon Mar 4 13:01:48 2024 -0600 Logs: e2e test flake in loki-table-explore-to-dash.spec.ts (#83856) fix flake in e2e test checking monaco loading state commit 5aa965b9e948a30cc9fa71becabc81d8f2d97b65 Author: Ryan McKinley Date: Mon Mar 4 10:23:32 2024 -0800 Prometheus: remove cue definition (#83808) Co-authored-by: ismail simsek commit 2e8c514cfd9299e3597ee463fd09ec32995814ad Author: Matthew Jacobson Date: Mon Mar 4 13:12:49 2024 -0500 Alerting: Stop persisting user-defined templates to disk (#83456) Updates Grafana Alertmanager to work with new interface from grafana/alerting#161. This change stops passing user-defined templates to the Grafana Alertmanager by persisting them to disk and instead passes them by string. commit fa51724bc638c4c17e8cd2e15e66ad70d0fd6ce3 Author: Alexander Weaver Date: Mon Mar 4 11:24:49 2024 -0600 Alerting: Move alertRuleInfo and tests to new files (#83854) Move ruleinfo and tests to new files commit 3036b50df353c7eb80126b075a5b4631d93dde36 Author: Ryan McKinley Date: Mon Mar 4 08:22:56 2024 -0800 Expressions: expose ConvertDataFramesToResults (#83805) commit 9bd84b30e9135e93fd55d667286c090d5c66868e Author: Alex Khomenko Date: Mon Mar 4 16:22:13 2024 +0100 Chore: Remove deprecated components from dashboard import pages (#83747) * Add Form component to core * Chore: Replace deprecated components in DashboardImportPage.tsx * Chore: Replace deprecated components in ImportDashboardForm.tsx * Chore: Replace deprecated components in ImportDashboardOverview.tsx commit c644826f50241fd65a0078a4db1983e11a87d596 Author: Will Browne Date: Mon Mar 4 16:06:57 2024 +0100 Query: Add query type to json marshal/unmarshal (#83821) add query type to json marshal/unmarshal commit 57935250fdf5837db60472ef82e1d3ce9b11e9f5 Author: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> Date: Mon Mar 4 10:03:33 2024 -0500 [DOC] Add profile-traces intro material; update Pyroscope data source info (#83739) * Add profile-traces intro material; update Pyroscope data source info * Apply suggestions from code review Co-authored-by: Jack Baldry * Updates and file rename from review * Add PYROSCOPE_VERSION * Apply suggestions from code review * Format tables Signed-off-by: Jack Baldry * Apply suggestions from code review Co-authored-by: Jack Baldry Co-authored-by: Jennifer Villa * Apply suggestions from code review --------- Signed-off-by: Jack Baldry Co-authored-by: Jack Baldry Co-authored-by: Jennifer Villa commit 89575f1df42cb5b9f2a4316201c75ad219488353 Author: tonypowa <45235678+tonypowa@users.noreply.github.com> Date: Mon Mar 4 16:03:20 2024 +0100 alerting:clarify silence preview (#83754) * alerting:clarify silence preview * prettier * Update docs/sources/alerting/configure-notifications/create-silence.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Lint docs --------- Co-authored-by: Armand Grillet <2117580+armandgrillet@users.noreply.github.com> Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> commit 7cf419c09a1415b3055b9ae99d70f415b28810e9 Author: Leon Sorokin Date: Mon Mar 4 08:47:20 2024 -0600 Dashboard: Revert LayoutItemContext (#83465) commit b63866baafaade1ee3e7dff3a15ff590dc8d750e Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Mon Mar 4 15:26:46 2024 +0100 Loki: Interpolate variables in live queries (#83831) * Loki: Interpolate variables in live queries * Update to not have to rempve private * Update comment commit 59fb26443f12cdef17e15900a5f90676f3784243 Author: Ivan Ortega Alba Date: Mon Mar 4 14:31:14 2024 +0100 DashboardSceneChangeTracker: Do not load the worker until is editing (#83817) commit 519f965c8ec5822f3121f2ece3c29c7fb0595759 Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Mon Mar 4 13:10:04 2024 +0000 Scenes/LibraryPanels: Fixes issue where repeat panel would disappear if dashboard didn't have variable used by lib panel (#83780) commit ad28e3cc77a99da82c17ad648af83e51b18aff84 Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Mon Mar 4 13:01:17 2024 +0000 Scenes: Fix panel repeats (#83707) commit 82a88cc83f1c6a9d6759d1b4936de0643a4cada9 Author: Alexander Zobnin Date: Mon Mar 4 15:29:13 2024 +0300 Access control: Extend GetUserPermissions() to query permissions in org (#83392) * Access control: Extend GetUserPermissions() to query permissions in specific org * Use db query to fetch permissions in org * refactor * refactor * use conditional join * minor refactor * Add test cases * Search permissions correctly in OSS vs Enterprise * Get permissions from memory * Refactor * remove unused func * Add tests for GetUserPermissionsInOrg * fix linter commit 67c062acc7cf0dd83921ec87660e4e3da841d763 Author: linoman <2051016+linoman@users.noreply.github.com> Date: Mon Mar 4 05:45:07 2024 -0600 Adjust HD validation default value (#83818) commit 111df1bba0efb85f7a4a3d7e54afe485cc30ff37 Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Mon Mar 4 12:10:37 2024 +0100 Alerting docs: update the Terraform Provision guide (#83773) * Alerting docs: update the Terraform Provision guide * Fix incorrect links * Update docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> --------- Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> commit 07e26226b7708be83d6c3c7ef21b48e20af5a59b Author: Misi Date: Mon Mar 4 11:55:59 2024 +0100 Auth: Add all settings to Azure AD SSO config UI (#83618) * Add all settings to AzureAD UI * prettify * Fixes * Load extra keys with type assertion commit fa44aebeff842ab5b2e374695fd59b24d2f5269a Author: Laura Fernández Date: Mon Mar 4 11:38:00 2024 +0100 ReturnToPrevious: Improve tests (#83812) commit 9e12caebc7d5207f14a6715a6264d81b4d19f4ac Author: Mitch Seaman Date: Mon Mar 4 11:14:37 2024 +0100 Docs: typo fix (remove an extra parenthesis) (#83811) commit 244589d7eac0fa839c74560e9205ccf8da7bcf87 Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Mon Mar 4 10:50:44 2024 +0100 Alerting docs: document `mute-timings` export endpoints (#83797) commit 9eb69b921344e01ebd861dac45854efef6f989e0 Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Mon Mar 4 10:20:10 2024 +0100 Alerting docs: fix incorrect `docs/reference` link (#83809) commit 869b89dce45090cd982414479a7e7b3bd7953253 Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Fri Mar 1 20:32:59 2024 -0500 K8s: Add accept header to ctx (#83802) commit d7bcd119c31bfb4d1aeb2779ccf6c158f297770c Author: Ryan McKinley Date: Fri Mar 1 14:26:04 2024 -0800 K8s: improve openapi generation (#83796) commit 2184592174b8ec1ff20381349773fef1533d4b9e Author: Laura Fernández Date: Fri Mar 1 19:01:27 2024 +0100 ReturnToPrevious: create tests to check `reportInteraction` (#83757) commit 5f6bf93dd507e92378cce7c2e67add6ccf4bd345 Author: Ryan McKinley Date: Fri Mar 1 09:38:32 2024 -0800 Expressions: Use enumerations rather than strings (#83741) commit c59ebfc60f5aec1eccfe07da7a6d948782ddafdb Author: Jack Westbrook Date: Fri Mar 1 18:29:39 2024 +0100 Fix: Cache busting of plugins module.js file (#83763) fix(plugins): make sure extractPath regex matches with and without leading slash commit 886d8fae360257ffa7f723cdf44c528fd0addfa5 Author: Kyle Cunningham Date: Fri Mar 1 11:20:14 2024 -0600 Table Panel: Improve text wrapping on hover (#83642) * Fix up text wrapping * Make sure there are basic cell styles * codeincarnate/text-wrapping-hover-pt2/ run prettier * Add comment for conditional * Update condition --------- Co-authored-by: jev forsberg commit 33cb4f9bf4ef4ef33047e7fdcbffb02345194835 Author: Kyle Cunningham Date: Fri Mar 1 11:19:21 2024 -0600 Table: Preserve filtered value state (#83631) * Hoist state * codeincarnate/table-filter-state/ lint --------- Co-authored-by: jev forsberg Co-authored-by: nmarrs commit 96127dce6211ee99c9c3a254c23134d9473c2255 Author: George Robinson Date: Fri Mar 1 17:17:55 2024 +0000 Alerting: Fix bug in screenshot service using incorrect limit (#83786) This commit fixes a bug in the screenshot service where [alerting].concurrent_render_limit was used instead of [rendering].concurrent_render_request_limit, as in the docs. commit 582fd53fac02e5fb73671a5acc6d2257a3062198 Author: Gilles De Mey Date: Fri Mar 1 18:09:14 2024 +0100 Alerting: Implement correct RBAC checks for creating new notification templates (#83767) commit fc10600ca5a79c68d739b55b33b06b8491da9911 Author: Gilles De Mey Date: Fri Mar 1 18:08:42 2024 +0100 Alerting: Fix editing Grafana folder via alert rule editor (#83771) commit 675b7debe7f115b54968038866ab0905db37ffc4 Author: Gilles De Mey Date: Fri Mar 1 18:08:17 2024 +0100 Alerting: Assume rule not found when everything is undefined (#83774) commit 8b551b08f9034a6b75ece345237da1d3394d100d Author: Eric Leijonmarck Date: Fri Mar 1 17:05:59 2024 +0000 TeamLBAC: Change to public preview (#83761) change to public preview commit 2964901ea62de904bbd48122d5b6e364dad26401 Author: Santiago Date: Fri Mar 1 14:04:20 2024 -0300 Chore: Fix typo in template not found error message (#83778) commit 8ad367e4adefabd95283a6bf467f5dbd40a9899c Author: Santiago Date: Fri Mar 1 13:28:08 2024 -0300 Chore: Remove redundant error check (#83769) commit 0dfdb2ae47383beb41aa981313b4f12b4b29a8fa Author: owensmallwood Date: Fri Mar 1 10:16:43 2024 -0600 Revert "Add FolderUID for library elements" (#83776) Revert "Add FolderUID for library elements (#79572)" This reverts commit 2532047e7a6f2bd4ac9b7d7ae3d1b2aadcfb3bd6. commit 068e6f6b94df101c0d75df2588a70ae28a2e81cc Author: Ida Štambuk Date: Fri Mar 1 16:56:45 2024 +0100 Cloudwatch: Fix new ConfigEditor to add the custom namespace field (#83762) commit 1c2b8576e117a7e6c308dd01bbbbaabc20b9c2ad Author: foehammer <44120040+foehammer127@users.noreply.github.com> Date: Fri Mar 1 09:48:20 2024 -0600 CloudWatch: de-duplicate implementation of AWSDatasourceSettings.Load in LoadCloudWatchSettings (#83556) commit 086d47d83c2b3075cdbe95771403b2a7e61cbf39 Author: Ashley Harrison Date: Fri Mar 1 14:54:39 2024 +0000 Chore: replace `react-popper` with `floating-ui` in `ExemplarMarker` (#83694) * replace react-popper with floating-ui in ExemplarMarker * fix e2e test * floating-ui uses mousemove commit 10a55560dfc09a63aa4a7ffe8bc00618abc1c935 Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Fri Mar 1 13:25:15 2024 +0000 Scenes: Add support for repeated library panels (#83579) commit dbe621eeb4f35ffc4824be4d094229952b065602 Author: Ida Štambuk Date: Fri Mar 1 13:52:45 2024 +0100 Cloudwatch: Bump grafana/aws-sdk-go to 0.24.0 (#83480) commit 371aced092f53bf7082f62ab13b4c04b68b0d6c3 Author: Misi Date: Fri Mar 1 13:49:06 2024 +0100 Chore: Add enabled status to authentication_ui_provider_clicked (#83766) Add enabled metadata to authentication_ui_provider_clicked commit 21affd3b0eaf1eb6f0ceb8012e32f336b4cee267 Author: Ashley Harrison Date: Fri Mar 1 12:24:19 2024 +0000 Library panels: Ensure all filters are visible on mobile (#83759) * convert styles to emotion object syntax * allow items to wrap commit 142ac22023f83b6444574e0fb060c61d8283559c Author: Gábor Farkas Date: Fri Mar 1 12:20:47 2024 +0100 Revert "Postgres: Switch the datasource plugin from lib/pq to pgx" (#83760) Revert "Postgres: Switch the datasource plugin from lib/pq to pgx (#81353)" This reverts commit 8c18d06386c87f2786119ea9a6334e35e4181cbe. commit 0aebb9ee3929f94157ebaaaa95776aa838b571db Author: Jo Date: Fri Mar 1 12:08:00 2024 +0100 Misc: Remove unused params and impossible logic (#83756) * remove unused params and impossible logic * remove unused param commit 11d341d2bbed45c8fcb8512a3b77aad4839641b3 Author: Konrad Lalik Date: Fri Mar 1 11:34:58 2024 +0100 Alerting: Add escape and quote support to the matcher name (#83601) Add escape and quote support to the matcher name commit 36a19bfa838e7f57651297130e6dd10e765c6f22 Author: Jo Date: Fri Mar 1 11:31:06 2024 +0100 AuthProxy: Allow disabling Auth Proxy cache (#83755) * extract auth proxy settings * simplify auth proxy methods * add doc mentions commit 1cec975a66f77403ad327b082910742be575432d Author: Levente Balogh Date: Fri Mar 1 11:13:16 2024 +0100 Docs: Update "What's new in G10?" (#83467) * docs: add info about the react-router migration into v10 what's new * fix: linting issue * Update docs/sources/whatsnew/whats-new-in-v10-0.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * docs: update linting --------- Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> commit 1a7af2d8430b77854f1e9779c0a8a44e3d9c961a Author: Anton Patsev <10828883+patsevanton@users.noreply.github.com> Date: Fri Mar 1 16:00:15 2024 +0600 Сorrection of spelling errors (#83565) thanks for your contribution ! commit 824c26cd5ebf45ce3d33b5fd57063c693f1973d5 Author: linoman <2051016+linoman@users.noreply.github.com> Date: Fri Mar 1 03:56:26 2024 -0600 Password Policy: add documentation (#83208) * add documentation * Update docs/sources/setup-grafana/configure-security/configure-authentication/grafana/index.md --------- Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> commit 75b020c19de1383bfbeab1954a3a2468e83a9b59 Author: Misi Date: Fri Mar 1 10:39:50 2024 +0100 Cfg: Add a setting to configure if the local file system is available (#83616) * Introduce environment.local_filesystem_available * Only show TLS client cert, client key, client ca when local_filesystem_available is true * Rename LocalFSAvailable to LocalFileSystemAvailable commit 2532047e7a6f2bd4ac9b7d7ae3d1b2aadcfb3bd6 Author: idafurjes <36131195+idafurjes@users.noreply.github.com> Date: Fri Mar 1 10:16:33 2024 +0100 Add FolderUID for library elements (#79572) * Add FolderUID in missing places for libraryelements * Add migration for FolderUID in library elements table * Add Folder UIDs tolibrary panels * Adjust dashboard import with folder uid * Fix lint * Rename back FolderUID to UID * Remove default * Check if folderUID is nil * Add unique indes on org_id,folder_uid,name and kind * Update pkg/services/libraryelements/database.go Co-authored-by: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> * Fix folder integration test, with unique index on library elements * Make folder uids nullable and rewrite migration query * Use dashboard uid instead of folder_uid * Adjust test --------- Co-authored-by: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> commit 2182cc47acaeae16e7208b2a59ac80ef1e9bba76 Author: Jo Date: Fri Mar 1 10:14:32 2024 +0100 LDAP: Fix LDAP users authenticated via auth proxy not being able to use LDAP active sync (#83715) * fix LDAP users authenticated via auth proxy not being able to use ldap sync * simplify id resolution at the cost of no fallthrough * remove unused services * remove unused cache key commit 4dc80945148afdccb28fb8af4cc0c2a1e1e7a775 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri Mar 1 07:59:07 2024 +0000 I18n: Download translations from Crowdin (#83695) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> commit b87ec6943143f682c4815293b0734b04f134571d Author: Charandas Date: Thu Feb 29 17:29:05 2024 -0800 K8s: add a remote services file config option to specify aggregation config (#83646) commit 5d7a979199447d3227151792f360e8a4b2d95027 Author: Nathan Marrs Date: Thu Feb 29 17:56:40 2024 -0700 Canvas: Add ability to edit selected connections in the inline editor (#83625) commit 859ecf2a3491b109dedefeabf3546aebcc470e11 Author: Leon Sorokin Date: Thu Feb 29 17:28:37 2024 -0600 VizTooltips: Render data links only when anchored (#83737) commit 88ebef5cba2529634357092595613226691e9f81 Author: Tim Levett Date: Thu Feb 29 16:59:40 2024 -0600 Transformations: Add substring matcher to the 'Filter by Value' transformation (#83548) commit 74115f1f08e3a6aca65384a43ff28b9540c647f9 Author: Ryan McKinley Date: Thu Feb 29 14:58:49 2024 -0800 Chore: fix apiserver integration tests (#83724) Co-authored-by: Todd Treece commit d7b031f318db98532ff15bedfc9ae36b406a4e2e Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Thu Feb 29 17:22:18 2024 -0500 Chore: Update Makefile to support go workspace (#83549) commit 42b55aedbc3e35ed426ff684347492cbdbea3314 Author: Leon Sorokin Date: Thu Feb 29 13:46:53 2024 -0600 DataLinks: Handle getLinks() regen during data updates and frame joining (#83654) commit c9d8d8713b6046024f33a527427a47674b754255 Author: Andreas Christou Date: Thu Feb 29 18:00:21 2024 +0000 CI: Bump `alpine` image version (#83716) Bump image version commit d21a61752fe75650f0aed0fc3c3db308cd4175e6 Author: Josh Hunt Date: Thu Feb 29 17:43:00 2024 +0000 Docs: Mention nvm in contribute docs (#83709) Mention nvm in contribute docs commit 098c611b654a41fe39101f9ad5f83defcd327cd0 Author: Spencer Torres Date: Thu Feb 29 12:30:40 2024 -0500 Docs: add ClickHouse to exploring logs/traces page (#83178) commit b2cb8d8038a65860bd3baa3e62ac07e01abd709a Author: JERHAV <77524797+JERHAV@users.noreply.github.com> Date: Thu Feb 29 17:54:37 2024 +0100 CloudWatch: Remove unnecessary sortDimensions function (#83450) Remove sortDimension Closes #83338 commit 0218e94d936b6acbf2974154f1f8c5e6027501b5 Author: Misi Date: Thu Feb 29 17:41:08 2024 +0100 Auth: Add Save and enable, Disable buttons to SSO UI (#83672) * Add Save and enable and Disable button * Change to use Dropdown, reorder buttons * Improve UI * Update public/app/features/auth-config/ProviderConfigForm.tsx * Apply suggestions from code review * Use Stack instead of separate Fields --------- Co-authored-by: Alex Khomenko commit f0dce33034495138a19162e003cfb45dbed9d3c1 Author: Josh Hunt Date: Thu Feb 29 16:29:17 2024 +0000 Chore: Taint ArrayVector with `never` to further discourage (#83681) Chore: Taint ArrayVector with never to further discourage commit 29d6cd8fa0dff11b09bde93398228e26abae6b58 Author: Ivan Ortega Alba Date: Thu Feb 29 17:18:26 2024 +0100 DashboardScene: Share change detection logic between saving and runtime (#81958) Co-authored-by: Alexandra Vargas Co-authored-by: Dominik Prokop commit b02ae375ba5599fa1e72fb818a1424a8e134efaa Author: linoman <2051016+linoman@users.noreply.github.com> Date: Thu Feb 29 09:48:32 2024 -0600 Chore: Query oauth info from a new instance (#83229) * query OAuth info from a new instance * add `hd` validation flag * add `disable_hd_validation` to settings map * update documentation --------- Co-authored-by: Jo commit 2a1d4f85c76684370ba52718e103c7e0d263fb04 Author: Jack Baldry Date: Thu Feb 29 15:08:45 2024 +0000 Update links to default Grafana branch (#83025) commit 549094d27ce955bac0ccdefd2ff1e8ade6416617 Author: Will Browne Date: Thu Feb 29 15:53:09 2024 +0100 grafana/data: Gardening 👨‍🌾✂️🌳 (#83615) * remove suspected unused dependencies from grafana/data * un-export funcs and types * re-export NoopTransformerOptions * remove knip commit e26cd8614d1f5f9445108c95824042191beb5be6 Author: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Thu Feb 29 09:39:25 2024 -0500 Docs: fix config file info in upgrade guide (#83273) * Updated incorrect custom config file names and locations * Corrected default config file name * Updated more config file info * Apply suggestions from code review Co-authored-by: Pepe Cano <825430+ppcano@users.noreply.github.com> * Reverted change * Fixed default config file info, added second custom file option, and added note about file locations * Added file path for second custom option * Apply suggestion from review Co-authored-by: Usman Ahmad * Apply suggestion from review Co-authored-by: Usman Ahmad * Apply suggestions from review Co-authored-by: Usman Ahmad * Apply suggestion from review * Add version interpolation syntax * Updated wording * Ran prettier --------- Co-authored-by: Pepe Cano <825430+ppcano@users.noreply.github.com> Co-authored-by: Usman Ahmad commit ca81d1f22da5ac912c5f81598aae233cd82423de Author: Esteban Beltran Date: Thu Feb 29 15:25:33 2024 +0100 Revert "Chore: Remove components from the graveyard folder in grafana/ui" (#83687) Revert "Chore: Remove components from the graveyard folder in grafana/ui (#83…" This reverts commit 65174311659277e6c10730f16f61cb04c2df1db8. commit 9ab03d4e202d333b97b45afd2150da2cad72e16f Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu Feb 29 15:47:17 2024 +0200 I18n: Download translations from Crowdin (#83605) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> commit 5572158eeaf93eab48a0fee000c723bae45e2f95 Author: David Harris Date: Thu Feb 29 13:14:12 2024 +0000 chore: update core plugin descriptions (#83449) * update alertmanager * fix typo * update candlestick * update histogram * update xy * fix prettier * dh-update-desc/ update snapshots * dh-update-desc/ revert variable * update failing test snapshot --------- Co-authored-by: jev forsberg Co-authored-by: nmarrs commit 1994d1e2c72e5c21391b599b0ee63496fd501aa5 Author: Ashley Harrison Date: Thu Feb 29 13:10:04 2024 +0000 E2C: Implement on-prem auth flow (#83513) * add connection modal * extend api with connect/disconnect endpoints * extract translations * display stack url * use react-hook-form * fix links spanning whole modal * review comments commit 036e19037ed702bf4b63f16cc8b3b6f9d7b785db Author: Andre Pereira Date: Thu Feb 29 12:41:31 2024 +0000 Tempo: Better fallbacks for metrics query (#83619) * Use query as fallback when there's one series and no labels. Use "" as the fallback for empty label values. Stop using the `promLabels` value from the Tempo response * Set refId to remove mentions of promLabels * Add quotes around the string value, add space after comma separator * Use name as refId when possible commit 9f617191da81e32d403e829bedaab25f4b6c6a70 Author: Gábor Farkas Date: Thu Feb 29 13:02:32 2024 +0100 grafana-data: fix the export of arrayvector (#83678) commit 88f010a72a644c7f24fcb09e53b82356ef3fa9fb Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Thu Feb 29 12:55:52 2024 +0100 Alerting docs: remove admonition/notes related to old Grafana versions (#83669) commit 2a78ffdb757cd6252a3faface0b43dd2acf8b1a0 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu Feb 29 11:49:05 2024 +0000 Update `make docs` procedure (#83658) Co-authored-by: grafanabot commit 0fd46a1bd35089d1591204df48d1258e432f9df9 Author: Josh Hunt Date: Thu Feb 29 11:40:11 2024 +0000 Revert "CodeEditor: Ensure latest onChange callback is called" (#83677) Revert "CodeEditor: Ensure latest onChange callback is called (#83599)" This reverts commit 3363e3f2d35a6da3474151febe26c50c3746956f. commit f0822e0aef19b0bafb30d8c7f13e1ed0e2a346d2 Author: Laura Fernández Date: Thu Feb 29 12:19:59 2024 +0100 ReturnToPrevious: Add this functionality to the "Go to panel" button on Alert rules (#83630) Add RTP to the 'Go to panel' button commit 2a429cd7db84d2783311b81c6334260107922fce Author: omahs <73983677+omahs@users.noreply.github.com> Date: Thu Feb 29 11:47:22 2024 +0100 Fix typos (#83621) Co-authored-by: Isabel Matwawana Co-authored-by: Jack Baldry commit f394e8333e7d9c8f2ed87558016bcaa236cb0209 Author: Levente Balogh Date: Thu Feb 29 10:29:41 2024 +0100 Chore: remove the `dataConnectionsConsole` feature toggle (#83661) chore: remove `dataConnectionsConsole` feature toggle commit f8ce8d06000cbb2c6e6566fda27b6f5c0201270a Author: Selene Date: Thu Feb 29 10:20:28 2024 +0100 Schemas: Restore spec core go generation (#83594) * Restore spec core go generation * Fix go imports commit 10721bfcd966176ecaf04127db983fa148d00881 Author: David Harris Date: Thu Feb 29 09:03:47 2024 +0000 Update angular-plugins.md (#83635) * Update angular-plugins.md Removes advice which only worked in `dev` instances. * Update angular-plugins.md Co-authored-by: Joseph Perez <45749060+josmperez@users.noreply.github.com> --------- Co-authored-by: Joseph Perez <45749060+josmperez@users.noreply.github.com> commit a76e21c837c0551ad2ad2cc91884f650c2e60eab Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu Feb 29 09:53:00 2024 +0100 TimeSeries: Support vertical orientation (#83272) * Time series with flipped axes * Ass prop and fix timezone labes * Remove not needed css * Fix zoomplugin * Update comment * Fix zoomplugin * Update * Update TooltipPlugin2 * Update variable names * Update naming and comments * Update public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx Co-authored-by: Jev Forsberg <46619047+baldm0mma@users.noreply.github.com> --------- Co-authored-by: Jev Forsberg <46619047+baldm0mma@users.noreply.github.com> commit 8ed932bb603d4219f6ceb8421b937e5015f06a91 Author: Alex Khomenko Date: Thu Feb 29 07:38:09 2024 +0100 Select: Add instructions for resetting a value (#83603) commit 2c95782b101db95d9e4deac29032ec5d987617f7 Author: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Wed Feb 28 23:37:29 2024 -0500 Docs: restructure Configure panel options (#83438) * Moved view json panel content from configure panel options to panel inspect view * Converted add title and description task to reference section * Removed edit panel section * Updated bullet list to match content * Removed view json content to be integrated later * Ran prettier * Docs: Edit Configure panel options (#83439) * Updated intro * Updated intro, descriptions, and repeating panels task * Reformatted sections of task and updated wording of LLM info * Copy edits * Added Cloud links and updated version syntax * Fixed link * Fixed formatting and removed vestigial sentence commit 88f5b62a237b3a0596801dea6a35d4a620ced891 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Wed Feb 28 18:06:55 2024 -0500 VizTooltips: Tolerate missing annotation text (#83634) commit 5eb7e09351c30b6a958bf543a7f4811f532323fa Author: Leon Sorokin Date: Wed Feb 28 17:06:05 2024 -0600 VizTooltips: Show data links without anchoring (#83638) commit 6e75584505f862e159c48d69a3469ee057606b44 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Wed Feb 28 16:06:31 2024 -0500 datatrails: improve label filter behavior (#83411) commit d6b1aa6575db4249fe6aa89e109bff9074c384ec Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Wed Feb 28 16:05:22 2024 -0500 datatrails: standardize loading and blocking message indicators (#83560) fix: standardize loading and blocking message indicators commit 239abe4234b696eed800ab1b38bfa1b9534fb70a Author: Ryan McKinley Date: Wed Feb 28 13:00:00 2024 -0800 Chore: Restore vectorator export (#83637) commit a862a4264d06311f0e1ee28f8d62b2af4c1d030c Author: Alexander Weaver Date: Wed Feb 28 14:40:13 2024 -0600 Alerting: Export rule validation logic and make it portable (#83555) * ValidateInterval doesn't need the entire config * Validation no longer depends on entire folder now that we've dropped foldertitle from api * Don't depend on entire config struct * Export validate group commit af528d2f660cc34c8b6e057d81a7c627166686c7 Author: William Wernert Date: Wed Feb 28 13:16:37 2024 -0500 Alerting/Annotations: Prevent panics from composite store jobs from crashing Grafana (#83459) * Don't directly use pointer to json * Don't crash entire process if a store job panics * Add debug logs when failing to parse/handle Loki entries commit 9190fb28e82c8ff8430a029f94743152252f40d4 Author: Leon Sorokin Date: Wed Feb 28 11:42:46 2024 -0600 VizTooltip: Improve edge positioning and a11y (#83584) commit b89de96681f10cbced9d0aaa5d811bf5670e0d4c Author: Eric Leijonmarck Date: Wed Feb 28 17:35:10 2024 +0000 Anonymous: Add docs for anon users charged on enterprise (#83626) add anon users enterprise commit 3363e3f2d35a6da3474151febe26c50c3746956f Author: Josh Hunt Date: Wed Feb 28 16:47:10 2024 +0000 CodeEditor: Ensure latest onChange callback is called (#83599) commit 65174311659277e6c10730f16f61cb04c2df1db8 Author: Ryan McKinley Date: Wed Feb 28 08:36:53 2024 -0800 Chore: Remove components from the graveyard folder in grafana/ui (#83545) commit 528ce96118a64f7df26601b1ed8177b6931e1718 Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Wed Feb 28 18:25:22 2024 +0200 Scenes: Fix lib panel and lib widget placement in collapsed/uncollapsed rows (#83516) * wip * tests + refactor ad panel func * Add row functionality * update row state only when there are children * Add new row + copy paste panels * Add library panel functionality * tests * PR mods * reafctor + tests * reafctor * fix test * refactor * fix bug on cancelling lib widget * dashboard now saves with lib panel widget * add lib panels widget works in rows as well * split add lib panel func to another PR * Add library panel functionality * Fix lib panel and lib widget placement in collapsed/uncollapsed rows * refactor * take panelKey into account when getting next panel id in dashboard * fix tests commit 260a87a3f61eff3eed14370a93d0f0a1edcbf6c0 Author: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com> Date: Wed Feb 28 10:02:41 2024 -0600 Docs: updated codeowners file (#83546) * removed myself from data sources, added to admin (IAM) * Remove accidental changes Signed-off-by: Jack Baldry * Rewrite Technical documentation codeowners for readability and consistency Now it is consistent with the comments at the head of the file, at least for this section of the repo. Signed-off-by: Jack Baldry --------- Signed-off-by: Jack Baldry Co-authored-by: Jack Baldry commit 183a42b7f6ce44853ea1b48733e8b6a54b0f0a0f Author: Konrad Lalik Date: Wed Feb 28 16:52:56 2024 +0100 Alerting: Improve alert rule and search interaction tracking (#83217) * Fix alert rule interaction tracking * Add search component interaction tracking * Add fine-grained search input analytics commit 07128cfec1d48ce989acca0db56cdae803d47926 Author: Ryan McKinley Date: Wed Feb 28 07:38:21 2024 -0800 Chore: Restore ArrayVector (#83608) commit ba4470dd7d39b3561ca25fda6f716fe5853ee5dd Author: Marie Cruz Date: Wed Feb 28 15:23:37 2024 +0000 docs: link annotation queries video to documentation (#83586) commit b905777ba9cb59fb20b38e951066d12e82efc12d Author: Joe Blubaugh Date: Wed Feb 28 23:19:02 2024 +0800 Alerting: Support deleting rule groups in the provisioning API (#83514) * Alerting: feat: support deleting rule groups in the provisioning API Adds support for DELETE to the provisioning API's alert rule groups route, which allows deleting the rule group with a single API call. Previously, groups were deleted by deleting rules one-by-one. Fixes #81860 This change doesn't add any new paths to the API, only new methods. --------- Co-authored-by: Yuri Tseretyan commit 467302480f4a6476e8b82ba0ef4f9918c65fd21f Author: Arati R <33031346+suntala@users.noreply.github.com> Date: Wed Feb 28 16:11:09 2024 +0100 Search: Include collapsed panels in search v2 (#83047) * Include collapsed panels in searchv2 * Include collapsed row in TestReadSummaries commit 91cd17f012d280d8a0255d91c2c5addcf8a803de Author: Misi Date: Wed Feb 28 16:01:02 2024 +0100 Chore: Move TLS settings to the Extra Security Measures section (SSO Settings UI) (#83602) Move TLS settings to the Extra Security Measures section commit 757fa06b85f8e1e9d416134e383fc15d7d521fa7 Author: ismail simsek Date: Wed Feb 28 15:59:06 2024 +0100 InfluxDB: Fix interpolation of multi value template variables by adding parenthesis around them (#83577) Put parenthesis around multi value template variable commit 7d6d256335d36a5ce560d8ab5e7da8816eccea5e Author: Ezequiel Victorero Date: Wed Feb 28 11:45:22 2024 -0300 Snapshots: Change default expiration (#83550) commit 58d6ce1c8746d82f7981c052d95dda978f7d00e3 Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Wed Feb 28 16:41:56 2024 +0200 Add lib panel from empty dashboard page (#83522) * wip * tests + refactor ad panel func * Add row functionality * update row state only when there are children * Add new row + copy paste panels * Add library panel functionality * tests * PR mods * reafctor + tests * reafctor * fix test * refactor * fix bug on cancelling lib widget * dashboard now saves with lib panel widget * add lib panels widget works in rows as well * split add lib panel func to another PR * Add library panel functionality * Add lib panel from empty dashboard page * refactor * take panelKey into account when getting next panel id in dashboard * fix tests commit 04539ffccbe78ec01847677f500069b88f36d169 Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Wed Feb 28 16:41:12 2024 +0200 Scenes: Add 'Import from library' functionality (#83498) * wip * tests + refactor ad panel func * Add row functionality * update row state only when there are children * Add new row + copy paste panels * Add library panel functionality * tests * PR mods * reafctor + tests * reafctor * fix test * refactor * fix bug on cancelling lib widget * dashboard now saves with lib panel widget * add lib panels widget works in rows as well * split add lib panel func to another PR * Add library panel functionality * refactor * take panelKey into account when getting next panel id in dashboard * fix tests commit 393b12f49f0edac1a9df1da0a4e687582ccaf40e Author: Jára Benc Date: Wed Feb 28 15:24:34 2024 +0100 Sql: Fix an issue with connection limits not updating when jsonData is updated (#83175) commit ed3c36bb46793afdb29300b955a066cdf705d52f Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Wed Feb 28 15:21:00 2024 +0100 Alerting: Use time_intervals instead of the deprecated mute_time_intervals in a… (#83147) * Use time_intervals instead of the deprecated mute_time_intervals in alert manager config * don't send mute_time_intervals in the payload * Add and update tests * Fix usecase when having both fields in response from getting alert manager config * Use mute_timings for grafana data source and both for cloud data source when deleting mute timing * Use mute_timings for grafana data source and both for cloud data source when saving a new or existing alert rule * Address first code review * Address more review comments commit 90e7791086b42902fcbc80a5dd1d1f74b8b0feb4 Author: Brendan O'Handley Date: Wed Feb 28 08:03:36 2024 -0600 Prometheus: Reduce flakiness in prometheus e2e tests (#83437) * reduce flakiness in prometheus e2e tests * prettier fix for azure docs * Update e2e/various-suite/prometheus-config.spec.ts Co-authored-by: Nick Richmond <5732000+NWRichmond@users.noreply.github.com> --------- Co-authored-by: Nick Richmond <5732000+NWRichmond@users.noreply.github.com> commit 411c89012febe13323e4b8aafc8d692f4460e680 Author: Sven Grossmann Date: Wed Feb 28 14:32:01 2024 +0100 Elasticsearch: Fix adhoc filters not applied in frontend mode (#83592) commit 3b7e7483c81c597e991ba7812d6ae67a64e9a67a Author: Misi Date: Wed Feb 28 13:45:59 2024 +0100 Auth: Align loading the legacy auth.grafananet section to the current behaviour in OAuthStrategy (#83479) * Align oauth_strategy to the current behaviour * lint * Address feedback commit acf97e43b664cf966f4c85d53580e4a5573e59cc Author: Jack Baldry Date: Wed Feb 28 09:54:19 2024 +0000 Review "Team LBAC" page (#83406) commit 213e39956338d617e5afdd4c972be855e3c44ae9 Author: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Wed Feb 28 10:50:03 2024 +0100 Docs: Change link in Trusted Types (#83391) commit d83319365f0c4fad173e8ed55eaa37041c5d9c26 Author: Ashley Harrison Date: Wed Feb 28 09:45:11 2024 +0000 DatePickerWithInput: use `floating-ui` so calendar can overflow scroll containers (#83521) * move DatePickerWithInput to use floating-ui * remove position: absolute from DatePicker, remove unnecessary css from CreateTokenModal commit 738e9126dec1c325c02613e4ce0c9a1b3ac19f3d Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Wed Feb 28 11:13:01 2024 +0200 Scenes: Add new row and copy/paste functionalities (#83231) * wip * tests + refactor ad panel func * Add row functionality * update row state only when there are children * Add new row + copy paste panels * Add library panel functionality * tests * PR mods * reafctor + tests * reafctor * fix test * refactor * fix bug on cancelling lib widget * dashboard now saves with lib panel widget * add lib panels widget works in rows as well * split add lib panel func to another PR commit ecb8447a7fbad847ad2df6831df9981d2774d95c Author: Piotr Jamróz Date: Wed Feb 28 09:19:20 2024 +0100 Explore: Translate table title in runtime to get a better test id (#83236) * Explore: Translate table title in runtime to get a better test id * Fix escaping * Retrigger the build * Prettify commit 3901077f395796a948a7353292945cd7f1722853 Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Wed Feb 28 09:10:19 2024 +0100 Alerting docs: changes weights and titles (#83519) * Alerting docs: changes weights and titles * language improvements to intro topic * corrects rulle spelling * adds alert instance info * updates admonition note commit 8c18d06386c87f2786119ea9a6334e35e4181cbe Author: Gábor Farkas Date: Wed Feb 28 07:52:45 2024 +0100 Postgres: Switch the datasource plugin from lib/pq to pgx (#81353) * postgres: switch from lib/pq to pgx * postgres: improved tls handling commit e8df62941ba12c9a3b2e189f3e4d849b109d5522 Author: Señor Performo - Leandro Melendez <54183040+srperf@users.noreply.github.com> Date: Tue Feb 27 16:12:08 2024 -0600 Docs: Add missing visualizations to Grafana vizualization index page (#83351) Co-authored-by: Nathan Marrs Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Co-authored-by: jev forsberg commit 70009201d44c2d0ab39cc77081808a69a6c4fd63 Author: Scott Lepper Date: Tue Feb 27 16:16:00 2024 -0500 Expressions: Sql expressions with Duckdb (#81666) duckdb temp storage of dataframes using parquet and querying from sql expressions --------- Co-authored-by: Ryan McKinley commit d8b7992c0cd6abe45ef519314d65b75e5d183237 Author: Sven Grossmann Date: Tue Feb 27 21:59:38 2024 +0100 Loki: Fix failing test waiting for `Loading...` copy (#83547) fix monaco tests commit de75813d8df8b109c686ed9e579518a4c93b8800 Author: Ryan McKinley Date: Tue Feb 27 12:28:57 2024 -0800 Chore: Remove the deprecated Vector type (#83469) commit 5c60f4d468dc06c14f3fb16ad46cb44cdfe7fac8 Author: Leon Sorokin Date: Tue Feb 27 13:33:33 2024 -0600 VizTooltip: Remove use of LayoutItemContext (#83542) commit 0f1cefa94249040dd4b4b0f3d06968cd50e4dabb Author: Leon Sorokin Date: Tue Feb 27 12:07:55 2024 -0600 VizTooltip: Cleanup (#83462) commit a30617f8bd9a1930c8d8362137a0d89310b7cf91 Author: Jev Forsberg <46619047+baldm0mma@users.noreply.github.com> Date: Tue Feb 27 10:41:55 2024 -0700 Transformations: Add transformation builder tests and refactor (#83285) * baldm0mma/script_tests/ update jest.config paths * baldm0mma/script_tests/ refactor makefile content * baldm0mma/script_tests/ refactor file write commands * baldm0mma/script_tests/ create test module * baldm0mma/script_tests/ add to codeowners * baldm0mma/script_tests/ recenter content * baldm0mma/script_tests/ create buildMarkdownContent and update whitespace * baldm0mma/script_tests/ run build script * baldm0mma/script_tests/ update tests to remove make dep and node dep * baldm0mma/script_tests/ update cntent * baldm0mma/script_tests/ update makefile * baldm0mma/script_tests/ update buildMarkdownContent * baldm0mma/script_tests/ remove unused imports * baldm0mma/script_tests/ update test annotation * baldm0mma/script_tests/ prettier * baldm0mma/script_tests/ update tests * baldm0mma/script_tests/ update content * baldm0mma/script_tests/ update annos * baldm0mma/script_tests/ remove unused vars * baldm0mma/script_tests/ remove unused imports * baldm0mma/script_tests/ update annos * baldm0mma/script_tests/ update paths * Update scripts/docs/generate-transformations.test.ts Co-authored-by: Jack Baldry * baldm0mma/script_tests/ update comment --------- Co-authored-by: Jack Baldry commit 19743a7fef3aa8e0e2fc25e8b1eefb6ca603afc7 Author: Oscar Kilhed Date: Tue Feb 27 17:56:29 2024 +0100 Dashboard scenes: Change library panel to set dashboard key to panel instead of library panel. (#83420) * change library panel so that the dashboard key is attached to the panel instead of the library panel * make sure the save model gets the id from the panel and not the library panel * do not reload everything when the library panel is already loaded * Fix merge issue * Clean up commit 96dfb385caf6dda32b76c684408c625369a34a52 Author: 김은빈 Date: Wed Feb 28 01:39:51 2024 +0900 Grafana: Replace magic number with a constant variable in response status (#80132) * Chore: Replace response status with const var * Apply suggestions from code review Co-authored-by: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> * Add net/http import --------- Co-authored-by: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> commit a7fbe3c6dc88ed8190cb8559ada72b3112225324 Author: linoman <2051016+linoman@users.noreply.github.com> Date: Tue Feb 27 10:34:43 2024 -0600 Password Policy: Update frontend facing messages (#83227) * update tests * update error messages commit fc29182f16b05258dc7934d10d080e2d254893ec Author: Ashley Harrison Date: Tue Feb 27 16:34:00 2024 +0000 Chore: Remove React 17 peer deps (#83524) remove react 17 peer dep commit 0ad8c215aa642fad96572e4a290bc50e8b00286f Author: Jack Westbrook Date: Tue Feb 27 17:12:53 2024 +0100 Build: Update plugin-config webpack config (#83256) build(plugin-configs): update webpack config to match latest create-plugin config commit 30965d47a35b20a21cc07c627d2761ac96498fac Author: Sergej-Vlasov <37613182+Sergej-Vlasov@users.noreply.github.com> Date: Tue Feb 27 17:48:08 2024 +0200 DashboardScene: Update query variable definition on query change (#83218) * update scenes query variable definition on query change * add variable query definition test commit facb19fefb6159c167fb3f0d844476e229ede07b Author: Adam Yeats <16296989+adamyeats@users.noreply.github.com> Date: Tue Feb 27 16:46:29 2024 +0100 AzureMonitor: Fix mishandled resources vs workspaces (#83184) commit eb25b669c6a3aca8f5bda80f59762e1b5d6580e5 Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Tue Feb 27 16:22:24 2024 +0100 Alerting docs: update Alerting Provisioning (#83376) * Minor updates to Provisioning Index page * Add instructions to export other alerting resources * Edit example provisioning a `template` via config file * Add `Resource` column to the `Export API endpoints` table * Sort the `export` endpoint on the table in `Alerting Provisioning HTTP API` * Minor updates for clarity to `Use configuration files to provision` docs * Add `More examples` in Terraform Provisioning docs * File provisioning: rename `Useful Links` section to `More examples` * Minor grammar change * Update docs/sources/alerting/set-up/provision-alerting-resources/_index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/_index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/export-alerting-resources/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/export-alerting-resources/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/export-alerting-resources/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Address requested changes to `Export` docs * export: Minor grammar change * Update docs/sources/alerting/set-up/provision-alerting-resources/export-alerting-resources/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Fix `doc-validator` issue with relative link * Use patch fixed version of doc-validator that better supports docs/reference destinations Signed-off-by: Jack Baldry --------- Signed-off-by: Jack Baldry Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> Co-authored-by: Jack Baldry commit 6fab62739d9ca7b48c231fd546a19c8058805a40 Author: ismail simsek Date: Tue Feb 27 15:40:32 2024 +0100 InfluxDB: Fix escaping template variable when it was used in parentheses (#83400) escape properly regardless of the parentheses commit e5640248834e6ecc47c4611ecdfe4569d96fd861 Author: Kristina Date: Tue Feb 27 08:14:54 2024 -0600 Docs: change discussion link to issue link (#83434) * change discussion link to issue link * run prettier commit e068804a9e4844283322105d5be0987bca9ba924 Author: Giuseppe Guerra Date: Tue Feb 27 15:14:23 2024 +0100 Plugins: Angular deprecation: Fix AngularDeprecationNotice not being rendered on first page load (#83221) * Plugins: Angular deprecation: Wait for plugins to be inizialized before rendering AngularDeprecationNotice * use then * fix tests * mockCleanUpDashboardAndVariables.mockReset(); * Handle plugin not found * PR review feedback * Add comment * removed unnecessary return * PR review feedback * Use grafanaBootData * Removed comments * fix tests * Use config for hideDeprecation as well commit a8574226bb9a551efe203252345a8befb166bb56 Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Tue Feb 27 14:14:01 2024 +0000 Dashboards: Fixes issue where panels would not refresh if time range updated while in panel view mode (#83418) commit d6eefc73034a2db4101eb47131231179b6a10640 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 27 13:59:24 2024 +0000 Update dependency eslint to v8.57.0 (#83517) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 12e0d5bef39c320e7c048e42c3b09e010314b1cb Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 27 13:43:13 2024 +0000 Update dependency diff to v5.2.0 (#83511) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 93f18404c0223ab6759cde4bfcff0fb719b43406 Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Tue Feb 27 14:00:09 2024 +0100 Alerting docs: readjust titles (#83504) * Alerting docs: readjust titles * removes outdated videos * removes menutitle commit 98efb2cf32c1d6e31c2b34daf9e1f62a58ce3e1d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 27 12:52:47 2024 +0000 Update dependency browserslist to v4.23.0 (#83505) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit db131f33123c5c704fd8d073e71d1cc216c95e04 Author: Josh Hunt Date: Tue Feb 27 12:52:21 2024 +0000 E2C: Refactor api with dataWithMockDelay helper: (#83509) * E2C: Refactor api with dataWithMockDelay helper: * update codeowners * owner comment commit ac284def029eb2fec919e77ddbf515d8a3fafa99 Author: Saturn IDC <38378045+fly3366@users.noreply.github.com> Date: Tue Feb 27 20:29:58 2024 +0800 CI: Fix missing vendor dependencies (#83464) commit 92f53d3670c91de7bc02fca259d5a47fc2619480 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Tue Feb 27 12:28:17 2024 +0000 Tracing: Add node graph panel suggestion (#83311) * Add node graph panel suggestion * Update logic * Add comment * Check for correct fields * Simplify logic * Also check for viz type * Remove extra logic on boolean commit d94db905c710e4bdc31a67e8a97427b8728872e3 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 27 13:52:12 2024 +0200 Update dependency @grafana/scenes to v3.8.0 (#83502) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 5edd96ae77d01d194a97ec8e4a42190fa7a829f3 Author: Will Browne Date: Tue Feb 27 12:38:02 2024 +0100 Plugins: Refactor plugin config into separate env var and request scoped services (#83261) * seperate services for env + req * merge with main * fix tests * undo changes to golden file * fix linter * remove unused fields * split out new config struct * provide config * undo go mod changes * more renaming * fix tests * undo bra.toml changes * update go.work.sum * undo changes * trigger * apply PR feedback commit d04c579fc23c1c06b5c117c452b7cd7bd9cc8023 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 27 11:19:57 2024 +0000 Update dependency webpack to v5.90.3 commit 8d9921a5bad0df535ee7b4cd5f70d43a44da0cde Author: Gabriel MABILLE Date: Tue Feb 27 12:21:26 2024 +0100 RBAC: Fix delete team permissions on team delete (#83442) * RBAC: Remove team permissions on delete * Remove unecessary deletes from store function * Nit on mock * Add test to the database * Nit on comment * Add another test to check that other permissions remain commit ffae7d111c905cbd79d48a6a2a866148b79ab856 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 27 10:43:07 2024 +0000 Update dependency rudder-sdk-js to v2.48.2 commit 45f8e7f8cf97f5dd3939a2989def547e2bf97944 Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Tue Feb 27 11:56:11 2024 +0100 Revert "Revert "Alerting docs: rework create alert rules definition and topic"" (#83372) * Revert "Revert "Alerting docs: rework create alert rules definition and topic…" This reverts commit 2b4f1087712e82d17db8ce29adf4a40f5b76e0fc. * updates aliases * fixes after testing aliases * more alias updates * test silence alias * fix alias for mute timings * attempt alias fix * ran prettier * fixes more aliases * quick title update * fixes alias * Update docs/sources/alerting/configure-notifications/manage-contact-points/_index.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/manage-contact-points/_index.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/manage-contact-points/_index.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-oncall.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/template-notifications/reference.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/template-notifications/use-notification-templates.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/template-notifications/using-go-templating-language.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/create-silence.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/mute-timings.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/template-notifications/_index.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/manage-notifications/_index.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/create-notification-policy.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/manage-contact-points/_index.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-oncall.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/pager-duty.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/mute-timings.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/template-notifications/_index.md Co-authored-by: Jack Baldry * fix silence aliases * fix canonical * Update docs/sources/alerting/configure-notifications/template-notifications/create-notification-templates.md Co-authored-by: Jack Baldry * Update docs/sources/alerting/configure-notifications/create-notification-policy.md Co-authored-by: Jack Baldry --------- Co-authored-by: Jack Baldry commit ff28c042453a346d5de8747442b29cadae8cdee1 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 27 10:23:55 2024 +0000 Update dependency ol-ext to v4.0.15 commit 4d0fca443cb47c203262b5f5a0540b06b9983b55 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue Feb 27 10:25:32 2024 +0000 I18n: Download translations from Crowdin (#83390) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> commit 801107892bd35b9594d1fd92f68d262b9849f286 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 27 09:57:52 2024 +0000 Update dependency @types/react to v18.2.60 commit cc3b088b6c45e4f60afe760d5ec5b7ce6a55169e Author: Jo Date: Tue Feb 27 11:10:54 2024 +0100 Teams: Fix missing context in team service (#83327) fix missing context in team service commit 81eded16aa9b1b3662786ec3c96852fa96cbc8fc Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 27 00:38:15 2024 +0000 Update dependency react-virtualized-auto-sizer to v1.0.23 commit faaf4dc1e33ec47ba226d675b89ddfed747ffe88 Author: Ashley Harrison Date: Tue Feb 27 09:54:06 2024 +0000 E2C: Implement cloud auth flow (#83409) * implement cloud auth * move logic into MigrationTokenPane folder * update PDC link * add missed translations commit 2540842c95da9b5fd98f7c42de4834c1d9089f42 Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Mon Feb 26 21:25:26 2024 -0600 Histogram: Replace null values (#83195) commit 93fef224ae5d28100a47b37338c9ff18a52d0dc9 Author: Leon Sorokin Date: Mon Feb 26 19:41:39 2024 -0600 TimeSeries: Don't re-init chart with bars style on data updates (#83355) commit 2c3596854f21e20d5cc12eea4f2c78462c6221fb Author: Leon Sorokin Date: Mon Feb 26 19:18:40 2024 -0600 BarChart: TooltipPlugin2 (#80920) Co-authored-by: Adela Almasan commit 2ed8201f256831217e270e7753bb234f8b9d78dd Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 27 00:07:30 2024 +0000 Update dependency nanoid to v5.0.6 commit 99271441fb9a8a56c839e03302a784dbf6762720 Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Mon Feb 26 18:35:45 2024 -0600 Legend: Allow item copy in Table mode (#83319) commit 4db30754a691a56fb539192e48f3ce4f9f70b289 Author: Leon Sorokin Date: Mon Feb 26 18:22:36 2024 -0600 Graph (old): Migrate right y axis to TimeSeries via custom.axisPlacement (#83452) commit d4a932ae33c3e590c6bca7a0e1fcedbc0364d409 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 26 23:36:04 2024 +0000 Update dependency dompurify to v3.0.9 commit 77f9d29291733ec0e6d55a4a65b915c2f309080c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 26 22:58:09 2024 +0000 Update dependency @types/semver to v7.5.8 commit f2a21f383190d9e48e9aafc66a1aefbf507bb1bd Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 27 00:55:27 2024 +0200 Update dependency @types/react-window-infinite-loader to v1.0.9 (#83445) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 6a11bee6af91a7ff84068c7b7e672438af5ba83a Author: Yuri Tseretyan Date: Mon Feb 26 17:04:27 2024 -0500 Alerting: Deprecate max_annotations_to_keep and max_annotation_age in [alerting] configuration section (#83266) * introduce new config section [unified_alerting.state_history.annotations] and deprecate settings in [alerting] Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> commit bdeff840687d542d106e724f90960c43d2e69482 Author: Isabella Siu Date: Mon Feb 26 16:19:45 2024 -0500 CloudWatch: Refactor "getDimensionValuesForWildcards" (#83335) commit 6097ce5b61d9a064c3f5dbb5c34af6603e46e47a Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 26 21:03:44 2024 +0000 Update dependency @types/react-color to v3.0.12 commit 44639e1063bf5d61ca972d202d4690037c216e8c Author: Isabella Siu Date: Mon Feb 26 16:18:22 2024 -0500 CloudWatch: Fetch externalId from settings instead of env (#83332) commit f8dc40df5249b90cdfd3005479570a25e8ad3177 Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Mon Feb 26 16:07:27 2024 -0500 Chore: Point to xorm fork in go.mod (#83436) commit 0848e7dd69272e97a1b88ba5e6a61d4e5ea7f978 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 26 19:23:51 2024 +0000 Update dependency @types/react to v18.2.59 commit 0bfe9db668736ab9950f477562fe4fe0edd24649 Author: Isabella Siu Date: Mon Feb 26 15:59:54 2024 -0500 CloudWatch: Move SessionCache onto the instance (#83278) commit 58b0323bbb9dd100d3265e1010b4aa341dbd12cb Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Mon Feb 26 21:03:13 2024 +0100 Fix linting of docs file (#83441) commit 9709ac8b84114043a954b3ee15444c61ebad6208 Author: Misi Date: Mon Feb 26 20:59:49 2024 +0100 Auth: Revert provider list change (#83435) * Load auth.grafananet as the last provider * skip test commit f209a5a8b882079f45350cc4160ea1e5007216e5 Author: Takashi Idobe Date: Mon Feb 26 12:52:44 2024 -0500 fix typos (#83414) commit e9a1150b357e1d19dfa0f0519059b24fbc969f30 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 26 17:02:14 2024 +0000 Update dependency @types/node to v20.11.20 commit c6a16e55201fa26f33144df8820d4528ecd93e2d Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Mon Feb 26 17:03:36 2024 +0000 Scenes/VizPanel: Add support for panel repeat options (#81818) commit e295c38a6e2f0f37bf7accf70de21774ba0e6116 Author: Stephanie Hingtgen Date: Mon Feb 26 08:59:55 2024 -0800 Chore: fix shellchecks (#83421) commit b3363543eadeb1eb02c6b6df6cbe237992b90f60 Author: Nick Richmond <5732000+NWRichmond@users.noreply.github.com> Date: Mon Feb 26 11:56:40 2024 -0500 Prometheus: avoid unnecessary network requests (#83342) * perf: avoid unnecessary network requests * test: restore mocks to undo `jest.replaceProperty` * chore: mirror updates for Prometheus library commit 9f88a883032bf53205fd2f0b941d18727f67676e Author: Usman Ahmad Date: Mon Feb 26 17:53:38 2024 +0100 Docs/grafana helm (#80390) * added the helm project * added page metadata * added the intro section * fixed menuTitle * added section i.e. Setting up the Grafana Helm repository * added the deployment section * finished the deploying grafana section * completed access grafana section * updating changes * added persistent storage section * added debugging section * fixed typos * fixed headings * fixed numerious typos * Apply suggestions from code review looks good now !! Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Apply suggestions from code review Thanks for the changes. It looks much better now Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * fixed the suggested changes and fixed minor typos * Apply suggestions from code review thanks for the improvements. looks polished now!! Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * fixed download link * fixed typo * final adjustments * corrects spelling * makes prettier --------- Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> Co-authored-by: Chris Moyer commit 6183f7ea6cd9b70f0d3e722d10b2362a5e04815b Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 26 18:52:55 2024 +0200 Update dependency @types/lucene to v2.1.7 (#83422) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 949156e842c099f3b980bbd4251326d23ce3bf54 Author: ismail simsek Date: Mon Feb 26 17:28:37 2024 +0100 InfluxDB: Fix fetching tag values only for selected measurement (#83353) show tag values only for selected measurement commit af76b8937e74e3b8d3f41baa18fcf831bea3bc0b Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 26 18:16:01 2024 +0200 Update dependency @types/js-yaml to v4.0.9 (#83417) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 7b2fa28e0bebca4f7fbaa1f0ecdf94d80166c6fb Author: Mihai Doarna Date: Mon Feb 26 18:02:46 2024 +0200 SSO: update info field from SocialBase struct (#83407) update info field from SocialBase struct commit 2c38bc5f13afe7e98c59799a0a9519c4e37fccda Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 26 17:59:30 2024 +0200 Update dependency @types/d3-force to v3.0.9 (#83413) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit d122af6b97f2800660cd2487b2dcb75ffa9e6a74 Author: Ryan McKinley Date: Mon Feb 26 07:56:35 2024 -0800 Live: Improve the debug panel and add a devenv dashbaord (#83350) commit 715ea44466f112db2d04e664bb8474995f5da625 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 26 17:43:52 2024 +0200 Update dependency @types/common-tags to v1.8.4 (#83410) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 050cf85e273684779ffedfab5c506cbff927fbdf Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 26 15:27:16 2024 +0000 Update dependency @types/chance to v1.1.6 (#83401) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 432fffcf4ce4d0ae6b0179b15eadc84b31eb7be4 Author: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Mon Feb 26 16:10:15 2024 +0100 Docs: Update help keyboard shortcut (#83397) commit fb324fc8ac747e18d4804975ac5980c2590c2823 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Mon Feb 26 10:01:54 2024 -0500 datatrails: fix: indicate `__ignore_usage__` for preview queries (#82291) * fix: indicate __ignore_usage__ for preview queries * fix: further remove var that shouldn't be there * refactor: move previewPanel code and add tests * fix: use simpler `AdHocFiltersVariable` state Co-authored-by: Andre Pereira --------- Co-authored-by: Andre Pereira commit da4fb8b3eda822a1168aaff8af50d46c8cca05c7 Author: Ashley Harrison Date: Mon Feb 26 14:36:34 2024 +0000 E2C: Initial config to switch between cloud and onprem pages (#83380) hacky config to switch between cloud and onprem pages commit 7f8a87458f1ac893457dcdb78d0efa7721341a72 Author: Isabella Siu Date: Mon Feb 26 09:35:59 2024 -0500 CloudWatch: Remove unused handleGetRegions and regionCache (#83333) * remove unused handleGetRegions and regionCache * cleanup commit 617adb137c91ed81dfef935b7c996e72a26e5e58 Author: Misi Date: Mon Feb 26 15:33:29 2024 +0100 Auth: OAuth strategy load extra fields separately (#83408) Load extra fields separately commit 1f484fef9d24ee522e5574b4427f80dbcd83bf61 Author: Eric Leijonmarck Date: Mon Feb 26 14:31:21 2024 +0000 Team LBAC: Add doc description of lbac rule (#83384) * chore: add doc description of lbac rule * spellling * Update docs/sources/administration/data-source-management/teamlbac/_index.md Co-authored-by: Alexander Zobnin --------- Co-authored-by: Alexander Zobnin commit 4c0da354ca4f4f4f246c8228bb5428da965f7b55 Author: Gábor Farkas Date: Mon Feb 26 15:24:08 2024 +0100 update go.mod/sum/work (#83402) * update go.mod/sum/work * updated codeowners commit 005b2c0f25490777df3bd60498c65ee5e80005ff Author: Hugo Häggmark Date: Mon Feb 26 15:04:58 2024 +0100 Docs: Update HALL_OF_FAME.md (#79775) Co-authored-by: Jack Baldry commit 80447dd0ccd6bf79396356295791c9de5d65d23e Author: idafurjes <36131195+idafurjes@users.noreply.github.com> Date: Mon Feb 26 14:52:16 2024 +0100 Restructure cloudmigration service (#83211) * Restructure cloudmigation service * Adjust codewoners and wire * Comment out unused metrics commit 648abdbd0ea4451a102601dec874c770d635eb12 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 26 12:30:11 2024 +0000 Update dependency @swc/core to v1.4.2 commit 09c8e7ccc06274454b6f279dd9dfb19081569ec5 Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Mon Feb 26 14:48:27 2024 +0200 Scenes: Add new vizualisation functionality (#83145) * wip * tests + refactor ad panel func * PR mods commit 445aeca04b592dca02e4e22eca6a757c98b14407 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 26 12:07:22 2024 +0000 Update dependency @react-types/shared to v3.22.1 commit d02de5ddb9022b5e72229b45da358fac58c79d2b Author: Khushi Jain Date: Mon Feb 26 17:57:34 2024 +0530 Image Rendering: Add settings for default width, height and scale (#82040) * Add Image width & height * ability to change default width, height and scale * default ini * Update conf/defaults.ini Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> * Update pkg/setting/setting.go Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> * Update pkg/setting/setting.go Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> * Added docs, changed frontend * Update conf/defaults.ini Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> * Update conf/defaults.ini Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> * Update conf/defaults.ini Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> * Update pkg/api/dtos/frontend_settings.go Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> * Update pkg/api/frontendsettings.go Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> * Update pkg/api/render.go Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> * add query float 64 * Update packages/grafana-runtime/src/config.ts Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> * Update public/app/features/dashboard/components/ShareModal/utils.ts Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> * spacing * fix tests * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> --------- Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> commit 3f2eb8bd6a57d1449ae7e6a76a98e69a58d5e8d5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 26 11:34:01 2024 +0000 Update dependency @grafana/faro-web-sdk to v1.3.9 commit fc8a9aad223cf3eaf872d16bd522c80b211994c7 Author: Andres Martinez Gotor Date: Mon Feb 26 13:02:55 2024 +0100 Datasource API: Add config to ctx (#83386) commit 5756f365e3fc39696e01a1f2114d8befaeb0bcb2 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 26 11:12:41 2024 +0000 Update dependency @grafana/faro-web-sdk to v1.3.9 commit 171852546df92ae7de1e580ecac2d5060e40fd57 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 26 10:27:30 2024 +0000 Update dependency @grafana/faro-core to v1.3.9 commit f6fa248ac1d2e417c931a4be82acede3dde4840e Author: Laura Fernández Date: Mon Feb 26 12:08:00 2024 +0100 ReturnToPrevious: Add guidelines to `returnToPrevious` hook (#83219) commit 2fa4ac2a739dbaa228a6928ebe2ee2580710d7aa Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Mon Feb 26 11:39:26 2024 +0100 Tempo: Remove duplicated code (#81476) commit 80d6bf6da0b2f51f0197f9ae6eda7e7cc89c2d9d Author: Gabriel MABILLE Date: Mon Feb 26 11:29:09 2024 +0100 AuthN: Remove embedded oauth server (#83146) * AuthN: Remove embedded oauth server * Restore main * go mod tidy * Fix problem * Remove permission intersection * Fix test and lint * Fix TestData test * Revert to origin/main * Update go.mod * Update go.mod * Update go.sum commit d0679f0993f15d2f6a67f7563bb3d8ee33588413 Author: Serge Zaitsev Date: Mon Feb 26 11:27:22 2024 +0100 Chore: Add support bundle for folders (#83360) * add support bundle for folders * fix ProvideService in tests * add a test for collector commit ae00b4fb532fa34332682a6edeefefc48131b78d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 26 12:24:33 2024 +0200 Update dependency @grafana/aws-sdk to v0.3.2 (#83374) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit d5adcf350a3801bdb93a931ab3983699af98fe55 Author: Ashley Harrison Date: Mon Feb 26 10:10:35 2024 +0000 E2C: Add cloud landing page (#83316) * restructure and create cloud empty state * use new Grid prop and run i18n:extract commit dea0a0f6c80a3ee8808d5193f5054be8245e6fe5 Author: Selene Date: Mon Feb 26 10:18:19 2024 +0100 Kinds: Generate k8 resources without use kindys/thema (#83310) Generate k8 resources reading cue file directly instead of use thema/kindsys binding commit 1899afccb5ac62004d2fc1ccfc2ddf42018c7e1a Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Mon Feb 26 08:59:13 2024 +0000 Tempo: Add support for ad-hoc filters (#83290) * Add support for ad-hoc filters * Add tests * Update types commit af4382e4c28cf3667b5fc898cb2aed66489b4fda Author: Erik Sundell Date: Mon Feb 26 09:21:29 2024 +0100 plugin-e2e: Fix flaky test (#83370) fix mock issue commit 11ff4e7b8c2520b9b1022b800255289b553ec460 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Sun Feb 25 12:54:10 2024 -0500 fix: datatrails: start step is "metric select" after reinitializing from URL (#83179) fix: datatrails: start step is "metric select" If restoring from a saved data trail, or URL, the selection of metric becomes a second step. The first step is always without a metric, and displays the metric selection scene. commit 110028706a160af8c66672d06554f616d660d82c Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Fri Feb 23 19:36:23 2024 -0500 K8s: Update codegen to support new packages (#83347) --------- Co-authored-by: Charandas Batra commit 6e6b9a62a20ec644dcb19b968395c61296551eef Author: Leon Sorokin Date: Fri Feb 23 16:18:24 2024 -0600 VizTooltips: Use global portal (#81986) Co-authored-by: Adela Almasan commit e4276a4ede829cb2e30e6dd32a9dc4ca09845054 Author: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Fri Feb 23 15:03:35 2024 -0700 Dashboard-Scene: Show empty state after removing last panel (#83114) * Show empty state after removing last panel * betterer * Refactor isEmpty state update in DashboardScene.tsx * don't need viewPanelScene check * track isEmpty through a behavior * Fix test * Add test for empty state * minor fix * Refactor isEmpty check * Don't use const * clean up commit 240480ac9bfe90b3bcae2404f3b77c4814a656a2 Author: ismail simsek Date: Fri Feb 23 22:57:12 2024 +0100 Chore: Add a key prop to warning component to prevent "should have a unique key prop" error (#83340) add a key prop to prevent "should have a unique key prop" warning/error on the browser console commit dfeb33fe5594195f039ef83884002e4773a6b60e Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Fri Feb 23 16:51:00 2024 -0500 Docs: restructure Configure field overrides (#81833) * Removed view and delete overrides sections * Added examples heading and moved examples down one heading level * Added override rules section and removed rule definitions from task * Added supported visualizations section and table and docs ref links * Docs: edit Configure field overrides (#81834) * Formatted note * Added missing content and general edits * Updated screenshots and examples and general edits * Fix small formatting issues * Fixed links * Uploaded images to admin, updated image links, and removed local images * Swapped figure shortcode for simple Markdown commit e5a26a3f7c21d0e9c50b678be5e5431cf0cc0cfa Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Fri Feb 23 15:15:43 2024 -0500 K8s: Add apimachinery and apiserver packages (#83190) commit f4b432841b8849c6cd88466808619fef1c47d2b3 Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Fri Feb 23 14:28:30 2024 -0500 Chore: Add env var check to `make drone` (#83337) commit 2146f6ce1f622f0b03bc88720010d3ce1f58293d Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Fri Feb 23 14:10:51 2024 -0500 Chore: Update drone signature (#83334) commit 8895b43c8562799dd1f6e2252896e0427f4582b1 Author: Tania <10127682+undef1nd@users.noreply.github.com> Date: Fri Feb 23 20:06:43 2024 +0100 Chore: Remove docs and kinds report generators (#83277) * Chore: Remove codegen for docs * Remove kindsysreport commit 23963a1f34dab9e1169a9adf9bed8f06bef8fb86 Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Fri Feb 23 12:48:26 2024 -0500 Chore: Update wire to v0.6.0 using bingo (#83323) commit 2b4f1087712e82d17db8ce29adf4a40f5b76e0fc Author: Jack Baldry Date: Fri Feb 23 17:30:08 2024 +0000 Revert "Alerting docs: rework create alert rules definition and topic" (#83328) commit 18d1ced069afa5bc7189596a619dcac9004f6c5b Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Fri Feb 23 12:03:40 2024 -0500 style: datatrails: indicate prometheus data source (#83199) * style: indicate prometheus data source commit b3c0f69576d8e2328c72937dec9fbd43c5e95e5a Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Fri Feb 23 12:03:10 2024 -0500 style: datatrails: use Tag on cards (#83198) * style: datatrails: use Tag on cards commit a97562906c350e434ca07019ab1f5d2a7e2d646d Author: Jev Forsberg <46619047+baldm0mma@users.noreply.github.com> Date: Fri Feb 23 10:00:24 2024 -0700 Table: Add ability for Table to render Standard Options "No value" value when DataFrames or field values are empty (#82948) * baldm0mma/no_value_message/ add fieldConfig to Table props * baldm0mma/no_value_message/ add noValuesDisplayText to table props * baldm0mma/no_value_message/ add fieldConfig to tablePanel * baldm0mma/no_value_message/ add tests * baldm0mma/no_value_message/ update test values * baldm0mma/no_value_message/ update args in tests * baldm0mma/no_value_message/ update with NO_DATA_TEXT const commit 92fa868a77e91ab517d7ff81ef683a89319a1342 Author: Kristina Date: Fri Feb 23 10:55:44 2024 -0600 remove oss from security config docs (#82936) commit 57114a4916a1d61e6649a4748fcd3ee5de3532f1 Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Fri Feb 23 17:52:19 2024 +0100 Alerting docs: rework create alert rules definition and topic (#83220) * Alerting docs: rework create alert rules definition and topic * ran prettier * corrects note * adds configure notifications section * update branch * corrects links * parts of alert rule creation * Update docs/sources/alerting/configure-notifications/_index.md Co-authored-by: Pepe Cano <825430+ppcano@users.noreply.github.com> * moved section * updates recording rule steps * gets rid of configure integrations topic * deletes configure integrations topic * deletes links * ran prettier * Include contact point links * phase 2 sorting out manage notifications * manage notification changes * manage notification updates * finishing touches * fixes links * link fixes * more link fix * link fixes * ran prettier --------- Signed-off-by: Jack Baldry Co-authored-by: Pepe Cano <825430+ppcano@users.noreply.github.com> Co-authored-by: Jack Baldry commit 1477e658ec0795d1a41bcd1da7c377125f05b72d Author: Andreas Christou Date: Fri Feb 23 16:15:28 2024 +0000 CI: Add retry for yarn install (#83317) Add retry for yarn install commit 19b1e71feefbae21bbe76a64fad9724ce876b9ed Author: Ieva Date: Fri Feb 23 16:13:21 2024 +0000 IP range AC for data sources: compare the base of the URL only (#83305) * compare the base of the URL and ignore the path * change the logic to compare scheme and host explicitly * fix the test commit 65534e62a64b312a86eaf8817273d1d390d29ae0 Author: Ieva Date: Fri Feb 23 16:03:23 2024 +0000 RBAC: add kind, attribute and identifier to annotation permissions during the migration (#83299) add kind, attribute and identifier to annotation permissions during the migration commit b2601d71d5bf025280169698a875f9a28ca55feb Author: Jo Date: Fri Feb 23 16:53:37 2024 +0100 IAM: Remove fully rolled out feature toggles (#83308) * remove anon stat ft * remove split scope flag * remove feature toggle from frontend commit bbe9c8661a75d514f4a76fe43dc2f2f34413b1d2 Author: Kristina Date: Fri Feb 23 09:44:21 2024 -0600 Explore: Use rich history local storage for autocomplete (#81386) * move autocomplete logic * Tests * Write to correct history * Remove historyUpdatedAction and related code * add helpful comments * Add option to mute all errors/warnings for autocomplete * Add back in legacy local storage query history for transition period * Move params to an object for easier use and defaults * Do not make time filter required * fix tests * change deprecation version and add issue number commit ea8b3267e55f8bba95769bc7adf9b6d427c7ab86 Author: Torkel Ödegaard Date: Fri Feb 23 16:22:34 2024 +0100 DataTrails: Sticky controls (#83286) * DataTrails: Sticky controls * Update commit 49e18a3e7a698fcc7b5ec2b2034824bde05f3a42 Author: Ashley Harrison Date: Fri Feb 23 15:00:24 2024 +0000 Chore: replace `react-popper` with `floating-ui` in `Popover` (#82922) * replace react-popper with floating-ui in Popover * update HoverCard * fix unit tests * mock useTransitionStyles to ensure consistent unit test results commit 83c01f9711ffc8be87facaddae4e7c553b44b65d Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Fri Feb 23 10:00:10 2024 -0500 datatrails: detect if current trail state is bookmarked (#83283) * fix: detect if current trail state is bookmarked commit 604e02be1520612578a46eefa96e7853d57be8a4 Author: Ashley Harrison Date: Fri Feb 23 14:25:44 2024 +0000 Grid: Add `alignItems` prop (#83314) add alignItems props to Grid commit 1631e4130393a2ccea8159ac99e8ebf1d0a726a0 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Fri Feb 23 14:03:35 2024 +0000 Tempo: Add template variable interpolation for filters (#83213) * Interpolate template variables in filters * Add tests commit 49d3cb29eb3c0caac8e2cd549919430146be799d Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Fri Feb 23 08:54:24 2024 -0500 Chore: Add go workspace (#83191) --------- Co-authored-by: ismail simsek commit 43b186a52ed3bb9a6a2e3fe6d26588387a001c66 Author: Daniel Reimhult <118008016+raymalt@users.noreply.github.com> Date: Fri Feb 23 14:24:12 2024 +0100 Unit: Add SI prefix for empty unit (#79897) * Unit: Add SI prefix for empty unit * Units: Change name from SI prefix to SI short commit 7730a38474bbdb622b26cbc7f74acec6fbda4ed1 Author: Andreas Christou Date: Fri Feb 23 13:11:04 2024 +0000 CI: Remove inline-builds flag (#83306) Remove inline-builds commit 3e456127cb4539587abb46676457df1abc3b767f Author: Erik Sundell Date: Fri Feb 23 12:39:30 2024 +0100 E2E: Add plugin-e2e scenario verification tests (#79969) * add playwright test and plugin-e2e * run tests in ci * add ds config tests * add panel edit tests * add annotation test * add variable edit page tests * add explore page tests * add panel plugin tests * add readme * remove comments * fix broken test * remove user.json * remove newline in starlark * fix lint issue * ignore failure of playwright tests * update code owners * add detailed error messages in every expect * update message frame * fix link * upload report to gcp * echo url * add playwright developer guide * bump plugin-e2e * add custom provisioning dir * update plugin-e2e * remove not used imports * fix typo * minor fixes * use latest version of plugin-e2e * fix broken link * use latest plugin-e2e * add feature toggle scenario verification tests * bump version * use auth file from package * fix type error * add panel data assertions * rename parent dir and bump version * fix codeowners * reset files * remove not used file * update plugin-e2e * separate tests per role * pass prov dir * skip using provisioning fixture * wip * fix permission test * move to e2e dir * fix path to readme * post comment with report url * format starlark * post comment with report url * post comment with report url * fix token * make test fail * fix exit code * bump version * bump to latest plugin-e2e * revert reporting message * remove comments * readding report comment * change exit code * format starlark * force test to fail * add new step that posts comment * fix link * use latest playwright image * fix failing test * format starlark * remove unused fixture Co-authored-by: Marcus Andersson --------- Co-authored-by: Marcus Andersson commit b25667223c77f8acdb6225d9454525f4416b1b72 Author: Ashley Harrison Date: Fri Feb 23 11:38:36 2024 +0000 Box: add `direction` prop to `Box` (#83296) add "direction" prop to Box commit a0353b237af45c8242457a9ba97ee607076877a5 Author: George Robinson Date: Fri Feb 23 11:25:43 2024 +0000 Alerting: Update swagger specs (#83260) commit a3c73ae7c46b06705726c63a885974c594fc9ea7 Author: Ashley Harrison Date: Fri Feb 23 11:18:09 2024 +0000 E2C: Add initial empty state (#83232) * update subtitle * add empty state * rename to InfoPaneLeft/Right * use ' * add "direction" prop to Box * update subtitle * Revert "add "direction" prop to Box" This reverts commit 99f82a27c732541fe9ca0f1dc87e6db0f6eb72fc. commit c63456612eeff8538772dce8a83dc0d56bdfb1ea Author: Ashley Harrison Date: Fri Feb 23 10:45:04 2024 +0000 Chore: replace `react-popper` with `floating-ui` in time pickers (#82640) * replace react-popper with floating-ui in RelativeTimeRangePicker * replace react-popper with floating-ui in DateTimePicker commit fa37d8467f300854f16a77a6ef0cc03f9554fe5d Author: Ben Donnelly Date: Fri Feb 23 10:14:54 2024 +0000 fix(build): make python and gcc are needed for some yarn dependencies… (#83228) fix(build): make python and gcc are needed for some yarn dependencies to build commit d4bc0fe01855f54343378fc0efd6a754c10105b0 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Feb 23 11:56:38 2024 +0200 Update dependency stylelint to v16 (#83252) * Update dependency stylelint to v16 * remove stylelint-config-prettier since it's no longer necessary --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit ad80518db07bcb5a0c3eb55e3015a25f529ddf6e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Feb 23 11:43:16 2024 +0200 Update dependency webpack-dev-server to v5 (#83258) * Update dependency webpack-dev-server to v5 * update webpack.hot config (is this even used anymore?) --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit 217154f85d8a5fad853e98d6044a88612a6b8b53 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Feb 23 09:20:02 2024 +0000 Update dependency stylelint-config-sass-guidelines to v11 (#83253) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit cc9ff3f8c9890b794493cca28b6b3a756f641a89 Author: Konrad Lalik Date: Fri Feb 23 09:19:16 2024 +0100 Alerting: Add Loki ASH tests checking proper rendering of timeline chart component (#83235) Add Loki ASH tests checking proper rendering of timeline chart component commit a4cc4179c87aafd8378d8737a84096f265a0c644 Author: Kyle Cunningham Date: Thu Feb 22 22:28:15 2024 -0600 Table: Fix units showing in footer after reductions without units (#82081) * Add preservesUnits property to reducer registry items * Hide units when appropriate * Prettier * some code cleanup * Prevent error when no stat is selected. --------- Co-authored-by: nmarrs commit 49299ebd9cf8e37556f32043b7b91ca3b0e68868 Author: Alvaro Huarte Date: Fri Feb 23 05:20:56 2024 +0100 Table Component: Improve text-wrapping behavior of cells (#82872) * Fix text-wrapping of cells in Tables * Set wordbreak on hover for long texts without spaces commit 62163f8844dcb238f5736b0b45fe9bb3fdf56ee7 Author: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> Date: Thu Feb 22 17:22:00 2024 -0500 [DOC] Tempo data source: fix broken link and clarify traces to profile (#83135) * Update Tempo data source to fix broken link * Correct profiles content * Move explanation table up * Chagnes from prettier * Resolve conflict commit 455bccea2a9932b84e5e6e66a7c4041cdc99611b Author: Ezequiel Victorero Date: Thu Feb 22 16:56:31 2024 -0300 Scenes: Render old snapshots (#82277) commit 086e60488fef9e76781e37b1d2af7815f28a104d Author: Torkel Ödegaard Date: Thu Feb 22 20:11:09 2024 +0100 DataTrails: Remove the adhoc filters label (#83237) commit d503107d7a7cb2f64f92bfb97c2b23ee6df9c35a Author: Andreas Christou Date: Thu Feb 22 18:59:06 2024 +0000 Docker: Add workaround for building on `arm64` (#83242) Add workaround for building on arm64 commit 28fa2849dfa0659add5c1224a1be2963631478c1 Author: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu Feb 22 10:34:21 2024 -0700 Dashboard-Scene: View panel as table in edit mode (#83077) * WIP: working functionality * betterer * Fully working: Alerts show up, toggling table view doesn't update viz type in options pane * betterer * improve * betterer * Refactoring a bit * wrong step * move data provider to vizPanel * Update * update * More refactorings * Fix InspectJsonTab tests (except 1); remove obsolete PanelControls * Fixed test * Update * minor fix --------- Co-authored-by: Torkel Ödegaard commit a564c8c4398fe2a763712fa54e21885017151b45 Author: George Robinson Date: Thu Feb 22 16:57:20 2024 +0000 Alerting: Keep order of time and mute time intervals consistent (#83257) commit 1ef2e8d3668eb99942086d20afb42dbe0cea3123 Author: Misi Date: Thu Feb 22 17:33:27 2024 +0100 Chore: Allow self-serve for ssoSettingsApi feature toggle (#83140) * Set AllowSelfServe to true ssoSettingsApi * Align ft registry validation with latest changes commit 18ec6fcdd326c5c4b0a9a998d1700a62d4605754 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 22 18:26:01 2024 +0200 Update dependency sass-loader to v14 (#83255) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 1ed1242358c0d3639724e57d86d45903e7a779b2 Author: George Robinson Date: Thu Feb 22 15:58:56 2024 +0000 Alerting: Basic support for time_intervals (#83216) This commit adds basic support for time_intervals, as mute_time_intervals is deprecated in Alertmanager and scheduled to be removed before 1.0. It does not add support for time_intervals in API or file provisioning, nor does it support exporting time intervals. This will be added in later commits to keep the changes as simple as possible. commit 13cb09b17871497fc41ad28a378b1d25fb87a3da Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 22 15:52:20 2024 +0000 Update dependency postcss-loader to v8 (#83161) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 0b62c15e4b894c5f91ae0678e73222232e490f7f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 22 15:48:37 2024 +0000 Update dependency rc-drawer to v7 (#83162) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit ae77fe36021024de869398dce923383a3b128c93 Author: Gábor Farkas Date: Thu Feb 22 16:47:03 2024 +0100 postgres: do not use unexported grafana-core config (#83241) * postgres: do not use unexported grafana-core config * fixed wrong value commit 5f89c69b66e0bd19c415b6133dbc87ba96fddff5 Author: Jack Westbrook Date: Thu Feb 22 16:22:23 2024 +0100 Chore: Bump Lerna 8.x.x (#83233) * chore(lerna): bump lerna to 8.x.x * chore(lerna): run lerna repair command to update lerna.json * ci(drone): use raw output (no quotes) when updating package.json version * ci(drone): update config file commit 5f41cc632ea404f5b6adf6b60a021fc75182d63c Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Thu Feb 22 09:34:16 2024 -0500 Docs: update import troubleshoot dashboards links (#83124) * Updated links to former manage dashboards content * Removed links to manage dashboards and added export content to Sharing page * Replaced grafana links with cloud docs links * Removed trailing slash from link * trigger CI --------- Co-authored-by: Jack Baldry commit 15d83960ec4664a0bee7098d54cde94b8bb5d1f1 Author: Jack Westbrook Date: Thu Feb 22 15:30:20 2024 +0100 Chore: Align usage of tsconfig in yarn workspaces to 1.3.0-rc1 (#83160) chore(tsconfig): align all usage in workspaces to 1.3.0-rc1 commit ee5dc14e171eec6c56248d32d75890659bc0e195 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Thu Feb 22 13:30:41 2024 +0000 Tempo: Remove trace to metrics feature toggle (#82884) * Remove trace to metrics feature toggle * Fix after merge commit 2a1873f03815f5ea26da31be7e5157a2c3f171a4 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Thu Feb 22 14:29:57 2024 +0100 Alerting: Fix saving evaluation group. (#83188) fix saving evaluation group commit 3ba33fe27834cd62f53799945c5d65fe3fdd54a4 Author: Gábor Farkas Date: Thu Feb 22 14:22:14 2024 +0100 mysql: do not use unexported grafana-core config (#83062) * mysql: do not use unexported grafana-core config * updated test commit 74c0463cd347e8b7a239dbb36ec6a2ef4926bc8e Author: Marie Cruz Date: Thu Feb 22 12:27:29 2024 +0000 Docs: update grafana fundamentals (#83075) * docs: update grafana fundamentals * fix: lint issues * docs: added more content on grafana fundamentals tutorial commit dbbbfa282ded4c751ff1792a85cab885afe8d0f4 Author: Leon Sorokin Date: Thu Feb 22 06:14:53 2024 -0600 StateTimeline: Properly type tooltip prop, remove ts-ignore (#83189) commit 0dcdfc261b781df8c604ccac904dc500a63d327b Author: Jack Westbrook Date: Thu Feb 22 12:31:40 2024 +0100 Monaco Editor: Load via ESM (#78261) * chore(monaco): bump monaco-editor to latest version * feat(codeeditor): use esm to load monaco editor * revert(monaco): put back previous version * feat(monaco): setup MonacoEnvironment when bootstrapping app * feat(monaco): load monaco languages from registry as workers * feat(webpack): clean up warnings, remove need to copy monaco into lib * fix(plugins): wip - remove amd loader workaround in systemjs hooks * chore(azure): clean up so QueryField passes typecheck * test(jest): update config to fix failing tests due to missing monaco-editor * test(jest): update config to work with monaco-editor and kusto * test(jest): prevent message eventlistener in nodeGraph/layout.worker tripping up monaco tests * test(plugins): wip - remove amd related tests from systemjs hooks * test(alerting): prefer clearAllMocks to prevent monaco editor failing due to missing matchMedia * test(parca): fix failing test due to undefined backendSrv * chore: move monacoEnv to app/core * test: increase testing-lib timeout to 2secs, fix parca test to assert dom element * feat(plugins): share kusto via systemjs * test(e2e): increase timeout for checking monaco editor in exemplars spec * test(e2e): assert monaco has loaded by checking the spinner is gone and window.monaco exists * test(e2e): check for monaco editor textarea * test(e2e): check monaco editor is loaded before assertions * test(e2e): add waitForMonacoToLoad util to reduce duplication * test(e2e): fix failing mysql spec * chore(jest): add comment to setupTests explaining need to incresae default timeout * chore(nodegraph): improve comment in layout.worker.utils to better explain the need for file commit 0dbf2da2549d20848266e91dd367d15cb9f17eec Author: Josh Hunt Date: Thu Feb 22 11:12:00 2024 +0000 MigrateToCloud: Add API interface for frontend (#83215) Add RTK Query mocks commit 5561ace46750dd25467736375481ca143b693288 Author: Ashley Harrison Date: Thu Feb 22 11:04:18 2024 +0000 Navigation: Scroll the active menu item into view when menu is an overlay (#83212) scroll the active menu item into view even when not docked commit 9c42826c30eafcc929d3c16e5efd995e5759b0ec Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Thu Feb 22 11:31:59 2024 +0100 Alerting docs: fix broken link and apply the Writer toolkit guideline to other links (#83209) * Fix `/docs/reference` cannot be used within admonitions * Remove `relref` links to apply Writers toolkit guidelines commit a35dc9ad4e3886bc97ea1fc11319c3554ec1c140 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Thu Feb 22 10:49:51 2024 +0100 Allow build and release process for Zipkin plugin (#83116) commit 8852b1dcc573ca3a201ac072a92f1a3227065e1b Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Thu Feb 22 10:26:50 2024 +0100 Run analysis steps only on `grafana/grafana` (#83185) commit b02183e9ac415c2305875f68597ecea19e4e5a24 Author: Konrad Lalik Date: Thu Feb 22 10:16:27 2024 +0100 Alerting: Fix dashboard nav drawers disappearing (#82890) Add DashNav modal renderer to handle modals rendered from Toolbar buttons commit 3dea5e30c38aa0900197d402852fa590fe3f4507 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Thu Feb 22 10:04:58 2024 +0100 Yarn: Clean up PnP fragments (#83138) commit 9282c7a7a409e19aa0295980fe97eba556143644 Author: Klesh Wong Date: Thu Feb 22 17:02:31 2024 +0800 AuthProxy: Invalidate previous cached item for user when changes are made to any header (#81445) * fix: sign in using auth_proxy with role a -> b -> a would end up with role b * Update pkg/services/authn/clients/proxy.go Co-authored-by: Karl Persson * Update pkg/services/authn/clients/proxy.go Co-authored-by: Karl Persson commit 8d68159b523f96aadbb8c811d3125577df0a89db Author: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> Date: Thu Feb 22 09:34:14 2024 +0100 Public Dashboards: Disable email-sharing when there is no license (#80887) * PublicDashboards: Disable email-shared dashboards when feature is disabled * fix pubdash creation when it was email-shared * add feature name const in OSS * update doc * Update service.go * fix test & linter * fix test * Update query_test.go * update tests * fix imports * fix doc linter issues * Update docs/sources/administration/enterprise-licensing/_index.md * fix after merge commit cfcf03bf7af4f1928ed728aa38eb86c874ba46cc Author: Pablo <2617411+thepalbi@users.noreply.github.com> Date: Wed Feb 21 16:57:31 2024 -0300 CloudWatch: Add Firehose kms-related metrics (#83192) Add KMS firehose metrics commit 030b83d8f2278a7d46283c9c474636d530ff348c Author: Josh Hunt Date: Wed Feb 21 18:02:37 2024 +0000 NestedFolderPicker: Seperate state from Browse Dashboards (#82672) * initial very very early stab at isolated state for nested folder picker * more * complete state rework. still need to do search * tidy up some comments * split api hook into seperate file, start to try and get search results back (its not working) * Fix loading status * Reset files * cleanup * fix tests * return object * restore hiding items * restore * restore * remove those comments * rename hooks * rename hooks * simplify selectors - thanks ash!!! * more ci please? commit d883af08dd58257d3ec0658971c5cab5598a147a Author: Jev Forsberg <46619047+baldm0mma@users.noreply.github.com> Date: Wed Feb 21 10:26:49 2024 -0700 Dependencies(Dev): Add @types/eslint-scope (#83118) baldm0mma/add_eslint-scope_dep/ add dep and yarn add commit ac88cfbdbb8123d2a60353ee719c0105c759f285 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed Feb 21 18:23:53 2024 +0200 I18n: Download translations from Crowdin (#83182) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> commit 5132828a2bde3eb2d03793ccf7bf1374d13d48f7 Author: Alex Khomenko Date: Wed Feb 21 17:14:49 2024 +0100 Chore: Remove Form usage from notification policies (#81758) * Chore: Replace Form component usage in EditDefaultPolicyForm.tsx * Chore: Replace Form component usage in EditNotificationPolicyForm.tsx * Remove ts-ignore commit 5460d75e74b0f6f477deed177683a2160477a8ba Author: Ivan Ortega Alba Date: Wed Feb 21 16:57:53 2024 +0100 QueryVariableEditor: Select a variable ds does not work (#83144) commit db4b4c4b0a3b01db11366f0754b5e920bb53934c Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Wed Feb 21 10:52:49 2024 -0500 datatrails: refactor: auto query generator, use more suitable queries (#82494) * refactor: datatrails auto query generator * fix: use "per-second rate" instead commit c75502dd8c03db989de9b2e0711e392bc1dcc89a Author: Ashley Harrison Date: Wed Feb 21 15:50:13 2024 +0000 Cloud migration UI: Add `migrate-to-cloud` route (#83072) * add migrate-to-cloud route * fix chunk name * gate route behind feature toggle * update permission checks commit e1edec02d01e5b0ca34972d6d040f84ded91925f Author: Sergej-Vlasov <37613182+Sergej-Vlasov@users.noreply.github.com> Date: Wed Feb 21 17:16:57 2024 +0200 DashboardScene: Validate variable name in scenes variable editor (#82415) * add variable name validation * adjust variable name validation logic * move variable name validation logic to model * add tests for onValidateVariableName * extract variable name validation itest into separate describe commit 7e8b679237328c6df9d6f52d61751b980c38b902 Author: linoman <2051016+linoman@users.noreply.github.com> Date: Wed Feb 21 08:40:18 2024 -0600 OAuth: Improve domain validation (#83110) * enforce hd claim validation * add tests commit 1394b3341fd729db069d244aec227b9b2cd070dc Author: Khushi Jain Date: Wed Feb 21 20:07:40 2024 +0530 Chore: Remove gf-form from datasource/loki and datasource/cloud-monitoring (#80117) * Chore: Remove gf-form from datasource/loki * remove query field * update betterer commit 028d0d0c2cdc490303d82717b2a55e9fd6fee645 Author: Carl Bergquist Date: Wed Feb 21 15:32:57 2024 +0100 Rename scope.name to scope.title since name exists in metadata. (#83172) name is part of metadata which is confusing Signed-off-by: bergquist commit 2258e6bd16b830c12af7fc830a253b02d171418f Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Wed Feb 21 13:49:41 2024 +0000 Traces: Add traces panel suggestion (#83089) * Add traces panel suggestion * Render suggestion * Update styling * Update styling commit 9bbb7f67e0d2bb884d40d0064de0dfdb5c2f3966 Author: Alexander Zobnin Date: Wed Feb 21 16:32:54 2024 +0300 Chore: Move store interface to top level (#83153) * Chore: Move store interface to top level * Update store mock commit be71277d33e1e7e1bfe3a4ca2922a33aa02b6c90 Author: Ben Sully Date: Wed Feb 21 13:25:00 2024 +0000 Plugins: fix loading of modules which resolve to Promises (#82299) * Plugins: fix loading of modules which resolve to Promises Prior to this commit we expected the default export of a plugin module to be an object with a `plugin` field. This is the case for the vast majority of plugins, but if a plugin uses webpack's `asyncWebAssembly` feature then the default export will actually be a promise which resolves to such an object. This commit checks the result of the SystemJS import to make sure it has a `plugin` field. If not, and if the `default` field looks like a Promise, it recursively attempts to resolve the Promise until the object looks like a plugin. I think this may have broken with the SystemJS upgrade (#70068) because it used to work without this change in Grafana 10.1, but it's difficult to say for sure. * Use Promise.resolve instead of await to clean up some logic * Override systemJSPrototype.import instead of handling defaults inside importPluginModule * Add comment to explain why we're overriding systemJS' import Co-authored-by: Jack Westbrook --------- Co-authored-by: Jack Westbrook commit 809c1eaddb7f66260c799b402486a19748c65108 Author: Ryan McKinley Date: Wed Feb 21 08:04:15 2024 -0500 Snapshots: delete from same org (#83111) delete in org commit 49a3553b9444a8628715b2ef69777fa8c0cf41ad Author: Esteban Beltran Date: Wed Feb 21 13:31:16 2024 +0100 Sandbox: Fix custom variable query editors not working inside the sandbox (#83152) commit 68fe045ec7433feee025f3e7c3f26c5f78cbb7d6 Author: Giuseppe Guerra Date: Wed Feb 21 12:57:40 2024 +0100 Plugins: Remove pluginsInstrumentationStatusSource feature toggle (#83067) * Plugins: Remove pluginsInstrumentationStatusSource feature toggle * update tests * Inline pluginRequestDurationWithLabels, pluginRequestCounterWithLabels, pluginRequestDurationSecondsWithLabels commit b6b5935992f4aa8a962ab075ff780855cb66aeff Author: Dan83 Date: Wed Feb 21 12:56:04 2024 +0100 Chore: Remove Form usage in NewFolderForm components (#83028) * Chore: Remove Form usage from NewFolderForm * Chore: Remove Form usage from NewFolderForm * Chore: Remove Form usage from NewFolderForm * Replace HorizontalGroup with Stack * add Default Values and launch prettier commit bfdb4625a052a0a2220b9a03287bc1a798f132eb Author: Torkel Ödegaard Date: Wed Feb 21 12:34:19 2024 +0100 Grafana/UI: Replace Splitter with useSplitter hook and refactor PanelEdit snapping logic to useSnappingSplitter hook (#82895) * Hook refactor * Update * Test * Update * Both directions work * fixes * refactoring * Update * Update * update * Remove consolo.log * Update * Fix commit 6b31b1fc037a473d9a3a3d7f2b084ae45473392a Author: Laura Fernández Date: Wed Feb 21 12:28:57 2024 +0100 TimeRangeList: absolute time range list not scrollable (#82887) commit d091e4c264fa66dd8387628ff3f09c9d89c334cf Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Wed Feb 21 12:21:44 2024 +0100 Alerting docs: adds simplified alert routing (#82158) * Alerting docs: adds simplified alert routing * adds to preview section * adds numbering * adds indent * deletes fullstop * ran prettier * adds feature toggle notes * fixes spelling error commit a62dccb0c045a0c0cd581e8ed55d0568e80e96a4 Author: Gábor Farkas Date: Wed Feb 21 11:32:10 2024 +0100 plugins: add more configuration parameters to the plugin-config (#83060) * envvars: add more configs * made row-limit optional * more consistent naming commit 778d80f922aa3a36d8dc45649a2061ad294288b0 Author: Jack Westbrook Date: Wed Feb 21 11:02:16 2024 +0100 Chore: Remove React from o11y dependencies and align zipkin and tempo dependencies (#83143) chore(zipkin): remove react from o11y dependencies, tidy tempo and zipkin dependencies commit 1352072338a063c77668a187402729685f1ff713 Author: Wilbert Guo Date: Wed Feb 21 01:55:06 2024 -0800 Cloudwatch: Fix filter button issue in Variable Editor (#83082) commit 4720c99bd513d9ba2dde350654bf6be5aa1c9839 Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Wed Feb 21 10:41:12 2024 +0100 Alerting docs: Fix migrating alert links (#83141) * Alerting docs: fixes migrating links * Fixes underscores and stars * Corrects numbering * ran prettier * Fix links Signed-off-by: Jack Baldry * Update docs/sources/alerting/set-up/migrating-alerts/_index.md Co-authored-by: Jack Baldry --------- Signed-off-by: Jack Baldry Co-authored-by: Jack Baldry commit e7a1ecca289a17a56d6c175a9ff073e1ae61ee1a Author: Alexander Zobnin Date: Wed Feb 21 12:30:26 2024 +0300 Annotations: Improve query performance when using dashboard filter (#83112) * Annotations: Improve query performance when using dashboard filter * Add dashboard id filter commit 620cc6dced923c75284b682fd4cc5215d879d107 Author: Eric Leijonmarck Date: Wed Feb 21 09:26:09 2024 +0000 Team LBAC: Add epilogue to permissions (#82523) * add epilogue to permissions * gs linting fix * update docs * Revert "update docs" This reverts commit 0902ce2d8a2dc3f402baa63ac0e9515c603231d0. commit d48bf34227ca7dc2061e8637e0b3e6a62b1a0635 Author: Andrej Ocenas Date: Wed Feb 21 10:14:29 2024 +0100 DataFrame: Improve typing of arrayToDataFrame helper and fix null/undefined handling (#83104) commit 4b2ef3616571445c1bd1cf33fc596a77f03c4c34 Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Wed Feb 21 09:39:13 2024 +0100 Alerting docs: fixes oncall broken links (#83139) commit 64e0a4282ef57d0d3efdf989fec67d21fbeac524 Author: Torkel Ödegaard Date: Wed Feb 21 09:38:42 2024 +0100 DataQuery: Track panel plugin id not type (#83091) commit 0bd009fb53040e9a4fdef010526d08ab4e75bb18 Author: Charandas Date: Tue Feb 20 13:00:02 2024 -0800 K8s: fix nil deref from dummy factory for API Server options (#83132) commit 16dee3cf1c540f9cffbc1a01aa275fb15a143300 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Tue Feb 20 21:17:40 2024 +0100 Alerting: Protect possible undefined (#83128) Protect possible undefined commit f2c0309d714361816466fe6d1161a9cd1fe9f352 Author: Sven Grossmann Date: Tue Feb 20 21:07:47 2024 +0100 Logs Panel: Add option extra UI functionality for log context (#83123) * use ref rather than state * add `getLogRowContextUi` to panel commit 53c19e4988b16b54f788ffdc94e5c5d4218288b9 Author: Carl Bergquist Date: Tue Feb 20 20:40:31 2024 +0100 move codespell check into /docs (#83109) Signed-off-by: bergquist commit 8f3d49687e040ea98ae56a684cc38d01b4c562f1 Author: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Tue Feb 20 19:02:36 2024 +0000 Release: Bump version to 11.0.0-pre (#83119) "Release: Updated versions in package to 11.0.0-pre" Co-authored-by: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> commit 8162ec296016d826cd4cc32690fc01c21ecd54d6 Author: Jan Garaj Date: Tue Feb 20 19:46:29 2024 +0100 CloudWatch: Update AWS/ES metrics (#83037) CloudWatch: update AWS/ES metrics commit ef23148b36f16f0efe119b6a740b97870ce61502 Author: Jan Garaj Date: Tue Feb 20 18:13:50 2024 +0100 CloudWatch: Update AWS/EC2 metrics (#83039) CloudWatch: update AWS/EC2 metrics commit 3ca01f54866d7a264ae196f50dbd4588d5061408 Author: Jack Westbrook Date: Tue Feb 20 18:03:07 2024 +0100 Yarn: Fix workspace refs (#83117) * Wip * chore(yarn): refresh lock file commit 4859cdeae52c629d03705d8fc988755d86acff8f Author: Kyle Brandt Date: Tue Feb 20 11:28:27 2024 -0500 K8S/Scopes: App-server for storing scope objects (#81996) Build out app-server stub --------- Signed-off-by: bergquist Co-authored-by: bergquist Co-authored-by: Todd Treece <360020+toddtreece@users.noreply.github.com> commit d1c5e491acb25ac3b1dba5eef7fa3a31edf2b0d2 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Tue Feb 20 17:24:40 2024 +0100 Zipkin: Decouple Zipkin plugin (#81354) commit db1388ca2201c1e0281312fedb0d757e57eacbd5 Author: Josh Hunt Date: Tue Feb 20 16:16:31 2024 +0000 Chore: Temp remove betterer json from precommit (#83113) commit 7f9ebe54649463f3dd84e9587e2b9ca128467e72 Author: Andres Martinez Gotor Date: Tue Feb 20 16:56:43 2024 +0100 Chore: Make the secret table name configurable (#83106) commit 3829da06160f4b291bcf613709aa0a581d641292 Author: Jan Garaj Date: Tue Feb 20 16:39:03 2024 +0100 CloudWatch: Update AWS/Lambda metrics (#83038) commit 30178d468f7efde0db370320a60880ee05869f86 Author: Piotr Jamróz Date: Tue Feb 20 15:08:29 2024 +0100 Skip a flaky test (#82522) Co-authored-by: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> commit 8c963ad90cc93ba950b34657edad1d7ed96c24c0 Author: Jack Baldry Date: Tue Feb 20 14:05:55 2024 +0000 Use updated default branch for links to Grafana repository (#83026) commit c237a39020898a3da5d4f8d5a9fc5220892d7758 Author: Laura Fernández Date: Tue Feb 20 15:05:12 2024 +0100 ReturnToPrevious: Check the state of the RTP feature toggle in the hook (#83087) commit 5431c51490465cbed0f2f7267f484a1422ec39b1 Author: Ida Štambuk Date: Tue Feb 20 14:52:11 2024 +0100 Cloudwatch: Add linting to restrict imports from core (#82538) --------- Co-authored-by: Kevin Yu commit f18b9ddac6ec46ed5eee5fda727e99322616123f Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Tue Feb 20 08:46:38 2024 -0500 Docs: add information about filtering for annotations (#82957) * Added information about filtering for annotations * Update generate-transformations.ts commit 9d6da82e36b6fc1456e6112928cbbf41e41cbe4d Author: Kristina Date: Tue Feb 20 07:32:40 2024 -0600 Query History: Count using SQL, not post query (#82208) * Count in SQL, not externally * Fix linter commit 1c8a2f136d7f177692f0ff70a0aaa47edb8bdb82 Author: Timur Olzhabayev Date: Tue Feb 20 14:18:32 2024 +0100 Docs: making docs clearer on subpath (#82239) * making docs clearer on subpath * Update docs/sources/tutorials/run-grafana-behind-a-proxy/index.md Co-authored-by: Joseph Perez <45749060+josmperez@users.noreply.github.com> * Update docs/sources/tutorials/run-grafana-behind-a-proxy/index.md Co-authored-by: Joseph Perez <45749060+josmperez@users.noreply.github.com> * Update docs/sources/tutorials/run-grafana-behind-a-proxy/index.md Co-authored-by: Joseph Perez <45749060+josmperez@users.noreply.github.com> * Update docs/sources/tutorials/run-grafana-behind-a-proxy/index.md Co-authored-by: Joseph Perez <45749060+josmperez@users.noreply.github.com> --------- Co-authored-by: Joseph Perez <45749060+josmperez@users.noreply.github.com> commit 8138ca34a468d169134eddc8cc715646f534fec4 Author: Lev Zakharov Date: Tue Feb 20 15:58:47 2024 +0300 Parca: Apply template variables for labelSelector in query (#82910) * Parca: Apply template variables for labelSelector in query * Remove unused imports --------- Co-authored-by: Joey Tawadrous commit 5e65820beead89efc7248f9b3c7eb3556f92e51c Author: Carl Bergquist Date: Tue Feb 20 13:54:46 2024 +0100 Cleanup root folder by moving a few files intro /contribute (#83103) Signed-off-by: bergquist commit 5b918f555cc6f3d044677d80e9a7d48249549a81 Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Tue Feb 20 13:50:31 2024 +0100 Alerting docs: correct `notification-policies` link (#83099) commit 8586893731aa200a5b10c07621988686abe10729 Author: Yulia Shanyrova Date: Tue Feb 20 12:48:51 2024 +0100 Plugins: Fix enable button to appear after installing APP plugin (#82511) * Fix enable button to appear after installing APP plugin * add plugin isFullyInstalled check for grafana cloud commit f683ba8bfc6374d21799c0024ab20eba069181b3 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Tue Feb 20 12:21:38 2024 +0100 Run downstream patch check only for `grafana/grafana` (#83050) commit b6098f2ddec6edf0ea7e873d4cd8d7ece2f59458 Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Tue Feb 20 11:49:39 2024 +0100 Alerting docs: clean up Cloud links (#83073) * Use fixed `/docs/grafana-cloud/alerting-and-irm` URLs for cloud references * Fix `docs/grafana-cloud/` data sources links * Fix `docs/grafana-cloud/` Panel & Visualization links * Fix `/docs/grafana-cloud/` link to Dashboard page * Set root directory `docs/reference` for non-cloud pages * Fix `admonition` cannot use a `docs/reference` relative link * Update `doc-validator` https://github.com/grafana/technical-documentation/releases/tag/doc-validator%2Fv4.1.0 Signed-off-by: Jack Baldry --------- Signed-off-by: Jack Baldry Co-authored-by: Jack Baldry commit 914f033497a1985b407c8199b243c2dd1d913260 Author: Jan Garaj Date: Tue Feb 20 11:22:28 2024 +0100 CloudWatch: Update AWS/AutoScaling metrics (#83036) commit 941881266ade31cba9d38093128a0d661b01811a Author: Will Browne Date: Tue Feb 20 11:11:50 2024 +0100 Plugins: Remove unused metadata.md file (#83086) * remove unused metadata * remove CODEOWNERS ref commit dc718a7d9dc581b595c93040a895096ff925e443 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Tue Feb 20 10:07:42 2024 +0100 Loki: Pass time range variable in variable editor (#82900) * Loki: Pass time range variable in variable editor * Remove not needed type * Update * Add tests, not re-run if type does not change * Add range as dependency commit 6db2d1a411693ad7c9a64f9836e0ee966b93c16c Author: Torkel Ödegaard Date: Tue Feb 20 08:43:02 2024 +0100 DashboardScene: Simplify controls a bit (#82908) * DashboardScene: Simplify controls a bit * update tests * more test updates * Update * improvements * Fix * Fix merge * Update * update commit 57499845c25d927fb239016c4a205fe31f983c14 Author: Gábor Farkas Date: Tue Feb 20 08:41:11 2024 +0100 envvars: improve tests (#83071) * envvars: improve tests * removed commented-out code Co-authored-by: Will Browne --------- Co-authored-by: Will Browne commit 87ab98ea95ddddf647b73e557b79d0f2d6e944b8 Author: Matthew Jacobson Date: Mon Feb 19 10:30:13 2024 -0500 Alerting: Fix panic in provisioning filter contacts by unknown name (#83070) commit b56f6ed0dcdc8a36e5dc2d98489f118cfa8bf81c Author: Oscar Kilhed Date: Mon Feb 19 15:25:45 2024 +0100 Dashboard scenes: Fixes inspect library panels (#82879) * working except for links * Make sure the links are present on the library panels * add tests, add empty links before panel is loaded, refactor legacy representation * Update --------- Co-authored-by: Torkel Ödegaard commit 1f980289623597a6f0493a4ff40bae5fa04cdbc8 Author: ismail simsek Date: Mon Feb 19 15:12:46 2024 +0100 Chore: Bump grafana-plugin-sdk-go version to v0.212.0 (#83064) bump version v0.212.0 commit 2768c92519870b7dc870423641e5dd45d89cbe34 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 19 12:59:21 2024 +0000 Update dependency @react-types/overlays to v3.8.5 commit d90774e8c23339db0df785ba414f43e63780f3ee Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 19 12:18:04 2024 +0000 Update dependency @react-types/menu to v3.9.7 commit 6ca26dd043573673cb491c3f362a0ead0ab7870e Author: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Mon Feb 19 13:49:34 2024 +0100 Grafana UI: Add code variant to Text component (#82318) * Text: Add code variant * Add TextLinkVariants * Add don't commit b4a77937fd962314cd6222e29e01839cac8f9794 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 19 11:12:24 2024 +0000 Update dependency @react-types/button to v3.9.2 commit 72c8ae2d8a85dcdd493641b9adcff415fba1f01b Author: Jack Baldry Date: Mon Feb 19 11:21:51 2024 +0000 Remove duplicate paragraph and wrap in note (#82575) commit d54141661502efdcfef288f43bb04d3e5e1c5ce0 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon Feb 19 13:16:52 2024 +0200 Update `make docs` procedure (#83045) Co-authored-by: grafanabot commit bfd5475e1e13464801229bbd1d1bfea7659c18a8 Author: Jan Garaj Date: Mon Feb 19 12:04:23 2024 +0100 CloudWatch: Update AWS/Kafka metrics (#83035) commit 2175509929c632f3e52a4332ae9debf337e47dd1 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 19 10:47:12 2024 +0000 Update React Aria commit 4910a901de83ff2a0b413412f1866bf2d7dce7f3 Author: Hugo Kiyodi Oshiro Date: Mon Feb 19 12:00:47 2024 +0100 Plugins: Disable uninstall while cloud uninstall is not completed (#81907) commit 7f77be8f85aafbd090401f0a7576847f078452aa Author: Gábor Farkas Date: Mon Feb 19 11:58:42 2024 +0100 postgres: tls: only use non-empty certificates (#82182) commit dcc977005cff0225fdb72bf0f040e945fe45cab5 Author: Hugo Kiyodi Oshiro Date: Mon Feb 19 09:38:06 2024 +0100 Plugins: Disable update button when cloud install is not completed (#81716) commit 1aff748e8f735d4610d4f13d1a4d1c933c585882 Author: Serge Zaitsev Date: Sun Feb 18 22:26:08 2024 +0100 Use split scopes instead of substr in search v1 (#82092) * use split scopes instead of substr in search v1 * tests, of course * yet, some test helpers dont use split scopes * another test helper to fix * add permission.identifier to group by * check if attribute is uid * fix tests * use SplitScope() * fix more tests commit 94a274635b92898a04de6d29c30e1a97f0326043 Author: Leon Sorokin Date: Sat Feb 17 00:38:13 2024 -0600 VizTooltips: Fix series labels after zooming (#82985) commit a02519895e2954a74a839bfd40707299ac4af27d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Sat Feb 17 04:19:58 2024 +0000 Update dependency msw to v2.2.1 commit edb799bf82556b8d8581be7d28be271a63d43c43 Author: Leon Sorokin Date: Sat Feb 17 00:35:41 2024 -0600 VizTooltips: Fix drag-zoom causing annotation init in other shared-cursor panels (#82986) commit f23f50f58d7ab5cb1fd88b42b6c58ec09c1a159d Author: Ryan McKinley Date: Fri Feb 16 16:59:11 2024 -0800 Expressions: Add model struct for the query types (not map[string]any) (#82745) commit 46a77c007414b2623ab5b05789d6d67efe646d81 Author: Matthew Jacobson Date: Fri Feb 16 15:17:07 2024 -0500 Alerting: Validate upgraded receivers early to display in preview (#82956) Previously receivers were only validated before saving the alertmanager configuration. This is a suboptimal experience for those upgrading with preview as the failed channel upgrade will return an API error instead of being summarized in the table. commit 38e8c62972a7e904a0d1473db6d7b9838c34a2bc Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Fri Feb 16 21:10:46 2024 +0200 Folders: Switch order of the columns in folder table indexes so that org_id becomes first (#82454) * Folders: Switch order of the columns in folder table so that org_id becomes first commit c6f8462a06a56fa75d059ef80b632ed69d8b5b04 Author: Brendan O'Handley Date: Fri Feb 16 12:55:39 2024 -0600 Prometheus: Library fixes for using in external vendor DS (#82115) * fix stateSlice type errors for build, do not export stateSlice in the future * fix exports for consistency * fix package.json for rollup, update licence, keep private * rollup as devdependencies * try a different version of @testing-library/dom to try to fix the aria-query issue in drone * remove testUtils export * change @testing-library/dom version back * remove icon bundling, grafana-ui handles this * remove unused dependencies * components folder: avoid nested barrel files and use named exports * configuration folder: avoid nested barrel files and use named exports * querybuilder folder: avoid nested barrel files and use named exports * general files: use named exports * fix loader issue with promql for external ds * default to support labels match api * export things necessary for custom config auth * remove changes to core datasource.test.ts * Update packages/grafana-prometheus/package.json Co-authored-by: Jack Westbrook * remove icons script, not needed * update readme, remove references to grafana/ui * remove private property * check tests * remove private property in package.json * update changelog * update npm drone script for file checks * debug why updating test in script broke another library that had never been tested before * fix npm test for checking licenses * fix npm test for checking licenses * fix npm test for checking licenses * fix npm test for checking licenses * update license file for npm drone test * fix bash script --------- Co-authored-by: Jack Westbrook commit 0a9389c8f7aa5cf846f9dcf2db6cea75fe1abf7f Author: Señor Performo - Leandro Melendez <54183040+srperf@users.noreply.github.com> Date: Fri Feb 16 12:28:56 2024 -0600 Add video to variables _index.md (#82926) Added the YouTube link to the video created explaining Variables. commit 538617bb0031149a2203b56a659bfc3de0343670 Author: David Harris Date: Fri Feb 16 17:56:26 2024 +0000 docs: angular plugins list rewrite (#82456) commit 46c26bbd0bdd83fa19625f8a54fd80f8b52293ae Author: Xavi Lacasa <114113189+volcanonoodle@users.noreply.github.com> Date: Fri Feb 16 18:54:59 2024 +0100 Auth: Fix email verification bypass when using basic authentication (#82914) commit fabaff9a248f0f31ccdb74c7ca5394286e8f3521 Author: William Wernert Date: Fri Feb 16 12:01:49 2024 -0500 Alerting: Create metric for rules using simple notifications (#82904) --------- Co-authored-by: Matthew Jacobson commit 3a63311286a5711549118e927e1959a87e972445 Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Fri Feb 16 16:46:10 2024 +0000 Scenes/LibraryPanels: Fix issue where library panels plugin type was not set correctly (#82917) commit dfaf6d1e2e13b2bd11dc8f0cd4432bfcad819aa9 Author: Matthew Jacobson Date: Fri Feb 16 11:29:54 2024 -0500 Alerting: Dry-run legacy upgrade on startup (#82835) Adds a feature flag (alertingUpgradeDryrunOnStart) that will dry-run the legacy alert upgrade on startup. It is enabled by default. When on legacy alerting, this feature flag will log the results of the legacy alerting upgrade on startup and draw attention to anything in the current legacy alerting configuration that will cause issues when the upgrade is eventually performed. It acts as a log warning for those where action is required before upgrading to Grafana v11 where legacy alerting will be removed. commit bc8952b9f18c115e68d6c8a757320176400dda82 Author: Misi Date: Fri Feb 16 17:24:42 2024 +0100 Docs: Setup OAuth providers using the SSO Settings UI (#81589) * initial changes for generic_oauth, okta * updates * add terraform examples for each provider * add link to terraform registry for grafana_sso_settings resource * remove auth_url, token_url and api_url from github, gitlab and google * Add documentation for enabling email lookup * Apply suggestions from code review Co-authored-by: lwandz13 <126723338+lwandz13@users.noreply.github.com> * Address review feedback * Update TF provider version * Apply suggestions from code review Co-authored-by: lwandz13 <126723338+lwandz13@users.noreply.github.com> * Use Azure AD for now --------- Co-authored-by: Mihai Doarna Co-authored-by: lwandz13 <126723338+lwandz13@users.noreply.github.com> commit f71f54c87235b3d7a77e2d60740980678603c9c5 Author: Gilles De Mey Date: Fri Feb 16 17:13:43 2024 +0100 Alerting: Updates to recording rules (#82329) commit 5de17432f544256bf44cfae6495104fcd0a9b3c4 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri Feb 16 17:11:03 2024 +0100 Alerting: Add pagination and improved search for notification policies (#81535) commit 7422a90e8ba0099bbbc241a688ae8f7e6c6b0027 Author: Laura Fernández Date: Fri Feb 16 17:00:51 2024 +0100 ReturnToPrevious: modify stage of the feature toggle (#82912) commit e7c6e9c5c95e9d41f0402290b18fe9d6041cd71e Author: Matthew Jacobson Date: Fri Feb 16 10:47:34 2024 -0500 Alerting: Fix migration edge-case race condition for silences (#81206) If the db already has an entry in the kvstore for the silences of an alertmanager before the migration has taken place, then it's possible that the active alertmanager will overwrite the silence file created by the migration before it has a chance to load it into memory. This should not happen normally but is possible in edge-cases. This change opts to bypass the unnecessary step of writing the silences to disk during the migration and instead write them directly to the kvstore. This avoids the race condition entirely and is more correct as we treat the database as the source of truth for AM state. commit 7f7ab324449a0927551ab9df025cd008d0a5e64a Author: Sven Grossmann Date: Fri Feb 16 16:28:46 2024 +0100 Elasticsearch: Add error source to logs (#82901) commit 8f0431ba46edf555ccb340a796c3c44a54f304cb Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Fri Feb 16 10:07:37 2024 -0500 K8s: Pass ID token in X-Extra-id-token header (#82893) commit ffb9a4de4a43f1a4591aa9b4d3c5b7184839e1e4 Author: Ashley Harrison Date: Fri Feb 16 14:35:26 2024 +0000 Chore: some test type fixes (#82889) * some test type fixes * ignore table-old since it's an angular panel that will be removed commit 82e3e2e5582c49fa793cb9a0c143b7b76cbdfb58 Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Fri Feb 16 14:17:41 2024 +0000 LibraryPanels/RBAC: Fix issue where folder scopes weren't being correctly inherited (#82700) commit 7b415cf79e479086a4eed6860a4b6218adb01ee4 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri Feb 16 15:15:02 2024 +0100 Alerting: Skip fetching receivers status in the alert rule form (#82892) commit 7b4dd4fe474164d065445e22b5ef4d94a4e6c852 Author: Kristina Date: Fri Feb 16 08:10:22 2024 -0600 Explore: Only update pane's instance of Inspector (#80106) * send instance ID to query inspector, ensure requestId match before updating data * Extract logic for mixed request ID, use in Explore prefix when appropriate * Change query inspector to get passed request ID * Fix test commit 9e04fd0fb737f6bc8817621aa31ef46984a1ffc6 Author: Karl Persson Date: Fri Feb 16 15:03:37 2024 +0100 AuthToken: Remove client token rotation feature toggle (#82886) * Remove usage of client token rotation flag * Remove client token rotation feature toggle commit 248031d007cc18611935895b8c2c6bd369888319 Author: Gilles De Mey Date: Fri Feb 16 14:47:03 2024 +0100 Alerting: Show legacy provisioned alert rules warning (#81902) commit 6ce0efeb41e7c9c4392f439a9b10e23486e2a094 Author: Giuseppe Guerra Date: Fri Feb 16 13:46:14 2024 +0100 Plugins: Enable feature toggle angularDeprecationUI by default (#82880) * Plugins: Enable feature toggle angularDeprecationUI by default * Clarified feature toggle description commit 592b830fd8c66630864e5368a1c22676f5bdc3d0 Author: Torkel Ödegaard Date: Fri Feb 16 13:04:45 2024 +0100 DashboardScene: Panel edit ux tweaks (#82500) * Panel edit ux * Update * Update * switch panel plugin bugfix * Icon change * Update * Update * Fixes commit 7343102d59e5905af31d4f398675d952593ee77a Author: Ieva Date: Fri Feb 16 11:52:43 2024 +0000 RBAC: Migration to remove the scope from permissions where action is alert.instances:read (#82202) * add a migration to remove the scope from any permissions where action is alert.instances:read * linting commit 1744487487dee3f0d3b13e1cdd0a6abb781640d1 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Fri Feb 16 11:17:41 2024 +0000 Tempo: Upgrade @grafana/lezer-traceql patch version to use trace metrics syntax (#82532) * Upgrade patch version * Update autocomplete/highlighting to be more specific * Update test commit df8250ff48a686ca94173b4ce95063ed76369cc1 Author: Alex Khomenko Date: Fri Feb 16 12:13:50 2024 +0100 Card: Remove mdx file and render docs from the story (#82565) * Card: Generate docs from the story * Update sort * Update betterer to check for the "autodocs" tag commit bb9d5799cfca786731921cdc461d9bd35b58e552 Author: Misi Date: Fri Feb 16 12:05:00 2024 +0100 Auth: Load `oauth_allow_insecure_email_lookup` using the SettingsProvider (#82460) * wip * Introduce fixed:server.config:writer role * Fix tests * Update name commit ac840690714acbb240f22b604d27bc6657b5320c Author: linoman <2051016+linoman@users.noreply.github.com> Date: Fri Feb 16 04:58:05 2024 -0600 Password policy (#82268) * add password service interface * add password service implementation * add tests for password service * add password service wiring * add feature toggle * Rework from service interface to static function * Replace previous password validations * Add codeowners to password service * add error logs * update config files --------- Co-authored-by: Karl Persson commit 846eadff635979f3bfa15cb6e398e358cf0ca363 Author: Gabriel MABILLE Date: Fri Feb 16 11:42:36 2024 +0100 RBAC Search: Replace `userLogin` filter by `namespacedID` filter (#81810) * Add namespace ID * Refactor and add tests * Rename maxOneOption -> atMostOneOption * Add ToDo * Remove UserLogin & UserID for NamespaceID Co-authored-by: jguer * Remove unecessary import of the userSvc * Update pkg/services/accesscontrol/acimpl/service.go * fix 1 -> userID * Update pkg/services/accesscontrol/accesscontrol.go --------- Co-authored-by: jguer commit fe0fc08b93c32bd4d25068672fdeb6d9e733bca7 Author: Misi Date: Fri Feb 16 11:20:08 2024 +0100 Chore: Update ssoSettingsApi feature toggle state to Public Preview (#82521) Update feature toggle state to Public Preview commit 691115da7aec9fb63a0032555e5565643047989d Author: Ashley Harrison Date: Fri Feb 16 09:40:16 2024 +0000 Chore: replace `react-popper` with `@floating-ui/react` in `DataSourcePicker` (#82528) * replace react-popper with floating-ui in DataSourcePicker * don't need {force:true} commit 94f544c9f65c6059359c82f144f978e4cf1e8b58 Author: ismail simsek Date: Fri Feb 16 10:20:09 2024 +0100 InfluxDB: Fix tag interpolation when varable used within a regex pattern (#82785) * fix tag interpolation * remove redundant variables commit 86c618a6d62dcd2f33983fb0cecbad71781da8c3 Author: Sven Kirschbaum Date: Fri Feb 16 09:43:47 2024 +0100 Alerting: Escape namespace and group path parameters (#80504) Co-authored-by: Jean-Philippe Quéméner commit 69f604f7fa14e52f90fd5e0ddf0864964f8633d8 Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Fri Feb 16 10:40:39 2024 +0200 Chore: Fix benchmarks (#82714) commit 901e1b1865398f299475b9b69e63c8c647be175e Author: Jo Date: Fri Feb 16 09:40:28 2024 +0100 TeamSync: Fix auth proxy docs on teamsync (#82457) fix auth proxy docs on teamsync commit c5d1b295ec3e2b25a01c176dd366300846ac7e96 Author: Jo Date: Fri Feb 16 09:36:52 2024 +0100 Plugins: Allow plugin page access granting via permissions (#82508) * AccessControl: Check permissions on AppRootPage * add frontend tests for app root permission checks * add accesscontrol oncall ft to tests commit cdd3e1c77601a3b87d07678cc1e1aec27e9fbc04 Author: Nathan Marrs Date: Thu Feb 15 22:29:59 2024 -0700 chore: Promote panel monitoring feature toggle to GA (#82472) commit 5985876f4ada02dc14a5d7090b6eaa3b4a16532c Author: Yuri Tseretyan Date: Thu Feb 15 17:58:34 2024 -0500 Alerting: make feature flag alertingSimplifiedRouting public (#82808) commit 118e4a50b707bda97a9cc40592818e9765b502ff Author: Matthew Jacobson Date: Thu Feb 15 17:34:00 2024 -0500 Alerting: Remove start page of legacy upgrade preview (#82010) Alerting: Remove start page of upgrade preview Alerting upgrade page will now always show the summary table even before upgrading any alerts or notification channels. There a few reasons for this: - The information on the start page is redundant as it's now contained in the documentation. - Previously, if some unexpected issue prevented performing a full upgrade, a user would have limited to no means to using the preview tool to help fix the problem. This is because you could not see the summary table until the full upgrade was performed at least once. Now, you can upgrade individual alerts and notification channels from the beginning. commit 8de9c4c373bb446b3ab8efaae716a30ac32576d4 Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Thu Feb 15 16:29:36 2024 -0600 Timeseries: Add hover proximity option (#81421) commit c540fd41955dc87b7b404c4c0278dce247c9af47 Author: ismail simsek Date: Thu Feb 15 21:33:28 2024 +0100 Prometheus: Fix expanding that contains multiple metrics (#82354) * fix expanding rules with one metric wrapped in a parenthesis * fix expanding rules with regex match operator * fix for multiple labels * refactor * don't modify recording rules name in label values * metric + metric fix * fix last issues with label regex and spaces * add comments * add the same changes to the prometheus library commit c8795883327627e889f20df826f8f8e3383a6fa8 Author: Ryan McKinley Date: Thu Feb 15 12:00:20 2024 -0800 APIServer: Use options pattern in standalone mode (#82760) use options commit ba63e623117c122952b323ad0039e4187035e08e Author: Julien Duchesne Date: Thu Feb 15 14:35:54 2024 -0500 Alerting: Return provenance of notification templates (#82274) commit 4b67ac117fd0a58e0845c685cf11013717137adb Author: Leon Sorokin Date: Thu Feb 15 12:54:43 2024 -0600 VizTooltips: Fix sorting (#82278) commit 80f324fadbc661d3ac95300f3a27cce3e013d1e7 Author: Lisa <60980933+LisaHJung@users.noreply.github.com> Date: Thu Feb 15 11:37:07 2024 -0700 Adding "Exploring logs, metrics, and traces with Grafana" video to docs (#82724) commit 3427321f65e8538b75bfe623375f68916af1dc9f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 15 18:10:34 2024 +0000 Update dependency webpack to v5.90.2 commit 7b37e225ca8cec5ed1c3ae99e369cb30cfc71eba Author: Laura Fernández Date: Thu Feb 15 19:29:22 2024 +0100 ReturnToPrevious: Modify `zIndex` to avoid overlapping with the nav, a drawer or a modal (#82680) commit 8e7c9f6587abebe82c1e635e83e5aeb1306422e2 Author: Lisa <60980933+LisaHJung@users.noreply.github.com> Date: Thu Feb 15 11:14:21 2024 -0700 Adding Grafana for Beginners video to doc (#82710) commit b894d26cbc549700fb363047e3a84529374e0786 Author: Marcus Efraimsson Date: Thu Feb 15 14:03:55 2024 -0400 SQLStore: New store without side-effects (#82657) commit 23a3fddca460bec101024db8e9ec5b029a6b3245 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 15 17:09:53 2024 +0000 Update dependency @types/node to v20.11.19 commit f593161ef683dc3156309238718b7abfe8736b3e Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Thu Feb 15 12:29:36 2024 -0500 K8s: Set X-Remote- headers for SignedInUser (#82543) commit 644d721cf0235c1f94ba7dcc5a8e3428c80b17c5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 15 16:43:07 2024 +0000 Update dependency @grafana/faro-web-sdk to v1.3.8 commit b7bbc5058f7d9ed25ac0a2093375d9a82bccc240 Author: William Wernert Date: Thu Feb 15 12:03:28 2024 -0500 Alerting: Don't validate rules on group update if they've only been reordered (#81841) --------- Co-authored-by: Yuri Tseretyan commit 16f5220adcf7a432e7180e04a0aeba547aee9291 Author: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu Feb 15 09:40:58 2024 -0700 DashboardScene: Empty dashboard state (#82338) * Organize * Refactor * Fix where settings were not showing up commit f4d81a8480478db35d5f23c5361a9ec84a041905 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 15 16:18:04 2024 +0000 Update dependency @grafana/faro-web-sdk to v1.3.8 commit 4d53385d5f8bcfa07d7a329e9916e067672aa852 Author: Ieva Date: Thu Feb 15 16:13:14 2024 +0000 RBAC: allow listing permissions on the root folder (#82184) * allow returning AC metadata for the root folder * add a test * share the reserved root folder UID with frontend commit d019335473d6464780054d4ad90ca5e63feffffa Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 15 15:35:13 2024 +0000 Update dependency @grafana/faro-core to v1.3.8 commit d071f4170da8d046b8eaed0d2750d114e833f027 Author: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Thu Feb 15 09:42:58 2024 -0600 Logs Panel: Add CSV to download options (#82480) * add CSV download to logs panel commit 0f47a6fa10bff83bd16a647fbfd5829d77148b13 Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Thu Feb 15 10:29:33 2024 -0500 Docs: add RBAC for library panels (#82495) * Added RBAC section to library panels page * Added some library panel basic role and fixed role information * Added remaining basic role information * Added library panel fixed role permissions and descriptions * Replaced 'general folder' with 'root level' * Added library panel action definitions * Added scope definitions * Fixed fixed role information * Added library panels to list of fixed roles * Fixed typos * Ran prettier * Fixed links * Fixed links again * Updated link syntax commit 7ec97f4ad82d45be9a1ecb1d49fd2f94a7aff81f Author: Sven Grossmann Date: Thu Feb 15 16:28:05 2024 +0100 Loki: Fix fetching of label names if no previous equality operator (#82582) commit 1eebd2a4ded5f7b573da75a53c9e0b21aa37a439 Author: Yuri Tseretyan Date: Thu Feb 15 09:45:10 2024 -0500 Alerting: Support for simplified notification settings in rule API (#81011) * Add notification settings to storage\domain and API models. Settings are a slice to workaround XORM mapping * Support validation of notification settings when rules are updated * Implement route generator for Alertmanager configuration. That fetches all notification settings. * Update multi-tenant Alertmanager to run the generator before applying the configuration. * Add notification settings labels to state calculation * update the Multi-tenant Alertmanager to provide validation for notification settings * update GET API so only admins can see auto-gen commit ff916d9c15d38eead1ed93c4da2d7fc0c5e3b081 Author: Arati R <33031346+suntala@users.noreply.github.com> Date: Thu Feb 15 15:21:01 2024 +0100 Docs: Update docs for creating nested folders (#82310) commit 4aabfb7835e6aa14c0d36f3a2db33f88cbbb796c Author: Brian Gann Date: Thu Feb 15 09:10:08 2024 -0500 Area Build/Packaging: release process - remove image check for armhf rpm no longer being built (#82406) remove image check for armhf rpm no longer being built commit 916a7bbb08bb153085d8b8d2cc27f50a16a0c2ab Author: ismail simsek Date: Thu Feb 15 15:09:30 2024 +0100 Prometheus: Move converter in prometheus package (#82269) * use jsoniter from sdk * move converter into the prometheus * remove jsonitere * unit test * remove redundant ownership commit 45c739356466d706ca1e83efc98b94bd220fe2a0 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu Feb 15 13:02:26 2024 +0100 Loki: Fix fetching of values for label if no previous equality operator (#82251) * Loki: Fix teching of values if no previoous equality operator * Update to consider regex with match everything * Update public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx Co-authored-by: Sven Grossmann * Fix lint --------- Co-authored-by: Sven Grossmann commit 951399ac390a8d5fa3c63dc6aff1343610207c9d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 15 13:44:59 2024 +0200 Update dependency eslint-plugin-jsdoc to v48.1.0 (#82531) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 7ab203cf6ee87fb07292c5065d7a307fedb51e63 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 15 13:26:12 2024 +0200 Update dependency eslint-plugin-jest to v27.8.0 (#82529) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit c6d91e906536ad1dddea63a28f6851bf841090b0 Author: Gábor Farkas Date: Thu Feb 15 12:25:35 2024 +0100 sql: remove unused code (#82527) commit 749a31738dc5de642e3ffd185ee85f754d435eba Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 15 10:45:31 2024 +0000 Update dependency @types/node to v20.11.18 commit 1bab82ab36db602d16f97c6ed933cd417ab4720d Author: Gábor Farkas Date: Thu Feb 15 12:01:18 2024 +0100 mssql: socks proxy: use plugin-sdk (#82407) commit db7fcd153b8e982fceacad39ec696678d14094b9 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 15 10:36:16 2024 +0000 Update dependency browserslist to v4.23.0 (#82516) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit c0b5b3265092f5ed402b3bc53e8281ba7b8bbf9a Author: Ashley Harrison Date: Thu Feb 15 09:44:44 2024 +0000 Chore: replace `react-popper` with `floating-ui` in `InlineToast` (#82381) replace react-popper with floating-ui in InlineToast commit 5105be4eebfbb1cde851adacc94299e6d5aa6bc5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 15 09:43:34 2024 +0000 Update dependency diff to v5.2.0 (#82465) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit b81c3ab9c1231103466658f03bd83d2e1b86304d Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Thu Feb 15 09:34:14 2024 +0000 Tempo: Improve UX of the query editors status select (#82167) Improve status UX commit a922ce86c87d2133b2041c88722baa6f0c12527d Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Thu Feb 15 09:33:29 2024 +0000 Pyroscope: Add Pyroscope to build and release step (#82363) Add Pyro to release step commit 2ef17efaf903b2a18c15c457cb44be8c80bce6d8 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Thu Feb 15 09:33:15 2024 +0000 Tempo: Reset tag value when key changed in Search tab (#82365) Reset tag value when key changed commit 808be099a77ca2fecb4f2a8e27faf7d5a4f811e5 Author: Sven Grossmann Date: Thu Feb 15 10:30:11 2024 +0100 Rollback `ansicolor` package to `1.1.100` to fix ansi styled logs (#82506) * rollback ansicolor * upgrade ansicolor in grafana-ui commit a6bc262093c15538466f4168b3695ab0633ae659 Author: Dimitris Sotirakis Date: Thu Feb 15 11:00:30 2024 +0200 Chore: Remove `grafana-delivery` references (#82505) * s/grafana-delivery/grafana-release-guild/g * Remove -squad suffix commit 6a47c8da8e2f3ff9e30ab8026572961453457599 Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Thu Feb 15 10:26:43 2024 +0200 Drone: Do not upload artifacts if e2e tests have not run (#82451) commit b7b83ded7176a74ca86f576da7fabd4d158797a3 Author: Torkel Ödegaard Date: Thu Feb 15 06:46:50 2024 +0100 DashboardScene: Panel edit search crash fix (#82449) * DashboardScene: Panel edit search crash fix * Fix issue with removing data links commit 8832971aff9540a4456d38bbb09d1c71c6089748 Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Wed Feb 14 17:53:33 2024 -0500 Perf: Lazy initialization of charsetmaps in go-mssqldb (#79729) commit 3482c8ef09d3edb07b5df6312f4bb82a1fdd814f Author: Ryan McKinley Date: Wed Feb 14 12:19:46 2024 -0800 Chore: Add omit wrapper for xorm (#82476) commit d4ae10ecc6714ddcf813c63b01409c45708300f1 Author: Alexander Weaver Date: Wed Feb 14 12:01:32 2024 -0600 Alerting: Small refactor, move unrelated functions out of fetcher (#82459) Move unrelated functions out of fetcher commit ff08c0a790405ec28d1ff7e0891e799cf56a5641 Author: Diego Augusto Molina Date: Wed Feb 14 14:53:32 2024 -0300 Chore: improve test readability in ngalert/schedule (#82453) Chore: improve test readability commit c490b702bfe2bd5eaed84b41ad961455c02ee57f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Feb 14 19:32:05 2024 +0200 Update dependency core-js to v3.36.0 (#82464) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 62efe6e1706f6a90c91a0ae0cabf6939cd3e5f87 Author: Dominik Prokop Date: Wed Feb 14 09:19:39 2024 -0800 Panel Query Options: Support query caching options (#82448) * schema update * Panel Query Options: Support query caching options commit f016f95298fe490a865612864520f3622f8e804a Author: Dominik Prokop Date: Wed Feb 14 09:18:04 2024 -0800 GroupBy variable core integration (#82185) * Bump scenes * Make GroupByVariableModel a VariableWithOptions * Serialise/deserialise group by variable * WIP: Group by variable editor * WIP tests * Group by variable tests * add feature toggle and gate variable creation behind it * Fix types * Do not resolve DS variable * Do not show the message if no DS is selected * Now groupby has options and current * Update public/app/features/dashboard-scene/settings/variables/components/GroupByVariableForm.test.tsx Co-authored-by: Ivan Ortega Alba * don't allow creating groupby if toggle is off + update tests * add unit tests * remove groupByKeys --------- Co-authored-by: Ashley Harrison Co-authored-by: Ivan Ortega commit 269fa400f0f546f8d6f2933930626adf98e5e25a Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Feb 14 16:44:09 2024 +0000 Update dependency @types/semver to v7.5.7 commit 2d4307d3d1a13f6e664a467c4232d06490235734 Author: Josh Hunt Date: Wed Feb 14 16:57:02 2024 +0000 Chore: Update Inter font files (#82446) * Chore: Update Inter font files * change codeowner of fonts to us commit 63cf8c8808909aa0d0e7e6dd104179a8ac40c54c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Feb 14 16:13:57 2024 +0000 Update dependency @swc/core to v1.4.1 commit 26b25dad4229be940d01ebf5977768ff7b13c3a5 Author: Kyle Cunningham Date: Wed Feb 14 23:35:14 2024 +0700 Tooltips: Hide dimension configuration when tooltip mode is hidden (#81627) * Hide dimensions when tooltip mode is hidden * add logic to heatmap --------- Co-authored-by: nmarrs commit e422309bc4475e1ab790788665ee26abcf1464af Author: Sven Grossmann Date: Wed Feb 14 17:24:56 2024 +0100 Loki: Enable `lokiStructuredMetadata` feature flag by default (#82325) commit 062fa2daa2ec25fa75b401e2ee9d115ddf5a6dc7 Author: Sven Grossmann Date: Wed Feb 14 17:18:46 2024 +0100 Loki Log Context: Always show label filters with at least one parsed label (#82211) * Loki: Always show label filter for parsed labels * add label as structured metadata * change contextfilter `fromParser` to `nonIndexed` * rename variable * simplify * fix test * fix test * rename more properties commit ce750e06187599da6b9c0a91ef95c7a62fe0d069 Author: Nathan Marrs Date: Wed Feb 14 09:06:25 2024 -0700 Deprecation: Create explicit feature toggles for remaining panels (#82217) commit c8ba2163e92e2b7a8b972095729bb81c37cf5a5b Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Feb 14 17:46:22 2024 +0200 Update dependency @grafana/lezer-logql to v0.2.3 (#82450) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 9c29e1a7839ce59552d49cded8ca7309a160711c Author: Diego Augusto Molina Date: Wed Feb 14 12:45:39 2024 -0300 Alerting: Fix data races and improve testing (#81994) * Alerting: fix race condition in (*ngalert/sender.ExternalAlertmanager).Run * Chore: Fix data races when accessing members of *ngalert/state.FakeInstanceStore * Chore: Fix data races in tests in ngalert/schedule and enable some parallel tests * Chore: fix linters * Chore: add TODO comment to remove loopvar once we move to Go 1.22 commit 06b5875c3c3396f9df01383b1a425a7347a158eb Author: Alvaro Huarte Date: Wed Feb 14 16:36:30 2024 +0100 Table Panel: Filter column values with operators or expressions (#79853) * Select column values using comparison operators * Select column values using expressions * Capitalize operator labels * Update docs/sources/panels-visualizations/visualizations/table/index.md Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> * Use $ instead of v to represent a variable * Define operators as a map string of objects * Fix typo --------- Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> commit 7d21eb0631cb9fb13c516dad00cf13c3102e1892 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Feb 14 17:24:30 2024 +0200 Update React Aria (#82447) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 12b117063173b4b0320844fb156264adc75b83ac Author: Misi Date: Wed Feb 14 16:06:52 2024 +0100 Auth: Validation fixes for SSO Settings (#82252) * Validation fixes * Add URL validations + tests * Add ApiUrl validation * Refactor validators * lint * Clean up * Improvements commit 8a7828da48198a01556f4657a1d6b9669035ab87 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Feb 14 15:01:33 2024 +0000 Update dependency marked to v12 (#82245) * Update dependency marked to v12 * make sure marked return value is synchronous --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit d956282913ad3f17b1c5b049989a7028d664e2bd Author: Torkel Ödegaard Date: Wed Feb 14 15:56:01 2024 +0100 Scenes: Upgade to 3.5.0 (#82441) * Scenes: Upgade to 2.4.1 * Update commit 88481fed1a6a64a58f11f09914994616803fe29a Author: Kyle Cunningham Date: Wed Feb 14 21:34:14 2024 +0700 Table Panel: Fix display of ad-hoc filter actions (#82442) Remove block display from ad-hoc filter actions commit 7efa8c28408cb8fd39132fe4cd2d0caee850b5e0 Author: Varsha <66315875+VarshaSBhat@users.noreply.github.com> Date: Wed Feb 14 15:28:48 2024 +0100 Docs: Add copy dashboard instructions (#82155) * Update index.md Added description of how to copy an existing dashboard * Moved Copy dashboard task from Import to Create page and edited for style --------- Co-authored-by: Isabel Matwawana commit 04005d770b112f857105f03c3003e72bd2a3aac8 Author: Alexa V <239999+axelavargas@users.noreply.github.com> Date: Wed Feb 14 15:18:46 2024 +0100 Revert: Scenes/PanelEditor: Fix panel options search crash 82003 (#82439) commit 44ecb26ea1e9bd1cbfd12317e52736d0085c9718 Author: Konrad Lalik Date: Wed Feb 14 15:04:15 2024 +0100 Alerting: Clarify provisioning export types (#82420) Add provisioning type export Alert commit 6ce286246b781ba5b9a05eaf81f32b628df38e22 Author: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Wed Feb 14 15:01:22 2024 +0100 Betterer: Expose results as JSON (#81352) * Expose betterer results as JSON * Make prettier and add command * Add aggregation * Add json generation to lefthook * Use relative path * Add grafanabot as codeowner * Fix parameter type * Include changes to results * Run betterer:json commit 4cbc7dfb5b2cb11dd129615d93762e2b7ab2c1c7 Author: Andriy Date: Wed Feb 14 14:56:18 2024 +0100 Add PagerDuty to the plugins list (#82419) commit 8dc1cd6d5925f0d2551bded78ac2c9dc945e19ef Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Wed Feb 14 14:38:20 2024 +0100 Alerting: Fix reading props from undefined in settings (#82418) * Fix reading props from undefined in settings * Make settings optional in GrafanaManagedReceiverConfig type commit 9dcb7800deae29010054b6f6f25132bdc10ac71c Author: Yulia Shanyrova Date: Wed Feb 14 14:30:24 2024 +0100 Plugins: Add fuzzy search to plugins catalogue (#81001) * WIP add fuzzysearch to plugins catalog * Add keywords to the plugins listing output * add fuzzy search to plugin catalog, add keywords to plugins at frontend side * refactor fuzzysearch function after review * review changes * change the version of uFuzzy library * change reduce result object in getPluginDetailsForFuzzySearch * fix yarn lock error * fix helpers tests * fix frontend searching test * fix frontend linting issues * fix tests --------- Co-authored-by: Esteban Beltran Co-authored-by: Giuseppe Guerra commit cf65d91ee9cf7c05b5a4d4f40d2ec6a64290edf6 Author: Ashley Harrison Date: Wed Feb 14 13:16:44 2024 +0000 Chore: upgrade to msw v2 (#82270) * Update dependency msw to v2 * close * minor fixes * fetch import changes * fix some alerting tests * fix another alerting test * fix systemjs tests * don't return undefined in json mocks * don't return undefined in json response * add type --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 4dcc59244a2e6c95c905a0b577680dc76a33b0a8 Author: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Wed Feb 14 07:14:22 2024 -0600 Logs panel: Table UI - Guess string field types (#82397) * Guess field type for string fields in logs table commit 705ab460a237a5a8ecd504b03765d0f1c3b1741a Author: Misi Date: Wed Feb 14 13:57:36 2024 +0100 Devenv: Add groups to jwt_proxy (#82424) Add groups claim to jwt, add groups commit 526916e10f35d297ab2aa449978b0451295ed4e1 Author: Torkel Ödegaard Date: Wed Feb 14 13:37:52 2024 +0100 DashboardScene: Panel edit use new splitter and new conditional data and options pane logic (#82377) Rebase commit 70aa8fe6b3f4239f489e0f553761417d1ba5de8f Author: Gilles De Mey Date: Wed Feb 14 13:12:04 2024 +0100 Alerting: Fix slack double pound and email summary (#82333) commit 2938f891ddf917425942179c1c8dbda149ac5a4b Author: Gábor Farkas Date: Wed Feb 14 13:05:47 2024 +0100 mysql: socks proxy: use plugin-sdk (#82375) commit 4c221966e4397666830e0388bddf8f478d3b09cc Author: Gábor Farkas Date: Wed Feb 14 13:05:31 2024 +0100 postgres: socks proxy: use plugin-sdk (#82376) commit 7c44dd713ae94be0eaf715fab8a75316701d2d28 Author: Ieva Date: Wed Feb 14 11:45:55 2024 +0000 IP range AC: Add X-Real-IP header (#82390) add X-Real-IP header commit fe795417f771f103aa460be5267a46bf4e4542df Author: Torkel Ödegaard Date: Wed Feb 14 12:45:29 2024 +0100 Grafana/UI: Add new Splitter component (#82357) * Initial thing working * Update * Progress * Update * Update * Simplify a bit more * minor refacto * more review fixes * Update * review fix * minor fix * update commit 7694e7bca321547fa2f78f5777685bdf74d8dd5f Author: Andre Pereira Date: Wed Feb 14 11:24:03 2024 +0000 Tempo: Support TraceQL metrics queries (#81886) * Support requesting traceql metrics. Small refactor of Tempo query function * Address PR comments and improve displayName commit f6ea39ff002db7fa05d16a6e0476895fb74f4bd4 Author: Gilles De Mey Date: Wed Feb 14 12:12:05 2024 +0100 Alerting: Prevent state badge from wrapping (#82330) commit 92eb2c900e9dc67b369e796bbe0e3652062402ac Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Wed Feb 14 11:43:46 2024 +0100 Alerting: Use VirtualizedSelect for ContactPointSelector (#82345) * Use VirtualizedSelect for ContactPointSelector * Conditionally use virtualized to keep description when having less than 500 contact points commit 37c860daf0e6973f4ee1cb22144e4370d9fa75d9 Author: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Wed Feb 14 09:56:28 2024 +0100 Changelog: Updated changelog for 10.2.4 (#82411) Co-authored-by: grafanabot commit a1ff439a20bf35bfc7dbb1b9f5e89dbd4207d575 Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Wed Feb 14 10:00:16 2024 +0200 Fix query inspector in explore to scroll to bottom (#82369) commit cb47177df92a9db194d80fccf77b8ce5db5e6e61 Author: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Tue Feb 13 17:00:27 2024 -0500 Changelog: Updated changelog for 10.1.7 (#82401) Co-authored-by: grafanabot commit b3663eafeef83474e07efc7f680e30a5ca7a6097 Author: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Tue Feb 13 14:47:17 2024 -0700 Changelog: Updated changelog for 10.3.3 (#82399) Co-authored-by: grafanabot commit ff5b0f7bd8605f8e0f347c64c96e7953842cea8e Author: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Tue Feb 13 15:04:09 2024 -0600 Logs panel: Table UI - remove beta badge (#82395) * remove beta badge commit 3d86d101b7d4737d44b432911689356b392d2827 Author: Ivan Ortega Alba Date: Tue Feb 13 21:38:26 2024 +0100 Dashboards: Use `auto` and only use `AdHocFiltersVariable` to manage filters (#81318) commit 63670b7adc5e9062a0cf96632f729c2a8cf7f26d Author: ismail simsek Date: Tue Feb 13 21:19:13 2024 +0100 Chore: Use jsoniter in prometheus from grafana-plugin-sdk-go (#82359) use jsoniter from grafana-plugin-sdk-go commit 558dc74b4d89c956fb8537288e3fca0f1633701a Author: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Tue Feb 13 19:10:06 2024 +0000 Changelog: Updated changelog for 10.0.11 (#82392) Co-authored-by: grafanabot commit f2ac24f9a044805c936aa407314345d5e3ce1ff5 Author: Lucy Chen <140550297+lucychen-grafana@users.noreply.github.com> Date: Tue Feb 13 13:08:49 2024 -0500 Documentation: Incorrect API example for Public Dashboard (#82335) fix doc commit 28de94f6a2a4f232dd1f4dcf460d35065f4792af Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Tue Feb 13 19:47:46 2024 +0200 Folders: Modify folder service Get() to optionally return fullpath (#81972) * Folders: Modify Get() to optionally return fullpath * Set FullPath to folder title if feature flag is off * Apply suggestion from code review commit e6e9d6a782ac25b7967252815d25d5b8df91bb0c Author: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Tue Feb 13 18:47:26 2024 +0100 Changelog: Updated changelog for 9.5.16 (#82386) Co-authored-by: grafanabot commit dbde08b03c05356c740d3adb02276651a085748b Author: Ezequiel Victorero Date: Tue Feb 13 14:15:55 2024 -0300 Scenes: Refactor original snapshot button in a new component (#82199) commit ccb4533a863dbeb3b804ad6d52f5894c11a2c27f Author: Alexander Weaver Date: Tue Feb 13 10:56:24 2024 -0600 Alerting: Remove unused AlertRuleVersionWithPauseStatus (#82383) Remove unused AlertRuleVersionWithPauseStatus commit 3f940f4da135320ba82319e527ae9699c4b55446 Author: ismail simsek Date: Tue Feb 13 17:47:00 2024 +0100 Chore: Use jsoniter in influxdb from grafana-plugin-sdk-go (#82360) use jsoniter from grafana-plugin-sdk-go commit dcbc3aa46a509951bc0c6c2cf26a4a02da0cb657 Author: Andreas Christou Date: Tue Feb 13 15:39:41 2024 +0000 Chore: Update `grabpl` to `v3.0.50` (#82379) Bump grabpl version commit 65a0a8cd3c33d115ccd6b29636bf36d0f4892efc Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Tue Feb 13 15:56:59 2024 +0100 Loki: Update `@grafana/lezer-logql` to `0.2.3` containing fix for ip label name (#82378) Loki: Update @grafana/lezer-logql to 0.2.3 containing fix for ip label name commit 99fa0645769d534d846fee454b8792a677fb5f8b Author: Alexander Weaver Date: Tue Feb 13 08:29:03 2024 -0600 Alerting: Emit warning when creating or updating unusually large groups (#82279) * Add config for limit of rules per rule group * Warn when editing big groups through normal API * Warn on prov api writes for groups * Wire up comp root, tests * Also add warning to state manager warm * Drop unnecessary conversion commit 556d531c8d65d7810e71b7d22c94271a18148327 Author: George Robinson Date: Tue Feb 13 13:37:33 2024 +0000 Alerting: Update grafana/alerting to 92f64f0 (#82373) commit 763dab7532b1884ee02fc3ff94dd59615248af28 Author: Torkel Ödegaard Date: Tue Feb 13 14:23:47 2024 +0100 DashboardScene: Panel edit toolbar actions (#82302) * DashboardScene: Panel edit toolbar actions * Make saving work * Update public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts Co-authored-by: Dominik Prokop --------- Co-authored-by: Dominik Prokop commit 082f020b7d8d7a4c6d3d9d90810aa9216265989a Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Tue Feb 13 13:44:08 2024 +0100 Elasticsearch: Fix resource calls for paths that include `:` (#82327) * Elasticsearch: Fix resource calls for paths that include : * Add tests * Add test case and comment * Remove redundant comment commit baa46e6a4658f74123d2e71a62f3f545768a65bd Author: Torkel Ödegaard Date: Tue Feb 13 13:36:07 2024 +0100 DasbhoardScene: Fixes panel menu new alert rule action (#82366) * DasbhoardScene: Fixes panel menu new alert rule action * Update commit 1abe4a02b483128129203f4560a3c4bab3a44a52 Author: Ihor Yeromin Date: Tue Feb 13 14:21:36 2024 +0200 Table: Update page index on data update (#81574) * fix(table): page index reset on data update commit 6c42bd31c63d818fe74e50ea4ef684f849a544e0 Author: Alex Khomenko Date: Tue Feb 13 13:04:42 2024 +0100 Teams: Remove Form component usage (#82367) * Teams: Remove Form usage * Update team settings commit 34fddfce10b350f21815c3a31ed42eec93a296a3 Author: ismail simsek Date: Tue Feb 13 12:23:37 2024 +0100 Chore: Use jsoniter in cloud-monitoring from grafana-plugin-sdk-go (#82358) use jsoniter from grafana-plugin-sdk-go commit d33a08775628fe0edd796e6295f27afc39a54e49 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Tue Feb 13 10:56:46 2024 +0000 Tempo: Remove unused code (#82151) Remove unused code commit 40545905260d1f1f1bfa0f1a11fb4810780b0b03 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Tue Feb 13 10:56:37 2024 +0000 Tempo: Use grafana/ui divider (#82141) * Use Grafana divider * Remove custom divider commit 55d17c7fcb273bafd13b5cc5a1df77b3acc13026 Author: Alex Khomenko Date: Tue Feb 13 11:51:54 2024 +0100 Chore: Remove Form usage from alerting config components (#81681) * Chore: Replace InputControl in BaseSettings * Chore: Replace InputControl and FormAPI in OptionElement.tsx * Update ConfigEditor.tsx commit d07aa255127dbf9532f691b582b4ae057aab9e0c Author: Gábor Farkas Date: Tue Feb 13 11:51:19 2024 +0100 go: updated grafana-plugin-sdk-go version (#82346) commit 86b8a0af9f9fd526a315de1b99ea56d9398047ce Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Tue Feb 13 11:48:39 2024 +0100 Tempo: Improve Betterer results (#81338) commit f7a425d3522a7b5e1ea641471bdb1b5274ebac36 Author: Torkel Ödegaard Date: Tue Feb 13 11:37:49 2024 +0100 DashboardScene: Panel edit visualization suggestions (#82296) * DashboardScene: Panel edit visualization suggestions * Tweak * tweak * Update betterer * Update commit 8a90c0f868b9593462c3eae367134bd4b33f7d37 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue Feb 13 09:45:15 2024 +0000 Update `make docs` procedure (#82342) Co-authored-by: grafanabot commit 3909b4c14af15bc12b64f25a2e97c421f04abb12 Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Tue Feb 13 10:21:50 2024 +0100 Alerting docs: update `` to `` (#82324) commit 0c6e4093509c981c19c78c297adb9ae23c239818 Author: Ryan McKinley Date: Mon Feb 12 16:50:25 2024 -0800 Chore: Update arrow and prometheus dependencies (#82215) * update arrow and prometheus * keep codeowner * use compare * use grafana-plugin-sdk-go v0.210.0 --------- Co-authored-by: ismail simsek commit d1e5e4e7473ab123e1dd8df117195ef63176a730 Author: Ryan McKinley Date: Mon Feb 12 15:13:51 2024 -0800 FeatureToggles: keep older history (#82336) commit d6e6298103d5d6a4efd1c21a3d74f429b506f3a7 Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Mon Feb 12 15:59:35 2024 -0500 K8s: Add Aggregation to Backend Service (#81591) Co-authored-by: Charandas Batra commit 6d5211e17221f1643ab935f672c23457d10e9232 Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Mon Feb 12 13:21:57 2024 -0500 K8s: Fix windows filepath issue in file storage (#81919) Co-authored-by: Dan Cech commit 2210d5228fd5a1884e1a099619a2c58304da6ff6 Author: Josh Hunt Date: Mon Feb 12 17:31:14 2024 +0000 Fix frontend-observability icon fill colour (#82326) commit fea23862b4c3733fed352320797d3c1e7901be3c Author: Ashley Harrison Date: Mon Feb 12 17:18:10 2024 +0000 Chore: remove `react-popper` from AnnotationEditor and AnnotationMarker (#82090) update AnnotationEditor and AnnotationMarker to use floating-ui/react instead of react-popper commit 29be9b127adce5277f24d61b78cf60710b80aaa5 Author: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Mon Feb 12 10:12:05 2024 -0600 Logs: Table UI - Enable feature flag by default (GA) (#81185) default logsExploreTableVisualisation to true commit c4b869790acfbf4bb2fbb2ce4d11275fed7e8a69 Author: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Mon Feb 12 09:35:03 2024 -0600 Logs Panel: Table UI - better default column spacing (#82205) * add default width to time fields in logs table commit 8f36f905ee5d9ad0e47fead3b88bec60c7df414b Author: Torkel Ödegaard Date: Mon Feb 12 16:23:12 2024 +0100 SceneSolo: Minor fixes (#82289) * SceneSolo: Minor fixes * remove logging commit cd09c36448348a0e5de75f4836d511f18a7947b1 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Mon Feb 12 09:52:44 2024 -0500 datatrails: fix: improve performance of related metrics sort (#82285) fix: improve performance of related metrics sort commit 1fe32479d7622394215f1a27a621f10441dd97c6 Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Mon Feb 12 16:06:46 2024 +0200 Scenes: Annotations functionality in settings (#81361) * wip listing/viewing annotations * list annotations in settings * fix tests * wip edit mode * PR mods * move, delete, edit, add new * edit annotation in settings * Annotations functionality * revert change * add ui tests, move angularEditorLoader * remove flaky test * refactor * bump scenes version, refactor getVizPanels, refactor edit page * fix nav breadcrumbs * annotation set dirty flag, add overlay to edit view * PR mods * PR mods * remove flaky test * change dirty flag setting logic for anotations * change button variants commit 1315c67c8b61d892f145598655867f8a7a5443a1 Author: Karl Persson Date: Mon Feb 12 14:48:29 2024 +0100 Team/User: UID migrations (#82298) * Add user uid migration to run on every startup to protect against empty values in a upgrade downgrade scenario * Add team uid migration to run on every startup to protect against empty values in a upgrade downgrade scenario * Run team uid migration commit 685e84b1f806d45972117d7e237f4b1738d7851a Author: Ashley Harrison Date: Mon Feb 12 13:22:24 2024 +0000 Chore: Remove `react-popper` from `DataLinkInput` and `SuggestionsInput` (#82160) * replace popper with floating-ui in DataLinkInput * replace popper with floating-ui in SuggestionsInput commit 71e7d654dea1b11f271900feda1f04072fa925c2 Author: Joao Silva <100691367+JoaoSilvaGrafana@users.noreply.github.com> Date: Mon Feb 12 13:07:40 2024 +0000 Card: Revert adding overline component (#82308) commit 788b9afda316c379a72c4ea8f38d7ba494de80b1 Author: Will Browne Date: Mon Feb 12 12:47:49 2024 +0100 Plugins: Make it possible to support multiple plugin versions (#82116) * first pass * use version in more places * add comment * update installer * fix wire * fix tests * tidy * simplify changes * fix in mem * remove unused step * fix step dupe logic for child plugins + add tests commit 730e1d2485bac4ca6e765f5ac59572bf0e3b2db8 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Mon Feb 12 12:45:29 2024 +0100 Alerting: Fix folder and rule name in groupBy for simplified routing (#82148) * Fix folder and rule name in groupBy for simplified routing * Review suggestions * Use proper type for MultiValueRemove props * hoist getting groupBy count * Use watch instead of getValues * Bring back validation for required fields commit d65d2ceb1b308a86f74cfb1cae9b072eec469e94 Author: Gábor Farkas Date: Mon Feb 12 12:42:12 2024 +0100 sql: add explanation comment (#82304) commit 67f006a91d3d0cf9b76aaf08f107345fa9985be4 Author: Gábor Farkas Date: Mon Feb 12 12:37:23 2024 +0100 sql: remove setting-import from sqleng (#82088) commit a3f34292904d22d0535a25fbe9a61cd9c696f316 Author: Faye Lin <49775184+lingyufei@users.noreply.github.com> Date: Mon Feb 12 19:19:22 2024 +0800 Variable: Inform users of the error details when Grafana is unable to retrieve the variable values from an SQL data source. (#82222) * feat: Inform users of the error details when Grafana is unable to retrieve the variable values from an SQL data source. * format code * use getAppEvents * throw an error when failing to executing the sql query * optimize code * prettier commit 70fc603d26d2ae585d9112b2a89af0ab66a25cba Author: Gilles De Mey Date: Mon Feb 12 12:17:19 2024 +0100 Alerting: Improve 404 and other HTTP request error handling (#82249) commit d91803c35ac2db434631c3622d198aa6fc9d81a4 Author: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Mon Feb 12 11:54:24 2024 +0100 Keybindings: Change 'h' to 'mod+h' to open help modal (#82253) Keybindings: Change 'h' to 'mod+h' commit 7f7f1b17e6465570630c1a9e7e8432ff9056715a Author: Torkel Ödegaard Date: Mon Feb 12 11:45:34 2024 +0100 DashboardScene: Panel edit progress (#82288) commit 18ee8f75839cf04b38cafd9770e49099a4a116f3 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon Feb 12 10:35:01 2024 +0000 I18n: Download translations from Crowdin (#82181) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> commit e2ea20b44642a8cb74611df8cab809d0b47e09ed Author: Torkel Ödegaard Date: Mon Feb 12 11:26:39 2024 +0100 DashboardScene: Fixes compatability wrapper to make annotation list panel work (#82287) commit 14ec1a7971ba40182b3851d2820437264f77ecca Author: Kyle Cunningham Date: Mon Feb 12 17:15:26 2024 +0700 Table Panel: Update column alignment labels in panel config (#82069) Update column alignment labels commit 00e96e45847dbf280e6a6ebeed92e2a8b44d62b0 Author: Misi Date: Mon Feb 12 11:12:08 2024 +0100 Auth: SSO Settings UI frontend improvements (#82264) * Add frontend fixes * Update labels + link commit 9c92329bee8c6f8901eb10fc0f145e5109c3faca Author: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Mon Feb 12 10:27:18 2024 +0100 Alerting docs: `Provision alerting resources` updates (#82221) * Alert provisioning: initial restructuring * Fix products labels * Restructure `Import and export Grafana Alerting resources` * Change URL to `export-alerting-resources` * Complete `Export alerting resources` * Export alerting resources * Update `configuration files` provisioning * Terraform Provisioning * Change to `Provision/Import/Export` terms and some notes * Replace `config` to `configuration` * Set (menu)Title `Export alerting resources` * Minor change on note about `Export Alerting endpoints` * Fix `doc-validator` issues * Fix grammar * Update docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Fix numbered lists and `Note:` without admonition * Convert text-based notes (`Note:`) to `admonition` blocks * Replace text-based `Note:` with adminitions * Remove `file-provisioning` grafana-cloud links * Update `Export alerting resources` intro * nitpicky format order --------- Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> commit 815e61258c2cd755440a0d64a33feb005153d4d7 Author: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> Date: Mon Feb 12 03:34:36 2024 -0500 [DOC] Update Pyroscope data source (#82130) Co-authored-by: Jack Baldry commit fcf2543fe38263393f506e42e87b3c9a7be0d142 Author: Gábor Farkas Date: Mon Feb 12 09:14:03 2024 +0100 updated grafana-plugin-sdk-go dependency (#82136) update grafana-plugin-sdk-go dependency commit fe6d1460b09b403fc74fd20e95f61513eede2555 Author: Torkel Ödegaard Date: Sun Feb 11 09:08:47 2024 +0100 DashboardScene: Adds solo page that uses dasboarde scene to render single panel (#77940) * DashboardScene: Adds solo page that uses dasboarde scene to render single panel * Update * Panel and row repeats working * Update * added e2e tests * Refactor * Fixes * Fix e2e * fix * fix * fix commit 02c0f5929ca6c7fe22c3420ee0314688546c3f77 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Sat Feb 10 09:57:11 2024 -0500 prometheus: fix: use shallow clone of scopedVars (#82280) fix: use shallow clone of scopedVars commit f0bbfc8422d79a6dce2e8f4a0191ae51fa8100e9 Author: Kyle Cunningham Date: Sat Feb 10 07:24:08 2024 +0700 Chore: Move BI feature flags to Dataviz (#82224) commit ce910a7eb243d3a88fbec60b5b3212e4edb8ba63 Author: Ryan McKinley Date: Fri Feb 9 15:34:12 2024 -0800 FeatureFlags: manage creation/modification times automatically (#82131) commit 14869cc4004a70eb61e5cc62c415972738610d6f Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Fri Feb 9 17:25:15 2024 -0500 Docs: Update developer dependencies (#82034) commit 5bbe9c6e6117ab5fa585133fdb9a37a736ee9d4a Author: Alexander Weaver Date: Fri Feb 9 15:53:58 2024 -0600 Alerting: Enable group-level rule evaluation jittering by default, remove feature toggle (#82212) * remove jitter feature flag * Add an out so users can manually disable jitter * Pass in cfg * Add TODO to remove knob in future commit b5d14d03d7388894bbee47a93f25707539ff1519 Author: Torkel Ödegaard Date: Fri Feb 9 21:44:44 2024 +0100 Scenes: Upgrade to 2.6.5 and Add query controller DashboardScene (#82232) * Update scenes and add query controller * Update * Update commit 87c3d0fb6a30508da198228101f45c2dcac30642 Author: Stephanie Hingtgen Date: Fri Feb 9 13:51:00 2024 -0600 K8s: Update stack id validation (#82275) commit c9593531ef939f6eac39b7f6d037b67545d2983b Author: Matias Chomicki Date: Fri Feb 9 19:48:10 2024 +0100 Loki query builder: force click in e2e test (#82051) commit 77111a07149ded181c0b072b80b609cc3cddbefc Author: Alyssa Bull <58453566+alyssabull@users.noreply.github.com> Date: Fri Feb 9 11:22:44 2024 -0700 Cloud Monitoring: Fix naming and warnings (#82271) commit 7f109c885d673e11718c37a43bc2128823bbda0e Author: Kevin Yu Date: Fri Feb 9 09:44:29 2024 -0800 CloudWatch: Fix code editor not resizing on mount when content height is > 200px (#81911) commit 42d6e176bcbda4783df52e77b62bc7190bbda83e Author: João Calisto Date: Fri Feb 9 17:48:56 2024 +0100 Feature Toggle Management: allow editing PublicPreview toggles (#81562) * Feature Toggle Management: allow editing PublicPreview toggles * lint * fix a bunch of tests * tests are passing * add permissions unit tests back * fix display * close dialog after submit * use reload method after submit * make local development easier * always show editing alert in the UI * fix readme --------- Co-authored-by: Michael Mandrus commit f60b5ecec49e578484286df00ebd59c94eef7e3a Author: Ryan McKinley Date: Fri Feb 9 08:48:11 2024 -0800 Chore: Avoid NPE with annotations query (#82216) commit 1f208cd8aed53b0a508b5104155fe34aed937314 Author: Alex Khomenko Date: Fri Feb 9 17:42:17 2024 +0100 Icons: Update observability icon (#82266) Update observability icon commit 54a77fa55e311d8059e6249ceb116564bebbc1a2 Author: Stephanie Hingtgen Date: Fri Feb 9 09:39:58 2024 -0700 K8s: StackIDs can be single digits (#82267) commit 9bc3517617c591bce6395fb6918d964a43621550 Author: Mitsuhiro Tanda Date: Sat Feb 10 01:37:55 2024 +0900 Snapshots: Fix issue where off-screen panels are not included in snapshots (#82135) commit 6f62d970e39916e8ec9960da6a31d40570f440b3 Author: Jo Date: Fri Feb 9 16:35:58 2024 +0100 JWT Authentication: Add support for specifying groups in auth.jwt for teamsync (#82175) * merge JSON search logic * document public methods * improve test coverage * use separate JWT setting struct * correct use of cfg.JWTAuth * add group tests * fix DynMap typing * add settings to default ini * add groups option to devenv path * fix test * lint * revert jwt-proxy change * remove redundant check * fix parallel test commit 32a1f3955a92e01b54dbb9a623b333f2cf1414f7 Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Fri Feb 9 09:09:34 2024 -0600 Canvas: Keep tooltip open until dismissed (#82213) commit fbdd27c23758caed634341f4513e8c4bcb1e70f7 Author: Konrad Lalik Date: Fri Feb 9 15:46:28 2024 +0100 Alerting: Add support for UTF-8 characters in notification policies and silences (#81455) * Add label matcher validation to support UTF-8 characters * Add double quotes wrapping and escaping on displating matcher form inputs * Apply matchers encoding and decoding on the RTKQ layer * Fix unescaping order * Revert "Apply matchers encoding and decoding on the RTKQ layer" This reverts commit 4d963c43b589526b9932d547b5873f223974fad3. * Add matchers formatter * Fix code organization to prevent breaking worker * Add matcher formatter to Policy and Modal components * Unquote matchers when finding matching policy instances * Add tests for quoting and unquoting * Rename cloud matcher formatter * Revert unintended change * Allow empty matcher values * fix test commit 790e1feb9319c250d82ef0dbd327c16059e418d8 Author: Dan Cech Date: Fri Feb 9 09:35:39 2024 -0500 Chore: Update test database initialization (#81673) * streamline initialization of test databases, support on-disk sqlite test db * clean up test databases * introduce testsuite helper * use testsuite everywhere we use a test db * update documentation * improve error handling * disable entity integration test until we can figure out locking error commit de4acb27ce203c4755771a195ab7ba533ec939dc Author: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Fri Feb 9 07:49:48 2024 -0600 Table: Add initial row index (#82200) * Add initial row index prop in table commit 984d2da9aeebad30de91a8d7162ea9cc058b796c Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Fri Feb 9 13:48:53 2024 +0000 LibraryPanels: Fix issue with repeated library panels (#82255) Fixes an issue where a library panel being repeated by a template variable would briefly use the All value for the first repeat instance commit fc5f2286755d497cd458fb44e2aab6661fbe0588 Author: Ashley Harrison Date: Fri Feb 9 13:30:50 2024 +0000 Revert "Update dependency lerna to v8 (#82196)" (#82254) This reverts commit 00fd023fda6afa7ceb85e08a76b73845b8971f65. commit 02d61857abc39b623b154a139d26312ff8c8d0f0 Author: Leon Sorokin Date: Fri Feb 9 06:55:09 2024 -0600 Annotations: Fix axis markers rendering in wrong (stale) positions (#82219) commit bc83d8263b231154566b18786f26479cb62a9df3 Author: Joao Silva <100691367+JoaoSilvaGrafana@users.noreply.github.com> Date: Fri Feb 9 12:37:28 2024 +0000 Card: Add `isCompact` prop and `Overline` sub-component (#82077) commit 48b4ca82283dce0f159c5a3da26528cb578c73fb Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Fri Feb 9 13:11:08 2024 +0100 Elasticsearch: Decouple frontend dependencies from core (#82179) * Elasticsearch: Decouple frontend dependencies from core * Remove not needed code change commit b1dc505a2b8706a062496397992adb6c02a8c190 Author: Misi Date: Fri Feb 9 13:10:23 2024 +0100 Auth: Validate admin assignment in SSO Settings (#82233) * Add validation for allowAssignGrafanaAdmin * Update default values * Do not render hidden fields * Change error message * Improve tests --------- Co-authored-by: Clarity-89 commit 5a5520b5dafa08d139f60a3dda852f246e0d2791 Author: Sergej-Vlasov <37613182+Sergej-Vlasov@users.noreply.github.com> Date: Fri Feb 9 12:01:58 2024 +0000 Dashboards: add delete variable flow to `VariableEditorForm` (#82149) * add delete variable flow to VariableEditorForm * adjust modal logic and replace HorizontalGroup with Stack * revert onDelete prop name commit e0bff6247c014b83e258cca77ddfd1836d85d033 Author: Ashley Harrison Date: Fri Feb 9 11:46:23 2024 +0000 Chore: ignore `loader-utils` update (#82236) ignore loader-utils commit 8beff981426b2f78083e7090cba1d4336324b7f9 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Fri Feb 9 12:16:40 2024 +0100 Update Prettier checks to parse also JSON files (#82046) commit beca6a08b030405b084eda0c831a26b95cb9b1e3 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri Feb 9 12:16:28 2024 +0100 Alerting: defaults for simplified routing (#82050) * Expand route settings by default when alert rule has values in these fields * Default to manual routing option if FF is enabled and local storage is not set to false * Add test for getDefautManualRouting function * Update seting local storage item to false in case of policy routing * Only save to local storage when creating a new alert rule commit 3e93a0991f6ae1e77a7dc72fd6b7e4429dff28a2 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri Feb 9 12:13:37 2024 +0100 Alerting: Use new readonly permission endpoints for getting contact points and mute timings (#82132) * use new read only contact points list endpoint in simplified routing section * Dont use alertmanager endpoint to get groupby defaults * Use the new read only endpoint for mute timings in route settings * review suggestions * Rename hook * Use options in params for useContactPointsWithStatus hook * Refactor useContactPointsWithStatus * second part of the enhanceContactPointsWithMetadata refactor commit ed9e26122ad19b05aab250a4aeaba6ca6b9b4552 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Feb 9 10:16:44 2024 +0000 Update dependency @types/node to v20.11.17 commit fc498f53756ed7ad5850c683b0b26745caa8ad61 Author: marybelvargas <107340764+marybelvargas@users.noreply.github.com> Date: Fri Feb 9 04:44:29 2024 -0600 Update RBAC role name: fixed:datasources.id:reader (#82186) commit b0dfeb19116183b3a4118c046e796e3921f63aed Author: ismail simsek Date: Fri Feb 9 11:39:21 2024 +0100 Chore: Clean up intervalv2 functions (#82074) * clean up intervalv2 functions * use roundInterval from grafana-plugin-sdk-go * use from grafana-plugin-sdk-go * have intervalv2 in publicdashboards and remove tsdb/intervalv2 * legacydata cleanup * remove unused variables * Update pkg/tsdb/legacydata/interval/interval.go Co-authored-by: Arati R. <33031346+suntala@users.noreply.github.com> --------- Co-authored-by: Arati R. <33031346+suntala@users.noreply.github.com> commit 3d73cd5c8e9e7590a6eb497b223f65aa6944a623 Author: Eric Leijonmarck Date: Fri Feb 9 10:20:09 2024 +0000 Docs: Team LBAC create concept and tasks (#82020) * create concept and tasks * update docs * formattting * Update docs/sources/administration/data-source-management/teamlbac/create-teamlbac-rules/index.md Co-authored-by: Alexander Zobnin * Update docs/sources/administration/data-source-management/teamlbac/create-teamlbac-rules/index.md Co-authored-by: Alexander Zobnin * Update docs/sources/administration/data-source-management/teamlbac/create-teamlbac-rules/index.md Co-authored-by: Alexander Zobnin * Update docs/sources/administration/data-source-management/teamlbac/create-teamlbac-rules/index.md Co-authored-by: Jack Baldry * Update docs/sources/administration/data-source-management/teamlbac/create-teamlbac-rules/index.md Co-authored-by: Jack Baldry * Update docs/sources/administration/data-source-management/teamlbac/create-teamlbac-rules/index.md Co-authored-by: Jack Baldry * Update docs/sources/administration/data-source-management/teamlbac/_index.md Co-authored-by: Jack Baldry * Update docs/sources/administration/data-source-management/teamlbac/create-teamlbac-rules/index.md Co-authored-by: Jack Baldry * update of docs * updated w. limitations and explaination of permissions * spelling * formatting * formatting * added another task * formatting --------- Co-authored-by: Alexander Zobnin Co-authored-by: Jack Baldry commit e03a96d09bbd7b5bbc2d4857038a23ccf84e420f Author: Misi Date: Fri Feb 9 11:14:56 2024 +0100 Docs: Update default value of rbac.permission_validation_enabled (#82234) * Update permission_validation_enabled default value in docs * Lint commit 00fd023fda6afa7ceb85e08a76b73845b8971f65 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Feb 9 10:07:57 2024 +0000 Update dependency lerna to v8 (#82196) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 765a1f853307faabc185efce22f837bd9607e343 Author: Ashley Harrison Date: Fri Feb 9 10:04:35 2024 +0000 Chore: fix some bad uses of `userEvent` (#82191) * fix some bad uses of userEvent * wrap advance call in act * don't use act * remove skip * need act here (see https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning#1-when-using-jestusefaketimers) * remove test that isn't correct anymore * implement same change in grafana-prometheus commit e9dab611feeea2adb722c27fd13db716c5d6ec5c Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri Feb 9 10:01:47 2024 +0000 Update `make docs` procedure (#82223) Co-authored-by: grafanabot commit 8afab6a80fe91f6facdaf6ead7c9ac2abb2ceb8d Author: Eric Jacobson Date: Fri Feb 9 02:37:43 2024 -0500 Icons: Add support for docker icon (#81884) - add docker icon to registry commit cc33f0bd1067f2d08b36c4fa1f80b86311c831bc Author: Sergey Kostrukov Date: Thu Feb 8 16:47:09 2024 -0600 Prometheus: Azure scopes from Grafana Azure SDK (#82023) * Prometheus: Azure scopes from Grafana Azure SDK * Refactor and add tests * Cosmetic fix * Cosmetic change 2 commit 930c8c5aa3207936844ea6b6a882f0170f9b95c9 Author: Leon Sorokin Date: Thu Feb 8 16:15:32 2024 -0600 EventBus: Add ability to tag events with arbitrary classification meta (#82087) commit 829672759c12b27f849c92c3a2aee4a6b0037920 Author: Nathan Marrs Date: Thu Feb 8 15:00:48 2024 -0700 Deprecation: Create explicit feature toggle to auto-migrate from graph panel (#79369) commit ce57166c9a5047ae7898fdbe794b61fc89476a78 Author: Matthew Jacobson Date: Thu Feb 8 12:59:25 2024 -0500 Alerting: GA alertingPreviewUpgrade and enable by default (#82038) * Alerting: GA alertingPreviewUpgrade and enable by default commit 633e354a76f02c9d8c26e930b30df0676f2fd70f Author: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Thu Feb 8 11:29:10 2024 -0600 Transformations: Fix converting time fields to number in reduceFields (#81830) * Fix bug converting time fields to number in reduce --------- Co-authored-by: Ryan McKinley commit 903e54e622d5357c1e277a8c8eab5fb6473512af Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 8 17:10:04 2024 +0000 Update dependency postcss to v8.4.35 commit ac5a38708695d7b9589219527a4529577a9f9c31 Author: Ryan McKinley Date: Thu Feb 8 09:27:03 2024 -0800 Peakq: move templates into query service (#82193) commit a439ee46bf35296e1ccd7887216f4f12e4fadaaa Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Thu Feb 8 12:02:03 2024 -0500 datatrails: fix: propagate unit to breakdown graphs (#82176) fix: propagate unit to breakdown graphs commit b8f7230dee2127d0789acddc11f43ee4d5d93863 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 8 17:29:00 2024 +0100 Update dependency @types/gtag.js to ^0.0.19 (#82178) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit e612b9b46be76d079bda7f7e4a8ca94df9b55539 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 8 16:12:05 2024 +0000 Update dependency @types/react-dom to v18.2.19 commit fb86ed79fcc5848a348cce691ec3bf94d0b0fa5d Author: Jo Date: Thu Feb 8 17:19:24 2024 +0100 Stats: Remove ACL references (#82112) remove acl references commit 37eb2e39f434f31cc2169facee6333288f27ab4d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 8 15:51:03 2024 +0000 Update dependency @grafana/scenes to v2.6.6 commit 0e15b4ac765d3ab281380118ac90919e11d1343e Author: Ashley Harrison Date: Thu Feb 8 15:56:43 2024 +0000 Navigation: Add config for `grafana-slo-app` and `grafana-aws-app` (#82174) add config for grafana-slo-app and grafana-aws-app commit 4a7dde5b9790405b29a6a1003d2b33e389b56b27 Author: Misi Date: Thu Feb 8 16:53:51 2024 +0100 Chore: Remove unnecessary usage of DynamicSection from SocialService (#82139) Remove unnecessary usage of DynamicSection commit 36a1f2808696e6abf38d8189b708a72d35e00591 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 8 15:47:29 2024 +0000 Update dependency i18next-parser to v8 (#82085) * Update dependency i18next-parser to v8 * remove deprecated option * fix lockfile --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit 732f9cacb46cd6f1991603572985a17db5c4b96e Author: Josh Hunt Date: Thu Feb 8 15:44:22 2024 +0000 ReturnToPrevious: Add interaction reporting (#81948) * Add analytics for return to previous * fix typos lol commit 71497c98e8ea9233249b1ec1b1cdf74eed644758 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Thu Feb 8 10:36:20 2024 -0500 datatrails: style: improve panel display (#82017) * style: improve panel display - Show summary text in hover legends (no queries) - Show additional description in main panel title commit 312f8f0f693502e2463e29940c61b532b853a4e3 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Thu Feb 8 10:35:09 2024 -0500 datatrails: debounce search and prefix filter (#81842) fix: debounce search and prefix filter commit 3756234de6c3e538c44cc4a11c274e6447b05efb Author: Ashley Harrison Date: Thu Feb 8 15:24:23 2024 +0000 Navigation: bump the bottom padding of the megamenu slightly (#82171) bump the bottom padding of the megamenu slightly commit 83270b3625ff4d9fb5ddfa6671bbf163136de554 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu Feb 8 14:55:31 2024 +0000 I18n: Download translations from Crowdin (#82119) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> commit 2250e358c7ed7ec7baf752f6fae9f25817ff550d Author: Gábor Farkas Date: Thu Feb 8 15:52:20 2024 +0100 postgres: updated snapshot test with better floating point values (#82168) commit 04ff7a1f1d1b353ff3fb7e380f1b54a785ef29bd Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 8 13:35:53 2024 +0000 Update dependency @grafana/experimental to v1.7.10 commit b658b562e88033c6bfc89ebd0062caeab9dbf8fd Author: Andres Martinez Gotor Date: Thu Feb 8 14:54:42 2024 +0100 Chore: Allow standalone release for core plugins without a backend (#82157) commit 4884194879b01d60d9b32eb063e5369bf559d1ff Author: Ryan McKinley Date: Thu Feb 8 05:29:42 2024 -0800 Peakq: use generic query function (#82121) Co-authored-by: Kyle Brandt commit 685ba7d287cd476bc3048c92006616375f64da90 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 8 13:29:17 2024 +0000 Update dependency i18next to v23 (#82084) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 59b78cec43f99f7c769d022247ae56ba626dc135 Author: chalapat Date: Thu Feb 8 18:55:49 2024 +0530 Doc: Custom branding is not applicable to OSS (#82082) Custom branding is not applicable to OSS Signed-off-by: Rao, B V Chalapathi commit 4dc1ebbb66e8d78889c8828399d5c55321a96571 Author: Jean-Philippe Quéméner Date: Thu Feb 8 13:36:09 2024 +0100 fix(alerting): add a proper compare func for location in mute timings (#82153) commit 74d7cd2cadac12c492dd4f78b2641b41a507fe57 Author: Misi Date: Thu Feb 8 13:33:47 2024 +0100 Auth: Add name to be configurable from the UI (#82144) * Add name to be configurable from the UI * Update public/app/features/auth-config/ProviderConfigPage.tsx Co-authored-by: Alex Khomenko * Align test --------- Co-authored-by: Alex Khomenko commit 19b07fbbfc7f5a7d5feb302d9c3c46e09ca5cfe8 Author: Gilles De Mey Date: Thu Feb 8 13:18:20 2024 +0100 Alerting: Fix editing group of nested folder (#81665) commit 28e66b4ad82ecebb551374325f0be4332412341c Author: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> Date: Thu Feb 8 13:09:34 2024 +0100 Rendering: Adds PDF support behind feature toggle (#81811) * start pdf refactor * Update AppChrome.tsx * Update AppChrome.tsx * add encoding param to rendering grpc service * fix plugin mode * clean up * fix backend tests * fix lint errors * Support pdf encoding in render http api --------- Co-authored-by: Torkel Ödegaard commit 90a26e18dbf81b3983a23823022086578dd9c22d Author: George Robinson Date: Thu Feb 8 11:25:27 2024 +0000 Alerting: Update Alertmanager to e82436c (#82145) This commit updates Alertmanager to commit e82436c, which is based on commit f69a508 from Prometheus Alertmanager. commit 99feb928cf87269d80c3322e1265d793933870b9 Author: Will Browne Date: Thu Feb 8 12:19:28 2024 +0100 Plugins: Don't auto prepend app sub url to plugin asset paths (#81658) * don't prepend app sub url to paths * simplify logo path * fix(plugins): dynamically prepend appSubUrl for System module resolving to work * fix(sandbox): support dynamic appSuburl prepend when loading plugin module.js * fix tests * update test name * fix tests * update fe + add some tests * refactor(plugins): move wrangleurl to utils, rename to resolveModulePath, update usage * chore: fix a typo * test(plugins): add missing name to utils test * reset test flag --------- Co-authored-by: Jack Westbrook commit 18963dc3aeb8a6270c3910797e0e22f9845e108f Author: Mikel Vuka Date: Thu Feb 8 11:47:18 2024 +0100 Elasticsearch: Apply ad-hoc filters to annotation queries (#82032) * Elasticsearch: filter annotations * fix broken tests * add a new test case * allow type assertion There is a lint rule to not be able to use assertions. In this case, it is needed and correct way to asset AdHocVariableModel[] Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> * simplify the code to add adhoc filters Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> * lint --------- Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> commit 9bfb7e1f0b64dc52ea3732a718200de466f457ae Author: Sergey Kostrukov Date: Thu Feb 8 04:42:20 2024 -0600 Azure Monitor: Azure routes from Grafana Azure SDK (#82043) Prometheus: Azure routes from Grafana Azure SDK commit 7814c817b97a29313445b752c763f7824584d363 Author: mikkancso Date: Thu Feb 8 11:04:51 2024 +0100 Select: fix type of `formatCreateLabel` prop (#82138) fix type of formatCreateLabel prop of Select commit 35e96d6b042743ee16dc208006b82caeff67bbd5 Author: Ivan Ortega Alba Date: Thu Feb 8 10:50:53 2024 +0100 Settings: Consistent footer actions across edit views (#82048) commit ea96c83a2c27c7e0501274507febfc4ef1447c19 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Thu Feb 8 09:24:23 2024 +0000 Tempo: Improve tags UX (#81166) * Only show add/remove tag when necessary in span filters * Only show add/remove tag when necessary in traceql * Only show add/remove tag when necessary in aggregateBy * Update styles * Add tests * Show remove tag for all if more than one tag * Also check for value only in search editor and update tests commit e09a29d89153cc7a213470c1a7826b7c79f170d6 Author: Alex Khomenko Date: Thu Feb 8 10:10:30 2024 +0100 Chore: Remove Form usage in Signup components (#81532) * Chore: Remove Form component from SignupPage.tsx * Replace HorizontalGroup with Stack * Replace form in VerifyEmail.tsx * Remove max-width commit 6ac0bc5ecf4ab12d04a78a08012e3ea04f9e2929 Author: Jo Date: Thu Feb 8 09:54:17 2024 +0100 Seeder: Add missing methods to Registrations (#81961) * add slice copy method * fix slice copy commit 117a4b4b0a073f8944a4e75fd18be12b20dd76ef Author: Charandas Date: Wed Feb 7 16:43:07 2024 -0800 K8s: resolve crash due to looking up aggregation config when running in a K8s environment (#82125) K8s: apply authn options only in dev mode - resolves crash due to looking up aggregation config commit 1ecf9faded15bb4fab00590d3bb7f1dabf58a2c9 Author: Oscar Kilhed Date: Wed Feb 7 23:51:39 2024 +0100 Chore: Skip flaky dashboard settings test. (#82098) skip flaky dashboard test commit 4b609f1121867ee0570b20f50e7703cc66efd282 Author: Matthew Jacobson Date: Wed Feb 7 16:14:56 2024 -0500 Alerting: upgrade preview enable folder alert tab and dashboard alert panel (#81992) * Alerting: upgrade preview enable folder alert tab and dashboard alert panel Enable the folder alert tab and the dashboard alert panel when the alertingPreviewUpgrade feature flag is enabled * Link directly to folder alerts in upgrade page commit b25ffe2b1544e2ad0ee0c43c693ddb1eb24fc5cc Author: ismail simsek Date: Wed Feb 7 21:32:57 2024 +0100 Prometheus: Remove all intervalv2 imports and use gtime functions wherever it's possible (#82064) * Remove all intervalv2 imports and use gtime functions where ever it's possible * Add a comment line * use roundInterval from grafana-plugin-sdk-go commit 367790663cc74d896b6dc65ef31da2ae761d3844 Author: ismail simsek Date: Wed Feb 7 21:08:20 2024 +0100 Chore: Bump grafana-plugin-sdk-go version to v0.208.0 (#82123) bump grafana-plugin-sdk-go version to v0.208.0 commit 1f1461734c2011a46ad69a32f49a59511cec084c Author: Dan Cech Date: Wed Feb 7 15:05:10 2024 -0500 Storage: Add support for sortBy selector (#80680) * add support for sortBy field selector * use label selectors instead of field selectors * set entity_labels on create & update * make entity server integration tests work * test fixes * be more consistent with handling of empty body, meta or status * workaround for database is locked errors during migration * fix double import of sqlite3 * rename functions and tidy up * refactor update * disable integration tests until we can fix the database locking issue commit bb6db46ecc9be4b8eea823733b93e751c4d0bbc3 Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Wed Feb 7 13:29:32 2024 -0500 Chore: Update wire to v0.6.0 (#82114) commit 156d7ae1942938927ed4954bcc606d0067280188 Author: Dan Cech Date: Wed Feb 7 13:17:02 2024 -0500 use in-process grpc client instead of wrapping server interface (#81926) * use in-process grpc client instead of wrapping server interface * comment out jwt token checks until we're ready to validate the token commit ba7e0d5c2e2eef9f9a1b92fe54c618039261b353 Author: Torkel Ödegaard Date: Wed Feb 7 18:58:23 2024 +0100 DashboardScene: Fix issue with url sync after saving title change (#81851) DashboardScene: Fix issue with url sycn after saving title change commit dd0ca1263bb0f635a84bbc2311d197ab643c129f Author: Matthew Jacobson Date: Wed Feb 7 12:55:48 2024 -0500 Alerting: Include rule uid, title, namespace in unique constraint errors (#82011) * Alerting: Include rule_uid, title, namespace_uid in unique constraint errors commit 645684df152b8fc8eb2b5532508f385f61e2477e Author: Josh Hunt Date: Wed Feb 7 17:22:39 2024 +0000 I18n: Add crowdin PRs to project board (#82100) * Revert all previous changes to the workflow Revert "I18n: Add pr to project board (#82096)" This reverts commit 30730ebdd8bf298c88570d91a07ba6d62b52dfa6. Revert "I18n: Fix workflow for adding PR to project board (#82078)" This reverts commit c16cb7ed3c98aea12a98ebefd485bec16d6d6e12. Revert "I18n: Use correct project and pull request ID (#82070)" This reverts commit 10a130191fc7acb21c321c51c5addfbc202f405c. Revert "Fix incorrect quotes in crowdin-download github action (#82063)" This reverts commit 945e26516b8ed64cfef60ac6744b0fe193884460. Revert "I18n: Add crowdin PRs to project board (#82059)" This reverts commit 5b9b990220a83cafeec6dd77aa6ef1064ef27ee5. * Add crowdin pr to frontend platform project board * refactor: clean up first trial --------- Co-authored-by: Laura Benz commit 791ead7806dc15e23430ade3a8790b424df0bfd2 Author: Gilles De Mey Date: Wed Feb 7 18:02:20 2024 +0100 Alerting: Detail view part 3 (#81286) commit 3464b6e5816e60328a9acd466b10de2f3f7bdcf3 Author: Ryan McKinley Date: Wed Feb 7 08:46:43 2024 -0800 Peakq: support multi-value template variables (#82036) commit e77ec26897cb528091360059829350e8967e563c Author: Gilles De Mey Date: Wed Feb 7 17:13:05 2024 +0100 UI: No interactive table pagination controls for rows < page size (#82109) commit 3ad860a63c1fb65e71dffaedcf91a6e85b764377 Author: Kyle Brandt Date: Wed Feb 7 10:48:53 2024 -0500 PeakQ: (Chore) Update Render to work with UTF (#82107) commit ef6e3153222bf6ce461302fc512eb4129a197372 Author: Ashley Harrison Date: Wed Feb 7 15:39:15 2024 +0000 Chore: use `Box` in command palette empty state (#82101) * use Box * use paddingTop/paddingBottom commit 00d69bc8e5e76289b981271d09ef118a66a8da5c Author: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Wed Feb 7 16:37:42 2024 +0100 i18n: Phrases for login page (#81478) * i18n markup for login components * Add serviceName to translation * Fix typo * Reset from main * Extract * Fix extract commit 843c4778990f559e9c3b82e89e5b86a57c68ea75 Author: Alexander Weaver Date: Wed Feb 7 09:31:22 2024 -0600 Alerting: Add exported API to scheduler to access currently loaded rules (#82031) * Add exported API to fetch rule definitions from scheduler * Add comment commit 9fb04be7ffd69428d2d78d6de84d1dd5ca344208 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Wed Feb 7 10:26:39 2024 -0500 enhancement: use SVG icon for cascader expand instead of `>` (#81844) * feature: use icon for cascader expand commit 832eda79631ab918e916b3f127cd3c3591f1d1f0 Author: Jack Baldry Date: Wed Feb 7 15:01:54 2024 +0000 Add missing step (#82066) commit 30730ebdd8bf298c88570d91a07ba6d62b52dfa6 Author: Josh Hunt Date: Wed Feb 7 14:57:14 2024 +0000 I18n: Add pr to project board (#82096) commit 756cd3c28b0648f55a6c0158f5faf63b632ebe70 Author: Kyle Cunningham Date: Wed Feb 7 21:28:26 2024 +0700 Transformations: Add Group to Nested Tables Transformation (#79952) * Stub group to subframe transformation * Get proper field grouping * Mostly working but fields not displaying 😭 * Fix display processing in nested tables * Modularize and start merging groupBy and groupToSubrame * Get this working * Prettier * Typing things * More types * Add option for showing subframe table headers * Prettier * Get tests going * Update tests * Fix naming and add icons * Betterer fix * Prettier * Fix CSS object syntax * Prettier * Stub alert for calcs with grouping, start renaming * Add logic to show warning message for calculations * Add calc warning * Renaming and feature flag * Rename images * Prettier * Fix tests * Update feature toggle * Fix error showing extra blank row * minor code cleanup --------- Co-authored-by: nmarrs commit 26bc87b60e4983053824fdb4efce8645a17436ce Author: Andres Martinez Gotor Date: Wed Feb 7 15:17:13 2024 +0100 Chore: Replace core plugins as external warning (#81877) commit 114e9e90f3aab988211ba98b6ccb0564456f420e Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Wed Feb 7 14:09:21 2024 +0000 Scenes/PanelEditor: Fix panel options search crash (#82003) Co-authored-by: Dominik Prokop commit 00aa876e467e8873b319ea0aef3bedab813cf444 Author: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Wed Feb 7 06:32:08 2024 -0700 Dashboard Scene: All panel menu items available (#81678) * WIP: removing panel functionality * wip * Deleting works with old model and with unified alerting * Add shortcut for removing panel * Add duplicate panel functionality; improve remove panel logic * Copy and new alert rule * Hide legend * WIP: Help wizard * Fix PanelMenuBehavior tests * Got help wizard to work in scenes * Fix HelpWizard and SupportSnapshotService tests * Use object for writing styles * betterer * Fix create lib panel * PanelRepeaterItem should be duplicated * Share randomizer from dashboard-scenes * share randomizer * Fix import * Update error message * Fix test * When duplicating PanelRepeaterGridItem's child use PanelRepeaterGridItem.state.source * Don't use getResultsStream --------- Co-authored-by: Torkel Ödegaard Co-authored-by: Dominik Prokop commit b5f26560c29b07d04c084eec80513438c4b027f8 Author: Nathan Vērzemnieks Date: Wed Feb 7 13:53:05 2024 +0100 Cloudwatch: Remove core imports from infra/log (#81543) * Cloudwatch: use logging from plugin-sdk-go instead of infra/log * Add a link to the TODO issue for moving `instrumentContext` commit c16cb7ed3c98aea12a98ebefd485bec16d6d6e12 Author: Josh Hunt Date: Wed Feb 7 12:47:21 2024 +0000 I18n: Fix workflow for adding PR to project board (#82078) commit 79cfd67fde60c00c2285633563d1a697ebebb0cb Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Feb 7 12:43:57 2024 +0000 Update dependency react-hook-form to v7.50.1 (#82072) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit fb4de35c0e33387bb2b20bebd8fcfd4efe8e87ec Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Feb 7 12:42:58 2024 +0000 Update dependency react-zoom-pan-pinch to v3.4.2 (#82073) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 10a130191fc7acb21c321c51c5addfbc202f405c Author: Josh Hunt Date: Wed Feb 7 12:21:19 2024 +0000 I18n: Use correct project and pull request ID (#82070) * use correct project and pr ids * add github token * fix env commit 9c0501a1674ba66169e082984f36534427a0eaf8 Author: Andres Martinez Gotor Date: Wed Feb 7 13:00:13 2024 +0100 Chore: Fix rootDir path when compiling core ds (#82068) commit 1f2f85004d23e3a949ec87555a07f68a1182498b Author: Oscar Kilhed Date: Wed Feb 7 12:59:18 2024 +0100 Scenes: Add alerts from panel edit (#81588) * Display alerts in scenes dashboard * sort of working adding alerts * move alert button to own component * First fixes * Generate link/url on click * some cleanup * making sure all links from scene go back to scene dashboard/panel; add rule button when there are rules; styling * remove unused import * add &scenes to url for alert instance annotations * Add tests from old alert tab * Revert addition of &scenes to dashboard urls * Refactor to simplify NewAlertRuleButton interface * update test * Use the raw range to calculate the relative range --------- Co-authored-by: Torkel Ödegaard Co-authored-by: Dominik Prokop commit 420a23c064073e9658cc4252f1d79bb121b477c4 Author: Gábor Farkas Date: Wed Feb 7 12:42:22 2024 +0100 sql: simplify time-conversion code (#81499) sql: simplify code commit 370e973256397060f18fb1cc4e51e917a5a129d9 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Feb 7 11:35:25 2024 +0000 Update dependency html-loader to v5 (#82062) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 945e26516b8ed64cfef60ac6744b0fe193884460 Author: Josh Hunt Date: Wed Feb 7 11:19:40 2024 +0000 Fix incorrect quotes in crowdin-download github action (#82063) commit f5a60568b47043f708262bef6b9b8bd088cea412 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Feb 7 13:18:52 2024 +0200 Update dependency fork-ts-checker-webpack-plugin to v9 (#82061) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit af8ea896d0e347fb28e3b7c57088a7e880d0187d Author: Ashley Harrison Date: Wed Feb 7 11:14:04 2024 +0000 GroupBy: add new `groupby` variable type and optional `groupByKeys` (#81717) * add new groupby type * rename to groupByKeys + introduce GroupByVariableModel * fix unit test * update scenes package * update interface * update fixture * update unit test * bump to scenes 2.6.2 * remove baseFilters for now commit 5b9b990220a83cafeec6dd77aa6ef1064ef27ee5 Author: Josh Hunt Date: Wed Feb 7 11:01:04 2024 +0000 I18n: Add crowdin PRs to project board (#82059) Add i18n prs to frontend platform project board commit 17358bd7c9d31a29c59e509ac5b8f2b91bdd81f8 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Feb 7 12:43:33 2024 +0200 Update dependency expose-loader to v5 (#82058) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit c3865771ca372960370d149125ef06d6c009fa69 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Feb 7 12:40:28 2024 +0200 Update dependency react-hook-form to v7.50.1 (#82057) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit ff21f941df77105351d5ff8ce4b912c68007d30a Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Wed Feb 7 10:21:53 2024 +0000 Parca: Fix flaky test (#81968) Fix flaky test commit c4cee7555152cc8288cf913b2a9c993576ee4dad Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Feb 7 10:20:19 2024 +0000 Update dependency eslint-config-prettier to v9 (#82013) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 76076717918c94afa62fec0596c89a2c54f99dd4 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Feb 7 10:19:56 2024 +0000 Update dependency eslint-plugin-jsdoc to v48 (#82014) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 899e06b439c9872b0643824d986b047653d31ee5 Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Wed Feb 7 11:04:13 2024 +0100 Alerting docs: changes alerting rules to alert rules (#82049) * Alerting docs: changes alerting rules to alert rules * ran prettier commit 26795999687af9aa4766f68cbc87a2c14c8e1901 Author: ismail simsek Date: Wed Feb 7 11:01:09 2024 +0100 Prometheus: Remove grafana/pkg/setting imports (#81813) * upgrade grafana-azure-sdk-go v1.12.0 * add AzureAuthEnabled to config * remove grafana/pkg/setting imports * remove grafana/pkg/setting imports * remove comment * add better error message * Fix the unit test * fix lint commit 9a045e24e84c8581682efcf5ef5f0e52ed87e61b Author: ismail simsek Date: Wed Feb 7 10:51:30 2024 +0100 Chore: Bump grafana-plugin-sdk-go version to v0.207.0 (#82028) * bump grafana-plugin-sdk-go version to v0.207.0 * update go.sum commit f59e76d54b0f5ea11e23f2f59eb3281682c7d1d0 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Wed Feb 7 09:54:31 2024 +0100 Alerting: Use mute_time_intervals as field name in dto ,for simplified routing (#82045) Use mute_time_intervals as field name in dto ,for simplified routing commit a385ae4fa51a99b526880b1d3eb9b3802f052306 Author: Dominik Prokop Date: Wed Feb 7 00:51:25 2024 -0800 DashboardScene: Do not show data pane for panels with `skipDataQuery` (#81934) * Bump scenes * Don't show data pane for panels without data support * Add tests * Review commit 8616f2e80ae4df7203d3d025292ed45a64b3410a Author: Gábor Farkas Date: Wed Feb 7 08:41:49 2024 +0100 sql: removed unnecessary error-check (#81808) * sql: removed unnecessary error-check * updated tests commit 7b8c7b623c91b9172a0ea658764ae6e3669b020f Author: Gábor Farkas Date: Wed Feb 7 08:39:24 2024 +0100 sql: remove the usage of "errutil" (#81888) commit 40a08a7720e65c3c6fcd03d437d78b5bc9d7b8cb Author: Ryan McKinley Date: Tue Feb 6 16:10:32 2024 -0800 K8s: use +enum tag in playlist and unstructured dummy (#82022) commit 83ea51f2414f926193e3b56b8f23e1f510168d12 Author: Ezequiel Victorero Date: Tue Feb 6 19:29:57 2024 -0300 Scenes: Add snapshots view support (#81522) commit 47546a4c7289a6a39628ae085ac0ceb3e1fa2018 Author: Yuri Tseretyan Date: Tue Feb 6 17:12:13 2024 -0500 Alerting: Update API to use folders' full paths (#81214) * update GetUserVisibleNamespaces to use FolderSeriver * update GetNamespaceByUID to use FolderService.GetFolders * update GetAlertRulesForScheduling to use FolderService.GetFolders * Update API and GetAlertRulesForScheduling to use the folder's full path * get full path of folder in RouteTestGrafanaRuleConfig * fix escaping of titles for MySQL commit 6f8852095e31fa26c6a089a28a714f110dd5c42d Author: Kevin Yu Date: Tue Feb 6 12:22:41 2024 -0800 CloudWatch: Remove core imports from CloudWatchRequest (#81422) * CloudWatch: Remove core imports from CloudWatchRequest and use appEvents for notifications * add error message to variable query editor multi filters * fix tests * fix lint * fix lint * add test for non multi-variable * pr comments commit a052dab7bc471c6643ce96a1eaae930a23b3946c Author: Ryan McKinley Date: Tue Feb 6 12:09:33 2024 -0800 K8s/Playlists: Only dual write when an external storage is configured (#82015) commit 315eb5acc346370340f84295463db22201fb5c15 Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Tue Feb 6 14:00:19 2024 -0600 VizTooltips: Remove default width/height values (#81832) commit 6373557c7882d499bd77acd13a0a0c8b41d48b8e Author: Kevin Yu Date: Tue Feb 6 11:24:14 2024 -0800 CloudWatch: remove core imports from CloudWatchMetricsQueryRunner (#80926) * CloudWatch: remove core imports from CloudWatchMetricsQueryRunner * use getAppEvents to publish error * use default wait time * put test back in original position * fix throttling error message link commit 3b852bb582e779708ebc9522b5ad6c3c405a7207 Author: Isabella Siu Date: Tue Feb 6 14:21:55 2024 -0500 CloudWatch: Remove references to pkg/infra/metrics (#81744) CloudWatch: remove references to pkg/infra/metrics commit aafff738994ead58077e76dc137e7577f6cb139e Author: Kevin Yu Date: Tue Feb 6 10:58:49 2024 -0800 CloudWatch: Remove core imports from LegacyLogGroupSelector (#80843) commit 0d3cf9a08bda76462c822bd99cb4ce4473e3402c Author: Kevin Yu Date: Tue Feb 6 10:44:42 2024 -0800 CloudWatch: Remove core imports from ConfigEditor (#80837) * CloudWatch: Remove core imports from ConfigEditor * test commit 484106eb04025b77a8eb7b32afa2e02b8377c9ae Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 6 20:28:48 2024 +0200 Update typescript-eslint monorepo to v6.21.0 (#82004) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit f733bc46c915f5a44ea3bf6b85e306a48184b565 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 6 20:20:21 2024 +0200 Update swc monorepo (#82002) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit d9b8bae02876e63db9f202ab02a5df33550f035c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 6 20:04:29 2024 +0200 Update dependency react-loading-skeleton to v3.4.0 (#81975) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 0b72ee2b770314cc744a1eeab6f07ac9e7e9c7b8 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 6 19:58:42 2024 +0200 Update dependency semver to v7.6.0 (#81989) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit d3ad41d8d940ddaca18f11547bdaf1ccd20d058d Author: Ashley Harrison Date: Tue Feb 6 17:56:50 2024 +0000 Chore: Ignore fingerprintjs in renovate (#81973) ignore fingerprintjs commit 33498d08ef63c07f327a2da9e672134887dc81d7 Author: Eric Leijonmarck Date: Tue Feb 6 16:49:37 2024 +0000 Team LBAC: docs (#77924) * initial commit for docs * docs update for team lbac * replace default rule doc with restrict access * new docs refactored * updated based on review * renaming of the file, to include the changes * review comments * fix linting * formatting * review comments * updated docs with better formating * formatting * adding a bit of context to lbac * update based on review from srash * added note commit 91754bcda56141fafe8afc9d48012c0d60feaed6 Author: Ryan McKinley Date: Tue Feb 6 08:40:35 2024 -0800 K8s: Refactor standalone apiserver initialization (#81932) commit abaed01d7e590abad104c2b7ba3c3c92995be643 Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Tue Feb 6 11:22:41 2024 -0500 K8S/Shared Queries: Add Experimental PeakQ API (#80839) First pass at a backend api built on top off app platform for shared-queries in explore and a query library with templating. Highly Experimental. Under grafanaAPIServerWithExperimentalAPIs = true Co-authored-by: Ryan McKinley Co-authored-by: Kyle Brandt commit ef9eca3a755b0541a4bfd151558a3515940a54e5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 6 15:54:33 2024 +0000 Update dependency @grafana/scenes to v2.6.3 commit 6697da61768fb23341465f3b8ef5e64575543fe5 Author: DugeraProve <112474797+DugeraProve@users.noreply.github.com> Date: Tue Feb 6 16:21:49 2024 +0000 Cloudwatch: Add AWS/EMRServerless and AWS/KafkaConnect Metrics (#79532) commit 3942d67f89b411ffe45f504c142303722ee7f677 Author: paulJonesCalian <151241545+paulJonesCalian@users.noreply.github.com> Date: Tue Feb 6 10:20:42 2024 -0600 chore: Fix typo in GraphTresholdsStyleMode enum (#81960) commit ec5623f5524d1187474074079f37b080461b706a Author: Esteban Beltran Date: Tue Feb 6 16:47:29 2024 +0100 Sandbox: Fix cloneDeep not working inside plugins (#81950) commit 4af5aef417950dddd3ed7046ad95d7fe315eb12c Author: Gabriel MABILLE Date: Tue Feb 6 16:26:17 2024 +0100 id forwarding: transfer Grafana id token to app plugins (#81967) * id forwarding: allow for app plugins as well * Add test commit eab7990349d70da5b7461cdb582c14ae4a2c4a43 Author: Ezequiel Victorero Date: Tue Feb 6 12:21:15 2024 -0300 ShareModal: Remove shareView param when creating a sharing URL (#81976) commit cf601fab0906f1d811f1aa2fe18a67bc5421e3db Author: Gokhan Date: Tue Feb 6 18:19:22 2024 +0300 Alerting: Enable sending notifications to a specific topic on Telegram (#79546) Co-authored-by: Yuri Tseretyan commit ce12eba8586b993f54c601ea8448e5d86d400f8a Author: Ryan McKinley Date: Tue Feb 6 07:09:40 2024 -0800 K8s/Folders: Add all features to k8s endpoints (#81858) commit 2ea82af6e7ffa3189f62fc9445b6498e89c78563 Author: William Wernert Date: Tue Feb 6 09:49:47 2024 -0500 Alerting: Pass in receiver service to API struct (#81978) commit 0015a31b7987c0651d47532e3ad23d98eb90a5d9 Author: Brendan O'Handley Date: Tue Feb 6 08:27:43 2024 -0600 Prometheus: Frontend library update version and readme (#81922) update version and readme commit ead2de22a983c84c682c36f86c1827842c83f4bd Author: Brendan O'Handley Date: Tue Feb 6 08:27:27 2024 -0600 Prometheus: Frontend library use AGPL license (#81921) * use AGPL license * update package.json commit ec0eb8fd794b44051788b6493c7544d15561c040 Author: Brendan O'Handley Date: Tue Feb 6 08:27:09 2024 -0600 Prometheus: Frontend library exports (#81909) * fix components folders for exports * export files in the components folder * export metrics modal and fix exports * export promquail and fix exports * export querybuilder/components and fix exports * export querybuilder * main index file exports * run prettier commit 32a7aa33b8e8df6f4ab0a6935139152daa96a0cb Author: Laura Benz <48948963+L-M-K-B@users.noreply.github.com> Date: Tue Feb 6 15:25:11 2024 +0100 I18n: Refactor action (#81970) refactor commit 3d3264668b507464a96308c3364e7dd52509fed8 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 6 14:00:05 2024 +0000 Update dependency react-hook-form to v7.50.1 commit 15223a4b85733b25c2190f940d54157ff388fbfb Author: Yuri Tseretyan Date: Tue Feb 6 09:18:40 2024 -0500 Folders: Set FullPath and FullPathUIDs when feature flag is off and query requests (#81896) * set FullPath and FullPathUIDs if feature flag is off and query requests Co-authored-by: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> commit 92e05ec8e9e66b3eaeb5e3c4ac4f6fb024e3e6a6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 6 13:54:04 2024 +0000 Update dependency postcss to v8.4.34 (#81962) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 28b336ac8081036ba830709c6c4edf25a313180f Author: Ashley Harrison Date: Tue Feb 6 13:43:11 2024 +0000 DockedMegaMenu: Clean up toggle and old code (#81878) * remove toggle * remove code not behind toggle * remove old MegaMenu * rename DockedMegaMenu -> MegaMenu and clean up go code * fix backend test * run yarn i18n:extract * fix some unit tests * fix remaining unit tests * fix remaining e2e/unit tests commit 390461f9e140427c2e72658fe9e7a25266d3b68a Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Tue Feb 6 13:30:59 2024 +0000 Tempo: Move query ref to TraceQLEditor (#81686) Move ref to TraceQLEditor commit 679d90be04fe6035e4a909d487130f75a4a8e1c1 Author: Wil.P <129160990+espyra88@users.noreply.github.com> Date: Tue Feb 6 14:26:31 2024 +0100 Patch 3 (#81937) * Update _index.md moved to the asked location * ran prettier * Update docs/sources/alerting/set-up/provision-alerting-resources/_index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/provision-alerting-resources/_index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> --------- Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> commit a6e27d1622c6ab2e8d7d8f80d4280d62b61c48c2 Author: Diego Augusto Molina Date: Tue Feb 6 10:17:37 2024 -0300 Chore: Fix plugins manager process data race in tests (#81914) * Chore: synchronize writes to pkg.plugins.log.Logs to prevent data races in test code * Chore: fix data race in tests in plugins process manager * Chore: improve Logs method naming * Chore: fix type change commit 1b63c27bb4cfe0bfdad4aaa71bc84d460ae741b6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 6 12:23:47 2024 +0000 Update dependency @grafana/scenes to v2.6.2 commit 16df04e2608f39b3dc27ff075fabd8d40b494406 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Tue Feb 6 08:08:57 2024 -0500 datatrails: improve width responsiveness for breakdown label selector (#81838) * fix: make datatrails breakdown label selector responsive commit cf616d5074003364f5aac0edf7b402e8e317eb73 Author: Jo Date: Tue Feb 6 14:06:19 2024 +0100 Remove X-Grafana-Device-Id from outbound requests (#81957) commit 7292b84614216f1b4ee028d8139d6ce2f5980f52 Author: Laura Benz <48948963+L-M-K-B@users.noreply.github.com> Date: Tue Feb 6 13:44:54 2024 +0100 I18n: Add Crowdin PRs to project board (#81947) refactor: add Crowdin PRs to project board commit fa30293987e43f1783b4cd5785022c11a1d5c55e Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Tue Feb 6 07:24:10 2024 -0500 Docs: remove disable scaling units entry (#81917) * Removed disable scaling units entry * Removed QoL section commit 2a114c1c37f4a3d13b7549fab14ded2c4aa9089f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 6 10:14:20 2024 +0000 Update dependency marked-mangle to v1.1.7 commit e2c27042965f35548ec96004a4887f6ea06733bb Author: Ashley Harrison Date: Tue Feb 6 10:46:24 2024 +0000 Browse Dashboards: Imported dashboards now display immediately in the dashboard list (#81819) * create importDashboard method in rtk query * fix unit tests * Revert "fix unit tests" This reverts commit 72cd81c8036d9222043ebf52db5e2a28394fa3c5. * fix unit test commit 8c38ebfeae32e7885684938be042c6b79f2bbbb5 Author: Ashley Harrison Date: Tue Feb 6 10:37:56 2024 +0000 Command Palette: Add empty state (#81903) * add an empty state for the command palette * cleaner logic commit 994aedaac39beea75fa9fb80178ff3f7d9ac056c Author: ismail simsek Date: Tue Feb 6 11:31:55 2024 +0100 Chore: Update InfluxDB response parser test files (#81899) * add show diagnostics tests * update test files commit ec1b669ce63cde99de25c00fbd89a82e939d4b34 Author: Laura Benz <48948963+L-M-K-B@users.noreply.github.com> Date: Tue Feb 6 11:15:26 2024 +0100 I18n: Export only approved translations (#81943) refactor: export only approved translations commit 17710104505d2f32d2a36f30c77d798bec65dd11 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 6 09:52:00 2024 +0000 Update dependency @types/react to v18.2.55 commit eef491d407131c9bd4be940b0da050a8a3c461be Author: Josh Hunt Date: Tue Feb 6 10:03:26 2024 +0000 Chore: Force transitive resolution of debug (#81908) Force resolution of debug commit 4b909365024d14c55f0ace0db78a886541a48e0a Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Feb 6 09:43:53 2024 +0000 Update dependency @grafana/scenes to v2.6.1 (#81906) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 90612500e0470bafb84ade7f6da84938cb5397c7 Author: Laura Benz <48948963+L-M-K-B@users.noreply.github.com> Date: Tue Feb 6 10:38:09 2024 +0100 I18n: Adjust GH actions workflow (#81887) * refactor: add github-actions bot to ignoreList * refactor: change GH user commit 3605d85c4ceab46f3390444e8727b20bceb0ce8e Author: Sergej-Vlasov <37613182+Sergej-Vlasov@users.noreply.github.com> Date: Tue Feb 6 09:20:07 2024 +0000 Dashboards: Remove `advancedDataSourcePicker` feature toggle (#81790) * remove advancedDataSourcePicker feature toggle from DataSourcePickerWithPrompt * remove advancedDataSourcePicker toggle from DataSourcePicker and adjust tests that relied on old picker * adjust failing tests in QueryVariableEditorForm * move DataSourceDropdown to DataSourcePicker file * covert style declaration syntax to object style in DataSourcePicker * remove advancedDataSourcePicker feature flag from registry * remove .only from test * adjust QueryVariableEditor test to avoid console.error commit 96301ce5338641f098bcf353bc0dae791bf3f6e0 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Tue Feb 6 08:58:08 2024 +0000 Tempo: Trace to metrics docs (#81548) * Trace to metrics docs and share variables that can be used in query section * Update text * Add span start/end * Update docs/sources/shared/datasources/tempo-traces-to-profiles.md Co-authored-by: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> * Update docs/sources/datasources/tempo/configure-tempo-data-source.md Co-authored-by: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> * Update docs/sources/datasources/tempo/configure-tempo-data-source.md Co-authored-by: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> * Update text * Update docs/sources/datasources/tempo/configure-tempo-data-source.md --------- Co-authored-by: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> commit c8ccc4649c576720a47a314158b49d7eca096046 Author: George Robinson Date: Tue Feb 6 08:33:47 2024 +0000 Alerting: Support UTF-8 (#81512) This pull request updates our fork of Alertmanager to commit 65bdab0, which is based on commit 5658f8c in Prometheus Alertmanager. It applies the changes from grafana/alerting#155 which removes the overrides for validation of alerts, labels and silences that we had put in place to allow alerts and silences to work for non-Prometheus datasources. However, as this is now supported in Alertmanager with the UTF-8 work, we can use the new upstream functions and remove these overrides. The compat package is a package in Alertmanager that takes care of backwards compatibility when parsing matchers, validating alerts, labels and silences. It has three modes: classic mode, UTF-8 strict mode, fallback mode. These modes are controlled via compat.InitFromFlags. Grafana initializes the compat package without any feature flags, which is the equivalent of fallback mode. Classic and UTF-8 strict mode are used in Mimir. While Grafana Managed Alerts have no need for fallback mode, Grafana can still be used as an interface to manage the configurations of Mimir Alertmanagers and view configurations of Prometheus Alertmanager, and those installations might not have migrated or being running on older versions. Such installations behave as if in classic mode, and Grafana must be able to parse their configurations to interact with them for some period of time. As such, Grafana uses fallback mode until we are ready to drop support for outdated installations of Mimir and the Prometheus Alertmanager. commit 613da422ca6d2f84bca4a1c677eb9345d93cae15 Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Tue Feb 6 10:32:55 2024 +0200 Scenes: Migrate permissions settings page (#81781) * Migrate permissions settings page * fix commit 33f80f7a168a8c3f38897daf0f4246ef4c907cd9 Author: Gábor Farkas Date: Tue Feb 6 09:29:50 2024 +0100 mysql: remove dev-mode log (#81894) commit c9de794d7f3936050f01999c0da11873c8f0e8e9 Author: Carl Bergquist Date: Tue Feb 6 09:29:41 2024 +0100 instrumentation: these features have been enabled for a while (#81617) Signed-off-by: bergquist commit bd48c06f9509a19fc20a4482cfe065fe81285e61 Author: Sven Grossmann Date: Tue Feb 6 07:11:54 2024 +0100 Elasticsearch: Set middlewares from Grafana's `httpClientProvider` (#81814) Elasticsearch: Set middlewares from httpClientProvider commit 2d25ce8aab30fda75f0f7387db71da576046121c Author: Clarence Date: Mon Feb 5 20:46:35 2024 +0100 chore: update typos found in docs (#81850) commit a6342fa576bfb1b0b1fc9b21a278a6b55daa347a Author: Diego Augusto Molina Date: Mon Feb 5 16:41:38 2024 -0300 Chore: Fix data race within tests and enable a few parallel tests in ssosettingsimpl (#81837) * Chore: Fix data race within tests of SSO Setting implementation * Chore: fix data race within tests to allow parallel testing * Chore: rollback changes runtime code to test a different approach * Chore: Fix data race in SSO Setting implementation Upsert method * Chore: fix typo in comment commit b6373249d300e18e6365e780c6f97bdcd86787cf Author: Brendan O'Handley Date: Mon Feb 5 13:06:04 2024 -0600 Prometheus: Update prometheus frontend library codewowners (#81912) update prometheus frontend library codewowners commit 81da3ff753c7124f69d1cf2a3fa7384908565390 Author: Isabella Siu Date: Mon Feb 5 13:59:32 2024 -0500 CloudWatch: Remove dependencies on grafana/pkg/setting (#81208) commit 2ab7d3c725969927434cb7af71c6cdba9fc21df7 Author: William Wernert Date: Mon Feb 5 13:12:15 2024 -0500 Alerting: Receivers API (read only endpoints) (#81751) * Add single receiver method * Add receiver permissions * Add single/multi GET endpoints for receivers * Remove stable tag from time intervals See end of PR description here: https://github.com/grafana/grafana/pull/81672 commit 49b49e28afe9fed812a836dafd2392516610fe8f Author: Jev Forsberg <46619047+baldm0mma@users.noreply.github.com> Date: Mon Feb 5 10:10:53 2024 -0700 Baldm0mma/xy tooltip tests (#81746) * baldm0mma/xyTooltipTests/ add first tests * passing test!!! * baldm0mma/xyTooltipTests/ add tests * baldm0mma/xyTooltipTests/ revert destructure move * baldm0mma/xyTooltipTests/ clean up test * baldm0mma/xyTooltipTests/ update test names * baldm0mma/xyTooltipTests/ ignore ts issue * baldm0mma/xyTooltipTests/ update any * baldm0mma/xyTooltipTests/ add follwing types * baldm0mma/xyTooltipTests/ remove "field" * baldm0mma/xyTooltipTests/ remove debug commit d5ac9340e5763d7e68cbe179514c7dc190c75296 Author: Leon Sorokin Date: Mon Feb 5 11:09:36 2024 -0600 Histogram: Add approx bucket count option (#80990) commit ce0c6a97873da1536dd28d725e5770854db449c2 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 5 18:49:20 2024 +0200 Update dependency webpack to v5.90.1 (#81901) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 6a1f58df3bb59156f3de2fea365ae8c7a7b7af63 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 5 15:49:46 2024 +0000 Update dependency rudder-sdk-js to v2.48.1 commit 57993f65edee6f5d5c1964df5f1f433041a336cd Author: Josh Hunt Date: Mon Feb 5 16:25:19 2024 +0000 Chore: webpack alias react and grafana-runtime to share singletons (#81789) * Chore: webpack alias react and grafana-runtime to share singletons * Move alias to dev webpack, add alias for grafana-data as well * remove whitespace commit 7852ea012df561ae452218cf08a2d95bd14a3e71 Author: Jo Date: Mon Feb 5 17:00:19 2024 +0100 Access: Remove split scopes feature toggle (#81874) * remove split scopes FT * Revert "remove split scopes FT" This reverts commit 349fb081d3f1f39be3df58e0aa4e1f0570288f27. * make toggle deprecated instead * fix gen commit 596e8281508f246b84868129c1ba16f2b6e566a1 Author: Gabriel MABILLE Date: Mon Feb 5 16:44:25 2024 +0100 Fix: Refresh token when id_token is expired (#79569) * Fix: Refresh token when id_token is expired * add id_token comparison * Fix wire * Use userID as cache key * Apply suggestions from code review --------- Co-authored-by: linoman <2051016+linoman@users.noreply.github.com> Co-authored-by: Misi commit 62806e8f8ca5895a60a481e8c20e8d0bd98a2014 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 5 15:15:14 2024 +0000 Update dependency react-zoom-pan-pinch to v3.4.2 commit 1fe32ce36ead4f27a8c56d7789beb8b11a17d7ae Author: Torkel Ödegaard Date: Mon Feb 5 16:42:22 2024 +0100 FieldOptions: Revert scalable unit option as we already support this via custom prefix/suffixes (#81893) * StandardFieldOptions: Revert scalable unit option * forgot to save merge fixes commit b02f0b926afb25b5eb895fb8527ed2dfbf3426a0 Author: Diego Augusto Molina Date: Mon Feb 5 12:25:54 2024 -0300 Settings: Fix data race when dynamically overriding settings with environment variables (#81667) Chore: Fix data race when dynamically overriding settings with environment variables commit c87e4eb7241f53f00953537b2e12319c88c658ac Author: Matias Chomicki Date: Mon Feb 5 16:20:50 2024 +0100 Log rows: add re-renders test cases (#81566) * Log rows: add re-renders test cases * Logs panel: add re-render test cases * Logs panel: update setup and properly trigger rerenders commit 61c7fcc270075e440d9c60dba0c070630369675e Author: Torkel Ödegaard Date: Mon Feb 5 16:08:12 2024 +0100 DashboardScene: Action toolbar progress (#81664) * DashboardScene: Action toolbar progress * Add discard confirmation modal * minor fix * Update * tweaked * Updating * Progress * Update * Update * Added some unit tests * fix test * Change name to Exit edit * Tweaks * fix test * Minor margin fix * Move share to left of edit commit 9b18a4d45ecdc6439f11f560002c37140d14ffaf Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 5 14:31:49 2024 +0000 Update dependency react-virtualized-auto-sizer to v1.0.22 commit 96772e1a3236e891038a29782018fa5b9da6233b Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Mon Feb 5 09:45:47 2024 -0500 datatrails: improve handling of error states and disable states (#81669) * fix: Cascader: allow disabled state * fix: datatrails metrics selection scene stability - clear panels and filter data when datasource changes - detect metric names loading / error state and disable components accordingly - put all scene variable dependencies together - reset metric names without clearing panels when time range changes commit fca19a7ba6ef70b3e31c77ea0138341340d4868e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 5 14:10:14 2024 +0000 Update dependency prettier to v3.2.5 commit d3f7231a276ede2b620a1b4e0df5127ccd8a844a Author: Torkel Ödegaard Date: Mon Feb 5 15:25:12 2024 +0100 DashboardScene: Reload when someone else saves changes (#81855) * DashboardScene: Reload when someone else saves changes * Update public/app/features/dashboard-scene/pages/DashboardScenePage.tsx Co-authored-by: Dominik Prokop * Fixes --------- Co-authored-by: Dominik Prokop commit 8f65e36b068ef1f5eb9871ff553912b4af5ad231 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Mon Feb 5 15:14:08 2024 +0100 Alerting: Show warning when cp does not exist and invalidate the form (#81621) * Show warning when cp does not exist and invalidate the form * Set error in contact selectedContactPoint form field manually * use RHF validate and trigger * Fix defaultvalue not being set in contact point and update the error message text * Simplify refetchReceivers prop definition in ContactPointSelectorProps --------- Co-authored-by: Gilles De Mey commit d63590112f135bd59c1a37df98a624fc670181a1 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 5 13:20:25 2024 +0000 Update dependency @testing-library/jest-dom to v6.4.2 commit ba70ee9b50344ff7ca0c2bd5d8f75f83e0d51034 Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Mon Feb 5 15:06:28 2024 +0100 Docs: adds update to x-provenance (#81863) * Docs: adds update to x-provenance * Prettier Signed-off-by: Jack Baldry --------- Signed-off-by: Jack Baldry Co-authored-by: Jack Baldry commit 600942503517d3a2e3994d399ff222acdd407fc3 Author: Jack Westbrook Date: Mon Feb 5 14:21:50 2024 +0100 Chore: Bump follow-redirects to latest (#81806) chore(yarn): bump follow-redirects to please dependabot commit 4d5ec1096f2c90e9112065d959fb492a93016791 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 5 12:58:17 2024 +0000 Update dependency moment-timezone to v0.5.45 (#81881) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit d17a77f4a2fe28199a42f2326855579f3c98a822 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 5 12:36:43 2024 +0000 Update dependency @types/react to v18.2.53 (#81879) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 2002a6c4fb112b1a96abb09401fefadf34c826e8 Author: ismail simsek Date: Mon Feb 5 13:36:33 2024 +0100 Chore: Bump grafana-azure-sdk-go to v1.12.0 and expose AzureAuthEnabled value in config (#81807) * upgrade grafana-azure-sdk-go v1.12.0 * add AzureAuthEnabled to config commit 80e58f98a70b0590a2f9806ca629d445f2d8448c Author: Laura Benz <48948963+L-M-K-B@users.noreply.github.com> Date: Mon Feb 5 13:33:37 2024 +0100 I18n: Fix path for Crowdin upload (#81733) * refactor: upload action * refactor: move parameters from config to download action commit 35514d7bc6f06c9e29d3ce60d0825f1b1e8abe87 Author: ismail simsek Date: Mon Feb 5 13:15:54 2024 +0100 Chore: Update grafana-prometheus test types (#81816) * Update types * update betterer commit b6668b47ef105c4075d6267203560c4b23d0c02b Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 5 13:47:15 2024 +0200 Update dependency @types/node to v20.11.16 (#81876) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit a5d890984bb1c88fbb9e83db511eddf23010f130 Author: Torkel Ödegaard Date: Mon Feb 5 12:32:59 2024 +0100 Dashbboard: Fixes time picker schema default issues (#81847) * Dashbboard: Fixes time picker schema default issues * Fix tests * fix imports commit b2730d194b3612603f89e5bb0cc7029e9d4e6f90 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 5 11:31:21 2024 +0000 Update dependency @types/jest to v29.5.12 (#81875) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 071b301e3920fd33d743f635bf72912972dc36bd Author: Torkel Ödegaard Date: Mon Feb 5 12:27:44 2024 +0100 Dashboard: Dashboard schema fixes and scene to save model fixes (#81867) commit 9fab7bd27d465203f218f1887624e1e1497b628e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 5 10:53:17 2024 +0000 Update dependency @testing-library/react to v14.2.1 (#81872) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit ded0554bac006c23cec2871c6322d348cc770dd3 Author: Joao Silva <100691367+JoaoSilvaGrafana@users.noreply.github.com> Date: Mon Feb 5 10:48:08 2024 +0000 TextLink: Do not strip base from external urls (#81799) commit f9c54abdcf74e74a573fe8fdcb8e8d3d183592c6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 5 10:03:44 2024 +0000 Update dependency @glideapps/glide-data-grid to v6.0.3 commit 6e3048c6fa9a6f4ce0bc92ef86a5787df158c772 Author: Torkel Ödegaard Date: Mon Feb 5 11:17:33 2024 +0100 DashboardScene: move overlay out from each edit view (#81852) commit ddf124de9d42452d7ac605a500b934fae8f848a9 Author: Gábor Farkas Date: Mon Feb 5 11:00:41 2024 +0100 intervalv2: use duration-formatting logic from plugin-sdk-go (#81690) commit 5c9913b65d7a183aa2d54a62340c4a7af202a737 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 5 11:56:12 2024 +0200 Update dependency @floating-ui/react to v0.26.9 (#81869) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit dcf58555e8aa7a4db3727dbe1118a7bf6a36f234 Author: Ashley Harrison Date: Mon Feb 5 09:47:30 2024 +0000 Chore: Some test type fixes (#81812) some test type fixes commit e50916ae64c0a03fca63b454f39169166dc07bf4 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Feb 5 09:36:02 2024 +0000 Update dependency @testing-library/jest-dom to v6.4.1 (#81820) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit c1547162e460118a017782a9dff92653139f0bfd Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Mon Feb 5 11:35:13 2024 +0200 Dashboards: Restore deleted index (#81859) Dashboards: Recreate deleted index commit ed62aefeb01449543dddc558c2e9271e81881a3e Author: Hugo Kiyodi Oshiro Date: Mon Feb 5 09:32:58 2024 +0100 Plugins: Never disable add new data source for core plugins (#81774) commit d9f7eda28461a53346700ace35493d547d133322 Author: Gabriel MABILLE Date: Mon Feb 5 09:31:20 2024 +0100 AuthN: Switch externalServiceAccounts toggle to public preview (#81583) commit ec5bc7c4ab5396ee68f8851de50957a8f578e783 Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Mon Feb 5 10:06:11 2024 +0200 Folders: Fix failure to update folder in SQLite (#81795) commit 8175b31e16ca8c279b8fa115c27d2952eea5d529 Author: Gábor Farkas Date: Mon Feb 5 08:08:35 2024 +0100 sql: make sure queries do not have fill-params (#81576) commit 202eecccbcd81db59bb8dc490e2790af17104233 Author: Ryan McKinley Date: Sun Feb 4 12:37:10 2024 -0800 K8s: Update common openapi generation scripts (#81857) commit 14a36b4040a6609c21bcc4cd249ec047c05c274b Author: Tania <10127682+undef1nd@users.noreply.github.com> Date: Sun Feb 4 01:16:19 2024 +0100 Folders: Forbid performing operations on folders via dashboards HTTP API (#81264) * Forbid creating folders via dashboard api * Update delete endpoint * Update docs commit bd0fd21852954229c4be9a8243eb8ee372066439 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Sat Feb 3 10:20:04 2024 -0500 fix: datatrails verify different time range before creating new history node (#81839) fix: datatrails verify different time range commit ba3ee60711a16d031a2b5253205ed1cb510ddad4 Author: Ryan McKinley Date: Fri Feb 2 14:19:45 2024 -0800 K8s: Allow more control over the final openapi results (#81829) commit 651faff08a17a36b3796eb8bd92d81caf036fbb2 Author: Ryan McKinley Date: Fri Feb 2 13:03:19 2024 -0800 Chore: go mod tidy after apiserver refactor (#81824) commit 68345b9596af6bc937b7d3e6ae2c47340323005f Author: Kevin Minehart Date: Fri Feb 2 12:59:48 2024 -0600 CI: Add dagger cloud token to PR steps for caching (#81817) * Add dagger cloud token to steps for caching * lint commit f23b9930149ef4821112b05c651c86aa1881786b Author: lean.dev <34773040+leandro-deveikis@users.noreply.github.com> Date: Fri Feb 2 15:45:57 2024 -0300 CloudWatch: Only override contextDialer when using PDC (#80992) commit d65df0191b0c8c06465b0e21131b1c119a5f0ad4 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Feb 2 19:30:27 2024 +0200 Update dependency react-hook-form to v7.50.0 (#81796) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 9b4b0eed995995ed6028a365248910684b7355e5 Author: fabienne-m <82636315+fabienne-m@users.noreply.github.com> Date: Fri Feb 2 16:46:50 2024 +0000 Add translations for new navigation items pdc and collector (#81351) * Add translations for new navigation items pdc and collector * run yarn i18n:extract * run prettier commit ed925c81d4a437f0a38c6b81057f7c9b34e81325 Author: Ryan McKinley Date: Fri Feb 2 08:42:43 2024 -0800 Chore: deprecate/remove folderId from more typescript (#81194) commit 5d7ed2319f6b7c8ed27c4891fbfd7f3a0f23cdde Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Fri Feb 2 11:06:36 2024 -0500 Docs: Restructure configure thresholds docs (#81519) * Removed intro text and About thresholds heading * Added Threshold options H2 with sub-headings and moved Default thresholds to H2 * Rearranged sections and added lorem ipsum placeholder text * Updated heading to Add a threshold, moved delete content to after task, and reformatted task * Replaced lorem ipsum text with TBA * Fixing UI option names * Docs: Edit configure thresholds page (#81520) * Added contractions * Added Threshold value heading and updated options information * Rewrote task * Added Supported visualizations section, updated links, and made general copy edits * Copy edits * Added screenshots for some examples * Edited intro section * Added table, removed note, and added note about options * Updated table and removed bullet list * Added table of threshold examples, other copy edits * Applied suggestions from review * Fixed deprecation note * Updated deprecation note * Replaced local images with uploaded images * Fixed deprecation note commit de4171862c45e30a973f194cd3af68a3dbd58703 Author: Andrej Ocenas Date: Fri Feb 2 16:49:51 2024 +0100 Tempo: Add custom headers middleware for grpc client (#81693) commit 4f1f5636bb98269bed0871bcf0fd9bd7dba28739 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Fri Feb 2 16:48:30 2024 +0100 Tempo: Support backtick strings (#81802) commit aaada8efcf2a99131050b715a172b5aba3f85a8b Author: Jack Westbrook Date: Fri Feb 2 16:07:05 2024 +0100 Chore: Refresh yarn lock file (#81803) chore(yarn): refresh lock file commit bdb592d9ec9d4a2ae4a1de7c05bbb3cebb5cafa9 Author: Oscar Kilhed Date: Fri Feb 2 16:04:23 2024 +0100 Scenes: Refactor panel editor tab counts (#81777) * refactor editor tab counts * Capitalize tab component property since it's a component commit 75c2d39f793c38c53b59d35fabc75bb550439dd0 Author: ismail simsek Date: Fri Feb 2 15:30:14 2024 +0100 Prometheus: Create Prometheus library (#81641) * Move to the library * copy from library * move them in src * have additional files * add unmigrated/dulicated code and files * migrate from brendan's pr module.ts, query_hints.ts, tracking.ts, and remove plugin.json * migrate from brendan's pr metric_find_query.test.ts * migrate from brendan's pr language_utils.test.ts * migrate from brendan's pr index.ts in root and in configuration * migrate from brendan's pr datasource.test.ts * migrate from brendan's pr typings folder * migrate from brendan's pr querycache folder * migrate from brendan's pr monaco-query-field folder * migrate from brendan's pr components folder without monaco-query-field folder * migrate from brendan's pr configuration/overhaul folder * migrate from brendan's pr AlertingSettingsOverhaul.tsx * Remove azure related code * migrate from brendan's pr ConfigEditor.tsx, DataSourceHttpSettingsOverhaul.tsx, ExemplarSetting.tsx, configuration/mocks.ts, PromSettings.test.tsx, PromSettings.tsx * migrate from brendan's pr useFlag.ts * migrate from brendan's pr metrics-modal folder * migrate from brendan's pr files inside components folder * migrate from brendan's pr LabelFilters* files because they are now under components folder * migrate from brendan's pr files under querybuilder/shared folder * migrate from brendan's pr aggregations.ts, QueryPattern.tsx, QueryPatternsModal.tsx, state.ts, testUtils.ts under querybuilder folder * Apply Ivana's PR https://github.com/grafana/grafana/pull/81656 * Apply jack's suggestions in this PR https://github.com/grafana/grafana/pull/77762 * Apply Ivana's PR https://github.com/grafana/grafana/pull/81656 * Fix type import * add monaco-promql to transformIgnorePatterns to run prometheus frontend library tests * remove Loki specific tests because we removed Loki code to decouple Loki * add prometheus specific references * We are moving these betterer issues from core Prometheus to the Library and we promise to remove all issues in the future, thank you * include prometheus library in package.json * add yarn lock with prometheus frontend library * decouple final core import from metric_find_query.test.ts * run prettier * fix core imports in promqail * fix lint errors * run prettier * add grafana-ui to devdeps to fix lint errors * update yarn.lock * grafana-ui fix * trying to fix grafana-ui type errors with lerna drone check * trying to fix grafana-ui type errors with lerna drone check * trying to fix grafana-ui type errors with lerna drone check * trying to fix grafana-ui type errors with lerna drone check * try to pass typecheck --------- Co-authored-by: Brendan O'Handley commit 89271df647e332bdba5d13e899aa8e0457479bae Author: Esteban Beltran Date: Fri Feb 2 15:25:32 2024 +0100 Chore: Levitate to ignore private packages for compatibility comparison (#81798) commit a30f8645d1a0d0c5c7525ef69080a0dc8681d5da Author: Ashley Harrison Date: Fri Feb 2 13:26:37 2024 +0000 Chore: Migrate frontend platform code to use `data-testid` for selectors (#81787) * migrate some stuff from aria-label to data-testid * convert styles to objects * fix unit tests * empty commit to kick drone now enterprise branch is there commit cf6ebb05481133604387a2c25ae592ea8f3e8ffa Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Fri Feb 2 15:11:23 2024 +0200 Swagger: Remove redundant annotation (#81780) commit ab00276d6f24390c36e4ad52e1e63309d3770503 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Feb 2 15:00:59 2024 +0200 Update dependency mini-css-extract-plugin to v2.8.0 (#81793) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit e749c2b062fdacf847ea9f927ef3b2bf76899cc8 Author: Matias Chomicki Date: Fri Feb 2 13:45:20 2024 +0100 Infinite scroll: implementation clean up (#81725) * Infinite scroll: clean up for clarity * Infinite scroll: clean up test * Formatting * Infinite scroll: disable by feature flag * Infinite scroll: improve visibility of the lower loader commit 574078b9773f7035320c0e7e315d33d44fb7a415 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Feb 2 12:23:41 2024 +0000 Update dependency webpack to v5.90.1 commit c92d3b002be2cf61657923c081530e8ae30d466e Author: Gábor Farkas Date: Fri Feb 2 13:18:22 2024 +0100 sql: use interval-formatting from the sdk (#81764) commit e9b8406598520e0caea815009c8894b6ae4a245f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Feb 2 12:18:11 2024 +0000 Update dependency swc-loader to v0.2.4 (#81785) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 42cd4266b4a0653f08c2870ed0e2b44222e09574 Author: Laura Fernández Date: Fri Feb 2 13:14:58 2024 +0100 ReturnToPrevious: make `href` optional (#81691) commit e1197641f34656c9c1ec8d1c20b208d36504a289 Author: Gilles De Mey Date: Fri Feb 2 12:58:04 2024 +0100 Alerting: Fixes for pending period (#81718) commit f2936d669572ac9b97b99edda612913ed6d29cb6 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Fri Feb 2 12:47:45 2024 +0100 Elasticsearch: Fix creating of legend so it is backward compatible with frontend produced frames (#81708) * Elasticsearch: Fix creating of legend so it is backward compatible with frontend produced frames * Update tests commit 50cd4c0f0f3d1c51e4aa9befab5dfe2d7ce650a2 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Feb 2 11:13:16 2024 +0000 Update dependency react-zoom-pan-pinch to v3.4.1 commit ae5e49e5cebeb92057833794edb4111d15ba5cd2 Author: Oscar Kilhed Date: Fri Feb 2 12:33:32 2024 +0100 Chore: Address flaky test in PanelDataTransformationsTab.test.tsx (#81779) * address flaky test * Disable flaky tests commit 35922a45f8dd31d72304e81b41f1578e1f8449fd Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Feb 2 10:37:17 2024 +0000 Update dependency nanoid to v5.0.5 commit 8d980fef7126c5403f3220b7ab3c2d1c999ae66d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Feb 2 10:00:04 2024 +0000 Update dependency @types/react to v18.2.51 commit 5da4021ea0fe82fa776226591b22aeddce3ebb06 Author: Misi Date: Fri Feb 2 11:14:22 2024 +0100 Auth: Fix routing of SSO setting pages (#81762) Fix ac.Parameter commit 4a1e8f3d98a8d09098497b8683350523257a877d Author: Gabriel MABILLE Date: Fri Feb 2 11:12:00 2024 +0100 RBAC: Reject plugin registrations without a name (#81719) * RBAC: Reject plugin registrations without a name * Lint' commit 65e9990a879846074334843ba5a8f31675c321db Author: Mikel Vuka Date: Fri Feb 2 11:08:52 2024 +0100 Elasticsearch: Implement CheckHealth method in the backend (#81671) * Elasticsearch: Implement CheckHealth method * improve logger output * remove frontend healthcheck * Revert "remove frontend healthcheck" This reverts commit 676265f39e48017872790ca7fb43209a86201a09. * adapt test --------- Co-authored-by: Sven Grossmann commit 6f02d193f61e0f7859b7620a3eeac77bb7b74ff0 Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Fri Feb 2 11:55:29 2024 +0200 Provisioning: Fix failure to save dashboard (#81694) commit ab6aa8fe2f2e6d7c8a4e3268fb6091a80c6f9ed4 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Feb 2 00:35:18 2024 +0000 Update dependency @types/node to v20.11.16 commit 115dfc0fd8df61a048eca1bab12ae62afa833d62 Author: Torkel Ödegaard Date: Fri Feb 2 10:53:41 2024 +0100 DashboardScene: Detect updated saved dashboard version and load new scene (#81715) commit 702e22806c56037cb903833c0173a4f5cdf6a6f2 Author: Eugene Klimov Date: Fri Feb 2 12:23:07 2024 +0400 Plugins Proxy: Allow using {{ .URL }} inside "routes" section in plugin.json (#80858) Signed-off-by: Slach Co-authored-by: Andres Martinez Gotor commit ea243b536c6fe96d0f85ac9a267d222e5c568b02 Author: Esteban Beltran Date: Fri Feb 2 09:19:30 2024 +0100 Levitate: fix markdown diff format (#81477) * Add some fake breaking changes * try to generate another breaking chnage * Keep trying * Update levitate script * Add fake breaking changes * Use latest * Strip ansi * Test * Remove ansi stripping * ClearAnsi again * another regex * Revert some changes used for testing * Rename function * Fix indentation in levitate workflow * Remove test breaking changes * Remove breaking changes * Update actions * Trigger breaking change * restore file commit 86b16ea62a840bebc1c93b3e4e7d71963c48c649 Author: Leon Sorokin Date: Fri Feb 2 01:57:08 2024 -0600 Histogram: Start y-axis at 0, set default color mode to classic palette (#81759) commit 795eb4a8d8a299543d222ccedec44f8eac38e005 Author: Ryan McKinley Date: Thu Feb 1 22:40:11 2024 -0800 K8s/Snapshots: Add dashboardsnapshot api group (#77667) commit 810d14d88faae97d8fa4a6aad821ae6d015fe193 Author: Ryan McKinley Date: Thu Feb 1 22:06:28 2024 -0800 K8s: use --runtime-config={group}/{version}=true to enabled APIs (#81614) commit 7464ea43462af128d8f7965afcb837932e0632f8 Author: Michael Mandrus <41969079+mmandrus@users.noreply.github.com> Date: Thu Feb 1 23:52:02 2024 -0500 Feature Toggles: Switch feature toggle admin page over to k8s API (#80854) * add handling for legacy and k8s apis to frontend * use backend srv directly not redux * add unit test to make sure the correct apis are being called * require api server flag * fix feature toggle name * ensure both pages work correctly * make consistent with legacy api * implement webhook update * fix unit test * remove old apis and update --------- Co-authored-by: Ryan McKinley commit 9c9e5e68c8bbdc30dcfe7080e5104de3a5c217b6 Author: Ryan McKinley Date: Thu Feb 1 18:14:10 2024 -0800 User: Add uid colum to user table (#81615) commit 9d17f6e6aa722ddd4b53e2cf56c421fc8ac6a313 Author: Ryan McKinley Date: Thu Feb 1 17:17:30 2024 -0800 Transformers: Do not add transformation info to output frames (#81747) commit 315d3a7a72511377a053c0f4c0a84ff29f1b1447 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 1 23:43:21 2024 +0000 Update dependency @types/jest to v29.5.12 commit 10bde9ce9c2dbf2f31c8ddfaea25240788d7156a Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Thu Feb 1 19:25:54 2024 -0500 Docs: add missing supported visualizations (#81668) * Added Supported visualizations sections and missing settings for value mappings * Updated supported visualizations table and added docs ref links * Updated supported visualizations table * Removed placeholder headings and accidentally added section to data links page * Added missing visualization commit e6a4992f37116092a60eef16fb964566bf211fc1 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 1 23:16:08 2024 +0000 Update dependency @testing-library/react to v14.2.1 commit 147db1a667bc044ac64235547183f0314d2da087 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 1 19:25:31 2024 +0000 Update dependency @testing-library/jest-dom to v6.4.1 commit f73d0eb41c031425a0069d49fd73203a2b753401 Author: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Thu Feb 1 17:08:02 2024 -0600 Transformations: Expose "keep fields" option in partition by values (#81743) * expose keep fields to partitionByValues UI commit 67b6be5515f01a88e58fe0fcad8d9eadc7149625 Author: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Thu Feb 1 17:27:30 2024 -0500 K8s: Refactor config/options for aggregation (#81739) commit 7a17963ab98b07cb72ba2f61009cbf9ff829fcff Author: Torkel Ödegaard Date: Thu Feb 1 23:10:15 2024 +0100 Scenes: Update to 2.4 (#81723) * Scenes: Update to 2.4 commit d1073deefd9d0bbbb5e72a2a21730736c206efa0 Author: Yuri Tseretyan Date: Thu Feb 1 15:17:13 2024 -0500 Alerting: Time intervals API (read only endpoints) (#81672) * declare new API and models GettableTimeIntervals, PostableTimeIntervals * add new actions alert.notifications.time-intervals:read and alert.notifications.time-intervals:write. * update existing alerting roles with the read action. Add to all alerting roles. * add integration tests commit 7e939401dcd33b04158866251bc3da251c3c00e2 Author: William Wernert Date: Thu Feb 1 14:42:59 2024 -0500 Alerting: Introduce initial common receiver service (#81211) * Create locking config store that mimics existing provisioning store * Rename existing receivers(_test).go * Introduce shared receiver group service * Fix test * Move query model to models package * ReceiverGroup -> Receiver * Remove locking config store * Move convert methods to compat.go * Cleanup commit 80ef96e213c868e4a74a73414453b16e016f5fa7 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 1 19:02:39 2024 +0000 Update dependency @glideapps/glide-data-grid to v6.0.2 commit 372cf797d128e6339168f262e373abffa508cd9e Author: ismail simsek Date: Thu Feb 1 20:16:21 2024 +0100 Chore: Update tests and import orders in prometheus backend (#81732) * use fmt instead of grafana-cli/log * reorder imports * reorder imports commit 8fea101931b2b82cd42e73f48417651c1ddb66ec Author: ismail simsek Date: Thu Feb 1 20:16:07 2024 +0100 Chore: Use maputil functions from grafana-azure-sdk-go (#81734) use maputil functions from grafana-azure-sdk-go commit 780c28be2d1cf8fdc3576338eb3fcfd88833d8dd Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Feb 1 20:50:22 2024 +0200 Update dependency dangerously-set-html-content to v1.1.0 (#80824) * Update dependency dangerously-set-html-content to v1.1.0 * allow rerender and use ' ' for empty state to prevent throwing --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit 19ba2114613fcad0302daadf87b152fdb4dd0112 Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Thu Feb 1 19:56:47 2024 +0200 Swagger: Update specifications (#81735) commit 7ab833d28c27b6ff1e173396eca250891c03076b Author: lean.dev <34773040+leandro-deveikis@users.noreply.github.com> Date: Thu Feb 1 14:37:36 2024 -0300 Licensing: Redact license when overriden by env variable (#81726) commit 2d83feaa5b86fef251a1b209e349fbea0abb9b8c Author: Jack Baldry Date: Thu Feb 1 17:16:03 2024 +0000 Add aliases for Grafana Cloud locations so that these pages can be moved (#81731) commit 8fc8b03d1daa2cc84e82b315744ec85ec483b6ab Author: ismail simsek Date: Thu Feb 1 18:07:32 2024 +0100 Prometheus: Remove featuremgmt imports (#81586) * remove featuremgmt imports * pass down feature flags instead of reading them for every query execution commit 95f90127add6fe01795ff8166e52cfa600331cca Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Thu Feb 1 17:50:29 2024 +0100 Alerting docs: rename provisioning files (#81722) commit 62c8584443c1163f936d54a7fd94067ec75abe2c Author: Alexa V <239999+axelavargas@users.noreply.github.com> Date: Thu Feb 1 17:07:59 2024 +0100 Dashboard: Migration - Edit Variable Settings: Implement "Add new" and Remove "Discard Changes" (#81660) * enable "add" new variables and implement logic * Remove discard changes code as we will not follow that pattern for now * Add unit test of the onAdd function * add unit test for utils functions commit 959c80daf663e5dd23f7f4891990b84dd5ddae7d Author: Kyle Brandt Date: Thu Feb 1 11:06:51 2024 -0500 k8S: (Chore) Add arg to hack codegen to filter openapi generation (#81720) commit 312fdf9ce073677618e59a27df10b14ad98c688d Author: Alyssa Bull <58453566+alyssabull@users.noreply.github.com> Date: Thu Feb 1 09:05:54 2024 -0700 Cloud Monitoring: Fix naming (#81654) commit cd0f29929754dd35b3e13b22cdef80b50e90d31f Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Thu Feb 1 17:04:41 2024 +0100 Alerting: Update logic for checking if contact point is being used (#81713) Don't count autorgenerated policy in receiver level when checking if contact point is used commit 3251b2e9b756db2febe6ba7f76b618dc5a4bcc2d Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu Feb 1 16:37:29 2024 +0100 Decouple: Make eslint rules stricter and don't allow import of anything from public (#81711) Decouple: Make eslint rules stronger and don't allow import of anything from public commit 7f1138dfe64e8888ee44f6e1ccea1d975a2b9b0d Author: Gilles De Mey Date: Thu Feb 1 16:11:23 2024 +0100 Alerting: Fix selecting empty contact point value for notification policy inheritance (#81482) commit 0726c7c3fac143c749170433b10c05967ee460ab Author: George Robinson Date: Thu Feb 1 14:53:15 2024 +0000 Alerting: Prevent inhibition rules in Grafana Alertmanager (#81712) This commit prevents saving configurations containing inhibition rules in Grafana Alertmanager. It does not reject inhibition rules when using external Alertmanagers, such as Mimir. This meant the validation had to be put in the MultiOrgAlertmanager instead of in the validation of PostableUserConfig. We can remove this when inhibition rules are supported in Grafana Managed Alerts. commit 88feccf6329503be4a47af9604ad3d5451e0102c Author: Oscar Kilhed Date: Thu Feb 1 15:52:27 2024 +0100 Dashboards: Add duration to dashboard init tracking (#81637) * Add duration to dashboard init * remove optional chaning, performance should be there * Fix tests commit 89fb56bc1125fbe078f31a87320bf5c386502dba Author: Bruno Date: Thu Feb 1 11:42:43 2024 -0300 Add secureSocksDSProxyEnabled label to grafana_datasource_request_duration_seconds (#80910) * Add secureSocksDSProxyEnabled label to grafana_datasource_request_duration_seconds * rename label from secureSocksDSProxyEnabled to secure_socks_ds_proxy_enabled * DataSourceMetricsMiddleware: add secure_socks_ds_proxy_enabled label to every metric commit 2332bfb0073e79eece3099dee6aafacf7debffb5 Author: Bruno Date: Thu Feb 1 11:09:58 2024 -0300 Set DatasourceName and DatasourceType in proxy.Options (#80923) * Set DatasourceName and DatasourceType in proxy.Options * upgrade github.com/grafana/grafana-plugin-sdk-go to v0.206.0 and fix merge conflicts commit d7ded807a289d96416d690454e45681daf58c5a2 Author: Piotr Jamróz Date: Thu Feb 1 15:09:48 2024 +0100 Explore: Disable cursor sync (#81698) commit 572c182a818bfb3c002ee0d986a033d238d8af85 Author: Piotr Jamróz Date: Thu Feb 1 15:08:40 2024 +0100 Unify frontend monitoring (#80075) * Unify frontend monitoring * Add missing mock * Add missing mock * Keep source:sandbox * Create separate logger for plugins/sql package * chore: rename "logAlertingError" to "logError" * Use internal Faro logging for debugging instead of redundant browser logging * Post-merge fix * Add more docs about debug levels * Unify logger names * Update packages/grafana-runtime/src/utils/logging.ts Co-authored-by: Ivan Ortega Alba * Update packages/grafana-runtime/src/utils/logging.ts Co-authored-by: Ivan Ortega Alba --------- Co-authored-by: Gilles De Mey Co-authored-by: Ivan Ortega Alba commit 7c2622a4f1e325d50984498d2b55fd6afabfee71 Author: Torkel Ödegaard Date: Thu Feb 1 15:07:01 2024 +0100 ShareModal: Fixes url sync issue that caused issue with save drawer (#81706) * ShareModal: Fixes url sync issue that caused issue with save drawer * Updated fix commit 7f6806e2202efb0f8404291416f3f0feea111b13 Author: Gilles De Mey Date: Thu Feb 1 14:18:43 2024 +0100 Alerting: Refactor `useExternalDataSourceAlertmanagers` (#81081) commit eb889c41ee2006f48bbf7dd6e71dbde181ec6037 Author: Gilles De Mey Date: Thu Feb 1 14:17:48 2024 +0100 Alerting: Fix support check for export with modifications (#81602) commit 3e01ba0f5762c01c3ea54a85e4a7cda4a2f50f0a Author: Ashley Harrison Date: Thu Feb 1 13:03:11 2024 +0000 QueryEditor: remove `slateAutocomplete` toggle (#81696) remove toggle commit d0ecf863df9894dd227a1173b08a82644e7d1391 Author: Andre Pereira Date: Thu Feb 1 13:01:24 2024 +0000 Datatrails: Metric as breadcrumb and style improvements (#81661) * Fixed duplicate trails entry for first time users. Show header with description for first time users * Renamed trail to history in History component * Use metric name in breadcrumb * Style tweaks around search field in metric select * Address PR comments * prettier commit 874ce14f0c992bf1bf567a00de3256f111207591 Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Thu Feb 1 12:48:07 2024 +0000 Scenes/PanelEditor: Add support for overrides tab and searching (#81595) commit 9de5e9754c36fed87e9aa94a322891b82f5b9007 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu Feb 1 13:46:32 2024 +0100 Loki: Add to `.eslintrc` list to restrict imports from core (#81710) Loki: Add to .eslintrc list to restrict imports from core commit 2d60c0123b51401f781f6c553c711eb6727d6575 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Thu Feb 1 13:27:29 2024 +0100 Chore: Improve how we install Python (#81695) commit fe1ed307c06ae764c8a44c51a794b4b461129242 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu Feb 1 13:22:18 2024 +0100 Loki: Use fixed components from `@grafana/experimental` 1.7.9 (#81636) * Loki: Use fixed components from experimental * Update * Remove downloads.html commit 1671b775465e33276827a7f8e9f6736cbb6e643c Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu Feb 1 13:22:03 2024 +0100 Loki: Fix processing of all lines labels in getParserAndLabelKeys (#81483) * Loki: Fix processing of all lines labels in getParserAndLabelKeys * Refactor * Update comment commit 85a745ca9df444689342061869ff661e86d92a58 Author: Josh Hunt Date: Thu Feb 1 12:15:32 2024 +0000 I18n: Trigger crowdin upload (#81704) commit 967a650d2d1bfa7d1f09e1fe7c743785f4217d0e Author: Laura Benz <48948963+L-M-K-B@users.noreply.github.com> Date: Thu Feb 1 12:44:18 2024 +0100 I18n: Refactor crowdin workflow (#81571) * refactor: crowdin config * feat: add GH actions * refactor: remove old GH action * refactor: fix formatting issue * refactor: adjust docs * refactor: add changes after code review * refactor: add changes after code review * refactor: update CODEOWNERS file commit bac4c7fb326e158e5ebc1bdbab86104f7dfb9bf7 Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Thu Feb 1 12:43:11 2024 +0100 Alerting docs: corrects save text (#81700) * Alerting docs: corrects save text * updates numbering commit 3df0611f81114268c706d1ae2ade9c761f71e35f Author: Gabriel MABILLE Date: Thu Feb 1 12:37:01 2024 +0100 RBAC: Fix authorize in org (#81552) * RBAC: Fix authorize in org * Implement option 2 * Fix typo * Fix alerting test * Add test to cover the not member case commit 0f1ba3a9feaffad633258d05806c51b28c34aa1a Author: Joao Silva <100691367+JoaoSilvaGrafana@users.noreply.github.com> Date: Thu Feb 1 10:59:47 2024 +0000 Chore: Change codeowners of annotations folder (#81551) commit 536153c3362782ae7f79e06755e8c7e0cd3b3acd Author: ismail simsek Date: Thu Feb 1 11:58:24 2024 +0100 InfluxDB: Run queries in parallel behind influxdbRunQueriesInParallel feature toggle (#81209) * create the feature flag * bring the concurrency in to the play * Update feature flag * Use concurrency number from settings * update influxdb dependency * use ConcurrentQueryCount from plugin-sdk-go * use helper method for concurrent query count * log the error * add value guard * add unit tests * handle concurrency error commit c43a170009e1fd6b229f2eea15621b8d7b78d09e Author: Mihai Doarna Date: Thu Feb 1 12:27:39 2024 +0200 SSO Auth: fix typos on field description (#81685) fix typos on field description commit 4561863fca6e4ffe1567963b071e681f6276525d Author: ismail simsek Date: Thu Feb 1 11:17:38 2024 +0100 Chore: Remove unused header params in prometheus request (#81684) Remove unused header params commit fd54d2549608b0747ae49706cbd7d5043f8083af Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Thu Feb 1 09:48:18 2024 +0000 Tempo: Fix issue with button click area being too large (#81500) Fix issue with button click area being too large commit 1606095a5cf61f189c6ef5b0472982616f082edc Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Thu Feb 1 09:48:04 2024 +0000 Tempo: Remove unused code (#81568) * Remove unused code * Remove unused code from tests commit 1a51240dc7435910f79b29b025a79d81b6f99bf8 Author: idafurjes <36131195+idafurjes@users.noreply.github.com> Date: Thu Feb 1 10:13:15 2024 +0100 Remove some folderIDs from database test (#81643) * Remove some folderIDs from database test * Add folderUID to insertTestDashboardForPlugin commit a416bd761dbd76b876ebbdca13894e7ef89d0692 Author: Alex Khomenko Date: Thu Feb 1 06:58:50 2024 +0100 Chore: Remove Form usage from admin components (#81618) * Chore: Remove Form usage from AdminEditOrgPage * Chore: Remove Form usage from UserCreatePage.tsx * Chore: Transform LdapPage to FC * Chore: Remove Form usage from LdapPage.tsx commit 177fa1b947ee06f1143833b5406055ccf258615b Author: ismail simsek Date: Thu Feb 1 00:30:21 2024 +0100 InfluxDB: Fix template variable interpolation (#80971) * use regex as templateSrv replace format * use regex as templateSrv replace format for raw queries * import path fix * don't use regex formatter * tag value escape * tag value escape with wrappers * polished interpolation logic * update unit tests * comments and more place to update * unit test update * fix escaping * handle the string and array of string type separately * update variable type commit c9bdf69a46ea7729cdebd0c01a93c39d84fedc05 Author: ajwerner Date: Wed Jan 31 18:08:40 2024 -0500 Stat: Fix data links that refer to fields (#80693) commit 11997a6d3577793320b893e2071fb9535b871eb0 Author: Dai Nguyen <88277570+ej25a@users.noreply.github.com> Date: Wed Jan 31 17:03:08 2024 -0600 Docs: Grafana.com skip_org_role_sync update (#80770) * Update Grafana.com org sync index.md Included additional information regarding logging in with Grafana.com credentials that it will override what is defined within the Grafana instance. * Update docs/sources/setup-grafana/configure-security/configure-authentication/grafana-com/index.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> --------- Co-authored-by: Eve Meelan <81647476+Eve832@users.noreply.github.com> Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> commit 0ce1ccd6f969485bbb896fb4b768faf5c5a933e6 Author: Matthew Jacobson Date: Wed Jan 31 14:05:30 2024 -0500 Alerting: Fix inconsistent AM raw config when applied via sync vs API (#81655) AM config applied via API would use the PostableUserConfig as the AM raw config and also the hash used to decide when the AM config has changed. However, when applied via the periodic sync the PostableApiAlertingConfig would be used instead. This leads to two issues: - Inconsistent hash comparisons when modifying the AM causing redundant applies. - GetStatus assumed the raw config was PostableUserConfig causing the endpoint to return correctly after a new config is applied via API and then nothing once the periodic sync runs. Note: Technically, the upstream GrafanaAlertamanger GetStatus shouldn't be returning PostableUserConfig or PostableApiAlertingConfig, but instead GettableStatus. However, this issue required changes elsewhere and is out of scope. commit e013cd427cb0457177e11f19ebd30bc523b36c76 Author: Ryan McKinley Date: Wed Jan 31 10:36:51 2024 -0800 K8s: Add basic query service (#80325) commit d1b938ba15b18040d8c5b6823ad8c82fc091df9e Author: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Wed Jan 31 11:58:02 2024 -0600 Logs: Table UI - Allow users to resize field selection section (#81201) * allow user resize of fields section in table ui --------- Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> commit 1749ec9d5e1f612c1b35dd19ecb2d73a4de0a97e Author: Alex Khomenko Date: Wed Jan 31 18:33:17 2024 +0100 Chore: Remove Form usage from SharedPreferences.tsx (#81468) * Chore: Remove Form usage from SharedPreferences.tsx * Update betterer * Update betterer commit c310a20966a5a7c5071ebc01af64f621870fa22c Author: Ieva Date: Wed Jan 31 17:09:24 2024 +0000 AuthZ: add headers for IP range AC checks for cloud data sources (#80208) * add feature toggle * add a middleware that appens headers for IP range AC * sort imports * sign IP range header and only append it if the request is going to allow listed data sources * sign a random generated string instead of IP, also change the name of the middleware to make it more generic * remove the DS IP range AC options from the config file; remove unwanted change * add test * sanitize the URLs when comparing * cleanup and fixes * check if X-Real-Ip is present, and set the internal request header if it is not present * use split string function from the util package commit e00aba0ce5a94006a07be192c5e494a79c33ccf2 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Wed Jan 31 17:57:16 2024 +0100 Prometheus: Fix OperationEditor and mounting of components (#81656) * Prometheus: Fix OperationEditor * Remove redundant import commit cb945aa5df3453ab2a851ef7ec8bee244e3eeac4 Author: Andrej Ocenas Date: Wed Jan 31 17:26:12 2024 +0100 NodeGraph: Use layered layout instead of force based layout (#78957) commit 395a06ab86a67200733cb5718fc8d9a29239c04a Author: Mihai Doarna Date: Wed Jan 31 18:06:22 2024 +0200 Auth: fix swagger responses for the SSO settings API (#81639) fix swagger responses for the sso settings API commit 91b2909c390a0466db3dda500c1c9e50b6784bbd Author: Laura Fernández Date: Wed Jan 31 17:04:34 2024 +0100 ReturnToPrevious: add shadow to be consistent (#81652) commit 1bcd597bc02d1b8631fce1a13fe9c60187451195 Author: Alexander Zobnin Date: Wed Jan 31 18:25:11 2024 +0300 Nested folders: Improve performance of shared with me dashboards listing (#81590) * Nested folders: Improve performance of shared with me dashboards listing * Fix tests * Clean up guardian commit b4d363e8fe59d01fbed9e855bf20cad949095f21 Author: Alexander Zobnin Date: Wed Jan 31 18:09:32 2024 +0300 Chore: Fix broken anonymous devices test (#81649) commit 7319a75110bff09e1d647535416f53abb4f78a80 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Wed Jan 31 09:33:27 2024 -0500 feat: datatrails: include metric prefix filter (#81316) * feat: datatrails: include metric prefix filter * fix: remove current metric from related metrics list * fix: Cascader issues - handle empty items list when generating searchable options - correct state management to ensure cascade shows cascade path of selection from search * fix: remove custom value creation commit c8f47e0c54faccafe94008e45187951fe6a7243f Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Wed Jan 31 09:27:44 2024 -0500 datatrails: UI improvements (#81427) * fix: datatrails: limit width of metric description * fix: datatrails: use vertical radio list for long label list * fix: datatrails: reduce spacing between header items * fix: datatrails: reduce gap in lower toolbar * fix: change to use Select component for longer lists commit a7f224987800db6d3234306c6cfe0ff0bfd4d4eb Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 31 14:25:50 2024 +0000 Update dependency react-zoom-pan-pinch to v3.4.0 (#81642) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 607ec6c96a9a85eb9908fe85781913be46e6700b Author: Gábor Farkas Date: Wed Jan 31 15:02:29 2024 +0100 CODEOWNERS: better root-file selection (#81633) commit 0633f5501c4ccd553957d922b9f6e23d583042a6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 31 13:32:18 2024 +0000 Update dependency dompurify to v3 (#81638) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 5c258b7a4bdb4db7b242994ed262997f43793872 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 31 12:32:50 2024 +0000 Update dependency @grafana/scenes to v2.2.2 commit c9bc937919d882f55876320aac4a2e3d2c3e04a1 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Wed Jan 31 13:39:07 2024 +0100 Loki: Limit running of samples based on query and time range (#81585) * Loki: Limit running of samples based on query and time range * Update commit a9f17a3f247a104f5de69692d06d5c7b9715331c Author: Ivan Ortega Alba Date: Wed Jan 31 13:31:08 2024 +0100 QueryVariable: Be able to edit the variable using scenes (#80847) commit 0851c18b55aef3287966da671ec893a5ad280bf5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 31 12:09:58 2024 +0000 Update dependency @grafana/experimental to v1.7.9 commit 42fe0fdf707e4b4b7e14cc3e99dc6425ab89843f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 31 12:05:38 2024 +0000 Update dependency date-fns to v3 (#81625) * Update dependency date-fns to v3 * update imports --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit e0054c06fe75437b84158abd07033bc927170853 Author: Matias Chomicki Date: Wed Jan 31 12:51:15 2024 +0100 DashNav: add missing key (#81626) * DashNav: add missing key * DashNav: use more specific key commit 564a1d32b7470aabec19d13175f14c3838e681e9 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Wed Jan 31 12:44:22 2024 +0100 Add Python as dependency (#80979) commit f20053e4b6cfda48020911ba74cac2a3c8e50ec4 Author: Dominik Prokop Date: Wed Jan 31 03:30:27 2024 -0800 DashboardScene: Allow editing panel links (#81560) * Panel links supplier for VizPanel * Update panel links behavior * Allow editing panel links * Update so that single link is rendered without a dropdown * Serialise links in scene -> save model transformation * Betterer fix * Fix inspect json tab test commit ab891d92fb4da9a1328abe7ef95cbfde0ae2511f Author: Konrad Lalik Date: Wed Jan 31 12:09:11 2024 +0100 Alerting: Fix missing pagination param in the oncall request (#81620) Add skip_pagination param to the oncall request commit 3b2352f0666a1e34fe819897c7b5d5f1c35999de Author: Torkel Ödegaard Date: Wed Jan 31 11:33:46 2024 +0100 DashboardScene: Initial work to support "new" dashboard route and creation logic (#81549) * DashboardScene: Initial work to get new route to work * Update * remove caching of new dashboard * remove old new dashboard func * Update * Update public/app/features/dashboard-scene/scene/DashboardScene.tsx Co-authored-by: Ivan Ortega Alba * Fixing test * dam messy tests --------- Co-authored-by: Ivan Ortega Alba commit 39057552dc2ddfe8ee770c67a71cf55159aca741 Author: Ashley Harrison Date: Wed Jan 31 10:01:20 2024 +0000 QueryField: Handle autocomplete better (#81484) * extract out function + add unit tests * add feature toggle and default it to on commit f896072cd7f5463a8b0e7ebc1e288089d1337ce1 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 31 09:47:30 2024 +0000 Update dependency css-minimizer-webpack-plugin to v6 (#81623) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 612bcada4174b4a88502d0e206a5debd3cdc6bbc Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 31 09:28:06 2024 +0000 Update dependency copy-webpack-plugin to v12 (#81613) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 3517f0075ba3927e8e45f15cf40210440ba56542 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Wed Jan 31 09:08:32 2024 +0000 Tempo: Add webpack to package.json (#81577) Add webpack to package.json commit b9f504204442b6c84acb519ea0fe635352a546ed Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Wed Jan 31 09:08:06 2024 +0000 Tempo: Add query ref in the query editor (#81343) * Add query ref * Fix lint * Add comment * Update public/app/plugins/datasource/tempo/traceql/QueryEditor.tsx Co-authored-by: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> --------- Co-authored-by: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> commit 0d9886a6548ee537da8e15403dfd4f3145d64b9f Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Wed Jan 31 09:07:35 2024 +0000 Tempo: Add span, trace vars to trace to metrics interpolation (#81046) * Add scoped vars to query * Update import * Remove ternary commit 8a4f060f75d3a3aa70d9f2372d37b722049bcd11 Author: Gábor Farkas Date: Wed Jan 31 10:00:59 2024 +0100 sql: add linting rule for decoupling postgres & mysql (#81378) commit cbd2032aeaebc2d1490ccff60d8badc77364d149 Author: Michael Mandrus <41969079+mmandrus@users.noreply.github.com> Date: Wed Jan 31 01:25:16 2024 -0500 Feature Toggles: Remove DocsURL from FeatureFlag struct (#79774) Co-authored-by: Ryan McKinley commit 12d08d5e7de1c859ac4ff0d7fa73e6076d0e7fe3 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 31 01:00:51 2024 +0000 Update dependency @testing-library/react to v14.2.0 (#81612) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit ae7d8624e9cfeef80c9069dcf86c49344ef40ebf Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 30 23:37:44 2024 +0000 Update dependency @types/node to v20.11.13 commit b0130ecb829925a3343abc208395a3bfdc1b9187 Author: Nathan Marrs Date: Tue Jan 30 16:37:46 2024 -0700 Canvas: Add element snapping and alignment (#80407) Co-authored-by: drew08t Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> commit c377644c48953a68eac3525e38a9732e301bc355 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 30 23:32:33 2024 +0000 Update dependency @types/google.analytics to ^0.0.46 (#81604) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 2ffd56c23b7f734a09627df835b327bf93d5001f Author: Ryan McKinley Date: Tue Jan 30 15:17:14 2024 -0800 K8s: Improve OpenAPI behaviour (#81606) commit 6fc1a6a54f92cb980d398977bb7e65a66d340573 Author: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Tue Jan 30 15:09:15 2024 -0800 XYChart: Add data filter to manual mode (#81115) * XYChart: Add data filter to manual mode * Add onChange to data filter for manual editor * Update placeholder for auto editor for consistency * Filter x y fields based on frame * Update frame calc for truthy * Use display name instead for frame filter * Update placeholders * Apply frame filter to series prep * Re run make gen cue * Remove old TODO * Force data filter to be selected * minor cleanup --------- Co-authored-by: nmarrs commit be6efd9518a1cc03456b2b1084a183c554a0690c Author: Alyssa Bull <58453566+alyssabull@users.noreply.github.com> Date: Tue Jan 30 15:52:44 2024 -0700 Cloud Monitoring: Add standalone files and modify plugin.json (#81596) commit 1966ce609c52c68e5dd7a7794d3865976795ddb1 Author: ismail simsek Date: Tue Jan 30 23:50:42 2024 +0100 Chore: Bump grafana-plugin-sdk-go version to v0.204.0 (#81603) * bump grafana-plugin-sdk-go to v0.204.0 * update go.sum entry for v0.204.0 commit 907ac1070821331da5a856e09a4e03208a57ee78 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 30 22:37:18 2024 +0000 Update dependency css-loader to v6.10.0 (#81592) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 131c72d6554b51c0e86261682ca905f63f8a317e Author: Yuri Tseretyan Date: Tue Jan 30 17:14:11 2024 -0500 Alerting: Fix scheduler to group folders by the unique key (orgID and UID) (#81303) commit 5c0d7749ebb660481bb026f18f2b69e90c6a267b Author: ausias-armesto Date: Tue Jan 30 22:01:25 2024 +0100 Add timeout parameter to the example (#80921) Adding the http timeout parameter to the example to know where is needed in the yaml configuration. commit f330d50b2afd0aa14f329b52ee8fbd0998590446 Author: ismail simsek Date: Tue Jan 30 21:41:29 2024 +0100 Revert "Chore: Bump github.com/grafana/grafana-plugin-sdk-go to v0.203.0" (#81597) commit df59f01cc3dbdbd8237e216f4c6fcb939dbdbea2 Author: Angelo Manos Date: Tue Jan 30 14:36:29 2024 -0600 Update Trace to Logs docs in configure-tempo-data-source.md (#79913) * Update configure-tempo-data-source.md Trace to logs documentation is unclear on a couple things. I think the context I've added will help people get things working a little easier. * remove misunderstanding. * Adds commas Co-authored-by: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> --------- Co-authored-by: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> commit 0f6380b76a6114bdd0454f9b5a529746a5166bf3 Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Tue Jan 30 19:29:46 2024 +0000 Scenes: Add support for panel overrides (#81470) commit 80afc8202da99f881a127dcea8f9e54abcc398cd Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 30 18:03:46 2024 +0000 Update Yarn to v4.1.0 (#81584) * Update Yarn to v4.1.0 * commit yarn 4.1.0.cjs --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit 43de30a09bf1b1350510e02a73238ace5645751a Author: Ashley Harrison Date: Tue Jan 30 17:11:56 2024 +0000 Chore: Extract out dragHandle styles (#81280) extract out dragHandle styles commit e041055012d8a9ba708c15bdc3b569b481c6b151 Author: Ryan McKinley Date: Tue Jan 30 08:50:37 2024 -0800 K8s/Folders: Rename api group to singular (#81443) commit d26959aafea75f0a2a9b5d8b996474353570311c Author: Alyssa Bull <58453566+alyssabull@users.noreply.github.com> Date: Tue Jan 30 09:48:20 2024 -0700 Cloud Monitoring: Add to release yml (#81580) commit fb1368d1efa40a971a85bf18596eace132a3e46f Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Tue Jan 30 11:42:20 2024 -0500 Docs: restructure manage dashboards page (#81311) * Added import and troubleshoot dashboards pages * Moved import dashboards to build dashboards folder * Updated import dashboards content * Updated manage dashboards page * Updated troublshooting dashboards page * Finalized text for Import dashboards and moved orphaned content to Sharing page * Made general copy edits to Troubleshooting dashboards * Moved More examples heading and content from Troubleshooting to Import * General copy edits to Troubleshooting * Fixed broken links and made small copy edits * Fixed broken link * Removed note and replaced with plain text description of Dashboards page Added to do for clarifying display of Shared with me section * Deleted orphaned export content; to be rolled in later * Copy edits * Updated Shared with me section * Copy edits * Apply suggestions from code review Co-authored-by: Jack Baldry --------- Co-authored-by: Jack Baldry commit 63c7096d328524efae10ed5e72b074e483a568aa Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Tue Jan 30 18:38:26 2024 +0200 Scenes: List annotations in dashboard settings (#80995) * wip listing/viewing annotations * list annotations in settings * fix tests * PR mods commit ce39af21a243cfeeb5c068c0d7fc2b83c03cba92 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 30 15:54:59 2024 +0000 Update dependency @grafana/scenes to v2.2.1 commit 89d3b55becc7dfc36d9711197f0eda88d68e3742 Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Tue Jan 30 18:26:34 2024 +0200 Folders: Reduce DB queries when counting and deleting resources under folders (#81153) * Add folder store method for fetching all folder descendants * Modify GetDescendantCounts() to fetch folder descendants at once * Reduce DB calls when counting library panels under dashboard * Reduce DB calls when counting dashboards under folder * Reduce DB calls during folder delete * Modify folder registry to count/delete entities under multiple folders * Reduce DB calls when counting * Reduce DB calls when deleting commit 0139ac205d0182795ad6fa403ed35205c3481791 Author: ismail simsek Date: Tue Jan 30 17:00:04 2024 +0100 Chore: Remove disablePrometheusExemplarSampling feature toggle (#81579) remove disablePrometheusExemplarSampling ft commit 6d582858f7e516e5a25ba0d97f1570c3d15a1b48 Author: Jack Westbrook Date: Tue Jan 30 16:59:18 2024 +0100 Webpack: Allow watching workspaces (#81569) * build(webpack): remove symlinks:false to enable watching packages with yarn start * build(webpack): add resolve node_modules for enterprise to resolve packages * add comment --------- Co-authored-by: joshhunt commit b84dde7b4aa3cd96527ccbd899e0d056d124b6f5 Author: Tania <10127682+undef1nd@users.noreply.github.com> Date: Tue Jan 30 16:57:01 2024 +0100 Chore: Change response status for dashboard import with invalid input (#81521) * Return BadRequest when dashboard import failed due to invalid input commit 27f3cec094842ea9f27b21372d4c22187b7fac8c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 30 17:46:36 2024 +0200 Update Yarn to v4 (#81575) update yarn version Co-authored-by: Ashley Harrison commit 69a1e7b510deb23d6c2ffa2402126eb9e698dc6c Author: Misi Date: Tue Jan 30 16:17:38 2024 +0100 Auth: Remove unnecessary field from Generic OAuth UI (#81565) * Remove unnecessary field * Update interaction event key commit b48e1f897efec6cf1b089daf32ca5653e32e299d Author: Torkel Ödegaard Date: Tue Jan 30 16:07:24 2024 +0100 Dashboard: Improve diff styling (#81509) * Dashboard: Improve diff styling * Update public/app/features/dashboard-scene/settings/version-history/DiffGroup.tsx Co-authored-by: Dominik Prokop * Fix * Update * Update --------- Co-authored-by: Dominik Prokop commit 4cf50595991ebce68d8683fd0dceef3420017a10 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 30 16:38:50 2024 +0200 Update typescript-eslint monorepo to v6.20.0 (#81572) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 7af58bab0298477bded76b805d5f4e9933879b7a Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 30 14:15:36 2024 +0000 Update dependency react-use to v17.5.0 (#81570) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 8440eadec2058045993ab28b0afb43d00cb48be9 Author: Andre Pereira Date: Tue Jan 30 13:51:31 2024 +0000 Data trails: Homepage redesign (#81496) * Rename trails and tweak styles in homepage * Use design system card and update layout. Add createdAt date to trail * Small style tweaks * Move queryDef state to metricScene * Date format update * More style tweaks * betterer update * Use smaller padding on Card and use Badge istead of Tag * Increase badge max width --------- Co-authored-by: Torkel Ödegaard commit ced0cca27a5fc452404c609910692b16b559f026 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 30 15:48:19 2024 +0200 Update dependency @testing-library/jest-dom to v6.4.0 (#81564) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 77775d548acdc274fe5192ba685b9078ef3fbdc4 Author: Arati R <33031346+suntala@users.noreply.github.com> Date: Tue Jan 30 14:15:12 2024 +0100 Chore: Add some tests for the Create method (#81364) * Add test for create method Co-authored-by: Tania B <10127682+undef1nd@users.noreply.github.com> * Change structure of entity package to break import cycle * Update wire file --------- Co-authored-by: Tania B <10127682+undef1nd@users.noreply.github.com> commit 41cd0ab12a2cb6e56037e0d921185c7cb412d8a7 Author: Giuseppe Guerra Date: Tue Jan 30 13:49:58 2024 +0100 Chore: Bump github.com/grafana/grafana-plugin-sdk-go to v0.203.0 (#81557) commit 544740dc5e24f14a638c97610e0175419f8562ee Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 30 12:48:04 2024 +0000 Update dependency tslib to v2.6.2 (#81561) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 8ee7b1e00c0e59be9a51ff7024a344c16377d97b Author: Laura Fernández Date: Tue Jan 30 13:34:59 2024 +0100 ReturnToPrevious : Add logic to show the new component in `AppChrome` behind the new toggle (#81035) commit b7517330ee3a25f0df623423d89b3465d34fae72 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 30 11:34:05 2024 +0000 Update dependency rc-tree to v5.8.5 commit ec4fafa08fa107301503bcf84ccd51fa62b27874 Author: Nathan Vērzemnieks Date: Tue Jan 30 13:11:52 2024 +0100 Cloudwatch: Deprecate cloudwatchNewRegionsHandler feature toggle and remove core imports from featuremgmt (#81310) * Remove core imports from grafana/pkg/services/featuremgmt in CloudWatch commit cc56e0e75c9e3e737445d2ba1c4212558faebdb7 Author: Sven Grossmann Date: Tue Jan 30 13:11:12 2024 +0100 Logs Panel: Fix scrolling with permalinks (#81558) commit 5c3e2117772eeaf39b6b858639365e3d4813dab2 Author: Matias Chomicki Date: Tue Jan 30 13:02:15 2024 +0100 combineFrames: add support to merge transformed data frames (#81554) * combineResponses: account for disordered frames * tempo: undo change * Formatting commit 147bee483149fe6b5f481702053360e8a44375df Author: Josh Hunt Date: Tue Jan 30 11:41:20 2024 +0000 Fix alerting i18n keys (#81555) commit 6e8495822a4f02a4abcee1f852bcd3ff905e0968 Author: Ida Štambuk Date: Tue Jan 30 12:25:16 2024 +0100 Feature Management: Move awsDatasourcesNewFormStyling to Public Preview (#81257) commit aaa839e120a2b6912dd011482b3dafdebc2d73b6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 30 10:40:44 2024 +0000 Update dependency @grafana/faro-web-sdk to v1.3.7 commit da8eb8faa5e7a064ee0d3c3e655f00c89d6339ef Author: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> Date: Tue Jan 30 05:41:33 2024 -0500 [DOC] Fix style issues and add videos to tracing and profiling shared files (#81215) Co-authored-by: Jack Baldry Co-authored-by: Ryan Perry Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> commit 2a734a9e3f37b0a6ab2aedd425c4e3203c1f9599 Author: Matias Chomicki Date: Tue Jan 30 11:36:20 2024 +0100 Supplementary queries: allow plugin decoupling by allowing providers to return a request instance (#80281) * Supplementary queries: add support for providers returning a request instance * Formatting * DataSourceWithSupplementaryQueriesSupport: update getDataProvider signature * getLogLevelFromLabels: fix buggy implementation * getLogLevelFromKey: fix key type Why number?? * Revert "getLogLevelFromKey: fix key type" This reverts commit 14a95298a6f803cc3270e0421b2e04dd0d65f131. * getSupplementaryQueryProvider: remove observable support * Datasources: remove unnecessary check The switch is doing the same job * Supplementary queries: update unit test * datasource_srv: sync mock with real api * Formatting * Supplementary queries: pass targets from getSupplementaryQueryProvider * LogsVolumeQueryOptions: remove range and make extract level optional * logsModel: add missing range to test data * query: sync tests with changes * Formatting * DataSourceWithSupplementaryQueriesSupport: update interface with deprecated and new methods * DataSourceWithSupplementaryQueriesSupport: sync Loki and Elasticsearch * queryLogsVolume: extractLevel no longer customizable * Loki: update test * Supplementary queries: add support for the new method * hasSupplementaryQuerySupport: update signature * Formatting * Betterer * Query: update test * Supplementary queries: add test for the legacy API * Update public/app/features/explore/utils/supplementaryQueries.ts Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> --------- Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> commit ecf0c2c1c9f7ca0128ff56bdcb945f067e5ada74 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 30 10:09:16 2024 +0000 Update dependency @grafana/faro-core to v1.3.7 commit 8ffcf32f831dcf184821b4c744777bdcb6b99f25 Author: Misi Date: Tue Jan 30 11:04:55 2024 +0100 Auth: Add interaction tracking to SSO settings UI (#81497) Add interaction tracking commit 89e64b8f688a5e8c7d270fdaa47e1a50afd2cce9 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 30 12:04:20 2024 +0200 Update dependency immer to v10.0.3 (#81545) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 253f797146366de224627b5bf1269cce70d5dd99 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 30 09:43:07 2024 +0000 Update dependency @types/react-test-renderer to v18.0.7 (#81525) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 9d21bac45e8a79fccfee777a59c36a9c7f155ae0 Author: Dominik Prokop Date: Tue Jan 30 01:38:49 2024 -0800 Scenes: Bump to 2.2.0 (#81536) commit 08f305797f83732546a60b7e0f8b0ec8358b6506 Author: Gabriel MABILLE Date: Tue Jan 30 10:37:47 2024 +0100 RBAC: Add metric to count search user permissions cache hits (#81451) commit d12493d654d049ef5c38b6962bb2b06d5090c4b0 Author: Oscar Kilhed Date: Tue Jan 30 10:30:35 2024 +0100 Dashboard scenes: Display alerts in scenes dashboard (#81260) Display alerts in scenes dashboard commit 9982830bd7937c7b81b7fd06a2f35af501e2c805 Author: Dominik Prokop Date: Tue Jan 30 01:28:17 2024 -0800 DashboardScene: Do not hide variables on save (#81501) commit f77c831e3f383e612f5805d6a8eaf832cbbf92e5 Author: Dominik Prokop Date: Tue Jan 30 00:06:31 2024 -0800 Data query: Allow logging panel plugin id when executing queries (#81164) * Data query: Allo logging panel plugin id when executing queries * Update tracing header middleware * Test fix * Add panelPluginType to query analytics * Cleanup commit 5ab75410e93a5f2b553151f9009b9c3d036c1a83 Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Mon Jan 29 17:38:25 2024 -0600 VizTooltip: Scrollable content for long lists (#81524) Co-authored-by: Leon Sorokin commit 464a61352c20ba91d11b7fd70270ac9a4dae7abf Author: Simon Podlipsky Date: Tue Jan 30 00:30:57 2024 +0100 Docs: Add HAProxy rewrite information considering `serve_from_sub_path` setting (#80062) * docs: specify HAProxy rewrite considering `serve_from_sub_path` setting * Update docs/sources/tutorials/run-grafana-behind-a-proxy/index.md Co-authored-by: Joseph Perez <45749060+josmperez@users.noreply.github.com> * Update docs/sources/tutorials/run-grafana-behind-a-proxy/index.md Co-authored-by: Joseph Perez <45749060+josmperez@users.noreply.github.com> * Update docs/sources/tutorials/run-grafana-behind-a-proxy/index.md Co-authored-by: Joseph Perez <45749060+josmperez@users.noreply.github.com> * Update docs/sources/tutorials/run-grafana-behind-a-proxy/index.md * Update docs/sources/tutorials/run-grafana-behind-a-proxy/index.md Co-authored-by: Will Browne --------- Co-authored-by: Joseph Perez <45749060+josmperez@users.noreply.github.com> Co-authored-by: Will Browne commit 574fae3f01922ba4e6a483bd5e5d9edcc2cfa51b Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 29 22:24:03 2024 +0000 Update dependency @types/node to v20.11.10 (#81516) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 24a6e6a1b471b43f63cb1abbc13a7982df060e99 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Mon Jan 29 16:54:56 2024 -0500 fix: datatrails: show a warning when search yields empty results (#81444) * fix: datatrails: show a warning when search yields empty results commit 226c76dc93f1e403463bc461268581cdafddc213 Author: Leon Sorokin Date: Mon Jan 29 15:34:43 2024 -0600 TimeSeries: Simplify hover proximity code (#81518) commit 12e691831dc6b17649b68fef9e8fcc8d2dd3f9e5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 29 22:31:34 2024 +0200 Update dependency @types/debounce-promise to v3.1.9 (#81514) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 43d0664340f3e3af219d7b5c747f486a073f5ce3 Author: Kyle Brandt Date: Mon Jan 29 15:22:17 2024 -0500 Prometheus: (Experimental) Inject label matchers into queries (also change drone to fix ARM rpm build and Update Swagger) (#81396) - Feature Toggle is `promQLScope`. - Query property is: "scope": { "matchers": "{job=~\".*\"}" } Misc: - Also updates drone GO version to address ARM bug https://github.com/golang/go/issues/58425 - Also updates Swagger defs that were causing builds to fail --------- Co-authored-by: Kevin Minehart commit c2b64c6739f5025c5a709eabba25f64252af0326 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 29 20:06:51 2024 +0000 Update dependency @grafana/google-sdk to v0.1.2 (#81507) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 715143d4ed135644d0f2ae169f0168d7ee79fbe1 Author: Torkel Ödegaard Date: Mon Jan 29 20:03:57 2024 +0100 DashboardScene: Saving updates (provisioned dashboard and fixes) (#81471) * Save provisioned dashboard * Update * fixes commit 02acbd795d5b43286627430b2ee5636bf54da091 Author: ismail simsek Date: Mon Jan 29 18:44:53 2024 +0100 Plugins: Update grafana-plugin-sdk-go to v0.202.0 (#81489) * update the version * Fixup (#81503) fixup --------- Co-authored-by: Will Browne commit 02db6d0f3cc26262cd2064ba582c07a42845bfa6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 29 19:25:38 2024 +0200 Update dependency @grafana/experimental to v1.7.8 (#81502) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 630897b941d56fedc33c4086d3067c0033c21d9c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 29 17:02:32 2024 +0000 Update dependency centrifuge to v5 (#81498) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 2ce81c1a520d0ff7d8af0ef33b645370e89d0dcf Author: Torkel Ödegaard Date: Mon Jan 29 17:45:18 2024 +0100 DashboardScene: Support detecting and ignoring variable value changes (#81448) * DashboardScene: Saving and ignoring variable value changes * Update commit 1b155a02fdcdee763e7454e375e780975d53c281 Author: Kyle Brandt Date: Mon Jan 29 11:39:23 2024 -0500 Prometheus: (Dataplane) Set FrameTypeVersion on Scalar type responses (#81491) So expressions (SSE) and recorded queries (RQ) detect the response correctly commit ba544e5b33ea6461668713d63145f53295e42b04 Author: Gábor Farkas Date: Mon Jan 29 17:27:15 2024 +0100 postgres: tests: improved number->timestamp tests (#81495) * postgres: tests: nicer values * postgres: tests: add float-to-timestamp tests commit 2d432d6ff380c26a1d8623688b4e232457615c6a Author: Alyssa Bull <58453566+alyssabull@users.noreply.github.com> Date: Mon Jan 29 09:24:23 2024 -0700 Plugins: Externalize Cloud Monitoring data source (#80181) commit de662810cfd841b6febaf0477406d4f780a38089 Author: William Wernert Date: Mon Jan 29 11:22:43 2024 -0500 Alerting: Create instance of alert rule generator in historian annotation tests (#81394) * Create generator variable to ensure closures have correct context commit 09a78a0ae994a94719c9d9f41e0d986a620da695 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 29 16:19:29 2024 +0000 Update dependency ansicolor to v2 (#81493) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 5486a111e6d300861dfea8ccec18d5f3ffd09609 Author: Matias Chomicki Date: Mon Jan 29 17:12:03 2024 +0100 parseSupportingQueryType: add missing case for infinite scroll (#81492) commit e3a648e107f069e768f572d38458b4a419a7606f Author: Alexa V <239999+axelavargas@users.noreply.github.com> Date: Mon Jan 29 16:53:09 2024 +0100 Dashboard: Migration - EditVariable Settings: Implement Interval Variable (#81259) * Extract logic from core IntervalEditor and create a new Form to be shared between scenes and core * Implement IntervalVariableEditor and refactor some utils functions * Add unit test commit da1538ba824d9ead58e1b6b912d9116249e8b247 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Mon Jan 29 16:52:34 2024 +0100 Alerting: Update docs after moving action buttons im the alert list, and move t… (#81375) Update docs after moving action buttons im the alert list, and move the loading spinner commit 1ef46440b8568887e0684729e161e0efcc7cc001 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 29 15:50:52 2024 +0000 Update dependency @welldone-software/why-did-you-render to v8 (#81479) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit fc82b0286f7c45fc23a5d88047fbbba5085d949f Author: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Mon Jan 29 08:13:51 2024 -0700 Dashboard-Scene: Add and remove Panel Editor queries and expressions (#81027) * wip * Functionality for adding and deleting queries and expressions * Remove console.log * handle counter updates * revert unintended change * Revert tsconfig.json * revert commit 26e71953a4015d8f21deaca371a212bffaa516ff Author: Konrad Lalik Date: Mon Jan 29 16:09:10 2024 +0100 Alerting: Improve integration with dashboards (#80201) * Add filtering by dashboard UID annotation * Update the inline doc for search * Add AlertRulesDrawer to the dashboards toolbar * Use DashboardPicker as a filter on the alert rules page * Fix accessibility errors * Update drawer subtitle * Display Alerting toolbar button only if there are linked alert rules * Change toolbar rendering method, prevent displaying when no linked rules * Improve text * Use React.lazy to load the Alert rule toolbar button and drawer when needed commit 75e1f7aa5e6d7f0f4d49afb1f9d35e65b06f39bc Author: Alex Khomenko Date: Mon Jan 29 15:54:50 2024 +0100 Grafana/ui: Add deprecation notices to the legacy layout components (#81328) commit 8f0ae76afefff9f69b61dd4a12d6a822265e702b Author: Josh Hunt Date: Mon Jan 29 14:27:35 2024 +0000 Chore: Use yarn node-modules linker (#79947) * Chore: Use yarn node-modules linker * fix react-router types resolution * temp skip failing tests * remove yarn-links for internal path aliases to fix some webpack errors * transpile all .ts files, even those in node_modules (usually our internal workspace packages * fix transformers mock * import react router type directly * remove old resolution * more cleanup * remove preserveSymlinks: true from tsconfig to make Go To Definition resolve grafana ui to the original location * developer guide * update dev guide * remove sdks * reenable tests * fix tsconfig trailing commas (where did they come from) commit b625d60aa92db7f0c7c20a756bbad82974e9f3c6 Author: Brendan O'Handley Date: Mon Jan 29 07:56:45 2024 -0600 Prometheus: Only use the series endpoint in the metrics browser (#81419) * only use the series endpoint in the metrics browser * Update public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx Co-authored-by: ismail simsek --------- Co-authored-by: ismail simsek commit 329440cb1e1ac5dde2f76fd2eb3c560d00714e5b Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Mon Jan 29 08:41:53 2024 -0500 fix: datatrails: hide graphs when previews disabled (#81423) fix: datatrails: hide graph when previews disabled commit 811ce04390e036b503bc9469a76c8692381f5de3 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 29 13:09:36 2024 +0000 Update dependency esbuild-plugin-browserslist to ^0.11.0 (#81474) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 01d043b6fbf46954633afcf510c2f4922546d240 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 29 12:09:26 2024 +0000 Update dependency rc-tree to v5.8.3 commit 6b28669e1ff58a612f2474ae7e7d83a114644af4 Author: Mihai Doarna Date: Mon Jan 29 14:17:56 2024 +0200 Send empty http response when body is nil (#80196) * build empty response if body is nil * fix test commit 67feb0bba11a0fca8250dcb6d5dd204f172bd983 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 29 10:44:00 2024 +0000 Update dependency ol-ext to v4.0.14 commit 2ab832e0259ee5695a1139c0156473cc5042d628 Author: Konrad Lalik Date: Mon Jan 29 13:02:04 2024 +0100 Alerting: Use only the alert condition query as the graph threshold (#81228) Use only the alert condition query as the graph threshold commit 1d5edb2a182927657afc9bfb7f97ca7d0a5d43e0 Author: Torkel Ödegaard Date: Mon Jan 29 12:04:45 2024 +0100 DashboardScene: Saving (#81163) * DashboardScene: First save works * Updates * version mismatch works * Error handling * save current time range working * Progress on save as * Save as works * Progress * First tests * Add unit tests * Minor tweak * Update * Update isDirty state when saving commit bcc240956464e392b7a2d156eca6a50defb629bd Author: Misi Date: Mon Jan 29 12:04:22 2024 +0100 Auth: Add validation to Generic OAuth API and UI (#81345) * wip * Update validation * Chore: Remove InputControl usage * Fixes, validation * Remove empty option * Validation changes * Add tests, rename * lint --------- Co-authored-by: Clarity-89 commit 7e96a2be56469b764bccc7d34308994990993eef Author: Misi Date: Mon Jan 29 12:02:04 2024 +0100 Auth: Reload OAuth provider after deletion of the current settings (#81374) * Reload after deletion of the current settings * Add grafana_ssosettings_setting_reload_failure_total counter * Returns successfully if data reload failed commit a3fda08d4ee45595f123d82500235cd5d4a00f05 Author: ismail simsek Date: Mon Jan 29 11:47:28 2024 +0100 Datasources: Add concurrency number to the settings (#81212) add concurrency to the settings commit ca5c297bfacdad622ec7d3017f5cec0786266666 Author: Sven Grossmann Date: Mon Jan 29 11:46:40 2024 +0100 Loki/Elastic: Assert queryfix value to always be string (#81349) Fix `value` can be `string` or `number` commit 2b59db7b41e5725a035ee3c0b2d29ec056352a72 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 29 10:26:16 2024 +0000 Update dependency browserslist to v4.22.3 commit df05c79d912203cb5ac364d22e5078bc4ad8d9cb Author: Matias Chomicki Date: Mon Jan 29 11:24:05 2024 +0100 Infinite scrolling: Add X-Query-Tag header (#81089) commit a83e01918a6fc5f20a05dcf5baeb21cee95b99ef Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Mon Jan 29 11:19:34 2024 +0100 Loki: Remove dependecy on core and move to `@grafana/o11y-ds-frontend` (#81284) * Loki: Remove dependecy on core and move to o11y-ds-frontend * Fix ctr -> ctrl * Remove test helpers commit 34e88077aaa5bc1a3b7ef2ae7e507d070ff315e2 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 29 09:56:34 2024 +0000 Update dependency @types/node to v20.11.10 commit 20e2f3006bda11096b57b9decbb4250cd4cd93d4 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Mon Jan 29 11:00:11 2024 +0100 Add test for `TemporaryAlert` (#81416) commit 26fa921547086bb0d26c6aa5a94bef3703a3d3a3 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 29 09:33:55 2024 +0000 Update dependency @swc/core to v1.3.107 commit 2e1229b908fb4d65808b7dcdde3a43970264609f Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Mon Jan 29 10:51:00 2024 +0100 Alerting: Fix child provisioned polices not being rendered as provisioned (#81449) Fix child provisioned polices not being rendered as provisioned commit 6f245121d09cc5cfb048334316c6e8bf60f8b635 Author: Giedrius Statkevičius Date: Mon Jan 29 11:31:49 2024 +0200 Plugins: Fix colon in CallResource URL returning an error when creating plugin resource request (#79746) * Plugin: handle colon character in path url.Parse() does not handle the given input correctly when the input contains a colon character. The user will see the following error message when trying to use remote cluster in Elasticsearch: ``` level=warn msg="Failed for create plugin resource request" error="parse \"foo-*,*:foo-*/_mapping\": first path segment in URL cannot contain colon" traceID= ``` As far as I can tell, we only want to set the path here + rawquery so avoid url.Parse() altogether. * Add more tests --------- Co-authored-by: Giuseppe Guerra commit 34eb50dab3c8685af148e448ee905bae423e69a3 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 29 09:27:35 2024 +0000 Update dependency @floating-ui/react to v0.26.8 (#81435) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 9a4534a17c29060e5050da8484eff169de4f4ee3 Author: Oscar Kilhed Date: Mon Jan 29 10:22:17 2024 +0100 Transformations: Use the display name of the original y field for the predicted field of the regression analysis transformation. (#81332) Fix name of regression transformation field commit d8d9121b6d7eef73cbea6a9cd9d46440bbe25fb0 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Mon Jan 29 09:12:49 2024 +0000 Tempo: Clean up older styles (#81247) * Clean up uber styles * Use theme spacing commit 61934588c579005de80c54b15f42f0d9449efd93 Author: Zoltán Bedi Date: Mon Jan 29 08:22:07 2024 +0100 E2E: Skip flaky mysql test (#81388) commit 6a61c3da2e4424b27602702247e0b79e20521905 Author: Leon Sorokin Date: Sun Jan 28 22:36:28 2024 -0600 TooltipPlugin2: Improve containment inside ancestors that hide overflow (#81442) commit d34bd1dc9001b89c7108314a4e658bfbd3a2e7ff Author: Leon Sorokin Date: Sun Jan 28 21:45:28 2024 -0600 Chore: uPlot v1.6.29 (#81441) commit 835ded5eec9137af3fd9e2849942285d6cf38e8f Author: Leon Sorokin Date: Sun Jan 28 21:33:34 2024 -0600 Histogram: Fix x-axis going past +Inf when rendering heatmap-rows frames (#81438) commit 1fab107e792f8ddaade6230947978d93523781d3 Author: Ryan McKinley Date: Sun Jan 28 15:22:45 2024 -0800 FeatureFlags: Avoid using cfg.IsFeatureToggleEnabled (#81407) commit 1a1531ca5e92554d107bb1eb9df2f9fdc604d824 Author: Nathan Marrs Date: Fri Jan 26 16:49:14 2024 -0700 Chore: Remove isEqual mock from field state test (#81019) commit 80ac0cc9f6a9988b72f51323cdee410cb56af1a6 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Sat Jan 27 00:47:42 2024 +0100 Tempo: Fix durations in TraceQL (#81418) commit b411aa7266129e3e7b5313317dfc7d0c8a50cdeb Author: Andrew Hackmann <5140848+bossinc@users.noreply.github.com> Date: Fri Jan 26 14:53:55 2024 -0800 Azure Monitor: use NewLoggerWith func instead of backend.Logger (#81124) using NewLoggerWith commit 05300213960248c578afe9195a6f3e5fd9795394 Author: Leon Sorokin Date: Fri Jan 26 16:32:12 2024 -0600 Field: Fix perf regression in getUniqueFieldName() (#81323) Co-authored-by: nmarrs commit ad1c4b726b584e70577a15c4cf42cd69b6450d12 Author: Summer Date: Fri Jan 26 15:22:05 2024 -0700 Grafana Build: fix release process not publishing latest storybook (#81412) Bugfix: release process not publishing latest storybook commit 1213b66188f484b42f3a2a6480c7f455c6e32c1c Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Fri Jan 26 16:39:37 2024 -0500 Docs: time range copy paste (#81408) Added new entry and deleted internal enablement video notes commit 42c9b582e0cac9569c601b85d6aa670be7393b3a Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Fri Jan 26 16:26:58 2024 -0500 Docs: add saved dashboard guidance (#81406) * Added saved dashboard guidance * Ran prettier commit 77b0369fdba1c254b64c98806eeac89f1ab6e0fd Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Fri Jan 26 21:46:52 2024 +0100 Fix `TemporaryAlert` (#81384) commit b758b91e84eabd3c829bc9228180c50f56d8279f Author: Ricky Whitaker Date: Fri Jan 26 12:17:43 2024 -0600 Update pr-codeql-analysis-go.yml to use token (#81395) Updating .github/workflows/pr-codeql-analysis-go.yml to use GH token to work in private security m irror commit 048d1e7c861c419c7b2522429b654cc17a9c903b Author: Ieva Date: Fri Jan 26 17:17:29 2024 +0000 RBAC: Annotation permission migration (#78899) * add annotation permissions to dashboard managed role and add migrations for annotation permissions * fix a bug with conditional access level definitions * add tests * Update pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go Co-authored-by: Gabriel MABILLE * apply feedback * add batching, fix tests and a typo * add one more test * undo unneeded change * undo unwanted change * only check the default basic permissions for non-OSS instances * account for all wildcards and simplify the check a bit * error handling and extra conditionals to avoid test failures * fix a bug with admin permissions not appearing for folders * fix the OSS check --------- Co-authored-by: Gabriel MABILLE commit 138079bbd80be5188e039463f7e5377507ac698b Author: Nathan Marrs Date: Fri Jan 26 10:14:59 2024 -0700 Transformations: Fix regression where disabling single transformation would display "No data" (#81320) commit a081abdd258cc48ffc34956603ef974f802d3e48 Author: Yuri Tseretyan Date: Fri Jan 26 12:12:45 2024 -0500 Folders: GetFolders to return empty respons if user does not have any permissions (#81304) add check for list of permissions commit 09fcb3c6cce9ed1743670b4c0a724a74cb4a9716 Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Fri Jan 26 10:02:44 2024 -0600 Candlestick: Add tooltip options (#81307) commit 8ca080d47c96b918a402034a2dbc74e8907e41d8 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 26 15:48:40 2024 +0000 Update dependency react-router-dom-v5-compat to v6.21.3 (#81379) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit cca3bb09173c085026106a732230309f02745e7e Author: Ashley Harrison Date: Fri Jan 26 15:48:06 2024 +0000 Chore: Expose icons and add icon documentation (#81371) * expose icons and initial draft documentation * expose archive-alt not archive * doc tweak * update docs * remove some width/heights from icons commit 2c4e1d4baf05795bcdd209ceccb0d9cc862af7a4 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Fri Jan 26 16:39:21 2024 +0100 Tempo: Fix typo (#81383) commit f44592a97a5c5a63ee0449846fcda46df196a071 Author: idafurjes <36131195+idafurjes@users.noreply.github.com> Date: Fri Jan 26 16:36:35 2024 +0100 Remove folderID from service tests (#80615) * Remove folderID from service tests * Remove folderID from ngalert migration tests * Remove tests related to folderIDs * Roll back change Before removing FolderID from this test, we need to adjust the code * Remove FolderID from publicdashboard pkg * Add back annotations test commit 04396c001ac9e1083ada9f16e3b2f6a40c049c3d Author: Ashley Harrison Date: Fri Jan 26 15:30:36 2024 +0000 Navigation: Move asserts app to root, add asserts icon (#81362) * move asserts app to root, add asserts icon * remove width/height/fill from asserts icon commit ad936b6c8364550ba4002805d3c5782e117fcf47 Author: Matthew Jacobson Date: Fri Jan 26 10:17:14 2024 -0500 Alerting: Fix group by and timing override fields in simplfied routing section (#81321) Fixes the group by custom labels and timings override logic in the simplified routing section of the edit rule page. Previously: - Custom labels would fail on first attempt at adding them to the group by. - Timings fields required all timings to be overridden instead of any of them. commit 4f43c4f3a761365f6b130115c42cb869852f7008 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 26 15:05:01 2024 +0000 Update dependency lru-cache to v10.2.0 (#81373) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 9a8fb5540bb649910d4a4be36d0e4648cba61463 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 26 14:58:12 2024 +0000 Update dependency logfmt to v1.4.0 (#81372) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit e9a99a46b072b70d998529d79ed9f6f5f2d8cfc8 Author: Domas Date: Fri Jan 26 16:37:49 2024 +0200 Tempo: Support multiple filter expressions for service graph queries (#81037) * support "OR" for service graph queries * make betterer happy * continue appeasing betterer * betterer results commit 8c212a19523e29f29bfc2759a663f547d2e84860 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Fri Jan 26 15:29:23 2024 +0100 Logs: Fix toggleable filters to be applied for specified query (#81368) commit f943c277865a8d0bd9e5e81781e92fba9569c896 Author: Gábor Farkas Date: Fri Jan 26 15:27:09 2024 +0100 grafana-sql: update packages (#81344) commit bf47eda6a976038f70c88985b8155f3206f62347 Author: Dominik Prokop Date: Fri Jan 26 06:03:50 2024 -0800 Scenes: Bump to 2.1.0 (#81367) commit 1a6105be8d34dcb266f02e9013ee272ceaf92e36 Author: Ashley Harrison Date: Fri Jan 26 13:59:28 2024 +0000 Datasource: Add optional `queries` parameter to `getTagKeysOptions` (#81071) add optional queries parameter to getTagKeysOptions commit 5a2e9ea2ee8bfb9e680f954e1a4ce6210b940a1c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 26 13:31:57 2024 +0000 Update dependency @grafana/experimental to v1.7.8 commit f042ca5b122e2cdc0735f48e67ba45ffb729bf72 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri Jan 26 14:56:00 2024 +0100 Alerting: Move action buttons in the alert list view (#81341) Move action buttons in the alert list view commit 5c9fe6ac93235763cb5883d792367c90df651810 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 26 13:25:21 2024 +0000 Update dependency @grafana/scenes to v2.1.0 (#81365) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 654db41450d8d5a215d4c08b2968a1a6b9ac291c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 26 13:24:28 2024 +0000 Update dependency i18next-browser-languagedetector to v7.2.0 (#81359) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 9c728def385fb4ee30f04ef99520da93b0632137 Author: Sven Grossmann Date: Fri Jan 26 14:03:24 2024 +0100 Loki: Fix label not being added to all subexpressions (#81360) commit e90ea8a941a347636eb105ada974db7a6ef40030 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 26 12:32:26 2024 +0000 Update dependency @opentelemetry/semantic-conventions to v1.21.0 (#81356) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 9ccf7542f2e4d5c174cfe2aed2200e36bb83fb6f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 26 12:11:18 2024 +0000 Update dependency immutable to v4.3.5 commit 857bce25e61f4f01593a79f90449a06cc1a1bf1a Author: Alex Khomenko Date: Fri Jan 26 13:04:44 2024 +0100 Chore: Replace Form usage in LoginForm.tsx (#81326) commit 3c4cfb1a7052922dbae8477119cf7ef36a69a7f8 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 26 11:38:01 2024 +0000 Update dependency @types/uuid to v9.0.8 commit 852777e5de024015677875e069125209b04343e5 Author: Gábor Farkas Date: Fri Jan 26 12:30:35 2024 +0100 levitate: skip checking grafana-sql (#81350) commit 467f29394817a27271267682246d816016668689 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Fri Jan 26 10:56:46 2024 +0000 Tempo: Upgrade trace to profiles docs (#81002) * Update docs to use embedded flame graph image * Update headings * Update link type * Add embedded flame graph content * Minor text changes * Add provisioning example * Move configure table into section * Add configure section * Update provisioning example * Update docs/sources/shared/datasources/tempo-traces-to-profiles.md Co-authored-by: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> * Update docs/sources/shared/datasources/tempo-traces-to-profiles.md Co-authored-by: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> * Update wording --------- Co-authored-by: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> commit f3f36e37fa2fa33cd530f4213c41833f72bc5db4 Author: Jo Date: Fri Jan 26 11:54:00 2024 +0100 AuthInfo: No mandatory auth_id in Auth Info service (#81335) * fix auth info update not having mandatory auth_id * remove uneeded newline commit 29e8a355cbca4f554f14161abe0b2de0cd5b8a9b Author: Gábor Farkas Date: Fri Jan 26 11:38:29 2024 +0100 sql: extract frontend code into separate package (#81109) * sql: extract frontend code into separate package * updated package version commit 2febbec758f73471938d9d5e68c15e2978667721 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 26 10:06:16 2024 +0000 Update dependency @types/node to v20.11.7 commit 7512b1a519458d159a34946ff1783fdbaf1693e5 Author: Gabriel MABILLE Date: Fri Jan 26 11:23:48 2024 +0100 RBAC: Search fix userID filter (#81337) commit cd443b24db77831e8908786ff23e58ef9fe94108 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 26 10:13:55 2024 +0000 Update dependency @floating-ui/react to v0.26.7 (#81334) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit c886a5804dbc420163b5bfc2f56456ce2ed149f5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 26 12:02:53 2024 +0200 Update dependency eslint-plugin-import to v2.29.1 (#81302) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit b4d490883ead230407400eb3453e972f6febc118 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 26 09:47:09 2024 +0000 Update dependency yaml to v2.3.4 (#81292) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit d86eac6ce08b79ba3d8b52dbcac55c0f5020a7bb Author: Aaron Godin Date: Fri Jan 26 03:44:11 2024 -0600 fix: Upgrade ts-node to work with latest typescript (#81322) fix: plugins-bundled/internal/input-datasource build regression from ts-node version commit 8761dfcc9763644e0ec2b18567a3fa2ef63ad504 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Fri Jan 26 10:32:20 2024 +0100 Add custom alert component (#81012) Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Co-authored-by: Matias Chomicki commit 19194ea122dc88abba7fc75a77dbf897789527d6 Author: Gabriel MABILLE Date: Fri Jan 26 10:11:41 2024 +0100 RBAC: Remove redundant search endpoint (#81331) commit 802012385d626f063a4bee4aac1672789a25455b Author: Timur Olzhabayev Date: Fri Jan 26 09:51:15 2024 +0100 Chore: fixing PanelDataTransformationsTab Test (#81329) * fixing An update to Input inside a test was not wrapped in act(...). * no need to await twice * looks like we do need to await twice commit 7230ec88a1cdf1ba759e212cc3d8418e59f734fd Author: Gábor Farkas Date: Fri Jan 26 09:45:17 2024 +0100 postgres: test: remove unused code (#81330) commit 722b78f3e04d488c9b6d670f9509a472d4e1f528 Author: Gabriel MABILLE Date: Fri Jan 26 09:43:16 2024 +0100 RBAC: Add userLogin filter to the permission search endpoint (#81137) * RBAC: Search add user login filter * Switch to a userService resolving instead * Remove unused error * Fallback to use the cache * account for userID filter * Account for the error * snake case * Add test cases * Add api tests * Fix return on error * Re-order imports commit 2e352ba4d6ba01881df3d5f62dedd9eee2000afb Author: Julien Duchesne Date: Fri Jan 26 03:16:45 2024 -0500 Docs(alerting): Document the `disable_provenance` attribute (#81290) We no longer need to pass a http header commit b1d1aa667a21fd01ae836968f4531b8c69adf79c Author: Gábor Farkas Date: Fri Jan 26 09:11:25 2024 +0100 postgres: refactor code that is called by tests (#81279) * postgres: refactor code that is called by tests too * removed debug log commit a10c577f52a41324a3b66acc45f96cc43b00eb34 Author: Alex Khomenko Date: Fri Jan 26 08:38:12 2024 +0100 AddPermission: Prevent page reload (#81324) commit 0f3606f3f5b2105db00ceb328dfb698de10082a2 Author: Alex Khomenko Date: Fri Jan 26 08:12:54 2024 +0100 Chore: Remove Form usage in ForgottenPassword (#81263) * Chore: Remove Form usage in ChangePassword.tsx * Chore: Remove Form usage in ForgottenPassword.tsx commit ae2e1aeee4c02fd213f3b25254c9fd8c237392df Author: Alexander Weaver Date: Thu Jan 25 16:46:22 2024 -0600 Alerting: Upgrade feature toggle stages for jitterAlertRules and jitterAlertRulesWithinGroups (#81314) Upgrade jitter toggle stages commit 366dc8db1b0b86102baea2c5659a95f7eac87511 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Thu Jan 25 23:41:23 2024 +0100 Tempo: Rewrite style using object (#81278) commit 7ab710daa7f4986fe0629e1db6a39ac99b46d8b3 Author: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu Jan 25 14:27:11 2024 -0700 Dashboard-Scenes: JSON Model and Version tabs don't hide edit mode on refresh (#81219) * Add NavToolbarActions to fix the bug * Update public/app/features/dashboard-scene/settings/VersionsEditView.tsx Co-authored-by: Victor Marin <36818606+mdvictor@users.noreply.github.com> * Fix compared view --------- Co-authored-by: Victor Marin <36818606+mdvictor@users.noreply.github.com> commit b1eec36df336c4657c62c818b8422f277e898035 Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Thu Jan 25 22:19:51 2024 +0200 Alerting: Fix authorisation to use namespace UIDs for scope (#81231) commit 2c7e95a680ee8bb041306b558d37e7e663114486 Author: Andre Pereira Date: Thu Jan 25 20:06:22 2024 +0000 Data Trails: Cleanup and explore+share buttons (#81062) * Share button works now. Removed add to dashboard button for now * WIP explore link * Remove settings dropdown for now * Use getExploreUrl to generate explore link * Fix conflicts * Update betterer * Navigate to a new trail when the recent trails list is empty * Address PR comments commit 9167d67c055aa1ebf5e3dd161db8e9f8f43045e1 Author: Charandas Date: Thu Jan 25 12:01:09 2024 -0800 K8s: update hack codegen script (#81216) commit 1595551a4a578a96dcdfb568bfcb862d35d72318 Author: Ihor Yeromin Date: Thu Jan 25 21:47:25 2024 +0200 Units: Watt-hour fix (#81299) fix(units): watt-hour commit c696b5e1dd2c6abf519cc448967e978b92c33c36 Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Thu Jan 25 14:36:00 2024 -0500 Docs: restructure Configure value mappings page (#80103) * Consolidated four add mappings tasks into one * Moved images from tasks to types of value mappings section * Removed edit value mappings section * Moved sentence about reordering mappings to intro section * Docs: Add to and update Configure value mappings page (#80104) * Added to do notes * Updated intro text and screenshot * More intro edits * Updated Types of value mappings section Replaced bullet list items with headings Updated text and screenshots of section * Updated Add a value mapping task * Recast sentence to remove passive voice * Replaced local image files with images on admin commit 2af8158f99ed799b4b6f7a220e03800aa073d313 Author: William Wernert Date: Thu Jan 25 12:56:09 2024 -0500 Remove Loki annotation toggle (#81296) commit f101d9df278ef10ec544174dd493d47ed5593148 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 25 17:15:00 2024 +0000 Update babel monorepo to v7.23.9 commit b821e1621c645d7ad7f582d3fd67b38a469051a2 Author: Jara Suárez de Puga García Date: Thu Jan 25 18:40:20 2024 +0100 Alerting Provisioning API: documentation Update. (#80388) * Update alerting_provisioning.md * Introduction and references * UID variable consistency formatting * Folder and Group variables consistency formatting * Delete Note from Contact Point section * api/v1/provisioning/folder/:folderUid/rule-groups/:group clarifications and extension. Request #8218 * Prettier format document * {name} to :name and format * Comma Co-authored-by: Eve Meelan <81647476+Eve832@users.noreply.github.com> --------- Co-authored-by: Eve Meelan <81647476+Eve832@users.noreply.github.com> commit a9731846ccbfd905b85246c7d2d4160f1d075cc4 Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Thu Jan 25 12:03:44 2024 -0500 Docs: fix broken link (#81285) Fixed broken link commit 18b762bdcac32f5384b5050c4bdbe857bdd7ae33 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 25 18:42:55 2024 +0200 Update dependency @types/node-forge to v1.3.11 (#81281) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 709a69fd3c672c6ae70485b0bebc4f3ac1a3df97 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 25 16:28:20 2024 +0000 Update dependency @types/react-window-infinite-loader to v1.0.9 (#81282) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit fb2b9f33d238b4029cb9c1a5f8465b46c895d08a Author: idafurjes <36131195+idafurjes@users.noreply.github.com> Date: Thu Jan 25 17:26:24 2024 +0100 Fix method to service in metric label for FolderID (#81283) commit dc9e590b7bd7cf4e09a1b1521f6607e03593d3d3 Author: Ieva Date: Thu Jan 25 16:24:52 2024 +0000 RBAC: Return the underlying error instead of internal server or bad request for managed permission endpoints (#80974) * return not found instead of an internal server error when listing/updating permissions * openapi gen commit 25dd8d5ceb4d61f808ad043fea753072900cd426 Author: Jean-Philippe Quéméner Date: Thu Jan 25 17:05:58 2024 +0100 Docs: add section about periodic state saving for alerting (#81244) commit 7d0017f3f2426e1051a5ada3d551df347169f148 Author: ismail simsek Date: Thu Jan 25 17:05:08 2024 +0100 InfluxDB: Use grafana timeout value for flux queries (#81252) * use grafana timeout for flux queries * get timeout from settings * fix test commit add5a5c01e33e2f3dff7d9372cf5775bbad2e294 Author: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Thu Jan 25 17:01:22 2024 +0100 LDAP: Use InteractiveTable and remove gf-form usage (#80291) * Use InteractiveTable * Remove unused return * Fix icon alignment * InteractiveTable in LdapUserMappingInfo * Update no teams text * InteractiveTable in LdapUserGroups * Remove unused code * Cleanup * LdapSyncInfo to InteractiveTable * Update more tables * Memoize * Fix connection status * Update lockfile * Refactor LdapSyncInfo * Fix lockfile * Remove showAttributeMapping as it is always true commit 5195e5347e01f57feaf42636ebe8bcf01d595139 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Thu Jan 25 16:44:16 2024 +0100 Prettify `tsconfig.json` files for T&P data sources (#81262) commit 828c2cebf0450142e1bc100a4420dc75305c70cd Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 25 15:22:19 2024 +0000 Update dependency @types/lucene to v2.1.7 (#81273) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 343556c3c5fcac83ad1f00be0a3d85a2c7ce0bdf Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 25 17:21:27 2024 +0200 Update dependency @types/js-yaml to v4.0.9 (#81272) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 021aca256c8ff9973b29448240a64ee38f0efe5b Author: lean.dev <34773040+leandro-deveikis@users.noreply.github.com> Date: Thu Jan 25 12:20:20 2024 -0300 Fix typos (#81270) commit ab467a41c2e5e334866de950ee83a65286993a92 Author: Alex Khomenko Date: Thu Jan 25 16:04:30 2024 +0100 Chore: Remove Form usage from AddPermission (#81261) commit 27839fec11870d0089ac02b866a3d3c500704635 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 25 15:02:06 2024 +0000 Update dependency @types/diff to v5.0.9 (#81266) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 138b314a070bc4444ac96f366adfec3e79c8815b Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 25 14:31:21 2024 +0000 Update dependency @types/d3-force to v3.0.9 commit 5772662ceeaef4002e3e78d0a36a1aa723374709 Author: Isabella Siu Date: Thu Jan 25 09:40:55 2024 -0500 CloudWatch: decouple from ngalert/models and query packages (#81210) commit da6614bf564bd6af655dc3db4f99524f8a3f7195 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 25 14:28:29 2024 +0000 Update dependency @types/common-tags to v1.8.4 (#81250) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 4dfc18e6286e4b8065c7a0e9018cc0ba81cffc08 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 25 14:28:07 2024 +0000 Update dependency @types/chance to v1.1.6 (#81243) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 22561e86900242b4ba864a133c698a833a62f151 Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Thu Jan 25 08:14:46 2024 -0600 Annotations: Prevent creating a new range in unsaved dashboards (#81256) commit 9ba13dd3092a3a9ff26b208181cbbc98beec0a60 Author: Sergej-Vlasov <37613182+Sergej-Vlasov@users.noreply.github.com> Date: Thu Jan 25 14:04:29 2024 +0000 Dashboards: Remove emptyDashboardPage feature flag (#81188) * remove emptyDashboardPage feature toggle from DashNav * remove emptyDashboardPage feature toggle from NewDashboardWithDS * remove emptyDashboardPage feature toggle from DashboardGrid * remove emptyDashboardPage feature toggle from DashboardModel * remove emptyDashboardPage feature toggle from initDashboard * remove unused AddPanelWidged component * remove add-panel type from test * remove emptyDashboardPage feature flag from registry.go commit d66d7a96425a5afedb44483202187b5efb656f54 Author: Hugo Kiyodi Oshiro Date: Thu Jan 25 14:32:31 2024 +0100 Plugins: Change managedPluginsInstall to public preview (#81053) commit 9b4c78dbc9fea403ddc7a06f624f6a8a0b6b6c36 Author: Ida Štambuk Date: Thu Jan 25 13:33:31 2024 +0100 Cloudwatch: Import httpClient from grafana-plugin-sdk-go instead of grafana/infra (#81187) commit 3094531b632d5bb4d89d422f6cabe7082847a50e Author: Alexander Zobnin Date: Thu Jan 25 15:02:32 2024 +0300 Folders: Optimize shared folders listing (#81245) * Folders: Expose function for getting all org folders with specific UIDs * lint * Fix test * fixup * Apply suggestion from code review * Remove changes in alerting scheduler * fixup * fixup after merge with main * Add batching * Use strings.Builder * Return all org folders if UIDs is empty * Filter out not accessible folders by the user * Remove comment * Fix batching when count is zero * Do not include dashboard permissions * Add some tests * fix test * Use batch request for folders * Use batch request to deduplicate folders * Refactor * Fix after merging main * Refactor --------- Co-authored-by: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> commit 2774e0d023abd182c426831538ed09e9f72b9579 Author: Alexa V <239999+axelavargas@users.noreply.github.com> Date: Thu Jan 25 12:56:37 2024 +0100 Dashboard: Migration - EditVariable Settings: Implement DataSource Variable (#80885) * Extract DatasourceVariableForm logic and use it in core grafana * Implement DataSourceVariable editor in scenes * Refactor VariableSelect and add unit test for DataSourceVariableEditor * Refactor old unit test to use userEvent and mock getDataSourceSrv commit 0880a239f89e0abe29c88d146452f3ec2b45dd90 Author: idafurjes <36131195+idafurjes@users.noreply.github.com> Date: Thu Jan 25 12:14:18 2024 +0100 Add leftover metrics for FolderID (#81246) commit 85429f7bd0e06ee7bdca8f450d1391025a6a7051 Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Thu Jan 25 12:02:35 2024 +0100 Alerting docs: swap alert steps (#81249) * Alerting docs: swap annotation and notification steps * Updates data source managed rule steps commit c58dc09e843737bfbe38faab4d4094bed9583050 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 25 10:52:49 2024 +0000 Update dependency @glideapps/glide-data-grid to v6 (#81189) * Update dependency @glideapps/glide-data-grid to v6 * mark glide-data-grid as esmodule --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit 14e55fefbbe9fc6cc850c250869daef612a93b1e Author: ismail simsek Date: Thu Jan 25 11:28:25 2024 +0100 InfluxDB: Check the value type before casting it to the string (#80986) * Check the value type before casting it to the string * set visualization as table by default * append all values for show diagnostics * golangci-lint * append metadata only to first frame commit c6793d4f123c1a3150cf6dde8cc07bd43b100471 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu Jan 25 11:19:22 2024 +0100 Loki: Implement visual query builder from `@grafana/experimental` (#81140) * Loki: Use visual query builder from grafana/experimental * Update to 1.7.7 * Update * In renderOperation console.error instead of throwing error * Remove redundant comment commit 4577e61ee7846448fdb4df3b979cae0ff375b7f8 Author: Misi Date: Thu Jan 25 11:13:24 2024 +0100 Auth: Improve /admin/authentication permission checks and include new SSO pages (#81183) * Move evalAuthSettings to ssoutils * Improve permission check for auth page commit 7e5544ab21da88ec2bf987ee6583fcf9bbb944bf Author: idafurjes <36131195+idafurjes@users.noreply.github.com> Date: Thu Jan 25 11:10:35 2024 +0100 Add MFolderIDsServiceCount to count folderIDs in services pkg (#81237) commit 0d66ad68f84364ea449fba795abc7fe3c0793929 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 25 09:28:39 2024 +0000 Update dependency @swc/core to v1.3.106 commit 478d7d58fa07e0182333bde74c870fa039dc6d9f Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Thu Jan 25 11:29:56 2024 +0200 Nested folders: Allow creating folders with duplicate names in different locations (#77076) * Add API test * Add move tests * Fix create folder * Fix move * Fix test * Drop and re-create index so that allows a folder to contain a dashboard and a subfolder with same name * Get folder by title defaults to root folder and optionally fetches folder by provided parent folder * Apply suggestions from code review commit 030a68bbf722c56497294666df8d5feba8860340 Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Thu Jan 25 10:28:32 2024 +0100 Alerting docs: recovery threshold (#81069) * Alerting docs: recovery threshold * ran prettier * Adds note that only available in oss * ran prettier commit 12e19d5364e747fe63e12ccd976792a12cf38c05 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 25 09:22:31 2024 +0000 Update dependency @braintree/sanitize-url to v7 (#81186) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 3e073c4dc1f3bac8dcc85b4f08fdb93fa015964c Author: Ashley Harrison Date: Thu Jan 25 09:20:37 2024 +0000 Nested Folders: Update documentation (#81054) * docs updates! * Apply suggestions from code review Co-authored-by: Jack Baldry * Apply suggestions from code review Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> * simplify --------- Co-authored-by: Jack Baldry Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> commit c74169733382f2a97f2057516d61cf47bc0b5bcf Author: Ashley Harrison Date: Thu Jan 25 09:20:09 2024 +0000 Chore: Don't import from outside of grafana-ui (#81160) don't import from outside of grafana-ui commit 0bcc60f437ed9cb553b06939de90c178fddb04c3 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Thu Jan 25 09:57:10 2024 +0100 Alerting: Swap order between Annotations and Labels step in the alert rule form. (#81060) * Swap order between Annotations and Labels and notifications step, and update some texts * Update routing preview label size * Fix dashboard and panel label when are selected * Swap order in modify export form commit 6e827889b463cd2a460300fce54053e69e70bfef Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Thu Jan 25 10:55:44 2024 +0200 Chore: Fix folders flaky test (#81234) It used to wrongly assume slice ordering commit 6218e28ee9c884b852822fa510c320c7781fda7e Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Thu Jan 25 08:53:54 2024 +0100 Alerting: Add refresh button to contact points selector in simplified routing section. (#80748) * Add refresh button for contact points selector in simplified routing section * Clear timeout when unmounting component * Fix timeout not being correclty removed when component unmounts * Update css field name * Kepp loading spinner if refetching receivers takes more than one second * Fix test snapshot in useContactPointsWithStatus hook * refactor how we wait for the request response and the timeout to finish commit ebe8c005cec6e4a4716ae6fadbc3ba180cf40341 Author: Piotr Jamróz Date: Thu Jan 25 08:51:16 2024 +0100 Explore: Set default time range to now-1h (#81135) Update default time range in Explore back to now-1h commit 5e88d29814184d2a7cee70c581c373ecab6ee972 Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Thu Jan 25 09:27:13 2024 +0200 Folders: Introduce folder service function for fetching folders by org and UIDs that contain optionally the folder full path (#80716) * Folders: Expose function for getting all org folders with specific UIDs * Return all org folders if UIDs is empty * Filter out not accessible folders by the user * Modify query to optionally returning a string that contains the UIDs of all parent folders separated by slash. commit f154b2b8557cab3f8ee4259c1f627163bf636cf1 Author: Alex Khomenko Date: Thu Jan 25 07:59:24 2024 +0100 Grafana/ui: Add Space component (#81145) * Grafana/ui: Add Space component * Add responsive styles and prop docs * Use the Box component * Docs * Replace the component from grafana/experimental * Update story * Tweak docs * Adjust docs commit 05eb4fcd7f90647b9663bbd51708c0f19a61b3cb Author: Torkel Ödegaard Date: Thu Jan 25 07:54:32 2024 +0100 Drawer: Resizable via draggable edge (#80796) * Drawer: POC of draggable resizable drawer side * Cleaner solution * refinements * refinements * Add touch support commit e08700c1b5dd9217585e84a79506ed9767d0d8ba Author: Torkel Ödegaard Date: Thu Jan 25 07:32:07 2024 +0100 Dashboard: New EmbeddedDashboard runtime component (#78916) * Embedding dashboards exploratino * Update * Update * Added e2e test * Update * initial state, and onStateChange, only explore panel menu action and other fixes and tests * fix e2e spec * Fix url * fixing test commit 9da3db1ddf02a29a037914991ba2e889940cb45d Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Wed Jan 24 19:24:52 2024 -0600 Annotations: Prevent creating on unsaved dashboard (#81200) commit 1d25039674b4fa7b8ba1df6cee8e50a9498d8b81 Author: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> Date: Wed Jan 24 17:47:35 2024 -0500 [DOC] Fix broken link tempo data source (#81126) * Fix broken link tempo data source * Use docs/reference shortcode * Update docs/sources/datasources/tempo/_index.md Co-authored-by: Jack Baldry --------- Co-authored-by: Jack Baldry commit 2203bc2a3d6ed91df04fcd9f31b3cd572c81871a Author: William Wernert Date: Wed Jan 24 17:15:55 2024 -0500 Alerting: Refactor provisioning tests/fakes (#81205) * Fix up test Alertmanager config JSON * Move fake AM config and provisioning stores to fakes package commit e45f664ca4f04edeabf8a67020b87590a3778e69 Author: Matthew Jacobson Date: Wed Jan 24 16:18:14 2024 -0500 Alerting: Replace index role_id, action, scope with action, scope, role_id on permission table (#80336) * Alerting: Add action, scope, role_id to permission table The existing role_id, action, scope index has the wrong ordering to be most effectively used in dashboard/folder permission requests. On a large tests set, the slow database calls were on the order of ~30-40ms, so when performed individually they don't have that large of a latency impact. However, when done in bulk in the migration this adds up to some very slow requests. After the index is added these same database calls are reduced to ~4-5ms * Change index to action, scope, role_id * Make new index unique and drop [role_id, action, scope] index commit 71e70c424fd5885fe1be52613d74cd54489c1f02 Author: Matthew Jacobson Date: Wed Jan 24 15:56:19 2024 -0500 Alerting: During legacy migration reduce the number of created silences (#78505) * Alerting: During legacy migration reduce the number of created silences During legacy migration every migrated rule was given a label rule_uid=. This was used to silence DatasourceError/DatasourceNoData alerts for migrated rules that had either ExecutionErrorState/NoDataState set to keep_state, respectively. This could potentially create a large amount of silences and a high cardinality label. Both of these scenarios have poor outcomes for CPU load and latency in unified alerting. Instead, this change creates one label per ExecutionErrorState/NoDataState when they are set to keep_state as well as two silence rules, if rules with said labels were created during migration. These silence rules are: - __legacy_silence_error_keep_state__ = true - __legacy_silence_nodata_keep_state__ = true This will drastically reduce the number of created silence rules in most cases as well as not create the potentially high cardinality label `rule_uid`. commit fbbda6c05e095a0678b65f128255f60231c58ae2 Author: Santiago Date: Wed Jan 24 21:39:06 2024 +0100 Alerting: Retry readiness check to the remote Alertmanager on 5xx status code responses (#81174) commit c9ff6a9ab98e315ceb1f8c2911ce2accdd4c79d8 Author: Ryan McKinley Date: Wed Jan 24 11:27:46 2024 -0800 Chore: Generate shorter UIDs (#79843) commit a81d3b1d221b2ed405246ab2b4bf1eef209fe922 Author: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Wed Jan 24 12:08:18 2024 -0600 Table: Cell inspector auto-detecting JSON (#81152) * set inspect mode to json if no errors parsing json commit a138ce668df0daac4840d37f4caac32dbaf3d813 Author: Josh Hunt Date: Wed Jan 24 17:24:24 2024 +0000 I18n: Remove unneeded i18n:compile script (#81184) commit b4ae711825a95c636e4eee78e1c73c72966a846c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 24 18:48:34 2024 +0200 Update Yarn to v4 (#81176) * Update Yarn to v4 * add back bundled yarn * properly --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit 1090d55b544cfb5efcd23acbb50a3973974485e0 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 24 16:18:02 2024 +0000 Update visx to v3.5.0 (#81175) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit dd9a503dd0837d4b5b0c2d9eca0b840fa0b9ad9c Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Wed Jan 24 10:16:15 2024 -0600 VizTooltip: No width limit on anchored tooltip (#81017) commit bbe74e4b03195bbad94ce90dc607bbd3752e3093 Author: Matías González <48534497+MatiasLGonzalez@users.noreply.github.com> Date: Wed Jan 24 13:08:03 2024 -0300 Currency: Added Paraguayan Guaraní (PYG) currency (#81007) commit 6fd8898cde271d796b8ac93d499031b7c31babae Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed Jan 24 16:03:38 2024 +0000 Update `make docs` procedure (#81170) Co-authored-by: grafanabot commit 20fe0eb173934958a3ab2c03407c744be69e40d4 Author: Marcus Efraimsson Date: Wed Jan 24 16:49:49 2024 +0100 Chore: Extract DatabaseConfig parsing from SQLStore (#81157) Extract the parsing/creating of database config/connectiong from SQLStore string to a separate DatabaseConfig struct. commit 3b96eb854ae89d0af267f70cdf5bd6e1fca49668 Author: Andres Martinez Gotor Date: Wed Jan 24 16:44:40 2024 +0100 Chore: Replace config with datasource in names (#81098) commit 3ea84e1b7f27bd38ba1d728eec47bb14aa11579d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 24 15:40:06 2024 +0000 Update typescript-eslint monorepo to v6.19.1 (#81162) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 7dd89134dc1757023d41e835a9319680a7175758 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 24 15:33:54 2024 +0000 Update dependency typescript to v5.3.3 (#81096) * Update dependency typescript to v5.3.3 * update ApiKeysPage * fix remaining conflict in lockfile * update sdk --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit 57c550778e2a67ebcb3dab19245eaa02cbb944ed Author: Jack Baldry Date: Wed Jan 24 15:30:42 2024 +0000 Stop README being built into website (#81171) commit 0434f191fe9637d67468f08961651244daa0fd6f Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Wed Jan 24 16:21:36 2024 +0100 Tempo: Fix NaN value using fallback (#81150) commit f726ea1e523b7b7a91beb207d8e4df40d62c238e Author: Kevin J Gao <32936811+gaokevin1@users.noreply.github.com> Date: Wed Jan 24 06:56:44 2024 -0800 Added Descope as an OAuth2 provider (#80050) * added Descope as an OAuth2 provider Added docs for customers of ours that have asked us how to use Descope with Grafana. We wanted to make sure they can easily find these docs on both our website and Grafana's. * Update docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md Co-authored-by: Ieva * Update docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md Co-authored-by: Ieva * Update docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md Co-authored-by: lwandz13 <126723338+lwandz13@users.noreply.github.com> * Changed note to use admonition * Prettier Signed-off-by: Jack Baldry --------- Signed-off-by: Jack Baldry Co-authored-by: Ieva Co-authored-by: lwandz13 <126723338+lwandz13@users.noreply.github.com> Co-authored-by: Jack Baldry commit c8f450c851bf2bbf3a6112c45a1bd849cf2d6093 Author: Alex Khomenko Date: Wed Jan 24 15:52:50 2024 +0100 Revert "Grafana/ui: Enable removing values in multiselect opened state" (#81161) commit c47b55ae10e537a62e50adbef44f030e108f29bd Author: Misi Date: Wed Jan 24 15:39:50 2024 +0100 Auth: Add SSO settings usage stats (#81143) * Add usage stats * UsageStats test + svc rename * Fix test commit c44594d6b3cf9def2a9f5dbbaedfc494f7ad5f8f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 24 16:31:19 2024 +0200 Update dependency webpack to v5.90.0 (#81158) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 41de0b4311f44e46708ee4190fb39ff9a905883a Author: Alex Khomenko Date: Wed Jan 24 15:13:24 2024 +0100 Grafana/ui: Add deprecation notice to the Form component (#81068) * Grafana/ui: Add deprecation notice to the Form component * Fix notice * Deprecate types commit d528d93b027d3ac24938b339cfd3b30ec4e05ee5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 24 16:10:39 2024 +0200 Update dependency @testing-library/jest-dom to v6.3.0 (#81154) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 9e01156b28eea2bbeb0df675c78501490b6a3e14 Author: Jack Baldry Date: Wed Jan 24 14:02:32 2024 +0000 Update last three versions for backporting `make docs` workflow (#81141) commit b2f2864628bc5ba13c5718411592893d42766f38 Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Wed Jan 24 14:01:00 2024 +0000 Scenes: Add panel frame options and visualization options to panel editor (#80884) commit 7218e11e23f686cee1523d3c94bd08fdeb305165 Author: Ida Štambuk Date: Wed Jan 24 14:51:51 2024 +0100 Cloudwatch: Move getNextRefIdChar util from app/core/utils to @grafana/data (#80471) --------- Co-authored-by: Alex Khomenko commit 1e85d65ce0b0fb34b7456bbe63dcb002bfc1d3bd Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 24 13:31:02 2024 +0000 Update dependency @grafana/experimental to v1.7.7 commit 3656657afcd475f92cc985de2622814b3bfcf91e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 24 13:27:02 2024 +0000 Update dependency @types/node to v20.11.6 (#81133) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 3f1e97cb070e92d6e21f65c3a25c66be9a6d9f57 Author: Ashley Harrison Date: Wed Jan 24 13:18:01 2024 +0000 NestedFolderPicker: Add `clearable` prop (#81114) * add clearable prop to NestedFolderPicker * update types commit 7872a128a2dfde2a0bb17a08ba602e60e04ef256 Author: Alexander Zobnin Date: Wed Jan 24 16:15:32 2024 +0300 Folders: Add metric for listing subfolders duration (#81144) commit 57ba8dc75da2ee4ef9ea910a2499008bc480e93a Author: Oscar Kilhed Date: Wed Jan 24 14:14:48 2024 +0100 Scenes: Add transformation flow for panel edit (#80738) * Adding transformations works * Use source data as data input for transformation settings, add search box suffix * remove useCallback that are probably not needed, fix tests * remove unused import * add tests for adding and removing transformations * use view all constant * Add reordering functionality * Fix removing one transformation removes all consecutive transformations * use closeDrawer function * Add tests for changing transformations * Remove any --------- Co-authored-by: Dominik Prokop commit a06197188f5de3b616e61e4ca7c0316917add6c4 Author: Andres Martinez Gotor Date: Wed Jan 24 14:01:15 2024 +0100 Chore: Fix plugins magefile (#81146) commit 28bb6979f571de108a49913ea2cb872222e518f5 Author: Karl Persson Date: Wed Jan 24 13:56:44 2024 +0100 IDForwading: cache based on expires in (#81136) * IDFowarding: Cache based on expires in * IDFowarding: Change default expires in --------- Co-authored-by: Victor Cinaglia commit 1c0222091684de303d2bd2786b9a4ac7f877d77f Author: Andres Martinez Gotor Date: Wed Jan 24 13:18:18 2024 +0100 Chore: Fix plugins magefile for Azure (#81138) commit e84ee33c8bb27c60c117c344892d730ab4fdf493 Author: Ashley Harrison Date: Wed Jan 24 11:41:25 2024 +0000 NestedFolderPicker: Debounce search correctly (#80956) * debounce nested folder picker search * readd logic when no search string commit 5b4a984b78b31eefe177ed24443aac23f1fd7428 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Wed Jan 24 11:41:13 2024 +0000 Tempo: Add warning message when scope missing in TraceQL (#80472) * Add warning message when scope missing in TraceQL * Check for warnings in nodes * Update formattiing * Tidy up logic * Tests * Rename files and move tests for highlighting queries with errors commit 6b4eaa0d184a7fecbdf4cba86e12638ca6bc2217 Author: idafurjes <36131195+idafurjes@users.noreply.github.com> Date: Wed Jan 24 12:39:11 2024 +0100 Add MFolderIDsAPICount metric to count FolderIDs in api package (#80866) * Add MFolderIDsAPICount metric to cound FolderIDs in api package * Change counter to counter vector with method names as string values commit 55767313320c8b38605ef5cc260dcfc6ffbfaae0 Author: Damian Szczepanik Date: Wed Jan 24 11:36:50 2024 +0100 Docs: Update variables-label-annotation.md (#81134) Update variables-label-annotation.md Additional closing bracket was removed commit ad755421f8b8dc01e00748e61acaf5d6a3afb018 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 24 09:22:40 2024 +0000 Update react-router monorepo (#81118) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 63679813b018ed3d4760d65e81ae2cea9e3263e5 Author: Gabriel MABILLE Date: Wed Jan 24 09:23:40 2024 +0100 RBAC: prevent seeding oncall access (#80862) * RBAC: prevent seeding oncall access * Add comments and an early exit * Test SeedAssignmentOnCallAccessMigrator * imports * Comment rework * Check error * Nit. commit fd73b75ef75e24ee734998ad1b8d537386548f70 Author: Gábor Farkas Date: Wed Jan 24 08:47:07 2024 +0100 SQL datasources: Consistent interval handling (#78517) sql: apply the received interval-value commit 6484f4a2ac2b4126f42d8d5b647a4f54c149f841 Author: Kyle Cunningham Date: Tue Jan 23 22:19:33 2024 -0600 Transformations: Focus search input on drawer open (#80859) * Focus search input on drawer open * Prettier commit cd2abce91493e1f6d100bcb9630a504bdc25b53d Author: Nathan Marrs Date: Tue Jan 23 14:59:22 2024 -0700 Table: Fix case where undefined data crashes the visualization (#80498) commit a7b58a7cdb5fd0ae8878d6226d02c65bc8aec715 Author: Yuri Kotov Date: Wed Jan 24 04:00:43 2024 +0700 Prometheus plugin: Use new labels endpoint, for LabelEditor (#80774) Prometheus plugin: use new labels endpoint, if supported, for LabelEditor Before this change LabelEditor always use old '/series' endpoint to get labels. Now new '/labels' endpoint would be used in case datasource supports it. commit 0173755446b3e99e3baec3edb3e2c23c2d9956ac Author: Sven Grossmann Date: Tue Jan 23 21:47:29 2024 +0100 Loki: Remove `parsed_query` from tracking in favor of `obfuscated_query` (#81042) * Loki: Remove `parsed_query` from tracking in favor of `obfuscated_query` * newline commit 0aaebbb9a284962359cca98883c10abe37bd17aa Author: Dai Nguyen <88277570+ej25a@users.noreply.github.com> Date: Tue Jan 23 14:44:20 2024 -0600 Docs: Updating Service Account API to include a note for Grafana Cloud instances (#80685) * Update Bearer Token serviceaccount.md To update the Service Account API to include a note stating that a Bearer token for Grafana Cloud instances is needed. * Update docs/sources/developers/http_api/serviceaccount.md --------- Co-authored-by: Eve Meelan <81647476+Eve832@users.noreply.github.com> commit d8c96b4406d374abbdaa349fa81469efbf48c16e Author: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> Date: Tue Jan 23 15:36:45 2024 -0500 [DOC] Adds best practices for tracing doc (#80924) * Adds best practices for tracing doc * Update docs/sources/datasources/tempo/tracing-best-practices.md commit 510dba72fce64988e9fed184d77a6074ec6a0317 Author: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Tue Jan 23 14:20:38 2024 -0600 Logs Panel: Permalink (copy shortlink) (#80764) Compute permalink url for log lines within logs panels in dashboards. --------- Co-authored-by: Matias Chomicki commit 05d858635c7d877452deb5211877d3fedabc529c Author: George Robinson Date: Tue Jan 23 19:43:17 2024 +0000 Alerting: Add metric for inhibition rules (#81119) This commit adds a metric for the number of inhibition rules. It matches the metric added upstream in #3681. commit 2607528b52be8d5f283d07cb05c149736517c13e Author: Matthew Jacobson Date: Tue Jan 23 14:09:52 2024 -0500 Alerting: Update legacy migration docs to include Upgrade Preview (#80628) * Alerting: Update legacy migration docs to include Upgrade Preview commit 57effed70ae3f013ac4d4f2a388dd8d285aae627 Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Tue Jan 23 20:33:32 2024 +0200 Chore: Fix flaky test (#81116) * Chore: Fix flaky test commit 633c03d40d354ae24695d2cd96d66963545b1a3c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 23 19:56:04 2024 +0200 Update opentelemetry-js monorepo (#81117) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit aeda4ade9f2d418d55749e01c7e7a4afdcac3d66 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 23 19:35:14 2024 +0200 Update dependency webpack-bundle-analyzer to v4.10.1 (#81110) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit e07417dca34962e78f7f56c0da37e4eb6c1fff59 Author: Matias Chomicki Date: Tue Jan 23 17:52:38 2024 +0100 Infinite scroll: share loader with log context + small fixes (#81067) * Loading indicator: refactor * Infinite scroll: use ref to keep the last scroll * Infinite scroll: share loading indicator with log context * Infinite scrolling: override state * Infinite scroll: clean up imports and dependency array * Logs: disable order while loading * Infinite scroll: update ref before calling shouldLoadMore * Loading indicator: remove unused export * Logs order: move disabled prop to inline field * Formatting * Update betterer file commit aa07c4a6b3472d9e1443e2a5ae3e94af2e485e34 Author: Matias Chomicki Date: Tue Jan 23 17:48:50 2024 +0100 Logs permalink: adjust for infinite scrolling (#80808) * Logs permalink: adjust for infinite scrolling * Refactor exception * Formatting commit ed2647b742c0db074c8833bde01886cedbc06e12 Author: Andreas Christou Date: Tue Jan 23 16:42:59 2024 +0000 Chore: Fix typo in docs workflow (#81111) Fix typo commit 2b355ff280cb50f5804349b0c921cb8e9ea58ea6 Author: Ryan McKinley Date: Tue Jan 23 08:27:28 2024 -0800 K8s: Remove grafanaAPIServer feature toggle (#81030) commit aa25776f813926cb4f1947d4ae5a014f4e7728ff Author: Jean-Philippe Quéméner Date: Tue Jan 23 17:03:30 2024 +0100 Alerting: Add a feature flag to periodically save states (#80987) commit f7fd8e6cd13a78392ec3795d5a60574fe880e1f0 Author: Denis <7009699+someden@users.noreply.github.com> Date: Tue Jan 23 18:56:20 2024 +0300 DashboardSchema: Add options to VariableModel (#79236) * Add includeAll and regex fields to VariableModel #67639 * Add allValue option to VariableModel commit cbdbdf72e520a8af2ae757f65dd226f4cac913db Author: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Tue Jan 23 15:54:06 2024 +0000 Changelog: Updated changelog for 10.3.1 (#81105) * Changelog: Updated changelog for 10.3.1 * Update 10.3.1 CHANGELOG --------- Co-authored-by: grafanabot Co-authored-by: Andreas Christou commit f2e1e78b3757bbf155c90c04737b8e99f8793e72 Author: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Tue Jan 23 15:48:05 2024 +0000 Changelog: Updated changelog for 10.3.0 (#81100) * Changelog: Updated changelog for 10.3.0 * Add release note --------- Co-authored-by: grafanabot Co-authored-by: Andreas Christou commit e03ce77ef0a149e1313846853f2c4d8aa62f3db3 Author: Torkel Ödegaard Date: Tue Jan 23 16:47:01 2024 +0100 DashboardDataSource: Improve handling of source provider activation, and add tests (#81034) * DashboardDataSource: Improve handling of source provider activation, and add tests * Add setContainerWidth call commit 51c19afcb25281d0aec84283623fcdf466794bdb Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Tue Jan 23 16:33:08 2024 +0100 Tempo: Rewrite styles using objects (#81038) commit 85b9edcd28ba4c3aa2b6213f173d89f57d241ffd Author: George Robinson Date: Tue Jan 23 15:29:38 2024 +0000 Alerting: Fix incorrect initialization of logger (#81099) commit a60e60183e0d953750a6f8f0bb449c71c8c2fa32 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 23 17:23:09 2024 +0200 Update dependency string_decoder to v1.3.0 (#81094) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit ea1ab0d84f52cd637c7e8b67b41657a96c577f27 Author: Misi Date: Tue Jan 23 16:11:26 2024 +0100 Auth: Remove DevModeRequired from the SsoSettingsApi feature toggle (#81091) Remove DevModeRequired from ft commit c14ef43691cf9308b1ccbf44342089103bc7136b Author: Jara Suárez de Puga García Date: Tue Jan 23 16:04:04 2024 +0100 Tempo TraceQl Editor update request #8382 (#80112) * Tempo TraceQl Editor update request #8382 * Docs: Typo * Docs: Typo 2 codespell lint * Update docs/sources/shared/datasources/tempo-editor-traceql.md * Update docs/sources/shared/datasources/tempo-editor-traceql.md * Update docs/sources/shared/datasources/tempo-editor-traceql.md --------- Co-authored-by: Eve Meelan <81647476+Eve832@users.noreply.github.com> commit 52550966af01a0b513ccbc48d959156463a3e54e Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Tue Jan 23 09:54:19 2024 -0500 datatrails: allow multiple search terms to help select metric names (#81032) * datatrails: allow multiple search terms help select metric names commit d8630bf9e412b01453fa7c47063a1a39ef1e7ac6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 23 14:50:58 2024 +0000 Update dependency sass to v1.70.0 (#81088) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 8246d97587e9caa9e7513f2ed917ae33af2f0d3b Author: Misi Date: Tue Jan 23 15:48:06 2024 +0100 Auth: Introduce `configurable_providers` config option for SSO settings (#80911) * Add SSOSettingsConfigurableProviders config option * Add check to Delete and ListWithRedactedSecrets * Add check to GET, small improvements commit 4148362d63481aa1d92041050267c701a7883ce9 Author: Andres Martinez Gotor Date: Tue Jan 23 15:47:40 2024 +0100 Chore: Fix path resolution for the release os Azure Monitor (#81065) commit 9f5a8bf92607aedb93361ca908d3c83542f07ca5 Author: Jo Date: Tue Jan 23 15:26:38 2024 +0100 AuthInfo: Revert #81013. Fix cache invalidation (#81050) * Revert "Auth: Revert "Auth: Cache Auth Info" (#81013)" This reverts commit ce84f7c5405b9696bbe443409b5130db490856cf. * fix cache invalidation during user takeover * fix incomplete test commit 8ff4e488d9e86c44f8353d3fd523eb1b8d2b08e7 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 23 16:25:04 2024 +0200 Update dependency rudder-sdk-js to v2.48.0 (#81082) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 2407bc83ee8b33dae5951f922a588b921a85a0d1 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Tue Jan 23 15:20:29 2024 +0100 Loki: Fix throwing error when no labels received (#81077) * Loki: Fix throwing error when no labels received * Remove es code commit e4a0a6a4eebfb26f89e76ac2ce45495fc3a3b865 Author: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Tue Jan 23 07:13:17 2024 -0700 PanelContext: Remove deprecated onSplitOpen (#80087) Remove deprecated onSplitOpen commit 8435e16215133766539e525764f66034b03c1200 Author: Jara Suárez de Puga García Date: Tue Jan 23 15:09:54 2024 +0100 Docs: configure grafana database configuration MYSQL (#80939) * Docs database max_open_conn MYSQL * Docs: suggestion max_connections Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> --------- Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> commit 01d0d56198b0d3e7c411bc9832f3ed3140535786 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 23 16:04:37 2024 +0200 Update dependency uuid to v9.0.1 (#81076) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit d84d0c888922fd21954efc0adfa5271e4cd488cb Author: Gilles De Mey Date: Tue Jan 23 15:04:12 2024 +0100 Alerting: Detail v2 part 2 (#80577) commit 1a794e882292ac21d88faa0a1a562895f1efe2d1 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 23 15:48:07 2024 +0200 Update dependency react-select to v5.8.0 (#81064) * Update dependency react-select to v5.8.0 * update snapshot --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit 4083d23f017d5118f80d478d16d52fb65a48b462 Author: Timur Olzhabayev Date: Tue Jan 23 14:32:26 2024 +0100 Chore: Bumping go to 1.21.6 (#80709) * Bumping go to 1.25.6 * bumping sqlite to 1.14.19 * Bumping sqlite version commit 57609e1388fec3aa92c7afc78bd8ec753e2a9724 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Tue Jan 23 14:27:13 2024 +0100 Remove unused file (#81036) commit f9486ad2ee68ad6638ca6361f77beeb65d991395 Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Tue Jan 23 14:24:12 2024 +0100 Alerting docs: updates eval group and provisioning topics for support (#81066) commit f6ff24fce18ce0362994dd0e5274d2162a7a2233 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 23 13:23:30 2024 +0000 Update dependency react-use to v17.5.0 (#81070) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 59595c7318efe9d90d350bd33e7b57a56168414a Author: Andrej Ocenas Date: Tue Jan 23 14:14:57 2024 +0100 Table: Keep expanded rows persistent when data changes if it has unique ID (#80031) commit 3203f1cf39c889cc3c5422bf77d77fb7f2a0837d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 23 12:55:54 2024 +0000 Update dependency react-calendar to v4.8.0 (#81061) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit b43b2608b591e938cd8ba4dcccf19eb9e094a25b Author: Eric Leijonmarck Date: Tue Jan 23 12:27:26 2024 +0000 Chore: Enable and Fix flaky test in anonimpl service (#80896) * fix: flaky test * set fixed time instead * flaky commit 5887f421b310f4200033e863b1a9cf103d8d23d6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 23 14:20:22 2024 +0200 Update dependency rc-slider to v10.5.0 (#81058) * Update dependency rc-slider to v10.5.0 * onAfterChange -> onChangeComplete --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit c6de6571552c55024480d83b3c2382fb65f6664d Author: Josh Hunt Date: Tue Jan 23 11:50:38 2024 +0000 Chore: Update yarn to 4.0.2 (#81056) update yarn to 4.0.2 commit 86cb412b1df0dccb522c098a01055052957d7aa5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 23 11:41:24 2024 +0000 Update dependency prettier to v3.2.4 (#81047) * Update dependency prettier to v3.2.4 * update sdk + run prettier --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit 3d033839d71d481fb14f2df498c09bb59121bc7f Author: Sven Grossmann Date: Tue Jan 23 12:41:13 2024 +0100 Elasticsearch: Fix URL creation and allowlist for `/_mapping` requests (#80970) * Elasticsearch: Fix URL creation for mapping requests * remove leading slash by default * add comment for es route * hardcode `_mapping` * update doc commit f9b8f219e4cfb7736a66809e9d2d81a62a46ecb8 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 23 11:40:18 2024 +0000 Update dependency rc-cascader to v3.21.2 (#81052) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 6768c6c059feec840d2e521b33c9ce22f046de77 Author: Marcus Efraimsson Date: Tue Jan 23 12:36:22 2024 +0100 Chore: Remove public vars in setting package (#81018) Removes the public variable setting.SecretKey plus some other ones. Introduces some new functions for creating setting.Cfg. commit 147bf017456cfaade09cac80b10a377eff18e435 Author: Karl Persson Date: Tue Jan 23 12:12:32 2024 +0100 IDForwarding: Always forward id tokens to plugins (#81041) * Always forward id tokens to plugins commit 5b6a4e880b8d461e3f3913c71be9e9b04861efee Author: Laura Benz <48948963+L-M-K-B@users.noreply.github.com> Date: Tue Jan 23 12:02:48 2024 +0100 Nav: Fix main a11y issues (#80309) * refactor: add nav announcement for screenreader * refactor: add aria label * refactor: add more aria labels * refactor: aria labels * refactor: remove aria-labels * refactor: add aria-live * refactor: add translations * refactor: repair empty message * refactor: repair empty message * refactor: remove translation of empty message * refactor: clean up * refactor: change translation commit 96c4b7bf1ec3c99da57e8749238d05ee07c4f9d2 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 23 10:58:09 2024 +0000 Update dependency postcss-reporter to v7.1.0 (#81045) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 864e52946a978c2db35f403ae8810f232f9478d0 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 23 10:20:29 2024 +0000 Update dependency lru-cache to v10.1.0 (#81003) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit d4d257db674331f017ce6b2de0770a48f2b4e89e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 23 09:59:46 2024 +0000 Update dependency @testing-library/jest-dom to v6.2.1 commit b315c5e54db71fa65da801ce63bafb1cd6ef8e76 Author: Sven Grossmann Date: Tue Jan 23 11:16:29 2024 +0100 Loki: Also log `statusSource` in error case (#81040) commit 84e6dc63683f68af81f596249483d222c3af464e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 23 09:53:50 2024 +0000 Update dependency moment to v2.30.1 (#81004) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit ef3aa55e3cf0ecebd721c3bcd8cd1446327b5527 Author: Juan Cabanas Date: Tue Jan 23 06:02:49 2024 -0300 Reporting: Add usedInRepeat property in BaseVariableModel (#79855) property added in interface commit 6a387aa1c5648e9be889a6551ce25de9822e9b88 Author: Clemens Korner Date: Tue Jan 23 09:51:12 2024 +0100 Provisioning: sync datasources/sample.yaml with docs (#80894) Provisioning: datasources/sample.yaml with docs commit 51f5e1af39c7adb20adf32656e4381f4e4541e2b Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Tue Jan 23 09:23:59 2024 +0100 Alerting: Fix preview getting the correct queries from the form (#80458) * fix preview getting the correct queries from the form * Remove setting queries in onChangeQueries handler as it only contains data queries and not expressions * Keep setValue('queries') but also adding expressions commit b9d2f8c73b8dc3f857644a33ffba19739aff2e6c Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Tue Jan 23 08:35:34 2024 +0100 Extract duplicated code (#80991) commit 391f3ca6156798d5e260472354d19adefd183eeb Author: Leon Sorokin Date: Tue Jan 23 00:08:03 2024 -0600 VizTooltips: Don't use y scales for sync, since they rarely match (#81031) commit 2a53ae637ea0ccd7ee8eb69dd5640b22feb93c04 Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Mon Jan 22 18:16:26 2024 -0500 docs: What’s new & Upgrade guide 10.3 (#80399) * initial commit for v10.3 whats new * Added breaking changes guide and updated What's new doc * Added 10.2.3 in frontmatter of files * Added content from What's new in Cloud * Added note about 10.203 and breaking changes section * Made formatting edits * Added 10.2.3 test note * Replaced 10.2.3 notes with asterisks * Added tag note * Move reporting item out of D&V section * Added breaking changes * Fixed availability notes * Moved feature from Traces to Profiles and removed Traces section * Reordered sections * Replaced Cloud links with OSS links and relrefs with full URLs * Updated template * Copy edit * Clarified outstanding questions * Updated data source admin permissions note * Removed duplicate alerting items * add InfluxDB SQL support * Ran prettier * Added availability, video and contributor info * Ran prettier * Added youtube video links * Removed old video link --------- Co-authored-by: nmarrs Co-authored-by: ismail simsek commit af72caded9d3fe7f5e8b012d099003314d7b97a0 Author: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Mon Jan 22 15:03:22 2024 -0700 Docs: Update section about time range with copy/paste functionalities (#80945) * Update time range screenshots and add copy/paste docs * Update docs/sources/dashboards/use-dashboards/index.md Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> * Update docs/sources/dashboards/use-dashboards/index.md Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> --------- Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> commit 7375e64275ab8725c1f52fd0de382979469b9c90 Author: lwandz13 <126723338+lwandz13@users.noreply.github.com> Date: Mon Jan 22 13:34:20 2024 -0600 Docs: update per Support request (#80845) * update per support request, additional enhancements * ran prettier commit c7c594dba068e55615fbbc6c938376d3d8d4b864 Author: Ryan McKinley Date: Mon Jan 22 11:32:25 2024 -0800 K8s/DataSource: Introduce PluginConfigProvider (#80928) commit ce84f7c5405b9696bbe443409b5130db490856cf Author: Misi Date: Mon Jan 22 20:25:24 2024 +0100 Auth: Revert "Auth: Cache Auth Info" (#81013) Revert "Auth: Cache Auth Info" commit dc3a5851de1725e93a66b1f959eb46a31c1dfa9d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 22 17:58:06 2024 +0000 Update dependency @testing-library/jest-dom to v6.2.1 commit 57708288718cf94071241378ff3f3674b7d62640 Author: Torkel Ödegaard Date: Mon Jan 22 19:22:04 2024 +0100 Scenes: Updates to scene v2.0 (#80953) * Scenes: Updates to scene v2.0 * Update commit 6e8e4a8b77b0d0bc7a82acde35ca13c779b02dbf Author: colin-stuart Date: Mon Jan 22 12:20:16 2024 -0500 Auth: Add docs for the SSO Settings List endpoint (#80927) * add docs for SSO Settings List endpoint * Update docs/sources/developers/http_api/sso-settings.md Co-authored-by: Misi * Update docs/sources/developers/http_api/sso-settings.md Co-authored-by: Misi --------- Co-authored-by: Misi commit afc3380a38125d1e06c282475bbcac4d58995f41 Author: Joey Orlando Date: Mon Jan 22 11:18:39 2024 -0500 update RBAC for OnCall documentation (#80828) * update RBAC for OnCall documentation * Update docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md Co-authored-by: Jack Baldry * fix typo --------- Co-authored-by: Jack Baldry commit cf13cb9f70c2230f17450667ce59440304fb023c Author: Michael Mandrus <41969079+mmandrus@users.noreply.github.com> Date: Mon Jan 22 11:09:08 2024 -0500 Cloud Migrations: Create new service for cloud migrations (#80949) * introduce feature toggle * create base service structure * fix sample metric * register metrics * add to codeowners * separate api dtos from service models * remove leading newline commit 07aa173939ea17dd6955cbda75760efc02abfbdb Author: Tania <10127682+undef1nd@users.noreply.github.com> Date: Mon Jan 22 17:04:18 2024 +0100 Nested Folders: Add back syncing of folders between folder and dashboard tbls (#80972) Add back syncing of folders between folder and dashboard tbls This partially reverts commit 06d2ae3ada3bb2c6c12065434c67be5660c6d632. commit 4243079cb52de57ba816e4c2a4eddcd7c11bb133 Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Mon Jan 22 18:03:30 2024 +0200 Folders: Fix creating/updating a folder whose title has leading and trailing spaces (#80909) * Add tests * Folders: Fix creating folder whose title has leading and trailing spaces * Fix folder update * Remove redundant argument * Fix test commit e0402115eabe9e0e5ee42f6a29edc64f9f8f54ed Author: Dave Henderson Date: Mon Jan 22 10:50:05 2024 -0500 Notifications: Optional trace propagation through SMTP (#80481) * Notifications: Optional trace propagation through SMTP Signed-off-by: Dave Henderson * fix failing test Signed-off-by: Dave Henderson * Add documentation Signed-off-by: Dave Henderson --------- Signed-off-by: Dave Henderson commit 6b8b741b3b8a1905fb0cd076364c36250c19f2de Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 22 15:43:33 2024 +0000 Update dependency logfmt to v1.4.0 (#80998) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 625ac98ee02148ce564b05b29f17a2a6ec0ce7d8 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 22 15:42:32 2024 +0000 Update dependency tslib to v2.6.2 (#80997) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit fcc11c92901294ba021c547e5b5de9e5be6291cc Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Mon Jan 22 10:07:47 2024 -0500 fix: datatrails: setRecentTrail: remove equivalent entries to new recent trail (#80932) * fix: datatrails: setRecentTrail: remove equivalent entries * fix: use lodash to simplify code commit 6d15e16d2ea210bebebf90717f304ea49f807709 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Mon Jan 22 15:02:55 2024 +0000 Pyroscope: Fix stale value for query in query editor (#80753) * Fix stale value for query in query editor * Separate out onLabelSelectorChange into its own hook * Move queryRef inside hook commit 3409e0ea5a4263bc88d451f444d0f2d316cb6fcf Author: Brendan O'Handley Date: Mon Jan 22 08:44:46 2024 -0600 Prometheus: Invest in tests and docs for min interval or min step explanation (#80165) * add clearer comment for function def * update test to reflect change in range for 1w step * clarify docs * add more clarity * add explanation to query options min interval and link to min step * Update docs/sources/panels-visualizations/query-transform-data/_index.md Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> --------- Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> commit 639bf3036d04a6eb9cdc261b77cf8767ba3bf22d Author: Brendan O'Handley Date: Mon Jan 22 08:42:24 2024 -0600 Prometheus: Add e2e tests for decoupling (#80333) * update selectors for prom * add selector to switch component, needs id instead of testid * add testid and ids to Prom settings * add e2e tests for prom config * add config to editor test * export select function * start query editor spec * clean up describe * add selectors for general query editor * add selectors to components in options in best locations * wrap header switch in id because component doesn't accept testid nor id * add id to wrap legend components in one selector * update selector in shared folder component, note to change in shared library * update selector in shared folder component, note to change in shared library * add notes for selectors in shared folder * add tests and file for query editor * add selectors for metrics browser in code editor * add selector to component to open metrics browser * add selectors to components within the metrics browser * add tests for metrics browser and stub resource calls * add selectors to query builder components * add e2e tests for query builder * generic query builder test with hints * add selectors for more code editor parts * add test for code and update selector * fix tests with selector * remove shared folder changes and use data-testid where possible * remove unused import * share getResources * create variable query editor selectors * add selectors to the variable query editor * add e2e tests for the Prometheus variable query editor * fix test function * refactor add data source method * add annotation selectors * add selectors to annotation components * add annotation e2e tests * commit for yarn i18n:extract error in drone commit 2210ed50b46f809d9e4b62fb1e844059cee7d105 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Mon Jan 22 09:42:07 2024 -0500 fix: data-trails get data source name for display (#80922) commit 279aa4863bfefb802b53bb4d1c44574a8e7281d7 Author: Gábor Farkas Date: Mon Jan 22 15:36:45 2024 +0100 Postgres: Handle single quotes in table names in the query editor (#80951) postgres: handle single quotes in table names commit 2be8211555dd7373631725db422c33d9fd3c5596 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 22 16:30:21 2024 +0200 Update dependency ts-node to v10.9.2 (#80989) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit a0e7aef840d6fece880f501facfb62c742c4f236 Author: Laura Fernández Date: Mon Jan 22 15:28:11 2024 +0100 ReturnToPrevious: create DismissableButton and ReturnToPrevious components (#80878) commit d4e831b0517bfc2eee8780ff716b72d7e1a1cb95 Author: Santiago Date: Mon Jan 22 15:25:50 2024 +0100 Alerting: Use mimir:r274-1780c50 in CI (#80985) * Alerting: Use new mimir image in CI * add image to blocks, enable experimental routes commit 2a0f1900d5cfb8f4820d37f2399c5a605adfa643 Author: Will Browne Date: Mon Jan 22 15:21:27 2024 +0100 Datasources: Add wireset for datasource.DataSourceAPIBuilder (#80914) tidy up commit f3c64a73372455f4d6d92f55d2193baf5c9f7bbb Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Mon Jan 22 14:21:14 2024 +0000 Tempo: Remove profiling toggles (#80792) * Remove traceToProfiles toggle * Remove tracesEmbeddedFlameGraph toggle * Remove superfluous import * Update ConfigDescriptionLink * Update getting of profiles from standalone build PR commit 0df63e73758015fee0520f4c28620a0e6ea77dea Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 22 16:16:11 2024 +0200 Update dependency ts-jest to v29.1.2 (#80988) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit a8dec1916bbfc499fa18f9695feae74a6d705742 Author: Pier <53210578+pie-r@users.noreply.github.com> Date: Mon Jan 22 15:13:31 2024 +0100 Security: Fix vulnerability GHSA-9763-4f94-gfch (#80952) chore: upgrade cloudfare circl dependency Signed-off-by: Pier <53210578+pie-r@users.noreply.github.com> commit dad5c9af8fa7d3edeb8bad607d46f3fba977a034 Author: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Mon Jan 22 14:59:16 2024 +0100 LDAP: Fix routing error (#80984) * Reorder auth routes * Fix lockfile commit 20bb0a3ab173fc8382e87bcf9c492a684d9c63d7 Author: Misi Date: Mon Jan 22 14:54:48 2024 +0100 AuthN: Support reloading SSO config after the sso settings have changed (#80734) * Add AuthNSvc reload handling * Working, need to add test * Remove commented out code * Add Reload implementation to connectors * Align and add tests, refactor * Add more tests, linting * Add extra checks + tests to oauth client * Clean up based on reviews * Move config instantiation into newSocialBase * Use specific error commit 1f4a520b9db667978dcb9925098ebf73ffc3413f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 22 15:52:43 2024 +0200 Update dependency @grafana/experimental to v1.7.6 (#80981) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 3a7b745278813a4e99babd03a2d2c6f51d0af43c Author: tonypowa <45235678+tonypowa@users.noreply.github.com> Date: Mon Jan 22 14:22:33 2024 +0100 fixed py code indentation pt.2 (#80975) * fixed py code indentation * pretty * 2nd code snippet update * pretty * pretty commit ef50fe9ebbc3a23044cf235dbb2df0eadbaba2ad Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 22 13:06:48 2024 +0000 Update dependency @types/react to v18.2.48 commit e6f81766741f58d23ab35bb026a6a6be7647640b Author: Gábor Farkas Date: Mon Jan 22 14:14:40 2024 +0100 postgres: test: switch to numbers that are represented well in floating point (#80722) commit 1cb3cc4ad1dd1520f9586af9c6cce4cb4af66cd0 Author: Ashley Harrison Date: Mon Jan 22 13:02:47 2024 +0000 Navigation: handle cloud id when modifying connections url (#80965) cloud id is different than onprem... who knew? commit 113746ee0f87eb3f392f1ba1026323f56bac4611 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 22 13:02:14 2024 +0000 Update dependency i18next-browser-languagedetector to v7.2.0 (#80976) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 085054095f3220561005b82c4a62dd12f6244d52 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 22 13:01:45 2024 +0000 Update dependency html-webpack-plugin to v5.6.0 (#80973) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 6cbc3df11ed32768fa0c304fe2a959c521cea087 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Mon Jan 22 13:47:50 2024 +0100 Add grafana-o11y-ds-frontend workspace package (#80362) commit 3a10e480ba2ce4e919bb13d18293fd3946bc7cd8 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 22 14:18:21 2024 +0200 Update dependency eslint-plugin-jsx-a11y to v6.8.0 (#80968) * Update dependency eslint-plugin-jsx-a11y to v6.8.0 * this shouldn't be a label methinks --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit eb7e1216a147b0befa40a405e42959d76a02afcf Author: Jean-Philippe Quéméner Date: Mon Jan 22 13:07:11 2024 +0100 feat(alerting): add async state persister (#80763) commit a7e94084338b753cdbebf84998da0391ecc81a74 Author: tonypowa <45235678+tonypowa@users.noreply.github.com> Date: Mon Jan 22 12:55:28 2024 +0100 fixed py code indentation (#80966) * fixed py code indentation * pretty commit 0c9265f59b1bccdbb1bfeae373b5053e532ff0e3 Author: Matias Chomicki Date: Mon Jan 22 12:42:18 2024 +0100 Infinite scroll: update deduplication method and only run log queries when scrolling (#80821) * Logs deduplication: replace array lookup with map * runLoadMoreLogs: only execute log queries * Formatting * Rename variable commit 60ece3b21b236c8ac05b2c6fe346cb1bc7cc13bd Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 22 11:20:58 2024 +0000 Update dependency ts-jest to v29.1.2 commit 61ed0ef73f6e2f7dcb14777d9323c7b867b25131 Author: Sven Grossmann Date: Mon Jan 22 12:25:51 2024 +0100 Tests: Update geomap golden json (#80964) update geomap golden json commit a07d2120f8f7c33628f71fdc9c64c7061cd4e981 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Mon Jan 22 12:17:54 2024 +0100 Alerting: Update text, link and aria-label in contact points link for simplified routing. (#80950) Update link to contact points using TextLink component, and fix its alignment commit 401dee7d7583860e1e94a62107979f959c371566 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 22 10:06:08 2024 +0000 Update dependency core-js to v3.35.1 commit 0fc6cfb4336e597ab2b01c2e96590516a9f412b5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 22 09:56:24 2024 +0000 Update dependency @swc/core to v1.3.105 commit b29748ac59ca6139cf11a77588ae3d276e76df17 Author: Ashley Harrison Date: Mon Jan 22 10:42:59 2024 +0000 Chore: remove `autoFocus` from time range filter input (#80961) remove autoFocus from time range filter input commit 5c7d97cbe0bb178b2c5e3bc0a037a9856eca3f16 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 22 10:02:57 2024 +0000 Update react monorepo (#80736) * Update react monorepo * type fixes --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit 0930156d1561cc92045e9710f3298829dbb64272 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 22 11:49:20 2024 +0200 Update dependency @grafana/experimental to v1.7.6 (#80601) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 98e3a01aff3dba6fe1e6f544eb95e9f1b523a11a Author: Piotr Jamróz Date: Mon Jan 22 09:27:11 2024 +0100 Correlations: Enable correlations feature toggle by default (#80881) * Enable correlations by default * Update docs commit 1f840600b68a0e01d7fa973b79183e56193d2551 Author: fowindee Date: Mon Jan 22 16:22:49 2024 +0800 Grafana/ui: Enable removing values in multiselect opened state (#78662) GrafabaUI: enabling remove values in opened state commit d7af7d01c8daf1c5b393f767a7590b5ed9c61b38 Author: fowindee Date: Sat Jan 20 09:40:43 2024 +0800 Stat: Support no value in spark line (#78986) commit 378c8636163d76eea81f3189a2eb515ca9f88a39 Author: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Fri Jan 19 16:55:17 2024 -0800 Geomap: Support geojson styling properties (#80272) * Geomap: Support geojson styling properties * Add enum for geojson styles * Move styleStrings outside of style function * Add styling note to geoJSON docs * Clean up geomap docs * Add support for points * Add simple example * Add support for all geojson types and update example * Update colors * Update docs * Remove old TODO * Update geojson name --------- Co-authored-by: nmarrs commit 9b9d91e7ae4297fca057c73d8cced431f4a3085b Author: andrea denisse Date: Fri Jan 19 17:10:40 2024 -0600 Packaging: Use the GRAFANA_HOME variable in postinst script on Debian (#80853) This commit addresses the issue where the postinst script was not using the GRAFANA_HOME variable from the /etc/default/grafana-server configuration file on Debian systems. Instead, it was relying on a hardcoded home directory. Fixes #80852 commit 777b9bdc4900484f2797996bd56303718d0a6332 Author: ismail simsek Date: Fri Jan 19 21:55:27 2024 +0100 Prometheus: minStep value is affecting $__interval value (#80904) * minStep value is affecting $__interval value * rename commit 9fc789d901bd9a0937082d629893a2651f738c08 Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Fri Jan 19 15:49:03 2024 -0500 Docs: restructure Configure data links page (#80100) * Moved content under Data links heading to intro of page and deleted heading * Made headings for data link variable types H3s nesting under Data link variables * Removed unecessary update and delete data links sections * Made old intro sentence part of new intro * Made Add a data link section an H2 * Removed unecessary typeahead suggestions section * Moved variables into tables and capitalized first word of descriptions * Docs: Edit Configure data links page (#80101) * Added content update notes * Rewrote Add a data link section per style guidelines * Copy edits * Copy edits * Copy edits * Copy edited intro text, removed instances of e.g., and replaced OSS links with Cloud links * Standardized the format of variables in tables * Added images (locally) and clarified context menu behaviour * Removed working notes * Fixed typo * Removed images from local and updated image pathways commit e241188f0082a0322fa00ce90eb2cbbfd2776b9e Author: colin-stuart Date: Fri Jan 19 14:39:09 2024 -0500 Auth: Implement the SSO Settings List endpoint (#80769) * add list endpoint & initial tests * add tests and ETag * format service_test.go * add list swagger param, generate openAPI, remove ETag, use RedactedPassword * correct swagger param name * Align tests to latest changes * use setting.RedactedValue() * add string assertion * lint & require no error on res.Body.Close() * add custom response type --------- Co-authored-by: Mihaly Gyongyosi commit 40312c527be9b9963e0ecda036826ca551ea6352 Author: Julien Duchesne Date: Fri Jan 19 14:37:11 2024 -0500 ngalert openapi: Fix ObjectMatchers definition (#79477) These don't get marshalled and unmarshalled in the same way as they are represented in Go This PR changes the OpenAPI spec to reflect what the API accepts and sends back commit 91e747d8ab2a40cf248bdd659cb3402997c8e44d Author: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Fri Jan 19 09:43:05 2024 -0800 XYChart: Fix data filter change bug (#80849) * XYChart: Fix data filter change bug * If no xAxis set, populate from dims commit 65104a7efab8be95f37f390c04e39253a2e5913b Author: Dimitris Sotirakis Date: Fri Jan 19 19:29:49 2024 +0200 `ImagePullSecrets`: Add `GAR` secret to `image_pull_secret` in `.drone.yml` (#80912) * Add GAR secret to image_pull_secret * Fix starlark fmt commit 361c49233db420d38a03715dd13107041c70e4cc Author: Dominik Prokop Date: Fri Jan 19 08:46:10 2024 -0800 DashboardScenePageStateManager: Do not initialise dashboard meta after fetch (#80875) commit 85d633f1b98b7b4e03ca155e7d48bfc7f4ef1767 Author: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Fri Jan 19 09:25:56 2024 -0700 Dashboards-Scenes: ability to edit and save JSON model (#80757) * View JSON Model tab * Use correct translation field * saving functionality * Remove TODO * Update button tittle commit d5bec225ed78e0755a3dacb7658eec30a92dfa3d Author: Will Browne Date: Fri Jan 19 16:22:17 2024 +0100 Plugins: Fix Plugin Context method docs (#80906) * fix method docs * add docs for new method commit 084a3c6b7fb99e85aa44d2269452aaaf014dfe9f Author: Nicki de Wet Date: Fri Jan 19 17:15:15 2024 +0200 Update notifications.md (#80868) Added clarity about the order policies are evaluated commit 3f30cbf91cc101249a92560a9ec889fa7075728c Author: Will Browne Date: Fri Jan 19 15:56:52 2024 +0100 DataSources: Add datasource fetching + querying interface (#80749) * first pass * separate oss + enterprise * tidy things up * add ctx * fix tests * use standalone svcs * mv plugin context provide * fix wire * fix import commit bb0fa4f99abd1ab616ac2f570b67e7d25ae54233 Author: Andre Pereira Date: Fri Jan 19 14:18:49 2024 +0000 Data trails: Add datasource picker to Logs tab and disable it (#80892) * wip * Logs are now working * Remove logs tab, for now? commit 35ade8974f5c09a37a05c6513748c9ace93eba15 Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Fri Jan 19 08:35:13 2024 -0500 Docs: move best practices page (#80844) Moved best practices page commit dff0e33c30c10a166e3f45a4dec4843b7fee6973 Author: Jack Westbrook Date: Fri Jan 19 14:18:14 2024 +0100 Webpack: Update bundled plugins SWC baseUrl (#80882) build(webpack): update config for swc.baseUrl commit e0d04209906d4f87cde1e41897ec53b49eeec86f Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Fri Jan 19 07:16:14 2024 -0600 VizTooltips: Copy to clipboard functionality (#80761) commit 29112f88228022c35cd546115733918fb57f4748 Author: Eric Leijonmarck Date: Fri Jan 19 12:49:58 2024 +0000 Make: Add `make gen-go` to initcmds in .bra (#80880) * add: make gen-go to initcmds * change order of init cmds commit 08082104e18a61c6d54d465f788d05a0ae6e95c0 Author: Alexander Zobnin Date: Fri Jan 19 15:47:58 2024 +0300 Access control: Add permissions cache hit/miss metrics (#80883) * Access control: Add permissions cache hit/miss metrics * Add metrics to OSS * Fix imports commit a8a9e6d0ee8bf536e605423003b086a9fda8b390 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 19 12:47:33 2024 +0000 Update dependency @grafana/scenes to v1.30.0 (#80874) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit ea37cee435443d2c3cd2d93a8106293e8646a00f Author: Javier Ruiz Date: Fri Jan 19 13:44:56 2024 +0100 Use specified time range, default to class value (#80557) commit 8c77dd8bb8e0ae23ad6e94a2a35487429eeee473 Author: Alex Khomenko Date: Fri Jan 19 13:21:22 2024 +0100 Switch: Add line height (#80879) commit 6752a512f3908798f948d48b1e607f704f346528 Author: Misi Date: Fri Jan 19 11:53:37 2024 +0100 Auth: Change UI route, add frontend endpoints to api.go (#80671) Delete advanced from UI route, fix 404 commit 4d6069583e8c4df0961bb0d7dadd031cbcc18036 Author: Kyle Cunningham Date: Fri Jan 19 04:47:05 2024 -0600 Transformations: Move transformation help to drawer component (#79247) * Move help to drawer component * Update component name * Flip hierarchy of transformation name and help description commit 03ebf0aa4c7f436467ca0775e9eb973370721be8 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 19 10:18:39 2024 +0000 Update dependency css-loader to v6.9.1 commit b60c9e0506c6ed430cfab79bb89c1339197a2900 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 19 10:15:22 2024 +0000 Update dependency eslint-plugin-import to v2.29.1 (#80832) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 0e4c6742c802a03a86ee0a09ea2f3491105d55a2 Author: Laura Benz <48948963+L-M-K-B@users.noreply.github.com> Date: Fri Jan 19 10:50:56 2024 +0100 GettingStarted: Improve styling and add tracking (#80410) * refactor: remove icons and related styling * refactor: remove cut off on the left side * fix: aria-labels * feat: add tracking * refactor: adjust button position * refactor: move previous button back * refactor: use emotion object syntax * feat: add tracking * refactor: remove console.log() commit ed196e67a806a3fcc297f684ee157fffbba7863c Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri Jan 19 09:33:48 2024 +0000 Update `make docs` procedure (#80855) Co-authored-by: grafanabot commit 759c088ac50fddaf7c5add182227d646ff3262bb Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Fri Jan 19 10:28:08 2024 +0100 Elasticsearch: Fix showing of logs when `__source` is log message field (#80804) Elasticsearch: Fix showing of logs whe __source is log message field commit 5f2ef36e63bdfbcc6c488cf14f2a445e85c29987 Author: Ashley Harrison Date: Fri Jan 19 09:18:48 2024 +0000 Chore: make `baseDir` absolute in grafana-plugin-config (#80826) make baseDir absolute commit 171ba185d32a9e740ad7cb8dc02fa42c42977b06 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri Jan 19 10:17:21 2024 +0100 Alerting: Allow selecting only ... in groupBy (#80857) Allow selecting only ... in groupBy commit b93567a839cf578de444113893070214d49a6c56 Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Fri Jan 19 10:58:20 2024 +0200 Scenes: Restore dashboard from version settings (#80600) * Add versions tab in dashboard settings * Fetch and render dashboard versions * Be able to compare two versions * PR discussion changes * remove unnecessary async in test * PR discussion mods * linter fix * styles and tests * Fix show more versions bug * migrate files + style fix * fix test * refactor styles - css object keys to camelCase * refactor file migrations * more files migrations * wip restore * remove unused type, cleanup * wip * restore functionality * revert defaults * wip * restore functional * refactor + tests * CR modifications * fix tests - CR mods commit 8784052ccfb6d72a40307556bf1f40fc621f68b6 Author: Nathan Marrs Date: Thu Jan 18 20:05:15 2024 -0700 Canvas: Pan zoom minor refactor (#80337) * enable context menu for canvas while in panel edit mode * Disable context menu when editing is not enabled in canvas * follow up minor refactor commit 18b9c8fd5fda882013e5224e48ce029d8259aee7 Author: Alexander Weaver Date: Thu Jan 18 15:43:41 2024 -0600 Alerting: Nilcheck JitterStrategyFrom so it can be used in contexts without feature toggles (#80841) Nilcheck so tests can have a nil feature toggles commit f285eb6717de7d40a07dd091387238866ad08096 Author: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu Jan 18 14:06:27 2024 -0700 Time Range: Copy-paste Time Range (#80107) * Working copy-paste functionality with validation * WIP; uses 't c' shortcut to copy time range * shortcuts working for explore and dashboards * cleanup * Don't update url when pasting in explore * Error handling, sync pane functionality, add to help modal * cleanup * add tests * fix i18n * Diferrentiate between explore and dashboard paste events; make on error prop generic * Fix * extract getting the copied time range logic into a function * Remove comments * Make error handling generic; markup for translations * Additional translation markup * markup for aria-label * Fix test * Replace fireEvent with userEvent * fix translations to match the standard pattern * Refactor keybindingSrv and TimeSrv to remove PasteTimeContext * Fix test * Remove unneccessary aria labels; update icons; buttons inline commit e8e8017d51323dc1c138c8652ae8eb0fb785cbd3 Author: Sarah Zinger Date: Thu Jan 18 15:04:37 2024 -0500 Cloudwatch: remove direct import on getDatasourceSrv (#80660) Cloudwatch: remove direct import on getDatasourceSrv commit 00a260effab802edc8f72df50bfb6447aac343f0 Author: Alexander Weaver Date: Thu Jan 18 12:48:11 2024 -0600 Alerting: Add setting to distribute rule group evaluations over time (#80766) * Simple, per-base-interval jitter * Add log just for test purposes * Add strategy approach, allow choosing between group or rule * Add flag to jitter rules * Add second toggle for jittering within a group * Wire up toggles to strategy * Slightly improve comment ordering * Add tests for offset generation * Rename JitterStrategyFrom * Improve debug log message * Use grafana SDK labels rather than prometheus labels commit 94c3be3b49751ede545a93065ec86afaf5f94c46 Author: Matthew Jacobson Date: Thu Jan 18 13:30:50 2024 -0500 Alerting: Fix incorrect render during long legacy upgrade cancel (#80339) When using the legacy migration dry-run, if a cancel takes a long time (long enough for the page to poll) the page will incorrectly render the previous data. This change stops the polling while the upgrading or cancelling. commit 02e2e1c64e5383583011ea0767ea196e478fa9c1 Author: Kyle Brandt Date: Thu Jan 18 13:23:31 2024 -0500 Prometheus: Set Status(Code) on backend response (#80806) Sets that status code on backend data responses in prometheus to match the status code returned by prometheus. If the failure is below HTTP/Application Layer, Bad Gateway is returned (502). --------- Co-authored-by: ismail simsek commit fb4125dfbb33cec506635abe31e837b607e782bc Author: Juan Cabanas Date: Thu Jan 18 15:12:29 2024 -0300 PublicDashboards: Add middleware function (#80582) commit 0ba7866e2ca682e4ae2d900476274870c94315d4 Author: Isabella Siu Date: Thu Jan 18 13:07:57 2024 -0500 Update feature toggle registry description for sseGroupByDatasource (#80830) commit 96fe605d95927c8e12de2c19664c49a97be9aa19 Author: Ryan McKinley Date: Thu Jan 18 10:03:45 2024 -0800 FeatureFlags: fix setting flags to false in startup (#80836) commit 9e08c88a1c5eb4f725a53c63737424740e3a8b19 Author: Sarah Zinger Date: Thu Jan 18 13:02:55 2024 -0500 Cloudwatch: remove dependency on app/core/config (#80668) commit 5b122a25b3f7eea9fc8be35fee6a5ae78a5502c2 Author: Andrej Ocenas Date: Thu Jan 18 18:51:25 2024 +0100 DataFrame: Add optional unique row id definition as frame.meta.uniqueRowIdFields (#80428) * Define an unique id for a row in dataframe * Update comment * Rename attribute * Add comment about plugin sdk * Fix comment commit 5800e40fba2accf96d81328f000e35bbb7c7acf1 Author: Laura Fernández Date: Thu Jan 18 18:12:14 2024 +0100 ReturnToPrevious: create feature toggle (#80831) commit 2cdf73f5840a8b65e28157377d62364a139cb346 Author: Torkel Ödegaard Date: Thu Jan 18 18:02:56 2024 +0100 Visualizations: Hue gradient mode now applies to the line color (#80805) commit f97cea55222f9f630cbba23fea18c5a4b74d67c5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 18 18:52:04 2024 +0200 Update dependency eslint to v8.56.0 (#80825) * Update dependency eslint to v8.56.0 * update sdk --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit e74313e1713fa613ac11bcacde2541fa770d0581 Author: William Wernert Date: Thu Jan 18 11:39:33 2024 -0500 Alerting/Annotations: Return nothing from Loki historian store if query type is `annotation` (#80742) * Return empty slice if query type is `annotation` * Add test + fix related test commit ded941eb84764f79a61bbedcc26945d6d1648ab0 Author: Sven Grossmann Date: Thu Jan 18 17:39:09 2024 +0100 Loki: Fix missing timerange in query builder values request (#80829) Loki: Fix missing timerange in query builder values commit b8cf8ec8d75fe926eba0af0be4ccc1e350e97022 Author: Mihai Doarna Date: Thu Jan 18 18:27:44 2024 +0200 Auth: fix swagger response for get SSO settings endpoint (#80817) fix swagger response for get SSO settings endpoint commit d72c035ea63604ec1edfaed917d1ec13877aea0c Author: Ashley Harrison Date: Thu Jan 18 16:16:41 2024 +0000 CommandPalette: Pass matched tag as a search query to 'Add new connection' page (#80556) * add some special logic to pass the matched tag to the add new connection page * extend action type to accept callback for url commit 10b1b5da52e2d3ea5df63ba16e3088a3d50bbb3f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 18 17:45:05 2024 +0200 Update dependency css-loader to v6.9.0 (#80819) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 596725ac56494615f975bc2cdb996eebc0ba408f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 18 17:31:17 2024 +0200 Update dependency core-js to v3.35.0 (#80818) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 0a651a90e9a9a3e1e94ecd8d2cc49d6253fbf5c6 Author: Ashley Harrison Date: Thu Jan 18 15:29:40 2024 +0000 Chore: use `GITHUB_TOKEN` in metrics-collector action (#80795) use GITHUB_TOKEN commit 484ced521fdbeeff6db44a90365aa3321897e782 Author: Alexander Zobnin Date: Thu Jan 18 17:56:01 2024 +0300 Auth: Fix identifying rendering request (#80807) * Auth: Fix identifying rendering request * Add comments commit dafd7c7920491b3eb426f22030b4f9936393dc94 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 18 14:35:08 2024 +0000 Update dependency classnames to v2.5.1 (#80814) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 2c5289028d322b890c3be2ecb4e358fb0745472c Author: Leon Sorokin Date: Thu Jan 18 08:34:28 2024 -0600 AnnotationsPlugin2: Skip zero-length, field-less, and time-less frames (#80810) commit 3903d3eb9494babb30b68891589749bea034bcfe Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 18 14:33:36 2024 +0000 Update dependency @types/node to v20.11.5 (#80813) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit c767481dee5ae4ba05e15a2dedcff4c9f93e4cc7 Author: Jo Date: Thu Jan 18 15:22:19 2024 +0100 Auth: Cache Auth Info (#80620) * leverage cache for auth info * fix tests and integration * fix panic * fix panic commit e77dbb63e3599a74613009f2342cc5d029c3af7d Author: Jo Date: Thu Jan 18 15:20:28 2024 +0100 AccessControl: Add group to role picker and standardize display (#79570) * add group to role picker and standardize display * change stuttery roles commit 8a4bd85efdd4101c481244b62d70e03e315d820c Author: Gilles De Mey Date: Thu Jan 18 15:15:21 2024 +0100 Alerting: Fix Graphite subqueries (#80744) commit bb2e0dad2217cbcc1c4a581886cbef4004fb3993 Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Thu Jan 18 16:12:49 2024 +0200 Chore: Rename folder service query variables for consistency (#80735) Chore: Rename service query variables for consistency commit 9f2775e77100858d263f47960d49a48f46f569c8 Author: aalapk <32711124+aalapk@users.noreply.github.com> Date: Thu Jan 18 09:10:01 2024 -0500 Update _index.md (#79606) Just suggesting a typo fix - change "an telemetry" to "a telemetry" commit ec18c51dfd024be194f2cde05a15a9bb9cfd9481 Author: Andre Pereira Date: Thu Jan 18 13:51:56 2024 +0000 Data trails: Avoid setting state in MetricScene component (#80746) * Move setting default action view to activate handler * Update breakdown when adding label to filters commit 4632823af9725e503f3273b4ae009fe9f5bbd87c Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Thu Jan 18 14:42:45 2024 +0100 Fix output of breaking change check (#80800) commit 6eb51f6367e86ed10012a6ebdb14c115b4715faa Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 18 13:40:41 2024 +0000 Update dependency @testing-library/react to v14.1.2 (#80802) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 9bd214516e80c4a125a9638f56a6252a6a2f0fcd Author: Ezequiel Victorero Date: Thu Jan 18 10:27:09 2024 -0300 Chore: Folder id deprecation in public dashboards (#80579) commit 83c4caebda66f6139916b7cfa13c2915da1c2f06 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 18 13:05:24 2024 +0000 Update dependency @testing-library/jest-dom to v6.2.0 (#80799) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit d888358a95ffdb1f79b91da41b42c76d1a77f809 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 18 13:04:19 2024 +0000 Update dependency @rollup/plugin-json to v6.1.0 (#80798) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 63ac32a4ae8485cb6308bc08f0b64c383cbead00 Author: Domas Date: Thu Jan 18 14:28:05 2024 +0200 Field color: Filter out editor options that have excludeFromPicker=true (#79907) * filter color palette options to to remove excluded items * add test * make betterer happy * remove unused commit 120e9044c7d8b4c3ba2acb2502de6340a480dec3 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu Jan 18 12:53:33 2024 +0100 Loki: Remove getQueryOptions as not needed in tests (#80747) * Loki: Remove getQueryOptions as not needed in tests * Update to not assert commit a1a9813d058c0aa934469d04dc725f82b1c18ff6 Author: Ashley Harrison Date: Thu Jan 18 11:48:59 2024 +0000 Chore: Adjust stale config to start from oldest first and increase operations limit (#80791) adjust to do oldest first and increase operations limit commit 950d57c0f523fe7fffdcbc6f74c7739fabcb80bf Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Thu Jan 18 11:31:51 2024 +0000 Scenes: Set min-height of left panel editor panes to 0 (#80641) commit a2e9a40caf09a3e2cc18e4bf8f8dcd48b8e9037d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 18 11:30:18 2024 +0000 Update dependency @react-types/shared to v3.22.0 (#80794) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 0c5f3756ab48c634eefd23eea704a13905bc90c8 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 18 11:29:04 2024 +0000 Update dependency @lezer/highlight to v1.2.0 (#80793) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 91b862adf9f4a68df8ae82fa255f6b9c07a59feb Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 18 10:53:30 2024 +0000 Update dependency react-hook-form to v7.49.3 (#80789) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit ec15b2933e770cd9975b5e3fd3884c9b03bec725 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 18 10:13:27 2024 +0000 Update dependency autoprefixer to v10.4.17 commit fe70a3cf60bc1f7c06558c178d593a0f462981e7 Author: Marco Schaefer <47627413+codecapitano@users.noreply.github.com> Date: Thu Jan 18 11:38:30 2024 +0100 Upgrade Faro to v1.3.6 (#80552) commit 8e43af2fc9f98912d1efaf8de439593e7942e4ad Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 18 10:11:20 2024 +0000 Update dependency @types/react-window-infinite-loader to v1.0.9 (#80786) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 507503905b510ddad026578d67b03fc41815d38c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 18 10:10:12 2024 +0000 Update dependency @types/node-forge to v1.3.11 (#80784) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 38854975537901704be36c6f1f2684b1a43b389a Author: Will Browne Date: Thu Jan 18 11:06:33 2024 +0100 Plugins: Update renderer plugin source (#80643) * rework renderer plugin source * add tests commit 4b113f87f9e631142d1f59659fd4a04a8995037a Author: Alexa V <239999+axelavargas@users.noreply.github.com> Date: Thu Jan 18 11:04:29 2024 +0100 Dashboard: Migration - EditVariable Settings: Implement Constant Variable (#80743) Co-authored-by: Ivan Ortega commit 80395e43d8ab81cc450dc49c4192f16e0b180267 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 18 09:51:47 2024 +0000 Update dependency @grafana/scenes to v1.29.0 (#80745) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit b620672c678a7d2b7a2986c530ba6630b4e5db89 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 18 09:45:20 2024 +0000 Update dependency @grafana/faro-web-sdk to v1.3.6 (#80762) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit ef0dfff75624c0aac6ab6f492ab4c7d05af86848 Author: Eric Leijonmarck Date: Thu Jan 18 09:09:36 2024 +0000 Users: OSS not using Orgs for users table showing (#80719) * fix: for removing column * linting commit 59d997e5a514b3490516fb3b0a731c6841b2a3f4 Author: Gabriel MABILLE Date: Thu Jan 18 09:47:11 2024 +0100 RBAC: Update plugin.json roles, includes and routes (#80592) * RBAC: Update plugin.json roles, includes and routes * action -> reqAction commit 3d353a1b5f132f91db286dd7246133eb70a5ce26 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Thu Jan 18 09:00:48 2024 +0100 Alerting: Fix using required groupBy options validation in simplified routing settings (#80780) Fix using required groupBy options validation in simplified routing settings commit 41e523bde7db5706f339d418c68d019039a8062e Author: Ryan McKinley Date: Wed Jan 17 21:32:44 2024 -0800 K8s/FeatureFlags: Add an apiserver to manage feature flags (dev only) (#80501) * add deployment registry API cloud only * update versions * add feature flag endpoints * use helpers * merge main * update AllowSelfServie and re-run code gen * fix package name * add allowselfserve flag to payload * remove config * update list api to return the full registry including states * change enabled check * fix compile error * add feature toggle and split path in frontend * changes * with status * add more status/state * add back config thing * add back config thing * merge main * merge main * now on the /current api endpoint * now on the /current api endpoint * drop frontend changes * change group name to featuretoggle (singular) * use the same settings * now with patch * more common refs * more common refs * WIP actually do the webhook * fix comment * fewer imports * registe standalone * one less file * fix singular name --------- Co-authored-by: Michael Mandrus commit cbc84a802d63e043535d5d17d01b340ce69424fc Author: Ihor Yeromin Date: Thu Jan 18 00:49:09 2024 +0200 VizTooltip: Add sizing options (#80306) Co-authored-by: Leon Sorokin Co-authored-by: Adela Almasan commit db83eb30a257f8f6375c4249793826b4a1a11963 Author: Michael Mandrus <41969079+mmandrus@users.noreply.github.com> Date: Wed Jan 17 16:53:25 2024 -0500 Caching: Remove useCachingService feature toggle (#80695) remove useCachingService feature toggle commit f434467ef827b46f8e9e8766779a1b47313ed09a Author: Ryan McKinley Date: Wed Jan 17 13:05:25 2024 -0800 Table: Support showing data links inline. (#80691) commit 87c3d3b029e6b65ddf3f768c15b2684272bda801 Author: Charandas Date: Wed Jan 17 12:21:24 2024 -0800 K8s: add the CRD server to the grafana-aggregator component (pkg/cmd) (#80759) commit 88ee7a1c6262b2067c6d76bdfbabc6775ac0aa1d Author: Matias Chomicki Date: Wed Jan 17 21:12:09 2024 +0100 Infinite scroll: exclude visible range from new requests (#80638) * Infinite scroll: exclude visible range from new requests * Infinite scroll: revert skipping 1 millisecond on new requests * Formatting * Logs models: filter exact duplicates in the response * mergeDataSeries: do not mutate currentData * runMoreLogsQueries: return queryResponse while loading * Infinite scrolling: use special refId for infinite scrolling queries * Remove log * Update public/app/features/logs/logsModel.ts Co-authored-by: Sven Grossmann * combinePanelData: refactor and add unit test * logsModel: export infinite scroll refid * Formatting * logsModel: add deduplication unit test * Formatting * findMatchingRow: add unit test * Fix test title * Fix imports order --------- Co-authored-by: Sven Grossmann commit 2aa533c6c51500751358132d58140be011c436a5 Author: Leon Sorokin Date: Wed Jan 17 14:08:30 2024 -0600 AnnotationsPlugin2: Skip exemplar frames (#80760) commit a4b2b861945a5bbb6519e12a1d8c9c24b1e65176 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 12:00:41 2024 -0800 Update Moveable (#73726) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison Co-authored-by: Ryan McKinley commit 9d8f4228bb63355f065637007e1d7e03227362f6 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Wed Jan 17 17:33:59 2024 +0000 UX: Remove spacing in duration row in the Tempo query editor (search tab) (#80373) Remove spacing in duration group commit 3b401d0d4db77341f8ae65da2170d3e6ce2a7a2e Author: Ivan Ortega Alba Date: Wed Jan 17 18:14:01 2024 +0100 CustomVariable: Be able to edit them using scenes (#80193) --------- Co-authored-by: Alexandra Vargas commit c9211fbd69dc5e95005572e2de02973248da4c99 Author: Julien Duchesne Date: Wed Jan 17 11:53:16 2024 -0500 ngalert openapi: Use same `basePath` as rest of Grafana (#79025) * ngalert openapi: Use same `basePath` as rest of Grafana Currently, there are two issues that prevent easily merging `ngalert` and grafana openapi specs: - The basePath is different. `grafana` has `/api` and `ngalert` has `/api/v1`. I changed `ngalert` to use `/api` - The `ngalert` endpoints have their basePath in the each operation path. The basePath should actually be omitted --------- Co-authored-by: Yuriy Tseretyan commit dce9d1e87c4a93cc14dce1b3dc3bc97d12cf0849 Author: Gabriel MABILLE Date: Wed Jan 17 17:07:47 2024 +0100 RBAC: Search endpoint support wildcards (#80383) * RBAC: Search endpoint support wildcards * Allow wildcard filter with RAM permissions as well commit c27bee567f832921a94e415e8ab40b5b61d87325 Author: Torkel Ödegaard Date: Wed Jan 17 17:05:24 2024 +0100 DataTrails: Fixes heatmap (#80706) * DataTrails: Heatmap y axis fix * Fix format commit c7859c2fa90d30cd902309f433d9780f946e7c6b Author: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Wed Jan 17 09:52:52 2024 -0600 Logs Panel: Add total count to logs volume panel in explore (#80730) add vizLegend override so logs volume can display sum commit f347de967cb5d6c4721dfc5072cd761d15f9b890 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 17:35:13 2024 +0200 Update React Aria (#80737) * Update React Aria * remove unused react-aria packages --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit 6b954165c5728dba8becd04406de4d8ba0faa9eb Author: Gabriel MABILLE Date: Wed Jan 17 16:32:23 2024 +0100 RBAC: Cover plugin routes (#80578) * RBAC: Cover plugin routes * Action instead of ReqAction * Fix test initializations * Fix NewPluginProxy call * Duplicate test to add RBAC checks * Cover legacy access control as well * Fix typo * action -> reqAction * Add example Co-authored-by: Andres Martinez Gotor --------- Co-authored-by: Andres Martinez Gotor commit 81a49e801623a7b5825dfa996be5a6eedc65e3f5 Author: Eric Leijonmarck Date: Wed Jan 17 15:19:29 2024 +0000 Anon: Fix comment out flaky test in anonimpl (#80728) * fix: flaky test * add one line commit 5a509ef1f103557d83be65af975c08e14954ef4a Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Wed Jan 17 17:14:29 2024 +0200 Scenes: Compare versions in dashboard settings (#80286) * Add versions tab in dashboard settings * Fetch and render dashboard versions * Be able to compare two versions * PR discussion changes * remove unnecessary async in test * PR discussion mods * linter fix * styles and tests * Fix show more versions bug * migrate files + style fix * fix test * refactor styles - css object keys to camelCase * refactor file migrations * more files migrations * remove unused type, cleanup commit 9969218231fe177927534270c10d7260bb3d6420 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 14:05:41 2024 +0000 Update dependency yaml to v2.3.4 commit 3356fb7726ddbed949de02fc4b616c62957b766b Author: Dominik Prokop Date: Wed Jan 17 06:58:57 2024 -0800 DashboardScene: Add dashboard slug support (#80726) commit df2b4efcfb1fb23ffaa2a7d02392fd1f4d234329 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 14:05:07 2024 +0000 Update dependency whatwg-fetch to v3.6.20 commit b1e6852da088659c782cc7dc31d8990115a5527d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 16:58:02 2024 +0200 Update swc monorepo (#80732) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit beb0b82bbdf9d36155fd01e9738f53dc321d4933 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 13:44:22 2024 +0000 Update dependency uuid to v9.0.1 commit da6926f6f767fa3e5cb80122af5a8c0524ef5aee Author: Misi Date: Wed Jan 17 14:55:55 2024 +0100 Auth: SSOSettings handle secret update (#80591) * first touches * Merge missing SSO settings to support Advanced Auth pages * fix * Update secrets correctly * Add test for upsert with redactedsecret * Verify decryption in the List tests commit 1de876c3547bdaa86aa005c2ff4fabb2ba059bbe Author: Dominik Prokop Date: Wed Jan 17 05:53:53 2024 -0800 DashboardScene: Get rid of panel edit route (#80605) * Wip: get rod of panel edit route * Cleanup unused code * Test update * Simplify url sync for inspect and vie/edit panel * Update navigating back to dashboard from edit panel * DashboardScene: Panel inspect improvements (#80655) Improve inspect, andle view pane end edit mode inspection * Url sync fixes * Test update commit dd7259b77e3b185eeb4b06b57b3cdae54c17da33 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 13:05:43 2024 +0000 Update dependency tslib to v2.6.2 commit a103be2285710f8c1c2dac470595336f816da9ff Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 12:16:23 2024 +0000 Update dependency react-virtualized-auto-sizer to v1.0.21 commit 2cb2845ec4c9a6d92da1c9baed6300f06d33f621 Author: Gábor Farkas Date: Wed Jan 17 13:42:32 2024 +0100 sql: use plugin-sdk code for value-to-float64 conversion (#80621) sql: use plugin-sdk code commit 67fe33aa62ec275c6b3c92fbb900fd62baf4cbd1 Author: Andres Martinez Gotor Date: Wed Jan 17 13:38:45 2024 +0100 Datasources: Ensure the 'Host' custom header functions as expected (#80715) Supports "Host" custom header. Co-authored-by: Pei-Tang Huang commit 82638d059fd8de976f19b5e046f680a609ea6918 Author: Jean-Philippe Quéméner Date: Wed Jan 17 13:33:13 2024 +0100 feat(alerting): add state persister interface (#80384) commit 2d49fb6a7a088bc500dba29c72dad266c914b6d5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 12:02:16 2024 +0000 Update dependency ts-node to v10.9.2 commit f8056a3e5616dbf90b1f4d4ea16a490543882890 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 12:13:14 2024 +0000 Update dependency react-virtualized-auto-sizer to v1.0.20 (#80596) * Update dependency react-virtualized-auto-sizer to v1.0.20 * Update dependency react-virtualized-auto-sizer to v1.0.20 * update types * mock in a few more tests * fix InspectDataTab test * fix test --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison commit 5add09a9ed0bd62e975b0ae9c0fc76a9539a577d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 11:42:37 2024 +0000 Update dependency terser-webpack-plugin to v5.3.10 commit e1ead0f53769b5864a65e698dc144c8da8f13c6a Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Wed Jan 17 12:45:17 2024 +0100 Tempo: Minor refactoring (#80701) commit 472f450333aea846c187bfc120b8bd0c3af36208 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 11:16:15 2024 +0000 Update dependency systemjs to v6.14.3 commit 322cd74b9c26fac4beca4dec79d7273716ccd301 Author: Piotr Jamróz Date: Wed Jan 17 12:26:47 2024 +0100 Explore: Re-enable basic e2e test for Explore (#80617) Re-enable basic test for Explore commit 8872a004e2e821e947be29e051a8131c3bc613be Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Wed Jan 17 11:13:14 2024 +0000 Chore: Improve typing for onPanelConfigChange (#80710) commit 7c74ab705933b012d0e6dcf76f749ceb8f1d0a01 Author: Timur Olzhabayev Date: Wed Jan 17 11:39:41 2024 +0100 Revert "bumping go to 1.25.6" This reverts commit 73439f2cd382f52f8001c02063cb92b044d28b29. commit 7a9f90788219cd3db54b00714633c56c3c316983 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 10:23:53 2024 +0000 Update dependency style-loader to v3.3.4 commit 73439f2cd382f52f8001c02063cb92b044d28b29 Author: Timur Olzhabayev Date: Wed Jan 17 11:38:25 2024 +0100 bumping go to 1.25.6 commit 58b13fed5af4a5c98969abc90c159d0b0c5bc986 Author: Gábor Farkas Date: Wed Jan 17 11:27:43 2024 +0100 postgres: tests: adjusted the timestamps to be in UTC (#80704) commit 76812240a820dc51ad1d6a3776d376e0374d711b Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 10:07:35 2024 +0000 Update dependency selecto to v1.26.3 commit 3217a0dc052d5ff7a014b0c6bd22065b67e6c419 Author: Santiago Date: Wed Jan 17 11:04:27 2024 +0100 Alerting: Fix state sync errors counter increment (#80702) commit 7b58f71b334b8dd153776af76642a4e9ded74194 Author: Karl Persson Date: Wed Jan 17 10:55:47 2024 +0100 AuthN: Add auth hook that can sync grafana cloud role to rbac cloud role (#80416) * AuthnSync: Rename files and structures * AuthnSync: register rbac cloud role sync if feature toggle is enabled * RBAC: Add new sync function to service interface * RBAC: add common prefix and role names for cloud fixed roles * AuthnSync+RBAC: implement rbac cloud role sync Co-authored-by: Ieva commit 24c32219bbda81f3c29e1feb4e8ee455620ee3e9 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 09:25:27 2024 +0000 Update dependency sass to v1.69.7 commit def1b05a939d2bdf5013a2bbb364b548652e5a73 Author: Ieva Date: Wed Jan 17 09:49:33 2024 +0000 RBAC: Clean up data source permissions after data source deletion (#80654) * clean up data source permissions after data source deletion * remove a comment commit 4291bf4d69d92b3459c6f14a9c32a3b99aedd97a Author: Gábor Farkas Date: Wed Jan 17 10:31:15 2024 +0100 postgres: tests: improve float64-conversion tests (#80627) postgres: more tests commit 89089efc98858984c8bfcdfbe1dd12c0e44e536d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 09:06:38 2024 +0000 Update dependency rimraf to v5.0.5 commit d1dab5828d3d203636546389fb75b9c1b6f73136 Author: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Wed Jan 17 11:07:39 2024 +0200 Alerting: Update rule API to address folders by UID (#74600) * Change ruler API to expect the folder UID as namespace * Update example requests * Fix tests * Update swagger * Modify FIle field in /api/prometheus/grafana/api/v1/rules * Fix ruler export * Modify folder in responses to be formatted as / * Add alerting test with nested folders * Apply suggestion from code review * Alerting: use folder UID instead of title in rule API (#77166) Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com> * Drop a few more latent uses of namespace_id * move getNamespaceKey to models package * switch GetAlertRulesForScheduling to use folder table * update GetAlertRulesForScheduling to return folder titles in format `parent_uid/title`. * fi tests * add tests for GetAlertRulesForScheduling when parent uid * fix integration tests after merge * fix test after merge * change format of the namespace to JSON array this is needed for forward compatibility, when we migrate to full paths * update EF code to decode nested folder --------- Co-authored-by: Yuri Tseretyan <yuriy.tseretyan@grafana.com> Co-authored-by: Virginia Cepeda <virginia.cepeda@grafana.com> Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com> Co-authored-by: Alex Weaver <weaver.alex.d@gmail.com> Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com> commit ec1d4274edf8d74d9d745173d5d8a65938378a74 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 09:03:21 2024 +0000 Update dependency regenerator-runtime to v0.14.1 (#80696) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 5d49602e41878a2c815bebea9ad97361afe0f996 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Wed Jan 17 09:59:43 2024 +0100 Automatically assign Pyroscope label (#80639) commit 43b6b6b2a41b10a17ca71876c558ce78d93feb96 Author: Karl Persson <kalle.persson@grafana.com> Date: Wed Jan 17 09:52:05 2024 +0100 IDForwarding: add "authenticatedBy" to id token (#80622) * IDForwading: Set authenticated by for users commit 0d1462cbbbe1e4712170d946818c2299bbb0e8b1 Author: Andres Martinez Gotor <andres.martinez@grafana.com> Date: Wed Jan 17 09:22:51 2024 +0100 Add real instances to testdata standalone API server (#80473) Co-authored-by: Will Browne <wbrowne@users.noreply.github.com> commit 2bf56b12cf10d0a26cead239de9e29a5d48afd4a Author: Gábor Farkas <gabor.farkas@gmail.com> Date: Wed Jan 17 09:20:27 2024 +0100 postgres: simplify proxy code (#80121) commit e062fa1d7e87a7248e2556aa2e7bbbdfa0adfc81 Author: Gabriel MABILLE <gamab@users.noreply.github.com> Date: Wed Jan 17 09:13:56 2024 +0100 RBAC: Change search permissions buckets to cover long searches (#80624) * RBAC: Change search permissions histogram start and factor * Add one bucket and lower start commit 878ba96a70303de63fa57fe50fbce2876ecc67d9 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 00:29:09 2024 +0000 Update dependency react-use to v17.4.3 (#80690) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit c4cea52aa06fc9becb052dc096061d67826d76e5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 17 02:13:33 2024 +0200 Update dependency react-hook-form to v7.49.3 (#80689) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit bfee861867286f572fee28f9bb90ae557f714a8d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 16 23:58:58 2024 +0000 Update dependency glob to v10.3.10 (#80684) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 00b954203d4b89f3944ff062147adb5abd2cbc7a Author: lwandz13 <126723338+lwandz13@users.noreply.github.com> Date: Tue Jan 16 17:14:35 2024 -0600 Docs: Updated anon user and dashboard access (#80400) * Clarified anon user and viewer role, style updates. * fixed spelling error commit 59608080c726f472fb2f4067d6197eb62f9bc1ba Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 16 23:11:45 2024 +0000 Update dependency @types/testing-library__jest-dom to v5.14.9 (#80672) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 3c796ecc8f1130f2b91373e3289b32811351dfd5 Author: Alexander Weaver <weaver.alex.d@gmail.com> Date: Tue Jan 16 16:35:56 2024 -0600 Alerting: Add metric counting rule groups per org (#80669) * Refactor, fix bad map hint * Count groups per org commit 6b37a887d50c0e26ede16536bfa3191f217a2e2c Author: Andre Pereira <adrapereira@gmail.com> Date: Tue Jan 16 22:05:44 2024 +0000 Data trails: Move and rename to explore metrics (#80649) * Move data trails to /explore/metrics * Fix breadcrumbs * Fix label option link commit 48a5c1e8509980b6ffe8cf5c5e486d096eb7058c Author: Ryan McKinley <ryantxu@gmail.com> Date: Tue Jan 16 13:18:25 2024 -0800 FeatureFlags: Remove the unsupported/undocumented option to read flags from a file (#79959) commit 68d4e8a9309e7fddd3c3db545b4af295ae8996a4 Author: Alexander Weaver <weaver.alex.d@gmail.com> Date: Tue Jan 16 14:48:11 2024 -0600 Annotations: Remove extraneous, debug log messages (#80670) * drop log messages * Revert timer * fix returns, no need to capture vars for log lines anymore commit 7b8db643a3a113d158181694774a0128384972d3 Author: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Tue Jan 16 14:27:29 2024 -0600 Logs Panel: Table UI - Reordering table columns via drag-and-drop (#79536) * Implement react-beautiful-dnd to facilitate column reordering * Refactoring field display components * Refactoring internal types to better align with Grafana style guide --------- Co-authored-by: Sven Grossmann <sven.grossmann@grafana.com> commit 581936a44295381bb5ecc88c5a27cc66c222a911 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 16 20:08:09 2024 +0000 Update dependency @types/semver to v7.5.6 (#80667) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 9770768efc9493824b57f7b8357c532ea5f2bebe Author: Giordano Ricci <me@giordanoricci.com> Date: Tue Jan 16 19:49:12 2024 +0000 Chore: refactor some styles in explore to use the object syntax (#80080) * Chore: refactor some styles in explore to use the object syntax * refactor LiveLogs styles to use object syntax * Revert "refactor LiveLogs styles to use object syntax" This reverts commit 293aa2589fb0f24305393467cbd1bb0726304962. commit 894353241ac940639e364c584f8a4c932da6e8d4 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 16 18:27:36 2024 +0000 Update dependency @types/prismjs to v1.26.3 (#80658) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 097f3c7e0667c4b908bda95bde32bcbee4280249 Author: colin-stuart <colindonstuart@gmail.com> Date: Tue Jan 16 13:27:28 2024 -0500 Auth: Add docs for the SSO Settings get endpoint (#80240) * add docs for sso-settings get endpoint * Update docs/sources/developers/http_api/sso-settings.md Co-authored-by: Misi <mgyongyosi@users.noreply.github.com> * Update docs/sources/developers/http_api/sso-settings.md Co-authored-by: Misi <mgyongyosi@users.noreply.github.com> --------- Co-authored-by: Misi <mgyongyosi@users.noreply.github.com> commit d27e2fec88389e261b583aab46fbff99b5d722c0 Author: Andre Pereira <adrapereira@gmail.com> Date: Tue Jan 16 18:22:53 2024 +0000 Data Trails: Actions redesign and overview tab (#80216) * Use last used datasource or default datasource when starting a trail * Use tabs and move actions bar to same line when possible * Added overview tab with description, type and labels * Clickable labels in overview tab * Show label value counts next to the label name * Fix action bar zIndex * Address PR comments * Refactor * Refactor getLabelOptions to utils * Reuse language provider from state * betterer * testing some refactors * Remove unreachable code * Refactor GROUP_BY var to MetricScene * Fix url by excluding var-groupby * Fix conflicts * Use <Text/> instead of custom styles * Simplify setting overview as default tab --------- Co-authored-by: Torkel Ödegaard <torkel@grafana.com> commit 14c82c2725f75d5926f5eea526291fefe552c035 Author: Oscar Kilhed <oscar.kilhed@grafana.com> Date: Tue Jan 16 18:59:32 2024 +0100 Scenes: Show transformations when editing scene dashboard (#80372) * Make dashboard data source query actually use DashboardDataSource * remove commented out bit * Always wrap SceneQueryRunner with SceneDataTransformer * Update Dashboard model compat wrapper tests * DashboardQueryEditor test * VizPanelManager tests update * transform save model to scene tests update * Betterer * PanelMenuBehavior test update * Few more bits * Prettier * Show transformations when editing scene dashboard * remove and edit transformations works * add add and remove buttons * Change styles to object to fix betterer issue * Revert "Change styles to object to fix betterer issue" This reverts commit 8627b9162c2d8496317440731cac0f531d55782e. * Fix the correct file... * Some refactoring * remove unneessary if statement * panel data not present on first render * move transformation tabs out of folder * fix tests * add lint exception * refactor tab component * Fix merge issue * reorder components --------- Co-authored-by: Torkel Ödegaard <torkel@grafana.com> Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com> commit dbae7ccd3faa760c471e861aed679f24ae5e86b8 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Tue Jan 16 12:46:12 2024 -0500 refactor: data trails auto query for buckets (#80170) * refactor: data trails auto query for buckets * refactor: vizBuilder function signature (#80178) * fix: use closures for setting unit and title in vizBuilders commit 739cba6eb9454f1ecbfb3f6ef515fa08274ca798 Author: Usman Ahmad <usman.ahmad@grafana.com> Date: Tue Jan 16 18:22:32 2024 +0100 updated grafana docker video timestamp (#80659) Update the documentation page: https://grafana.com/docs/grafana/latest/setup-grafana/installation/docker/ Added the time when the actual demo starts. commit 3aa228f50ce4daee9e2d3bf4d390de3e5d1b95fe Author: Levent Tutar <ltutar@xebia.com> Date: Tue Jan 16 18:10:16 2024 +0100 Update _index.md with the correct number of bullets. (#80382) There are 4 bullets but the text mentions 3. commit 4ea1e80a3931355085415eed9ddc3f72590d5790 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 16 18:33:48 2024 +0200 Update dependency @types/lodash to v4.14.202 (#80644) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 0f093c1463bd2f5b39ff9f9889bd83f0b144ef10 Author: Usman Ahmad <usman.ahmad@grafana.com> Date: Tue Jan 16 17:32:57 2024 +0100 Update Grafana Kubernetes installation page (#80569) Removed the Vimeo and added the YouTube link after an internal discussion with the team. commit e1d387d826ab18c44a8cc151c4c4276aef565174 Author: Ryan McKinley <ryantxu@gmail.com> Date: Tue Jan 16 08:30:01 2024 -0800 K8s/Storage: Register field-selector on all kinds (#79822) Co-authored-by: Dan Cech <dcech@grafana.com> commit 73715a1de9d8da568164ca3a7e051d0685549b20 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Tue Jan 16 17:29:30 2024 +0100 Loki: Use DataSourcePicker from runtime in DerivedField (#80629) commit 17efc72cae481fc4d71eab806808e676d7e151f6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 16 16:19:26 2024 +0000 Update dependency @types/jest to v29.5.11 (#80642) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 3afd94185cf96ca271c598dd5a4873f2efd3a521 Author: Santiago <santiagohernandez.1997@gmail.com> Date: Tue Jan 16 17:12:24 2024 +0100 Alerting: Add metric to check for default AM configurations (#80225) * Alerting: Add metric to check for default AM configurations * Use a gauge for the config hash * don't go out of bounds when converting uint64 to float64 * expose metric for config hash * update metrics after applying config commit 06800e2d312fbf42dcd6a9ed3eb5cef6c785cfa4 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 16 16:03:52 2024 +0000 Update dependency @testing-library/user-event to v14.5.2 (#80633) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit c0da4d2a2ada2b241427ddc4e2d8beddf70363b8 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Tue Jan 16 17:00:27 2024 +0100 Remove legacy dependency (#80619) commit a9c376db1df777daebc6849253eb27494d1e5b67 Author: Lauri Tirkkonen <101785056+lauri-paypay@users.noreply.github.com> Date: Tue Jan 16 18:00:00 2024 +0200 grafana metrics dashboard: use correct grafana http request metrics (#80338) grafana's metrics exporter does not provide a metric called "http_request_total"; fix the dashboard to use the actually existing "grafana_http_request_duration_seconds_count" commit f9dcc9ff9064d64e8ba77aa96179f001c48b658f Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Tue Jan 16 10:52:32 2024 -0500 Docs: add more time zone guidance (#79595) * Created report time zone section with added guidance * Added more information * Updated from review suggestions * Update docs/sources/dashboards/create-reports/index.md Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> * Ran prettier --------- Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> commit 054feec107c27197429cc2655a1c591566d7ac39 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 16 14:29:04 2024 +0000 Update dependency @lezer/common to v1.2.1 commit 28009228e57f05ad7d288498889dd11df98a7662 Author: Eric Leijonmarck <eric.leijonmarck@gmail.com> Date: Tue Jan 16 15:41:28 2024 +0000 Anonymous: Fix flaky tests (#80631) fix: flaky tests commit ea4bfc23ca05a466711d5a0a57c8597a2054783f Author: Gilles De Mey <gilles.de.mey@gmail.com> Date: Tue Jan 16 15:30:25 2024 +0100 Alerting: Improve notification policies view performance (#80209) commit ddbe4d2bbaaa54867faac23e7e54a94a463185dc Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 16 14:25:05 2024 +0000 Update dependency @grafana/lezer-logql to v0.2.2 (#80614) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 01e430e82101cfd47ff0cd296502647c056574ed Author: Horst Gutmann <horst.gutmann@grafana.com> Date: Tue Jan 16 15:24:40 2024 +0100 chore: Bump Alpine base image to 3.18.5 (#80540) commit bdbc3f351c419c5aca1ad2f25fc899943be49864 Author: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Tue Jan 16 07:04:31 2024 -0700 Explore: Format data in Data tab in Query Inspector (#80004) * Fix betterer * Improve formatting logic commit e7099eabb337c3f0fcc65ed5189d52334f4bdca6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 16 15:27:57 2024 +0200 Update dependency @grafana/faro-web-sdk to v1.3.6 (#80613) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit de01e1db18b690b124900285bdeb81e130aa6d1f Author: Jack Westbrook <jack.westbrook@gmail.com> Date: Tue Jan 16 14:26:43 2024 +0100 Chore: Remove unused babel dependencies and references (#76791) * build(babel): remove unused babel dependenices and references from project * chore: put back ts-loader * chore(yarn): refresh lock file * revert: remove ts-loader commit 8f7315f7d920b1eb13e8854183692697959ee3bc Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 16 13:12:14 2024 +0000 Update dependency @grafana/faro-core to v1.3.6 (#80612) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 5640f25213c11ea55d1098dbaf1f9e51a380aa7a Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Tue Jan 16 12:58:07 2024 +0000 Scenes/PanelEdit: Fix some styles and flesh out the skeleton (#80366) commit 6796e66fb8be0b7528e1e64f933e1e7cff581e6c Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Tue Jan 16 12:46:15 2024 +0000 Pyroscope: Add standalone build (#80222) * Pyroscope standalone build * Fix for tests * Add missing packages * Remove import * Update trace to profiles * Update test commit 96010eb21e7b108b64306755e516feeb7e3fedf6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 16 14:19:47 2024 +0200 Update dependency @floating-ui/react to v0.26.6 (#80611) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 6c609051a0af2fad8f9f6918b93ca9397c147ddb Author: Alexa V <239999+axelavargas@users.noreply.github.com> Date: Tue Jan 16 12:38:34 2024 +0100 Dashboard: Migration - EditVariable Settings: Implement TextBox Variable (#80293) Co-authored-by: Ivan Ortega <ivanortegaalba@gmail.com> commit c0918d41dda3d30698caa8f73369f923f7d864c6 Author: Tania <10127682+undef1nd@users.noreply.github.com> Date: Tue Jan 16 12:35:10 2024 +0100 Chore: Remove extra loop over folders in GetFolders handler (#79933) Chore: Remove extra loop in GetFolders handler commit 712c505251c38e0cef80306b1d39e083a6391518 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Tue Jan 16 12:27:51 2024 +0100 Divider: Remove core component and replace with `grafana/ui` components (#80574) * Divider: Remove core component and replace with grafana/ui components * Update gap * Bump experimental in tempo --------- Co-authored-by: Fabrizio <fabrizio.casati@grafana.com> commit 07778cb221a28849036b0ace3a37c571b2811f07 Author: Ivan Ortega Alba <ivanortegaalba@gmail.com> Date: Tue Jan 16 12:24:54 2024 +0100 DashboardLinksSettings: Move them to Scenes (#79998) commit 313c43749c76309604b4b733e62978dee2168d53 Author: Uladzimir Dzmitračkoŭ <3773351+going-confetti@users.noreply.github.com> Date: Tue Jan 16 12:22:32 2024 +0100 Explore: Fix incorrect interpolation of table title (#80227) commit 127decee1ebb735098ddd458b13f50b1ff0d5483 Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Tue Jan 16 11:19:32 2024 +0000 Release: Deprecate latest.json and replace with api call to grafana.com (#80537) * remove latest.json and replace with api call to grafana.com * remove latest.json * Revert "remove latest.json" This reverts commit bcff43d898d571c36680c2267c3cfaa5d8a60bef. * Revert "remove latest.json and replace with api call to grafana.com" This reverts commit 02b867d84ed43e0b0e27524afa87fae1f92f6835. * add deprecation message to latest.json commit 31256bcc85493b5e3b17a6cae9ca0058f422b030 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 16 11:02:50 2024 +0000 Update dependency @babel/core to v7.23.7 commit b97170b01e23bcaa38cfd57288730cb26813ec99 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Tue Jan 16 12:07:53 2024 +0100 Loki: Remove dependency in test files on selectOptionInTest (#80546) * Loki: Remove dependecy in test files on selectOptionInTest * Remove await waitFor(() => where not needed commit 67d77e76beafa47943d701245aec370a3943ce41 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Tue Jan 16 12:00:31 2024 +0100 Loki: Refactor debug section to remove dependency on deprecated `getFieldLinksForExplore` (#80594) * Refactor debug section * Show href only if matched regex commit b5a1a3e106f0d210a21af2dc11ec81ef8b876472 Author: Tolya Korniltsev <korniltsev.anatoly@gmail.com> Date: Tue Jan 16 17:58:35 2024 +0700 Profiling: Import godeltaprof/http/pprof (#80509) chore: import godeltaprof/http/pprof This adds /debug/pprof/delta_{heap,mutex,block} endpoints to DefaultServeMux and the profiling port. commit f4629f7014ecebca4122b34fc1a6c5f0e6bf8286 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 16 10:27:58 2024 +0000 Update dependency react-window to v1.8.10 commit 522519f671c2c3affae3ed4208b2cf24406eb68f Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Tue Jan 16 11:36:40 2024 +0100 Tempo: Decouple Tempo from Grafana core (#79888) commit 767029a43df1f20f30bb1573099511f3e0287a0d Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Tue Jan 16 10:11:20 2024 +0000 use exposed interface from rc-tooltip commit f5cabd4db01f0ee6e341bc67da7caa7d743eea45 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 18:14:05 2024 +0000 Update dependency rc-tooltip to v6.1.3 commit 3c9a93c86d0eae177e0928c0c04722bbd3526aad Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Tue Jan 16 11:22:04 2024 +0100 Update tests to protect against regression in #79938 (#80595) commit cd8bf4c8cb965d3966085815cc2f1da4b7062b40 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 16 01:53:04 2024 +0000 Update dependency react-use to v17.4.3 commit c02d57c6a9e8ae83164dd4e3e128c1fd2068c293 Author: Timur Olzhabayev <timur.olzhabayev@grafana.com> Date: Tue Jan 16 11:08:39 2024 +0100 Chore: removing folderId from plugindashboard service (#80570) * removing folderId from plugindashboard service * fixing linting commit 55106c6ba89d11ae576f8cbe430ecfb3ad6d99ee Author: Andres Martinez Gotor <andres.martinez@grafana.com> Date: Tue Jan 16 10:26:42 2024 +0100 Update grafana-plugin-sdk v0.199.0 (#80590) commit 60711c0e6345958cc164b8ac5b38f632030eabb7 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 16 00:51:12 2024 +0000 Update dependency react-hook-form to v7.49.3 commit 081779a3478d737760878cf1c9be448c574aa71b Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 21:51:36 2024 +0000 Update dependency react-grid-layout to v1.4.4 commit 54cfaf7ff0e8f669918ff3e8ae3e3a477fd4682b Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 21:17:59 2024 +0000 Update dependency react-draggable to v4.4.6 commit 8184b3604719fb59f449dedc8ddb9302ff820755 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 19:30:30 2024 +0000 Update dependency re-resizable to v6.9.11 commit 773b65cdcb3f5c8429d3825f79003aca141c0806 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 18:14:38 2024 +0000 Update dependency rc-tree to v5.8.2 commit 82c85de0e8ce20bca180c6543c2990f815c226b3 Author: Kevin Yu <kevinwcyu@users.noreply.github.com> Date: Mon Jan 15 10:09:34 2024 -0800 CloudWatch: remove DataSourcePicker core import (#79491) commit 7f5420b18b490d8a061f75770ffb00698c37e043 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 17:22:13 2024 +0000 Update dependency postcss-scss to v4.0.9 commit 190b748d343fda6652da578ccf7cc41a51748636 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 17:21:26 2024 +0000 Update dependency postcss-loader to v7.3.4 commit 48ff532ca84ece677dc700961658d3f196de9b28 Author: Gabriel MABILLE <gamab@users.noreply.github.com> Date: Mon Jan 15 17:56:01 2024 +0100 RBAC: Add histogram metric on search endpoint (#80553) RBAC: Add histogram on search endpoint commit d59a9ac3a74311536ba75e30dba56bf2f23c5e95 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 16:17:17 2024 +0000 Update dependency postcss to v8.4.33 commit 905b19c0ddcc13664a468ed599ad6ffd5ebcc99e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 16:16:50 2024 +0000 Update dependency ol-ext to v4.0.13 commit ae23cd7cf7c7be5e9515df5a9f11cb8c46c88feb Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Mon Jan 15 17:45:01 2024 +0100 Alerting: Fix validation of selected contact point being required in the alert form when simplified routing (#80565) Fix validation of selected contact point being required in the alert rule form commit d3a89a28fab51604e189e5cf4d2e07180002d7a5 Author: Ida Štambuk <ida.stambuk@grafana.com> Date: Mon Jan 15 17:19:26 2024 +0100 Cloudwatch Metrics: Adjust error handling (#79911) commit 406ace27c0635c58690324a6689ec30d56a5f26e Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Mon Jan 15 16:19:16 2024 +0000 Chore: remove merge conflict from .betterer.results (#80563) remove merge conflict commit 084b9f060cc5e43cdfcd877d987a690a3a77fcc1 Author: Torkel Ödegaard <torkel@grafana.com> Date: Mon Jan 15 17:13:22 2024 +0100 SharePanelQuery: Improve query editor UI (#80535) * SharePanelQuery: Improve query editor UI * tweak * Update commit 693b07c0f7a675452ee5e709276a12f91393672c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 15:51:10 2024 +0000 Update dependency moment-timezone to v0.5.44 (#80561) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit d77922c3c36853b3a6e150b1790c35d76708d403 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 15:23:28 2024 +0000 Update dependency mini-css-extract-plugin to v2.7.7 commit 8157711893d0ebe4ace006f1e6d55eebb82cd775 Author: Torkel Ödegaard <torkel@grafana.com> Date: Mon Jan 15 16:43:30 2024 +0100 DashboardDataSource: Implement sharing logic inside the data source (#80526) * Make dashboard data source query actually use DashboardDataSource * remove commented out bit * Always wrap SceneQueryRunner with SceneDataTransformer * Update Dashboard model compat wrapper tests * DashboardQueryEditor test * VizPanelManager tests update * transform save model to scene tests update * Betterer * PanelMenuBehavior test update * Few more bits * Prettier --------- Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com> commit bffb28c177123514bfcc3ea0e8f2e3add18a84c8 Author: arukiidou <arukiidou@yahoo.co.jp> Date: Tue Jan 16 00:24:02 2024 +0900 refactor: use golang.org/x/oauth2 pkce option (#80511) Signed-off-by: junya koyama <arukiidou@yahoo.co.jp> commit 1cf53a34d113d55146d0d25eacbaed4002422cf6 Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Mon Jan 15 16:57:45 2024 +0200 Scenes: Render versions in dashboard settings (#80229) * Add versions tab in dashboard settings * Fetch and render dashboard versions * PR discussion changes * remove unnecessary async in test * PR discussion mods * linter fix commit d5db67a0731a44706fa8fe263e62f51d26b7e2e2 Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Mon Jan 15 14:29:39 2024 +0000 Chore: type improvements (#80464) * type improvements * some more fixes * add TODOs to remove type assertions commit b23ecaa3d1d7a6f63eadd70143dd429fbe04f9b3 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 14:13:30 2024 +0000 Update dependency marked-mangle to v1.1.6 commit 87b613411c1d15704caa14dcc090f0c4429dfe44 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 14:24:50 2024 +0000 Update dependency kbar to v0.1.0-beta.45 (#80554) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 7a3a7221329c5ab714498c61dd803ca5088a1c02 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 13:17:53 2024 +0000 Update dependency jquery to v3.7.1 commit 986ddc1ad6bde99f71305dad86602fe5c0284c1b Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 13:17:25 2024 +0000 Update dependency jest-fail-on-console to v3.1.2 commit 8adfad405521f5817d5b86936252eeb078b404b6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 12:30:10 2024 +0000 Update dependency @grafana/faro-web-sdk to v1.3.6 commit 10f0d094ad031ba6dc1a720ac76bb42cde5dd442 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Mon Jan 15 13:36:26 2024 +0100 Alerting: Visualize autogenerated policy tree for simplified routing. (#79509) * WIP * WIP: disable some actions when is autogenerated policy * WIP * Wip: add checks for group by in auto-generated policy * Make autogenerated policy readOnly and enable Readonly modal for it * Use real check for autogenerated root * Fix test * Refactor: rename consts * Add test for policy form being read only * Add tests * Update some code comments * Fix Switch component not being styled as disabled * Rename isAutogeneratedChunkOpen property to isBranchOpen and fix test * Revert fix for Switch as it has moved to another separate PR * Split Policy component in smaller sub components * use useAlertmanagerAbility form for checking autogenerated tree visibility and fix container for autogenerated policy being rendered when it's not supported * Update useAbilities test and dont use toAbility for ViewAutogeneratedPolicyTree * Fix Policy being unmounted every 10 secs and move the collapsed/expanded state to each Policy component * remove permissions from createDropdownMenuActions method parameters and convert the method to a hook * Revert using PolicyItem * Add test for createDropdownMenuActions * Revert having a read only view form for the policy * Remove readonly from default policy form * Only show collapsible when node has children * Split DefaultPolicyIndicator * use hidehideCurrentPolicy instead of showCurrentPolicy * Address some review suggestions commit 9f0bb9cb07c829b8c3dc50feb32912631bb82d55 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 12:08:22 2024 +0000 Update dependency @grafana/faro-core to v1.3.6 commit 2a4f6ba5b025cbbf3571b7f5f24233e1cc59f547 Author: Giordano Ricci <me@giordanoricci.com> Date: Mon Jan 15 12:26:34 2024 +0000 Explore: skip flaky e2e test (#80543) commit 3979ea0c479a2c562d045584355600908e7e2d8b Author: Eric Leijonmarck <eric.leijonmarck@gmail.com> Date: Mon Jan 15 12:13:38 2024 +0000 Anonymous Access: Pagination for devices (#80028) * first commit * add: pagination to anondevices * fmt * swagger and tests * swagger * testing out test * fixing tests * made it possible to query for from and to time * refactor: change to query for ip adress instead * fix: tests commit e2b706fdd3c15b52b187401394249eba50fe6f3d Author: Máté Szabó <mszabo@fandom.com> Date: Mon Jan 15 13:02:00 2024 +0100 Jaeger: Add service dependency graph support (#72200) * Jaeger: Add service dependency graph support Add support for visualizing Jaeger's service dependency graph via the Jaeger data source. Per the discussion[1], this is done by proxying the internal Jaeger HTTP API endpoint used by Jaeger's own UI for fetching graph data, and transforming it into a format suitable for the node graph panel in Grafana. --- [1] https://github.com/grafana/grafana/discussions/52035 * Small lint fixes * Type fix --------- Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com> commit 6a36525d612cd1ee6c4d6b824383fa32c137d2e2 Author: Gábor Farkas <gabor.farkas@gmail.com> Date: Mon Jan 15 12:50:01 2024 +0100 postgres: better error handling (#80375) commit a3b9ec21db4e50a90e049132723af118dc3f39b3 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 11:34:39 2024 +0000 Update dependency immutable to v4.3.4 commit ec53487c995777b314f566f5a1054e3f8e29ec05 Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Mon Jan 15 11:43:19 2024 +0000 NestedFolderPicker: separate toggle to force enable picker without `nestedFolders` (#80461) * separate nestedFolderPickerOverride toggle to force enable it without nestedFolders * let's call it newFolderPicker * update unit tests and keyboard handling * reduce spacing when no folder open chevron --------- Co-authored-by: Josh Hunt <joshhunt@users.noreply.github.com> commit d91d4e87b9532c2d8e773d516575e5b037f6096c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 11:14:33 2024 +0000 Update dependency immer to v10.0.3 commit 15af2e5053d78ed0061f74953979e7e2b93811f9 Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Mon Jan 15 11:30:55 2024 +0000 Connections: Pass keywords from the backend to CommandPalette (#80276) * pass keywords from the backend to CommandPalette * add csv and json to keywords commit 343422537ee764b009c8a50dd9311e4e407aebf8 Author: Andres Martinez Gotor <andres.martinez@grafana.com> Date: Mon Jan 15 12:11:09 2024 +0100 Chore: Fix Azure Monitor plugin build (#80528) commit 94ec6474d372c73e09a4782c1ebf0a7773c9d8aa Author: Josh Hunt <joshhunt@users.noreply.github.com> Date: Mon Jan 15 10:56:31 2024 +0000 NestedFolders: Support Shared with me folder for showing items you've been granted access to (#80141) * start shared with me frontend tweaks * prevent linking to sharedwithme folder * tests * make divider take up 0 height * Prevent sharedwithme from being selected * test git push * pr feedback * prevent setting url for sharedwithme * split iconForItem/kind functions * Hide sharedwithme in nested folder picker * fix test fixture commit b7d6fa442305badcbb59f99e6f037234250c38eb Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 10:10:37 2024 +0000 Update dependency glob to v10.3.10 commit 97f71c2240735fec1dc6706c9cfc6a5a4ba2e714 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 10:09:52 2024 +0000 Update dependency eslint-plugin-jest to v27.6.3 commit 20db9ebcc24167165fdbc73cd2a6b925c3b776c9 Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon Jan 15 11:37:57 2024 +0100 Bump go.opentelemetry.io/collector/pdata from 1.0.0-rc8 to 1.0.1 (#80520) Bumps [go.opentelemetry.io/collector/pdata](https://github.com/open-telemetry/opentelemetry-collector) from 1.0.0-rc8 to 1.0.1. - [Release notes](https://github.com/open-telemetry/opentelemetry-collector/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-collector/blob/main/CHANGELOG-API.md) - [Commits](https://github.com/open-telemetry/opentelemetry-collector/compare/pdata/v1.0.0-rc8...pdata/v1.0.1) --- updated-dependencies: - dependency-name: go.opentelemetry.io/collector/pdata dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> commit 083605c17f47d6eea81990981e28192faa8feb1c Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Mon Jan 15 10:12:41 2024 +0000 DockedMenu: only set the menu state to be `docked` if the window size is big enough (#80379) * only set the menu docked in state if the window size is big enough * adjust sizes * remove top border on dockedMegaMenu * another css tweak * use xxl consistently * CONSISTENTLY * ok maybe not commit f862bcb509e825f78bbb1bd3f4811cad1ffdc14e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 09:48:54 2024 +0000 Update dependency csstype to v3.1.3 commit 9b5b76aedd9aa0fb04722587f9930792f0aef0a6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 10:02:01 2024 +0000 Update dependency @types/slate-plain-serializer to v0.7.5 (#80495) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 702e7cb3ac3d5c028bfe4d2f9e69337437b72571 Author: Andreas Christou <andreas.christou@grafana.com> Date: Mon Jan 15 09:54:52 2024 +0000 Azure: Fix type exports (#80469) Fix type exports commit 1596339796b12b47f9781f1b60c49d6e3d5b4ea2 Author: Kyle Cunningham <codeincarnate@users.noreply.github.com> Date: Mon Jan 15 03:53:22 2024 -0600 Chore: Update CODEOWNERS for end of BI (#80522) Update CODEOWNERS commit 21200fd5f84966353aeebf23d1bc00525f5056a4 Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Mon Jan 15 09:43:53 2024 +0000 Chore: Mock out faro so this test doesn't throw an error (#80477) mock out faro for all tests commit d231e271f2fcf13289b490d65bc402acb1385dd7 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 08:31:38 2024 +0000 Update dependency autoprefixer to v10.4.16 commit cfddc67a257528f892a9a8e191443d948f500df3 Author: Gábor Farkas <gabor.farkas@gmail.com> Date: Mon Jan 15 09:57:43 2024 +0100 sql: support the no-tls option in the socks proxy (#80376) postgres: support the no-tls option in the socks proxy commit 7452fe92de0be251963c354390f887378f5c15b9 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 08:00:19 2024 +0000 Update dependency @types/yargs to v17.0.32 commit 2080d49faef578cd3bc646a8c6799c6cf3e255fd Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 05:25:26 2024 +0000 Update dependency @types/webpack-env to v1.18.4 commit 6b5805dd1b52f8f369e564019c603b95287c8e2c Author: Gábor Farkas <gabor.farkas@gmail.com> Date: Mon Jan 15 08:53:43 2024 +0100 postgres: vendor in the file-exists helper (#80446) commit b4d7c484c71d2e0894021398827bfdb43a564a3a Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 01:44:10 2024 +0000 Update dependency @types/trusted-types to v2.0.7 commit cb4b9e083cd3d36698c85ec14615a5386d56b022 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Jan 15 00:16:59 2024 +0000 Update dependency @types/tinycolor2 to v1.4.6 commit 0e04c934b7c81ff5513e9fd822dee3ff510ec4b1 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Sun Jan 14 21:57:40 2024 +0000 Update dependency @types/testing-library__jest-dom to v5.14.9 commit 4482062bf314a9a27b1998f39f1b704ba617d4c6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Sun Jan 14 19:12:27 2024 +0000 Update dependency @types/string-hash to v1.1.3 commit 02300c3ae64df2573c6f650d2e02d932820a2aac Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 18:29:59 2024 +0000 Update dependency @types/react-resizable to v3.0.7 commit 4b071f54529e24a2723eedf7ca4e7e989b3bd956 Author: Yuri Tseretyan <yuriy.tseretyan@grafana.com> Date: Fri Jan 12 17:16:54 2024 -0500 Alerting: Fix MuteTiming Get API to return provenance status (#80494) commit 2fb03dfa56fe8bfe98197f1c57221dda9e0c8193 Author: Julien Duchesne <julien.duchesne@grafana.com> Date: Fri Jan 12 16:58:20 2024 -0500 fix(swagger): Mute Timing PUT OK status is 202 (#80459) commit 003f7b1581e3c6467d23d06c33356b52100668f9 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 21:08:55 2024 +0000 Update dependency @types/semver to v7.5.6 commit d76defe51790056e98e2e6d53caeee210d75569f Author: Dan Cech <dcech@grafana.com> Date: Fri Jan 12 22:18:14 2024 +0100 K8s: Move GrafanaMetaAccessor into grafana-apiserver and remove usage of kinds metadata (#79602) * move GrafanaMetaAccessor into pkg/apis, add support for Spec.Title & Spec.Name * K8s: Move GrafanaMetaAccessor (PR into another) (#79728) * access titles * remove title * remove title * remove kinds metadata accessor * remove kinds metadata accessor * fixes * error handling * fix tests --------- Co-authored-by: Ryan McKinley <ryantxu@gmail.com> commit da894994d42ee2bde00f2366fe6d30ad00b0e907 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 20:26:50 2024 +0000 Update dependency @types/redux-mock-store to v1.0.6 commit 3790454bcfda6950bfa7d31a607d25142a2bfab3 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 20:08:18 2024 +0000 Update dependency @types/react-window-infinite-loader to v1.0.9 commit bb05a6f58fde5710c5bd882607a5b28012bd8e5f Author: Ryan McKinley <ryantxu@gmail.com> Date: Fri Jan 12 12:05:30 2024 -0800 K8s: Move shared apis to a common folder with shared openapi spec (#80484) commit 81c45bfe449584dc7ff50e4fabd5d0c6568999b5 Author: Alexander Weaver <weaver.alex.d@gmail.com> Date: Fri Jan 12 14:05:04 2024 -0600 Annotations: Split cleanup into separate queries and deletes to avoid deadlocks on MySQL (#80329) * Split subquery when cleaning annotations * update comment * Raise batch size, now that we pay attention to it * Iterate in batches * Separate cancellable batch implementation to allow for multi-statement callbacks, add overload for single-statement use * Use split-out utility in outer batching loop so it respects context cancellation * guard against empty queries * Use SQL parameters * Use same approach for tags * drop unused function * Work around parameter limit on sqlite for large batches * Bulk insert test data in DB * Refactor test to customise test data creation * Add test for catching SQLITE_MAX_VARIABLE_NUMBER limit * Turn annotation cleanup test to integration tests * lint --------- Co-authored-by: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> commit f84c8f685310cc41b52831c130e700a01b225b58 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 19:28:00 2024 +0000 Update dependency @types/react-transition-group to v4.4.10 commit 05d8fd9d855ca68cd080d98e8c57c86045a45900 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 19:03:34 2024 +0000 Update dependency @types/react-test-renderer to v18.0.7 commit 4479e7218ded0048fa4b9d7a47bb79246b33cb82 Author: Yuri Tseretyan <yuriy.tseretyan@grafana.com> Date: Fri Jan 12 14:23:44 2024 -0500 Alerting: MuteTiming service return errutil + GetTiming by name (#79772) * add get mute timing by name to MuteTimingService * update get mute timing request handler to use the service method * replace validation, uniqueness and used errors with errutils * update mute timing methods return errutil responses * use the term "time interval" in errors bevause mute timings are deprecated in Alertmanager and will be replaced by time intervals in the future. * update create and update methods to return struct instead of pointer commit 11662e18b397a516bbb0c342bdfb23dd5f94922e Author: Tamás Nyíri <nyiri.tamas.nyt@gmail.com> Date: Fri Jan 12 19:59:46 2024 +0100 TimelineChart: Fix rendering of small boxes (#72775) (#80420) commit 235c747472571e028c9e54f0c3791254b30ad4fd Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 18:30:25 2024 +0000 Update dependency @types/react-table to v7.7.19 commit 77ae1a5f6f186f9eb2c5b8429bba8334a09f4cf6 Author: Armand Grillet <2117580+armandgrillet@users.noreply.github.com> Date: Fri Jan 12 18:35:31 2024 +0100 Alerting: Rename min interval field to interval in alert query editor (#80453) commit 7f3b748b294303170af7b9e75cfb05c3b69bb38e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 17:31:53 2024 +0000 Update dependency @types/react-highlight-words to v0.16.7 (#80475) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit d96cd145d7eb9b6e4e19f1c40b8cb9dcfaed88a5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 17:14:32 2024 +0000 Update dependency @types/react-color to v3.0.11 commit 4e474161a1c3da57e06d38e243eefbfe2ef84f55 Author: Sven Grossmann <sven.grossmann@grafana.com> Date: Fri Jan 12 18:19:00 2024 +0100 Logs: Add show context to dashboard panel (#80403) * Logs: Add show context to dashboard panel * add prop to enable show context toggle * update test * adjust tests * add query targets as a dependency * extract `useDatasourcesFromTargets` hook * add tests * remove comment commit e3745b5fb86e571d335336d3582da7bdc40de2c8 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 16:07:45 2024 +0000 Update dependency @types/react-beautiful-dnd to v13.1.8 commit 1129f80746165b237033c098a7547f9ab0e10973 Author: Pascal Zimmermann <pascal.zimmermann@theiotstudio.com> Date: Fri Jan 12 18:07:16 2024 +0100 docs: Adjust the update command documentation of the public dashboard endpoint (#77925) commit 289bfc070efeb734de8247ac89a98f08a4d9c189 Author: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Fri Jan 12 11:05:58 2024 -0600 Loki: Derived fields unit test flake (#79949) use async find when interacting with elements to avoid flakey 'not wrapped in act(...)' error commit c6db73f0bf910a86a1f563d55e3f489d77d3e455 Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Fri Jan 12 17:35:24 2024 +0100 Tempo: Fix regression caused by #79938 (#80465) commit 9ba56d9349ffa84bb2c5c1c8be4235e9fc0c92c9 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 16:07:19 2024 +0000 Update dependency @types/prismjs to v1.26.3 commit 66c85d9826fe45de0eeb0a634dde7d8d50477c5a Author: Jamin <okpusjamin@gmail.com> Date: Fri Jan 12 16:30:11 2024 +0000 Frontend: Migrate `LoadingIndicator.tsx` from aria-label e2e selectors to data-testid (#79664) * refactor: update component to use data-testid * refactor: update loading indicator e2e selector * oops - forgot to update betterer results * update loading indicator in variable picker --------- Co-authored-by: joshhunt <josh@trtr.co> Co-authored-by: Josh Hunt <joshhunt@users.noreply.github.com> commit a886bd3c79a417a70b51509384d1f1ec3e87e96b Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 15:56:43 2024 +0000 Update dependency @types/pluralize to ^0.0.33 (#80457) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 4ea997b595d8c68ac566fbee178e4a914f250693 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri Jan 12 16:47:27 2024 +0100 Alerting: Fix firing alerts title when showing active in Insights panel (#80414) * Fix firing alerts title when showing active alerts in Insights panel * Update red to green and change the component name to Active.tsx instead of Firing.tsx commit cb419e799b769d79239eaf0f046dec72bba3eed9 Author: idafurjes <36131195+idafurjes@users.noreply.github.com> Date: Fri Jan 12 16:43:39 2024 +0100 Remove folderid service test (#80433) * Remove FolderID from service tests * Add models * Add folderID pack to publicdashboard tests * Remove folderID from dashboard tests * Remove folderID from folders * Remove folderID from ngalert tests * Remove nolint comment * Add back some tests after rebase commit e553d4b796f9c869075464ab65c5b253d7c66d2e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 15:15:01 2024 +0000 Update dependency @types/papaparse to v5.3.14 commit 2b43977a3936df94ba444560ed8b62d07f60942f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 14:09:14 2024 +0000 Update dependency @types/node-forge to v1.3.11 commit 9f351d104183dcb15461392eaf95b4e058fd1c2a Author: Joao Silva <100691367+JoaoSilvaGrafana@users.noreply.github.com> Date: Fri Jan 12 14:45:47 2024 +0000 Timeline: Fix codeowners to point at dataviz squad (#80452) commit 941d5431f73788c7a5a414580a69c2e9d7799636 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 14:09:40 2024 +0000 Update dependency @types/ol-ext to v3.2.4 commit c196bde2e01df1b33af9d89e6c7fedf39979bf08 Author: Misi <mgyongyosi@users.noreply.github.com> Date: Fri Jan 12 15:20:50 2024 +0100 Auth: Include missing SSO settings from system settings on read paths (#80421) * first touches * Merge missing SSO settings to support Advanced Auth pages * fix commit d50abe2ea2f44476101372bb2c1c51cceeb0d5f5 Author: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Fri Jan 12 15:16:59 2024 +0100 Alerting docs: changes wrong label on configure alert state history doc (#80438) commit e471064cdaa9032cc5d65761841b8592b2643db2 Author: Giordano Ricci <me@giordanoricci.com> Date: Fri Jan 12 14:05:46 2024 +0000 Explore: use numeric ids for panel ids when building query transactions (#80135) commit cc050ca3170fc8f16b07219b4d911d37081d5e6f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 13:14:02 2024 +0000 Update dependency @types/mousetrap to v1.6.15 commit b9c616ce1d558c9322604706a5b5e87a6b6c5f5b Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 13:13:23 2024 +0000 Update dependency @types/mock-raf to v1.0.6 commit 4a5f27114233df02aaff750289734d493cf601c2 Author: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Fri Jan 12 13:22:53 2024 +0000 Scenes/PanelEdit: Replace panel when commiting editor changes (#80282) * Scenes/PanelEdit: Replace panel when commiting editor changes * Clone panel rather than setting it as body directly commit a491938dfebbcc9d2f6330bbed4e4bee2e7031b5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 12:13:00 2024 +0000 Update dependency @types/marked to v5.0.2 commit 95c7034a588536ad841598ec32975e921b43995e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 12:12:07 2024 +0000 Update dependency @types/lucene to v2.1.7 commit 3594a068a6765334475a5591c46372028832e26e Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 11:11:43 2024 +0000 Update dependency @types/lodash to v4.14.202 commit dae5430095b453fa4caf0e8ee4bd43ac83a45a84 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 11:11:15 2024 +0000 Update dependency @types/js-yaml to v4.0.9 commit aa670280fc46b2d96e3096f1395e5145c715cb44 Author: Matias Chomicki <matyax@gmail.com> Date: Fri Jan 12 12:22:03 2024 +0100 Logs: add infinite scrolling to Explore (#76348) * Explore: propose action, thunk, and decorators for load more * LogsContainer: add loadMore method * Query: remove unused var * Loading more: use navigation to simulate scrolling * Explore: figure out data combination * Fix imports * Explore: deduplicate results when using query splitting * LogsNavigation: add scroll behavior * Remove old code * Scroll: adjust delta value * Load more: remove refIds from signature We can resolve them inside Explore state * Load more: rename to loadMoreLogs * Infinite scrolling: use scrollElement to listen to scrolling events * Explore logs: add fixed height to scrollable logs container * Logs: make logs container the scrolling element * Logs: remove dynamic logs container size It works very well with 1 query, but breaks with more than 1 query or when Logs is not the last rendered panel * Logs navigation: revert changes * Infinite scroll: create component * Infinite scroll: refactor and clean up effect * Infinite scroll: support oldest first scrolling direction * Infinite scroll: support loading oldest logs in ascending and descending order * Infinite scroll: use scroll to top from logs navigation * Logs: make logs container smaller * Logs: make container smaller * State: integrate explore's loading states * Infinite scroll: add loading to effect dependency array * Infinite scroll: display message when scroll limit is reached * Infinite scroll: add support to scroll in both directions * Infinite scroll: capture wheel events for top scroll * scrollableLogsContainer: deprecate in favor of logsInfiniteScrolling * Infinite scroll: implement timerange limits * Infinite scroll: pass timezone * Fix unused variables and imports * Infinite scroll: implement timerange limits for absolute time * Infinite scroll: fix timerange limits for absolute and relative times * Infinite scroll: reset out-of-bounds message * Logs: make container taller * Line limit: use "displayed" instead of "returned" for infinite scrolling * Infinite scrolling: disable behavior when there is no scroll * Remove console log * Infinite scroll: hide limit reached message when using relative time * Logs: migrate styles to object notation * Prettier formatting * LogsModel: fix import order * Update betterer.results * Logs: remove exploreScrollableLogsContainer test * Infinite scroll: display loader * Infinite scroll: improve wheel handling * Explore: unify correlations code * Explore: move new function to helpers * Remove comment * Fix imports * Formatting * Query: add missing awaits in unit test * Logs model: add unit test * Combine frames: move code to feature/logs * Explore: move getCorrelations call back to query It was causing a weird test failure * Fix imports * Infinite scroll: parametrize scrolling threshold * Logs: fix overflow css * Infinite scroll: add basic unit test * Infinite scroll: add unit test for absolute time ranges * Formatting * Explore query: add custom interaction for scrolling * Query: move correlations before update time * Fix import in test * Update comment * Remove comment * Remove comment * Infinite scroll: report interactions from component * Fix import order * Rename action * Infinite scroll: update limit reached message * Explore logs: remove type assertion * Update betterer commit df513c870f3132f9452c0ae9b74601f32390581c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 10:27:46 2024 +0000 Update dependency @types/jest to v29.5.11 commit dfc251d5b99488e89d633b18bf96877a77aecc93 Author: Ida Štambuk <ida.stambuk@grafana.com> Date: Fri Jan 12 12:07:22 2024 +0100 Cloudwatch Variable Editor: Adjust width in new form styling (#80387) commit 7dc6a047f763599434ded453c65d7bb57511905c Author: SeamusGrafana <102023327+SeamusGrafana@users.noreply.github.com> Date: Fri Jan 12 10:36:17 2024 +0000 Auth: Update Authentik DB in DevEnv to fix expired SSL Certs (#80427) Update Authentik DB commit 900fa04c9bd703f1769ecdd9d4133f4b0471f245 Author: Dominik Prokop <dominik.prokop@grafana.com> Date: Fri Jan 12 02:24:53 2024 -0800 VariableEditorForm: Fix multi-value variable runtime error (#80425) commit d2293e0848ba02e587e7e0803789acd3e6fd0ae2 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 10:21:00 2024 +0000 Update dependency @types/is-hotkey to v0.1.10 (#80422) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit dd9b3d65d4f53d686ef7a48af3226359d64eba65 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Fri Jan 12 10:20:01 2024 +0000 Tempo: Update trace to profiles link (#80364) Update trace to profiles link commit e9f1b41d23a954efd9e7c49637e3d3dd944d0721 Author: Ivan Ortega Alba <ivanortegaalba@gmail.com> Date: Fri Jan 12 11:01:11 2024 +0100 EditVariable: Add base form to edit variables (#80172) commit e69feef31498680d5a2483f35c03310c96c2a882 Author: Giuseppe Guerra <giuseppe.guerra@grafana.com> Date: Fri Jan 12 10:57:44 2024 +0100 Devenv: Tempo: Fix metrics_generator override in config (#80270) * Devenv: Tempo: Fix metrics_generator override in config * trigger commit d3abab2896f6c2baa6f5978232693c22db7ee044 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 09:44:01 2024 +0000 Update dependency @types/hoist-non-react-statics to v3.3.5 commit 5d4ca21afa5ce2c89ec4e29747b90c953ae8afb9 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 11:56:21 2024 +0200 Update dependency @types/gtag.js to ^0.0.18 (#80418) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit ecf5c46e95bec1fff129941fa91a27a67f662358 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 09:27:53 2024 +0000 Update dependency @rollup/plugin-commonjs to v25.0.7 commit 2576f9103ce55815b92e70aa0243b89b785dda38 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 12 09:41:07 2024 +0000 Update dependency @types/google.analytics to ^0.0.45 (#80392) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 243eba228e9b15487138e6a147686dbba7e5be3b Author: Hugo Kiyodi Oshiro <hugo.oshiro@grafana.com> Date: Fri Jan 12 10:36:10 2024 +0100 Plugins: Parse defaultValues correctly for nested options (#80302) * Plugins: Parse defaultValues correctly for nested options --------- Co-authored-by: Esteban Beltran <esteban@academo.me> commit caee68b5a8a007614fef102e34ddf11af738578f Author: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Fri Jan 12 10:21:50 2024 +0100 Release: Bump version to 10.4.0-pre (#80412) "Release: Updated versions in package to 10.4.0-pre" Co-authored-by: grafana-delivery-bot[bot] <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> commit cd66149c651c5aff578693dd1d33c150ebec759b Author: Dominik Prokop <dominik.prokop@grafana.com> Date: Fri Jan 12 01:21:32 2024 -0800 DashboardScene: Handle Dashboard data source when editing panel (#79991) * Dashboard model compat wrapper update * Handle changing data source type to and from Dashboard data source * VizPanelManager tests * Betterer * DashboardModelCompatibilityWrapper tests * Review: test updates * Test updates * Test fix * Move the complexity of the dashboard data source edit directly to the ShareQueryDataProvider * Make sure deactivation handler ain't called multiple times * Make sure compat panel compat wrapper return queries for shared runner with transformations * Betterer * VizPanelManager: Remove data object subscription * Remove unnecesary code commit 74da229cd62523edea0405e0702867c9ae8fc749 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Fri Jan 12 10:13:30 2024 +0100 Loki: Remove prom utils in import/export from abstract query (#80312) commit 1807435d9e505f1d90ca6288fe69fc5c7e81c54c Author: Mihai Doarna <mihai.doarna@grafana.com> Date: Fri Jan 12 10:25:51 2024 +0200 Auth: Implement the reload functionality from OAuth social connectors (#79795) * implement Reload() func for azuread provider * add unit test for failure * use mutex when updating the info field * implement the Reload() func for the other providers * use mutex when reading info * retrieve info using GetOAuthInfo() in common file * move Reload() to SocialBase commit 39e4f8ec1bb5696aa38dd5362cf7f7d1789d0ed0 Author: Mihai Doarna <mihai.doarna@grafana.com> Date: Fri Jan 12 10:24:16 2024 +0200 Auth: configure SSO settings reload interval from the ini file (#80290) * configure sso reload interval from ini file * change section name to sso_settings commit b53e0521d2f5b89c06dd50ec09fc13ea211cfe49 Author: Leon Sorokin <leeoniya@gmail.com> Date: Fri Jan 12 01:02:40 2024 -0600 Panels: AnnotationsPlugin2 (#79531) Co-authored-by: Adela Almasan <adela.almasan@grafana.com> commit d4f76c33910e73f2ebe5727f7adc7a5bb94e7a7d Author: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu Jan 11 15:18:35 2024 -0700 Dashboard-Scene Migration: User can view JSON Mode tab (#80332) * View JSON Model tab * Use correct translation field * Extract json grabbing and formatting into a model method commit 771fc6c78d72db8c8909b733fc7db07742d91041 Author: Dan Cech <dcech@grafana.com> Date: Thu Jan 11 22:59:12 2024 +0100 Fix issue registering dashboard summary resource (#80402) commit 5ecc7dd2fa2cce26137f5553327584dbc43d7957 Author: Matthew Jacobson <matthew.jacobson@grafana.com> Date: Thu Jan 11 15:51:36 2024 -0500 Alerting: Increase size of kvstore value type for MySQL to LONGTEXT (#80331) * Alerting: Increase size of kvstore value type for MySQL to LONGTEXT alertmanager uses the kvstore to persist its notification log and the current column limit for MySQL (16.7mb) puts the maximum entries at a level that is potentially achievable for heavy alerting users (~40-80k entries). In comparison, the current type for PSQL (TEXT) is effectively unlimited and I believe SQLIte defaults to 2gb which is also plenty of leeway. commit ca9d147a44a38a0036c899e307be8aff4d8a7175 Author: Arati R <33031346+suntala@users.noreply.github.com> Date: Thu Jan 11 19:55:45 2024 +0100 Give dialects control over how insert and update queries are performed (#79946) * Refactor insert, update * Add separate insert, update methods * Refactor Insert, Update signatures commit 4e6b0fd9ceb80e1a253b12635970c35768c284eb Author: Usman Ahmad <usman.ahmad@grafana.com> Date: Thu Jan 11 19:08:00 2024 +0100 updated Grafana Open Source documentation (#80357) Added missing installation section to run Grafana on Kubernetes commit 1f334d6ca7007ca28c6b713d06b8590eeb122fa2 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 11 17:44:28 2024 +0000 Update dependency @types/file-saver to v2.0.7 commit c9ac069076aef93ba9cfcbe5ba2982d564f99e57 Author: Gabriel MABILLE <gamab@users.noreply.github.com> Date: Thu Jan 11 18:43:43 2024 +0100 RBAC: Add origin column to seed_assignment (#80326) * RBAC: Add origin column to seed_assignment * Add OnCall permission migration commit c40b2f90baa5a4a2e283952e49d1094ec1189d71 Author: Nathan Marrs <nathanielmarrs@gmail.com> Date: Thu Jan 11 10:41:19 2024 -0700 Canvas: Support context menu in panel edit mode (#80335) commit eb6420930122eee2d43617bf41668688c2f90ea2 Author: Ivan Ortega Alba <ivanortegaalba@gmail.com> Date: Thu Jan 11 17:53:06 2024 +0100 GeneralSettings: Enable support for nowDelay (#79703) commit d3ff4715a0daf1d51d86424c906151daa55d67c4 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 11 16:32:32 2024 +0000 Update dependency @types/diff to v5.0.9 commit d68bf3d715bd62d1ca7887764c818558164e5445 Author: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> Date: Thu Jan 11 08:30:35 2024 -0800 Update rasp pi install tutorial, correct APT install steps (#80236) * Update the Linux install steps * Minor updates to text commit 51efdb1f430efbe3dd9cfa1870f93e9646561660 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 11 16:13:19 2024 +0000 Update dependency @types/debounce-promise to v3.1.9 commit e054ef7c988fd16a0766baaeec8dfd97152acc81 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Thu Jan 11 16:28:59 2024 +0000 Tempo: Add template vars to embedded flamegraph (#80363) * Add template vars to embedded flamegraph * Update location of templateSrv.replace in spanFlameGraph commit 81b465111a85ed47a41955a53633a87a111a61ed Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Thu Jan 11 11:28:23 2024 -0500 Docs: replace share icon with share button (#80173) * replaced share icon with share button * Replaced share icon with share button * Made fix in reporting * trigger CI commit 75fdfafba50012c7d6f6659b6d12525ffe471bed Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 11 15:56:10 2024 +0000 Update dependency @types/common-tags to v1.8.4 commit aa73dfa735cb85e8a873555624d476423aae717e Author: Andre Pereira <adrapereira@gmail.com> Date: Thu Jan 11 16:10:16 2024 +0000 Data Trails: Sync cursor of all breakdown graphs with main metric graph (#80226) * Sync cursor of all breakdown graphs with main metric graph * Remove syncs from breakdown scene since they're not needed commit a70d48a724b23690ccb57f52183a42ed4d083dbc Author: Andreas Christou <andreas.christou@grafana.com> Date: Thu Jan 11 15:57:18 2024 +0000 Runtime: Add property for disabling caching (#80245) * Add property for disabling caching * Lint commit 95a9074ad4aa4019ba1f4726151f644eb03ff8be Author: Kevin Minehart <kmineh0151@gmail.com> Date: Thu Jan 11 09:53:02 2024 -0600 CI: Make --grafana-repo configurable via GRAFANA_REPO (#80378) Make --grafana-repo configurable via GRAFANA_REPO commit 741a5a1a26baf4dc19e8d7bf6ac9c0ba2d271135 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 11 12:22:30 2024 +0000 Update dependency @types/chance to v1.1.6 commit 7c7a0b929615f941835896a62d309204627a6a68 Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Thu Jan 11 10:46:06 2024 -0500 Docs: fix broken Cloud docs link (#80321) * Replaced relref with docs reference * Removed admonition formatting so docs ref links will work commit e1af9bcecc518efb81276511bc9337e352bcc98c Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Thu Jan 11 16:28:12 2024 +0100 Alerting: Fix group filter (#80358) * Fix group filter * Fix Warning: Receivedfor a non-boolean attribute * remove defaultQueryString.length > 3 from the logic to check if input is invalid commit 9501000856e7f60e578a850a7b721a057b28db97 Author: Kevin Minehart <kmineh0151@gmail.com> Date: Thu Jan 11 09:22:14 2024 -0600 CI: add rgm promotion pipeline (#80296) * add package upload promotion pipeline commit ced5b29951451702ab926252cfb8b5cf07bfee8a Author: Gábor Farkas <gabor.farkas@gmail.com> Date: Thu Jan 11 16:05:38 2024 +0100 Postgres: Fix enabling the socks proxy (#80361) postgres: fix enabling the socks proxy commit 5c8e88d6ab270cc82aeb5875aa16e2ae0a1a5168 Author: Hariom Maurya <harrymaurya05@gmail.com> Date: Thu Jan 11 20:22:19 2024 +0530 Tempo: Add `}` when `{` is inserted automatically (#80113) commit c9dd12851fd1d033b1c558b31f3eae82400e8b7e Author: Josh Hunt <joshhunt@users.noreply.github.com> Date: Thu Jan 11 14:50:01 2024 +0000 Chore: Temp allow verify-i18n step to fail (#80371) allow verify-i18n to fail commit 77db6a9ca4dde1454e44f8b0dd8a869b4680da62 Author: Yuri Tseretyan <yuriy.tseretyan@grafana.com> Date: Thu Jan 11 09:21:03 2024 -0500 Alerting: Fix GetAlertRulesForScheduling to use folder table and join by org_id (#80330) commit e1aa8a95d9b83a4b8b7de72998f182fcc413d629 Author: Sven Grossmann <sven.grossmann@grafana.com> Date: Thu Jan 11 15:09:13 2024 +0100 Loki: Fix bug duplicating parsed labels across multiple log lines (#80292) commit 19479195169bdc01b5feb31415dc9828ca89beda Author: Tania <10127682+undef1nd@users.noreply.github.com> Date: Thu Jan 11 15:02:13 2024 +0100 Chore: Add dashboard retrieval by FolderUID (#80288) * Add dashboard retrieval by uid Co-authored-by: Serge Zaitsev <serge.zaitsev@grafana.com> commit 12135998e61195f49e6298efdd99f3c106ca102d Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Thu Jan 11 12:54:19 2024 +0000 Profile: Use `Stack` so the profile tabs span the full width of the page (#80353) use Stack so the profile tabs span the full width of the page commit 4bf5c6365712f2d53088acb68b2904fe83df8399 Author: Mihai Doarna <mihai.doarna@grafana.com> Date: Thu Jan 11 14:45:01 2024 +0200 Auth: tidy up the database layer from the SSO Settings Service (#80341) tidy up the database layer commit dad50fbba9b03774340e43646feb8830c8b8fb8b Author: Eric Leijonmarck <eric.leijonmarck@gmail.com> Date: Thu Jan 11 12:34:53 2024 +0000 Service Accounts: Add deprecation message for apikeys (#80354) * add: deprecation message for apikeys * refactor: more strong language for service accounts commit 41144209de5043d3e53b178b7f204acc137a8f93 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 11 11:41:08 2024 +0000 Update dependency @types/angular-route to v1.7.6 commit bb2156967d0d1094136a3a939aeedbb437fb0ad2 Author: Alexander Zobnin <alexanderzobnin@gmail.com> Date: Thu Jan 11 14:48:55 2024 +0300 Team LBAC: Show permissions warning (#80215) * Permissions: Add team LBAC warnings * Replace info with warning if present * Keep info message * Update warning message * Translate permission warning * Use box component * Generate translation placeholder * Use multiple messages * Move LBAC warnings to enterprise commit cc5a573ce00d2b7b7af918d7e840de5cc09489eb Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 11 11:08:06 2024 +0000 Update dependency @types/angular to v1.8.9 commit 31b02a8c9a23ce210468b428467618f511ceb023 Author: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> Date: Thu Jan 11 12:37:21 2024 +0100 Rendering: Fix streaming panels always reaching timeout (#80022) commit 6c87d9a1e7a8223bd947414b767023657fb11bfe Author: Santiago <santiagohernandez.1997@gmail.com> Date: Thu Jan 11 12:12:35 2024 +0100 Alerting: Stop retries on 4xx status code responses (remote Alertmanager readiness check) (#80350) commit 8dba53b541acec66c08c39a700b05c985c903eb5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Jan 11 10:13:09 2024 +0000 Update dependency @testing-library/user-event to v14.5.2 commit 0f4e123de0d5d432d9c282a1031d16fc5808e7a0 Author: Ieva <ieva.vasiljeva@grafana.com> Date: Thu Jan 11 11:01:19 2024 +0000 RBAC: prioritise directly applied permissions over inherited permissions (#80212) show directly applied permissions over inherited permissions commit 310ad0474cf3a27be2f9e9d19eba306e5819f712 Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Thu Jan 11 10:57:54 2024 +0000 Chore: Update stale config to run on issues as well (#80024) * update stale config to run on issues as well * add issue write permissions commit 6d81e08e72d723d78267272b5b4c92bb024b7f39 Author: Ezequiel Victorero <ezequiel.victorero@grafana.com> Date: Thu Jan 11 07:38:14 2024 -0300 Analytics: Restore property in export json event (#80099) commit 5bfb799c0dac208b70af950d7525f5dff6c9d259 Author: Karl Persson <kalle.persson@grafana.com> Date: Thu Jan 11 11:37:52 2024 +0100 index: Fetch auth module and provide it to front-end (#80345) commit 370fd5a5af6fce85f64f0c8b42209c1da5c1e3fc Author: Alex Khomenko <Clarity-89@users.noreply.github.com> Date: Thu Jan 11 11:23:38 2024 +0100 SSO Config: Add generic OAuth (#79972) * Setup route * Set up the page * Add orgs * Load settings * Make API call * Remove log * Add FormPrompt * Update types * Add tests * Fix tests * Cleanup * Load settings * Fix naming * Switch to PUT endpoint * Switch to CSS object * Setup fields * Render fields * Extend types * Dynamic provider page * Rename page * Filter out non-implemented providers * Fix types * Add teamIDs validation * Update tests * Fix URL * Update name * Send full data * Add password input * Update test * Expand default values * Fix test * Use SecretInput * Remove dev mode for the feature toggle * Convert fields * Remove fieldFormat utils * Update fields logic * Update tests * Update betterer * SSO: Add Generic OAuth page * SSO: Add Generic OAuth page * SSO: Make client secret not required * Update field name * Revert feature toggle to dev mode * Use provider endpoint * Fix form state check * Update tests * Fix URL redirect after form submit * Mock locationService * Separate Form component * Update fields * Add more fields * Add more fields * Fix spacing * Add UserMapping fields * Add rest of the fields * Add FieldRenderer * Update types * Update comment * Update feature toggle * Add checkbox * Do not submit form if there are errors * Fix revalidation * Redirect on success only * Fix redirect behavior * Add missing descriptions * Use inline checkbox * Add enabled field * Restore feature toggle * Remove source field from PUT request * Add URL to the fields * Add hidden prop to fields and sections * Add Delete button * Prettier * Add authStyle, still not working, description updates * Fix saving select values * Run prettier * Use defaultValue in Select field --------- Co-authored-by: Mihaly Gyongyosi <mgyongyosi@users.noreply.github.com> commit ec3207a9431b1f87d148268dd0c8273915ee1ce5 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 10 20:52:51 2024 +0000 Update dependency @testing-library/dom to v9.3.4 commit 2fd26e277330b4dfcf878b7de07dbee5cf6263f5 Author: Torkel Ödegaard <torkel@grafana.com> Date: Thu Jan 11 10:33:38 2024 +0100 AppChrome: Fixes topnav height (#80342) * AppChrome: Fixes top padding * better fix commit 85d68b88cfbe81b41f286a06bec16a7408b916ae Author: Ryan McKinley <ryantxu@gmail.com> Date: Wed Jan 10 21:34:18 2024 -0800 FeatureFlags: Remove enabled from FeatureFlag model (#79960) commit 48b5ac779bd9f8b697729c968a275c5b417ca54f Author: William Wernert <william.wernert@grafana.com> Date: Wed Jan 10 18:42:35 2024 -0500 Alerting/Annotations: Add annotation backend for Loki alert state history (#78156) * Move scope type vars to testutil package * Expose parts of state historian for use in annotation backend * Implement Loki ASH Annotation store This store will only implement the `Get` method of a RepositoryImpl since alert state history writes to Loki elsewhere. * Use interface for Loki HTTP Client * Add tests for Loki ASH Annotation store * Add missing test * Fix lint * Organize tests * Add filter tests * Improve tests * Move filter logic into outer function * Fix lint * Add comment * Fix tests * Fix lint * Rename historian store + refactor * Cleanup historian store * Fix tests * Minor cleanup * Use new `ShouldRecordAnnotation` filter * Fix logic and add tests for this check * Fix typos, remove unused variables, `< 1` -> `== 0` * More closely mimic RBAC filter from xorm to ensure correct logic * Move off weaveworks client * Address PR comments commit 2c09f969f1b6c10514d1c5ccd83ba6aba01baec9 Author: Ryan McKinley <ryantxu@gmail.com> Date: Wed Jan 10 15:20:30 2024 -0800 K8s: Add dashboard service (requires dev mode) (#78565) commit be12d3919fe76f13757e9c28fd81cda2d96ac6cb Author: Matias Chomicki <matyax@gmail.com> Date: Wed Jan 10 23:52:43 2024 +0100 Logs navigation: fix multiple incorrect calls to addResultsFromCache (#80307) * Logs container: prevent unnecessary rerenders from arrow functions * Logs navigation: refactor effects and calls to addResultsToCache * Logs navigation pages: disable buttons while loading * Logs navigation: add regression test * Formatting commit 752d788bd6beb84cbacc731b94c95da886ae9486 Author: Kevin Yu <kevinwcyu@users.noreply.github.com> Date: Wed Jan 10 13:59:30 2024 -0800 Chore: rename CloudWatch mock file (#80260) commit 284afbcd4b9c4b16ddc70a3cf8b6d831369d6c70 Author: Alyssa Bull <58453566+alyssabull@users.noreply.github.com> Date: Wed Jan 10 13:58:03 2024 -0700 Azure Monitor: Add select all subscription option for ARG queries (#79582) commit afa33f12b2cf50d2c7e438f498d27b0684797778 Author: Matthew Jacobson <matthew.jacobson@grafana.com> Date: Wed Jan 10 15:52:58 2024 -0500 Alerting: Create alertingQueryOptimization feature flag for alert query optimization (#78932) * Alerting: Create feature flag for alert query optimization Adds a feature flag alertingQueryOptimization for an already existing functionality: alert query optimization. This feature flag will now be disabled by default. commit bba691777a8de5996f1280edd74d38100fba4460 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 10 19:43:56 2024 +0000 Update dependency @react-types/overlays to v3.8.4 commit 35fe96b26dead0d4e6fb42a0a7321128c80480df Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 10 18:13:41 2024 +0000 Update dependency @react-types/menu to v3.9.6 commit f365d35cf8277e825489861df69694bfd0aab288 Author: Matthew Jacobson <matthew.jacobson@grafana.com> Date: Wed Jan 10 14:40:00 2024 -0500 Alerting: Show warning when query optimized (#78751) * Alerting: Show warning when query optimized * Use frame.AppendNotices * Improve warning to include why and a prompt for action commit 31921bbb01f7546647d3c8ea183412dabc27518a Author: Leon Sorokin <leeoniya@gmail.com> Date: Wed Jan 10 12:09:54 2024 -0600 DashboardGrid: Add LayoutItemContext to affect zIndex from panels (#80116) commit 796ef05e97daafd4867bcafe6bf58bd3b0380311 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Wed Jan 10 12:23:41 2024 -0500 fix: data trails auto query use general generator for unconventional metrics (#80301) commit 7400c8c844afc829873fb972608a4472b50fe395 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 10 17:04:30 2024 +0000 Update dependency @react-types/button to v3.9.1 commit 119df0c5cea2978307052c8fc7866e6b1b419bb6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 10 17:04:03 2024 +0000 Update dependency @react-awesome-query-builder/ui to v6.4.2 commit d0ccfc0a7b588a1474601a6d9afaa33a1b58d945 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 10 16:39:31 2024 +0000 Update dependency @react-awesome-query-builder/core to v6.4.2 commit 0e8c81d3b938722699e25eb43eb1b057569e7e62 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 10 16:43:03 2024 +0000 Update dependency @lezer/common to v1.2.0 (#75434) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 86fae6018e200c063a908f5da0e8ae2f6d472d78 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 10 18:36:12 2024 +0200 Update dependency @pmmmwh/react-refresh-webpack-plugin to v0.5.11 (#80300) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit c959b5549c5e14e932619d60c8e871d19fc93a85 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 10 18:17:54 2024 +0200 Update typescript-eslint monorepo to v6 (#71634) * Update typescript-eslint monorepo to v6 * move to latest eslint-config-grafana --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com> commit 744c1032eed0f1a36171db07048e49522ec22842 Author: Tania <10127682+undef1nd@users.noreply.github.com> Date: Wed Jan 10 16:48:28 2024 +0100 Provisioning: Fix dual write of folders (#80140) * Provisioning: Store folders in folders table * Solve linting issues * Remove a comment commit 6be672443391e0c856b099c6bbb1cd44ec0c3a06 Author: Ryan McKinley <ryantxu@gmail.com> Date: Wed Jan 10 07:45:23 2024 -0800 K8s/Testdata: Expose testdata in standalone apiserver (#80248) commit 42f1059bc95a9e146f73a9ab617d971bdaa61a73 Author: Andres Martinez Gotor <andres.martinez@grafana.com> Date: Wed Jan 10 16:38:36 2024 +0100 Chore: Avoid copying package.json into dist folder (#80278) commit ee7daeb2a73ba05ed82e33085b6800da2793bd10 Author: Ryan McKinley <ryantxu@gmail.com> Date: Wed Jan 10 07:30:16 2024 -0800 APIServer: Move shared code to a utility/helper function (#80261) commit 4f832c4c69f4f70b08a185010235d252118b773c Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Wed Jan 10 15:42:04 2024 +0100 Build: Update plugin IDs list in build and release process (#80160) commit 153767a78765d2506a90aae6f9b99ee8fa2b827a Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 10 11:29:52 2024 +0000 Update dependency @leeoniya/ufuzzy to v1.0.14 commit 930efc3824bf05d94ddb5dcaf03933ffe723374e Author: Mihai Doarna <mihai.doarna@grafana.com> Date: Wed Jan 10 16:03:03 2024 +0200 Auth: Add docs for the SSO Settings update endpoint (#79980) add docs for sso settings update endpoint commit 772e5993b6da81f81da0cece0940ae009cfba6bc Author: Mihai Doarna <mihai.doarna@grafana.com> Date: Wed Jan 10 16:01:37 2024 +0200 Auth: reload SSO settings for HA setups (#80231) * reload SSO settings for HA setups * remove check for grafana HA * add unit tests * fetch all sso settings with one sql query * register background service commit 02136e5a2feac6d20df25083788a42cf27375017 Author: Jo <joao.guerreiro@grafana.com> Date: Wed Jan 10 14:50:08 2024 +0100 Auth: Fix authentik devenv update (#80285) fix authentik update commit 5b3cd9f55b292f0b693f3bb5171da7b69dac4a95 Author: Karl Persson <kalle.persson@grafana.com> Date: Wed Jan 10 14:19:01 2024 +0100 features: Add feature flag for grafana cloud rbac roles (#80283) commit 71b98163e52306c5da36b243bd74e74f634920a0 Author: Jamin <okpusjamin@gmail.com> Date: Wed Jan 10 11:51:16 2024 +0000 Frontend: Migrate `DataSourceHttpSettings.tsx` from aria-label e2e selectors to data-testid (#79615) commit 642391c9f991c654fa67a84a1cdf27863868ccda Author: Johannes Ehm <johannes.ehm@gmail.com> Date: Wed Jan 10 12:49:38 2024 +0100 Alerting: adds execErrState to the alerting file provisioning example as it is missing (#79002) commit 5c3723e8097b210f3b3e5406b0d3a989eacc8078 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 10 11:15:28 2024 +0000 Update dependency @grafana/scenes to v1.28.6 commit b40d3e748717961ef68457442d71c1284189c577 Author: Giuseppe Guerra <giuseppe.guerra@grafana.com> Date: Wed Jan 10 12:25:54 2024 +0100 Plugins: Add enablePluginsTracingByDefault feature flag (#80195) * Add enablePluginsTracingByDefault feature flag * Enable tracing for all plugins if enablePluginsTracingByDefault is set * fix docstrings for IsEnabled and IsEnabledGlobally * fix tests * do not use separate feature manager * add test case * Revert "fix tests" This reverts commit 46a2420ed1576fac207676030079b44227065a3c. * cleanup * fix plugin tracing disabled if wrong plugin setting is present * add test case for enabled on plugin with wrong plugin setting but with enablePluginsTracingByDefault feature flag * Add RequiresRestart = true to enablePluginsTracingByDefault * re-generate feature flags * pr review feedback commit 108c196d08cc685d2e6bb68bf6df6652d37bf594 Author: Jamin <okpusjamin@gmail.com> Date: Wed Jan 10 11:20:51 2024 +0000 Frontend: Migrate `Drawer.tsx` from aria-label e2e selectors to data-testid (#79616) * refactor: update aria-label to data-testid * refactor: update aria-label to data-testid * refactor: replace title * refactor: update selector text * feat: update translation file * fix old phrases --------- Co-authored-by: joshhunt <josh@trtr.co> commit d768362a5d9247d2da8fdeeb61ebe6322fff9c5f Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 10 11:12:01 2024 +0000 Update dependency @grafana/google-sdk to v0.1.2 (#80277) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 90e4a53288449ed7174ee844022ac9fe3017a571 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Jan 10 10:52:41 2024 +0000 Update dependency @floating-ui/react to v0.26.5 (#80273) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 898f837662d9fcab2788c740ed10b8e9200d32de Author: Denis <7009699+someden@users.noreply.github.com> Date: Wed Jan 10 13:24:55 2024 +0300 Docs: add description for timepicker fields (#79527) * Docs: add description for timepicker fields * Update docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md Co-authored-by: Alexa V <239999+axelavargas@users.noreply.github.com> * prettify --------- Co-authored-by: Alexa V <239999+axelavargas@users.noreply.github.com> commit e1746df794c7b8f88865ab94b81a7ed143824fd0 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 9 16:23:27 2024 +0000 Update d3 commit 9e78faa7ba4a61d4bd86548ca817d79e351f9900 Author: Santiago <santiagohernandez.1997@gmail.com> Date: Wed Jan 10 11:18:24 2024 +0100 Alerting: Add metrics to the remote Alertmanager struct (#79835) * Alerting: Add metrics to the remote Alertmanager struct * rephrase http_requests_failed description * make linter happy * remove unnecessary metrics * extract timed client to separate package * use histogram collector from dskit * remove weaveworks dependency * capture metrics for all requests to the remote Alertmanager (both clients) * use the timed client in the MimirAuthRoundTripper * HTTPRequestsDuration -> HTTPRequestDuration, clean up mimir client factory function * refactor * less git diff * gauge for last readiness check in seconds * initialize LastReadinesCheck to 0, tweak metric names and descriptions * add counters for sync attempts/errors * last config sync and last state sync timestamps (gauges) * change latency metric name * metric for remote Alertmanager mode * code review comments * move label constants to metrics package commit 1162c28a5588b9c7ee39236eaafcafd1f094da40 Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Wed Jan 10 09:57:17 2024 +0000 Connections: hook up search to url params to match cloud behaviour (#80166) hook up connections search to url params to match cloud behaviour commit 2d6ad9f7c53351af998765492a7523854da72d9c Author: Kristian Bremberg <114284895+KristianGrafana@users.noreply.github.com> Date: Wed Jan 10 10:31:35 2024 +0100 Login: convert scheme relative URL to path (#80220) * Login: convert scheme relative URL to path * linting commit 282a3f9a66c8ee20691cc4cfa8940e2ec2b0a3a8 Author: Konrad Lalik <konrad.lalik@grafana.com> Date: Wed Jan 10 10:17:57 2024 +0100 Alerting: Allow copying error messages from query badges (#80078) * Add interactive mode to the Badge component * Add expression errors as Alert components instead of badges * Revert "Add interactive mode to the Badge component" This reverts commit 9558743fc7098008a6732f23b7d65de2419494cd. commit d738b96742674ec6818526664f76436ec45c0731 Author: Esteban Beltran <academo@users.noreply.github.com> Date: Wed Jan 10 09:27:22 2024 +0100 Chore: Fix levitate workflow to not post a comment when there's no breaking change (#80266) commit 4fa6bad7c03948cd648f784283bc60d7b2199ad3 Author: Isabella Siu <Isabella.siu@grafana.com> Date: Tue Jan 9 16:52:37 2024 -0500 CloudWatch: Remove dependency on local TemplateSrv (#79283) commit f3bb16c598bb2f650ad9f53f3af6141b963b2e53 Author: Marie Cruz <mdcruz@users.noreply.github.com> Date: Tue Jan 9 21:33:12 2024 +0000 docs: add grafana video to install grafana page (#80237) commit 53411eeaa0e999516a0398f78d68fec0cb2c5d1f Author: Ryan McKinley <ryantxu@gmail.com> Date: Tue Jan 9 12:26:24 2024 -0800 K8s: Expose testdata connection as an api-server (dev mode only) (#79726) commit 5589c0a8f2c494b951bc4392be23da4c342301cd Author: Pangidoan Butar <38452094+doanbutar@users.noreply.github.com> Date: Wed Jan 10 04:07:40 2024 +0800 Updating swagger endpoint (#80008) The user should navigate to /swagger-ui instead of /swagger commit 1d4419fbe497ea4f3b66364a5840cbdb643d4b04 Author: Matthew Jacobson <matthew.jacobson@grafana.com> Date: Tue Jan 9 14:47:19 2024 -0500 Alerting: Fix NoData & Error alerts not resolving when rule is reset (#80184) * Alerting: Fix NoData & Error alerts not resolving when rule is reset On rule reset, when creating the PostableAlerts StateToPostableAlert did not attach the correct NoData/Error alertname and rulename labels to expire/resolve the active alerts when the previous cached state was NoData/Error. commit 542741f74868b5b587fc9ceb65fcae97fc4f3caa Author: Alexander Weaver <weaver.alex.d@gmail.com> Date: Tue Jan 9 13:19:37 2024 -0600 Alerting: Log scheduler maxAttempts, guard against invalid retry counts, log retry errors (#80234) * Log maxAttempts, add guard, log retry errors * fix whitespace * Initialize evaluator in TestProcessTicks commit 1caaa56de07a46103db2cfd787737a5491bf1db5 Author: Ryan McKinley <ryantxu@gmail.com> Date: Tue Jan 9 10:38:06 2024 -0800 FeatureFlags: Use interface rather than manager (#80000) commit e550829dae13edcb45cb8fad3f0d9833529f3f49 Author: Nathan Marrs <nathanielmarrs@gmail.com> Date: Tue Jan 9 11:09:03 2024 -0700 Docs: Update canvas pan and zoom docs with enablement video (#80108) commit 458bfb8d012692cd1cc0b53bc38d7c5b8da90abf Author: lwandz13 <126723338+lwandz13@users.noreply.github.com> Date: Tue Jan 9 10:35:22 2024 -0600 Docs: Clarify cloud auth method (#80176) added some clarification around auth method for cloud commit 114845a99a47ff7aa1593f69190d369150f9d70d Author: Ryan McKinley <ryantxu@gmail.com> Date: Tue Jan 9 08:24:16 2024 -0800 Transformations: Use an explicit join seperator when converting from an array to string field (#80169) commit 3c045d1dfb39c4ec7576b4a50bb2e49808b13551 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 9 18:20:08 2024 +0200 Update dependency @emotion/react to v11.11.3 (#80230) * Update dependency @emotion/react to v11.11.3 * fix types --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com> commit 1cefa419c0efe25fadf1d5e9b6ca1757e0cb0e1c Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 9 15:54:57 2024 +0000 Update babel monorepo (#80214) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit a449df34553ca03ef72e8dc17cec1229d1778ae6 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Tue Jan 9 15:32:24 2024 +0000 Update dependency @cypress/webpack-preprocessor to v6.0.1 (#80217) * Update dependency @cypress/webpack-preprocessor to v6.0.1 * fix type errors * restore old checksum for scenes --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com> commit 35e3988d4a0e4bc2931cd36e20ef419ed72ab2ff Author: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Tue Jan 9 17:26:46 2024 +0200 Add versions tab in dashboard settings (#80190) commit df204a9563a842a9837fbe67e03cca07ae0482fc Author: Sven Grossmann <sven.grossmann@grafana.com> Date: Tue Jan 9 16:07:04 2024 +0100 Loki: Add documentation for `label` derived field (#80218) commit ecc667c9a55d35fe4240e7609724365e2e6a708d Author: Gábor Farkas <gabor.farkas@gmail.com> Date: Tue Jan 9 15:54:21 2024 +0100 postgres: add snapshot tests (#79794) * postgres: add snapshot tests * fixed wrong comment Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com> --------- Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com> commit 3332562900bdebd9ee9abc65b76ee8c91b477b5f Author: Mihai Doarna <mihai.doarna@grafana.com> Date: Tue Jan 9 15:27:55 2024 +0200 Auth: use Empty() for sending empty http responses in SSO Settings API (#80200) use Empty() for sending empty http response commit c045a9f3954f34d75e1c155fc0935e07eb5ad905 Author: Giuseppe Guerra <giuseppe.guerra@grafana.com> Date: Tue Jan 9 14:23:03 2024 +0100 Plugins: Fix symlinks inside plugins path not being followed (#80205) * Plugins: Loader: Fix symlinks not followed when loading external plugins * Add test case commit 314cdaf618cf54ba46b8b3109475196aeb3c495f Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Tue Jan 9 11:40:39 2024 +0000 Pyroscope: Decouple frontend (#80066) * Decouple query options * Decouple Variable support * Fix after merge commit 58f4533382b854ced975579e55f284da38fc5f54 Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Tue Jan 9 11:00:46 2024 +0000 Chore: Better renovate config (#80202) * better config * allow react-hook-form updates commit 0f8d4db934a0428afc1e6142be3b32a3f9b45b20 Author: Sven Grossmann <sven.grossmann@grafana.com> Date: Tue Jan 9 11:56:48 2024 +0100 Log Context: Add highlighted words to log rows (#80119) * Log Context: Add highlighted words to log rows * Update public/app/features/logs/components/log-context/LogRowContextModal.tsx Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> --------- Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> commit 40e4d3deb5c7cef2cc245563d6b9a1e8ad360961 Author: Ezequiel Victorero <ezequiel.victorero@grafana.com> Date: Tue Jan 9 07:43:31 2024 -0300 UI: Hide additional separator when time picker is hidden (#80168) UI: Hide additional separator when time range is hidden commit 76ca1c63796ecdd421112e1d3d79dc7fa3be3fae Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Tue Jan 9 10:35:16 2024 +0000 Chore: Update lockfile resolution to newer version of `follow-redirects` (#80199) update lockfile resolution to newer version of follow-redirects commit adb7295babaac378b3cf09d45b0ad5459af949f6 Author: Mihai Doarna <mihai.doarna@grafana.com> Date: Tue Jan 9 12:14:39 2024 +0200 Auth: Send an empty http response without the json header in SSO Settings API (#80197) send an empty http response without the json header commit 229c1f417f7d1af17b5efd17cc4864e997b020de Author: Konrad Lalik <konrad.lalik@grafana.com> Date: Tue Jan 9 11:08:28 2024 +0100 Alerting: Disable export all rules button when no GMA rules (#80126) Disable export button when no GMA rules commit cb43246fcb843022bd40a62a7fcbef0da06c3ed8 Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Tue Jan 9 10:06:57 2024 +0000 Chore: update renovate config to automerge >v1 patch updates (#80092) update renovate config to automerge >v1 patch updates commit 68ba6cc67bd9475c6a4060a90b849d3aa18153a1 Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Tue Jan 9 10:00:00 2024 +0000 Chore: some type fixes (#80094) * some type fixes * few more fixes * more * fix unit test commit fb34916d1e126e8ff86803481eb1ab8ee16617fc Author: Matias Chomicki <matyax@gmail.com> Date: Tue Jan 9 10:49:27 2024 +0100 Logs navigation: derive current page index (#80167) * Logs navigation: derive current page index * Formatting commit 6f8ddac4eb425810fd04cc9b98ef904473be8404 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Tue Jan 9 08:39:43 2024 +0100 Alerting: Simplified routing part3 (#79941) * Update alert rule model in FE following BE design doc * Remove unnecessary conditional rendering * Update styles for optional route settings: add indentation * Update test * Add validation for grouBy to include grafana_folder and alertname * Split conversions between FEdataModel/ DTO, in separate functions * Update texts following Brenda's suggestions * Update text commit 48612063dd4cf9b3103aa2a51f51282a195b990a Author: Charandas <charandas@users.noreply.github.com> Date: Mon Jan 8 21:33:42 2024 +0100 Grafana app platform: an aggregator cmd and package (#79948) commit 26f54a2fc76f21b3cd57537b5fb9c8a76b8efce7 Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Mon Jan 8 13:36:48 2024 -0600 VizTooltips: Add wrapper shadow (#80164) commit 49af9926611086cf90bec01b1882ca70474305fa Author: Lucy Chen <140550297+lucychen-grafana@users.noreply.github.com> Date: Mon Jan 8 13:30:01 2024 -0500 Task: Add i18n support for public dashboards (#79659) * feat(public-dashboards): add i18n support * fix(public-dashboard): correct name convention for i18n * fix(public-dashboard): correct i18n key convention + extraction * feat(public-dashboard): mark up i18n for remaining cfg modal * fix(public-dashboard): de-dynamicize ack comps for i18n + markup i18n for missing parts * feat(public-dashboard): mark up i18n for DeletePubDashModal * feat(public-dashboard): mark up i18n for ShareModal public dashboard * chore(i18n): run yarn i18n:extract * update naming cconvention * add mark up phrases * update json file * fix import * fix title * fix url * Copy button translation * Update user admin page * escape char * interpolation * fix escape * prettier space * update naming * update setting keys * standardize key naming convention * fix radiobutton * Fix naming convention as recommended by frontend team * Prettier and fix naming * fix variables that cannot be translated * prettier check --------- Co-authored-by: hainenber <dotronghai96@gmail.com> commit df8624c8cd9ad4f8e75844ba46520a7e57d04680 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Mon Jan 8 18:43:59 2024 +0100 Loki: Remove imports from core in various files (#79990) * Remove imports from core in tracking.ts * Remove imports from core in tracking.test.ts * Remove imports from core in querySplitting.test.ts commit 6fbd6e3d310f530b938101f58c9e88efb6f9fdb2 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Mon Jan 8 18:34:49 2024 +0100 ConfigDescriptionLink: Replace with component from `@grafana/experimental` (#80144) ConfigDescriptionLink: Replace with component from grafana/experimental commit 3dc7cfdc18114f653587b7a24b4eeb490a4eb83f Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Mon Jan 8 18:32:16 2024 +0100 Loki: Implement error source (#80143) commit 495093540198cbcd7363f472dcac5b9c0ddce77f Author: Matias Chomicki <matyax@gmail.com> Date: Mon Jan 8 17:17:19 2024 +0100 Loki Query Builder: Add parsing support for aggregations with grouping (#80145) Loki Query Builder: Add parsing support for aggregations with grouping arguments commit c60a1dddc20508f8849e5c4749f3736b75ac836e Author: Jo <joao.guerreiro@grafana.com> Date: Mon Jan 8 17:06:06 2024 +0100 Analytics: Fix metanalytics sending 'undefined' to backend (#80127) * fix metanalytics sending 'undefined' * revert panelId defined * Update public/app/features/query/state/queryAnalytics.ts Co-authored-by: Ivan Ortega Alba <ivanortegaalba@gmail.com> * finish fix --------- Co-authored-by: Ivan Ortega Alba <ivanortegaalba@gmail.com> commit cff1ad4922cb9738f6673a645a204c19d12cdbc1 Author: Matias Chomicki <matyax@gmail.com> Date: Mon Jan 8 17:03:39 2024 +0100 Logs popover: enabled by default (#80146) commit c3d8c6e0eac880ab773ec812e393965d24c8998d Author: Jo <joao.guerreiro@grafana.com> Date: Mon Jan 8 16:02:31 2024 +0100 JWT: Fallthrough to fetch keyset in case of cache error (#80081) fallthrough to fetch keyset in case of cache error. Fixes #67582 commit d2b023076b3377d92e1578b9a54570e538867db8 Author: Gilles De Mey <gilles.de.mey@gmail.com> Date: Mon Jan 8 15:48:36 2024 +0100 Alerting: Add support for TTL for pushover for Mimir Alertmanager (#78687) commit 890d6a960f47a53d246e27c3bd95cb251990b3b1 Author: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Mon Jan 8 09:48:08 2024 -0500 refactor: data-trails auto query logic (#79435) * refactor: data-trails auto query logic for most currently identified metric suffixes (excluding `_bucket`) commit 062e772bb2ee7c803c7ba920b2b7d428a99f3651 Author: colin-stuart <colindonstuart@gmail.com> Date: Mon Jan 8 09:35:14 2024 -0500 Auth: Implement the SSO Settings GET endpoint (#79144) * Return data in camelCase from the OAuth fb strategy * changes * wip * Add defaults for oauth fb strategy * revert other changes * basic includeDefaults query param implementation * basic secret removal and etag implementation * correct imports * rebase * move default settings filter to models * only replace ClientSecret value if set * first GetForProvider test & use FNV for ETag to avoid Blocklisted import error * add tests * add annotation for the openapi spec & generate spec * remove TODO * use IsSecret, improve tests, remove DefaultOAuthSettings * add comment explaining generateFNVETag * add error handling for generateFNVETag * run go generate * Update pkg/services/ssosettings/api/api.go Co-authored-by: Mihai Doarna <mihai.doarna@grafana.com> * move isSecret to service, create GetForProviderWithRedactedSecrets func * add unit test for GetForProviderWithRedactedSecrets & remove duplicated code * regen openapi/swagger * revert dependency bumps --------- Co-authored-by: Mihaly Gyongyosi <mgyongyosi@users.noreply.github.com> Co-authored-by: Mihai Doarna <mihai.doarna@grafana.com> commit 505196bcd5043d57355e7134f89963c71b54d969 Author: Adam Bannach <113929542+abannachGrafana@users.noreply.github.com> Date: Mon Jan 8 08:25:11 2024 -0600 Chore: Remove costManagementUi toggle (#80098) * chore: remove cost management ff; fallback to adding AM and LVE to apps drawer * chore: revert fallback app drawer placement commit 21e9c01fc1e5568828c09e879c3e65aaf416c56a Author: Giordano Ricci <me@giordanoricci.com> Date: Mon Jan 8 14:11:05 2024 +0000 Chore: Fix some Explore deprecations (#80076) commit 25ff4baa76899108cf2ce5f5f9f36755df5754cd Author: Ezequiel Victorero <ezequiel.victorero@grafana.com> Date: Mon Jan 8 10:42:24 2024 -0300 UI: New share button and toolbar reorganize (#77563) commit eae6adf0029328bcd3a9ce9fec29932c5511459c Author: Misi <mgyongyosi@users.noreply.github.com> Date: Mon Jan 8 14:36:15 2024 +0100 Auth: Use cfg.Raw in OAuthStrategy for loading settings (#80136) Use cfg.Raw in OAuthStrategy, remove unnecessary tests commit 6438523b41da4b27c86bb89edad4886a93e90eb6 Author: Timur Olzhabayev <timur.olzhabayev@grafana.com> Date: Mon Jan 8 14:32:46 2024 +0100 Chore: Switching some go dependencie to the correct owner - partner-datasources squad (#77776) * Switching dependencie to the correct owner * switching storrage-blog-go back to backend platform * reverting some of the mod assignements * reverting some of the changes * not sure how it got changed, but switching this one back commit 81f8c022a0c0ade1643cf02fab33311586e9d11f Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Mon Jan 8 13:11:46 2024 +0000 Pyroscope: Decouple backend from infra imports (#80018) Decouple backend from infra imports commit 6c8e30c96af8738b6936aad9b2eab346000e6b13 Author: Kévin Gomez <kevin.gomez@grafana.com> Date: Mon Jan 8 14:05:26 2024 +0100 Kinds: publish grafana-schema/common to the kind-registry (#78728) commit 9d31720db5b1d915bedb0a706ebfb0277c619f45 Author: Kévin Gomez <kevin.gomez@grafana.com> Date: Mon Jan 8 14:00:30 2024 +0100 Chore: set @grafana/platform-cat as owner of kind-registry related scripts (#79304) Chore: set ownership of kind-registry release actions to @grafana/platform-cat commit d1b0e9082dce87642b3d9bd18abd9430b509c8fa Author: Sven Grossmann <sven.grossmann@grafana.com> Date: Mon Jan 8 13:26:16 2024 +0100 Loki: Fix metric time splitting to split starting with the start time (#80085) * Loki: Fix metric time splitting to split starting with the start time * add test case to gdev-dashboard * fix splitting test commit f0cb88e3b5c6760289e3e6a8d0c6e6b6fd263f6c Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Mon Jan 8 12:07:19 2024 +0000 Pyroscope: Add Span ID to options collapsed info (#79981) * Pyroscope: Add Span ID to options collapsed info * Update for multiple * Update public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx Co-authored-by: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> --------- Co-authored-by: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> commit 78ae795e069d3d9093b4c4ae2e8f9e638598bb3b Author: Will Browne <wbrowne@users.noreply.github.com> Date: Mon Jan 8 11:45:03 2024 +0100 Plugins: Fix loading of dist folders (#80015) * end to end * tidy * fix whitespace * remove unused code * fix linter * fix gosec + add sort * fix test * apply cr feedback commit 0440b29ebf684b99a78aac6215bde87c8d910ef4 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Mon Jan 8 10:29:08 2024 +0100 Chore: Bump `@grafana/experimental` from 1.7.4 to 1.7.5 (#80074) * For LLM, replace enabled() with health() * For LLM, replace enabled() with health() * Update experimental in azueremonitor and testdata plugins commit 6fd0ae0474bd76149d190ef426bc3a32e931f89e Author: Konrad Lalik <konrad.lalik@grafana.com> Date: Mon Jan 8 10:16:37 2024 +0100 Alerting: Fix alertmanager query param when returning to silences list (#80021) * Add alertmanager query param when returning to silences list. Sync query and storage alertmanager param * Remove unused imports commit 9cdd519e5357f09b8487147b7700df0d877c0ae8 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Sun Jan 7 12:51:21 2024 +0100 Loki: Use localStorage to set and get showExplain (#80069) * Loki: Use localStorage to set and get showExplain * Move hooks outside of shared folder commit 2ff1e40a7f7c896a72b9c6506d027aa8b7826cee Author: Nathan Marrs <nathanielmarrs@gmail.com> Date: Fri Jan 5 18:40:11 2024 -0700 chore: Update DataViz feature toggles related to 10.3 release to be public preview (#80109) Update feature toggles for tooltips / canvas to be public preview commit aa03b8f8a7e07054a1747216778712e303520970 Author: Matthew Jacobson <matthew.jacobson@grafana.com> Date: Fri Jan 5 18:19:12 2024 -0500 Alerting: Guided legacy alerting upgrade dry-run (#80071) This PR has two steps that together create a functional dry-run capability for the migration. By enabling the feature flag alertingPreviewUpgrade when on legacy alerting it will: a. Allow all Grafana Alerting background services except for the scheduler to start (multiorg alertmanager, state manager, routes, …). b. Allow the UI to show Grafana Alerting pages alongside legacy ones (with appropriate in-app warnings that UA is not actually running). c. Show a new “Alerting Upgrade” page and register associated /api/v1/upgrade endpoints that will allow the user to upgrade their organization live without restart and present a summary of the upgrade in a table. commit 72182e02a4b5dbff3f0176cf85643e6aaf16b117 Author: Yuri Tseretyan <yuriy.tseretyan@grafana.com> Date: Fri Jan 5 17:26:15 2024 -0500 Alerting: Mute timing service tests (#79817) split tests for mute timing service to functions for each method this makes it clear the scope of tests commit 200c71f5d60fc605024d18617caa5ffd29680116 Author: Leon Sorokin <leeoniya@gmail.com> Date: Fri Jan 5 16:07:04 2024 -0600 VizTooltips: Optimize performance (#80102) commit 90fb6a01222c35104dee73bbcce3a3d649429c19 Author: Arati R <33031346+suntala@users.noreply.github.com> Date: Fri Jan 5 22:44:34 2024 +0100 Update unified storage readme (#79934) * Update unified storage readme Co-authored-by: Dan Cech <dcech@grafana.com> commit 494f36e0bd7c7903b40ef8bfbfbd72dc786adfcf Author: Yuri Tseretyan <yuriy.tseretyan@grafana.com> Date: Fri Jan 5 16:15:18 2024 -0500 Alerting: Update provisioning services that handle Alertmanager configuraiton to access config via storage (#79814) * extract get and save operations to a alertmanagerConfigStore. this removes duplicated code in service (currently only mute timings) and improves testing * replace generic errors with errutils one with better messages. * update provisioning services to use new store --------- Co-authored-by: Alexander Weaver <weaver.alex.d@gmail.com> commit 8dc04ea63abc9bbe816318ad7cbb42553e9b9247 Author: Isabella Siu <Isabella.siu@grafana.com> Date: Fri Jan 5 14:56:30 2024 -0500 AWS Datasources: Enable awsAsyncQueryCaching by default (#80045) commit 1ec04243dabcce54c3e3f21b4c10247e5cf83428 Author: Leon Sorokin <leeoniya@gmail.com> Date: Fri Jan 5 13:11:24 2024 -0600 Heatmap: All tooltip mode selector (#79956) Co-authored-by: Adela Almasan <adela.almasan@grafana.com> Co-authored-by: nmarrs <nathanielmarrs@gmail.com> commit 583b9797afbc2dbfe36b325c7b61422d70472aac Author: Oscar Kilhed <oscar.kilhed@grafana.com> Date: Fri Jan 5 19:39:02 2024 +0100 Transformations: Move moving average, regression analysis and format string transformations to public preview (#80067) * move transformation features to public preview * update docs * Update docs/sources/panels-visualizations/query-transform-data/transform-data/index.md Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> * Update docs/sources/panels-visualizations/query-transform-data/transform-data/index.md Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> * Update docs/sources/panels-visualizations/query-transform-data/transform-data/index.md Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> * fix formating of ffs --------- Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> commit 701895ed3c5357e10fda6001260221a2d02b3e70 Author: Nathan Marrs <nathanielmarrs@gmail.com> Date: Fri Jan 5 11:34:11 2024 -0700 Deprecation: Add missing angular panels to migration gdev for better testing / tracking (#80052) add piechart + worldmap to panel migrations gdev dashboard commit 49891d6a72537c869628a13ab75c8d616fbac590 Author: Matthew Jacobson <matthew.jacobson@grafana.com> Date: Fri Jan 5 13:31:05 2024 -0500 Alerting: Add feature flag alertingPreviewUpgrade for migration preview + dry-run (#80036) * Add feature flag * Remove from docs for now commit 3a966fc6caeb9f9b4895330de9ccda38b8f19c7b Author: Yuri Tseretyan <yuriy.tseretyan@grafana.com> Date: Fri Jan 5 12:59:41 2024 -0500 Alerting: Enable recovery threshold feature by default (#80088) commit 3609dbd0a2112438e5ebea31ec864c0e02f33850 Author: Nathan Marrs <nathanielmarrs@gmail.com> Date: Fri Jan 5 09:58:47 2024 -0700 Barchart: Fix percent stacking regression (#79903) Co-authored-by: Leon Sorokin <leeoniya@gmail.com> commit c12b125bb15edbe3c0c68c08701d2d177b1f2ec0 Author: b murphy <howsyouredge@gmail.com> Date: Fri Jan 5 16:40:11 2024 +0000 Docs: Update to US English per Writers Toolkit, plus clean up some grammar (#76298) Co-authored-by: Jack Baldry <jack.baldry@grafana.com> Co-authored-by: tonypowa <45235678+tonypowa@users.noreply.github.com> commit 4623b499472895543a4b0937da75385ae495844f Author: Alexander Weaver <weaver.alex.d@gmail.com> Date: Fri Jan 5 10:24:04 2024 -0600 Drop weaveworks/common dependency (#80090) Drop weaveworks/common commit c48e2c7d0dc658b01ddda033970b27d0c1397747 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Fri Jan 5 17:11:58 2024 +0100 Loki: Update mocks and move into __mocks__ (#79993) Loki: Update mocks and move into __mocks__ commit a8fb01a5027d73e85be13611901b16980c9a6e45 Author: Alexander Weaver <weaver.alex.d@gmail.com> Date: Fri Jan 5 10:08:38 2024 -0600 Swap weaveworks/common utilities for equivalents in grafana/dskit (#80051) * Replace histogram collector and grpc injectors * Extract request timing utility * Also vendor test file * Suppress erroneous linter warn commit 45f157e5db304cf82e40557cd19e517e07f78dc1 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Fri Jan 5 16:54:46 2024 +0100 Loki: Fix import of escapeLabelValueInExactSelector to be from Loki (#80063) * Loki: Fix import of escapeLabelValueInExactSelector to be from Loki * Add test * Update test nane commit c3934ba60b4dc95c41809ca757e0218596d1de6a Author: Giordano Ricci <me@giordanoricci.com> Date: Fri Jan 5 15:10:31 2024 +0000 Explore: Preserve time range when creating a dashboard panel from Explore (#80070) * Explore: Preserve time range when creating a dashboard panel from Explore * fix tests commit 41eff02d7505078efb6a1d53a2fdc4a7e76e8687 Author: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> Date: Fri Jan 5 15:57:13 2024 +0100 Docs: Add table data in PDF (#80059) * Docs: Add table data in PDF * fix lint issues * Switch to public preview * Apply suggestions from code review Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> --------- Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> commit f5a221e93fae66bf137626ce976732bb38f4949f Author: Oscar Kilhed <oscar.kilhed@grafana.com> Date: Fri Jan 5 15:50:20 2024 +0100 Transformations: Fix bug where having NaN in the input to regression analysis transformation causes all predictions to be NaN (#80079) Make regression analysis transformation handle NaN commit 2563b7b33031b4a18d00c4a0f3e87b26a6004fde Author: Trần Hoàng Việt <54036989+HoangViet144@users.noreply.github.com> Date: Fri Jan 5 21:42:11 2024 +0700 Document: Update Configure Keycloak OAuth2 authentication document (#80010) update signout_redirect_url format commit c7f515b9b2186a823721caa59f1617b31cfb2083 Author: Julien Duchesne <julien.duchesne@grafana.com> Date: Fri Jan 5 09:40:08 2024 -0500 fix(swagger): POST -> GET method for two access control endpoints (#80082) Missed those here: https://github.com/grafana/grafana/pull/80053 commit 7587c403c4af85b0749b5779827619fcf1419427 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Fri Jan 5 15:26:09 2024 +0100 Update dependency esbuild-plugin-browserslist to ^0.10.0 (#76362) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 61623a4a9b8305ac8e4093c646c9cce4dac0c6f9 Author: Giordano Ricci <me@giordanoricci.com> Date: Fri Jan 5 14:19:50 2024 +0000 Chore: move PrometheusListView specific utils in the correct directory (#80072) commit 8bd053e5f4954de80980edae3a24817cc09680d7 Author: Giordano Ricci <me@giordanoricci.com> Date: Fri Jan 5 14:19:39 2024 +0000 Explore: Init with mixed DS if there's no root DS in the URL and queries have multiple datasources (#80068) commit 329ec2624a514afa0c042800a00341fdc811e068 Author: Marco Schaefer <47627413+codecapitano@users.noreply.github.com> Date: Fri Jan 5 14:47:32 2024 +0100 fix: Faro usage issue (#80033) commit fbd0ceec7caf073dda04aa308bec37b7347c22a3 Author: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Fri Jan 5 14:32:01 2024 +0100 Chore: Remove gf-form in Permissions (#79908) * Remove empty table in favor of Alert * Change to using Box and Text commit 5e74c196281562fe22911b987b0e2fb95cb3520d Author: Julien Duchesne <julien.duchesne@grafana.com> Date: Fri Jan 5 08:12:01 2024 -0500 fix(swagger): Add new access control endpoints (#80053) There were a few errors that prevented these endpoints (which are the most up-to-date ones) from being present in the openapi spec: - The `enterprise` tag excluded the endpoints from being generated - `okRespoonse` typo - Invalid templating on the parameters - Missing parameter structs commit 47b986606ef3957325e3d5bfd220db0a96c7a0c7 Author: Dominik Prokop <dominik.prokop@grafana.com> Date: Fri Jan 5 04:32:06 2024 -0800 DashboardScene: Update tracking behavior (#80057) commit 99f7110e3986a8b80fce995a617f1a5c1ecabae7 Author: Alex Khomenko <Clarity-89@users.noreply.github.com> Date: Fri Jan 5 12:41:49 2024 +0200 React Hook Form: Update to v 7.49.2 (#79493) * Update RHF to latest * Update Form types * Fix alerting types * Fix correlations types * Update tests * Fix tests * Update LabelsField.tsx to use InputControl * Update RuleEditorGrafanaRules.test.tsx * Update RuleEditorCloudRules.test.tsx * Only require one label * Update RuleEditorRecordingRule.test.tsx * Fix labels rules * Revert * Remove RHF from ignore rules * Revert * update form validation for overriding group timings * Fix changes to correlations * Fix auth type errors --------- Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com> Co-authored-by: Kristina Durivage <kristina.durivage@grafana.com> commit 3537c5440ffdfcf4e35e854af9f947695bcdc82d Author: Matthew Jacobson <matthew.jacobson@grafana.com> Date: Fri Jan 5 05:37:13 2024 -0500 Alerting: Refactor migration to return pairs of legacy and upgraded structs (#79719) Some refactoring that will simplify next changes for dry-run PRs. This should be no-op as far as the created ngalert resources and database state, though it does change some logs. The key change here is to modify migrateOrg to return pairs of legacy struct + ngalert struct instead of actually persisting the alerts and alertmanager config. This will allow us to capture error information during dry-run migration. It also moves most persistence-related operations such as title deduplication and folder creation to the right before we persist. This will simplify eventual partial migrations (individual alerts, dashboards, channels, ...). Additionally it changes channel code to deal with PostableGrafanaReceiver instead of PostableApiReceiver (integration instead of contact point). commit 359b118e6ac209e1882e00b07dae7bf7855d3442 Author: Mihai Doarna <mihai.doarna@grafana.com> Date: Fri Jan 5 12:25:59 2024 +0200 Auth: fix camelCase in getFallbackStrategyFor() func (#80061) fix camelCase in getFallbackStrategyFor() func commit ca40e333df37cad393ea5c0d6a5313be8b61a4c0 Author: Jo <joao.guerreiro@grafana.com> Date: Fri Jan 5 11:22:55 2024 +0100 Docs: fix id token hint information (#79890) * fix id token hint information * Update docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> --------- Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> commit 1f6575e65ee8d0eef254786c47e79bdf59a868b6 Author: Santiago <santiagohernandez.1997@gmail.com> Date: Fri Jan 5 11:05:27 2024 +0100 Alerting: Test MOA in remote secondary mode (#79828) commit 2b02ada7ad50325d4ce151702d23e317da3f4c79 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Fri Jan 5 11:01:47 2024 +0100 Loki: Remove dependency on appNotification (#80035) * Loki: Remove dependency on appNotification * Fix and add tests * Fix lint commit b6bee6f72b14aa53cb4f0874f2b46ad60be16817 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Fri Jan 5 10:33:44 2024 +0100 Loki: Remove usage of store from `app/core/store` and use localStorage directly (#80023) Loki: Remove usage of store from and use localStorage directly commit bfb85f27b1a3cb8119bb97ab0546d4490b4f37ba Author: Gábor Farkas <gabor.farkas@gmail.com> Date: Fri Jan 5 08:37:15 2024 +0100 sql: improve sqleng-api, leave sql.DB creation to the plugins (#79672) commit 8923cc27cec05018e34234716daa0a5796071e61 Author: Gábor Farkas <gabor.farkas@gmail.com> Date: Fri Jan 5 08:33:46 2024 +0100 sql: do not import stacktrace-generator code from core grafana (#79507) sql: do not import from core grafana commit 9bf1aa91ada411b9d9947529acf0b268699649e5 Author: Nathan Marrs <nathanielmarrs@gmail.com> Date: Thu Jan 4 17:40:58 2024 -0700 Docs: Canvas pan / zoom (#79958) Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> commit c18da48e50c13fd3e5651b8f44d1791f9e1e3c8f Author: Matthew Jacobson <matthew.jacobson@grafana.com> Date: Thu Jan 4 18:01:57 2024 -0500 Alerting: Separate overlapping legacy and UA alerting routes (#76517) * Separate overlapping legacy and UA alerting routes api/alert-notifiers, alerting/list, and alerting/notifications existed in both legacy and UA. Rename legacy route paths and nav ids to be independent of UA ones. commit 935ecdd80934cdb38730d36ee8adff06ee261a37 Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Thu Jan 4 16:55:23 2024 -0600 XYChart: Improved new tooltip (#75818) Co-authored-by: Ihor Yeromin <yeryomin.igor@gmail.com> Co-authored-by: Leon Sorokin <leeoniya@gmail.com> commit a03eca29eb8b241ef8c4fd286bea70c3cdfcba2b Author: Alexander Weaver <weaver.alex.d@gmail.com> Date: Thu Jan 4 16:16:51 2024 -0600 Upgrade grafana/dskit (#80049) Upgrade dskit commit 098e47747f279790bb11dfed5ec1ee4a37cb9450 Author: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Thu Jan 4 14:01:28 2024 -0600 Logs Panel: Table UI - add explore viz type to grafana_explore_logs_result_displayed event (#80037) * add explore visualisation type to grafana_explore_logs_result_displayed event * add log row count --------- Co-authored-by: Sven Grossmann <sven.grossmann@grafana.com> commit 6da0ce5e016e3aeb378171648d15fabf3ffbf514 Author: Isabel <76437239+imatwawana@users.noreply.github.com> Date: Thu Jan 4 14:27:46 2024 -0500 Docs: remove tooltip-mode information (#79985) Removed tooltip-mode shared file link commit 339ea65a58511b8f3eac85b23e8c41220b9a729b Author: Nathan Marrs <nathanielmarrs@gmail.com> Date: Thu Jan 4 12:13:55 2024 -0700 Gdev: Fix breaking gdev testdata datasource (#80007) fix for breaking gdev testdata datasource commit 90d4704cd752b12aef4f0932855e5f1c11bc6cd4 Author: Alexander Weaver <weaver.alex.d@gmail.com> Date: Thu Jan 4 12:40:21 2024 -0600 Alerting: Fix URL timestamp conversion in historian API in annotation mode (#80026) Fix timestamp conversion when calling annotation store commit fc8e2472e16e59c644f6c5f1a5bbe9b73c9d514a Author: Esteban Beltran <academo@users.noreply.github.com> Date: Thu Jan 4 18:19:20 2024 +0100 Chore: Simplify Levitate breaking changes workflow (#80014) * Test using an app instead of token for this workflow * Rename workflow for testing * Add a test workflow * Test 1 * Integrate report * Checkout repository * Try with github token instad of the app * Use short slog instead of adding org * Add failure to pipeline when breakikng * Change exit code to report * Improve message * Restore files * Temporarily lift restriction * Put pack path restriction * remove comment commit 91e49ec0d62431f51c8bcfdf558c9b5c273d0c9e Author: Sven Grossmann <sven.grossmann@grafana.com> Date: Thu Jan 4 17:58:03 2024 +0100 Loki: Fix `getParserAndLabelKeys` not returning parsed labels (#80029) commit f6a46744a60da89a1f10616d03fc107873044201 Author: Yuri Tseretyan <yuriy.tseretyan@grafana.com> Date: Thu Jan 4 11:47:13 2024 -0500 Alerting: Support hysteresis command expression (#75189) Backend: * Update the Grafana Alerting engine to provide feedback to HysteresisCommand. The feedback information is stored in state.Manager as a fingerprint of each state. The fingerprint is persisted to the database. Only fingerprints that belong to Pending and Alerting states are considered as "loaded" and provided back to the command. - add ResultFingerprint to state.State. It's different from other fingerprints we store in the state because it is calculated from the result labels. - add rule_fingerprint column to alert_instance - update alerting evaluator to accept AlertingResultsReader via context, and update scheduler to provide it. - add AlertingResultsFromRuleState that implements the new interface in eval package - update getExprRequest to patch the hysteresis command. * Only one "Recovery Threshold" query is allowed to be used in the alert rule and it must be the Condition. Frontend: * Add hysteresis option to Threshold in UI. It's called "Recovery Threshold" * Add test for getUnloadEvaluatorTypeFromCondition * Hide hysteresis in panel expressions * Refactor isInvalid and add test for it * Remove unnecesary React.memo * Add tests for updateEvaluatorConditions --------- Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com> commit 29c251851d576b79bf97e45fbec638666643c1b3 Author: Eric Leijonmarck <eric.leijonmarck@gmail.com> Date: Thu Jan 4 17:37:03 2024 +0100 Doc: fix remove link to enterprise issue for datasource permission breaking change changelog for 10.2.3 (#80030) * fix: link to enterprise issue * fix: remove link to enterprise commit ef6b35ad3b1c334fbf566798a9c9d0d2ecf58fc7 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Thu Jan 4 16:10:09 2024 +0000 Tempo: Copy trace query to TraceQL tab (#79935) * Copy trace query to TraceQL tab * Remove datasourceType * Update button position * Update test * Remove extra check * Update text commit e508c8353817c667967a98bd80d412e13939e337 Author: Ishan Jain <51803183+ishanjainn@users.noreply.github.com> Date: Thu Jan 4 21:22:29 2024 +0530 Adding the link to the E2C migration guide (#79868) * Adding the link to the E2C migration guide * Update docs/sources/introduction/grafana-enterprise.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> --------- Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> commit e64cb8541f33cf1891c74ea64bcaf5e13d5b50c3 Author: Bryan Huhta <32787160+bryanhuhta@users.noreply.github.com> Date: Thu Jan 4 09:35:47 2024 -0600 Pyroscope: Send start/end with profile types query (#77523) commit e93c150406741deaddd87b9a755f1da5957799a8 Author: Dominik Prokop <dominik.prokop@grafana.com> Date: Thu Jan 4 07:34:15 2024 -0800 DashboardScene: Enable scene tracking information (#79963) * DashboardScene: Enable scene tracking information alt * tests update * Review * Revert "tests update" This reverts commit 43f894236a5a807b18e9474563662d270e985cb3. * nit commit 20096259d3891202c0ce29e6b32d6fa6516658b9 Author: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu Jan 4 08:15:50 2024 -0700 Explore: Add active state to ContentOutlineItemButton (#78779) * Add active state to ContentOutlineItemButton * Improve * WIP: improve * Cleanup * Fix * Improve betterer, remove extra curly braces commit 40583aec0fd88631184f0127a992b3af57899a3a Author: Alvaro Huarte <ahuarte47@yahoo.es> Date: Thu Jan 4 16:09:16 2024 +0100 Table: Add select/unselect all column values to table filter (#79290) * Add/Remove columns values to the filter using a UX similar to the github inbox * Align select all checkbox and fix wording * Update docs/sources/panels-visualizations/visualizations/table/index.md Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> --------- Co-authored-by: Oscar Kilhed <oscar.kilhed@grafana.com> Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com> commit f0c38611a2bcec2e8b94b0ef4906f4c1f0a7cb82 Author: Torkel Ödegaard <torkel@grafana.com> Date: Thu Jan 4 15:44:41 2024 +0100 Scenes: Remove old scenes stuff (#79760) * Scenes: Remove old scenes stuff * Fixes * Fixes * update commit 692e8958a3a0731d013f72b54748a752672fa722 Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Thu Jan 4 08:29:39 2024 -0600 VizTooltips: Disable `newVizTooltips` when dashboard shared cursor is enabled (#79996) commit d29f9cfd3cb78661b8056afba6315a0e063aa777 Author: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Thu Jan 4 15:07:22 2024 +0100 Fix: Switch component not being styled as disabled when is checked (#80012) * Fix Switch component not being styled as disabled when checked * Remove unnecessary code commit 0844b87b2fe1917be14ed1c93f8407aa56e54467 Author: Alex Khomenko <Clarity-89@users.noreply.github.com> Date: Thu Jan 4 15:42:28 2024 +0200 PluginDetails: Fix usage list height (#79695) commit a349ef6c0bf93119611c36c026f9c55fa96898bc Author: Torkel Ödegaard <torkel@grafana.com> Date: Thu Jan 4 14:33:19 2024 +0100 Themes: Fixes system theme asset paths (#80019) commit 89a3337afa7da1b0a62896ce9617c268f99c569e Author: Eric Leijonmarck <eric.leijonmarck@gmail.com> Date: Thu Jan 4 14:04:45 2024 +0100 Fix: Text area for devices not found to say no devices (#80011) * initla commit linting make it along the area * linting commit 576b8ccff6e606fda4fa66b15c96f996afbae6f1 Author: Jamin <okpusjamin@gmail.com> Date: Thu Jan 4 11:52:38 2024 +0000 Frontend: Migrate `PageToolbar.tsx` from aria-label e2e selectors to data-testid (#79663) * refactor: update PageToolbar to use data-testid * refactor: update selector text --------- Co-authored-by: joshhunt <josh@trtr.co> commit 5154a9d99cdaeee4fe160efdaa84bc9bb3f94fe2 Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Thu Jan 4 11:25:48 2024 +0000 Chore: wait for component to render properly to prevent act warning (#80016) wait for component to render properly to prevent act warning commit 70a25eaf2c382fe82dc24ce5c54c5a3dca6f520d Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Thu Jan 4 11:24:01 2024 +0000 Pyroscope: Update data source name in variable editor tooltip (#79974) Update data source name in tooltip commit 1cec6195f129f8cb8292b0932adfd89fbef2e2dc Author: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> Date: Thu Jan 4 11:30:01 2024 +0100 Rendering: Fix plugin initialization (#80013) commit b2b4e8f68de3a842a670ad284f005fbbf7dae79e Author: Zoltán Bedi <zoltan.bedi@gmail.com> Date: Thu Jan 4 11:28:00 2024 +0100 MySQL: Update documentation around timezone (#79213) * MySQL: Update documentation around timezone * Update docs/sources/datasources/mysql/_index.md Co-authored-by: lwandz13 <126723338+lwandz13@users.noreply.github.com> * Update formatting * see -> refer to --------- Co-authored-by: lwandz13 <126723338+lwandz13@users.noreply.github.com> commit 2d18961768a74275e55d5b675b0577454b1ea9cc Author: Giordano Ricci <me@giordanoricci.com> Date: Thu Jan 4 09:50:27 2024 +0000 Explore: Fix URL sync with async queries import (#79584) * cancel pending queries * Update public/app/features/explore/hooks/useStateSync/index.ts Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com> * Update public/app/features/explore/hooks/useStateSync/index.ts * Fix test --------- Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com> commit 5ae3249c36c2baf98cf7d637acd55121a8294cee Author: Jo <joao.guerreiro@grafana.com> Date: Thu Jan 4 10:46:55 2024 +0100 Auth: Hide forgot password if grafana auth is disabled (#79895) * hide forgot password if grafana auth is disabled * fix test commit e92462765979419bf19f506d57a6721d8771b516 Author: Torkel Ödegaard <torkel@grafana.com> Date: Thu Jan 4 08:00:07 2024 +0100 Frontend: Reload the browser when backend configuration/assets change (#79057) * Detect frontend asset changes * Update * merge main * Frontend: Detect new assets / versions / config changes (#79258) * avoid first check * Updates and add tests * Update * Update * Updated code * refine * use context --------- Co-authored-by: Ryan McKinley <ryantxu@gmail.com> commit 7210e378b8ee4b0ca46a25f2aefbf550babd0184 Author: Leon Sorokin <leeoniya@gmail.com> Date: Wed Jan 3 22:56:03 2024 -0600 TimeSeries: Fix stacking opacity accumulation on exit from PanelEdit (#80006) commit dd77ff6bcde9f363927795ef70769989d7b22181 Author: Andreas Christou <andreas.christou@grafana.com> Date: Wed Jan 3 19:20:22 2024 +0000 Plugins: Externalise Azure Monitor data source (#79545) * Set up frontend linting for Azure - Fix final frontend import - Fix other lint issues * Add Azure Monitor to backend linting * Remove featuremgmt dependency * Add intervalv2 to list of disallowed imports * Remove config dependency - Replace with function from azure-sdk * Remove util dependency * Duplicate interval functionality from core * Add required backend wrappers * Update frontend * Add testing helper * Add missing package * Bump minimum grafana dependency * Fix dependency * Regen cue * Fix lint * Update expected response file * Update import and dependency commit d680a020ccf3dbc3aae5c8d8d86f7182ce6fceb3 Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Wed Jan 3 16:18:27 2024 +0000 Command Palette: Adjust command palette extensions priority (#79992) adjust command palette extensions priority commit 5c67c4b082f1f691186c76776d908e99a1d39b17 Author: Esteban Beltran <academo@users.noreply.github.com> Date: Wed Jan 3 15:51:31 2024 +0100 Chore: Log error from App loading in console and faro (#79977) * Chore: Log error from App loading in console and faro * Add error spy for console commit c219a19f9791c2b17b607b15061669236ce3726a Author: Mihai Doarna <mihai.doarna@grafana.com> Date: Wed Jan 3 16:05:11 2024 +0200 Auth: Add missing 404 status code from the SSO Settings delete endpoint (#79982) add missing 404 status code from sso settings delete endpoint commit 7be8301a265c7b228d8cee77188a094504fa1335 Author: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Wed Jan 3 14:57:53 2024 +0100 Loki: Add integration tests to query builder (#79978) * Loki: Add integration tests * Use findBy instead of getBy * Fix console error commit 54369158ce5f036a1955fdb5bf8d4f7024418206 Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Wed Jan 3 13:21:36 2024 +0000 Betterer: update results file (#79979) update results file commit 9de79fb5e9eba015d3db77a91549e55010c42fe4 Author: Ashley Harrison <ashley.harrison@grafana.com> Date: Wed Jan 3 12:42:26 2024 +0000 Chore: remove `react-popper-tooltip` in favour of `@floating-ui/react` (#79465) * use floating-ui instead of react-popper-tooltip in Tooltip * remove useTheme2 usage * remove escape handling logic in favour of useDismiss * don't need this useEffect anymore * convert Toggletip to use floating-ui * use explicit version * convert OperationInfoButton to use Toggletip * convert nestedFolderPicker to use floating-ui * convert Dropdown to use floating-ui and remove react-popper-tooltip * fix Modal/Tooltip tests * revert to old toggletip behaviour * revert OperationInfoButton to not use Toggletip * add mock for requestAnimationFrame * remove requestAnimationFrame mock * remove fakeTimers where they're not used * use floating-ui in ButtonSelect * Fix filters unit tests * only attach description if label is different * use 'fixed' strategy for Toggletip * use stroke and strokeWidth * set move: false to only show the tooltip if a hover event occurs * update type for onClose commit 9c9a055c3e4b5a8a376cf9c9e7f5bb0de86c5cbb Author: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Wed Jan 3 13:30:39 2024 +0100 Chore: Remove gf-form from PluginDashboards (#79300) Remove gf-form in PluginDashboards commit 65a655cfc9f4899711f1d09b18f15d2e57940955 Author: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Wed Jan 3 13:30:17 2024 +0100 Chore: Remove gf-form in DashboardLinks (#79762) commit 070e41d1368ef1fc41459fa34f8b3158757aa3ee Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Wed Jan 3 06:27:34 2024 -0600 Exemplars: Update UX to match new tooltips (#79916) commit 552fa785640a779abf570daf5b881cc9ba6f5e01 Author: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Wed Jan 3 06:27:17 2024 -0600 StateTimeline: Add tooltip multi mode (#79944) Co-authored-by: Leon Sorokin <leeoniya@gmail.com> commit 7d5432e10a19b5e75fd4cb280b822041bf94d46e Author: Marco Schaefer <47627413+codecapitano@users.noreply.github.com> Date: Wed Jan 3 13:00:27 2024 +0100 Faro: Add missing SessionInstrumentation for Faro config (#79826) commit b1c7aa269ba49676e8f184877ef7ef090549902b Author: Giuseppe Guerra <giuseppe.guerra@grafana.com> Date: Wed Jan 3 12:36:44 2024 +0100 Dependencies: Bump github.com/grafana/grafana-plugin-sdk-go from v0.197.0 to v0.198.0 (#79928) * chore: Bump google.golang.org/grpc from 1.59.0 to 1.60.1 * Bump google.golang.org/protobuf to v1.32.0 * Fix make protobuf failing with latest protoc and protoc-gen-go * Re-generate protobuf files * Re-generate protobuf files * Bump grafana-plugin-sdk-go * go mod tidy commit 00bc13e37cdbeb9a65164feaf5625d6ac7f7a39f Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Wed Jan 3 11:25:38 2024 +0000 Pyroscope: Remove unnecessary import and logic (#79568) * Remove unecessary import and logic * Update test commit ade91e5038cc31ca5969a6c88131da02dc48a97f Author: Mihai Doarna <mihai.doarna@grafana.com> Date: Wed Jan 3 13:04:35 2024 +0200 Auth: Implement the SSO Settings update endpoint (#79676) * merge with system settings before storing them in the db * add base for validating sso settings * add unit tests for sso settings validation * call Reload() from sso service upsert() * remove actual validation because it was moved in a separate pr * use constant to fix lint error * check if provider is configurable in service Upsert() method * add unit tests for update provider settings api method * fix lint error commit a255058ccf06ef2b81d9274677002947f34c18e0 Author: Ida Štambuk <ida.stambuk@grafana.com> Date: Wed Jan 3 11:31:59 2024 +0100 Query Editor: Display error even if error field is empty (#79943) commit ed5c90a8b197b83ad23719514ce1252a9dc4bede Author: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Wed Jan 3 11:28:13 2024 +0100 Tempo: Fix Spans table format (#79938) commit e372b54722b3f53dbe3f1ef31e7ed4f326e74130 Author: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Wed Jan 3 10:00:22 2024 +0000 Tempo: Easily filter by trace duration (#79931) * Easily filter by trace duration * Add test * Update onChange commit fd2c326964c30e454d4468c71d2a13815885be03 Author: Esteban Beltran <academo@users.noreply.github.com> Date: Wed Jan 3 10:45:23 2024 +0100 Faro: Send context with faro logError function (#79499) * Faro: Send context with faro logError function * Empty commit commit 6465d87afdf4b29a34332cf78b2f0c1aa2191812 Author: Mihai Doarna <mihai.doarna@grafana.com> Date: Wed Jan 3 10:02:03 2024 +0200 Auth: Add basic validation for SSO settings (#79696) * add basic validation for sso settings * remove validation for the client secret commit fb79be4a4330ae765ce9a4d386ce0c0ccdd160aa Author: Leon Sorokin <leeoniya@gmail.com> Date: Tue Jan 2 23:33:31 2024 -0600 Transformations: Add frame source picker to allow transforming annotations (#77842) Co-authored-by: Ryan McKinley <ryantxu@gmail.com> --- .betterer.results | 1785 +- .betterer.results.json | 8166 ++++++++ .betterer.ts | 14 +- .bingo/Variables.mk | 15 +- .bingo/variables.env | 4 +- .bingo/wire.mod | 2 +- .bingo/wire.sum | 53 + .bra.toml | 2 + .changelog-archive/CHANGELOG.2.md | 2 +- .changelog-archive/CHANGELOG.3.md | 2 +- .changelog-archive/CHANGELOG.4.md | 2 +- .changelog-archive/CHANGELOG.6.md | 2 +- .changelog-archive/CHANGELOG.7.md | 16 +- .drone.yml | 769 +- .eslintrc | 26 +- .github/CODEOWNERS | 161 +- .github/bot.md | 4 +- .github/pr-commands.json | 15 +- .github/renovate.json5 | 10 +- .github/workflows/alerting-swagger-gen.yml | 2 +- .github/workflows/codeql-analysis.yml | 3 +- .../core-plugins-build-and-release.yml | 45 +- ...te-security-patch-from-security-mirror.yml | 6 +- .../detect-breaking-changes-build-skip.yml | 34 - .../detect-breaking-changes-build.yml | 163 - .../detect-breaking-changes-levitate.yml | 340 + .../detect-breaking-changes-report.yml | 223 - .github/workflows/doc-validator.yml | 2 +- .github/workflows/i18n-crowdin-download.yml | 122 + .github/workflows/i18n-crowdin-fix-files.yml | 67 - .github/workflows/i18n-crowdin-upload.yml | 33 + .github/workflows/issue-labeled.yml | 4 +- .github/workflows/metrics-collector.yml | 4 +- .github/workflows/pr-codeql-analysis-go.yml | 12 +- .../pr-codeql-analysis-javascript.yml | 1 + .../workflows/pr-codeql-analysis-python.yml | 1 + .github/workflows/pr-patch-check.yml | 3 +- .github/workflows/publish-kinds-next.yml | 2 +- .github/workflows/publish-kinds-release.yml | 2 +- ...ublish-technical-documentation-release.yml | 2 +- .../workflows/scripts/kinds/verify-kinds.go | 406 +- .github/workflows/stale.yml | 36 +- .github/workflows/sync-mirror.yml | 2 +- .github/workflows/update-make-docs.yml | 2 +- .github/workflows/verify-kinds.yml | 5 +- .gitignore | 12 +- .golangci.toml | 11 + .prettierignore | 10 +- .vscode/launch.json | 6 +- ...ether-drop-https-3382d2649f-178c3afb88.zip | Bin 317732 -> 0 bytes .yarn/releases/yarn-4.0.0.cjs | 893 - .yarn/releases/yarn-4.1.0.cjs | 893 + .yarn/sdks/eslint/bin/eslint.js | 20 - .yarn/sdks/eslint/lib/api.js | 20 - .yarn/sdks/eslint/package.json | 14 - .yarn/sdks/integrations.yml | 6 - .yarn/sdks/prettier/package.json | 7 - .yarn/sdks/typescript/bin/tsc | 20 - .yarn/sdks/typescript/bin/tsserver | 20 - .yarn/sdks/typescript/lib/tsc.js | 20 - .yarn/sdks/typescript/lib/tsserver.js | 225 - .yarn/sdks/typescript/lib/tsserverlibrary.js | 225 - .yarn/sdks/typescript/lib/typescript.js | 20 - .yarn/sdks/typescript/package.json | 10 - .yarnrc.yml | 17 +- CHANGELOG.md | 477 +- Dockerfile | 15 +- GOVERNANCE.md | 2 +- HALL_OF_FAME.md | 2 +- LICENSING.md | 2 +- Makefile | 23 +- WORKFLOW.md | 2 +- babel.config.json | 60 - conf/defaults.ini | 154 +- conf/provisioning/alerting/sample.yaml | 39 + conf/provisioning/datasources/sample.yaml | 111 +- conf/provisioning/notifiers/sample.yaml | 25 - conf/sample.ini | 101 +- ISSUE_TRIAGE.md => contribute/ISSUE_TRIAGE.md | 8 +- .../UPGRADING_DEPENDENCIES.md | 0 contribute/backend/errors.md | 2 +- contribute/backend/instrumentation.md | 2 +- contribute/backend/style-guide.md | 37 +- contribute/create-pull-request.md | 4 +- contribute/deprecation-policy.md | 6 +- contribute/developer-guide.md | 59 +- contribute/drone-pipeline.md | 2 +- contribute/engineering/terminology.md | 2 +- contribute/internationalization.md | 20 +- contribute/style-guides/frontend.md | 2 +- crowdin.yml | 11 +- .../bulk_alerting_dashboards.yaml | 9 - .../dashboard.libsonnet | 169 - .../datasources.jsonnet | 14 - .../alerting/testdata_alerts.json | 806 - .../datasource-loki/loki_query_splitting.json | 1865 +- devenv/dev-dashboards/live/live-publish.json | 447 + .../dev-dashboards/migrations/migrations.json | 849 +- .../panel-canvas/canvas-datalinks.json | 2940 +++ .../panel-histogram/histogram_tests.json | 203 +- .../timeline-align-endtime.json | 271 + .../timeline-align-nulls-retain.json | 213 + .../timeseries-by-value-color-schemes.json | 2 +- .../xychart-tooltip-color-test.json | 687 + .../scenarios/tall_dashboard.json | 1791 ++ devenv/docker/blocks/auth/README.md | 2 +- devenv/docker/blocks/auth/authentik/cloak.sql | 2004 +- .../blocks/auth/authentik/docker-compose.yaml | 4 +- devenv/docker/blocks/auth/jwt_proxy/cloak.sql | 300 +- devenv/docker/blocks/auth/jwt_proxy/readme.md | 1 + .../blocks/mimir_backend/docker-compose.yaml | 3 +- devenv/docker/blocks/tempo/tempo.yaml | 5 +- .../grafana/provisioning/alerts.jsonnet | 202 - devenv/docker/ha_test/alerts.sh | 26 - .../grafana/provisioning/alerts.jsonnet | 202 - devenv/jsonnet/dev-dashboards.libsonnet | 7 +- devenv/setup.sh | 32 +- .codespellignore => docs/.codespellignore | 0 docs/Makefile | 3 +- docs/make-docs | 109 +- docs/sources/_index.md | 9 +- docs/sources/administration/api-keys/index.md | 4 + .../administration/correlations/_index.md | 5 - .../{index.md => _index.md} | 45 +- .../data-source-management/teamlbac/_index.md | 68 + .../configure-teamlbac-for-loki/index.md | 45 + .../teamlbac/create-teamlbac-rules/index.md | 125 + .../enterprise-licensing/_index.md | 7 + .../organization-preferences/index.md | 2 +- .../administration/provisioning/index.md | 76 +- .../access-control/_index.md | 1 + .../access-control/configure-rbac/index.md | 2 +- .../custom-role-actions-scopes/index.md | 265 +- .../index.md | 55 +- .../administration/service-accounts/index.md | 2 +- docs/sources/alerting/_index.md | 126 +- .../sources/alerting/alerting-rules/_index.md | 58 +- .../create-grafana-managed-rule.md | 136 +- ...reate-mimir-loki-managed-recording-rule.md | 29 +- .../create-mimir-loki-managed-rule.md | 40 +- .../manage-contact-points/_index.md | 77 - .../integrations/_index.md | 50 - .../templating-labels-annotations.md} | 17 +- .../configure-notifications/_index.md | 26 + .../create-notification-policy.md | 17 +- .../create-silence.md | 29 +- .../manage-contact-points/_index.md | 144 + .../integrations/configure-oncall.md | 15 +- .../integrations/configure-slack.md | 93 + .../integrations/pager-duty.md | 4 +- .../integrations/webhook-notifier.md | 9 +- .../mute-timings.md | 23 +- .../template-notifications/_index.md | 24 +- .../create-notification-templates.md | 4 +- .../images-in-notifications.md | 22 +- .../template-notifications/reference.md | 4 +- .../use-notification-templates.md | 16 +- .../using-go-templating-language.md | 18 +- docs/sources/alerting/difference-old-new.md | 54 - docs/sources/alerting/fundamentals/_index.md | 112 +- .../fundamentals/alert-rules/_index.md | 118 +- .../alert-rules/alert-instances.md | 31 - .../alert-rules/alert-rule-types.md | 77 - .../alert-rules/annotation-label.md | 141 + .../alert-rules/organising-alerts.md | 6 +- .../_index.md => queries-conditions.md} | 92 +- .../alert-rules/recording-rules/_index.md | 27 - .../_index.md => rule-evaluation.md} | 8 +- .../alert-rules/state-and-health.md | 19 +- .../alerting/fundamentals/alertmanager.md | 59 - .../fundamentals/annotation-label/_index.md | 53 - .../annotation-label/how-to-use-labels.md | 63 - .../labels-and-label-matchers.md | 64 - .../fundamentals/data-source-alerting.md | 95 - .../fundamentals/evaluate-grafana-alerts.md | 114 - .../fundamentals/high-availability/_index.md | 43 - .../_index.md | 14 +- .../notifications/alertmanager.md | 46 + .../contact-points.md} | 11 +- .../message-templating.md | 14 +- .../notification-policies.md} | 13 +- .../alerting/manage-notifications/_index.md | 41 +- .../declare-incident-from-alert.md | 2 +- .../manage-contact-points.md | 34 - .../manage-notifications/view-alert-groups.md | 9 +- .../manage-notifications/view-alert-rules.md | 8 +- .../manage-notifications/view-state-health.md | 4 +- docs/sources/alerting/set-up/_index.md | 18 +- .../configure-alert-state-history/index.md | 8 +- .../set-up/configure-alertmanager/index.md | 8 +- .../configure-high-availability/_index.md | 167 +- .../set-up/migrating-alerts/_index.md | 161 - .../legacy-alerting-deprecation.md | 60 - .../set-up/performance-limitations/index.md | 18 +- .../provision-alerting-resources/_index.md | 68 +- .../export-alerting-resources/index.md | 213 + .../file-provisioning/index.md | 360 +- .../http-api-provisioning/_index.md | 20 + .../terraform-provisioning/index.md | 558 +- .../view-provisioned-resources/index.md | 105 - .../breaking-changes-v10-0.md | 2 +- .../breaking-changes-v10-3.md | 77 + docs/sources/dashboards/_index.md | 34 +- .../assess-dashboard-usage/index.md | 9 +- .../annotate-visualizations/index.md | 9 +- .../build-dashboards/best-practices/index.md | 2 +- .../create-dashboard/index.md | 14 + .../import-dashboards/index.md | 60 + .../manage-dashboard-links/index.md | 2 +- .../manage-library-panels/index.md | 9 + .../view-dashboard-json-model/index.md | 34 +- .../dashboards/create-reports/index.md | 35 +- .../dashboards/manage-dashboards/index.md | 147 +- .../share-dashboards-panels/index.md | 40 +- .../troubleshoot-dashboards/index.md | 58 + .../dashboards/use-dashboards/index.md | 13 +- docs/sources/dashboards/variables/_index.md | 2 + docs/sources/datasources/_index.md | 48 +- .../aws-authentication/index.md | 6 +- .../datasources/azure-monitor/_index.md | 4 +- .../azure-monitor/query-editor/index.md | 24 +- .../datasources/elasticsearch/_index.md | 2 +- .../elasticsearch/query-editor/index.md | 6 +- .../google-authentication/index.md | 2 +- docs/sources/datasources/grafana-pyroscope.md | 115 - docs/sources/datasources/influxdb/_index.md | 19 +- docs/sources/datasources/jaeger/_index.md | 10 +- docs/sources/datasources/loki/_index.md | 7 + .../loki/configure-loki-data-source.md | 10 +- docs/sources/datasources/mysql/_index.md | 2 +- docs/sources/datasources/prometheus/_index.md | 2 +- .../prometheus/query-editor/index.md | 4 +- docs/sources/datasources/pyroscope/_index.md | 85 + .../configure-pyroscope-data-source.md | 50 + .../pyroscope/profiling-and-tracing.md | 16 + .../pyroscope/query-profile-data.md | 82 + docs/sources/datasources/tempo/_index.md | 11 +- .../tempo/configure-tempo-data-source.md | 120 +- .../datasources/tempo/query-editor/_index.md | 13 +- .../tempo/tracing-best-practices.md | 25 + docs/sources/datasources/zipkin/_index.md | 5 - .../developers/angular_deprecation/_index.md | 28 +- .../angular_deprecation/angular-plugins.md | 876 +- docs/sources/developers/http_api/_index.md | 2 +- .../developers/http_api/access_control.md | 28 +- docs/sources/developers/http_api/admin.md | 42 - docs/sources/developers/http_api/alerting.md | 184 - .../alerting_notification_channels.md | 424 - .../http_api/alerting_provisioning.md | 1524 +- .../developers/http_api/annotations.md | 2 +- docs/sources/developers/http_api/dashboard.md | 2 + .../developers/http_api/dashboard_public.md | 8 +- docs/sources/developers/http_api/folder.md | 4 +- .../http_api/folder_dashboard_search.md | 4 +- docs/sources/developers/http_api/reporting.md | 6 +- .../developers/http_api/serviceaccount.md | 1 + .../developers/http_api/sso-settings.md | 168 + docs/sources/developers/kinds/_index.md | 36 - .../developers/kinds/composable/_index.md | 12 - .../alertgroups/panelcfg/schema-reference.md | 33 - .../panelcfg/schema-reference.md | 40 - .../dataquery/schema-reference.md | 24 - .../barchart/panelcfg/schema-reference.md | 181 - .../bargauge/panelcfg/schema-reference.md | 83 - .../candlestick/panelcfg/schema-reference.md | 257 - .../canvas/panelcfg/schema-reference.md | 160 - .../cloudwatch/dataquery/schema-reference.md | 24 - .../panelcfg/schema-reference.md | 41 - .../datagrid/panelcfg/schema-reference.md | 31 - .../debug/panelcfg/schema-reference.md | 42 - .../dataquery/schema-reference.md | 283 - .../gauge/panelcfg/schema-reference.md | 80 - .../geomap/panelcfg/schema-reference.md | 97 - .../dataquery/schema-reference.md | 24 - .../dataquery/schema-reference.md | 33 - .../heatmap/panelcfg/schema-reference.md | 240 - .../histogram/panelcfg/schema-reference.md | 145 - .../logs/panelcfg/schema-reference.md | 38 - .../loki/dataquery/schema-reference.md | 36 - .../news/panelcfg/schema-reference.md | 32 - .../nodegraph/panelcfg/schema-reference.md | 57 - .../parca/dataquery/schema-reference.md | 30 - .../piechart/panelcfg/schema-reference.md | 152 - .../prometheus/dataquery/schema-reference.md | 36 - .../stat/panelcfg/schema-reference.md | 81 - .../panelcfg/schema-reference.md | 117 - .../panelcfg/schema-reference.md | 116 - .../table/panelcfg/schema-reference.md | 332 - .../tempo/dataquery/schema-reference.md | 24 - .../testdata/dataquery/schema-reference.md | 118 - .../text/panelcfg/schema-reference.md | 46 - .../timeseries/panelcfg/schema-reference.md | 231 - .../trend/panelcfg/schema-reference.md | 223 - .../xychart/panelcfg/schema-reference.md | 239 - docs/sources/developers/kinds/core/_index.md | 14 - .../core/accesspolicy/schema-reference.md | 131 - .../kinds/core/dashboard/schema-reference.md | 669 - .../kinds/core/folder/schema-reference.md | 111 - .../core/librarypanel/schema-reference.md | 142 - .../core/preferences/schema-reference.md | 144 - .../core/publicdashboard/schema-reference.md | 111 - .../kinds/core/role/schema-reference.md | 110 - .../core/rolebinding/schema-reference.md | 136 - .../core/serviceaccount/schema-reference.md | 114 - .../kinds/core/team/schema-reference.md | 107 - docs/sources/developers/kinds/maturity.md | 298 - .../developers/plugins/plugin.schema.json | 72 +- docs/sources/explore/_index.md | 2 + docs/sources/explore/logs-integration.md | 1 + docs/sources/explore/trace-integration.md | 6 +- .../fundamentals/intro-histograms/index.md | 4 +- .../fundamentals/intro-to-prometheus/index.md | 1 + .../timeseries-dimensions/index.md | 3 +- docs/sources/fundamentals/timeseries/index.md | 3 +- .../getting-started/build-first-dashboard.md | 2 +- .../get-started-grafana-influxdb.md | 2 +- .../introduction/grafana-enterprise.md | 3 + docs/sources/old-alerting/_index.md | 33 - .../old-alerting/add-notification-template.md | 38 - docs/sources/old-alerting/create-alerts.md | 138 - docs/sources/old-alerting/notifications.md | 302 - .../old-alerting/pause-an-alert-rule.md | 26 - .../old-alerting/troubleshoot-alerts.md | 53 - docs/sources/old-alerting/view-alerts.md | 32 - docs/sources/panels-visualizations/_index.md | 9 +- .../configure-data-links/index.md | 170 +- .../configure-legend/index.md | 2 +- .../configure-overrides/index.md | 187 +- .../configure-panel-options/index.md | 119 +- .../configure-standard-options/index.md | 4 - .../configure-thresholds/index.md | 143 +- .../configure-value-mappings/index.md | 140 +- .../query-transform-data/_index.md | 5 + .../expression-queries/index.md | 2 +- .../query-transform-data/share-query/index.md | 3 +- .../transform-data/index.md | 62 +- .../visualizations/_index.md | 15 + .../visualizations/canvas/index.md | 22 +- .../visualizations/geomap/index.md | 24 +- .../visualizations/histogram/index.md | 2 - .../visualizations/node-graph/index.md | 22 +- .../visualizations/table/index.md | 24 +- .../visualizations/time-series/index.md | 4 + .../release-notes/release-notes-7-4-0.md | 2 +- .../release-notes-8-0-0-beta1.md | 2 +- .../release-notes/release-notes-8-2-0.md | 2 +- .../release-notes/release-notes-9-0-4.md | 2 +- .../setup-grafana/configure-grafana/_index.md | 135 +- .../configure-custom-branding/index.md | 1 - .../feature-toggles/index.md | 123 +- .../configure-security/_index.md | 16 +- .../configure-security/audit-grafana.md | 36 - .../configure-authentication/_index.md | 46 +- .../auth-proxy/index.md | 15 +- .../configure-authentication/azuread/index.md | 65 +- .../enhanced-ldap/index.md | 4 +- .../generic-oauth/index.md | 195 +- .../configure-authentication/github/index.md | 163 +- .../configure-authentication/gitlab/index.md | 149 +- .../configure-authentication/google/index.md | 79 +- .../{grafana-com => grafana-cloud}/index.md | 22 +- .../configure-authentication/grafana/index.md | 51 +- .../keycloak/index.md | 11 +- .../configure-authentication/ldap/index.md | 52 +- .../configure-authentication/okta/index.md | 109 +- .../configure-authentication/saml/index.md | 2 +- .../encrypt-secrets-using-aws-kms/index.md | 6 +- .../index.md | 6 +- .../index.md | 6 +- .../index.md | 6 +- .../configure-request-security.md | 1 - .../configure-security-hardening/index.md | 2 +- .../configure-security/configure-team-sync.md | 5 +- .../setup-grafana/image-rendering/_index.md | 2 +- .../installation/docker/index.md | 2 +- .../setup-grafana/installation/helm/index.md | 381 + .../installation/kubernetes/index.md | 2 +- .../setup-grafana/installation/mac/index.md | 4 + .../set-up-for-high-availability.md | 6 +- .../shared/alerts/alerting_provisioning.md | 1709 ++ .../sources/shared/back-up/back-up-grafana.md | 6 +- .../pyroscope-profile-tracing-intro.md | 123 + .../datasources/tempo-editor-traceql.md | 20 +- .../datasources/tempo-search-traceql.md | 25 +- .../datasources/tempo-traces-to-profiles.md | 86 +- .../shared/upgrade/upgrade-common-tasks.md | 14 +- .../visualizations/connect-null-values.md | 4 +- .../visualizations/disconnect-values.md | 2 +- .../create-alerts-with-logs/index.md | 296 +- .../tutorials/grafana-fundamentals/index.md | 127 +- .../install-grafana-on-raspberry-pi/index.md | 29 +- .../index.md | 3 - .../run-grafana-behind-a-proxy/index.md | 44 +- .../upgrade-guide/upgrade-v10.3/index.md | 23 + .../upgrade-guide/upgrade-v10.4/index.md | 37 + docs/sources/whatsnew/_index.md | 2 + docs/sources/whatsnew/whats-new-in-v10-0.md | 8 +- docs/sources/whatsnew/whats-new-in-v10-1.md | 2 +- docs/sources/whatsnew/whats-new-in-v10-2.md | 4 +- docs/sources/whatsnew/whats-new-in-v10-3.md | 410 + docs/sources/whatsnew/whats-new-in-v10-4.md | 282 + docs/sources/whatsnew/whats-new-in-v7-0.md | 4 +- docs/sources/whatsnew/whats-new-in-v7-1.md | 4 +- docs/sources/whatsnew/whats-new-in-v7-2.md | 4 +- docs/sources/whatsnew/whats-new-in-v7-3.md | 4 +- docs/sources/whatsnew/whats-new-in-v7-4.md | 4 +- docs/sources/whatsnew/whats-new-in-v7-5.md | 2 +- docs/sources/whatsnew/whats-new-in-v8-0.md | 2 +- docs/sources/whatsnew/whats-new-in-v8-1.md | 2 +- docs/sources/whatsnew/whats-new-in-v8-2.md | 2 +- docs/sources/whatsnew/whats-new-in-v8-3.md | 2 +- docs/sources/whatsnew/whats-new-in-v8-4.md | 2 +- docs/sources/whatsnew/whats-new-in-v9-0.md | 2 +- docs/sources/whatsnew/whats-new-in-v9-1.md | 2 +- docs/sources/whatsnew/whats-new-in-v9-2.md | 2 +- docs/sources/whatsnew/whats-new-in-v9-3.md | 2 +- docs/sources/whatsnew/whats-new-in-v9-4.md | 2 +- docs/sources/whatsnew/whats-new-in-v9-5.md | 2 +- .../sources/whatsnew/whats-new-next/README.md | 4 + e2e/cloud-plugins-suite/azure-monitor.spec.ts | 60 +- e2e/dashboards-suite/dashboard-browse.spec.ts | 4 +- .../dashboard-public-create.spec.ts | 6 +- .../dashboard-public-templating.spec.ts | 2 +- .../embedded-dashboard.spec.ts | 24 + .../general-dashboards.spec.ts | 33 + .../new-datasource-variable.spec.ts | 14 +- .../new-query-variable.spec.ts | 4 +- e2e/panels-suite/panelEdit_transforms.spec.ts | 13 +- e2e/plugin-e2e/plugin-e2e-api-tests/README.md | 8 + .../as-admin-user/annotationEditPage.spec.ts | 53 + .../datasourceConfigPage.spec.ts | 44 + .../as-admin-user/explorePage.spec.ts | 15 + .../as-admin-user/featureToggles.spec.ts | 17 + .../as-admin-user/panelDataAssertion.spec.ts | 101 + .../as-admin-user/panelEditPage.spec.ts | 86 + .../as-admin-user/variableEditPage.spec.ts | 16 + .../as-viewer-user/permissions.spec.ts | 15 + e2e/plugin-e2e/plugin-e2e-api-tests/errors.ts | 4 + .../plugin-e2e-api-tests/mocks/queries.ts | 138 + .../plugin-e2e-api-tests/mocks/resources.ts | 19 + e2e/utils/support/monaco.ts | 7 + e2e/utils/support/types.ts | 16 +- e2e/various-suite/exemplars.spec.ts | 13 +- e2e/various-suite/explore.spec.ts | 20 - .../frontend-sandbox-app.spec.ts | 22 +- e2e/various-suite/graph-auto-migrate.spec.ts | 38 +- .../helpers/prometheus-helpers.ts | 62 + e2e/various-suite/loki-editor.spec.ts | 7 +- e2e/various-suite/loki-query-builder.spec.ts | 17 +- .../loki-table-explore-to-dash.spec.ts | 16 +- e2e/various-suite/mysql.spec.ts | 9 +- e2e/various-suite/navigation.spec.ts | 6 +- .../prometheus-annotations.spec.ts | 75 + e2e/various-suite/prometheus-config.spec.ts | 111 + e2e/various-suite/prometheus-editor.spec.ts | 182 + .../prometheus-variable-editor.spec.ts | 122 + e2e/various-suite/query-editor.spec.ts | 10 +- e2e/various-suite/solo-route.spec.ts | 30 + emails/templates/invited_to_org.mjml | 2 +- emails/templates/new_user_invite.mjml | 2 +- emails/templates/ng_alert_notification.mjml | 2 +- emails/templates/reset_password.mjml | 2 +- emails/templates/signup_started.mjml | 2 +- emails/templates/verify_email.mjml | 40 + emails/templates/verify_email.txt | 6 + emails/templates/welcome_on_signup.mjml | 2 +- embed.go | 2 +- go.mod | 351 +- go.sum | 2205 ++- go.work | 13 + go.work.sum | 826 + hack/README.md | 18 +- hack/externalTools.go | 7 + hack/make-aggregator-pki.sh | 27 + hack/openapi-codegen.sh | 174 + hack/update-codegen.sh | 110 +- jest.config.js | 14 +- kinds/dashboard/dashboard_kind.cue | 33 +- kinds/gen.go | 71 +- latest.json | 1 + lefthook.yml | 3 +- lerna.json | 3 +- nx.json | 18 + package.json | 431 +- packages/README.md | 2 +- packages/grafana-data/package.json | 63 +- .../src/context/plugins/usePluginContext.tsx | 20 - .../src/dataframe/ArrayDataFrame.test.ts | 40 + .../src/dataframe/ArrayDataFrame.ts | 53 +- .../src/dataframe/MutableDataFrame.ts | 6 +- packages/grafana-data/src/dataframe/index.ts | 2 + .../grafana-data/src/dataframe/utils.test.ts | 31 +- packages/grafana-data/src/dataframe/utils.ts | 37 + .../grafana-data/src/datetime/durationutil.ts | 4 +- packages/grafana-data/src/events/types.ts | 12 + .../src/field/displayProcessor.ts | 2 +- packages/grafana-data/src/field/fieldColor.ts | 6 +- .../grafana-data/src/field/fieldComparers.ts | 12 +- .../grafana-data/src/field/fieldDisplay.ts | 20 + .../src/field/fieldOverrides.test.ts | 2 +- .../grafana-data/src/field/fieldOverrides.ts | 7 + .../grafana-data/src/field/fieldState.test.ts | 5 - packages/grafana-data/src/field/fieldState.ts | 4 +- packages/grafana-data/src/field/thresholds.ts | 4 +- packages/grafana-data/src/index.ts | 7 +- .../src/monaco/languageRegistry.ts | 2 +- .../src/panel/getPanelOptionsWithDefaults.ts | 2 +- packages/grafana-data/src/query/index.ts | 1 + packages/grafana-data/src/query/refId.test.ts | 35 + packages/grafana-data/src/query/refId.ts | 23 + packages/grafana-data/src/text/markdown.ts | 21 +- .../src/themes/createTypography.ts | 2 + .../src/transformations/fieldReducer.test.ts | 11 +- .../src/transformations/fieldReducer.ts | 98 +- .../grafana-data/src/transformations/index.ts | 1 + .../src/transformations/matchers.ts | 2 + .../src/transformations/matchers/ids.ts | 2 + .../transformations/matchers/nameMatcher.ts | 2 +- .../transformations/matchers/predicates.ts | 6 +- .../valueMatchers/substringMatchers.test.ts | 130 + .../valueMatchers/substringMatchers.ts | 41 + .../src/transformations/transformDataFrame.ts | 13 - .../src/transformations/transformers.ts | 2 + .../transformers/calculateField.ts | 2 +- .../transformers/convertFieldType.test.ts | 32 + .../transformers/convertFieldType.ts | 16 +- .../transformers/ensureColumns.test.ts | 5 - .../transformers/filterByValue.test.ts | 19 +- .../transformers/formatTime.test.ts | 41 +- .../transformers/formatTime.ts | 56 +- .../transformations/transformers/groupBy.ts | 138 +- .../transformers/groupToNestedTable.test.ts | 243 + .../transformers/groupToNestedTable.ts | 235 + .../transformers/histogram.test.ts | 90 + .../transformations/transformers/histogram.ts | 45 +- .../src/transformations/transformers/ids.ts | 1 + .../transformers/joinDataFrames.ts | 32 +- .../transformers/nulls/nullToValue.ts | 32 +- .../transformers/reduce.test.ts | 30 +- .../transformations/transformers/reduce.ts | 27 +- .../transformers/seriesToRows.test.ts | 27 + .../transformers/seriesToRows.ts | 2 +- .../transformations/transformers/sortBy.ts | 2 +- .../src/types/OptionsUIRegistryBuilder.ts | 2 +- packages/grafana-data/src/types/app.ts | 4 +- packages/grafana-data/src/types/config.ts | 22 +- packages/grafana-data/src/types/data.ts | 12 +- packages/grafana-data/src/types/dataFrame.ts | 16 +- packages/grafana-data/src/types/datasource.ts | 21 +- packages/grafana-data/src/types/explore.ts | 4 +- .../src/types/featureToggles.gen.ts | 54 +- .../grafana-data/src/types/fieldOverrides.ts | 7 +- packages/grafana-data/src/types/icon.ts | 6 + packages/grafana-data/src/types/logs.ts | 30 +- packages/grafana-data/src/types/navModel.ts | 2 + packages/grafana-data/src/types/plugin.ts | 4 + .../src/types/pluginExtensions.ts | 12 +- packages/grafana-data/src/types/query.ts | 13 +- .../grafana-data/src/types/templateVars.ts | 13 + packages/grafana-data/src/types/vector.ts | 79 - .../src/utils/OptionsUIBuilders.ts | 21 +- .../grafana-data/src/utils/arrayUtils.test.ts | 50 +- packages/grafana-data/src/utils/arrayUtils.ts | 24 + packages/grafana-data/src/utils/location.ts | 2 +- packages/grafana-data/src/utils/nodeGraph.ts | 8 +- .../src/valueFormats/categories.ts | 218 +- .../src/valueFormats/dateTimeFormatters.ts | 7 - .../src/valueFormats/symbolFormatters.test.ts | 27 - .../src/valueFormats/symbolFormatters.ts | 7 +- .../src/valueFormats/valueFormats.test.ts | 12 - .../src/valueFormats/valueFormats.ts | 27 +- .../src/vector/AppendedVectors.test.ts | 27 - .../src/vector/AppendedVectors.ts | 79 - .../src/vector/ArrayVector.test.ts | 14 + .../grafana-data/src/vector/ArrayVector.ts | 10 +- .../grafana-data/src/vector/AsNumberVector.ts | 14 - .../src/vector/BinaryOperationVector.test.ts | 24 - .../src/vector/BinaryOperationVector.ts | 18 - .../grafana-data/src/vector/CircularVector.ts | 24 +- .../src/vector/ConstantVector.test.ts | 18 - .../grafana-data/src/vector/ConstantVector.ts | 10 - .../src/vector/FormattedVector.ts | 14 - .../src/vector/FunctionalVector.ts | 14 +- .../grafana-data/src/vector/IndexVector.ts | 36 - .../src/vector/SortedVector.test.ts | 14 - .../grafana-data/src/vector/SortedVector.ts | 39 - packages/grafana-data/src/vector/index.ts | 11 - .../grafana-data/src/vector/vectorToArray.ts | 10 - packages/grafana-e2e-selectors/package.json | 13 +- .../src/selectors/components.ts | 101 +- .../src/selectors/pages.ts | 43 +- .../fixtures/exemplars-query-response.json | 103 +- packages/grafana-e2e/package.json | 8 +- packages/grafana-e2e/src/support/types.ts | 16 +- packages/grafana-eslint-rules/README.md | 2 +- packages/grafana-eslint-rules/package.json | 10 +- packages/grafana-flamegraph/package.json | 44 +- .../src/FlameGraph/FlameGraph.test.tsx | 24 +- .../src/FlameGraph/FlameGraph.tsx | 12 +- .../src/FlameGraph/FlameGraphCanvas.tsx | 15 +- .../src/FlameGraph/FlameGraphContextMenu.tsx | 38 +- .../src/FlameGraph/dataTransform.ts | 2 +- .../src/FlameGraph/rendering.ts | 4 +- .../src/FlameGraphContainer.tsx | 12 +- packages/grafana-flamegraph/src/types.ts | 4 + .../grafana-o11y-ds-frontend/package.json | 50 + .../src}/IntervalInput/IntervalInput.test.tsx | 0 .../src}/IntervalInput/IntervalInput.tsx | 0 .../src}/IntervalInput/validation.test.ts | 0 .../src}/IntervalInput/validation.ts | 0 .../LocalStorageValueProvider.tsx | 49 + .../src/NodeGraph}/NodeGraphSettings.tsx | 4 +- .../src/SpanBar/SpanBarSettings.tsx | 110 + .../src/TemporaryAlert.test.tsx | 28 + .../src/TemporaryAlert.tsx | 74 + .../src}/TraceToLogs/TagMappingInput.tsx | 6 - .../TraceToLogs/TraceToLogsSettings.test.tsx | 0 .../src}/TraceToLogs/TraceToLogsSettings.tsx | 7 +- .../TraceToMetrics/TraceToMetricsSettings.tsx | 31 +- .../TraceToProfilesSettings.test.tsx | 0 .../TraceToProfilesSettings.tsx | 31 +- .../src/combineResponses.test.ts | 779 + .../src/combineResponses.ts | 169 + .../grafana-o11y-ds-frontend/src/index.ts | 18 + .../src/pyroscope/ProfileTypesCascader.tsx | 85 + .../src/pyroscope/dataquery.gen.ts | 38 + .../src/pyroscope/datasource.ts | 13 + .../src/pyroscope/types.ts | 16 + .../grafana-o11y-ds-frontend/src/store.ts | 64 + .../grafana-o11y-ds-frontend/src/utils.ts | 1 + .../grafana-o11y-ds-frontend/tsconfig.json | 12 + packages/grafana-plugin-configs/package.json | 20 +- .../grafana-plugin-configs/webpack.config.ts | 25 +- packages/grafana-prometheus/CHANGELOG.md | 3 + packages/grafana-prometheus/LICENSE_AGPL | 661 + packages/grafana-prometheus/README.md | 13 + packages/grafana-prometheus/package.json | 150 + packages/grafana-prometheus/rollup.config.ts | 38 + .../src/add_label_to_query.test.ts | 114 + .../src/add_label_to_query.ts | 100 + .../src/components/AnnotationQueryEditor.tsx | 139 + .../src/components/PromCheatSheet.tsx | 50 + .../src/components/PromExemplarField.tsx | 79 + .../components/PromExploreExtraField.test.tsx | 41 + .../src/components/PromExploreExtraField.tsx | 145 + .../components/PromQueryEditorByApp.test.tsx | 83 + .../src/components/PromQueryEditorByApp.tsx | 21 + .../components/PromQueryEditorForAlerting.tsx | 25 + .../src/components/PromQueryField.test.tsx | 183 + .../src/components/PromQueryField.tsx | 290 + .../PrometheusMetricsBrowser.test.tsx | 324 + .../components/PrometheusMetricsBrowser.tsx | 684 + .../components/VariableQueryEditor.test.tsx | 392 + .../src/components/VariableQueryEditor.tsx | 437 + .../monaco-query-field/MonacoQueryField.tsx | 324 + .../MonacoQueryFieldLazy.tsx | 14 + .../MonacoQueryFieldProps.ts | 21 + .../MonacoQueryFieldWrapper.tsx | 34 + .../monaco-query-field/getOverrideServices.ts | 116 + .../monaco-completion-provider/completions.ts | 208 + .../monaco-completion-provider/index.ts | 113 + .../situation.test.ts | 185 + .../monaco-completion-provider/situation.ts | 569 + .../monaco-completion-provider/util.ts | 25 + .../validation.test.ts | 85 + .../monaco-completion-provider/validation.ts | 126 + .../components/monaco-query-field/promql.ts | 247 + .../src/components/types.ts | 6 + .../AlertingSettingsOverhaul.tsx | 61 + .../src/configuration/ConfigEditor.test.tsx | 93 + .../src/configuration/ConfigEditor.tsx | 139 + .../DataSourceHttpSettingsOverhaul.tsx | 97 + .../src/configuration/ExemplarSetting.tsx | 150 + .../src/configuration/ExemplarsSettings.tsx | 67 + .../src/configuration/PromFlavorVersions.ts | 99 + .../src/configuration/PromSettings.test.tsx | 65 + .../src/configuration/PromSettings.tsx | 478 + .../src/configuration/mocks.ts | 14 + .../src/dashboards/grafana_stats.json | 1187 ++ .../src/dashboards/prometheus_2_stats.json | 1403 ++ .../src/dashboards/prometheus_stats.json | 834 + .../src/dataquery.ts} | 20 +- .../grafana-prometheus/src/datasource.test.ts | 1296 ++ packages/grafana-prometheus/src/datasource.ts | 1014 + .../LocalStorageValueProvider.tsx | 49 + .../LocalStorageValueProvider/index.tsx | 1 + .../src/gcopypaste/app/core/store.ts | 65 + .../app/core/utils/CancelablePromise.ts | 31 + .../src/gcopypaste/app/core/utils/query.ts | 20 + .../datasources/__mocks__/dataSourcesMocks.ts | 30 + .../app/features/live/data/amendTimeSeries.ts | 93 + .../src/components/Select/SelectBase.tsx | 126 + .../gcopypaste/public/test/matchers/index.ts | 10 + .../public/test/matchers/toEmitValues.test.ts | 140 + .../public/test/matchers/toEmitValues.ts | 90 + .../test/matchers/toEmitValuesWith.test.ts | 153 + .../public/test/matchers/toEmitValuesWith.ts | 62 + .../gcopypaste/public/test/matchers/types.ts | 13 + .../gcopypaste/public/test/matchers/utils.ts | 63 + .../test/helpers/selectOptionInTest.ts | 23 + .../src/img/cortex_logo.svg | 1 + .../grafana-prometheus/src/img/mimir_logo.svg | 1 + .../src/img/prometheus_logo.svg | 1 + .../src/img/thanos_logo.svg | 1 + packages/grafana-prometheus/src/index.ts | 87 + .../src/language_provider.mock.ts | 18 + .../src/language_provider.test.ts | 393 + .../src/language_provider.ts | 378 + .../src/language_utils.test.ts | 518 + .../grafana-prometheus/src/language_utils.ts | 533 + .../src/metric_find_query.test.ts | 417 + .../src/metric_find_query.ts | 203 + .../src/migrations/variableMigration.ts | 137 + .../grafana-prometheus/src/module.test.ts | 7 + packages/grafana-prometheus/src/module.ts | 13 + .../grafana-prometheus/src/promql.test.ts | 23 + packages/grafana-prometheus/src/promql.ts | 607 + .../src/query_hints.test.ts | 192 + .../grafana-prometheus/src/query_hints.ts | 151 + .../querybuilder/PromQueryModeller.test.ts | 335 + .../src/querybuilder/PromQueryModeller.ts | 93 + .../src/querybuilder/QueryPattern.tsx | 118 + .../querybuilder/QueryPatternsModal.test.tsx | 143 + .../src/querybuilder/QueryPatternsModal.tsx | 132 + .../src/querybuilder/aggregations.ts | 62 + .../querybuilder/binaryScalarOperations.ts | 120 + .../components/LabelFilterItem.tsx | 194 + .../components/LabelFilters.test.tsx | 163 + .../querybuilder/components/LabelFilters.tsx | 114 + .../components/LabelParamEditor.tsx | 60 + .../components/MetricSelect.test.tsx | 191 + .../querybuilder/components/MetricSelect.tsx | 423 + .../components/MetricsLabelsSection.tsx | 252 + .../querybuilder/components/NestedQuery.tsx | 129 + .../components/NestedQueryList.tsx | 49 + .../components/PromQueryBuilder.test.tsx | 392 + .../components/PromQueryBuilder.tsx | 149 + .../PromQueryBuilderContainer.test.tsx | 64 + .../components/PromQueryBuilderContainer.tsx | 121 + .../components/PromQueryBuilderExplained.tsx | 41 + .../PromQueryBuilderOptions.test.tsx | 134 + .../components/PromQueryBuilderOptions.tsx | 165 + .../components/PromQueryCodeEditor.test.tsx | 64 + .../components/PromQueryCodeEditor.tsx | 52 + .../PromQueryEditorSelector.test.tsx | 256 + .../components/PromQueryEditorSelector.tsx | 166 + .../components/PromQueryLegendEditor.tsx | 121 + .../querybuilder/components/QueryPreview.tsx | 24 + .../metrics-modal/AdditionalSettings.tsx | 85 + .../components/metrics-modal/FeedbackLink.tsx | 40 + .../metrics-modal/MetricsModal.test.tsx | 261 + .../components/metrics-modal/MetricsModal.tsx | 414 + .../components/metrics-modal/ResultsTable.tsx | 252 + .../components/metrics-modal/index.ts | 1 + .../components/metrics-modal/state/helpers.ts | 260 + .../components/metrics-modal/state/state.ts | 116 + .../components/metrics-modal/styles.ts | 101 + .../components/metrics-modal/types.ts | 30 + .../components/metrics-modal/uFuzzy.ts | 45 + .../components/promQail/PromQail.test.tsx | 148 + .../components/promQail/PromQail.tsx | 616 + .../promQail/QueryAssistantButton.test.tsx | 51 + .../promQail/QueryAssistantButton.tsx | 87 + .../promQail/QuerySuggestionContainer.tsx | 101 + .../promQail/QuerySuggestionItem.tsx | 321 + .../querybuilder/components/promQail/index.ts | 1 + .../components/promQail/prompts.ts | 114 + .../promQail/resources/AI_Logo_bw.svg | 4 + .../promQail/resources/AI_Logo_color.svg | 11 + .../components/promQail/state/helpers.test.ts | 74 + .../components/promQail/state/helpers.ts | 418 + .../components/promQail/state/state.ts | 43 + .../components/promQail/state/templates.ts | 337 + .../querybuilder/components/promQail/types.ts | 17 + .../src/querybuilder}/hooks/useFlag.test.ts | 16 +- .../src/querybuilder}/hooks/useFlag.ts | 13 +- .../src/querybuilder/operationUtils.test.ts | 208 + .../src/querybuilder/operationUtils.ts | 351 + .../src/querybuilder/operations.ts | 376 + .../src/querybuilder/parsing.test.ts | 720 + .../src/querybuilder/parsing.ts | 468 + .../src/querybuilder/parsingUtils.test.ts | 42 + .../src/querybuilder/parsingUtils.ts | 140 + .../shared/LokiAndPromQueryModellerBase.ts | 122 + .../querybuilder/shared/OperationEditor.tsx | 308 + .../shared/OperationExplainedBox.tsx | 77 + .../querybuilder/shared/OperationHeader.tsx | 121 + .../shared/OperationInfoButton.tsx | 121 + .../shared/OperationList.test.tsx | 85 + .../src/querybuilder/shared/OperationList.tsx | 204 + .../shared/OperationListExplained.tsx | 55 + .../shared/OperationParamEditor.tsx | 130 + .../shared/OperationsEditorRow.tsx | 29 + .../querybuilder/shared/QueryBuilderHints.tsx | 87 + .../shared/QueryEditorModeToggle.tsx | 23 + .../querybuilder/shared/QueryHeaderSwitch.tsx | 39 + .../querybuilder/shared/QueryOptionGroup.tsx | 85 + .../src/querybuilder/shared/RawQuery.tsx | 38 + .../src/querybuilder/shared/types.ts | 112 + .../src/querybuilder/state.test.ts | 64 + .../src/querybuilder/state.ts | 72 + .../src/querybuilder/testUtils.ts | 27 + .../src/querybuilder/types.ts | 139 + .../src/querycache/QueryCache.test.ts | 521 + .../src/querycache/QueryCache.ts | 441 + .../src/querycache/QueryCacheTestData.ts | 862 + .../src/result_transformer.test.ts | 1164 ++ .../src/result_transformer.ts | 423 + packages/grafana-prometheus/src/tracking.ts | 46 + packages/grafana-prometheus/src/types.ts | 141 + .../grafana-prometheus/src/typings/jest.d.ts | 17 + packages/grafana-prometheus/src/variables.ts | 58 + .../grafana-prometheus/tsconfig.build.json | 4 + packages/grafana-prometheus/tsconfig.json | 12 + packages/grafana-runtime/package.json | 46 +- .../grafana-runtime/src/analytics/types.ts | 1 + .../src/components/EmbeddedDashboard.tsx | 32 + packages/grafana-runtime/src/config.ts | 45 +- packages/grafana-runtime/src/index.ts | 4 +- .../pluginExtensions/getPluginExtensions.ts | 9 +- .../src/utils/DataSourceWithBackend.ts | 10 +- packages/grafana-runtime/src/utils/logging.ts | 83 +- packages/grafana-runtime/src/utils/plugin.ts | 8 - .../src/utils/returnToPrevious.ts | 23 + packages/grafana-schema/package.json | 12 +- .../grafana-schema/src/common/common.gen.ts | 26 +- packages/grafana-schema/src/common/data.cue | 5 + .../grafana-schema/src/common/mudball.cue | 6 +- packages/grafana-schema/src/common/table.cue | 9 +- .../accesspolicy/x/accesspolicy_types.gen.ts | 2 +- .../x/AlertGroupsPanelCfg_types.gen.ts | 27 - .../x/AnnotationsListPanelCfg_types.gen.ts | 5 +- .../x/AzureMonitorDataQuery_types.gen.ts | 5 +- .../panelcfg/x/BarChartPanelCfg_types.gen.ts | 5 +- .../panelcfg/x/BarGaugePanelCfg_types.gen.ts | 5 +- .../x/CandlestickPanelCfg_types.gen.ts | 7 +- .../panelcfg/x/CanvasPanelCfg_types.gen.ts | 10 +- .../x/CloudWatchDataQuery_types.gen.ts | 5 +- .../x/DashboardListPanelCfg_types.gen.ts | 5 +- .../panelcfg/x/DatagridPanelCfg_types.gen.ts | 5 +- .../panelcfg/x/DebugPanelCfg_types.gen.ts | 5 +- .../x/ElasticsearchDataQuery_types.gen.ts | 5 +- .../panelcfg/x/GaugePanelCfg_types.gen.ts | 5 +- .../panelcfg/x/GeomapPanelCfg_types.gen.ts | 5 +- ...oogleCloudMonitoringDataQuery_types.gen.ts | 5 +- .../x/GrafanaPyroscopeDataQuery_types.gen.ts | 5 +- .../panelcfg/x/HeatmapPanelCfg_types.gen.ts | 13 +- .../panelcfg/x/HistogramPanelCfg_types.gen.ts | 10 +- .../logs/panelcfg/x/LogsPanelCfg_types.gen.ts | 6 +- .../dataquery/x/LokiDataQuery_types.gen.ts | 6 +- .../news/panelcfg/x/NewsPanelCfg_types.gen.ts | 5 +- .../panelcfg/x/NodeGraphPanelCfg_types.gen.ts | 5 +- .../dataquery/x/ParcaDataQuery_types.gen.ts | 3 +- .../panelcfg/x/PieChartPanelCfg_types.gen.ts | 5 +- .../stat/panelcfg/x/StatPanelCfg_types.gen.ts | 5 +- .../x/StateTimelinePanelCfg_types.gen.ts | 5 +- .../x/StatusHistoryPanelCfg_types.gen.ts | 5 +- .../panelcfg/x/TablePanelCfg_types.gen.ts | 5 +- .../dataquery/x/TempoDataQuery_types.gen.ts | 14 +- .../x/TestDataDataQuery_types.gen.ts | 8 +- .../text/panelcfg/x/TextPanelCfg_types.gen.ts | 5 +- .../x/TimeSeriesPanelCfg_types.gen.ts | 6 +- .../panelcfg/x/TrendPanelCfg_types.gen.ts | 5 +- .../panelcfg/x/XYChartPanelCfg_types.gen.ts | 6 +- .../raw/dashboard/x/dashboard_types.gen.ts | 54 +- .../librarypanel/x/librarypanel_types.gen.ts | 2 +- .../preferences/x/preferences_types.gen.ts | 2 +- .../x/publicdashboard_types.gen.ts | 2 +- .../src/raw/role/x/role_types.gen.ts | 2 +- .../rolebinding/x/rolebinding_types.gen.ts | 2 +- .../src/raw/team/x/team_types.gen.ts | 2 +- .../src/veneer/dashboard.types.ts | 3 +- packages/grafana-sql/package.json | 54 + .../grafana-sql/src}/ResponseParser.test.ts | 0 .../grafana-sql/src}/ResponseParser.ts | 0 .../src}/components/ConfirmModal.tsx | 0 .../src}/components/DatasetSelector.tsx | 0 .../src}/components/ErrorBoundary.tsx | 0 .../src}/components/QueryEditor.tsx | 3 +- .../QueryEditorFeatureFlag.utils.ts | 0 .../src}/components/QueryHeader.tsx | 4 +- .../src}/components/SqlComponents.test.tsx | 0 .../components/SqlComponents.testHelpers.ts | 0 .../src}/components/TableSelector.tsx | 0 .../configuration/ConnectionLimits.tsx | 59 +- .../src}/components/configuration/Divider.tsx | 0 .../components/configuration/NumberInput.tsx | 34 + .../configuration/TLSSecretsConfig.tsx | 0 .../useMigrateDatabaseFields.test.ts | 3 + .../configuration/useMigrateDatabaseFields.ts | 7 +- .../grafana-sql/src}/components/index.ts | 0 .../query-editor-raw/QueryEditorRaw.tsx | 0 .../query-editor-raw/QueryToolbox.tsx | 0 .../query-editor-raw/QueryValidator.tsx | 0 .../components/query-editor-raw/README.md | 0 .../components/query-editor-raw/RawEditor.tsx | 0 .../AwesomeQueryBuilder.tsx | 0 .../visual-query-builder/GroupByRow.tsx | 0 .../visual-query-builder/OrderByRow.tsx | 4 +- .../visual-query-builder/Preview.tsx | 0 .../visual-query-builder/SQLGroupByRow.tsx | 0 .../visual-query-builder/SQLOrderByRow.tsx | 0 .../visual-query-builder/SQLSelectRow.tsx | 0 .../visual-query-builder/SQLWhereRow.tsx | 0 .../visual-query-builder/SelectRow.tsx | 0 .../visual-query-builder/VisualEditor.tsx | 0 .../visual-query-builder/WhereRow.tsx | 0 .../components/visual-query-builder/index.ts | 0 .../grafana-sql/src}/constants.ts | 0 .../src}/datasource/SqlDatasource.ts | 16 +- .../grafana-sql/src}/defaults.ts | 0 .../grafana-sql/src}/expressions.ts | 0 packages/grafana-sql/src/index.ts | 22 + .../sql => packages/grafana-sql/src}/types.ts | 0 .../grafana-sql/src}/utils/formatSQL.ts | 0 packages/grafana-sql/src/utils/logging.ts | 3 + .../grafana-sql/src}/utils/migration.test.ts | 0 .../grafana-sql/src}/utils/migration.ts | 0 .../grafana-sql/src}/utils/sql.utils.ts | 0 .../grafana-sql/src}/utils/testHelpers.ts | 0 .../grafana-sql/src}/utils/useSqlChange.ts | 0 packages/grafana-sql/tsconfig.json | 13 + packages/grafana-ui/.storybook/preview.ts | 4 + packages/grafana-ui/package.json | 146 +- .../grafana-ui/src/components/Alert/Alert.tsx | 5 +- .../AutoSaveField/AutoSaveField.tsx | 9 +- .../components/BigValue/BigValue.story.tsx | 56 + .../src/components/Button/Button.tsx | 6 +- .../ButtonCascader/_ButtonCascader.scss | 14 +- .../grafana-ui/src/components/Card/Card.mdx | 450 - .../src/components/Card/Card.story.tsx | 250 +- .../grafana-ui/src/components/Card/Card.tsx | 15 +- .../src/components/Card/CardContainer.tsx | 5 +- .../src/components/Cascader/Cascader.test.tsx | 34 +- .../src/components/Cascader/Cascader.tsx | 90 +- .../components/ColorPicker/ColorPicker.tsx | 17 - .../ConfirmButton/ConfirmButton.tsx | 236 +- .../components/DataLinks/DataLinkInput.tsx | 90 +- .../DataSourceHttpSettings.tsx | 7 +- .../DateTimePickers/DatePicker/DatePicker.tsx | 1 - .../DatePickerWithInput.tsx | 67 +- .../DateTimePicker/DateTimePicker.tsx | 27 +- .../RelativeTimeRangePicker.tsx | 46 +- .../DateTimePickers/TimeRangeInput.tsx | 2 +- .../DateTimePickers/TimeRangePicker.tsx | 5 +- .../TimeRangePicker/TimePickerContent.tsx | 19 +- .../TimeRangePicker/TimePickerFooter.tsx | 7 +- .../TimeRangePicker/TimeRangeContent.test.tsx | 37 +- .../TimeRangePicker/TimeRangeContent.tsx | 63 +- .../TimeZonePicker/TimeZoneOffset.tsx | 3 - .../src/components/DragHandle/DragHandle.tsx | 105 + .../src/components/Drawer/Drawer.tsx | 163 +- .../src/components/Dropdown/ButtonSelect.tsx | 100 +- .../src/components/Dropdown/Dropdown.tsx | 58 +- .../ErrorBoundary/ErrorBoundary.tsx | 6 +- .../src/components/Forms/Checkbox.tsx | 2 +- .../src/components/Forms/FieldArray.mdx | 2 + .../src/components/Forms/FieldArray.tsx | 3 + .../grafana-ui/src/components/Forms/Form.mdx | 2 + .../grafana-ui/src/components/Forms/Form.tsx | 7 +- .../grafana-ui/src/components/Icon/utils.ts | 4 +- .../components/InlineToast/InlineToast.tsx | 150 +- .../src/components/InputControl.tsx | 4 + .../InteractiveTable.test.tsx | 19 +- .../InteractiveTable/InteractiveTable.tsx | 3 +- .../src/components/Layout/Box/Box.story.tsx | 1 + .../src/components/Layout/Box/Box.tsx | 11 +- .../src/components/Layout/Grid/Grid.story.tsx | 14 +- .../src/components/Layout/Grid/Grid.tsx | 12 +- .../src/components/Layout/Layout.tsx | 10 + .../src/components/Layout/Space.mdx | 22 + .../src/components/Layout/Space.story.tsx | 70 + .../src/components/Layout/Space.tsx | 26 + .../src/components/Link/TextLink.story.tsx | 5 +- .../src/components/Link/TextLink.test.tsx | 52 + .../src/components/Link/TextLink.tsx | 30 +- .../grafana-ui/src/components/Modal/Modal.tsx | 2 +- .../src/components/Monaco/CodeEditor.tsx | 4 +- .../components/Monaco/ReactMonacoEditor.tsx | 16 +- .../src/components/PageLayout/PageToolbar.tsx | 2 +- .../PanelChrome/LoadingIndicator.tsx | 2 +- .../components/PanelChrome/PanelChrome.tsx | 19 +- .../components/PanelChrome/PanelContext.ts | 8 - .../RefreshPicker/RefreshPicker.tsx | 2 +- .../src/components/Select/Select.mdx | 71 +- .../src/components/Select/Select.story.tsx | 3 + .../src/components/Select/SelectBase.test.tsx | 9 +- .../src/components/Select/SelectMenu.tsx | 7 +- .../components/Select/resetSelectStyles.ts | 4 +- .../grafana-ui/src/components/Select/types.ts | 2 +- .../src/components/Slider/HandleTooltip.tsx | 10 +- .../src/components/Slider/RangeSlider.tsx | 4 +- .../src/components/Slider/Slider.tsx | 4 +- .../src/components/Sparkline/Sparkline.tsx | 10 +- .../src/components/Splitter/useSplitter.mdx | 34 + .../components/Splitter/useSplitter.story.tsx | 71 + .../src/components/Splitter/useSplitter.ts | 410 + .../src/components/Switch/Switch.story.tsx | 16 +- .../src/components/Switch/Switch.tsx | 27 +- .../TabbedContainer/TabbedContainer.tsx | 10 +- .../src/components/Table/DataLinksCell.tsx | 27 + .../src/components/Table/DefaultCell.tsx | 25 +- .../src/components/Table/Filter.tsx | 18 +- .../src/components/Table/FilterList.tsx | 177 +- .../src/components/Table/FilterPopup.tsx | 27 +- .../src/components/Table/HeaderRow.tsx | 51 +- .../src/components/Table/RowsList.tsx | 17 +- .../grafana-ui/src/components/Table/Table.mdx | 6 + .../src/components/Table/Table.test.tsx | 207 +- .../grafana-ui/src/components/Table/Table.tsx | 58 +- .../Table/TableCellInspectModal.tsx | 15 +- .../grafana-ui/src/components/Table/hooks.ts | 48 +- .../src/components/Table/reducer.ts | 7 +- .../grafana-ui/src/components/Table/styles.ts | 14 +- .../grafana-ui/src/components/Table/types.ts | 21 +- .../grafana-ui/src/components/Table/utils.ts | 24 +- .../grafana-ui/src/components/Text/Text.mdx | 1 + .../src/components/Text/Text.story.tsx | 5 +- .../Text/Typography.internal.story.tsx | 87 + .../components/ThemeDemos/EmotionPerfTest.tsx | 17 +- .../components/Toggletip/Toggletip.story.tsx | 2 +- .../components/Toggletip/Toggletip.test.tsx | 34 +- .../src/components/Toggletip/Toggletip.tsx | 174 +- .../src/components/Toggletip/types.ts | 9 +- .../ToolbarButton/ToolbarButton.tsx | 2 +- .../ToolbarButton/ToolbarButtonRow.tsx | 4 +- .../src/components/Tooltip/Popover.tsx | 170 +- .../src/components/Tooltip/Tooltip.tsx | 128 +- .../src/components/Tooltip/types.ts | 29 +- .../src/components/UnitPicker/UnitPicker.tsx | 1 + .../VizLegend/VizLegendTableItem.tsx | 1 + .../src/components/VizTooltip/HeaderLabel.tsx | 16 - .../src/components/VizTooltip/SeriesList.tsx | 43 - .../VizTooltip/VizTooltipColorIndicator.tsx | 17 +- .../VizTooltip/VizTooltipContent.tsx | 64 +- .../VizTooltip/VizTooltipFooter.tsx | 10 +- .../VizTooltip/VizTooltipHeader.tsx | 26 +- .../VizTooltip/VizTooltipHeaderLabelValue.tsx | 23 - .../components/VizTooltip/VizTooltipRow.tsx | 172 +- .../src/components/VizTooltip/types.ts | 7 +- .../src/components/VizTooltip/utils.test.ts | 172 +- .../src/components/VizTooltip/utils.ts | 86 +- packages/grafana-ui/src/components/index.ts | 3 + .../grafana-ui/src/components/uPlot/Plot.scss | 7 +- .../grafana-ui/src/components/uPlot/config.ts | 16 +- .../uPlot/config/UPlotAxisBuilder.ts | 3 +- .../uPlot/config/UPlotConfigBuilder.test.ts | 9 +- .../uPlot/config/UPlotConfigBuilder.ts | 34 +- .../uPlot/config/UPlotScaleBuilder.ts | 8 +- .../uPlot/config/UPlotSeriesBuilder.ts | 4 + .../uPlot/config/UPlotThresholds.ts | 16 +- .../components/uPlot/config/gradientFills.ts | 4 +- .../uPlot/plugins/EventBusPlugin.tsx | 164 + .../uPlot/plugins/TooltipPlugin2.tsx | 333 +- .../components/uPlot/plugins/ZoomPlugin.tsx | 18 +- .../src/components/uPlot/plugins/index.ts | 1 + .../src/components/uPlot/utils.test.ts | 2 +- .../grafana-ui/src/components/uPlot/utils.ts | 4 +- .../GraphNG/__snapshots__/utils.test.ts.snap | 23 +- .../src/graveyard/GraphNG/utils.test.ts | 3 +- .../src/graveyard/TimeSeries/TimeSeries.tsx | 3 +- .../src/graveyard/TimeSeries/utils.ts | 67 +- .../src/options/builder/tooltip.tsx | 41 +- packages/grafana-ui/src/schema.ts | 2 +- .../src/slate-plugins/suggestions.test.tsx | 29 + .../src/slate-plugins/suggestions.tsx | 47 +- .../src/themes/GlobalStyles/elements.ts | 4 +- .../src/themes/_variables.scss.tmpl.ts | 1 - packages/grafana-ui/src/themes/mixins.ts | 7 +- packages/grafana-ui/src/types/forms.ts | 6 + packages/grafana-ui/src/utils/tooltipUtils.ts | 111 +- packaging/deb/control/postinst | 22 +- packaging/docker/build.sh | 2 +- packaging/rpm/control/postinst | 5 - pkg/api/accesscontrol.go | 91 +- pkg/api/admin.go | 4 +- pkg/api/admin_provisioning.go | 32 +- pkg/api/admin_provisioning_test.go | 20 - pkg/api/admin_users.go | 32 +- pkg/api/admin_users_test.go | 6 +- pkg/api/alerting.go | 1037 +- pkg/api/annotations.go | 38 +- pkg/api/api.go | 92 +- pkg/api/api_test.go | 11 + pkg/api/apierrors/dashboard.go | 6 - pkg/api/apierrors/folder.go | 13 +- pkg/api/apikey.go | 4 +- pkg/api/avatar/avatar.go | 6 +- pkg/api/common_test.go | 2 +- pkg/api/dashboard.go | 29 +- pkg/api/dashboard_permission.go | 10 +- pkg/api/dashboard_snapshot.go | 241 +- pkg/api/dashboard_snapshot_test.go | 21 +- pkg/api/dashboard_test.go | 15 +- pkg/api/datasources.go | 74 +- pkg/api/datasources_test.go | 14 +- pkg/api/dtos/alerting.go | 137 - pkg/api/dtos/alerting_test.go | 35 - pkg/api/dtos/dashboard.go | 29 +- pkg/api/dtos/frontend_settings.go | 57 +- pkg/api/dtos/index.go | 26 +- pkg/api/dtos/invite.go | 17 +- pkg/api/dtos/models.go | 15 +- pkg/api/dtos/plugins.go | 4 +- pkg/api/dtos/user.go | 32 +- pkg/api/fakes.go | 2 +- pkg/api/featuremgmt.go | 160 - pkg/api/featuremgmt_test.go | 499 - pkg/api/folder.go | 71 +- pkg/api/folder_bench_test.go | 34 +- pkg/api/folder_permission.go | 13 +- pkg/api/folder_test.go | 2 +- pkg/api/frontendsettings.go | 158 +- pkg/api/frontendsettings_test.go | 15 +- pkg/api/http_server.go | 31 +- pkg/api/index.go | 86 +- pkg/api/login.go | 18 +- pkg/api/login_test.go | 20 +- pkg/api/metrics.go | 24 + pkg/api/metrics_test.go | 17 +- pkg/api/org_invite.go | 27 +- pkg/api/org_users.go | 30 +- pkg/api/org_users_test.go | 1 + pkg/api/password.go | 9 +- pkg/api/playlist.go | 18 +- pkg/api/plugin_proxy.go | 2 +- pkg/api/plugin_resource.go | 11 +- pkg/api/plugin_resource_test.go | 94 +- pkg/api/pluginproxy/ds_auth_provider.go | 3 +- pkg/api/pluginproxy/ds_proxy.go | 17 +- pkg/api/pluginproxy/ds_proxy_test.go | 35 + pkg/api/pluginproxy/pluginproxy.go | 29 +- pkg/api/pluginproxy/pluginproxy_test.go | 125 +- pkg/api/plugins.go | 31 +- pkg/api/plugins_test.go | 38 +- pkg/api/render.go | 42 +- pkg/api/response/response.go | 4 +- pkg/api/response/response_test.go | 46 + pkg/api/routing/routing.go | 4 +- pkg/api/search.go | 7 +- pkg/api/short_url.go | 2 +- pkg/api/signup.go | 37 +- pkg/api/user.go | 181 +- pkg/api/user_test.go | 713 +- pkg/api/user_token.go | 22 +- pkg/api/user_token_test.go | 2 - pkg/api/webassets/webassets.go | 64 +- pkg/api/webassets/webassets_test.go | 114 +- pkg/apimachinery/apis/common/v0alpha1/doc.go | 5 + .../apis/common/v0alpha1/resource.go} | 27 +- .../apis/common/v0alpha1/types.go | 17 + .../apis/common/v0alpha1/unstructured.go | 118 + .../common}/v0alpha1/zz_generated.defaults.go | 0 .../common/v0alpha1/zz_generated.openapi.go} | 190 +- ...enerated.openapi_violation_exceptions.list | 37 + pkg/apimachinery/go.mod | 37 + pkg/apimachinery/go.sum | 104 + pkg/apis/dashboard/v0alpha1/doc.go | 6 + pkg/apis/dashboard/v0alpha1/register.go | 31 + pkg/apis/dashboard/v0alpha1/types.go | 114 + .../v0alpha1/zz_generated.deepcopy.go | 289 + .../v0alpha1/zz_generated.defaults.go | 19 + .../v0alpha1/zz_generated.openapi.go | 509 + ...enerated.openapi_violation_exceptions.list | 1 + pkg/apis/dashboardsnapshot/v0alpha1/doc.go | 6 + .../dashboardsnapshot/v0alpha1/register.go | 25 + pkg/apis/dashboardsnapshot/v0alpha1/types.go | 136 + .../v0alpha1/zz_generated.deepcopy.go | 271 + .../v0alpha1/zz_generated.defaults.go | 19 + .../v0alpha1/zz_generated.openapi.go | 521 + ...enerated.openapi_violation_exceptions.list | 3 + pkg/apis/datasource/v0alpha1/doc.go | 6 + pkg/apis/datasource/v0alpha1/register.go | 18 + pkg/apis/datasource/v0alpha1/types.go | 44 + .../v0alpha1/zz_generated.deepcopy.go | 100 + .../v0alpha1/zz_generated.defaults.go | 19 + .../v0alpha1/zz_generated.openapi.go | 175 + pkg/apis/example/v0alpha1/doc.go | 3 +- pkg/apis/example/v0alpha1/register.go | 30 + pkg/apis/example/v0alpha1/types.go | 23 +- .../example/v0alpha1/zz_generated.deepcopy.go | 1 + .../example/v0alpha1/zz_generated.openapi.go | 5 +- ...enerated.openapi_violation_exceptions.list | 1 + pkg/apis/featuretoggle/v0alpha1/doc.go | 6 + pkg/apis/featuretoggle/v0alpha1/register.go | 33 + pkg/apis/featuretoggle/v0alpha1/types.go | 117 + .../v0alpha1/zz_generated.deepcopy.go | 215 + .../v0alpha1/zz_generated.defaults.go | 19 + .../v0alpha1/zz_generated.openapi.go | 438 + ...enerated.openapi_violation_exceptions.list | 3 + pkg/apis/folder/v0alpha1/doc.go | 6 + pkg/apis/folder/v0alpha1/register.go | 26 + pkg/apis/folder/v0alpha1/types.go | 72 + .../v0alpha1/zz_generated.deepcopy.go | 86 +- .../folder/v0alpha1/zz_generated.defaults.go | 19 + .../folder/v0alpha1/zz_generated.openapi.go | 333 + ...enerated.openapi_violation_exceptions.list | 1 + pkg/apis/folders/v0alpha1/types.go | 60 - .../folders/v0alpha1/zz_generated.openapi.go | 2687 --- pkg/apis/{folders => peakq}/v0alpha1/doc.go | 5 +- pkg/apis/peakq/v0alpha1/register.go | 51 + pkg/apis/peakq/v0alpha1/types.go | 32 + .../peakq/v0alpha1/zz_generated.deepcopy.go | 105 + .../peakq/v0alpha1/zz_generated.defaults.go | 19 + .../peakq/v0alpha1/zz_generated.openapi.go | 157 + pkg/apis/playlist/v0alpha1/doc.go | 1 + pkg/apis/playlist/v0alpha1/register.go | 25 + pkg/apis/playlist/v0alpha1/types.go | 24 +- .../v0alpha1/zz_generated.deepcopy.go | 16 +- .../v0alpha1/zz_generated.defaults.go | 16 +- .../playlist/v0alpha1/zz_generated.openapi.go | 3 +- ...enerated.openapi_violation_exceptions.list | 1 + pkg/apis/query/v0alpha1/datasource.go | 49 + pkg/apis/query/v0alpha1/doc.go | 6 + pkg/apis/query/v0alpha1/query.go | 59 + pkg/apis/query/v0alpha1/query_test.go | 78 + pkg/apis/query/v0alpha1/register.go | 31 + pkg/apis/query/v0alpha1/template/doc.go | 6 + pkg/apis/query/v0alpha1/template/format.go | 68 + .../query/v0alpha1/template/format_test.go | 81 + pkg/apis/query/v0alpha1/template/render.go | 115 + .../query/v0alpha1/template/render_test.go | 210 + pkg/apis/query/v0alpha1/template/types.go | 112 + .../template/zz_generated.deepcopy.go | 131 + .../template/zz_generated.defaults.go | 19 + .../query/v0alpha1/zz_generated.deepcopy.go | 188 + .../query/v0alpha1/zz_generated.defaults.go | 19 + .../query/v0alpha1/zz_generated.openapi.go | 625 + ...enerated.openapi_violation_exceptions.list | 5 + pkg/apis/scope/v0alpha1/doc.go | 6 + pkg/apis/scope/v0alpha1/register.go | 58 + pkg/apis/scope/v0alpha1/types.go | 56 + .../scope/v0alpha1/zz_generated.deepcopy.go | 190 + .../scope/v0alpha1/zz_generated.defaults.go | 19 + .../scope/v0alpha1/zz_generated.openapi.go | 325 + ...enerated.openapi_violation_exceptions.list | 4 + pkg/apis/service/v0alpha1/doc.go | 6 + pkg/apis/service/v0alpha1/register.go | 50 + pkg/apis/service/v0alpha1/types.go | 27 + .../service/v0alpha1/zz_generated.deepcopy.go | 88 + .../service/v0alpha1/zz_generated.defaults.go | 19 + .../service/v0alpha1/zz_generated.openapi.go | 128 + .../builder}/common.go | 14 +- pkg/apiserver/builder/helper.go | 153 + pkg/apiserver/builder/openapi.go | 115 + .../builder}/request_handler.go | 57 +- pkg/apiserver/endpoints/filters/accept.go | 15 + .../endpoints/filters/accept_test.go | 46 + pkg/apiserver/endpoints/request/accept.go | 22 + .../endpoints/request/accept_test.go | 26 + .../responsewriter/responsewriter.go | 132 + .../responsewriter/responsewriter_test.go | 137 + pkg/apiserver/go.mod | 150 + pkg/apiserver/go.sum | 306 + .../registry/generic/strategy.go | 0 .../rest/dualwriter.go | 7 +- .../storage/file/file.go | 108 +- .../storage/file/restoptions.go | 38 +- .../storage/file/util.go | 20 +- .../storage/file/watchset.go | 0 pkg/build/cmd/publishstorybook.go | 2 +- pkg/build/config/version.go | 2 +- pkg/build/docker/build.go | 2 +- pkg/cmd/grafana-cli/commands/commands.go | 5 +- .../commands/conflict_user_command.go | 22 +- .../commands/conflict_user_command_test.go | 12 +- .../encrypt_datasource_passwords.go | 18 +- .../encrypt_datasource_passwords_test.go | 18 +- .../commands/reset_password_command.go | 21 +- pkg/cmd/grafana-cli/services/services.go | 3 +- pkg/cmd/grafana-server/commands/cli.go | 4 +- pkg/cmd/grafana/apiserver/README.md | 89 - pkg/cmd/grafana/apiserver/apiserver.md | 34 + pkg/cmd/grafana/apiserver/cmd.go | 47 +- .../{base => aggregator-test}/apiservice.yaml | 3 +- .../deploy/aggregator-test/externalname.yaml | 8 + .../kustomization.yaml | 2 +- .../apiserver/deploy/base/namespace.yaml | 4 - .../deploy/darwin/kustomization.yaml | 5 - .../apiserver/deploy/darwin/service.yaml | 10 - .../apiserver/deploy/linux/kustomization.yaml | 5 - .../apiserver/deploy/linux/service.yaml | 23 - pkg/cmd/grafana/apiserver/server.go | 154 +- pkg/cmd/grafana/main.go | 1 + pkg/codegen/generators.go | 19 +- pkg/codegen/jenny_basecorereg.go | 64 - pkg/codegen/jenny_core_registry.go | 62 + pkg/codegen/jenny_corekind.go | 72 - pkg/codegen/jenny_docs.go | 793 - pkg/codegen/jenny_eachmajor.go | 103 +- pkg/codegen/jenny_go_resources.go | 128 - pkg/codegen/jenny_go_spec.go | 46 + pkg/codegen/jenny_go_types.go | 42 - pkg/codegen/jenny_k8_resources.go | 130 + pkg/codegen/jenny_ts_resources.go | 85 - pkg/codegen/jenny_ts_types.go | 12 +- pkg/codegen/jenny_tsveneerindex.go | 2 + pkg/codegen/latest_jenny.go | 54 - pkg/codegen/tmpl.go | 31 +- pkg/codegen/tmpl/addenda.tmpl | 64 - pkg/codegen/tmpl/autogen_header.tmpl | 28 - pkg/codegen/tmpl/core_metadata.tmpl | 33 + pkg/codegen/tmpl/core_registry.tmpl | 46 + pkg/codegen/tmpl/core_resource.tmpl | 17 +- pkg/codegen/tmpl/core_status.tmpl | 65 + pkg/codegen/tmpl/coremodel_imports.tmpl | 10 - pkg/codegen/tmpl/cuetsy_multi.tmpl | 2 - pkg/codegen/tmpl/docs.tmpl | 21 - pkg/codegen/tmpl/kind_core.tmpl | 70 - pkg/codegen/tmpl/kind_registry.tmpl | 61 - pkg/codegen/tmpl/plugin_lineage_binding.tmpl | 12 - pkg/codegen/tmpl/plugin_lineage_file.tmpl | 59 - pkg/codegen/tmpl/plugin_registry_ref.tmpl | 16 - pkg/codegen/util_go.go | 60 - .../imguploader/azureblobuploader_test.go | 2 +- pkg/components/imguploader/imguploader.go | 16 +- .../imguploader/imguploader_test.go | 30 +- pkg/components/imguploader/s3uploader_test.go | 2 +- pkg/expr/classic/classic.go | 42 +- pkg/expr/commands.go | 27 +- pkg/expr/commands_test.go | 2 +- pkg/expr/converter.go | 361 + pkg/expr/converter_test.go | 121 + pkg/expr/dataplane_test.go | 13 +- pkg/expr/graph.go | 40 + pkg/expr/graph_test.go | 5 +- pkg/expr/mathexp/parse/node.go | 4 + pkg/expr/mathexp/reduce.go | 42 +- pkg/expr/mathexp/reduce_test.go | 6 +- pkg/expr/mathexp/resample.go | 33 +- pkg/expr/mathexp/resample_test.go | 4 +- pkg/expr/mathexp/types.go | 47 +- pkg/expr/ml.go | 2 +- pkg/expr/models.go | 102 + pkg/expr/nodes.go | 376 +- pkg/expr/nodes_test.go | 108 - pkg/expr/reader.go | 186 + pkg/expr/service.go | 10 +- pkg/expr/service_test.go | 14 +- pkg/expr/sql/parser.go | 99 + pkg/expr/sql/parser_test.go | 58 + pkg/expr/sql_command.go | 105 + pkg/expr/sql_command_test.go | 26 + pkg/expr/threshold.go | 28 +- pkg/expr/threshold_test.go | 16 +- .../applyconfiguration/internal/internal.go | 48 + .../service/v0alpha1/externalname.go | 196 + .../service/v0alpha1/externalnamespec.go | 25 + pkg/generated/applyconfiguration/utils.go | 25 + .../clientset/versioned/clientset.go | 106 + .../versioned/fake/clientset_generated.go | 71 + pkg/generated/clientset/versioned/fake/doc.go | 6 + .../clientset/versioned/fake/register.go | 42 + .../clientset/versioned/scheme/doc.go | 6 + .../clientset/versioned/scheme/register.go | 42 + .../versioned/typed/service/v0alpha1/doc.go | 6 + .../typed/service/v0alpha1/externalname.go | 194 + .../typed/service/v0alpha1/fake/doc.go | 6 + .../v0alpha1/fake/fake_externalname.go | 140 + .../v0alpha1/fake/fake_service_client.go | 26 + .../service/v0alpha1/generated_expansion.go | 7 + .../typed/service/v0alpha1/service_client.go | 93 + .../informers/externalversions/factory.go | 247 + .../informers/externalversions/generic.go | 48 + .../internalinterfaces/factory_interfaces.go | 26 + .../externalversions/service/interface.go | 32 + .../service/v0alpha1/externalname.go | 76 + .../service/v0alpha1/interface.go | 31 + .../service/v0alpha1/expansion_generated.go | 13 + .../listers/service/v0alpha1/externalname.go | 85 + pkg/infra/appcontext/user.go | 12 +- pkg/infra/db/db.go | 10 +- pkg/infra/filestorage/fs_integration_test.go | 5 + .../datasource_metrics_middleware.go | 15 +- .../datasource_metrics_middleware_test.go | 72 +- .../grafana_request_id_header_middleware.go | 34 + ...afana_request_id_header_middleware_test.go | 113 + .../http_client_provider.go | 9 +- .../http_client_provider_test.go | 9 +- .../response_limit_middleware.go | 31 - .../response_limit_middleware_test.go | 60 - .../httpclientprovider/sigv4_middleware.go | 47 - .../sigv4_middleware_test.go | 118 - pkg/infra/httpclient/max_bytes_reader.go | 66 - pkg/infra/httpclient/max_bytes_reader_test.go | 40 - pkg/infra/kvstore/kvstore_test.go | 5 + pkg/infra/log/log.go | 31 + pkg/infra/metrics/metrics.go | 113 +- pkg/infra/metrics/service.go | 14 +- pkg/infra/metrics/settings.go | 3 +- pkg/infra/remotecache/redis_storage.go | 11 +- pkg/infra/remotecache/remotecache_test.go | 5 + pkg/infra/serverlock/serverlock_test.go | 5 + pkg/infra/usagestats/service/api.go | 5 +- pkg/infra/usagestats/service/api_test.go | 4 +- pkg/infra/usagestats/service/usage_stats.go | 43 +- .../usagestats/service/usage_stats_test.go | 7 +- .../statscollector/concurrent_users_test.go | 6 + .../usagestats/statscollector/service.go | 29 +- .../usagestats/statscollector/service_test.go | 17 +- pkg/kinds/accesspolicy/accesspolicy_gen.go | 12 +- .../accesspolicy/accesspolicy_kind_gen.go | 79 - .../accesspolicy/accesspolicy_metadata_gen.go | 2 +- .../accesspolicy/accesspolicy_status_gen.go | 2 +- pkg/kinds/dashboard/dashboard_gen.go | 12 +- pkg/kinds/dashboard/dashboard_kind_gen.go | 79 - pkg/kinds/dashboard/dashboard_metadata_gen.go | 2 +- pkg/kinds/dashboard/dashboard_spec_gen.go | 47 +- pkg/kinds/dashboard/dashboard_status_gen.go | 2 +- pkg/kinds/general.go | 382 +- pkg/kinds/general_test.go | 35 - pkg/kinds/librarypanel/librarypanel_gen.go | 12 +- .../librarypanel/librarypanel_kind_gen.go | 79 - .../librarypanel/librarypanel_metadata_gen.go | 2 +- .../librarypanel/librarypanel_status_gen.go | 2 +- pkg/kinds/preferences/preferences_gen.go | 12 +- pkg/kinds/preferences/preferences_kind_gen.go | 79 - .../preferences/preferences_metadata_gen.go | 2 +- .../preferences/preferences_status_gen.go | 2 +- .../publicdashboard/publicdashboard_gen.go | 12 +- .../publicdashboard_kind_gen.go | 79 - .../publicdashboard_metadata_gen.go | 2 +- .../publicdashboard_status_gen.go | 2 +- pkg/kinds/role/role_gen.go | 12 +- pkg/kinds/role/role_kind_gen.go | 79 - pkg/kinds/role/role_metadata_gen.go | 2 +- pkg/kinds/role/role_status_gen.go | 2 +- pkg/kinds/rolebinding/rolebinding_gen.go | 12 +- pkg/kinds/rolebinding/rolebinding_kind_gen.go | 79 - .../rolebinding/rolebinding_metadata_gen.go | 2 +- .../rolebinding/rolebinding_status_gen.go | 2 +- pkg/kinds/team/team_gen.go | 12 +- pkg/kinds/team/team_kind_gen.go | 79 - pkg/kinds/team/team_metadata_gen.go | 2 +- pkg/kinds/team/team_status_gen.go | 2 +- pkg/kindsysreport/attributes.go | 74 - pkg/kindsysreport/codegen/report.go | 391 - pkg/kindsysreport/codegen/report.json | 2338 --- pkg/kindsysreport/codeowners.go | 61 - pkg/login/social/connectors/azuread_oauth.go | 73 +- .../social/connectors/azuread_oauth_test.go | 287 + pkg/login/social/connectors/common.go | 82 +- pkg/login/social/connectors/generic_oauth.go | 118 +- .../social/connectors/generic_oauth_test.go | 557 +- pkg/login/social/connectors/github_oauth.go | 94 +- .../social/connectors/github_oauth_test.go | 274 + pkg/login/social/connectors/gitlab_oauth.go | 43 +- .../social/connectors/gitlab_oauth_test.go | 215 +- pkg/login/social/connectors/google_oauth.go | 88 +- .../social/connectors/google_oauth_test.go | 278 + .../social/connectors/grafana_com_oauth.go | 57 +- .../connectors/grafana_com_oauth_test.go | 237 + pkg/login/social/connectors/okta_oauth.go | 54 +- .../social/connectors/okta_oauth_test.go | 244 + pkg/login/social/connectors/social_base.go | 96 +- pkg/login/social/socialimpl/service.go | 9 +- pkg/login/social/socialimpl/service_test.go | 113 +- pkg/middleware/auth.go | 2 +- pkg/middleware/loggermw/logger.go | 7 +- pkg/middleware/middleware_test.go | 6 +- pkg/middleware/recovery.go | 13 +- pkg/middleware/recovery_test.go | 3 +- pkg/middleware/request_metrics.go | 18 +- pkg/middleware/request_test.go | 2 +- pkg/plugins/apiserver.go | 27 + pkg/plugins/apiserver_test.go | 32 + pkg/plugins/auth/models.go | 4 +- .../pluginextensionv2/generate.sh | 2 +- .../pluginextensionv2/rendererv2.pb.go | 226 +- .../pluginextensionv2/rendererv2.proto | 1 + .../pluginextensionv2/rendererv2_grpc.pb.go | 146 + .../pluginextensionv2/sanitizer.pb.go | 88 +- .../pluginextensionv2/sanitizer_grpc.pb.go | 109 + .../secretsmanagerplugin/secretsmanager.pb.go | 4 +- .../secretsmanager_grpc.pb.go | 2 +- pkg/plugins/codegen/jenny_plugin_registry.go | 74 + pkg/plugins/codegen/jenny_plugingotypes.go | 4 +- pkg/plugins/codegen/jenny_pluginseachmajor.go | 101 - pkg/plugins/codegen/jenny_plugintreelist.go | 100 - pkg/plugins/codegen/jenny_plugintstypes.go | 86 +- pkg/plugins/codegen/tmpl.go | 15 +- .../codegen/tmpl/composable_registry.tmpl | 132 + pkg/plugins/codegen/tmpl/plugin_registry.tmpl | 30 - pkg/plugins/codegen/util_go.go | 68 - pkg/plugins/config/config.go | 83 +- pkg/plugins/envvars/envvars.go | 298 +- pkg/plugins/ifaces.go | 9 +- pkg/plugins/log/fake.go | 31 +- pkg/plugins/manager/client/client.go | 23 +- pkg/plugins/manager/client/client_test.go | 17 +- pkg/plugins/manager/fakes/fakes.go | 52 +- pkg/plugins/manager/filestore/fs.go | 4 +- pkg/plugins/manager/installer.go | 14 +- pkg/plugins/manager/installer_test.go | 12 +- .../manager/loader/assetpath/assetpath.go | 19 +- .../loader/assetpath/assetpath_test.go | 54 +- pkg/plugins/manager/loader/finder/local.go | 20 +- .../manager/loader/finder/local_test.go | 136 +- pkg/plugins/manager/loader/loader.go | 4 + pkg/plugins/manager/loader/loader_test.go | 144 +- .../manager/pipeline/bootstrap/bootstrap.go | 2 +- .../manager/pipeline/bootstrap/steps.go | 19 +- .../manager/pipeline/bootstrap/steps_test.go | 43 +- .../manager/pipeline/discovery/discovery.go | 2 +- .../manager/pipeline/discovery/steps.go | 44 +- .../pipeline/initialization/initialization.go | 4 +- .../manager/pipeline/initialization/steps.go | 2 +- .../pipeline/initialization/steps_test.go | 8 +- .../manager/pipeline/termination/steps.go | 2 +- .../pipeline/termination/termination.go | 4 +- .../manager/pipeline/validation/steps.go | 8 +- .../manager/pipeline/validation/validation.go | 4 +- pkg/plugins/manager/process/process.go | 24 +- pkg/plugins/manager/process/process_test.go | 51 +- pkg/plugins/manager/registry/ifaces.go | 6 +- pkg/plugins/manager/registry/in_memory.go | 5 +- .../manager/registry/in_memory_test.go | 64 +- pkg/plugins/manager/signature/authorizer.go | 6 +- pkg/plugins/manager/signature/manifest.go | 8 +- .../manager/signature/manifest_test.go | 16 +- .../manager/sources/source_local_disk.go | 32 + .../manager/sources/source_local_disk_test.go | 71 + pkg/plugins/manager/sources/sources.go | 60 +- pkg/plugins/manager/sources/sources_test.go | 81 +- .../oauth-external-registration/plugin.json | 37 - pkg/plugins/models.go | 1 + pkg/plugins/pfs/corelist/corelist_load_gen.go | 1 - pkg/plugins/pfs/decl.go | 20 +- pkg/plugins/pfs/decl_parser.go | 16 +- pkg/plugins/pfs/errors.go | 10 - pkg/plugins/pfs/grafanaplugin.cue | 31 - pkg/plugins/pfs/pfs.go | 151 +- pkg/plugins/pfs/pfs_test.go | 3 - pkg/plugins/pfs/plugin.go | 4 +- pkg/plugins/pfs/plugindef_types.go | 42 + pkg/plugins/plugindef/gen.go | 130 - pkg/plugins/plugindef/pascal_test.go | 43 - pkg/plugins/plugindef/plugindef.cue | 439 - pkg/plugins/plugindef/plugindef.go | 73 - .../plugindef/plugindef_bindings_gen.go | 84 - pkg/plugins/plugindef/plugindef_types_gen.go | 495 - pkg/plugins/plugins.go | 9 +- pkg/plugins/plugins_test.go | 5 +- pkg/plugins/pluginscdn/pluginscdn.go | 4 +- pkg/plugins/pluginscdn/pluginscdn_test.go | 4 +- pkg/plugins/repo/service.go | 2 +- pkg/promlib/README.md | 13 + .../prometheus => promlib}/client/client.go | 2 +- .../client/client_test.go | 12 +- .../client/transport.go | 27 +- pkg/promlib/client/transport_test.go | 27 + pkg/{util => promlib}/converter/prom.go | 78 +- pkg/{util => promlib}/converter/prom_test.go | 28 +- .../testdata/loki-streams-a-frame.jsonc | 0 .../converter/testdata/loki-streams-a.json | 0 .../testdata/loki-streams-b-frame.jsonc | 0 .../converter/testdata/loki-streams-b.json | 0 .../testdata/loki-streams-c-frame.jsonc | 0 .../converter/testdata/loki-streams-c.json | 0 ...ki-streams-structured-metadata-frame.jsonc | 0 .../loki-streams-structured-metadata.json | 0 .../converter/testdata/prom-error-frame.jsonc | 0 .../converter/testdata/prom-error.json | 0 .../testdata/prom-exemplars-a-frame.json | 0 .../testdata/prom-exemplars-a-frame.jsonc | 0 .../testdata/prom-exemplars-a-golden.txt | 0 .../converter/testdata/prom-exemplars-a.json | 0 .../testdata/prom-exemplars-b-frame.json | 0 .../testdata/prom-exemplars-b-frame.jsonc | 0 .../testdata/prom-exemplars-b-golden.txt | 0 .../converter/testdata/prom-exemplars-b.json | 0 .../prom-exemplars-diff-labels-frame.jsonc | 0 .../testdata/prom-exemplars-diff-labels.json | 0 .../testdata/prom-exemplars-frame.jsonc | 0 .../converter/testdata/prom-exemplars.json | 0 .../testdata/prom-labels-frame.jsonc | 0 .../converter/testdata/prom-labels.json | 0 .../testdata/prom-matrix-frame.jsonc | 0 ...rom-matrix-histogram-no-labels-frame.jsonc | 0 .../prom-matrix-histogram-no-labels.json | 0 ...m-matrix-histogram-partitioned-frame.jsonc | 0 .../prom-matrix-histogram-partitioned.json | 0 .../prom-matrix-with-nans-frame.jsonc | 0 .../testdata/prom-matrix-with-nans.json | 0 .../converter/testdata/prom-matrix.json | 0 .../testdata/prom-scalar-frame.jsonc | 0 .../converter/testdata/prom-scalar.json | 0 .../testdata/prom-series-frame.jsonc | 0 .../converter/testdata/prom-series.json | 0 .../testdata/prom-string-frame.jsonc | 0 .../converter/testdata/prom-string.json | 0 .../testdata/prom-vector-frame.jsonc | 0 ...rom-vector-histogram-no-labels-frame.jsonc | 0 .../prom-vector-histogram-no-labels.json | 0 .../converter/testdata/prom-vector.json | 0 .../testdata/prom-warnings-frame.jsonc | 0 .../converter/testdata/prom-warnings.json | 0 pkg/promlib/go.mod | 109 + pkg/promlib/go.sum | 129 + .../prometheus => promlib}/healthcheck.go | 25 +- .../healthcheck_test.go | 31 +- .../prometheus => promlib}/heuristics.go | 8 +- .../prometheus => promlib}/heuristics_test.go | 29 +- .../instrumentation/instrumentation.go | 0 .../instrumentation/instrumentation_test.go | 0 pkg/promlib/intervalv2/intervalv2.go | 74 + pkg/promlib/intervalv2/intervalv2_test.go | 63 + pkg/promlib/library.go | 156 + pkg/promlib/library_test.go | 147 + .../middleware/custom_query_params.go | 0 .../middleware/custom_query_params_test.go | 0 .../middleware/force_http_get.go | 0 .../middleware/force_http_get_test.go | 0 .../prometheus => promlib}/models/query.go | 146 +- .../models/query_test.go | 94 +- .../prometheus => promlib}/models/result.go | 1 + pkg/promlib/models/scope.go | 85 + .../querydata/exemplar/framer.go | 0 .../querydata/exemplar/labels.go | 0 .../querydata/exemplar/sampler.go | 2 +- .../querydata/exemplar/sampler_stddev.go | 2 +- .../querydata/exemplar/sampler_stddev_test.go | 4 +- .../querydata/exemplar/sampler_test.go | 4 +- .../exemplar/testdata/noop_sampler.jsonc | 0 .../exemplar/testdata/stddev_sampler.jsonc | 0 .../querydata/framing_bench_test.go | 13 +- .../querydata/framing_test.go | 19 +- .../querydata/request.go | 58 +- .../querydata/request_test.go | 59 +- .../querydata/response.go | 15 +- .../querydata/response_test.go | 14 +- .../resource/resource.go | 7 +- .../testdata/exemplar.query.json | 0 .../testdata/exemplar.result.golden.jsonc | 0 .../testdata/exemplar.result.json | 0 .../testdata/range_auto.query.json | 0 .../testdata/range_auto.result.golden.jsonc | 0 .../testdata/range_auto.result.json | 0 .../testdata/range_infinity.query.json | 0 .../range_infinity.result.golden.jsonc | 0 .../testdata/range_infinity.result.json | 0 .../testdata/range_missing.query.json | 0 .../range_missing.result.golden.jsonc | 0 .../testdata/range_missing.result.json | 0 .../testdata/range_nan.query.json | 0 .../testdata/range_nan.result.golden.jsonc | 0 .../testdata/range_nan.result.json | 0 .../testdata/range_simple.query.json | 0 .../testdata/range_simple.result.golden.jsonc | 0 .../testdata/range_simple.result.json | 0 .../prometheus => promlib}/utils/utils.go | 0 pkg/registry/apis/apis.go | 14 + .../apis/dashboard/access/sql_dashboards.go | 427 + pkg/registry/apis/dashboard/access/token.go | 63 + pkg/registry/apis/dashboard/access/types.go | 31 + pkg/registry/apis/dashboard/authorizer.go | 95 + pkg/registry/apis/dashboard/legacy_storage.go | 155 + pkg/registry/apis/dashboard/register.go | 222 + pkg/registry/apis/dashboard/sub_access.go | 114 + pkg/registry/apis/dashboard/sub_versions.go | 117 + .../apis/dashboard/summary_storage.go | 74 + .../apis/dashboardsnapshot/conversions.go | 71 + .../apis/dashboardsnapshot/exporter.go | 130 + .../apis/dashboardsnapshot/options_storage.go | 91 + .../apis/dashboardsnapshot/register.go | 377 + .../apis/dashboardsnapshot/sql_storage.go | 147 + .../apis/dashboardsnapshot/sub_body.go | 60 + pkg/registry/apis/datasource/README.md | 8 + pkg/registry/apis/datasource/authorizer.go | 82 + pkg/registry/apis/datasource/connections.go | 61 + pkg/registry/apis/datasource/middleware.go | 23 + pkg/registry/apis/datasource/plugincontext.go | 123 + pkg/registry/apis/datasource/querier.go | 148 + pkg/registry/apis/datasource/register.go | 354 + pkg/registry/apis/datasource/sub_health.go | 82 + pkg/registry/apis/datasource/sub_proxy.go | 48 + pkg/registry/apis/datasource/sub_query.go | 88 + pkg/registry/apis/datasource/sub_resource.go | 81 + pkg/registry/apis/example/dummy_storage.go | 17 +- pkg/registry/apis/example/register.go | 19 +- pkg/registry/apis/example/storage.go | 2 +- pkg/registry/apis/example/subresource.go | 2 +- pkg/registry/apis/featuretoggle/README.md | 5 + pkg/registry/apis/featuretoggle/current.go | 238 + .../apis/featuretoggle/current_test.go | 460 + pkg/registry/apis/featuretoggle/features.go | 114 + pkg/registry/apis/featuretoggle/register.go | 213 + pkg/registry/apis/featuretoggle/toggles.go | 92 + pkg/registry/apis/folders/conversions.go | 42 +- pkg/registry/apis/folders/legacy_storage.go | 31 +- pkg/registry/apis/folders/register.go | 164 +- pkg/registry/apis/folders/storage.go | 6 +- pkg/registry/apis/folders/sub_access.go | 78 + pkg/registry/apis/folders/sub_children.go | 72 - pkg/registry/apis/folders/sub_count.go | 75 + pkg/registry/apis/folders/sub_parents.go | 26 +- pkg/registry/apis/peakq/register.go | 177 + pkg/registry/apis/peakq/render.go | 107 + pkg/registry/apis/peakq/render_examples.go | 74 + .../apis/peakq/render_examples_test.go | 21 + pkg/registry/apis/peakq/storage.go | 62 + pkg/registry/apis/playlist/conversions.go | 30 +- .../apis/playlist/conversions_test.go | 2 +- pkg/registry/apis/playlist/legacy_storage.go | 2 +- pkg/registry/apis/playlist/register.go | 40 +- pkg/registry/apis/playlist/storage.go | 4 +- pkg/registry/apis/query/client.go | 22 + pkg/registry/apis/query/client/plugin.go | 179 + pkg/registry/apis/query/client/testdata.go | 75 + pkg/registry/apis/query/metrics.go | 48 + pkg/registry/apis/query/parser.go | 216 + pkg/registry/apis/query/parser_test.go | 131 + pkg/registry/apis/query/plugins.go | 62 + pkg/registry/apis/query/query.go | 272 + pkg/registry/apis/query/register.go | 304 + .../query/testdata/cyclic-references.json | 29 + .../testdata/multiple-uids-same-plugin.json | 60 + .../apis/query/testdata/self-reference.json | 20 + .../apis/query/testdata/with-expressions.json | 79 + pkg/registry/apis/scope/register.go | 99 + pkg/registry/apis/scope/storage.go | 98 + pkg/registry/apis/service/register.go | 97 + pkg/registry/apis/service/storage.go | 62 + pkg/registry/apis/wireset.go | 23 +- .../backgroundsvcs/background_services.go | 16 +- pkg/registry/corekind/base.go | 42 - pkg/registry/corekind/base_gen.go | 159 - pkg/registry/schemas/composable_kind.go | 458 + pkg/registry/schemas/core_kind.go | 115 + pkg/server/test_env.go | 4 + pkg/server/wire.go | 41 +- pkg/server/wireexts_oss.go | 5 + pkg/services/accesscontrol/accesscontrol.go | 173 +- .../accesscontrol/accesscontrol_test.go | 208 - .../accesscontrol/acimpl/accesscontrol.go | 21 +- pkg/services/accesscontrol/acimpl/service.go | 176 +- .../accesscontrol/acimpl/service_test.go | 130 +- pkg/services/accesscontrol/actest/common.go | 2 +- pkg/services/accesscontrol/actest/fake.go | 13 + .../accesscontrol/actest/store_mock.go | 59 +- pkg/services/accesscontrol/api/api.go | 50 +- pkg/services/accesscontrol/api/api_test.go | 77 + .../accesscontrol/authorize_in_org_test.go | 18 + .../accesscontrol/database/database.go | 106 +- .../accesscontrol/database/database_test.go | 119 +- pkg/services/accesscontrol/errors.go | 10 + pkg/services/accesscontrol/filter_test.go | 5 + pkg/services/accesscontrol/middleware.go | 116 +- .../accesscontrol/migrator/migrator_test.go | 5 + pkg/services/accesscontrol/mock/mock.go | 31 + pkg/services/accesscontrol/models.go | 31 +- .../ossaccesscontrol/permissions_services.go | 53 +- .../accesscontrol/pluginutils/utils.go | 19 + .../accesscontrol/pluginutils/utils_test.go | 21 +- .../accesscontrol/resourcepermissions/api.go | 143 +- .../resourcepermissions/api_test.go | 7 +- .../resourcepermissions/service.go | 5 +- .../resourcepermissions/service_test.go | 7 +- .../resourcepermissions/store.go | 4 +- .../resourcepermissions/store_bench_test.go | 5 +- .../resourcepermissions/store_test.go | 5 + pkg/services/accesscontrol/roles.go | 36 +- pkg/services/accesscontrol/ssoutils/utils.go | 24 + pkg/services/alerting/alerting_usage.go | 114 - pkg/services/alerting/alerting_usage_test.go | 122 - pkg/services/alerting/conditions/evaluator.go | 157 - .../alerting/conditions/evaluator_test.go | 62 - pkg/services/alerting/conditions/query.go | 430 - .../conditions/query_interval_test.go | 191 - .../alerting/conditions/query_test.go | 386 - pkg/services/alerting/conditions/reducer.go | 174 - .../alerting/conditions/reducer_test.go | 409 - pkg/services/alerting/engine.go | 288 - .../alerting/engine_integration_test.go | 178 - pkg/services/alerting/engine_test.go | 231 - pkg/services/alerting/eval_context.go | 281 - pkg/services/alerting/eval_context_test.go | 421 - pkg/services/alerting/eval_handler.go | 81 - pkg/services/alerting/eval_handler_test.go | 208 - pkg/services/alerting/extractor.go | 306 - pkg/services/alerting/extractor_test.go | 313 - pkg/services/alerting/interfaces.go | 65 - pkg/services/alerting/models.go | 52 - pkg/services/alerting/models/alert.go | 206 - .../alerting/models/alert_notification.go | 154 - pkg/services/alerting/models/alert_test.go | 64 - pkg/services/alerting/notifier.go | 331 - pkg/services/alerting/notifier_test.go | 405 - .../alerting/notifiers/alertmanager.go | 206 - .../alerting/notifiers/alertmanager_test.go | 138 - pkg/services/alerting/notifiers/base.go | 151 - pkg/services/alerting/notifiers/base_test.go | 221 - pkg/services/alerting/notifiers/dingding.go | 158 - .../alerting/notifiers/dingding_test.go | 61 - pkg/services/alerting/notifiers/discord.go | 250 - .../alerting/notifiers/discord_test.go | 57 - pkg/services/alerting/notifiers/email.go | 125 - pkg/services/alerting/notifiers/email_test.go | 80 - pkg/services/alerting/notifiers/googlechat.go | 246 - .../alerting/notifiers/googlechat_test.go | 53 - pkg/services/alerting/notifiers/hipchat.go | 183 - .../alerting/notifiers/hipchat_test.go | 81 - pkg/services/alerting/notifiers/kafka.go | 132 - pkg/services/alerting/notifiers/kafka_test.go | 55 - pkg/services/alerting/notifiers/line.go | 101 - pkg/services/alerting/notifiers/line_test.go | 49 - pkg/services/alerting/notifiers/opsgenie.go | 246 - .../alerting/notifiers/opsgenie_test.go | 228 - pkg/services/alerting/notifiers/pagerduty.go | 248 - .../alerting/notifiers/pagerduty_test.go | 542 - pkg/services/alerting/notifiers/pushover.go | 415 - .../alerting/notifiers/pushover_test.go | 98 - pkg/services/alerting/notifiers/sensu.go | 155 - pkg/services/alerting/notifiers/sensu_test.go | 57 - pkg/services/alerting/notifiers/sensugo.go | 206 - .../alerting/notifiers/sensugo_test.go | 60 - pkg/services/alerting/notifiers/slack.go | 478 - pkg/services/alerting/notifiers/slack_test.go | 273 - pkg/services/alerting/notifiers/teams.go | 144 - pkg/services/alerting/notifiers/teams_test.go | 75 - pkg/services/alerting/notifiers/telegram.go | 280 - .../alerting/notifiers/telegram_test.go | 128 - pkg/services/alerting/notifiers/threema.go | 167 - .../alerting/notifiers/threema_test.go | 126 - pkg/services/alerting/notifiers/victorops.go | 165 - .../alerting/notifiers/victorops_test.go | 165 - pkg/services/alerting/notifiers/webhook.go | 162 - .../alerting/notifiers/webhook_test.go | 51 - pkg/services/alerting/reader.go | 51 - pkg/services/alerting/result_handler.go | 115 - pkg/services/alerting/rule.go | 245 - pkg/services/alerting/rule_test.go | 247 - pkg/services/alerting/scheduler.go | 84 - pkg/services/alerting/service.go | 172 - pkg/services/alerting/service_test.go | 160 - pkg/services/alerting/store.go | 415 - pkg/services/alerting/store_notification.go | 594 - .../alerting/store_notification_test.go | 504 - pkg/services/alerting/store_test.go | 492 - pkg/services/alerting/test_notification.go | 92 - pkg/services/alerting/test_rule.go | 47 - .../alerting/testdata/collapsed-panels.json | 596 - .../alerting/testdata/dash-without-id.json | 282 - .../alerting/testdata/graphite-alert.json | 64 - .../alerting/testdata/influxdb-alert.json | 283 - .../testdata/panel-with-bad-query-id.json | 184 - .../testdata/panel-with-datasource-ref.json | 38 - .../alerting/testdata/panel-with-id-0.json | 63 - .../panel-without-specified-datasource.json | 184 - .../alerting/testdata/panels-missing-id.json | 62 - .../alerting/testdata/settings/empty.json | 1 - .../testdata/settings/one_condition.json | 38 - .../testdata/settings/three_conditions.json | 94 - .../testdata/settings/two_conditions.json | 66 - .../alerting/testdata/v5-dashboard.json | 60 - .../accesscontrol/accesscontrol.go | 21 +- .../accesscontrol/accesscontrol_test.go | 12 +- .../annotationsimpl/annotations.go | 4 +- .../annotationsimpl/annotations_test.go | 8 +- .../annotationsimpl/cleanup_test.go | 129 +- .../annotationsimpl/composite_store.go | 37 +- .../annotationsimpl/composite_store_test.go | 48 +- .../annotationsimpl/loki/historian_store.go | 252 +- .../loki/historian_store_test.go | 794 + .../annotations/annotationsimpl/store.go | 6 + .../annotations/annotationsimpl/xorm_store.go | 125 +- pkg/services/annotations/testutil/testutil.go | 7 +- .../anonymous/anonimpl/anonstore/database.go | 88 + .../anonimpl/anonstore/database_test.go | 5 + .../anonymous/anonimpl/anonstore/fake.go | 4 + pkg/services/anonymous/anonimpl/api/api.go | 56 +- pkg/services/anonymous/anonimpl/impl.go | 12 +- pkg/services/anonymous/anonimpl/impl_test.go | 94 + pkg/services/anonymous/sortopts/sortopts.go | 97 + pkg/services/apikey/apikeyimpl/store_test.go | 5 + .../README.md | 6 +- pkg/services/apiserver/aggregator/README.md | 127 + .../apiserver/aggregator/aggregator.go | 458 + .../aggregator/availableController.go | 466 + pkg/services/apiserver/aggregator/config.go | 68 + .../examples/autoregister/apiservices.yaml | 14 + .../examples/manual-test/apiservice.yaml | 15 + .../examples/manual-test/externalname.yaml | 8 + pkg/services/apiserver/aggregator/resolver.go | 32 + .../apiserver/auth/authenticator/provider.go | 11 + .../auth/authenticator/signedinuser.go | 45 + .../auth/authenticator/signedinuser_test.go | 88 + .../auth/authorizer/impersonation.go | 0 .../auth/authorizer/impersonation_test.go | 0 .../auth/authorizer/org_id.go | 2 +- .../auth/authorizer/org_role.go | 0 .../auth/authorizer/provider.go | 3 + .../auth/authorizer/stack_id.go | 2 +- pkg/services/apiserver/config.go | 57 + .../endpoints/request/namespace.go | 6 +- .../endpoints/request/namespace_test.go | 23 +- pkg/services/apiserver/options/aggregator.go | 110 + pkg/services/apiserver/options/extra.go | 46 + .../options}/log.go | 2 +- pkg/services/apiserver/options/options.go | 147 + pkg/services/apiserver/options/storage.go | 59 + pkg/services/apiserver/service.go | 451 + pkg/services/apiserver/standalone/factory.go | 164 + pkg/services/apiserver/standalone/runtime.go | 46 + .../apiserver/standalone/runtime_test.go | 22 + .../storage/entity/restoptions.go | 4 +- .../apiserver/storage/entity/selector.go | 81 + .../storage/entity/storage.go | 418 +- .../storage/entity/utils.go | 41 +- .../storage/entity/utils_test.go | 35 +- .../utils/clientConfig.go | 0 pkg/services/apiserver/utils/meta.go | 278 + pkg/services/apiserver/utils/meta_test.go | 208 + .../utils/tableConverter.go | 0 .../utils/tableConverter_test.go | 2 +- .../utils/uids.go | 0 .../wireset.go | 6 +- pkg/services/auth/authimpl/auth_token.go | 16 +- pkg/services/auth/authimpl/auth_token_test.go | 9 +- pkg/services/auth/id.go | 8 +- pkg/services/auth/identity/requester.go | 10 +- pkg/services/auth/idimpl/service.go | 91 +- pkg/services/auth/idimpl/service_test.go | 57 +- pkg/services/auth/idimpl/signer.go | 2 +- pkg/services/auth/idtest/mock.go | 18 + pkg/services/auth/jwt/auth.go | 2 +- pkg/services/auth/jwt/auth_test.go | 45 +- pkg/services/auth/jwt/jwt.go | 4 +- pkg/services/auth/jwt/key_sets.go | 24 +- pkg/services/auth/jwt/validation.go | 2 +- pkg/services/authn/authn.go | 4 + pkg/services/authn/authnimpl/service.go | 65 +- .../authn/authnimpl/sync/oauth_token_sync.go | 131 +- .../authnimpl/sync/oauth_token_sync_test.go | 180 +- pkg/services/authn/authnimpl/sync/org_sync.go | 69 +- .../authn/authnimpl/sync/org_sync_test.go | 96 + .../authn/authnimpl/sync/permission_sync.go | 44 - .../authnimpl/sync/permission_sync_test.go | 65 - .../authn/authnimpl/sync/rbac_sync.go | 93 + .../authn/authnimpl/sync/rbac_sync_test.go | 157 + .../authn/authnimpl/sync/user_sync.go | 2 +- pkg/services/authn/authnimpl/usage_stats.go | 4 +- .../authn/authnimpl/usage_stats_test.go | 4 +- pkg/services/authn/authntest/fake.go | 6 +- pkg/services/authn/authntest/mock.go | 4 + pkg/services/authn/clients/api_key.go | 21 +- pkg/services/authn/clients/api_key_test.go | 34 +- pkg/services/authn/clients/basic.go | 5 - pkg/services/authn/clients/basic_test.go | 6 - pkg/services/authn/clients/ext_jwt.go | 9 +- pkg/services/authn/clients/ext_jwt_test.go | 36 +- pkg/services/authn/clients/grafana.go | 8 +- pkg/services/authn/clients/grafana_test.go | 6 +- pkg/services/authn/clients/identity.go | 33 + pkg/services/authn/clients/jwt.go | 68 +- pkg/services/authn/clients/jwt_test.go | 306 +- pkg/services/authn/clients/oauth.go | 113 +- pkg/services/authn/clients/oauth_test.go | 104 +- pkg/services/authn/clients/proxy.go | 101 +- pkg/services/authn/clients/proxy_test.go | 73 +- pkg/services/authn/clients/render.go | 37 +- pkg/services/authn/clients/render_test.go | 19 +- pkg/services/authn/clients/session.go | 53 +- pkg/services/authn/clients/session_test.go | 86 +- pkg/services/authn/identity.go | 36 +- pkg/services/cleanup/cleanup.go | 17 + pkg/services/cloudmigration/api/api.go | 56 + pkg/services/cloudmigration/api/api_test.go | 12 + .../cloudmigrationimpl/cloudmigration.go | 67 + .../cloudmigrationimpl/cloudmigration_noop.go | 16 + .../cloudmigrationimpl/cloudmigration_test.go | 15 + .../cloudmigrationimpl/metric.go | 26 + .../cloudmigrationimpl/store.go | 11 + .../cloudmigrationimpl/xorm_store.go | 16 + .../cloudmigrationimpl/xorm_store_test.go | 9 + .../cloudmigration/cloudmigrations.go | 9 + .../cloudmigration/cloudmigrationtest/fake.go | 15 + pkg/services/cloudmigration/model.go | 43 + pkg/services/contexthandler/contexthandler.go | 42 +- .../contexthandler/contexthandler_test.go | 51 +- pkg/services/contexthandler/model/model.go | 2 +- pkg/services/dashboardimport/api/api.go | 7 +- .../dashboardimport/service/service.go | 4 + .../dashboardimport/service/service_test.go | 53 +- .../utils/dash_template_evaluator.go | 12 +- pkg/services/dashboards/accesscontrol.go | 11 +- pkg/services/dashboards/accesscontrol_test.go | 224 +- pkg/services/dashboards/dashboard.go | 17 +- .../dashboards/dashboard_provisioning_mock.go | 24 +- .../dashboards/dashboard_service_mock.go | 100 +- pkg/services/dashboards/database/database.go | 264 +- .../database/database_folder_test.go | 39 +- .../database/database_provisioning_test.go | 5 +- .../dashboards/database/database_test.go | 212 +- .../migrations/folder_uid_migrator.go | 20 + pkg/services/dashboards/errors.go | 2 +- pkg/services/dashboards/models.go | 92 +- pkg/services/dashboards/models_test.go | 60 +- .../dashboards/service/dashboard_service.go | 212 +- .../dashboard_service_integration_test.go | 64 +- .../service/dashboard_service_test.go | 67 +- pkg/services/dashboards/store_mock.go | 37 +- .../dashboardsnapshots/database/database.go | 24 +- .../database/database_test.go | 42 +- pkg/services/dashboardsnapshots/models.go | 27 +- pkg/services/dashboardsnapshots/service.go | 211 + .../dashboardsnapshots/service/service.go | 2 +- .../service/service_test.go | 25 +- .../dashboardversion/dashverimpl/dashver.go | 6 +- .../dashverimpl/dashver_test.go | 6 +- .../dashverimpl/store_test.go | 14 +- pkg/services/datasources/datasources.go | 2 +- .../fakes/fake_datasource_service.go | 2 +- .../datasources/service/datasource.go | 20 +- .../datasources/service/datasource_test.go | 112 +- pkg/services/datasources/service/legacy.go | 90 + pkg/services/datasources/service/store.go | 14 +- pkg/services/extsvcauth/models.go | 18 +- .../extsvcauth/oauthserver/api/api.go | 37 - pkg/services/extsvcauth/oauthserver/errors.go | 25 - .../oauthserver/external_service.go | 153 - .../oauthserver/external_service_test.go | 213 - pkg/services/extsvcauth/oauthserver/models.go | 58 - .../oauthserver/oasimpl/aggregate_store.go | 162 - .../oasimpl/aggregate_store_test.go | 119 - .../oauthserver/oasimpl/introspection.go | 21 - .../extsvcauth/oauthserver/oasimpl/service.go | 500 - .../oauthserver/oasimpl/service_test.go | 625 - .../extsvcauth/oauthserver/oasimpl/session.go | 16 - .../extsvcauth/oauthserver/oasimpl/token.go | 351 - .../oauthserver/oasimpl/token_test.go | 745 - .../extsvcauth/oauthserver/oastest/fakes.go | 38 - .../oauthserver/oastest/store_mock.go | 191 - .../extsvcauth/oauthserver/store/database.go | 252 - .../oauthserver/store/database_test.go | 485 - .../extsvcauth/oauthserver/utils/utils.go | 35 - .../oauthserver/utils/utils_test.go | 82 - pkg/services/extsvcauth/registry/service.go | 38 +- .../extsvcauth/registry/service_test.go | 63 +- pkg/services/featuremgmt/codeowners.go | 1 - pkg/services/featuremgmt/manager.go | 152 +- pkg/services/featuremgmt/manager_test.go | 61 +- pkg/services/featuremgmt/models.go | 42 +- pkg/services/featuremgmt/registry.go | 679 +- pkg/services/featuremgmt/service.go | 41 +- pkg/services/featuremgmt/service_test.go | 67 +- pkg/services/featuremgmt/settings.go | 34 - pkg/services/featuremgmt/settings_test.go | 25 - .../featuremgmt/testdata/features.yaml | 33 - .../featuremgmt/testdata/included.yaml | 13 - pkg/services/featuremgmt/toggles_gen.csv | 314 +- pkg/services/featuremgmt/toggles_gen.go | 226 +- pkg/services/featuremgmt/toggles_gen.json | 2145 +++ pkg/services/featuremgmt/toggles_gen_test.go | 146 +- pkg/services/featuremgmt/usage_stats_test.go | 2 +- .../folderimpl/dashboard_folder_store.go | 23 +- .../folderimpl/dashboard_folder_store_test.go | 50 +- pkg/services/folder/folderimpl/folder.go | 479 +- pkg/services/folder/folderimpl/folder_test.go | 997 +- pkg/services/folder/folderimpl/metrics.go | 11 + pkg/services/folder/folderimpl/sqlstore.go | 358 +- .../folder/folderimpl/sqlstore_test.go | 195 +- pkg/services/folder/folderimpl/store.go | 26 +- pkg/services/folder/folderimpl/store_fake.go | 10 +- .../folder/foldertest/folder_store_mock.go | 18 +- pkg/services/folder/foldertest/foldertest.go | 10 +- pkg/services/folder/model.go | 43 +- pkg/services/folder/registry.go | 4 +- pkg/services/folder/service.go | 20 +- pkg/services/grafana-apiserver/config.go | 60 - pkg/services/grafana-apiserver/config_test.go | 40 - pkg/services/grafana-apiserver/service.go | 493 - pkg/services/grpcserver/service.go | 4 +- .../guardian/accesscontrol_guardian_test.go | 78 +- pkg/services/guardian/guardian.go | 10 + pkg/services/ldap/api/service.go | 4 +- pkg/services/libraryelements/api.go | 27 +- pkg/services/libraryelements/database.go | 47 +- .../libraryelements/libraryelements_test.go | 17 +- pkg/services/libraryelements/model/model.go | 30 - .../libraryelements/model/model_test.go | 57 - pkg/services/libraryelements/writers.go | 4 + pkg/services/librarypanels/librarypanels.go | 37 +- .../librarypanels/librarypanels_test.go | 21 +- .../live/database/tests/storage_test.go | 5 + pkg/services/live/live.go | 2 +- pkg/services/live/live_test.go | 5 + pkg/services/login/authinfo.go | 6 +- pkg/services/login/authinfo_test.go | 9 +- pkg/services/login/authinfoimpl/service.go | 169 +- pkg/services/login/authinfoimpl/store_test.go | 5 + .../loginattemptimpl/store_test.go | 5 + pkg/services/navtree/models.go | 12 +- pkg/services/navtree/navtreeimpl/admin.go | 22 +- pkg/services/navtree/navtreeimpl/applinks.go | 61 +- .../navtree/navtreeimpl/applinks_test.go | 2 +- pkg/services/navtree/navtreeimpl/navtree.go | 99 +- pkg/services/ngalert/README.md | 2 +- pkg/services/ngalert/accesscontrol.go | 17 + .../ngalert/accesscontrol/fakes/rules.go | 74 + pkg/services/ngalert/accesscontrol/models.go | 4 +- pkg/services/ngalert/api/api.go | 13 +- pkg/services/ngalert/api/api_alertmanager.go | 11 +- .../ngalert/api/api_alertmanager_guards.go | 10 +- .../api/api_alertmanager_guards_test.go | 2 + .../ngalert/api/api_alertmanager_test.go | 184 +- pkg/services/ngalert/api/api_configuration.go | 6 +- pkg/services/ngalert/api/api_notifications.go | 83 + .../ngalert/api/api_notifications_test.go | 188 + pkg/services/ngalert/api/api_prometheus.go | 2 +- pkg/services/ngalert/api/api_provisioning.go | 118 +- .../ngalert/api/api_provisioning_test.go | 179 +- pkg/services/ngalert/api/api_ruler.go | 136 +- pkg/services/ngalert/api/api_ruler_export.go | 8 +- .../ngalert/api/api_ruler_export_test.go | 24 +- pkg/services/ngalert/api/api_ruler_test.go | 97 +- .../ngalert/api/api_ruler_validation.go | 81 +- .../ngalert/api/api_ruler_validation_test.go | 143 +- pkg/services/ngalert/api/api_testing.go | 81 +- pkg/services/ngalert/api/api_testing_test.go | 137 +- pkg/services/ngalert/api/authorization.go | 35 +- .../ngalert/api/authorization_test.go | 3 +- pkg/services/ngalert/api/compat.go | 143 +- .../api/generated_base_api_notifications.go | 96 + .../api/generated_base_api_provisioning.go | 19 + pkg/services/ngalert/api/lotex_ruler.go | 37 +- pkg/services/ngalert/api/lotex_ruler_test.go | 263 + pkg/services/ngalert/api/notifications.go | 32 + pkg/services/ngalert/api/persist.go | 6 +- pkg/services/ngalert/api/provisioning.go | 4 + .../test-data/post-rulegroup-101-export.hcl | 9 + .../test-data/post-rulegroup-101-export.json | 10 +- .../test-data/post-rulegroup-101-export.yaml | 11 + .../api/test-data/post-rulegroup-101.json | 10 +- .../test-data/ruler-grafana-recipient.http | 4 +- pkg/services/ngalert/api/tooling/README.md | 4 +- pkg/services/ngalert/api/tooling/api.json | 369 +- .../ngalert/api/tooling/definitions/admin.go | 10 +- .../api/tooling/definitions/alertmanager.go | 619 +- .../tooling/definitions/alertmanager_test.go | 871 - .../definitions/alertmanager_validation.go | 89 - .../alertmanager_validation_test.go | 12 +- .../ngalert/api/tooling/definitions/api.go | 2 +- .../api/tooling/definitions/contact_points.go | 5 +- .../api/tooling/definitions/cortex-ruler.go | 121 +- .../ngalert/api/tooling/definitions/prom.go | 8 +- .../definitions/provisioning_alert_rules.go | 55 +- .../definitions/provisioning_contactpoints.go | 10 +- .../definitions/provisioning_mute_timings.go | 17 +- .../definitions/provisioning_policies.go | 8 +- .../definitions/provisioning_templates.go | 8 +- .../api/tooling/definitions/receivers.go | 56 + .../definitions/ruler_state_history.go | 2 +- .../ngalert/api/tooling/definitions/shared.go | 7 + .../api/tooling/definitions/testing.go | 8 +- .../api/tooling/definitions/time_intervals.go | 64 + pkg/services/ngalert/api/tooling/post.json | 593 +- pkg/services/ngalert/api/tooling/spec.json | 600 +- .../templates/controller-api.mustache | 6 +- pkg/services/ngalert/backtesting/engine.go | 36 +- .../ngalert/backtesting/engine_test.go | 8 +- pkg/services/ngalert/backtesting/eval_data.go | 8 +- pkg/services/ngalert/client/client.go | 72 + pkg/services/ngalert/client/client_test.go | 29 + pkg/services/ngalert/eval/context.go | 21 +- pkg/services/ngalert/eval/eval.go | 80 +- pkg/services/ngalert/eval/eval_test.go | 180 + pkg/services/ngalert/eval/testing.go | 8 + pkg/services/ngalert/image/service.go | 4 +- pkg/services/ngalert/limits.go | 12 +- pkg/services/ngalert/metrics/alertmanager.go | 9 +- pkg/services/ngalert/metrics/historian.go | 2 +- .../ngalert/metrics/multi_org_alertmanager.go | 26 +- pkg/services/ngalert/metrics/ngalert.go | 6 + .../ngalert/metrics/remote_alertmanager.go | 84 + pkg/services/ngalert/metrics/scheduler.go | 20 + pkg/services/ngalert/metrics/state.go | 14 +- pkg/services/ngalert/migration/alert_rule.go | 321 - .../ngalert/migration/alert_rule_test.go | 319 - pkg/services/ngalert/migration/channel.go | 236 - .../ngalert/migration/channel_test.go | 493 - pkg/services/ngalert/migration/cond_trans.go | 418 - .../ngalert/migration/cond_trans_test.go | 191 - .../ngalert/migration/migration_test.go | 1433 -- pkg/services/ngalert/migration/models.go | 90 - .../ngalert/migration/models/alertmanager.go | 79 - .../ngalert/migration/models/models.go | 104 - .../ngalert/migration/models/models_test.go | 149 - .../ngalert/migration/models/state.go | 15 - pkg/services/ngalert/migration/permissions.go | 422 - .../ngalert/migration/permissions_test.go | 821 - .../ngalert/migration/securejsondata.go | 31 - pkg/services/ngalert/migration/service.go | 189 - .../ngalert/migration/service_test.go | 373 - pkg/services/ngalert/migration/silences.go | 159 - .../ngalert/migration/store/database.go | 481 - .../ngalert/migration/store/testing.go | 108 - pkg/services/ngalert/migration/template.go | 213 - .../ngalert/migration/template_test.go | 328 - pkg/services/ngalert/migration/testing.go | 42 - pkg/services/ngalert/migration/ualert.go | 110 - pkg/services/ngalert/migration/ualert_test.go | 139 - pkg/services/ngalert/models/alert_query.go | 24 + .../ngalert/models/alert_query_test.go | 80 +- pkg/services/ngalert/models/alert_rule.go | 106 +- .../ngalert/models/alert_rule_test.go | 117 +- pkg/services/ngalert/models/errors.go | 16 + pkg/services/ngalert/models/instance.go | 1 + pkg/services/ngalert/models/notifications.go | 167 + .../ngalert/models/notifications_test.go | 145 + pkg/services/ngalert/models/receivers.go | 17 + pkg/services/ngalert/models/testing.go | 228 +- pkg/services/ngalert/ngalert.go | 76 +- pkg/services/ngalert/ngalert_test.go | 2 - pkg/services/ngalert/notifier/alertmanager.go | 109 +- .../ngalert/notifier/alertmanager_config.go | 50 +- .../ngalert/notifier/alertmanager_test.go | 7 +- .../ngalert/notifier/autogen_alertmanager.go | 185 + .../notifier/autogen_alertmanager_test.go | 238 + .../channels_config/available_channels.go | 9 + pkg/services/ngalert/notifier/compat.go | 82 + pkg/services/ngalert/notifier/config.go | 90 +- pkg/services/ngalert/notifier/config_test.go | 98 - .../ngalert/notifier/multiorg_alertmanager.go | 45 +- .../multiorg_alertmanager_remote_test.go | 279 + .../notifier/multiorg_alertmanager_test.go | 18 +- pkg/services/ngalert/notifier/receiver_svc.go | 201 + .../ngalert/notifier/receiver_svc_test.go | 261 + pkg/services/ngalert/notifier/status.go | 2 +- pkg/services/ngalert/notifier/testing.go | 40 +- .../{receivers.go => testreceivers.go} | 0 ...eceivers_test.go => testreceivers_test.go} | 0 pkg/services/ngalert/notifier/validation.go | 136 + .../ngalert/provisioning/alert_rules.go | 253 +- .../ngalert/provisioning/alert_rules_test.go | 153 +- pkg/services/ngalert/provisioning/compat.go | 22 + pkg/services/ngalert/provisioning/config.go | 34 +- .../ngalert/provisioning/config_test.go | 127 + .../ngalert/provisioning/contactpoints.go | 229 +- .../provisioning/contactpoints_test.go | 167 +- pkg/services/ngalert/provisioning/errors.go | 34 + .../ngalert/provisioning/mute_timings.go | 177 +- .../ngalert/provisioning/mute_timings_test.go | 919 +- .../provisioning/notification_policies.go | 73 +- .../notification_policies_test.go | 49 +- .../ngalert/provisioning/provisioning_test.go | 11 + .../ngalert/provisioning/templates.go | 93 +- .../ngalert/provisioning/templates_test.go | 121 +- pkg/services/ngalert/provisioning/testing.go | 142 +- pkg/services/ngalert/remote/alertmanager.go | 23 +- .../ngalert/remote/alertmanager_test.go | 20 +- .../ngalert/remote/client/alertmanager.go | 24 +- pkg/services/ngalert/remote/client/mimir.go | 12 +- pkg/services/ngalert/schedule/alert_rule.go | 461 + .../ngalert/schedule/alert_rule_test.go | 721 + pkg/services/ngalert/schedule/fetcher.go | 25 - pkg/services/ngalert/schedule/jitter.go | 70 + pkg/services/ngalert/schedule/jitter_test.go | 100 + .../ngalert/schedule/loaded_metrics_reader.go | 44 + .../schedule/loaded_metrics_reader_test.go | 65 + pkg/services/ngalert/schedule/metrics.go | 75 + .../{fetcher_test.go => metrics_test.go} | 3 +- pkg/services/ngalert/schedule/registry.go | 117 +- .../ngalert/schedule/registry_test.go | 233 +- pkg/services/ngalert/schedule/schedule.go | 364 +- .../ngalert/schedule/schedule_unit_test.go | 526 +- pkg/services/ngalert/schedule/testing.go | 32 +- pkg/services/ngalert/sender/notifier.go | 2 +- pkg/services/ngalert/sender/notifier_ext.go | 12 +- pkg/services/ngalert/sender/router.go | 17 +- pkg/services/ngalert/sender/router_test.go | 16 +- pkg/services/ngalert/sender/sender.go | 11 +- pkg/services/ngalert/state/cache.go | 65 +- pkg/services/ngalert/state/cache_test.go | 56 + pkg/services/ngalert/state/compat.go | 19 +- pkg/services/ngalert/state/compat_test.go | 109 +- .../ngalert/state/historian/annotation.go | 10 +- .../state/historian/annotation_test.go | 42 + pkg/services/ngalert/state/historian/core.go | 10 +- .../ngalert/state/historian/core_test.go | 28 +- pkg/services/ngalert/state/historian/loki.go | 18 +- .../ngalert/state/historian/loki_http.go | 80 +- .../ngalert/state/historian/loki_http_test.go | 45 +- .../ngalert/state/historian/loki_test.go | 33 +- pkg/services/ngalert/state/manager.go | 144 +- .../ngalert/state/manager_bench_test.go | 9 +- .../ngalert/state/manager_private_test.go | 903 +- pkg/services/ngalert/state/manager_test.go | 476 +- pkg/services/ngalert/state/persist.go | 1 + pkg/services/ngalert/state/persister_async.go | 69 + .../ngalert/state/persister_async_test.go | 77 + pkg/services/ngalert/state/persister_noop.go | 16 + pkg/services/ngalert/state/persister_sync.go | 110 + .../ngalert/state/persister_sync_test.go | 103 + pkg/services/ngalert/state/state.go | 111 +- pkg/services/ngalert/state/state_test.go | 113 + pkg/services/ngalert/state/testing.go | 25 +- pkg/services/ngalert/store/alert_rule.go | 374 +- pkg/services/ngalert/store/alert_rule_test.go | 291 +- .../ngalert/store/alertmanager_test.go | 5 + pkg/services/ngalert/store/deltas.go | 21 + pkg/services/ngalert/store/deltas_test.go | 1 - .../ngalert/store/instance_database.go | 38 +- .../ngalert/store/instance_database_test.go | 95 + pkg/services/ngalert/tests/fakes/config.go | 58 + .../ngalert/tests/fakes/provisioning.go | 55 + pkg/services/ngalert/tests/fakes/receivers.go | 60 + pkg/services/ngalert/tests/fakes/rules.go | 43 +- pkg/services/ngalert/tests/util.go | 10 +- pkg/services/ngalert/testutil/testutil.go | 11 +- pkg/services/notifications/codes.go | 12 +- pkg/services/notifications/codes_test.go | 8 +- pkg/services/notifications/email.go | 2 +- pkg/services/notifications/mailer.go | 7 +- pkg/services/notifications/mock.go | 28 +- pkg/services/notifications/models.go | 6 + pkg/services/notifications/notifications.go | 39 +- pkg/services/notifications/smtp.go | 74 +- pkg/services/notifications/smtp_test.go | 38 +- pkg/services/notifications/testing.go | 9 +- pkg/services/oauthtoken/oauth_token.go | 211 +- pkg/services/oauthtoken/oauth_token_test.go | 608 +- .../oauthtoken/oauthtokentest/mock.go | 4 +- .../oauthtokentest/oauthtokentest.go | 2 +- .../oauthtoken/oauthtokentest/service_mock.go | 146 + pkg/services/org/orgimpl/store_test.go | 5 + .../playlist/playlistimpl/store_test.go | 5 + .../plugindashboards/plugindashboards.go | 18 +- .../angulardetectorsprovider/dynamic.go | 2 +- .../angulardetectorsprovider/dynamic_test.go | 2 +- .../angularinspector/angularinspector.go | 5 +- .../angularinspector/angularinspector_test.go | 18 +- .../clientmiddleware/caching_middleware.go | 4 +- .../clientmiddleware/forward_id_middleware.go | 16 +- .../forward_id_middleware_test.go | 46 +- .../grafana_request_id_header_middleware.go | 162 + ...afana_request_id_header_middleware_test.go | 138 + .../clientmiddleware/logger_middleware.go | 12 +- .../clientmiddleware/metrics_middleware.go | 29 +- .../metrics_middleware_test.go | 71 +- .../tracing_header_middleware.go | 2 +- .../pluginsintegration/config/config.go | 71 - .../dashboards/filestore.go | 6 +- .../dashboards/filestore_test.go | 2 +- .../pluginsintegration/loader/loader_test.go | 513 +- .../pluginsintegration/pipeline/pipeline.go | 18 +- .../pluginsintegration/pipeline/steps.go | 85 +- .../pluginsintegration/pipeline/steps_test.go | 120 +- .../pluginaccesscontrol/accesscontrol.go | 4 +- .../pluginsintegration/pluginconfig/config.go | 147 + .../{config => pluginconfig}/config_test.go | 4 +- .../pluginconfig/envvars.go | 178 + .../pluginconfig}/envvars_test.go | 544 +- .../pluginsintegration/pluginconfig/fakes.go | 16 + .../pluginconfig/request.go | 160 + .../pluginconfig/request_test.go | 398 + .../{config => pluginconfig}/tracing.go | 2 +- .../{config => pluginconfig}/tracing_test.go | 2 +- .../plugincontext/plugincontext.go | 103 +- .../plugincontext/plugincontext_test.go | 6 +- .../pluginexternal/check.go | 43 + .../pluginexternal/check_test.go | 53 + .../plugins_integration_test.go | 90 +- .../pluginsettings/service/service_test.go | 5 + .../pluginsintegration/pluginsintegration.go | 49 +- .../pluginsintegration/pluginstore/store.go | 3 +- .../pluginsintegration/renderer/renderer.go | 50 +- .../renderer/renderer_test.go | 78 + .../renderer/testdata/plugins/app/plugin.json | 3 + .../testdata/plugins/datasource/plugin.json | 3 + .../testdata/plugins/renderer/plugin.json | 3 + .../plugins/secrets-manager/plugin.json | 3 + .../serviceregistration.go | 38 +- .../pluginsintegration/test_helper.go | 18 +- .../preference/prefimpl/store_test.go | 5 + .../provisioning/alerting/provisioner.go | 26 +- .../alerting/rules_provisioner.go | 43 +- .../provisioning/alerting/rules_types.go | 101 +- .../provisioning/alerting/rules_types_test.go | 114 + .../provisioning/dashboards/dashboard.go | 24 +- .../provisioning/dashboards/file_reader.go | 52 +- .../dashboards/file_reader_symlink_test.go | 2 +- .../dashboards/file_reader_test.go | 53 +- pkg/services/provisioning/dashboards/types.go | 2 + .../provisioning/dashboards/validator.go | 2 + .../provisioning/dashboards/validator_test.go | 27 +- .../notifiers/alert_notifications.go | 176 - .../provisioning/notifiers/config_reader.go | 192 - .../notifiers/config_reader_test.go | 369 - .../test-configs/broken-yaml/broken.yaml | 9 - .../test-configs/broken-yaml/not.yaml.text | 6 - .../correct-properties-with-orgName.yaml | 12 - .../correct-properties.yaml | 46 - .../test-configs/double-default/default-1.yml | 7 - .../double-default/default-2.yaml | 7 - .../test-configs/empty_folder/.gitignore | 4 - .../incorrect-settings.yaml | 9 - .../no-required-fields.yaml | 35 - .../two-notifications/two-notifications.yaml | 12 - .../unknown-notifier/notification.yaml | 4 - pkg/services/provisioning/notifiers/types.go | 107 - pkg/services/provisioning/provisioning.go | 39 +- .../provisioning/provisioning_mock.go | 10 - .../provisioning/provisioning_test.go | 4 +- pkg/services/publicdashboards/api/api.go | 14 +- pkg/services/publicdashboards/api/api_test.go | 5 +- .../publicdashboards/api/common_test.go | 18 +- .../publicdashboards/api/middleware.go | 3 + .../publicdashboards/api/query_test.go | 8 +- .../publicdashboards/database/database.go | 8 +- .../database/database_test.go | 78 +- .../publicdashboards/models/models.go | 9 +- .../public_dashboard_middleware_mock.go | 13 +- .../public_dashboard_service_mock.go | 2 +- .../public_dashboard_service_wrapper_mock.go | 2 +- .../public_dashboard_store_mock.go | 48 +- .../publicdashboards/publicdashboard.go | 3 +- .../publicdashboards/service/common_test.go | 51 + .../service/intervalv2/intervalv2.go | 77 + .../service}/intervalv2/intervalv2_test.go | 69 - .../publicdashboards/service/query_test.go | 141 +- .../publicdashboards/service/service.go | 21 +- .../publicdashboards/service/service_test.go | 233 +- pkg/services/query/query.go | 24 +- pkg/services/query/query_test.go | 12 +- pkg/services/queryhistory/database.go | 10 +- .../queryhistory/queryhistory_test.go | 5 + pkg/services/queryhistory/writers.go | 29 +- pkg/services/quota/quotaimpl/quota_test.go | 3 +- pkg/services/quota/quotaimpl/store_test.go | 5 + pkg/services/rendering/http_mode.go | 14 +- pkg/services/rendering/interface.go | 5 +- pkg/services/rendering/mock.go | 65 +- pkg/services/rendering/plugin_mode.go | 14 +- pkg/services/rendering/rendering.go | 52 +- pkg/services/rendering/rendering_test.go | 6 +- pkg/services/screenshot/screenshot.go | 16 +- pkg/services/screenshot/screenshot_test.go | 9 +- pkg/services/search/service.go | 2 + pkg/services/searchV2/bluge.go | 6 + pkg/services/searchV2/http.go | 15 +- pkg/services/searchV2/service_bench_test.go | 23 +- pkg/services/searchV2/types.go | 2 +- pkg/services/searchusers/searchusers.go | 11 +- pkg/services/secrets/database/database.go | 34 +- .../kvstore/migrations/datasource_mig_test.go | 5 + pkg/services/secrets/kvstore/plugin_test.go | 5 + pkg/services/secrets/kvstore/test_helpers.go | 4 + pkg/services/secrets/manager/helpers.go | 5 +- pkg/services/secrets/manager/manager.go | 2 +- pkg/services/secrets/manager/manager_test.go | 5 + pkg/services/serviceaccounts/api/api.go | 8 +- .../serviceaccounts/database/store.go | 23 +- .../serviceaccounts/database/store_test.go | 116 +- .../serviceaccounts/extsvcaccounts/service.go | 14 +- .../extsvcaccounts/service_test.go | 9 +- .../serviceaccounts/manager/service.go | 7 +- .../serviceaccounts/manager/service_test.go | 4 +- .../serviceaccounts/manager/stats_test.go | 4 +- pkg/services/serviceaccounts/models.go | 2 + pkg/services/serviceaccounts/proxy/service.go | 4 +- .../serviceaccounts/proxy/service_test.go | 90 +- .../shorturls/shorturlimpl/shorturl_test.go | 5 + .../signingkeys/signingkeystore/store_test.go | 5 + pkg/services/sqlstore/database_config.go | 228 + pkg/services/sqlstore/database_config_test.go | 223 + .../accesscontrol/dashboard_permissions.go | 182 + .../migrations/accesscontrol/migrations.go | 10 + .../accesscontrol/scope_migrator.go | 33 + .../accesscontrol/seed_assignment.go | 83 + .../migrations/accesscontrol/test/ac_test.go | 54 +- .../test/dashbord_permission_migrator_test.go | 335 + .../test/managed_permission_migrator_test.go | 6 +- .../accesscontrol/test/scope_migrator_test.go | 145 + .../test/seed_assign_mig_test.go | 79 + .../sqlstore/migrations/cloud_migrations.go | 32 + .../migrations/external_alertmanagers.go | 6 +- .../sqlstore/migrations/folder_mig.go | 25 + .../sqlstore/migrations/kv_store_mig.go | 7 + .../sqlstore/migrations/migrations.go | 28 +- .../sqlstore/migrations/migrations_test.go | 86 +- .../migrations/oauthserver/migrations.go | 52 - .../ualert/rule_notification_settings_mig.go | 20 + .../sqlstore/migrations/ualert/tables.go | 4 + ...ice_account_multiple_org_login_migrator.go | 68 + .../user/test/service_account_test.go | 247 + .../migrations/user/test/user_test.go | 55 + pkg/services/sqlstore/migrations/user_mig.go | 18 + pkg/services/sqlstore/migrator/dialect.go | 43 +- pkg/services/sqlstore/migrator/migrator.go | 10 +- .../sqlstore/migrator/sqlite_dialect.go | 4 + .../sqlstore/permissions/dashboard.go | 36 +- .../dashboard_filter_no_subquery.go | 32 +- .../sqlstore/permissions/dashboard_test.go | 247 +- .../permissions/dashboards_bench_test.go | 24 +- pkg/services/sqlstore/searchstore/builder.go | 1 + .../sqlstore/searchstore/search_test.go | 9 + pkg/services/sqlstore/sqlstore.go | 454 +- pkg/services/sqlstore/sqlstore_test.go | 142 +- pkg/services/sqlstore/sqlutil/sqlutil.go | 140 +- pkg/services/sqlstore/tls_mysql.go | 2 +- pkg/services/sqlstore/user.go | 4 +- pkg/services/ssosettings/api/api.go | 114 +- pkg/services/ssosettings/api/api_test.go | 438 +- pkg/services/ssosettings/database/database.go | 61 +- .../ssosettings/database/database_test.go | 126 +- pkg/services/ssosettings/errors.go | 21 +- pkg/services/ssosettings/models/models.go | 17 + pkg/services/ssosettings/ssosettings.go | 20 +- .../ssosettings/ssosettingsimpl/metrics.go | 31 + .../ssosettings/ssosettingsimpl/service.go | 402 +- .../ssosettingsimpl/service_test.go | 1249 +- .../ssosettingsimpl/usage_stats.go | 30 + .../ssosettingsimpl/usage_stats_test.go | 68 + .../fallback_strategy_fake.go | 4 +- .../ssosettingstests/reloadable_mock.go | 67 + .../ssosettingstests/service_mock.go | 96 +- .../ssosettingstests/store_fake.go | 16 +- .../ssosettingstests/store_mock.go | 36 +- .../ssosettings/strategies/oauth_strategy.go | 50 +- .../strategies/oauth_strategy_test.go | 213 +- .../ssosettings/strategies/saml_strategy.go | 66 + .../strategies/saml_strategy_test.go | 103 + .../validation/oauth_validators.go | 69 + .../validation/oauth_validators_test.go | 154 + .../ssosettings/validation/validator.go | 16 + pkg/services/star/starimpl/store_test.go | 5 + pkg/services/stats/statsimpl/stats.go | 35 - pkg/services/stats/statsimpl/stats_test.go | 5 + pkg/services/store/config.go | 2 +- pkg/services/store/entity/README.md | 60 +- pkg/services/store/entity/client_wrapper.go | 102 +- pkg/services/store/entity/db/dbimpl/dbimpl.go | 155 + .../{ => db}/migrations/entity_store_mig.go | 14 +- .../entity/{ => db}/migrations/migrator.go | 4 +- pkg/services/store/entity/db/service.go | 152 +- pkg/services/store/entity/entity.pb.go | 781 +- pkg/services/store/entity/entity.proto | 48 +- pkg/services/store/entity/entity_grpc.pb.go | 2 +- .../store/entity/grpc/authenticator.go | 97 + pkg/services/store/entity/key.go | 45 +- pkg/services/store/entity/server/config.go | 2 +- pkg/services/store/entity/server/service.go | 60 +- .../store/entity/sqlstash/broadcaster.go | 254 + .../store/entity/sqlstash/broadcaster_test.go | 106 + .../store/entity/sqlstash/folder_support.go | 4 +- .../store/entity/sqlstash/querybuilder.go | 74 +- .../entity/sqlstash/sql_storage_server.go | 908 +- .../sqlstash/sql_storage_server_test.go | 138 + .../tests/{common.go => common_test.go} | 24 +- .../entity/tests/server_integration_test.go | 178 +- pkg/services/store/http.go | 52 +- pkg/services/store/kind/dashboard/summary.go | 77 +- .../testdata/with-library-panels-info.json | 30 + .../testdata/with-library-panels.json | 28 + pkg/services/store/service.go | 2 +- pkg/services/store/service_test.go | 5 + .../testdata/public_testdata.golden.jsonc | 22 +- .../supportbundlesimpl/service.go | 6 +- .../supportbundlesimpl/service_bundle.go | 13 +- .../supportbundlesimpl/service_bundle_test.go | 4 + pkg/services/tag/tagimpl/store_test.go | 5 + pkg/services/team/model.go | 15 - pkg/services/team/model_test.go | 45 - pkg/services/team/team.go | 2 +- pkg/services/team/teamapi/team.go | 10 +- pkg/services/team/teamapi/team_members.go | 2 +- pkg/services/team/teamimpl/store.go | 35 +- pkg/services/team/teamimpl/store_test.go | 57 +- pkg/services/team/teamimpl/team.go | 13 +- pkg/services/team/teamtest/team.go | 2 +- pkg/services/temp_user/model.go | 19 +- pkg/services/temp_user/temp_user.go | 2 + pkg/services/temp_user/tempuserimpl/store.go | 26 + .../temp_user/tempuserimpl/store_test.go | 62 +- .../temp_user/tempuserimpl/temp_user.go | 16 + pkg/services/temp_user/tempusertest/fake.go | 37 + pkg/services/updatechecker/grafana.go | 29 +- pkg/services/user/error.go | 1 + pkg/services/user/identity.go | 59 +- pkg/services/user/model.go | 34 +- pkg/services/user/password.go | 71 + pkg/services/user/password_test.go | 93 + pkg/services/user/user.go | 5 + pkg/services/user/userimpl/store.go | 5 + pkg/services/user/userimpl/store_test.go | 42 +- pkg/services/user/userimpl/user.go | 37 +- pkg/services/user/userimpl/user_test.go | 4 +- pkg/services/user/userimpl/verifier.go | 82 + pkg/services/user/userimpl/verifier_test.go | 108 + pkg/services/user/usertest/mock.go | 511 + pkg/setting/provider.go | 6 - pkg/setting/setting.go | 590 +- pkg/setting/setting_auth_proxy.go | 45 + pkg/setting/setting_azure.go | 4 + pkg/setting/setting_azure_test.go | 21 + pkg/setting/setting_data_proxy.go | 4 +- .../setting_grafana_javascript_agent.go | 2 + pkg/setting/setting_jwt.go | 48 + pkg/setting/setting_quota.go | 10 +- pkg/setting/setting_smtp.go | 6 + pkg/setting/setting_test.go | 499 +- pkg/setting/setting_unified_alerting.go | 75 +- pkg/setting/setting_unified_alerting_test.go | 6 +- .../alerting/api_admin_configuration_test.go | 50 +- .../api_alertmanager_configuration_test.go | 255 +- .../api/alerting/api_alertmanager_test.go | 157 +- .../api/alerting/api_backtesting_test.go | 4 +- .../api_notifications_time_interval_test.go | 204 + .../api/alerting/api_provisioning_test.go | 25 +- pkg/tests/api/alerting/api_ruler_test.go | 843 +- pkg/tests/api/alerting/api_testing_test.go | 7 + .../alerting/test-data/hysteresis_rule.json | 71 + .../rule-notification-settings-1-post.json | 58 + .../test-data/rulegroup-3-export.json | 75 + .../alerting/test-data/rulegroup-3-get.json | 90 + .../alerting/test-data/rulegroup-3-post.json | 56 + pkg/tests/api/alerting/testing.go | 178 +- .../api/azuremonitor/azuremonitor_test.go | 5 + pkg/tests/api/correlations/common_test.go | 7 +- .../correlations/correlations_update_test.go | 2 + .../api/dashboards/api_dashboards_test.go | 5 + .../api/elasticsearch/elasticsearch_test.go | 5 + pkg/tests/api/folders/api_folder_test.go | 220 + pkg/tests/api/folders/api_folders_test.go | 54 +- pkg/tests/api/graphite/graphite_test.go | 5 + pkg/tests/api/influxdb/influxdb_test.go | 5 + pkg/tests/api/loki/loki_test.go | 5 + pkg/tests/api/opentdsb/opentdsb_test.go | 5 + pkg/tests/api/plugins/api_plugins_test.go | 25 +- .../backendplugin/backendplugin_test.go | 5 + .../api/plugins/data/expectedListResp.json | 457 +- pkg/tests/api/prometheus/prometheus_test.go | 5 + pkg/tests/api/stats/admin_test.go | 5 + pkg/tests/apis/dashboard/dashboards_test.go | 117 + .../testdata/dashboard-generate.yaml | 6 + .../testdata/dashboard-test-apply.yaml | 6 + .../testdata/dashboard-test-create.yaml | 6 + .../testdata/dashboard-test-replace.yaml | 6 + .../apis/dashboardsnapshot/snapshots_test.go | 85 + pkg/tests/apis/datasource/testdata_test.go | 148 + pkg/tests/apis/example/example_test.go | 21 +- .../apis/{folders => folder}/folders_test.go | 28 +- .../testdata/folder-generate.yaml | 2 +- pkg/tests/apis/helper.go | 78 +- pkg/tests/apis/playlist/playlist_test.go | 19 +- pkg/tests/apis/query/query_test.go | 129 + pkg/tests/testinfra/testinfra.go | 34 +- pkg/tests/testsuite/testsuite.go | 15 + pkg/tests/web/index_view_test.go | 5 + pkg/tsdb/Magefile.go | 72 +- .../azuremonitor/azmoncredentials/builder.go | 2 +- .../azuremonitor-resource-handler.go | 6 +- .../azuremonitor-resource-handler_test.go | 6 +- pkg/tsdb/azuremonitor/azuremonitor.go | 81 +- pkg/tsdb/azuremonitor/azuremonitor_test.go | 93 +- pkg/tsdb/azuremonitor/httpclient.go | 8 +- pkg/tsdb/azuremonitor/httpclient_test.go | 24 +- .../azure-log-analytics-datasource.go | 30 +- .../azure-log-analytics-datasource_test.go | 36 + pkg/tsdb/azuremonitor/loganalytics/utils.go | 16 - pkg/tsdb/azuremonitor/macros/macros.go | 4 +- .../metrics/azuremonitor-datasource.go | 17 +- .../metrics/azuremonitor-datasource_test.go | 19 +- .../azure-resource-graph-datasource.go | 15 +- .../azure-resource-graph-datasource_test.go | 18 - pkg/tsdb/azuremonitor/routes.go | 140 +- .../azuremonitor/standalone/datasource.go | 38 + pkg/tsdb/azuremonitor/standalone/main.go | 23 + pkg/tsdb/azuremonitor/time/time-grain.go | 4 +- pkg/tsdb/azuremonitor/types/types.go | 3 +- pkg/tsdb/cloud-monitoring/annotation_query.go | 5 +- pkg/tsdb/cloud-monitoring/cloudmonitoring.go | 43 +- .../cloud-monitoring/cloudmonitoring_test.go | 64 +- .../cloud-monitoring/converter/converter.go | 1175 ++ pkg/tsdb/cloud-monitoring/httpclient.go | 3 +- pkg/tsdb/cloud-monitoring/promql_query.go | 24 +- .../cloud-monitoring/promql_query_test.go | 3 +- pkg/tsdb/cloud-monitoring/resource_handler.go | 13 +- .../cloud-monitoring/resource_handler_test.go | 2 + pkg/tsdb/cloud-monitoring/slo_query.go | 11 +- pkg/tsdb/cloud-monitoring/slo_query_test.go | 7 +- .../cloud-monitoring/standalone/datasource.go | 38 + pkg/tsdb/cloud-monitoring/standalone/main.go | 23 + pkg/tsdb/cloud-monitoring/time/interval.go | 76 + .../cloud-monitoring/time_series_filter.go | 14 +- .../time_series_filter_test.go | 39 +- .../cloud-monitoring/time_series_query.go | 15 +- .../time_series_query_test.go | 11 +- pkg/tsdb/cloud-monitoring/types.go | 13 +- pkg/tsdb/cloud-monitoring/utils.go | 41 +- pkg/tsdb/cloudwatch/annotation_query_test.go | 17 +- pkg/tsdb/cloudwatch/clients/metrics.go | 13 +- pkg/tsdb/cloudwatch/clients/metrics_test.go | 9 +- pkg/tsdb/cloudwatch/cloudwatch.go | 189 +- .../cloudwatch/cloudwatch_integration_test.go | 59 +- pkg/tsdb/cloudwatch/cloudwatch_test.go | 100 +- pkg/tsdb/cloudwatch/constants/metrics.go | 107 +- pkg/tsdb/cloudwatch/features/features.go | 16 + .../get_dimension_values_for_wildcards.go | 12 +- ...get_dimension_values_for_wildcards_test.go | 24 +- .../cloudwatch/get_metric_data_executor.go | 5 +- .../cloudwatch/get_metric_query_batches.go | 2 +- .../get_metric_query_batches_test.go | 18 +- pkg/tsdb/cloudwatch/log_actions.go | 23 +- pkg/tsdb/cloudwatch/log_actions_test.go | 61 +- pkg/tsdb/cloudwatch/log_sync_query_test.go | 64 +- .../cloudwatch/metric_data_input_builder.go | 5 +- .../metric_data_input_builder_test.go | 6 +- .../cloudwatch/metric_data_query_builder.go | 3 +- .../metric_data_query_builder_test.go | 46 +- pkg/tsdb/cloudwatch/metric_find_query.go | 49 - pkg/tsdb/cloudwatch/metric_find_query_test.go | 118 +- pkg/tsdb/cloudwatch/models/api.go | 4 +- .../cloudwatch/models/cloudwatch_query.go | 22 +- .../models/cloudwatch_query_test.go | 4 +- pkg/tsdb/cloudwatch/models/settings.go | 19 +- pkg/tsdb/cloudwatch/models/settings_test.go | 60 +- pkg/tsdb/cloudwatch/resource_handler.go | 48 +- pkg/tsdb/cloudwatch/response_parser.go | 15 +- pkg/tsdb/cloudwatch/response_parser_test.go | 76 + .../cloudwatch/routes/dimension_keys_test.go | 4 +- pkg/tsdb/cloudwatch/routes/external_id.go | 11 +- .../cloudwatch/routes/external_id_test.go | 22 +- .../routes/log_group_fields_test.go | 4 +- pkg/tsdb/cloudwatch/routes/log_groups.go | 5 +- pkg/tsdb/cloudwatch/routes/log_groups_test.go | 4 +- pkg/tsdb/cloudwatch/routes/middleware.go | 6 +- pkg/tsdb/cloudwatch/services/regions.go | 2 +- pkg/tsdb/cloudwatch/services/regions_test.go | 4 +- pkg/tsdb/cloudwatch/test_utils.go | 44 +- pkg/tsdb/cloudwatch/time_series_query.go | 46 +- pkg/tsdb/cloudwatch/time_series_query_test.go | 61 +- pkg/tsdb/cloudwatch/utils/metrics.go | 22 + pkg/tsdb/cloudwatch/utils/utils.go | 10 + pkg/tsdb/elasticsearch/client/client.go | 27 +- pkg/tsdb/elasticsearch/client/client_test.go | 110 +- .../elasticsearch/client/index_pattern.go | 4 +- pkg/tsdb/elasticsearch/client/models.go | 3 + .../elasticsearch/client/search_request.go | 11 +- .../client/search_request_test.go | 21 +- pkg/tsdb/elasticsearch/data_query.go | 29 +- pkg/tsdb/elasticsearch/data_query_test.go | 2 +- pkg/tsdb/elasticsearch/elasticsearch.go | 57 +- pkg/tsdb/elasticsearch/elasticsearch_test.go | 30 + pkg/tsdb/elasticsearch/healthcheck.go | 107 + pkg/tsdb/elasticsearch/healthcheck_test.go | 80 + pkg/tsdb/elasticsearch/models.go | 2 + pkg/tsdb/elasticsearch/parse_query.go | 1 + pkg/tsdb/elasticsearch/querydata_test.go | 5 +- pkg/tsdb/elasticsearch/response_parser.go | 29 +- .../elasticsearch/response_parser_test.go | 199 +- .../testdata/trimedges_string.golden.jsonc | 7 +- .../testdata_response/logs.a.golden.jsonc | 129 +- .../metric_avg.a.golden.jsonc | 7 +- .../metric_complex.a.golden.jsonc | 114 +- .../metric_extended_stats.a.golden.jsonc | 14 +- .../metric_multi.a.golden.jsonc | 7 +- .../metric_multi.b.golden.jsonc | 7 +- .../metric_percentiles.a.golden.jsonc | 14 +- .../metric_simple.a.golden.jsonc | 87 +- .../metric_top_metrics.a.golden.jsonc | 7 +- .../grafana-postgresql-datasource/locker.go | 85 - .../locker_test.go | 63 - .../grafana-postgresql-datasource/postgres.go | 199 +- .../postgres_snapshot_test.go | 195 + .../postgres_test.go | 304 +- .../grafana-postgresql-datasource/proxy.go | 83 +- .../proxy_test.go | 65 +- .../testdata/table/no_rows.golden.jsonc | 36 + .../testdata/table/no_rows.sql | 12 + .../testdata/table/simple.golden.jsonc | 112 + .../testdata/table/simple.sql | 18 + .../timestamp_convert_bigint.golden.jsonc | 113 + .../table/timestamp_convert_bigint.sql | 14 + .../timestamp_convert_double.golden.jsonc | 113 + .../table/timestamp_convert_double.sql | 14 + .../timestamp_convert_integer.golden.jsonc | 92 + .../table/timestamp_convert_integer.sql | 12 + .../table/timestamp_convert_real.golden.jsonc | 113 + .../testdata/table/timestamp_convert_real.sql | 14 + .../testdata/table/types_char.golden.jsonc | 140 + .../testdata/table/types_char.sql | 16 + .../table/types_datetime.golden.jsonc | 226 + .../testdata/table/types_datetime.sql | 44 + .../testdata/table/types_numeric.golden.jsonc | 188 + .../testdata/table/types_numeric.sql | 20 + .../testdata/table/types_other.golden.jsonc | 123 + .../testdata/table/types_other.sql | 15 + .../7x_compat_metric_label.golden.jsonc | 93 + .../time_series/7x_compat_metric_label.sql | 19 + .../convert_to_float64.golden.jsonc | 207 + .../time_series/convert_to_float64.sql | 29 + .../convert_to_float64_not.golden.jsonc | 97 + .../time_series/convert_to_float64_not.sql | 22 + .../time_series/fill_null.golden.jsonc | 107 + .../testdata/time_series/fill_null.sql | 15 + .../time_series/fill_previous.golden.jsonc | 107 + .../testdata/time_series/fill_previous.sql | 15 + .../time_series/fill_value.golden.jsonc | 107 + .../testdata/time_series/fill_value.sql | 15 + .../time_series/no_rows_long.golden.jsonc | 36 + .../testdata/time_series/no_rows_long.sql | 12 + .../time_series/no_rows_wide.golden.jsonc | 36 + .../testdata/time_series/no_rows_wide.sql | 10 + .../testdata/time_series/simple.golden.jsonc | 99 + .../testdata/time_series/simple.sql | 18 + .../grafana-postgresql-datasource/tls/tls.go | 135 + .../tls/tls_loader.go | 101 + .../tls/tls_test.go | 402 + .../tls/tls_test_helpers.go | 105 + .../tlsmanager.go | 224 - .../tlsmanager_test.go | 293 - .../grafana-pyroscope-datasource/instance.go | 42 +- .../pyroscopeClient.go | 14 +- .../grafana-pyroscope-datasource/query.go | 2 +- .../query_test.go | 2 +- .../grafana-pyroscope-datasource/service.go | 13 +- .../standalone/datasource.go | 51 + .../standalone/main.go | 23 + .../kinds/dataquery/types_dataquery_gen.go | 10 +- .../grafana-testdata-datasource/testdata.go | 9 + pkg/tsdb/influxdb/flux/executor_test.go | 7 +- pkg/tsdb/influxdb/flux/flux.go | 4 +- pkg/tsdb/influxdb/flux/macros.go | 4 +- pkg/tsdb/influxdb/fsql/arrow.go | 6 +- pkg/tsdb/influxdb/fsql/arrow_test.go | 6 +- pkg/tsdb/influxdb/fsql/client.go | 8 +- pkg/tsdb/influxdb/fsql/fsql.go | 2 +- pkg/tsdb/influxdb/fsql/fsql_test.go | 22 +- pkg/tsdb/influxdb/influxdb.go | 3 +- .../influxql/buffered/response_parser.go | 19 +- .../influxql/buffered/response_parser_test.go | 4 + .../influxdb/influxql/converter/converter.go | 14 +- pkg/tsdb/influxdb/influxql/influxql.go | 117 +- .../testdata/all_values_are_null.json | 30 +- .../influxql/testdata/empty_response.json | 8 +- .../testdata/error_on_top_level_response.json | 4 +- .../influxql/testdata/error_response.json | 8 +- .../testdata/influx_select_all_from_cpu.json | 45 +- .../testdata/invalid_timestamp_format.json | 34 +- .../testdata/invalid_value_format.json | 30 +- .../testdata/metric_find_queries.json | 48 +- .../testdata/multiple_measurements.json | 41 +- .../testdata/multiple_series_with_tags.json | 150 +- ...series_with_tags_and_multiple_columns.json | 274 +- .../one_measurement_with_two_columns.json | 35 +- .../influxdb/influxql/testdata/response.json | 31 +- ...sponse_with_nil_bools_and_nil_strings.json | 41 +- .../testdata/response_with_weird_tag.json | 27 +- .../influxql/testdata/retention_policy.json | 56 +- .../influxql/testdata/show_diagnostics.json | 1 + .../show_diagnostics.time_series.golden.jsonc | 512 + .../testdata/show_tag_values_response.json | 63 +- .../influxql/testdata/simple_response.json | 45 +- ...mple_response_with_diverse_data_types.json | 36 +- .../testdata/some_values_are_null.json | 30 +- .../string_column_with_null_value.json | 52 +- .../string_column_with_null_value2.json | 52 +- pkg/tsdb/influxdb/influxql/util/util.go | 9 + pkg/tsdb/influxdb/influxql/util/util_test.go | 23 + pkg/tsdb/influxdb/mocks_test.go | 20 +- pkg/tsdb/influxdb/models/datasource_info.go | 4 +- pkg/tsdb/influxdb/models/query.go | 30 +- pkg/tsdb/influxdb/models/query_test.go | 24 + pkg/tsdb/intervalv2/intervalv2.go | 255 - pkg/tsdb/legacydata/conversions.go | 84 + pkg/tsdb/legacydata/interval/interval.go | 128 +- pkg/tsdb/legacydata/interval/interval_test.go | 20 - pkg/tsdb/legacydata/service/service_test.go | 12 +- pkg/tsdb/loki/api.go | 30 +- pkg/tsdb/loki/api_test.go | 25 + pkg/tsdb/loki/framing_test.go | 61 +- .../kinds/dataquery/types_dataquery_gen.go | 7 +- pkg/tsdb/loki/loki.go | 24 +- pkg/tsdb/loki/parse_query.go | 8 +- pkg/tsdb/loki/step.go | 4 +- ...streams_structured_metadata_2.golden.jsonc | 380 + .../streams_structured_metadata_2.json | 78 + ...streams_structured_metadata_2.golden.jsonc | 363 + .../streams_structured_metadata_2.json | 78 + ...streams_structured_metadata_2.golden.jsonc | 363 + .../streams_structured_metadata_2.json | 78 + ...streams_structured_metadata_2.golden.jsonc | 380 + .../streams_structured_metadata_2.json | 78 + pkg/tsdb/loki/types.go | 9 +- pkg/tsdb/mssql/mssql.go | 35 +- pkg/tsdb/mssql/mssql_test.go | 84 +- pkg/tsdb/mssql/proxy.go | 12 +- pkg/tsdb/mssql/proxy_test.go | 48 +- pkg/tsdb/mysql/macros.go | 5 +- pkg/tsdb/mysql/macros_test.go | 3 +- pkg/tsdb/mysql/mysql.go | 65 +- pkg/tsdb/mysql/mysql_test.go | 38 +- pkg/tsdb/mysql/proxy.go | 17 +- pkg/tsdb/mysql/proxy_test.go | 35 +- pkg/tsdb/prometheus/azureauth/azure.go | 37 +- pkg/tsdb/prometheus/azureauth/azure_test.go | 45 +- pkg/tsdb/prometheus/client/transport_test.go | 45 - .../kinds/dataquery/types_dataquery_gen.go | 103 - pkg/tsdb/prometheus/prometheus.go | 147 +- pkg/tsdb/prometheus/prometheus_test.go | 145 +- pkg/tsdb/sqleng/proxyutil/proxy_test_util.go | 111 - pkg/tsdb/sqleng/proxyutil/proxy_util.go | 27 - pkg/tsdb/sqleng/sql_engine.go | 488 +- pkg/tsdb/sqleng/sql_engine_test.go | 84 +- pkg/tsdb/tempo/grpc.go | 57 +- .../kinds/dataquery/types_dataquery_gen.go | 7 +- pkg/tsdb/tempo/search_stream.go | 7 + pkg/tsdb/tempo/standalone/datasource.go | 41 + pkg/tsdb/tempo/standalone/main.go | 16 + pkg/util/converter/jsonitere/jsonitere.go | 66 - pkg/util/filepath.go | 38 +- pkg/util/json.go | 108 + pkg/util/json_test.go | 155 + pkg/util/maputil/maputil.go | 73 - pkg/util/osutil/osutil.go | 42 + pkg/util/osutil/osutil_test.go | 35 + pkg/util/shortid_generator.go | 36 +- pkg/util/shortid_generator_test.go | 24 +- pkg/util/xorm/go.mod | 17 +- pkg/util/xorm/go.sum | 22 +- pkg/util/xorm/session_cols.go | 6 + pkg/util/xorm/xorm_test.go | 17 + pkg/web/context.go | 6 + playwright.config.ts | 65 + .../internal/input-datasource/.gitignore | 1 - .../internal/input-datasource/README.md | 3 - .../__mocks__/d3-interpolate.ts | 1 - .../internal/input-datasource/jest.config.js | 18 - .../internal/input-datasource/package.json | 36 - .../src/InputConfigEditor.tsx | 69 - .../src/InputDatasource.test.ts | 58 - .../input-datasource/src/InputDatasource.ts | 124 - .../input-datasource/src/InputQueryEditor.tsx | 86 - .../input-datasource/src/img/input.svg | 14 - .../internal/input-datasource/src/module.ts | 10 - .../internal/input-datasource/src/plugin.json | 21 - .../input-datasource/src/testHelpers.ts | 27 - .../internal/input-datasource/src/types.ts | 11 - .../internal/input-datasource/src/utils.ts | 8 - .../internal/input-datasource/tsconfig.json | 17 - .../input-datasource/webpack.config.ts | 159 - project.json | 35 + public/api-enterprise-spec.json | 1005 +- public/api-merged.json | 10747 +++++------ public/app/app.ts | 27 +- public/app/core/actions/index.ts | 1 + .../AccessControl/AddPermission.tsx | 80 +- .../AccessControl/PermissionList.tsx | 6 + .../AccessControl/PermissionListItem.tsx | 97 +- .../components/AccessControl/Permissions.tsx | 184 +- .../core/components/AccessControl/types.ts | 1 + .../components/AppChrome/AppChrome.test.tsx | 42 +- .../core/components/AppChrome/AppChrome.tsx | 77 +- .../components/AppChrome/AppChromeMenu.tsx | 6 +- .../components/AppChrome/AppChromeService.tsx | 69 +- .../DockedMegaMenu/MegaMenu.test.tsx | 82 - .../AppChrome/DockedMegaMenu/MegaMenu.tsx | 132 - .../AppChrome/DockedMegaMenu/utils.test.ts | 186 - .../AppChrome/DockedMegaMenu/utils.ts | 142 - .../FeatureHighlight.tsx | 0 .../AppChrome/MegaMenu/MegaMenu.test.tsx | 27 +- .../AppChrome/MegaMenu/MegaMenu.tsx | 138 +- .../MegaMenuItem.tsx | 8 +- .../MegaMenuItemText.tsx | 0 .../AppChrome/MegaMenu/NavBarItemIcon.tsx | 38 - .../AppChrome/MegaMenu/NavBarMenu.tsx | 231 - .../AppChrome/MegaMenu/NavBarMenuItem.tsx | 139 - .../MegaMenu/NavBarMenuItemWrapper.tsx | 105 - .../AppChrome/MegaMenu/NavBarMenuSection.tsx | 117 - .../MegaMenu/NavFeatureHighlight.tsx | 34 - .../components/AppChrome/MegaMenu/utils.ts | 18 +- .../AppChrome/NavToolbar/NavToolbar.tsx | 5 +- .../ReturnToPrevious/DismissableButton.tsx | 58 + .../ReturnToPrevious.test.tsx | 76 + .../ReturnToPrevious/ReturnToPrevious.tsx | 54 + .../AppChrome/SectionNav/SectionNav.tsx | 109 - .../SectionNav/SectionNavItem.test.tsx | 25 - .../AppChrome/SectionNav/SectionNavItem.tsx | 131 - .../AppChrome/SectionNav/SectionNavToggle.tsx | 68 - .../AppChrome/TopBar/SignInLink.tsx | 7 +- .../app/core/components/Breadcrumbs/utils.ts | 10 +- .../core/components/ConfigDescriptionLink.tsx | 47 - public/app/core/components/Divider.tsx | 25 - .../app/core/components/FlaggedScroller.tsx | 54 + public/app/core/components/Footer/Footer.tsx | 6 +- .../ForgottenPassword/ChangePassword.tsx | 121 +- .../ChangePasswordPage.test.tsx | 3 + .../ForgottenPassword/ForgottenPassword.tsx | 56 +- public/app/core/components/Form/Form.tsx | 62 + .../app/core/components/GraphNG/GraphNG.tsx | 153 +- .../GraphNG/__snapshots__/utils.test.ts.snap | 30 +- .../app/core/components/GraphNG/utils.test.ts | 4 - public/app/core/components/GraphNG/utils.ts | 39 +- .../components/GrotNotFound/GrotNotFound.tsx | 62 + .../GrotNotFound/useMousePosition.ts | 29 + .../app/core/components/Login/LoginForm.tsx | 79 +- .../core/components/Login/LoginPage.test.tsx | 3 + .../app/core/components/Login/LoginPage.tsx | 20 +- .../components/Login/LoginServiceButtons.tsx | 28 +- .../app/core/components/Login/UserSignup.tsx | 7 +- .../NestedFolderPicker/NestedFolderList.tsx | 5 +- .../NestedFolderPicker.test.tsx | 201 +- .../NestedFolderPicker/NestedFolderPicker.tsx | 237 +- .../components/NestedFolderPicker/Trigger.tsx | 43 +- .../NestedFolderPicker/useFoldersQuery.ts | 201 + .../{hooks.ts => useTreeInteractions.ts} | 6 +- .../components/OptionsUI/fieldColor.test.tsx | 54 + .../core/components/OptionsUI/fieldColor.tsx | 30 +- .../core/components/OptionsUI/registry.tsx | 33 +- .../app/core/components/OptionsUI/slider.tsx | 4 +- public/app/core/components/Page/Page.tsx | 59 +- .../app/core/components/Page/PageHeader.tsx | 1 + public/app/core/components/Page/types.ts | 10 +- .../PageHeader/PanelHeaderMenuItem.tsx | 5 +- .../PageNotFound/EntityNotFound.tsx | 10 +- .../QueryOperationAction.test.tsx | 14 +- .../QueryOperationAction.tsx | 39 +- .../components/RolePicker/RolePickerInput.tsx | 4 +- .../components/RolePicker/TeamRolePicker.tsx | 2 +- .../components/RolePicker/UserRolePicker.tsx | 2 +- .../core/components/Select/FolderPicker.tsx | 4 +- .../Select/OldFolderPicker.test.tsx | 12 +- .../components/Select/OldFolderPicker.tsx | 21 +- .../app/core/components/Select/OrgPicker.tsx | 5 +- .../SharedPreferences/SharedPreferences.tsx | 180 +- .../components/Signup/SignupPage.test.tsx | 1 + .../app/core/components/Signup/SignupPage.tsx | 127 +- .../core/components/Signup/VerifyEmail.tsx | 66 +- .../Signup/VerifyEmailPage.test.tsx | 1 + .../SplitPaneWrapper/SplitPaneWrapper.tsx | 86 +- .../TimePicker/TimePickerWithHistory.tsx | 10 +- .../core/components/TimeSeries/TimeSeries.tsx | 7 +- .../app/core/components/TimeSeries/utils.ts | 170 +- .../TimelineChart/TimelineChart.tsx | 9 +- .../core/components/TimelineChart/timeline.ts | 40 +- .../components/TimelineChart/utils.test.ts | 181 +- .../core/components/TimelineChart/utils.ts | 137 +- .../ValidationLabels/ValidationLabels.tsx | 123 + public/app/core/components/help/HelpModal.tsx | 236 +- public/app/core/context/GrafanaContext.ts | 23 +- .../core/history/RichHistoryLocalStorage.ts | 14 +- .../history/richHistoryLocalStorageUtils.ts | 4 +- .../history/richHistoryStorageProvider.ts | 6 + .../core/internationalization/index.test.tsx | 26 + .../app/core/internationalization/index.tsx | 23 +- public/app/core/monacoEnv.ts | 32 + .../core/navigation/GrafanaRouteLoading.tsx | 13 +- public/app/core/navigation/types.ts | 2 +- public/app/core/reducers/root.test.ts | 1 + public/app/core/reducers/root.ts | 4 +- .../services/NewFrontendAssetsChecker.test.ts | 49 + .../core/services/NewFrontendAssetsChecker.ts | 112 + public/app/core/services/backend_srv.ts | 24 +- public/app/core/services/context_srv.ts | 9 +- .../GrafanaJavascriptAgentBackend.test.ts | 256 +- .../GrafanaJavascriptAgentBackend.ts | 30 +- public/app/core/services/keybindingSrv.ts | 12 +- public/app/core/services/theme.ts | 2 +- public/app/core/specs/backend_srv.test.ts | 73 +- public/app/core/specs/ticks.test.ts | 21 - public/app/core/specs/time_series.test.ts | 5 +- public/app/core/utils/auth.ts | 4 + public/app/core/utils/browser.ts | 2 +- public/app/core/utils/explore.test.ts | 23 - public/app/core/utils/explore.ts | 76 +- public/app/core/utils/fetch.ts | 16 +- .../app/core/utils/navBarItem-translations.ts | 30 +- public/app/core/utils/query.ts | 1 + public/app/core/utils/richHistory.test.ts | 32 +- public/app/core/utils/richHistory.ts | 38 +- public/app/core/utils/richHistoryTypes.ts | 4 +- public/app/core/utils/ticks.ts | 121 - public/app/core/utils/timePicker.ts | 21 +- .../app/features/admin/AdminEditOrgPage.tsx | 39 +- .../admin/AdminFeatureTogglesAPI.test.ts | 102 + .../features/admin/AdminFeatureTogglesAPI.ts | 115 +- .../admin/AdminFeatureTogglesPage.tsx | 33 +- .../admin/AdminFeatureTogglesTable.tsx | 88 +- .../app/features/admin/ServerStats.test.tsx | 1 - public/app/features/admin/ServerStats.tsx | 2 +- public/app/features/admin/UserCreatePage.tsx | 71 +- .../features/admin/UserListAnonymousPage.tsx | 89 +- public/app/features/admin/UserListPage.tsx | 5 +- .../DashboardsListModalButton.tsx | 54 +- .../DeleteUserModalButton.tsx | 61 +- .../UserListPublicDashboardPage.tsx | 29 +- public/app/features/admin/UserOrgs.tsx | 1 - public/app/features/admin/UserSessions.tsx | 1 - .../features/admin/Users/AnonUsersTable.tsx | 44 +- .../features/admin/Users/OrgUsersTable.tsx | 5 +- .../app/features/admin/Users/UsersTable.tsx | 43 +- .../admin/ldap/LdapConnectionStatus.tsx | 89 +- public/app/features/admin/ldap/LdapPage.tsx | 178 +- .../app/features/admin/ldap/LdapSyncInfo.tsx | 81 +- .../features/admin/ldap/LdapUserGroups.tsx | 84 +- .../app/features/admin/ldap/LdapUserInfo.tsx | 30 +- .../admin/ldap/LdapUserMappingInfo.tsx | 85 +- .../admin/ldap/LdapUserPermissions.tsx | 91 +- .../app/features/admin/ldap/LdapUserTeams.tsx | 81 +- .../admin/migrate-to-cloud/MigrateToCloud.tsx | 11 + .../features/admin/migrate-to-cloud/api.ts | 207 + .../admin/migrate-to-cloud/cloud/InfoPane.tsx | 75 + .../DeleteMigrationTokenModal.tsx | 45 + .../MigrationTokenModal.tsx | 45 + .../MigrationTokenPane/MigrationTokenPane.tsx | 72 + .../cloud/MigrationTokenPane/TokenStatus.tsx | 24 + .../admin/migrate-to-cloud/cloud/Page.tsx | 34 + .../admin/migrate-to-cloud/fixtures/mswAPI.ts | 15 + .../onprem/DisconnectModal.tsx | 56 + .../EmptyState/CallToAction/CallToAction.tsx | 36 + .../EmptyState/CallToAction/ConnectModal.tsx | 128 + .../onprem/EmptyState/EmptyState.tsx | 38 + .../onprem/EmptyState/InfoPaneLeft.tsx | 45 + .../onprem/EmptyState/InfoPaneRight.tsx | 43 + .../admin/migrate-to-cloud/onprem/Page.tsx | 37 + .../onprem/ResourcesTable.tsx | 103 + .../migrate-to-cloud/shared/InfoItem.tsx | 24 + public/app/features/admin/state/actions.ts | 75 +- public/app/features/admin/state/reducers.ts | 53 +- .../app/features/alerting/AlertHowToModal.tsx | 22 - .../features/alerting/AlertRuleItem.test.tsx | 42 - .../app/features/alerting/AlertRuleItem.tsx | 60 - .../features/alerting/AlertRuleList.test.tsx | 115 - .../app/features/alerting/AlertRuleList.tsx | 147 - public/app/features/alerting/AlertTab.tsx | 275 - .../features/alerting/AlertTabCtrl.test.ts | 84 - public/app/features/alerting/AlertTabCtrl.ts | 526 - .../app/features/alerting/AlertTabIndex.tsx | 8 - .../alerting/EditNotificationChannelPage.tsx | 135 - .../features/alerting/FeatureTogglePage.tsx | 32 - .../alerting/NewNotificationChannelPage.tsx | 82 - .../alerting/NotificationsListPage.tsx | 124 - public/app/features/alerting/StateHistory.tsx | 113 - .../features/alerting/TestRuleResult.test.tsx | 47 - .../app/features/alerting/TestRuleResult.tsx | 129 - .../alerting/components/BasicSettings.tsx | 52 - .../alerting/components/ChannelSettings.tsx | 39 - .../alerting/components/DeprecationNotice.tsx | 25 - .../components/NotificationChannelForm.tsx | 131 - .../components/NotificationChannelOptions.tsx | 81 - .../components/NotificationSettings.tsx | 58 - .../alerting/components/OptionElement.tsx | 58 - .../getAlertingValidationMessage.test.ts | 200 - .../alerting/getAlertingValidationMessage.ts | 48 - .../features/alerting/partials/alert_tab.html | 244 - public/app/features/alerting/routes.tsx | 513 +- public/app/features/alerting/state/actions.ts | 79 - .../alerting/unified/AlertingNotEnabled.tsx | 29 + .../unified/AlertsFolderView.test.tsx | 11 +- .../alerting/unified/AlertsFolderView.tsx | 7 +- .../features/alerting/unified/Analytics.ts | 159 +- .../alerting/unified/CloneRuleEditor.test.tsx | 32 +- .../unified/GrafanaRuleQueryViewer.test.tsx | 15 +- .../unified/GrafanaRuleQueryViewer.tsx | 279 +- .../unified/MoreActionsRuleButtons.tsx | 70 - .../alerting/unified/MuteTimings.test.tsx | 160 +- .../features/alerting/unified/MuteTimings.tsx | 15 +- .../unified/NotificationPolicies.test.tsx | 32 +- .../alerting/unified/NotificationPolicies.tsx | 133 +- .../alerting/unified/PanelAlertTab.tsx | 2 +- .../unified/PanelAlertTabContent.test.tsx | 2 +- .../alerting/unified/PanelAlertTabContent.tsx | 4 +- .../RuleEditorCloudOnlyAllowed.test.tsx | 5 +- .../unified/RuleEditorCloudRules.test.tsx | 5 +- .../unified/RuleEditorExisting.test.tsx | 11 +- .../unified/RuleEditorGrafanaRules.test.tsx | 50 +- .../unified/RuleEditorRecordingRule.test.tsx | 7 +- .../alerting/unified/RuleList.test.tsx | 13 +- .../features/alerting/unified/RuleList.tsx | 43 +- .../features/alerting/unified/RuleViewer.tsx | 72 +- .../alerting/unified/Silences.test.tsx | 39 +- public/app/features/alerting/unified/TODO.md | 2 +- .../NotificationPolicies.test.tsx.snap | 281 + .../PanelAlertTabContent.test.tsx.snap | 4 + .../alerting/unified/api/alertRuleApi.ts | 22 +- .../alerting/unified/api/alertingApi.ts | 27 +- .../alerting/unified/api/alertmanagerApi.ts | 11 +- .../alerting/unified/api/dataSourcesApi.ts | 15 + .../unified/api/featureDiscoveryApi.ts | 2 +- .../alerting/unified/api/onCallApi.ts | 6 +- .../features/alerting/unified/api/ruler.ts | 12 +- .../alerting/unified/api/upgradeApi.ts | 444 + .../unified/components/AlertLabels.test.tsx | 4 +- .../unified/components/AlertStateDot.tsx | 52 +- .../components/AlertingPageWrapper.tsx | 4 +- .../components/ConditionalWrap.tsx | 0 .../unified/components/Expression.test.tsx | 63 - ...rafanaAlertmanagerDeliveryWarning.test.tsx | 13 +- .../alerting/unified/components/HoverCard.tsx | 76 +- .../alerting/unified/components/Label.tsx | 3 +- .../unified/components/PluginBridge.mock.ts | 25 +- .../unified/components/Provisioning.tsx | 11 +- .../unified/components/WithReturnButton.tsx | 19 + .../admin/AlertmanagerConfig.test.tsx | 2 +- .../unified/components/admin/ConfigEditor.tsx | 141 +- .../admin/ExternalAlertmanagerDataSources.tsx | 18 +- .../admin/ExternalAlertmanagers.tsx | 5 +- .../alert-groups/AlertGroupFilter.tsx | 9 +- .../alert-groups/AlertStateFilter.tsx | 14 +- .../components/alert-groups/GroupBy.tsx | 6 +- .../components/alert-groups/MatcherFilter.tsx | 73 +- .../contact-points/ContactPoints.test.tsx | 10 + .../contact-points/ContactPoints.tsx | 18 +- .../__mocks__/grafanaManagedServer.ts | 45 +- .../__mocks__/mimirFlavoredServer.ts | 19 +- .../__mocks__/vanillaAlertmanagerServer.ts | 9 +- .../useContactPoints.test.tsx.snap | 1 + .../contact-points/useContactPoints.tsx | 56 +- .../components/contact-points/utils.test.ts | 157 + .../components/contact-points/utils.ts | 82 +- .../components/export/FileExportPreview.tsx | 66 +- .../export/GrafanaModifyExport.test.tsx | 33 +- .../unified/components/export/providers.ts | 6 + .../components/expressions/Expression.tsx | 56 +- .../ExpressionStatusIndicator.test.tsx | 32 - .../expressions/ExpressionStatusIndicator.tsx | 26 +- .../components/expressions/util.test.ts | 132 +- .../unified/components/expressions/util.ts | 63 +- .../mute-timings/MuteTimingForm.tsx | 106 +- .../mute-timings/MuteTimingTimeInterval.tsx | 47 +- .../mute-timings/MuteTimingTimeRange.tsx | 36 +- .../mute-timings/MuteTimingsTable.tsx | 122 +- .../unified/components/mute-timings/util.tsx | 10 +- .../EditDefaultPolicyForm.tsx | 227 +- .../EditNotificationPolicyForm.tsx | 413 +- .../notification-policies/Filters.tsx | 73 +- .../notification-policies/Matchers.tsx | 14 +- .../notification-policies/Modals.tsx | 53 +- .../notification-policies/Policy.test.tsx | 113 +- .../notification-policies/Policy.tsx | 875 +- .../receivers/DuplicateTemplateView.tsx | 2 +- .../components/receivers/EditTemplateView.tsx | 2 +- .../receivers/PayloadEditor.test.tsx | 10 +- .../components/receivers/TemplateForm.tsx | 4 +- .../receivers/form/ChannelOptions.tsx | 2 +- .../receivers/form/ChannelSubForm.tsx | 3 +- .../receivers/form/ReceiverForm.tsx | 6 +- .../receivers/form/fields/OptionField.tsx | 15 +- .../useReceiversMetadata.ts | 2 +- .../rule-editor/AnnotationHeaderField.tsx | 2 + .../rule-editor/AnnotationsStep.tsx | 13 +- .../rule-editor/DashboardPicker.test.tsx | 10 +- .../components/rule-editor/FolderAndGroup.tsx | 107 +- .../rule-editor/GrafanaAlertStatePicker.tsx | 1 + .../rule-editor/GrafanaEvaluationBehavior.tsx | 13 +- .../components/rule-editor/LabelsField.tsx | 83 +- .../rule-editor/NotificationsStep.tsx | 16 +- .../components/rule-editor/QueryOptions.tsx | 6 +- .../components/rule-editor/QueryRows.tsx | 4 +- .../components/rule-editor/QueryWrapper.tsx | 21 +- .../rule-editor/RecordingRuleEditor.tsx | 7 +- .../rule-editor/RuleFolderPicker.tsx | 8 +- .../components/rule-editor/VizWrapper.tsx | 4 +- .../alert-rule-form/AlertRuleForm.tsx | 49 +- .../alert-rule-form/ModifyExportRuleForm.tsx | 24 +- .../simplifiedRouting/AlertManagerRouting.tsx | 75 +- .../SimplifiedRuleEditor.test.tsx | 433 + .../contactPoint/ContactPointSelector.tsx | 224 +- .../route-settings/MuteTimingFields.tsx | 27 +- .../route-settings/RouteSettings.tsx | 125 +- .../NotificationPolicyMatchers.tsx | 10 +- .../NotificationPreview.tsx | 3 +- .../notificaton-preview/NotificationRoute.tsx | 6 +- .../NotificationRouteDetailsModal.tsx | 24 +- ...eAlertmanagerNotificationRoutingPreview.ts | 5 +- .../CloudDataSourceSelector.tsx | 1 - .../QueryAndExpressionsStep.tsx | 28 +- .../descriptions.tsx | 32 + .../query-and-alert-condition/reducer.ts | 2 +- .../components/rule-editor/util.test.ts | 28 +- .../unified/components/rule-editor/util.ts | 18 +- .../components/rule-viewer/Actions.tsx | 113 + .../rule-viewer/{v2 => }/DeleteModal.tsx | 6 +- .../rule-viewer/FederatedRuleWarning.tsx | 18 + .../components/rule-viewer/RuleContext.tsx | 33 + .../rule-viewer/RuleViewer.test.tsx | 174 + .../components/rule-viewer/RuleViewer.tsx | 335 + .../rule-viewer/RuleViewer.v1.test.tsx | 377 - .../components/rule-viewer/RuleViewer.v1.tsx | 261 - .../rule-viewer/RuleViewerVisualization.tsx | 132 +- .../components/rule-viewer/StateBadges.tsx | 80 + .../rule-viewer/__mocks__/server.ts | 57 + .../components/rule-viewer/tabs/Details.tsx | 4 +- .../components/rule-viewer/tabs/Query.tsx | 48 +- .../tabs/Query/DataSourceModelPreview.tsx | 40 + .../tabs/Query/LokiQueryPreview.tsx | 14 + .../tabs/Query/PrometheusQueryPreview.tsx | 14 + .../tabs/Query/SQLQueryPreview.tsx | 39 + .../rule-viewer/v2/RuleViewer.v2.tsx | 393 - .../unified/components/rules/CloneRule.tsx | 36 +- .../unified/components/rules/CloudRules.tsx | 58 +- .../components/rules/EditRuleGroupModal.tsx | 20 +- .../unified/components/rules/GrafanaRules.tsx | 37 +- .../rules/ReorderRuleGroupModal.tsx | 6 +- .../components/rules/RuleDetails.test.tsx | 16 +- .../unified/components/rules/RuleDetails.tsx | 13 +- .../rules/RuleDetailsActionButtons.tsx | 106 +- .../rules/RuleDetailsMatchingInstances.tsx | 6 +- .../unified/components/rules/RuleHealth.tsx | 4 +- .../components/rules/RuleListErrors.tsx | 6 +- .../unified/components/rules/RulesFilter.tsx | 101 +- .../components/rules/RulesGroup.test.tsx | 14 +- .../unified/components/rules/RulesGroup.tsx | 17 +- .../rules/state-history/LogTimelineViewer.tsx | 6 +- .../state-history/LokiStateHistory.test.tsx | 114 +- .../components/silences/SilencesTable.tsx | 17 +- .../alerting/unified/home/GettingStarted.tsx | 265 +- .../alerting/unified/home/Insights.tsx | 4 +- .../__snapshots__/useAbilities.test.tsx.snap | 78 + .../unified/hooks/useAbilities.test.tsx | 18 +- .../alerting/unified/hooks/useAbilities.ts | 11 +- .../alerting/unified/hooks/useCombinedRule.ts | 5 +- .../hooks/useCombinedRuleNamespaces.ts | 51 +- .../hooks/useExternalAMSelector.test.tsx | 187 +- .../unified/hooks/useExternalAmSelector.ts | 150 +- .../unified/hooks/useFilteredRules.test.ts | 26 + .../unified/hooks/useFilteredRules.ts | 10 +- .../unified/hooks/useMuteTimingOptions.ts | 4 +- .../unified/hooks/usePanelCombinedRules.ts | 19 +- .../alerting/unified/initAlerting.tsx | 29 + .../grafana/{Firing.tsx => Active.tsx} | 8 +- .../unified/integration/AlertRulesDrawer.tsx | 38 + .../integration/AlertRulesDrawerContent.tsx | 41 + .../integration/AlertRulesToolbarButton.tsx | 36 + .../app/features/alerting/unified/mockApi.ts | 159 +- public/app/features/alerting/unified/mocks.ts | 18 +- .../alerting/unified/mocks/alertRuleApi.ts | 7 +- .../alerting/unified/mocks/alertmanagerApi.ts | 11 +- .../alerting/unified/mocks/plugins.ts | 10 +- .../alerting/unified/mocks/rulerApi.ts | 13 +- .../alerting/unified/mocks/templatesApi.ts | 7 +- .../alerting/unified/routeGroupsMatcher.ts | 25 +- .../unified/search/rulesSearchParser.ts | 6 + .../alerting/unified/search/search.grammar | 7 +- .../alerting/unified/search/search.js | 31 +- .../alerting/unified/search/search.terms.js | 6 +- .../alerting/unified/search/searchParser.ts | 2 + .../unified/state/AlertmanagerContext.tsx | 17 +- .../alerting/unified/state/actions.ts | 113 +- .../unified/types/mute-timing-form.ts | 11 +- .../alerting/unified/types/receiver-form.ts | 2 +- .../alerting/unified/useRouteGroupsMatcher.ts | 64 +- .../__snapshots__/routeTree.test.ts.snap | 87 + .../__snapshots__/rule-form.test.ts.snap | 4 +- .../alerting/unified/utils/alertmanager.ts | 11 +- .../alerting/unified/utils/amroutes.test.ts | 119 +- .../alerting/unified/utils/amroutes.ts | 33 +- .../cloud-alertmanager-notifier-types.ts | 10 + .../features/alerting/unified/utils/config.ts | 5 +- .../utils/dataSourceFromExpression.ts | 0 .../alerting/unified/utils/datasource.ts | 25 +- .../alerting/unified/utils/matchers.test.ts | 42 +- .../alerting/unified/utils/matchers.ts | 38 + .../features/alerting/unified/utils/misc.ts | 40 +- .../alerting/unified/utils/mute-timings.ts | 61 +- .../utils/notification-policies.test.ts | 64 +- .../unified/utils/notification-policies.ts | 46 +- .../features/alerting/unified/utils/query.ts | 43 +- .../alerting/unified/utils/routeTree.test.ts | 107 + .../alerting/unified/utils/routeTree.ts | 149 +- .../alerting/unified/utils/rule-form.test.ts | 159 +- .../alerting/unified/utils/rule-form.ts | 191 +- .../alerting/unified/utils/rulerClient.ts | 25 +- .../features/alerting/unified/utils/rules.ts | 4 +- .../alerting/unified/utils/timeRange.ts | 1 + .../utils/notificationChannel.test.ts | 210 - .../alerting/utils/notificationChannels.ts | 72 - .../StandardAnnotationQueryEditor.tsx | 118 +- public/app/features/api-keys/ApiKeysPage.tsx | 10 +- .../features/auth-config/AuthDrawer.test.tsx | 22 + .../app/features/auth-config/AuthDrawer.tsx | 104 + .../auth-config/AuthProvidersListPage.tsx | 32 +- .../features/auth-config/FieldRenderer.tsx | 149 + .../auth-config/ProviderConfigForm.test.tsx | 115 +- .../auth-config/ProviderConfigForm.tsx | 326 +- .../auth-config/ProviderConfigPage.tsx | 23 +- .../auth-config/components/ProviderCard.tsx | 14 +- public/app/features/auth-config/constants.ts | 13 + public/app/features/auth-config/fields.ts | 116 - public/app/features/auth-config/fields.tsx | 494 + public/app/features/auth-config/types.ts | 29 +- public/app/features/auth-config/utils/data.ts | 47 +- public/app/features/auth-config/utils/url.ts | 14 +- .../BrowseDashboardsPage.test.tsx | 43 +- .../BrowseFolderAlertingPage.test.tsx | 25 +- .../BrowseFolderLibraryPanelsPage.test.tsx | 32 +- .../api/browseDashboardsAPI.ts | 66 +- .../browse-dashboards/api/services.ts | 3 +- .../components/BrowseActions/DeleteModal.tsx | 3 +- .../BrowseActions/MoveModal.test.tsx | 7 +- .../components/BrowseActions/MoveModal.tsx | 3 +- .../components/CheckboxCell.tsx | 16 +- .../components/DashboardsTree.test.tsx | 76 +- .../components/DashboardsTree.tsx | 48 +- .../browse-dashboards/components/NameCell.tsx | 4 +- .../components/NewFolderForm.tsx | 66 +- .../browse-dashboards/components/utils.ts | 6 + .../fixtures/dashboardsTreeItem.fixture.ts | 8 + .../features/browse-dashboards/state/hooks.ts | 20 +- .../browse-dashboards/state/reducers.test.ts | 61 +- .../browse-dashboards/state/reducers.ts | 48 +- .../app/features/browse-dashboards/types.ts | 2 +- public/app/features/canvas/element.ts | 3 +- .../app/features/canvas/elements/button.tsx | 34 +- .../features/canvas/elements/droneFront.tsx | 8 +- .../features/canvas/elements/droneSide.tsx | 8 +- .../app/features/canvas/elements/droneTop.tsx | 24 +- .../app/features/canvas/elements/ellipse.tsx | 37 +- public/app/features/canvas/elements/icon.tsx | 26 +- .../features/canvas/elements/metricValue.tsx | 26 +- .../features/canvas/elements/rectangle.tsx | 27 +- .../canvas/elements/server/server.tsx | 22 +- public/app/features/canvas/elements/text.tsx | 20 +- .../features/canvas/elements/windTurbine.tsx | 14 +- .../canvas/runtime/SceneTransformWrapper.tsx | 54 +- public/app/features/canvas/runtime/ables.tsx | 12 +- .../app/features/canvas/runtime/element.tsx | 5 +- public/app/features/canvas/runtime/scene.tsx | 116 +- .../commandPalette/CommandPalette.tsx | 12 +- .../features/commandPalette/EmptyState.tsx | 23 + .../features/commandPalette/KBarResults.tsx | 6 +- .../actions/dashboardActions.ts | 6 +- .../commandPalette/actions/staticActions.ts | 21 +- public/app/features/commandPalette/types.ts | 6 +- public/app/features/commandPalette/values.ts | 12 +- .../tabs/ConnectData/ConnectData.test.tsx | 45 +- .../tabs/ConnectData/ConnectData.tsx | 32 +- .../tabs/ConnectData/Search/Search.tsx | 37 +- .../correlations/CorrelationsPage.test.tsx | 20 +- .../Forms/ConfigureCorrelationSourceForm.tsx | 3 +- .../Forms/ConfigureCorrelationTargetForm.tsx | 3 +- .../Forms/TransformationEditorRow.tsx | 43 +- .../Forms/TransformationsEditor.tsx | 90 +- .../correlations/components/Wizard/types.ts | 4 +- public/app/features/correlations/types.ts | 2 +- .../features/correlations/useCorrelations.ts | 8 +- public/app/features/correlations/utils.ts | 4 +- .../embedding/EmbeddedDashboard.tsx | 125 + .../embedding/EmbeddedDashboardLazy.tsx | 12 + .../embedding/EmbeddedDashboardTestPage.tsx | 22 + .../inspect/HelpWizard/HelpWizard.test.tsx | 94 + .../inspect/HelpWizard/HelpWizard.tsx | 229 + .../HelpWizard/SupportSnapshotService.test.ts | 140 + .../HelpWizard/SupportSnapshotService.ts | 133 + .../inspect}/HelpWizard/randomizer.test.ts | 0 .../inspect}/HelpWizard/randomizer.ts | 0 .../inspect/HelpWizard/utils.ts | 323 + .../inspect/InspectJsonTab.test.tsx | 156 +- .../inspect/InspectJsonTab.tsx | 54 +- .../inspect/PanelInspectDrawer.tsx | 60 +- .../pages/DashboardScenePage.test.tsx | 64 +- .../pages/DashboardScenePage.tsx | 37 +- .../DashboardScenePageStateManager.test.ts | 99 +- .../pages/DashboardScenePageStateManager.ts | 198 +- .../dashboard-scene/pages/PanelEditPage.tsx | 36 - .../panel-edit/LibraryVizPanelInfo.tsx | 58 + .../EmptyTransformationsMessage.tsx | 37 + .../PanelDataPane/NewAlertRuleButton.tsx | 51 + .../PanelDataAlertingTab.test.tsx | 357 + .../PanelDataPane/PanelDataAlertingTab.tsx | 124 +- .../PanelDataPane/PanelDataPane.tsx | 87 +- .../PanelDataPane/PanelDataQueriesTab.tsx | 187 +- .../PanelDataTransformationsTab.test.tsx | 184 + .../PanelDataTransformationsTab.tsx | 197 +- .../PanelDataPane/TransformationsDrawer.tsx | 91 + .../PanelDataAlertingTab.test.tsx.snap | 113 + .../panel-edit/PanelDataPane/types.ts | 18 +- .../panel-edit/PanelEditControls.tsx | 33 + .../panel-edit/PanelEditor.test.ts | 183 + .../panel-edit/PanelEditor.tsx | 291 +- .../panel-edit/PanelEditorRenderer.tsx | 195 +- .../panel-edit/PanelEditorUrlSync.ts | 49 - .../panel-edit/PanelOptions.test.tsx | 63 + .../panel-edit/PanelOptions.tsx | 113 + .../panel-edit/PanelOptionsPane.tsx | 130 +- .../panel-edit/PanelVizTypePicker.tsx | 106 +- .../panel-edit/SaveLibraryVizPanelModal.tsx | 107 + .../panel-edit/ShareDataProvider.tsx | 35 + .../panel-edit/VizPanelManager.test.tsx | 356 +- .../panel-edit/VizPanelManager.tsx | 424 +- .../splitter/useSnappingSplitter.ts | 104 + .../panel-edit/testfiles/testDashboard.json | 368 - .../panel-edit/testfiles/testDashboard.ts | 375 + .../saving/DashboardPrompt.test.tsx | 156 + .../saving/DashboardPrompt.tsx | 188 + .../saving/DashboardSceneChangeTracker.ts | 123 + .../saving/DetectChangesWorker.ts | 10 + .../saving/SaveDashboardAsForm.tsx | 201 + .../saving/SaveDashboardDrawer.test.tsx | 207 + .../saving/SaveDashboardDrawer.tsx | 86 + .../saving/SaveDashboardForm.tsx | 160 + .../saving/SaveProvisionedDashboardForm.tsx | 97 + .../__mocks__/createDetectChangesWorker.ts | 19 + .../saving/createDetectChangesWorker.ts | 3 + .../saving/getDashboardChanges.ts | 145 + .../getDashboardChangesFromScene.test.ts | 122 + .../saving/getDashboardChangesFromScene.ts | 14 + .../saving/getSaveDashboardChange.ts | 88 + .../dashboard-scene/saving/shared.tsx | 73 + .../saving/useSaveDashboard.ts | 88 + .../scene/AddLibraryPanelWidget.test.tsx | 252 + .../scene/AddLibraryPanelWidget.tsx | 169 + .../scene/AlertStatesDataLayer.ts | 173 +- .../scene/DashboardControls.tsx | 85 +- .../scene/DashboardLinksControls.tsx | 17 +- .../scene/DashboardScene.test.tsx | 817 +- .../dashboard-scene/scene/DashboardScene.tsx | 617 +- .../scene/DashboardSceneRenderer.tsx | 57 +- .../scene/DashboardSceneUrlSync.ts | 127 +- .../scene/GoToSnapshotOriginButton.test.tsx | 60 + .../scene/GoToSnapshotOriginButton.tsx | 62 + .../scene/LibraryVizPanel.test.ts | 136 + .../dashboard-scene/scene/LibraryVizPanel.tsx | 123 +- .../scene/NavToolbarActions.test.tsx | 150 + .../scene/NavToolbarActions.tsx | 617 +- .../dashboard-scene/scene/PanelLinks.tsx | 30 +- .../scene/PanelMenuBehavior.test.tsx | 94 +- .../scene/PanelMenuBehavior.tsx | 437 +- .../scene/PanelRepeaterGridItem.test.tsx | 11 +- .../scene/PanelRepeaterGridItem.tsx | 67 +- .../scene/RowRepeaterBehavior.test.tsx | 33 + .../scene/RowRepeaterBehavior.ts | 37 +- .../scene/ShareQueryDataProvider.test.ts | 56 - .../scene/ShareQueryDataProvider.ts | 145 - .../scene/UnlinkLibraryPanelModal.tsx | 44 + .../scene}/UnlinkModal.tsx | 0 .../scene/keyboardShortcuts.ts | 55 +- .../scene/row-actions/RowActions.tsx | 208 + .../scene/row-actions/RowOptionsButton.tsx | 50 + .../scene/row-actions/RowOptionsForm.test.tsx | 51 + .../scene/row-actions/RowOptionsForm.tsx | 61 + .../scene/row-actions/RowOptionsModal.tsx | 40 + .../scene/setDashboardPanelContext.test.ts | 22 +- .../scene/setDashboardPanelContext.ts | 42 +- .../serialization/SaveDashboardDrawer.tsx | 41 - .../transformSceneToSaveModel.test.ts.snap | 134 +- .../serialization/angularMigration.test.ts | 5 +- .../buildNewDashboardSaveModel.ts | 26 + .../serialization/dataLayersToAnnotations.ts | 1 + .../sceneVariablesSetToVariables.test.ts | 235 +- .../sceneVariablesSetToVariables.ts | 31 +- .../transformSaveModelToScene.test.ts | 511 +- .../transformSaveModelToScene.ts | 272 +- .../transformSceneToSaveModel.test.ts | 216 +- .../transformSceneToSaveModel.ts | 202 +- .../settings/AnnotationsEditView.test.tsx | 218 + .../settings/AnnotationsEditView.tsx | 216 +- .../settings/DashboardLinksEditView.test.tsx | 260 + .../settings/DashboardLinksEditView.tsx | 102 +- .../settings/EditListViewSceneUrlSync.ts | 8 +- .../settings/GeneralSettingsEditView.test.tsx | 46 +- .../settings/GeneralSettingsEditView.tsx | 34 +- .../settings/JsonModelEditView.tsx | 210 + .../settings/PermissionsEditView.test.tsx | 73 + .../settings/PermissionsEditView.tsx | 46 + .../settings/VariablesEditView.test.tsx | 246 +- .../settings/VariablesEditView.tsx | 177 +- .../settings/VersionsEditView.test.tsx | 199 + .../settings/VersionsEditView.tsx | 252 + .../annotations}/AngularEditorLoader.tsx | 0 .../AnnotationSettingsEdit.test.tsx | 226 + .../annotations/AnnotationSettingsEdit.tsx | 321 + .../AnnotationSettingsList.test.tsx | 139 + .../annotations/AnnotationSettingsList.tsx | 139 + .../settings/annotations/index.tsx | 2 + .../settings/links/DashboardLinkForm.tsx | 109 + .../settings/links/DashboardLinkList.tsx | 115 + .../dashboard-scene/settings/links/utils.ts | 25 + .../dashboard-scene/settings/utils.ts | 24 + .../settings/variables/VariableEditorForm.tsx | 168 + .../settings/variables/VariableEditorList.tsx | 14 +- .../variables/VariableEditorListRow.tsx | 19 +- .../components/AdHocVariableForm.test.tsx | 118 + .../components/AdHocVariableForm.tsx | 85 + .../components/ConstantVariableForm.tsx | 27 + .../components/CustomVariableForm.test.tsx | 116 + .../components/CustomVariableForm.tsx | 57 + .../components/DataSourceVariableForm.tsx | 79 + .../components/GroupByVariableForm.test.tsx | 114 + .../components/GroupByVariableForm.tsx | 81 + .../components/IntervalVariableForm.tsx | 96 + .../variables/components/QueryEditor.tsx | 78 + .../components/QueryVariableForm.test.tsx | 273 + .../components/QueryVariableForm.tsx | 136 + .../components/SelectionOptionsForm.tsx | 52 + .../components/TextBoxVariableForm.test.tsx | 34 + .../components/TextBoxVariableForm.tsx | 30 + .../components}/VariableCheckboxField.tsx | 5 +- .../components}/VariableHideSelect.tsx | 0 .../variables/components}/VariableLegend.tsx | 0 .../components}/VariableSelectField.tsx | 29 +- .../components}/VariableTextAreaField.tsx | 29 +- .../components}/VariableTextField.tsx | 7 +- .../components/VariableTypeSelect.tsx | 30 + .../components}/VariableValuesPreview.tsx | 13 +- .../AdHocFiltersVariableEditor.test.tsx | 145 + .../editors/AdHocFiltersVariableEditor.tsx | 54 + .../editors/ConstantVariableEditor.test.tsx | 50 + .../editors/ConstantVariableEditor.tsx | 19 + .../editors/CustomVariableEditor.test.tsx | 115 + .../editors/CustomVariableEditor.tsx | 41 + .../editors/DataSourceVariableEditor.test.tsx | 157 + .../editors/DataSourceVariableEditor.tsx | 62 + .../editors/GroupByVariableEditor.test.tsx | 103 + .../editors/GroupByVariableEditor.tsx | 51 + .../editors/IntervalVariableEditor.test.tsx | 115 + .../editors/IntervalVariableEditor.tsx | 53 + .../editors/QueryVariableEditor.test.tsx | 332 + .../variables/editors/QueryVariableEditor.tsx | 82 + .../editors/TextBoxVariableEditor.test.tsx | 54 + .../editors/TextBoxVariableEditor.tsx | 20 + .../settings/variables/utils.test.ts | 371 + .../settings/variables/utils.ts | 223 + .../settings/version-history}/DiffGroup.tsx | 22 +- .../settings/version-history}/DiffTitle.tsx | 63 +- .../settings/version-history}/DiffValues.tsx | 17 +- .../settings/version-history}/DiffViewer.tsx | 24 +- .../version-history}/HistorySrv.test.ts | 39 +- .../settings/version-history/HistorySrv.ts | 50 + .../version-history/RevertDashboardModal.tsx | 44 + .../VersionHistoryButtons.tsx | 0 .../VersionHistoryComparison.tsx | 85 + .../version-history}/VersionHistoryHeader.tsx | 12 +- .../version-history/VersionHistoryTable.tsx | 90 + .../__mocks__/dashboardHistoryMocks.ts | 0 .../settings/version-history}/index.ts | 0 .../settings/version-history}/utils.test.ts | 4 +- .../settings/version-history}/utils.ts | 6 +- .../sharing/ShareLinkTab.test.tsx | 10 +- .../dashboard-scene/sharing/ShareLinkTab.tsx | 14 +- .../sharing/SharePanelEmbedTab.tsx | 46 +- .../sharing/ShareSnapshotTab.tsx | 23 +- .../sharing/public-dashboards/utils.ts | 2 +- .../dashboard-scene/solo/SoloPanelPage.tsx | 63 + .../dashboard-scene/solo/useSoloPanel.ts | 84 + ...DashboardModelCompatibilityWrapper.test.ts | 166 +- .../DashboardModelCompatibilityWrapper.ts | 102 +- .../utils/PanelModelCompatibilityWrapper.ts | 70 + .../utils/createPanelDataProvider.ts | 39 +- .../utils/dashboardSceneGraph.test.ts | 305 +- .../utils/dashboardSceneGraph.ts | 167 +- .../utils/getVariablesCompatibility.ts | 2 +- .../dashboard-scene/utils/interactions.ts | 18 +- .../dashboard-scene/utils/test-utils.ts | 20 +- .../dashboard-scene/utils/urlBuilders.test.ts | 30 +- .../dashboard-scene/utils/urlBuilders.ts | 30 +- .../features/dashboard-scene/utils/utils.ts | 83 +- .../AddPanelButton/AddPanelButton.tsx | 35 +- .../AddPanelButton/AddPanelMenu.test.tsx | 1 - .../AddPanelWidget/AddPanelWidget.test.tsx | 32 - .../AddPanelWidget/AddPanelWidget.tsx | 299 - .../AddPanelWidget/_AddPanelWidget.scss | 78 - .../components/AddPanelWidget/index.ts | 1 - .../AnnotationSettingsEdit.tsx | 3 +- .../AnnotationSettingsList.tsx | 10 +- .../DashExportModal/DashboardExporter.test.ts | 47 +- .../dashboard/components/DashNav/DashNav.tsx | 119 +- .../components/DashNav/ShareButton.tsx | 46 +- .../AccessControlDashboardPermissions.tsx | 3 +- .../DashboardPrompt/DashboardPrompt.tsx | 2 +- .../AnnotationsSettings.test.tsx | 4 +- .../DashboardSettings/AnnotationsSettings.tsx | 4 +- .../DashboardSettings/DashboardSettings.tsx | 9 +- .../GeneralSettings.test.tsx | 4 +- .../DashboardSettings/GeneralSettings.tsx | 4 +- .../DashboardSettings/JsonEditorSettings.tsx | 6 +- .../DashboardSettings/LinksSettings.test.tsx | 10 +- .../DashboardSettings/LinksSettings.tsx | 25 +- .../DashboardSettings/ListNewButton.tsx | 2 +- .../DashboardSettings/TimePickerSettings.tsx | 2 +- .../VersionsSettings.test.tsx | 4 +- .../DashboardSettings/VersionsSettings.tsx | 25 +- .../DeleteDashboard/DeleteDashboardModal.tsx | 31 +- .../components/GenAI/GenAIButton.test.tsx | 45 +- .../components/GenAI/GenAIButton.tsx | 40 +- .../components/GenAI/GenAIHistory.tsx | 35 +- .../dashboard/components/GenAI/hooks.ts | 19 +- .../dashboard/components/GenAI/utils.test.ts | 10 +- .../dashboard/components/GenAI/utils.ts | 2 +- .../components/HelpWizard/HelpWizard.test.tsx | 2 +- .../HelpWizard/SupportSnapshotService.ts | 4 +- .../dashboard/components/HelpWizard/utils.ts | 7 +- .../LinksSettings/LinkSettingsEdit.tsx | 131 +- .../LinksSettings/LinkSettingsList.tsx | 100 +- .../PanelEditor/OptionsPaneCategory.tsx | 131 +- .../OptionsPaneCategoryDescriptor.tsx | 16 +- .../PanelEditor/OptionsPaneItemOverrides.tsx | 7 +- .../PanelEditor/OptionsPaneOptions.tsx | 11 +- .../components/PanelEditor/PanelEditor.tsx | 2 +- .../PanelEditor/PanelEditorTabs.tsx | 64 +- .../PanelEditor/getFieldOverrideElements.tsx | 24 +- .../PanelEditor/getPanelFrameOptions.tsx | 180 +- .../PanelEditor/getVisualizationOptions.tsx | 139 +- .../PanelEditor/state/selectors.test.ts | 48 - .../components/PanelEditor/state/selectors.ts | 9 +- .../dashboard/components/PanelEditor/types.ts | 2 +- .../RepeatRowSelect/RepeatRowSelect.tsx | 42 +- .../RowOptions/RowOptionsButton.tsx | 2 +- .../components/RowOptions/RowOptionsForm.tsx | 6 +- .../components/RowOptions/RowOptionsModal.tsx | 2 +- .../SaveDashboard/SaveDashboardDiff.tsx | 34 +- .../SaveDashboard/SaveDashboardDrawer.tsx | 3 +- .../forms/SaveDashboardAsForm.tsx | 2 +- .../SaveDashboard/forms/SaveDashboardForm.tsx | 2 +- .../components/SaveDashboard/types.ts | 7 +- .../components/ShareModal/ShareExport.tsx | 4 +- .../components/ShareModal/ShareLink.test.tsx | 26 +- .../components/ShareModal/ShareModal.tsx | 3 +- .../ConfigPublicDashboard.tsx | 17 +- .../ConfigPublicDashboard/Configuration.tsx | 32 +- .../EmailSharingConfiguration.tsx | 65 +- .../SettingsBarHeader.tsx | 7 +- .../ConfigPublicDashboard/SettingsSummary.tsx | 28 +- .../AcknowledgeCheckboxes.tsx | 85 +- .../CreatePublicDashboard.tsx | 59 +- .../ModalAlerts/NoUpsertPermissionsAlert.tsx | 11 +- .../ModalAlerts/SaveDashboardChangesAlert.tsx | 6 +- .../UnsupportedDataSourcesAlert.tsx | 13 +- .../UnsupportedTemplateVariablesAlert.tsx | 10 +- .../SharePublicDashboard.test.tsx | 76 +- .../SharePublicDashboard/utilsTest.tsx | 20 +- .../components/ShareModal/ShareSnapshot.tsx | 20 +- .../dashboard/components/ShareModal/utils.ts | 11 +- .../components/SubMenu/DashboardLinks.tsx | 6 +- .../dashboard/components/SubMenu/SubMenu.tsx | 4 - ... TransformationEditorHelpDisplay.test.tsx} | 22 +- ...sx => TransformationEditorHelpDisplay.tsx} | 24 +- .../TransformationFilter.tsx | 77 +- .../TransformationOperationRow.tsx | 27 +- .../TransformationOperationRows.tsx | 5 +- .../TransformationPickerNg.tsx | 37 +- .../TransformationsEditor.test.tsx | 2 +- .../TransformationsEditor.tsx | 61 +- .../components/VersionHistory/HistorySrv.ts | 48 - .../VersionHistoryComparison.tsx | 115 +- .../VersionHistory/VersionHistoryTable.tsx | 131 +- .../VersionHistory/useDashboardRestore.tsx | 5 +- .../containers/DashboardPage.test.tsx | 15 +- .../dashboard/containers/DashboardPage.tsx | 33 +- .../containers/DashboardPageProxy.test.tsx | 28 +- .../containers/DashboardPageProxy.tsx | 20 +- .../containers/NewDashboardWithDS.tsx | 19 +- .../containers/PublicDashboardPage.test.tsx | 10 +- .../features/dashboard/containers/types.ts | 2 + .../dashgrid/DashboardEmpty.test.tsx | 1 - .../dashboard/dashgrid/DashboardEmpty.tsx | 30 +- .../dashboard/dashgrid/DashboardGrid.test.tsx | 7 - .../dashboard/dashgrid/DashboardGrid.tsx | 105 +- .../dashgrid/PanelStateWrapper.test.tsx | 2 + .../dashboard/dashgrid/PanelStateWrapper.tsx | 9 +- public/app/features/dashboard/index.ts | 1 - .../dashboard/services/DashboardSrv.ts | 2 - .../dashboard/services/SnapshotSrv.ts | 125 +- .../dashboard/services/TimeSrv.test.ts | 10 +- .../features/dashboard/services/TimeSrv.ts | 51 +- .../dashboard/state/DashboardMigrator.test.ts | 24 +- .../state/DashboardModel.repeat.test.ts | 28 +- .../dashboard/state/DashboardModel.test.ts | 8 +- .../dashboard/state/DashboardModel.ts | 85 +- .../dashboard/state/PanelModel.test.ts | 19 +- .../features/dashboard/state/PanelModel.ts | 20 +- .../app/features/dashboard/state/TimeModel.ts | 2 +- .../state/getPanelPluginToMigrateTo.ts | 51 + .../dashboard/state/initDashboard.test.ts | 28 +- .../features/dashboard/state/initDashboard.ts | 68 +- .../dashboard/utils/dashboard.test.ts | 2 +- .../dashboard/utils/getPanelChromeProps.tsx | 2 +- .../features/dashboard/utils/getPanelMenu.ts | 23 +- public/app/features/dashboard/utils/panel.ts | 7 +- .../features/dashboard/utils/tracking.test.ts | 3 +- .../app/features/dashboard/utils/tracking.ts | 3 +- .../components/DashboardsTable.test.tsx | 1 - .../datasources/components/EditDataSource.tsx | 10 +- .../picker/BuiltInDataSourceList.tsx | 3 +- .../components/picker/DataSourceDropdown.tsx | 437 - .../components/picker/DataSourceList.tsx | 7 +- ...own.test.tsx => DataSourcePicker.test.tsx} | 24 +- .../components/picker/DataSourcePicker.tsx | 466 +- .../components/picker/popperModifiers.ts | 42 - .../datasources/state/actions.test.ts | 11 +- .../datasources/state/buildCategories.test.ts | 2 +- .../datasources/state/buildCategories.ts | 6 + .../explore/ContentOutline/ContentOutline.tsx | 51 +- .../ContentOutline/ContentOutlineContext.tsx | 2 +- .../ContentOutlineItemButton.tsx | 36 +- .../explore/CorrelationEditorModeBar.tsx | 8 +- .../features/explore/CorrelationHelper.tsx | 6 +- .../CorrelationTransformationAddModal.tsx | 33 +- public/app/features/explore/Explore.test.tsx | 10 +- public/app/features/explore/Explore.tsx | 25 +- public/app/features/explore/ExploreDrawer.tsx | 50 +- .../features/explore/ExplorePaneContainer.tsx | 25 +- .../explore/ExploreQueryInspector.test.tsx | 101 +- .../explore/ExploreQueryInspector.tsx | 27 +- .../features/explore/ExploreTimeControls.tsx | 3 +- .../app/features/explore/ExploreToolbar.tsx | 11 +- .../features/explore/FeatureTogglePage.tsx | 12 +- .../FlameGraph/FlameGraphExploreContainer.tsx | 14 +- .../features/explore/Graph/ExploreGraph.tsx | 11 +- .../app/features/explore/LiveTailButton.tsx | 44 +- public/app/features/explore/Logs/LiveLogs.tsx | 3 +- .../app/features/explore/Logs/Logs.test.tsx | 142 +- public/app/features/explore/Logs/Logs.tsx | 286 +- .../explore/Logs/LogsColumnSearch.tsx | 2 +- .../features/explore/Logs/LogsContainer.tsx | 23 +- .../explore/Logs/LogsMetaRow.test.tsx | 112 +- .../app/features/explore/Logs/LogsMetaRow.tsx | 44 +- .../explore/Logs/LogsNavigation.test.tsx | 64 + .../features/explore/Logs/LogsNavigation.tsx | 73 +- .../explore/Logs/LogsNavigationPages.tsx | 4 +- .../features/explore/Logs/LogsSamplePanel.tsx | 13 +- .../features/explore/Logs/LogsTable.test.tsx | 85 +- .../app/features/explore/Logs/LogsTable.tsx | 80 +- .../explore/Logs/LogsTableActiveFields.tsx | 109 + .../explore/Logs/LogsTableAvailableFields.tsx | 63 + .../explore/Logs/LogsTableEmptyFields.tsx | 21 + .../explore/Logs/LogsTableMultiSelect.tsx | 22 +- .../explore/Logs/LogsTableNavColumn.tsx | 108 - .../explore/Logs/LogsTableNavField.tsx | 83 + .../features/explore/Logs/LogsTableWrap.tsx | 270 +- .../features/explore/Logs/LogsVolumePanel.tsx | 5 +- .../app/features/explore/Logs/PopoverMenu.tsx | 2 +- .../app/features/explore/Logs/utils/logs.ts | 2 + public/app/features/explore/MetaInfoText.tsx | 53 +- public/app/features/explore/NoData.tsx | 32 +- .../explore/NoDataSourceCallToAction.tsx | 23 +- .../PrometheusListView/ItemValues.test.tsx | 3 +- .../explore/PrometheusListView/ItemValues.tsx | 3 +- .../PrometheusListView/RawListContainer.tsx | 7 +- ...awPrometheusListItemsFromDataFrame.test.ts | 2 +- .../getRawPrometheusListItemsFromDataFrame.ts | 4 +- .../app/features/explore/QueryRows.test.tsx | 9 +- .../RawPrometheus/RawPrometheusContainer.tsx | 3 +- .../RichHistory/RichHistoryCard.test.tsx | 2 +- .../explore/RichHistory/RichHistoryCard.tsx | 2 +- .../RichHistory/RichHistoryQueriesTab.tsx | 12 +- .../RichHistory/RichHistoryStarredTab.tsx | 2 +- .../app/features/explore/SecondaryActions.tsx | 12 +- .../explore/SupplementaryResultError.tsx | 26 +- .../explore/Table/TableContainer.test.tsx | 8 + .../features/explore/Table/TableContainer.tsx | 7 +- .../features/explore/TraceView/TraceView.tsx | 57 +- .../TraceView/TraceViewContainer.test.tsx | 9 +- .../explore/TraceView/TraceViewContainer.tsx | 2 +- .../Actions/TracePageActions.tsx | 31 +- .../SearchBar/NextPrevResult.test.tsx | 24 +- .../SpanFilters/SpanFilters.test.tsx | 38 +- .../SpanFilters/SpanFilters.tsx | 34 +- .../TracePageHeader/SpanGraph/index.tsx | 20 +- .../TracePageHeader/TracePageHeader.tsx | 52 +- .../TraceTimelineViewer/SpanBar.tsx | 2 +- .../TraceTimelineViewer/SpanBarRow.test.tsx | 3 +- .../TraceTimelineViewer/SpanBarRow.tsx | 2 +- .../SpanDetail/AccordianKeyValues.tsx | 10 +- .../SpanDetail/AccordianLogs.tsx | 10 +- .../SpanDetail/AccordianReferences.tsx | 12 +- .../SpanDetail/AccordianText.tsx | 5 +- .../SpanDetail/KeyValuesTable.tsx | 11 +- .../SpanDetail/SpanFlameGraph.tsx | 40 +- .../SpanDetail/index.test.tsx | 4 +- .../TraceTimelineViewer/SpanDetail/index.tsx | 61 +- .../TraceTimelineViewer/SpanDetailRow.tsx | 11 +- .../TimelineColumnResizer.tsx | 6 +- .../TimelineHeaderRow/TimelineHeaderRow.tsx | 7 +- .../TraceTimelineViewer/TimelineRow.tsx | 20 +- .../VirtualizedTraceView.tsx | 7 +- .../components/TraceTimelineViewer/index.tsx | 6 +- .../TraceView/components/common/Divider.tsx | 55 - .../components/model/link-patterns.test.ts | 273 +- .../components/model/link-patterns.tsx | 89 +- .../TraceView/components/model/span.tsx | 25 - .../components/settings/SpanBarSettings.tsx | 3 +- .../components/types/TTraceDiffState.tsx | 24 - .../TraceView/components/types/config.tsx | 57 - .../TraceView/components/uberUtilityStyles.ts | 73 - .../explore/TraceView/createSpanLink.test.ts | 6 +- .../explore/TraceView/createSpanLink.tsx | 73 +- .../features/explore/__mocks__/makeLogs.ts | 4 +- .../AddToDashboard/AddToDashboardForm.tsx | 16 +- .../AddToDashboard/addToDashboard.test.ts | 26 +- .../AddToDashboard/addToDashboard.ts | 29 +- .../extensions/AddToDashboard/index.test.tsx | 5 + .../extensions/ToolbarExtensionPoint.test.tsx | 43 +- .../extensions/ToolbarExtensionPoint.tsx | 13 +- .../hooks/useKeyboardShortcuts.test.tsx | 47 +- .../explore/hooks/useKeyboardShortcuts.ts | 22 +- .../hooks/useStateSync/external.utils.ts | 30 + .../explore/hooks/useStateSync/index.test.tsx | 63 + .../explore/hooks/useStateSync/index.ts | 376 +- .../hooks/useStateSync/internal.utils.ts | 146 + .../hooks/useStateSync/migrators/v0.ts | 4 +- .../hooks/useStateSync/migrators/v1.ts | 16 +- .../useStateSync/synchronizer/fromURL.ts | 88 + .../hooks/useStateSync/synchronizer/init.ts | 127 + .../hooks/useStateSync/synchronizer/toURL.ts | 75 + .../explore/spec/datasourceState.test.tsx | 11 + .../explore/spec/helper/interactions.ts | 8 +- .../app/features/explore/spec/helper/query.ts | 6 +- .../explore/spec/interpolation.test.tsx | 5 +- .../app/features/explore/spec/query.test.tsx | 11 + .../explore/spec/queryHistory.test.tsx | 8 +- .../app/features/explore/spec/split.test.tsx | 11 +- .../app/features/explore/state/datasource.ts | 21 +- .../app/features/explore/state/explorePane.ts | 2 - public/app/features/explore/state/helpers.ts | 5 +- public/app/features/explore/state/history.ts | 59 +- public/app/features/explore/state/main.ts | 18 +- .../app/features/explore/state/query.test.ts | 121 +- public/app/features/explore/state/query.ts | 167 +- public/app/features/explore/state/time.ts | 56 +- .../app/features/explore/state/utils.test.ts | 115 +- public/app/features/explore/state/utils.ts | 92 +- public/app/features/explore/utils/links.ts | 4 +- .../utils/supplementaryQueries.test.ts | 81 +- .../explore/utils/supplementaryQueries.ts | 16 +- .../utils/supplementaryQueries_legacy.test.ts | 365 + .../expressions/ExpressionQueryEditor.tsx | 7 + .../expressions/components/SqlExpr.tsx | 27 + .../expressions/components/Threshold.tsx | 355 +- .../__snapshots__/hysteresis.test.ts.snap | 212 + .../expressions/components/hysteresis.test.ts | 217 + .../components/thresholdReducer.ts | 172 + public/app/features/expressions/types.ts | 23 +- .../features/geo/gazetteer/worldmap.test.ts | 2 +- .../features/inspector/InspectDataOptions.tsx | 13 +- .../inspector/InspectDataTab.test.tsx | 57 +- .../app/features/inspector/InspectDataTab.tsx | 18 +- .../app/features/inspector/QueryInspector.tsx | 15 +- .../app/features/inspector/utils/download.ts | 2 +- public/app/features/invites/SignupInvited.tsx | 3 +- .../LibraryPanelsSearch.test.tsx | 1 + .../LibraryPanelsSearch.tsx | 132 +- .../app/features/library-panels/state/api.ts | 33 + .../live/centrifuge/LiveDataStream.test.ts | 4 +- public/app/features/live/info.ts | 44 + .../logs/components/InfiniteScroll.test.tsx | 298 + .../logs/components/InfiniteScroll.tsx | 246 + .../{log-context => }/LoadingIndicator.tsx | 9 +- .../logs/components/LogDetailsRow.tsx | 13 +- .../LogRowMessageDisplayedFields.tsx | 40 +- .../app/features/logs/components/LogRows.tsx | 18 +- .../logs/components/getLogRowStyles.ts | 3 - .../log-context/LogRowContextModal.test.tsx | 111 + .../log-context/LogRowContextModal.tsx | 13 +- .../logs/components/log-context/types.ts | 1 - .../app/features/logs/components/logParser.ts | 94 +- public/app/features/logs/logsModel.test.ts | 137 +- public/app/features/logs/logsModel.ts | 75 +- public/app/features/logs/utils.test.ts | 49 + public/app/features/logs/utils.ts | 12 + .../manage-dashboards/DashboardImportPage.tsx | 17 +- .../components/ImportDashboardForm.tsx | 25 +- .../components/ImportDashboardOverview.tsx | 7 +- .../DeletePublicDashboardButton.tsx | 43 +- .../DeletePublicDashboardModal.tsx | 36 +- .../PublicDashboardListTable.test.tsx | 21 +- .../PublicDashboardListTable.tsx | 24 +- .../manage-dashboards/state/actions.test.ts | 24 +- .../manage-dashboards/state/actions.ts | 30 +- public/app/features/org/OrgProfile.tsx | 3 +- public/app/features/org/UserInviteForm.tsx | 6 +- .../panel/components/PanelDataErrorView.tsx | 5 +- .../VisualizationSuggestions.tsx | 64 +- .../panel/panellinks/linkSuppliers.ts | 21 + .../panel/panellinks/specs/link_srv.test.ts | 2 +- public/app/features/panel/state/actions.ts | 5 +- .../features/panel/state/getAllSuggestions.ts | 2 + public/app/features/playlist/PlaylistForm.tsx | 127 +- .../app/features/playlist/PlaylistSrv.test.ts | 4 +- public/app/features/playlist/PlaylistSrv.ts | 25 +- .../admin/__mocks__/catalogPlugin.mock.ts | 1 + .../admin/__mocks__/remotePlugin.mock.ts | 1 + public/app/features/plugins/admin/api.ts | 18 +- .../GetStartedWithApp.tsx | 3 +- .../GetStartedWithDataSource.test.tsx | 80 + .../GetStartedWithDataSource.tsx | 5 +- .../InstallControlsButton.test.tsx | 153 + .../InstallControls/InstallControlsButton.tsx | 14 +- .../admin/components/PluginDashboards.tsx | 6 +- .../admin/components/PluginDetailsBody.tsx | 91 +- .../admin/components/PluginList.test.tsx | 1 + .../admin/components/PluginListItem.test.tsx | 1 + .../admin/components/PluginListItem.tsx | 12 +- .../components/PluginListItemBadges.test.tsx | 1 + .../plugins/admin/components/PluginUsage.tsx | 9 +- .../features/plugins/admin/helpers.test.ts | 50 + public/app/features/plugins/admin/helpers.ts | 71 +- .../plugins/admin/hooks/usePluginConfig.tsx | 9 +- .../admin/hooks/usePluginDetailsTabs.tsx | 5 +- .../plugins/admin/pages/Browse.test.tsx | 16 +- .../features/plugins/admin/pages/Browse.tsx | 1 + .../features/plugins/admin/state/actions.ts | 26 +- .../app/features/plugins/admin/state/hooks.ts | 5 +- .../features/plugins/admin/state/selectors.ts | 12 +- public/app/features/plugins/admin/types.ts | 10 + .../app/features/plugins/built_in_plugins.ts | 26 +- .../plugins/components/AppRootPage.test.tsx | 156 +- .../plugins/components/AppRootPage.tsx | 18 +- public/app/features/plugins/datasource_srv.ts | 6 +- .../plugins/extensions/utils.test.tsx | 20 +- public/app/features/plugins/loader/cache.ts | 2 +- .../app/features/plugins/loader/constants.ts | 2 - .../plugins/loader/pluginLoader.mock.ts | 123 +- .../plugins/loader/sharedDependencies.ts | 2 + .../plugins/loader/systemjsHooks.test.ts | 132 +- .../features/plugins/loader/systemjsHooks.ts | 14 +- .../app/features/plugins/loader/utils.test.ts | 31 + public/app/features/plugins/loader/utils.ts | 12 + public/app/features/plugins/plugin_loader.ts | 17 +- public/app/features/plugins/sandbox/README.md | 4 +- .../features/plugins/sandbox/code_loader.ts | 9 +- .../plugins/sandbox/distortion_map.ts | 29 +- .../plugins/sandbox/document_sandbox.ts | 30 +- public/app/features/plugins/sandbox/utils.ts | 30 +- public/app/features/plugins/sql/.eslintrc | 10 - public/app/features/plugins/sql/index.ts | 2 - .../plugins/tests/datasource_srv.test.ts | 5 +- public/app/features/plugins/utils.ts | 3 + .../features/profile/ChangePasswordForm.tsx | 174 +- .../features/profile/UserProfileEditForm.tsx | 3 +- .../features/profile/UserProfileEditPage.tsx | 5 +- ...torRow.test.ts => QueryEditorRow.test.tsx} | 74 +- .../query/components/QueryEditorRow.tsx | 8 +- .../components/QueryEditorRowHeader.test.tsx | 3 +- .../features/query/components/QueryGroup.tsx | 16 +- .../AlertStatesWorker.test.ts | 132 - .../DashboardQueryRunner/AlertStatesWorker.ts | 48 - .../AnnotationsQueryRunner.test.ts | 5 +- .../DashboardQueryRunner.test.ts | 65 +- .../DashboardQueryRunner.ts | 4 +- .../state/DashboardQueryRunner/testHelpers.ts | 1 + .../query/state/PanelQueryRunner.test.ts | 74 +- .../features/query/state/PanelQueryRunner.ts | 79 +- .../query/state/mergePanelAndDashData.test.ts | 14 +- .../query/state/mergePanelAndDashData.ts | 18 +- .../query/state/queryAnalytics.test.ts | 6 + .../features/query/state/queryAnalytics.ts | 6 +- public/app/features/query/state/runRequest.ts | 6 +- .../query/state/updateQueries.test.ts | 2 +- public/app/features/query/utils.ts | 3 + public/app/features/sandbox/TestStuffPage.tsx | 14 +- public/app/features/scenes/SceneListPage.tsx | 57 - public/app/features/scenes/ScenePage.tsx | 33 - .../scenes/apps/GrafanaMonitoringApp.tsx | 110 - .../features/scenes/apps/SceneRadioToggle.tsx | 24 - .../features/scenes/apps/SceneSearchBox.tsx | 20 - public/app/features/scenes/apps/scenes.tsx | 428 - public/app/features/scenes/apps/traffic.tsx | 127 - public/app/features/scenes/apps/transforms.ts | 42 - public/app/features/scenes/apps/utils.ts | 57 - .../scenes/scenes/gridMultiTimeRange.tsx | 73 - .../features/scenes/scenes/gridMultiple.tsx | 117 - .../scenes/scenes/gridWithMultipleData.tsx | 105 - public/app/features/scenes/scenes/index.tsx | 45 - public/app/features/scenes/scenes/queries.ts | 22 - .../scenes/scenes/queryVariableDemo.tsx | 68 - .../scenes/scenes/repeatingPanels.tsx | 189 - .../features/scenes/scenes/sceneWithRows.tsx | 58 - .../scenes/scenes/transformations.tsx | 79 - .../features/scenes/scenes/variablesDemo.tsx | 203 - .../search/page/components/columns.tsx | 1 + public/app/features/search/service/sql.ts | 9 +- public/app/features/search/service/utils.ts | 29 + public/app/features/search/types.ts | 1 + .../ServiceAccountCreatePage.test.tsx | 2 + .../ServiceAccountCreatePage.tsx | 3 +- .../ServiceAccountsListPage.tsx | 2 +- .../components/CreateTokenModal.tsx | 29 +- .../features/storage/CreateNewFolderModal.tsx | 5 +- public/app/features/storage/storage.ts | 4 +- .../support-bundles/SupportBundlesCreate.tsx | 9 +- public/app/features/teams/CreateTeam.tsx | 73 +- public/app/features/teams/TeamPages.test.tsx | 1 + public/app/features/teams/TeamSettings.tsx | 102 +- .../features/templating/macroRegistry.test.ts | 2 +- .../features/templating/template_srv.mock.ts | 42 +- .../app/features/templating/template_srv.ts | 6 +- .../AddToFiltersGraphAction.tsx | 12 +- .../{ => ActionTabs}/BreakdownScene.tsx | 159 +- .../{ => ActionTabs}/ByFrameRepeater.tsx | 0 .../{ => ActionTabs}/LayoutSwitcher.tsx | 0 .../features/trails/ActionTabs/LogsScene.tsx | 95 + .../trails/ActionTabs/MetricOverviewScene.tsx | 139 + .../trails/ActionTabs/RelatedMetricsScene.tsx | 5 + .../app/features/trails/ActionTabs/utils.ts | 24 + .../AutoQueryEngine.test.ts | 355 + .../AutomaticMetricQueries/AutoQueryEngine.ts | 178 +- .../AutomaticMetricQueries/AutoVizPanel.tsx | 74 +- .../graph-builders/heatmap.ts | 18 + .../graph-builders/percentiles.ts | 10 + .../graph-builders/simple.ts | 11 + .../graph-builders/types.ts | 4 + .../previewPanel.test.ts | 31 + .../AutomaticMetricQueries/previewPanel.ts | 48 + .../query-generators/common/generator.ts | 58 + .../query-generators/common/index.ts | 9 + .../query-generators/common/queries.test.ts | 80 + .../query-generators/common/queries.ts | 39 + .../query-generators/common/rules.ts | 39 + .../query-generators/common/types.ts | 5 + .../query-generators/histogram.ts | 85 + .../query-generators/index.ts | 13 + .../query-generators/summary.ts | 39 + .../query-generators/types.ts | 3 + .../trails/AutomaticMetricQueries/types.ts | 19 + .../trails/AutomaticMetricQueries/units.ts | 21 + .../trails/BreakdownLabelSelector.tsx | 60 + public/app/features/trails/DataTrail.tsx | 38 +- public/app/features/trails/DataTrailCard.tsx | 135 +- .../app/features/trails/DataTrailDrawer.tsx | 67 - .../app/features/trails/DataTrailSettings.tsx | 29 +- public/app/features/trails/DataTrailsApp.tsx | 14 +- .../app/features/trails/DataTrailsHistory.tsx | 29 +- public/app/features/trails/DataTrailsHome.tsx | 35 +- .../trails/Integrations/DataTrailEmbedded.tsx | 37 + .../trails/Integrations/SceneDrawer.tsx | 46 + .../Integrations/dashboardIntegration.ts | 115 + .../trails/Integrations/getQueryMetrics.ts | 31 + .../app/features/trails/Integrations/utils.ts | 45 + .../MetricCategory/MetricCategoryCascader.tsx | 62 + .../MetricCategory/useMetricCategories.ts | 43 + .../app/features/trails/MetricGraphScene.tsx | 92 + public/app/features/trails/MetricScene.tsx | 240 +- .../app/features/trails/MetricSelectScene.tsx | 308 +- public/app/features/trails/MetricsHeader.tsx | 10 + .../features/trails/SelectMetricAction.tsx | 2 +- .../features/trails/SelectMetricTrailView.tsx | 83 - .../app/features/trails/ShareTrailButton.tsx | 28 + public/app/features/trails/StatusWrapper.tsx | 39 + .../trails/TrailStore/TrailStore.test.ts | 185 +- .../features/trails/TrailStore/TrailStore.ts | 53 +- .../trails/TrailStore/useBookmarkState.ts | 39 + .../features/trails/dashboardIntegration.ts | 38 - .../app/features/trails/hideEmptyPreviews.ts | 2 +- public/app/features/trails/relatedMetrics.ts | 42 + public/app/features/trails/shared.ts | 31 +- public/app/features/trails/utils.ts | 73 +- .../ValueMatchers/BasicMatcherEditor.tsx | 14 + .../transformers/calculateHeatmap/heatmap.ts | 7 +- .../app/features/transformers/docs/content.ts | 1801 +- .../WindowOptionsEditor.tsx | 11 +- .../ConvertFieldTypeTransformerEditor.tsx | 52 +- .../editors/GroupByTransformerEditor.tsx | 26 +- .../GroupToNestedTableTransformerEditor.tsx | 185 + .../editors/HistogramTransformerEditor.tsx | 53 + .../extractFields/extractFields.test.ts | 3 - .../PartitionByValuesEditor.tsx | 14 + .../partitionByValues/partitionByValues.ts | 4 +- .../prepareTimeSeries.test.ts | 20 +- .../regression/regression.test.ts | 28 + .../transformers/regression/regression.ts | 7 +- .../transformers/standardTransformers.ts | 2 + .../suggestionsInput/SuggestionsInput.tsx | 103 +- .../adhoc/AdHocVariableEditor.test.tsx | 89 - .../variables/adhoc/AdHocVariableEditor.tsx | 24 +- .../app/features/variables/adhoc/actions.ts | 11 +- .../constant/ConstantVariableEditor.tsx | 26 +- .../variables/custom/CustomVariableEditor.tsx | 50 +- .../DataSourceVariableEditor.test.tsx | 22 +- .../datasource/DataSourceVariableEditor.tsx | 76 +- .../editor/LegacyVariableQueryEditor.tsx | 5 +- .../editor/SelectionOptionsEditor.tsx | 36 +- .../editor/VariableEditorContainer.tsx | 15 +- .../variables/editor/VariableEditorEditor.tsx | 22 +- .../variables/editor/VariableTypeSelect.tsx | 2 +- public/app/features/variables/guard.test.ts | 2 + .../interval/IntervalVariableEditor.tsx | 86 +- .../OptionsPicker/OptionPicker.test.tsx | 2 +- .../pickers/OptionsPicker/reducer.test.ts | 40 +- .../variables/pickers/shared/VariableLink.tsx | 27 +- .../query/QueryVariableEditor.test.tsx | 90 +- .../variables/query/QueryVariableEditor.tsx | 163 +- .../query/QueryVariableRefreshSelect.tsx | 5 +- .../query/QueryVariableSortSelect.tsx | 8 +- .../features/variables/query/actions.test.tsx | 12 +- .../variables/query/queryRunners.test.ts | 102 +- .../variables/state/__tests__/fixtures.ts | 16 + .../features/variables/state/reducers.test.ts | 7 +- .../state/transactionReducer.test.ts | 9 +- .../textbox/TextBoxVariableEditor.tsx | 4 +- .../data-hover/DataHoverView.tsx | 100 +- .../data-hover/ExemplarHoverView.tsx | 122 + .../datasource/alertmanager/plugin.json | 3 +- .../plugins/datasource/alertmanager/types.ts | 5 +- .../datasource/azuremonitor/CHANGELOG.md | 0 .../azuremonitor/__mocks__/variables.ts | 13 +- .../ArgQueryEditor/ArgQueryEditor.test.tsx | 51 + .../ArgQueryEditor/ArgQueryEditor.tsx | 5 +- .../ArgQueryEditor/SubscriptionField.tsx | 7 + .../components/AzureCheatSheet.tsx | 4 +- .../components/AzureCredentialsForm.tsx | 4 +- .../components/LogsQueryEditor/QueryField.tsx | 16 +- .../LogsQueryEditor/TimeManagement.tsx | 8 +- .../DimensionFields.test.tsx | 2 +- .../MetricsQueryEditor.test.tsx | 2 +- .../QueryEditor/QueryEditor.test.tsx | 2 +- .../components/QueryEditor/QueryEditor.tsx | 3 +- .../ResourcePicker/AdvancedMulti.tsx | 3 +- .../components/ResourcePicker/NestedEntry.tsx | 4 +- .../ResourcePicker/ResourcePicker.tsx | 3 +- .../azuremonitor/components/Space.tsx | 38 - .../components/TracesQueryEditor/Filter.tsx | 1 - .../TracesQueryEditor/Filters.test.tsx | 19 +- .../VariableEditor/VariableEditor.tsx | 3 +- .../datasource/azuremonitor/dataquery.gen.ts | 4 +- .../datasource/azuremonitor/package.json | 50 + .../datasource/azuremonitor/plugin.json | 6 +- .../datasource/azuremonitor/tsconfig.json | 4 + .../datasource/azuremonitor/types/query.ts | 4 +- .../azuremonitor/utils/common.test.ts | 2 +- .../azuremonitor/utils/testUtils.ts | 8 + .../datasource/azuremonitor/webpack.config.ts | 3 + .../datasource/cloud-monitoring/.eslintignore | 2 + .../datasource/cloud-monitoring/CHANGELOG.md | 0 .../components/Aggregation.test.tsx | 4 +- .../components/AnnotationsHelp.tsx | 2 +- .../VariableQueryEditor.test.tsx.snap | 2 + .../cloud-monitoring/dataquery.gen.ts | 4 +- .../datasource/cloud-monitoring/package.json | 55 + .../datasource/cloud-monitoring/plugin.json | 7 +- .../cloud-monitoring/specs/datasource.test.ts | 3 +- .../datasource/cloud-monitoring/tsconfig.json | 4 + .../cloud-monitoring/types/query.ts | 12 +- .../cloud-monitoring/types/types.ts | 6 +- .../cloud-monitoring/webpack.config.ts | 3 + .../__mocks__/AnnotationQueryRunner.ts | 6 +- .../__mocks__/CloudWatchDataSource.ts | 72 +- .../{variables.ts => CloudWatchVariables.ts} | 0 .../cloudwatch/__mocks__/LogsQueryRunner.ts | 9 +- .../__mocks__/MetricsQueryRunner.ts | 28 +- .../cloudwatch/__mocks__/ResourcesAPI.ts | 3 +- .../AnnotationQueryEditor.tsx | 4 +- .../ConfigEditor/ConfigEditor.test.tsx | 83 +- .../components/ConfigEditor/ConfigEditor.tsx | 71 +- .../ConfigEditor/XrayLinkConfig.tsx | 5 +- .../Errors/ThrottlingErrorMessage.tsx | 2 +- .../LogsQueryEditor/LogsQueryEditor.tsx | 33 +- .../LogsQueryEditor/LogsQueryField.tsx | 4 +- .../LogsQueryEditor/LogsQueryFieldOld.tsx | 119 - .../MathExpressionQueryField.tsx | 5 +- .../MetricsQueryEditor.test.tsx | 7 +- .../MetricsQueryEditor/MetricsQueryEditor.tsx | 4 +- .../SQLBuilderEditor.test.tsx | 15 +- .../SQLBuilderEditor/SQLFilter.tsx | 88 +- .../VariableQueryEditor/MultiFilter.test.tsx | 16 +- .../VariableQueryEditor/MultiFilter.tsx | 11 +- .../VariableQueryEditor/MultiFilterItem.tsx | 10 +- .../VariableQueryEditor.test.tsx | 15 +- .../VariableQueryEditor.tsx | 23 +- .../VariableQueryField.tsx | 65 +- .../shared/Dimensions/FilterItem.test.tsx | 17 +- .../shared/Dimensions/FilterItem.tsx | 10 +- .../LogGroups/LegacyLogGroupSelector.tsx | 16 +- .../shared/LogGroups/LogGroupsField.test.tsx | 14 +- .../shared/LogGroups/LogGroupsSelector.tsx | 4 +- .../LogGroups/SelectedLogGroups.test.tsx | 4 +- .../datasource/cloudwatch/dataquery.gen.ts | 4 +- .../datasource/cloudwatch/datasource.test.ts | 10 +- .../datasource/cloudwatch/datasource.ts | 3 +- .../datasource/cloudwatch/hooks.test.ts | 59 +- .../plugins/datasource/cloudwatch/hooks.ts | 26 +- .../CloudWatchLogsLanguageProvider.test.ts | 3 +- .../CloudWatchLogsLanguageProvider.ts | 9 +- .../cloudwatch-sql/SQLGenerator.test.ts | 48 +- .../language/cloudwatch-sql/SQLGenerator.ts | 2 +- .../completion/CompletionItemProvider.ts | 15 +- .../migrations/dashboardMigrations.ts | 6 +- .../CloudWatchAnnotationQueryRunner.ts | 2 +- .../CloudWatchLogsQueryRunner.test.ts | 12 +- .../query-runner/CloudWatchLogsQueryRunner.ts | 3 +- .../CloudWatchMetricsQueryRunner.test.ts | 449 +- .../CloudWatchMetricsQueryRunner.ts | 128 +- .../query-runner/CloudWatchRequest.ts | 69 +- .../cloudwatch/resources/ResourceAPI.test.ts | 59 +- .../cloudwatch/resources/ResourcesAPI.ts | 26 +- .../cloudwatch/utils/templateVariableUtils.ts | 30 +- .../dashboard/DashboardQueryEditor.test.tsx | 15 +- .../dashboard/DashboardQueryEditor.tsx | 136 +- .../datasource/dashboard/datasource.test.ts | 93 + .../datasource/dashboard/datasource.ts | 63 +- .../plugins/datasource/dashboard/module.ts | 3 +- .../elasticsearch/ElasticResponse.test.ts | 4 +- .../elasticsearch/ElasticResponse.ts | 5 +- .../elasticsearch/IndexPattern.test.ts | 2 - .../elasticsearch/LegacyQueryRunner.ts | 6 +- .../elasticsearch/QueryBuilder.test.ts | 4 +- .../DateHistogramSettingsEditor.test.tsx | 9 +- .../DateHistogramSettingsEditor.tsx | 2 +- .../TermsSettingsEditor.test.tsx | 3 +- .../SettingsEditor/useDescription.ts | 3 +- .../state/reducer.test.ts | 9 +- .../MetricEditor.test.tsx | 4 +- .../state/reducer.test.ts | 3 +- .../SettingsEditor/SettingField.tsx | 2 +- .../state/reducer.test.ts | 7 +- .../components/QueryEditor/state.test.ts | 3 +- .../elasticsearch/components/reducerTester.ts | 109 + .../configuration/ConfigEditor.test.tsx | 2 +- .../configuration/ConfigEditor.tsx | 68 +- .../elasticsearch/configuration/DataLink.tsx | 2 +- .../elasticsearch/configuration/DataLinks.tsx | 3 +- .../configuration/ElasticDetails.test.tsx | 2 +- .../configuration/ElasticDetails.tsx | 3 +- .../configuration/LogsConfig.test.tsx | 2 +- .../configuration/LogsConfig.tsx | 3 +- .../configuration/__mocks__/configOptions.ts | 17 + .../elasticsearch/configuration/mocks.ts | 20 - .../datasource/elasticsearch/dataquery.gen.ts | 6 +- .../elasticsearch/datasource.test.ts | 145 +- .../datasource/elasticsearch/datasource.ts | 86 +- .../plugins/datasource/elasticsearch/mocks.ts | 2 +- .../datasource/elasticsearch/tracking.ts | 2 +- .../plugins/datasource/elasticsearch/types.ts | 8 +- .../datasource/elasticsearch/utils.test.ts | 38 +- .../plugins/datasource/elasticsearch/utils.ts | 50 + .../PostgresQueryEditor.tsx | 5 +- .../PostgresQueryModel.ts | 3 +- .../configuration/ConfigurationEditor.tsx | 9 +- .../datasource.test.ts | 3 +- .../datasource.ts | 11 +- .../grafana-postgresql-datasource/module.ts | 2 +- .../postgresMetaQuery.test.ts | 14 + .../postgresMetaQuery.ts | 9 +- .../sqlCompletionProvider.ts | 2 +- .../grafana-postgresql-datasource/sqlUtil.ts | 3 +- .../grafana-postgresql-datasource/types.ts | 2 +- .../.eslintignore | 2 + .../grafana-pyroscope-datasource/CHANGELOG.md | 1 + .../QueryEditor/ProfileTypesCascader.tsx | 13 +- .../QueryEditor/QueryEditor.tsx | 31 +- .../QueryEditor/QueryOptionGroup.tsx | 82 + .../QueryEditor/QueryOptions.tsx | 34 +- .../VariableQueryEditor.tsx | 37 +- .../VariableSupport.test.ts | 13 +- .../VariableSupport.ts | 25 +- .../dataquery.gen.ts | 6 +- .../datasource.ts | 16 +- .../grafana-pyroscope-datasource/package.json | 49 + .../grafana-pyroscope-datasource/plugin.json | 7 +- .../tsconfig.json | 4 + .../grafana-pyroscope-datasource/types.ts | 4 +- .../grafana-pyroscope-datasource/utils.ts | 84 + .../webpack.config.ts | 15 + .../MetaDataInspector.tsx | 4 +- .../QueryEditor.tsx | 10 +- .../components/NodeGraphEditor.tsx | 49 +- .../components/RandomWalkEditor.tsx | 4 +- .../components/SimulationQueryEditor.tsx | 2 +- .../components/StreamingClientEditor.tsx | 52 +- .../grafana-testdata-datasource/constants.ts | 4 +- .../grafana-testdata-datasource/dataquery.cue | 5 +- .../dataquery.gen.ts | 11 +- .../grafana-testdata-datasource/datasource.ts | 70 +- .../nodeGraphUtils.ts | 242 +- .../grafana-testdata-datasource/package.json | 45 +- .../grafana-testdata-datasource/runStreams.ts | 122 +- .../testData/serviceMapResponse.ts | 27 +- .../testData/serviceMapResponseMedium.ts | 1004 + .../grafana-testdata-datasource/variables.ts | 4 +- .../grafana/components/QueryEditor.tsx | 36 +- .../datasource/grafana/datasource.test.ts | 10 +- .../plugins/datasource/grafana/datasource.ts | 2 +- .../datasource/graphite/datasource.test.ts | 17 +- .../plugins/datasource/graphite/datasource.ts | 8 +- .../datasource/graphite/state/context.tsx | 6 +- .../app/plugins/datasource/graphite/types.ts | 1 + .../editor/config/InfluxSQLConfig.tsx | 21 +- .../editor/query/fsql/FSQLEditor.tsx | 4 +- .../influxql/visual/VisualInfluxQLEditor.tsx | 2 +- .../datasource/influxdb/datasource.test.ts | 118 +- .../plugins/datasource/influxdb/datasource.ts | 132 +- .../influxdb/datasource_backend_mode.test.ts | 38 +- .../influxdb/datasource_sql.test.ts | 3 +- .../influxdb/fsql/datasource.flightsql.ts | 6 +- .../datasource/influxdb/fsql/fields.ts | 2 +- .../datasource/influxdb/fsql/sqlUtil.test.ts | 57 +- .../datasource/influxdb/fsql/sqlUtil.ts | 27 +- .../plugins/datasource/influxdb/fsql/types.ts | 2 +- .../app/plugins/datasource/influxdb/mocks.ts | 6 +- .../app/plugins/datasource/influxdb/types.ts | 1 + .../jaeger/components/QueryEditor.tsx | 9 +- .../jaeger/configuration/ConfigEditor.tsx | 31 +- .../datasource/jaeger/datasource.test.ts | 39 + .../plugins/datasource/jaeger/datasource.ts | 12 +- .../jaeger/dependencyGraphTransform.test.ts | 106 + .../jaeger/dependencyGraphTransform.ts | 125 + .../datasource/jaeger/graphTransform.ts | 3 +- public/app/plugins/datasource/jaeger/types.ts | 11 +- .../datasource/loki/LanguageProvider.test.ts | 101 +- .../datasource/loki/LanguageProvider.ts | 37 +- .../loki/LogContextProvider.test.ts | 161 +- .../datasource/loki/LogContextProvider.ts | 94 +- .../loki/LokiVariableSupport.test.ts | 3 +- .../datasource/loki/__mocks__/datasource.ts | 59 + .../loki/{mocks.ts => __mocks__/frames.ts} | 111 +- .../loki/__mocks__/metadataRequest.ts | 26 + .../components/AnnotationsQueryEditor.tsx | 11 +- .../loki/components/LokiContextUi.test.tsx | 70 +- .../loki/components/LokiContextUi.tsx | 79 +- .../loki/components/LokiOptionFields.tsx | 33 +- .../loki/components/LokiQueryEditor.test.tsx | 20 +- .../loki/components/LokiQueryEditor.tsx | 20 +- .../components/LokiQueryEditorByApp.test.tsx | 2 +- .../loki/components/LokiQueryField.test.tsx | 12 +- .../components/VariableQueryEditor.test.tsx | 151 +- .../loki/components/VariableQueryEditor.tsx | 11 +- .../MonacoFieldWrapper.test.tsx | 8 +- .../MonacoQueryField.test.tsx | 2 +- .../CompletionDataProvider.test.ts | 9 +- .../CompletionDataProvider.ts | 2 +- .../completions.test.ts | 4 +- .../configuration/AlertingSettings.test.tsx | 2 +- .../loki/configuration/AlertingSettings.tsx | 3 +- .../loki/configuration/ConfigEditor.test.tsx | 2 +- .../loki/configuration/ConfigEditor.tsx | 44 +- .../loki/configuration/DebugSection.test.tsx | 45 +- .../loki/configuration/DebugSection.tsx | 35 +- .../loki/configuration/DerivedField.tsx | 2 +- .../loki/configuration/DerivedFields.test.tsx | 24 +- .../loki/configuration/DerivedFields.tsx | 3 +- .../loki/configuration/QuerySettings.tsx | 3 +- .../app/plugins/datasource/loki/dataquery.cue | 2 +- .../plugins/datasource/loki/dataquery.gen.ts | 5 +- .../datasource/loki/datasource.test.ts | 120 +- .../app/plugins/datasource/loki/datasource.ts | 95 +- .../datasource/loki/getDerivedFields.test.ts | 1 - .../datasource/loki/languageUtils.test.ts | 39 +- .../plugins/datasource/loki/languageUtils.ts | 57 +- .../datasource/loki/makeTableFrames.ts | 4 +- .../loki/metricTimeSplitting.test.ts | 56 +- .../datasource/loki/metricTimeSplitting.ts | 31 +- .../datasource/loki/modifyQuery.test.ts | 12 + .../plugins/datasource/loki/modifyQuery.ts | 42 +- .../datasource/loki/querySplitting.test.ts | 247 +- .../plugins/datasource/loki/querySplitting.ts | 2 +- .../datasource/loki/queryUtils.test.ts | 2 +- .../querybuilder/LokiQueryModeller.test.ts | 14 +- .../loki/querybuilder/LokiQueryModeller.ts | 72 +- .../querybuilder/binaryScalarOperations.ts | 12 +- .../components/LabelBrowserModal.test.tsx | 2 +- .../components/LabelBrowserModal.tsx | 2 +- .../components/LabelParamEditor.tsx | 68 + .../components/LokiQueryBuilder.test.tsx | 214 +- .../components/LokiQueryBuilder.tsx | 61 +- .../LokiQueryBuilderContainer.test.tsx | 97 +- .../components/LokiQueryBuilderExplained.tsx | 8 +- .../components/LokiQueryBuilderOptions.tsx | 3 +- .../components/LokiQueryCodeEditor.test.tsx | 2 +- .../components/LokiQueryCodeEditor.tsx | 3 +- .../components/NestedQuery.test.tsx | 10 +- .../components/NestedQueryList.test.tsx | 2 +- .../querybuilder/components/QueryPattern.tsx | 4 +- .../components/QueryPatternsModal.tsx | 5 +- .../querybuilder/components/QueryPreview.tsx | 5 +- .../components/UnwrapParamEditor.test.tsx | 11 +- .../components/UnwrapParamEditor.tsx | 9 +- .../loki/querybuilder/operationUtils.test.ts | 37 +- .../loki/querybuilder/operationUtils.ts | 101 +- .../loki/querybuilder/operations.test.ts | 7 +- .../loki/querybuilder/operations.ts | 19 +- .../loki/querybuilder/parsing.test.ts | 15 + .../datasource/loki/querybuilder/parsing.ts | 12 +- .../loki/querybuilder/parsingUtils.ts | 4 +- .../loki/querybuilder/state.test.ts | 2 +- .../datasource/loki/querybuilder/state.ts | 7 +- .../datasource/loki/querybuilder/types.ts | 10 +- .../datasource/loki/responseUtils.test.ts | 445 +- .../plugins/datasource/loki/responseUtils.ts | 172 +- .../plugins/datasource/loki/sortDataFrame.ts | 10 +- .../plugins/datasource/loki/tracking.test.ts | 35 +- .../app/plugins/datasource/loki/tracking.ts | 8 +- public/app/plugins/datasource/loki/types.ts | 10 +- .../datasource/mixed/MixedDataSource.ts | 8 +- .../datasource/mssql/MSSqlQueryModel.ts | 3 +- .../configuration/ConfigurationEditor.tsx | 3 +- .../datasource/mssql/datasource.test.ts | 2 +- .../plugins/datasource/mssql/datasource.ts | 4 +- public/app/plugins/datasource/mssql/module.ts | 3 +- .../datasource/mssql/sqlCompletionProvider.ts | 2 +- .../app/plugins/datasource/mssql/sqlUtil.ts | 3 +- public/app/plugins/datasource/mssql/types.ts | 2 +- .../datasource/mysql/MySqlDatasource.ts | 4 +- .../configuration/ConfigurationEditor.tsx | 16 +- public/app/plugins/datasource/mysql/fields.ts | 2 +- public/app/plugins/datasource/mysql/module.ts | 3 +- .../datasource/mysql/specs/datasource.test.ts | 3 +- .../app/plugins/datasource/mysql/sqlUtil.ts | 3 +- public/app/plugins/datasource/mysql/types.ts | 2 +- .../parca/QueryEditor/QueryEditor.test.tsx | 17 +- .../parca/QueryEditor/QueryEditor.tsx | 4 +- .../plugins/datasource/parca/dataquery.gen.ts | 6 +- .../datasource/parca/datasource.test.ts | 71 + .../plugins/datasource/parca/datasource.ts | 16 +- .../app/plugins/datasource/parca/package.json | 30 +- public/app/plugins/datasource/parca/types.ts | 2 +- .../components/AnnotationQueryEditor.tsx | 10 +- .../prometheus/components/PromQueryField.tsx | 2 + .../PrometheusMetricsBrowser.test.tsx | 6 + .../components/PrometheusMetricsBrowser.tsx | 30 +- .../components/VariableQueryEditor.test.tsx | 11 +- .../components/VariableQueryEditor.tsx | 26 +- .../AlertingSettingsOverhaul.tsx | 2 + .../configuration/AzureCredentialsForm.tsx | 4 +- .../prometheus/configuration/PromSettings.tsx | 13 + .../prometheus/dashboards/grafana_stats.json | 8 +- .../datasource/prometheus/dataquery.cue | 54 - .../datasource/prometheus/dataquery.ts | 47 + .../datasource/prometheus/datasource.test.ts | 17 +- .../datasource/prometheus/datasource.ts | 24 +- .../prometheus/language_provider.mock.ts | 1 + .../prometheus/language_provider.ts | 23 +- .../prometheus/language_utils.test.ts | 30 +- .../datasource/prometheus/language_utils.ts | 124 +- .../prometheus/metric_find_query.test.ts | 10 +- .../components/LabelFilterItem.tsx | 2 - .../components/LabelParamEditor.tsx | 2 +- .../querybuilder/components/MetricSelect.tsx | 3 + .../components/MetricsLabelsSection.tsx | 7 +- .../components/PromQueryBuilder.test.tsx | 38 +- .../components/PromQueryBuilder.tsx | 31 +- .../components/PromQueryBuilderOptions.tsx | 104 +- .../components/PromQueryCodeEditor.tsx | 6 +- .../components/PromQueryEditorSelector.tsx | 16 +- .../components/PromQueryLegendEditor.tsx | 2 + .../components/metrics-modal/MetricsModal.tsx | 6 +- .../components/promQail/PromQail.test.tsx | 14 +- .../components/promQail/PromQail.tsx | 7 +- .../promQail/QueryAssistantButton.test.tsx | 4 +- .../promQail/QueryAssistantButton.tsx | 2 + .../components/promQail/state/helpers.test.ts | 16 +- .../components/promQail/state/helpers.ts | 6 +- .../querybuilder/hooks/useFlag.test.ts | 23 + .../prometheus/querybuilder/hooks/useFlag.ts | 36 + .../shared/OperationInfoButton.tsx | 55 +- .../prometheus/result_transformer.test.ts | 93 +- .../prometheus/result_transformer.ts | 6 +- .../plugins/datasource/prometheus/types.ts | 2 +- .../plugins/datasource/tempo/.eslintignore | 2 + .../app/plugins/datasource/tempo/CHANGELOG.md | 1 + .../plugins/datasource/tempo/LokiSearch.tsx | 57 - .../tempo/NativeSearch/NativeSearch.test.tsx | 172 - .../tempo/NativeSearch/NativeSearch.tsx | 272 - .../NativeSearch/TagsField/TagsField.tsx | 182 - .../NativeSearch/TagsField/autocomplete.ts | 245 - .../tempo/NativeSearch/TagsField/syntax.ts | 124 - .../plugins/datasource/tempo/QueryField.tsx | 55 +- .../SearchTraceQLEditor/DurationInput.tsx | 12 +- .../SearchTraceQLEditor/GroupByField.test.tsx | 48 +- .../SearchTraceQLEditor/GroupByField.tsx | 28 +- .../SearchTraceQLEditor/SearchField.test.tsx | 23 +- .../tempo/SearchTraceQLEditor/SearchField.tsx | 174 +- .../SearchTraceQLEditor/TagsInput.test.tsx | 18 +- .../tempo/SearchTraceQLEditor/TagsInput.tsx | 61 +- .../TraceQLSearch.test.tsx | 92 +- .../SearchTraceQLEditor/TraceQLSearch.tsx | 107 +- .../tempo/SearchTraceQLEditor/utils.test.ts | 26 + .../tempo/SearchTraceQLEditor/utils.ts | 12 +- .../datasource/tempo/ServiceGraphSection.tsx | 27 +- .../tempo/VariableQueryEditor.test.tsx | 2 +- .../tempo/_importedDependencies/README.md | 3 + .../components/AdHocFilter/AdHocFilter.tsx | 104 + .../AdHocFilter/AdHocFilterBuilder.tsx | 75 + .../components/AdHocFilter/AdHocFilterKey.tsx | 82 + .../AdHocFilter/AdHocFilterRenderer.tsx | 54 + .../AdHocFilter/AdHocFilterValue.tsx | 68 + .../AdHocFilter/ConditionSegment.tsx | 13 + .../AdHocFilter/OperatorSegment.tsx | 27 + .../components/AdHocFilter/types.ts | 8 + .../datasources/loki/types.ts | 8 + .../prometheus/QueryOptionGroup.tsx | 115 + .../datasources/prometheus/RawQuery.tsx | 37 + .../datasources}/prometheus/dataquery.gen.ts | 0 .../datasources/prometheus/language_utils.ts | 122 + .../datasources/prometheus/types.ts | 165 + .../test/helpers/createFetchResponse.ts | 15 + .../test/helpers/selectOptionInTest.ts | 8 + .../tempo/configuration/ConfigEditor.tsx | 178 +- .../configuration/LokiSearchSettings.tsx | 65 - .../tempo/configuration/QuerySettings.tsx | 23 +- .../configuration/ServiceGraphSettings.tsx | 2 +- .../plugins/datasource/tempo/dataquery.cue | 7 +- .../plugins/datasource/tempo/dataquery.gen.ts | 13 +- .../datasource/tempo/datasource.test.ts | 573 +- .../plugins/datasource/tempo/datasource.ts | 465 +- .../datasource/tempo/graphTransform.ts | 11 +- .../datasource/tempo/mockServiceGraph.json | 2 +- .../app/plugins/datasource/tempo/package.json | 68 + .../app/plugins/datasource/tempo/plugin.json | 8 +- .../tempo/resultTransformer.test.ts | 285 +- .../datasource/tempo/resultTransformer.ts | 363 +- .../plugins/datasource/tempo/testResponse.ts | 22 - .../plugins/datasource/tempo/test_utils.ts | 31 + .../datasource/tempo/traceql/QueryEditor.tsx | 71 +- .../traceql/TempoQueryBuilderOptions.tsx | 19 +- .../tempo/traceql/TraceQLEditor.test.tsx | 90 - .../tempo/traceql/TraceQLEditor.tsx | 195 +- .../tempo/traceql/autocomplete.test.ts | 14 +- .../datasource/tempo/traceql/autocomplete.ts | 19 +- .../tempo/traceql/highlighting.test.ts | 195 + .../{errorHighlighting.ts => highlighting.ts} | 109 +- .../datasource/tempo/traceql/situation.ts | 11 +- .../datasource/tempo/traceql/traceql.ts | 8 + .../plugins/datasource/tempo/tracking.test.ts | 37 - .../app/plugins/datasource/tempo/tracking.ts | 30 +- .../plugins/datasource/tempo/tsconfig.json | 7 + public/app/plugins/datasource/tempo/types.ts | 51 +- .../plugins/datasource/tempo/utils.test.ts | 51 + public/app/plugins/datasource/tempo/utils.ts | 79 + .../datasource/tempo/webpack.config.ts | 3 + .../plugins/datasource/zipkin/CHANGELOG.md | 1 + .../datasource/zipkin/ConfigEditor.tsx | 28 +- .../datasource/zipkin/QueryField.test.tsx | 4 +- .../plugins/datasource/zipkin/QueryField.tsx | 28 +- .../app/plugins/datasource/zipkin/README.md | 8 +- .../datasource/zipkin/datasource.test.ts | 5 +- .../plugins/datasource/zipkin/datasource.ts | 7 +- .../plugins/datasource/zipkin/package.json | 38 + .../app/plugins/datasource/zipkin/plugin.json | 6 +- .../plugins/datasource/zipkin/tsconfig.json | 7 + .../datasource/zipkin/utils/graphTransform.ts | 2 +- .../datasource/zipkin/webpack.config.ts | 3 + public/app/plugins/gen.go | 64 +- .../plugins/panel/alertGroups/AlertGroup.tsx | 125 - .../alertGroups/AlertGroupsPanel.test.tsx | 145 - .../panel/alertGroups/AlertGroupsPanel.tsx | 66 - .../panel/alertGroups/AlertmanagerPicker.tsx | 40 - .../alertGroups/img/icn-alertgroups-panel.svg | 1 - .../app/plugins/panel/alertGroups/module.tsx | 47 - .../plugins/panel/alertGroups/panelcfg.cue | 32 - .../plugins/panel/alertGroups/panelcfg.gen.ts | 24 - .../app/plugins/panel/alertGroups/plugin.json | 19 - .../panel/alertGroups/useFilteredGroups.ts | 15 - .../app/plugins/panel/alertlist/AlertList.tsx | 273 - .../alertlist/AlertListMigration.test.ts | 137 - .../alertlist/AlertListMigrationHandler.ts | 40 - .../panel/alertlist/UnifiedAlertList.tsx | 31 +- .../panel/alertlist/UnifiedalertList.test.tsx | 36 +- public/app/plugins/panel/alertlist/module.tsx | 154 +- .../plugins/panel/alertlist/suggestions.ts | 21 - .../panel/annolist/AnnoListPanel.test.tsx | 17 +- .../plugins/panel/annolist/AnnoListPanel.tsx | 17 +- public/app/plugins/panel/annolist/module.tsx | 4 +- .../plugins/panel/annolist/panelcfg.gen.ts | 2 +- .../plugins/panel/barchart/BarChartPanel.tsx | 46 +- .../barchart/__snapshots__/utils.test.ts.snap | 24 +- public/app/plugins/panel/barchart/bars.ts | 88 +- .../plugins/panel/barchart/migrations.test.ts | 32 + .../app/plugins/panel/barchart/migrations.ts | 36 + public/app/plugins/panel/barchart/module.tsx | 12 +- .../plugins/panel/barchart/panelcfg.gen.ts | 2 +- public/app/plugins/panel/barchart/quadtree.ts | 18 +- public/app/plugins/panel/barchart/types.ts | 6 + .../app/plugins/panel/barchart/utils.test.ts | 21 +- public/app/plugins/panel/barchart/utils.ts | 40 +- .../plugins/panel/bargauge/panelcfg.gen.ts | 2 +- .../panel/candlestick/CandlestickPanel.tsx | 121 +- .../app/plugins/panel/candlestick/module.tsx | 5 +- .../plugins/panel/candlestick/panelcfg.cue | 1 + .../plugins/panel/candlestick/panelcfg.gen.ts | 4 +- .../app/plugins/panel/candlestick/plugin.json | 2 + public/app/plugins/panel/candlestick/types.ts | 6 +- .../canvas/components/CanvasContextMenu.tsx | 5 +- .../connections/ConnectionAnchors.tsx | 2 + .../components/connections/ConnectionSVG.tsx | 194 +- .../components/connections/Connections.tsx | 241 +- .../canvas/editor/inline/InlineEditBody.tsx | 17 +- public/app/plugins/panel/canvas/panelcfg.cue | 1 + .../app/plugins/panel/canvas/panelcfg.gen.ts | 7 +- public/app/plugins/panel/canvas/types.ts | 3 + public/app/plugins/panel/canvas/utils.ts | 172 +- .../plugins/panel/dashlist/migrations.test.ts | 2 +- public/app/plugins/panel/dashlist/module.tsx | 2 +- .../plugins/panel/dashlist/panelcfg.gen.ts | 2 +- .../plugins/panel/datagrid/panelcfg.gen.ts | 2 +- .../app/plugins/panel/datagrid/utils.test.ts | 14 +- .../plugins/panel/debug/EventBusLogger.tsx | 2 +- .../app/plugins/panel/debug/panelcfg.gen.ts | 2 +- .../app/plugins/panel/gauge/panelcfg.gen.ts | 2 +- .../panel/geomap/editor/FitMapViewEditor.tsx | 4 +- .../panel/geomap/layers/data/geojsonLayer.ts | 50 +- .../app/plugins/panel/geomap/panelcfg.gen.ts | 2 +- .../app/plugins/panel/geomap/style/types.ts | 16 + .../app/plugins/panel/geomap/utils/layers.ts | 4 +- .../app/plugins/panel/geomap/utils/tooltip.ts | 2 +- .../panel/gettingstarted/GettingStarted.tsx | 169 +- .../gettingstarted/components/DocsCard.tsx | 14 +- .../panel/gettingstarted/components/Step.tsx | 52 +- .../components/TutorialCard.tsx | 8 +- .../gettingstarted/components/sharedStyles.ts | 8 - public/app/plugins/panel/graph/module.ts | 4 +- .../panel/heatmap/ExemplarModalHeader.tsx | 23 +- .../panel/heatmap/HeatmapHoverViewOld.tsx | 41 +- .../plugins/panel/heatmap/HeatmapPanel.tsx | 199 +- ...eatmapHoverView.tsx => HeatmapTooltip.tsx} | 275 +- public/app/plugins/panel/heatmap/fields.ts | 64 +- .../plugins/panel/heatmap/migrations.test.ts | 4 +- .../app/plugins/panel/heatmap/migrations.ts | 17 +- public/app/plugins/panel/heatmap/module.tsx | 51 +- public/app/plugins/panel/heatmap/panelcfg.cue | 8 +- .../app/plugins/panel/heatmap/panelcfg.gen.ts | 10 +- public/app/plugins/panel/heatmap/utils.ts | 65 +- .../app/plugins/panel/histogram/Histogram.tsx | 43 +- .../panel/histogram/HistogramPanel.tsx | 1 + .../panel/histogram/migrations.test.ts | 28 + .../app/plugins/panel/histogram/migrations.ts | 30 + public/app/plugins/panel/histogram/module.tsx | 19 +- .../app/plugins/panel/histogram/panelcfg.cue | 4 +- .../plugins/panel/histogram/panelcfg.gen.ts | 7 +- .../app/plugins/panel/histogram/plugin.json | 2 + .../plugins/panel/live/LiveChannelEditor.tsx | 174 +- public/app/plugins/panel/live/LivePanel.tsx | 69 +- public/app/plugins/panel/live/LivePublish.tsx | 67 + public/app/plugins/panel/live/module.tsx | 17 +- public/app/plugins/panel/live/types.ts | 12 +- .../app/plugins/panel/logs/LogsPanel.test.tsx | 307 +- public/app/plugins/panel/logs/LogsPanel.tsx | 257 +- public/app/plugins/panel/logs/panelcfg.cue | 17 +- public/app/plugins/panel/logs/panelcfg.gen.ts | 3 +- .../logs/useDatasourcesFromTargets.test.ts | 44 + .../panel/logs/useDatasourcesFromTargets.ts | 31 + .../app/plugins/panel/news/component/News.tsx | 7 +- public/app/plugins/panel/news/panelcfg.gen.ts | 2 +- public/app/plugins/panel/nodeGraph/Edge.tsx | 13 +- .../app/plugins/panel/nodeGraph/NodeGraph.tsx | 4 +- .../panel/nodeGraph/createLayoutWorker.ts | 1 + public/app/plugins/panel/nodeGraph/layout.ts | 18 +- .../plugins/panel/nodeGraph/layout.worker.js | 171 +- .../panel/nodeGraph/layout.worker.utils.js | 174 + .../panel/nodeGraph/layoutMsagl.worker.js | 250 + public/app/plugins/panel/nodeGraph/module.tsx | 75 +- .../plugins/panel/nodeGraph/panelcfg.gen.ts | 2 +- .../plugins/panel/nodeGraph/suggestions.ts | 71 + public/app/plugins/panel/nodeGraph/types.ts | 5 + .../app/plugins/panel/nodeGraph/usePanning.ts | 4 +- .../app/plugins/panel/nodeGraph/utils.test.ts | 2 + public/app/plugins/panel/nodeGraph/utils.ts | 29 +- .../plugins/panel/piechart/panelcfg.gen.ts | 2 +- .../app/plugins/panel/stat/StatMigrations.ts | 2 +- public/app/plugins/panel/stat/panelcfg.gen.ts | 2 +- .../state-timeline/StateTimelinePanel.tsx | 76 +- .../state-timeline/StateTimelineTooltip2.tsx | 164 +- .../plugins/panel/state-timeline/module.tsx | 5 +- .../panel/state-timeline/panelcfg.gen.ts | 2 +- .../status-history/StatusHistoryPanel.tsx | 98 +- .../status-history/StatusHistoryTooltip2.tsx | 158 - .../panel/status-history/panelcfg.gen.ts | 2 +- .../app/plugins/panel/status-history/utils.ts | 8 +- public/app/plugins/panel/table-old/module.ts | 17 +- .../panel/table/TableCellOptionEditor.tsx | 1 + public/app/plugins/panel/table/TablePanel.tsx | 1 + .../plugins/panel/table/migrations.test.ts | 33 + public/app/plugins/panel/table/migrations.ts | 7 + public/app/plugins/panel/table/module.tsx | 12 +- .../app/plugins/panel/table/panelcfg.gen.ts | 2 +- public/app/plugins/panel/text/TextPanel.tsx | 3 +- public/app/plugins/panel/text/panelcfg.gen.ts | 2 +- .../timeseries/ThresholdsStyleEditor.tsx | 8 +- .../panel/timeseries/TimeSeriesPanel.tsx | 135 +- .../panel/timeseries/TimeSeriesTooltip.tsx | 149 +- .../__snapshots__/migrations.test.ts.snap | 4 + public/app/plugins/panel/timeseries/config.ts | 4 +- .../plugins/panel/timeseries/migrations.ts | 15 +- .../app/plugins/panel/timeseries/module.tsx | 2 +- .../app/plugins/panel/timeseries/panelcfg.cue | 5 +- .../plugins/panel/timeseries/panelcfg.gen.ts | 3 +- .../timeseries/plugins/AnnotationsPlugin2.tsx | 263 + .../timeseries/plugins/ExemplarMarker.tsx | 396 +- .../plugins/annotations/AnnotationEditor.tsx | 102 +- .../annotations/AnnotationEditorForm.tsx | 14 +- .../plugins/annotations/AnnotationMarker.tsx | 117 +- .../annotations2/AnnotationEditor2.tsx | 159 + .../annotations2/AnnotationMarker2.tsx | 109 + .../annotations2/AnnotationTooltip2.tsx | 158 + public/app/plugins/panel/timeseries/utils.ts | 60 +- .../app/plugins/panel/traces/TracesPanel.tsx | 6 +- public/app/plugins/panel/traces/module.tsx | 3 +- .../app/plugins/panel/traces/suggestions.ts | 29 + public/app/plugins/panel/trend/TrendPanel.tsx | 35 +- .../app/plugins/panel/trend/TrendTooltip.tsx | 174 - public/app/plugins/panel/trend/module.tsx | 2 +- .../app/plugins/panel/trend/panelcfg.gen.ts | 2 +- .../app/plugins/panel/xychart/AutoEditor.tsx | 8 +- .../plugins/panel/xychart/ManualEditor.tsx | 77 +- .../panel/xychart/ScatterSeriesEditor.tsx | 15 +- .../app/plugins/panel/xychart/TooltipView.tsx | 36 +- .../{XYChartPanel2.tsx => XYChartPanel.tsx} | 122 +- .../panel/xychart/XYChartTooltip.test.tsx | 180 + .../plugins/panel/xychart/XYChartTooltip.tsx | 101 + public/app/plugins/panel/xychart/dims.ts | 13 +- public/app/plugins/panel/xychart/module.tsx | 6 +- public/app/plugins/panel/xychart/panelcfg.cue | 7 +- .../app/plugins/panel/xychart/panelcfg.gen.ts | 3 +- public/app/plugins/panel/xychart/plugin.json | 2 + public/app/plugins/panel/xychart/scatter.ts | 71 +- public/app/plugins/panel/xychart/types.ts | 11 + public/app/plugins/panel/xychart/utils.ts | 9 + public/app/routes/routes.tsx | 76 +- public/app/store/configureStore.ts | 4 +- public/app/types/dashboard.ts | 20 +- public/app/types/events.ts | 12 + public/app/types/folders.ts | 5 + public/app/types/plugins.ts | 1 - public/app/types/suggestions.ts | 2 + public/app/types/unified-alerting-dto.ts | 13 +- public/app/types/unified-alerting.ts | 7 +- public/app/types/user.ts | 9 + public/emails/verify_email_update.html | 215 + public/emails/verify_email_update.txt | 9 + public/fonts/inter/Inter-Medium.woff2 | Bin 0 -> 111380 bytes public/fonts/inter/Inter-Regular.woff2 | Bin 0 -> 108488 bytes ...73FwrK3iLTeHuS_fvQtMwCp50KnMa0ZL7SUc.woff2 | Bin 17016 -> 0 bytes ...UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7.woff2 | Bin 37056 -> 0 bytes ...73FwrK3iLTeHuS_fvQtMwCp50KnMa1pL7SUc.woff2 | Bin 22516 -> 0 bytes ...73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7SUc.woff2 | Bin 57908 -> 0 bytes ...73FwrK3iLTeHuS_fvQtMwCp50KnMa2JL7SUc.woff2 | Bin 26952 -> 0 bytes ...73FwrK3iLTeHuS_fvQtMwCp50KnMa2ZL7SUc.woff2 | Bin 11412 -> 0 bytes ...73FwrK3iLTeHuS_fvQtMwCp50KnMa2pL7SUc.woff2 | Bin 8892 -> 0 bytes public/img/grot-404-dark.svg | 126 +- public/img/grot-404-light.svg | 126 +- public/img/icons/README.md | 12 + public/img/icons/unicons/ai.svg | 2 +- .../unicons/application-observability.svg | 4 +- public/img/icons/unicons/asserts.svg | 5 + .../icons/unicons/frontend-observability.svg | 8 +- public/img/icons/unicons/k6.svg | 8 +- public/img/icons/unicons/spinner.svg | 2 +- public/img/icons/unicons/unarchive.svg | 4 + public/img/plugins/pagerduty.svg | 20 + .../dark/groupToNestedTable.svg | 52 + .../disabled/groupToNestedTable.svg | 40 + .../light/groupToNestedTable.svg | 52 + public/lib/monaco-languages/kusto.ts | 6 +- public/locales/de-DE/grafana.json | 367 +- public/locales/en-US/grafana.json | 319 +- public/locales/es-ES/grafana.json | 371 +- public/locales/fr-FR/grafana.json | 409 +- public/locales/i18next-parser.config.js | 7 +- public/locales/pseudo-LOCALE/grafana.json | 319 +- public/locales/pt-BR/grafana.json | 1656 ++ public/locales/zh-Hans/grafana.json | 365 +- public/maps/example-with-style.geojson | 150 + public/openapi3.json | 12018 ++++++------ public/sass/_grafana.scss | 1 - public/sass/_variables.generated.scss | 1 - public/sass/base/_fonts.scss | 127 +- public/sass/components/_dashboard_grid.scss | 14 +- public/sass/components/_scrollbar.scss | 1 + public/test/helpers/alertingRuleEditor.tsx | 9 +- public/test/jest-resolver.js | 17 + public/test/mocks/datasource_srv.ts | 8 +- public/test/mocks/getGrafanaContextMock.ts | 5 + public/test/mocks/monaco.ts | 1 - public/test/mocks/workers.ts | 2 +- public/test/setupTests.ts | 10 + public/test/specs/helpers.ts | 2 +- public/views/error.html | 10 +- public/views/index.html | 21 +- public/views/swagger.html | 1 + scripts/check-breaking-changes.sh | 25 +- scripts/ci-frontend-metrics.sh | 2 +- scripts/cli/bettererResultsToJson.ts | 42 + scripts/docs/generate-transformations.test.ts | 57 + scripts/docs/generate-transformations.ts | 56 +- scripts/drone/env-var-check.sh | 12 + scripts/drone/events/pr.star | 3 +- scripts/drone/pipelines/build.star | 6 + scripts/drone/pipelines/swagger_gen.star | 12 +- .../drone/pipelines/trigger_downstream.star | 2 +- scripts/drone/rgm.star | 87 +- scripts/drone/services/services.star | 2 +- scripts/drone/steps/lib.star | 95 +- scripts/drone/steps/rgm.star | 12 + scripts/drone/utils/images.star | 8 +- scripts/drone/utils/utils.star | 8 +- scripts/drone/variables.star | 4 +- scripts/drone/vault.star | 6 +- scripts/levitate-parse-json-report.js | 10 +- scripts/list-release-artifacts.sh | 2 - scripts/modowners/README.md | 5 +- scripts/modowners/modowners.go | 2 +- scripts/validate-npm-packages.sh | 10 +- scripts/webpack/webpack.common.js | 51 +- scripts/webpack/webpack.dev.js | 32 +- scripts/webpack/webpack.hot.js | 9 +- scripts/webpack/webpack.prod.js | 1 - stylelint.config.js | 5 +- tsconfig.json | 1 - yarn.lock | 16051 ++++++++-------- 4890 files changed, 251732 insertions(+), 152195 deletions(-) create mode 100644 .betterer.results.json delete mode 100644 .github/workflows/detect-breaking-changes-build-skip.yml delete mode 100644 .github/workflows/detect-breaking-changes-build.yml create mode 100644 .github/workflows/detect-breaking-changes-levitate.yml delete mode 100644 .github/workflows/detect-breaking-changes-report.yml create mode 100644 .github/workflows/i18n-crowdin-download.yml delete mode 100644 .github/workflows/i18n-crowdin-fix-files.yml create mode 100644 .github/workflows/i18n-crowdin-upload.yml delete mode 100644 .yarn/cache/tether-drop-https-3382d2649f-178c3afb88.zip delete mode 100755 .yarn/releases/yarn-4.0.0.cjs create mode 100755 .yarn/releases/yarn-4.1.0.cjs delete mode 100755 .yarn/sdks/eslint/bin/eslint.js delete mode 100644 .yarn/sdks/eslint/lib/api.js delete mode 100644 .yarn/sdks/eslint/package.json delete mode 100644 .yarn/sdks/integrations.yml delete mode 100644 .yarn/sdks/prettier/package.json delete mode 100755 .yarn/sdks/typescript/bin/tsc delete mode 100755 .yarn/sdks/typescript/bin/tsserver delete mode 100644 .yarn/sdks/typescript/lib/tsc.js delete mode 100644 .yarn/sdks/typescript/lib/tsserver.js delete mode 100644 .yarn/sdks/typescript/lib/tsserverlibrary.js delete mode 100644 .yarn/sdks/typescript/lib/typescript.js delete mode 100644 .yarn/sdks/typescript/package.json delete mode 100644 babel.config.json delete mode 100644 conf/provisioning/notifiers/sample.yaml rename ISSUE_TRIAGE.md => contribute/ISSUE_TRIAGE.md (97%) rename UPGRADING_DEPENDENCIES.md => contribute/UPGRADING_DEPENDENCIES.md (100%) delete mode 100644 devenv/bulk_alerting_dashboards/bulk_alerting_dashboards.yaml delete mode 100644 devenv/bulk_alerting_dashboards/dashboard.libsonnet delete mode 100644 devenv/bulk_alerting_dashboards/datasources.jsonnet delete mode 100644 devenv/dev-dashboards/alerting/testdata_alerts.json create mode 100644 devenv/dev-dashboards/live/live-publish.json create mode 100644 devenv/dev-dashboards/panel-canvas/canvas-datalinks.json create mode 100644 devenv/dev-dashboards/panel-timeline/timeline-align-endtime.json create mode 100644 devenv/dev-dashboards/panel-timeline/timeline-align-nulls-retain.json create mode 100644 devenv/dev-dashboards/panel-xychart/xychart-tooltip-color-test.json create mode 100644 devenv/dev-dashboards/scenarios/tall_dashboard.json delete mode 100644 devenv/docker/ha-test-unified-alerting/grafana/provisioning/alerts.jsonnet delete mode 100644 devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet rename .codespellignore => docs/.codespellignore (100%) rename docs/sources/administration/data-source-management/{index.md => _index.md} (81%) create mode 100644 docs/sources/administration/data-source-management/teamlbac/_index.md create mode 100644 docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-loki/index.md create mode 100644 docs/sources/administration/data-source-management/teamlbac/create-teamlbac-rules/index.md delete mode 100644 docs/sources/alerting/alerting-rules/manage-contact-points/_index.md delete mode 100644 docs/sources/alerting/alerting-rules/manage-contact-points/integrations/_index.md rename docs/sources/alerting/{fundamentals/annotation-label/variables-label-annotation.md => alerting-rules/templating-labels-annotations.md} (93%) create mode 100644 docs/sources/alerting/configure-notifications/_index.md rename docs/sources/alerting/{alerting-rules => configure-notifications}/create-notification-policy.md (89%) rename docs/sources/alerting/{manage-notifications => configure-notifications}/create-silence.md (71%) create mode 100644 docs/sources/alerting/configure-notifications/manage-contact-points/_index.md rename docs/sources/alerting/{alerting-rules => configure-notifications}/manage-contact-points/integrations/configure-oncall.md (77%) create mode 100644 docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md rename docs/sources/alerting/{alerting-rules => configure-notifications}/manage-contact-points/integrations/pager-duty.md (79%) rename docs/sources/alerting/{alerting-rules => configure-notifications}/manage-contact-points/integrations/webhook-notifier.md (91%) rename docs/sources/alerting/{manage-notifications => configure-notifications}/mute-timings.md (82%) rename docs/sources/alerting/{manage-notifications => configure-notifications}/template-notifications/_index.md (68%) rename docs/sources/alerting/{manage-notifications => configure-notifications}/template-notifications/create-notification-templates.md (96%) rename docs/sources/alerting/{manage-notifications => configure-notifications/template-notifications}/images-in-notifications.md (91%) rename docs/sources/alerting/{manage-notifications => configure-notifications}/template-notifications/reference.md (95%) rename docs/sources/alerting/{manage-notifications => configure-notifications}/template-notifications/use-notification-templates.md (58%) rename docs/sources/alerting/{manage-notifications => configure-notifications}/template-notifications/using-go-templating-language.md (88%) delete mode 100644 docs/sources/alerting/difference-old-new.md delete mode 100644 docs/sources/alerting/fundamentals/alert-rules/alert-instances.md delete mode 100644 docs/sources/alerting/fundamentals/alert-rules/alert-rule-types.md create mode 100644 docs/sources/alerting/fundamentals/alert-rules/annotation-label.md rename docs/sources/alerting/fundamentals/alert-rules/{queries-conditions/_index.md => queries-conditions.md} (62%) delete mode 100644 docs/sources/alerting/fundamentals/alert-rules/recording-rules/_index.md rename docs/sources/alerting/fundamentals/alert-rules/{rule-evaluation/_index.md => rule-evaluation.md} (79%) delete mode 100644 docs/sources/alerting/fundamentals/alertmanager.md delete mode 100644 docs/sources/alerting/fundamentals/annotation-label/_index.md delete mode 100644 docs/sources/alerting/fundamentals/annotation-label/how-to-use-labels.md delete mode 100644 docs/sources/alerting/fundamentals/annotation-label/labels-and-label-matchers.md delete mode 100644 docs/sources/alerting/fundamentals/data-source-alerting.md delete mode 100644 docs/sources/alerting/fundamentals/evaluate-grafana-alerts.md delete mode 100644 docs/sources/alerting/fundamentals/high-availability/_index.md rename docs/sources/alerting/fundamentals/{notification-policies => notifications}/_index.md (87%) create mode 100644 docs/sources/alerting/fundamentals/notifications/alertmanager.md rename docs/sources/alerting/fundamentals/{contact-points/index.md => notifications/contact-points.md} (92%) rename docs/sources/alerting/fundamentals/{alert-rules => notifications}/message-templating.md (86%) rename docs/sources/alerting/fundamentals/{notification-policies/notifications.md => notifications/notification-policies.md} (94%) delete mode 100644 docs/sources/alerting/manage-notifications/manage-contact-points.md delete mode 100644 docs/sources/alerting/set-up/migrating-alerts/_index.md delete mode 100644 docs/sources/alerting/set-up/migrating-alerts/legacy-alerting-deprecation.md create mode 100644 docs/sources/alerting/set-up/provision-alerting-resources/export-alerting-resources/index.md create mode 100644 docs/sources/alerting/set-up/provision-alerting-resources/http-api-provisioning/_index.md delete mode 100644 docs/sources/alerting/set-up/provision-alerting-resources/view-provisioned-resources/index.md create mode 100644 docs/sources/breaking-changes/breaking-changes-v10-3.md create mode 100644 docs/sources/dashboards/build-dashboards/import-dashboards/index.md create mode 100644 docs/sources/dashboards/troubleshoot-dashboards/index.md delete mode 100644 docs/sources/datasources/grafana-pyroscope.md create mode 100644 docs/sources/datasources/pyroscope/_index.md create mode 100644 docs/sources/datasources/pyroscope/configure-pyroscope-data-source.md create mode 100644 docs/sources/datasources/pyroscope/profiling-and-tracing.md create mode 100644 docs/sources/datasources/pyroscope/query-profile-data.md create mode 100644 docs/sources/datasources/tempo/tracing-best-practices.md delete mode 100644 docs/sources/developers/http_api/alerting.md delete mode 100644 docs/sources/developers/http_api/alerting_notification_channels.md delete mode 100644 docs/sources/developers/kinds/_index.md delete mode 100644 docs/sources/developers/kinds/composable/_index.md delete mode 100644 docs/sources/developers/kinds/composable/alertgroups/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/annotationslist/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/azuremonitor/dataquery/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/barchart/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/bargauge/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/candlestick/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/canvas/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/cloudwatch/dataquery/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/dashboardlist/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/datagrid/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/debug/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/elasticsearch/dataquery/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/gauge/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/geomap/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/googlecloudmonitoring/dataquery/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/grafanapyroscope/dataquery/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/heatmap/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/histogram/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/logs/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/loki/dataquery/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/news/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/nodegraph/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/parca/dataquery/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/piechart/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/prometheus/dataquery/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/stat/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/statetimeline/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/statushistory/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/table/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/tempo/dataquery/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/testdata/dataquery/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/text/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/timeseries/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/trend/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/composable/xychart/panelcfg/schema-reference.md delete mode 100644 docs/sources/developers/kinds/core/_index.md delete mode 100644 docs/sources/developers/kinds/core/accesspolicy/schema-reference.md delete mode 100644 docs/sources/developers/kinds/core/dashboard/schema-reference.md delete mode 100644 docs/sources/developers/kinds/core/folder/schema-reference.md delete mode 100644 docs/sources/developers/kinds/core/librarypanel/schema-reference.md delete mode 100644 docs/sources/developers/kinds/core/preferences/schema-reference.md delete mode 100644 docs/sources/developers/kinds/core/publicdashboard/schema-reference.md delete mode 100644 docs/sources/developers/kinds/core/role/schema-reference.md delete mode 100644 docs/sources/developers/kinds/core/rolebinding/schema-reference.md delete mode 100644 docs/sources/developers/kinds/core/serviceaccount/schema-reference.md delete mode 100644 docs/sources/developers/kinds/core/team/schema-reference.md delete mode 100644 docs/sources/developers/kinds/maturity.md delete mode 100644 docs/sources/old-alerting/_index.md delete mode 100644 docs/sources/old-alerting/add-notification-template.md delete mode 100644 docs/sources/old-alerting/create-alerts.md delete mode 100644 docs/sources/old-alerting/notifications.md delete mode 100644 docs/sources/old-alerting/pause-an-alert-rule.md delete mode 100644 docs/sources/old-alerting/troubleshoot-alerts.md delete mode 100644 docs/sources/old-alerting/view-alerts.md rename docs/sources/setup-grafana/configure-security/configure-authentication/{grafana-com => grafana-cloud}/index.md (54%) create mode 100644 docs/sources/setup-grafana/installation/helm/index.md create mode 100644 docs/sources/shared/alerts/alerting_provisioning.md create mode 100644 docs/sources/shared/datasources/pyroscope-profile-tracing-intro.md create mode 100644 docs/sources/upgrade-guide/upgrade-v10.3/index.md create mode 100644 docs/sources/upgrade-guide/upgrade-v10.4/index.md create mode 100644 docs/sources/whatsnew/whats-new-in-v10-3.md create mode 100644 docs/sources/whatsnew/whats-new-in-v10-4.md create mode 100644 e2e/dashboards-suite/embedded-dashboard.spec.ts create mode 100644 e2e/dashboards-suite/general-dashboards.spec.ts create mode 100644 e2e/plugin-e2e/plugin-e2e-api-tests/README.md create mode 100644 e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/annotationEditPage.spec.ts create mode 100644 e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/datasourceConfigPage.spec.ts create mode 100644 e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/explorePage.spec.ts create mode 100644 e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/featureToggles.spec.ts create mode 100644 e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts create mode 100644 e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts create mode 100644 e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/variableEditPage.spec.ts create mode 100644 e2e/plugin-e2e/plugin-e2e-api-tests/as-viewer-user/permissions.spec.ts create mode 100644 e2e/plugin-e2e/plugin-e2e-api-tests/errors.ts create mode 100644 e2e/plugin-e2e/plugin-e2e-api-tests/mocks/queries.ts create mode 100644 e2e/plugin-e2e/plugin-e2e-api-tests/mocks/resources.ts create mode 100644 e2e/utils/support/monaco.ts create mode 100644 e2e/various-suite/helpers/prometheus-helpers.ts create mode 100644 e2e/various-suite/prometheus-annotations.spec.ts create mode 100644 e2e/various-suite/prometheus-config.spec.ts create mode 100644 e2e/various-suite/prometheus-editor.spec.ts create mode 100644 e2e/various-suite/prometheus-variable-editor.spec.ts create mode 100644 emails/templates/verify_email.mjml create mode 100644 emails/templates/verify_email.txt create mode 100644 go.work create mode 100644 go.work.sum create mode 100644 hack/externalTools.go create mode 100755 hack/make-aggregator-pki.sh create mode 100644 hack/openapi-codegen.sh create mode 100644 nx.json create mode 100644 packages/grafana-data/src/query/index.ts create mode 100644 packages/grafana-data/src/query/refId.test.ts create mode 100644 packages/grafana-data/src/query/refId.ts create mode 100644 packages/grafana-data/src/transformations/matchers/valueMatchers/substringMatchers.test.ts create mode 100644 packages/grafana-data/src/transformations/matchers/valueMatchers/substringMatchers.ts create mode 100644 packages/grafana-data/src/transformations/transformers/groupToNestedTable.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/groupToNestedTable.ts delete mode 100644 packages/grafana-data/src/vector/AppendedVectors.test.ts delete mode 100644 packages/grafana-data/src/vector/AppendedVectors.ts delete mode 100644 packages/grafana-data/src/vector/AsNumberVector.ts delete mode 100644 packages/grafana-data/src/vector/BinaryOperationVector.test.ts delete mode 100644 packages/grafana-data/src/vector/BinaryOperationVector.ts delete mode 100644 packages/grafana-data/src/vector/ConstantVector.test.ts delete mode 100644 packages/grafana-data/src/vector/ConstantVector.ts delete mode 100644 packages/grafana-data/src/vector/FormattedVector.ts delete mode 100644 packages/grafana-data/src/vector/IndexVector.ts delete mode 100644 packages/grafana-data/src/vector/SortedVector.test.ts delete mode 100644 packages/grafana-data/src/vector/SortedVector.ts delete mode 100644 packages/grafana-data/src/vector/index.ts delete mode 100644 packages/grafana-data/src/vector/vectorToArray.ts create mode 100644 packages/grafana-o11y-ds-frontend/package.json rename {public/app/core/components => packages/grafana-o11y-ds-frontend/src}/IntervalInput/IntervalInput.test.tsx (100%) rename {public/app/core/components => packages/grafana-o11y-ds-frontend/src}/IntervalInput/IntervalInput.tsx (100%) rename {public/app/core/components => packages/grafana-o11y-ds-frontend/src}/IntervalInput/validation.test.ts (100%) rename {public/app/core/components => packages/grafana-o11y-ds-frontend/src}/IntervalInput/validation.ts (100%) create mode 100644 packages/grafana-o11y-ds-frontend/src/LocalStorageValueProvider/LocalStorageValueProvider.tsx rename {public/app/core/components => packages/grafana-o11y-ds-frontend/src/NodeGraph}/NodeGraphSettings.tsx (94%) create mode 100644 packages/grafana-o11y-ds-frontend/src/SpanBar/SpanBarSettings.tsx create mode 100644 packages/grafana-o11y-ds-frontend/src/TemporaryAlert.test.tsx create mode 100644 packages/grafana-o11y-ds-frontend/src/TemporaryAlert.tsx rename {public/app/core/components => packages/grafana-o11y-ds-frontend/src}/TraceToLogs/TagMappingInput.tsx (93%) rename {public/app/core/components => packages/grafana-o11y-ds-frontend/src}/TraceToLogs/TraceToLogsSettings.test.tsx (100%) rename {public/app/core/components => packages/grafana-o11y-ds-frontend/src}/TraceToLogs/TraceToLogsSettings.tsx (96%) rename {public/app/core/components => packages/grafana-o11y-ds-frontend/src}/TraceToMetrics/TraceToMetricsSettings.tsx (94%) rename {public/app/core/components => packages/grafana-o11y-ds-frontend/src}/TraceToProfiles/TraceToProfilesSettings.test.tsx (100%) rename {public/app/core/components => packages/grafana-o11y-ds-frontend/src}/TraceToProfiles/TraceToProfilesSettings.tsx (86%) create mode 100644 packages/grafana-o11y-ds-frontend/src/combineResponses.test.ts create mode 100644 packages/grafana-o11y-ds-frontend/src/combineResponses.ts create mode 100644 packages/grafana-o11y-ds-frontend/src/index.ts create mode 100644 packages/grafana-o11y-ds-frontend/src/pyroscope/ProfileTypesCascader.tsx create mode 100644 packages/grafana-o11y-ds-frontend/src/pyroscope/dataquery.gen.ts create mode 100644 packages/grafana-o11y-ds-frontend/src/pyroscope/datasource.ts create mode 100644 packages/grafana-o11y-ds-frontend/src/pyroscope/types.ts create mode 100644 packages/grafana-o11y-ds-frontend/src/store.ts rename public/app/core/utils/tracing.ts => packages/grafana-o11y-ds-frontend/src/utils.ts (97%) create mode 100644 packages/grafana-o11y-ds-frontend/tsconfig.json create mode 100644 packages/grafana-prometheus/CHANGELOG.md create mode 100644 packages/grafana-prometheus/LICENSE_AGPL create mode 100644 packages/grafana-prometheus/README.md create mode 100644 packages/grafana-prometheus/package.json create mode 100644 packages/grafana-prometheus/rollup.config.ts create mode 100644 packages/grafana-prometheus/src/add_label_to_query.test.ts create mode 100644 packages/grafana-prometheus/src/add_label_to_query.ts create mode 100644 packages/grafana-prometheus/src/components/AnnotationQueryEditor.tsx create mode 100644 packages/grafana-prometheus/src/components/PromCheatSheet.tsx create mode 100644 packages/grafana-prometheus/src/components/PromExemplarField.tsx create mode 100644 packages/grafana-prometheus/src/components/PromExploreExtraField.test.tsx create mode 100644 packages/grafana-prometheus/src/components/PromExploreExtraField.tsx create mode 100644 packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx create mode 100644 packages/grafana-prometheus/src/components/PromQueryEditorByApp.tsx create mode 100644 packages/grafana-prometheus/src/components/PromQueryEditorForAlerting.tsx create mode 100644 packages/grafana-prometheus/src/components/PromQueryField.test.tsx create mode 100644 packages/grafana-prometheus/src/components/PromQueryField.tsx create mode 100644 packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx create mode 100644 packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx create mode 100644 packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx create mode 100644 packages/grafana-prometheus/src/components/VariableQueryEditor.tsx create mode 100644 packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx create mode 100644 packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryFieldLazy.tsx create mode 100644 packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryFieldProps.ts create mode 100644 packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryFieldWrapper.tsx create mode 100644 packages/grafana-prometheus/src/components/monaco-query-field/getOverrideServices.ts create mode 100644 packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts create mode 100644 packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts create mode 100644 packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.test.ts create mode 100644 packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.ts create mode 100644 packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/util.ts create mode 100644 packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/validation.test.ts create mode 100644 packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/validation.ts create mode 100644 packages/grafana-prometheus/src/components/monaco-query-field/promql.ts create mode 100644 packages/grafana-prometheus/src/components/types.ts create mode 100644 packages/grafana-prometheus/src/configuration/AlertingSettingsOverhaul.tsx create mode 100644 packages/grafana-prometheus/src/configuration/ConfigEditor.test.tsx create mode 100644 packages/grafana-prometheus/src/configuration/ConfigEditor.tsx create mode 100644 packages/grafana-prometheus/src/configuration/DataSourceHttpSettingsOverhaul.tsx create mode 100644 packages/grafana-prometheus/src/configuration/ExemplarSetting.tsx create mode 100644 packages/grafana-prometheus/src/configuration/ExemplarsSettings.tsx create mode 100644 packages/grafana-prometheus/src/configuration/PromFlavorVersions.ts create mode 100644 packages/grafana-prometheus/src/configuration/PromSettings.test.tsx create mode 100644 packages/grafana-prometheus/src/configuration/PromSettings.tsx create mode 100644 packages/grafana-prometheus/src/configuration/mocks.ts create mode 100644 packages/grafana-prometheus/src/dashboards/grafana_stats.json create mode 100644 packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json create mode 100644 packages/grafana-prometheus/src/dashboards/prometheus_stats.json rename packages/{grafana-schema/src/raw/composable/prometheus/dataquery/x/PrometheusDataQuery_types.gen.ts => grafana-prometheus/src/dataquery.ts} (74%) create mode 100644 packages/grafana-prometheus/src/datasource.test.ts create mode 100644 packages/grafana-prometheus/src/datasource.ts create mode 100644 packages/grafana-prometheus/src/gcopypaste/app/core/components/LocalStorageValueProvider/LocalStorageValueProvider.tsx create mode 100644 packages/grafana-prometheus/src/gcopypaste/app/core/components/LocalStorageValueProvider/index.tsx create mode 100644 packages/grafana-prometheus/src/gcopypaste/app/core/store.ts create mode 100644 packages/grafana-prometheus/src/gcopypaste/app/core/utils/CancelablePromise.ts create mode 100644 packages/grafana-prometheus/src/gcopypaste/app/core/utils/query.ts create mode 100644 packages/grafana-prometheus/src/gcopypaste/app/features/datasources/__mocks__/dataSourcesMocks.ts create mode 100644 packages/grafana-prometheus/src/gcopypaste/app/features/live/data/amendTimeSeries.ts create mode 100644 packages/grafana-prometheus/src/gcopypaste/packages/grafana-ui/src/components/Select/SelectBase.tsx create mode 100644 packages/grafana-prometheus/src/gcopypaste/public/test/matchers/index.ts create mode 100644 packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValues.test.ts create mode 100644 packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValues.ts create mode 100644 packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValuesWith.test.ts create mode 100644 packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValuesWith.ts create mode 100644 packages/grafana-prometheus/src/gcopypaste/public/test/matchers/types.ts create mode 100644 packages/grafana-prometheus/src/gcopypaste/public/test/matchers/utils.ts create mode 100644 packages/grafana-prometheus/src/gcopypaste/test/helpers/selectOptionInTest.ts create mode 100644 packages/grafana-prometheus/src/img/cortex_logo.svg create mode 100644 packages/grafana-prometheus/src/img/mimir_logo.svg create mode 100644 packages/grafana-prometheus/src/img/prometheus_logo.svg create mode 100644 packages/grafana-prometheus/src/img/thanos_logo.svg create mode 100644 packages/grafana-prometheus/src/index.ts create mode 100644 packages/grafana-prometheus/src/language_provider.mock.ts create mode 100644 packages/grafana-prometheus/src/language_provider.test.ts create mode 100644 packages/grafana-prometheus/src/language_provider.ts create mode 100644 packages/grafana-prometheus/src/language_utils.test.ts create mode 100644 packages/grafana-prometheus/src/language_utils.ts create mode 100644 packages/grafana-prometheus/src/metric_find_query.test.ts create mode 100644 packages/grafana-prometheus/src/metric_find_query.ts create mode 100644 packages/grafana-prometheus/src/migrations/variableMigration.ts create mode 100644 packages/grafana-prometheus/src/module.test.ts create mode 100644 packages/grafana-prometheus/src/module.ts create mode 100644 packages/grafana-prometheus/src/promql.test.ts create mode 100644 packages/grafana-prometheus/src/promql.ts create mode 100644 packages/grafana-prometheus/src/query_hints.test.ts create mode 100644 packages/grafana-prometheus/src/query_hints.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/PromQueryModeller.test.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/PromQueryModeller.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/QueryPattern.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.test.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/aggregations.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/binaryScalarOperations.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/components/LabelFilterItem.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/LabelFilters.test.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/LabelFilters.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/LabelParamEditor.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/MetricSelect.test.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/MetricSelect.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/NestedQuery.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/NestedQueryList.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.test.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderExplained.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.test.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.test.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.test.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/PromQueryLegendEditor.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/QueryPreview.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/metrics-modal/AdditionalSettings.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/metrics-modal/FeedbackLink.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/metrics-modal/ResultsTable.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/metrics-modal/index.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/helpers.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/state.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/components/metrics-modal/styles.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/components/metrics-modal/types.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/components/metrics-modal/uFuzzy.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/components/promQail/PromQail.test.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/promQail/PromQail.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/promQail/QueryAssistantButton.test.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/promQail/QueryAssistantButton.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/promQail/QuerySuggestionContainer.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/promQail/QuerySuggestionItem.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/promQail/index.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/components/promQail/prompts.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/components/promQail/resources/AI_Logo_bw.svg create mode 100644 packages/grafana-prometheus/src/querybuilder/components/promQail/resources/AI_Logo_color.svg create mode 100644 packages/grafana-prometheus/src/querybuilder/components/promQail/state/helpers.test.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/components/promQail/state/helpers.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/components/promQail/state/state.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/components/promQail/state/templates.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/components/promQail/types.ts rename {public/app/plugins/datasource/prometheus/querybuilder/shared => packages/grafana-prometheus/src/querybuilder}/hooks/useFlag.test.ts (51%) rename {public/app/plugins/datasource/prometheus/querybuilder/shared => packages/grafana-prometheus/src/querybuilder}/hooks/useFlag.ts (63%) create mode 100644 packages/grafana-prometheus/src/querybuilder/operationUtils.test.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/operationUtils.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/operations.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/parsing.test.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/parsing.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/parsingUtils.test.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/parsingUtils.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/shared/LokiAndPromQueryModellerBase.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/shared/OperationEditor.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/shared/OperationExplainedBox.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/shared/OperationHeader.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/shared/OperationInfoButton.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/shared/OperationList.test.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/shared/OperationList.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/shared/OperationListExplained.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/shared/OperationParamEditor.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/shared/OperationsEditorRow.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/shared/QueryBuilderHints.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/shared/QueryEditorModeToggle.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/shared/QueryHeaderSwitch.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/shared/QueryOptionGroup.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/shared/RawQuery.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/shared/types.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/state.test.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/state.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/testUtils.ts create mode 100644 packages/grafana-prometheus/src/querybuilder/types.ts create mode 100644 packages/grafana-prometheus/src/querycache/QueryCache.test.ts create mode 100644 packages/grafana-prometheus/src/querycache/QueryCache.ts create mode 100644 packages/grafana-prometheus/src/querycache/QueryCacheTestData.ts create mode 100644 packages/grafana-prometheus/src/result_transformer.test.ts create mode 100644 packages/grafana-prometheus/src/result_transformer.ts create mode 100644 packages/grafana-prometheus/src/tracking.ts create mode 100644 packages/grafana-prometheus/src/types.ts create mode 100644 packages/grafana-prometheus/src/typings/jest.d.ts create mode 100644 packages/grafana-prometheus/src/variables.ts create mode 100644 packages/grafana-prometheus/tsconfig.build.json create mode 100644 packages/grafana-prometheus/tsconfig.json create mode 100644 packages/grafana-runtime/src/components/EmbeddedDashboard.tsx create mode 100644 packages/grafana-runtime/src/utils/returnToPrevious.ts create mode 100644 packages/grafana-schema/src/common/data.cue delete mode 100644 packages/grafana-schema/src/raw/composable/alertgroups/panelcfg/x/AlertGroupsPanelCfg_types.gen.ts create mode 100644 packages/grafana-sql/package.json rename {public/app/features/plugins/sql => packages/grafana-sql/src}/ResponseParser.test.ts (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/ResponseParser.ts (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/ConfirmModal.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/DatasetSelector.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/ErrorBoundary.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/QueryEditor.tsx (97%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/QueryEditorFeatureFlag.utils.ts (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/QueryHeader.tsx (98%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/SqlComponents.test.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/SqlComponents.testHelpers.ts (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/TableSelector.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/configuration/ConnectionLimits.tsx (81%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/configuration/Divider.tsx (100%) create mode 100644 packages/grafana-sql/src/components/configuration/NumberInput.tsx rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/configuration/TLSSecretsConfig.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/configuration/useMigrateDatabaseFields.test.ts (96%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/configuration/useMigrateDatabaseFields.ts (90%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/index.ts (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/query-editor-raw/QueryEditorRaw.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/query-editor-raw/QueryToolbox.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/query-editor-raw/QueryValidator.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/query-editor-raw/README.md (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/query-editor-raw/RawEditor.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/visual-query-builder/AwesomeQueryBuilder.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/visual-query-builder/GroupByRow.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/visual-query-builder/OrderByRow.tsx (95%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/visual-query-builder/Preview.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/visual-query-builder/SQLGroupByRow.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/visual-query-builder/SQLOrderByRow.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/visual-query-builder/SQLSelectRow.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/visual-query-builder/SQLWhereRow.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/visual-query-builder/SelectRow.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/visual-query-builder/VisualEditor.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/visual-query-builder/WhereRow.tsx (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/components/visual-query-builder/index.ts (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/constants.ts (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/datasource/SqlDatasource.ts (95%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/defaults.ts (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/expressions.ts (100%) create mode 100644 packages/grafana-sql/src/index.ts rename {public/app/features/plugins/sql => packages/grafana-sql/src}/types.ts (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/utils/formatSQL.ts (100%) create mode 100644 packages/grafana-sql/src/utils/logging.ts rename {public/app/features/plugins/sql => packages/grafana-sql/src}/utils/migration.test.ts (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/utils/migration.ts (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/utils/sql.utils.ts (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/utils/testHelpers.ts (100%) rename {public/app/features/plugins/sql => packages/grafana-sql/src}/utils/useSqlChange.ts (100%) create mode 100644 packages/grafana-sql/tsconfig.json delete mode 100644 packages/grafana-ui/src/components/Card/Card.mdx create mode 100644 packages/grafana-ui/src/components/DragHandle/DragHandle.tsx create mode 100644 packages/grafana-ui/src/components/Layout/Space.mdx create mode 100644 packages/grafana-ui/src/components/Layout/Space.story.tsx create mode 100644 packages/grafana-ui/src/components/Layout/Space.tsx create mode 100644 packages/grafana-ui/src/components/Link/TextLink.test.tsx create mode 100644 packages/grafana-ui/src/components/Splitter/useSplitter.mdx create mode 100644 packages/grafana-ui/src/components/Splitter/useSplitter.story.tsx create mode 100644 packages/grafana-ui/src/components/Splitter/useSplitter.ts create mode 100644 packages/grafana-ui/src/components/Table/DataLinksCell.tsx create mode 100644 packages/grafana-ui/src/components/Text/Typography.internal.story.tsx delete mode 100644 packages/grafana-ui/src/components/VizTooltip/HeaderLabel.tsx delete mode 100644 packages/grafana-ui/src/components/VizTooltip/SeriesList.tsx delete mode 100644 packages/grafana-ui/src/components/VizTooltip/VizTooltipHeaderLabelValue.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/plugins/EventBusPlugin.tsx create mode 100644 packages/grafana-ui/src/slate-plugins/suggestions.test.tsx create mode 100644 pkg/api/api_test.go delete mode 100644 pkg/api/dtos/alerting.go delete mode 100644 pkg/api/dtos/alerting_test.go delete mode 100644 pkg/api/featuremgmt.go delete mode 100644 pkg/api/featuremgmt_test.go create mode 100644 pkg/apimachinery/apis/common/v0alpha1/doc.go rename pkg/{apis/types.go => apimachinery/apis/common/v0alpha1/resource.go} (76%) create mode 100644 pkg/apimachinery/apis/common/v0alpha1/types.go create mode 100644 pkg/apimachinery/apis/common/v0alpha1/unstructured.go rename pkg/{apis/folders => apimachinery/apis/common}/v0alpha1/zz_generated.defaults.go (100%) rename pkg/{services/grafana-apiserver/openapi.go => apimachinery/apis/common/v0alpha1/zz_generated.openapi.go} (95%) create mode 100644 pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi_violation_exceptions.list create mode 100644 pkg/apimachinery/go.mod create mode 100644 pkg/apimachinery/go.sum create mode 100644 pkg/apis/dashboard/v0alpha1/doc.go create mode 100644 pkg/apis/dashboard/v0alpha1/register.go create mode 100644 pkg/apis/dashboard/v0alpha1/types.go create mode 100644 pkg/apis/dashboard/v0alpha1/zz_generated.deepcopy.go create mode 100644 pkg/apis/dashboard/v0alpha1/zz_generated.defaults.go create mode 100644 pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go create mode 100644 pkg/apis/dashboard/v0alpha1/zz_generated.openapi_violation_exceptions.list create mode 100644 pkg/apis/dashboardsnapshot/v0alpha1/doc.go create mode 100644 pkg/apis/dashboardsnapshot/v0alpha1/register.go create mode 100644 pkg/apis/dashboardsnapshot/v0alpha1/types.go create mode 100644 pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.deepcopy.go create mode 100644 pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.defaults.go create mode 100644 pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi.go create mode 100644 pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi_violation_exceptions.list create mode 100644 pkg/apis/datasource/v0alpha1/doc.go create mode 100644 pkg/apis/datasource/v0alpha1/register.go create mode 100644 pkg/apis/datasource/v0alpha1/types.go create mode 100644 pkg/apis/datasource/v0alpha1/zz_generated.deepcopy.go create mode 100644 pkg/apis/datasource/v0alpha1/zz_generated.defaults.go create mode 100644 pkg/apis/datasource/v0alpha1/zz_generated.openapi.go create mode 100644 pkg/apis/example/v0alpha1/register.go create mode 100644 pkg/apis/example/v0alpha1/zz_generated.openapi_violation_exceptions.list create mode 100644 pkg/apis/featuretoggle/v0alpha1/doc.go create mode 100644 pkg/apis/featuretoggle/v0alpha1/register.go create mode 100644 pkg/apis/featuretoggle/v0alpha1/types.go create mode 100644 pkg/apis/featuretoggle/v0alpha1/zz_generated.deepcopy.go create mode 100644 pkg/apis/featuretoggle/v0alpha1/zz_generated.defaults.go create mode 100644 pkg/apis/featuretoggle/v0alpha1/zz_generated.openapi.go create mode 100644 pkg/apis/featuretoggle/v0alpha1/zz_generated.openapi_violation_exceptions.list create mode 100644 pkg/apis/folder/v0alpha1/doc.go create mode 100644 pkg/apis/folder/v0alpha1/register.go create mode 100644 pkg/apis/folder/v0alpha1/types.go rename pkg/apis/{folders => folder}/v0alpha1/zz_generated.deepcopy.go (62%) create mode 100644 pkg/apis/folder/v0alpha1/zz_generated.defaults.go create mode 100644 pkg/apis/folder/v0alpha1/zz_generated.openapi.go create mode 100644 pkg/apis/folder/v0alpha1/zz_generated.openapi_violation_exceptions.list delete mode 100644 pkg/apis/folders/v0alpha1/types.go delete mode 100644 pkg/apis/folders/v0alpha1/zz_generated.openapi.go rename pkg/apis/{folders => peakq}/v0alpha1/doc.go (60%) create mode 100644 pkg/apis/peakq/v0alpha1/register.go create mode 100644 pkg/apis/peakq/v0alpha1/types.go create mode 100644 pkg/apis/peakq/v0alpha1/zz_generated.deepcopy.go create mode 100644 pkg/apis/peakq/v0alpha1/zz_generated.defaults.go create mode 100644 pkg/apis/peakq/v0alpha1/zz_generated.openapi.go create mode 100644 pkg/apis/playlist/v0alpha1/register.go create mode 100644 pkg/apis/playlist/v0alpha1/zz_generated.openapi_violation_exceptions.list create mode 100644 pkg/apis/query/v0alpha1/datasource.go create mode 100644 pkg/apis/query/v0alpha1/doc.go create mode 100644 pkg/apis/query/v0alpha1/query.go create mode 100644 pkg/apis/query/v0alpha1/query_test.go create mode 100644 pkg/apis/query/v0alpha1/register.go create mode 100644 pkg/apis/query/v0alpha1/template/doc.go create mode 100644 pkg/apis/query/v0alpha1/template/format.go create mode 100644 pkg/apis/query/v0alpha1/template/format_test.go create mode 100644 pkg/apis/query/v0alpha1/template/render.go create mode 100644 pkg/apis/query/v0alpha1/template/render_test.go create mode 100644 pkg/apis/query/v0alpha1/template/types.go create mode 100644 pkg/apis/query/v0alpha1/template/zz_generated.deepcopy.go create mode 100644 pkg/apis/query/v0alpha1/template/zz_generated.defaults.go create mode 100644 pkg/apis/query/v0alpha1/zz_generated.deepcopy.go create mode 100644 pkg/apis/query/v0alpha1/zz_generated.defaults.go create mode 100644 pkg/apis/query/v0alpha1/zz_generated.openapi.go create mode 100644 pkg/apis/query/v0alpha1/zz_generated.openapi_violation_exceptions.list create mode 100644 pkg/apis/scope/v0alpha1/doc.go create mode 100644 pkg/apis/scope/v0alpha1/register.go create mode 100644 pkg/apis/scope/v0alpha1/types.go create mode 100644 pkg/apis/scope/v0alpha1/zz_generated.deepcopy.go create mode 100644 pkg/apis/scope/v0alpha1/zz_generated.defaults.go create mode 100644 pkg/apis/scope/v0alpha1/zz_generated.openapi.go create mode 100644 pkg/apis/scope/v0alpha1/zz_generated.openapi_violation_exceptions.list create mode 100644 pkg/apis/service/v0alpha1/doc.go create mode 100644 pkg/apis/service/v0alpha1/register.go create mode 100644 pkg/apis/service/v0alpha1/types.go create mode 100644 pkg/apis/service/v0alpha1/zz_generated.deepcopy.go create mode 100644 pkg/apis/service/v0alpha1/zz_generated.defaults.go create mode 100644 pkg/apis/service/v0alpha1/zz_generated.openapi.go rename pkg/{services/grafana-apiserver => apiserver/builder}/common.go (84%) create mode 100644 pkg/apiserver/builder/helper.go create mode 100644 pkg/apiserver/builder/openapi.go rename pkg/{services/grafana-apiserver => apiserver/builder}/request_handler.go (62%) create mode 100644 pkg/apiserver/endpoints/filters/accept.go create mode 100644 pkg/apiserver/endpoints/filters/accept_test.go create mode 100644 pkg/apiserver/endpoints/request/accept.go create mode 100644 pkg/apiserver/endpoints/request/accept_test.go create mode 100644 pkg/apiserver/endpoints/responsewriter/responsewriter.go create mode 100644 pkg/apiserver/endpoints/responsewriter/responsewriter_test.go create mode 100644 pkg/apiserver/go.mod create mode 100644 pkg/apiserver/go.sum rename pkg/{services/grafana-apiserver => apiserver}/registry/generic/strategy.go (100%) rename pkg/{services/grafana-apiserver => apiserver}/rest/dualwriter.go (97%) rename pkg/{services/grafana-apiserver => apiserver}/storage/file/file.go (86%) rename pkg/{services/grafana-apiserver => apiserver}/storage/file/restoptions.go (57%) rename pkg/{services/grafana-apiserver => apiserver}/storage/file/util.go (74%) rename pkg/{services/grafana-apiserver => apiserver}/storage/file/watchset.go (100%) delete mode 100644 pkg/cmd/grafana/apiserver/README.md create mode 100644 pkg/cmd/grafana/apiserver/apiserver.md rename pkg/cmd/grafana/apiserver/deploy/{base => aggregator-test}/apiservice.yaml (94%) create mode 100644 pkg/cmd/grafana/apiserver/deploy/aggregator-test/externalname.yaml rename pkg/cmd/grafana/apiserver/deploy/{base => aggregator-test}/kustomization.yaml (58%) delete mode 100644 pkg/cmd/grafana/apiserver/deploy/base/namespace.yaml delete mode 100644 pkg/cmd/grafana/apiserver/deploy/darwin/kustomization.yaml delete mode 100644 pkg/cmd/grafana/apiserver/deploy/darwin/service.yaml delete mode 100644 pkg/cmd/grafana/apiserver/deploy/linux/kustomization.yaml delete mode 100644 pkg/cmd/grafana/apiserver/deploy/linux/service.yaml delete mode 100644 pkg/codegen/jenny_basecorereg.go create mode 100644 pkg/codegen/jenny_core_registry.go delete mode 100644 pkg/codegen/jenny_corekind.go delete mode 100644 pkg/codegen/jenny_docs.go delete mode 100644 pkg/codegen/jenny_go_resources.go create mode 100644 pkg/codegen/jenny_go_spec.go delete mode 100644 pkg/codegen/jenny_go_types.go create mode 100644 pkg/codegen/jenny_k8_resources.go delete mode 100644 pkg/codegen/jenny_ts_resources.go delete mode 100644 pkg/codegen/latest_jenny.go delete mode 100644 pkg/codegen/tmpl/addenda.tmpl delete mode 100644 pkg/codegen/tmpl/autogen_header.tmpl create mode 100644 pkg/codegen/tmpl/core_metadata.tmpl create mode 100644 pkg/codegen/tmpl/core_registry.tmpl create mode 100644 pkg/codegen/tmpl/core_status.tmpl delete mode 100644 pkg/codegen/tmpl/coremodel_imports.tmpl delete mode 100644 pkg/codegen/tmpl/cuetsy_multi.tmpl delete mode 100644 pkg/codegen/tmpl/docs.tmpl delete mode 100644 pkg/codegen/tmpl/kind_core.tmpl delete mode 100644 pkg/codegen/tmpl/kind_registry.tmpl delete mode 100644 pkg/codegen/tmpl/plugin_lineage_binding.tmpl delete mode 100644 pkg/codegen/tmpl/plugin_lineage_file.tmpl delete mode 100644 pkg/codegen/tmpl/plugin_registry_ref.tmpl create mode 100644 pkg/expr/converter.go create mode 100644 pkg/expr/converter_test.go create mode 100644 pkg/expr/models.go create mode 100644 pkg/expr/reader.go create mode 100644 pkg/expr/sql/parser.go create mode 100644 pkg/expr/sql/parser_test.go create mode 100644 pkg/expr/sql_command.go create mode 100644 pkg/expr/sql_command_test.go create mode 100644 pkg/generated/applyconfiguration/internal/internal.go create mode 100644 pkg/generated/applyconfiguration/service/v0alpha1/externalname.go create mode 100644 pkg/generated/applyconfiguration/service/v0alpha1/externalnamespec.go create mode 100644 pkg/generated/applyconfiguration/utils.go create mode 100644 pkg/generated/clientset/versioned/clientset.go create mode 100644 pkg/generated/clientset/versioned/fake/clientset_generated.go create mode 100644 pkg/generated/clientset/versioned/fake/doc.go create mode 100644 pkg/generated/clientset/versioned/fake/register.go create mode 100644 pkg/generated/clientset/versioned/scheme/doc.go create mode 100644 pkg/generated/clientset/versioned/scheme/register.go create mode 100644 pkg/generated/clientset/versioned/typed/service/v0alpha1/doc.go create mode 100644 pkg/generated/clientset/versioned/typed/service/v0alpha1/externalname.go create mode 100644 pkg/generated/clientset/versioned/typed/service/v0alpha1/fake/doc.go create mode 100644 pkg/generated/clientset/versioned/typed/service/v0alpha1/fake/fake_externalname.go create mode 100644 pkg/generated/clientset/versioned/typed/service/v0alpha1/fake/fake_service_client.go create mode 100644 pkg/generated/clientset/versioned/typed/service/v0alpha1/generated_expansion.go create mode 100644 pkg/generated/clientset/versioned/typed/service/v0alpha1/service_client.go create mode 100644 pkg/generated/informers/externalversions/factory.go create mode 100644 pkg/generated/informers/externalversions/generic.go create mode 100644 pkg/generated/informers/externalversions/internalinterfaces/factory_interfaces.go create mode 100644 pkg/generated/informers/externalversions/service/interface.go create mode 100644 pkg/generated/informers/externalversions/service/v0alpha1/externalname.go create mode 100644 pkg/generated/informers/externalversions/service/v0alpha1/interface.go create mode 100644 pkg/generated/listers/service/v0alpha1/expansion_generated.go create mode 100644 pkg/generated/listers/service/v0alpha1/externalname.go create mode 100644 pkg/infra/httpclient/httpclientprovider/grafana_request_id_header_middleware.go create mode 100644 pkg/infra/httpclient/httpclientprovider/grafana_request_id_header_middleware_test.go delete mode 100644 pkg/infra/httpclient/httpclientprovider/response_limit_middleware.go delete mode 100644 pkg/infra/httpclient/httpclientprovider/response_limit_middleware_test.go delete mode 100644 pkg/infra/httpclient/httpclientprovider/sigv4_middleware.go delete mode 100644 pkg/infra/httpclient/httpclientprovider/sigv4_middleware_test.go delete mode 100644 pkg/infra/httpclient/max_bytes_reader.go delete mode 100644 pkg/infra/httpclient/max_bytes_reader_test.go delete mode 100644 pkg/kinds/accesspolicy/accesspolicy_kind_gen.go delete mode 100644 pkg/kinds/dashboard/dashboard_kind_gen.go delete mode 100644 pkg/kinds/general_test.go delete mode 100644 pkg/kinds/librarypanel/librarypanel_kind_gen.go delete mode 100644 pkg/kinds/preferences/preferences_kind_gen.go delete mode 100644 pkg/kinds/publicdashboard/publicdashboard_kind_gen.go delete mode 100644 pkg/kinds/role/role_kind_gen.go delete mode 100644 pkg/kinds/rolebinding/rolebinding_kind_gen.go delete mode 100644 pkg/kinds/team/team_kind_gen.go delete mode 100644 pkg/kindsysreport/attributes.go delete mode 100644 pkg/kindsysreport/codegen/report.go delete mode 100644 pkg/kindsysreport/codegen/report.json delete mode 100644 pkg/kindsysreport/codeowners.go create mode 100644 pkg/plugins/apiserver.go create mode 100644 pkg/plugins/apiserver_test.go create mode 100644 pkg/plugins/backendplugin/pluginextensionv2/rendererv2_grpc.pb.go create mode 100644 pkg/plugins/backendplugin/pluginextensionv2/sanitizer_grpc.pb.go create mode 100644 pkg/plugins/codegen/jenny_plugin_registry.go delete mode 100644 pkg/plugins/codegen/jenny_pluginseachmajor.go delete mode 100644 pkg/plugins/codegen/jenny_plugintreelist.go create mode 100644 pkg/plugins/codegen/tmpl/composable_registry.tmpl delete mode 100644 pkg/plugins/codegen/tmpl/plugin_registry.tmpl delete mode 100644 pkg/plugins/codegen/util_go.go create mode 100644 pkg/plugins/manager/sources/source_local_disk_test.go delete mode 100644 pkg/plugins/manager/testdata/oauth-external-registration/plugin.json delete mode 100644 pkg/plugins/pfs/grafanaplugin.cue create mode 100644 pkg/plugins/pfs/plugindef_types.go delete mode 100644 pkg/plugins/plugindef/gen.go delete mode 100644 pkg/plugins/plugindef/pascal_test.go delete mode 100644 pkg/plugins/plugindef/plugindef.cue delete mode 100644 pkg/plugins/plugindef/plugindef.go delete mode 100644 pkg/plugins/plugindef/plugindef_bindings_gen.go delete mode 100644 pkg/plugins/plugindef/plugindef_types_gen.go create mode 100644 pkg/promlib/README.md rename pkg/{tsdb/prometheus => promlib}/client/client.go (98%) rename pkg/{tsdb/prometheus => promlib}/client/client_test.go (94%) rename pkg/{tsdb/prometheus => promlib}/client/transport.go (59%) create mode 100644 pkg/promlib/client/transport_test.go rename pkg/{util => promlib}/converter/prom.go (91%) rename pkg/{util => promlib}/converter/prom_test.go (71%) rename pkg/{util => promlib}/converter/testdata/loki-streams-a-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/loki-streams-a.json (100%) rename pkg/{util => promlib}/converter/testdata/loki-streams-b-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/loki-streams-b.json (100%) rename pkg/{util => promlib}/converter/testdata/loki-streams-c-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/loki-streams-c.json (100%) rename pkg/{util => promlib}/converter/testdata/loki-streams-structured-metadata-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/loki-streams-structured-metadata.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-error-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/prom-error.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-exemplars-a-frame.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-exemplars-a-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/prom-exemplars-a-golden.txt (100%) rename pkg/{util => promlib}/converter/testdata/prom-exemplars-a.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-exemplars-b-frame.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-exemplars-b-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/prom-exemplars-b-golden.txt (100%) rename pkg/{util => promlib}/converter/testdata/prom-exemplars-b.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-exemplars-diff-labels-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/prom-exemplars-diff-labels.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-exemplars-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/prom-exemplars.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-labels-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/prom-labels.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-matrix-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/prom-matrix-histogram-no-labels-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/prom-matrix-histogram-no-labels.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-matrix-histogram-partitioned-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/prom-matrix-histogram-partitioned.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-matrix-with-nans-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/prom-matrix-with-nans.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-matrix.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-scalar-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/prom-scalar.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-series-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/prom-series.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-string-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/prom-string.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-vector-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/prom-vector-histogram-no-labels-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/prom-vector-histogram-no-labels.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-vector.json (100%) rename pkg/{util => promlib}/converter/testdata/prom-warnings-frame.jsonc (100%) rename pkg/{util => promlib}/converter/testdata/prom-warnings.json (100%) create mode 100644 pkg/promlib/go.mod create mode 100644 pkg/promlib/go.sum rename pkg/{tsdb/prometheus => promlib}/healthcheck.go (82%) rename pkg/{tsdb/prometheus => promlib}/healthcheck_test.go (73%) rename pkg/{tsdb/prometheus => promlib}/heuristics.go (91%) rename pkg/{tsdb/prometheus => promlib}/heuristics_test.go (68%) rename pkg/{tsdb/prometheus => promlib}/instrumentation/instrumentation.go (100%) rename pkg/{tsdb/prometheus => promlib}/instrumentation/instrumentation_test.go (100%) create mode 100644 pkg/promlib/intervalv2/intervalv2.go create mode 100644 pkg/promlib/intervalv2/intervalv2_test.go create mode 100644 pkg/promlib/library.go create mode 100644 pkg/promlib/library_test.go rename pkg/{tsdb/prometheus => promlib}/middleware/custom_query_params.go (100%) rename pkg/{tsdb/prometheus => promlib}/middleware/custom_query_params_test.go (100%) rename pkg/{tsdb/prometheus => promlib}/middleware/force_http_get.go (100%) rename pkg/{tsdb/prometheus => promlib}/middleware/force_http_get_test.go (100%) rename pkg/{tsdb/prometheus => promlib}/models/query.go (65%) rename pkg/{tsdb/prometheus => promlib}/models/query_test.go (85%) rename pkg/{tsdb/prometheus => promlib}/models/result.go (99%) create mode 100644 pkg/promlib/models/scope.go rename pkg/{tsdb/prometheus => promlib}/querydata/exemplar/framer.go (100%) rename pkg/{tsdb/prometheus => promlib}/querydata/exemplar/labels.go (100%) rename pkg/{tsdb/prometheus => promlib}/querydata/exemplar/sampler.go (93%) rename pkg/{tsdb/prometheus => promlib}/querydata/exemplar/sampler_stddev.go (97%) rename pkg/{tsdb/prometheus => promlib}/querydata/exemplar/sampler_stddev_test.go (84%) rename pkg/{tsdb/prometheus => promlib}/querydata/exemplar/sampler_test.go (88%) rename pkg/{tsdb/prometheus => promlib}/querydata/exemplar/testdata/noop_sampler.jsonc (100%) rename pkg/{tsdb/prometheus => promlib}/querydata/exemplar/testdata/stddev_sampler.jsonc (100%) rename pkg/{tsdb/prometheus => promlib}/querydata/framing_bench_test.go (90%) rename pkg/{tsdb/prometheus => promlib}/querydata/framing_test.go (91%) rename pkg/{tsdb/prometheus => promlib}/querydata/request.go (73%) rename pkg/{tsdb/prometheus => promlib}/querydata/request_test.go (89%) rename pkg/{tsdb/prometheus => promlib}/querydata/response.go (91%) rename pkg/{tsdb/prometheus => promlib}/querydata/response_test.go (94%) rename pkg/{tsdb/prometheus => promlib}/resource/resource.go (92%) rename pkg/{tsdb/prometheus => promlib}/testdata/exemplar.query.json (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/exemplar.result.golden.jsonc (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/exemplar.result.json (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/range_auto.query.json (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/range_auto.result.golden.jsonc (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/range_auto.result.json (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/range_infinity.query.json (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/range_infinity.result.golden.jsonc (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/range_infinity.result.json (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/range_missing.query.json (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/range_missing.result.golden.jsonc (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/range_missing.result.json (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/range_nan.query.json (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/range_nan.result.golden.jsonc (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/range_nan.result.json (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/range_simple.query.json (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/range_simple.result.golden.jsonc (100%) rename pkg/{tsdb/prometheus => promlib}/testdata/range_simple.result.json (100%) rename pkg/{tsdb/prometheus => promlib}/utils/utils.go (100%) create mode 100644 pkg/registry/apis/dashboard/access/sql_dashboards.go create mode 100644 pkg/registry/apis/dashboard/access/token.go create mode 100644 pkg/registry/apis/dashboard/access/types.go create mode 100644 pkg/registry/apis/dashboard/authorizer.go create mode 100644 pkg/registry/apis/dashboard/legacy_storage.go create mode 100644 pkg/registry/apis/dashboard/register.go create mode 100644 pkg/registry/apis/dashboard/sub_access.go create mode 100644 pkg/registry/apis/dashboard/sub_versions.go create mode 100644 pkg/registry/apis/dashboard/summary_storage.go create mode 100644 pkg/registry/apis/dashboardsnapshot/conversions.go create mode 100644 pkg/registry/apis/dashboardsnapshot/exporter.go create mode 100644 pkg/registry/apis/dashboardsnapshot/options_storage.go create mode 100644 pkg/registry/apis/dashboardsnapshot/register.go create mode 100644 pkg/registry/apis/dashboardsnapshot/sql_storage.go create mode 100644 pkg/registry/apis/dashboardsnapshot/sub_body.go create mode 100644 pkg/registry/apis/datasource/README.md create mode 100644 pkg/registry/apis/datasource/authorizer.go create mode 100644 pkg/registry/apis/datasource/connections.go create mode 100644 pkg/registry/apis/datasource/middleware.go create mode 100644 pkg/registry/apis/datasource/plugincontext.go create mode 100644 pkg/registry/apis/datasource/querier.go create mode 100644 pkg/registry/apis/datasource/register.go create mode 100644 pkg/registry/apis/datasource/sub_health.go create mode 100644 pkg/registry/apis/datasource/sub_proxy.go create mode 100644 pkg/registry/apis/datasource/sub_query.go create mode 100644 pkg/registry/apis/datasource/sub_resource.go create mode 100644 pkg/registry/apis/featuretoggle/README.md create mode 100644 pkg/registry/apis/featuretoggle/current.go create mode 100644 pkg/registry/apis/featuretoggle/current_test.go create mode 100644 pkg/registry/apis/featuretoggle/features.go create mode 100644 pkg/registry/apis/featuretoggle/register.go create mode 100644 pkg/registry/apis/featuretoggle/toggles.go create mode 100644 pkg/registry/apis/folders/sub_access.go delete mode 100644 pkg/registry/apis/folders/sub_children.go create mode 100644 pkg/registry/apis/folders/sub_count.go create mode 100644 pkg/registry/apis/peakq/register.go create mode 100644 pkg/registry/apis/peakq/render.go create mode 100644 pkg/registry/apis/peakq/render_examples.go create mode 100644 pkg/registry/apis/peakq/render_examples_test.go create mode 100644 pkg/registry/apis/peakq/storage.go create mode 100644 pkg/registry/apis/query/client.go create mode 100644 pkg/registry/apis/query/client/plugin.go create mode 100644 pkg/registry/apis/query/client/testdata.go create mode 100644 pkg/registry/apis/query/metrics.go create mode 100644 pkg/registry/apis/query/parser.go create mode 100644 pkg/registry/apis/query/parser_test.go create mode 100644 pkg/registry/apis/query/plugins.go create mode 100644 pkg/registry/apis/query/query.go create mode 100644 pkg/registry/apis/query/register.go create mode 100644 pkg/registry/apis/query/testdata/cyclic-references.json create mode 100644 pkg/registry/apis/query/testdata/multiple-uids-same-plugin.json create mode 100644 pkg/registry/apis/query/testdata/self-reference.json create mode 100644 pkg/registry/apis/query/testdata/with-expressions.json create mode 100644 pkg/registry/apis/scope/register.go create mode 100644 pkg/registry/apis/scope/storage.go create mode 100644 pkg/registry/apis/service/register.go create mode 100644 pkg/registry/apis/service/storage.go delete mode 100644 pkg/registry/corekind/base.go delete mode 100644 pkg/registry/corekind/base_gen.go create mode 100644 pkg/registry/schemas/composable_kind.go create mode 100644 pkg/registry/schemas/core_kind.go create mode 100644 pkg/services/accesscontrol/ssoutils/utils.go delete mode 100644 pkg/services/alerting/alerting_usage.go delete mode 100644 pkg/services/alerting/alerting_usage_test.go delete mode 100644 pkg/services/alerting/conditions/evaluator.go delete mode 100644 pkg/services/alerting/conditions/evaluator_test.go delete mode 100644 pkg/services/alerting/conditions/query.go delete mode 100644 pkg/services/alerting/conditions/query_interval_test.go delete mode 100644 pkg/services/alerting/conditions/query_test.go delete mode 100644 pkg/services/alerting/conditions/reducer.go delete mode 100644 pkg/services/alerting/conditions/reducer_test.go delete mode 100644 pkg/services/alerting/engine.go delete mode 100644 pkg/services/alerting/engine_integration_test.go delete mode 100644 pkg/services/alerting/engine_test.go delete mode 100644 pkg/services/alerting/eval_context.go delete mode 100644 pkg/services/alerting/eval_context_test.go delete mode 100644 pkg/services/alerting/eval_handler.go delete mode 100644 pkg/services/alerting/eval_handler_test.go delete mode 100644 pkg/services/alerting/extractor.go delete mode 100644 pkg/services/alerting/extractor_test.go delete mode 100644 pkg/services/alerting/interfaces.go delete mode 100644 pkg/services/alerting/models.go delete mode 100644 pkg/services/alerting/models/alert.go delete mode 100644 pkg/services/alerting/models/alert_notification.go delete mode 100644 pkg/services/alerting/models/alert_test.go delete mode 100644 pkg/services/alerting/notifier.go delete mode 100644 pkg/services/alerting/notifier_test.go delete mode 100644 pkg/services/alerting/notifiers/alertmanager.go delete mode 100644 pkg/services/alerting/notifiers/alertmanager_test.go delete mode 100644 pkg/services/alerting/notifiers/base.go delete mode 100644 pkg/services/alerting/notifiers/base_test.go delete mode 100644 pkg/services/alerting/notifiers/dingding.go delete mode 100644 pkg/services/alerting/notifiers/dingding_test.go delete mode 100644 pkg/services/alerting/notifiers/discord.go delete mode 100644 pkg/services/alerting/notifiers/discord_test.go delete mode 100644 pkg/services/alerting/notifiers/email.go delete mode 100644 pkg/services/alerting/notifiers/email_test.go delete mode 100644 pkg/services/alerting/notifiers/googlechat.go delete mode 100644 pkg/services/alerting/notifiers/googlechat_test.go delete mode 100644 pkg/services/alerting/notifiers/hipchat.go delete mode 100644 pkg/services/alerting/notifiers/hipchat_test.go delete mode 100644 pkg/services/alerting/notifiers/kafka.go delete mode 100644 pkg/services/alerting/notifiers/kafka_test.go delete mode 100644 pkg/services/alerting/notifiers/line.go delete mode 100644 pkg/services/alerting/notifiers/line_test.go delete mode 100644 pkg/services/alerting/notifiers/opsgenie.go delete mode 100644 pkg/services/alerting/notifiers/opsgenie_test.go delete mode 100644 pkg/services/alerting/notifiers/pagerduty.go delete mode 100644 pkg/services/alerting/notifiers/pagerduty_test.go delete mode 100644 pkg/services/alerting/notifiers/pushover.go delete mode 100644 pkg/services/alerting/notifiers/pushover_test.go delete mode 100644 pkg/services/alerting/notifiers/sensu.go delete mode 100644 pkg/services/alerting/notifiers/sensu_test.go delete mode 100644 pkg/services/alerting/notifiers/sensugo.go delete mode 100644 pkg/services/alerting/notifiers/sensugo_test.go delete mode 100644 pkg/services/alerting/notifiers/slack.go delete mode 100644 pkg/services/alerting/notifiers/slack_test.go delete mode 100644 pkg/services/alerting/notifiers/teams.go delete mode 100644 pkg/services/alerting/notifiers/teams_test.go delete mode 100644 pkg/services/alerting/notifiers/telegram.go delete mode 100644 pkg/services/alerting/notifiers/telegram_test.go delete mode 100644 pkg/services/alerting/notifiers/threema.go delete mode 100644 pkg/services/alerting/notifiers/threema_test.go delete mode 100644 pkg/services/alerting/notifiers/victorops.go delete mode 100644 pkg/services/alerting/notifiers/victorops_test.go delete mode 100644 pkg/services/alerting/notifiers/webhook.go delete mode 100644 pkg/services/alerting/notifiers/webhook_test.go delete mode 100644 pkg/services/alerting/reader.go delete mode 100644 pkg/services/alerting/result_handler.go delete mode 100644 pkg/services/alerting/rule.go delete mode 100644 pkg/services/alerting/rule_test.go delete mode 100644 pkg/services/alerting/scheduler.go delete mode 100644 pkg/services/alerting/service.go delete mode 100644 pkg/services/alerting/service_test.go delete mode 100644 pkg/services/alerting/store.go delete mode 100644 pkg/services/alerting/store_notification.go delete mode 100644 pkg/services/alerting/store_notification_test.go delete mode 100644 pkg/services/alerting/store_test.go delete mode 100644 pkg/services/alerting/test_notification.go delete mode 100644 pkg/services/alerting/test_rule.go delete mode 100644 pkg/services/alerting/testdata/collapsed-panels.json delete mode 100644 pkg/services/alerting/testdata/dash-without-id.json delete mode 100644 pkg/services/alerting/testdata/graphite-alert.json delete mode 100644 pkg/services/alerting/testdata/influxdb-alert.json delete mode 100644 pkg/services/alerting/testdata/panel-with-bad-query-id.json delete mode 100644 pkg/services/alerting/testdata/panel-with-datasource-ref.json delete mode 100644 pkg/services/alerting/testdata/panel-with-id-0.json delete mode 100644 pkg/services/alerting/testdata/panel-without-specified-datasource.json delete mode 100644 pkg/services/alerting/testdata/panels-missing-id.json delete mode 100644 pkg/services/alerting/testdata/settings/empty.json delete mode 100644 pkg/services/alerting/testdata/settings/one_condition.json delete mode 100644 pkg/services/alerting/testdata/settings/three_conditions.json delete mode 100644 pkg/services/alerting/testdata/settings/two_conditions.json delete mode 100644 pkg/services/alerting/testdata/v5-dashboard.json create mode 100644 pkg/services/anonymous/sortopts/sortopts.go rename pkg/services/{grafana-apiserver => apiserver}/README.md (94%) create mode 100644 pkg/services/apiserver/aggregator/README.md create mode 100644 pkg/services/apiserver/aggregator/aggregator.go create mode 100644 pkg/services/apiserver/aggregator/availableController.go create mode 100644 pkg/services/apiserver/aggregator/config.go create mode 100644 pkg/services/apiserver/aggregator/examples/autoregister/apiservices.yaml create mode 100644 pkg/services/apiserver/aggregator/examples/manual-test/apiservice.yaml create mode 100644 pkg/services/apiserver/aggregator/examples/manual-test/externalname.yaml create mode 100644 pkg/services/apiserver/aggregator/resolver.go create mode 100644 pkg/services/apiserver/auth/authenticator/provider.go create mode 100644 pkg/services/apiserver/auth/authenticator/signedinuser.go create mode 100644 pkg/services/apiserver/auth/authenticator/signedinuser_test.go rename pkg/services/{grafana-apiserver => apiserver}/auth/authorizer/impersonation.go (100%) rename pkg/services/{grafana-apiserver => apiserver}/auth/authorizer/impersonation_test.go (100%) rename pkg/services/{grafana-apiserver => apiserver}/auth/authorizer/org_id.go (95%) rename pkg/services/{grafana-apiserver => apiserver}/auth/authorizer/org_role.go (100%) rename pkg/services/{grafana-apiserver => apiserver}/auth/authorizer/provider.go (92%) rename pkg/services/{grafana-apiserver => apiserver}/auth/authorizer/stack_id.go (94%) create mode 100644 pkg/services/apiserver/config.go rename pkg/services/{grafana-apiserver => apiserver}/endpoints/request/namespace.go (94%) rename pkg/services/{grafana-apiserver => apiserver}/endpoints/request/namespace_test.go (90%) create mode 100644 pkg/services/apiserver/options/aggregator.go create mode 100644 pkg/services/apiserver/options/extra.go rename pkg/services/{grafana-apiserver => apiserver/options}/log.go (97%) create mode 100644 pkg/services/apiserver/options/options.go create mode 100644 pkg/services/apiserver/options/storage.go create mode 100644 pkg/services/apiserver/service.go create mode 100644 pkg/services/apiserver/standalone/factory.go create mode 100644 pkg/services/apiserver/standalone/runtime.go create mode 100644 pkg/services/apiserver/standalone/runtime_test.go rename pkg/services/{grafana-apiserver => apiserver}/storage/entity/restoptions.go (96%) create mode 100644 pkg/services/apiserver/storage/entity/selector.go rename pkg/services/{grafana-apiserver => apiserver}/storage/entity/storage.go (55%) rename pkg/services/{grafana-apiserver => apiserver}/storage/entity/utils.go (79%) rename pkg/services/{grafana-apiserver => apiserver}/storage/entity/utils_test.go (91%) rename pkg/services/{grafana-apiserver => apiserver}/utils/clientConfig.go (100%) create mode 100644 pkg/services/apiserver/utils/meta.go create mode 100644 pkg/services/apiserver/utils/meta_test.go rename pkg/services/{grafana-apiserver => apiserver}/utils/tableConverter.go (100%) rename pkg/services/{grafana-apiserver => apiserver}/utils/tableConverter_test.go (98%) rename pkg/services/{grafana-apiserver => apiserver}/utils/uids.go (100%) rename pkg/services/{grafana-apiserver => apiserver}/wireset.go (65%) create mode 100644 pkg/services/auth/idtest/mock.go delete mode 100644 pkg/services/authn/authnimpl/sync/permission_sync.go delete mode 100644 pkg/services/authn/authnimpl/sync/permission_sync_test.go create mode 100644 pkg/services/authn/authnimpl/sync/rbac_sync.go create mode 100644 pkg/services/authn/authnimpl/sync/rbac_sync_test.go create mode 100644 pkg/services/authn/clients/identity.go create mode 100644 pkg/services/cloudmigration/api/api.go create mode 100644 pkg/services/cloudmigration/api/api_test.go create mode 100644 pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go create mode 100644 pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go create mode 100644 pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go create mode 100644 pkg/services/cloudmigration/cloudmigrationimpl/metric.go create mode 100644 pkg/services/cloudmigration/cloudmigrationimpl/store.go create mode 100644 pkg/services/cloudmigration/cloudmigrationimpl/xorm_store.go create mode 100644 pkg/services/cloudmigration/cloudmigrationimpl/xorm_store_test.go create mode 100644 pkg/services/cloudmigration/cloudmigrations.go create mode 100644 pkg/services/cloudmigration/cloudmigrationtest/fake.go create mode 100644 pkg/services/cloudmigration/model.go create mode 100644 pkg/services/datasources/service/legacy.go delete mode 100644 pkg/services/extsvcauth/oauthserver/api/api.go delete mode 100644 pkg/services/extsvcauth/oauthserver/errors.go delete mode 100644 pkg/services/extsvcauth/oauthserver/external_service.go delete mode 100644 pkg/services/extsvcauth/oauthserver/external_service_test.go delete mode 100644 pkg/services/extsvcauth/oauthserver/models.go delete mode 100644 pkg/services/extsvcauth/oauthserver/oasimpl/aggregate_store.go delete mode 100644 pkg/services/extsvcauth/oauthserver/oasimpl/aggregate_store_test.go delete mode 100644 pkg/services/extsvcauth/oauthserver/oasimpl/introspection.go delete mode 100644 pkg/services/extsvcauth/oauthserver/oasimpl/service.go delete mode 100644 pkg/services/extsvcauth/oauthserver/oasimpl/service_test.go delete mode 100644 pkg/services/extsvcauth/oauthserver/oasimpl/session.go delete mode 100644 pkg/services/extsvcauth/oauthserver/oasimpl/token.go delete mode 100644 pkg/services/extsvcauth/oauthserver/oasimpl/token_test.go delete mode 100644 pkg/services/extsvcauth/oauthserver/oastest/fakes.go delete mode 100644 pkg/services/extsvcauth/oauthserver/oastest/store_mock.go delete mode 100644 pkg/services/extsvcauth/oauthserver/store/database.go delete mode 100644 pkg/services/extsvcauth/oauthserver/store/database_test.go delete mode 100644 pkg/services/extsvcauth/oauthserver/utils/utils.go delete mode 100644 pkg/services/extsvcauth/oauthserver/utils/utils_test.go delete mode 100644 pkg/services/featuremgmt/settings.go delete mode 100644 pkg/services/featuremgmt/settings_test.go delete mode 100644 pkg/services/featuremgmt/testdata/features.yaml delete mode 100644 pkg/services/featuremgmt/testdata/included.yaml create mode 100644 pkg/services/featuremgmt/toggles_gen.json delete mode 100644 pkg/services/grafana-apiserver/config.go delete mode 100644 pkg/services/grafana-apiserver/config_test.go delete mode 100644 pkg/services/grafana-apiserver/service.go delete mode 100644 pkg/services/libraryelements/model/model_test.go create mode 100644 pkg/services/ngalert/accesscontrol/fakes/rules.go create mode 100644 pkg/services/ngalert/api/api_notifications.go create mode 100644 pkg/services/ngalert/api/api_notifications_test.go create mode 100644 pkg/services/ngalert/api/generated_base_api_notifications.go create mode 100644 pkg/services/ngalert/api/notifications.go create mode 100644 pkg/services/ngalert/api/tooling/definitions/receivers.go create mode 100644 pkg/services/ngalert/api/tooling/definitions/time_intervals.go create mode 100644 pkg/services/ngalert/client/client.go create mode 100644 pkg/services/ngalert/client/client_test.go create mode 100644 pkg/services/ngalert/metrics/remote_alertmanager.go delete mode 100644 pkg/services/ngalert/migration/alert_rule.go delete mode 100644 pkg/services/ngalert/migration/alert_rule_test.go delete mode 100644 pkg/services/ngalert/migration/channel.go delete mode 100644 pkg/services/ngalert/migration/channel_test.go delete mode 100644 pkg/services/ngalert/migration/cond_trans.go delete mode 100644 pkg/services/ngalert/migration/cond_trans_test.go delete mode 100644 pkg/services/ngalert/migration/migration_test.go delete mode 100644 pkg/services/ngalert/migration/models.go delete mode 100644 pkg/services/ngalert/migration/models/alertmanager.go delete mode 100644 pkg/services/ngalert/migration/models/models.go delete mode 100644 pkg/services/ngalert/migration/models/models_test.go delete mode 100644 pkg/services/ngalert/migration/models/state.go delete mode 100644 pkg/services/ngalert/migration/permissions.go delete mode 100644 pkg/services/ngalert/migration/permissions_test.go delete mode 100644 pkg/services/ngalert/migration/securejsondata.go delete mode 100644 pkg/services/ngalert/migration/service.go delete mode 100644 pkg/services/ngalert/migration/service_test.go delete mode 100644 pkg/services/ngalert/migration/silences.go delete mode 100644 pkg/services/ngalert/migration/store/database.go delete mode 100644 pkg/services/ngalert/migration/store/testing.go delete mode 100644 pkg/services/ngalert/migration/template.go delete mode 100644 pkg/services/ngalert/migration/template_test.go delete mode 100644 pkg/services/ngalert/migration/testing.go delete mode 100644 pkg/services/ngalert/migration/ualert.go delete mode 100644 pkg/services/ngalert/migration/ualert_test.go create mode 100644 pkg/services/ngalert/models/errors.go create mode 100644 pkg/services/ngalert/models/notifications.go create mode 100644 pkg/services/ngalert/models/notifications_test.go create mode 100644 pkg/services/ngalert/models/receivers.go create mode 100644 pkg/services/ngalert/notifier/autogen_alertmanager.go create mode 100644 pkg/services/ngalert/notifier/autogen_alertmanager_test.go create mode 100644 pkg/services/ngalert/notifier/multiorg_alertmanager_remote_test.go create mode 100644 pkg/services/ngalert/notifier/receiver_svc.go create mode 100644 pkg/services/ngalert/notifier/receiver_svc_test.go rename pkg/services/ngalert/notifier/{receivers.go => testreceivers.go} (100%) rename pkg/services/ngalert/notifier/{receivers_test.go => testreceivers_test.go} (100%) create mode 100644 pkg/services/ngalert/notifier/validation.go create mode 100644 pkg/services/ngalert/provisioning/config_test.go create mode 100644 pkg/services/ngalert/provisioning/provisioning_test.go create mode 100644 pkg/services/ngalert/schedule/alert_rule.go create mode 100644 pkg/services/ngalert/schedule/alert_rule_test.go create mode 100644 pkg/services/ngalert/schedule/jitter.go create mode 100644 pkg/services/ngalert/schedule/jitter_test.go create mode 100644 pkg/services/ngalert/schedule/loaded_metrics_reader.go create mode 100644 pkg/services/ngalert/schedule/loaded_metrics_reader_test.go create mode 100644 pkg/services/ngalert/schedule/metrics.go rename pkg/services/ngalert/schedule/{fetcher_test.go => metrics_test.go} (92%) create mode 100644 pkg/services/ngalert/state/persister_async.go create mode 100644 pkg/services/ngalert/state/persister_async_test.go create mode 100644 pkg/services/ngalert/state/persister_noop.go create mode 100644 pkg/services/ngalert/state/persister_sync.go create mode 100644 pkg/services/ngalert/state/persister_sync_test.go create mode 100644 pkg/services/ngalert/tests/fakes/config.go create mode 100644 pkg/services/ngalert/tests/fakes/provisioning.go create mode 100644 pkg/services/ngalert/tests/fakes/receivers.go create mode 100644 pkg/services/oauthtoken/oauthtokentest/service_mock.go create mode 100644 pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware.go create mode 100644 pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware_test.go delete mode 100644 pkg/services/pluginsintegration/config/config.go create mode 100644 pkg/services/pluginsintegration/pluginconfig/config.go rename pkg/services/pluginsintegration/{config => pluginconfig}/config_test.go (97%) create mode 100644 pkg/services/pluginsintegration/pluginconfig/envvars.go rename pkg/{plugins/envvars => services/pluginsintegration/pluginconfig}/envvars_test.go (62%) create mode 100644 pkg/services/pluginsintegration/pluginconfig/fakes.go create mode 100644 pkg/services/pluginsintegration/pluginconfig/request.go create mode 100644 pkg/services/pluginsintegration/pluginconfig/request_test.go rename pkg/services/pluginsintegration/{config => pluginconfig}/tracing.go (97%) rename pkg/services/pluginsintegration/{config => pluginconfig}/tracing_test.go (98%) create mode 100644 pkg/services/pluginsintegration/pluginexternal/check.go create mode 100644 pkg/services/pluginsintegration/pluginexternal/check_test.go create mode 100644 pkg/services/pluginsintegration/renderer/renderer_test.go create mode 100644 pkg/services/pluginsintegration/renderer/testdata/plugins/app/plugin.json create mode 100644 pkg/services/pluginsintegration/renderer/testdata/plugins/datasource/plugin.json create mode 100644 pkg/services/pluginsintegration/renderer/testdata/plugins/renderer/plugin.json create mode 100644 pkg/services/pluginsintegration/renderer/testdata/plugins/secrets-manager/plugin.json delete mode 100644 pkg/services/provisioning/notifiers/alert_notifications.go delete mode 100644 pkg/services/provisioning/notifiers/config_reader.go delete mode 100644 pkg/services/provisioning/notifiers/config_reader_test.go delete mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/broken.yaml delete mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/not.yaml.text delete mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties-with-orgName/correct-properties-with-orgName.yaml delete mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties/correct-properties.yaml delete mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-1.yml delete mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-2.yaml delete mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/empty_folder/.gitignore delete mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/incorrect-settings/incorrect-settings.yaml delete mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/no-required-fields/no-required-fields.yaml delete mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/two-notifications/two-notifications.yaml delete mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/unknown-notifier/notification.yaml delete mode 100644 pkg/services/provisioning/notifiers/types.go create mode 100644 pkg/services/publicdashboards/service/common_test.go create mode 100644 pkg/services/publicdashboards/service/intervalv2/intervalv2.go rename pkg/{tsdb => services/publicdashboards/service}/intervalv2/intervalv2_test.go (54%) create mode 100644 pkg/services/sqlstore/database_config.go create mode 100644 pkg/services/sqlstore/database_config_test.go create mode 100644 pkg/services/sqlstore/migrations/accesscontrol/scope_migrator.go create mode 100644 pkg/services/sqlstore/migrations/accesscontrol/test/dashbord_permission_migrator_test.go create mode 100644 pkg/services/sqlstore/migrations/accesscontrol/test/scope_migrator_test.go create mode 100644 pkg/services/sqlstore/migrations/accesscontrol/test/seed_assign_mig_test.go create mode 100644 pkg/services/sqlstore/migrations/cloud_migrations.go delete mode 100644 pkg/services/sqlstore/migrations/oauthserver/migrations.go create mode 100644 pkg/services/sqlstore/migrations/ualert/rule_notification_settings_mig.go create mode 100644 pkg/services/sqlstore/migrations/user/service_account_multiple_org_login_migrator.go create mode 100644 pkg/services/sqlstore/migrations/user/test/service_account_test.go create mode 100644 pkg/services/sqlstore/migrations/user/test/user_test.go create mode 100644 pkg/services/ssosettings/ssosettingsimpl/metrics.go create mode 100644 pkg/services/ssosettings/ssosettingsimpl/usage_stats.go create mode 100644 pkg/services/ssosettings/ssosettingsimpl/usage_stats_test.go create mode 100644 pkg/services/ssosettings/ssosettingstests/reloadable_mock.go create mode 100644 pkg/services/ssosettings/strategies/saml_strategy.go create mode 100644 pkg/services/ssosettings/strategies/saml_strategy_test.go create mode 100644 pkg/services/ssosettings/validation/oauth_validators.go create mode 100644 pkg/services/ssosettings/validation/oauth_validators_test.go create mode 100644 pkg/services/ssosettings/validation/validator.go create mode 100644 pkg/services/store/entity/db/dbimpl/dbimpl.go rename pkg/services/store/entity/{ => db}/migrations/entity_store_mig.go (94%) rename pkg/services/store/entity/{ => db}/migrations/migrator.go (91%) create mode 100644 pkg/services/store/entity/grpc/authenticator.go create mode 100644 pkg/services/store/entity/sqlstash/broadcaster.go create mode 100644 pkg/services/store/entity/sqlstash/broadcaster_test.go create mode 100644 pkg/services/store/entity/sqlstash/sql_storage_server_test.go rename pkg/services/store/entity/tests/{common.go => common_test.go} (82%) delete mode 100644 pkg/services/team/model_test.go create mode 100644 pkg/services/temp_user/tempusertest/fake.go create mode 100644 pkg/services/user/password.go create mode 100644 pkg/services/user/password_test.go create mode 100644 pkg/services/user/userimpl/verifier.go create mode 100644 pkg/services/user/userimpl/verifier_test.go create mode 100644 pkg/services/user/usertest/mock.go create mode 100644 pkg/setting/setting_auth_proxy.go create mode 100644 pkg/setting/setting_jwt.go create mode 100644 pkg/tests/api/alerting/api_notifications_time_interval_test.go create mode 100644 pkg/tests/api/alerting/test-data/hysteresis_rule.json create mode 100644 pkg/tests/api/alerting/test-data/rule-notification-settings-1-post.json create mode 100644 pkg/tests/api/alerting/test-data/rulegroup-3-export.json create mode 100644 pkg/tests/api/alerting/test-data/rulegroup-3-get.json create mode 100644 pkg/tests/api/alerting/test-data/rulegroup-3-post.json create mode 100644 pkg/tests/api/folders/api_folder_test.go create mode 100644 pkg/tests/apis/dashboard/dashboards_test.go create mode 100644 pkg/tests/apis/dashboard/testdata/dashboard-generate.yaml create mode 100644 pkg/tests/apis/dashboard/testdata/dashboard-test-apply.yaml create mode 100644 pkg/tests/apis/dashboard/testdata/dashboard-test-create.yaml create mode 100644 pkg/tests/apis/dashboard/testdata/dashboard-test-replace.yaml create mode 100644 pkg/tests/apis/dashboardsnapshot/snapshots_test.go create mode 100644 pkg/tests/apis/datasource/testdata_test.go rename pkg/tests/apis/{folders => folder}/folders_test.go (75%) rename pkg/tests/apis/{folders => folder}/testdata/folder-generate.yaml (82%) create mode 100644 pkg/tests/apis/query/query_test.go create mode 100644 pkg/tests/testsuite/testsuite.go create mode 100644 pkg/tsdb/azuremonitor/standalone/datasource.go create mode 100644 pkg/tsdb/azuremonitor/standalone/main.go create mode 100644 pkg/tsdb/cloud-monitoring/converter/converter.go create mode 100644 pkg/tsdb/cloud-monitoring/standalone/datasource.go create mode 100644 pkg/tsdb/cloud-monitoring/standalone/main.go create mode 100644 pkg/tsdb/cloud-monitoring/time/interval.go create mode 100644 pkg/tsdb/cloudwatch/features/features.go create mode 100644 pkg/tsdb/cloudwatch/utils/metrics.go create mode 100644 pkg/tsdb/elasticsearch/healthcheck.go create mode 100644 pkg/tsdb/elasticsearch/healthcheck_test.go delete mode 100644 pkg/tsdb/grafana-postgresql-datasource/locker.go delete mode 100644 pkg/tsdb/grafana-postgresql-datasource/locker_test.go create mode 100644 pkg/tsdb/grafana-postgresql-datasource/postgres_snapshot_test.go create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/no_rows.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/no_rows.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/simple.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/simple.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_bigint.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_bigint.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_double.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_double.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_integer.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_integer.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_real.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_real.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_char.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_char.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_numeric.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_numeric.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_other.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_other.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/7x_compat_metric_label.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/7x_compat_metric_label.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/convert_to_float64.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/convert_to_float64.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/convert_to_float64_not.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/convert_to_float64_not.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_null.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_null.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_previous.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_previous.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_value.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_value.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/no_rows_long.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/no_rows_long.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/no_rows_wide.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/no_rows_wide.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/simple.golden.jsonc create mode 100644 pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/simple.sql create mode 100644 pkg/tsdb/grafana-postgresql-datasource/tls/tls.go create mode 100644 pkg/tsdb/grafana-postgresql-datasource/tls/tls_loader.go create mode 100644 pkg/tsdb/grafana-postgresql-datasource/tls/tls_test.go create mode 100644 pkg/tsdb/grafana-postgresql-datasource/tls/tls_test_helpers.go delete mode 100644 pkg/tsdb/grafana-postgresql-datasource/tlsmanager.go delete mode 100644 pkg/tsdb/grafana-postgresql-datasource/tlsmanager_test.go create mode 100644 pkg/tsdb/grafana-pyroscope-datasource/standalone/datasource.go create mode 100644 pkg/tsdb/grafana-pyroscope-datasource/standalone/main.go create mode 100644 pkg/tsdb/influxdb/influxql/testdata/show_diagnostics.json create mode 100644 pkg/tsdb/influxdb/influxql/testdata/show_diagnostics.time_series.golden.jsonc create mode 100644 pkg/tsdb/influxdb/influxql/util/util_test.go delete mode 100644 pkg/tsdb/intervalv2/intervalv2.go create mode 100644 pkg/tsdb/legacydata/conversions.go create mode 100644 pkg/tsdb/loki/testdata/streams_structured_metadata_2.golden.jsonc create mode 100644 pkg/tsdb/loki/testdata/streams_structured_metadata_2.json create mode 100644 pkg/tsdb/loki/testdata_dataplane/streams_structured_metadata_2.golden.jsonc create mode 100644 pkg/tsdb/loki/testdata_dataplane/streams_structured_metadata_2.json create mode 100644 pkg/tsdb/loki/testdata_logs_dataplane/streams_structured_metadata_2.golden.jsonc create mode 100644 pkg/tsdb/loki/testdata_logs_dataplane/streams_structured_metadata_2.json create mode 100644 pkg/tsdb/loki/testdata_metric_dataplane/streams_structured_metadata_2.golden.jsonc create mode 100644 pkg/tsdb/loki/testdata_metric_dataplane/streams_structured_metadata_2.json delete mode 100644 pkg/tsdb/prometheus/client/transport_test.go delete mode 100644 pkg/tsdb/prometheus/kinds/dataquery/types_dataquery_gen.go delete mode 100644 pkg/tsdb/sqleng/proxyutil/proxy_test_util.go delete mode 100644 pkg/tsdb/sqleng/proxyutil/proxy_util.go create mode 100644 pkg/tsdb/tempo/standalone/datasource.go create mode 100644 pkg/tsdb/tempo/standalone/main.go delete mode 100644 pkg/util/converter/jsonitere/jsonitere.go create mode 100644 pkg/util/json_test.go delete mode 100644 pkg/util/maputil/maputil.go create mode 100644 pkg/util/osutil/osutil.go create mode 100644 pkg/util/osutil/osutil_test.go create mode 100644 pkg/util/xorm/xorm_test.go create mode 100644 playwright.config.ts delete mode 100644 plugins-bundled/internal/input-datasource/.gitignore delete mode 100644 plugins-bundled/internal/input-datasource/README.md delete mode 100644 plugins-bundled/internal/input-datasource/__mocks__/d3-interpolate.ts delete mode 100644 plugins-bundled/internal/input-datasource/jest.config.js delete mode 100644 plugins-bundled/internal/input-datasource/package.json delete mode 100644 plugins-bundled/internal/input-datasource/src/InputConfigEditor.tsx delete mode 100644 plugins-bundled/internal/input-datasource/src/InputDatasource.test.ts delete mode 100644 plugins-bundled/internal/input-datasource/src/InputDatasource.ts delete mode 100644 plugins-bundled/internal/input-datasource/src/InputQueryEditor.tsx delete mode 100644 plugins-bundled/internal/input-datasource/src/img/input.svg delete mode 100644 plugins-bundled/internal/input-datasource/src/module.ts delete mode 100644 plugins-bundled/internal/input-datasource/src/plugin.json delete mode 100644 plugins-bundled/internal/input-datasource/src/testHelpers.ts delete mode 100644 plugins-bundled/internal/input-datasource/src/types.ts delete mode 100644 plugins-bundled/internal/input-datasource/src/utils.ts delete mode 100644 plugins-bundled/internal/input-datasource/tsconfig.json delete mode 100644 plugins-bundled/internal/input-datasource/webpack.config.ts create mode 100644 project.json delete mode 100644 public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.test.tsx delete mode 100644 public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx delete mode 100644 public/app/core/components/AppChrome/DockedMegaMenu/utils.test.ts delete mode 100644 public/app/core/components/AppChrome/DockedMegaMenu/utils.ts rename public/app/core/components/AppChrome/{DockedMegaMenu => MegaMenu}/FeatureHighlight.tsx (100%) rename public/app/core/components/AppChrome/{DockedMegaMenu => MegaMenu}/MegaMenuItem.tsx (96%) rename public/app/core/components/AppChrome/{DockedMegaMenu => MegaMenu}/MegaMenuItemText.tsx (100%) delete mode 100644 public/app/core/components/AppChrome/MegaMenu/NavBarItemIcon.tsx delete mode 100644 public/app/core/components/AppChrome/MegaMenu/NavBarMenu.tsx delete mode 100644 public/app/core/components/AppChrome/MegaMenu/NavBarMenuItem.tsx delete mode 100644 public/app/core/components/AppChrome/MegaMenu/NavBarMenuItemWrapper.tsx delete mode 100644 public/app/core/components/AppChrome/MegaMenu/NavBarMenuSection.tsx delete mode 100644 public/app/core/components/AppChrome/MegaMenu/NavFeatureHighlight.tsx create mode 100644 public/app/core/components/AppChrome/ReturnToPrevious/DismissableButton.tsx create mode 100644 public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.test.tsx create mode 100644 public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.tsx delete mode 100644 public/app/core/components/AppChrome/SectionNav/SectionNav.tsx delete mode 100644 public/app/core/components/AppChrome/SectionNav/SectionNavItem.test.tsx delete mode 100644 public/app/core/components/AppChrome/SectionNav/SectionNavItem.tsx delete mode 100644 public/app/core/components/AppChrome/SectionNav/SectionNavToggle.tsx delete mode 100644 public/app/core/components/ConfigDescriptionLink.tsx delete mode 100644 public/app/core/components/Divider.tsx create mode 100644 public/app/core/components/FlaggedScroller.tsx create mode 100644 public/app/core/components/Form/Form.tsx create mode 100644 public/app/core/components/GrotNotFound/GrotNotFound.tsx create mode 100644 public/app/core/components/GrotNotFound/useMousePosition.ts create mode 100644 public/app/core/components/NestedFolderPicker/useFoldersQuery.ts rename public/app/core/components/NestedFolderPicker/{hooks.ts => useTreeInteractions.ts} (91%) create mode 100644 public/app/core/components/OptionsUI/fieldColor.test.tsx create mode 100644 public/app/core/components/ValidationLabels/ValidationLabels.tsx create mode 100644 public/app/core/internationalization/index.test.tsx create mode 100644 public/app/core/monacoEnv.ts create mode 100644 public/app/core/services/NewFrontendAssetsChecker.test.ts create mode 100644 public/app/core/services/NewFrontendAssetsChecker.ts create mode 100644 public/app/features/admin/AdminFeatureTogglesAPI.test.ts create mode 100644 public/app/features/admin/migrate-to-cloud/MigrateToCloud.tsx create mode 100644 public/app/features/admin/migrate-to-cloud/api.ts create mode 100644 public/app/features/admin/migrate-to-cloud/cloud/InfoPane.tsx create mode 100644 public/app/features/admin/migrate-to-cloud/cloud/MigrationTokenPane/DeleteMigrationTokenModal.tsx create mode 100644 public/app/features/admin/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenModal.tsx create mode 100644 public/app/features/admin/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenPane.tsx create mode 100644 public/app/features/admin/migrate-to-cloud/cloud/MigrationTokenPane/TokenStatus.tsx create mode 100644 public/app/features/admin/migrate-to-cloud/cloud/Page.tsx create mode 100644 public/app/features/admin/migrate-to-cloud/fixtures/mswAPI.ts create mode 100644 public/app/features/admin/migrate-to-cloud/onprem/DisconnectModal.tsx create mode 100644 public/app/features/admin/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx create mode 100644 public/app/features/admin/migrate-to-cloud/onprem/EmptyState/CallToAction/ConnectModal.tsx create mode 100644 public/app/features/admin/migrate-to-cloud/onprem/EmptyState/EmptyState.tsx create mode 100644 public/app/features/admin/migrate-to-cloud/onprem/EmptyState/InfoPaneLeft.tsx create mode 100644 public/app/features/admin/migrate-to-cloud/onprem/EmptyState/InfoPaneRight.tsx create mode 100644 public/app/features/admin/migrate-to-cloud/onprem/Page.tsx create mode 100644 public/app/features/admin/migrate-to-cloud/onprem/ResourcesTable.tsx create mode 100644 public/app/features/admin/migrate-to-cloud/shared/InfoItem.tsx delete mode 100644 public/app/features/alerting/AlertHowToModal.tsx delete mode 100644 public/app/features/alerting/AlertRuleItem.test.tsx delete mode 100644 public/app/features/alerting/AlertRuleItem.tsx delete mode 100644 public/app/features/alerting/AlertRuleList.test.tsx delete mode 100644 public/app/features/alerting/AlertRuleList.tsx delete mode 100644 public/app/features/alerting/AlertTab.tsx delete mode 100644 public/app/features/alerting/AlertTabCtrl.test.ts delete mode 100644 public/app/features/alerting/AlertTabCtrl.ts delete mode 100644 public/app/features/alerting/AlertTabIndex.tsx delete mode 100644 public/app/features/alerting/EditNotificationChannelPage.tsx delete mode 100644 public/app/features/alerting/FeatureTogglePage.tsx delete mode 100644 public/app/features/alerting/NewNotificationChannelPage.tsx delete mode 100644 public/app/features/alerting/NotificationsListPage.tsx delete mode 100644 public/app/features/alerting/StateHistory.tsx delete mode 100644 public/app/features/alerting/TestRuleResult.test.tsx delete mode 100644 public/app/features/alerting/TestRuleResult.tsx delete mode 100644 public/app/features/alerting/components/BasicSettings.tsx delete mode 100644 public/app/features/alerting/components/ChannelSettings.tsx delete mode 100644 public/app/features/alerting/components/DeprecationNotice.tsx delete mode 100644 public/app/features/alerting/components/NotificationChannelForm.tsx delete mode 100644 public/app/features/alerting/components/NotificationChannelOptions.tsx delete mode 100644 public/app/features/alerting/components/NotificationSettings.tsx delete mode 100644 public/app/features/alerting/components/OptionElement.tsx delete mode 100644 public/app/features/alerting/getAlertingValidationMessage.test.ts delete mode 100644 public/app/features/alerting/getAlertingValidationMessage.ts delete mode 100644 public/app/features/alerting/partials/alert_tab.html delete mode 100644 public/app/features/alerting/state/actions.ts create mode 100644 public/app/features/alerting/unified/AlertingNotEnabled.tsx delete mode 100644 public/app/features/alerting/unified/MoreActionsRuleButtons.tsx create mode 100644 public/app/features/alerting/unified/__snapshots__/NotificationPolicies.test.tsx.snap create mode 100644 public/app/features/alerting/unified/api/dataSourcesApi.ts create mode 100644 public/app/features/alerting/unified/api/upgradeApi.ts rename public/app/features/alerting/{ => unified}/components/ConditionalWrap.tsx (100%) delete mode 100644 public/app/features/alerting/unified/components/Expression.test.tsx create mode 100644 public/app/features/alerting/unified/components/WithReturnButton.tsx create mode 100644 public/app/features/alerting/unified/components/contact-points/utils.test.ts create mode 100644 public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/descriptions.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/Actions.tsx rename public/app/features/alerting/unified/components/rule-viewer/{v2 => }/DeleteModal.tsx (90%) create mode 100644 public/app/features/alerting/unified/components/rule-viewer/FederatedRuleWarning.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/RuleContext.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx delete mode 100644 public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx delete mode 100644 public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/StateBadges.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/__mocks__/server.ts create mode 100644 public/app/features/alerting/unified/components/rule-viewer/tabs/Query/DataSourceModelPreview.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/tabs/Query/LokiQueryPreview.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/tabs/Query/PrometheusQueryPreview.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/tabs/Query/SQLQueryPreview.tsx delete mode 100644 public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx create mode 100644 public/app/features/alerting/unified/initAlerting.tsx rename public/app/features/alerting/unified/insights/grafana/{Firing.tsx => Active.tsx} (86%) create mode 100644 public/app/features/alerting/unified/integration/AlertRulesDrawer.tsx create mode 100644 public/app/features/alerting/unified/integration/AlertRulesDrawerContent.tsx create mode 100644 public/app/features/alerting/unified/integration/AlertRulesToolbarButton.tsx create mode 100644 public/app/features/alerting/unified/utils/__snapshots__/routeTree.test.ts.snap rename public/app/features/alerting/{ => unified}/utils/dataSourceFromExpression.ts (100%) create mode 100644 public/app/features/alerting/unified/utils/routeTree.test.ts delete mode 100644 public/app/features/alerting/utils/notificationChannel.test.ts delete mode 100644 public/app/features/alerting/utils/notificationChannels.ts create mode 100644 public/app/features/auth-config/AuthDrawer.test.tsx create mode 100644 public/app/features/auth-config/AuthDrawer.tsx create mode 100644 public/app/features/auth-config/FieldRenderer.tsx delete mode 100644 public/app/features/auth-config/fields.ts create mode 100644 public/app/features/auth-config/fields.tsx create mode 100644 public/app/features/commandPalette/EmptyState.tsx create mode 100644 public/app/features/dashboard-scene/embedding/EmbeddedDashboard.tsx create mode 100644 public/app/features/dashboard-scene/embedding/EmbeddedDashboardLazy.tsx create mode 100644 public/app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage.tsx create mode 100644 public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.test.tsx create mode 100644 public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.tsx create mode 100644 public/app/features/dashboard-scene/inspect/HelpWizard/SupportSnapshotService.test.ts create mode 100644 public/app/features/dashboard-scene/inspect/HelpWizard/SupportSnapshotService.ts rename public/app/features/{dashboard/components => dashboard-scene/inspect}/HelpWizard/randomizer.test.ts (100%) rename public/app/features/{dashboard/components => dashboard-scene/inspect}/HelpWizard/randomizer.ts (100%) create mode 100644 public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts delete mode 100644 public/app/features/dashboard-scene/pages/PanelEditPage.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/LibraryVizPanelInfo.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/PanelDataPane/NewAlertRuleButton.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.test.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/PanelDataPane/TransformationsDrawer.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/PanelDataPane/__snapshots__/PanelDataAlertingTab.test.tsx.snap create mode 100644 public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts delete mode 100644 public/app/features/dashboard-scene/panel-edit/PanelEditorUrlSync.ts create mode 100644 public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/SaveLibraryVizPanelModal.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/ShareDataProvider.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/splitter/useSnappingSplitter.ts delete mode 100644 public/app/features/dashboard-scene/panel-edit/testfiles/testDashboard.json create mode 100644 public/app/features/dashboard-scene/panel-edit/testfiles/testDashboard.ts create mode 100644 public/app/features/dashboard-scene/saving/DashboardPrompt.test.tsx create mode 100644 public/app/features/dashboard-scene/saving/DashboardPrompt.tsx create mode 100644 public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts create mode 100644 public/app/features/dashboard-scene/saving/DetectChangesWorker.ts create mode 100644 public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx create mode 100644 public/app/features/dashboard-scene/saving/SaveDashboardDrawer.test.tsx create mode 100644 public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx create mode 100644 public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx create mode 100644 public/app/features/dashboard-scene/saving/SaveProvisionedDashboardForm.tsx create mode 100644 public/app/features/dashboard-scene/saving/__mocks__/createDetectChangesWorker.ts create mode 100644 public/app/features/dashboard-scene/saving/createDetectChangesWorker.ts create mode 100644 public/app/features/dashboard-scene/saving/getDashboardChanges.ts create mode 100644 public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts create mode 100644 public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.ts create mode 100644 public/app/features/dashboard-scene/saving/getSaveDashboardChange.ts create mode 100644 public/app/features/dashboard-scene/saving/shared.tsx create mode 100644 public/app/features/dashboard-scene/saving/useSaveDashboard.ts create mode 100644 public/app/features/dashboard-scene/scene/AddLibraryPanelWidget.test.tsx create mode 100644 public/app/features/dashboard-scene/scene/AddLibraryPanelWidget.tsx create mode 100644 public/app/features/dashboard-scene/scene/GoToSnapshotOriginButton.test.tsx create mode 100644 public/app/features/dashboard-scene/scene/GoToSnapshotOriginButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/LibraryVizPanel.test.ts create mode 100644 public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx delete mode 100644 public/app/features/dashboard-scene/scene/ShareQueryDataProvider.test.ts delete mode 100644 public/app/features/dashboard-scene/scene/ShareQueryDataProvider.ts create mode 100644 public/app/features/dashboard-scene/scene/UnlinkLibraryPanelModal.tsx rename public/app/features/{library-panels/components/UnlinkModal => dashboard-scene/scene}/UnlinkModal.tsx (100%) create mode 100644 public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx create mode 100644 public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx create mode 100644 public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx create mode 100644 public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx delete mode 100644 public/app/features/dashboard-scene/serialization/SaveDashboardDrawer.tsx create mode 100644 public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts create mode 100644 public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/DashboardLinksEditView.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/JsonModelEditView.tsx create mode 100644 public/app/features/dashboard-scene/settings/PermissionsEditView.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/PermissionsEditView.tsx create mode 100644 public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/VersionsEditView.tsx rename public/app/features/{dashboard/components/AnnotationSettings => dashboard-scene/settings/annotations}/AngularEditorLoader.tsx (100%) create mode 100644 public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsEdit.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsEdit.tsx create mode 100644 public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsList.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsList.tsx create mode 100644 public/app/features/dashboard-scene/settings/annotations/index.tsx create mode 100644 public/app/features/dashboard-scene/settings/links/DashboardLinkForm.tsx create mode 100644 public/app/features/dashboard-scene/settings/links/DashboardLinkList.tsx create mode 100644 public/app/features/dashboard-scene/settings/links/utils.ts create mode 100644 public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/AdHocVariableForm.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/AdHocVariableForm.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/ConstantVariableForm.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/DataSourceVariableForm.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/GroupByVariableForm.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/GroupByVariableForm.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/IntervalVariableForm.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/QueryEditor.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/SelectionOptionsForm.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/TextBoxVariableForm.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/TextBoxVariableForm.tsx rename public/app/features/{variables/editor => dashboard-scene/settings/variables/components}/VariableCheckboxField.tsx (82%) rename public/app/features/{variables/editor => dashboard-scene/settings/variables/components}/VariableHideSelect.tsx (100%) rename public/app/features/{variables/editor => dashboard-scene/settings/variables/components}/VariableLegend.tsx (100%) rename public/app/features/{variables/editor => dashboard-scene/settings/variables/components}/VariableSelectField.tsx (63%) rename public/app/features/{variables/editor => dashboard-scene/settings/variables/components}/VariableTextAreaField.tsx (73%) rename public/app/features/{variables/editor => dashboard-scene/settings/variables/components}/VariableTextField.tsx (88%) create mode 100644 public/app/features/dashboard-scene/settings/variables/components/VariableTypeSelect.tsx rename public/app/features/{variables/editor => dashboard-scene/settings/variables/components}/VariableValuesPreview.tsx (81%) create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/AdHocFiltersVariableEditor.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/AdHocFiltersVariableEditor.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/ConstantVariableEditor.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/ConstantVariableEditor.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/DataSourceVariableEditor.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/DataSourceVariableEditor.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/GroupByVariableEditor.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/GroupByVariableEditor.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/IntervalVariableEditor.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/IntervalVariableEditor.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/TextBoxVariableEditor.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/TextBoxVariableEditor.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/utils.test.ts create mode 100644 public/app/features/dashboard-scene/settings/variables/utils.ts rename public/app/features/{dashboard/components/VersionHistory => dashboard-scene/settings/version-history}/DiffGroup.tsx (78%) rename public/app/features/{dashboard/components/VersionHistory => dashboard-scene/settings/version-history}/DiffTitle.tsx (50%) rename public/app/features/{dashboard/components/VersionHistory => dashboard-scene/settings/version-history}/DiffValues.tsx (73%) rename public/app/features/{dashboard/components/VersionHistory => dashboard-scene/settings/version-history}/DiffViewer.tsx (90%) rename public/app/features/{dashboard/components/VersionHistory => dashboard-scene/settings/version-history}/HistorySrv.test.ts (56%) create mode 100644 public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts create mode 100644 public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx rename public/app/features/{dashboard/components/VersionHistory => dashboard-scene/settings/version-history}/VersionHistoryButtons.tsx (100%) create mode 100644 public/app/features/dashboard-scene/settings/version-history/VersionHistoryComparison.tsx rename public/app/features/{dashboard/components/VersionHistory => dashboard-scene/settings/version-history}/VersionHistoryHeader.tsx (85%) create mode 100644 public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx rename public/app/features/{dashboard/components/VersionHistory => dashboard-scene/settings/version-history}/__mocks__/dashboardHistoryMocks.ts (100%) rename public/app/features/{dashboard/components/VersionHistory => dashboard-scene/settings/version-history}/index.ts (100%) rename public/app/features/{dashboard/components/VersionHistory => dashboard-scene/settings/version-history}/utils.test.ts (97%) rename public/app/features/{dashboard/components/VersionHistory => dashboard-scene/settings/version-history}/utils.ts (94%) create mode 100644 public/app/features/dashboard-scene/solo/SoloPanelPage.tsx create mode 100644 public/app/features/dashboard-scene/solo/useSoloPanel.ts create mode 100644 public/app/features/dashboard-scene/utils/PanelModelCompatibilityWrapper.ts delete mode 100644 public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx delete mode 100644 public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx delete mode 100644 public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss delete mode 100644 public/app/features/dashboard/components/AddPanelWidget/index.ts rename public/app/features/dashboard/components/TransformationsEditor/{TransformationEditorHelperModal.test.tsx => TransformationEditorHelpDisplay.test.tsx} (77%) rename public/app/features/dashboard/components/TransformationsEditor/{TransformationEditorHelperModal.tsx => TransformationEditorHelpDisplay.tsx} (59%) delete mode 100644 public/app/features/dashboard/components/VersionHistory/HistorySrv.ts create mode 100644 public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts delete mode 100644 public/app/features/datasources/components/picker/DataSourceDropdown.tsx rename public/app/features/datasources/components/picker/{DataSourceDropdown.test.tsx => DataSourcePicker.test.tsx} (92%) delete mode 100644 public/app/features/datasources/components/picker/popperModifiers.ts create mode 100644 public/app/features/explore/Logs/LogsTableActiveFields.tsx create mode 100644 public/app/features/explore/Logs/LogsTableAvailableFields.tsx create mode 100644 public/app/features/explore/Logs/LogsTableEmptyFields.tsx delete mode 100644 public/app/features/explore/Logs/LogsTableNavColumn.tsx create mode 100644 public/app/features/explore/Logs/LogsTableNavField.tsx rename public/app/features/explore/{ => PrometheusListView}/utils/getRawPrometheusListItemsFromDataFrame.test.ts (98%) rename public/app/features/explore/{ => PrometheusListView}/utils/getRawPrometheusListItemsFromDataFrame.ts (95%) delete mode 100644 public/app/features/explore/TraceView/components/common/Divider.tsx delete mode 100644 public/app/features/explore/TraceView/components/model/span.tsx delete mode 100644 public/app/features/explore/TraceView/components/types/TTraceDiffState.tsx delete mode 100644 public/app/features/explore/TraceView/components/types/config.tsx delete mode 100644 public/app/features/explore/TraceView/components/uberUtilityStyles.ts create mode 100644 public/app/features/explore/hooks/useStateSync/external.utils.ts create mode 100644 public/app/features/explore/hooks/useStateSync/internal.utils.ts create mode 100644 public/app/features/explore/hooks/useStateSync/synchronizer/fromURL.ts create mode 100644 public/app/features/explore/hooks/useStateSync/synchronizer/init.ts create mode 100644 public/app/features/explore/hooks/useStateSync/synchronizer/toURL.ts create mode 100644 public/app/features/explore/utils/supplementaryQueries_legacy.test.ts create mode 100644 public/app/features/expressions/components/SqlExpr.tsx create mode 100644 public/app/features/expressions/components/__snapshots__/hysteresis.test.ts.snap create mode 100644 public/app/features/expressions/components/hysteresis.test.ts create mode 100644 public/app/features/expressions/components/thresholdReducer.ts create mode 100644 public/app/features/live/info.ts create mode 100644 public/app/features/logs/components/InfiniteScroll.test.tsx create mode 100644 public/app/features/logs/components/InfiniteScroll.tsx rename public/app/features/logs/components/{log-context => }/LoadingIndicator.tsx (69%) delete mode 100644 public/app/features/logs/components/log-context/types.ts create mode 100644 public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithDataSource.test.tsx create mode 100644 public/app/features/plugins/loader/utils.test.ts delete mode 100644 public/app/features/plugins/sql/.eslintrc delete mode 100644 public/app/features/plugins/sql/index.ts rename public/app/features/query/components/{QueryEditorRow.test.ts => QueryEditorRow.test.tsx} (72%) delete mode 100644 public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.test.ts delete mode 100644 public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.ts create mode 100644 public/app/features/query/utils.ts delete mode 100644 public/app/features/scenes/SceneListPage.tsx delete mode 100644 public/app/features/scenes/ScenePage.tsx delete mode 100644 public/app/features/scenes/apps/GrafanaMonitoringApp.tsx delete mode 100644 public/app/features/scenes/apps/SceneRadioToggle.tsx delete mode 100644 public/app/features/scenes/apps/SceneSearchBox.tsx delete mode 100644 public/app/features/scenes/apps/scenes.tsx delete mode 100644 public/app/features/scenes/apps/traffic.tsx delete mode 100644 public/app/features/scenes/apps/transforms.ts delete mode 100644 public/app/features/scenes/apps/utils.ts delete mode 100644 public/app/features/scenes/scenes/gridMultiTimeRange.tsx delete mode 100644 public/app/features/scenes/scenes/gridMultiple.tsx delete mode 100644 public/app/features/scenes/scenes/gridWithMultipleData.tsx delete mode 100644 public/app/features/scenes/scenes/index.tsx delete mode 100644 public/app/features/scenes/scenes/queries.ts delete mode 100644 public/app/features/scenes/scenes/queryVariableDemo.tsx delete mode 100644 public/app/features/scenes/scenes/repeatingPanels.tsx delete mode 100644 public/app/features/scenes/scenes/sceneWithRows.tsx delete mode 100644 public/app/features/scenes/scenes/transformations.tsx delete mode 100644 public/app/features/scenes/scenes/variablesDemo.tsx rename public/app/features/trails/{ => ActionTabs}/AddToFiltersGraphAction.tsx (77%) rename public/app/features/trails/{ => ActionTabs}/BreakdownScene.tsx (70%) rename public/app/features/trails/{ => ActionTabs}/ByFrameRepeater.tsx (100%) rename public/app/features/trails/{ => ActionTabs}/LayoutSwitcher.tsx (100%) create mode 100644 public/app/features/trails/ActionTabs/LogsScene.tsx create mode 100644 public/app/features/trails/ActionTabs/MetricOverviewScene.tsx create mode 100644 public/app/features/trails/ActionTabs/RelatedMetricsScene.tsx create mode 100644 public/app/features/trails/ActionTabs/utils.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/AutoQueryEngine.test.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/graph-builders/heatmap.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/graph-builders/percentiles.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/graph-builders/simple.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/graph-builders/types.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/previewPanel.test.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/previewPanel.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/query-generators/common/generator.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/query-generators/common/index.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/query-generators/common/queries.test.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/query-generators/common/queries.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/query-generators/common/rules.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/query-generators/common/types.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/query-generators/histogram.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/query-generators/index.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/query-generators/summary.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/query-generators/types.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/types.ts create mode 100644 public/app/features/trails/AutomaticMetricQueries/units.ts create mode 100644 public/app/features/trails/BreakdownLabelSelector.tsx delete mode 100644 public/app/features/trails/DataTrailDrawer.tsx create mode 100644 public/app/features/trails/Integrations/DataTrailEmbedded.tsx create mode 100644 public/app/features/trails/Integrations/SceneDrawer.tsx create mode 100644 public/app/features/trails/Integrations/dashboardIntegration.ts create mode 100644 public/app/features/trails/Integrations/getQueryMetrics.ts create mode 100644 public/app/features/trails/Integrations/utils.ts create mode 100644 public/app/features/trails/MetricCategory/MetricCategoryCascader.tsx create mode 100644 public/app/features/trails/MetricCategory/useMetricCategories.ts create mode 100644 public/app/features/trails/MetricGraphScene.tsx create mode 100644 public/app/features/trails/MetricsHeader.tsx delete mode 100644 public/app/features/trails/SelectMetricTrailView.tsx create mode 100644 public/app/features/trails/ShareTrailButton.tsx create mode 100644 public/app/features/trails/StatusWrapper.tsx create mode 100644 public/app/features/trails/TrailStore/useBookmarkState.ts delete mode 100644 public/app/features/trails/dashboardIntegration.ts create mode 100644 public/app/features/trails/relatedMetrics.ts create mode 100644 public/app/features/transformers/editors/GroupToNestedTableTransformerEditor.tsx delete mode 100644 public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx create mode 100644 public/app/features/visualization/data-hover/ExemplarHoverView.tsx rename metadata.md => public/app/plugins/datasource/azuremonitor/CHANGELOG.md (100%) delete mode 100644 public/app/plugins/datasource/azuremonitor/components/Space.tsx create mode 100644 public/app/plugins/datasource/azuremonitor/package.json create mode 100644 public/app/plugins/datasource/azuremonitor/tsconfig.json create mode 100644 public/app/plugins/datasource/azuremonitor/utils/testUtils.ts create mode 100644 public/app/plugins/datasource/azuremonitor/webpack.config.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/.eslintignore rename pkg/services/provisioning/notifiers/testdata/test-configs/empty/empty.yaml => public/app/plugins/datasource/cloud-monitoring/CHANGELOG.md (100%) create mode 100644 public/app/plugins/datasource/cloud-monitoring/package.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/tsconfig.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/webpack.config.ts rename public/app/plugins/datasource/cloudwatch/__mocks__/{variables.ts => CloudWatchVariables.ts} (100%) delete mode 100644 public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryFieldOld.tsx create mode 100644 public/app/plugins/datasource/dashboard/datasource.test.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/reducerTester.ts create mode 100644 public/app/plugins/datasource/elasticsearch/configuration/__mocks__/configOptions.ts delete mode 100644 public/app/plugins/datasource/elasticsearch/configuration/mocks.ts create mode 100644 public/app/plugins/datasource/grafana-postgresql-datasource/postgresMetaQuery.test.ts create mode 100644 public/app/plugins/datasource/grafana-pyroscope-datasource/.eslintignore create mode 100644 public/app/plugins/datasource/grafana-pyroscope-datasource/CHANGELOG.md create mode 100644 public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptionGroup.tsx create mode 100644 public/app/plugins/datasource/grafana-pyroscope-datasource/package.json create mode 100644 public/app/plugins/datasource/grafana-pyroscope-datasource/tsconfig.json create mode 100644 public/app/plugins/datasource/grafana-pyroscope-datasource/utils.ts create mode 100644 public/app/plugins/datasource/grafana-pyroscope-datasource/webpack.config.ts create mode 100644 public/app/plugins/datasource/grafana-testdata-datasource/testData/serviceMapResponseMedium.ts create mode 100644 public/app/plugins/datasource/jaeger/dependencyGraphTransform.test.ts create mode 100644 public/app/plugins/datasource/jaeger/dependencyGraphTransform.ts create mode 100644 public/app/plugins/datasource/loki/__mocks__/datasource.ts rename public/app/plugins/datasource/loki/{mocks.ts => __mocks__/frames.ts} (58%) create mode 100644 public/app/plugins/datasource/loki/__mocks__/metadataRequest.ts create mode 100644 public/app/plugins/datasource/loki/querybuilder/components/LabelParamEditor.tsx create mode 100644 public/app/plugins/datasource/parca/datasource.test.ts delete mode 100644 public/app/plugins/datasource/prometheus/dataquery.cue create mode 100644 public/app/plugins/datasource/prometheus/dataquery.ts create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/hooks/useFlag.test.ts create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/hooks/useFlag.ts create mode 100644 public/app/plugins/datasource/tempo/.eslintignore create mode 100644 public/app/plugins/datasource/tempo/CHANGELOG.md delete mode 100644 public/app/plugins/datasource/tempo/LokiSearch.tsx delete mode 100644 public/app/plugins/datasource/tempo/NativeSearch/NativeSearch.test.tsx delete mode 100644 public/app/plugins/datasource/tempo/NativeSearch/NativeSearch.tsx delete mode 100644 public/app/plugins/datasource/tempo/NativeSearch/TagsField/TagsField.tsx delete mode 100644 public/app/plugins/datasource/tempo/NativeSearch/TagsField/autocomplete.ts delete mode 100644 public/app/plugins/datasource/tempo/NativeSearch/TagsField/syntax.ts create mode 100644 public/app/plugins/datasource/tempo/_importedDependencies/README.md create mode 100644 public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilter.tsx create mode 100644 public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterBuilder.tsx create mode 100644 public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterKey.tsx create mode 100644 public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterRenderer.tsx create mode 100644 public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterValue.tsx create mode 100644 public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/ConditionSegment.tsx create mode 100644 public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/OperatorSegment.tsx create mode 100644 public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/types.ts create mode 100644 public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/types.ts create mode 100644 public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/QueryOptionGroup.tsx create mode 100644 public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/RawQuery.tsx rename public/app/plugins/datasource/{ => tempo/_importedDependencies/datasources}/prometheus/dataquery.gen.ts (100%) create mode 100644 public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/language_utils.ts create mode 100644 public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/types.ts create mode 100644 public/app/plugins/datasource/tempo/_importedDependencies/test/helpers/createFetchResponse.ts create mode 100644 public/app/plugins/datasource/tempo/_importedDependencies/test/helpers/selectOptionInTest.ts delete mode 100644 public/app/plugins/datasource/tempo/configuration/LokiSearchSettings.tsx create mode 100644 public/app/plugins/datasource/tempo/package.json create mode 100644 public/app/plugins/datasource/tempo/test_utils.ts delete mode 100644 public/app/plugins/datasource/tempo/traceql/TraceQLEditor.test.tsx create mode 100644 public/app/plugins/datasource/tempo/traceql/highlighting.test.ts rename public/app/plugins/datasource/tempo/traceql/{errorHighlighting.ts => highlighting.ts} (57%) create mode 100644 public/app/plugins/datasource/tempo/tsconfig.json create mode 100644 public/app/plugins/datasource/tempo/utils.test.ts create mode 100644 public/app/plugins/datasource/tempo/webpack.config.ts create mode 100644 public/app/plugins/datasource/zipkin/CHANGELOG.md create mode 100644 public/app/plugins/datasource/zipkin/package.json create mode 100644 public/app/plugins/datasource/zipkin/tsconfig.json create mode 100644 public/app/plugins/datasource/zipkin/webpack.config.ts delete mode 100644 public/app/plugins/panel/alertGroups/AlertGroup.tsx delete mode 100644 public/app/plugins/panel/alertGroups/AlertGroupsPanel.test.tsx delete mode 100644 public/app/plugins/panel/alertGroups/AlertGroupsPanel.tsx delete mode 100644 public/app/plugins/panel/alertGroups/AlertmanagerPicker.tsx delete mode 100644 public/app/plugins/panel/alertGroups/img/icn-alertgroups-panel.svg delete mode 100644 public/app/plugins/panel/alertGroups/module.tsx delete mode 100644 public/app/plugins/panel/alertGroups/panelcfg.cue delete mode 100644 public/app/plugins/panel/alertGroups/panelcfg.gen.ts delete mode 100644 public/app/plugins/panel/alertGroups/plugin.json delete mode 100644 public/app/plugins/panel/alertGroups/useFilteredGroups.ts delete mode 100644 public/app/plugins/panel/alertlist/AlertList.tsx delete mode 100644 public/app/plugins/panel/alertlist/AlertListMigration.test.ts delete mode 100644 public/app/plugins/panel/alertlist/AlertListMigrationHandler.ts delete mode 100644 public/app/plugins/panel/alertlist/suggestions.ts create mode 100644 public/app/plugins/panel/barchart/migrations.test.ts create mode 100644 public/app/plugins/panel/barchart/migrations.ts rename public/app/plugins/panel/heatmap/{HeatmapHoverView.tsx => HeatmapTooltip.tsx} (57%) create mode 100644 public/app/plugins/panel/histogram/migrations.test.ts create mode 100644 public/app/plugins/panel/histogram/migrations.ts create mode 100644 public/app/plugins/panel/live/LivePublish.tsx create mode 100644 public/app/plugins/panel/logs/useDatasourcesFromTargets.test.ts create mode 100644 public/app/plugins/panel/logs/useDatasourcesFromTargets.ts create mode 100644 public/app/plugins/panel/nodeGraph/layout.worker.utils.js create mode 100644 public/app/plugins/panel/nodeGraph/layoutMsagl.worker.js create mode 100644 public/app/plugins/panel/nodeGraph/suggestions.ts delete mode 100644 public/app/plugins/panel/status-history/StatusHistoryTooltip2.tsx create mode 100644 public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx create mode 100644 public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx create mode 100644 public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationMarker2.tsx create mode 100644 public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationTooltip2.tsx create mode 100644 public/app/plugins/panel/traces/suggestions.ts delete mode 100644 public/app/plugins/panel/trend/TrendTooltip.tsx rename public/app/plugins/panel/xychart/{XYChartPanel2.tsx => XYChartPanel.tsx} (69%) create mode 100644 public/app/plugins/panel/xychart/XYChartTooltip.test.tsx create mode 100644 public/app/plugins/panel/xychart/XYChartTooltip.tsx create mode 100644 public/app/plugins/panel/xychart/utils.ts create mode 100644 public/emails/verify_email_update.html create mode 100644 public/emails/verify_email_update.txt create mode 100644 public/fonts/inter/Inter-Medium.woff2 create mode 100644 public/fonts/inter/Inter-Regular.woff2 delete mode 100644 public/fonts/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa0ZL7SUc.woff2 delete mode 100644 public/fonts/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7.woff2 delete mode 100644 public/fonts/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1pL7SUc.woff2 delete mode 100644 public/fonts/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7SUc.woff2 delete mode 100644 public/fonts/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa2JL7SUc.woff2 delete mode 100644 public/fonts/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa2ZL7SUc.woff2 delete mode 100644 public/fonts/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa2pL7SUc.woff2 create mode 100644 public/img/icons/README.md create mode 100644 public/img/icons/unicons/asserts.svg create mode 100644 public/img/icons/unicons/unarchive.svg create mode 100644 public/img/plugins/pagerduty.svg create mode 100644 public/img/transformations/dark/groupToNestedTable.svg create mode 100644 public/img/transformations/disabled/groupToNestedTable.svg create mode 100644 public/img/transformations/light/groupToNestedTable.svg create mode 100644 public/locales/pt-BR/grafana.json create mode 100644 public/maps/example-with-style.geojson delete mode 100644 public/test/mocks/monaco.ts create mode 100644 scripts/cli/bettererResultsToJson.ts create mode 100644 scripts/docs/generate-transformations.test.ts create mode 100755 scripts/drone/env-var-check.sh diff --git a/.betterer.results b/.betterer.results index cb5461faa3536..b36e5fc6856d6 100644 --- a/.betterer.results +++ b/.betterer.results @@ -9,8 +9,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"] ], "packages/grafana-data/src/dataframe/ArrayDataFrame.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "packages/grafana-data/src/dataframe/CircularDataFrame.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -34,14 +33,10 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "5"], [0, 0, 0, "Unexpected any. Specify a different type.", "6"], [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], + [0, 0, 0, "Do not use any type assertions.", "8"], [0, 0, 0, "Unexpected any. Specify a different type.", "9"], [0, 0, 0, "Do not use any type assertions.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Do not use any type assertions.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "13"], - [0, 0, 0, "Unexpected any. Specify a different type.", "14"], - [0, 0, 0, "Do not use any type assertions.", "15"] + [0, 0, 0, "Do not use any type assertions.", "11"] ], "packages/grafana-data/src/dataframe/StreamingDataFrame.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -293,17 +288,14 @@ exports[`better eslint`] = { ], "packages/grafana-data/src/types/fieldOverrides.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"], [0, 0, 0, "Unexpected any. Specify a different type.", "5"], [0, 0, 0, "Unexpected any. Specify a different type.", "6"], [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"] + [0, 0, 0, "Unexpected any. Specify a different type.", "8"] ], "packages/grafana-data/src/types/flot.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -328,9 +320,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "7"], [0, 0, 0, "Do not use any type assertions.", "8"] ], - "packages/grafana-data/src/types/logs.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "packages/grafana-data/src/types/options.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] @@ -376,11 +365,6 @@ exports[`better eslint`] = { "packages/grafana-data/src/types/variables.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "packages/grafana-data/src/types/vector.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] - ], "packages/grafana-data/src/utils/OptionsUIBuilders.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -435,8 +419,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "packages/grafana-data/src/utils/location.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "packages/grafana-data/src/utils/url.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -456,25 +439,9 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"] ], - "packages/grafana-data/src/vector/AppendedVectors.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"] - ], - "packages/grafana-data/src/vector/ArrayVector.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] - ], "packages/grafana-data/src/vector/CircularVector.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "packages/grafana-data/src/vector/ConstantVector.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "packages/grafana-data/src/vector/FormattedVector.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "packages/grafana-data/src/vector/FunctionalVector.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -484,11 +451,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "5"], [0, 0, 0, "Unexpected any. Specify a different type.", "6"], [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"] - ], - "packages/grafana-data/src/vector/SortedVector.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + [0, 0, 0, "Unexpected any. Specify a different type.", "8"] ], "packages/grafana-data/test/__mocks__/pluginMocks.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -533,6 +496,232 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] ], + "packages/grafana-o11y-ds-frontend/src/utils.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "packages/grafana-prometheus/src/components/PromExemplarField.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"], + [0, 0, 0, "Styles should be written using objects.", "2"] + ], + "packages/grafana-prometheus/src/components/PromExploreExtraField.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"] + ], + "packages/grafana-prometheus/src/components/PromQueryField.test.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "packages/grafana-prometheus/src/components/PromQueryField.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"], + [0, 0, 0, "Styles should be written using objects.", "2"], + [0, 0, 0, "Styles should be written using objects.", "3"], + [0, 0, 0, "Styles should be written using objects.", "4"], + [0, 0, 0, "Styles should be written using objects.", "5"], + [0, 0, 0, "Styles should be written using objects.", "6"], + [0, 0, 0, "Styles should be written using objects.", "7"], + [0, 0, 0, "Styles should be written using objects.", "8"], + [0, 0, 0, "Styles should be written using objects.", "9"], + [0, 0, 0, "Styles should be written using objects.", "10"], + [0, 0, 0, "Styles should be written using objects.", "11"] + ], + "packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"] + ], + "packages/grafana-prometheus/src/configuration/ConfigEditor.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"], + [0, 0, 0, "Styles should be written using objects.", "2"], + [0, 0, 0, "Styles should be written using objects.", "3"], + [0, 0, 0, "Styles should be written using objects.", "4"], + [0, 0, 0, "Styles should be written using objects.", "5"], + [0, 0, 0, "Styles should be written using objects.", "6"], + [0, 0, 0, "Styles should be written using objects.", "7"], + [0, 0, 0, "Styles should be written using objects.", "8"], + [0, 0, 0, "Styles should be written using objects.", "9"], + [0, 0, 0, "Styles should be written using objects.", "10"], + [0, 0, 0, "Styles should be written using objects.", "11"], + [0, 0, 0, "Styles should be written using objects.", "12"], + [0, 0, 0, "Styles should be written using objects.", "13"], + [0, 0, 0, "Styles should be written using objects.", "14"], + [0, 0, 0, "Styles should be written using objects.", "15"] + ], + "packages/grafana-prometheus/src/configuration/ExemplarsSettings.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"] + ], + "packages/grafana-prometheus/src/datasource.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Unexpected any. Specify a different type.", "5"], + [0, 0, 0, "Unexpected any. Specify a different type.", "6"], + [0, 0, 0, "Unexpected any. Specify a different type.", "7"], + [0, 0, 0, "Unexpected any. Specify a different type.", "8"], + [0, 0, 0, "Unexpected any. Specify a different type.", "9"], + [0, 0, 0, "Unexpected any. Specify a different type.", "10"], + [0, 0, 0, "Unexpected any. Specify a different type.", "11"], + [0, 0, 0, "Unexpected any. Specify a different type.", "12"] + ], + "packages/grafana-prometheus/src/gcopypaste/app/features/live/data/amendTimeSeries.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"], + [0, 0, 0, "Do not use any type assertions.", "3"], + [0, 0, 0, "Do not use any type assertions.", "4"] + ], + "packages/grafana-prometheus/src/gcopypaste/public/test/matchers/index.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValuesWith.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + ], + "packages/grafana-prometheus/src/gcopypaste/public/test/matchers/utils.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "packages/grafana-prometheus/src/gcopypaste/test/helpers/selectOptionInTest.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "packages/grafana-prometheus/src/language_provider.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + ], + "packages/grafana-prometheus/src/language_utils.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"] + ], + "packages/grafana-prometheus/src/metric_find_query.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + ], + "packages/grafana-prometheus/src/query_hints.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "packages/grafana-prometheus/src/querybuilder/QueryPattern.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"], + [0, 0, 0, "Styles should be written using objects.", "2"], + [0, 0, 0, "Styles should be written using objects.", "3"] + ], + "packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"] + ], + "packages/grafana-prometheus/src/querybuilder/binaryScalarOperations.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "packages/grafana-prometheus/src/querybuilder/components/LabelFilterItem.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"], + [0, 0, 0, "Do not use any type assertions.", "3"] + ], + "packages/grafana-prometheus/src/querybuilder/components/LabelFilters.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"] + ], + "packages/grafana-prometheus/src/querybuilder/components/LabelParamEditor.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "packages/grafana-prometheus/src/querybuilder/components/MetricSelect.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Styles should be written using objects.", "4"], + [0, 0, 0, "Styles should be written using objects.", "5"], + [0, 0, 0, "Styles should be written using objects.", "6"], + [0, 0, 0, "Styles should be written using objects.", "7"], + [0, 0, 0, "Styles should be written using objects.", "8"], + [0, 0, 0, "Styles should be written using objects.", "9"], + [0, 0, 0, "Styles should be written using objects.", "10"], + [0, 0, 0, "Styles should be written using objects.", "11"], + [0, 0, 0, "Styles should be written using objects.", "12"] + ], + "packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"] + ], + "packages/grafana-prometheus/src/querybuilder/components/metrics-modal/AdditionalSettings.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"], + [0, 0, 0, "Styles should be written using objects.", "2"] + ], + "packages/grafana-prometheus/src/querybuilder/components/metrics-modal/ResultsTable.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"], + [0, 0, 0, "Styles should be written using objects.", "2"], + [0, 0, 0, "Styles should be written using objects.", "3"], + [0, 0, 0, "Styles should be written using objects.", "4"], + [0, 0, 0, "Styles should be written using objects.", "5"], + [0, 0, 0, "Styles should be written using objects.", "6"], + [0, 0, 0, "Styles should be written using objects.", "7"], + [0, 0, 0, "Styles should be written using objects.", "8"], + [0, 0, 0, "Styles should be written using objects.", "9"], + [0, 0, 0, "Styles should be written using objects.", "10"], + [0, 0, 0, "Styles should be written using objects.", "11"], + [0, 0, 0, "Styles should be written using objects.", "12"] + ], + "packages/grafana-prometheus/src/querybuilder/components/metrics-modal/styles.ts:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"], + [0, 0, 0, "Styles should be written using objects.", "2"], + [0, 0, 0, "Styles should be written using objects.", "3"], + [0, 0, 0, "Styles should be written using objects.", "4"], + [0, 0, 0, "Styles should be written using objects.", "5"], + [0, 0, 0, "Styles should be written using objects.", "6"], + [0, 0, 0, "Styles should be written using objects.", "7"], + [0, 0, 0, "Styles should be written using objects.", "8"], + [0, 0, 0, "Styles should be written using objects.", "9"], + [0, 0, 0, "Styles should be written using objects.", "10"], + [0, 0, 0, "Styles should be written using objects.", "11"], + [0, 0, 0, "Styles should be written using objects.", "12"], + [0, 0, 0, "Styles should be written using objects.", "13"], + [0, 0, 0, "Styles should be written using objects.", "14"], + [0, 0, 0, "Styles should be written using objects.", "15"], + [0, 0, 0, "Styles should be written using objects.", "16"], + [0, 0, 0, "Styles should be written using objects.", "17"], + [0, 0, 0, "Styles should be written using objects.", "18"] + ], + "packages/grafana-prometheus/src/querybuilder/operationUtils.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "packages/grafana-prometheus/src/querybuilder/shared/OperationEditor.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "packages/grafana-prometheus/src/querybuilder/shared/OperationParamEditor.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"] + ], + "packages/grafana-prometheus/src/querybuilder/shared/QueryBuilderHints.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"] + ], + "packages/grafana-prometheus/src/querybuilder/shared/types.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + ], + "packages/grafana-prometheus/src/types.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + ], "packages/grafana-runtime/src/analytics/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -576,12 +765,12 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "11"], [0, 0, 0, "Unexpected any. Specify a different type.", "12"] ], + "packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "packages/grafana-runtime/src/utils/DataSourceWithBackend.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "packages/grafana-runtime/src/utils/queryResponse.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -598,19 +787,38 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "5"], [0, 0, 0, "Do not use any type assertions.", "6"] ], - "packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + "packages/grafana-sql/src/components/query-editor-raw/QueryToolbox.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"], + [0, 0, 0, "Styles should be written using objects.", "2"], + [0, 0, 0, "Styles should be written using objects.", "3"], + [0, 0, 0, "Styles should be written using objects.", "4"] ], - "packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] + "packages/grafana-sql/src/components/query-editor-raw/QueryValidator.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"], + [0, 0, 0, "Styles should be written using objects.", "2"] ], - "packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + "packages/grafana-sql/src/components/query-editor-raw/RawEditor.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"] + ], + "packages/grafana-sql/src/components/visual-query-builder/AwesomeQueryBuilder.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "packages/grafana-sql/src/components/visual-query-builder/SQLWhereRow.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx:5381": [ - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] + "packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "packages/grafana-ui/src/components/DataSourceSettings/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -622,9 +830,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "6"], [0, 0, 0, "Unexpected any. Specify a different type.", "7"] ], - "packages/grafana-ui/src/components/DateTimePickers/TimeRangeInput.tsx:5381": [ - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] - ], "packages/grafana-ui/src/components/Forms/Legacy/Input/Input.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -656,12 +861,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Unexpected any. Specify a different type.", "3"] ], - "packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx:5381": [ - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] - ], - "packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx:5381": [ - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] - ], "packages/grafana-ui/src/components/PanelChrome/PanelContext.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] @@ -750,7 +949,8 @@ exports[`better eslint`] = { ], "packages/grafana-ui/src/components/Table/Table.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"] ], "packages/grafana-ui/src/components/Table/TableCell.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -936,9 +1136,6 @@ exports[`better eslint`] = { "packages/grafana-ui/src/utils/useAsyncDependency.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "plugins-bundled/internal/input-datasource/src/InputDatasource.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/core/TableModel.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -948,12 +1145,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "5"], [0, 0, 0, "Unexpected any. Specify a different type.", "6"] ], - "public/app/core/components/AppChrome/SectionNav/SectionNavItem.tsx:5381": [ - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"] - ], "public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], @@ -968,11 +1159,9 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "4"], [0, 0, 0, "Do not use any type assertions.", "5"], [0, 0, 0, "Do not use any type assertions.", "6"], - [0, 0, 0, "Do not use any type assertions.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Do not use any type assertions.", "9"], - [0, 0, 0, "Do not use any type assertions.", "10"], - [0, 0, 0, "Do not use any type assertions.", "11"] + [0, 0, 0, "Unexpected any. Specify a different type.", "7"], + [0, 0, 0, "Do not use any type assertions.", "8"], + [0, 0, 0, "Do not use any type assertions.", "9"] ], "public/app/core/components/GraphNG/hooks.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -990,15 +1179,13 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "6"], [0, 0, 0, "Unexpected any. Specify a different type.", "7"], [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"] + [0, 0, 0, "Unexpected any. Specify a different type.", "9"], + [0, 0, 0, "Unexpected any. Specify a different type.", "10"] ], "public/app/core/components/PageHeader/PageHeader.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] ], - "public/app/core/components/PageHeader/PanelHeaderMenuItem.tsx:5381": [ - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] - ], "public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] @@ -1006,11 +1193,6 @@ exports[`better eslint`] = { "public/app/core/components/QueryOperationRow/OperationRowHelp.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/core/components/QueryOperationRow/QueryOperationAction.tsx:5381": [ - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] - ], "public/app/core/components/QueryOperationRow/QueryOperationRow.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] @@ -1036,18 +1218,6 @@ exports[`better eslint`] = { "public/app/core/components/RolePicker/ValueContainer.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/core/components/Select/OldFolderPicker.tsx:5381": [ - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] - ], - "public/app/core/components/SharedPreferences/SharedPreferences.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], - "public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] - ], "public/app/core/components/TagFilter/TagFilter.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -1067,14 +1237,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], - "public/app/core/components/TraceToLogs/TagMappingInput.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "public/app/core/components/TraceToMetrics/TraceToMetricsSettings.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] - ], "public/app/core/components/Upgrade/ProBadge.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], @@ -1102,16 +1264,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/core/components/help/HelpModal.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"] - ], "public/app/core/navigation/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -1150,9 +1302,6 @@ exports[`better eslint`] = { "public/app/core/specs/backend_srv.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/core/specs/time_series.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/core/time_series2.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -1193,23 +1342,13 @@ exports[`better eslint`] = { "public/app/core/utils/deferred.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/core/utils/explore.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] - ], "public/app/core/utils/fetch.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Do not use any type assertions.", "3"], [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"], - [0, 0, 0, "Do not use any type assertions.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Do not use any type assertions.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"] + [0, 0, 0, "Unexpected any. Specify a different type.", "5"] ], "public/app/core/utils/flatten.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -1225,15 +1364,8 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/core/utils/ticks.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"] - ], - "public/app/core/utils/tracing.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/features/admin/LicenseChrome.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], @@ -1291,78 +1423,12 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "1"], [0, 0, 0, "Styles should be written using objects.", "2"] ], - "public/app/features/alerting/AlertTab.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], - "public/app/features/alerting/AlertTabCtrl.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "13"], - [0, 0, 0, "Unexpected any. Specify a different type.", "14"], - [0, 0, 0, "Unexpected any. Specify a different type.", "15"], - [0, 0, 0, "Unexpected any. Specify a different type.", "16"], - [0, 0, 0, "Unexpected any. Specify a different type.", "17"], - [0, 0, 0, "Unexpected any. Specify a different type.", "18"], - [0, 0, 0, "Unexpected any. Specify a different type.", "19"], - [0, 0, 0, "Unexpected any. Specify a different type.", "20"], - [0, 0, 0, "Unexpected any. Specify a different type.", "21"], - [0, 0, 0, "Unexpected any. Specify a different type.", "22"], - [0, 0, 0, "Do not use any type assertions.", "23"], - [0, 0, 0, "Unexpected any. Specify a different type.", "24"], - [0, 0, 0, "Unexpected any. Specify a different type.", "25"], - [0, 0, 0, "Unexpected any. Specify a different type.", "26"], - [0, 0, 0, "Unexpected any. Specify a different type.", "27"], - [0, 0, 0, "Unexpected any. Specify a different type.", "28"], - [0, 0, 0, "Unexpected any. Specify a different type.", "29"], - [0, 0, 0, "Unexpected any. Specify a different type.", "30"], - [0, 0, 0, "Unexpected any. Specify a different type.", "31"], - [0, 0, 0, "Unexpected any. Specify a different type.", "32"], - [0, 0, 0, "Unexpected any. Specify a different type.", "33"] - ], - "public/app/features/alerting/EditNotificationChannelPage.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/features/alerting/StateHistory.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] - ], - "public/app/features/alerting/TestRuleResult.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] - ], - "public/app/features/alerting/components/NotificationChannelForm.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] - ], - "public/app/features/alerting/components/NotificationChannelOptions.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "public/app/features/alerting/components/OptionElement.tsx:5381": [ + "public/app/features/alerting/routes.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/alerting/state/ThresholdMapper.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/alerting/state/actions.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] - ], "public/app/features/alerting/state/alertDef.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -1392,9 +1458,6 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/AlertWarning.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/features/alerting/unified/AlertsFolderView.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/alerting/unified/AlertsFolderView.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -1417,11 +1480,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "11"], [0, 0, 0, "Styles should be written using objects.", "12"], [0, 0, 0, "Styles should be written using objects.", "13"], - [0, 0, 0, "Styles should be written using objects.", "14"], - [0, 0, 0, "Styles should be written using objects.", "15"], - [0, 0, 0, "Styles should be written using objects.", "16"], - [0, 0, 0, "Styles should be written using objects.", "17"], - [0, 0, 0, "Styles should be written using objects.", "18"] + [0, 0, 0, "Styles should be written using objects.", "14"] ], "public/app/features/alerting/unified/NotificationPolicies.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] @@ -1455,10 +1514,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"] ], "public/app/features/alerting/unified/components/AlertStateDot.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"] + [0, 0, 0, "Styles should be written using objects.", "0"] ], "public/app/features/alerting/unified/components/AnnotationDetailsField.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -1505,13 +1561,6 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/features/alerting/unified/components/HoverCard.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"] - ], "public/app/features/alerting/unified/components/Label.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -1581,22 +1630,17 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"] + [0, 0, 0, "Styles should be written using objects.", "3"] ], "public/app/features/alerting/unified/components/alert-groups/AlertGroupHeader.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/features/alerting/unified/components/alert-groups/AlertStateFilter.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -1663,23 +1707,12 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "2"], [0, 0, 0, "Styles should be written using objects.", "3"] ], - "public/app/features/alerting/unified/components/notification-policies/Filters.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/features/alerting/unified/components/notification-policies/Matchers.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] ], "public/app/features/alerting/unified/components/notification-policies/Policy.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/alerting/unified/components/notification-policies/PromDurationDocs.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], @@ -2012,41 +2045,16 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"], - [0, 0, 0, "Styles should be written using objects.", "9"], - [0, 0, 0, "Styles should be written using objects.", "10"], - [0, 0, 0, "Styles should be written using objects.", "11"], - [0, 0, 0, "Styles should be written using objects.", "12"] - ], "public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] ], - "public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"] - ], "public/app/features/alerting/unified/components/rules/ActionButton.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], "public/app/features/alerting/unified/components/rules/AlertInstanceStateFilter.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/features/alerting/unified/components/rules/CloneRule.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/features/alerting/unified/components/rules/CloudRules.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -2107,8 +2115,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "1"], [0, 0, 0, "Styles should be written using objects.", "2"], [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"] + [0, 0, 0, "Styles should be written using objects.", "4"] ], "public/app/features/alerting/unified/components/rules/RuleHealth.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] @@ -2126,13 +2133,6 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/components/rules/RuleState.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/features/alerting/unified/components/rules/RulesFilter.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"] - ], "public/app/features/alerting/unified/components/rules/RulesGroup.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -2225,23 +2225,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "3"], [0, 0, 0, "Styles should be written using objects.", "4"] ], - "public/app/features/alerting/unified/home/GettingStarted.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"], - [0, 0, 0, "Styles should be written using objects.", "9"], - [0, 0, 0, "Styles should be written using objects.", "10"], - [0, 0, 0, "Styles should be written using objects.", "11"], - [0, 0, 0, "Styles should be written using objects.", "12"], - [0, 0, 0, "Styles should be written using objects.", "13"], - [0, 0, 0, "Styles should be written using objects.", "14"] - ], "public/app/features/alerting/unified/hooks/useAlertmanagerConfig.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -2259,11 +2242,9 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"], [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Do not use any type assertions.", "6"], - [0, 0, 0, "Do not use any type assertions.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Do not use any type assertions.", "10"] + [0, 0, 0, "Unexpected any. Specify a different type.", "6"], + [0, 0, 0, "Unexpected any. Specify a different type.", "7"], + [0, 0, 0, "Do not use any type assertions.", "8"] ], "public/app/features/alerting/unified/state/actions.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -2293,6 +2274,9 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], + "public/app/features/alerting/unified/utils/misc.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/alerting/unified/utils/receiver-form.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -2319,9 +2303,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"] ], - "public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/features/annotations/events_processing.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -2383,11 +2364,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] ], - "public/app/features/connections/tabs/ConnectData/ConnectData.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] - ], "public/app/features/connections/tabs/ConnectData/NoAccessModal/NoAccessModal.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -2402,9 +2378,6 @@ exports[`better eslint`] = { "public/app/features/connections/tabs/ConnectData/NoResults/NoResults.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/features/connections/tabs/ConnectData/Search/Search.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/features/correlations/CorrelationsPage.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -2423,36 +2396,46 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] ], + "public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx:5381": [ [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] ], - "public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"] - ], - "public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx:5381": [ + "public/app/features/dashboard-scene/pages/DashboardScenePage.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"] ], - "public/app/features/dashboard-scene/scene/DashboardScene.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + "public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx:5381": [ + "public/app/features/dashboard-scene/saving/DashboardPrompt.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/features/dashboard-scene/saving/DetectChangesWorker.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "public/app/features/dashboard-scene/scene/setDashboardPanelContext.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + "public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx:5381": [ + [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"], + [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"] + ], + "public/app/features/dashboard-scene/saving/getDashboardChanges.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"] + ], + "public/app/features/dashboard-scene/saving/getSaveDashboardChange.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/features/dashboard-scene/serialization/angularMigration.test.ts:5381": [ + "public/app/features/dashboard-scene/saving/shared.tsx:5381": [ + [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] + ], + "public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"] ], "public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -2469,7 +2452,8 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "5"], [0, 0, 0, "Unexpected any. Specify a different type.", "6"], [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"] + [0, 0, 0, "Unexpected any. Specify a different type.", "8"], + [0, 0, 0, "Unexpected any. Specify a different type.", "9"] ], "public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -2477,29 +2461,26 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"], [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"] + [0, 0, 0, "Do not use any type assertions.", "5"], + [0, 0, 0, "Do not use any type assertions.", "6"] + ], + "public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/features/dashboard-scene/settings/variables/utils.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/features/dashboard-scene/utils/PanelModelCompatibilityWrapper.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/dashboard-scene/utils/test-utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"] ], - "public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"], - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "2"], - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "3"], - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"], - [0, 0, 0, "Styles should be written using objects.", "9"], - [0, 0, 0, "Styles should be written using objects.", "10"] - ], "public/app/features/dashboard/components/AddWidgetModal/AddWidgetModal.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -2521,22 +2502,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "13"], - [0, 0, 0, "Unexpected any. Specify a different type.", "14"], - [0, 0, 0, "Unexpected any. Specify a different type.", "15"], - [0, 0, 0, "Unexpected any. Specify a different type.", "16"], - [0, 0, 0, "Unexpected any. Specify a different type.", "17"], - [0, 0, 0, "Unexpected any. Specify a different type.", "18"], - [0, 0, 0, "Unexpected any. Specify a different type.", "19"], - [0, 0, 0, "Unexpected any. Specify a different type.", "20"] + [0, 0, 0, "Unexpected any. Specify a different type.", "5"] ], "public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -2591,10 +2557,6 @@ exports[`better eslint`] = { "public/app/features/dashboard/components/Inspector/PanelInspector.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/dashboard/components/LinksSettings/LinkSettingsList.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] - ], "public/app/features/dashboard/components/PanelEditor/DynamicConfigValueEditor.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] @@ -2608,16 +2570,7 @@ exports[`better eslint`] = { ], "public/app/features/dashboard/components/PanelEditor/OptionsPaneCategory.tsx:5381": [ [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"], - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"], - [0, 0, 0, "Styles should be written using objects.", "9"], - [0, 0, 0, "Styles should be written using objects.", "10"] + [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"] ], "public/app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -2711,9 +2664,6 @@ exports[`better eslint`] = { [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"], [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"] ], - "public/app/features/dashboard/components/SaveDashboard/SaveDashboardDiff.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/features/dashboard/components/SaveDashboard/SaveDashboardErrorProxy.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -2799,54 +2749,14 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "9"], [0, 0, 0, "Styles should be written using objects.", "10"] ], - "public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/features/dashboard/components/VersionHistory/DiffGroup.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] - ], - "public/app/features/dashboard/components/VersionHistory/DiffTitle.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"] - ], - "public/app/features/dashboard/components/VersionHistory/DiffValues.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], - "public/app/features/dashboard/components/VersionHistory/DiffViewer.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], - "public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] - ], - "public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], - "public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "public/app/features/dashboard/components/VersionHistory/utils.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/features/dashboard/containers/DashboardPage.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -2854,6 +2764,9 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "4"] ], + "public/app/features/dashboard/containers/DashboardPageProxy.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -2877,14 +2790,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "8"], [0, 0, 0, "Unexpected any. Specify a different type.", "9"], [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "13"], - [0, 0, 0, "Unexpected any. Specify a different type.", "14"], - [0, 0, 0, "Unexpected any. Specify a different type.", "15"], - [0, 0, 0, "Unexpected any. Specify a different type.", "16"], - [0, 0, 0, "Unexpected any. Specify a different type.", "17"], - [0, 0, 0, "Unexpected any. Specify a different type.", "18"] + [0, 0, 0, "Unexpected any. Specify a different type.", "11"] ], "public/app/features/dashboard/state/DashboardMigrator.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -2923,10 +2829,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"] + [0, 0, 0, "Unexpected any. Specify a different type.", "3"] ], "public/app/features/dashboard/state/DashboardModel.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -2958,14 +2861,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "23"] ], "public/app/features/dashboard/state/PanelModel.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/dashboard/state/PanelModel.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -3001,16 +2897,10 @@ exports[`better eslint`] = { "public/app/features/dashboard/state/actions.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/dashboard/state/initDashboard.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"] + "public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/dashboard/state/initDashboard.ts:5381": [ + "public/app/features/dashboard/state/initDashboard.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/dashboard/utils/getPanelMenu.test.ts:5381": [ @@ -3040,9 +2930,6 @@ exports[`better eslint`] = { "public/app/features/datasources/components/DataSourceTypeCardList.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/features/datasources/components/EditDataSource.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/datasources/components/picker/DataSourceCard.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -3054,16 +2941,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "7"], [0, 0, 0, "Styles should be written using objects.", "8"] ], - "public/app/features/datasources/components/picker/DataSourceDropdown.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"] - ], "public/app/features/datasources/components/picker/DataSourceList.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -3087,11 +2964,7 @@ exports[`better eslint`] = { ], "public/app/features/datasources/state/actions.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/features/datasources/state/actions.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -3190,26 +3063,6 @@ exports[`better eslint`] = { "public/app/features/explore/ContentOutline/ContentOutline.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/explore/ExploreDrawer.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] - ], - "public/app/features/explore/ExplorePaneContainer.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], - "public/app/features/explore/FeatureTogglePage.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], - "public/app/features/explore/FlameGraph/FlameGraphExploreContainer.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], - "public/app/features/explore/LiveTailButton.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"] - ], "public/app/features/explore/Logs/LiveLogs.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -3218,19 +3071,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "4"] ], "public/app/features/explore/Logs/Logs.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"], - [0, 0, 0, "Styles should be written using objects.", "9"], - [0, 0, 0, "Styles should be written using objects.", "10"], - [0, 0, 0, "Styles should be written using objects.", "11"], - [0, 0, 0, "Styles should be written using objects.", "12"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/explore/Logs/LogsMetaRow.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], @@ -3252,8 +3093,7 @@ exports[`better eslint`] = { "public/app/features/explore/Logs/LogsSamplePanel.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"] + [0, 0, 0, "Styles should be written using objects.", "2"] ], "public/app/features/explore/Logs/LogsVolumePanel.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], @@ -3272,19 +3112,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "2"], [0, 0, 0, "Styles should be written using objects.", "3"] ], - "public/app/features/explore/MetaInfoText.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"] - ], - "public/app/features/explore/NoData.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] - ], - "public/app/features/explore/NoDataSourceCallToAction.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/features/explore/NodeGraph/NodeGraphContainer.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], @@ -3317,9 +3144,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "1"], [0, 0, 0, "Styles should be written using objects.", "2"] ], - "public/app/features/explore/QueryRows.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/explore/RichHistory/RichHistoryCard.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -3368,13 +3192,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "5"], [0, 0, 0, "Styles should be written using objects.", "6"] ], - "public/app/features/explore/SecondaryActions.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], - "public/app/features/explore/SupplementaryResultError.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] - ], "public/app/features/explore/TraceView/TraceView.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -3383,10 +3200,6 @@ exports[`better eslint`] = { "public/app/features/explore/TraceView/components/TracePageHeader/Actions/ActionButton.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] - ], "public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -3408,8 +3221,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "2"], [0, 0, 0, "Styles should be written using objects.", "3"], [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"] + [0, 0, 0, "Styles should be written using objects.", "5"] ], "public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/CanvasSpanGraph.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] @@ -3447,11 +3259,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "5"], [0, 0, 0, "Styles should be written using objects.", "6"], [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"], - [0, 0, 0, "Styles should be written using objects.", "9"], - [0, 0, 0, "Styles should be written using objects.", "10"], - [0, 0, 0, "Styles should be written using objects.", "11"], - [0, 0, 0, "Styles should be written using objects.", "12"] + [0, 0, 0, "Styles should be written using objects.", "8"] ], "public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], @@ -3593,9 +3401,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "6"], [0, 0, 0, "Styles should be written using objects.", "7"] ], - "public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineRow.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -3610,11 +3415,6 @@ exports[`better eslint`] = { "public/app/features/explore/TraceView/components/common/CopyIcon.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/features/explore/TraceView/components/common/Divider.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] - ], "public/app/features/explore/TraceView/components/common/LabeledList.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -3630,10 +3430,6 @@ exports[`better eslint`] = { "public/app/features/explore/TraceView/components/demo/trace-generators.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/explore/TraceView/components/model/link-patterns.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/features/explore/TraceView/components/model/link-patterns.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -3646,8 +3442,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "8"], [0, 0, 0, "Do not use any type assertions.", "9"], [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Do not use any type assertions.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"] + [0, 0, 0, "Do not use any type assertions.", "11"] ], "public/app/features/explore/TraceView/components/model/transform-trace-data.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -3659,25 +3454,6 @@ exports[`better eslint`] = { "public/app/features/explore/TraceView/components/types/trace.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/explore/TraceView/components/uberUtilityStyles.ts:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"], - [0, 0, 0, "Styles should be written using objects.", "9"], - [0, 0, 0, "Styles should be written using objects.", "10"], - [0, 0, 0, "Styles should be written using objects.", "11"], - [0, 0, 0, "Styles should be written using objects.", "12"], - [0, 0, 0, "Styles should be written using objects.", "13"], - [0, 0, 0, "Styles should be written using objects.", "14"], - [0, 0, 0, "Styles should be written using objects.", "15"], - [0, 0, 0, "Styles should be written using objects.", "16"] - ], "public/app/features/explore/TraceView/createSpanLink.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] @@ -3686,12 +3462,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "public/app/features/explore/spec/interpolation.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/features/explore/spec/queryHistory.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/explore/state/time.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -3721,10 +3491,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "5"], [0, 0, 0, "Styles should be written using objects.", "6"] ], - "public/app/features/expressions/components/Threshold.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] - ], "public/app/features/expressions/guards.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -3739,9 +3505,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/features/geo/gazetteer/worldmap.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/geo/utils/frameVectorSource.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] @@ -3794,17 +3557,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "1"], [0, 0, 0, "Styles should be written using objects.", "2"] ], - "public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"] - ], "public/app/features/library-panels/components/LibraryPanelsView/actions.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -3845,6 +3597,9 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "3"], [0, 0, 0, "Do not use any type assertions.", "4"] ], + "public/app/features/logs/components/LoadingIndicator.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"] + ], "public/app/features/logs/components/LogDetailsRow.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -3917,9 +3672,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "34"], [0, 0, 0, "Styles should be written using objects.", "35"] ], - "public/app/features/logs/components/log-context/LoadingIndicator.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/features/logs/components/log-context/LogRowContextModal.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -3960,9 +3712,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "4"], [0, 0, 0, "Styles should be written using objects.", "5"] ], - "public/app/features/manage-dashboards/state/actions.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/manage-dashboards/state/actions.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -3970,7 +3719,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Do not use any type assertions.", "4"], [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Do not use any type assertions.", "6"], + [0, 0, 0, "Unexpected any. Specify a different type.", "6"], [0, 0, 0, "Unexpected any. Specify a different type.", "7"], [0, 0, 0, "Unexpected any. Specify a different type.", "8"], [0, 0, 0, "Unexpected any. Specify a different type.", "9"], @@ -3978,9 +3727,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "11"], [0, 0, 0, "Unexpected any. Specify a different type.", "12"], [0, 0, 0, "Unexpected any. Specify a different type.", "13"], - [0, 0, 0, "Unexpected any. Specify a different type.", "14"], - [0, 0, 0, "Unexpected any. Specify a different type.", "15"], - [0, 0, 0, "Unexpected any. Specify a different type.", "16"] + [0, 0, 0, "Unexpected any. Specify a different type.", "14"] ], "public/app/features/manage-dashboards/state/reducers.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -4046,9 +3793,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"] ], "public/app/features/plugins/admin/components/PluginDetailsBody.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/plugins/admin/components/PluginDetailsHeaderDependencies.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], @@ -4121,33 +3866,10 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"] ], - "public/app/features/plugins/sql/components/query-editor-raw/QueryToolbox.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"] - ], - "public/app/features/plugins/sql/components/query-editor-raw/QueryValidator.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] - ], - "public/app/features/plugins/sql/components/query-editor-raw/RawEditor.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] - ], - "public/app/features/plugins/sql/components/visual-query-builder/AwesomeQueryBuilder.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/features/plugins/sql/components/visual-query-builder/SQLWhereRow.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/plugins/tests/datasource_srv.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], "public/app/features/plugins/utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -4155,9 +3877,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"] ], - "public/app/features/profile/ChangePasswordForm.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/features/query/components/QueryEditorRow.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -4190,14 +3909,6 @@ exports[`better eslint`] = { "public/app/features/query/components/QueryGroupOptions.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] - ], - "public/app/features/query/state/DashboardQueryRunner/AnnotationsQueryRunner.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/query/state/DashboardQueryRunner/AnnotationsQueryRunner.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -4220,8 +3931,7 @@ exports[`better eslint`] = { ], "public/app/features/query/state/PanelQueryRunner.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"] + [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/features/query/state/runRequest.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -4237,11 +3947,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "7"], [0, 0, 0, "Unexpected any. Specify a different type.", "8"], [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"] - ], - "public/app/features/sandbox/TestStuffPage.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] + [0, 0, 0, "Unexpected any. Specify a different type.", "10"] ], "public/app/features/search/page/components/ActionRow.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], @@ -4284,12 +3990,6 @@ exports[`better eslint`] = { "public/app/features/search/utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/serviceaccounts/components/CreateTokenModal.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"] - ], "public/app/features/serviceaccounts/components/ServiceAccountProfile.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], @@ -4344,9 +4044,7 @@ exports[`better eslint`] = { ], "public/app/features/storage/storage.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/features/teams/TeamGroupSync.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -4366,14 +4064,14 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], - "public/app/features/templating/macroRegistry.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/templating/templateProxies.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/templating/template_srv.mock.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"], + [0, 0, 0, "Do not use any type assertions.", "3"] ], "public/app/features/templating/template_srv.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -4386,16 +4084,9 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "7"], [0, 0, 0, "Unexpected any. Specify a different type.", "8"], [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Do not use any type assertions.", "12"], - [0, 0, 0, "Do not use any type assertions.", "13"], - [0, 0, 0, "Unexpected any. Specify a different type.", "14"], - [0, 0, 0, "Do not use any type assertions.", "15"] - ], - "public/app/features/trails/SelectMetricTrailView.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + [0, 0, 0, "Do not use any type assertions.", "10"], + [0, 0, 0, "Do not use any type assertions.", "11"], + [0, 0, 0, "Do not use any type assertions.", "12"] ], "public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -4424,8 +4115,7 @@ exports[`better eslint`] = { ], "public/app/features/transformers/calculateHeatmap/heatmap.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/features/transformers/configFromQuery/ConfigFromQueryTransformerEditor.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] @@ -4437,9 +4127,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/transformers/editors/CalculateFieldTransformerEditor/WindowOptionsEditor.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -4502,8 +4190,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"] ], "public/app/features/transformers/prepareTimeSeries/prepareTimeSeries.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/transformers/prepareTimeSeries/prepareTimeSeries.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -4534,9 +4221,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], - "public/app/features/variables/adhoc/actions.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/variables/adhoc/picker/AdHocFilterRenderer.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -4549,16 +4233,12 @@ exports[`better eslint`] = { "public/app/features/variables/datasource/actions.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/variables/editor/LegacyVariableQueryEditor.tsx:5381": [ - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] - ], "public/app/features/variables/editor/VariableEditorContainer.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/features/variables/editor/VariableEditorEditor.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/variables/editor/VariableEditorList.tsx:5381": [ [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"], @@ -4578,16 +4258,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "9"], [0, 0, 0, "Styles should be written using objects.", "10"] ], - "public/app/features/variables/editor/VariableSelectField.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] - ], - "public/app/features/variables/editor/VariableTextAreaField.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], - "public/app/features/variables/editor/VariableValuesPreview.tsx:5381": [ - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] - ], "public/app/features/variables/editor/getVariableQueryEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] @@ -4634,32 +4304,13 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "public/app/features/variables/pickers/OptionsPicker/reducer.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"] - ], "public/app/features/variables/pickers/shared/VariableLink.tsx:5381": [ - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"] ], "public/app/features/variables/pickers/shared/VariableOptions.tsx:5381": [ [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] ], - "public/app/features/variables/query/QueryVariableEditor.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/variables/query/QueryVariableEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] @@ -4669,11 +4320,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/features/variables/query/actions.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/variables/query/actions.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -4687,24 +4334,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/features/variables/query/queryRunners.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "13"], - [0, 0, 0, "Unexpected any. Specify a different type.", "14"], - [0, 0, 0, "Unexpected any. Specify a different type.", "15"], - [0, 0, 0, "Unexpected any. Specify a different type.", "16"], - [0, 0, 0, "Unexpected any. Specify a different type.", "17"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/variables/query/queryRunners.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -4742,21 +4372,11 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "public/app/features/variables/state/reducers.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/features/variables/state/sharedReducer.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], - "public/app/features/variables/state/transactionReducer.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] - ], "public/app/features/variables/state/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -4794,14 +4414,6 @@ exports[`better eslint`] = { "public/app/features/visualization/data-hover/DataHoverRows.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/features/visualization/data-hover/DataHoverView.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"] - ], "public/app/plugins/datasource/alertmanager/DataSource.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -4813,10 +4425,6 @@ exports[`better eslint`] = { "public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/QueryField.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] - ], "public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -4866,9 +4474,6 @@ exports[`better eslint`] = { "public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryEditor.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryFieldOld.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/DynamicLabelsField.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], @@ -4906,9 +4511,6 @@ exports[`better eslint`] = { "public/app/plugins/datasource/cloudwatch/utils/logsRetry.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/plugins/datasource/dashboard/runSharedRequest.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] @@ -4961,9 +4563,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Unexpected any. Specify a different type.", "3"] ], - "public/app/plugins/datasource/elasticsearch/QueryBuilder.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/datasource/elasticsearch/QueryBuilder.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -5074,6 +4673,9 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] ], + "public/app/plugins/datasource/grafana-pyroscope-datasource/utils.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/plugins/datasource/grafana-testdata-datasource/QueryEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -5100,9 +4702,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/datasource/grafana-testdata-datasource/runStreams.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -5110,14 +4709,11 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/plugins/datasource/grafana/components/QueryEditor.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"], - [0, 0, 0, "Do not use any type assertions.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"] + [0, 0, 0, "Styles should be written using objects.", "4"] ], "public/app/plugins/datasource/grafana/components/TimePickerInput.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], @@ -5129,9 +4725,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] ], - "public/app/plugins/datasource/grafana/datasource.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/datasource/grafana/datasource.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -5180,11 +4773,7 @@ exports[`better eslint`] = { "public/app/plugins/datasource/graphite/datasource.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"] + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], "public/app/plugins/datasource/graphite/datasource.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -5419,9 +5008,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] ], - "public/app/plugins/datasource/jaeger/components/QueryEditor.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/plugins/datasource/jaeger/configuration/ConfigEditor.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], @@ -5476,10 +5062,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "12"], [0, 0, 0, "Styles should be written using objects.", "13"] ], - "public/app/plugins/datasource/loki/components/LokiOptionFields.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] - ], "public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryField.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] @@ -5487,10 +5069,6 @@ exports[`better eslint`] = { "public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/plugins/datasource/loki/configuration/DebugSection.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/plugins/datasource/loki/configuration/DerivedField.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -5700,11 +5278,6 @@ exports[`better eslint`] = { "public/app/plugins/datasource/prometheus/configuration/ExemplarsSettings.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/plugins/datasource/prometheus/datasource.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] - ], "public/app/plugins/datasource/prometheus/datasource.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -5732,9 +5305,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"] ], - "public/app/plugins/datasource/prometheus/metric_find_query.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/datasource/prometheus/metric_find_query.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -5865,87 +5435,38 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], - "public/app/plugins/datasource/prometheus/result_transformer.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/datasource/prometheus/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], - "public/app/plugins/datasource/tempo/LokiSearch.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] - ], - "public/app/plugins/datasource/tempo/NativeSearch/NativeSearch.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] - ], - "public/app/plugins/datasource/tempo/NativeSearch/TagsField/TagsField.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] - ], - "public/app/plugins/datasource/tempo/SearchTraceQLEditor/DurationInput.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], - "public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] + "public/app/plugins/datasource/tempo/ServiceGraphSection.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] + "public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterRenderer.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] + "public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/language_utils.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] + "public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/types.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], - "public/app/plugins/datasource/tempo/ServiceGraphSection.tsx:5381": [ + "public/app/plugins/datasource/tempo/_importedDependencies/test/helpers/createFetchResponse.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] - ], - "public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], - "public/app/plugins/datasource/tempo/configuration/QuerySettings.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] + [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/plugins/datasource/tempo/configuration/TraceQLSearchSettings.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/plugins/datasource/tempo/datasource.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "13"], - [0, 0, 0, "Unexpected any. Specify a different type.", "14"] - ], "public/app/plugins/datasource/tempo/datasource.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Do not use any type assertions.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"] + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"] ], "public/app/plugins/datasource/tempo/language_provider.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -5960,31 +5481,16 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "6"], [0, 0, 0, "Unexpected any. Specify a different type.", "7"] ], - "public/app/plugins/datasource/tempo/traceql/QueryEditor.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], - "public/app/plugins/datasource/tempo/traceql/TraceQLEditor.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] - ], - "public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/plugins/datasource/zipkin/ConfigEditor.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], "public/app/plugins/datasource/zipkin/QueryField.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/plugins/datasource/zipkin/datasource.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/plugins/datasource/zipkin/utils/testResponse.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -5993,39 +5499,10 @@ exports[`better eslint`] = { "public/app/plugins/datasource/zipkin/utils/transforms.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/plugins/panel/alertGroups/AlertGroup.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"] - ], "public/app/plugins/panel/alertlist/AlertInstances.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] ], - "public/app/plugins/panel/alertlist/AlertList.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"], - [0, 0, 0, "Styles should be written using objects.", "9"], - [0, 0, 0, "Styles should be written using objects.", "10"], - [0, 0, 0, "Styles should be written using objects.", "11"], - [0, 0, 0, "Styles should be written using objects.", "12"] - ], - "public/app/plugins/panel/alertlist/AlertListMigrationHandler.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] - ], "public/app/plugins/panel/alertlist/UnifiedAlertList.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -6054,20 +5531,9 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "5"], [0, 0, 0, "Styles should be written using objects.", "6"] ], - "public/app/plugins/panel/annolist/AnnoListPanel.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] - ], "public/app/plugins/panel/annolist/AnnoListPanel.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"] - ], - "public/app/plugins/panel/annolist/module.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"] ], "public/app/plugins/panel/barchart/BarChartPanel.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -6114,9 +5580,6 @@ exports[`better eslint`] = { "public/app/plugins/panel/canvas/globalStyles.ts:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/plugins/panel/dashlist/migrations.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/panel/dashlist/styles.ts:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -6137,8 +5600,7 @@ exports[`better eslint`] = { ], "public/app/plugins/panel/debug/EventBusLogger.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/plugins/panel/gauge/GaugeMigrations.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -6188,25 +5650,11 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/plugins/panel/geomap/utils/layers.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/plugins/panel/geomap/utils/tooltip.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/plugins/panel/gettingstarted/GettingStarted.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"], - [0, 0, 0, "Styles should be written using objects.", "9"], - [0, 0, 0, "Styles should be written using objects.", "10"] - ], "public/app/plugins/panel/gettingstarted/components/DocsCard.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -6214,12 +5662,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "3"], [0, 0, 0, "Styles should be written using objects.", "4"] ], - "public/app/plugins/panel/gettingstarted/components/Step.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"] - ], "public/app/plugins/panel/gettingstarted/components/TutorialCard.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -6229,8 +5671,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "5"] ], "public/app/plugins/panel/gettingstarted/components/sharedStyles.ts:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] + [0, 0, 0, "Styles should be written using objects.", "0"] ], "public/app/plugins/panel/heatmap/HeatmapPanel.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -6266,9 +5707,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "16"] ], "public/app/plugins/panel/histogram/Histogram.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/plugins/panel/live/LiveChannelEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -6284,9 +5723,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "6"], [0, 0, 0, "Styles should be written using objects.", "7"] ], - "public/app/plugins/panel/live/types.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/panel/logs/LogsPanel.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -6350,13 +5786,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] ], - "public/app/plugins/panel/nodeGraph/usePanning.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "public/app/plugins/panel/nodeGraph/utils.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/plugins/panel/piechart/PieChart.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -6368,110 +5797,12 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "public/app/plugins/panel/stat/StatMigrations.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], - "public/app/plugins/panel/state-timeline/migrations.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], - "public/app/plugins/panel/table-old/column_options.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "13"], - [0, 0, 0, "Unexpected any. Specify a different type.", "14"], - [0, 0, 0, "Unexpected any. Specify a different type.", "15"], - [0, 0, 0, "Unexpected any. Specify a different type.", "16"], - [0, 0, 0, "Unexpected any. Specify a different type.", "17"], - [0, 0, 0, "Unexpected any. Specify a different type.", "18"], - [0, 0, 0, "Unexpected any. Specify a different type.", "19"], - [0, 0, 0, "Unexpected any. Specify a different type.", "20"], - [0, 0, 0, "Unexpected any. Specify a different type.", "21"] - ], - "public/app/plugins/panel/table-old/editor.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"] - ], - "public/app/plugins/panel/table-old/module.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "13"], - [0, 0, 0, "Unexpected any. Specify a different type.", "14"], - [0, 0, 0, "Unexpected any. Specify a different type.", "15"], - [0, 0, 0, "Unexpected any. Specify a different type.", "16"], - [0, 0, 0, "Unexpected any. Specify a different type.", "17"], - [0, 0, 0, "Unexpected any. Specify a different type.", "18"] - ], - "public/app/plugins/panel/table-old/renderer.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Do not use any type assertions.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"] - ], - "public/app/plugins/panel/table-old/transformers.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"] - ], - "public/app/plugins/panel/table-old/types.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"] + "public/app/plugins/panel/stat/StatMigrations.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/plugins/panel/state-timeline/migrations.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/plugins/panel/table/TablePanel.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], @@ -6505,30 +5836,18 @@ exports[`better eslint`] = { ], "public/app/plugins/panel/timeseries/migrations.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Do not use any type assertions.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Do not use any type assertions.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"] + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Do not use any type assertions.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Do not use any type assertions.", "5"], + [0, 0, 0, "Unexpected any. Specify a different type.", "6"], + [0, 0, 0, "Do not use any type assertions.", "7"], + [0, 0, 0, "Unexpected any. Specify a different type.", "8"], + [0, 0, 0, "Unexpected any. Specify a different type.", "9"] ], - "public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"], - [0, 0, 0, "Styles should be written using objects.", "9"], - [0, 0, 0, "Styles should be written using objects.", "10"] + "public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/plugins/panel/timeseries/plugins/ThresholdDragHandle.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], @@ -6540,12 +5859,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "6"] ], "public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditor.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditorForm.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], @@ -6556,11 +5870,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "5"], [0, 0, 0, "Styles should be written using objects.", "6"] ], - "public/app/plugins/panel/timeseries/plugins/annotations/AnnotationMarker.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] - ], "public/app/plugins/panel/timeseries/plugins/annotations/AnnotationTooltip.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -6572,6 +5881,15 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "7"], [0, 0, 0, "Styles should be written using objects.", "8"] ], + "public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationMarker2.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationTooltip2.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "public/app/plugins/panel/timeseries/plugins/styles.ts:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] @@ -6596,24 +5914,11 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/plugins/panel/xychart/TooltipView.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/plugins/panel/xychart/XYChartPanel2.tsx:5381": [ + "public/app/plugins/panel/xychart/XYChartPanel.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] - ], - "public/app/plugins/panel/xychart/dims.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"] + [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/plugins/panel/xychart/scatter.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -6731,8 +6036,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "7"], [0, 0, 0, "Unexpected any. Specify a different type.", "8"], [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"] + [0, 0, 0, "Unexpected any. Specify a different type.", "10"] ] }` }; @@ -6810,6 +6114,58 @@ exports[`no gf-form usage`] = { "packages/grafana-e2e/src/flows/addDataSource.ts:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], + "packages/grafana-prometheus/src/components/PromExploreExtraField.tsx:5381": [ + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] + ], + "packages/grafana-prometheus/src/components/PromQueryField.tsx:5381": [ + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] + ], + "packages/grafana-prometheus/src/configuration/AlertingSettingsOverhaul.tsx:5381": [ + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] + ], + "packages/grafana-prometheus/src/configuration/ExemplarSetting.tsx:5381": [ + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] + ], + "packages/grafana-prometheus/src/configuration/PromSettings.tsx:5381": [ + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] + ], + "packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx:5381": [ + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] + ], "packages/grafana-ui/src/components/DataSourceSettings/AlertingSettings.tsx:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], @@ -6911,9 +6267,6 @@ exports[`no gf-form usage`] = { [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], - "plugins-bundled/internal/input-datasource/src/InputConfigEditor.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], "public/app/angular/components/code_editor/code_editor.ts:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], @@ -6996,9 +6349,6 @@ exports[`no gf-form usage`] = { "public/app/core/components/AccessControl/PermissionList.tsx:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], - "public/app/core/components/AccessControl/Permissions.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], "public/app/core/components/PageHeader/PageHeader.tsx:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] @@ -7008,35 +6358,6 @@ exports[`no gf-form usage`] = { [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], - "public/app/features/admin/ldap/LdapConnectionStatus.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], - "public/app/features/admin/ldap/LdapSyncInfo.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], - "public/app/features/admin/ldap/LdapUserGroups.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], - "public/app/features/admin/ldap/LdapUserInfo.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], - "public/app/features/admin/ldap/LdapUserMappingInfo.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], - "public/app/features/admin/ldap/LdapUserPermissions.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], - "public/app/features/admin/ldap/LdapUserTeams.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], "public/app/features/admin/partials/edit_org.html:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], @@ -7053,85 +6374,6 @@ exports[`no gf-form usage`] = { [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], - "public/app/features/alerting/AlertRuleList.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], - "public/app/features/alerting/partials/alert_tab.html:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], "public/app/features/annotations/partials/event_editor.html:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], @@ -7148,15 +6390,9 @@ exports[`no gf-form usage`] = { [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], - "public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], "public/app/features/dashboard/components/SubMenu/SubMenuItems.tsx:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], - "public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], "public/app/features/datasources/components/BasicSettings.tsx:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], @@ -7180,9 +6416,6 @@ exports[`no gf-form usage`] = { "public/app/features/plugins/admin/components/AppConfigWrapper.tsx:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], - "public/app/features/plugins/admin/components/PluginDashboards.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], "public/app/features/query/components/QueryEditorRow.tsx:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], @@ -7285,9 +6518,6 @@ exports[`no gf-form usage`] = { [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], - "public/app/plugins/datasource/cloud-monitoring/components/AnnotationsHelp.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], "public/app/plugins/datasource/cloudwatch/components/ConfigEditor/ConfigEditor.tsx:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], @@ -7295,7 +6525,6 @@ exports[`no gf-form usage`] = { [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], "public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryEditor.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], "public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryField.tsx:5381": [ @@ -7303,12 +6532,6 @@ exports[`no gf-form usage`] = { [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], - "public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryFieldOld.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], "public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LegacyLogGroupNamesSelection.tsx:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] @@ -7377,14 +6600,6 @@ exports[`no gf-form usage`] = { [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], - "public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], - "public/app/plugins/datasource/loki/components/LokiOptionFields.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], "public/app/plugins/datasource/loki/components/LokiQueryField.tsx:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], @@ -7481,6 +6696,24 @@ exports[`no gf-form usage`] = { "public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], + "public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilter.tsx:5381": [ + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] + ], + "public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterKey.tsx:5381": [ + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] + ], + "public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterRenderer.tsx:5381": [ + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] + ], + "public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterValue.tsx:5381": [ + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] + ], + "public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/ConditionSegment.tsx:5381": [ + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] + ], "public/app/plugins/datasource/zipkin/QueryField.tsx:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] diff --git a/.betterer.results.json b/.betterer.results.json new file mode 100644 index 0000000000000..d5db475d6b14b --- /dev/null +++ b/.betterer.results.json @@ -0,0 +1,8166 @@ +{ + "better eslint": { + "/e2e/utils/support/types.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-data/src/dataframe/ArrayDataFrame.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-data/src/dataframe/CircularDataFrame.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/dataframe/DataFrameJSON.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/packages/grafana-data/src/dataframe/DataFrameView.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/packages/grafana-data/src/dataframe/MutableDataFrame.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 13 + }, + { + "message": "Do not use any type assertions.", + "count": 3 + } + ], + "/packages/grafana-data/src/dataframe/StreamingDataFrame.ts": [ + { + "message": "Do not use any type assertions.", + "count": 6 + } + ], + "/packages/grafana-data/src/dataframe/dimensions.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/dataframe/processDataFrame.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/dataframe/processDataFrame.ts": [ + { + "message": "Do not use any type assertions.", + "count": 18 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 5 + } + ], + "/packages/grafana-data/src/datetime/datemath.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-data/src/datetime/durationutil.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-data/src/datetime/formatter.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-data/src/datetime/moment_wrapper.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 8 + } + ], + "/packages/grafana-data/src/datetime/parser.ts": [ + { + "message": "Do not use any type assertions.", + "count": 9 + } + ], + "/packages/grafana-data/src/datetime/rangeutil.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-data/src/events/EventBus.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-data/src/events/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 5 + } + ], + "/packages/grafana-data/src/field/displayProcessor.ts": [ + { + "message": "Do not use any type assertions.", + "count": 3 + } + ], + "/packages/grafana-data/src/field/overrides/processors.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 6 + }, + { + "message": "Do not use any type assertions.", + "count": 3 + } + ], + "/packages/grafana-data/src/field/standardFieldConfigEditorRegistry.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 7 + } + ], + "/packages/grafana-data/src/geo/layer.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-data/src/panel/PanelPlugin.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 6 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/packages/grafana-data/src/panel/registryFactories.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-data/src/themes/colorManipulator.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/packages/grafana-data/src/themes/createColors.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-data/src/transformations/matchers/valueMatchers/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-data/src/transformations/standardTransformersRegistry.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/transformations/transformDataFrame.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/transformations/transformers/nulls/nullInsertThreshold.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/packages/grafana-data/src/transformations/transformers/reduce.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-data/src/transformations/transformers/utils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 7 + } + ], + "/packages/grafana-data/src/types/ScopedVars.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-data/src/types/annotations.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 6 + } + ], + "/packages/grafana-data/src/types/app.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/packages/grafana-data/src/types/config.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/types/dashboard.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/packages/grafana-data/src/types/data.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + } + ], + "/packages/grafana-data/src/types/dataFrame.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + } + ], + "/packages/grafana-data/src/types/dataLink.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 7 + } + ], + "/packages/grafana-data/src/types/datasource.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 25 + } + ], + "/packages/grafana-data/src/types/explore.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/types/fieldOverrides.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 9 + } + ], + "/packages/grafana-data/src/types/flot.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/types/graph.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/packages/grafana-data/src/types/legacyEvents.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-data/src/types/live.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 7 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/packages/grafana-data/src/types/options.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-data/src/types/panel.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 14 + } + ], + "/packages/grafana-data/src/types/plugin.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-data/src/types/select.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-data/src/types/templateVars.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-data/src/types/trace.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/types/transformations.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 5 + } + ], + "/packages/grafana-data/src/types/variables.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/types/vector.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/packages/grafana-data/src/utils/OptionsUIBuilders.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 27 + } + ], + "/packages/grafana-data/src/utils/Registry.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/utils/arrayUtils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-data/src/utils/csv.ts": [ + { + "message": "Do not use any type assertions.", + "count": 3 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-data/src/utils/dataLinks.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/utils/datasource.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/packages/grafana-data/src/utils/flotPairs.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/utils/location.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-data/src/utils/url.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 7 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/packages/grafana-data/src/utils/valueMappings.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/packages/grafana-data/src/vector/AppendedVectors.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/packages/grafana-data/src/vector/ArrayVector.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/packages/grafana-data/src/vector/CircularVector.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/vector/ConstantVector.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/vector/FormattedVector.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/src/vector/FunctionalVector.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 10 + } + ], + "/packages/grafana-data/src/vector/SortedVector.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-data/test/__mocks__/pluginMocks.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/packages/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/packages/grafana-flamegraph/src/FlameGraph/FlameGraphTooltip.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/packages/grafana-flamegraph/src/FlameGraphHeader.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 13 + } + ], + "/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/packages/grafana-o11y-ds-frontend/src/utils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/components/PromExemplarField.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/packages/grafana-prometheus/src/components/PromExploreExtraField.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/packages/grafana-prometheus/src/components/PromQueryField.test.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/components/PromQueryField.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 12 + } + ], + "/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/packages/grafana-prometheus/src/configuration/ConfigEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 16 + } + ], + "/packages/grafana-prometheus/src/configuration/ExemplarsSettings.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/datasource.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 13 + } + ], + "/packages/grafana-prometheus/src/gcopypaste/app/features/live/data/amendTimeSeries.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 4 + } + ], + "/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/index.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValuesWith.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/utils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/gcopypaste/test/helpers/selectOptionInTest.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/language_provider.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 5 + } + ], + "/packages/grafana-prometheus/src/language_utils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/metric_find_query.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 5 + } + ], + "/packages/grafana-prometheus/src/query_hints.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/querybuilder/QueryPattern.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/packages/grafana-prometheus/src/querybuilder/binaryScalarOperations.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/querybuilder/components/LabelFilterItem.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 4 + } + ], + "/packages/grafana-prometheus/src/querybuilder/components/LabelFilters.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/querybuilder/components/LabelParamEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/querybuilder/components/MetricSelect.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + }, + { + "message": "Styles should be written using objects.", + "count": 9 + } + ], + "/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/AdditionalSettings.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/ResultsTable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 13 + } + ], + "/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/styles.ts": [ + { + "message": "Styles should be written using objects.", + "count": 19 + } + ], + "/packages/grafana-prometheus/src/querybuilder/operationUtils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/querybuilder/shared/OperationEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/querybuilder/shared/OperationParamEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/packages/grafana-prometheus/src/querybuilder/shared/QueryBuilderHints.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/packages/grafana-prometheus/src/querybuilder/shared/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/packages/grafana-prometheus/src/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/packages/grafana-runtime/src/analytics/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-runtime/src/config.ts": [ + { + "message": "Do not use any type assertions.", + "count": 3 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-runtime/src/services/AngularLoader.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/packages/grafana-runtime/src/services/EchoSrv.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + } + ], + "/packages/grafana-runtime/src/services/LocationService.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 5 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-runtime/src/services/backendSrv.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 13 + } + ], + "/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-runtime/src/utils/queryResponse.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-schema/src/veneer/common.types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-schema/src/veneer/dashboard.types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 5 + } + ], + "/packages/grafana-sql/src/components/query-editor-raw/QueryToolbox.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/packages/grafana-sql/src/components/query-editor-raw/QueryValidator.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/packages/grafana-sql/src/components/query-editor-raw/RawEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/packages/grafana-sql/src/components/visual-query-builder/AwesomeQueryBuilder.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-sql/src/components/visual-query-builder/SQLWhereRow.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-ui/src/components/DataSourceSettings/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 8 + } + ], + "/packages/grafana-ui/src/components/Forms/Legacy/Input/Input.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/packages/grafana-ui/src/components/Forms/Legacy/Select/Select.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Forms/Legacy/Select/SelectOption.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/JSONFormatter/json_explorer/json_explorer.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-ui/src/components/MatchersUI/FieldValueMatcher.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/MatchersUI/fieldMatchersUI.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Modal/ModalsContext.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + } + ], + "/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-ui/src/components/PanelChrome/index.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Segment/SegmentSelect.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Select/SelectBase.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 8 + }, + { + "message": "Do not use any type assertions.", + "count": 4 + } + ], + "/packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/packages/grafana-ui/src/components/Select/ValueContainer.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Select/resetSelectStyles.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + } + ], + "/packages/grafana-ui/src/components/Select/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 6 + } + ], + "/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 15 + }, + { + "message": "Do not use any type assertions.", + "count": 5 + } + ], + "/packages/grafana-ui/src/components/StatsPicker/StatsPicker.story.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/packages/grafana-ui/src/components/Table/Filter.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Table/FilterPopup.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Table/FooterRow.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Table/HeaderRow.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Table/Table.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Table/TableCell.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 3 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Table/TableCellInspectModal.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Table/reducer.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Table/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-ui/src/components/Table/utils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 6 + } + ], + "/packages/grafana-ui/src/components/Tags/Tag.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/ValuePicker/ValuePicker.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/VizLegend/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 4 + } + ], + "/packages/grafana-ui/src/components/VizTooltip/VizTooltip.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-ui/src/components/uPlot/Plot.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/packages/grafana-ui/src/components/uPlot/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/uPlot/utils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/packages/grafana-ui/src/graveyard/Graph/GraphContextMenu.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/graveyard/Graph/utils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/graveyard/GraphNG/GraphNG.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 6 + }, + { + "message": "Do not use any type assertions.", + "count": 6 + } + ], + "/packages/grafana-ui/src/graveyard/GraphNG/hooks.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/packages/grafana-ui/src/graveyard/GraphNG/nullToUndefThreshold.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-ui/src/graveyard/TimeSeries/utils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-ui/src/options/builder/axis.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-ui/src/options/builder/hideSeries.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-ui/src/slate-plugins/braces.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/packages/grafana-ui/src/slate-plugins/slate-prism/index.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/packages/grafana-ui/src/slate-plugins/slate-prism/options.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-ui/src/slate-plugins/suggestions.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/themes/ThemeContext.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 3 + } + ], + "/packages/grafana-ui/src/themes/stylesFactory.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-ui/src/types/forms.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/types/jquery.d.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 9 + } + ], + "/packages/grafana-ui/src/types/mdx.d.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/types/react-table-config.d.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-ui/src/utils/debug.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/packages/grafana-ui/src/utils/dom.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/packages/grafana-ui/src/utils/logger.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/packages/grafana-ui/src/utils/storybook/withTheme.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/packages/grafana-ui/src/utils/useAsyncDependency.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/plugins-bundled/internal/input-datasource/src/InputDatasource.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/core/TableModel.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 7 + } + ], + "/public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/core/components/DynamicImports/SafeDynamicImport.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/core/components/GraphNG/GraphNG.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 6 + }, + { + "message": "Do not use any type assertions.", + "count": 6 + } + ], + "/public/app/core/components/GraphNG/hooks.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/core/components/NestedFolderPicker/Trigger.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/core/components/OptionsUI/registry.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 11 + } + ], + "/public/app/core/components/PageHeader/PageHeader.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/core/components/QueryOperationRow/OperationRowHelp.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/core/components/QueryOperationRow/QueryOperationRowHeader.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/core/components/RolePicker/RolePickerInput.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/core/components/RolePicker/ValueContainer.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/core/components/TagFilter/TagFilter.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 5 + }, + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/core/components/TagFilter/TagOption.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/core/components/TimeSeries/utils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/core/components/Upgrade/ProBadge.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/core/components/Upgrade/UpgradeBox.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 18 + } + ], + "/public/app/core/components/connectWithCleanUp.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/core/navigation/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/core/reducers/root.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/core/services/ResponseQueue.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/core/services/backend_srv.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 9 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/core/services/context_srv.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/core/services/echo/backends/analytics/ApplicationInsightsBackend.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/core/services/echo/backends/analytics/RudderstackBackend.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/core/specs/backend_srv.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/core/time_series2.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 19 + } + ], + "/public/app/core/utils/ConfigProvider.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/core/utils/connectWithReduxStore.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 8 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/core/utils/deferred.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/core/utils/fetch.ts": [ + { + "message": "Do not use any type assertions.", + "count": 5 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/core/utils/flatten.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/core/utils/object.ts": [ + { + "message": "Do not use any type assertions.", + "count": 3 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/core/utils/richHistory.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/core/utils/ticks.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/admin/LicenseChrome.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/admin/OrgRolePicker.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/admin/UpgradePage.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/admin/UserListPublicDashboardPage/DashboardsListModalButton.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 8 + } + ], + "/public/app/features/admin/UserListPublicDashboardPage/DeleteUserModalButton.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/admin/UserOrgs.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 17 + } + ], + "/public/app/features/admin/UserPermissions.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/admin/UserProfile.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/AlertTab.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/alerting/AlertTabCtrl.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 33 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/alerting/EditNotificationChannelPage.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/alerting/StateHistory.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/TestRuleResult.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/public/app/features/alerting/components/NotificationChannelForm.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/components/NotificationChannelOptions.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/alerting/components/OptionElement.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/alerting/state/ThresholdMapper.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/alerting/state/actions.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/public/app/features/alerting/state/alertDef.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/alerting/state/query_part.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 11 + } + ], + "/public/app/features/alerting/state/reducers.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/AlertGroups.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/AlertWarning.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/AlertsFolderView.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 19 + } + ], + "/public/app/features/alerting/unified/NotificationPolicies.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/PanelAlertTabContent.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/RedirectToRuleViewer.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/alerting/unified/RuleList.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/alerting/unified/components/AlertLabel.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/AlertLabels.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/AlertManagerPicker.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/AlertStateDot.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/AnnotationDetailsField.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/Authorize.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/DetailsField.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/DynamicTable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/features/alerting/unified/components/DynamicTableWithGuidelines.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/features/alerting/unified/components/EmptyArea.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/EmptyAreaWithCTA.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/Expression.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/HoverCard.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/components/Label.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/MetaText.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/Spacer.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/StateColoredText.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/alerting/unified/components/StateTag.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 9 + } + ], + "/public/app/features/alerting/unified/components/Tokenize.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/alerting/unified/components/Well.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/admin/AlertmanagerConfigSelector.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/alert-groups/AlertGroupAlertsTable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/alert-groups/AlertGroupHeader.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/export/FileExportPreview.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/expressions/Expression.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 25 + } + ], + "/public/app/features/alerting/unified/components/expressions/ExpressionStatusIndicator.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/mute-timings/MuteTimingForm.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeInterval.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeRange.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/components/mute-timings/MuteTimingsTable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/notification-policies/Policy.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/notification-policies/PromDurationDocs.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/alerting/unified/components/notification-policies/formStyles.ts": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/features/alerting/unified/components/receivers/AlertInstanceModalSelector.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 18 + } + ], + "/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 9 + } + ], + "/public/app/features/alerting/unified/components/receivers/ReceiversSection.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 13 + } + ], + "/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/components/receivers/form/CollapsibleSection.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/components/receivers/form/GenerateAlertDataModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/receivers/form/TestContactPointModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/receivers/form/fields/KeyValueMapInput.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 5 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/receivers/form/fields/StringArrayInput.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/receivers/form/fields/SubformArrayField.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/receivers/form/fields/SubformField.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/receivers/form/fields/styles.ts": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/AnnotationKeyInput.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 11 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/CloudAlertPreview.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/CloudEvaluationBehavior.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/CustomAnnotationHeaderField.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/DashboardAnnotationField.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 15 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/ExpressionEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/ExpressionsEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 11 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/GroupAndNamespaceFields.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/PreviewRuleResult.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/QueryEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/QueryOptions.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/RecordingRuleEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/RuleInspector.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 9 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 11 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/CloudDataSourceSelector.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/rule-types/RuleType.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 13 + } + ], + "/public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/components/rules/ActionButton.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rules/AlertInstanceStateFilter.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rules/CloudRules.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/components/rules/GrafanaRules.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/alerting/unified/components/rules/NoRulesCTA.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/components/rules/RuleDetails.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rules/RuleDetailsAnnotations.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rules/RuleDetailsDataSources.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rules/RuleDetailsExpression.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/components/rules/RuleHealth.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/rules/RuleListStateSection.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/rules/RuleState.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/rules/RulesGroup.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 12 + } + ], + "/public/app/features/alerting/unified/components/rules/RulesTable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/components/rules/state-history/LokiStateHistory.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/features/alerting/unified/components/rules/state-history/StateHistory.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/silences/Matchers.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/silences/MatchersField.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/components/silences/SilenceDetails.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/silences/SilencePeriod.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/components/silences/SilencedAlertsTable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/components/silences/SilencedInstancesPreview.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/components/silences/SilencesFilter.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/alerting/unified/components/silences/SilencesTable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/home/GettingStarted.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 15 + } + ], + "/public/app/features/alerting/unified/hooks/useAlertmanagerConfig.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/hooks/useControlledFieldArray.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/mocks.ts": [ + { + "message": "Do not use any type assertions.", + "count": 5 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + } + ], + "/public/app/features/alerting/unified/state/actions.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/styles/notifications.ts": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/styles/pagination.ts": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/styles/table.ts": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/alerting/unified/types/receiver-form.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/alerting/unified/utils/misc.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/public/app/features/alerting/unified/utils/misc.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/utils/receiver-form.ts": [ + { + "message": "Do not use any type assertions.", + "count": 3 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/utils/redux.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 7 + } + ], + "/public/app/features/alerting/unified/utils/rulerClient.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/alerting/unified/utils/rules.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 3 + } + ], + "/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/annotations/events_processing.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/annotations/standardAnnotationSupport.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/api-keys/ApiKeysTable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/api-keys/MigrateToServiceAccountsCard.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/auth-config/utils/data.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/canvas/element.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 5 + } + ], + "/public/app/features/canvas/elements/ellipse.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/canvas/runtime/element.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 5 + }, + { + "message": "Do not use any type assertions.", + "count": 4 + } + ], + "/public/app/features/canvas/runtime/frame.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/canvas/runtime/root.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/canvas/runtime/scene.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/connections/components/ConnectionsRedirectNotice/ConnectionsRedirectNotice.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/connections/tabs/ConnectData/CategoryHeader/CategoryHeader.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/connections/tabs/ConnectData/NoAccessModal/NoAccessModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 9 + } + ], + "/public/app/features/connections/tabs/ConnectData/NoResults/NoResults.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/correlations/CorrelationsPage.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/correlations/Forms/AddCorrelationForm.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/correlations/Forms/ConfigureCorrelationBasicInfoForm.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 1 + } + ], + "/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 2 + } + ], + "/public/app/features/dashboard-scene/saving/getSaveDashboardChange.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/dashboard-scene/saving/shared.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 1 + } + ], + "/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + } + ], + "/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 10 + } + ], + "/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts": [ + { + "message": "Do not use any type assertions.", + "count": 7 + } + ], + "/public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard-scene/settings/variables/utils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/dashboard-scene/utils/PanelModelCompatibilityWrapper.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/dashboard-scene/utils/test-utils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 3 + } + ], + "/public/app/features/dashboard/components/AddWidgetModal/AddWidgetModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 4 + }, + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsList.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 6 + } + ], + "/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 7 + }, + { + "message": "Do not use any type assertions.", + "count": 3 + } + ], + "/public/app/features/dashboard/components/DashNav/DashNavButton.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/DashboardLoading/DashboardFailed.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/DashboardLoading/DashboardLoading.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.test.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 7 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/DashboardSettings/ListNewButton.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/dashboard/components/Inspector/PanelInspector.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/PanelEditor/DynamicConfigValueEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/dashboard/components/PanelEditor/OptionsPane.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategory.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 2 + } + ], + "/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 10 + } + ], + "/public/app/features/dashboard/components/PanelEditor/OverrideCategoryTitle.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 3 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 10 + } + ], + "/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/dashboard/components/PanelEditor/VisualizationButton.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 10 + } + ], + "/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/public/app/features/dashboard/components/PanelEditor/utils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/dashboard/components/PublicDashboardNotAvailable/PublicDashboardNotAvailable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/dashboard/components/RowOptions/RowOptionsModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 2 + } + ], + "/public/app/features/dashboard/components/SaveDashboard/SaveDashboardErrorProxy.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/dashboard/components/SaveDashboard/UnsavedChangesModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.test.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 3 + }, + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/SaveDashboard/forms/SaveProvisionedDashboardForm.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/SaveDashboard/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/ShareModal/ShareExport.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/EmailSharingConfiguration.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/SettingsSummary.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/AcknowledgeCheckboxes.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/CreatePublicDashboard.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/SubMenu/SubMenu.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/dashboard/components/TransformationsEditor/TransformationEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 10 + } + ], + "/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard/containers/DashboardPage.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 1 + } + ], + "/public/app/features/dashboard/containers/DashboardPageProxy.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard/dashgrid/SeriesVisibilityConfigFactory.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/dashboard/services/DashboardLoaderSrv.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/public/app/features/dashboard/state/DashboardMigrator.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 12 + } + ], + "/public/app/features/dashboard/state/DashboardMigrator.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 27 + }, + { + "message": "Do not use any type assertions.", + "count": 4 + } + ], + "/public/app/features/dashboard/state/DashboardModel.repeat.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + } + ], + "/public/app/features/dashboard/state/DashboardModel.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard/state/DashboardModel.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 23 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/dashboard/state/PanelModel.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard/state/PanelModel.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 20 + }, + { + "message": "Do not use any type assertions.", + "count": 5 + } + ], + "/public/app/features/dashboard/state/TimeModel.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/dashboard/state/actions.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard/state/initDashboard.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard/utils/getPanelMenu.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard/utils/getPanelMenu.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dashboard/utils/panelMerge.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/datasources/components/BasicSettings.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/datasources/components/DataSourceTestingStatus.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/datasources/components/DataSourceTypeCard.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 1 + } + ], + "/public/app/features/datasources/components/DataSourceTypeCardList.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/datasources/components/EditDataSource.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/datasources/components/picker/DataSourceCard.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 9 + } + ], + "/public/app/features/datasources/components/picker/DataSourceList.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/datasources/components/picker/DataSourceLogo.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/datasources/components/picker/DataSourceModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 10 + } + ], + "/public/app/features/datasources/state/actions.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/datasources/state/actions.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/datasources/state/navModel.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/datasources/state/reducers.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/datasources/state/selectors.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/dimensions/editors/ColorDimensionEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/dimensions/editors/FileUploader.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 8 + } + ], + "/public/app/features/dimensions/editors/FolderPickerTab.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dimensions/editors/ResourceCards.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/dimensions/editors/ResourceDimensionEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/dimensions/editors/ResourcePicker.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/dimensions/editors/ResourcePickerPopover.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/dimensions/editors/ScalarDimensionEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dimensions/editors/ScaleDimensionEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dimensions/editors/TextDimensionEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/dimensions/editors/ThresholdsEditor/ThresholdsEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 8 + } + ], + "/public/app/features/dimensions/editors/URLPickerTab.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/dimensions/editors/ValueMappingsEditor/ValueMappingsEditorModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/dimensions/scale.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dimensions/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/dimensions/utils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/explore/ContentOutline/ContentOutline.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/explore/Logs/LiveLogs.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/explore/Logs/Logs.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/explore/Logs/LogsMetaRow.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/explore/Logs/LogsNavigation.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/explore/Logs/LogsNavigationPages.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/explore/Logs/LogsSamplePanel.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/explore/Logs/LogsVolumePanel.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/explore/Logs/LogsVolumePanelList.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/explore/Logs/utils/LogsCrossFadeTransition.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/explore/NodeGraph/NodeGraphContainer.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/explore/PrometheusListView/ItemLabels.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/explore/PrometheusListView/ItemValues.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/explore/PrometheusListView/RawListContainer.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/features/explore/PrometheusListView/RawListItem.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/explore/PrometheusListView/RawListItemAttributes.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/explore/RichHistory/RichHistoryCard.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 14 + } + ], + "/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 14 + } + ], + "/public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/features/explore/TraceView/TraceView.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/explore/TraceView/components/TracePageHeader/Actions/ActionButton.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/CanvasSpanGraph.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/GraphTicks.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/Scrubber.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/TickLabels.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/ViewingLayer.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 8 + } + ], + "/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 9 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 22 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 8 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 14 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianText.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/TextList.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 11 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/Ticks.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 8 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 8 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/explore/TraceView/components/common/BreakableText.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/explore/TraceView/components/common/CopyIcon.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/explore/TraceView/components/common/LabeledList.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/explore/TraceView/components/common/NewWindowIcon.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/explore/TraceView/components/common/TraceName.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/explore/TraceView/components/demo/trace-generators.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/explore/TraceView/components/model/link-patterns.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 10 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/explore/TraceView/components/model/transform-trace-data.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/explore/TraceView/components/settings/SpanBarSettings.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/explore/TraceView/components/types/trace.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/explore/TraceView/createSpanLink.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/explore/spec/helper/setup.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/explore/state/time.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/explore/state/utils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 4 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/expressions/ExpressionDatasource.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/expressions/components/Condition.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/expressions/components/Math.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/features/expressions/guards.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/geo/editor/GazetteerPathEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/geo/editor/locationModeEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/geo/gazetteer/gazetteer.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/geo/utils/frameVectorSource.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/inspector/DetailText.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/inspector/InspectDataOptions.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/inspector/InspectDataTab.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 1 + } + ], + "/public/app/features/inspector/InspectJSONTab.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 1 + } + ], + "/public/app/features/inspector/InspectStatsTab.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/inspector/InspectStatsTable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/inspector/InspectStatsTraceIdsTable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/inspector/QueryInspector.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 1 + }, + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 2 + } + ], + "/public/app/features/inspector/styles.ts": [ + { + "message": "Styles should be written using objects.", + "count": 11 + } + ], + "/public/app/features/library-panels/components/LibraryPanelInfo/LibraryPanelInfo.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 9 + } + ], + "/public/app/features/library-panels/components/LibraryPanelsView/actions.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/library-panels/components/OpenLibraryPanelModal/OpenLibraryPanelModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/library-panels/styles.ts": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/features/library-panels/utils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/live/LiveConnectionWarning.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/live/centrifuge/LiveDataStream.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/live/centrifuge/channel.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/live/centrifuge/serviceWorkerProxy.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/live/data/amendTimeSeries.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 4 + } + ], + "/public/app/features/logs/components/LoadingIndicator.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/logs/components/LogDetailsRow.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/features/logs/components/LogLabelStats.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/logs/components/LogLabelStatsRow.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 8 + } + ], + "/public/app/features/logs/components/LogLabels.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/logs/components/getLogRowStyles.ts": [ + { + "message": "Styles should be written using objects.", + "count": 36 + } + ], + "/public/app/features/logs/components/log-context/LogRowContextModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 13 + } + ], + "/public/app/features/logs/utils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/manage-dashboards/DashboardImportPage.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/manage-dashboards/components/ImportDashboardLibraryPanelsList.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/features/manage-dashboards/state/actions.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 14 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/manage-dashboards/state/reducers.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/org/state/actions.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/org/state/reducers.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 1 + } + ], + "/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/panel/panellinks/linkSuppliers.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/panel/panellinks/link_srv.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + } + ], + "/public/app/features/playlist/EmptyQueryListBanner.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/playlist/PlaylistForm.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 2 + } + ], + "/public/app/features/playlist/PlaylistTableRows.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/plugins/admin/components/Badges/PluginUpdateAvailableBadge.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/plugins/admin/components/Badges/sharedStyles.ts": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithDataSource.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/plugins/admin/components/HorizontalGroup.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/plugins/admin/components/InstallControls/InstallControlsWarning.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/plugins/admin/components/PluginActions.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/plugins/admin/components/PluginDetailsBody.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/plugins/admin/components/PluginDetailsHeaderDependencies.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/plugins/admin/components/PluginDetailsHeaderSignature.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/plugins/admin/components/PluginDetailsPage.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/plugins/admin/components/PluginSignatureDetailsBadge.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/plugins/admin/components/PluginSubtitle.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/plugins/admin/components/PluginUsage.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/plugins/admin/components/VersionList.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/plugins/admin/hooks/useHistory.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/plugins/admin/hooks/usePluginInfo.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/plugins/admin/pages/Browse.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/plugins/admin/state/actions.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/plugins/admin/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/plugins/datasource_srv.ts": [ + { + "message": "Do not use any type assertions.", + "count": 5 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 8 + } + ], + "/public/app/features/plugins/sandbox/distortion_map.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts": [ + { + "message": "Do not use any type assertions.", + "count": 3 + } + ], + "/public/app/features/plugins/tests/datasource_srv.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/public/app/features/plugins/utils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 4 + } + ], + "/public/app/features/profile/ChangePasswordForm.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/query/components/QueryEditorRow.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 4 + }, + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 1 + } + ], + "/public/app/features/query/components/QueryEditorRowHeader.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 8 + } + ], + "/public/app/features/query/components/QueryGroup.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 2 + }, + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/features/query/components/QueryGroupOptions.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/query/state/DashboardQueryRunner/AnnotationsQueryRunner.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/query/state/DashboardQueryRunner/PublicAnnotationsDataSource.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/query/state/DashboardQueryRunner/testHelpers.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/query/state/DashboardQueryRunner/utils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + } + ], + "/public/app/features/query/state/PanelQueryRunner.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/query/state/runRequest.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/query/state/updateQueries.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 11 + } + ], + "/public/app/features/search/page/components/ActionRow.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/search/page/components/SearchResultsTable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 13 + } + ], + "/public/app/features/search/page/components/columns.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/search/service/bluge.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/search/service/sql.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/search/service/utils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/search/state/SearchStateManager.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/search/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/search/utils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/serviceaccounts/components/CreateTokenModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/features/serviceaccounts/components/ServiceAccountProfile.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/serviceaccounts/components/ServiceAccountProfileRow.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/serviceaccounts/components/ServiceAccountTokensTable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/features/serviceaccounts/state/reducers.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/storage/Breadcrumb.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/storage/FileView.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/features/storage/FolderView.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/storage/RootView.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/storage/StoragePage.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/features/storage/UploadButton.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/storage/storage.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/teams/TeamGroupSync.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/teams/state/reducers.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/teams/state/selectors.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/templating/fieldAccessorCache.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/templating/formatVariableValue.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/public/app/features/templating/templateProxies.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/templating/template_srv.mock.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/templating/template_srv.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 10 + }, + { + "message": "Do not use any type assertions.", + "count": 3 + } + ], + "/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/utils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/transformers/calculateHeatmap/editor/helper.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/transformers/calculateHeatmap/heatmap.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/transformers/configFromQuery/ConfigFromQueryTransformerEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/transformers/editors/CalculateFieldTransformerEditor/CumulativeOptionsEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/transformers/editors/CalculateFieldTransformerEditor/ReduceRowOptionsEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/transformers/editors/CalculateFieldTransformerEditor/WindowOptionsEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/transformers/editors/GroupByTransformerEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/transformers/editors/ReduceTransformerEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/transformers/editors/SortByTransformerEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/transformers/extractFields/components/JSONPathEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/transformers/extractFields/extractFields.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/transformers/extractFields/fieldExtractors.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/transformers/fieldToConfigMapping/FieldToConfigMappingEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/transformers/fieldToConfigMapping/fieldToConfigMapping.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + } + ], + "/public/app/features/transformers/lookupGazetteer/FieldLookupTransformerEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/transformers/lookupGazetteer/fieldLookup.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/transformers/prepareTimeSeries/PrepareTimeSeriesEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/transformers/prepareTimeSeries/prepareTimeSeries.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/transformers/prepareTimeSeries/prepareTimeSeries.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/transformers/spatial/SpatialTransformerEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/transformers/spatial/optionsHelper.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + }, + { + "message": "Do not use any type assertions.", + "count": 3 + } + ], + "/public/app/features/transformers/standardTransformers.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/users/TokenRevokedModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/features/variables/adapters.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/public/app/features/variables/adhoc/picker/AdHocFilterRenderer.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/variables/constant/reducer.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/variables/custom/reducer.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/variables/datasource/actions.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/variables/editor/VariableEditorContainer.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/variables/editor/VariableEditorEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/variables/editor/VariableEditorList.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 2 + }, + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/features/variables/editor/VariableEditorListRow.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 4 + }, + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/features/variables/editor/getVariableQueryEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/variables/editor/reducer.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/variables/editor/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/variables/guard.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/variables/inspect/NetworkGraph.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 7 + } + ], + "/public/app/features/variables/inspect/VariablesUnknownTable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/features/variables/inspect/utils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 8 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/variables/pickers/OptionsPicker/actions.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/variables/pickers/shared/VariableLink.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/features/variables/pickers/shared/VariableOptions.tsx": [ + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 1 + } + ], + "/public/app/features/variables/query/QueryVariableEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/variables/query/VariableQueryRunner.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/variables/query/actions.test.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/variables/query/actions.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 5 + } + ], + "/public/app/features/variables/query/operators.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/variables/query/queryRunners.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/variables/query/queryRunners.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/variables/query/reducer.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/variables/query/variableQueryObserver.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/variables/shared/formatVariable.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/variables/shared/testing/optionsVariableBuilder.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/variables/shared/testing/variableBuilder.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/variables/state/actions.ts": [ + { + "message": "Do not use any type assertions.", + "count": 7 + } + ], + "/public/app/features/variables/state/keyedVariablesReducer.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/variables/state/sharedReducer.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/features/variables/state/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/features/variables/state/upgradeLegacyQueries.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/features/variables/system/adapter.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + }, + { + "message": "Do not use any type assertions.", + "count": 4 + } + ], + "/public/app/features/variables/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 5 + } + ], + "/public/app/features/variables/utils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 6 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/features/visualization/data-hover/DataHoverRows.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/alertmanager/DataSource.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/alertmanager/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/QueryField.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/azuremonitor/utils/messageFromError.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloud-monitoring/CloudMonitoringMetricFindQuery.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloud-monitoring/annotationSupport.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/plugins/datasource/cloud-monitoring/components/Aggregation.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloud-monitoring/components/CloudMonitoringCheatSheet.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloud-monitoring/components/VariableQueryEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloud-monitoring/components/VisualMetricQueryEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloud-monitoring/datasource.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/datasource/cloud-monitoring/functions.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/plugins/datasource/cloud-monitoring/types/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloudwatch/components/CheatSheet/LogsCheatSheet.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/XrayLinkConfig.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryFieldOld.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/DynamicLabelsField.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LegacyLogGroupNamesSelection.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloudwatch/datasource.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloudwatch/guards.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/public/app/plugins/datasource/cloudwatch/memoizedDebounce.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/datasource/cloudwatch/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 7 + } + ], + "/public/app/plugins/datasource/cloudwatch/utils/datalinks.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/plugins/datasource/cloudwatch/utils/logsRetry.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/dashboard/runSharedRequest.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/plugins/datasource/elasticsearch/ElasticResponse.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 32 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/plugins/datasource/elasticsearch/LanguageProvider.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + } + ], + "/public/app/plugins/datasource/elasticsearch/LegacyQueryRunner.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/elasticsearch/QueryBuilder.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 8 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/AddRemove.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/MetricPicker.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/BucketAggregationEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/index.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/TopMetricsSettingsEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/aggregations.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/styles.ts": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryEditorRow.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/SettingsEditorContainer.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/styles.ts": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/elasticsearch/configuration/DataLink.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/plugins/datasource/elasticsearch/datasource.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 5 + } + ], + "/public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/elasticsearch/test-helpers/render.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/LabelsEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/grafana-pyroscope-datasource/utils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/grafana-testdata-datasource/QueryEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 3 + } + ], + "/public/app/plugins/datasource/grafana-testdata-datasource/components/RandomWalkEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationQueryEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationSchemaForm.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/grafana-testdata-datasource/datasource.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/grafana-testdata-datasource/runStreams.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/grafana/components/AnnotationQueryEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/grafana/components/QueryEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + }, + { + "message": "Do not use any type assertions.", + "count": 4 + }, + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/grafana/components/TimePickerInput.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/plugins/datasource/grafana/components/TimeRegionEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/grafana/datasource.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + }, + { + "message": "Do not use any type assertions.", + "count": 7 + } + ], + "/public/app/plugins/datasource/graphite/components/AddGraphiteFunction.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/graphite/components/FunctionParamEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/graphite/components/GraphiteFunctionEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/graphite/components/GraphiteQueryEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/plugins/datasource/graphite/components/MetricTankMetaInspector.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 10 + } + ], + "/public/app/plugins/datasource/graphite/components/TagsSection.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/graphite/components/helpers.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/graphite/datasource.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/public/app/plugins/datasource/graphite/datasource.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 51 + }, + { + "message": "Do not use any type assertions.", + "count": 4 + } + ], + "/public/app/plugins/datasource/graphite/gfunc.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 9 + }, + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/plugins/datasource/graphite/graphite_query.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 19 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/graphite/lexer.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 7 + } + ], + "/public/app/plugins/datasource/graphite/migrations.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/graphite/specs/graphite_query.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/graphite/specs/store.test.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/datasource/graphite/state/context.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/plugins/datasource/graphite/state/helpers.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/graphite/state/store.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/plugins/datasource/graphite/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/datasource/graphite/utils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/influxdb/components/editor/config/ConfigEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/influxdb/components/editor/query/flux/FluxQueryEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/plugins/datasource/influxdb/components/editor/query/fsql/FSQLEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/influxdb/datasource.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 14 + } + ], + "/public/app/plugins/datasource/influxdb/influx_query_model.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 13 + } + ], + "/public/app/plugins/datasource/influxdb/influx_series.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 19 + } + ], + "/public/app/plugins/datasource/influxdb/migrations.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/influxdb/mocks.ts": [ + { + "message": "Do not use any type assertions.", + "count": 4 + } + ], + "/public/app/plugins/datasource/influxdb/query_part.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 16 + } + ], + "/public/app/plugins/datasource/influxdb/response_parser.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/datasource/jaeger/CheatSheet.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/jaeger/configuration/ConfigEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/jaeger/configuration/TraceIdTimeParams.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/jaeger/datasource.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/jaeger/testResponse.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/datasource/jaeger/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/loki/LanguageProvider.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/datasource/loki/LiveStreams.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/loki/components/LokiContextUi.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 11 + } + ], + "/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 14 + } + ], + "/public/app/plugins/datasource/loki/components/LokiOptionFields.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryField.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/loki/configuration/DerivedField.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/plugins/datasource/loki/configuration/DerivedFields.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/plugins/datasource/loki/datasource.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 6 + } + ], + "/public/app/plugins/datasource/loki/queryUtils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/loki/querybuilder/binaryScalarOperations.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/plugins/datasource/loki/querybuilder/components/QueryPattern.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/plugins/datasource/loki/querybuilder/components/QueryPatternsModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/plugins/datasource/loki/streaming.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/datasource/opentsdb/components/OpenTsdbQueryEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/opentsdb/datasource.d.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/opentsdb/datasource.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 57 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/opentsdb/migrations.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/parca/QueryEditor/LabelsEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/prometheus/components/PromExemplarField.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 12 + } + ], + "/public/app/plugins/datasource/prometheus/components/monaco-query-field/MonacoQueryField.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsConfig.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 18 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/configuration/ConfigEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + }, + { + "message": "Styles should be written using objects.", + "count": 16 + } + ], + "/public/app/plugins/datasource/prometheus/configuration/ExemplarsSettings.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/datasource.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 13 + } + ], + "/public/app/plugins/datasource/prometheus/language_provider.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 5 + } + ], + "/public/app/plugins/datasource/prometheus/language_utils.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/metric_find_query.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 5 + } + ], + "/public/app/plugins/datasource/prometheus/query_hints.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/QueryPattern.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/QueryPatternsModal.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/binaryScalarOperations.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilterItem.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 4 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilters.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/components/LabelParamEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + }, + { + "message": "Styles should be written using objects.", + "count": 9 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/AdditionalSettings.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/ResultsTable.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 13 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/styles.ts": [ + { + "message": "Styles should be written using objects.", + "count": 19 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/operationUtils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilterItem.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 8 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationParamEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/shared/QueryBuilderHints.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/shared/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/public/app/plugins/datasource/prometheus/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/public/app/plugins/datasource/tempo/LokiSearch.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/plugins/datasource/tempo/ServiceGraphSection.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterRenderer.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/LanguageProvider.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/monaco-query-field/MonacoQueryField.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + }, + { + "message": "Use data-testid for E2E selectors instead of aria-label", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/monaco-query-field/monaco-completion-provider/index.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + } + ], + "/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/language_utils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + } + ], + "/public/app/plugins/datasource/tempo/_importedDependencies/store.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/tempo/_importedDependencies/test/helpers/createFetchResponse.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/plugins/datasource/tempo/configuration/TraceQLSearchSettings.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/datasource/tempo/datasource.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + } + ], + "/public/app/plugins/datasource/tempo/language_provider.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/tempo/resultTransformer.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + }, + { + "message": "Do not use any type assertions.", + "count": 4 + } + ], + "/public/app/plugins/datasource/zipkin/ConfigEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/datasource/zipkin/QueryField.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/zipkin/datasource.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/datasource/zipkin/utils/testResponse.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/datasource/zipkin/utils/transforms.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/alertGroups/AlertGroup.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/plugins/panel/alertlist/AlertInstances.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/panel/alertlist/AlertList.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + }, + { + "message": "Styles should be written using objects.", + "count": 10 + } + ], + "/public/app/plugins/panel/alertlist/AlertListMigrationHandler.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 17 + } + ], + "/public/app/plugins/panel/alertlist/unified-alerting/UngroupedView.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/plugins/panel/annolist/AnnoListPanel.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/panel/barchart/BarChartPanel.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/plugins/panel/barchart/bars.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/barchart/module.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/barchart/quadtree.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/candlestick/CandlestickPanel.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/canvas/editor/element/APIEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/canvas/editor/element/PlacementEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/canvas/editor/element/elementEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/canvas/editor/inline/InlineEditBody.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 3 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/canvas/editor/layer/TreeNavigationEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/canvas/editor/layer/layerEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/panel/canvas/globalStyles.ts": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/panel/dashlist/styles.ts": [ + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/plugins/panel/datagrid/utils.ts": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/plugins/panel/debug/CursorView.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/debug/EventBusLogger.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/panel/gauge/GaugeMigrations.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/geomap/components/MarkersLegend.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 2 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/geomap/editor/GeomapStyleRulesEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/geomap/editor/StyleEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 12 + } + ], + "/public/app/plugins/panel/geomap/editor/StyleRuleEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/geomap/layers/basemaps/esri.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/geomap/layers/data/geojsonDynamic.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/geomap/layers/data/routeLayer.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/geomap/layers/registry.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/panel/geomap/migrations.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/panel/geomap/utils/layers.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/geomap/utils/tooltip.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/gettingstarted/components/DocsCard.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/plugins/panel/gettingstarted/components/Step.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 4 + } + ], + "/public/app/plugins/panel/gettingstarted/components/TutorialCard.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/plugins/panel/gettingstarted/components/sharedStyles.ts": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/panel/heatmap/HeatmapPanel.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/heatmap/migrations.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/heatmap/palettes.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/heatmap/types.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/heatmap/utils.ts": [ + { + "message": "Do not use any type assertions.", + "count": 17 + } + ], + "/public/app/plugins/panel/histogram/Histogram.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/live/LiveChannelEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/panel/live/LivePanel.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/plugins/panel/live/types.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/logs/LogsPanel.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/nodeGraph/Edge.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/nodeGraph/Legend.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/panel/nodeGraph/Marker.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 3 + } + ], + "/public/app/plugins/panel/nodeGraph/Node.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 9 + } + ], + "/public/app/plugins/panel/nodeGraph/NodeGraph.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 10 + }, + { + "message": "Do not use any type assertions.", + "count": 4 + } + ], + "/public/app/plugins/panel/nodeGraph/ViewControls.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/nodeGraph/editor/ArcOptionsEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/panel/nodeGraph/layout.ts": [ + { + "message": "Do not use any type assertions.", + "count": 2 + } + ], + "/public/app/plugins/panel/nodeGraph/useContextMenu.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/panel/piechart/PieChart.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 5 + } + ], + "/public/app/plugins/panel/piechart/migrations.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/panel/stat/StatMigrations.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/state-timeline/migrations.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 2 + } + ], + "/public/app/plugins/panel/table/TablePanel.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/panel/table/migrations.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 6 + } + ], + "/public/app/plugins/panel/text/TextPanel.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/panel/text/TextPanelEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/panel/text/textPanelMigrationHandler.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/timeseries/TimezonesEditor.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/panel/timeseries/migrations.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 6 + }, + { + "message": "Do not use any type assertions.", + "count": 4 + } + ], + "/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/timeseries/plugins/ThresholdDragHandle.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditorForm.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 7 + } + ], + "/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationTooltip.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 9 + } + ], + "/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationMarker2.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationTooltip2.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/plugins/panel/timeseries/plugins/styles.ts": [ + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/panel/traces/TracesPanel.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], + "/public/app/plugins/panel/welcome/Welcome.tsx": [ + { + "message": "Styles should be written using objects.", + "count": 6 + } + ], + "/public/app/plugins/panel/xychart/AutoEditor.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Styles should be written using objects.", + "count": 2 + } + ], + "/public/app/plugins/panel/xychart/ManualEditor.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/xychart/TooltipView.tsx": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/xychart/XYChartPanel.tsx": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/plugins/panel/xychart/scatter.ts": [ + { + "message": "Do not use any type assertions.", + "count": 7 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 4 + } + ], + "/public/app/store/configureStore.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/store/store.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + }, + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/types/alerting.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 5 + } + ], + "/public/app/types/appEvent.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 7 + } + ], + "/public/app/types/dashboard.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/app/types/events.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 13 + } + ], + "/public/app/types/jquery/jquery.d.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 9 + } + ], + "/public/app/types/store.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + }, + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/app/types/unified-alerting-dto.ts": [ + { + "message": "Do not use any type assertions.", + "count": 1 + } + ], + "/public/test/core/redux/reduxTester.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 6 + } + ], + "/public/test/core/thunk/thunkTester.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 8 + } + ], + "/public/test/global-jquery-shim.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/test/helpers/getDashboardModel.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/test/helpers/initTemplateSrv.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/test/jest-setup.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/test/lib/common.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 1 + } + ], + "/public/test/specs/helpers.ts": [ + { + "message": "Unexpected any. Specify a different type.", + "count": 11 + } + ] + }, + "no undocumented stories": { + "/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/DateTimePickers/RelativeTimeRangePicker/RelativeTimeRangePicker.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/DateTimePickers/TimeOfDayPicker.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/DateTimePickers/WeekStartPicker.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/PageLayout/PageToolbar.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/QueryField/QueryField.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Segment/Segment.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Segment/SegmentAsync.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Slider/RangeSlider.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Slider/Slider.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/StatsPicker/StatsPicker.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/ThemeDemos/ThemeDemo.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/VizLegend/VizLegend.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/VizTooltip/SeriesTable.story.tsx": [ + { + "message": "No undocumented stories are allowed, please add an .mdx file with some documentation", + "count": 1 + } + ] + }, + "no gf-form usage": { + "/e2e/utils/flows/addDataSource.ts": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/packages/grafana-e2e/src/flows/addDataSource.ts": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/components/PromExploreExtraField.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 4 + } + ], + "/packages/grafana-prometheus/src/components/PromQueryField.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 6 + } + ], + "/packages/grafana-prometheus/src/configuration/AlertingSettingsOverhaul.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/packages/grafana-prometheus/src/configuration/ExemplarSetting.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/packages/grafana-prometheus/src/configuration/PromSettings.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 25 + } + ], + "/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/DataSourceSettings/AlertingSettings.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 16 + } + ], + "/packages/grafana-ui/src/components/DataSourceSettings/HttpProxySettings.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/packages/grafana-ui/src/components/DataSourceSettings/TLSAuthSettings.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/packages/grafana-ui/src/components/FormField/FormField.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/FormLabel/FormLabel.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/packages/grafana-ui/src/components/Forms/Legacy/Input/Input.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Forms/Legacy/Select/NoOptionsMessage.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 2 + } + ], + "/packages/grafana-ui/src/components/Forms/Legacy/Select/Select.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 6 + } + ], + "/packages/grafana-ui/src/components/Forms/Legacy/Select/SelectOption.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 4 + } + ], + "/packages/grafana-ui/src/components/Forms/Legacy/Switch/Switch.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 8 + } + ], + "/packages/grafana-ui/src/components/SecretFormField/SecretFormField.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 2 + } + ], + "/packages/grafana-ui/src/components/Segment/Segment.story.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 2 + } + ], + "/packages/grafana-ui/src/components/Segment/SegmentAsync.story.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 2 + } + ], + "/packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/packages/grafana-ui/src/components/Segment/SegmentInput.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 2 + } + ], + "/plugins-bundled/internal/input-datasource/src/InputConfigEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/angular/components/code_editor/code_editor.ts": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/angular/components/form_dropdown/form_dropdown.ts": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 4 + } + ], + "/public/app/angular/components/info_popover.ts": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 2 + } + ], + "/public/app/angular/components/switch.ts": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 8 + } + ], + "/public/app/angular/dropdown_typeahead.ts": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 4 + } + ], + "/public/app/angular/metric_segment.ts": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 4 + } + ], + "/public/app/angular/misc.ts": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 2 + } + ], + "/public/app/angular/panel/partials/query_editor_row.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/angular/partials/tls_auth_settings.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 35 + } + ], + "/public/app/core/components/AccessControl/PermissionList.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/core/components/PageHeader/PageHeader.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 2 + } + ], + "/public/app/features/admin/UserLdapSyncInfo.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/public/app/features/admin/partials/edit_org.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 8 + } + ], + "/public/app/features/admin/partials/styleguide.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 4 + } + ], + "/public/app/features/alerting/AlertRuleList.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 4 + } + ], + "/public/app/features/alerting/partials/alert_tab.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 71 + } + ], + "/public/app/features/annotations/partials/event_editor.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 7 + } + ], + "/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 2 + } + ], + "/public/app/features/dashboard/components/SubMenu/SubMenuItems.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/features/datasources/components/BasicSettings.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/public/app/features/datasources/components/ButtonRow.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/features/datasources/components/DataSourceLoadError.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/features/datasources/components/DataSourcePluginState.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 4 + } + ], + "/public/app/features/datasources/components/DataSourceTestingStatus.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/features/plugins/admin/components/AppConfigWrapper.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/features/query/components/QueryEditorRow.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/features/query/components/QueryGroupOptions.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 17 + } + ], + "/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 5 + } + ], + "/public/app/features/variables/adhoc/picker/AdHocFilter.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/features/variables/adhoc/picker/AdHocFilterKey.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/public/app/features/variables/adhoc/picker/AdHocFilterRenderer.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/features/variables/adhoc/picker/AdHocFilterValue.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/features/variables/adhoc/picker/ConditionSegment.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 2 + } + ], + "/public/app/features/variables/pickers/PickerRenderer.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 5 + } + ], + "/public/app/features/variables/pickers/shared/VariableInput.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/partials/confirm_modal.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/partials/reset_password.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 13 + } + ], + "/public/app/partials/signup_invited.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 14 + } + ], + "/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 4 + } + ], + "/public/app/plugins/datasource/cloud-monitoring/components/AnnotationsHelp.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/ConfigEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/XrayLinkConfig.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 2 + } + ], + "/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryField.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryFieldOld.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 4 + } + ], + "/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LegacyLogGroupNamesSelection.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 2 + } + ], + "/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LogGroupsField.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 2 + } + ], + "/public/app/plugins/datasource/elasticsearch/components/QueryEditor/SettingsEditorContainer.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/plugins/datasource/grafana-pyroscope-datasource/ConfigEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/public/app/plugins/datasource/graphite/components/AnnotationsEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/public/app/plugins/datasource/graphite/configuration/MappingsConfiguration.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/plugins/datasource/influxdb/components/editor/annotation/AnnotationEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 8 + } + ], + "/public/app/plugins/datasource/influxdb/components/editor/query/QueryEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/plugins/datasource/influxdb/components/editor/query/flux/FluxQueryEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 5 + } + ], + "/public/app/plugins/datasource/influxdb/components/editor/query/fsql/FSQLEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 5 + } + ], + "/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/PartListSection.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/TagsSection.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/plugins/datasource/influxdb/components/editor/variable/VariableQueryEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 4 + } + ], + "/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/plugins/datasource/loki/components/LokiOptionFields.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/public/app/plugins/datasource/loki/components/LokiQueryField.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/public/app/plugins/datasource/loki/configuration/DerivedField.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/plugins/datasource/opentsdb/components/AnnotationEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 4 + } + ], + "/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 6 + } + ], + "/public/app/plugins/datasource/prometheus/configuration/AlertingSettingsOverhaul.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/public/app/plugins/datasource/prometheus/configuration/AzureAuthSettings.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 21 + } + ], + "/public/app/plugins/datasource/prometheus/configuration/ExemplarSetting.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 25 + } + ], + "/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilter.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterKey.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterRenderer.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterValue.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 1 + } + ], + "/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/ConditionSegment.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 2 + } + ], + "/public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/LokiQueryField.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 3 + } + ], + "/public/app/plugins/datasource/zipkin/QueryField.tsx": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 2 + } + ], + "/public/app/plugins/panel/graph/axes_editor.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 50 + } + ], + "/public/app/plugins/panel/graph/tab_display.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 56 + } + ], + "/public/app/plugins/panel/graph/tab_legend.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 42 + } + ], + "/public/app/plugins/panel/graph/tab_series_overrides.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 10 + } + ], + "/public/app/plugins/panel/graph/thresholds_form.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 31 + } + ], + "/public/app/plugins/panel/graph/time_regions_form.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 32 + } + ], + "/public/app/plugins/panel/heatmap/partials/axes_editor.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 53 + } + ], + "/public/app/plugins/panel/heatmap/partials/display_editor.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 51 + } + ], + "/public/app/plugins/panel/table-old/column_options.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 91 + } + ], + "/public/app/plugins/panel/table-old/editor.html": [ + { + "message": "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", + "count": 22 + } + ] + } +} diff --git a/.betterer.ts b/.betterer.ts index e6e3a0667771f..019979e7e9405 100644 --- a/.betterer.ts +++ b/.betterer.ts @@ -5,11 +5,12 @@ import path from 'path'; import { glob } from 'glob'; // Why are we ignoring these? -// They're all deprecated/being removed soon so doesn't make sense to fix types +// They're all deprecated/being removed so doesn't make sense to fix types const eslintPathsToIgnore = [ 'packages/grafana-e2e', // deprecated. 'public/app/angular', // will be removed in Grafana 11 'public/app/plugins/panel/graph', // will be removed alongside angular + 'public/app/plugins/panel/table-old', // will be removed alongside angular ]; // Avoid using functions that report the position of the issues, as this causes a lot of merge conflicts @@ -31,9 +32,16 @@ function countUndocumentedStories() { await Promise.all( filePaths.map(async (filePath) => { // look for .mdx import in the story file - const regex = new RegExp("^import.*.mdx';$", 'gm'); + const mdxImportRegex = new RegExp("^import.*\\.mdx';$", 'gm'); + // Looks for the "autodocs" string in the file + const autodocsStringRegex = /autodocs/; + const fileText = await fs.readFile(filePath, 'utf8'); - if (!regex.test(fileText)) { + + const hasMdxImport = mdxImportRegex.test(fileText); + const hasAutodocsString = autodocsStringRegex.test(fileText); + // If both .mdx import and autodocs string are missing, add an issue + if (!hasMdxImport && !hasAutodocsString) { // In this case the file contents don't matter: const file = fileTestResult.addFile(filePath, ''); // Add the issue to the first character of the file: diff --git a/.bingo/Variables.mk b/.bingo/Variables.mk index 2c9eb124234e3..363c8b2cd3082 100644 --- a/.bingo/Variables.mk +++ b/.bingo/Variables.mk @@ -1,13 +1,8 @@ -# Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.8. DO NOT EDIT. +# Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.9. DO NOT EDIT. # All tools are designed to be build inside $GOBIN. BINGO_DIR := $(dir $(lastword $(MAKEFILE_LIST))) GOPATH ?= $(shell go env GOPATH) -ifeq ($(OS),Windows_NT) - PATHSEP := $(if $(COMSPEC),;,:) - GOBIN ?= $(firstword $(subst $(PATHSEP), ,$(subst \,/,${GOPATH})))/bin -else - GOBIN ?= $(firstword $(subst :, ,${GOPATH}))/bin -endif +GOBIN ?= $(firstword $(subst :, ,${GOPATH}))/bin GO ?= $(shell which go) # Below generated variables ensure that every time a tool under each variable is invoked, the correct version @@ -64,9 +59,9 @@ $(SWAGGER): $(BINGO_DIR)/swagger.mod @echo "(re)installing $(GOBIN)/swagger-v0.30.2" @cd $(BINGO_DIR) && GOWORK=off $(GO) build -mod=mod -modfile=swagger.mod -o=$(GOBIN)/swagger-v0.30.2 "github.com/go-swagger/go-swagger/cmd/swagger" -WIRE := $(GOBIN)/wire-v0.5.0 +WIRE := $(GOBIN)/wire-v0.6.0 $(WIRE): $(BINGO_DIR)/wire.mod @# Install binary/ries using Go 1.14+ build command. This is using bwplotka/bingo-controlled, separate go module with pinned dependencies. - @echo "(re)installing $(GOBIN)/wire-v0.5.0" - @cd $(BINGO_DIR) && GOWORK=off $(GO) build -mod=mod -modfile=wire.mod -o=$(GOBIN)/wire-v0.5.0 "github.com/google/wire/cmd/wire" + @echo "(re)installing $(GOBIN)/wire-v0.6.0" + @cd $(BINGO_DIR) && GOWORK=off $(GO) build -mod=mod -modfile=wire.mod -o=$(GOBIN)/wire-v0.6.0 "github.com/google/wire/cmd/wire" diff --git a/.bingo/variables.env b/.bingo/variables.env index cf844231b928d..dc64e5f430bc0 100644 --- a/.bingo/variables.env +++ b/.bingo/variables.env @@ -1,4 +1,4 @@ -# Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.8. DO NOT EDIT. +# Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.9. DO NOT EDIT. # All tools are designed to be build inside $GOBIN. # Those variables will work only until 'bingo get' was invoked, or if tools were installed via Makefile's Variables.mk. GOBIN=${GOBIN:=$(go env GOBIN)} @@ -22,5 +22,5 @@ LEFTHOOK="${GOBIN}/lefthook-v1.4.8" SWAGGER="${GOBIN}/swagger-v0.30.2" -WIRE="${GOBIN}/wire-v0.5.0" +WIRE="${GOBIN}/wire-v0.6.0" diff --git a/.bingo/wire.mod b/.bingo/wire.mod index fc39b30da167c..fdfc21a466ffd 100644 --- a/.bingo/wire.mod +++ b/.bingo/wire.mod @@ -2,4 +2,4 @@ module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT go 1.16 -require github.com/google/wire v0.5.0 // cmd/wire +require github.com/google/wire v0.6.0 // cmd/wire diff --git a/.bingo/wire.sum b/.bingo/wire.sum index 6d4b4b386440f..be6e646d063d6 100644 --- a/.bingo/wire.sum +++ b/.bingo/wire.sum @@ -1,13 +1,66 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/subcommands v1.0.1 h1:/eqq+otEXm5vhfBrbREPCSVQbvofip6kIz+mX5TUH7k= github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8= github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU= +github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI= +github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b h1:NVD8gBK33xpdqCaZVVtd6OFJp+3dxkXuz7+U7KaVN6s= golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/.bra.toml b/.bra.toml index b57cf283edcf9..e76c9fd62095a 100644 --- a/.bra.toml +++ b/.bra.toml @@ -1,5 +1,6 @@ [run] init_cmds = [ + ["GO_BUILD_DEV=1", "make", "gen-go"], ["GO_BUILD_DEV=1", "make", "build-go"], ["make", "gen-jsonnet"], ["./bin/grafana", "server", "-packaging=dev", "cfg:app_mode=development"] @@ -16,6 +17,7 @@ watch_exts = [".go", ".ini", ".toml", ".template.html"] ignore_files = [".*_gen.go"] build_delay = 1500 cmds = [ + ["GO_BUILD_DEV=1", "make", "gen-go"], ["GO_BUILD_DEV=1", "make", "build-go"], ["make", "gen-jsonnet"], ["./bin/grafana", "server", "-packaging=dev", "cfg:app_mode=development"] diff --git a/.changelog-archive/CHANGELOG.2.md b/.changelog-archive/CHANGELOG.2.md index 34491f2731f88..7fc14a683dc40 100644 --- a/.changelog-archive/CHANGELOG.2.md +++ b/.changelog-archive/CHANGELOG.2.md @@ -105,7 +105,7 @@ - Notice to makers/users of custom data sources, there is a minor breaking change in 2.2 that require an update to custom data sources for them to work in 2.2. [Read this doc](https://github.com/grafana/grafana/tree/master/docs/sources/datasources/plugin_api.md) for more on the data source api change. -- Data source api changes, [PLUGIN_CHANGES.md](https://github.com/grafana/grafana/blob/master/public/app/plugins/PLUGIN_CHANGES.md) +- Data source api changes, [PLUGIN_CHANGES.md](https://github.com/grafana/grafana/blob/main/public/app/plugins/PLUGIN_CHANGES.md) - The duplicate query function used in data source editors is changed, and moveMetricQuery function was renamed **Tech (Note for devs)** diff --git a/.changelog-archive/CHANGELOG.3.md b/.changelog-archive/CHANGELOG.3.md index 12f18c3128765..258aa1ce2e432 100644 --- a/.changelog-archive/CHANGELOG.3.md +++ b/.changelog-archive/CHANGELOG.3.md @@ -198,7 +198,7 @@ slack channel (link to slack channel in readme). ### Breaking changes -- **Plugin API**: Both data source and panel plugin api (and plugin.json schema) have been updated, requiring an update to plugins. See [plugin api](https://github.com/grafana/grafana/blob/master/public/app/plugins/plugin_api.md) for more info. +- **Plugin API**: Both data source and panel plugin api (and plugin.json schema) have been updated, requiring an update to plugins. See [plugin api](https://github.com/grafana/grafana/blob/main/public/app/plugins/plugin_api.md) for more info. - **InfluxDB 0.8.x** The data source for the old version of influxdb (0.8.x) is no longer included in default builds, but can easily be installed via improved plugin system, closes [#3523](https://github.com/grafana/grafana/issues/3523) - **KairosDB** The data source is no longer included in default builds, but can easily be installed via improved plugin system, closes [#3524](https://github.com/grafana/grafana/issues/3524) - **Templating**: Templating value formats (glob/regex/pipe etc) are now handled automatically and not specified by the user, this makes variable values possible to reuse in many contexts. It can in some edge cases break existing dashboards that have template variables that do not reload on dashboard load. To fix any issue just go into template variable options and update the variable (so it's values are reloaded.). diff --git a/.changelog-archive/CHANGELOG.4.md b/.changelog-archive/CHANGELOG.4.md index bb93963a2b443..e919c0c51e3e9 100644 --- a/.changelog-archive/CHANGELOG.4.md +++ b/.changelog-archive/CHANGELOG.4.md @@ -100,7 +100,7 @@ See [security announcement](https://community.grafana.com/t/grafana-5-2-3-and-4- ## Tech - **Go**: Grafana is now built using golang 1.9 -- **Webpack**: Changed from systemjs to webpack (see readme or building from source guide for new build instructions). Systemjs is still used to load plugins but now plugins can only import a limited set of dependencies. See [PLUGIN_DEV.md](https://github.com/grafana/grafana/blob/master/PLUGIN_DEV.md) for more details on how this can effect some plugins. +- **Webpack**: Changed from systemjs to webpack (see readme or building from source guide for new build instructions). Systemjs is still used to load plugins but now plugins can only import a limited set of dependencies. See [PLUGIN_DEV.md](https://github.com/grafana/grafana/blob/main/PLUGIN_DEV.md) for more details on how this can effect some plugins. # 4.5.2 (2017-09-22) diff --git a/.changelog-archive/CHANGELOG.6.md b/.changelog-archive/CHANGELOG.6.md index 4cfbded16cab1..6d4ddfe0086de 100644 --- a/.changelog-archive/CHANGELOG.6.md +++ b/.changelog-archive/CHANGELOG.6.md @@ -1291,4 +1291,4 @@ repo on July 1st. Make sure you have switched to the new repo by then. The new r - **Text Panel**: The text panel does no longer by default allow unsanitized HTML. [#4117](https://github.com/grafana/grafana/issues/4117). This means that if you have text panels with scripts tags they will no longer work as before. To enable unsafe javascript execution in text panels enable the settings `disable_sanitize_html` under the section `[panels]` in your Grafana ini file, or set env variable `GF_PANELS_DISABLE_SANITIZE_HTML=true`. - **Dashboard**: Panel property `minSpan` replaced by `maxPerRow`. Dashboard migration will automatically migrate all dashboard panels using the `minSpan` property to the new `maxPerRow` property [#12991](https://github.com/grafana/grafana/pull/12991) -For older release notes, refer to the [CHANGELOG_ARCHIVE.md](https://github.com/grafana/grafana/blob/master/CHANGELOG_ARCHIVE.md) +For older release notes, refer to the [CHANGELOG_ARCHIVE.md](https://github.com/grafana/grafana/blob/main/CHANGELOG_ARCHIVE.md) diff --git a/.changelog-archive/CHANGELOG.7.md b/.changelog-archive/CHANGELOG.7.md index baa857fcdfc88..504788b7481e4 100644 --- a/.changelog-archive/CHANGELOG.7.md +++ b/.changelog-archive/CHANGELOG.7.md @@ -544,7 +544,7 @@ Issue [#29407](https://github.com/grafana/grafana/issues/29407) We have upgraded AngularJS from version 1.6.6 to 1.8.2. Due to this upgrade some old angular plugins might stop working and will require a small update. This is due to the deprecation and removal of pre-assigned bindings. So if your custom angular controllers expect component bindings in the controller constructor you need to move this code to an `$onInit` function. For more details on how to migrate AngularJS code open the [migration guide](https://docs.angularjs.org/guide/migration) and search for **pre-assigning bindings**. -In order not to break all angular panel plugins and data sources we have some custom [angular inject behavior](https://github.com/grafana/grafana/blob/master/public/app/core/injectorMonkeyPatch.ts) that makes sure that bindings for these controllers are still set before constructor is called so many old angular panels and data source plugins will still work. Issue [#28736](https://github.com/grafana/grafana/issues/28736) +In order not to break all angular panel plugins and data sources we have some custom [angular inject behavior](https://github.com/grafana/grafana/blob/main/public/app/core/injectorMonkeyPatch.ts) that makes sure that bindings for these controllers are still set before constructor is called so many old angular panels and data source plugins will still work. Issue [#28736](https://github.com/grafana/grafana/issues/28736) ### Deprecations @@ -1288,8 +1288,8 @@ This option to group query variable values into groups by tags has been an exper - **Datasource/Loki**: Support for [deprecated Loki endpoints](https://github.com/grafana/loki/blob/master/docs/api.md#lokis-http-api) has been removed. - **Backend plugins**: Grafana now requires backend plugins to be signed, otherwise Grafana will not load/start them. This is an additional security measure to make sure backend plugin binaries and files haven't been tampered with. Refer to [Upgrade Grafana](https://grafana.com/docs/grafana/latest/installation/upgrading/#upgrading-to-v7-0) for more information. - **Docker**: Our Ubuntu based images have been upgraded to Ubuntu [20.04 LTS](https://releases.ubuntu.com/20.04/). -- **@grafana/ui**: Forms migration notice, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md) -- **@grafana/ui**: Select API change for creating custom values, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md) +- **@grafana/ui**: Forms migration notice, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/main/packages/grafana-ui/CHANGELOG.md) +- **@grafana/ui**: Select API change for creating custom values, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/main/packages/grafana-ui/CHANGELOG.md) **Deprecation warnings** @@ -1304,7 +1304,7 @@ Not just visualizing data from anywhere, in Grafana 7 you can transform it too. Data transformations will provide a common set of data operations that were previously duplicated as custom features in many panels or data sources but are now an integral part of the Grafana data processing pipeline and something all data sources and panels can take advantage of. -In Grafana 7.0 we have a shared data model for both time series and table data that we call [DataFrame](https://github.com/grafana/grafana/blob/master/docs/sources/plugins/developing/dataframe.md). A DataFrame is like a table with columns but we refer to columns as fields. A time series is simply a DataFrame with two fields (time & value). +In Grafana 7.0 we have a shared data model for both time series and table data that we call [DataFrame](https://github.com/grafana/grafana/blob/main/docs/sources/plugins/developing/dataframe.md). A DataFrame is like a table with columns but we refer to columns as fields. A time series is simply a DataFrame with two fields (time & value). **Transformations shipping in 7.0** @@ -1414,7 +1414,7 @@ We have also extended the time zone options so you can select any of the standar ### Features / Enhancements - **Docker**: Upgrade to Alpine 3.11. [#24056](https://github.com/grafana/grafana/pull/24056), [@aknuds1](https://github.com/aknuds1) -- **Forms**: Remove Forms namespace [BREAKING]. Will cause all `Forms` imports to stop working. See migration guide in [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md)[#24378](https://github.com/grafana/grafana/pull/24378), [@tskarhed](https://github.com/tskarhed) +- **Forms**: Remove Forms namespace [BREAKING]. Will cause all `Forms` imports to stop working. See migration guide in [@grafana/ui changelog](https://github.com/grafana/grafana/blob/main/packages/grafana-ui/CHANGELOG.md)[#24378](https://github.com/grafana/grafana/pull/24378), [@tskarhed](https://github.com/tskarhed) ### Bug Fixes @@ -1429,7 +1429,7 @@ We have also extended the time zone options so you can select any of the standar - **Removed PhantomJS**: PhantomJS was deprecated in [Grafana v6.4](https://grafana.com/docs/grafana/latest/guides/whats-new-in-v6-4/#phantomjs-deprecation) and starting from Grafana v7.0.0, all PhantomJS support has been removed. This means that Grafana no longer ships with a built-in image renderer, and we advise you to install the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer). - **Docker**: Our Ubuntu based images have been upgraded to Ubuntu [20.04 LTS](https://releases.ubuntu.com/20.04/). - **Dashboard**: A global minimum dashboard refresh interval is now enforced and defaults to 5 seconds. -- **@grafana/ui**: Forms migration notice, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md) +- **@grafana/ui**: Forms migration notice, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/main/packages/grafana-ui/CHANGELOG.md) - **Interval calculation**: There is now a new option `Max data points` that controls the auto interval `$__interval` calculation. Interval was previously calculated by dividing the panel width by the time range. With the new max data points option it is now easy to set `$__interval` to a dynamic value that is time range agnostic. For example if you set `Max data points` to 10 Grafana will dynamically set `$__interval` by dividing the current time range by 10. - **Datasource/Loki**: Support for [deprecated Loki endpoints](https://github.com/grafana/loki/blob/master/docs/api.md#lokis-http-api) has been removed. @@ -1484,8 +1484,8 @@ We have also extended the time zone options so you can select any of the standar - **Removed PhantomJS**: PhantomJS was deprecated in [Grafana v6.4](https://grafana.com/docs/grafana/latest/guides/whats-new-in-v6-4/#phantomjs-deprecation) and starting from Grafana v7.0.0, all PhantomJS support has been removed. This means that Grafana no longer ships with a built-in image renderer, and we advise you to install the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer). - **Docker**: Our Ubuntu based images have been upgraded to Ubuntu [20.04 LTS](https://releases.ubuntu.com/20.04/). - **Dashboard**: A global minimum dashboard refresh interval is now enforced and defaults to 5 seconds. -- **@grafana/ui**: Forms migration notice, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md) -- **@grafana/ui**: Select API change for creating custom values, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md) +- **@grafana/ui**: Forms migration notice, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/main/packages/grafana-ui/CHANGELOG.md) +- **@grafana/ui**: Select API change for creating custom values, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/main/packages/grafana-ui/CHANGELOG.md) - **Interval calculation**: There is now a new option `Max data points` that controls the auto interval `$__interval` calculation. Interval was previously calculated by dividing the panel width by the time range. With the new max data points option it is now easy to set `$__interval` to a dynamic value that is time range agnostic. For example if you set `Max data points` to 10 Grafana will dynamically set `$__interval` by dividing the current time range by 10. - **Datasource/Loki**: Support for [deprecated Loki endpoints](https://github.com/grafana/loki/blob/master/docs/api.md#lokis-http-api) has been removed. diff --git a/.drone.yml b/.drone.yml index 2b571a50617cc..4443759a20a77 100644 --- a/.drone.yml +++ b/.drone.yml @@ -5,7 +5,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: pr-verify-drone node: @@ -17,14 +18,14 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: compile-build-cmd - commands: - ./bin/build verify-drone @@ -55,7 +56,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: pr-verify-starlark node: @@ -67,21 +69,21 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: compile-build-cmd - commands: - go install github.com/bazelbuild/buildtools/buildifier@latest - buildifier --lint=warn -mode=check -r . depends_on: - compile-build-cmd - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: lint-starlark trigger: event: @@ -105,7 +107,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: pr-test-frontend node: @@ -117,10 +120,11 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - - yarn install --immutable + - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python + - yarn install --immutable || yarn install --immutable depends_on: [] image: node:20.9.0-alpine name: yarn-install @@ -184,7 +188,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: pr-lint-frontend node: @@ -217,10 +222,11 @@ steps: name: clone-enterprise - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - - yarn install --immutable + - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python + - yarn install --immutable || yarn install --immutable depends_on: [] image: node:20.9.0-alpine name: yarn-install @@ -244,9 +250,9 @@ steps: \"\nTranslation extraction has not been committed. Please run 'yarn i18n:extract', commit the changes and push again.\"\n exit 1\n fi\n \ " - - yarn run i18n:compile depends_on: - yarn-install + failure: ignore image: node:20.9.0-alpine name: verify-i18n trigger: @@ -273,7 +279,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: pr-test-backend node: @@ -306,7 +313,7 @@ steps: name: clone-enterprise - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -316,7 +323,7 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -325,21 +332,22 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-jsonnet - commands: - apk add --update make - make gen-go depends_on: - verify-gen-cue - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: wire-install - commands: - apk add --update build-base shared-mime-info shared-mime-info-lang - - go test -tags requires_buildifer -short -covermode=atomic -timeout=5m ./pkg/... + - go list -f '{{.Dir}}/...' -m | xargs go test -tags requires_buildifer -short -covermode=atomic + -timeout=5m depends_on: - wire-install - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: test-backend - commands: - apk add --update build-base @@ -348,7 +356,7 @@ steps: | grep -o '\(.*\)/' | sort -u) depends_on: - wire-install - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: test-backend-integration trigger: event: @@ -358,6 +366,7 @@ trigger: - docs/** - '*.md' include: + - Makefile - pkg/** - packaging/** - .drone.yml @@ -379,7 +388,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: pr-lint-backend node: @@ -391,14 +401,14 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: compile-build-cmd - commands: - apk add --update curl jq bash @@ -425,7 +435,7 @@ steps: - apk add --update make - make gen-go depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: wire-install - commands: - apk add --update make build-base @@ -434,16 +444,16 @@ steps: - wire-install environment: CGO_ENABLED: "1" - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: lint-backend - commands: - go run scripts/modowners/modowners.go check go.mod - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: validate-modfile - commands: - apk add --update make - make swagger-validate - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: validate-openapi-spec trigger: event: @@ -453,6 +463,7 @@ trigger: - docs/** - '*.md' include: + - Makefile - pkg/** - packaging/** - .drone.yml @@ -474,7 +485,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: pr-build-e2e node: @@ -486,11 +498,11 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -499,7 +511,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: compile-build-cmd - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -509,7 +521,7 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -518,17 +530,18 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-jsonnet - commands: - apk add --update make - make gen-go depends_on: - verify-gen-cue - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: wire-install - commands: - - yarn install --immutable + - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python + - yarn install --immutable || yarn install --immutable depends_on: [] image: node:20.9.0-alpine name: yarn-install @@ -558,10 +571,13 @@ steps: from_secret: drone_token - commands: - /src/grafana-build artifacts -a targz:grafana:linux/amd64 -a targz:grafana:linux/arm64 - -a targz:grafana:linux/arm/v7 --go-version=1.21.5 --yarn-cache=$$YARN_CACHE_FOLDER + -a targz:grafana:linux/arm/v7 --go-version=1.21.8 --yarn-cache=$$YARN_CACHE_FOLDER --build-id=$$DRONE_BUILD_NUMBER --grafana-dir=$$PWD > packages.txt depends_on: - yarn-install + environment: + _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: + from_secret: dagger_token image: grafana/grafana-build:main name: rgm-package pull: always @@ -580,7 +596,7 @@ steps: GF_APP_MODE: development GF_SERVER_HTTP_PORT: "3001" GF_SERVER_ROUTER_LOGGING: "1" - image: alpine:3.18.4 + image: alpine:3.19.1 name: grafana-server - commands: - ./bin/build e2e-tests --port 3001 --suite dashboards-suite @@ -641,6 +657,61 @@ steps: repo: - grafana/grafana - commands: + - sleep 10s + - yarn e2e:playwright + depends_on: + - grafana-server + environment: + HOST: grafana-server + PORT: "3001" + PROV_DIR: /grafana/scripts/grafana-server/tmp/conf/provisioning + image: mcr.microsoft.com/playwright:v1.42.1-jammy + name: playwright-plugin-e2e +- commands: + - apt-get update + - apt-get install -yq zip + - printenv GCP_GRAFANA_UPLOAD_ARTIFACTS_KEY > /tmp/gcpkey_upload_artifacts.json + - gcloud auth activate-service-account --key-file=/tmp/gcpkey_upload_artifacts.json + - gsutil cp -r ./playwright-report/. gs://releng-pipeline-artifacts-dev/${DRONE_BUILD_NUMBER}/playwright-report + - export E2E_PLAYWRIGHT_REPORT_URL=https://storage.googleapis.com/releng-pipeline-artifacts-dev/${DRONE_BUILD_NUMBER}/playwright-report/index.html + - "echo \"E2E Playwright report uploaded to: \n $${E2E_PLAYWRIGHT_REPORT_URL}\"" + depends_on: + - playwright-plugin-e2e + environment: + GCP_GRAFANA_UPLOAD_ARTIFACTS_KEY: + from_secret: gcp_upload_artifacts_key + failure: ignore + image: google/cloud-sdk:431.0.0 + name: playwright-e2e-report-upload + when: + status: + - success + - failure +- commands: + - if [ ! -d ./playwright-report/trace ]; then echo 'all tests passed'; exit 0; fi + - export E2E_PLAYWRIGHT_REPORT_URL=https://storage.googleapis.com/releng-pipeline-artifacts-dev/${DRONE_BUILD_NUMBER}/playwright-report/index.html + - 'curl -L -X POST https://api.github.com/repos/grafana/grafana/issues/${DRONE_PULL_REQUEST}/comments + -H "Accept: application/vnd.github+json" -H "Authorization: Bearer $${GITHUB_TOKEN}" + -H "X-GitHub-Api-Version: 2022-11-28" -d "{\"body\":\"❌ Failed to run Playwright + plugin e2e tests. <br /> <br /> Click [here]($${E2E_PLAYWRIGHT_REPORT_URL}) to + browse the Playwright report and trace viewer. <br /> For information on how to + run Playwright tests locally, refer to the [Developer guide](https://github.com/grafana/grafana/blob/main/contribute/developer-guide.md#to-run-the-playwright-tests). + \"}"' + depends_on: + - playwright-e2e-report-upload + environment: + GITHUB_TOKEN: + from_secret: github_token + failure: ignore + image: byrnedo/alpine-curl:0.1.8 + name: playwright-e2e-report-post-link + when: + status: + - success + - failure +- commands: + - if [ -z `find ./e2e -type f -name *spec.ts.mp4` ]; then echo 'missing videos'; + false; fi - apt-get update - apt-get install -yq zip - printenv GCP_GRAFANA_UPLOAD_ARTIFACTS_KEY > /tmp/gcpkey_upload_artifacts.json @@ -703,12 +774,15 @@ steps: - /src/grafana-build artifacts -a docker:grafana:linux/amd64 -a docker:grafana:linux/amd64:ubuntu -a docker:grafana:linux/arm64 -a docker:grafana:linux/arm64:ubuntu -a docker:grafana:linux/arm/v7 -a docker:grafana:linux/arm/v7:ubuntu --yarn-cache=$$YARN_CACHE_FOLDER --build-id=$$DRONE_BUILD_NUMBER - --ubuntu-base=ubuntu:22.04 --alpine-base=alpine:3.18.4 --tag-format='{{ .version_base - }}-{{ .buildID }}-{{ .arch }}' --grafana-dir=$$PWD --ubuntu-tag-format='{{ .version_base - }}-{{ .buildID }}-ubuntu-{{ .arch }}' > docker.txt + --go-version=1.21.8 --ubuntu-base=ubuntu:22.04 --alpine-base=alpine:3.19.1 --tag-format='{{ + .version_base }}-{{ .buildID }}-{{ .arch }}' --grafana-dir=$$PWD --ubuntu-tag-format='{{ + .version_base }}-{{ .buildID }}-ubuntu-{{ .arch }}' > docker.txt - find ./dist -name '*docker*.tar.gz' -type f | xargs -n1 docker load -i depends_on: - yarn-install + environment: + _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: + from_secret: dagger_token image: grafana/grafana-build:main name: rgm-build-docker pull: always @@ -756,7 +830,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: pr-integration-tests node: @@ -800,9 +875,9 @@ services: - name: mysql80 path: /var/lib/mysql - commands: - - /bin/mimir -target=backend + - /bin/mimir -target=backend -alertmanager.grafana-alertmanager-compatibility-enabled environment: {} - image: us.gcr.io/kubernetes-dev/mimir:gotjosh-state-config-grafana-663a0ae78 + image: grafana/mimir:r274-1780c50 name: mimir_backend - environment: {} image: redis:6.2.11-alpine @@ -834,7 +909,7 @@ steps: name: clone-enterprise - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -843,11 +918,11 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: compile-build-cmd - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -857,7 +932,7 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -866,14 +941,14 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-jsonnet - commands: - apk add --update make - make gen-go depends_on: - verify-gen-cue - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: wire-install - commands: - dockerize -wait tcp://postgres:5432 -timeout 120s @@ -894,7 +969,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: postgres-integration-tests - commands: - dockerize -wait tcp://mysql57:3306 -timeout 120s @@ -915,7 +990,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql57 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: mysql-5.7-integration-tests - commands: - dockerize -wait tcp://mysql80:3306 -timeout 120s @@ -936,7 +1011,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql80 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: mysql-8.0-integration-tests - commands: - dockerize -wait tcp://redis:6379 -timeout 120s @@ -945,13 +1020,14 @@ steps: - commands: - apk add --update build-base - go clean -testcache - - go test -run IntegrationRedis -covermode=atomic -timeout=2m ./pkg/... + - go list -f '{{.Dir}}/...' -m | xargs go test -run IntegrationRedis -covermode=atomic + -timeout=2m depends_on: - wire-install - wait-for-redis environment: REDIS_URL: redis://redis:6379/0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: redis-integration-tests - commands: - dockerize -wait tcp://memcached:11211 -timeout 120s @@ -960,13 +1036,14 @@ steps: - commands: - apk add --update build-base - go clean -testcache - - go test -run IntegrationMemcached -covermode=atomic -timeout=2m ./pkg/... + - go list -f '{{.Dir}}/...' -m | xargs go test -run IntegrationMemcached -covermode=atomic + -timeout=2m depends_on: - wire-install - wait-for-memcached environment: MEMCACHED_HOSTS: memcached:11211 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: memcached-integration-tests - commands: - dockerize -wait tcp://mimir_backend:8080 -timeout 120s @@ -982,7 +1059,7 @@ steps: environment: AM_TENANT_ID: test AM_URL: http://mimir_backend:8080 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: remote-alertmanager-integration-tests trigger: event: @@ -1020,7 +1097,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: pr-docs node: @@ -1032,16 +1110,17 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - - yarn install --immutable + - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python + - yarn install --immutable || yarn install --immutable depends_on: [] image: node:20.9.0-alpine name: yarn-install - commands: - pip3 install codespell - - codespell -I .codespellignore docs/ + - codespell -I docs/.codespellignore docs/ image: python:3.8 name: codespell - commands: @@ -1069,7 +1148,7 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-cue trigger: event: @@ -1094,7 +1173,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: pr-shellcheck node: @@ -1109,7 +1189,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: compile-build-cmd - commands: - apt-get update -yq && apt-get install shellcheck @@ -1136,7 +1216,8 @@ clone: retries: 3 depends_on: [] image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: pr-swagger-gen node: @@ -1152,11 +1233,15 @@ steps: | jq .head.repo.fork) - if [ "$is_fork" != false ]; then return 1; fi - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" - grafana-enterprise - - cd grafana-enterprise + ../grafana-enterprise + - cd ../grafana-enterprise - if git checkout ${DRONE_SOURCE_BRANCH}; then echo "checked out ${DRONE_SOURCE_BRANCH}"; - elif git checkout main; then echo "git checkout main"; else git checkout main; - fi + elif git checkout ${DRONE_TARGET_BRANCH}; then echo "git checkout ${DRONE_TARGET_BRANCH}"; + else git checkout main; fi + - cd ../ + - ln -s src grafana + - cd ./grafana-enterprise + - ./build.sh environment: GITHUB_TOKEN: from_secret: github_token @@ -1168,25 +1253,19 @@ steps: - make swagger-clean && make openapi3-gen - for f in public/api-merged.json public/openapi3.json; do git add $f; done - if [ -z "$(git diff --name-only --cached)" ]; then echo "Everything seems up to - date!"; else echo "Please ensure the branch is up-to-date, then regenerate the - specification by running make swagger-clean && make openapi3-gen" && return 1; - fi + date!"; else git diff --cached && echo "Please ensure the branch is up-to-date, + then regenerate the specification by running make swagger-clean && make openapi3-gen" + && return 1; fi depends_on: - clone-enterprise environment: GITHUB_TOKEN: from_secret: github_token - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: swagger-gen trigger: event: - pull_request - paths: - exclude: - - docs/** - - '*.md' - include: - - pkg/** type: docker volumes: - host: @@ -1199,7 +1278,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: pr-integration-benchmarks node: @@ -1243,9 +1323,9 @@ services: - name: mysql80 path: /var/lib/mysql - commands: - - /bin/mimir -target=backend + - /bin/mimir -target=backend -alertmanager.grafana-alertmanager-compatibility-enabled environment: {} - image: us.gcr.io/kubernetes-dev/mimir:gotjosh-state-config-grafana-663a0ae78 + image: grafana/mimir:r274-1780c50 name: mimir_backend - environment: {} image: redis:6.2.11-alpine @@ -1277,7 +1357,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: compile-build-cmd - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -1288,7 +1368,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - clone-enterprise - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -1298,14 +1378,14 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - clone-enterprise - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-jsonnet - commands: - apk add --update make - make gen-go depends_on: - verify-gen-cue - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: wire-install - commands: - apk add --update build-base @@ -1313,7 +1393,7 @@ steps: - go test -v -run=^$ -benchmem -timeout=1h -count=8 -bench=. ${GO_PACKAGES} depends_on: - wire-install - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: sqlite-benchmark-integration-tests - commands: - apk add --update build-base @@ -1325,7 +1405,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: postgres-benchmark-integration-tests - commands: - apk add --update build-base @@ -1336,7 +1416,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql57 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: mysql-5.7-benchmark-integration-tests - commands: - apk add --update build-base @@ -1347,7 +1427,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql80 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: mysql-8.0-benchmark-integration-tests trigger: event: @@ -1375,7 +1455,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: main-docs node: @@ -1387,16 +1468,17 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - - yarn install --immutable + - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python + - yarn install --immutable || yarn install --immutable depends_on: [] image: node:20.9.0-alpine name: yarn-install - commands: - pip3 install codespell - - codespell -I .codespellignore docs/ + - codespell -I docs/.codespellignore docs/ image: python:3.8 name: codespell - commands: @@ -1424,7 +1506,7 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-cue trigger: branch: main @@ -1450,7 +1532,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: main-test-frontend node: @@ -1462,10 +1545,11 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - - yarn install --immutable + - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python + - yarn install --immutable || yarn install --immutable depends_on: [] image: node:20.9.0-alpine name: yarn-install @@ -1507,7 +1591,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: main-lint-frontend node: @@ -1519,10 +1604,11 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - - yarn install --immutable + - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python + - yarn install --immutable || yarn install --immutable depends_on: [] image: node:20.9.0-alpine name: yarn-install @@ -1546,9 +1632,9 @@ steps: \"\nTranslation extraction has not been committed. Please run 'yarn i18n:extract', commit the changes and push again.\"\n exit 1\n fi\n \ " - - yarn run i18n:compile depends_on: - yarn-install + failure: ignore image: node:20.9.0-alpine name: verify-i18n trigger: @@ -1574,7 +1660,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: main-test-backend node: @@ -1586,7 +1673,7 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -1596,7 +1683,7 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -1605,21 +1692,22 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-jsonnet - commands: - apk add --update make - make gen-go depends_on: - verify-gen-cue - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: wire-install - commands: - apk add --update build-base shared-mime-info shared-mime-info-lang - - go test -tags requires_buildifer -short -covermode=atomic -timeout=5m ./pkg/... + - go list -f '{{.Dir}}/...' -m | xargs go test -tags requires_buildifer -short -covermode=atomic + -timeout=5m depends_on: - wire-install - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: test-backend - commands: - apk add --update build-base @@ -1628,7 +1716,7 @@ steps: | grep -o '\(.*\)/' | sort -u) depends_on: - wire-install - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: test-backend-integration trigger: branch: main @@ -1653,7 +1741,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: main-lint-backend node: @@ -1665,20 +1754,20 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: compile-build-cmd - commands: - apk add --update make - make gen-go depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: wire-install - commands: - apk add --update make build-base @@ -1687,16 +1776,16 @@ steps: - wire-install environment: CGO_ENABLED: "1" - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: lint-backend - commands: - go run scripts/modowners/modowners.go check go.mod - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: validate-modfile - commands: - apk add --update make - make swagger-validate - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: validate-openapi-spec - commands: - ./bin/build verify-drone @@ -1727,7 +1816,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: main-build-e2e-publish node: @@ -1739,11 +1829,11 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1752,7 +1842,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: compile-build-cmd - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -1762,7 +1852,7 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -1771,23 +1861,24 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-jsonnet - commands: - apk add --update make - make gen-go depends_on: - verify-gen-cue - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: wire-install - commands: - - yarn install --immutable + - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python + - yarn install --immutable || yarn install --immutable depends_on: [] image: node:20.9.0-alpine name: yarn-install - commands: - apk add --update jq - - new_version=$(cat package.json | jq .version | sed s/pre/${DRONE_BUILD_NUMBER}/g) + - new_version=$(cat package.json | jq -r .version | sed s/pre/${DRONE_BUILD_NUMBER}/g) - 'echo "New version: $new_version"' - yarn run lerna version $new_version --exact --no-git-tag-version --no-push --force-publish -y @@ -1810,10 +1901,13 @@ steps: name: build-frontend-packages - commands: - /src/grafana-build artifacts -a targz:grafana:linux/amd64 -a targz:grafana:linux/arm64 - -a targz:grafana:linux/arm/v7 --go-version=1.21.5 --yarn-cache=$$YARN_CACHE_FOLDER + -a targz:grafana:linux/arm/v7 --go-version=1.21.8 --yarn-cache=$$YARN_CACHE_FOLDER --build-id=$$DRONE_BUILD_NUMBER --grafana-dir=$$PWD > packages.txt depends_on: - update-package-json-version + environment: + _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: + from_secret: dagger_token image: grafana/grafana-build:main name: rgm-package pull: always @@ -1832,7 +1926,7 @@ steps: GF_APP_MODE: development GF_SERVER_HTTP_PORT: "3001" GF_SERVER_ROUTER_LOGGING: "1" - image: alpine:3.18.4 + image: alpine:3.19.1 name: grafana-server - commands: - ./bin/build e2e-tests --port 3001 --suite dashboards-suite @@ -1893,6 +1987,61 @@ steps: repo: - grafana/grafana - commands: + - sleep 10s + - yarn e2e:playwright + depends_on: + - grafana-server + environment: + HOST: grafana-server + PORT: "3001" + PROV_DIR: /grafana/scripts/grafana-server/tmp/conf/provisioning + image: mcr.microsoft.com/playwright:v1.42.1-jammy + name: playwright-plugin-e2e +- commands: + - apt-get update + - apt-get install -yq zip + - printenv GCP_GRAFANA_UPLOAD_ARTIFACTS_KEY > /tmp/gcpkey_upload_artifacts.json + - gcloud auth activate-service-account --key-file=/tmp/gcpkey_upload_artifacts.json + - gsutil cp -r ./playwright-report/. gs://releng-pipeline-artifacts-dev/${DRONE_BUILD_NUMBER}/playwright-report + - export E2E_PLAYWRIGHT_REPORT_URL=https://storage.googleapis.com/releng-pipeline-artifacts-dev/${DRONE_BUILD_NUMBER}/playwright-report/index.html + - "echo \"E2E Playwright report uploaded to: \n $${E2E_PLAYWRIGHT_REPORT_URL}\"" + depends_on: + - playwright-plugin-e2e + environment: + GCP_GRAFANA_UPLOAD_ARTIFACTS_KEY: + from_secret: gcp_upload_artifacts_key + failure: ignore + image: google/cloud-sdk:431.0.0 + name: playwright-e2e-report-upload + when: + status: + - success + - failure +- commands: + - if [ ! -d ./playwright-report/trace ]; then echo 'all tests passed'; exit 0; fi + - export E2E_PLAYWRIGHT_REPORT_URL=https://storage.googleapis.com/releng-pipeline-artifacts-dev/${DRONE_BUILD_NUMBER}/playwright-report/index.html + - 'curl -L -X POST https://api.github.com/repos/grafana/grafana/issues/${DRONE_PULL_REQUEST}/comments + -H "Accept: application/vnd.github+json" -H "Authorization: Bearer $${GITHUB_TOKEN}" + -H "X-GitHub-Api-Version: 2022-11-28" -d "{\"body\":\"❌ Failed to run Playwright + plugin e2e tests. <br /> <br /> Click [here]($${E2E_PLAYWRIGHT_REPORT_URL}) to + browse the Playwright report and trace viewer. <br /> For information on how to + run Playwright tests locally, refer to the [Developer guide](https://github.com/grafana/grafana/blob/main/contribute/developer-guide.md#to-run-the-playwright-tests). + \"}"' + depends_on: + - playwright-e2e-report-upload + environment: + GITHUB_TOKEN: + from_secret: github_token + failure: ignore + image: byrnedo/alpine-curl:0.1.8 + name: playwright-e2e-report-post-link + when: + status: + - success + - failure +- commands: + - if [ -z `find ./e2e -type f -name *spec.ts.mp4` ]; then echo 'missing videos'; + false; fi - apt-get update - apt-get install -yq zip - printenv GCP_GRAFANA_UPLOAD_ARTIFACTS_KEY > /tmp/gcpkey_upload_artifacts.json @@ -1991,12 +2140,15 @@ steps: - /src/grafana-build artifacts -a docker:grafana:linux/amd64 -a docker:grafana:linux/amd64:ubuntu -a docker:grafana:linux/arm64 -a docker:grafana:linux/arm64:ubuntu -a docker:grafana:linux/arm/v7 -a docker:grafana:linux/arm/v7:ubuntu --yarn-cache=$$YARN_CACHE_FOLDER --build-id=$$DRONE_BUILD_NUMBER - --ubuntu-base=ubuntu:22.04 --alpine-base=alpine:3.18.4 --tag-format='{{ .version_base - }}-{{ .buildID }}-{{ .arch }}' --grafana-dir=$$PWD --ubuntu-tag-format='{{ .version_base - }}-{{ .buildID }}-ubuntu-{{ .arch }}' > docker.txt + --go-version=1.21.8 --ubuntu-base=ubuntu:22.04 --alpine-base=alpine:3.19.1 --tag-format='{{ + .version_base }}-{{ .buildID }}-{{ .arch }}' --grafana-dir=$$PWD --ubuntu-tag-format='{{ + .version_base }}-{{ .buildID }}-ubuntu-{{ .arch }}' > docker.txt - find ./dist -name '*docker*.tar.gz' -type f | xargs -n1 docker load -i depends_on: - update-package-json-version + environment: + _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: + from_secret: dagger_token image: grafana/grafana-build:main name: rgm-build-docker pull: always @@ -2127,7 +2279,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: main-integration-tests node: @@ -2171,9 +2324,9 @@ services: - name: mysql80 path: /var/lib/mysql - commands: - - /bin/mimir -target=backend + - /bin/mimir -target=backend -alertmanager.grafana-alertmanager-compatibility-enabled environment: {} - image: us.gcr.io/kubernetes-dev/mimir:gotjosh-state-config-grafana-663a0ae78 + image: grafana/mimir:r274-1780c50 name: mimir_backend - environment: {} image: redis:6.2.11-alpine @@ -2184,7 +2337,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2193,11 +2346,11 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: compile-build-cmd - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -2207,7 +2360,7 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -2216,14 +2369,14 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-jsonnet - commands: - apk add --update make - make gen-go depends_on: - verify-gen-cue - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: wire-install - commands: - dockerize -wait tcp://postgres:5432 -timeout 120s @@ -2244,7 +2397,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: postgres-integration-tests - commands: - dockerize -wait tcp://mysql57:3306 -timeout 120s @@ -2265,7 +2418,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql57 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: mysql-5.7-integration-tests - commands: - dockerize -wait tcp://mysql80:3306 -timeout 120s @@ -2286,7 +2439,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql80 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: mysql-8.0-integration-tests - commands: - dockerize -wait tcp://redis:6379 -timeout 120s @@ -2295,13 +2448,14 @@ steps: - commands: - apk add --update build-base - go clean -testcache - - go test -run IntegrationRedis -covermode=atomic -timeout=2m ./pkg/... + - go list -f '{{.Dir}}/...' -m | xargs go test -run IntegrationRedis -covermode=atomic + -timeout=2m depends_on: - wire-install - wait-for-redis environment: REDIS_URL: redis://redis:6379/0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: redis-integration-tests - commands: - dockerize -wait tcp://memcached:11211 -timeout 120s @@ -2310,13 +2464,14 @@ steps: - commands: - apk add --update build-base - go clean -testcache - - go test -run IntegrationMemcached -covermode=atomic -timeout=2m ./pkg/... + - go list -f '{{.Dir}}/...' -m | xargs go test -run IntegrationMemcached -covermode=atomic + -timeout=2m depends_on: - wire-install - wait-for-memcached environment: MEMCACHED_HOSTS: memcached:11211 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: memcached-integration-tests - commands: - dockerize -wait tcp://mimir_backend:8080 -timeout 120s @@ -2332,7 +2487,7 @@ steps: environment: AM_TENANT_ID: test AM_URL: http://mimir_backend:8080 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: remote-alertmanager-integration-tests trigger: branch: main @@ -2370,7 +2525,8 @@ depends_on: environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: main-windows platform: @@ -2385,7 +2541,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: windows-init @@ -2414,7 +2570,8 @@ depends_on: environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: main-trigger-downstream node: @@ -2445,7 +2602,7 @@ trigger: - docs/** - latest.json repo: - - grafana/grafana-security-mirror + - grafana/grafana type: docker volumes: - host: @@ -2497,7 +2654,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: publish-docker-public node: @@ -2509,11 +2667,11 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2522,7 +2680,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: compile-build-cmd - commands: - ./bin/build artifacts docker fetch --edition oss @@ -2603,7 +2761,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: publish-artifacts-public node: @@ -2618,7 +2777,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: compile-build-cmd - commands: - ./bin/build artifacts packages --tag $${DRONE_TAG} --src-bucket $${PRERELEASE_BUCKET} @@ -2672,7 +2831,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: publish-npm-packages-public node: @@ -2687,10 +2847,11 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: compile-build-cmd - commands: - - yarn install --immutable + - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python + - yarn install --immutable || yarn install --immutable depends_on: [] image: node:20.9.0-alpine name: yarn-install @@ -2737,7 +2898,8 @@ depends_on: environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: publish-packages node: @@ -2752,7 +2914,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: compile-build-cmd - depends_on: - compile-build-cmd @@ -2825,7 +2987,8 @@ depends_on: - main-test-backend - main-test-frontend image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: rgm-main-prerelease node: @@ -2841,7 +3004,7 @@ steps: environment: _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: from_secret: dagger_token - ALPINE_BASE: alpine:3.18.4 + ALPINE_BASE: alpine:3.19.1 CDN_DESTINATION: from_secret: rgm_cdn_destination DESTINATION: @@ -2858,7 +3021,7 @@ steps: from_secret: gcp_key_base64 GITHUB_TOKEN: from_secret: github_token - GO_VERSION: 1.21.5 + GO_VERSION: 1.21.8 GPG_PASSPHRASE: from_secret: packages_gpg_passphrase GPG_PRIVATE_KEY: @@ -2900,7 +3063,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: release-whatsnew-checker node: @@ -2915,13 +3079,13 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: compile-build-cmd - commands: - ./bin/build whatsnew-checker depends_on: - compile-build-cmd - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: whats-new-checker trigger: event: @@ -2944,7 +3108,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: release-test-frontend node: @@ -2956,10 +3121,11 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - - yarn install --immutable + - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python + - yarn install --immutable || yarn install --immutable depends_on: [] image: node:20.9.0-alpine name: yarn-install @@ -2999,7 +3165,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: release-test-backend node: @@ -3011,7 +3178,7 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -3021,7 +3188,7 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -3030,21 +3197,22 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-jsonnet - commands: - apk add --update make - make gen-go depends_on: - verify-gen-cue - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: wire-install - commands: - apk add --update build-base shared-mime-info shared-mime-info-lang - - go test -tags requires_buildifer -short -covermode=atomic -timeout=5m ./pkg/... + - go list -f '{{.Dir}}/...' -m | xargs go test -tags requires_buildifer -short -covermode=atomic + -timeout=5m depends_on: - wire-install - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: test-backend - commands: - apk add --update build-base @@ -3053,7 +3221,7 @@ steps: | grep -o '\(.*\)/' | sort -u) depends_on: - wire-install - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: test-backend-integration trigger: event: @@ -3076,7 +3244,8 @@ depends_on: - release-test-backend - release-test-frontend image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: rgm-tag-prerelease node: @@ -3092,7 +3261,7 @@ steps: environment: _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: from_secret: dagger_token - ALPINE_BASE: alpine:3.18.4 + ALPINE_BASE: alpine:3.19.1 CDN_DESTINATION: from_secret: rgm_cdn_destination DESTINATION: @@ -3109,7 +3278,7 @@ steps: from_secret: gcp_key_base64 GITHUB_TOKEN: from_secret: github_token - GO_VERSION: 1.21.5 + GO_VERSION: 1.21.8 GPG_PASSPHRASE: from_secret: packages_gpg_passphrase GPG_PRIVATE_KEY: @@ -3147,7 +3316,8 @@ clone: depends_on: - rgm-tag-prerelease image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: rgm-tag-prerelease-windows platform: @@ -3162,7 +3332,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: windows-init @@ -3211,7 +3381,8 @@ depends_on: - rgm-tag-prerelease - rgm-tag-prerelease-windows image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: rgm-tag-verify-prerelease-assets node: @@ -3256,7 +3427,8 @@ depends_on: - release-test-backend - release-test-frontend image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: rgm-version-branch-prerelease node: @@ -3272,7 +3444,7 @@ steps: environment: _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: from_secret: dagger_token - ALPINE_BASE: alpine:3.18.4 + ALPINE_BASE: alpine:3.19.1 CDN_DESTINATION: from_secret: rgm_cdn_destination DESTINATION: @@ -3289,7 +3461,7 @@ steps: from_secret: gcp_key_base64 GITHUB_TOKEN: from_secret: github_token - GO_VERSION: 1.21.5 + GO_VERSION: 1.21.8 GPG_PASSPHRASE: from_secret: packages_gpg_passphrase GPG_PRIVATE_KEY: @@ -3321,7 +3493,8 @@ clone: depends_on: - rgm-version-branch-prerelease image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: rgm-prerelease-verify-prerelease-assets node: @@ -3360,7 +3533,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: nightly-test-frontend node: @@ -3372,10 +3546,11 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - - yarn install --immutable + - apk add --update g++ make python3 && ln -sf /usr/bin/python3 /usr/bin/python + - yarn install --immutable || yarn install --immutable depends_on: [] image: node:20.9.0-alpine name: yarn-install @@ -3413,7 +3588,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: nightly-test-backend node: @@ -3425,7 +3601,7 @@ services: [] steps: - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -3435,7 +3611,7 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -3444,21 +3620,22 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-jsonnet - commands: - apk add --update make - make gen-go depends_on: - verify-gen-cue - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: wire-install - commands: - apk add --update build-base shared-mime-info shared-mime-info-lang - - go test -tags requires_buildifer -short -covermode=atomic -timeout=5m ./pkg/... + - go list -f '{{.Dir}}/...' -m | xargs go test -tags requires_buildifer -short -covermode=atomic + -timeout=5m depends_on: - wire-install - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: test-backend - commands: - apk add --update build-base @@ -3467,7 +3644,7 @@ steps: | grep -o '\(.*\)/' | sort -u) depends_on: - wire-install - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: test-backend-integration trigger: cron: @@ -3488,7 +3665,8 @@ depends_on: - nightly-test-backend - nightly-test-frontend image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: rgm-nightly-build node: @@ -3504,7 +3682,7 @@ steps: environment: _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: from_secret: dagger_token - ALPINE_BASE: alpine:3.18.4 + ALPINE_BASE: alpine:3.19.1 CDN_DESTINATION: from_secret: rgm_cdn_destination DESTINATION: @@ -3521,7 +3699,7 @@ steps: from_secret: gcp_key_base64 GITHUB_TOKEN: from_secret: github_token - GO_VERSION: 1.21.5 + GO_VERSION: 1.21.8 GPG_PASSPHRASE: from_secret: packages_gpg_passphrase GPG_PRIVATE_KEY: @@ -3595,7 +3773,8 @@ clone: depends_on: - rgm-nightly-build image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: rgm-nightly-publish node: @@ -3650,7 +3829,7 @@ steps: environment: _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: from_secret: dagger_token - ALPINE_BASE: alpine:3.18.4 + ALPINE_BASE: alpine:3.19.1 CDN_DESTINATION: from_secret: rgm_cdn_destination DESTINATION: @@ -3667,7 +3846,7 @@ steps: from_secret: gcp_key_base64 GITHUB_TOKEN: from_secret: github_token - GO_VERSION: 1.21.5 + GO_VERSION: 1.21.8 GPG_PASSPHRASE: from_secret: packages_gpg_passphrase GPG_PRIVATE_KEY: @@ -3738,13 +3917,115 @@ volumes: path: /var/run/docker.sock name: docker --- +clone: + retries: 3 +depends_on: [] +image_pull_secrets: +- gcr +- gar +kind: pipeline +name: rgm-promotion +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: +- commands: + - 'dagger run --silent /src/grafana-build artifacts -a $${ARTIFACTS} --grafana-ref=$${GRAFANA_REF} + --enterprise-ref=$${ENTERPRISE_REF} --grafana-repo=$${GRAFANA_REPO} --version=$${VERSION} ' + environment: + _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: + from_secret: dagger_token + ALPINE_BASE: alpine:3.19.1 + CDN_DESTINATION: + from_secret: rgm_cdn_destination + DESTINATION: + from_secret: destination + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USERNAME: + from_secret: docker_username + DOWNLOADS_DESTINATION: + from_secret: rgm_downloads_destination + GCOM_API_KEY: + from_secret: grafana_api_key + GCP_KEY_BASE64: + from_secret: gcp_key_base64 + GITHUB_TOKEN: + from_secret: github_token + GO_VERSION: 1.21.8 + GPG_PASSPHRASE: + from_secret: packages_gpg_passphrase + GPG_PRIVATE_KEY: + from_secret: packages_gpg_private_key + GPG_PUBLIC_KEY: + from_secret: packages_gpg_public_key + NPM_TOKEN: + from_secret: npm_token + STORYBOOK_DESTINATION: + from_secret: rgm_storybook_destination + UBUNTU_BASE: ubuntu:22.04 + image: grafana/grafana-build:main + name: rgm-build + pull: always + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - printenv GCP_KEY_BASE64 | base64 -d > /tmp/key.json + - gcloud auth activate-service-account --key-file=/tmp/key.json + - gcloud storage cp -r dist/* $${UPLOAD_TO} + environment: + _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: + from_secret: dagger_token + CDN_DESTINATION: + from_secret: rgm_cdn_destination + DESTINATION: + from_secret: destination + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USERNAME: + from_secret: docker_username + DOWNLOADS_DESTINATION: + from_secret: rgm_downloads_destination + GCOM_API_KEY: + from_secret: grafana_api_key + GCP_KEY_BASE64: + from_secret: gcp_key_base64 + GITHUB_TOKEN: + from_secret: github_token + GPG_PASSPHRASE: + from_secret: packages_gpg_passphrase + GPG_PRIVATE_KEY: + from_secret: packages_gpg_private_key + GPG_PUBLIC_KEY: + from_secret: packages_gpg_public_key + NPM_TOKEN: + from_secret: npm_token + STORYBOOK_DESTINATION: + from_secret: rgm_storybook_destination + image: google/cloud-sdk:alpine + name: rgm-copy +trigger: + event: + - promote + target: upload-packages +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- clone: disable: true depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: testing-test-backend-windows platform: @@ -3764,20 +4045,20 @@ steps: - commands: [] depends_on: - clone - image: golang:1.21.5-windowsservercore-1809 + image: golang:1.21.8-windowsservercore-1809 name: windows-init - commands: - go install github.com/google/wire/cmd/wire@v0.5.0 - wire gen -tags oss ./pkg/server depends_on: - windows-init - image: golang:1.21.5-windowsservercore-1809 + image: golang:1.21.8-windowsservercore-1809 name: wire-install - commands: - go test -tags requires_buildifer -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: golang:1.21.5-windowsservercore-1809 + image: golang:1.21.8-windowsservercore-1809 name: test-backend trigger: event: @@ -3796,7 +4077,8 @@ depends_on: [] environment: EDITION: oss image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: integration-tests node: @@ -3840,9 +4122,9 @@ services: - name: mysql80 path: /var/lib/mysql - commands: - - /bin/mimir -target=backend + - /bin/mimir -target=backend -alertmanager.grafana-alertmanager-compatibility-enabled environment: {} - image: us.gcr.io/kubernetes-dev/mimir:gotjosh-state-config-grafana-663a0ae78 + image: grafana/mimir:r274-1780c50 name: mimir_backend - environment: {} image: redis:6.2.11-alpine @@ -3853,13 +4135,13 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.18.4 + image: alpine:3.19.1 name: identify-runner - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -3869,7 +4151,7 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -3878,14 +4160,14 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: verify-gen-jsonnet - commands: - apk add --update make - make gen-go depends_on: - verify-gen-cue - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: wire-install - commands: - dockerize -wait tcp://postgres:5432 -timeout 120s @@ -3906,7 +4188,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: postgres-integration-tests - commands: - dockerize -wait tcp://mysql57:3306 -timeout 120s @@ -3927,7 +4209,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql57 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: mysql-5.7-integration-tests - commands: - dockerize -wait tcp://mysql80:3306 -timeout 120s @@ -3948,7 +4230,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql80 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: mysql-8.0-integration-tests - commands: - dockerize -wait tcp://redis:6379 -timeout 120s @@ -3957,13 +4239,14 @@ steps: - commands: - apk add --update build-base - go clean -testcache - - go test -run IntegrationRedis -covermode=atomic -timeout=2m ./pkg/... + - go list -f '{{.Dir}}/...' -m | xargs go test -run IntegrationRedis -covermode=atomic + -timeout=2m depends_on: - wire-install - wait-for-redis environment: REDIS_URL: redis://redis:6379/0 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: redis-integration-tests - commands: - dockerize -wait tcp://memcached:11211 -timeout 120s @@ -3972,13 +4255,14 @@ steps: - commands: - apk add --update build-base - go clean -testcache - - go test -run IntegrationMemcached -covermode=atomic -timeout=2m ./pkg/... + - go list -f '{{.Dir}}/...' -m | xargs go test -run IntegrationMemcached -covermode=atomic + -timeout=2m depends_on: - wire-install - wait-for-memcached environment: MEMCACHED_HOSTS: memcached:11211 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: memcached-integration-tests - commands: - dockerize -wait tcp://mimir_backend:8080 -timeout 120s @@ -3994,7 +4278,7 @@ steps: environment: AM_TENANT_ID: test AM_URL: http://mimir_backend:8080 - image: golang:1.21.5-alpine3.18 + image: golang:1.21.8-alpine name: remote-alertmanager-integration-tests trigger: event: @@ -4019,7 +4303,8 @@ clone: disable: true depends_on: [] image_pull_secrets: -- dockerconfigjson +- gcr +- gar kind: pipeline name: publish-ci-windows-test-image platform: @@ -4347,17 +4632,17 @@ steps: path: /root/.docker/ - commands: - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM alpine/git:2.40.1 - - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM golang:1.21.5-alpine3.18 + - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM golang:1.21.8-alpine - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM node:20.9.0-alpine - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM google/cloud-sdk:431.0.0 - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/grafana-ci-deploy:1.3.3 - - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM alpine:3.18.4 + - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM alpine:3.19.1 - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM ubuntu:22.04 - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM byrnedo/alpine-curl:0.1.8 - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM plugins/slack - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM python:3.8 - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM postgres:12.3-alpine - - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM us.gcr.io/kubernetes-dev/mimir:gotjosh-state-config-grafana-663a0ae78 + - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/mimir:r274-1780c50 - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM mysql:5.7.39 - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM mysql:8.0.32 - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM redis:6.2.11-alpine @@ -4370,6 +4655,7 @@ steps: - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM cypress/included:13.1.0 - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM jwilder/dockerize:0.6.1 - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM koalaman/shellcheck:stable + - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM mcr.microsoft.com/playwright:v1.42.1-jammy depends_on: - authenticate-gcr image: aquasec/trivy:0.21.0 @@ -4381,17 +4667,17 @@ steps: path: /root/.docker/ - commands: - trivy --exit-code 1 --severity HIGH,CRITICAL alpine/git:2.40.1 - - trivy --exit-code 1 --severity HIGH,CRITICAL golang:1.21.5-alpine3.18 + - trivy --exit-code 1 --severity HIGH,CRITICAL golang:1.21.8-alpine - trivy --exit-code 1 --severity HIGH,CRITICAL node:20.9.0-alpine - trivy --exit-code 1 --severity HIGH,CRITICAL google/cloud-sdk:431.0.0 - trivy --exit-code 1 --severity HIGH,CRITICAL grafana/grafana-ci-deploy:1.3.3 - - trivy --exit-code 1 --severity HIGH,CRITICAL alpine:3.18.4 + - trivy --exit-code 1 --severity HIGH,CRITICAL alpine:3.19.1 - trivy --exit-code 1 --severity HIGH,CRITICAL ubuntu:22.04 - trivy --exit-code 1 --severity HIGH,CRITICAL byrnedo/alpine-curl:0.1.8 - trivy --exit-code 1 --severity HIGH,CRITICAL plugins/slack - trivy --exit-code 1 --severity HIGH,CRITICAL python:3.8 - trivy --exit-code 1 --severity HIGH,CRITICAL postgres:12.3-alpine - - trivy --exit-code 1 --severity HIGH,CRITICAL us.gcr.io/kubernetes-dev/mimir:gotjosh-state-config-grafana-663a0ae78 + - trivy --exit-code 1 --severity HIGH,CRITICAL grafana/mimir:r274-1780c50 - trivy --exit-code 1 --severity HIGH,CRITICAL mysql:5.7.39 - trivy --exit-code 1 --severity HIGH,CRITICAL mysql:8.0.32 - trivy --exit-code 1 --severity HIGH,CRITICAL redis:6.2.11-alpine @@ -4404,6 +4690,7 @@ steps: - trivy --exit-code 1 --severity HIGH,CRITICAL cypress/included:13.1.0 - trivy --exit-code 1 --severity HIGH,CRITICAL jwilder/dockerize:0.6.1 - trivy --exit-code 1 --severity HIGH,CRITICAL koalaman/shellcheck:stable + - trivy --exit-code 1 --severity HIGH,CRITICAL mcr.microsoft.com/playwright:v1.42.1-jammy depends_on: - authenticate-gcr environment: @@ -4458,7 +4745,13 @@ get: name: .dockerconfigjson path: secret/data/common/gcr kind: secret -name: dockerconfigjson +name: gcr +--- +get: + name: .dockerconfigjson + path: secret/data/common/gar +kind: secret +name: gar --- get: name: pat @@ -4629,6 +4922,6 @@ kind: secret name: gcr_credentials --- kind: signature -hmac: d7e383e4bf37190a97d695ef8f91755cb8cb70fb8088a5c1f63262adcf8e4f5e +hmac: feb0603ccd1169c54e142a4b0105bc6d2805e5e0f4d8f0e1b0edd95d41d450b9 ... diff --git a/.eslintrc b/.eslintrc index 1403b7bc7d716..07e7cfc40645d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -96,10 +96,30 @@ }, { "files": [ + "public/app/plugins/datasource/grafana-postgresql-datasource/*.{ts,tsx}", + "public/app/plugins/datasource/grafana-postgresql-datasource/**/*.{ts,tsx}", + "public/app/plugins/datasource/grafana-pyroscope-datasource/*.{ts,tsx}", + "public/app/plugins/datasource/grafana-pyroscope-datasource/**/*.{ts,tsx}", "public/app/plugins/datasource/grafana-testdata-datasource/*.{ts,tsx}", "public/app/plugins/datasource/grafana-testdata-datasource/**/*.{ts,tsx}", + "public/app/plugins/datasource/azuremonitor/*.{ts,tsx}", + "public/app/plugins/datasource/azuremonitor/**/*.{ts,tsx}", + "public/app/plugins/datasource/cloud-monitoring/*.{ts,tsx}", + "public/app/plugins/datasource/cloud-monitoring/**/*.{ts,tsx}", + "public/app/plugins/datasource/mysql/*.{ts,tsx}", + "public/app/plugins/datasource/mysql/**/*.{ts,tsx}", "public/app/plugins/datasource/parca/*.{ts,tsx}", - "public/app/plugins/datasource/parca/**/*.{ts,tsx}" + "public/app/plugins/datasource/parca/**/*.{ts,tsx}", + "public/app/plugins/datasource/tempo/*.{ts,tsx}", + "public/app/plugins/datasource/tempo/**/*.{ts,tsx}", + "public/app/plugins/datasource/loki/*.{ts,tsx}", + "public/app/plugins/datasource/loki/**/*.{ts,tsx}", + "public/app/plugins/datasource/elasticsearch/*.{ts,tsx}", + "public/app/plugins/datasource/elasticsearch/**/*.{ts,tsx}", + "public/app/plugins/datasource/cloudwatch/*.{ts,tsx}", + "public/app/plugins/datasource/cloudwatch/**/*.{ts,tsx}", + "public/app/plugins/datasource/zipkin/*.{ts,tsx}", + "public/app/plugins/datasource/zipkin/**/*.{ts,tsx}" ], "settings": { "import/resolver": { @@ -115,8 +135,8 @@ "zones": [ { "target": "./public/app/plugins", - "from": "./public/app", - "except": ["./plugins"], + "from": "./public", + "except": ["./app/plugins"], "message": "Core plugins are not allowed to depend on Grafana core packages" } ] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b9e0ee5c1fb64..7ce2a910e6d80 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,13 +13,11 @@ # Documentation /.changelog-archive @grafana/grafana-release-guild -/.codespellignore @grafana/docs-tooling /CHANGELOG.md @grafana/grafana-release-guild /CODE_OF_CONDUCT.md @grafana/grafana-community-support /CONTRIBUTING.md @grafana/grafana-community-support /GOVERNANCE.md @RichiH /HALL_OF_FAME.md @grafana/grafana-community-support -/ISSUE_TRIAGE.md @grafana/grafana-community-support /LICENSE @torkelo /LICENSING.md @torkelo /MAINTAINERS.md @RichiH @@ -28,36 +26,42 @@ /ROADMAP.md @torkelo /SECURITY.md @grafana/security-team /SUPPORT.md @torkelo -/UPGRADING_DEPENDENCIES.md @grafana/docs-grafana /WORKFLOW.md @torkelo /contribute/ @grafana/grafana-community-support +/contribute/UPGRADING_DEPENDENCIES.md @grafana/docs-grafana /devenv/README.md @grafana/docs-grafana -# Technical documentation +# START Technical documentation # `make docs` procedure and related workflows are owned @grafana/docs-tooling. Slack #docs. -# Documentation sources might have different owners. -/docs/ @grafana/docs-tooling -/docs/sources/ @Eve832 -/docs/sources/administration/ @jdbaldry -/docs/sources/alerting/ @brendamuir -/docs/sources/dashboards/ @imatwawana -/docs/sources/datasources/ @lwandz13 -/docs/sources/explore/ @grafana/explore-squad -/docs/sources/fundamentals @chri2547 -/docs/sources/getting-started/ @chri2547 -/docs/sources/introduction/ @chri2547 -/docs/sources/old-alerting/ @brendamuir -/docs/sources/panels-visualizations/ @imatwawana +/docs/ @grafana/docs-tooling + +/docs/.codespellignore @grafana/docs-tooling +/docs/sources/ @Eve832 + +/docs/sources/administration/ @jdbaldry @lwandz13 +/docs/sources/alerting/ @brendamuir +/docs/sources/dashboards/ @imatwawana +/docs/sources/datasources/ @jdbaldry +/docs/sources/explore/ @grafana/explore-squad +/docs/sources/fundamentals @chri2547 +/docs/sources/getting-started/ @chri2547 +/docs/sources/introduction/ @chri2547 +/docs/sources/panels-visualizations/ @imatwawana +/docs/sources/release-notes/ @Eve832 @GrafanaWriter +/docs/sources/setup-grafana/ @chri2547 +/docs/sources/upgrade-guide/ @imatwawana +/docs/sources/whatsnew/ @imatwawana + +/docs/sources/developers/plugins/ @Eve832 @josmperez @grafana/plugins-platform-frontend @grafana/plugins-platform-backend + /docs/sources/panels-visualizations/query-transform-data/transform-data/index.md @imatwawana @baldm0mma -/docs/sources/release-notes/ @Eve832 @GrafanaWriter -/docs/sources/setup-grafana/ @chri2547 -/docs/sources/upgrade-guide/ @imatwawana -/docs/sources/whatsnew/ @imatwawana -/docs/sources/developers/plugins/ @Eve832 @josmperez @grafana/plugins-platform-frontend @grafana/plugins-platform-backend +# END Technical documentation # Backend code /go.mod @grafana/backend-platform /go.sum @grafana/backend-platform +/go.work @grafana/grafana-app-platform-squad +/go.work.sum @grafana/grafana-app-platform-squad /.bingo/ @grafana/backend-platform /pkg/README.md @grafana/backend-platform /pkg/ruleguard.rules.go @grafana/backend-platform @@ -98,6 +102,9 @@ /pkg/mocks/ @grafana/backend-platform /pkg/models/ @grafana/backend-platform /pkg/server/ @grafana/backend-platform +/pkg/apiserver @grafana/grafana-app-platform-squad +/pkg/apimachinery @grafana/grafana-app-platform-squad +/pkg/promlib @grafana/observability-metrics /pkg/services/annotations/ @grafana/backend-platform /pkg/services/apikey/ @grafana/identity-access-team /pkg/services/cleanup/ @grafana/backend-platform @@ -108,7 +115,7 @@ /pkg/services/dashboardversion/ @grafana/backend-platform /pkg/services/encryption/ @grafana/backend-platform /pkg/services/folder/ @grafana/backend-platform -/pkg/services/grafana-apiserver @grafana/grafana-app-platform-squad +/pkg/services/apiserver @grafana/grafana-app-platform-squad /pkg/services/hooks/ @grafana/backend-platform /pkg/services/kmsproviders/ @grafana/backend-platform /pkg/services/licensing/ @grafana/backend-platform @@ -142,11 +149,9 @@ /pkg/tests/apis/ @grafana/grafana-app-platform-squad /pkg/tests/api/correlations/ @grafana/explore-squad /pkg/tsdb/grafanads/ @grafana/backend-platform -/pkg/tsdb/intervalv2/ @grafana/backend-platform /pkg/tsdb/legacydata/ @grafana/backend-platform /pkg/tsdb/opentsdb/ @grafana/backend-platform /pkg/tsdb/sqleng/ @grafana/partner-datasources @grafana/oss-big-tent -/pkg/tsdb/sqleng/proxyutil @grafana/hosted-grafana-team /pkg/util/ @grafana/backend-platform /pkg/web/ @grafana/backend-platform @@ -166,7 +171,6 @@ /devenv/bulk-dashboards/ @grafana/dashboards-squad /devenv/bulk-folders/ @grafana/grafana-frontend-platform -/devenv/bulk_alerting_dashboards/ @grafana/alerting-backend-product /devenv/create_docker_compose.sh @grafana/backend-platform /devenv/dashboards.yaml @grafana/dashboards-squad /devenv/datasources.yaml @grafana/backend-platform @@ -185,13 +189,13 @@ /devenv/docker/blocks/influxdb1/ @grafana/observability-metrics /devenv/docker/blocks/jaeger/ @grafana/observability-traces-and-profiling /devenv/docker/blocks/maildev/ @grafana/alerting-frontend -/devenv/docker/blocks/mariadb/ @grafana/grafana-bi-squad +/devenv/docker/blocks/mariadb/ @grafana/oss-big-tent /devenv/docker/blocks/memcached/ @grafana/backend-platform /devenv/docker/blocks/mimir_backend/ @grafana/alerting-backend-product -/devenv/docker/blocks/mssql/ @grafana/grafana-bi-squad -/devenv/docker/blocks/mssql_arm64/ @grafana/grafana-bi-squad -/devenv/docker/blocks/mssql_tests/ @grafana/grafana-bi-squad -/devenv/docker/blocks/mssql_tls/ @grafana/grafana-bi-squad +/devenv/docker/blocks/mssql/ @grafana/partner-datasources +/devenv/docker/blocks/mssql_arm64/ @grafana/partner-datasources +/devenv/docker/blocks/mssql_tests/ @grafana/partner-datasources +/devenv/docker/blocks/mssql_tls/ @grafana/partner-datasources /devenv/docker/blocks/mysql/ @grafana/oss-big-tent /devenv/docker/blocks/mysql_exporter/ @grafana/oss-big-tent /devenv/docker/blocks/mysql_opendata/ @grafana/oss-big-tent @@ -271,21 +275,19 @@ /pkg/services/searchV2/ @grafana/grafana-app-platform-squad /pkg/services/store/ @grafana/grafana-app-platform-squad /pkg/infra/filestorage/ @grafana/grafana-app-platform-squad -/pkg/util/converter/ @grafana/grafana-app-platform-squad /pkg/modules/ @grafana/grafana-app-platform-squad -/pkg/kindsysreport/ @grafana/grafana-app-platform-squad /pkg/services/grpcserver/ @grafana/grafana-app-platform-squad +/pkg/generated @grafana/grafana-app-platform-squad # Alerting /pkg/services/ngalert/ @grafana/alerting-backend-product /pkg/services/sqlstore/migrations/ualert/ @grafana/alerting-backend-product -/pkg/services/alerting/ @grafana/alerting-backend-product /pkg/tests/api/alerting/ @grafana/alerting-backend-product /public/app/features/alerting/ @grafana/alerting-frontend # Library Services -/pkg/services/libraryelements/ @grafana/grafana-frontend-platform -/pkg/services/librarypanels/ @grafana/grafana-frontend-platform +/pkg/services/libraryelements/ @grafana/dashboards-squad +/pkg/services/librarypanels/ @grafana/dashboards-squad # Plugins /pkg/api/pluginproxy/ @grafana/plugins-platform-backend @@ -309,14 +311,15 @@ /public/app/core/internationalization/ @grafana/grafana-frontend-platform /e2e/ @grafana/grafana-frontend-platform /e2e/cloud-plugins-suite/ @grafana/partner-datasources +/e2e/plugin-e2e/plugin-e2e-api-tests/ @grafana/plugins-platform-frontend /packages/ @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend /packages/grafana-e2e-selectors/ @grafana/grafana-frontend-platform /packages/grafana-e2e/ @grafana/grafana-frontend-platform /packages/grafana-ui/.storybook/ @grafana/plugins-platform-frontend /packages/grafana-ui/src/components/ @grafana/grafana-frontend-platform /packages/grafana-ui/src/components/DateTimePickers/ @grafana/grafana-frontend-platform -/packages/grafana-ui/src/components/Table/ @grafana/grafana-bi-squad -/packages/grafana-ui/src/components/Table/SparklineCell.tsx @grafana/grafana-bi-squad @grafana/app-o11y-visualizations +/packages/grafana-ui/src/components/Table/ @grafana/dataviz-squad +/packages/grafana-ui/src/components/Table/SparklineCell.tsx @grafana/dataviz-squad @grafana/app-o11y-visualizations /packages/grafana-ui/src/components/Gauge/ @grafana/dataviz-squad /packages/grafana-ui/src/components/BarGauge/ @grafana/dataviz-squad /packages/grafana-ui/src/components/uPlot/ @grafana/dataviz-squad @@ -331,19 +334,23 @@ /packages/grafana-ui/src/graveyard/GraphNG/ @grafana/dataviz-squad /packages/grafana-ui/src/graveyard/TimeSeries/ @grafana/dataviz-squad /packages/grafana-ui/src/utils/storybook/ @grafana/plugins-platform-frontend -/packages/grafana-data/src/transformations/ @grafana/grafana-bi-squad +/packages/grafana-data/src/transformations/ @grafana/dataviz-squad /packages/grafana-data/src/**/*logs* @grafana/observability-logs /packages/grafana-schema/src/**/*tempo* @grafana/observability-traces-and-profiling /packages/grafana-schema/src/**/*canvas* @grafana/dataviz-squad /packages/grafana-flamegraph/ @grafana/observability-traces-and-profiling /plugins-bundled/ @grafana/plugins-platform-frontend /packages/grafana-plugin-configs/ @grafana/plugins-platform-frontend - +/packages/grafana-prometheus/ @grafana/observability-metrics +/packages/grafana-o11y-ds-frontend/ @grafana/observability-logs @grafana/observability-traces-and-profiling +/packages/grafana-sql/ @grafana/partner-datasources @grafana/oss-big-tent # root files, mostly frontend -.browserslistrc @grafana/frontend-ops -package.json @grafana/frontend-ops -tsconfig.json @grafana/frontend-ops +/.browserslistrc @grafana/frontend-ops +/package.json @grafana/frontend-ops +/nx.json @grafana/frontend-ops +/project.json @grafana/frontend-ops +/tsconfig.json @grafana/frontend-ops /.editorconfig @grafana/frontend-ops /.eslintignore @grafana/frontend-ops /.gitattributes @grafana/frontend-ops @@ -353,34 +360,38 @@ tsconfig.json @grafana/frontend-ops /.yarn @grafana/frontend-ops /.yarnrc.yml @grafana/frontend-ops /yarn.lock @grafana/frontend-ops -/babel.config.json @grafana/frontend-ops -lerna.json @grafana/frontend-ops +/lerna.json @grafana/frontend-ops /.prettierrc.js @grafana/frontend-ops /.eslintrc @grafana/frontend-ops /.vim @zoltanbedi /jest.config.js @grafana/frontend-ops /latest.json @grafana/frontend-ops -/metadata.md @grafana/plugins-platform /stylelint.config.js @grafana/frontend-ops /tools/ @grafana/frontend-ops /lefthook.yml @grafana/frontend-ops /lefthook.rc @grafana/frontend-ops -.husky/pre-commit @grafana/frontend-ops -cypress.config.js @grafana/grafana-frontend-platform -.levignore.js @grafana/plugins-platform-frontend +/.husky/pre-commit @grafana/frontend-ops +/cypress.config.js @grafana/grafana-frontend-platform +/.levignore.js @grafana/plugins-platform-frontend +playwright.config.ts @grafana/plugins-platform-frontend # public folder /public/app/core/ @grafana/grafana-frontend-platform /public/app/core/components/TimePicker/ @grafana/grafana-frontend-platform /public/app/core/components/Layers/ @grafana/dataviz-squad -/public/app/core/components/TraceToLogs @grafana/observability-traces-and-profiling /public/app/core/components/GraphNG/ @grafana/dataviz-squad /public/app/core/components/TimeSeries/ @grafana/dataviz-squad +/public/app/core/components/TimelineChart/ @grafana/dataviz-squad +/public/app/core/components/Form/ @grafana/grafana-frontend-platform /public/app/features/all.ts @grafana/grafana-frontend-platform /public/app/features/admin/ @grafana/identity-access-team + +# Temp owners until Enterprise team takes over +/public/app/features/admin/migrate-to-cloud @grafana/grafana-frontend-platform + /public/app/features/auth-config/ @grafana/identity-access-team -/public/app/features/annotations/ @grafana/grafana-frontend-platform +/public/app/features/annotations/ @grafana/dashboards-squad /public/app/features/api-keys/ @grafana/identity-access-team /public/app/features/canvas/ @grafana/dataviz-squad /public/app/features/geo/ @grafana/dataviz-squad @@ -389,17 +400,17 @@ cypress.config.js @grafana/grafana-frontend-platform /public/app/features/connections/ @grafana/plugins-platform-frontend @mikkancso /public/app/features/correlations/ @grafana/explore-squad /public/app/features/dashboard/ @grafana/dashboards-squad -/public/app/features/dashboard/components/TransformationsEditor/ @grafana/grafana-bi-squad +/public/app/features/dashboard/components/TransformationsEditor/ @grafana/dataviz-squad /public/app/features/dashboard-scene/ @grafana/dashboards-squad /public/app/features/datasources/ @grafana/plugins-platform-frontend @mikkancso /public/app/features/dimensions/ @grafana/dataviz-squad -/public/app/features/dataframe-import/ @grafana/grafana-bi-squad +/public/app/features/dataframe-import/ @grafana/dataviz-squad /public/app/features/explore/ @grafana/explore-squad /public/app/features/expressions/ @grafana/observability-metrics /public/app/features/folders/ @grafana/grafana-frontend-platform /public/app/features/inspector/ @grafana/dashboards-squad /public/app/features/invites/ @grafana/grafana-frontend-platform -/public/app/features/library-panels/ @grafana/grafana-frontend-platform +/public/app/features/library-panels/ @grafana/dashboards-squad /public/app/features/logs/ @grafana/observability-logs /public/app/features/live/ @grafana/grafana-app-platform-squad /public/app/features/manage-dashboards/ @grafana/dashboards-squad @@ -408,12 +419,10 @@ cypress.config.js @grafana/grafana-frontend-platform /public/app/features/panel/ @grafana/dashboards-squad /public/app/features/playlist/ @grafana/dashboards-squad /public/app/features/plugins/ @grafana/plugins-platform-frontend -/public/app/features/plugins/sql/ @grafana/partner-datasources @grafana/oss-big-tent /public/app/features/profile/ @grafana/grafana-frontend-platform /public/app/features/runtime/ @ryantxu /public/app/features/query/ @grafana/dashboards-squad /public/app/features/sandbox/ @grafana/grafana-frontend-platform -/public/app/features/scenes/ @grafana/dashboards-squad /public/app/features/browse-dashboards/ @grafana/grafana-frontend-platform /public/app/features/search/ @grafana/grafana-frontend-platform /public/app/features/serviceaccounts/ @grafana/identity-access-team @@ -421,18 +430,17 @@ cypress.config.js @grafana/grafana-frontend-platform /public/app/features/teams/ @grafana/identity-access-team /public/app/features/templating/ @grafana/dashboards-squad /public/app/features/trails/ @torkelo -/public/app/features/transformers/ @grafana/grafana-bi-squad -/public/app/features/transformers/timeSeriesTable/ @grafana/grafana-bi-squad @grafana/app-o11y-visualizations +/public/app/features/transformers/ @grafana/dataviz-squad +/public/app/features/transformers/timeSeriesTable/ @grafana/dataviz-squad @grafana/app-o11y-visualizations /public/app/features/users/ @grafana/identity-access-team /public/app/features/variables/ @grafana/dashboards-squad -/public/app/plugins/panel/alertGroups/ @grafana/alerting-frontend /public/app/plugins/panel/alertlist/ @grafana/alerting-frontend /public/app/plugins/panel/annolist/ @grafana/grafana-frontend-platform /public/app/plugins/panel/barchart/ @grafana/dataviz-squad /public/app/plugins/panel/bargauge/ @grafana/dataviz-squad /public/app/plugins/panel/dashlist/ @grafana/grafana-frontend-platform /public/app/plugins/panel/debug/ @ryantxu -/public/app/plugins/panel/datagrid/ @grafana/grafana-bi-squad +/public/app/plugins/panel/datagrid/ @grafana/dataviz-squad /public/app/plugins/panel/gauge/ @grafana/dataviz-squad /public/app/plugins/panel/gettingstarted/ @grafana/grafana-frontend-platform /public/app/plugins/panel/graph/ @grafana/dataviz-squad @@ -445,9 +453,9 @@ cypress.config.js @grafana/grafana-frontend-platform /public/app/plugins/panel/piechart/ @grafana/dataviz-squad /public/app/plugins/panel/state-timeline/ @grafana/dataviz-squad /public/app/plugins/panel/status-history/ @grafana/dataviz-squad -/public/app/plugins/panel/table/ @grafana/grafana-bi-squad -/public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx @grafana/grafana-bi-squad @grafana/app-o11y-visualizations -/public/app/plugins/panel/table-old/ @grafana/grafana-bi-squad +/public/app/plugins/panel/table/ @grafana/dataviz-squad +/public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx @grafana/dataviz-squad @grafana/app-o11y-visualizations +/public/app/plugins/panel/table-old/ @grafana/dataviz-squad /public/app/plugins/panel/timeseries/ @grafana/dataviz-squad /public/app/plugins/panel/trend/ @grafana/dataviz-squad /public/app/plugins/panel/geomap/ @grafana/dataviz-squad @@ -463,14 +471,15 @@ cypress.config.js @grafana/grafana-frontend-platform /public/app/routes/ @grafana/grafana-frontend-platform /public/app/store/ @grafana/grafana-frontend-platform /public/app/types/ @grafana/grafana-frontend-platform +/public/app/types/alerting.ts @grafana/alerting-frontend /public/dashboards/ @grafana/dashboards-squad -/public/fonts/ @grafana/alerting-frontend /public/gazetteer/ @ryantxu /public/img/ @grafana/grafana-frontend-platform /public/lib/ @grafana/grafana-frontend-platform /public/lib/monaco-languages/kusto.ts @grafana/partner-datasources /public/maps/ @ryantxu /public/robots.txt @grafana/frontend-ops +/public/fonts/ @grafana/grafana-frontend-platform /public/sass/ @grafana/grafana-frontend-platform /public/test/ @grafana/grafana-frontend-platform /public/test/helpers/alertingRuleEditor.tsx @grafana/alerting-frontend @@ -505,7 +514,7 @@ cypress.config.js @grafana/grafana-frontend-platform /scripts/circle-* @grafana/grafana-release-guild /scripts/publish-npm-packages.sh @grafana/grafana-release-guild @grafana/plugins-platform-frontend /scripts/validate-npm-packages.sh @grafana/grafana-release-guild @grafana/plugins-platform-frontend -/scripts/ci-frontend-metrics.sh @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend @grafana/grafana-bi-squad +/scripts/ci-frontend-metrics.sh @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend @grafana/dataviz-squad /scripts/cli/ @grafana/grafana-frontend-platform /scripts/clean-git-or-error.sh @grafana/grafana-as-code /scripts/grafana-server/ @grafana/grafana-frontend-platform @@ -525,12 +534,13 @@ cypress.config.js @grafana/grafana-frontend-platform /scripts/generate-icon-bundle.js @grafana/plugins-platform-frontend @grafana/grafana-frontend-platform /scripts/levitate-parse-json-report.js @grafana/plugins-platform-frontend -/scripts/docs/generate-transformations.ts @grafana/grafana-bi-squad +/scripts/docs/generate-transformations* @grafana/dataviz-squad /scripts/webpack/ @grafana/frontend-ops /scripts/generate-a11y-report.sh @grafana/grafana-frontend-platform .pa11yci.conf.js @grafana/grafana-frontend-platform .pa11yci-pr.conf.js @grafana/grafana-frontend-platform .betterer.results @grafanabot +.betterer.results.json @grafanabot .betterer.ts @grafana/grafana-frontend-platform # @grafana/ui component documentation @@ -551,7 +561,7 @@ cypress.config.js @grafana/grafana-frontend-platform /public/app/plugins/datasource/jaeger/ @grafana/observability-traces-and-profiling /public/app/plugins/datasource/loki/ @grafana/observability-logs /public/app/plugins/datasource/mixed/ @grafana/dashboards-squad -/public/app/plugins/datasource/mssql/ @grafana/grafana-bi-squad +/public/app/plugins/datasource/mssql/ @grafana/partner-datasources /public/app/plugins/datasource/mysql/ @grafana/oss-big-tent /public/app/plugins/datasource/opentsdb/ @grafana/observability-metrics /public/app/plugins/datasource/grafana-postgresql-datasource/ @grafana/oss-big-tent @@ -599,10 +609,9 @@ cypress.config.js @grafana/grafana-frontend-platform /pkg/services/supportbundles/ @grafana/identity-access-team # Grafana Operator Experience Team -/pkg/infra/httpclient/httpclientprovider/sigv4_middleware.go @grafana/grafana-operator-experience-squad -/pkg/infra/httpclient/httpclientprovider/sigv4_middleware_test.go @grafana/grafana-operator-experience-squad /pkg/services/caching/ @grafana/grafana-operator-experience-squad /pkg/services/featuremgmt/ @grafana/grafana-operator-experience-squad +/pkg/services/cloudmigration/ @grafana/grafana-operator-experience-squad # Kind definitions /kinds/dashboard @grafana/dashboards-squad @@ -616,7 +625,7 @@ embed.go @grafana/grafana-as-code /pkg/registry/apis/ @grafana/grafana-app-platform-squad /pkg/codegen/ @grafana/grafana-as-code /pkg/kinds/*/*_gen.go @grafana/grafana-as-code -/pkg/registry/corekind/ @grafana/grafana-as-code +/pkg/registry/schemas/ @grafana/grafana-as-code /public/app/plugins/*gen.go @grafana/grafana-as-code /cue.mod/ @grafana/grafana-as-code @@ -667,16 +676,17 @@ embed.go @grafana/grafana-as-code /.github/workflows/update-changelog.yml @grafana/grafana-release-guild /.github/workflows/update-make-docs.yml @grafana/docs-tooling /.github/workflows/snyk.yml @grafana/security-team -/.github/workflows/scripts/kinds/verify-kinds.go @grafana/grafana-as-code -/.github/workflows/publish-kinds-next.yml @grafana/grafana-as-code -/.github/workflows/publish-kinds-release.yml @grafana/grafana-as-code -/.github/workflows/verify-kinds.yml @grafana/grafana-as-code +/.github/workflows/scripts/kinds/verify-kinds.go @grafana/platform-cat +/.github/workflows/publish-kinds-next.yml @grafana/platform-cat +/.github/workflows/publish-kinds-release.yml @grafana/platform-cat +/.github/workflows/verify-kinds.yml @grafana/platform-cat /.github/workflows/dashboards-issue-add-label.yml @grafana/dashboards-squad /.github/workflows/ephemeral-instances-pr-comment.yml @grafana/grafana-operator-experience-squad /.github/workflows/ephemeral-instances-pr-opened-closed.yml @grafana/grafana-operator-experience-squad /.github/workflows/create-security-patch-from-security-mirror.yml @grafana/grafana-release-guild /.github/workflows/core-plugins-build-and-release.yml @grafana/plugins-platform-frontend @grafana/plugins-platform-backend -/.github/workflows/i18n-crowdin-fix-files.yml @grafana/grafana-frontend-platform +/.github/workflows/i18n-crowdin-upload.yml @grafana/grafana-frontend-platform +/.github/workflows/i18n-crowdin-download.yml @grafana/grafana-frontend-platform /.github/workflows/feature-toggle-cleanup.yml @tolzhabayev /.github/workflows/scripts/feature-toggle-cleanup/feature-toggle-cleanup.js @tolzhabayev @@ -696,5 +706,4 @@ embed.go @grafana/grafana-as-code /conf/provisioning/alerting/ @grafana/alerting-backend-product /conf/provisioning/dashboards/ @grafana/dashboards-squad /conf/provisioning/datasources/ @grafana/plugins-platform-backend -/conf/provisioning/notifiers/ @bergquist /conf/provisioning/plugins/ @grafana/plugins-platform-backend diff --git a/.github/bot.md b/.github/bot.md index ec745bcf93aa9..9477ab291e27f 100644 --- a/.github/bot.md +++ b/.github/bot.md @@ -9,8 +9,8 @@ Comment commands: Label commands: -* Add label `bot/question` the the bot will close with standard question message and add label `type/question` -* Add label `bot/duplicate` the the bot will close with standard duplicate message and add label `type/duplicate` +* Add label `bot/question` the bot will close with standard question message and add label `type/question` +* Add label `bot/duplicate` the bot will close with standard duplicate message and add label `type/duplicate` * Add label `bot/needs more info` for bot to request more info (or use comment command mentioned above) * Add label `bot/close feature request` for bot to close a feature request with standard message and adds label `not implemented` * Add label `bot/no new info` for bot to close an issue where we asked for more info but has not received any updates in at least 14 days. diff --git a/.github/pr-commands.json b/.github/pr-commands.json index 27ac70337c0ae..0e4f0da1150c3 100644 --- a/.github/pr-commands.json +++ b/.github/pr-commands.json @@ -23,7 +23,6 @@ "package.json", "tsconfig.json", "lerna.json", - ".babelrc", ".prettierrc.js", ".eslintrc", "**/*.mdx" @@ -119,7 +118,7 @@ }, { "type": "changedfiles", - "matches": [ "public/app/plugins/datasource/jaeger"], + "matches": [ "public/app/plugins/datasource/jaeger/**/*"], "action": "updateLabel", "addLabel": "datasource/Jaeger" }, @@ -147,6 +146,18 @@ "action": "updateLabel", "addLabel": "datasource/OpenTSDB" }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/parca/**/*"], + "action": "updateLabel", + "addLabel": "datasource/Parca" + }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/grafana-pyroscope-datasource/**/*", "pkg/tsdb/grafana-pyroscope-datasource/**/*"], + "action": "updateLabel", + "addLabel": "datasource/grafana-pyroscope" + }, { "type": "changedfiles", "matches": [ "public/app/plugins/datasource/grafana-postgresql-datasource/**/*", "pkg/tsdb/grafana-postgresql-datasource/**/*"], diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 331453813b3c1..f1df23dde1a45 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -6,8 +6,10 @@ "ignoreDeps": [ "history", // we should bump this together with react-router-dom (see https://github.com/grafana/grafana/issues/76744) "react-router-dom", // we should bump this together with history (see https://github.com/grafana/grafana/issues/76744) + "loader-utils", // v3 requires upstream changes in ngtemplate-loader. ignore, and remove when we remove angular. "monaco-editor", // due to us exposing this via @grafana/ui/CodeEditor's props bumping can break plugins - "react-hook-form", // due to us exposing these hooks via @grafana/ui form components bumping can break plugins + "@fingerprintjs/fingerprintjs", // we don't want to bump to v4 due to licensing changes + "@swc/core", // versions ~1.4.5 contain multiple bugs related to baseUrl resolution breaking builds. ], "includePaths": ["package.json", "packages/**", "public/app/plugins/**"], "ignorePaths": ["emails/**", "plugins-bundled/**", "**/mocks/**", "packages/grafana-e2e/**"], @@ -15,10 +17,10 @@ "postUpdateOptions": ["yarnDedupeHighest"], "packageRules": [ { + "automerge": true, + "matchCurrentVersion": "!/^0/", "matchUpdateTypes": ["patch"], - "excludePackagePatterns": ["^@?storybook", "^@locker"], - "extends": ["schedule:monthly"], - "groupName": "Monthly patch updates" + "excludePackagePatterns": ["^@?storybook", "^@locker"] }, { "matchPackagePatterns": ["^@?storybook"], diff --git a/.github/workflows/alerting-swagger-gen.yml b/.github/workflows/alerting-swagger-gen.yml index 54b984bb56318..5e7b7baa99eba 100644 --- a/.github/workflows/alerting-swagger-gen.yml +++ b/.github/workflows/alerting-swagger-gen.yml @@ -16,7 +16,7 @@ jobs: - name: Set go version uses: actions/setup-go@v4 with: - go-version: '1.21.5' + go-version: '1.21.8' - name: Build swagger run: | make -C pkg/services/ngalert/api/tooling post.json api.json diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index a6bfe311ef865..eb71b7f8f5e00 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -47,7 +47,7 @@ jobs: name: Set go version uses: actions/setup-go@v4 with: - go-version: '1.21.5' + go-version: '1.21.8' # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL @@ -67,3 +67,4 @@ jobs: - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v2 + if: github.repository == 'grafana/grafana' diff --git a/.github/workflows/core-plugins-build-and-release.yml b/.github/workflows/core-plugins-build-and-release.yml index aa1136569bf08..77046482b9724 100644 --- a/.github/workflows/core-plugins-build-and-release.yml +++ b/.github/workflows/core-plugins-build-and-release.yml @@ -1,3 +1,5 @@ +name: Build and release core plugins + on: workflow_dispatch: inputs: @@ -6,7 +8,13 @@ on: required: true type: choice options: + - grafana-azure-monitor-datasource + - grafana-pyroscope-datasource - grafana-testdata-datasource + - parca + - stackdriver + - tempo + - zipkin concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}-${{ inputs.plugin_id }} @@ -59,7 +67,12 @@ jobs: shell: bash id: get_dir run: | - dir=$(find public/app/plugins -name ${{ inputs.plugin_id }} -print -quit) + dir=$(dirname \ + $(egrep -lir --include=plugin.json --exclude-dir=dist \ + '"id": "${{ inputs.plugin_id }}"' \ + public/app/plugins \ + ) \ + ) echo "dir=${dir}" >> $GITHUB_OUTPUT - name: Install frontend dependencies shell: bash @@ -77,7 +90,7 @@ jobs: id: check_backend shell: bash run: | - if [ -d ./pkg/tsdb/${{ inputs.plugin_id }} ]; then + if egrep -qr --include=main.go 'datasource.Manage\("${{ inputs.plugin_id }}"' pkg/tsdb; then echo "has_backend=true" >> $GITHUB_OUTPUT else echo "has_backend=false" >> $GITHUB_OUTPUT @@ -172,7 +185,7 @@ jobs: exit 1 fi - name: store build artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: build-artifacts path: ${{ steps.get_dir.outputs.dir }}/ci/packages/*.zip @@ -188,6 +201,7 @@ jobs: gsutil -m cp -r ci/packages/*darwin* gs://${{ env.GCP_BUCKET }}/${{ inputs.plugin_id }}/release/${VERSION}/darwin gsutil -m cp -r ci/packages/*any* gs://${{ env.GCP_BUCKET }}/${{ inputs.plugin_id }}/release/${VERSION}/any - name: Publish new plugin version on grafana.com + if: steps.check_backend.outputs.has_backend == 'true' working-directory: ${{ steps.get_dir.outputs.dir }} env: GCOM_TOKEN: ${{ env.PLUGINS_GCOM_TOKEN }} @@ -228,4 +242,27 @@ jobs: echo "Failed to publish plugin version. Got:" echo $result exit 1 - fi \ No newline at end of file + fi + - name: Publish new plugin version on grafana.com (frontend only) + if: steps.check_backend.outputs.has_backend == 'false' + working-directory: ${{ steps.get_dir.outputs.dir }} + env: + GCOM_TOKEN: ${{ env.PLUGINS_GCOM_TOKEN }} + VERSION: ${{ steps.build_frontend.outputs.version }} + run: | + echo "Publish new plugin version on grafana.com:" + echo "Plugin version: ${VERSION}" + result=`curl -H "Authorization: Bearer $GCOM_TOKEN" -H "Content-Type: application/json" ${{ env.GCOM_API}}/api/plugins -d "{ + \"url\": \"https://github.com/grafana/grafana/tree/main/${{ steps.get_dir.outputs.dir }}\", + \"download\": { + \"any\": { + \"url\": \"https://storage.googleapis.com/${{ env.GCP_BUCKET }}/${{ inputs.plugin_id }}/release/${VERSION}/any/${{ inputs.plugin_id }}-${VERSION}.any.zip\", + \"md5\": \"$(cat ci/packages/info-any.json | jq -r .plugin.md5)\" + } + } + }"` + if [[ "$(echo $result | jq -r .version)" == "null" ]]; then + echo "Failed to publish plugin version. Got:" + echo $result + exit 1 + fi diff --git a/.github/workflows/create-security-patch-from-security-mirror.yml b/.github/workflows/create-security-patch-from-security-mirror.yml index 413addb6827dc..e187149b8b47c 100644 --- a/.github/workflows/create-security-patch-from-security-mirror.yml +++ b/.github/workflows/create-security-patch-from-security-mirror.yml @@ -1,5 +1,5 @@ -# Owned by grafana-delivery-squad -# Intended to be dropped into the base repo (Ex: grafana/grafana) for use in the security mirror. +# Owned by grafana-release-guild +# Intended to be dropped into the base repo (Ex: grafana/grafana) for use in the security mirror. name: Create security patch run-name: create-security-patch on: @@ -17,7 +17,7 @@ jobs: trigger_downstream_create_security_patch: concurrency: create-patch-${{ github.ref_name }} uses: grafana/security-patch-actions/.github/workflows/create-patch.yml@main - if: github.repository == 'grafana/grafana-security-mirror' + if: github.repository == 'grafana/grafana-security-mirror' with: repo: "${{ github.repository }}" src_ref: "${{ github.head_ref }}" # this is the source branch name, Ex: "feature/newthing" diff --git a/.github/workflows/detect-breaking-changes-build-skip.yml b/.github/workflows/detect-breaking-changes-build-skip.yml deleted file mode 100644 index 58e3cb5642451..0000000000000 --- a/.github/workflows/detect-breaking-changes-build-skip.yml +++ /dev/null @@ -1,34 +0,0 @@ -# Workflow for skipping the Levitate detection -# (This is needed because workflows that are skipped due to path filtering will show up as pending in Github. -# As this has the same name as the one in detect-breaking-changes-build.yml it will take over in these cases and succeed quickly.) - -name: Levitate / Detect breaking changes - -on: - pull_request: - paths-ignore: - - "packages/**" - branches: - - 'main' - -jobs: - detect: - name: Detect breaking changes - runs-on: ubuntu-latest - - steps: - - name: Skipping - run: echo "No modifications in the public API (packages/), skipping." - - # Build and persist output as a JSON (we need to tell the report workflow that the check has been skipped) - - name: Persisting the check output - run: | - mkdir -p ./levitate - echo "{ \"shouldSkip\": true }" > ./levitate/result.json - - # Upload artifact (so it can be used in the more privileged "report" workflow) - - name: Upload check output as artifact - uses: actions/upload-artifact@v3 - with: - name: levitate - path: levitate/ diff --git a/.github/workflows/detect-breaking-changes-build.yml b/.github/workflows/detect-breaking-changes-build.yml deleted file mode 100644 index 27d119d638e13..0000000000000 --- a/.github/workflows/detect-breaking-changes-build.yml +++ /dev/null @@ -1,163 +0,0 @@ -# Only runs if anything under the packages/ directory changes. -# (Otherwise detect-breaking-changes-build-skip.yml takes over) - -name: Levitate / Detect breaking changes - -on: - pull_request: - paths: - - 'packages/**' - branches: - - 'main' - -jobs: - buildPR: - name: Build PR - runs-on: ubuntu-latest - defaults: - run: - working-directory: './pr' - - steps: - - uses: actions/checkout@v4 - with: - path: './pr' - - uses: actions/setup-node@v4 - with: - node-version: 20.9.0 - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT - - - name: Restore yarn cache - uses: actions/cache@v3.3.1 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: yarn-cache-folder-${{ hashFiles('**/yarn.lock', '.yarnrc.yml') }} - restore-keys: | - yarn-cache-folder- - - - name: Install dependencies - run: yarn install --immutable - - - name: Build packages - run: yarn packages:build - - - name: Pack packages - run: yarn packages:pack --out ./%s.tgz - - - name: Zip built tarballed packages - run: zip -r ./pr_built_packages.zip ./packages/**/*.tgz - - - name: Upload build output as artifact - uses: actions/upload-artifact@v3 - with: - name: buildPr - path: './pr/pr_built_packages.zip' - - buildBase: - name: Build Base - runs-on: ubuntu-latest - defaults: - run: - working-directory: './base' - - steps: - - uses: actions/checkout@v4 - with: - path: './base' - ref: ${{ github.event.pull_request.base.ref }} - - - uses: actions/setup-node@v4 - with: - node-version: 20.9.0 - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT - - - name: Restore yarn cache - uses: actions/cache@v3.3.1 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: yarn-cache-folder-${{ hashFiles('**/yarn.lock', '.yarnrc.yml') }} - restore-keys: | - yarn-cache-folder- - - - name: Install dependencies - run: yarn install --immutable - - - name: Build packages - run: yarn packages:build - - - name: Pack packages - run: yarn packages:pack --out ./%s.tgz - - - name: Zip built tarballed packages - run: zip -r ./base_built_packages.zip ./packages/**/*.tgz - - - name: Upload build output as artifact - uses: actions/upload-artifact@v3 - with: - name: buildBase - path: './base/base_built_packages.zip' - - Detect: - name: Detect breaking changes - runs-on: ubuntu-latest - needs: ['buildPR', 'buildBase'] - env: - GITHUB_STEP_NUMBER: 8 - - steps: - - uses: actions/checkout@v4 - - - name: Get built packages from pr - uses: actions/download-artifact@v3 - with: - name: buildPr - - - name: Get built packages from base - uses: actions/download-artifact@v3 - with: - name: buildBase - - - name: Unzip artifact from pr - run: unzip -j pr_built_packages.zip -d ./pr && rm pr_built_packages.zip - - - name: Unzip artifact from base - run: unzip -j base_built_packages.zip -d ./base && rm base_built_packages.zip - - - name: Get link for the Github Action job - id: job - uses: actions/github-script@v6 - with: - script: | - const name = 'Detect breaking changes'; - const script = require('./.github/workflows/scripts/pr-get-job-link.js') - await script({name, github, context, core}) - - - name: Detect breaking changes - id: breaking-changes - run: ./scripts/check-breaking-changes.sh - env: - FORCE_COLOR: 3 - GITHUB_JOB_LINK: ${{ steps.job.outputs.link }} - - - name: Persisting the check output - run: | - mkdir -p ./levitate - echo "{ \"exit_code\": ${{ steps.breaking-changes.outputs.is_breaking }}, \"message\": \"${{ steps.breaking-changes.outputs.message }}\", \"job_link\": \"${{ steps.job.outputs.link }}#step:${GITHUB_STEP_NUMBER}:1\", \"pr_number\": \"${{ github.event.pull_request.number }}\" }" > ./levitate/result.json - - - name: Upload check output as artifact - uses: actions/upload-artifact@v3 - with: - name: levitate - path: levitate/ - - - name: Exit - run: exit ${{ steps.breaking-changes.outputs.is_breaking }} - shell: bash diff --git a/.github/workflows/detect-breaking-changes-levitate.yml b/.github/workflows/detect-breaking-changes-levitate.yml new file mode 100644 index 0000000000000..6ef0ac3514a68 --- /dev/null +++ b/.github/workflows/detect-breaking-changes-levitate.yml @@ -0,0 +1,340 @@ +# Only runs if anything under the packages/ directory changes. +--- +name: Levitate / Detect breaking changes in PR + +on: + pull_request: + paths: + - 'packages/**' + branches: + - 'main' + +jobs: + buildPR: + name: Build PR + runs-on: ubuntu-latest + defaults: + run: + working-directory: './pr' + + steps: + - uses: actions/checkout@v4 + with: + path: './pr' + - uses: actions/setup-node@v4 + with: + node-version: 20.9.0 + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT + + - name: Restore yarn cache + uses: actions/cache@v4 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: yarn-cache-folder-${{ hashFiles('**/yarn.lock', '.yarnrc.yml') }} + restore-keys: | + yarn-cache-folder- + + - name: Install dependencies + run: yarn install --immutable + + - name: Build packages + run: yarn packages:build + + - name: Pack packages + run: yarn packages:pack --out ./%s.tgz + + - name: Zip built tarballed packages + run: zip -r ./pr_built_packages.zip ./packages/**/*.tgz + + - name: Upload build output as artifact + uses: actions/upload-artifact@v4 + with: + name: buildPr + path: './pr/pr_built_packages.zip' + + buildBase: + name: Build Base + runs-on: ubuntu-latest + defaults: + run: + working-directory: './base' + + steps: + - uses: actions/checkout@v4 + with: + path: './base' + ref: ${{ github.event.pull_request.base.ref }} + + - uses: actions/setup-node@v4 + with: + node-version: 20.9.0 + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT + + - name: Restore yarn cache + uses: actions/cache@v4 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: yarn-cache-folder-${{ hashFiles('**/yarn.lock', '.yarnrc.yml') }} + restore-keys: | + yarn-cache-folder- + + - name: Install dependencies + run: yarn install --immutable + + - name: Build packages + run: yarn packages:build + + - name: Pack packages + run: yarn packages:pack --out ./%s.tgz + + - name: Zip built tarballed packages + run: zip -r ./base_built_packages.zip ./packages/**/*.tgz + + - name: Upload build output as artifact + uses: actions/upload-artifact@v4 + with: + name: buildBase + path: './base/base_built_packages.zip' + + Detect: + name: Detect breaking changes + runs-on: ubuntu-latest + needs: ['buildPR', 'buildBase'] + env: + GITHUB_STEP_NUMBER: 8 + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.9.0 + + - name: Get built packages from pr + uses: actions/download-artifact@v4 + with: + name: buildPr + + - name: Get built packages from base + uses: actions/download-artifact@v4 + with: + name: buildBase + + - name: Unzip artifact from pr + run: unzip -j pr_built_packages.zip -d ./pr && rm pr_built_packages.zip + + - name: Unzip artifact from base + run: unzip -j base_built_packages.zip -d ./base && rm base_built_packages.zip + + - name: Get link for the Github Action job + id: job + uses: actions/github-script@v6 + with: + script: | + const name = 'Detect breaking changes'; + const script = require('./.github/workflows/scripts/pr-get-job-link.js') + await script({name, github, context, core}) + + - name: Detect breaking changes + id: breaking-changes + run: ./scripts/check-breaking-changes.sh + env: + FORCE_COLOR: 3 + GITHUB_JOB_LINK: ${{ steps.job.outputs.link }} + + - name: Persisting the check output + run: | + mkdir -p ./levitate + echo "{ \"exit_code\": ${{ steps.breaking-changes.outputs.is_breaking }}, \"message\": \"${{ steps.breaking-changes.outputs.message }}\", \"job_link\": \"${{ steps.job.outputs.link }}#step:${GITHUB_STEP_NUMBER}:1\", \"pr_number\": \"${{ github.event.pull_request.number }}\" }" > ./levitate/result.json + + - name: Upload check output as artifact + uses: actions/upload-artifact@v4 + with: + name: levitate + path: levitate/ + + + Report: + name: Report breaking changes in PR + runs-on: ubuntu-latest + needs: ['Detect'] + + steps: + - name: "Generate token" + id: generate_token + uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 + with: + app_id: ${{ secrets.GRAFANA_PR_AUTOMATION_APP_ID }} + private_key: ${{ secrets.GRAFANA_PR_AUTOMATION_APP_PEM }} + + - uses: actions/checkout@v4 + + - name: 'Download artifact' + uses: actions/download-artifact@v4 + with: + name: levitate + + - name: Parsing levitate result + uses: actions/github-script@v6 + id: levitate-run + with: + script: | + const filePath = 'result.json'; + const script = require('./.github/workflows/scripts/json-file-to-job-output.js'); + await script({ core, filePath }); + + # Check if label exists + - name: Check if "levitate breaking change" label exists + id: does-label-exist + uses: actions/github-script@v6 + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + with: + script: | + const { data } = await github.rest.issues.listLabelsOnIssue({ + issue_number: process.env.PR_NUMBER, + owner: context.repo.owner, + repo: context.repo.repo, + }); + const labels = data.map(({ name }) => name); + const doesExist = labels.includes('levitate breaking change'); + + return doesExist ? 1 : 0; + + # put the markdown into a variable + - name: Levitate Markdown + id: levitate-markdown + run: | + if [ -f "levitate.md" ]; then + { + echo 'levitate_markdown<<EOF' + cat levitate.md + echo EOF + } >> $GITHUB_OUTPUT + else + echo "levitate_markdown=No breaking changes detected" >> $GITHUB_OUTPUT + fi + + + # Comment on the PR + - name: Comment on PR + if: steps.levitate-run.outputs.exit_code == 1 + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: levitate-breaking-change-comment + number: ${{ github.event.pull_request.number }} + message: | + ⚠️   **Possible breaking changes (md version)**   ⚠️ + + ${{ steps.levitate-markdown.outputs.levitate_markdown }} + + [Read our guideline](https://github.com/grafana/grafana/blob/main/contribute/breaking-changes-guide/breaking-changes-guide.md) + [Console output](${{ steps.levitate-run.outputs.job_link }}) + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + + # Remove comment from the PR (no more breaking changes) + - name: Remove comment from PR + if: steps.levitate-run.outputs.exit_code == 0 + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: levitate-breaking-change-comment + number: ${{ github.event.pull_request.number }} + delete: true + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + + # Posts a notification to Slack if a PR has a breaking change and it did not have a breaking change before + - name: Post to Slack + id: slack + if: steps.levitate-run.outputs.exit_code == 1 && steps.does-label-exist.outputs.result == 0 && env.HAS_SECRETS + uses: slackapi/slack-github-action@v1.24.0 + with: + payload: | + { + "pr_link": "https://github.com/grafana/grafana/pull/${{ steps.levitate-run.outputs.pr_number }}", + "pr_number": "${{ steps.levitate-run.outputs.pr_number }}", + "job_link": "${{ steps.levitate-run.outputs.job_link }}", + "reporting_job_link": "${{ github.event.workflow_run.html_url }}", + "message": "${{ steps.levitate-run.outputs.message }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_LEVITATE_WEBHOOK_URL }} + HAS_SECRETS: ${{ (github.repository == 'grafana/grafana' || secrets.SLACK_LEVITATE_WEBHOOK_URL != '') || '' }} + + # Add the label + - name: Add "levitate breaking change" label + if: steps.levitate-run.outputs.exit_code == 1 && steps.does-label-exist.outputs.result == 0 + uses: actions/github-script@v6 + env: + PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} + with: + github-token: ${{ steps.generate_token.outputs.token }} + script: | + await github.rest.issues.addLabels({ + issue_number: process.env.PR_NUMBER, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ['levitate breaking change'] + }) + + # Remove label (no more breaking changes) + - name: Remove "levitate breaking change" label + if: steps.levitate-run.outputs.exit_code == 0 && steps.does-label-exist.outputs.result == 1 + uses: actions/github-script@v6 + env: + PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} + with: + github-token: ${{ steps.generate_token.outputs.token }} + script: | + await github.rest.issues.removeLabel({ + issue_number: process.env.PR_NUMBER, + owner: context.repo.owner, + repo: context.repo.repo, + name: 'levitate breaking change' + }) + + # Add reviewers + # This is very weird, the actual request goes through (comes back with a 201), but does not assign the team. + # Related issue: https://github.com/renovatebot/renovate/issues/1908 + - name: Add "grafana/plugins-platform-frontend" as a reviewer + if: steps.levitate-run.outputs.exit_code == 1 + uses: actions/github-script@v6 + env: + PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} + with: + github-token: ${{ steps.generate_token.outputs.token }} + script: | + await github.rest.pulls.requestReviewers({ + pull_number: process.env.PR_NUMBER, + owner: context.repo.owner, + repo: context.repo.repo, + reviewers: [], + team_reviewers: ['plugins-platform-frontend'] + }); + + # Remove reviewers (no more breaking changes) + - name: Remove "grafana/plugins-platform-frontend" from the list of reviewers + if: steps.levitate-run.outputs.exit_code == 0 + uses: actions/github-script@v6 + env: + PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} + with: + github-token: ${{ steps.generate_token.outputs.token }} + script: | + await github.rest.pulls.removeRequestedReviewers({ + pull_number: process.env.PR_NUMBER, + owner: context.repo.owner, + repo: context.repo.repo, + reviewers: [], + team_reviewers: ['plugins-platform-frontend'] + }); + + - name: Exit + run: exit ${{ steps.levitate-run.outputs.exit_code }} + shell: bash diff --git a/.github/workflows/detect-breaking-changes-report.yml b/.github/workflows/detect-breaking-changes-report.yml deleted file mode 100644 index c6a2f0a6119ce..0000000000000 --- a/.github/workflows/detect-breaking-changes-report.yml +++ /dev/null @@ -1,223 +0,0 @@ -name: Levitate / Report breaking changes - -on: - workflow_run: - workflows: ["Levitate / Detect breaking changes"] - types: [completed] - -permissions: - pull-requests: write - -jobs: - notify: - name: Report - runs-on: ubuntu-latest - env: - ARTIFACT_NAME: 'levitate' # The name of the artifact that we would like to download - ARTIFACT_FOLDER: '${{ github.workspace }}/tmp' # The name of the folder where we will download the artifact to - permissions: - contents: read - issues: write - pull-requests: write - - steps: - - uses: actions/checkout@v4 - - # Download artifact (as a .zip archive) - - name: 'Download artifact' - uses: actions/github-script@v6 - env: - RUN_ID: ${{ github.event.workflow_run.id }} - with: - script: | - const fs = require('fs'); - - const { owner, repo } = context.repo; - const runId = process.env.RUN_ID; - const artifactName = process.env.ARTIFACT_NAME; - const artifactFolder = process.env.ARTIFACT_FOLDER; - const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ - owner, - repo, - run_id: runId, - }); - const artifact = artifacts.data.artifacts.find(a => a.name === artifactName); - - if (!artifact) { - throw new Error(`Could not find artifact ${ artifactName } in workflow (${ runId })`); - } - - const download = await github.rest.actions.downloadArtifact({ - owner, - repo, - artifact_id: artifact.id, - archive_format: 'zip', - }); - - fs.mkdirSync(artifactFolder, { recursive: true }); - fs.writeFileSync(`${ artifactFolder }/${ artifactName }.zip`, Buffer.from(download.data)); - - # Unzip artifact - - name: Unzip artifact - run: unzip "${ARTIFACT_FOLDER}/${ARTIFACT_NAME}.zip" -d "${ARTIFACT_FOLDER}" - - # Parse the artifact and register fields as step output variables - # (All fields in the JSON will be available as ${{ steps.levitate-run.outputs.<field-name> }} - - name: Parsing levitate result - uses: actions/github-script@v6 - id: levitate-run - with: - script: | - const filePath = `${ process.env.ARTIFACT_FOLDER }/result.json`; - const script = require('./.github/workflows/scripts/json-file-to-job-output.js'); - await script({ core, filePath }); - - # Skip - print a message if the "Detect" workflow was skipped - - name: Check if the workflow should be skipped - if: steps.levitate-run.outputs.shouldSkip == 'true' - run: echo "Skipping." - - # Check if label exists - - name: Check if "levitate breaking change" label exists - id: does-label-exist - if: steps.levitate-run.outputs.shouldSkip != 'true' - uses: actions/github-script@v6 - env: - PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} - with: - script: | - const { data } = await github.rest.issues.listLabelsOnIssue({ - issue_number: process.env.PR_NUMBER, - owner: context.repo.owner, - repo: context.repo.repo, - }); - const labels = data.map(({ name }) => name); - const doesExist = labels.includes('levitate breaking change'); - - return doesExist ? 1 : 0; - - # put the markdown into a variable - - name: Levitate Markdown - id: levitate-markdown - if: steps.levitate-run.outputs.shouldSkip != 'true' - run: | - if [ -f "${ARTIFACT_FOLDER}/levitate.md" ]; then - { - echo 'levitate_markdown<<EOF' - cat ${ARTIFACT_FOLDER}/levitate.md - echo EOF - } >> $GITHUB_OUTPUT - else - echo "levitate_markdown=No breaking changes detected" >> $GITHUB_OUTPUT - fi - - - # Comment on the PR - - name: Comment on PR - if: steps.levitate-run.outputs.exit_code == 1 && steps.levitate-run.outputs.shouldSkip != 'true' - uses: marocchino/sticky-pull-request-comment@v2 - with: - number: ${{ steps.levitate-run.outputs.pr_number }} - message: | - ⚠️   **Possible breaking changes (md version)** - - ${{ steps.levitate-markdown.outputs.levitate_markdown }} - - [Console output](${{ steps.levitate-run.outputs.job_link }}) - [Read our guideline](https://github.com/grafana/grafana/blob/main/contribute/breaking-changes-guide/breaking-changes-guide.md) - - # Remove comment from the PR (no more breaking changes) - - name: Remove comment from PR - if: steps.levitate-run.outputs.exit_code == 0 && steps.levitate-run.outputs.shouldSkip != 'true' - uses: marocchino/sticky-pull-request-comment@v2 - with: - number: ${{ steps.levitate-run.outputs.pr_number }} - delete: true - - # Posts a notification to Slack if a PR has a breaking change and it did not have a breaking change before - - name: Post to Slack - id: slack - if: steps.levitate-run.outputs.exit_code == 1 && steps.does-label-exist.outputs.result == 0 && steps.levitate-run.outputs.shouldSkip != 'true' && env.HAS_SECRETS - uses: slackapi/slack-github-action@v1.24.0 - with: - payload: | - { - "pr_link": "https://github.com/grafana/grafana/pull/${{ steps.levitate-run.outputs.pr_number }}", - "pr_number": "${{ steps.levitate-run.outputs.pr_number }}", - "job_link": "${{ steps.levitate-run.outputs.job_link }}", - "reporting_job_link": "${{ github.event.workflow_run.html_url }}", - "message": "${{ steps.levitate-run.outputs.message }}" - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_LEVITATE_WEBHOOK_URL }} - HAS_SECRETS: ${{ (github.repository == 'grafana/grafana' || secrets.SLACK_LEVITATE_WEBHOOK_URL != '') || '' }} - - # Add the label - - name: Add "levitate breaking change" label - if: steps.levitate-run.outputs.exit_code == 1 && steps.does-label-exist.outputs.result == 0 && steps.levitate-run.outputs.shouldSkip != 'true' - uses: actions/github-script@v6 - env: - PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - await github.rest.issues.addLabels({ - issue_number: process.env.PR_NUMBER, - owner: context.repo.owner, - repo: context.repo.repo, - labels: ['levitate breaking change'] - }) - - # Remove label (no more breaking changes) - - name: Remove "levitate breaking change" label - if: steps.levitate-run.outputs.exit_code == 0 && steps.does-label-exist.outputs.result == 1 && steps.levitate-run.outputs.shouldSkip != 'true' - uses: actions/github-script@v6 - env: - PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - await github.rest.issues.removeLabel({ - issue_number: process.env.PR_NUMBER, - owner: context.repo.owner, - repo: context.repo.repo, - name: 'levitate breaking change' - }) - - # Add reviewers - # This is very weird, the actual request goes through (comes back with a 201), but does not assign the team. - # Related issue: https://github.com/renovatebot/renovate/issues/1908 - - name: Add "grafana/plugins-platform-frontend" as a reviewer - if: steps.levitate-run.outputs.exit_code && steps.levitate-run.outputs.shouldSkip != 'true' - uses: actions/github-script@v6 - env: - PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - await github.rest.pulls.requestReviewers({ - pull_number: process.env.PR_NUMBER, - owner: context.repo.owner, - repo: context.repo.repo, - reviewers: [], - team_reviewers: ['grafana/plugins-platform-frontend'] - }); - - # Remove reviewers (no more breaking changes) - - name: Remove "grafana/plugins-platform-frontend" from the list of reviewers - if: steps.levitate-run.outputs.exit_code == 0 && steps.levitate-run.outputs.shouldSkip != 'true' - uses: actions/github-script@v6 - env: - PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - await github.rest.pulls.removeRequestedReviewers({ - pull_number: process.env.PR_NUMBER, - owner: context.repo.owner, - repo: context.repo.repo, - reviewers: [], - team_reviewers: ['grafana/plugins-platform-frontend'] - }); - - diff --git a/.github/workflows/doc-validator.yml b/.github/workflows/doc-validator.yml index 9e28a128b9785..620a8f84e7db4 100644 --- a/.github/workflows/doc-validator.yml +++ b/.github/workflows/doc-validator.yml @@ -7,7 +7,7 @@ jobs: doc-validator: runs-on: "ubuntu-latest" container: - image: "grafana/doc-validator:v4.0.0" + image: "grafana/doc-validator:v4.1.1" steps: - name: "Checkout code" uses: "actions/checkout@v4" diff --git a/.github/workflows/i18n-crowdin-download.yml b/.github/workflows/i18n-crowdin-download.yml new file mode 100644 index 0000000000000..c442dc1404897 --- /dev/null +++ b/.github/workflows/i18n-crowdin-download.yml @@ -0,0 +1,122 @@ +name: Crowdin Download Action + +on: + workflow_dispatch: + schedule: + - cron: "0 * * * *" + +jobs: + download-sources-from-crowdin: + runs-on: ubuntu-latest + + permissions: + contents: write # needed to commit changes into the PR + pull-requests: write # needed to update PR description, labels, etc + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + + - name: Download sources + id: crowdin-download + uses: crowdin/github-action@v1 + with: + upload_sources: false + upload_translations: false + download_sources: false + download_translations: true + export_only_approved: true + localization_branch_name: i18n_crowdin_translations + create_pull_request: true + pull_request_title: 'I18n: Download translations from Crowdin' + pull_request_body: | + :robot: Automatic download of translations from Crowdin. + + Steps for merging: + 1. A quick sanity check of the changes and approve. Things to look out for: + - No changes in the English file. The source of truth is in the main branch, NOT in Crowdin. + - Translations maybe be removed if the English phrase was removed, but there should not be many of these + - Anything else that looks 'funky'. Ask if you're not sure. + 2. Approve & (Auto-)merge. :tada: + + If there's a conflict, close the pull request and **delete the branch**. A GH action will recreate the pull request. + Remember, the longer this pull request is open, the more likely it is that it'll get conflicts. + pull_request_labels: 'area/frontend, area/internationalization, no-changelog, no-backport' + pull_request_reviewers: 'grafana-frontend-platform' + pull_request_base_branch_name: 'main' + base_url: 'https://grafana.api.crowdin.com' + config: 'crowdin.yml' + source: 'public/locales/en-US/grafana.json' + translation: 'public/locales/%locale%/%original_file_name%' + # Magic details of the github-actions bot user, to pass CLA checks + github_user_name: "github-actions[bot]" + github_user_email: "41898282+github-actions[bot]@users.noreply.github.com" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + + - name: Generate token + if: steps.crowdin-download.outputs.pull_request_url + id: generate_token + uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 + with: + app_id: ${{ secrets.GRAFANA_PR_AUTOMATION_APP_ID }} + private_key: ${{ secrets.GRAFANA_PR_AUTOMATION_APP_PEM }} + + - name: Get pull request ID + if: steps.crowdin-download.outputs.pull_request_url + shell: bash + # Crowdin action returns us the URL of the pull request, but we need an ID for the GraphQL API + # that looks like 'PR_kwDOAOaWjc5mP_GU' + run: | + pr_id=$(gh pr view ${{ steps.crowdin-download.outputs.pull_request_url }} --json id -q .id) + echo "PULL_REQUEST_ID=$pr_id" >> "$GITHUB_ENV" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Get project board ID + uses: octokit/graphql-action@v2.x + id: get-project-id + if: steps.crowdin-download.outputs.pull_request_url + with: + # Frontend Platform project - https://github.com/orgs/grafana/projects/78 + org: grafana + project_number: 78 + query: | + query getProjectId($org: String!, $project_number: Int!){ + organization(login: $org) { + projectV2(number: $project_number) { + title + id + } + } + } + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + + - name: Add to project board + uses: octokit/graphql-action@v2.x + if: steps.crowdin-download.outputs.pull_request_url + with: + projectid: ${{ fromJson(steps.get-project-id.outputs.data).organization.projectV2.id }} + prid: ${{ env.PULL_REQUEST_ID }} + query: | + mutation addPullRequestToProject($projectid: ID!, $prid: ID!){ + addProjectV2ItemById(input: {projectId: $projectid, contentId: $prid}) { + item { + id + } + } + } + + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + + - name: Run auto-milestone + uses: grafana/grafana-github-actions-go/auto-milestone@main + if: steps.crowdin-download.outputs.pull_request_url + with: + pr: ${{ steps.crowdin-download.outputs.pull_request_number }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/i18n-crowdin-fix-files.yml b/.github/workflows/i18n-crowdin-fix-files.yml deleted file mode 100644 index 5117bd381d6d5..0000000000000 --- a/.github/workflows/i18n-crowdin-fix-files.yml +++ /dev/null @@ -1,67 +0,0 @@ -# When Crowdin creates a pull request from the crowdin-service-branch branch, -# run `yarn i18n:extract` and commit the changed grafana.json files back into the PR -# to reformat crowdin's changes to prevent conflicts with our CI checks. - -name: Fix Crowdin I18n files - -on: - pull_request: - paths: - - 'public/locales/*/grafana.json' - branches: - - main # Only run on pull requests *target* main (will be merged into main) - -jobs: - fix-files: - # Only run on pull requests *from* the crowdin-service-branch branch - if: github.head_ref == 'crowdin-service-branch' - - name: Fix files - runs-on: ubuntu-latest - - - permissions: - contents: write # needed to commit changes back into the PR - pull-requests: write # needed to update PR description - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - - - uses: actions/setup-node@v4 - with: - node-version: 20.9.0 - cache: 'yarn' - - - name: Install dependencies - run: yarn install - - - name: Extract I18n files - run: yarn i18n:extract - - - name: Commit changes - uses: stefanzweifel/git-auto-commit-action@8756aa072ef5b4a080af5dc8fef36c5d586e521d # v5.0.0 - with: - commit_message: "Github Action: Auto-fix i18n files" - file_pattern: public/locales/*/grafana.json - - - name: Update PR description - uses: devindford/Append_PR_Comment@32dd2619cd96ac8da9907c416c992fe265233ca8 # v1.1.3 - if: ${{ ! contains(github.event.pull_request.body, 'Steps for merging') }} - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - body-update-action: prefix - body-template: | - :robot: Automatic sync of translations from Crowdin. - - Steps for merging: - 1. Wait for the "Github Action: Auto-fix i18n files" commit that may be required for CI to pass. - 2. A quick sanity check of the changes and approve. Things to look out for: - - No changes to the English strings. The source of truth is already in the main branch, NOT Crowdin. - - Translations maybe be removed if the English phrase was removed, but there should not be many of these - - Anything else that looks 'funky'. Ask if you're not sure. - 3. Approve & (Auto-)merge. :tada: - - If there's a conflict, close the pull request and **delete the branch**. Crowdin will recreate the pull request eventually. - Remember, the longer this pull request is open, the more likely it is that it'll get conflicts. diff --git a/.github/workflows/i18n-crowdin-upload.yml b/.github/workflows/i18n-crowdin-upload.yml new file mode 100644 index 0000000000000..9e028b5386fe2 --- /dev/null +++ b/.github/workflows/i18n-crowdin-upload.yml @@ -0,0 +1,33 @@ +name: Crowdin Upload Action + +on: + workflow_dispatch: + push: + paths: + - 'public/locales/en-US/grafana.json' + branches: + - main + +jobs: + upload-sources-to-crowdin: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Upload sources + uses: crowdin/github-action@v1 + with: + upload_sources: true + upload_sources_args: '--dest=public/locales/en-US/grafana.json' + upload_translations: false + download_translations: false + create_pull_request: false + base_url: 'https://grafana.api.crowdin.com' + config: 'crowdin.yml' + source: 'public/locales/en-US/grafana.json' + translation: 'public/locales/%locale%/%original_file_name%' + env: + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.github/workflows/issue-labeled.yml b/.github/workflows/issue-labeled.yml index e0a5abcdc3798..586b2d6d7f13e 100644 --- a/.github/workflows/issue-labeled.yml +++ b/.github/workflows/issue-labeled.yml @@ -53,7 +53,7 @@ jobs: echo "TEAM=${TEAM}" >> $GITHUB_ENV - name: "Prepare payload" - uses: frabert/replace-string-action@v2.0 + uses: frabert/replace-string-action@v2.5 id: preparePayload with: # replace double quotes with single quotes to avoid breaking the JSON payload sent to Slack @@ -64,7 +64,7 @@ jobs: - name: Get Token id: get_workflow_token - uses: peter-murray/workflow-application-token-action@v2 + uses: peter-murray/workflow-application-token-action@v3 with: application_id: ${{ secrets.APP_GRAFANA_TEAM_CHECKER_ID }} application_private_key: ${{ secrets.APP_GRAFANA_TEAM_CHECKER_KEY }} diff --git a/.github/workflows/metrics-collector.yml b/.github/workflows/metrics-collector.yml index 7728516153329..2e22a830a8845 100644 --- a/.github/workflows/metrics-collector.yml +++ b/.github/workflows/metrics-collector.yml @@ -25,7 +25,7 @@ jobs: id: check shell: bash run: | - if [ -n "${{ (secrets.GRAFANA_MISC_STATS_API_KEY != '' && secrets.GH_BOT_ACCESS_TOKEN != '') || '' }}" ]; then + if [ -n "${{ (secrets.GRAFANA_MISC_STATS_API_KEY != '') || '' }}" ]; then echo "has-secrets=1" >> "$GITHUB_OUTPUT" fi @@ -46,5 +46,5 @@ jobs: uses: ./actions/metrics-collector with: metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}} - token: ${{secrets.GH_BOT_ACCESS_TOKEN}} + token: ${{secrets.GITHUB_TOKEN}} configPath: "metrics-collector" diff --git a/.github/workflows/pr-codeql-analysis-go.yml b/.github/workflows/pr-codeql-analysis-go.yml index c47c19a09a8e0..6aa56cf57f66a 100644 --- a/.github/workflows/pr-codeql-analysis-go.yml +++ b/.github/workflows/pr-codeql-analysis-go.yml @@ -16,17 +16,26 @@ jobs: runs-on: ubuntu-latest steps: + - name: "Generate token" + id: generate_token + continue-on-error: true + uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a + with: + app_id: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_ID }} + private_key: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_PEM }} + - name: Checkout repository uses: actions/checkout@v4 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. fetch-depth: 2 + token: ${{ steps.generate_token.outputs.token }} - name: Set go version uses: actions/setup-go@v4 with: - go-version: '1.21.5' + go-version: '1.21.8' # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL @@ -41,3 +50,4 @@ jobs: - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v2 + if: github.repository == 'grafana/grafana' diff --git a/.github/workflows/pr-codeql-analysis-javascript.yml b/.github/workflows/pr-codeql-analysis-javascript.yml index 304b2798fbc80..e09b3a71c7051 100644 --- a/.github/workflows/pr-codeql-analysis-javascript.yml +++ b/.github/workflows/pr-codeql-analysis-javascript.yml @@ -33,3 +33,4 @@ jobs: - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v2 + if: github.repository == 'grafana/grafana' diff --git a/.github/workflows/pr-codeql-analysis-python.yml b/.github/workflows/pr-codeql-analysis-python.yml index acd2352a2aecf..e990d71391509 100644 --- a/.github/workflows/pr-codeql-analysis-python.yml +++ b/.github/workflows/pr-codeql-analysis-python.yml @@ -31,3 +31,4 @@ jobs: - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v2 + if: github.repository == 'grafana/grafana' diff --git a/.github/workflows/pr-patch-check.yml b/.github/workflows/pr-patch-check.yml index 1ff327ebdef8c..ef1009b7545a4 100644 --- a/.github/workflows/pr-patch-check.yml +++ b/.github/workflows/pr-patch-check.yml @@ -1,4 +1,4 @@ -# Owned by grafana-delivery-squad +# Owned by grafana-release-guild # Intended to be dropped into the base repo Ex: grafana/grafana name: Check for patch conflicts run-name: check-patch-conflicts-${{ github.base_ref }}-${{ github.head_ref }} @@ -18,6 +18,7 @@ on: jobs: trigger_downstream_patch_check: uses: grafana/security-patch-actions/.github/workflows/test-patches.yml@main + if: github.repository == 'grafana/grafana' with: src_repo: "${{ github.repository }}" src_ref: "${{ github.head_ref }}" # this is the source branch name, Ex: "feature/newthing" diff --git a/.github/workflows/publish-kinds-next.yml b/.github/workflows/publish-kinds-next.yml index ec88f288df7c9..7dc3cedb741b8 100644 --- a/.github/workflows/publish-kinds-next.yml +++ b/.github/workflows/publish-kinds-next.yml @@ -36,7 +36,7 @@ jobs: - name: "Setup Go" uses: "actions/setup-go@v4" with: - go-version: '1.21.5' + go-version: '1.21.8' - name: "Verify kinds" run: go run .github/workflows/scripts/kinds/verify-kinds.go diff --git a/.github/workflows/publish-kinds-release.yml b/.github/workflows/publish-kinds-release.yml index 5e32b09d40840..acdfcbcb0b5b9 100644 --- a/.github/workflows/publish-kinds-release.yml +++ b/.github/workflows/publish-kinds-release.yml @@ -39,7 +39,7 @@ jobs: - name: "Setup Go" uses: "actions/setup-go@v4" with: - go-version: '1.21.5' + go-version: '1.21.8' - name: "Verify kinds" run: go run .github/workflows/scripts/kinds/verify-kinds.go diff --git a/.github/workflows/publish-technical-documentation-release.yml b/.github/workflows/publish-technical-documentation-release.yml index 87f34de25b269..c22bb41cb4897 100644 --- a/.github/workflows/publish-technical-documentation-release.yml +++ b/.github/workflows/publish-technical-documentation-release.yml @@ -55,7 +55,7 @@ jobs: # Tags aren't necessarily made to the HEAD of the version branch. # The documentation to be published is always on the HEAD of the version branch. if: "steps.has-matching-release-tag.outputs.bool == 'true' && github.ref_type == 'tag'" - run: "git switch --detach origin/${{ steps.target.output.target }}.x" + run: "git switch --detach origin/${{ steps.target.outputs.target }}.x" - name: "Publish to website repository (release)" if: "steps.has-matching-release-tag.outputs.bool == 'true'" diff --git a/.github/workflows/scripts/kinds/verify-kinds.go b/.github/workflows/scripts/kinds/verify-kinds.go index 72fc3b3047619..ab60a90bd3ee0 100644 --- a/.github/workflows/scripts/kinds/verify-kinds.go +++ b/.github/workflows/scripts/kinds/verify-kinds.go @@ -1,227 +1,124 @@ package main import ( - "archive/zip" "context" + "errors" "fmt" - "io" - "net/http" + "golang.org/x/text/cases" + "golang.org/x/text/language" "os" "path/filepath" "regexp" - "strconv" "strings" - "testing/fstest" "cuelang.org/go/cue" cueformat "cuelang.org/go/cue/format" - "github.com/google/go-github/github" "github.com/grafana/codejen" - "github.com/grafana/grafana/pkg/codegen" - "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/grafana/pkg/plugins/pfs" - "github.com/grafana/grafana/pkg/plugins/pfs/corelist" - "github.com/grafana/grafana/pkg/registry/corekind" - "github.com/grafana/kindsys" - "github.com/grafana/thema" - "golang.org/x/oauth2" + "github.com/grafana/grafana/pkg/registry/schemas" ) -const ( - GITHUB_OWNER = "grafana" - GITHUB_REPO = "kind-registry" -) +var nonAlphaNumRegex = regexp.MustCompile("[^a-zA-Z0-9 ]+") // main This script verifies that stable kinds are not updated once published (new schemas // can be added but existing ones cannot be updated). -// If the env variable CODEGEN_VERIFY is not present, this also generates kind files into a -// local "next" folder, ready to be published in the kind-registry repo. +// It generates kind files into a local "next" folder, ready to be published in the kind-registry repo. // If kind names are given as parameters, the script will make the above actions only for the // given kinds. func main() { - var corek []kindsys.Kind - var compok []kindsys.Composable - - kindRegistry, err := NewKindRegistry() - defer kindRegistry.cleanUp() - if err != nil { - die(err) - } - - // Search for the latest version directory present in the kind-registry repo - latestRegistryDir, err := kindRegistry.findLatestDir() - if err != nil { - die(fmt.Errorf("failed to get latest directory for published kinds: %s", err)) - } - - errs := make([]error, 0) - - // Kind verification - for _, kind := range corekind.NewBase(nil).All() { - name := kind.Props().Common().MachineName - err := verifyKind(kindRegistry, kind, name, "core", latestRegistryDir) - if err != nil { - errs = append(errs, err) - continue - } - - corek = append(corek, kind) - } - - for _, pp := range corelist.New(nil) { - for _, kind := range pp.ComposableKinds { - si, err := kindsys.FindSchemaInterface(kind.Def().Properties.SchemaInterface) - if err != nil { - errs = append(errs, err) - continue - } - - name := strings.ToLower(fmt.Sprintf("%s/%s", strings.TrimSuffix(kind.Lineage().Name(), si.Name()), si.Name())) - err = verifyKind(kindRegistry, kind, name, "composable", latestRegistryDir) - if err != nil { - errs = append(errs, err) - continue - } - - compok = append(compok, kind) - } - } - - die(errs...) - - if _, set := os.LookupEnv("CODEGEN_VERIFY"); set { - os.Exit(0) - } - // File generation jfs := codejen.NewFS() outputPath := filepath.Join(".github", "workflows", "scripts", "kinds") - coreJennies := codejen.JennyList[kindsys.Kind]{} + corekinds, err := schemas.GetCoreKinds() + die(err) + + composableKinds, err := schemas.GetComposableKinds() + die(err) + + coreJennies := codejen.JennyList[schemas.CoreKind]{} coreJennies.Append( - KindRegistryJenny(outputPath), + CoreKindRegistryJenny(outputPath), ) - corefs, err := coreJennies.GenerateFS(corek...) + corefs, err := coreJennies.GenerateFS(corekinds...) die(err) die(jfs.Merge(corefs)) - composableJennies := codejen.JennyList[kindsys.Composable]{} + composableJennies := codejen.JennyList[schemas.ComposableKind]{} composableJennies.Append( ComposableKindRegistryJenny(outputPath), ) - composablefs, err := composableJennies.GenerateFS(compok...) + composablefs, err := composableJennies.GenerateFS(composableKinds...) die(err) die(jfs.Merge(composablefs)) if err = jfs.Write(context.Background(), ""); err != nil { die(fmt.Errorf("error while writing generated code to disk:\n%s", err)) } -} -func die(errs ...error) { - if len(errs) > 0 && errs[0] != nil { - for _, err := range errs { - fmt.Fprint(os.Stderr, err, "\n") - } - os.Exit(1) + if err := copyCueSchemas("packages/grafana-schema/src/common", filepath.Join(outputPath, "next")); err != nil { + die(fmt.Errorf("error while copying the grafana-schema/common package:\n%s", err)) } } -// verifyKind verifies that stable kinds are not updated once published (new schemas -// can be added but existing ones cannot be updated) -func verifyKind(registry *kindRegistry, kind kindsys.Kind, name string, category string, latestRegistryDir string) error { - oldKindString, err := registry.getPublishedKind(name, category, latestRegistryDir) - if err != nil { - return err - } +func copyCueSchemas(fromDir string, toDir string) error { + baseTargetDir := filepath.Base(fromDir) - var oldKind kindsys.Kind - if oldKindString != "" { - switch category { - case "core": - oldKind, err = loadCoreKind(name, oldKindString) - case "composable": - oldKind, err = loadComposableKind(name, oldKindString) - default: - return fmt.Errorf("kind can only be core or composable") + return filepath.Walk(fromDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err } - } - if err != nil { - return err - } - - // Kind is new - no need to compare it - if oldKind == nil { - return nil - } - - // Check that maturity isn't downgraded - if kind.Maturity().Less(oldKind.Maturity()) { - return fmt.Errorf("kind maturity can't be downgraded once a kind is published") - } - - if oldKind.Maturity().Less(kindsys.MaturityStable) { - return nil - } - // Check that old schemas do not contain updates - err = thema.IsAppendOnly(oldKind.Lineage(), kind.Lineage()) - if err != nil { - return fmt.Errorf("existing schemas in lineage %s cannot be modified: %w", name, err) - } + targetPath := filepath.Join( + toDir, + baseTargetDir, + strings.TrimPrefix(path, fromDir), + ) - return nil -} + if info.IsDir() { + return ensureDirectoryExists(targetPath, info.Mode()) + } -func isLess(v1 []uint64, v2 []uint64) bool { - if len(v1) == 1 || len(v2) == 1 { - return v1[0] < v2[0] - } + if !strings.HasSuffix(path, ".cue") { + return nil + } - return v1[0] < v2[0] || (v1[0] == v2[0] && isLess(v1[2:], v2[2:])) + return copyFile(path, targetPath, info.Mode()) + }) } -func loadCoreKind(name string, kind string) (kindsys.Kind, error) { - fs := fstest.MapFS{ - fmt.Sprintf("%s.cue", name): &fstest.MapFile{ - Data: []byte(kind), - }, - } - - rt := cuectx.GrafanaThemaRuntime() - - def, err := cuectx.LoadCoreKindDef(fmt.Sprintf("%s.cue", name), rt.Context(), fs) +func copyFile(from string, to string, mode os.FileMode) error { + input, err := os.ReadFile(from) if err != nil { - return nil, fmt.Errorf("%s is not a valid kind: %w", name, err) + return err } - return kindsys.BindCore(rt, def) + return os.WriteFile(to, input, mode) } -func loadComposableKind(name string, kind string) (kindsys.Kind, error) { - parts := strings.Split(name, "/") - if len(parts) > 1 { - name = parts[1] - } - - fs := fstest.MapFS{ - fmt.Sprintf("%s.cue", name): &fstest.MapFile{ - Data: []byte(kind), - }, +func ensureDirectoryExists(directory string, mode os.FileMode) error { + _, err := os.Stat(directory) + if errors.Is(err, os.ErrNotExist) { + if err = os.Mkdir(directory, mode); err != nil { + return err + } + } else if err != nil { + return err } - rt := cuectx.GrafanaThemaRuntime() + return os.Chmod(directory, mode) +} - def, err := pfs.LoadComposableKindDef(fs, rt, fmt.Sprintf("%s.cue", name)) - if err != nil { - return nil, fmt.Errorf("%s is not a valid kind: %w", name, err) +func die(errs ...error) { + if len(errs) > 0 && errs[0] != nil { + for _, err := range errs { + fmt.Fprint(os.Stderr, err, "\n") + } + os.Exit(1) } - - return kindsys.BindComposable(rt, def) } -// KindRegistryJenny generates kind files into the "next" folder of the local kind registry. -func KindRegistryJenny(path string) codegen.OneToOne { +// CoreKindRegistryJenny generates kind files into the "next" folder of the local kind registry. +func CoreKindRegistryJenny(path string) codejen.OneToOne[schemas.CoreKind] { return &kindregjenny{ path: path, } @@ -235,35 +132,18 @@ func (j *kindregjenny) JennyName() string { return "KindRegistryJenny" } -func (j *kindregjenny) Generate(kind kindsys.Kind) (*codejen.File, error) { - name := kind.Props().Common().MachineName - core, ok := kind.(kindsys.Core) - if !ok { - return nil, fmt.Errorf("kind sent to KindRegistryJenny must be a core kind") - } - - newKindBytes, err := kindToBytes(core.Def().V) +func (j *kindregjenny) Generate(kind schemas.CoreKind) (*codejen.File, error) { + newKindBytes, err := kindToBytes(kind.CueFile) if err != nil { return nil, err } - path := filepath.Join(j.path, "next", "core", name, name+".cue") + path := filepath.Join(j.path, "next", "core", kind.Name, kind.Name+".cue") return codejen.NewFile(path, newKindBytes, j), nil } -// kindToBytes converts a kind cue value to a .cue file content -func kindToBytes(kind cue.Value) ([]byte, error) { - node := kind.Syntax( - cue.All(), - cue.Schema(), - cue.Docs(true), - ) - - return cueformat.Node(node) -} - // ComposableKindRegistryJenny generates kind files into the "next" folder of the local kind registry. -func ComposableKindRegistryJenny(path string) codejen.OneToOne[kindsys.Composable] { +func ComposableKindRegistryJenny(path string) codejen.OneToOne[schemas.ComposableKind] { return &ckrJenny{ path: path, } @@ -277,149 +157,73 @@ func (j *ckrJenny) JennyName() string { return "ComposableKindRegistryJenny" } -func (j *ckrJenny) Generate(k kindsys.Composable) (*codejen.File, error) { - si, err := kindsys.FindSchemaInterface(k.Def().Properties.SchemaInterface) - if err != nil { - panic(err) - } +func (j *ckrJenny) Generate(k schemas.ComposableKind) (*codejen.File, error) { + name := strings.ToLower(fmt.Sprintf("%s/%s", k.Name, k.Filename)) - name := strings.ToLower(fmt.Sprintf("%s/%s", strings.TrimSuffix(k.Lineage().Name(), si.Name()), si.Name())) + v := fixComposableKindFormat(k) - newKindBytes, err := kindToBytes(k.Def().V) + newKindBytes, err := kindToBytes(v) if err != nil { return nil, err } newKindBytes = []byte(fmt.Sprintf("package grafanaplugin\n\n%s", newKindBytes)) - return codejen.NewFile(filepath.Join(j.path, "next", "composable", name+".cue"), newKindBytes, j), nil -} - -type kindRegistry struct { - zipDir string - zipFile *zip.ReadCloser + return codejen.NewFile(filepath.Join(j.path, "next", "composable", name), newKindBytes, j), nil } -// NewKindRegistry downloads the archive of the kind-registry GH repository and open it -func NewKindRegistry() (*kindRegistry, error) { - ctx := context.Background() - tc := oauth2.NewClient(ctx, nil) - client := github.NewClient(tc) - - // Create a temporary file to store the downloaded archive - file, err := os.CreateTemp("", "*.zip") - if err != nil { - return nil, fmt.Errorf("failed to create temporary file: %w", err) - } - defer file.Close() - - // Get the repository archive URL - archiveURL, _, err := client.Repositories.GetArchiveLink(ctx, GITHUB_OWNER, GITHUB_REPO, github.Zipball, &github.RepositoryContentGetOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to get archive URL: %w", err) - } - - // Download the archive file - httpClient := http.DefaultClient - resp, err := httpClient.Get(archiveURL.String()) - if err != nil { - return nil, fmt.Errorf("failed to download archive: %w", err) - } - defer resp.Body.Close() - - // Save the downloaded archive to the temporary file - _, err = io.Copy(file, resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to save archive: %w", err) - } - - // Open the zip file for reading - zipDir := file.Name() - zipFile, err := zip.OpenReader(zipDir) - if err != nil { - return nil, fmt.Errorf("failed to open zip file %s: %w", zipDir, err) - } +// kindToBytes converts a kind cue value to a .cue file content +func kindToBytes(kind cue.Value) ([]byte, error) { + node := kind.Syntax( + cue.All(), + cue.Schema(), + cue.Docs(true), + ) - return &kindRegistry{ - zipDir: zipDir, - zipFile: zipFile, - }, nil + return cueformat.Node(node) } -// cleanUp removes the archive from the temporary files and closes the zip reader -func (registry *kindRegistry) cleanUp() { - if registry.zipDir != "" { - err := os.Remove(registry.zipDir) - if err != nil { - fmt.Fprint(os.Stderr, fmt.Errorf("failed to remove zip archive: %w", err)) - } +func fixComposableKindFormat(schema schemas.ComposableKind) cue.Value { + variant := "PanelCfg" + if schema.CueFile.LookupPath(cue.ParsePath("composableKinds.DataQuery")).Exists() { + variant = "DataQuery" } - if registry.zipFile != nil { - err := registry.zipFile.Close() - if err != nil { - fmt.Fprint(os.Stderr, fmt.Errorf("failed to close zip file reader: %w", err)) - } - } -} - -// findLatestDir get the latest version directory published in the kind registry -func (registry *kindRegistry) findLatestDir() (string, error) { - re := regexp.MustCompile(`([0-9]+)\.([0-9]+)\.([0-9]+)`) - latestVersion := []uint64{0, 0, 0} - latestDir := "" + newCue := schema.CueFile.Context().CompileString( + fmt.Sprintf("schemaInterface: %q\n", variant) + + fmt.Sprintf("name: %q + %q\n\n", UpperCamelCase(schema.Name), variant) + + "lineage: _", + ) - for _, file := range registry.zipFile.File { - if !file.FileInfo().IsDir() { - continue - } + lineagePath := cue.MakePath(cue.Str("composableKinds"), cue.Str(variant), cue.Str("lineage")) + return newCue.FillPath(cue.MakePath(cue.Str("lineage")), schema.CueFile.LookupPath(lineagePath)) +} - parts := re.FindStringSubmatch(file.Name) - if parts == nil || len(parts) < 4 { - continue - } +func UpperCamelCase(s string) string { + s = LowerCamelCase(s) - version := make([]uint64, len(parts)-1) - for i := 1; i < len(parts); i++ { - version[i-1], _ = strconv.ParseUint(parts[i], 10, 32) - } - - if isLess(latestVersion, version) { - latestVersion = version - latestDir = file.Name - } + // Uppercase the first letter + if len(s) > 0 { + s = strings.ToUpper(s[:1]) + s[1:] } - return latestDir, nil + return s } -// getPublishedKind retrieves the latest published kind from the kind registry -func (registry *kindRegistry) getPublishedKind(name string, category string, latestRegistryDir string) (string, error) { - if latestRegistryDir == "" { - return "", nil - } +func LowerCamelCase(s string) string { + // Replace all non-alphanumeric characters by spaces + s = nonAlphaNumRegex.ReplaceAllString(s, " ") - var cueFilePath string - switch category { - case "core": - cueFilePath = fmt.Sprintf("%s/%s.cue", name, name) - case "composable": - cueFilePath = fmt.Sprintf("%s.cue", name) - default: - return "", fmt.Errorf("kind can only be core or composable") - } + // Title case s + s = cases.Title(language.AmericanEnglish, cases.NoLower).String(s) - kindPath := filepath.Join(latestRegistryDir, category, cueFilePath) - file, err := registry.zipFile.Open(kindPath) - if err != nil { - return "", fmt.Errorf("failed to open file: %w", err) - } - defer file.Close() + // Remove all spaces + s = strings.ReplaceAll(s, " ", "") - data, err := io.ReadAll(file) - if err != nil { - return "", fmt.Errorf("failed to read file: %w", err) + // Lowercase the first letter + if len(s) > 0 { + s = strings.ToLower(s[:1]) + s[1:] } - return string(data), nil + return s } diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 1a452bb6a01ee..778a49b16d00f 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -4,6 +4,7 @@ on: - cron: '30 1 * * *' permissions: + issues: write pull-requests: write jobs: @@ -13,24 +14,29 @@ jobs: - uses: actions/stale@v9 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - # Number of days of inactivity before a stale Issue or Pull Request is closed. - # Set to -1 to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. - days-before-close: 14 - # Number of days of inactivity before an Issue or Pull Request becomes stale - days-before-stale: 30 - # We don't want any Issues to be marked as stale for now. - days-before-issue-stale: -1 - exempt-issue-labels: no stalebot - exempt-pr-labels: no stalebot - operations-per-run: 500 + operations-per-run: 750 + # start from the oldest issues/PRs when performing stale operations + ascending: true + days-before-issue-stale: 365 + days-before-issue-close: 30 stale-issue-label: stale + exempt-issue-labels: no stalebot,type/epic + stale-issue-message: > + This issue has been automatically marked as stale because it has not had + activity in the last year. It will be closed in 30 days if no further activity occurs. Please + feel free to leave a comment if you believe the issue is still relevant. + Thank you for your contributions! + close-issue-message: > + This issue has been automatically closed because it has not had any further + activity in the last 30 days. Thank you for your contributions! + days-before-pr-stale: 30 + days-before-pr-close: 14 stale-pr-label: stale + exempt-pr-labels: no stalebot stale-pr-message: > This pull request has been automatically marked as stale because it has not had activity in the last 30 days. It will be closed in 2 weeks if no further activity occurs. Please - feel free to give a status update now, ping for review, or re-open when it's ready. - Thank you for your contributions! + feel free to give a status update or ping for review. Thank you for your contributions! close-pr-message: > - This pull request has been automatically closed because it has not had - activity in the last 2 weeks. Please feel free to give a status update now, ping for review, or re-open when it's ready. - Thank you for your contributions! + This pull request has been automatically closed because it has not had any further + activity in the last 2 weeks. Thank you for your contributions! diff --git a/.github/workflows/sync-mirror.yml b/.github/workflows/sync-mirror.yml index fd8d93d62260c..09c8f87d50902 100644 --- a/.github/workflows/sync-mirror.yml +++ b/.github/workflows/sync-mirror.yml @@ -1,4 +1,4 @@ -# Owned by grafana-delivery-squad +# Owned by grafana-release-guild # Intended to be dropped into the base repo, Ex: grafana/grafana name: Sync to mirror run-name: sync-to-mirror-${{ github.ref_name }} diff --git a/.github/workflows/update-make-docs.yml b/.github/workflows/update-make-docs.yml index 09159af49e718..49b64504bd0db 100644 --- a/.github/workflows/update-make-docs.yml +++ b/.github/workflows/update-make-docs.yml @@ -12,8 +12,8 @@ jobs: - uses: grafana/writers-toolkit/update-make-docs@update-make-docs/v1 with: pr_options: > - --label 'backport v10.0.x' --label 'backport v10.1.x' --label 'backport v10.2.x' + --label 'backport v10.3.x' --label no-changelog --label type/docs diff --git a/.github/workflows/verify-kinds.yml b/.github/workflows/verify-kinds.yml index cda6ccbc679ed..e9acdccfe0c98 100644 --- a/.github/workflows/verify-kinds.yml +++ b/.github/workflows/verify-kinds.yml @@ -4,7 +4,7 @@ on: pull_request: branches: [ main ] paths: - - '**/*.cue' + - '**/*.cue' jobs: main: @@ -18,10 +18,9 @@ jobs: - name: "Setup Go" uses: "actions/setup-go@v4" with: - go-version: '1.21.5' + go-version: '1.21.8' - name: "Verify kinds" run: go run .github/workflows/scripts/kinds/verify-kinds.go env: - CODEGEN_VERIFY: 1 GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.gitignore b/.gitignore index 771a993d2a779..1ee9062d9283b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,9 +24,6 @@ __debug_bin* !.yarn/plugins !.yarn/sdks !.yarn/versions -# we temporarily commit this file because yarn downloading it -# somehow produces different checksum values -!.yarn/cache/pa11y-ci-https-1e9675e9e1-668c9119bd.zip .pnp.* # Enterprise emails @@ -134,9 +131,7 @@ pkg/services/quota/quotaimpl/storage/storage.json /devenv/bulk-dashboards/*.json /devenv/bulk-folders/*/*.json -/devenv/bulk_alerting_dashboards/*.json /devenv/datasources_bulk.yaml -/devenv/bulk_alerting_dashboards/bulk_alerting_datasources.yaml /scripts/build/release_publisher/release_publisher *.patch @@ -174,6 +169,11 @@ compilation-stats.json /e2e/build_results.zip /e2e/extensions /e2e/extensions-suite +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ # grafana server /scripts/grafana-server/server.log @@ -202,6 +202,7 @@ public/api-spec.json deployment_tools_config.json .betterer.cache +.nx # Temporary file for backporting PRs .pr-body.txt @@ -212,3 +213,4 @@ public/app/plugins/**/dist/ # Ignore transpiled JavaScript resulting from the generate-transformations.ts script. /public/app/features/transformers/docs/*.js /scripts/docs/generate-transformations.js + diff --git a/.golangci.toml b/.golangci.toml index 3fe5ae002aa00..b722776e84ebe 100644 --- a/.golangci.toml +++ b/.golangci.toml @@ -52,12 +52,23 @@ deny = [ { pkg = "github.com/grafana/grafana/pkg/server", desc = "Core plugins are not allowed to depend on Grafana core packages" }, { pkg = "github.com/grafana/grafana/pkg/tests", desc = "Core plugins are not allowed to depend on Grafana core packages" }, { pkg = "github.com/grafana/grafana/pkg/web", desc = "Core plugins are not allowed to depend on Grafana core packages" }, + { pkg = "github.com/grafana/grafana/pkg/tsdb/intervalv2", desc = "Core plugins are not allowed to depend on Grafana core packages" }, ] files = [ + "**/pkg/tsdb/grafana-pyroscope-datasource/*", + "**/pkg/tsdb/grafana-pyroscope-datasource/**/*", "**/pkg/tsdb/grafana-testdata-datasource/*", "**/pkg/tsdb/grafana-testdata-datasource/**/*", + "**/pkg/tsdb/azuremonitor/*", + "**/pkg/tsdb/azuremonitor/**/*", + "**/pkg/tsdb/cloud-monitoring/*", + "**/pkg/tsdb/cloud-monitoring/**/*", "**/pkg/tsdb/parca/*", "**/pkg/tsdb/parca/**/*", + "**/pkg/tsdb/tempo/*", + "**/pkg/tsdb/tempo/**/*", + "**/pkg/tsdb/cloudwatch/*", + "**/pkg/tsdb/cloudwatch/**/*", ] [linters-settings.gocritic] diff --git a/.prettierignore b/.prettierignore index 9f95507f7b307..160926fd718cc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,6 +12,7 @@ node_modules pkg public/lib/monaco public/sass/*.generated.scss +scripts/cli/bettererIssueTemplate.md scripts/grafana-server/tmp vendor @@ -31,10 +32,5 @@ public/api-merged.json public/api-enterprise-spec.json public/openapi3.json -# Generated Kinds report -kinds/report.json - -# Generated schema docs -docs/sources/developers/kinds/ - -scripts/cli/bettererIssueTemplate.md +# Crowdin files +public/locales/**/*.json diff --git a/.vscode/launch.json b/.vscode/launch.json index d2f9c3b15843e..0ff75cc92ac20 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,14 +12,16 @@ "args": ["server", "--homepath", "${workspaceFolder}", "--packaging", "dev"] }, { - "name": "Run API Server (k8s)", + "name": "Run API Server (testdata)", "type": "go", "request": "launch", "mode": "auto", "program": "${workspaceFolder}/pkg/cmd/grafana/", "env": {}, "cwd": "${workspaceFolder}", - "args": ["apiserver", "example.grafana.app"] + "args": ["apiserver", + "--secure-port=8443", + "--runtime-config=testdata.datasource.grafana.app/v0alpha1=true"] }, { "name": "Attach to Chrome", diff --git a/.yarn/cache/tether-drop-https-3382d2649f-178c3afb88.zip b/.yarn/cache/tether-drop-https-3382d2649f-178c3afb88.zip deleted file mode 100644 index be3f3c66ca98c5ee035086c783e83040ec05ae57..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 317732 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%0AGcd7w?8AXkmMg0 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%0AwmSiP33M?z8b857)XA~$Yd=5 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≫6e5g+yl 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%0D&iPep-OiNwtz@Q(ywmU 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%0DxnCv94FS3E*y%o)!lqI__ymcE(9)P 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φwljaCs5nyz`ZJn`$o0;>MD|UDT59=Og{rrFd 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 diff --git a/.yarn/releases/yarn-4.0.0.cjs b/.yarn/releases/yarn-4.0.0.cjs deleted file mode 100755 index b570710a23f9a..0000000000000 --- a/.yarn/releases/yarn-4.0.0.cjs +++ /dev/null @@ -1,893 +0,0 @@ -#!/usr/bin/env node -/* eslint-disable */ -//prettier-ignore -(()=>{var t_e=Object.create;var LR=Object.defineProperty;var r_e=Object.getOwnPropertyDescriptor;var n_e=Object.getOwnPropertyNames;var i_e=Object.getPrototypeOf,s_e=Object.prototype.hasOwnProperty;var Be=(t=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(t,{get:(e,r)=>(typeof require<"u"?require:e)[r]}):t)(function(t){if(typeof require<"u")return require.apply(this,arguments);throw new Error('Dynamic require of "'+t+'" is not supported')});var yt=(t,e)=>()=>(t&&(e=t(t=0)),e);var _=(t,e)=>()=>(e||t((e={exports:{}}).exports,e),e.exports),Vt=(t,e)=>{for(var r in e)LR(t,r,{get:e[r],enumerable:!0})},o_e=(t,e,r,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let a of n_e(e))!s_e.call(t,a)&&a!==r&&LR(t,a,{get:()=>e[a],enumerable:!(o=r_e(e,a))||o.enumerable});return t};var $e=(t,e,r)=>(r=t!=null?t_e(i_e(t)):{},o_e(e||!t||!t.__esModule?LR(r,"default",{value:t,enumerable:!0}):r,t));var vi={};Vt(vi,{SAFE_TIME:()=>Q7,S_IFDIR:()=>wD,S_IFLNK:()=>ID,S_IFMT:()=>Ou,S_IFREG:()=>Hw});var Ou,wD,Hw,ID,Q7,F7=yt(()=>{Ou=61440,wD=16384,Hw=32768,ID=40960,Q7=456789e3});var ar={};Vt(ar,{EBADF:()=>Io,EBUSY:()=>a_e,EEXIST:()=>p_e,EINVAL:()=>c_e,EISDIR:()=>f_e,ENOENT:()=>u_e,ENOSYS:()=>l_e,ENOTDIR:()=>A_e,ENOTEMPTY:()=>g_e,EOPNOTSUPP:()=>d_e,EROFS:()=>h_e,ERR_DIR_CLOSED:()=>NR});function Tl(t,e){return Object.assign(new Error(`${t}: ${e}`),{code:t})}function a_e(t){return Tl("EBUSY",t)}function l_e(t,e){return Tl("ENOSYS",`${t}, ${e}`)}function c_e(t){return Tl("EINVAL",`invalid argument, ${t}`)}function Io(t){return Tl("EBADF",`bad file descriptor, ${t}`)}function u_e(t){return Tl("ENOENT",`no such file or directory, ${t}`)}function A_e(t){return Tl("ENOTDIR",`not a directory, ${t}`)}function f_e(t){return Tl("EISDIR",`illegal operation on a directory, ${t}`)}function p_e(t){return Tl("EEXIST",`file already exists, ${t}`)}function h_e(t){return Tl("EROFS",`read-only filesystem, ${t}`)}function g_e(t){return Tl("ENOTEMPTY",`directory not empty, ${t}`)}function d_e(t){return Tl("EOPNOTSUPP",`operation not supported, ${t}`)}function NR(){return Tl("ERR_DIR_CLOSED","Directory handle was closed")}var BD=yt(()=>{});var Ea={};Vt(Ea,{BigIntStatsEntry:()=>ey,DEFAULT_MODE:()=>UR,DirEntry:()=>OR,StatEntry:()=>$m,areStatsEqual:()=>_R,clearStats:()=>vD,convertToBigIntStats:()=>y_e,makeDefaultStats:()=>R7,makeEmptyStats:()=>m_e});function R7(){return new $m}function m_e(){return vD(R7())}function vD(t){for(let e in t)if(Object.hasOwn(t,e)){let r=t[e];typeof r=="number"?t[e]=0:typeof r=="bigint"?t[e]=BigInt(0):MR.types.isDate(r)&&(t[e]=new Date(0))}return t}function y_e(t){let e=new ey;for(let r in t)if(Object.hasOwn(t,r)){let o=t[r];typeof o=="number"?e[r]=BigInt(o):MR.types.isDate(o)&&(e[r]=new Date(o))}return e.atimeNs=e.atimeMs*BigInt(1e6),e.mtimeNs=e.mtimeMs*BigInt(1e6),e.ctimeNs=e.ctimeMs*BigInt(1e6),e.birthtimeNs=e.birthtimeMs*BigInt(1e6),e}function _R(t,e){if(t.atimeMs!==e.atimeMs||t.birthtimeMs!==e.birthtimeMs||t.blksize!==e.blksize||t.blocks!==e.blocks||t.ctimeMs!==e.ctimeMs||t.dev!==e.dev||t.gid!==e.gid||t.ino!==e.ino||t.isBlockDevice()!==e.isBlockDevice()||t.isCharacterDevice()!==e.isCharacterDevice()||t.isDirectory()!==e.isDirectory()||t.isFIFO()!==e.isFIFO()||t.isFile()!==e.isFile()||t.isSocket()!==e.isSocket()||t.isSymbolicLink()!==e.isSymbolicLink()||t.mode!==e.mode||t.mtimeMs!==e.mtimeMs||t.nlink!==e.nlink||t.rdev!==e.rdev||t.size!==e.size||t.uid!==e.uid)return!1;let r=t,o=e;return!(r.atimeNs!==o.atimeNs||r.mtimeNs!==o.mtimeNs||r.ctimeNs!==o.ctimeNs||r.birthtimeNs!==o.birthtimeNs)}var MR,UR,OR,$m,ey,HR=yt(()=>{MR=$e(Be("util")),UR=33188,OR=class{constructor(){this.name="";this.path="";this.mode=0}isBlockDevice(){return!1}isCharacterDevice(){return!1}isDirectory(){return(this.mode&61440)===16384}isFIFO(){return!1}isFile(){return(this.mode&61440)===32768}isSocket(){return!1}isSymbolicLink(){return(this.mode&61440)===40960}},$m=class{constructor(){this.uid=0;this.gid=0;this.size=0;this.blksize=0;this.atimeMs=0;this.mtimeMs=0;this.ctimeMs=0;this.birthtimeMs=0;this.atime=new Date(0);this.mtime=new Date(0);this.ctime=new Date(0);this.birthtime=new Date(0);this.dev=0;this.ino=0;this.mode=UR;this.nlink=1;this.rdev=0;this.blocks=1}isBlockDevice(){return!1}isCharacterDevice(){return!1}isDirectory(){return(this.mode&61440)===16384}isFIFO(){return!1}isFile(){return(this.mode&61440)===32768}isSocket(){return!1}isSymbolicLink(){return(this.mode&61440)===40960}},ey=class{constructor(){this.uid=BigInt(0);this.gid=BigInt(0);this.size=BigInt(0);this.blksize=BigInt(0);this.atimeMs=BigInt(0);this.mtimeMs=BigInt(0);this.ctimeMs=BigInt(0);this.birthtimeMs=BigInt(0);this.atimeNs=BigInt(0);this.mtimeNs=BigInt(0);this.ctimeNs=BigInt(0);this.birthtimeNs=BigInt(0);this.atime=new Date(0);this.mtime=new Date(0);this.ctime=new Date(0);this.birthtime=new Date(0);this.dev=BigInt(0);this.ino=BigInt(0);this.mode=BigInt(UR);this.nlink=BigInt(1);this.rdev=BigInt(0);this.blocks=BigInt(1)}isBlockDevice(){return!1}isCharacterDevice(){return!1}isDirectory(){return(this.mode&BigInt(61440))===BigInt(16384)}isFIFO(){return!1}isFile(){return(this.mode&BigInt(61440))===BigInt(32768)}isSocket(){return!1}isSymbolicLink(){return(this.mode&BigInt(61440))===BigInt(40960)}}});function B_e(t){let e,r;if(e=t.match(w_e))t=e[1];else if(r=t.match(I_e))t=`\\\\${r[1]?".\\":""}${r[2]}`;else return t;return t.replace(/\//g,"\\")}function v_e(t){t=t.replace(/\\/g,"/");let e,r;return(e=t.match(E_e))?t=`/${e[1]}`:(r=t.match(C_e))&&(t=`/unc/${r[1]?".dot/":""}${r[2]}`),t}function DD(t,e){return t===fe?L7(e):jR(e)}var jw,Bt,dr,fe,V,T7,E_e,C_e,w_e,I_e,jR,L7,Ca=yt(()=>{jw=$e(Be("path")),Bt={root:"/",dot:".",parent:".."},dr={home:"~",nodeModules:"node_modules",manifest:"package.json",lockfile:"yarn.lock",virtual:"__virtual__",pnpJs:".pnp.js",pnpCjs:".pnp.cjs",pnpData:".pnp.data.json",pnpEsmLoader:".pnp.loader.mjs",rc:".yarnrc.yml",env:".env"},fe=Object.create(jw.default),V=Object.create(jw.default.posix);fe.cwd=()=>process.cwd();V.cwd=process.platform==="win32"?()=>jR(process.cwd()):process.cwd;process.platform==="win32"&&(V.resolve=(...t)=>t.length>0&&V.isAbsolute(t[0])?jw.default.posix.resolve(...t):jw.default.posix.resolve(V.cwd(),...t));T7=function(t,e,r){return e=t.normalize(e),r=t.normalize(r),e===r?".":(e.endsWith(t.sep)||(e=e+t.sep),r.startsWith(e)?r.slice(e.length):null)};fe.contains=(t,e)=>T7(fe,t,e);V.contains=(t,e)=>T7(V,t,e);E_e=/^([a-zA-Z]:.*)$/,C_e=/^\/\/(\.\/)?(.*)$/,w_e=/^\/([a-zA-Z]:.*)$/,I_e=/^\/unc\/(\.dot\/)?(.*)$/;jR=process.platform==="win32"?v_e:t=>t,L7=process.platform==="win32"?B_e:t=>t;fe.fromPortablePath=L7;fe.toPortablePath=jR});async function PD(t,e){let r="0123456789abcdef";await t.mkdirPromise(e.indexPath,{recursive:!0});let o=[];for(let a of r)for(let n of r)o.push(t.mkdirPromise(t.pathUtils.join(e.indexPath,`${a}${n}`),{recursive:!0}));return await Promise.all(o),e.indexPath}async function N7(t,e,r,o,a){let n=t.pathUtils.normalize(e),u=r.pathUtils.normalize(o),A=[],p=[],{atime:h,mtime:C}=a.stableTime?{atime:Lg,mtime:Lg}:await r.lstatPromise(u);await t.mkdirpPromise(t.pathUtils.dirname(e),{utimes:[h,C]}),await qR(A,p,t,n,r,u,{...a,didParentExist:!0});for(let I of A)await I();await Promise.all(p.map(I=>I()))}async function qR(t,e,r,o,a,n,u){let A=u.didParentExist?await O7(r,o):null,p=await a.lstatPromise(n),{atime:h,mtime:C}=u.stableTime?{atime:Lg,mtime:Lg}:p,I;switch(!0){case p.isDirectory():I=await P_e(t,e,r,o,A,a,n,p,u);break;case p.isFile():I=await b_e(t,e,r,o,A,a,n,p,u);break;case p.isSymbolicLink():I=await k_e(t,e,r,o,A,a,n,p,u);break;default:throw new Error(`Unsupported file type (${p.mode})`)}return(u.linkStrategy?.type!=="HardlinkFromIndex"||!p.isFile())&&((I||A?.mtime?.getTime()!==C.getTime()||A?.atime?.getTime()!==h.getTime())&&(e.push(()=>r.lutimesPromise(o,h,C)),I=!0),(A===null||(A.mode&511)!==(p.mode&511))&&(e.push(()=>r.chmodPromise(o,p.mode&511)),I=!0)),I}async function O7(t,e){try{return await t.lstatPromise(e)}catch{return null}}async function P_e(t,e,r,o,a,n,u,A,p){if(a!==null&&!a.isDirectory())if(p.overwrite)t.push(async()=>r.removePromise(o)),a=null;else return!1;let h=!1;a===null&&(t.push(async()=>{try{await r.mkdirPromise(o,{mode:A.mode})}catch(v){if(v.code!=="EEXIST")throw v}}),h=!0);let C=await n.readdirPromise(u),I=p.didParentExist&&!a?{...p,didParentExist:!1}:p;if(p.stableSort)for(let v of C.sort())await qR(t,e,r,r.pathUtils.join(o,v),n,n.pathUtils.join(u,v),I)&&(h=!0);else(await Promise.all(C.map(async b=>{await qR(t,e,r,r.pathUtils.join(o,b),n,n.pathUtils.join(u,b),I)}))).some(b=>b)&&(h=!0);return h}async function S_e(t,e,r,o,a,n,u,A,p,h){let C=await n.checksumFilePromise(u,{algorithm:"sha1"}),I=r.pathUtils.join(h.indexPath,C.slice(0,2),`${C}.dat`),v;(te=>(te[te.Lock=0]="Lock",te[te.Rename=1]="Rename"))(v||={});let b=1,E=await O7(r,I);if(a){let U=E&&a.dev===E.dev&&a.ino===E.ino,z=E?.mtimeMs!==D_e;if(U&&z&&h.autoRepair&&(b=0,E=null),!U)if(p.overwrite)t.push(async()=>r.removePromise(o)),a=null;else return!1}let F=!E&&b===1?`${I}.${Math.floor(Math.random()*4294967296).toString(16).padStart(8,"0")}`:null,N=!1;return t.push(async()=>{if(!E&&(b===0&&await r.lockPromise(I,async()=>{let U=await n.readFilePromise(u);await r.writeFilePromise(I,U)}),b===1&&F)){let U=await n.readFilePromise(u);await r.writeFilePromise(F,U);try{await r.linkPromise(F,I)}catch(z){if(z.code==="EEXIST")N=!0,await r.unlinkPromise(F);else throw z}}a||await r.linkPromise(I,o)}),e.push(async()=>{E||await r.lutimesPromise(I,Lg,Lg),F&&!N&&await r.unlinkPromise(F)}),!1}async function x_e(t,e,r,o,a,n,u,A,p){if(a!==null)if(p.overwrite)t.push(async()=>r.removePromise(o)),a=null;else return!1;return t.push(async()=>{let h=await n.readFilePromise(u);await r.writeFilePromise(o,h)}),!0}async function b_e(t,e,r,o,a,n,u,A,p){return p.linkStrategy?.type==="HardlinkFromIndex"?S_e(t,e,r,o,a,n,u,A,p,p.linkStrategy):x_e(t,e,r,o,a,n,u,A,p)}async function k_e(t,e,r,o,a,n,u,A,p){if(a!==null)if(p.overwrite)t.push(async()=>r.removePromise(o)),a=null;else return!1;return t.push(async()=>{await r.symlinkPromise(DD(r.pathUtils,await n.readlinkPromise(u)),o)}),!0}var Lg,D_e,GR=yt(()=>{Ca();Lg=new Date(456789e3*1e3),D_e=Lg.getTime()});function SD(t,e,r,o){let a=()=>{let n=r.shift();if(typeof n>"u")return null;let u=t.pathUtils.join(e,n);return Object.assign(t.statSync(u),{name:n,path:void 0})};return new qw(e,a,o)}var qw,M7=yt(()=>{BD();qw=class{constructor(e,r,o={}){this.path=e;this.nextDirent=r;this.opts=o;this.closed=!1}throwIfClosed(){if(this.closed)throw NR()}async*[Symbol.asyncIterator](){try{let e;for(;(e=await this.read())!==null;)yield e}finally{await this.close()}}read(e){let r=this.readSync();return typeof e<"u"?e(null,r):Promise.resolve(r)}readSync(){return this.throwIfClosed(),this.nextDirent()}close(e){return this.closeSync(),typeof e<"u"?e(null):Promise.resolve()}closeSync(){this.throwIfClosed(),this.opts.onClose?.(),this.closed=!0}}});function U7(t,e){if(t!==e)throw new Error(`Invalid StatWatcher status: expected '${e}', got '${t}'`)}var _7,ty,H7=yt(()=>{_7=Be("events");HR();ty=class extends _7.EventEmitter{constructor(r,o,{bigint:a=!1}={}){super();this.status="ready";this.changeListeners=new Map;this.startTimeout=null;this.fakeFs=r,this.path=o,this.bigint=a,this.lastStats=this.stat()}static create(r,o,a){let n=new ty(r,o,a);return n.start(),n}start(){U7(this.status,"ready"),this.status="running",this.startTimeout=setTimeout(()=>{this.startTimeout=null,this.fakeFs.existsSync(this.path)||this.emit("change",this.lastStats,this.lastStats)},3)}stop(){U7(this.status,"running"),this.status="stopped",this.startTimeout!==null&&(clearTimeout(this.startTimeout),this.startTimeout=null),this.emit("stop")}stat(){try{return this.fakeFs.statSync(this.path,{bigint:this.bigint})}catch{let o=this.bigint?new ey:new $m;return vD(o)}}makeInterval(r){let o=setInterval(()=>{let a=this.stat(),n=this.lastStats;_R(a,n)||(this.lastStats=a,this.emit("change",a,n))},r.interval);return r.persistent?o:o.unref()}registerChangeListener(r,o){this.addListener("change",r),this.changeListeners.set(r,this.makeInterval(o))}unregisterChangeListener(r){this.removeListener("change",r);let o=this.changeListeners.get(r);typeof o<"u"&&clearInterval(o),this.changeListeners.delete(r)}unregisterAllChangeListeners(){for(let r of this.changeListeners.keys())this.unregisterChangeListener(r)}hasChangeListeners(){return this.changeListeners.size>0}ref(){for(let r of this.changeListeners.values())r.ref();return this}unref(){for(let r of this.changeListeners.values())r.unref();return this}}});function ry(t,e,r,o){let a,n,u,A;switch(typeof r){case"function":a=!1,n=!0,u=5007,A=r;break;default:({bigint:a=!1,persistent:n=!0,interval:u=5007}=r),A=o;break}let p=xD.get(t);typeof p>"u"&&xD.set(t,p=new Map);let h=p.get(e);return typeof h>"u"&&(h=ty.create(t,e,{bigint:a}),p.set(e,h)),h.registerChangeListener(A,{persistent:n,interval:u}),h}function Ng(t,e,r){let o=xD.get(t);if(typeof o>"u")return;let a=o.get(e);typeof a>"u"||(typeof r>"u"?a.unregisterAllChangeListeners():a.unregisterChangeListener(r),a.hasChangeListeners()||(a.stop(),o.delete(e)))}function Og(t){let e=xD.get(t);if(!(typeof e>"u"))for(let r of e.keys())Ng(t,r)}var xD,YR=yt(()=>{H7();xD=new WeakMap});function Q_e(t){let e=t.match(/\r?\n/g);if(e===null)return q7.EOL;let r=e.filter(a=>a===`\r -`).length,o=e.length-r;return r>o?`\r -`:` -`}function Mg(t,e){return e.replace(/\r?\n/g,Q_e(t))}var j7,q7,hf,Mu,Ug=yt(()=>{j7=Be("crypto"),q7=Be("os");GR();Ca();hf=class{constructor(e){this.pathUtils=e}async*genTraversePromise(e,{stableSort:r=!1}={}){let o=[e];for(;o.length>0;){let a=o.shift();if((await this.lstatPromise(a)).isDirectory()){let u=await this.readdirPromise(a);if(r)for(let A of u.sort())o.push(this.pathUtils.join(a,A));else throw new Error("Not supported")}else yield a}}async checksumFilePromise(e,{algorithm:r="sha512"}={}){let o=await this.openPromise(e,"r");try{let n=Buffer.allocUnsafeSlow(65536),u=(0,j7.createHash)(r),A=0;for(;(A=await this.readPromise(o,n,0,65536))!==0;)u.update(A===65536?n:n.slice(0,A));return u.digest("hex")}finally{await this.closePromise(o)}}async removePromise(e,{recursive:r=!0,maxRetries:o=5}={}){let a;try{a=await this.lstatPromise(e)}catch(n){if(n.code==="ENOENT")return;throw n}if(a.isDirectory()){if(r){let n=await this.readdirPromise(e);await Promise.all(n.map(u=>this.removePromise(this.pathUtils.resolve(e,u))))}for(let n=0;n<=o;n++)try{await this.rmdirPromise(e);break}catch(u){if(u.code!=="EBUSY"&&u.code!=="ENOTEMPTY")throw u;n<o&&await new Promise(A=>setTimeout(A,n*100))}}else await this.unlinkPromise(e)}removeSync(e,{recursive:r=!0}={}){let o;try{o=this.lstatSync(e)}catch(a){if(a.code==="ENOENT")return;throw a}if(o.isDirectory()){if(r)for(let a of this.readdirSync(e))this.removeSync(this.pathUtils.resolve(e,a));this.rmdirSync(e)}else this.unlinkSync(e)}async mkdirpPromise(e,{chmod:r,utimes:o}={}){if(e=this.resolve(e),e===this.pathUtils.dirname(e))return;let a=e.split(this.pathUtils.sep),n;for(let u=2;u<=a.length;++u){let A=a.slice(0,u).join(this.pathUtils.sep);if(!this.existsSync(A)){try{await this.mkdirPromise(A)}catch(p){if(p.code==="EEXIST")continue;throw p}if(n??=A,r!=null&&await this.chmodPromise(A,r),o!=null)await this.utimesPromise(A,o[0],o[1]);else{let p=await this.statPromise(this.pathUtils.dirname(A));await this.utimesPromise(A,p.atime,p.mtime)}}}return n}mkdirpSync(e,{chmod:r,utimes:o}={}){if(e=this.resolve(e),e===this.pathUtils.dirname(e))return;let a=e.split(this.pathUtils.sep),n;for(let u=2;u<=a.length;++u){let A=a.slice(0,u).join(this.pathUtils.sep);if(!this.existsSync(A)){try{this.mkdirSync(A)}catch(p){if(p.code==="EEXIST")continue;throw p}if(n??=A,r!=null&&this.chmodSync(A,r),o!=null)this.utimesSync(A,o[0],o[1]);else{let p=this.statSync(this.pathUtils.dirname(A));this.utimesSync(A,p.atime,p.mtime)}}}return n}async copyPromise(e,r,{baseFs:o=this,overwrite:a=!0,stableSort:n=!1,stableTime:u=!1,linkStrategy:A=null}={}){return await N7(this,e,o,r,{overwrite:a,stableSort:n,stableTime:u,linkStrategy:A})}copySync(e,r,{baseFs:o=this,overwrite:a=!0}={}){let n=o.lstatSync(r),u=this.existsSync(e);if(n.isDirectory()){this.mkdirpSync(e);let p=o.readdirSync(r);for(let h of p)this.copySync(this.pathUtils.join(e,h),o.pathUtils.join(r,h),{baseFs:o,overwrite:a})}else if(n.isFile()){if(!u||a){u&&this.removeSync(e);let p=o.readFileSync(r);this.writeFileSync(e,p)}}else if(n.isSymbolicLink()){if(!u||a){u&&this.removeSync(e);let p=o.readlinkSync(r);this.symlinkSync(DD(this.pathUtils,p),e)}}else throw new Error(`Unsupported file type (file: ${r}, mode: 0o${n.mode.toString(8).padStart(6,"0")})`);let A=n.mode&511;this.chmodSync(e,A)}async changeFilePromise(e,r,o={}){return Buffer.isBuffer(r)?this.changeFileBufferPromise(e,r,o):this.changeFileTextPromise(e,r,o)}async changeFileBufferPromise(e,r,{mode:o}={}){let a=Buffer.alloc(0);try{a=await this.readFilePromise(e)}catch{}Buffer.compare(a,r)!==0&&await this.writeFilePromise(e,r,{mode:o})}async changeFileTextPromise(e,r,{automaticNewlines:o,mode:a}={}){let n="";try{n=await this.readFilePromise(e,"utf8")}catch{}let u=o?Mg(n,r):r;n!==u&&await this.writeFilePromise(e,u,{mode:a})}changeFileSync(e,r,o={}){return Buffer.isBuffer(r)?this.changeFileBufferSync(e,r,o):this.changeFileTextSync(e,r,o)}changeFileBufferSync(e,r,{mode:o}={}){let a=Buffer.alloc(0);try{a=this.readFileSync(e)}catch{}Buffer.compare(a,r)!==0&&this.writeFileSync(e,r,{mode:o})}changeFileTextSync(e,r,{automaticNewlines:o=!1,mode:a}={}){let n="";try{n=this.readFileSync(e,"utf8")}catch{}let u=o?Mg(n,r):r;n!==u&&this.writeFileSync(e,u,{mode:a})}async movePromise(e,r){try{await this.renamePromise(e,r)}catch(o){if(o.code==="EXDEV")await this.copyPromise(r,e),await this.removePromise(e);else throw o}}moveSync(e,r){try{this.renameSync(e,r)}catch(o){if(o.code==="EXDEV")this.copySync(r,e),this.removeSync(e);else throw o}}async lockPromise(e,r){let o=`${e}.flock`,a=1e3/60,n=Date.now(),u=null,A=async()=>{let p;try{[p]=await this.readJsonPromise(o)}catch{return Date.now()-n<500}try{return process.kill(p,0),!0}catch{return!1}};for(;u===null;)try{u=await this.openPromise(o,"wx")}catch(p){if(p.code==="EEXIST"){if(!await A())try{await this.unlinkPromise(o);continue}catch{}if(Date.now()-n<60*1e3)await new Promise(h=>setTimeout(h,a));else throw new Error(`Couldn't acquire a lock in a reasonable time (via ${o})`)}else throw p}await this.writePromise(u,JSON.stringify([process.pid]));try{return await r()}finally{try{await this.closePromise(u),await this.unlinkPromise(o)}catch{}}}async readJsonPromise(e){let r=await this.readFilePromise(e,"utf8");try{return JSON.parse(r)}catch(o){throw o.message+=` (in ${e})`,o}}readJsonSync(e){let r=this.readFileSync(e,"utf8");try{return JSON.parse(r)}catch(o){throw o.message+=` (in ${e})`,o}}async writeJsonPromise(e,r,{compact:o=!1}={}){let a=o?0:2;return await this.writeFilePromise(e,`${JSON.stringify(r,null,a)} -`)}writeJsonSync(e,r,{compact:o=!1}={}){let a=o?0:2;return this.writeFileSync(e,`${JSON.stringify(r,null,a)} -`)}async preserveTimePromise(e,r){let o=await this.lstatPromise(e),a=await r();typeof a<"u"&&(e=a),await this.lutimesPromise(e,o.atime,o.mtime)}async preserveTimeSync(e,r){let o=this.lstatSync(e),a=r();typeof a<"u"&&(e=a),this.lutimesSync(e,o.atime,o.mtime)}},Mu=class extends hf{constructor(){super(V)}}});var Ps,gf=yt(()=>{Ug();Ps=class extends hf{getExtractHint(e){return this.baseFs.getExtractHint(e)}resolve(e){return this.mapFromBase(this.baseFs.resolve(this.mapToBase(e)))}getRealPath(){return this.mapFromBase(this.baseFs.getRealPath())}async openPromise(e,r,o){return this.baseFs.openPromise(this.mapToBase(e),r,o)}openSync(e,r,o){return this.baseFs.openSync(this.mapToBase(e),r,o)}async opendirPromise(e,r){return Object.assign(await this.baseFs.opendirPromise(this.mapToBase(e),r),{path:e})}opendirSync(e,r){return Object.assign(this.baseFs.opendirSync(this.mapToBase(e),r),{path:e})}async readPromise(e,r,o,a,n){return await this.baseFs.readPromise(e,r,o,a,n)}readSync(e,r,o,a,n){return this.baseFs.readSync(e,r,o,a,n)}async writePromise(e,r,o,a,n){return typeof r=="string"?await this.baseFs.writePromise(e,r,o):await this.baseFs.writePromise(e,r,o,a,n)}writeSync(e,r,o,a,n){return typeof r=="string"?this.baseFs.writeSync(e,r,o):this.baseFs.writeSync(e,r,o,a,n)}async closePromise(e){return this.baseFs.closePromise(e)}closeSync(e){this.baseFs.closeSync(e)}createReadStream(e,r){return this.baseFs.createReadStream(e!==null?this.mapToBase(e):e,r)}createWriteStream(e,r){return this.baseFs.createWriteStream(e!==null?this.mapToBase(e):e,r)}async realpathPromise(e){return this.mapFromBase(await this.baseFs.realpathPromise(this.mapToBase(e)))}realpathSync(e){return this.mapFromBase(this.baseFs.realpathSync(this.mapToBase(e)))}async existsPromise(e){return this.baseFs.existsPromise(this.mapToBase(e))}existsSync(e){return this.baseFs.existsSync(this.mapToBase(e))}accessSync(e,r){return this.baseFs.accessSync(this.mapToBase(e),r)}async accessPromise(e,r){return this.baseFs.accessPromise(this.mapToBase(e),r)}async statPromise(e,r){return this.baseFs.statPromise(this.mapToBase(e),r)}statSync(e,r){return this.baseFs.statSync(this.mapToBase(e),r)}async fstatPromise(e,r){return this.baseFs.fstatPromise(e,r)}fstatSync(e,r){return this.baseFs.fstatSync(e,r)}lstatPromise(e,r){return this.baseFs.lstatPromise(this.mapToBase(e),r)}lstatSync(e,r){return this.baseFs.lstatSync(this.mapToBase(e),r)}async fchmodPromise(e,r){return this.baseFs.fchmodPromise(e,r)}fchmodSync(e,r){return this.baseFs.fchmodSync(e,r)}async chmodPromise(e,r){return this.baseFs.chmodPromise(this.mapToBase(e),r)}chmodSync(e,r){return this.baseFs.chmodSync(this.mapToBase(e),r)}async fchownPromise(e,r,o){return this.baseFs.fchownPromise(e,r,o)}fchownSync(e,r,o){return this.baseFs.fchownSync(e,r,o)}async chownPromise(e,r,o){return this.baseFs.chownPromise(this.mapToBase(e),r,o)}chownSync(e,r,o){return this.baseFs.chownSync(this.mapToBase(e),r,o)}async renamePromise(e,r){return this.baseFs.renamePromise(this.mapToBase(e),this.mapToBase(r))}renameSync(e,r){return this.baseFs.renameSync(this.mapToBase(e),this.mapToBase(r))}async copyFilePromise(e,r,o=0){return this.baseFs.copyFilePromise(this.mapToBase(e),this.mapToBase(r),o)}copyFileSync(e,r,o=0){return this.baseFs.copyFileSync(this.mapToBase(e),this.mapToBase(r),o)}async appendFilePromise(e,r,o){return this.baseFs.appendFilePromise(this.fsMapToBase(e),r,o)}appendFileSync(e,r,o){return this.baseFs.appendFileSync(this.fsMapToBase(e),r,o)}async writeFilePromise(e,r,o){return this.baseFs.writeFilePromise(this.fsMapToBase(e),r,o)}writeFileSync(e,r,o){return this.baseFs.writeFileSync(this.fsMapToBase(e),r,o)}async unlinkPromise(e){return this.baseFs.unlinkPromise(this.mapToBase(e))}unlinkSync(e){return this.baseFs.unlinkSync(this.mapToBase(e))}async utimesPromise(e,r,o){return this.baseFs.utimesPromise(this.mapToBase(e),r,o)}utimesSync(e,r,o){return this.baseFs.utimesSync(this.mapToBase(e),r,o)}async lutimesPromise(e,r,o){return this.baseFs.lutimesPromise(this.mapToBase(e),r,o)}lutimesSync(e,r,o){return this.baseFs.lutimesSync(this.mapToBase(e),r,o)}async mkdirPromise(e,r){return this.baseFs.mkdirPromise(this.mapToBase(e),r)}mkdirSync(e,r){return this.baseFs.mkdirSync(this.mapToBase(e),r)}async rmdirPromise(e,r){return this.baseFs.rmdirPromise(this.mapToBase(e),r)}rmdirSync(e,r){return this.baseFs.rmdirSync(this.mapToBase(e),r)}async linkPromise(e,r){return this.baseFs.linkPromise(this.mapToBase(e),this.mapToBase(r))}linkSync(e,r){return this.baseFs.linkSync(this.mapToBase(e),this.mapToBase(r))}async symlinkPromise(e,r,o){let a=this.mapToBase(r);if(this.pathUtils.isAbsolute(e))return this.baseFs.symlinkPromise(this.mapToBase(e),a,o);let n=this.mapToBase(this.pathUtils.join(this.pathUtils.dirname(r),e)),u=this.baseFs.pathUtils.relative(this.baseFs.pathUtils.dirname(a),n);return this.baseFs.symlinkPromise(u,a,o)}symlinkSync(e,r,o){let a=this.mapToBase(r);if(this.pathUtils.isAbsolute(e))return this.baseFs.symlinkSync(this.mapToBase(e),a,o);let n=this.mapToBase(this.pathUtils.join(this.pathUtils.dirname(r),e)),u=this.baseFs.pathUtils.relative(this.baseFs.pathUtils.dirname(a),n);return this.baseFs.symlinkSync(u,a,o)}async readFilePromise(e,r){return this.baseFs.readFilePromise(this.fsMapToBase(e),r)}readFileSync(e,r){return this.baseFs.readFileSync(this.fsMapToBase(e),r)}readdirPromise(e,r){return this.baseFs.readdirPromise(this.mapToBase(e),r)}readdirSync(e,r){return this.baseFs.readdirSync(this.mapToBase(e),r)}async readlinkPromise(e){return this.mapFromBase(await this.baseFs.readlinkPromise(this.mapToBase(e)))}readlinkSync(e){return this.mapFromBase(this.baseFs.readlinkSync(this.mapToBase(e)))}async truncatePromise(e,r){return this.baseFs.truncatePromise(this.mapToBase(e),r)}truncateSync(e,r){return this.baseFs.truncateSync(this.mapToBase(e),r)}async ftruncatePromise(e,r){return this.baseFs.ftruncatePromise(e,r)}ftruncateSync(e,r){return this.baseFs.ftruncateSync(e,r)}watch(e,r,o){return this.baseFs.watch(this.mapToBase(e),r,o)}watchFile(e,r,o){return this.baseFs.watchFile(this.mapToBase(e),r,o)}unwatchFile(e,r){return this.baseFs.unwatchFile(this.mapToBase(e),r)}fsMapToBase(e){return typeof e=="number"?e:this.mapToBase(e)}}});var Uu,G7=yt(()=>{gf();Uu=class extends Ps{constructor(r,{baseFs:o,pathUtils:a}){super(a);this.target=r,this.baseFs=o}getRealPath(){return this.target}getBaseFs(){return this.baseFs}mapFromBase(r){return r}mapToBase(r){return r}}});var Y7,Tn,_g=yt(()=>{Y7=$e(Be("fs"));Ug();Ca();Tn=class extends Mu{constructor(r=Y7.default){super();this.realFs=r}getExtractHint(){return!1}getRealPath(){return Bt.root}resolve(r){return V.resolve(r)}async openPromise(r,o,a){return await new Promise((n,u)=>{this.realFs.open(fe.fromPortablePath(r),o,a,this.makeCallback(n,u))})}openSync(r,o,a){return this.realFs.openSync(fe.fromPortablePath(r),o,a)}async opendirPromise(r,o){return await new Promise((a,n)=>{typeof o<"u"?this.realFs.opendir(fe.fromPortablePath(r),o,this.makeCallback(a,n)):this.realFs.opendir(fe.fromPortablePath(r),this.makeCallback(a,n))}).then(a=>{let n=a;return Object.defineProperty(n,"path",{value:r,configurable:!0,writable:!0}),n})}opendirSync(r,o){let n=typeof o<"u"?this.realFs.opendirSync(fe.fromPortablePath(r),o):this.realFs.opendirSync(fe.fromPortablePath(r));return Object.defineProperty(n,"path",{value:r,configurable:!0,writable:!0}),n}async readPromise(r,o,a=0,n=0,u=-1){return await new Promise((A,p)=>{this.realFs.read(r,o,a,n,u,(h,C)=>{h?p(h):A(C)})})}readSync(r,o,a,n,u){return this.realFs.readSync(r,o,a,n,u)}async writePromise(r,o,a,n,u){return await new Promise((A,p)=>typeof o=="string"?this.realFs.write(r,o,a,this.makeCallback(A,p)):this.realFs.write(r,o,a,n,u,this.makeCallback(A,p)))}writeSync(r,o,a,n,u){return typeof o=="string"?this.realFs.writeSync(r,o,a):this.realFs.writeSync(r,o,a,n,u)}async closePromise(r){await new Promise((o,a)=>{this.realFs.close(r,this.makeCallback(o,a))})}closeSync(r){this.realFs.closeSync(r)}createReadStream(r,o){let a=r!==null?fe.fromPortablePath(r):r;return this.realFs.createReadStream(a,o)}createWriteStream(r,o){let a=r!==null?fe.fromPortablePath(r):r;return this.realFs.createWriteStream(a,o)}async realpathPromise(r){return await new Promise((o,a)=>{this.realFs.realpath(fe.fromPortablePath(r),{},this.makeCallback(o,a))}).then(o=>fe.toPortablePath(o))}realpathSync(r){return fe.toPortablePath(this.realFs.realpathSync(fe.fromPortablePath(r),{}))}async existsPromise(r){return await new Promise(o=>{this.realFs.exists(fe.fromPortablePath(r),o)})}accessSync(r,o){return this.realFs.accessSync(fe.fromPortablePath(r),o)}async accessPromise(r,o){return await new Promise((a,n)=>{this.realFs.access(fe.fromPortablePath(r),o,this.makeCallback(a,n))})}existsSync(r){return this.realFs.existsSync(fe.fromPortablePath(r))}async statPromise(r,o){return await new Promise((a,n)=>{o?this.realFs.stat(fe.fromPortablePath(r),o,this.makeCallback(a,n)):this.realFs.stat(fe.fromPortablePath(r),this.makeCallback(a,n))})}statSync(r,o){return o?this.realFs.statSync(fe.fromPortablePath(r),o):this.realFs.statSync(fe.fromPortablePath(r))}async fstatPromise(r,o){return await new Promise((a,n)=>{o?this.realFs.fstat(r,o,this.makeCallback(a,n)):this.realFs.fstat(r,this.makeCallback(a,n))})}fstatSync(r,o){return o?this.realFs.fstatSync(r,o):this.realFs.fstatSync(r)}async lstatPromise(r,o){return await new Promise((a,n)=>{o?this.realFs.lstat(fe.fromPortablePath(r),o,this.makeCallback(a,n)):this.realFs.lstat(fe.fromPortablePath(r),this.makeCallback(a,n))})}lstatSync(r,o){return o?this.realFs.lstatSync(fe.fromPortablePath(r),o):this.realFs.lstatSync(fe.fromPortablePath(r))}async fchmodPromise(r,o){return await new Promise((a,n)=>{this.realFs.fchmod(r,o,this.makeCallback(a,n))})}fchmodSync(r,o){return this.realFs.fchmodSync(r,o)}async chmodPromise(r,o){return await new Promise((a,n)=>{this.realFs.chmod(fe.fromPortablePath(r),o,this.makeCallback(a,n))})}chmodSync(r,o){return this.realFs.chmodSync(fe.fromPortablePath(r),o)}async fchownPromise(r,o,a){return await new Promise((n,u)=>{this.realFs.fchown(r,o,a,this.makeCallback(n,u))})}fchownSync(r,o,a){return this.realFs.fchownSync(r,o,a)}async chownPromise(r,o,a){return await new Promise((n,u)=>{this.realFs.chown(fe.fromPortablePath(r),o,a,this.makeCallback(n,u))})}chownSync(r,o,a){return this.realFs.chownSync(fe.fromPortablePath(r),o,a)}async renamePromise(r,o){return await new Promise((a,n)=>{this.realFs.rename(fe.fromPortablePath(r),fe.fromPortablePath(o),this.makeCallback(a,n))})}renameSync(r,o){return this.realFs.renameSync(fe.fromPortablePath(r),fe.fromPortablePath(o))}async copyFilePromise(r,o,a=0){return await new Promise((n,u)=>{this.realFs.copyFile(fe.fromPortablePath(r),fe.fromPortablePath(o),a,this.makeCallback(n,u))})}copyFileSync(r,o,a=0){return this.realFs.copyFileSync(fe.fromPortablePath(r),fe.fromPortablePath(o),a)}async appendFilePromise(r,o,a){return await new Promise((n,u)=>{let A=typeof r=="string"?fe.fromPortablePath(r):r;a?this.realFs.appendFile(A,o,a,this.makeCallback(n,u)):this.realFs.appendFile(A,o,this.makeCallback(n,u))})}appendFileSync(r,o,a){let n=typeof r=="string"?fe.fromPortablePath(r):r;a?this.realFs.appendFileSync(n,o,a):this.realFs.appendFileSync(n,o)}async writeFilePromise(r,o,a){return await new Promise((n,u)=>{let A=typeof r=="string"?fe.fromPortablePath(r):r;a?this.realFs.writeFile(A,o,a,this.makeCallback(n,u)):this.realFs.writeFile(A,o,this.makeCallback(n,u))})}writeFileSync(r,o,a){let n=typeof r=="string"?fe.fromPortablePath(r):r;a?this.realFs.writeFileSync(n,o,a):this.realFs.writeFileSync(n,o)}async unlinkPromise(r){return await new Promise((o,a)=>{this.realFs.unlink(fe.fromPortablePath(r),this.makeCallback(o,a))})}unlinkSync(r){return this.realFs.unlinkSync(fe.fromPortablePath(r))}async utimesPromise(r,o,a){return await new Promise((n,u)=>{this.realFs.utimes(fe.fromPortablePath(r),o,a,this.makeCallback(n,u))})}utimesSync(r,o,a){this.realFs.utimesSync(fe.fromPortablePath(r),o,a)}async lutimesPromise(r,o,a){return await new Promise((n,u)=>{this.realFs.lutimes(fe.fromPortablePath(r),o,a,this.makeCallback(n,u))})}lutimesSync(r,o,a){this.realFs.lutimesSync(fe.fromPortablePath(r),o,a)}async mkdirPromise(r,o){return await new Promise((a,n)=>{this.realFs.mkdir(fe.fromPortablePath(r),o,this.makeCallback(a,n))})}mkdirSync(r,o){return this.realFs.mkdirSync(fe.fromPortablePath(r),o)}async rmdirPromise(r,o){return await new Promise((a,n)=>{o?this.realFs.rmdir(fe.fromPortablePath(r),o,this.makeCallback(a,n)):this.realFs.rmdir(fe.fromPortablePath(r),this.makeCallback(a,n))})}rmdirSync(r,o){return this.realFs.rmdirSync(fe.fromPortablePath(r),o)}async linkPromise(r,o){return await new Promise((a,n)=>{this.realFs.link(fe.fromPortablePath(r),fe.fromPortablePath(o),this.makeCallback(a,n))})}linkSync(r,o){return this.realFs.linkSync(fe.fromPortablePath(r),fe.fromPortablePath(o))}async symlinkPromise(r,o,a){return await new Promise((n,u)=>{this.realFs.symlink(fe.fromPortablePath(r.replace(/\/+$/,"")),fe.fromPortablePath(o),a,this.makeCallback(n,u))})}symlinkSync(r,o,a){return this.realFs.symlinkSync(fe.fromPortablePath(r.replace(/\/+$/,"")),fe.fromPortablePath(o),a)}async readFilePromise(r,o){return await new Promise((a,n)=>{let u=typeof r=="string"?fe.fromPortablePath(r):r;this.realFs.readFile(u,o,this.makeCallback(a,n))})}readFileSync(r,o){let a=typeof r=="string"?fe.fromPortablePath(r):r;return this.realFs.readFileSync(a,o)}async readdirPromise(r,o){return await new Promise((a,n)=>{o?this.realFs.readdir(fe.fromPortablePath(r),o,this.makeCallback(a,n)):this.realFs.readdir(fe.fromPortablePath(r),this.makeCallback(u=>a(u),n))})}readdirSync(r,o){return o?this.realFs.readdirSync(fe.fromPortablePath(r),o):this.realFs.readdirSync(fe.fromPortablePath(r))}async readlinkPromise(r){return await new Promise((o,a)=>{this.realFs.readlink(fe.fromPortablePath(r),this.makeCallback(o,a))}).then(o=>fe.toPortablePath(o))}readlinkSync(r){return fe.toPortablePath(this.realFs.readlinkSync(fe.fromPortablePath(r)))}async truncatePromise(r,o){return await new Promise((a,n)=>{this.realFs.truncate(fe.fromPortablePath(r),o,this.makeCallback(a,n))})}truncateSync(r,o){return this.realFs.truncateSync(fe.fromPortablePath(r),o)}async ftruncatePromise(r,o){return await new Promise((a,n)=>{this.realFs.ftruncate(r,o,this.makeCallback(a,n))})}ftruncateSync(r,o){return this.realFs.ftruncateSync(r,o)}watch(r,o,a){return this.realFs.watch(fe.fromPortablePath(r),o,a)}watchFile(r,o,a){return this.realFs.watchFile(fe.fromPortablePath(r),o,a)}unwatchFile(r,o){return this.realFs.unwatchFile(fe.fromPortablePath(r),o)}makeCallback(r,o){return(a,n)=>{a?o(a):r(n)}}}});var gn,W7=yt(()=>{_g();gf();Ca();gn=class extends Ps{constructor(r,{baseFs:o=new Tn}={}){super(V);this.target=this.pathUtils.normalize(r),this.baseFs=o}getRealPath(){return this.pathUtils.resolve(this.baseFs.getRealPath(),this.target)}resolve(r){return this.pathUtils.isAbsolute(r)?V.normalize(r):this.baseFs.resolve(V.join(this.target,r))}mapFromBase(r){return r}mapToBase(r){return this.pathUtils.isAbsolute(r)?r:this.pathUtils.join(this.target,r)}}});var K7,_u,V7=yt(()=>{_g();gf();Ca();K7=Bt.root,_u=class extends Ps{constructor(r,{baseFs:o=new Tn}={}){super(V);this.target=this.pathUtils.resolve(Bt.root,r),this.baseFs=o}getRealPath(){return this.pathUtils.resolve(this.baseFs.getRealPath(),this.pathUtils.relative(Bt.root,this.target))}getTarget(){return this.target}getBaseFs(){return this.baseFs}mapToBase(r){let o=this.pathUtils.normalize(r);if(this.pathUtils.isAbsolute(r))return this.pathUtils.resolve(this.target,this.pathUtils.relative(K7,r));if(o.match(/^\.\.\/?/))throw new Error(`Resolving this path (${r}) would escape the jail`);return this.pathUtils.resolve(this.target,r)}mapFromBase(r){return this.pathUtils.resolve(K7,this.pathUtils.relative(this.target,r))}}});var ny,z7=yt(()=>{gf();ny=class extends Ps{constructor(r,o){super(o);this.instance=null;this.factory=r}get baseFs(){return this.instance||(this.instance=this.factory()),this.instance}set baseFs(r){this.instance=r}mapFromBase(r){return r}mapToBase(r){return r}}});var Hg,wa,Up,J7=yt(()=>{Hg=Be("fs");Ug();_g();YR();BD();Ca();wa=4278190080,Up=class extends Mu{constructor({baseFs:r=new Tn,filter:o=null,magicByte:a=42,maxOpenFiles:n=1/0,useCache:u=!0,maxAge:A=5e3,typeCheck:p=Hg.constants.S_IFREG,getMountPoint:h,factoryPromise:C,factorySync:I}){if(Math.floor(a)!==a||!(a>1&&a<=127))throw new Error("The magic byte must be set to a round value between 1 and 127 included");super();this.fdMap=new Map;this.nextFd=3;this.isMount=new Set;this.notMount=new Set;this.realPaths=new Map;this.limitOpenFilesTimeout=null;this.baseFs=r,this.mountInstances=u?new Map:null,this.factoryPromise=C,this.factorySync=I,this.filter=o,this.getMountPoint=h,this.magic=a<<24,this.maxAge=A,this.maxOpenFiles=n,this.typeCheck=p}getExtractHint(r){return this.baseFs.getExtractHint(r)}getRealPath(){return this.baseFs.getRealPath()}saveAndClose(){if(Og(this),this.mountInstances)for(let[r,{childFs:o}]of this.mountInstances.entries())o.saveAndClose?.(),this.mountInstances.delete(r)}discardAndClose(){if(Og(this),this.mountInstances)for(let[r,{childFs:o}]of this.mountInstances.entries())o.discardAndClose?.(),this.mountInstances.delete(r)}resolve(r){return this.baseFs.resolve(r)}remapFd(r,o){let a=this.nextFd++|this.magic;return this.fdMap.set(a,[r,o]),a}async openPromise(r,o,a){return await this.makeCallPromise(r,async()=>await this.baseFs.openPromise(r,o,a),async(n,{subPath:u})=>this.remapFd(n,await n.openPromise(u,o,a)))}openSync(r,o,a){return this.makeCallSync(r,()=>this.baseFs.openSync(r,o,a),(n,{subPath:u})=>this.remapFd(n,n.openSync(u,o,a)))}async opendirPromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.opendirPromise(r,o),async(a,{subPath:n})=>await a.opendirPromise(n,o),{requireSubpath:!1})}opendirSync(r,o){return this.makeCallSync(r,()=>this.baseFs.opendirSync(r,o),(a,{subPath:n})=>a.opendirSync(n,o),{requireSubpath:!1})}async readPromise(r,o,a,n,u){if((r&wa)!==this.magic)return await this.baseFs.readPromise(r,o,a,n,u);let A=this.fdMap.get(r);if(typeof A>"u")throw Io("read");let[p,h]=A;return await p.readPromise(h,o,a,n,u)}readSync(r,o,a,n,u){if((r&wa)!==this.magic)return this.baseFs.readSync(r,o,a,n,u);let A=this.fdMap.get(r);if(typeof A>"u")throw Io("readSync");let[p,h]=A;return p.readSync(h,o,a,n,u)}async writePromise(r,o,a,n,u){if((r&wa)!==this.magic)return typeof o=="string"?await this.baseFs.writePromise(r,o,a):await this.baseFs.writePromise(r,o,a,n,u);let A=this.fdMap.get(r);if(typeof A>"u")throw Io("write");let[p,h]=A;return typeof o=="string"?await p.writePromise(h,o,a):await p.writePromise(h,o,a,n,u)}writeSync(r,o,a,n,u){if((r&wa)!==this.magic)return typeof o=="string"?this.baseFs.writeSync(r,o,a):this.baseFs.writeSync(r,o,a,n,u);let A=this.fdMap.get(r);if(typeof A>"u")throw Io("writeSync");let[p,h]=A;return typeof o=="string"?p.writeSync(h,o,a):p.writeSync(h,o,a,n,u)}async closePromise(r){if((r&wa)!==this.magic)return await this.baseFs.closePromise(r);let o=this.fdMap.get(r);if(typeof o>"u")throw Io("close");this.fdMap.delete(r);let[a,n]=o;return await a.closePromise(n)}closeSync(r){if((r&wa)!==this.magic)return this.baseFs.closeSync(r);let o=this.fdMap.get(r);if(typeof o>"u")throw Io("closeSync");this.fdMap.delete(r);let[a,n]=o;return a.closeSync(n)}createReadStream(r,o){return r===null?this.baseFs.createReadStream(r,o):this.makeCallSync(r,()=>this.baseFs.createReadStream(r,o),(a,{archivePath:n,subPath:u})=>{let A=a.createReadStream(u,o);return A.path=fe.fromPortablePath(this.pathUtils.join(n,u)),A})}createWriteStream(r,o){return r===null?this.baseFs.createWriteStream(r,o):this.makeCallSync(r,()=>this.baseFs.createWriteStream(r,o),(a,{subPath:n})=>a.createWriteStream(n,o))}async realpathPromise(r){return await this.makeCallPromise(r,async()=>await this.baseFs.realpathPromise(r),async(o,{archivePath:a,subPath:n})=>{let u=this.realPaths.get(a);return typeof u>"u"&&(u=await this.baseFs.realpathPromise(a),this.realPaths.set(a,u)),this.pathUtils.join(u,this.pathUtils.relative(Bt.root,await o.realpathPromise(n)))})}realpathSync(r){return this.makeCallSync(r,()=>this.baseFs.realpathSync(r),(o,{archivePath:a,subPath:n})=>{let u=this.realPaths.get(a);return typeof u>"u"&&(u=this.baseFs.realpathSync(a),this.realPaths.set(a,u)),this.pathUtils.join(u,this.pathUtils.relative(Bt.root,o.realpathSync(n)))})}async existsPromise(r){return await this.makeCallPromise(r,async()=>await this.baseFs.existsPromise(r),async(o,{subPath:a})=>await o.existsPromise(a))}existsSync(r){return this.makeCallSync(r,()=>this.baseFs.existsSync(r),(o,{subPath:a})=>o.existsSync(a))}async accessPromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.accessPromise(r,o),async(a,{subPath:n})=>await a.accessPromise(n,o))}accessSync(r,o){return this.makeCallSync(r,()=>this.baseFs.accessSync(r,o),(a,{subPath:n})=>a.accessSync(n,o))}async statPromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.statPromise(r,o),async(a,{subPath:n})=>await a.statPromise(n,o))}statSync(r,o){return this.makeCallSync(r,()=>this.baseFs.statSync(r,o),(a,{subPath:n})=>a.statSync(n,o))}async fstatPromise(r,o){if((r&wa)!==this.magic)return this.baseFs.fstatPromise(r,o);let a=this.fdMap.get(r);if(typeof a>"u")throw Io("fstat");let[n,u]=a;return n.fstatPromise(u,o)}fstatSync(r,o){if((r&wa)!==this.magic)return this.baseFs.fstatSync(r,o);let a=this.fdMap.get(r);if(typeof a>"u")throw Io("fstatSync");let[n,u]=a;return n.fstatSync(u,o)}async lstatPromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.lstatPromise(r,o),async(a,{subPath:n})=>await a.lstatPromise(n,o))}lstatSync(r,o){return this.makeCallSync(r,()=>this.baseFs.lstatSync(r,o),(a,{subPath:n})=>a.lstatSync(n,o))}async fchmodPromise(r,o){if((r&wa)!==this.magic)return this.baseFs.fchmodPromise(r,o);let a=this.fdMap.get(r);if(typeof a>"u")throw Io("fchmod");let[n,u]=a;return n.fchmodPromise(u,o)}fchmodSync(r,o){if((r&wa)!==this.magic)return this.baseFs.fchmodSync(r,o);let a=this.fdMap.get(r);if(typeof a>"u")throw Io("fchmodSync");let[n,u]=a;return n.fchmodSync(u,o)}async chmodPromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.chmodPromise(r,o),async(a,{subPath:n})=>await a.chmodPromise(n,o))}chmodSync(r,o){return this.makeCallSync(r,()=>this.baseFs.chmodSync(r,o),(a,{subPath:n})=>a.chmodSync(n,o))}async fchownPromise(r,o,a){if((r&wa)!==this.magic)return this.baseFs.fchownPromise(r,o,a);let n=this.fdMap.get(r);if(typeof n>"u")throw Io("fchown");let[u,A]=n;return u.fchownPromise(A,o,a)}fchownSync(r,o,a){if((r&wa)!==this.magic)return this.baseFs.fchownSync(r,o,a);let n=this.fdMap.get(r);if(typeof n>"u")throw Io("fchownSync");let[u,A]=n;return u.fchownSync(A,o,a)}async chownPromise(r,o,a){return await this.makeCallPromise(r,async()=>await this.baseFs.chownPromise(r,o,a),async(n,{subPath:u})=>await n.chownPromise(u,o,a))}chownSync(r,o,a){return this.makeCallSync(r,()=>this.baseFs.chownSync(r,o,a),(n,{subPath:u})=>n.chownSync(u,o,a))}async renamePromise(r,o){return await this.makeCallPromise(r,async()=>await this.makeCallPromise(o,async()=>await this.baseFs.renamePromise(r,o),async()=>{throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"})}),async(a,{subPath:n})=>await this.makeCallPromise(o,async()=>{throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"})},async(u,{subPath:A})=>{if(a!==u)throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"});return await a.renamePromise(n,A)}))}renameSync(r,o){return this.makeCallSync(r,()=>this.makeCallSync(o,()=>this.baseFs.renameSync(r,o),()=>{throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"})}),(a,{subPath:n})=>this.makeCallSync(o,()=>{throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"})},(u,{subPath:A})=>{if(a!==u)throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"});return a.renameSync(n,A)}))}async copyFilePromise(r,o,a=0){let n=async(u,A,p,h)=>{if((a&Hg.constants.COPYFILE_FICLONE_FORCE)!==0)throw Object.assign(new Error(`EXDEV: cross-device clone not permitted, copyfile '${A}' -> ${h}'`),{code:"EXDEV"});if(a&Hg.constants.COPYFILE_EXCL&&await this.existsPromise(A))throw Object.assign(new Error(`EEXIST: file already exists, copyfile '${A}' -> '${h}'`),{code:"EEXIST"});let C;try{C=await u.readFilePromise(A)}catch{throw Object.assign(new Error(`EINVAL: invalid argument, copyfile '${A}' -> '${h}'`),{code:"EINVAL"})}await p.writeFilePromise(h,C)};return await this.makeCallPromise(r,async()=>await this.makeCallPromise(o,async()=>await this.baseFs.copyFilePromise(r,o,a),async(u,{subPath:A})=>await n(this.baseFs,r,u,A)),async(u,{subPath:A})=>await this.makeCallPromise(o,async()=>await n(u,A,this.baseFs,o),async(p,{subPath:h})=>u!==p?await n(u,A,p,h):await u.copyFilePromise(A,h,a)))}copyFileSync(r,o,a=0){let n=(u,A,p,h)=>{if((a&Hg.constants.COPYFILE_FICLONE_FORCE)!==0)throw Object.assign(new Error(`EXDEV: cross-device clone not permitted, copyfile '${A}' -> ${h}'`),{code:"EXDEV"});if(a&Hg.constants.COPYFILE_EXCL&&this.existsSync(A))throw Object.assign(new Error(`EEXIST: file already exists, copyfile '${A}' -> '${h}'`),{code:"EEXIST"});let C;try{C=u.readFileSync(A)}catch{throw Object.assign(new Error(`EINVAL: invalid argument, copyfile '${A}' -> '${h}'`),{code:"EINVAL"})}p.writeFileSync(h,C)};return this.makeCallSync(r,()=>this.makeCallSync(o,()=>this.baseFs.copyFileSync(r,o,a),(u,{subPath:A})=>n(this.baseFs,r,u,A)),(u,{subPath:A})=>this.makeCallSync(o,()=>n(u,A,this.baseFs,o),(p,{subPath:h})=>u!==p?n(u,A,p,h):u.copyFileSync(A,h,a)))}async appendFilePromise(r,o,a){return await this.makeCallPromise(r,async()=>await this.baseFs.appendFilePromise(r,o,a),async(n,{subPath:u})=>await n.appendFilePromise(u,o,a))}appendFileSync(r,o,a){return this.makeCallSync(r,()=>this.baseFs.appendFileSync(r,o,a),(n,{subPath:u})=>n.appendFileSync(u,o,a))}async writeFilePromise(r,o,a){return await this.makeCallPromise(r,async()=>await this.baseFs.writeFilePromise(r,o,a),async(n,{subPath:u})=>await n.writeFilePromise(u,o,a))}writeFileSync(r,o,a){return this.makeCallSync(r,()=>this.baseFs.writeFileSync(r,o,a),(n,{subPath:u})=>n.writeFileSync(u,o,a))}async unlinkPromise(r){return await this.makeCallPromise(r,async()=>await this.baseFs.unlinkPromise(r),async(o,{subPath:a})=>await o.unlinkPromise(a))}unlinkSync(r){return this.makeCallSync(r,()=>this.baseFs.unlinkSync(r),(o,{subPath:a})=>o.unlinkSync(a))}async utimesPromise(r,o,a){return await this.makeCallPromise(r,async()=>await this.baseFs.utimesPromise(r,o,a),async(n,{subPath:u})=>await n.utimesPromise(u,o,a))}utimesSync(r,o,a){return this.makeCallSync(r,()=>this.baseFs.utimesSync(r,o,a),(n,{subPath:u})=>n.utimesSync(u,o,a))}async lutimesPromise(r,o,a){return await this.makeCallPromise(r,async()=>await this.baseFs.lutimesPromise(r,o,a),async(n,{subPath:u})=>await n.lutimesPromise(u,o,a))}lutimesSync(r,o,a){return this.makeCallSync(r,()=>this.baseFs.lutimesSync(r,o,a),(n,{subPath:u})=>n.lutimesSync(u,o,a))}async mkdirPromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.mkdirPromise(r,o),async(a,{subPath:n})=>await a.mkdirPromise(n,o))}mkdirSync(r,o){return this.makeCallSync(r,()=>this.baseFs.mkdirSync(r,o),(a,{subPath:n})=>a.mkdirSync(n,o))}async rmdirPromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.rmdirPromise(r,o),async(a,{subPath:n})=>await a.rmdirPromise(n,o))}rmdirSync(r,o){return this.makeCallSync(r,()=>this.baseFs.rmdirSync(r,o),(a,{subPath:n})=>a.rmdirSync(n,o))}async linkPromise(r,o){return await this.makeCallPromise(o,async()=>await this.baseFs.linkPromise(r,o),async(a,{subPath:n})=>await a.linkPromise(r,n))}linkSync(r,o){return this.makeCallSync(o,()=>this.baseFs.linkSync(r,o),(a,{subPath:n})=>a.linkSync(r,n))}async symlinkPromise(r,o,a){return await this.makeCallPromise(o,async()=>await this.baseFs.symlinkPromise(r,o,a),async(n,{subPath:u})=>await n.symlinkPromise(r,u))}symlinkSync(r,o,a){return this.makeCallSync(o,()=>this.baseFs.symlinkSync(r,o,a),(n,{subPath:u})=>n.symlinkSync(r,u))}async readFilePromise(r,o){return this.makeCallPromise(r,async()=>await this.baseFs.readFilePromise(r,o),async(a,{subPath:n})=>await a.readFilePromise(n,o))}readFileSync(r,o){return this.makeCallSync(r,()=>this.baseFs.readFileSync(r,o),(a,{subPath:n})=>a.readFileSync(n,o))}async readdirPromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.readdirPromise(r,o),async(a,{subPath:n})=>await a.readdirPromise(n,o),{requireSubpath:!1})}readdirSync(r,o){return this.makeCallSync(r,()=>this.baseFs.readdirSync(r,o),(a,{subPath:n})=>a.readdirSync(n,o),{requireSubpath:!1})}async readlinkPromise(r){return await this.makeCallPromise(r,async()=>await this.baseFs.readlinkPromise(r),async(o,{subPath:a})=>await o.readlinkPromise(a))}readlinkSync(r){return this.makeCallSync(r,()=>this.baseFs.readlinkSync(r),(o,{subPath:a})=>o.readlinkSync(a))}async truncatePromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.truncatePromise(r,o),async(a,{subPath:n})=>await a.truncatePromise(n,o))}truncateSync(r,o){return this.makeCallSync(r,()=>this.baseFs.truncateSync(r,o),(a,{subPath:n})=>a.truncateSync(n,o))}async ftruncatePromise(r,o){if((r&wa)!==this.magic)return this.baseFs.ftruncatePromise(r,o);let a=this.fdMap.get(r);if(typeof a>"u")throw Io("ftruncate");let[n,u]=a;return n.ftruncatePromise(u,o)}ftruncateSync(r,o){if((r&wa)!==this.magic)return this.baseFs.ftruncateSync(r,o);let a=this.fdMap.get(r);if(typeof a>"u")throw Io("ftruncateSync");let[n,u]=a;return n.ftruncateSync(u,o)}watch(r,o,a){return this.makeCallSync(r,()=>this.baseFs.watch(r,o,a),(n,{subPath:u})=>n.watch(u,o,a))}watchFile(r,o,a){return this.makeCallSync(r,()=>this.baseFs.watchFile(r,o,a),()=>ry(this,r,o,a))}unwatchFile(r,o){return this.makeCallSync(r,()=>this.baseFs.unwatchFile(r,o),()=>Ng(this,r,o))}async makeCallPromise(r,o,a,{requireSubpath:n=!0}={}){if(typeof r!="string")return await o();let u=this.resolve(r),A=this.findMount(u);return A?n&&A.subPath==="/"?await o():await this.getMountPromise(A.archivePath,async p=>await a(p,A)):await o()}makeCallSync(r,o,a,{requireSubpath:n=!0}={}){if(typeof r!="string")return o();let u=this.resolve(r),A=this.findMount(u);return!A||n&&A.subPath==="/"?o():this.getMountSync(A.archivePath,p=>a(p,A))}findMount(r){if(this.filter&&!this.filter.test(r))return null;let o="";for(;;){let a=r.substring(o.length),n=this.getMountPoint(a,o);if(!n)return null;if(o=this.pathUtils.join(o,n),!this.isMount.has(o)){if(this.notMount.has(o))continue;try{if(this.typeCheck!==null&&(this.baseFs.lstatSync(o).mode&Hg.constants.S_IFMT)!==this.typeCheck){this.notMount.add(o);continue}}catch{return null}this.isMount.add(o)}return{archivePath:o,subPath:this.pathUtils.join(Bt.root,r.substring(o.length))}}}limitOpenFiles(r){if(this.mountInstances===null)return;let o=Date.now(),a=o+this.maxAge,n=r===null?0:this.mountInstances.size-r;for(let[u,{childFs:A,expiresAt:p,refCount:h}]of this.mountInstances.entries())if(!(h!==0||A.hasOpenFileHandles?.())){if(o>=p){A.saveAndClose?.(),this.mountInstances.delete(u),n-=1;continue}else if(r===null||n<=0){a=p;break}A.saveAndClose?.(),this.mountInstances.delete(u),n-=1}this.limitOpenFilesTimeout===null&&(r===null&&this.mountInstances.size>0||r!==null)&&isFinite(a)&&(this.limitOpenFilesTimeout=setTimeout(()=>{this.limitOpenFilesTimeout=null,this.limitOpenFiles(null)},a-o).unref())}async getMountPromise(r,o){if(this.mountInstances){let a=this.mountInstances.get(r);if(!a){let n=await this.factoryPromise(this.baseFs,r);a=this.mountInstances.get(r),a||(a={childFs:n(),expiresAt:0,refCount:0})}this.mountInstances.delete(r),this.limitOpenFiles(this.maxOpenFiles-1),this.mountInstances.set(r,a),a.expiresAt=Date.now()+this.maxAge,a.refCount+=1;try{return await o(a.childFs)}finally{a.refCount-=1}}else{let a=(await this.factoryPromise(this.baseFs,r))();try{return await o(a)}finally{a.saveAndClose?.()}}}getMountSync(r,o){if(this.mountInstances){let a=this.mountInstances.get(r);return a||(a={childFs:this.factorySync(this.baseFs,r),expiresAt:0,refCount:0}),this.mountInstances.delete(r),this.limitOpenFiles(this.maxOpenFiles-1),this.mountInstances.set(r,a),a.expiresAt=Date.now()+this.maxAge,o(a.childFs)}else{let a=this.factorySync(this.baseFs,r);try{return o(a)}finally{a.saveAndClose?.()}}}}});var Zt,WR,Gw,X7=yt(()=>{Ug();Ca();Zt=()=>Object.assign(new Error("ENOSYS: unsupported filesystem access"),{code:"ENOSYS"}),WR=class extends hf{constructor(){super(V)}getExtractHint(){throw Zt()}getRealPath(){throw Zt()}resolve(){throw Zt()}async openPromise(){throw Zt()}openSync(){throw Zt()}async opendirPromise(){throw Zt()}opendirSync(){throw Zt()}async readPromise(){throw Zt()}readSync(){throw Zt()}async writePromise(){throw Zt()}writeSync(){throw Zt()}async closePromise(){throw Zt()}closeSync(){throw Zt()}createWriteStream(){throw Zt()}createReadStream(){throw Zt()}async realpathPromise(){throw Zt()}realpathSync(){throw Zt()}async readdirPromise(){throw Zt()}readdirSync(){throw Zt()}async existsPromise(e){throw Zt()}existsSync(e){throw Zt()}async accessPromise(){throw Zt()}accessSync(){throw Zt()}async statPromise(){throw Zt()}statSync(){throw Zt()}async fstatPromise(e){throw Zt()}fstatSync(e){throw Zt()}async lstatPromise(e){throw Zt()}lstatSync(e){throw Zt()}async fchmodPromise(){throw Zt()}fchmodSync(){throw Zt()}async chmodPromise(){throw Zt()}chmodSync(){throw Zt()}async fchownPromise(){throw Zt()}fchownSync(){throw Zt()}async chownPromise(){throw Zt()}chownSync(){throw Zt()}async mkdirPromise(){throw Zt()}mkdirSync(){throw Zt()}async rmdirPromise(){throw Zt()}rmdirSync(){throw Zt()}async linkPromise(){throw Zt()}linkSync(){throw Zt()}async symlinkPromise(){throw Zt()}symlinkSync(){throw Zt()}async renamePromise(){throw Zt()}renameSync(){throw Zt()}async copyFilePromise(){throw Zt()}copyFileSync(){throw Zt()}async appendFilePromise(){throw Zt()}appendFileSync(){throw Zt()}async writeFilePromise(){throw Zt()}writeFileSync(){throw Zt()}async unlinkPromise(){throw Zt()}unlinkSync(){throw Zt()}async utimesPromise(){throw Zt()}utimesSync(){throw Zt()}async lutimesPromise(){throw Zt()}lutimesSync(){throw Zt()}async readFilePromise(){throw Zt()}readFileSync(){throw Zt()}async readlinkPromise(){throw Zt()}readlinkSync(){throw Zt()}async truncatePromise(){throw Zt()}truncateSync(){throw Zt()}async ftruncatePromise(e,r){throw Zt()}ftruncateSync(e,r){throw Zt()}watch(){throw Zt()}watchFile(){throw Zt()}unwatchFile(){throw Zt()}},Gw=WR;Gw.instance=new WR});var _p,Z7=yt(()=>{gf();Ca();_p=class extends Ps{constructor(r){super(fe);this.baseFs=r}mapFromBase(r){return fe.fromPortablePath(r)}mapToBase(r){return fe.toPortablePath(r)}}});var F_e,KR,R_e,mi,$7=yt(()=>{_g();gf();Ca();F_e=/^[0-9]+$/,KR=/^(\/(?:[^/]+\/)*?(?:\$\$virtual|__virtual__))((?:\/((?:[^/]+-)?[a-f0-9]+)(?:\/([^/]+))?)?((?:\/.*)?))$/,R_e=/^([^/]+-)?[a-f0-9]+$/,mi=class extends Ps{constructor({baseFs:r=new Tn}={}){super(V);this.baseFs=r}static makeVirtualPath(r,o,a){if(V.basename(r)!=="__virtual__")throw new Error('Assertion failed: Virtual folders must be named "__virtual__"');if(!V.basename(o).match(R_e))throw new Error("Assertion failed: Virtual components must be ended by an hexadecimal hash");let u=V.relative(V.dirname(r),a).split("/"),A=0;for(;A<u.length&&u[A]==="..";)A+=1;let p=u.slice(A);return V.join(r,o,String(A),...p)}static resolveVirtual(r){let o=r.match(KR);if(!o||!o[3]&&o[5])return r;let a=V.dirname(o[1]);if(!o[3]||!o[4])return a;if(!F_e.test(o[4]))return r;let u=Number(o[4]),A="../".repeat(u),p=o[5]||".";return mi.resolveVirtual(V.join(a,A,p))}getExtractHint(r){return this.baseFs.getExtractHint(r)}getRealPath(){return this.baseFs.getRealPath()}realpathSync(r){let o=r.match(KR);if(!o)return this.baseFs.realpathSync(r);if(!o[5])return r;let a=this.baseFs.realpathSync(this.mapToBase(r));return mi.makeVirtualPath(o[1],o[3],a)}async realpathPromise(r){let o=r.match(KR);if(!o)return await this.baseFs.realpathPromise(r);if(!o[5])return r;let a=await this.baseFs.realpathPromise(this.mapToBase(r));return mi.makeVirtualPath(o[1],o[3],a)}mapToBase(r){if(r==="")return r;if(this.pathUtils.isAbsolute(r))return mi.resolveVirtual(r);let o=mi.resolveVirtual(this.baseFs.resolve(Bt.dot)),a=mi.resolveVirtual(this.baseFs.resolve(r));return V.relative(o,a)||Bt.dot}mapFromBase(r){return r}}});function T_e(t,e){return typeof VR.default.isUtf8<"u"?VR.default.isUtf8(t):Buffer.byteLength(e)===t.byteLength}var VR,kD,eY,bD,tY=yt(()=>{VR=$e(Be("buffer")),kD=Be("url"),eY=Be("util");gf();Ca();bD=class extends Ps{constructor(r){super(fe);this.baseFs=r}mapFromBase(r){return r}mapToBase(r){if(typeof r=="string")return r;if(r instanceof kD.URL)return(0,kD.fileURLToPath)(r);if(Buffer.isBuffer(r)){let o=r.toString();if(!T_e(r,o))throw new Error("Non-utf8 buffers are not supported at the moment. Please upvote the following issue if you encounter this error: https://github.com/yarnpkg/berry/issues/4942");return o}throw new Error(`Unsupported path type: ${(0,eY.inspect)(r)}`)}}});var rY,Bo,df,Hp,QD,FD,iy,Tc,Lc,L_e,N_e,O_e,M_e,Yw,nY=yt(()=>{rY=Be("readline"),Bo=Symbol("kBaseFs"),df=Symbol("kFd"),Hp=Symbol("kClosePromise"),QD=Symbol("kCloseResolve"),FD=Symbol("kCloseReject"),iy=Symbol("kRefs"),Tc=Symbol("kRef"),Lc=Symbol("kUnref"),Yw=class{constructor(e,r){this[L_e]=1;this[N_e]=void 0;this[O_e]=void 0;this[M_e]=void 0;this[Bo]=r,this[df]=e}get fd(){return this[df]}async appendFile(e,r){try{this[Tc](this.appendFile);let o=(typeof r=="string"?r:r?.encoding)??void 0;return await this[Bo].appendFilePromise(this.fd,e,o?{encoding:o}:void 0)}finally{this[Lc]()}}async chown(e,r){try{return this[Tc](this.chown),await this[Bo].fchownPromise(this.fd,e,r)}finally{this[Lc]()}}async chmod(e){try{return this[Tc](this.chmod),await this[Bo].fchmodPromise(this.fd,e)}finally{this[Lc]()}}createReadStream(e){return this[Bo].createReadStream(null,{...e,fd:this.fd})}createWriteStream(e){return this[Bo].createWriteStream(null,{...e,fd:this.fd})}datasync(){throw new Error("Method not implemented.")}sync(){throw new Error("Method not implemented.")}async read(e,r,o,a){try{this[Tc](this.read);let n;return Buffer.isBuffer(e)?n=e:(e??={},n=e.buffer??Buffer.alloc(16384),r=e.offset||0,o=e.length??n.byteLength,a=e.position??null),r??=0,o??=0,o===0?{bytesRead:o,buffer:n}:{bytesRead:await this[Bo].readPromise(this.fd,n,r,o,a),buffer:n}}finally{this[Lc]()}}async readFile(e){try{this[Tc](this.readFile);let r=(typeof e=="string"?e:e?.encoding)??void 0;return await this[Bo].readFilePromise(this.fd,r)}finally{this[Lc]()}}readLines(e){return(0,rY.createInterface)({input:this.createReadStream(e),crlfDelay:1/0})}async stat(e){try{return this[Tc](this.stat),await this[Bo].fstatPromise(this.fd,e)}finally{this[Lc]()}}async truncate(e){try{return this[Tc](this.truncate),await this[Bo].ftruncatePromise(this.fd,e)}finally{this[Lc]()}}utimes(e,r){throw new Error("Method not implemented.")}async writeFile(e,r){try{this[Tc](this.writeFile);let o=(typeof r=="string"?r:r?.encoding)??void 0;await this[Bo].writeFilePromise(this.fd,e,o)}finally{this[Lc]()}}async write(...e){try{if(this[Tc](this.write),ArrayBuffer.isView(e[0])){let[r,o,a,n]=e;return{bytesWritten:await this[Bo].writePromise(this.fd,r,o??void 0,a??void 0,n??void 0),buffer:r}}else{let[r,o,a]=e;return{bytesWritten:await this[Bo].writePromise(this.fd,r,o,a),buffer:r}}}finally{this[Lc]()}}async writev(e,r){try{this[Tc](this.writev);let o=0;if(typeof r<"u")for(let a of e){let n=await this.write(a,void 0,void 0,r);o+=n.bytesWritten,r+=n.bytesWritten}else for(let a of e){let n=await this.write(a);o+=n.bytesWritten}return{buffers:e,bytesWritten:o}}finally{this[Lc]()}}readv(e,r){throw new Error("Method not implemented.")}close(){if(this[df]===-1)return Promise.resolve();if(this[Hp])return this[Hp];if(this[iy]--,this[iy]===0){let e=this[df];this[df]=-1,this[Hp]=this[Bo].closePromise(e).finally(()=>{this[Hp]=void 0})}else this[Hp]=new Promise((e,r)=>{this[QD]=e,this[FD]=r}).finally(()=>{this[Hp]=void 0,this[FD]=void 0,this[QD]=void 0});return this[Hp]}[(Bo,df,L_e=iy,N_e=Hp,O_e=QD,M_e=FD,Tc)](e){if(this[df]===-1){let r=new Error("file closed");throw r.code="EBADF",r.syscall=e.name,r}this[iy]++}[Lc](){if(this[iy]--,this[iy]===0){let e=this[df];this[df]=-1,this[Bo].closePromise(e).then(this[QD],this[FD])}}}});function Ww(t,e){e=new bD(e);let r=(o,a,n)=>{let u=o[a];o[a]=n,typeof u?.[sy.promisify.custom]<"u"&&(n[sy.promisify.custom]=u[sy.promisify.custom])};{r(t,"exists",(o,...a)=>{let u=typeof a[a.length-1]=="function"?a.pop():()=>{};process.nextTick(()=>{e.existsPromise(o).then(A=>{u(A)},()=>{u(!1)})})}),r(t,"read",(...o)=>{let[a,n,u,A,p,h]=o;if(o.length<=3){let C={};o.length<3?h=o[1]:(C=o[1],h=o[2]),{buffer:n=Buffer.alloc(16384),offset:u=0,length:A=n.byteLength,position:p}=C}if(u==null&&(u=0),A|=0,A===0){process.nextTick(()=>{h(null,0,n)});return}p==null&&(p=-1),process.nextTick(()=>{e.readPromise(a,n,u,A,p).then(C=>{h(null,C,n)},C=>{h(C,0,n)})})});for(let o of iY){let a=o.replace(/Promise$/,"");if(typeof t[a]>"u")continue;let n=e[o];if(typeof n>"u")continue;r(t,a,(...A)=>{let h=typeof A[A.length-1]=="function"?A.pop():()=>{};process.nextTick(()=>{n.apply(e,A).then(C=>{h(null,C)},C=>{h(C)})})})}t.realpath.native=t.realpath}{r(t,"existsSync",o=>{try{return e.existsSync(o)}catch{return!1}}),r(t,"readSync",(...o)=>{let[a,n,u,A,p]=o;return o.length<=3&&({offset:u=0,length:A=n.byteLength,position:p}=o[2]||{}),u==null&&(u=0),A|=0,A===0?0:(p==null&&(p=-1),e.readSync(a,n,u,A,p))});for(let o of U_e){let a=o;if(typeof t[a]>"u")continue;let n=e[o];typeof n>"u"||r(t,a,n.bind(e))}t.realpathSync.native=t.realpathSync}{let o=t.promises;for(let a of iY){let n=a.replace(/Promise$/,"");if(typeof o[n]>"u")continue;let u=e[a];typeof u>"u"||a!=="open"&&r(o,n,(A,...p)=>A instanceof Yw?A[n].apply(A,p):u.call(e,A,...p))}r(o,"open",async(...a)=>{let n=await e.openPromise(...a);return new Yw(n,e)})}t.read[sy.promisify.custom]=async(o,a,...n)=>({bytesRead:await e.readPromise(o,a,...n),buffer:a}),t.write[sy.promisify.custom]=async(o,a,...n)=>({bytesWritten:await e.writePromise(o,a,...n),buffer:a})}function RD(t,e){let r=Object.create(t);return Ww(r,e),r}var sy,U_e,iY,sY=yt(()=>{sy=Be("util");tY();nY();U_e=new Set(["accessSync","appendFileSync","createReadStream","createWriteStream","chmodSync","fchmodSync","chownSync","fchownSync","closeSync","copyFileSync","linkSync","lstatSync","fstatSync","lutimesSync","mkdirSync","openSync","opendirSync","readlinkSync","readFileSync","readdirSync","readlinkSync","realpathSync","renameSync","rmdirSync","statSync","symlinkSync","truncateSync","ftruncateSync","unlinkSync","unwatchFile","utimesSync","watch","watchFile","writeFileSync","writeSync"]),iY=new Set(["accessPromise","appendFilePromise","fchmodPromise","chmodPromise","fchownPromise","chownPromise","closePromise","copyFilePromise","linkPromise","fstatPromise","lstatPromise","lutimesPromise","mkdirPromise","openPromise","opendirPromise","readdirPromise","realpathPromise","readFilePromise","readdirPromise","readlinkPromise","renamePromise","rmdirPromise","statPromise","symlinkPromise","truncatePromise","ftruncatePromise","unlinkPromise","utimesPromise","writeFilePromise","writeSync"])});function oY(t){let e=Math.ceil(Math.random()*4294967296).toString(16).padStart(8,"0");return`${t}${e}`}function aY(){if(zR)return zR;let t=fe.toPortablePath(lY.default.tmpdir()),e=oe.realpathSync(t);return process.once("exit",()=>{oe.rmtempSync()}),zR={tmpdir:t,realTmpdir:e}}var lY,Nc,zR,oe,cY=yt(()=>{lY=$e(Be("os"));_g();Ca();Nc=new Set,zR=null;oe=Object.assign(new Tn,{detachTemp(t){Nc.delete(t)},mktempSync(t){let{tmpdir:e,realTmpdir:r}=aY();for(;;){let o=oY("xfs-");try{this.mkdirSync(V.join(e,o))}catch(n){if(n.code==="EEXIST")continue;throw n}let a=V.join(r,o);if(Nc.add(a),typeof t>"u")return a;try{return t(a)}finally{if(Nc.has(a)){Nc.delete(a);try{this.removeSync(a)}catch{}}}}},async mktempPromise(t){let{tmpdir:e,realTmpdir:r}=aY();for(;;){let o=oY("xfs-");try{await this.mkdirPromise(V.join(e,o))}catch(n){if(n.code==="EEXIST")continue;throw n}let a=V.join(r,o);if(Nc.add(a),typeof t>"u")return a;try{return await t(a)}finally{if(Nc.has(a)){Nc.delete(a);try{await this.removePromise(a)}catch{}}}}},async rmtempPromise(){await Promise.all(Array.from(Nc.values()).map(async t=>{try{await oe.removePromise(t,{maxRetries:0}),Nc.delete(t)}catch{}}))},rmtempSync(){for(let t of Nc)try{oe.removeSync(t),Nc.delete(t)}catch{}}})});var Kw={};Vt(Kw,{AliasFS:()=>Uu,BasePortableFakeFS:()=>Mu,CustomDir:()=>qw,CwdFS:()=>gn,FakeFS:()=>hf,Filename:()=>dr,JailFS:()=>_u,LazyFS:()=>ny,MountFS:()=>Up,NoFS:()=>Gw,NodeFS:()=>Tn,PortablePath:()=>Bt,PosixFS:()=>_p,ProxiedFS:()=>Ps,VirtualFS:()=>mi,constants:()=>vi,errors:()=>ar,extendFs:()=>RD,normalizeLineEndings:()=>Mg,npath:()=>fe,opendir:()=>SD,patchFs:()=>Ww,ppath:()=>V,setupCopyIndex:()=>PD,statUtils:()=>Ea,unwatchAllFiles:()=>Og,unwatchFile:()=>Ng,watchFile:()=>ry,xfs:()=>oe});var Pt=yt(()=>{F7();BD();HR();GR();M7();YR();Ug();Ca();Ca();G7();Ug();W7();V7();z7();J7();X7();_g();Z7();gf();$7();sY();cY()});var hY=_((txt,pY)=>{pY.exports=fY;fY.sync=H_e;var uY=Be("fs");function __e(t,e){var r=e.pathExt!==void 0?e.pathExt:process.env.PATHEXT;if(!r||(r=r.split(";"),r.indexOf("")!==-1))return!0;for(var o=0;o<r.length;o++){var a=r[o].toLowerCase();if(a&&t.substr(-a.length).toLowerCase()===a)return!0}return!1}function AY(t,e,r){return!t.isSymbolicLink()&&!t.isFile()?!1:__e(e,r)}function fY(t,e,r){uY.stat(t,function(o,a){r(o,o?!1:AY(a,t,e))})}function H_e(t,e){return AY(uY.statSync(t),t,e)}});var EY=_((rxt,yY)=>{yY.exports=dY;dY.sync=j_e;var gY=Be("fs");function dY(t,e,r){gY.stat(t,function(o,a){r(o,o?!1:mY(a,e))})}function j_e(t,e){return mY(gY.statSync(t),e)}function mY(t,e){return t.isFile()&&q_e(t,e)}function q_e(t,e){var r=t.mode,o=t.uid,a=t.gid,n=e.uid!==void 0?e.uid:process.getuid&&process.getuid(),u=e.gid!==void 0?e.gid:process.getgid&&process.getgid(),A=parseInt("100",8),p=parseInt("010",8),h=parseInt("001",8),C=A|p,I=r&h||r&p&&a===u||r&A&&o===n||r&C&&n===0;return I}});var wY=_((ixt,CY)=>{var nxt=Be("fs"),TD;process.platform==="win32"||global.TESTING_WINDOWS?TD=hY():TD=EY();CY.exports=JR;JR.sync=G_e;function JR(t,e,r){if(typeof e=="function"&&(r=e,e={}),!r){if(typeof Promise!="function")throw new TypeError("callback not provided");return new Promise(function(o,a){JR(t,e||{},function(n,u){n?a(n):o(u)})})}TD(t,e||{},function(o,a){o&&(o.code==="EACCES"||e&&e.ignoreErrors)&&(o=null,a=!1),r(o,a)})}function G_e(t,e){try{return TD.sync(t,e||{})}catch(r){if(e&&e.ignoreErrors||r.code==="EACCES")return!1;throw r}}});var xY=_((sxt,SY)=>{var oy=process.platform==="win32"||process.env.OSTYPE==="cygwin"||process.env.OSTYPE==="msys",IY=Be("path"),Y_e=oy?";":":",BY=wY(),vY=t=>Object.assign(new Error(`not found: ${t}`),{code:"ENOENT"}),DY=(t,e)=>{let r=e.colon||Y_e,o=t.match(/\//)||oy&&t.match(/\\/)?[""]:[...oy?[process.cwd()]:[],...(e.path||process.env.PATH||"").split(r)],a=oy?e.pathExt||process.env.PATHEXT||".EXE;.CMD;.BAT;.COM":"",n=oy?a.split(r):[""];return oy&&t.indexOf(".")!==-1&&n[0]!==""&&n.unshift(""),{pathEnv:o,pathExt:n,pathExtExe:a}},PY=(t,e,r)=>{typeof e=="function"&&(r=e,e={}),e||(e={});let{pathEnv:o,pathExt:a,pathExtExe:n}=DY(t,e),u=[],A=h=>new Promise((C,I)=>{if(h===o.length)return e.all&&u.length?C(u):I(vY(t));let v=o[h],b=/^".*"$/.test(v)?v.slice(1,-1):v,E=IY.join(b,t),F=!b&&/^\.[\\\/]/.test(t)?t.slice(0,2)+E:E;C(p(F,h,0))}),p=(h,C,I)=>new Promise((v,b)=>{if(I===a.length)return v(A(C+1));let E=a[I];BY(h+E,{pathExt:n},(F,N)=>{if(!F&&N)if(e.all)u.push(h+E);else return v(h+E);return v(p(h,C,I+1))})});return r?A(0).then(h=>r(null,h),r):A(0)},W_e=(t,e)=>{e=e||{};let{pathEnv:r,pathExt:o,pathExtExe:a}=DY(t,e),n=[];for(let u=0;u<r.length;u++){let A=r[u],p=/^".*"$/.test(A)?A.slice(1,-1):A,h=IY.join(p,t),C=!p&&/^\.[\\\/]/.test(t)?t.slice(0,2)+h:h;for(let I=0;I<o.length;I++){let v=C+o[I];try{if(BY.sync(v,{pathExt:a}))if(e.all)n.push(v);else return v}catch{}}}if(e.all&&n.length)return n;if(e.nothrow)return null;throw vY(t)};SY.exports=PY;PY.sync=W_e});var kY=_((oxt,XR)=>{"use strict";var bY=(t={})=>{let e=t.env||process.env;return(t.platform||process.platform)!=="win32"?"PATH":Object.keys(e).reverse().find(o=>o.toUpperCase()==="PATH")||"Path"};XR.exports=bY;XR.exports.default=bY});var TY=_((axt,RY)=>{"use strict";var QY=Be("path"),K_e=xY(),V_e=kY();function FY(t,e){let r=t.options.env||process.env,o=process.cwd(),a=t.options.cwd!=null,n=a&&process.chdir!==void 0&&!process.chdir.disabled;if(n)try{process.chdir(t.options.cwd)}catch{}let u;try{u=K_e.sync(t.command,{path:r[V_e({env:r})],pathExt:e?QY.delimiter:void 0})}catch{}finally{n&&process.chdir(o)}return u&&(u=QY.resolve(a?t.options.cwd:"",u)),u}function z_e(t){return FY(t)||FY(t,!0)}RY.exports=z_e});var LY=_((lxt,$R)=>{"use strict";var ZR=/([()\][%!^"`<>&|;, *?])/g;function J_e(t){return t=t.replace(ZR,"^$1"),t}function X_e(t,e){return t=`${t}`,t=t.replace(/(\\*)"/g,'$1$1\\"'),t=t.replace(/(\\*)$/,"$1$1"),t=`"${t}"`,t=t.replace(ZR,"^$1"),e&&(t=t.replace(ZR,"^$1")),t}$R.exports.command=J_e;$R.exports.argument=X_e});var OY=_((cxt,NY)=>{"use strict";NY.exports=/^#!(.*)/});var UY=_((uxt,MY)=>{"use strict";var Z_e=OY();MY.exports=(t="")=>{let e=t.match(Z_e);if(!e)return null;let[r,o]=e[0].replace(/#! ?/,"").split(" "),a=r.split("/").pop();return a==="env"?o:o?`${a} ${o}`:a}});var HY=_((Axt,_Y)=>{"use strict";var eT=Be("fs"),$_e=UY();function e8e(t){let r=Buffer.alloc(150),o;try{o=eT.openSync(t,"r"),eT.readSync(o,r,0,150,0),eT.closeSync(o)}catch{}return $_e(r.toString())}_Y.exports=e8e});var YY=_((fxt,GY)=>{"use strict";var t8e=Be("path"),jY=TY(),qY=LY(),r8e=HY(),n8e=process.platform==="win32",i8e=/\.(?:com|exe)$/i,s8e=/node_modules[\\/].bin[\\/][^\\/]+\.cmd$/i;function o8e(t){t.file=jY(t);let e=t.file&&r8e(t.file);return e?(t.args.unshift(t.file),t.command=e,jY(t)):t.file}function a8e(t){if(!n8e)return t;let e=o8e(t),r=!i8e.test(e);if(t.options.forceShell||r){let o=s8e.test(e);t.command=t8e.normalize(t.command),t.command=qY.command(t.command),t.args=t.args.map(n=>qY.argument(n,o));let a=[t.command].concat(t.args).join(" ");t.args=["/d","/s","/c",`"${a}"`],t.command=process.env.comspec||"cmd.exe",t.options.windowsVerbatimArguments=!0}return t}function l8e(t,e,r){e&&!Array.isArray(e)&&(r=e,e=null),e=e?e.slice(0):[],r=Object.assign({},r);let o={command:t,args:e,options:r,file:void 0,original:{command:t,args:e}};return r.shell?o:a8e(o)}GY.exports=l8e});var VY=_((pxt,KY)=>{"use strict";var tT=process.platform==="win32";function rT(t,e){return Object.assign(new Error(`${e} ${t.command} ENOENT`),{code:"ENOENT",errno:"ENOENT",syscall:`${e} ${t.command}`,path:t.command,spawnargs:t.args})}function c8e(t,e){if(!tT)return;let r=t.emit;t.emit=function(o,a){if(o==="exit"){let n=WY(a,e,"spawn");if(n)return r.call(t,"error",n)}return r.apply(t,arguments)}}function WY(t,e){return tT&&t===1&&!e.file?rT(e.original,"spawn"):null}function u8e(t,e){return tT&&t===1&&!e.file?rT(e.original,"spawnSync"):null}KY.exports={hookChildProcess:c8e,verifyENOENT:WY,verifyENOENTSync:u8e,notFoundError:rT}});var sT=_((hxt,ay)=>{"use strict";var zY=Be("child_process"),nT=YY(),iT=VY();function JY(t,e,r){let o=nT(t,e,r),a=zY.spawn(o.command,o.args,o.options);return iT.hookChildProcess(a,o),a}function A8e(t,e,r){let o=nT(t,e,r),a=zY.spawnSync(o.command,o.args,o.options);return a.error=a.error||iT.verifyENOENTSync(a.status,o),a}ay.exports=JY;ay.exports.spawn=JY;ay.exports.sync=A8e;ay.exports._parse=nT;ay.exports._enoent=iT});var ZY=_((gxt,XY)=>{"use strict";function f8e(t,e){function r(){this.constructor=t}r.prototype=e.prototype,t.prototype=new r}function jg(t,e,r,o){this.message=t,this.expected=e,this.found=r,this.location=o,this.name="SyntaxError",typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(this,jg)}f8e(jg,Error);jg.buildMessage=function(t,e){var r={literal:function(h){return'"'+a(h.text)+'"'},class:function(h){var C="",I;for(I=0;I<h.parts.length;I++)C+=h.parts[I]instanceof Array?n(h.parts[I][0])+"-"+n(h.parts[I][1]):n(h.parts[I]);return"["+(h.inverted?"^":"")+C+"]"},any:function(h){return"any character"},end:function(h){return"end of input"},other:function(h){return h.description}};function o(h){return h.charCodeAt(0).toString(16).toUpperCase()}function a(h){return h.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(C){return"\\x0"+o(C)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(C){return"\\x"+o(C)})}function n(h){return h.replace(/\\/g,"\\\\").replace(/\]/g,"\\]").replace(/\^/g,"\\^").replace(/-/g,"\\-").replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(C){return"\\x0"+o(C)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(C){return"\\x"+o(C)})}function u(h){return r[h.type](h)}function A(h){var C=new Array(h.length),I,v;for(I=0;I<h.length;I++)C[I]=u(h[I]);if(C.sort(),C.length>0){for(I=1,v=1;I<C.length;I++)C[I-1]!==C[I]&&(C[v]=C[I],v++);C.length=v}switch(C.length){case 1:return C[0];case 2:return C[0]+" or "+C[1];default:return C.slice(0,-1).join(", ")+", or "+C[C.length-1]}}function p(h){return h?'"'+a(h)+'"':"end of input"}return"Expected "+A(t)+" but "+p(e)+" found."};function p8e(t,e){e=e!==void 0?e:{};var r={},o={Start:fg},a=fg,n=function(L){return L||[]},u=function(L,K,re){return[{command:L,type:K}].concat(re||[])},A=function(L,K){return[{command:L,type:K||";"}]},p=function(L){return L},h=";",C=Br(";",!1),I="&",v=Br("&",!1),b=function(L,K){return K?{chain:L,then:K}:{chain:L}},E=function(L,K){return{type:L,line:K}},F="&&",N=Br("&&",!1),U="||",z=Br("||",!1),te=function(L,K){return K?{...L,then:K}:L},le=function(L,K){return{type:L,chain:K}},pe="|&",ue=Br("|&",!1),ye="|",ae=Br("|",!1),Ie="=",Fe=Br("=",!1),g=function(L,K){return{name:L,args:[K]}},Ee=function(L){return{name:L,args:[]}},De="(",ce=Br("(",!1),ne=")",ee=Br(")",!1),we=function(L,K){return{type:"subshell",subshell:L,args:K}},be="{",ht=Br("{",!1),H="}",lt=Br("}",!1),Te=function(L,K){return{type:"group",group:L,args:K}},ke=function(L,K){return{type:"command",args:K,envs:L}},xe=function(L){return{type:"envs",envs:L}},He=function(L){return L},Re=function(L){return L},ze=/^[0-9]/,je=Cs([["0","9"]],!1,!1),x=function(L,K,re){return{type:"redirection",subtype:K,fd:L!==null?parseInt(L):null,args:[re]}},w=">>",S=Br(">>",!1),y=">&",R=Br(">&",!1),J=">",X=Br(">",!1),Z="<<<",ie=Br("<<<",!1),Pe="<&",Le=Br("<&",!1),ot="<",dt=Br("<",!1),jt=function(L){return{type:"argument",segments:[].concat(...L)}},$t=function(L){return L},xt="$'",an=Br("$'",!1),kr="'",mr=Br("'",!1),xr=function(L){return[{type:"text",text:L}]},Wr='""',Kn=Br('""',!1),Ns=function(){return{type:"text",text:""}},Ti='"',ps=Br('"',!1),io=function(L){return L},Si=function(L){return{type:"arithmetic",arithmetic:L,quoted:!0}},Os=function(L){return{type:"shell",shell:L,quoted:!0}},so=function(L){return{type:"variable",...L,quoted:!0}},cc=function(L){return{type:"text",text:L}},cu=function(L){return{type:"arithmetic",arithmetic:L,quoted:!1}},op=function(L){return{type:"shell",shell:L,quoted:!1}},ap=function(L){return{type:"variable",...L,quoted:!1}},Ms=function(L){return{type:"glob",pattern:L}},Dn=/^[^']/,oo=Cs(["'"],!0,!1),Us=function(L){return L.join("")},ml=/^[^$"]/,yl=Cs(["$",'"'],!0,!1),ao=`\\ -`,Vn=Br(`\\ -`,!1),On=function(){return""},Li="\\",Mn=Br("\\",!1),_i=/^[\\$"`]/,tr=Cs(["\\","$",'"',"`"],!1,!1),Oe=function(L){return L},ii="\\a",Ma=Br("\\a",!1),hr=function(){return"a"},uc="\\b",uu=Br("\\b",!1),Ac=function(){return"\b"},El=/^[Ee]/,vA=Cs(["E","e"],!1,!1),Au=function(){return"\x1B"},Ce="\\f",Rt=Br("\\f",!1),fc=function(){return"\f"},Hi="\\n",fu=Br("\\n",!1),Yt=function(){return` -`},Cl="\\r",DA=Br("\\r",!1),lp=function(){return"\r"},pc="\\t",PA=Br("\\t",!1),Qn=function(){return" "},hi="\\v",hc=Br("\\v",!1),SA=function(){return"\v"},sa=/^[\\'"?]/,Ni=Cs(["\\","'",'"',"?"],!1,!1),_o=function(L){return String.fromCharCode(parseInt(L,16))},Ze="\\x",lo=Br("\\x",!1),gc="\\u",pu=Br("\\u",!1),ji="\\U",hu=Br("\\U",!1),xA=function(L){return String.fromCodePoint(parseInt(L,16))},Ua=/^[0-7]/,dc=Cs([["0","7"]],!1,!1),hs=/^[0-9a-fA-f]/,Ut=Cs([["0","9"],["a","f"],["A","f"]],!1,!1),Fn=lg(),Ci="{}",oa=Br("{}",!1),co=function(){return"{}"},_s="-",aa=Br("-",!1),la="+",Ho=Br("+",!1),wi=".",gs=Br(".",!1),ds=function(L,K,re){return{type:"number",value:(L==="-"?-1:1)*parseFloat(K.join("")+"."+re.join(""))}},ms=function(L,K){return{type:"number",value:(L==="-"?-1:1)*parseInt(K.join(""))}},Hs=function(L){return{type:"variable",...L}},Un=function(L){return{type:"variable",name:L}},Pn=function(L){return L},ys="*",We=Br("*",!1),tt="/",It=Br("/",!1),nr=function(L,K,re){return{type:K==="*"?"multiplication":"division",right:re}},$=function(L,K){return K.reduce((re,he)=>({left:re,...he}),L)},me=function(L,K,re){return{type:K==="+"?"addition":"subtraction",right:re}},Ne="$((",ft=Br("$((",!1),pt="))",Tt=Br("))",!1),er=function(L){return L},Zr="$(",qi=Br("$(",!1),es=function(L){return L},xi="${",jo=Br("${",!1),bA=":-",kA=Br(":-",!1),cp=function(L,K){return{name:L,defaultValue:K}},rg=":-}",gu=Br(":-}",!1),ng=function(L){return{name:L,defaultValue:[]}},du=":+",uo=Br(":+",!1),QA=function(L,K){return{name:L,alternativeValue:K}},mc=":+}",ca=Br(":+}",!1),ig=function(L){return{name:L,alternativeValue:[]}},yc=function(L){return{name:L}},Pm="$",sg=Br("$",!1),$n=function(L){return e.isGlobPattern(L)},up=function(L){return L},og=/^[a-zA-Z0-9_]/,FA=Cs([["a","z"],["A","Z"],["0","9"],"_"],!1,!1),js=function(){return ag()},mu=/^[$@*?#a-zA-Z0-9_\-]/,Ha=Cs(["$","@","*","?","#",["a","z"],["A","Z"],["0","9"],"_","-"],!1,!1),Gi=/^[()}<>$|&; \t"']/,ua=Cs(["(",")","}","<",">","$","|","&",";"," "," ",'"',"'"],!1,!1),yu=/^[<>&; \t"']/,Es=Cs(["<",">","&",";"," "," ",'"',"'"],!1,!1),Ec=/^[ \t]/,Cc=Cs([" "," "],!1,!1),Y=0,Dt=0,wl=[{line:1,column:1}],bi=0,wc=[],ct=0,Eu;if("startRule"in e){if(!(e.startRule in o))throw new Error(`Can't start parsing from rule "`+e.startRule+'".');a=o[e.startRule]}function ag(){return t.substring(Dt,Y)}function mw(){return Ic(Dt,Y)}function RA(L,K){throw K=K!==void 0?K:Ic(Dt,Y),Ag([ug(L)],t.substring(Dt,Y),K)}function Ap(L,K){throw K=K!==void 0?K:Ic(Dt,Y),Sm(L,K)}function Br(L,K){return{type:"literal",text:L,ignoreCase:K}}function Cs(L,K,re){return{type:"class",parts:L,inverted:K,ignoreCase:re}}function lg(){return{type:"any"}}function cg(){return{type:"end"}}function ug(L){return{type:"other",description:L}}function fp(L){var K=wl[L],re;if(K)return K;for(re=L-1;!wl[re];)re--;for(K=wl[re],K={line:K.line,column:K.column};re<L;)t.charCodeAt(re)===10?(K.line++,K.column=1):K.column++,re++;return wl[L]=K,K}function Ic(L,K){var re=fp(L),he=fp(K);return{start:{offset:L,line:re.line,column:re.column},end:{offset:K,line:he.line,column:he.column}}}function Ct(L){Y<bi||(Y>bi&&(bi=Y,wc=[]),wc.push(L))}function Sm(L,K){return new jg(L,null,null,K)}function Ag(L,K,re){return new jg(jg.buildMessage(L,K),L,K,re)}function fg(){var L,K,re;for(L=Y,K=[],re=Qt();re!==r;)K.push(re),re=Qt();return K!==r?(re=Cu(),re===r&&(re=null),re!==r?(Dt=L,K=n(re),L=K):(Y=L,L=r)):(Y=L,L=r),L}function Cu(){var L,K,re,he,Je;if(L=Y,K=wu(),K!==r){for(re=[],he=Qt();he!==r;)re.push(he),he=Qt();re!==r?(he=pg(),he!==r?(Je=xm(),Je===r&&(Je=null),Je!==r?(Dt=L,K=u(K,he,Je),L=K):(Y=L,L=r)):(Y=L,L=r)):(Y=L,L=r)}else Y=L,L=r;if(L===r)if(L=Y,K=wu(),K!==r){for(re=[],he=Qt();he!==r;)re.push(he),he=Qt();re!==r?(he=pg(),he===r&&(he=null),he!==r?(Dt=L,K=A(K,he),L=K):(Y=L,L=r)):(Y=L,L=r)}else Y=L,L=r;return L}function xm(){var L,K,re,he,Je;for(L=Y,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r)if(re=Cu(),re!==r){for(he=[],Je=Qt();Je!==r;)he.push(Je),Je=Qt();he!==r?(Dt=L,K=p(re),L=K):(Y=L,L=r)}else Y=L,L=r;else Y=L,L=r;return L}function pg(){var L;return t.charCodeAt(Y)===59?(L=h,Y++):(L=r,ct===0&&Ct(C)),L===r&&(t.charCodeAt(Y)===38?(L=I,Y++):(L=r,ct===0&&Ct(v))),L}function wu(){var L,K,re;return L=Y,K=Aa(),K!==r?(re=yw(),re===r&&(re=null),re!==r?(Dt=L,K=b(K,re),L=K):(Y=L,L=r)):(Y=L,L=r),L}function yw(){var L,K,re,he,Je,mt,fr;for(L=Y,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r)if(re=bm(),re!==r){for(he=[],Je=Qt();Je!==r;)he.push(Je),Je=Qt();if(he!==r)if(Je=wu(),Je!==r){for(mt=[],fr=Qt();fr!==r;)mt.push(fr),fr=Qt();mt!==r?(Dt=L,K=E(re,Je),L=K):(Y=L,L=r)}else Y=L,L=r;else Y=L,L=r}else Y=L,L=r;else Y=L,L=r;return L}function bm(){var L;return t.substr(Y,2)===F?(L=F,Y+=2):(L=r,ct===0&&Ct(N)),L===r&&(t.substr(Y,2)===U?(L=U,Y+=2):(L=r,ct===0&&Ct(z))),L}function Aa(){var L,K,re;return L=Y,K=hg(),K!==r?(re=Bc(),re===r&&(re=null),re!==r?(Dt=L,K=te(K,re),L=K):(Y=L,L=r)):(Y=L,L=r),L}function Bc(){var L,K,re,he,Je,mt,fr;for(L=Y,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r)if(re=Il(),re!==r){for(he=[],Je=Qt();Je!==r;)he.push(Je),Je=Qt();if(he!==r)if(Je=Aa(),Je!==r){for(mt=[],fr=Qt();fr!==r;)mt.push(fr),fr=Qt();mt!==r?(Dt=L,K=le(re,Je),L=K):(Y=L,L=r)}else Y=L,L=r;else Y=L,L=r}else Y=L,L=r;else Y=L,L=r;return L}function Il(){var L;return t.substr(Y,2)===pe?(L=pe,Y+=2):(L=r,ct===0&&Ct(ue)),L===r&&(t.charCodeAt(Y)===124?(L=ye,Y++):(L=r,ct===0&&Ct(ae))),L}function Iu(){var L,K,re,he,Je,mt;if(L=Y,K=yg(),K!==r)if(t.charCodeAt(Y)===61?(re=Ie,Y++):(re=r,ct===0&&Ct(Fe)),re!==r)if(he=qo(),he!==r){for(Je=[],mt=Qt();mt!==r;)Je.push(mt),mt=Qt();Je!==r?(Dt=L,K=g(K,he),L=K):(Y=L,L=r)}else Y=L,L=r;else Y=L,L=r;else Y=L,L=r;if(L===r)if(L=Y,K=yg(),K!==r)if(t.charCodeAt(Y)===61?(re=Ie,Y++):(re=r,ct===0&&Ct(Fe)),re!==r){for(he=[],Je=Qt();Je!==r;)he.push(Je),Je=Qt();he!==r?(Dt=L,K=Ee(K),L=K):(Y=L,L=r)}else Y=L,L=r;else Y=L,L=r;return L}function hg(){var L,K,re,he,Je,mt,fr,Cr,yn,oi,Oi;for(L=Y,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r)if(t.charCodeAt(Y)===40?(re=De,Y++):(re=r,ct===0&&Ct(ce)),re!==r){for(he=[],Je=Qt();Je!==r;)he.push(Je),Je=Qt();if(he!==r)if(Je=Cu(),Je!==r){for(mt=[],fr=Qt();fr!==r;)mt.push(fr),fr=Qt();if(mt!==r)if(t.charCodeAt(Y)===41?(fr=ne,Y++):(fr=r,ct===0&&Ct(ee)),fr!==r){for(Cr=[],yn=Qt();yn!==r;)Cr.push(yn),yn=Qt();if(Cr!==r){for(yn=[],oi=ja();oi!==r;)yn.push(oi),oi=ja();if(yn!==r){for(oi=[],Oi=Qt();Oi!==r;)oi.push(Oi),Oi=Qt();oi!==r?(Dt=L,K=we(Je,yn),L=K):(Y=L,L=r)}else Y=L,L=r}else Y=L,L=r}else Y=L,L=r;else Y=L,L=r}else Y=L,L=r;else Y=L,L=r}else Y=L,L=r;else Y=L,L=r;if(L===r){for(L=Y,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r)if(t.charCodeAt(Y)===123?(re=be,Y++):(re=r,ct===0&&Ct(ht)),re!==r){for(he=[],Je=Qt();Je!==r;)he.push(Je),Je=Qt();if(he!==r)if(Je=Cu(),Je!==r){for(mt=[],fr=Qt();fr!==r;)mt.push(fr),fr=Qt();if(mt!==r)if(t.charCodeAt(Y)===125?(fr=H,Y++):(fr=r,ct===0&&Ct(lt)),fr!==r){for(Cr=[],yn=Qt();yn!==r;)Cr.push(yn),yn=Qt();if(Cr!==r){for(yn=[],oi=ja();oi!==r;)yn.push(oi),oi=ja();if(yn!==r){for(oi=[],Oi=Qt();Oi!==r;)oi.push(Oi),Oi=Qt();oi!==r?(Dt=L,K=Te(Je,yn),L=K):(Y=L,L=r)}else Y=L,L=r}else Y=L,L=r}else Y=L,L=r;else Y=L,L=r}else Y=L,L=r;else Y=L,L=r}else Y=L,L=r;else Y=L,L=r;if(L===r){for(L=Y,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r){for(re=[],he=Iu();he!==r;)re.push(he),he=Iu();if(re!==r){for(he=[],Je=Qt();Je!==r;)he.push(Je),Je=Qt();if(he!==r){if(Je=[],mt=pp(),mt!==r)for(;mt!==r;)Je.push(mt),mt=pp();else Je=r;if(Je!==r){for(mt=[],fr=Qt();fr!==r;)mt.push(fr),fr=Qt();mt!==r?(Dt=L,K=ke(re,Je),L=K):(Y=L,L=r)}else Y=L,L=r}else Y=L,L=r}else Y=L,L=r}else Y=L,L=r;if(L===r){for(L=Y,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r){if(re=[],he=Iu(),he!==r)for(;he!==r;)re.push(he),he=Iu();else re=r;if(re!==r){for(he=[],Je=Qt();Je!==r;)he.push(Je),Je=Qt();he!==r?(Dt=L,K=xe(re),L=K):(Y=L,L=r)}else Y=L,L=r}else Y=L,L=r}}}return L}function TA(){var L,K,re,he,Je;for(L=Y,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r){if(re=[],he=hp(),he!==r)for(;he!==r;)re.push(he),he=hp();else re=r;if(re!==r){for(he=[],Je=Qt();Je!==r;)he.push(Je),Je=Qt();he!==r?(Dt=L,K=He(re),L=K):(Y=L,L=r)}else Y=L,L=r}else Y=L,L=r;return L}function pp(){var L,K,re;for(L=Y,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r?(re=ja(),re!==r?(Dt=L,K=Re(re),L=K):(Y=L,L=r)):(Y=L,L=r),L===r){for(L=Y,K=[],re=Qt();re!==r;)K.push(re),re=Qt();K!==r?(re=hp(),re!==r?(Dt=L,K=Re(re),L=K):(Y=L,L=r)):(Y=L,L=r)}return L}function ja(){var L,K,re,he,Je;for(L=Y,K=[],re=Qt();re!==r;)K.push(re),re=Qt();return K!==r?(ze.test(t.charAt(Y))?(re=t.charAt(Y),Y++):(re=r,ct===0&&Ct(je)),re===r&&(re=null),re!==r?(he=gg(),he!==r?(Je=hp(),Je!==r?(Dt=L,K=x(re,he,Je),L=K):(Y=L,L=r)):(Y=L,L=r)):(Y=L,L=r)):(Y=L,L=r),L}function gg(){var L;return t.substr(Y,2)===w?(L=w,Y+=2):(L=r,ct===0&&Ct(S)),L===r&&(t.substr(Y,2)===y?(L=y,Y+=2):(L=r,ct===0&&Ct(R)),L===r&&(t.charCodeAt(Y)===62?(L=J,Y++):(L=r,ct===0&&Ct(X)),L===r&&(t.substr(Y,3)===Z?(L=Z,Y+=3):(L=r,ct===0&&Ct(ie)),L===r&&(t.substr(Y,2)===Pe?(L=Pe,Y+=2):(L=r,ct===0&&Ct(Le)),L===r&&(t.charCodeAt(Y)===60?(L=ot,Y++):(L=r,ct===0&&Ct(dt))))))),L}function hp(){var L,K,re;for(L=Y,K=[],re=Qt();re!==r;)K.push(re),re=Qt();return K!==r?(re=qo(),re!==r?(Dt=L,K=Re(re),L=K):(Y=L,L=r)):(Y=L,L=r),L}function qo(){var L,K,re;if(L=Y,K=[],re=ws(),re!==r)for(;re!==r;)K.push(re),re=ws();else K=r;return K!==r&&(Dt=L,K=jt(K)),L=K,L}function ws(){var L,K;return L=Y,K=Ii(),K!==r&&(Dt=L,K=$t(K)),L=K,L===r&&(L=Y,K=km(),K!==r&&(Dt=L,K=$t(K)),L=K,L===r&&(L=Y,K=Qm(),K!==r&&(Dt=L,K=$t(K)),L=K,L===r&&(L=Y,K=Go(),K!==r&&(Dt=L,K=$t(K)),L=K))),L}function Ii(){var L,K,re,he;return L=Y,t.substr(Y,2)===xt?(K=xt,Y+=2):(K=r,ct===0&&Ct(an)),K!==r?(re=ln(),re!==r?(t.charCodeAt(Y)===39?(he=kr,Y++):(he=r,ct===0&&Ct(mr)),he!==r?(Dt=L,K=xr(re),L=K):(Y=L,L=r)):(Y=L,L=r)):(Y=L,L=r),L}function km(){var L,K,re,he;return L=Y,t.charCodeAt(Y)===39?(K=kr,Y++):(K=r,ct===0&&Ct(mr)),K!==r?(re=dp(),re!==r?(t.charCodeAt(Y)===39?(he=kr,Y++):(he=r,ct===0&&Ct(mr)),he!==r?(Dt=L,K=xr(re),L=K):(Y=L,L=r)):(Y=L,L=r)):(Y=L,L=r),L}function Qm(){var L,K,re,he;if(L=Y,t.substr(Y,2)===Wr?(K=Wr,Y+=2):(K=r,ct===0&&Ct(Kn)),K!==r&&(Dt=L,K=Ns()),L=K,L===r)if(L=Y,t.charCodeAt(Y)===34?(K=Ti,Y++):(K=r,ct===0&&Ct(ps)),K!==r){for(re=[],he=LA();he!==r;)re.push(he),he=LA();re!==r?(t.charCodeAt(Y)===34?(he=Ti,Y++):(he=r,ct===0&&Ct(ps)),he!==r?(Dt=L,K=io(re),L=K):(Y=L,L=r)):(Y=L,L=r)}else Y=L,L=r;return L}function Go(){var L,K,re;if(L=Y,K=[],re=gp(),re!==r)for(;re!==r;)K.push(re),re=gp();else K=r;return K!==r&&(Dt=L,K=io(K)),L=K,L}function LA(){var L,K;return L=Y,K=Gr(),K!==r&&(Dt=L,K=Si(K)),L=K,L===r&&(L=Y,K=mp(),K!==r&&(Dt=L,K=Os(K)),L=K,L===r&&(L=Y,K=Dc(),K!==r&&(Dt=L,K=so(K)),L=K,L===r&&(L=Y,K=dg(),K!==r&&(Dt=L,K=cc(K)),L=K))),L}function gp(){var L,K;return L=Y,K=Gr(),K!==r&&(Dt=L,K=cu(K)),L=K,L===r&&(L=Y,K=mp(),K!==r&&(Dt=L,K=op(K)),L=K,L===r&&(L=Y,K=Dc(),K!==r&&(Dt=L,K=ap(K)),L=K,L===r&&(L=Y,K=Ew(),K!==r&&(Dt=L,K=Ms(K)),L=K,L===r&&(L=Y,K=pa(),K!==r&&(Dt=L,K=cc(K)),L=K)))),L}function dp(){var L,K,re;for(L=Y,K=[],Dn.test(t.charAt(Y))?(re=t.charAt(Y),Y++):(re=r,ct===0&&Ct(oo));re!==r;)K.push(re),Dn.test(t.charAt(Y))?(re=t.charAt(Y),Y++):(re=r,ct===0&&Ct(oo));return K!==r&&(Dt=L,K=Us(K)),L=K,L}function dg(){var L,K,re;if(L=Y,K=[],re=fa(),re===r&&(ml.test(t.charAt(Y))?(re=t.charAt(Y),Y++):(re=r,ct===0&&Ct(yl))),re!==r)for(;re!==r;)K.push(re),re=fa(),re===r&&(ml.test(t.charAt(Y))?(re=t.charAt(Y),Y++):(re=r,ct===0&&Ct(yl)));else K=r;return K!==r&&(Dt=L,K=Us(K)),L=K,L}function fa(){var L,K,re;return L=Y,t.substr(Y,2)===ao?(K=ao,Y+=2):(K=r,ct===0&&Ct(Vn)),K!==r&&(Dt=L,K=On()),L=K,L===r&&(L=Y,t.charCodeAt(Y)===92?(K=Li,Y++):(K=r,ct===0&&Ct(Mn)),K!==r?(_i.test(t.charAt(Y))?(re=t.charAt(Y),Y++):(re=r,ct===0&&Ct(tr)),re!==r?(Dt=L,K=Oe(re),L=K):(Y=L,L=r)):(Y=L,L=r)),L}function ln(){var L,K,re;for(L=Y,K=[],re=Ao(),re===r&&(Dn.test(t.charAt(Y))?(re=t.charAt(Y),Y++):(re=r,ct===0&&Ct(oo)));re!==r;)K.push(re),re=Ao(),re===r&&(Dn.test(t.charAt(Y))?(re=t.charAt(Y),Y++):(re=r,ct===0&&Ct(oo)));return K!==r&&(Dt=L,K=Us(K)),L=K,L}function Ao(){var L,K,re;return L=Y,t.substr(Y,2)===ii?(K=ii,Y+=2):(K=r,ct===0&&Ct(Ma)),K!==r&&(Dt=L,K=hr()),L=K,L===r&&(L=Y,t.substr(Y,2)===uc?(K=uc,Y+=2):(K=r,ct===0&&Ct(uu)),K!==r&&(Dt=L,K=Ac()),L=K,L===r&&(L=Y,t.charCodeAt(Y)===92?(K=Li,Y++):(K=r,ct===0&&Ct(Mn)),K!==r?(El.test(t.charAt(Y))?(re=t.charAt(Y),Y++):(re=r,ct===0&&Ct(vA)),re!==r?(Dt=L,K=Au(),L=K):(Y=L,L=r)):(Y=L,L=r),L===r&&(L=Y,t.substr(Y,2)===Ce?(K=Ce,Y+=2):(K=r,ct===0&&Ct(Rt)),K!==r&&(Dt=L,K=fc()),L=K,L===r&&(L=Y,t.substr(Y,2)===Hi?(K=Hi,Y+=2):(K=r,ct===0&&Ct(fu)),K!==r&&(Dt=L,K=Yt()),L=K,L===r&&(L=Y,t.substr(Y,2)===Cl?(K=Cl,Y+=2):(K=r,ct===0&&Ct(DA)),K!==r&&(Dt=L,K=lp()),L=K,L===r&&(L=Y,t.substr(Y,2)===pc?(K=pc,Y+=2):(K=r,ct===0&&Ct(PA)),K!==r&&(Dt=L,K=Qn()),L=K,L===r&&(L=Y,t.substr(Y,2)===hi?(K=hi,Y+=2):(K=r,ct===0&&Ct(hc)),K!==r&&(Dt=L,K=SA()),L=K,L===r&&(L=Y,t.charCodeAt(Y)===92?(K=Li,Y++):(K=r,ct===0&&Ct(Mn)),K!==r?(sa.test(t.charAt(Y))?(re=t.charAt(Y),Y++):(re=r,ct===0&&Ct(Ni)),re!==r?(Dt=L,K=Oe(re),L=K):(Y=L,L=r)):(Y=L,L=r),L===r&&(L=NA()))))))))),L}function NA(){var L,K,re,he,Je,mt,fr,Cr,yn,oi,Oi,Cg;return L=Y,t.charCodeAt(Y)===92?(K=Li,Y++):(K=r,ct===0&&Ct(Mn)),K!==r?(re=qa(),re!==r?(Dt=L,K=_o(re),L=K):(Y=L,L=r)):(Y=L,L=r),L===r&&(L=Y,t.substr(Y,2)===Ze?(K=Ze,Y+=2):(K=r,ct===0&&Ct(lo)),K!==r?(re=Y,he=Y,Je=qa(),Je!==r?(mt=si(),mt!==r?(Je=[Je,mt],he=Je):(Y=he,he=r)):(Y=he,he=r),he===r&&(he=qa()),he!==r?re=t.substring(re,Y):re=he,re!==r?(Dt=L,K=_o(re),L=K):(Y=L,L=r)):(Y=L,L=r),L===r&&(L=Y,t.substr(Y,2)===gc?(K=gc,Y+=2):(K=r,ct===0&&Ct(pu)),K!==r?(re=Y,he=Y,Je=si(),Je!==r?(mt=si(),mt!==r?(fr=si(),fr!==r?(Cr=si(),Cr!==r?(Je=[Je,mt,fr,Cr],he=Je):(Y=he,he=r)):(Y=he,he=r)):(Y=he,he=r)):(Y=he,he=r),he!==r?re=t.substring(re,Y):re=he,re!==r?(Dt=L,K=_o(re),L=K):(Y=L,L=r)):(Y=L,L=r),L===r&&(L=Y,t.substr(Y,2)===ji?(K=ji,Y+=2):(K=r,ct===0&&Ct(hu)),K!==r?(re=Y,he=Y,Je=si(),Je!==r?(mt=si(),mt!==r?(fr=si(),fr!==r?(Cr=si(),Cr!==r?(yn=si(),yn!==r?(oi=si(),oi!==r?(Oi=si(),Oi!==r?(Cg=si(),Cg!==r?(Je=[Je,mt,fr,Cr,yn,oi,Oi,Cg],he=Je):(Y=he,he=r)):(Y=he,he=r)):(Y=he,he=r)):(Y=he,he=r)):(Y=he,he=r)):(Y=he,he=r)):(Y=he,he=r)):(Y=he,he=r),he!==r?re=t.substring(re,Y):re=he,re!==r?(Dt=L,K=xA(re),L=K):(Y=L,L=r)):(Y=L,L=r)))),L}function qa(){var L;return Ua.test(t.charAt(Y))?(L=t.charAt(Y),Y++):(L=r,ct===0&&Ct(dc)),L}function si(){var L;return hs.test(t.charAt(Y))?(L=t.charAt(Y),Y++):(L=r,ct===0&&Ct(Ut)),L}function pa(){var L,K,re,he,Je;if(L=Y,K=[],re=Y,t.charCodeAt(Y)===92?(he=Li,Y++):(he=r,ct===0&&Ct(Mn)),he!==r?(t.length>Y?(Je=t.charAt(Y),Y++):(Je=r,ct===0&&Ct(Fn)),Je!==r?(Dt=re,he=Oe(Je),re=he):(Y=re,re=r)):(Y=re,re=r),re===r&&(re=Y,t.substr(Y,2)===Ci?(he=Ci,Y+=2):(he=r,ct===0&&Ct(oa)),he!==r&&(Dt=re,he=co()),re=he,re===r&&(re=Y,he=Y,ct++,Je=Fm(),ct--,Je===r?he=void 0:(Y=he,he=r),he!==r?(t.length>Y?(Je=t.charAt(Y),Y++):(Je=r,ct===0&&Ct(Fn)),Je!==r?(Dt=re,he=Oe(Je),re=he):(Y=re,re=r)):(Y=re,re=r))),re!==r)for(;re!==r;)K.push(re),re=Y,t.charCodeAt(Y)===92?(he=Li,Y++):(he=r,ct===0&&Ct(Mn)),he!==r?(t.length>Y?(Je=t.charAt(Y),Y++):(Je=r,ct===0&&Ct(Fn)),Je!==r?(Dt=re,he=Oe(Je),re=he):(Y=re,re=r)):(Y=re,re=r),re===r&&(re=Y,t.substr(Y,2)===Ci?(he=Ci,Y+=2):(he=r,ct===0&&Ct(oa)),he!==r&&(Dt=re,he=co()),re=he,re===r&&(re=Y,he=Y,ct++,Je=Fm(),ct--,Je===r?he=void 0:(Y=he,he=r),he!==r?(t.length>Y?(Je=t.charAt(Y),Y++):(Je=r,ct===0&&Ct(Fn)),Je!==r?(Dt=re,he=Oe(Je),re=he):(Y=re,re=r)):(Y=re,re=r)));else K=r;return K!==r&&(Dt=L,K=Us(K)),L=K,L}function vc(){var L,K,re,he,Je,mt;if(L=Y,t.charCodeAt(Y)===45?(K=_s,Y++):(K=r,ct===0&&Ct(aa)),K===r&&(t.charCodeAt(Y)===43?(K=la,Y++):(K=r,ct===0&&Ct(Ho))),K===r&&(K=null),K!==r){if(re=[],ze.test(t.charAt(Y))?(he=t.charAt(Y),Y++):(he=r,ct===0&&Ct(je)),he!==r)for(;he!==r;)re.push(he),ze.test(t.charAt(Y))?(he=t.charAt(Y),Y++):(he=r,ct===0&&Ct(je));else re=r;if(re!==r)if(t.charCodeAt(Y)===46?(he=wi,Y++):(he=r,ct===0&&Ct(gs)),he!==r){if(Je=[],ze.test(t.charAt(Y))?(mt=t.charAt(Y),Y++):(mt=r,ct===0&&Ct(je)),mt!==r)for(;mt!==r;)Je.push(mt),ze.test(t.charAt(Y))?(mt=t.charAt(Y),Y++):(mt=r,ct===0&&Ct(je));else Je=r;Je!==r?(Dt=L,K=ds(K,re,Je),L=K):(Y=L,L=r)}else Y=L,L=r;else Y=L,L=r}else Y=L,L=r;if(L===r){if(L=Y,t.charCodeAt(Y)===45?(K=_s,Y++):(K=r,ct===0&&Ct(aa)),K===r&&(t.charCodeAt(Y)===43?(K=la,Y++):(K=r,ct===0&&Ct(Ho))),K===r&&(K=null),K!==r){if(re=[],ze.test(t.charAt(Y))?(he=t.charAt(Y),Y++):(he=r,ct===0&&Ct(je)),he!==r)for(;he!==r;)re.push(he),ze.test(t.charAt(Y))?(he=t.charAt(Y),Y++):(he=r,ct===0&&Ct(je));else re=r;re!==r?(Dt=L,K=ms(K,re),L=K):(Y=L,L=r)}else Y=L,L=r;if(L===r&&(L=Y,K=Dc(),K!==r&&(Dt=L,K=Hs(K)),L=K,L===r&&(L=Y,K=Ga(),K!==r&&(Dt=L,K=Un(K)),L=K,L===r)))if(L=Y,t.charCodeAt(Y)===40?(K=De,Y++):(K=r,ct===0&&Ct(ce)),K!==r){for(re=[],he=Qt();he!==r;)re.push(he),he=Qt();if(re!==r)if(he=ts(),he!==r){for(Je=[],mt=Qt();mt!==r;)Je.push(mt),mt=Qt();Je!==r?(t.charCodeAt(Y)===41?(mt=ne,Y++):(mt=r,ct===0&&Ct(ee)),mt!==r?(Dt=L,K=Pn(he),L=K):(Y=L,L=r)):(Y=L,L=r)}else Y=L,L=r;else Y=L,L=r}else Y=L,L=r}return L}function Bl(){var L,K,re,he,Je,mt,fr,Cr;if(L=Y,K=vc(),K!==r){for(re=[],he=Y,Je=[],mt=Qt();mt!==r;)Je.push(mt),mt=Qt();if(Je!==r)if(t.charCodeAt(Y)===42?(mt=ys,Y++):(mt=r,ct===0&&Ct(We)),mt===r&&(t.charCodeAt(Y)===47?(mt=tt,Y++):(mt=r,ct===0&&Ct(It))),mt!==r){for(fr=[],Cr=Qt();Cr!==r;)fr.push(Cr),Cr=Qt();fr!==r?(Cr=vc(),Cr!==r?(Dt=he,Je=nr(K,mt,Cr),he=Je):(Y=he,he=r)):(Y=he,he=r)}else Y=he,he=r;else Y=he,he=r;for(;he!==r;){for(re.push(he),he=Y,Je=[],mt=Qt();mt!==r;)Je.push(mt),mt=Qt();if(Je!==r)if(t.charCodeAt(Y)===42?(mt=ys,Y++):(mt=r,ct===0&&Ct(We)),mt===r&&(t.charCodeAt(Y)===47?(mt=tt,Y++):(mt=r,ct===0&&Ct(It))),mt!==r){for(fr=[],Cr=Qt();Cr!==r;)fr.push(Cr),Cr=Qt();fr!==r?(Cr=vc(),Cr!==r?(Dt=he,Je=nr(K,mt,Cr),he=Je):(Y=he,he=r)):(Y=he,he=r)}else Y=he,he=r;else Y=he,he=r}re!==r?(Dt=L,K=$(K,re),L=K):(Y=L,L=r)}else Y=L,L=r;return L}function ts(){var L,K,re,he,Je,mt,fr,Cr;if(L=Y,K=Bl(),K!==r){for(re=[],he=Y,Je=[],mt=Qt();mt!==r;)Je.push(mt),mt=Qt();if(Je!==r)if(t.charCodeAt(Y)===43?(mt=la,Y++):(mt=r,ct===0&&Ct(Ho)),mt===r&&(t.charCodeAt(Y)===45?(mt=_s,Y++):(mt=r,ct===0&&Ct(aa))),mt!==r){for(fr=[],Cr=Qt();Cr!==r;)fr.push(Cr),Cr=Qt();fr!==r?(Cr=Bl(),Cr!==r?(Dt=he,Je=me(K,mt,Cr),he=Je):(Y=he,he=r)):(Y=he,he=r)}else Y=he,he=r;else Y=he,he=r;for(;he!==r;){for(re.push(he),he=Y,Je=[],mt=Qt();mt!==r;)Je.push(mt),mt=Qt();if(Je!==r)if(t.charCodeAt(Y)===43?(mt=la,Y++):(mt=r,ct===0&&Ct(Ho)),mt===r&&(t.charCodeAt(Y)===45?(mt=_s,Y++):(mt=r,ct===0&&Ct(aa))),mt!==r){for(fr=[],Cr=Qt();Cr!==r;)fr.push(Cr),Cr=Qt();fr!==r?(Cr=Bl(),Cr!==r?(Dt=he,Je=me(K,mt,Cr),he=Je):(Y=he,he=r)):(Y=he,he=r)}else Y=he,he=r;else Y=he,he=r}re!==r?(Dt=L,K=$(K,re),L=K):(Y=L,L=r)}else Y=L,L=r;return L}function Gr(){var L,K,re,he,Je,mt;if(L=Y,t.substr(Y,3)===Ne?(K=Ne,Y+=3):(K=r,ct===0&&Ct(ft)),K!==r){for(re=[],he=Qt();he!==r;)re.push(he),he=Qt();if(re!==r)if(he=ts(),he!==r){for(Je=[],mt=Qt();mt!==r;)Je.push(mt),mt=Qt();Je!==r?(t.substr(Y,2)===pt?(mt=pt,Y+=2):(mt=r,ct===0&&Ct(Tt)),mt!==r?(Dt=L,K=er(he),L=K):(Y=L,L=r)):(Y=L,L=r)}else Y=L,L=r;else Y=L,L=r}else Y=L,L=r;return L}function mp(){var L,K,re,he;return L=Y,t.substr(Y,2)===Zr?(K=Zr,Y+=2):(K=r,ct===0&&Ct(qi)),K!==r?(re=Cu(),re!==r?(t.charCodeAt(Y)===41?(he=ne,Y++):(he=r,ct===0&&Ct(ee)),he!==r?(Dt=L,K=es(re),L=K):(Y=L,L=r)):(Y=L,L=r)):(Y=L,L=r),L}function Dc(){var L,K,re,he,Je,mt;return L=Y,t.substr(Y,2)===xi?(K=xi,Y+=2):(K=r,ct===0&&Ct(jo)),K!==r?(re=Ga(),re!==r?(t.substr(Y,2)===bA?(he=bA,Y+=2):(he=r,ct===0&&Ct(kA)),he!==r?(Je=TA(),Je!==r?(t.charCodeAt(Y)===125?(mt=H,Y++):(mt=r,ct===0&&Ct(lt)),mt!==r?(Dt=L,K=cp(re,Je),L=K):(Y=L,L=r)):(Y=L,L=r)):(Y=L,L=r)):(Y=L,L=r)):(Y=L,L=r),L===r&&(L=Y,t.substr(Y,2)===xi?(K=xi,Y+=2):(K=r,ct===0&&Ct(jo)),K!==r?(re=Ga(),re!==r?(t.substr(Y,3)===rg?(he=rg,Y+=3):(he=r,ct===0&&Ct(gu)),he!==r?(Dt=L,K=ng(re),L=K):(Y=L,L=r)):(Y=L,L=r)):(Y=L,L=r),L===r&&(L=Y,t.substr(Y,2)===xi?(K=xi,Y+=2):(K=r,ct===0&&Ct(jo)),K!==r?(re=Ga(),re!==r?(t.substr(Y,2)===du?(he=du,Y+=2):(he=r,ct===0&&Ct(uo)),he!==r?(Je=TA(),Je!==r?(t.charCodeAt(Y)===125?(mt=H,Y++):(mt=r,ct===0&&Ct(lt)),mt!==r?(Dt=L,K=QA(re,Je),L=K):(Y=L,L=r)):(Y=L,L=r)):(Y=L,L=r)):(Y=L,L=r)):(Y=L,L=r),L===r&&(L=Y,t.substr(Y,2)===xi?(K=xi,Y+=2):(K=r,ct===0&&Ct(jo)),K!==r?(re=Ga(),re!==r?(t.substr(Y,3)===mc?(he=mc,Y+=3):(he=r,ct===0&&Ct(ca)),he!==r?(Dt=L,K=ig(re),L=K):(Y=L,L=r)):(Y=L,L=r)):(Y=L,L=r),L===r&&(L=Y,t.substr(Y,2)===xi?(K=xi,Y+=2):(K=r,ct===0&&Ct(jo)),K!==r?(re=Ga(),re!==r?(t.charCodeAt(Y)===125?(he=H,Y++):(he=r,ct===0&&Ct(lt)),he!==r?(Dt=L,K=yc(re),L=K):(Y=L,L=r)):(Y=L,L=r)):(Y=L,L=r),L===r&&(L=Y,t.charCodeAt(Y)===36?(K=Pm,Y++):(K=r,ct===0&&Ct(sg)),K!==r?(re=Ga(),re!==r?(Dt=L,K=yc(re),L=K):(Y=L,L=r)):(Y=L,L=r)))))),L}function Ew(){var L,K,re;return L=Y,K=mg(),K!==r?(Dt=Y,re=$n(K),re?re=void 0:re=r,re!==r?(Dt=L,K=up(K),L=K):(Y=L,L=r)):(Y=L,L=r),L}function mg(){var L,K,re,he,Je;if(L=Y,K=[],re=Y,he=Y,ct++,Je=Eg(),ct--,Je===r?he=void 0:(Y=he,he=r),he!==r?(t.length>Y?(Je=t.charAt(Y),Y++):(Je=r,ct===0&&Ct(Fn)),Je!==r?(Dt=re,he=Oe(Je),re=he):(Y=re,re=r)):(Y=re,re=r),re!==r)for(;re!==r;)K.push(re),re=Y,he=Y,ct++,Je=Eg(),ct--,Je===r?he=void 0:(Y=he,he=r),he!==r?(t.length>Y?(Je=t.charAt(Y),Y++):(Je=r,ct===0&&Ct(Fn)),Je!==r?(Dt=re,he=Oe(Je),re=he):(Y=re,re=r)):(Y=re,re=r);else K=r;return K!==r&&(Dt=L,K=Us(K)),L=K,L}function yg(){var L,K,re;if(L=Y,K=[],og.test(t.charAt(Y))?(re=t.charAt(Y),Y++):(re=r,ct===0&&Ct(FA)),re!==r)for(;re!==r;)K.push(re),og.test(t.charAt(Y))?(re=t.charAt(Y),Y++):(re=r,ct===0&&Ct(FA));else K=r;return K!==r&&(Dt=L,K=js()),L=K,L}function Ga(){var L,K,re;if(L=Y,K=[],mu.test(t.charAt(Y))?(re=t.charAt(Y),Y++):(re=r,ct===0&&Ct(Ha)),re!==r)for(;re!==r;)K.push(re),mu.test(t.charAt(Y))?(re=t.charAt(Y),Y++):(re=r,ct===0&&Ct(Ha));else K=r;return K!==r&&(Dt=L,K=js()),L=K,L}function Fm(){var L;return Gi.test(t.charAt(Y))?(L=t.charAt(Y),Y++):(L=r,ct===0&&Ct(ua)),L}function Eg(){var L;return yu.test(t.charAt(Y))?(L=t.charAt(Y),Y++):(L=r,ct===0&&Ct(Es)),L}function Qt(){var L,K;if(L=[],Ec.test(t.charAt(Y))?(K=t.charAt(Y),Y++):(K=r,ct===0&&Ct(Cc)),K!==r)for(;K!==r;)L.push(K),Ec.test(t.charAt(Y))?(K=t.charAt(Y),Y++):(K=r,ct===0&&Ct(Cc));else L=r;return L}if(Eu=a(),Eu!==r&&Y===t.length)return Eu;throw Eu!==r&&Y<t.length&&Ct(cg()),Ag(wc,bi<t.length?t.charAt(bi):null,bi<t.length?Ic(bi,bi+1):Ic(bi,bi))}XY.exports={SyntaxError:jg,parse:p8e}});function ND(t,e={isGlobPattern:()=>!1}){try{return(0,$Y.parse)(t,e)}catch(r){throw r.location&&(r.message=r.message.replace(/(\.)?$/,` (line ${r.location.start.line}, column ${r.location.start.column})$1`)),r}}function ly(t,{endSemicolon:e=!1}={}){return t.map(({command:r,type:o},a)=>`${OD(r)}${o===";"?a!==t.length-1||e?";":"":" &"}`).join(" ")}function OD(t){return`${cy(t.chain)}${t.then?` ${oT(t.then)}`:""}`}function oT(t){return`${t.type} ${OD(t.line)}`}function cy(t){return`${lT(t)}${t.then?` ${aT(t.then)}`:""}`}function aT(t){return`${t.type} ${cy(t.chain)}`}function lT(t){switch(t.type){case"command":return`${t.envs.length>0?`${t.envs.map(e=>LD(e)).join(" ")} `:""}${t.args.map(e=>cT(e)).join(" ")}`;case"subshell":return`(${ly(t.subshell)})${t.args.length>0?` ${t.args.map(e=>Vw(e)).join(" ")}`:""}`;case"group":return`{ ${ly(t.group,{endSemicolon:!0})} }${t.args.length>0?` ${t.args.map(e=>Vw(e)).join(" ")}`:""}`;case"envs":return t.envs.map(e=>LD(e)).join(" ");default:throw new Error(`Unsupported command type: "${t.type}"`)}}function LD(t){return`${t.name}=${t.args[0]?qg(t.args[0]):""}`}function cT(t){switch(t.type){case"redirection":return Vw(t);case"argument":return qg(t);default:throw new Error(`Unsupported argument type: "${t.type}"`)}}function Vw(t){return`${t.subtype} ${t.args.map(e=>qg(e)).join(" ")}`}function qg(t){return t.segments.map(e=>uT(e)).join("")}function uT(t){let e=(o,a)=>a?`"${o}"`:o,r=o=>o===""?"''":o.match(/[()}<>$|&;"'\n\t ]/)?o.match(/['\t\p{C}]/u)?o.match(/'/)?`"${o.replace(/["$\t\p{C}]/u,g8e)}"`:`$'${o.replace(/[\t\p{C}]/u,tW)}'`:`'${o}'`:o;switch(t.type){case"text":return r(t.text);case"glob":return t.pattern;case"shell":return e(`\${${ly(t.shell)}}`,t.quoted);case"variable":return e(typeof t.defaultValue>"u"?typeof t.alternativeValue>"u"?`\${${t.name}}`:t.alternativeValue.length===0?`\${${t.name}:+}`:`\${${t.name}:+${t.alternativeValue.map(o=>qg(o)).join(" ")}}`:t.defaultValue.length===0?`\${${t.name}:-}`:`\${${t.name}:-${t.defaultValue.map(o=>qg(o)).join(" ")}}`,t.quoted);case"arithmetic":return`$(( ${MD(t.arithmetic)} ))`;default:throw new Error(`Unsupported argument segment type: "${t.type}"`)}}function MD(t){let e=a=>{switch(a){case"addition":return"+";case"subtraction":return"-";case"multiplication":return"*";case"division":return"/";default:throw new Error(`Can't extract operator from arithmetic expression of type "${a}"`)}},r=(a,n)=>n?`( ${a} )`:a,o=a=>r(MD(a),!["number","variable"].includes(a.type));switch(t.type){case"number":return String(t.value);case"variable":return t.name;default:return`${o(t.left)} ${e(t.type)} ${o(t.right)}`}}var $Y,eW,h8e,tW,g8e,rW=yt(()=>{$Y=$e(ZY());eW=new Map([["\f","\\f"],[` -`,"\\n"],["\r","\\r"],[" ","\\t"],["\v","\\v"],["\0","\\0"]]),h8e=new Map([["\\","\\\\"],["$","\\$"],['"','\\"'],...Array.from(eW,([t,e])=>[t,`"$'${e}'"`])]),tW=t=>eW.get(t)??`\\x${t.charCodeAt(0).toString(16).padStart(2,"0")}`,g8e=t=>h8e.get(t)??`"$'${tW(t)}'"`});var iW=_((bxt,nW)=>{"use strict";function d8e(t,e){function r(){this.constructor=t}r.prototype=e.prototype,t.prototype=new r}function Gg(t,e,r,o){this.message=t,this.expected=e,this.found=r,this.location=o,this.name="SyntaxError",typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(this,Gg)}d8e(Gg,Error);Gg.buildMessage=function(t,e){var r={literal:function(h){return'"'+a(h.text)+'"'},class:function(h){var C="",I;for(I=0;I<h.parts.length;I++)C+=h.parts[I]instanceof Array?n(h.parts[I][0])+"-"+n(h.parts[I][1]):n(h.parts[I]);return"["+(h.inverted?"^":"")+C+"]"},any:function(h){return"any character"},end:function(h){return"end of input"},other:function(h){return h.description}};function o(h){return h.charCodeAt(0).toString(16).toUpperCase()}function a(h){return h.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(C){return"\\x0"+o(C)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(C){return"\\x"+o(C)})}function n(h){return h.replace(/\\/g,"\\\\").replace(/\]/g,"\\]").replace(/\^/g,"\\^").replace(/-/g,"\\-").replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(C){return"\\x0"+o(C)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(C){return"\\x"+o(C)})}function u(h){return r[h.type](h)}function A(h){var C=new Array(h.length),I,v;for(I=0;I<h.length;I++)C[I]=u(h[I]);if(C.sort(),C.length>0){for(I=1,v=1;I<C.length;I++)C[I-1]!==C[I]&&(C[v]=C[I],v++);C.length=v}switch(C.length){case 1:return C[0];case 2:return C[0]+" or "+C[1];default:return C.slice(0,-1).join(", ")+", or "+C[C.length-1]}}function p(h){return h?'"'+a(h)+'"':"end of input"}return"Expected "+A(t)+" but "+p(e)+" found."};function m8e(t,e){e=e!==void 0?e:{};var r={},o={resolution:ke},a=ke,n="/",u=De("/",!1),A=function(je,x){return{from:je,descriptor:x}},p=function(je){return{descriptor:je}},h="@",C=De("@",!1),I=function(je,x){return{fullName:je,description:x}},v=function(je){return{fullName:je}},b=function(){return Ie()},E=/^[^\/@]/,F=ce(["/","@"],!0,!1),N=/^[^\/]/,U=ce(["/"],!0,!1),z=0,te=0,le=[{line:1,column:1}],pe=0,ue=[],ye=0,ae;if("startRule"in e){if(!(e.startRule in o))throw new Error(`Can't start parsing from rule "`+e.startRule+'".');a=o[e.startRule]}function Ie(){return t.substring(te,z)}function Fe(){return ht(te,z)}function g(je,x){throw x=x!==void 0?x:ht(te,z),Te([we(je)],t.substring(te,z),x)}function Ee(je,x){throw x=x!==void 0?x:ht(te,z),lt(je,x)}function De(je,x){return{type:"literal",text:je,ignoreCase:x}}function ce(je,x,w){return{type:"class",parts:je,inverted:x,ignoreCase:w}}function ne(){return{type:"any"}}function ee(){return{type:"end"}}function we(je){return{type:"other",description:je}}function be(je){var x=le[je],w;if(x)return x;for(w=je-1;!le[w];)w--;for(x=le[w],x={line:x.line,column:x.column};w<je;)t.charCodeAt(w)===10?(x.line++,x.column=1):x.column++,w++;return le[je]=x,x}function ht(je,x){var w=be(je),S=be(x);return{start:{offset:je,line:w.line,column:w.column},end:{offset:x,line:S.line,column:S.column}}}function H(je){z<pe||(z>pe&&(pe=z,ue=[]),ue.push(je))}function lt(je,x){return new Gg(je,null,null,x)}function Te(je,x,w){return new Gg(Gg.buildMessage(je,x),je,x,w)}function ke(){var je,x,w,S;return je=z,x=xe(),x!==r?(t.charCodeAt(z)===47?(w=n,z++):(w=r,ye===0&&H(u)),w!==r?(S=xe(),S!==r?(te=je,x=A(x,S),je=x):(z=je,je=r)):(z=je,je=r)):(z=je,je=r),je===r&&(je=z,x=xe(),x!==r&&(te=je,x=p(x)),je=x),je}function xe(){var je,x,w,S;return je=z,x=He(),x!==r?(t.charCodeAt(z)===64?(w=h,z++):(w=r,ye===0&&H(C)),w!==r?(S=ze(),S!==r?(te=je,x=I(x,S),je=x):(z=je,je=r)):(z=je,je=r)):(z=je,je=r),je===r&&(je=z,x=He(),x!==r&&(te=je,x=v(x)),je=x),je}function He(){var je,x,w,S,y;return je=z,t.charCodeAt(z)===64?(x=h,z++):(x=r,ye===0&&H(C)),x!==r?(w=Re(),w!==r?(t.charCodeAt(z)===47?(S=n,z++):(S=r,ye===0&&H(u)),S!==r?(y=Re(),y!==r?(te=je,x=b(),je=x):(z=je,je=r)):(z=je,je=r)):(z=je,je=r)):(z=je,je=r),je===r&&(je=z,x=Re(),x!==r&&(te=je,x=b()),je=x),je}function Re(){var je,x,w;if(je=z,x=[],E.test(t.charAt(z))?(w=t.charAt(z),z++):(w=r,ye===0&&H(F)),w!==r)for(;w!==r;)x.push(w),E.test(t.charAt(z))?(w=t.charAt(z),z++):(w=r,ye===0&&H(F));else x=r;return x!==r&&(te=je,x=b()),je=x,je}function ze(){var je,x,w;if(je=z,x=[],N.test(t.charAt(z))?(w=t.charAt(z),z++):(w=r,ye===0&&H(U)),w!==r)for(;w!==r;)x.push(w),N.test(t.charAt(z))?(w=t.charAt(z),z++):(w=r,ye===0&&H(U));else x=r;return x!==r&&(te=je,x=b()),je=x,je}if(ae=a(),ae!==r&&z===t.length)return ae;throw ae!==r&&z<t.length&&H(ee()),Te(ue,pe<t.length?t.charAt(pe):null,pe<t.length?ht(pe,pe+1):ht(pe,pe))}nW.exports={SyntaxError:Gg,parse:m8e}});function UD(t){let e=t.match(/^\*{1,2}\/(.*)/);if(e)throw new Error(`The override for '${t}' includes a glob pattern. Glob patterns have been removed since their behaviours don't match what you'd expect. Set the override to '${e[1]}' instead.`);try{return(0,sW.parse)(t)}catch(r){throw r.location&&(r.message=r.message.replace(/(\.)?$/,` (line ${r.location.start.line}, column ${r.location.start.column})$1`)),r}}function _D(t){let e="";return t.from&&(e+=t.from.fullName,t.from.description&&(e+=`@${t.from.description}`),e+="/"),e+=t.descriptor.fullName,t.descriptor.description&&(e+=`@${t.descriptor.description}`),e}var sW,oW=yt(()=>{sW=$e(iW())});var Wg=_((Qxt,Yg)=>{"use strict";function aW(t){return typeof t>"u"||t===null}function y8e(t){return typeof t=="object"&&t!==null}function E8e(t){return Array.isArray(t)?t:aW(t)?[]:[t]}function C8e(t,e){var r,o,a,n;if(e)for(n=Object.keys(e),r=0,o=n.length;r<o;r+=1)a=n[r],t[a]=e[a];return t}function w8e(t,e){var r="",o;for(o=0;o<e;o+=1)r+=t;return r}function I8e(t){return t===0&&Number.NEGATIVE_INFINITY===1/t}Yg.exports.isNothing=aW;Yg.exports.isObject=y8e;Yg.exports.toArray=E8e;Yg.exports.repeat=w8e;Yg.exports.isNegativeZero=I8e;Yg.exports.extend=C8e});var uy=_((Fxt,lW)=>{"use strict";function zw(t,e){Error.call(this),this.name="YAMLException",this.reason=t,this.mark=e,this.message=(this.reason||"(unknown reason)")+(this.mark?" "+this.mark.toString():""),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error().stack||""}zw.prototype=Object.create(Error.prototype);zw.prototype.constructor=zw;zw.prototype.toString=function(e){var r=this.name+": ";return r+=this.reason||"(unknown reason)",!e&&this.mark&&(r+=" "+this.mark.toString()),r};lW.exports=zw});var AW=_((Rxt,uW)=>{"use strict";var cW=Wg();function AT(t,e,r,o,a){this.name=t,this.buffer=e,this.position=r,this.line=o,this.column=a}AT.prototype.getSnippet=function(e,r){var o,a,n,u,A;if(!this.buffer)return null;for(e=e||4,r=r||75,o="",a=this.position;a>0&&`\0\r -\x85\u2028\u2029`.indexOf(this.buffer.charAt(a-1))===-1;)if(a-=1,this.position-a>r/2-1){o=" ... ",a+=5;break}for(n="",u=this.position;u<this.buffer.length&&`\0\r -\x85\u2028\u2029`.indexOf(this.buffer.charAt(u))===-1;)if(u+=1,u-this.position>r/2-1){n=" ... ",u-=5;break}return A=this.buffer.slice(a,u),cW.repeat(" ",e)+o+A+n+` -`+cW.repeat(" ",e+this.position-a+o.length)+"^"};AT.prototype.toString=function(e){var r,o="";return this.name&&(o+='in "'+this.name+'" '),o+="at line "+(this.line+1)+", column "+(this.column+1),e||(r=this.getSnippet(),r&&(o+=`: -`+r)),o};uW.exports=AT});var os=_((Txt,pW)=>{"use strict";var fW=uy(),B8e=["kind","resolve","construct","instanceOf","predicate","represent","defaultStyle","styleAliases"],v8e=["scalar","sequence","mapping"];function D8e(t){var e={};return t!==null&&Object.keys(t).forEach(function(r){t[r].forEach(function(o){e[String(o)]=r})}),e}function P8e(t,e){if(e=e||{},Object.keys(e).forEach(function(r){if(B8e.indexOf(r)===-1)throw new fW('Unknown option "'+r+'" is met in definition of "'+t+'" YAML type.')}),this.tag=t,this.kind=e.kind||null,this.resolve=e.resolve||function(){return!0},this.construct=e.construct||function(r){return r},this.instanceOf=e.instanceOf||null,this.predicate=e.predicate||null,this.represent=e.represent||null,this.defaultStyle=e.defaultStyle||null,this.styleAliases=D8e(e.styleAliases||null),v8e.indexOf(this.kind)===-1)throw new fW('Unknown kind "'+this.kind+'" is specified for "'+t+'" YAML type.')}pW.exports=P8e});var Kg=_((Lxt,gW)=>{"use strict";var hW=Wg(),HD=uy(),S8e=os();function fT(t,e,r){var o=[];return t.include.forEach(function(a){r=fT(a,e,r)}),t[e].forEach(function(a){r.forEach(function(n,u){n.tag===a.tag&&n.kind===a.kind&&o.push(u)}),r.push(a)}),r.filter(function(a,n){return o.indexOf(n)===-1})}function x8e(){var t={scalar:{},sequence:{},mapping:{},fallback:{}},e,r;function o(a){t[a.kind][a.tag]=t.fallback[a.tag]=a}for(e=0,r=arguments.length;e<r;e+=1)arguments[e].forEach(o);return t}function Ay(t){this.include=t.include||[],this.implicit=t.implicit||[],this.explicit=t.explicit||[],this.implicit.forEach(function(e){if(e.loadKind&&e.loadKind!=="scalar")throw new HD("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.")}),this.compiledImplicit=fT(this,"implicit",[]),this.compiledExplicit=fT(this,"explicit",[]),this.compiledTypeMap=x8e(this.compiledImplicit,this.compiledExplicit)}Ay.DEFAULT=null;Ay.create=function(){var e,r;switch(arguments.length){case 1:e=Ay.DEFAULT,r=arguments[0];break;case 2:e=arguments[0],r=arguments[1];break;default:throw new HD("Wrong number of arguments for Schema.create function")}if(e=hW.toArray(e),r=hW.toArray(r),!e.every(function(o){return o instanceof Ay}))throw new HD("Specified list of super schemas (or a single Schema object) contains a non-Schema object.");if(!r.every(function(o){return o instanceof S8e}))throw new HD("Specified list of YAML types (or a single Type object) contains a non-Type object.");return new Ay({include:e,explicit:r})};gW.exports=Ay});var mW=_((Nxt,dW)=>{"use strict";var b8e=os();dW.exports=new b8e("tag:yaml.org,2002:str",{kind:"scalar",construct:function(t){return t!==null?t:""}})});var EW=_((Oxt,yW)=>{"use strict";var k8e=os();yW.exports=new k8e("tag:yaml.org,2002:seq",{kind:"sequence",construct:function(t){return t!==null?t:[]}})});var wW=_((Mxt,CW)=>{"use strict";var Q8e=os();CW.exports=new Q8e("tag:yaml.org,2002:map",{kind:"mapping",construct:function(t){return t!==null?t:{}}})});var jD=_((Uxt,IW)=>{"use strict";var F8e=Kg();IW.exports=new F8e({explicit:[mW(),EW(),wW()]})});var vW=_((_xt,BW)=>{"use strict";var R8e=os();function T8e(t){if(t===null)return!0;var e=t.length;return e===1&&t==="~"||e===4&&(t==="null"||t==="Null"||t==="NULL")}function L8e(){return null}function N8e(t){return t===null}BW.exports=new R8e("tag:yaml.org,2002:null",{kind:"scalar",resolve:T8e,construct:L8e,predicate:N8e,represent:{canonical:function(){return"~"},lowercase:function(){return"null"},uppercase:function(){return"NULL"},camelcase:function(){return"Null"}},defaultStyle:"lowercase"})});var PW=_((Hxt,DW)=>{"use strict";var O8e=os();function M8e(t){if(t===null)return!1;var e=t.length;return e===4&&(t==="true"||t==="True"||t==="TRUE")||e===5&&(t==="false"||t==="False"||t==="FALSE")}function U8e(t){return t==="true"||t==="True"||t==="TRUE"}function _8e(t){return Object.prototype.toString.call(t)==="[object Boolean]"}DW.exports=new O8e("tag:yaml.org,2002:bool",{kind:"scalar",resolve:M8e,construct:U8e,predicate:_8e,represent:{lowercase:function(t){return t?"true":"false"},uppercase:function(t){return t?"TRUE":"FALSE"},camelcase:function(t){return t?"True":"False"}},defaultStyle:"lowercase"})});var xW=_((jxt,SW)=>{"use strict";var H8e=Wg(),j8e=os();function q8e(t){return 48<=t&&t<=57||65<=t&&t<=70||97<=t&&t<=102}function G8e(t){return 48<=t&&t<=55}function Y8e(t){return 48<=t&&t<=57}function W8e(t){if(t===null)return!1;var e=t.length,r=0,o=!1,a;if(!e)return!1;if(a=t[r],(a==="-"||a==="+")&&(a=t[++r]),a==="0"){if(r+1===e)return!0;if(a=t[++r],a==="b"){for(r++;r<e;r++)if(a=t[r],a!=="_"){if(a!=="0"&&a!=="1")return!1;o=!0}return o&&a!=="_"}if(a==="x"){for(r++;r<e;r++)if(a=t[r],a!=="_"){if(!q8e(t.charCodeAt(r)))return!1;o=!0}return o&&a!=="_"}for(;r<e;r++)if(a=t[r],a!=="_"){if(!G8e(t.charCodeAt(r)))return!1;o=!0}return o&&a!=="_"}if(a==="_")return!1;for(;r<e;r++)if(a=t[r],a!=="_"){if(a===":")break;if(!Y8e(t.charCodeAt(r)))return!1;o=!0}return!o||a==="_"?!1:a!==":"?!0:/^(:[0-5]?[0-9])+$/.test(t.slice(r))}function K8e(t){var e=t,r=1,o,a,n=[];return e.indexOf("_")!==-1&&(e=e.replace(/_/g,"")),o=e[0],(o==="-"||o==="+")&&(o==="-"&&(r=-1),e=e.slice(1),o=e[0]),e==="0"?0:o==="0"?e[1]==="b"?r*parseInt(e.slice(2),2):e[1]==="x"?r*parseInt(e,16):r*parseInt(e,8):e.indexOf(":")!==-1?(e.split(":").forEach(function(u){n.unshift(parseInt(u,10))}),e=0,a=1,n.forEach(function(u){e+=u*a,a*=60}),r*e):r*parseInt(e,10)}function V8e(t){return Object.prototype.toString.call(t)==="[object Number]"&&t%1===0&&!H8e.isNegativeZero(t)}SW.exports=new j8e("tag:yaml.org,2002:int",{kind:"scalar",resolve:W8e,construct:K8e,predicate:V8e,represent:{binary:function(t){return t>=0?"0b"+t.toString(2):"-0b"+t.toString(2).slice(1)},octal:function(t){return t>=0?"0"+t.toString(8):"-0"+t.toString(8).slice(1)},decimal:function(t){return t.toString(10)},hexadecimal:function(t){return t>=0?"0x"+t.toString(16).toUpperCase():"-0x"+t.toString(16).toUpperCase().slice(1)}},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}})});var QW=_((qxt,kW)=>{"use strict";var bW=Wg(),z8e=os(),J8e=new RegExp("^(?:[-+]?(?:0|[1-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\\.[0-9_]*|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");function X8e(t){return!(t===null||!J8e.test(t)||t[t.length-1]==="_")}function Z8e(t){var e,r,o,a;return e=t.replace(/_/g,"").toLowerCase(),r=e[0]==="-"?-1:1,a=[],"+-".indexOf(e[0])>=0&&(e=e.slice(1)),e===".inf"?r===1?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:e===".nan"?NaN:e.indexOf(":")>=0?(e.split(":").forEach(function(n){a.unshift(parseFloat(n,10))}),e=0,o=1,a.forEach(function(n){e+=n*o,o*=60}),r*e):r*parseFloat(e,10)}var $8e=/^[-+]?[0-9]+e/;function eHe(t,e){var r;if(isNaN(t))switch(e){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===t)switch(e){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===t)switch(e){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(bW.isNegativeZero(t))return"-0.0";return r=t.toString(10),$8e.test(r)?r.replace("e",".e"):r}function tHe(t){return Object.prototype.toString.call(t)==="[object Number]"&&(t%1!==0||bW.isNegativeZero(t))}kW.exports=new z8e("tag:yaml.org,2002:float",{kind:"scalar",resolve:X8e,construct:Z8e,predicate:tHe,represent:eHe,defaultStyle:"lowercase"})});var pT=_((Gxt,FW)=>{"use strict";var rHe=Kg();FW.exports=new rHe({include:[jD()],implicit:[vW(),PW(),xW(),QW()]})});var hT=_((Yxt,RW)=>{"use strict";var nHe=Kg();RW.exports=new nHe({include:[pT()]})});var OW=_((Wxt,NW)=>{"use strict";var iHe=os(),TW=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),LW=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");function sHe(t){return t===null?!1:TW.exec(t)!==null||LW.exec(t)!==null}function oHe(t){var e,r,o,a,n,u,A,p=0,h=null,C,I,v;if(e=TW.exec(t),e===null&&(e=LW.exec(t)),e===null)throw new Error("Date resolve error");if(r=+e[1],o=+e[2]-1,a=+e[3],!e[4])return new Date(Date.UTC(r,o,a));if(n=+e[4],u=+e[5],A=+e[6],e[7]){for(p=e[7].slice(0,3);p.length<3;)p+="0";p=+p}return e[9]&&(C=+e[10],I=+(e[11]||0),h=(C*60+I)*6e4,e[9]==="-"&&(h=-h)),v=new Date(Date.UTC(r,o,a,n,u,A,p)),h&&v.setTime(v.getTime()-h),v}function aHe(t){return t.toISOString()}NW.exports=new iHe("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:sHe,construct:oHe,instanceOf:Date,represent:aHe})});var UW=_((Kxt,MW)=>{"use strict";var lHe=os();function cHe(t){return t==="<<"||t===null}MW.exports=new lHe("tag:yaml.org,2002:merge",{kind:"scalar",resolve:cHe})});var jW=_((Vxt,HW)=>{"use strict";var Vg;try{_W=Be,Vg=_W("buffer").Buffer}catch{}var _W,uHe=os(),gT=`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/= -\r`;function AHe(t){if(t===null)return!1;var e,r,o=0,a=t.length,n=gT;for(r=0;r<a;r++)if(e=n.indexOf(t.charAt(r)),!(e>64)){if(e<0)return!1;o+=6}return o%8===0}function fHe(t){var e,r,o=t.replace(/[\r\n=]/g,""),a=o.length,n=gT,u=0,A=[];for(e=0;e<a;e++)e%4===0&&e&&(A.push(u>>16&255),A.push(u>>8&255),A.push(u&255)),u=u<<6|n.indexOf(o.charAt(e));return r=a%4*6,r===0?(A.push(u>>16&255),A.push(u>>8&255),A.push(u&255)):r===18?(A.push(u>>10&255),A.push(u>>2&255)):r===12&&A.push(u>>4&255),Vg?Vg.from?Vg.from(A):new Vg(A):A}function pHe(t){var e="",r=0,o,a,n=t.length,u=gT;for(o=0;o<n;o++)o%3===0&&o&&(e+=u[r>>18&63],e+=u[r>>12&63],e+=u[r>>6&63],e+=u[r&63]),r=(r<<8)+t[o];return a=n%3,a===0?(e+=u[r>>18&63],e+=u[r>>12&63],e+=u[r>>6&63],e+=u[r&63]):a===2?(e+=u[r>>10&63],e+=u[r>>4&63],e+=u[r<<2&63],e+=u[64]):a===1&&(e+=u[r>>2&63],e+=u[r<<4&63],e+=u[64],e+=u[64]),e}function hHe(t){return Vg&&Vg.isBuffer(t)}HW.exports=new uHe("tag:yaml.org,2002:binary",{kind:"scalar",resolve:AHe,construct:fHe,predicate:hHe,represent:pHe})});var GW=_((Jxt,qW)=>{"use strict";var gHe=os(),dHe=Object.prototype.hasOwnProperty,mHe=Object.prototype.toString;function yHe(t){if(t===null)return!0;var e=[],r,o,a,n,u,A=t;for(r=0,o=A.length;r<o;r+=1){if(a=A[r],u=!1,mHe.call(a)!=="[object Object]")return!1;for(n in a)if(dHe.call(a,n))if(!u)u=!0;else return!1;if(!u)return!1;if(e.indexOf(n)===-1)e.push(n);else return!1}return!0}function EHe(t){return t!==null?t:[]}qW.exports=new gHe("tag:yaml.org,2002:omap",{kind:"sequence",resolve:yHe,construct:EHe})});var WW=_((Xxt,YW)=>{"use strict";var CHe=os(),wHe=Object.prototype.toString;function IHe(t){if(t===null)return!0;var e,r,o,a,n,u=t;for(n=new Array(u.length),e=0,r=u.length;e<r;e+=1){if(o=u[e],wHe.call(o)!=="[object Object]"||(a=Object.keys(o),a.length!==1))return!1;n[e]=[a[0],o[a[0]]]}return!0}function BHe(t){if(t===null)return[];var e,r,o,a,n,u=t;for(n=new Array(u.length),e=0,r=u.length;e<r;e+=1)o=u[e],a=Object.keys(o),n[e]=[a[0],o[a[0]]];return n}YW.exports=new CHe("tag:yaml.org,2002:pairs",{kind:"sequence",resolve:IHe,construct:BHe})});var VW=_((Zxt,KW)=>{"use strict";var vHe=os(),DHe=Object.prototype.hasOwnProperty;function PHe(t){if(t===null)return!0;var e,r=t;for(e in r)if(DHe.call(r,e)&&r[e]!==null)return!1;return!0}function SHe(t){return t!==null?t:{}}KW.exports=new vHe("tag:yaml.org,2002:set",{kind:"mapping",resolve:PHe,construct:SHe})});var fy=_(($xt,zW)=>{"use strict";var xHe=Kg();zW.exports=new xHe({include:[hT()],implicit:[OW(),UW()],explicit:[jW(),GW(),WW(),VW()]})});var XW=_((ebt,JW)=>{"use strict";var bHe=os();function kHe(){return!0}function QHe(){}function FHe(){return""}function RHe(t){return typeof t>"u"}JW.exports=new bHe("tag:yaml.org,2002:js/undefined",{kind:"scalar",resolve:kHe,construct:QHe,predicate:RHe,represent:FHe})});var $W=_((tbt,ZW)=>{"use strict";var THe=os();function LHe(t){if(t===null||t.length===0)return!1;var e=t,r=/\/([gim]*)$/.exec(t),o="";return!(e[0]==="/"&&(r&&(o=r[1]),o.length>3||e[e.length-o.length-1]!=="/"))}function NHe(t){var e=t,r=/\/([gim]*)$/.exec(t),o="";return e[0]==="/"&&(r&&(o=r[1]),e=e.slice(1,e.length-o.length-1)),new RegExp(e,o)}function OHe(t){var e="/"+t.source+"/";return t.global&&(e+="g"),t.multiline&&(e+="m"),t.ignoreCase&&(e+="i"),e}function MHe(t){return Object.prototype.toString.call(t)==="[object RegExp]"}ZW.exports=new THe("tag:yaml.org,2002:js/regexp",{kind:"scalar",resolve:LHe,construct:NHe,predicate:MHe,represent:OHe})});var rK=_((rbt,tK)=>{"use strict";var qD;try{eK=Be,qD=eK("esprima")}catch{typeof window<"u"&&(qD=window.esprima)}var eK,UHe=os();function _He(t){if(t===null)return!1;try{var e="("+t+")",r=qD.parse(e,{range:!0});return!(r.type!=="Program"||r.body.length!==1||r.body[0].type!=="ExpressionStatement"||r.body[0].expression.type!=="ArrowFunctionExpression"&&r.body[0].expression.type!=="FunctionExpression")}catch{return!1}}function HHe(t){var e="("+t+")",r=qD.parse(e,{range:!0}),o=[],a;if(r.type!=="Program"||r.body.length!==1||r.body[0].type!=="ExpressionStatement"||r.body[0].expression.type!=="ArrowFunctionExpression"&&r.body[0].expression.type!=="FunctionExpression")throw new Error("Failed to resolve function");return r.body[0].expression.params.forEach(function(n){o.push(n.name)}),a=r.body[0].expression.body.range,r.body[0].expression.body.type==="BlockStatement"?new Function(o,e.slice(a[0]+1,a[1]-1)):new Function(o,"return "+e.slice(a[0],a[1]))}function jHe(t){return t.toString()}function qHe(t){return Object.prototype.toString.call(t)==="[object Function]"}tK.exports=new UHe("tag:yaml.org,2002:js/function",{kind:"scalar",resolve:_He,construct:HHe,predicate:qHe,represent:jHe})});var Jw=_((ibt,iK)=>{"use strict";var nK=Kg();iK.exports=nK.DEFAULT=new nK({include:[fy()],explicit:[XW(),$W(),rK()]})});var BK=_((sbt,Xw)=>{"use strict";var mf=Wg(),AK=uy(),GHe=AW(),fK=fy(),YHe=Jw(),qp=Object.prototype.hasOwnProperty,GD=1,pK=2,hK=3,YD=4,dT=1,WHe=2,sK=3,KHe=/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/,VHe=/[\x85\u2028\u2029]/,zHe=/[,\[\]\{\}]/,gK=/^(?:!|!!|![a-z\-]+!)$/i,dK=/^(?:!|[^,\[\]\{\}])(?:%[0-9a-f]{2}|[0-9a-z\-#;\/\?:@&=\+\$,_\.!~\*'\(\)\[\]])*$/i;function oK(t){return Object.prototype.toString.call(t)}function Hu(t){return t===10||t===13}function Jg(t){return t===9||t===32}function Ia(t){return t===9||t===32||t===10||t===13}function py(t){return t===44||t===91||t===93||t===123||t===125}function JHe(t){var e;return 48<=t&&t<=57?t-48:(e=t|32,97<=e&&e<=102?e-97+10:-1)}function XHe(t){return t===120?2:t===117?4:t===85?8:0}function ZHe(t){return 48<=t&&t<=57?t-48:-1}function aK(t){return t===48?"\0":t===97?"\x07":t===98?"\b":t===116||t===9?" ":t===110?` -`:t===118?"\v":t===102?"\f":t===114?"\r":t===101?"\x1B":t===32?" ":t===34?'"':t===47?"/":t===92?"\\":t===78?"\x85":t===95?"\xA0":t===76?"\u2028":t===80?"\u2029":""}function $He(t){return t<=65535?String.fromCharCode(t):String.fromCharCode((t-65536>>10)+55296,(t-65536&1023)+56320)}var mK=new Array(256),yK=new Array(256);for(zg=0;zg<256;zg++)mK[zg]=aK(zg)?1:0,yK[zg]=aK(zg);var zg;function e6e(t,e){this.input=t,this.filename=e.filename||null,this.schema=e.schema||YHe,this.onWarning=e.onWarning||null,this.legacy=e.legacy||!1,this.json=e.json||!1,this.listener=e.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=t.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.documents=[]}function EK(t,e){return new AK(e,new GHe(t.filename,t.input,t.position,t.line,t.position-t.lineStart))}function Sr(t,e){throw EK(t,e)}function WD(t,e){t.onWarning&&t.onWarning.call(null,EK(t,e))}var lK={YAML:function(e,r,o){var a,n,u;e.version!==null&&Sr(e,"duplication of %YAML directive"),o.length!==1&&Sr(e,"YAML directive accepts exactly one argument"),a=/^([0-9]+)\.([0-9]+)$/.exec(o[0]),a===null&&Sr(e,"ill-formed argument of the YAML directive"),n=parseInt(a[1],10),u=parseInt(a[2],10),n!==1&&Sr(e,"unacceptable YAML version of the document"),e.version=o[0],e.checkLineBreaks=u<2,u!==1&&u!==2&&WD(e,"unsupported YAML version of the document")},TAG:function(e,r,o){var a,n;o.length!==2&&Sr(e,"TAG directive accepts exactly two arguments"),a=o[0],n=o[1],gK.test(a)||Sr(e,"ill-formed tag handle (first argument) of the TAG directive"),qp.call(e.tagMap,a)&&Sr(e,'there is a previously declared suffix for "'+a+'" tag handle'),dK.test(n)||Sr(e,"ill-formed tag prefix (second argument) of the TAG directive"),e.tagMap[a]=n}};function jp(t,e,r,o){var a,n,u,A;if(e<r){if(A=t.input.slice(e,r),o)for(a=0,n=A.length;a<n;a+=1)u=A.charCodeAt(a),u===9||32<=u&&u<=1114111||Sr(t,"expected valid JSON character");else KHe.test(A)&&Sr(t,"the stream contains non-printable characters");t.result+=A}}function cK(t,e,r,o){var a,n,u,A;for(mf.isObject(r)||Sr(t,"cannot merge mappings; the provided source object is unacceptable"),a=Object.keys(r),u=0,A=a.length;u<A;u+=1)n=a[u],qp.call(e,n)||(e[n]=r[n],o[n]=!0)}function hy(t,e,r,o,a,n,u,A){var p,h;if(Array.isArray(a))for(a=Array.prototype.slice.call(a),p=0,h=a.length;p<h;p+=1)Array.isArray(a[p])&&Sr(t,"nested arrays are not supported inside keys"),typeof a=="object"&&oK(a[p])==="[object Object]"&&(a[p]="[object Object]");if(typeof a=="object"&&oK(a)==="[object Object]"&&(a="[object Object]"),a=String(a),e===null&&(e={}),o==="tag:yaml.org,2002:merge")if(Array.isArray(n))for(p=0,h=n.length;p<h;p+=1)cK(t,e,n[p],r);else cK(t,e,n,r);else!t.json&&!qp.call(r,a)&&qp.call(e,a)&&(t.line=u||t.line,t.position=A||t.position,Sr(t,"duplicated mapping key")),e[a]=n,delete r[a];return e}function mT(t){var e;e=t.input.charCodeAt(t.position),e===10?t.position++:e===13?(t.position++,t.input.charCodeAt(t.position)===10&&t.position++):Sr(t,"a line break is expected"),t.line+=1,t.lineStart=t.position}function Wi(t,e,r){for(var o=0,a=t.input.charCodeAt(t.position);a!==0;){for(;Jg(a);)a=t.input.charCodeAt(++t.position);if(e&&a===35)do a=t.input.charCodeAt(++t.position);while(a!==10&&a!==13&&a!==0);if(Hu(a))for(mT(t),a=t.input.charCodeAt(t.position),o++,t.lineIndent=0;a===32;)t.lineIndent++,a=t.input.charCodeAt(++t.position);else break}return r!==-1&&o!==0&&t.lineIndent<r&&WD(t,"deficient indentation"),o}function KD(t){var e=t.position,r;return r=t.input.charCodeAt(e),!!((r===45||r===46)&&r===t.input.charCodeAt(e+1)&&r===t.input.charCodeAt(e+2)&&(e+=3,r=t.input.charCodeAt(e),r===0||Ia(r)))}function yT(t,e){e===1?t.result+=" ":e>1&&(t.result+=mf.repeat(` -`,e-1))}function t6e(t,e,r){var o,a,n,u,A,p,h,C,I=t.kind,v=t.result,b;if(b=t.input.charCodeAt(t.position),Ia(b)||py(b)||b===35||b===38||b===42||b===33||b===124||b===62||b===39||b===34||b===37||b===64||b===96||(b===63||b===45)&&(a=t.input.charCodeAt(t.position+1),Ia(a)||r&&py(a)))return!1;for(t.kind="scalar",t.result="",n=u=t.position,A=!1;b!==0;){if(b===58){if(a=t.input.charCodeAt(t.position+1),Ia(a)||r&&py(a))break}else if(b===35){if(o=t.input.charCodeAt(t.position-1),Ia(o))break}else{if(t.position===t.lineStart&&KD(t)||r&&py(b))break;if(Hu(b))if(p=t.line,h=t.lineStart,C=t.lineIndent,Wi(t,!1,-1),t.lineIndent>=e){A=!0,b=t.input.charCodeAt(t.position);continue}else{t.position=u,t.line=p,t.lineStart=h,t.lineIndent=C;break}}A&&(jp(t,n,u,!1),yT(t,t.line-p),n=u=t.position,A=!1),Jg(b)||(u=t.position+1),b=t.input.charCodeAt(++t.position)}return jp(t,n,u,!1),t.result?!0:(t.kind=I,t.result=v,!1)}function r6e(t,e){var r,o,a;if(r=t.input.charCodeAt(t.position),r!==39)return!1;for(t.kind="scalar",t.result="",t.position++,o=a=t.position;(r=t.input.charCodeAt(t.position))!==0;)if(r===39)if(jp(t,o,t.position,!0),r=t.input.charCodeAt(++t.position),r===39)o=t.position,t.position++,a=t.position;else return!0;else Hu(r)?(jp(t,o,a,!0),yT(t,Wi(t,!1,e)),o=a=t.position):t.position===t.lineStart&&KD(t)?Sr(t,"unexpected end of the document within a single quoted scalar"):(t.position++,a=t.position);Sr(t,"unexpected end of the stream within a single quoted scalar")}function n6e(t,e){var r,o,a,n,u,A;if(A=t.input.charCodeAt(t.position),A!==34)return!1;for(t.kind="scalar",t.result="",t.position++,r=o=t.position;(A=t.input.charCodeAt(t.position))!==0;){if(A===34)return jp(t,r,t.position,!0),t.position++,!0;if(A===92){if(jp(t,r,t.position,!0),A=t.input.charCodeAt(++t.position),Hu(A))Wi(t,!1,e);else if(A<256&&mK[A])t.result+=yK[A],t.position++;else if((u=XHe(A))>0){for(a=u,n=0;a>0;a--)A=t.input.charCodeAt(++t.position),(u=JHe(A))>=0?n=(n<<4)+u:Sr(t,"expected hexadecimal character");t.result+=$He(n),t.position++}else Sr(t,"unknown escape sequence");r=o=t.position}else Hu(A)?(jp(t,r,o,!0),yT(t,Wi(t,!1,e)),r=o=t.position):t.position===t.lineStart&&KD(t)?Sr(t,"unexpected end of the document within a double quoted scalar"):(t.position++,o=t.position)}Sr(t,"unexpected end of the stream within a double quoted scalar")}function i6e(t,e){var r=!0,o,a=t.tag,n,u=t.anchor,A,p,h,C,I,v={},b,E,F,N;if(N=t.input.charCodeAt(t.position),N===91)p=93,I=!1,n=[];else if(N===123)p=125,I=!0,n={};else return!1;for(t.anchor!==null&&(t.anchorMap[t.anchor]=n),N=t.input.charCodeAt(++t.position);N!==0;){if(Wi(t,!0,e),N=t.input.charCodeAt(t.position),N===p)return t.position++,t.tag=a,t.anchor=u,t.kind=I?"mapping":"sequence",t.result=n,!0;r||Sr(t,"missed comma between flow collection entries"),E=b=F=null,h=C=!1,N===63&&(A=t.input.charCodeAt(t.position+1),Ia(A)&&(h=C=!0,t.position++,Wi(t,!0,e))),o=t.line,gy(t,e,GD,!1,!0),E=t.tag,b=t.result,Wi(t,!0,e),N=t.input.charCodeAt(t.position),(C||t.line===o)&&N===58&&(h=!0,N=t.input.charCodeAt(++t.position),Wi(t,!0,e),gy(t,e,GD,!1,!0),F=t.result),I?hy(t,n,v,E,b,F):h?n.push(hy(t,null,v,E,b,F)):n.push(b),Wi(t,!0,e),N=t.input.charCodeAt(t.position),N===44?(r=!0,N=t.input.charCodeAt(++t.position)):r=!1}Sr(t,"unexpected end of the stream within a flow collection")}function s6e(t,e){var r,o,a=dT,n=!1,u=!1,A=e,p=0,h=!1,C,I;if(I=t.input.charCodeAt(t.position),I===124)o=!1;else if(I===62)o=!0;else return!1;for(t.kind="scalar",t.result="";I!==0;)if(I=t.input.charCodeAt(++t.position),I===43||I===45)dT===a?a=I===43?sK:WHe:Sr(t,"repeat of a chomping mode identifier");else if((C=ZHe(I))>=0)C===0?Sr(t,"bad explicit indentation width of a block scalar; it cannot be less than one"):u?Sr(t,"repeat of an indentation width identifier"):(A=e+C-1,u=!0);else break;if(Jg(I)){do I=t.input.charCodeAt(++t.position);while(Jg(I));if(I===35)do I=t.input.charCodeAt(++t.position);while(!Hu(I)&&I!==0)}for(;I!==0;){for(mT(t),t.lineIndent=0,I=t.input.charCodeAt(t.position);(!u||t.lineIndent<A)&&I===32;)t.lineIndent++,I=t.input.charCodeAt(++t.position);if(!u&&t.lineIndent>A&&(A=t.lineIndent),Hu(I)){p++;continue}if(t.lineIndent<A){a===sK?t.result+=mf.repeat(` -`,n?1+p:p):a===dT&&n&&(t.result+=` -`);break}for(o?Jg(I)?(h=!0,t.result+=mf.repeat(` -`,n?1+p:p)):h?(h=!1,t.result+=mf.repeat(` -`,p+1)):p===0?n&&(t.result+=" "):t.result+=mf.repeat(` -`,p):t.result+=mf.repeat(` -`,n?1+p:p),n=!0,u=!0,p=0,r=t.position;!Hu(I)&&I!==0;)I=t.input.charCodeAt(++t.position);jp(t,r,t.position,!1)}return!0}function uK(t,e){var r,o=t.tag,a=t.anchor,n=[],u,A=!1,p;for(t.anchor!==null&&(t.anchorMap[t.anchor]=n),p=t.input.charCodeAt(t.position);p!==0&&!(p!==45||(u=t.input.charCodeAt(t.position+1),!Ia(u)));){if(A=!0,t.position++,Wi(t,!0,-1)&&t.lineIndent<=e){n.push(null),p=t.input.charCodeAt(t.position);continue}if(r=t.line,gy(t,e,hK,!1,!0),n.push(t.result),Wi(t,!0,-1),p=t.input.charCodeAt(t.position),(t.line===r||t.lineIndent>e)&&p!==0)Sr(t,"bad indentation of a sequence entry");else if(t.lineIndent<e)break}return A?(t.tag=o,t.anchor=a,t.kind="sequence",t.result=n,!0):!1}function o6e(t,e,r){var o,a,n,u,A=t.tag,p=t.anchor,h={},C={},I=null,v=null,b=null,E=!1,F=!1,N;for(t.anchor!==null&&(t.anchorMap[t.anchor]=h),N=t.input.charCodeAt(t.position);N!==0;){if(o=t.input.charCodeAt(t.position+1),n=t.line,u=t.position,(N===63||N===58)&&Ia(o))N===63?(E&&(hy(t,h,C,I,v,null),I=v=b=null),F=!0,E=!0,a=!0):E?(E=!1,a=!0):Sr(t,"incomplete explicit mapping pair; a key node is missed; or followed by a non-tabulated empty line"),t.position+=1,N=o;else if(gy(t,r,pK,!1,!0))if(t.line===n){for(N=t.input.charCodeAt(t.position);Jg(N);)N=t.input.charCodeAt(++t.position);if(N===58)N=t.input.charCodeAt(++t.position),Ia(N)||Sr(t,"a whitespace character is expected after the key-value separator within a block mapping"),E&&(hy(t,h,C,I,v,null),I=v=b=null),F=!0,E=!1,a=!1,I=t.tag,v=t.result;else if(F)Sr(t,"can not read an implicit mapping pair; a colon is missed");else return t.tag=A,t.anchor=p,!0}else if(F)Sr(t,"can not read a block mapping entry; a multiline key may not be an implicit key");else return t.tag=A,t.anchor=p,!0;else break;if((t.line===n||t.lineIndent>e)&&(gy(t,e,YD,!0,a)&&(E?v=t.result:b=t.result),E||(hy(t,h,C,I,v,b,n,u),I=v=b=null),Wi(t,!0,-1),N=t.input.charCodeAt(t.position)),t.lineIndent>e&&N!==0)Sr(t,"bad indentation of a mapping entry");else if(t.lineIndent<e)break}return E&&hy(t,h,C,I,v,null),F&&(t.tag=A,t.anchor=p,t.kind="mapping",t.result=h),F}function a6e(t){var e,r=!1,o=!1,a,n,u;if(u=t.input.charCodeAt(t.position),u!==33)return!1;if(t.tag!==null&&Sr(t,"duplication of a tag property"),u=t.input.charCodeAt(++t.position),u===60?(r=!0,u=t.input.charCodeAt(++t.position)):u===33?(o=!0,a="!!",u=t.input.charCodeAt(++t.position)):a="!",e=t.position,r){do u=t.input.charCodeAt(++t.position);while(u!==0&&u!==62);t.position<t.length?(n=t.input.slice(e,t.position),u=t.input.charCodeAt(++t.position)):Sr(t,"unexpected end of the stream within a verbatim tag")}else{for(;u!==0&&!Ia(u);)u===33&&(o?Sr(t,"tag suffix cannot contain exclamation marks"):(a=t.input.slice(e-1,t.position+1),gK.test(a)||Sr(t,"named tag handle cannot contain such characters"),o=!0,e=t.position+1)),u=t.input.charCodeAt(++t.position);n=t.input.slice(e,t.position),zHe.test(n)&&Sr(t,"tag suffix cannot contain flow indicator characters")}return n&&!dK.test(n)&&Sr(t,"tag name cannot contain such characters: "+n),r?t.tag=n:qp.call(t.tagMap,a)?t.tag=t.tagMap[a]+n:a==="!"?t.tag="!"+n:a==="!!"?t.tag="tag:yaml.org,2002:"+n:Sr(t,'undeclared tag handle "'+a+'"'),!0}function l6e(t){var e,r;if(r=t.input.charCodeAt(t.position),r!==38)return!1;for(t.anchor!==null&&Sr(t,"duplication of an anchor property"),r=t.input.charCodeAt(++t.position),e=t.position;r!==0&&!Ia(r)&&!py(r);)r=t.input.charCodeAt(++t.position);return t.position===e&&Sr(t,"name of an anchor node must contain at least one character"),t.anchor=t.input.slice(e,t.position),!0}function c6e(t){var e,r,o;if(o=t.input.charCodeAt(t.position),o!==42)return!1;for(o=t.input.charCodeAt(++t.position),e=t.position;o!==0&&!Ia(o)&&!py(o);)o=t.input.charCodeAt(++t.position);return t.position===e&&Sr(t,"name of an alias node must contain at least one character"),r=t.input.slice(e,t.position),qp.call(t.anchorMap,r)||Sr(t,'unidentified alias "'+r+'"'),t.result=t.anchorMap[r],Wi(t,!0,-1),!0}function gy(t,e,r,o,a){var n,u,A,p=1,h=!1,C=!1,I,v,b,E,F;if(t.listener!==null&&t.listener("open",t),t.tag=null,t.anchor=null,t.kind=null,t.result=null,n=u=A=YD===r||hK===r,o&&Wi(t,!0,-1)&&(h=!0,t.lineIndent>e?p=1:t.lineIndent===e?p=0:t.lineIndent<e&&(p=-1)),p===1)for(;a6e(t)||l6e(t);)Wi(t,!0,-1)?(h=!0,A=n,t.lineIndent>e?p=1:t.lineIndent===e?p=0:t.lineIndent<e&&(p=-1)):A=!1;if(A&&(A=h||a),(p===1||YD===r)&&(GD===r||pK===r?E=e:E=e+1,F=t.position-t.lineStart,p===1?A&&(uK(t,F)||o6e(t,F,E))||i6e(t,E)?C=!0:(u&&s6e(t,E)||r6e(t,E)||n6e(t,E)?C=!0:c6e(t)?(C=!0,(t.tag!==null||t.anchor!==null)&&Sr(t,"alias node should not have any properties")):t6e(t,E,GD===r)&&(C=!0,t.tag===null&&(t.tag="?")),t.anchor!==null&&(t.anchorMap[t.anchor]=t.result)):p===0&&(C=A&&uK(t,F))),t.tag!==null&&t.tag!=="!")if(t.tag==="?"){for(t.result!==null&&t.kind!=="scalar"&&Sr(t,'unacceptable node kind for !<?> tag; it should be "scalar", not "'+t.kind+'"'),I=0,v=t.implicitTypes.length;I<v;I+=1)if(b=t.implicitTypes[I],b.resolve(t.result)){t.result=b.construct(t.result),t.tag=b.tag,t.anchor!==null&&(t.anchorMap[t.anchor]=t.result);break}}else qp.call(t.typeMap[t.kind||"fallback"],t.tag)?(b=t.typeMap[t.kind||"fallback"][t.tag],t.result!==null&&b.kind!==t.kind&&Sr(t,"unacceptable node kind for !<"+t.tag+'> tag; it should be "'+b.kind+'", not "'+t.kind+'"'),b.resolve(t.result)?(t.result=b.construct(t.result),t.anchor!==null&&(t.anchorMap[t.anchor]=t.result)):Sr(t,"cannot resolve a node with !<"+t.tag+"> explicit tag")):Sr(t,"unknown tag !<"+t.tag+">");return t.listener!==null&&t.listener("close",t),t.tag!==null||t.anchor!==null||C}function u6e(t){var e=t.position,r,o,a,n=!1,u;for(t.version=null,t.checkLineBreaks=t.legacy,t.tagMap={},t.anchorMap={};(u=t.input.charCodeAt(t.position))!==0&&(Wi(t,!0,-1),u=t.input.charCodeAt(t.position),!(t.lineIndent>0||u!==37));){for(n=!0,u=t.input.charCodeAt(++t.position),r=t.position;u!==0&&!Ia(u);)u=t.input.charCodeAt(++t.position);for(o=t.input.slice(r,t.position),a=[],o.length<1&&Sr(t,"directive name must not be less than one character in length");u!==0;){for(;Jg(u);)u=t.input.charCodeAt(++t.position);if(u===35){do u=t.input.charCodeAt(++t.position);while(u!==0&&!Hu(u));break}if(Hu(u))break;for(r=t.position;u!==0&&!Ia(u);)u=t.input.charCodeAt(++t.position);a.push(t.input.slice(r,t.position))}u!==0&&mT(t),qp.call(lK,o)?lK[o](t,o,a):WD(t,'unknown document directive "'+o+'"')}if(Wi(t,!0,-1),t.lineIndent===0&&t.input.charCodeAt(t.position)===45&&t.input.charCodeAt(t.position+1)===45&&t.input.charCodeAt(t.position+2)===45?(t.position+=3,Wi(t,!0,-1)):n&&Sr(t,"directives end mark is expected"),gy(t,t.lineIndent-1,YD,!1,!0),Wi(t,!0,-1),t.checkLineBreaks&&VHe.test(t.input.slice(e,t.position))&&WD(t,"non-ASCII line breaks are interpreted as content"),t.documents.push(t.result),t.position===t.lineStart&&KD(t)){t.input.charCodeAt(t.position)===46&&(t.position+=3,Wi(t,!0,-1));return}if(t.position<t.length-1)Sr(t,"end of the stream or a document separator is expected");else return}function CK(t,e){t=String(t),e=e||{},t.length!==0&&(t.charCodeAt(t.length-1)!==10&&t.charCodeAt(t.length-1)!==13&&(t+=` -`),t.charCodeAt(0)===65279&&(t=t.slice(1)));var r=new e6e(t,e),o=t.indexOf("\0");for(o!==-1&&(r.position=o,Sr(r,"null byte is not allowed in input")),r.input+="\0";r.input.charCodeAt(r.position)===32;)r.lineIndent+=1,r.position+=1;for(;r.position<r.length-1;)u6e(r);return r.documents}function wK(t,e,r){e!==null&&typeof e=="object"&&typeof r>"u"&&(r=e,e=null);var o=CK(t,r);if(typeof e!="function")return o;for(var a=0,n=o.length;a<n;a+=1)e(o[a])}function IK(t,e){var r=CK(t,e);if(r.length!==0){if(r.length===1)return r[0];throw new AK("expected a single document in the stream, but found more")}}function A6e(t,e,r){return typeof e=="object"&&e!==null&&typeof r>"u"&&(r=e,e=null),wK(t,e,mf.extend({schema:fK},r))}function f6e(t,e){return IK(t,mf.extend({schema:fK},e))}Xw.exports.loadAll=wK;Xw.exports.load=IK;Xw.exports.safeLoadAll=A6e;Xw.exports.safeLoad=f6e});var WK=_((obt,IT)=>{"use strict";var $w=Wg(),eI=uy(),p6e=Jw(),h6e=fy(),QK=Object.prototype.toString,FK=Object.prototype.hasOwnProperty,g6e=9,Zw=10,d6e=13,m6e=32,y6e=33,E6e=34,RK=35,C6e=37,w6e=38,I6e=39,B6e=42,TK=44,v6e=45,LK=58,D6e=61,P6e=62,S6e=63,x6e=64,NK=91,OK=93,b6e=96,MK=123,k6e=124,UK=125,vo={};vo[0]="\\0";vo[7]="\\a";vo[8]="\\b";vo[9]="\\t";vo[10]="\\n";vo[11]="\\v";vo[12]="\\f";vo[13]="\\r";vo[27]="\\e";vo[34]='\\"';vo[92]="\\\\";vo[133]="\\N";vo[160]="\\_";vo[8232]="\\L";vo[8233]="\\P";var Q6e=["y","Y","yes","Yes","YES","on","On","ON","n","N","no","No","NO","off","Off","OFF"];function F6e(t,e){var r,o,a,n,u,A,p;if(e===null)return{};for(r={},o=Object.keys(e),a=0,n=o.length;a<n;a+=1)u=o[a],A=String(e[u]),u.slice(0,2)==="!!"&&(u="tag:yaml.org,2002:"+u.slice(2)),p=t.compiledTypeMap.fallback[u],p&&FK.call(p.styleAliases,A)&&(A=p.styleAliases[A]),r[u]=A;return r}function vK(t){var e,r,o;if(e=t.toString(16).toUpperCase(),t<=255)r="x",o=2;else if(t<=65535)r="u",o=4;else if(t<=4294967295)r="U",o=8;else throw new eI("code point within a string may not be greater than 0xFFFFFFFF");return"\\"+r+$w.repeat("0",o-e.length)+e}function R6e(t){this.schema=t.schema||p6e,this.indent=Math.max(1,t.indent||2),this.noArrayIndent=t.noArrayIndent||!1,this.skipInvalid=t.skipInvalid||!1,this.flowLevel=$w.isNothing(t.flowLevel)?-1:t.flowLevel,this.styleMap=F6e(this.schema,t.styles||null),this.sortKeys=t.sortKeys||!1,this.lineWidth=t.lineWidth||80,this.noRefs=t.noRefs||!1,this.noCompatMode=t.noCompatMode||!1,this.condenseFlow=t.condenseFlow||!1,this.implicitTypes=this.schema.compiledImplicit,this.explicitTypes=this.schema.compiledExplicit,this.tag=null,this.result="",this.duplicates=[],this.usedDuplicates=null}function DK(t,e){for(var r=$w.repeat(" ",e),o=0,a=-1,n="",u,A=t.length;o<A;)a=t.indexOf(` -`,o),a===-1?(u=t.slice(o),o=A):(u=t.slice(o,a+1),o=a+1),u.length&&u!==` -`&&(n+=r),n+=u;return n}function ET(t,e){return` -`+$w.repeat(" ",t.indent*e)}function T6e(t,e){var r,o,a;for(r=0,o=t.implicitTypes.length;r<o;r+=1)if(a=t.implicitTypes[r],a.resolve(e))return!0;return!1}function wT(t){return t===m6e||t===g6e}function dy(t){return 32<=t&&t<=126||161<=t&&t<=55295&&t!==8232&&t!==8233||57344<=t&&t<=65533&&t!==65279||65536<=t&&t<=1114111}function L6e(t){return dy(t)&&!wT(t)&&t!==65279&&t!==d6e&&t!==Zw}function PK(t,e){return dy(t)&&t!==65279&&t!==TK&&t!==NK&&t!==OK&&t!==MK&&t!==UK&&t!==LK&&(t!==RK||e&&L6e(e))}function N6e(t){return dy(t)&&t!==65279&&!wT(t)&&t!==v6e&&t!==S6e&&t!==LK&&t!==TK&&t!==NK&&t!==OK&&t!==MK&&t!==UK&&t!==RK&&t!==w6e&&t!==B6e&&t!==y6e&&t!==k6e&&t!==D6e&&t!==P6e&&t!==I6e&&t!==E6e&&t!==C6e&&t!==x6e&&t!==b6e}function _K(t){var e=/^\n* /;return e.test(t)}var HK=1,jK=2,qK=3,GK=4,VD=5;function O6e(t,e,r,o,a){var n,u,A,p=!1,h=!1,C=o!==-1,I=-1,v=N6e(t.charCodeAt(0))&&!wT(t.charCodeAt(t.length-1));if(e)for(n=0;n<t.length;n++){if(u=t.charCodeAt(n),!dy(u))return VD;A=n>0?t.charCodeAt(n-1):null,v=v&&PK(u,A)}else{for(n=0;n<t.length;n++){if(u=t.charCodeAt(n),u===Zw)p=!0,C&&(h=h||n-I-1>o&&t[I+1]!==" ",I=n);else if(!dy(u))return VD;A=n>0?t.charCodeAt(n-1):null,v=v&&PK(u,A)}h=h||C&&n-I-1>o&&t[I+1]!==" "}return!p&&!h?v&&!a(t)?HK:jK:r>9&&_K(t)?VD:h?GK:qK}function M6e(t,e,r,o){t.dump=function(){if(e.length===0)return"''";if(!t.noCompatMode&&Q6e.indexOf(e)!==-1)return"'"+e+"'";var a=t.indent*Math.max(1,r),n=t.lineWidth===-1?-1:Math.max(Math.min(t.lineWidth,40),t.lineWidth-a),u=o||t.flowLevel>-1&&r>=t.flowLevel;function A(p){return T6e(t,p)}switch(O6e(e,u,t.indent,n,A)){case HK:return e;case jK:return"'"+e.replace(/'/g,"''")+"'";case qK:return"|"+SK(e,t.indent)+xK(DK(e,a));case GK:return">"+SK(e,t.indent)+xK(DK(U6e(e,n),a));case VD:return'"'+_6e(e,n)+'"';default:throw new eI("impossible error: invalid scalar style")}}()}function SK(t,e){var r=_K(t)?String(e):"",o=t[t.length-1]===` -`,a=o&&(t[t.length-2]===` -`||t===` -`),n=a?"+":o?"":"-";return r+n+` -`}function xK(t){return t[t.length-1]===` -`?t.slice(0,-1):t}function U6e(t,e){for(var r=/(\n+)([^\n]*)/g,o=function(){var h=t.indexOf(` -`);return h=h!==-1?h:t.length,r.lastIndex=h,bK(t.slice(0,h),e)}(),a=t[0]===` -`||t[0]===" ",n,u;u=r.exec(t);){var A=u[1],p=u[2];n=p[0]===" ",o+=A+(!a&&!n&&p!==""?` -`:"")+bK(p,e),a=n}return o}function bK(t,e){if(t===""||t[0]===" ")return t;for(var r=/ [^ ]/g,o,a=0,n,u=0,A=0,p="";o=r.exec(t);)A=o.index,A-a>e&&(n=u>a?u:A,p+=` -`+t.slice(a,n),a=n+1),u=A;return p+=` -`,t.length-a>e&&u>a?p+=t.slice(a,u)+` -`+t.slice(u+1):p+=t.slice(a),p.slice(1)}function _6e(t){for(var e="",r,o,a,n=0;n<t.length;n++){if(r=t.charCodeAt(n),r>=55296&&r<=56319&&(o=t.charCodeAt(n+1),o>=56320&&o<=57343)){e+=vK((r-55296)*1024+o-56320+65536),n++;continue}a=vo[r],e+=!a&&dy(r)?t[n]:a||vK(r)}return e}function H6e(t,e,r){var o="",a=t.tag,n,u;for(n=0,u=r.length;n<u;n+=1)Xg(t,e,r[n],!1,!1)&&(n!==0&&(o+=","+(t.condenseFlow?"":" ")),o+=t.dump);t.tag=a,t.dump="["+o+"]"}function j6e(t,e,r,o){var a="",n=t.tag,u,A;for(u=0,A=r.length;u<A;u+=1)Xg(t,e+1,r[u],!0,!0)&&((!o||u!==0)&&(a+=ET(t,e)),t.dump&&Zw===t.dump.charCodeAt(0)?a+="-":a+="- ",a+=t.dump);t.tag=n,t.dump=a||"[]"}function q6e(t,e,r){var o="",a=t.tag,n=Object.keys(r),u,A,p,h,C;for(u=0,A=n.length;u<A;u+=1)C="",u!==0&&(C+=", "),t.condenseFlow&&(C+='"'),p=n[u],h=r[p],Xg(t,e,p,!1,!1)&&(t.dump.length>1024&&(C+="? "),C+=t.dump+(t.condenseFlow?'"':"")+":"+(t.condenseFlow?"":" "),Xg(t,e,h,!1,!1)&&(C+=t.dump,o+=C));t.tag=a,t.dump="{"+o+"}"}function G6e(t,e,r,o){var a="",n=t.tag,u=Object.keys(r),A,p,h,C,I,v;if(t.sortKeys===!0)u.sort();else if(typeof t.sortKeys=="function")u.sort(t.sortKeys);else if(t.sortKeys)throw new eI("sortKeys must be a boolean or a function");for(A=0,p=u.length;A<p;A+=1)v="",(!o||A!==0)&&(v+=ET(t,e)),h=u[A],C=r[h],Xg(t,e+1,h,!0,!0,!0)&&(I=t.tag!==null&&t.tag!=="?"||t.dump&&t.dump.length>1024,I&&(t.dump&&Zw===t.dump.charCodeAt(0)?v+="?":v+="? "),v+=t.dump,I&&(v+=ET(t,e)),Xg(t,e+1,C,!0,I)&&(t.dump&&Zw===t.dump.charCodeAt(0)?v+=":":v+=": ",v+=t.dump,a+=v));t.tag=n,t.dump=a||"{}"}function kK(t,e,r){var o,a,n,u,A,p;for(a=r?t.explicitTypes:t.implicitTypes,n=0,u=a.length;n<u;n+=1)if(A=a[n],(A.instanceOf||A.predicate)&&(!A.instanceOf||typeof e=="object"&&e instanceof A.instanceOf)&&(!A.predicate||A.predicate(e))){if(t.tag=r?A.tag:"?",A.represent){if(p=t.styleMap[A.tag]||A.defaultStyle,QK.call(A.represent)==="[object Function]")o=A.represent(e,p);else if(FK.call(A.represent,p))o=A.represent[p](e,p);else throw new eI("!<"+A.tag+'> tag resolver accepts not "'+p+'" style');t.dump=o}return!0}return!1}function Xg(t,e,r,o,a,n){t.tag=null,t.dump=r,kK(t,r,!1)||kK(t,r,!0);var u=QK.call(t.dump);o&&(o=t.flowLevel<0||t.flowLevel>e);var A=u==="[object Object]"||u==="[object Array]",p,h;if(A&&(p=t.duplicates.indexOf(r),h=p!==-1),(t.tag!==null&&t.tag!=="?"||h||t.indent!==2&&e>0)&&(a=!1),h&&t.usedDuplicates[p])t.dump="*ref_"+p;else{if(A&&h&&!t.usedDuplicates[p]&&(t.usedDuplicates[p]=!0),u==="[object Object]")o&&Object.keys(t.dump).length!==0?(G6e(t,e,t.dump,a),h&&(t.dump="&ref_"+p+t.dump)):(q6e(t,e,t.dump),h&&(t.dump="&ref_"+p+" "+t.dump));else if(u==="[object Array]"){var C=t.noArrayIndent&&e>0?e-1:e;o&&t.dump.length!==0?(j6e(t,C,t.dump,a),h&&(t.dump="&ref_"+p+t.dump)):(H6e(t,C,t.dump),h&&(t.dump="&ref_"+p+" "+t.dump))}else if(u==="[object String]")t.tag!=="?"&&M6e(t,t.dump,e,n);else{if(t.skipInvalid)return!1;throw new eI("unacceptable kind of an object to dump "+u)}t.tag!==null&&t.tag!=="?"&&(t.dump="!<"+t.tag+"> "+t.dump)}return!0}function Y6e(t,e){var r=[],o=[],a,n;for(CT(t,r,o),a=0,n=o.length;a<n;a+=1)e.duplicates.push(r[o[a]]);e.usedDuplicates=new Array(n)}function CT(t,e,r){var o,a,n;if(t!==null&&typeof t=="object")if(a=e.indexOf(t),a!==-1)r.indexOf(a)===-1&&r.push(a);else if(e.push(t),Array.isArray(t))for(a=0,n=t.length;a<n;a+=1)CT(t[a],e,r);else for(o=Object.keys(t),a=0,n=o.length;a<n;a+=1)CT(t[o[a]],e,r)}function YK(t,e){e=e||{};var r=new R6e(e);return r.noRefs||Y6e(t,r),Xg(r,0,t,!0,!0)?r.dump+` -`:""}function W6e(t,e){return YK(t,$w.extend({schema:h6e},e))}IT.exports.dump=YK;IT.exports.safeDump=W6e});var VK=_((abt,ki)=>{"use strict";var zD=BK(),KK=WK();function JD(t){return function(){throw new Error("Function "+t+" is deprecated and cannot be used.")}}ki.exports.Type=os();ki.exports.Schema=Kg();ki.exports.FAILSAFE_SCHEMA=jD();ki.exports.JSON_SCHEMA=pT();ki.exports.CORE_SCHEMA=hT();ki.exports.DEFAULT_SAFE_SCHEMA=fy();ki.exports.DEFAULT_FULL_SCHEMA=Jw();ki.exports.load=zD.load;ki.exports.loadAll=zD.loadAll;ki.exports.safeLoad=zD.safeLoad;ki.exports.safeLoadAll=zD.safeLoadAll;ki.exports.dump=KK.dump;ki.exports.safeDump=KK.safeDump;ki.exports.YAMLException=uy();ki.exports.MINIMAL_SCHEMA=jD();ki.exports.SAFE_SCHEMA=fy();ki.exports.DEFAULT_SCHEMA=Jw();ki.exports.scan=JD("scan");ki.exports.parse=JD("parse");ki.exports.compose=JD("compose");ki.exports.addConstructor=JD("addConstructor")});var JK=_((lbt,zK)=>{"use strict";var K6e=VK();zK.exports=K6e});var ZK=_((cbt,XK)=>{"use strict";function V6e(t,e){function r(){this.constructor=t}r.prototype=e.prototype,t.prototype=new r}function Zg(t,e,r,o){this.message=t,this.expected=e,this.found=r,this.location=o,this.name="SyntaxError",typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(this,Zg)}V6e(Zg,Error);Zg.buildMessage=function(t,e){var r={literal:function(h){return'"'+a(h.text)+'"'},class:function(h){var C="",I;for(I=0;I<h.parts.length;I++)C+=h.parts[I]instanceof Array?n(h.parts[I][0])+"-"+n(h.parts[I][1]):n(h.parts[I]);return"["+(h.inverted?"^":"")+C+"]"},any:function(h){return"any character"},end:function(h){return"end of input"},other:function(h){return h.description}};function o(h){return h.charCodeAt(0).toString(16).toUpperCase()}function a(h){return h.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(C){return"\\x0"+o(C)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(C){return"\\x"+o(C)})}function n(h){return h.replace(/\\/g,"\\\\").replace(/\]/g,"\\]").replace(/\^/g,"\\^").replace(/-/g,"\\-").replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(C){return"\\x0"+o(C)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(C){return"\\x"+o(C)})}function u(h){return r[h.type](h)}function A(h){var C=new Array(h.length),I,v;for(I=0;I<h.length;I++)C[I]=u(h[I]);if(C.sort(),C.length>0){for(I=1,v=1;I<C.length;I++)C[I-1]!==C[I]&&(C[v]=C[I],v++);C.length=v}switch(C.length){case 1:return C[0];case 2:return C[0]+" or "+C[1];default:return C.slice(0,-1).join(", ")+", or "+C[C.length-1]}}function p(h){return h?'"'+a(h)+'"':"end of input"}return"Expected "+A(t)+" but "+p(e)+" found."};function z6e(t,e){e=e!==void 0?e:{};var r={},o={Start:pu},a=pu,n=function($){return[].concat(...$)},u="-",A=Qn("-",!1),p=function($){return $},h=function($){return Object.assign({},...$)},C="#",I=Qn("#",!1),v=hc(),b=function(){return{}},E=":",F=Qn(":",!1),N=function($,me){return{[$]:me}},U=",",z=Qn(",",!1),te=function($,me){return me},le=function($,me,Ne){return Object.assign({},...[$].concat(me).map(ft=>({[ft]:Ne})))},pe=function($){return $},ue=function($){return $},ye=sa("correct indentation"),ae=" ",Ie=Qn(" ",!1),Fe=function($){return $.length===nr*It},g=function($){return $.length===(nr+1)*It},Ee=function(){return nr++,!0},De=function(){return nr--,!0},ce=function(){return DA()},ne=sa("pseudostring"),ee=/^[^\r\n\t ?:,\][{}#&*!|>'"%@`\-]/,we=hi(["\r",` -`," "," ","?",":",",","]","[","{","}","#","&","*","!","|",">","'",'"',"%","@","`","-"],!0,!1),be=/^[^\r\n\t ,\][{}:#"']/,ht=hi(["\r",` -`," "," ",",","]","[","{","}",":","#",'"',"'"],!0,!1),H=function(){return DA().replace(/^ *| *$/g,"")},lt="--",Te=Qn("--",!1),ke=/^[a-zA-Z\/0-9]/,xe=hi([["a","z"],["A","Z"],"/",["0","9"]],!1,!1),He=/^[^\r\n\t :,]/,Re=hi(["\r",` -`," "," ",":",","],!0,!1),ze="null",je=Qn("null",!1),x=function(){return null},w="true",S=Qn("true",!1),y=function(){return!0},R="false",J=Qn("false",!1),X=function(){return!1},Z=sa("string"),ie='"',Pe=Qn('"',!1),Le=function(){return""},ot=function($){return $},dt=function($){return $.join("")},jt=/^[^"\\\0-\x1F\x7F]/,$t=hi(['"',"\\",["\0",""],"\x7F"],!0,!1),xt='\\"',an=Qn('\\"',!1),kr=function(){return'"'},mr="\\\\",xr=Qn("\\\\",!1),Wr=function(){return"\\"},Kn="\\/",Ns=Qn("\\/",!1),Ti=function(){return"/"},ps="\\b",io=Qn("\\b",!1),Si=function(){return"\b"},Os="\\f",so=Qn("\\f",!1),cc=function(){return"\f"},cu="\\n",op=Qn("\\n",!1),ap=function(){return` -`},Ms="\\r",Dn=Qn("\\r",!1),oo=function(){return"\r"},Us="\\t",ml=Qn("\\t",!1),yl=function(){return" "},ao="\\u",Vn=Qn("\\u",!1),On=function($,me,Ne,ft){return String.fromCharCode(parseInt(`0x${$}${me}${Ne}${ft}`))},Li=/^[0-9a-fA-F]/,Mn=hi([["0","9"],["a","f"],["A","F"]],!1,!1),_i=sa("blank space"),tr=/^[ \t]/,Oe=hi([" "," "],!1,!1),ii=sa("white space"),Ma=/^[ \t\n\r]/,hr=hi([" "," ",` -`,"\r"],!1,!1),uc=`\r -`,uu=Qn(`\r -`,!1),Ac=` -`,El=Qn(` -`,!1),vA="\r",Au=Qn("\r",!1),Ce=0,Rt=0,fc=[{line:1,column:1}],Hi=0,fu=[],Yt=0,Cl;if("startRule"in e){if(!(e.startRule in o))throw new Error(`Can't start parsing from rule "`+e.startRule+'".');a=o[e.startRule]}function DA(){return t.substring(Rt,Ce)}function lp(){return _o(Rt,Ce)}function pc($,me){throw me=me!==void 0?me:_o(Rt,Ce),gc([sa($)],t.substring(Rt,Ce),me)}function PA($,me){throw me=me!==void 0?me:_o(Rt,Ce),lo($,me)}function Qn($,me){return{type:"literal",text:$,ignoreCase:me}}function hi($,me,Ne){return{type:"class",parts:$,inverted:me,ignoreCase:Ne}}function hc(){return{type:"any"}}function SA(){return{type:"end"}}function sa($){return{type:"other",description:$}}function Ni($){var me=fc[$],Ne;if(me)return me;for(Ne=$-1;!fc[Ne];)Ne--;for(me=fc[Ne],me={line:me.line,column:me.column};Ne<$;)t.charCodeAt(Ne)===10?(me.line++,me.column=1):me.column++,Ne++;return fc[$]=me,me}function _o($,me){var Ne=Ni($),ft=Ni(me);return{start:{offset:$,line:Ne.line,column:Ne.column},end:{offset:me,line:ft.line,column:ft.column}}}function Ze($){Ce<Hi||(Ce>Hi&&(Hi=Ce,fu=[]),fu.push($))}function lo($,me){return new Zg($,null,null,me)}function gc($,me,Ne){return new Zg(Zg.buildMessage($,me),$,me,Ne)}function pu(){var $;return $=xA(),$}function ji(){var $,me,Ne;for($=Ce,me=[],Ne=hu();Ne!==r;)me.push(Ne),Ne=hu();return me!==r&&(Rt=$,me=n(me)),$=me,$}function hu(){var $,me,Ne,ft,pt;return $=Ce,me=hs(),me!==r?(t.charCodeAt(Ce)===45?(Ne=u,Ce++):(Ne=r,Yt===0&&Ze(A)),Ne!==r?(ft=Pn(),ft!==r?(pt=dc(),pt!==r?(Rt=$,me=p(pt),$=me):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r),$}function xA(){var $,me,Ne;for($=Ce,me=[],Ne=Ua();Ne!==r;)me.push(Ne),Ne=Ua();return me!==r&&(Rt=$,me=h(me)),$=me,$}function Ua(){var $,me,Ne,ft,pt,Tt,er,Zr,qi;if($=Ce,me=Pn(),me===r&&(me=null),me!==r){if(Ne=Ce,t.charCodeAt(Ce)===35?(ft=C,Ce++):(ft=r,Yt===0&&Ze(I)),ft!==r){if(pt=[],Tt=Ce,er=Ce,Yt++,Zr=tt(),Yt--,Zr===r?er=void 0:(Ce=er,er=r),er!==r?(t.length>Ce?(Zr=t.charAt(Ce),Ce++):(Zr=r,Yt===0&&Ze(v)),Zr!==r?(er=[er,Zr],Tt=er):(Ce=Tt,Tt=r)):(Ce=Tt,Tt=r),Tt!==r)for(;Tt!==r;)pt.push(Tt),Tt=Ce,er=Ce,Yt++,Zr=tt(),Yt--,Zr===r?er=void 0:(Ce=er,er=r),er!==r?(t.length>Ce?(Zr=t.charAt(Ce),Ce++):(Zr=r,Yt===0&&Ze(v)),Zr!==r?(er=[er,Zr],Tt=er):(Ce=Tt,Tt=r)):(Ce=Tt,Tt=r);else pt=r;pt!==r?(ft=[ft,pt],Ne=ft):(Ce=Ne,Ne=r)}else Ce=Ne,Ne=r;if(Ne===r&&(Ne=null),Ne!==r){if(ft=[],pt=We(),pt!==r)for(;pt!==r;)ft.push(pt),pt=We();else ft=r;ft!==r?(Rt=$,me=b(),$=me):(Ce=$,$=r)}else Ce=$,$=r}else Ce=$,$=r;if($===r&&($=Ce,me=hs(),me!==r?(Ne=oa(),Ne!==r?(ft=Pn(),ft===r&&(ft=null),ft!==r?(t.charCodeAt(Ce)===58?(pt=E,Ce++):(pt=r,Yt===0&&Ze(F)),pt!==r?(Tt=Pn(),Tt===r&&(Tt=null),Tt!==r?(er=dc(),er!==r?(Rt=$,me=N(Ne,er),$=me):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r),$===r&&($=Ce,me=hs(),me!==r?(Ne=co(),Ne!==r?(ft=Pn(),ft===r&&(ft=null),ft!==r?(t.charCodeAt(Ce)===58?(pt=E,Ce++):(pt=r,Yt===0&&Ze(F)),pt!==r?(Tt=Pn(),Tt===r&&(Tt=null),Tt!==r?(er=dc(),er!==r?(Rt=$,me=N(Ne,er),$=me):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r),$===r))){if($=Ce,me=hs(),me!==r)if(Ne=co(),Ne!==r)if(ft=Pn(),ft!==r)if(pt=aa(),pt!==r){if(Tt=[],er=We(),er!==r)for(;er!==r;)Tt.push(er),er=We();else Tt=r;Tt!==r?(Rt=$,me=N(Ne,pt),$=me):(Ce=$,$=r)}else Ce=$,$=r;else Ce=$,$=r;else Ce=$,$=r;else Ce=$,$=r;if($===r)if($=Ce,me=hs(),me!==r)if(Ne=co(),Ne!==r){if(ft=[],pt=Ce,Tt=Pn(),Tt===r&&(Tt=null),Tt!==r?(t.charCodeAt(Ce)===44?(er=U,Ce++):(er=r,Yt===0&&Ze(z)),er!==r?(Zr=Pn(),Zr===r&&(Zr=null),Zr!==r?(qi=co(),qi!==r?(Rt=pt,Tt=te(Ne,qi),pt=Tt):(Ce=pt,pt=r)):(Ce=pt,pt=r)):(Ce=pt,pt=r)):(Ce=pt,pt=r),pt!==r)for(;pt!==r;)ft.push(pt),pt=Ce,Tt=Pn(),Tt===r&&(Tt=null),Tt!==r?(t.charCodeAt(Ce)===44?(er=U,Ce++):(er=r,Yt===0&&Ze(z)),er!==r?(Zr=Pn(),Zr===r&&(Zr=null),Zr!==r?(qi=co(),qi!==r?(Rt=pt,Tt=te(Ne,qi),pt=Tt):(Ce=pt,pt=r)):(Ce=pt,pt=r)):(Ce=pt,pt=r)):(Ce=pt,pt=r);else ft=r;ft!==r?(pt=Pn(),pt===r&&(pt=null),pt!==r?(t.charCodeAt(Ce)===58?(Tt=E,Ce++):(Tt=r,Yt===0&&Ze(F)),Tt!==r?(er=Pn(),er===r&&(er=null),er!==r?(Zr=dc(),Zr!==r?(Rt=$,me=le(Ne,ft,Zr),$=me):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)}else Ce=$,$=r;else Ce=$,$=r}return $}function dc(){var $,me,Ne,ft,pt,Tt,er;if($=Ce,me=Ce,Yt++,Ne=Ce,ft=tt(),ft!==r?(pt=Ut(),pt!==r?(t.charCodeAt(Ce)===45?(Tt=u,Ce++):(Tt=r,Yt===0&&Ze(A)),Tt!==r?(er=Pn(),er!==r?(ft=[ft,pt,Tt,er],Ne=ft):(Ce=Ne,Ne=r)):(Ce=Ne,Ne=r)):(Ce=Ne,Ne=r)):(Ce=Ne,Ne=r),Yt--,Ne!==r?(Ce=me,me=void 0):me=r,me!==r?(Ne=We(),Ne!==r?(ft=Fn(),ft!==r?(pt=ji(),pt!==r?(Tt=Ci(),Tt!==r?(Rt=$,me=pe(pt),$=me):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r),$===r&&($=Ce,me=tt(),me!==r?(Ne=Fn(),Ne!==r?(ft=xA(),ft!==r?(pt=Ci(),pt!==r?(Rt=$,me=pe(ft),$=me):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r),$===r))if($=Ce,me=_s(),me!==r){if(Ne=[],ft=We(),ft!==r)for(;ft!==r;)Ne.push(ft),ft=We();else Ne=r;Ne!==r?(Rt=$,me=ue(me),$=me):(Ce=$,$=r)}else Ce=$,$=r;return $}function hs(){var $,me,Ne;for(Yt++,$=Ce,me=[],t.charCodeAt(Ce)===32?(Ne=ae,Ce++):(Ne=r,Yt===0&&Ze(Ie));Ne!==r;)me.push(Ne),t.charCodeAt(Ce)===32?(Ne=ae,Ce++):(Ne=r,Yt===0&&Ze(Ie));return me!==r?(Rt=Ce,Ne=Fe(me),Ne?Ne=void 0:Ne=r,Ne!==r?(me=[me,Ne],$=me):(Ce=$,$=r)):(Ce=$,$=r),Yt--,$===r&&(me=r,Yt===0&&Ze(ye)),$}function Ut(){var $,me,Ne;for($=Ce,me=[],t.charCodeAt(Ce)===32?(Ne=ae,Ce++):(Ne=r,Yt===0&&Ze(Ie));Ne!==r;)me.push(Ne),t.charCodeAt(Ce)===32?(Ne=ae,Ce++):(Ne=r,Yt===0&&Ze(Ie));return me!==r?(Rt=Ce,Ne=g(me),Ne?Ne=void 0:Ne=r,Ne!==r?(me=[me,Ne],$=me):(Ce=$,$=r)):(Ce=$,$=r),$}function Fn(){var $;return Rt=Ce,$=Ee(),$?$=void 0:$=r,$}function Ci(){var $;return Rt=Ce,$=De(),$?$=void 0:$=r,$}function oa(){var $;return $=ds(),$===r&&($=la()),$}function co(){var $,me,Ne;if($=ds(),$===r){if($=Ce,me=[],Ne=Ho(),Ne!==r)for(;Ne!==r;)me.push(Ne),Ne=Ho();else me=r;me!==r&&(Rt=$,me=ce()),$=me}return $}function _s(){var $;return $=wi(),$===r&&($=gs(),$===r&&($=ds(),$===r&&($=la()))),$}function aa(){var $;return $=wi(),$===r&&($=ds(),$===r&&($=Ho())),$}function la(){var $,me,Ne,ft,pt,Tt;if(Yt++,$=Ce,ee.test(t.charAt(Ce))?(me=t.charAt(Ce),Ce++):(me=r,Yt===0&&Ze(we)),me!==r){for(Ne=[],ft=Ce,pt=Pn(),pt===r&&(pt=null),pt!==r?(be.test(t.charAt(Ce))?(Tt=t.charAt(Ce),Ce++):(Tt=r,Yt===0&&Ze(ht)),Tt!==r?(pt=[pt,Tt],ft=pt):(Ce=ft,ft=r)):(Ce=ft,ft=r);ft!==r;)Ne.push(ft),ft=Ce,pt=Pn(),pt===r&&(pt=null),pt!==r?(be.test(t.charAt(Ce))?(Tt=t.charAt(Ce),Ce++):(Tt=r,Yt===0&&Ze(ht)),Tt!==r?(pt=[pt,Tt],ft=pt):(Ce=ft,ft=r)):(Ce=ft,ft=r);Ne!==r?(Rt=$,me=H(),$=me):(Ce=$,$=r)}else Ce=$,$=r;return Yt--,$===r&&(me=r,Yt===0&&Ze(ne)),$}function Ho(){var $,me,Ne,ft,pt;if($=Ce,t.substr(Ce,2)===lt?(me=lt,Ce+=2):(me=r,Yt===0&&Ze(Te)),me===r&&(me=null),me!==r)if(ke.test(t.charAt(Ce))?(Ne=t.charAt(Ce),Ce++):(Ne=r,Yt===0&&Ze(xe)),Ne!==r){for(ft=[],He.test(t.charAt(Ce))?(pt=t.charAt(Ce),Ce++):(pt=r,Yt===0&&Ze(Re));pt!==r;)ft.push(pt),He.test(t.charAt(Ce))?(pt=t.charAt(Ce),Ce++):(pt=r,Yt===0&&Ze(Re));ft!==r?(Rt=$,me=H(),$=me):(Ce=$,$=r)}else Ce=$,$=r;else Ce=$,$=r;return $}function wi(){var $,me;return $=Ce,t.substr(Ce,4)===ze?(me=ze,Ce+=4):(me=r,Yt===0&&Ze(je)),me!==r&&(Rt=$,me=x()),$=me,$}function gs(){var $,me;return $=Ce,t.substr(Ce,4)===w?(me=w,Ce+=4):(me=r,Yt===0&&Ze(S)),me!==r&&(Rt=$,me=y()),$=me,$===r&&($=Ce,t.substr(Ce,5)===R?(me=R,Ce+=5):(me=r,Yt===0&&Ze(J)),me!==r&&(Rt=$,me=X()),$=me),$}function ds(){var $,me,Ne,ft;return Yt++,$=Ce,t.charCodeAt(Ce)===34?(me=ie,Ce++):(me=r,Yt===0&&Ze(Pe)),me!==r?(t.charCodeAt(Ce)===34?(Ne=ie,Ce++):(Ne=r,Yt===0&&Ze(Pe)),Ne!==r?(Rt=$,me=Le(),$=me):(Ce=$,$=r)):(Ce=$,$=r),$===r&&($=Ce,t.charCodeAt(Ce)===34?(me=ie,Ce++):(me=r,Yt===0&&Ze(Pe)),me!==r?(Ne=ms(),Ne!==r?(t.charCodeAt(Ce)===34?(ft=ie,Ce++):(ft=r,Yt===0&&Ze(Pe)),ft!==r?(Rt=$,me=ot(Ne),$=me):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)),Yt--,$===r&&(me=r,Yt===0&&Ze(Z)),$}function ms(){var $,me,Ne;if($=Ce,me=[],Ne=Hs(),Ne!==r)for(;Ne!==r;)me.push(Ne),Ne=Hs();else me=r;return me!==r&&(Rt=$,me=dt(me)),$=me,$}function Hs(){var $,me,Ne,ft,pt,Tt;return jt.test(t.charAt(Ce))?($=t.charAt(Ce),Ce++):($=r,Yt===0&&Ze($t)),$===r&&($=Ce,t.substr(Ce,2)===xt?(me=xt,Ce+=2):(me=r,Yt===0&&Ze(an)),me!==r&&(Rt=$,me=kr()),$=me,$===r&&($=Ce,t.substr(Ce,2)===mr?(me=mr,Ce+=2):(me=r,Yt===0&&Ze(xr)),me!==r&&(Rt=$,me=Wr()),$=me,$===r&&($=Ce,t.substr(Ce,2)===Kn?(me=Kn,Ce+=2):(me=r,Yt===0&&Ze(Ns)),me!==r&&(Rt=$,me=Ti()),$=me,$===r&&($=Ce,t.substr(Ce,2)===ps?(me=ps,Ce+=2):(me=r,Yt===0&&Ze(io)),me!==r&&(Rt=$,me=Si()),$=me,$===r&&($=Ce,t.substr(Ce,2)===Os?(me=Os,Ce+=2):(me=r,Yt===0&&Ze(so)),me!==r&&(Rt=$,me=cc()),$=me,$===r&&($=Ce,t.substr(Ce,2)===cu?(me=cu,Ce+=2):(me=r,Yt===0&&Ze(op)),me!==r&&(Rt=$,me=ap()),$=me,$===r&&($=Ce,t.substr(Ce,2)===Ms?(me=Ms,Ce+=2):(me=r,Yt===0&&Ze(Dn)),me!==r&&(Rt=$,me=oo()),$=me,$===r&&($=Ce,t.substr(Ce,2)===Us?(me=Us,Ce+=2):(me=r,Yt===0&&Ze(ml)),me!==r&&(Rt=$,me=yl()),$=me,$===r&&($=Ce,t.substr(Ce,2)===ao?(me=ao,Ce+=2):(me=r,Yt===0&&Ze(Vn)),me!==r?(Ne=Un(),Ne!==r?(ft=Un(),ft!==r?(pt=Un(),pt!==r?(Tt=Un(),Tt!==r?(Rt=$,me=On(Ne,ft,pt,Tt),$=me):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)))))))))),$}function Un(){var $;return Li.test(t.charAt(Ce))?($=t.charAt(Ce),Ce++):($=r,Yt===0&&Ze(Mn)),$}function Pn(){var $,me;if(Yt++,$=[],tr.test(t.charAt(Ce))?(me=t.charAt(Ce),Ce++):(me=r,Yt===0&&Ze(Oe)),me!==r)for(;me!==r;)$.push(me),tr.test(t.charAt(Ce))?(me=t.charAt(Ce),Ce++):(me=r,Yt===0&&Ze(Oe));else $=r;return Yt--,$===r&&(me=r,Yt===0&&Ze(_i)),$}function ys(){var $,me;if(Yt++,$=[],Ma.test(t.charAt(Ce))?(me=t.charAt(Ce),Ce++):(me=r,Yt===0&&Ze(hr)),me!==r)for(;me!==r;)$.push(me),Ma.test(t.charAt(Ce))?(me=t.charAt(Ce),Ce++):(me=r,Yt===0&&Ze(hr));else $=r;return Yt--,$===r&&(me=r,Yt===0&&Ze(ii)),$}function We(){var $,me,Ne,ft,pt,Tt;if($=Ce,me=tt(),me!==r){for(Ne=[],ft=Ce,pt=Pn(),pt===r&&(pt=null),pt!==r?(Tt=tt(),Tt!==r?(pt=[pt,Tt],ft=pt):(Ce=ft,ft=r)):(Ce=ft,ft=r);ft!==r;)Ne.push(ft),ft=Ce,pt=Pn(),pt===r&&(pt=null),pt!==r?(Tt=tt(),Tt!==r?(pt=[pt,Tt],ft=pt):(Ce=ft,ft=r)):(Ce=ft,ft=r);Ne!==r?(me=[me,Ne],$=me):(Ce=$,$=r)}else Ce=$,$=r;return $}function tt(){var $;return t.substr(Ce,2)===uc?($=uc,Ce+=2):($=r,Yt===0&&Ze(uu)),$===r&&(t.charCodeAt(Ce)===10?($=Ac,Ce++):($=r,Yt===0&&Ze(El)),$===r&&(t.charCodeAt(Ce)===13?($=vA,Ce++):($=r,Yt===0&&Ze(Au)))),$}let It=2,nr=0;if(Cl=a(),Cl!==r&&Ce===t.length)return Cl;throw Cl!==r&&Ce<t.length&&Ze(SA()),gc(fu,Hi<t.length?t.charAt(Hi):null,Hi<t.length?_o(Hi,Hi+1):_o(Hi,Hi))}XK.exports={SyntaxError:Zg,parse:z6e}});function eV(t){return t.match(J6e)?t:JSON.stringify(t)}function rV(t){return typeof t>"u"?!0:typeof t=="object"&&t!==null&&!Array.isArray(t)?Object.keys(t).every(e=>rV(t[e])):!1}function BT(t,e,r){if(t===null)return`null -`;if(typeof t=="number"||typeof t=="boolean")return`${t.toString()} -`;if(typeof t=="string")return`${eV(t)} -`;if(Array.isArray(t)){if(t.length===0)return`[] -`;let o=" ".repeat(e);return` -${t.map(n=>`${o}- ${BT(n,e+1,!1)}`).join("")}`}if(typeof t=="object"&&t){let[o,a]=t instanceof XD?[t.data,!1]:[t,!0],n=" ".repeat(e),u=Object.keys(o);a&&u.sort((p,h)=>{let C=$K.indexOf(p),I=$K.indexOf(h);return C===-1&&I===-1?p<h?-1:p>h?1:0:C!==-1&&I===-1?-1:C===-1&&I!==-1?1:C-I});let A=u.filter(p=>!rV(o[p])).map((p,h)=>{let C=o[p],I=eV(p),v=BT(C,e+1,!0),b=h>0||r?n:"",E=I.length>1024?`? ${I} -${b}:`:`${I}:`,F=v.startsWith(` -`)?v:` ${v}`;return`${b}${E}${F}`}).join(e===0?` -`:"")||` -`;return r?` -${A}`:`${A}`}throw new Error(`Unsupported value type (${t})`)}function Ba(t){try{let e=BT(t,0,!1);return e!==` -`?e:""}catch(e){throw e.location&&(e.message=e.message.replace(/(\.)?$/,` (line ${e.location.start.line}, column ${e.location.start.column})$1`)),e}}function X6e(t){return t.endsWith(` -`)||(t+=` -`),(0,tV.parse)(t)}function $6e(t){if(Z6e.test(t))return X6e(t);let e=(0,ZD.safeLoad)(t,{schema:ZD.FAILSAFE_SCHEMA,json:!0});if(e==null)return{};if(typeof e!="object")throw new Error(`Expected an indexed object, got a ${typeof e} instead. Does your file follow Yaml's rules?`);if(Array.isArray(e))throw new Error("Expected an indexed object, got an array instead. Does your file follow Yaml's rules?");return e}function Ki(t){return $6e(t)}var ZD,tV,J6e,$K,XD,Z6e,nV=yt(()=>{ZD=$e(JK()),tV=$e(ZK()),J6e=/^(?![-?:,\][{}#&*!|>'"%@` \t\r\n]).([ \t]*(?![,\][{}:# \t\r\n]).)*$/,$K=["__metadata","version","resolution","dependencies","peerDependencies","dependenciesMeta","peerDependenciesMeta","binaries"],XD=class{constructor(e){this.data=e}};Ba.PreserveOrdering=XD;Z6e=/^(#.*(\r?\n))*?#\s+yarn\s+lockfile\s+v1\r?\n/i});var tI={};Vt(tI,{parseResolution:()=>UD,parseShell:()=>ND,parseSyml:()=>Ki,stringifyArgument:()=>cT,stringifyArgumentSegment:()=>uT,stringifyArithmeticExpression:()=>MD,stringifyCommand:()=>lT,stringifyCommandChain:()=>cy,stringifyCommandChainThen:()=>aT,stringifyCommandLine:()=>OD,stringifyCommandLineThen:()=>oT,stringifyEnvSegment:()=>LD,stringifyRedirectArgument:()=>Vw,stringifyResolution:()=>_D,stringifyShell:()=>ly,stringifyShellLine:()=>ly,stringifySyml:()=>Ba,stringifyValueArgument:()=>qg});var Ll=yt(()=>{rW();oW();nV()});var sV=_((hbt,vT)=>{"use strict";var eje=t=>{let e=!1,r=!1,o=!1;for(let a=0;a<t.length;a++){let n=t[a];e&&/[a-zA-Z]/.test(n)&&n.toUpperCase()===n?(t=t.slice(0,a)+"-"+t.slice(a),e=!1,o=r,r=!0,a++):r&&o&&/[a-zA-Z]/.test(n)&&n.toLowerCase()===n?(t=t.slice(0,a-1)+"-"+t.slice(a-1),o=r,r=!1,e=!0):(e=n.toLowerCase()===n&&n.toUpperCase()!==n,o=r,r=n.toUpperCase()===n&&n.toLowerCase()!==n)}return t},iV=(t,e)=>{if(!(typeof t=="string"||Array.isArray(t)))throw new TypeError("Expected the input to be `string | string[]`");e=Object.assign({pascalCase:!1},e);let r=a=>e.pascalCase?a.charAt(0).toUpperCase()+a.slice(1):a;return Array.isArray(t)?t=t.map(a=>a.trim()).filter(a=>a.length).join("-"):t=t.trim(),t.length===0?"":t.length===1?e.pascalCase?t.toUpperCase():t.toLowerCase():(t!==t.toLowerCase()&&(t=eje(t)),t=t.replace(/^[_.\- ]+/,"").toLowerCase().replace(/[_.\- ]+(\w|$)/g,(a,n)=>n.toUpperCase()).replace(/\d+(\w|$)/g,a=>a.toUpperCase()),r(t))};vT.exports=iV;vT.exports.default=iV});var oV=_((gbt,tje)=>{tje.exports=[{name:"AppVeyor",constant:"APPVEYOR",env:"APPVEYOR",pr:"APPVEYOR_PULL_REQUEST_NUMBER"},{name:"Azure Pipelines",constant:"AZURE_PIPELINES",env:"SYSTEM_TEAMFOUNDATIONCOLLECTIONURI",pr:"SYSTEM_PULLREQUEST_PULLREQUESTID"},{name:"Appcircle",constant:"APPCIRCLE",env:"AC_APPCIRCLE"},{name:"Bamboo",constant:"BAMBOO",env:"bamboo_planKey"},{name:"Bitbucket Pipelines",constant:"BITBUCKET",env:"BITBUCKET_COMMIT",pr:"BITBUCKET_PR_ID"},{name:"Bitrise",constant:"BITRISE",env:"BITRISE_IO",pr:"BITRISE_PULL_REQUEST"},{name:"Buddy",constant:"BUDDY",env:"BUDDY_WORKSPACE_ID",pr:"BUDDY_EXECUTION_PULL_REQUEST_ID"},{name:"Buildkite",constant:"BUILDKITE",env:"BUILDKITE",pr:{env:"BUILDKITE_PULL_REQUEST",ne:"false"}},{name:"CircleCI",constant:"CIRCLE",env:"CIRCLECI",pr:"CIRCLE_PULL_REQUEST"},{name:"Cirrus CI",constant:"CIRRUS",env:"CIRRUS_CI",pr:"CIRRUS_PR"},{name:"AWS CodeBuild",constant:"CODEBUILD",env:"CODEBUILD_BUILD_ARN"},{name:"Codefresh",constant:"CODEFRESH",env:"CF_BUILD_ID",pr:{any:["CF_PULL_REQUEST_NUMBER","CF_PULL_REQUEST_ID"]}},{name:"Codeship",constant:"CODESHIP",env:{CI_NAME:"codeship"}},{name:"Drone",constant:"DRONE",env:"DRONE",pr:{DRONE_BUILD_EVENT:"pull_request"}},{name:"dsari",constant:"DSARI",env:"DSARI"},{name:"GitHub Actions",constant:"GITHUB_ACTIONS",env:"GITHUB_ACTIONS",pr:{GITHUB_EVENT_NAME:"pull_request"}},{name:"GitLab CI",constant:"GITLAB",env:"GITLAB_CI",pr:"CI_MERGE_REQUEST_ID"},{name:"GoCD",constant:"GOCD",env:"GO_PIPELINE_LABEL"},{name:"LayerCI",constant:"LAYERCI",env:"LAYERCI",pr:"LAYERCI_PULL_REQUEST"},{name:"Hudson",constant:"HUDSON",env:"HUDSON_URL"},{name:"Jenkins",constant:"JENKINS",env:["JENKINS_URL","BUILD_ID"],pr:{any:["ghprbPullId","CHANGE_ID"]}},{name:"Magnum CI",constant:"MAGNUM",env:"MAGNUM"},{name:"Netlify CI",constant:"NETLIFY",env:"NETLIFY",pr:{env:"PULL_REQUEST",ne:"false"}},{name:"Nevercode",constant:"NEVERCODE",env:"NEVERCODE",pr:{env:"NEVERCODE_PULL_REQUEST",ne:"false"}},{name:"Render",constant:"RENDER",env:"RENDER",pr:{IS_PULL_REQUEST:"true"}},{name:"Sail CI",constant:"SAIL",env:"SAILCI",pr:"SAIL_PULL_REQUEST_NUMBER"},{name:"Semaphore",constant:"SEMAPHORE",env:"SEMAPHORE",pr:"PULL_REQUEST_NUMBER"},{name:"Screwdriver",constant:"SCREWDRIVER",env:"SCREWDRIVER",pr:{env:"SD_PULL_REQUEST",ne:"false"}},{name:"Shippable",constant:"SHIPPABLE",env:"SHIPPABLE",pr:{IS_PULL_REQUEST:"true"}},{name:"Solano CI",constant:"SOLANO",env:"TDDIUM",pr:"TDDIUM_PR_ID"},{name:"Strider CD",constant:"STRIDER",env:"STRIDER"},{name:"TaskCluster",constant:"TASKCLUSTER",env:["TASK_ID","RUN_ID"]},{name:"TeamCity",constant:"TEAMCITY",env:"TEAMCITY_VERSION"},{name:"Travis CI",constant:"TRAVIS",env:"TRAVIS",pr:{env:"TRAVIS_PULL_REQUEST",ne:"false"}},{name:"Vercel",constant:"VERCEL",env:"NOW_BUILDER"},{name:"Visual Studio App Center",constant:"APPCENTER",env:"APPCENTER_BUILD_ID"}]});var $g=_(Xa=>{"use strict";var lV=oV(),ju=process.env;Object.defineProperty(Xa,"_vendors",{value:lV.map(function(t){return t.constant})});Xa.name=null;Xa.isPR=null;lV.forEach(function(t){let r=(Array.isArray(t.env)?t.env:[t.env]).every(function(o){return aV(o)});if(Xa[t.constant]=r,r)switch(Xa.name=t.name,typeof t.pr){case"string":Xa.isPR=!!ju[t.pr];break;case"object":"env"in t.pr?Xa.isPR=t.pr.env in ju&&ju[t.pr.env]!==t.pr.ne:"any"in t.pr?Xa.isPR=t.pr.any.some(function(o){return!!ju[o]}):Xa.isPR=aV(t.pr);break;default:Xa.isPR=null}});Xa.isCI=!!(ju.CI||ju.CONTINUOUS_INTEGRATION||ju.BUILD_NUMBER||ju.RUN_ID||Xa.name);function aV(t){return typeof t=="string"?!!ju[t]:Object.keys(t).every(function(e){return ju[e]===t[e]})}});var Hn,cn,ed,DT,$D,cV,PT,ST,eP=yt(()=>{(function(t){t.StartOfInput="\0",t.EndOfInput="",t.EndOfPartialInput=""})(Hn||(Hn={}));(function(t){t[t.InitialNode=0]="InitialNode",t[t.SuccessNode=1]="SuccessNode",t[t.ErrorNode=2]="ErrorNode",t[t.CustomNode=3]="CustomNode"})(cn||(cn={}));ed=-1,DT=/^(-h|--help)(?:=([0-9]+))?$/,$D=/^(--[a-z]+(?:-[a-z]+)*|-[a-zA-Z]+)$/,cV=/^-[a-zA-Z]{2,}$/,PT=/^([^=]+)=([\s\S]*)$/,ST=process.env.DEBUG_CLI==="1"});var it,my,tP,xT,rP=yt(()=>{eP();it=class extends Error{constructor(e){super(e),this.clipanion={type:"usage"},this.name="UsageError"}},my=class extends Error{constructor(e,r){if(super(),this.input=e,this.candidates=r,this.clipanion={type:"none"},this.name="UnknownSyntaxError",this.candidates.length===0)this.message="Command not found, but we're not sure what's the alternative.";else if(this.candidates.every(o=>o.reason!==null&&o.reason===r[0].reason)){let[{reason:o}]=this.candidates;this.message=`${o} - -${this.candidates.map(({usage:a})=>`$ ${a}`).join(` -`)}`}else if(this.candidates.length===1){let[{usage:o}]=this.candidates;this.message=`Command not found; did you mean: - -$ ${o} -${xT(e)}`}else this.message=`Command not found; did you mean one of: - -${this.candidates.map(({usage:o},a)=>`${`${a}.`.padStart(4)} ${o}`).join(` -`)} - -${xT(e)}`}},tP=class extends Error{constructor(e,r){super(),this.input=e,this.usages=r,this.clipanion={type:"none"},this.name="AmbiguousSyntaxError",this.message=`Cannot find which to pick amongst the following alternatives: - -${this.usages.map((o,a)=>`${`${a}.`.padStart(4)} ${o}`).join(` -`)} - -${xT(e)}`}},xT=t=>`While running ${t.filter(e=>e!==Hn.EndOfInput&&e!==Hn.EndOfPartialInput).map(e=>{let r=JSON.stringify(e);return e.match(/\s/)||e.length===0||r!==`"${e}"`?r:e}).join(" ")}`});function rje(t){let e=t.split(` -`),r=e.filter(a=>a.match(/\S/)),o=r.length>0?r.reduce((a,n)=>Math.min(a,n.length-n.trimStart().length),Number.MAX_VALUE):0;return e.map(a=>a.slice(o).trimRight()).join(` -`)}function Do(t,{format:e,paragraphs:r}){return t=t.replace(/\r\n?/g,` -`),t=rje(t),t=t.replace(/^\n+|\n+$/g,""),t=t.replace(/^(\s*)-([^\n]*?)\n+/gm,`$1-$2 - -`),t=t.replace(/\n(\n)?\n*/g,(o,a)=>a||" "),r&&(t=t.split(/\n/).map(o=>{let a=o.match(/^\s*[*-][\t ]+(.*)/);if(!a)return o.match(/(.{1,80})(?: |$)/g).join(` -`);let n=o.length-o.trimStart().length;return a[1].match(new RegExp(`(.{1,${78-n}})(?: |$)`,"g")).map((u,A)=>" ".repeat(n)+(A===0?"- ":" ")+u).join(` -`)}).join(` - -`)),t=t.replace(/(`+)((?:.|[\n])*?)\1/g,(o,a,n)=>e.code(a+n+a)),t=t.replace(/(\*\*)((?:.|[\n])*?)\1/g,(o,a,n)=>e.bold(a+n+a)),t?`${t} -`:""}var bT,uV,AV,kT=yt(()=>{bT=Array(80).fill("\u2501");for(let t=0;t<=24;++t)bT[bT.length-t]=`\x1B[38;5;${232+t}m\u2501`;uV={header:t=>`\x1B[1m\u2501\u2501\u2501 ${t}${t.length<80-5?` ${bT.slice(t.length+5).join("")}`:":"}\x1B[0m`,bold:t=>`\x1B[1m${t}\x1B[22m`,error:t=>`\x1B[31m\x1B[1m${t}\x1B[22m\x1B[39m`,code:t=>`\x1B[36m${t}\x1B[39m`},AV={header:t=>t,bold:t=>t,error:t=>t,code:t=>t}});function Ko(t){return{...t,[rI]:!0}}function qu(t,e){return typeof t>"u"?[t,e]:typeof t=="object"&&t!==null&&!Array.isArray(t)?[void 0,t]:[t,e]}function nP(t,{mergeName:e=!1}={}){let r=t.match(/^([^:]+): (.*)$/m);if(!r)return"validation failed";let[,o,a]=r;return e&&(a=a[0].toLowerCase()+a.slice(1)),a=o!=="."||!e?`${o.replace(/^\.(\[|$)/,"$1")}: ${a}`:`: ${a}`,a}function nI(t,e){return e.length===1?new it(`${t}${nP(e[0],{mergeName:!0})}`):new it(`${t}: -${e.map(r=>` -- ${nP(r)}`).join("")}`)}function td(t,e,r){if(typeof r>"u")return e;let o=[],a=[],n=A=>{let p=e;return e=A,n.bind(null,p)};if(!r(e,{errors:o,coercions:a,coercion:n}))throw nI(`Invalid value for ${t}`,o);for(let[,A]of a)A();return e}var rI,yf=yt(()=>{rP();rI=Symbol("clipanion/isOption")});var Vo={};Vt(Vo,{KeyRelationship:()=>Gu,TypeAssertionError:()=>Yp,applyCascade:()=>rd,as:()=>wje,assert:()=>yje,assertWithErrors:()=>Eje,cascade:()=>mV,fn:()=>Ije,hasAtLeastOneKey:()=>OT,hasExactLength:()=>dV,hasForbiddenKeys:()=>Hje,hasKeyRelationship:()=>aI,hasMaxLength:()=>vje,hasMinLength:()=>Bje,hasMutuallyExclusiveKeys:()=>jje,hasRequiredKeys:()=>_je,hasUniqueItems:()=>Dje,isArray:()=>iP,isAtLeast:()=>LT,isAtMost:()=>xje,isBase64:()=>Nje,isBoolean:()=>uje,isDate:()=>fje,isDict:()=>gje,isEnum:()=>Vs,isHexColor:()=>Lje,isISO8601:()=>Tje,isInExclusiveRange:()=>kje,isInInclusiveRange:()=>bje,isInstanceOf:()=>mje,isInteger:()=>NT,isJSON:()=>Oje,isLiteral:()=>pV,isLowerCase:()=>Qje,isMap:()=>hje,isNegative:()=>Pje,isNullable:()=>Uje,isNumber:()=>RT,isObject:()=>hV,isOneOf:()=>TT,isOptional:()=>Mje,isPartial:()=>dje,isPayload:()=>Aje,isPositive:()=>Sje,isRecord:()=>oP,isSet:()=>pje,isString:()=>Ey,isTuple:()=>sP,isUUID4:()=>Rje,isUnknown:()=>FT,isUpperCase:()=>Fje,makeTrait:()=>gV,makeValidator:()=>Hr,matchesRegExp:()=>sI,softAssert:()=>Cje});function jn(t){return t===null?"null":t===void 0?"undefined":t===""?"an empty string":typeof t=="symbol"?`<${t.toString()}>`:Array.isArray(t)?"an array":JSON.stringify(t)}function yy(t,e){if(t.length===0)return"nothing";if(t.length===1)return jn(t[0]);let r=t.slice(0,-1),o=t[t.length-1],a=t.length>2?`, ${e} `:` ${e} `;return`${r.map(n=>jn(n)).join(", ")}${a}${jn(o)}`}function Gp(t,e){var r,o,a;return typeof e=="number"?`${(r=t?.p)!==null&&r!==void 0?r:"."}[${e}]`:nje.test(e)?`${(o=t?.p)!==null&&o!==void 0?o:""}.${e}`:`${(a=t?.p)!==null&&a!==void 0?a:"."}[${JSON.stringify(e)}]`}function QT(t,e,r){return t===1?e:r}function pr({errors:t,p:e}={},r){return t?.push(`${e??"."}: ${r}`),!1}function lje(t,e){return r=>{t[e]=r}}function Yu(t,e){return r=>{let o=t[e];return t[e]=r,Yu(t,e).bind(null,o)}}function iI(t,e,r){let o=()=>(t(r()),a),a=()=>(t(e),o);return o}function FT(){return Hr({test:(t,e)=>!0})}function pV(t){return Hr({test:(e,r)=>e!==t?pr(r,`Expected ${jn(t)} (got ${jn(e)})`):!0})}function Ey(){return Hr({test:(t,e)=>typeof t!="string"?pr(e,`Expected a string (got ${jn(t)})`):!0})}function Vs(t){let e=Array.isArray(t)?t:Object.values(t),r=e.every(a=>typeof a=="string"||typeof a=="number"),o=new Set(e);return o.size===1?pV([...o][0]):Hr({test:(a,n)=>o.has(a)?!0:r?pr(n,`Expected one of ${yy(e,"or")} (got ${jn(a)})`):pr(n,`Expected a valid enumeration value (got ${jn(a)})`)})}function uje(){return Hr({test:(t,e)=>{var r;if(typeof t!="boolean"){if(typeof e?.coercions<"u"){if(typeof e?.coercion>"u")return pr(e,"Unbound coercion result");let o=cje.get(t);if(typeof o<"u")return e.coercions.push([(r=e.p)!==null&&r!==void 0?r:".",e.coercion.bind(null,o)]),!0}return pr(e,`Expected a boolean (got ${jn(t)})`)}return!0}})}function RT(){return Hr({test:(t,e)=>{var r;if(typeof t!="number"){if(typeof e?.coercions<"u"){if(typeof e?.coercion>"u")return pr(e,"Unbound coercion result");let o;if(typeof t=="string"){let a;try{a=JSON.parse(t)}catch{}if(typeof a=="number")if(JSON.stringify(a)===t)o=a;else return pr(e,`Received a number that can't be safely represented by the runtime (${t})`)}if(typeof o<"u")return e.coercions.push([(r=e.p)!==null&&r!==void 0?r:".",e.coercion.bind(null,o)]),!0}return pr(e,`Expected a number (got ${jn(t)})`)}return!0}})}function Aje(t){return Hr({test:(e,r)=>{var o;if(typeof r?.coercions>"u")return pr(r,"The isPayload predicate can only be used with coercion enabled");if(typeof r.coercion>"u")return pr(r,"Unbound coercion result");if(typeof e!="string")return pr(r,`Expected a string (got ${jn(e)})`);let a;try{a=JSON.parse(e)}catch{return pr(r,`Expected a JSON string (got ${jn(e)})`)}let n={value:a};return t(a,Object.assign(Object.assign({},r),{coercion:Yu(n,"value")}))?(r.coercions.push([(o=r.p)!==null&&o!==void 0?o:".",r.coercion.bind(null,n.value)]),!0):!1}})}function fje(){return Hr({test:(t,e)=>{var r;if(!(t instanceof Date)){if(typeof e?.coercions<"u"){if(typeof e?.coercion>"u")return pr(e,"Unbound coercion result");let o;if(typeof t=="string"&&fV.test(t))o=new Date(t);else{let a;if(typeof t=="string"){let n;try{n=JSON.parse(t)}catch{}typeof n=="number"&&(a=n)}else typeof t=="number"&&(a=t);if(typeof a<"u")if(Number.isSafeInteger(a)||!Number.isSafeInteger(a*1e3))o=new Date(a*1e3);else return pr(e,`Received a timestamp that can't be safely represented by the runtime (${t})`)}if(typeof o<"u")return e.coercions.push([(r=e.p)!==null&&r!==void 0?r:".",e.coercion.bind(null,o)]),!0}return pr(e,`Expected a date (got ${jn(t)})`)}return!0}})}function iP(t,{delimiter:e}={}){return Hr({test:(r,o)=>{var a;let n=r;if(typeof r=="string"&&typeof e<"u"&&typeof o?.coercions<"u"){if(typeof o?.coercion>"u")return pr(o,"Unbound coercion result");r=r.split(e)}if(!Array.isArray(r))return pr(o,`Expected an array (got ${jn(r)})`);let u=!0;for(let A=0,p=r.length;A<p&&(u=t(r[A],Object.assign(Object.assign({},o),{p:Gp(o,A),coercion:Yu(r,A)}))&&u,!(!u&&o?.errors==null));++A);return r!==n&&o.coercions.push([(a=o.p)!==null&&a!==void 0?a:".",o.coercion.bind(null,r)]),u}})}function pje(t,{delimiter:e}={}){let r=iP(t,{delimiter:e});return Hr({test:(o,a)=>{var n,u;if(Object.getPrototypeOf(o).toString()==="[object Set]")if(typeof a?.coercions<"u"){if(typeof a?.coercion>"u")return pr(a,"Unbound coercion result");let A=[...o],p=[...o];if(!r(p,Object.assign(Object.assign({},a),{coercion:void 0})))return!1;let h=()=>p.some((C,I)=>C!==A[I])?new Set(p):o;return a.coercions.push([(n=a.p)!==null&&n!==void 0?n:".",iI(a.coercion,o,h)]),!0}else{let A=!0;for(let p of o)if(A=t(p,Object.assign({},a))&&A,!A&&a?.errors==null)break;return A}if(typeof a?.coercions<"u"){if(typeof a?.coercion>"u")return pr(a,"Unbound coercion result");let A={value:o};return r(o,Object.assign(Object.assign({},a),{coercion:Yu(A,"value")}))?(a.coercions.push([(u=a.p)!==null&&u!==void 0?u:".",iI(a.coercion,o,()=>new Set(A.value))]),!0):!1}return pr(a,`Expected a set (got ${jn(o)})`)}})}function hje(t,e){let r=iP(sP([t,e])),o=oP(e,{keys:t});return Hr({test:(a,n)=>{var u,A,p;if(Object.getPrototypeOf(a).toString()==="[object Map]")if(typeof n?.coercions<"u"){if(typeof n?.coercion>"u")return pr(n,"Unbound coercion result");let h=[...a],C=[...a];if(!r(C,Object.assign(Object.assign({},n),{coercion:void 0})))return!1;let I=()=>C.some((v,b)=>v[0]!==h[b][0]||v[1]!==h[b][1])?new Map(C):a;return n.coercions.push([(u=n.p)!==null&&u!==void 0?u:".",iI(n.coercion,a,I)]),!0}else{let h=!0;for(let[C,I]of a)if(h=t(C,Object.assign({},n))&&h,!h&&n?.errors==null||(h=e(I,Object.assign(Object.assign({},n),{p:Gp(n,C)}))&&h,!h&&n?.errors==null))break;return h}if(typeof n?.coercions<"u"){if(typeof n?.coercion>"u")return pr(n,"Unbound coercion result");let h={value:a};return Array.isArray(a)?r(a,Object.assign(Object.assign({},n),{coercion:void 0}))?(n.coercions.push([(A=n.p)!==null&&A!==void 0?A:".",iI(n.coercion,a,()=>new Map(h.value))]),!0):!1:o(a,Object.assign(Object.assign({},n),{coercion:Yu(h,"value")}))?(n.coercions.push([(p=n.p)!==null&&p!==void 0?p:".",iI(n.coercion,a,()=>new Map(Object.entries(h.value)))]),!0):!1}return pr(n,`Expected a map (got ${jn(a)})`)}})}function sP(t,{delimiter:e}={}){let r=dV(t.length);return Hr({test:(o,a)=>{var n;if(typeof o=="string"&&typeof e<"u"&&typeof a?.coercions<"u"){if(typeof a?.coercion>"u")return pr(a,"Unbound coercion result");o=o.split(e),a.coercions.push([(n=a.p)!==null&&n!==void 0?n:".",a.coercion.bind(null,o)])}if(!Array.isArray(o))return pr(a,`Expected a tuple (got ${jn(o)})`);let u=r(o,Object.assign({},a));for(let A=0,p=o.length;A<p&&A<t.length&&(u=t[A](o[A],Object.assign(Object.assign({},a),{p:Gp(a,A),coercion:Yu(o,A)}))&&u,!(!u&&a?.errors==null));++A);return u}})}function oP(t,{keys:e=null}={}){let r=iP(sP([e??Ey(),t]));return Hr({test:(o,a)=>{var n;if(Array.isArray(o)&&typeof a?.coercions<"u")return typeof a?.coercion>"u"?pr(a,"Unbound coercion result"):r(o,Object.assign(Object.assign({},a),{coercion:void 0}))?(o=Object.fromEntries(o),a.coercions.push([(n=a.p)!==null&&n!==void 0?n:".",a.coercion.bind(null,o)]),!0):!1;if(typeof o!="object"||o===null)return pr(a,`Expected an object (got ${jn(o)})`);let u=Object.keys(o),A=!0;for(let p=0,h=u.length;p<h&&(A||a?.errors!=null);++p){let C=u[p],I=o[C];if(C==="__proto__"||C==="constructor"){A=pr(Object.assign(Object.assign({},a),{p:Gp(a,C)}),"Unsafe property name");continue}if(e!==null&&!e(C,a)){A=!1;continue}if(!t(I,Object.assign(Object.assign({},a),{p:Gp(a,C),coercion:Yu(o,C)}))){A=!1;continue}}return A}})}function gje(t,e={}){return oP(t,e)}function hV(t,{extra:e=null}={}){let r=Object.keys(t),o=Hr({test:(a,n)=>{if(typeof a!="object"||a===null)return pr(n,`Expected an object (got ${jn(a)})`);let u=new Set([...r,...Object.keys(a)]),A={},p=!0;for(let h of u){if(h==="constructor"||h==="__proto__")p=pr(Object.assign(Object.assign({},n),{p:Gp(n,h)}),"Unsafe property name");else{let C=Object.prototype.hasOwnProperty.call(t,h)?t[h]:void 0,I=Object.prototype.hasOwnProperty.call(a,h)?a[h]:void 0;typeof C<"u"?p=C(I,Object.assign(Object.assign({},n),{p:Gp(n,h),coercion:Yu(a,h)}))&&p:e===null?p=pr(Object.assign(Object.assign({},n),{p:Gp(n,h)}),`Extraneous property (got ${jn(I)})`):Object.defineProperty(A,h,{enumerable:!0,get:()=>I,set:lje(a,h)})}if(!p&&n?.errors==null)break}return e!==null&&(p||n?.errors!=null)&&(p=e(A,n)&&p),p}});return Object.assign(o,{properties:t})}function dje(t){return hV(t,{extra:oP(FT())})}function gV(t){return()=>t}function Hr({test:t}){return gV(t)()}function yje(t,e){if(!e(t))throw new Yp}function Eje(t,e){let r=[];if(!e(t,{errors:r}))throw new Yp({errors:r})}function Cje(t,e){}function wje(t,e,{coerce:r=!1,errors:o,throw:a}={}){let n=o?[]:void 0;if(!r){if(e(t,{errors:n}))return a?t:{value:t,errors:void 0};if(a)throw new Yp({errors:n});return{value:void 0,errors:n??!0}}let u={value:t},A=Yu(u,"value"),p=[];if(!e(t,{errors:n,coercion:A,coercions:p})){if(a)throw new Yp({errors:n});return{value:void 0,errors:n??!0}}for(let[,h]of p)h();return a?u.value:{value:u.value,errors:void 0}}function Ije(t,e){let r=sP(t);return(...o)=>{if(!r(o))throw new Yp;return e(...o)}}function Bje(t){return Hr({test:(e,r)=>e.length>=t?!0:pr(r,`Expected to have a length of at least ${t} elements (got ${e.length})`)})}function vje(t){return Hr({test:(e,r)=>e.length<=t?!0:pr(r,`Expected to have a length of at most ${t} elements (got ${e.length})`)})}function dV(t){return Hr({test:(e,r)=>e.length!==t?pr(r,`Expected to have a length of exactly ${t} elements (got ${e.length})`):!0})}function Dje({map:t}={}){return Hr({test:(e,r)=>{let o=new Set,a=new Set;for(let n=0,u=e.length;n<u;++n){let A=e[n],p=typeof t<"u"?t(A):A;if(o.has(p)){if(a.has(p))continue;pr(r,`Expected to contain unique elements; got a duplicate with ${jn(e)}`),a.add(p)}else o.add(p)}return a.size===0}})}function Pje(){return Hr({test:(t,e)=>t<=0?!0:pr(e,`Expected to be negative (got ${t})`)})}function Sje(){return Hr({test:(t,e)=>t>=0?!0:pr(e,`Expected to be positive (got ${t})`)})}function LT(t){return Hr({test:(e,r)=>e>=t?!0:pr(r,`Expected to be at least ${t} (got ${e})`)})}function xje(t){return Hr({test:(e,r)=>e<=t?!0:pr(r,`Expected to be at most ${t} (got ${e})`)})}function bje(t,e){return Hr({test:(r,o)=>r>=t&&r<=e?!0:pr(o,`Expected to be in the [${t}; ${e}] range (got ${r})`)})}function kje(t,e){return Hr({test:(r,o)=>r>=t&&r<e?!0:pr(o,`Expected to be in the [${t}; ${e}[ range (got ${r})`)})}function NT({unsafe:t=!1}={}){return Hr({test:(e,r)=>e!==Math.round(e)?pr(r,`Expected to be an integer (got ${e})`):!t&&!Number.isSafeInteger(e)?pr(r,`Expected to be a safe integer (got ${e})`):!0})}function sI(t){return Hr({test:(e,r)=>t.test(e)?!0:pr(r,`Expected to match the pattern ${t.toString()} (got ${jn(e)})`)})}function Qje(){return Hr({test:(t,e)=>t!==t.toLowerCase()?pr(e,`Expected to be all-lowercase (got ${t})`):!0})}function Fje(){return Hr({test:(t,e)=>t!==t.toUpperCase()?pr(e,`Expected to be all-uppercase (got ${t})`):!0})}function Rje(){return Hr({test:(t,e)=>aje.test(t)?!0:pr(e,`Expected to be a valid UUID v4 (got ${jn(t)})`)})}function Tje(){return Hr({test:(t,e)=>fV.test(t)?!0:pr(e,`Expected to be a valid ISO 8601 date string (got ${jn(t)})`)})}function Lje({alpha:t=!1}){return Hr({test:(e,r)=>(t?ije.test(e):sje.test(e))?!0:pr(r,`Expected to be a valid hexadecimal color string (got ${jn(e)})`)})}function Nje(){return Hr({test:(t,e)=>oje.test(t)?!0:pr(e,`Expected to be a valid base 64 string (got ${jn(t)})`)})}function Oje(t=FT()){return Hr({test:(e,r)=>{let o;try{o=JSON.parse(e)}catch{return pr(r,`Expected to be a valid JSON string (got ${jn(e)})`)}return t(o,r)}})}function mV(t,...e){let r=Array.isArray(e[0])?e[0]:e;return Hr({test:(o,a)=>{var n,u;let A={value:o},p=typeof a?.coercions<"u"?Yu(A,"value"):void 0,h=typeof a?.coercions<"u"?[]:void 0;if(!t(o,Object.assign(Object.assign({},a),{coercion:p,coercions:h})))return!1;let C=[];if(typeof h<"u")for(let[,I]of h)C.push(I());try{if(typeof a?.coercions<"u"){if(A.value!==o){if(typeof a?.coercion>"u")return pr(a,"Unbound coercion result");a.coercions.push([(n=a.p)!==null&&n!==void 0?n:".",a.coercion.bind(null,A.value)])}(u=a?.coercions)===null||u===void 0||u.push(...h)}return r.every(I=>I(A.value,a))}finally{for(let I of C)I()}}})}function rd(t,...e){let r=Array.isArray(e[0])?e[0]:e;return mV(t,r)}function Mje(t){return Hr({test:(e,r)=>typeof e>"u"?!0:t(e,r)})}function Uje(t){return Hr({test:(e,r)=>e===null?!0:t(e,r)})}function _je(t,e){var r;let o=new Set(t),a=oI[(r=e?.missingIf)!==null&&r!==void 0?r:"missing"];return Hr({test:(n,u)=>{let A=new Set(Object.keys(n)),p=[];for(let h of o)a(A,h,n)||p.push(h);return p.length>0?pr(u,`Missing required ${QT(p.length,"property","properties")} ${yy(p,"and")}`):!0}})}function OT(t,e){var r;let o=new Set(t),a=oI[(r=e?.missingIf)!==null&&r!==void 0?r:"missing"];return Hr({test:(n,u)=>Object.keys(n).some(h=>a(o,h,n))?!0:pr(u,`Missing at least one property from ${yy(Array.from(o),"or")}`)})}function Hje(t,e){var r;let o=new Set(t),a=oI[(r=e?.missingIf)!==null&&r!==void 0?r:"missing"];return Hr({test:(n,u)=>{let A=new Set(Object.keys(n)),p=[];for(let h of o)a(A,h,n)&&p.push(h);return p.length>0?pr(u,`Forbidden ${QT(p.length,"property","properties")} ${yy(p,"and")}`):!0}})}function jje(t,e){var r;let o=new Set(t),a=oI[(r=e?.missingIf)!==null&&r!==void 0?r:"missing"];return Hr({test:(n,u)=>{let A=new Set(Object.keys(n)),p=[];for(let h of o)a(A,h,n)&&p.push(h);return p.length>1?pr(u,`Mutually exclusive properties ${yy(p,"and")}`):!0}})}function aI(t,e,r,o){var a,n;let u=new Set((a=o?.ignore)!==null&&a!==void 0?a:[]),A=oI[(n=o?.missingIf)!==null&&n!==void 0?n:"missing"],p=new Set(r),h=qje[e],C=e===Gu.Forbids?"or":"and";return Hr({test:(I,v)=>{let b=new Set(Object.keys(I));if(!A(b,t,I)||u.has(I[t]))return!0;let E=[];for(let F of p)(A(b,F,I)&&!u.has(I[F]))!==h.expect&&E.push(F);return E.length>=1?pr(v,`Property "${t}" ${h.message} ${QT(E.length,"property","properties")} ${yy(E,C)}`):!0}})}var nje,ije,sje,oje,aje,fV,cje,mje,TT,Yp,oI,Gu,qje,Za=yt(()=>{nje=/^[a-zA-Z_][a-zA-Z0-9_]*$/;ije=/^#[0-9a-f]{6}$/i,sje=/^#[0-9a-f]{6}([0-9a-f]{2})?$/i,oje=/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/,aje=/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}$/i,fV=/^(?:[1-9]\d{3}(-?)(?:(?:0[1-9]|1[0-2])\1(?:0[1-9]|1\d|2[0-8])|(?:0[13-9]|1[0-2])\1(?:29|30)|(?:0[13578]|1[02])(?:\1)31|00[1-9]|0[1-9]\d|[12]\d{2}|3(?:[0-5]\d|6[0-5]))|(?:[1-9]\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)(?:(-?)02(?:\2)29|-?366))T(?:[01]\d|2[0-3])(:?)[0-5]\d(?:\3[0-5]\d)?(?:Z|[+-][01]\d(?:\3[0-5]\d)?)$/;cje=new Map([["true",!0],["True",!0],["1",!0],[1,!0],["false",!1],["False",!1],["0",!1],[0,!1]]);mje=t=>Hr({test:(e,r)=>e instanceof t?!0:pr(r,`Expected an instance of ${t.name} (got ${jn(e)})`)}),TT=(t,{exclusive:e=!1}={})=>Hr({test:(r,o)=>{var a,n,u;let A=[],p=typeof o?.errors<"u"?[]:void 0;for(let h=0,C=t.length;h<C;++h){let I=typeof o?.errors<"u"?[]:void 0,v=typeof o?.coercions<"u"?[]:void 0;if(t[h](r,Object.assign(Object.assign({},o),{errors:I,coercions:v,p:`${(a=o?.p)!==null&&a!==void 0?a:"."}#${h+1}`}))){if(A.push([`#${h+1}`,v]),!e)break}else p?.push(I[0])}if(A.length===1){let[,h]=A[0];return typeof h<"u"&&((n=o?.coercions)===null||n===void 0||n.push(...h)),!0}return A.length>1?pr(o,`Expected to match exactly a single predicate (matched ${A.join(", ")})`):(u=o?.errors)===null||u===void 0||u.push(...p),!1}});Yp=class extends Error{constructor({errors:e}={}){let r="Type mismatch";if(e&&e.length>0){r+=` -`;for(let o of e)r+=` -- ${o}`}super(r)}};oI={missing:(t,e)=>t.has(e),undefined:(t,e,r)=>t.has(e)&&typeof r[e]<"u",nil:(t,e,r)=>t.has(e)&&r[e]!=null,falsy:(t,e,r)=>t.has(e)&&!!r[e]};(function(t){t.Forbids="Forbids",t.Requires="Requires"})(Gu||(Gu={}));qje={[Gu.Forbids]:{expect:!1,message:"forbids using"},[Gu.Requires]:{expect:!0,message:"requires using"}}});var nt,Wp=yt(()=>{yf();nt=class{constructor(){this.help=!1}static Usage(e){return e}async catch(e){throw e}async validateAndExecute(){let r=this.constructor.schema;if(Array.isArray(r)){let{isDict:a,isUnknown:n,applyCascade:u}=await Promise.resolve().then(()=>(Za(),Vo)),A=u(a(n()),r),p=[],h=[];if(!A(this,{errors:p,coercions:h}))throw nI("Invalid option schema",p);for(let[,I]of h)I()}else if(r!=null)throw new Error("Invalid command schema");let o=await this.execute();return typeof o<"u"?o:0}};nt.isOption=rI;nt.Default=[]});function va(t){ST&&console.log(t)}function EV(){let t={nodes:[]};for(let e=0;e<cn.CustomNode;++e)t.nodes.push($a());return t}function Gje(t){let e=EV(),r=[],o=e.nodes.length;for(let a of t){r.push(o);for(let n=0;n<a.nodes.length;++n)wV(n)||e.nodes.push(Zje(a.nodes[n],o));o+=a.nodes.length-cn.CustomNode+1}for(let a of r)Cy(e,cn.InitialNode,a);return e}function Oc(t,e){return t.nodes.push(e),t.nodes.length-1}function Yje(t){let e=new Set,r=o=>{if(e.has(o))return;e.add(o);let a=t.nodes[o];for(let u of Object.values(a.statics))for(let{to:A}of u)r(A);for(let[,{to:u}]of a.dynamics)r(u);for(let{to:u}of a.shortcuts)r(u);let n=new Set(a.shortcuts.map(({to:u})=>u));for(;a.shortcuts.length>0;){let{to:u}=a.shortcuts.shift(),A=t.nodes[u];for(let[p,h]of Object.entries(A.statics)){let C=Object.prototype.hasOwnProperty.call(a.statics,p)?a.statics[p]:a.statics[p]=[];for(let I of h)C.some(({to:v})=>I.to===v)||C.push(I)}for(let[p,h]of A.dynamics)a.dynamics.some(([C,{to:I}])=>p===C&&h.to===I)||a.dynamics.push([p,h]);for(let p of A.shortcuts)n.has(p.to)||(a.shortcuts.push(p),n.add(p.to))}};r(cn.InitialNode)}function Wje(t,{prefix:e=""}={}){if(ST){va(`${e}Nodes are:`);for(let r=0;r<t.nodes.length;++r)va(`${e} ${r}: ${JSON.stringify(t.nodes[r])}`)}}function Kje(t,e,r=!1){va(`Running a vm on ${JSON.stringify(e)}`);let o=[{node:cn.InitialNode,state:{candidateUsage:null,requiredOptions:[],errorMessage:null,ignoreOptions:!1,options:[],path:[],positionals:[],remainder:null,selectedIndex:null,partial:!1,tokens:[]}}];Wje(t,{prefix:" "});let a=[Hn.StartOfInput,...e];for(let n=0;n<a.length;++n){let u=a[n],A=u===Hn.EndOfInput||u===Hn.EndOfPartialInput,p=n-1;va(` Processing ${JSON.stringify(u)}`);let h=[];for(let{node:C,state:I}of o){va(` Current node is ${C}`);let v=t.nodes[C];if(C===cn.ErrorNode){h.push({node:C,state:I});continue}console.assert(v.shortcuts.length===0,"Shortcuts should have been eliminated by now");let b=Object.prototype.hasOwnProperty.call(v.statics,u);if(!r||n<a.length-1||b)if(b){let E=v.statics[u];for(let{to:F,reducer:N}of E)h.push({node:F,state:typeof N<"u"?aP(UT,N,I,u,p):I}),va(` Static transition to ${F} found`)}else va(" No static transition found");else{let E=!1;for(let F of Object.keys(v.statics))if(!!F.startsWith(u)){if(u===F)for(let{to:N,reducer:U}of v.statics[F])h.push({node:N,state:typeof U<"u"?aP(UT,U,I,u,p):I}),va(` Static transition to ${N} found`);else for(let{to:N}of v.statics[F])h.push({node:N,state:{...I,remainder:F.slice(u.length)}}),va(` Static transition to ${N} found (partial match)`);E=!0}E||va(" No partial static transition found")}if(!A)for(let[E,{to:F,reducer:N}]of v.dynamics)aP($je,E,I,u,p)&&(h.push({node:F,state:typeof N<"u"?aP(UT,N,I,u,p):I}),va(` Dynamic transition to ${F} found (via ${E})`))}if(h.length===0&&A&&e.length===1)return[{node:cn.InitialNode,state:yV}];if(h.length===0)throw new my(e,o.filter(({node:C})=>C!==cn.ErrorNode).map(({state:C})=>({usage:C.candidateUsage,reason:null})));if(h.every(({node:C})=>C===cn.ErrorNode))throw new my(e,h.map(({state:C})=>({usage:C.candidateUsage,reason:C.errorMessage})));o=zje(h)}if(o.length>0){va(" Results:");for(let n of o)va(` - ${n.node} -> ${JSON.stringify(n.state)}`)}else va(" No results");return o}function Vje(t,e,{endToken:r=Hn.EndOfInput}={}){let o=Kje(t,[...e,r]);return Jje(e,o.map(({state:a})=>a))}function zje(t){let e=0;for(let{state:r}of t)r.path.length>e&&(e=r.path.length);return t.filter(({state:r})=>r.path.length===e)}function Jje(t,e){let r=e.filter(v=>v.selectedIndex!==null),o=r.filter(v=>!v.partial);if(o.length>0&&(r=o),r.length===0)throw new Error;let a=r.filter(v=>v.selectedIndex===ed||v.requiredOptions.every(b=>b.some(E=>v.options.find(F=>F.name===E))));if(a.length===0)throw new my(t,r.map(v=>({usage:v.candidateUsage,reason:null})));let n=0;for(let v of a)v.path.length>n&&(n=v.path.length);let u=a.filter(v=>v.path.length===n),A=v=>v.positionals.filter(({extra:b})=>!b).length+v.options.length,p=u.map(v=>({state:v,positionalCount:A(v)})),h=0;for(let{positionalCount:v}of p)v>h&&(h=v);let C=p.filter(({positionalCount:v})=>v===h).map(({state:v})=>v),I=Xje(C);if(I.length>1)throw new tP(t,I.map(v=>v.candidateUsage));return I[0]}function Xje(t){let e=[],r=[];for(let o of t)o.selectedIndex===ed?r.push(o):e.push(o);return r.length>0&&e.push({...yV,path:CV(...r.map(o=>o.path)),options:r.reduce((o,a)=>o.concat(a.options),[])}),e}function CV(t,e,...r){return e===void 0?Array.from(t):CV(t.filter((o,a)=>o===e[a]),...r)}function $a(){return{dynamics:[],shortcuts:[],statics:{}}}function wV(t){return t===cn.SuccessNode||t===cn.ErrorNode}function MT(t,e=0){return{to:wV(t.to)?t.to:t.to>=cn.CustomNode?t.to+e-cn.CustomNode+1:t.to+e,reducer:t.reducer}}function Zje(t,e=0){let r=$a();for(let[o,a]of t.dynamics)r.dynamics.push([o,MT(a,e)]);for(let o of t.shortcuts)r.shortcuts.push(MT(o,e));for(let[o,a]of Object.entries(t.statics))r.statics[o]=a.map(n=>MT(n,e));return r}function Ss(t,e,r,o,a){t.nodes[e].dynamics.push([r,{to:o,reducer:a}])}function Cy(t,e,r,o){t.nodes[e].shortcuts.push({to:r,reducer:o})}function zo(t,e,r,o,a){(Object.prototype.hasOwnProperty.call(t.nodes[e].statics,r)?t.nodes[e].statics[r]:t.nodes[e].statics[r]=[]).push({to:o,reducer:a})}function aP(t,e,r,o,a){if(Array.isArray(e)){let[n,...u]=e;return t[n](r,o,a,...u)}else return t[e](r,o,a)}var yV,$je,UT,el,_T,wy,lP=yt(()=>{eP();rP();yV={candidateUsage:null,requiredOptions:[],errorMessage:null,ignoreOptions:!1,path:[],positionals:[],options:[],remainder:null,selectedIndex:ed,partial:!1,tokens:[]};$je={always:()=>!0,isOptionLike:(t,e)=>!t.ignoreOptions&&e!=="-"&&e.startsWith("-"),isNotOptionLike:(t,e)=>t.ignoreOptions||e==="-"||!e.startsWith("-"),isOption:(t,e,r,o)=>!t.ignoreOptions&&e===o,isBatchOption:(t,e,r,o)=>!t.ignoreOptions&&cV.test(e)&&[...e.slice(1)].every(a=>o.has(`-${a}`)),isBoundOption:(t,e,r,o,a)=>{let n=e.match(PT);return!t.ignoreOptions&&!!n&&$D.test(n[1])&&o.has(n[1])&&a.filter(u=>u.nameSet.includes(n[1])).every(u=>u.allowBinding)},isNegatedOption:(t,e,r,o)=>!t.ignoreOptions&&e===`--no-${o.slice(2)}`,isHelp:(t,e)=>!t.ignoreOptions&&DT.test(e),isUnsupportedOption:(t,e,r,o)=>!t.ignoreOptions&&e.startsWith("-")&&$D.test(e)&&!o.has(e),isInvalidOption:(t,e)=>!t.ignoreOptions&&e.startsWith("-")&&!$D.test(e)},UT={setCandidateState:(t,e,r,o)=>({...t,...o}),setSelectedIndex:(t,e,r,o)=>({...t,selectedIndex:o}),setPartialIndex:(t,e,r,o)=>({...t,selectedIndex:o,partial:!0}),pushBatch:(t,e,r,o)=>{let a=t.options.slice(),n=t.tokens.slice();for(let u=1;u<e.length;++u){let A=o.get(`-${e[u]}`),p=u===1?[0,2]:[u,u+1];a.push({name:A,value:!0}),n.push({segmentIndex:r,type:"option",option:A,slice:p})}return{...t,options:a,tokens:n}},pushBound:(t,e,r)=>{let[,o,a]=e.match(PT),n=t.options.concat({name:o,value:a}),u=t.tokens.concat([{segmentIndex:r,type:"option",slice:[0,o.length],option:o},{segmentIndex:r,type:"assign",slice:[o.length,o.length+1]},{segmentIndex:r,type:"value",slice:[o.length+1,o.length+a.length+1]}]);return{...t,options:n,tokens:u}},pushPath:(t,e,r)=>{let o=t.path.concat(e),a=t.tokens.concat({segmentIndex:r,type:"path"});return{...t,path:o,tokens:a}},pushPositional:(t,e,r)=>{let o=t.positionals.concat({value:e,extra:!1}),a=t.tokens.concat({segmentIndex:r,type:"positional"});return{...t,positionals:o,tokens:a}},pushExtra:(t,e,r)=>{let o=t.positionals.concat({value:e,extra:!0}),a=t.tokens.concat({segmentIndex:r,type:"positional"});return{...t,positionals:o,tokens:a}},pushExtraNoLimits:(t,e,r)=>{let o=t.positionals.concat({value:e,extra:el}),a=t.tokens.concat({segmentIndex:r,type:"positional"});return{...t,positionals:o,tokens:a}},pushTrue:(t,e,r,o)=>{let a=t.options.concat({name:o,value:!0}),n=t.tokens.concat({segmentIndex:r,type:"option",option:o});return{...t,options:a,tokens:n}},pushFalse:(t,e,r,o)=>{let a=t.options.concat({name:o,value:!1}),n=t.tokens.concat({segmentIndex:r,type:"option",option:o});return{...t,options:a,tokens:n}},pushUndefined:(t,e,r,o)=>{let a=t.options.concat({name:e,value:void 0}),n=t.tokens.concat({segmentIndex:r,type:"option",option:e});return{...t,options:a,tokens:n}},pushStringValue:(t,e,r)=>{var o;let a=t.options[t.options.length-1],n=t.options.slice(),u=t.tokens.concat({segmentIndex:r,type:"value"});return a.value=((o=a.value)!==null&&o!==void 0?o:[]).concat([e]),{...t,options:n,tokens:u}},setStringValue:(t,e,r)=>{let o=t.options[t.options.length-1],a=t.options.slice(),n=t.tokens.concat({segmentIndex:r,type:"value"});return o.value=e,{...t,options:a,tokens:n}},inhibateOptions:t=>({...t,ignoreOptions:!0}),useHelp:(t,e,r,o)=>{let[,,a]=e.match(DT);return typeof a<"u"?{...t,options:[{name:"-c",value:String(o)},{name:"-i",value:a}]}:{...t,options:[{name:"-c",value:String(o)}]}},setError:(t,e,r,o)=>e===Hn.EndOfInput||e===Hn.EndOfPartialInput?{...t,errorMessage:`${o}.`}:{...t,errorMessage:`${o} ("${e}").`},setOptionArityError:(t,e)=>{let r=t.options[t.options.length-1];return{...t,errorMessage:`Not enough arguments to option ${r.name}.`}}},el=Symbol(),_T=class{constructor(e,r){this.allOptionNames=new Map,this.arity={leading:[],trailing:[],extra:[],proxy:!1},this.options=[],this.paths=[],this.cliIndex=e,this.cliOpts=r}addPath(e){this.paths.push(e)}setArity({leading:e=this.arity.leading,trailing:r=this.arity.trailing,extra:o=this.arity.extra,proxy:a=this.arity.proxy}){Object.assign(this.arity,{leading:e,trailing:r,extra:o,proxy:a})}addPositional({name:e="arg",required:r=!0}={}){if(!r&&this.arity.extra===el)throw new Error("Optional parameters cannot be declared when using .rest() or .proxy()");if(!r&&this.arity.trailing.length>0)throw new Error("Optional parameters cannot be declared after the required trailing positional arguments");!r&&this.arity.extra!==el?this.arity.extra.push(e):this.arity.extra!==el&&this.arity.extra.length===0?this.arity.leading.push(e):this.arity.trailing.push(e)}addRest({name:e="arg",required:r=0}={}){if(this.arity.extra===el)throw new Error("Infinite lists cannot be declared multiple times in the same command");if(this.arity.trailing.length>0)throw new Error("Infinite lists cannot be declared after the required trailing positional arguments");for(let o=0;o<r;++o)this.addPositional({name:e});this.arity.extra=el}addProxy({required:e=0}={}){this.addRest({required:e}),this.arity.proxy=!0}addOption({names:e,description:r,arity:o=0,hidden:a=!1,required:n=!1,allowBinding:u=!0}){if(!u&&o>1)throw new Error("The arity cannot be higher than 1 when the option only supports the --arg=value syntax");if(!Number.isInteger(o))throw new Error(`The arity must be an integer, got ${o}`);if(o<0)throw new Error(`The arity must be positive, got ${o}`);let A=e.reduce((p,h)=>h.length>p.length?h:p,"");for(let p of e)this.allOptionNames.set(p,A);this.options.push({preferredName:A,nameSet:e,description:r,arity:o,hidden:a,required:n,allowBinding:u})}setContext(e){this.context=e}usage({detailed:e=!0,inlineOptions:r=!0}={}){let o=[this.cliOpts.binaryName],a=[];if(this.paths.length>0&&o.push(...this.paths[0]),e){for(let{preferredName:u,nameSet:A,arity:p,hidden:h,description:C,required:I}of this.options){if(h)continue;let v=[];for(let E=0;E<p;++E)v.push(` #${E}`);let b=`${A.join(",")}${v.join("")}`;!r&&C?a.push({preferredName:u,nameSet:A,definition:b,description:C,required:I}):o.push(I?`<${b}>`:`[${b}]`)}o.push(...this.arity.leading.map(u=>`<${u}>`)),this.arity.extra===el?o.push("..."):o.push(...this.arity.extra.map(u=>`[${u}]`)),o.push(...this.arity.trailing.map(u=>`<${u}>`))}return{usage:o.join(" "),options:a}}compile(){if(typeof this.context>"u")throw new Error("Assertion failed: No context attached");let e=EV(),r=cn.InitialNode,o=this.usage().usage,a=this.options.filter(A=>A.required).map(A=>A.nameSet);r=Oc(e,$a()),zo(e,cn.InitialNode,Hn.StartOfInput,r,["setCandidateState",{candidateUsage:o,requiredOptions:a}]);let n=this.arity.proxy?"always":"isNotOptionLike",u=this.paths.length>0?this.paths:[[]];for(let A of u){let p=r;if(A.length>0){let v=Oc(e,$a());Cy(e,p,v),this.registerOptions(e,v),p=v}for(let v=0;v<A.length;++v){let b=Oc(e,$a());zo(e,p,A[v],b,"pushPath"),p=b}if(this.arity.leading.length>0||!this.arity.proxy){let v=Oc(e,$a());Ss(e,p,"isHelp",v,["useHelp",this.cliIndex]),Ss(e,v,"always",v,"pushExtra"),zo(e,v,Hn.EndOfInput,cn.SuccessNode,["setSelectedIndex",ed]),this.registerOptions(e,p)}this.arity.leading.length>0&&(zo(e,p,Hn.EndOfInput,cn.ErrorNode,["setError","Not enough positional arguments"]),zo(e,p,Hn.EndOfPartialInput,cn.SuccessNode,["setPartialIndex",this.cliIndex]));let h=p;for(let v=0;v<this.arity.leading.length;++v){let b=Oc(e,$a());(!this.arity.proxy||v+1!==this.arity.leading.length)&&this.registerOptions(e,b),(this.arity.trailing.length>0||v+1!==this.arity.leading.length)&&(zo(e,b,Hn.EndOfInput,cn.ErrorNode,["setError","Not enough positional arguments"]),zo(e,b,Hn.EndOfPartialInput,cn.SuccessNode,["setPartialIndex",this.cliIndex])),Ss(e,h,"isNotOptionLike",b,"pushPositional"),h=b}let C=h;if(this.arity.extra===el||this.arity.extra.length>0){let v=Oc(e,$a());if(Cy(e,h,v),this.arity.extra===el){let b=Oc(e,$a());this.arity.proxy||this.registerOptions(e,b),Ss(e,h,n,b,"pushExtraNoLimits"),Ss(e,b,n,b,"pushExtraNoLimits"),Cy(e,b,v)}else for(let b=0;b<this.arity.extra.length;++b){let E=Oc(e,$a());(!this.arity.proxy||b>0)&&this.registerOptions(e,E),Ss(e,C,n,E,"pushExtra"),Cy(e,E,v),C=E}C=v}this.arity.trailing.length>0&&(zo(e,C,Hn.EndOfInput,cn.ErrorNode,["setError","Not enough positional arguments"]),zo(e,C,Hn.EndOfPartialInput,cn.SuccessNode,["setPartialIndex",this.cliIndex]));let I=C;for(let v=0;v<this.arity.trailing.length;++v){let b=Oc(e,$a());this.arity.proxy||this.registerOptions(e,b),v+1<this.arity.trailing.length&&(zo(e,b,Hn.EndOfInput,cn.ErrorNode,["setError","Not enough positional arguments"]),zo(e,b,Hn.EndOfPartialInput,cn.SuccessNode,["setPartialIndex",this.cliIndex])),Ss(e,I,"isNotOptionLike",b,"pushPositional"),I=b}Ss(e,I,n,cn.ErrorNode,["setError","Extraneous positional argument"]),zo(e,I,Hn.EndOfInput,cn.SuccessNode,["setSelectedIndex",this.cliIndex]),zo(e,I,Hn.EndOfPartialInput,cn.SuccessNode,["setSelectedIndex",this.cliIndex])}return{machine:e,context:this.context}}registerOptions(e,r){Ss(e,r,["isOption","--"],r,"inhibateOptions"),Ss(e,r,["isBatchOption",this.allOptionNames],r,["pushBatch",this.allOptionNames]),Ss(e,r,["isBoundOption",this.allOptionNames,this.options],r,"pushBound"),Ss(e,r,["isUnsupportedOption",this.allOptionNames],cn.ErrorNode,["setError","Unsupported option name"]),Ss(e,r,["isInvalidOption"],cn.ErrorNode,["setError","Invalid option name"]);for(let o of this.options)if(o.arity===0)for(let a of o.nameSet)Ss(e,r,["isOption",a],r,["pushTrue",o.preferredName]),a.startsWith("--")&&!a.startsWith("--no-")&&Ss(e,r,["isNegatedOption",a],r,["pushFalse",o.preferredName]);else{let a=Oc(e,$a());for(let n of o.nameSet)Ss(e,r,["isOption",n],a,["pushUndefined",o.preferredName]);for(let n=0;n<o.arity;++n){let u=Oc(e,$a());zo(e,a,Hn.EndOfInput,cn.ErrorNode,"setOptionArityError"),zo(e,a,Hn.EndOfPartialInput,cn.ErrorNode,"setOptionArityError"),Ss(e,a,"isOptionLike",cn.ErrorNode,"setOptionArityError");let A=o.arity===1?"setStringValue":"pushStringValue";Ss(e,a,"isNotOptionLike",u,A),a=u}Cy(e,a,r)}}},wy=class{constructor({binaryName:e="..."}={}){this.builders=[],this.opts={binaryName:e}}static build(e,r={}){return new wy(r).commands(e).compile()}getBuilderByIndex(e){if(!(e>=0&&e<this.builders.length))throw new Error(`Assertion failed: Out-of-bound command index (${e})`);return this.builders[e]}commands(e){for(let r of e)r(this.command());return this}command(){let e=new _T(this.builders.length,this.opts);return this.builders.push(e),e}compile(){let e=[],r=[];for(let a of this.builders){let{machine:n,context:u}=a.compile();e.push(n),r.push(u)}let o=Gje(e);return Yje(o),{machine:o,contexts:r,process:(a,{partial:n}={})=>{let u=n?Hn.EndOfPartialInput:Hn.EndOfInput;return Vje(o,a,{endToken:u})}}}}});function BV(){return cP.default&&"getColorDepth"in cP.default.WriteStream.prototype?cP.default.WriteStream.prototype.getColorDepth():process.env.FORCE_COLOR==="0"?1:process.env.FORCE_COLOR==="1"||typeof process.stdout<"u"&&process.stdout.isTTY?8:1}function vV(t){let e=IV;if(typeof e>"u"){if(t.stdout===process.stdout&&t.stderr===process.stderr)return null;let{AsyncLocalStorage:r}=Be("async_hooks");e=IV=new r;let o=process.stdout._write;process.stdout._write=function(n,u,A){let p=e.getStore();return typeof p>"u"?o.call(this,n,u,A):p.stdout.write(n,u,A)};let a=process.stderr._write;process.stderr._write=function(n,u,A){let p=e.getStore();return typeof p>"u"?a.call(this,n,u,A):p.stderr.write(n,u,A)}}return r=>e.run(t,r)}var cP,IV,DV=yt(()=>{cP=$e(Be("tty"),1)});var Iy,PV=yt(()=>{Wp();Iy=class extends nt{constructor(e){super(),this.contexts=e,this.commands=[]}static from(e,r){let o=new Iy(r);o.path=e.path;for(let a of e.options)switch(a.name){case"-c":o.commands.push(Number(a.value));break;case"-i":o.index=Number(a.value);break}return o}async execute(){let e=this.commands;if(typeof this.index<"u"&&this.index>=0&&this.index<e.length&&(e=[e[this.index]]),e.length===0)this.context.stdout.write(this.cli.usage());else if(e.length===1)this.context.stdout.write(this.cli.usage(this.contexts[e[0]].commandClass,{detailed:!0}));else if(e.length>1){this.context.stdout.write(`Multiple commands match your selection: -`),this.context.stdout.write(` -`);let r=0;for(let o of this.commands)this.context.stdout.write(this.cli.usage(this.contexts[o].commandClass,{prefix:`${r++}. `.padStart(5)}));this.context.stdout.write(` -`),this.context.stdout.write(`Run again with -h=<index> to see the longer details of any of those commands. -`)}}}});async function bV(...t){let{resolvedOptions:e,resolvedCommandClasses:r,resolvedArgv:o,resolvedContext:a}=QV(t);return as.from(r,e).runExit(o,a)}async function kV(...t){let{resolvedOptions:e,resolvedCommandClasses:r,resolvedArgv:o,resolvedContext:a}=QV(t);return as.from(r,e).run(o,a)}function QV(t){let e,r,o,a;switch(typeof process<"u"&&typeof process.argv<"u"&&(o=process.argv.slice(2)),t.length){case 1:r=t[0];break;case 2:t[0]&&t[0].prototype instanceof nt||Array.isArray(t[0])?(r=t[0],Array.isArray(t[1])?o=t[1]:a=t[1]):(e=t[0],r=t[1]);break;case 3:Array.isArray(t[2])?(e=t[0],r=t[1],o=t[2]):t[0]&&t[0].prototype instanceof nt||Array.isArray(t[0])?(r=t[0],o=t[1],a=t[2]):(e=t[0],r=t[1],a=t[2]);break;default:e=t[0],r=t[1],o=t[2],a=t[3];break}if(typeof o>"u")throw new Error("The argv parameter must be provided when running Clipanion outside of a Node context");return{resolvedOptions:e,resolvedCommandClasses:r,resolvedArgv:o,resolvedContext:a}}function xV(t){return t()}var SV,as,FV=yt(()=>{eP();lP();kT();DV();Wp();PV();SV=Symbol("clipanion/errorCommand");as=class{constructor({binaryLabel:e,binaryName:r="...",binaryVersion:o,enableCapture:a=!1,enableColors:n}={}){this.registrations=new Map,this.builder=new wy({binaryName:r}),this.binaryLabel=e,this.binaryName=r,this.binaryVersion=o,this.enableCapture=a,this.enableColors=n}static from(e,r={}){let o=new as(r),a=Array.isArray(e)?e:[e];for(let n of a)o.register(n);return o}register(e){var r;let o=new Map,a=new e;for(let p in a){let h=a[p];typeof h=="object"&&h!==null&&h[nt.isOption]&&o.set(p,h)}let n=this.builder.command(),u=n.cliIndex,A=(r=e.paths)!==null&&r!==void 0?r:a.paths;if(typeof A<"u")for(let p of A)n.addPath(p);this.registrations.set(e,{specs:o,builder:n,index:u});for(let[p,{definition:h}]of o.entries())h(n,p);n.setContext({commandClass:e})}process(e,r){let{input:o,context:a,partial:n}=typeof e=="object"&&Array.isArray(e)?{input:e,context:r}:e,{contexts:u,process:A}=this.builder.compile(),p=A(o,{partial:n}),h={...as.defaultContext,...a};switch(p.selectedIndex){case ed:{let C=Iy.from(p,u);return C.context=h,C.tokens=p.tokens,C}default:{let{commandClass:C}=u[p.selectedIndex],I=this.registrations.get(C);if(typeof I>"u")throw new Error("Assertion failed: Expected the command class to have been registered.");let v=new C;v.context=h,v.tokens=p.tokens,v.path=p.path;try{for(let[b,{transformer:E}]of I.specs.entries())v[b]=E(I.builder,b,p,h);return v}catch(b){throw b[SV]=v,b}}break}}async run(e,r){var o,a;let n,u={...as.defaultContext,...r},A=(o=this.enableColors)!==null&&o!==void 0?o:u.colorDepth>1;if(!Array.isArray(e))n=e;else try{n=this.process(e,u)}catch(C){return u.stdout.write(this.error(C,{colored:A})),1}if(n.help)return u.stdout.write(this.usage(n,{colored:A,detailed:!0})),0;n.context=u,n.cli={binaryLabel:this.binaryLabel,binaryName:this.binaryName,binaryVersion:this.binaryVersion,enableCapture:this.enableCapture,enableColors:this.enableColors,definitions:()=>this.definitions(),definition:C=>this.definition(C),error:(C,I)=>this.error(C,I),format:C=>this.format(C),process:(C,I)=>this.process(C,{...u,...I}),run:(C,I)=>this.run(C,{...u,...I}),usage:(C,I)=>this.usage(C,I)};let p=this.enableCapture&&(a=vV(u))!==null&&a!==void 0?a:xV,h;try{h=await p(()=>n.validateAndExecute().catch(C=>n.catch(C).then(()=>0)))}catch(C){return u.stdout.write(this.error(C,{colored:A,command:n})),1}return h}async runExit(e,r){process.exitCode=await this.run(e,r)}definition(e,{colored:r=!1}={}){if(!e.usage)return null;let{usage:o}=this.getUsageByRegistration(e,{detailed:!1}),{usage:a,options:n}=this.getUsageByRegistration(e,{detailed:!0,inlineOptions:!1}),u=typeof e.usage.category<"u"?Do(e.usage.category,{format:this.format(r),paragraphs:!1}):void 0,A=typeof e.usage.description<"u"?Do(e.usage.description,{format:this.format(r),paragraphs:!1}):void 0,p=typeof e.usage.details<"u"?Do(e.usage.details,{format:this.format(r),paragraphs:!0}):void 0,h=typeof e.usage.examples<"u"?e.usage.examples.map(([C,I])=>[Do(C,{format:this.format(r),paragraphs:!1}),I.replace(/\$0/g,this.binaryName)]):void 0;return{path:o,usage:a,category:u,description:A,details:p,examples:h,options:n}}definitions({colored:e=!1}={}){let r=[];for(let o of this.registrations.keys()){let a=this.definition(o,{colored:e});!a||r.push(a)}return r}usage(e=null,{colored:r,detailed:o=!1,prefix:a="$ "}={}){var n;if(e===null){for(let p of this.registrations.keys()){let h=p.paths,C=typeof p.usage<"u";if(!h||h.length===0||h.length===1&&h[0].length===0||((n=h?.some(b=>b.length===0))!==null&&n!==void 0?n:!1))if(e){e=null;break}else e=p;else if(C){e=null;continue}}e&&(o=!0)}let u=e!==null&&e instanceof nt?e.constructor:e,A="";if(u)if(o){let{description:p="",details:h="",examples:C=[]}=u.usage||{};p!==""&&(A+=Do(p,{format:this.format(r),paragraphs:!1}).replace(/^./,b=>b.toUpperCase()),A+=` -`),(h!==""||C.length>0)&&(A+=`${this.format(r).header("Usage")} -`,A+=` -`);let{usage:I,options:v}=this.getUsageByRegistration(u,{inlineOptions:!1});if(A+=`${this.format(r).bold(a)}${I} -`,v.length>0){A+=` -`,A+=`${this.format(r).header("Options")} -`;let b=v.reduce((E,F)=>Math.max(E,F.definition.length),0);A+=` -`;for(let{definition:E,description:F}of v)A+=` ${this.format(r).bold(E.padEnd(b))} ${Do(F,{format:this.format(r),paragraphs:!1})}`}if(h!==""&&(A+=` -`,A+=`${this.format(r).header("Details")} -`,A+=` -`,A+=Do(h,{format:this.format(r),paragraphs:!0})),C.length>0){A+=` -`,A+=`${this.format(r).header("Examples")} -`;for(let[b,E]of C)A+=` -`,A+=Do(b,{format:this.format(r),paragraphs:!1}),A+=`${E.replace(/^/m,` ${this.format(r).bold(a)}`).replace(/\$0/g,this.binaryName)} -`}}else{let{usage:p}=this.getUsageByRegistration(u);A+=`${this.format(r).bold(a)}${p} -`}else{let p=new Map;for(let[v,{index:b}]of this.registrations.entries()){if(typeof v.usage>"u")continue;let E=typeof v.usage.category<"u"?Do(v.usage.category,{format:this.format(r),paragraphs:!1}):null,F=p.get(E);typeof F>"u"&&p.set(E,F=[]);let{usage:N}=this.getUsageByIndex(b);F.push({commandClass:v,usage:N})}let h=Array.from(p.keys()).sort((v,b)=>v===null?-1:b===null?1:v.localeCompare(b,"en",{usage:"sort",caseFirst:"upper"})),C=typeof this.binaryLabel<"u",I=typeof this.binaryVersion<"u";C||I?(C&&I?A+=`${this.format(r).header(`${this.binaryLabel} - ${this.binaryVersion}`)} - -`:C?A+=`${this.format(r).header(`${this.binaryLabel}`)} -`:A+=`${this.format(r).header(`${this.binaryVersion}`)} -`,A+=` ${this.format(r).bold(a)}${this.binaryName} <command> -`):A+=`${this.format(r).bold(a)}${this.binaryName} <command> -`;for(let v of h){let b=p.get(v).slice().sort((F,N)=>F.usage.localeCompare(N.usage,"en",{usage:"sort",caseFirst:"upper"})),E=v!==null?v.trim():"General commands";A+=` -`,A+=`${this.format(r).header(`${E}`)} -`;for(let{commandClass:F,usage:N}of b){let U=F.usage.description||"undocumented";A+=` -`,A+=` ${this.format(r).bold(N)} -`,A+=` ${Do(U,{format:this.format(r),paragraphs:!1})}`}}A+=` -`,A+=Do("You can also print more details about any of these commands by calling them with the `-h,--help` flag right after the command name.",{format:this.format(r),paragraphs:!0})}return A}error(e,r){var o,{colored:a,command:n=(o=e[SV])!==null&&o!==void 0?o:null}=r===void 0?{}:r;(!e||typeof e!="object"||!("stack"in e))&&(e=new Error(`Execution failed with a non-error rejection (rejected value: ${JSON.stringify(e)})`));let u="",A=e.name.replace(/([a-z])([A-Z])/g,"$1 $2");A==="Error"&&(A="Internal Error"),u+=`${this.format(a).error(A)}: ${e.message} -`;let p=e.clipanion;return typeof p<"u"?p.type==="usage"&&(u+=` -`,u+=this.usage(n)):e.stack&&(u+=`${e.stack.replace(/^.*\n/,"")} -`),u}format(e){var r;return((r=e??this.enableColors)!==null&&r!==void 0?r:as.defaultContext.colorDepth>1)?uV:AV}getUsageByRegistration(e,r){let o=this.registrations.get(e);if(typeof o>"u")throw new Error("Assertion failed: Unregistered command");return this.getUsageByIndex(o.index,r)}getUsageByIndex(e,r){return this.builder.getBuilderByIndex(e).usage(r)}};as.defaultContext={env:process.env,stdin:process.stdin,stdout:process.stdout,stderr:process.stderr,colorDepth:BV()}});var lI,RV=yt(()=>{Wp();lI=class extends nt{async execute(){this.context.stdout.write(`${JSON.stringify(this.cli.definitions(),null,2)} -`)}};lI.paths=[["--clipanion=definitions"]]});var cI,TV=yt(()=>{Wp();cI=class extends nt{async execute(){this.context.stdout.write(this.cli.usage())}};cI.paths=[["-h"],["--help"]]});function uP(t={}){return Ko({definition(e,r){var o;e.addProxy({name:(o=t.name)!==null&&o!==void 0?o:r,required:t.required})},transformer(e,r,o){return o.positionals.map(({value:a})=>a)}})}var HT=yt(()=>{yf()});var uI,LV=yt(()=>{Wp();HT();uI=class extends nt{constructor(){super(...arguments),this.args=uP()}async execute(){this.context.stdout.write(`${JSON.stringify(this.cli.process(this.args).tokens,null,2)} -`)}};uI.paths=[["--clipanion=tokens"]]});var AI,NV=yt(()=>{Wp();AI=class extends nt{async execute(){var e;this.context.stdout.write(`${(e=this.cli.binaryVersion)!==null&&e!==void 0?e:"<unknown>"} -`)}};AI.paths=[["-v"],["--version"]]});var jT={};Vt(jT,{DefinitionsCommand:()=>lI,HelpCommand:()=>cI,TokensCommand:()=>uI,VersionCommand:()=>AI});var OV=yt(()=>{RV();TV();LV();NV()});function MV(t,e,r){let[o,a]=qu(e,r??{}),{arity:n=1}=a,u=t.split(","),A=new Set(u);return Ko({definition(p){p.addOption({names:u,arity:n,hidden:a?.hidden,description:a?.description,required:a.required})},transformer(p,h,C){let I,v=typeof o<"u"?[...o]:void 0;for(let{name:b,value:E}of C.options)!A.has(b)||(I=b,v=v??[],v.push(E));return typeof v<"u"?td(I??h,v,a.validator):v}})}var UV=yt(()=>{yf()});function _V(t,e,r){let[o,a]=qu(e,r??{}),n=t.split(","),u=new Set(n);return Ko({definition(A){A.addOption({names:n,allowBinding:!1,arity:0,hidden:a.hidden,description:a.description,required:a.required})},transformer(A,p,h){let C=o;for(let{name:I,value:v}of h.options)!u.has(I)||(C=v);return C}})}var HV=yt(()=>{yf()});function jV(t,e,r){let[o,a]=qu(e,r??{}),n=t.split(","),u=new Set(n);return Ko({definition(A){A.addOption({names:n,allowBinding:!1,arity:0,hidden:a.hidden,description:a.description,required:a.required})},transformer(A,p,h){let C=o;for(let{name:I,value:v}of h.options)!u.has(I)||(C??(C=0),v?C+=1:C=0);return C}})}var qV=yt(()=>{yf()});function GV(t={}){return Ko({definition(e,r){var o;e.addRest({name:(o=t.name)!==null&&o!==void 0?o:r,required:t.required})},transformer(e,r,o){let a=u=>{let A=o.positionals[u];return A.extra===el||A.extra===!1&&u<e.arity.leading.length},n=0;for(;n<o.positionals.length&&a(n);)n+=1;return o.positionals.splice(0,n).map(({value:u})=>u)}})}var YV=yt(()=>{lP();yf()});function eqe(t,e,r){let[o,a]=qu(e,r??{}),{arity:n=1}=a,u=t.split(","),A=new Set(u);return Ko({definition(p){p.addOption({names:u,arity:a.tolerateBoolean?0:n,hidden:a.hidden,description:a.description,required:a.required})},transformer(p,h,C,I){let v,b=o;typeof a.env<"u"&&I.env[a.env]&&(v=a.env,b=I.env[a.env]);for(let{name:E,value:F}of C.options)!A.has(E)||(v=E,b=F);return typeof b=="string"?td(v??h,b,a.validator):b}})}function tqe(t={}){let{required:e=!0}=t;return Ko({definition(r,o){var a;r.addPositional({name:(a=t.name)!==null&&a!==void 0?a:o,required:t.required})},transformer(r,o,a){var n;for(let u=0;u<a.positionals.length;++u){if(a.positionals[u].extra===el||e&&a.positionals[u].extra===!0||!e&&a.positionals[u].extra===!1)continue;let[A]=a.positionals.splice(u,1);return td((n=t.name)!==null&&n!==void 0?n:o,A.value,t.validator)}}})}function WV(t,...e){return typeof t=="string"?eqe(t,...e):tqe(t)}var KV=yt(()=>{lP();yf()});var ge={};Vt(ge,{Array:()=>MV,Boolean:()=>_V,Counter:()=>jV,Proxy:()=>uP,Rest:()=>GV,String:()=>WV,applyValidator:()=>td,cleanValidationError:()=>nP,formatError:()=>nI,isOptionSymbol:()=>rI,makeCommandOption:()=>Ko,rerouteArguments:()=>qu});var VV=yt(()=>{yf();HT();UV();HV();qV();YV();KV()});var fI={};Vt(fI,{Builtins:()=>jT,Cli:()=>as,Command:()=>nt,Option:()=>ge,UsageError:()=>it,formatMarkdownish:()=>Do,run:()=>kV,runExit:()=>bV});var qt=yt(()=>{rP();kT();Wp();FV();OV();VV()});var zV=_((Ikt,rqe)=>{rqe.exports={name:"dotenv",version:"16.3.1",description:"Loads environment variables from .env file",main:"lib/main.js",types:"lib/main.d.ts",exports:{".":{types:"./lib/main.d.ts",require:"./lib/main.js",default:"./lib/main.js"},"./config":"./config.js","./config.js":"./config.js","./lib/env-options":"./lib/env-options.js","./lib/env-options.js":"./lib/env-options.js","./lib/cli-options":"./lib/cli-options.js","./lib/cli-options.js":"./lib/cli-options.js","./package.json":"./package.json"},scripts:{"dts-check":"tsc --project tests/types/tsconfig.json",lint:"standard","lint-readme":"standard-markdown",pretest:"npm run lint && npm run dts-check",test:"tap tests/*.js --100 -Rspec",prerelease:"npm test",release:"standard-version"},repository:{type:"git",url:"git://github.com/motdotla/dotenv.git"},funding:"https://github.com/motdotla/dotenv?sponsor=1",keywords:["dotenv","env",".env","environment","variables","config","settings"],readmeFilename:"README.md",license:"BSD-2-Clause",devDependencies:{"@definitelytyped/dtslint":"^0.0.133","@types/node":"^18.11.3",decache:"^4.6.1",sinon:"^14.0.1",standard:"^17.0.0","standard-markdown":"^7.1.0","standard-version":"^9.5.0",tap:"^16.3.0",tar:"^6.1.11",typescript:"^4.8.4"},engines:{node:">=12"},browser:{fs:!1}}});var $V=_((Bkt,Ef)=>{var JV=Be("fs"),GT=Be("path"),nqe=Be("os"),iqe=Be("crypto"),sqe=zV(),YT=sqe.version,oqe=/(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg;function aqe(t){let e={},r=t.toString();r=r.replace(/\r\n?/mg,` -`);let o;for(;(o=oqe.exec(r))!=null;){let a=o[1],n=o[2]||"";n=n.trim();let u=n[0];n=n.replace(/^(['"`])([\s\S]*)\1$/mg,"$2"),u==='"'&&(n=n.replace(/\\n/g,` -`),n=n.replace(/\\r/g,"\r")),e[a]=n}return e}function lqe(t){let e=ZV(t),r=xs.configDotenv({path:e});if(!r.parsed)throw new Error(`MISSING_DATA: Cannot parse ${e} for an unknown reason`);let o=XV(t).split(","),a=o.length,n;for(let u=0;u<a;u++)try{let A=o[u].trim(),p=Aqe(r,A);n=xs.decrypt(p.ciphertext,p.key);break}catch(A){if(u+1>=a)throw A}return xs.parse(n)}function cqe(t){console.log(`[dotenv@${YT}][INFO] ${t}`)}function uqe(t){console.log(`[dotenv@${YT}][WARN] ${t}`)}function qT(t){console.log(`[dotenv@${YT}][DEBUG] ${t}`)}function XV(t){return t&&t.DOTENV_KEY&&t.DOTENV_KEY.length>0?t.DOTENV_KEY:process.env.DOTENV_KEY&&process.env.DOTENV_KEY.length>0?process.env.DOTENV_KEY:""}function Aqe(t,e){let r;try{r=new URL(e)}catch(A){throw A.code==="ERR_INVALID_URL"?new Error("INVALID_DOTENV_KEY: Wrong format. Must be in valid uri format like dotenv://:key_1234@dotenv.org/vault/.env.vault?environment=development"):A}let o=r.password;if(!o)throw new Error("INVALID_DOTENV_KEY: Missing key part");let a=r.searchParams.get("environment");if(!a)throw new Error("INVALID_DOTENV_KEY: Missing environment part");let n=`DOTENV_VAULT_${a.toUpperCase()}`,u=t.parsed[n];if(!u)throw new Error(`NOT_FOUND_DOTENV_ENVIRONMENT: Cannot locate environment ${n} in your .env.vault file.`);return{ciphertext:u,key:o}}function ZV(t){let e=GT.resolve(process.cwd(),".env");return t&&t.path&&t.path.length>0&&(e=t.path),e.endsWith(".vault")?e:`${e}.vault`}function fqe(t){return t[0]==="~"?GT.join(nqe.homedir(),t.slice(1)):t}function pqe(t){cqe("Loading env from encrypted .env.vault");let e=xs._parseVault(t),r=process.env;return t&&t.processEnv!=null&&(r=t.processEnv),xs.populate(r,e,t),{parsed:e}}function hqe(t){let e=GT.resolve(process.cwd(),".env"),r="utf8",o=Boolean(t&&t.debug);t&&(t.path!=null&&(e=fqe(t.path)),t.encoding!=null&&(r=t.encoding));try{let a=xs.parse(JV.readFileSync(e,{encoding:r})),n=process.env;return t&&t.processEnv!=null&&(n=t.processEnv),xs.populate(n,a,t),{parsed:a}}catch(a){return o&&qT(`Failed to load ${e} ${a.message}`),{error:a}}}function gqe(t){let e=ZV(t);return XV(t).length===0?xs.configDotenv(t):JV.existsSync(e)?xs._configVault(t):(uqe(`You set DOTENV_KEY but you are missing a .env.vault file at ${e}. Did you forget to build it?`),xs.configDotenv(t))}function dqe(t,e){let r=Buffer.from(e.slice(-64),"hex"),o=Buffer.from(t,"base64"),a=o.slice(0,12),n=o.slice(-16);o=o.slice(12,-16);try{let u=iqe.createDecipheriv("aes-256-gcm",r,a);return u.setAuthTag(n),`${u.update(o)}${u.final()}`}catch(u){let A=u instanceof RangeError,p=u.message==="Invalid key length",h=u.message==="Unsupported state or unable to authenticate data";if(A||p){let C="INVALID_DOTENV_KEY: It must be 64 characters long (or more)";throw new Error(C)}else if(h){let C="DECRYPTION_FAILED: Please check your DOTENV_KEY";throw new Error(C)}else throw console.error("Error: ",u.code),console.error("Error: ",u.message),u}}function mqe(t,e,r={}){let o=Boolean(r&&r.debug),a=Boolean(r&&r.override);if(typeof e!="object")throw new Error("OBJECT_REQUIRED: Please check the processEnv argument being passed to populate");for(let n of Object.keys(e))Object.prototype.hasOwnProperty.call(t,n)?(a===!0&&(t[n]=e[n]),o&&qT(a===!0?`"${n}" is already defined and WAS overwritten`:`"${n}" is already defined and was NOT overwritten`)):t[n]=e[n]}var xs={configDotenv:hqe,_configVault:pqe,_parseVault:lqe,config:gqe,decrypt:dqe,parse:aqe,populate:mqe};Ef.exports.configDotenv=xs.configDotenv;Ef.exports._configVault=xs._configVault;Ef.exports._parseVault=xs._parseVault;Ef.exports.config=xs.config;Ef.exports.decrypt=xs.decrypt;Ef.exports.parse=xs.parse;Ef.exports.populate=xs.populate;Ef.exports=xs});var tz=_((vkt,ez)=>{"use strict";ez.exports=(t,...e)=>new Promise(r=>{r(t(...e))})});var nd=_((Dkt,WT)=>{"use strict";var yqe=tz(),rz=t=>{if(t<1)throw new TypeError("Expected `concurrency` to be a number from 1 and up");let e=[],r=0,o=()=>{r--,e.length>0&&e.shift()()},a=(A,p,...h)=>{r++;let C=yqe(A,...h);p(C),C.then(o,o)},n=(A,p,...h)=>{r<t?a(A,p,...h):e.push(a.bind(null,A,p,...h))},u=(A,...p)=>new Promise(h=>n(A,h,...p));return Object.defineProperties(u,{activeCount:{get:()=>r},pendingCount:{get:()=>e.length}}),u};WT.exports=rz;WT.exports.default=rz});function Wu(t){return`YN${t.toString(10).padStart(4,"0")}`}function AP(t){let e=Number(t.slice(2));if(typeof wr[e]>"u")throw new Error(`Unknown message name: "${t}"`);return e}var wr,fP=yt(()=>{wr=(Oe=>(Oe[Oe.UNNAMED=0]="UNNAMED",Oe[Oe.EXCEPTION=1]="EXCEPTION",Oe[Oe.MISSING_PEER_DEPENDENCY=2]="MISSING_PEER_DEPENDENCY",Oe[Oe.CYCLIC_DEPENDENCIES=3]="CYCLIC_DEPENDENCIES",Oe[Oe.DISABLED_BUILD_SCRIPTS=4]="DISABLED_BUILD_SCRIPTS",Oe[Oe.BUILD_DISABLED=5]="BUILD_DISABLED",Oe[Oe.SOFT_LINK_BUILD=6]="SOFT_LINK_BUILD",Oe[Oe.MUST_BUILD=7]="MUST_BUILD",Oe[Oe.MUST_REBUILD=8]="MUST_REBUILD",Oe[Oe.BUILD_FAILED=9]="BUILD_FAILED",Oe[Oe.RESOLVER_NOT_FOUND=10]="RESOLVER_NOT_FOUND",Oe[Oe.FETCHER_NOT_FOUND=11]="FETCHER_NOT_FOUND",Oe[Oe.LINKER_NOT_FOUND=12]="LINKER_NOT_FOUND",Oe[Oe.FETCH_NOT_CACHED=13]="FETCH_NOT_CACHED",Oe[Oe.YARN_IMPORT_FAILED=14]="YARN_IMPORT_FAILED",Oe[Oe.REMOTE_INVALID=15]="REMOTE_INVALID",Oe[Oe.REMOTE_NOT_FOUND=16]="REMOTE_NOT_FOUND",Oe[Oe.RESOLUTION_PACK=17]="RESOLUTION_PACK",Oe[Oe.CACHE_CHECKSUM_MISMATCH=18]="CACHE_CHECKSUM_MISMATCH",Oe[Oe.UNUSED_CACHE_ENTRY=19]="UNUSED_CACHE_ENTRY",Oe[Oe.MISSING_LOCKFILE_ENTRY=20]="MISSING_LOCKFILE_ENTRY",Oe[Oe.WORKSPACE_NOT_FOUND=21]="WORKSPACE_NOT_FOUND",Oe[Oe.TOO_MANY_MATCHING_WORKSPACES=22]="TOO_MANY_MATCHING_WORKSPACES",Oe[Oe.CONSTRAINTS_MISSING_DEPENDENCY=23]="CONSTRAINTS_MISSING_DEPENDENCY",Oe[Oe.CONSTRAINTS_INCOMPATIBLE_DEPENDENCY=24]="CONSTRAINTS_INCOMPATIBLE_DEPENDENCY",Oe[Oe.CONSTRAINTS_EXTRANEOUS_DEPENDENCY=25]="CONSTRAINTS_EXTRANEOUS_DEPENDENCY",Oe[Oe.CONSTRAINTS_INVALID_DEPENDENCY=26]="CONSTRAINTS_INVALID_DEPENDENCY",Oe[Oe.CANT_SUGGEST_RESOLUTIONS=27]="CANT_SUGGEST_RESOLUTIONS",Oe[Oe.FROZEN_LOCKFILE_EXCEPTION=28]="FROZEN_LOCKFILE_EXCEPTION",Oe[Oe.CROSS_DRIVE_VIRTUAL_LOCAL=29]="CROSS_DRIVE_VIRTUAL_LOCAL",Oe[Oe.FETCH_FAILED=30]="FETCH_FAILED",Oe[Oe.DANGEROUS_NODE_MODULES=31]="DANGEROUS_NODE_MODULES",Oe[Oe.NODE_GYP_INJECTED=32]="NODE_GYP_INJECTED",Oe[Oe.AUTHENTICATION_NOT_FOUND=33]="AUTHENTICATION_NOT_FOUND",Oe[Oe.INVALID_CONFIGURATION_KEY=34]="INVALID_CONFIGURATION_KEY",Oe[Oe.NETWORK_ERROR=35]="NETWORK_ERROR",Oe[Oe.LIFECYCLE_SCRIPT=36]="LIFECYCLE_SCRIPT",Oe[Oe.CONSTRAINTS_MISSING_FIELD=37]="CONSTRAINTS_MISSING_FIELD",Oe[Oe.CONSTRAINTS_INCOMPATIBLE_FIELD=38]="CONSTRAINTS_INCOMPATIBLE_FIELD",Oe[Oe.CONSTRAINTS_EXTRANEOUS_FIELD=39]="CONSTRAINTS_EXTRANEOUS_FIELD",Oe[Oe.CONSTRAINTS_INVALID_FIELD=40]="CONSTRAINTS_INVALID_FIELD",Oe[Oe.AUTHENTICATION_INVALID=41]="AUTHENTICATION_INVALID",Oe[Oe.PROLOG_UNKNOWN_ERROR=42]="PROLOG_UNKNOWN_ERROR",Oe[Oe.PROLOG_SYNTAX_ERROR=43]="PROLOG_SYNTAX_ERROR",Oe[Oe.PROLOG_EXISTENCE_ERROR=44]="PROLOG_EXISTENCE_ERROR",Oe[Oe.STACK_OVERFLOW_RESOLUTION=45]="STACK_OVERFLOW_RESOLUTION",Oe[Oe.AUTOMERGE_FAILED_TO_PARSE=46]="AUTOMERGE_FAILED_TO_PARSE",Oe[Oe.AUTOMERGE_IMMUTABLE=47]="AUTOMERGE_IMMUTABLE",Oe[Oe.AUTOMERGE_SUCCESS=48]="AUTOMERGE_SUCCESS",Oe[Oe.AUTOMERGE_REQUIRED=49]="AUTOMERGE_REQUIRED",Oe[Oe.DEPRECATED_CLI_SETTINGS=50]="DEPRECATED_CLI_SETTINGS",Oe[Oe.PLUGIN_NAME_NOT_FOUND=51]="PLUGIN_NAME_NOT_FOUND",Oe[Oe.INVALID_PLUGIN_REFERENCE=52]="INVALID_PLUGIN_REFERENCE",Oe[Oe.CONSTRAINTS_AMBIGUITY=53]="CONSTRAINTS_AMBIGUITY",Oe[Oe.CACHE_OUTSIDE_PROJECT=54]="CACHE_OUTSIDE_PROJECT",Oe[Oe.IMMUTABLE_INSTALL=55]="IMMUTABLE_INSTALL",Oe[Oe.IMMUTABLE_CACHE=56]="IMMUTABLE_CACHE",Oe[Oe.INVALID_MANIFEST=57]="INVALID_MANIFEST",Oe[Oe.PACKAGE_PREPARATION_FAILED=58]="PACKAGE_PREPARATION_FAILED",Oe[Oe.INVALID_RANGE_PEER_DEPENDENCY=59]="INVALID_RANGE_PEER_DEPENDENCY",Oe[Oe.INCOMPATIBLE_PEER_DEPENDENCY=60]="INCOMPATIBLE_PEER_DEPENDENCY",Oe[Oe.DEPRECATED_PACKAGE=61]="DEPRECATED_PACKAGE",Oe[Oe.INCOMPATIBLE_OS=62]="INCOMPATIBLE_OS",Oe[Oe.INCOMPATIBLE_CPU=63]="INCOMPATIBLE_CPU",Oe[Oe.FROZEN_ARTIFACT_EXCEPTION=64]="FROZEN_ARTIFACT_EXCEPTION",Oe[Oe.TELEMETRY_NOTICE=65]="TELEMETRY_NOTICE",Oe[Oe.PATCH_HUNK_FAILED=66]="PATCH_HUNK_FAILED",Oe[Oe.INVALID_CONFIGURATION_VALUE=67]="INVALID_CONFIGURATION_VALUE",Oe[Oe.UNUSED_PACKAGE_EXTENSION=68]="UNUSED_PACKAGE_EXTENSION",Oe[Oe.REDUNDANT_PACKAGE_EXTENSION=69]="REDUNDANT_PACKAGE_EXTENSION",Oe[Oe.AUTO_NM_SUCCESS=70]="AUTO_NM_SUCCESS",Oe[Oe.NM_CANT_INSTALL_EXTERNAL_SOFT_LINK=71]="NM_CANT_INSTALL_EXTERNAL_SOFT_LINK",Oe[Oe.NM_PRESERVE_SYMLINKS_REQUIRED=72]="NM_PRESERVE_SYMLINKS_REQUIRED",Oe[Oe.UPDATE_LOCKFILE_ONLY_SKIP_LINK=73]="UPDATE_LOCKFILE_ONLY_SKIP_LINK",Oe[Oe.NM_HARDLINKS_MODE_DOWNGRADED=74]="NM_HARDLINKS_MODE_DOWNGRADED",Oe[Oe.PROLOG_INSTANTIATION_ERROR=75]="PROLOG_INSTANTIATION_ERROR",Oe[Oe.INCOMPATIBLE_ARCHITECTURE=76]="INCOMPATIBLE_ARCHITECTURE",Oe[Oe.GHOST_ARCHITECTURE=77]="GHOST_ARCHITECTURE",Oe[Oe.RESOLUTION_MISMATCH=78]="RESOLUTION_MISMATCH",Oe[Oe.PROLOG_LIMIT_EXCEEDED=79]="PROLOG_LIMIT_EXCEEDED",Oe[Oe.NETWORK_DISABLED=80]="NETWORK_DISABLED",Oe[Oe.NETWORK_UNSAFE_HTTP=81]="NETWORK_UNSAFE_HTTP",Oe[Oe.RESOLUTION_FAILED=82]="RESOLUTION_FAILED",Oe[Oe.AUTOMERGE_GIT_ERROR=83]="AUTOMERGE_GIT_ERROR",Oe[Oe.CONSTRAINTS_CHECK_FAILED=84]="CONSTRAINTS_CHECK_FAILED",Oe[Oe.UPDATED_RESOLUTION_RECORD=85]="UPDATED_RESOLUTION_RECORD",Oe[Oe.EXPLAIN_PEER_DEPENDENCIES_CTA=86]="EXPLAIN_PEER_DEPENDENCIES_CTA",Oe[Oe.MIGRATION_SUCCESS=87]="MIGRATION_SUCCESS",Oe[Oe.VERSION_NOTICE=88]="VERSION_NOTICE",Oe[Oe.TIPS_NOTICE=89]="TIPS_NOTICE",Oe[Oe.OFFLINE_MODE_ENABLED=90]="OFFLINE_MODE_ENABLED",Oe))(wr||{})});var pI=_((Skt,nz)=>{var Eqe="2.0.0",Cqe=Number.MAX_SAFE_INTEGER||9007199254740991,wqe=16,Iqe=256-6,Bqe=["major","premajor","minor","preminor","patch","prepatch","prerelease"];nz.exports={MAX_LENGTH:256,MAX_SAFE_COMPONENT_LENGTH:wqe,MAX_SAFE_BUILD_LENGTH:Iqe,MAX_SAFE_INTEGER:Cqe,RELEASE_TYPES:Bqe,SEMVER_SPEC_VERSION:Eqe,FLAG_INCLUDE_PRERELEASE:1,FLAG_LOOSE:2}});var hI=_((xkt,iz)=>{var vqe=typeof process=="object"&&process.env&&process.env.NODE_DEBUG&&/\bsemver\b/i.test(process.env.NODE_DEBUG)?(...t)=>console.error("SEMVER",...t):()=>{};iz.exports=vqe});var By=_((Cf,sz)=>{var{MAX_SAFE_COMPONENT_LENGTH:KT,MAX_SAFE_BUILD_LENGTH:Dqe,MAX_LENGTH:Pqe}=pI(),Sqe=hI();Cf=sz.exports={};var xqe=Cf.re=[],bqe=Cf.safeRe=[],lr=Cf.src=[],cr=Cf.t={},kqe=0,VT="[a-zA-Z0-9-]",Qqe=[["\\s",1],["\\d",Pqe],[VT,Dqe]],Fqe=t=>{for(let[e,r]of Qqe)t=t.split(`${e}*`).join(`${e}{0,${r}}`).split(`${e}+`).join(`${e}{1,${r}}`);return t},zr=(t,e,r)=>{let o=Fqe(e),a=kqe++;Sqe(t,a,e),cr[t]=a,lr[a]=e,xqe[a]=new RegExp(e,r?"g":void 0),bqe[a]=new RegExp(o,r?"g":void 0)};zr("NUMERICIDENTIFIER","0|[1-9]\\d*");zr("NUMERICIDENTIFIERLOOSE","\\d+");zr("NONNUMERICIDENTIFIER",`\\d*[a-zA-Z-]${VT}*`);zr("MAINVERSION",`(${lr[cr.NUMERICIDENTIFIER]})\\.(${lr[cr.NUMERICIDENTIFIER]})\\.(${lr[cr.NUMERICIDENTIFIER]})`);zr("MAINVERSIONLOOSE",`(${lr[cr.NUMERICIDENTIFIERLOOSE]})\\.(${lr[cr.NUMERICIDENTIFIERLOOSE]})\\.(${lr[cr.NUMERICIDENTIFIERLOOSE]})`);zr("PRERELEASEIDENTIFIER",`(?:${lr[cr.NUMERICIDENTIFIER]}|${lr[cr.NONNUMERICIDENTIFIER]})`);zr("PRERELEASEIDENTIFIERLOOSE",`(?:${lr[cr.NUMERICIDENTIFIERLOOSE]}|${lr[cr.NONNUMERICIDENTIFIER]})`);zr("PRERELEASE",`(?:-(${lr[cr.PRERELEASEIDENTIFIER]}(?:\\.${lr[cr.PRERELEASEIDENTIFIER]})*))`);zr("PRERELEASELOOSE",`(?:-?(${lr[cr.PRERELEASEIDENTIFIERLOOSE]}(?:\\.${lr[cr.PRERELEASEIDENTIFIERLOOSE]})*))`);zr("BUILDIDENTIFIER",`${VT}+`);zr("BUILD",`(?:\\+(${lr[cr.BUILDIDENTIFIER]}(?:\\.${lr[cr.BUILDIDENTIFIER]})*))`);zr("FULLPLAIN",`v?${lr[cr.MAINVERSION]}${lr[cr.PRERELEASE]}?${lr[cr.BUILD]}?`);zr("FULL",`^${lr[cr.FULLPLAIN]}$`);zr("LOOSEPLAIN",`[v=\\s]*${lr[cr.MAINVERSIONLOOSE]}${lr[cr.PRERELEASELOOSE]}?${lr[cr.BUILD]}?`);zr("LOOSE",`^${lr[cr.LOOSEPLAIN]}$`);zr("GTLT","((?:<|>)?=?)");zr("XRANGEIDENTIFIERLOOSE",`${lr[cr.NUMERICIDENTIFIERLOOSE]}|x|X|\\*`);zr("XRANGEIDENTIFIER",`${lr[cr.NUMERICIDENTIFIER]}|x|X|\\*`);zr("XRANGEPLAIN",`[v=\\s]*(${lr[cr.XRANGEIDENTIFIER]})(?:\\.(${lr[cr.XRANGEIDENTIFIER]})(?:\\.(${lr[cr.XRANGEIDENTIFIER]})(?:${lr[cr.PRERELEASE]})?${lr[cr.BUILD]}?)?)?`);zr("XRANGEPLAINLOOSE",`[v=\\s]*(${lr[cr.XRANGEIDENTIFIERLOOSE]})(?:\\.(${lr[cr.XRANGEIDENTIFIERLOOSE]})(?:\\.(${lr[cr.XRANGEIDENTIFIERLOOSE]})(?:${lr[cr.PRERELEASELOOSE]})?${lr[cr.BUILD]}?)?)?`);zr("XRANGE",`^${lr[cr.GTLT]}\\s*${lr[cr.XRANGEPLAIN]}$`);zr("XRANGELOOSE",`^${lr[cr.GTLT]}\\s*${lr[cr.XRANGEPLAINLOOSE]}$`);zr("COERCE",`(^|[^\\d])(\\d{1,${KT}})(?:\\.(\\d{1,${KT}}))?(?:\\.(\\d{1,${KT}}))?(?:$|[^\\d])`);zr("COERCERTL",lr[cr.COERCE],!0);zr("LONETILDE","(?:~>?)");zr("TILDETRIM",`(\\s*)${lr[cr.LONETILDE]}\\s+`,!0);Cf.tildeTrimReplace="$1~";zr("TILDE",`^${lr[cr.LONETILDE]}${lr[cr.XRANGEPLAIN]}$`);zr("TILDELOOSE",`^${lr[cr.LONETILDE]}${lr[cr.XRANGEPLAINLOOSE]}$`);zr("LONECARET","(?:\\^)");zr("CARETTRIM",`(\\s*)${lr[cr.LONECARET]}\\s+`,!0);Cf.caretTrimReplace="$1^";zr("CARET",`^${lr[cr.LONECARET]}${lr[cr.XRANGEPLAIN]}$`);zr("CARETLOOSE",`^${lr[cr.LONECARET]}${lr[cr.XRANGEPLAINLOOSE]}$`);zr("COMPARATORLOOSE",`^${lr[cr.GTLT]}\\s*(${lr[cr.LOOSEPLAIN]})$|^$`);zr("COMPARATOR",`^${lr[cr.GTLT]}\\s*(${lr[cr.FULLPLAIN]})$|^$`);zr("COMPARATORTRIM",`(\\s*)${lr[cr.GTLT]}\\s*(${lr[cr.LOOSEPLAIN]}|${lr[cr.XRANGEPLAIN]})`,!0);Cf.comparatorTrimReplace="$1$2$3";zr("HYPHENRANGE",`^\\s*(${lr[cr.XRANGEPLAIN]})\\s+-\\s+(${lr[cr.XRANGEPLAIN]})\\s*$`);zr("HYPHENRANGELOOSE",`^\\s*(${lr[cr.XRANGEPLAINLOOSE]})\\s+-\\s+(${lr[cr.XRANGEPLAINLOOSE]})\\s*$`);zr("STAR","(<|>)?=?\\s*\\*");zr("GTE0","^\\s*>=\\s*0\\.0\\.0\\s*$");zr("GTE0PRE","^\\s*>=\\s*0\\.0\\.0-0\\s*$")});var pP=_((bkt,oz)=>{var Rqe=Object.freeze({loose:!0}),Tqe=Object.freeze({}),Lqe=t=>t?typeof t!="object"?Rqe:t:Tqe;oz.exports=Lqe});var zT=_((kkt,cz)=>{var az=/^[0-9]+$/,lz=(t,e)=>{let r=az.test(t),o=az.test(e);return r&&o&&(t=+t,e=+e),t===e?0:r&&!o?-1:o&&!r?1:t<e?-1:1},Nqe=(t,e)=>lz(e,t);cz.exports={compareIdentifiers:lz,rcompareIdentifiers:Nqe}});var Po=_((Qkt,pz)=>{var hP=hI(),{MAX_LENGTH:uz,MAX_SAFE_INTEGER:gP}=pI(),{safeRe:Az,t:fz}=By(),Oqe=pP(),{compareIdentifiers:vy}=zT(),tl=class{constructor(e,r){if(r=Oqe(r),e instanceof tl){if(e.loose===!!r.loose&&e.includePrerelease===!!r.includePrerelease)return e;e=e.version}else if(typeof e!="string")throw new TypeError(`Invalid version. Must be a string. Got type "${typeof e}".`);if(e.length>uz)throw new TypeError(`version is longer than ${uz} characters`);hP("SemVer",e,r),this.options=r,this.loose=!!r.loose,this.includePrerelease=!!r.includePrerelease;let o=e.trim().match(r.loose?Az[fz.LOOSE]:Az[fz.FULL]);if(!o)throw new TypeError(`Invalid Version: ${e}`);if(this.raw=e,this.major=+o[1],this.minor=+o[2],this.patch=+o[3],this.major>gP||this.major<0)throw new TypeError("Invalid major version");if(this.minor>gP||this.minor<0)throw new TypeError("Invalid minor version");if(this.patch>gP||this.patch<0)throw new TypeError("Invalid patch version");o[4]?this.prerelease=o[4].split(".").map(a=>{if(/^[0-9]+$/.test(a)){let n=+a;if(n>=0&&n<gP)return n}return a}):this.prerelease=[],this.build=o[5]?o[5].split("."):[],this.format()}format(){return this.version=`${this.major}.${this.minor}.${this.patch}`,this.prerelease.length&&(this.version+=`-${this.prerelease.join(".")}`),this.version}toString(){return this.version}compare(e){if(hP("SemVer.compare",this.version,this.options,e),!(e instanceof tl)){if(typeof e=="string"&&e===this.version)return 0;e=new tl(e,this.options)}return e.version===this.version?0:this.compareMain(e)||this.comparePre(e)}compareMain(e){return e instanceof tl||(e=new tl(e,this.options)),vy(this.major,e.major)||vy(this.minor,e.minor)||vy(this.patch,e.patch)}comparePre(e){if(e instanceof tl||(e=new tl(e,this.options)),this.prerelease.length&&!e.prerelease.length)return-1;if(!this.prerelease.length&&e.prerelease.length)return 1;if(!this.prerelease.length&&!e.prerelease.length)return 0;let r=0;do{let o=this.prerelease[r],a=e.prerelease[r];if(hP("prerelease compare",r,o,a),o===void 0&&a===void 0)return 0;if(a===void 0)return 1;if(o===void 0)return-1;if(o===a)continue;return vy(o,a)}while(++r)}compareBuild(e){e instanceof tl||(e=new tl(e,this.options));let r=0;do{let o=this.build[r],a=e.build[r];if(hP("prerelease compare",r,o,a),o===void 0&&a===void 0)return 0;if(a===void 0)return 1;if(o===void 0)return-1;if(o===a)continue;return vy(o,a)}while(++r)}inc(e,r,o){switch(e){case"premajor":this.prerelease.length=0,this.patch=0,this.minor=0,this.major++,this.inc("pre",r,o);break;case"preminor":this.prerelease.length=0,this.patch=0,this.minor++,this.inc("pre",r,o);break;case"prepatch":this.prerelease.length=0,this.inc("patch",r,o),this.inc("pre",r,o);break;case"prerelease":this.prerelease.length===0&&this.inc("patch",r,o),this.inc("pre",r,o);break;case"major":(this.minor!==0||this.patch!==0||this.prerelease.length===0)&&this.major++,this.minor=0,this.patch=0,this.prerelease=[];break;case"minor":(this.patch!==0||this.prerelease.length===0)&&this.minor++,this.patch=0,this.prerelease=[];break;case"patch":this.prerelease.length===0&&this.patch++,this.prerelease=[];break;case"pre":{let a=Number(o)?1:0;if(!r&&o===!1)throw new Error("invalid increment argument: identifier is empty");if(this.prerelease.length===0)this.prerelease=[a];else{let n=this.prerelease.length;for(;--n>=0;)typeof this.prerelease[n]=="number"&&(this.prerelease[n]++,n=-2);if(n===-1){if(r===this.prerelease.join(".")&&o===!1)throw new Error("invalid increment argument: identifier already exists");this.prerelease.push(a)}}if(r){let n=[r,a];o===!1&&(n=[r]),vy(this.prerelease[0],r)===0?isNaN(this.prerelease[1])&&(this.prerelease=n):this.prerelease=n}break}default:throw new Error(`invalid increment argument: ${e}`)}return this.raw=this.format(),this.build.length&&(this.raw+=`+${this.build.join(".")}`),this}};pz.exports=tl});var id=_((Fkt,gz)=>{var hz=Po(),Mqe=(t,e,r=!1)=>{if(t instanceof hz)return t;try{return new hz(t,e)}catch(o){if(!r)return null;throw o}};gz.exports=Mqe});var mz=_((Rkt,dz)=>{var Uqe=id(),_qe=(t,e)=>{let r=Uqe(t,e);return r?r.version:null};dz.exports=_qe});var Ez=_((Tkt,yz)=>{var Hqe=id(),jqe=(t,e)=>{let r=Hqe(t.trim().replace(/^[=v]+/,""),e);return r?r.version:null};yz.exports=jqe});var Iz=_((Lkt,wz)=>{var Cz=Po(),qqe=(t,e,r,o,a)=>{typeof r=="string"&&(a=o,o=r,r=void 0);try{return new Cz(t instanceof Cz?t.version:t,r).inc(e,o,a).version}catch{return null}};wz.exports=qqe});var Dz=_((Nkt,vz)=>{var Bz=id(),Gqe=(t,e)=>{let r=Bz(t,null,!0),o=Bz(e,null,!0),a=r.compare(o);if(a===0)return null;let n=a>0,u=n?r:o,A=n?o:r,p=!!u.prerelease.length;if(!!A.prerelease.length&&!p)return!A.patch&&!A.minor?"major":u.patch?"patch":u.minor?"minor":"major";let C=p?"pre":"";return r.major!==o.major?C+"major":r.minor!==o.minor?C+"minor":r.patch!==o.patch?C+"patch":"prerelease"};vz.exports=Gqe});var Sz=_((Okt,Pz)=>{var Yqe=Po(),Wqe=(t,e)=>new Yqe(t,e).major;Pz.exports=Wqe});var bz=_((Mkt,xz)=>{var Kqe=Po(),Vqe=(t,e)=>new Kqe(t,e).minor;xz.exports=Vqe});var Qz=_((Ukt,kz)=>{var zqe=Po(),Jqe=(t,e)=>new zqe(t,e).patch;kz.exports=Jqe});var Rz=_((_kt,Fz)=>{var Xqe=id(),Zqe=(t,e)=>{let r=Xqe(t,e);return r&&r.prerelease.length?r.prerelease:null};Fz.exports=Zqe});var Nl=_((Hkt,Lz)=>{var Tz=Po(),$qe=(t,e,r)=>new Tz(t,r).compare(new Tz(e,r));Lz.exports=$qe});var Oz=_((jkt,Nz)=>{var eGe=Nl(),tGe=(t,e,r)=>eGe(e,t,r);Nz.exports=tGe});var Uz=_((qkt,Mz)=>{var rGe=Nl(),nGe=(t,e)=>rGe(t,e,!0);Mz.exports=nGe});var dP=_((Gkt,Hz)=>{var _z=Po(),iGe=(t,e,r)=>{let o=new _z(t,r),a=new _z(e,r);return o.compare(a)||o.compareBuild(a)};Hz.exports=iGe});var qz=_((Ykt,jz)=>{var sGe=dP(),oGe=(t,e)=>t.sort((r,o)=>sGe(r,o,e));jz.exports=oGe});var Yz=_((Wkt,Gz)=>{var aGe=dP(),lGe=(t,e)=>t.sort((r,o)=>aGe(o,r,e));Gz.exports=lGe});var gI=_((Kkt,Wz)=>{var cGe=Nl(),uGe=(t,e,r)=>cGe(t,e,r)>0;Wz.exports=uGe});var mP=_((Vkt,Kz)=>{var AGe=Nl(),fGe=(t,e,r)=>AGe(t,e,r)<0;Kz.exports=fGe});var JT=_((zkt,Vz)=>{var pGe=Nl(),hGe=(t,e,r)=>pGe(t,e,r)===0;Vz.exports=hGe});var XT=_((Jkt,zz)=>{var gGe=Nl(),dGe=(t,e,r)=>gGe(t,e,r)!==0;zz.exports=dGe});var yP=_((Xkt,Jz)=>{var mGe=Nl(),yGe=(t,e,r)=>mGe(t,e,r)>=0;Jz.exports=yGe});var EP=_((Zkt,Xz)=>{var EGe=Nl(),CGe=(t,e,r)=>EGe(t,e,r)<=0;Xz.exports=CGe});var ZT=_(($kt,Zz)=>{var wGe=JT(),IGe=XT(),BGe=gI(),vGe=yP(),DGe=mP(),PGe=EP(),SGe=(t,e,r,o)=>{switch(e){case"===":return typeof t=="object"&&(t=t.version),typeof r=="object"&&(r=r.version),t===r;case"!==":return typeof t=="object"&&(t=t.version),typeof r=="object"&&(r=r.version),t!==r;case"":case"=":case"==":return wGe(t,r,o);case"!=":return IGe(t,r,o);case">":return BGe(t,r,o);case">=":return vGe(t,r,o);case"<":return DGe(t,r,o);case"<=":return PGe(t,r,o);default:throw new TypeError(`Invalid operator: ${e}`)}};Zz.exports=SGe});var eJ=_((eQt,$z)=>{var xGe=Po(),bGe=id(),{safeRe:CP,t:wP}=By(),kGe=(t,e)=>{if(t instanceof xGe)return t;if(typeof t=="number"&&(t=String(t)),typeof t!="string")return null;e=e||{};let r=null;if(!e.rtl)r=t.match(CP[wP.COERCE]);else{let o;for(;(o=CP[wP.COERCERTL].exec(t))&&(!r||r.index+r[0].length!==t.length);)(!r||o.index+o[0].length!==r.index+r[0].length)&&(r=o),CP[wP.COERCERTL].lastIndex=o.index+o[1].length+o[2].length;CP[wP.COERCERTL].lastIndex=-1}return r===null?null:bGe(`${r[2]}.${r[3]||"0"}.${r[4]||"0"}`,e)};$z.exports=kGe});var rJ=_((tQt,tJ)=>{"use strict";tJ.exports=function(t){t.prototype[Symbol.iterator]=function*(){for(let e=this.head;e;e=e.next)yield e.value}}});var IP=_((rQt,nJ)=>{"use strict";nJ.exports=Cn;Cn.Node=sd;Cn.create=Cn;function Cn(t){var e=this;if(e instanceof Cn||(e=new Cn),e.tail=null,e.head=null,e.length=0,t&&typeof t.forEach=="function")t.forEach(function(a){e.push(a)});else if(arguments.length>0)for(var r=0,o=arguments.length;r<o;r++)e.push(arguments[r]);return e}Cn.prototype.removeNode=function(t){if(t.list!==this)throw new Error("removing node which does not belong to this list");var e=t.next,r=t.prev;return e&&(e.prev=r),r&&(r.next=e),t===this.head&&(this.head=e),t===this.tail&&(this.tail=r),t.list.length--,t.next=null,t.prev=null,t.list=null,e};Cn.prototype.unshiftNode=function(t){if(t!==this.head){t.list&&t.list.removeNode(t);var e=this.head;t.list=this,t.next=e,e&&(e.prev=t),this.head=t,this.tail||(this.tail=t),this.length++}};Cn.prototype.pushNode=function(t){if(t!==this.tail){t.list&&t.list.removeNode(t);var e=this.tail;t.list=this,t.prev=e,e&&(e.next=t),this.tail=t,this.head||(this.head=t),this.length++}};Cn.prototype.push=function(){for(var t=0,e=arguments.length;t<e;t++)FGe(this,arguments[t]);return this.length};Cn.prototype.unshift=function(){for(var t=0,e=arguments.length;t<e;t++)RGe(this,arguments[t]);return this.length};Cn.prototype.pop=function(){if(!!this.tail){var t=this.tail.value;return this.tail=this.tail.prev,this.tail?this.tail.next=null:this.head=null,this.length--,t}};Cn.prototype.shift=function(){if(!!this.head){var t=this.head.value;return this.head=this.head.next,this.head?this.head.prev=null:this.tail=null,this.length--,t}};Cn.prototype.forEach=function(t,e){e=e||this;for(var r=this.head,o=0;r!==null;o++)t.call(e,r.value,o,this),r=r.next};Cn.prototype.forEachReverse=function(t,e){e=e||this;for(var r=this.tail,o=this.length-1;r!==null;o--)t.call(e,r.value,o,this),r=r.prev};Cn.prototype.get=function(t){for(var e=0,r=this.head;r!==null&&e<t;e++)r=r.next;if(e===t&&r!==null)return r.value};Cn.prototype.getReverse=function(t){for(var e=0,r=this.tail;r!==null&&e<t;e++)r=r.prev;if(e===t&&r!==null)return r.value};Cn.prototype.map=function(t,e){e=e||this;for(var r=new Cn,o=this.head;o!==null;)r.push(t.call(e,o.value,this)),o=o.next;return r};Cn.prototype.mapReverse=function(t,e){e=e||this;for(var r=new Cn,o=this.tail;o!==null;)r.push(t.call(e,o.value,this)),o=o.prev;return r};Cn.prototype.reduce=function(t,e){var r,o=this.head;if(arguments.length>1)r=e;else if(this.head)o=this.head.next,r=this.head.value;else throw new TypeError("Reduce of empty list with no initial value");for(var a=0;o!==null;a++)r=t(r,o.value,a),o=o.next;return r};Cn.prototype.reduceReverse=function(t,e){var r,o=this.tail;if(arguments.length>1)r=e;else if(this.tail)o=this.tail.prev,r=this.tail.value;else throw new TypeError("Reduce of empty list with no initial value");for(var a=this.length-1;o!==null;a--)r=t(r,o.value,a),o=o.prev;return r};Cn.prototype.toArray=function(){for(var t=new Array(this.length),e=0,r=this.head;r!==null;e++)t[e]=r.value,r=r.next;return t};Cn.prototype.toArrayReverse=function(){for(var t=new Array(this.length),e=0,r=this.tail;r!==null;e++)t[e]=r.value,r=r.prev;return t};Cn.prototype.slice=function(t,e){e=e||this.length,e<0&&(e+=this.length),t=t||0,t<0&&(t+=this.length);var r=new Cn;if(e<t||e<0)return r;t<0&&(t=0),e>this.length&&(e=this.length);for(var o=0,a=this.head;a!==null&&o<t;o++)a=a.next;for(;a!==null&&o<e;o++,a=a.next)r.push(a.value);return r};Cn.prototype.sliceReverse=function(t,e){e=e||this.length,e<0&&(e+=this.length),t=t||0,t<0&&(t+=this.length);var r=new Cn;if(e<t||e<0)return r;t<0&&(t=0),e>this.length&&(e=this.length);for(var o=this.length,a=this.tail;a!==null&&o>e;o--)a=a.prev;for(;a!==null&&o>t;o--,a=a.prev)r.push(a.value);return r};Cn.prototype.splice=function(t,e,...r){t>this.length&&(t=this.length-1),t<0&&(t=this.length+t);for(var o=0,a=this.head;a!==null&&o<t;o++)a=a.next;for(var n=[],o=0;a&&o<e;o++)n.push(a.value),a=this.removeNode(a);a===null&&(a=this.tail),a!==this.head&&a!==this.tail&&(a=a.prev);for(var o=0;o<r.length;o++)a=QGe(this,a,r[o]);return n};Cn.prototype.reverse=function(){for(var t=this.head,e=this.tail,r=t;r!==null;r=r.prev){var o=r.prev;r.prev=r.next,r.next=o}return this.head=e,this.tail=t,this};function QGe(t,e,r){var o=e===t.head?new sd(r,null,e,t):new sd(r,e,e.next,t);return o.next===null&&(t.tail=o),o.prev===null&&(t.head=o),t.length++,o}function FGe(t,e){t.tail=new sd(e,t.tail,null,t),t.head||(t.head=t.tail),t.length++}function RGe(t,e){t.head=new sd(e,null,t.head,t),t.tail||(t.tail=t.head),t.length++}function sd(t,e,r,o){if(!(this instanceof sd))return new sd(t,e,r,o);this.list=o,this.value=t,e?(e.next=this,this.prev=e):this.prev=null,r?(r.prev=this,this.next=r):this.next=null}try{rJ()(Cn)}catch{}});var lJ=_((nQt,aJ)=>{"use strict";var TGe=IP(),od=Symbol("max"),If=Symbol("length"),Dy=Symbol("lengthCalculator"),mI=Symbol("allowStale"),ad=Symbol("maxAge"),wf=Symbol("dispose"),iJ=Symbol("noDisposeOnSet"),bs=Symbol("lruList"),Mc=Symbol("cache"),oJ=Symbol("updateAgeOnGet"),$T=()=>1,tL=class{constructor(e){if(typeof e=="number"&&(e={max:e}),e||(e={}),e.max&&(typeof e.max!="number"||e.max<0))throw new TypeError("max must be a non-negative number");let r=this[od]=e.max||1/0,o=e.length||$T;if(this[Dy]=typeof o!="function"?$T:o,this[mI]=e.stale||!1,e.maxAge&&typeof e.maxAge!="number")throw new TypeError("maxAge must be a number");this[ad]=e.maxAge||0,this[wf]=e.dispose,this[iJ]=e.noDisposeOnSet||!1,this[oJ]=e.updateAgeOnGet||!1,this.reset()}set max(e){if(typeof e!="number"||e<0)throw new TypeError("max must be a non-negative number");this[od]=e||1/0,dI(this)}get max(){return this[od]}set allowStale(e){this[mI]=!!e}get allowStale(){return this[mI]}set maxAge(e){if(typeof e!="number")throw new TypeError("maxAge must be a non-negative number");this[ad]=e,dI(this)}get maxAge(){return this[ad]}set lengthCalculator(e){typeof e!="function"&&(e=$T),e!==this[Dy]&&(this[Dy]=e,this[If]=0,this[bs].forEach(r=>{r.length=this[Dy](r.value,r.key),this[If]+=r.length})),dI(this)}get lengthCalculator(){return this[Dy]}get length(){return this[If]}get itemCount(){return this[bs].length}rforEach(e,r){r=r||this;for(let o=this[bs].tail;o!==null;){let a=o.prev;sJ(this,e,o,r),o=a}}forEach(e,r){r=r||this;for(let o=this[bs].head;o!==null;){let a=o.next;sJ(this,e,o,r),o=a}}keys(){return this[bs].toArray().map(e=>e.key)}values(){return this[bs].toArray().map(e=>e.value)}reset(){this[wf]&&this[bs]&&this[bs].length&&this[bs].forEach(e=>this[wf](e.key,e.value)),this[Mc]=new Map,this[bs]=new TGe,this[If]=0}dump(){return this[bs].map(e=>BP(this,e)?!1:{k:e.key,v:e.value,e:e.now+(e.maxAge||0)}).toArray().filter(e=>e)}dumpLru(){return this[bs]}set(e,r,o){if(o=o||this[ad],o&&typeof o!="number")throw new TypeError("maxAge must be a number");let a=o?Date.now():0,n=this[Dy](r,e);if(this[Mc].has(e)){if(n>this[od])return Py(this,this[Mc].get(e)),!1;let p=this[Mc].get(e).value;return this[wf]&&(this[iJ]||this[wf](e,p.value)),p.now=a,p.maxAge=o,p.value=r,this[If]+=n-p.length,p.length=n,this.get(e),dI(this),!0}let u=new rL(e,r,n,a,o);return u.length>this[od]?(this[wf]&&this[wf](e,r),!1):(this[If]+=u.length,this[bs].unshift(u),this[Mc].set(e,this[bs].head),dI(this),!0)}has(e){if(!this[Mc].has(e))return!1;let r=this[Mc].get(e).value;return!BP(this,r)}get(e){return eL(this,e,!0)}peek(e){return eL(this,e,!1)}pop(){let e=this[bs].tail;return e?(Py(this,e),e.value):null}del(e){Py(this,this[Mc].get(e))}load(e){this.reset();let r=Date.now();for(let o=e.length-1;o>=0;o--){let a=e[o],n=a.e||0;if(n===0)this.set(a.k,a.v);else{let u=n-r;u>0&&this.set(a.k,a.v,u)}}}prune(){this[Mc].forEach((e,r)=>eL(this,r,!1))}},eL=(t,e,r)=>{let o=t[Mc].get(e);if(o){let a=o.value;if(BP(t,a)){if(Py(t,o),!t[mI])return}else r&&(t[oJ]&&(o.value.now=Date.now()),t[bs].unshiftNode(o));return a.value}},BP=(t,e)=>{if(!e||!e.maxAge&&!t[ad])return!1;let r=Date.now()-e.now;return e.maxAge?r>e.maxAge:t[ad]&&r>t[ad]},dI=t=>{if(t[If]>t[od])for(let e=t[bs].tail;t[If]>t[od]&&e!==null;){let r=e.prev;Py(t,e),e=r}},Py=(t,e)=>{if(e){let r=e.value;t[wf]&&t[wf](r.key,r.value),t[If]-=r.length,t[Mc].delete(r.key),t[bs].removeNode(e)}},rL=class{constructor(e,r,o,a,n){this.key=e,this.value=r,this.length=o,this.now=a,this.maxAge=n||0}},sJ=(t,e,r,o)=>{let a=r.value;BP(t,a)&&(Py(t,r),t[mI]||(a=void 0)),a&&e.call(o,a.value,a.key,t)};aJ.exports=tL});var Ol=_((iQt,fJ)=>{var ld=class{constructor(e,r){if(r=NGe(r),e instanceof ld)return e.loose===!!r.loose&&e.includePrerelease===!!r.includePrerelease?e:new ld(e.raw,r);if(e instanceof nL)return this.raw=e.value,this.set=[[e]],this.format(),this;if(this.options=r,this.loose=!!r.loose,this.includePrerelease=!!r.includePrerelease,this.raw=e.trim().split(/\s+/).join(" "),this.set=this.raw.split("||").map(o=>this.parseRange(o.trim())).filter(o=>o.length),!this.set.length)throw new TypeError(`Invalid SemVer Range: ${this.raw}`);if(this.set.length>1){let o=this.set[0];if(this.set=this.set.filter(a=>!uJ(a[0])),this.set.length===0)this.set=[o];else if(this.set.length>1){for(let a of this.set)if(a.length===1&&qGe(a[0])){this.set=[a];break}}}this.format()}format(){return this.range=this.set.map(e=>e.join(" ").trim()).join("||").trim(),this.range}toString(){return this.range}parseRange(e){let o=((this.options.includePrerelease&&HGe)|(this.options.loose&&jGe))+":"+e,a=cJ.get(o);if(a)return a;let n=this.options.loose,u=n?Da[Jo.HYPHENRANGELOOSE]:Da[Jo.HYPHENRANGE];e=e.replace(u,$Ge(this.options.includePrerelease)),ci("hyphen replace",e),e=e.replace(Da[Jo.COMPARATORTRIM],MGe),ci("comparator trim",e),e=e.replace(Da[Jo.TILDETRIM],UGe),ci("tilde trim",e),e=e.replace(Da[Jo.CARETTRIM],_Ge),ci("caret trim",e);let A=e.split(" ").map(I=>GGe(I,this.options)).join(" ").split(/\s+/).map(I=>ZGe(I,this.options));n&&(A=A.filter(I=>(ci("loose invalid filter",I,this.options),!!I.match(Da[Jo.COMPARATORLOOSE])))),ci("range list",A);let p=new Map,h=A.map(I=>new nL(I,this.options));for(let I of h){if(uJ(I))return[I];p.set(I.value,I)}p.size>1&&p.has("")&&p.delete("");let C=[...p.values()];return cJ.set(o,C),C}intersects(e,r){if(!(e instanceof ld))throw new TypeError("a Range is required");return this.set.some(o=>AJ(o,r)&&e.set.some(a=>AJ(a,r)&&o.every(n=>a.every(u=>n.intersects(u,r)))))}test(e){if(!e)return!1;if(typeof e=="string")try{e=new OGe(e,this.options)}catch{return!1}for(let r=0;r<this.set.length;r++)if(e5e(this.set[r],e,this.options))return!0;return!1}};fJ.exports=ld;var LGe=lJ(),cJ=new LGe({max:1e3}),NGe=pP(),nL=yI(),ci=hI(),OGe=Po(),{safeRe:Da,t:Jo,comparatorTrimReplace:MGe,tildeTrimReplace:UGe,caretTrimReplace:_Ge}=By(),{FLAG_INCLUDE_PRERELEASE:HGe,FLAG_LOOSE:jGe}=pI(),uJ=t=>t.value==="<0.0.0-0",qGe=t=>t.value==="",AJ=(t,e)=>{let r=!0,o=t.slice(),a=o.pop();for(;r&&o.length;)r=o.every(n=>a.intersects(n,e)),a=o.pop();return r},GGe=(t,e)=>(ci("comp",t,e),t=KGe(t,e),ci("caret",t),t=YGe(t,e),ci("tildes",t),t=zGe(t,e),ci("xrange",t),t=XGe(t,e),ci("stars",t),t),Xo=t=>!t||t.toLowerCase()==="x"||t==="*",YGe=(t,e)=>t.trim().split(/\s+/).map(r=>WGe(r,e)).join(" "),WGe=(t,e)=>{let r=e.loose?Da[Jo.TILDELOOSE]:Da[Jo.TILDE];return t.replace(r,(o,a,n,u,A)=>{ci("tilde",t,o,a,n,u,A);let p;return Xo(a)?p="":Xo(n)?p=`>=${a}.0.0 <${+a+1}.0.0-0`:Xo(u)?p=`>=${a}.${n}.0 <${a}.${+n+1}.0-0`:A?(ci("replaceTilde pr",A),p=`>=${a}.${n}.${u}-${A} <${a}.${+n+1}.0-0`):p=`>=${a}.${n}.${u} <${a}.${+n+1}.0-0`,ci("tilde return",p),p})},KGe=(t,e)=>t.trim().split(/\s+/).map(r=>VGe(r,e)).join(" "),VGe=(t,e)=>{ci("caret",t,e);let r=e.loose?Da[Jo.CARETLOOSE]:Da[Jo.CARET],o=e.includePrerelease?"-0":"";return t.replace(r,(a,n,u,A,p)=>{ci("caret",t,a,n,u,A,p);let h;return Xo(n)?h="":Xo(u)?h=`>=${n}.0.0${o} <${+n+1}.0.0-0`:Xo(A)?n==="0"?h=`>=${n}.${u}.0${o} <${n}.${+u+1}.0-0`:h=`>=${n}.${u}.0${o} <${+n+1}.0.0-0`:p?(ci("replaceCaret pr",p),n==="0"?u==="0"?h=`>=${n}.${u}.${A}-${p} <${n}.${u}.${+A+1}-0`:h=`>=${n}.${u}.${A}-${p} <${n}.${+u+1}.0-0`:h=`>=${n}.${u}.${A}-${p} <${+n+1}.0.0-0`):(ci("no pr"),n==="0"?u==="0"?h=`>=${n}.${u}.${A}${o} <${n}.${u}.${+A+1}-0`:h=`>=${n}.${u}.${A}${o} <${n}.${+u+1}.0-0`:h=`>=${n}.${u}.${A} <${+n+1}.0.0-0`),ci("caret return",h),h})},zGe=(t,e)=>(ci("replaceXRanges",t,e),t.split(/\s+/).map(r=>JGe(r,e)).join(" ")),JGe=(t,e)=>{t=t.trim();let r=e.loose?Da[Jo.XRANGELOOSE]:Da[Jo.XRANGE];return t.replace(r,(o,a,n,u,A,p)=>{ci("xRange",t,o,a,n,u,A,p);let h=Xo(n),C=h||Xo(u),I=C||Xo(A),v=I;return a==="="&&v&&(a=""),p=e.includePrerelease?"-0":"",h?a===">"||a==="<"?o="<0.0.0-0":o="*":a&&v?(C&&(u=0),A=0,a===">"?(a=">=",C?(n=+n+1,u=0,A=0):(u=+u+1,A=0)):a==="<="&&(a="<",C?n=+n+1:u=+u+1),a==="<"&&(p="-0"),o=`${a+n}.${u}.${A}${p}`):C?o=`>=${n}.0.0${p} <${+n+1}.0.0-0`:I&&(o=`>=${n}.${u}.0${p} <${n}.${+u+1}.0-0`),ci("xRange return",o),o})},XGe=(t,e)=>(ci("replaceStars",t,e),t.trim().replace(Da[Jo.STAR],"")),ZGe=(t,e)=>(ci("replaceGTE0",t,e),t.trim().replace(Da[e.includePrerelease?Jo.GTE0PRE:Jo.GTE0],"")),$Ge=t=>(e,r,o,a,n,u,A,p,h,C,I,v,b)=>(Xo(o)?r="":Xo(a)?r=`>=${o}.0.0${t?"-0":""}`:Xo(n)?r=`>=${o}.${a}.0${t?"-0":""}`:u?r=`>=${r}`:r=`>=${r}${t?"-0":""}`,Xo(h)?p="":Xo(C)?p=`<${+h+1}.0.0-0`:Xo(I)?p=`<${h}.${+C+1}.0-0`:v?p=`<=${h}.${C}.${I}-${v}`:t?p=`<${h}.${C}.${+I+1}-0`:p=`<=${p}`,`${r} ${p}`.trim()),e5e=(t,e,r)=>{for(let o=0;o<t.length;o++)if(!t[o].test(e))return!1;if(e.prerelease.length&&!r.includePrerelease){for(let o=0;o<t.length;o++)if(ci(t[o].semver),t[o].semver!==nL.ANY&&t[o].semver.prerelease.length>0){let a=t[o].semver;if(a.major===e.major&&a.minor===e.minor&&a.patch===e.patch)return!0}return!1}return!0}});var yI=_((sQt,yJ)=>{var EI=Symbol("SemVer ANY"),Sy=class{static get ANY(){return EI}constructor(e,r){if(r=pJ(r),e instanceof Sy){if(e.loose===!!r.loose)return e;e=e.value}e=e.trim().split(/\s+/).join(" "),sL("comparator",e,r),this.options=r,this.loose=!!r.loose,this.parse(e),this.semver===EI?this.value="":this.value=this.operator+this.semver.version,sL("comp",this)}parse(e){let r=this.options.loose?hJ[gJ.COMPARATORLOOSE]:hJ[gJ.COMPARATOR],o=e.match(r);if(!o)throw new TypeError(`Invalid comparator: ${e}`);this.operator=o[1]!==void 0?o[1]:"",this.operator==="="&&(this.operator=""),o[2]?this.semver=new dJ(o[2],this.options.loose):this.semver=EI}toString(){return this.value}test(e){if(sL("Comparator.test",e,this.options.loose),this.semver===EI||e===EI)return!0;if(typeof e=="string")try{e=new dJ(e,this.options)}catch{return!1}return iL(e,this.operator,this.semver,this.options)}intersects(e,r){if(!(e instanceof Sy))throw new TypeError("a Comparator is required");return this.operator===""?this.value===""?!0:new mJ(e.value,r).test(this.value):e.operator===""?e.value===""?!0:new mJ(this.value,r).test(e.semver):(r=pJ(r),r.includePrerelease&&(this.value==="<0.0.0-0"||e.value==="<0.0.0-0")||!r.includePrerelease&&(this.value.startsWith("<0.0.0")||e.value.startsWith("<0.0.0"))?!1:!!(this.operator.startsWith(">")&&e.operator.startsWith(">")||this.operator.startsWith("<")&&e.operator.startsWith("<")||this.semver.version===e.semver.version&&this.operator.includes("=")&&e.operator.includes("=")||iL(this.semver,"<",e.semver,r)&&this.operator.startsWith(">")&&e.operator.startsWith("<")||iL(this.semver,">",e.semver,r)&&this.operator.startsWith("<")&&e.operator.startsWith(">")))}};yJ.exports=Sy;var pJ=pP(),{safeRe:hJ,t:gJ}=By(),iL=ZT(),sL=hI(),dJ=Po(),mJ=Ol()});var CI=_((oQt,EJ)=>{var t5e=Ol(),r5e=(t,e,r)=>{try{e=new t5e(e,r)}catch{return!1}return e.test(t)};EJ.exports=r5e});var wJ=_((aQt,CJ)=>{var n5e=Ol(),i5e=(t,e)=>new n5e(t,e).set.map(r=>r.map(o=>o.value).join(" ").trim().split(" "));CJ.exports=i5e});var BJ=_((lQt,IJ)=>{var s5e=Po(),o5e=Ol(),a5e=(t,e,r)=>{let o=null,a=null,n=null;try{n=new o5e(e,r)}catch{return null}return t.forEach(u=>{n.test(u)&&(!o||a.compare(u)===-1)&&(o=u,a=new s5e(o,r))}),o};IJ.exports=a5e});var DJ=_((cQt,vJ)=>{var l5e=Po(),c5e=Ol(),u5e=(t,e,r)=>{let o=null,a=null,n=null;try{n=new c5e(e,r)}catch{return null}return t.forEach(u=>{n.test(u)&&(!o||a.compare(u)===1)&&(o=u,a=new l5e(o,r))}),o};vJ.exports=u5e});var xJ=_((uQt,SJ)=>{var oL=Po(),A5e=Ol(),PJ=gI(),f5e=(t,e)=>{t=new A5e(t,e);let r=new oL("0.0.0");if(t.test(r)||(r=new oL("0.0.0-0"),t.test(r)))return r;r=null;for(let o=0;o<t.set.length;++o){let a=t.set[o],n=null;a.forEach(u=>{let A=new oL(u.semver.version);switch(u.operator){case">":A.prerelease.length===0?A.patch++:A.prerelease.push(0),A.raw=A.format();case"":case">=":(!n||PJ(A,n))&&(n=A);break;case"<":case"<=":break;default:throw new Error(`Unexpected operation: ${u.operator}`)}}),n&&(!r||PJ(r,n))&&(r=n)}return r&&t.test(r)?r:null};SJ.exports=f5e});var kJ=_((AQt,bJ)=>{var p5e=Ol(),h5e=(t,e)=>{try{return new p5e(t,e).range||"*"}catch{return null}};bJ.exports=h5e});var vP=_((fQt,TJ)=>{var g5e=Po(),RJ=yI(),{ANY:d5e}=RJ,m5e=Ol(),y5e=CI(),QJ=gI(),FJ=mP(),E5e=EP(),C5e=yP(),w5e=(t,e,r,o)=>{t=new g5e(t,o),e=new m5e(e,o);let a,n,u,A,p;switch(r){case">":a=QJ,n=E5e,u=FJ,A=">",p=">=";break;case"<":a=FJ,n=C5e,u=QJ,A="<",p="<=";break;default:throw new TypeError('Must provide a hilo val of "<" or ">"')}if(y5e(t,e,o))return!1;for(let h=0;h<e.set.length;++h){let C=e.set[h],I=null,v=null;if(C.forEach(b=>{b.semver===d5e&&(b=new RJ(">=0.0.0")),I=I||b,v=v||b,a(b.semver,I.semver,o)?I=b:u(b.semver,v.semver,o)&&(v=b)}),I.operator===A||I.operator===p||(!v.operator||v.operator===A)&&n(t,v.semver))return!1;if(v.operator===p&&u(t,v.semver))return!1}return!0};TJ.exports=w5e});var NJ=_((pQt,LJ)=>{var I5e=vP(),B5e=(t,e,r)=>I5e(t,e,">",r);LJ.exports=B5e});var MJ=_((hQt,OJ)=>{var v5e=vP(),D5e=(t,e,r)=>v5e(t,e,"<",r);OJ.exports=D5e});var HJ=_((gQt,_J)=>{var UJ=Ol(),P5e=(t,e,r)=>(t=new UJ(t,r),e=new UJ(e,r),t.intersects(e,r));_J.exports=P5e});var qJ=_((dQt,jJ)=>{var S5e=CI(),x5e=Nl();jJ.exports=(t,e,r)=>{let o=[],a=null,n=null,u=t.sort((C,I)=>x5e(C,I,r));for(let C of u)S5e(C,e,r)?(n=C,a||(a=C)):(n&&o.push([a,n]),n=null,a=null);a&&o.push([a,null]);let A=[];for(let[C,I]of o)C===I?A.push(C):!I&&C===u[0]?A.push("*"):I?C===u[0]?A.push(`<=${I}`):A.push(`${C} - ${I}`):A.push(`>=${C}`);let p=A.join(" || "),h=typeof e.raw=="string"?e.raw:String(e);return p.length<h.length?p:e}});var zJ=_((mQt,VJ)=>{var GJ=Ol(),lL=yI(),{ANY:aL}=lL,wI=CI(),cL=Nl(),b5e=(t,e,r={})=>{if(t===e)return!0;t=new GJ(t,r),e=new GJ(e,r);let o=!1;e:for(let a of t.set){for(let n of e.set){let u=Q5e(a,n,r);if(o=o||u!==null,u)continue e}if(o)return!1}return!0},k5e=[new lL(">=0.0.0-0")],YJ=[new lL(">=0.0.0")],Q5e=(t,e,r)=>{if(t===e)return!0;if(t.length===1&&t[0].semver===aL){if(e.length===1&&e[0].semver===aL)return!0;r.includePrerelease?t=k5e:t=YJ}if(e.length===1&&e[0].semver===aL){if(r.includePrerelease)return!0;e=YJ}let o=new Set,a,n;for(let b of t)b.operator===">"||b.operator===">="?a=WJ(a,b,r):b.operator==="<"||b.operator==="<="?n=KJ(n,b,r):o.add(b.semver);if(o.size>1)return null;let u;if(a&&n){if(u=cL(a.semver,n.semver,r),u>0)return null;if(u===0&&(a.operator!==">="||n.operator!=="<="))return null}for(let b of o){if(a&&!wI(b,String(a),r)||n&&!wI(b,String(n),r))return null;for(let E of e)if(!wI(b,String(E),r))return!1;return!0}let A,p,h,C,I=n&&!r.includePrerelease&&n.semver.prerelease.length?n.semver:!1,v=a&&!r.includePrerelease&&a.semver.prerelease.length?a.semver:!1;I&&I.prerelease.length===1&&n.operator==="<"&&I.prerelease[0]===0&&(I=!1);for(let b of e){if(C=C||b.operator===">"||b.operator===">=",h=h||b.operator==="<"||b.operator==="<=",a){if(v&&b.semver.prerelease&&b.semver.prerelease.length&&b.semver.major===v.major&&b.semver.minor===v.minor&&b.semver.patch===v.patch&&(v=!1),b.operator===">"||b.operator===">="){if(A=WJ(a,b,r),A===b&&A!==a)return!1}else if(a.operator===">="&&!wI(a.semver,String(b),r))return!1}if(n){if(I&&b.semver.prerelease&&b.semver.prerelease.length&&b.semver.major===I.major&&b.semver.minor===I.minor&&b.semver.patch===I.patch&&(I=!1),b.operator==="<"||b.operator==="<="){if(p=KJ(n,b,r),p===b&&p!==n)return!1}else if(n.operator==="<="&&!wI(n.semver,String(b),r))return!1}if(!b.operator&&(n||a)&&u!==0)return!1}return!(a&&h&&!n&&u!==0||n&&C&&!a&&u!==0||v||I)},WJ=(t,e,r)=>{if(!t)return e;let o=cL(t.semver,e.semver,r);return o>0?t:o<0||e.operator===">"&&t.operator===">="?e:t},KJ=(t,e,r)=>{if(!t)return e;let o=cL(t.semver,e.semver,r);return o<0?t:o>0||e.operator==="<"&&t.operator==="<="?e:t};VJ.exports=b5e});var Jn=_((yQt,ZJ)=>{var uL=By(),JJ=pI(),F5e=Po(),XJ=zT(),R5e=id(),T5e=mz(),L5e=Ez(),N5e=Iz(),O5e=Dz(),M5e=Sz(),U5e=bz(),_5e=Qz(),H5e=Rz(),j5e=Nl(),q5e=Oz(),G5e=Uz(),Y5e=dP(),W5e=qz(),K5e=Yz(),V5e=gI(),z5e=mP(),J5e=JT(),X5e=XT(),Z5e=yP(),$5e=EP(),e9e=ZT(),t9e=eJ(),r9e=yI(),n9e=Ol(),i9e=CI(),s9e=wJ(),o9e=BJ(),a9e=DJ(),l9e=xJ(),c9e=kJ(),u9e=vP(),A9e=NJ(),f9e=MJ(),p9e=HJ(),h9e=qJ(),g9e=zJ();ZJ.exports={parse:R5e,valid:T5e,clean:L5e,inc:N5e,diff:O5e,major:M5e,minor:U5e,patch:_5e,prerelease:H5e,compare:j5e,rcompare:q5e,compareLoose:G5e,compareBuild:Y5e,sort:W5e,rsort:K5e,gt:V5e,lt:z5e,eq:J5e,neq:X5e,gte:Z5e,lte:$5e,cmp:e9e,coerce:t9e,Comparator:r9e,Range:n9e,satisfies:i9e,toComparators:s9e,maxSatisfying:o9e,minSatisfying:a9e,minVersion:l9e,validRange:c9e,outside:u9e,gtr:A9e,ltr:f9e,intersects:p9e,simplifyRange:h9e,subset:g9e,SemVer:F5e,re:uL.re,src:uL.src,tokens:uL.t,SEMVER_SPEC_VERSION:JJ.SEMVER_SPEC_VERSION,RELEASE_TYPES:JJ.RELEASE_TYPES,compareIdentifiers:XJ.compareIdentifiers,rcompareIdentifiers:XJ.rcompareIdentifiers}});var eX=_((EQt,$J)=>{"use strict";function d9e(t,e){function r(){this.constructor=t}r.prototype=e.prototype,t.prototype=new r}function cd(t,e,r,o){this.message=t,this.expected=e,this.found=r,this.location=o,this.name="SyntaxError",typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(this,cd)}d9e(cd,Error);cd.buildMessage=function(t,e){var r={literal:function(h){return'"'+a(h.text)+'"'},class:function(h){var C="",I;for(I=0;I<h.parts.length;I++)C+=h.parts[I]instanceof Array?n(h.parts[I][0])+"-"+n(h.parts[I][1]):n(h.parts[I]);return"["+(h.inverted?"^":"")+C+"]"},any:function(h){return"any character"},end:function(h){return"end of input"},other:function(h){return h.description}};function o(h){return h.charCodeAt(0).toString(16).toUpperCase()}function a(h){return h.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(C){return"\\x0"+o(C)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(C){return"\\x"+o(C)})}function n(h){return h.replace(/\\/g,"\\\\").replace(/\]/g,"\\]").replace(/\^/g,"\\^").replace(/-/g,"\\-").replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(C){return"\\x0"+o(C)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(C){return"\\x"+o(C)})}function u(h){return r[h.type](h)}function A(h){var C=new Array(h.length),I,v;for(I=0;I<h.length;I++)C[I]=u(h[I]);if(C.sort(),C.length>0){for(I=1,v=1;I<C.length;I++)C[I-1]!==C[I]&&(C[v]=C[I],v++);C.length=v}switch(C.length){case 1:return C[0];case 2:return C[0]+" or "+C[1];default:return C.slice(0,-1).join(", ")+", or "+C[C.length-1]}}function p(h){return h?'"'+a(h)+'"':"end of input"}return"Expected "+A(t)+" but "+p(e)+" found."};function m9e(t,e){e=e!==void 0?e:{};var r={},o={Expression:y},a=y,n="|",u=Te("|",!1),A="&",p=Te("&",!1),h="^",C=Te("^",!1),I=function(Z,ie){return!!ie.reduce((Pe,Le)=>{switch(Le[1]){case"|":return Pe|Le[3];case"&":return Pe&Le[3];case"^":return Pe^Le[3]}},Z)},v="!",b=Te("!",!1),E=function(Z){return!Z},F="(",N=Te("(",!1),U=")",z=Te(")",!1),te=function(Z){return Z},le=/^[^ \t\n\r()!|&\^]/,pe=ke([" "," ",` -`,"\r","(",")","!","|","&","^"],!0,!1),ue=function(Z){return e.queryPattern.test(Z)},ye=function(Z){return e.checkFn(Z)},ae=Re("whitespace"),Ie=/^[ \t\n\r]/,Fe=ke([" "," ",` -`,"\r"],!1,!1),g=0,Ee=0,De=[{line:1,column:1}],ce=0,ne=[],ee=0,we;if("startRule"in e){if(!(e.startRule in o))throw new Error(`Can't start parsing from rule "`+e.startRule+'".');a=o[e.startRule]}function be(){return t.substring(Ee,g)}function ht(){return je(Ee,g)}function H(Z,ie){throw ie=ie!==void 0?ie:je(Ee,g),S([Re(Z)],t.substring(Ee,g),ie)}function lt(Z,ie){throw ie=ie!==void 0?ie:je(Ee,g),w(Z,ie)}function Te(Z,ie){return{type:"literal",text:Z,ignoreCase:ie}}function ke(Z,ie,Pe){return{type:"class",parts:Z,inverted:ie,ignoreCase:Pe}}function xe(){return{type:"any"}}function He(){return{type:"end"}}function Re(Z){return{type:"other",description:Z}}function ze(Z){var ie=De[Z],Pe;if(ie)return ie;for(Pe=Z-1;!De[Pe];)Pe--;for(ie=De[Pe],ie={line:ie.line,column:ie.column};Pe<Z;)t.charCodeAt(Pe)===10?(ie.line++,ie.column=1):ie.column++,Pe++;return De[Z]=ie,ie}function je(Z,ie){var Pe=ze(Z),Le=ze(ie);return{start:{offset:Z,line:Pe.line,column:Pe.column},end:{offset:ie,line:Le.line,column:Le.column}}}function x(Z){g<ce||(g>ce&&(ce=g,ne=[]),ne.push(Z))}function w(Z,ie){return new cd(Z,null,null,ie)}function S(Z,ie,Pe){return new cd(cd.buildMessage(Z,ie),Z,ie,Pe)}function y(){var Z,ie,Pe,Le,ot,dt,jt,$t;if(Z=g,ie=R(),ie!==r){for(Pe=[],Le=g,ot=X(),ot!==r?(t.charCodeAt(g)===124?(dt=n,g++):(dt=r,ee===0&&x(u)),dt===r&&(t.charCodeAt(g)===38?(dt=A,g++):(dt=r,ee===0&&x(p)),dt===r&&(t.charCodeAt(g)===94?(dt=h,g++):(dt=r,ee===0&&x(C)))),dt!==r?(jt=X(),jt!==r?($t=R(),$t!==r?(ot=[ot,dt,jt,$t],Le=ot):(g=Le,Le=r)):(g=Le,Le=r)):(g=Le,Le=r)):(g=Le,Le=r);Le!==r;)Pe.push(Le),Le=g,ot=X(),ot!==r?(t.charCodeAt(g)===124?(dt=n,g++):(dt=r,ee===0&&x(u)),dt===r&&(t.charCodeAt(g)===38?(dt=A,g++):(dt=r,ee===0&&x(p)),dt===r&&(t.charCodeAt(g)===94?(dt=h,g++):(dt=r,ee===0&&x(C)))),dt!==r?(jt=X(),jt!==r?($t=R(),$t!==r?(ot=[ot,dt,jt,$t],Le=ot):(g=Le,Le=r)):(g=Le,Le=r)):(g=Le,Le=r)):(g=Le,Le=r);Pe!==r?(Ee=Z,ie=I(ie,Pe),Z=ie):(g=Z,Z=r)}else g=Z,Z=r;return Z}function R(){var Z,ie,Pe,Le,ot,dt;return Z=g,t.charCodeAt(g)===33?(ie=v,g++):(ie=r,ee===0&&x(b)),ie!==r?(Pe=R(),Pe!==r?(Ee=Z,ie=E(Pe),Z=ie):(g=Z,Z=r)):(g=Z,Z=r),Z===r&&(Z=g,t.charCodeAt(g)===40?(ie=F,g++):(ie=r,ee===0&&x(N)),ie!==r?(Pe=X(),Pe!==r?(Le=y(),Le!==r?(ot=X(),ot!==r?(t.charCodeAt(g)===41?(dt=U,g++):(dt=r,ee===0&&x(z)),dt!==r?(Ee=Z,ie=te(Le),Z=ie):(g=Z,Z=r)):(g=Z,Z=r)):(g=Z,Z=r)):(g=Z,Z=r)):(g=Z,Z=r),Z===r&&(Z=J())),Z}function J(){var Z,ie,Pe,Le,ot;if(Z=g,ie=X(),ie!==r){if(Pe=g,Le=[],le.test(t.charAt(g))?(ot=t.charAt(g),g++):(ot=r,ee===0&&x(pe)),ot!==r)for(;ot!==r;)Le.push(ot),le.test(t.charAt(g))?(ot=t.charAt(g),g++):(ot=r,ee===0&&x(pe));else Le=r;Le!==r?Pe=t.substring(Pe,g):Pe=Le,Pe!==r?(Ee=g,Le=ue(Pe),Le?Le=void 0:Le=r,Le!==r?(Ee=Z,ie=ye(Pe),Z=ie):(g=Z,Z=r)):(g=Z,Z=r)}else g=Z,Z=r;return Z}function X(){var Z,ie;for(ee++,Z=[],Ie.test(t.charAt(g))?(ie=t.charAt(g),g++):(ie=r,ee===0&&x(Fe));ie!==r;)Z.push(ie),Ie.test(t.charAt(g))?(ie=t.charAt(g),g++):(ie=r,ee===0&&x(Fe));return ee--,Z===r&&(ie=r,ee===0&&x(ae)),Z}if(we=a(),we!==r&&g===t.length)return we;throw we!==r&&g<t.length&&x(He()),S(ne,ce<t.length?t.charAt(ce):null,ce<t.length?je(ce,ce+1):je(ce,ce))}$J.exports={SyntaxError:cd,parse:m9e}});var tX=_(DP=>{var{parse:y9e}=eX();DP.makeParser=(t=/[a-z]+/)=>(e,r)=>y9e(e,{queryPattern:t,checkFn:r});DP.parse=DP.makeParser()});var nX=_((wQt,rX)=>{"use strict";rX.exports={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]}});var AL=_((IQt,sX)=>{var II=nX(),iX={};for(let t of Object.keys(II))iX[II[t]]=t;var Ar={rgb:{channels:3,labels:"rgb"},hsl:{channels:3,labels:"hsl"},hsv:{channels:3,labels:"hsv"},hwb:{channels:3,labels:"hwb"},cmyk:{channels:4,labels:"cmyk"},xyz:{channels:3,labels:"xyz"},lab:{channels:3,labels:"lab"},lch:{channels:3,labels:"lch"},hex:{channels:1,labels:["hex"]},keyword:{channels:1,labels:["keyword"]},ansi16:{channels:1,labels:["ansi16"]},ansi256:{channels:1,labels:["ansi256"]},hcg:{channels:3,labels:["h","c","g"]},apple:{channels:3,labels:["r16","g16","b16"]},gray:{channels:1,labels:["gray"]}};sX.exports=Ar;for(let t of Object.keys(Ar)){if(!("channels"in Ar[t]))throw new Error("missing channels property: "+t);if(!("labels"in Ar[t]))throw new Error("missing channel labels property: "+t);if(Ar[t].labels.length!==Ar[t].channels)throw new Error("channel and label counts mismatch: "+t);let{channels:e,labels:r}=Ar[t];delete Ar[t].channels,delete Ar[t].labels,Object.defineProperty(Ar[t],"channels",{value:e}),Object.defineProperty(Ar[t],"labels",{value:r})}Ar.rgb.hsl=function(t){let e=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(e,r,o),n=Math.max(e,r,o),u=n-a,A,p;n===a?A=0:e===n?A=(r-o)/u:r===n?A=2+(o-e)/u:o===n&&(A=4+(e-r)/u),A=Math.min(A*60,360),A<0&&(A+=360);let h=(a+n)/2;return n===a?p=0:h<=.5?p=u/(n+a):p=u/(2-n-a),[A,p*100,h*100]};Ar.rgb.hsv=function(t){let e,r,o,a,n,u=t[0]/255,A=t[1]/255,p=t[2]/255,h=Math.max(u,A,p),C=h-Math.min(u,A,p),I=function(v){return(h-v)/6/C+1/2};return C===0?(a=0,n=0):(n=C/h,e=I(u),r=I(A),o=I(p),u===h?a=o-r:A===h?a=1/3+e-o:p===h&&(a=2/3+r-e),a<0?a+=1:a>1&&(a-=1)),[a*360,n*100,h*100]};Ar.rgb.hwb=function(t){let e=t[0],r=t[1],o=t[2],a=Ar.rgb.hsl(t)[0],n=1/255*Math.min(e,Math.min(r,o));return o=1-1/255*Math.max(e,Math.max(r,o)),[a,n*100,o*100]};Ar.rgb.cmyk=function(t){let e=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(1-e,1-r,1-o),n=(1-e-a)/(1-a)||0,u=(1-r-a)/(1-a)||0,A=(1-o-a)/(1-a)||0;return[n*100,u*100,A*100,a*100]};function E9e(t,e){return(t[0]-e[0])**2+(t[1]-e[1])**2+(t[2]-e[2])**2}Ar.rgb.keyword=function(t){let e=iX[t];if(e)return e;let r=1/0,o;for(let a of Object.keys(II)){let n=II[a],u=E9e(t,n);u<r&&(r=u,o=a)}return o};Ar.keyword.rgb=function(t){return II[t]};Ar.rgb.xyz=function(t){let e=t[0]/255,r=t[1]/255,o=t[2]/255;e=e>.04045?((e+.055)/1.055)**2.4:e/12.92,r=r>.04045?((r+.055)/1.055)**2.4:r/12.92,o=o>.04045?((o+.055)/1.055)**2.4:o/12.92;let a=e*.4124+r*.3576+o*.1805,n=e*.2126+r*.7152+o*.0722,u=e*.0193+r*.1192+o*.9505;return[a*100,n*100,u*100]};Ar.rgb.lab=function(t){let e=Ar.rgb.xyz(t),r=e[0],o=e[1],a=e[2];r/=95.047,o/=100,a/=108.883,r=r>.008856?r**(1/3):7.787*r+16/116,o=o>.008856?o**(1/3):7.787*o+16/116,a=a>.008856?a**(1/3):7.787*a+16/116;let n=116*o-16,u=500*(r-o),A=200*(o-a);return[n,u,A]};Ar.hsl.rgb=function(t){let e=t[0]/360,r=t[1]/100,o=t[2]/100,a,n,u;if(r===0)return u=o*255,[u,u,u];o<.5?a=o*(1+r):a=o+r-o*r;let A=2*o-a,p=[0,0,0];for(let h=0;h<3;h++)n=e+1/3*-(h-1),n<0&&n++,n>1&&n--,6*n<1?u=A+(a-A)*6*n:2*n<1?u=a:3*n<2?u=A+(a-A)*(2/3-n)*6:u=A,p[h]=u*255;return p};Ar.hsl.hsv=function(t){let e=t[0],r=t[1]/100,o=t[2]/100,a=r,n=Math.max(o,.01);o*=2,r*=o<=1?o:2-o,a*=n<=1?n:2-n;let u=(o+r)/2,A=o===0?2*a/(n+a):2*r/(o+r);return[e,A*100,u*100]};Ar.hsv.rgb=function(t){let e=t[0]/60,r=t[1]/100,o=t[2]/100,a=Math.floor(e)%6,n=e-Math.floor(e),u=255*o*(1-r),A=255*o*(1-r*n),p=255*o*(1-r*(1-n));switch(o*=255,a){case 0:return[o,p,u];case 1:return[A,o,u];case 2:return[u,o,p];case 3:return[u,A,o];case 4:return[p,u,o];case 5:return[o,u,A]}};Ar.hsv.hsl=function(t){let e=t[0],r=t[1]/100,o=t[2]/100,a=Math.max(o,.01),n,u;u=(2-r)*o;let A=(2-r)*a;return n=r*a,n/=A<=1?A:2-A,n=n||0,u/=2,[e,n*100,u*100]};Ar.hwb.rgb=function(t){let e=t[0]/360,r=t[1]/100,o=t[2]/100,a=r+o,n;a>1&&(r/=a,o/=a);let u=Math.floor(6*e),A=1-o;n=6*e-u,(u&1)!==0&&(n=1-n);let p=r+n*(A-r),h,C,I;switch(u){default:case 6:case 0:h=A,C=p,I=r;break;case 1:h=p,C=A,I=r;break;case 2:h=r,C=A,I=p;break;case 3:h=r,C=p,I=A;break;case 4:h=p,C=r,I=A;break;case 5:h=A,C=r,I=p;break}return[h*255,C*255,I*255]};Ar.cmyk.rgb=function(t){let e=t[0]/100,r=t[1]/100,o=t[2]/100,a=t[3]/100,n=1-Math.min(1,e*(1-a)+a),u=1-Math.min(1,r*(1-a)+a),A=1-Math.min(1,o*(1-a)+a);return[n*255,u*255,A*255]};Ar.xyz.rgb=function(t){let e=t[0]/100,r=t[1]/100,o=t[2]/100,a,n,u;return a=e*3.2406+r*-1.5372+o*-.4986,n=e*-.9689+r*1.8758+o*.0415,u=e*.0557+r*-.204+o*1.057,a=a>.0031308?1.055*a**(1/2.4)-.055:a*12.92,n=n>.0031308?1.055*n**(1/2.4)-.055:n*12.92,u=u>.0031308?1.055*u**(1/2.4)-.055:u*12.92,a=Math.min(Math.max(0,a),1),n=Math.min(Math.max(0,n),1),u=Math.min(Math.max(0,u),1),[a*255,n*255,u*255]};Ar.xyz.lab=function(t){let e=t[0],r=t[1],o=t[2];e/=95.047,r/=100,o/=108.883,e=e>.008856?e**(1/3):7.787*e+16/116,r=r>.008856?r**(1/3):7.787*r+16/116,o=o>.008856?o**(1/3):7.787*o+16/116;let a=116*r-16,n=500*(e-r),u=200*(r-o);return[a,n,u]};Ar.lab.xyz=function(t){let e=t[0],r=t[1],o=t[2],a,n,u;n=(e+16)/116,a=r/500+n,u=n-o/200;let A=n**3,p=a**3,h=u**3;return n=A>.008856?A:(n-16/116)/7.787,a=p>.008856?p:(a-16/116)/7.787,u=h>.008856?h:(u-16/116)/7.787,a*=95.047,n*=100,u*=108.883,[a,n,u]};Ar.lab.lch=function(t){let e=t[0],r=t[1],o=t[2],a;a=Math.atan2(o,r)*360/2/Math.PI,a<0&&(a+=360);let u=Math.sqrt(r*r+o*o);return[e,u,a]};Ar.lch.lab=function(t){let e=t[0],r=t[1],a=t[2]/360*2*Math.PI,n=r*Math.cos(a),u=r*Math.sin(a);return[e,n,u]};Ar.rgb.ansi16=function(t,e=null){let[r,o,a]=t,n=e===null?Ar.rgb.hsv(t)[2]:e;if(n=Math.round(n/50),n===0)return 30;let u=30+(Math.round(a/255)<<2|Math.round(o/255)<<1|Math.round(r/255));return n===2&&(u+=60),u};Ar.hsv.ansi16=function(t){return Ar.rgb.ansi16(Ar.hsv.rgb(t),t[2])};Ar.rgb.ansi256=function(t){let e=t[0],r=t[1],o=t[2];return e===r&&r===o?e<8?16:e>248?231:Math.round((e-8)/247*24)+232:16+36*Math.round(e/255*5)+6*Math.round(r/255*5)+Math.round(o/255*5)};Ar.ansi16.rgb=function(t){let e=t%10;if(e===0||e===7)return t>50&&(e+=3.5),e=e/10.5*255,[e,e,e];let r=(~~(t>50)+1)*.5,o=(e&1)*r*255,a=(e>>1&1)*r*255,n=(e>>2&1)*r*255;return[o,a,n]};Ar.ansi256.rgb=function(t){if(t>=232){let n=(t-232)*10+8;return[n,n,n]}t-=16;let e,r=Math.floor(t/36)/5*255,o=Math.floor((e=t%36)/6)/5*255,a=e%6/5*255;return[r,o,a]};Ar.rgb.hex=function(t){let r=(((Math.round(t[0])&255)<<16)+((Math.round(t[1])&255)<<8)+(Math.round(t[2])&255)).toString(16).toUpperCase();return"000000".substring(r.length)+r};Ar.hex.rgb=function(t){let e=t.toString(16).match(/[a-f0-9]{6}|[a-f0-9]{3}/i);if(!e)return[0,0,0];let r=e[0];e[0].length===3&&(r=r.split("").map(A=>A+A).join(""));let o=parseInt(r,16),a=o>>16&255,n=o>>8&255,u=o&255;return[a,n,u]};Ar.rgb.hcg=function(t){let e=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.max(Math.max(e,r),o),n=Math.min(Math.min(e,r),o),u=a-n,A,p;return u<1?A=n/(1-u):A=0,u<=0?p=0:a===e?p=(r-o)/u%6:a===r?p=2+(o-e)/u:p=4+(e-r)/u,p/=6,p%=1,[p*360,u*100,A*100]};Ar.hsl.hcg=function(t){let e=t[1]/100,r=t[2]/100,o=r<.5?2*e*r:2*e*(1-r),a=0;return o<1&&(a=(r-.5*o)/(1-o)),[t[0],o*100,a*100]};Ar.hsv.hcg=function(t){let e=t[1]/100,r=t[2]/100,o=e*r,a=0;return o<1&&(a=(r-o)/(1-o)),[t[0],o*100,a*100]};Ar.hcg.rgb=function(t){let e=t[0]/360,r=t[1]/100,o=t[2]/100;if(r===0)return[o*255,o*255,o*255];let a=[0,0,0],n=e%1*6,u=n%1,A=1-u,p=0;switch(Math.floor(n)){case 0:a[0]=1,a[1]=u,a[2]=0;break;case 1:a[0]=A,a[1]=1,a[2]=0;break;case 2:a[0]=0,a[1]=1,a[2]=u;break;case 3:a[0]=0,a[1]=A,a[2]=1;break;case 4:a[0]=u,a[1]=0,a[2]=1;break;default:a[0]=1,a[1]=0,a[2]=A}return p=(1-r)*o,[(r*a[0]+p)*255,(r*a[1]+p)*255,(r*a[2]+p)*255]};Ar.hcg.hsv=function(t){let e=t[1]/100,r=t[2]/100,o=e+r*(1-e),a=0;return o>0&&(a=e/o),[t[0],a*100,o*100]};Ar.hcg.hsl=function(t){let e=t[1]/100,o=t[2]/100*(1-e)+.5*e,a=0;return o>0&&o<.5?a=e/(2*o):o>=.5&&o<1&&(a=e/(2*(1-o))),[t[0],a*100,o*100]};Ar.hcg.hwb=function(t){let e=t[1]/100,r=t[2]/100,o=e+r*(1-e);return[t[0],(o-e)*100,(1-o)*100]};Ar.hwb.hcg=function(t){let e=t[1]/100,o=1-t[2]/100,a=o-e,n=0;return a<1&&(n=(o-a)/(1-a)),[t[0],a*100,n*100]};Ar.apple.rgb=function(t){return[t[0]/65535*255,t[1]/65535*255,t[2]/65535*255]};Ar.rgb.apple=function(t){return[t[0]/255*65535,t[1]/255*65535,t[2]/255*65535]};Ar.gray.rgb=function(t){return[t[0]/100*255,t[0]/100*255,t[0]/100*255]};Ar.gray.hsl=function(t){return[0,0,t[0]]};Ar.gray.hsv=Ar.gray.hsl;Ar.gray.hwb=function(t){return[0,100,t[0]]};Ar.gray.cmyk=function(t){return[0,0,0,t[0]]};Ar.gray.lab=function(t){return[t[0],0,0]};Ar.gray.hex=function(t){let e=Math.round(t[0]/100*255)&255,o=((e<<16)+(e<<8)+e).toString(16).toUpperCase();return"000000".substring(o.length)+o};Ar.rgb.gray=function(t){return[(t[0]+t[1]+t[2])/3/255*100]}});var aX=_((BQt,oX)=>{var PP=AL();function C9e(){let t={},e=Object.keys(PP);for(let r=e.length,o=0;o<r;o++)t[e[o]]={distance:-1,parent:null};return t}function w9e(t){let e=C9e(),r=[t];for(e[t].distance=0;r.length;){let o=r.pop(),a=Object.keys(PP[o]);for(let n=a.length,u=0;u<n;u++){let A=a[u],p=e[A];p.distance===-1&&(p.distance=e[o].distance+1,p.parent=o,r.unshift(A))}}return e}function I9e(t,e){return function(r){return e(t(r))}}function B9e(t,e){let r=[e[t].parent,t],o=PP[e[t].parent][t],a=e[t].parent;for(;e[a].parent;)r.unshift(e[a].parent),o=I9e(PP[e[a].parent][a],o),a=e[a].parent;return o.conversion=r,o}oX.exports=function(t){let e=w9e(t),r={},o=Object.keys(e);for(let a=o.length,n=0;n<a;n++){let u=o[n];e[u].parent!==null&&(r[u]=B9e(u,e))}return r}});var cX=_((vQt,lX)=>{var fL=AL(),v9e=aX(),xy={},D9e=Object.keys(fL);function P9e(t){let e=function(...r){let o=r[0];return o==null?o:(o.length>1&&(r=o),t(r))};return"conversion"in t&&(e.conversion=t.conversion),e}function S9e(t){let e=function(...r){let o=r[0];if(o==null)return o;o.length>1&&(r=o);let a=t(r);if(typeof a=="object")for(let n=a.length,u=0;u<n;u++)a[u]=Math.round(a[u]);return a};return"conversion"in t&&(e.conversion=t.conversion),e}D9e.forEach(t=>{xy[t]={},Object.defineProperty(xy[t],"channels",{value:fL[t].channels}),Object.defineProperty(xy[t],"labels",{value:fL[t].labels});let e=v9e(t);Object.keys(e).forEach(o=>{let a=e[o];xy[t][o]=S9e(a),xy[t][o].raw=P9e(a)})});lX.exports=xy});var BI=_((DQt,hX)=>{"use strict";var uX=(t,e)=>(...r)=>`\x1B[${t(...r)+e}m`,AX=(t,e)=>(...r)=>{let o=t(...r);return`\x1B[${38+e};5;${o}m`},fX=(t,e)=>(...r)=>{let o=t(...r);return`\x1B[${38+e};2;${o[0]};${o[1]};${o[2]}m`},SP=t=>t,pX=(t,e,r)=>[t,e,r],by=(t,e,r)=>{Object.defineProperty(t,e,{get:()=>{let o=r();return Object.defineProperty(t,e,{value:o,enumerable:!0,configurable:!0}),o},enumerable:!0,configurable:!0})},pL,ky=(t,e,r,o)=>{pL===void 0&&(pL=cX());let a=o?10:0,n={};for(let[u,A]of Object.entries(pL)){let p=u==="ansi16"?"ansi":u;u===e?n[p]=t(r,a):typeof A=="object"&&(n[p]=t(A[e],a))}return n};function x9e(){let t=new Map,e={modifier:{reset:[0,0],bold:[1,22],dim:[2,22],italic:[3,23],underline:[4,24],inverse:[7,27],hidden:[8,28],strikethrough:[9,29]},color:{black:[30,39],red:[31,39],green:[32,39],yellow:[33,39],blue:[34,39],magenta:[35,39],cyan:[36,39],white:[37,39],blackBright:[90,39],redBright:[91,39],greenBright:[92,39],yellowBright:[93,39],blueBright:[94,39],magentaBright:[95,39],cyanBright:[96,39],whiteBright:[97,39]},bgColor:{bgBlack:[40,49],bgRed:[41,49],bgGreen:[42,49],bgYellow:[43,49],bgBlue:[44,49],bgMagenta:[45,49],bgCyan:[46,49],bgWhite:[47,49],bgBlackBright:[100,49],bgRedBright:[101,49],bgGreenBright:[102,49],bgYellowBright:[103,49],bgBlueBright:[104,49],bgMagentaBright:[105,49],bgCyanBright:[106,49],bgWhiteBright:[107,49]}};e.color.gray=e.color.blackBright,e.bgColor.bgGray=e.bgColor.bgBlackBright,e.color.grey=e.color.blackBright,e.bgColor.bgGrey=e.bgColor.bgBlackBright;for(let[r,o]of Object.entries(e)){for(let[a,n]of Object.entries(o))e[a]={open:`\x1B[${n[0]}m`,close:`\x1B[${n[1]}m`},o[a]=e[a],t.set(n[0],n[1]);Object.defineProperty(e,r,{value:o,enumerable:!1})}return Object.defineProperty(e,"codes",{value:t,enumerable:!1}),e.color.close="\x1B[39m",e.bgColor.close="\x1B[49m",by(e.color,"ansi",()=>ky(uX,"ansi16",SP,!1)),by(e.color,"ansi256",()=>ky(AX,"ansi256",SP,!1)),by(e.color,"ansi16m",()=>ky(fX,"rgb",pX,!1)),by(e.bgColor,"ansi",()=>ky(uX,"ansi16",SP,!0)),by(e.bgColor,"ansi256",()=>ky(AX,"ansi256",SP,!0)),by(e.bgColor,"ansi16m",()=>ky(fX,"rgb",pX,!0)),e}Object.defineProperty(hX,"exports",{enumerable:!0,get:x9e})});var dX=_((PQt,gX)=>{"use strict";gX.exports=(t,e=process.argv)=>{let r=t.startsWith("-")?"":t.length===1?"-":"--",o=e.indexOf(r+t),a=e.indexOf("--");return o!==-1&&(a===-1||o<a)}});var dL=_((SQt,yX)=>{"use strict";var b9e=Be("os"),mX=Be("tty"),Ml=dX(),{env:ls}=process,Kp;Ml("no-color")||Ml("no-colors")||Ml("color=false")||Ml("color=never")?Kp=0:(Ml("color")||Ml("colors")||Ml("color=true")||Ml("color=always"))&&(Kp=1);"FORCE_COLOR"in ls&&(ls.FORCE_COLOR==="true"?Kp=1:ls.FORCE_COLOR==="false"?Kp=0:Kp=ls.FORCE_COLOR.length===0?1:Math.min(parseInt(ls.FORCE_COLOR,10),3));function hL(t){return t===0?!1:{level:t,hasBasic:!0,has256:t>=2,has16m:t>=3}}function gL(t,e){if(Kp===0)return 0;if(Ml("color=16m")||Ml("color=full")||Ml("color=truecolor"))return 3;if(Ml("color=256"))return 2;if(t&&!e&&Kp===void 0)return 0;let r=Kp||0;if(ls.TERM==="dumb")return r;if(process.platform==="win32"){let o=b9e.release().split(".");return Number(o[0])>=10&&Number(o[2])>=10586?Number(o[2])>=14931?3:2:1}if("CI"in ls)return["TRAVIS","CIRCLECI","APPVEYOR","GITLAB_CI"].some(o=>o in ls)||ls.CI_NAME==="codeship"?1:r;if("TEAMCITY_VERSION"in ls)return/^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(ls.TEAMCITY_VERSION)?1:0;if("GITHUB_ACTIONS"in ls)return 1;if(ls.COLORTERM==="truecolor")return 3;if("TERM_PROGRAM"in ls){let o=parseInt((ls.TERM_PROGRAM_VERSION||"").split(".")[0],10);switch(ls.TERM_PROGRAM){case"iTerm.app":return o>=3?3:2;case"Apple_Terminal":return 2}}return/-256(color)?$/i.test(ls.TERM)?2:/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(ls.TERM)||"COLORTERM"in ls?1:r}function k9e(t){let e=gL(t,t&&t.isTTY);return hL(e)}yX.exports={supportsColor:k9e,stdout:hL(gL(!0,mX.isatty(1))),stderr:hL(gL(!0,mX.isatty(2)))}});var CX=_((xQt,EX)=>{"use strict";var Q9e=(t,e,r)=>{let o=t.indexOf(e);if(o===-1)return t;let a=e.length,n=0,u="";do u+=t.substr(n,o-n)+e+r,n=o+a,o=t.indexOf(e,n);while(o!==-1);return u+=t.substr(n),u},F9e=(t,e,r,o)=>{let a=0,n="";do{let u=t[o-1]==="\r";n+=t.substr(a,(u?o-1:o)-a)+e+(u?`\r -`:` -`)+r,a=o+1,o=t.indexOf(` -`,a)}while(o!==-1);return n+=t.substr(a),n};EX.exports={stringReplaceAll:Q9e,stringEncaseCRLFWithFirstIndex:F9e}});var DX=_((bQt,vX)=>{"use strict";var R9e=/(?:\\(u(?:[a-f\d]{4}|\{[a-f\d]{1,6}\})|x[a-f\d]{2}|.))|(?:\{(~)?(\w+(?:\([^)]*\))?(?:\.\w+(?:\([^)]*\))?)*)(?:[ \t]|(?=\r?\n)))|(\})|((?:.|[\r\n\f])+?)/gi,wX=/(?:^|\.)(\w+)(?:\(([^)]*)\))?/g,T9e=/^(['"])((?:\\.|(?!\1)[^\\])*)\1$/,L9e=/\\(u(?:[a-f\d]{4}|\{[a-f\d]{1,6}\})|x[a-f\d]{2}|.)|([^\\])/gi,N9e=new Map([["n",` -`],["r","\r"],["t"," "],["b","\b"],["f","\f"],["v","\v"],["0","\0"],["\\","\\"],["e","\x1B"],["a","\x07"]]);function BX(t){let e=t[0]==="u",r=t[1]==="{";return e&&!r&&t.length===5||t[0]==="x"&&t.length===3?String.fromCharCode(parseInt(t.slice(1),16)):e&&r?String.fromCodePoint(parseInt(t.slice(2,-1),16)):N9e.get(t)||t}function O9e(t,e){let r=[],o=e.trim().split(/\s*,\s*/g),a;for(let n of o){let u=Number(n);if(!Number.isNaN(u))r.push(u);else if(a=n.match(T9e))r.push(a[2].replace(L9e,(A,p,h)=>p?BX(p):h));else throw new Error(`Invalid Chalk template style argument: ${n} (in style '${t}')`)}return r}function M9e(t){wX.lastIndex=0;let e=[],r;for(;(r=wX.exec(t))!==null;){let o=r[1];if(r[2]){let a=O9e(o,r[2]);e.push([o].concat(a))}else e.push([o])}return e}function IX(t,e){let r={};for(let a of e)for(let n of a.styles)r[n[0]]=a.inverse?null:n.slice(1);let o=t;for(let[a,n]of Object.entries(r))if(!!Array.isArray(n)){if(!(a in o))throw new Error(`Unknown Chalk style: ${a}`);o=n.length>0?o[a](...n):o[a]}return o}vX.exports=(t,e)=>{let r=[],o=[],a=[];if(e.replace(R9e,(n,u,A,p,h,C)=>{if(u)a.push(BX(u));else if(p){let I=a.join("");a=[],o.push(r.length===0?I:IX(t,r)(I)),r.push({inverse:A,styles:M9e(p)})}else if(h){if(r.length===0)throw new Error("Found extraneous } in Chalk template literal");o.push(IX(t,r)(a.join(""))),a=[],r.pop()}else a.push(C)}),o.push(a.join("")),r.length>0){let n=`Chalk template literal is missing ${r.length} closing bracket${r.length===1?"":"s"} (\`}\`)`;throw new Error(n)}return o.join("")}});var IL=_((kQt,bX)=>{"use strict";var vI=BI(),{stdout:yL,stderr:EL}=dL(),{stringReplaceAll:U9e,stringEncaseCRLFWithFirstIndex:_9e}=CX(),PX=["ansi","ansi","ansi256","ansi16m"],Qy=Object.create(null),H9e=(t,e={})=>{if(e.level>3||e.level<0)throw new Error("The `level` option should be an integer from 0 to 3");let r=yL?yL.level:0;t.level=e.level===void 0?r:e.level},CL=class{constructor(e){return SX(e)}},SX=t=>{let e={};return H9e(e,t),e.template=(...r)=>G9e(e.template,...r),Object.setPrototypeOf(e,xP.prototype),Object.setPrototypeOf(e.template,e),e.template.constructor=()=>{throw new Error("`chalk.constructor()` is deprecated. Use `new chalk.Instance()` instead.")},e.template.Instance=CL,e.template};function xP(t){return SX(t)}for(let[t,e]of Object.entries(vI))Qy[t]={get(){let r=bP(this,wL(e.open,e.close,this._styler),this._isEmpty);return Object.defineProperty(this,t,{value:r}),r}};Qy.visible={get(){let t=bP(this,this._styler,!0);return Object.defineProperty(this,"visible",{value:t}),t}};var xX=["rgb","hex","keyword","hsl","hsv","hwb","ansi","ansi256"];for(let t of xX)Qy[t]={get(){let{level:e}=this;return function(...r){let o=wL(vI.color[PX[e]][t](...r),vI.color.close,this._styler);return bP(this,o,this._isEmpty)}}};for(let t of xX){let e="bg"+t[0].toUpperCase()+t.slice(1);Qy[e]={get(){let{level:r}=this;return function(...o){let a=wL(vI.bgColor[PX[r]][t](...o),vI.bgColor.close,this._styler);return bP(this,a,this._isEmpty)}}}}var j9e=Object.defineProperties(()=>{},{...Qy,level:{enumerable:!0,get(){return this._generator.level},set(t){this._generator.level=t}}}),wL=(t,e,r)=>{let o,a;return r===void 0?(o=t,a=e):(o=r.openAll+t,a=e+r.closeAll),{open:t,close:e,openAll:o,closeAll:a,parent:r}},bP=(t,e,r)=>{let o=(...a)=>q9e(o,a.length===1?""+a[0]:a.join(" "));return o.__proto__=j9e,o._generator=t,o._styler=e,o._isEmpty=r,o},q9e=(t,e)=>{if(t.level<=0||!e)return t._isEmpty?"":e;let r=t._styler;if(r===void 0)return e;let{openAll:o,closeAll:a}=r;if(e.indexOf("\x1B")!==-1)for(;r!==void 0;)e=U9e(e,r.close,r.open),r=r.parent;let n=e.indexOf(` -`);return n!==-1&&(e=_9e(e,a,o,n)),o+e+a},mL,G9e=(t,...e)=>{let[r]=e;if(!Array.isArray(r))return e.join(" ");let o=e.slice(1),a=[r.raw[0]];for(let n=1;n<r.length;n++)a.push(String(o[n-1]).replace(/[{}\\]/g,"\\$&"),String(r.raw[n]));return mL===void 0&&(mL=DX()),mL(t,a.join(""))};Object.defineProperties(xP.prototype,Qy);var DI=xP();DI.supportsColor=yL;DI.stderr=xP({level:EL?EL.level:0});DI.stderr.supportsColor=EL;DI.Level={None:0,Basic:1,Ansi256:2,TrueColor:3,0:"None",1:"Basic",2:"Ansi256",3:"TrueColor"};bX.exports=DI});var kP=_(Ul=>{"use strict";Ul.isInteger=t=>typeof t=="number"?Number.isInteger(t):typeof t=="string"&&t.trim()!==""?Number.isInteger(Number(t)):!1;Ul.find=(t,e)=>t.nodes.find(r=>r.type===e);Ul.exceedsLimit=(t,e,r=1,o)=>o===!1||!Ul.isInteger(t)||!Ul.isInteger(e)?!1:(Number(e)-Number(t))/Number(r)>=o;Ul.escapeNode=(t,e=0,r)=>{let o=t.nodes[e];!o||(r&&o.type===r||o.type==="open"||o.type==="close")&&o.escaped!==!0&&(o.value="\\"+o.value,o.escaped=!0)};Ul.encloseBrace=t=>t.type!=="brace"?!1:t.commas>>0+t.ranges>>0===0?(t.invalid=!0,!0):!1;Ul.isInvalidBrace=t=>t.type!=="brace"?!1:t.invalid===!0||t.dollar?!0:t.commas>>0+t.ranges>>0===0||t.open!==!0||t.close!==!0?(t.invalid=!0,!0):!1;Ul.isOpenOrClose=t=>t.type==="open"||t.type==="close"?!0:t.open===!0||t.close===!0;Ul.reduce=t=>t.reduce((e,r)=>(r.type==="text"&&e.push(r.value),r.type==="range"&&(r.type="text"),e),[]);Ul.flatten=(...t)=>{let e=[],r=o=>{for(let a=0;a<o.length;a++){let n=o[a];Array.isArray(n)?r(n,e):n!==void 0&&e.push(n)}return e};return r(t),e}});var QP=_((FQt,QX)=>{"use strict";var kX=kP();QX.exports=(t,e={})=>{let r=(o,a={})=>{let n=e.escapeInvalid&&kX.isInvalidBrace(a),u=o.invalid===!0&&e.escapeInvalid===!0,A="";if(o.value)return(n||u)&&kX.isOpenOrClose(o)?"\\"+o.value:o.value;if(o.value)return o.value;if(o.nodes)for(let p of o.nodes)A+=r(p);return A};return r(t)}});var RX=_((RQt,FX)=>{"use strict";FX.exports=function(t){return typeof t=="number"?t-t===0:typeof t=="string"&&t.trim()!==""?Number.isFinite?Number.isFinite(+t):isFinite(+t):!1}});var jX=_((TQt,HX)=>{"use strict";var TX=RX(),ud=(t,e,r)=>{if(TX(t)===!1)throw new TypeError("toRegexRange: expected the first argument to be a number");if(e===void 0||t===e)return String(t);if(TX(e)===!1)throw new TypeError("toRegexRange: expected the second argument to be a number.");let o={relaxZeros:!0,...r};typeof o.strictZeros=="boolean"&&(o.relaxZeros=o.strictZeros===!1);let a=String(o.relaxZeros),n=String(o.shorthand),u=String(o.capture),A=String(o.wrap),p=t+":"+e+"="+a+n+u+A;if(ud.cache.hasOwnProperty(p))return ud.cache[p].result;let h=Math.min(t,e),C=Math.max(t,e);if(Math.abs(h-C)===1){let F=t+"|"+e;return o.capture?`(${F})`:o.wrap===!1?F:`(?:${F})`}let I=_X(t)||_X(e),v={min:t,max:e,a:h,b:C},b=[],E=[];if(I&&(v.isPadded=I,v.maxLen=String(v.max).length),h<0){let F=C<0?Math.abs(C):1;E=LX(F,Math.abs(h),v,o),h=v.a=0}return C>=0&&(b=LX(h,C,v,o)),v.negatives=E,v.positives=b,v.result=Y9e(E,b,o),o.capture===!0?v.result=`(${v.result})`:o.wrap!==!1&&b.length+E.length>1&&(v.result=`(?:${v.result})`),ud.cache[p]=v,v.result};function Y9e(t,e,r){let o=BL(t,e,"-",!1,r)||[],a=BL(e,t,"",!1,r)||[],n=BL(t,e,"-?",!0,r)||[];return o.concat(n).concat(a).join("|")}function W9e(t,e){let r=1,o=1,a=OX(t,r),n=new Set([e]);for(;t<=a&&a<=e;)n.add(a),r+=1,a=OX(t,r);for(a=MX(e+1,o)-1;t<a&&a<=e;)n.add(a),o+=1,a=MX(e+1,o)-1;return n=[...n],n.sort(z9e),n}function K9e(t,e,r){if(t===e)return{pattern:t,count:[],digits:0};let o=V9e(t,e),a=o.length,n="",u=0;for(let A=0;A<a;A++){let[p,h]=o[A];p===h?n+=p:p!=="0"||h!=="9"?n+=J9e(p,h,r):u++}return u&&(n+=r.shorthand===!0?"\\d":"[0-9]"),{pattern:n,count:[u],digits:a}}function LX(t,e,r,o){let a=W9e(t,e),n=[],u=t,A;for(let p=0;p<a.length;p++){let h=a[p],C=K9e(String(u),String(h),o),I="";if(!r.isPadded&&A&&A.pattern===C.pattern){A.count.length>1&&A.count.pop(),A.count.push(C.count[0]),A.string=A.pattern+UX(A.count),u=h+1;continue}r.isPadded&&(I=X9e(h,r,o)),C.string=I+C.pattern+UX(C.count),n.push(C),u=h+1,A=C}return n}function BL(t,e,r,o,a){let n=[];for(let u of t){let{string:A}=u;!o&&!NX(e,"string",A)&&n.push(r+A),o&&NX(e,"string",A)&&n.push(r+A)}return n}function V9e(t,e){let r=[];for(let o=0;o<t.length;o++)r.push([t[o],e[o]]);return r}function z9e(t,e){return t>e?1:e>t?-1:0}function NX(t,e,r){return t.some(o=>o[e]===r)}function OX(t,e){return Number(String(t).slice(0,-e)+"9".repeat(e))}function MX(t,e){return t-t%Math.pow(10,e)}function UX(t){let[e=0,r=""]=t;return r||e>1?`{${e+(r?","+r:"")}}`:""}function J9e(t,e,r){return`[${t}${e-t===1?"":"-"}${e}]`}function _X(t){return/^-?(0+)\d/.test(t)}function X9e(t,e,r){if(!e.isPadded)return t;let o=Math.abs(e.maxLen-String(t).length),a=r.relaxZeros!==!1;switch(o){case 0:return"";case 1:return a?"0?":"0";case 2:return a?"0{0,2}":"00";default:return a?`0{0,${o}}`:`0{${o}}`}}ud.cache={};ud.clearCache=()=>ud.cache={};HX.exports=ud});var PL=_((LQt,JX)=>{"use strict";var Z9e=Be("util"),YX=jX(),qX=t=>t!==null&&typeof t=="object"&&!Array.isArray(t),$9e=t=>e=>t===!0?Number(e):String(e),vL=t=>typeof t=="number"||typeof t=="string"&&t!=="",PI=t=>Number.isInteger(+t),DL=t=>{let e=`${t}`,r=-1;if(e[0]==="-"&&(e=e.slice(1)),e==="0")return!1;for(;e[++r]==="0";);return r>0},e7e=(t,e,r)=>typeof t=="string"||typeof e=="string"?!0:r.stringify===!0,t7e=(t,e,r)=>{if(e>0){let o=t[0]==="-"?"-":"";o&&(t=t.slice(1)),t=o+t.padStart(o?e-1:e,"0")}return r===!1?String(t):t},GX=(t,e)=>{let r=t[0]==="-"?"-":"";for(r&&(t=t.slice(1),e--);t.length<e;)t="0"+t;return r?"-"+t:t},r7e=(t,e)=>{t.negatives.sort((u,A)=>u<A?-1:u>A?1:0),t.positives.sort((u,A)=>u<A?-1:u>A?1:0);let r=e.capture?"":"?:",o="",a="",n;return t.positives.length&&(o=t.positives.join("|")),t.negatives.length&&(a=`-(${r}${t.negatives.join("|")})`),o&&a?n=`${o}|${a}`:n=o||a,e.wrap?`(${r}${n})`:n},WX=(t,e,r,o)=>{if(r)return YX(t,e,{wrap:!1,...o});let a=String.fromCharCode(t);if(t===e)return a;let n=String.fromCharCode(e);return`[${a}-${n}]`},KX=(t,e,r)=>{if(Array.isArray(t)){let o=r.wrap===!0,a=r.capture?"":"?:";return o?`(${a}${t.join("|")})`:t.join("|")}return YX(t,e,r)},VX=(...t)=>new RangeError("Invalid range arguments: "+Z9e.inspect(...t)),zX=(t,e,r)=>{if(r.strictRanges===!0)throw VX([t,e]);return[]},n7e=(t,e)=>{if(e.strictRanges===!0)throw new TypeError(`Expected step "${t}" to be a number`);return[]},i7e=(t,e,r=1,o={})=>{let a=Number(t),n=Number(e);if(!Number.isInteger(a)||!Number.isInteger(n)){if(o.strictRanges===!0)throw VX([t,e]);return[]}a===0&&(a=0),n===0&&(n=0);let u=a>n,A=String(t),p=String(e),h=String(r);r=Math.max(Math.abs(r),1);let C=DL(A)||DL(p)||DL(h),I=C?Math.max(A.length,p.length,h.length):0,v=C===!1&&e7e(t,e,o)===!1,b=o.transform||$9e(v);if(o.toRegex&&r===1)return WX(GX(t,I),GX(e,I),!0,o);let E={negatives:[],positives:[]},F=z=>E[z<0?"negatives":"positives"].push(Math.abs(z)),N=[],U=0;for(;u?a>=n:a<=n;)o.toRegex===!0&&r>1?F(a):N.push(t7e(b(a,U),I,v)),a=u?a-r:a+r,U++;return o.toRegex===!0?r>1?r7e(E,o):KX(N,null,{wrap:!1,...o}):N},s7e=(t,e,r=1,o={})=>{if(!PI(t)&&t.length>1||!PI(e)&&e.length>1)return zX(t,e,o);let a=o.transform||(v=>String.fromCharCode(v)),n=`${t}`.charCodeAt(0),u=`${e}`.charCodeAt(0),A=n>u,p=Math.min(n,u),h=Math.max(n,u);if(o.toRegex&&r===1)return WX(p,h,!1,o);let C=[],I=0;for(;A?n>=u:n<=u;)C.push(a(n,I)),n=A?n-r:n+r,I++;return o.toRegex===!0?KX(C,null,{wrap:!1,options:o}):C},FP=(t,e,r,o={})=>{if(e==null&&vL(t))return[t];if(!vL(t)||!vL(e))return zX(t,e,o);if(typeof r=="function")return FP(t,e,1,{transform:r});if(qX(r))return FP(t,e,0,r);let a={...o};return a.capture===!0&&(a.wrap=!0),r=r||a.step||1,PI(r)?PI(t)&&PI(e)?i7e(t,e,r,a):s7e(t,e,Math.max(Math.abs(r),1),a):r!=null&&!qX(r)?n7e(r,a):FP(t,e,1,r)};JX.exports=FP});var $X=_((NQt,ZX)=>{"use strict";var o7e=PL(),XX=kP(),a7e=(t,e={})=>{let r=(o,a={})=>{let n=XX.isInvalidBrace(a),u=o.invalid===!0&&e.escapeInvalid===!0,A=n===!0||u===!0,p=e.escapeInvalid===!0?"\\":"",h="";if(o.isOpen===!0||o.isClose===!0)return p+o.value;if(o.type==="open")return A?p+o.value:"(";if(o.type==="close")return A?p+o.value:")";if(o.type==="comma")return o.prev.type==="comma"?"":A?o.value:"|";if(o.value)return o.value;if(o.nodes&&o.ranges>0){let C=XX.reduce(o.nodes),I=o7e(...C,{...e,wrap:!1,toRegex:!0});if(I.length!==0)return C.length>1&&I.length>1?`(${I})`:I}if(o.nodes)for(let C of o.nodes)h+=r(C,o);return h};return r(t)};ZX.exports=a7e});var rZ=_((OQt,tZ)=>{"use strict";var l7e=PL(),eZ=QP(),Fy=kP(),Ad=(t="",e="",r=!1)=>{let o=[];if(t=[].concat(t),e=[].concat(e),!e.length)return t;if(!t.length)return r?Fy.flatten(e).map(a=>`{${a}}`):e;for(let a of t)if(Array.isArray(a))for(let n of a)o.push(Ad(n,e,r));else for(let n of e)r===!0&&typeof n=="string"&&(n=`{${n}}`),o.push(Array.isArray(n)?Ad(a,n,r):a+n);return Fy.flatten(o)},c7e=(t,e={})=>{let r=e.rangeLimit===void 0?1e3:e.rangeLimit,o=(a,n={})=>{a.queue=[];let u=n,A=n.queue;for(;u.type!=="brace"&&u.type!=="root"&&u.parent;)u=u.parent,A=u.queue;if(a.invalid||a.dollar){A.push(Ad(A.pop(),eZ(a,e)));return}if(a.type==="brace"&&a.invalid!==!0&&a.nodes.length===2){A.push(Ad(A.pop(),["{}"]));return}if(a.nodes&&a.ranges>0){let I=Fy.reduce(a.nodes);if(Fy.exceedsLimit(...I,e.step,r))throw new RangeError("expanded array length exceeds range limit. Use options.rangeLimit to increase or disable the limit.");let v=l7e(...I,e);v.length===0&&(v=eZ(a,e)),A.push(Ad(A.pop(),v)),a.nodes=[];return}let p=Fy.encloseBrace(a),h=a.queue,C=a;for(;C.type!=="brace"&&C.type!=="root"&&C.parent;)C=C.parent,h=C.queue;for(let I=0;I<a.nodes.length;I++){let v=a.nodes[I];if(v.type==="comma"&&a.type==="brace"){I===1&&h.push(""),h.push("");continue}if(v.type==="close"){A.push(Ad(A.pop(),h,p));continue}if(v.value&&v.type!=="open"){h.push(Ad(h.pop(),v.value));continue}v.nodes&&o(v,a)}return h};return Fy.flatten(o(t))};tZ.exports=c7e});var iZ=_((MQt,nZ)=>{"use strict";nZ.exports={MAX_LENGTH:1024*64,CHAR_0:"0",CHAR_9:"9",CHAR_UPPERCASE_A:"A",CHAR_LOWERCASE_A:"a",CHAR_UPPERCASE_Z:"Z",CHAR_LOWERCASE_Z:"z",CHAR_LEFT_PARENTHESES:"(",CHAR_RIGHT_PARENTHESES:")",CHAR_ASTERISK:"*",CHAR_AMPERSAND:"&",CHAR_AT:"@",CHAR_BACKSLASH:"\\",CHAR_BACKTICK:"`",CHAR_CARRIAGE_RETURN:"\r",CHAR_CIRCUMFLEX_ACCENT:"^",CHAR_COLON:":",CHAR_COMMA:",",CHAR_DOLLAR:"$",CHAR_DOT:".",CHAR_DOUBLE_QUOTE:'"',CHAR_EQUAL:"=",CHAR_EXCLAMATION_MARK:"!",CHAR_FORM_FEED:"\f",CHAR_FORWARD_SLASH:"/",CHAR_HASH:"#",CHAR_HYPHEN_MINUS:"-",CHAR_LEFT_ANGLE_BRACKET:"<",CHAR_LEFT_CURLY_BRACE:"{",CHAR_LEFT_SQUARE_BRACKET:"[",CHAR_LINE_FEED:` -`,CHAR_NO_BREAK_SPACE:"\xA0",CHAR_PERCENT:"%",CHAR_PLUS:"+",CHAR_QUESTION_MARK:"?",CHAR_RIGHT_ANGLE_BRACKET:">",CHAR_RIGHT_CURLY_BRACE:"}",CHAR_RIGHT_SQUARE_BRACKET:"]",CHAR_SEMICOLON:";",CHAR_SINGLE_QUOTE:"'",CHAR_SPACE:" ",CHAR_TAB:" ",CHAR_UNDERSCORE:"_",CHAR_VERTICAL_LINE:"|",CHAR_ZERO_WIDTH_NOBREAK_SPACE:"\uFEFF"}});var cZ=_((UQt,lZ)=>{"use strict";var u7e=QP(),{MAX_LENGTH:sZ,CHAR_BACKSLASH:SL,CHAR_BACKTICK:A7e,CHAR_COMMA:f7e,CHAR_DOT:p7e,CHAR_LEFT_PARENTHESES:h7e,CHAR_RIGHT_PARENTHESES:g7e,CHAR_LEFT_CURLY_BRACE:d7e,CHAR_RIGHT_CURLY_BRACE:m7e,CHAR_LEFT_SQUARE_BRACKET:oZ,CHAR_RIGHT_SQUARE_BRACKET:aZ,CHAR_DOUBLE_QUOTE:y7e,CHAR_SINGLE_QUOTE:E7e,CHAR_NO_BREAK_SPACE:C7e,CHAR_ZERO_WIDTH_NOBREAK_SPACE:w7e}=iZ(),I7e=(t,e={})=>{if(typeof t!="string")throw new TypeError("Expected a string");let r=e||{},o=typeof r.maxLength=="number"?Math.min(sZ,r.maxLength):sZ;if(t.length>o)throw new SyntaxError(`Input length (${t.length}), exceeds max characters (${o})`);let a={type:"root",input:t,nodes:[]},n=[a],u=a,A=a,p=0,h=t.length,C=0,I=0,v,b={},E=()=>t[C++],F=N=>{if(N.type==="text"&&A.type==="dot"&&(A.type="text"),A&&A.type==="text"&&N.type==="text"){A.value+=N.value;return}return u.nodes.push(N),N.parent=u,N.prev=A,A=N,N};for(F({type:"bos"});C<h;)if(u=n[n.length-1],v=E(),!(v===w7e||v===C7e)){if(v===SL){F({type:"text",value:(e.keepEscaping?v:"")+E()});continue}if(v===aZ){F({type:"text",value:"\\"+v});continue}if(v===oZ){p++;let N=!0,U;for(;C<h&&(U=E());){if(v+=U,U===oZ){p++;continue}if(U===SL){v+=E();continue}if(U===aZ&&(p--,p===0))break}F({type:"text",value:v});continue}if(v===h7e){u=F({type:"paren",nodes:[]}),n.push(u),F({type:"text",value:v});continue}if(v===g7e){if(u.type!=="paren"){F({type:"text",value:v});continue}u=n.pop(),F({type:"text",value:v}),u=n[n.length-1];continue}if(v===y7e||v===E7e||v===A7e){let N=v,U;for(e.keepQuotes!==!0&&(v="");C<h&&(U=E());){if(U===SL){v+=U+E();continue}if(U===N){e.keepQuotes===!0&&(v+=U);break}v+=U}F({type:"text",value:v});continue}if(v===d7e){I++;let U={type:"brace",open:!0,close:!1,dollar:A.value&&A.value.slice(-1)==="$"||u.dollar===!0,depth:I,commas:0,ranges:0,nodes:[]};u=F(U),n.push(u),F({type:"open",value:v});continue}if(v===m7e){if(u.type!=="brace"){F({type:"text",value:v});continue}let N="close";u=n.pop(),u.close=!0,F({type:N,value:v}),I--,u=n[n.length-1];continue}if(v===f7e&&I>0){if(u.ranges>0){u.ranges=0;let N=u.nodes.shift();u.nodes=[N,{type:"text",value:u7e(u)}]}F({type:"comma",value:v}),u.commas++;continue}if(v===p7e&&I>0&&u.commas===0){let N=u.nodes;if(I===0||N.length===0){F({type:"text",value:v});continue}if(A.type==="dot"){if(u.range=[],A.value+=v,A.type="range",u.nodes.length!==3&&u.nodes.length!==5){u.invalid=!0,u.ranges=0,A.type="text";continue}u.ranges++,u.args=[];continue}if(A.type==="range"){N.pop();let U=N[N.length-1];U.value+=A.value+v,A=U,u.ranges--;continue}F({type:"dot",value:v});continue}F({type:"text",value:v})}do if(u=n.pop(),u.type!=="root"){u.nodes.forEach(z=>{z.nodes||(z.type==="open"&&(z.isOpen=!0),z.type==="close"&&(z.isClose=!0),z.nodes||(z.type="text"),z.invalid=!0)});let N=n[n.length-1],U=N.nodes.indexOf(u);N.nodes.splice(U,1,...u.nodes)}while(n.length>0);return F({type:"eos"}),a};lZ.exports=I7e});var fZ=_((_Qt,AZ)=>{"use strict";var uZ=QP(),B7e=$X(),v7e=rZ(),D7e=cZ(),rl=(t,e={})=>{let r=[];if(Array.isArray(t))for(let o of t){let a=rl.create(o,e);Array.isArray(a)?r.push(...a):r.push(a)}else r=[].concat(rl.create(t,e));return e&&e.expand===!0&&e.nodupes===!0&&(r=[...new Set(r)]),r};rl.parse=(t,e={})=>D7e(t,e);rl.stringify=(t,e={})=>uZ(typeof t=="string"?rl.parse(t,e):t,e);rl.compile=(t,e={})=>(typeof t=="string"&&(t=rl.parse(t,e)),B7e(t,e));rl.expand=(t,e={})=>{typeof t=="string"&&(t=rl.parse(t,e));let r=v7e(t,e);return e.noempty===!0&&(r=r.filter(Boolean)),e.nodupes===!0&&(r=[...new Set(r)]),r};rl.create=(t,e={})=>t===""||t.length<3?[t]:e.expand!==!0?rl.compile(t,e):rl.expand(t,e);AZ.exports=rl});var SI=_((HQt,mZ)=>{"use strict";var P7e=Be("path"),Ku="\\\\/",pZ=`[^${Ku}]`,Bf="\\.",S7e="\\+",x7e="\\?",RP="\\/",b7e="(?=.)",hZ="[^/]",xL=`(?:${RP}|$)`,gZ=`(?:^|${RP})`,bL=`${Bf}{1,2}${xL}`,k7e=`(?!${Bf})`,Q7e=`(?!${gZ}${bL})`,F7e=`(?!${Bf}{0,1}${xL})`,R7e=`(?!${bL})`,T7e=`[^.${RP}]`,L7e=`${hZ}*?`,dZ={DOT_LITERAL:Bf,PLUS_LITERAL:S7e,QMARK_LITERAL:x7e,SLASH_LITERAL:RP,ONE_CHAR:b7e,QMARK:hZ,END_ANCHOR:xL,DOTS_SLASH:bL,NO_DOT:k7e,NO_DOTS:Q7e,NO_DOT_SLASH:F7e,NO_DOTS_SLASH:R7e,QMARK_NO_DOT:T7e,STAR:L7e,START_ANCHOR:gZ},N7e={...dZ,SLASH_LITERAL:`[${Ku}]`,QMARK:pZ,STAR:`${pZ}*?`,DOTS_SLASH:`${Bf}{1,2}(?:[${Ku}]|$)`,NO_DOT:`(?!${Bf})`,NO_DOTS:`(?!(?:^|[${Ku}])${Bf}{1,2}(?:[${Ku}]|$))`,NO_DOT_SLASH:`(?!${Bf}{0,1}(?:[${Ku}]|$))`,NO_DOTS_SLASH:`(?!${Bf}{1,2}(?:[${Ku}]|$))`,QMARK_NO_DOT:`[^.${Ku}]`,START_ANCHOR:`(?:^|[${Ku}])`,END_ANCHOR:`(?:[${Ku}]|$)`},O7e={alnum:"a-zA-Z0-9",alpha:"a-zA-Z",ascii:"\\x00-\\x7F",blank:" \\t",cntrl:"\\x00-\\x1F\\x7F",digit:"0-9",graph:"\\x21-\\x7E",lower:"a-z",print:"\\x20-\\x7E ",punct:"\\-!\"#$%&'()\\*+,./:;<=>?@[\\]^_`{|}~",space:" \\t\\r\\n\\v\\f",upper:"A-Z",word:"A-Za-z0-9_",xdigit:"A-Fa-f0-9"};mZ.exports={MAX_LENGTH:1024*64,POSIX_REGEX_SOURCE:O7e,REGEX_BACKSLASH:/\\(?![*+?^${}(|)[\]])/g,REGEX_NON_SPECIAL_CHARS:/^[^@![\].,$*+?^{}()|\\/]+/,REGEX_SPECIAL_CHARS:/[-*+?.^${}(|)[\]]/,REGEX_SPECIAL_CHARS_BACKREF:/(\\?)((\W)(\3*))/g,REGEX_SPECIAL_CHARS_GLOBAL:/([-*+?.^${}(|)[\]])/g,REGEX_REMOVE_BACKSLASH:/(?:\[.*?[^\\]\]|\\(?=.))/g,REPLACEMENTS:{"***":"*","**/**":"**","**/**/**":"**"},CHAR_0:48,CHAR_9:57,CHAR_UPPERCASE_A:65,CHAR_LOWERCASE_A:97,CHAR_UPPERCASE_Z:90,CHAR_LOWERCASE_Z:122,CHAR_LEFT_PARENTHESES:40,CHAR_RIGHT_PARENTHESES:41,CHAR_ASTERISK:42,CHAR_AMPERSAND:38,CHAR_AT:64,CHAR_BACKWARD_SLASH:92,CHAR_CARRIAGE_RETURN:13,CHAR_CIRCUMFLEX_ACCENT:94,CHAR_COLON:58,CHAR_COMMA:44,CHAR_DOT:46,CHAR_DOUBLE_QUOTE:34,CHAR_EQUAL:61,CHAR_EXCLAMATION_MARK:33,CHAR_FORM_FEED:12,CHAR_FORWARD_SLASH:47,CHAR_GRAVE_ACCENT:96,CHAR_HASH:35,CHAR_HYPHEN_MINUS:45,CHAR_LEFT_ANGLE_BRACKET:60,CHAR_LEFT_CURLY_BRACE:123,CHAR_LEFT_SQUARE_BRACKET:91,CHAR_LINE_FEED:10,CHAR_NO_BREAK_SPACE:160,CHAR_PERCENT:37,CHAR_PLUS:43,CHAR_QUESTION_MARK:63,CHAR_RIGHT_ANGLE_BRACKET:62,CHAR_RIGHT_CURLY_BRACE:125,CHAR_RIGHT_SQUARE_BRACKET:93,CHAR_SEMICOLON:59,CHAR_SINGLE_QUOTE:39,CHAR_SPACE:32,CHAR_TAB:9,CHAR_UNDERSCORE:95,CHAR_VERTICAL_LINE:124,CHAR_ZERO_WIDTH_NOBREAK_SPACE:65279,SEP:P7e.sep,extglobChars(t){return{"!":{type:"negate",open:"(?:(?!(?:",close:`))${t.STAR})`},"?":{type:"qmark",open:"(?:",close:")?"},"+":{type:"plus",open:"(?:",close:")+"},"*":{type:"star",open:"(?:",close:")*"},"@":{type:"at",open:"(?:",close:")"}}},globChars(t){return t===!0?N7e:dZ}}});var xI=_(Pa=>{"use strict";var M7e=Be("path"),U7e=process.platform==="win32",{REGEX_BACKSLASH:_7e,REGEX_REMOVE_BACKSLASH:H7e,REGEX_SPECIAL_CHARS:j7e,REGEX_SPECIAL_CHARS_GLOBAL:q7e}=SI();Pa.isObject=t=>t!==null&&typeof t=="object"&&!Array.isArray(t);Pa.hasRegexChars=t=>j7e.test(t);Pa.isRegexChar=t=>t.length===1&&Pa.hasRegexChars(t);Pa.escapeRegex=t=>t.replace(q7e,"\\$1");Pa.toPosixSlashes=t=>t.replace(_7e,"/");Pa.removeBackslashes=t=>t.replace(H7e,e=>e==="\\"?"":e);Pa.supportsLookbehinds=()=>{let t=process.version.slice(1).split(".").map(Number);return t.length===3&&t[0]>=9||t[0]===8&&t[1]>=10};Pa.isWindows=t=>t&&typeof t.windows=="boolean"?t.windows:U7e===!0||M7e.sep==="\\";Pa.escapeLast=(t,e,r)=>{let o=t.lastIndexOf(e,r);return o===-1?t:t[o-1]==="\\"?Pa.escapeLast(t,e,o-1):`${t.slice(0,o)}\\${t.slice(o)}`};Pa.removePrefix=(t,e={})=>{let r=t;return r.startsWith("./")&&(r=r.slice(2),e.prefix="./"),r};Pa.wrapOutput=(t,e={},r={})=>{let o=r.contains?"":"^",a=r.contains?"":"$",n=`${o}(?:${t})${a}`;return e.negated===!0&&(n=`(?:^(?!${n}).*$)`),n}});var DZ=_((qQt,vZ)=>{"use strict";var yZ=xI(),{CHAR_ASTERISK:kL,CHAR_AT:G7e,CHAR_BACKWARD_SLASH:bI,CHAR_COMMA:Y7e,CHAR_DOT:QL,CHAR_EXCLAMATION_MARK:FL,CHAR_FORWARD_SLASH:BZ,CHAR_LEFT_CURLY_BRACE:RL,CHAR_LEFT_PARENTHESES:TL,CHAR_LEFT_SQUARE_BRACKET:W7e,CHAR_PLUS:K7e,CHAR_QUESTION_MARK:EZ,CHAR_RIGHT_CURLY_BRACE:V7e,CHAR_RIGHT_PARENTHESES:CZ,CHAR_RIGHT_SQUARE_BRACKET:z7e}=SI(),wZ=t=>t===BZ||t===bI,IZ=t=>{t.isPrefix!==!0&&(t.depth=t.isGlobstar?1/0:1)},J7e=(t,e)=>{let r=e||{},o=t.length-1,a=r.parts===!0||r.scanToEnd===!0,n=[],u=[],A=[],p=t,h=-1,C=0,I=0,v=!1,b=!1,E=!1,F=!1,N=!1,U=!1,z=!1,te=!1,le=!1,pe=!1,ue=0,ye,ae,Ie={value:"",depth:0,isGlob:!1},Fe=()=>h>=o,g=()=>p.charCodeAt(h+1),Ee=()=>(ye=ae,p.charCodeAt(++h));for(;h<o;){ae=Ee();let we;if(ae===bI){z=Ie.backslashes=!0,ae=Ee(),ae===RL&&(U=!0);continue}if(U===!0||ae===RL){for(ue++;Fe()!==!0&&(ae=Ee());){if(ae===bI){z=Ie.backslashes=!0,Ee();continue}if(ae===RL){ue++;continue}if(U!==!0&&ae===QL&&(ae=Ee())===QL){if(v=Ie.isBrace=!0,E=Ie.isGlob=!0,pe=!0,a===!0)continue;break}if(U!==!0&&ae===Y7e){if(v=Ie.isBrace=!0,E=Ie.isGlob=!0,pe=!0,a===!0)continue;break}if(ae===V7e&&(ue--,ue===0)){U=!1,v=Ie.isBrace=!0,pe=!0;break}}if(a===!0)continue;break}if(ae===BZ){if(n.push(h),u.push(Ie),Ie={value:"",depth:0,isGlob:!1},pe===!0)continue;if(ye===QL&&h===C+1){C+=2;continue}I=h+1;continue}if(r.noext!==!0&&(ae===K7e||ae===G7e||ae===kL||ae===EZ||ae===FL)===!0&&g()===TL){if(E=Ie.isGlob=!0,F=Ie.isExtglob=!0,pe=!0,ae===FL&&h===C&&(le=!0),a===!0){for(;Fe()!==!0&&(ae=Ee());){if(ae===bI){z=Ie.backslashes=!0,ae=Ee();continue}if(ae===CZ){E=Ie.isGlob=!0,pe=!0;break}}continue}break}if(ae===kL){if(ye===kL&&(N=Ie.isGlobstar=!0),E=Ie.isGlob=!0,pe=!0,a===!0)continue;break}if(ae===EZ){if(E=Ie.isGlob=!0,pe=!0,a===!0)continue;break}if(ae===W7e){for(;Fe()!==!0&&(we=Ee());){if(we===bI){z=Ie.backslashes=!0,Ee();continue}if(we===z7e){b=Ie.isBracket=!0,E=Ie.isGlob=!0,pe=!0;break}}if(a===!0)continue;break}if(r.nonegate!==!0&&ae===FL&&h===C){te=Ie.negated=!0,C++;continue}if(r.noparen!==!0&&ae===TL){if(E=Ie.isGlob=!0,a===!0){for(;Fe()!==!0&&(ae=Ee());){if(ae===TL){z=Ie.backslashes=!0,ae=Ee();continue}if(ae===CZ){pe=!0;break}}continue}break}if(E===!0){if(pe=!0,a===!0)continue;break}}r.noext===!0&&(F=!1,E=!1);let De=p,ce="",ne="";C>0&&(ce=p.slice(0,C),p=p.slice(C),I-=C),De&&E===!0&&I>0?(De=p.slice(0,I),ne=p.slice(I)):E===!0?(De="",ne=p):De=p,De&&De!==""&&De!=="/"&&De!==p&&wZ(De.charCodeAt(De.length-1))&&(De=De.slice(0,-1)),r.unescape===!0&&(ne&&(ne=yZ.removeBackslashes(ne)),De&&z===!0&&(De=yZ.removeBackslashes(De)));let ee={prefix:ce,input:t,start:C,base:De,glob:ne,isBrace:v,isBracket:b,isGlob:E,isExtglob:F,isGlobstar:N,negated:te,negatedExtglob:le};if(r.tokens===!0&&(ee.maxDepth=0,wZ(ae)||u.push(Ie),ee.tokens=u),r.parts===!0||r.tokens===!0){let we;for(let be=0;be<n.length;be++){let ht=we?we+1:C,H=n[be],lt=t.slice(ht,H);r.tokens&&(be===0&&C!==0?(u[be].isPrefix=!0,u[be].value=ce):u[be].value=lt,IZ(u[be]),ee.maxDepth+=u[be].depth),(be!==0||lt!=="")&&A.push(lt),we=H}if(we&&we+1<t.length){let be=t.slice(we+1);A.push(be),r.tokens&&(u[u.length-1].value=be,IZ(u[u.length-1]),ee.maxDepth+=u[u.length-1].depth)}ee.slashes=n,ee.parts=A}return ee};vZ.exports=J7e});var xZ=_((GQt,SZ)=>{"use strict";var TP=SI(),nl=xI(),{MAX_LENGTH:LP,POSIX_REGEX_SOURCE:X7e,REGEX_NON_SPECIAL_CHARS:Z7e,REGEX_SPECIAL_CHARS_BACKREF:$7e,REPLACEMENTS:PZ}=TP,eYe=(t,e)=>{if(typeof e.expandRange=="function")return e.expandRange(...t,e);t.sort();let r=`[${t.join("-")}]`;try{new RegExp(r)}catch{return t.map(a=>nl.escapeRegex(a)).join("..")}return r},Ry=(t,e)=>`Missing ${t}: "${e}" - use "\\\\${e}" to match literal characters`,LL=(t,e)=>{if(typeof t!="string")throw new TypeError("Expected a string");t=PZ[t]||t;let r={...e},o=typeof r.maxLength=="number"?Math.min(LP,r.maxLength):LP,a=t.length;if(a>o)throw new SyntaxError(`Input length: ${a}, exceeds maximum allowed length: ${o}`);let n={type:"bos",value:"",output:r.prepend||""},u=[n],A=r.capture?"":"?:",p=nl.isWindows(e),h=TP.globChars(p),C=TP.extglobChars(h),{DOT_LITERAL:I,PLUS_LITERAL:v,SLASH_LITERAL:b,ONE_CHAR:E,DOTS_SLASH:F,NO_DOT:N,NO_DOT_SLASH:U,NO_DOTS_SLASH:z,QMARK:te,QMARK_NO_DOT:le,STAR:pe,START_ANCHOR:ue}=h,ye=x=>`(${A}(?:(?!${ue}${x.dot?F:I}).)*?)`,ae=r.dot?"":N,Ie=r.dot?te:le,Fe=r.bash===!0?ye(r):pe;r.capture&&(Fe=`(${Fe})`),typeof r.noext=="boolean"&&(r.noextglob=r.noext);let g={input:t,index:-1,start:0,dot:r.dot===!0,consumed:"",output:"",prefix:"",backtrack:!1,negated:!1,brackets:0,braces:0,parens:0,quotes:0,globstar:!1,tokens:u};t=nl.removePrefix(t,g),a=t.length;let Ee=[],De=[],ce=[],ne=n,ee,we=()=>g.index===a-1,be=g.peek=(x=1)=>t[g.index+x],ht=g.advance=()=>t[++g.index]||"",H=()=>t.slice(g.index+1),lt=(x="",w=0)=>{g.consumed+=x,g.index+=w},Te=x=>{g.output+=x.output!=null?x.output:x.value,lt(x.value)},ke=()=>{let x=1;for(;be()==="!"&&(be(2)!=="("||be(3)==="?");)ht(),g.start++,x++;return x%2===0?!1:(g.negated=!0,g.start++,!0)},xe=x=>{g[x]++,ce.push(x)},He=x=>{g[x]--,ce.pop()},Re=x=>{if(ne.type==="globstar"){let w=g.braces>0&&(x.type==="comma"||x.type==="brace"),S=x.extglob===!0||Ee.length&&(x.type==="pipe"||x.type==="paren");x.type!=="slash"&&x.type!=="paren"&&!w&&!S&&(g.output=g.output.slice(0,-ne.output.length),ne.type="star",ne.value="*",ne.output=Fe,g.output+=ne.output)}if(Ee.length&&x.type!=="paren"&&(Ee[Ee.length-1].inner+=x.value),(x.value||x.output)&&Te(x),ne&&ne.type==="text"&&x.type==="text"){ne.value+=x.value,ne.output=(ne.output||"")+x.value;return}x.prev=ne,u.push(x),ne=x},ze=(x,w)=>{let S={...C[w],conditions:1,inner:""};S.prev=ne,S.parens=g.parens,S.output=g.output;let y=(r.capture?"(":"")+S.open;xe("parens"),Re({type:x,value:w,output:g.output?"":E}),Re({type:"paren",extglob:!0,value:ht(),output:y}),Ee.push(S)},je=x=>{let w=x.close+(r.capture?")":""),S;if(x.type==="negate"){let y=Fe;if(x.inner&&x.inner.length>1&&x.inner.includes("/")&&(y=ye(r)),(y!==Fe||we()||/^\)+$/.test(H()))&&(w=x.close=`)$))${y}`),x.inner.includes("*")&&(S=H())&&/^\.[^\\/.]+$/.test(S)){let R=LL(S,{...e,fastpaths:!1}).output;w=x.close=`)${R})${y})`}x.prev.type==="bos"&&(g.negatedExtglob=!0)}Re({type:"paren",extglob:!0,value:ee,output:w}),He("parens")};if(r.fastpaths!==!1&&!/(^[*!]|[/()[\]{}"])/.test(t)){let x=!1,w=t.replace($7e,(S,y,R,J,X,Z)=>J==="\\"?(x=!0,S):J==="?"?y?y+J+(X?te.repeat(X.length):""):Z===0?Ie+(X?te.repeat(X.length):""):te.repeat(R.length):J==="."?I.repeat(R.length):J==="*"?y?y+J+(X?Fe:""):Fe:y?S:`\\${S}`);return x===!0&&(r.unescape===!0?w=w.replace(/\\/g,""):w=w.replace(/\\+/g,S=>S.length%2===0?"\\\\":S?"\\":"")),w===t&&r.contains===!0?(g.output=t,g):(g.output=nl.wrapOutput(w,g,e),g)}for(;!we();){if(ee=ht(),ee==="\0")continue;if(ee==="\\"){let S=be();if(S==="/"&&r.bash!==!0||S==="."||S===";")continue;if(!S){ee+="\\",Re({type:"text",value:ee});continue}let y=/^\\+/.exec(H()),R=0;if(y&&y[0].length>2&&(R=y[0].length,g.index+=R,R%2!==0&&(ee+="\\")),r.unescape===!0?ee=ht():ee+=ht(),g.brackets===0){Re({type:"text",value:ee});continue}}if(g.brackets>0&&(ee!=="]"||ne.value==="["||ne.value==="[^")){if(r.posix!==!1&&ee===":"){let S=ne.value.slice(1);if(S.includes("[")&&(ne.posix=!0,S.includes(":"))){let y=ne.value.lastIndexOf("["),R=ne.value.slice(0,y),J=ne.value.slice(y+2),X=X7e[J];if(X){ne.value=R+X,g.backtrack=!0,ht(),!n.output&&u.indexOf(ne)===1&&(n.output=E);continue}}}(ee==="["&&be()!==":"||ee==="-"&&be()==="]")&&(ee=`\\${ee}`),ee==="]"&&(ne.value==="["||ne.value==="[^")&&(ee=`\\${ee}`),r.posix===!0&&ee==="!"&&ne.value==="["&&(ee="^"),ne.value+=ee,Te({value:ee});continue}if(g.quotes===1&&ee!=='"'){ee=nl.escapeRegex(ee),ne.value+=ee,Te({value:ee});continue}if(ee==='"'){g.quotes=g.quotes===1?0:1,r.keepQuotes===!0&&Re({type:"text",value:ee});continue}if(ee==="("){xe("parens"),Re({type:"paren",value:ee});continue}if(ee===")"){if(g.parens===0&&r.strictBrackets===!0)throw new SyntaxError(Ry("opening","("));let S=Ee[Ee.length-1];if(S&&g.parens===S.parens+1){je(Ee.pop());continue}Re({type:"paren",value:ee,output:g.parens?")":"\\)"}),He("parens");continue}if(ee==="["){if(r.nobracket===!0||!H().includes("]")){if(r.nobracket!==!0&&r.strictBrackets===!0)throw new SyntaxError(Ry("closing","]"));ee=`\\${ee}`}else xe("brackets");Re({type:"bracket",value:ee});continue}if(ee==="]"){if(r.nobracket===!0||ne&&ne.type==="bracket"&&ne.value.length===1){Re({type:"text",value:ee,output:`\\${ee}`});continue}if(g.brackets===0){if(r.strictBrackets===!0)throw new SyntaxError(Ry("opening","["));Re({type:"text",value:ee,output:`\\${ee}`});continue}He("brackets");let S=ne.value.slice(1);if(ne.posix!==!0&&S[0]==="^"&&!S.includes("/")&&(ee=`/${ee}`),ne.value+=ee,Te({value:ee}),r.literalBrackets===!1||nl.hasRegexChars(S))continue;let y=nl.escapeRegex(ne.value);if(g.output=g.output.slice(0,-ne.value.length),r.literalBrackets===!0){g.output+=y,ne.value=y;continue}ne.value=`(${A}${y}|${ne.value})`,g.output+=ne.value;continue}if(ee==="{"&&r.nobrace!==!0){xe("braces");let S={type:"brace",value:ee,output:"(",outputIndex:g.output.length,tokensIndex:g.tokens.length};De.push(S),Re(S);continue}if(ee==="}"){let S=De[De.length-1];if(r.nobrace===!0||!S){Re({type:"text",value:ee,output:ee});continue}let y=")";if(S.dots===!0){let R=u.slice(),J=[];for(let X=R.length-1;X>=0&&(u.pop(),R[X].type!=="brace");X--)R[X].type!=="dots"&&J.unshift(R[X].value);y=eYe(J,r),g.backtrack=!0}if(S.comma!==!0&&S.dots!==!0){let R=g.output.slice(0,S.outputIndex),J=g.tokens.slice(S.tokensIndex);S.value=S.output="\\{",ee=y="\\}",g.output=R;for(let X of J)g.output+=X.output||X.value}Re({type:"brace",value:ee,output:y}),He("braces"),De.pop();continue}if(ee==="|"){Ee.length>0&&Ee[Ee.length-1].conditions++,Re({type:"text",value:ee});continue}if(ee===","){let S=ee,y=De[De.length-1];y&&ce[ce.length-1]==="braces"&&(y.comma=!0,S="|"),Re({type:"comma",value:ee,output:S});continue}if(ee==="/"){if(ne.type==="dot"&&g.index===g.start+1){g.start=g.index+1,g.consumed="",g.output="",u.pop(),ne=n;continue}Re({type:"slash",value:ee,output:b});continue}if(ee==="."){if(g.braces>0&&ne.type==="dot"){ne.value==="."&&(ne.output=I);let S=De[De.length-1];ne.type="dots",ne.output+=ee,ne.value+=ee,S.dots=!0;continue}if(g.braces+g.parens===0&&ne.type!=="bos"&&ne.type!=="slash"){Re({type:"text",value:ee,output:I});continue}Re({type:"dot",value:ee,output:I});continue}if(ee==="?"){if(!(ne&&ne.value==="(")&&r.noextglob!==!0&&be()==="("&&be(2)!=="?"){ze("qmark",ee);continue}if(ne&&ne.type==="paren"){let y=be(),R=ee;if(y==="<"&&!nl.supportsLookbehinds())throw new Error("Node.js v10 or higher is required for regex lookbehinds");(ne.value==="("&&!/[!=<:]/.test(y)||y==="<"&&!/<([!=]|\w+>)/.test(H()))&&(R=`\\${ee}`),Re({type:"text",value:ee,output:R});continue}if(r.dot!==!0&&(ne.type==="slash"||ne.type==="bos")){Re({type:"qmark",value:ee,output:le});continue}Re({type:"qmark",value:ee,output:te});continue}if(ee==="!"){if(r.noextglob!==!0&&be()==="("&&(be(2)!=="?"||!/[!=<:]/.test(be(3)))){ze("negate",ee);continue}if(r.nonegate!==!0&&g.index===0){ke();continue}}if(ee==="+"){if(r.noextglob!==!0&&be()==="("&&be(2)!=="?"){ze("plus",ee);continue}if(ne&&ne.value==="("||r.regex===!1){Re({type:"plus",value:ee,output:v});continue}if(ne&&(ne.type==="bracket"||ne.type==="paren"||ne.type==="brace")||g.parens>0){Re({type:"plus",value:ee});continue}Re({type:"plus",value:v});continue}if(ee==="@"){if(r.noextglob!==!0&&be()==="("&&be(2)!=="?"){Re({type:"at",extglob:!0,value:ee,output:""});continue}Re({type:"text",value:ee});continue}if(ee!=="*"){(ee==="$"||ee==="^")&&(ee=`\\${ee}`);let S=Z7e.exec(H());S&&(ee+=S[0],g.index+=S[0].length),Re({type:"text",value:ee});continue}if(ne&&(ne.type==="globstar"||ne.star===!0)){ne.type="star",ne.star=!0,ne.value+=ee,ne.output=Fe,g.backtrack=!0,g.globstar=!0,lt(ee);continue}let x=H();if(r.noextglob!==!0&&/^\([^?]/.test(x)){ze("star",ee);continue}if(ne.type==="star"){if(r.noglobstar===!0){lt(ee);continue}let S=ne.prev,y=S.prev,R=S.type==="slash"||S.type==="bos",J=y&&(y.type==="star"||y.type==="globstar");if(r.bash===!0&&(!R||x[0]&&x[0]!=="/")){Re({type:"star",value:ee,output:""});continue}let X=g.braces>0&&(S.type==="comma"||S.type==="brace"),Z=Ee.length&&(S.type==="pipe"||S.type==="paren");if(!R&&S.type!=="paren"&&!X&&!Z){Re({type:"star",value:ee,output:""});continue}for(;x.slice(0,3)==="/**";){let ie=t[g.index+4];if(ie&&ie!=="/")break;x=x.slice(3),lt("/**",3)}if(S.type==="bos"&&we()){ne.type="globstar",ne.value+=ee,ne.output=ye(r),g.output=ne.output,g.globstar=!0,lt(ee);continue}if(S.type==="slash"&&S.prev.type!=="bos"&&!J&&we()){g.output=g.output.slice(0,-(S.output+ne.output).length),S.output=`(?:${S.output}`,ne.type="globstar",ne.output=ye(r)+(r.strictSlashes?")":"|$)"),ne.value+=ee,g.globstar=!0,g.output+=S.output+ne.output,lt(ee);continue}if(S.type==="slash"&&S.prev.type!=="bos"&&x[0]==="/"){let ie=x[1]!==void 0?"|$":"";g.output=g.output.slice(0,-(S.output+ne.output).length),S.output=`(?:${S.output}`,ne.type="globstar",ne.output=`${ye(r)}${b}|${b}${ie})`,ne.value+=ee,g.output+=S.output+ne.output,g.globstar=!0,lt(ee+ht()),Re({type:"slash",value:"/",output:""});continue}if(S.type==="bos"&&x[0]==="/"){ne.type="globstar",ne.value+=ee,ne.output=`(?:^|${b}|${ye(r)}${b})`,g.output=ne.output,g.globstar=!0,lt(ee+ht()),Re({type:"slash",value:"/",output:""});continue}g.output=g.output.slice(0,-ne.output.length),ne.type="globstar",ne.output=ye(r),ne.value+=ee,g.output+=ne.output,g.globstar=!0,lt(ee);continue}let w={type:"star",value:ee,output:Fe};if(r.bash===!0){w.output=".*?",(ne.type==="bos"||ne.type==="slash")&&(w.output=ae+w.output),Re(w);continue}if(ne&&(ne.type==="bracket"||ne.type==="paren")&&r.regex===!0){w.output=ee,Re(w);continue}(g.index===g.start||ne.type==="slash"||ne.type==="dot")&&(ne.type==="dot"?(g.output+=U,ne.output+=U):r.dot===!0?(g.output+=z,ne.output+=z):(g.output+=ae,ne.output+=ae),be()!=="*"&&(g.output+=E,ne.output+=E)),Re(w)}for(;g.brackets>0;){if(r.strictBrackets===!0)throw new SyntaxError(Ry("closing","]"));g.output=nl.escapeLast(g.output,"["),He("brackets")}for(;g.parens>0;){if(r.strictBrackets===!0)throw new SyntaxError(Ry("closing",")"));g.output=nl.escapeLast(g.output,"("),He("parens")}for(;g.braces>0;){if(r.strictBrackets===!0)throw new SyntaxError(Ry("closing","}"));g.output=nl.escapeLast(g.output,"{"),He("braces")}if(r.strictSlashes!==!0&&(ne.type==="star"||ne.type==="bracket")&&Re({type:"maybe_slash",value:"",output:`${b}?`}),g.backtrack===!0){g.output="";for(let x of g.tokens)g.output+=x.output!=null?x.output:x.value,x.suffix&&(g.output+=x.suffix)}return g};LL.fastpaths=(t,e)=>{let r={...e},o=typeof r.maxLength=="number"?Math.min(LP,r.maxLength):LP,a=t.length;if(a>o)throw new SyntaxError(`Input length: ${a}, exceeds maximum allowed length: ${o}`);t=PZ[t]||t;let n=nl.isWindows(e),{DOT_LITERAL:u,SLASH_LITERAL:A,ONE_CHAR:p,DOTS_SLASH:h,NO_DOT:C,NO_DOTS:I,NO_DOTS_SLASH:v,STAR:b,START_ANCHOR:E}=TP.globChars(n),F=r.dot?I:C,N=r.dot?v:C,U=r.capture?"":"?:",z={negated:!1,prefix:""},te=r.bash===!0?".*?":b;r.capture&&(te=`(${te})`);let le=ae=>ae.noglobstar===!0?te:`(${U}(?:(?!${E}${ae.dot?h:u}).)*?)`,pe=ae=>{switch(ae){case"*":return`${F}${p}${te}`;case".*":return`${u}${p}${te}`;case"*.*":return`${F}${te}${u}${p}${te}`;case"*/*":return`${F}${te}${A}${p}${N}${te}`;case"**":return F+le(r);case"**/*":return`(?:${F}${le(r)}${A})?${N}${p}${te}`;case"**/*.*":return`(?:${F}${le(r)}${A})?${N}${te}${u}${p}${te}`;case"**/.*":return`(?:${F}${le(r)}${A})?${u}${p}${te}`;default:{let Ie=/^(.*?)\.(\w+)$/.exec(ae);if(!Ie)return;let Fe=pe(Ie[1]);return Fe?Fe+u+Ie[2]:void 0}}},ue=nl.removePrefix(t,z),ye=pe(ue);return ye&&r.strictSlashes!==!0&&(ye+=`${A}?`),ye};SZ.exports=LL});var kZ=_((YQt,bZ)=>{"use strict";var tYe=Be("path"),rYe=DZ(),NL=xZ(),OL=xI(),nYe=SI(),iYe=t=>t&&typeof t=="object"&&!Array.isArray(t),Mi=(t,e,r=!1)=>{if(Array.isArray(t)){let C=t.map(v=>Mi(v,e,r));return v=>{for(let b of C){let E=b(v);if(E)return E}return!1}}let o=iYe(t)&&t.tokens&&t.input;if(t===""||typeof t!="string"&&!o)throw new TypeError("Expected pattern to be a non-empty string");let a=e||{},n=OL.isWindows(e),u=o?Mi.compileRe(t,e):Mi.makeRe(t,e,!1,!0),A=u.state;delete u.state;let p=()=>!1;if(a.ignore){let C={...e,ignore:null,onMatch:null,onResult:null};p=Mi(a.ignore,C,r)}let h=(C,I=!1)=>{let{isMatch:v,match:b,output:E}=Mi.test(C,u,e,{glob:t,posix:n}),F={glob:t,state:A,regex:u,posix:n,input:C,output:E,match:b,isMatch:v};return typeof a.onResult=="function"&&a.onResult(F),v===!1?(F.isMatch=!1,I?F:!1):p(C)?(typeof a.onIgnore=="function"&&a.onIgnore(F),F.isMatch=!1,I?F:!1):(typeof a.onMatch=="function"&&a.onMatch(F),I?F:!0)};return r&&(h.state=A),h};Mi.test=(t,e,r,{glob:o,posix:a}={})=>{if(typeof t!="string")throw new TypeError("Expected input to be a string");if(t==="")return{isMatch:!1,output:""};let n=r||{},u=n.format||(a?OL.toPosixSlashes:null),A=t===o,p=A&&u?u(t):t;return A===!1&&(p=u?u(t):t,A=p===o),(A===!1||n.capture===!0)&&(n.matchBase===!0||n.basename===!0?A=Mi.matchBase(t,e,r,a):A=e.exec(p)),{isMatch:Boolean(A),match:A,output:p}};Mi.matchBase=(t,e,r,o=OL.isWindows(r))=>(e instanceof RegExp?e:Mi.makeRe(e,r)).test(tYe.basename(t));Mi.isMatch=(t,e,r)=>Mi(e,r)(t);Mi.parse=(t,e)=>Array.isArray(t)?t.map(r=>Mi.parse(r,e)):NL(t,{...e,fastpaths:!1});Mi.scan=(t,e)=>rYe(t,e);Mi.compileRe=(t,e,r=!1,o=!1)=>{if(r===!0)return t.output;let a=e||{},n=a.contains?"":"^",u=a.contains?"":"$",A=`${n}(?:${t.output})${u}`;t&&t.negated===!0&&(A=`^(?!${A}).*$`);let p=Mi.toRegex(A,e);return o===!0&&(p.state=t),p};Mi.makeRe=(t,e={},r=!1,o=!1)=>{if(!t||typeof t!="string")throw new TypeError("Expected a non-empty string");let a={negated:!1,fastpaths:!0};return e.fastpaths!==!1&&(t[0]==="."||t[0]==="*")&&(a.output=NL.fastpaths(t,e)),a.output||(a=NL(t,e)),Mi.compileRe(a,e,r,o)};Mi.toRegex=(t,e)=>{try{let r=e||{};return new RegExp(t,r.flags||(r.nocase?"i":""))}catch(r){if(e&&e.debug===!0)throw r;return/$^/}};Mi.constants=nYe;bZ.exports=Mi});var FZ=_((WQt,QZ)=>{"use strict";QZ.exports=kZ()});var Zo=_((KQt,NZ)=>{"use strict";var TZ=Be("util"),LZ=fZ(),Vu=FZ(),ML=xI(),RZ=t=>t===""||t==="./",yi=(t,e,r)=>{e=[].concat(e),t=[].concat(t);let o=new Set,a=new Set,n=new Set,u=0,A=C=>{n.add(C.output),r&&r.onResult&&r.onResult(C)};for(let C=0;C<e.length;C++){let I=Vu(String(e[C]),{...r,onResult:A},!0),v=I.state.negated||I.state.negatedExtglob;v&&u++;for(let b of t){let E=I(b,!0);!(v?!E.isMatch:E.isMatch)||(v?o.add(E.output):(o.delete(E.output),a.add(E.output)))}}let h=(u===e.length?[...n]:[...a]).filter(C=>!o.has(C));if(r&&h.length===0){if(r.failglob===!0)throw new Error(`No matches found for "${e.join(", ")}"`);if(r.nonull===!0||r.nullglob===!0)return r.unescape?e.map(C=>C.replace(/\\/g,"")):e}return h};yi.match=yi;yi.matcher=(t,e)=>Vu(t,e);yi.isMatch=(t,e,r)=>Vu(e,r)(t);yi.any=yi.isMatch;yi.not=(t,e,r={})=>{e=[].concat(e).map(String);let o=new Set,a=[],n=A=>{r.onResult&&r.onResult(A),a.push(A.output)},u=new Set(yi(t,e,{...r,onResult:n}));for(let A of a)u.has(A)||o.add(A);return[...o]};yi.contains=(t,e,r)=>{if(typeof t!="string")throw new TypeError(`Expected a string: "${TZ.inspect(t)}"`);if(Array.isArray(e))return e.some(o=>yi.contains(t,o,r));if(typeof e=="string"){if(RZ(t)||RZ(e))return!1;if(t.includes(e)||t.startsWith("./")&&t.slice(2).includes(e))return!0}return yi.isMatch(t,e,{...r,contains:!0})};yi.matchKeys=(t,e,r)=>{if(!ML.isObject(t))throw new TypeError("Expected the first argument to be an object");let o=yi(Object.keys(t),e,r),a={};for(let n of o)a[n]=t[n];return a};yi.some=(t,e,r)=>{let o=[].concat(t);for(let a of[].concat(e)){let n=Vu(String(a),r);if(o.some(u=>n(u)))return!0}return!1};yi.every=(t,e,r)=>{let o=[].concat(t);for(let a of[].concat(e)){let n=Vu(String(a),r);if(!o.every(u=>n(u)))return!1}return!0};yi.all=(t,e,r)=>{if(typeof t!="string")throw new TypeError(`Expected a string: "${TZ.inspect(t)}"`);return[].concat(e).every(o=>Vu(o,r)(t))};yi.capture=(t,e,r)=>{let o=ML.isWindows(r),n=Vu.makeRe(String(t),{...r,capture:!0}).exec(o?ML.toPosixSlashes(e):e);if(n)return n.slice(1).map(u=>u===void 0?"":u)};yi.makeRe=(...t)=>Vu.makeRe(...t);yi.scan=(...t)=>Vu.scan(...t);yi.parse=(t,e)=>{let r=[];for(let o of[].concat(t||[]))for(let a of LZ(String(o),e))r.push(Vu.parse(a,e));return r};yi.braces=(t,e)=>{if(typeof t!="string")throw new TypeError("Expected a string");return e&&e.nobrace===!0||!/\{.*\}/.test(t)?[t]:LZ(t,e)};yi.braceExpand=(t,e)=>{if(typeof t!="string")throw new TypeError("Expected a string");return yi.braces(t,{...e,expand:!0})};NZ.exports=yi});var MZ=_((VQt,OZ)=>{"use strict";OZ.exports=({onlyFirst:t=!1}={})=>{let e=["[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)","(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"].join("|");return new RegExp(e,t?void 0:"g")}});var NP=_((zQt,UZ)=>{"use strict";var sYe=MZ();UZ.exports=t=>typeof t=="string"?t.replace(sYe(),""):t});var HZ=_((JQt,_Z)=>{function oYe(){this.__data__=[],this.size=0}_Z.exports=oYe});var Ty=_((XQt,jZ)=>{function aYe(t,e){return t===e||t!==t&&e!==e}jZ.exports=aYe});var kI=_((ZQt,qZ)=>{var lYe=Ty();function cYe(t,e){for(var r=t.length;r--;)if(lYe(t[r][0],e))return r;return-1}qZ.exports=cYe});var YZ=_(($Qt,GZ)=>{var uYe=kI(),AYe=Array.prototype,fYe=AYe.splice;function pYe(t){var e=this.__data__,r=uYe(e,t);if(r<0)return!1;var o=e.length-1;return r==o?e.pop():fYe.call(e,r,1),--this.size,!0}GZ.exports=pYe});var KZ=_((eFt,WZ)=>{var hYe=kI();function gYe(t){var e=this.__data__,r=hYe(e,t);return r<0?void 0:e[r][1]}WZ.exports=gYe});var zZ=_((tFt,VZ)=>{var dYe=kI();function mYe(t){return dYe(this.__data__,t)>-1}VZ.exports=mYe});var XZ=_((rFt,JZ)=>{var yYe=kI();function EYe(t,e){var r=this.__data__,o=yYe(r,t);return o<0?(++this.size,r.push([t,e])):r[o][1]=e,this}JZ.exports=EYe});var QI=_((nFt,ZZ)=>{var CYe=HZ(),wYe=YZ(),IYe=KZ(),BYe=zZ(),vYe=XZ();function Ly(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e<r;){var o=t[e];this.set(o[0],o[1])}}Ly.prototype.clear=CYe;Ly.prototype.delete=wYe;Ly.prototype.get=IYe;Ly.prototype.has=BYe;Ly.prototype.set=vYe;ZZ.exports=Ly});var e$=_((iFt,$Z)=>{var DYe=QI();function PYe(){this.__data__=new DYe,this.size=0}$Z.exports=PYe});var r$=_((sFt,t$)=>{function SYe(t){var e=this.__data__,r=e.delete(t);return this.size=e.size,r}t$.exports=SYe});var i$=_((oFt,n$)=>{function xYe(t){return this.__data__.get(t)}n$.exports=xYe});var o$=_((aFt,s$)=>{function bYe(t){return this.__data__.has(t)}s$.exports=bYe});var UL=_((lFt,a$)=>{var kYe=typeof global=="object"&&global&&global.Object===Object&&global;a$.exports=kYe});var _l=_((cFt,l$)=>{var QYe=UL(),FYe=typeof self=="object"&&self&&self.Object===Object&&self,RYe=QYe||FYe||Function("return this")();l$.exports=RYe});var fd=_((uFt,c$)=>{var TYe=_l(),LYe=TYe.Symbol;c$.exports=LYe});var p$=_((AFt,f$)=>{var u$=fd(),A$=Object.prototype,NYe=A$.hasOwnProperty,OYe=A$.toString,FI=u$?u$.toStringTag:void 0;function MYe(t){var e=NYe.call(t,FI),r=t[FI];try{t[FI]=void 0;var o=!0}catch{}var a=OYe.call(t);return o&&(e?t[FI]=r:delete t[FI]),a}f$.exports=MYe});var g$=_((fFt,h$)=>{var UYe=Object.prototype,_Ye=UYe.toString;function HYe(t){return _Ye.call(t)}h$.exports=HYe});var pd=_((pFt,y$)=>{var d$=fd(),jYe=p$(),qYe=g$(),GYe="[object Null]",YYe="[object Undefined]",m$=d$?d$.toStringTag:void 0;function WYe(t){return t==null?t===void 0?YYe:GYe:m$&&m$ in Object(t)?jYe(t):qYe(t)}y$.exports=WYe});var il=_((hFt,E$)=>{function KYe(t){var e=typeof t;return t!=null&&(e=="object"||e=="function")}E$.exports=KYe});var OP=_((gFt,C$)=>{var VYe=pd(),zYe=il(),JYe="[object AsyncFunction]",XYe="[object Function]",ZYe="[object GeneratorFunction]",$Ye="[object Proxy]";function eWe(t){if(!zYe(t))return!1;var e=VYe(t);return e==XYe||e==ZYe||e==JYe||e==$Ye}C$.exports=eWe});var I$=_((dFt,w$)=>{var tWe=_l(),rWe=tWe["__core-js_shared__"];w$.exports=rWe});var D$=_((mFt,v$)=>{var _L=I$(),B$=function(){var t=/[^.]+$/.exec(_L&&_L.keys&&_L.keys.IE_PROTO||"");return t?"Symbol(src)_1."+t:""}();function nWe(t){return!!B$&&B$ in t}v$.exports=nWe});var HL=_((yFt,P$)=>{var iWe=Function.prototype,sWe=iWe.toString;function oWe(t){if(t!=null){try{return sWe.call(t)}catch{}try{return t+""}catch{}}return""}P$.exports=oWe});var x$=_((EFt,S$)=>{var aWe=OP(),lWe=D$(),cWe=il(),uWe=HL(),AWe=/[\\^$.*+?()[\]{}|]/g,fWe=/^\[object .+?Constructor\]$/,pWe=Function.prototype,hWe=Object.prototype,gWe=pWe.toString,dWe=hWe.hasOwnProperty,mWe=RegExp("^"+gWe.call(dWe).replace(AWe,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");function yWe(t){if(!cWe(t)||lWe(t))return!1;var e=aWe(t)?mWe:fWe;return e.test(uWe(t))}S$.exports=yWe});var k$=_((CFt,b$)=>{function EWe(t,e){return t?.[e]}b$.exports=EWe});var Vp=_((wFt,Q$)=>{var CWe=x$(),wWe=k$();function IWe(t,e){var r=wWe(t,e);return CWe(r)?r:void 0}Q$.exports=IWe});var MP=_((IFt,F$)=>{var BWe=Vp(),vWe=_l(),DWe=BWe(vWe,"Map");F$.exports=DWe});var RI=_((BFt,R$)=>{var PWe=Vp(),SWe=PWe(Object,"create");R$.exports=SWe});var N$=_((vFt,L$)=>{var T$=RI();function xWe(){this.__data__=T$?T$(null):{},this.size=0}L$.exports=xWe});var M$=_((DFt,O$)=>{function bWe(t){var e=this.has(t)&&delete this.__data__[t];return this.size-=e?1:0,e}O$.exports=bWe});var _$=_((PFt,U$)=>{var kWe=RI(),QWe="__lodash_hash_undefined__",FWe=Object.prototype,RWe=FWe.hasOwnProperty;function TWe(t){var e=this.__data__;if(kWe){var r=e[t];return r===QWe?void 0:r}return RWe.call(e,t)?e[t]:void 0}U$.exports=TWe});var j$=_((SFt,H$)=>{var LWe=RI(),NWe=Object.prototype,OWe=NWe.hasOwnProperty;function MWe(t){var e=this.__data__;return LWe?e[t]!==void 0:OWe.call(e,t)}H$.exports=MWe});var G$=_((xFt,q$)=>{var UWe=RI(),_We="__lodash_hash_undefined__";function HWe(t,e){var r=this.__data__;return this.size+=this.has(t)?0:1,r[t]=UWe&&e===void 0?_We:e,this}q$.exports=HWe});var W$=_((bFt,Y$)=>{var jWe=N$(),qWe=M$(),GWe=_$(),YWe=j$(),WWe=G$();function Ny(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e<r;){var o=t[e];this.set(o[0],o[1])}}Ny.prototype.clear=jWe;Ny.prototype.delete=qWe;Ny.prototype.get=GWe;Ny.prototype.has=YWe;Ny.prototype.set=WWe;Y$.exports=Ny});var z$=_((kFt,V$)=>{var K$=W$(),KWe=QI(),VWe=MP();function zWe(){this.size=0,this.__data__={hash:new K$,map:new(VWe||KWe),string:new K$}}V$.exports=zWe});var X$=_((QFt,J$)=>{function JWe(t){var e=typeof t;return e=="string"||e=="number"||e=="symbol"||e=="boolean"?t!=="__proto__":t===null}J$.exports=JWe});var TI=_((FFt,Z$)=>{var XWe=X$();function ZWe(t,e){var r=t.__data__;return XWe(e)?r[typeof e=="string"?"string":"hash"]:r.map}Z$.exports=ZWe});var eee=_((RFt,$$)=>{var $We=TI();function eKe(t){var e=$We(this,t).delete(t);return this.size-=e?1:0,e}$$.exports=eKe});var ree=_((TFt,tee)=>{var tKe=TI();function rKe(t){return tKe(this,t).get(t)}tee.exports=rKe});var iee=_((LFt,nee)=>{var nKe=TI();function iKe(t){return nKe(this,t).has(t)}nee.exports=iKe});var oee=_((NFt,see)=>{var sKe=TI();function oKe(t,e){var r=sKe(this,t),o=r.size;return r.set(t,e),this.size+=r.size==o?0:1,this}see.exports=oKe});var UP=_((OFt,aee)=>{var aKe=z$(),lKe=eee(),cKe=ree(),uKe=iee(),AKe=oee();function Oy(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e<r;){var o=t[e];this.set(o[0],o[1])}}Oy.prototype.clear=aKe;Oy.prototype.delete=lKe;Oy.prototype.get=cKe;Oy.prototype.has=uKe;Oy.prototype.set=AKe;aee.exports=Oy});var cee=_((MFt,lee)=>{var fKe=QI(),pKe=MP(),hKe=UP(),gKe=200;function dKe(t,e){var r=this.__data__;if(r instanceof fKe){var o=r.__data__;if(!pKe||o.length<gKe-1)return o.push([t,e]),this.size=++r.size,this;r=this.__data__=new hKe(o)}return r.set(t,e),this.size=r.size,this}lee.exports=dKe});var _P=_((UFt,uee)=>{var mKe=QI(),yKe=e$(),EKe=r$(),CKe=i$(),wKe=o$(),IKe=cee();function My(t){var e=this.__data__=new mKe(t);this.size=e.size}My.prototype.clear=yKe;My.prototype.delete=EKe;My.prototype.get=CKe;My.prototype.has=wKe;My.prototype.set=IKe;uee.exports=My});var fee=_((_Ft,Aee)=>{var BKe="__lodash_hash_undefined__";function vKe(t){return this.__data__.set(t,BKe),this}Aee.exports=vKe});var hee=_((HFt,pee)=>{function DKe(t){return this.__data__.has(t)}pee.exports=DKe});var dee=_((jFt,gee)=>{var PKe=UP(),SKe=fee(),xKe=hee();function HP(t){var e=-1,r=t==null?0:t.length;for(this.__data__=new PKe;++e<r;)this.add(t[e])}HP.prototype.add=HP.prototype.push=SKe;HP.prototype.has=xKe;gee.exports=HP});var yee=_((qFt,mee)=>{function bKe(t,e){for(var r=-1,o=t==null?0:t.length;++r<o;)if(e(t[r],r,t))return!0;return!1}mee.exports=bKe});var Cee=_((GFt,Eee)=>{function kKe(t,e){return t.has(e)}Eee.exports=kKe});var jL=_((YFt,wee)=>{var QKe=dee(),FKe=yee(),RKe=Cee(),TKe=1,LKe=2;function NKe(t,e,r,o,a,n){var u=r&TKe,A=t.length,p=e.length;if(A!=p&&!(u&&p>A))return!1;var h=n.get(t),C=n.get(e);if(h&&C)return h==e&&C==t;var I=-1,v=!0,b=r&LKe?new QKe:void 0;for(n.set(t,e),n.set(e,t);++I<A;){var E=t[I],F=e[I];if(o)var N=u?o(F,E,I,e,t,n):o(E,F,I,t,e,n);if(N!==void 0){if(N)continue;v=!1;break}if(b){if(!FKe(e,function(U,z){if(!RKe(b,z)&&(E===U||a(E,U,r,o,n)))return b.push(z)})){v=!1;break}}else if(!(E===F||a(E,F,r,o,n))){v=!1;break}}return n.delete(t),n.delete(e),v}wee.exports=NKe});var qL=_((WFt,Iee)=>{var OKe=_l(),MKe=OKe.Uint8Array;Iee.exports=MKe});var vee=_((KFt,Bee)=>{function UKe(t){var e=-1,r=Array(t.size);return t.forEach(function(o,a){r[++e]=[a,o]}),r}Bee.exports=UKe});var Pee=_((VFt,Dee)=>{function _Ke(t){var e=-1,r=Array(t.size);return t.forEach(function(o){r[++e]=o}),r}Dee.exports=_Ke});var Qee=_((zFt,kee)=>{var See=fd(),xee=qL(),HKe=Ty(),jKe=jL(),qKe=vee(),GKe=Pee(),YKe=1,WKe=2,KKe="[object Boolean]",VKe="[object Date]",zKe="[object Error]",JKe="[object Map]",XKe="[object Number]",ZKe="[object RegExp]",$Ke="[object Set]",eVe="[object String]",tVe="[object Symbol]",rVe="[object ArrayBuffer]",nVe="[object DataView]",bee=See?See.prototype:void 0,YL=bee?bee.valueOf:void 0;function iVe(t,e,r,o,a,n,u){switch(r){case nVe:if(t.byteLength!=e.byteLength||t.byteOffset!=e.byteOffset)return!1;t=t.buffer,e=e.buffer;case rVe:return!(t.byteLength!=e.byteLength||!n(new xee(t),new xee(e)));case KKe:case VKe:case XKe:return HKe(+t,+e);case zKe:return t.name==e.name&&t.message==e.message;case ZKe:case eVe:return t==e+"";case JKe:var A=qKe;case $Ke:var p=o&YKe;if(A||(A=GKe),t.size!=e.size&&!p)return!1;var h=u.get(t);if(h)return h==e;o|=WKe,u.set(t,e);var C=jKe(A(t),A(e),o,a,n,u);return u.delete(t),C;case tVe:if(YL)return YL.call(t)==YL.call(e)}return!1}kee.exports=iVe});var jP=_((JFt,Fee)=>{function sVe(t,e){for(var r=-1,o=e.length,a=t.length;++r<o;)t[a+r]=e[r];return t}Fee.exports=sVe});var Hl=_((XFt,Ree)=>{var oVe=Array.isArray;Ree.exports=oVe});var WL=_((ZFt,Tee)=>{var aVe=jP(),lVe=Hl();function cVe(t,e,r){var o=e(t);return lVe(t)?o:aVe(o,r(t))}Tee.exports=cVe});var Nee=_(($Ft,Lee)=>{function uVe(t,e){for(var r=-1,o=t==null?0:t.length,a=0,n=[];++r<o;){var u=t[r];e(u,r,t)&&(n[a++]=u)}return n}Lee.exports=uVe});var KL=_((eRt,Oee)=>{function AVe(){return[]}Oee.exports=AVe});var qP=_((tRt,Uee)=>{var fVe=Nee(),pVe=KL(),hVe=Object.prototype,gVe=hVe.propertyIsEnumerable,Mee=Object.getOwnPropertySymbols,dVe=Mee?function(t){return t==null?[]:(t=Object(t),fVe(Mee(t),function(e){return gVe.call(t,e)}))}:pVe;Uee.exports=dVe});var Hee=_((rRt,_ee)=>{function mVe(t,e){for(var r=-1,o=Array(t);++r<t;)o[r]=e(r);return o}_ee.exports=mVe});var zu=_((nRt,jee)=>{function yVe(t){return t!=null&&typeof t=="object"}jee.exports=yVe});var Gee=_((iRt,qee)=>{var EVe=pd(),CVe=zu(),wVe="[object Arguments]";function IVe(t){return CVe(t)&&EVe(t)==wVe}qee.exports=IVe});var LI=_((sRt,Kee)=>{var Yee=Gee(),BVe=zu(),Wee=Object.prototype,vVe=Wee.hasOwnProperty,DVe=Wee.propertyIsEnumerable,PVe=Yee(function(){return arguments}())?Yee:function(t){return BVe(t)&&vVe.call(t,"callee")&&!DVe.call(t,"callee")};Kee.exports=PVe});var zee=_((oRt,Vee)=>{function SVe(){return!1}Vee.exports=SVe});var OI=_((NI,Uy)=>{var xVe=_l(),bVe=zee(),Zee=typeof NI=="object"&&NI&&!NI.nodeType&&NI,Jee=Zee&&typeof Uy=="object"&&Uy&&!Uy.nodeType&&Uy,kVe=Jee&&Jee.exports===Zee,Xee=kVe?xVe.Buffer:void 0,QVe=Xee?Xee.isBuffer:void 0,FVe=QVe||bVe;Uy.exports=FVe});var MI=_((aRt,$ee)=>{var RVe=9007199254740991,TVe=/^(?:0|[1-9]\d*)$/;function LVe(t,e){var r=typeof t;return e=e??RVe,!!e&&(r=="number"||r!="symbol"&&TVe.test(t))&&t>-1&&t%1==0&&t<e}$ee.exports=LVe});var GP=_((lRt,ete)=>{var NVe=9007199254740991;function OVe(t){return typeof t=="number"&&t>-1&&t%1==0&&t<=NVe}ete.exports=OVe});var rte=_((cRt,tte)=>{var MVe=pd(),UVe=GP(),_Ve=zu(),HVe="[object Arguments]",jVe="[object Array]",qVe="[object Boolean]",GVe="[object Date]",YVe="[object Error]",WVe="[object Function]",KVe="[object Map]",VVe="[object Number]",zVe="[object Object]",JVe="[object RegExp]",XVe="[object Set]",ZVe="[object String]",$Ve="[object WeakMap]",eze="[object ArrayBuffer]",tze="[object DataView]",rze="[object Float32Array]",nze="[object Float64Array]",ize="[object Int8Array]",sze="[object Int16Array]",oze="[object Int32Array]",aze="[object Uint8Array]",lze="[object Uint8ClampedArray]",cze="[object Uint16Array]",uze="[object Uint32Array]",ui={};ui[rze]=ui[nze]=ui[ize]=ui[sze]=ui[oze]=ui[aze]=ui[lze]=ui[cze]=ui[uze]=!0;ui[HVe]=ui[jVe]=ui[eze]=ui[qVe]=ui[tze]=ui[GVe]=ui[YVe]=ui[WVe]=ui[KVe]=ui[VVe]=ui[zVe]=ui[JVe]=ui[XVe]=ui[ZVe]=ui[$Ve]=!1;function Aze(t){return _Ve(t)&&UVe(t.length)&&!!ui[MVe(t)]}tte.exports=Aze});var YP=_((uRt,nte)=>{function fze(t){return function(e){return t(e)}}nte.exports=fze});var WP=_((UI,_y)=>{var pze=UL(),ite=typeof UI=="object"&&UI&&!UI.nodeType&&UI,_I=ite&&typeof _y=="object"&&_y&&!_y.nodeType&&_y,hze=_I&&_I.exports===ite,VL=hze&&pze.process,gze=function(){try{var t=_I&&_I.require&&_I.require("util").types;return t||VL&&VL.binding&&VL.binding("util")}catch{}}();_y.exports=gze});var KP=_((ARt,ate)=>{var dze=rte(),mze=YP(),ste=WP(),ote=ste&&ste.isTypedArray,yze=ote?mze(ote):dze;ate.exports=yze});var zL=_((fRt,lte)=>{var Eze=Hee(),Cze=LI(),wze=Hl(),Ize=OI(),Bze=MI(),vze=KP(),Dze=Object.prototype,Pze=Dze.hasOwnProperty;function Sze(t,e){var r=wze(t),o=!r&&Cze(t),a=!r&&!o&&Ize(t),n=!r&&!o&&!a&&vze(t),u=r||o||a||n,A=u?Eze(t.length,String):[],p=A.length;for(var h in t)(e||Pze.call(t,h))&&!(u&&(h=="length"||a&&(h=="offset"||h=="parent")||n&&(h=="buffer"||h=="byteLength"||h=="byteOffset")||Bze(h,p)))&&A.push(h);return A}lte.exports=Sze});var VP=_((pRt,cte)=>{var xze=Object.prototype;function bze(t){var e=t&&t.constructor,r=typeof e=="function"&&e.prototype||xze;return t===r}cte.exports=bze});var JL=_((hRt,ute)=>{function kze(t,e){return function(r){return t(e(r))}}ute.exports=kze});var fte=_((gRt,Ate)=>{var Qze=JL(),Fze=Qze(Object.keys,Object);Ate.exports=Fze});var hte=_((dRt,pte)=>{var Rze=VP(),Tze=fte(),Lze=Object.prototype,Nze=Lze.hasOwnProperty;function Oze(t){if(!Rze(t))return Tze(t);var e=[];for(var r in Object(t))Nze.call(t,r)&&r!="constructor"&&e.push(r);return e}pte.exports=Oze});var HI=_((mRt,gte)=>{var Mze=OP(),Uze=GP();function _ze(t){return t!=null&&Uze(t.length)&&!Mze(t)}gte.exports=_ze});var zP=_((yRt,dte)=>{var Hze=zL(),jze=hte(),qze=HI();function Gze(t){return qze(t)?Hze(t):jze(t)}dte.exports=Gze});var XL=_((ERt,mte)=>{var Yze=WL(),Wze=qP(),Kze=zP();function Vze(t){return Yze(t,Kze,Wze)}mte.exports=Vze});var Cte=_((CRt,Ete)=>{var yte=XL(),zze=1,Jze=Object.prototype,Xze=Jze.hasOwnProperty;function Zze(t,e,r,o,a,n){var u=r&zze,A=yte(t),p=A.length,h=yte(e),C=h.length;if(p!=C&&!u)return!1;for(var I=p;I--;){var v=A[I];if(!(u?v in e:Xze.call(e,v)))return!1}var b=n.get(t),E=n.get(e);if(b&&E)return b==e&&E==t;var F=!0;n.set(t,e),n.set(e,t);for(var N=u;++I<p;){v=A[I];var U=t[v],z=e[v];if(o)var te=u?o(z,U,v,e,t,n):o(U,z,v,t,e,n);if(!(te===void 0?U===z||a(U,z,r,o,n):te)){F=!1;break}N||(N=v=="constructor")}if(F&&!N){var le=t.constructor,pe=e.constructor;le!=pe&&"constructor"in t&&"constructor"in e&&!(typeof le=="function"&&le instanceof le&&typeof pe=="function"&&pe instanceof pe)&&(F=!1)}return n.delete(t),n.delete(e),F}Ete.exports=Zze});var Ite=_((wRt,wte)=>{var $ze=Vp(),eJe=_l(),tJe=$ze(eJe,"DataView");wte.exports=tJe});var vte=_((IRt,Bte)=>{var rJe=Vp(),nJe=_l(),iJe=rJe(nJe,"Promise");Bte.exports=iJe});var Pte=_((BRt,Dte)=>{var sJe=Vp(),oJe=_l(),aJe=sJe(oJe,"Set");Dte.exports=aJe});var xte=_((vRt,Ste)=>{var lJe=Vp(),cJe=_l(),uJe=lJe(cJe,"WeakMap");Ste.exports=uJe});var jI=_((DRt,Lte)=>{var ZL=Ite(),$L=MP(),eN=vte(),tN=Pte(),rN=xte(),Tte=pd(),Hy=HL(),bte="[object Map]",AJe="[object Object]",kte="[object Promise]",Qte="[object Set]",Fte="[object WeakMap]",Rte="[object DataView]",fJe=Hy(ZL),pJe=Hy($L),hJe=Hy(eN),gJe=Hy(tN),dJe=Hy(rN),hd=Tte;(ZL&&hd(new ZL(new ArrayBuffer(1)))!=Rte||$L&&hd(new $L)!=bte||eN&&hd(eN.resolve())!=kte||tN&&hd(new tN)!=Qte||rN&&hd(new rN)!=Fte)&&(hd=function(t){var e=Tte(t),r=e==AJe?t.constructor:void 0,o=r?Hy(r):"";if(o)switch(o){case fJe:return Rte;case pJe:return bte;case hJe:return kte;case gJe:return Qte;case dJe:return Fte}return e});Lte.exports=hd});var qte=_((PRt,jte)=>{var nN=_P(),mJe=jL(),yJe=Qee(),EJe=Cte(),Nte=jI(),Ote=Hl(),Mte=OI(),CJe=KP(),wJe=1,Ute="[object Arguments]",_te="[object Array]",JP="[object Object]",IJe=Object.prototype,Hte=IJe.hasOwnProperty;function BJe(t,e,r,o,a,n){var u=Ote(t),A=Ote(e),p=u?_te:Nte(t),h=A?_te:Nte(e);p=p==Ute?JP:p,h=h==Ute?JP:h;var C=p==JP,I=h==JP,v=p==h;if(v&&Mte(t)){if(!Mte(e))return!1;u=!0,C=!1}if(v&&!C)return n||(n=new nN),u||CJe(t)?mJe(t,e,r,o,a,n):yJe(t,e,p,r,o,a,n);if(!(r&wJe)){var b=C&&Hte.call(t,"__wrapped__"),E=I&&Hte.call(e,"__wrapped__");if(b||E){var F=b?t.value():t,N=E?e.value():e;return n||(n=new nN),a(F,N,r,o,n)}}return v?(n||(n=new nN),EJe(t,e,r,o,a,n)):!1}jte.exports=BJe});var Kte=_((SRt,Wte)=>{var vJe=qte(),Gte=zu();function Yte(t,e,r,o,a){return t===e?!0:t==null||e==null||!Gte(t)&&!Gte(e)?t!==t&&e!==e:vJe(t,e,r,o,Yte,a)}Wte.exports=Yte});var zte=_((xRt,Vte)=>{var DJe=Kte();function PJe(t,e){return DJe(t,e)}Vte.exports=PJe});var iN=_((bRt,Jte)=>{var SJe=Vp(),xJe=function(){try{var t=SJe(Object,"defineProperty");return t({},"",{}),t}catch{}}();Jte.exports=xJe});var XP=_((kRt,Zte)=>{var Xte=iN();function bJe(t,e,r){e=="__proto__"&&Xte?Xte(t,e,{configurable:!0,enumerable:!0,value:r,writable:!0}):t[e]=r}Zte.exports=bJe});var sN=_((QRt,$te)=>{var kJe=XP(),QJe=Ty();function FJe(t,e,r){(r!==void 0&&!QJe(t[e],r)||r===void 0&&!(e in t))&&kJe(t,e,r)}$te.exports=FJe});var tre=_((FRt,ere)=>{function RJe(t){return function(e,r,o){for(var a=-1,n=Object(e),u=o(e),A=u.length;A--;){var p=u[t?A:++a];if(r(n[p],p,n)===!1)break}return e}}ere.exports=RJe});var nre=_((RRt,rre)=>{var TJe=tre(),LJe=TJe();rre.exports=LJe});var oN=_((qI,jy)=>{var NJe=_l(),are=typeof qI=="object"&&qI&&!qI.nodeType&&qI,ire=are&&typeof jy=="object"&&jy&&!jy.nodeType&&jy,OJe=ire&&ire.exports===are,sre=OJe?NJe.Buffer:void 0,ore=sre?sre.allocUnsafe:void 0;function MJe(t,e){if(e)return t.slice();var r=t.length,o=ore?ore(r):new t.constructor(r);return t.copy(o),o}jy.exports=MJe});var ZP=_((TRt,cre)=>{var lre=qL();function UJe(t){var e=new t.constructor(t.byteLength);return new lre(e).set(new lre(t)),e}cre.exports=UJe});var aN=_((LRt,ure)=>{var _Je=ZP();function HJe(t,e){var r=e?_Je(t.buffer):t.buffer;return new t.constructor(r,t.byteOffset,t.length)}ure.exports=HJe});var $P=_((NRt,Are)=>{function jJe(t,e){var r=-1,o=t.length;for(e||(e=Array(o));++r<o;)e[r]=t[r];return e}Are.exports=jJe});var hre=_((ORt,pre)=>{var qJe=il(),fre=Object.create,GJe=function(){function t(){}return function(e){if(!qJe(e))return{};if(fre)return fre(e);t.prototype=e;var r=new t;return t.prototype=void 0,r}}();pre.exports=GJe});var eS=_((MRt,gre)=>{var YJe=JL(),WJe=YJe(Object.getPrototypeOf,Object);gre.exports=WJe});var lN=_((URt,dre)=>{var KJe=hre(),VJe=eS(),zJe=VP();function JJe(t){return typeof t.constructor=="function"&&!zJe(t)?KJe(VJe(t)):{}}dre.exports=JJe});var yre=_((_Rt,mre)=>{var XJe=HI(),ZJe=zu();function $Je(t){return ZJe(t)&&XJe(t)}mre.exports=$Je});var cN=_((HRt,Cre)=>{var eXe=pd(),tXe=eS(),rXe=zu(),nXe="[object Object]",iXe=Function.prototype,sXe=Object.prototype,Ere=iXe.toString,oXe=sXe.hasOwnProperty,aXe=Ere.call(Object);function lXe(t){if(!rXe(t)||eXe(t)!=nXe)return!1;var e=tXe(t);if(e===null)return!0;var r=oXe.call(e,"constructor")&&e.constructor;return typeof r=="function"&&r instanceof r&&Ere.call(r)==aXe}Cre.exports=lXe});var uN=_((jRt,wre)=>{function cXe(t,e){if(!(e==="constructor"&&typeof t[e]=="function")&&e!="__proto__")return t[e]}wre.exports=cXe});var tS=_((qRt,Ire)=>{var uXe=XP(),AXe=Ty(),fXe=Object.prototype,pXe=fXe.hasOwnProperty;function hXe(t,e,r){var o=t[e];(!(pXe.call(t,e)&&AXe(o,r))||r===void 0&&!(e in t))&&uXe(t,e,r)}Ire.exports=hXe});var gd=_((GRt,Bre)=>{var gXe=tS(),dXe=XP();function mXe(t,e,r,o){var a=!r;r||(r={});for(var n=-1,u=e.length;++n<u;){var A=e[n],p=o?o(r[A],t[A],A,r,t):void 0;p===void 0&&(p=t[A]),a?dXe(r,A,p):gXe(r,A,p)}return r}Bre.exports=mXe});var Dre=_((YRt,vre)=>{function yXe(t){var e=[];if(t!=null)for(var r in Object(t))e.push(r);return e}vre.exports=yXe});var Sre=_((WRt,Pre)=>{var EXe=il(),CXe=VP(),wXe=Dre(),IXe=Object.prototype,BXe=IXe.hasOwnProperty;function vXe(t){if(!EXe(t))return wXe(t);var e=CXe(t),r=[];for(var o in t)o=="constructor"&&(e||!BXe.call(t,o))||r.push(o);return r}Pre.exports=vXe});var qy=_((KRt,xre)=>{var DXe=zL(),PXe=Sre(),SXe=HI();function xXe(t){return SXe(t)?DXe(t,!0):PXe(t)}xre.exports=xXe});var kre=_((VRt,bre)=>{var bXe=gd(),kXe=qy();function QXe(t){return bXe(t,kXe(t))}bre.exports=QXe});var Nre=_((zRt,Lre)=>{var Qre=sN(),FXe=oN(),RXe=aN(),TXe=$P(),LXe=lN(),Fre=LI(),Rre=Hl(),NXe=yre(),OXe=OI(),MXe=OP(),UXe=il(),_Xe=cN(),HXe=KP(),Tre=uN(),jXe=kre();function qXe(t,e,r,o,a,n,u){var A=Tre(t,r),p=Tre(e,r),h=u.get(p);if(h){Qre(t,r,h);return}var C=n?n(A,p,r+"",t,e,u):void 0,I=C===void 0;if(I){var v=Rre(p),b=!v&&OXe(p),E=!v&&!b&&HXe(p);C=p,v||b||E?Rre(A)?C=A:NXe(A)?C=TXe(A):b?(I=!1,C=FXe(p,!0)):E?(I=!1,C=RXe(p,!0)):C=[]:_Xe(p)||Fre(p)?(C=A,Fre(A)?C=jXe(A):(!UXe(A)||MXe(A))&&(C=LXe(p))):I=!1}I&&(u.set(p,C),a(C,p,o,n,u),u.delete(p)),Qre(t,r,C)}Lre.exports=qXe});var Ure=_((JRt,Mre)=>{var GXe=_P(),YXe=sN(),WXe=nre(),KXe=Nre(),VXe=il(),zXe=qy(),JXe=uN();function Ore(t,e,r,o,a){t!==e&&WXe(e,function(n,u){if(a||(a=new GXe),VXe(n))KXe(t,e,u,r,Ore,o,a);else{var A=o?o(JXe(t,u),n,u+"",t,e,a):void 0;A===void 0&&(A=n),YXe(t,u,A)}},zXe)}Mre.exports=Ore});var AN=_((XRt,_re)=>{function XXe(t){return t}_re.exports=XXe});var jre=_((ZRt,Hre)=>{function ZXe(t,e,r){switch(r.length){case 0:return t.call(e);case 1:return t.call(e,r[0]);case 2:return t.call(e,r[0],r[1]);case 3:return t.call(e,r[0],r[1],r[2])}return t.apply(e,r)}Hre.exports=ZXe});var fN=_(($Rt,Gre)=>{var $Xe=jre(),qre=Math.max;function eZe(t,e,r){return e=qre(e===void 0?t.length-1:e,0),function(){for(var o=arguments,a=-1,n=qre(o.length-e,0),u=Array(n);++a<n;)u[a]=o[e+a];a=-1;for(var A=Array(e+1);++a<e;)A[a]=o[a];return A[e]=r(u),$Xe(t,this,A)}}Gre.exports=eZe});var Wre=_((eTt,Yre)=>{function tZe(t){return function(){return t}}Yre.exports=tZe});var zre=_((tTt,Vre)=>{var rZe=Wre(),Kre=iN(),nZe=AN(),iZe=Kre?function(t,e){return Kre(t,"toString",{configurable:!0,enumerable:!1,value:rZe(e),writable:!0})}:nZe;Vre.exports=iZe});var Xre=_((rTt,Jre)=>{var sZe=800,oZe=16,aZe=Date.now;function lZe(t){var e=0,r=0;return function(){var o=aZe(),a=oZe-(o-r);if(r=o,a>0){if(++e>=sZe)return arguments[0]}else e=0;return t.apply(void 0,arguments)}}Jre.exports=lZe});var pN=_((nTt,Zre)=>{var cZe=zre(),uZe=Xre(),AZe=uZe(cZe);Zre.exports=AZe});var ene=_((iTt,$re)=>{var fZe=AN(),pZe=fN(),hZe=pN();function gZe(t,e){return hZe(pZe(t,e,fZe),t+"")}$re.exports=gZe});var rne=_((sTt,tne)=>{var dZe=Ty(),mZe=HI(),yZe=MI(),EZe=il();function CZe(t,e,r){if(!EZe(r))return!1;var o=typeof e;return(o=="number"?mZe(r)&&yZe(e,r.length):o=="string"&&e in r)?dZe(r[e],t):!1}tne.exports=CZe});var ine=_((oTt,nne)=>{var wZe=ene(),IZe=rne();function BZe(t){return wZe(function(e,r){var o=-1,a=r.length,n=a>1?r[a-1]:void 0,u=a>2?r[2]:void 0;for(n=t.length>3&&typeof n=="function"?(a--,n):void 0,u&&IZe(r[0],r[1],u)&&(n=a<3?void 0:n,a=1),e=Object(e);++o<a;){var A=r[o];A&&t(e,A,o,n)}return e})}nne.exports=BZe});var one=_((aTt,sne)=>{var vZe=Ure(),DZe=ine(),PZe=DZe(function(t,e,r,o){vZe(t,e,r,o)});sne.exports=PZe});var _e={};Vt(_e,{AsyncActions:()=>dN,BufferStream:()=>gN,CachingStrategy:()=>yne,DefaultStream:()=>mN,allSettledSafe:()=>Uc,assertNever:()=>EN,bufferStream:()=>Ky,buildIgnorePattern:()=>RZe,convertMapsToIndexableObjects:()=>nS,dynamicRequire:()=>zp,escapeRegExp:()=>xZe,getArrayWithDefault:()=>Gy,getFactoryWithDefault:()=>ol,getMapWithDefault:()=>Yy,getSetWithDefault:()=>dd,groupBy:()=>IN,isIndexableObject:()=>hN,isPathLike:()=>TZe,isTaggedYarnVersion:()=>SZe,makeDeferred:()=>gne,mapAndFilter:()=>sl,mapAndFind:()=>YI,mergeIntoTarget:()=>Cne,overrideType:()=>bZe,parseBoolean:()=>WI,parseInt:()=>Vy,parseOptionalBoolean:()=>Ene,plural:()=>rS,prettifyAsyncErrors:()=>Wy,prettifySyncErrors:()=>CN,releaseAfterUseAsync:()=>QZe,replaceEnvVariables:()=>iS,sortMap:()=>ks,toMerged:()=>LZe,tryParseOptionalBoolean:()=>wN,validateEnum:()=>kZe});function SZe(t){return!!(fne.default.valid(t)&&t.match(/^[^-]+(-rc\.[0-9]+)?$/))}function rS(t,{one:e,more:r,zero:o=r}){return t===0?o:t===1?e:r}function xZe(t){return t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function bZe(t){}function EN(t){throw new Error(`Assertion failed: Unexpected object '${t}'`)}function kZe(t,e){let r=Object.values(t);if(!r.includes(e))throw new it(`Invalid value for enumeration: ${JSON.stringify(e)} (expected one of ${r.map(o=>JSON.stringify(o)).join(", ")})`);return e}function sl(t,e){let r=[];for(let o of t){let a=e(o);a!==pne&&r.push(a)}return r}function YI(t,e){for(let r of t){let o=e(r);if(o!==hne)return o}}function hN(t){return typeof t=="object"&&t!==null}async function Uc(t){let e=await Promise.allSettled(t),r=[];for(let o of e){if(o.status==="rejected")throw o.reason;r.push(o.value)}return r}function nS(t){if(t instanceof Map&&(t=Object.fromEntries(t)),hN(t))for(let e of Object.keys(t)){let r=t[e];hN(r)&&(t[e]=nS(r))}return t}function ol(t,e,r){let o=t.get(e);return typeof o>"u"&&t.set(e,o=r()),o}function Gy(t,e){let r=t.get(e);return typeof r>"u"&&t.set(e,r=[]),r}function dd(t,e){let r=t.get(e);return typeof r>"u"&&t.set(e,r=new Set),r}function Yy(t,e){let r=t.get(e);return typeof r>"u"&&t.set(e,r=new Map),r}async function QZe(t,e){if(e==null)return await t();try{return await t()}finally{await e()}}async function Wy(t,e){try{return await t()}catch(r){throw r.message=e(r.message),r}}function CN(t,e){try{return t()}catch(r){throw r.message=e(r.message),r}}async function Ky(t){return await new Promise((e,r)=>{let o=[];t.on("error",a=>{r(a)}),t.on("data",a=>{o.push(a)}),t.on("end",()=>{e(Buffer.concat(o))})})}function gne(){let t,e;return{promise:new Promise((o,a)=>{t=o,e=a}),resolve:t,reject:e}}function dne(t){return GI(fe.fromPortablePath(t))}function mne(path){let physicalPath=fe.fromPortablePath(path),currentCacheEntry=GI.cache[physicalPath];delete GI.cache[physicalPath];let result;try{result=dne(physicalPath);let freshCacheEntry=GI.cache[physicalPath],dynamicModule=eval("module"),freshCacheIndex=dynamicModule.children.indexOf(freshCacheEntry);freshCacheIndex!==-1&&dynamicModule.children.splice(freshCacheIndex,1)}finally{GI.cache[physicalPath]=currentCacheEntry}return result}function FZe(t){let e=ane.get(t),r=oe.statSync(t);if(e?.mtime===r.mtimeMs)return e.instance;let o=mne(t);return ane.set(t,{mtime:r.mtimeMs,instance:o}),o}function zp(t,{cachingStrategy:e=2}={}){switch(e){case 0:return mne(t);case 1:return FZe(t);case 2:return dne(t);default:throw new Error("Unsupported caching strategy")}}function ks(t,e){let r=Array.from(t);Array.isArray(e)||(e=[e]);let o=[];for(let n of e)o.push(r.map(u=>n(u)));let a=r.map((n,u)=>u);return a.sort((n,u)=>{for(let A of o){let p=A[n]<A[u]?-1:A[n]>A[u]?1:0;if(p!==0)return p}return 0}),a.map(n=>r[n])}function RZe(t){return t.length===0?null:t.map(e=>`(${une.default.makeRe(e,{windows:!1,dot:!0}).source})`).join("|")}function iS(t,{env:e}){let r=/\${(?<variableName>[\d\w_]+)(?<colon>:)?(?:-(?<fallback>[^}]*))?}/g;return t.replace(r,(...o)=>{let{variableName:a,colon:n,fallback:u}=o[o.length-1],A=Object.hasOwn(e,a),p=e[a];if(p||A&&!n)return p;if(u!=null)return u;throw new it(`Environment variable not found (${a})`)})}function WI(t){switch(t){case"true":case"1":case 1:case!0:return!0;case"false":case"0":case 0:case!1:return!1;default:throw new Error(`Couldn't parse "${t}" as a boolean`)}}function Ene(t){return typeof t>"u"?t:WI(t)}function wN(t){try{return Ene(t)}catch{return null}}function TZe(t){return!!(fe.isAbsolute(t)||t.match(/^(\.{1,2}|~)\//))}function Cne(t,...e){let r=u=>({value:u}),o=r(t),a=e.map(u=>r(u)),{value:n}=(0,cne.default)(o,...a,(u,A)=>{if(Array.isArray(u)&&Array.isArray(A)){for(let p of A)u.find(h=>(0,lne.default)(h,p))||u.push(p);return u}});return n}function LZe(...t){return Cne({},...t)}function IN(t,e){let r=Object.create(null);for(let o of t){let a=o[e];r[a]??=[],r[a].push(o)}return r}function Vy(t){return typeof t=="string"?Number.parseInt(t,10):t}var lne,cne,une,Ane,fne,yN,pne,hne,gN,dN,mN,GI,ane,yne,jl=yt(()=>{Pt();qt();lne=$e(zte()),cne=$e(one()),une=$e(Zo()),Ane=$e(nd()),fne=$e(Jn()),yN=Be("stream");pne=Symbol();sl.skip=pne;hne=Symbol();YI.skip=hne;gN=class extends yN.Transform{constructor(){super(...arguments);this.chunks=[]}_transform(r,o,a){if(o!=="buffer"||!Buffer.isBuffer(r))throw new Error("Assertion failed: BufferStream only accept buffers");this.chunks.push(r),a(null,null)}_flush(r){r(null,Buffer.concat(this.chunks))}};dN=class{constructor(e){this.deferred=new Map;this.promises=new Map;this.limit=(0,Ane.default)(e)}set(e,r){let o=this.deferred.get(e);typeof o>"u"&&this.deferred.set(e,o=gne());let a=this.limit(()=>r());return this.promises.set(e,a),a.then(()=>{this.promises.get(e)===a&&o.resolve()},n=>{this.promises.get(e)===a&&o.reject(n)}),o.promise}reduce(e,r){let o=this.promises.get(e)??Promise.resolve();this.set(e,()=>r(o))}async wait(){await Promise.all(this.promises.values())}},mN=class extends yN.Transform{constructor(r=Buffer.alloc(0)){super();this.active=!0;this.ifEmpty=r}_transform(r,o,a){if(o!=="buffer"||!Buffer.isBuffer(r))throw new Error("Assertion failed: DefaultStream only accept buffers");this.active=!1,a(null,r)}_flush(r){this.active&&this.ifEmpty.length>0?r(null,this.ifEmpty):r(null)}},GI=eval("require");ane=new Map;yne=(o=>(o[o.NoCache=0]="NoCache",o[o.FsTime=1]="FsTime",o[o.Node=2]="Node",o))(yne||{})});var zy,BN,vN,wne=yt(()=>{zy=(r=>(r.HARD="HARD",r.SOFT="SOFT",r))(zy||{}),BN=(o=>(o.Dependency="Dependency",o.PeerDependency="PeerDependency",o.PeerDependencyMeta="PeerDependencyMeta",o))(BN||{}),vN=(o=>(o.Inactive="inactive",o.Redundant="redundant",o.Active="active",o))(vN||{})});var de={};Vt(de,{LogLevel:()=>cS,Style:()=>oS,Type:()=>Et,addLogFilterSupport:()=>zI,applyColor:()=>zs,applyHyperlink:()=>Xy,applyStyle:()=>md,json:()=>yd,jsonOrPretty:()=>MZe,mark:()=>bN,pretty:()=>_t,prettyField:()=>Ju,prettyList:()=>xN,prettyTruncatedLocatorList:()=>lS,stripAnsi:()=>Jy.default,supportsColor:()=>aS,supportsHyperlinks:()=>SN,tuple:()=>_c});function Ine(t){let e=["KiB","MiB","GiB","TiB"],r=e.length;for(;r>1&&t<1024**r;)r-=1;let o=1024**r;return`${Math.floor(t*100/o)/100} ${e[r-1]}`}function _c(t,e){return[e,t]}function md(t,e,r){return t.get("enableColors")&&r&2&&(e=VI.default.bold(e)),e}function zs(t,e,r){if(!t.get("enableColors"))return e;let o=NZe.get(r);if(o===null)return e;let a=typeof o>"u"?r:PN.level>=3?o[0]:o[1],n=typeof a=="number"?DN.ansi256(a):a.startsWith("#")?DN.hex(a):DN[a];if(typeof n!="function")throw new Error(`Invalid format type ${a}`);return n(e)}function Xy(t,e,r){return t.get("enableHyperlinks")?OZe?`\x1B]8;;${r}\x1B\\${e}\x1B]8;;\x1B\\`:`\x1B]8;;${r}\x07${e}\x1B]8;;\x07`:e}function _t(t,e,r){if(e===null)return zs(t,"null",Et.NULL);if(Object.hasOwn(sS,r))return sS[r].pretty(t,e);if(typeof e!="string")throw new Error(`Assertion failed: Expected the value to be a string, got ${typeof e}`);return zs(t,e,r)}function xN(t,e,r,{separator:o=", "}={}){return[...e].map(a=>_t(t,a,r)).join(o)}function yd(t,e){if(t===null)return null;if(Object.hasOwn(sS,e))return sS[e].json(t);if(typeof t!="string")throw new Error(`Assertion failed: Expected the value to be a string, got ${typeof t}`);return t}function MZe(t,e,[r,o]){return t?yd(r,o):_t(e,r,o)}function bN(t){return{Check:zs(t,"\u2713","green"),Cross:zs(t,"\u2718","red"),Question:zs(t,"?","cyan")}}function Ju(t,{label:e,value:[r,o]}){return`${_t(t,e,Et.CODE)}: ${_t(t,r,o)}`}function lS(t,e,r){let o=[],a=[...e],n=r;for(;a.length>0;){let h=a[0],C=`${jr(t,h)}, `,I=kN(h).length+2;if(o.length>0&&n<I)break;o.push([C,I]),n-=I,a.shift()}if(a.length===0)return o.map(([h])=>h).join("").slice(0,-2);let u="X".repeat(a.length.toString().length),A=`and ${u} more.`,p=a.length;for(;o.length>1&&n<A.length;)n+=o[o.length-1][1],p+=1,o.pop();return[o.map(([h])=>h).join(""),A.replace(u,_t(t,p,Et.NUMBER))].join("")}function zI(t,{configuration:e}){let r=e.get("logFilters"),o=new Map,a=new Map,n=[];for(let I of r){let v=I.get("level");if(typeof v>"u")continue;let b=I.get("code");typeof b<"u"&&o.set(b,v);let E=I.get("text");typeof E<"u"&&a.set(E,v);let F=I.get("pattern");typeof F<"u"&&n.push([Bne.default.matcher(F,{contains:!0}),v])}n.reverse();let u=(I,v,b)=>{if(I===null||I===0)return b;let E=a.size>0||n.length>0?(0,Jy.default)(v):v;if(a.size>0){let F=a.get(E);if(typeof F<"u")return F??b}if(n.length>0){for(let[F,N]of n)if(F(E))return N??b}if(o.size>0){let F=o.get(Wu(I));if(typeof F<"u")return F??b}return b},A=t.reportInfo,p=t.reportWarning,h=t.reportError,C=function(I,v,b,E){switch(u(v,b,E)){case"info":A.call(I,v,b);break;case"warning":p.call(I,v??0,b);break;case"error":h.call(I,v??0,b);break}};t.reportInfo=function(...I){return C(this,...I,"info")},t.reportWarning=function(...I){return C(this,...I,"warning")},t.reportError=function(...I){return C(this,...I,"error")}}var VI,KI,Bne,Jy,vne,Et,oS,PN,aS,SN,DN,NZe,So,sS,OZe,cS,ql=yt(()=>{Pt();VI=$e(IL()),KI=$e($g());qt();Bne=$e(Zo()),Jy=$e(NP()),vne=Be("util");fP();xo();Et={NO_HINT:"NO_HINT",ID:"ID",NULL:"NULL",SCOPE:"SCOPE",NAME:"NAME",RANGE:"RANGE",REFERENCE:"REFERENCE",NUMBER:"NUMBER",PATH:"PATH",URL:"URL",ADDED:"ADDED",REMOVED:"REMOVED",CODE:"CODE",INSPECT:"INSPECT",DURATION:"DURATION",SIZE:"SIZE",SIZE_DIFF:"SIZE_DIFF",IDENT:"IDENT",DESCRIPTOR:"DESCRIPTOR",LOCATOR:"LOCATOR",RESOLUTION:"RESOLUTION",DEPENDENT:"DEPENDENT",PACKAGE_EXTENSION:"PACKAGE_EXTENSION",SETTING:"SETTING",MARKDOWN:"MARKDOWN",MARKDOWN_INLINE:"MARKDOWN_INLINE"},oS=(e=>(e[e.BOLD=2]="BOLD",e))(oS||{}),PN=KI.default.GITHUB_ACTIONS?{level:2}:VI.default.supportsColor?{level:VI.default.supportsColor.level}:{level:0},aS=PN.level!==0,SN=aS&&!KI.default.GITHUB_ACTIONS&&!KI.default.CIRCLE&&!KI.default.GITLAB,DN=new VI.default.Instance(PN),NZe=new Map([[Et.NO_HINT,null],[Et.NULL,["#a853b5",129]],[Et.SCOPE,["#d75f00",166]],[Et.NAME,["#d7875f",173]],[Et.RANGE,["#00afaf",37]],[Et.REFERENCE,["#87afff",111]],[Et.NUMBER,["#ffd700",220]],[Et.PATH,["#d75fd7",170]],[Et.URL,["#d75fd7",170]],[Et.ADDED,["#5faf00",70]],[Et.REMOVED,["#ff3131",160]],[Et.CODE,["#87afff",111]],[Et.SIZE,["#ffd700",220]]]),So=t=>t;sS={[Et.ID]:So({pretty:(t,e)=>typeof e=="number"?zs(t,`${e}`,Et.NUMBER):zs(t,e,Et.CODE),json:t=>t}),[Et.INSPECT]:So({pretty:(t,e)=>(0,vne.inspect)(e,{depth:1/0,colors:t.get("enableColors"),compact:!0,breakLength:1/0}),json:t=>t}),[Et.NUMBER]:So({pretty:(t,e)=>zs(t,`${e}`,Et.NUMBER),json:t=>t}),[Et.IDENT]:So({pretty:(t,e)=>cs(t,e),json:t=>fn(t)}),[Et.LOCATOR]:So({pretty:(t,e)=>jr(t,e),json:t=>xa(t)}),[Et.DESCRIPTOR]:So({pretty:(t,e)=>qn(t,e),json:t=>Sa(t)}),[Et.RESOLUTION]:So({pretty:(t,{descriptor:e,locator:r})=>JI(t,e,r),json:({descriptor:t,locator:e})=>({descriptor:Sa(t),locator:e!==null?xa(e):null})}),[Et.DEPENDENT]:So({pretty:(t,{locator:e,descriptor:r})=>QN(t,e,r),json:({locator:t,descriptor:e})=>({locator:xa(t),descriptor:Sa(e)})}),[Et.PACKAGE_EXTENSION]:So({pretty:(t,e)=>{switch(e.type){case"Dependency":return`${cs(t,e.parentDescriptor)} \u27A4 ${zs(t,"dependencies",Et.CODE)} \u27A4 ${cs(t,e.descriptor)}`;case"PeerDependency":return`${cs(t,e.parentDescriptor)} \u27A4 ${zs(t,"peerDependencies",Et.CODE)} \u27A4 ${cs(t,e.descriptor)}`;case"PeerDependencyMeta":return`${cs(t,e.parentDescriptor)} \u27A4 ${zs(t,"peerDependenciesMeta",Et.CODE)} \u27A4 ${cs(t,Js(e.selector))} \u27A4 ${zs(t,e.key,Et.CODE)}`;default:throw new Error(`Assertion failed: Unsupported package extension type: ${e.type}`)}},json:t=>{switch(t.type){case"Dependency":return`${fn(t.parentDescriptor)} > ${fn(t.descriptor)}`;case"PeerDependency":return`${fn(t.parentDescriptor)} >> ${fn(t.descriptor)}`;case"PeerDependencyMeta":return`${fn(t.parentDescriptor)} >> ${t.selector} / ${t.key}`;default:throw new Error(`Assertion failed: Unsupported package extension type: ${t.type}`)}}}),[Et.SETTING]:So({pretty:(t,e)=>(t.get(e),Xy(t,zs(t,e,Et.CODE),`https://yarnpkg.com/configuration/yarnrc#${e}`)),json:t=>t}),[Et.DURATION]:So({pretty:(t,e)=>{if(e>1e3*60){let r=Math.floor(e/1e3/60),o=Math.ceil((e-r*60*1e3)/1e3);return o===0?`${r}m`:`${r}m ${o}s`}else{let r=Math.floor(e/1e3),o=e-r*1e3;return o===0?`${r}s`:`${r}s ${o}ms`}},json:t=>t}),[Et.SIZE]:So({pretty:(t,e)=>zs(t,Ine(e),Et.NUMBER),json:t=>t}),[Et.SIZE_DIFF]:So({pretty:(t,e)=>{let r=e>=0?"+":"-",o=r==="+"?Et.REMOVED:Et.ADDED;return zs(t,`${r} ${Ine(Math.max(Math.abs(e),1))}`,o)},json:t=>t}),[Et.PATH]:So({pretty:(t,e)=>zs(t,fe.fromPortablePath(e),Et.PATH),json:t=>fe.fromPortablePath(t)}),[Et.MARKDOWN]:So({pretty:(t,{text:e,format:r,paragraphs:o})=>Do(e,{format:r,paragraphs:o}),json:({text:t})=>t}),[Et.MARKDOWN_INLINE]:So({pretty:(t,e)=>(e=e.replace(/(`+)((?:.|[\n])*?)\1/g,(r,o,a)=>_t(t,o+a+o,Et.CODE)),e=e.replace(/(\*\*)((?:.|[\n])*?)\1/g,(r,o,a)=>md(t,a,2)),e),json:t=>t})};OZe=!!process.env.KONSOLE_VERSION;cS=(a=>(a.Error="error",a.Warning="warning",a.Info="info",a.Discard="discard",a))(cS||{})});var Dne=_(Zy=>{"use strict";Object.defineProperty(Zy,"__esModule",{value:!0});Zy.splitWhen=Zy.flatten=void 0;function UZe(t){return t.reduce((e,r)=>[].concat(e,r),[])}Zy.flatten=UZe;function _Ze(t,e){let r=[[]],o=0;for(let a of t)e(a)?(o++,r[o]=[]):r[o].push(a);return r}Zy.splitWhen=_Ze});var Pne=_(uS=>{"use strict";Object.defineProperty(uS,"__esModule",{value:!0});uS.isEnoentCodeError=void 0;function HZe(t){return t.code==="ENOENT"}uS.isEnoentCodeError=HZe});var Sne=_(AS=>{"use strict";Object.defineProperty(AS,"__esModule",{value:!0});AS.createDirentFromStats=void 0;var FN=class{constructor(e,r){this.name=e,this.isBlockDevice=r.isBlockDevice.bind(r),this.isCharacterDevice=r.isCharacterDevice.bind(r),this.isDirectory=r.isDirectory.bind(r),this.isFIFO=r.isFIFO.bind(r),this.isFile=r.isFile.bind(r),this.isSocket=r.isSocket.bind(r),this.isSymbolicLink=r.isSymbolicLink.bind(r)}};function jZe(t,e){return new FN(t,e)}AS.createDirentFromStats=jZe});var xne=_(Xu=>{"use strict";Object.defineProperty(Xu,"__esModule",{value:!0});Xu.removeLeadingDotSegment=Xu.escape=Xu.makeAbsolute=Xu.unixify=void 0;var qZe=Be("path"),GZe=2,YZe=/(\\?)([()*?[\]{|}]|^!|[!+@](?=\())/g;function WZe(t){return t.replace(/\\/g,"/")}Xu.unixify=WZe;function KZe(t,e){return qZe.resolve(t,e)}Xu.makeAbsolute=KZe;function VZe(t){return t.replace(YZe,"\\$2")}Xu.escape=VZe;function zZe(t){if(t.charAt(0)==="."){let e=t.charAt(1);if(e==="/"||e==="\\")return t.slice(GZe)}return t}Xu.removeLeadingDotSegment=zZe});var kne=_((ITt,bne)=>{bne.exports=function(e){if(typeof e!="string"||e==="")return!1;for(var r;r=/(\\).|([@?!+*]\(.*\))/g.exec(e);){if(r[2])return!0;e=e.slice(r.index+r[0].length)}return!1}});var Rne=_((BTt,Fne)=>{var JZe=kne(),Qne={"{":"}","(":")","[":"]"},XZe=function(t){if(t[0]==="!")return!0;for(var e=0,r=-2,o=-2,a=-2,n=-2,u=-2;e<t.length;){if(t[e]==="*"||t[e+1]==="?"&&/[\].+)]/.test(t[e])||o!==-1&&t[e]==="["&&t[e+1]!=="]"&&(o<e&&(o=t.indexOf("]",e)),o>e&&(u===-1||u>o||(u=t.indexOf("\\",e),u===-1||u>o)))||a!==-1&&t[e]==="{"&&t[e+1]!=="}"&&(a=t.indexOf("}",e),a>e&&(u=t.indexOf("\\",e),u===-1||u>a))||n!==-1&&t[e]==="("&&t[e+1]==="?"&&/[:!=]/.test(t[e+2])&&t[e+3]!==")"&&(n=t.indexOf(")",e),n>e&&(u=t.indexOf("\\",e),u===-1||u>n))||r!==-1&&t[e]==="("&&t[e+1]!=="|"&&(r<e&&(r=t.indexOf("|",e)),r!==-1&&t[r+1]!==")"&&(n=t.indexOf(")",r),n>r&&(u=t.indexOf("\\",r),u===-1||u>n))))return!0;if(t[e]==="\\"){var A=t[e+1];e+=2;var p=Qne[A];if(p){var h=t.indexOf(p,e);h!==-1&&(e=h+1)}if(t[e]==="!")return!0}else e++}return!1},ZZe=function(t){if(t[0]==="!")return!0;for(var e=0;e<t.length;){if(/[*?{}()[\]]/.test(t[e]))return!0;if(t[e]==="\\"){var r=t[e+1];e+=2;var o=Qne[r];if(o){var a=t.indexOf(o,e);a!==-1&&(e=a+1)}if(t[e]==="!")return!0}else e++}return!1};Fne.exports=function(e,r){if(typeof e!="string"||e==="")return!1;if(JZe(e))return!0;var o=XZe;return r&&r.strict===!1&&(o=ZZe),o(e)}});var Lne=_((vTt,Tne)=>{"use strict";var $Ze=Rne(),e$e=Be("path").posix.dirname,t$e=Be("os").platform()==="win32",RN="/",r$e=/\\/g,n$e=/[\{\[].*[\}\]]$/,i$e=/(^|[^\\])([\{\[]|\([^\)]+$)/,s$e=/\\([\!\*\?\|\[\]\(\)\{\}])/g;Tne.exports=function(e,r){var o=Object.assign({flipBackslashes:!0},r);o.flipBackslashes&&t$e&&e.indexOf(RN)<0&&(e=e.replace(r$e,RN)),n$e.test(e)&&(e+=RN),e+="a";do e=e$e(e);while($Ze(e)||i$e.test(e));return e.replace(s$e,"$1")}});var qne=_(qr=>{"use strict";Object.defineProperty(qr,"__esModule",{value:!0});qr.matchAny=qr.convertPatternsToRe=qr.makeRe=qr.getPatternParts=qr.expandBraceExpansion=qr.expandPatternsWithBraceExpansion=qr.isAffectDepthOfReadingPattern=qr.endsWithSlashGlobStar=qr.hasGlobStar=qr.getBaseDirectory=qr.isPatternRelatedToParentDirectory=qr.getPatternsOutsideCurrentDirectory=qr.getPatternsInsideCurrentDirectory=qr.getPositivePatterns=qr.getNegativePatterns=qr.isPositivePattern=qr.isNegativePattern=qr.convertToNegativePattern=qr.convertToPositivePattern=qr.isDynamicPattern=qr.isStaticPattern=void 0;var o$e=Be("path"),a$e=Lne(),TN=Zo(),Nne="**",l$e="\\",c$e=/[*?]|^!/,u$e=/\[[^[]*]/,A$e=/(?:^|[^!*+?@])\([^(]*\|[^|]*\)/,f$e=/[!*+?@]\([^(]*\)/,p$e=/,|\.\./;function One(t,e={}){return!Mne(t,e)}qr.isStaticPattern=One;function Mne(t,e={}){return t===""?!1:!!(e.caseSensitiveMatch===!1||t.includes(l$e)||c$e.test(t)||u$e.test(t)||A$e.test(t)||e.extglob!==!1&&f$e.test(t)||e.braceExpansion!==!1&&h$e(t))}qr.isDynamicPattern=Mne;function h$e(t){let e=t.indexOf("{");if(e===-1)return!1;let r=t.indexOf("}",e+1);if(r===-1)return!1;let o=t.slice(e,r);return p$e.test(o)}function g$e(t){return fS(t)?t.slice(1):t}qr.convertToPositivePattern=g$e;function d$e(t){return"!"+t}qr.convertToNegativePattern=d$e;function fS(t){return t.startsWith("!")&&t[1]!=="("}qr.isNegativePattern=fS;function Une(t){return!fS(t)}qr.isPositivePattern=Une;function m$e(t){return t.filter(fS)}qr.getNegativePatterns=m$e;function y$e(t){return t.filter(Une)}qr.getPositivePatterns=y$e;function E$e(t){return t.filter(e=>!LN(e))}qr.getPatternsInsideCurrentDirectory=E$e;function C$e(t){return t.filter(LN)}qr.getPatternsOutsideCurrentDirectory=C$e;function LN(t){return t.startsWith("..")||t.startsWith("./..")}qr.isPatternRelatedToParentDirectory=LN;function w$e(t){return a$e(t,{flipBackslashes:!1})}qr.getBaseDirectory=w$e;function I$e(t){return t.includes(Nne)}qr.hasGlobStar=I$e;function _ne(t){return t.endsWith("/"+Nne)}qr.endsWithSlashGlobStar=_ne;function B$e(t){let e=o$e.basename(t);return _ne(t)||One(e)}qr.isAffectDepthOfReadingPattern=B$e;function v$e(t){return t.reduce((e,r)=>e.concat(Hne(r)),[])}qr.expandPatternsWithBraceExpansion=v$e;function Hne(t){return TN.braces(t,{expand:!0,nodupes:!0})}qr.expandBraceExpansion=Hne;function D$e(t,e){let{parts:r}=TN.scan(t,Object.assign(Object.assign({},e),{parts:!0}));return r.length===0&&(r=[t]),r[0].startsWith("/")&&(r[0]=r[0].slice(1),r.unshift("")),r}qr.getPatternParts=D$e;function jne(t,e){return TN.makeRe(t,e)}qr.makeRe=jne;function P$e(t,e){return t.map(r=>jne(r,e))}qr.convertPatternsToRe=P$e;function S$e(t,e){return e.some(r=>r.test(t))}qr.matchAny=S$e});var Kne=_((PTt,Wne)=>{"use strict";var x$e=Be("stream"),Gne=x$e.PassThrough,b$e=Array.prototype.slice;Wne.exports=k$e;function k$e(){let t=[],e=b$e.call(arguments),r=!1,o=e[e.length-1];o&&!Array.isArray(o)&&o.pipe==null?e.pop():o={};let a=o.end!==!1,n=o.pipeError===!0;o.objectMode==null&&(o.objectMode=!0),o.highWaterMark==null&&(o.highWaterMark=64*1024);let u=Gne(o);function A(){for(let C=0,I=arguments.length;C<I;C++)t.push(Yne(arguments[C],o));return p(),this}function p(){if(r)return;r=!0;let C=t.shift();if(!C){process.nextTick(h);return}Array.isArray(C)||(C=[C]);let I=C.length+1;function v(){--I>0||(r=!1,p())}function b(E){function F(){E.removeListener("merge2UnpipeEnd",F),E.removeListener("end",F),n&&E.removeListener("error",N),v()}function N(U){u.emit("error",U)}if(E._readableState.endEmitted)return v();E.on("merge2UnpipeEnd",F),E.on("end",F),n&&E.on("error",N),E.pipe(u,{end:!1}),E.resume()}for(let E=0;E<C.length;E++)b(C[E]);v()}function h(){r=!1,u.emit("queueDrain"),a&&u.end()}return u.setMaxListeners(0),u.add=A,u.on("unpipe",function(C){C.emit("merge2UnpipeEnd")}),e.length&&A.apply(null,e),u}function Yne(t,e){if(Array.isArray(t))for(let r=0,o=t.length;r<o;r++)t[r]=Yne(t[r],e);else{if(!t._readableState&&t.pipe&&(t=t.pipe(Gne(e))),!t._readableState||!t.pause||!t.pipe)throw new Error("Only readable stream can be merged.");t.pause()}return t}});var zne=_(pS=>{"use strict";Object.defineProperty(pS,"__esModule",{value:!0});pS.merge=void 0;var Q$e=Kne();function F$e(t){let e=Q$e(t);return t.forEach(r=>{r.once("error",o=>e.emit("error",o))}),e.once("close",()=>Vne(t)),e.once("end",()=>Vne(t)),e}pS.merge=F$e;function Vne(t){t.forEach(e=>e.emit("close"))}});var Jne=_($y=>{"use strict";Object.defineProperty($y,"__esModule",{value:!0});$y.isEmpty=$y.isString=void 0;function R$e(t){return typeof t=="string"}$y.isString=R$e;function T$e(t){return t===""}$y.isEmpty=T$e});var vf=_(bo=>{"use strict";Object.defineProperty(bo,"__esModule",{value:!0});bo.string=bo.stream=bo.pattern=bo.path=bo.fs=bo.errno=bo.array=void 0;var L$e=Dne();bo.array=L$e;var N$e=Pne();bo.errno=N$e;var O$e=Sne();bo.fs=O$e;var M$e=xne();bo.path=M$e;var U$e=qne();bo.pattern=U$e;var _$e=zne();bo.stream=_$e;var H$e=Jne();bo.string=H$e});var $ne=_(ko=>{"use strict";Object.defineProperty(ko,"__esModule",{value:!0});ko.convertPatternGroupToTask=ko.convertPatternGroupsToTasks=ko.groupPatternsByBaseDirectory=ko.getNegativePatternsAsPositive=ko.getPositivePatterns=ko.convertPatternsToTasks=ko.generate=void 0;var Df=vf();function j$e(t,e){let r=Xne(t),o=Zne(t,e.ignore),a=r.filter(p=>Df.pattern.isStaticPattern(p,e)),n=r.filter(p=>Df.pattern.isDynamicPattern(p,e)),u=NN(a,o,!1),A=NN(n,o,!0);return u.concat(A)}ko.generate=j$e;function NN(t,e,r){let o=[],a=Df.pattern.getPatternsOutsideCurrentDirectory(t),n=Df.pattern.getPatternsInsideCurrentDirectory(t),u=ON(a),A=ON(n);return o.push(...MN(u,e,r)),"."in A?o.push(UN(".",n,e,r)):o.push(...MN(A,e,r)),o}ko.convertPatternsToTasks=NN;function Xne(t){return Df.pattern.getPositivePatterns(t)}ko.getPositivePatterns=Xne;function Zne(t,e){return Df.pattern.getNegativePatterns(t).concat(e).map(Df.pattern.convertToPositivePattern)}ko.getNegativePatternsAsPositive=Zne;function ON(t){let e={};return t.reduce((r,o)=>{let a=Df.pattern.getBaseDirectory(o);return a in r?r[a].push(o):r[a]=[o],r},e)}ko.groupPatternsByBaseDirectory=ON;function MN(t,e,r){return Object.keys(t).map(o=>UN(o,t[o],e,r))}ko.convertPatternGroupsToTasks=MN;function UN(t,e,r,o){return{dynamic:o,positive:e,negative:r,base:t,patterns:[].concat(e,r.map(Df.pattern.convertToNegativePattern))}}ko.convertPatternGroupToTask=UN});var tie=_(eE=>{"use strict";Object.defineProperty(eE,"__esModule",{value:!0});eE.removeDuplicateSlashes=eE.transform=void 0;var q$e=/(?!^)\/{2,}/g;function G$e(t){return t.map(e=>eie(e))}eE.transform=G$e;function eie(t){return t.replace(q$e,"/")}eE.removeDuplicateSlashes=eie});var nie=_(hS=>{"use strict";Object.defineProperty(hS,"__esModule",{value:!0});hS.read=void 0;function Y$e(t,e,r){e.fs.lstat(t,(o,a)=>{if(o!==null){rie(r,o);return}if(!a.isSymbolicLink()||!e.followSymbolicLink){_N(r,a);return}e.fs.stat(t,(n,u)=>{if(n!==null){if(e.throwErrorOnBrokenSymbolicLink){rie(r,n);return}_N(r,a);return}e.markSymbolicLink&&(u.isSymbolicLink=()=>!0),_N(r,u)})})}hS.read=Y$e;function rie(t,e){t(e)}function _N(t,e){t(null,e)}});var iie=_(gS=>{"use strict";Object.defineProperty(gS,"__esModule",{value:!0});gS.read=void 0;function W$e(t,e){let r=e.fs.lstatSync(t);if(!r.isSymbolicLink()||!e.followSymbolicLink)return r;try{let o=e.fs.statSync(t);return e.markSymbolicLink&&(o.isSymbolicLink=()=>!0),o}catch(o){if(!e.throwErrorOnBrokenSymbolicLink)return r;throw o}}gS.read=W$e});var sie=_(Jp=>{"use strict";Object.defineProperty(Jp,"__esModule",{value:!0});Jp.createFileSystemAdapter=Jp.FILE_SYSTEM_ADAPTER=void 0;var dS=Be("fs");Jp.FILE_SYSTEM_ADAPTER={lstat:dS.lstat,stat:dS.stat,lstatSync:dS.lstatSync,statSync:dS.statSync};function K$e(t){return t===void 0?Jp.FILE_SYSTEM_ADAPTER:Object.assign(Object.assign({},Jp.FILE_SYSTEM_ADAPTER),t)}Jp.createFileSystemAdapter=K$e});var oie=_(jN=>{"use strict";Object.defineProperty(jN,"__esModule",{value:!0});var V$e=sie(),HN=class{constructor(e={}){this._options=e,this.followSymbolicLink=this._getValue(this._options.followSymbolicLink,!0),this.fs=V$e.createFileSystemAdapter(this._options.fs),this.markSymbolicLink=this._getValue(this._options.markSymbolicLink,!1),this.throwErrorOnBrokenSymbolicLink=this._getValue(this._options.throwErrorOnBrokenSymbolicLink,!0)}_getValue(e,r){return e??r}};jN.default=HN});var Ed=_(Xp=>{"use strict";Object.defineProperty(Xp,"__esModule",{value:!0});Xp.statSync=Xp.stat=Xp.Settings=void 0;var aie=nie(),z$e=iie(),qN=oie();Xp.Settings=qN.default;function J$e(t,e,r){if(typeof e=="function"){aie.read(t,GN(),e);return}aie.read(t,GN(e),r)}Xp.stat=J$e;function X$e(t,e){let r=GN(e);return z$e.read(t,r)}Xp.statSync=X$e;function GN(t={}){return t instanceof qN.default?t:new qN.default(t)}});var cie=_((OTt,lie)=>{lie.exports=Z$e;function Z$e(t,e){var r,o,a,n=!0;Array.isArray(t)?(r=[],o=t.length):(a=Object.keys(t),r={},o=a.length);function u(p){function h(){e&&e(p,r),e=null}n?process.nextTick(h):h()}function A(p,h,C){r[p]=C,(--o===0||h)&&u(h)}o?a?a.forEach(function(p){t[p](function(h,C){A(p,h,C)})}):t.forEach(function(p,h){p(function(C,I){A(h,C,I)})}):u(null),n=!1}});var YN=_(yS=>{"use strict";Object.defineProperty(yS,"__esModule",{value:!0});yS.IS_SUPPORT_READDIR_WITH_FILE_TYPES=void 0;var mS=process.versions.node.split(".");if(mS[0]===void 0||mS[1]===void 0)throw new Error(`Unexpected behavior. The 'process.versions.node' variable has invalid value: ${process.versions.node}`);var uie=Number.parseInt(mS[0],10),$$e=Number.parseInt(mS[1],10),Aie=10,eet=10,tet=uie>Aie,ret=uie===Aie&&$$e>=eet;yS.IS_SUPPORT_READDIR_WITH_FILE_TYPES=tet||ret});var fie=_(ES=>{"use strict";Object.defineProperty(ES,"__esModule",{value:!0});ES.createDirentFromStats=void 0;var WN=class{constructor(e,r){this.name=e,this.isBlockDevice=r.isBlockDevice.bind(r),this.isCharacterDevice=r.isCharacterDevice.bind(r),this.isDirectory=r.isDirectory.bind(r),this.isFIFO=r.isFIFO.bind(r),this.isFile=r.isFile.bind(r),this.isSocket=r.isSocket.bind(r),this.isSymbolicLink=r.isSymbolicLink.bind(r)}};function net(t,e){return new WN(t,e)}ES.createDirentFromStats=net});var KN=_(CS=>{"use strict";Object.defineProperty(CS,"__esModule",{value:!0});CS.fs=void 0;var iet=fie();CS.fs=iet});var VN=_(wS=>{"use strict";Object.defineProperty(wS,"__esModule",{value:!0});wS.joinPathSegments=void 0;function set(t,e,r){return t.endsWith(r)?t+e:t+r+e}wS.joinPathSegments=set});var yie=_(Zp=>{"use strict";Object.defineProperty(Zp,"__esModule",{value:!0});Zp.readdir=Zp.readdirWithFileTypes=Zp.read=void 0;var oet=Ed(),pie=cie(),aet=YN(),hie=KN(),gie=VN();function cet(t,e,r){if(!e.stats&&aet.IS_SUPPORT_READDIR_WITH_FILE_TYPES){die(t,e,r);return}mie(t,e,r)}Zp.read=cet;function die(t,e,r){e.fs.readdir(t,{withFileTypes:!0},(o,a)=>{if(o!==null){IS(r,o);return}let n=a.map(A=>({dirent:A,name:A.name,path:gie.joinPathSegments(t,A.name,e.pathSegmentSeparator)}));if(!e.followSymbolicLinks){zN(r,n);return}let u=n.map(A=>uet(A,e));pie(u,(A,p)=>{if(A!==null){IS(r,A);return}zN(r,p)})})}Zp.readdirWithFileTypes=die;function uet(t,e){return r=>{if(!t.dirent.isSymbolicLink()){r(null,t);return}e.fs.stat(t.path,(o,a)=>{if(o!==null){if(e.throwErrorOnBrokenSymbolicLink){r(o);return}r(null,t);return}t.dirent=hie.fs.createDirentFromStats(t.name,a),r(null,t)})}}function mie(t,e,r){e.fs.readdir(t,(o,a)=>{if(o!==null){IS(r,o);return}let n=a.map(u=>{let A=gie.joinPathSegments(t,u,e.pathSegmentSeparator);return p=>{oet.stat(A,e.fsStatSettings,(h,C)=>{if(h!==null){p(h);return}let I={name:u,path:A,dirent:hie.fs.createDirentFromStats(u,C)};e.stats&&(I.stats=C),p(null,I)})}});pie(n,(u,A)=>{if(u!==null){IS(r,u);return}zN(r,A)})})}Zp.readdir=mie;function IS(t,e){t(e)}function zN(t,e){t(null,e)}});var Bie=_($p=>{"use strict";Object.defineProperty($p,"__esModule",{value:!0});$p.readdir=$p.readdirWithFileTypes=$p.read=void 0;var Aet=Ed(),fet=YN(),Eie=KN(),Cie=VN();function pet(t,e){return!e.stats&&fet.IS_SUPPORT_READDIR_WITH_FILE_TYPES?wie(t,e):Iie(t,e)}$p.read=pet;function wie(t,e){return e.fs.readdirSync(t,{withFileTypes:!0}).map(o=>{let a={dirent:o,name:o.name,path:Cie.joinPathSegments(t,o.name,e.pathSegmentSeparator)};if(a.dirent.isSymbolicLink()&&e.followSymbolicLinks)try{let n=e.fs.statSync(a.path);a.dirent=Eie.fs.createDirentFromStats(a.name,n)}catch(n){if(e.throwErrorOnBrokenSymbolicLink)throw n}return a})}$p.readdirWithFileTypes=wie;function Iie(t,e){return e.fs.readdirSync(t).map(o=>{let a=Cie.joinPathSegments(t,o,e.pathSegmentSeparator),n=Aet.statSync(a,e.fsStatSettings),u={name:o,path:a,dirent:Eie.fs.createDirentFromStats(o,n)};return e.stats&&(u.stats=n),u})}$p.readdir=Iie});var vie=_(eh=>{"use strict";Object.defineProperty(eh,"__esModule",{value:!0});eh.createFileSystemAdapter=eh.FILE_SYSTEM_ADAPTER=void 0;var tE=Be("fs");eh.FILE_SYSTEM_ADAPTER={lstat:tE.lstat,stat:tE.stat,lstatSync:tE.lstatSync,statSync:tE.statSync,readdir:tE.readdir,readdirSync:tE.readdirSync};function het(t){return t===void 0?eh.FILE_SYSTEM_ADAPTER:Object.assign(Object.assign({},eh.FILE_SYSTEM_ADAPTER),t)}eh.createFileSystemAdapter=het});var Die=_(XN=>{"use strict";Object.defineProperty(XN,"__esModule",{value:!0});var get=Be("path"),det=Ed(),met=vie(),JN=class{constructor(e={}){this._options=e,this.followSymbolicLinks=this._getValue(this._options.followSymbolicLinks,!1),this.fs=met.createFileSystemAdapter(this._options.fs),this.pathSegmentSeparator=this._getValue(this._options.pathSegmentSeparator,get.sep),this.stats=this._getValue(this._options.stats,!1),this.throwErrorOnBrokenSymbolicLink=this._getValue(this._options.throwErrorOnBrokenSymbolicLink,!0),this.fsStatSettings=new det.Settings({followSymbolicLink:this.followSymbolicLinks,fs:this.fs,throwErrorOnBrokenSymbolicLink:this.throwErrorOnBrokenSymbolicLink})}_getValue(e,r){return e??r}};XN.default=JN});var BS=_(th=>{"use strict";Object.defineProperty(th,"__esModule",{value:!0});th.Settings=th.scandirSync=th.scandir=void 0;var Pie=yie(),yet=Bie(),ZN=Die();th.Settings=ZN.default;function Eet(t,e,r){if(typeof e=="function"){Pie.read(t,$N(),e);return}Pie.read(t,$N(e),r)}th.scandir=Eet;function Cet(t,e){let r=$N(e);return yet.read(t,r)}th.scandirSync=Cet;function $N(t={}){return t instanceof ZN.default?t:new ZN.default(t)}});var xie=_((KTt,Sie)=>{"use strict";function wet(t){var e=new t,r=e;function o(){var n=e;return n.next?e=n.next:(e=new t,r=e),n.next=null,n}function a(n){r.next=n,r=n}return{get:o,release:a}}Sie.exports=wet});var kie=_((VTt,eO)=>{"use strict";var Iet=xie();function bie(t,e,r){if(typeof t=="function"&&(r=e,e=t,t=null),r<1)throw new Error("fastqueue concurrency must be greater than 1");var o=Iet(Bet),a=null,n=null,u=0,A=null,p={push:F,drain:Gl,saturated:Gl,pause:C,paused:!1,concurrency:r,running:h,resume:b,idle:E,length:I,getQueue:v,unshift:N,empty:Gl,kill:z,killAndDrain:te,error:le};return p;function h(){return u}function C(){p.paused=!0}function I(){for(var pe=a,ue=0;pe;)pe=pe.next,ue++;return ue}function v(){for(var pe=a,ue=[];pe;)ue.push(pe.value),pe=pe.next;return ue}function b(){if(!!p.paused){p.paused=!1;for(var pe=0;pe<p.concurrency;pe++)u++,U()}}function E(){return u===0&&p.length()===0}function F(pe,ue){var ye=o.get();ye.context=t,ye.release=U,ye.value=pe,ye.callback=ue||Gl,ye.errorHandler=A,u===p.concurrency||p.paused?n?(n.next=ye,n=ye):(a=ye,n=ye,p.saturated()):(u++,e.call(t,ye.value,ye.worked))}function N(pe,ue){var ye=o.get();ye.context=t,ye.release=U,ye.value=pe,ye.callback=ue||Gl,u===p.concurrency||p.paused?a?(ye.next=a,a=ye):(a=ye,n=ye,p.saturated()):(u++,e.call(t,ye.value,ye.worked))}function U(pe){pe&&o.release(pe);var ue=a;ue?p.paused?u--:(n===a&&(n=null),a=ue.next,ue.next=null,e.call(t,ue.value,ue.worked),n===null&&p.empty()):--u===0&&p.drain()}function z(){a=null,n=null,p.drain=Gl}function te(){a=null,n=null,p.drain(),p.drain=Gl}function le(pe){A=pe}}function Gl(){}function Bet(){this.value=null,this.callback=Gl,this.next=null,this.release=Gl,this.context=null,this.errorHandler=null;var t=this;this.worked=function(r,o){var a=t.callback,n=t.errorHandler,u=t.value;t.value=null,t.callback=Gl,t.errorHandler&&n(r,u),a.call(t.context,r,o),t.release(t)}}function vet(t,e,r){typeof t=="function"&&(r=e,e=t,t=null);function o(C,I){e.call(this,C).then(function(v){I(null,v)},I)}var a=bie(t,o,r),n=a.push,u=a.unshift;return a.push=A,a.unshift=p,a.drained=h,a;function A(C){var I=new Promise(function(v,b){n(C,function(E,F){if(E){b(E);return}v(F)})});return I.catch(Gl),I}function p(C){var I=new Promise(function(v,b){u(C,function(E,F){if(E){b(E);return}v(F)})});return I.catch(Gl),I}function h(){var C=a.drain,I=new Promise(function(v){a.drain=function(){C(),v()}});return I}}eO.exports=bie;eO.exports.promise=vet});var vS=_(Zu=>{"use strict";Object.defineProperty(Zu,"__esModule",{value:!0});Zu.joinPathSegments=Zu.replacePathSegmentSeparator=Zu.isAppliedFilter=Zu.isFatalError=void 0;function Det(t,e){return t.errorFilter===null?!0:!t.errorFilter(e)}Zu.isFatalError=Det;function Pet(t,e){return t===null||t(e)}Zu.isAppliedFilter=Pet;function xet(t,e){return t.split(/[/\\]/).join(e)}Zu.replacePathSegmentSeparator=xet;function bet(t,e,r){return t===""?e:t.endsWith(r)?t+e:t+r+e}Zu.joinPathSegments=bet});var nO=_(rO=>{"use strict";Object.defineProperty(rO,"__esModule",{value:!0});var ket=vS(),tO=class{constructor(e,r){this._root=e,this._settings=r,this._root=ket.replacePathSegmentSeparator(e,r.pathSegmentSeparator)}};rO.default=tO});var oO=_(sO=>{"use strict";Object.defineProperty(sO,"__esModule",{value:!0});var Qet=Be("events"),Fet=BS(),Ret=kie(),DS=vS(),Tet=nO(),iO=class extends Tet.default{constructor(e,r){super(e,r),this._settings=r,this._scandir=Fet.scandir,this._emitter=new Qet.EventEmitter,this._queue=Ret(this._worker.bind(this),this._settings.concurrency),this._isFatalError=!1,this._isDestroyed=!1,this._queue.drain=()=>{this._isFatalError||this._emitter.emit("end")}}read(){return this._isFatalError=!1,this._isDestroyed=!1,setImmediate(()=>{this._pushToQueue(this._root,this._settings.basePath)}),this._emitter}get isDestroyed(){return this._isDestroyed}destroy(){if(this._isDestroyed)throw new Error("The reader is already destroyed");this._isDestroyed=!0,this._queue.killAndDrain()}onEntry(e){this._emitter.on("entry",e)}onError(e){this._emitter.once("error",e)}onEnd(e){this._emitter.once("end",e)}_pushToQueue(e,r){let o={directory:e,base:r};this._queue.push(o,a=>{a!==null&&this._handleError(a)})}_worker(e,r){this._scandir(e.directory,this._settings.fsScandirSettings,(o,a)=>{if(o!==null){r(o,void 0);return}for(let n of a)this._handleEntry(n,e.base);r(null,void 0)})}_handleError(e){this._isDestroyed||!DS.isFatalError(this._settings,e)||(this._isFatalError=!0,this._isDestroyed=!0,this._emitter.emit("error",e))}_handleEntry(e,r){if(this._isDestroyed||this._isFatalError)return;let o=e.path;r!==void 0&&(e.path=DS.joinPathSegments(r,e.name,this._settings.pathSegmentSeparator)),DS.isAppliedFilter(this._settings.entryFilter,e)&&this._emitEntry(e),e.dirent.isDirectory()&&DS.isAppliedFilter(this._settings.deepFilter,e)&&this._pushToQueue(o,r===void 0?void 0:e.path)}_emitEntry(e){this._emitter.emit("entry",e)}};sO.default=iO});var Qie=_(lO=>{"use strict";Object.defineProperty(lO,"__esModule",{value:!0});var Let=oO(),aO=class{constructor(e,r){this._root=e,this._settings=r,this._reader=new Let.default(this._root,this._settings),this._storage=[]}read(e){this._reader.onError(r=>{Net(e,r)}),this._reader.onEntry(r=>{this._storage.push(r)}),this._reader.onEnd(()=>{Oet(e,this._storage)}),this._reader.read()}};lO.default=aO;function Net(t,e){t(e)}function Oet(t,e){t(null,e)}});var Fie=_(uO=>{"use strict";Object.defineProperty(uO,"__esModule",{value:!0});var Met=Be("stream"),Uet=oO(),cO=class{constructor(e,r){this._root=e,this._settings=r,this._reader=new Uet.default(this._root,this._settings),this._stream=new Met.Readable({objectMode:!0,read:()=>{},destroy:()=>{this._reader.isDestroyed||this._reader.destroy()}})}read(){return this._reader.onError(e=>{this._stream.emit("error",e)}),this._reader.onEntry(e=>{this._stream.push(e)}),this._reader.onEnd(()=>{this._stream.push(null)}),this._reader.read(),this._stream}};uO.default=cO});var Rie=_(fO=>{"use strict";Object.defineProperty(fO,"__esModule",{value:!0});var _et=BS(),PS=vS(),Het=nO(),AO=class extends Het.default{constructor(){super(...arguments),this._scandir=_et.scandirSync,this._storage=[],this._queue=new Set}read(){return this._pushToQueue(this._root,this._settings.basePath),this._handleQueue(),this._storage}_pushToQueue(e,r){this._queue.add({directory:e,base:r})}_handleQueue(){for(let e of this._queue.values())this._handleDirectory(e.directory,e.base)}_handleDirectory(e,r){try{let o=this._scandir(e,this._settings.fsScandirSettings);for(let a of o)this._handleEntry(a,r)}catch(o){this._handleError(o)}}_handleError(e){if(!!PS.isFatalError(this._settings,e))throw e}_handleEntry(e,r){let o=e.path;r!==void 0&&(e.path=PS.joinPathSegments(r,e.name,this._settings.pathSegmentSeparator)),PS.isAppliedFilter(this._settings.entryFilter,e)&&this._pushToStorage(e),e.dirent.isDirectory()&&PS.isAppliedFilter(this._settings.deepFilter,e)&&this._pushToQueue(o,r===void 0?void 0:e.path)}_pushToStorage(e){this._storage.push(e)}};fO.default=AO});var Tie=_(hO=>{"use strict";Object.defineProperty(hO,"__esModule",{value:!0});var jet=Rie(),pO=class{constructor(e,r){this._root=e,this._settings=r,this._reader=new jet.default(this._root,this._settings)}read(){return this._reader.read()}};hO.default=pO});var Lie=_(dO=>{"use strict";Object.defineProperty(dO,"__esModule",{value:!0});var qet=Be("path"),Get=BS(),gO=class{constructor(e={}){this._options=e,this.basePath=this._getValue(this._options.basePath,void 0),this.concurrency=this._getValue(this._options.concurrency,Number.POSITIVE_INFINITY),this.deepFilter=this._getValue(this._options.deepFilter,null),this.entryFilter=this._getValue(this._options.entryFilter,null),this.errorFilter=this._getValue(this._options.errorFilter,null),this.pathSegmentSeparator=this._getValue(this._options.pathSegmentSeparator,qet.sep),this.fsScandirSettings=new Get.Settings({followSymbolicLinks:this._options.followSymbolicLinks,fs:this._options.fs,pathSegmentSeparator:this._options.pathSegmentSeparator,stats:this._options.stats,throwErrorOnBrokenSymbolicLink:this._options.throwErrorOnBrokenSymbolicLink})}_getValue(e,r){return e??r}};dO.default=gO});var xS=_($u=>{"use strict";Object.defineProperty($u,"__esModule",{value:!0});$u.Settings=$u.walkStream=$u.walkSync=$u.walk=void 0;var Nie=Qie(),Yet=Fie(),Wet=Tie(),mO=Lie();$u.Settings=mO.default;function Ket(t,e,r){if(typeof e=="function"){new Nie.default(t,SS()).read(e);return}new Nie.default(t,SS(e)).read(r)}$u.walk=Ket;function Vet(t,e){let r=SS(e);return new Wet.default(t,r).read()}$u.walkSync=Vet;function zet(t,e){let r=SS(e);return new Yet.default(t,r).read()}$u.walkStream=zet;function SS(t={}){return t instanceof mO.default?t:new mO.default(t)}});var bS=_(EO=>{"use strict";Object.defineProperty(EO,"__esModule",{value:!0});var Jet=Be("path"),Xet=Ed(),Oie=vf(),yO=class{constructor(e){this._settings=e,this._fsStatSettings=new Xet.Settings({followSymbolicLink:this._settings.followSymbolicLinks,fs:this._settings.fs,throwErrorOnBrokenSymbolicLink:this._settings.followSymbolicLinks})}_getFullEntryPath(e){return Jet.resolve(this._settings.cwd,e)}_makeEntry(e,r){let o={name:r,path:r,dirent:Oie.fs.createDirentFromStats(r,e)};return this._settings.stats&&(o.stats=e),o}_isFatalError(e){return!Oie.errno.isEnoentCodeError(e)&&!this._settings.suppressErrors}};EO.default=yO});var IO=_(wO=>{"use strict";Object.defineProperty(wO,"__esModule",{value:!0});var Zet=Be("stream"),$et=Ed(),ett=xS(),ttt=bS(),CO=class extends ttt.default{constructor(){super(...arguments),this._walkStream=ett.walkStream,this._stat=$et.stat}dynamic(e,r){return this._walkStream(e,r)}static(e,r){let o=e.map(this._getFullEntryPath,this),a=new Zet.PassThrough({objectMode:!0});a._write=(n,u,A)=>this._getEntry(o[n],e[n],r).then(p=>{p!==null&&r.entryFilter(p)&&a.push(p),n===o.length-1&&a.end(),A()}).catch(A);for(let n=0;n<o.length;n++)a.write(n);return a}_getEntry(e,r,o){return this._getStat(e).then(a=>this._makeEntry(a,r)).catch(a=>{if(o.errorFilter(a))return null;throw a})}_getStat(e){return new Promise((r,o)=>{this._stat(e,this._fsStatSettings,(a,n)=>a===null?r(n):o(a))})}};wO.default=CO});var Mie=_(vO=>{"use strict";Object.defineProperty(vO,"__esModule",{value:!0});var rtt=xS(),ntt=bS(),itt=IO(),BO=class extends ntt.default{constructor(){super(...arguments),this._walkAsync=rtt.walk,this._readerStream=new itt.default(this._settings)}dynamic(e,r){return new Promise((o,a)=>{this._walkAsync(e,r,(n,u)=>{n===null?o(u):a(n)})})}async static(e,r){let o=[],a=this._readerStream.static(e,r);return new Promise((n,u)=>{a.once("error",u),a.on("data",A=>o.push(A)),a.once("end",()=>n(o))})}};vO.default=BO});var Uie=_(PO=>{"use strict";Object.defineProperty(PO,"__esModule",{value:!0});var rE=vf(),DO=class{constructor(e,r,o){this._patterns=e,this._settings=r,this._micromatchOptions=o,this._storage=[],this._fillStorage()}_fillStorage(){let e=rE.pattern.expandPatternsWithBraceExpansion(this._patterns);for(let r of e){let o=this._getPatternSegments(r),a=this._splitSegmentsIntoSections(o);this._storage.push({complete:a.length<=1,pattern:r,segments:o,sections:a})}}_getPatternSegments(e){return rE.pattern.getPatternParts(e,this._micromatchOptions).map(o=>rE.pattern.isDynamicPattern(o,this._settings)?{dynamic:!0,pattern:o,patternRe:rE.pattern.makeRe(o,this._micromatchOptions)}:{dynamic:!1,pattern:o})}_splitSegmentsIntoSections(e){return rE.array.splitWhen(e,r=>r.dynamic&&rE.pattern.hasGlobStar(r.pattern))}};PO.default=DO});var _ie=_(xO=>{"use strict";Object.defineProperty(xO,"__esModule",{value:!0});var stt=Uie(),SO=class extends stt.default{match(e){let r=e.split("/"),o=r.length,a=this._storage.filter(n=>!n.complete||n.segments.length>o);for(let n of a){let u=n.sections[0];if(!n.complete&&o>u.length||r.every((p,h)=>{let C=n.segments[h];return!!(C.dynamic&&C.patternRe.test(p)||!C.dynamic&&C.pattern===p)}))return!0}return!1}};xO.default=SO});var Hie=_(kO=>{"use strict";Object.defineProperty(kO,"__esModule",{value:!0});var kS=vf(),ott=_ie(),bO=class{constructor(e,r){this._settings=e,this._micromatchOptions=r}getFilter(e,r,o){let a=this._getMatcher(r),n=this._getNegativePatternsRe(o);return u=>this._filter(e,u,a,n)}_getMatcher(e){return new ott.default(e,this._settings,this._micromatchOptions)}_getNegativePatternsRe(e){let r=e.filter(kS.pattern.isAffectDepthOfReadingPattern);return kS.pattern.convertPatternsToRe(r,this._micromatchOptions)}_filter(e,r,o,a){if(this._isSkippedByDeep(e,r.path)||this._isSkippedSymbolicLink(r))return!1;let n=kS.path.removeLeadingDotSegment(r.path);return this._isSkippedByPositivePatterns(n,o)?!1:this._isSkippedByNegativePatterns(n,a)}_isSkippedByDeep(e,r){return this._settings.deep===1/0?!1:this._getEntryLevel(e,r)>=this._settings.deep}_getEntryLevel(e,r){let o=r.split("/").length;if(e==="")return o;let a=e.split("/").length;return o-a}_isSkippedSymbolicLink(e){return!this._settings.followSymbolicLinks&&e.dirent.isSymbolicLink()}_isSkippedByPositivePatterns(e,r){return!this._settings.baseNameMatch&&!r.match(e)}_isSkippedByNegativePatterns(e,r){return!kS.pattern.matchAny(e,r)}};kO.default=bO});var jie=_(FO=>{"use strict";Object.defineProperty(FO,"__esModule",{value:!0});var Cd=vf(),QO=class{constructor(e,r){this._settings=e,this._micromatchOptions=r,this.index=new Map}getFilter(e,r){let o=Cd.pattern.convertPatternsToRe(e,this._micromatchOptions),a=Cd.pattern.convertPatternsToRe(r,this._micromatchOptions);return n=>this._filter(n,o,a)}_filter(e,r,o){if(this._settings.unique&&this._isDuplicateEntry(e)||this._onlyFileFilter(e)||this._onlyDirectoryFilter(e)||this._isSkippedByAbsoluteNegativePatterns(e.path,o))return!1;let a=this._settings.baseNameMatch?e.name:e.path,n=e.dirent.isDirectory(),u=this._isMatchToPatterns(a,r,n)&&!this._isMatchToPatterns(e.path,o,n);return this._settings.unique&&u&&this._createIndexRecord(e),u}_isDuplicateEntry(e){return this.index.has(e.path)}_createIndexRecord(e){this.index.set(e.path,void 0)}_onlyFileFilter(e){return this._settings.onlyFiles&&!e.dirent.isFile()}_onlyDirectoryFilter(e){return this._settings.onlyDirectories&&!e.dirent.isDirectory()}_isSkippedByAbsoluteNegativePatterns(e,r){if(!this._settings.absolute)return!1;let o=Cd.path.makeAbsolute(this._settings.cwd,e);return Cd.pattern.matchAny(o,r)}_isMatchToPatterns(e,r,o){let a=Cd.path.removeLeadingDotSegment(e),n=Cd.pattern.matchAny(a,r);return!n&&o?Cd.pattern.matchAny(a+"/",r):n}};FO.default=QO});var qie=_(TO=>{"use strict";Object.defineProperty(TO,"__esModule",{value:!0});var att=vf(),RO=class{constructor(e){this._settings=e}getFilter(){return e=>this._isNonFatalError(e)}_isNonFatalError(e){return att.errno.isEnoentCodeError(e)||this._settings.suppressErrors}};TO.default=RO});var Yie=_(NO=>{"use strict";Object.defineProperty(NO,"__esModule",{value:!0});var Gie=vf(),LO=class{constructor(e){this._settings=e}getTransformer(){return e=>this._transform(e)}_transform(e){let r=e.path;return this._settings.absolute&&(r=Gie.path.makeAbsolute(this._settings.cwd,r),r=Gie.path.unixify(r)),this._settings.markDirectories&&e.dirent.isDirectory()&&(r+="/"),this._settings.objectMode?Object.assign(Object.assign({},e),{path:r}):r}};NO.default=LO});var QS=_(MO=>{"use strict";Object.defineProperty(MO,"__esModule",{value:!0});var ltt=Be("path"),ctt=Hie(),utt=jie(),Att=qie(),ftt=Yie(),OO=class{constructor(e){this._settings=e,this.errorFilter=new Att.default(this._settings),this.entryFilter=new utt.default(this._settings,this._getMicromatchOptions()),this.deepFilter=new ctt.default(this._settings,this._getMicromatchOptions()),this.entryTransformer=new ftt.default(this._settings)}_getRootDirectory(e){return ltt.resolve(this._settings.cwd,e.base)}_getReaderOptions(e){let r=e.base==="."?"":e.base;return{basePath:r,pathSegmentSeparator:"/",concurrency:this._settings.concurrency,deepFilter:this.deepFilter.getFilter(r,e.positive,e.negative),entryFilter:this.entryFilter.getFilter(e.positive,e.negative),errorFilter:this.errorFilter.getFilter(),followSymbolicLinks:this._settings.followSymbolicLinks,fs:this._settings.fs,stats:this._settings.stats,throwErrorOnBrokenSymbolicLink:this._settings.throwErrorOnBrokenSymbolicLink,transform:this.entryTransformer.getTransformer()}}_getMicromatchOptions(){return{dot:this._settings.dot,matchBase:this._settings.baseNameMatch,nobrace:!this._settings.braceExpansion,nocase:!this._settings.caseSensitiveMatch,noext:!this._settings.extglob,noglobstar:!this._settings.globstar,posix:!0,strictSlashes:!1}}};MO.default=OO});var Wie=_(_O=>{"use strict";Object.defineProperty(_O,"__esModule",{value:!0});var ptt=Mie(),htt=QS(),UO=class extends htt.default{constructor(){super(...arguments),this._reader=new ptt.default(this._settings)}async read(e){let r=this._getRootDirectory(e),o=this._getReaderOptions(e);return(await this.api(r,e,o)).map(n=>o.transform(n))}api(e,r,o){return r.dynamic?this._reader.dynamic(e,o):this._reader.static(r.patterns,o)}};_O.default=UO});var Kie=_(jO=>{"use strict";Object.defineProperty(jO,"__esModule",{value:!0});var gtt=Be("stream"),dtt=IO(),mtt=QS(),HO=class extends mtt.default{constructor(){super(...arguments),this._reader=new dtt.default(this._settings)}read(e){let r=this._getRootDirectory(e),o=this._getReaderOptions(e),a=this.api(r,e,o),n=new gtt.Readable({objectMode:!0,read:()=>{}});return a.once("error",u=>n.emit("error",u)).on("data",u=>n.emit("data",o.transform(u))).once("end",()=>n.emit("end")),n.once("close",()=>a.destroy()),n}api(e,r,o){return r.dynamic?this._reader.dynamic(e,o):this._reader.static(r.patterns,o)}};jO.default=HO});var Vie=_(GO=>{"use strict";Object.defineProperty(GO,"__esModule",{value:!0});var ytt=Ed(),Ett=xS(),Ctt=bS(),qO=class extends Ctt.default{constructor(){super(...arguments),this._walkSync=Ett.walkSync,this._statSync=ytt.statSync}dynamic(e,r){return this._walkSync(e,r)}static(e,r){let o=[];for(let a of e){let n=this._getFullEntryPath(a),u=this._getEntry(n,a,r);u===null||!r.entryFilter(u)||o.push(u)}return o}_getEntry(e,r,o){try{let a=this._getStat(e);return this._makeEntry(a,r)}catch(a){if(o.errorFilter(a))return null;throw a}}_getStat(e){return this._statSync(e,this._fsStatSettings)}};GO.default=qO});var zie=_(WO=>{"use strict";Object.defineProperty(WO,"__esModule",{value:!0});var wtt=Vie(),Itt=QS(),YO=class extends Itt.default{constructor(){super(...arguments),this._reader=new wtt.default(this._settings)}read(e){let r=this._getRootDirectory(e),o=this._getReaderOptions(e);return this.api(r,e,o).map(o.transform)}api(e,r,o){return r.dynamic?this._reader.dynamic(e,o):this._reader.static(r.patterns,o)}};WO.default=YO});var Jie=_(iE=>{"use strict";Object.defineProperty(iE,"__esModule",{value:!0});iE.DEFAULT_FILE_SYSTEM_ADAPTER=void 0;var nE=Be("fs"),Btt=Be("os"),vtt=Math.max(Btt.cpus().length,1);iE.DEFAULT_FILE_SYSTEM_ADAPTER={lstat:nE.lstat,lstatSync:nE.lstatSync,stat:nE.stat,statSync:nE.statSync,readdir:nE.readdir,readdirSync:nE.readdirSync};var KO=class{constructor(e={}){this._options=e,this.absolute=this._getValue(this._options.absolute,!1),this.baseNameMatch=this._getValue(this._options.baseNameMatch,!1),this.braceExpansion=this._getValue(this._options.braceExpansion,!0),this.caseSensitiveMatch=this._getValue(this._options.caseSensitiveMatch,!0),this.concurrency=this._getValue(this._options.concurrency,vtt),this.cwd=this._getValue(this._options.cwd,process.cwd()),this.deep=this._getValue(this._options.deep,1/0),this.dot=this._getValue(this._options.dot,!1),this.extglob=this._getValue(this._options.extglob,!0),this.followSymbolicLinks=this._getValue(this._options.followSymbolicLinks,!0),this.fs=this._getFileSystemMethods(this._options.fs),this.globstar=this._getValue(this._options.globstar,!0),this.ignore=this._getValue(this._options.ignore,[]),this.markDirectories=this._getValue(this._options.markDirectories,!1),this.objectMode=this._getValue(this._options.objectMode,!1),this.onlyDirectories=this._getValue(this._options.onlyDirectories,!1),this.onlyFiles=this._getValue(this._options.onlyFiles,!0),this.stats=this._getValue(this._options.stats,!1),this.suppressErrors=this._getValue(this._options.suppressErrors,!1),this.throwErrorOnBrokenSymbolicLink=this._getValue(this._options.throwErrorOnBrokenSymbolicLink,!1),this.unique=this._getValue(this._options.unique,!0),this.onlyDirectories&&(this.onlyFiles=!1),this.stats&&(this.objectMode=!0)}_getValue(e,r){return e===void 0?r:e}_getFileSystemMethods(e={}){return Object.assign(Object.assign({},iE.DEFAULT_FILE_SYSTEM_ADAPTER),e)}};iE.default=KO});var RS=_((ELt,$ie)=>{"use strict";var Xie=$ne(),Zie=tie(),Dtt=Wie(),Ptt=Kie(),Stt=zie(),VO=Jie(),wd=vf();async function zO(t,e){sE(t);let r=JO(t,Dtt.default,e),o=await Promise.all(r);return wd.array.flatten(o)}(function(t){function e(u,A){sE(u);let p=JO(u,Stt.default,A);return wd.array.flatten(p)}t.sync=e;function r(u,A){sE(u);let p=JO(u,Ptt.default,A);return wd.stream.merge(p)}t.stream=r;function o(u,A){sE(u);let p=Zie.transform([].concat(u)),h=new VO.default(A);return Xie.generate(p,h)}t.generateTasks=o;function a(u,A){sE(u);let p=new VO.default(A);return wd.pattern.isDynamicPattern(u,p)}t.isDynamicPattern=a;function n(u){return sE(u),wd.path.escape(u)}t.escapePath=n})(zO||(zO={}));function JO(t,e,r){let o=Zie.transform([].concat(t)),a=new VO.default(r),n=Xie.generate(o,a),u=new e(a);return n.map(u.read,u)}function sE(t){if(![].concat(t).every(o=>wd.string.isString(o)&&!wd.string.isEmpty(o)))throw new TypeError("Patterns must be a string (non empty) or an array of strings")}$ie.exports=zO});var wn={};Vt(wn,{checksumFile:()=>LS,checksumPattern:()=>NS,makeHash:()=>Qs});function Qs(...t){let e=(0,TS.createHash)("sha512"),r="";for(let o of t)typeof o=="string"?r+=o:o&&(r&&(e.update(r),r=""),e.update(o));return r&&e.update(r),e.digest("hex")}async function LS(t,{baseFs:e,algorithm:r}={baseFs:oe,algorithm:"sha512"}){let o=await e.openPromise(t,"r");try{let n=Buffer.allocUnsafeSlow(65536),u=(0,TS.createHash)(r),A=0;for(;(A=await e.readPromise(o,n,0,65536))!==0;)u.update(A===65536?n:n.slice(0,A));return u.digest("hex")}finally{await e.closePromise(o)}}async function NS(t,{cwd:e}){let o=(await(0,XO.default)(t,{cwd:fe.fromPortablePath(e),onlyDirectories:!0})).map(A=>`${A}/**/*`),a=await(0,XO.default)([t,...o],{cwd:fe.fromPortablePath(e),onlyFiles:!1});a.sort();let n=await Promise.all(a.map(async A=>{let p=[Buffer.from(A)],h=fe.toPortablePath(A),C=await oe.lstatPromise(h);return C.isSymbolicLink()?p.push(Buffer.from(await oe.readlinkPromise(h))):C.isFile()&&p.push(await oe.readFilePromise(h)),p.join("\0")})),u=(0,TS.createHash)("sha512");for(let A of n)u.update(A);return u.digest("hex")}var TS,XO,rh=yt(()=>{Pt();TS=Be("crypto"),XO=$e(RS())});var G={};Vt(G,{areDescriptorsEqual:()=>ise,areIdentsEqual:()=>t1,areLocatorsEqual:()=>r1,areVirtualPackagesEquivalent:()=>Ntt,bindDescriptor:()=>Ttt,bindLocator:()=>Ltt,convertDescriptorToLocator:()=>OS,convertLocatorToDescriptor:()=>$O,convertPackageToLocator:()=>Qtt,convertToIdent:()=>ktt,convertToManifestRange:()=>Wtt,copyPackage:()=>ZI,devirtualizeDescriptor:()=>$I,devirtualizeLocator:()=>e1,ensureDevirtualizedDescriptor:()=>Ftt,ensureDevirtualizedLocator:()=>Rtt,getIdentVendorPath:()=>nM,isPackageCompatible:()=>jS,isVirtualDescriptor:()=>Pf,isVirtualLocator:()=>Hc,makeDescriptor:()=>In,makeIdent:()=>eA,makeLocator:()=>Fs,makeRange:()=>_S,parseDescriptor:()=>nh,parseFileStyleRange:()=>Gtt,parseIdent:()=>Js,parseLocator:()=>Sf,parseRange:()=>Id,prettyDependent:()=>QN,prettyDescriptor:()=>qn,prettyIdent:()=>cs,prettyLocator:()=>jr,prettyLocatorNoColors:()=>kN,prettyRange:()=>lE,prettyReference:()=>i1,prettyResolution:()=>JI,prettyWorkspace:()=>s1,renamePackage:()=>eM,slugifyIdent:()=>ZO,slugifyLocator:()=>aE,sortDescriptors:()=>cE,stringifyDescriptor:()=>Sa,stringifyIdent:()=>fn,stringifyLocator:()=>xa,tryParseDescriptor:()=>n1,tryParseIdent:()=>sse,tryParseLocator:()=>US,tryParseRange:()=>qtt,virtualizeDescriptor:()=>tM,virtualizePackage:()=>rM});function eA(t,e){if(t?.startsWith("@"))throw new Error("Invalid scope: don't prefix it with '@'");return{identHash:Qs(t,e),scope:t,name:e}}function In(t,e){return{identHash:t.identHash,scope:t.scope,name:t.name,descriptorHash:Qs(t.identHash,e),range:e}}function Fs(t,e){return{identHash:t.identHash,scope:t.scope,name:t.name,locatorHash:Qs(t.identHash,e),reference:e}}function ktt(t){return{identHash:t.identHash,scope:t.scope,name:t.name}}function OS(t){return{identHash:t.identHash,scope:t.scope,name:t.name,locatorHash:t.descriptorHash,reference:t.range}}function $O(t){return{identHash:t.identHash,scope:t.scope,name:t.name,descriptorHash:t.locatorHash,range:t.reference}}function Qtt(t){return{identHash:t.identHash,scope:t.scope,name:t.name,locatorHash:t.locatorHash,reference:t.reference}}function eM(t,e){return{identHash:e.identHash,scope:e.scope,name:e.name,locatorHash:e.locatorHash,reference:e.reference,version:t.version,languageName:t.languageName,linkType:t.linkType,conditions:t.conditions,dependencies:new Map(t.dependencies),peerDependencies:new Map(t.peerDependencies),dependenciesMeta:new Map(t.dependenciesMeta),peerDependenciesMeta:new Map(t.peerDependenciesMeta),bin:new Map(t.bin)}}function ZI(t){return eM(t,t)}function tM(t,e){if(e.includes("#"))throw new Error("Invalid entropy");return In(t,`virtual:${e}#${t.range}`)}function rM(t,e){if(e.includes("#"))throw new Error("Invalid entropy");return eM(t,Fs(t,`virtual:${e}#${t.reference}`))}function Pf(t){return t.range.startsWith(XI)}function Hc(t){return t.reference.startsWith(XI)}function $I(t){if(!Pf(t))throw new Error("Not a virtual descriptor");return In(t,t.range.replace(MS,""))}function e1(t){if(!Hc(t))throw new Error("Not a virtual descriptor");return Fs(t,t.reference.replace(MS,""))}function Ftt(t){return Pf(t)?In(t,t.range.replace(MS,"")):t}function Rtt(t){return Hc(t)?Fs(t,t.reference.replace(MS,"")):t}function Ttt(t,e){return t.range.includes("::")?t:In(t,`${t.range}::${oE.default.stringify(e)}`)}function Ltt(t,e){return t.reference.includes("::")?t:Fs(t,`${t.reference}::${oE.default.stringify(e)}`)}function t1(t,e){return t.identHash===e.identHash}function ise(t,e){return t.descriptorHash===e.descriptorHash}function r1(t,e){return t.locatorHash===e.locatorHash}function Ntt(t,e){if(!Hc(t))throw new Error("Invalid package type");if(!Hc(e))throw new Error("Invalid package type");if(!t1(t,e)||t.dependencies.size!==e.dependencies.size)return!1;for(let r of t.dependencies.values()){let o=e.dependencies.get(r.identHash);if(!o||!ise(r,o))return!1}return!0}function Js(t){let e=sse(t);if(!e)throw new Error(`Invalid ident (${t})`);return e}function sse(t){let e=t.match(Ott);if(!e)return null;let[,r,o]=e;return eA(typeof r<"u"?r:null,o)}function nh(t,e=!1){let r=n1(t,e);if(!r)throw new Error(`Invalid descriptor (${t})`);return r}function n1(t,e=!1){let r=e?t.match(Mtt):t.match(Utt);if(!r)return null;let[,o,a,n]=r;if(n==="unknown")throw new Error(`Invalid range (${t})`);let u=typeof o<"u"?o:null,A=typeof n<"u"?n:"unknown";return In(eA(u,a),A)}function Sf(t,e=!1){let r=US(t,e);if(!r)throw new Error(`Invalid locator (${t})`);return r}function US(t,e=!1){let r=e?t.match(_tt):t.match(Htt);if(!r)return null;let[,o,a,n]=r;if(n==="unknown")throw new Error(`Invalid reference (${t})`);let u=typeof o<"u"?o:null,A=typeof n<"u"?n:"unknown";return Fs(eA(u,a),A)}function Id(t,e){let r=t.match(jtt);if(r===null)throw new Error(`Invalid range (${t})`);let o=typeof r[1]<"u"?r[1]:null;if(typeof e?.requireProtocol=="string"&&o!==e.requireProtocol)throw new Error(`Invalid protocol (${o})`);if(e?.requireProtocol&&o===null)throw new Error(`Missing protocol (${o})`);let a=typeof r[3]<"u"?decodeURIComponent(r[2]):null;if(e?.requireSource&&a===null)throw new Error(`Missing source (${t})`);let n=typeof r[3]<"u"?decodeURIComponent(r[3]):decodeURIComponent(r[2]),u=e?.parseSelector?oE.default.parse(n):n,A=typeof r[4]<"u"?oE.default.parse(r[4]):null;return{protocol:o,source:a,selector:u,params:A}}function qtt(t,e){try{return Id(t,e)}catch{return null}}function Gtt(t,{protocol:e}){let{selector:r,params:o}=Id(t,{requireProtocol:e,requireBindings:!0});if(typeof o.locator!="string")throw new Error(`Assertion failed: Invalid bindings for ${t}`);return{parentLocator:Sf(o.locator,!0),path:r}}function ese(t){return t=t.replaceAll("%","%25"),t=t.replaceAll(":","%3A"),t=t.replaceAll("#","%23"),t}function Ytt(t){return t===null?!1:Object.entries(t).length>0}function _S({protocol:t,source:e,selector:r,params:o}){let a="";return t!==null&&(a+=`${t}`),e!==null&&(a+=`${ese(e)}#`),a+=ese(r),Ytt(o)&&(a+=`::${oE.default.stringify(o)}`),a}function Wtt(t){let{params:e,protocol:r,source:o,selector:a}=Id(t);for(let n in e)n.startsWith("__")&&delete e[n];return _S({protocol:r,source:o,params:e,selector:a})}function fn(t){return t.scope?`@${t.scope}/${t.name}`:`${t.name}`}function Sa(t){return t.scope?`@${t.scope}/${t.name}@${t.range}`:`${t.name}@${t.range}`}function xa(t){return t.scope?`@${t.scope}/${t.name}@${t.reference}`:`${t.name}@${t.reference}`}function ZO(t){return t.scope!==null?`@${t.scope}-${t.name}`:t.name}function aE(t){let{protocol:e,selector:r}=Id(t.reference),o=e!==null?e.replace(Ktt,""):"exotic",a=tse.default.valid(r),n=a!==null?`${o}-${a}`:`${o}`,u=10;return t.scope?`${ZO(t)}-${n}-${t.locatorHash.slice(0,u)}`:`${ZO(t)}-${n}-${t.locatorHash.slice(0,u)}`}function cs(t,e){return e.scope?`${_t(t,`@${e.scope}/`,Et.SCOPE)}${_t(t,e.name,Et.NAME)}`:`${_t(t,e.name,Et.NAME)}`}function HS(t){if(t.startsWith(XI)){let e=HS(t.substring(t.indexOf("#")+1)),r=t.substring(XI.length,XI.length+xtt);return`${e} [${r}]`}else return t.replace(Vtt,"?[...]")}function lE(t,e){return`${_t(t,HS(e),Et.RANGE)}`}function qn(t,e){return`${cs(t,e)}${_t(t,"@",Et.RANGE)}${lE(t,e.range)}`}function i1(t,e){return`${_t(t,HS(e),Et.REFERENCE)}`}function jr(t,e){return`${cs(t,e)}${_t(t,"@",Et.REFERENCE)}${i1(t,e.reference)}`}function kN(t){return`${fn(t)}@${HS(t.reference)}`}function cE(t){return ks(t,[e=>fn(e),e=>e.range])}function s1(t,e){return cs(t,e.anchoredLocator)}function JI(t,e,r){let o=Pf(e)?$I(e):e;return r===null?`${qn(t,o)} \u2192 ${bN(t).Cross}`:o.identHash===r.identHash?`${qn(t,o)} \u2192 ${i1(t,r.reference)}`:`${qn(t,o)} \u2192 ${jr(t,r)}`}function QN(t,e,r){return r===null?`${jr(t,e)}`:`${jr(t,e)} (via ${lE(t,r.range)})`}function nM(t){return`node_modules/${fn(t)}`}function jS(t,e){return t.conditions?btt(t.conditions,r=>{let[,o,a]=r.match(nse),n=e[o];return n?n.includes(a):!0}):!0}var oE,tse,rse,XI,xtt,nse,btt,MS,Ott,Mtt,Utt,_tt,Htt,jtt,Ktt,Vtt,xo=yt(()=>{oE=$e(Be("querystring")),tse=$e(Jn()),rse=$e(tX());ql();rh();jl();xo();XI="virtual:",xtt=5,nse=/(os|cpu|libc)=([a-z0-9_-]+)/,btt=(0,rse.makeParser)(nse);MS=/^[^#]*#/;Ott=/^(?:@([^/]+?)\/)?([^@/]+)$/;Mtt=/^(?:@([^/]+?)\/)?([^@/]+?)(?:@(.+))$/,Utt=/^(?:@([^/]+?)\/)?([^@/]+?)(?:@(.+))?$/;_tt=/^(?:@([^/]+?)\/)?([^@/]+?)(?:@(.+))$/,Htt=/^(?:@([^/]+?)\/)?([^@/]+?)(?:@(.+))?$/;jtt=/^([^#:]*:)?((?:(?!::)[^#])*)(?:#((?:(?!::).)*))?(?:::(.*))?$/;Ktt=/:$/;Vtt=/\?.*/});var ose,ase=yt(()=>{xo();ose={hooks:{reduceDependency:(t,e,r,o,{resolver:a,resolveOptions:n})=>{for(let{pattern:u,reference:A}of e.topLevelWorkspace.manifest.resolutions){if(u.from&&(u.from.fullName!==fn(r)||e.configuration.normalizeLocator(Fs(Js(u.from.fullName),u.from.description??r.reference)).locatorHash!==r.locatorHash)||u.descriptor.fullName!==fn(t)||e.configuration.normalizeDependency(In(Sf(u.descriptor.fullName),u.descriptor.description??t.range)).descriptorHash!==t.descriptorHash)continue;return a.bindDescriptor(e.configuration.normalizeDependency(In(t,A)),e.topLevelWorkspace.anchoredLocator,n)}return t},validateProject:async(t,e)=>{for(let r of t.workspaces){let o=s1(t.configuration,r);await t.configuration.triggerHook(a=>a.validateWorkspace,r,{reportWarning:(a,n)=>e.reportWarning(a,`${o}: ${n}`),reportError:(a,n)=>e.reportError(a,`${o}: ${n}`)})}},validateWorkspace:async(t,e)=>{let{manifest:r}=t;r.resolutions.length&&t.cwd!==t.project.cwd&&r.errors.push(new Error("Resolutions field will be ignored"));for(let o of r.errors)e.reportWarning(57,o.message)}}}});var o1,Xn,Bd=yt(()=>{o1=class{supportsDescriptor(e,r){return!!(e.range.startsWith(o1.protocol)||r.project.tryWorkspaceByDescriptor(e)!==null)}supportsLocator(e,r){return!!e.reference.startsWith(o1.protocol)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,o){return e}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){return[o.project.getWorkspaceByDescriptor(e).anchoredLocator]}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){let o=r.project.getWorkspaceByCwd(e.reference.slice(o1.protocol.length));return{...e,version:o.manifest.version||"0.0.0",languageName:"unknown",linkType:"SOFT",conditions:null,dependencies:r.project.configuration.normalizeDependencyMap(new Map([...o.manifest.dependencies,...o.manifest.devDependencies])),peerDependencies:new Map([...o.manifest.peerDependencies]),dependenciesMeta:o.manifest.dependenciesMeta,peerDependenciesMeta:o.manifest.peerDependenciesMeta,bin:o.manifest.bin}}},Xn=o1;Xn.protocol="workspace:"});var Qr={};Vt(Qr,{SemVer:()=>fse.SemVer,clean:()=>Jtt,getComparator:()=>use,mergeComparators:()=>iM,satisfiesWithPrereleases:()=>xf,simplifyRanges:()=>sM,stringifyComparator:()=>Ase,validRange:()=>ba});function xf(t,e,r=!1){if(!t)return!1;let o=`${e}${r}`,a=lse.get(o);if(typeof a>"u")try{a=new ih.default.Range(e,{includePrerelease:!0,loose:r})}catch{return!1}finally{lse.set(o,a||null)}else if(a===null)return!1;let n;try{n=new ih.default.SemVer(t,a)}catch{return!1}return a.test(n)?!0:(n.prerelease&&(n.prerelease=[]),a.set.some(u=>{for(let A of u)A.semver.prerelease&&(A.semver.prerelease=[]);return u.every(A=>A.test(n))}))}function ba(t){if(t.indexOf(":")!==-1)return null;let e=cse.get(t);if(typeof e<"u")return e;try{e=new ih.default.Range(t)}catch{e=null}return cse.set(t,e),e}function Jtt(t){let e=ztt.exec(t);return e?e[1]:null}function use(t){if(t.semver===ih.default.Comparator.ANY)return{gt:null,lt:null};switch(t.operator){case"":return{gt:[">=",t.semver],lt:["<=",t.semver]};case">":case">=":return{gt:[t.operator,t.semver],lt:null};case"<":case"<=":return{gt:null,lt:[t.operator,t.semver]};default:throw new Error(`Assertion failed: Unexpected comparator operator (${t.operator})`)}}function iM(t){if(t.length===0)return null;let e=null,r=null;for(let o of t){if(o.gt){let a=e!==null?ih.default.compare(o.gt[1],e[1]):null;(a===null||a>0||a===0&&o.gt[0]===">")&&(e=o.gt)}if(o.lt){let a=r!==null?ih.default.compare(o.lt[1],r[1]):null;(a===null||a<0||a===0&&o.lt[0]==="<")&&(r=o.lt)}}if(e&&r){let o=ih.default.compare(e[1],r[1]);if(o===0&&(e[0]===">"||r[0]==="<")||o>0)return null}return{gt:e,lt:r}}function Ase(t){if(t.gt&&t.lt){if(t.gt[0]===">="&&t.lt[0]==="<="&&t.gt[1].version===t.lt[1].version)return t.gt[1].version;if(t.gt[0]===">="&&t.lt[0]==="<"){if(t.lt[1].version===`${t.gt[1].major+1}.0.0-0`)return`^${t.gt[1].version}`;if(t.lt[1].version===`${t.gt[1].major}.${t.gt[1].minor+1}.0-0`)return`~${t.gt[1].version}`}}let e=[];return t.gt&&e.push(t.gt[0]+t.gt[1].version),t.lt&&e.push(t.lt[0]+t.lt[1].version),e.length?e.join(" "):"*"}function sM(t){let e=t.map(o=>ba(o).set.map(a=>a.map(n=>use(n)))),r=e.shift().map(o=>iM(o)).filter(o=>o!==null);for(let o of e){let a=[];for(let n of r)for(let u of o){let A=iM([n,...u]);A!==null&&a.push(A)}r=a}return r.length===0?null:r.map(o=>Ase(o)).join(" || ")}var ih,fse,lse,cse,ztt,bf=yt(()=>{ih=$e(Jn()),fse=$e(Jn()),lse=new Map;cse=new Map;ztt=/^(?:[\sv=]*?)((0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\s*)$/});function pse(t){let e=t.match(/^[ \t]+/m);return e?e[0]:" "}function hse(t){return t.charCodeAt(0)===65279?t.slice(1):t}function $o(t){return t.replace(/\\/g,"/")}function qS(t,{yamlCompatibilityMode:e}){return e?wN(t):typeof t>"u"||typeof t=="boolean"?t:null}function gse(t,e){let r=e.search(/[^!]/);if(r===-1)return"invalid";let o=r%2===0?"":"!",a=e.slice(r);return`${o}${t}=${a}`}function oM(t,e){return e.length===1?gse(t,e[0]):`(${e.map(r=>gse(t,r)).join(" | ")})`}var dse,uE,Ot,AE=yt(()=>{Pt();Ll();dse=$e(Jn());Bd();jl();bf();xo();uE=class{constructor(){this.indent=" ";this.name=null;this.version=null;this.os=null;this.cpu=null;this.libc=null;this.type=null;this.packageManager=null;this.private=!1;this.license=null;this.main=null;this.module=null;this.browser=null;this.languageName=null;this.bin=new Map;this.scripts=new Map;this.dependencies=new Map;this.devDependencies=new Map;this.peerDependencies=new Map;this.workspaceDefinitions=[];this.dependenciesMeta=new Map;this.peerDependenciesMeta=new Map;this.resolutions=[];this.files=null;this.publishConfig=null;this.installConfig=null;this.preferUnplugged=null;this.raw={};this.errors=[]}static async tryFind(e,{baseFs:r=new Tn}={}){let o=V.join(e,"package.json");try{return await uE.fromFile(o,{baseFs:r})}catch(a){if(a.code==="ENOENT")return null;throw a}}static async find(e,{baseFs:r}={}){let o=await uE.tryFind(e,{baseFs:r});if(o===null)throw new Error("Manifest not found");return o}static async fromFile(e,{baseFs:r=new Tn}={}){let o=new uE;return await o.loadFile(e,{baseFs:r}),o}static fromText(e){let r=new uE;return r.loadFromText(e),r}loadFromText(e){let r;try{r=JSON.parse(hse(e)||"{}")}catch(o){throw o.message+=` (when parsing ${e})`,o}this.load(r),this.indent=pse(e)}async loadFile(e,{baseFs:r=new Tn}){let o=await r.readFilePromise(e,"utf8"),a;try{a=JSON.parse(hse(o)||"{}")}catch(n){throw n.message+=` (when parsing ${e})`,n}this.load(a),this.indent=pse(o)}load(e,{yamlCompatibilityMode:r=!1}={}){if(typeof e!="object"||e===null)throw new Error(`Utterly invalid manifest data (${e})`);this.raw=e;let o=[];if(this.name=null,typeof e.name=="string")try{this.name=Js(e.name)}catch{o.push(new Error("Parsing failed for the 'name' field"))}if(typeof e.version=="string"?this.version=e.version:this.version=null,Array.isArray(e.os)){let n=[];this.os=n;for(let u of e.os)typeof u!="string"?o.push(new Error("Parsing failed for the 'os' field")):n.push(u)}else this.os=null;if(Array.isArray(e.cpu)){let n=[];this.cpu=n;for(let u of e.cpu)typeof u!="string"?o.push(new Error("Parsing failed for the 'cpu' field")):n.push(u)}else this.cpu=null;if(Array.isArray(e.libc)){let n=[];this.libc=n;for(let u of e.libc)typeof u!="string"?o.push(new Error("Parsing failed for the 'libc' field")):n.push(u)}else this.libc=null;if(typeof e.type=="string"?this.type=e.type:this.type=null,typeof e.packageManager=="string"?this.packageManager=e.packageManager:this.packageManager=null,typeof e.private=="boolean"?this.private=e.private:this.private=!1,typeof e.license=="string"?this.license=e.license:this.license=null,typeof e.languageName=="string"?this.languageName=e.languageName:this.languageName=null,typeof e.main=="string"?this.main=$o(e.main):this.main=null,typeof e.module=="string"?this.module=$o(e.module):this.module=null,e.browser!=null)if(typeof e.browser=="string")this.browser=$o(e.browser);else{this.browser=new Map;for(let[n,u]of Object.entries(e.browser))this.browser.set($o(n),typeof u=="string"?$o(u):u)}else this.browser=null;if(this.bin=new Map,typeof e.bin=="string")e.bin.trim()===""?o.push(new Error("Invalid bin field")):this.name!==null?this.bin.set(this.name.name,$o(e.bin)):o.push(new Error("String bin field, but no attached package name"));else if(typeof e.bin=="object"&&e.bin!==null)for(let[n,u]of Object.entries(e.bin)){if(typeof u!="string"||u.trim()===""){o.push(new Error(`Invalid bin definition for '${n}'`));continue}let A=Js(n);this.bin.set(A.name,$o(u))}if(this.scripts=new Map,typeof e.scripts=="object"&&e.scripts!==null)for(let[n,u]of Object.entries(e.scripts)){if(typeof u!="string"){o.push(new Error(`Invalid script definition for '${n}'`));continue}this.scripts.set(n,u)}if(this.dependencies=new Map,typeof e.dependencies=="object"&&e.dependencies!==null)for(let[n,u]of Object.entries(e.dependencies)){if(typeof u!="string"){o.push(new Error(`Invalid dependency range for '${n}'`));continue}let A;try{A=Js(n)}catch{o.push(new Error(`Parsing failed for the dependency name '${n}'`));continue}let p=In(A,u);this.dependencies.set(p.identHash,p)}if(this.devDependencies=new Map,typeof e.devDependencies=="object"&&e.devDependencies!==null)for(let[n,u]of Object.entries(e.devDependencies)){if(typeof u!="string"){o.push(new Error(`Invalid dependency range for '${n}'`));continue}let A;try{A=Js(n)}catch{o.push(new Error(`Parsing failed for the dependency name '${n}'`));continue}let p=In(A,u);this.devDependencies.set(p.identHash,p)}if(this.peerDependencies=new Map,typeof e.peerDependencies=="object"&&e.peerDependencies!==null)for(let[n,u]of Object.entries(e.peerDependencies)){let A;try{A=Js(n)}catch{o.push(new Error(`Parsing failed for the dependency name '${n}'`));continue}(typeof u!="string"||!u.startsWith(Xn.protocol)&&!ba(u))&&(o.push(new Error(`Invalid dependency range for '${n}'`)),u="*");let p=In(A,u);this.peerDependencies.set(p.identHash,p)}typeof e.workspaces=="object"&&e.workspaces!==null&&e.workspaces.nohoist&&o.push(new Error("'nohoist' is deprecated, please use 'installConfig.hoistingLimits' instead"));let a=Array.isArray(e.workspaces)?e.workspaces:typeof e.workspaces=="object"&&e.workspaces!==null&&Array.isArray(e.workspaces.packages)?e.workspaces.packages:[];this.workspaceDefinitions=[];for(let n of a){if(typeof n!="string"){o.push(new Error(`Invalid workspace definition for '${n}'`));continue}this.workspaceDefinitions.push({pattern:n})}if(this.dependenciesMeta=new Map,typeof e.dependenciesMeta=="object"&&e.dependenciesMeta!==null)for(let[n,u]of Object.entries(e.dependenciesMeta)){if(typeof u!="object"||u===null){o.push(new Error(`Invalid meta field for '${n}`));continue}let A=nh(n),p=this.ensureDependencyMeta(A),h=qS(u.built,{yamlCompatibilityMode:r});if(h===null){o.push(new Error(`Invalid built meta field for '${n}'`));continue}let C=qS(u.optional,{yamlCompatibilityMode:r});if(C===null){o.push(new Error(`Invalid optional meta field for '${n}'`));continue}let I=qS(u.unplugged,{yamlCompatibilityMode:r});if(I===null){o.push(new Error(`Invalid unplugged meta field for '${n}'`));continue}Object.assign(p,{built:h,optional:C,unplugged:I})}if(this.peerDependenciesMeta=new Map,typeof e.peerDependenciesMeta=="object"&&e.peerDependenciesMeta!==null)for(let[n,u]of Object.entries(e.peerDependenciesMeta)){if(typeof u!="object"||u===null){o.push(new Error(`Invalid meta field for '${n}'`));continue}let A=nh(n),p=this.ensurePeerDependencyMeta(A),h=qS(u.optional,{yamlCompatibilityMode:r});if(h===null){o.push(new Error(`Invalid optional meta field for '${n}'`));continue}Object.assign(p,{optional:h})}if(this.resolutions=[],typeof e.resolutions=="object"&&e.resolutions!==null)for(let[n,u]of Object.entries(e.resolutions)){if(typeof u!="string"){o.push(new Error(`Invalid resolution entry for '${n}'`));continue}try{this.resolutions.push({pattern:UD(n),reference:u})}catch(A){o.push(A);continue}}if(Array.isArray(e.files)){this.files=new Set;for(let n of e.files){if(typeof n!="string"){o.push(new Error(`Invalid files entry for '${n}'`));continue}this.files.add(n)}}else this.files=null;if(typeof e.publishConfig=="object"&&e.publishConfig!==null){if(this.publishConfig={},typeof e.publishConfig.access=="string"&&(this.publishConfig.access=e.publishConfig.access),typeof e.publishConfig.main=="string"&&(this.publishConfig.main=$o(e.publishConfig.main)),typeof e.publishConfig.module=="string"&&(this.publishConfig.module=$o(e.publishConfig.module)),e.publishConfig.browser!=null)if(typeof e.publishConfig.browser=="string")this.publishConfig.browser=$o(e.publishConfig.browser);else{this.publishConfig.browser=new Map;for(let[n,u]of Object.entries(e.publishConfig.browser))this.publishConfig.browser.set($o(n),typeof u=="string"?$o(u):u)}if(typeof e.publishConfig.registry=="string"&&(this.publishConfig.registry=e.publishConfig.registry),typeof e.publishConfig.bin=="string")this.name!==null?this.publishConfig.bin=new Map([[this.name.name,$o(e.publishConfig.bin)]]):o.push(new Error("String bin field, but no attached package name"));else if(typeof e.publishConfig.bin=="object"&&e.publishConfig.bin!==null){this.publishConfig.bin=new Map;for(let[n,u]of Object.entries(e.publishConfig.bin)){if(typeof u!="string"){o.push(new Error(`Invalid bin definition for '${n}'`));continue}this.publishConfig.bin.set(n,$o(u))}}if(Array.isArray(e.publishConfig.executableFiles)){this.publishConfig.executableFiles=new Set;for(let n of e.publishConfig.executableFiles){if(typeof n!="string"){o.push(new Error("Invalid executable file definition"));continue}this.publishConfig.executableFiles.add($o(n))}}}else this.publishConfig=null;if(typeof e.installConfig=="object"&&e.installConfig!==null){this.installConfig={};for(let n of Object.keys(e.installConfig))n==="hoistingLimits"?typeof e.installConfig.hoistingLimits=="string"?this.installConfig.hoistingLimits=e.installConfig.hoistingLimits:o.push(new Error("Invalid hoisting limits definition")):n=="selfReferences"?typeof e.installConfig.selfReferences=="boolean"?this.installConfig.selfReferences=e.installConfig.selfReferences:o.push(new Error("Invalid selfReferences definition, must be a boolean value")):o.push(new Error(`Unrecognized installConfig key: ${n}`))}else this.installConfig=null;if(typeof e.optionalDependencies=="object"&&e.optionalDependencies!==null)for(let[n,u]of Object.entries(e.optionalDependencies)){if(typeof u!="string"){o.push(new Error(`Invalid dependency range for '${n}'`));continue}let A;try{A=Js(n)}catch{o.push(new Error(`Parsing failed for the dependency name '${n}'`));continue}let p=In(A,u);this.dependencies.set(p.identHash,p);let h=In(A,"unknown"),C=this.ensureDependencyMeta(h);Object.assign(C,{optional:!0})}typeof e.preferUnplugged=="boolean"?this.preferUnplugged=e.preferUnplugged:this.preferUnplugged=null,this.errors=o}getForScope(e){switch(e){case"dependencies":return this.dependencies;case"devDependencies":return this.devDependencies;case"peerDependencies":return this.peerDependencies;default:throw new Error(`Unsupported value ("${e}")`)}}hasConsumerDependency(e){return!!(this.dependencies.has(e.identHash)||this.peerDependencies.has(e.identHash))}hasHardDependency(e){return!!(this.dependencies.has(e.identHash)||this.devDependencies.has(e.identHash))}hasSoftDependency(e){return!!this.peerDependencies.has(e.identHash)}hasDependency(e){return!!(this.hasHardDependency(e)||this.hasSoftDependency(e))}getConditions(){let e=[];return this.os&&this.os.length>0&&e.push(oM("os",this.os)),this.cpu&&this.cpu.length>0&&e.push(oM("cpu",this.cpu)),this.libc&&this.libc.length>0&&e.push(oM("libc",this.libc)),e.length>0?e.join(" & "):null}ensureDependencyMeta(e){if(e.range!=="unknown"&&!dse.default.valid(e.range))throw new Error(`Invalid meta field range for '${Sa(e)}'`);let r=fn(e),o=e.range!=="unknown"?e.range:null,a=this.dependenciesMeta.get(r);a||this.dependenciesMeta.set(r,a=new Map);let n=a.get(o);return n||a.set(o,n={}),n}ensurePeerDependencyMeta(e){if(e.range!=="unknown")throw new Error(`Invalid meta field range for '${Sa(e)}'`);let r=fn(e),o=this.peerDependenciesMeta.get(r);return o||this.peerDependenciesMeta.set(r,o={}),o}setRawField(e,r,{after:o=[]}={}){let a=new Set(o.filter(n=>Object.hasOwn(this.raw,n)));if(a.size===0||Object.hasOwn(this.raw,e))this.raw[e]=r;else{let n=this.raw,u=this.raw={},A=!1;for(let p of Object.keys(n))u[p]=n[p],A||(a.delete(p),a.size===0&&(u[e]=r,A=!0))}}exportTo(e,{compatibilityMode:r=!0}={}){if(Object.assign(e,this.raw),this.name!==null?e.name=fn(this.name):delete e.name,this.version!==null?e.version=this.version:delete e.version,this.os!==null?e.os=this.os:delete e.os,this.cpu!==null?e.cpu=this.cpu:delete e.cpu,this.type!==null?e.type=this.type:delete e.type,this.packageManager!==null?e.packageManager=this.packageManager:delete e.packageManager,this.private?e.private=!0:delete e.private,this.license!==null?e.license=this.license:delete e.license,this.languageName!==null?e.languageName=this.languageName:delete e.languageName,this.main!==null?e.main=this.main:delete e.main,this.module!==null?e.module=this.module:delete e.module,this.browser!==null){let n=this.browser;typeof n=="string"?e.browser=n:n instanceof Map&&(e.browser=Object.assign({},...Array.from(n.keys()).sort().map(u=>({[u]:n.get(u)}))))}else delete e.browser;this.bin.size===1&&this.name!==null&&this.bin.has(this.name.name)?e.bin=this.bin.get(this.name.name):this.bin.size>0?e.bin=Object.assign({},...Array.from(this.bin.keys()).sort().map(n=>({[n]:this.bin.get(n)}))):delete e.bin,this.workspaceDefinitions.length>0?this.raw.workspaces&&!Array.isArray(this.raw.workspaces)?e.workspaces={...this.raw.workspaces,packages:this.workspaceDefinitions.map(({pattern:n})=>n)}:e.workspaces=this.workspaceDefinitions.map(({pattern:n})=>n):this.raw.workspaces&&!Array.isArray(this.raw.workspaces)&&Object.keys(this.raw.workspaces).length>0?e.workspaces=this.raw.workspaces:delete e.workspaces;let o=[],a=[];for(let n of this.dependencies.values()){let u=this.dependenciesMeta.get(fn(n)),A=!1;if(r&&u){let p=u.get(null);p&&p.optional&&(A=!0)}A?a.push(n):o.push(n)}o.length>0?e.dependencies=Object.assign({},...cE(o).map(n=>({[fn(n)]:n.range}))):delete e.dependencies,a.length>0?e.optionalDependencies=Object.assign({},...cE(a).map(n=>({[fn(n)]:n.range}))):delete e.optionalDependencies,this.devDependencies.size>0?e.devDependencies=Object.assign({},...cE(this.devDependencies.values()).map(n=>({[fn(n)]:n.range}))):delete e.devDependencies,this.peerDependencies.size>0?e.peerDependencies=Object.assign({},...cE(this.peerDependencies.values()).map(n=>({[fn(n)]:n.range}))):delete e.peerDependencies,e.dependenciesMeta={};for(let[n,u]of ks(this.dependenciesMeta.entries(),([A,p])=>A))for(let[A,p]of ks(u.entries(),([h,C])=>h!==null?`0${h}`:"1")){let h=A!==null?Sa(In(Js(n),A)):n,C={...p};r&&A===null&&delete C.optional,Object.keys(C).length!==0&&(e.dependenciesMeta[h]=C)}if(Object.keys(e.dependenciesMeta).length===0&&delete e.dependenciesMeta,this.peerDependenciesMeta.size>0?e.peerDependenciesMeta=Object.assign({},...ks(this.peerDependenciesMeta.entries(),([n,u])=>n).map(([n,u])=>({[n]:u}))):delete e.peerDependenciesMeta,this.resolutions.length>0?e.resolutions=Object.assign({},...this.resolutions.map(({pattern:n,reference:u})=>({[_D(n)]:u}))):delete e.resolutions,this.files!==null?e.files=Array.from(this.files):delete e.files,this.preferUnplugged!==null?e.preferUnplugged=this.preferUnplugged:delete e.preferUnplugged,this.scripts!==null&&this.scripts.size>0){e.scripts??={};for(let n of Object.keys(e.scripts))this.scripts.has(n)||delete e.scripts[n];for(let[n,u]of this.scripts.entries())e.scripts[n]=u}else delete e.scripts;return e}},Ot=uE;Ot.fileName="package.json",Ot.allDependencies=["dependencies","devDependencies","peerDependencies"],Ot.hardDependencies=["dependencies","devDependencies"]});var yse=_((TLt,mse)=>{var Xtt=_l(),Ztt=function(){return Xtt.Date.now()};mse.exports=Ztt});var Cse=_((LLt,Ese)=>{var $tt=/\s/;function ert(t){for(var e=t.length;e--&&$tt.test(t.charAt(e)););return e}Ese.exports=ert});var Ise=_((NLt,wse)=>{var trt=Cse(),rrt=/^\s+/;function nrt(t){return t&&t.slice(0,trt(t)+1).replace(rrt,"")}wse.exports=nrt});var fE=_((OLt,Bse)=>{var irt=pd(),srt=zu(),ort="[object Symbol]";function art(t){return typeof t=="symbol"||srt(t)&&irt(t)==ort}Bse.exports=art});var Sse=_((MLt,Pse)=>{var lrt=Ise(),vse=il(),crt=fE(),Dse=0/0,urt=/^[-+]0x[0-9a-f]+$/i,Art=/^0b[01]+$/i,frt=/^0o[0-7]+$/i,prt=parseInt;function hrt(t){if(typeof t=="number")return t;if(crt(t))return Dse;if(vse(t)){var e=typeof t.valueOf=="function"?t.valueOf():t;t=vse(e)?e+"":e}if(typeof t!="string")return t===0?t:+t;t=lrt(t);var r=Art.test(t);return r||frt.test(t)?prt(t.slice(2),r?2:8):urt.test(t)?Dse:+t}Pse.exports=hrt});var kse=_((ULt,bse)=>{var grt=il(),aM=yse(),xse=Sse(),drt="Expected a function",mrt=Math.max,yrt=Math.min;function Ert(t,e,r){var o,a,n,u,A,p,h=0,C=!1,I=!1,v=!0;if(typeof t!="function")throw new TypeError(drt);e=xse(e)||0,grt(r)&&(C=!!r.leading,I="maxWait"in r,n=I?mrt(xse(r.maxWait)||0,e):n,v="trailing"in r?!!r.trailing:v);function b(ue){var ye=o,ae=a;return o=a=void 0,h=ue,u=t.apply(ae,ye),u}function E(ue){return h=ue,A=setTimeout(U,e),C?b(ue):u}function F(ue){var ye=ue-p,ae=ue-h,Ie=e-ye;return I?yrt(Ie,n-ae):Ie}function N(ue){var ye=ue-p,ae=ue-h;return p===void 0||ye>=e||ye<0||I&&ae>=n}function U(){var ue=aM();if(N(ue))return z(ue);A=setTimeout(U,F(ue))}function z(ue){return A=void 0,v&&o?b(ue):(o=a=void 0,u)}function te(){A!==void 0&&clearTimeout(A),h=0,o=p=a=A=void 0}function le(){return A===void 0?u:z(aM())}function pe(){var ue=aM(),ye=N(ue);if(o=arguments,a=this,p=ue,ye){if(A===void 0)return E(p);if(I)return clearTimeout(A),A=setTimeout(U,e),b(p)}return A===void 0&&(A=setTimeout(U,e)),u}return pe.cancel=te,pe.flush=le,pe}bse.exports=Ert});var lM=_((_Lt,Qse)=>{var Crt=kse(),wrt=il(),Irt="Expected a function";function Brt(t,e,r){var o=!0,a=!0;if(typeof t!="function")throw new TypeError(Irt);return wrt(r)&&(o="leading"in r?!!r.leading:o,a="trailing"in r?!!r.trailing:a),Crt(t,e,{leading:o,maxWait:e,trailing:a})}Qse.exports=Brt});function Drt(t){return typeof t.reportCode<"u"}var Fse,Rse,Tse,vrt,Jt,Xs,Yl=yt(()=>{Fse=$e(lM()),Rse=Be("stream"),Tse=Be("string_decoder"),vrt=15,Jt=class extends Error{constructor(r,o,a){super(o);this.reportExtra=a;this.reportCode=r}};Xs=class{constructor(){this.cacheHits=new Set;this.cacheMisses=new Set;this.reportedInfos=new Set;this.reportedWarnings=new Set;this.reportedErrors=new Set}getRecommendedLength(){return 180}reportCacheHit(e){this.cacheHits.add(e.locatorHash)}reportCacheMiss(e,r){this.cacheMisses.add(e.locatorHash)}static progressViaCounter(e){let r=0,o,a=new Promise(p=>{o=p}),n=p=>{let h=o;a=new Promise(C=>{o=C}),r=p,h()},u=(p=0)=>{n(r+1)},A=async function*(){for(;r<e;)await a,yield{progress:r/e}}();return{[Symbol.asyncIterator](){return A},hasProgress:!0,hasTitle:!1,set:n,tick:u}}static progressViaTitle(){let e,r,o=new Promise(u=>{r=u}),a=(0,Fse.default)(u=>{let A=r;o=new Promise(p=>{r=p}),e=u,A()},1e3/vrt),n=async function*(){for(;;)await o,yield{title:e}}();return{[Symbol.asyncIterator](){return n},hasProgress:!1,hasTitle:!0,setTitle:a}}async startProgressPromise(e,r){let o=this.reportProgress(e);try{return await r(e)}finally{o.stop()}}startProgressSync(e,r){let o=this.reportProgress(e);try{return r(e)}finally{o.stop()}}reportInfoOnce(e,r,o){let a=o&&o.key?o.key:r;this.reportedInfos.has(a)||(this.reportedInfos.add(a),this.reportInfo(e,r),o?.reportExtra?.(this))}reportWarningOnce(e,r,o){let a=o&&o.key?o.key:r;this.reportedWarnings.has(a)||(this.reportedWarnings.add(a),this.reportWarning(e,r),o?.reportExtra?.(this))}reportErrorOnce(e,r,o){let a=o&&o.key?o.key:r;this.reportedErrors.has(a)||(this.reportedErrors.add(a),this.reportError(e,r),o?.reportExtra?.(this))}reportExceptionOnce(e){Drt(e)?this.reportErrorOnce(e.reportCode,e.message,{key:e,reportExtra:e.reportExtra}):this.reportErrorOnce(1,e.stack||e.message,{key:e})}createStreamReporter(e=null){let r=new Rse.PassThrough,o=new Tse.StringDecoder,a="";return r.on("data",n=>{let u=o.write(n),A;do if(A=u.indexOf(` -`),A!==-1){let p=a+u.substring(0,A);u=u.substring(A+1),a="",e!==null?this.reportInfo(null,`${e} ${p}`):this.reportInfo(null,p)}while(A!==-1);a+=u}),r.on("end",()=>{let n=o.end();n!==""&&(e!==null?this.reportInfo(null,`${e} ${n}`):this.reportInfo(null,n))}),r}}});var pE,cM=yt(()=>{Yl();xo();pE=class{constructor(e){this.fetchers=e}supports(e,r){return!!this.tryFetcher(e,r)}getLocalPath(e,r){return this.getFetcher(e,r).getLocalPath(e,r)}async fetch(e,r){return await this.getFetcher(e,r).fetch(e,r)}tryFetcher(e,r){let o=this.fetchers.find(a=>a.supports(e,r));return o||null}getFetcher(e,r){let o=this.fetchers.find(a=>a.supports(e,r));if(!o)throw new Jt(11,`${jr(r.project.configuration,e)} isn't supported by any available fetcher`);return o}}});var vd,uM=yt(()=>{xo();vd=class{constructor(e){this.resolvers=e.filter(r=>r)}supportsDescriptor(e,r){return!!this.tryResolverByDescriptor(e,r)}supportsLocator(e,r){return!!this.tryResolverByLocator(e,r)}shouldPersistResolution(e,r){return this.getResolverByLocator(e,r).shouldPersistResolution(e,r)}bindDescriptor(e,r,o){return this.getResolverByDescriptor(e,o).bindDescriptor(e,r,o)}getResolutionDependencies(e,r){return this.getResolverByDescriptor(e,r).getResolutionDependencies(e,r)}async getCandidates(e,r,o){return await this.getResolverByDescriptor(e,o).getCandidates(e,r,o)}async getSatisfying(e,r,o,a){return this.getResolverByDescriptor(e,a).getSatisfying(e,r,o,a)}async resolve(e,r){return await this.getResolverByLocator(e,r).resolve(e,r)}tryResolverByDescriptor(e,r){let o=this.resolvers.find(a=>a.supportsDescriptor(e,r));return o||null}getResolverByDescriptor(e,r){let o=this.resolvers.find(a=>a.supportsDescriptor(e,r));if(!o)throw new Error(`${qn(r.project.configuration,e)} isn't supported by any available resolver`);return o}tryResolverByLocator(e,r){let o=this.resolvers.find(a=>a.supportsLocator(e,r));return o||null}getResolverByLocator(e,r){let o=this.resolvers.find(a=>a.supportsLocator(e,r));if(!o)throw new Error(`${jr(r.project.configuration,e)} isn't supported by any available resolver`);return o}}});var hE,AM=yt(()=>{Pt();xo();hE=class{supports(e){return!!e.reference.startsWith("virtual:")}getLocalPath(e,r){let o=e.reference.indexOf("#");if(o===-1)throw new Error("Invalid virtual package reference");let a=e.reference.slice(o+1),n=Fs(e,a);return r.fetcher.getLocalPath(n,r)}async fetch(e,r){let o=e.reference.indexOf("#");if(o===-1)throw new Error("Invalid virtual package reference");let a=e.reference.slice(o+1),n=Fs(e,a),u=await r.fetcher.fetch(n,r);return await this.ensureVirtualLink(e,u,r)}getLocatorFilename(e){return aE(e)}async ensureVirtualLink(e,r,o){let a=r.packageFs.getRealPath(),n=o.project.configuration.get("virtualFolder"),u=this.getLocatorFilename(e),A=mi.makeVirtualPath(n,u,a),p=new Uu(A,{baseFs:r.packageFs,pathUtils:V});return{...r,packageFs:p}}}});var gE,a1,Lse=yt(()=>{gE=class{static isVirtualDescriptor(e){return!!e.range.startsWith(gE.protocol)}static isVirtualLocator(e){return!!e.reference.startsWith(gE.protocol)}supportsDescriptor(e,r){return gE.isVirtualDescriptor(e)}supportsLocator(e,r){return gE.isVirtualLocator(e)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,o){throw new Error('Assertion failed: calling "bindDescriptor" on a virtual descriptor is unsupported')}getResolutionDependencies(e,r){throw new Error('Assertion failed: calling "getResolutionDependencies" on a virtual descriptor is unsupported')}async getCandidates(e,r,o){throw new Error('Assertion failed: calling "getCandidates" on a virtual descriptor is unsupported')}async getSatisfying(e,r,o,a){throw new Error('Assertion failed: calling "getSatisfying" on a virtual descriptor is unsupported')}async resolve(e,r){throw new Error('Assertion failed: calling "resolve" on a virtual locator is unsupported')}},a1=gE;a1.protocol="virtual:"});var dE,fM=yt(()=>{Pt();Bd();dE=class{supports(e){return!!e.reference.startsWith(Xn.protocol)}getLocalPath(e,r){return this.getWorkspace(e,r).cwd}async fetch(e,r){let o=this.getWorkspace(e,r).cwd;return{packageFs:new gn(o),prefixPath:Bt.dot,localPath:o}}getWorkspace(e,r){return r.project.getWorkspaceByCwd(e.reference.slice(Xn.protocol.length))}}});function l1(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}function Nse(t){return typeof t>"u"?3:l1(t)?0:Array.isArray(t)?1:2}function gM(t,e){return Object.hasOwn(t,e)}function Srt(t){return l1(t)&&gM(t,"onConflict")&&typeof t.onConflict=="string"}function xrt(t){if(typeof t>"u")return{onConflict:"default",value:t};if(!Srt(t))return{onConflict:"default",value:t};if(gM(t,"value"))return t;let{onConflict:e,...r}=t;return{onConflict:e,value:r}}function Ose(t,e){let r=l1(t)&&gM(t,e)?t[e]:void 0;return xrt(r)}function mE(t,e){return[t,e,Mse]}function dM(t){return Array.isArray(t)?t[2]===Mse:!1}function pM(t,e){if(l1(t)){let r={};for(let o of Object.keys(t))r[o]=pM(t[o],e);return mE(e,r)}return Array.isArray(t)?mE(e,t.map(r=>pM(r,e))):mE(e,t)}function hM(t,e,r,o,a){let n,u=[],A=a,p=0;for(let C=a-1;C>=o;--C){let[I,v]=t[C],{onConflict:b,value:E}=Ose(v,r),F=Nse(E);if(F!==3){if(n??=F,F!==n||b==="hardReset"){p=A;break}if(F===2)return mE(I,E);if(u.unshift([I,E]),b==="reset"){p=C;break}b==="extend"&&C===o&&(o=0),A=C}}if(typeof n>"u")return null;let h=u.map(([C])=>C).join(", ");switch(n){case 1:return mE(h,new Array().concat(...u.map(([C,I])=>I.map(v=>pM(v,C)))));case 0:{let C=Object.assign({},...u.map(([,F])=>F)),I=Object.keys(C),v={},b=t.map(([F,N])=>[F,Ose(N,r).value]),E=Prt(b,([F,N])=>{let U=Nse(N);return U!==0&&U!==3});if(E!==-1){let F=b.slice(E+1);for(let N of I)v[N]=hM(F,e,N,0,F.length)}else for(let F of I)v[F]=hM(b,e,F,p,b.length);return mE(h,v)}default:throw new Error("Assertion failed: Non-extendable value type")}}function Use(t){return hM(t.map(([e,r])=>[e,{["."]:r}]),[],".",0,t.length)}function c1(t){return dM(t)?t[1]:t}function GS(t){let e=dM(t)?t[1]:t;if(Array.isArray(e))return e.map(r=>GS(r));if(l1(e)){let r={};for(let[o,a]of Object.entries(e))r[o]=GS(a);return r}return e}function mM(t){return dM(t)?t[0]:null}var Prt,Mse,_se=yt(()=>{Prt=(t,e,r)=>{let o=[...t];return o.reverse(),o.findIndex(e,r)};Mse=Symbol()});var YS={};Vt(YS,{getDefaultGlobalFolder:()=>EM,getHomeFolder:()=>yE,isFolderInside:()=>CM});function EM(){if(process.platform==="win32"){let t=fe.toPortablePath(process.env.LOCALAPPDATA||fe.join((0,yM.homedir)(),"AppData","Local"));return V.resolve(t,"Yarn/Berry")}if(process.env.XDG_DATA_HOME){let t=fe.toPortablePath(process.env.XDG_DATA_HOME);return V.resolve(t,"yarn/berry")}return V.resolve(yE(),".yarn/berry")}function yE(){return fe.toPortablePath((0,yM.homedir)()||"/usr/local/share")}function CM(t,e){let r=V.relative(e,t);return r&&!r.startsWith("..")&&!V.isAbsolute(r)}var yM,WS=yt(()=>{Pt();yM=Be("os")});var Gse=_(EE=>{"use strict";var $Lt=Be("net"),krt=Be("tls"),wM=Be("http"),Hse=Be("https"),Qrt=Be("events"),eNt=Be("assert"),Frt=Be("util");EE.httpOverHttp=Rrt;EE.httpsOverHttp=Trt;EE.httpOverHttps=Lrt;EE.httpsOverHttps=Nrt;function Rrt(t){var e=new kf(t);return e.request=wM.request,e}function Trt(t){var e=new kf(t);return e.request=wM.request,e.createSocket=jse,e.defaultPort=443,e}function Lrt(t){var e=new kf(t);return e.request=Hse.request,e}function Nrt(t){var e=new kf(t);return e.request=Hse.request,e.createSocket=jse,e.defaultPort=443,e}function kf(t){var e=this;e.options=t||{},e.proxyOptions=e.options.proxy||{},e.maxSockets=e.options.maxSockets||wM.Agent.defaultMaxSockets,e.requests=[],e.sockets=[],e.on("free",function(o,a,n,u){for(var A=qse(a,n,u),p=0,h=e.requests.length;p<h;++p){var C=e.requests[p];if(C.host===A.host&&C.port===A.port){e.requests.splice(p,1),C.request.onSocket(o);return}}o.destroy(),e.removeSocket(o)})}Frt.inherits(kf,Qrt.EventEmitter);kf.prototype.addRequest=function(e,r,o,a){var n=this,u=IM({request:e},n.options,qse(r,o,a));if(n.sockets.length>=this.maxSockets){n.requests.push(u);return}n.createSocket(u,function(A){A.on("free",p),A.on("close",h),A.on("agentRemove",h),e.onSocket(A);function p(){n.emit("free",A,u)}function h(C){n.removeSocket(A),A.removeListener("free",p),A.removeListener("close",h),A.removeListener("agentRemove",h)}})};kf.prototype.createSocket=function(e,r){var o=this,a={};o.sockets.push(a);var n=IM({},o.proxyOptions,{method:"CONNECT",path:e.host+":"+e.port,agent:!1,headers:{host:e.host+":"+e.port}});e.localAddress&&(n.localAddress=e.localAddress),n.proxyAuth&&(n.headers=n.headers||{},n.headers["Proxy-Authorization"]="Basic "+new Buffer(n.proxyAuth).toString("base64")),sh("making CONNECT request");var u=o.request(n);u.useChunkedEncodingByDefault=!1,u.once("response",A),u.once("upgrade",p),u.once("connect",h),u.once("error",C),u.end();function A(I){I.upgrade=!0}function p(I,v,b){process.nextTick(function(){h(I,v,b)})}function h(I,v,b){if(u.removeAllListeners(),v.removeAllListeners(),I.statusCode!==200){sh("tunneling socket could not be established, statusCode=%d",I.statusCode),v.destroy();var E=new Error("tunneling socket could not be established, statusCode="+I.statusCode);E.code="ECONNRESET",e.request.emit("error",E),o.removeSocket(a);return}if(b.length>0){sh("got illegal response body from proxy"),v.destroy();var E=new Error("got illegal response body from proxy");E.code="ECONNRESET",e.request.emit("error",E),o.removeSocket(a);return}return sh("tunneling connection has established"),o.sockets[o.sockets.indexOf(a)]=v,r(v)}function C(I){u.removeAllListeners(),sh(`tunneling socket could not be established, cause=%s -`,I.message,I.stack);var v=new Error("tunneling socket could not be established, cause="+I.message);v.code="ECONNRESET",e.request.emit("error",v),o.removeSocket(a)}};kf.prototype.removeSocket=function(e){var r=this.sockets.indexOf(e);if(r!==-1){this.sockets.splice(r,1);var o=this.requests.shift();o&&this.createSocket(o,function(a){o.request.onSocket(a)})}};function jse(t,e){var r=this;kf.prototype.createSocket.call(r,t,function(o){var a=t.request.getHeader("host"),n=IM({},r.options,{socket:o,servername:a?a.replace(/:.*$/,""):t.host}),u=krt.connect(0,n);r.sockets[r.sockets.indexOf(o)]=u,e(u)})}function qse(t,e,r){return typeof t=="string"?{host:t,port:e,localAddress:r}:t}function IM(t){for(var e=1,r=arguments.length;e<r;++e){var o=arguments[e];if(typeof o=="object")for(var a=Object.keys(o),n=0,u=a.length;n<u;++n){var A=a[n];o[A]!==void 0&&(t[A]=o[A])}}return t}var sh;process.env.NODE_DEBUG&&/\btunnel\b/.test(process.env.NODE_DEBUG)?sh=function(){var t=Array.prototype.slice.call(arguments);typeof t[0]=="string"?t[0]="TUNNEL: "+t[0]:t.unshift("TUNNEL:"),console.error.apply(console,t)}:sh=function(){};EE.debug=sh});var Wse=_((rNt,Yse)=>{Yse.exports=Gse()});var Ff=_((Qf,KS)=>{"use strict";Object.defineProperty(Qf,"__esModule",{value:!0});var Kse=["Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Uint16Array","Int32Array","Uint32Array","Float32Array","Float64Array","BigInt64Array","BigUint64Array"];function Ort(t){return Kse.includes(t)}var Mrt=["Function","Generator","AsyncGenerator","GeneratorFunction","AsyncGeneratorFunction","AsyncFunction","Observable","Array","Buffer","Object","RegExp","Date","Error","Map","Set","WeakMap","WeakSet","ArrayBuffer","SharedArrayBuffer","DataView","Promise","URL","FormData","URLSearchParams","HTMLElement",...Kse];function Urt(t){return Mrt.includes(t)}var _rt=["null","undefined","string","number","bigint","boolean","symbol"];function Hrt(t){return _rt.includes(t)}function CE(t){return e=>typeof e===t}var{toString:Vse}=Object.prototype,u1=t=>{let e=Vse.call(t).slice(8,-1);if(/HTML\w+Element/.test(e)&&Se.domElement(t))return"HTMLElement";if(Urt(e))return e},ei=t=>e=>u1(e)===t;function Se(t){if(t===null)return"null";switch(typeof t){case"undefined":return"undefined";case"string":return"string";case"number":return"number";case"boolean":return"boolean";case"function":return"Function";case"bigint":return"bigint";case"symbol":return"symbol";default:}if(Se.observable(t))return"Observable";if(Se.array(t))return"Array";if(Se.buffer(t))return"Buffer";let e=u1(t);if(e)return e;if(t instanceof String||t instanceof Boolean||t instanceof Number)throw new TypeError("Please don't use object wrappers for primitive types");return"Object"}Se.undefined=CE("undefined");Se.string=CE("string");var jrt=CE("number");Se.number=t=>jrt(t)&&!Se.nan(t);Se.bigint=CE("bigint");Se.function_=CE("function");Se.null_=t=>t===null;Se.class_=t=>Se.function_(t)&&t.toString().startsWith("class ");Se.boolean=t=>t===!0||t===!1;Se.symbol=CE("symbol");Se.numericString=t=>Se.string(t)&&!Se.emptyStringOrWhitespace(t)&&!Number.isNaN(Number(t));Se.array=(t,e)=>Array.isArray(t)?Se.function_(e)?t.every(e):!0:!1;Se.buffer=t=>{var e,r,o,a;return(a=(o=(r=(e=t)===null||e===void 0?void 0:e.constructor)===null||r===void 0?void 0:r.isBuffer)===null||o===void 0?void 0:o.call(r,t))!==null&&a!==void 0?a:!1};Se.nullOrUndefined=t=>Se.null_(t)||Se.undefined(t);Se.object=t=>!Se.null_(t)&&(typeof t=="object"||Se.function_(t));Se.iterable=t=>{var e;return Se.function_((e=t)===null||e===void 0?void 0:e[Symbol.iterator])};Se.asyncIterable=t=>{var e;return Se.function_((e=t)===null||e===void 0?void 0:e[Symbol.asyncIterator])};Se.generator=t=>Se.iterable(t)&&Se.function_(t.next)&&Se.function_(t.throw);Se.asyncGenerator=t=>Se.asyncIterable(t)&&Se.function_(t.next)&&Se.function_(t.throw);Se.nativePromise=t=>ei("Promise")(t);var qrt=t=>{var e,r;return Se.function_((e=t)===null||e===void 0?void 0:e.then)&&Se.function_((r=t)===null||r===void 0?void 0:r.catch)};Se.promise=t=>Se.nativePromise(t)||qrt(t);Se.generatorFunction=ei("GeneratorFunction");Se.asyncGeneratorFunction=t=>u1(t)==="AsyncGeneratorFunction";Se.asyncFunction=t=>u1(t)==="AsyncFunction";Se.boundFunction=t=>Se.function_(t)&&!t.hasOwnProperty("prototype");Se.regExp=ei("RegExp");Se.date=ei("Date");Se.error=ei("Error");Se.map=t=>ei("Map")(t);Se.set=t=>ei("Set")(t);Se.weakMap=t=>ei("WeakMap")(t);Se.weakSet=t=>ei("WeakSet")(t);Se.int8Array=ei("Int8Array");Se.uint8Array=ei("Uint8Array");Se.uint8ClampedArray=ei("Uint8ClampedArray");Se.int16Array=ei("Int16Array");Se.uint16Array=ei("Uint16Array");Se.int32Array=ei("Int32Array");Se.uint32Array=ei("Uint32Array");Se.float32Array=ei("Float32Array");Se.float64Array=ei("Float64Array");Se.bigInt64Array=ei("BigInt64Array");Se.bigUint64Array=ei("BigUint64Array");Se.arrayBuffer=ei("ArrayBuffer");Se.sharedArrayBuffer=ei("SharedArrayBuffer");Se.dataView=ei("DataView");Se.directInstanceOf=(t,e)=>Object.getPrototypeOf(t)===e.prototype;Se.urlInstance=t=>ei("URL")(t);Se.urlString=t=>{if(!Se.string(t))return!1;try{return new URL(t),!0}catch{return!1}};Se.truthy=t=>Boolean(t);Se.falsy=t=>!t;Se.nan=t=>Number.isNaN(t);Se.primitive=t=>Se.null_(t)||Hrt(typeof t);Se.integer=t=>Number.isInteger(t);Se.safeInteger=t=>Number.isSafeInteger(t);Se.plainObject=t=>{if(Vse.call(t)!=="[object Object]")return!1;let e=Object.getPrototypeOf(t);return e===null||e===Object.getPrototypeOf({})};Se.typedArray=t=>Ort(u1(t));var Grt=t=>Se.safeInteger(t)&&t>=0;Se.arrayLike=t=>!Se.nullOrUndefined(t)&&!Se.function_(t)&&Grt(t.length);Se.inRange=(t,e)=>{if(Se.number(e))return t>=Math.min(0,e)&&t<=Math.max(e,0);if(Se.array(e)&&e.length===2)return t>=Math.min(...e)&&t<=Math.max(...e);throw new TypeError(`Invalid range: ${JSON.stringify(e)}`)};var Yrt=1,Wrt=["innerHTML","ownerDocument","style","attributes","nodeValue"];Se.domElement=t=>Se.object(t)&&t.nodeType===Yrt&&Se.string(t.nodeName)&&!Se.plainObject(t)&&Wrt.every(e=>e in t);Se.observable=t=>{var e,r,o,a;return t?t===((r=(e=t)[Symbol.observable])===null||r===void 0?void 0:r.call(e))||t===((a=(o=t)["@@observable"])===null||a===void 0?void 0:a.call(o)):!1};Se.nodeStream=t=>Se.object(t)&&Se.function_(t.pipe)&&!Se.observable(t);Se.infinite=t=>t===1/0||t===-1/0;var zse=t=>e=>Se.integer(e)&&Math.abs(e%2)===t;Se.evenInteger=zse(0);Se.oddInteger=zse(1);Se.emptyArray=t=>Se.array(t)&&t.length===0;Se.nonEmptyArray=t=>Se.array(t)&&t.length>0;Se.emptyString=t=>Se.string(t)&&t.length===0;Se.nonEmptyString=t=>Se.string(t)&&t.length>0;var Krt=t=>Se.string(t)&&!/\S/.test(t);Se.emptyStringOrWhitespace=t=>Se.emptyString(t)||Krt(t);Se.emptyObject=t=>Se.object(t)&&!Se.map(t)&&!Se.set(t)&&Object.keys(t).length===0;Se.nonEmptyObject=t=>Se.object(t)&&!Se.map(t)&&!Se.set(t)&&Object.keys(t).length>0;Se.emptySet=t=>Se.set(t)&&t.size===0;Se.nonEmptySet=t=>Se.set(t)&&t.size>0;Se.emptyMap=t=>Se.map(t)&&t.size===0;Se.nonEmptyMap=t=>Se.map(t)&&t.size>0;Se.propertyKey=t=>Se.any([Se.string,Se.number,Se.symbol],t);Se.formData=t=>ei("FormData")(t);Se.urlSearchParams=t=>ei("URLSearchParams")(t);var Jse=(t,e,r)=>{if(!Se.function_(e))throw new TypeError(`Invalid predicate: ${JSON.stringify(e)}`);if(r.length===0)throw new TypeError("Invalid number of values");return t.call(r,e)};Se.any=(t,...e)=>(Se.array(t)?t:[t]).some(o=>Jse(Array.prototype.some,o,e));Se.all=(t,...e)=>Jse(Array.prototype.every,t,e);var Ht=(t,e,r,o={})=>{if(!t){let{multipleValues:a}=o,n=a?`received values of types ${[...new Set(r.map(u=>`\`${Se(u)}\``))].join(", ")}`:`received value of type \`${Se(r)}\``;throw new TypeError(`Expected value which is \`${e}\`, ${n}.`)}};Qf.assert={undefined:t=>Ht(Se.undefined(t),"undefined",t),string:t=>Ht(Se.string(t),"string",t),number:t=>Ht(Se.number(t),"number",t),bigint:t=>Ht(Se.bigint(t),"bigint",t),function_:t=>Ht(Se.function_(t),"Function",t),null_:t=>Ht(Se.null_(t),"null",t),class_:t=>Ht(Se.class_(t),"Class",t),boolean:t=>Ht(Se.boolean(t),"boolean",t),symbol:t=>Ht(Se.symbol(t),"symbol",t),numericString:t=>Ht(Se.numericString(t),"string with a number",t),array:(t,e)=>{Ht(Se.array(t),"Array",t),e&&t.forEach(e)},buffer:t=>Ht(Se.buffer(t),"Buffer",t),nullOrUndefined:t=>Ht(Se.nullOrUndefined(t),"null or undefined",t),object:t=>Ht(Se.object(t),"Object",t),iterable:t=>Ht(Se.iterable(t),"Iterable",t),asyncIterable:t=>Ht(Se.asyncIterable(t),"AsyncIterable",t),generator:t=>Ht(Se.generator(t),"Generator",t),asyncGenerator:t=>Ht(Se.asyncGenerator(t),"AsyncGenerator",t),nativePromise:t=>Ht(Se.nativePromise(t),"native Promise",t),promise:t=>Ht(Se.promise(t),"Promise",t),generatorFunction:t=>Ht(Se.generatorFunction(t),"GeneratorFunction",t),asyncGeneratorFunction:t=>Ht(Se.asyncGeneratorFunction(t),"AsyncGeneratorFunction",t),asyncFunction:t=>Ht(Se.asyncFunction(t),"AsyncFunction",t),boundFunction:t=>Ht(Se.boundFunction(t),"Function",t),regExp:t=>Ht(Se.regExp(t),"RegExp",t),date:t=>Ht(Se.date(t),"Date",t),error:t=>Ht(Se.error(t),"Error",t),map:t=>Ht(Se.map(t),"Map",t),set:t=>Ht(Se.set(t),"Set",t),weakMap:t=>Ht(Se.weakMap(t),"WeakMap",t),weakSet:t=>Ht(Se.weakSet(t),"WeakSet",t),int8Array:t=>Ht(Se.int8Array(t),"Int8Array",t),uint8Array:t=>Ht(Se.uint8Array(t),"Uint8Array",t),uint8ClampedArray:t=>Ht(Se.uint8ClampedArray(t),"Uint8ClampedArray",t),int16Array:t=>Ht(Se.int16Array(t),"Int16Array",t),uint16Array:t=>Ht(Se.uint16Array(t),"Uint16Array",t),int32Array:t=>Ht(Se.int32Array(t),"Int32Array",t),uint32Array:t=>Ht(Se.uint32Array(t),"Uint32Array",t),float32Array:t=>Ht(Se.float32Array(t),"Float32Array",t),float64Array:t=>Ht(Se.float64Array(t),"Float64Array",t),bigInt64Array:t=>Ht(Se.bigInt64Array(t),"BigInt64Array",t),bigUint64Array:t=>Ht(Se.bigUint64Array(t),"BigUint64Array",t),arrayBuffer:t=>Ht(Se.arrayBuffer(t),"ArrayBuffer",t),sharedArrayBuffer:t=>Ht(Se.sharedArrayBuffer(t),"SharedArrayBuffer",t),dataView:t=>Ht(Se.dataView(t),"DataView",t),urlInstance:t=>Ht(Se.urlInstance(t),"URL",t),urlString:t=>Ht(Se.urlString(t),"string with a URL",t),truthy:t=>Ht(Se.truthy(t),"truthy",t),falsy:t=>Ht(Se.falsy(t),"falsy",t),nan:t=>Ht(Se.nan(t),"NaN",t),primitive:t=>Ht(Se.primitive(t),"primitive",t),integer:t=>Ht(Se.integer(t),"integer",t),safeInteger:t=>Ht(Se.safeInteger(t),"integer",t),plainObject:t=>Ht(Se.plainObject(t),"plain object",t),typedArray:t=>Ht(Se.typedArray(t),"TypedArray",t),arrayLike:t=>Ht(Se.arrayLike(t),"array-like",t),domElement:t=>Ht(Se.domElement(t),"HTMLElement",t),observable:t=>Ht(Se.observable(t),"Observable",t),nodeStream:t=>Ht(Se.nodeStream(t),"Node.js Stream",t),infinite:t=>Ht(Se.infinite(t),"infinite number",t),emptyArray:t=>Ht(Se.emptyArray(t),"empty array",t),nonEmptyArray:t=>Ht(Se.nonEmptyArray(t),"non-empty array",t),emptyString:t=>Ht(Se.emptyString(t),"empty string",t),nonEmptyString:t=>Ht(Se.nonEmptyString(t),"non-empty string",t),emptyStringOrWhitespace:t=>Ht(Se.emptyStringOrWhitespace(t),"empty string or whitespace",t),emptyObject:t=>Ht(Se.emptyObject(t),"empty object",t),nonEmptyObject:t=>Ht(Se.nonEmptyObject(t),"non-empty object",t),emptySet:t=>Ht(Se.emptySet(t),"empty set",t),nonEmptySet:t=>Ht(Se.nonEmptySet(t),"non-empty set",t),emptyMap:t=>Ht(Se.emptyMap(t),"empty map",t),nonEmptyMap:t=>Ht(Se.nonEmptyMap(t),"non-empty map",t),propertyKey:t=>Ht(Se.propertyKey(t),"PropertyKey",t),formData:t=>Ht(Se.formData(t),"FormData",t),urlSearchParams:t=>Ht(Se.urlSearchParams(t),"URLSearchParams",t),evenInteger:t=>Ht(Se.evenInteger(t),"even integer",t),oddInteger:t=>Ht(Se.oddInteger(t),"odd integer",t),directInstanceOf:(t,e)=>Ht(Se.directInstanceOf(t,e),"T",t),inRange:(t,e)=>Ht(Se.inRange(t,e),"in range",t),any:(t,...e)=>Ht(Se.any(t,...e),"predicate returns truthy for any value",e,{multipleValues:!0}),all:(t,...e)=>Ht(Se.all(t,...e),"predicate returns truthy for all values",e,{multipleValues:!0})};Object.defineProperties(Se,{class:{value:Se.class_},function:{value:Se.function_},null:{value:Se.null_}});Object.defineProperties(Qf.assert,{class:{value:Qf.assert.class_},function:{value:Qf.assert.function_},null:{value:Qf.assert.null_}});Qf.default=Se;KS.exports=Se;KS.exports.default=Se;KS.exports.assert=Qf.assert});var Xse=_((nNt,BM)=>{"use strict";var VS=class extends Error{constructor(e){super(e||"Promise was canceled"),this.name="CancelError"}get isCanceled(){return!0}},wE=class{static fn(e){return(...r)=>new wE((o,a,n)=>{r.push(n),e(...r).then(o,a)})}constructor(e){this._cancelHandlers=[],this._isPending=!0,this._isCanceled=!1,this._rejectOnCancel=!0,this._promise=new Promise((r,o)=>{this._reject=o;let a=A=>{this._isPending=!1,r(A)},n=A=>{this._isPending=!1,o(A)},u=A=>{if(!this._isPending)throw new Error("The `onCancel` handler was attached after the promise settled.");this._cancelHandlers.push(A)};return Object.defineProperties(u,{shouldReject:{get:()=>this._rejectOnCancel,set:A=>{this._rejectOnCancel=A}}}),e(a,n,u)})}then(e,r){return this._promise.then(e,r)}catch(e){return this._promise.catch(e)}finally(e){return this._promise.finally(e)}cancel(e){if(!(!this._isPending||this._isCanceled)){if(this._cancelHandlers.length>0)try{for(let r of this._cancelHandlers)r()}catch(r){this._reject(r)}this._isCanceled=!0,this._rejectOnCancel&&this._reject(new VS(e))}}get isCanceled(){return this._isCanceled}};Object.setPrototypeOf(wE.prototype,Promise.prototype);BM.exports=wE;BM.exports.CancelError=VS});var Zse=_((DM,PM)=>{"use strict";Object.defineProperty(DM,"__esModule",{value:!0});var Vrt=Be("tls"),vM=(t,e)=>{let r;typeof e=="function"?r={connect:e}:r=e;let o=typeof r.connect=="function",a=typeof r.secureConnect=="function",n=typeof r.close=="function",u=()=>{o&&r.connect(),t instanceof Vrt.TLSSocket&&a&&(t.authorized?r.secureConnect():t.authorizationError||t.once("secureConnect",r.secureConnect)),n&&t.once("close",r.close)};t.writable&&!t.connecting?u():t.connecting?t.once("connect",u):t.destroyed&&n&&r.close(t._hadError)};DM.default=vM;PM.exports=vM;PM.exports.default=vM});var $se=_((xM,bM)=>{"use strict";Object.defineProperty(xM,"__esModule",{value:!0});var zrt=Zse(),Jrt=Number(process.versions.node.split(".")[0]),SM=t=>{let e={start:Date.now(),socket:void 0,lookup:void 0,connect:void 0,secureConnect:void 0,upload:void 0,response:void 0,end:void 0,error:void 0,abort:void 0,phases:{wait:void 0,dns:void 0,tcp:void 0,tls:void 0,request:void 0,firstByte:void 0,download:void 0,total:void 0}};t.timings=e;let r=u=>{let A=u.emit.bind(u);u.emit=(p,...h)=>(p==="error"&&(e.error=Date.now(),e.phases.total=e.error-e.start,u.emit=A),A(p,...h))};r(t),t.prependOnceListener("abort",()=>{e.abort=Date.now(),(!e.response||Jrt>=13)&&(e.phases.total=Date.now()-e.start)});let o=u=>{e.socket=Date.now(),e.phases.wait=e.socket-e.start;let A=()=>{e.lookup=Date.now(),e.phases.dns=e.lookup-e.socket};u.prependOnceListener("lookup",A),zrt.default(u,{connect:()=>{e.connect=Date.now(),e.lookup===void 0&&(u.removeListener("lookup",A),e.lookup=e.connect,e.phases.dns=e.lookup-e.socket),e.phases.tcp=e.connect-e.lookup},secureConnect:()=>{e.secureConnect=Date.now(),e.phases.tls=e.secureConnect-e.connect}})};t.socket?o(t.socket):t.prependOnceListener("socket",o);let a=()=>{var u;e.upload=Date.now(),e.phases.request=e.upload-(u=e.secureConnect,u??e.connect)};return(()=>typeof t.writableFinished=="boolean"?t.writableFinished:t.finished&&t.outputSize===0&&(!t.socket||t.socket.writableLength===0))()?a():t.prependOnceListener("finish",a),t.prependOnceListener("response",u=>{e.response=Date.now(),e.phases.firstByte=e.response-e.upload,u.timings=e,r(u),u.prependOnceListener("end",()=>{e.end=Date.now(),e.phases.download=e.end-e.response,e.phases.total=e.end-e.start})}),e};xM.default=SM;bM.exports=SM;bM.exports.default=SM});var ooe=_((iNt,FM)=>{"use strict";var{V4MAPPED:Xrt,ADDRCONFIG:Zrt,ALL:soe,promises:{Resolver:eoe},lookup:$rt}=Be("dns"),{promisify:kM}=Be("util"),ent=Be("os"),IE=Symbol("cacheableLookupCreateConnection"),QM=Symbol("cacheableLookupInstance"),toe=Symbol("expires"),tnt=typeof soe=="number",roe=t=>{if(!(t&&typeof t.createConnection=="function"))throw new Error("Expected an Agent instance as the first argument")},rnt=t=>{for(let e of t)e.family!==6&&(e.address=`::ffff:${e.address}`,e.family=6)},noe=()=>{let t=!1,e=!1;for(let r of Object.values(ent.networkInterfaces()))for(let o of r)if(!o.internal&&(o.family==="IPv6"?e=!0:t=!0,t&&e))return{has4:t,has6:e};return{has4:t,has6:e}},nnt=t=>Symbol.iterator in t,ioe={ttl:!0},int={all:!0},zS=class{constructor({cache:e=new Map,maxTtl:r=1/0,fallbackDuration:o=3600,errorTtl:a=.15,resolver:n=new eoe,lookup:u=$rt}={}){if(this.maxTtl=r,this.errorTtl=a,this._cache=e,this._resolver=n,this._dnsLookup=kM(u),this._resolver instanceof eoe?(this._resolve4=this._resolver.resolve4.bind(this._resolver),this._resolve6=this._resolver.resolve6.bind(this._resolver)):(this._resolve4=kM(this._resolver.resolve4.bind(this._resolver)),this._resolve6=kM(this._resolver.resolve6.bind(this._resolver))),this._iface=noe(),this._pending={},this._nextRemovalTime=!1,this._hostnamesToFallback=new Set,o<1)this._fallback=!1;else{this._fallback=!0;let A=setInterval(()=>{this._hostnamesToFallback.clear()},o*1e3);A.unref&&A.unref()}this.lookup=this.lookup.bind(this),this.lookupAsync=this.lookupAsync.bind(this)}set servers(e){this.clear(),this._resolver.setServers(e)}get servers(){return this._resolver.getServers()}lookup(e,r,o){if(typeof r=="function"?(o=r,r={}):typeof r=="number"&&(r={family:r}),!o)throw new Error("Callback must be a function.");this.lookupAsync(e,r).then(a=>{r.all?o(null,a):o(null,a.address,a.family,a.expires,a.ttl)},o)}async lookupAsync(e,r={}){typeof r=="number"&&(r={family:r});let o=await this.query(e);if(r.family===6){let a=o.filter(n=>n.family===6);r.hints&Xrt&&(tnt&&r.hints&soe||a.length===0)?rnt(o):o=a}else r.family===4&&(o=o.filter(a=>a.family===4));if(r.hints&Zrt){let{_iface:a}=this;o=o.filter(n=>n.family===6?a.has6:a.has4)}if(o.length===0){let a=new Error(`cacheableLookup ENOTFOUND ${e}`);throw a.code="ENOTFOUND",a.hostname=e,a}return r.all?o:o[0]}async query(e){let r=await this._cache.get(e);if(!r){let o=this._pending[e];if(o)r=await o;else{let a=this.queryAndCache(e);this._pending[e]=a,r=await a}}return r=r.map(o=>({...o})),r}async _resolve(e){let r=async h=>{try{return await h}catch(C){if(C.code==="ENODATA"||C.code==="ENOTFOUND")return[];throw C}},[o,a]=await Promise.all([this._resolve4(e,ioe),this._resolve6(e,ioe)].map(h=>r(h))),n=0,u=0,A=0,p=Date.now();for(let h of o)h.family=4,h.expires=p+h.ttl*1e3,n=Math.max(n,h.ttl);for(let h of a)h.family=6,h.expires=p+h.ttl*1e3,u=Math.max(u,h.ttl);return o.length>0?a.length>0?A=Math.min(n,u):A=n:A=u,{entries:[...o,...a],cacheTtl:A}}async _lookup(e){try{return{entries:await this._dnsLookup(e,{all:!0}),cacheTtl:0}}catch{return{entries:[],cacheTtl:0}}}async _set(e,r,o){if(this.maxTtl>0&&o>0){o=Math.min(o,this.maxTtl)*1e3,r[toe]=Date.now()+o;try{await this._cache.set(e,r,o)}catch(a){this.lookupAsync=async()=>{let n=new Error("Cache Error. Please recreate the CacheableLookup instance.");throw n.cause=a,n}}nnt(this._cache)&&this._tick(o)}}async queryAndCache(e){if(this._hostnamesToFallback.has(e))return this._dnsLookup(e,int);try{let r=await this._resolve(e);r.entries.length===0&&this._fallback&&(r=await this._lookup(e),r.entries.length!==0&&this._hostnamesToFallback.add(e));let o=r.entries.length===0?this.errorTtl:r.cacheTtl;return await this._set(e,r.entries,o),delete this._pending[e],r.entries}catch(r){throw delete this._pending[e],r}}_tick(e){let r=this._nextRemovalTime;(!r||e<r)&&(clearTimeout(this._removalTimeout),this._nextRemovalTime=e,this._removalTimeout=setTimeout(()=>{this._nextRemovalTime=!1;let o=1/0,a=Date.now();for(let[n,u]of this._cache){let A=u[toe];a>=A?this._cache.delete(n):A<o&&(o=A)}o!==1/0&&this._tick(o-a)},e),this._removalTimeout.unref&&this._removalTimeout.unref())}install(e){if(roe(e),IE in e)throw new Error("CacheableLookup has been already installed");e[IE]=e.createConnection,e[QM]=this,e.createConnection=(r,o)=>("lookup"in r||(r.lookup=this.lookup),e[IE](r,o))}uninstall(e){if(roe(e),e[IE]){if(e[QM]!==this)throw new Error("The agent is not owned by this CacheableLookup instance");e.createConnection=e[IE],delete e[IE],delete e[QM]}}updateInterfaceInfo(){let{_iface:e}=this;this._iface=noe(),(e.has4&&!this._iface.has4||e.has6&&!this._iface.has6)&&this._cache.clear()}clear(e){if(e){this._cache.delete(e);return}this._cache.clear()}};FM.exports=zS;FM.exports.default=zS});var coe=_((sNt,RM)=>{"use strict";var snt=typeof URL>"u"?Be("url").URL:URL,ont="text/plain",ant="us-ascii",aoe=(t,e)=>e.some(r=>r instanceof RegExp?r.test(t):r===t),lnt=(t,{stripHash:e})=>{let r=t.match(/^data:([^,]*?),([^#]*?)(?:#(.*))?$/);if(!r)throw new Error(`Invalid URL: ${t}`);let o=r[1].split(";"),a=r[2],n=e?"":r[3],u=!1;o[o.length-1]==="base64"&&(o.pop(),u=!0);let A=(o.shift()||"").toLowerCase(),h=[...o.map(C=>{let[I,v=""]=C.split("=").map(b=>b.trim());return I==="charset"&&(v=v.toLowerCase(),v===ant)?"":`${I}${v?`=${v}`:""}`}).filter(Boolean)];return u&&h.push("base64"),(h.length!==0||A&&A!==ont)&&h.unshift(A),`data:${h.join(";")},${u?a.trim():a}${n?`#${n}`:""}`},loe=(t,e)=>{if(e={defaultProtocol:"http:",normalizeProtocol:!0,forceHttp:!1,forceHttps:!1,stripAuthentication:!0,stripHash:!1,stripWWW:!0,removeQueryParameters:[/^utm_\w+/i],removeTrailingSlash:!0,removeDirectoryIndex:!1,sortQueryParameters:!0,...e},Reflect.has(e,"normalizeHttps"))throw new Error("options.normalizeHttps is renamed to options.forceHttp");if(Reflect.has(e,"normalizeHttp"))throw new Error("options.normalizeHttp is renamed to options.forceHttps");if(Reflect.has(e,"stripFragment"))throw new Error("options.stripFragment is renamed to options.stripHash");if(t=t.trim(),/^data:/i.test(t))return lnt(t,e);let r=t.startsWith("//");!r&&/^\.*\//.test(t)||(t=t.replace(/^(?!(?:\w+:)?\/\/)|^\/\//,e.defaultProtocol));let a=new snt(t);if(e.forceHttp&&e.forceHttps)throw new Error("The `forceHttp` and `forceHttps` options cannot be used together");if(e.forceHttp&&a.protocol==="https:"&&(a.protocol="http:"),e.forceHttps&&a.protocol==="http:"&&(a.protocol="https:"),e.stripAuthentication&&(a.username="",a.password=""),e.stripHash&&(a.hash=""),a.pathname&&(a.pathname=a.pathname.replace(/((?!:).|^)\/{2,}/g,(n,u)=>/^(?!\/)/g.test(u)?`${u}/`:"/")),a.pathname&&(a.pathname=decodeURI(a.pathname)),e.removeDirectoryIndex===!0&&(e.removeDirectoryIndex=[/^index\.[a-z]+$/]),Array.isArray(e.removeDirectoryIndex)&&e.removeDirectoryIndex.length>0){let n=a.pathname.split("/"),u=n[n.length-1];aoe(u,e.removeDirectoryIndex)&&(n=n.slice(0,n.length-1),a.pathname=n.slice(1).join("/")+"/")}if(a.hostname&&(a.hostname=a.hostname.replace(/\.$/,""),e.stripWWW&&/^www\.([a-z\-\d]{2,63})\.([a-z.]{2,5})$/.test(a.hostname)&&(a.hostname=a.hostname.replace(/^www\./,""))),Array.isArray(e.removeQueryParameters))for(let n of[...a.searchParams.keys()])aoe(n,e.removeQueryParameters)&&a.searchParams.delete(n);return e.sortQueryParameters&&a.searchParams.sort(),e.removeTrailingSlash&&(a.pathname=a.pathname.replace(/\/$/,"")),t=a.toString(),(e.removeTrailingSlash||a.pathname==="/")&&a.hash===""&&(t=t.replace(/\/$/,"")),r&&!e.normalizeProtocol&&(t=t.replace(/^http:\/\//,"//")),e.stripProtocol&&(t=t.replace(/^(?:https?:)?\/\//,"")),t};RM.exports=loe;RM.exports.default=loe});var foe=_((oNt,Aoe)=>{Aoe.exports=uoe;function uoe(t,e){if(t&&e)return uoe(t)(e);if(typeof t!="function")throw new TypeError("need wrapper function");return Object.keys(t).forEach(function(o){r[o]=t[o]}),r;function r(){for(var o=new Array(arguments.length),a=0;a<o.length;a++)o[a]=arguments[a];var n=t.apply(this,o),u=o[o.length-1];return typeof n=="function"&&n!==u&&Object.keys(u).forEach(function(A){n[A]=u[A]}),n}}});var LM=_((aNt,TM)=>{var poe=foe();TM.exports=poe(JS);TM.exports.strict=poe(hoe);JS.proto=JS(function(){Object.defineProperty(Function.prototype,"once",{value:function(){return JS(this)},configurable:!0}),Object.defineProperty(Function.prototype,"onceStrict",{value:function(){return hoe(this)},configurable:!0})});function JS(t){var e=function(){return e.called?e.value:(e.called=!0,e.value=t.apply(this,arguments))};return e.called=!1,e}function hoe(t){var e=function(){if(e.called)throw new Error(e.onceError);return e.called=!0,e.value=t.apply(this,arguments)},r=t.name||"Function wrapped with `once`";return e.onceError=r+" shouldn't be called more than once",e.called=!1,e}});var NM=_((lNt,doe)=>{var cnt=LM(),unt=function(){},Ant=function(t){return t.setHeader&&typeof t.abort=="function"},fnt=function(t){return t.stdio&&Array.isArray(t.stdio)&&t.stdio.length===3},goe=function(t,e,r){if(typeof e=="function")return goe(t,null,e);e||(e={}),r=cnt(r||unt);var o=t._writableState,a=t._readableState,n=e.readable||e.readable!==!1&&t.readable,u=e.writable||e.writable!==!1&&t.writable,A=function(){t.writable||p()},p=function(){u=!1,n||r.call(t)},h=function(){n=!1,u||r.call(t)},C=function(E){r.call(t,E?new Error("exited with error code: "+E):null)},I=function(E){r.call(t,E)},v=function(){if(n&&!(a&&a.ended))return r.call(t,new Error("premature close"));if(u&&!(o&&o.ended))return r.call(t,new Error("premature close"))},b=function(){t.req.on("finish",p)};return Ant(t)?(t.on("complete",p),t.on("abort",v),t.req?b():t.on("request",b)):u&&!o&&(t.on("end",A),t.on("close",A)),fnt(t)&&t.on("exit",C),t.on("end",h),t.on("finish",p),e.error!==!1&&t.on("error",I),t.on("close",v),function(){t.removeListener("complete",p),t.removeListener("abort",v),t.removeListener("request",b),t.req&&t.req.removeListener("finish",p),t.removeListener("end",A),t.removeListener("close",A),t.removeListener("finish",p),t.removeListener("exit",C),t.removeListener("end",h),t.removeListener("error",I),t.removeListener("close",v)}};doe.exports=goe});var Eoe=_((cNt,yoe)=>{var pnt=LM(),hnt=NM(),OM=Be("fs"),A1=function(){},gnt=/^v?\.0/.test(process.version),XS=function(t){return typeof t=="function"},dnt=function(t){return!gnt||!OM?!1:(t instanceof(OM.ReadStream||A1)||t instanceof(OM.WriteStream||A1))&&XS(t.close)},mnt=function(t){return t.setHeader&&XS(t.abort)},ynt=function(t,e,r,o){o=pnt(o);var a=!1;t.on("close",function(){a=!0}),hnt(t,{readable:e,writable:r},function(u){if(u)return o(u);a=!0,o()});var n=!1;return function(u){if(!a&&!n){if(n=!0,dnt(t))return t.close(A1);if(mnt(t))return t.abort();if(XS(t.destroy))return t.destroy();o(u||new Error("stream was destroyed"))}}},moe=function(t){t()},Ent=function(t,e){return t.pipe(e)},Cnt=function(){var t=Array.prototype.slice.call(arguments),e=XS(t[t.length-1]||A1)&&t.pop()||A1;if(Array.isArray(t[0])&&(t=t[0]),t.length<2)throw new Error("pump requires two streams per minimum");var r,o=t.map(function(a,n){var u=n<t.length-1,A=n>0;return ynt(a,u,A,function(p){r||(r=p),p&&o.forEach(moe),!u&&(o.forEach(moe),e(r))})});return t.reduce(Ent)};yoe.exports=Cnt});var woe=_((uNt,Coe)=>{"use strict";var{PassThrough:wnt}=Be("stream");Coe.exports=t=>{t={...t};let{array:e}=t,{encoding:r}=t,o=r==="buffer",a=!1;e?a=!(r||o):r=r||"utf8",o&&(r=null);let n=new wnt({objectMode:a});r&&n.setEncoding(r);let u=0,A=[];return n.on("data",p=>{A.push(p),a?u=A.length:u+=p.length}),n.getBufferedValue=()=>e?A:o?Buffer.concat(A,u):A.join(""),n.getBufferedLength=()=>u,n}});var Ioe=_((ANt,BE)=>{"use strict";var Int=Eoe(),Bnt=woe(),ZS=class extends Error{constructor(){super("maxBuffer exceeded"),this.name="MaxBufferError"}};async function $S(t,e){if(!t)return Promise.reject(new Error("Expected a stream"));e={maxBuffer:1/0,...e};let{maxBuffer:r}=e,o;return await new Promise((a,n)=>{let u=A=>{A&&(A.bufferedData=o.getBufferedValue()),n(A)};o=Int(t,Bnt(e),A=>{if(A){u(A);return}a()}),o.on("data",()=>{o.getBufferedLength()>r&&u(new ZS)})}),o.getBufferedValue()}BE.exports=$S;BE.exports.default=$S;BE.exports.buffer=(t,e)=>$S(t,{...e,encoding:"buffer"});BE.exports.array=(t,e)=>$S(t,{...e,array:!0});BE.exports.MaxBufferError=ZS});var voe=_((pNt,Boe)=>{"use strict";var vnt=new Set([200,203,204,206,300,301,404,405,410,414,501]),Dnt=new Set([200,203,204,300,301,302,303,307,308,404,405,410,414,501]),Pnt=new Set([500,502,503,504]),Snt={date:!0,connection:!0,"keep-alive":!0,"proxy-authenticate":!0,"proxy-authorization":!0,te:!0,trailer:!0,"transfer-encoding":!0,upgrade:!0},xnt={"content-length":!0,"content-encoding":!0,"transfer-encoding":!0,"content-range":!0};function Dd(t){let e=parseInt(t,10);return isFinite(e)?e:0}function bnt(t){return t?Pnt.has(t.status):!0}function MM(t){let e={};if(!t)return e;let r=t.trim().split(/\s*,\s*/);for(let o of r){let[a,n]=o.split(/\s*=\s*/,2);e[a]=n===void 0?!0:n.replace(/^"|"$/g,"")}return e}function knt(t){let e=[];for(let r in t){let o=t[r];e.push(o===!0?r:r+"="+o)}if(!!e.length)return e.join(", ")}Boe.exports=class{constructor(e,r,{shared:o,cacheHeuristic:a,immutableMinTimeToLive:n,ignoreCargoCult:u,_fromObject:A}={}){if(A){this._fromObject(A);return}if(!r||!r.headers)throw Error("Response headers missing");this._assertRequestHasHeaders(e),this._responseTime=this.now(),this._isShared=o!==!1,this._cacheHeuristic=a!==void 0?a:.1,this._immutableMinTtl=n!==void 0?n:24*3600*1e3,this._status="status"in r?r.status:200,this._resHeaders=r.headers,this._rescc=MM(r.headers["cache-control"]),this._method="method"in e?e.method:"GET",this._url=e.url,this._host=e.headers.host,this._noAuthorization=!e.headers.authorization,this._reqHeaders=r.headers.vary?e.headers:null,this._reqcc=MM(e.headers["cache-control"]),u&&"pre-check"in this._rescc&&"post-check"in this._rescc&&(delete this._rescc["pre-check"],delete this._rescc["post-check"],delete this._rescc["no-cache"],delete this._rescc["no-store"],delete this._rescc["must-revalidate"],this._resHeaders=Object.assign({},this._resHeaders,{"cache-control":knt(this._rescc)}),delete this._resHeaders.expires,delete this._resHeaders.pragma),r.headers["cache-control"]==null&&/no-cache/.test(r.headers.pragma)&&(this._rescc["no-cache"]=!0)}now(){return Date.now()}storable(){return!!(!this._reqcc["no-store"]&&(this._method==="GET"||this._method==="HEAD"||this._method==="POST"&&this._hasExplicitExpiration())&&Dnt.has(this._status)&&!this._rescc["no-store"]&&(!this._isShared||!this._rescc.private)&&(!this._isShared||this._noAuthorization||this._allowsStoringAuthenticated())&&(this._resHeaders.expires||this._rescc["max-age"]||this._isShared&&this._rescc["s-maxage"]||this._rescc.public||vnt.has(this._status)))}_hasExplicitExpiration(){return this._isShared&&this._rescc["s-maxage"]||this._rescc["max-age"]||this._resHeaders.expires}_assertRequestHasHeaders(e){if(!e||!e.headers)throw Error("Request headers missing")}satisfiesWithoutRevalidation(e){this._assertRequestHasHeaders(e);let r=MM(e.headers["cache-control"]);return r["no-cache"]||/no-cache/.test(e.headers.pragma)||r["max-age"]&&this.age()>r["max-age"]||r["min-fresh"]&&this.timeToLive()<1e3*r["min-fresh"]||this.stale()&&!(r["max-stale"]&&!this._rescc["must-revalidate"]&&(r["max-stale"]===!0||r["max-stale"]>this.age()-this.maxAge()))?!1:this._requestMatches(e,!1)}_requestMatches(e,r){return(!this._url||this._url===e.url)&&this._host===e.headers.host&&(!e.method||this._method===e.method||r&&e.method==="HEAD")&&this._varyMatches(e)}_allowsStoringAuthenticated(){return this._rescc["must-revalidate"]||this._rescc.public||this._rescc["s-maxage"]}_varyMatches(e){if(!this._resHeaders.vary)return!0;if(this._resHeaders.vary==="*")return!1;let r=this._resHeaders.vary.trim().toLowerCase().split(/\s*,\s*/);for(let o of r)if(e.headers[o]!==this._reqHeaders[o])return!1;return!0}_copyWithoutHopByHopHeaders(e){let r={};for(let o in e)Snt[o]||(r[o]=e[o]);if(e.connection){let o=e.connection.trim().split(/\s*,\s*/);for(let a of o)delete r[a]}if(r.warning){let o=r.warning.split(/,/).filter(a=>!/^\s*1[0-9][0-9]/.test(a));o.length?r.warning=o.join(",").trim():delete r.warning}return r}responseHeaders(){let e=this._copyWithoutHopByHopHeaders(this._resHeaders),r=this.age();return r>3600*24&&!this._hasExplicitExpiration()&&this.maxAge()>3600*24&&(e.warning=(e.warning?`${e.warning}, `:"")+'113 - "rfc7234 5.5.4"'),e.age=`${Math.round(r)}`,e.date=new Date(this.now()).toUTCString(),e}date(){let e=Date.parse(this._resHeaders.date);return isFinite(e)?e:this._responseTime}age(){let e=this._ageValue(),r=(this.now()-this._responseTime)/1e3;return e+r}_ageValue(){return Dd(this._resHeaders.age)}maxAge(){if(!this.storable()||this._rescc["no-cache"]||this._isShared&&this._resHeaders["set-cookie"]&&!this._rescc.public&&!this._rescc.immutable||this._resHeaders.vary==="*")return 0;if(this._isShared){if(this._rescc["proxy-revalidate"])return 0;if(this._rescc["s-maxage"])return Dd(this._rescc["s-maxage"])}if(this._rescc["max-age"])return Dd(this._rescc["max-age"]);let e=this._rescc.immutable?this._immutableMinTtl:0,r=this.date();if(this._resHeaders.expires){let o=Date.parse(this._resHeaders.expires);return Number.isNaN(o)||o<r?0:Math.max(e,(o-r)/1e3)}if(this._resHeaders["last-modified"]){let o=Date.parse(this._resHeaders["last-modified"]);if(isFinite(o)&&r>o)return Math.max(e,(r-o)/1e3*this._cacheHeuristic)}return e}timeToLive(){let e=this.maxAge()-this.age(),r=e+Dd(this._rescc["stale-if-error"]),o=e+Dd(this._rescc["stale-while-revalidate"]);return Math.max(0,e,r,o)*1e3}stale(){return this.maxAge()<=this.age()}_useStaleIfError(){return this.maxAge()+Dd(this._rescc["stale-if-error"])>this.age()}useStaleWhileRevalidate(){return this.maxAge()+Dd(this._rescc["stale-while-revalidate"])>this.age()}static fromObject(e){return new this(void 0,void 0,{_fromObject:e})}_fromObject(e){if(this._responseTime)throw Error("Reinitialized");if(!e||e.v!==1)throw Error("Invalid serialization");this._responseTime=e.t,this._isShared=e.sh,this._cacheHeuristic=e.ch,this._immutableMinTtl=e.imm!==void 0?e.imm:24*3600*1e3,this._status=e.st,this._resHeaders=e.resh,this._rescc=e.rescc,this._method=e.m,this._url=e.u,this._host=e.h,this._noAuthorization=e.a,this._reqHeaders=e.reqh,this._reqcc=e.reqcc}toObject(){return{v:1,t:this._responseTime,sh:this._isShared,ch:this._cacheHeuristic,imm:this._immutableMinTtl,st:this._status,resh:this._resHeaders,rescc:this._rescc,m:this._method,u:this._url,h:this._host,a:this._noAuthorization,reqh:this._reqHeaders,reqcc:this._reqcc}}revalidationHeaders(e){this._assertRequestHasHeaders(e);let r=this._copyWithoutHopByHopHeaders(e.headers);if(delete r["if-range"],!this._requestMatches(e,!0)||!this.storable())return delete r["if-none-match"],delete r["if-modified-since"],r;if(this._resHeaders.etag&&(r["if-none-match"]=r["if-none-match"]?`${r["if-none-match"]}, ${this._resHeaders.etag}`:this._resHeaders.etag),r["accept-ranges"]||r["if-match"]||r["if-unmodified-since"]||this._method&&this._method!="GET"){if(delete r["if-modified-since"],r["if-none-match"]){let a=r["if-none-match"].split(/,/).filter(n=>!/^\s*W\//.test(n));a.length?r["if-none-match"]=a.join(",").trim():delete r["if-none-match"]}}else this._resHeaders["last-modified"]&&!r["if-modified-since"]&&(r["if-modified-since"]=this._resHeaders["last-modified"]);return r}revalidatedPolicy(e,r){if(this._assertRequestHasHeaders(e),this._useStaleIfError()&&bnt(r))return{modified:!1,matches:!1,policy:this};if(!r||!r.headers)throw Error("Response headers missing");let o=!1;if(r.status!==void 0&&r.status!=304?o=!1:r.headers.etag&&!/^\s*W\//.test(r.headers.etag)?o=this._resHeaders.etag&&this._resHeaders.etag.replace(/^\s*W\//,"")===r.headers.etag:this._resHeaders.etag&&r.headers.etag?o=this._resHeaders.etag.replace(/^\s*W\//,"")===r.headers.etag.replace(/^\s*W\//,""):this._resHeaders["last-modified"]?o=this._resHeaders["last-modified"]===r.headers["last-modified"]:!this._resHeaders.etag&&!this._resHeaders["last-modified"]&&!r.headers.etag&&!r.headers["last-modified"]&&(o=!0),!o)return{policy:new this.constructor(e,r),modified:r.status!=304,matches:!1};let a={};for(let u in this._resHeaders)a[u]=u in r.headers&&!xnt[u]?r.headers[u]:this._resHeaders[u];let n=Object.assign({},r,{status:this._status,method:this._method,headers:a});return{policy:new this.constructor(e,n,{shared:this._isShared,cacheHeuristic:this._cacheHeuristic,immutableMinTimeToLive:this._immutableMinTtl}),modified:!1,matches:!0}}}});var ex=_((hNt,Doe)=>{"use strict";Doe.exports=t=>{let e={};for(let[r,o]of Object.entries(t))e[r.toLowerCase()]=o;return e}});var Soe=_((gNt,Poe)=>{"use strict";var Qnt=Be("stream").Readable,Fnt=ex(),UM=class extends Qnt{constructor(e,r,o,a){if(typeof e!="number")throw new TypeError("Argument `statusCode` should be a number");if(typeof r!="object")throw new TypeError("Argument `headers` should be an object");if(!(o instanceof Buffer))throw new TypeError("Argument `body` should be a buffer");if(typeof a!="string")throw new TypeError("Argument `url` should be a string");super(),this.statusCode=e,this.headers=Fnt(r),this.body=o,this.url=a}_read(){this.push(this.body),this.push(null)}};Poe.exports=UM});var boe=_((dNt,xoe)=>{"use strict";var Rnt=["destroy","setTimeout","socket","headers","trailers","rawHeaders","statusCode","httpVersion","httpVersionMinor","httpVersionMajor","rawTrailers","statusMessage"];xoe.exports=(t,e)=>{let r=new Set(Object.keys(t).concat(Rnt));for(let o of r)o in e||(e[o]=typeof t[o]=="function"?t[o].bind(t):t[o])}});var Qoe=_((mNt,koe)=>{"use strict";var Tnt=Be("stream").PassThrough,Lnt=boe(),Nnt=t=>{if(!(t&&t.pipe))throw new TypeError("Parameter `response` must be a response stream.");let e=new Tnt;return Lnt(t,e),t.pipe(e)};koe.exports=Nnt});var Foe=_(_M=>{_M.stringify=function t(e){if(typeof e>"u")return e;if(e&&Buffer.isBuffer(e))return JSON.stringify(":base64:"+e.toString("base64"));if(e&&e.toJSON&&(e=e.toJSON()),e&&typeof e=="object"){var r="",o=Array.isArray(e);r=o?"[":"{";var a=!0;for(var n in e){var u=typeof e[n]=="function"||!o&&typeof e[n]>"u";Object.hasOwnProperty.call(e,n)&&!u&&(a||(r+=","),a=!1,o?e[n]==null?r+="null":r+=t(e[n]):e[n]!==void 0&&(r+=t(n)+":"+t(e[n])))}return r+=o?"]":"}",r}else return typeof e=="string"?JSON.stringify(/^:/.test(e)?":"+e:e):typeof e>"u"?"null":JSON.stringify(e)};_M.parse=function(t){return JSON.parse(t,function(e,r){return typeof r=="string"?/^:base64:/.test(r)?Buffer.from(r.substring(8),"base64"):/^:/.test(r)?r.substring(1):r:r})}});var Loe=_((ENt,Toe)=>{"use strict";var Ont=Be("events"),Roe=Foe(),Mnt=t=>{let e={redis:"@keyv/redis",mongodb:"@keyv/mongo",mongo:"@keyv/mongo",sqlite:"@keyv/sqlite",postgresql:"@keyv/postgres",postgres:"@keyv/postgres",mysql:"@keyv/mysql"};if(t.adapter||t.uri){let r=t.adapter||/^[^:]*/.exec(t.uri)[0];return new(Be(e[r]))(t)}return new Map},HM=class extends Ont{constructor(e,r){if(super(),this.opts=Object.assign({namespace:"keyv",serialize:Roe.stringify,deserialize:Roe.parse},typeof e=="string"?{uri:e}:e,r),!this.opts.store){let o=Object.assign({},this.opts);this.opts.store=Mnt(o)}typeof this.opts.store.on=="function"&&this.opts.store.on("error",o=>this.emit("error",o)),this.opts.store.namespace=this.opts.namespace}_getKeyPrefix(e){return`${this.opts.namespace}:${e}`}get(e,r){e=this._getKeyPrefix(e);let{store:o}=this.opts;return Promise.resolve().then(()=>o.get(e)).then(a=>typeof a=="string"?this.opts.deserialize(a):a).then(a=>{if(a!==void 0){if(typeof a.expires=="number"&&Date.now()>a.expires){this.delete(e);return}return r&&r.raw?a:a.value}})}set(e,r,o){e=this._getKeyPrefix(e),typeof o>"u"&&(o=this.opts.ttl),o===0&&(o=void 0);let{store:a}=this.opts;return Promise.resolve().then(()=>{let n=typeof o=="number"?Date.now()+o:null;return r={value:r,expires:n},this.opts.serialize(r)}).then(n=>a.set(e,n,o)).then(()=>!0)}delete(e){e=this._getKeyPrefix(e);let{store:r}=this.opts;return Promise.resolve().then(()=>r.delete(e))}clear(){let{store:e}=this.opts;return Promise.resolve().then(()=>e.clear())}};Toe.exports=HM});var Moe=_((wNt,Ooe)=>{"use strict";var Unt=Be("events"),tx=Be("url"),_nt=coe(),Hnt=Ioe(),jM=voe(),Noe=Soe(),jnt=ex(),qnt=Qoe(),Gnt=Loe(),jc=class{constructor(e,r){if(typeof e!="function")throw new TypeError("Parameter `request` must be a function");return this.cache=new Gnt({uri:typeof r=="string"&&r,store:typeof r!="string"&&r,namespace:"cacheable-request"}),this.createCacheableRequest(e)}createCacheableRequest(e){return(r,o)=>{let a;if(typeof r=="string")a=qM(tx.parse(r)),r={};else if(r instanceof tx.URL)a=qM(tx.parse(r.toString())),r={};else{let[I,...v]=(r.path||"").split("?"),b=v.length>0?`?${v.join("?")}`:"";a=qM({...r,pathname:I,search:b})}r={headers:{},method:"GET",cache:!0,strictTtl:!1,automaticFailover:!1,...r,...Ynt(a)},r.headers=jnt(r.headers);let n=new Unt,u=_nt(tx.format(a),{stripWWW:!1,removeTrailingSlash:!1,stripAuthentication:!1}),A=`${r.method}:${u}`,p=!1,h=!1,C=I=>{h=!0;let v=!1,b,E=new Promise(N=>{b=()=>{v||(v=!0,N())}}),F=N=>{if(p&&!I.forceRefresh){N.status=N.statusCode;let z=jM.fromObject(p.cachePolicy).revalidatedPolicy(I,N);if(!z.modified){let te=z.policy.responseHeaders();N=new Noe(p.statusCode,te,p.body,p.url),N.cachePolicy=z.policy,N.fromCache=!0}}N.fromCache||(N.cachePolicy=new jM(I,N,I),N.fromCache=!1);let U;I.cache&&N.cachePolicy.storable()?(U=qnt(N),(async()=>{try{let z=Hnt.buffer(N);if(await Promise.race([E,new Promise(ue=>N.once("end",ue))]),v)return;let te=await z,le={cachePolicy:N.cachePolicy.toObject(),url:N.url,statusCode:N.fromCache?p.statusCode:N.statusCode,body:te},pe=I.strictTtl?N.cachePolicy.timeToLive():void 0;I.maxTtl&&(pe=pe?Math.min(pe,I.maxTtl):I.maxTtl),await this.cache.set(A,le,pe)}catch(z){n.emit("error",new jc.CacheError(z))}})()):I.cache&&p&&(async()=>{try{await this.cache.delete(A)}catch(z){n.emit("error",new jc.CacheError(z))}})(),n.emit("response",U||N),typeof o=="function"&&o(U||N)};try{let N=e(I,F);N.once("error",b),N.once("abort",b),n.emit("request",N)}catch(N){n.emit("error",new jc.RequestError(N))}};return(async()=>{let I=async b=>{await Promise.resolve();let E=b.cache?await this.cache.get(A):void 0;if(typeof E>"u")return C(b);let F=jM.fromObject(E.cachePolicy);if(F.satisfiesWithoutRevalidation(b)&&!b.forceRefresh){let N=F.responseHeaders(),U=new Noe(E.statusCode,N,E.body,E.url);U.cachePolicy=F,U.fromCache=!0,n.emit("response",U),typeof o=="function"&&o(U)}else p=E,b.headers=F.revalidationHeaders(b),C(b)},v=b=>n.emit("error",new jc.CacheError(b));this.cache.once("error",v),n.on("response",()=>this.cache.removeListener("error",v));try{await I(r)}catch(b){r.automaticFailover&&!h&&C(r),n.emit("error",new jc.CacheError(b))}})(),n}}};function Ynt(t){let e={...t};return e.path=`${t.pathname||"/"}${t.search||""}`,delete e.pathname,delete e.search,e}function qM(t){return{protocol:t.protocol,auth:t.auth,hostname:t.hostname||t.host||"localhost",port:t.port,pathname:t.pathname,search:t.search}}jc.RequestError=class extends Error{constructor(t){super(t.message),this.name="RequestError",Object.assign(this,t)}};jc.CacheError=class extends Error{constructor(t){super(t.message),this.name="CacheError",Object.assign(this,t)}};Ooe.exports=jc});var _oe=_((vNt,Uoe)=>{"use strict";var Wnt=["aborted","complete","headers","httpVersion","httpVersionMinor","httpVersionMajor","method","rawHeaders","rawTrailers","setTimeout","socket","statusCode","statusMessage","trailers","url"];Uoe.exports=(t,e)=>{if(e._readableState.autoDestroy)throw new Error("The second stream must have the `autoDestroy` option set to `false`");let r=new Set(Object.keys(t).concat(Wnt)),o={};for(let a of r)a in e||(o[a]={get(){let n=t[a];return typeof n=="function"?n.bind(t):n},set(n){t[a]=n},enumerable:!0,configurable:!1});return Object.defineProperties(e,o),t.once("aborted",()=>{e.destroy(),e.emit("aborted")}),t.once("close",()=>{t.complete&&e.readable?e.once("end",()=>{e.emit("close")}):e.emit("close")}),e}});var joe=_((DNt,Hoe)=>{"use strict";var{Transform:Knt,PassThrough:Vnt}=Be("stream"),GM=Be("zlib"),znt=_oe();Hoe.exports=t=>{let e=(t.headers["content-encoding"]||"").toLowerCase();if(!["gzip","deflate","br"].includes(e))return t;let r=e==="br";if(r&&typeof GM.createBrotliDecompress!="function")return t.destroy(new Error("Brotli is not supported on Node.js < 12")),t;let o=!0,a=new Knt({transform(A,p,h){o=!1,h(null,A)},flush(A){A()}}),n=new Vnt({autoDestroy:!1,destroy(A,p){t.destroy(),p(A)}}),u=r?GM.createBrotliDecompress():GM.createUnzip();return u.once("error",A=>{if(o&&!t.readable){n.end();return}n.destroy(A)}),znt(t,n),t.pipe(a).pipe(u).pipe(n),n}});var WM=_((PNt,qoe)=>{"use strict";var YM=class{constructor(e={}){if(!(e.maxSize&&e.maxSize>0))throw new TypeError("`maxSize` must be a number greater than 0");this.maxSize=e.maxSize,this.onEviction=e.onEviction,this.cache=new Map,this.oldCache=new Map,this._size=0}_set(e,r){if(this.cache.set(e,r),this._size++,this._size>=this.maxSize){if(this._size=0,typeof this.onEviction=="function")for(let[o,a]of this.oldCache.entries())this.onEviction(o,a);this.oldCache=this.cache,this.cache=new Map}}get(e){if(this.cache.has(e))return this.cache.get(e);if(this.oldCache.has(e)){let r=this.oldCache.get(e);return this.oldCache.delete(e),this._set(e,r),r}}set(e,r){return this.cache.has(e)?this.cache.set(e,r):this._set(e,r),this}has(e){return this.cache.has(e)||this.oldCache.has(e)}peek(e){if(this.cache.has(e))return this.cache.get(e);if(this.oldCache.has(e))return this.oldCache.get(e)}delete(e){let r=this.cache.delete(e);return r&&this._size--,this.oldCache.delete(e)||r}clear(){this.cache.clear(),this.oldCache.clear(),this._size=0}*keys(){for(let[e]of this)yield e}*values(){for(let[,e]of this)yield e}*[Symbol.iterator](){for(let e of this.cache)yield e;for(let e of this.oldCache){let[r]=e;this.cache.has(r)||(yield e)}}get size(){let e=0;for(let r of this.oldCache.keys())this.cache.has(r)||e++;return Math.min(this._size+e,this.maxSize)}};qoe.exports=YM});var VM=_((SNt,Koe)=>{"use strict";var Jnt=Be("events"),Xnt=Be("tls"),Znt=Be("http2"),$nt=WM(),ea=Symbol("currentStreamsCount"),Goe=Symbol("request"),Wl=Symbol("cachedOriginSet"),vE=Symbol("gracefullyClosing"),eit=["maxDeflateDynamicTableSize","maxSessionMemory","maxHeaderListPairs","maxOutstandingPings","maxReservedRemoteStreams","maxSendHeaderBlockLength","paddingStrategy","localAddress","path","rejectUnauthorized","minDHSize","ca","cert","clientCertEngine","ciphers","key","pfx","servername","minVersion","maxVersion","secureProtocol","crl","honorCipherOrder","ecdhCurve","dhparam","secureOptions","sessionIdContext"],tit=(t,e,r)=>{let o=0,a=t.length;for(;o<a;){let n=o+a>>>1;r(t[n],e)?o=n+1:a=n}return o},rit=(t,e)=>t.remoteSettings.maxConcurrentStreams>e.remoteSettings.maxConcurrentStreams,KM=(t,e)=>{for(let r of t)r[Wl].length<e[Wl].length&&r[Wl].every(o=>e[Wl].includes(o))&&r[ea]+e[ea]<=e.remoteSettings.maxConcurrentStreams&&Woe(r)},nit=(t,e)=>{for(let r of t)e[Wl].length<r[Wl].length&&e[Wl].every(o=>r[Wl].includes(o))&&e[ea]+r[ea]<=r.remoteSettings.maxConcurrentStreams&&Woe(e)},Yoe=({agent:t,isFree:e})=>{let r={};for(let o in t.sessions){let n=t.sessions[o].filter(u=>{let A=u[tA.kCurrentStreamsCount]<u.remoteSettings.maxConcurrentStreams;return e?A:!A});n.length!==0&&(r[o]=n)}return r},Woe=t=>{t[vE]=!0,t[ea]===0&&t.close()},tA=class extends Jnt{constructor({timeout:e=6e4,maxSessions:r=1/0,maxFreeSessions:o=10,maxCachedTlsSessions:a=100}={}){super(),this.sessions={},this.queue={},this.timeout=e,this.maxSessions=r,this.maxFreeSessions=o,this._freeSessionsCount=0,this._sessionsCount=0,this.settings={enablePush:!1},this.tlsSessionCache=new $nt({maxSize:a})}static normalizeOrigin(e,r){return typeof e=="string"&&(e=new URL(e)),r&&e.hostname!==r&&(e.hostname=r),e.origin}normalizeOptions(e){let r="";if(e)for(let o of eit)e[o]&&(r+=`:${e[o]}`);return r}_tryToCreateNewSession(e,r){if(!(e in this.queue)||!(r in this.queue[e]))return;let o=this.queue[e][r];this._sessionsCount<this.maxSessions&&!o.completed&&(o.completed=!0,o())}getSession(e,r,o){return new Promise((a,n)=>{Array.isArray(o)?(o=[...o],a()):o=[{resolve:a,reject:n}];let u=this.normalizeOptions(r),A=tA.normalizeOrigin(e,r&&r.servername);if(A===void 0){for(let{reject:C}of o)C(new TypeError("The `origin` argument needs to be a string or an URL object"));return}if(u in this.sessions){let C=this.sessions[u],I=-1,v=-1,b;for(let E of C){let F=E.remoteSettings.maxConcurrentStreams;if(F<I)break;if(E[Wl].includes(A)){let N=E[ea];if(N>=F||E[vE]||E.destroyed)continue;b||(I=F),N>v&&(b=E,v=N)}}if(b){if(o.length!==1){for(let{reject:E}of o){let F=new Error(`Expected the length of listeners to be 1, got ${o.length}. -Please report this to https://github.com/szmarczak/http2-wrapper/`);E(F)}return}o[0].resolve(b);return}}if(u in this.queue){if(A in this.queue[u]){this.queue[u][A].listeners.push(...o),this._tryToCreateNewSession(u,A);return}}else this.queue[u]={};let p=()=>{u in this.queue&&this.queue[u][A]===h&&(delete this.queue[u][A],Object.keys(this.queue[u]).length===0&&delete this.queue[u])},h=()=>{let C=`${A}:${u}`,I=!1;try{let v=Znt.connect(e,{createConnection:this.createConnection,settings:this.settings,session:this.tlsSessionCache.get(C),...r});v[ea]=0,v[vE]=!1;let b=()=>v[ea]<v.remoteSettings.maxConcurrentStreams,E=!0;v.socket.once("session",N=>{this.tlsSessionCache.set(C,N)}),v.once("error",N=>{for(let{reject:U}of o)U(N);this.tlsSessionCache.delete(C)}),v.setTimeout(this.timeout,()=>{v.destroy()}),v.once("close",()=>{if(I){E&&this._freeSessionsCount--,this._sessionsCount--;let N=this.sessions[u];N.splice(N.indexOf(v),1),N.length===0&&delete this.sessions[u]}else{let N=new Error("Session closed without receiving a SETTINGS frame");N.code="HTTP2WRAPPER_NOSETTINGS";for(let{reject:U}of o)U(N);p()}this._tryToCreateNewSession(u,A)});let F=()=>{if(!(!(u in this.queue)||!b())){for(let N of v[Wl])if(N in this.queue[u]){let{listeners:U}=this.queue[u][N];for(;U.length!==0&&b();)U.shift().resolve(v);let z=this.queue[u];if(z[N].listeners.length===0&&(delete z[N],Object.keys(z).length===0)){delete this.queue[u];break}if(!b())break}}};v.on("origin",()=>{v[Wl]=v.originSet,b()&&(F(),KM(this.sessions[u],v))}),v.once("remoteSettings",()=>{if(v.ref(),v.unref(),this._sessionsCount++,h.destroyed){let N=new Error("Agent has been destroyed");for(let U of o)U.reject(N);v.destroy();return}v[Wl]=v.originSet;{let N=this.sessions;if(u in N){let U=N[u];U.splice(tit(U,v,rit),0,v)}else N[u]=[v]}this._freeSessionsCount+=1,I=!0,this.emit("session",v),F(),p(),v[ea]===0&&this._freeSessionsCount>this.maxFreeSessions&&v.close(),o.length!==0&&(this.getSession(A,r,o),o.length=0),v.on("remoteSettings",()=>{F(),KM(this.sessions[u],v)})}),v[Goe]=v.request,v.request=(N,U)=>{if(v[vE])throw new Error("The session is gracefully closing. No new streams are allowed.");let z=v[Goe](N,U);return v.ref(),++v[ea],v[ea]===v.remoteSettings.maxConcurrentStreams&&this._freeSessionsCount--,z.once("close",()=>{if(E=b(),--v[ea],!v.destroyed&&!v.closed&&(nit(this.sessions[u],v),b()&&!v.closed)){E||(this._freeSessionsCount++,E=!0);let te=v[ea]===0;te&&v.unref(),te&&(this._freeSessionsCount>this.maxFreeSessions||v[vE])?v.close():(KM(this.sessions[u],v),F())}}),z}}catch(v){for(let b of o)b.reject(v);p()}};h.listeners=o,h.completed=!1,h.destroyed=!1,this.queue[u][A]=h,this._tryToCreateNewSession(u,A)})}request(e,r,o,a){return new Promise((n,u)=>{this.getSession(e,r,[{reject:u,resolve:A=>{try{n(A.request(o,a))}catch(p){u(p)}}}])})}createConnection(e,r){return tA.connect(e,r)}static connect(e,r){r.ALPNProtocols=["h2"];let o=e.port||443,a=e.hostname||e.host;return typeof r.servername>"u"&&(r.servername=a),Xnt.connect(o,a,r)}closeFreeSessions(){for(let e of Object.values(this.sessions))for(let r of e)r[ea]===0&&r.close()}destroy(e){for(let r of Object.values(this.sessions))for(let o of r)o.destroy(e);for(let r of Object.values(this.queue))for(let o of Object.values(r))o.destroyed=!0;this.queue={}}get freeSessions(){return Yoe({agent:this,isFree:!0})}get busySessions(){return Yoe({agent:this,isFree:!1})}};tA.kCurrentStreamsCount=ea;tA.kGracefullyClosing=vE;Koe.exports={Agent:tA,globalAgent:new tA}});var JM=_((xNt,Voe)=>{"use strict";var{Readable:iit}=Be("stream"),zM=class extends iit{constructor(e,r){super({highWaterMark:r,autoDestroy:!1}),this.statusCode=null,this.statusMessage="",this.httpVersion="2.0",this.httpVersionMajor=2,this.httpVersionMinor=0,this.headers={},this.trailers={},this.req=null,this.aborted=!1,this.complete=!1,this.upgrade=null,this.rawHeaders=[],this.rawTrailers=[],this.socket=e,this.connection=e,this._dumped=!1}_destroy(e){this.req._request.destroy(e)}setTimeout(e,r){return this.req.setTimeout(e,r),this}_dump(){this._dumped||(this._dumped=!0,this.removeAllListeners("data"),this.resume())}_read(){this.req&&this.req._request.resume()}};Voe.exports=zM});var XM=_((bNt,zoe)=>{"use strict";zoe.exports=t=>{let e={protocol:t.protocol,hostname:typeof t.hostname=="string"&&t.hostname.startsWith("[")?t.hostname.slice(1,-1):t.hostname,host:t.host,hash:t.hash,search:t.search,pathname:t.pathname,href:t.href,path:`${t.pathname||""}${t.search||""}`};return typeof t.port=="string"&&t.port.length!==0&&(e.port=Number(t.port)),(t.username||t.password)&&(e.auth=`${t.username||""}:${t.password||""}`),e}});var Xoe=_((kNt,Joe)=>{"use strict";Joe.exports=(t,e,r)=>{for(let o of r)t.on(o,(...a)=>e.emit(o,...a))}});var $oe=_((QNt,Zoe)=>{"use strict";Zoe.exports=t=>{switch(t){case":method":case":scheme":case":authority":case":path":return!0;default:return!1}}});var tae=_((RNt,eae)=>{"use strict";var DE=(t,e,r)=>{eae.exports[e]=class extends t{constructor(...a){super(typeof r=="string"?r:r(a)),this.name=`${super.name} [${e}]`,this.code=e}}};DE(TypeError,"ERR_INVALID_ARG_TYPE",t=>{let e=t[0].includes(".")?"property":"argument",r=t[1],o=Array.isArray(r);return o&&(r=`${r.slice(0,-1).join(", ")} or ${r.slice(-1)}`),`The "${t[0]}" ${e} must be ${o?"one of":"of"} type ${r}. Received ${typeof t[2]}`});DE(TypeError,"ERR_INVALID_PROTOCOL",t=>`Protocol "${t[0]}" not supported. Expected "${t[1]}"`);DE(Error,"ERR_HTTP_HEADERS_SENT",t=>`Cannot ${t[0]} headers after they are sent to the client`);DE(TypeError,"ERR_INVALID_HTTP_TOKEN",t=>`${t[0]} must be a valid HTTP token [${t[1]}]`);DE(TypeError,"ERR_HTTP_INVALID_HEADER_VALUE",t=>`Invalid value "${t[0]} for header "${t[1]}"`);DE(TypeError,"ERR_INVALID_CHAR",t=>`Invalid character in ${t[0]} [${t[1]}]`)});var r4=_((TNt,lae)=>{"use strict";var sit=Be("http2"),{Writable:oit}=Be("stream"),{Agent:rae,globalAgent:ait}=VM(),lit=JM(),cit=XM(),uit=Xoe(),Ait=$oe(),{ERR_INVALID_ARG_TYPE:ZM,ERR_INVALID_PROTOCOL:fit,ERR_HTTP_HEADERS_SENT:nae,ERR_INVALID_HTTP_TOKEN:pit,ERR_HTTP_INVALID_HEADER_VALUE:hit,ERR_INVALID_CHAR:git}=tae(),{HTTP2_HEADER_STATUS:iae,HTTP2_HEADER_METHOD:sae,HTTP2_HEADER_PATH:oae,HTTP2_METHOD_CONNECT:dit}=sit.constants,Qo=Symbol("headers"),$M=Symbol("origin"),e4=Symbol("session"),aae=Symbol("options"),rx=Symbol("flushedHeaders"),f1=Symbol("jobs"),mit=/^[\^`\-\w!#$%&*+.|~]+$/,yit=/[^\t\u0020-\u007E\u0080-\u00FF]/,t4=class extends oit{constructor(e,r,o){super({autoDestroy:!1});let a=typeof e=="string"||e instanceof URL;if(a&&(e=cit(e instanceof URL?e:new URL(e))),typeof r=="function"||r===void 0?(o=r,r=a?e:{...e}):r={...e,...r},r.h2session)this[e4]=r.h2session;else if(r.agent===!1)this.agent=new rae({maxFreeSessions:0});else if(typeof r.agent>"u"||r.agent===null)typeof r.createConnection=="function"?(this.agent=new rae({maxFreeSessions:0}),this.agent.createConnection=r.createConnection):this.agent=ait;else if(typeof r.agent.request=="function")this.agent=r.agent;else throw new ZM("options.agent",["Agent-like Object","undefined","false"],r.agent);if(r.protocol&&r.protocol!=="https:")throw new fit(r.protocol,"https:");let n=r.port||r.defaultPort||this.agent&&this.agent.defaultPort||443,u=r.hostname||r.host||"localhost";delete r.hostname,delete r.host,delete r.port;let{timeout:A}=r;if(r.timeout=void 0,this[Qo]=Object.create(null),this[f1]=[],this.socket=null,this.connection=null,this.method=r.method||"GET",this.path=r.path,this.res=null,this.aborted=!1,this.reusedSocket=!1,r.headers)for(let[p,h]of Object.entries(r.headers))this.setHeader(p,h);r.auth&&!("authorization"in this[Qo])&&(this[Qo].authorization="Basic "+Buffer.from(r.auth).toString("base64")),r.session=r.tlsSession,r.path=r.socketPath,this[aae]=r,n===443?(this[$M]=`https://${u}`,":authority"in this[Qo]||(this[Qo][":authority"]=u)):(this[$M]=`https://${u}:${n}`,":authority"in this[Qo]||(this[Qo][":authority"]=`${u}:${n}`)),A&&this.setTimeout(A),o&&this.once("response",o),this[rx]=!1}get method(){return this[Qo][sae]}set method(e){e&&(this[Qo][sae]=e.toUpperCase())}get path(){return this[Qo][oae]}set path(e){e&&(this[Qo][oae]=e)}get _mustNotHaveABody(){return this.method==="GET"||this.method==="HEAD"||this.method==="DELETE"}_write(e,r,o){if(this._mustNotHaveABody){o(new Error("The GET, HEAD and DELETE methods must NOT have a body"));return}this.flushHeaders();let a=()=>this._request.write(e,r,o);this._request?a():this[f1].push(a)}_final(e){if(this.destroyed)return;this.flushHeaders();let r=()=>{if(this._mustNotHaveABody){e();return}this._request.end(e)};this._request?r():this[f1].push(r)}abort(){this.res&&this.res.complete||(this.aborted||process.nextTick(()=>this.emit("abort")),this.aborted=!0,this.destroy())}_destroy(e,r){this.res&&this.res._dump(),this._request&&this._request.destroy(),r(e)}async flushHeaders(){if(this[rx]||this.destroyed)return;this[rx]=!0;let e=this.method===dit,r=o=>{if(this._request=o,this.destroyed){o.destroy();return}e||uit(o,this,["timeout","continue","close","error"]);let a=u=>(...A)=>{!this.writable&&!this.destroyed?u(...A):this.once("finish",()=>{u(...A)})};o.once("response",a((u,A,p)=>{let h=new lit(this.socket,o.readableHighWaterMark);this.res=h,h.req=this,h.statusCode=u[iae],h.headers=u,h.rawHeaders=p,h.once("end",()=>{this.aborted?(h.aborted=!0,h.emit("aborted")):(h.complete=!0,h.socket=null,h.connection=null)}),e?(h.upgrade=!0,this.emit("connect",h,o,Buffer.alloc(0))?this.emit("close"):o.destroy()):(o.on("data",C=>{!h._dumped&&!h.push(C)&&o.pause()}),o.once("end",()=>{h.push(null)}),this.emit("response",h)||h._dump())})),o.once("headers",a(u=>this.emit("information",{statusCode:u[iae]}))),o.once("trailers",a((u,A,p)=>{let{res:h}=this;h.trailers=u,h.rawTrailers=p}));let{socket:n}=o.session;this.socket=n,this.connection=n;for(let u of this[f1])u();this.emit("socket",this.socket)};if(this[e4])try{r(this[e4].request(this[Qo]))}catch(o){this.emit("error",o)}else{this.reusedSocket=!0;try{r(await this.agent.request(this[$M],this[aae],this[Qo]))}catch(o){this.emit("error",o)}}}getHeader(e){if(typeof e!="string")throw new ZM("name","string",e);return this[Qo][e.toLowerCase()]}get headersSent(){return this[rx]}removeHeader(e){if(typeof e!="string")throw new ZM("name","string",e);if(this.headersSent)throw new nae("remove");delete this[Qo][e.toLowerCase()]}setHeader(e,r){if(this.headersSent)throw new nae("set");if(typeof e!="string"||!mit.test(e)&&!Ait(e))throw new pit("Header name",e);if(typeof r>"u")throw new hit(r,e);if(yit.test(r))throw new git("header content",e);this[Qo][e.toLowerCase()]=r}setNoDelay(){}setSocketKeepAlive(){}setTimeout(e,r){let o=()=>this._request.setTimeout(e,r);return this._request?o():this[f1].push(o),this}get maxHeadersCount(){if(!this.destroyed&&this._request)return this._request.session.localSettings.maxHeaderListSize}set maxHeadersCount(e){}};lae.exports=t4});var uae=_((LNt,cae)=>{"use strict";var Eit=Be("tls");cae.exports=(t={})=>new Promise((e,r)=>{let o=Eit.connect(t,()=>{t.resolveSocket?(o.off("error",r),e({alpnProtocol:o.alpnProtocol,socket:o})):(o.destroy(),e({alpnProtocol:o.alpnProtocol}))});o.on("error",r)})});var fae=_((NNt,Aae)=>{"use strict";var Cit=Be("net");Aae.exports=t=>{let e=t.host,r=t.headers&&t.headers.host;return r&&(r.startsWith("[")?r.indexOf("]")===-1?e=r:e=r.slice(1,-1):e=r.split(":",1)[0]),Cit.isIP(e)?"":e}});var gae=_((ONt,i4)=>{"use strict";var pae=Be("http"),n4=Be("https"),wit=uae(),Iit=WM(),Bit=r4(),vit=fae(),Dit=XM(),nx=new Iit({maxSize:100}),p1=new Map,hae=(t,e,r)=>{e._httpMessage={shouldKeepAlive:!0};let o=()=>{t.emit("free",e,r)};e.on("free",o);let a=()=>{t.removeSocket(e,r)};e.on("close",a);let n=()=>{t.removeSocket(e,r),e.off("close",a),e.off("free",o),e.off("agentRemove",n)};e.on("agentRemove",n),t.emit("free",e,r)},Pit=async t=>{let e=`${t.host}:${t.port}:${t.ALPNProtocols.sort()}`;if(!nx.has(e)){if(p1.has(e))return(await p1.get(e)).alpnProtocol;let{path:r,agent:o}=t;t.path=t.socketPath;let a=wit(t);p1.set(e,a);try{let{socket:n,alpnProtocol:u}=await a;if(nx.set(e,u),t.path=r,u==="h2")n.destroy();else{let{globalAgent:A}=n4,p=n4.Agent.prototype.createConnection;o?o.createConnection===p?hae(o,n,t):n.destroy():A.createConnection===p?hae(A,n,t):n.destroy()}return p1.delete(e),u}catch(n){throw p1.delete(e),n}}return nx.get(e)};i4.exports=async(t,e,r)=>{if((typeof t=="string"||t instanceof URL)&&(t=Dit(new URL(t))),typeof e=="function"&&(r=e,e=void 0),e={ALPNProtocols:["h2","http/1.1"],...t,...e,resolveSocket:!0},!Array.isArray(e.ALPNProtocols)||e.ALPNProtocols.length===0)throw new Error("The `ALPNProtocols` option must be an Array with at least one entry");e.protocol=e.protocol||"https:";let o=e.protocol==="https:";e.host=e.hostname||e.host||"localhost",e.session=e.tlsSession,e.servername=e.servername||vit(e),e.port=e.port||(o?443:80),e._defaultAgent=o?n4.globalAgent:pae.globalAgent;let a=e.agent;if(a){if(a.addRequest)throw new Error("The `options.agent` object can contain only `http`, `https` or `http2` properties");e.agent=a[o?"https":"http"]}return o&&await Pit(e)==="h2"?(a&&(e.agent=a.http2),new Bit(e,r)):pae.request(e,r)};i4.exports.protocolCache=nx});var mae=_((MNt,dae)=>{"use strict";var Sit=Be("http2"),xit=VM(),s4=r4(),bit=JM(),kit=gae(),Qit=(t,e,r)=>new s4(t,e,r),Fit=(t,e,r)=>{let o=new s4(t,e,r);return o.end(),o};dae.exports={...Sit,ClientRequest:s4,IncomingMessage:bit,...xit,request:Qit,get:Fit,auto:kit}});var a4=_(o4=>{"use strict";Object.defineProperty(o4,"__esModule",{value:!0});var yae=Ff();o4.default=t=>yae.default.nodeStream(t)&&yae.default.function_(t.getBoundary)});var Iae=_(l4=>{"use strict";Object.defineProperty(l4,"__esModule",{value:!0});var Cae=Be("fs"),wae=Be("util"),Eae=Ff(),Rit=a4(),Tit=wae.promisify(Cae.stat);l4.default=async(t,e)=>{if(e&&"content-length"in e)return Number(e["content-length"]);if(!t)return 0;if(Eae.default.string(t))return Buffer.byteLength(t);if(Eae.default.buffer(t))return t.length;if(Rit.default(t))return wae.promisify(t.getLength.bind(t))();if(t instanceof Cae.ReadStream){let{size:r}=await Tit(t.path);return r===0?void 0:r}}});var u4=_(c4=>{"use strict";Object.defineProperty(c4,"__esModule",{value:!0});function Lit(t,e,r){let o={};for(let a of r)o[a]=(...n)=>{e.emit(a,...n)},t.on(a,o[a]);return()=>{for(let a of r)t.off(a,o[a])}}c4.default=Lit});var Bae=_(A4=>{"use strict";Object.defineProperty(A4,"__esModule",{value:!0});A4.default=()=>{let t=[];return{once(e,r,o){e.once(r,o),t.push({origin:e,event:r,fn:o})},unhandleAll(){for(let e of t){let{origin:r,event:o,fn:a}=e;r.removeListener(o,a)}t.length=0}}}});var Dae=_(h1=>{"use strict";Object.defineProperty(h1,"__esModule",{value:!0});h1.TimeoutError=void 0;var Nit=Be("net"),Oit=Bae(),vae=Symbol("reentry"),Mit=()=>{},ix=class extends Error{constructor(e,r){super(`Timeout awaiting '${r}' for ${e}ms`),this.event=r,this.name="TimeoutError",this.code="ETIMEDOUT"}};h1.TimeoutError=ix;h1.default=(t,e,r)=>{if(vae in t)return Mit;t[vae]=!0;let o=[],{once:a,unhandleAll:n}=Oit.default(),u=(I,v,b)=>{var E;let F=setTimeout(v,I,I,b);(E=F.unref)===null||E===void 0||E.call(F);let N=()=>{clearTimeout(F)};return o.push(N),N},{host:A,hostname:p}=r,h=(I,v)=>{t.destroy(new ix(I,v))},C=()=>{for(let I of o)I();n()};if(t.once("error",I=>{if(C(),t.listenerCount("error")===0)throw I}),t.once("close",C),a(t,"response",I=>{a(I,"end",C)}),typeof e.request<"u"&&u(e.request,h,"request"),typeof e.socket<"u"){let I=()=>{h(e.socket,"socket")};t.setTimeout(e.socket,I),o.push(()=>{t.removeListener("timeout",I)})}return a(t,"socket",I=>{var v;let{socketPath:b}=t;if(I.connecting){let E=Boolean(b??Nit.isIP((v=p??A)!==null&&v!==void 0?v:"")!==0);if(typeof e.lookup<"u"&&!E&&typeof I.address().address>"u"){let F=u(e.lookup,h,"lookup");a(I,"lookup",F)}if(typeof e.connect<"u"){let F=()=>u(e.connect,h,"connect");E?a(I,"connect",F()):a(I,"lookup",N=>{N===null&&a(I,"connect",F())})}typeof e.secureConnect<"u"&&r.protocol==="https:"&&a(I,"connect",()=>{let F=u(e.secureConnect,h,"secureConnect");a(I,"secureConnect",F)})}if(typeof e.send<"u"){let E=()=>u(e.send,h,"send");I.connecting?a(I,"connect",()=>{a(t,"upload-complete",E())}):a(t,"upload-complete",E())}}),typeof e.response<"u"&&a(t,"upload-complete",()=>{let I=u(e.response,h,"response");a(t,"response",I)}),C}});var Sae=_(f4=>{"use strict";Object.defineProperty(f4,"__esModule",{value:!0});var Pae=Ff();f4.default=t=>{t=t;let e={protocol:t.protocol,hostname:Pae.default.string(t.hostname)&&t.hostname.startsWith("[")?t.hostname.slice(1,-1):t.hostname,host:t.host,hash:t.hash,search:t.search,pathname:t.pathname,href:t.href,path:`${t.pathname||""}${t.search||""}`};return Pae.default.string(t.port)&&t.port.length>0&&(e.port=Number(t.port)),(t.username||t.password)&&(e.auth=`${t.username||""}:${t.password||""}`),e}});var xae=_(p4=>{"use strict";Object.defineProperty(p4,"__esModule",{value:!0});var Uit=Be("url"),_it=["protocol","host","hostname","port","pathname","search"];p4.default=(t,e)=>{var r,o;if(e.path){if(e.pathname)throw new TypeError("Parameters `path` and `pathname` are mutually exclusive.");if(e.search)throw new TypeError("Parameters `path` and `search` are mutually exclusive.");if(e.searchParams)throw new TypeError("Parameters `path` and `searchParams` are mutually exclusive.")}if(e.search&&e.searchParams)throw new TypeError("Parameters `search` and `searchParams` are mutually exclusive.");if(!t){if(!e.protocol)throw new TypeError("No URL protocol specified");t=`${e.protocol}//${(o=(r=e.hostname)!==null&&r!==void 0?r:e.host)!==null&&o!==void 0?o:""}`}let a=new Uit.URL(t);if(e.path){let n=e.path.indexOf("?");n===-1?e.pathname=e.path:(e.pathname=e.path.slice(0,n),e.search=e.path.slice(n+1)),delete e.path}for(let n of _it)e[n]&&(a[n]=e[n].toString());return a}});var bae=_(g4=>{"use strict";Object.defineProperty(g4,"__esModule",{value:!0});var h4=class{constructor(){this.weakMap=new WeakMap,this.map=new Map}set(e,r){typeof e=="object"?this.weakMap.set(e,r):this.map.set(e,r)}get(e){return typeof e=="object"?this.weakMap.get(e):this.map.get(e)}has(e){return typeof e=="object"?this.weakMap.has(e):this.map.has(e)}};g4.default=h4});var m4=_(d4=>{"use strict";Object.defineProperty(d4,"__esModule",{value:!0});var Hit=async t=>{let e=[],r=0;for await(let o of t)e.push(o),r+=Buffer.byteLength(o);return Buffer.isBuffer(e[0])?Buffer.concat(e,r):Buffer.from(e.join(""))};d4.default=Hit});var Qae=_(Pd=>{"use strict";Object.defineProperty(Pd,"__esModule",{value:!0});Pd.dnsLookupIpVersionToFamily=Pd.isDnsLookupIpVersion=void 0;var kae={auto:0,ipv4:4,ipv6:6};Pd.isDnsLookupIpVersion=t=>t in kae;Pd.dnsLookupIpVersionToFamily=t=>{if(Pd.isDnsLookupIpVersion(t))return kae[t];throw new Error("Invalid DNS lookup IP version")}});var y4=_(sx=>{"use strict";Object.defineProperty(sx,"__esModule",{value:!0});sx.isResponseOk=void 0;sx.isResponseOk=t=>{let{statusCode:e}=t,r=t.request.options.followRedirect?299:399;return e>=200&&e<=r||e===304}});var Rae=_(E4=>{"use strict";Object.defineProperty(E4,"__esModule",{value:!0});var Fae=new Set;E4.default=t=>{Fae.has(t)||(Fae.add(t),process.emitWarning(`Got: ${t}`,{type:"DeprecationWarning"}))}});var Tae=_(C4=>{"use strict";Object.defineProperty(C4,"__esModule",{value:!0});var Ai=Ff(),jit=(t,e)=>{if(Ai.default.null_(t.encoding))throw new TypeError("To get a Buffer, set `options.responseType` to `buffer` instead");Ai.assert.any([Ai.default.string,Ai.default.undefined],t.encoding),Ai.assert.any([Ai.default.boolean,Ai.default.undefined],t.resolveBodyOnly),Ai.assert.any([Ai.default.boolean,Ai.default.undefined],t.methodRewriting),Ai.assert.any([Ai.default.boolean,Ai.default.undefined],t.isStream),Ai.assert.any([Ai.default.string,Ai.default.undefined],t.responseType),t.responseType===void 0&&(t.responseType="text");let{retry:r}=t;if(e?t.retry={...e.retry}:t.retry={calculateDelay:o=>o.computedValue,limit:0,methods:[],statusCodes:[],errorCodes:[],maxRetryAfter:void 0},Ai.default.object(r)?(t.retry={...t.retry,...r},t.retry.methods=[...new Set(t.retry.methods.map(o=>o.toUpperCase()))],t.retry.statusCodes=[...new Set(t.retry.statusCodes)],t.retry.errorCodes=[...new Set(t.retry.errorCodes)]):Ai.default.number(r)&&(t.retry.limit=r),Ai.default.undefined(t.retry.maxRetryAfter)&&(t.retry.maxRetryAfter=Math.min(...[t.timeout.request,t.timeout.connect].filter(Ai.default.number))),Ai.default.object(t.pagination)){e&&(t.pagination={...e.pagination,...t.pagination});let{pagination:o}=t;if(!Ai.default.function_(o.transform))throw new Error("`options.pagination.transform` must be implemented");if(!Ai.default.function_(o.shouldContinue))throw new Error("`options.pagination.shouldContinue` must be implemented");if(!Ai.default.function_(o.filter))throw new TypeError("`options.pagination.filter` must be implemented");if(!Ai.default.function_(o.paginate))throw new Error("`options.pagination.paginate` must be implemented")}return t.responseType==="json"&&t.headers.accept===void 0&&(t.headers.accept="application/json"),t};C4.default=jit});var Lae=_(g1=>{"use strict";Object.defineProperty(g1,"__esModule",{value:!0});g1.retryAfterStatusCodes=void 0;g1.retryAfterStatusCodes=new Set([413,429,503]);var qit=({attemptCount:t,retryOptions:e,error:r,retryAfter:o})=>{if(t>e.limit)return 0;let a=e.methods.includes(r.options.method),n=e.errorCodes.includes(r.code),u=r.response&&e.statusCodes.includes(r.response.statusCode);if(!a||!n&&!u)return 0;if(r.response){if(o)return e.maxRetryAfter===void 0||o>e.maxRetryAfter?0:o;if(r.response.statusCode===413)return 0}let A=Math.random()*100;return 2**(t-1)*1e3+A};g1.default=qit});var y1=_(Bn=>{"use strict";Object.defineProperty(Bn,"__esModule",{value:!0});Bn.UnsupportedProtocolError=Bn.ReadError=Bn.TimeoutError=Bn.UploadError=Bn.CacheError=Bn.HTTPError=Bn.MaxRedirectsError=Bn.RequestError=Bn.setNonEnumerableProperties=Bn.knownHookEvents=Bn.withoutBody=Bn.kIsNormalizedAlready=void 0;var Nae=Be("util"),Oae=Be("stream"),Git=Be("fs"),oh=Be("url"),Mae=Be("http"),w4=Be("http"),Yit=Be("https"),Wit=$se(),Kit=ooe(),Uae=Moe(),Vit=joe(),zit=mae(),Jit=ex(),st=Ff(),Xit=Iae(),_ae=a4(),Zit=u4(),Hae=Dae(),$it=Sae(),jae=xae(),est=bae(),tst=m4(),qae=Qae(),rst=y4(),ah=Rae(),nst=Tae(),ist=Lae(),I4,Zs=Symbol("request"),lx=Symbol("response"),PE=Symbol("responseSize"),SE=Symbol("downloadedSize"),xE=Symbol("bodySize"),bE=Symbol("uploadedSize"),ox=Symbol("serverResponsesPiped"),Gae=Symbol("unproxyEvents"),Yae=Symbol("isFromCache"),B4=Symbol("cancelTimeouts"),Wae=Symbol("startedReading"),kE=Symbol("stopReading"),ax=Symbol("triggerRead"),lh=Symbol("body"),d1=Symbol("jobs"),Kae=Symbol("originalResponse"),Vae=Symbol("retryTimeout");Bn.kIsNormalizedAlready=Symbol("isNormalizedAlready");var sst=st.default.string(process.versions.brotli);Bn.withoutBody=new Set(["GET","HEAD"]);Bn.knownHookEvents=["init","beforeRequest","beforeRedirect","beforeError","beforeRetry","afterResponse"];function ost(t){for(let e in t){let r=t[e];if(!st.default.string(r)&&!st.default.number(r)&&!st.default.boolean(r)&&!st.default.null_(r)&&!st.default.undefined(r))throw new TypeError(`The \`searchParams\` value '${String(r)}' must be a string, number, boolean or null`)}}function ast(t){return st.default.object(t)&&!("statusCode"in t)}var v4=new est.default,lst=async t=>new Promise((e,r)=>{let o=a=>{r(a)};t.pending||e(),t.once("error",o),t.once("ready",()=>{t.off("error",o),e()})}),cst=new Set([300,301,302,303,304,307,308]),ust=["context","body","json","form"];Bn.setNonEnumerableProperties=(t,e)=>{let r={};for(let o of t)if(!!o)for(let a of ust)a in o&&(r[a]={writable:!0,configurable:!0,enumerable:!1,value:o[a]});Object.defineProperties(e,r)};var Vi=class extends Error{constructor(e,r,o){var a;if(super(e),Error.captureStackTrace(this,this.constructor),this.name="RequestError",this.code=r.code,o instanceof gx?(Object.defineProperty(this,"request",{enumerable:!1,value:o}),Object.defineProperty(this,"response",{enumerable:!1,value:o[lx]}),Object.defineProperty(this,"options",{enumerable:!1,value:o.options})):Object.defineProperty(this,"options",{enumerable:!1,value:o}),this.timings=(a=this.request)===null||a===void 0?void 0:a.timings,st.default.string(r.stack)&&st.default.string(this.stack)){let n=this.stack.indexOf(this.message)+this.message.length,u=this.stack.slice(n).split(` -`).reverse(),A=r.stack.slice(r.stack.indexOf(r.message)+r.message.length).split(` -`).reverse();for(;A.length!==0&&A[0]===u[0];)u.shift();this.stack=`${this.stack.slice(0,n)}${u.reverse().join(` -`)}${A.reverse().join(` -`)}`}}};Bn.RequestError=Vi;var cx=class extends Vi{constructor(e){super(`Redirected ${e.options.maxRedirects} times. Aborting.`,{},e),this.name="MaxRedirectsError"}};Bn.MaxRedirectsError=cx;var ux=class extends Vi{constructor(e){super(`Response code ${e.statusCode} (${e.statusMessage})`,{},e.request),this.name="HTTPError"}};Bn.HTTPError=ux;var Ax=class extends Vi{constructor(e,r){super(e.message,e,r),this.name="CacheError"}};Bn.CacheError=Ax;var fx=class extends Vi{constructor(e,r){super(e.message,e,r),this.name="UploadError"}};Bn.UploadError=fx;var px=class extends Vi{constructor(e,r,o){super(e.message,e,o),this.name="TimeoutError",this.event=e.event,this.timings=r}};Bn.TimeoutError=px;var m1=class extends Vi{constructor(e,r){super(e.message,e,r),this.name="ReadError"}};Bn.ReadError=m1;var hx=class extends Vi{constructor(e){super(`Unsupported protocol "${e.url.protocol}"`,{},e),this.name="UnsupportedProtocolError"}};Bn.UnsupportedProtocolError=hx;var Ast=["socket","connect","continue","information","upgrade","timeout"],gx=class extends Oae.Duplex{constructor(e,r={},o){super({autoDestroy:!1,highWaterMark:0}),this[SE]=0,this[bE]=0,this.requestInitialized=!1,this[ox]=new Set,this.redirects=[],this[kE]=!1,this[ax]=!1,this[d1]=[],this.retryCount=0,this._progressCallbacks=[];let a=()=>this._unlockWrite(),n=()=>this._lockWrite();this.on("pipe",h=>{h.prependListener("data",a),h.on("data",n),h.prependListener("end",a),h.on("end",n)}),this.on("unpipe",h=>{h.off("data",a),h.off("data",n),h.off("end",a),h.off("end",n)}),this.on("pipe",h=>{h instanceof w4.IncomingMessage&&(this.options.headers={...h.headers,...this.options.headers})});let{json:u,body:A,form:p}=r;if((u||A||p)&&this._lockWrite(),Bn.kIsNormalizedAlready in r)this.options=r;else try{this.options=this.constructor.normalizeArguments(e,r,o)}catch(h){st.default.nodeStream(r.body)&&r.body.destroy(),this.destroy(h);return}(async()=>{var h;try{this.options.body instanceof Git.ReadStream&&await lst(this.options.body);let{url:C}=this.options;if(!C)throw new TypeError("Missing `url` property");if(this.requestUrl=C.toString(),decodeURI(this.requestUrl),await this._finalizeBody(),await this._makeRequest(),this.destroyed){(h=this[Zs])===null||h===void 0||h.destroy();return}for(let I of this[d1])I();this[d1].length=0,this.requestInitialized=!0}catch(C){if(C instanceof Vi){this._beforeError(C);return}this.destroyed||this.destroy(C)}})()}static normalizeArguments(e,r,o){var a,n,u,A,p;let h=r;if(st.default.object(e)&&!st.default.urlInstance(e))r={...o,...e,...r};else{if(e&&r&&r.url!==void 0)throw new TypeError("The `url` option is mutually exclusive with the `input` argument");r={...o,...r},e!==void 0&&(r.url=e),st.default.urlInstance(r.url)&&(r.url=new oh.URL(r.url.toString()))}if(r.cache===!1&&(r.cache=void 0),r.dnsCache===!1&&(r.dnsCache=void 0),st.assert.any([st.default.string,st.default.undefined],r.method),st.assert.any([st.default.object,st.default.undefined],r.headers),st.assert.any([st.default.string,st.default.urlInstance,st.default.undefined],r.prefixUrl),st.assert.any([st.default.object,st.default.undefined],r.cookieJar),st.assert.any([st.default.object,st.default.string,st.default.undefined],r.searchParams),st.assert.any([st.default.object,st.default.string,st.default.undefined],r.cache),st.assert.any([st.default.object,st.default.number,st.default.undefined],r.timeout),st.assert.any([st.default.object,st.default.undefined],r.context),st.assert.any([st.default.object,st.default.undefined],r.hooks),st.assert.any([st.default.boolean,st.default.undefined],r.decompress),st.assert.any([st.default.boolean,st.default.undefined],r.ignoreInvalidCookies),st.assert.any([st.default.boolean,st.default.undefined],r.followRedirect),st.assert.any([st.default.number,st.default.undefined],r.maxRedirects),st.assert.any([st.default.boolean,st.default.undefined],r.throwHttpErrors),st.assert.any([st.default.boolean,st.default.undefined],r.http2),st.assert.any([st.default.boolean,st.default.undefined],r.allowGetBody),st.assert.any([st.default.string,st.default.undefined],r.localAddress),st.assert.any([qae.isDnsLookupIpVersion,st.default.undefined],r.dnsLookupIpVersion),st.assert.any([st.default.object,st.default.undefined],r.https),st.assert.any([st.default.boolean,st.default.undefined],r.rejectUnauthorized),r.https&&(st.assert.any([st.default.boolean,st.default.undefined],r.https.rejectUnauthorized),st.assert.any([st.default.function_,st.default.undefined],r.https.checkServerIdentity),st.assert.any([st.default.string,st.default.object,st.default.array,st.default.undefined],r.https.certificateAuthority),st.assert.any([st.default.string,st.default.object,st.default.array,st.default.undefined],r.https.key),st.assert.any([st.default.string,st.default.object,st.default.array,st.default.undefined],r.https.certificate),st.assert.any([st.default.string,st.default.undefined],r.https.passphrase),st.assert.any([st.default.string,st.default.buffer,st.default.array,st.default.undefined],r.https.pfx)),st.assert.any([st.default.object,st.default.undefined],r.cacheOptions),st.default.string(r.method)?r.method=r.method.toUpperCase():r.method="GET",r.headers===o?.headers?r.headers={...r.headers}:r.headers=Jit({...o?.headers,...r.headers}),"slashes"in r)throw new TypeError("The legacy `url.Url` has been deprecated. Use `URL` instead.");if("auth"in r)throw new TypeError("Parameter `auth` is deprecated. Use `username` / `password` instead.");if("searchParams"in r&&r.searchParams&&r.searchParams!==o?.searchParams){let b;if(st.default.string(r.searchParams)||r.searchParams instanceof oh.URLSearchParams)b=new oh.URLSearchParams(r.searchParams);else{ost(r.searchParams),b=new oh.URLSearchParams;for(let E in r.searchParams){let F=r.searchParams[E];F===null?b.append(E,""):F!==void 0&&b.append(E,F)}}(a=o?.searchParams)===null||a===void 0||a.forEach((E,F)=>{b.has(F)||b.append(F,E)}),r.searchParams=b}if(r.username=(n=r.username)!==null&&n!==void 0?n:"",r.password=(u=r.password)!==null&&u!==void 0?u:"",st.default.undefined(r.prefixUrl)?r.prefixUrl=(A=o?.prefixUrl)!==null&&A!==void 0?A:"":(r.prefixUrl=r.prefixUrl.toString(),r.prefixUrl!==""&&!r.prefixUrl.endsWith("/")&&(r.prefixUrl+="/")),st.default.string(r.url)){if(r.url.startsWith("/"))throw new Error("`input` must not start with a slash when using `prefixUrl`");r.url=jae.default(r.prefixUrl+r.url,r)}else(st.default.undefined(r.url)&&r.prefixUrl!==""||r.protocol)&&(r.url=jae.default(r.prefixUrl,r));if(r.url){"port"in r&&delete r.port;let{prefixUrl:b}=r;Object.defineProperty(r,"prefixUrl",{set:F=>{let N=r.url;if(!N.href.startsWith(F))throw new Error(`Cannot change \`prefixUrl\` from ${b} to ${F}: ${N.href}`);r.url=new oh.URL(F+N.href.slice(b.length)),b=F},get:()=>b});let{protocol:E}=r.url;if(E==="unix:"&&(E="http:",r.url=new oh.URL(`http://unix${r.url.pathname}${r.url.search}`)),r.searchParams&&(r.url.search=r.searchParams.toString()),E!=="http:"&&E!=="https:")throw new hx(r);r.username===""?r.username=r.url.username:r.url.username=r.username,r.password===""?r.password=r.url.password:r.url.password=r.password}let{cookieJar:C}=r;if(C){let{setCookie:b,getCookieString:E}=C;st.assert.function_(b),st.assert.function_(E),b.length===4&&E.length===0&&(b=Nae.promisify(b.bind(r.cookieJar)),E=Nae.promisify(E.bind(r.cookieJar)),r.cookieJar={setCookie:b,getCookieString:E})}let{cache:I}=r;if(I&&(v4.has(I)||v4.set(I,new Uae((b,E)=>{let F=b[Zs](b,E);return st.default.promise(F)&&(F.once=(N,U)=>{if(N==="error")F.catch(U);else if(N==="abort")(async()=>{try{(await F).once("abort",U)}catch{}})();else throw new Error(`Unknown HTTP2 promise event: ${N}`);return F}),F},I))),r.cacheOptions={...r.cacheOptions},r.dnsCache===!0)I4||(I4=new Kit.default),r.dnsCache=I4;else if(!st.default.undefined(r.dnsCache)&&!r.dnsCache.lookup)throw new TypeError(`Parameter \`dnsCache\` must be a CacheableLookup instance or a boolean, got ${st.default(r.dnsCache)}`);st.default.number(r.timeout)?r.timeout={request:r.timeout}:o&&r.timeout!==o.timeout?r.timeout={...o.timeout,...r.timeout}:r.timeout={...r.timeout},r.context||(r.context={});let v=r.hooks===o?.hooks;r.hooks={...r.hooks};for(let b of Bn.knownHookEvents)if(b in r.hooks)if(st.default.array(r.hooks[b]))r.hooks[b]=[...r.hooks[b]];else throw new TypeError(`Parameter \`${b}\` must be an Array, got ${st.default(r.hooks[b])}`);else r.hooks[b]=[];if(o&&!v)for(let b of Bn.knownHookEvents)o.hooks[b].length>0&&(r.hooks[b]=[...o.hooks[b],...r.hooks[b]]);if("family"in r&&ah.default('"options.family" was never documented, please use "options.dnsLookupIpVersion"'),o?.https&&(r.https={...o.https,...r.https}),"rejectUnauthorized"in r&&ah.default('"options.rejectUnauthorized" is now deprecated, please use "options.https.rejectUnauthorized"'),"checkServerIdentity"in r&&ah.default('"options.checkServerIdentity" was never documented, please use "options.https.checkServerIdentity"'),"ca"in r&&ah.default('"options.ca" was never documented, please use "options.https.certificateAuthority"'),"key"in r&&ah.default('"options.key" was never documented, please use "options.https.key"'),"cert"in r&&ah.default('"options.cert" was never documented, please use "options.https.certificate"'),"passphrase"in r&&ah.default('"options.passphrase" was never documented, please use "options.https.passphrase"'),"pfx"in r&&ah.default('"options.pfx" was never documented, please use "options.https.pfx"'),"followRedirects"in r)throw new TypeError("The `followRedirects` option does not exist. Use `followRedirect` instead.");if(r.agent){for(let b in r.agent)if(b!=="http"&&b!=="https"&&b!=="http2")throw new TypeError(`Expected the \`options.agent\` properties to be \`http\`, \`https\` or \`http2\`, got \`${b}\``)}return r.maxRedirects=(p=r.maxRedirects)!==null&&p!==void 0?p:0,Bn.setNonEnumerableProperties([o,h],r),nst.default(r,o)}_lockWrite(){let e=()=>{throw new TypeError("The payload has been already provided")};this.write=e,this.end=e}_unlockWrite(){this.write=super.write,this.end=super.end}async _finalizeBody(){let{options:e}=this,{headers:r}=e,o=!st.default.undefined(e.form),a=!st.default.undefined(e.json),n=!st.default.undefined(e.body),u=o||a||n,A=Bn.withoutBody.has(e.method)&&!(e.method==="GET"&&e.allowGetBody);if(this._cannotHaveBody=A,u){if(A)throw new TypeError(`The \`${e.method}\` method cannot be used with a body`);if([n,o,a].filter(p=>p).length>1)throw new TypeError("The `body`, `json` and `form` options are mutually exclusive");if(n&&!(e.body instanceof Oae.Readable)&&!st.default.string(e.body)&&!st.default.buffer(e.body)&&!_ae.default(e.body))throw new TypeError("The `body` option must be a stream.Readable, string or Buffer");if(o&&!st.default.object(e.form))throw new TypeError("The `form` option must be an Object");{let p=!st.default.string(r["content-type"]);n?(_ae.default(e.body)&&p&&(r["content-type"]=`multipart/form-data; boundary=${e.body.getBoundary()}`),this[lh]=e.body):o?(p&&(r["content-type"]="application/x-www-form-urlencoded"),this[lh]=new oh.URLSearchParams(e.form).toString()):(p&&(r["content-type"]="application/json"),this[lh]=e.stringifyJson(e.json));let h=await Xit.default(this[lh],e.headers);st.default.undefined(r["content-length"])&&st.default.undefined(r["transfer-encoding"])&&!A&&!st.default.undefined(h)&&(r["content-length"]=String(h))}}else A?this._lockWrite():this._unlockWrite();this[xE]=Number(r["content-length"])||void 0}async _onResponseBase(e){let{options:r}=this,{url:o}=r;this[Kae]=e,r.decompress&&(e=Vit(e));let a=e.statusCode,n=e;n.statusMessage=n.statusMessage?n.statusMessage:Mae.STATUS_CODES[a],n.url=r.url.toString(),n.requestUrl=this.requestUrl,n.redirectUrls=this.redirects,n.request=this,n.isFromCache=e.fromCache||!1,n.ip=this.ip,n.retryCount=this.retryCount,this[Yae]=n.isFromCache,this[PE]=Number(e.headers["content-length"])||void 0,this[lx]=e,e.once("end",()=>{this[PE]=this[SE],this.emit("downloadProgress",this.downloadProgress)}),e.once("error",A=>{e.destroy(),this._beforeError(new m1(A,this))}),e.once("aborted",()=>{this._beforeError(new m1({name:"Error",message:"The server aborted pending request",code:"ECONNRESET"},this))}),this.emit("downloadProgress",this.downloadProgress);let u=e.headers["set-cookie"];if(st.default.object(r.cookieJar)&&u){let A=u.map(async p=>r.cookieJar.setCookie(p,o.toString()));r.ignoreInvalidCookies&&(A=A.map(async p=>p.catch(()=>{})));try{await Promise.all(A)}catch(p){this._beforeError(p);return}}if(r.followRedirect&&e.headers.location&&cst.has(a)){if(e.resume(),this[Zs]&&(this[B4](),delete this[Zs],this[Gae]()),(a===303&&r.method!=="GET"&&r.method!=="HEAD"||!r.methodRewriting)&&(r.method="GET","body"in r&&delete r.body,"json"in r&&delete r.json,"form"in r&&delete r.form,this[lh]=void 0,delete r.headers["content-length"]),this.redirects.length>=r.maxRedirects){this._beforeError(new cx(this));return}try{let p=Buffer.from(e.headers.location,"binary").toString(),h=new oh.URL(p,o),C=h.toString();decodeURI(C),h.hostname!==o.hostname||h.port!==o.port?("host"in r.headers&&delete r.headers.host,"cookie"in r.headers&&delete r.headers.cookie,"authorization"in r.headers&&delete r.headers.authorization,(r.username||r.password)&&(r.username="",r.password="")):(h.username=r.username,h.password=r.password),this.redirects.push(C),r.url=h;for(let I of r.hooks.beforeRedirect)await I(r,n);this.emit("redirect",n,r),await this._makeRequest()}catch(p){this._beforeError(p);return}return}if(r.isStream&&r.throwHttpErrors&&!rst.isResponseOk(n)){this._beforeError(new ux(n));return}e.on("readable",()=>{this[ax]&&this._read()}),this.on("resume",()=>{e.resume()}),this.on("pause",()=>{e.pause()}),e.once("end",()=>{this.push(null)}),this.emit("response",e);for(let A of this[ox])if(!A.headersSent){for(let p in e.headers){let h=r.decompress?p!=="content-encoding":!0,C=e.headers[p];h&&A.setHeader(p,C)}A.statusCode=a}}async _onResponse(e){try{await this._onResponseBase(e)}catch(r){this._beforeError(r)}}_onRequest(e){let{options:r}=this,{timeout:o,url:a}=r;Wit.default(e),this[B4]=Hae.default(e,o,a);let n=r.cache?"cacheableResponse":"response";e.once(n,p=>{this._onResponse(p)}),e.once("error",p=>{var h;e.destroy(),(h=e.res)===null||h===void 0||h.removeAllListeners("end"),p=p instanceof Hae.TimeoutError?new px(p,this.timings,this):new Vi(p.message,p,this),this._beforeError(p)}),this[Gae]=Zit.default(e,this,Ast),this[Zs]=e,this.emit("uploadProgress",this.uploadProgress);let u=this[lh],A=this.redirects.length===0?this:e;st.default.nodeStream(u)?(u.pipe(A),u.once("error",p=>{this._beforeError(new fx(p,this))})):(this._unlockWrite(),st.default.undefined(u)?(this._cannotHaveBody||this._noPipe)&&(A.end(),this._lockWrite()):(this._writeRequest(u,void 0,()=>{}),A.end(),this._lockWrite())),this.emit("request",e)}async _createCacheableRequest(e,r){return new Promise((o,a)=>{Object.assign(r,$it.default(e)),delete r.url;let n,u=v4.get(r.cache)(r,async A=>{A._readableState.autoDestroy=!1,n&&(await n).emit("cacheableResponse",A),o(A)});r.url=e,u.once("error",a),u.once("request",async A=>{n=A,o(n)})})}async _makeRequest(){var e,r,o,a,n;let{options:u}=this,{headers:A}=u;for(let U in A)if(st.default.undefined(A[U]))delete A[U];else if(st.default.null_(A[U]))throw new TypeError(`Use \`undefined\` instead of \`null\` to delete the \`${U}\` header`);if(u.decompress&&st.default.undefined(A["accept-encoding"])&&(A["accept-encoding"]=sst?"gzip, deflate, br":"gzip, deflate"),u.cookieJar){let U=await u.cookieJar.getCookieString(u.url.toString());st.default.nonEmptyString(U)&&(u.headers.cookie=U)}for(let U of u.hooks.beforeRequest){let z=await U(u);if(!st.default.undefined(z)){u.request=()=>z;break}}u.body&&this[lh]!==u.body&&(this[lh]=u.body);let{agent:p,request:h,timeout:C,url:I}=u;if(u.dnsCache&&!("lookup"in u)&&(u.lookup=u.dnsCache.lookup),I.hostname==="unix"){let U=/(?<socketPath>.+?):(?<path>.+)/.exec(`${I.pathname}${I.search}`);if(U?.groups){let{socketPath:z,path:te}=U.groups;Object.assign(u,{socketPath:z,path:te,host:""})}}let v=I.protocol==="https:",b;u.http2?b=zit.auto:b=v?Yit.request:Mae.request;let E=(e=u.request)!==null&&e!==void 0?e:b,F=u.cache?this._createCacheableRequest:E;p&&!u.http2&&(u.agent=p[v?"https":"http"]),u[Zs]=E,delete u.request,delete u.timeout;let N=u;if(N.shared=(r=u.cacheOptions)===null||r===void 0?void 0:r.shared,N.cacheHeuristic=(o=u.cacheOptions)===null||o===void 0?void 0:o.cacheHeuristic,N.immutableMinTimeToLive=(a=u.cacheOptions)===null||a===void 0?void 0:a.immutableMinTimeToLive,N.ignoreCargoCult=(n=u.cacheOptions)===null||n===void 0?void 0:n.ignoreCargoCult,u.dnsLookupIpVersion!==void 0)try{N.family=qae.dnsLookupIpVersionToFamily(u.dnsLookupIpVersion)}catch{throw new Error("Invalid `dnsLookupIpVersion` option value")}u.https&&("rejectUnauthorized"in u.https&&(N.rejectUnauthorized=u.https.rejectUnauthorized),u.https.checkServerIdentity&&(N.checkServerIdentity=u.https.checkServerIdentity),u.https.certificateAuthority&&(N.ca=u.https.certificateAuthority),u.https.certificate&&(N.cert=u.https.certificate),u.https.key&&(N.key=u.https.key),u.https.passphrase&&(N.passphrase=u.https.passphrase),u.https.pfx&&(N.pfx=u.https.pfx));try{let U=await F(I,N);st.default.undefined(U)&&(U=b(I,N)),u.request=h,u.timeout=C,u.agent=p,u.https&&("rejectUnauthorized"in u.https&&delete N.rejectUnauthorized,u.https.checkServerIdentity&&delete N.checkServerIdentity,u.https.certificateAuthority&&delete N.ca,u.https.certificate&&delete N.cert,u.https.key&&delete N.key,u.https.passphrase&&delete N.passphrase,u.https.pfx&&delete N.pfx),ast(U)?this._onRequest(U):this.writable?(this.once("finish",()=>{this._onResponse(U)}),this._unlockWrite(),this.end(),this._lockWrite()):this._onResponse(U)}catch(U){throw U instanceof Uae.CacheError?new Ax(U,this):new Vi(U.message,U,this)}}async _error(e){try{for(let r of this.options.hooks.beforeError)e=await r(e)}catch(r){e=new Vi(r.message,r,this)}this.destroy(e)}_beforeError(e){if(this[kE])return;let{options:r}=this,o=this.retryCount+1;this[kE]=!0,e instanceof Vi||(e=new Vi(e.message,e,this));let a=e,{response:n}=a;(async()=>{if(n&&!n.body){n.setEncoding(this._readableState.encoding);try{n.rawBody=await tst.default(n),n.body=n.rawBody.toString()}catch{}}if(this.listenerCount("retry")!==0){let u;try{let A;n&&"retry-after"in n.headers&&(A=Number(n.headers["retry-after"]),Number.isNaN(A)?(A=Date.parse(n.headers["retry-after"])-Date.now(),A<=0&&(A=1)):A*=1e3),u=await r.retry.calculateDelay({attemptCount:o,retryOptions:r.retry,error:a,retryAfter:A,computedValue:ist.default({attemptCount:o,retryOptions:r.retry,error:a,retryAfter:A,computedValue:0})})}catch(A){this._error(new Vi(A.message,A,this));return}if(u){let A=async()=>{try{for(let p of this.options.hooks.beforeRetry)await p(this.options,a,o)}catch(p){this._error(new Vi(p.message,e,this));return}this.destroyed||(this.destroy(),this.emit("retry",o,e))};this[Vae]=setTimeout(A,u);return}}this._error(a)})()}_read(){this[ax]=!0;let e=this[lx];if(e&&!this[kE]){e.readableLength&&(this[ax]=!1);let r;for(;(r=e.read())!==null;){this[SE]+=r.length,this[Wae]=!0;let o=this.downloadProgress;o.percent<1&&this.emit("downloadProgress",o),this.push(r)}}}_write(e,r,o){let a=()=>{this._writeRequest(e,r,o)};this.requestInitialized?a():this[d1].push(a)}_writeRequest(e,r,o){this[Zs].destroyed||(this._progressCallbacks.push(()=>{this[bE]+=Buffer.byteLength(e,r);let a=this.uploadProgress;a.percent<1&&this.emit("uploadProgress",a)}),this[Zs].write(e,r,a=>{!a&&this._progressCallbacks.length>0&&this._progressCallbacks.shift()(),o(a)}))}_final(e){let r=()=>{for(;this._progressCallbacks.length!==0;)this._progressCallbacks.shift()();if(!(Zs in this)){e();return}if(this[Zs].destroyed){e();return}this[Zs].end(o=>{o||(this[xE]=this[bE],this.emit("uploadProgress",this.uploadProgress),this[Zs].emit("upload-complete")),e(o)})};this.requestInitialized?r():this[d1].push(r)}_destroy(e,r){var o;this[kE]=!0,clearTimeout(this[Vae]),Zs in this&&(this[B4](),!((o=this[lx])===null||o===void 0)&&o.complete||this[Zs].destroy()),e!==null&&!st.default.undefined(e)&&!(e instanceof Vi)&&(e=new Vi(e.message,e,this)),r(e)}get _isAboutToError(){return this[kE]}get ip(){var e;return(e=this.socket)===null||e===void 0?void 0:e.remoteAddress}get aborted(){var e,r,o;return((r=(e=this[Zs])===null||e===void 0?void 0:e.destroyed)!==null&&r!==void 0?r:this.destroyed)&&!(!((o=this[Kae])===null||o===void 0)&&o.complete)}get socket(){var e,r;return(r=(e=this[Zs])===null||e===void 0?void 0:e.socket)!==null&&r!==void 0?r:void 0}get downloadProgress(){let e;return this[PE]?e=this[SE]/this[PE]:this[PE]===this[SE]?e=1:e=0,{percent:e,transferred:this[SE],total:this[PE]}}get uploadProgress(){let e;return this[xE]?e=this[bE]/this[xE]:this[xE]===this[bE]?e=1:e=0,{percent:e,transferred:this[bE],total:this[xE]}}get timings(){var e;return(e=this[Zs])===null||e===void 0?void 0:e.timings}get isFromCache(){return this[Yae]}pipe(e,r){if(this[Wae])throw new Error("Failed to pipe. The response has been emitted already.");return e instanceof w4.ServerResponse&&this[ox].add(e),super.pipe(e,r)}unpipe(e){return e instanceof w4.ServerResponse&&this[ox].delete(e),super.unpipe(e),this}};Bn.default=gx});var E1=_(qc=>{"use strict";var fst=qc&&qc.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),pst=qc&&qc.__exportStar||function(t,e){for(var r in t)r!=="default"&&!Object.prototype.hasOwnProperty.call(e,r)&&fst(e,t,r)};Object.defineProperty(qc,"__esModule",{value:!0});qc.CancelError=qc.ParseError=void 0;var zae=y1(),D4=class extends zae.RequestError{constructor(e,r){let{options:o}=r.request;super(`${e.message} in "${o.url.toString()}"`,e,r.request),this.name="ParseError"}};qc.ParseError=D4;var P4=class extends zae.RequestError{constructor(e){super("Promise was canceled",{},e),this.name="CancelError"}get isCanceled(){return!0}};qc.CancelError=P4;pst(y1(),qc)});var Xae=_(S4=>{"use strict";Object.defineProperty(S4,"__esModule",{value:!0});var Jae=E1(),hst=(t,e,r,o)=>{let{rawBody:a}=t;try{if(e==="text")return a.toString(o);if(e==="json")return a.length===0?"":r(a.toString());if(e==="buffer")return a;throw new Jae.ParseError({message:`Unknown body type '${e}'`,name:"Error"},t)}catch(n){throw new Jae.ParseError(n,t)}};S4.default=hst});var x4=_(ch=>{"use strict";var gst=ch&&ch.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),dst=ch&&ch.__exportStar||function(t,e){for(var r in t)r!=="default"&&!Object.prototype.hasOwnProperty.call(e,r)&&gst(e,t,r)};Object.defineProperty(ch,"__esModule",{value:!0});var mst=Be("events"),yst=Ff(),Est=Xse(),dx=E1(),Zae=Xae(),$ae=y1(),Cst=u4(),wst=m4(),ele=y4(),Ist=["request","response","redirect","uploadProgress","downloadProgress"];function tle(t){let e,r,o=new mst.EventEmitter,a=new Est((u,A,p)=>{let h=C=>{let I=new $ae.default(void 0,t);I.retryCount=C,I._noPipe=!0,p(()=>I.destroy()),p.shouldReject=!1,p(()=>A(new dx.CancelError(I))),e=I,I.once("response",async E=>{var F;if(E.retryCount=C,E.request.aborted)return;let N;try{N=await wst.default(I),E.rawBody=N}catch{return}if(I._isAboutToError)return;let U=((F=E.headers["content-encoding"])!==null&&F!==void 0?F:"").toLowerCase(),z=["gzip","deflate","br"].includes(U),{options:te}=I;if(z&&!te.decompress)E.body=N;else try{E.body=Zae.default(E,te.responseType,te.parseJson,te.encoding)}catch(le){if(E.body=N.toString(),ele.isResponseOk(E)){I._beforeError(le);return}}try{for(let[le,pe]of te.hooks.afterResponse.entries())E=await pe(E,async ue=>{let ye=$ae.default.normalizeArguments(void 0,{...ue,retry:{calculateDelay:()=>0},throwHttpErrors:!1,resolveBodyOnly:!1},te);ye.hooks.afterResponse=ye.hooks.afterResponse.slice(0,le);for(let Ie of ye.hooks.beforeRetry)await Ie(ye);let ae=tle(ye);return p(()=>{ae.catch(()=>{}),ae.cancel()}),ae})}catch(le){I._beforeError(new dx.RequestError(le.message,le,I));return}if(!ele.isResponseOk(E)){I._beforeError(new dx.HTTPError(E));return}r=E,u(I.options.resolveBodyOnly?E.body:E)});let v=E=>{if(a.isCanceled)return;let{options:F}=I;if(E instanceof dx.HTTPError&&!F.throwHttpErrors){let{response:N}=E;u(I.options.resolveBodyOnly?N.body:N);return}A(E)};I.once("error",v);let b=I.options.body;I.once("retry",(E,F)=>{var N,U;if(b===((N=F.request)===null||N===void 0?void 0:N.options.body)&&yst.default.nodeStream((U=F.request)===null||U===void 0?void 0:U.options.body)){v(F);return}h(E)}),Cst.default(I,o,Ist)};h(0)});a.on=(u,A)=>(o.on(u,A),a);let n=u=>{let A=(async()=>{await a;let{options:p}=r.request;return Zae.default(r,u,p.parseJson,p.encoding)})();return Object.defineProperties(A,Object.getOwnPropertyDescriptors(a)),A};return a.json=()=>{let{headers:u}=e.options;return!e.writableFinished&&u.accept===void 0&&(u.accept="application/json"),n("json")},a.buffer=()=>n("buffer"),a.text=()=>n("text"),a}ch.default=tle;dst(E1(),ch)});var rle=_(b4=>{"use strict";Object.defineProperty(b4,"__esModule",{value:!0});var Bst=E1();function vst(t,...e){let r=(async()=>{if(t instanceof Bst.RequestError)try{for(let a of e)if(a)for(let n of a)t=await n(t)}catch(a){t=a}throw t})(),o=()=>r;return r.json=o,r.text=o,r.buffer=o,r.on=o,r}b4.default=vst});var sle=_(k4=>{"use strict";Object.defineProperty(k4,"__esModule",{value:!0});var nle=Ff();function ile(t){for(let e of Object.values(t))(nle.default.plainObject(e)||nle.default.array(e))&&ile(e);return Object.freeze(t)}k4.default=ile});var ale=_(ole=>{"use strict";Object.defineProperty(ole,"__esModule",{value:!0})});var Q4=_(Vl=>{"use strict";var Dst=Vl&&Vl.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),Pst=Vl&&Vl.__exportStar||function(t,e){for(var r in t)r!=="default"&&!Object.prototype.hasOwnProperty.call(e,r)&&Dst(e,t,r)};Object.defineProperty(Vl,"__esModule",{value:!0});Vl.defaultHandler=void 0;var lle=Ff(),Kl=x4(),Sst=rle(),yx=y1(),xst=sle(),bst={RequestError:Kl.RequestError,CacheError:Kl.CacheError,ReadError:Kl.ReadError,HTTPError:Kl.HTTPError,MaxRedirectsError:Kl.MaxRedirectsError,TimeoutError:Kl.TimeoutError,ParseError:Kl.ParseError,CancelError:Kl.CancelError,UnsupportedProtocolError:Kl.UnsupportedProtocolError,UploadError:Kl.UploadError},kst=async t=>new Promise(e=>{setTimeout(e,t)}),{normalizeArguments:mx}=yx.default,cle=(...t)=>{let e;for(let r of t)e=mx(void 0,r,e);return e},Qst=t=>t.isStream?new yx.default(void 0,t):Kl.default(t),Fst=t=>"defaults"in t&&"options"in t.defaults,Rst=["get","post","put","patch","head","delete"];Vl.defaultHandler=(t,e)=>e(t);var ule=(t,e)=>{if(t)for(let r of t)r(e)},Ale=t=>{t._rawHandlers=t.handlers,t.handlers=t.handlers.map(o=>(a,n)=>{let u,A=o(a,p=>(u=n(p),u));if(A!==u&&!a.isStream&&u){let p=A,{then:h,catch:C,finally:I}=p;Object.setPrototypeOf(p,Object.getPrototypeOf(u)),Object.defineProperties(p,Object.getOwnPropertyDescriptors(u)),p.then=h,p.catch=C,p.finally=I}return A});let e=(o,a={},n)=>{var u,A;let p=0,h=C=>t.handlers[p++](C,p===t.handlers.length?Qst:h);if(lle.default.plainObject(o)){let C={...o,...a};yx.setNonEnumerableProperties([o,a],C),a=C,o=void 0}try{let C;try{ule(t.options.hooks.init,a),ule((u=a.hooks)===null||u===void 0?void 0:u.init,a)}catch(v){C=v}let I=mx(o,a,n??t.options);if(I[yx.kIsNormalizedAlready]=!0,C)throw new Kl.RequestError(C.message,C,I);return h(I)}catch(C){if(a.isStream)throw C;return Sst.default(C,t.options.hooks.beforeError,(A=a.hooks)===null||A===void 0?void 0:A.beforeError)}};e.extend=(...o)=>{let a=[t.options],n=[...t._rawHandlers],u;for(let A of o)Fst(A)?(a.push(A.defaults.options),n.push(...A.defaults._rawHandlers),u=A.defaults.mutableDefaults):(a.push(A),"handlers"in A&&n.push(...A.handlers),u=A.mutableDefaults);return n=n.filter(A=>A!==Vl.defaultHandler),n.length===0&&n.push(Vl.defaultHandler),Ale({options:cle(...a),handlers:n,mutableDefaults:Boolean(u)})};let r=async function*(o,a){let n=mx(o,a,t.options);n.resolveBodyOnly=!1;let u=n.pagination;if(!lle.default.object(u))throw new TypeError("`options.pagination` must be implemented");let A=[],{countLimit:p}=u,h=0;for(;h<u.requestLimit;){h!==0&&await kst(u.backoff);let C=await e(void 0,void 0,n),I=await u.transform(C),v=[];for(let E of I)if(u.filter(E,A,v)&&(!u.shouldContinue(E,A,v)||(yield E,u.stackAllItems&&A.push(E),v.push(E),--p<=0)))return;let b=u.paginate(C,A,v);if(b===!1)return;b===C.request.options?n=C.request.options:b!==void 0&&(n=mx(void 0,b,n)),h++}};e.paginate=r,e.paginate.all=async(o,a)=>{let n=[];for await(let u of r(o,a))n.push(u);return n},e.paginate.each=r,e.stream=(o,a)=>e(o,{...a,isStream:!0});for(let o of Rst)e[o]=(a,n)=>e(a,{...n,method:o}),e.stream[o]=(a,n)=>e(a,{...n,method:o,isStream:!0});return Object.assign(e,bst),Object.defineProperty(e,"defaults",{value:t.mutableDefaults?t:xst.default(t),writable:t.mutableDefaults,configurable:t.mutableDefaults,enumerable:!0}),e.mergeOptions=cle,e};Vl.default=Ale;Pst(ale(),Vl)});var hle=_((Rf,Ex)=>{"use strict";var Tst=Rf&&Rf.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),fle=Rf&&Rf.__exportStar||function(t,e){for(var r in t)r!=="default"&&!Object.prototype.hasOwnProperty.call(e,r)&&Tst(e,t,r)};Object.defineProperty(Rf,"__esModule",{value:!0});var Lst=Be("url"),ple=Q4(),Nst={options:{method:"GET",retry:{limit:2,methods:["GET","PUT","HEAD","DELETE","OPTIONS","TRACE"],statusCodes:[408,413,429,500,502,503,504,521,522,524],errorCodes:["ETIMEDOUT","ECONNRESET","EADDRINUSE","ECONNREFUSED","EPIPE","ENOTFOUND","ENETUNREACH","EAI_AGAIN"],maxRetryAfter:void 0,calculateDelay:({computedValue:t})=>t},timeout:{},headers:{"user-agent":"got (https://github.com/sindresorhus/got)"},hooks:{init:[],beforeRequest:[],beforeRedirect:[],beforeRetry:[],beforeError:[],afterResponse:[]},cache:void 0,dnsCache:void 0,decompress:!0,throwHttpErrors:!0,followRedirect:!0,isStream:!1,responseType:"text",resolveBodyOnly:!1,maxRedirects:10,prefixUrl:"",methodRewriting:!0,ignoreInvalidCookies:!1,context:{},http2:!1,allowGetBody:!1,https:void 0,pagination:{transform:t=>t.request.options.responseType==="json"?t.body:JSON.parse(t.body),paginate:t=>{if(!Reflect.has(t.headers,"link"))return!1;let e=t.headers.link.split(","),r;for(let o of e){let a=o.split(";");if(a[1].includes("next")){r=a[0].trimStart().trim(),r=r.slice(1,-1);break}}return r?{url:new Lst.URL(r)}:!1},filter:()=>!0,shouldContinue:()=>!0,countLimit:1/0,backoff:0,requestLimit:1e4,stackAllItems:!0},parseJson:t=>JSON.parse(t),stringifyJson:t=>JSON.stringify(t),cacheOptions:{}},handlers:[ple.defaultHandler],mutableDefaults:!1},F4=ple.default(Nst);Rf.default=F4;Ex.exports=F4;Ex.exports.default=F4;Ex.exports.__esModule=!0;fle(Q4(),Rf);fle(x4(),Rf)});var rn={};Vt(rn,{Method:()=>wle,del:()=>Hst,get:()=>N4,getNetworkSettings:()=>Cle,post:()=>O4,put:()=>_st,request:()=>C1});function mle(t){let e=new Cx.URL(t),r={host:e.hostname,headers:{}};return e.port&&(r.port=Number(e.port)),e.username&&e.password&&(r.proxyAuth=`${e.username}:${e.password}`),{proxy:r}}async function R4(t){return ol(dle,t,()=>oe.readFilePromise(t).then(e=>(dle.set(t,e),e)))}function Ust({statusCode:t,statusMessage:e},r){let o=_t(r,t,Et.NUMBER),a=`https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/${t}`;return Xy(r,`${o}${e?` (${e})`:""}`,a)}async function wx(t,{configuration:e,customErrorMessage:r}){try{return await t}catch(o){if(o.name!=="HTTPError")throw o;let a=r?.(o,e)??o.response.body?.error;a==null&&(o.message.startsWith("Response code")?a="The remote server failed to provide the requested resource":a=o.message),o.code==="ETIMEDOUT"&&o.event==="socket"&&(a+=`(can be increased via ${_t(e,"httpTimeout",Et.SETTING)})`);let n=new Jt(35,a,u=>{o.response&&u.reportError(35,` ${Ju(e,{label:"Response Code",value:_c(Et.NO_HINT,Ust(o.response,e))})}`),o.request&&(u.reportError(35,` ${Ju(e,{label:"Request Method",value:_c(Et.NO_HINT,o.request.options.method)})}`),u.reportError(35,` ${Ju(e,{label:"Request URL",value:_c(Et.URL,o.request.requestUrl)})}`)),o.request.redirects.length>0&&u.reportError(35,` ${Ju(e,{label:"Request Redirects",value:_c(Et.NO_HINT,xN(e,o.request.redirects,Et.URL))})}`),o.request.retryCount===o.request.options.retry.limit&&u.reportError(35,` ${Ju(e,{label:"Request Retry Count",value:_c(Et.NO_HINT,`${_t(e,o.request.retryCount,Et.NUMBER)} (can be increased via ${_t(e,"httpRetry",Et.SETTING)})`)})}`)});throw n.originalError=o,n}}function Cle(t,e){let r=[...e.configuration.get("networkSettings")].sort(([u],[A])=>A.length-u.length),o={enableNetwork:void 0,httpsCaFilePath:void 0,httpProxy:void 0,httpsProxy:void 0,httpsKeyFilePath:void 0,httpsCertFilePath:void 0},a=Object.keys(o),n=typeof t=="string"?new Cx.URL(t):t;for(let[u,A]of r)if(L4.default.isMatch(n.hostname,u))for(let p of a){let h=A.get(p);h!==null&&typeof o[p]>"u"&&(o[p]=h)}for(let u of a)typeof o[u]>"u"&&(o[u]=e.configuration.get(u));return o}async function C1(t,e,{configuration:r,headers:o,jsonRequest:a,jsonResponse:n,method:u="GET",wrapNetworkRequest:A}){let p={target:t,body:e,configuration:r,headers:o,jsonRequest:a,jsonResponse:n,method:u},h=async()=>await jst(t,e,p),C=typeof A<"u"?await A(h,p):h;return await(await r.reduceHook(v=>v.wrapNetworkRequest,C,p))()}async function N4(t,{configuration:e,jsonResponse:r,customErrorMessage:o,wrapNetworkRequest:a,...n}){let u=()=>wx(C1(t,null,{configuration:e,wrapNetworkRequest:a,...n}),{configuration:e,customErrorMessage:o}).then(p=>p.body),A=await(typeof a<"u"?u():ol(gle,t,()=>u().then(p=>(gle.set(t,p),p))));return r?JSON.parse(A.toString()):A}async function _st(t,e,{customErrorMessage:r,...o}){return(await wx(C1(t,e,{...o,method:"PUT"}),{customErrorMessage:r,configuration:o.configuration})).body}async function O4(t,e,{customErrorMessage:r,...o}){return(await wx(C1(t,e,{...o,method:"POST"}),{customErrorMessage:r,configuration:o.configuration})).body}async function Hst(t,{customErrorMessage:e,...r}){return(await wx(C1(t,null,{...r,method:"DELETE"}),{customErrorMessage:e,configuration:r.configuration})).body}async function jst(t,e,{configuration:r,headers:o,jsonRequest:a,jsonResponse:n,method:u="GET"}){let A=typeof t=="string"?new Cx.URL(t):t,p=Cle(A,{configuration:r});if(p.enableNetwork===!1)throw new Jt(80,`Request to '${A.href}' has been blocked because of your configuration settings`);if(A.protocol==="http:"&&!L4.default.isMatch(A.hostname,r.get("unsafeHttpWhitelist")))throw new Jt(81,`Unsafe http requests must be explicitly whitelisted in your configuration (${A.hostname})`);let C={agent:{http:p.httpProxy?T4.default.httpOverHttp(mle(p.httpProxy)):Ost,https:p.httpsProxy?T4.default.httpsOverHttp(mle(p.httpsProxy)):Mst},headers:o,method:u};C.responseType=n?"json":"buffer",e!==null&&(Buffer.isBuffer(e)||!a&&typeof e=="string"?C.body=e:C.json=e);let I=r.get("httpTimeout"),v=r.get("httpRetry"),b=r.get("enableStrictSsl"),E=p.httpsCaFilePath,F=p.httpsCertFilePath,N=p.httpsKeyFilePath,{default:U}=await Promise.resolve().then(()=>$e(hle())),z=E?await R4(E):void 0,te=F?await R4(F):void 0,le=N?await R4(N):void 0,pe=U.extend({timeout:{socket:I},retry:v,https:{rejectUnauthorized:b,certificateAuthority:z,certificate:te,key:le},...C});return r.getLimit("networkConcurrency")(()=>pe(A))}var yle,Ele,L4,T4,Cx,gle,dle,Ost,Mst,wle,Ix=yt(()=>{Pt();yle=Be("https"),Ele=Be("http"),L4=$e(Zo()),T4=$e(Wse()),Cx=Be("url");Yl();ql();jl();gle=new Map,dle=new Map,Ost=new Ele.Agent({keepAlive:!0}),Mst=new yle.Agent({keepAlive:!0});wle=(a=>(a.GET="GET",a.PUT="PUT",a.POST="POST",a.DELETE="DELETE",a))(wle||{})});var zi={};Vt(zi,{availableParallelism:()=>U4,getArchitecture:()=>w1,getArchitectureName:()=>Yst,getArchitectureSet:()=>M4,getCaller:()=>zst,openUrl:()=>qst});function Gst(){if(process.platform==="darwin"||process.platform==="win32")return null;let e=(process.report?.getReport()??{}).sharedObjects??[],r=/\/(?:(ld-linux-|[^/]+-linux-gnu\/)|(libc.musl-|ld-musl-))/;return YI(e,o=>{let a=o.match(r);if(!a)return YI.skip;if(a[1])return"glibc";if(a[2])return"musl";throw new Error("Assertion failed: Expected the libc variant to have been detected")})??null}function w1(){return Ble=Ble??{os:process.platform,cpu:process.arch,libc:Gst()}}function Yst(t=w1()){return t.libc?`${t.os}-${t.cpu}-${t.libc}`:`${t.os}-${t.cpu}`}function M4(){let t=w1();return vle=vle??{os:[t.os],cpu:[t.cpu],libc:t.libc?[t.libc]:[]}}function Vst(t){let e=Wst.exec(t);if(!e)return null;let r=e[2]&&e[2].indexOf("native")===0,o=e[2]&&e[2].indexOf("eval")===0,a=Kst.exec(e[2]);return o&&a!=null&&(e[2]=a[1],e[3]=a[2],e[4]=a[3]),{file:r?null:e[2],methodName:e[1]||"<unknown>",arguments:r?[e[2]]:[],line:e[3]?+e[3]:null,column:e[4]?+e[4]:null}}function zst(){let e=new Error().stack.split(` -`)[3];return Vst(e)}function U4(){return typeof Bx.default.availableParallelism<"u"?Bx.default.availableParallelism():Math.max(1,Bx.default.cpus().length)}var Bx,Ile,qst,Ble,vle,Wst,Kst,vx=yt(()=>{Pt();Bx=$e(Be("os"));Dx();jl();Ile=new Map([["darwin","open"],["linux","xdg-open"],["win32","explorer.exe"]]).get(process.platform),qst=typeof Ile<"u"?async t=>{try{return await _4(Ile,[t],{cwd:V.cwd()}),!0}catch{return!1}}:void 0;Wst=/^\s*at (.*?) ?\(((?:file|https?|blob|chrome-extension|native|eval|webpack|<anonymous>|\/|[a-z]:\\|\\\\).*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i,Kst=/\((\S*)(?::(\d+))(?::(\d+))\)/});function Y4(t,e,r,o,a){let n=c1(r);if(o.isArray||o.type==="ANY"&&Array.isArray(n))return Array.isArray(n)?n.map((u,A)=>H4(t,`${e}[${A}]`,u,o,a)):String(n).split(/,/).map(u=>H4(t,e,u,o,a));if(Array.isArray(n))throw new Error(`Non-array configuration settings "${e}" cannot be an array`);return H4(t,e,r,o,a)}function H4(t,e,r,o,a){let n=c1(r);switch(o.type){case"ANY":return GS(n);case"SHAPE":return $st(t,e,r,o,a);case"MAP":return eot(t,e,r,o,a)}if(n===null&&!o.isNullable&&o.default!==null)throw new Error(`Non-nullable configuration settings "${e}" cannot be set to null`);if(o.values?.includes(n))return n;let A=(()=>{if(o.type==="BOOLEAN"&&typeof n!="string")return WI(n);if(typeof n!="string")throw new Error(`Expected configuration setting "${e}" to be a string, got ${typeof n}`);let p=iS(n,{env:t.env});switch(o.type){case"ABSOLUTE_PATH":{let h=a,C=mM(r);return C&&C[0]!=="<"&&(h=V.dirname(C)),V.resolve(h,fe.toPortablePath(p))}case"LOCATOR_LOOSE":return Sf(p,!1);case"NUMBER":return parseInt(p);case"LOCATOR":return Sf(p);case"BOOLEAN":return WI(p);default:return p}})();if(o.values&&!o.values.includes(A))throw new Error(`Invalid value, expected one of ${o.values.join(", ")}`);return A}function $st(t,e,r,o,a){let n=c1(r);if(typeof n!="object"||Array.isArray(n))throw new it(`Object configuration settings "${e}" must be an object`);let u=W4(t,o,{ignoreArrays:!0});if(n===null)return u;for(let[A,p]of Object.entries(n)){let h=`${e}.${A}`;if(!o.properties[A])throw new it(`Unrecognized configuration settings found: ${e}.${A} - run "yarn config -v" to see the list of settings supported in Yarn`);u.set(A,Y4(t,h,p,o.properties[A],a))}return u}function eot(t,e,r,o,a){let n=c1(r),u=new Map;if(typeof n!="object"||Array.isArray(n))throw new it(`Map configuration settings "${e}" must be an object`);if(n===null)return u;for(let[A,p]of Object.entries(n)){let h=o.normalizeKeys?o.normalizeKeys(A):A,C=`${e}['${h}']`,I=o.valueDefinition;u.set(h,Y4(t,C,p,I,a))}return u}function W4(t,e,{ignoreArrays:r=!1}={}){switch(e.type){case"SHAPE":{if(e.isArray&&!r)return[];let o=new Map;for(let[a,n]of Object.entries(e.properties))o.set(a,W4(t,n));return o}break;case"MAP":return e.isArray&&!r?[]:new Map;case"ABSOLUTE_PATH":return e.default===null?null:t.projectCwd===null?Array.isArray(e.default)?e.default.map(o=>V.normalize(o)):V.isAbsolute(e.default)?V.normalize(e.default):e.isNullable?null:void 0:Array.isArray(e.default)?e.default.map(o=>V.resolve(t.projectCwd,o)):V.resolve(t.projectCwd,e.default);default:return e.default}}function Sx(t,e,r){if(e.type==="SECRET"&&typeof t=="string"&&r.hideSecrets)return Zst;if(e.type==="ABSOLUTE_PATH"&&typeof t=="string"&&r.getNativePaths)return fe.fromPortablePath(t);if(e.isArray&&Array.isArray(t)){let o=[];for(let a of t)o.push(Sx(a,e,r));return o}if(e.type==="MAP"&&t instanceof Map){if(t.size===0)return;let o=new Map;for(let[a,n]of t.entries()){let u=Sx(n,e.valueDefinition,r);typeof u<"u"&&o.set(a,u)}return o}if(e.type==="SHAPE"&&t instanceof Map){if(t.size===0)return;let o=new Map;for(let[a,n]of t.entries()){let u=e.properties[a],A=Sx(n,u,r);typeof A<"u"&&o.set(a,A)}return o}return t}function tot(){let t={};for(let[e,r]of Object.entries(process.env))e=e.toLowerCase(),e.startsWith(xx)&&(e=(0,Ple.default)(e.slice(xx.length)),t[e]=r);return t}function q4(){let t=`${xx}rc_filename`;for(let[e,r]of Object.entries(process.env))if(e.toLowerCase()===t&&typeof r=="string")return r;return G4}async function Dle(t){try{return await oe.readFilePromise(t)}catch{return Buffer.of()}}async function rot(t,e){return Buffer.compare(...await Promise.all([Dle(t),Dle(e)]))===0}async function not(t,e){let[r,o]=await Promise.all([oe.statPromise(t),oe.statPromise(e)]);return r.dev===o.dev&&r.ino===o.ino}async function sot({configuration:t,selfPath:e}){let r=t.get("yarnPath");return t.get("ignorePath")||r===null||r===e||await iot(r,e)?null:r}var Ple,Tf,Sle,xle,ble,j4,Jst,I1,Xst,QE,xx,G4,Zst,B1,kle,bx,Px,iot,rA,Ke,v1=yt(()=>{Pt();Ll();Ple=$e(sV()),Tf=$e($g());qt();Sle=$e($V()),xle=Be("module"),ble=$e(nd()),j4=Be("stream");ase();AE();cM();uM();AM();Lse();fM();Bd();_se();WS();ql();rh();Ix();jl();vx();bf();xo();Jst=Tf.GITHUB_ACTIONS&&process.env.GITHUB_EVENT_PATH?!(oe.readJsonSync(fe.toPortablePath(process.env.GITHUB_EVENT_PATH)).repository?.private??!0):!1,I1=new Set(["@yarnpkg/plugin-constraints","@yarnpkg/plugin-exec","@yarnpkg/plugin-interactive-tools","@yarnpkg/plugin-stage","@yarnpkg/plugin-typescript","@yarnpkg/plugin-version","@yarnpkg/plugin-workspace-tools"]),Xst=new Set(["isTestEnv","injectNpmUser","injectNpmPassword","injectNpm2FaToken","cacheCheckpointOverride","cacheVersionOverride","lockfileVersionOverride","binFolder","version","flags","profile","gpg","ignoreNode","wrapOutput","home","confDir","registry","ignoreCwd"]),QE=/^(?!v)[a-z0-9._-]+$/i,xx="yarn_",G4=".yarnrc.yml",Zst="********",B1=(C=>(C.ANY="ANY",C.BOOLEAN="BOOLEAN",C.ABSOLUTE_PATH="ABSOLUTE_PATH",C.LOCATOR="LOCATOR",C.LOCATOR_LOOSE="LOCATOR_LOOSE",C.NUMBER="NUMBER",C.STRING="STRING",C.SECRET="SECRET",C.SHAPE="SHAPE",C.MAP="MAP",C))(B1||{}),kle=Et,bx=(r=>(r.JUNCTIONS="junctions",r.SYMLINKS="symlinks",r))(bx||{}),Px={lastUpdateCheck:{description:"Last timestamp we checked whether new Yarn versions were available",type:"STRING",default:null},yarnPath:{description:"Path to the local executable that must be used over the global one",type:"ABSOLUTE_PATH",default:null},ignorePath:{description:"If true, the local executable will be ignored when using the global one",type:"BOOLEAN",default:!1},globalFolder:{description:"Folder where all system-global files are stored",type:"ABSOLUTE_PATH",default:EM()},cacheFolder:{description:"Folder where the cache files must be written",type:"ABSOLUTE_PATH",default:"./.yarn/cache"},compressionLevel:{description:"Zip files compression level, from 0 to 9 or mixed (a variant of 9, which stores some files uncompressed, when compression doesn't yield good results)",type:"NUMBER",values:["mixed",0,1,2,3,4,5,6,7,8,9],default:0},virtualFolder:{description:"Folder where the virtual packages (cf doc) will be mapped on the disk (must be named __virtual__)",type:"ABSOLUTE_PATH",default:"./.yarn/__virtual__"},installStatePath:{description:"Path of the file where the install state will be persisted",type:"ABSOLUTE_PATH",default:"./.yarn/install-state.gz"},immutablePatterns:{description:"Array of glob patterns; files matching them won't be allowed to change during immutable installs",type:"STRING",default:[],isArray:!0},rcFilename:{description:"Name of the files where the configuration can be found",type:"STRING",default:q4()},enableGlobalCache:{description:"If true, the system-wide cache folder will be used regardless of `cache-folder`",type:"BOOLEAN",default:!0},cacheMigrationMode:{description:"Defines the conditions under which Yarn upgrades should cause the cache archives to be regenerated.",type:"STRING",values:["always","match-spec","required-only"],default:"always"},enableColors:{description:"If true, the CLI is allowed to use colors in its output",type:"BOOLEAN",default:aS,defaultText:"<dynamic>"},enableHyperlinks:{description:"If true, the CLI is allowed to use hyperlinks in its output",type:"BOOLEAN",default:SN,defaultText:"<dynamic>"},enableInlineBuilds:{description:"If true, the CLI will print the build output on the command line",type:"BOOLEAN",default:Tf.isCI,defaultText:"<dynamic>"},enableMessageNames:{description:"If true, the CLI will prefix most messages with codes suitable for search engines",type:"BOOLEAN",default:!0},enableProgressBars:{description:"If true, the CLI is allowed to show a progress bar for long-running events",type:"BOOLEAN",default:!Tf.isCI,defaultText:"<dynamic>"},enableTimers:{description:"If true, the CLI is allowed to print the time spent executing commands",type:"BOOLEAN",default:!0},enableTips:{description:"If true, installs will print a helpful message every day of the week",type:"BOOLEAN",default:!Tf.isCI,defaultText:"<dynamic>"},preferInteractive:{description:"If true, the CLI will automatically use the interactive mode when called from a TTY",type:"BOOLEAN",default:!1},preferTruncatedLines:{description:"If true, the CLI will truncate lines that would go beyond the size of the terminal",type:"BOOLEAN",default:!1},progressBarStyle:{description:"Which style of progress bar should be used (only when progress bars are enabled)",type:"STRING",default:void 0,defaultText:"<dynamic>"},defaultLanguageName:{description:"Default language mode that should be used when a package doesn't offer any insight",type:"STRING",default:"node"},defaultProtocol:{description:"Default resolution protocol used when resolving pure semver and tag ranges",type:"STRING",default:"npm:"},enableTransparentWorkspaces:{description:"If false, Yarn won't automatically resolve workspace dependencies unless they use the `workspace:` protocol",type:"BOOLEAN",default:!0},supportedArchitectures:{description:"Architectures that Yarn will fetch and inject into the resolver",type:"SHAPE",properties:{os:{description:"Array of supported process.platform strings, or null to target them all",type:"STRING",isArray:!0,isNullable:!0,default:["current"]},cpu:{description:"Array of supported process.arch strings, or null to target them all",type:"STRING",isArray:!0,isNullable:!0,default:["current"]},libc:{description:"Array of supported libc libraries, or null to target them all",type:"STRING",isArray:!0,isNullable:!0,default:["current"]}}},enableMirror:{description:"If true, the downloaded packages will be retrieved and stored in both the local and global folders",type:"BOOLEAN",default:!0},enableNetwork:{description:"If false, Yarn will refuse to use the network if required to",type:"BOOLEAN",default:!0},enableOfflineMode:{description:"If true, Yarn will attempt to retrieve files and metadata from the global cache rather than the network",type:"BOOLEAN",default:!1},httpProxy:{description:"URL of the http proxy that must be used for outgoing http requests",type:"STRING",default:null},httpsProxy:{description:"URL of the http proxy that must be used for outgoing https requests",type:"STRING",default:null},unsafeHttpWhitelist:{description:"List of the hostnames for which http queries are allowed (glob patterns are supported)",type:"STRING",default:[],isArray:!0},httpTimeout:{description:"Timeout of each http request in milliseconds",type:"NUMBER",default:6e4},httpRetry:{description:"Retry times on http failure",type:"NUMBER",default:3},networkConcurrency:{description:"Maximal number of concurrent requests",type:"NUMBER",default:50},taskPoolConcurrency:{description:"Maximal amount of concurrent heavy task processing",type:"NUMBER",default:U4()},taskPoolMode:{description:"Execution strategy for heavy tasks",type:"STRING",values:["async","workers"],default:"workers"},networkSettings:{description:"Network settings per hostname (glob patterns are supported)",type:"MAP",valueDefinition:{description:"",type:"SHAPE",properties:{httpsCaFilePath:{description:"Path to file containing one or multiple Certificate Authority signing certificates",type:"ABSOLUTE_PATH",default:null},enableNetwork:{description:"If false, the package manager will refuse to use the network if required to",type:"BOOLEAN",default:null},httpProxy:{description:"URL of the http proxy that must be used for outgoing http requests",type:"STRING",default:null},httpsProxy:{description:"URL of the http proxy that must be used for outgoing https requests",type:"STRING",default:null},httpsKeyFilePath:{description:"Path to file containing private key in PEM format",type:"ABSOLUTE_PATH",default:null},httpsCertFilePath:{description:"Path to file containing certificate chain in PEM format",type:"ABSOLUTE_PATH",default:null}}}},httpsCaFilePath:{description:"A path to a file containing one or multiple Certificate Authority signing certificates",type:"ABSOLUTE_PATH",default:null},httpsKeyFilePath:{description:"Path to file containing private key in PEM format",type:"ABSOLUTE_PATH",default:null},httpsCertFilePath:{description:"Path to file containing certificate chain in PEM format",type:"ABSOLUTE_PATH",default:null},enableStrictSsl:{description:"If false, SSL certificate errors will be ignored",type:"BOOLEAN",default:!0},logFilters:{description:"Overrides for log levels",type:"SHAPE",isArray:!0,concatenateValues:!0,properties:{code:{description:"Code of the messages covered by this override",type:"STRING",default:void 0},text:{description:"Code of the texts covered by this override",type:"STRING",default:void 0},pattern:{description:"Code of the patterns covered by this override",type:"STRING",default:void 0},level:{description:"Log level override, set to null to remove override",type:"STRING",values:Object.values(cS),isNullable:!0,default:void 0}}},enableTelemetry:{description:"If true, telemetry will be periodically sent, following the rules in https://yarnpkg.com/advanced/telemetry",type:"BOOLEAN",default:!0},telemetryInterval:{description:"Minimal amount of time between two telemetry uploads, in days",type:"NUMBER",default:7},telemetryUserId:{description:"If you desire to tell us which project you are, you can set this field. Completely optional and opt-in.",type:"STRING",default:null},enableHardenedMode:{description:"If true, automatically enable --check-resolutions --refresh-lockfile on installs",type:"BOOLEAN",default:Tf.isPR&&Jst,defaultText:"<true on public PRs>"},enableScripts:{description:"If true, packages are allowed to have install scripts by default",type:"BOOLEAN",default:!0},enableStrictSettings:{description:"If true, unknown settings will cause Yarn to abort",type:"BOOLEAN",default:!0},enableImmutableCache:{description:"If true, the cache is reputed immutable and actions that would modify it will throw",type:"BOOLEAN",default:!1},checksumBehavior:{description:"Enumeration defining what to do when a checksum doesn't match expectations",type:"STRING",default:"throw"},injectEnvironmentFiles:{description:"List of all the environment files that Yarn should inject inside the process when it starts",type:"ABSOLUTE_PATH",default:[".env.yarn?"],isArray:!0},packageExtensions:{description:"Map of package corrections to apply on the dependency tree",type:"MAP",valueDefinition:{description:"The extension that will be applied to any package whose version matches the specified range",type:"SHAPE",properties:{dependencies:{description:"The set of dependencies that must be made available to the current package in order for it to work properly",type:"MAP",valueDefinition:{description:"A range",type:"STRING"}},peerDependencies:{description:"Inherited dependencies - the consumer of the package will be tasked to provide them",type:"MAP",valueDefinition:{description:"A semver range",type:"STRING"}},peerDependenciesMeta:{description:"Extra information related to the dependencies listed in the peerDependencies field",type:"MAP",valueDefinition:{description:"The peerDependency meta",type:"SHAPE",properties:{optional:{description:"If true, the selected peer dependency will be marked as optional by the package manager and the consumer omitting it won't be reported as an error",type:"BOOLEAN",default:!1}}}}}}}};iot=process.platform==="win32"?rot:not;rA=class{constructor(e){this.isCI=Tf.isCI;this.projectCwd=null;this.plugins=new Map;this.settings=new Map;this.values=new Map;this.sources=new Map;this.invalid=new Map;this.env={};this.limits=new Map;this.packageExtensions=null;this.startingCwd=e}static create(e,r,o){let a=new rA(e);typeof r<"u"&&!(r instanceof Map)&&(a.projectCwd=r),a.importSettings(Px);let n=typeof o<"u"?o:r instanceof Map?r:new Map;for(let[u,A]of n)a.activatePlugin(u,A);return a}static async find(e,r,{strict:o=!0,usePathCheck:a=null,useRc:n=!0}={}){let u=tot();delete u.rcFilename;let A=new rA(e),p=await rA.findRcFiles(e),h=await rA.findFolderRcFile(yE());h&&(p.find(ye=>ye.path===h.path)||p.unshift(h));let C=Use(p.map(ue=>[ue.path,ue.data])),I=Bt.dot,v=new Set(Object.keys(Px)),b=({yarnPath:ue,ignorePath:ye,injectEnvironmentFiles:ae})=>({yarnPath:ue,ignorePath:ye,injectEnvironmentFiles:ae}),E=({yarnPath:ue,ignorePath:ye,injectEnvironmentFiles:ae,...Ie})=>{let Fe={};for(let[g,Ee]of Object.entries(Ie))v.has(g)&&(Fe[g]=Ee);return Fe},F=({yarnPath:ue,ignorePath:ye,...ae})=>{let Ie={};for(let[Fe,g]of Object.entries(ae))v.has(Fe)||(Ie[Fe]=g);return Ie};if(A.importSettings(b(Px)),A.useWithSource("<environment>",b(u),e,{strict:!1}),C){let[ue,ye]=C;A.useWithSource(ue,b(ye),I,{strict:!1})}if(a){if(await sot({configuration:A,selfPath:a})!==null)return A;A.useWithSource("<override>",{ignorePath:!0},e,{strict:!1,overwrite:!0})}let N=await rA.findProjectCwd(e);A.startingCwd=e,A.projectCwd=N;let U=Object.assign(Object.create(null),process.env);A.env=U;let z=await Promise.all(A.get("injectEnvironmentFiles").map(async ue=>{let ye=ue.endsWith("?")?await oe.readFilePromise(ue.slice(0,-1),"utf8").catch(()=>""):await oe.readFilePromise(ue,"utf8");return(0,Sle.parse)(ye)}));for(let ue of z)for(let[ye,ae]of Object.entries(ue))A.env[ye]=iS(ae,{env:U});if(A.importSettings(E(Px)),A.useWithSource("<environment>",E(u),e,{strict:o}),C){let[ue,ye]=C;A.useWithSource(ue,E(ye),I,{strict:o})}let te=ue=>"default"in ue?ue.default:ue,le=new Map([["@@core",ose]]);if(r!==null)for(let ue of r.plugins.keys())le.set(ue,te(r.modules.get(ue)));for(let[ue,ye]of le)A.activatePlugin(ue,ye);let pe=new Map([]);if(r!==null){let ue=new Map;for(let Ie of xle.builtinModules)ue.set(Ie,()=>zp(Ie));for(let[Ie,Fe]of r.modules)ue.set(Ie,()=>Fe);let ye=new Set,ae=async(Ie,Fe)=>{let{factory:g,name:Ee}=zp(Ie);if(!g||ye.has(Ee))return;let De=new Map(ue),ce=ee=>{if(De.has(ee))return De.get(ee)();throw new it(`This plugin cannot access the package referenced via ${ee} which is neither a builtin, nor an exposed entry`)},ne=await Wy(async()=>te(await g(ce)),ee=>`${ee} (when initializing ${Ee}, defined in ${Fe})`);ue.set(Ee,()=>ne),ye.add(Ee),pe.set(Ee,ne)};if(u.plugins)for(let Ie of u.plugins.split(";")){let Fe=V.resolve(e,fe.toPortablePath(Ie));await ae(Fe,"<environment>")}for(let{path:Ie,cwd:Fe,data:g}of p)if(!!n&&!!Array.isArray(g.plugins))for(let Ee of g.plugins){let De=typeof Ee!="string"?Ee.path:Ee,ce=Ee?.spec??"",ne=Ee?.checksum??"";if(I1.has(ce))continue;let ee=V.resolve(Fe,fe.toPortablePath(De));if(!await oe.existsPromise(ee)){if(!ce){let ht=_t(A,V.basename(ee,".cjs"),Et.NAME),H=_t(A,".gitignore",Et.NAME),lt=_t(A,A.values.get("rcFilename"),Et.NAME),Te=_t(A,"https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored",Et.URL);throw new it(`Missing source for the ${ht} plugin - please try to remove the plugin from ${lt} then reinstall it manually. This error usually occurs because ${H} is incorrect, check ${Te} to make sure your plugin folder isn't gitignored.`)}if(!ce.match(/^https?:/)){let ht=_t(A,V.basename(ee,".cjs"),Et.NAME),H=_t(A,A.values.get("rcFilename"),Et.NAME);throw new it(`Failed to recognize the source for the ${ht} plugin - please try to delete the plugin from ${H} then reinstall it manually.`)}let we=await N4(ce,{configuration:A}),be=Qs(we);if(ne&&ne!==be){let ht=_t(A,V.basename(ee,".cjs"),Et.NAME),H=_t(A,A.values.get("rcFilename"),Et.NAME),lt=_t(A,`yarn plugin import ${ce}`,Et.CODE);throw new it(`Failed to fetch the ${ht} plugin from its remote location: its checksum seems to have changed. If this is expected, please remove the plugin from ${H} then run ${lt} to reimport it.`)}await oe.mkdirPromise(V.dirname(ee),{recursive:!0}),await oe.writeFilePromise(ee,we)}await ae(ee,Ie)}}for(let[ue,ye]of pe)A.activatePlugin(ue,ye);if(A.useWithSource("<environment>",F(u),e,{strict:o}),C){let[ue,ye]=C;A.useWithSource(ue,F(ye),I,{strict:o})}return A.get("enableGlobalCache")&&(A.values.set("cacheFolder",`${A.get("globalFolder")}/cache`),A.sources.set("cacheFolder","<internal>")),A}static async findRcFiles(e){let r=q4(),o=[],a=e,n=null;for(;a!==n;){n=a;let u=V.join(n,r);if(oe.existsSync(u)){let A=await oe.readFilePromise(u,"utf8"),p;try{p=Ki(A)}catch{let C="";throw A.match(/^\s+(?!-)[^:]+\s+\S+/m)&&(C=" (in particular, make sure you list the colons after each key name)"),new it(`Parse error when loading ${u}; please check it's proper Yaml${C}`)}o.unshift({path:u,cwd:n,data:p})}a=V.dirname(n)}return o}static async findFolderRcFile(e){let r=V.join(e,dr.rc),o;try{o=await oe.readFilePromise(r,"utf8")}catch(n){if(n.code==="ENOENT")return null;throw n}let a=Ki(o);return{path:r,cwd:e,data:a}}static async findProjectCwd(e){let r=null,o=e,a=null;for(;o!==a;){if(a=o,oe.existsSync(V.join(a,dr.lockfile)))return a;oe.existsSync(V.join(a,dr.manifest))&&(r=a),o=V.dirname(a)}return r}static async updateConfiguration(e,r,o={}){let a=q4(),n=V.join(e,a),u=oe.existsSync(n)?Ki(await oe.readFilePromise(n,"utf8")):{},A=!1,p;if(typeof r=="function"){try{p=r(u)}catch{p=r({})}if(p===u)return!1}else{p=u;for(let h of Object.keys(r)){let C=u[h],I=r[h],v;if(typeof I=="function")try{v=I(C)}catch{v=I(void 0)}else v=I;C!==v&&(v===rA.deleteProperty?delete p[h]:p[h]=v,A=!0)}if(!A)return!1}return await oe.changeFilePromise(n,Ba(p),{automaticNewlines:!0}),!0}static async addPlugin(e,r){r.length!==0&&await rA.updateConfiguration(e,o=>{let a=o.plugins??[];if(a.length===0)return{...o,plugins:r};let n=[],u=[...r];for(let A of a){let p=typeof A!="string"?A.path:A,h=u.find(C=>C.path===p);h?(n.push(h),u=u.filter(C=>C!==h)):n.push(A)}return n.push(...u),{...o,plugins:n}})}static async updateHomeConfiguration(e){let r=yE();return await rA.updateConfiguration(r,e)}activatePlugin(e,r){this.plugins.set(e,r),typeof r.configuration<"u"&&this.importSettings(r.configuration)}importSettings(e){for(let[r,o]of Object.entries(e))if(o!=null){if(this.settings.has(r))throw new Error(`Cannot redefine settings "${r}"`);this.settings.set(r,o),this.values.set(r,W4(this,o))}}useWithSource(e,r,o,a){try{this.use(e,r,o,a)}catch(n){throw n.message+=` (in ${_t(this,e,Et.PATH)})`,n}}use(e,r,o,{strict:a=!0,overwrite:n=!1}={}){a=a&&this.get("enableStrictSettings");for(let u of["enableStrictSettings",...Object.keys(r)]){let A=r[u],p=mM(A);if(p&&(e=p),typeof A>"u"||u==="plugins"||e==="<environment>"&&Xst.has(u))continue;if(u==="rcFilename")throw new it(`The rcFilename settings can only be set via ${`${xx}RC_FILENAME`.toUpperCase()}, not via a rc file`);let h=this.settings.get(u);if(!h){let I=yE(),v=e[0]!=="<"?V.dirname(e):null;if(a&&!(v!==null?I===v:!1))throw new it(`Unrecognized or legacy configuration settings found: ${u} - run "yarn config -v" to see the list of settings supported in Yarn`);this.invalid.set(u,e);continue}if(this.sources.has(u)&&!(n||h.type==="MAP"||h.isArray&&h.concatenateValues))continue;let C;try{C=Y4(this,u,A,h,o)}catch(I){throw I.message+=` in ${_t(this,e,Et.PATH)}`,I}if(u==="enableStrictSettings"&&e!=="<environment>"){a=C;continue}if(h.type==="MAP"){let I=this.values.get(u);this.values.set(u,new Map(n?[...I,...C]:[...C,...I])),this.sources.set(u,`${this.sources.get(u)}, ${e}`)}else if(h.isArray&&h.concatenateValues){let I=this.values.get(u);this.values.set(u,n?[...I,...C]:[...C,...I]),this.sources.set(u,`${this.sources.get(u)}, ${e}`)}else this.values.set(u,C),this.sources.set(u,e)}}get(e){if(!this.values.has(e))throw new Error(`Invalid configuration key "${e}"`);return this.values.get(e)}getSpecial(e,{hideSecrets:r=!1,getNativePaths:o=!1}){let a=this.get(e),n=this.settings.get(e);if(typeof n>"u")throw new it(`Couldn't find a configuration settings named "${e}"`);return Sx(a,n,{hideSecrets:r,getNativePaths:o})}getSubprocessStreams(e,{header:r,prefix:o,report:a}){let n,u,A=oe.createWriteStream(e);if(this.get("enableInlineBuilds")){let p=a.createStreamReporter(`${o} ${_t(this,"STDOUT","green")}`),h=a.createStreamReporter(`${o} ${_t(this,"STDERR","red")}`);n=new j4.PassThrough,n.pipe(p),n.pipe(A),u=new j4.PassThrough,u.pipe(h),u.pipe(A)}else n=A,u=A,typeof r<"u"&&n.write(`${r} -`);return{stdout:n,stderr:u}}makeResolver(){let e=[];for(let r of this.plugins.values())for(let o of r.resolvers||[])e.push(new o);return new vd([new a1,new Xn,...e])}makeFetcher(){let e=[];for(let r of this.plugins.values())for(let o of r.fetchers||[])e.push(new o);return new pE([new hE,new dE,...e])}getLinkers(){let e=[];for(let r of this.plugins.values())for(let o of r.linkers||[])e.push(new o);return e}getSupportedArchitectures(){let e=w1(),r=this.get("supportedArchitectures"),o=r.get("os");o!==null&&(o=o.map(u=>u==="current"?e.os:u));let a=r.get("cpu");a!==null&&(a=a.map(u=>u==="current"?e.cpu:u));let n=r.get("libc");return n!==null&&(n=sl(n,u=>u==="current"?e.libc??sl.skip:u)),{os:o,cpu:a,libc:n}}async getPackageExtensions(){if(this.packageExtensions!==null)return this.packageExtensions;this.packageExtensions=new Map;let e=this.packageExtensions,r=(o,a,{userProvided:n=!1}={})=>{if(!ba(o.range))throw new Error("Only semver ranges are allowed as keys for the packageExtensions setting");let u=new Ot;u.load(a,{yamlCompatibilityMode:!0});let A=Gy(e,o.identHash),p=[];A.push([o.range,p]);let h={status:"inactive",userProvided:n,parentDescriptor:o};for(let C of u.dependencies.values())p.push({...h,type:"Dependency",descriptor:C});for(let C of u.peerDependencies.values())p.push({...h,type:"PeerDependency",descriptor:C});for(let[C,I]of u.peerDependenciesMeta)for(let[v,b]of Object.entries(I))p.push({...h,type:"PeerDependencyMeta",selector:C,key:v,value:b})};await this.triggerHook(o=>o.registerPackageExtensions,this,r);for(let[o,a]of this.get("packageExtensions"))r(nh(o,!0),nS(a),{userProvided:!0});return e}normalizeLocator(e){return ba(e.reference)?Fs(e,`${this.get("defaultProtocol")}${e.reference}`):QE.test(e.reference)?Fs(e,`${this.get("defaultProtocol")}${e.reference}`):e}normalizeDependency(e){return ba(e.range)?In(e,`${this.get("defaultProtocol")}${e.range}`):QE.test(e.range)?In(e,`${this.get("defaultProtocol")}${e.range}`):e}normalizeDependencyMap(e){return new Map([...e].map(([r,o])=>[r,this.normalizeDependency(o)]))}normalizePackage(e,{packageExtensions:r}){let o=ZI(e),a=r.get(e.identHash);if(typeof a<"u"){let u=e.version;if(u!==null){for(let[A,p]of a)if(!!xf(u,A))for(let h of p)switch(h.status==="inactive"&&(h.status="redundant"),h.type){case"Dependency":typeof o.dependencies.get(h.descriptor.identHash)>"u"&&(h.status="active",o.dependencies.set(h.descriptor.identHash,this.normalizeDependency(h.descriptor)));break;case"PeerDependency":typeof o.peerDependencies.get(h.descriptor.identHash)>"u"&&(h.status="active",o.peerDependencies.set(h.descriptor.identHash,h.descriptor));break;case"PeerDependencyMeta":{let C=o.peerDependenciesMeta.get(h.selector);(typeof C>"u"||!Object.hasOwn(C,h.key)||C[h.key]!==h.value)&&(h.status="active",ol(o.peerDependenciesMeta,h.selector,()=>({}))[h.key]=h.value)}break;default:EN(h);break}}}let n=u=>u.scope?`${u.scope}__${u.name}`:`${u.name}`;for(let u of o.peerDependenciesMeta.keys()){let A=Js(u);o.peerDependencies.has(A.identHash)||o.peerDependencies.set(A.identHash,In(A,"*"))}for(let u of o.peerDependencies.values()){if(u.scope==="types")continue;let A=n(u),p=eA("types",A),h=fn(p);o.peerDependencies.has(p.identHash)||o.peerDependenciesMeta.has(h)||(o.peerDependencies.set(p.identHash,In(p,"*")),o.peerDependenciesMeta.set(h,{optional:!0}))}return o.dependencies=new Map(ks(o.dependencies,([,u])=>Sa(u))),o.peerDependencies=new Map(ks(o.peerDependencies,([,u])=>Sa(u))),o}getLimit(e){return ol(this.limits,e,()=>(0,ble.default)(this.get(e)))}async triggerHook(e,...r){for(let o of this.plugins.values()){let a=o.hooks;if(!a)continue;let n=e(a);!n||await n(...r)}}async triggerMultipleHooks(e,r){for(let o of r)await this.triggerHook(e,...o)}async reduceHook(e,r,...o){let a=r;for(let n of this.plugins.values()){let u=n.hooks;if(!u)continue;let A=e(u);!A||(a=await A(a,...o))}return a}async firstHook(e,...r){for(let o of this.plugins.values()){let a=o.hooks;if(!a)continue;let n=e(a);if(!n)continue;let u=await n(...r);if(typeof u<"u")return u}return null}},Ke=rA;Ke.deleteProperty=Symbol(),Ke.telemetry=null});var Ur={};Vt(Ur,{EndStrategy:()=>J4,ExecError:()=>kx,PipeError:()=>D1,execvp:()=>_4,pipevp:()=>Gc});function Sd(t){return t!==null&&typeof t.fd=="number"}function K4(){}function V4(){for(let t of xd)t.kill()}async function Gc(t,e,{cwd:r,env:o=process.env,strict:a=!1,stdin:n=null,stdout:u,stderr:A,end:p=2}){let h=["pipe","pipe","pipe"];n===null?h[0]="ignore":Sd(n)&&(h[0]=n),Sd(u)&&(h[1]=u),Sd(A)&&(h[2]=A);let C=(0,z4.default)(t,e,{cwd:fe.fromPortablePath(r),env:{...o,PWD:fe.fromPortablePath(r)},stdio:h});xd.add(C),xd.size===1&&(process.on("SIGINT",K4),process.on("SIGTERM",V4)),!Sd(n)&&n!==null&&n.pipe(C.stdin),Sd(u)||C.stdout.pipe(u,{end:!1}),Sd(A)||C.stderr.pipe(A,{end:!1});let I=()=>{for(let v of new Set([u,A]))Sd(v)||v.end()};return new Promise((v,b)=>{C.on("error",E=>{xd.delete(C),xd.size===0&&(process.off("SIGINT",K4),process.off("SIGTERM",V4)),(p===2||p===1)&&I(),b(E)}),C.on("close",(E,F)=>{xd.delete(C),xd.size===0&&(process.off("SIGINT",K4),process.off("SIGTERM",V4)),(p===2||p===1&&E!==0)&&I(),E===0||!a?v({code:X4(E,F)}):b(new D1({fileName:t,code:E,signal:F}))})})}async function _4(t,e,{cwd:r,env:o=process.env,encoding:a="utf8",strict:n=!1}){let u=["ignore","pipe","pipe"],A=[],p=[],h=fe.fromPortablePath(r);typeof o.PWD<"u"&&(o={...o,PWD:h});let C=(0,z4.default)(t,e,{cwd:h,env:o,stdio:u});return C.stdout.on("data",I=>{A.push(I)}),C.stderr.on("data",I=>{p.push(I)}),await new Promise((I,v)=>{C.on("error",b=>{let E=Ke.create(r),F=_t(E,t,Et.PATH);v(new Jt(1,`Process ${F} failed to spawn`,N=>{N.reportError(1,` ${Ju(E,{label:"Thrown Error",value:_c(Et.NO_HINT,b.message)})}`)}))}),C.on("close",(b,E)=>{let F=a==="buffer"?Buffer.concat(A):Buffer.concat(A).toString(a),N=a==="buffer"?Buffer.concat(p):Buffer.concat(p).toString(a);b===0||!n?I({code:X4(b,E),stdout:F,stderr:N}):v(new kx({fileName:t,code:b,signal:E,stdout:F,stderr:N}))})})}function X4(t,e){let r=oot.get(e);return typeof r<"u"?128+r:t??1}function aot(t,e,{configuration:r,report:o}){o.reportError(1,` ${Ju(r,t!==null?{label:"Exit Code",value:_c(Et.NUMBER,t)}:{label:"Exit Signal",value:_c(Et.CODE,e)})}`)}var z4,J4,D1,kx,xd,oot,Dx=yt(()=>{Pt();z4=$e(sT());v1();Yl();ql();J4=(o=>(o[o.Never=0]="Never",o[o.ErrorCode=1]="ErrorCode",o[o.Always=2]="Always",o))(J4||{}),D1=class extends Jt{constructor({fileName:r,code:o,signal:a}){let n=Ke.create(V.cwd()),u=_t(n,r,Et.PATH);super(1,`Child ${u} reported an error`,A=>{aot(o,a,{configuration:n,report:A})});this.code=X4(o,a)}},kx=class extends D1{constructor({fileName:r,code:o,signal:a,stdout:n,stderr:u}){super({fileName:r,code:o,signal:a});this.stdout=n,this.stderr=u}};xd=new Set;oot=new Map([["SIGINT",2],["SIGQUIT",3],["SIGKILL",9],["SIGTERM",15]])});function Fle(t){Qle=t}function P1(){return typeof Z4>"u"&&(Z4=Qle()),Z4}var Z4,Qle,$4=yt(()=>{Qle=()=>{throw new Error("Assertion failed: No libzip instance is available, and no factory was configured")}});var Rle=_((Qx,tU)=>{var lot=Object.assign({},Be("fs")),eU=function(){var t=typeof document<"u"&&document.currentScript?document.currentScript.src:void 0;return typeof __filename<"u"&&(t=t||__filename),function(e){e=e||{};var r=typeof e<"u"?e:{},o,a;r.ready=new Promise(function(We,tt){o=We,a=tt});var n={},u;for(u in r)r.hasOwnProperty(u)&&(n[u]=r[u]);var A=[],p="./this.program",h=function(We,tt){throw tt},C=!1,I=!0,v="";function b(We){return r.locateFile?r.locateFile(We,v):v+We}var E,F,N,U;I&&(C?v=Be("path").dirname(v)+"/":v=__dirname+"/",E=function(tt,It){var nr=ii(tt);return nr?It?nr:nr.toString():(N||(N=lot),U||(U=Be("path")),tt=U.normalize(tt),N.readFileSync(tt,It?null:"utf8"))},F=function(tt){var It=E(tt,!0);return It.buffer||(It=new Uint8Array(It)),Ee(It.buffer),It},process.argv.length>1&&(p=process.argv[1].replace(/\\/g,"/")),A=process.argv.slice(2),h=function(We){process.exit(We)},r.inspect=function(){return"[Emscripten Module object]"});var z=r.print||console.log.bind(console),te=r.printErr||console.warn.bind(console);for(u in n)n.hasOwnProperty(u)&&(r[u]=n[u]);n=null,r.arguments&&(A=r.arguments),r.thisProgram&&(p=r.thisProgram),r.quit&&(h=r.quit);var le=0,pe=function(We){le=We},ue;r.wasmBinary&&(ue=r.wasmBinary);var ye=r.noExitRuntime||!0;typeof WebAssembly!="object"&&Ti("no native wasm support detected");function ae(We,tt,It){switch(tt=tt||"i8",tt.charAt(tt.length-1)==="*"&&(tt="i32"),tt){case"i1":return He[We>>0];case"i8":return He[We>>0];case"i16":return ap((We>>1)*2);case"i32":return Ms((We>>2)*4);case"i64":return Ms((We>>2)*4);case"float":return cu((We>>2)*4);case"double":return op((We>>3)*8);default:Ti("invalid type for getValue: "+tt)}return null}var Ie,Fe=!1,g;function Ee(We,tt){We||Ti("Assertion failed: "+tt)}function De(We){var tt=r["_"+We];return Ee(tt,"Cannot call unknown function "+We+", make sure it is exported"),tt}function ce(We,tt,It,nr,$){var me={string:function(es){var xi=0;if(es!=null&&es!==0){var jo=(es.length<<2)+1;xi=Un(jo),ht(es,xi,jo)}return xi},array:function(es){var xi=Un(es.length);return Te(es,xi),xi}};function Ne(es){return tt==="string"?we(es):tt==="boolean"?Boolean(es):es}var ft=De(We),pt=[],Tt=0;if(nr)for(var er=0;er<nr.length;er++){var Zr=me[It[er]];Zr?(Tt===0&&(Tt=ms()),pt[er]=Zr(nr[er])):pt[er]=nr[er]}var qi=ft.apply(null,pt);return qi=Ne(qi),Tt!==0&&Hs(Tt),qi}function ne(We,tt,It,nr){It=It||[];var $=It.every(function(Ne){return Ne==="number"}),me=tt!=="string";return me&&$&&!nr?De(We):function(){return ce(We,tt,It,arguments,nr)}}var ee=new TextDecoder("utf8");function we(We,tt){if(!We)return"";for(var It=We+tt,nr=We;!(nr>=It)&&Re[nr];)++nr;return ee.decode(Re.subarray(We,nr))}function be(We,tt,It,nr){if(!(nr>0))return 0;for(var $=It,me=It+nr-1,Ne=0;Ne<We.length;++Ne){var ft=We.charCodeAt(Ne);if(ft>=55296&&ft<=57343){var pt=We.charCodeAt(++Ne);ft=65536+((ft&1023)<<10)|pt&1023}if(ft<=127){if(It>=me)break;tt[It++]=ft}else if(ft<=2047){if(It+1>=me)break;tt[It++]=192|ft>>6,tt[It++]=128|ft&63}else if(ft<=65535){if(It+2>=me)break;tt[It++]=224|ft>>12,tt[It++]=128|ft>>6&63,tt[It++]=128|ft&63}else{if(It+3>=me)break;tt[It++]=240|ft>>18,tt[It++]=128|ft>>12&63,tt[It++]=128|ft>>6&63,tt[It++]=128|ft&63}}return tt[It]=0,It-$}function ht(We,tt,It){return be(We,Re,tt,It)}function H(We){for(var tt=0,It=0;It<We.length;++It){var nr=We.charCodeAt(It);nr>=55296&&nr<=57343&&(nr=65536+((nr&1023)<<10)|We.charCodeAt(++It)&1023),nr<=127?++tt:nr<=2047?tt+=2:nr<=65535?tt+=3:tt+=4}return tt}function lt(We){var tt=H(We)+1,It=Ni(tt);return It&&be(We,He,It,tt),It}function Te(We,tt){He.set(We,tt)}function ke(We,tt){return We%tt>0&&(We+=tt-We%tt),We}var xe,He,Re,ze,je,x,w,S,y,R;function J(We){xe=We,r.HEAP_DATA_VIEW=R=new DataView(We),r.HEAP8=He=new Int8Array(We),r.HEAP16=ze=new Int16Array(We),r.HEAP32=x=new Int32Array(We),r.HEAPU8=Re=new Uint8Array(We),r.HEAPU16=je=new Uint16Array(We),r.HEAPU32=w=new Uint32Array(We),r.HEAPF32=S=new Float32Array(We),r.HEAPF64=y=new Float64Array(We)}var X=r.INITIAL_MEMORY||16777216,Z,ie=[],Pe=[],Le=[],ot=!1;function dt(){if(r.preRun)for(typeof r.preRun=="function"&&(r.preRun=[r.preRun]);r.preRun.length;)xt(r.preRun.shift());oo(ie)}function jt(){ot=!0,oo(Pe)}function $t(){if(r.postRun)for(typeof r.postRun=="function"&&(r.postRun=[r.postRun]);r.postRun.length;)kr(r.postRun.shift());oo(Le)}function xt(We){ie.unshift(We)}function an(We){Pe.unshift(We)}function kr(We){Le.unshift(We)}var mr=0,xr=null,Wr=null;function Kn(We){mr++,r.monitorRunDependencies&&r.monitorRunDependencies(mr)}function Ns(We){if(mr--,r.monitorRunDependencies&&r.monitorRunDependencies(mr),mr==0&&(xr!==null&&(clearInterval(xr),xr=null),Wr)){var tt=Wr;Wr=null,tt()}}r.preloadedImages={},r.preloadedAudios={};function Ti(We){r.onAbort&&r.onAbort(We),We+="",te(We),Fe=!0,g=1,We="abort("+We+"). Build with -s ASSERTIONS=1 for more info.";var tt=new WebAssembly.RuntimeError(We);throw a(tt),tt}var ps="data:application/octet-stream;base64,";function io(We){return We.startsWith(ps)}var Si="data:application/octet-stream;base64,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QCAAKAJIIgEgASADaiADEAcaAkAgAyAAKAJsIgFNBEAgACABIANrNgJsDAELIABCADcCbAsgACAAKAJoIANrIgE2AmggACAAKAJYIANrNgJYIAEgACgChC5JBEAgACABNgKELgsgAEH8gAEoAgARAwAgAiADaiECCwJAIAAoAgAiASgCBCIERQ0AIAAoAjwhBSAAIAIgBCACIARJGyICBH8gACgCSCAAKAJoaiAFaiEFIAEgBCACazYCBAJAAkACQAJAIAEoAhwiBCgCFEEBaw4CAQACCyAEQaABaiAFIAEoAgAgAkHcgAEoAgARCAAMAgsgASABKAIwIAUgASgCACACQcSAASgCABEEADYCMAwBCyAFIAEoAgAgAhAHGgsgASABKAIAIAJqNgIAIAEgASgCCCACajYCCCAAKAI8BSAFCyACaiICNgI8AkAgACgChC4iASACakEDSQ0AIAAoAmggAWshAQJAIAAoAnRBgQhPBEAgACAAIAAoAkggAWoiAi0AACACLQABIAAoAnwRAAA2AlQMAQsgAUUNACAAIAFBAWsgACgChAERAgAaCyAAKAKELiAAKAI8IgJBAUZrIgRFDQAgACABIAQgACgCgAERBQAgACAAKAKELiAEazYChC4gACgCPCECCyACQYUCSw0AIAAoAgAoAgRFDQAgACgCMCEBDAELCwJAIAAoAkQiAiAAKAJAIgNNDQAgAAJ/IAAoAjwgACgCaGoiASADSwRAIAAoAkggAWpBACACIAFrIgNBggIgA0GCAkkbIgMQGSABIANqDAELIAFBggJqIgEgA00NASAAKAJIIANqQQAgAiADayICIAEgA2siAyACIANJGyIDEBkgACgCQCADags2AkALC50CAQF/AkAgAAJ/IAAoAqAuIgFBwABGBEAgACgCBCAAKAIQaiAAKQOYLjcAACAAQgA3A5guIAAgACgCEEEIajYCEEEADAELIAFBIE4EQCAAKAIEIAAoAhBqIAApA5guPgAAIAAgAEGcLmo1AgA3A5guIAAgACgCEEEEajYCECAAIAAoAqAuQSBrIgE2AqAuCyABQRBOBEAgACgCBCAAKAIQaiAAKQOYLj0AACAAIAAoAhBBAmo2AhAgACAAKQOYLkIQiDcDmC4gACAAKAKgLkEQayIBNgKgLgsgAUEISA0BIAAgACgCECIBQQFqNgIQ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KAJUIgEEfyABQQAgARAiEBkgACgCVAVBAAsQBiAAQQA2AlQLC9wMAgl/AX4jAEFAaiIGJAACQAJAAkACQAJAIAEoAjBBABAjIgVBAkZBACABKAI4QQAQIyIEQQFGGw0AIAVBAUZBACAEQQJGGw0AIAVBAkciAw0BIARBAkcNAQsgASABLwEMQYAQcjsBDEEAIQMMAQsgASABLwEMQf/vA3E7AQxBACEFIANFBEBB9eABIAEoAjAgAEEIahBpIgVFDQILIAJBgAJxBEAgBSEDDAELIARBAkcEQCAFIQMMAQtB9cYBIAEoAjggAEEIahBpIgNFBEAgBRAcDAILIAMgBTYCAAsgASABLwEMQf7/A3EgAS8BUiIFQQBHcjsBDAJAAkACQAJAAn8CQAJAIAEpAyhC/v///w9WDQAgASkDIEL+////D1YNACACQYAEcUUNASABKQNIQv////8PVA0BCyAFQYECa0H//wNxQQNJIQdBAQwBCyAFQYECa0H//wNxIQQgAkGACnFBgApHDQEgBEEDSSEHQQALIQkgBkIcEBciBEUEQCAAQQhqIgAEQCAAQQA2AgQgAEEONgIACyADEBwMBQsgAkGACHEhBQJAAkAgAkGAAnEEQAJAIAUNACABKQMgQv////8PVg0AIAEpAyhCgICAgBBUDQMLIAQgASkDKBAYIAEpAyAhDAwBCwJAAkACQCAFDQAgASkDIEL/////D1YNACABKQMoIgxC/////w9WDQEgASkDSEKAgICAEFQNBAsgASkDKCIMQv////8PVA0BCyAEIAwQGAsgASkDICIMQv////8PWgRAIAQgDBAYCyABKQNIIgxC/////w9UDQELIAQgDBAYCyAELQAARQRAIABBCGoiAARAIABBADYCBCAAQRQ2AgALIAQQCCADEBwMBQtBASEKQQEgBC0AAAR+IAQpAxAFQgALp0H//wNxIAYQRyEFIAQQCCAFIAM2AgAgBw0BDAILIAMhBSAEQQJLDQELIAZCBxAXIgRFBEAgAEEIaiIABEAgAEEANgIEIABBDjYCAAsgBRAcDAMLIARBAhANIARBhxJBAhAsIAQgAS0AUhBwIAQgAS8BEBANIAQtAABFBEAgAEEIaiIABEAgAEEANgIEIABBFDYCAAsgBBAIDAILQYGyAkEHIAYQRyEDIAQQCCADIAU2AgBBASEL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NgIEIABB7A42AhggGiEdDAMLIARB/wFxIgJBwABxRQRAIAggDSADLwECQQJ0aiAdp0F/IAJ0QX9zcUECdGoiAy0AASICayEIIB0gAq2IIR0gAy0AACIERQ0HDAELCyAEQSBxBEAgB0G//gA2AgQgASECDAgLIAdB0f4ANgIEIABB0A42AhggASECDAcLIB1BfyAGdEF/c62DIBt8IhunIQUgCCAEQQ9xIgNrIQggGiAErUIPg4ghHSABIBdrIgYgAjMBAiAaQX8gA3RBf3Otg3ynIgRPDQIgBCAGayIGIBlNDQEgBygCjEdFDQEgB0HR/gA2AgQgAEG5DDYCGAsgASECIAshBQwFCwJAIA5FBEAgDCARIAZraiEDDAELIAYgDk0EQCAMIA4gBmtqIQMMAQsgDCARIAYgDmsiBmtqIQMgBSAGTQ0AIAUgBmshBQJAAkAgASADTSABIA8gAWusIhogBq0iGyAaIBtUGyIapyIGaiICIANLcQ0AIAMgBmogAUsgASADT3ENACABIAMgBhAHGiACIQEMAQsgASADIAMgAWsiASABQR91IgFqIAFzIgIQByACaiEBIBogAq0iHn0iHFANACACIANqIQIDQAJAIBwgHiAcIB5UGyIbQiBUBEAgGyEaDAELIBsiGkIgfSIgQgWIQgF8QgODIh9QRQRAA0AgASACKQAANwAAIAEgAikAGDcAGCABIAIpABA3ABAgASACKQAINwAIIBpCIH0hGiACQSBqIQIgAUEgaiEBIB9CAX0iH0IAUg0ACwsgIELgAFQNAANAIAEgAikAADcAACABIAIpABg3ABggASACKQAQNwAQIAEgAikACDcACCABIAIpADg3ADggASACKQAwNwAwIAEgAikAKDcAKCABIAIpACA3ACAgASACKQBYNwBYIAEgAikAUDcAUCABIAIpAEg3AEggASACKQBANwBAIAEgAikAYDcAYCABIAIpAGg3AGggASACKQBwNwBwIAEgAikAeDcAeCACQYABaiECIAFBgAFqIQEgGkKAAX0iGkIfVg0ACwsgGkIQWgRAIAEgAikAADcAACABIAIpAAg3AAggGkIQfSEaIAJBEGohAiABQRBqIQELIBpCCFoEQCABIAIpAAA3AAAgGkIIfSEaIAJBCGohAiABQQhqIQELIBpCBFoEQCABIAIoAAA2AAAgGkIEfSEaIAJBBGohAiABQQRqIQELIBpCAloEQCABIAIvAAA7AAAgGkICfSEaIAJBAmohAiABQQJqIQELIBwgG30hHCAaUEUEQCABIAItAAA6AAAgAkEBaiECIAFBAWohAQsgHEIAUg0ACwsgDiEGIAwhAwsgBSAGSwRAAkACQCABIANNIAEgDyABa6wiGiAGrSIbIBogG1QbIhqnIglqIgIgA0txDQAgAyAJaiABSyABIANPcQ0AIAEgAyAJEAcaDAELIAEgAyADIAFrIgEgAUEfdSIBaiABcyIBEAcgAWohAiAaIAGtIh59IhxQDQAgASADaiEBA0ACQCAcIB4gHCAeVBsiG0IgVARAIBshGgwBCyAbIhpCIH0iIEIFiEIBfEIDgyIfUEUEQANAIAIgASkAADcAACACIAEpABg3ABggAiABKQAQNwAQIAIgASkACDcACCAaQiB9IRogAUEgaiEBIAJBIGohAiAfQgF9Ih9CAFINAAsLICBC4ABUDQADQCACIAEpAAA3AAAgAiABKQAYNwAYIAIgASkAEDcAECACIAEpAAg3AAggAiABKQA4NwA4IAIgASkAMDcAMCACIAEpACg3ACggAiABKQAgNwAgIAIgASkAWDcAWCACIAEpAFA3AFAgAiABKQBINwBIIAIgASkAQDcAQCACIAEpAGA3AGAgAiABKQBoNwBoIAIgASkAcDcAcCACIAEpAHg3AHggAUGAAWohASACQYABaiECIBpCgAF9IhpCH1YNAAsLIBpCEFoEQCACIAEpAAA3AAAgAiABKQAINwAIIBpCEH0hGiACQRBqIQIgAUEQaiEBCyAaQghaBEAgAiABKQAANwAAIBpCCH0hGiACQQhqIQIgAUEIaiEBCyAaQgRaBEAgAiABKAAANgAAIBpCBH0hGiACQQRqIQIgAUEEaiEBCyAaQgJaBEAgAiABLwAAOwAAIBpCAn0hGiACQQJqIQIgAUECaiEBCyAcIBt9IRwgGlBFBEAgAiABLQAAOgAAIAJBAWohAiABQQFqIQELIBxCAFINAAsLIAUgBmshAUEAIARrIQUCQCAEQQdLBEAgBCEDDAELIAEgBE0EQCAEIQMMAQsgAiAEayEFA0ACQCACIAUpAAA3AAAgBEEBdCEDIAEgBGshASACIARqIQIgBEEDSw0AIAMhBCABIANLDQELC0EAIANrIQULIAIgBWohBAJAIAUgDyACa6wiGiABrSIbIBogG1QbIhqnIgFIIAVBf0pxDQAgBUEBSCABIARqIAJLcQ0AIAIgBCABEAcgAWohAgwDCyACIAQgAyADQR91IgFqIAFzIgEQByABaiECIBogAa0iHn0iHFANAiABIARqIQEDQAJAIBwgHiAcIB5UGyIbQiBUBEAgGyEaDAELIBsiGkIgfSIgQgWIQgF8QgODIh9QRQRAA0AgAiABKQAANwAAIAIgASkAGDcAGCACIAEpABA3ABAgAiABKQAINwAIIBpCIH0hGiABQSBqIQEgAkEgaiECIB9CAX0iH0IAUg0ACwsgIELgAFQNAANAIAIgASkAADcAACACIAEpABg3ABggAiABKQAQNwAQIAIgASkACDcACCACIAEpADg3ADggAiABKQAwNwAwIAIgASkAKDcAKCACIAEpACA3ACAgAiABKQBYNwBYIAIgASkAUDcAUCACIAEpAEg3AEggAiABKQBANwBAIAIgASkAYDcAYCACIAEpAGg3AGggAiABKQBwNwBwIAIgASkAeDcAeCABQYABaiEBIAJBgAFqIQIgGkKAAX0iGkIfVg0ACwsgGkIQWgRAIAIgASkAADcAACACIAEpAAg3AAggGkIQfSEaIAJBEGohAiABQRBqIQELIBpCCFoEQCACIAEpAAA3AAAgGkIIfSEaIAJBCGohAiABQQhqIQELIBpCBFoEQCACIAEoAAA2AAAgGkIEfSEaIAJBBGohAiABQQRqIQELIBpCAloEQCACIAEvAAA7AAAgGkICfSEaIAJBAmohAiABQQJqIQELIBwgG30hHCAaUEUEQCACIAEtAAA6AAAgAkEBaiECIAFBAWohAQsgHFBFDQALDAILAkAgASADTSABIA8gAWusIhogBa0iGyAaIBtUGyIapyIEaiICIANLcQ0AIAMgBGogAUsgASADT3ENACABIAMgBBAHGgwCCyABIAMgAyABayIBIAFBH3UiAWogAXMiARAHIAFqIQIgGiABrSIefSIcUA0BIAEgA2ohAQNAAkAgHCAeIBwgHlQbIhtCIFQEQCAbIRoMAQsgGyIaQiB9IiBCBYhCAXxCA4MiH1BFBEADQCACIAEpAAA3AAAgAiABKQAYNwAYIAIgASkAEDcAECACIAEpAAg3AAggGkIgfSEaIAFBIGohASACQSBqIQIgH0IBfSIfQgBSDQALCyAgQuAAVA0AA0AgAiABKQAANwAAIAIgASkAGDcAGCACIAEpABA3ABAgAiABKQAINwAIIAIgASkAODcAOCACIAEpADA3ADAgAiABKQAoNwAoIAIgASkAIDcAICACIAEpAFg3AFggAiABKQBQNwBQIAIgASkASDcASCACIAEpAEA3AEAgAiABKQBgNwBgIAIgASkAaDcAaCACIAEpAHA3AHAgAiABKQB4NwB4IAFBgAFqIQEgAkGAAWohAiAaQoABfSIaQh9WDQALCyAaQhBaBEAgAiABKQAANwAAIAIgASkACDcACCAaQhB9IRogAkEQaiECIAFBEGohAQsgGkIIWgRAIAIgASkAADcAACAaQgh9IRogAkEIaiECIAFBCGohAQsgGkIEWgRAIAIgASgAADYAACAaQgR9IRogAkEEaiECIAFBBGohAQsgGkICWgRAIAIgAS8AADsAACAaQgJ9IRogAkECaiECIAFBAmohAQsgHCAbfSEcIBpQRQRAIAIgAS0AADoAACACQQFqIQIgAUEBaiEBCyAcUEUNAAsMAQsCQAJAIBYEQAJAIAQgBUkEQCAHKAKYRyAESw0BCyABIARrIQMCQEEAIARrIgVBf0ogDyABa6wiGiAbIBogG1QbIhqnIgIgBUpxDQAgBUEBSCACIANqIAFLcQ0AIAEgAyACEAcgAmohAgwFCyABIAMgBCAEQR91IgFqIAFzIgEQByABaiECIBogAa0iHn0iHFANBCABIANqIQEDQAJAIBwgHiAcIB5UGyIbQiBUBEAgGyEaDAELIBsiGkIgfSIgQgWIQgF8QgODIh9QRQRAA0AgAiABKQAANwAAIAIgASkAGDcAGCACIAEpABA3ABAgAiABKQAINwAIIBpCIH0hGiABQSBqIQEgAkEgaiECIB9CAX0iH0IAUg0ACwsgIELgAFQNAANAIAIgASkAADcAACACIAEpABg3ABggAiABKQAQNwAQIAIgASkACDcACCACIAEpADg3ADggAiABKQAwNwAwIAIgASkAKDcAKCACIAEpACA3ACAgAiABKQBYNwBYIAIgASkAUDcAUCACIAEpAEg3AEggAiABKQBANwBAIAIgASkAYDcAYCACIAEpAGg3AGggAiABKQBwNwBwIAIgASkAeDcAeCABQYABaiEBIAJBgAFqIQIgGkKAAX0iGkIfVg0ACwsgGkIQWgRAIAIgASkAADcAACACIAEpAAg3AAggGkIQfSEaIAJBEGohAiABQRBqIQELIBpCCFoEQCACIAEpAAA3AAAgGkIIfSEaIAJBCGohAiABQQhqIQELIBpCBFoEQCACIAEoAAA2AAAgGkIEfSEaIAJBBGohAiABQQRqIQELIBpCAloEQCACIAEvAAA7AAAgGkICfSEaIAJBAmohAiABQQJqIQELIBwgG30hHCAaUEUEQCACIAEtAAA6AAAgAkEBaiECIAFBAWohAQsgHFBFDQALDAQLIBAgAWsiCUEBaiIGIAUgBSAGSxshAyABIARrIQIgAUEHcUUNAiADRQ0CIAEgAi0AADoAACACQQFqIQIgAUEBaiIGQQdxQQAgA0EBayIFGw0BIAYhASAFIQMgCSEGDAILAkAgBCAFSQRAIAcoAphHIARLDQELIAEgASAEayIGKQAANwAAIAEgBUEBa0EHcUEBaiIDaiECIAUgA2siBEUNAyADIAZqIQEDQCACIAEpAAA3AAAgAUEIaiEBIAJBCGohAiAEQQhrIgQNAAsMAwsgASAEIAUQPyECDAILIAEgAi0AADoAASAJQQFrIQYgA0ECayEFIAJBAWohAgJAIAFBAmoiCkEHcUUNACAFRQ0AIAEgAi0AADoAAiAJQQJrIQYgA0EDayEFIAJBAWohAgJAIAFBA2oiCkEHcUUNACAFRQ0AIAEgAi0AADoAAyAJQQNrIQYgA0EEayEFIAJBAWohAgJAIAFBBGoiCkEHcUUNACAFRQ0AIAEgAi0AADoABCAJQQRrIQYgA0EFayEFIAJBAWohAgJAIAFBBWoiCkEHcUUNACAFRQ0AIAEgAi0AADoABSAJQQVrIQYgA0EGayEFIAJBAWohAgJAIAFBBmoiCkEHcUUNACAFRQ0AIAEgAi0AADoABiAJQQZrIQYgA0EHayEFIAJBAWohAgJAIAFBB2oiCkEHcUUNACAFRQ0AIAEgAi0AADoAByAJQQdrIQYgA0EIayEDIAFBCGohASACQQFqIQIMBgsgCiEBIAUhAwwFCyAKIQEgBSEDDAQLIAohASAFIQMMAwsgCiEBIAUhAwwCCyAKIQEgBSEDDAELIAohASAFIQMLAkACQCAGQRdNBEAgA0UNASADQQFrIQUgA0EHcSIEBEADQCABIAItAAA6AAAgA0EBayEDIAFBAWohASACQQFqIQIgBEEBayIEDQALCyAFQQdJDQEDQCABIAItAAA6AAAgASACLQABOgABIAEgAi0AAjoAAiABIAItAAM6AAMgASACLQAEOgAEIAEgAi0ABToABSABIAItAAY6AAYgASACLQAHOgAHIAFBCGohASACQQhqIQIgA0EIayIDDQALDAELIAMNAQsgASECDAELIAEgBCADED8hAgsgCyEFDAELIAEgAy0AAjoAACABQQFqIQILIAUgFE8NACACIBNJDQELCyAAIAI2AgwgACAFIAhBA3ZrIgE2AgAgACATIAJrQYMCajYCECAAIBQgAWtBDmo2AgQgByAIQQdxIgA2AogBIAcgHUJ/IACthkJ/hYM+AoQBC+cFAQR/IAMgAiACIANLGyEEIAAgAWshAgJAIABBB3FFDQAgBEUNACAAIAItAAA6AAAgA0EBayEGIAJBAWohAiAAQQFqIgdBB3FBACAEQQFrIgUbRQRAIAchACAFIQQgBiEDDAELIAAgAi0AADoAASADQQJrIQYgBEECayEFIAJBAWohAgJAIABBAmoiB0EHcUUNACAFRQ0AIAAgAi0AADoAAiADQQNrIQYgBEEDayEFIAJBAWohAgJAIABBA2oiB0EHcUUNACAFRQ0AIAAgAi0AADoAAyADQQRrIQYgBEEEayEFIAJBAWohAgJAIABBBGoiB0EHcUUNACAFRQ0AIAAgAi0AADoABCADQQVrIQYgBEEFayEFIAJBAWohAgJAIABBBWoiB0EHcUUNACAFRQ0AIAAgAi0AADoABSADQQZrIQYgBEEGayEFIAJBAWohAgJAIABBBmoiB0EHcUUNACAFRQ0AIAAgAi0AADoABiADQQdrIQYgBEEHayEFIAJBAWohAgJAIABBB2oiB0EHcUUNACAFRQ0AIAAgAi0AADoAByADQQhrIQMgBEEIayEEIABBCGohACACQQFqIQIMBgsgByEAIAUhBCAGIQMMBQsgByEAIAUhBCAGIQMMBAsgByEAIAUhBCAGIQMMAwsgByEAIAUhBCAGIQMMAgsgByEAIAUhBCAGIQMMAQsgByEAIAUhBCAGIQMLAkAgA0EXTQRAIARFDQEgBEEBayEBIARBB3EiAwRAA0AgACACLQAAOgAAIARBAWshBCAAQQFqIQAgAkEBaiECIANBAWsi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CyAAIAMtAAAEfiADKQMQBUIAC6dB//8DcSADKAIEEEcLIQUgAxAICyAEQRBqJAAgBQvgAQICfwF+QTAQCSICRQRAIAEEQCABQQA2AgQgAUEONgIAC0EADwsgAkIANwMIIAJBADYCACACQgA3AxAgAkIANwMYIAJCADcDICACQgA3ACUgAFAEQCACDwsCQCAAQv////8AVg0AIACnQQR0EAkiA0UNACACIAM2AgBBACEBQgEhBANAIAMgAUEEdGoiAUIANwIAIAFCADcABSAAIARSBEAgBKchASAEQgF8IQQMAQsLIAIgADcDCCACIAA3AxAgAg8LIAEEQCABQQA2AgQgAUEONgIAC0EAEBAgAhAGQQAL7gECA38BfiMAQRBrIgQkAAJAIARBDGpCBBAXIgNFBEBBfyECDAELAkAgAQRAIAJBgAZxIQUDQAJAIAUgASgCBHFFDQACQCADKQMIQgBUBEAgA0EAOgAADAELIANCADcDECADQQE6AAALIAMgAS8BCBANIAMgAS8BChANIAMtAABFBEAgAEEIaiIABEAgAEEANgIEIABBFDYCAAtBfyECDAQLQX8hAiAAIARBDGpCBBAbQQBIDQMgATMBCiIGUA0AIAAgASgCDCAGEBtBAEgNAwsgASgCACIBDQALC0EAIQILIAMQCAsgBEEQaiQAIAILPAEBfyAABEAgAUGABnEhAQNAIAEgACgCBHEEQCACIAAvAQpqQQRqIQILIAAoAgAiAA0ACwsgAkH//wNxC5wBAQN/IABFBEBBAA8LIAAhAwNAAn8CQAJAIAAvAQgiAUH04AFNBEAgAUEBRg0BIAFB9cYBRg0BDAILIAFBgbICRg0AIAFB9eABRw0BCyAAKAIAIQEgAEEANgIAIAAoAgwQBiAAEAYgASADIAAgA0YbIQMCQCACRQRAQQAhAgwBCyACIAE2AgALIAEMAQsgACICKAIACyIADQALIAMLsgQCBX8BfgJAAkACQCAAIAGtEBciAQRAIAEtAAANAUEAIQAMAgsgBARAIARBADYCBCAEQQ42AgALQQAPC0EAIQADQCABLQAABH4gASkDCCABKQMQfQVCAAtCBFQNASABEAwhByABIAEQDCIGrRATIghFBEBBACECIAQEQCAEQQA2AgQgBEEVNgIACyABEAggAEUNAwNAIAAoAgAhASAAKAIMEAYg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DCAAQRg2AggLDAMLIAAgBiAFLwFqQQAgBCABEQYAIQEgBhALIAFFDQILAkAgDUUEQCABIQMMAQsgACABIAUvAWgQgQEhAyABEAsgA0UNAQsCQCAJRSAMckUEQCADIQEMAQsgACADQQEQgAEhASADEAsgAUUNAQsgASEDDAELQQAhAwsgBUHwAGokACADC4UBAQF/IAFFBEAgAEEIaiIABEAgAEEANgIEIABBEjYCAAtBAA8LQTgQCSIDRQRAIABBCGoiAARAIABBADYCBCAAQQ42AgALQQAPCyADQQA2AhAgA0IANwIIIANCADcDKCADQQA2AgQgAyACNgIAIANCADcDGCADQQA2AjAgACABQTsgAxBCCw8AIAAgASACQQBBABCCAQusAgECfyABRQRAIABBCGoiAARAIABBADYCBCAAQRI2AgALQQAPCwJAIAJBfUsNACACQf//A3FBCEYNACAAQQhqIgAEQCAAQQA2AgQgAEEQNgIAC0EADwsCQEGwwAAQCSIFBEAgBUEANgIIIAVCADcCACAFQYiBAUGogQEgAxs2AqhAIAUgAjYCFCAFIAM6ABAgBUEAOgAPIAVBADsBDCAFIAMgAkF9SyIGcToADiAFQQggAiAGG0H//wNxIAQgBUGIgQFBqIEBIAMbKAIAEQAAIgI2AqxAIAINASAFEDEgBRAGCyAAQQhqIgAEQCAAQQA2AgQgAEEONgIAC0EADwsgACABQTogBRBCIgAEfyAABSAFKAKsQCAFKAKoQCgCBBEDACAFEDEgBRAGQQALC6ABAQF/IAIgACgCBCIDIAIgA0kbIgIEQCAAIAMgAms2AgQCQAJAAkACQCAAKAIcIgMoAhRBAWsOAgEAAgsgA0GgAWogASAAKAIAIAJB3IABKAIAEQgADAILIAAgACgCMCABIAAoAgAgAkHEgAEoAgARBAA2AjAMAQsgASAAKAIAIAIQBxoLIAAgACgCACACajYCACAAIAAoAgggAmo2AggLC7cCAQR/QX4hAgJAIABFDQAgACgCIEUNACAAKAIkIgRFDQAgACgCHCIBRQ0AIAEoAgAgAEcNAAJAAkAgASgCICIDQTlrDjkBAgICAgICAgICAgIBAgICAQICAgICAgICAgICAgICAgICAQICAgICAgICAgICAQICAgICAgICAgEACyADQZoFRg0AIANBKkcNAQsCfwJ/An8gASgCBCICBEAgBCAAKAIoIAIQHiAAKAIcIQELIAEoAlAiAgsEQCAAKAIkIAAoAiggAhAeIAAoAhwhAQsgASgCTCICCwRAIAAoAiQgACgCKCACEB4gACgCHCEBCyABKAJIIgILBEAgACgCJCAAKAIoIAIQHiAAKAIcIQELIAAoAiQgACgCKCABEB4gAEEANgIcQX1BACADQfEARhshAgsgAgvrCQEIfyAAKAIwIgMgACgCDEEFayICIAIgA0sbIQggACgCACIEKAIEIQkgAUEERiEHAkADQCAEKAIQIgMgACgCoC5BKmpBA3UiAkkEQEEBIQYMAgsgCCADIAJrIgMgACgCaCAAKAJYayICIAQoAgRqIgVB//8DIAVB//8DSRsiBiADIAZJGyIDSwRAQQEhBiADQQBHIAdyRQ0CIAFFDQIgAyAFRw0CCyAAQQBBACAHIAMgBUZxIgUQOSAAIAAoAhBBBGsiBDYCECAAKAIEIARqIAM7AAAgACAAKAIQQQJqIgQ2AhAgACgCBCAEaiADQX9zOwAAIAAgACgCEEECajYCECAAKAIAEAoCfyACBEAgACgCACgCDCAAKAJIIAAoAlhqIAMgAiACIANLGyICEAcaIAAoAgAiBCAEKAIMIAJqNgIMIAQgBCgCECACazYCECAEIAQoAhQgAmo2AhQgACAAKAJYIAJqNgJYIAMgAmshAwsgAwsEQCAAKAIAIgIgAigCDCADEIMBIAAoAgAiAiACKAIMIANqNgIMIAIgAigCECADazYCECACIAIoAhQgA2o2AhQLIAAoAgAhBCAFRQ0AC0EAIQYLAkAgCSAEKAIEayICRQRAIAAoAmghAwwBCwJAIAAoAjAiAyACTQRAIABBAjYCgC4gACgCSCAEKAIAIANrIAMQBxogACAAKAIwIgM2AoQuIAAgAzYCaAwBCyACIAAoAkQgACgCaCIFa08EQCAAIAUgA2siBDYCaCAAKAJIIgUgAyAFaiAEEAcaIAAoAoAuIgNBAU0EQCAAIANBAWo2AoAuCyAAIAAoAmgiBSAAKAKELiIDIAMgBUsbNgKELiAAKAIAIQQLIAAoAkggBWogBCgCACACayACEAcaIAAgACgCaCACaiIDNgJoIAAgACgCMCAAKAKELiIEayIFIAIgAiAFSxsgBGo2AoQuCyAAIAM2AlgLIAAgAyAAKAJAIgIgAiADSRs2AkBBAyECAkAgBkUNACAAKAIAIgUoAgQhAgJAAkAgAUF7cUUNACACDQBBASECIAMgACgCWEYNAiAAKAJEIANrIQRBACECDAELIAIgACgCRCADayIETQ0AIAAoAlgiByAAKAIwIgZIDQAgACADIAZrIgM2AmggACAHIAZrNgJYIAAoAkgiAiACIAZqIAMQBxogACgCgC4iA0EBTQRAIAAgA0EBajYCgC4LIAAgACgCaCIDIAAoAoQuIgIgAiADSxs2AoQuIAAoAjAgBGohBCAAKAIAIgUoAgQhAgsCQCACIAQgAiAESRsiAkUEQCAAKAIwIQUMAQsgBSAAKAJIIANqIAIQgwEgACAAKAJoIAJqIgM2AmggACAAKAIwIgUgACgChC4iBGsiBiACIAIgBksbIARqNgKELgsgACADIAAoAkAiAiACIANJGzYCQCADIAAoAlgiBmsiAyAFIAAoAgwgACgCoC5BKmpBA3VrIgJB//8DIAJB//8DSRsiBCAEIAVLG0kEQEEAIQIgAUEERiADQQBHckUNASABRQ0BIAAoAgAoAgQNASADIARLDQELQQAhAiABQQRGBEAgACgCACgCBEUgAyAETXEhAgsgACAAKAJIIAZqIAQgAyADIARLGyIBIAIQOSAAIAAoAlggAWo2AlggACgCABAKQQJBACACGw8LIAIL/woCCn8DfiAAKQOYLiENIAAoAqAuIQQgAkEATgRAQQRBAyABLwECIggbIQlBB0GKASAIGyEFQX8hCgNAIAghByABIAsiDEEBaiILQQJ0ai8BAiEIAkACQCAGQQFqIgMgBU4NACAHIAhHDQAgAyEGDAELAkAgAyAJSARAIAAgB0ECdGoiBkHOFWohCSAGQcwVaiEKA0AgCjMBACEPAn8gBCAJLwEAIgZqIgVBP00EQCAPIASthiANhCENIAUMAQsgBEHAAEYEQCAAKAIEIAAoAhBqIA03AAAgACAAKAIQQQhqNgIQIA8hDSAGDAELIAAoAgQgACgCEGogDyAErYYgDYQ3AAAgACAAKAIQQQhqNgIQIA9BwAAgBGutiCENIAVBQGoLIQQgA0EBayIDDQALDAELIAcEQAJAIAcgCkYEQCANIQ8gBCEFIAMhBgwBCyAAIAdBAnRqIgNBzBVqMwEAIQ8gBCADQc4Vai8BACIDaiIFQT9NBEAgDyAErYYgDYQhDwwBCyAEQcAARgRAIAAoAgQgACgCEGogDTcAACAAIAAoAhBBCGo2AhAgAyEFDAELIAAoAgQgACgCEGogDyAErYYgDYQ3AAAgACAAKAIQQQhqNgIQIAVBQGohBSAPQcAAIARrrYghDwsgADMBjBYhDgJAIAUgAC8BjhYiBGoiA0E/TQRAIA4gBa2GIA+EIQ4MAQsgBUHAAEYEQCAAKAIEIAAoAhBqIA83AAAgACAAKAIQQQhqNgIQIAQhAwwBCyAAKAIEIAAoAhBqIA4gBa2GIA+ENwAAIAAgACgCEEEIajYCECADQUBqIQMgDkHAACAFa62IIQ4LIAasQgN9IQ0gA0E9TQRAIANBAmohBCANIAOthiAOhCENDAILIANBwABGBEAgACgCBCAAKAIQaiAONwAAIAAgACgCEEEIajYCEEECIQQMAgsgACgCBCAAKAIQaiANIAOthiAOhDcAACAAIAAoAhBBCGo2AhAgA0E+ayEEIA1BwAAgA2utiCENDAELIAZBCUwEQCAAMwGQFiEOAkAgBCAALwGSFiIFaiIDQT9NBEAgDiAErYYgDYQhDgwBCyAEQcAARgRAIAAoAgQgACgCEGogDTcAACAAIAAoAhBBCGo2AhAgBSEDDAELIAAoAgQgACgCEGogDiAErYYgDYQ3AAAgACAAKAIQQQhqNgIQIANBQGohAyAOQcAAIARrrYghDgsgBqxCAn0hDSADQTxNBEAgA0EDaiEEIA0gA62GIA6EIQ0MAgsgA0HAAEYEQCAAKAIEIAAoAhBqIA43AAAgACAAKAIQQQhqNgIQQQMhBAwCCyAAKAIEIAAoAhBqIA0gA62GIA6ENwAAIAAgACgCEEEIajYCECADQT1rIQQgDUHAACADa62IIQ0MAQsgADMBlBYhDgJAIAQgAC8BlhYiBWoiA0E/TQRAIA4gBK2GIA2EIQ4MAQsgBEHAAEYEQCAAKAIEIAAoAhBqIA03AAAgACAAKAIQQQhqNgIQIAUhAwwBCyAAKAIEIAAoAhBqIA4gBK2GIA2ENwAAIAAgACgCEEEIajYCECADQUBqIQMgDkHAACAEa62IIQ4LIAatQgp9IQ0gA0E4TQRAIANBB2ohBCANIAOthiAOhCENDAELIANBwABGBEAgACgCBCAAKAIQaiAONwAAIAAgACgCEEEIajYCEEEHIQQMAQsgACgCBCAAKAIQaiANIAOthiAOhDcAACAAIAAoAhBBCGo2AhAgA0E5ayEEIA1BwAAgA2utiCENC0EAIQYCfyAIRQRAQYoBIQVBAwwBC0EGQQcgByAIRiIDGyEFQQNBBCADGwshCSAHIQoLIAIgDEcNAAsLIAAgBDYCoC4gACANNwOYLgv5BQIIfwJ+AkAgACgC8C1FBEAgACkDmC4hCyAAKAKgLiEDDAELA0AgCSIDQQNqIQkgAyAAKALsLWoiAy0AAiEFIAApA5guIQwgACgCoC4hBAJAIAMvAAAiB0UEQCABIAVBAnRqIgMzAQAhCyAEIAMvAQIiBWoiA0E/TQRAIAsgBK2GIAyEIQsMAgsgBEHAAEYEQCAAKAIEIAAoAhBqIAw3AAAgACAAKAIQQQhqNgIQIAUhAwwCCyAAKAIEIAAoAhBqIAsgBK2GIAyENwAAIAAgACgCEEEIajYCECADQUBqIQMgC0HAACAEa62IIQsMAQsgBUGAzwBqLQAAIghBAnQiBiABaiIDQYQIajMBACELIANBhghqLwEAIQMgCEEIa0ETTQRAIAUgBkGA0QBqKAIAa60gA62GIAuEIQsgBkHA0wBqKAIAIANqIQMLIAMgAiAHQQFrIgcgB0EHdkGAAmogB0GAAkkbQYDLAGotAAAiBUECdCIIaiIKLwECaiEGIAozAQAgA62GIAuEIQsgBCAFQQRJBH8gBgUgByAIQYDSAGooAgBrrSAGrYYgC4QhCyAIQcDUAGooAgAgBmoLIgVqIgNBP00EQCALIASthiAMhCELDAELIARBwABGBEAgACgCBCAAKAIQaiAMNwAAIAAgACgCEEEIajYCECAFIQMMAQsgACgCBCAAKAIQaiALIASthiAMhDcAACAAIAAoAhBBCGo2AhAgA0FAaiEDIAtBwAAgBGutiCELCyAAIAs3A5guIAAgAzYCoC4gCSAAKALwLUkNAAsLIAFBgAhqMwEAIQwCQCADIAFBgghqLwEAIgJqIgFBP00EQCAMIAOthiALhCEMDAELIANBwABGBEAgACgCBCAAKAIQaiALNwAAIAAgACgCEEEIajYCECACIQEMAQsgACgCBCAAKAIQaiAMIAOthiALhDcAACAAIAAoAhBBCGo2AhAgAUFAaiEBIAxBwAAgA2utiCEMCyAAIAw3A5guIAAgATYCoC4L8AQBA38gAEHkAWohAgNAIAIgAUECdCIDakEAOwEAIAIgA0EEcmpBADsBACABQQJqIgFBngJHDQALIABBADsBzBUgAEEAOwHYEyAAQZQWakEAOwEAIABBkBZqQQA7AQAgAEGMFmpBADsBACAAQYgWakEAOwEAIABBhBZqQQA7AQAgAEGAFmpBADsBACAAQfwVakEAOwEAIABB+BVqQQA7AQAgAEH0FWpBADsBACAAQfAVakEAOwEAIABB7BVqQQA7AQAgAEHoFWpBADsBACAAQeQVakEAOwEAIABB4BVqQQA7AQAgAEHcFWpBADsBACAAQdgVakEAOwEAIABB1BVqQQA7AQAgAEHQFWpBADsBACAAQcwUakEAOwEAIABByBRqQQA7AQAgAEHEFGpBADsBACAAQcAUakEAOwEAIABBvBRqQQA7AQAgAEG4FGpBADsBACAAQbQUakEAOwEAIABBsBRqQQA7AQAgAEGsFGpBADsBACAAQagUakEAOwEAIABBpBRqQQA7AQAgAEGgFGpBADsBACAAQZwUakEAOwEAIABBmBRqQQA7AQAgAEGUFGpBADsBACAAQZAUakEAOwEAIABBjBRqQQA7AQAgAEGIFGpBADsBACAAQYQUakEAOwEAIABBgBRqQQA7AQAgAEH8E2pBADsBACAAQfgTakEAOwEAIABB9BNqQQA7AQAgAEHwE2pBADsBACAAQewTakEAOwEAIABB6BNqQQA7AQAgAEHkE2pBADsBACAAQeATakEAOwEAIABB3BNqQQA7AQAgAEIANwL8LSAAQeQJakEBOwEAIABBADYC+C0gAEEANgLwLQuKAwIGfwR+QcgAEAkiBEUEQEEADwsgBEIANwMAIARCADcDMCAEQQA2AiggBEIANwMgIARCADcDGCAEQgA3AxAgBEIANwMIIARCADcDOCABUARAIARBCBAJIgA2AgQgAEUEQCAEEAYgAwRAIANBADYCBCADQQ42AgALQQAPCyAAQgA3AwAgBA8LAkAgAaciBUEEdBAJIgZFDQAgBCAGNgIAIAVBA3RBCGoQCSIFRQ0AIAQgATcDECAEIAU2AgQDQCAAIAynIghBBHRqIgcpAwgiDVBFBEAgBygCACIHRQRAIAMEQCADQQA2AgQgA0ESNgIACyAGEAYgBRAGIAQQBkEADwsgBiAKp0EEdGoiCSANNwMIIAkgBzYCACAFIAhBA3RqIAs3AwAgCyANfCELIApCAXwhCgsgDEIBfCIMIAFSDQALIAQgCjcDCCAEQgAgCiACGzcDGCAFIAqnQQN0aiALNwMAIAQgCzcDMCAEDwsgAwRAIANBADYCBCADQQ42AgALIAYQBiAEEAZBAAvlAQIDfwF+QX8hBQJAIAAgASACQQAQJiIERQ0AIAAgASACEIsBIgZFDQACfgJAIAJBCHENACAAKAJAIAGnQQR0aigCCCICRQ0AIAIgAxAhQQBOBEAgAykDAAwCCyAAQQhqIgAEQCAAQQA2AgQgAEEPNgIAC0F/DwsgAxAqIAMgBCgCGDYCLCADIAQpAyg3AxggAyAEKAIUNgIoIAMgBCkDIDcDICADIAQoAhA7ATAgAyAELwFSOwEyQvwBQtwBIAQtAAYbCyEHIAMgBjYCCCADIAE3AxAgAyAHQgOENwMAQQAhBQsgBQspAQF/IAAgASACIABBCGoiABAmIgNFBEBBAA8LIAMoAjBBACACIAAQJQuAAwEGfwJ/An9BMCABQYB/Sw0BGgJ/IAFBgH9PBEBBhIQBQTA2AgBBAAwBC0EAQRAgAUELakF4cSABQQtJGyIFQcwAahAJIgFFDQAaIAFBCGshAgJAIAFBP3FFBEAgAiEBDAELIAFBBGsiBigCACIHQXhxIAFBP2pBQHFBCGsiASABQUBrIAEgAmtBD0sbIgEgAmsiA2shBCAHQQNxRQRAIAIoAgAhAiABIAQ2AgQgASACIANqNgIADAELIAEgBCABKAIEQQFxckECcjYCBCABIARqIgQgBCgCBEEBcjYCBCAGIAMgBigCAEEBcXJBAnI2AgAgAiADaiIEIAQoAgRBAXI2AgQgAiADEDsLAkAgASgCBCICQQNxRQ0AIAJBeHEiAyAFQRBqTQ0AIAEgBSACQQFxckECcjYCBCABIAVqIgIgAyAFayIFQQNyNgIEIAEgA2oiAyADKAIEQQFyNgIEIAIgBRA7CyABQQhqCyIBRQsEQEEwDwsgACABNgIAQQALCwoAIABBiIQBEAQL6AIBBX8gACgCUCEBIAAvATAhBEEEIQUDQCABQQAgAS8BACICIARrIgMgAiADSRs7AQAgAUEAIAEvAQIiAiAEayIDIAIgA0kbOwECIAFBACABLwEEIgIgBGsiAyACIANJGzsBBCABQQAgAS8BBiICIARrIgMgAiADSRs7AQYgBUGAgARGRQRAIAFBCGohASAFQQRqIQUMAQsLAkAgBEUNACAEQQNxIQUgACgCTCEBIARBAWtBA08EQCAEIAVrIQADQCABQQAgAS8BACICIARrIgMgAiADSRs7AQAgAUEAIAEvAQIiAiAEayIDIAIgA0kbOwECIAFBACABLwEEIgIgBGsiAyACIANJGzsBBCABQQAgAS8BBiICIARrIgMgAiADSRs7AQYgAUEIaiEBIABBBGsiAA0ACwsgBUUNAANAIAFBACABLwEAIgAgBGsiAiAAIAJJGzsBACABQQJqIQEgBUEBayIFDQALCwuDAQEEfyACQQFOBEAgAiAAKAJIIAFqIgJqIQMgACgCUCEEA0AgBCACKAAAQbHz3fF5bEEPdkH+/wdxaiIFLwEAIgYgAUH//wNxRwRAIAAoAkwgASAAKAI4cUH//wNxQQF0aiAGOwEAIAUgATsBAAsgAUEBaiEBIAJBAWoiAiADSQ0ACwsLUAECfyABIAAoAlAgACgCSCABaigAAEGx893xeWxBD3ZB/v8HcWoiAy8BACICRwRAIAAoAkwgACgCOCABcUEBdGogAjsBACADIAE7AQALIAILugEBAX8jAEEQayICJAAgAkEAOgAIQYCBAUECNgIAQfyAAUEDNgIAQfiAAUEENgIAQfSAAUEFNgIAQfCAAUEGNgIAQeyAAUEHNgIAQeiAAUEINgIAQeSAAUEJNgIAQeCAAUEKNgIAQdyAAUELNgIAQdiAAUEMNgIAQdSAAUENNgIAQdCAAUEONgIAQcyAAUEPNgIAQciAAUEQNgIAQcSAAUERNgIAQcCAAUESNgIAIAAgARBYIAJBEGokAAu9AQEBfyMAQRBrIgEkACABQQA6AAhBgIEBQQI2AgBB/IABQQM2AgBB+IABQQQ2AgBB9IABQQU2AgBB8IABQQY2AgBB7IABQQc2AgBB6IABQQg2AgBB5IABQQk2AgBB4IABQQo2AgBB3IABQQs2AgBB2IABQQw2AgBB1IABQQ02AgBB0IABQQ42AgBBzIABQQ82AgBByIABQRA2AgBBxIABQRE2AgBBwIABQRI2AgAgAEEANgJAIAFBEGokAEEAC70BAQF/IwBBEGsiASQAIAFBADoACEGAgQFBAjYCAEH8gAFBAzYCAEH4gAFBBDYCAEH0gAFBBTYCAEHwgAFBBjYCAEHsgAFBBzYCAEHogAFBCDYCAEHkgAFBCTYCAEHggAFBCjYCAEHcgAFBCzYCAEHYgAFBDDYCAEHUgAFBDTYCAEHQgAFBDjYCAEHMgAFBDzYCAEHIgAFBEDYCAEHEgAFBETYCAEHAgAFBEjYCACAAKAJAIQAgAUEQaiQAIAALvgEBAX8jAEEQayIEJAAgBEEAOgAIQYCBAUECNgIAQfyAAUEDNgIAQfiAAUEENgIAQfSAAUEFNgIAQfCAAUEGNgIAQeyAAUEHNgIAQeiAAUEINgIAQeSAAUEJNgIAQeCAAUEKNgIAQdyAAUELNgIAQdiAAUEMNgIAQdSAAUENNgIAQdCAAUEONgIAQcyAAUEPNgIAQciAAUEQNgIAQcSAAUERNgIAQcCAAUESNgIAIAAgASACIAMQVyAEQRBqJAALygEAIwBBEGsiAyQAIANBADoACEGAgQFBAjYCAEH8gAFBAzYCAEH4gAFBBDYCAEH0gAFBBTYCAEHwgAFBBjYCAEHsgAFBBzYCAEHogAFBCDYCAEHkgAFBCTYCAEHggAFBCjYCAEHcgAFBCzYCAEHYgAFBDDYCAEHUgAFBDTYCAEHQgAFBDjYCAEHMgAFBDzYCAEHIgAFBEDYCAEHEgAFBETYCAEHAgAFBEjYCACAAIAAoAkAgASACQdSAASgCABEAADYCQCADQRBqJAALwAEBAX8jAEEQayIDJAAgA0EAOgAIQYCBAUECNgIAQfyAAUEDNgIAQfiAAUEENgIAQfSAAUEFNgIAQfCAAUEGNgIAQeyAAUEHNgIAQeiAAUEINgIAQeSAAUEJNgIAQeCAAUEKNgIAQdyAAUELNgIAQdiAAUEMNgIAQdSAAUENNgIAQdCAAUEONgIAQcyAAUEPNgIAQciAAUEQNgIAQcSAAUERNgIAQcCAAUESNgIAIAAgASACEF0hACADQRBqJAAgAAu+AQEBfyMAQRBrIgIkACACQQA6AAhBgIEBQQI2AgBB/IABQQM2AgBB+IABQQQ2AgBB9IABQQU2AgBB8IABQQY2AgBB7IABQQc2AgBB6IABQQg2AgBB5IABQQk2AgBB4IABQQo2AgBB3IABQQs2AgBB2IABQQw2AgBB1IABQQ02AgBB0IABQQ42AgBBzIABQQ82AgBByIABQRA2AgBBxIABQRE2AgBBwIABQRI2AgAgACABEFwhACACQRBqJAAgAAu2AQEBfyMAQRBrIgAkACAAQQA6AAhBgIEBQQI2AgBB/IABQQM2AgBB+IABQQQ2AgBB9IABQQU2AgBB8IABQQY2AgBB7IABQQc2AgBB6IABQQg2AgBB5IABQQk2AgBB4IABQQo2AgBB3IABQQs2AgBB2IABQQw2AgBB1IABQQ02AgBB0IABQQ42AgBBzIABQQ82AgBByIABQRA2AgBBxIABQRE2AgBBwIABQRI2AgAgAEEQaiQAQQgLwgEBAX8jAEEQayIEJAAgBEEAOgAIQYCBAUECNgIAQfyAAUEDNgIAQfiAAUEENgIAQfSAAUEFNgIAQfCAAUEGNgIAQeyAAUEHNgIAQeiAAUEINgIAQeSAAUEJNgIAQeCAAUEKNgIAQdyAAUELNgIAQdiAAUEMNgIAQdSAAUENNgIAQdCAAUEONgIAQcyAAUEPNgIAQciAAUEQNgIAQcSAAUERNgIAQcCAAUESNgIAIAAgASACIAMQWSEAIARBEGokACAAC8IBAQF/IwBBEGsiBCQAIARBADoACEGAgQFBAjYCAEH8gAFBAzYCAEH4gAFBBDYCAEH0gAFBBTYCAEHwgAFBBjYCAEHsgAFBBzYCAEHogAFBCDYCAEHkgAFBCTYCAEHggAFBCjYCAEHcgAFBCzYCAEHYgAFBDDYCAEHUgAFBDTYCAEHQgAFBDjYCAEHMgAFBDzYCAEHIgAFBEDYCAEHEgAFBETYCAEHAgAFBEjYCACAAIAEgAiADEFYhACAEQRBqJAAgAAsHACAALwEwC8ABAQF/IwBBEGsiAyQAIANBADoACEGAgQFBAjYCAEH8gAFBAzYCAEH4gAFBBDYCAEH0gAFBBTYCAEHwgAFBBjYCAEHsgAFBBzYCAEHogAFBCDYCAEHkgAFBCTYCAEHggAFBCjYCAEHcgAFBCzYCAEHYgAFBDDYCAEHUgAFBDTYCAEHQgAFBDjYCAEHMgAFBDzYCAEHIgAFBEDYCAEHEgAFBETYCAEHAgAFBEjYCACAAIAEgAhBVIQAgA0EQaiQAIAALBwAgACgCQAsaACAAIAAoAkAgASACQdSAASgCABEAADYCQAsLACAAQQA2AkBBAAsHACAAKAIgCwQAQQgLzgUCA34BfyMAQYBAaiIIJAACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQCAEDhECAwwFAAEECAkJCQkJCQcJBgkLIANCCFoEfiACIAEoAmQ2AgAgAiABKAJoNgIEQggFQn8LIQYMCwsgARAGDAoLIAEoAhAiAgRAIAIgASkDGCABQeQAaiICEEEiA1ANCCABKQMIIgVCf4UgA1QEQCACBEAgAkEANgIEIAJBFTYCAAsMCQsgAUEANgIQIAEgAyAFfDcDCCABIAEpAwAgA3w3AwALIAEtAHgEQCABKQMAIQUMCQtCACEDIAEpAwAiBVAEQCABQgA3AyAMCgsDQCAAIAggBSADfSIFQoDAACAFQoDAAFQbEBEiB0J/VwRAIAFB5ABqIgEEQCABIAAoAgw2AgAgASAAKAIQNgIECwwJCyAHUEUEQCABKQMAIgUgAyAHfCIDWA0KDAELCyABQeQAagRAIAFBADYCaCABQRE2AmQLDAcLIAEpAwggASkDICIFfSIHIAMgAyAHVhsiA1ANCAJAIAEtAHhFDQAgACAFQQAQFEF/Sg0AIAFB5ABqIgEEQCABIAAoAgw2AgAgASAAKAIQNgIECwwHCyAAIAIgAxARIgZCf1cEQCABQeQAagRAIAFBADYCaCABQRE2AmQLDAcLIAEgASkDICAGfCIDNwMgIAZCAFINCEIAIQYgAyABKQMIWg0IIAFB5ABqBEAgAUEANgJoIAFBETYCZAsMBgsgASkDICABKQMAIgV9IAEpAwggBX0gAiADIAFB5ABqEEQiA0IAUw0FIAEgASkDACADfDcDIAwHCyACIAFBKGoQYEEfdawhBgwGCyABMABgIQYMBQsgASkDcCEGDAQLIAEpAyAgASkDAH0hBgwDCyABQeQAagRAIAFBADYCaCABQRw2AmQLC0J/IQYMAQsgASAFNwMgCyAIQYBAayQAIAYLBwAgACgCAAsPACAAIAAoAjBBAWo2AjALGABB+IMBQgA3AgBBgIQBQQA2AgBB+IMBCwcAIABBDGoLBwAgACgCLAsHACAAKAIoCwcAIAAoAhgLFQAgACABrSACrUIghoQgAyAEEIoBCxMBAX4gABAzIgFCIIinEAAgAacLbwEBfiABrSACrUIghoQhBSMAQRBrIgEkAAJ/IABFBEAgBVBFBEAgBARAIARBADYCBCAEQRI2AgALQQAMAgtBAEIAIAMgBBA6DAELIAEgBTcDCCABIAA2AgAgAUIBIAMgBBA6CyEAIAFBEGokACAACxQAIAAgASACrSADrUIghoQgBBBSC9oCAgJ/AX4CfyABrSACrUIghoQiByAAKQMwVEEAIARBCkkbRQRAIABBCGoEQCAAQQA2AgwgAEESNgIIC0F/DAELIAAtABhBAnEEQCAAQQhqBEAgAEEANgIMIABBGTYCCAtBfwwBCyADBH8gA0H//wNxQQhGIANBfUtyBUEBC0UEQCAAQQhqBEAgAEEANgIMIABBEDYCCAtBfwwBCyAAKAJAIgEgB6ciBUEEdGooAgAiAgR/IAIoAhAgA0YFIANBf0YLIQYgASAFQQR0aiIBIQUgASgCBCEBAkAgBgRAIAFFDQEgAUEAOwFQIAEgASgCAEF+cSIANgIAIAANASABECAgBUEANgIEQQAMAgsCQCABDQAgBSACECsiATYCBCABDQAgAEEIagRAIABBADYCDCAAQQ42AggLQX8MAgsgASAEOwFQIAEgAzYCECABIAEoAgBBAXI2AgALQQALCxwBAX4gACABIAIgAEEIahBMIgNCIIinEAAgA6cLHwEBfiAAIAEgAq0gA61CIIaEEBEiBEIgiKcQACAEpwteAQF+An5CfyAARQ0AGiAAKQMwIgIgAUEIcUUNABpCACACUA0AGiAAKAJAIQADQCACIAKnQQR0IABqQRBrKAIADQEaIAJCAX0iAkIAUg0AC0IACyICQiCIpxAAIAKnCxMAIAAgAa0gAq1CIIaEIAMQiwELnwEBAn4CfiACrSADrUIghoQhBUJ/IQQCQCAARQ0AIAAoAgQNACAAQQRqIQIgBUJ/VwRAIAIEQCACQQA2AgQgAkESNgIAC0J/DAILQgAhBCAALQAQDQAgBVANACAAKAIUIAEgBRARIgRCf1UNACAAKAIUIQAgAgRAIAIgACgCDDYCACACIAAoAhA2AgQLQn8hBAsgBAsiBEIgiKcQACAEpwueAQEBfwJ/IAAgACABrSACrUIghoQgAyAAKAIcEH8iAQRAIAEQMkF/TARAIABBCGoEQCAAIAEoAgw2AgggACABKAIQNgIMCyABEAtBAAwCC0EYEAkiBEUEQCAAQQhqBEAgAEEANgIMIABBDjYCCAsgARALQQAMAgsgBCAANgIAIARBADYCDCAEQgA3AgQgBCABNgIUIARBADoAEAsgBAsLsQICAX8BfgJ/QX8hBAJAIAAgAa0gAq1CIIaEIgZBAEEAECZFDQAgAC0AGEECcQRAIABBCGoEQCAAQQA2AgwgAEEZNgIIC0F/DAILIAAoAkAiASAGpyICQQR0aiIEKAIIIgUEQEEAIQQgBSADEHFBf0oNASAAQQhqBEAgAEEANgIMIABBDzYCCAtBfwwCCwJAIAQoAgAiBQRAIAUoAhQgA0YNAQsCQCABIAJBBHRqIgEoAgQiBA0AIAEgBRArIgQ2AgQgBA0AIABBCGoEQCAAQQA2AgwgAEEONgIIC0F/DAMLIAQgAzYCFCAEIAQoAgBBIHI2AgBBAAwCC0EAIQQgASACQQR0aiIBKAIEIgBFDQAgACAAKAIAQV9xIgI2AgAgAg0AIAAQICABQQA2AgQLIAQLCxQAIAAgAa0gAq1CIIaEIAQgBRBzCxIAIAAgAa0gAq1CIIaEIAMQFAtBAQF+An4gAUEAIAIbRQRAIABBCGoEQCAAQQA2AgwgAEESNgIIC0J/DAELIAAgASACIAMQdAsiBEIgiKcQACAEpwvGAwIFfwF+An4CQAJAIAAiBC0AGEECcQRAIARBCGoEQCAEQQA2AgwgBEEZNgIICwwBCyABRQRAIARBCGoEQCAEQQA2AgwgBEESNgIICwwBCyABECIiByABakEBay0AAEEvRwRAIAdBAmoQCSIARQRAIARBCGoEQCAEQQA2AgwgBEEONgIICwwCCwJAAkAgACIGIAEiBXNBA3ENACAFQQNxBEADQCAGIAUtAAAiAzoAACADRQ0DIAZBAWohBiAFQQFqIgVBA3ENAAsLIAUoAgAiA0F/cyADQYGChAhrcUGAgYKEeHENAANAIAYgAzYCACAFKAIEIQMgBkEEaiEGIAVBBGohBSADQYGChAhrIANBf3NxQYCBgoR4cUUNAAsLIAYgBS0AACIDOgAAIANFDQADQCAGIAUtAAEiAzoAASAGQQFqIQYgBUEBaiEFIAMNAAsLIAcgACIDakEvOwAACyAEQQBCAEEAEFIiAEUEQCADEAYMAQsgBCADIAEgAxsgACACEHQhCCADEAYgCEJ/VwRAIAAQCyAIDAMLIAQgCEEDQYCA/I8EEHNBf0oNASAEIAgQchoLQn8hCAsgCAsiCEIgiKcQACAIpwsQACAAIAGtIAKtQiCGhBByCxYAIAAgAa0gAq1CIIaEIAMgBCAFEGYL3iMDD38IfgF8IwBB8ABrIgkkAAJAIAFBAE5BACAAG0UEQCACBEAgAkEANgIEIAJBEjYCAAsMAQsgACkDGCISAn5BsIMBKQMAIhNCf1EEQCAJQoOAgIBwNwMwIAlChoCAgPAANwMoIAlCgYCAgCA3AyBBsIMBQQAgCUEgahAkNwMAIAlCj4CAgHA3AxAgCUKJgICAoAE3AwAgCUKMgICA0AE3AwhBuIMBQQggCRAkNwMAQbCDASkDACETCyATC4MgE1IEQCACBEAgAkEANgIEIAJBHDYCAAsMAQsgASABQRByQbiDASkDACITIBKDIBNRGyIKQRhxQRhGBEAgAgRAIAJBADYCBCACQRk2AgALDAELIAlBOGoQKgJAIAAgCUE4ahAhBEACQCAAKAIMQQVGBEAgACgCEEEsRg0BCyACBEAgAiAAKAIMNgIAIAIgACgCEDYCBAsMAgsgCkEBcUUEQCACBEAgAkEANgIEIAJBCTYCAAsMAwsgAhBJIgVFDQEgBSAKNgIEIAUgADYCACAKQRBxRQ0CIAUgBSgCFEECcjYCFCAFIAUoAhhBAnI2AhgMAgsgCkECcQRAIAIEQCACQQA2AgQgAkEKNgIACwwCCyAAEDJBf0wEQCACBEAgAiAAKAIMNgIAIAIgACgCEDYCBAsMAQsCfyAKQQhxBEACQCACEEkiAUUNACABIAo2AgQgASAANgIAIApBEHFFDQAgASABKAIUQQJyNgIUIAEgASgCGEECcjYCGAsgAQwBCyMAQUBqIg4kACAOQQhqECoCQCAAIA5BCGoQIUF/TARAIAIEQCACIAAoAgw2AgAgAiAAKAIQNgIECwwBCyAOLQAIQQRxRQRAIAIEQCACQYoBNgIEIAJBBDYCAAsMAQsgDikDICETIAIQSSIFRQRAQQAhBQwBCyAFIAo2AgQgBSAANgIAIApBEHEEQCAFIAUoAhRBAnI2AhQgBSAFKAIYQQJyNgIYCwJAAkACQCATUARAAn8gACEBAkADQCABKQMYQoCAEINCAFINASABKAIAIgENAAtBAQwBCyABQQBCAEESEA6nCw0EIAVBCGoEQCAFQQA2AgwgBUETNgIICwwBCyMAQdAAayIBJAACQCATQhVYBEAgBUEIagRAIAVBADYCDCAFQRM2AggLDAELAkACQCAFKAIAQgAgE0KqgAQgE0KqgARUGyISfUECEBRBf0oNACAFKAIAIgMoAgxBBEYEQCADKAIQQRZGDQELIAVBCGoEQCAFIAMoAgw2AgggBSADKAIQNgIMCwwBCyAFKAIAEDMiE0J/VwRAIAUoAgAhAyAFQQhqIggEQCAIIAMoAgw2AgAgCCADKAIQNgIECwwBCyAFKAIAIBJBACAFQQhqIg8QLSIERQ0BIBJCqoAEWgRAAkAgBCkDCEIUVARAIARBADoAAAwBCyAEQhQ3AxAgBEEBOgAACwsgAQRAIAFBADYCBCABQRM2AgALIARCABATIQwCQCAELQAABH4gBCkDCCAEKQMQfQVCAAunIgdBEmtBA0sEQEJ/IRcDQCAMQQFrIQMgByAMakEVayEGAkADQCADQQFqIgNB0AAgBiADaxB6IgNFDQEgA0EBaiIMQZ8SQQMQPQ0ACwJAIAMgBCgCBGusIhIgBCkDCFYEQCAEQQA6AAAMAQsgBCASNwMQIARBAToAAAsgBC0AAAR+IAQpAxAFQgALIRICQCAELQAABH4gBCkDCCAEKQMQfQVCAAtCFVgEQCABBEAgAUEANgIEIAFBEzYCAAsMAQsgBEIEEBMoAABB0JaVMEcEQCABBEAgAUEANgIEIAFBEzYCAAsMAQsCQAJAAkAgEkIUVA0AIAQoAgQgEqdqQRRrKAAAQdCWmThHDQACQCASQhR9IhQgBCIDKQMIVgRAIANBADoAAAwBCyADIBQ3AxAgA0EBOgAACyAFKAIUIRAgBSgCACEGIAMtAAAEfiAEKQMQBUIACyEWIARCBBATGiAEEAwhCyAEEAwhDSAEEB0iFEJ/VwRAIAEEQCABQRY2AgQgAUEENgIACwwECyAUQjh8IhUgEyAWfCIWVgRAIAEEQCABQQA2AgQgAUEVNgIACwwECwJAAkAgEyAUVg0AIBUgEyAEKQMIfFYNAAJAIBQgE30iFSAEKQMIVgRAIANBADoAAAwBCyADIBU3AxAgA0EBOgAAC0EAIQcMAQsgBiAUQQAQFEF/TARAIAEEQCABIAYoAgw2AgAgASAGKAIQNgIECwwFC0EBIQcgBkI4IAFBEGogARAtIgNFDQQLIANCBBATKAAAQdCWmTBHBEAgAQRAIAFBADYCBCABQRU2AgALIAdFDQQgAxAIDAQLIAMQHSEVAkAgEEEEcSIGRQ0AIBQgFXxCDHwgFlENACABBEAgAUEANgIEIAFBFTYCAAsgB0UNBCADEAgMBAsgA0IEEBMaIAMQFSIQIAsgC0H//wNGGyELIAMQFSIRIA0gDUH//wNGGyENAkAgBkUNACANIBFGQQAgCyAQRhsNACABBEAgAUEANgIEIAFBFTYCAAsgB0UNBCADEAgMBAsgCyANcgRAIAEEQCABQQA2AgQgAUEBNgIACyAHRQ0EIAMQCAwECyADEB0iGCADEB1SBEAgAQRAIAFBADYCBCABQQE2AgALIAdFDQQgAxAIDAQLIAMQHSEVIAMQHSEWIAMtAABFBEAgAQRAIAFBADYCBCABQRQ2AgALIAdFDQQgAxAIDAQLIAcEQCADEAgLAkAgFkIAWQRAIBUgFnwiGSAWWg0BCyABBEAgAUEWNgIEIAFBBDYCAAsMBAsgEyAUfCIUIBlUBEAgAQRAIAFBADYCBCABQRU2AgALDAQLAkAgBkUNACAUIBlRDQAgAQRAIAFBADYCBCABQRU2AgALDAQLIBggFUIugFgNASABBEAgAUEANgIEIAFBFTYCAAsMAwsCQCASIAQpAwhWBEAgBEEAOgAADAELIAQgEjcDECAEQQE6AAALIAUoAhQhAyAELQAABH4gBCkDCCAEKQMQfQVCAAtCFVgEQCABBEAgAUEANgIEIAFBFTYCAAsMAwsgBC0AAAR+IAQpAxAFQgALIRQgBEIEEBMaIAQQFQRAIAEEQCABQQA2AgQgAUEBNgIACwwDCyAEEAwgBBAMIgZHBEAgAQRAIAFBADYCBCABQRM2AgALDAMLIAQQFSEHIAQQFa0iFiAHrSIVfCIYIBMgFHwiFFYEQCABBEAgAUEANgIEIAFBFTYCAAsMAwsCQCADQQRxRQ0AIBQgGFENACABBEAgAUEANgIEIAFBFTYCAAsMAwsgBq0gARBqIgNFDQIgAyAWNwMgIAMgFTcDGCADQQA6ACwMAQsgGCABEGoiA0UNASADIBY3AyAgAyAVNwMYIANBAToALAsCQCASQhR8IhQgBCkDCFYEQCAEQQA6AAAMAQsgBCAUNwMQIARBAToAAAsgBBAMIQYCQCADKQMYIAMpAyB8IBIgE3xWDQACQCAGRQRAIAUtAARBBHFFDQELAkAgEkIWfCISIAQpAwhWBEAgBEEAOgAADAELIAQgEjcDECAEQQE6AAALIAQtAAAEfiAEKQMIIAQpAxB9BUIACyIUIAatIhJUDQEgBS0ABEEEcUEAIBIgFFIbDQEgBkUNACADIAQgEhATIAZBACABEDUiBjYCKCAGDQAgAxAWDAILAkAgEyADKQMgIhJYBEACQCASIBN9IhIgBCkDCFYEQCAEQQA6AAAMAQsgBCASNwMQIARBAToAAAsgBCADKQMYEBMiBkUNAiAGIAMpAxgQFyIHDQEgAQRAIAFBADYCBCABQQ42AgALIAMQFgwDCyAFKAIAIBJBABAUIQcgBSgCACEGIAdBf0wEQCABBEAgASAGKAIMNgIAIAEgBigCEDYCBAsgAxAWDAMLQQAhByAGEDMgAykDIFENACABBEAgAUEANgIEIAFBEzYCAAsgAxAWDAILQgAhFAJAAkAgAykDGCIWUEUEQANAIBQgAykDCFIiC0UEQCADLQAsDQMgFkIuVA0DAn8CQCADKQMQIhVCgIAEfCISIBVaQQAgEkKAgICAAVQbRQ0AIAMoAgAgEqdBBHQQNCIGRQ0AIAMgBjYCAAJAIAMpAwgiFSASWg0AIAYgFadBBHRqIgZCADcCACAGQgA3AAUgFUIBfCIVIBJRDQADQCADKAIAIBWnQQR0aiIGQgA3AgAgBkIANwAFIBVCAXwiFSASUg0ACwsgAyASNwMIIAMgEjcDEEEBDAELIAEEQCABQQA2AgQgAUEONgIAC0EAC0UNBAtB2AAQCSIGBH8gBkIANwMgIAZBADYCGCAGQv////8PNwMQIAZBADsBDCAGQb+GKDYCCCAGQQE6AAYgBkEAOwEEIAZBADYCACAGQgA3A0ggBkGAgNiNeDYCRCAGQgA3AyggBkIANwMwIAZCADcDOCAGQUBrQQA7AQAgBkIANwNQIAYFQQALIQYgAygCACAUp0EEdGogBjYCAAJAIAYEQCAGIAUoAgAgB0EAIAEQaCISQn9VDQELIAsNBCABKAIAQRNHDQQgAQRAIAFBADYCBCABQRU2AgALDAQLIBRCAXwhFCAWIBJ9IhZCAFINAAsLIBQgAykDCFINAAJAIAUtAARBBHFFDQAgBwRAIActAAAEfyAHKQMQIAcpAwhRBUEAC0UNAgwBCyAFKAIAEDMiEkJ/VwRAIAUoAgAhBiABBEAgASAGKAIMNgIAIAEgBigCEDYCBAsgAxAWDAULIBIgAykDGCADKQMgfFINAQsgBxAIAn4gCARAAn8gF0IAVwRAIAUgCCABEEghFwsgBSADIAEQSCISIBdVCwRAIAgQFiASDAILIAMQFgwFC0IAIAUtAARBBHFFDQAaIAUgAyABEEgLIRcgAyEIDAMLIAEEQCABQQA2AgQgAUEVNgIACyAHEAggAxAWDAILIAMQFiAHEAgMAQsgAQRAIAFBADYCBCABQRU2AgALIAMQFgsCQCAMIAQoAgRrrCISIAQpAwhWBEAgBEEAOgAADAELIAQgEjcDECAEQQE6AAALIAQtAAAEfiAEKQMIIAQpAxB9BUIAC6ciB0ESa0EDSw0BCwsgBBAIIBdCf1UNAwwBCyAEEAgLIA8iAwRAIAMgASgCADYCACADIAEoAgQ2AgQLIAgQFgtBACEICyABQdAAaiQAIAgNAQsgAgRAIAIgBSgCCDYCACACIAUoAgw2AgQLDAELIAUgCCgCADYCQCAFIAgpAwg3AzAgBSAIKQMQNwM4IAUgCCgCKDYCICAIEAYgBSgCUCEIIAVBCGoiBCEBQQAhBwJAIAUpAzAiE1ANAEGAgICAeCEGAn8gE7pEAAAAAAAA6D+jRAAA4P///+9BpCIaRAAAAAAAAPBBYyAaRAAAAAAAAAAAZnEEQCAaqwwBC0EACyIDQYCAgIB4TQRAIANBAWsiA0EBdiADciIDQQJ2IANyIgNBBHYgA3IiA0EIdiADciIDQRB2IANyQQFqIQYLIAYgCCgCACIMTQ0AIAYQPCILRQRAIAEEQCABQQA2AgQgAUEONgIACwwBCwJAIAgpAwhCACAMG1AEQCAIKAIQIQ8MAQsgCCgCECEPA0AgDyAHQQJ0aigCACIBBEADQCABKAIYIQMgASALIAEoAhwgBnBBAnRqIg0oAgA2AhggDSABNgIAIAMiAQ0ACwsgB0EBaiIHIAxHDQALCyAPEAYgCCAGNgIAIAggCzYCEAsCQCAFKQMwUA0AQgAhEwJAIApBBHFFBEADQCAFKAJAIBOnQQR0aigCACgCMEEAQQAgAhAlIgFFDQQgBSgCUCABIBNBCCAEEE1FBEAgBCgCAEEKRw0DCyATQgF8IhMgBSkDMFQNAAwDCwALA0AgBSgCQCATp0EEdGooAgAoAjBBAEEAIAIQJSIBRQ0DIAUoAlAgASATQQggBBBNRQ0BIBNCAXwiEyAFKQMwVA0ACwwBCyACBEAgAiAEKAIANgIAIAIgBCgCBDYCBAsMAQsgBSAFKAIUNgIYDAELIAAgACgCMEEBajYCMCAFEEtBACEFCyAOQUBrJAAgBQsiBQ0BIAAQGhoLQQAhBQsgCUHwAGokACAFCxAAIwAgAGtBcHEiACQAIAALBgAgACQACwQAIwAL4CoDEX8IfgN8IwBBwMAAayIHJABBfyECAkAgAEUNAAJ/IAAtAChFBEBBACAAKAIYIAAoAhRGDQEaC0EBCyEBAkACQCAAKQMwIhRQRQRAIAAoAkAhCgNAIAogEqdBBHRqIgMtAAwhCwJAAkAgAygCCA0AIAsNACADKAIEIgNFDQEgAygCAEUNAQtBASEBCyAXIAtBAXOtQv8Bg3whFyASQgF8IhIgFFINAAsgF0IAUg0BCyAAKAIEQQhxIAFyRQ0BAn8gACgCACIDKAIkIgFBA0cEQCADKAIgBH9BfyADEBpBAEgNAhogAygCJAUgAQsEQCADEEMLQX8gA0EAQgBBDxAOQgBTDQEaIANBAzYCJAtBAAtBf0oNASAAKAIAKAIMQRZGBEAgACgCACgCEEEsRg0CCyAAKAIAIQEgAEEIagRAIAAgASgCDDYCCCAAIAEoAhA2AgwLDAILIAFFDQAgFCAXVARAIABBCGoEQCAAQQA2AgwgAEEUNgIICwwCCyAXp0EDdBAJIgtFDQFCfyEWQgAhEgNAAkAgCiASp0EEdGoiBigCACIDRQ0AAkAgBigCCA0AIAYtAAwNACAGKAIEIgFFDQEgASgCAEUNAQsgFiADKQNIIhMgEyAWVhshFgsgBi0ADEUEQCAXIBlYBEAgCxAGIABBCGoEQCAAQQA2AgwgAEEUNgIICwwECyALIBmnQQN0aiASNwMAIBlCAXwhGQsgEkIBfCISIBRSDQALIBcgGVYEQCALEAYgAEEIagRAIABBADYCDCAAQRQ2AggLDAILAkACQCAAKAIAKQMYQoCACINQDQACQAJAIBZCf1INACAAKQMwIhNQDQIgE0IBgyEVIAAoAkAhAwJAIBNCAVEEQEJ/IRRCACESQgAhFgwBCyATQn6DIRlCfyEUQgAhEkIAIRYDQCADIBKnQQR0aigCACIBBEAgFiABKQNIIhMgEyAWVCIBGyEWIBQgEiABGyEUCyADIBJCAYQiGKdBBHRqKAIAIgEEQCAWIAEpA0giEyATIBZUIgEbIRYgFCAYIAEbIRQLIBJCAnwhEiAZQgJ9IhlQRQ0ACwsCQCAVUA0AIAMgEqdBBHRqKAIAIgFFDQAgFiABKQNIIhMgEyAWVCIBGyEWIBQgEiABGyEUCyAUQn9RDQBCACETIwBBEGsiBiQAAkAgACAUIABBCGoiCBBBIhVQDQAgFSAAKAJAIBSnQQR0aigCACIKKQMgIhh8IhQgGFpBACAUQn9VG0UEQCAIBEAgCEEWNgIEIAhBBDYCAAsMAQsgCi0ADEEIcUUEQCAUIRMMAQsgACgCACAUQQAQFCEBIAAoAgAhAyABQX9MBEAgCARAIAggAygCDDYCACAIIAMoAhA2AgQLDAELIAMgBkEMakIEEBFCBFIEQCAAKAIAIQEgCARAIAggASgCDDYCACAIIAEoAhA2AgQLDAELIBRCBHwgFCAGKAAMQdCWncAARhtCFEIMAn9BASEBAkAgCikDKEL+////D1YNACAKKQMgQv7///8PVg0AQQAhAQsgAQsbfCIUQn9XBEAgCARAIAhBFjYCBCAIQQQ2AgALDAELIBQhEwsgBkEQaiQAIBMiFkIAUg0BIAsQBgwFCyAWUA0BCwJ/IAAoAgAiASgCJEEBRgRAIAFBDGoEQCABQQA2AhAgAUESNgIMC0F/DAELQX8gAUEAIBZBERAOQgBTDQAaIAFBATYCJEEAC0F/Sg0BC0IAIRYCfyAAKAIAIgEoAiRBAUYEQCABQQxqBEAgAUEANgIQIAFBEjYCDAtBfwwBC0F/IAFBAEIAQQgQDkIAUw0AGiABQQE2AiRBAAtBf0oNACAAKAIAIQEgAEEIagRAIAAgASgCDDYCCCAAIAEoAhA2AgwLIAsQBgwCCyAAKAJUIgIEQCACQgA3AxggAigCAEQAAAAAAAAAACACKAIMIAIoAgQRDgALIABBCGohBCAXuiEcQgAhFAJAAkACQANAIBcgFCITUgRAIBO6IByjIRsgE0IBfCIUuiAcoyEaAkAgACgCVCICRQ0AIAIgGjkDKCACIBs5AyAgAisDECAaIBuhRAAAAAAAAAAAoiAboCIaIAIrAxihY0UNACACKAIAIBogAigCDCACKAIEEQ4AIAIgGjkDGAsCfwJAIAAoAkAgCyATp0EDdGopAwAiE6dBBHRqIg0oAgAiAQRAIAEpA0ggFlQNAQsgDSgCBCEFAkACfwJAIA0oAggiAkUEQCAFRQ0BQQEgBSgCACICQQFxDQIaIAJBwABxQQZ2DAILQQEgBQ0BGgsgDSABECsiBTYCBCAFRQ0BIAJBAEcLIQZBACEJIwBBEGsiDCQAAkAgEyAAKQMwWgRAIABBCGoEQCAAQQA2AgwgAEESNgIIC0F/IQkMAQsgACgCQCIKIBOnIgNBBHRqIg8oAgAiAkUNACACLQAEDQACQCACKQNIQhp8IhhCf1cEQCAAQQhqBEAgAEEWNgIMIABBBDYCCAsMAQtBfyEJIAAoAgAgGEEAEBRBf0wEQCAAKAIAIQIgAEEIagRAIAAgAigCDDYCCCAAIAIoAhA2AgwLDAILIAAoAgBCBCAMQQxqIABBCGoiDhAtIhBFDQEgEBAMIQEgEBAMIQggEC0AAAR/IBApAxAgECkDCFEFQQALIQIgEBAIIAJFBEAgDgRAIA5BADYCBCAOQRQ2AgALDAILAkAgCEUNACAAKAIAIAGtQQEQFEF/TARAQYSEASgCACECIA4EQCAOIAI2AgQgDkEENgIACwwDC0EAIAAoAgAgCEEAIA4QRSIBRQ0BIAEgCEGAAiAMQQhqIA4QbiECIAEQBiACRQ0BIAwoAggiAkUNACAMIAIQbSICNgIIIA8oAgAoAjQgAhBvIQIgDygCACACNgI0CyAPKAIAIgJBAToABEEAIQkgCiADQQR0aigCBCIBRQ0BIAEtAAQNASACKAI0IQIgAUEBOgAEIAEgAjYCNAwBC0F/IQkLIAxBEGokACAJQQBIDQUgACgCABAfIhhCAFMNBSAFIBg3A0ggBgRAQQAhDCANKAIIIg0hASANRQRAIAAgACATQQhBABB/IgwhASAMRQ0HCwJAAkAgASAHQQhqECFBf0wEQCAEBEAgBCABKAIMNgIAIAQgASgCEDYCBAsMAQsgBykDCCISQsAAg1AEQCAHQQA7ATggByASQsAAhCISNwMICwJAAkAgBSgCECICQX5PBEAgBy8BOCIDRQ0BIAUgAzYCECADIQIMAgsgAg0AIBJCBINQDQAgByAHKQMgNwMoIAcgEkIIhCISNwMIQQAhAgwBCyAHIBJC9////w+DIhI3AwgLIBJCgAGDUARAIAdBADsBOiAHIBJCgAGEIhI3AwgLAn8gEkIEg1AEQEJ/IRVBgAoMAQsgBSAHKQMgIhU3AyggEkIIg1AEQAJAAkACQAJAQQggAiACQX1LG0H//wNxDg0CAwMDAwMDAwEDAwMAAwtBgApBgAIgFUKUwuTzD1YbDAQLQYAKQYACIBVCg4Ow/w9WGwwDC0GACkGAAiAVQv////8PVhsMAgtBgApBgAIgFUIAUhsMAQsgBSAHKQMoNwMgQYACCyEPIAAoAgAQHyITQn9XBEAgACgCACECIAQEQCAEIAIoAgw2AgAgBCACKAIQNgIECwwBCyAFIAUvAQxB9/8DcTsBDCAAIAUgDxA3IgpBAEgNACAHLwE4IghBCCAFKAIQIgMgA0F9SxtB//8DcSICRyEGAkACQAJAAkACQAJAAkAgAiAIRwRAIANBAEchAwwBC0EAIQMgBS0AAEGAAXFFDQELIAUvAVIhCSAHLwE6IQIMAQsgBS8BUiIJIAcvAToiAkYNAQsgASABKAIwQQFqNgIwIAJB//8DcQ0BIAEhAgwCCyABIAEoAjBBAWo2AjBBACEJDAILQSZBACAHLwE6QQFGGyICRQRAIAQEQCAEQQA2AgQgBEEYNgIACyABEAsMAwsgACABIAcvATpBACAAKAIcIAIRBgAhAiABEAsgAkUNAgsgCUEARyEJIAhBAEcgBnFFBEAgAiEBDAELIAAgAiAHLwE4EIEBIQEgAhALIAFFDQELAkAgCEUgBnJFBEAgASECDAELIAAgAUEAEIABIQIgARALIAJFDQELAkAgA0UEQCACIQMMAQsgACACIAUoAhBBASAFLwFQEIIBIQMgAhALIANFDQELAkAgCUUEQCADIQEMAQsgBSgCVCIBRQRAIAAoAhwhAQsCfyAFLwFSGkEBCwRAIAQEQCAEQQA2AgQgBEEYNgIACyADEAsMAgsgACADIAUvAVJBASABQQARBgAhASADEAsgAUUNAQsgACgCABAfIhhCf1cEQCAAKAIAIQIgBARAIAQgAigCDDYCACAEIAIoAhA2AgQLDAELAkAgARAyQQBOBEACfwJAAkAgASAHQUBrQoDAABARIhJCAVMNAEIAIRkgFUIAVQRAIBW5IRoDQCAAIAdBQGsgEhAbQQBIDQMCQCASQoDAAFINACAAKAJUIgJFDQAgAiAZQoBAfSIZuSAaoxB7CyABIAdBQGtCgMAAEBEiEkIAVQ0ACwwBCwNAIAAgB0FAayASEBtBAEgNAiABIAdBQGtCgMAAEBEiEkIAVQ0ACwtBACASQn9VDQEaIAQEQCAEIAEoAgw2AgAgBCABKAIQNgIECwtBfwshAiABEBoaDAELIAQEQCAEIAEoAgw2AgAgBCABKAIQNgIEC0F/IQILIAEgB0EIahAhQX9MBEAgBARAIAQgASgCDDYCACAEIAEoAhA2AgQLQX8hAgsCf0EAIQkCQCABIgNFDQADQCADLQAaQQFxBEBB/wEhCSADQQBCAEEQEA4iFUIAUw0CIBVCBFkEQCADQQxqBEAgA0EANgIQIANBFDYCDAsMAwsgFachCQwCCyADKAIAIgMNAAsLIAlBGHRBGHUiA0F/TAsEQCAEBEAgBCABKAIMNgIAIAQgASgCEDYCBAsgARALDAELIAEQCyACQQBIDQAgACgCABAfIRUgACgCACECIBVCf1cEQCAEBEAgBCACKAIMNgIAIAQgAigCEDYCBAsMAQsgAiATEHVBf0wEQCAAKAIAIQIgBARAIAQgAigCDDYCACAEIAIoAhA2AgQLDAELIAcpAwgiE0LkAINC5ABSBEAgBARAIARBADYCBCAEQRQ2AgALDAELAkAgBS0AAEEgcQ0AIBNCEINQRQRAIAUgBygCMDYCFAwBCyAFQRRqEAEaCyAFIAcvATg2AhAgBSAHKAI0NgIYIAcpAyAhEyAFIBUgGH03AyAgBSATNwMoIAUgBS8BDEH5/wNxIANB/wFxQQF0cjsBDCAPQQp2IQNBPyEBAkACQAJAAkAgBSgCECICQQxrDgMAAQIBCyAFQS47AQoMAgtBLSEBIAMNACAFKQMoQv7///8PVg0AIAUpAyBC/v///w9WDQBBFCEBIAJBCEYNACAFLwFSQQFGDQAgBSgCMCICBH8gAi8BBAVBAAtB//8DcSICBEAgAiAFKAIwKAIAakEBay0AAEEvRg0BC0EKIQELIAUgATsBCgsgACAFIA8QNyICQQBIDQAgAiAKRwRAIAQEQCAEQQA2AgQgBEEUNgIACwwBCyAAKAIAIBUQdUF/Sg0BIAAoAgAhAiAEBEAgBCACKAIMNgIAIAQgAigCEDYCBAsLIA0NByAMEAsMBwsgDQ0CIAwQCwwCCyAFIAUvAQxB9/8DcTsBDCAAIAVBgAIQN0EASA0FIAAgEyAEEEEiE1ANBSAAKAIAIBNBABAUQX9MBEAgACgCACECIAQEQCAEIAIoAgw2AgAgBCACKAIQNgIECwwGCyAFKQMgIRIjAEGAQGoiAyQAAkAgElBFBEAgAEEIaiECIBK6IRoDQEF/IQEgACgCACADIBJCgMAAIBJCgMAAVBsiEyACEGVBAEgNAiAAIAMgExAbQQBIDQIgACgCVCAaIBIgE30iErqhIBqjEHsgEkIAUg0ACwtBACEBCyADQYBAayQAIAFBf0oNAUEBIREgAUEcdkEIcUEIRgwCCyAEBEAgBEEANgIEIARBDjYCAAsMBAtBAAtFDQELCyARDQBBfyECAkAgACgCABAfQgBTDQAgFyEUQQAhCkIAIRcjAEHwAGsiESQAAkAgACgCABAfIhVCAFkEQCAUUEUEQANAIAAgACgCQCALIBenQQN0aigCAEEEdGoiAygCBCIBBH8gAQUgAygCAAtBgAQQNyIBQQBIBEBCfyEXDAQLIAFBAEcgCnIhCiAXQgF8IhcgFFINAAsLQn8hFyAAKAIAEB8iGEJ/VwRAIAAoAgAhASAAQQhqBEAgACABKAIMNgIIIAAgASgCEDYCDAsMAgsgEULiABAXIgZFBEAgAEEIagRAIABBADYCDCAAQQ42AggLDAILIBggFX0hEyAVQv////8PViAUQv//A1ZyIApyQQFxBEAgBkGZEkEEECwgBkIsEBggBkEtEA0gBkEtEA0gBkEAEBIgBkEAEBIgBiAUEBggBiAUEBggBiATEBggBiAVEBggBkGUEkEEECwgBkEAEBIgBiAYEBggBkEBEBILIAZBnhJBBBAsIAZBABASIAYgFEL//wMgFEL//wNUG6dB//8DcSIBEA0gBiABEA0gBkF/IBOnIBNC/v///w9WGxASIAZBfyAVpyAVQv7///8PVhsQEiAGIABBJEEgIAAtACgbaigCACIDBH8gAy8BBAVBAAtB//8DcRANIAYtAABFBEAgAEEIagRAIABBADYCDCAAQRQ2AggLIAYQCAwCCyAAIAYoAgQgBi0AAAR+IAYpAxAFQgALEBshASAGEAggAUEASA0BIAMEQCAAIAMoAgAgAzMBBBAbQQBIDQILIBMhFwwBCyAAKAIAIQEgAEEIagRAIAAgASgCDDYCCCAAIAEoAhA2AgwLQn8hFwsgEUHwAGokACAXQgBTDQAgACgCABAfQj+HpyECCyALEAYgAkEASA0BAn8gACgCACIBKAIkQQFHBEAgAUEMagRAIAFBADYCECABQRI2AgwLQX8MAQsgASgCICICQQJPBEAgAUEMagRAIAFBADYCECABQR02AgwLQX8MAQsCQCACQQFHDQAgARAaQQBODQBBfwwBCyABQQBCAEEJEA5Cf1cEQCABQQI2AiRBfwwBCyABQQA2AiRBAAtFDQIgACgCACECIAQEQCAEIAIoAgw2AgAgBCACKAIQNgIECwwBCyALEAYLIAAoAlQQfCAAKAIAEENBfyECDAILIAAoAlQQfAsgABBLQQAhAgsgB0HAwABqJAAgAgtFAEHwgwFCADcDAEHogwFCADcDAEHggwFCADcDAEHYgwFCADcDAEHQgwFCADcDAEHIgwFCADcDAEHAgwFCADcDAEHAgwELoQMBCH8jAEGgAWsiAiQAIAAQMQJAAn8CQCAAKAIAIgFBAE4EQCABQbATKAIASA0BCyACIAE2AhAgAkEgakH2ESACQRBqEHZBASEGIAJBIGohBCACQSBqECIhA0EADAELIAFBAnQiAUGwEmooAgAhBQJ/AkACQCABQcATaigCAEEBaw4CAAEECyAAKAIEIQNB9IIBKAIAIQdBACEBAkACQANAIAMgAUHQ8QBqLQAARwRAQdcAIQQgAUEBaiIBQdcARw0BDAILCyABIgQNAEGw8gAhAwwBC0Gw8gAhAQNAIAEtAAAhCCABQQFqIgMhASAIDQAgAyEBIARBAWsiBA0ACwsgBygCFBogAwwBC0EAIAAoAgRrQQJ0QdjAAGooAgALIgRFDQEgBBAiIQMgBUUEQEEAIQVBASEGQQAMAQsgBRAiQQJqCyEBIAEgA2pBAWoQCSIBRQRAQegSKAIAIQUMAQsgAiAENgIIIAJBrBJBkRIgBhs2AgQgAkGsEiAFIAYbNgIAIAFBqwogAhB2IAAgATYCCCABIQULIAJBoAFqJAAgBQszAQF/IAAoAhQiAyABIAIgACgCECADayIBIAEgAksbIgEQBxogACAAKAIUIAFqNgIUIAILBgBBsIgBCwYAQayIAQsGAEGkiAELBwAgAEEEagsHACAAQQhqCyYBAX8gACgCFCIBBEAgARALCyAAKAIEIQEgAEEEahAxIAAQBiABC6kBAQN/AkAgAC0AACICRQ0AA0AgAS0AACIERQRAIAIhAwwCCwJAIAIgBEYNACACQSByIAIgAkHBAGtBGkkbIAEtAAAiAkEgciACIAJBwQBrQRpJG0YNACAALQAAIQMMAgsgAUEBaiEBIAAtAAEhAiAAQQFqIQAgAg0ACwsgA0H/AXEiAEEgciAAIABBwQBrQRpJGyABLQAAIgBBIHIgACAAQcEAa0EaSRtrC8sGAgJ+An8jAEHgAGsiByQAAkACQAJAAkACQAJAAkACQAJAAkACQCAEDg8AAQoCAwQGBwgICAgICAUICyABQgA3AyAMCQsgACACIAMQESIFQn9XBEAgAUEIaiIBBEAgASAAKAIMNgIAIAEgACgCEDYCBAsMCAsCQCAFUARAIAEpAygiAyABKQMgUg0BIAEgAzcDGCABQQE2AgQgASgCAEUNASAAIAdBKGoQIUF/TARAIAFBCGoiAQRAIAEgACgCDDYCACABIAAoAhA2AgQLDAoLAkAgBykDKCIDQiCDUA0AIAcoAlQgASgCMEYNACABQQhqBEAgAUEANgIMIAFBBzYCCAsMCgsgA0IEg1ANASAHKQNAIAEpAxhRDQEgAUEIagRAIAFBADYCDCABQRU2AggLDAkLIAEoAgQNACABKQMoIgMgASkDICIGVA0AIAUgAyAGfSIDWA0AIAEoAjAhBANAIAECfyAFIAN9IgZC/////w8gBkL/////D1QbIganIQBBACACIAOnaiIIRQ0AGiAEIAggAEHUgAEoAgARAAALIgQ2AjAgASABKQMoIAZ8NwMoIAUgAyAGfCIDVg0ACwsgASABKQMgIAV8NwMgDAgLIAEoAgRFDQcgAiABKQMYIgM3AxggASgCMCEAIAJBADYCMCACIAM3AyAgAiAANgIsIAIgAikDAELsAYQ3AwAMBwsgA0IIWgR+IAIgASgCCDYCACACIAEoAgw2AgRCCAVCfwshBQwGCyABEAYMBQtCfyEFIAApAxgiA0J/VwRAIAFBCGoiAQRAIAEgACgCDDYCACABIAAoAhA2AgQLDAULIAdBfzYCGCAHQo+AgICAAjcDECAHQoyAgIDQATcDCCAHQomAgICgATcDACADQQggBxAkQn+FgyEFDAQLIANCD1gEQCABQQhqBEAgAUEANgIMIAFBEjYCCAsMAwsgAkUNAgJAIAAgAikDACACKAIIEBRBAE4EQCAAEDMiA0J/VQ0BCyABQQhqIgEEQCABIAAoAgw2AgAgASAAKAIQNgIECwwDCyABIAM3AyAMAwsgASkDICEFDAILIAFBCGoEQCABQQA2AgwgAUEcNgIICwtCfyEFCyAHQeAAaiQAIAULjAcCAn4CfyMAQRBrIgckAAJAAkACQAJAAkACQAJAAkACQAJAIAQOEQABAgMFBggICAgICAgIBwgECAsgAUJ/NwMgIAFBADoADyABQQA7AQwgAUIANwMYIAEoAqxAIAEoAqhAKAIMEQEArUIBfSEFDAgLQn8hBSABKAIADQdCACEFIANQDQcgAS0ADQ0HIAFBKGohBAJAA0ACQCAHIAMgBX03AwggASgCrEAgAiAFp2ogB0EIaiABKAKoQCgCHBEAACEIQgAgBykDCCAIQQJGGyAFfCEFAkACQAJAIAhBAWsOAwADAQILIAFBAToADSABKQMgIgNCf1cEQCABBEAgAUEANgIEIAFBFDYCAAsMBQsgAS0ADkUNBCADIAVWDQQgASADNwMYIAFBAToADyACIAQgA6cQBxogASkDGCEFDAwLIAEtAAwNAyAAIARCgMAAEBEiBkJ/VwRAIAEEQCABIAAoAgw2AgAgASAAKAIQNgIECwwECyAGUARAIAFBAToADCABKAKsQCABKAKoQCgCGBEDACABKQMgQn9VDQEgAUIANwMgDAELAkAgASkDIEIAWQRAIAFBADoADgwBCyABIAY3AyALIAEoAqxAIAQgBiABKAKoQCgCFBEPABoLIAMgBVYNAQwCCwsgASgCAA0AIAEEQCABQQA2AgQgAUEUNgIACwsgBVBFBEAgAUEAOgAOIAEgASkDGCAFfDcDGAwIC0J/QgAgASgCABshBQwHCyABKAKsQCABKAKoQCgCEBEBAK1CAX0hBQwGCyABLQAQBEAgAS0ADQRAIAIgAS0ADwR/QQAFQQggASgCFCIAIABBfUsbCzsBMCACIAEpAxg3AyAgAiACKQMAQsgAhDcDAAwHCyACIAIpAwBCt////w+DNwMADAYLIAJBADsBMCACKQMAIQMgAS0ADQRAIAEpAxghBSACIANCxACENwMAIAIgBTcDGEIAIQUMBgsgAiADQrv///8Pg0LAAIQ3AwAMBQsgAS0ADw0EIAEoAqxAIAEoAqhAKAIIEQEArCEFDAQLIANCCFoEfiACIAEoAgA2AgAgAiABKAIENgIEQggFQn8LIQUMAwsgAUUNAiABKAKsQCABKAKoQCgCBBEDACABEDEgARAGDAILIAdBfzYCAEEQIAcQJEI/hCEFDAELIAEEQCABQQA2AgQgAUEUNgIAC0J/IQULIAdBEGokACAFC2MAQcgAEAkiAEUEQEGEhAEoAgAhASACBEAgAiABNgIEIAJBATYCAAsgAA8LIABBADoADCAAQQA6AAQgACACNgIAIABBADYCOCAAQgA3AzAgACABQQkgAUEBa0EJSRs2AgggAAu3fAIefwZ+IAIpAwAhIiAAIAE2AhwgACAiQv////8PICJC/////w9UGz4CICAAQRBqIQECfyAALQAEBEACfyAALQAMQQJ0IQpBfiEEAkACQAJAIAEiBUUNACAFKAIgRQ0AIAUoAiRFDQAgBSgCHCIDRQ0AIAMoAgAgBUcNAAJAAkAgAygCICIGQTlrDjkBAgICAgICAgICAgIBAgICAQICAgICAgICAgICAgICAgICAQICAgICAgICAgICAQICAgICAgICAgEACyAGQZoFRg0AIAZBKkcNAQsgCkEFSw0AAkACQCAFKAIMRQ0AIAUoAgQiAQRAIAUoAgBFDQELIAZBmgVHDQEgCkEERg0BCyAFQeDAACgCADYCGEF+DAQLIAUoAhBFDQEgAygCJCEEIAMgCjYCJAJAIAMoAhAEQCADEDACQCAFKAIQIgYgAygCECIIIAYgCEkbIgFFDQAgBSgCDCADKAIIIAEQBxogBSAFKAIMIAFqNgIMIAMgAygCCCABajYCCCAFIAUoAhQgAWo2AhQgBSAFKAIQIAFrIgY2AhAgAyADKAIQIAFrIgg2AhAgCA0AIAMgAygCBDYCCEEAIQgLIAYEQCADKAIgIQYMAgsMBAsgAQ0AIApBAXRBd0EAIApBBEsbaiAEQQF0QXdBACAEQQRKG2pKDQAgCkEERg0ADAILAkACQAJAAkACQCAGQSpHBEAgBkGaBUcNASAFKAIERQ0DDAcLIAMoAhRFBEAgA0HxADYCIAwCCyADKAI0QQx0QYDwAWshBAJAIAMoAowBQQJODQAgAygCiAEiAUEBTA0AIAFBBUwEQCAEQcAAciEEDAELQYABQcABIAFBBkYbIARyIQQLIAMoAgQgCGogBEEgciAEIAMoAmgbIgFBH3AgAXJBH3NBCHQgAUGA/gNxQQh2cjsAACADIAMoAhBBAmoiATYCECADKAJoBEAgAygCBCABaiAFKAIwIgFBGHQgAUEIdEGAgPwHcXIgAUEIdkGA/gNxIAFBGHZycjYAACADIAMoAhBBBGo2AhALIAVBATYCMCADQfEANgIgIAUQCiADKAIQDQcgAygCICEGCwJAAkACQAJAIAZBOUYEfyADQaABakHkgAEoAgARAQAaIAMgAygCECIBQQFqNgIQIAEgAygCBGpBHzoAACADIAMoAhAiAUEBajYCECABIAMoAgRqQYsBOgAAIAMgAygCECIBQQFqNgIQIAEgAygCBGpBCDoAAAJAIAMoAhwiAUUEQCADKAIEIAMoAhBqQQA2AAAgAyADKAIQIgFBBWo2AhAgASADKAIEakEAOgAEQQIhBCADKAKIASIBQQlHBEBBBCABQQJIQQJ0IAMoAowBQQFKGyEECyADIAMoAhAiAUEBajYCECABIAMoAgRqIAQ6AAAgAyADKAIQIgFBAWo2AhAgASADKAIEakEDOgAAIANB8QA2AiAgBRAKIAMoAhBFDQEMDQsgASgCJCELIAEoAhwhCSABKAIQIQggASgCLCENIAEoAgAhBiADIAMoAhAiAUEBajYCEEECIQQgASADKAIEaiANQQBHQQF0IAZBAEdyIAhBAEdBAnRyIAlBAEdBA3RyIAtBAEdBBHRyOgAAIAMoAgQgAygCEGogAygCHCgCBDYAACADIAMoAhAiDUEEaiIGNgIQIAMoAogBIgFBCUcEQEEEIAFBAkhBAnQgAygCjAFBAUobIQQLIAMgDUEFajYCECADKAIEIAZqIAQ6AAAgAygCHCgCDCEEIAMgAygCECIBQQFqNgIQIAEgAygCBGogBDoAACADKAIcIgEoAhAEfyADKAIEIAMoAhBqIAEoAhQ7AAAgAyADKAIQQQJqNgIQIAMoAhwFIAELKAIsBEAgBQJ/IAUoAjAhBiADKAIQIQRBACADKAIEIgFFDQAaIAYgASAEQdSAASgCABEAAAs2AjALIANBxQA2AiAgA0EANgIYDAILIAMoAiAFIAYLQcUAaw4jAAQEBAEEBAQEBAQEBAQEBAQEBAQEBAIEBAQEBAQEBAQEBAMECyADKAIcIgEoAhAiBgRAIAMoAgwiCCADKAIQIgQgAS8BFCADKAIYIg1rIglqSQRAA0AgAygCBCAEaiAGIA1qIAggBGsiCBAHGiADIAMoAgwiDTYCEAJAIAMoAhwoAixFDQAgBCANTw0AIAUCfyAFKAIwIQZBACADKAIEIARqIgFFDQAaIAYgASANIARrQdSAASgCABEAAAs2AjALIAMgAygCGCAIajYCGCAFKAIcIgYQMAJAIAUoAhAiBCAGKAIQIgEgASAESxsiAUUNACAFKAIMIAYoAgggARAHGiAFIAUoAgwgAWo2AgwgBiAGKAIIIAFqNgIIIAUgBSgCFCABajYCFCAFIAUoAhAgAWs2AhAgBiAGKAIQIAFrIgE2AhAgAQ0AIAYgBigCBDYCCAsgAygCEA0MIAMoAhghDSADKAIcKAIQIQZBACEEIAkgCGsiCSADKAIMIghLDQALCyADKAIEIARqIAYgDWogCRAHGiADIAMoAhAgCWoiDTYCEAJAIAMoAhwoAixFDQAgBCANTw0AIAUCfyAFKAIwIQZBACADKAIEIARqIgFFDQAaIAYgASANIARrQdSAASgCABEAAAs2AjALIANBADYCGAsgA0HJADYCIAsgAygCHCgCHARAIAMoAhAiBCEJA0ACQCAEIAMoAgxHDQACQCADKAIcKAIsRQ0AIAQgCU0NACAFAn8gBSgCMCEGQQAgAygCBCAJaiIBRQ0AGiAGIAEgBCAJa0HUgAEoAgARAAALNgIwCyAFKAIcIgYQMAJAIAUoAhAiBCAGKAIQIgEgASAESxsiAUUNACAFKAIMIAYoAgggARAHGiAFIAUoAgwgAWo2AgwgBiAGKAIIIAFqNgIIIAUgBSgCFCABajYCFCAFIAUoAhAgAWs2AhAgBiAGKAIQIAFrIgE2AhAgAQ0AIAYgBigCBDYCCAtBACEEQQAhCSADKAIQRQ0ADAsLIAMoAhwoAhwhBiADIAMoAhgiAUEBajYCGCABIAZqLQAAIQEgAyAEQQFqNgIQIAMoAgQgBGogAToAACABBEAgAygCECEEDAELCwJAIAMoAhwoAixFDQAgAygCECIGIAlNDQAgBQJ/IAUoAjAhBEEAIAMoAgQgCWoiAUUNABogBCABIAYgCWtB1IABKAIAEQAACzYCMAsgA0EANgIYCyADQdsANgIgCwJAIAMoAhwoAiRFDQAgAygCECIEIQkDQAJAIAQgAygCDEcNAAJAIAMoAhwoAixFDQAgBCAJTQ0AIAUCfyAFKAIwIQZBACADKAIEIAlqIgFFDQAaIAYgASAEIAlrQdSAASgCABEAAAs2AjALIAUoAhwiBhAwAkAgBSgCECIEIAYoAhAiASABIARLGyIBRQ0AIAUoAgwgBigCCCABEAcaIAUgBSgCDCABajYCDCAGIAYoAgggAWo2AgggBSAFKAIUIAFqNgIUIAUgBSgCECABazYCECAGIAYoAhAgAWsiATYCECABDQAgBiAGKAIENgIIC0EAIQRBACEJIAMoAhBFDQAMCgsgAygCHCgCJCEGIAMgAygCGCIBQQFqNgIYIAEgBmotAAAhASADIARBAWo2AhAgAygCBCAEaiABOgAAIAEEQCADKAIQIQQMAQsLIAMoAhwoAixFDQAgAygCECIGIAlNDQAgBQJ/IAUoAjAhBEEAIAMoAgQgCWoiAUUNABogBCABIAYgCWtB1IABKAIAEQAACzYCMAsgA0HnADYCIAsCQCADKAIcKAIsBEAgAygCDCADKAIQIgFBAmpJBH8gBRAKIAMoAhANAkEABSABCyADKAIEaiAFKAIwOwAAIAMgAygCEEECajYCECADQaABakHkgAEoAgARAQAaCyADQfEANgIgIAUQCiADKAIQRQ0BDAcLDAYLIAUoAgQNAQsgAygCPA0AIApFDQEgAygCIEGaBUYNAQsCfyADKAKIASIBRQRAIAMgChCFAQwBCwJAAkACQCADKAKMAUECaw4CAAECCwJ/AkADQAJAAkAgAygCPA0AIAMQLyADKAI8DQAgCg0BQQAMBAsgAygCSCADKAJoai0AACEEIAMgAygC8C0iAUEBajYC8C0gASADKALsLWpBADoAACADIAMoAvAtIgFBAWo2AvAtIAEgAygC7C1qQQA6AAAgAyADKALwLSIBQQFqNgLwLSABIAMoAuwtaiAEOgAAIAMgBEECdGoiASABLwHkAUEBajsB5AEgAyADKAI8QQFrNgI8IAMgAygCaEEBaiIBNgJoIAMoAvAtIAMoAvQtRw0BQQAhBCADIAMoAlgiBkEATgR/IAMoAkggBmoFQQALIAEgBmtBABAPIAMgAygCaDYCWCADKAIAEAogAygCACgCEA0BDAILCyADQQA2AoQuIApBBEYEQCADIAMoAlgiAUEATgR/IAMoAkggAWoFQQALIAMoAmggAWtBARAPIAMgAygCaDYCWCADKAIAEApBA0ECIAMoAgAoAhAbDAILIAMoAvAtBEBBACEEIAMgAygCWCIBQQBOBH8gAygCSCABagVBAAsgAygCaCABa0EAEA8gAyADKAJoNgJYIAMoAgAQCiADKAIAKAIQRQ0BC0EBIQQLIAQL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bGl0ZXJhbC9sZW5ndGhzIHNldABpbnZhbGlkIGNvZGUgbGVuZ3RocyBzZXQAdW5rbm93biBoZWFkZXIgZmxhZ3Mgc2V0AGludmFsaWQgZGlzdGFuY2VzIHNldABpbnZhbGlkIGJpdCBsZW5ndGggcmVwZWF0AEZpbGUgYWxyZWFkeSBleGlzdHMAdG9vIG1hbnkgbGVuZ3RoIG9yIGRpc3RhbmNlIHN5bWJvbHMAaW52YWxpZCBzdG9yZWQgYmxvY2sgbGVuZ3RocwAlcyVzJXMAYnVmZmVyIGVycm9yAE5vIGVycm9yAHN0cmVhbSBlcnJvcgBUZWxsIGVycm9yAEludGVybmFsIGVycm9yAFNlZWsgZXJyb3IAV3JpdGUgZXJyb3IAZmlsZSBlcnJvcgBSZWFkIGVycm9yAFpsaWIgZXJyb3IAZGF0YSBlcnJvcgBDUkMgZXJyb3IAaW5jb21wYXRpYmxlIHZlcnNpb24AaW52YWxpZCBjb2RlIC0tIG1pc3NpbmcgZW5kLW9mLWJsb2NrAGluY29ycmVjdCBoZWFkZXIgY2hlY2sAaW5jb3JyZWN0IGxlbmd0aCBjaGVjawBpbmNvcnJlY3QgZGF0YSBjaGVjawBpbnZhbGlkIGRpc3RhbmNlIHRvbyBmYXIgYmFjawBoZWFkZXIgY3JjIG1pc21hdGNoADEuMi4xMy56bGliLW5nAGludmFsaWQgd2luZG93IHNpemUAUmVhZC1vbmx5IGFyY2hpdmUATm90IGEgemlwIGFyY2hpdmUAUmVzb3VyY2Ugc3RpbGwgaW4gdXNlAE1hbGxvYyBmYWlsdXJlAGludmFsaWQgYmxvY2sgdHlwZQBGYWlsdXJlIHRvIGNyZWF0ZSB0ZW1wb3JhcnkgZmlsZQBDYW4ndCBvcGVuIGZpbGUATm8gc3VjaCBmaWxlAFByZW1hdHVyZSBlbmQgb2YgZmlsZQBDYW4ndCByZW1vdmUgZmlsZQBpbnZhbGlkIGxpdGVyYWwvbGVuZ3RoIGNvZGUAaW52YWxpZCBkaXN0YW5jZSBjb2RlAHVua25vd24gY29tcHJlc3Npb24gbWV0aG9kAHN0cmVhbSBlbmQAQ29tcHJlc3NlZCBkYXRhIGludmFsaWQATXVsdGktZGlzayB6aXAgYXJjaGl2ZXMgbm90IHN1cHBvcnRl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UgAIANIACAAyAAgAsgAIAHIACADyAAgACgAIAIoACABKAAgAygAIACoACACqAAgAagAIAOoACAAaAAgAmgAIAFoACADaAAgAOgAIALoACAB6AAgA+gAIAAYACACGAAgARgAIAMYACAAmAAgApgAIAGYACADmAAgAFgAIAJYACABWAAgA1gAIADYACAC2AAgAdgAIAPYACAAOAAgAjgAIAE4ACADOAAgALgAIAK4ACABuAAgA7gAIAB4ACACeAAgAXgAIAN4ACAA+AAgAvgAIAH4ACAD+AAgAAQAIAIEACABBAAgAwQAIACEACAChAAgAYQAIAOEACAARAAgAkQAIAFEACADRAAgAMQAIALEACABxAAgA8QAIAAkACACJAAgASQAIAMkACAApAAgAqQAIAGkACADpAAgAGQAIAJkACABZAAgA2QAIADkACAC5AAgAeQAIAPkACAAFAAgAhQAIAEUACADFAAgAJQAIAKUACABlAAgA5QAIABUACACVAAgAVQAIANUACAA1AAgAtQAIAHUACAD1AAgADQAIAI0ACABNAAgAzQAIAC0ACACtAAgAbQAIAO0ACAAdAAgAnQAIAF0ACADdAAgAPQAIAL0ACAB9AAgA/QAIABMACQATAQkAkwAJAJMBCQBTAAkAUwEJANMACQDTAQkAMwAJADMBCQCzAAkAswEJAHMACQBzAQkA8wAJAPMBCQALAAkACwEJAIsACQCLAQkASwAJAEsBCQDLAAkAywEJACsACQArAQkAqwAJAKsBCQBrAAkAawEJAOsACQDrAQkAGwAJABsBCQCbAAkAmwEJAFsACQBbAQkA2wAJANsBCQA7AAkAOwEJALsACQC7AQkAewAJAHsBCQD7AAkA+wEJAAcACQAHAQkAhwAJAIcBCQBHAAkARwEJAMcACQDHAQkAJwAJACcBCQCnAAkApwEJAGcACQBnAQkA5wAJAOcBCQAXAAkAFwEJAJcACQCXAQkAVwAJAFcBCQDXAAkA1wEJADcACQA3AQkAtwAJALcBCQB3AAkAdwEJAPcACQD3AQkADwAJAA8BCQCPAAkAjwEJAE8ACQBPAQkAzwAJAM8BCQAvAAkALwEJAK8ACQCvAQkAbwAJAG8BCQDvAAkA7wEJAB8ACQAfAQkAnwAJAJ8BCQBfAAkAXwEJAN8ACQDfAQkAPwAJAD8BCQC/AAkA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SXMgYSBkaXJlY3RvcnkAVGV4dCBmaWxlIGJ1c3kARXhlYyBmb3JtYXQgZXJyb3IASW52YWxpZCBhcmd1bWVudABBcmd1bWVudCBsaXN0IHRvbyBsb25nAFN5bWJvbGljIGxpbmsgbG9vcABGaWxlbmFtZSB0b28gbG9uZwBUb28gbWFueSBvcGVuIGZpbGVzIGluIHN5c3RlbQBObyBmaWxlIGRlc2NyaXB0b3JzIGF2YWlsYWJsZQBCYWQgZmlsZSBkZXNjcmlwdG9yAE5vIGNoaWxkIHByb2Nlc3MAQmFkIGFkZHJlc3MARmlsZSB0b28gbGFyZ2UAVG9vIG1hbnkgbGlua3MATm8gbG9ja3MgYXZhaWxhYmxlAFJlc291cmNlIGRlYWRsb2NrIHdvdWxkIG9jY3VyAFN0YXRlIG5vdCByZWNvdmVyYWJsZQBQcmV2aW91cyBvd25lciBkaWVkAE9wZXJhdGlvbiBjYW5jZWxlZABGdW5jdGlvbiBub3QgaW1wbGVtZW50ZWQATm8gbWVzc2FnZSBvZiBkZXNpcmVkIHR5cGUASWRlbnRpZmllciByZW1vdmVkAERldmljZSBub3QgYSBzdHJlYW0ATm8gZGF0YSBhdmFpbGFibGUARGV2aWNlIHRpbWVvdXQAT3V0IG9mIHN0cmVhbXMgcmVzb3VyY2VzAExpbmsgaGFzIGJlZW4gc2V2ZXJlZABQcm90b2NvbCBlcnJvcgBCYWQgbWVzc2FnZQBGaWxlIGRlc2NyaXB0b3IgaW4gYmFkIHN0YXRlAE5vdCBhIHNvY2tldABEZXN0aW5hdGlvbiBhZGRyZXNzIHJlcXVpcmVkAE1lc3NhZ2UgdG9vIGxhcmdlAFByb3RvY29sIHdyb25nIHR5cGUgZm9yIHNvY2tldABQcm90b2NvbCBub3QgYXZhaWxhYmxlAFByb3RvY29sIG5vdCBzdXBwb3J0ZWQAU29ja2V0IHR5cGUgbm90IHN1cHBvcnRlZABOb3Qgc3VwcG9ydGVkAFByb3RvY29sIGZhbWlseSBub3Qgc3VwcG9ydGVkAEFkZHJlc3MgZmFtaWx5IG5vdCBzdXBwb3J0ZWQgYnkgcHJvdG9jb2wAQWRkcmVzcyBub3QgYXZhaWxhYmxlAE5ldHdvcmsgaXMgZG93bgBOZXR3b3JrIHVucmVhY2hhYmxlAENvbm5lY3Rpb24gcmVzZXQgYnkgbmV0d29yawBDb25uZWN0aW9uIGFib3J0ZWQATm8gYnVmZmVyIHNwYWNlIGF2YWlsYWJsZQBTb2NrZXQgaXMgY29ubmVjdGVkAFNvY2tldCBub3QgY29ubmVjdGVkAENhbm5vdCBzZW5kIGFmdGVyIHNvY2tldCBzaHV0ZG93bgBPcGVyYXRpb24gYWxyZWFkeSBpbiBwcm9ncmVzcwBPcGVyYXRpb24gaW4gcHJvZ3Jlc3MAU3RhbGUgZmlsZSBoYW5kbGUAUmVtb3RlIEkvTyBlcnJvcgBRdW90YSBleGNlZWRlZABObyBtZWRpdW0gZm91bmQAV3JvbmcgbWVkaXVtIHR5cGUATm8gZXJyb3IgaW5mb3JtYXRpb24AQcCAAQuFARMAAAAUAAAAFQAAABYAAAAXAAAAGAAAABkAAAAaAAAAGwAAABwAAAAdAAAAHgAAAB8AAAAgAAAAIQAAACIAAAAjAAAAgERQADEAAAAyAAAAMwAAADQAAAA1AAAANgAAADcAAAA4AAAAOQAAADIAAAAzAAAANAAAADUAAAA2AAAANwAAADgAQfSCAQsCXEQAQbCDAQsQ/////////////////////w==";io(Si)||(Si=b(Si));function Os(We){try{if(We==Si&&ue)return new Uint8Array(ue);var tt=ii(We);if(tt)return tt;if(F)return F(We);throw"sync fetching of the wasm failed: you can preload it to Module['wasmBinary'] manually, or emcc.py will do that for you when generating HTML (but not JS)"}catch(It){Ti(It)}}function so(We,tt){var It,nr,$;try{$=Os(We),nr=new WebAssembly.Module($),It=new WebAssembly.Instance(nr,tt)}catch(Ne){var me=Ne.toString();throw te("failed to compile wasm module: "+me),(me.includes("imported Memory")||me.includes("memory import"))&&te("Memory size incompatibility issues may be due to changing INITIAL_MEMORY at runtime to something too large. Use ALLOW_MEMORY_GROWTH to allow any size memory (and also make sure not to set INITIAL_MEMORY at runtime to something smaller than it was at compile time)."),Ne}return[It,nr]}function cc(){var We={a:Ma};function tt($,me){var Ne=$.exports;r.asm=Ne,Ie=r.asm.g,J(Ie.buffer),Z=r.asm.W,an(r.asm.h),Ns("wasm-instantiate")}if(Kn("wasm-instantiate"),r.instantiateWasm)try{var It=r.instantiateWasm(We,tt);return It}catch($){return te("Module.instantiateWasm callback failed with error: "+$),!1}var nr=so(Si,We);return tt(nr[0]),r.asm}function cu(We){return R.getFloat32(We,!0)}function op(We){return R.getFloat64(We,!0)}function ap(We){return R.getInt16(We,!0)}function Ms(We){return R.getInt32(We,!0)}function Dn(We,tt){R.setInt32(We,tt,!0)}function oo(We){for(;We.length>0;){var tt=We.shift();if(typeof tt=="function"){tt(r);continue}var It=tt.func;typeof It=="number"?tt.arg===void 0?Z.get(It)():Z.get(It)(tt.arg):It(tt.arg===void 0?null:tt.arg)}}function Us(We,tt){var It=new Date(Ms((We>>2)*4)*1e3);Dn((tt>>2)*4,It.getUTCSeconds()),Dn((tt+4>>2)*4,It.getUTCMinutes()),Dn((tt+8>>2)*4,It.getUTCHours()),Dn((tt+12>>2)*4,It.getUTCDate()),Dn((tt+16>>2)*4,It.getUTCMonth()),Dn((tt+20>>2)*4,It.getUTCFullYear()-1900),Dn((tt+24>>2)*4,It.getUTCDay()),Dn((tt+36>>2)*4,0),Dn((tt+32>>2)*4,0);var nr=Date.UTC(It.getUTCFullYear(),0,1,0,0,0,0),$=(It.getTime()-nr)/(1e3*60*60*24)|0;return Dn((tt+28>>2)*4,$),Us.GMTString||(Us.GMTString=lt("GMT")),Dn((tt+40>>2)*4,Us.GMTString),tt}function ml(We,tt){return Us(We,tt)}function yl(We,tt,It){Re.copyWithin(We,tt,tt+It)}function ao(We){try{return Ie.grow(We-xe.byteLength+65535>>>16),J(Ie.buffer),1}catch{}}function Vn(We){var tt=Re.length;We=We>>>0;var It=2147483648;if(We>It)return!1;for(var nr=1;nr<=4;nr*=2){var $=tt*(1+.2/nr);$=Math.min($,We+100663296);var me=Math.min(It,ke(Math.max(We,$),65536)),Ne=ao(me);if(Ne)return!0}return!1}function On(We){pe(We)}function Li(We){var tt=Date.now()/1e3|0;return We&&Dn((We>>2)*4,tt),tt}function Mn(){if(Mn.called)return;Mn.called=!0;var We=new Date().getFullYear(),tt=new Date(We,0,1),It=new Date(We,6,1),nr=tt.getTimezoneOffset(),$=It.getTimezoneOffset(),me=Math.max(nr,$);Dn((ds()>>2)*4,me*60),Dn((gs()>>2)*4,Number(nr!=$));function Ne(Zr){var qi=Zr.toTimeString().match(/\(([A-Za-z ]+)\)$/);return qi?qi[1]:"GMT"}var ft=Ne(tt),pt=Ne(It),Tt=lt(ft),er=lt(pt);$<nr?(Dn((wi()>>2)*4,Tt),Dn((wi()+4>>2)*4,er)):(Dn((wi()>>2)*4,er),Dn((wi()+4>>2)*4,Tt))}function _i(We){Mn();var tt=Date.UTC(Ms((We+20>>2)*4)+1900,Ms((We+16>>2)*4),Ms((We+12>>2)*4),Ms((We+8>>2)*4),Ms((We+4>>2)*4),Ms((We>>2)*4),0),It=new Date(tt);Dn((We+24>>2)*4,It.getUTCDay());var nr=Date.UTC(It.getUTCFullYear(),0,1,0,0,0,0),$=(It.getTime()-nr)/(1e3*60*60*24)|0;return Dn((We+28>>2)*4,$),It.getTime()/1e3|0}var tr=typeof atob=="function"?atob:function(We){var tt="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",It="",nr,$,me,Ne,ft,pt,Tt,er=0;We=We.replace(/[^A-Za-z0-9\+\/\=]/g,"");do Ne=tt.indexOf(We.charAt(er++)),ft=tt.indexOf(We.charAt(er++)),pt=tt.indexOf(We.charAt(er++)),Tt=tt.indexOf(We.charAt(er++)),nr=Ne<<2|ft>>4,$=(ft&15)<<4|pt>>2,me=(pt&3)<<6|Tt,It=It+String.fromCharCode(nr),pt!==64&&(It=It+String.fromCharCode($)),Tt!==64&&(It=It+String.fromCharCode(me));while(er<We.length);return It};function Oe(We){if(typeof I=="boolean"&&I){var tt;try{tt=Buffer.from(We,"base64")}catch{tt=new Buffer(We,"base64")}return new Uint8Array(tt.buffer,tt.byteOffset,tt.byteLength)}try{for(var It=tr(We),nr=new Uint8Array(It.length),$=0;$<It.length;++$)nr[$]=It.charCodeAt($);return nr}catch{throw new Error("Converting base64 string to bytes failed.")}}function ii(We){if(!!io(We))return Oe(We.slice(ps.length))}var Ma={e:ml,c:yl,d:Vn,a:On,b:Li,f:_i},hr=cc(),uc=r.___wasm_call_ctors=hr.h,uu=r._zip_ext_count_symlinks=hr.i,Ac=r._zip_file_get_external_attributes=hr.j,El=r._zipstruct_statS=hr.k,vA=r._zipstruct_stat_size=hr.l,Au=r._zipstruct_stat_mtime=hr.m,Ce=r._zipstruct_stat_crc=hr.n,Rt=r._zipstruct_errorS=hr.o,fc=r._zipstruct_error_code_zip=hr.p,Hi=r._zipstruct_stat_comp_size=hr.q,fu=r._zipstruct_stat_comp_method=hr.r,Yt=r._zip_close=hr.s,Cl=r._zip_delete=hr.t,DA=r._zip_dir_add=hr.u,lp=r._zip_discard=hr.v,pc=r._zip_error_init_with_code=hr.w,PA=r._zip_get_error=hr.x,Qn=r._zip_file_get_error=hr.y,hi=r._zip_error_strerror=hr.z,hc=r._zip_fclose=hr.A,SA=r._zip_file_add=hr.B,sa=r._free=hr.C,Ni=r._malloc=hr.D,_o=r._zip_source_error=hr.E,Ze=r._zip_source_seek=hr.F,lo=r._zip_file_set_external_attributes=hr.G,gc=r._zip_file_set_mtime=hr.H,pu=r._zip_fopen_index=hr.I,ji=r._zip_fread=hr.J,hu=r._zip_get_name=hr.K,xA=r._zip_get_num_entries=hr.L,Ua=r._zip_source_read=hr.M,dc=r._zip_name_locate=hr.N,hs=r._zip_open_from_source=hr.O,Ut=r._zip_set_file_compression=hr.P,Fn=r._zip_source_buffer=hr.Q,Ci=r._zip_source_buffer_create=hr.R,oa=r._zip_source_close=hr.S,co=r._zip_source_free=hr.T,_s=r._zip_source_keep=hr.U,aa=r._zip_source_open=hr.V,la=r._zip_source_tell=hr.X,Ho=r._zip_stat_index=hr.Y,wi=r.__get_tzname=hr.Z,gs=r.__get_daylight=hr._,ds=r.__get_timezone=hr.$,ms=r.stackSave=hr.aa,Hs=r.stackRestore=hr.ba,Un=r.stackAlloc=hr.ca;r.cwrap=ne,r.getValue=ae;var Pn;Wr=function We(){Pn||ys(),Pn||(Wr=We)};function ys(We){if(We=We||A,mr>0||(dt(),mr>0))return;function tt(){Pn||(Pn=!0,r.calledRun=!0,!Fe&&(jt(),o(r),r.onRuntimeInitialized&&r.onRuntimeInitialized(),$t()))}r.setStatus?(r.setStatus("Running..."),setTimeout(function(){setTimeout(function(){r.setStatus("")},1),tt()},1)):tt()}if(r.run=ys,r.preInit)for(typeof r.preInit=="function"&&(r.preInit=[r.preInit]);r.preInit.length>0;)r.preInit.pop()();return ys(),e}}();typeof Qx=="object"&&typeof tU=="object"?tU.exports=eU:typeof define=="function"&&define.amd?define([],function(){return eU}):typeof Qx=="object"&&(Qx.createModule=eU)});var Lf,Tle,Lle,Nle=yt(()=>{Lf=["number","number"],Tle=(ee=>(ee[ee.ZIP_ER_OK=0]="ZIP_ER_OK",ee[ee.ZIP_ER_MULTIDISK=1]="ZIP_ER_MULTIDISK",ee[ee.ZIP_ER_RENAME=2]="ZIP_ER_RENAME",ee[ee.ZIP_ER_CLOSE=3]="ZIP_ER_CLOSE",ee[ee.ZIP_ER_SEEK=4]="ZIP_ER_SEEK",ee[ee.ZIP_ER_READ=5]="ZIP_ER_READ",ee[ee.ZIP_ER_WRITE=6]="ZIP_ER_WRITE",ee[ee.ZIP_ER_CRC=7]="ZIP_ER_CRC",ee[ee.ZIP_ER_ZIPCLOSED=8]="ZIP_ER_ZIPCLOSED",ee[ee.ZIP_ER_NOENT=9]="ZIP_ER_NOENT",ee[ee.ZIP_ER_EXISTS=10]="ZIP_ER_EXISTS",ee[ee.ZIP_ER_OPEN=11]="ZIP_ER_OPEN",ee[ee.ZIP_ER_TMPOPEN=12]="ZIP_ER_TMPOPEN",ee[ee.ZIP_ER_ZLIB=13]="ZIP_ER_ZLIB",ee[ee.ZIP_ER_MEMORY=14]="ZIP_ER_MEMORY",ee[ee.ZIP_ER_CHANGED=15]="ZIP_ER_CHANGED",ee[ee.ZIP_ER_COMPNOTSUPP=16]="ZIP_ER_COMPNOTSUPP",ee[ee.ZIP_ER_EOF=17]="ZIP_ER_EOF",ee[ee.ZIP_ER_INVAL=18]="ZIP_ER_INVAL",ee[ee.ZIP_ER_NOZIP=19]="ZIP_ER_NOZIP",ee[ee.ZIP_ER_INTERNAL=20]="ZIP_ER_INTERNAL",ee[ee.ZIP_ER_INCONS=21]="ZIP_ER_INCONS",ee[ee.ZIP_ER_REMOVE=22]="ZIP_ER_REMOVE",ee[ee.ZIP_ER_DELETED=23]="ZIP_ER_DELETED",ee[ee.ZIP_ER_ENCRNOTSUPP=24]="ZIP_ER_ENCRNOTSUPP",ee[ee.ZIP_ER_RDONLY=25]="ZIP_ER_RDONLY",ee[ee.ZIP_ER_NOPASSWD=26]="ZIP_ER_NOPASSWD",ee[ee.ZIP_ER_WRONGPASSWD=27]="ZIP_ER_WRONGPASSWD",ee[ee.ZIP_ER_OPNOTSUPP=28]="ZIP_ER_OPNOTSUPP",ee[ee.ZIP_ER_INUSE=29]="ZIP_ER_INUSE",ee[ee.ZIP_ER_TELL=30]="ZIP_ER_TELL",ee[ee.ZIP_ER_COMPRESSED_DATA=31]="ZIP_ER_COMPRESSED_DATA",ee))(Tle||{}),Lle=t=>({get HEAPU8(){return t.HEAPU8},errors:Tle,SEEK_SET:0,SEEK_CUR:1,SEEK_END:2,ZIP_CHECKCONS:4,ZIP_EXCL:2,ZIP_RDONLY:16,ZIP_FL_OVERWRITE:8192,ZIP_FL_COMPRESSED:4,ZIP_OPSYS_DOS:0,ZIP_OPSYS_AMIGA:1,ZIP_OPSYS_OPENVMS:2,ZIP_OPSYS_UNIX:3,ZIP_OPSYS_VM_CMS:4,ZIP_OPSYS_ATARI_ST:5,ZIP_OPSYS_OS_2:6,ZIP_OPSYS_MACINTOSH:7,ZIP_OPSYS_Z_SYSTEM:8,ZIP_OPSYS_CPM:9,ZIP_OPSYS_WINDOWS_NTFS:10,ZIP_OPSYS_MVS:11,ZIP_OPSYS_VSE:12,ZIP_OPSYS_ACORN_RISC:13,ZIP_OPSYS_VFAT:14,ZIP_OPSYS_ALTERNATE_MVS:15,ZIP_OPSYS_BEOS:16,ZIP_OPSYS_TANDEM:17,ZIP_OPSYS_OS_400:18,ZIP_OPSYS_OS_X:19,ZIP_CM_DEFAULT:-1,ZIP_CM_STORE:0,ZIP_CM_DEFLATE:8,uint08S:t._malloc(1),uint32S:t._malloc(4),malloc:t._malloc,free:t._free,getValue:t.getValue,openFromSource:t.cwrap("zip_open_from_source","number",["number","number","number"]),close:t.cwrap("zip_close","number",["number"]),discard:t.cwrap("zip_discard",null,["number"]),getError:t.cwrap("zip_get_error","number",["number"]),getName:t.cwrap("zip_get_name","string",["number","number","number"]),getNumEntries:t.cwrap("zip_get_num_entries","number",["number","number"]),delete:t.cwrap("zip_delete","number",["number","number"]),statIndex:t.cwrap("zip_stat_index","number",["number",...Lf,"number","number"]),fopenIndex:t.cwrap("zip_fopen_index","number",["number",...Lf,"number"]),fread:t.cwrap("zip_fread","number",["number","number","number","number"]),fclose:t.cwrap("zip_fclose","number",["number"]),dir:{add:t.cwrap("zip_dir_add","number",["number","string"])},file:{add:t.cwrap("zip_file_add","number",["number","string","number","number"]),getError:t.cwrap("zip_file_get_error","number",["number"]),getExternalAttributes:t.cwrap("zip_file_get_external_attributes","number",["number",...Lf,"number","number","number"]),setExternalAttributes:t.cwrap("zip_file_set_external_attributes","number",["number",...Lf,"number","number","number"]),setMtime:t.cwrap("zip_file_set_mtime","number",["number",...Lf,"number","number"]),setCompression:t.cwrap("zip_set_file_compression","number",["number",...Lf,"number","number"])},ext:{countSymlinks:t.cwrap("zip_ext_count_symlinks","number",["number"])},error:{initWithCode:t.cwrap("zip_error_init_with_code",null,["number","number"]),strerror:t.cwrap("zip_error_strerror","string",["number"])},name:{locate:t.cwrap("zip_name_locate","number",["number","string","number"])},source:{fromUnattachedBuffer:t.cwrap("zip_source_buffer_create","number",["number",...Lf,"number","number"]),fromBuffer:t.cwrap("zip_source_buffer","number",["number","number",...Lf,"number"]),free:t.cwrap("zip_source_free",null,["number"]),keep:t.cwrap("zip_source_keep",null,["number"]),open:t.cwrap("zip_source_open","number",["number"]),close:t.cwrap("zip_source_close","number",["number"]),seek:t.cwrap("zip_source_seek","number",["number",...Lf,"number"]),tell:t.cwrap("zip_source_tell","number",["number"]),read:t.cwrap("zip_source_read","number",["number","number","number"]),error:t.cwrap("zip_source_error","number",["number"])},struct:{statS:t.cwrap("zipstruct_statS","number",[]),statSize:t.cwrap("zipstruct_stat_size","number",["number"]),statCompSize:t.cwrap("zipstruct_stat_comp_size","number",["number"]),statCompMethod:t.cwrap("zipstruct_stat_comp_method","number",["number"]),statMtime:t.cwrap("zipstruct_stat_mtime","number",["number"]),statCrc:t.cwrap("zipstruct_stat_crc","number",["number"]),errorS:t.cwrap("zipstruct_errorS","number",[]),errorCodeZip:t.cwrap("zipstruct_error_code_zip","number",["number"])}})});function rU(t,e){let r=t.indexOf(e);if(r<=0)return null;let o=r;for(;r>=0&&(o=r+e.length,t[o]!==V.sep);){if(t[r-1]===V.sep)return null;r=t.indexOf(e,o)}return t.length>o&&t[o]!==V.sep?null:t.slice(0,o)}var zl,Ole=yt(()=>{Pt();Pt();nA();zl=class extends Up{static async openPromise(e,r){let o=new zl(r);try{return await e(o)}finally{o.saveAndClose()}}constructor(e={}){let r=e.fileExtensions,o=e.readOnlyArchives,a=typeof r>"u"?A=>rU(A,".zip"):A=>{for(let p of r){let h=rU(A,p);if(h)return h}return null},n=(A,p)=>new Ji(p,{baseFs:A,readOnly:o,stats:A.statSync(p)}),u=async(A,p)=>{let h={baseFs:A,readOnly:o,stats:await A.statPromise(p)};return()=>new Ji(p,h)};super({...e,factorySync:n,factoryPromise:u,getMountPoint:a})}}});function cot(t){if(typeof t=="string"&&String(+t)===t)return+t;if(typeof t=="number"&&Number.isFinite(t))return t<0?Date.now()/1e3:t;if(Mle.types.isDate(t))return t.getTime()/1e3;throw new Error("Invalid time")}function Fx(){return Buffer.from([80,75,5,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0])}var ta,nU,Mle,iU,Ule,Rx,Ji,sU=yt(()=>{Pt();Pt();Pt();Pt();Pt();Pt();ta=Be("fs"),nU=Be("stream"),Mle=Be("util"),iU=$e(Be("zlib"));$4();Ule="mixed";Rx=class extends Error{constructor(r,o){super(r);this.name="Libzip Error",this.code=o}},Ji=class extends Mu{constructor(r,o={}){super();this.listings=new Map;this.entries=new Map;this.fileSources=new Map;this.fds=new Map;this.nextFd=0;this.ready=!1;this.readOnly=!1;let a=o;if(this.level=typeof a.level<"u"?a.level:Ule,r??=Fx(),typeof r=="string"){let{baseFs:A=new Tn}=a;this.baseFs=A,this.path=r}else this.path=null,this.baseFs=null;if(o.stats)this.stats=o.stats;else if(typeof r=="string")try{this.stats=this.baseFs.statSync(r)}catch(A){if(A.code==="ENOENT"&&a.create)this.stats=Ea.makeDefaultStats();else throw A}else this.stats=Ea.makeDefaultStats();this.libzip=P1();let n=this.libzip.malloc(4);try{let A=0;o.readOnly&&(A|=this.libzip.ZIP_RDONLY,this.readOnly=!0),typeof r=="string"&&(r=a.create?Fx():this.baseFs.readFileSync(r));let p=this.allocateUnattachedSource(r);try{this.zip=this.libzip.openFromSource(p,A,n),this.lzSource=p}catch(h){throw this.libzip.source.free(p),h}if(this.zip===0){let h=this.libzip.struct.errorS();throw this.libzip.error.initWithCode(h,this.libzip.getValue(n,"i32")),this.makeLibzipError(h)}}finally{this.libzip.free(n)}this.listings.set(Bt.root,new Set);let u=this.libzip.getNumEntries(this.zip,0);for(let A=0;A<u;++A){let p=this.libzip.getName(this.zip,A,0);if(V.isAbsolute(p))continue;let h=V.resolve(Bt.root,p);this.registerEntry(h,A),p.endsWith("/")&&this.registerListing(h)}if(this.symlinkCount=this.libzip.ext.countSymlinks(this.zip),this.symlinkCount===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));this.ready=!0}makeLibzipError(r){let o=this.libzip.struct.errorCodeZip(r),a=this.libzip.error.strerror(r),n=new Rx(a,this.libzip.errors[o]);if(o===this.libzip.errors.ZIP_ER_CHANGED)throw new Error(`Assertion failed: Unexpected libzip error: ${n.message}`);return n}getExtractHint(r){for(let o of this.entries.keys()){let a=this.pathUtils.extname(o);if(r.relevantExtensions.has(a))return!0}return!1}getAllFiles(){return Array.from(this.entries.keys())}getRealPath(){if(!this.path)throw new Error("ZipFS don't have real paths when loaded from a buffer");return this.path}prepareClose(){if(!this.ready)throw ar.EBUSY("archive closed, close");Og(this)}getBufferAndClose(){if(this.prepareClose(),this.entries.size===0)return this.discardAndClose(),Fx();try{if(this.libzip.source.keep(this.lzSource),this.libzip.close(this.zip)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));if(this.libzip.source.open(this.lzSource)===-1)throw this.makeLibzipError(this.libzip.source.error(this.lzSource));if(this.libzip.source.seek(this.lzSource,0,0,this.libzip.SEEK_END)===-1)throw this.makeLibzipError(this.libzip.source.error(this.lzSource));let r=this.libzip.source.tell(this.lzSource);if(r===-1)throw this.makeLibzipError(this.libzip.source.error(this.lzSource));if(this.libzip.source.seek(this.lzSource,0,0,this.libzip.SEEK_SET)===-1)throw this.makeLibzipError(this.libzip.source.error(this.lzSource));let o=this.libzip.malloc(r);if(!o)throw new Error("Couldn't allocate enough memory");try{let a=this.libzip.source.read(this.lzSource,o,r);if(a===-1)throw this.makeLibzipError(this.libzip.source.error(this.lzSource));if(a<r)throw new Error("Incomplete read");if(a>r)throw new Error("Overread");let n=this.libzip.HEAPU8.subarray(o,o+r);return Buffer.from(n)}finally{this.libzip.free(o)}}finally{this.libzip.source.close(this.lzSource),this.libzip.source.free(this.lzSource),this.ready=!1}}discardAndClose(){this.prepareClose(),this.libzip.discard(this.zip),this.ready=!1}saveAndClose(){if(!this.path||!this.baseFs)throw new Error("ZipFS cannot be saved and must be discarded when loaded from a buffer");if(this.readOnly){this.discardAndClose();return}let r=this.baseFs.existsSync(this.path)||this.stats.mode===Ea.DEFAULT_MODE?void 0:this.stats.mode;this.baseFs.writeFileSync(this.path,this.getBufferAndClose(),{mode:r}),this.ready=!1}resolve(r){return V.resolve(Bt.root,r)}async openPromise(r,o,a){return this.openSync(r,o,a)}openSync(r,o,a){let n=this.nextFd++;return this.fds.set(n,{cursor:0,p:r}),n}hasOpenFileHandles(){return!!this.fds.size}async opendirPromise(r,o){return this.opendirSync(r,o)}opendirSync(r,o={}){let a=this.resolveFilename(`opendir '${r}'`,r);if(!this.entries.has(a)&&!this.listings.has(a))throw ar.ENOENT(`opendir '${r}'`);let n=this.listings.get(a);if(!n)throw ar.ENOTDIR(`opendir '${r}'`);let u=[...n],A=this.openSync(a,"r");return SD(this,a,u,{onClose:()=>{this.closeSync(A)}})}async readPromise(r,o,a,n,u){return this.readSync(r,o,a,n,u)}readSync(r,o,a=0,n=o.byteLength,u=-1){let A=this.fds.get(r);if(typeof A>"u")throw ar.EBADF("read");let p=u===-1||u===null?A.cursor:u,h=this.readFileSync(A.p);h.copy(o,a,p,p+n);let C=Math.max(0,Math.min(h.length-p,n));return(u===-1||u===null)&&(A.cursor+=C),C}async writePromise(r,o,a,n,u){return typeof o=="string"?this.writeSync(r,o,u):this.writeSync(r,o,a,n,u)}writeSync(r,o,a,n,u){throw typeof this.fds.get(r)>"u"?ar.EBADF("read"):new Error("Unimplemented")}async closePromise(r){return this.closeSync(r)}closeSync(r){if(typeof this.fds.get(r)>"u")throw ar.EBADF("read");this.fds.delete(r)}createReadStream(r,{encoding:o}={}){if(r===null)throw new Error("Unimplemented");let a=this.openSync(r,"r"),n=Object.assign(new nU.PassThrough({emitClose:!0,autoDestroy:!0,destroy:(A,p)=>{clearImmediate(u),this.closeSync(a),p(A)}}),{close(){n.destroy()},bytesRead:0,path:r,pending:!1}),u=setImmediate(async()=>{try{let A=await this.readFilePromise(r,o);n.bytesRead=A.length,n.end(A)}catch(A){n.destroy(A)}});return n}createWriteStream(r,{encoding:o}={}){if(this.readOnly)throw ar.EROFS(`open '${r}'`);if(r===null)throw new Error("Unimplemented");let a=[],n=this.openSync(r,"w"),u=Object.assign(new nU.PassThrough({autoDestroy:!0,emitClose:!0,destroy:(A,p)=>{try{A?p(A):(this.writeFileSync(r,Buffer.concat(a),o),p(null))}catch(h){p(h)}finally{this.closeSync(n)}}}),{close(){u.destroy()},bytesWritten:0,path:r,pending:!1});return u.on("data",A=>{let p=Buffer.from(A);u.bytesWritten+=p.length,a.push(p)}),u}async realpathPromise(r){return this.realpathSync(r)}realpathSync(r){let o=this.resolveFilename(`lstat '${r}'`,r);if(!this.entries.has(o)&&!this.listings.has(o))throw ar.ENOENT(`lstat '${r}'`);return o}async existsPromise(r){return this.existsSync(r)}existsSync(r){if(!this.ready)throw ar.EBUSY(`archive closed, existsSync '${r}'`);if(this.symlinkCount===0){let a=V.resolve(Bt.root,r);return this.entries.has(a)||this.listings.has(a)}let o;try{o=this.resolveFilename(`stat '${r}'`,r,void 0,!1)}catch{return!1}return o===void 0?!1:this.entries.has(o)||this.listings.has(o)}async accessPromise(r,o){return this.accessSync(r,o)}accessSync(r,o=ta.constants.F_OK){let a=this.resolveFilename(`access '${r}'`,r);if(!this.entries.has(a)&&!this.listings.has(a))throw ar.ENOENT(`access '${r}'`);if(this.readOnly&&o&ta.constants.W_OK)throw ar.EROFS(`access '${r}'`)}async statPromise(r,o={bigint:!1}){return o.bigint?this.statSync(r,{bigint:!0}):this.statSync(r)}statSync(r,o={bigint:!1,throwIfNoEntry:!0}){let a=this.resolveFilename(`stat '${r}'`,r,void 0,o.throwIfNoEntry);if(a!==void 0){if(!this.entries.has(a)&&!this.listings.has(a)){if(o.throwIfNoEntry===!1)return;throw ar.ENOENT(`stat '${r}'`)}if(r[r.length-1]==="/"&&!this.listings.has(a))throw ar.ENOTDIR(`stat '${r}'`);return this.statImpl(`stat '${r}'`,a,o)}}async fstatPromise(r,o){return this.fstatSync(r,o)}fstatSync(r,o){let a=this.fds.get(r);if(typeof a>"u")throw ar.EBADF("fstatSync");let{p:n}=a,u=this.resolveFilename(`stat '${n}'`,n);if(!this.entries.has(u)&&!this.listings.has(u))throw ar.ENOENT(`stat '${n}'`);if(n[n.length-1]==="/"&&!this.listings.has(u))throw ar.ENOTDIR(`stat '${n}'`);return this.statImpl(`fstat '${n}'`,u,o)}async lstatPromise(r,o={bigint:!1}){return o.bigint?this.lstatSync(r,{bigint:!0}):this.lstatSync(r)}lstatSync(r,o={bigint:!1,throwIfNoEntry:!0}){let a=this.resolveFilename(`lstat '${r}'`,r,!1,o.throwIfNoEntry);if(a!==void 0){if(!this.entries.has(a)&&!this.listings.has(a)){if(o.throwIfNoEntry===!1)return;throw ar.ENOENT(`lstat '${r}'`)}if(r[r.length-1]==="/"&&!this.listings.has(a))throw ar.ENOTDIR(`lstat '${r}'`);return this.statImpl(`lstat '${r}'`,a,o)}}statImpl(r,o,a={}){let n=this.entries.get(o);if(typeof n<"u"){let u=this.libzip.struct.statS();if(this.libzip.statIndex(this.zip,n,0,0,u)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));let p=this.stats.uid,h=this.stats.gid,C=this.libzip.struct.statSize(u)>>>0,I=512,v=Math.ceil(C/I),b=(this.libzip.struct.statMtime(u)>>>0)*1e3,E=b,F=b,N=b,U=new Date(E),z=new Date(F),te=new Date(N),le=new Date(b),pe=this.listings.has(o)?ta.constants.S_IFDIR:this.isSymbolicLink(n)?ta.constants.S_IFLNK:ta.constants.S_IFREG,ue=pe===ta.constants.S_IFDIR?493:420,ye=pe|this.getUnixMode(n,ue)&511,ae=this.libzip.struct.statCrc(u),Ie=Object.assign(new Ea.StatEntry,{uid:p,gid:h,size:C,blksize:I,blocks:v,atime:U,birthtime:z,ctime:te,mtime:le,atimeMs:E,birthtimeMs:F,ctimeMs:N,mtimeMs:b,mode:ye,crc:ae});return a.bigint===!0?Ea.convertToBigIntStats(Ie):Ie}if(this.listings.has(o)){let u=this.stats.uid,A=this.stats.gid,p=0,h=512,C=0,I=this.stats.mtimeMs,v=this.stats.mtimeMs,b=this.stats.mtimeMs,E=this.stats.mtimeMs,F=new Date(I),N=new Date(v),U=new Date(b),z=new Date(E),te=ta.constants.S_IFDIR|493,le=0,pe=Object.assign(new Ea.StatEntry,{uid:u,gid:A,size:p,blksize:h,blocks:C,atime:F,birthtime:N,ctime:U,mtime:z,atimeMs:I,birthtimeMs:v,ctimeMs:b,mtimeMs:E,mode:te,crc:le});return a.bigint===!0?Ea.convertToBigIntStats(pe):pe}throw new Error("Unreachable")}getUnixMode(r,o){if(this.libzip.file.getExternalAttributes(this.zip,r,0,0,this.libzip.uint08S,this.libzip.uint32S)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));return this.libzip.getValue(this.libzip.uint08S,"i8")>>>0!==this.libzip.ZIP_OPSYS_UNIX?o:this.libzip.getValue(this.libzip.uint32S,"i32")>>>16}registerListing(r){let o=this.listings.get(r);if(o)return o;this.registerListing(V.dirname(r)).add(V.basename(r));let n=new Set;return this.listings.set(r,n),n}registerEntry(r,o){this.registerListing(V.dirname(r)).add(V.basename(r)),this.entries.set(r,o)}unregisterListing(r){this.listings.delete(r),this.listings.get(V.dirname(r))?.delete(V.basename(r))}unregisterEntry(r){this.unregisterListing(r);let o=this.entries.get(r);this.entries.delete(r),!(typeof o>"u")&&(this.fileSources.delete(o),this.isSymbolicLink(o)&&this.symlinkCount--)}deleteEntry(r,o){if(this.unregisterEntry(r),this.libzip.delete(this.zip,o)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip))}resolveFilename(r,o,a=!0,n=!0){if(!this.ready)throw ar.EBUSY(`archive closed, ${r}`);let u=V.resolve(Bt.root,o);if(u==="/")return Bt.root;let A=this.entries.get(u);if(a&&A!==void 0)if(this.symlinkCount!==0&&this.isSymbolicLink(A)){let p=this.getFileSource(A).toString();return this.resolveFilename(r,V.resolve(V.dirname(u),p),!0,n)}else return u;for(;;){let p=this.resolveFilename(r,V.dirname(u),!0,n);if(p===void 0)return p;let h=this.listings.has(p),C=this.entries.has(p);if(!h&&!C){if(n===!1)return;throw ar.ENOENT(r)}if(!h)throw ar.ENOTDIR(r);if(u=V.resolve(p,V.basename(u)),!a||this.symlinkCount===0)break;let I=this.libzip.name.locate(this.zip,u.slice(1),0);if(I===-1)break;if(this.isSymbolicLink(I)){let v=this.getFileSource(I).toString();u=V.resolve(V.dirname(u),v)}else break}return u}allocateBuffer(r){Buffer.isBuffer(r)||(r=Buffer.from(r));let o=this.libzip.malloc(r.byteLength);if(!o)throw new Error("Couldn't allocate enough memory");return new Uint8Array(this.libzip.HEAPU8.buffer,o,r.byteLength).set(r),{buffer:o,byteLength:r.byteLength}}allocateUnattachedSource(r){let o=this.libzip.struct.errorS(),{buffer:a,byteLength:n}=this.allocateBuffer(r),u=this.libzip.source.fromUnattachedBuffer(a,n,0,1,o);if(u===0)throw this.libzip.free(o),this.makeLibzipError(o);return u}allocateSource(r){let{buffer:o,byteLength:a}=this.allocateBuffer(r),n=this.libzip.source.fromBuffer(this.zip,o,a,0,1);if(n===0)throw this.libzip.free(o),this.makeLibzipError(this.libzip.getError(this.zip));return n}setFileSource(r,o){let a=Buffer.isBuffer(o)?o:Buffer.from(o),n=V.relative(Bt.root,r),u=this.allocateSource(o);try{let A=this.libzip.file.add(this.zip,n,u,this.libzip.ZIP_FL_OVERWRITE);if(A===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));if(this.level!=="mixed"){let p=this.level===0?this.libzip.ZIP_CM_STORE:this.libzip.ZIP_CM_DEFLATE;if(this.libzip.file.setCompression(this.zip,A,0,p,this.level)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip))}return this.fileSources.set(A,a),A}catch(A){throw this.libzip.source.free(u),A}}isSymbolicLink(r){if(this.symlinkCount===0)return!1;if(this.libzip.file.getExternalAttributes(this.zip,r,0,0,this.libzip.uint08S,this.libzip.uint32S)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));return this.libzip.getValue(this.libzip.uint08S,"i8")>>>0!==this.libzip.ZIP_OPSYS_UNIX?!1:(this.libzip.getValue(this.libzip.uint32S,"i32")>>>16&ta.constants.S_IFMT)===ta.constants.S_IFLNK}getFileSource(r,o={asyncDecompress:!1}){let a=this.fileSources.get(r);if(typeof a<"u")return a;let n=this.libzip.struct.statS();if(this.libzip.statIndex(this.zip,r,0,0,n)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));let A=this.libzip.struct.statCompSize(n),p=this.libzip.struct.statCompMethod(n),h=this.libzip.malloc(A);try{let C=this.libzip.fopenIndex(this.zip,r,0,this.libzip.ZIP_FL_COMPRESSED);if(C===0)throw this.makeLibzipError(this.libzip.getError(this.zip));try{let I=this.libzip.fread(C,h,A,0);if(I===-1)throw this.makeLibzipError(this.libzip.file.getError(C));if(I<A)throw new Error("Incomplete read");if(I>A)throw new Error("Overread");let v=this.libzip.HEAPU8.subarray(h,h+A),b=Buffer.from(v);if(p===0)return this.fileSources.set(r,b),b;if(o.asyncDecompress)return new Promise((E,F)=>{iU.default.inflateRaw(b,(N,U)=>{N?F(N):(this.fileSources.set(r,U),E(U))})});{let E=iU.default.inflateRawSync(b);return this.fileSources.set(r,E),E}}finally{this.libzip.fclose(C)}}finally{this.libzip.free(h)}}async fchmodPromise(r,o){return this.chmodPromise(this.fdToPath(r,"fchmod"),o)}fchmodSync(r,o){return this.chmodSync(this.fdToPath(r,"fchmodSync"),o)}async chmodPromise(r,o){return this.chmodSync(r,o)}chmodSync(r,o){if(this.readOnly)throw ar.EROFS(`chmod '${r}'`);o&=493;let a=this.resolveFilename(`chmod '${r}'`,r,!1),n=this.entries.get(a);if(typeof n>"u")throw new Error(`Assertion failed: The entry should have been registered (${a})`);let A=this.getUnixMode(n,ta.constants.S_IFREG|0)&-512|o;if(this.libzip.file.setExternalAttributes(this.zip,n,0,0,this.libzip.ZIP_OPSYS_UNIX,A<<16)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip))}async fchownPromise(r,o,a){return this.chownPromise(this.fdToPath(r,"fchown"),o,a)}fchownSync(r,o,a){return this.chownSync(this.fdToPath(r,"fchownSync"),o,a)}async chownPromise(r,o,a){return this.chownSync(r,o,a)}chownSync(r,o,a){throw new Error("Unimplemented")}async renamePromise(r,o){return this.renameSync(r,o)}renameSync(r,o){throw new Error("Unimplemented")}async copyFilePromise(r,o,a){let{indexSource:n,indexDest:u,resolvedDestP:A}=this.prepareCopyFile(r,o,a),p=await this.getFileSource(n,{asyncDecompress:!0}),h=this.setFileSource(A,p);h!==u&&this.registerEntry(A,h)}copyFileSync(r,o,a=0){let{indexSource:n,indexDest:u,resolvedDestP:A}=this.prepareCopyFile(r,o,a),p=this.getFileSource(n),h=this.setFileSource(A,p);h!==u&&this.registerEntry(A,h)}prepareCopyFile(r,o,a=0){if(this.readOnly)throw ar.EROFS(`copyfile '${r} -> '${o}'`);if((a&ta.constants.COPYFILE_FICLONE_FORCE)!==0)throw ar.ENOSYS("unsupported clone operation",`copyfile '${r}' -> ${o}'`);let n=this.resolveFilename(`copyfile '${r} -> ${o}'`,r),u=this.entries.get(n);if(typeof u>"u")throw ar.EINVAL(`copyfile '${r}' -> '${o}'`);let A=this.resolveFilename(`copyfile '${r}' -> ${o}'`,o),p=this.entries.get(A);if((a&(ta.constants.COPYFILE_EXCL|ta.constants.COPYFILE_FICLONE_FORCE))!==0&&typeof p<"u")throw ar.EEXIST(`copyfile '${r}' -> '${o}'`);return{indexSource:u,resolvedDestP:A,indexDest:p}}async appendFilePromise(r,o,a){if(this.readOnly)throw ar.EROFS(`open '${r}'`);return typeof a>"u"?a={flag:"a"}:typeof a=="string"?a={flag:"a",encoding:a}:typeof a.flag>"u"&&(a={flag:"a",...a}),this.writeFilePromise(r,o,a)}appendFileSync(r,o,a={}){if(this.readOnly)throw ar.EROFS(`open '${r}'`);return typeof a>"u"?a={flag:"a"}:typeof a=="string"?a={flag:"a",encoding:a}:typeof a.flag>"u"&&(a={flag:"a",...a}),this.writeFileSync(r,o,a)}fdToPath(r,o){let a=this.fds.get(r)?.p;if(typeof a>"u")throw ar.EBADF(o);return a}async writeFilePromise(r,o,a){let{encoding:n,mode:u,index:A,resolvedP:p}=this.prepareWriteFile(r,a);A!==void 0&&typeof a=="object"&&a.flag&&a.flag.includes("a")&&(o=Buffer.concat([await this.getFileSource(A,{asyncDecompress:!0}),Buffer.from(o)])),n!==null&&(o=o.toString(n));let h=this.setFileSource(p,o);h!==A&&this.registerEntry(p,h),u!==null&&await this.chmodPromise(p,u)}writeFileSync(r,o,a){let{encoding:n,mode:u,index:A,resolvedP:p}=this.prepareWriteFile(r,a);A!==void 0&&typeof a=="object"&&a.flag&&a.flag.includes("a")&&(o=Buffer.concat([this.getFileSource(A),Buffer.from(o)])),n!==null&&(o=o.toString(n));let h=this.setFileSource(p,o);h!==A&&this.registerEntry(p,h),u!==null&&this.chmodSync(p,u)}prepareWriteFile(r,o){if(typeof r=="number"&&(r=this.fdToPath(r,"read")),this.readOnly)throw ar.EROFS(`open '${r}'`);let a=this.resolveFilename(`open '${r}'`,r);if(this.listings.has(a))throw ar.EISDIR(`open '${r}'`);let n=null,u=null;typeof o=="string"?n=o:typeof o=="object"&&({encoding:n=null,mode:u=null}=o);let A=this.entries.get(a);return{encoding:n,mode:u,resolvedP:a,index:A}}async unlinkPromise(r){return this.unlinkSync(r)}unlinkSync(r){if(this.readOnly)throw ar.EROFS(`unlink '${r}'`);let o=this.resolveFilename(`unlink '${r}'`,r);if(this.listings.has(o))throw ar.EISDIR(`unlink '${r}'`);let a=this.entries.get(o);if(typeof a>"u")throw ar.EINVAL(`unlink '${r}'`);this.deleteEntry(o,a)}async utimesPromise(r,o,a){return this.utimesSync(r,o,a)}utimesSync(r,o,a){if(this.readOnly)throw ar.EROFS(`utimes '${r}'`);let n=this.resolveFilename(`utimes '${r}'`,r);this.utimesImpl(n,a)}async lutimesPromise(r,o,a){return this.lutimesSync(r,o,a)}lutimesSync(r,o,a){if(this.readOnly)throw ar.EROFS(`lutimes '${r}'`);let n=this.resolveFilename(`utimes '${r}'`,r,!1);this.utimesImpl(n,a)}utimesImpl(r,o){this.listings.has(r)&&(this.entries.has(r)||this.hydrateDirectory(r));let a=this.entries.get(r);if(a===void 0)throw new Error("Unreachable");if(this.libzip.file.setMtime(this.zip,a,0,cot(o),0)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip))}async mkdirPromise(r,o){return this.mkdirSync(r,o)}mkdirSync(r,{mode:o=493,recursive:a=!1}={}){if(a)return this.mkdirpSync(r,{chmod:o});if(this.readOnly)throw ar.EROFS(`mkdir '${r}'`);let n=this.resolveFilename(`mkdir '${r}'`,r);if(this.entries.has(n)||this.listings.has(n))throw ar.EEXIST(`mkdir '${r}'`);this.hydrateDirectory(n),this.chmodSync(n,o)}async rmdirPromise(r,o){return this.rmdirSync(r,o)}rmdirSync(r,{recursive:o=!1}={}){if(this.readOnly)throw ar.EROFS(`rmdir '${r}'`);if(o){this.removeSync(r);return}let a=this.resolveFilename(`rmdir '${r}'`,r),n=this.listings.get(a);if(!n)throw ar.ENOTDIR(`rmdir '${r}'`);if(n.size>0)throw ar.ENOTEMPTY(`rmdir '${r}'`);let u=this.entries.get(a);if(typeof u>"u")throw ar.EINVAL(`rmdir '${r}'`);this.deleteEntry(r,u)}hydrateDirectory(r){let o=this.libzip.dir.add(this.zip,V.relative(Bt.root,r));if(o===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));return this.registerListing(r),this.registerEntry(r,o),o}async linkPromise(r,o){return this.linkSync(r,o)}linkSync(r,o){throw ar.EOPNOTSUPP(`link '${r}' -> '${o}'`)}async symlinkPromise(r,o){return this.symlinkSync(r,o)}symlinkSync(r,o){if(this.readOnly)throw ar.EROFS(`symlink '${r}' -> '${o}'`);let a=this.resolveFilename(`symlink '${r}' -> '${o}'`,o);if(this.listings.has(a))throw ar.EISDIR(`symlink '${r}' -> '${o}'`);if(this.entries.has(a))throw ar.EEXIST(`symlink '${r}' -> '${o}'`);let n=this.setFileSource(a,r);if(this.registerEntry(a,n),this.libzip.file.setExternalAttributes(this.zip,n,0,0,this.libzip.ZIP_OPSYS_UNIX,(ta.constants.S_IFLNK|511)<<16)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));this.symlinkCount+=1}async readFilePromise(r,o){typeof o=="object"&&(o=o?o.encoding:void 0);let a=await this.readFileBuffer(r,{asyncDecompress:!0});return o?a.toString(o):a}readFileSync(r,o){typeof o=="object"&&(o=o?o.encoding:void 0);let a=this.readFileBuffer(r);return o?a.toString(o):a}readFileBuffer(r,o={asyncDecompress:!1}){typeof r=="number"&&(r=this.fdToPath(r,"read"));let a=this.resolveFilename(`open '${r}'`,r);if(!this.entries.has(a)&&!this.listings.has(a))throw ar.ENOENT(`open '${r}'`);if(r[r.length-1]==="/"&&!this.listings.has(a))throw ar.ENOTDIR(`open '${r}'`);if(this.listings.has(a))throw ar.EISDIR("read");let n=this.entries.get(a);if(n===void 0)throw new Error("Unreachable");return this.getFileSource(n,o)}async readdirPromise(r,o){return this.readdirSync(r,o)}readdirSync(r,o){let a=this.resolveFilename(`scandir '${r}'`,r);if(!this.entries.has(a)&&!this.listings.has(a))throw ar.ENOENT(`scandir '${r}'`);let n=this.listings.get(a);if(!n)throw ar.ENOTDIR(`scandir '${r}'`);if(o?.recursive)if(o?.withFileTypes){let u=Array.from(n,A=>Object.assign(this.statImpl("lstat",V.join(r,A)),{name:A,path:Bt.dot}));for(let A of u){if(!A.isDirectory())continue;let p=V.join(A.path,A.name),h=this.listings.get(V.join(a,p));for(let C of h)u.push(Object.assign(this.statImpl("lstat",V.join(r,p,C)),{name:C,path:p}))}return u}else{let u=[...n];for(let A of u){let p=this.listings.get(V.join(a,A));if(!(typeof p>"u"))for(let h of p)u.push(V.join(A,h))}return u}else return o?.withFileTypes?Array.from(n,u=>Object.assign(this.statImpl("lstat",V.join(r,u)),{name:u,path:void 0})):[...n]}async readlinkPromise(r){let o=this.prepareReadlink(r);return(await this.getFileSource(o,{asyncDecompress:!0})).toString()}readlinkSync(r){let o=this.prepareReadlink(r);return this.getFileSource(o).toString()}prepareReadlink(r){let o=this.resolveFilename(`readlink '${r}'`,r,!1);if(!this.entries.has(o)&&!this.listings.has(o))throw ar.ENOENT(`readlink '${r}'`);if(r[r.length-1]==="/"&&!this.listings.has(o))throw ar.ENOTDIR(`open '${r}'`);if(this.listings.has(o))throw ar.EINVAL(`readlink '${r}'`);let a=this.entries.get(o);if(a===void 0)throw new Error("Unreachable");if(!this.isSymbolicLink(a))throw ar.EINVAL(`readlink '${r}'`);return a}async truncatePromise(r,o=0){let a=this.resolveFilename(`open '${r}'`,r),n=this.entries.get(a);if(typeof n>"u")throw ar.EINVAL(`open '${r}'`);let u=await this.getFileSource(n,{asyncDecompress:!0}),A=Buffer.alloc(o,0);return u.copy(A),await this.writeFilePromise(r,A)}truncateSync(r,o=0){let a=this.resolveFilename(`open '${r}'`,r),n=this.entries.get(a);if(typeof n>"u")throw ar.EINVAL(`open '${r}'`);let u=this.getFileSource(n),A=Buffer.alloc(o,0);return u.copy(A),this.writeFileSync(r,A)}async ftruncatePromise(r,o){return this.truncatePromise(this.fdToPath(r,"ftruncate"),o)}ftruncateSync(r,o){return this.truncateSync(this.fdToPath(r,"ftruncateSync"),o)}watch(r,o,a){let n;switch(typeof o){case"function":case"string":case"undefined":n=!0;break;default:({persistent:n=!0}=o);break}if(!n)return{on:()=>{},close:()=>{}};let u=setInterval(()=>{},24*60*60*1e3);return{on:()=>{},close:()=>{clearInterval(u)}}}watchFile(r,o,a){let n=V.resolve(Bt.root,r);return ry(this,n,o,a)}unwatchFile(r,o){let a=V.resolve(Bt.root,r);return Ng(this,a,o)}}});function Hle(t,e,r=Buffer.alloc(0),o){let a=new Ji(r),n=I=>I===e||I.startsWith(`${e}/`)?I.slice(0,e.length):null,u=async(I,v)=>()=>a,A=(I,v)=>a,p={...t},h=new Tn(p),C=new Up({baseFs:h,getMountPoint:n,factoryPromise:u,factorySync:A,magicByte:21,maxAge:1/0,typeCheck:o?.typeCheck});return Ww(_le.default,new _p(C)),a}var _le,jle=yt(()=>{Pt();_le=$e(Be("fs"));sU()});var qle=yt(()=>{Ole();sU();jle()});var S1={};Vt(S1,{DEFAULT_COMPRESSION_LEVEL:()=>Ule,LibzipError:()=>Rx,ZipFS:()=>Ji,ZipOpenFS:()=>zl,getArchivePart:()=>rU,getLibzipPromise:()=>Aot,getLibzipSync:()=>uot,makeEmptyArchive:()=>Fx,mountMemoryDrive:()=>Hle});function uot(){return P1()}async function Aot(){return P1()}var Gle,nA=yt(()=>{$4();Gle=$e(Rle());Nle();qle();Fle(()=>{let t=(0,Gle.default)();return Lle(t)})});var FE,Yle=yt(()=>{Pt();qt();x1();FE=class extends nt{constructor(){super(...arguments);this.cwd=ge.String("--cwd",process.cwd(),{description:"The directory to run the command in"});this.commandName=ge.String();this.args=ge.Proxy()}async execute(){let r=this.args.length>0?`${this.commandName} ${this.args.join(" ")}`:this.commandName;return await RE(r,[],{cwd:fe.toPortablePath(this.cwd),stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr})}};FE.usage={description:"run a command using yarn's portable shell",details:` - This command will run a command using Yarn's portable shell. - - Make sure to escape glob patterns, redirections, and other features that might be expanded by your own shell. - - Note: To escape something from Yarn's shell, you might have to escape it twice, the first time from your own shell. - - Note: Don't use this command in Yarn scripts, as Yarn's shell is automatically used. - - For a list of features, visit: https://github.com/yarnpkg/berry/blob/master/packages/yarnpkg-shell/README.md. - `,examples:[["Run a simple command","$0 echo Hello"],["Run a command with a glob pattern","$0 echo '*.js'"],["Run a command with a redirection","$0 echo Hello World '>' hello.txt"],["Run a command with an escaped glob pattern (The double escape is needed in Unix shells)",`$0 echo '"*.js"'`],["Run a command with a variable (Double quotes are needed in Unix shells, to prevent them from expanding the variable)",'$0 "GREETING=Hello echo $GREETING World"']]}});var al,Wle=yt(()=>{al=class extends Error{constructor(e){super(e),this.name="ShellError"}}});var Nx={};Vt(Nx,{fastGlobOptions:()=>zle,isBraceExpansion:()=>oU,isGlobPattern:()=>fot,match:()=>pot,micromatchOptions:()=>Lx});function fot(t){if(!Tx.default.scan(t,Lx).isGlob)return!1;try{Tx.default.parse(t,Lx)}catch{return!1}return!0}function pot(t,{cwd:e,baseFs:r}){return(0,Kle.default)(t,{...zle,cwd:fe.fromPortablePath(e),fs:RD(Vle.default,new _p(r))})}function oU(t){return Tx.default.scan(t,Lx).isBrace}var Kle,Vle,Tx,Lx,zle,Jle=yt(()=>{Pt();Kle=$e(RS()),Vle=$e(Be("fs")),Tx=$e(Zo()),Lx={strictBrackets:!0},zle={onlyDirectories:!1,onlyFiles:!1}});function aU(){}function lU(){for(let t of bd)t.kill()}function ece(t,e,r,o){return a=>{let n=a[0]instanceof iA.Transform?"pipe":a[0],u=a[1]instanceof iA.Transform?"pipe":a[1],A=a[2]instanceof iA.Transform?"pipe":a[2],p=(0,Zle.default)(t,e,{...o,stdio:[n,u,A]});return bd.add(p),bd.size===1&&(process.on("SIGINT",aU),process.on("SIGTERM",lU)),a[0]instanceof iA.Transform&&a[0].pipe(p.stdin),a[1]instanceof iA.Transform&&p.stdout.pipe(a[1],{end:!1}),a[2]instanceof iA.Transform&&p.stderr.pipe(a[2],{end:!1}),{stdin:p.stdin,promise:new Promise(h=>{p.on("error",C=>{switch(bd.delete(p),bd.size===0&&(process.off("SIGINT",aU),process.off("SIGTERM",lU)),C.code){case"ENOENT":a[2].write(`command not found: ${t} -`),h(127);break;case"EACCES":a[2].write(`permission denied: ${t} -`),h(128);break;default:a[2].write(`uncaught error: ${C.message} -`),h(1);break}}),p.on("close",C=>{bd.delete(p),bd.size===0&&(process.off("SIGINT",aU),process.off("SIGTERM",lU)),h(C!==null?C:129)})})}}}function tce(t){return e=>{let r=e[0]==="pipe"?new iA.PassThrough:e[0];return{stdin:r,promise:Promise.resolve().then(()=>t({stdin:r,stdout:e[1],stderr:e[2]}))}}}function Ox(t,e){return TE.start(t,e)}function Xle(t,e=null){let r=new iA.PassThrough,o=new $le.StringDecoder,a="";return r.on("data",n=>{let u=o.write(n),A;do if(A=u.indexOf(` -`),A!==-1){let p=a+u.substring(0,A);u=u.substring(A+1),a="",t(e!==null?`${e} ${p}`:p)}while(A!==-1);a+=u}),r.on("end",()=>{let n=o.end();n!==""&&t(e!==null?`${e} ${n}`:n)}),r}function rce(t,{prefix:e}){return{stdout:Xle(r=>t.stdout.write(`${r} -`),t.stdout.isTTY?e:null),stderr:Xle(r=>t.stderr.write(`${r} -`),t.stderr.isTTY?e:null)}}var Zle,iA,$le,bd,Jl,cU,TE,uU=yt(()=>{Zle=$e(sT()),iA=Be("stream"),$le=Be("string_decoder"),bd=new Set;Jl=class{constructor(e){this.stream=e}close(){}get(){return this.stream}},cU=class{constructor(){this.stream=null}close(){if(this.stream===null)throw new Error("Assertion failed: No stream attached");this.stream.end()}attach(e){this.stream=e}get(){if(this.stream===null)throw new Error("Assertion failed: No stream attached");return this.stream}},TE=class{constructor(e,r){this.stdin=null;this.stdout=null;this.stderr=null;this.pipe=null;this.ancestor=e,this.implementation=r}static start(e,{stdin:r,stdout:o,stderr:a}){let n=new TE(null,e);return n.stdin=r,n.stdout=o,n.stderr=a,n}pipeTo(e,r=1){let o=new TE(this,e),a=new cU;return o.pipe=a,o.stdout=this.stdout,o.stderr=this.stderr,(r&1)===1?this.stdout=a:this.ancestor!==null&&(this.stderr=this.ancestor.stdout),(r&2)===2?this.stderr=a:this.ancestor!==null&&(this.stderr=this.ancestor.stderr),o}async exec(){let e=["ignore","ignore","ignore"];if(this.pipe)e[0]="pipe";else{if(this.stdin===null)throw new Error("Assertion failed: No input stream registered");e[0]=this.stdin.get()}let r;if(this.stdout===null)throw new Error("Assertion failed: No output stream registered");r=this.stdout,e[1]=r.get();let o;if(this.stderr===null)throw new Error("Assertion failed: No error stream registered");o=this.stderr,e[2]=o.get();let a=this.implementation(e);return this.pipe&&this.pipe.attach(a.stdin),await a.promise.then(n=>(r.close(),o.close(),n))}async run(){let e=[];for(let o=this;o;o=o.ancestor)e.push(o.exec());return(await Promise.all(e))[0]}}});var F1={};Vt(F1,{EntryCommand:()=>FE,ShellError:()=>al,execute:()=>RE,globUtils:()=>Nx});function nce(t,e,r){let o=new ll.PassThrough({autoDestroy:!0});switch(t){case 0:(e&1)===1&&r.stdin.pipe(o,{end:!1}),(e&2)===2&&r.stdin instanceof ll.Writable&&o.pipe(r.stdin,{end:!1});break;case 1:(e&1)===1&&r.stdout.pipe(o,{end:!1}),(e&2)===2&&o.pipe(r.stdout,{end:!1});break;case 2:(e&1)===1&&r.stderr.pipe(o,{end:!1}),(e&2)===2&&o.pipe(r.stderr,{end:!1});break;default:throw new al(`Bad file descriptor: "${t}"`)}return o}function Ux(t,e={}){let r={...t,...e};return r.environment={...t.environment,...e.environment},r.variables={...t.variables,...e.variables},r}async function got(t,e,r){let o=[],a=new ll.PassThrough;return a.on("data",n=>o.push(n)),await _x(t,e,Ux(r,{stdout:a})),Buffer.concat(o).toString().replace(/[\r\n]+$/,"")}async function ice(t,e,r){let o=t.map(async n=>{let u=await kd(n.args,e,r);return{name:n.name,value:u.join(" ")}});return(await Promise.all(o)).reduce((n,u)=>(n[u.name]=u.value,n),{})}function Mx(t){return t.match(/[^ \r\n\t]+/g)||[]}async function uce(t,e,r,o,a=o){switch(t.name){case"$":o(String(process.pid));break;case"#":o(String(e.args.length));break;case"@":if(t.quoted)for(let n of e.args)a(n);else for(let n of e.args){let u=Mx(n);for(let A=0;A<u.length-1;++A)a(u[A]);o(u[u.length-1])}break;case"*":{let n=e.args.join(" ");if(t.quoted)o(n);else for(let u of Mx(n))a(u)}break;case"PPID":o(String(process.ppid));break;case"RANDOM":o(String(Math.floor(Math.random()*32768)));break;default:{let n=parseInt(t.name,10),u,A=Number.isFinite(n);if(A?n>=0&&n<e.args.length&&(u=e.args[n]):Object.hasOwn(r.variables,t.name)?u=r.variables[t.name]:Object.hasOwn(r.environment,t.name)&&(u=r.environment[t.name]),typeof u<"u"&&t.alternativeValue?u=(await kd(t.alternativeValue,e,r)).join(" "):typeof u>"u"&&(t.defaultValue?u=(await kd(t.defaultValue,e,r)).join(" "):t.alternativeValue&&(u="")),typeof u>"u")throw A?new al(`Unbound argument #${n}`):new al(`Unbound variable "${t.name}"`);if(t.quoted)o(u);else{let p=Mx(u);for(let C=0;C<p.length-1;++C)a(p[C]);let h=p[p.length-1];typeof h<"u"&&o(h)}}break}}async function b1(t,e,r){if(t.type==="number"){if(Number.isInteger(t.value))return t.value;throw new Error(`Invalid number: "${t.value}", only integers are allowed`)}else if(t.type==="variable"){let o=[];await uce({...t,quoted:!0},e,r,n=>o.push(n));let a=Number(o.join(" "));return Number.isNaN(a)?b1({type:"variable",name:o.join(" ")},e,r):b1({type:"number",value:a},e,r)}else return dot[t.type](await b1(t.left,e,r),await b1(t.right,e,r))}async function kd(t,e,r){let o=new Map,a=[],n=[],u=C=>{n.push(C)},A=()=>{n.length>0&&a.push(n.join("")),n=[]},p=C=>{u(C),A()},h=(C,I,v)=>{let b=JSON.stringify({type:C,fd:I}),E=o.get(b);typeof E>"u"&&o.set(b,E=[]),E.push(v)};for(let C of t){let I=!1;switch(C.type){case"redirection":{let v=await kd(C.args,e,r);for(let b of v)h(C.subtype,C.fd,b)}break;case"argument":for(let v of C.segments)switch(v.type){case"text":u(v.text);break;case"glob":u(v.pattern),I=!0;break;case"shell":{let b=await got(v.shell,e,r);if(v.quoted)u(b);else{let E=Mx(b);for(let F=0;F<E.length-1;++F)p(E[F]);u(E[E.length-1])}}break;case"variable":await uce(v,e,r,u,p);break;case"arithmetic":u(String(await b1(v.arithmetic,e,r)));break}break}if(A(),I){let v=a.pop();if(typeof v>"u")throw new Error("Assertion failed: Expected a glob pattern to have been set");let b=await e.glob.match(v,{cwd:r.cwd,baseFs:e.baseFs});if(b.length===0){let E=oU(v)?". Note: Brace expansion of arbitrary strings isn't currently supported. For more details, please read this issue: https://github.com/yarnpkg/berry/issues/22":"";throw new al(`No matches found: "${v}"${E}`)}for(let E of b.sort())p(E)}}if(o.size>0){let C=[];for(let[I,v]of o.entries())C.splice(C.length,0,I,String(v.length),...v);a.splice(0,0,"__ysh_set_redirects",...C,"--")}return a}function k1(t,e,r){e.builtins.has(t[0])||(t=["command",...t]);let o=fe.fromPortablePath(r.cwd),a=r.environment;typeof a.PWD<"u"&&(a={...a,PWD:o});let[n,...u]=t;if(n==="command")return ece(u[0],u.slice(1),e,{cwd:o,env:a});let A=e.builtins.get(n);if(typeof A>"u")throw new Error(`Assertion failed: A builtin should exist for "${n}"`);return tce(async({stdin:p,stdout:h,stderr:C})=>{let{stdin:I,stdout:v,stderr:b}=r;r.stdin=p,r.stdout=h,r.stderr=C;try{return await A(u,e,r)}finally{r.stdin=I,r.stdout=v,r.stderr=b}})}function mot(t,e,r){return o=>{let a=new ll.PassThrough,n=_x(t,e,Ux(r,{stdin:a}));return{stdin:a,promise:n}}}function yot(t,e,r){return o=>{let a=new ll.PassThrough,n=_x(t,e,r);return{stdin:a,promise:n}}}function sce(t,e,r,o){if(e.length===0)return t;{let a;do a=String(Math.random());while(Object.hasOwn(o.procedures,a));return o.procedures={...o.procedures},o.procedures[a]=t,k1([...e,"__ysh_run_procedure",a],r,o)}}async function oce(t,e,r){let o=t,a=null,n=null;for(;o;){let u=o.then?{...r}:r,A;switch(o.type){case"command":{let p=await kd(o.args,e,r),h=await ice(o.envs,e,r);A=o.envs.length?k1(p,e,Ux(u,{environment:h})):k1(p,e,u)}break;case"subshell":{let p=await kd(o.args,e,r),h=mot(o.subshell,e,u);A=sce(h,p,e,u)}break;case"group":{let p=await kd(o.args,e,r),h=yot(o.group,e,u);A=sce(h,p,e,u)}break;case"envs":{let p=await ice(o.envs,e,r);u.environment={...u.environment,...p},A=k1(["true"],e,u)}break}if(typeof A>"u")throw new Error("Assertion failed: An action should have been generated");if(a===null)n=Ox(A,{stdin:new Jl(u.stdin),stdout:new Jl(u.stdout),stderr:new Jl(u.stderr)});else{if(n===null)throw new Error("Assertion failed: The execution pipeline should have been setup");switch(a){case"|":n=n.pipeTo(A,1);break;case"|&":n=n.pipeTo(A,3);break}}o.then?(a=o.then.type,o=o.then.chain):o=null}if(n===null)throw new Error("Assertion failed: The execution pipeline should have been setup");return await n.run()}async function Eot(t,e,r,{background:o=!1}={}){function a(n){let u=["#2E86AB","#A23B72","#F18F01","#C73E1D","#CCE2A3"],A=u[n%u.length];return ace.default.hex(A)}if(o){let n=r.nextBackgroundJobIndex++,u=a(n),A=`[${n}]`,p=u(A),{stdout:h,stderr:C}=rce(r,{prefix:p});return r.backgroundJobs.push(oce(t,e,Ux(r,{stdout:h,stderr:C})).catch(I=>C.write(`${I.message} -`)).finally(()=>{r.stdout.isTTY&&r.stdout.write(`Job ${p}, '${u(cy(t))}' has ended -`)})),0}return await oce(t,e,r)}async function Cot(t,e,r,{background:o=!1}={}){let a,n=A=>{a=A,r.variables["?"]=String(A)},u=async A=>{try{return await Eot(A.chain,e,r,{background:o&&typeof A.then>"u"})}catch(p){if(!(p instanceof al))throw p;return r.stderr.write(`${p.message} -`),1}};for(n(await u(t));t.then;){if(r.exitCode!==null)return r.exitCode;switch(t.then.type){case"&&":a===0&&n(await u(t.then.line));break;case"||":a!==0&&n(await u(t.then.line));break;default:throw new Error(`Assertion failed: Unsupported command type: "${t.then.type}"`)}t=t.then.line}return a}async function _x(t,e,r){let o=r.backgroundJobs;r.backgroundJobs=[];let a=0;for(let{command:n,type:u}of t){if(a=await Cot(n,e,r,{background:u==="&"}),r.exitCode!==null)return r.exitCode;r.variables["?"]=String(a)}return await Promise.all(r.backgroundJobs),r.backgroundJobs=o,a}function Ace(t){switch(t.type){case"variable":return t.name==="@"||t.name==="#"||t.name==="*"||Number.isFinite(parseInt(t.name,10))||"defaultValue"in t&&!!t.defaultValue&&t.defaultValue.some(e=>Q1(e))||"alternativeValue"in t&&!!t.alternativeValue&&t.alternativeValue.some(e=>Q1(e));case"arithmetic":return AU(t.arithmetic);case"shell":return fU(t.shell);default:return!1}}function Q1(t){switch(t.type){case"redirection":return t.args.some(e=>Q1(e));case"argument":return t.segments.some(e=>Ace(e));default:throw new Error(`Assertion failed: Unsupported argument type: "${t.type}"`)}}function AU(t){switch(t.type){case"variable":return Ace(t);case"number":return!1;default:return AU(t.left)||AU(t.right)}}function fU(t){return t.some(({command:e})=>{for(;e;){let r=e.chain;for(;r;){let o;switch(r.type){case"subshell":o=fU(r.subshell);break;case"command":o=r.envs.some(a=>a.args.some(n=>Q1(n)))||r.args.some(a=>Q1(a));break}if(o)return!0;if(!r.then)break;r=r.then.chain}if(!e.then)break;e=e.then.line}return!1})}async function RE(t,e=[],{baseFs:r=new Tn,builtins:o={},cwd:a=fe.toPortablePath(process.cwd()),env:n=process.env,stdin:u=process.stdin,stdout:A=process.stdout,stderr:p=process.stderr,variables:h={},glob:C=Nx}={}){let I={};for(let[E,F]of Object.entries(n))typeof F<"u"&&(I[E]=F);let v=new Map(hot);for(let[E,F]of Object.entries(o))v.set(E,F);u===null&&(u=new ll.PassThrough,u.end());let b=ND(t,C);if(!fU(b)&&b.length>0&&e.length>0){let{command:E}=b[b.length-1];for(;E.then;)E=E.then.line;let F=E.chain;for(;F.then;)F=F.then.chain;F.type==="command"&&(F.args=F.args.concat(e.map(N=>({type:"argument",segments:[{type:"text",text:N}]}))))}return await _x(b,{args:e,baseFs:r,builtins:v,initialStdin:u,initialStdout:A,initialStderr:p,glob:C},{cwd:a,environment:I,exitCode:null,procedures:{},stdin:u,stdout:A,stderr:p,variables:Object.assign({},h,{["?"]:0}),nextBackgroundJobIndex:1,backgroundJobs:[]})}var ace,lce,ll,cce,hot,dot,x1=yt(()=>{Pt();Ll();ace=$e(IL()),lce=Be("os"),ll=Be("stream"),cce=Be("timers/promises");Yle();Wle();Jle();uU();uU();hot=new Map([["cd",async([t=(0,lce.homedir)(),...e],r,o)=>{let a=V.resolve(o.cwd,fe.toPortablePath(t));if(!(await r.baseFs.statPromise(a).catch(u=>{throw u.code==="ENOENT"?new al(`cd: no such file or directory: ${t}`):u})).isDirectory())throw new al(`cd: not a directory: ${t}`);return o.cwd=a,0}],["pwd",async(t,e,r)=>(r.stdout.write(`${fe.fromPortablePath(r.cwd)} -`),0)],[":",async(t,e,r)=>0],["true",async(t,e,r)=>0],["false",async(t,e,r)=>1],["exit",async([t,...e],r,o)=>o.exitCode=parseInt(t??o.variables["?"],10)],["echo",async(t,e,r)=>(r.stdout.write(`${t.join(" ")} -`),0)],["sleep",async([t],e,r)=>{if(typeof t>"u")throw new al("sleep: missing operand");let o=Number(t);if(Number.isNaN(o))throw new al(`sleep: invalid time interval '${t}'`);return await(0,cce.setTimeout)(1e3*o,0)}],["__ysh_run_procedure",async(t,e,r)=>{let o=r.procedures[t[0]];return await Ox(o,{stdin:new Jl(r.stdin),stdout:new Jl(r.stdout),stderr:new Jl(r.stderr)}).run()}],["__ysh_set_redirects",async(t,e,r)=>{let o=r.stdin,a=r.stdout,n=r.stderr,u=[],A=[],p=[],h=0;for(;t[h]!=="--";){let I=t[h++],{type:v,fd:b}=JSON.parse(I),E=z=>{switch(b){case null:case 0:u.push(z);break;default:throw new Error(`Unsupported file descriptor: "${b}"`)}},F=z=>{switch(b){case null:case 1:A.push(z);break;case 2:p.push(z);break;default:throw new Error(`Unsupported file descriptor: "${b}"`)}},N=Number(t[h++]),U=h+N;for(let z=h;z<U;++h,++z)switch(v){case"<":E(()=>e.baseFs.createReadStream(V.resolve(r.cwd,fe.toPortablePath(t[z]))));break;case"<<<":E(()=>{let te=new ll.PassThrough;return process.nextTick(()=>{te.write(`${t[z]} -`),te.end()}),te});break;case"<&":E(()=>nce(Number(t[z]),1,r));break;case">":case">>":{let te=V.resolve(r.cwd,fe.toPortablePath(t[z]));F(te==="/dev/null"?new ll.Writable({autoDestroy:!0,emitClose:!0,write(le,pe,ue){setImmediate(ue)}}):e.baseFs.createWriteStream(te,v===">>"?{flags:"a"}:void 0))}break;case">&":F(nce(Number(t[z]),2,r));break;default:throw new Error(`Assertion failed: Unsupported redirection type: "${v}"`)}}if(u.length>0){let I=new ll.PassThrough;o=I;let v=b=>{if(b===u.length)I.end();else{let E=u[b]();E.pipe(I,{end:!1}),E.on("end",()=>{v(b+1)})}};v(0)}if(A.length>0){let I=new ll.PassThrough;a=I;for(let v of A)I.pipe(v)}if(p.length>0){let I=new ll.PassThrough;n=I;for(let v of p)I.pipe(v)}let C=await Ox(k1(t.slice(h+1),e,r),{stdin:new Jl(o),stdout:new Jl(a),stderr:new Jl(n)}).run();return await Promise.all(A.map(I=>new Promise((v,b)=>{I.on("error",E=>{b(E)}),I.on("close",()=>{v()}),I.end()}))),await Promise.all(p.map(I=>new Promise((v,b)=>{I.on("error",E=>{b(E)}),I.on("close",()=>{v()}),I.end()}))),C}]]);dot={addition:(t,e)=>t+e,subtraction:(t,e)=>t-e,multiplication:(t,e)=>t*e,division:(t,e)=>Math.trunc(t/e)}});var Hx=_((JMt,fce)=>{function wot(t,e){for(var r=-1,o=t==null?0:t.length,a=Array(o);++r<o;)a[r]=e(t[r],r,t);return a}fce.exports=wot});var yce=_((XMt,mce)=>{var pce=fd(),Iot=Hx(),Bot=Hl(),vot=fE(),Dot=1/0,hce=pce?pce.prototype:void 0,gce=hce?hce.toString:void 0;function dce(t){if(typeof t=="string")return t;if(Bot(t))return Iot(t,dce)+"";if(vot(t))return gce?gce.call(t):"";var e=t+"";return e=="0"&&1/t==-Dot?"-0":e}mce.exports=dce});var R1=_((ZMt,Ece)=>{var Pot=yce();function Sot(t){return t==null?"":Pot(t)}Ece.exports=Sot});var pU=_(($Mt,Cce)=>{function xot(t,e,r){var o=-1,a=t.length;e<0&&(e=-e>a?0:a+e),r=r>a?a:r,r<0&&(r+=a),a=e>r?0:r-e>>>0,e>>>=0;for(var n=Array(a);++o<a;)n[o]=t[o+e];return n}Cce.exports=xot});var Ice=_((e4t,wce)=>{var bot=pU();function kot(t,e,r){var o=t.length;return r=r===void 0?o:r,!e&&r>=o?t:bot(t,e,r)}wce.exports=kot});var hU=_((t4t,Bce)=>{var Qot="\\ud800-\\udfff",Fot="\\u0300-\\u036f",Rot="\\ufe20-\\ufe2f",Tot="\\u20d0-\\u20ff",Lot=Fot+Rot+Tot,Not="\\ufe0e\\ufe0f",Oot="\\u200d",Mot=RegExp("["+Oot+Qot+Lot+Not+"]");function Uot(t){return Mot.test(t)}Bce.exports=Uot});var Dce=_((r4t,vce)=>{function _ot(t){return t.split("")}vce.exports=_ot});var Rce=_((n4t,Fce)=>{var Pce="\\ud800-\\udfff",Hot="\\u0300-\\u036f",jot="\\ufe20-\\ufe2f",qot="\\u20d0-\\u20ff",Got=Hot+jot+qot,Yot="\\ufe0e\\ufe0f",Wot="["+Pce+"]",gU="["+Got+"]",dU="\\ud83c[\\udffb-\\udfff]",Kot="(?:"+gU+"|"+dU+")",Sce="[^"+Pce+"]",xce="(?:\\ud83c[\\udde6-\\uddff]){2}",bce="[\\ud800-\\udbff][\\udc00-\\udfff]",Vot="\\u200d",kce=Kot+"?",Qce="["+Yot+"]?",zot="(?:"+Vot+"(?:"+[Sce,xce,bce].join("|")+")"+Qce+kce+")*",Jot=Qce+kce+zot,Xot="(?:"+[Sce+gU+"?",gU,xce,bce,Wot].join("|")+")",Zot=RegExp(dU+"(?="+dU+")|"+Xot+Jot,"g");function $ot(t){return t.match(Zot)||[]}Fce.exports=$ot});var Lce=_((i4t,Tce)=>{var eat=Dce(),tat=hU(),rat=Rce();function nat(t){return tat(t)?rat(t):eat(t)}Tce.exports=nat});var Oce=_((s4t,Nce)=>{var iat=Ice(),sat=hU(),oat=Lce(),aat=R1();function lat(t){return function(e){e=aat(e);var r=sat(e)?oat(e):void 0,o=r?r[0]:e.charAt(0),a=r?iat(r,1).join(""):e.slice(1);return o[t]()+a}}Nce.exports=lat});var Uce=_((o4t,Mce)=>{var cat=Oce(),uat=cat("toUpperCase");Mce.exports=uat});var mU=_((a4t,_ce)=>{var Aat=R1(),fat=Uce();function pat(t){return fat(Aat(t).toLowerCase())}_ce.exports=pat});var Hce=_((l4t,jx)=>{function hat(){var t=0,e=1,r=2,o=3,a=4,n=5,u=6,A=7,p=8,h=9,C=10,I=11,v=12,b=13,E=14,F=15,N=16,U=17,z=0,te=1,le=2,pe=3,ue=4;function ye(g,Ee){return 55296<=g.charCodeAt(Ee)&&g.charCodeAt(Ee)<=56319&&56320<=g.charCodeAt(Ee+1)&&g.charCodeAt(Ee+1)<=57343}function ae(g,Ee){Ee===void 0&&(Ee=0);var De=g.charCodeAt(Ee);if(55296<=De&&De<=56319&&Ee<g.length-1){var ce=De,ne=g.charCodeAt(Ee+1);return 56320<=ne&&ne<=57343?(ce-55296)*1024+(ne-56320)+65536:ce}if(56320<=De&&De<=57343&&Ee>=1){var ce=g.charCodeAt(Ee-1),ne=De;return 55296<=ce&&ce<=56319?(ce-55296)*1024+(ne-56320)+65536:ne}return De}function Ie(g,Ee,De){var ce=[g].concat(Ee).concat([De]),ne=ce[ce.length-2],ee=De,we=ce.lastIndexOf(E);if(we>1&&ce.slice(1,we).every(function(H){return H==o})&&[o,b,U].indexOf(g)==-1)return le;var be=ce.lastIndexOf(a);if(be>0&&ce.slice(1,be).every(function(H){return H==a})&&[v,a].indexOf(ne)==-1)return ce.filter(function(H){return H==a}).length%2==1?pe:ue;if(ne==t&&ee==e)return z;if(ne==r||ne==t||ne==e)return ee==E&&Ee.every(function(H){return H==o})?le:te;if(ee==r||ee==t||ee==e)return te;if(ne==u&&(ee==u||ee==A||ee==h||ee==C))return z;if((ne==h||ne==A)&&(ee==A||ee==p))return z;if((ne==C||ne==p)&&ee==p)return z;if(ee==o||ee==F)return z;if(ee==n)return z;if(ne==v)return z;var ht=ce.indexOf(o)!=-1?ce.lastIndexOf(o)-1:ce.length-2;return[b,U].indexOf(ce[ht])!=-1&&ce.slice(ht+1,-1).every(function(H){return H==o})&&ee==E||ne==F&&[N,U].indexOf(ee)!=-1?z:Ee.indexOf(a)!=-1?le:ne==a&&ee==a?z:te}this.nextBreak=function(g,Ee){if(Ee===void 0&&(Ee=0),Ee<0)return 0;if(Ee>=g.length-1)return g.length;for(var De=Fe(ae(g,Ee)),ce=[],ne=Ee+1;ne<g.length;ne++)if(!ye(g,ne-1)){var ee=Fe(ae(g,ne));if(Ie(De,ce,ee))return ne;ce.push(ee)}return g.length},this.splitGraphemes=function(g){for(var Ee=[],De=0,ce;(ce=this.nextBreak(g,De))<g.length;)Ee.push(g.slice(De,ce)),De=ce;return De<g.length&&Ee.push(g.slice(De)),Ee},this.iterateGraphemes=function(g){var Ee=0,De={next:function(){var ce,ne;return(ne=this.nextBreak(g,Ee))<g.length?(ce=g.slice(Ee,ne),Ee=ne,{value:ce,done:!1}):Ee<g.length?(ce=g.slice(Ee),Ee=g.length,{value:ce,done:!1}):{value:void 0,done:!0}}.bind(this)};return typeof Symbol<"u"&&Symbol.iterator&&(De[Symbol.iterator]=function(){return De}),De},this.countGraphemes=function(g){for(var Ee=0,De=0,ce;(ce=this.nextBreak(g,De))<g.length;)De=ce,Ee++;return De<g.length&&Ee++,Ee};function Fe(g){return 1536<=g&&g<=1541||g==1757||g==1807||g==2274||g==3406||g==69821||70082<=g&&g<=70083||g==72250||72326<=g&&g<=72329||g==73030?v:g==13?t:g==10?e:0<=g&&g<=9||11<=g&&g<=12||14<=g&&g<=31||127<=g&&g<=159||g==173||g==1564||g==6158||g==8203||8206<=g&&g<=8207||g==8232||g==8233||8234<=g&&g<=8238||8288<=g&&g<=8292||g==8293||8294<=g&&g<=8303||55296<=g&&g<=57343||g==65279||65520<=g&&g<=65528||65529<=g&&g<=65531||113824<=g&&g<=113827||119155<=g&&g<=119162||g==917504||g==917505||917506<=g&&g<=917535||917632<=g&&g<=917759||918e3<=g&&g<=921599?r:768<=g&&g<=879||1155<=g&&g<=1159||1160<=g&&g<=1161||1425<=g&&g<=1469||g==1471||1473<=g&&g<=1474||1476<=g&&g<=1477||g==1479||1552<=g&&g<=1562||1611<=g&&g<=1631||g==1648||1750<=g&&g<=1756||1759<=g&&g<=1764||1767<=g&&g<=1768||1770<=g&&g<=1773||g==1809||1840<=g&&g<=1866||1958<=g&&g<=1968||2027<=g&&g<=2035||2070<=g&&g<=2073||2075<=g&&g<=2083||2085<=g&&g<=2087||2089<=g&&g<=2093||2137<=g&&g<=2139||2260<=g&&g<=2273||2275<=g&&g<=2306||g==2362||g==2364||2369<=g&&g<=2376||g==2381||2385<=g&&g<=2391||2402<=g&&g<=2403||g==2433||g==2492||g==2494||2497<=g&&g<=2500||g==2509||g==2519||2530<=g&&g<=2531||2561<=g&&g<=2562||g==2620||2625<=g&&g<=2626||2631<=g&&g<=2632||2635<=g&&g<=2637||g==2641||2672<=g&&g<=2673||g==2677||2689<=g&&g<=2690||g==2748||2753<=g&&g<=2757||2759<=g&&g<=2760||g==2765||2786<=g&&g<=2787||2810<=g&&g<=2815||g==2817||g==2876||g==2878||g==2879||2881<=g&&g<=2884||g==2893||g==2902||g==2903||2914<=g&&g<=2915||g==2946||g==3006||g==3008||g==3021||g==3031||g==3072||3134<=g&&g<=3136||3142<=g&&g<=3144||3146<=g&&g<=3149||3157<=g&&g<=3158||3170<=g&&g<=3171||g==3201||g==3260||g==3263||g==3266||g==3270||3276<=g&&g<=3277||3285<=g&&g<=3286||3298<=g&&g<=3299||3328<=g&&g<=3329||3387<=g&&g<=3388||g==3390||3393<=g&&g<=3396||g==3405||g==3415||3426<=g&&g<=3427||g==3530||g==3535||3538<=g&&g<=3540||g==3542||g==3551||g==3633||3636<=g&&g<=3642||3655<=g&&g<=3662||g==3761||3764<=g&&g<=3769||3771<=g&&g<=3772||3784<=g&&g<=3789||3864<=g&&g<=3865||g==3893||g==3895||g==3897||3953<=g&&g<=3966||3968<=g&&g<=3972||3974<=g&&g<=3975||3981<=g&&g<=3991||3993<=g&&g<=4028||g==4038||4141<=g&&g<=4144||4146<=g&&g<=4151||4153<=g&&g<=4154||4157<=g&&g<=4158||4184<=g&&g<=4185||4190<=g&&g<=4192||4209<=g&&g<=4212||g==4226||4229<=g&&g<=4230||g==4237||g==4253||4957<=g&&g<=4959||5906<=g&&g<=5908||5938<=g&&g<=5940||5970<=g&&g<=5971||6002<=g&&g<=6003||6068<=g&&g<=6069||6071<=g&&g<=6077||g==6086||6089<=g&&g<=6099||g==6109||6155<=g&&g<=6157||6277<=g&&g<=6278||g==6313||6432<=g&&g<=6434||6439<=g&&g<=6440||g==6450||6457<=g&&g<=6459||6679<=g&&g<=6680||g==6683||g==6742||6744<=g&&g<=6750||g==6752||g==6754||6757<=g&&g<=6764||6771<=g&&g<=6780||g==6783||6832<=g&&g<=6845||g==6846||6912<=g&&g<=6915||g==6964||6966<=g&&g<=6970||g==6972||g==6978||7019<=g&&g<=7027||7040<=g&&g<=7041||7074<=g&&g<=7077||7080<=g&&g<=7081||7083<=g&&g<=7085||g==7142||7144<=g&&g<=7145||g==7149||7151<=g&&g<=7153||7212<=g&&g<=7219||7222<=g&&g<=7223||7376<=g&&g<=7378||7380<=g&&g<=7392||7394<=g&&g<=7400||g==7405||g==7412||7416<=g&&g<=7417||7616<=g&&g<=7673||7675<=g&&g<=7679||g==8204||8400<=g&&g<=8412||8413<=g&&g<=8416||g==8417||8418<=g&&g<=8420||8421<=g&&g<=8432||11503<=g&&g<=11505||g==11647||11744<=g&&g<=11775||12330<=g&&g<=12333||12334<=g&&g<=12335||12441<=g&&g<=12442||g==42607||42608<=g&&g<=42610||42612<=g&&g<=42621||42654<=g&&g<=42655||42736<=g&&g<=42737||g==43010||g==43014||g==43019||43045<=g&&g<=43046||43204<=g&&g<=43205||43232<=g&&g<=43249||43302<=g&&g<=43309||43335<=g&&g<=43345||43392<=g&&g<=43394||g==43443||43446<=g&&g<=43449||g==43452||g==43493||43561<=g&&g<=43566||43569<=g&&g<=43570||43573<=g&&g<=43574||g==43587||g==43596||g==43644||g==43696||43698<=g&&g<=43700||43703<=g&&g<=43704||43710<=g&&g<=43711||g==43713||43756<=g&&g<=43757||g==43766||g==44005||g==44008||g==44013||g==64286||65024<=g&&g<=65039||65056<=g&&g<=65071||65438<=g&&g<=65439||g==66045||g==66272||66422<=g&&g<=66426||68097<=g&&g<=68099||68101<=g&&g<=68102||68108<=g&&g<=68111||68152<=g&&g<=68154||g==68159||68325<=g&&g<=68326||g==69633||69688<=g&&g<=69702||69759<=g&&g<=69761||69811<=g&&g<=69814||69817<=g&&g<=69818||69888<=g&&g<=69890||69927<=g&&g<=69931||69933<=g&&g<=69940||g==70003||70016<=g&&g<=70017||70070<=g&&g<=70078||70090<=g&&g<=70092||70191<=g&&g<=70193||g==70196||70198<=g&&g<=70199||g==70206||g==70367||70371<=g&&g<=70378||70400<=g&&g<=70401||g==70460||g==70462||g==70464||g==70487||70502<=g&&g<=70508||70512<=g&&g<=70516||70712<=g&&g<=70719||70722<=g&&g<=70724||g==70726||g==70832||70835<=g&&g<=70840||g==70842||g==70845||70847<=g&&g<=70848||70850<=g&&g<=70851||g==71087||71090<=g&&g<=71093||71100<=g&&g<=71101||71103<=g&&g<=71104||71132<=g&&g<=71133||71219<=g&&g<=71226||g==71229||71231<=g&&g<=71232||g==71339||g==71341||71344<=g&&g<=71349||g==71351||71453<=g&&g<=71455||71458<=g&&g<=71461||71463<=g&&g<=71467||72193<=g&&g<=72198||72201<=g&&g<=72202||72243<=g&&g<=72248||72251<=g&&g<=72254||g==72263||72273<=g&&g<=72278||72281<=g&&g<=72283||72330<=g&&g<=72342||72344<=g&&g<=72345||72752<=g&&g<=72758||72760<=g&&g<=72765||g==72767||72850<=g&&g<=72871||72874<=g&&g<=72880||72882<=g&&g<=72883||72885<=g&&g<=72886||73009<=g&&g<=73014||g==73018||73020<=g&&g<=73021||73023<=g&&g<=73029||g==73031||92912<=g&&g<=92916||92976<=g&&g<=92982||94095<=g&&g<=94098||113821<=g&&g<=113822||g==119141||119143<=g&&g<=119145||119150<=g&&g<=119154||119163<=g&&g<=119170||119173<=g&&g<=119179||119210<=g&&g<=119213||119362<=g&&g<=119364||121344<=g&&g<=121398||121403<=g&&g<=121452||g==121461||g==121476||121499<=g&&g<=121503||121505<=g&&g<=121519||122880<=g&&g<=122886||122888<=g&&g<=122904||122907<=g&&g<=122913||122915<=g&&g<=122916||122918<=g&&g<=122922||125136<=g&&g<=125142||125252<=g&&g<=125258||917536<=g&&g<=917631||917760<=g&&g<=917999?o:127462<=g&&g<=127487?a:g==2307||g==2363||2366<=g&&g<=2368||2377<=g&&g<=2380||2382<=g&&g<=2383||2434<=g&&g<=2435||2495<=g&&g<=2496||2503<=g&&g<=2504||2507<=g&&g<=2508||g==2563||2622<=g&&g<=2624||g==2691||2750<=g&&g<=2752||g==2761||2763<=g&&g<=2764||2818<=g&&g<=2819||g==2880||2887<=g&&g<=2888||2891<=g&&g<=2892||g==3007||3009<=g&&g<=3010||3014<=g&&g<=3016||3018<=g&&g<=3020||3073<=g&&g<=3075||3137<=g&&g<=3140||3202<=g&&g<=3203||g==3262||3264<=g&&g<=3265||3267<=g&&g<=3268||3271<=g&&g<=3272||3274<=g&&g<=3275||3330<=g&&g<=3331||3391<=g&&g<=3392||3398<=g&&g<=3400||3402<=g&&g<=3404||3458<=g&&g<=3459||3536<=g&&g<=3537||3544<=g&&g<=3550||3570<=g&&g<=3571||g==3635||g==3763||3902<=g&&g<=3903||g==3967||g==4145||4155<=g&&g<=4156||4182<=g&&g<=4183||g==4228||g==6070||6078<=g&&g<=6085||6087<=g&&g<=6088||6435<=g&&g<=6438||6441<=g&&g<=6443||6448<=g&&g<=6449||6451<=g&&g<=6456||6681<=g&&g<=6682||g==6741||g==6743||6765<=g&&g<=6770||g==6916||g==6965||g==6971||6973<=g&&g<=6977||6979<=g&&g<=6980||g==7042||g==7073||7078<=g&&g<=7079||g==7082||g==7143||7146<=g&&g<=7148||g==7150||7154<=g&&g<=7155||7204<=g&&g<=7211||7220<=g&&g<=7221||g==7393||7410<=g&&g<=7411||g==7415||43043<=g&&g<=43044||g==43047||43136<=g&&g<=43137||43188<=g&&g<=43203||43346<=g&&g<=43347||g==43395||43444<=g&&g<=43445||43450<=g&&g<=43451||43453<=g&&g<=43456||43567<=g&&g<=43568||43571<=g&&g<=43572||g==43597||g==43755||43758<=g&&g<=43759||g==43765||44003<=g&&g<=44004||44006<=g&&g<=44007||44009<=g&&g<=44010||g==44012||g==69632||g==69634||g==69762||69808<=g&&g<=69810||69815<=g&&g<=69816||g==69932||g==70018||70067<=g&&g<=70069||70079<=g&&g<=70080||70188<=g&&g<=70190||70194<=g&&g<=70195||g==70197||70368<=g&&g<=70370||70402<=g&&g<=70403||g==70463||70465<=g&&g<=70468||70471<=g&&g<=70472||70475<=g&&g<=70477||70498<=g&&g<=70499||70709<=g&&g<=70711||70720<=g&&g<=70721||g==70725||70833<=g&&g<=70834||g==70841||70843<=g&&g<=70844||g==70846||g==70849||71088<=g&&g<=71089||71096<=g&&g<=71099||g==71102||71216<=g&&g<=71218||71227<=g&&g<=71228||g==71230||g==71340||71342<=g&&g<=71343||g==71350||71456<=g&&g<=71457||g==71462||72199<=g&&g<=72200||g==72249||72279<=g&&g<=72280||g==72343||g==72751||g==72766||g==72873||g==72881||g==72884||94033<=g&&g<=94078||g==119142||g==119149?n:4352<=g&&g<=4447||43360<=g&&g<=43388?u:4448<=g&&g<=4519||55216<=g&&g<=55238?A:4520<=g&&g<=4607||55243<=g&&g<=55291?p:g==44032||g==44060||g==44088||g==44116||g==44144||g==44172||g==44200||g==44228||g==44256||g==44284||g==44312||g==44340||g==44368||g==44396||g==44424||g==44452||g==44480||g==44508||g==44536||g==44564||g==44592||g==44620||g==44648||g==44676||g==44704||g==44732||g==44760||g==44788||g==44816||g==44844||g==44872||g==44900||g==44928||g==44956||g==44984||g==45012||g==45040||g==45068||g==45096||g==45124||g==45152||g==45180||g==45208||g==45236||g==45264||g==45292||g==45320||g==45348||g==45376||g==45404||g==45432||g==45460||g==45488||g==45516||g==45544||g==45572||g==45600||g==45628||g==45656||g==45684||g==45712||g==45740||g==45768||g==45796||g==45824||g==45852||g==45880||g==45908||g==45936||g==45964||g==45992||g==46020||g==46048||g==46076||g==46104||g==46132||g==46160||g==46188||g==46216||g==46244||g==46272||g==46300||g==46328||g==46356||g==46384||g==46412||g==46440||g==46468||g==46496||g==46524||g==46552||g==46580||g==46608||g==46636||g==46664||g==46692||g==46720||g==46748||g==46776||g==46804||g==46832||g==46860||g==46888||g==46916||g==46944||g==46972||g==47e3||g==47028||g==47056||g==47084||g==47112||g==47140||g==47168||g==47196||g==47224||g==47252||g==47280||g==47308||g==47336||g==47364||g==47392||g==47420||g==47448||g==47476||g==47504||g==47532||g==47560||g==47588||g==47616||g==47644||g==47672||g==47700||g==47728||g==47756||g==47784||g==47812||g==47840||g==47868||g==47896||g==47924||g==47952||g==47980||g==48008||g==48036||g==48064||g==48092||g==48120||g==48148||g==48176||g==48204||g==48232||g==48260||g==48288||g==48316||g==48344||g==48372||g==48400||g==48428||g==48456||g==48484||g==48512||g==48540||g==48568||g==48596||g==48624||g==48652||g==48680||g==48708||g==48736||g==48764||g==48792||g==48820||g==48848||g==48876||g==48904||g==48932||g==48960||g==48988||g==49016||g==49044||g==49072||g==49100||g==49128||g==49156||g==49184||g==49212||g==49240||g==49268||g==49296||g==49324||g==49352||g==49380||g==49408||g==49436||g==49464||g==49492||g==49520||g==49548||g==49576||g==49604||g==49632||g==49660||g==49688||g==49716||g==49744||g==49772||g==49800||g==49828||g==49856||g==49884||g==49912||g==49940||g==49968||g==49996||g==50024||g==50052||g==50080||g==50108||g==50136||g==50164||g==50192||g==50220||g==50248||g==50276||g==50304||g==50332||g==50360||g==50388||g==50416||g==50444||g==50472||g==50500||g==50528||g==50556||g==50584||g==50612||g==50640||g==50668||g==50696||g==50724||g==50752||g==50780||g==50808||g==50836||g==50864||g==50892||g==50920||g==50948||g==50976||g==51004||g==51032||g==51060||g==51088||g==51116||g==51144||g==51172||g==51200||g==51228||g==51256||g==51284||g==51312||g==51340||g==51368||g==51396||g==51424||g==51452||g==51480||g==51508||g==51536||g==51564||g==51592||g==51620||g==51648||g==51676||g==51704||g==51732||g==51760||g==51788||g==51816||g==51844||g==51872||g==51900||g==51928||g==51956||g==51984||g==52012||g==52040||g==52068||g==52096||g==52124||g==52152||g==52180||g==52208||g==52236||g==52264||g==52292||g==52320||g==52348||g==52376||g==52404||g==52432||g==52460||g==52488||g==52516||g==52544||g==52572||g==52600||g==52628||g==52656||g==52684||g==52712||g==52740||g==52768||g==52796||g==52824||g==52852||g==52880||g==52908||g==52936||g==52964||g==52992||g==53020||g==53048||g==53076||g==53104||g==53132||g==53160||g==53188||g==53216||g==53244||g==53272||g==53300||g==53328||g==53356||g==53384||g==53412||g==53440||g==53468||g==53496||g==53524||g==53552||g==53580||g==53608||g==53636||g==53664||g==53692||g==53720||g==53748||g==53776||g==53804||g==53832||g==53860||g==53888||g==53916||g==53944||g==53972||g==54e3||g==54028||g==54056||g==54084||g==54112||g==54140||g==54168||g==54196||g==54224||g==54252||g==54280||g==54308||g==54336||g==54364||g==54392||g==54420||g==54448||g==54476||g==54504||g==54532||g==54560||g==54588||g==54616||g==54644||g==54672||g==54700||g==54728||g==54756||g==54784||g==54812||g==54840||g==54868||g==54896||g==54924||g==54952||g==54980||g==55008||g==55036||g==55064||g==55092||g==55120||g==55148||g==55176?h:44033<=g&&g<=44059||44061<=g&&g<=44087||44089<=g&&g<=44115||44117<=g&&g<=44143||44145<=g&&g<=44171||44173<=g&&g<=44199||44201<=g&&g<=44227||44229<=g&&g<=44255||44257<=g&&g<=44283||44285<=g&&g<=44311||44313<=g&&g<=44339||44341<=g&&g<=44367||44369<=g&&g<=44395||44397<=g&&g<=44423||44425<=g&&g<=44451||44453<=g&&g<=44479||44481<=g&&g<=44507||44509<=g&&g<=44535||44537<=g&&g<=44563||44565<=g&&g<=44591||44593<=g&&g<=44619||44621<=g&&g<=44647||44649<=g&&g<=44675||44677<=g&&g<=44703||44705<=g&&g<=44731||44733<=g&&g<=44759||44761<=g&&g<=44787||44789<=g&&g<=44815||44817<=g&&g<=44843||44845<=g&&g<=44871||44873<=g&&g<=44899||44901<=g&&g<=44927||44929<=g&&g<=44955||44957<=g&&g<=44983||44985<=g&&g<=45011||45013<=g&&g<=45039||45041<=g&&g<=45067||45069<=g&&g<=45095||45097<=g&&g<=45123||45125<=g&&g<=45151||45153<=g&&g<=45179||45181<=g&&g<=45207||45209<=g&&g<=45235||45237<=g&&g<=45263||45265<=g&&g<=45291||45293<=g&&g<=45319||45321<=g&&g<=45347||45349<=g&&g<=45375||45377<=g&&g<=45403||45405<=g&&g<=45431||45433<=g&&g<=45459||45461<=g&&g<=45487||45489<=g&&g<=45515||45517<=g&&g<=45543||45545<=g&&g<=45571||45573<=g&&g<=45599||45601<=g&&g<=45627||45629<=g&&g<=45655||45657<=g&&g<=45683||45685<=g&&g<=45711||45713<=g&&g<=45739||45741<=g&&g<=45767||45769<=g&&g<=45795||45797<=g&&g<=45823||45825<=g&&g<=45851||45853<=g&&g<=45879||45881<=g&&g<=45907||45909<=g&&g<=45935||45937<=g&&g<=45963||45965<=g&&g<=45991||45993<=g&&g<=46019||46021<=g&&g<=46047||46049<=g&&g<=46075||46077<=g&&g<=46103||46105<=g&&g<=46131||46133<=g&&g<=46159||46161<=g&&g<=46187||46189<=g&&g<=46215||46217<=g&&g<=46243||46245<=g&&g<=46271||46273<=g&&g<=46299||46301<=g&&g<=46327||46329<=g&&g<=46355||46357<=g&&g<=46383||46385<=g&&g<=46411||46413<=g&&g<=46439||46441<=g&&g<=46467||46469<=g&&g<=46495||46497<=g&&g<=46523||46525<=g&&g<=46551||46553<=g&&g<=46579||46581<=g&&g<=46607||46609<=g&&g<=46635||46637<=g&&g<=46663||46665<=g&&g<=46691||46693<=g&&g<=46719||46721<=g&&g<=46747||46749<=g&&g<=46775||46777<=g&&g<=46803||46805<=g&&g<=46831||46833<=g&&g<=46859||46861<=g&&g<=46887||46889<=g&&g<=46915||46917<=g&&g<=46943||46945<=g&&g<=46971||46973<=g&&g<=46999||47001<=g&&g<=47027||47029<=g&&g<=47055||47057<=g&&g<=47083||47085<=g&&g<=47111||47113<=g&&g<=47139||47141<=g&&g<=47167||47169<=g&&g<=47195||47197<=g&&g<=47223||47225<=g&&g<=47251||47253<=g&&g<=47279||47281<=g&&g<=47307||47309<=g&&g<=47335||47337<=g&&g<=47363||47365<=g&&g<=47391||47393<=g&&g<=47419||47421<=g&&g<=47447||47449<=g&&g<=47475||47477<=g&&g<=47503||47505<=g&&g<=47531||47533<=g&&g<=47559||47561<=g&&g<=47587||47589<=g&&g<=47615||47617<=g&&g<=47643||47645<=g&&g<=47671||47673<=g&&g<=47699||47701<=g&&g<=47727||47729<=g&&g<=47755||47757<=g&&g<=47783||47785<=g&&g<=47811||47813<=g&&g<=47839||47841<=g&&g<=47867||47869<=g&&g<=47895||47897<=g&&g<=47923||47925<=g&&g<=47951||47953<=g&&g<=47979||47981<=g&&g<=48007||48009<=g&&g<=48035||48037<=g&&g<=48063||48065<=g&&g<=48091||48093<=g&&g<=48119||48121<=g&&g<=48147||48149<=g&&g<=48175||48177<=g&&g<=48203||48205<=g&&g<=48231||48233<=g&&g<=48259||48261<=g&&g<=48287||48289<=g&&g<=48315||48317<=g&&g<=48343||48345<=g&&g<=48371||48373<=g&&g<=48399||48401<=g&&g<=48427||48429<=g&&g<=48455||48457<=g&&g<=48483||48485<=g&&g<=48511||48513<=g&&g<=48539||48541<=g&&g<=48567||48569<=g&&g<=48595||48597<=g&&g<=48623||48625<=g&&g<=48651||48653<=g&&g<=48679||48681<=g&&g<=48707||48709<=g&&g<=48735||48737<=g&&g<=48763||48765<=g&&g<=48791||48793<=g&&g<=48819||48821<=g&&g<=48847||48849<=g&&g<=48875||48877<=g&&g<=48903||48905<=g&&g<=48931||48933<=g&&g<=48959||48961<=g&&g<=48987||48989<=g&&g<=49015||49017<=g&&g<=49043||49045<=g&&g<=49071||49073<=g&&g<=49099||49101<=g&&g<=49127||49129<=g&&g<=49155||49157<=g&&g<=49183||49185<=g&&g<=49211||49213<=g&&g<=49239||49241<=g&&g<=49267||49269<=g&&g<=49295||49297<=g&&g<=49323||49325<=g&&g<=49351||49353<=g&&g<=49379||49381<=g&&g<=49407||49409<=g&&g<=49435||49437<=g&&g<=49463||49465<=g&&g<=49491||49493<=g&&g<=49519||49521<=g&&g<=49547||49549<=g&&g<=49575||49577<=g&&g<=49603||49605<=g&&g<=49631||49633<=g&&g<=49659||49661<=g&&g<=49687||49689<=g&&g<=49715||49717<=g&&g<=49743||49745<=g&&g<=49771||49773<=g&&g<=49799||49801<=g&&g<=49827||49829<=g&&g<=49855||49857<=g&&g<=49883||49885<=g&&g<=49911||49913<=g&&g<=49939||49941<=g&&g<=49967||49969<=g&&g<=49995||49997<=g&&g<=50023||50025<=g&&g<=50051||50053<=g&&g<=50079||50081<=g&&g<=50107||50109<=g&&g<=50135||50137<=g&&g<=50163||50165<=g&&g<=50191||50193<=g&&g<=50219||50221<=g&&g<=50247||50249<=g&&g<=50275||50277<=g&&g<=50303||50305<=g&&g<=50331||50333<=g&&g<=50359||50361<=g&&g<=50387||50389<=g&&g<=50415||50417<=g&&g<=50443||50445<=g&&g<=50471||50473<=g&&g<=50499||50501<=g&&g<=50527||50529<=g&&g<=50555||50557<=g&&g<=50583||50585<=g&&g<=50611||50613<=g&&g<=50639||50641<=g&&g<=50667||50669<=g&&g<=50695||50697<=g&&g<=50723||50725<=g&&g<=50751||50753<=g&&g<=50779||50781<=g&&g<=50807||50809<=g&&g<=50835||50837<=g&&g<=50863||50865<=g&&g<=50891||50893<=g&&g<=50919||50921<=g&&g<=50947||50949<=g&&g<=50975||50977<=g&&g<=51003||51005<=g&&g<=51031||51033<=g&&g<=51059||51061<=g&&g<=51087||51089<=g&&g<=51115||51117<=g&&g<=51143||51145<=g&&g<=51171||51173<=g&&g<=51199||51201<=g&&g<=51227||51229<=g&&g<=51255||51257<=g&&g<=51283||51285<=g&&g<=51311||51313<=g&&g<=51339||51341<=g&&g<=51367||51369<=g&&g<=51395||51397<=g&&g<=51423||51425<=g&&g<=51451||51453<=g&&g<=51479||51481<=g&&g<=51507||51509<=g&&g<=51535||51537<=g&&g<=51563||51565<=g&&g<=51591||51593<=g&&g<=51619||51621<=g&&g<=51647||51649<=g&&g<=51675||51677<=g&&g<=51703||51705<=g&&g<=51731||51733<=g&&g<=51759||51761<=g&&g<=51787||51789<=g&&g<=51815||51817<=g&&g<=51843||51845<=g&&g<=51871||51873<=g&&g<=51899||51901<=g&&g<=51927||51929<=g&&g<=51955||51957<=g&&g<=51983||51985<=g&&g<=52011||52013<=g&&g<=52039||52041<=g&&g<=52067||52069<=g&&g<=52095||52097<=g&&g<=52123||52125<=g&&g<=52151||52153<=g&&g<=52179||52181<=g&&g<=52207||52209<=g&&g<=52235||52237<=g&&g<=52263||52265<=g&&g<=52291||52293<=g&&g<=52319||52321<=g&&g<=52347||52349<=g&&g<=52375||52377<=g&&g<=52403||52405<=g&&g<=52431||52433<=g&&g<=52459||52461<=g&&g<=52487||52489<=g&&g<=52515||52517<=g&&g<=52543||52545<=g&&g<=52571||52573<=g&&g<=52599||52601<=g&&g<=52627||52629<=g&&g<=52655||52657<=g&&g<=52683||52685<=g&&g<=52711||52713<=g&&g<=52739||52741<=g&&g<=52767||52769<=g&&g<=52795||52797<=g&&g<=52823||52825<=g&&g<=52851||52853<=g&&g<=52879||52881<=g&&g<=52907||52909<=g&&g<=52935||52937<=g&&g<=52963||52965<=g&&g<=52991||52993<=g&&g<=53019||53021<=g&&g<=53047||53049<=g&&g<=53075||53077<=g&&g<=53103||53105<=g&&g<=53131||53133<=g&&g<=53159||53161<=g&&g<=53187||53189<=g&&g<=53215||53217<=g&&g<=53243||53245<=g&&g<=53271||53273<=g&&g<=53299||53301<=g&&g<=53327||53329<=g&&g<=53355||53357<=g&&g<=53383||53385<=g&&g<=53411||53413<=g&&g<=53439||53441<=g&&g<=53467||53469<=g&&g<=53495||53497<=g&&g<=53523||53525<=g&&g<=53551||53553<=g&&g<=53579||53581<=g&&g<=53607||53609<=g&&g<=53635||53637<=g&&g<=53663||53665<=g&&g<=53691||53693<=g&&g<=53719||53721<=g&&g<=53747||53749<=g&&g<=53775||53777<=g&&g<=53803||53805<=g&&g<=53831||53833<=g&&g<=53859||53861<=g&&g<=53887||53889<=g&&g<=53915||53917<=g&&g<=53943||53945<=g&&g<=53971||53973<=g&&g<=53999||54001<=g&&g<=54027||54029<=g&&g<=54055||54057<=g&&g<=54083||54085<=g&&g<=54111||54113<=g&&g<=54139||54141<=g&&g<=54167||54169<=g&&g<=54195||54197<=g&&g<=54223||54225<=g&&g<=54251||54253<=g&&g<=54279||54281<=g&&g<=54307||54309<=g&&g<=54335||54337<=g&&g<=54363||54365<=g&&g<=54391||54393<=g&&g<=54419||54421<=g&&g<=54447||54449<=g&&g<=54475||54477<=g&&g<=54503||54505<=g&&g<=54531||54533<=g&&g<=54559||54561<=g&&g<=54587||54589<=g&&g<=54615||54617<=g&&g<=54643||54645<=g&&g<=54671||54673<=g&&g<=54699||54701<=g&&g<=54727||54729<=g&&g<=54755||54757<=g&&g<=54783||54785<=g&&g<=54811||54813<=g&&g<=54839||54841<=g&&g<=54867||54869<=g&&g<=54895||54897<=g&&g<=54923||54925<=g&&g<=54951||54953<=g&&g<=54979||54981<=g&&g<=55007||55009<=g&&g<=55035||55037<=g&&g<=55063||55065<=g&&g<=55091||55093<=g&&g<=55119||55121<=g&&g<=55147||55149<=g&&g<=55175||55177<=g&&g<=55203?C:g==9757||g==9977||9994<=g&&g<=9997||g==127877||127938<=g&&g<=127940||g==127943||127946<=g&&g<=127948||128066<=g&&g<=128067||128070<=g&&g<=128080||g==128110||128112<=g&&g<=128120||g==128124||128129<=g&&g<=128131||128133<=g&&g<=128135||g==128170||128372<=g&&g<=128373||g==128378||g==128400||128405<=g&&g<=128406||128581<=g&&g<=128583||128587<=g&&g<=128591||g==128675||128692<=g&&g<=128694||g==128704||g==128716||129304<=g&&g<=129308||129310<=g&&g<=129311||g==129318||129328<=g&&g<=129337||129341<=g&&g<=129342||129489<=g&&g<=129501?b:127995<=g&&g<=127999?E:g==8205?F:g==9792||g==9794||9877<=g&&g<=9878||g==9992||g==10084||g==127752||g==127806||g==127859||g==127891||g==127908||g==127912||g==127979||g==127981||g==128139||128187<=g&&g<=128188||g==128295||g==128300||g==128488||g==128640||g==128658?N:128102<=g&&g<=128105?U:I}return this}typeof jx<"u"&&jx.exports&&(jx.exports=hat)});var qce=_((c4t,jce)=>{var gat=/^(.*?)(\x1b\[[^m]+m|\x1b\]8;;.*?(\x1b\\|\u0007))/,qx;function dat(){if(qx)return qx;if(typeof Intl.Segmenter<"u"){let t=new Intl.Segmenter("en",{granularity:"grapheme"});return qx=e=>Array.from(t.segment(e),({segment:r})=>r)}else{let t=Hce(),e=new t;return qx=r=>e.splitGraphemes(r)}}jce.exports=(t,e=0,r=t.length)=>{if(e<0||r<0)throw new RangeError("Negative indices aren't supported by this implementation");let o=r-e,a="",n=0,u=0;for(;t.length>0;){let A=t.match(gat)||[t,t,void 0],p=dat()(A[1]),h=Math.min(e-n,p.length);p=p.slice(h);let C=Math.min(o-u,p.length);a+=p.slice(0,C).join(""),n+=h,u+=C,typeof A[2]<"u"&&(a+=A[2]),t=t.slice(A[0].length)}return a}});var tn,T1=yt(()=>{tn=process.env.YARN_IS_TEST_ENV?"0.0.0":"4.0.0"});function zce(t,{configuration:e,json:r}){if(!e.get("enableMessageNames"))return"";let a=Wu(t===null?0:t);return!r&&t===null?_t(e,a,"grey"):a}function yU(t,{configuration:e,json:r}){let o=zce(t,{configuration:e,json:r});if(!o||t===null||t===0)return o;let a=wr[t],n=`https://yarnpkg.com/advanced/error-codes#${o}---${a}`.toLowerCase();return Xy(e,o,n)}async function LE({configuration:t,stdout:e,forceError:r},o){let a=await Lt.start({configuration:t,stdout:e,includeFooter:!1},async n=>{let u=!1,A=!1;for(let p of o)typeof p.option<"u"&&(p.error||r?(A=!0,n.reportError(50,p.message)):(u=!0,n.reportWarning(50,p.message)),p.callback?.());u&&!A&&n.reportSeparator()});return a.hasErrors()?a.exitCode():null}var Kce,Gx,mat,Gce,Yce,uh,Vce,Wce,yat,Eat,Yx,Cat,Lt,L1=yt(()=>{Kce=$e(qce()),Gx=$e($g());fP();Yl();T1();ql();mat="\xB7",Gce=["\u280B","\u2819","\u2839","\u2838","\u283C","\u2834","\u2826","\u2827","\u2807","\u280F"],Yce=80,uh=Gx.default.GITHUB_ACTIONS?{start:t=>`::group::${t} -`,end:t=>`::endgroup:: -`}:Gx.default.TRAVIS?{start:t=>`travis_fold:start:${t} -`,end:t=>`travis_fold:end:${t} -`}:Gx.default.GITLAB?{start:t=>`section_start:${Math.floor(Date.now()/1e3)}:${t.toLowerCase().replace(/\W+/g,"_")}[collapsed=true]\r\x1B[0K${t} -`,end:t=>`section_end:${Math.floor(Date.now()/1e3)}:${t.toLowerCase().replace(/\W+/g,"_")}\r\x1B[0K`}:null,Vce=uh!==null,Wce=new Date,yat=["iTerm.app","Apple_Terminal","WarpTerminal","vscode"].includes(process.env.TERM_PROGRAM)||!!process.env.WT_SESSION,Eat=t=>t,Yx=Eat({patrick:{date:[17,3],chars:["\u{1F340}","\u{1F331}"],size:40},simba:{date:[19,7],chars:["\u{1F981}","\u{1F334}"],size:40},jack:{date:[31,10],chars:["\u{1F383}","\u{1F987}"],size:40},hogsfather:{date:[31,12],chars:["\u{1F389}","\u{1F384}"],size:40},default:{chars:["=","-"],size:80}}),Cat=yat&&Object.keys(Yx).find(t=>{let e=Yx[t];return!(e.date&&(e.date[0]!==Wce.getDate()||e.date[1]!==Wce.getMonth()+1))})||"default";Lt=class extends Xs{constructor({configuration:r,stdout:o,json:a=!1,forceSectionAlignment:n=!1,includeNames:u=!0,includePrefix:A=!0,includeFooter:p=!0,includeLogs:h=!a,includeInfos:C=h,includeWarnings:I=h}){super();this.uncommitted=new Set;this.warningCount=0;this.errorCount=0;this.timerFooter=[];this.startTime=Date.now();this.indent=0;this.level=0;this.progress=new Map;this.progressTime=0;this.progressFrame=0;this.progressTimeout=null;this.progressStyle=null;this.progressMaxScaledSize=null;if(zI(this,{configuration:r}),this.configuration=r,this.forceSectionAlignment=n,this.includeNames=u,this.includePrefix=A,this.includeFooter=p,this.includeInfos=C,this.includeWarnings=I,this.json=a,this.stdout=o,r.get("enableProgressBars")&&!a&&o.isTTY&&o.columns>22){let v=r.get("progressBarStyle")||Cat;if(!Object.hasOwn(Yx,v))throw new Error("Assertion failed: Invalid progress bar style");this.progressStyle=Yx[v];let b=Math.min(this.getRecommendedLength(),80);this.progressMaxScaledSize=Math.floor(this.progressStyle.size*b/80)}}static async start(r,o){let a=new this(r),n=process.emitWarning;process.emitWarning=(u,A)=>{if(typeof u!="string"){let h=u;u=h.message,A=A??h.name}let p=typeof A<"u"?`${A}: ${u}`:u;a.reportWarning(0,p)},r.includeVersion&&a.reportInfo(0,md(r.configuration,`Yarn ${tn}`,2));try{await o(a)}catch(u){a.reportExceptionOnce(u)}finally{await a.finalize(),process.emitWarning=n}return a}hasErrors(){return this.errorCount>0}exitCode(){return this.hasErrors()?1:0}getRecommendedLength(){let o=this.progressStyle!==null?this.stdout.columns-1:super.getRecommendedLength();return Math.max(40,o-10-this.indent*2)}startSectionSync({reportHeader:r,reportFooter:o,skipIfEmpty:a},n){let u={committed:!1,action:()=>{r?.()}};a?this.uncommitted.add(u):(u.action(),u.committed=!0);let A=Date.now();try{return n()}catch(p){throw this.reportExceptionOnce(p),p}finally{let p=Date.now();this.uncommitted.delete(u),u.committed&&o?.(p-A)}}async startSectionPromise({reportHeader:r,reportFooter:o,skipIfEmpty:a},n){let u={committed:!1,action:()=>{r?.()}};a?this.uncommitted.add(u):(u.action(),u.committed=!0);let A=Date.now();try{return await n()}catch(p){throw this.reportExceptionOnce(p),p}finally{let p=Date.now();this.uncommitted.delete(u),u.committed&&o?.(p-A)}}startTimerImpl(r,o,a){return{cb:typeof o=="function"?o:a,reportHeader:()=>{this.level+=1,this.reportInfo(null,`\u250C ${r}`),this.indent+=1,uh!==null&&!this.json&&this.includeInfos&&this.stdout.write(uh.start(r))},reportFooter:A=>{if(this.indent-=1,uh!==null&&!this.json&&this.includeInfos){this.stdout.write(uh.end(r));for(let p of this.timerFooter)p()}this.configuration.get("enableTimers")&&A>200?this.reportInfo(null,`\u2514 Completed in ${_t(this.configuration,A,Et.DURATION)}`):this.reportInfo(null,"\u2514 Completed"),this.level-=1},skipIfEmpty:(typeof o=="function"?{}:o).skipIfEmpty}}startTimerSync(r,o,a){let{cb:n,...u}=this.startTimerImpl(r,o,a);return this.startSectionSync(u,n)}async startTimerPromise(r,o,a){let{cb:n,...u}=this.startTimerImpl(r,o,a);return this.startSectionPromise(u,n)}reportSeparator(){this.indent===0?this.writeLine(""):this.reportInfo(null,"")}reportInfo(r,o){if(!this.includeInfos)return;this.commit();let a=this.formatNameWithHyperlink(r),n=a?`${a}: `:"",u=`${this.formatPrefix(n,"blueBright")}${o}`;this.json?this.reportJson({type:"info",name:r,displayName:this.formatName(r),indent:this.formatIndent(),data:o}):this.writeLine(u)}reportWarning(r,o){if(this.warningCount+=1,!this.includeWarnings)return;this.commit();let a=this.formatNameWithHyperlink(r),n=a?`${a}: `:"";this.json?this.reportJson({type:"warning",name:r,displayName:this.formatName(r),indent:this.formatIndent(),data:o}):this.writeLine(`${this.formatPrefix(n,"yellowBright")}${o}`)}reportError(r,o){this.errorCount+=1,this.timerFooter.push(()=>this.reportErrorImpl(r,o)),this.reportErrorImpl(r,o)}reportErrorImpl(r,o){this.commit();let a=this.formatNameWithHyperlink(r),n=a?`${a}: `:"";this.json?this.reportJson({type:"error",name:r,displayName:this.formatName(r),indent:this.formatIndent(),data:o}):this.writeLine(`${this.formatPrefix(n,"redBright")}${o}`,{truncate:!1})}reportFold(r,o){if(!uh)return;let a=`${uh.start(r)}${o}${uh.end(r)}`;this.timerFooter.push(()=>this.stdout.write(a))}reportProgress(r){if(this.progressStyle===null)return{...Promise.resolve(),stop:()=>{}};if(r.hasProgress&&r.hasTitle)throw new Error("Unimplemented: Progress bars can't have both progress and titles.");let o=!1,a=Promise.resolve().then(async()=>{let u={progress:r.hasProgress?0:void 0,title:r.hasTitle?"":void 0};this.progress.set(r,{definition:u,lastScaledSize:r.hasProgress?-1:void 0,lastTitle:void 0}),this.refreshProgress({delta:-1});for await(let{progress:A,title:p}of r)o||u.progress===A&&u.title===p||(u.progress=A,u.title=p,this.refreshProgress());n()}),n=()=>{o||(o=!0,this.progress.delete(r),this.refreshProgress({delta:1}))};return{...a,stop:n}}reportJson(r){this.json&&this.writeLine(`${JSON.stringify(r)}`)}async finalize(){if(!this.includeFooter)return;let r="";this.errorCount>0?r="Failed with errors":this.warningCount>0?r="Done with warnings":r="Done";let o=_t(this.configuration,Date.now()-this.startTime,Et.DURATION),a=this.configuration.get("enableTimers")?`${r} in ${o}`:r;this.errorCount>0?this.reportError(0,a):this.warningCount>0?this.reportWarning(0,a):this.reportInfo(0,a)}writeLine(r,{truncate:o}={}){this.clearProgress({clear:!0}),this.stdout.write(`${this.truncate(r,{truncate:o})} -`),this.writeProgress()}writeLines(r,{truncate:o}={}){this.clearProgress({delta:r.length});for(let a of r)this.stdout.write(`${this.truncate(a,{truncate:o})} -`);this.writeProgress()}commit(){let r=this.uncommitted;this.uncommitted=new Set;for(let o of r)o.committed=!0,o.action()}clearProgress({delta:r=0,clear:o=!1}){this.progressStyle!==null&&this.progress.size+r>0&&(this.stdout.write(`\x1B[${this.progress.size+r}A`),(r>0||o)&&this.stdout.write("\x1B[0J"))}writeProgress(){if(this.progressStyle===null||(this.progressTimeout!==null&&clearTimeout(this.progressTimeout),this.progressTimeout=null,this.progress.size===0))return;let r=Date.now();r-this.progressTime>Yce&&(this.progressFrame=(this.progressFrame+1)%Gce.length,this.progressTime=r);let o=Gce[this.progressFrame];for(let a of this.progress.values()){let n="";if(typeof a.lastScaledSize<"u"){let h=this.progressStyle.chars[0].repeat(a.lastScaledSize),C=this.progressStyle.chars[1].repeat(this.progressMaxScaledSize-a.lastScaledSize);n=` ${h}${C}`}let u=this.formatName(null),A=u?`${u}: `:"",p=a.definition.title?` ${a.definition.title}`:"";this.stdout.write(`${_t(this.configuration,"\u27A4","blueBright")} ${A}${o}${n}${p} -`)}this.progressTimeout=setTimeout(()=>{this.refreshProgress({force:!0})},Yce)}refreshProgress({delta:r=0,force:o=!1}={}){let a=!1,n=!1;if(o||this.progress.size===0)a=!0;else for(let u of this.progress.values()){let A=typeof u.definition.progress<"u"?Math.trunc(this.progressMaxScaledSize*u.definition.progress):void 0,p=u.lastScaledSize;u.lastScaledSize=A;let h=u.lastTitle;if(u.lastTitle=u.definition.title,A!==p||(n=h!==u.definition.title)){a=!0;break}}a&&(this.clearProgress({delta:r,clear:n}),this.writeProgress())}truncate(r,{truncate:o}={}){return this.progressStyle===null&&(o=!1),typeof o>"u"&&(o=this.configuration.get("preferTruncatedLines")),o&&(r=(0,Kce.default)(r,0,this.stdout.columns-1)),r}formatName(r){return this.includeNames?zce(r,{configuration:this.configuration,json:this.json}):""}formatPrefix(r,o){return this.includePrefix?`${_t(this.configuration,"\u27A4",o)} ${r}${this.formatIndent()}`:""}formatNameWithHyperlink(r){return this.includeNames?yU(r,{configuration:this.configuration,json:this.json}):""}formatIndent(){return this.level>0||!this.forceSectionAlignment?"\u2502 ".repeat(this.indent):`${mat} `}}});var un={};Vt(un,{PackageManager:()=>Zce,detectPackageManager:()=>$ce,executePackageAccessibleBinary:()=>iue,executePackageScript:()=>Wx,executePackageShellcode:()=>EU,executeWorkspaceAccessibleBinary:()=>Sat,executeWorkspaceLifecycleScript:()=>rue,executeWorkspaceScript:()=>tue,getPackageAccessibleBinaries:()=>Kx,getWorkspaceAccessibleBinaries:()=>nue,hasPackageScript:()=>vat,hasWorkspaceScript:()=>CU,isNodeScript:()=>wU,makeScriptEnv:()=>N1,maybeExecuteWorkspaceLifecycleScript:()=>Pat,prepareExternalProject:()=>Bat});async function Ah(t,e,r,o=[]){if(process.platform==="win32"){let a=`@goto #_undefined_# 2>NUL || @title %COMSPEC% & @setlocal & @"${r}" ${o.map(n=>`"${n.replace('"','""')}"`).join(" ")} %*`;await oe.writeFilePromise(V.format({dir:t,name:e,ext:".cmd"}),a)}await oe.writeFilePromise(V.join(t,e),`#!/bin/sh -exec "${r}" ${o.map(a=>`'${a.replace(/'/g,`'"'"'`)}'`).join(" ")} "$@" -`,{mode:493})}async function $ce(t){let e=await Ot.tryFind(t);if(e?.packageManager){let o=US(e.packageManager);if(o?.name){let a=`found ${JSON.stringify({packageManager:e.packageManager})} in manifest`,[n]=o.reference.split(".");switch(o.name){case"yarn":return{packageManagerField:!0,packageManager:Number(n)===1?"Yarn Classic":"Yarn",reason:a};case"npm":return{packageManagerField:!0,packageManager:"npm",reason:a};case"pnpm":return{packageManagerField:!0,packageManager:"pnpm",reason:a}}}}let r;try{r=await oe.readFilePromise(V.join(t,dr.lockfile),"utf8")}catch{}return r!==void 0?r.match(/^__metadata:$/m)?{packageManager:"Yarn",reason:'"__metadata" key found in yarn.lock'}:{packageManager:"Yarn Classic",reason:'"__metadata" key not found in yarn.lock, must be a Yarn classic lockfile'}:oe.existsSync(V.join(t,"package-lock.json"))?{packageManager:"npm",reason:`found npm's "package-lock.json" lockfile`}:oe.existsSync(V.join(t,"pnpm-lock.yaml"))?{packageManager:"pnpm",reason:`found pnpm's "pnpm-lock.yaml" lockfile`}:null}async function N1({project:t,locator:e,binFolder:r,ignoreCorepack:o,lifecycleScript:a,baseEnv:n=t?.configuration.env??process.env}){let u={};for(let[C,I]of Object.entries(n))typeof I<"u"&&(u[C.toLowerCase()!=="path"?C:"PATH"]=I);let A=fe.fromPortablePath(r);u.BERRY_BIN_FOLDER=fe.fromPortablePath(A);let p=process.env.COREPACK_ROOT&&!o?fe.join(process.env.COREPACK_ROOT,"dist/yarn.js"):process.argv[1];if(await Promise.all([Ah(r,"node",process.execPath),...tn!==null?[Ah(r,"run",process.execPath,[p,"run"]),Ah(r,"yarn",process.execPath,[p]),Ah(r,"yarnpkg",process.execPath,[p]),Ah(r,"node-gyp",process.execPath,[p,"run","--top-level","node-gyp"])]:[]]),t&&(u.INIT_CWD=fe.cwd(),u.PROJECT_CWD=fe.fromPortablePath(t.cwd)),u.PATH=u.PATH?`${A}${fe.delimiter}${u.PATH}`:`${A}`,u.npm_execpath=`${A}${fe.sep}yarn`,u.npm_node_execpath=`${A}${fe.sep}node`,e){if(!t)throw new Error("Assertion failed: Missing project");let C=t.tryWorkspaceByLocator(e),I=C?C.manifest.version??"":t.storedPackages.get(e.locatorHash).version??"";u.npm_package_name=fn(e),u.npm_package_version=I;let v;if(C)v=C.cwd;else{let b=t.storedPackages.get(e.locatorHash);if(!b)throw new Error(`Package for ${jr(t.configuration,e)} not found in the project`);let E=t.configuration.getLinkers(),F={project:t,report:new Lt({stdout:new fh.PassThrough,configuration:t.configuration})},N=E.find(U=>U.supportsPackage(b,F));if(!N)throw new Error(`The package ${jr(t.configuration,b)} isn't supported by any of the available linkers`);v=await N.findPackageLocation(b,F)}u.npm_package_json=fe.fromPortablePath(V.join(v,dr.manifest))}let h=tn!==null?`yarn/${tn}`:`yarn/${zp("@yarnpkg/core").version}-core`;return u.npm_config_user_agent=`${h} npm/? node/${process.version} ${process.platform} ${process.arch}`,a&&(u.npm_lifecycle_event=a),t&&await t.configuration.triggerHook(C=>C.setupScriptEnvironment,t,u,async(C,I,v)=>await Ah(r,C,I,v)),u}async function Bat(t,e,{configuration:r,report:o,workspace:a=null,locator:n=null}){await Iat(async()=>{await oe.mktempPromise(async u=>{let A=V.join(u,"pack.log"),p=null,{stdout:h,stderr:C}=r.getSubprocessStreams(A,{prefix:fe.fromPortablePath(t),report:o}),I=n&&Hc(n)?e1(n):n,v=I?xa(I):"an external project";h.write(`Packing ${v} from sources -`);let b=await $ce(t),E;b!==null?(h.write(`Using ${b.packageManager} for bootstrap. Reason: ${b.reason} - -`),E=b.packageManager):(h.write(`No package manager configuration detected; defaulting to Yarn - -`),E="Yarn");let F=E==="Yarn"&&!b?.packageManagerField;await oe.mktempPromise(async N=>{let U=await N1({binFolder:N,ignoreCorepack:F}),te=new Map([["Yarn Classic",async()=>{let pe=a!==null?["workspace",a]:[],ue=V.join(t,dr.manifest),ye=await oe.readFilePromise(ue),ae=await Gc(process.execPath,[process.argv[1],"set","version","classic","--only-if-needed","--yarn-path"],{cwd:t,env:U,stdin:p,stdout:h,stderr:C,end:1});if(ae.code!==0)return ae.code;await oe.writeFilePromise(ue,ye),await oe.appendFilePromise(V.join(t,".npmignore"),`/.yarn -`),h.write(` -`),delete U.NODE_ENV;let Ie=await Gc("yarn",["install"],{cwd:t,env:U,stdin:p,stdout:h,stderr:C,end:1});if(Ie.code!==0)return Ie.code;h.write(` -`);let Fe=await Gc("yarn",[...pe,"pack","--filename",fe.fromPortablePath(e)],{cwd:t,env:U,stdin:p,stdout:h,stderr:C});return Fe.code!==0?Fe.code:0}],["Yarn",async()=>{let pe=a!==null?["workspace",a]:[];U.YARN_ENABLE_INLINE_BUILDS="1";let ue=V.join(t,dr.lockfile);await oe.existsPromise(ue)||await oe.writeFilePromise(ue,"");let ye=await Gc("yarn",[...pe,"pack","--install-if-needed","--filename",fe.fromPortablePath(e)],{cwd:t,env:U,stdin:p,stdout:h,stderr:C});return ye.code!==0?ye.code:0}],["npm",async()=>{if(a!==null){let Ee=new fh.PassThrough,De=Ky(Ee);Ee.pipe(h,{end:!1});let ce=await Gc("npm",["--version"],{cwd:t,env:U,stdin:p,stdout:Ee,stderr:C,end:0});if(Ee.end(),ce.code!==0)return h.end(),C.end(),ce.code;let ne=(await De).toString().trim();if(!xf(ne,">=7.x")){let ee=eA(null,"npm"),we=In(ee,ne),be=In(ee,">=7.x");throw new Error(`Workspaces aren't supported by ${qn(r,we)}; please upgrade to ${qn(r,be)} (npm has been detected as the primary package manager for ${_t(r,t,Et.PATH)})`)}}let pe=a!==null?["--workspace",a]:[];delete U.npm_config_user_agent,delete U.npm_config_production,delete U.NPM_CONFIG_PRODUCTION,delete U.NODE_ENV;let ue=await Gc("npm",["install","--legacy-peer-deps"],{cwd:t,env:U,stdin:p,stdout:h,stderr:C,end:1});if(ue.code!==0)return ue.code;let ye=new fh.PassThrough,ae=Ky(ye);ye.pipe(h);let Ie=await Gc("npm",["pack","--silent",...pe],{cwd:t,env:U,stdin:p,stdout:ye,stderr:C});if(Ie.code!==0)return Ie.code;let Fe=(await ae).toString().trim().replace(/^.*\n/s,""),g=V.resolve(t,fe.toPortablePath(Fe));return await oe.renamePromise(g,e),0}]]).get(E);if(typeof te>"u")throw new Error("Assertion failed: Unsupported workflow");let le=await te();if(!(le===0||typeof le>"u"))throw oe.detachTemp(u),new Jt(58,`Packing the package failed (exit code ${le}, logs can be found here: ${_t(r,A,Et.PATH)})`)})})})}async function vat(t,e,{project:r}){let o=r.tryWorkspaceByLocator(t);if(o!==null)return CU(o,e);let a=r.storedPackages.get(t.locatorHash);if(!a)throw new Error(`Package for ${jr(r.configuration,t)} not found in the project`);return await zl.openPromise(async n=>{let u=r.configuration,A=r.configuration.getLinkers(),p={project:r,report:new Lt({stdout:new fh.PassThrough,configuration:u})},h=A.find(b=>b.supportsPackage(a,p));if(!h)throw new Error(`The package ${jr(r.configuration,a)} isn't supported by any of the available linkers`);let C=await h.findPackageLocation(a,p),I=new gn(C,{baseFs:n});return(await Ot.find(Bt.dot,{baseFs:I})).scripts.has(e)})}async function Wx(t,e,r,{cwd:o,project:a,stdin:n,stdout:u,stderr:A}){return await oe.mktempPromise(async p=>{let{manifest:h,env:C,cwd:I}=await eue(t,{project:a,binFolder:p,cwd:o,lifecycleScript:e}),v=h.scripts.get(e);if(typeof v>"u")return 1;let b=async()=>await RE(v,r,{cwd:I,env:C,stdin:n,stdout:u,stderr:A});return await(await a.configuration.reduceHook(F=>F.wrapScriptExecution,b,a,t,e,{script:v,args:r,cwd:I,env:C,stdin:n,stdout:u,stderr:A}))()})}async function EU(t,e,r,{cwd:o,project:a,stdin:n,stdout:u,stderr:A}){return await oe.mktempPromise(async p=>{let{env:h,cwd:C}=await eue(t,{project:a,binFolder:p,cwd:o});return await RE(e,r,{cwd:C,env:h,stdin:n,stdout:u,stderr:A})})}async function Dat(t,{binFolder:e,cwd:r,lifecycleScript:o}){let a=await N1({project:t.project,locator:t.anchoredLocator,binFolder:e,lifecycleScript:o});return await IU(e,await nue(t)),typeof r>"u"&&(r=V.dirname(await oe.realpathPromise(V.join(t.cwd,"package.json")))),{manifest:t.manifest,binFolder:e,env:a,cwd:r}}async function eue(t,{project:e,binFolder:r,cwd:o,lifecycleScript:a}){let n=e.tryWorkspaceByLocator(t);if(n!==null)return Dat(n,{binFolder:r,cwd:o,lifecycleScript:a});let u=e.storedPackages.get(t.locatorHash);if(!u)throw new Error(`Package for ${jr(e.configuration,t)} not found in the project`);return await zl.openPromise(async A=>{let p=e.configuration,h=e.configuration.getLinkers(),C={project:e,report:new Lt({stdout:new fh.PassThrough,configuration:p})},I=h.find(N=>N.supportsPackage(u,C));if(!I)throw new Error(`The package ${jr(e.configuration,u)} isn't supported by any of the available linkers`);let v=await N1({project:e,locator:t,binFolder:r,lifecycleScript:a});await IU(r,await Kx(t,{project:e}));let b=await I.findPackageLocation(u,C),E=new gn(b,{baseFs:A}),F=await Ot.find(Bt.dot,{baseFs:E});return typeof o>"u"&&(o=b),{manifest:F,binFolder:r,env:v,cwd:o}})}async function tue(t,e,r,{cwd:o,stdin:a,stdout:n,stderr:u}){return await Wx(t.anchoredLocator,e,r,{cwd:o,project:t.project,stdin:a,stdout:n,stderr:u})}function CU(t,e){return t.manifest.scripts.has(e)}async function rue(t,e,{cwd:r,report:o}){let{configuration:a}=t.project,n=null;await oe.mktempPromise(async u=>{let A=V.join(u,`${e}.log`),p=`# This file contains the result of Yarn calling the "${e}" lifecycle script inside a workspace ("${fe.fromPortablePath(t.cwd)}") -`,{stdout:h,stderr:C}=a.getSubprocessStreams(A,{report:o,prefix:jr(a,t.anchoredLocator),header:p});o.reportInfo(36,`Calling the "${e}" lifecycle script`);let I=await tue(t,e,[],{cwd:r,stdin:n,stdout:h,stderr:C});if(h.end(),C.end(),I!==0)throw oe.detachTemp(u),new Jt(36,`${(0,Jce.default)(e)} script failed (exit code ${_t(a,I,Et.NUMBER)}, logs can be found here: ${_t(a,A,Et.PATH)}); run ${_t(a,`yarn ${e}`,Et.CODE)} to investigate`)})}async function Pat(t,e,r){CU(t,e)&&await rue(t,e,r)}function wU(t){let e=V.extname(t);if(e.match(/\.[cm]?[jt]sx?$/))return!0;if(e===".exe"||e===".bin")return!1;let r=Buffer.alloc(4),o;try{o=oe.openSync(t,"r")}catch{return!0}try{oe.readSync(o,r,0,r.length,0)}finally{oe.closeSync(o)}let a=r.readUint32BE();return!(a===3405691582||a===3489328638||a===2135247942||(a&4294901760)===1297743872)}async function Kx(t,{project:e}){let r=e.configuration,o=new Map,a=e.storedPackages.get(t.locatorHash);if(!a)throw new Error(`Package for ${jr(r,t)} not found in the project`);let n=new fh.Writable,u=r.getLinkers(),A={project:e,report:new Lt({configuration:r,stdout:n})},p=new Set([t.locatorHash]);for(let C of a.dependencies.values()){let I=e.storedResolutions.get(C.descriptorHash);if(!I)throw new Error(`Assertion failed: The resolution (${qn(r,C)}) should have been registered`);p.add(I)}let h=await Promise.all(Array.from(p,async C=>{let I=e.storedPackages.get(C);if(!I)throw new Error(`Assertion failed: The package (${C}) should have been registered`);if(I.bin.size===0)return sl.skip;let v=u.find(E=>E.supportsPackage(I,A));if(!v)return sl.skip;let b=null;try{b=await v.findPackageLocation(I,A)}catch(E){if(E.code==="LOCATOR_NOT_INSTALLED")return sl.skip;throw E}return{dependency:I,packageLocation:b}}));for(let C of h){if(C===sl.skip)continue;let{dependency:I,packageLocation:v}=C;for(let[b,E]of I.bin){let F=V.resolve(v,E);o.set(b,[I,fe.fromPortablePath(F),wU(F)])}}return o}async function nue(t){return await Kx(t.anchoredLocator,{project:t.project})}async function IU(t,e){await Promise.all(Array.from(e,([r,[,o,a]])=>a?Ah(t,r,process.execPath,[o]):Ah(t,r,o,[])))}async function iue(t,e,r,{cwd:o,project:a,stdin:n,stdout:u,stderr:A,nodeArgs:p=[],packageAccessibleBinaries:h}){h??=await Kx(t,{project:a});let C=h.get(e);if(!C)throw new Error(`Binary not found (${e}) for ${jr(a.configuration,t)}`);return await oe.mktempPromise(async I=>{let[,v]=C,b=await N1({project:a,locator:t,binFolder:I});await IU(b.BERRY_BIN_FOLDER,h);let E=wU(fe.toPortablePath(v))?Gc(process.execPath,[...p,v,...r],{cwd:o,env:b,stdin:n,stdout:u,stderr:A}):Gc(v,r,{cwd:o,env:b,stdin:n,stdout:u,stderr:A}),F;try{F=await E}finally{await oe.removePromise(b.BERRY_BIN_FOLDER)}return F.code})}async function Sat(t,e,r,{cwd:o,stdin:a,stdout:n,stderr:u,packageAccessibleBinaries:A}){return await iue(t.anchoredLocator,e,r,{project:t.project,cwd:o,stdin:a,stdout:n,stderr:u,packageAccessibleBinaries:A})}var Jce,Xce,fh,Zce,wat,Iat,BU=yt(()=>{Pt();Pt();nA();x1();Jce=$e(mU()),Xce=$e(nd()),fh=Be("stream");AE();Yl();L1();T1();Dx();ql();jl();bf();xo();Zce=(a=>(a.Yarn1="Yarn Classic",a.Yarn2="Yarn",a.Npm="npm",a.Pnpm="pnpm",a))(Zce||{});wat=2,Iat=(0,Xce.default)(wat)});var NE=_((k4t,oue)=>{"use strict";var sue=new Map([["C","cwd"],["f","file"],["z","gzip"],["P","preservePaths"],["U","unlink"],["strip-components","strip"],["stripComponents","strip"],["keep-newer","newer"],["keepNewer","newer"],["keep-newer-files","newer"],["keepNewerFiles","newer"],["k","keep"],["keep-existing","keep"],["keepExisting","keep"],["m","noMtime"],["no-mtime","noMtime"],["p","preserveOwner"],["L","follow"],["h","follow"]]);oue.exports=t=>t?Object.keys(t).map(e=>[sue.has(e)?sue.get(e):e,t[e]]).reduce((e,r)=>(e[r[0]]=r[1],e),Object.create(null)):{}});var ME=_((Q4t,gue)=>{"use strict";var aue=typeof process=="object"&&process?process:{stdout:null,stderr:null},xat=Be("events"),lue=Be("stream"),cue=Be("string_decoder").StringDecoder,Nf=Symbol("EOF"),Of=Symbol("maybeEmitEnd"),ph=Symbol("emittedEnd"),Vx=Symbol("emittingEnd"),O1=Symbol("emittedError"),zx=Symbol("closed"),uue=Symbol("read"),Jx=Symbol("flush"),Aue=Symbol("flushChunk"),ka=Symbol("encoding"),Mf=Symbol("decoder"),Xx=Symbol("flowing"),M1=Symbol("paused"),OE=Symbol("resume"),Rs=Symbol("bufferLength"),vU=Symbol("bufferPush"),DU=Symbol("bufferShift"),Fo=Symbol("objectMode"),Ro=Symbol("destroyed"),PU=Symbol("emitData"),fue=Symbol("emitEnd"),SU=Symbol("emitEnd2"),Uf=Symbol("async"),U1=t=>Promise.resolve().then(t),pue=global._MP_NO_ITERATOR_SYMBOLS_!=="1",bat=pue&&Symbol.asyncIterator||Symbol("asyncIterator not implemented"),kat=pue&&Symbol.iterator||Symbol("iterator not implemented"),Qat=t=>t==="end"||t==="finish"||t==="prefinish",Fat=t=>t instanceof ArrayBuffer||typeof t=="object"&&t.constructor&&t.constructor.name==="ArrayBuffer"&&t.byteLength>=0,Rat=t=>!Buffer.isBuffer(t)&&ArrayBuffer.isView(t),Zx=class{constructor(e,r,o){this.src=e,this.dest=r,this.opts=o,this.ondrain=()=>e[OE](),r.on("drain",this.ondrain)}unpipe(){this.dest.removeListener("drain",this.ondrain)}proxyErrors(){}end(){this.unpipe(),this.opts.end&&this.dest.end()}},xU=class extends Zx{unpipe(){this.src.removeListener("error",this.proxyErrors),super.unpipe()}constructor(e,r,o){super(e,r,o),this.proxyErrors=a=>r.emit("error",a),e.on("error",this.proxyErrors)}};gue.exports=class hue extends lue{constructor(e){super(),this[Xx]=!1,this[M1]=!1,this.pipes=[],this.buffer=[],this[Fo]=e&&e.objectMode||!1,this[Fo]?this[ka]=null:this[ka]=e&&e.encoding||null,this[ka]==="buffer"&&(this[ka]=null),this[Uf]=e&&!!e.async||!1,this[Mf]=this[ka]?new cue(this[ka]):null,this[Nf]=!1,this[ph]=!1,this[Vx]=!1,this[zx]=!1,this[O1]=null,this.writable=!0,this.readable=!0,this[Rs]=0,this[Ro]=!1}get bufferLength(){return this[Rs]}get encoding(){return this[ka]}set encoding(e){if(this[Fo])throw new Error("cannot set encoding in objectMode");if(this[ka]&&e!==this[ka]&&(this[Mf]&&this[Mf].lastNeed||this[Rs]))throw new Error("cannot change encoding");this[ka]!==e&&(this[Mf]=e?new cue(e):null,this.buffer.length&&(this.buffer=this.buffer.map(r=>this[Mf].write(r)))),this[ka]=e}setEncoding(e){this.encoding=e}get objectMode(){return this[Fo]}set objectMode(e){this[Fo]=this[Fo]||!!e}get async(){return this[Uf]}set async(e){this[Uf]=this[Uf]||!!e}write(e,r,o){if(this[Nf])throw new Error("write after end");if(this[Ro])return this.emit("error",Object.assign(new Error("Cannot call write after a stream was destroyed"),{code:"ERR_STREAM_DESTROYED"})),!0;typeof r=="function"&&(o=r,r="utf8"),r||(r="utf8");let a=this[Uf]?U1:n=>n();return!this[Fo]&&!Buffer.isBuffer(e)&&(Rat(e)?e=Buffer.from(e.buffer,e.byteOffset,e.byteLength):Fat(e)?e=Buffer.from(e):typeof e!="string"&&(this.objectMode=!0)),this[Fo]?(this.flowing&&this[Rs]!==0&&this[Jx](!0),this.flowing?this.emit("data",e):this[vU](e),this[Rs]!==0&&this.emit("readable"),o&&a(o),this.flowing):e.length?(typeof e=="string"&&!(r===this[ka]&&!this[Mf].lastNeed)&&(e=Buffer.from(e,r)),Buffer.isBuffer(e)&&this[ka]&&(e=this[Mf].write(e)),this.flowing&&this[Rs]!==0&&this[Jx](!0),this.flowing?this.emit("data",e):this[vU](e),this[Rs]!==0&&this.emit("readable"),o&&a(o),this.flowing):(this[Rs]!==0&&this.emit("readable"),o&&a(o),this.flowing)}read(e){if(this[Ro])return null;if(this[Rs]===0||e===0||e>this[Rs])return this[Of](),null;this[Fo]&&(e=null),this.buffer.length>1&&!this[Fo]&&(this.encoding?this.buffer=[this.buffer.join("")]:this.buffer=[Buffer.concat(this.buffer,this[Rs])]);let r=this[uue](e||null,this.buffer[0]);return this[Of](),r}[uue](e,r){return e===r.length||e===null?this[DU]():(this.buffer[0]=r.slice(e),r=r.slice(0,e),this[Rs]-=e),this.emit("data",r),!this.buffer.length&&!this[Nf]&&this.emit("drain"),r}end(e,r,o){return typeof e=="function"&&(o=e,e=null),typeof r=="function"&&(o=r,r="utf8"),e&&this.write(e,r),o&&this.once("end",o),this[Nf]=!0,this.writable=!1,(this.flowing||!this[M1])&&this[Of](),this}[OE](){this[Ro]||(this[M1]=!1,this[Xx]=!0,this.emit("resume"),this.buffer.length?this[Jx]():this[Nf]?this[Of]():this.emit("drain"))}resume(){return this[OE]()}pause(){this[Xx]=!1,this[M1]=!0}get destroyed(){return this[Ro]}get flowing(){return this[Xx]}get paused(){return this[M1]}[vU](e){this[Fo]?this[Rs]+=1:this[Rs]+=e.length,this.buffer.push(e)}[DU](){return this.buffer.length&&(this[Fo]?this[Rs]-=1:this[Rs]-=this.buffer[0].length),this.buffer.shift()}[Jx](e){do;while(this[Aue](this[DU]()));!e&&!this.buffer.length&&!this[Nf]&&this.emit("drain")}[Aue](e){return e?(this.emit("data",e),this.flowing):!1}pipe(e,r){if(this[Ro])return;let o=this[ph];return r=r||{},e===aue.stdout||e===aue.stderr?r.end=!1:r.end=r.end!==!1,r.proxyErrors=!!r.proxyErrors,o?r.end&&e.end():(this.pipes.push(r.proxyErrors?new xU(this,e,r):new Zx(this,e,r)),this[Uf]?U1(()=>this[OE]()):this[OE]()),e}unpipe(e){let r=this.pipes.find(o=>o.dest===e);r&&(this.pipes.splice(this.pipes.indexOf(r),1),r.unpipe())}addListener(e,r){return this.on(e,r)}on(e,r){let o=super.on(e,r);return e==="data"&&!this.pipes.length&&!this.flowing?this[OE]():e==="readable"&&this[Rs]!==0?super.emit("readable"):Qat(e)&&this[ph]?(super.emit(e),this.removeAllListeners(e)):e==="error"&&this[O1]&&(this[Uf]?U1(()=>r.call(this,this[O1])):r.call(this,this[O1])),o}get emittedEnd(){return this[ph]}[Of](){!this[Vx]&&!this[ph]&&!this[Ro]&&this.buffer.length===0&&this[Nf]&&(this[Vx]=!0,this.emit("end"),this.emit("prefinish"),this.emit("finish"),this[zx]&&this.emit("close"),this[Vx]=!1)}emit(e,r,...o){if(e!=="error"&&e!=="close"&&e!==Ro&&this[Ro])return;if(e==="data")return r?this[Uf]?U1(()=>this[PU](r)):this[PU](r):!1;if(e==="end")return this[fue]();if(e==="close"){if(this[zx]=!0,!this[ph]&&!this[Ro])return;let n=super.emit("close");return this.removeAllListeners("close"),n}else if(e==="error"){this[O1]=r;let n=super.emit("error",r);return this[Of](),n}else if(e==="resume"){let n=super.emit("resume");return this[Of](),n}else if(e==="finish"||e==="prefinish"){let n=super.emit(e);return this.removeAllListeners(e),n}let a=super.emit(e,r,...o);return this[Of](),a}[PU](e){for(let o of this.pipes)o.dest.write(e)===!1&&this.pause();let r=super.emit("data",e);return this[Of](),r}[fue](){this[ph]||(this[ph]=!0,this.readable=!1,this[Uf]?U1(()=>this[SU]()):this[SU]())}[SU](){if(this[Mf]){let r=this[Mf].end();if(r){for(let o of this.pipes)o.dest.write(r);super.emit("data",r)}}for(let r of this.pipes)r.end();let e=super.emit("end");return this.removeAllListeners("end"),e}collect(){let e=[];this[Fo]||(e.dataLength=0);let r=this.promise();return this.on("data",o=>{e.push(o),this[Fo]||(e.dataLength+=o.length)}),r.then(()=>e)}concat(){return this[Fo]?Promise.reject(new Error("cannot concat in objectMode")):this.collect().then(e=>this[Fo]?Promise.reject(new Error("cannot concat in objectMode")):this[ka]?e.join(""):Buffer.concat(e,e.dataLength))}promise(){return new Promise((e,r)=>{this.on(Ro,()=>r(new Error("stream destroyed"))),this.on("error",o=>r(o)),this.on("end",()=>e())})}[bat](){return{next:()=>{let r=this.read();if(r!==null)return Promise.resolve({done:!1,value:r});if(this[Nf])return Promise.resolve({done:!0});let o=null,a=null,n=h=>{this.removeListener("data",u),this.removeListener("end",A),a(h)},u=h=>{this.removeListener("error",n),this.removeListener("end",A),this.pause(),o({value:h,done:!!this[Nf]})},A=()=>{this.removeListener("error",n),this.removeListener("data",u),o({done:!0})},p=()=>n(new Error("stream destroyed"));return new Promise((h,C)=>{a=C,o=h,this.once(Ro,p),this.once("error",n),this.once("end",A),this.once("data",u)})}}}[kat](){return{next:()=>{let r=this.read();return{value:r,done:r===null}}}}destroy(e){return this[Ro]?(e?this.emit("error",e):this.emit(Ro),this):(this[Ro]=!0,this.buffer.length=0,this[Rs]=0,typeof this.close=="function"&&!this[zx]&&this.close(),e?this.emit("error",e):this.emit(Ro),this)}static isStream(e){return!!e&&(e instanceof hue||e instanceof lue||e instanceof xat&&(typeof e.pipe=="function"||typeof e.write=="function"&&typeof e.end=="function"))}}});var mue=_((F4t,due)=>{var Tat=Be("zlib").constants||{ZLIB_VERNUM:4736};due.exports=Object.freeze(Object.assign(Object.create(null),{Z_NO_FLUSH:0,Z_PARTIAL_FLUSH:1,Z_SYNC_FLUSH:2,Z_FULL_FLUSH:3,Z_FINISH:4,Z_BLOCK:5,Z_OK:0,Z_STREAM_END:1,Z_NEED_DICT:2,Z_ERRNO:-1,Z_STREAM_ERROR:-2,Z_DATA_ERROR:-3,Z_MEM_ERROR:-4,Z_BUF_ERROR:-5,Z_VERSION_ERROR:-6,Z_NO_COMPRESSION:0,Z_BEST_SPEED:1,Z_BEST_COMPRESSION:9,Z_DEFAULT_COMPRESSION:-1,Z_FILTERED:1,Z_HUFFMAN_ONLY:2,Z_RLE:3,Z_FIXED:4,Z_DEFAULT_STRATEGY:0,DEFLATE:1,INFLATE:2,GZIP:3,GUNZIP:4,DEFLATERAW:5,INFLATERAW:6,UNZIP:7,BROTLI_DECODE:8,BROTLI_ENCODE:9,Z_MIN_WINDOWBITS:8,Z_MAX_WINDOWBITS:15,Z_DEFAULT_WINDOWBITS:15,Z_MIN_CHUNK:64,Z_MAX_CHUNK:1/0,Z_DEFAULT_CHUNK:16384,Z_MIN_MEMLEVEL:1,Z_MAX_MEMLEVEL:9,Z_DEFAULT_MEMLEVEL:8,Z_MIN_LEVEL:-1,Z_MAX_LEVEL:9,Z_DEFAULT_LEVEL:-1,BROTLI_OPERATION_PROCESS:0,BROTLI_OPERATION_FLUSH:1,BROTLI_OPERATION_FINISH:2,BROTLI_OPERATION_EMIT_METADATA:3,BROTLI_MODE_GENERIC:0,BROTLI_MODE_TEXT:1,BROTLI_MODE_FONT:2,BROTLI_DEFAULT_MODE:0,BROTLI_MIN_QUALITY:0,BROTLI_MAX_QUALITY:11,BROTLI_DEFAULT_QUALITY:11,BROTLI_MIN_WINDOW_BITS:10,BROTLI_MAX_WINDOW_BITS:24,BROTLI_LARGE_MAX_WINDOW_BITS:30,BROTLI_DEFAULT_WINDOW:22,BROTLI_MIN_INPUT_BLOCK_BITS:16,BROTLI_MAX_INPUT_BLOCK_BITS:24,BROTLI_PARAM_MODE:0,BROTLI_PARAM_QUALITY:1,BROTLI_PARAM_LGWIN:2,BROTLI_PARAM_LGBLOCK:3,BROTLI_PARAM_DISABLE_LITERAL_CONTEXT_MODELING:4,BROTLI_PARAM_SIZE_HINT:5,BROTLI_PARAM_LARGE_WINDOW:6,BROTLI_PARAM_NPOSTFIX:7,BROTLI_PARAM_NDIRECT:8,BROTLI_DECODER_RESULT_ERROR:0,BROTLI_DECODER_RESULT_SUCCESS:1,BROTLI_DECODER_RESULT_NEEDS_MORE_INPUT:2,BROTLI_DECODER_RESULT_NEEDS_MORE_OUTPUT:3,BROTLI_DECODER_PARAM_DISABLE_RING_BUFFER_REALLOCATION:0,BROTLI_DECODER_PARAM_LARGE_WINDOW:1,BROTLI_DECODER_NO_ERROR:0,BROTLI_DECODER_SUCCESS:1,BROTLI_DECODER_NEEDS_MORE_INPUT:2,BROTLI_DECODER_NEEDS_MORE_OUTPUT:3,BROTLI_DECODER_ERROR_FORMAT_EXUBERANT_NIBBLE:-1,BROTLI_DECODER_ERROR_FORMAT_RESERVED:-2,BROTLI_DECODER_ERROR_FORMAT_EXUBERANT_META_NIBBLE:-3,BROTLI_DECODER_ERROR_FORMAT_SIMPLE_HUFFMAN_ALPHABET:-4,BROTLI_DECODER_ERROR_FORMAT_SIMPLE_HUFFMAN_SAME:-5,BROTLI_DECODER_ERROR_FORMAT_CL_SPACE:-6,BROTLI_DECODER_ERROR_FORMAT_HUFFMAN_SPACE:-7,BROTLI_DECODER_ERROR_FORMAT_CONTEXT_MAP_REPEAT:-8,BROTLI_DECODER_ERROR_FORMAT_BLOCK_LENGTH_1:-9,BROTLI_DECODER_ERROR_FORMAT_BLOCK_LENGTH_2:-10,BROTLI_DECODER_ERROR_FORMAT_TRANSFORM:-11,BROTLI_DECODER_ERROR_FORMAT_DICTIONARY:-12,BROTLI_DECODER_ERROR_FORMAT_WINDOW_BITS:-13,BROTLI_DECODER_ERROR_FORMAT_PADDING_1:-14,BROTLI_DECODER_ERROR_FORMAT_PADDING_2:-15,BROTLI_DECODER_ERROR_FORMAT_DISTANCE:-16,BROTLI_DECODER_ERROR_DICTIONARY_NOT_SET:-19,BROTLI_DECODER_ERROR_INVALID_ARGUMENTS:-20,BROTLI_DECODER_ERROR_ALLOC_CONTEXT_MODES:-21,BROTLI_DECODER_ERROR_ALLOC_TREE_GROUPS:-22,BROTLI_DECODER_ERROR_ALLOC_CONTEXT_MAP:-25,BROTLI_DECODER_ERROR_ALLOC_RING_BUFFER_1:-26,BROTLI_DECODER_ERROR_ALLOC_RING_BUFFER_2:-27,BROTLI_DECODER_ERROR_ALLOC_BLOCK_TYPE_TREES:-30,BROTLI_DECODER_ERROR_UNREACHABLE:-31},Tat))});var GU=_(cl=>{"use strict";var RU=Be("assert"),hh=Be("buffer").Buffer,Cue=Be("zlib"),Qd=cl.constants=mue(),Lat=ME(),yue=hh.concat,Fd=Symbol("_superWrite"),_E=class extends Error{constructor(e){super("zlib: "+e.message),this.code=e.code,this.errno=e.errno,this.code||(this.code="ZLIB_ERROR"),this.message="zlib: "+e.message,Error.captureStackTrace(this,this.constructor)}get name(){return"ZlibError"}},Nat=Symbol("opts"),_1=Symbol("flushFlag"),Eue=Symbol("finishFlushFlag"),qU=Symbol("fullFlushFlag"),ti=Symbol("handle"),$x=Symbol("onError"),UE=Symbol("sawError"),bU=Symbol("level"),kU=Symbol("strategy"),QU=Symbol("ended"),R4t=Symbol("_defaultFullFlush"),eb=class extends Lat{constructor(e,r){if(!e||typeof e!="object")throw new TypeError("invalid options for ZlibBase constructor");super(e),this[UE]=!1,this[QU]=!1,this[Nat]=e,this[_1]=e.flush,this[Eue]=e.finishFlush;try{this[ti]=new Cue[r](e)}catch(o){throw new _E(o)}this[$x]=o=>{this[UE]||(this[UE]=!0,this.close(),this.emit("error",o))},this[ti].on("error",o=>this[$x](new _E(o))),this.once("end",()=>this.close)}close(){this[ti]&&(this[ti].close(),this[ti]=null,this.emit("close"))}reset(){if(!this[UE])return RU(this[ti],"zlib binding closed"),this[ti].reset()}flush(e){this.ended||(typeof e!="number"&&(e=this[qU]),this.write(Object.assign(hh.alloc(0),{[_1]:e})))}end(e,r,o){return e&&this.write(e,r),this.flush(this[Eue]),this[QU]=!0,super.end(null,null,o)}get ended(){return this[QU]}write(e,r,o){if(typeof r=="function"&&(o=r,r="utf8"),typeof e=="string"&&(e=hh.from(e,r)),this[UE])return;RU(this[ti],"zlib binding closed");let a=this[ti]._handle,n=a.close;a.close=()=>{};let u=this[ti].close;this[ti].close=()=>{},hh.concat=h=>h;let A;try{let h=typeof e[_1]=="number"?e[_1]:this[_1];A=this[ti]._processChunk(e,h),hh.concat=yue}catch(h){hh.concat=yue,this[$x](new _E(h))}finally{this[ti]&&(this[ti]._handle=a,a.close=n,this[ti].close=u,this[ti].removeAllListeners("error"))}this[ti]&&this[ti].on("error",h=>this[$x](new _E(h)));let p;if(A)if(Array.isArray(A)&&A.length>0){p=this[Fd](hh.from(A[0]));for(let h=1;h<A.length;h++)p=this[Fd](A[h])}else p=this[Fd](hh.from(A));return o&&o(),p}[Fd](e){return super.write(e)}},_f=class extends eb{constructor(e,r){e=e||{},e.flush=e.flush||Qd.Z_NO_FLUSH,e.finishFlush=e.finishFlush||Qd.Z_FINISH,super(e,r),this[qU]=Qd.Z_FULL_FLUSH,this[bU]=e.level,this[kU]=e.strategy}params(e,r){if(!this[UE]){if(!this[ti])throw new Error("cannot switch params when binding is closed");if(!this[ti].params)throw new Error("not supported in this implementation");if(this[bU]!==e||this[kU]!==r){this.flush(Qd.Z_SYNC_FLUSH),RU(this[ti],"zlib binding closed");let o=this[ti].flush;this[ti].flush=(a,n)=>{this.flush(a),n()};try{this[ti].params(e,r)}finally{this[ti].flush=o}this[ti]&&(this[bU]=e,this[kU]=r)}}}},TU=class extends _f{constructor(e){super(e,"Deflate")}},LU=class extends _f{constructor(e){super(e,"Inflate")}},FU=Symbol("_portable"),NU=class extends _f{constructor(e){super(e,"Gzip"),this[FU]=e&&!!e.portable}[Fd](e){return this[FU]?(this[FU]=!1,e[9]=255,super[Fd](e)):super[Fd](e)}},OU=class extends _f{constructor(e){super(e,"Gunzip")}},MU=class extends _f{constructor(e){super(e,"DeflateRaw")}},UU=class extends _f{constructor(e){super(e,"InflateRaw")}},_U=class extends _f{constructor(e){super(e,"Unzip")}},tb=class extends eb{constructor(e,r){e=e||{},e.flush=e.flush||Qd.BROTLI_OPERATION_PROCESS,e.finishFlush=e.finishFlush||Qd.BROTLI_OPERATION_FINISH,super(e,r),this[qU]=Qd.BROTLI_OPERATION_FLUSH}},HU=class extends tb{constructor(e){super(e,"BrotliCompress")}},jU=class extends tb{constructor(e){super(e,"BrotliDecompress")}};cl.Deflate=TU;cl.Inflate=LU;cl.Gzip=NU;cl.Gunzip=OU;cl.DeflateRaw=MU;cl.InflateRaw=UU;cl.Unzip=_U;typeof Cue.BrotliCompress=="function"?(cl.BrotliCompress=HU,cl.BrotliDecompress=jU):cl.BrotliCompress=cl.BrotliDecompress=class{constructor(){throw new Error("Brotli is not supported in this version of Node.js")}}});var HE=_((N4t,wue)=>{var Oat=process.env.TESTING_TAR_FAKE_PLATFORM||process.platform;wue.exports=Oat!=="win32"?t=>t:t=>t&&t.replace(/\\/g,"/")});var rb=_((M4t,Iue)=>{"use strict";var Mat=ME(),YU=HE(),WU=Symbol("slurp");Iue.exports=class extends Mat{constructor(e,r,o){switch(super(),this.pause(),this.extended=r,this.globalExtended=o,this.header=e,this.startBlockSize=512*Math.ceil(e.size/512),this.blockRemain=this.startBlockSize,this.remain=e.size,this.type=e.type,this.meta=!1,this.ignore=!1,this.type){case"File":case"OldFile":case"Link":case"SymbolicLink":case"CharacterDevice":case"BlockDevice":case"Directory":case"FIFO":case"ContiguousFile":case"GNUDumpDir":break;case"NextFileHasLongLinkpath":case"NextFileHasLongPath":case"OldGnuLongPath":case"GlobalExtendedHeader":case"ExtendedHeader":case"OldExtendedHeader":this.meta=!0;break;default:this.ignore=!0}this.path=YU(e.path),this.mode=e.mode,this.mode&&(this.mode=this.mode&4095),this.uid=e.uid,this.gid=e.gid,this.uname=e.uname,this.gname=e.gname,this.size=e.size,this.mtime=e.mtime,this.atime=e.atime,this.ctime=e.ctime,this.linkpath=YU(e.linkpath),this.uname=e.uname,this.gname=e.gname,r&&this[WU](r),o&&this[WU](o,!0)}write(e){let r=e.length;if(r>this.blockRemain)throw new Error("writing more to entry than is appropriate");let o=this.remain,a=this.blockRemain;return this.remain=Math.max(0,o-r),this.blockRemain=Math.max(0,a-r),this.ignore?!0:o>=r?super.write(e):super.write(e.slice(0,o))}[WU](e,r){for(let o in e)e[o]!==null&&e[o]!==void 0&&!(r&&o==="path")&&(this[o]=o==="path"||o==="linkpath"?YU(e[o]):e[o])}}});var KU=_(nb=>{"use strict";nb.name=new Map([["0","File"],["","OldFile"],["1","Link"],["2","SymbolicLink"],["3","CharacterDevice"],["4","BlockDevice"],["5","Directory"],["6","FIFO"],["7","ContiguousFile"],["g","GlobalExtendedHeader"],["x","ExtendedHeader"],["A","SolarisACL"],["D","GNUDumpDir"],["I","Inode"],["K","NextFileHasLongLinkpath"],["L","NextFileHasLongPath"],["M","ContinuationFile"],["N","OldGnuLongPath"],["S","SparseFile"],["V","TapeVolumeHeader"],["X","OldExtendedHeader"]]);nb.code=new Map(Array.from(nb.name).map(t=>[t[1],t[0]]))});var Pue=_((_4t,Due)=>{"use strict";var Uat=(t,e)=>{if(Number.isSafeInteger(t))t<0?Hat(t,e):_at(t,e);else throw Error("cannot encode number outside of javascript safe integer range");return e},_at=(t,e)=>{e[0]=128;for(var r=e.length;r>1;r--)e[r-1]=t&255,t=Math.floor(t/256)},Hat=(t,e)=>{e[0]=255;var r=!1;t=t*-1;for(var o=e.length;o>1;o--){var a=t&255;t=Math.floor(t/256),r?e[o-1]=Bue(a):a===0?e[o-1]=0:(r=!0,e[o-1]=vue(a))}},jat=t=>{let e=t[0],r=e===128?Gat(t.slice(1,t.length)):e===255?qat(t):null;if(r===null)throw Error("invalid base256 encoding");if(!Number.isSafeInteger(r))throw Error("parsed number outside of javascript safe integer range");return r},qat=t=>{for(var e=t.length,r=0,o=!1,a=e-1;a>-1;a--){var n=t[a],u;o?u=Bue(n):n===0?u=n:(o=!0,u=vue(n)),u!==0&&(r-=u*Math.pow(256,e-a-1))}return r},Gat=t=>{for(var e=t.length,r=0,o=e-1;o>-1;o--){var a=t[o];a!==0&&(r+=a*Math.pow(256,e-o-1))}return r},Bue=t=>(255^t)&255,vue=t=>(255^t)+1&255;Due.exports={encode:Uat,parse:jat}});var qE=_((H4t,xue)=>{"use strict";var VU=KU(),jE=Be("path").posix,Sue=Pue(),zU=Symbol("slurp"),ul=Symbol("type"),ZU=class{constructor(e,r,o,a){this.cksumValid=!1,this.needPax=!1,this.nullBlock=!1,this.block=null,this.path=null,this.mode=null,this.uid=null,this.gid=null,this.size=null,this.mtime=null,this.cksum=null,this[ul]="0",this.linkpath=null,this.uname=null,this.gname=null,this.devmaj=0,this.devmin=0,this.atime=null,this.ctime=null,Buffer.isBuffer(e)?this.decode(e,r||0,o,a):e&&this.set(e)}decode(e,r,o,a){if(r||(r=0),!e||!(e.length>=r+512))throw new Error("need 512 bytes for header");if(this.path=Rd(e,r,100),this.mode=gh(e,r+100,8),this.uid=gh(e,r+108,8),this.gid=gh(e,r+116,8),this.size=gh(e,r+124,12),this.mtime=JU(e,r+136,12),this.cksum=gh(e,r+148,12),this[zU](o),this[zU](a,!0),this[ul]=Rd(e,r+156,1),this[ul]===""&&(this[ul]="0"),this[ul]==="0"&&this.path.substr(-1)==="/"&&(this[ul]="5"),this[ul]==="5"&&(this.size=0),this.linkpath=Rd(e,r+157,100),e.slice(r+257,r+265).toString()==="ustar\x0000")if(this.uname=Rd(e,r+265,32),this.gname=Rd(e,r+297,32),this.devmaj=gh(e,r+329,8),this.devmin=gh(e,r+337,8),e[r+475]!==0){let u=Rd(e,r+345,155);this.path=u+"/"+this.path}else{let u=Rd(e,r+345,130);u&&(this.path=u+"/"+this.path),this.atime=JU(e,r+476,12),this.ctime=JU(e,r+488,12)}let n=8*32;for(let u=r;u<r+148;u++)n+=e[u];for(let u=r+156;u<r+512;u++)n+=e[u];this.cksumValid=n===this.cksum,this.cksum===null&&n===8*32&&(this.nullBlock=!0)}[zU](e,r){for(let o in e)e[o]!==null&&e[o]!==void 0&&!(r&&o==="path")&&(this[o]=e[o])}encode(e,r){if(e||(e=this.block=Buffer.alloc(512),r=0),r||(r=0),!(e.length>=r+512))throw new Error("need 512 bytes for header");let o=this.ctime||this.atime?130:155,a=Yat(this.path||"",o),n=a[0],u=a[1];this.needPax=a[2],this.needPax=Td(e,r,100,n)||this.needPax,this.needPax=dh(e,r+100,8,this.mode)||this.needPax,this.needPax=dh(e,r+108,8,this.uid)||this.needPax,this.needPax=dh(e,r+116,8,this.gid)||this.needPax,this.needPax=dh(e,r+124,12,this.size)||this.needPax,this.needPax=XU(e,r+136,12,this.mtime)||this.needPax,e[r+156]=this[ul].charCodeAt(0),this.needPax=Td(e,r+157,100,this.linkpath)||this.needPax,e.write("ustar\x0000",r+257,8),this.needPax=Td(e,r+265,32,this.uname)||this.needPax,this.needPax=Td(e,r+297,32,this.gname)||this.needPax,this.needPax=dh(e,r+329,8,this.devmaj)||this.needPax,this.needPax=dh(e,r+337,8,this.devmin)||this.needPax,this.needPax=Td(e,r+345,o,u)||this.needPax,e[r+475]!==0?this.needPax=Td(e,r+345,155,u)||this.needPax:(this.needPax=Td(e,r+345,130,u)||this.needPax,this.needPax=XU(e,r+476,12,this.atime)||this.needPax,this.needPax=XU(e,r+488,12,this.ctime)||this.needPax);let A=8*32;for(let p=r;p<r+148;p++)A+=e[p];for(let p=r+156;p<r+512;p++)A+=e[p];return this.cksum=A,dh(e,r+148,8,this.cksum),this.cksumValid=!0,this.needPax}set(e){for(let r in e)e[r]!==null&&e[r]!==void 0&&(this[r]=e[r])}get type(){return VU.name.get(this[ul])||this[ul]}get typeKey(){return this[ul]}set type(e){VU.code.has(e)?this[ul]=VU.code.get(e):this[ul]=e}},Yat=(t,e)=>{let o=t,a="",n,u=jE.parse(t).root||".";if(Buffer.byteLength(o)<100)n=[o,a,!1];else{a=jE.dirname(o),o=jE.basename(o);do Buffer.byteLength(o)<=100&&Buffer.byteLength(a)<=e?n=[o,a,!1]:Buffer.byteLength(o)>100&&Buffer.byteLength(a)<=e?n=[o.substr(0,100-1),a,!0]:(o=jE.join(jE.basename(a),o),a=jE.dirname(a));while(a!==u&&!n);n||(n=[t.substr(0,100-1),"",!0])}return n},Rd=(t,e,r)=>t.slice(e,e+r).toString("utf8").replace(/\0.*/,""),JU=(t,e,r)=>Wat(gh(t,e,r)),Wat=t=>t===null?null:new Date(t*1e3),gh=(t,e,r)=>t[e]&128?Sue.parse(t.slice(e,e+r)):Vat(t,e,r),Kat=t=>isNaN(t)?null:t,Vat=(t,e,r)=>Kat(parseInt(t.slice(e,e+r).toString("utf8").replace(/\0.*$/,"").trim(),8)),zat={12:8589934591,8:2097151},dh=(t,e,r,o)=>o===null?!1:o>zat[r]||o<0?(Sue.encode(o,t.slice(e,e+r)),!0):(Jat(t,e,r,o),!1),Jat=(t,e,r,o)=>t.write(Xat(o,r),e,r,"ascii"),Xat=(t,e)=>Zat(Math.floor(t).toString(8),e),Zat=(t,e)=>(t.length===e-1?t:new Array(e-t.length-1).join("0")+t+" ")+"\0",XU=(t,e,r,o)=>o===null?!1:dh(t,e,r,o.getTime()/1e3),$at=new Array(156).join("\0"),Td=(t,e,r,o)=>o===null?!1:(t.write(o+$at,e,r,"utf8"),o.length!==Buffer.byteLength(o)||o.length>r);xue.exports=ZU});var ib=_((j4t,bue)=>{"use strict";var elt=qE(),tlt=Be("path"),H1=class{constructor(e,r){this.atime=e.atime||null,this.charset=e.charset||null,this.comment=e.comment||null,this.ctime=e.ctime||null,this.gid=e.gid||null,this.gname=e.gname||null,this.linkpath=e.linkpath||null,this.mtime=e.mtime||null,this.path=e.path||null,this.size=e.size||null,this.uid=e.uid||null,this.uname=e.uname||null,this.dev=e.dev||null,this.ino=e.ino||null,this.nlink=e.nlink||null,this.global=r||!1}encode(){let e=this.encodeBody();if(e==="")return null;let r=Buffer.byteLength(e),o=512*Math.ceil(1+r/512),a=Buffer.allocUnsafe(o);for(let n=0;n<512;n++)a[n]=0;new elt({path:("PaxHeader/"+tlt.basename(this.path)).slice(0,99),mode:this.mode||420,uid:this.uid||null,gid:this.gid||null,size:r,mtime:this.mtime||null,type:this.global?"GlobalExtendedHeader":"ExtendedHeader",linkpath:"",uname:this.uname||"",gname:this.gname||"",devmaj:0,devmin:0,atime:this.atime||null,ctime:this.ctime||null}).encode(a),a.write(e,512,r,"utf8");for(let n=r+512;n<a.length;n++)a[n]=0;return a}encodeBody(){return this.encodeField("path")+this.encodeField("ctime")+this.encodeField("atime")+this.encodeField("dev")+this.encodeField("ino")+this.encodeField("nlink")+this.encodeField("charset")+this.encodeField("comment")+this.encodeField("gid")+this.encodeField("gname")+this.encodeField("linkpath")+this.encodeField("mtime")+this.encodeField("size")+this.encodeField("uid")+this.encodeField("uname")}encodeField(e){if(this[e]===null||this[e]===void 0)return"";let r=this[e]instanceof Date?this[e].getTime()/1e3:this[e],o=" "+(e==="dev"||e==="ino"||e==="nlink"?"SCHILY.":"")+e+"="+r+` -`,a=Buffer.byteLength(o),n=Math.floor(Math.log(a)/Math.log(10))+1;return a+n>=Math.pow(10,n)&&(n+=1),n+a+o}};H1.parse=(t,e,r)=>new H1(rlt(nlt(t),e),r);var rlt=(t,e)=>e?Object.keys(t).reduce((r,o)=>(r[o]=t[o],r),e):t,nlt=t=>t.replace(/\n$/,"").split(` -`).reduce(ilt,Object.create(null)),ilt=(t,e)=>{let r=parseInt(e,10);if(r!==Buffer.byteLength(e)+1)return t;e=e.substr((r+" ").length);let o=e.split("="),a=o.shift().replace(/^SCHILY\.(dev|ino|nlink)/,"$1");if(!a)return t;let n=o.join("=");return t[a]=/^([A-Z]+\.)?([mac]|birth|creation)time$/.test(a)?new Date(n*1e3):/^[0-9]+$/.test(n)?+n:n,t};bue.exports=H1});var GE=_((q4t,kue)=>{kue.exports=t=>{let e=t.length-1,r=-1;for(;e>-1&&t.charAt(e)==="/";)r=e,e--;return r===-1?t:t.slice(0,r)}});var sb=_((G4t,Que)=>{"use strict";Que.exports=t=>class extends t{warn(e,r,o={}){this.file&&(o.file=this.file),this.cwd&&(o.cwd=this.cwd),o.code=r instanceof Error&&r.code||e,o.tarCode=e,!this.strict&&o.recoverable!==!1?(r instanceof Error&&(o=Object.assign(r,o),r=r.message),this.emit("warn",o.tarCode,r,o)):r instanceof Error?this.emit("error",Object.assign(r,o)):this.emit("error",Object.assign(new Error(`${e}: ${r}`),o))}}});var e3=_((W4t,Fue)=>{"use strict";var ob=["|","<",">","?",":"],$U=ob.map(t=>String.fromCharCode(61440+t.charCodeAt(0))),slt=new Map(ob.map((t,e)=>[t,$U[e]])),olt=new Map($U.map((t,e)=>[t,ob[e]]));Fue.exports={encode:t=>ob.reduce((e,r)=>e.split(r).join(slt.get(r)),t),decode:t=>$U.reduce((e,r)=>e.split(r).join(olt.get(r)),t)}});var t3=_((K4t,Tue)=>{var{isAbsolute:alt,parse:Rue}=Be("path").win32;Tue.exports=t=>{let e="",r=Rue(t);for(;alt(t)||r.root;){let o=t.charAt(0)==="/"&&t.slice(0,4)!=="//?/"?"/":r.root;t=t.substr(o.length),e+=o,r=Rue(t)}return[e,t]}});var Nue=_((V4t,Lue)=>{"use strict";Lue.exports=(t,e,r)=>(t&=4095,r&&(t=(t|384)&-19),e&&(t&256&&(t|=64),t&32&&(t|=8),t&4&&(t|=1)),t)});var A3=_((X4t,Jue)=>{"use strict";var que=ME(),Gue=ib(),Yue=qE(),oA=Be("fs"),Oue=Be("path"),sA=HE(),llt=GE(),Wue=(t,e)=>e?(t=sA(t).replace(/^\.(\/|$)/,""),llt(e)+"/"+t):sA(t),clt=16*1024*1024,Mue=Symbol("process"),Uue=Symbol("file"),_ue=Symbol("directory"),n3=Symbol("symlink"),Hue=Symbol("hardlink"),j1=Symbol("header"),ab=Symbol("read"),i3=Symbol("lstat"),lb=Symbol("onlstat"),s3=Symbol("onread"),o3=Symbol("onreadlink"),a3=Symbol("openfile"),l3=Symbol("onopenfile"),mh=Symbol("close"),ub=Symbol("mode"),c3=Symbol("awaitDrain"),r3=Symbol("ondrain"),aA=Symbol("prefix"),jue=Symbol("hadError"),Kue=sb(),ult=e3(),Vue=t3(),zue=Nue(),Ab=Kue(class extends que{constructor(e,r){if(r=r||{},super(r),typeof e!="string")throw new TypeError("path is required");this.path=sA(e),this.portable=!!r.portable,this.myuid=process.getuid&&process.getuid()||0,this.myuser=process.env.USER||"",this.maxReadSize=r.maxReadSize||clt,this.linkCache=r.linkCache||new Map,this.statCache=r.statCache||new Map,this.preservePaths=!!r.preservePaths,this.cwd=sA(r.cwd||process.cwd()),this.strict=!!r.strict,this.noPax=!!r.noPax,this.noMtime=!!r.noMtime,this.mtime=r.mtime||null,this.prefix=r.prefix?sA(r.prefix):null,this.fd=null,this.blockLen=null,this.blockRemain=null,this.buf=null,this.offset=null,this.length=null,this.pos=null,this.remain=null,typeof r.onwarn=="function"&&this.on("warn",r.onwarn);let o=!1;if(!this.preservePaths){let[a,n]=Vue(this.path);a&&(this.path=n,o=a)}this.win32=!!r.win32||process.platform==="win32",this.win32&&(this.path=ult.decode(this.path.replace(/\\/g,"/")),e=e.replace(/\\/g,"/")),this.absolute=sA(r.absolute||Oue.resolve(this.cwd,e)),this.path===""&&(this.path="./"),o&&this.warn("TAR_ENTRY_INFO",`stripping ${o} from absolute path`,{entry:this,path:o+this.path}),this.statCache.has(this.absolute)?this[lb](this.statCache.get(this.absolute)):this[i3]()}emit(e,...r){return e==="error"&&(this[jue]=!0),super.emit(e,...r)}[i3](){oA.lstat(this.absolute,(e,r)=>{if(e)return this.emit("error",e);this[lb](r)})}[lb](e){this.statCache.set(this.absolute,e),this.stat=e,e.isFile()||(e.size=0),this.type=flt(e),this.emit("stat",e),this[Mue]()}[Mue](){switch(this.type){case"File":return this[Uue]();case"Directory":return this[_ue]();case"SymbolicLink":return this[n3]();default:return this.end()}}[ub](e){return zue(e,this.type==="Directory",this.portable)}[aA](e){return Wue(e,this.prefix)}[j1](){this.type==="Directory"&&this.portable&&(this.noMtime=!0),this.header=new Yue({path:this[aA](this.path),linkpath:this.type==="Link"?this[aA](this.linkpath):this.linkpath,mode:this[ub](this.stat.mode),uid:this.portable?null:this.stat.uid,gid:this.portable?null:this.stat.gid,size:this.stat.size,mtime:this.noMtime?null:this.mtime||this.stat.mtime,type:this.type,uname:this.portable?null:this.stat.uid===this.myuid?this.myuser:"",atime:this.portable?null:this.stat.atime,ctime:this.portable?null:this.stat.ctime}),this.header.encode()&&!this.noPax&&super.write(new Gue({atime:this.portable?null:this.header.atime,ctime:this.portable?null:this.header.ctime,gid:this.portable?null:this.header.gid,mtime:this.noMtime?null:this.mtime||this.header.mtime,path:this[aA](this.path),linkpath:this.type==="Link"?this[aA](this.linkpath):this.linkpath,size:this.header.size,uid:this.portable?null:this.header.uid,uname:this.portable?null:this.header.uname,dev:this.portable?null:this.stat.dev,ino:this.portable?null:this.stat.ino,nlink:this.portable?null:this.stat.nlink}).encode()),super.write(this.header.block)}[_ue](){this.path.substr(-1)!=="/"&&(this.path+="/"),this.stat.size=0,this[j1](),this.end()}[n3](){oA.readlink(this.absolute,(e,r)=>{if(e)return this.emit("error",e);this[o3](r)})}[o3](e){this.linkpath=sA(e),this[j1](),this.end()}[Hue](e){this.type="Link",this.linkpath=sA(Oue.relative(this.cwd,e)),this.stat.size=0,this[j1](),this.end()}[Uue](){if(this.stat.nlink>1){let e=this.stat.dev+":"+this.stat.ino;if(this.linkCache.has(e)){let r=this.linkCache.get(e);if(r.indexOf(this.cwd)===0)return this[Hue](r)}this.linkCache.set(e,this.absolute)}if(this[j1](),this.stat.size===0)return this.end();this[a3]()}[a3](){oA.open(this.absolute,"r",(e,r)=>{if(e)return this.emit("error",e);this[l3](r)})}[l3](e){if(this.fd=e,this[jue])return this[mh]();this.blockLen=512*Math.ceil(this.stat.size/512),this.blockRemain=this.blockLen;let r=Math.min(this.blockLen,this.maxReadSize);this.buf=Buffer.allocUnsafe(r),this.offset=0,this.pos=0,this.remain=this.stat.size,this.length=this.buf.length,this[ab]()}[ab](){let{fd:e,buf:r,offset:o,length:a,pos:n}=this;oA.read(e,r,o,a,n,(u,A)=>{if(u)return this[mh](()=>this.emit("error",u));this[s3](A)})}[mh](e){oA.close(this.fd,e)}[s3](e){if(e<=0&&this.remain>0){let a=new Error("encountered unexpected EOF");return a.path=this.absolute,a.syscall="read",a.code="EOF",this[mh](()=>this.emit("error",a))}if(e>this.remain){let a=new Error("did not encounter expected EOF");return a.path=this.absolute,a.syscall="read",a.code="EOF",this[mh](()=>this.emit("error",a))}if(e===this.remain)for(let a=e;a<this.length&&e<this.blockRemain;a++)this.buf[a+this.offset]=0,e++,this.remain++;let r=this.offset===0&&e===this.buf.length?this.buf:this.buf.slice(this.offset,this.offset+e);this.write(r)?this[r3]():this[c3](()=>this[r3]())}[c3](e){this.once("drain",e)}write(e){if(this.blockRemain<e.length){let r=new Error("writing more data than expected");return r.path=this.absolute,this.emit("error",r)}return this.remain-=e.length,this.blockRemain-=e.length,this.pos+=e.length,this.offset+=e.length,super.write(e)}[r3](){if(!this.remain)return this.blockRemain&&super.write(Buffer.alloc(this.blockRemain)),this[mh](e=>e?this.emit("error",e):this.end());this.offset>=this.length&&(this.buf=Buffer.allocUnsafe(Math.min(this.blockRemain,this.buf.length)),this.offset=0),this.length=this.buf.length-this.offset,this[ab]()}}),u3=class extends Ab{[i3](){this[lb](oA.lstatSync(this.absolute))}[n3](){this[o3](oA.readlinkSync(this.absolute))}[a3](){this[l3](oA.openSync(this.absolute,"r"))}[ab](){let e=!0;try{let{fd:r,buf:o,offset:a,length:n,pos:u}=this,A=oA.readSync(r,o,a,n,u);this[s3](A),e=!1}finally{if(e)try{this[mh](()=>{})}catch{}}}[c3](e){e()}[mh](e){oA.closeSync(this.fd),e()}},Alt=Kue(class extends que{constructor(e,r){r=r||{},super(r),this.preservePaths=!!r.preservePaths,this.portable=!!r.portable,this.strict=!!r.strict,this.noPax=!!r.noPax,this.noMtime=!!r.noMtime,this.readEntry=e,this.type=e.type,this.type==="Directory"&&this.portable&&(this.noMtime=!0),this.prefix=r.prefix||null,this.path=sA(e.path),this.mode=this[ub](e.mode),this.uid=this.portable?null:e.uid,this.gid=this.portable?null:e.gid,this.uname=this.portable?null:e.uname,this.gname=this.portable?null:e.gname,this.size=e.size,this.mtime=this.noMtime?null:r.mtime||e.mtime,this.atime=this.portable?null:e.atime,this.ctime=this.portable?null:e.ctime,this.linkpath=sA(e.linkpath),typeof r.onwarn=="function"&&this.on("warn",r.onwarn);let o=!1;if(!this.preservePaths){let[a,n]=Vue(this.path);a&&(this.path=n,o=a)}this.remain=e.size,this.blockRemain=e.startBlockSize,this.header=new Yue({path:this[aA](this.path),linkpath:this.type==="Link"?this[aA](this.linkpath):this.linkpath,mode:this.mode,uid:this.portable?null:this.uid,gid:this.portable?null:this.gid,size:this.size,mtime:this.noMtime?null:this.mtime,type:this.type,uname:this.portable?null:this.uname,atime:this.portable?null:this.atime,ctime:this.portable?null:this.ctime}),o&&this.warn("TAR_ENTRY_INFO",`stripping ${o} from absolute path`,{entry:this,path:o+this.path}),this.header.encode()&&!this.noPax&&super.write(new Gue({atime:this.portable?null:this.atime,ctime:this.portable?null:this.ctime,gid:this.portable?null:this.gid,mtime:this.noMtime?null:this.mtime,path:this[aA](this.path),linkpath:this.type==="Link"?this[aA](this.linkpath):this.linkpath,size:this.size,uid:this.portable?null:this.uid,uname:this.portable?null:this.uname,dev:this.portable?null:this.readEntry.dev,ino:this.portable?null:this.readEntry.ino,nlink:this.portable?null:this.readEntry.nlink}).encode()),super.write(this.header.block),e.pipe(this)}[aA](e){return Wue(e,this.prefix)}[ub](e){return zue(e,this.type==="Directory",this.portable)}write(e){let r=e.length;if(r>this.blockRemain)throw new Error("writing more to entry than is appropriate");return this.blockRemain-=r,super.write(e)}end(){return this.blockRemain&&super.write(Buffer.alloc(this.blockRemain)),super.end()}});Ab.Sync=u3;Ab.Tar=Alt;var flt=t=>t.isFile()?"File":t.isDirectory()?"Directory":t.isSymbolicLink()?"SymbolicLink":"Unsupported";Jue.exports=Ab});var Cb=_(($4t,nAe)=>{"use strict";var yb=class{constructor(e,r){this.path=e||"./",this.absolute=r,this.entry=null,this.stat=null,this.readdir=null,this.pending=!1,this.ignore=!1,this.piped=!1}},plt=ME(),hlt=GU(),glt=rb(),C3=A3(),dlt=C3.Sync,mlt=C3.Tar,ylt=IP(),Xue=Buffer.alloc(1024),hb=Symbol("onStat"),fb=Symbol("ended"),lA=Symbol("queue"),YE=Symbol("current"),Ld=Symbol("process"),pb=Symbol("processing"),Zue=Symbol("processJob"),cA=Symbol("jobs"),f3=Symbol("jobDone"),gb=Symbol("addFSEntry"),$ue=Symbol("addTarEntry"),d3=Symbol("stat"),m3=Symbol("readdir"),db=Symbol("onreaddir"),mb=Symbol("pipe"),eAe=Symbol("entry"),p3=Symbol("entryOpt"),y3=Symbol("writeEntryClass"),rAe=Symbol("write"),h3=Symbol("ondrain"),Eb=Be("fs"),tAe=Be("path"),Elt=sb(),g3=HE(),w3=Elt(class extends plt{constructor(e){super(e),e=e||Object.create(null),this.opt=e,this.file=e.file||"",this.cwd=e.cwd||process.cwd(),this.maxReadSize=e.maxReadSize,this.preservePaths=!!e.preservePaths,this.strict=!!e.strict,this.noPax=!!e.noPax,this.prefix=g3(e.prefix||""),this.linkCache=e.linkCache||new Map,this.statCache=e.statCache||new Map,this.readdirCache=e.readdirCache||new Map,this[y3]=C3,typeof e.onwarn=="function"&&this.on("warn",e.onwarn),this.portable=!!e.portable,this.zip=null,e.gzip?(typeof e.gzip!="object"&&(e.gzip={}),this.portable&&(e.gzip.portable=!0),this.zip=new hlt.Gzip(e.gzip),this.zip.on("data",r=>super.write(r)),this.zip.on("end",r=>super.end()),this.zip.on("drain",r=>this[h3]()),this.on("resume",r=>this.zip.resume())):this.on("drain",this[h3]),this.noDirRecurse=!!e.noDirRecurse,this.follow=!!e.follow,this.noMtime=!!e.noMtime,this.mtime=e.mtime||null,this.filter=typeof e.filter=="function"?e.filter:r=>!0,this[lA]=new ylt,this[cA]=0,this.jobs=+e.jobs||4,this[pb]=!1,this[fb]=!1}[rAe](e){return super.write(e)}add(e){return this.write(e),this}end(e){return e&&this.write(e),this[fb]=!0,this[Ld](),this}write(e){if(this[fb])throw new Error("write after end");return e instanceof glt?this[$ue](e):this[gb](e),this.flowing}[$ue](e){let r=g3(tAe.resolve(this.cwd,e.path));if(!this.filter(e.path,e))e.resume();else{let o=new yb(e.path,r,!1);o.entry=new mlt(e,this[p3](o)),o.entry.on("end",a=>this[f3](o)),this[cA]+=1,this[lA].push(o)}this[Ld]()}[gb](e){let r=g3(tAe.resolve(this.cwd,e));this[lA].push(new yb(e,r)),this[Ld]()}[d3](e){e.pending=!0,this[cA]+=1;let r=this.follow?"stat":"lstat";Eb[r](e.absolute,(o,a)=>{e.pending=!1,this[cA]-=1,o?this.emit("error",o):this[hb](e,a)})}[hb](e,r){this.statCache.set(e.absolute,r),e.stat=r,this.filter(e.path,r)||(e.ignore=!0),this[Ld]()}[m3](e){e.pending=!0,this[cA]+=1,Eb.readdir(e.absolute,(r,o)=>{if(e.pending=!1,this[cA]-=1,r)return this.emit("error",r);this[db](e,o)})}[db](e,r){this.readdirCache.set(e.absolute,r),e.readdir=r,this[Ld]()}[Ld](){if(!this[pb]){this[pb]=!0;for(let e=this[lA].head;e!==null&&this[cA]<this.jobs;e=e.next)if(this[Zue](e.value),e.value.ignore){let r=e.next;this[lA].removeNode(e),e.next=r}this[pb]=!1,this[fb]&&!this[lA].length&&this[cA]===0&&(this.zip?this.zip.end(Xue):(super.write(Xue),super.end()))}}get[YE](){return this[lA]&&this[lA].head&&this[lA].head.value}[f3](e){this[lA].shift(),this[cA]-=1,this[Ld]()}[Zue](e){if(!e.pending){if(e.entry){e===this[YE]&&!e.piped&&this[mb](e);return}if(e.stat||(this.statCache.has(e.absolute)?this[hb](e,this.statCache.get(e.absolute)):this[d3](e)),!!e.stat&&!e.ignore&&!(!this.noDirRecurse&&e.stat.isDirectory()&&!e.readdir&&(this.readdirCache.has(e.absolute)?this[db](e,this.readdirCache.get(e.absolute)):this[m3](e),!e.readdir))){if(e.entry=this[eAe](e),!e.entry){e.ignore=!0;return}e===this[YE]&&!e.piped&&this[mb](e)}}}[p3](e){return{onwarn:(r,o,a)=>this.warn(r,o,a),noPax:this.noPax,cwd:this.cwd,absolute:e.absolute,preservePaths:this.preservePaths,maxReadSize:this.maxReadSize,strict:this.strict,portable:this.portable,linkCache:this.linkCache,statCache:this.statCache,noMtime:this.noMtime,mtime:this.mtime,prefix:this.prefix}}[eAe](e){this[cA]+=1;try{return new this[y3](e.path,this[p3](e)).on("end",()=>this[f3](e)).on("error",r=>this.emit("error",r))}catch(r){this.emit("error",r)}}[h3](){this[YE]&&this[YE].entry&&this[YE].entry.resume()}[mb](e){e.piped=!0,e.readdir&&e.readdir.forEach(a=>{let n=e.path,u=n==="./"?"":n.replace(/\/*$/,"/");this[gb](u+a)});let r=e.entry,o=this.zip;o?r.on("data",a=>{o.write(a)||r.pause()}):r.on("data",a=>{super.write(a)||r.pause()})}pause(){return this.zip&&this.zip.pause(),super.pause()}}),E3=class extends w3{constructor(e){super(e),this[y3]=dlt}pause(){}resume(){}[d3](e){let r=this.follow?"statSync":"lstatSync";this[hb](e,Eb[r](e.absolute))}[m3](e,r){this[db](e,Eb.readdirSync(e.absolute))}[mb](e){let r=e.entry,o=this.zip;e.readdir&&e.readdir.forEach(a=>{let n=e.path,u=n==="./"?"":n.replace(/\/*$/,"/");this[gb](u+a)}),o?r.on("data",a=>{o.write(a)}):r.on("data",a=>{super[rAe](a)})}};w3.Sync=E3;nAe.exports=w3});var $E=_(G1=>{"use strict";var Clt=ME(),wlt=Be("events").EventEmitter,Qa=Be("fs"),v3=Qa.writev;if(!v3){let t=process.binding("fs"),e=t.FSReqWrap||t.FSReqCallback;v3=(r,o,a,n)=>{let u=(p,h)=>n(p,h,o),A=new e;A.oncomplete=u,t.writeBuffers(r,o,a,A)}}var XE=Symbol("_autoClose"),Yc=Symbol("_close"),q1=Symbol("_ended"),Gn=Symbol("_fd"),iAe=Symbol("_finished"),Eh=Symbol("_flags"),I3=Symbol("_flush"),D3=Symbol("_handleChunk"),P3=Symbol("_makeBuf"),Db=Symbol("_mode"),wb=Symbol("_needDrain"),zE=Symbol("_onerror"),ZE=Symbol("_onopen"),B3=Symbol("_onread"),KE=Symbol("_onwrite"),Ch=Symbol("_open"),Hf=Symbol("_path"),Nd=Symbol("_pos"),uA=Symbol("_queue"),VE=Symbol("_read"),sAe=Symbol("_readSize"),yh=Symbol("_reading"),Ib=Symbol("_remain"),oAe=Symbol("_size"),Bb=Symbol("_write"),WE=Symbol("_writing"),vb=Symbol("_defaultFlag"),JE=Symbol("_errored"),Pb=class extends Clt{constructor(e,r){if(r=r||{},super(r),this.readable=!0,this.writable=!1,typeof e!="string")throw new TypeError("path must be a string");this[JE]=!1,this[Gn]=typeof r.fd=="number"?r.fd:null,this[Hf]=e,this[sAe]=r.readSize||16*1024*1024,this[yh]=!1,this[oAe]=typeof r.size=="number"?r.size:1/0,this[Ib]=this[oAe],this[XE]=typeof r.autoClose=="boolean"?r.autoClose:!0,typeof this[Gn]=="number"?this[VE]():this[Ch]()}get fd(){return this[Gn]}get path(){return this[Hf]}write(){throw new TypeError("this is a readable stream")}end(){throw new TypeError("this is a readable stream")}[Ch](){Qa.open(this[Hf],"r",(e,r)=>this[ZE](e,r))}[ZE](e,r){e?this[zE](e):(this[Gn]=r,this.emit("open",r),this[VE]())}[P3](){return Buffer.allocUnsafe(Math.min(this[sAe],this[Ib]))}[VE](){if(!this[yh]){this[yh]=!0;let e=this[P3]();if(e.length===0)return process.nextTick(()=>this[B3](null,0,e));Qa.read(this[Gn],e,0,e.length,null,(r,o,a)=>this[B3](r,o,a))}}[B3](e,r,o){this[yh]=!1,e?this[zE](e):this[D3](r,o)&&this[VE]()}[Yc](){if(this[XE]&&typeof this[Gn]=="number"){let e=this[Gn];this[Gn]=null,Qa.close(e,r=>r?this.emit("error",r):this.emit("close"))}}[zE](e){this[yh]=!0,this[Yc](),this.emit("error",e)}[D3](e,r){let o=!1;return this[Ib]-=e,e>0&&(o=super.write(e<r.length?r.slice(0,e):r)),(e===0||this[Ib]<=0)&&(o=!1,this[Yc](),super.end()),o}emit(e,r){switch(e){case"prefinish":case"finish":break;case"drain":typeof this[Gn]=="number"&&this[VE]();break;case"error":return this[JE]?void 0:(this[JE]=!0,super.emit(e,r));default:return super.emit(e,r)}}},S3=class extends Pb{[Ch](){let e=!0;try{this[ZE](null,Qa.openSync(this[Hf],"r")),e=!1}finally{e&&this[Yc]()}}[VE](){let e=!0;try{if(!this[yh]){this[yh]=!0;do{let r=this[P3](),o=r.length===0?0:Qa.readSync(this[Gn],r,0,r.length,null);if(!this[D3](o,r))break}while(!0);this[yh]=!1}e=!1}finally{e&&this[Yc]()}}[Yc](){if(this[XE]&&typeof this[Gn]=="number"){let e=this[Gn];this[Gn]=null,Qa.closeSync(e),this.emit("close")}}},Sb=class extends wlt{constructor(e,r){r=r||{},super(r),this.readable=!1,this.writable=!0,this[JE]=!1,this[WE]=!1,this[q1]=!1,this[wb]=!1,this[uA]=[],this[Hf]=e,this[Gn]=typeof r.fd=="number"?r.fd:null,this[Db]=r.mode===void 0?438:r.mode,this[Nd]=typeof r.start=="number"?r.start:null,this[XE]=typeof r.autoClose=="boolean"?r.autoClose:!0;let o=this[Nd]!==null?"r+":"w";this[vb]=r.flags===void 0,this[Eh]=this[vb]?o:r.flags,this[Gn]===null&&this[Ch]()}emit(e,r){if(e==="error"){if(this[JE])return;this[JE]=!0}return super.emit(e,r)}get fd(){return this[Gn]}get path(){return this[Hf]}[zE](e){this[Yc](),this[WE]=!0,this.emit("error",e)}[Ch](){Qa.open(this[Hf],this[Eh],this[Db],(e,r)=>this[ZE](e,r))}[ZE](e,r){this[vb]&&this[Eh]==="r+"&&e&&e.code==="ENOENT"?(this[Eh]="w",this[Ch]()):e?this[zE](e):(this[Gn]=r,this.emit("open",r),this[I3]())}end(e,r){return e&&this.write(e,r),this[q1]=!0,!this[WE]&&!this[uA].length&&typeof this[Gn]=="number"&&this[KE](null,0),this}write(e,r){return typeof e=="string"&&(e=Buffer.from(e,r)),this[q1]?(this.emit("error",new Error("write() after end()")),!1):this[Gn]===null||this[WE]||this[uA].length?(this[uA].push(e),this[wb]=!0,!1):(this[WE]=!0,this[Bb](e),!0)}[Bb](e){Qa.write(this[Gn],e,0,e.length,this[Nd],(r,o)=>this[KE](r,o))}[KE](e,r){e?this[zE](e):(this[Nd]!==null&&(this[Nd]+=r),this[uA].length?this[I3]():(this[WE]=!1,this[q1]&&!this[iAe]?(this[iAe]=!0,this[Yc](),this.emit("finish")):this[wb]&&(this[wb]=!1,this.emit("drain"))))}[I3](){if(this[uA].length===0)this[q1]&&this[KE](null,0);else if(this[uA].length===1)this[Bb](this[uA].pop());else{let e=this[uA];this[uA]=[],v3(this[Gn],e,this[Nd],(r,o)=>this[KE](r,o))}}[Yc](){if(this[XE]&&typeof this[Gn]=="number"){let e=this[Gn];this[Gn]=null,Qa.close(e,r=>r?this.emit("error",r):this.emit("close"))}}},x3=class extends Sb{[Ch](){let e;if(this[vb]&&this[Eh]==="r+")try{e=Qa.openSync(this[Hf],this[Eh],this[Db])}catch(r){if(r.code==="ENOENT")return this[Eh]="w",this[Ch]();throw r}else e=Qa.openSync(this[Hf],this[Eh],this[Db]);this[ZE](null,e)}[Yc](){if(this[XE]&&typeof this[Gn]=="number"){let e=this[Gn];this[Gn]=null,Qa.closeSync(e),this.emit("close")}}[Bb](e){let r=!0;try{this[KE](null,Qa.writeSync(this[Gn],e,0,e.length,this[Nd])),r=!1}finally{if(r)try{this[Yc]()}catch{}}}};G1.ReadStream=Pb;G1.ReadStreamSync=S3;G1.WriteStream=Sb;G1.WriteStreamSync=x3});var Tb=_((rUt,pAe)=>{"use strict";var Ilt=sb(),Blt=qE(),vlt=Be("events"),Dlt=IP(),Plt=1024*1024,Slt=rb(),aAe=ib(),xlt=GU(),b3=Buffer.from([31,139]),Xl=Symbol("state"),Od=Symbol("writeEntry"),jf=Symbol("readEntry"),k3=Symbol("nextEntry"),lAe=Symbol("processEntry"),Zl=Symbol("extendedHeader"),Y1=Symbol("globalExtendedHeader"),wh=Symbol("meta"),cAe=Symbol("emitMeta"),fi=Symbol("buffer"),qf=Symbol("queue"),Md=Symbol("ended"),uAe=Symbol("emittedEnd"),Ud=Symbol("emit"),Fa=Symbol("unzip"),xb=Symbol("consumeChunk"),bb=Symbol("consumeChunkSub"),Q3=Symbol("consumeBody"),AAe=Symbol("consumeMeta"),fAe=Symbol("consumeHeader"),kb=Symbol("consuming"),F3=Symbol("bufferConcat"),R3=Symbol("maybeEnd"),W1=Symbol("writing"),Ih=Symbol("aborted"),Qb=Symbol("onDone"),_d=Symbol("sawValidEntry"),Fb=Symbol("sawNullBlock"),Rb=Symbol("sawEOF"),blt=t=>!0;pAe.exports=Ilt(class extends vlt{constructor(e){e=e||{},super(e),this.file=e.file||"",this[_d]=null,this.on(Qb,r=>{(this[Xl]==="begin"||this[_d]===!1)&&this.warn("TAR_BAD_ARCHIVE","Unrecognized archive format")}),e.ondone?this.on(Qb,e.ondone):this.on(Qb,r=>{this.emit("prefinish"),this.emit("finish"),this.emit("end"),this.emit("close")}),this.strict=!!e.strict,this.maxMetaEntrySize=e.maxMetaEntrySize||Plt,this.filter=typeof e.filter=="function"?e.filter:blt,this.writable=!0,this.readable=!1,this[qf]=new Dlt,this[fi]=null,this[jf]=null,this[Od]=null,this[Xl]="begin",this[wh]="",this[Zl]=null,this[Y1]=null,this[Md]=!1,this[Fa]=null,this[Ih]=!1,this[Fb]=!1,this[Rb]=!1,typeof e.onwarn=="function"&&this.on("warn",e.onwarn),typeof e.onentry=="function"&&this.on("entry",e.onentry)}[fAe](e,r){this[_d]===null&&(this[_d]=!1);let o;try{o=new Blt(e,r,this[Zl],this[Y1])}catch(a){return this.warn("TAR_ENTRY_INVALID",a)}if(o.nullBlock)this[Fb]?(this[Rb]=!0,this[Xl]==="begin"&&(this[Xl]="header"),this[Ud]("eof")):(this[Fb]=!0,this[Ud]("nullBlock"));else if(this[Fb]=!1,!o.cksumValid)this.warn("TAR_ENTRY_INVALID","checksum failure",{header:o});else if(!o.path)this.warn("TAR_ENTRY_INVALID","path is required",{header:o});else{let a=o.type;if(/^(Symbolic)?Link$/.test(a)&&!o.linkpath)this.warn("TAR_ENTRY_INVALID","linkpath required",{header:o});else if(!/^(Symbolic)?Link$/.test(a)&&o.linkpath)this.warn("TAR_ENTRY_INVALID","linkpath forbidden",{header:o});else{let n=this[Od]=new Slt(o,this[Zl],this[Y1]);if(!this[_d])if(n.remain){let u=()=>{n.invalid||(this[_d]=!0)};n.on("end",u)}else this[_d]=!0;n.meta?n.size>this.maxMetaEntrySize?(n.ignore=!0,this[Ud]("ignoredEntry",n),this[Xl]="ignore",n.resume()):n.size>0&&(this[wh]="",n.on("data",u=>this[wh]+=u),this[Xl]="meta"):(this[Zl]=null,n.ignore=n.ignore||!this.filter(n.path,n),n.ignore?(this[Ud]("ignoredEntry",n),this[Xl]=n.remain?"ignore":"header",n.resume()):(n.remain?this[Xl]="body":(this[Xl]="header",n.end()),this[jf]?this[qf].push(n):(this[qf].push(n),this[k3]())))}}}[lAe](e){let r=!0;return e?Array.isArray(e)?this.emit.apply(this,e):(this[jf]=e,this.emit("entry",e),e.emittedEnd||(e.on("end",o=>this[k3]()),r=!1)):(this[jf]=null,r=!1),r}[k3](){do;while(this[lAe](this[qf].shift()));if(!this[qf].length){let e=this[jf];!e||e.flowing||e.size===e.remain?this[W1]||this.emit("drain"):e.once("drain",o=>this.emit("drain"))}}[Q3](e,r){let o=this[Od],a=o.blockRemain,n=a>=e.length&&r===0?e:e.slice(r,r+a);return o.write(n),o.blockRemain||(this[Xl]="header",this[Od]=null,o.end()),n.length}[AAe](e,r){let o=this[Od],a=this[Q3](e,r);return this[Od]||this[cAe](o),a}[Ud](e,r,o){!this[qf].length&&!this[jf]?this.emit(e,r,o):this[qf].push([e,r,o])}[cAe](e){switch(this[Ud]("meta",this[wh]),e.type){case"ExtendedHeader":case"OldExtendedHeader":this[Zl]=aAe.parse(this[wh],this[Zl],!1);break;case"GlobalExtendedHeader":this[Y1]=aAe.parse(this[wh],this[Y1],!0);break;case"NextFileHasLongPath":case"OldGnuLongPath":this[Zl]=this[Zl]||Object.create(null),this[Zl].path=this[wh].replace(/\0.*/,"");break;case"NextFileHasLongLinkpath":this[Zl]=this[Zl]||Object.create(null),this[Zl].linkpath=this[wh].replace(/\0.*/,"");break;default:throw new Error("unknown meta: "+e.type)}}abort(e){this[Ih]=!0,this.emit("abort",e),this.warn("TAR_ABORT",e,{recoverable:!1})}write(e){if(this[Ih])return;if(this[Fa]===null&&e){if(this[fi]&&(e=Buffer.concat([this[fi],e]),this[fi]=null),e.length<b3.length)return this[fi]=e,!0;for(let o=0;this[Fa]===null&&o<b3.length;o++)e[o]!==b3[o]&&(this[Fa]=!1);if(this[Fa]===null){let o=this[Md];this[Md]=!1,this[Fa]=new xlt.Unzip,this[Fa].on("data",n=>this[xb](n)),this[Fa].on("error",n=>this.abort(n)),this[Fa].on("end",n=>{this[Md]=!0,this[xb]()}),this[W1]=!0;let a=this[Fa][o?"end":"write"](e);return this[W1]=!1,a}}this[W1]=!0,this[Fa]?this[Fa].write(e):this[xb](e),this[W1]=!1;let r=this[qf].length?!1:this[jf]?this[jf].flowing:!0;return!r&&!this[qf].length&&this[jf].once("drain",o=>this.emit("drain")),r}[F3](e){e&&!this[Ih]&&(this[fi]=this[fi]?Buffer.concat([this[fi],e]):e)}[R3](){if(this[Md]&&!this[uAe]&&!this[Ih]&&!this[kb]){this[uAe]=!0;let e=this[Od];if(e&&e.blockRemain){let r=this[fi]?this[fi].length:0;this.warn("TAR_BAD_ARCHIVE",`Truncated input (needed ${e.blockRemain} more bytes, only ${r} available)`,{entry:e}),this[fi]&&e.write(this[fi]),e.end()}this[Ud](Qb)}}[xb](e){if(this[kb])this[F3](e);else if(!e&&!this[fi])this[R3]();else{if(this[kb]=!0,this[fi]){this[F3](e);let r=this[fi];this[fi]=null,this[bb](r)}else this[bb](e);for(;this[fi]&&this[fi].length>=512&&!this[Ih]&&!this[Rb];){let r=this[fi];this[fi]=null,this[bb](r)}this[kb]=!1}(!this[fi]||this[Md])&&this[R3]()}[bb](e){let r=0,o=e.length;for(;r+512<=o&&!this[Ih]&&!this[Rb];)switch(this[Xl]){case"begin":case"header":this[fAe](e,r),r+=512;break;case"ignore":case"body":r+=this[Q3](e,r);break;case"meta":r+=this[AAe](e,r);break;default:throw new Error("invalid state: "+this[Xl])}r<o&&(this[fi]?this[fi]=Buffer.concat([e.slice(r),this[fi]]):this[fi]=e.slice(r))}end(e){this[Ih]||(this[Fa]?this[Fa].end(e):(this[Md]=!0,this.write(e)))}})});var Lb=_((nUt,mAe)=>{"use strict";var klt=NE(),gAe=Tb(),eC=Be("fs"),Qlt=$E(),hAe=Be("path"),T3=GE();mAe.exports=(t,e,r)=>{typeof t=="function"?(r=t,e=null,t={}):Array.isArray(t)&&(e=t,t={}),typeof e=="function"&&(r=e,e=null),e?e=Array.from(e):e=[];let o=klt(t);if(o.sync&&typeof r=="function")throw new TypeError("callback not supported for sync tar functions");if(!o.file&&typeof r=="function")throw new TypeError("callback only supported with file option");return e.length&&Rlt(o,e),o.noResume||Flt(o),o.file&&o.sync?Tlt(o):o.file?Llt(o,r):dAe(o)};var Flt=t=>{let e=t.onentry;t.onentry=e?r=>{e(r),r.resume()}:r=>r.resume()},Rlt=(t,e)=>{let r=new Map(e.map(n=>[T3(n),!0])),o=t.filter,a=(n,u)=>{let A=u||hAe.parse(n).root||".",p=n===A?!1:r.has(n)?r.get(n):a(hAe.dirname(n),A);return r.set(n,p),p};t.filter=o?(n,u)=>o(n,u)&&a(T3(n)):n=>a(T3(n))},Tlt=t=>{let e=dAe(t),r=t.file,o=!0,a;try{let n=eC.statSync(r),u=t.maxReadSize||16*1024*1024;if(n.size<u)e.end(eC.readFileSync(r));else{let A=0,p=Buffer.allocUnsafe(u);for(a=eC.openSync(r,"r");A<n.size;){let h=eC.readSync(a,p,0,u,A);A+=h,e.write(p.slice(0,h))}e.end()}o=!1}finally{if(o&&a)try{eC.closeSync(a)}catch{}}},Llt=(t,e)=>{let r=new gAe(t),o=t.maxReadSize||16*1024*1024,a=t.file,n=new Promise((u,A)=>{r.on("error",A),r.on("end",u),eC.stat(a,(p,h)=>{if(p)A(p);else{let C=new Qlt.ReadStream(a,{readSize:o,size:h.size});C.on("error",A),C.pipe(r)}})});return e?n.then(e,e):n},dAe=t=>new gAe(t)});var BAe=_((iUt,IAe)=>{"use strict";var Nlt=NE(),Nb=Cb(),yAe=$E(),EAe=Lb(),CAe=Be("path");IAe.exports=(t,e,r)=>{if(typeof e=="function"&&(r=e),Array.isArray(t)&&(e=t,t={}),!e||!Array.isArray(e)||!e.length)throw new TypeError("no files or directories specified");e=Array.from(e);let o=Nlt(t);if(o.sync&&typeof r=="function")throw new TypeError("callback not supported for sync tar functions");if(!o.file&&typeof r=="function")throw new TypeError("callback only supported with file option");return o.file&&o.sync?Olt(o,e):o.file?Mlt(o,e,r):o.sync?Ult(o,e):_lt(o,e)};var Olt=(t,e)=>{let r=new Nb.Sync(t),o=new yAe.WriteStreamSync(t.file,{mode:t.mode||438});r.pipe(o),wAe(r,e)},Mlt=(t,e,r)=>{let o=new Nb(t),a=new yAe.WriteStream(t.file,{mode:t.mode||438});o.pipe(a);let n=new Promise((u,A)=>{a.on("error",A),a.on("close",u),o.on("error",A)});return L3(o,e),r?n.then(r,r):n},wAe=(t,e)=>{e.forEach(r=>{r.charAt(0)==="@"?EAe({file:CAe.resolve(t.cwd,r.substr(1)),sync:!0,noResume:!0,onentry:o=>t.add(o)}):t.add(r)}),t.end()},L3=(t,e)=>{for(;e.length;){let r=e.shift();if(r.charAt(0)==="@")return EAe({file:CAe.resolve(t.cwd,r.substr(1)),noResume:!0,onentry:o=>t.add(o)}).then(o=>L3(t,e));t.add(r)}t.end()},Ult=(t,e)=>{let r=new Nb.Sync(t);return wAe(r,e),r},_lt=(t,e)=>{let r=new Nb(t);return L3(r,e),r}});var N3=_((sUt,kAe)=>{"use strict";var Hlt=NE(),vAe=Cb(),Al=Be("fs"),DAe=$E(),PAe=Lb(),SAe=Be("path"),xAe=qE();kAe.exports=(t,e,r)=>{let o=Hlt(t);if(!o.file)throw new TypeError("file is required");if(o.gzip)throw new TypeError("cannot append to compressed archives");if(!e||!Array.isArray(e)||!e.length)throw new TypeError("no files or directories specified");return e=Array.from(e),o.sync?jlt(o,e):Glt(o,e,r)};var jlt=(t,e)=>{let r=new vAe.Sync(t),o=!0,a,n;try{try{a=Al.openSync(t.file,"r+")}catch(p){if(p.code==="ENOENT")a=Al.openSync(t.file,"w+");else throw p}let u=Al.fstatSync(a),A=Buffer.alloc(512);e:for(n=0;n<u.size;n+=512){for(let C=0,I=0;C<512;C+=I){if(I=Al.readSync(a,A,C,A.length-C,n+C),n===0&&A[0]===31&&A[1]===139)throw new Error("cannot append to compressed archives");if(!I)break e}let p=new xAe(A);if(!p.cksumValid)break;let h=512*Math.ceil(p.size/512);if(n+h+512>u.size)break;n+=h,t.mtimeCache&&t.mtimeCache.set(p.path,p.mtime)}o=!1,qlt(t,r,n,a,e)}finally{if(o)try{Al.closeSync(a)}catch{}}},qlt=(t,e,r,o,a)=>{let n=new DAe.WriteStreamSync(t.file,{fd:o,start:r});e.pipe(n),Ylt(e,a)},Glt=(t,e,r)=>{e=Array.from(e);let o=new vAe(t),a=(u,A,p)=>{let h=(E,F)=>{E?Al.close(u,N=>p(E)):p(null,F)},C=0;if(A===0)return h(null,0);let I=0,v=Buffer.alloc(512),b=(E,F)=>{if(E)return h(E);if(I+=F,I<512&&F)return Al.read(u,v,I,v.length-I,C+I,b);if(C===0&&v[0]===31&&v[1]===139)return h(new Error("cannot append to compressed archives"));if(I<512)return h(null,C);let N=new xAe(v);if(!N.cksumValid)return h(null,C);let U=512*Math.ceil(N.size/512);if(C+U+512>A||(C+=U+512,C>=A))return h(null,C);t.mtimeCache&&t.mtimeCache.set(N.path,N.mtime),I=0,Al.read(u,v,0,512,C,b)};Al.read(u,v,0,512,C,b)},n=new Promise((u,A)=>{o.on("error",A);let p="r+",h=(C,I)=>{if(C&&C.code==="ENOENT"&&p==="r+")return p="w+",Al.open(t.file,p,h);if(C)return A(C);Al.fstat(I,(v,b)=>{if(v)return Al.close(I,()=>A(v));a(I,b.size,(E,F)=>{if(E)return A(E);let N=new DAe.WriteStream(t.file,{fd:I,start:F});o.pipe(N),N.on("error",A),N.on("close",u),bAe(o,e)})})};Al.open(t.file,p,h)});return r?n.then(r,r):n},Ylt=(t,e)=>{e.forEach(r=>{r.charAt(0)==="@"?PAe({file:SAe.resolve(t.cwd,r.substr(1)),sync:!0,noResume:!0,onentry:o=>t.add(o)}):t.add(r)}),t.end()},bAe=(t,e)=>{for(;e.length;){let r=e.shift();if(r.charAt(0)==="@")return PAe({file:SAe.resolve(t.cwd,r.substr(1)),noResume:!0,onentry:o=>t.add(o)}).then(o=>bAe(t,e));t.add(r)}t.end()}});var FAe=_((oUt,QAe)=>{"use strict";var Wlt=NE(),Klt=N3();QAe.exports=(t,e,r)=>{let o=Wlt(t);if(!o.file)throw new TypeError("file is required");if(o.gzip)throw new TypeError("cannot append to compressed archives");if(!e||!Array.isArray(e)||!e.length)throw new TypeError("no files or directories specified");return e=Array.from(e),Vlt(o),Klt(o,e,r)};var Vlt=t=>{let e=t.filter;t.mtimeCache||(t.mtimeCache=new Map),t.filter=e?(r,o)=>e(r,o)&&!(t.mtimeCache.get(r)>o.mtime):(r,o)=>!(t.mtimeCache.get(r)>o.mtime)}});var LAe=_((aUt,TAe)=>{var{promisify:RAe}=Be("util"),Bh=Be("fs"),zlt=t=>{if(!t)t={mode:511,fs:Bh};else if(typeof t=="object")t={mode:511,fs:Bh,...t};else if(typeof t=="number")t={mode:t,fs:Bh};else if(typeof t=="string")t={mode:parseInt(t,8),fs:Bh};else throw new TypeError("invalid options argument");return t.mkdir=t.mkdir||t.fs.mkdir||Bh.mkdir,t.mkdirAsync=RAe(t.mkdir),t.stat=t.stat||t.fs.stat||Bh.stat,t.statAsync=RAe(t.stat),t.statSync=t.statSync||t.fs.statSync||Bh.statSync,t.mkdirSync=t.mkdirSync||t.fs.mkdirSync||Bh.mkdirSync,t};TAe.exports=zlt});var OAe=_((lUt,NAe)=>{var Jlt=process.platform,{resolve:Xlt,parse:Zlt}=Be("path"),$lt=t=>{if(/\0/.test(t))throw Object.assign(new TypeError("path must be a string without null bytes"),{path:t,code:"ERR_INVALID_ARG_VALUE"});if(t=Xlt(t),Jlt==="win32"){let e=/[*|"<>?:]/,{root:r}=Zlt(t);if(e.test(t.substr(r.length)))throw Object.assign(new Error("Illegal characters in path."),{path:t,code:"EINVAL"})}return t};NAe.exports=$lt});var jAe=_((cUt,HAe)=>{var{dirname:MAe}=Be("path"),UAe=(t,e,r=void 0)=>r===e?Promise.resolve():t.statAsync(e).then(o=>o.isDirectory()?r:void 0,o=>o.code==="ENOENT"?UAe(t,MAe(e),e):void 0),_Ae=(t,e,r=void 0)=>{if(r!==e)try{return t.statSync(e).isDirectory()?r:void 0}catch(o){return o.code==="ENOENT"?_Ae(t,MAe(e),e):void 0}};HAe.exports={findMade:UAe,findMadeSync:_Ae}});var U3=_((uUt,GAe)=>{var{dirname:qAe}=Be("path"),O3=(t,e,r)=>{e.recursive=!1;let o=qAe(t);return o===t?e.mkdirAsync(t,e).catch(a=>{if(a.code!=="EISDIR")throw a}):e.mkdirAsync(t,e).then(()=>r||t,a=>{if(a.code==="ENOENT")return O3(o,e).then(n=>O3(t,e,n));if(a.code!=="EEXIST"&&a.code!=="EROFS")throw a;return e.statAsync(t).then(n=>{if(n.isDirectory())return r;throw a},()=>{throw a})})},M3=(t,e,r)=>{let o=qAe(t);if(e.recursive=!1,o===t)try{return e.mkdirSync(t,e)}catch(a){if(a.code!=="EISDIR")throw a;return}try{return e.mkdirSync(t,e),r||t}catch(a){if(a.code==="ENOENT")return M3(t,e,M3(o,e,r));if(a.code!=="EEXIST"&&a.code!=="EROFS")throw a;try{if(!e.statSync(t).isDirectory())throw a}catch{throw a}}};GAe.exports={mkdirpManual:O3,mkdirpManualSync:M3}});var KAe=_((AUt,WAe)=>{var{dirname:YAe}=Be("path"),{findMade:ect,findMadeSync:tct}=jAe(),{mkdirpManual:rct,mkdirpManualSync:nct}=U3(),ict=(t,e)=>(e.recursive=!0,YAe(t)===t?e.mkdirAsync(t,e):ect(e,t).then(o=>e.mkdirAsync(t,e).then(()=>o).catch(a=>{if(a.code==="ENOENT")return rct(t,e);throw a}))),sct=(t,e)=>{if(e.recursive=!0,YAe(t)===t)return e.mkdirSync(t,e);let o=tct(e,t);try{return e.mkdirSync(t,e),o}catch(a){if(a.code==="ENOENT")return nct(t,e);throw a}};WAe.exports={mkdirpNative:ict,mkdirpNativeSync:sct}});var XAe=_((fUt,JAe)=>{var VAe=Be("fs"),oct=process.version,_3=oct.replace(/^v/,"").split("."),zAe=+_3[0]>10||+_3[0]==10&&+_3[1]>=12,act=zAe?t=>t.mkdir===VAe.mkdir:()=>!1,lct=zAe?t=>t.mkdirSync===VAe.mkdirSync:()=>!1;JAe.exports={useNative:act,useNativeSync:lct}});var nfe=_((pUt,rfe)=>{var tC=LAe(),rC=OAe(),{mkdirpNative:ZAe,mkdirpNativeSync:$Ae}=KAe(),{mkdirpManual:efe,mkdirpManualSync:tfe}=U3(),{useNative:cct,useNativeSync:uct}=XAe(),nC=(t,e)=>(t=rC(t),e=tC(e),cct(e)?ZAe(t,e):efe(t,e)),Act=(t,e)=>(t=rC(t),e=tC(e),uct(e)?$Ae(t,e):tfe(t,e));nC.sync=Act;nC.native=(t,e)=>ZAe(rC(t),tC(e));nC.manual=(t,e)=>efe(rC(t),tC(e));nC.nativeSync=(t,e)=>$Ae(rC(t),tC(e));nC.manualSync=(t,e)=>tfe(rC(t),tC(e));rfe.exports=nC});var ufe=_((hUt,cfe)=>{"use strict";var $l=Be("fs"),Hd=Be("path"),fct=$l.lchown?"lchown":"chown",pct=$l.lchownSync?"lchownSync":"chownSync",sfe=$l.lchown&&!process.version.match(/v1[1-9]+\./)&&!process.version.match(/v10\.[6-9]/),ife=(t,e,r)=>{try{return $l[pct](t,e,r)}catch(o){if(o.code!=="ENOENT")throw o}},hct=(t,e,r)=>{try{return $l.chownSync(t,e,r)}catch(o){if(o.code!=="ENOENT")throw o}},gct=sfe?(t,e,r,o)=>a=>{!a||a.code!=="EISDIR"?o(a):$l.chown(t,e,r,o)}:(t,e,r,o)=>o,H3=sfe?(t,e,r)=>{try{return ife(t,e,r)}catch(o){if(o.code!=="EISDIR")throw o;hct(t,e,r)}}:(t,e,r)=>ife(t,e,r),dct=process.version,ofe=(t,e,r)=>$l.readdir(t,e,r),mct=(t,e)=>$l.readdirSync(t,e);/^v4\./.test(dct)&&(ofe=(t,e,r)=>$l.readdir(t,r));var Ob=(t,e,r,o)=>{$l[fct](t,e,r,gct(t,e,r,a=>{o(a&&a.code!=="ENOENT"?a:null)}))},afe=(t,e,r,o,a)=>{if(typeof e=="string")return $l.lstat(Hd.resolve(t,e),(n,u)=>{if(n)return a(n.code!=="ENOENT"?n:null);u.name=e,afe(t,u,r,o,a)});if(e.isDirectory())j3(Hd.resolve(t,e.name),r,o,n=>{if(n)return a(n);let u=Hd.resolve(t,e.name);Ob(u,r,o,a)});else{let n=Hd.resolve(t,e.name);Ob(n,r,o,a)}},j3=(t,e,r,o)=>{ofe(t,{withFileTypes:!0},(a,n)=>{if(a){if(a.code==="ENOENT")return o();if(a.code!=="ENOTDIR"&&a.code!=="ENOTSUP")return o(a)}if(a||!n.length)return Ob(t,e,r,o);let u=n.length,A=null,p=h=>{if(!A){if(h)return o(A=h);if(--u===0)return Ob(t,e,r,o)}};n.forEach(h=>afe(t,h,e,r,p))})},yct=(t,e,r,o)=>{if(typeof e=="string")try{let a=$l.lstatSync(Hd.resolve(t,e));a.name=e,e=a}catch(a){if(a.code==="ENOENT")return;throw a}e.isDirectory()&&lfe(Hd.resolve(t,e.name),r,o),H3(Hd.resolve(t,e.name),r,o)},lfe=(t,e,r)=>{let o;try{o=mct(t,{withFileTypes:!0})}catch(a){if(a.code==="ENOENT")return;if(a.code==="ENOTDIR"||a.code==="ENOTSUP")return H3(t,e,r);throw a}return o&&o.length&&o.forEach(a=>yct(t,a,e,r)),H3(t,e,r)};cfe.exports=j3;j3.sync=lfe});var hfe=_((gUt,q3)=>{"use strict";var Afe=nfe(),ec=Be("fs"),Mb=Be("path"),ffe=ufe(),Wc=HE(),Ub=class extends Error{constructor(e,r){super("Cannot extract through symbolic link"),this.path=r,this.symlink=e}get name(){return"SylinkError"}},_b=class extends Error{constructor(e,r){super(r+": Cannot cd into '"+e+"'"),this.path=e,this.code=r}get name(){return"CwdError"}},Hb=(t,e)=>t.get(Wc(e)),K1=(t,e,r)=>t.set(Wc(e),r),Ect=(t,e)=>{ec.stat(t,(r,o)=>{(r||!o.isDirectory())&&(r=new _b(t,r&&r.code||"ENOTDIR")),e(r)})};q3.exports=(t,e,r)=>{t=Wc(t);let o=e.umask,a=e.mode|448,n=(a&o)!==0,u=e.uid,A=e.gid,p=typeof u=="number"&&typeof A=="number"&&(u!==e.processUid||A!==e.processGid),h=e.preserve,C=e.unlink,I=e.cache,v=Wc(e.cwd),b=(N,U)=>{N?r(N):(K1(I,t,!0),U&&p?ffe(U,u,A,z=>b(z)):n?ec.chmod(t,a,r):r())};if(I&&Hb(I,t)===!0)return b();if(t===v)return Ect(t,b);if(h)return Afe(t,{mode:a}).then(N=>b(null,N),b);let F=Wc(Mb.relative(v,t)).split("/");jb(v,F,a,I,C,v,null,b)};var jb=(t,e,r,o,a,n,u,A)=>{if(!e.length)return A(null,u);let p=e.shift(),h=Wc(Mb.resolve(t+"/"+p));if(Hb(o,h))return jb(h,e,r,o,a,n,u,A);ec.mkdir(h,r,pfe(h,e,r,o,a,n,u,A))},pfe=(t,e,r,o,a,n,u,A)=>p=>{p?ec.lstat(t,(h,C)=>{if(h)h.path=h.path&&Wc(h.path),A(h);else if(C.isDirectory())jb(t,e,r,o,a,n,u,A);else if(a)ec.unlink(t,I=>{if(I)return A(I);ec.mkdir(t,r,pfe(t,e,r,o,a,n,u,A))});else{if(C.isSymbolicLink())return A(new Ub(t,t+"/"+e.join("/")));A(p)}}):(u=u||t,jb(t,e,r,o,a,n,u,A))},Cct=t=>{let e=!1,r="ENOTDIR";try{e=ec.statSync(t).isDirectory()}catch(o){r=o.code}finally{if(!e)throw new _b(t,r)}};q3.exports.sync=(t,e)=>{t=Wc(t);let r=e.umask,o=e.mode|448,a=(o&r)!==0,n=e.uid,u=e.gid,A=typeof n=="number"&&typeof u=="number"&&(n!==e.processUid||u!==e.processGid),p=e.preserve,h=e.unlink,C=e.cache,I=Wc(e.cwd),v=N=>{K1(C,t,!0),N&&A&&ffe.sync(N,n,u),a&&ec.chmodSync(t,o)};if(C&&Hb(C,t)===!0)return v();if(t===I)return Cct(I),v();if(p)return v(Afe.sync(t,o));let E=Wc(Mb.relative(I,t)).split("/"),F=null;for(let N=E.shift(),U=I;N&&(U+="/"+N);N=E.shift())if(U=Wc(Mb.resolve(U)),!Hb(C,U))try{ec.mkdirSync(U,o),F=F||U,K1(C,U,!0)}catch{let te=ec.lstatSync(U);if(te.isDirectory()){K1(C,U,!0);continue}else if(h){ec.unlinkSync(U),ec.mkdirSync(U,o),F=F||U,K1(C,U,!0);continue}else if(te.isSymbolicLink())return new Ub(U,U+"/"+E.join("/"))}return v(F)}});var Y3=_((dUt,gfe)=>{var G3=Object.create(null),{hasOwnProperty:wct}=Object.prototype;gfe.exports=t=>(wct.call(G3,t)||(G3[t]=t.normalize("NFKD")),G3[t])});var Efe=_((mUt,yfe)=>{var dfe=Be("assert"),Ict=Y3(),Bct=GE(),{join:mfe}=Be("path"),vct=process.env.TESTING_TAR_FAKE_PLATFORM||process.platform,Dct=vct==="win32";yfe.exports=()=>{let t=new Map,e=new Map,r=h=>h.split("/").slice(0,-1).reduce((I,v)=>(I.length&&(v=mfe(I[I.length-1],v)),I.push(v||"/"),I),[]),o=new Set,a=h=>{let C=e.get(h);if(!C)throw new Error("function does not have any path reservations");return{paths:C.paths.map(I=>t.get(I)),dirs:[...C.dirs].map(I=>t.get(I))}},n=h=>{let{paths:C,dirs:I}=a(h);return C.every(v=>v[0]===h)&&I.every(v=>v[0]instanceof Set&&v[0].has(h))},u=h=>o.has(h)||!n(h)?!1:(o.add(h),h(()=>A(h)),!0),A=h=>{if(!o.has(h))return!1;let{paths:C,dirs:I}=e.get(h),v=new Set;return C.forEach(b=>{let E=t.get(b);dfe.equal(E[0],h),E.length===1?t.delete(b):(E.shift(),typeof E[0]=="function"?v.add(E[0]):E[0].forEach(F=>v.add(F)))}),I.forEach(b=>{let E=t.get(b);dfe(E[0]instanceof Set),E[0].size===1&&E.length===1?t.delete(b):E[0].size===1?(E.shift(),v.add(E[0])):E[0].delete(h)}),o.delete(h),v.forEach(b=>u(b)),!0};return{check:n,reserve:(h,C)=>{h=Dct?["win32 parallelization disabled"]:h.map(v=>Ict(Bct(mfe(v))).toLowerCase());let I=new Set(h.map(v=>r(v)).reduce((v,b)=>v.concat(b)));return e.set(C,{dirs:I,paths:h}),h.forEach(v=>{let b=t.get(v);b?b.push(C):t.set(v,[C])}),I.forEach(v=>{let b=t.get(v);b?b[b.length-1]instanceof Set?b[b.length-1].add(C):b.push(new Set([C])):t.set(v,[new Set([C])])}),u(C)}}}});var Ife=_((yUt,wfe)=>{var Pct=process.platform,Sct=Pct==="win32",xct=global.__FAKE_TESTING_FS__||Be("fs"),{O_CREAT:bct,O_TRUNC:kct,O_WRONLY:Qct,UV_FS_O_FILEMAP:Cfe=0}=xct.constants,Fct=Sct&&!!Cfe,Rct=512*1024,Tct=Cfe|kct|bct|Qct;wfe.exports=Fct?t=>t<Rct?Tct:"w":()=>"w"});var e_=_((EUt,Nfe)=>{"use strict";var Lct=Be("assert"),Nct=Tb(),vn=Be("fs"),Oct=$E(),Gf=Be("path"),Rfe=hfe(),Bfe=e3(),Mct=Efe(),Uct=t3(),fl=HE(),_ct=GE(),Hct=Y3(),vfe=Symbol("onEntry"),V3=Symbol("checkFs"),Dfe=Symbol("checkFs2"),Yb=Symbol("pruneCache"),z3=Symbol("isReusable"),tc=Symbol("makeFs"),J3=Symbol("file"),X3=Symbol("directory"),Wb=Symbol("link"),Pfe=Symbol("symlink"),Sfe=Symbol("hardlink"),xfe=Symbol("unsupported"),bfe=Symbol("checkPath"),vh=Symbol("mkdir"),To=Symbol("onError"),qb=Symbol("pending"),kfe=Symbol("pend"),iC=Symbol("unpend"),W3=Symbol("ended"),K3=Symbol("maybeClose"),Z3=Symbol("skip"),V1=Symbol("doChown"),z1=Symbol("uid"),J1=Symbol("gid"),X1=Symbol("checkedCwd"),Tfe=Be("crypto"),Lfe=Ife(),jct=process.env.TESTING_TAR_FAKE_PLATFORM||process.platform,Z1=jct==="win32",qct=(t,e)=>{if(!Z1)return vn.unlink(t,e);let r=t+".DELETE."+Tfe.randomBytes(16).toString("hex");vn.rename(t,r,o=>{if(o)return e(o);vn.unlink(r,e)})},Gct=t=>{if(!Z1)return vn.unlinkSync(t);let e=t+".DELETE."+Tfe.randomBytes(16).toString("hex");vn.renameSync(t,e),vn.unlinkSync(e)},Qfe=(t,e,r)=>t===t>>>0?t:e===e>>>0?e:r,Ffe=t=>Hct(_ct(fl(t))).toLowerCase(),Yct=(t,e)=>{e=Ffe(e);for(let r of t.keys()){let o=Ffe(r);(o===e||o.indexOf(e+"/")===0)&&t.delete(r)}},Wct=t=>{for(let e of t.keys())t.delete(e)},$1=class extends Nct{constructor(e){if(e||(e={}),e.ondone=r=>{this[W3]=!0,this[K3]()},super(e),this[X1]=!1,this.reservations=Mct(),this.transform=typeof e.transform=="function"?e.transform:null,this.writable=!0,this.readable=!1,this[qb]=0,this[W3]=!1,this.dirCache=e.dirCache||new Map,typeof e.uid=="number"||typeof e.gid=="number"){if(typeof e.uid!="number"||typeof e.gid!="number")throw new TypeError("cannot set owner without number uid and gid");if(e.preserveOwner)throw new TypeError("cannot preserve owner in archive and also set owner explicitly");this.uid=e.uid,this.gid=e.gid,this.setOwner=!0}else this.uid=null,this.gid=null,this.setOwner=!1;e.preserveOwner===void 0&&typeof e.uid!="number"?this.preserveOwner=process.getuid&&process.getuid()===0:this.preserveOwner=!!e.preserveOwner,this.processUid=(this.preserveOwner||this.setOwner)&&process.getuid?process.getuid():null,this.processGid=(this.preserveOwner||this.setOwner)&&process.getgid?process.getgid():null,this.forceChown=e.forceChown===!0,this.win32=!!e.win32||Z1,this.newer=!!e.newer,this.keep=!!e.keep,this.noMtime=!!e.noMtime,this.preservePaths=!!e.preservePaths,this.unlink=!!e.unlink,this.cwd=fl(Gf.resolve(e.cwd||process.cwd())),this.strip=+e.strip||0,this.processUmask=e.noChmod?0:process.umask(),this.umask=typeof e.umask=="number"?e.umask:this.processUmask,this.dmode=e.dmode||511&~this.umask,this.fmode=e.fmode||438&~this.umask,this.on("entry",r=>this[vfe](r))}warn(e,r,o={}){return(e==="TAR_BAD_ARCHIVE"||e==="TAR_ABORT")&&(o.recoverable=!1),super.warn(e,r,o)}[K3](){this[W3]&&this[qb]===0&&(this.emit("prefinish"),this.emit("finish"),this.emit("end"),this.emit("close"))}[bfe](e){if(this.strip){let r=fl(e.path).split("/");if(r.length<this.strip)return!1;if(e.path=r.slice(this.strip).join("/"),e.type==="Link"){let o=fl(e.linkpath).split("/");if(o.length>=this.strip)e.linkpath=o.slice(this.strip).join("/");else return!1}}if(!this.preservePaths){let r=fl(e.path),o=r.split("/");if(o.includes("..")||Z1&&/^[a-z]:\.\.$/i.test(o[0]))return this.warn("TAR_ENTRY_ERROR","path contains '..'",{entry:e,path:r}),!1;let[a,n]=Uct(r);a&&(e.path=n,this.warn("TAR_ENTRY_INFO",`stripping ${a} from absolute path`,{entry:e,path:r}))}if(Gf.isAbsolute(e.path)?e.absolute=fl(Gf.resolve(e.path)):e.absolute=fl(Gf.resolve(this.cwd,e.path)),!this.preservePaths&&e.absolute.indexOf(this.cwd+"/")!==0&&e.absolute!==this.cwd)return this.warn("TAR_ENTRY_ERROR","path escaped extraction target",{entry:e,path:fl(e.path),resolvedPath:e.absolute,cwd:this.cwd}),!1;if(e.absolute===this.cwd&&e.type!=="Directory"&&e.type!=="GNUDumpDir")return!1;if(this.win32){let{root:r}=Gf.win32.parse(e.absolute);e.absolute=r+Bfe.encode(e.absolute.substr(r.length));let{root:o}=Gf.win32.parse(e.path);e.path=o+Bfe.encode(e.path.substr(o.length))}return!0}[vfe](e){if(!this[bfe](e))return e.resume();switch(Lct.equal(typeof e.absolute,"string"),e.type){case"Directory":case"GNUDumpDir":e.mode&&(e.mode=e.mode|448);case"File":case"OldFile":case"ContiguousFile":case"Link":case"SymbolicLink":return this[V3](e);case"CharacterDevice":case"BlockDevice":case"FIFO":default:return this[xfe](e)}}[To](e,r){e.name==="CwdError"?this.emit("error",e):(this.warn("TAR_ENTRY_ERROR",e,{entry:r}),this[iC](),r.resume())}[vh](e,r,o){Rfe(fl(e),{uid:this.uid,gid:this.gid,processUid:this.processUid,processGid:this.processGid,umask:this.processUmask,preserve:this.preservePaths,unlink:this.unlink,cache:this.dirCache,cwd:this.cwd,mode:r,noChmod:this.noChmod},o)}[V1](e){return this.forceChown||this.preserveOwner&&(typeof e.uid=="number"&&e.uid!==this.processUid||typeof e.gid=="number"&&e.gid!==this.processGid)||typeof this.uid=="number"&&this.uid!==this.processUid||typeof this.gid=="number"&&this.gid!==this.processGid}[z1](e){return Qfe(this.uid,e.uid,this.processUid)}[J1](e){return Qfe(this.gid,e.gid,this.processGid)}[J3](e,r){let o=e.mode&4095||this.fmode,a=new Oct.WriteStream(e.absolute,{flags:Lfe(e.size),mode:o,autoClose:!1});a.on("error",p=>{a.fd&&vn.close(a.fd,()=>{}),a.write=()=>!0,this[To](p,e),r()});let n=1,u=p=>{if(p){a.fd&&vn.close(a.fd,()=>{}),this[To](p,e),r();return}--n===0&&vn.close(a.fd,h=>{h?this[To](h,e):this[iC](),r()})};a.on("finish",p=>{let h=e.absolute,C=a.fd;if(e.mtime&&!this.noMtime){n++;let I=e.atime||new Date,v=e.mtime;vn.futimes(C,I,v,b=>b?vn.utimes(h,I,v,E=>u(E&&b)):u())}if(this[V1](e)){n++;let I=this[z1](e),v=this[J1](e);vn.fchown(C,I,v,b=>b?vn.chown(h,I,v,E=>u(E&&b)):u())}u()});let A=this.transform&&this.transform(e)||e;A!==e&&(A.on("error",p=>{this[To](p,e),r()}),e.pipe(A)),A.pipe(a)}[X3](e,r){let o=e.mode&4095||this.dmode;this[vh](e.absolute,o,a=>{if(a){this[To](a,e),r();return}let n=1,u=A=>{--n===0&&(r(),this[iC](),e.resume())};e.mtime&&!this.noMtime&&(n++,vn.utimes(e.absolute,e.atime||new Date,e.mtime,u)),this[V1](e)&&(n++,vn.chown(e.absolute,this[z1](e),this[J1](e),u)),u()})}[xfe](e){e.unsupported=!0,this.warn("TAR_ENTRY_UNSUPPORTED",`unsupported entry type: ${e.type}`,{entry:e}),e.resume()}[Pfe](e,r){this[Wb](e,e.linkpath,"symlink",r)}[Sfe](e,r){let o=fl(Gf.resolve(this.cwd,e.linkpath));this[Wb](e,o,"link",r)}[kfe](){this[qb]++}[iC](){this[qb]--,this[K3]()}[Z3](e){this[iC](),e.resume()}[z3](e,r){return e.type==="File"&&!this.unlink&&r.isFile()&&r.nlink<=1&&!Z1}[V3](e){this[kfe]();let r=[e.path];e.linkpath&&r.push(e.linkpath),this.reservations.reserve(r,o=>this[Dfe](e,o))}[Yb](e){e.type==="SymbolicLink"?Wct(this.dirCache):e.type!=="Directory"&&Yct(this.dirCache,e.absolute)}[Dfe](e,r){this[Yb](e);let o=A=>{this[Yb](e),r(A)},a=()=>{this[vh](this.cwd,this.dmode,A=>{if(A){this[To](A,e),o();return}this[X1]=!0,n()})},n=()=>{if(e.absolute!==this.cwd){let A=fl(Gf.dirname(e.absolute));if(A!==this.cwd)return this[vh](A,this.dmode,p=>{if(p){this[To](p,e),o();return}u()})}u()},u=()=>{vn.lstat(e.absolute,(A,p)=>{if(p&&(this.keep||this.newer&&p.mtime>e.mtime)){this[Z3](e),o();return}if(A||this[z3](e,p))return this[tc](null,e,o);if(p.isDirectory()){if(e.type==="Directory"){let h=!this.noChmod&&e.mode&&(p.mode&4095)!==e.mode,C=I=>this[tc](I,e,o);return h?vn.chmod(e.absolute,e.mode,C):C()}if(e.absolute!==this.cwd)return vn.rmdir(e.absolute,h=>this[tc](h,e,o))}if(e.absolute===this.cwd)return this[tc](null,e,o);qct(e.absolute,h=>this[tc](h,e,o))})};this[X1]?n():a()}[tc](e,r,o){if(e){this[To](e,r),o();return}switch(r.type){case"File":case"OldFile":case"ContiguousFile":return this[J3](r,o);case"Link":return this[Sfe](r,o);case"SymbolicLink":return this[Pfe](r,o);case"Directory":case"GNUDumpDir":return this[X3](r,o)}}[Wb](e,r,o,a){vn[o](r,e.absolute,n=>{n?this[To](n,e):(this[iC](),e.resume()),a()})}},Gb=t=>{try{return[null,t()]}catch(e){return[e,null]}},$3=class extends $1{[tc](e,r){return super[tc](e,r,()=>{})}[V3](e){if(this[Yb](e),!this[X1]){let n=this[vh](this.cwd,this.dmode);if(n)return this[To](n,e);this[X1]=!0}if(e.absolute!==this.cwd){let n=fl(Gf.dirname(e.absolute));if(n!==this.cwd){let u=this[vh](n,this.dmode);if(u)return this[To](u,e)}}let[r,o]=Gb(()=>vn.lstatSync(e.absolute));if(o&&(this.keep||this.newer&&o.mtime>e.mtime))return this[Z3](e);if(r||this[z3](e,o))return this[tc](null,e);if(o.isDirectory()){if(e.type==="Directory"){let u=!this.noChmod&&e.mode&&(o.mode&4095)!==e.mode,[A]=u?Gb(()=>{vn.chmodSync(e.absolute,e.mode)}):[];return this[tc](A,e)}let[n]=Gb(()=>vn.rmdirSync(e.absolute));this[tc](n,e)}let[a]=e.absolute===this.cwd?[]:Gb(()=>Gct(e.absolute));this[tc](a,e)}[J3](e,r){let o=e.mode&4095||this.fmode,a=A=>{let p;try{vn.closeSync(n)}catch(h){p=h}(A||p)&&this[To](A||p,e),r()},n;try{n=vn.openSync(e.absolute,Lfe(e.size),o)}catch(A){return a(A)}let u=this.transform&&this.transform(e)||e;u!==e&&(u.on("error",A=>this[To](A,e)),e.pipe(u)),u.on("data",A=>{try{vn.writeSync(n,A,0,A.length)}catch(p){a(p)}}),u.on("end",A=>{let p=null;if(e.mtime&&!this.noMtime){let h=e.atime||new Date,C=e.mtime;try{vn.futimesSync(n,h,C)}catch(I){try{vn.utimesSync(e.absolute,h,C)}catch{p=I}}}if(this[V1](e)){let h=this[z1](e),C=this[J1](e);try{vn.fchownSync(n,h,C)}catch(I){try{vn.chownSync(e.absolute,h,C)}catch{p=p||I}}}a(p)})}[X3](e,r){let o=e.mode&4095||this.dmode,a=this[vh](e.absolute,o);if(a){this[To](a,e),r();return}if(e.mtime&&!this.noMtime)try{vn.utimesSync(e.absolute,e.atime||new Date,e.mtime)}catch{}if(this[V1](e))try{vn.chownSync(e.absolute,this[z1](e),this[J1](e))}catch{}r(),e.resume()}[vh](e,r){try{return Rfe.sync(fl(e),{uid:this.uid,gid:this.gid,processUid:this.processUid,processGid:this.processGid,umask:this.processUmask,preserve:this.preservePaths,unlink:this.unlink,cache:this.dirCache,cwd:this.cwd,mode:r})}catch(o){return o}}[Wb](e,r,o,a){try{vn[o+"Sync"](r,e.absolute),a(),e.resume()}catch(n){return this[To](n,e)}}};$1.Sync=$3;Nfe.exports=$1});var Hfe=_((CUt,_fe)=>{"use strict";var Kct=NE(),Kb=e_(),Mfe=Be("fs"),Ufe=$E(),Ofe=Be("path"),t_=GE();_fe.exports=(t,e,r)=>{typeof t=="function"?(r=t,e=null,t={}):Array.isArray(t)&&(e=t,t={}),typeof e=="function"&&(r=e,e=null),e?e=Array.from(e):e=[];let o=Kct(t);if(o.sync&&typeof r=="function")throw new TypeError("callback not supported for sync tar functions");if(!o.file&&typeof r=="function")throw new TypeError("callback only supported with file option");return e.length&&Vct(o,e),o.file&&o.sync?zct(o):o.file?Jct(o,r):o.sync?Xct(o):Zct(o)};var Vct=(t,e)=>{let r=new Map(e.map(n=>[t_(n),!0])),o=t.filter,a=(n,u)=>{let A=u||Ofe.parse(n).root||".",p=n===A?!1:r.has(n)?r.get(n):a(Ofe.dirname(n),A);return r.set(n,p),p};t.filter=o?(n,u)=>o(n,u)&&a(t_(n)):n=>a(t_(n))},zct=t=>{let e=new Kb.Sync(t),r=t.file,o=Mfe.statSync(r),a=t.maxReadSize||16*1024*1024;new Ufe.ReadStreamSync(r,{readSize:a,size:o.size}).pipe(e)},Jct=(t,e)=>{let r=new Kb(t),o=t.maxReadSize||16*1024*1024,a=t.file,n=new Promise((u,A)=>{r.on("error",A),r.on("close",u),Mfe.stat(a,(p,h)=>{if(p)A(p);else{let C=new Ufe.ReadStream(a,{readSize:o,size:h.size});C.on("error",A),C.pipe(r)}})});return e?n.then(e,e):n},Xct=t=>new Kb.Sync(t),Zct=t=>new Kb(t)});var jfe=_(us=>{"use strict";us.c=us.create=BAe();us.r=us.replace=N3();us.t=us.list=Lb();us.u=us.update=FAe();us.x=us.extract=Hfe();us.Pack=Cb();us.Unpack=e_();us.Parse=Tb();us.ReadEntry=rb();us.WriteEntry=A3();us.Header=qE();us.Pax=ib();us.types=KU()});var r_,qfe,Dh,e2,t2,Gfe=yt(()=>{r_=$e(nd()),qfe=Be("worker_threads"),Dh=Symbol("kTaskInfo"),e2=class{constructor(e,r){this.fn=e;this.limit=(0,r_.default)(r.poolSize)}run(e){return this.limit(()=>this.fn(e))}},t2=class{constructor(e,r){this.source=e;this.workers=[];this.limit=(0,r_.default)(r.poolSize),this.cleanupInterval=setInterval(()=>{if(this.limit.pendingCount===0&&this.limit.activeCount===0){let o=this.workers.pop();o?o.terminate():clearInterval(this.cleanupInterval)}},5e3).unref()}createWorker(){this.cleanupInterval.refresh();let e=new qfe.Worker(this.source,{eval:!0,execArgv:[...process.execArgv,"--unhandled-rejections=strict"]});return e.on("message",r=>{if(!e[Dh])throw new Error("Assertion failed: Worker sent a result without having a task assigned");e[Dh].resolve(r),e[Dh]=null,e.unref(),this.workers.push(e)}),e.on("error",r=>{e[Dh]?.reject(r),e[Dh]=null}),e.on("exit",r=>{r!==0&&e[Dh]?.reject(new Error(`Worker exited with code ${r}`)),e[Dh]=null}),e}run(e){return this.limit(()=>{let r=this.workers.pop()??this.createWorker();return r.ref(),new Promise((o,a)=>{r[Dh]={resolve:o,reject:a},r.postMessage(e)})})}}});var Wfe=_((vUt,Yfe)=>{var n_;Yfe.exports.getContent=()=>(typeof n_>"u"&&(n_=Be("zlib").brotliDecompressSync(Buffer.from("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","base64")).toString()),n_)});var Xi={};Vt(Xi,{convertToZip:()=>rut,convertToZipWorker:()=>o_,extractArchiveTo:()=>Xfe,getDefaultTaskPool:()=>zfe,getTaskPoolForConfiguration:()=>Jfe,makeArchiveFromDirectory:()=>tut});function $ct(t,e){switch(t){case"async":return new e2(o_,{poolSize:e});case"workers":return new t2((0,s_.getContent)(),{poolSize:e});default:throw new Error(`Assertion failed: Unknown value ${t} for taskPoolMode`)}}function zfe(){return typeof i_>"u"&&(i_=$ct("workers",zi.availableParallelism())),i_}function Jfe(t){return typeof t>"u"?zfe():ol(eut,t,()=>{let e=t.get("taskPoolMode"),r=t.get("taskPoolConcurrency");switch(e){case"async":return new e2(o_,{poolSize:r});case"workers":return new t2((0,s_.getContent)(),{poolSize:r});default:throw new Error(`Assertion failed: Unknown value ${e} for taskPoolMode`)}})}async function o_(t){let{tmpFile:e,tgz:r,compressionLevel:o,extractBufferOpts:a}=t,n=new Ji(e,{create:!0,level:o,stats:Ea.makeDefaultStats()}),u=Buffer.from(r.buffer,r.byteOffset,r.byteLength);return await Xfe(u,n,a),n.saveAndClose(),e}async function tut(t,{baseFs:e=new Tn,prefixPath:r=Bt.root,compressionLevel:o,inMemory:a=!1}={}){let n;if(a)n=new Ji(null,{level:o});else{let A=await oe.mktempPromise(),p=V.join(A,"archive.zip");n=new Ji(p,{create:!0,level:o})}let u=V.resolve(Bt.root,r);return await n.copyPromise(u,t,{baseFs:e,stableTime:!0,stableSort:!0}),n}async function rut(t,e={}){let r=await oe.mktempPromise(),o=V.join(r,"archive.zip"),a=e.compressionLevel??e.configuration?.get("compressionLevel")??"mixed",n={prefixPath:e.prefixPath,stripComponents:e.stripComponents};return await(e.taskPool??Jfe(e.configuration)).run({tmpFile:o,tgz:t,compressionLevel:a,extractBufferOpts:n}),new Ji(o,{level:e.compressionLevel})}async function*nut(t){let e=new Vfe.default.Parse,r=new Kfe.PassThrough({objectMode:!0,autoDestroy:!0,emitClose:!0});e.on("entry",o=>{r.write(o)}),e.on("error",o=>{r.destroy(o)}),e.on("close",()=>{r.destroyed||r.end()}),e.end(t);for await(let o of r){let a=o;yield a,a.resume()}}async function Xfe(t,e,{stripComponents:r=0,prefixPath:o=Bt.dot}={}){function a(n){if(n.path[0]==="/")return!0;let u=n.path.split(/\//g);return!!(u.some(A=>A==="..")||u.length<=r)}for await(let n of nut(t)){if(a(n))continue;let u=V.normalize(fe.toPortablePath(n.path)).replace(/\/$/,"").split(/\//g);if(u.length<=r)continue;let A=u.slice(r).join("/"),p=V.join(o,A),h=420;switch((n.type==="Directory"||((n.mode??0)&73)!==0)&&(h|=73),n.type){case"Directory":e.mkdirpSync(V.dirname(p),{chmod:493,utimes:[vi.SAFE_TIME,vi.SAFE_TIME]}),e.mkdirSync(p,{mode:h}),e.utimesSync(p,vi.SAFE_TIME,vi.SAFE_TIME);break;case"OldFile":case"File":e.mkdirpSync(V.dirname(p),{chmod:493,utimes:[vi.SAFE_TIME,vi.SAFE_TIME]}),e.writeFileSync(p,await Ky(n),{mode:h}),e.utimesSync(p,vi.SAFE_TIME,vi.SAFE_TIME);break;case"SymbolicLink":e.mkdirpSync(V.dirname(p),{chmod:493,utimes:[vi.SAFE_TIME,vi.SAFE_TIME]}),e.symlinkSync(n.linkpath,p),e.lutimesSync(p,vi.SAFE_TIME,vi.SAFE_TIME);break}}return e}var Kfe,Vfe,s_,i_,eut,Zfe=yt(()=>{Ye();Pt();nA();Kfe=Be("stream"),Vfe=$e(jfe());Gfe();jl();s_=$e(Wfe());eut=new WeakMap});var epe=_((a_,$fe)=>{(function(t,e){typeof a_=="object"?$fe.exports=e():typeof define=="function"&&define.amd?define(e):t.treeify=e()})(a_,function(){function t(a,n){var u=n?"\u2514":"\u251C";return a?u+="\u2500 ":u+="\u2500\u2500\u2510",u}function e(a,n){var u=[];for(var A in a)!a.hasOwnProperty(A)||n&&typeof a[A]=="function"||u.push(A);return u}function r(a,n,u,A,p,h,C){var I="",v=0,b,E,F=A.slice(0);if(F.push([n,u])&&A.length>0&&(A.forEach(function(U,z){z>0&&(I+=(U[1]?" ":"\u2502")+" "),!E&&U[0]===n&&(E=!0)}),I+=t(a,u)+a,p&&(typeof n!="object"||n instanceof Date)&&(I+=": "+n),E&&(I+=" (circular ref.)"),C(I)),!E&&typeof n=="object"){var N=e(n,h);N.forEach(function(U){b=++v===N.length,r(U,n[U],b,F,p,h,C)})}}var o={};return o.asLines=function(a,n,u,A){var p=typeof u!="function"?u:!1;r(".",a,!1,[],n,p,A||u)},o.asTree=function(a,n,u){var A="";return r(".",a,!1,[],n,u,function(p){A+=p+` -`}),A},o})});var $s={};Vt($s,{emitList:()=>iut,emitTree:()=>ipe,treeNodeToJson:()=>npe,treeNodeToTreeify:()=>rpe});function rpe(t,{configuration:e}){let r={},o=0,a=(n,u)=>{let A=Array.isArray(n)?n.entries():Object.entries(n);for(let[p,h]of A){if(!h)continue;let{label:C,value:I,children:v}=h,b=[];typeof C<"u"&&b.push(md(e,C,2)),typeof I<"u"&&b.push(_t(e,I[0],I[1])),b.length===0&&b.push(md(e,`${p}`,2));let E=b.join(": ").trim(),F=`\0${o++}\0`,N=u[`${F}${E}`]={};typeof v<"u"&&a(v,N)}};if(typeof t.children>"u")throw new Error("The root node must only contain children");return a(t.children,r),r}function npe(t){let e=r=>{if(typeof r.children>"u"){if(typeof r.value>"u")throw new Error("Assertion failed: Expected a value to be set if the children are missing");return yd(r.value[0],r.value[1])}let o=Array.isArray(r.children)?r.children.entries():Object.entries(r.children??{}),a=Array.isArray(r.children)?[]:{};for(let[n,u]of o)u&&(a[sut(n)]=e(u));return typeof r.value>"u"?a:{value:yd(r.value[0],r.value[1]),children:a}};return e(t)}function iut(t,{configuration:e,stdout:r,json:o}){let a=t.map(n=>({value:n}));ipe({children:a},{configuration:e,stdout:r,json:o})}function ipe(t,{configuration:e,stdout:r,json:o,separators:a=0}){if(o){let u=Array.isArray(t.children)?t.children.values():Object.values(t.children??{});for(let A of u)A&&r.write(`${JSON.stringify(npe(A))} -`);return}let n=(0,tpe.asTree)(rpe(t,{configuration:e}),!1,!1);if(n=n.replace(/\0[0-9]+\0/g,""),a>=1&&(n=n.replace(/^([├└]─)/gm,`\u2502 -$1`).replace(/^│\n/,"")),a>=2)for(let u=0;u<2;++u)n=n.replace(/^([│ ].{2}[├│ ].{2}[^\n]+\n)(([│ ]).{2}[├└].{2}[^\n]*\n[│ ].{2}[│ ].{2}[├└]─)/gm,`$1$3 \u2502 (\\n)? -$2`).replace(/^│\n/,"");if(a>=3)throw new Error("Only the first two levels are accepted by treeUtils.emitTree");r.write(n)}function sut(t){return typeof t=="string"?t.replace(/^\0[0-9]+\0/,""):t}var tpe,spe=yt(()=>{tpe=$e(epe());ql()});function r2(t){let e=t.match(out);if(!e?.groups)throw new Error("Assertion failed: Expected the checksum to match the requested pattern");let r=e.groups.cacheVersion?parseInt(e.groups.cacheVersion):null;return{cacheKey:e.groups.cacheKey??null,cacheVersion:r,cacheSpec:e.groups.cacheSpec??null,hash:e.groups.hash}}var ope,l_,c_,Vb,Nr,out,u_=yt(()=>{Ye();Pt();Pt();nA();ope=Be("crypto"),l_=$e(Be("fs"));Yl();rh();jl();xo();c_=Vy(process.env.YARN_CACHE_CHECKPOINT_OVERRIDE??process.env.YARN_CACHE_VERSION_OVERRIDE??9),Vb=Vy(process.env.YARN_CACHE_VERSION_OVERRIDE??10),Nr=class{constructor(e,{configuration:r,immutable:o=r.get("enableImmutableCache"),check:a=!1}){this.markedFiles=new Set;this.mutexes=new Map;this.cacheId=`-${(0,ope.randomBytes)(8).toString("hex")}.tmp`;this.configuration=r,this.cwd=e,this.immutable=o,this.check=a;let{cacheSpec:n,cacheKey:u}=Nr.getCacheKey(r);this.cacheSpec=n,this.cacheKey=u}static async find(e,{immutable:r,check:o}={}){let a=new Nr(e.get("cacheFolder"),{configuration:e,immutable:r,check:o});return await a.setup(),a}static getCacheKey(e){let r=e.get("compressionLevel"),o=r!=="mixed"?`c${r}`:"";return{cacheKey:[Vb,o].join(""),cacheSpec:o}}get mirrorCwd(){if(!this.configuration.get("enableMirror"))return null;let e=`${this.configuration.get("globalFolder")}/cache`;return e!==this.cwd?e:null}getVersionFilename(e){return`${aE(e)}-${this.cacheKey}.zip`}getChecksumFilename(e,r){let a=r2(r).hash.slice(0,10);return`${aE(e)}-${a}.zip`}isChecksumCompatible(e){if(e===null)return!1;let{cacheVersion:r,cacheSpec:o}=r2(e);if(r===null||r<c_)return!1;let a=this.configuration.get("cacheMigrationMode");return!(r<Vb&&a==="always"||o!==this.cacheSpec&&a!=="required-only")}getLocatorPath(e,r){return this.mirrorCwd===null?V.resolve(this.cwd,this.getVersionFilename(e)):r===null?V.resolve(this.cwd,this.getVersionFilename(e)):V.resolve(this.cwd,this.getChecksumFilename(e,r))}getLocatorMirrorPath(e){let r=this.mirrorCwd;return r!==null?V.resolve(r,this.getVersionFilename(e)):null}async setup(){if(!this.configuration.get("enableGlobalCache"))if(this.immutable){if(!await oe.existsPromise(this.cwd))throw new Jt(56,"Cache path does not exist.")}else{await oe.mkdirPromise(this.cwd,{recursive:!0});let e=V.resolve(this.cwd,".gitignore");await oe.changeFilePromise(e,`/.gitignore -*.flock -*.tmp -`)}(this.mirrorCwd||!this.immutable)&&await oe.mkdirPromise(this.mirrorCwd||this.cwd,{recursive:!0})}async fetchPackageFromCache(e,r,{onHit:o,onMiss:a,loader:n,...u}){let A=this.getLocatorMirrorPath(e),p=new Tn,h=()=>{let ae=new Ji,Ie=V.join(Bt.root,nM(e));return ae.mkdirSync(Ie,{recursive:!0}),ae.writeJsonSync(V.join(Ie,dr.manifest),{name:fn(e),mocked:!0}),ae},C=async(ae,{isColdHit:Ie,controlPath:Fe=null})=>{if(Fe===null&&u.unstablePackages?.has(e.locatorHash))return{isValid:!0,hash:null};let g=r&&!Ie?r2(r).cacheKey:this.cacheKey,Ee=!u.skipIntegrityCheck||!r?`${g}/${await LS(ae)}`:r;if(Fe!==null){let ce=!u.skipIntegrityCheck||!r?`${this.cacheKey}/${await LS(Fe)}`:r;if(Ee!==ce)throw new Jt(18,"The remote archive doesn't match the local checksum - has the local cache been corrupted?")}let De=null;switch(r!==null&&Ee!==r&&(this.check?De="throw":r2(r).cacheKey!==r2(Ee).cacheKey?De="update":De=this.configuration.get("checksumBehavior")),De){case null:case"update":return{isValid:!0,hash:Ee};case"ignore":return{isValid:!0,hash:r};case"reset":return{isValid:!1,hash:r};default:case"throw":throw new Jt(18,"The remote archive doesn't match the expected checksum")}},I=async ae=>{if(!n)throw new Error(`Cache check required but no loader configured for ${jr(this.configuration,e)}`);let Ie=await n(),Fe=Ie.getRealPath();Ie.saveAndClose(),await oe.chmodPromise(Fe,420);let g=await C(ae,{controlPath:Fe,isColdHit:!1});if(!g.isValid)throw new Error("Assertion failed: Expected a valid checksum");return g.hash},v=async()=>{if(A===null||!await oe.existsPromise(A)){let ae=await n(),Ie=ae.getRealPath();return ae.saveAndClose(),{source:"loader",path:Ie}}return{source:"mirror",path:A}},b=async()=>{if(!n)throw new Error(`Cache entry required but missing for ${jr(this.configuration,e)}`);if(this.immutable)throw new Jt(56,`Cache entry required but missing for ${jr(this.configuration,e)}`);let{path:ae,source:Ie}=await v(),{hash:Fe}=await C(ae,{isColdHit:!0}),g=this.getLocatorPath(e,Fe),Ee=[];Ie!=="mirror"&&A!==null&&Ee.push(async()=>{let ce=`${A}${this.cacheId}`;await oe.copyFilePromise(ae,ce,l_.default.constants.COPYFILE_FICLONE),await oe.chmodPromise(ce,420),await oe.renamePromise(ce,A)}),(!u.mirrorWriteOnly||A===null)&&Ee.push(async()=>{let ce=`${g}${this.cacheId}`;await oe.copyFilePromise(ae,ce,l_.default.constants.COPYFILE_FICLONE),await oe.chmodPromise(ce,420),await oe.renamePromise(ce,g)});let De=u.mirrorWriteOnly?A??g:g;return await Promise.all(Ee.map(ce=>ce())),[!1,De,Fe]},E=async()=>{let Ie=(async()=>{let Fe=u.unstablePackages?.has(e.locatorHash),g=Fe||!r||this.isChecksumCompatible(r)?this.getLocatorPath(e,r):null,Ee=g!==null?this.markedFiles.has(g)||await p.existsPromise(g):!1,De=!!u.mockedPackages?.has(e.locatorHash)&&(!this.check||!Ee),ce=De||Ee,ne=ce?o:a;if(ne&&ne(),ce){let ee=null,we=g;if(!De)if(this.check)ee=await I(we);else{let be=await C(we,{isColdHit:!1});if(be.isValid)ee=be.hash;else return b()}return[De,we,ee]}else{if(this.immutable&&Fe)throw new Jt(56,`Cache entry required but missing for ${jr(this.configuration,e)}; consider defining ${de.pretty(this.configuration,"supportedArchitectures",de.Type.CODE)} to cache packages for multiple systems`);return b()}})();this.mutexes.set(e.locatorHash,Ie);try{return await Ie}finally{this.mutexes.delete(e.locatorHash)}};for(let ae;ae=this.mutexes.get(e.locatorHash);)await ae;let[F,N,U]=await E();F||this.markedFiles.add(N);let z,te=F?()=>h():()=>new Ji(N,{baseFs:p,readOnly:!0}),le=new ny(()=>CN(()=>z=te(),ae=>`Failed to open the cache entry for ${jr(this.configuration,e)}: ${ae}`),V),pe=new Uu(N,{baseFs:le,pathUtils:V}),ue=()=>{z?.discardAndClose()},ye=u.unstablePackages?.has(e.locatorHash)?null:U;return[pe,ue,ye]}},out=/^(?:(?<cacheKey>(?<cacheVersion>[0-9]+)(?<cacheSpec>.*))\/)?(?<hash>.*)$/});var zb,ape=yt(()=>{zb=(r=>(r[r.SCRIPT=0]="SCRIPT",r[r.SHELLCODE=1]="SHELLCODE",r))(zb||{})});var aut,sC,A_=yt(()=>{Pt();Ll();bf();xo();aut=[[/^(git(?:\+(?:https|ssh))?:\/\/.*(?:\.git)?)#(.*)$/,(t,e,r,o)=>`${r}#commit=${o}`],[/^https:\/\/((?:[^/]+?)@)?codeload\.github\.com\/([^/]+\/[^/]+)\/tar\.gz\/([0-9a-f]+)$/,(t,e,r="",o,a)=>`https://${r}github.com/${o}.git#commit=${a}`],[/^https:\/\/((?:[^/]+?)@)?github\.com\/([^/]+\/[^/]+?)(?:\.git)?#([0-9a-f]+)$/,(t,e,r="",o,a)=>`https://${r}github.com/${o}.git#commit=${a}`],[/^https?:\/\/[^/]+\/(?:[^/]+\/)*(?:@.+(?:\/|(?:%2f)))?([^/]+)\/(?:-|download)\/\1-[^/]+\.tgz(?:#|$)/,t=>`npm:${t}`],[/^https:\/\/npm\.pkg\.github\.com\/download\/(?:@[^/]+)\/(?:[^/]+)\/(?:[^/]+)\/(?:[0-9a-f]+)(?:#|$)/,t=>`npm:${t}`],[/^https:\/\/npm\.fontawesome\.com\/(?:@[^/]+)\/([^/]+)\/-\/([^/]+)\/\1-\2.tgz(?:#|$)/,t=>`npm:${t}`],[/^https?:\/\/[^/]+\/.*\/(@[^/]+)\/([^/]+)\/-\/\1\/\2-(?:[.\d\w-]+)\.tgz(?:#|$)/,(t,e)=>_S({protocol:"npm:",source:null,selector:t,params:{__archiveUrl:e}})],[/^[^/]+\.tgz#[0-9a-f]+$/,t=>`npm:${t}`]],sC=class{constructor(e){this.resolver=e;this.resolutions=null}async setup(e,{report:r}){let o=V.join(e.cwd,dr.lockfile);if(!oe.existsSync(o))return;let a=await oe.readFilePromise(o,"utf8"),n=Ki(a);if(Object.hasOwn(n,"__metadata"))return;let u=this.resolutions=new Map;for(let A of Object.keys(n)){let p=n1(A);if(!p){r.reportWarning(14,`Failed to parse the string "${A}" into a proper descriptor`);continue}let h=ba(p.range)?In(p,`npm:${p.range}`):p,{version:C,resolved:I}=n[A];if(!I)continue;let v;for(let[E,F]of aut){let N=I.match(E);if(N){v=F(C,...N);break}}if(!v){r.reportWarning(14,`${qn(e.configuration,h)}: Only some patterns can be imported from legacy lockfiles (not "${I}")`);continue}let b=h;try{let E=Id(h.range),F=n1(E.selector,!0);F&&(b=F)}catch{}u.set(h.descriptorHash,Fs(b,v))}}supportsDescriptor(e,r){return this.resolutions?this.resolutions.has(e.descriptorHash):!1}supportsLocator(e,r){return!1}shouldPersistResolution(e,r){throw new Error("Assertion failed: This resolver doesn't support resolving locators to packages")}bindDescriptor(e,r,o){return e}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){if(!this.resolutions)throw new Error("Assertion failed: The resolution store should have been setup");let a=this.resolutions.get(e.descriptorHash);if(!a)throw new Error("Assertion failed: The resolution should have been registered");let n=$O(a),u=o.project.configuration.normalizeDependency(n);return await this.resolver.getCandidates(u,r,o)}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){throw new Error("Assertion failed: This resolver doesn't support resolving locators to packages")}}});var AA,lpe=yt(()=>{Yl();L1();ql();AA=class extends Xs{constructor({configuration:r,stdout:o,suggestInstall:a=!0}){super();this.errorCount=0;zI(this,{configuration:r}),this.configuration=r,this.stdout=o,this.suggestInstall=a}static async start(r,o){let a=new this(r);try{await o(a)}catch(n){a.reportExceptionOnce(n)}finally{await a.finalize()}return a}hasErrors(){return this.errorCount>0}exitCode(){return this.hasErrors()?1:0}reportCacheHit(r){}reportCacheMiss(r){}startSectionSync(r,o){return o()}async startSectionPromise(r,o){return await o()}startTimerSync(r,o,a){return(typeof o=="function"?o:a)()}async startTimerPromise(r,o,a){return await(typeof o=="function"?o:a)()}reportSeparator(){}reportInfo(r,o){}reportWarning(r,o){}reportError(r,o){this.errorCount+=1,this.stdout.write(`${_t(this.configuration,"\u27A4","redBright")} ${this.formatNameWithHyperlink(r)}: ${o} -`)}reportProgress(r){return{...Promise.resolve().then(async()=>{for await(let{}of r);}),stop:()=>{}}}reportJson(r){}reportFold(r,o){}async finalize(){this.errorCount>0&&(this.stdout.write(` -`),this.stdout.write(`${_t(this.configuration,"\u27A4","redBright")} Errors happened when preparing the environment required to run this command. -`),this.suggestInstall&&this.stdout.write(`${_t(this.configuration,"\u27A4","redBright")} This might be caused by packages being missing from the lockfile, in which case running "yarn install" might help. -`))}formatNameWithHyperlink(r){return yU(r,{configuration:this.configuration,json:!1})}}});var oC,f_=yt(()=>{xo();oC=class{constructor(e){this.resolver=e}supportsDescriptor(e,r){return!!(r.project.storedResolutions.get(e.descriptorHash)||r.project.originalPackages.has(OS(e).locatorHash))}supportsLocator(e,r){return!!(r.project.originalPackages.has(e.locatorHash)&&!r.project.lockfileNeedsRefresh)}shouldPersistResolution(e,r){throw new Error("The shouldPersistResolution method shouldn't be called on the lockfile resolver, which would always answer yes")}bindDescriptor(e,r,o){return e}getResolutionDependencies(e,r){return this.resolver.getResolutionDependencies(e,r)}async getCandidates(e,r,o){let a=o.project.storedResolutions.get(e.descriptorHash);if(a){let u=o.project.originalPackages.get(a);if(u)return[u]}let n=o.project.originalPackages.get(OS(e).locatorHash);if(n)return[n];throw new Error("Resolution expected from the lockfile data")}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){let o=r.project.originalPackages.get(e.locatorHash);if(!o)throw new Error("The lockfile resolver isn't meant to resolve packages - they should already have been stored into a cache");return o}}});function Yf(){}function lut(t,e,r,o,a){for(var n=0,u=e.length,A=0,p=0;n<u;n++){var h=e[n];if(h.removed){if(h.value=t.join(o.slice(p,p+h.count)),p+=h.count,n&&e[n-1].added){var I=e[n-1];e[n-1]=e[n],e[n]=I}}else{if(!h.added&&a){var C=r.slice(A,A+h.count);C=C.map(function(b,E){var F=o[p+E];return F.length>b.length?F:b}),h.value=t.join(C)}else h.value=t.join(r.slice(A,A+h.count));A+=h.count,h.added||(p+=h.count)}}var v=e[u-1];return u>1&&typeof v.value=="string"&&(v.added||v.removed)&&t.equals("",v.value)&&(e[u-2].value+=v.value,e.pop()),e}function cut(t){return{newPos:t.newPos,components:t.components.slice(0)}}function uut(t,e){if(typeof t=="function")e.callback=t;else if(t)for(var r in t)t.hasOwnProperty(r)&&(e[r]=t[r]);return e}function Ape(t,e,r){return r=uut(r,{ignoreWhitespace:!0}),m_.diff(t,e,r)}function Aut(t,e,r){return y_.diff(t,e,r)}function Jb(t){return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?Jb=function(e){return typeof e}:Jb=function(e){return e&&typeof Symbol=="function"&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Jb(t)}function p_(t){return hut(t)||gut(t)||dut(t)||mut()}function hut(t){if(Array.isArray(t))return h_(t)}function gut(t){if(typeof Symbol<"u"&&Symbol.iterator in Object(t))return Array.from(t)}function dut(t,e){if(!!t){if(typeof t=="string")return h_(t,e);var r=Object.prototype.toString.call(t).slice(8,-1);if(r==="Object"&&t.constructor&&(r=t.constructor.name),r==="Map"||r==="Set")return Array.from(t);if(r==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r))return h_(t,e)}}function h_(t,e){(e==null||e>t.length)&&(e=t.length);for(var r=0,o=new Array(e);r<e;r++)o[r]=t[r];return o}function mut(){throw new TypeError(`Invalid attempt to spread non-iterable instance. -In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function g_(t,e,r,o,a){e=e||[],r=r||[],o&&(t=o(a,t));var n;for(n=0;n<e.length;n+=1)if(e[n]===t)return r[n];var u;if(yut.call(t)==="[object Array]"){for(e.push(t),u=new Array(t.length),r.push(u),n=0;n<t.length;n+=1)u[n]=g_(t[n],e,r,o,a);return e.pop(),r.pop(),u}if(t&&t.toJSON&&(t=t.toJSON()),Jb(t)==="object"&&t!==null){e.push(t),u={},r.push(u);var A=[],p;for(p in t)t.hasOwnProperty(p)&&A.push(p);for(A.sort(),n=0;n<A.length;n+=1)p=A[n],u[p]=g_(t[p],e,r,o,p);e.pop(),r.pop()}else u=t;return u}function fpe(t,e,r,o,a,n,u){u||(u={}),typeof u.context>"u"&&(u.context=4);var A=Aut(r,o,u);if(!A)return;A.push({value:"",lines:[]});function p(U){return U.map(function(z){return" "+z})}for(var h=[],C=0,I=0,v=[],b=1,E=1,F=function(z){var te=A[z],le=te.lines||te.value.replace(/\n$/,"").split(` -`);if(te.lines=le,te.added||te.removed){var pe;if(!C){var ue=A[z-1];C=b,I=E,ue&&(v=u.context>0?p(ue.lines.slice(-u.context)):[],C-=v.length,I-=v.length)}(pe=v).push.apply(pe,p_(le.map(function(ce){return(te.added?"+":"-")+ce}))),te.added?E+=le.length:b+=le.length}else{if(C)if(le.length<=u.context*2&&z<A.length-2){var ye;(ye=v).push.apply(ye,p_(p(le)))}else{var ae,Ie=Math.min(le.length,u.context);(ae=v).push.apply(ae,p_(p(le.slice(0,Ie))));var Fe={oldStart:C,oldLines:b-C+Ie,newStart:I,newLines:E-I+Ie,lines:v};if(z>=A.length-2&&le.length<=u.context){var g=/\n$/.test(r),Ee=/\n$/.test(o),De=le.length==0&&v.length>Fe.oldLines;!g&&De&&r.length>0&&v.splice(Fe.oldLines,0,"\\ No newline at end of file"),(!g&&!De||!Ee)&&v.push("\\ No newline at end of file")}h.push(Fe),C=0,I=0,v=[]}b+=le.length,E+=le.length}},N=0;N<A.length;N++)F(N);return{oldFileName:t,newFileName:e,oldHeader:a,newHeader:n,hunks:h}}var XUt,cpe,upe,m_,y_,fut,put,yut,n2,d_,E_=yt(()=>{Yf.prototype={diff:function(e,r){var o=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{},a=o.callback;typeof o=="function"&&(a=o,o={}),this.options=o;var n=this;function u(F){return a?(setTimeout(function(){a(void 0,F)},0),!0):F}e=this.castInput(e),r=this.castInput(r),e=this.removeEmpty(this.tokenize(e)),r=this.removeEmpty(this.tokenize(r));var A=r.length,p=e.length,h=1,C=A+p;o.maxEditLength&&(C=Math.min(C,o.maxEditLength));var I=[{newPos:-1,components:[]}],v=this.extractCommon(I[0],r,e,0);if(I[0].newPos+1>=A&&v+1>=p)return u([{value:this.join(r),count:r.length}]);function b(){for(var F=-1*h;F<=h;F+=2){var N=void 0,U=I[F-1],z=I[F+1],te=(z?z.newPos:0)-F;U&&(I[F-1]=void 0);var le=U&&U.newPos+1<A,pe=z&&0<=te&&te<p;if(!le&&!pe){I[F]=void 0;continue}if(!le||pe&&U.newPos<z.newPos?(N=cut(z),n.pushComponent(N.components,void 0,!0)):(N=U,N.newPos++,n.pushComponent(N.components,!0,void 0)),te=n.extractCommon(N,r,e,F),N.newPos+1>=A&&te+1>=p)return u(lut(n,N.components,r,e,n.useLongestToken));I[F]=N}h++}if(a)(function F(){setTimeout(function(){if(h>C)return a();b()||F()},0)})();else for(;h<=C;){var E=b();if(E)return E}},pushComponent:function(e,r,o){var a=e[e.length-1];a&&a.added===r&&a.removed===o?e[e.length-1]={count:a.count+1,added:r,removed:o}:e.push({count:1,added:r,removed:o})},extractCommon:function(e,r,o,a){for(var n=r.length,u=o.length,A=e.newPos,p=A-a,h=0;A+1<n&&p+1<u&&this.equals(r[A+1],o[p+1]);)A++,p++,h++;return h&&e.components.push({count:h}),e.newPos=A,p},equals:function(e,r){return this.options.comparator?this.options.comparator(e,r):e===r||this.options.ignoreCase&&e.toLowerCase()===r.toLowerCase()},removeEmpty:function(e){for(var r=[],o=0;o<e.length;o++)e[o]&&r.push(e[o]);return r},castInput:function(e){return e},tokenize:function(e){return e.split("")},join:function(e){return e.join("")}};XUt=new Yf;cpe=/^[A-Za-z\xC0-\u02C6\u02C8-\u02D7\u02DE-\u02FF\u1E00-\u1EFF]+$/,upe=/\S/,m_=new Yf;m_.equals=function(t,e){return this.options.ignoreCase&&(t=t.toLowerCase(),e=e.toLowerCase()),t===e||this.options.ignoreWhitespace&&!upe.test(t)&&!upe.test(e)};m_.tokenize=function(t){for(var e=t.split(/([^\S\r\n]+|[()[\]{}'"\r\n]|\b)/),r=0;r<e.length-1;r++)!e[r+1]&&e[r+2]&&cpe.test(e[r])&&cpe.test(e[r+2])&&(e[r]+=e[r+2],e.splice(r+1,2),r--);return e};y_=new Yf;y_.tokenize=function(t){var e=[],r=t.split(/(\n|\r\n)/);r[r.length-1]||r.pop();for(var o=0;o<r.length;o++){var a=r[o];o%2&&!this.options.newlineIsToken?e[e.length-1]+=a:(this.options.ignoreWhitespace&&(a=a.trim()),e.push(a))}return e};fut=new Yf;fut.tokenize=function(t){return t.split(/(\S.+?[.!?])(?=\s+|$)/)};put=new Yf;put.tokenize=function(t){return t.split(/([{}:;,]|\s+)/)};yut=Object.prototype.toString,n2=new Yf;n2.useLongestToken=!0;n2.tokenize=y_.tokenize;n2.castInput=function(t){var e=this.options,r=e.undefinedReplacement,o=e.stringifyReplacer,a=o===void 0?function(n,u){return typeof u>"u"?r:u}:o;return typeof t=="string"?t:JSON.stringify(g_(t,null,null,a),a," ")};n2.equals=function(t,e){return Yf.prototype.equals.call(n2,t.replace(/,([\r\n])/g,"$1"),e.replace(/,([\r\n])/g,"$1"))};d_=new Yf;d_.tokenize=function(t){return t.slice()};d_.join=d_.removeEmpty=function(t){return t}});var hpe=_(($Ut,ppe)=>{var Eut=Hl(),Cut=fE(),wut=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,Iut=/^\w*$/;function But(t,e){if(Eut(t))return!1;var r=typeof t;return r=="number"||r=="symbol"||r=="boolean"||t==null||Cut(t)?!0:Iut.test(t)||!wut.test(t)||e!=null&&t in Object(e)}ppe.exports=But});var mpe=_((e3t,dpe)=>{var gpe=UP(),vut="Expected a function";function C_(t,e){if(typeof t!="function"||e!=null&&typeof e!="function")throw new TypeError(vut);var r=function(){var o=arguments,a=e?e.apply(this,o):o[0],n=r.cache;if(n.has(a))return n.get(a);var u=t.apply(this,o);return r.cache=n.set(a,u)||n,u};return r.cache=new(C_.Cache||gpe),r}C_.Cache=gpe;dpe.exports=C_});var Epe=_((t3t,ype)=>{var Dut=mpe(),Put=500;function Sut(t){var e=Dut(t,function(o){return r.size===Put&&r.clear(),o}),r=e.cache;return e}ype.exports=Sut});var w_=_((r3t,Cpe)=>{var xut=Epe(),but=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,kut=/\\(\\)?/g,Qut=xut(function(t){var e=[];return t.charCodeAt(0)===46&&e.push(""),t.replace(but,function(r,o,a,n){e.push(a?n.replace(kut,"$1"):o||r)}),e});Cpe.exports=Qut});var jd=_((n3t,wpe)=>{var Fut=Hl(),Rut=hpe(),Tut=w_(),Lut=R1();function Nut(t,e){return Fut(t)?t:Rut(t,e)?[t]:Tut(Lut(t))}wpe.exports=Nut});var aC=_((i3t,Ipe)=>{var Out=fE(),Mut=1/0;function Uut(t){if(typeof t=="string"||Out(t))return t;var e=t+"";return e=="0"&&1/t==-Mut?"-0":e}Ipe.exports=Uut});var Xb=_((s3t,Bpe)=>{var _ut=jd(),Hut=aC();function jut(t,e){e=_ut(e,t);for(var r=0,o=e.length;t!=null&&r<o;)t=t[Hut(e[r++])];return r&&r==o?t:void 0}Bpe.exports=jut});var I_=_((o3t,Dpe)=>{var qut=tS(),Gut=jd(),Yut=MI(),vpe=il(),Wut=aC();function Kut(t,e,r,o){if(!vpe(t))return t;e=Gut(e,t);for(var a=-1,n=e.length,u=n-1,A=t;A!=null&&++a<n;){var p=Wut(e[a]),h=r;if(p==="__proto__"||p==="constructor"||p==="prototype")return t;if(a!=u){var C=A[p];h=o?o(C,p,A):void 0,h===void 0&&(h=vpe(C)?C:Yut(e[a+1])?[]:{})}qut(A,p,h),A=A[p]}return t}Dpe.exports=Kut});var Spe=_((a3t,Ppe)=>{var Vut=Xb(),zut=I_(),Jut=jd();function Xut(t,e,r){for(var o=-1,a=e.length,n={};++o<a;){var u=e[o],A=Vut(t,u);r(A,u)&&zut(n,Jut(u,t),A)}return n}Ppe.exports=Xut});var bpe=_((l3t,xpe)=>{function Zut(t,e){return t!=null&&e in Object(t)}xpe.exports=Zut});var B_=_((c3t,kpe)=>{var $ut=jd(),eAt=LI(),tAt=Hl(),rAt=MI(),nAt=GP(),iAt=aC();function sAt(t,e,r){e=$ut(e,t);for(var o=-1,a=e.length,n=!1;++o<a;){var u=iAt(e[o]);if(!(n=t!=null&&r(t,u)))break;t=t[u]}return n||++o!=a?n:(a=t==null?0:t.length,!!a&&nAt(a)&&rAt(u,a)&&(tAt(t)||eAt(t)))}kpe.exports=sAt});var Fpe=_((u3t,Qpe)=>{var oAt=bpe(),aAt=B_();function lAt(t,e){return t!=null&&aAt(t,e,oAt)}Qpe.exports=lAt});var Tpe=_((A3t,Rpe)=>{var cAt=Spe(),uAt=Fpe();function AAt(t,e){return cAt(t,e,function(r,o){return uAt(t,o)})}Rpe.exports=AAt});var Mpe=_((f3t,Ope)=>{var Lpe=fd(),fAt=LI(),pAt=Hl(),Npe=Lpe?Lpe.isConcatSpreadable:void 0;function hAt(t){return pAt(t)||fAt(t)||!!(Npe&&t&&t[Npe])}Ope.exports=hAt});var Hpe=_((p3t,_pe)=>{var gAt=jP(),dAt=Mpe();function Upe(t,e,r,o,a){var n=-1,u=t.length;for(r||(r=dAt),a||(a=[]);++n<u;){var A=t[n];e>0&&r(A)?e>1?Upe(A,e-1,r,o,a):gAt(a,A):o||(a[a.length]=A)}return a}_pe.exports=Upe});var qpe=_((h3t,jpe)=>{var mAt=Hpe();function yAt(t){var e=t==null?0:t.length;return e?mAt(t,1):[]}jpe.exports=yAt});var v_=_((g3t,Gpe)=>{var EAt=qpe(),CAt=fN(),wAt=pN();function IAt(t){return wAt(CAt(t,void 0,EAt),t+"")}Gpe.exports=IAt});var D_=_((d3t,Ype)=>{var BAt=Tpe(),vAt=v_(),DAt=vAt(function(t,e){return t==null?{}:BAt(t,e)});Ype.exports=DAt});var Zb,Wpe=yt(()=>{Yl();Zb=class{constructor(e){this.resolver=e}supportsDescriptor(e,r){return this.resolver.supportsDescriptor(e,r)}supportsLocator(e,r){return this.resolver.supportsLocator(e,r)}shouldPersistResolution(e,r){return this.resolver.shouldPersistResolution(e,r)}bindDescriptor(e,r,o){return this.resolver.bindDescriptor(e,r,o)}getResolutionDependencies(e,r){return this.resolver.getResolutionDependencies(e,r)}async getCandidates(e,r,o){throw new Jt(20,`This package doesn't seem to be present in your lockfile; run "yarn install" to update the lockfile`)}async getSatisfying(e,r,o,a){throw new Jt(20,`This package doesn't seem to be present in your lockfile; run "yarn install" to update the lockfile`)}async resolve(e,r){throw new Jt(20,`This package doesn't seem to be present in your lockfile; run "yarn install" to update the lockfile`)}}});var Qi,P_=yt(()=>{Yl();Qi=class extends Xs{reportCacheHit(e){}reportCacheMiss(e){}startSectionSync(e,r){return r()}async startSectionPromise(e,r){return await r()}startTimerSync(e,r,o){return(typeof r=="function"?r:o)()}async startTimerPromise(e,r,o){return await(typeof r=="function"?r:o)()}reportSeparator(){}reportInfo(e,r){}reportWarning(e,r){}reportError(e,r){}reportProgress(e){return{...Promise.resolve().then(async()=>{for await(let{}of e);}),stop:()=>{}}}reportJson(e){}reportFold(e,r){}async finalize(){}}});var Kpe,lC,S_=yt(()=>{Pt();Kpe=$e(RS());AE();Bd();ql();rh();bf();xo();lC=class{constructor(e,{project:r}){this.workspacesCwds=new Set;this.project=r,this.cwd=e}async setup(){this.manifest=await Ot.tryFind(this.cwd)??new Ot,this.relativeCwd=V.relative(this.project.cwd,this.cwd)||Bt.dot;let e=this.manifest.name?this.manifest.name:eA(null,`${this.computeCandidateName()}-${Qs(this.relativeCwd).substring(0,6)}`);this.anchoredDescriptor=In(e,`${Xn.protocol}${this.relativeCwd}`),this.anchoredLocator=Fs(e,`${Xn.protocol}${this.relativeCwd}`);let r=this.manifest.workspaceDefinitions.map(({pattern:a})=>a);if(r.length===0)return;let o=await(0,Kpe.default)(r,{cwd:fe.fromPortablePath(this.cwd),onlyDirectories:!0,ignore:["**/node_modules","**/.git","**/.yarn"]});o.sort(),await o.reduce(async(a,n)=>{let u=V.resolve(this.cwd,fe.toPortablePath(n)),A=await oe.existsPromise(V.join(u,"package.json"));await a,A&&this.workspacesCwds.add(u)},Promise.resolve())}get anchoredPackage(){let e=this.project.storedPackages.get(this.anchoredLocator.locatorHash);if(!e)throw new Error(`Assertion failed: Expected workspace ${s1(this.project.configuration,this)} (${_t(this.project.configuration,V.join(this.cwd,dr.manifest),Et.PATH)}) to have been resolved. Run "yarn install" to update the lockfile`);return e}accepts(e){let r=e.indexOf(":"),o=r!==-1?e.slice(0,r+1):null,a=r!==-1?e.slice(r+1):e;if(o===Xn.protocol&&V.normalize(a)===this.relativeCwd||o===Xn.protocol&&(a==="*"||a==="^"||a==="~"))return!0;let n=ba(a);return n?o===Xn.protocol?n.test(this.manifest.version??"0.0.0"):this.project.configuration.get("enableTransparentWorkspaces")&&this.manifest.version!==null?n.test(this.manifest.version):!1:!1}computeCandidateName(){return this.cwd===this.project.cwd?"root-workspace":`${V.basename(this.cwd)}`||"unnamed-workspace"}getRecursiveWorkspaceDependencies({dependencies:e=Ot.hardDependencies}={}){let r=new Set,o=a=>{for(let n of e)for(let u of a.manifest[n].values()){let A=this.project.tryWorkspaceByDescriptor(u);A===null||r.has(A)||(r.add(A),o(A))}};return o(this),r}getRecursiveWorkspaceDependents({dependencies:e=Ot.hardDependencies}={}){let r=new Set,o=a=>{for(let n of this.project.workspaces)e.some(A=>[...n.manifest[A].values()].some(p=>{let h=this.project.tryWorkspaceByDescriptor(p);return h!==null&&r1(h.anchoredLocator,a.anchoredLocator)}))&&!r.has(n)&&(r.add(n),o(n))};return o(this),r}getRecursiveWorkspaceChildren(){let e=[];for(let r of this.workspacesCwds){let o=this.project.workspacesByCwd.get(r);o&&e.push(o,...o.getRecursiveWorkspaceChildren())}return e}async persistManifest(){let e={};this.manifest.exportTo(e);let r=V.join(this.cwd,Ot.fileName),o=`${JSON.stringify(e,null,this.manifest.indent)} -`;await oe.changeFilePromise(r,o,{automaticNewlines:!0}),this.manifest.raw=e}}});function QAt({project:t,allDescriptors:e,allResolutions:r,allPackages:o,accessibleLocators:a=new Set,optionalBuilds:n=new Set,peerRequirements:u=new Map,peerWarnings:A=[],volatileDescriptors:p=new Set}){let h=new Map,C=[],I=new Map,v=new Map,b=new Map,E=new Map,F=new Map,N=new Map(t.workspaces.map(ue=>{let ye=ue.anchoredLocator.locatorHash,ae=o.get(ye);if(typeof ae>"u")throw new Error("Assertion failed: The workspace should have an associated package");return[ye,ZI(ae)]})),U=()=>{let ue=oe.mktempSync(),ye=V.join(ue,"stacktrace.log"),ae=String(C.length+1).length,Ie=C.map((Fe,g)=>`${`${g+1}.`.padStart(ae," ")} ${xa(Fe)} -`).join("");throw oe.writeFileSync(ye,Ie),oe.detachTemp(ue),new Jt(45,`Encountered a stack overflow when resolving peer dependencies; cf ${fe.fromPortablePath(ye)}`)},z=ue=>{let ye=r.get(ue.descriptorHash);if(typeof ye>"u")throw new Error("Assertion failed: The resolution should have been registered");let ae=o.get(ye);if(!ae)throw new Error("Assertion failed: The package could not be found");return ae},te=(ue,ye,ae,{top:Ie,optional:Fe})=>{C.length>1e3&&U(),C.push(ye);let g=le(ue,ye,ae,{top:Ie,optional:Fe});return C.pop(),g},le=(ue,ye,ae,{top:Ie,optional:Fe})=>{if(a.has(ye.locatorHash))return;a.add(ye.locatorHash),Fe||n.delete(ye.locatorHash);let g=o.get(ye.locatorHash);if(!g)throw new Error(`Assertion failed: The package (${jr(t.configuration,ye)}) should have been registered`);let Ee=[],De=[],ce=[],ne=[],ee=[];for(let be of Array.from(g.dependencies.values())){if(g.peerDependencies.has(be.identHash)&&g.locatorHash!==Ie)continue;if(Pf(be))throw new Error("Assertion failed: Virtual packages shouldn't be encountered when virtualizing a branch");p.delete(be.descriptorHash);let ht=Fe;if(!ht){let Re=g.dependenciesMeta.get(fn(be));if(typeof Re<"u"){let ze=Re.get(null);typeof ze<"u"&&ze.optional&&(ht=!0)}}let H=r.get(be.descriptorHash);if(!H)throw new Error(`Assertion failed: The resolution (${qn(t.configuration,be)}) should have been registered`);let lt=N.get(H)||o.get(H);if(!lt)throw new Error(`Assertion failed: The package (${H}, resolved from ${qn(t.configuration,be)}) should have been registered`);if(lt.peerDependencies.size===0){te(be,lt,new Map,{top:Ie,optional:ht});continue}let Te,ke,xe=new Set,He;De.push(()=>{Te=tM(be,ye.locatorHash),ke=rM(lt,ye.locatorHash),g.dependencies.delete(be.identHash),g.dependencies.set(Te.identHash,Te),r.set(Te.descriptorHash,ke.locatorHash),e.set(Te.descriptorHash,Te),o.set(ke.locatorHash,ke),Ee.push([lt,Te,ke])}),ce.push(()=>{He=new Map;for(let Re of ke.peerDependencies.values()){let ze=g.dependencies.get(Re.identHash);if(!ze&&t1(ye,Re)&&(ue.identHash===ye.identHash?ze=ue:(ze=In(ye,ue.range),e.set(ze.descriptorHash,ze),r.set(ze.descriptorHash,ye.locatorHash),p.delete(ze.descriptorHash))),(!ze||ze.range==="missing:")&&ke.dependencies.has(Re.identHash)){ke.peerDependencies.delete(Re.identHash);continue}ze||(ze=In(Re,"missing:")),ke.dependencies.set(ze.identHash,ze),Pf(ze)&&dd(b,ze.descriptorHash).add(ke.locatorHash),I.set(ze.identHash,ze),ze.range==="missing:"&&xe.add(ze.identHash),He.set(Re.identHash,ae.get(Re.identHash)??ke.locatorHash)}ke.dependencies=new Map(ks(ke.dependencies,([Re,ze])=>fn(ze)))}),ne.push(()=>{if(!o.has(ke.locatorHash))return;let Re=h.get(lt.locatorHash);typeof Re=="number"&&Re>=2&&U();let ze=h.get(lt.locatorHash),je=typeof ze<"u"?ze+1:1;h.set(lt.locatorHash,je),te(Te,ke,He,{top:Ie,optional:ht}),h.set(lt.locatorHash,je-1)}),ee.push(()=>{let Re=g.dependencies.get(be.identHash);if(typeof Re>"u")throw new Error("Assertion failed: Expected the peer dependency to have been turned into a dependency");let ze=r.get(Re.descriptorHash);if(typeof ze>"u")throw new Error("Assertion failed: Expected the descriptor to be registered");if(dd(F,ze).add(ye.locatorHash),!!o.has(ke.locatorHash)){for(let je of ke.peerDependencies.values()){let x=He.get(je.identHash);if(typeof x>"u")throw new Error("Assertion failed: Expected the peer dependency ident to be registered");Gy(Yy(E,x),fn(je)).push(ke.locatorHash)}for(let je of xe)ke.dependencies.delete(je)}})}for(let be of[...De,...ce])be();let we;do{we=!0;for(let[be,ht,H]of Ee){let lt=Yy(v,be.locatorHash),Te=Qs(...[...H.dependencies.values()].map(Re=>{let ze=Re.range!=="missing:"?r.get(Re.descriptorHash):"missing:";if(typeof ze>"u")throw new Error(`Assertion failed: Expected the resolution for ${qn(t.configuration,Re)} to have been registered`);return ze===Ie?`${ze} (top)`:ze}),ht.identHash),ke=lt.get(Te);if(typeof ke>"u"){lt.set(Te,ht);continue}if(ke===ht)continue;o.delete(H.locatorHash),e.delete(ht.descriptorHash),r.delete(ht.descriptorHash),a.delete(H.locatorHash);let xe=b.get(ht.descriptorHash)||[],He=[g.locatorHash,...xe];b.delete(ht.descriptorHash);for(let Re of He){let ze=o.get(Re);typeof ze>"u"||(ze.dependencies.get(ht.identHash).descriptorHash!==ke.descriptorHash&&(we=!1),ze.dependencies.set(ht.identHash,ke))}}}while(!we);for(let be of[...ne,...ee])be()};for(let ue of t.workspaces){let ye=ue.anchoredLocator;p.delete(ue.anchoredDescriptor.descriptorHash),te(ue.anchoredDescriptor,ye,new Map,{top:ye.locatorHash,optional:!1})}let pe=new Map;for(let[ue,ye]of F){let ae=o.get(ue);if(typeof ae>"u")throw new Error("Assertion failed: Expected the root to be registered");let Ie=E.get(ue);if(!(typeof Ie>"u"))for(let Fe of ye){let g=o.get(Fe);if(!(typeof g>"u")&&!!t.tryWorkspaceByLocator(g))for(let[Ee,De]of Ie){let ce=Js(Ee);if(g.peerDependencies.has(ce.identHash))continue;let ne=`p${Qs(Fe,Ee,ue).slice(0,5)}`;u.set(ne,{subject:Fe,requested:ce,rootRequester:ue,allRequesters:De});let ee=ae.dependencies.get(ce.identHash);if(typeof ee<"u"){let we=z(ee),be=we.version??"0.0.0",ht=new Set;for(let lt of De){let Te=o.get(lt);if(typeof Te>"u")throw new Error("Assertion failed: Expected the link to be registered");let ke=Te.peerDependencies.get(ce.identHash);if(typeof ke>"u")throw new Error("Assertion failed: Expected the ident to be registered");ht.add(ke.range)}if(![...ht].every(lt=>{if(lt.startsWith(Xn.protocol)){if(!t.tryWorkspaceByLocator(we))return!1;lt=lt.slice(Xn.protocol.length),(lt==="^"||lt==="~")&&(lt="*")}return xf(be,lt)})){let lt=ol(pe,we.locatorHash,()=>({type:2,requested:ce,subject:we,dependents:new Map,requesters:new Map,links:new Map,version:be,hash:`p${Qs(Ee).slice(0,5)}`}));lt.dependents.set(g.locatorHash,g),lt.requesters.set(ae.locatorHash,ae);for(let Te of De)lt.links.set(Te,o.get(Te));A.push({type:1,subject:g,requested:ce,requester:ae,version:be,hash:ne,requirementCount:De.length})}}else ae.peerDependenciesMeta.get(Ee)?.optional||A.push({type:0,subject:g,requested:ce,requester:ae,hash:ne})}}}A.push(...pe.values())}function FAt(t,e){let r=IN(t.peerWarnings,"type"),o=r[2]?.map(n=>{let u=Array.from(n.links.values(),C=>{let I=t.storedPackages.get(C.locatorHash);if(typeof I>"u")throw new Error("Assertion failed: Expected the package to be registered");let v=I.peerDependencies.get(n.requested.identHash);if(typeof v>"u")throw new Error("Assertion failed: Expected the ident to be registered");return v.range}),A=n.dependents.size>1?"and other dependencies request":"requests",p=sM(u),h=p?lE(t.configuration,p):_t(t.configuration,"but they have non-overlapping ranges!","redBright");return`${cs(t.configuration,n.requested)} is listed by your project with version ${i1(t.configuration,n.version)}, which doesn't satisfy what ${cs(t.configuration,n.requesters.values().next().value)} ${A} (${h}).`})??[],a=r[0]?.map(n=>`${jr(t.configuration,n.subject)} doesn't provide ${cs(t.configuration,n.requested)} (${_t(t.configuration,n.hash,Et.CODE)}), requested by ${cs(t.configuration,n.requester)}.`)??[];e.startSectionSync({reportFooter:()=>{e.reportWarning(86,`Some peer dependencies are incorrectly met; run ${_t(t.configuration,"yarn explain peer-requirements <hash>",Et.CODE)} for details, where ${_t(t.configuration,"<hash>",Et.CODE)} is the six-letter p-prefixed code.`)},skipIfEmpty:!0},()=>{for(let n of ks(o,u=>Jy.default(u)))e.reportWarning(60,n);for(let n of ks(a,u=>Jy.default(u)))e.reportWarning(2,n)})}var $b,ek,tk,Jpe,k_,b_,Q_,rk,PAt,SAt,Vpe,xAt,bAt,kAt,pl,x_,zpe,St,Xpe=yt(()=>{Pt();Pt();Ll();qt();$b=Be("crypto");E_();ek=$e(D_()),tk=$e(nd()),Jpe=$e(Jn()),k_=Be("util"),b_=$e(Be("v8")),Q_=$e(Be("zlib"));u_();v1();A_();f_();AE();uM();Yl();Wpe();L1();P_();Bd();S_();WS();ql();rh();jl();vx();BU();bf();xo();rk=Vy(process.env.YARN_LOCKFILE_VERSION_OVERRIDE??8),PAt=3,SAt=/ *, */g,Vpe=/\/$/,xAt=32,bAt=(0,k_.promisify)(Q_.default.gzip),kAt=(0,k_.promisify)(Q_.default.gunzip),pl=(r=>(r.UpdateLockfile="update-lockfile",r.SkipBuild="skip-build",r))(pl||{}),x_={restoreLinkersCustomData:["linkersCustomData"],restoreResolutions:["accessibleLocators","conditionalLocators","disabledLocators","optionalBuilds","storedDescriptors","storedResolutions","storedPackages","lockFileChecksum"],restoreBuildState:["skippedBuilds","storedBuildState"]},zpe=t=>Qs(`${PAt}`,t),St=class{constructor(e,{configuration:r}){this.resolutionAliases=new Map;this.workspaces=[];this.workspacesByCwd=new Map;this.workspacesByIdent=new Map;this.storedResolutions=new Map;this.storedDescriptors=new Map;this.storedPackages=new Map;this.storedChecksums=new Map;this.storedBuildState=new Map;this.accessibleLocators=new Set;this.conditionalLocators=new Set;this.disabledLocators=new Set;this.originalPackages=new Map;this.optionalBuilds=new Set;this.skippedBuilds=new Set;this.lockfileLastVersion=null;this.lockfileNeedsRefresh=!1;this.peerRequirements=new Map;this.peerWarnings=[];this.linkersCustomData=new Map;this.lockFileChecksum=null;this.installStateChecksum=null;this.configuration=r,this.cwd=e}static async find(e,r){if(!e.projectCwd)throw new it(`No project found in ${r}`);let o=e.projectCwd,a=r,n=null;for(;n!==e.projectCwd;){if(n=a,oe.existsSync(V.join(n,dr.manifest))){o=n;break}a=V.dirname(n)}let u=new St(e.projectCwd,{configuration:e});Ke.telemetry?.reportProject(u.cwd),await u.setupResolutions(),await u.setupWorkspaces(),Ke.telemetry?.reportWorkspaceCount(u.workspaces.length),Ke.telemetry?.reportDependencyCount(u.workspaces.reduce((E,F)=>E+F.manifest.dependencies.size+F.manifest.devDependencies.size,0));let A=u.tryWorkspaceByCwd(o);if(A)return{project:u,workspace:A,locator:A.anchoredLocator};let p=await u.findLocatorForLocation(`${o}/`,{strict:!0});if(p)return{project:u,locator:p,workspace:null};let h=_t(e,u.cwd,Et.PATH),C=_t(e,V.relative(u.cwd,o),Et.PATH),I=`- If ${h} isn't intended to be a project, remove any yarn.lock and/or package.json file there.`,v=`- If ${h} is intended to be a project, it might be that you forgot to list ${C} in its workspace configuration.`,b=`- Finally, if ${h} is fine and you intend ${C} to be treated as a completely separate project (not even a workspace), create an empty yarn.lock file in it.`;throw new it(`The nearest package directory (${_t(e,o,Et.PATH)}) doesn't seem to be part of the project declared in ${_t(e,u.cwd,Et.PATH)}. - -${[I,v,b].join(` -`)}`)}async setupResolutions(){this.storedResolutions=new Map,this.storedDescriptors=new Map,this.storedPackages=new Map,this.lockFileChecksum=null;let e=V.join(this.cwd,dr.lockfile),r=this.configuration.get("defaultLanguageName");if(oe.existsSync(e)){let o=await oe.readFilePromise(e,"utf8");this.lockFileChecksum=zpe(o);let a=Ki(o);if(a.__metadata){let n=a.__metadata.version,u=a.__metadata.cacheKey;this.lockfileLastVersion=n,this.lockfileNeedsRefresh=n<rk;for(let A of Object.keys(a)){if(A==="__metadata")continue;let p=a[A];if(typeof p.resolution>"u")throw new Error(`Assertion failed: Expected the lockfile entry to have a resolution field (${A})`);let h=Sf(p.resolution,!0),C=new Ot;C.load(p,{yamlCompatibilityMode:!0});let I=C.version,v=C.languageName||r,b=p.linkType.toUpperCase(),E=p.conditions??null,F=C.dependencies,N=C.peerDependencies,U=C.dependenciesMeta,z=C.peerDependenciesMeta,te=C.bin;if(p.checksum!=null){let pe=typeof u<"u"&&!p.checksum.includes("/")?`${u}/${p.checksum}`:p.checksum;this.storedChecksums.set(h.locatorHash,pe)}let le={...h,version:I,languageName:v,linkType:b,conditions:E,dependencies:F,peerDependencies:N,dependenciesMeta:U,peerDependenciesMeta:z,bin:te};this.originalPackages.set(le.locatorHash,le);for(let pe of A.split(SAt)){let ue=nh(pe);n<=6&&(ue=this.configuration.normalizeDependency(ue),ue=In(ue,ue.range.replace(/^patch:[^@]+@(?!npm(:|%3A))/,"$1npm%3A"))),this.storedDescriptors.set(ue.descriptorHash,ue),this.storedResolutions.set(ue.descriptorHash,h.locatorHash)}}}else o.includes("yarn lockfile v1")&&(this.lockfileLastVersion=-1)}}async setupWorkspaces(){this.workspaces=[],this.workspacesByCwd=new Map,this.workspacesByIdent=new Map;let e=new Set,r=(0,tk.default)(4),o=async(a,n)=>{if(e.has(n))return a;e.add(n);let u=new lC(n,{project:this});await r(()=>u.setup());let A=a.then(()=>{this.addWorkspace(u)});return Array.from(u.workspacesCwds).reduce(o,A)};await o(Promise.resolve(),this.cwd)}addWorkspace(e){let r=this.workspacesByIdent.get(e.anchoredLocator.identHash);if(typeof r<"u")throw new Error(`Duplicate workspace name ${cs(this.configuration,e.anchoredLocator)}: ${fe.fromPortablePath(e.cwd)} conflicts with ${fe.fromPortablePath(r.cwd)}`);this.workspaces.push(e),this.workspacesByCwd.set(e.cwd,e),this.workspacesByIdent.set(e.anchoredLocator.identHash,e)}get topLevelWorkspace(){return this.getWorkspaceByCwd(this.cwd)}tryWorkspaceByCwd(e){V.isAbsolute(e)||(e=V.resolve(this.cwd,e)),e=V.normalize(e).replace(/\/+$/,"");let r=this.workspacesByCwd.get(e);return r||null}getWorkspaceByCwd(e){let r=this.tryWorkspaceByCwd(e);if(!r)throw new Error(`Workspace not found (${e})`);return r}tryWorkspaceByFilePath(e){let r=null;for(let o of this.workspaces)V.relative(o.cwd,e).startsWith("../")||r&&r.cwd.length>=o.cwd.length||(r=o);return r||null}getWorkspaceByFilePath(e){let r=this.tryWorkspaceByFilePath(e);if(!r)throw new Error(`Workspace not found (${e})`);return r}tryWorkspaceByIdent(e){let r=this.workspacesByIdent.get(e.identHash);return typeof r>"u"?null:r}getWorkspaceByIdent(e){let r=this.tryWorkspaceByIdent(e);if(!r)throw new Error(`Workspace not found (${cs(this.configuration,e)})`);return r}tryWorkspaceByDescriptor(e){if(e.range.startsWith(Xn.protocol)){let o=e.range.slice(Xn.protocol.length);if(o!=="^"&&o!=="~"&&o!=="*"&&!ba(o))return this.tryWorkspaceByCwd(o)}let r=this.tryWorkspaceByIdent(e);return r===null||(Pf(e)&&(e=$I(e)),!r.accepts(e.range))?null:r}getWorkspaceByDescriptor(e){let r=this.tryWorkspaceByDescriptor(e);if(r===null)throw new Error(`Workspace not found (${qn(this.configuration,e)})`);return r}tryWorkspaceByLocator(e){let r=this.tryWorkspaceByIdent(e);return r===null||(Hc(e)&&(e=e1(e)),r.anchoredLocator.locatorHash!==e.locatorHash)?null:r}getWorkspaceByLocator(e){let r=this.tryWorkspaceByLocator(e);if(!r)throw new Error(`Workspace not found (${jr(this.configuration,e)})`);return r}deleteDescriptor(e){this.storedResolutions.delete(e),this.storedDescriptors.delete(e)}deleteLocator(e){this.originalPackages.delete(e),this.storedPackages.delete(e),this.accessibleLocators.delete(e)}forgetResolution(e){if("descriptorHash"in e){let r=this.storedResolutions.get(e.descriptorHash);this.deleteDescriptor(e.descriptorHash);let o=new Set(this.storedResolutions.values());typeof r<"u"&&!o.has(r)&&this.deleteLocator(r)}if("locatorHash"in e){this.deleteLocator(e.locatorHash);for(let[r,o]of this.storedResolutions)o===e.locatorHash&&this.deleteDescriptor(r)}}forgetTransientResolutions(){let e=this.configuration.makeResolver(),r=new Map;for(let[o,a]of this.storedResolutions.entries()){let n=r.get(a);n||r.set(a,n=new Set),n.add(o)}for(let o of this.originalPackages.values()){let a;try{a=e.shouldPersistResolution(o,{project:this,resolver:e})}catch{a=!1}if(!a){this.deleteLocator(o.locatorHash);let n=r.get(o.locatorHash);if(n){r.delete(o.locatorHash);for(let u of n)this.deleteDescriptor(u)}}}}forgetVirtualResolutions(){for(let e of this.storedPackages.values())for(let[r,o]of e.dependencies)Pf(o)&&e.dependencies.set(r,$I(o))}getDependencyMeta(e,r){let o={},n=this.topLevelWorkspace.manifest.dependenciesMeta.get(fn(e));if(!n)return o;let u=n.get(null);if(u&&Object.assign(o,u),r===null||!Jpe.default.valid(r))return o;for(let[A,p]of n)A!==null&&A===r&&Object.assign(o,p);return o}async findLocatorForLocation(e,{strict:r=!1}={}){let o=new Qi,a=this.configuration.getLinkers(),n={project:this,report:o};for(let u of a){let A=await u.findPackageLocator(e,n);if(A){if(r&&(await u.findPackageLocation(A,n)).replace(Vpe,"")!==e.replace(Vpe,""))continue;return A}}return null}async loadUserConfig(){let e=V.join(this.cwd,"yarn.config.cjs");return await oe.existsPromise(e)?zp(e):null}async preparePackage(e,{resolver:r,resolveOptions:o}){let a=await this.configuration.getPackageExtensions(),n=this.configuration.normalizePackage(e,{packageExtensions:a});for(let[u,A]of n.dependencies){let p=await this.configuration.reduceHook(C=>C.reduceDependency,A,this,n,A,{resolver:r,resolveOptions:o});if(!t1(A,p))throw new Error("Assertion failed: The descriptor ident cannot be changed through aliases");let h=r.bindDescriptor(p,n,o);n.dependencies.set(u,h)}return n}async resolveEverything(e){if(!this.workspacesByCwd||!this.workspacesByIdent)throw new Error("Workspaces must have been setup before calling this function");this.forgetVirtualResolutions();let r=new Map(this.originalPackages),o=[];e.lockfileOnly||this.forgetTransientResolutions();let a=e.resolver||this.configuration.makeResolver(),n=new sC(a);await n.setup(this,{report:e.report});let u=e.lockfileOnly?[new Zb(a)]:[n,a],A=new vd([new oC(a),...u]),p=new vd([...u]),h=this.configuration.makeFetcher(),C=e.lockfileOnly?{project:this,report:e.report,resolver:A}:{project:this,report:e.report,resolver:A,fetchOptions:{project:this,cache:e.cache,checksums:this.storedChecksums,report:e.report,fetcher:h,cacheOptions:{mirrorWriteOnly:!0}}},I=new Map,v=new Map,b=new Map,E=new Map,F=new Map,N=new Map,U=this.topLevelWorkspace.anchoredLocator,z=new Set,te=[],le=M4(),pe=this.configuration.getSupportedArchitectures();await e.report.startProgressPromise(Xs.progressViaTitle(),async ce=>{let ne=async H=>{let lt=await Wy(async()=>await A.resolve(H,C),He=>`${jr(this.configuration,H)}: ${He}`);if(!r1(H,lt))throw new Error(`Assertion failed: The locator cannot be changed by the resolver (went from ${jr(this.configuration,H)} to ${jr(this.configuration,lt)})`);E.set(lt.locatorHash,lt),!r.delete(lt.locatorHash)&&!this.tryWorkspaceByLocator(lt)&&o.push(lt);let ke=await this.preparePackage(lt,{resolver:A,resolveOptions:C}),xe=Uc([...ke.dependencies.values()].map(He=>ht(He)));return te.push(xe),xe.catch(()=>{}),v.set(ke.locatorHash,ke),ke},ee=async H=>{let lt=F.get(H.locatorHash);if(typeof lt<"u")return lt;let Te=Promise.resolve().then(()=>ne(H));return F.set(H.locatorHash,Te),Te},we=async(H,lt)=>{let Te=await ht(lt);return I.set(H.descriptorHash,H),b.set(H.descriptorHash,Te.locatorHash),Te},be=async H=>{ce.setTitle(qn(this.configuration,H));let lt=this.resolutionAliases.get(H.descriptorHash);if(typeof lt<"u")return we(H,this.storedDescriptors.get(lt));let Te=A.getResolutionDependencies(H,C),ke=Object.fromEntries(await Uc(Object.entries(Te).map(async([Re,ze])=>{let je=A.bindDescriptor(ze,U,C),x=await ht(je);return z.add(x.locatorHash),[Re,x]}))),He=(await Wy(async()=>await A.getCandidates(H,ke,C),Re=>`${qn(this.configuration,H)}: ${Re}`))[0];if(typeof He>"u")throw new Jt(82,`${qn(this.configuration,H)}: No candidates found`);if(e.checkResolutions){let{locators:Re}=await p.getSatisfying(H,ke,[He],{...C,resolver:p});if(!Re.find(ze=>ze.locatorHash===He.locatorHash))throw new Jt(78,`Invalid resolution ${JI(this.configuration,H,He)}`)}return I.set(H.descriptorHash,H),b.set(H.descriptorHash,He.locatorHash),ee(He)},ht=H=>{let lt=N.get(H.descriptorHash);if(typeof lt<"u")return lt;I.set(H.descriptorHash,H);let Te=Promise.resolve().then(()=>be(H));return N.set(H.descriptorHash,Te),Te};for(let H of this.workspaces){let lt=H.anchoredDescriptor;te.push(ht(lt))}for(;te.length>0;){let H=[...te];te.length=0,await Uc(H)}});let ue=sl(r.values(),ce=>this.tryWorkspaceByLocator(ce)?sl.skip:ce);if(o.length>0||ue.length>0){let ce=new Set(this.workspaces.flatMap(H=>{let lt=v.get(H.anchoredLocator.locatorHash);if(!lt)throw new Error("Assertion failed: The workspace should have been resolved");return Array.from(lt.dependencies.values(),Te=>{let ke=b.get(Te.descriptorHash);if(!ke)throw new Error("Assertion failed: The resolution should have been registered");return ke})})),ne=H=>ce.has(H.locatorHash)?"0":"1",ee=H=>xa(H),we=ks(o,[ne,ee]),be=ks(ue,[ne,ee]),ht=e.report.getRecommendedLength();we.length>0&&e.report.reportInfo(85,`${_t(this.configuration,"+",Et.ADDED)} ${lS(this.configuration,we,ht)}`),be.length>0&&e.report.reportInfo(85,`${_t(this.configuration,"-",Et.REMOVED)} ${lS(this.configuration,be,ht)}`)}let ye=new Set(this.resolutionAliases.values()),ae=new Set(v.keys()),Ie=new Set,Fe=new Map,g=[];QAt({project:this,accessibleLocators:Ie,volatileDescriptors:ye,optionalBuilds:ae,peerRequirements:Fe,peerWarnings:g,allDescriptors:I,allResolutions:b,allPackages:v});for(let ce of z)ae.delete(ce);for(let ce of ye)I.delete(ce),b.delete(ce);let Ee=new Set,De=new Set;for(let ce of v.values())ce.conditions!=null&&(!ae.has(ce.locatorHash)||(jS(ce,pe)||(jS(ce,le)&&e.report.reportWarningOnce(77,`${jr(this.configuration,ce)}: Your current architecture (${process.platform}-${process.arch}) is supported by this package, but is missing from the ${_t(this.configuration,"supportedArchitectures",Et.SETTING)} setting`),De.add(ce.locatorHash)),Ee.add(ce.locatorHash)));this.storedResolutions=b,this.storedDescriptors=I,this.storedPackages=v,this.accessibleLocators=Ie,this.conditionalLocators=Ee,this.disabledLocators=De,this.originalPackages=E,this.optionalBuilds=ae,this.peerRequirements=Fe,this.peerWarnings=g}async fetchEverything({cache:e,report:r,fetcher:o,mode:a,persistProject:n=!0}){let u={mockedPackages:this.disabledLocators,unstablePackages:this.conditionalLocators},A=o||this.configuration.makeFetcher(),p={checksums:this.storedChecksums,project:this,cache:e,fetcher:A,report:r,cacheOptions:u},h=Array.from(new Set(ks(this.storedResolutions.values(),[E=>{let F=this.storedPackages.get(E);if(!F)throw new Error("Assertion failed: The locator should have been registered");return xa(F)}])));a==="update-lockfile"&&(h=h.filter(E=>!this.storedChecksums.has(E)));let C=!1,I=Xs.progressViaCounter(h.length);await r.reportProgress(I);let v=(0,tk.default)(xAt);if(await Uc(h.map(E=>v(async()=>{let F=this.storedPackages.get(E);if(!F)throw new Error("Assertion failed: The locator should have been registered");if(Hc(F))return;let N;try{N=await A.fetch(F,p)}catch(U){U.message=`${jr(this.configuration,F)}: ${U.message}`,r.reportExceptionOnce(U),C=U;return}N.checksum!=null?this.storedChecksums.set(F.locatorHash,N.checksum):this.storedChecksums.delete(F.locatorHash),N.releaseFs&&N.releaseFs()}).finally(()=>{I.tick()}))),C)throw C;let b=n&&a!=="update-lockfile"?await this.cacheCleanup({cache:e,report:r}):null;if(r.cacheMisses.size>0||b){let F=(await Promise.all([...r.cacheMisses].map(async ue=>{let ye=this.storedPackages.get(ue),ae=this.storedChecksums.get(ue)??null,Ie=e.getLocatorPath(ye,ae);return(await oe.statPromise(Ie)).size}))).reduce((ue,ye)=>ue+ye,0)-(b?.size??0),N=r.cacheMisses.size,U=b?.count??0,z=`${rS(N,{zero:"No new packages",one:"A package was",more:`${_t(this.configuration,N,Et.NUMBER)} packages were`})} added to the project`,te=`${rS(U,{zero:"none were",one:"one was",more:`${_t(this.configuration,U,Et.NUMBER)} were`})} removed`,le=F!==0?` (${_t(this.configuration,F,Et.SIZE_DIFF)})`:"",pe=U>0?N>0?`${z}, and ${te}${le}.`:`${z}, but ${te}${le}.`:`${z}${le}.`;r.reportInfo(13,pe)}}async linkEverything({cache:e,report:r,fetcher:o,mode:a}){let n={mockedPackages:this.disabledLocators,unstablePackages:this.conditionalLocators,skipIntegrityCheck:!0},u=o||this.configuration.makeFetcher(),A={checksums:this.storedChecksums,project:this,cache:e,fetcher:u,report:r,cacheOptions:n},p=this.configuration.getLinkers(),h={project:this,report:r},C=new Map(p.map(ce=>{let ne=ce.makeInstaller(h),ee=ce.getCustomDataKey(),we=this.linkersCustomData.get(ee);return typeof we<"u"&&ne.attachCustomData(we),[ce,ne]})),I=new Map,v=new Map,b=new Map,E=new Map(await Uc([...this.accessibleLocators].map(async ce=>{let ne=this.storedPackages.get(ce);if(!ne)throw new Error("Assertion failed: The locator should have been registered");return[ce,await u.fetch(ne,A)]}))),F=[],N=new Set,U=[];for(let ce of this.accessibleLocators){let ne=this.storedPackages.get(ce);if(typeof ne>"u")throw new Error("Assertion failed: The locator should have been registered");let ee=E.get(ne.locatorHash);if(typeof ee>"u")throw new Error("Assertion failed: The fetch result should have been registered");let we=[],be=H=>{we.push(H)},ht=this.tryWorkspaceByLocator(ne);if(ht!==null){let H=[],{scripts:lt}=ht.manifest;for(let ke of["preinstall","install","postinstall"])lt.has(ke)&&H.push({type:0,script:ke});try{for(let[ke,xe]of C)if(ke.supportsPackage(ne,h)&&(await xe.installPackage(ne,ee,{holdFetchResult:be})).buildRequest!==null)throw new Error("Assertion failed: Linkers can't return build directives for workspaces; this responsibility befalls to the Yarn core")}finally{we.length===0?ee.releaseFs?.():F.push(Uc(we).catch(()=>{}).then(()=>{ee.releaseFs?.()}))}let Te=V.join(ee.packageFs.getRealPath(),ee.prefixPath);v.set(ne.locatorHash,Te),!Hc(ne)&&H.length>0&&b.set(ne.locatorHash,{buildDirectives:H,buildLocations:[Te]})}else{let H=p.find(ke=>ke.supportsPackage(ne,h));if(!H)throw new Jt(12,`${jr(this.configuration,ne)} isn't supported by any available linker`);let lt=C.get(H);if(!lt)throw new Error("Assertion failed: The installer should have been registered");let Te;try{Te=await lt.installPackage(ne,ee,{holdFetchResult:be})}finally{we.length===0?ee.releaseFs?.():F.push(Uc(we).then(()=>{}).then(()=>{ee.releaseFs?.()}))}I.set(ne.locatorHash,H),v.set(ne.locatorHash,Te.packageLocation),Te.buildRequest&&Te.packageLocation&&(Te.buildRequest.skipped?(N.add(ne.locatorHash),this.skippedBuilds.has(ne.locatorHash)||U.push([ne,Te.buildRequest.explain])):b.set(ne.locatorHash,{buildDirectives:Te.buildRequest.directives,buildLocations:[Te.packageLocation]}))}}let z=new Map;for(let ce of this.accessibleLocators){let ne=this.storedPackages.get(ce);if(!ne)throw new Error("Assertion failed: The locator should have been registered");let ee=this.tryWorkspaceByLocator(ne)!==null,we=async(be,ht)=>{let H=v.get(ne.locatorHash);if(typeof H>"u")throw new Error(`Assertion failed: The package (${jr(this.configuration,ne)}) should have been registered`);let lt=[];for(let Te of ne.dependencies.values()){let ke=this.storedResolutions.get(Te.descriptorHash);if(typeof ke>"u")throw new Error(`Assertion failed: The resolution (${qn(this.configuration,Te)}, from ${jr(this.configuration,ne)})should have been registered`);let xe=this.storedPackages.get(ke);if(typeof xe>"u")throw new Error(`Assertion failed: The package (${ke}, resolved from ${qn(this.configuration,Te)}) should have been registered`);let He=this.tryWorkspaceByLocator(xe)===null?I.get(ke):null;if(typeof He>"u")throw new Error(`Assertion failed: The package (${ke}, resolved from ${qn(this.configuration,Te)}) should have been registered`);He===be||He===null?v.get(xe.locatorHash)!==null&<.push([Te,xe]):!ee&&H!==null&&Gy(z,ke).push(H)}H!==null&&await ht.attachInternalDependencies(ne,lt)};if(ee)for(let[be,ht]of C)be.supportsPackage(ne,h)&&await we(be,ht);else{let be=I.get(ne.locatorHash);if(!be)throw new Error("Assertion failed: The linker should have been found");let ht=C.get(be);if(!ht)throw new Error("Assertion failed: The installer should have been registered");await we(be,ht)}}for(let[ce,ne]of z){let ee=this.storedPackages.get(ce);if(!ee)throw new Error("Assertion failed: The package should have been registered");let we=I.get(ee.locatorHash);if(!we)throw new Error("Assertion failed: The linker should have been found");let be=C.get(we);if(!be)throw new Error("Assertion failed: The installer should have been registered");await be.attachExternalDependents(ee,ne)}let te=new Map;for(let[ce,ne]of C){let ee=await ne.finalizeInstall();for(let we of ee?.records??[])we.buildRequest.skipped?(N.add(we.locator.locatorHash),this.skippedBuilds.has(we.locator.locatorHash)||U.push([we.locator,we.buildRequest.explain])):b.set(we.locator.locatorHash,{buildDirectives:we.buildRequest.directives,buildLocations:we.buildLocations});typeof ee?.customData<"u"&&te.set(ce.getCustomDataKey(),ee.customData)}if(this.linkersCustomData=te,await Uc(F),a==="skip-build")return;for(let[,ce]of ks(U,([ne])=>xa(ne)))ce(r);let le=new Set(this.storedPackages.keys()),pe=new Set(b.keys());for(let ce of pe)le.delete(ce);let ue=(0,$b.createHash)("sha512");ue.update(process.versions.node),await this.configuration.triggerHook(ce=>ce.globalHashGeneration,this,ce=>{ue.update("\0"),ue.update(ce)});let ye=ue.digest("hex"),ae=new Map,Ie=ce=>{let ne=ae.get(ce.locatorHash);if(typeof ne<"u")return ne;let ee=this.storedPackages.get(ce.locatorHash);if(typeof ee>"u")throw new Error("Assertion failed: The package should have been registered");let we=(0,$b.createHash)("sha512");we.update(ce.locatorHash),ae.set(ce.locatorHash,"<recursive>");for(let be of ee.dependencies.values()){let ht=this.storedResolutions.get(be.descriptorHash);if(typeof ht>"u")throw new Error(`Assertion failed: The resolution (${qn(this.configuration,be)}) should have been registered`);let H=this.storedPackages.get(ht);if(typeof H>"u")throw new Error("Assertion failed: The package should have been registered");we.update(Ie(H))}return ne=we.digest("hex"),ae.set(ce.locatorHash,ne),ne},Fe=(ce,ne)=>{let ee=(0,$b.createHash)("sha512");ee.update(ye),ee.update(Ie(ce));for(let we of ne)ee.update(we);return ee.digest("hex")},g=new Map,Ee=!1,De=ce=>{let ne=new Set([ce.locatorHash]);for(let ee of ne){let we=this.storedPackages.get(ee);if(!we)throw new Error("Assertion failed: The package should have been registered");for(let be of we.dependencies.values()){let ht=this.storedResolutions.get(be.descriptorHash);if(!ht)throw new Error(`Assertion failed: The resolution (${qn(this.configuration,be)}) should have been registered`);if(ht!==ce.locatorHash&&pe.has(ht))return!1;let H=this.storedPackages.get(ht);if(!H)throw new Error("Assertion failed: The package should have been registered");let lt=this.tryWorkspaceByLocator(H);if(lt){if(lt.anchoredLocator.locatorHash!==ce.locatorHash&&pe.has(lt.anchoredLocator.locatorHash))return!1;ne.add(lt.anchoredLocator.locatorHash)}ne.add(ht)}}return!0};for(;pe.size>0;){let ce=pe.size,ne=[];for(let ee of pe){let we=this.storedPackages.get(ee);if(!we)throw new Error("Assertion failed: The package should have been registered");if(!De(we))continue;let be=b.get(we.locatorHash);if(!be)throw new Error("Assertion failed: The build directive should have been registered");let ht=Fe(we,be.buildLocations);if(this.storedBuildState.get(we.locatorHash)===ht){g.set(we.locatorHash,ht),pe.delete(ee);continue}Ee||(await this.persistInstallStateFile(),Ee=!0),this.storedBuildState.has(we.locatorHash)?r.reportInfo(8,`${jr(this.configuration,we)} must be rebuilt because its dependency tree changed`):r.reportInfo(7,`${jr(this.configuration,we)} must be built because it never has been before or the last one failed`);let H=be.buildLocations.map(async lt=>{if(!V.isAbsolute(lt))throw new Error(`Assertion failed: Expected the build location to be absolute (not ${lt})`);for(let Te of be.buildDirectives){let ke=`# This file contains the result of Yarn building a package (${xa(we)}) -`;switch(Te.type){case 0:ke+=`# Script name: ${Te.script} -`;break;case 1:ke+=`# Script code: ${Te.script} -`;break}let xe=null;if(!await oe.mktempPromise(async Re=>{let ze=V.join(Re,"build.log"),{stdout:je,stderr:x}=this.configuration.getSubprocessStreams(ze,{header:ke,prefix:jr(this.configuration,we),report:r}),w;try{switch(Te.type){case 0:w=await Wx(we,Te.script,[],{cwd:lt,project:this,stdin:xe,stdout:je,stderr:x});break;case 1:w=await EU(we,Te.script,[],{cwd:lt,project:this,stdin:xe,stdout:je,stderr:x});break}}catch(R){x.write(R.stack),w=1}if(je.end(),x.end(),w===0)return!0;oe.detachTemp(Re);let S=`${jr(this.configuration,we)} couldn't be built successfully (exit code ${_t(this.configuration,w,Et.NUMBER)}, logs can be found here: ${_t(this.configuration,ze,Et.PATH)})`,y=this.optionalBuilds.has(we.locatorHash);return y?r.reportInfo(9,S):r.reportError(9,S),Vce&&r.reportFold(fe.fromPortablePath(ze),oe.readFileSync(ze,"utf8")),y}))return!1}return!0});ne.push(...H,Promise.allSettled(H).then(lt=>{pe.delete(ee),lt.every(Te=>Te.status==="fulfilled"&&Te.value===!0)&&g.set(we.locatorHash,ht)}))}if(await Uc(ne),ce===pe.size){let ee=Array.from(pe).map(we=>{let be=this.storedPackages.get(we);if(!be)throw new Error("Assertion failed: The package should have been registered");return jr(this.configuration,be)}).join(", ");r.reportError(3,`Some packages have circular dependencies that make their build order unsatisfiable - as a result they won't be built (affected packages are: ${ee})`);break}}this.storedBuildState=g,this.skippedBuilds=N}async installWithNewReport(e,r){return(await Lt.start({configuration:this.configuration,json:e.json,stdout:e.stdout,forceSectionAlignment:!0,includeLogs:!e.json&&!e.quiet,includeVersion:!0},async a=>{await this.install({...r,report:a})})).exitCode()}async install(e){let r=this.configuration.get("nodeLinker");Ke.telemetry?.reportInstall(r);let o=!1;if(await e.report.startTimerPromise("Project validation",{skipIfEmpty:!0},async()=>{this.configuration.get("enableOfflineMode")&&e.report.reportWarning(90,"Offline work is enabled; Yarn won't fetch packages from the remote registry if it can avoid it"),await this.configuration.triggerHook(C=>C.validateProject,this,{reportWarning:(C,I)=>{e.report.reportWarning(C,I)},reportError:(C,I)=>{e.report.reportError(C,I),o=!0}})}),o)return;let a=await this.configuration.getPackageExtensions();for(let C of a.values())for(let[,I]of C)for(let v of I)v.status="inactive";let n=V.join(this.cwd,dr.lockfile),u=null;if(e.immutable)try{u=await oe.readFilePromise(n,"utf8")}catch(C){throw C.code==="ENOENT"?new Jt(28,"The lockfile would have been created by this install, which is explicitly forbidden."):C}await e.report.startTimerPromise("Resolution step",async()=>{await this.resolveEverything(e)}),await e.report.startTimerPromise("Post-resolution validation",{skipIfEmpty:!0},async()=>{FAt(this,e.report);for(let[,C]of a)for(let[,I]of C)for(let v of I)if(v.userProvided){let b=_t(this.configuration,v,Et.PACKAGE_EXTENSION);switch(v.status){case"inactive":e.report.reportWarning(68,`${b}: No matching package in the dependency tree; you may not need this rule anymore.`);break;case"redundant":e.report.reportWarning(69,`${b}: This rule seems redundant when applied on the original package; the extension may have been applied upstream.`);break}}if(u!==null){let C=Mg(u,this.generateLockfile());if(C!==u){let I=fpe(n,n,u,C,void 0,void 0,{maxEditLength:100});if(I){e.report.reportSeparator();for(let v of I.hunks){e.report.reportInfo(null,`@@ -${v.oldStart},${v.oldLines} +${v.newStart},${v.newLines} @@`);for(let b of v.lines)b.startsWith("+")?e.report.reportError(28,_t(this.configuration,b,Et.ADDED)):b.startsWith("-")?e.report.reportError(28,_t(this.configuration,b,Et.REMOVED)):e.report.reportInfo(null,_t(this.configuration,b,"grey"))}e.report.reportSeparator()}throw new Jt(28,"The lockfile would have been modified by this install, which is explicitly forbidden.")}}});for(let C of a.values())for(let[,I]of C)for(let v of I)v.userProvided&&v.status==="active"&&Ke.telemetry?.reportPackageExtension(yd(v,Et.PACKAGE_EXTENSION));await e.report.startTimerPromise("Fetch step",async()=>{await this.fetchEverything(e)});let A=e.immutable?[...new Set(this.configuration.get("immutablePatterns"))].sort():[],p=await Promise.all(A.map(async C=>NS(C,{cwd:this.cwd})));(typeof e.persistProject>"u"||e.persistProject)&&await this.persist(),await e.report.startTimerPromise("Link step",async()=>{if(e.mode==="update-lockfile"){e.report.reportWarning(73,`Skipped due to ${_t(this.configuration,"mode=update-lockfile",Et.CODE)}`);return}await this.linkEverything(e);let C=await Promise.all(A.map(async I=>NS(I,{cwd:this.cwd})));for(let I=0;I<A.length;++I)p[I]!==C[I]&&e.report.reportError(64,`The checksum for ${A[I]} has been modified by this install, which is explicitly forbidden.`)}),await this.persistInstallStateFile();let h=!1;await e.report.startTimerPromise("Post-install validation",{skipIfEmpty:!0},async()=>{await this.configuration.triggerHook(C=>C.validateProjectAfterInstall,this,{reportWarning:(C,I)=>{e.report.reportWarning(C,I)},reportError:(C,I)=>{e.report.reportError(C,I),h=!0}})}),!h&&await this.configuration.triggerHook(C=>C.afterAllInstalled,this,e)}generateLockfile(){let e=new Map;for(let[n,u]of this.storedResolutions.entries()){let A=e.get(u);A||e.set(u,A=new Set),A.add(n)}let r={},{cacheKey:o}=Nr.getCacheKey(this.configuration);r.__metadata={version:rk,cacheKey:o};for(let[n,u]of e.entries()){let A=this.originalPackages.get(n);if(!A)continue;let p=[];for(let b of u){let E=this.storedDescriptors.get(b);if(!E)throw new Error("Assertion failed: The descriptor should have been registered");p.push(E)}let h=p.map(b=>Sa(b)).sort().join(", "),C=new Ot;C.version=A.linkType==="HARD"?A.version:"0.0.0-use.local",C.languageName=A.languageName,C.dependencies=new Map(A.dependencies),C.peerDependencies=new Map(A.peerDependencies),C.dependenciesMeta=new Map(A.dependenciesMeta),C.peerDependenciesMeta=new Map(A.peerDependenciesMeta),C.bin=new Map(A.bin);let I,v=this.storedChecksums.get(A.locatorHash);if(typeof v<"u"){let b=v.indexOf("/");if(b===-1)throw new Error("Assertion failed: Expected the checksum to reference its cache key");let E=v.slice(0,b),F=v.slice(b+1);E===o?I=F:I=v}r[h]={...C.exportTo({},{compatibilityMode:!1}),linkType:A.linkType.toLowerCase(),resolution:xa(A),checksum:I,conditions:A.conditions||void 0}}return`${[`# This file is generated by running "yarn install" inside your project. -`,`# Manual changes might be lost - proceed with caution! -`].join("")} -`+Ba(r)}async persistLockfile(){let e=V.join(this.cwd,dr.lockfile),r="";try{r=await oe.readFilePromise(e,"utf8")}catch{}let o=this.generateLockfile(),a=Mg(r,o);a!==r&&(await oe.writeFilePromise(e,a),this.lockFileChecksum=zpe(a),this.lockfileNeedsRefresh=!1)}async persistInstallStateFile(){let e=[];for(let u of Object.values(x_))e.push(...u);let r=(0,ek.default)(this,e),o=b_.default.serialize(r),a=Qs(o);if(this.installStateChecksum===a)return;let n=this.configuration.get("installStatePath");await oe.mkdirPromise(V.dirname(n),{recursive:!0}),await oe.writeFilePromise(n,await bAt(o)),this.installStateChecksum=a}async restoreInstallState({restoreLinkersCustomData:e=!0,restoreResolutions:r=!0,restoreBuildState:o=!0}={}){let a=this.configuration.get("installStatePath"),n;try{let u=await kAt(await oe.readFilePromise(a));n=b_.default.deserialize(u),this.installStateChecksum=Qs(u)}catch{r&&await this.applyLightResolution();return}e&&typeof n.linkersCustomData<"u"&&(this.linkersCustomData=n.linkersCustomData),o&&Object.assign(this,(0,ek.default)(n,x_.restoreBuildState)),r&&(n.lockFileChecksum===this.lockFileChecksum?Object.assign(this,(0,ek.default)(n,x_.restoreResolutions)):await this.applyLightResolution())}async applyLightResolution(){await this.resolveEverything({lockfileOnly:!0,report:new Qi}),await this.persistInstallStateFile()}async persist(){let e=(0,tk.default)(4);await Promise.all([this.persistLockfile(),...this.workspaces.map(r=>e(()=>r.persistManifest()))])}async cacheCleanup({cache:e,report:r}){if(this.configuration.get("enableGlobalCache"))return null;let o=new Set([".gitignore"]);if(!CM(e.cwd,this.cwd)||!await oe.existsPromise(e.cwd))return null;let a=[];for(let u of await oe.readdirPromise(e.cwd)){if(o.has(u))continue;let A=V.resolve(e.cwd,u);e.markedFiles.has(A)||(e.immutable?r.reportError(56,`${_t(this.configuration,V.basename(A),"magenta")} appears to be unused and would be marked for deletion, but the cache is immutable`):a.push(oe.lstatPromise(A).then(async p=>(await oe.removePromise(A),p.size))))}if(a.length===0)return null;let n=await Promise.all(a);return{count:a.length,size:n.reduce((u,A)=>u+A,0)}}}});function RAt(t){let o=Math.floor(t.timeNow/864e5),a=t.updateInterval*864e5,n=t.state.lastUpdate??t.timeNow+a+Math.floor(a*t.randomInitialInterval),u=n+a,A=t.state.lastTips??o*864e5,p=A+864e5+8*36e5-t.timeZone,h=u<=t.timeNow,C=p<=t.timeNow,I=null;return(h||C||!t.state.lastUpdate||!t.state.lastTips)&&(I={},I.lastUpdate=h?t.timeNow:n,I.lastTips=A,I.blocks=h?{}:t.state.blocks,I.displayedTips=t.state.displayedTips),{nextState:I,triggerUpdate:h,triggerTips:C,nextTips:C?o*864e5:A}}var cC,Zpe=yt(()=>{Pt();T1();rh();Ix();jl();bf();cC=class{constructor(e,r){this.values=new Map;this.hits=new Map;this.enumerators=new Map;this.nextTips=0;this.displayedTips=[];this.shouldCommitTips=!1;this.configuration=e;let o=this.getRegistryPath();this.isNew=!oe.existsSync(o),this.shouldShowTips=!1,this.sendReport(r),this.startBuffer()}commitTips(){this.shouldShowTips&&(this.shouldCommitTips=!0)}selectTip(e){let r=new Set(this.displayedTips),o=A=>A&&tn?xf(tn,A):!1,a=e.map((A,p)=>p).filter(A=>e[A]&&o(e[A]?.selector));if(a.length===0)return null;let n=a.filter(A=>!r.has(A));if(n.length===0){let A=Math.floor(a.length*.2);this.displayedTips=A>0?this.displayedTips.slice(-A):[],n=a.filter(p=>!r.has(p))}let u=n[Math.floor(Math.random()*n.length)];return this.displayedTips.push(u),this.commitTips(),e[u]}reportVersion(e){this.reportValue("version",e.replace(/-git\..*/,"-git"))}reportCommandName(e){this.reportValue("commandName",e||"<none>")}reportPluginName(e){this.reportValue("pluginName",e)}reportProject(e){this.reportEnumerator("projectCount",e)}reportInstall(e){this.reportHit("installCount",e)}reportPackageExtension(e){this.reportValue("packageExtension",e)}reportWorkspaceCount(e){this.reportValue("workspaceCount",String(e))}reportDependencyCount(e){this.reportValue("dependencyCount",String(e))}reportValue(e,r){dd(this.values,e).add(r)}reportEnumerator(e,r){dd(this.enumerators,e).add(Qs(r))}reportHit(e,r="*"){let o=Yy(this.hits,e),a=ol(o,r,()=>0);o.set(r,a+1)}getRegistryPath(){let e=this.configuration.get("globalFolder");return V.join(e,"telemetry.json")}sendReport(e){let r=this.getRegistryPath(),o;try{o=oe.readJsonSync(r)}catch{o={}}let{nextState:a,triggerUpdate:n,triggerTips:u,nextTips:A}=RAt({state:o,timeNow:Date.now(),timeZone:new Date().getTimezoneOffset()*60*1e3,randomInitialInterval:Math.random(),updateInterval:this.configuration.get("telemetryInterval")});if(this.nextTips=A,this.displayedTips=o.displayedTips??[],a!==null)try{oe.mkdirSync(V.dirname(r),{recursive:!0}),oe.writeJsonSync(r,a)}catch{return!1}if(u&&this.configuration.get("enableTips")&&(this.shouldShowTips=!0),n){let p=o.blocks??{};if(Object.keys(p).length===0){let h=`https://browser-http-intake.logs.datadoghq.eu/v1/input/${e}?ddsource=yarn`,C=I=>O4(h,I,{configuration:this.configuration}).catch(()=>{});for(let[I,v]of Object.entries(o.blocks??{})){if(Object.keys(v).length===0)continue;let b=v;b.userId=I,b.reportType="primary";for(let N of Object.keys(b.enumerators??{}))b.enumerators[N]=b.enumerators[N].length;C(b);let E=new Map,F=20;for(let[N,U]of Object.entries(b.values))U.length>0&&E.set(N,U.slice(0,F));for(;E.size>0;){let N={};N.userId=I,N.reportType="secondary",N.metrics={};for(let[U,z]of E)N.metrics[U]=z.shift(),z.length===0&&E.delete(U);C(N)}}}}return!0}applyChanges(){let e=this.getRegistryPath(),r;try{r=oe.readJsonSync(e)}catch{r={}}let o=this.configuration.get("telemetryUserId")??"*",a=r.blocks=r.blocks??{},n=a[o]=a[o]??{};for(let u of this.hits.keys()){let A=n.hits=n.hits??{},p=A[u]=A[u]??{};for(let[h,C]of this.hits.get(u))p[h]=(p[h]??0)+C}for(let u of["values","enumerators"])for(let A of this[u].keys()){let p=n[u]=n[u]??{};p[A]=[...new Set([...p[A]??[],...this[u].get(A)??[]])]}this.shouldCommitTips&&(r.lastTips=this.nextTips,r.displayedTips=this.displayedTips),oe.mkdirSync(V.dirname(e),{recursive:!0}),oe.writeJsonSync(e,r)}startBuffer(){process.on("exit",()=>{try{this.applyChanges()}catch{}})}}});var i2={};Vt(i2,{BuildDirectiveType:()=>zb,CACHE_CHECKPOINT:()=>c_,CACHE_VERSION:()=>Vb,Cache:()=>Nr,Configuration:()=>Ke,DEFAULT_RC_FILENAME:()=>G4,FormatType:()=>kle,InstallMode:()=>pl,LEGACY_PLUGINS:()=>I1,LOCKFILE_VERSION:()=>rk,LegacyMigrationResolver:()=>sC,LightReport:()=>AA,LinkType:()=>zy,LockfileResolver:()=>oC,Manifest:()=>Ot,MessageName:()=>wr,MultiFetcher:()=>pE,PackageExtensionStatus:()=>vN,PackageExtensionType:()=>BN,Project:()=>St,Report:()=>Xs,ReportError:()=>Jt,SettingsType:()=>B1,StreamReport:()=>Lt,TAG_REGEXP:()=>QE,TelemetryManager:()=>cC,ThrowReport:()=>Qi,VirtualFetcher:()=>hE,WindowsLinkType:()=>bx,Workspace:()=>lC,WorkspaceFetcher:()=>dE,WorkspaceResolver:()=>Xn,YarnVersion:()=>tn,execUtils:()=>Ur,folderUtils:()=>YS,formatUtils:()=>de,hashUtils:()=>wn,httpUtils:()=>rn,miscUtils:()=>_e,nodeUtils:()=>zi,parseMessageName:()=>AP,reportOptionDeprecations:()=>LE,scriptUtils:()=>un,semverUtils:()=>Qr,stringifyMessageName:()=>Wu,structUtils:()=>G,tgzUtils:()=>Xi,treeUtils:()=>$s});var Ye=yt(()=>{Dx();WS();ql();rh();Ix();jl();vx();BU();bf();xo();Zfe();spe();u_();v1();v1();ape();A_();lpe();f_();AE();fP();cM();Xpe();Yl();L1();Zpe();P_();AM();fM();Bd();S_();T1();wne()});var ihe=_((H_t,o2)=>{"use strict";var LAt=process.env.TERM_PROGRAM==="Hyper",NAt=process.platform==="win32",the=process.platform==="linux",F_={ballotDisabled:"\u2612",ballotOff:"\u2610",ballotOn:"\u2611",bullet:"\u2022",bulletWhite:"\u25E6",fullBlock:"\u2588",heart:"\u2764",identicalTo:"\u2261",line:"\u2500",mark:"\u203B",middot:"\xB7",minus:"\uFF0D",multiplication:"\xD7",obelus:"\xF7",pencilDownRight:"\u270E",pencilRight:"\u270F",pencilUpRight:"\u2710",percent:"%",pilcrow2:"\u2761",pilcrow:"\xB6",plusMinus:"\xB1",section:"\xA7",starsOff:"\u2606",starsOn:"\u2605",upDownArrow:"\u2195"},rhe=Object.assign({},F_,{check:"\u221A",cross:"\xD7",ellipsisLarge:"...",ellipsis:"...",info:"i",question:"?",questionSmall:"?",pointer:">",pointerSmall:"\xBB",radioOff:"( )",radioOn:"(*)",warning:"\u203C"}),nhe=Object.assign({},F_,{ballotCross:"\u2718",check:"\u2714",cross:"\u2716",ellipsisLarge:"\u22EF",ellipsis:"\u2026",info:"\u2139",question:"?",questionFull:"\uFF1F",questionSmall:"\uFE56",pointer:the?"\u25B8":"\u276F",pointerSmall:the?"\u2023":"\u203A",radioOff:"\u25EF",radioOn:"\u25C9",warning:"\u26A0"});o2.exports=NAt&&!LAt?rhe:nhe;Reflect.defineProperty(o2.exports,"common",{enumerable:!1,value:F_});Reflect.defineProperty(o2.exports,"windows",{enumerable:!1,value:rhe});Reflect.defineProperty(o2.exports,"other",{enumerable:!1,value:nhe})});var Kc=_((j_t,R_)=>{"use strict";var OAt=t=>t!==null&&typeof t=="object"&&!Array.isArray(t),MAt=/[\u001b\u009b][[\]#;?()]*(?:(?:(?:[^\W_]*;?[^\W_]*)\u0007)|(?:(?:[0-9]{1,4}(;[0-9]{0,4})*)?[~0-9=<>cf-nqrtyA-PRZ]))/g,she=()=>{let t={enabled:!0,visible:!0,styles:{},keys:{}};"FORCE_COLOR"in process.env&&(t.enabled=process.env.FORCE_COLOR!=="0");let e=n=>{let u=n.open=`\x1B[${n.codes[0]}m`,A=n.close=`\x1B[${n.codes[1]}m`,p=n.regex=new RegExp(`\\u001b\\[${n.codes[1]}m`,"g");return n.wrap=(h,C)=>{h.includes(A)&&(h=h.replace(p,A+u));let I=u+h+A;return C?I.replace(/\r*\n/g,`${A}$&${u}`):I},n},r=(n,u,A)=>typeof n=="function"?n(u):n.wrap(u,A),o=(n,u)=>{if(n===""||n==null)return"";if(t.enabled===!1)return n;if(t.visible===!1)return"";let A=""+n,p=A.includes(` -`),h=u.length;for(h>0&&u.includes("unstyle")&&(u=[...new Set(["unstyle",...u])].reverse());h-- >0;)A=r(t.styles[u[h]],A,p);return A},a=(n,u,A)=>{t.styles[n]=e({name:n,codes:u}),(t.keys[A]||(t.keys[A]=[])).push(n),Reflect.defineProperty(t,n,{configurable:!0,enumerable:!0,set(h){t.alias(n,h)},get(){let h=C=>o(C,h.stack);return Reflect.setPrototypeOf(h,t),h.stack=this.stack?this.stack.concat(n):[n],h}})};return a("reset",[0,0],"modifier"),a("bold",[1,22],"modifier"),a("dim",[2,22],"modifier"),a("italic",[3,23],"modifier"),a("underline",[4,24],"modifier"),a("inverse",[7,27],"modifier"),a("hidden",[8,28],"modifier"),a("strikethrough",[9,29],"modifier"),a("black",[30,39],"color"),a("red",[31,39],"color"),a("green",[32,39],"color"),a("yellow",[33,39],"color"),a("blue",[34,39],"color"),a("magenta",[35,39],"color"),a("cyan",[36,39],"color"),a("white",[37,39],"color"),a("gray",[90,39],"color"),a("grey",[90,39],"color"),a("bgBlack",[40,49],"bg"),a("bgRed",[41,49],"bg"),a("bgGreen",[42,49],"bg"),a("bgYellow",[43,49],"bg"),a("bgBlue",[44,49],"bg"),a("bgMagenta",[45,49],"bg"),a("bgCyan",[46,49],"bg"),a("bgWhite",[47,49],"bg"),a("blackBright",[90,39],"bright"),a("redBright",[91,39],"bright"),a("greenBright",[92,39],"bright"),a("yellowBright",[93,39],"bright"),a("blueBright",[94,39],"bright"),a("magentaBright",[95,39],"bright"),a("cyanBright",[96,39],"bright"),a("whiteBright",[97,39],"bright"),a("bgBlackBright",[100,49],"bgBright"),a("bgRedBright",[101,49],"bgBright"),a("bgGreenBright",[102,49],"bgBright"),a("bgYellowBright",[103,49],"bgBright"),a("bgBlueBright",[104,49],"bgBright"),a("bgMagentaBright",[105,49],"bgBright"),a("bgCyanBright",[106,49],"bgBright"),a("bgWhiteBright",[107,49],"bgBright"),t.ansiRegex=MAt,t.hasColor=t.hasAnsi=n=>(t.ansiRegex.lastIndex=0,typeof n=="string"&&n!==""&&t.ansiRegex.test(n)),t.alias=(n,u)=>{let A=typeof u=="string"?t[u]:u;if(typeof A!="function")throw new TypeError("Expected alias to be the name of an existing color (string) or a function");A.stack||(Reflect.defineProperty(A,"name",{value:n}),t.styles[n]=A,A.stack=[n]),Reflect.defineProperty(t,n,{configurable:!0,enumerable:!0,set(p){t.alias(n,p)},get(){let p=h=>o(h,p.stack);return Reflect.setPrototypeOf(p,t),p.stack=this.stack?this.stack.concat(A.stack):A.stack,p}})},t.theme=n=>{if(!OAt(n))throw new TypeError("Expected theme to be an object");for(let u of Object.keys(n))t.alias(u,n[u]);return t},t.alias("unstyle",n=>typeof n=="string"&&n!==""?(t.ansiRegex.lastIndex=0,n.replace(t.ansiRegex,"")):""),t.alias("noop",n=>n),t.none=t.clear=t.noop,t.stripColor=t.unstyle,t.symbols=ihe(),t.define=a,t};R_.exports=she();R_.exports.create=she});var Lo=_(nn=>{"use strict";var UAt=Object.prototype.toString,rc=Kc(),ohe=!1,T_=[],ahe={yellow:"blue",cyan:"red",green:"magenta",black:"white",blue:"yellow",red:"cyan",magenta:"green",white:"black"};nn.longest=(t,e)=>t.reduce((r,o)=>Math.max(r,e?o[e].length:o.length),0);nn.hasColor=t=>!!t&&rc.hasColor(t);var ik=nn.isObject=t=>t!==null&&typeof t=="object"&&!Array.isArray(t);nn.nativeType=t=>UAt.call(t).slice(8,-1).toLowerCase().replace(/\s/g,"");nn.isAsyncFn=t=>nn.nativeType(t)==="asyncfunction";nn.isPrimitive=t=>t!=null&&typeof t!="object"&&typeof t!="function";nn.resolve=(t,e,...r)=>typeof e=="function"?e.call(t,...r):e;nn.scrollDown=(t=[])=>[...t.slice(1),t[0]];nn.scrollUp=(t=[])=>[t.pop(),...t];nn.reorder=(t=[])=>{let e=t.slice();return e.sort((r,o)=>r.index>o.index?1:r.index<o.index?-1:0),e};nn.swap=(t,e,r)=>{let o=t.length,a=r===o?0:r<0?o-1:r,n=t[e];t[e]=t[a],t[a]=n};nn.width=(t,e=80)=>{let r=t&&t.columns?t.columns:e;return t&&typeof t.getWindowSize=="function"&&(r=t.getWindowSize()[0]),process.platform==="win32"?r-1:r};nn.height=(t,e=20)=>{let r=t&&t.rows?t.rows:e;return t&&typeof t.getWindowSize=="function"&&(r=t.getWindowSize()[1]),r};nn.wordWrap=(t,e={})=>{if(!t)return t;typeof e=="number"&&(e={width:e});let{indent:r="",newline:o=` -`+r,width:a=80}=e,n=(o+r).match(/[^\S\n]/g)||[];a-=n.length;let u=`.{1,${a}}([\\s\\u200B]+|$)|[^\\s\\u200B]+?([\\s\\u200B]+|$)`,A=t.trim(),p=new RegExp(u,"g"),h=A.match(p)||[];return h=h.map(C=>C.replace(/\n$/,"")),e.padEnd&&(h=h.map(C=>C.padEnd(a," "))),e.padStart&&(h=h.map(C=>C.padStart(a," "))),r+h.join(o)};nn.unmute=t=>{let e=t.stack.find(o=>rc.keys.color.includes(o));return e?rc[e]:t.stack.find(o=>o.slice(2)==="bg")?rc[e.slice(2)]:o=>o};nn.pascal=t=>t?t[0].toUpperCase()+t.slice(1):"";nn.inverse=t=>{if(!t||!t.stack)return t;let e=t.stack.find(o=>rc.keys.color.includes(o));if(e){let o=rc["bg"+nn.pascal(e)];return o?o.black:t}let r=t.stack.find(o=>o.slice(0,2)==="bg");return r?rc[r.slice(2).toLowerCase()]||t:rc.none};nn.complement=t=>{if(!t||!t.stack)return t;let e=t.stack.find(o=>rc.keys.color.includes(o)),r=t.stack.find(o=>o.slice(0,2)==="bg");if(e&&!r)return rc[ahe[e]||e];if(r){let o=r.slice(2).toLowerCase(),a=ahe[o];return a&&rc["bg"+nn.pascal(a)]||t}return rc.none};nn.meridiem=t=>{let e=t.getHours(),r=t.getMinutes(),o=e>=12?"pm":"am";e=e%12;let a=e===0?12:e,n=r<10?"0"+r:r;return a+":"+n+" "+o};nn.set=(t={},e="",r)=>e.split(".").reduce((o,a,n,u)=>{let A=u.length-1>n?o[a]||{}:r;return!nn.isObject(A)&&n<u.length-1&&(A={}),o[a]=A},t);nn.get=(t={},e="",r)=>{let o=t[e]==null?e.split(".").reduce((a,n)=>a&&a[n],t):t[e];return o??r};nn.mixin=(t,e)=>{if(!ik(t))return e;if(!ik(e))return t;for(let r of Object.keys(e)){let o=Object.getOwnPropertyDescriptor(e,r);if(o.hasOwnProperty("value"))if(t.hasOwnProperty(r)&&ik(o.value)){let a=Object.getOwnPropertyDescriptor(t,r);ik(a.value)?t[r]=nn.merge({},t[r],e[r]):Reflect.defineProperty(t,r,o)}else Reflect.defineProperty(t,r,o);else Reflect.defineProperty(t,r,o)}return t};nn.merge=(...t)=>{let e={};for(let r of t)nn.mixin(e,r);return e};nn.mixinEmitter=(t,e)=>{let r=e.constructor.prototype;for(let o of Object.keys(r)){let a=r[o];typeof a=="function"?nn.define(t,o,a.bind(e)):nn.define(t,o,a)}};nn.onExit=t=>{let e=(r,o)=>{ohe||(ohe=!0,T_.forEach(a=>a()),r===!0&&process.exit(128+o))};T_.length===0&&(process.once("SIGTERM",e.bind(null,!0,15)),process.once("SIGINT",e.bind(null,!0,2)),process.once("exit",e)),T_.push(t)};nn.define=(t,e,r)=>{Reflect.defineProperty(t,e,{value:r})};nn.defineExport=(t,e,r)=>{let o;Reflect.defineProperty(t,e,{enumerable:!0,configurable:!0,set(a){o=a},get(){return o?o():r()}})}});var lhe=_(pC=>{"use strict";pC.ctrl={a:"first",b:"backward",c:"cancel",d:"deleteForward",e:"last",f:"forward",g:"reset",i:"tab",k:"cutForward",l:"reset",n:"newItem",m:"cancel",j:"submit",p:"search",r:"remove",s:"save",u:"undo",w:"cutLeft",x:"toggleCursor",v:"paste"};pC.shift={up:"shiftUp",down:"shiftDown",left:"shiftLeft",right:"shiftRight",tab:"prev"};pC.fn={up:"pageUp",down:"pageDown",left:"pageLeft",right:"pageRight",delete:"deleteForward"};pC.option={b:"backward",f:"forward",d:"cutRight",left:"cutLeft",up:"altUp",down:"altDown"};pC.keys={pageup:"pageUp",pagedown:"pageDown",home:"home",end:"end",cancel:"cancel",delete:"deleteForward",backspace:"delete",down:"down",enter:"submit",escape:"cancel",left:"left",space:"space",number:"number",return:"submit",right:"right",tab:"next",up:"up"}});var Ahe=_((Y_t,uhe)=>{"use strict";var che=Be("readline"),_At=lhe(),HAt=/^(?:\x1b)([a-zA-Z0-9])$/,jAt=/^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/,qAt={OP:"f1",OQ:"f2",OR:"f3",OS:"f4","[11~":"f1","[12~":"f2","[13~":"f3","[14~":"f4","[[A":"f1","[[B":"f2","[[C":"f3","[[D":"f4","[[E":"f5","[15~":"f5","[17~":"f6","[18~":"f7","[19~":"f8","[20~":"f9","[21~":"f10","[23~":"f11","[24~":"f12","[A":"up","[B":"down","[C":"right","[D":"left","[E":"clear","[F":"end","[H":"home",OA:"up",OB:"down",OC:"right",OD:"left",OE:"clear",OF:"end",OH:"home","[1~":"home","[2~":"insert","[3~":"delete","[4~":"end","[5~":"pageup","[6~":"pagedown","[[5~":"pageup","[[6~":"pagedown","[7~":"home","[8~":"end","[a":"up","[b":"down","[c":"right","[d":"left","[e":"clear","[2$":"insert","[3$":"delete","[5$":"pageup","[6$":"pagedown","[7$":"home","[8$":"end",Oa:"up",Ob:"down",Oc:"right",Od:"left",Oe:"clear","[2^":"insert","[3^":"delete","[5^":"pageup","[6^":"pagedown","[7^":"home","[8^":"end","[Z":"tab"};function GAt(t){return["[a","[b","[c","[d","[e","[2$","[3$","[5$","[6$","[7$","[8$","[Z"].includes(t)}function YAt(t){return["Oa","Ob","Oc","Od","Oe","[2^","[3^","[5^","[6^","[7^","[8^"].includes(t)}var sk=(t="",e={})=>{let r,o={name:e.name,ctrl:!1,meta:!1,shift:!1,option:!1,sequence:t,raw:t,...e};if(Buffer.isBuffer(t)?t[0]>127&&t[1]===void 0?(t[0]-=128,t="\x1B"+String(t)):t=String(t):t!==void 0&&typeof t!="string"?t=String(t):t||(t=o.sequence||""),o.sequence=o.sequence||t||o.name,t==="\r")o.raw=void 0,o.name="return";else if(t===` -`)o.name="enter";else if(t===" ")o.name="tab";else if(t==="\b"||t==="\x7F"||t==="\x1B\x7F"||t==="\x1B\b")o.name="backspace",o.meta=t.charAt(0)==="\x1B";else if(t==="\x1B"||t==="\x1B\x1B")o.name="escape",o.meta=t.length===2;else if(t===" "||t==="\x1B ")o.name="space",o.meta=t.length===2;else if(t<="")o.name=String.fromCharCode(t.charCodeAt(0)+"a".charCodeAt(0)-1),o.ctrl=!0;else if(t.length===1&&t>="0"&&t<="9")o.name="number";else if(t.length===1&&t>="a"&&t<="z")o.name=t;else if(t.length===1&&t>="A"&&t<="Z")o.name=t.toLowerCase(),o.shift=!0;else if(r=HAt.exec(t))o.meta=!0,o.shift=/^[A-Z]$/.test(r[1]);else if(r=jAt.exec(t)){let a=[...t];a[0]==="\x1B"&&a[1]==="\x1B"&&(o.option=!0);let n=[r[1],r[2],r[4],r[6]].filter(Boolean).join(""),u=(r[3]||r[5]||1)-1;o.ctrl=!!(u&4),o.meta=!!(u&10),o.shift=!!(u&1),o.code=n,o.name=qAt[n],o.shift=GAt(n)||o.shift,o.ctrl=YAt(n)||o.ctrl}return o};sk.listen=(t={},e)=>{let{stdin:r}=t;if(!r||r!==process.stdin&&!r.isTTY)throw new Error("Invalid stream passed");let o=che.createInterface({terminal:!0,input:r});che.emitKeypressEvents(r,o);let a=(A,p)=>e(A,sk(A,p),o),n=r.isRaw;return r.isTTY&&r.setRawMode(!0),r.on("keypress",a),o.resume(),()=>{r.isTTY&&r.setRawMode(n),r.removeListener("keypress",a),o.pause(),o.close()}};sk.action=(t,e,r)=>{let o={..._At,...r};return e.ctrl?(e.action=o.ctrl[e.name],e):e.option&&o.option?(e.action=o.option[e.name],e):e.shift?(e.action=o.shift[e.name],e):(e.action=o.keys[e.name],e)};uhe.exports=sk});var phe=_((W_t,fhe)=>{"use strict";fhe.exports=t=>{t.timers=t.timers||{};let e=t.options.timers;if(!!e)for(let r of Object.keys(e)){let o=e[r];typeof o=="number"&&(o={interval:o}),WAt(t,r,o)}};function WAt(t,e,r={}){let o=t.timers[e]={name:e,start:Date.now(),ms:0,tick:0},a=r.interval||120;o.frames=r.frames||[],o.loading=!0;let n=setInterval(()=>{o.ms=Date.now()-o.start,o.tick++,t.render()},a);return o.stop=()=>{o.loading=!1,clearInterval(n)},Reflect.defineProperty(o,"interval",{value:n}),t.once("close",()=>o.stop()),o.stop}});var ghe=_((K_t,hhe)=>{"use strict";var{define:KAt,width:VAt}=Lo(),L_=class{constructor(e){let r=e.options;KAt(this,"_prompt",e),this.type=e.type,this.name=e.name,this.message="",this.header="",this.footer="",this.error="",this.hint="",this.input="",this.cursor=0,this.index=0,this.lines=0,this.tick=0,this.prompt="",this.buffer="",this.width=VAt(r.stdout||process.stdout),Object.assign(this,r),this.name=this.name||this.message,this.message=this.message||this.name,this.symbols=e.symbols,this.styles=e.styles,this.required=new Set,this.cancelled=!1,this.submitted=!1}clone(){let e={...this};return e.status=this.status,e.buffer=Buffer.from(e.buffer),delete e.clone,e}set color(e){this._color=e}get color(){let e=this.prompt.styles;if(this.cancelled)return e.cancelled;if(this.submitted)return e.submitted;let r=this._color||e[this.status];return typeof r=="function"?r:e.pending}set loading(e){this._loading=e}get loading(){return typeof this._loading=="boolean"?this._loading:this.loadingChoices?"choices":!1}get status(){return this.cancelled?"cancelled":this.submitted?"submitted":"pending"}};hhe.exports=L_});var mhe=_((V_t,dhe)=>{"use strict";var N_=Lo(),eo=Kc(),O_={default:eo.noop,noop:eo.noop,set inverse(t){this._inverse=t},get inverse(){return this._inverse||N_.inverse(this.primary)},set complement(t){this._complement=t},get complement(){return this._complement||N_.complement(this.primary)},primary:eo.cyan,success:eo.green,danger:eo.magenta,strong:eo.bold,warning:eo.yellow,muted:eo.dim,disabled:eo.gray,dark:eo.dim.gray,underline:eo.underline,set info(t){this._info=t},get info(){return this._info||this.primary},set em(t){this._em=t},get em(){return this._em||this.primary.underline},set heading(t){this._heading=t},get heading(){return this._heading||this.muted.underline},set pending(t){this._pending=t},get pending(){return this._pending||this.primary},set submitted(t){this._submitted=t},get submitted(){return this._submitted||this.success},set cancelled(t){this._cancelled=t},get cancelled(){return this._cancelled||this.danger},set typing(t){this._typing=t},get typing(){return this._typing||this.dim},set placeholder(t){this._placeholder=t},get placeholder(){return this._placeholder||this.primary.dim},set highlight(t){this._highlight=t},get highlight(){return this._highlight||this.inverse}};O_.merge=(t={})=>{t.styles&&typeof t.styles.enabled=="boolean"&&(eo.enabled=t.styles.enabled),t.styles&&typeof t.styles.visible=="boolean"&&(eo.visible=t.styles.visible);let e=N_.merge({},O_,t.styles);delete e.merge;for(let r of Object.keys(eo))e.hasOwnProperty(r)||Reflect.defineProperty(e,r,{get:()=>eo[r]});for(let r of Object.keys(eo.styles))e.hasOwnProperty(r)||Reflect.defineProperty(e,r,{get:()=>eo[r]});return e};dhe.exports=O_});var Ehe=_((z_t,yhe)=>{"use strict";var M_=process.platform==="win32",Wf=Kc(),zAt=Lo(),U_={...Wf.symbols,upDownDoubleArrow:"\u21D5",upDownDoubleArrow2:"\u2B0D",upDownArrow:"\u2195",asterisk:"*",asterism:"\u2042",bulletWhite:"\u25E6",electricArrow:"\u2301",ellipsisLarge:"\u22EF",ellipsisSmall:"\u2026",fullBlock:"\u2588",identicalTo:"\u2261",indicator:Wf.symbols.check,leftAngle:"\u2039",mark:"\u203B",minus:"\u2212",multiplication:"\xD7",obelus:"\xF7",percent:"%",pilcrow:"\xB6",pilcrow2:"\u2761",pencilUpRight:"\u2710",pencilDownRight:"\u270E",pencilRight:"\u270F",plus:"+",plusMinus:"\xB1",pointRight:"\u261E",rightAngle:"\u203A",section:"\xA7",hexagon:{off:"\u2B21",on:"\u2B22",disabled:"\u2B22"},ballot:{on:"\u2611",off:"\u2610",disabled:"\u2612"},stars:{on:"\u2605",off:"\u2606",disabled:"\u2606"},folder:{on:"\u25BC",off:"\u25B6",disabled:"\u25B6"},prefix:{pending:Wf.symbols.question,submitted:Wf.symbols.check,cancelled:Wf.symbols.cross},separator:{pending:Wf.symbols.pointerSmall,submitted:Wf.symbols.middot,cancelled:Wf.symbols.middot},radio:{off:M_?"( )":"\u25EF",on:M_?"(*)":"\u25C9",disabled:M_?"(|)":"\u24BE"},numbers:["\u24EA","\u2460","\u2461","\u2462","\u2463","\u2464","\u2465","\u2466","\u2467","\u2468","\u2469","\u246A","\u246B","\u246C","\u246D","\u246E","\u246F","\u2470","\u2471","\u2472","\u2473","\u3251","\u3252","\u3253","\u3254","\u3255","\u3256","\u3257","\u3258","\u3259","\u325A","\u325B","\u325C","\u325D","\u325E","\u325F","\u32B1","\u32B2","\u32B3","\u32B4","\u32B5","\u32B6","\u32B7","\u32B8","\u32B9","\u32BA","\u32BB","\u32BC","\u32BD","\u32BE","\u32BF"]};U_.merge=t=>{let e=zAt.merge({},Wf.symbols,U_,t.symbols);return delete e.merge,e};yhe.exports=U_});var whe=_((J_t,Che)=>{"use strict";var JAt=mhe(),XAt=Ehe(),ZAt=Lo();Che.exports=t=>{t.options=ZAt.merge({},t.options.theme,t.options),t.symbols=XAt.merge(t.options),t.styles=JAt.merge(t.options)}});var Phe=_((vhe,Dhe)=>{"use strict";var Ihe=process.env.TERM_PROGRAM==="Apple_Terminal",$At=Kc(),__=Lo(),Vc=Dhe.exports=vhe,Di="\x1B[",Bhe="\x07",H_=!1,Ph=Vc.code={bell:Bhe,beep:Bhe,beginning:`${Di}G`,down:`${Di}J`,esc:Di,getPosition:`${Di}6n`,hide:`${Di}?25l`,line:`${Di}2K`,lineEnd:`${Di}K`,lineStart:`${Di}1K`,restorePosition:Di+(Ihe?"8":"u"),savePosition:Di+(Ihe?"7":"s"),screen:`${Di}2J`,show:`${Di}?25h`,up:`${Di}1J`},qd=Vc.cursor={get hidden(){return H_},hide(){return H_=!0,Ph.hide},show(){return H_=!1,Ph.show},forward:(t=1)=>`${Di}${t}C`,backward:(t=1)=>`${Di}${t}D`,nextLine:(t=1)=>`${Di}E`.repeat(t),prevLine:(t=1)=>`${Di}F`.repeat(t),up:(t=1)=>t?`${Di}${t}A`:"",down:(t=1)=>t?`${Di}${t}B`:"",right:(t=1)=>t?`${Di}${t}C`:"",left:(t=1)=>t?`${Di}${t}D`:"",to(t,e){return e?`${Di}${e+1};${t+1}H`:`${Di}${t+1}G`},move(t=0,e=0){let r="";return r+=t<0?qd.left(-t):t>0?qd.right(t):"",r+=e<0?qd.up(-e):e>0?qd.down(e):"",r},restore(t={}){let{after:e,cursor:r,initial:o,input:a,prompt:n,size:u,value:A}=t;if(o=__.isPrimitive(o)?String(o):"",a=__.isPrimitive(a)?String(a):"",A=__.isPrimitive(A)?String(A):"",u){let p=Vc.cursor.up(u)+Vc.cursor.to(n.length),h=a.length-r;return h>0&&(p+=Vc.cursor.left(h)),p}if(A||e){let p=!a&&!!o?-o.length:-a.length+r;return e&&(p-=e.length),a===""&&o&&!n.includes(o)&&(p+=o.length),Vc.cursor.move(p)}}},j_=Vc.erase={screen:Ph.screen,up:Ph.up,down:Ph.down,line:Ph.line,lineEnd:Ph.lineEnd,lineStart:Ph.lineStart,lines(t){let e="";for(let r=0;r<t;r++)e+=Vc.erase.line+(r<t-1?Vc.cursor.up(1):"");return t&&(e+=Vc.code.beginning),e}};Vc.clear=(t="",e=process.stdout.columns)=>{if(!e)return j_.line+qd.to(0);let r=n=>[...$At.unstyle(n)].length,o=t.split(/\r?\n/),a=0;for(let n of o)a+=1+Math.floor(Math.max(r(n)-1,0)/e);return(j_.line+qd.prevLine()).repeat(a-1)+j_.line+qd.to(0)}});var hC=_((X_t,xhe)=>{"use strict";var eft=Be("events"),She=Kc(),q_=Ahe(),tft=phe(),rft=ghe(),nft=whe(),Ra=Lo(),Gd=Phe(),a2=class extends eft{constructor(e={}){super(),this.name=e.name,this.type=e.type,this.options=e,nft(this),tft(this),this.state=new rft(this),this.initial=[e.initial,e.default].find(r=>r!=null),this.stdout=e.stdout||process.stdout,this.stdin=e.stdin||process.stdin,this.scale=e.scale||1,this.term=this.options.term||process.env.TERM_PROGRAM,this.margin=sft(this.options.margin),this.setMaxListeners(0),ift(this)}async keypress(e,r={}){this.keypressed=!0;let o=q_.action(e,q_(e,r),this.options.actions);this.state.keypress=o,this.emit("keypress",e,o),this.emit("state",this.state.clone());let a=this.options[o.action]||this[o.action]||this.dispatch;if(typeof a=="function")return await a.call(this,e,o);this.alert()}alert(){delete this.state.alert,this.options.show===!1?this.emit("alert"):this.stdout.write(Gd.code.beep)}cursorHide(){this.stdout.write(Gd.cursor.hide()),Ra.onExit(()=>this.cursorShow())}cursorShow(){this.stdout.write(Gd.cursor.show())}write(e){!e||(this.stdout&&this.state.show!==!1&&this.stdout.write(e),this.state.buffer+=e)}clear(e=0){let r=this.state.buffer;this.state.buffer="",!(!r&&!e||this.options.show===!1)&&this.stdout.write(Gd.cursor.down(e)+Gd.clear(r,this.width))}restore(){if(this.state.closed||this.options.show===!1)return;let{prompt:e,after:r,rest:o}=this.sections(),{cursor:a,initial:n="",input:u="",value:A=""}=this,p=this.state.size=o.length,h={after:r,cursor:a,initial:n,input:u,prompt:e,size:p,value:A},C=Gd.cursor.restore(h);C&&this.stdout.write(C)}sections(){let{buffer:e,input:r,prompt:o}=this.state;o=She.unstyle(o);let a=She.unstyle(e),n=a.indexOf(o),u=a.slice(0,n),p=a.slice(n).split(` -`),h=p[0],C=p[p.length-1],v=(o+(r?" "+r:"")).length,b=v<h.length?h.slice(v+1):"";return{header:u,prompt:h,after:b,rest:p.slice(1),last:C}}async submit(){this.state.submitted=!0,this.state.validating=!0,this.options.onSubmit&&await this.options.onSubmit.call(this,this.name,this.value,this);let e=this.state.error||await this.validate(this.value,this.state);if(e!==!0){let r=` -`+this.symbols.pointer+" ";typeof e=="string"?r+=e.trim():r+="Invalid input",this.state.error=` -`+this.styles.danger(r),this.state.submitted=!1,await this.render(),await this.alert(),this.state.validating=!1,this.state.error=void 0;return}this.state.validating=!1,await this.render(),await this.close(),this.value=await this.result(this.value),this.emit("submit",this.value)}async cancel(e){this.state.cancelled=this.state.submitted=!0,await this.render(),await this.close(),typeof this.options.onCancel=="function"&&await this.options.onCancel.call(this,this.name,this.value,this),this.emit("cancel",await this.error(e))}async close(){this.state.closed=!0;try{let e=this.sections(),r=Math.ceil(e.prompt.length/this.width);e.rest&&this.write(Gd.cursor.down(e.rest.length)),this.write(` -`.repeat(r))}catch{}this.emit("close")}start(){!this.stop&&this.options.show!==!1&&(this.stop=q_.listen(this,this.keypress.bind(this)),this.once("close",this.stop))}async skip(){return this.skipped=this.options.skip===!0,typeof this.options.skip=="function"&&(this.skipped=await this.options.skip.call(this,this.name,this.value)),this.skipped}async initialize(){let{format:e,options:r,result:o}=this;if(this.format=()=>e.call(this,this.value),this.result=()=>o.call(this,this.value),typeof r.initial=="function"&&(this.initial=await r.initial.call(this,this)),typeof r.onRun=="function"&&await r.onRun.call(this,this),typeof r.onSubmit=="function"){let a=r.onSubmit.bind(this),n=this.submit.bind(this);delete this.options.onSubmit,this.submit=async()=>(await a(this.name,this.value,this),n())}await this.start(),await this.render()}render(){throw new Error("expected prompt to have a custom render method")}run(){return new Promise(async(e,r)=>{if(this.once("submit",e),this.once("cancel",r),await this.skip())return this.render=()=>{},this.submit();await this.initialize(),this.emit("run")})}async element(e,r,o){let{options:a,state:n,symbols:u,timers:A}=this,p=A&&A[e];n.timer=p;let h=a[e]||n[e]||u[e],C=r&&r[e]!=null?r[e]:await h;if(C==="")return C;let I=await this.resolve(C,n,r,o);return!I&&r&&r[e]?this.resolve(h,n,r,o):I}async prefix(){let e=await this.element("prefix")||this.symbols,r=this.timers&&this.timers.prefix,o=this.state;return o.timer=r,Ra.isObject(e)&&(e=e[o.status]||e.pending),Ra.hasColor(e)?e:(this.styles[o.status]||this.styles.pending)(e)}async message(){let e=await this.element("message");return Ra.hasColor(e)?e:this.styles.strong(e)}async separator(){let e=await this.element("separator")||this.symbols,r=this.timers&&this.timers.separator,o=this.state;o.timer=r;let a=e[o.status]||e.pending||o.separator,n=await this.resolve(a,o);return Ra.isObject(n)&&(n=n[o.status]||n.pending),Ra.hasColor(n)?n:this.styles.muted(n)}async pointer(e,r){let o=await this.element("pointer",e,r);if(typeof o=="string"&&Ra.hasColor(o))return o;if(o){let a=this.styles,n=this.index===r,u=n?a.primary:h=>h,A=await this.resolve(o[n?"on":"off"]||o,this.state),p=Ra.hasColor(A)?A:u(A);return n?p:" ".repeat(A.length)}}async indicator(e,r){let o=await this.element("indicator",e,r);if(typeof o=="string"&&Ra.hasColor(o))return o;if(o){let a=this.styles,n=e.enabled===!0,u=n?a.success:a.dark,A=o[n?"on":"off"]||o;return Ra.hasColor(A)?A:u(A)}return""}body(){return null}footer(){if(this.state.status==="pending")return this.element("footer")}header(){if(this.state.status==="pending")return this.element("header")}async hint(){if(this.state.status==="pending"&&!this.isValue(this.state.input)){let e=await this.element("hint");return Ra.hasColor(e)?e:this.styles.muted(e)}}error(e){return this.state.submitted?"":e||this.state.error}format(e){return e}result(e){return e}validate(e){return this.options.required===!0?this.isValue(e):!0}isValue(e){return e!=null&&e!==""}resolve(e,...r){return Ra.resolve(this,e,...r)}get base(){return a2.prototype}get style(){return this.styles[this.state.status]}get height(){return this.options.rows||Ra.height(this.stdout,25)}get width(){return this.options.columns||Ra.width(this.stdout,80)}get size(){return{width:this.width,height:this.height}}set cursor(e){this.state.cursor=e}get cursor(){return this.state.cursor}set input(e){this.state.input=e}get input(){return this.state.input}set value(e){this.state.value=e}get value(){let{input:e,value:r}=this.state,o=[r,e].find(this.isValue.bind(this));return this.isValue(o)?o:this.initial}static get prompt(){return e=>new this(e).run()}};function ift(t){let e=a=>t[a]===void 0||typeof t[a]=="function",r=["actions","choices","initial","margin","roles","styles","symbols","theme","timers","value"],o=["body","footer","error","header","hint","indicator","message","prefix","separator","skip"];for(let a of Object.keys(t.options)){if(r.includes(a)||/^on[A-Z]/.test(a))continue;let n=t.options[a];typeof n=="function"&&e(a)?o.includes(a)||(t[a]=n.bind(t)):typeof t[a]!="function"&&(t[a]=n)}}function sft(t){typeof t=="number"&&(t=[t,t,t,t]);let e=[].concat(t||[]),r=a=>a%2===0?` -`:" ",o=[];for(let a=0;a<4;a++){let n=r(a);e[a]?o.push(n.repeat(e[a])):o.push("")}return o}xhe.exports=a2});var Qhe=_((Z_t,khe)=>{"use strict";var oft=Lo(),bhe={default(t,e){return e},checkbox(t,e){throw new Error("checkbox role is not implemented yet")},editable(t,e){throw new Error("editable role is not implemented yet")},expandable(t,e){throw new Error("expandable role is not implemented yet")},heading(t,e){return e.disabled="",e.indicator=[e.indicator," "].find(r=>r!=null),e.message=e.message||"",e},input(t,e){throw new Error("input role is not implemented yet")},option(t,e){return bhe.default(t,e)},radio(t,e){throw new Error("radio role is not implemented yet")},separator(t,e){return e.disabled="",e.indicator=[e.indicator," "].find(r=>r!=null),e.message=e.message||t.symbols.line.repeat(5),e},spacer(t,e){return e}};khe.exports=(t,e={})=>{let r=oft.merge({},bhe,e.roles);return r[t]||r.default}});var l2=_(($_t,The)=>{"use strict";var aft=Kc(),lft=hC(),cft=Qhe(),ok=Lo(),{reorder:G_,scrollUp:uft,scrollDown:Aft,isObject:Fhe,swap:fft}=ok,Y_=class extends lft{constructor(e){super(e),this.cursorHide(),this.maxSelected=e.maxSelected||1/0,this.multiple=e.multiple||!1,this.initial=e.initial||0,this.delay=e.delay||0,this.longest=0,this.num=""}async initialize(){typeof this.options.initial=="function"&&(this.initial=await this.options.initial.call(this)),await this.reset(!0),await super.initialize()}async reset(){let{choices:e,initial:r,autofocus:o,suggest:a}=this.options;if(this.state._choices=[],this.state.choices=[],this.choices=await Promise.all(await this.toChoices(e)),this.choices.forEach(n=>n.enabled=!1),typeof a!="function"&&this.selectable.length===0)throw new Error("At least one choice must be selectable");Fhe(r)&&(r=Object.keys(r)),Array.isArray(r)?(o!=null&&(this.index=this.findIndex(o)),r.forEach(n=>this.enable(this.find(n))),await this.render()):(o!=null&&(r=o),typeof r=="string"&&(r=this.findIndex(r)),typeof r=="number"&&r>-1&&(this.index=Math.max(0,Math.min(r,this.choices.length)),this.enable(this.find(this.index)))),this.isDisabled(this.focused)&&await this.down()}async toChoices(e,r){this.state.loadingChoices=!0;let o=[],a=0,n=async(u,A)=>{typeof u=="function"&&(u=await u.call(this)),u instanceof Promise&&(u=await u);for(let p=0;p<u.length;p++){let h=u[p]=await this.toChoice(u[p],a++,A);o.push(h),h.choices&&await n(h.choices,h)}return o};return n(e,r).then(u=>(this.state.loadingChoices=!1,u))}async toChoice(e,r,o){if(typeof e=="function"&&(e=await e.call(this,this)),e instanceof Promise&&(e=await e),typeof e=="string"&&(e={name:e}),e.normalized)return e;e.normalized=!0;let a=e.value;if(e=cft(e.role,this.options)(this,e),typeof e.disabled=="string"&&!e.hint&&(e.hint=e.disabled,e.disabled=!0),e.disabled===!0&&e.hint==null&&(e.hint="(disabled)"),e.index!=null)return e;e.name=e.name||e.key||e.title||e.value||e.message,e.message=e.message||e.name||"",e.value=[e.value,e.name].find(this.isValue.bind(this)),e.input="",e.index=r,e.cursor=0,ok.define(e,"parent",o),e.level=o?o.level+1:1,e.indent==null&&(e.indent=o?o.indent+" ":e.indent||""),e.path=o?o.path+"."+e.name:e.name,e.enabled=!!(this.multiple&&!this.isDisabled(e)&&(e.enabled||this.isSelected(e))),this.isDisabled(e)||(this.longest=Math.max(this.longest,aft.unstyle(e.message).length));let u={...e};return e.reset=(A=u.input,p=u.value)=>{for(let h of Object.keys(u))e[h]=u[h];e.input=A,e.value=p},a==null&&typeof e.initial=="function"&&(e.input=await e.initial.call(this,this.state,e,r)),e}async onChoice(e,r){this.emit("choice",e,r,this),typeof e.onChoice=="function"&&await e.onChoice.call(this,this.state,e,r)}async addChoice(e,r,o){let a=await this.toChoice(e,r,o);return this.choices.push(a),this.index=this.choices.length-1,this.limit=this.choices.length,a}async newItem(e,r,o){let a={name:"New choice name?",editable:!0,newChoice:!0,...e},n=await this.addChoice(a,r,o);return n.updateChoice=()=>{delete n.newChoice,n.name=n.message=n.input,n.input="",n.cursor=0},this.render()}indent(e){return e.indent==null?e.level>1?" ".repeat(e.level-1):"":e.indent}dispatch(e,r){if(this.multiple&&this[r.name])return this[r.name]();this.alert()}focus(e,r){return typeof r!="boolean"&&(r=e.enabled),r&&!e.enabled&&this.selected.length>=this.maxSelected?this.alert():(this.index=e.index,e.enabled=r&&!this.isDisabled(e),e)}space(){return this.multiple?(this.toggle(this.focused),this.render()):this.alert()}a(){if(this.maxSelected<this.choices.length)return this.alert();let e=this.selectable.every(r=>r.enabled);return this.choices.forEach(r=>r.enabled=!e),this.render()}i(){return this.choices.length-this.selected.length>this.maxSelected?this.alert():(this.choices.forEach(e=>e.enabled=!e.enabled),this.render())}g(e=this.focused){return this.choices.some(r=>!!r.parent)?(this.toggle(e.parent&&!e.choices?e.parent:e),this.render()):this.a()}toggle(e,r){if(!e.enabled&&this.selected.length>=this.maxSelected)return this.alert();typeof r!="boolean"&&(r=!e.enabled),e.enabled=r,e.choices&&e.choices.forEach(a=>this.toggle(a,r));let o=e.parent;for(;o;){let a=o.choices.filter(n=>this.isDisabled(n));o.enabled=a.every(n=>n.enabled===!0),o=o.parent}return Rhe(this,this.choices),this.emit("toggle",e,this),e}enable(e){return this.selected.length>=this.maxSelected?this.alert():(e.enabled=!this.isDisabled(e),e.choices&&e.choices.forEach(this.enable.bind(this)),e)}disable(e){return e.enabled=!1,e.choices&&e.choices.forEach(this.disable.bind(this)),e}number(e){this.num+=e;let r=o=>{let a=Number(o);if(a>this.choices.length-1)return this.alert();let n=this.focused,u=this.choices.find(A=>a===A.index);if(!u.enabled&&this.selected.length>=this.maxSelected)return this.alert();if(this.visible.indexOf(u)===-1){let A=G_(this.choices),p=A.indexOf(u);if(n.index>p){let h=A.slice(p,p+this.limit),C=A.filter(I=>!h.includes(I));this.choices=h.concat(C)}else{let h=p-this.limit+1;this.choices=A.slice(h).concat(A.slice(0,h))}}return this.index=this.choices.indexOf(u),this.toggle(this.focused),this.render()};return clearTimeout(this.numberTimeout),new Promise(o=>{let a=this.choices.length,n=this.num,u=(A=!1,p)=>{clearTimeout(this.numberTimeout),A&&(p=r(n)),this.num="",o(p)};if(n==="0"||n.length===1&&Number(n+"0")>a)return u(!0);if(Number(n)>a)return u(!1,this.alert());this.numberTimeout=setTimeout(()=>u(!0),this.delay)})}home(){return this.choices=G_(this.choices),this.index=0,this.render()}end(){let e=this.choices.length-this.limit,r=G_(this.choices);return this.choices=r.slice(e).concat(r.slice(0,e)),this.index=this.limit-1,this.render()}first(){return this.index=0,this.render()}last(){return this.index=this.visible.length-1,this.render()}prev(){return this.visible.length<=1?this.alert():this.up()}next(){return this.visible.length<=1?this.alert():this.down()}right(){return this.cursor>=this.input.length?this.alert():(this.cursor++,this.render())}left(){return this.cursor<=0?this.alert():(this.cursor--,this.render())}up(){let e=this.choices.length,r=this.visible.length,o=this.index;return this.options.scroll===!1&&o===0?this.alert():e>r&&o===0?this.scrollUp():(this.index=(o-1%e+e)%e,this.isDisabled()?this.up():this.render())}down(){let e=this.choices.length,r=this.visible.length,o=this.index;return this.options.scroll===!1&&o===r-1?this.alert():e>r&&o===r-1?this.scrollDown():(this.index=(o+1)%e,this.isDisabled()?this.down():this.render())}scrollUp(e=0){return this.choices=uft(this.choices),this.index=e,this.isDisabled()?this.up():this.render()}scrollDown(e=this.visible.length-1){return this.choices=Aft(this.choices),this.index=e,this.isDisabled()?this.down():this.render()}async shiftUp(){if(this.options.sort===!0){this.sorting=!0,this.swap(this.index-1),await this.up(),this.sorting=!1;return}return this.scrollUp(this.index)}async shiftDown(){if(this.options.sort===!0){this.sorting=!0,this.swap(this.index+1),await this.down(),this.sorting=!1;return}return this.scrollDown(this.index)}pageUp(){return this.visible.length<=1?this.alert():(this.limit=Math.max(this.limit-1,0),this.index=Math.min(this.limit-1,this.index),this._limit=this.limit,this.isDisabled()?this.up():this.render())}pageDown(){return this.visible.length>=this.choices.length?this.alert():(this.index=Math.max(0,this.index),this.limit=Math.min(this.limit+1,this.choices.length),this._limit=this.limit,this.isDisabled()?this.down():this.render())}swap(e){fft(this.choices,this.index,e)}isDisabled(e=this.focused){return e&&["disabled","collapsed","hidden","completing","readonly"].some(o=>e[o]===!0)?!0:e&&e.role==="heading"}isEnabled(e=this.focused){if(Array.isArray(e))return e.every(r=>this.isEnabled(r));if(e.choices){let r=e.choices.filter(o=>!this.isDisabled(o));return e.enabled&&r.every(o=>this.isEnabled(o))}return e.enabled&&!this.isDisabled(e)}isChoice(e,r){return e.name===r||e.index===Number(r)}isSelected(e){return Array.isArray(this.initial)?this.initial.some(r=>this.isChoice(e,r)):this.isChoice(e,this.initial)}map(e=[],r="value"){return[].concat(e||[]).reduce((o,a)=>(o[a]=this.find(a,r),o),{})}filter(e,r){let a=typeof e=="function"?e:(A,p)=>[A.name,p].includes(e),u=(this.options.multiple?this.state._choices:this.choices).filter(a);return r?u.map(A=>A[r]):u}find(e,r){if(Fhe(e))return r?e[r]:e;let a=typeof e=="function"?e:(u,A)=>[u.name,A].includes(e),n=this.choices.find(a);if(n)return r?n[r]:n}findIndex(e){return this.choices.indexOf(this.find(e))}async submit(){let e=this.focused;if(!e)return this.alert();if(e.newChoice)return e.input?(e.updateChoice(),this.render()):this.alert();if(this.choices.some(u=>u.newChoice))return this.alert();let{reorder:r,sort:o}=this.options,a=this.multiple===!0,n=this.selected;return n===void 0?this.alert():(Array.isArray(n)&&r!==!1&&o!==!0&&(n=ok.reorder(n)),this.value=a?n.map(u=>u.name):n.name,super.submit())}set choices(e=[]){this.state._choices=this.state._choices||[],this.state.choices=e;for(let r of e)this.state._choices.some(o=>o.name===r.name)||this.state._choices.push(r);if(!this._initial&&this.options.initial){this._initial=!0;let r=this.initial;if(typeof r=="string"||typeof r=="number"){let o=this.find(r);o&&(this.initial=o.index,this.focus(o,!0))}}}get choices(){return Rhe(this,this.state.choices||[])}set visible(e){this.state.visible=e}get visible(){return(this.state.visible||this.choices).slice(0,this.limit)}set limit(e){this.state.limit=e}get limit(){let{state:e,options:r,choices:o}=this,a=e.limit||this._limit||r.limit||o.length;return Math.min(a,this.height)}set value(e){super.value=e}get value(){return typeof super.value!="string"&&super.value===this.initial?this.input:super.value}set index(e){this.state.index=e}get index(){return Math.max(0,this.state?this.state.index:0)}get enabled(){return this.filter(this.isEnabled.bind(this))}get focused(){let e=this.choices[this.index];return e&&this.state.submitted&&this.multiple!==!0&&(e.enabled=!0),e}get selectable(){return this.choices.filter(e=>!this.isDisabled(e))}get selected(){return this.multiple?this.enabled:this.focused}};function Rhe(t,e){if(e instanceof Promise)return e;if(typeof e=="function"){if(ok.isAsyncFn(e))return e;e=e.call(t,t)}for(let r of e){if(Array.isArray(r.choices)){let o=r.choices.filter(a=>!t.isDisabled(a));r.enabled=o.every(a=>a.enabled===!0)}t.isDisabled(r)===!0&&delete r.enabled}return e}The.exports=Y_});var Sh=_((e8t,Lhe)=>{"use strict";var pft=l2(),W_=Lo(),K_=class extends pft{constructor(e){super(e),this.emptyError=this.options.emptyError||"No items were selected"}async dispatch(e,r){if(this.multiple)return this[r.name]?await this[r.name](e,r):await super.dispatch(e,r);this.alert()}separator(){if(this.options.separator)return super.separator();let e=this.styles.muted(this.symbols.ellipsis);return this.state.submitted?super.separator():e}pointer(e,r){return!this.multiple||this.options.pointer?super.pointer(e,r):""}indicator(e,r){return this.multiple?super.indicator(e,r):""}choiceMessage(e,r){let o=this.resolve(e.message,this.state,e,r);return e.role==="heading"&&!W_.hasColor(o)&&(o=this.styles.strong(o)),this.resolve(o,this.state,e,r)}choiceSeparator(){return":"}async renderChoice(e,r){await this.onChoice(e,r);let o=this.index===r,a=await this.pointer(e,r),n=await this.indicator(e,r)+(e.pad||""),u=await this.resolve(e.hint,this.state,e,r);u&&!W_.hasColor(u)&&(u=this.styles.muted(u));let A=this.indent(e),p=await this.choiceMessage(e,r),h=()=>[this.margin[3],A+a+n,p,this.margin[1],u].filter(Boolean).join(" ");return e.role==="heading"?h():e.disabled?(W_.hasColor(p)||(p=this.styles.disabled(p)),h()):(o&&(p=this.styles.em(p)),h())}async renderChoices(){if(this.state.loading==="choices")return this.styles.warning("Loading choices");if(this.state.submitted)return"";let e=this.visible.map(async(n,u)=>await this.renderChoice(n,u)),r=await Promise.all(e);r.length||r.push(this.styles.danger("No matching choices"));let o=this.margin[0]+r.join(` -`),a;return this.options.choicesHeader&&(a=await this.resolve(this.options.choicesHeader,this.state)),[a,o].filter(Boolean).join(` -`)}format(){return!this.state.submitted||this.state.cancelled?"":Array.isArray(this.selected)?this.selected.map(e=>this.styles.primary(e.name)).join(", "):this.styles.primary(this.selected.name)}async render(){let{submitted:e,size:r}=this.state,o="",a=await this.header(),n=await this.prefix(),u=await this.separator(),A=await this.message();this.options.promptLine!==!1&&(o=[n,A,u,""].join(" "),this.state.prompt=o);let p=await this.format(),h=await this.error()||await this.hint(),C=await this.renderChoices(),I=await this.footer();p&&(o+=p),h&&!o.includes(h)&&(o+=" "+h),e&&!p&&!C.trim()&&this.multiple&&this.emptyError!=null&&(o+=this.styles.danger(this.emptyError)),this.clear(r),this.write([a,o,C,I].filter(Boolean).join(` -`)),this.write(this.margin[2]),this.restore()}};Lhe.exports=K_});var Ohe=_((t8t,Nhe)=>{"use strict";var hft=Sh(),gft=(t,e)=>{let r=t.toLowerCase();return o=>{let n=o.toLowerCase().indexOf(r),u=e(o.slice(n,n+r.length));return n>=0?o.slice(0,n)+u+o.slice(n+r.length):o}},V_=class extends hft{constructor(e){super(e),this.cursorShow()}moveCursor(e){this.state.cursor+=e}dispatch(e){return this.append(e)}space(e){return this.options.multiple?super.space(e):this.append(e)}append(e){let{cursor:r,input:o}=this.state;return this.input=o.slice(0,r)+e+o.slice(r),this.moveCursor(1),this.complete()}delete(){let{cursor:e,input:r}=this.state;return r?(this.input=r.slice(0,e-1)+r.slice(e),this.moveCursor(-1),this.complete()):this.alert()}deleteForward(){let{cursor:e,input:r}=this.state;return r[e]===void 0?this.alert():(this.input=`${r}`.slice(0,e)+`${r}`.slice(e+1),this.complete())}number(e){return this.append(e)}async complete(){this.completing=!0,this.choices=await this.suggest(this.input,this.state._choices),this.state.limit=void 0,this.index=Math.min(Math.max(this.visible.length-1,0),this.index),await this.render(),this.completing=!1}suggest(e=this.input,r=this.state._choices){if(typeof this.options.suggest=="function")return this.options.suggest.call(this,e,r);let o=e.toLowerCase();return r.filter(a=>a.message.toLowerCase().includes(o))}pointer(){return""}format(){if(!this.focused)return this.input;if(this.options.multiple&&this.state.submitted)return this.selected.map(e=>this.styles.primary(e.message)).join(", ");if(this.state.submitted){let e=this.value=this.input=this.focused.value;return this.styles.primary(e)}return this.input}async render(){if(this.state.status!=="pending")return super.render();let e=this.options.highlight?this.options.highlight.bind(this):this.styles.placeholder,r=gft(this.input,e),o=this.choices;this.choices=o.map(a=>({...a,message:r(a.message)})),await super.render(),this.choices=o}submit(){return this.options.multiple&&(this.value=this.selected.map(e=>e.name)),super.submit()}};Nhe.exports=V_});var J_=_((r8t,Mhe)=>{"use strict";var z_=Lo();Mhe.exports=(t,e={})=>{t.cursorHide();let{input:r="",initial:o="",pos:a,showCursor:n=!0,color:u}=e,A=u||t.styles.placeholder,p=z_.inverse(t.styles.primary),h=F=>p(t.styles.black(F)),C=r,I=" ",v=h(I);if(t.blink&&t.blink.off===!0&&(h=F=>F,v=""),n&&a===0&&o===""&&r==="")return h(I);if(n&&a===0&&(r===o||r===""))return h(o[0])+A(o.slice(1));o=z_.isPrimitive(o)?`${o}`:"",r=z_.isPrimitive(r)?`${r}`:"";let b=o&&o.startsWith(r)&&o!==r,E=b?h(o[r.length]):v;if(a!==r.length&&n===!0&&(C=r.slice(0,a)+h(r[a])+r.slice(a+1),E=""),n===!1&&(E=""),b){let F=t.styles.unstyle(C+E);return C+E+A(o.slice(F.length))}return C+E}});var ak=_((n8t,Uhe)=>{"use strict";var dft=Kc(),mft=Sh(),yft=J_(),X_=class extends mft{constructor(e){super({...e,multiple:!0}),this.type="form",this.initial=this.options.initial,this.align=[this.options.align,"right"].find(r=>r!=null),this.emptyError="",this.values={}}async reset(e){return await super.reset(),e===!0&&(this._index=this.index),this.index=this._index,this.values={},this.choices.forEach(r=>r.reset&&r.reset()),this.render()}dispatch(e){return!!e&&this.append(e)}append(e){let r=this.focused;if(!r)return this.alert();let{cursor:o,input:a}=r;return r.value=r.input=a.slice(0,o)+e+a.slice(o),r.cursor++,this.render()}delete(){let e=this.focused;if(!e||e.cursor<=0)return this.alert();let{cursor:r,input:o}=e;return e.value=e.input=o.slice(0,r-1)+o.slice(r),e.cursor--,this.render()}deleteForward(){let e=this.focused;if(!e)return this.alert();let{cursor:r,input:o}=e;if(o[r]===void 0)return this.alert();let a=`${o}`.slice(0,r)+`${o}`.slice(r+1);return e.value=e.input=a,this.render()}right(){let e=this.focused;return e?e.cursor>=e.input.length?this.alert():(e.cursor++,this.render()):this.alert()}left(){let e=this.focused;return e?e.cursor<=0?this.alert():(e.cursor--,this.render()):this.alert()}space(e,r){return this.dispatch(e,r)}number(e,r){return this.dispatch(e,r)}next(){let e=this.focused;if(!e)return this.alert();let{initial:r,input:o}=e;return r&&r.startsWith(o)&&o!==r?(e.value=e.input=r,e.cursor=e.value.length,this.render()):super.next()}prev(){let e=this.focused;return e?e.cursor===0?super.prev():(e.value=e.input="",e.cursor=0,this.render()):this.alert()}separator(){return""}format(e){return this.state.submitted?"":super.format(e)}pointer(){return""}indicator(e){return e.input?"\u29BF":"\u2299"}async choiceSeparator(e,r){let o=await this.resolve(e.separator,this.state,e,r)||":";return o?" "+this.styles.disabled(o):""}async renderChoice(e,r){await this.onChoice(e,r);let{state:o,styles:a}=this,{cursor:n,initial:u="",name:A,hint:p,input:h=""}=e,{muted:C,submitted:I,primary:v,danger:b}=a,E=p,F=this.index===r,N=e.validate||(()=>!0),U=await this.choiceSeparator(e,r),z=e.message;this.align==="right"&&(z=z.padStart(this.longest+1," ")),this.align==="left"&&(z=z.padEnd(this.longest+1," "));let te=this.values[A]=h||u,le=h?"success":"dark";await N.call(e,te,this.state)!==!0&&(le="danger");let pe=a[le],ue=pe(await this.indicator(e,r))+(e.pad||""),ye=this.indent(e),ae=()=>[ye,ue,z+U,h,E].filter(Boolean).join(" ");if(o.submitted)return z=dft.unstyle(z),h=I(h),E="",ae();if(e.format)h=await e.format.call(this,h,e,r);else{let Ie=this.styles.muted;h=yft(this,{input:h,initial:u,pos:n,showCursor:F,color:Ie})}return this.isValue(h)||(h=this.styles.muted(this.symbols.ellipsis)),e.result&&(this.values[A]=await e.result.call(this,te,e,r)),F&&(z=v(z)),e.error?h+=(h?" ":"")+b(e.error.trim()):e.hint&&(h+=(h?" ":"")+C(e.hint.trim())),ae()}async submit(){return this.value=this.values,super.base.submit.call(this)}};Uhe.exports=X_});var Z_=_((i8t,Hhe)=>{"use strict";var Eft=ak(),Cft=()=>{throw new Error("expected prompt to have a custom authenticate method")},_he=(t=Cft)=>{class e extends Eft{constructor(o){super(o)}async submit(){this.value=await t.call(this,this.values,this.state),super.base.submit.call(this)}static create(o){return _he(o)}}return e};Hhe.exports=_he()});var Ghe=_((s8t,qhe)=>{"use strict";var wft=Z_();function Ift(t,e){return t.username===this.options.username&&t.password===this.options.password}var jhe=(t=Ift)=>{let e=[{name:"username",message:"username"},{name:"password",message:"password",format(o){return this.options.showPassword?o:(this.state.submitted?this.styles.primary:this.styles.muted)(this.symbols.asterisk.repeat(o.length))}}];class r extends wft.create(t){constructor(a){super({...a,choices:e})}static create(a){return jhe(a)}}return r};qhe.exports=jhe()});var lk=_((o8t,Yhe)=>{"use strict";var Bft=hC(),{isPrimitive:vft,hasColor:Dft}=Lo(),$_=class extends Bft{constructor(e){super(e),this.cursorHide()}async initialize(){let e=await this.resolve(this.initial,this.state);this.input=await this.cast(e),await super.initialize()}dispatch(e){return this.isValue(e)?(this.input=e,this.submit()):this.alert()}format(e){let{styles:r,state:o}=this;return o.submitted?r.success(e):r.primary(e)}cast(e){return this.isTrue(e)}isTrue(e){return/^[ty1]/i.test(e)}isFalse(e){return/^[fn0]/i.test(e)}isValue(e){return vft(e)&&(this.isTrue(e)||this.isFalse(e))}async hint(){if(this.state.status==="pending"){let e=await this.element("hint");return Dft(e)?e:this.styles.muted(e)}}async render(){let{input:e,size:r}=this.state,o=await this.prefix(),a=await this.separator(),n=await this.message(),u=this.styles.muted(this.default),A=[o,n,u,a].filter(Boolean).join(" ");this.state.prompt=A;let p=await this.header(),h=this.value=this.cast(e),C=await this.format(h),I=await this.error()||await this.hint(),v=await this.footer();I&&!A.includes(I)&&(C+=" "+I),A+=" "+C,this.clear(r),this.write([p,A,v].filter(Boolean).join(` -`)),this.restore()}set value(e){super.value=e}get value(){return this.cast(super.value)}};Yhe.exports=$_});var Khe=_((a8t,Whe)=>{"use strict";var Pft=lk(),e8=class extends Pft{constructor(e){super(e),this.default=this.options.default||(this.initial?"(Y/n)":"(y/N)")}};Whe.exports=e8});var zhe=_((l8t,Vhe)=>{"use strict";var Sft=Sh(),xft=ak(),gC=xft.prototype,t8=class extends Sft{constructor(e){super({...e,multiple:!0}),this.align=[this.options.align,"left"].find(r=>r!=null),this.emptyError="",this.values={}}dispatch(e,r){let o=this.focused,a=o.parent||{};return!o.editable&&!a.editable&&(e==="a"||e==="i")?super[e]():gC.dispatch.call(this,e,r)}append(e,r){return gC.append.call(this,e,r)}delete(e,r){return gC.delete.call(this,e,r)}space(e){return this.focused.editable?this.append(e):super.space()}number(e){return this.focused.editable?this.append(e):super.number(e)}next(){return this.focused.editable?gC.next.call(this):super.next()}prev(){return this.focused.editable?gC.prev.call(this):super.prev()}async indicator(e,r){let o=e.indicator||"",a=e.editable?o:super.indicator(e,r);return await this.resolve(a,this.state,e,r)||""}indent(e){return e.role==="heading"?"":e.editable?" ":" "}async renderChoice(e,r){return e.indent="",e.editable?gC.renderChoice.call(this,e,r):super.renderChoice(e,r)}error(){return""}footer(){return this.state.error}async validate(){let e=!0;for(let r of this.choices){if(typeof r.validate!="function"||r.role==="heading")continue;let o=r.parent?this.value[r.parent.name]:this.value;if(r.editable?o=r.value===r.name?r.initial||"":r.value:this.isDisabled(r)||(o=r.enabled===!0),e=await r.validate(o,this.state),e!==!0)break}return e!==!0&&(this.state.error=typeof e=="string"?e:"Invalid Input"),e}submit(){if(this.focused.newChoice===!0)return super.submit();if(this.choices.some(e=>e.newChoice))return this.alert();this.value={};for(let e of this.choices){let r=e.parent?this.value[e.parent.name]:this.value;if(e.role==="heading"){this.value[e.name]={};continue}e.editable?r[e.name]=e.value===e.name?e.initial||"":e.value:this.isDisabled(e)||(r[e.name]=e.enabled===!0)}return this.base.submit.call(this)}};Vhe.exports=t8});var Yd=_((c8t,Jhe)=>{"use strict";var bft=hC(),kft=J_(),{isPrimitive:Qft}=Lo(),r8=class extends bft{constructor(e){super(e),this.initial=Qft(this.initial)?String(this.initial):"",this.initial&&this.cursorHide(),this.state.prevCursor=0,this.state.clipboard=[]}async keypress(e,r={}){let o=this.state.prevKeypress;return this.state.prevKeypress=r,this.options.multiline===!0&&r.name==="return"&&(!o||o.name!=="return")?this.append(` -`,r):super.keypress(e,r)}moveCursor(e){this.cursor+=e}reset(){return this.input=this.value="",this.cursor=0,this.render()}dispatch(e,r){if(!e||r.ctrl||r.code)return this.alert();this.append(e)}append(e){let{cursor:r,input:o}=this.state;this.input=`${o}`.slice(0,r)+e+`${o}`.slice(r),this.moveCursor(String(e).length),this.render()}insert(e){this.append(e)}delete(){let{cursor:e,input:r}=this.state;if(e<=0)return this.alert();this.input=`${r}`.slice(0,e-1)+`${r}`.slice(e),this.moveCursor(-1),this.render()}deleteForward(){let{cursor:e,input:r}=this.state;if(r[e]===void 0)return this.alert();this.input=`${r}`.slice(0,e)+`${r}`.slice(e+1),this.render()}cutForward(){let e=this.cursor;if(this.input.length<=e)return this.alert();this.state.clipboard.push(this.input.slice(e)),this.input=this.input.slice(0,e),this.render()}cutLeft(){let e=this.cursor;if(e===0)return this.alert();let r=this.input.slice(0,e),o=this.input.slice(e),a=r.split(" ");this.state.clipboard.push(a.pop()),this.input=a.join(" "),this.cursor=this.input.length,this.input+=o,this.render()}paste(){if(!this.state.clipboard.length)return this.alert();this.insert(this.state.clipboard.pop()),this.render()}toggleCursor(){this.state.prevCursor?(this.cursor=this.state.prevCursor,this.state.prevCursor=0):(this.state.prevCursor=this.cursor,this.cursor=0),this.render()}first(){this.cursor=0,this.render()}last(){this.cursor=this.input.length-1,this.render()}next(){let e=this.initial!=null?String(this.initial):"";if(!e||!e.startsWith(this.input))return this.alert();this.input=this.initial,this.cursor=this.initial.length,this.render()}prev(){if(!this.input)return this.alert();this.reset()}backward(){return this.left()}forward(){return this.right()}right(){return this.cursor>=this.input.length?this.alert():(this.moveCursor(1),this.render())}left(){return this.cursor<=0?this.alert():(this.moveCursor(-1),this.render())}isValue(e){return!!e}async format(e=this.value){let r=await this.resolve(this.initial,this.state);return this.state.submitted?this.styles.submitted(e||r):kft(this,{input:e,initial:r,pos:this.cursor})}async render(){let e=this.state.size,r=await this.prefix(),o=await this.separator(),a=await this.message(),n=[r,a,o].filter(Boolean).join(" ");this.state.prompt=n;let u=await this.header(),A=await this.format(),p=await this.error()||await this.hint(),h=await this.footer();p&&!A.includes(p)&&(A+=" "+p),n+=" "+A,this.clear(e),this.write([u,n,h].filter(Boolean).join(` -`)),this.restore()}};Jhe.exports=r8});var Zhe=_((u8t,Xhe)=>{"use strict";var Fft=t=>t.filter((e,r)=>t.lastIndexOf(e)===r),ck=t=>Fft(t).filter(Boolean);Xhe.exports=(t,e={},r="")=>{let{past:o=[],present:a=""}=e,n,u;switch(t){case"prev":case"undo":return n=o.slice(0,o.length-1),u=o[o.length-1]||"",{past:ck([r,...n]),present:u};case"next":case"redo":return n=o.slice(1),u=o[0]||"",{past:ck([...n,r]),present:u};case"save":return{past:ck([...o,r]),present:""};case"remove":return u=ck(o.filter(A=>A!==r)),a="",u.length&&(a=u.pop()),{past:u,present:a};default:throw new Error(`Invalid action: "${t}"`)}}});var i8=_((A8t,e0e)=>{"use strict";var Rft=Yd(),$he=Zhe(),n8=class extends Rft{constructor(e){super(e);let r=this.options.history;if(r&&r.store){let o=r.values||this.initial;this.autosave=!!r.autosave,this.store=r.store,this.data=this.store.get("values")||{past:[],present:o},this.initial=this.data.present||this.data.past[this.data.past.length-1]}}completion(e){return this.store?(this.data=$he(e,this.data,this.input),this.data.present?(this.input=this.data.present,this.cursor=this.input.length,this.render()):this.alert()):this.alert()}altUp(){return this.completion("prev")}altDown(){return this.completion("next")}prev(){return this.save(),super.prev()}save(){!this.store||(this.data=$he("save",this.data,this.input),this.store.set("values",this.data))}submit(){return this.store&&this.autosave===!0&&this.save(),super.submit()}};e0e.exports=n8});var r0e=_((f8t,t0e)=>{"use strict";var Tft=Yd(),s8=class extends Tft{format(){return""}};t0e.exports=s8});var i0e=_((p8t,n0e)=>{"use strict";var Lft=Yd(),o8=class extends Lft{constructor(e={}){super(e),this.sep=this.options.separator||/, */,this.initial=e.initial||""}split(e=this.value){return e?String(e).split(this.sep):[]}format(){let e=this.state.submitted?this.styles.primary:r=>r;return this.list.map(e).join(", ")}async submit(e){let r=this.state.error||await this.validate(this.list,this.state);return r!==!0?(this.state.error=r,super.submit()):(this.value=this.list,super.submit())}get list(){return this.split()}};n0e.exports=o8});var o0e=_((h8t,s0e)=>{"use strict";var Nft=Sh(),a8=class extends Nft{constructor(e){super({...e,multiple:!0})}};s0e.exports=a8});var c8=_((g8t,a0e)=>{"use strict";var Oft=Yd(),l8=class extends Oft{constructor(e={}){super({style:"number",...e}),this.min=this.isValue(e.min)?this.toNumber(e.min):-1/0,this.max=this.isValue(e.max)?this.toNumber(e.max):1/0,this.delay=e.delay!=null?e.delay:1e3,this.float=e.float!==!1,this.round=e.round===!0||e.float===!1,this.major=e.major||10,this.minor=e.minor||1,this.initial=e.initial!=null?e.initial:"",this.input=String(this.initial),this.cursor=this.input.length,this.cursorShow()}append(e){return!/[-+.]/.test(e)||e==="."&&this.input.includes(".")?this.alert("invalid number"):super.append(e)}number(e){return super.append(e)}next(){return this.input&&this.input!==this.initial?this.alert():this.isValue(this.initial)?(this.input=this.initial,this.cursor=String(this.initial).length,this.render()):this.alert()}up(e){let r=e||this.minor,o=this.toNumber(this.input);return o>this.max+r?this.alert():(this.input=`${o+r}`,this.render())}down(e){let r=e||this.minor,o=this.toNumber(this.input);return o<this.min-r?this.alert():(this.input=`${o-r}`,this.render())}shiftDown(){return this.down(this.major)}shiftUp(){return this.up(this.major)}format(e=this.input){return typeof this.options.format=="function"?this.options.format.call(this,e):this.styles.info(e)}toNumber(e=""){return this.float?+e:Math.round(+e)}isValue(e){return/^[-+]?[0-9]+((\.)|(\.[0-9]+))?$/.test(e)}submit(){let e=[this.input,this.initial].find(r=>this.isValue(r));return this.value=this.toNumber(e||0),super.submit()}};a0e.exports=l8});var c0e=_((d8t,l0e)=>{l0e.exports=c8()});var A0e=_((m8t,u0e)=>{"use strict";var Mft=Yd(),u8=class extends Mft{constructor(e){super(e),this.cursorShow()}format(e=this.input){return this.keypressed?(this.state.submitted?this.styles.primary:this.styles.muted)(this.symbols.asterisk.repeat(e.length)):""}};u0e.exports=u8});var h0e=_((y8t,p0e)=>{"use strict";var Uft=Kc(),_ft=l2(),f0e=Lo(),A8=class extends _ft{constructor(e={}){super(e),this.widths=[].concat(e.messageWidth||50),this.align=[].concat(e.align||"left"),this.linebreak=e.linebreak||!1,this.edgeLength=e.edgeLength||3,this.newline=e.newline||` - `;let r=e.startNumber||1;typeof this.scale=="number"&&(this.scaleKey=!1,this.scale=Array(this.scale).fill(0).map((o,a)=>({name:a+r})))}async reset(){return this.tableized=!1,await super.reset(),this.render()}tableize(){if(this.tableized===!0)return;this.tableized=!0;let e=0;for(let r of this.choices){e=Math.max(e,r.message.length),r.scaleIndex=r.initial||2,r.scale=[];for(let o=0;o<this.scale.length;o++)r.scale.push({index:o})}this.widths[0]=Math.min(this.widths[0],e+3)}async dispatch(e,r){if(this.multiple)return this[r.name]?await this[r.name](e,r):await super.dispatch(e,r);this.alert()}heading(e,r,o){return this.styles.strong(e)}separator(){return this.styles.muted(this.symbols.ellipsis)}right(){let e=this.focused;return e.scaleIndex>=this.scale.length-1?this.alert():(e.scaleIndex++,this.render())}left(){let e=this.focused;return e.scaleIndex<=0?this.alert():(e.scaleIndex--,this.render())}indent(){return""}format(){return this.state.submitted?this.choices.map(r=>this.styles.info(r.index)).join(", "):""}pointer(){return""}renderScaleKey(){return this.scaleKey===!1||this.state.submitted?"":["",...this.scale.map(o=>` ${o.name} - ${o.message}`)].map(o=>this.styles.muted(o)).join(` -`)}renderScaleHeading(e){let r=this.scale.map(p=>p.name);typeof this.options.renderScaleHeading=="function"&&(r=this.options.renderScaleHeading.call(this,e));let o=this.scaleLength-r.join("").length,a=Math.round(o/(r.length-1)),u=r.map(p=>this.styles.strong(p)).join(" ".repeat(a)),A=" ".repeat(this.widths[0]);return this.margin[3]+A+this.margin[1]+u}scaleIndicator(e,r,o){if(typeof this.options.scaleIndicator=="function")return this.options.scaleIndicator.call(this,e,r,o);let a=e.scaleIndex===r.index;return r.disabled?this.styles.hint(this.symbols.radio.disabled):a?this.styles.success(this.symbols.radio.on):this.symbols.radio.off}renderScale(e,r){let o=e.scale.map(n=>this.scaleIndicator(e,n,r)),a=this.term==="Hyper"?"":" ";return o.join(a+this.symbols.line.repeat(this.edgeLength))}async renderChoice(e,r){await this.onChoice(e,r);let o=this.index===r,a=await this.pointer(e,r),n=await e.hint;n&&!f0e.hasColor(n)&&(n=this.styles.muted(n));let u=E=>this.margin[3]+E.replace(/\s+$/,"").padEnd(this.widths[0]," "),A=this.newline,p=this.indent(e),h=await this.resolve(e.message,this.state,e,r),C=await this.renderScale(e,r),I=this.margin[1]+this.margin[3];this.scaleLength=Uft.unstyle(C).length,this.widths[0]=Math.min(this.widths[0],this.width-this.scaleLength-I.length);let b=f0e.wordWrap(h,{width:this.widths[0],newline:A}).split(` -`).map(E=>u(E)+this.margin[1]);return o&&(C=this.styles.info(C),b=b.map(E=>this.styles.info(E))),b[0]+=C,this.linebreak&&b.push(""),[p+a,b.join(` -`)].filter(Boolean)}async renderChoices(){if(this.state.submitted)return"";this.tableize();let e=this.visible.map(async(a,n)=>await this.renderChoice(a,n)),r=await Promise.all(e),o=await this.renderScaleHeading();return this.margin[0]+[o,...r.map(a=>a.join(" "))].join(` -`)}async render(){let{submitted:e,size:r}=this.state,o=await this.prefix(),a=await this.separator(),n=await this.message(),u="";this.options.promptLine!==!1&&(u=[o,n,a,""].join(" "),this.state.prompt=u);let A=await this.header(),p=await this.format(),h=await this.renderScaleKey(),C=await this.error()||await this.hint(),I=await this.renderChoices(),v=await this.footer(),b=this.emptyError;p&&(u+=p),C&&!u.includes(C)&&(u+=" "+C),e&&!p&&!I.trim()&&this.multiple&&b!=null&&(u+=this.styles.danger(b)),this.clear(r),this.write([A,u,h,I,v].filter(Boolean).join(` -`)),this.state.submitted||this.write(this.margin[2]),this.restore()}submit(){this.value={};for(let e of this.choices)this.value[e.name]=e.scaleIndex;return this.base.submit.call(this)}};p0e.exports=A8});var m0e=_((E8t,d0e)=>{"use strict";var g0e=Kc(),Hft=(t="")=>typeof t=="string"?t.replace(/^['"]|['"]$/g,""):"",p8=class{constructor(e){this.name=e.key,this.field=e.field||{},this.value=Hft(e.initial||this.field.initial||""),this.message=e.message||this.name,this.cursor=0,this.input="",this.lines=[]}},jft=async(t={},e={},r=o=>o)=>{let o=new Set,a=t.fields||[],n=t.template,u=[],A=[],p=[],h=1;typeof n=="function"&&(n=await n());let C=-1,I=()=>n[++C],v=()=>n[C+1],b=E=>{E.line=h,u.push(E)};for(b({type:"bos",value:""});C<n.length-1;){let E=I();if(/^[^\S\n ]$/.test(E)){b({type:"text",value:E});continue}if(E===` -`){b({type:"newline",value:E}),h++;continue}if(E==="\\"){E+=I(),b({type:"text",value:E});continue}if((E==="$"||E==="#"||E==="{")&&v()==="{"){let N=I();E+=N;let U={type:"template",open:E,inner:"",close:"",value:E},z;for(;z=I();){if(z==="}"){v()==="}"&&(z+=I()),U.value+=z,U.close=z;break}z===":"?(U.initial="",U.key=U.inner):U.initial!==void 0&&(U.initial+=z),U.value+=z,U.inner+=z}U.template=U.open+(U.initial||U.inner)+U.close,U.key=U.key||U.inner,e.hasOwnProperty(U.key)&&(U.initial=e[U.key]),U=r(U),b(U),p.push(U.key),o.add(U.key);let te=A.find(le=>le.name===U.key);U.field=a.find(le=>le.name===U.key),te||(te=new p8(U),A.push(te)),te.lines.push(U.line-1);continue}let F=u[u.length-1];F.type==="text"&&F.line===h?F.value+=E:b({type:"text",value:E})}return b({type:"eos",value:""}),{input:n,tabstops:u,unique:o,keys:p,items:A}};d0e.exports=async t=>{let e=t.options,r=new Set(e.required===!0?[]:e.required||[]),o={...e.values,...e.initial},{tabstops:a,items:n,keys:u}=await jft(e,o),A=f8("result",t,e),p=f8("format",t,e),h=f8("validate",t,e,!0),C=t.isValue.bind(t);return async(I={},v=!1)=>{let b=0;I.required=r,I.items=n,I.keys=u,I.output="";let E=async(z,te,le,pe)=>{let ue=await h(z,te,le,pe);return ue===!1?"Invalid field "+le.name:ue};for(let z of a){let te=z.value,le=z.key;if(z.type!=="template"){te&&(I.output+=te);continue}if(z.type==="template"){let pe=n.find(Fe=>Fe.name===le);e.required===!0&&I.required.add(pe.name);let ue=[pe.input,I.values[pe.value],pe.value,te].find(C),ae=(pe.field||{}).message||z.inner;if(v){let Fe=await E(I.values[le],I,pe,b);if(Fe&&typeof Fe=="string"||Fe===!1){I.invalid.set(le,Fe);continue}I.invalid.delete(le);let g=await A(I.values[le],I,pe,b);I.output+=g0e.unstyle(g);continue}pe.placeholder=!1;let Ie=te;te=await p(te,I,pe,b),ue!==te?(I.values[le]=ue,te=t.styles.typing(ue),I.missing.delete(ae)):(I.values[le]=void 0,ue=`<${ae}>`,te=t.styles.primary(ue),pe.placeholder=!0,I.required.has(le)&&I.missing.add(ae)),I.missing.has(ae)&&I.validating&&(te=t.styles.warning(ue)),I.invalid.has(le)&&I.validating&&(te=t.styles.danger(ue)),b===I.index&&(Ie!==te?te=t.styles.underline(te):te=t.styles.heading(g0e.unstyle(te))),b++}te&&(I.output+=te)}let F=I.output.split(` -`).map(z=>" "+z),N=n.length,U=0;for(let z of n)I.invalid.has(z.name)&&z.lines.forEach(te=>{F[te][0]===" "&&(F[te]=I.styles.danger(I.symbols.bullet)+F[te].slice(1))}),t.isValue(I.values[z.name])&&U++;return I.completed=(U/N*100).toFixed(0),I.output=F.join(` -`),I.output}};function f8(t,e,r,o){return(a,n,u,A)=>typeof u.field[t]=="function"?u.field[t].call(e,a,n,u,A):[o,a].find(p=>e.isValue(p))}});var E0e=_((C8t,y0e)=>{"use strict";var qft=Kc(),Gft=m0e(),Yft=hC(),h8=class extends Yft{constructor(e){super(e),this.cursorHide(),this.reset(!0)}async initialize(){this.interpolate=await Gft(this),await super.initialize()}async reset(e){this.state.keys=[],this.state.invalid=new Map,this.state.missing=new Set,this.state.completed=0,this.state.values={},e!==!0&&(await this.initialize(),await this.render())}moveCursor(e){let r=this.getItem();this.cursor+=e,r.cursor+=e}dispatch(e,r){if(!r.code&&!r.ctrl&&e!=null&&this.getItem()){this.append(e,r);return}this.alert()}append(e,r){let o=this.getItem(),a=o.input.slice(0,this.cursor),n=o.input.slice(this.cursor);this.input=o.input=`${a}${e}${n}`,this.moveCursor(1),this.render()}delete(){let e=this.getItem();if(this.cursor<=0||!e.input)return this.alert();let r=e.input.slice(this.cursor),o=e.input.slice(0,this.cursor-1);this.input=e.input=`${o}${r}`,this.moveCursor(-1),this.render()}increment(e){return e>=this.state.keys.length-1?0:e+1}decrement(e){return e<=0?this.state.keys.length-1:e-1}first(){this.state.index=0,this.render()}last(){this.state.index=this.state.keys.length-1,this.render()}right(){if(this.cursor>=this.input.length)return this.alert();this.moveCursor(1),this.render()}left(){if(this.cursor<=0)return this.alert();this.moveCursor(-1),this.render()}prev(){this.state.index=this.decrement(this.state.index),this.getItem(),this.render()}next(){this.state.index=this.increment(this.state.index),this.getItem(),this.render()}up(){this.prev()}down(){this.next()}format(e){let r=this.state.completed<100?this.styles.warning:this.styles.success;return this.state.submitted===!0&&this.state.completed!==100&&(r=this.styles.danger),r(`${this.state.completed}% completed`)}async render(){let{index:e,keys:r=[],submitted:o,size:a}=this.state,n=[this.options.newline,` -`].find(z=>z!=null),u=await this.prefix(),A=await this.separator(),p=await this.message(),h=[u,p,A].filter(Boolean).join(" ");this.state.prompt=h;let C=await this.header(),I=await this.error()||"",v=await this.hint()||"",b=o?"":await this.interpolate(this.state),E=this.state.key=r[e]||"",F=await this.format(E),N=await this.footer();F&&(h+=" "+F),v&&!F&&this.state.completed===0&&(h+=" "+v),this.clear(a);let U=[C,h,b,N,I.trim()];this.write(U.filter(Boolean).join(n)),this.restore()}getItem(e){let{items:r,keys:o,index:a}=this.state,n=r.find(u=>u.name===o[a]);return n&&n.input!=null&&(this.input=n.input,this.cursor=n.cursor),n}async submit(){typeof this.interpolate!="function"&&await this.initialize(),await this.interpolate(this.state,!0);let{invalid:e,missing:r,output:o,values:a}=this.state;if(e.size){let A="";for(let[p,h]of e)A+=`Invalid ${p}: ${h} -`;return this.state.error=A,super.submit()}if(r.size)return this.state.error="Required: "+[...r.keys()].join(", "),super.submit();let u=qft.unstyle(o).split(` -`).map(A=>A.slice(1)).join(` -`);return this.value={values:a,result:u},super.submit()}};y0e.exports=h8});var w0e=_((w8t,C0e)=>{"use strict";var Wft="(Use <shift>+<up/down> to sort)",Kft=Sh(),g8=class extends Kft{constructor(e){super({...e,reorder:!1,sort:!0,multiple:!0}),this.state.hint=[this.options.hint,Wft].find(this.isValue.bind(this))}indicator(){return""}async renderChoice(e,r){let o=await super.renderChoice(e,r),a=this.symbols.identicalTo+" ",n=this.index===r&&this.sorting?this.styles.muted(a):" ";return this.options.drag===!1&&(n=""),this.options.numbered===!0?n+`${r+1} - `+o:n+o}get selected(){return this.choices}submit(){return this.value=this.choices.map(e=>e.value),super.submit()}};C0e.exports=g8});var B0e=_((I8t,I0e)=>{"use strict";var Vft=l2(),d8=class extends Vft{constructor(e={}){if(super(e),this.emptyError=e.emptyError||"No items were selected",this.term=process.env.TERM_PROGRAM,!this.options.header){let r=["","4 - Strongly Agree","3 - Agree","2 - Neutral","1 - Disagree","0 - Strongly Disagree",""];r=r.map(o=>this.styles.muted(o)),this.state.header=r.join(` - `)}}async toChoices(...e){if(this.createdScales)return!1;this.createdScales=!0;let r=await super.toChoices(...e);for(let o of r)o.scale=zft(5,this.options),o.scaleIdx=2;return r}dispatch(){this.alert()}space(){let e=this.focused,r=e.scale[e.scaleIdx],o=r.selected;return e.scale.forEach(a=>a.selected=!1),r.selected=!o,this.render()}indicator(){return""}pointer(){return""}separator(){return this.styles.muted(this.symbols.ellipsis)}right(){let e=this.focused;return e.scaleIdx>=e.scale.length-1?this.alert():(e.scaleIdx++,this.render())}left(){let e=this.focused;return e.scaleIdx<=0?this.alert():(e.scaleIdx--,this.render())}indent(){return" "}async renderChoice(e,r){await this.onChoice(e,r);let o=this.index===r,a=this.term==="Hyper",n=a?9:8,u=a?"":" ",A=this.symbols.line.repeat(n),p=" ".repeat(n+(a?0:1)),h=te=>(te?this.styles.success("\u25C9"):"\u25EF")+u,C=r+1+".",I=o?this.styles.heading:this.styles.noop,v=await this.resolve(e.message,this.state,e,r),b=this.indent(e),E=b+e.scale.map((te,le)=>h(le===e.scaleIdx)).join(A),F=te=>te===e.scaleIdx?I(te):te,N=b+e.scale.map((te,le)=>F(le)).join(p),U=()=>[C,v].filter(Boolean).join(" "),z=()=>[U(),E,N," "].filter(Boolean).join(` -`);return o&&(E=this.styles.cyan(E),N=this.styles.cyan(N)),z()}async renderChoices(){if(this.state.submitted)return"";let e=this.visible.map(async(o,a)=>await this.renderChoice(o,a)),r=await Promise.all(e);return r.length||r.push(this.styles.danger("No matching choices")),r.join(` -`)}format(){return this.state.submitted?this.choices.map(r=>this.styles.info(r.scaleIdx)).join(", "):""}async render(){let{submitted:e,size:r}=this.state,o=await this.prefix(),a=await this.separator(),n=await this.message(),u=[o,n,a].filter(Boolean).join(" ");this.state.prompt=u;let A=await this.header(),p=await this.format(),h=await this.error()||await this.hint(),C=await this.renderChoices(),I=await this.footer();(p||!h)&&(u+=" "+p),h&&!u.includes(h)&&(u+=" "+h),e&&!p&&!C&&this.multiple&&this.type!=="form"&&(u+=this.styles.danger(this.emptyError)),this.clear(r),this.write([u,A,C,I].filter(Boolean).join(` -`)),this.restore()}submit(){this.value={};for(let e of this.choices)this.value[e.name]=e.scaleIdx;return this.base.submit.call(this)}};function zft(t,e={}){if(Array.isArray(e.scale))return e.scale.map(o=>({...o}));let r=[];for(let o=1;o<t+1;o++)r.push({i:o,selected:!1});return r}I0e.exports=d8});var D0e=_((B8t,v0e)=>{v0e.exports=i8()});var S0e=_((v8t,P0e)=>{"use strict";var Jft=lk(),m8=class extends Jft{async initialize(){await super.initialize(),this.value=this.initial=!!this.options.initial,this.disabled=this.options.disabled||"no",this.enabled=this.options.enabled||"yes",await this.render()}reset(){this.value=this.initial,this.render()}delete(){this.alert()}toggle(){this.value=!this.value,this.render()}enable(){if(this.value===!0)return this.alert();this.value=!0,this.render()}disable(){if(this.value===!1)return this.alert();this.value=!1,this.render()}up(){this.toggle()}down(){this.toggle()}right(){this.toggle()}left(){this.toggle()}next(){this.toggle()}prev(){this.toggle()}dispatch(e="",r){switch(e.toLowerCase()){case" ":return this.toggle();case"1":case"y":case"t":return this.enable();case"0":case"n":case"f":return this.disable();default:return this.alert()}}format(){let e=o=>this.styles.primary.underline(o);return[this.value?this.disabled:e(this.disabled),this.value?e(this.enabled):this.enabled].join(this.styles.muted(" / "))}async render(){let{size:e}=this.state,r=await this.header(),o=await this.prefix(),a=await this.separator(),n=await this.message(),u=await this.format(),A=await this.error()||await this.hint(),p=await this.footer(),h=[o,n,a,u].join(" ");this.state.prompt=h,A&&!h.includes(A)&&(h+=" "+A),this.clear(e),this.write([r,h,p].filter(Boolean).join(` -`)),this.write(this.margin[2]),this.restore()}};P0e.exports=m8});var b0e=_((D8t,x0e)=>{"use strict";var Xft=Sh(),y8=class extends Xft{constructor(e){if(super(e),typeof this.options.correctChoice!="number"||this.options.correctChoice<0)throw new Error("Please specify the index of the correct answer from the list of choices")}async toChoices(e,r){let o=await super.toChoices(e,r);if(o.length<2)throw new Error("Please give at least two choices to the user");if(this.options.correctChoice>o.length)throw new Error("Please specify the index of the correct answer from the list of choices");return o}check(e){return e.index===this.options.correctChoice}async result(e){return{selectedAnswer:e,correctAnswer:this.options.choices[this.options.correctChoice].value,correct:await this.check(this.state)}}};x0e.exports=y8});var Q0e=_(E8=>{"use strict";var k0e=Lo(),As=(t,e)=>{k0e.defineExport(E8,t,e),k0e.defineExport(E8,t.toLowerCase(),e)};As("AutoComplete",()=>Ohe());As("BasicAuth",()=>Ghe());As("Confirm",()=>Khe());As("Editable",()=>zhe());As("Form",()=>ak());As("Input",()=>i8());As("Invisible",()=>r0e());As("List",()=>i0e());As("MultiSelect",()=>o0e());As("Numeral",()=>c0e());As("Password",()=>A0e());As("Scale",()=>h0e());As("Select",()=>Sh());As("Snippet",()=>E0e());As("Sort",()=>w0e());As("Survey",()=>B0e());As("Text",()=>D0e());As("Toggle",()=>S0e());As("Quiz",()=>b0e())});var R0e=_((S8t,F0e)=>{F0e.exports={ArrayPrompt:l2(),AuthPrompt:Z_(),BooleanPrompt:lk(),NumberPrompt:c8(),StringPrompt:Yd()}});var u2=_((x8t,L0e)=>{"use strict";var T0e=Be("assert"),w8=Be("events"),xh=Lo(),zc=class extends w8{constructor(e,r){super(),this.options=xh.merge({},e),this.answers={...r}}register(e,r){if(xh.isObject(e)){for(let a of Object.keys(e))this.register(a,e[a]);return this}T0e.equal(typeof r,"function","expected a function");let o=e.toLowerCase();return r.prototype instanceof this.Prompt?this.prompts[o]=r:this.prompts[o]=r(this.Prompt,this),this}async prompt(e=[]){for(let r of[].concat(e))try{typeof r=="function"&&(r=await r.call(this)),await this.ask(xh.merge({},this.options,r))}catch(o){return Promise.reject(o)}return this.answers}async ask(e){typeof e=="function"&&(e=await e.call(this));let r=xh.merge({},this.options,e),{type:o,name:a}=e,{set:n,get:u}=xh;if(typeof o=="function"&&(o=await o.call(this,e,this.answers)),!o)return this.answers[a];T0e(this.prompts[o],`Prompt "${o}" is not registered`);let A=new this.prompts[o](r),p=u(this.answers,a);A.state.answers=this.answers,A.enquirer=this,a&&A.on("submit",C=>{this.emit("answer",a,C,A),n(this.answers,a,C)});let h=A.emit.bind(A);return A.emit=(...C)=>(this.emit.call(this,...C),h(...C)),this.emit("prompt",A,this),r.autofill&&p!=null?(A.value=A.input=p,r.autofill==="show"&&await A.submit()):p=A.value=await A.run(),p}use(e){return e.call(this,this),this}set Prompt(e){this._Prompt=e}get Prompt(){return this._Prompt||this.constructor.Prompt}get prompts(){return this.constructor.prompts}static set Prompt(e){this._Prompt=e}static get Prompt(){return this._Prompt||hC()}static get prompts(){return Q0e()}static get types(){return R0e()}static get prompt(){let e=(r,...o)=>{let a=new this(...o),n=a.emit.bind(a);return a.emit=(...u)=>(e.emit(...u),n(...u)),a.prompt(r)};return xh.mixinEmitter(e,new w8),e}};xh.mixinEmitter(zc,new w8);var C8=zc.prompts;for(let t of Object.keys(C8)){let e=t.toLowerCase(),r=o=>new C8[t](o).run();zc.prompt[e]=r,zc[e]=r,zc[t]||Reflect.defineProperty(zc,t,{get:()=>C8[t]})}var c2=t=>{xh.defineExport(zc,t,()=>zc.types[t])};c2("ArrayPrompt");c2("AuthPrompt");c2("BooleanPrompt");c2("NumberPrompt");c2("StringPrompt");L0e.exports=zc});var h2=_((uHt,j0e)=>{var npt=Xb();function ipt(t,e,r){var o=t==null?void 0:npt(t,e);return o===void 0?r:o}j0e.exports=ipt});var Y0e=_((dHt,G0e)=>{function spt(t,e){for(var r=-1,o=t==null?0:t.length;++r<o&&e(t[r],r,t)!==!1;);return t}G0e.exports=spt});var K0e=_((mHt,W0e)=>{var opt=gd(),apt=zP();function lpt(t,e){return t&&opt(e,apt(e),t)}W0e.exports=lpt});var z0e=_((yHt,V0e)=>{var cpt=gd(),upt=qy();function Apt(t,e){return t&&cpt(e,upt(e),t)}V0e.exports=Apt});var X0e=_((EHt,J0e)=>{var fpt=gd(),ppt=qP();function hpt(t,e){return fpt(t,ppt(t),e)}J0e.exports=hpt});var S8=_((CHt,Z0e)=>{var gpt=jP(),dpt=eS(),mpt=qP(),ypt=KL(),Ept=Object.getOwnPropertySymbols,Cpt=Ept?function(t){for(var e=[];t;)gpt(e,mpt(t)),t=dpt(t);return e}:ypt;Z0e.exports=Cpt});var ege=_((wHt,$0e)=>{var wpt=gd(),Ipt=S8();function Bpt(t,e){return wpt(t,Ipt(t),e)}$0e.exports=Bpt});var x8=_((IHt,tge)=>{var vpt=WL(),Dpt=S8(),Ppt=qy();function Spt(t){return vpt(t,Ppt,Dpt)}tge.exports=Spt});var nge=_((BHt,rge)=>{var xpt=Object.prototype,bpt=xpt.hasOwnProperty;function kpt(t){var e=t.length,r=new t.constructor(e);return e&&typeof t[0]=="string"&&bpt.call(t,"index")&&(r.index=t.index,r.input=t.input),r}rge.exports=kpt});var sge=_((vHt,ige)=>{var Qpt=ZP();function Fpt(t,e){var r=e?Qpt(t.buffer):t.buffer;return new t.constructor(r,t.byteOffset,t.byteLength)}ige.exports=Fpt});var age=_((DHt,oge)=>{var Rpt=/\w*$/;function Tpt(t){var e=new t.constructor(t.source,Rpt.exec(t));return e.lastIndex=t.lastIndex,e}oge.exports=Tpt});var fge=_((PHt,Age)=>{var lge=fd(),cge=lge?lge.prototype:void 0,uge=cge?cge.valueOf:void 0;function Lpt(t){return uge?Object(uge.call(t)):{}}Age.exports=Lpt});var hge=_((SHt,pge)=>{var Npt=ZP(),Opt=sge(),Mpt=age(),Upt=fge(),_pt=aN(),Hpt="[object Boolean]",jpt="[object Date]",qpt="[object Map]",Gpt="[object Number]",Ypt="[object RegExp]",Wpt="[object Set]",Kpt="[object String]",Vpt="[object Symbol]",zpt="[object ArrayBuffer]",Jpt="[object DataView]",Xpt="[object Float32Array]",Zpt="[object Float64Array]",$pt="[object Int8Array]",eht="[object Int16Array]",tht="[object Int32Array]",rht="[object Uint8Array]",nht="[object Uint8ClampedArray]",iht="[object Uint16Array]",sht="[object Uint32Array]";function oht(t,e,r){var o=t.constructor;switch(e){case zpt:return Npt(t);case Hpt:case jpt:return new o(+t);case Jpt:return Opt(t,r);case Xpt:case Zpt:case $pt:case eht:case tht:case rht:case nht:case iht:case sht:return _pt(t,r);case qpt:return new o;case Gpt:case Kpt:return new o(t);case Ypt:return Mpt(t);case Wpt:return new o;case Vpt:return Upt(t)}}pge.exports=oht});var dge=_((xHt,gge)=>{var aht=jI(),lht=zu(),cht="[object Map]";function uht(t){return lht(t)&&aht(t)==cht}gge.exports=uht});var Cge=_((bHt,Ege)=>{var Aht=dge(),fht=YP(),mge=WP(),yge=mge&&mge.isMap,pht=yge?fht(yge):Aht;Ege.exports=pht});var Ige=_((kHt,wge)=>{var hht=jI(),ght=zu(),dht="[object Set]";function mht(t){return ght(t)&&hht(t)==dht}wge.exports=mht});var Pge=_((QHt,Dge)=>{var yht=Ige(),Eht=YP(),Bge=WP(),vge=Bge&&Bge.isSet,Cht=vge?Eht(vge):yht;Dge.exports=Cht});var b8=_((FHt,kge)=>{var wht=_P(),Iht=Y0e(),Bht=tS(),vht=K0e(),Dht=z0e(),Pht=oN(),Sht=$P(),xht=X0e(),bht=ege(),kht=XL(),Qht=x8(),Fht=jI(),Rht=nge(),Tht=hge(),Lht=lN(),Nht=Hl(),Oht=OI(),Mht=Cge(),Uht=il(),_ht=Pge(),Hht=zP(),jht=qy(),qht=1,Ght=2,Yht=4,Sge="[object Arguments]",Wht="[object Array]",Kht="[object Boolean]",Vht="[object Date]",zht="[object Error]",xge="[object Function]",Jht="[object GeneratorFunction]",Xht="[object Map]",Zht="[object Number]",bge="[object Object]",$ht="[object RegExp]",e0t="[object Set]",t0t="[object String]",r0t="[object Symbol]",n0t="[object WeakMap]",i0t="[object ArrayBuffer]",s0t="[object DataView]",o0t="[object Float32Array]",a0t="[object Float64Array]",l0t="[object Int8Array]",c0t="[object Int16Array]",u0t="[object Int32Array]",A0t="[object Uint8Array]",f0t="[object Uint8ClampedArray]",p0t="[object Uint16Array]",h0t="[object Uint32Array]",ri={};ri[Sge]=ri[Wht]=ri[i0t]=ri[s0t]=ri[Kht]=ri[Vht]=ri[o0t]=ri[a0t]=ri[l0t]=ri[c0t]=ri[u0t]=ri[Xht]=ri[Zht]=ri[bge]=ri[$ht]=ri[e0t]=ri[t0t]=ri[r0t]=ri[A0t]=ri[f0t]=ri[p0t]=ri[h0t]=!0;ri[zht]=ri[xge]=ri[n0t]=!1;function Ak(t,e,r,o,a,n){var u,A=e&qht,p=e&Ght,h=e&Yht;if(r&&(u=a?r(t,o,a,n):r(t)),u!==void 0)return u;if(!Uht(t))return t;var C=Nht(t);if(C){if(u=Rht(t),!A)return Sht(t,u)}else{var I=Fht(t),v=I==xge||I==Jht;if(Oht(t))return Pht(t,A);if(I==bge||I==Sge||v&&!a){if(u=p||v?{}:Lht(t),!A)return p?bht(t,Dht(u,t)):xht(t,vht(u,t))}else{if(!ri[I])return a?t:{};u=Tht(t,I,A)}}n||(n=new wht);var b=n.get(t);if(b)return b;n.set(t,u),_ht(t)?t.forEach(function(N){u.add(Ak(N,e,r,N,t,n))}):Mht(t)&&t.forEach(function(N,U){u.set(U,Ak(N,e,r,U,t,n))});var E=h?p?Qht:kht:p?jht:Hht,F=C?void 0:E(t);return Iht(F||t,function(N,U){F&&(U=N,N=t[U]),Bht(u,U,Ak(N,e,r,U,t,n))}),u}kge.exports=Ak});var k8=_((RHt,Qge)=>{var g0t=b8(),d0t=1,m0t=4;function y0t(t){return g0t(t,d0t|m0t)}Qge.exports=y0t});var Q8=_((THt,Fge)=>{var E0t=I_();function C0t(t,e,r){return t==null?t:E0t(t,e,r)}Fge.exports=C0t});var Oge=_((_Ht,Nge)=>{var w0t=Object.prototype,I0t=w0t.hasOwnProperty;function B0t(t,e){return t!=null&&I0t.call(t,e)}Nge.exports=B0t});var Uge=_((HHt,Mge)=>{var v0t=Oge(),D0t=B_();function P0t(t,e){return t!=null&&D0t(t,e,v0t)}Mge.exports=P0t});var Hge=_((jHt,_ge)=>{function S0t(t){var e=t==null?0:t.length;return e?t[e-1]:void 0}_ge.exports=S0t});var qge=_((qHt,jge)=>{var x0t=Xb(),b0t=pU();function k0t(t,e){return e.length<2?t:x0t(t,b0t(e,0,-1))}jge.exports=k0t});var R8=_((GHt,Gge)=>{var Q0t=jd(),F0t=Hge(),R0t=qge(),T0t=aC();function L0t(t,e){return e=Q0t(e,t),t=R0t(t,e),t==null||delete t[T0t(F0t(e))]}Gge.exports=L0t});var T8=_((YHt,Yge)=>{var N0t=R8();function O0t(t,e){return t==null?!0:N0t(t,e)}Yge.exports=O0t});var Jge=_((C6t,_0t)=>{_0t.exports={name:"@yarnpkg/cli",version:"4.0.0",license:"BSD-2-Clause",main:"./sources/index.ts",exports:{".":"./sources/index.ts","./polyfills":"./sources/polyfills.ts","./package.json":"./package.json"},dependencies:{"@yarnpkg/core":"workspace:^","@yarnpkg/fslib":"workspace:^","@yarnpkg/libzip":"workspace:^","@yarnpkg/parsers":"workspace:^","@yarnpkg/plugin-compat":"workspace:^","@yarnpkg/plugin-constraints":"workspace:^","@yarnpkg/plugin-dlx":"workspace:^","@yarnpkg/plugin-essentials":"workspace:^","@yarnpkg/plugin-exec":"workspace:^","@yarnpkg/plugin-file":"workspace:^","@yarnpkg/plugin-git":"workspace:^","@yarnpkg/plugin-github":"workspace:^","@yarnpkg/plugin-http":"workspace:^","@yarnpkg/plugin-init":"workspace:^","@yarnpkg/plugin-interactive-tools":"workspace:^","@yarnpkg/plugin-link":"workspace:^","@yarnpkg/plugin-nm":"workspace:^","@yarnpkg/plugin-npm":"workspace:^","@yarnpkg/plugin-npm-cli":"workspace:^","@yarnpkg/plugin-pack":"workspace:^","@yarnpkg/plugin-patch":"workspace:^","@yarnpkg/plugin-pnp":"workspace:^","@yarnpkg/plugin-pnpm":"workspace:^","@yarnpkg/plugin-stage":"workspace:^","@yarnpkg/plugin-typescript":"workspace:^","@yarnpkg/plugin-version":"workspace:^","@yarnpkg/plugin-workspace-tools":"workspace:^","@yarnpkg/shell":"workspace:^","ci-info":"^3.2.0",clipanion:"^4.0.0-rc.2",semver:"^7.1.2",tslib:"^2.4.0",typanion:"^3.14.0"},devDependencies:{"@types/semver":"^7.1.0","@yarnpkg/builder":"workspace:^","@yarnpkg/monorepo":"workspace:^","@yarnpkg/pnpify":"workspace:^"},peerDependencies:{"@yarnpkg/core":"workspace:^"},scripts:{postpack:"rm -rf lib",prepack:'run build:compile "$(pwd)"',"build:cli+hook":"run build:pnp:hook && builder build bundle","build:cli":"builder build bundle","run:cli":"builder run","update-local":"run build:cli --no-git-hash && rsync -a --delete bundles/ bin/"},publishConfig:{main:"./lib/index.js",bin:null,exports:{".":"./lib/index.js","./package.json":"./package.json"}},files:["/lib/**/*","!/lib/pluginConfiguration.*","!/lib/cli.*"],"@yarnpkg/builder":{bundles:{standard:["@yarnpkg/plugin-essentials","@yarnpkg/plugin-compat","@yarnpkg/plugin-constraints","@yarnpkg/plugin-dlx","@yarnpkg/plugin-exec","@yarnpkg/plugin-file","@yarnpkg/plugin-git","@yarnpkg/plugin-github","@yarnpkg/plugin-http","@yarnpkg/plugin-init","@yarnpkg/plugin-interactive-tools","@yarnpkg/plugin-link","@yarnpkg/plugin-nm","@yarnpkg/plugin-npm","@yarnpkg/plugin-npm-cli","@yarnpkg/plugin-pack","@yarnpkg/plugin-patch","@yarnpkg/plugin-pnp","@yarnpkg/plugin-pnpm","@yarnpkg/plugin-stage","@yarnpkg/plugin-typescript","@yarnpkg/plugin-version","@yarnpkg/plugin-workspace-tools"]}},repository:{type:"git",url:"ssh://git@github.com/yarnpkg/berry.git",directory:"packages/yarnpkg-cli"},engines:{node:">=18.12.0"}}});var q8=_((XGt,cde)=>{"use strict";cde.exports=function(e,r){r===!0&&(r=0);var o="";if(typeof e=="string")try{o=new URL(e).protocol}catch{}else e&&e.constructor===URL&&(o=e.protocol);var a=o.split(/\:|\+/).filter(Boolean);return typeof r=="number"?a[r]:a}});var Ade=_((ZGt,ude)=>{"use strict";var igt=q8();function sgt(t){var e={protocols:[],protocol:null,port:null,resource:"",host:"",user:"",password:"",pathname:"",hash:"",search:"",href:t,query:{},parse_failed:!1};try{var r=new URL(t);e.protocols=igt(r),e.protocol=e.protocols[0],e.port=r.port,e.resource=r.hostname,e.host=r.host,e.user=r.username||"",e.password=r.password||"",e.pathname=r.pathname,e.hash=r.hash.slice(1),e.search=r.search.slice(1),e.href=r.href,e.query=Object.fromEntries(r.searchParams)}catch{e.protocols=["file"],e.protocol=e.protocols[0],e.port="",e.resource="",e.user="",e.pathname="",e.hash="",e.search="",e.href=t,e.query={},e.parse_failed=!0}return e}ude.exports=sgt});var hde=_(($Gt,pde)=>{"use strict";var ogt=Ade();function agt(t){return t&&typeof t=="object"&&"default"in t?t:{default:t}}var lgt=agt(ogt),cgt="text/plain",ugt="us-ascii",fde=(t,e)=>e.some(r=>r instanceof RegExp?r.test(t):r===t),Agt=(t,{stripHash:e})=>{let r=/^data:(?<type>[^,]*?),(?<data>[^#]*?)(?:#(?<hash>.*))?$/.exec(t);if(!r)throw new Error(`Invalid URL: ${t}`);let{type:o,data:a,hash:n}=r.groups,u=o.split(";");n=e?"":n;let A=!1;u[u.length-1]==="base64"&&(u.pop(),A=!0);let p=(u.shift()||"").toLowerCase(),C=[...u.map(I=>{let[v,b=""]=I.split("=").map(E=>E.trim());return v==="charset"&&(b=b.toLowerCase(),b===ugt)?"":`${v}${b?`=${b}`:""}`}).filter(Boolean)];return A&&C.push("base64"),(C.length>0||p&&p!==cgt)&&C.unshift(p),`data:${C.join(";")},${A?a.trim():a}${n?`#${n}`:""}`};function fgt(t,e){if(e={defaultProtocol:"http:",normalizeProtocol:!0,forceHttp:!1,forceHttps:!1,stripAuthentication:!0,stripHash:!1,stripTextFragment:!0,stripWWW:!0,removeQueryParameters:[/^utm_\w+/i],removeTrailingSlash:!0,removeSingleSlash:!0,removeDirectoryIndex:!1,sortQueryParameters:!0,...e},t=t.trim(),/^data:/i.test(t))return Agt(t,e);if(/^view-source:/i.test(t))throw new Error("`view-source:` is not supported as it is a non-standard protocol");let r=t.startsWith("//");!r&&/^\.*\//.test(t)||(t=t.replace(/^(?!(?:\w+:)?\/\/)|^\/\//,e.defaultProtocol));let a=new URL(t);if(e.forceHttp&&e.forceHttps)throw new Error("The `forceHttp` and `forceHttps` options cannot be used together");if(e.forceHttp&&a.protocol==="https:"&&(a.protocol="http:"),e.forceHttps&&a.protocol==="http:"&&(a.protocol="https:"),e.stripAuthentication&&(a.username="",a.password=""),e.stripHash?a.hash="":e.stripTextFragment&&(a.hash=a.hash.replace(/#?:~:text.*?$/i,"")),a.pathname){let u=/\b[a-z][a-z\d+\-.]{1,50}:\/\//g,A=0,p="";for(;;){let C=u.exec(a.pathname);if(!C)break;let I=C[0],v=C.index,b=a.pathname.slice(A,v);p+=b.replace(/\/{2,}/g,"/"),p+=I,A=v+I.length}let h=a.pathname.slice(A,a.pathname.length);p+=h.replace(/\/{2,}/g,"/"),a.pathname=p}if(a.pathname)try{a.pathname=decodeURI(a.pathname)}catch{}if(e.removeDirectoryIndex===!0&&(e.removeDirectoryIndex=[/^index\.[a-z]+$/]),Array.isArray(e.removeDirectoryIndex)&&e.removeDirectoryIndex.length>0){let u=a.pathname.split("/"),A=u[u.length-1];fde(A,e.removeDirectoryIndex)&&(u=u.slice(0,-1),a.pathname=u.slice(1).join("/")+"/")}if(a.hostname&&(a.hostname=a.hostname.replace(/\.$/,""),e.stripWWW&&/^www\.(?!www\.)[a-z\-\d]{1,63}\.[a-z.\-\d]{2,63}$/.test(a.hostname)&&(a.hostname=a.hostname.replace(/^www\./,""))),Array.isArray(e.removeQueryParameters))for(let u of[...a.searchParams.keys()])fde(u,e.removeQueryParameters)&&a.searchParams.delete(u);if(e.removeQueryParameters===!0&&(a.search=""),e.sortQueryParameters){a.searchParams.sort();try{a.search=decodeURIComponent(a.search)}catch{}}e.removeTrailingSlash&&(a.pathname=a.pathname.replace(/\/$/,""));let n=t;return t=a.toString(),!e.removeSingleSlash&&a.pathname==="/"&&!n.endsWith("/")&&a.hash===""&&(t=t.replace(/\/$/,"")),(e.removeTrailingSlash||a.pathname==="/")&&a.hash===""&&e.removeSingleSlash&&(t=t.replace(/\/$/,"")),r&&!e.normalizeProtocol&&(t=t.replace(/^http:\/\//,"//")),e.stripProtocol&&(t=t.replace(/^(?:https?:)?\/\//,"")),t}var G8=(t,e=!1)=>{let r=/^(?:([a-z_][a-z0-9_-]{0,31})@|https?:\/\/)([\w\.\-@]+)[\/:]([\~,\.\w,\-,\_,\/]+?(?:\.git|\/)?)$/,o=n=>{let u=new Error(n);throw u.subject_url=t,u};(typeof t!="string"||!t.trim())&&o("Invalid url."),t.length>G8.MAX_INPUT_LENGTH&&o("Input exceeds maximum length. If needed, change the value of parseUrl.MAX_INPUT_LENGTH."),e&&(typeof e!="object"&&(e={stripHash:!1}),t=fgt(t,e));let a=lgt.default(t);if(a.parse_failed){let n=a.href.match(r);n?(a.protocols=["ssh"],a.protocol="ssh",a.resource=n[2],a.host=n[2],a.user=n[1],a.pathname=`/${n[3]}`,a.parse_failed=!1):o("URL parsing failed.")}return a};G8.MAX_INPUT_LENGTH=2048;pde.exports=G8});var mde=_((e5t,dde)=>{"use strict";var pgt=q8();function gde(t){if(Array.isArray(t))return t.indexOf("ssh")!==-1||t.indexOf("rsync")!==-1;if(typeof t!="string")return!1;var e=pgt(t);if(t=t.substring(t.indexOf("://")+3),gde(e))return!0;var r=new RegExp(".([a-zA-Z\\d]+):(\\d+)/");return!t.match(r)&&t.indexOf("@")<t.indexOf(":")}dde.exports=gde});var Cde=_((t5t,Ede)=>{"use strict";var hgt=hde(),yde=mde();function ggt(t){var e=hgt(t);return e.token="",e.password==="x-oauth-basic"?e.token=e.user:e.user==="x-token-auth"&&(e.token=e.password),yde(e.protocols)||e.protocols.length===0&&yde(t)?e.protocol="ssh":e.protocols.length?e.protocol=e.protocols[0]:(e.protocol="file",e.protocols=["file"]),e.href=e.href.replace(/\/$/,""),e}Ede.exports=ggt});var Ide=_((r5t,wde)=>{"use strict";var dgt=Cde();function Y8(t){if(typeof t!="string")throw new Error("The url must be a string.");var e=/^([a-z\d-]{1,39})\/([-\.\w]{1,100})$/i;e.test(t)&&(t="https://github.com/"+t);var r=dgt(t),o=r.resource.split("."),a=null;switch(r.toString=function(N){return Y8.stringify(this,N)},r.source=o.length>2?o.slice(1-o.length).join("."):r.source=r.resource,r.git_suffix=/\.git$/.test(r.pathname),r.name=decodeURIComponent((r.pathname||r.href).replace(/(^\/)|(\/$)/g,"").replace(/\.git$/,"")),r.owner=decodeURIComponent(r.user),r.source){case"git.cloudforge.com":r.owner=r.user,r.organization=o[0],r.source="cloudforge.com";break;case"visualstudio.com":if(r.resource==="vs-ssh.visualstudio.com"){a=r.name.split("/"),a.length===4&&(r.organization=a[1],r.owner=a[2],r.name=a[3],r.full_name=a[2]+"/"+a[3]);break}else{a=r.name.split("/"),a.length===2?(r.owner=a[1],r.name=a[1],r.full_name="_git/"+r.name):a.length===3?(r.name=a[2],a[0]==="DefaultCollection"?(r.owner=a[2],r.organization=a[0],r.full_name=r.organization+"/_git/"+r.name):(r.owner=a[0],r.full_name=r.owner+"/_git/"+r.name)):a.length===4&&(r.organization=a[0],r.owner=a[1],r.name=a[3],r.full_name=r.organization+"/"+r.owner+"/_git/"+r.name);break}case"dev.azure.com":case"azure.com":if(r.resource==="ssh.dev.azure.com"){a=r.name.split("/"),a.length===4&&(r.organization=a[1],r.owner=a[2],r.name=a[3]);break}else{a=r.name.split("/"),a.length===5?(r.organization=a[0],r.owner=a[1],r.name=a[4],r.full_name="_git/"+r.name):a.length===3?(r.name=a[2],a[0]==="DefaultCollection"?(r.owner=a[2],r.organization=a[0],r.full_name=r.organization+"/_git/"+r.name):(r.owner=a[0],r.full_name=r.owner+"/_git/"+r.name)):a.length===4&&(r.organization=a[0],r.owner=a[1],r.name=a[3],r.full_name=r.organization+"/"+r.owner+"/_git/"+r.name),r.query&&r.query.path&&(r.filepath=r.query.path.replace(/^\/+/g,"")),r.query&&r.query.version&&(r.ref=r.query.version.replace(/^GB/,""));break}default:a=r.name.split("/");var n=a.length-1;if(a.length>=2){var u=a.indexOf("-",2),A=a.indexOf("blob",2),p=a.indexOf("tree",2),h=a.indexOf("commit",2),C=a.indexOf("src",2),I=a.indexOf("raw",2),v=a.indexOf("edit",2);n=u>0?u-1:A>0?A-1:p>0?p-1:h>0?h-1:C>0?C-1:I>0?I-1:v>0?v-1:n,r.owner=a.slice(0,n).join("/"),r.name=a[n],h&&(r.commit=a[n+2])}r.ref="",r.filepathtype="",r.filepath="";var b=a.length>n&&a[n+1]==="-"?n+1:n;a.length>b+2&&["raw","src","blob","tree","edit"].indexOf(a[b+1])>=0&&(r.filepathtype=a[b+1],r.ref=a[b+2],a.length>b+3&&(r.filepath=a.slice(b+3).join("/"))),r.organization=r.owner;break}r.full_name||(r.full_name=r.owner,r.name&&(r.full_name&&(r.full_name+="/"),r.full_name+=r.name)),r.owner.startsWith("scm/")&&(r.source="bitbucket-server",r.owner=r.owner.replace("scm/",""),r.organization=r.owner,r.full_name=r.owner+"/"+r.name);var E=/(projects|users)\/(.*?)\/repos\/(.*?)((\/.*$)|$)/,F=E.exec(r.pathname);return F!=null&&(r.source="bitbucket-server",F[1]==="users"?r.owner="~"+F[2]:r.owner=F[2],r.organization=r.owner,r.name=F[3],a=F[4].split("/"),a.length>1&&(["raw","browse"].indexOf(a[1])>=0?(r.filepathtype=a[1],a.length>2&&(r.filepath=a.slice(2).join("/"))):a[1]==="commits"&&a.length>2&&(r.commit=a[2])),r.full_name=r.owner+"/"+r.name,r.query.at?r.ref=r.query.at:r.ref=""),r}Y8.stringify=function(t,e){e=e||(t.protocols&&t.protocols.length?t.protocols.join("+"):t.protocol);var r=t.port?":"+t.port:"",o=t.user||"git",a=t.git_suffix?".git":"";switch(e){case"ssh":return r?"ssh://"+o+"@"+t.resource+r+"/"+t.full_name+a:o+"@"+t.resource+":"+t.full_name+a;case"git+ssh":case"ssh+git":case"ftp":case"ftps":return e+"://"+o+"@"+t.resource+r+"/"+t.full_name+a;case"http":case"https":var n=t.token?mgt(t):t.user&&(t.protocols.includes("http")||t.protocols.includes("https"))?t.user+"@":"";return e+"://"+n+t.resource+r+"/"+ygt(t)+a;default:return t.href}};function mgt(t){switch(t.source){case"bitbucket.org":return"x-token-auth:"+t.token+"@";default:return t.token+"@"}}function ygt(t){switch(t.source){case"bitbucket-server":return"scm/"+t.full_name;default:return""+t.full_name}}wde.exports=Y8});var Mde=_((L9t,Ode)=>{var bgt=Hx(),kgt=$P(),Qgt=Hl(),Fgt=fE(),Rgt=w_(),Tgt=aC(),Lgt=R1();function Ngt(t){return Qgt(t)?bgt(t,Tgt):Fgt(t)?[t]:kgt(Rgt(Lgt(t)))}Ode.exports=Ngt});function _gt(t,e){return e===1&&Ugt.has(t[0])}function w2(t){let e=Array.isArray(t)?t:(0,Hde.default)(t);return e.map((o,a)=>Ogt.test(o)?`[${o}]`:Mgt.test(o)&&!_gt(e,a)?`.${o}`:`[${JSON.stringify(o)}]`).join("").replace(/^\./,"")}function Hgt(t,e){let r=[];if(e.methodName!==null&&r.push(de.pretty(t,e.methodName,de.Type.CODE)),e.file!==null){let o=[];o.push(de.pretty(t,e.file,de.Type.PATH)),e.line!==null&&(o.push(de.pretty(t,e.line,de.Type.NUMBER)),e.column!==null&&o.push(de.pretty(t,e.column,de.Type.NUMBER))),r.push(`(${o.join(de.pretty(t,":","grey"))})`)}return r.join(" ")}function gk(t,{manifestUpdates:e,reportedErrors:r},{fix:o}={}){let a=new Map,n=new Map,u=[...r.keys()].map(A=>[A,new Map]);for(let[A,p]of[...u,...e]){let h=r.get(A)?.map(b=>({text:b,fixable:!1}))??[],C=!1,I=t.getWorkspaceByCwd(A),v=I.manifest.exportTo({});for(let[b,E]of p){if(E.size>1){let F=[...E].map(([N,U])=>{let z=de.pretty(t.configuration,N,de.Type.INSPECT),te=U.size>0?Hgt(t.configuration,U.values().next().value):null;return te!==null?` -${z} at ${te}`:` -${z}`}).join("");h.push({text:`Conflict detected in constraint targeting ${de.pretty(t.configuration,b,de.Type.CODE)}; conflicting values are:${F}`,fixable:!1})}else{let[[F]]=E,N=(0,Ude.default)(v,b);if(N===F)continue;if(!o){let U=typeof N>"u"?`Missing field ${de.pretty(t.configuration,b,de.Type.CODE)}; expected ${de.pretty(t.configuration,F,de.Type.INSPECT)}`:typeof F>"u"?`Extraneous field ${de.pretty(t.configuration,b,de.Type.CODE)} currently set to ${de.pretty(t.configuration,N,de.Type.INSPECT)}`:`Invalid field ${de.pretty(t.configuration,b,de.Type.CODE)}; expected ${de.pretty(t.configuration,F,de.Type.INSPECT)}, found ${de.pretty(t.configuration,N,de.Type.INSPECT)}`;h.push({text:U,fixable:!0});continue}typeof F>"u"?(0,jde.default)(v,b):(0,_de.default)(v,b,F),C=!0}C&&a.set(I,v)}h.length>0&&n.set(I,h)}return{changedWorkspaces:a,remainingErrors:n}}function qde(t,{configuration:e}){let r={children:[]};for(let[o,a]of t){let n=[];for(let A of a){let p=A.text.split(/\n/);A.fixable&&(p[0]=`${de.pretty(e,"\u2699","gray")} ${p[0]}`),n.push({value:de.tuple(de.Type.NO_HINT,p[0]),children:p.slice(1).map(h=>({value:de.tuple(de.Type.NO_HINT,h)}))})}let u={value:de.tuple(de.Type.LOCATOR,o.anchoredLocator),children:_e.sortMap(n,A=>A.value[1])};r.children.push(u)}return r.children=_e.sortMap(r.children,o=>o.value[1]),r}var Ude,_de,Hde,jde,CC,Ogt,Mgt,Ugt,I2=yt(()=>{Ye();Ude=$e(h2()),_de=$e(Q8()),Hde=$e(Mde()),jde=$e(T8()),CC=class{constructor(e){this.indexedFields=e;this.items=[];this.indexes={};this.clear()}clear(){this.items=[];for(let e of this.indexedFields)this.indexes[e]=new Map}insert(e){this.items.push(e);for(let r of this.indexedFields){let o=Object.hasOwn(e,r)?e[r]:void 0;if(typeof o>"u")continue;_e.getArrayWithDefault(this.indexes[r],o).push(e)}return e}find(e){if(typeof e>"u")return this.items;let r=Object.entries(e);if(r.length===0)return this.items;let o=[],a;for(let[u,A]of r){let p=u,h=Object.hasOwn(this.indexes,p)?this.indexes[p]:void 0;if(typeof h>"u"){o.push([p,A]);continue}let C=new Set(h.get(A)??[]);if(C.size===0)return[];if(typeof a>"u")a=C;else for(let I of a)C.has(I)||a.delete(I);if(a.size===0)break}let n=[...a??[]];return o.length>0&&(n=n.filter(u=>{for(let[A,p]of o)if(!(typeof p<"u"?Object.hasOwn(u,A)&&u[A]===p:Object.hasOwn(u,A)===!1))return!1;return!0})),n}},Ogt=/^[0-9]+$/,Mgt=/^[a-zA-Z0-9_]+$/,Ugt=new Set(["scripts",...Ot.allDependencies])});var Gde=_((K9t,sH)=>{var jgt;(function(t){var e=function(){return{"append/2":[new t.type.Rule(new t.type.Term("append",[new t.type.Var("X"),new t.type.Var("L")]),new t.type.Term("foldl",[new t.type.Term("append",[]),new t.type.Var("X"),new t.type.Term("[]",[]),new t.type.Var("L")]))],"append/3":[new t.type.Rule(new t.type.Term("append",[new t.type.Term("[]",[]),new t.type.Var("X"),new t.type.Var("X")]),null),new t.type.Rule(new t.type.Term("append",[new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("T")]),new t.type.Var("X"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("S")])]),new t.type.Term("append",[new t.type.Var("T"),new t.type.Var("X"),new t.type.Var("S")]))],"member/2":[new t.type.Rule(new t.type.Term("member",[new t.type.Var("X"),new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("_")])]),null),new t.type.Rule(new t.type.Term("member",[new t.type.Var("X"),new t.type.Term(".",[new t.type.Var("_"),new t.type.Var("Xs")])]),new t.type.Term("member",[new t.type.Var("X"),new t.type.Var("Xs")]))],"permutation/2":[new t.type.Rule(new t.type.Term("permutation",[new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("permutation",[new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("T")]),new t.type.Var("S")]),new t.type.Term(",",[new t.type.Term("permutation",[new t.type.Var("T"),new t.type.Var("P")]),new t.type.Term(",",[new t.type.Term("append",[new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("P")]),new t.type.Term("append",[new t.type.Var("X"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("Y")]),new t.type.Var("S")])])]))],"maplist/2":[new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("_"),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Xs")])]),new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("P"),new t.type.Var("X")]),new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Var("Xs")])]))],"maplist/3":[new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("A"),new t.type.Var("As")]),new t.type.Term(".",[new t.type.Var("B"),new t.type.Var("Bs")])]),new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("P"),new t.type.Var("A"),new t.type.Var("B")]),new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Var("As"),new t.type.Var("Bs")])]))],"maplist/4":[new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("A"),new t.type.Var("As")]),new t.type.Term(".",[new t.type.Var("B"),new t.type.Var("Bs")]),new t.type.Term(".",[new t.type.Var("C"),new t.type.Var("Cs")])]),new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("P"),new t.type.Var("A"),new t.type.Var("B"),new t.type.Var("C")]),new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Var("As"),new t.type.Var("Bs"),new t.type.Var("Cs")])]))],"maplist/5":[new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("A"),new t.type.Var("As")]),new t.type.Term(".",[new t.type.Var("B"),new t.type.Var("Bs")]),new t.type.Term(".",[new t.type.Var("C"),new t.type.Var("Cs")]),new t.type.Term(".",[new t.type.Var("D"),new t.type.Var("Ds")])]),new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("P"),new t.type.Var("A"),new t.type.Var("B"),new t.type.Var("C"),new t.type.Var("D")]),new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Var("As"),new t.type.Var("Bs"),new t.type.Var("Cs"),new t.type.Var("Ds")])]))],"maplist/6":[new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("A"),new t.type.Var("As")]),new t.type.Term(".",[new t.type.Var("B"),new t.type.Var("Bs")]),new t.type.Term(".",[new t.type.Var("C"),new t.type.Var("Cs")]),new t.type.Term(".",[new t.type.Var("D"),new t.type.Var("Ds")]),new t.type.Term(".",[new t.type.Var("E"),new t.type.Var("Es")])]),new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("P"),new t.type.Var("A"),new t.type.Var("B"),new t.type.Var("C"),new t.type.Var("D"),new t.type.Var("E")]),new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Var("As"),new t.type.Var("Bs"),new t.type.Var("Cs"),new t.type.Var("Ds"),new t.type.Var("Es")])]))],"maplist/7":[new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("A"),new t.type.Var("As")]),new t.type.Term(".",[new t.type.Var("B"),new t.type.Var("Bs")]),new t.type.Term(".",[new t.type.Var("C"),new t.type.Var("Cs")]),new t.type.Term(".",[new t.type.Var("D"),new t.type.Var("Ds")]),new t.type.Term(".",[new t.type.Var("E"),new t.type.Var("Es")]),new t.type.Term(".",[new t.type.Var("F"),new t.type.Var("Fs")])]),new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("P"),new t.type.Var("A"),new t.type.Var("B"),new t.type.Var("C"),new t.type.Var("D"),new t.type.Var("E"),new t.type.Var("F")]),new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Var("As"),new t.type.Var("Bs"),new t.type.Var("Cs"),new t.type.Var("Ds"),new t.type.Var("Es"),new t.type.Var("Fs")])]))],"maplist/8":[new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("A"),new t.type.Var("As")]),new t.type.Term(".",[new t.type.Var("B"),new t.type.Var("Bs")]),new t.type.Term(".",[new t.type.Var("C"),new t.type.Var("Cs")]),new t.type.Term(".",[new t.type.Var("D"),new t.type.Var("Ds")]),new t.type.Term(".",[new t.type.Var("E"),new t.type.Var("Es")]),new t.type.Term(".",[new t.type.Var("F"),new t.type.Var("Fs")]),new t.type.Term(".",[new t.type.Var("G"),new t.type.Var("Gs")])]),new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("P"),new t.type.Var("A"),new t.type.Var("B"),new t.type.Var("C"),new t.type.Var("D"),new t.type.Var("E"),new t.type.Var("F"),new t.type.Var("G")]),new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Var("As"),new t.type.Var("Bs"),new t.type.Var("Cs"),new t.type.Var("Ds"),new t.type.Var("Es"),new t.type.Var("Fs"),new t.type.Var("Gs")])]))],"include/3":[new t.type.Rule(new t.type.Term("include",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("include",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("T")]),new t.type.Var("L")]),new t.type.Term(",",[new t.type.Term("=..",[new t.type.Var("P"),new t.type.Var("A")]),new t.type.Term(",",[new t.type.Term("append",[new t.type.Var("A"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Term("[]",[])]),new t.type.Var("B")]),new t.type.Term(",",[new t.type.Term("=..",[new t.type.Var("F"),new t.type.Var("B")]),new t.type.Term(",",[new t.type.Term(";",[new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("F")]),new t.type.Term(",",[new t.type.Term("=",[new t.type.Var("L"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("S")])]),new t.type.Term("!",[])])]),new t.type.Term("=",[new t.type.Var("L"),new t.type.Var("S")])]),new t.type.Term("include",[new t.type.Var("P"),new t.type.Var("T"),new t.type.Var("S")])])])])]))],"exclude/3":[new t.type.Rule(new t.type.Term("exclude",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("exclude",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("T")]),new t.type.Var("S")]),new t.type.Term(",",[new t.type.Term("exclude",[new t.type.Var("P"),new t.type.Var("T"),new t.type.Var("E")]),new t.type.Term(",",[new t.type.Term("=..",[new t.type.Var("P"),new t.type.Var("L")]),new t.type.Term(",",[new t.type.Term("append",[new t.type.Var("L"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Term("[]",[])]),new t.type.Var("Q")]),new t.type.Term(",",[new t.type.Term("=..",[new t.type.Var("R"),new t.type.Var("Q")]),new t.type.Term(";",[new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("R")]),new t.type.Term(",",[new t.type.Term("!",[]),new t.type.Term("=",[new t.type.Var("S"),new t.type.Var("E")])])]),new t.type.Term("=",[new t.type.Var("S"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("E")])])])])])])]))],"foldl/4":[new t.type.Rule(new t.type.Term("foldl",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Var("I"),new t.type.Var("I")]),null),new t.type.Rule(new t.type.Term("foldl",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("T")]),new t.type.Var("I"),new t.type.Var("R")]),new t.type.Term(",",[new t.type.Term("=..",[new t.type.Var("P"),new t.type.Var("L")]),new t.type.Term(",",[new t.type.Term("append",[new t.type.Var("L"),new t.type.Term(".",[new t.type.Var("I"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Term(".",[new t.type.Var("X"),new t.type.Term("[]",[])])])]),new t.type.Var("L2")]),new t.type.Term(",",[new t.type.Term("=..",[new t.type.Var("P2"),new t.type.Var("L2")]),new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("P2")]),new t.type.Term("foldl",[new t.type.Var("P"),new t.type.Var("T"),new t.type.Var("X"),new t.type.Var("R")])])])])]))],"select/3":[new t.type.Rule(new t.type.Term("select",[new t.type.Var("E"),new t.type.Term(".",[new t.type.Var("E"),new t.type.Var("Xs")]),new t.type.Var("Xs")]),null),new t.type.Rule(new t.type.Term("select",[new t.type.Var("E"),new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Xs")]),new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Ys")])]),new t.type.Term("select",[new t.type.Var("E"),new t.type.Var("Xs"),new t.type.Var("Ys")]))],"sum_list/2":[new t.type.Rule(new t.type.Term("sum_list",[new t.type.Term("[]",[]),new t.type.Num(0,!1)]),null),new t.type.Rule(new t.type.Term("sum_list",[new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Xs")]),new t.type.Var("S")]),new t.type.Term(",",[new t.type.Term("sum_list",[new t.type.Var("Xs"),new t.type.Var("Y")]),new t.type.Term("is",[new t.type.Var("S"),new t.type.Term("+",[new t.type.Var("X"),new t.type.Var("Y")])])]))],"max_list/2":[new t.type.Rule(new t.type.Term("max_list",[new t.type.Term(".",[new t.type.Var("X"),new t.type.Term("[]",[])]),new t.type.Var("X")]),null),new t.type.Rule(new t.type.Term("max_list",[new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Xs")]),new t.type.Var("S")]),new t.type.Term(",",[new t.type.Term("max_list",[new t.type.Var("Xs"),new t.type.Var("Y")]),new t.type.Term(";",[new t.type.Term(",",[new t.type.Term(">=",[new t.type.Var("X"),new t.type.Var("Y")]),new t.type.Term(",",[new t.type.Term("=",[new t.type.Var("S"),new t.type.Var("X")]),new t.type.Term("!",[])])]),new t.type.Term("=",[new t.type.Var("S"),new t.type.Var("Y")])])]))],"min_list/2":[new t.type.Rule(new t.type.Term("min_list",[new t.type.Term(".",[new t.type.Var("X"),new t.type.Term("[]",[])]),new t.type.Var("X")]),null),new t.type.Rule(new t.type.Term("min_list",[new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Xs")]),new t.type.Var("S")]),new t.type.Term(",",[new t.type.Term("min_list",[new t.type.Var("Xs"),new t.type.Var("Y")]),new t.type.Term(";",[new t.type.Term(",",[new t.type.Term("=<",[new t.type.Var("X"),new t.type.Var("Y")]),new t.type.Term(",",[new t.type.Term("=",[new t.type.Var("S"),new t.type.Var("X")]),new t.type.Term("!",[])])]),new t.type.Term("=",[new t.type.Var("S"),new t.type.Var("Y")])])]))],"prod_list/2":[new t.type.Rule(new t.type.Term("prod_list",[new t.type.Term("[]",[]),new t.type.Num(1,!1)]),null),new t.type.Rule(new t.type.Term("prod_list",[new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Xs")]),new t.type.Var("S")]),new t.type.Term(",",[new t.type.Term("prod_list",[new t.type.Var("Xs"),new t.type.Var("Y")]),new t.type.Term("is",[new t.type.Var("S"),new t.type.Term("*",[new t.type.Var("X"),new t.type.Var("Y")])])]))],"last/2":[new t.type.Rule(new t.type.Term("last",[new t.type.Term(".",[new t.type.Var("X"),new t.type.Term("[]",[])]),new t.type.Var("X")]),null),new t.type.Rule(new t.type.Term("last",[new t.type.Term(".",[new t.type.Var("_"),new t.type.Var("Xs")]),new t.type.Var("X")]),new t.type.Term("last",[new t.type.Var("Xs"),new t.type.Var("X")]))],"prefix/2":[new t.type.Rule(new t.type.Term("prefix",[new t.type.Var("Part"),new t.type.Var("Whole")]),new t.type.Term("append",[new t.type.Var("Part"),new t.type.Var("_"),new t.type.Var("Whole")]))],"nth0/3":[new t.type.Rule(new t.type.Term("nth0",[new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z")]),new t.type.Term(";",[new t.type.Term("->",[new t.type.Term("var",[new t.type.Var("X")]),new t.type.Term("nth",[new t.type.Num(0,!1),new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("_")])]),new t.type.Term(",",[new t.type.Term(">=",[new t.type.Var("X"),new t.type.Num(0,!1)]),new t.type.Term(",",[new t.type.Term("nth",[new t.type.Num(0,!1),new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("_")]),new t.type.Term("!",[])])])]))],"nth1/3":[new t.type.Rule(new t.type.Term("nth1",[new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z")]),new t.type.Term(";",[new t.type.Term("->",[new t.type.Term("var",[new t.type.Var("X")]),new t.type.Term("nth",[new t.type.Num(1,!1),new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("_")])]),new t.type.Term(",",[new t.type.Term(">",[new t.type.Var("X"),new t.type.Num(0,!1)]),new t.type.Term(",",[new t.type.Term("nth",[new t.type.Num(1,!1),new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("_")]),new t.type.Term("!",[])])])]))],"nth0/4":[new t.type.Rule(new t.type.Term("nth0",[new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("W")]),new t.type.Term(";",[new t.type.Term("->",[new t.type.Term("var",[new t.type.Var("X")]),new t.type.Term("nth",[new t.type.Num(0,!1),new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("W")])]),new t.type.Term(",",[new t.type.Term(">=",[new t.type.Var("X"),new t.type.Num(0,!1)]),new t.type.Term(",",[new t.type.Term("nth",[new t.type.Num(0,!1),new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("W")]),new t.type.Term("!",[])])])]))],"nth1/4":[new t.type.Rule(new t.type.Term("nth1",[new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("W")]),new t.type.Term(";",[new t.type.Term("->",[new t.type.Term("var",[new t.type.Var("X")]),new t.type.Term("nth",[new t.type.Num(1,!1),new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("W")])]),new t.type.Term(",",[new t.type.Term(">",[new t.type.Var("X"),new t.type.Num(0,!1)]),new t.type.Term(",",[new t.type.Term("nth",[new t.type.Num(1,!1),new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("W")]),new t.type.Term("!",[])])])]))],"nth/5":[new t.type.Rule(new t.type.Term("nth",[new t.type.Var("N"),new t.type.Var("N"),new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Xs")]),new t.type.Var("X"),new t.type.Var("Xs")]),null),new t.type.Rule(new t.type.Term("nth",[new t.type.Var("N"),new t.type.Var("O"),new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Xs")]),new t.type.Var("Y"),new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Ys")])]),new t.type.Term(",",[new t.type.Term("is",[new t.type.Var("M"),new t.type.Term("+",[new t.type.Var("N"),new t.type.Num(1,!1)])]),new t.type.Term("nth",[new t.type.Var("M"),new t.type.Var("O"),new t.type.Var("Xs"),new t.type.Var("Y"),new t.type.Var("Ys")])]))],"length/2":function(o,a,n){var u=n.args[0],A=n.args[1];if(!t.type.is_variable(A)&&!t.type.is_integer(A))o.throw_error(t.error.type("integer",A,n.indicator));else if(t.type.is_integer(A)&&A.value<0)o.throw_error(t.error.domain("not_less_than_zero",A,n.indicator));else{var p=new t.type.Term("length",[u,new t.type.Num(0,!1),A]);t.type.is_integer(A)&&(p=new t.type.Term(",",[p,new t.type.Term("!",[])])),o.prepend([new t.type.State(a.goal.replace(p),a.substitution,a)])}},"length/3":[new t.type.Rule(new t.type.Term("length",[new t.type.Term("[]",[]),new t.type.Var("N"),new t.type.Var("N")]),null),new t.type.Rule(new t.type.Term("length",[new t.type.Term(".",[new t.type.Var("_"),new t.type.Var("X")]),new t.type.Var("A"),new t.type.Var("N")]),new t.type.Term(",",[new t.type.Term("succ",[new t.type.Var("A"),new t.type.Var("B")]),new t.type.Term("length",[new t.type.Var("X"),new t.type.Var("B"),new t.type.Var("N")])]))],"replicate/3":function(o,a,n){var u=n.args[0],A=n.args[1],p=n.args[2];if(t.type.is_variable(A))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_integer(A))o.throw_error(t.error.type("integer",A,n.indicator));else if(A.value<0)o.throw_error(t.error.domain("not_less_than_zero",A,n.indicator));else if(!t.type.is_variable(p)&&!t.type.is_list(p))o.throw_error(t.error.type("list",p,n.indicator));else{for(var h=new t.type.Term("[]"),C=0;C<A.value;C++)h=new t.type.Term(".",[u,h]);o.prepend([new t.type.State(a.goal.replace(new t.type.Term("=",[h,p])),a.substitution,a)])}},"sort/2":function(o,a,n){var u=n.args[0],A=n.args[1];if(t.type.is_variable(u))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_variable(A)&&!t.type.is_fully_list(A))o.throw_error(t.error.type("list",A,n.indicator));else{for(var p=[],h=u;h.indicator==="./2";)p.push(h.args[0]),h=h.args[1];if(t.type.is_variable(h))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_empty_list(h))o.throw_error(t.error.type("list",u,n.indicator));else{for(var C=p.sort(t.compare),I=C.length-1;I>0;I--)C[I].equals(C[I-1])&&C.splice(I,1);for(var v=new t.type.Term("[]"),I=C.length-1;I>=0;I--)v=new t.type.Term(".",[C[I],v]);o.prepend([new t.type.State(a.goal.replace(new t.type.Term("=",[v,A])),a.substitution,a)])}}},"msort/2":function(o,a,n){var u=n.args[0],A=n.args[1];if(t.type.is_variable(u))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_variable(A)&&!t.type.is_fully_list(A))o.throw_error(t.error.type("list",A,n.indicator));else{for(var p=[],h=u;h.indicator==="./2";)p.push(h.args[0]),h=h.args[1];if(t.type.is_variable(h))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_empty_list(h))o.throw_error(t.error.type("list",u,n.indicator));else{for(var C=p.sort(t.compare),I=new t.type.Term("[]"),v=C.length-1;v>=0;v--)I=new t.type.Term(".",[C[v],I]);o.prepend([new t.type.State(a.goal.replace(new t.type.Term("=",[I,A])),a.substitution,a)])}}},"keysort/2":function(o,a,n){var u=n.args[0],A=n.args[1];if(t.type.is_variable(u))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_variable(A)&&!t.type.is_fully_list(A))o.throw_error(t.error.type("list",A,n.indicator));else{for(var p=[],h,C=u;C.indicator==="./2";){if(h=C.args[0],t.type.is_variable(h)){o.throw_error(t.error.instantiation(n.indicator));return}else if(!t.type.is_term(h)||h.indicator!=="-/2"){o.throw_error(t.error.type("pair",h,n.indicator));return}h.args[0].pair=h.args[1],p.push(h.args[0]),C=C.args[1]}if(t.type.is_variable(C))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_empty_list(C))o.throw_error(t.error.type("list",u,n.indicator));else{for(var I=p.sort(t.compare),v=new t.type.Term("[]"),b=I.length-1;b>=0;b--)v=new t.type.Term(".",[new t.type.Term("-",[I[b],I[b].pair]),v]),delete I[b].pair;o.prepend([new t.type.State(a.goal.replace(new t.type.Term("=",[v,A])),a.substitution,a)])}}},"take/3":function(o,a,n){var u=n.args[0],A=n.args[1],p=n.args[2];if(t.type.is_variable(A)||t.type.is_variable(u))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_list(A))o.throw_error(t.error.type("list",A,n.indicator));else if(!t.type.is_integer(u))o.throw_error(t.error.type("integer",u,n.indicator));else if(!t.type.is_variable(p)&&!t.type.is_list(p))o.throw_error(t.error.type("list",p,n.indicator));else{for(var h=u.value,C=[],I=A;h>0&&I.indicator==="./2";)C.push(I.args[0]),I=I.args[1],h--;if(h===0){for(var v=new t.type.Term("[]"),h=C.length-1;h>=0;h--)v=new t.type.Term(".",[C[h],v]);o.prepend([new t.type.State(a.goal.replace(new t.type.Term("=",[v,p])),a.substitution,a)])}}},"drop/3":function(o,a,n){var u=n.args[0],A=n.args[1],p=n.args[2];if(t.type.is_variable(A)||t.type.is_variable(u))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_list(A))o.throw_error(t.error.type("list",A,n.indicator));else if(!t.type.is_integer(u))o.throw_error(t.error.type("integer",u,n.indicator));else if(!t.type.is_variable(p)&&!t.type.is_list(p))o.throw_error(t.error.type("list",p,n.indicator));else{for(var h=u.value,C=[],I=A;h>0&&I.indicator==="./2";)C.push(I.args[0]),I=I.args[1],h--;h===0&&o.prepend([new t.type.State(a.goal.replace(new t.type.Term("=",[I,p])),a.substitution,a)])}},"reverse/2":function(o,a,n){var u=n.args[0],A=n.args[1],p=t.type.is_instantiated_list(u),h=t.type.is_instantiated_list(A);if(t.type.is_variable(u)&&t.type.is_variable(A))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_variable(u)&&!t.type.is_fully_list(u))o.throw_error(t.error.type("list",u,n.indicator));else if(!t.type.is_variable(A)&&!t.type.is_fully_list(A))o.throw_error(t.error.type("list",A,n.indicator));else if(!p&&!h)o.throw_error(t.error.instantiation(n.indicator));else{for(var C=p?u:A,I=new t.type.Term("[]",[]);C.indicator==="./2";)I=new t.type.Term(".",[C.args[0],I]),C=C.args[1];o.prepend([new t.type.State(a.goal.replace(new t.type.Term("=",[I,p?A:u])),a.substitution,a)])}},"list_to_set/2":function(o,a,n){var u=n.args[0],A=n.args[1];if(t.type.is_variable(u))o.throw_error(t.error.instantiation(n.indicator));else{for(var p=u,h=[];p.indicator==="./2";)h.push(p.args[0]),p=p.args[1];if(t.type.is_variable(p))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_term(p)||p.indicator!=="[]/0")o.throw_error(t.error.type("list",u,n.indicator));else{for(var C=[],I=new t.type.Term("[]",[]),v,b=0;b<h.length;b++){v=!1;for(var E=0;E<C.length&&!v;E++)v=t.compare(h[b],C[E])===0;v||C.push(h[b])}for(b=C.length-1;b>=0;b--)I=new t.type.Term(".",[C[b],I]);o.prepend([new t.type.State(a.goal.replace(new t.type.Term("=",[A,I])),a.substitution,a)])}}}}},r=["append/2","append/3","member/2","permutation/2","maplist/2","maplist/3","maplist/4","maplist/5","maplist/6","maplist/7","maplist/8","include/3","exclude/3","foldl/4","sum_list/2","max_list/2","min_list/2","prod_list/2","last/2","prefix/2","nth0/3","nth1/3","nth0/4","nth1/4","length/2","replicate/3","select/3","sort/2","msort/2","keysort/2","take/3","drop/3","reverse/2","list_to_set/2"];typeof sH<"u"?sH.exports=function(o){t=o,new t.type.Module("lists",e(),r)}:new t.type.Module("lists",e(),r)})(jgt)});var sme=_(Yr=>{"use strict";var Zd=process.platform==="win32",oH="aes-256-cbc",qgt="sha256",Kde="The current environment doesn't support interactive reading from TTY.",Yn=Be("fs"),Yde=process.binding("tty_wrap").TTY,lH=Be("child_process"),l0=Be("path"),cH={prompt:"> ",hideEchoBack:!1,mask:"*",limit:[],limitMessage:"Input another, please.$<( [)limit(])>",defaultInput:"",trueValue:[],falseValue:[],caseSensitive:!1,keepWhitespace:!1,encoding:"utf8",bufferSize:1024,print:void 0,history:!0,cd:!1,phContent:void 0,preCheck:void 0},Vf="none",Xc,IC,Wde=!1,a0,mk,aH,Ggt=0,hH="",Xd=[],yk,Vde=!1,uH=!1,B2=!1;function zde(t){function e(r){return r.replace(/[^\w\u0080-\uFFFF]/g,function(o){return"#"+o.charCodeAt(0)+";"})}return mk.concat(function(r){var o=[];return Object.keys(r).forEach(function(a){r[a]==="boolean"?t[a]&&o.push("--"+a):r[a]==="string"&&t[a]&&o.push("--"+a,e(t[a]))}),o}({display:"string",displayOnly:"boolean",keyIn:"boolean",hideEchoBack:"boolean",mask:"string",limit:"string",caseSensitive:"boolean"}))}function Ygt(t,e){function r(U){var z,te="",le;for(aH=aH||Be("os").tmpdir();;){z=l0.join(aH,U+te);try{le=Yn.openSync(z,"wx")}catch(pe){if(pe.code==="EEXIST"){te++;continue}else throw pe}Yn.closeSync(le);break}return z}var o,a,n,u={},A,p,h=r("readline-sync.stdout"),C=r("readline-sync.stderr"),I=r("readline-sync.exit"),v=r("readline-sync.done"),b=Be("crypto"),E,F,N;E=b.createHash(qgt),E.update(""+process.pid+Ggt+++Math.random()),N=E.digest("hex"),F=b.createDecipher(oH,N),o=zde(t),Zd?(a=process.env.ComSpec||"cmd.exe",process.env.Q='"',n=["/V:ON","/S","/C","(%Q%"+a+"%Q% /V:ON /S /C %Q%%Q%"+a0+"%Q%"+o.map(function(U){return" %Q%"+U+"%Q%"}).join("")+" & (echo !ERRORLEVEL!)>%Q%"+I+"%Q%%Q%) 2>%Q%"+C+"%Q% |%Q%"+process.execPath+"%Q% %Q%"+__dirname+"\\encrypt.js%Q% %Q%"+oH+"%Q% %Q%"+N+"%Q% >%Q%"+h+"%Q% & (echo 1)>%Q%"+v+"%Q%"]):(a="/bin/sh",n=["-c",'("'+a0+'"'+o.map(function(U){return" '"+U.replace(/'/g,"'\\''")+"'"}).join("")+'; echo $?>"'+I+'") 2>"'+C+'" |"'+process.execPath+'" "'+__dirname+'/encrypt.js" "'+oH+'" "'+N+'" >"'+h+'"; echo 1 >"'+v+'"']),B2&&B2("_execFileSync",o);try{lH.spawn(a,n,e)}catch(U){u.error=new Error(U.message),u.error.method="_execFileSync - spawn",u.error.program=a,u.error.args=n}for(;Yn.readFileSync(v,{encoding:t.encoding}).trim()!=="1";);return(A=Yn.readFileSync(I,{encoding:t.encoding}).trim())==="0"?u.input=F.update(Yn.readFileSync(h,{encoding:"binary"}),"hex",t.encoding)+F.final(t.encoding):(p=Yn.readFileSync(C,{encoding:t.encoding}).trim(),u.error=new Error(Kde+(p?` -`+p:"")),u.error.method="_execFileSync",u.error.program=a,u.error.args=n,u.error.extMessage=p,u.error.exitCode=+A),Yn.unlinkSync(h),Yn.unlinkSync(C),Yn.unlinkSync(I),Yn.unlinkSync(v),u}function Wgt(t){var e,r={},o,a={env:process.env,encoding:t.encoding};if(a0||(Zd?process.env.PSModulePath?(a0="powershell.exe",mk=["-ExecutionPolicy","Bypass","-File",__dirname+"\\read.ps1"]):(a0="cscript.exe",mk=["//nologo",__dirname+"\\read.cs.js"]):(a0="/bin/sh",mk=[__dirname+"/read.sh"])),Zd&&!process.env.PSModulePath&&(a.stdio=[process.stdin]),lH.execFileSync){e=zde(t),B2&&B2("execFileSync",e);try{r.input=lH.execFileSync(a0,e,a)}catch(n){o=n.stderr?(n.stderr+"").trim():"",r.error=new Error(Kde+(o?` -`+o:"")),r.error.method="execFileSync",r.error.program=a0,r.error.args=e,r.error.extMessage=o,r.error.exitCode=n.status,r.error.code=n.code,r.error.signal=n.signal}}else r=Ygt(t,a);return r.error||(r.input=r.input.replace(/^\s*'|'\s*$/g,""),t.display=""),r}function AH(t){var e="",r=t.display,o=!t.display&&t.keyIn&&t.hideEchoBack&&!t.mask;function a(){var n=Wgt(t);if(n.error)throw n.error;return n.input}return uH&&uH(t),function(){var n,u,A;function p(){return n||(n=process.binding("fs"),u=process.binding("constants")),n}if(typeof Vf=="string")if(Vf=null,Zd){if(A=function(h){var C=h.replace(/^\D+/,"").split("."),I=0;return(C[0]=+C[0])&&(I+=C[0]*1e4),(C[1]=+C[1])&&(I+=C[1]*100),(C[2]=+C[2])&&(I+=C[2]),I}(process.version),!(A>=20302&&A<40204||A>=5e4&&A<50100||A>=50600&&A<60200)&&process.stdin.isTTY)process.stdin.pause(),Vf=process.stdin.fd,IC=process.stdin._handle;else try{Vf=p().open("CONIN$",u.O_RDWR,parseInt("0666",8)),IC=new Yde(Vf,!0)}catch{}if(process.stdout.isTTY)Xc=process.stdout.fd;else{try{Xc=Yn.openSync("\\\\.\\CON","w")}catch{}if(typeof Xc!="number")try{Xc=p().open("CONOUT$",u.O_RDWR,parseInt("0666",8))}catch{}}}else{if(process.stdin.isTTY){process.stdin.pause();try{Vf=Yn.openSync("/dev/tty","r"),IC=process.stdin._handle}catch{}}else try{Vf=Yn.openSync("/dev/tty","r"),IC=new Yde(Vf,!1)}catch{}if(process.stdout.isTTY)Xc=process.stdout.fd;else try{Xc=Yn.openSync("/dev/tty","w")}catch{}}}(),function(){var n,u,A=!t.hideEchoBack&&!t.keyIn,p,h,C,I,v;yk="";function b(E){return E===Wde?!0:IC.setRawMode(E)!==0?!1:(Wde=E,!0)}if(Vde||!IC||typeof Xc!="number"&&(t.display||!A)){e=a();return}if(t.display&&(Yn.writeSync(Xc,t.display),t.display=""),!t.displayOnly){if(!b(!A)){e=a();return}for(h=t.keyIn?1:t.bufferSize,p=Buffer.allocUnsafe&&Buffer.alloc?Buffer.alloc(h):new Buffer(h),t.keyIn&&t.limit&&(u=new RegExp("[^"+t.limit+"]","g"+(t.caseSensitive?"":"i")));;){C=0;try{C=Yn.readSync(Vf,p,0,h)}catch(E){if(E.code!=="EOF"){b(!1),e+=a();return}}if(C>0?(I=p.toString(t.encoding,0,C),yk+=I):(I=` -`,yk+=String.fromCharCode(0)),I&&typeof(v=(I.match(/^(.*?)[\r\n]/)||[])[1])=="string"&&(I=v,n=!0),I&&(I=I.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g,"")),I&&u&&(I=I.replace(u,"")),I&&(A||(t.hideEchoBack?t.mask&&Yn.writeSync(Xc,new Array(I.length+1).join(t.mask)):Yn.writeSync(Xc,I)),e+=I),!t.keyIn&&n||t.keyIn&&e.length>=h)break}!A&&!o&&Yn.writeSync(Xc,` -`),b(!1)}}(),t.print&&!o&&t.print(r+(t.displayOnly?"":(t.hideEchoBack?new Array(e.length+1).join(t.mask):e)+` -`),t.encoding),t.displayOnly?"":hH=t.keepWhitespace||t.keyIn?e:e.trim()}function Kgt(t,e){var r=[];function o(a){a!=null&&(Array.isArray(a)?a.forEach(o):(!e||e(a))&&r.push(a))}return o(t),r}function gH(t){return t.replace(/[\x00-\x7f]/g,function(e){return"\\x"+("00"+e.charCodeAt().toString(16)).substr(-2)})}function Ts(){var t=Array.prototype.slice.call(arguments),e,r;return t.length&&typeof t[0]=="boolean"&&(r=t.shift(),r&&(e=Object.keys(cH),t.unshift(cH))),t.reduce(function(o,a){return a==null||(a.hasOwnProperty("noEchoBack")&&!a.hasOwnProperty("hideEchoBack")&&(a.hideEchoBack=a.noEchoBack,delete a.noEchoBack),a.hasOwnProperty("noTrim")&&!a.hasOwnProperty("keepWhitespace")&&(a.keepWhitespace=a.noTrim,delete a.noTrim),r||(e=Object.keys(a)),e.forEach(function(n){var u;if(!!a.hasOwnProperty(n))switch(u=a[n],n){case"mask":case"limitMessage":case"defaultInput":case"encoding":u=u!=null?u+"":"",u&&n!=="limitMessage"&&(u=u.replace(/[\r\n]/g,"")),o[n]=u;break;case"bufferSize":!isNaN(u=parseInt(u,10))&&typeof u=="number"&&(o[n]=u);break;case"displayOnly":case"keyIn":case"hideEchoBack":case"caseSensitive":case"keepWhitespace":case"history":case"cd":o[n]=!!u;break;case"limit":case"trueValue":case"falseValue":o[n]=Kgt(u,function(A){var p=typeof A;return p==="string"||p==="number"||p==="function"||A instanceof RegExp}).map(function(A){return typeof A=="string"?A.replace(/[\r\n]/g,""):A});break;case"print":case"phContent":case"preCheck":o[n]=typeof u=="function"?u:void 0;break;case"prompt":case"display":o[n]=u??"";break}})),o},{})}function fH(t,e,r){return e.some(function(o){var a=typeof o;return a==="string"?r?t===o:t.toLowerCase()===o.toLowerCase():a==="number"?parseFloat(t)===o:a==="function"?o(t):o instanceof RegExp?o.test(t):!1})}function dH(t,e){var r=l0.normalize(Zd?(process.env.HOMEDRIVE||"")+(process.env.HOMEPATH||""):process.env.HOME||"").replace(/[\/\\]+$/,"");return t=l0.normalize(t),e?t.replace(/^~(?=\/|\\|$)/,r):t.replace(new RegExp("^"+gH(r)+"(?=\\/|\\\\|$)",Zd?"i":""),"~")}function BC(t,e){var r="(?:\\(([\\s\\S]*?)\\))?(\\w+|.-.)(?:\\(([\\s\\S]*?)\\))?",o=new RegExp("(\\$)?(\\$<"+r+">)","g"),a=new RegExp("(\\$)?(\\$\\{"+r+"\\})","g");function n(u,A,p,h,C,I){var v;return A||typeof(v=e(C))!="string"?p:v?(h||"")+v+(I||""):""}return t.replace(o,n).replace(a,n)}function Jde(t,e,r){var o,a=[],n=-1,u=0,A="",p;function h(C,I){return I.length>3?(C.push(I[0]+"..."+I[I.length-1]),p=!0):I.length&&(C=C.concat(I)),C}return o=t.reduce(function(C,I){return C.concat((I+"").split(""))},[]).reduce(function(C,I){var v,b;return e||(I=I.toLowerCase()),v=/^\d$/.test(I)?1:/^[A-Z]$/.test(I)?2:/^[a-z]$/.test(I)?3:0,r&&v===0?A+=I:(b=I.charCodeAt(0),v&&v===n&&b===u+1?a.push(I):(C=h(C,a),a=[I],n=v),u=b),C},[]),o=h(o,a),A&&(o.push(A),p=!0),{values:o,suppressed:p}}function Xde(t,e){return t.join(t.length>2?", ":e?" / ":"/")}function Zde(t,e){var r,o,a={},n;if(e.phContent&&(r=e.phContent(t,e)),typeof r!="string")switch(t){case"hideEchoBack":case"mask":case"defaultInput":case"caseSensitive":case"keepWhitespace":case"encoding":case"bufferSize":case"history":case"cd":r=e.hasOwnProperty(t)?typeof e[t]=="boolean"?e[t]?"on":"off":e[t]+"":"";break;case"limit":case"trueValue":case"falseValue":o=e[e.hasOwnProperty(t+"Src")?t+"Src":t],e.keyIn?(a=Jde(o,e.caseSensitive),o=a.values):o=o.filter(function(u){var A=typeof u;return A==="string"||A==="number"}),r=Xde(o,a.suppressed);break;case"limitCount":case"limitCountNotZero":r=e[e.hasOwnProperty("limitSrc")?"limitSrc":"limit"].length,r=r||t!=="limitCountNotZero"?r+"":"";break;case"lastInput":r=hH;break;case"cwd":case"CWD":case"cwdHome":r=process.cwd(),t==="CWD"?r=l0.basename(r):t==="cwdHome"&&(r=dH(r));break;case"date":case"time":case"localeDate":case"localeTime":r=new Date()["to"+t.replace(/^./,function(u){return u.toUpperCase()})+"String"]();break;default:typeof(n=(t.match(/^history_m(\d+)$/)||[])[1])=="string"&&(r=Xd[Xd.length-n]||"")}return r}function $de(t){var e=/^(.)-(.)$/.exec(t),r="",o,a,n,u;if(!e)return null;for(o=e[1].charCodeAt(0),a=e[2].charCodeAt(0),u=o<a?1:-1,n=o;n!==a+u;n+=u)r+=String.fromCharCode(n);return r}function pH(t){var e=new RegExp(/(\s*)(?:("|')(.*?)(?:\2|$)|(\S+))/g),r,o="",a=[],n;for(t=t.trim();r=e.exec(t);)n=r[3]||r[4]||"",r[1]&&(a.push(o),o=""),o+=n;return o&&a.push(o),a}function eme(t,e){return e.trueValue.length&&fH(t,e.trueValue,e.caseSensitive)?!0:e.falseValue.length&&fH(t,e.falseValue,e.caseSensitive)?!1:t}function tme(t){var e,r,o,a,n,u,A;function p(C){return Zde(C,t)}function h(C){t.display+=(/[^\r\n]$/.test(t.display)?` -`:"")+C}for(t.limitSrc=t.limit,t.displaySrc=t.display,t.limit="",t.display=BC(t.display+"",p);;){if(e=AH(t),r=!1,o="",t.defaultInput&&!e&&(e=t.defaultInput),t.history&&((a=/^\s*\!(?:\!|-1)(:p)?\s*$/.exec(e))?(n=Xd[0]||"",a[1]?r=!0:e=n,h(n+` -`),r||(t.displayOnly=!0,AH(t),t.displayOnly=!1)):e&&e!==Xd[Xd.length-1]&&(Xd=[e])),!r&&t.cd&&e)switch(u=pH(e),u[0].toLowerCase()){case"cd":if(u[1])try{process.chdir(dH(u[1],!0))}catch(C){h(C+"")}r=!0;break;case"pwd":h(process.cwd()),r=!0;break}if(!r&&t.preCheck&&(A=t.preCheck(e,t),e=A.res,A.forceNext&&(r=!0)),!r){if(!t.limitSrc.length||fH(e,t.limitSrc,t.caseSensitive))break;t.limitMessage&&(o=BC(t.limitMessage,p))}h((o?o+` -`:"")+BC(t.displaySrc+"",p))}return eme(e,t)}Yr._DBG_set_useExt=function(t){Vde=t};Yr._DBG_set_checkOptions=function(t){uH=t};Yr._DBG_set_checkMethod=function(t){B2=t};Yr._DBG_clearHistory=function(){hH="",Xd=[]};Yr.setDefaultOptions=function(t){return cH=Ts(!0,t),Ts(!0)};Yr.question=function(t,e){return tme(Ts(Ts(!0,e),{display:t}))};Yr.prompt=function(t){var e=Ts(!0,t);return e.display=e.prompt,tme(e)};Yr.keyIn=function(t,e){var r=Ts(Ts(!0,e),{display:t,keyIn:!0,keepWhitespace:!0});return r.limitSrc=r.limit.filter(function(o){var a=typeof o;return a==="string"||a==="number"}).map(function(o){return BC(o+"",$de)}),r.limit=gH(r.limitSrc.join("")),["trueValue","falseValue"].forEach(function(o){r[o]=r[o].reduce(function(a,n){var u=typeof n;return u==="string"||u==="number"?a=a.concat((n+"").split("")):a.push(n),a},[])}),r.display=BC(r.display+"",function(o){return Zde(o,r)}),eme(AH(r),r)};Yr.questionEMail=function(t,e){return t==null&&(t="Input e-mail address: "),Yr.question(t,Ts({hideEchoBack:!1,limit:/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,limitMessage:"Input valid e-mail address, please.",trueValue:null,falseValue:null},e,{keepWhitespace:!1,cd:!1}))};Yr.questionNewPassword=function(t,e){var r,o,a,n=Ts({hideEchoBack:!0,mask:"*",limitMessage:`It can include: $<charlist> -And the length must be: $<length>`,trueValue:null,falseValue:null,caseSensitive:!0},e,{history:!1,cd:!1,phContent:function(b){return b==="charlist"?r.text:b==="length"?o+"..."+a:null}}),u,A,p,h,C,I,v;for(e=e||{},u=BC(e.charlist?e.charlist+"":"$<!-~>",$de),(isNaN(o=parseInt(e.min,10))||typeof o!="number")&&(o=12),(isNaN(a=parseInt(e.max,10))||typeof a!="number")&&(a=24),h=new RegExp("^["+gH(u)+"]{"+o+","+a+"}$"),r=Jde([u],n.caseSensitive,!0),r.text=Xde(r.values,r.suppressed),A=e.confirmMessage!=null?e.confirmMessage:"Reinput a same one to confirm it: ",p=e.unmatchMessage!=null?e.unmatchMessage:"It differs from first one. Hit only the Enter key if you want to retry from first one.",t==null&&(t="Input new password: "),C=n.limitMessage;!v;)n.limit=h,n.limitMessage=C,I=Yr.question(t,n),n.limit=[I,""],n.limitMessage=p,v=Yr.question(A,n);return I};function rme(t,e,r){var o;function a(n){return o=r(n),!isNaN(o)&&typeof o=="number"}return Yr.question(t,Ts({limitMessage:"Input valid number, please."},e,{limit:a,cd:!1})),o}Yr.questionInt=function(t,e){return rme(t,e,function(r){return parseInt(r,10)})};Yr.questionFloat=function(t,e){return rme(t,e,parseFloat)};Yr.questionPath=function(t,e){var r,o="",a=Ts({hideEchoBack:!1,limitMessage:`$<error( -)>Input valid path, please.$<( Min:)min>$<( Max:)max>`,history:!0,cd:!0},e,{keepWhitespace:!1,limit:function(n){var u,A,p;n=dH(n,!0),o="";function h(C){C.split(/\/|\\/).reduce(function(I,v){var b=l0.resolve(I+=v+l0.sep);if(!Yn.existsSync(b))Yn.mkdirSync(b);else if(!Yn.statSync(b).isDirectory())throw new Error("Non directory already exists: "+b);return I},"")}try{if(u=Yn.existsSync(n),r=u?Yn.realpathSync(n):l0.resolve(n),!e.hasOwnProperty("exists")&&!u||typeof e.exists=="boolean"&&e.exists!==u)return o=(u?"Already exists":"No such file or directory")+": "+r,!1;if(!u&&e.create&&(e.isDirectory?h(r):(h(l0.dirname(r)),Yn.closeSync(Yn.openSync(r,"w"))),r=Yn.realpathSync(r)),u&&(e.min||e.max||e.isFile||e.isDirectory)){if(A=Yn.statSync(r),e.isFile&&!A.isFile())return o="Not file: "+r,!1;if(e.isDirectory&&!A.isDirectory())return o="Not directory: "+r,!1;if(e.min&&A.size<+e.min||e.max&&A.size>+e.max)return o="Size "+A.size+" is out of range: "+r,!1}if(typeof e.validate=="function"&&(p=e.validate(r))!==!0)return typeof p=="string"&&(o=p),!1}catch(C){return o=C+"",!1}return!0},phContent:function(n){return n==="error"?o:n!=="min"&&n!=="max"?null:e.hasOwnProperty(n)?e[n]+"":""}});return e=e||{},t==null&&(t='Input path (you can "cd" and "pwd"): '),Yr.question(t,a),r};function nme(t,e){var r={},o={};return typeof t=="object"?(Object.keys(t).forEach(function(a){typeof t[a]=="function"&&(o[e.caseSensitive?a:a.toLowerCase()]=t[a])}),r.preCheck=function(a){var n;return r.args=pH(a),n=r.args[0]||"",e.caseSensitive||(n=n.toLowerCase()),r.hRes=n!=="_"&&o.hasOwnProperty(n)?o[n].apply(a,r.args.slice(1)):o.hasOwnProperty("_")?o._.apply(a,r.args):null,{res:a,forceNext:!1}},o.hasOwnProperty("_")||(r.limit=function(){var a=r.args[0]||"";return e.caseSensitive||(a=a.toLowerCase()),o.hasOwnProperty(a)})):r.preCheck=function(a){return r.args=pH(a),r.hRes=typeof t=="function"?t.apply(a,r.args):!0,{res:a,forceNext:!1}},r}Yr.promptCL=function(t,e){var r=Ts({hideEchoBack:!1,limitMessage:"Requested command is not available.",caseSensitive:!1,history:!0},e),o=nme(t,r);return r.limit=o.limit,r.preCheck=o.preCheck,Yr.prompt(r),o.args};Yr.promptLoop=function(t,e){for(var r=Ts({hideEchoBack:!1,trueValue:null,falseValue:null,caseSensitive:!1,history:!0},e);!t(Yr.prompt(r)););};Yr.promptCLLoop=function(t,e){var r=Ts({hideEchoBack:!1,limitMessage:"Requested command is not available.",caseSensitive:!1,history:!0},e),o=nme(t,r);for(r.limit=o.limit,r.preCheck=o.preCheck;Yr.prompt(r),!o.hRes;);};Yr.promptSimShell=function(t){return Yr.prompt(Ts({hideEchoBack:!1,history:!0},t,{prompt:function(){return Zd?"$<cwd>>":(process.env.USER||"")+(process.env.HOSTNAME?"@"+process.env.HOSTNAME.replace(/\..*$/,""):"")+":$<cwdHome>$ "}()}))};function ime(t,e,r){var o;return t==null&&(t="Are you sure? "),(!e||e.guide!==!1)&&(t+="")&&(t=t.replace(/\s*:?\s*$/,"")+" [y/n]: "),o=Yr.keyIn(t,Ts(e,{hideEchoBack:!1,limit:r,trueValue:"y",falseValue:"n",caseSensitive:!1})),typeof o=="boolean"?o:""}Yr.keyInYN=function(t,e){return ime(t,e)};Yr.keyInYNStrict=function(t,e){return ime(t,e,"yn")};Yr.keyInPause=function(t,e){t==null&&(t="Continue..."),(!e||e.guide!==!1)&&(t+="")&&(t=t.replace(/\s+$/,"")+" (Hit any key)"),Yr.keyIn(t,Ts({limit:null},e,{hideEchoBack:!0,mask:""}))};Yr.keyInSelect=function(t,e,r){var o=Ts({hideEchoBack:!1},r,{trueValue:null,falseValue:null,caseSensitive:!1,phContent:function(p){return p==="itemsCount"?t.length+"":p==="firstItem"?(t[0]+"").trim():p==="lastItem"?(t[t.length-1]+"").trim():null}}),a="",n={},u=49,A=` -`;if(!Array.isArray(t)||!t.length||t.length>35)throw"`items` must be Array (max length: 35).";return t.forEach(function(p,h){var C=String.fromCharCode(u);a+=C,n[C]=h,A+="["+C+"] "+(p+"").trim()+` -`,u=u===57?97:u+1}),(!r||r.cancel!==!1)&&(a+="0",n[0]=-1,A+="[0] "+(r&&r.cancel!=null&&typeof r.cancel!="boolean"?(r.cancel+"").trim():"CANCEL")+` -`),o.limit=a,A+=` -`,e==null&&(e="Choose one from list: "),(e+="")&&((!r||r.guide!==!1)&&(e=e.replace(/\s*:?\s*$/,"")+" [$<limit>]: "),A+=e),n[Yr.keyIn(A,o).toLowerCase()]};Yr.getRawInput=function(){return yk};function v2(t,e){var r;return e.length&&(r={},r[t]=e[0]),Yr.setDefaultOptions(r)[t]}Yr.setPrint=function(){return v2("print",arguments)};Yr.setPrompt=function(){return v2("prompt",arguments)};Yr.setEncoding=function(){return v2("encoding",arguments)};Yr.setMask=function(){return v2("mask",arguments)};Yr.setBufferSize=function(){return v2("bufferSize",arguments)}});var mH=_((z9t,hl)=>{(function(){var t={major:0,minor:2,patch:66,status:"beta"};tau_file_system={files:{},open:function(w,S,y){var R=tau_file_system.files[w];if(!R){if(y==="read")return null;R={path:w,text:"",type:S,get:function(J,X){return X===this.text.length||X>this.text.length?"end_of_file":this.text.substring(X,X+J)},put:function(J,X){return X==="end_of_file"?(this.text+=J,!0):X==="past_end_of_file"?null:(this.text=this.text.substring(0,X)+J+this.text.substring(X+J.length),!0)},get_byte:function(J){if(J==="end_of_stream")return-1;var X=Math.floor(J/2);if(this.text.length<=X)return-1;var Z=n(this.text[Math.floor(J/2)],0);return J%2===0?Z&255:Z/256>>>0},put_byte:function(J,X){var Z=X==="end_of_stream"?this.text.length:Math.floor(X/2);if(this.text.length<Z)return null;var ie=this.text.length===Z?-1:n(this.text[Math.floor(X/2)],0);return X%2===0?(ie=ie/256>>>0,ie=(ie&255)<<8|J&255):(ie=ie&255,ie=(J&255)<<8|ie&255),this.text.length===Z?this.text+=u(ie):this.text=this.text.substring(0,Z)+u(ie)+this.text.substring(Z+1),!0},flush:function(){return!0},close:function(){var J=tau_file_system.files[this.path];return J?!0:null}},tau_file_system.files[w]=R}return y==="write"&&(R.text=""),R}},tau_user_input={buffer:"",get:function(w,S){for(var y;tau_user_input.buffer.length<w;)y=window.prompt(),y&&(tau_user_input.buffer+=y);return y=tau_user_input.buffer.substr(0,w),tau_user_input.buffer=tau_user_input.buffer.substr(w),y}},tau_user_output={put:function(w,S){return console.log(w),!0},flush:function(){return!0}},nodejs_file_system={open:function(w,S,y){var R=Be("fs"),J=R.openSync(w,y[0]);return y==="read"&&!R.existsSync(w)?null:{get:function(X,Z){var ie=new Buffer(X);return R.readSync(J,ie,0,X,Z),ie.toString()},put:function(X,Z){var ie=Buffer.from(X);if(Z==="end_of_file")R.writeSync(J,ie);else{if(Z==="past_end_of_file")return null;R.writeSync(J,ie,0,ie.length,Z)}return!0},get_byte:function(X){return null},put_byte:function(X,Z){return null},flush:function(){return!0},close:function(){return R.closeSync(J),!0}}}},nodejs_user_input={buffer:"",get:function(w,S){for(var y,R=sme();nodejs_user_input.buffer.length<w;)nodejs_user_input.buffer+=R.question();return y=nodejs_user_input.buffer.substr(0,w),nodejs_user_input.buffer=nodejs_user_input.buffer.substr(w),y}},nodejs_user_output={put:function(w,S){return process.stdout.write(w),!0},flush:function(){return!0}};var e;Array.prototype.indexOf?e=function(w,S){return w.indexOf(S)}:e=function(w,S){for(var y=w.length,R=0;R<y;R++)if(S===w[R])return R;return-1};var r=function(w,S){if(w.length!==0){for(var y=w[0],R=w.length,J=1;J<R;J++)y=S(y,w[J]);return y}},o;Array.prototype.map?o=function(w,S){return w.map(S)}:o=function(w,S){for(var y=[],R=w.length,J=0;J<R;J++)y.push(S(w[J]));return y};var a;Array.prototype.filter?a=function(w,S){return w.filter(S)}:a=function(w,S){for(var y=[],R=w.length,J=0;J<R;J++)S(w[J])&&y.push(w[J]);return y};var n;String.prototype.codePointAt?n=function(w,S){return w.codePointAt(S)}:n=function(w,S){return w.charCodeAt(S)};var u;String.fromCodePoint?u=function(){return String.fromCodePoint.apply(null,arguments)}:u=function(){return String.fromCharCode.apply(null,arguments)};var A=0,p=1,h=/(\\a)|(\\b)|(\\f)|(\\n)|(\\r)|(\\t)|(\\v)|\\x([0-9a-fA-F]+)\\|\\([0-7]+)\\|(\\\\)|(\\')|('')|(\\")|(\\`)|(\\.)|(.)/g,C={"\\a":7,"\\b":8,"\\f":12,"\\n":10,"\\r":13,"\\t":9,"\\v":11};function I(w){var S=[],y=!1;return w.replace(h,function(R,J,X,Z,ie,Pe,Le,ot,dt,jt,$t,xt,an,kr,mr,xr,Wr){switch(!0){case dt!==void 0:return S.push(parseInt(dt,16)),"";case jt!==void 0:return S.push(parseInt(jt,8)),"";case $t!==void 0:case xt!==void 0:case an!==void 0:case kr!==void 0:case mr!==void 0:return S.push(n(R.substr(1),0)),"";case Wr!==void 0:return S.push(n(Wr,0)),"";case xr!==void 0:y=!0;default:return S.push(C[R]),""}}),y?null:S}function v(w,S){var y="";if(w.length<2)return w;try{w=w.replace(/\\([0-7]+)\\/g,function(Z,ie){return u(parseInt(ie,8))}),w=w.replace(/\\x([0-9a-fA-F]+)\\/g,function(Z,ie){return u(parseInt(ie,16))})}catch{return null}for(var R=0;R<w.length;R++){var J=w.charAt(R),X=w.charAt(R+1);if(J===S&&X===S)R++,y+=S;else if(J==="\\")if(["a","b","f","n","r","t","v","'",'"',"\\","a","\b","\f",` -`,"\r"," ","\v"].indexOf(X)!==-1)switch(R+=1,X){case"a":y+="a";break;case"b":y+="\b";break;case"f":y+="\f";break;case"n":y+=` -`;break;case"r":y+="\r";break;case"t":y+=" ";break;case"v":y+="\v";break;case"'":y+="'";break;case'"':y+='"';break;case"\\":y+="\\";break}else return null;else y+=J}return y}function b(w){for(var S="",y=0;y<w.length;y++)switch(w.charAt(y)){case"'":S+="\\'";break;case"\\":S+="\\\\";break;case"\b":S+="\\b";break;case"\f":S+="\\f";break;case` -`:S+="\\n";break;case"\r":S+="\\r";break;case" ":S+="\\t";break;case"\v":S+="\\v";break;default:S+=w.charAt(y);break}return S}function E(w){var S=w.substr(2);switch(w.substr(0,2).toLowerCase()){case"0x":return parseInt(S,16);case"0b":return parseInt(S,2);case"0o":return parseInt(S,8);case"0'":return I(S)[0];default:return parseFloat(w)}}var F={whitespace:/^\s*(?:(?:%.*)|(?:\/\*(?:\n|\r|.)*?\*\/)|(?:\s+))\s*/,variable:/^(?:[A-Z_][a-zA-Z0-9_]*)/,atom:/^(\!|,|;|[a-z][0-9a-zA-Z_]*|[#\$\&\*\+\-\.\/\:\<\=\>\?\@\^\~\\]+|'(?:[^']*?(?:\\(?:x?\d+)?\\)*(?:'')*(?:\\')*)*')/,number:/^(?:0o[0-7]+|0x[0-9a-fA-F]+|0b[01]+|0'(?:''|\\[abfnrtv\\'"`]|\\x?\d+\\|[^\\])|\d+(?:\.\d+(?:[eE][+-]?\d+)?)?)/,string:/^(?:"([^"]|""|\\")*"|`([^`]|``|\\`)*`)/,l_brace:/^(?:\[)/,r_brace:/^(?:\])/,l_bracket:/^(?:\{)/,r_bracket:/^(?:\})/,bar:/^(?:\|)/,l_paren:/^(?:\()/,r_paren:/^(?:\))/};function N(w,S){return w.get_flag("char_conversion").id==="on"?S.replace(/./g,function(y){return w.get_char_conversion(y)}):S}function U(w){this.thread=w,this.text="",this.tokens=[]}U.prototype.set_last_tokens=function(w){return this.tokens=w},U.prototype.new_text=function(w){this.text=w,this.tokens=[]},U.prototype.get_tokens=function(w){var S,y=0,R=0,J=0,X=[],Z=!1;if(w){var ie=this.tokens[w-1];y=ie.len,S=N(this.thread,this.text.substr(ie.len)),R=ie.line,J=ie.start}else S=this.text;if(/^\s*$/.test(S))return null;for(;S!=="";){var Pe=[],Le=!1;if(/^\n/.exec(S)!==null){R++,J=0,y++,S=S.replace(/\n/,""),Z=!0;continue}for(var ot in F)if(F.hasOwnProperty(ot)){var dt=F[ot].exec(S);dt&&Pe.push({value:dt[0],name:ot,matches:dt})}if(!Pe.length)return this.set_last_tokens([{value:S,matches:[],name:"lexical",line:R,start:J}]);var ie=r(Pe,function(kr,mr){return kr.value.length>=mr.value.length?kr:mr});switch(ie.start=J,ie.line=R,S=S.replace(ie.value,""),J+=ie.value.length,y+=ie.value.length,ie.name){case"atom":ie.raw=ie.value,ie.value.charAt(0)==="'"&&(ie.value=v(ie.value.substr(1,ie.value.length-2),"'"),ie.value===null&&(ie.name="lexical",ie.value="unknown escape sequence"));break;case"number":ie.float=ie.value.substring(0,2)!=="0x"&&ie.value.match(/[.eE]/)!==null&&ie.value!=="0'.",ie.value=E(ie.value),ie.blank=Le;break;case"string":var jt=ie.value.charAt(0);ie.value=v(ie.value.substr(1,ie.value.length-2),jt),ie.value===null&&(ie.name="lexical",ie.value="unknown escape sequence");break;case"whitespace":var $t=X[X.length-1];$t&&($t.space=!0),Le=!0;continue;case"r_bracket":X.length>0&&X[X.length-1].name==="l_bracket"&&(ie=X.pop(),ie.name="atom",ie.value="{}",ie.raw="{}",ie.space=!1);break;case"r_brace":X.length>0&&X[X.length-1].name==="l_brace"&&(ie=X.pop(),ie.name="atom",ie.value="[]",ie.raw="[]",ie.space=!1);break}ie.len=y,X.push(ie),Le=!1}var xt=this.set_last_tokens(X);return xt.length===0?null:xt};function z(w,S,y,R,J){if(!S[y])return{type:A,value:x.error.syntax(S[y-1],"expression expected",!0)};var X;if(R==="0"){var Z=S[y];switch(Z.name){case"number":return{type:p,len:y+1,value:new x.type.Num(Z.value,Z.float)};case"variable":return{type:p,len:y+1,value:new x.type.Var(Z.value)};case"string":var ie;switch(w.get_flag("double_quotes").id){case"atom":ie=new H(Z.value,[]);break;case"codes":ie=new H("[]",[]);for(var Pe=Z.value.length-1;Pe>=0;Pe--)ie=new H(".",[new x.type.Num(n(Z.value,Pe),!1),ie]);break;case"chars":ie=new H("[]",[]);for(var Pe=Z.value.length-1;Pe>=0;Pe--)ie=new H(".",[new x.type.Term(Z.value.charAt(Pe),[]),ie]);break}return{type:p,len:y+1,value:ie};case"l_paren":var xt=z(w,S,y+1,w.__get_max_priority(),!0);return xt.type!==p?xt:S[xt.len]&&S[xt.len].name==="r_paren"?(xt.len++,xt):{type:A,derived:!0,value:x.error.syntax(S[xt.len]?S[xt.len]:S[xt.len-1],") or operator expected",!S[xt.len])};case"l_bracket":var xt=z(w,S,y+1,w.__get_max_priority(),!0);return xt.type!==p?xt:S[xt.len]&&S[xt.len].name==="r_bracket"?(xt.len++,xt.value=new H("{}",[xt.value]),xt):{type:A,derived:!0,value:x.error.syntax(S[xt.len]?S[xt.len]:S[xt.len-1],"} or operator expected",!S[xt.len])}}var Le=te(w,S,y,J);return Le.type===p||Le.derived||(Le=le(w,S,y),Le.type===p||Le.derived)?Le:{type:A,derived:!1,value:x.error.syntax(S[y],"unexpected token")}}var ot=w.__get_max_priority(),dt=w.__get_next_priority(R),jt=y;if(S[y].name==="atom"&&S[y+1]&&(S[y].space||S[y+1].name!=="l_paren")){var Z=S[y++],$t=w.__lookup_operator_classes(R,Z.value);if($t&&$t.indexOf("fy")>-1){var xt=z(w,S,y,R,J);if(xt.type!==A)return Z.value==="-"&&!Z.space&&x.type.is_number(xt.value)?{value:new x.type.Num(-xt.value.value,xt.value.is_float),len:xt.len,type:p}:{value:new x.type.Term(Z.value,[xt.value]),len:xt.len,type:p};X=xt}else if($t&&$t.indexOf("fx")>-1){var xt=z(w,S,y,dt,J);if(xt.type!==A)return{value:new x.type.Term(Z.value,[xt.value]),len:xt.len,type:p};X=xt}}y=jt;var xt=z(w,S,y,dt,J);if(xt.type===p){y=xt.len;var Z=S[y];if(S[y]&&(S[y].name==="atom"&&w.__lookup_operator_classes(R,Z.value)||S[y].name==="bar"&&w.__lookup_operator_classes(R,"|"))){var an=dt,kr=R,$t=w.__lookup_operator_classes(R,Z.value);if($t.indexOf("xf")>-1)return{value:new x.type.Term(Z.value,[xt.value]),len:++xt.len,type:p};if($t.indexOf("xfx")>-1){var mr=z(w,S,y+1,an,J);return mr.type===p?{value:new x.type.Term(Z.value,[xt.value,mr.value]),len:mr.len,type:p}:(mr.derived=!0,mr)}else if($t.indexOf("xfy")>-1){var mr=z(w,S,y+1,kr,J);return mr.type===p?{value:new x.type.Term(Z.value,[xt.value,mr.value]),len:mr.len,type:p}:(mr.derived=!0,mr)}else if(xt.type!==A)for(;;){y=xt.len;var Z=S[y];if(Z&&Z.name==="atom"&&w.__lookup_operator_classes(R,Z.value)){var $t=w.__lookup_operator_classes(R,Z.value);if($t.indexOf("yf")>-1)xt={value:new x.type.Term(Z.value,[xt.value]),len:++y,type:p};else if($t.indexOf("yfx")>-1){var mr=z(w,S,++y,an,J);if(mr.type===A)return mr.derived=!0,mr;y=mr.len,xt={value:new x.type.Term(Z.value,[xt.value,mr.value]),len:y,type:p}}else break}else break}}else X={type:A,value:x.error.syntax(S[xt.len-1],"operator expected")};return xt}return xt}function te(w,S,y,R){if(!S[y]||S[y].name==="atom"&&S[y].raw==="."&&!R&&(S[y].space||!S[y+1]||S[y+1].name!=="l_paren"))return{type:A,derived:!1,value:x.error.syntax(S[y-1],"unfounded token")};var J=S[y],X=[];if(S[y].name==="atom"&&S[y].raw!==","){if(y++,S[y-1].space)return{type:p,len:y,value:new x.type.Term(J.value,X)};if(S[y]&&S[y].name==="l_paren"){if(S[y+1]&&S[y+1].name==="r_paren")return{type:A,derived:!0,value:x.error.syntax(S[y+1],"argument expected")};var Z=z(w,S,++y,"999",!0);if(Z.type===A)return Z.derived?Z:{type:A,derived:!0,value:x.error.syntax(S[y]?S[y]:S[y-1],"argument expected",!S[y])};for(X.push(Z.value),y=Z.len;S[y]&&S[y].name==="atom"&&S[y].value===",";){if(Z=z(w,S,y+1,"999",!0),Z.type===A)return Z.derived?Z:{type:A,derived:!0,value:x.error.syntax(S[y+1]?S[y+1]:S[y],"argument expected",!S[y+1])};X.push(Z.value),y=Z.len}if(S[y]&&S[y].name==="r_paren")y++;else return{type:A,derived:!0,value:x.error.syntax(S[y]?S[y]:S[y-1],", or ) expected",!S[y])}}return{type:p,len:y,value:new x.type.Term(J.value,X)}}return{type:A,derived:!1,value:x.error.syntax(S[y],"term expected")}}function le(w,S,y){if(!S[y])return{type:A,derived:!1,value:x.error.syntax(S[y-1],"[ expected")};if(S[y]&&S[y].name==="l_brace"){var R=z(w,S,++y,"999",!0),J=[R.value],X=void 0;if(R.type===A)return S[y]&&S[y].name==="r_brace"?{type:p,len:y+1,value:new x.type.Term("[]",[])}:{type:A,derived:!0,value:x.error.syntax(S[y],"] expected")};for(y=R.len;S[y]&&S[y].name==="atom"&&S[y].value===",";){if(R=z(w,S,y+1,"999",!0),R.type===A)return R.derived?R:{type:A,derived:!0,value:x.error.syntax(S[y+1]?S[y+1]:S[y],"argument expected",!S[y+1])};J.push(R.value),y=R.len}var Z=!1;if(S[y]&&S[y].name==="bar"){if(Z=!0,R=z(w,S,y+1,"999",!0),R.type===A)return R.derived?R:{type:A,derived:!0,value:x.error.syntax(S[y+1]?S[y+1]:S[y],"argument expected",!S[y+1])};X=R.value,y=R.len}return S[y]&&S[y].name==="r_brace"?{type:p,len:y+1,value:g(J,X)}:{type:A,derived:!0,value:x.error.syntax(S[y]?S[y]:S[y-1],Z?"] expected":", or | or ] expected",!S[y])}}return{type:A,derived:!1,value:x.error.syntax(S[y],"list expected")}}function pe(w,S,y){var R=S[y].line,J=z(w,S,y,w.__get_max_priority(),!1),X=null,Z;if(J.type!==A)if(y=J.len,S[y]&&S[y].name==="atom"&&S[y].raw===".")if(y++,x.type.is_term(J.value)){if(J.value.indicator===":-/2"?(X=new x.type.Rule(J.value.args[0],Fe(J.value.args[1])),Z={value:X,len:y,type:p}):J.value.indicator==="-->/2"?(X=ae(new x.type.Rule(J.value.args[0],J.value.args[1]),w),X.body=Fe(X.body),Z={value:X,len:y,type:x.type.is_rule(X)?p:A}):(X=new x.type.Rule(J.value,null),Z={value:X,len:y,type:p}),X){var ie=X.singleton_variables();ie.length>0&&w.throw_warning(x.warning.singleton(ie,X.head.indicator,R))}return Z}else return{type:A,value:x.error.syntax(S[y],"callable expected")};else return{type:A,value:x.error.syntax(S[y]?S[y]:S[y-1],". or operator expected")};return J}function ue(w,S,y){y=y||{},y.from=y.from?y.from:"$tau-js",y.reconsult=y.reconsult!==void 0?y.reconsult:!0;var R=new U(w),J={},X;R.new_text(S);var Z=0,ie=R.get_tokens(Z);do{if(ie===null||!ie[Z])break;var Pe=pe(w,ie,Z);if(Pe.type===A)return new H("throw",[Pe.value]);if(Pe.value.body===null&&Pe.value.head.indicator==="?-/1"){var Le=new ze(w.session);Le.add_goal(Pe.value.head.args[0]),Le.answer(function(dt){x.type.is_error(dt)?w.throw_warning(dt.args[0]):(dt===!1||dt===null)&&w.throw_warning(x.warning.failed_goal(Pe.value.head.args[0],Pe.len))}),Z=Pe.len;var ot=!0}else if(Pe.value.body===null&&Pe.value.head.indicator===":-/1"){var ot=w.run_directive(Pe.value.head.args[0]);Z=Pe.len,Pe.value.head.args[0].indicator==="char_conversion/2"&&(ie=R.get_tokens(Z),Z=0)}else{X=Pe.value.head.indicator,y.reconsult!==!1&&J[X]!==!0&&!w.is_multifile_predicate(X)&&(w.session.rules[X]=a(w.session.rules[X]||[],function(jt){return jt.dynamic}),J[X]=!0);var ot=w.add_rule(Pe.value,y);Z=Pe.len}if(!ot)return ot}while(!0);return!0}function ye(w,S){var y=new U(w);y.new_text(S);var R=0;do{var J=y.get_tokens(R);if(J===null)break;var X=z(w,J,0,w.__get_max_priority(),!1);if(X.type!==A){var Z=X.len,ie=Z;if(J[Z]&&J[Z].name==="atom"&&J[Z].raw===".")w.add_goal(Fe(X.value));else{var Pe=J[Z];return new H("throw",[x.error.syntax(Pe||J[Z-1],". or operator expected",!Pe)])}R=X.len+1}else return new H("throw",[X.value])}while(!0);return!0}function ae(w,S){w=w.rename(S);var y=S.next_free_variable(),R=Ie(w.body,y,S);return R.error?R.value:(w.body=R.value,w.head.args=w.head.args.concat([y,R.variable]),w.head=new H(w.head.id,w.head.args),w)}function Ie(w,S,y){var R;if(x.type.is_term(w)&&w.indicator==="!/0")return{value:w,variable:S,error:!1};if(x.type.is_term(w)&&w.indicator===",/2"){var J=Ie(w.args[0],S,y);if(J.error)return J;var X=Ie(w.args[1],J.variable,y);return X.error?X:{value:new H(",",[J.value,X.value]),variable:X.variable,error:!1}}else{if(x.type.is_term(w)&&w.indicator==="{}/1")return{value:w.args[0],variable:S,error:!1};if(x.type.is_empty_list(w))return{value:new H("true",[]),variable:S,error:!1};if(x.type.is_list(w)){R=y.next_free_variable();for(var Z=w,ie;Z.indicator==="./2";)ie=Z,Z=Z.args[1];return x.type.is_variable(Z)?{value:x.error.instantiation("DCG"),variable:S,error:!0}:x.type.is_empty_list(Z)?(ie.args[1]=R,{value:new H("=",[S,w]),variable:R,error:!1}):{value:x.error.type("list",w,"DCG"),variable:S,error:!0}}else return x.type.is_callable(w)?(R=y.next_free_variable(),w.args=w.args.concat([S,R]),w=new H(w.id,w.args),{value:w,variable:R,error:!1}):{value:x.error.type("callable",w,"DCG"),variable:S,error:!0}}}function Fe(w){return x.type.is_variable(w)?new H("call",[w]):x.type.is_term(w)&&[",/2",";/2","->/2"].indexOf(w.indicator)!==-1?new H(w.id,[Fe(w.args[0]),Fe(w.args[1])]):w}function g(w,S){for(var y=S||new x.type.Term("[]",[]),R=w.length-1;R>=0;R--)y=new x.type.Term(".",[w[R],y]);return y}function Ee(w,S){for(var y=w.length-1;y>=0;y--)w[y]===S&&w.splice(y,1)}function De(w){for(var S={},y=[],R=0;R<w.length;R++)w[R]in S||(y.push(w[R]),S[w[R]]=!0);return y}function ce(w,S,y,R){if(w.session.rules[y]!==null){for(var J=0;J<w.session.rules[y].length;J++)if(w.session.rules[y][J]===R){w.session.rules[y].splice(J,1),w.success(S);break}}}function ne(w){return function(S,y,R){var J=R.args[0],X=R.args.slice(1,w);if(x.type.is_variable(J))S.throw_error(x.error.instantiation(S.level));else if(!x.type.is_callable(J))S.throw_error(x.error.type("callable",J,S.level));else{var Z=new H(J.id,J.args.concat(X));S.prepend([new xe(y.goal.replace(Z),y.substitution,y)])}}}function ee(w){for(var S=w.length-1;S>=0;S--)if(w.charAt(S)==="/")return new H("/",[new H(w.substring(0,S)),new be(parseInt(w.substring(S+1)),!1)])}function we(w){this.id=w}function be(w,S){this.is_float=S!==void 0?S:parseInt(w)!==w,this.value=this.is_float?w:parseInt(w)}var ht=0;function H(w,S,y){this.ref=y||++ht,this.id=w,this.args=S||[],this.indicator=w+"/"+this.args.length}var lt=0;function Te(w,S,y,R,J,X){this.id=lt++,this.stream=w,this.mode=S,this.alias=y,this.type=R!==void 0?R:"text",this.reposition=J!==void 0?J:!0,this.eof_action=X!==void 0?X:"eof_code",this.position=this.mode==="append"?"end_of_stream":0,this.output=this.mode==="write"||this.mode==="append",this.input=this.mode==="read"}function ke(w){w=w||{},this.links=w}function xe(w,S,y){S=S||new ke,y=y||null,this.goal=w,this.substitution=S,this.parent=y}function He(w,S,y){this.head=w,this.body=S,this.dynamic=y||!1}function Re(w){w=w===void 0||w<=0?1e3:w,this.rules={},this.src_predicates={},this.rename=0,this.modules=[],this.thread=new ze(this),this.total_threads=1,this.renamed_variables={},this.public_predicates={},this.multifile_predicates={},this.limit=w,this.streams={user_input:new Te(typeof hl<"u"&&hl.exports?nodejs_user_input:tau_user_input,"read","user_input","text",!1,"reset"),user_output:new Te(typeof hl<"u"&&hl.exports?nodejs_user_output:tau_user_output,"write","user_output","text",!1,"eof_code")},this.file_system=typeof hl<"u"&&hl.exports?nodejs_file_system:tau_file_system,this.standard_input=this.streams.user_input,this.standard_output=this.streams.user_output,this.current_input=this.streams.user_input,this.current_output=this.streams.user_output,this.format_success=function(S){return S.substitution},this.format_error=function(S){return S.goal},this.flag={bounded:x.flag.bounded.value,max_integer:x.flag.max_integer.value,min_integer:x.flag.min_integer.value,integer_rounding_function:x.flag.integer_rounding_function.value,char_conversion:x.flag.char_conversion.value,debug:x.flag.debug.value,max_arity:x.flag.max_arity.value,unknown:x.flag.unknown.value,double_quotes:x.flag.double_quotes.value,occurs_check:x.flag.occurs_check.value,dialect:x.flag.dialect.value,version_data:x.flag.version_data.value,nodejs:x.flag.nodejs.value},this.__loaded_modules=[],this.__char_conversion={},this.__operators={1200:{":-":["fx","xfx"],"-->":["xfx"],"?-":["fx"]},1100:{";":["xfy"]},1050:{"->":["xfy"]},1e3:{",":["xfy"]},900:{"\\+":["fy"]},700:{"=":["xfx"],"\\=":["xfx"],"==":["xfx"],"\\==":["xfx"],"@<":["xfx"],"@=<":["xfx"],"@>":["xfx"],"@>=":["xfx"],"=..":["xfx"],is:["xfx"],"=:=":["xfx"],"=\\=":["xfx"],"<":["xfx"],"=<":["xfx"],">":["xfx"],">=":["xfx"]},600:{":":["xfy"]},500:{"+":["yfx"],"-":["yfx"],"/\\":["yfx"],"\\/":["yfx"]},400:{"*":["yfx"],"/":["yfx"],"//":["yfx"],rem:["yfx"],mod:["yfx"],"<<":["yfx"],">>":["yfx"]},200:{"**":["xfx"],"^":["xfy"],"-":["fy"],"+":["fy"],"\\":["fy"]}}}function ze(w){this.epoch=Date.now(),this.session=w,this.session.total_threads++,this.total_steps=0,this.cpu_time=0,this.cpu_time_last=0,this.points=[],this.debugger=!1,this.debugger_states=[],this.level="top_level/0",this.__calls=[],this.current_limit=this.session.limit,this.warnings=[]}function je(w,S,y){this.id=w,this.rules=S,this.exports=y,x.module[w]=this}je.prototype.exports_predicate=function(w){return this.exports.indexOf(w)!==-1},we.prototype.unify=function(w,S){if(S&&e(w.variables(),this.id)!==-1&&!x.type.is_variable(w))return null;var y={};return y[this.id]=w,new ke(y)},be.prototype.unify=function(w,S){return x.type.is_number(w)&&this.value===w.value&&this.is_float===w.is_float?new ke:null},H.prototype.unify=function(w,S){if(x.type.is_term(w)&&this.indicator===w.indicator){for(var y=new ke,R=0;R<this.args.length;R++){var J=x.unify(this.args[R].apply(y),w.args[R].apply(y),S);if(J===null)return null;for(var X in J.links)y.links[X]=J.links[X];y=y.apply(J)}return y}return null},Te.prototype.unify=function(w,S){return x.type.is_stream(w)&&this.id===w.id?new ke:null},we.prototype.toString=function(w){return this.id},be.prototype.toString=function(w){return this.is_float&&e(this.value.toString(),".")===-1?this.value+".0":this.value.toString()},H.prototype.toString=function(w,S,y){if(w=w||{},w.quoted=w.quoted===void 0?!0:w.quoted,w.ignore_ops=w.ignore_ops===void 0?!1:w.ignore_ops,w.numbervars=w.numbervars===void 0?!1:w.numbervars,S=S===void 0?1200:S,y=y===void 0?"":y,w.numbervars&&this.indicator==="$VAR/1"&&x.type.is_integer(this.args[0])&&this.args[0].value>=0){var R=this.args[0].value,J=Math.floor(R/26),X=R%26;return"ABCDEFGHIJKLMNOPQRSTUVWXYZ"[X]+(J!==0?J:"")}switch(this.indicator){case"[]/0":case"{}/0":case"!/0":return this.id;case"{}/1":return"{"+this.args[0].toString(w)+"}";case"./2":for(var Z="["+this.args[0].toString(w),ie=this.args[1];ie.indicator==="./2";)Z+=", "+ie.args[0].toString(w),ie=ie.args[1];return ie.indicator!=="[]/0"&&(Z+="|"+ie.toString(w)),Z+="]",Z;case",/2":return"("+this.args[0].toString(w)+", "+this.args[1].toString(w)+")";default:var Pe=this.id,Le=w.session?w.session.lookup_operator(this.id,this.args.length):null;if(w.session===void 0||w.ignore_ops||Le===null)return w.quoted&&!/^(!|,|;|[a-z][0-9a-zA-Z_]*)$/.test(Pe)&&Pe!=="{}"&&Pe!=="[]"&&(Pe="'"+b(Pe)+"'"),Pe+(this.args.length?"("+o(this.args,function($t){return $t.toString(w)}).join(", ")+")":"");var ot=Le.priority>S.priority||Le.priority===S.priority&&(Le.class==="xfy"&&this.indicator!==S.indicator||Le.class==="yfx"&&this.indicator!==S.indicator||this.indicator===S.indicator&&Le.class==="yfx"&&y==="right"||this.indicator===S.indicator&&Le.class==="xfy"&&y==="left");Le.indicator=this.indicator;var dt=ot?"(":"",jt=ot?")":"";return this.args.length===0?"("+this.id+")":["fy","fx"].indexOf(Le.class)!==-1?dt+Pe+" "+this.args[0].toString(w,Le)+jt:["yf","xf"].indexOf(Le.class)!==-1?dt+this.args[0].toString(w,Le)+" "+Pe+jt:dt+this.args[0].toString(w,Le,"left")+" "+this.id+" "+this.args[1].toString(w,Le,"right")+jt}},Te.prototype.toString=function(w){return"<stream>("+this.id+")"},ke.prototype.toString=function(w){var S="{";for(var y in this.links)!this.links.hasOwnProperty(y)||(S!=="{"&&(S+=", "),S+=y+"/"+this.links[y].toString(w));return S+="}",S},xe.prototype.toString=function(w){return this.goal===null?"<"+this.substitution.toString(w)+">":"<"+this.goal.toString(w)+", "+this.substitution.toString(w)+">"},He.prototype.toString=function(w){return this.body?this.head.toString(w)+" :- "+this.body.toString(w)+".":this.head.toString(w)+"."},Re.prototype.toString=function(w){for(var S="",y=0;y<this.modules.length;y++)S+=":- use_module(library("+this.modules[y]+`)). -`;S+=` -`;for(key in this.rules)for(y=0;y<this.rules[key].length;y++)S+=this.rules[key][y].toString(w),S+=` -`;return S},we.prototype.clone=function(){return new we(this.id)},be.prototype.clone=function(){return new be(this.value,this.is_float)},H.prototype.clone=function(){return new H(this.id,o(this.args,function(w){return w.clone()}))},Te.prototype.clone=function(){return new Stram(this.stream,this.mode,this.alias,this.type,this.reposition,this.eof_action)},ke.prototype.clone=function(){var w={};for(var S in this.links)!this.links.hasOwnProperty(S)||(w[S]=this.links[S].clone());return new ke(w)},xe.prototype.clone=function(){return new xe(this.goal.clone(),this.substitution.clone(),this.parent)},He.prototype.clone=function(){return new He(this.head.clone(),this.body!==null?this.body.clone():null)},we.prototype.equals=function(w){return x.type.is_variable(w)&&this.id===w.id},be.prototype.equals=function(w){return x.type.is_number(w)&&this.value===w.value&&this.is_float===w.is_float},H.prototype.equals=function(w){if(!x.type.is_term(w)||this.indicator!==w.indicator)return!1;for(var S=0;S<this.args.length;S++)if(!this.args[S].equals(w.args[S]))return!1;return!0},Te.prototype.equals=function(w){return x.type.is_stream(w)&&this.id===w.id},ke.prototype.equals=function(w){var S;if(!x.type.is_substitution(w))return!1;for(S in this.links)if(!!this.links.hasOwnProperty(S)&&(!w.links[S]||!this.links[S].equals(w.links[S])))return!1;for(S in w.links)if(!!w.links.hasOwnProperty(S)&&!this.links[S])return!1;return!0},xe.prototype.equals=function(w){return x.type.is_state(w)&&this.goal.equals(w.goal)&&this.substitution.equals(w.substitution)&&this.parent===w.parent},He.prototype.equals=function(w){return x.type.is_rule(w)&&this.head.equals(w.head)&&(this.body===null&&w.body===null||this.body!==null&&this.body.equals(w.body))},we.prototype.rename=function(w){return w.get_free_variable(this)},be.prototype.rename=function(w){return this},H.prototype.rename=function(w){return new H(this.id,o(this.args,function(S){return S.rename(w)}))},Te.prototype.rename=function(w){return this},He.prototype.rename=function(w){return new He(this.head.rename(w),this.body!==null?this.body.rename(w):null)},we.prototype.variables=function(){return[this.id]},be.prototype.variables=function(){return[]},H.prototype.variables=function(){return[].concat.apply([],o(this.args,function(w){return w.variables()}))},Te.prototype.variables=function(){return[]},He.prototype.variables=function(){return this.body===null?this.head.variables():this.head.variables().concat(this.body.variables())},we.prototype.apply=function(w){return w.lookup(this.id)?w.lookup(this.id):this},be.prototype.apply=function(w){return this},H.prototype.apply=function(w){if(this.indicator==="./2"){for(var S=[],y=this;y.indicator==="./2";)S.push(y.args[0].apply(w)),y=y.args[1];for(var R=y.apply(w),J=S.length-1;J>=0;J--)R=new H(".",[S[J],R]);return R}return new H(this.id,o(this.args,function(X){return X.apply(w)}),this.ref)},Te.prototype.apply=function(w){return this},He.prototype.apply=function(w){return new He(this.head.apply(w),this.body!==null?this.body.apply(w):null)},ke.prototype.apply=function(w){var S,y={};for(S in this.links)!this.links.hasOwnProperty(S)||(y[S]=this.links[S].apply(w));return new ke(y)},H.prototype.select=function(){for(var w=this;w.indicator===",/2";)w=w.args[0];return w},H.prototype.replace=function(w){return this.indicator===",/2"?this.args[0].indicator===",/2"?new H(",",[this.args[0].replace(w),this.args[1]]):w===null?this.args[1]:new H(",",[w,this.args[1]]):w},H.prototype.search=function(w){if(x.type.is_term(w)&&w.ref!==void 0&&this.ref===w.ref)return!0;for(var S=0;S<this.args.length;S++)if(x.type.is_term(this.args[S])&&this.args[S].search(w))return!0;return!1},Re.prototype.get_current_input=function(){return this.current_input},ze.prototype.get_current_input=function(){return this.session.get_current_input()},Re.prototype.get_current_output=function(){return this.current_output},ze.prototype.get_current_output=function(){return this.session.get_current_output()},Re.prototype.set_current_input=function(w){this.current_input=w},ze.prototype.set_current_input=function(w){return this.session.set_current_input(w)},Re.prototype.set_current_output=function(w){this.current_input=w},ze.prototype.set_current_output=function(w){return this.session.set_current_output(w)},Re.prototype.get_stream_by_alias=function(w){return this.streams[w]},ze.prototype.get_stream_by_alias=function(w){return this.session.get_stream_by_alias(w)},Re.prototype.file_system_open=function(w,S,y){return this.file_system.open(w,S,y)},ze.prototype.file_system_open=function(w,S,y){return this.session.file_system_open(w,S,y)},Re.prototype.get_char_conversion=function(w){return this.__char_conversion[w]||w},ze.prototype.get_char_conversion=function(w){return this.session.get_char_conversion(w)},Re.prototype.parse=function(w){return this.thread.parse(w)},ze.prototype.parse=function(w){var S=new U(this);S.new_text(w);var y=S.get_tokens();if(y===null)return!1;var R=z(this,y,0,this.__get_max_priority(),!1);return R.len!==y.length?!1:{value:R.value,expr:R,tokens:y}},Re.prototype.get_flag=function(w){return this.flag[w]},ze.prototype.get_flag=function(w){return this.session.get_flag(w)},Re.prototype.add_rule=function(w,S){return S=S||{},S.from=S.from?S.from:"$tau-js",this.src_predicates[w.head.indicator]=S.from,this.rules[w.head.indicator]||(this.rules[w.head.indicator]=[]),this.rules[w.head.indicator].push(w),this.public_predicates.hasOwnProperty(w.head.indicator)||(this.public_predicates[w.head.indicator]=!1),!0},ze.prototype.add_rule=function(w,S){return this.session.add_rule(w,S)},Re.prototype.run_directive=function(w){this.thread.run_directive(w)},ze.prototype.run_directive=function(w){return x.type.is_directive(w)?(x.directive[w.indicator](this,w),!0):!1},Re.prototype.__get_max_priority=function(){return"1200"},ze.prototype.__get_max_priority=function(){return this.session.__get_max_priority()},Re.prototype.__get_next_priority=function(w){var S=0;w=parseInt(w);for(var y in this.__operators)if(!!this.__operators.hasOwnProperty(y)){var R=parseInt(y);R>S&&R<w&&(S=R)}return S.toString()},ze.prototype.__get_next_priority=function(w){return this.session.__get_next_priority(w)},Re.prototype.__lookup_operator_classes=function(w,S){return this.__operators.hasOwnProperty(w)&&this.__operators[w][S]instanceof Array&&this.__operators[w][S]||!1},ze.prototype.__lookup_operator_classes=function(w,S){return this.session.__lookup_operator_classes(w,S)},Re.prototype.lookup_operator=function(w,S){for(var y in this.__operators)if(this.__operators[y][w]){for(var R=0;R<this.__operators[y][w].length;R++)if(S===0||this.__operators[y][w][R].length===S+1)return{priority:y,class:this.__operators[y][w][R]}}return null},ze.prototype.lookup_operator=function(w,S){return this.session.lookup_operator(w,S)},Re.prototype.throw_warning=function(w){this.thread.throw_warning(w)},ze.prototype.throw_warning=function(w){this.warnings.push(w)},Re.prototype.get_warnings=function(){return this.thread.get_warnings()},ze.prototype.get_warnings=function(){return this.warnings},Re.prototype.add_goal=function(w,S){this.thread.add_goal(w,S)},ze.prototype.add_goal=function(w,S,y){y=y||null,S===!0&&(this.points=[]);for(var R=w.variables(),J={},X=0;X<R.length;X++)J[R[X]]=new we(R[X]);this.points.push(new xe(w,new ke(J),y))},Re.prototype.consult=function(w,S){return this.thread.consult(w,S)},ze.prototype.consult=function(w,S){var y="";if(typeof w=="string"){y=w;var R=y.length;if(y.substring(R-3,R)===".pl"&&document.getElementById(y)){var J=document.getElementById(y),X=J.getAttribute("type");X!==null&&X.replace(/ /g,"").toLowerCase()==="text/prolog"&&(y=J.text)}}else if(w.nodeName)switch(w.nodeName.toLowerCase()){case"input":case"textarea":y=w.value;break;default:y=w.innerHTML;break}else return!1;return this.warnings=[],ue(this,y,S)},Re.prototype.query=function(w){return this.thread.query(w)},ze.prototype.query=function(w){return this.points=[],this.debugger_points=[],ye(this,w)},Re.prototype.head_point=function(){return this.thread.head_point()},ze.prototype.head_point=function(){return this.points[this.points.length-1]},Re.prototype.get_free_variable=function(w){return this.thread.get_free_variable(w)},ze.prototype.get_free_variable=function(w){var S=[];if(w.id==="_"||this.session.renamed_variables[w.id]===void 0){for(this.session.rename++,this.points.length>0&&(S=this.head_point().substitution.domain());e(S,x.format_variable(this.session.rename))!==-1;)this.session.rename++;if(w.id==="_")return new we(x.format_variable(this.session.rename));this.session.renamed_variables[w.id]=x.format_variable(this.session.rename)}return new we(this.session.renamed_variables[w.id])},Re.prototype.next_free_variable=function(){return this.thread.next_free_variable()},ze.prototype.next_free_variable=function(){this.session.rename++;var w=[];for(this.points.length>0&&(w=this.head_point().substitution.domain());e(w,x.format_variable(this.session.rename))!==-1;)this.session.rename++;return new we(x.format_variable(this.session.rename))},Re.prototype.is_public_predicate=function(w){return!this.public_predicates.hasOwnProperty(w)||this.public_predicates[w]===!0},ze.prototype.is_public_predicate=function(w){return this.session.is_public_predicate(w)},Re.prototype.is_multifile_predicate=function(w){return this.multifile_predicates.hasOwnProperty(w)&&this.multifile_predicates[w]===!0},ze.prototype.is_multifile_predicate=function(w){return this.session.is_multifile_predicate(w)},Re.prototype.prepend=function(w){return this.thread.prepend(w)},ze.prototype.prepend=function(w){for(var S=w.length-1;S>=0;S--)this.points.push(w[S])},Re.prototype.success=function(w,S){return this.thread.success(w,S)},ze.prototype.success=function(w,y){var y=typeof y>"u"?w:y;this.prepend([new xe(w.goal.replace(null),w.substitution,y)])},Re.prototype.throw_error=function(w){return this.thread.throw_error(w)},ze.prototype.throw_error=function(w){this.prepend([new xe(new H("throw",[w]),new ke,null,null)])},Re.prototype.step_rule=function(w,S){return this.thread.step_rule(w,S)},ze.prototype.step_rule=function(w,S){var y=S.indicator;if(w==="user"&&(w=null),w===null&&this.session.rules.hasOwnProperty(y))return this.session.rules[y];for(var R=w===null?this.session.modules:e(this.session.modules,w)===-1?[]:[w],J=0;J<R.length;J++){var X=x.module[R[J]];if(X.rules.hasOwnProperty(y)&&(X.rules.hasOwnProperty(this.level)||X.exports_predicate(y)))return x.module[R[J]].rules[y]}return null},Re.prototype.step=function(){return this.thread.step()},ze.prototype.step=function(){if(this.points.length!==0){var w=!1,S=this.points.pop();if(this.debugger&&this.debugger_states.push(S),x.type.is_term(S.goal)){var y=S.goal.select(),R=null,J=[];if(y!==null){this.total_steps++;for(var X=S;X.parent!==null&&X.parent.goal.search(y);)X=X.parent;if(this.level=X.parent===null?"top_level/0":X.parent.goal.select().indicator,x.type.is_term(y)&&y.indicator===":/2"&&(R=y.args[0].id,y=y.args[1]),R===null&&x.type.is_builtin(y))this.__call_indicator=y.indicator,w=x.predicate[y.indicator](this,S,y);else{var Z=this.step_rule(R,y);if(Z===null)this.session.rules.hasOwnProperty(y.indicator)||(this.get_flag("unknown").id==="error"?this.throw_error(x.error.existence("procedure",y.indicator,this.level)):this.get_flag("unknown").id==="warning"&&this.throw_warning("unknown procedure "+y.indicator+" (from "+this.level+")"));else if(Z instanceof Function)w=Z(this,S,y);else{for(var ie in Z)if(!!Z.hasOwnProperty(ie)){var Pe=Z[ie];this.session.renamed_variables={},Pe=Pe.rename(this);var Le=this.get_flag("occurs_check").indicator==="true/0",ot=new xe,dt=x.unify(y,Pe.head,Le);dt!==null&&(ot.goal=S.goal.replace(Pe.body),ot.goal!==null&&(ot.goal=ot.goal.apply(dt)),ot.substitution=S.substitution.apply(dt),ot.parent=S,J.push(ot))}this.prepend(J)}}}}else x.type.is_variable(S.goal)?this.throw_error(x.error.instantiation(this.level)):this.throw_error(x.error.type("callable",S.goal,this.level));return w}},Re.prototype.answer=function(w){return this.thread.answer(w)},ze.prototype.answer=function(w){w=w||function(S){},this.__calls.push(w),!(this.__calls.length>1)&&this.again()},Re.prototype.answers=function(w,S,y){return this.thread.answers(w,S,y)},ze.prototype.answers=function(w,S,y){var R=S||1e3,J=this;if(S<=0){y&&y();return}this.answer(function(X){w(X),X!==!1?setTimeout(function(){J.answers(w,S-1,y)},1):y&&y()})},Re.prototype.again=function(w){return this.thread.again(w)},ze.prototype.again=function(w){for(var S,y=Date.now();this.__calls.length>0;){for(this.warnings=[],w!==!1&&(this.current_limit=this.session.limit);this.current_limit>0&&this.points.length>0&&this.head_point().goal!==null&&!x.type.is_error(this.head_point().goal);)if(this.current_limit--,this.step()===!0)return;var R=Date.now();this.cpu_time_last=R-y,this.cpu_time+=this.cpu_time_last;var J=this.__calls.shift();this.current_limit<=0?J(null):this.points.length===0?J(!1):x.type.is_error(this.head_point().goal)?(S=this.session.format_error(this.points.pop()),this.points=[],J(S)):(this.debugger&&this.debugger_states.push(this.head_point()),S=this.session.format_success(this.points.pop()),J(S))}},Re.prototype.unfold=function(w){if(w.body===null)return!1;var S=w.head,y=w.body,R=y.select(),J=new ze(this),X=[];J.add_goal(R),J.step();for(var Z=J.points.length-1;Z>=0;Z--){var ie=J.points[Z],Pe=S.apply(ie.substitution),Le=y.replace(ie.goal);Le!==null&&(Le=Le.apply(ie.substitution)),X.push(new He(Pe,Le))}var ot=this.rules[S.indicator],dt=e(ot,w);return X.length>0&&dt!==-1?(ot.splice.apply(ot,[dt,1].concat(X)),!0):!1},ze.prototype.unfold=function(w){return this.session.unfold(w)},we.prototype.interpret=function(w){return x.error.instantiation(w.level)},be.prototype.interpret=function(w){return this},H.prototype.interpret=function(w){return x.type.is_unitary_list(this)?this.args[0].interpret(w):x.operate(w,this)},we.prototype.compare=function(w){return this.id<w.id?-1:this.id>w.id?1:0},be.prototype.compare=function(w){if(this.value===w.value&&this.is_float===w.is_float)return 0;if(this.value<w.value||this.value===w.value&&this.is_float&&!w.is_float)return-1;if(this.value>w.value)return 1},H.prototype.compare=function(w){if(this.args.length<w.args.length||this.args.length===w.args.length&&this.id<w.id)return-1;if(this.args.length>w.args.length||this.args.length===w.args.length&&this.id>w.id)return 1;for(var S=0;S<this.args.length;S++){var y=x.compare(this.args[S],w.args[S]);if(y!==0)return y}return 0},ke.prototype.lookup=function(w){return this.links[w]?this.links[w]:null},ke.prototype.filter=function(w){var S={};for(var y in this.links)if(!!this.links.hasOwnProperty(y)){var R=this.links[y];w(y,R)&&(S[y]=R)}return new ke(S)},ke.prototype.exclude=function(w){var S={};for(var y in this.links)!this.links.hasOwnProperty(y)||e(w,y)===-1&&(S[y]=this.links[y]);return new ke(S)},ke.prototype.add=function(w,S){this.links[w]=S},ke.prototype.domain=function(w){var S=w===!0?function(J){return J}:function(J){return new we(J)},y=[];for(var R in this.links)y.push(S(R));return y},we.prototype.compile=function(){return'new pl.type.Var("'+this.id.toString()+'")'},be.prototype.compile=function(){return"new pl.type.Num("+this.value.toString()+", "+this.is_float.toString()+")"},H.prototype.compile=function(){return'new pl.type.Term("'+this.id.replace(/"/g,'\\"')+'", ['+o(this.args,function(w){return w.compile()})+"])"},He.prototype.compile=function(){return"new pl.type.Rule("+this.head.compile()+", "+(this.body===null?"null":this.body.compile())+")"},Re.prototype.compile=function(){var w,S=[],y;for(var R in this.rules)if(!!this.rules.hasOwnProperty(R)){var J=this.rules[R];y=[],w='"'+R+'": [';for(var X=0;X<J.length;X++)y.push(J[X].compile());w+=y.join(),w+="]",S.push(w)}return"{"+S.join()+"};"},we.prototype.toJavaScript=function(){},be.prototype.toJavaScript=function(){return this.value},H.prototype.toJavaScript=function(){if(this.args.length===0&&this.indicator!=="[]/0")return this.id;if(x.type.is_list(this)){for(var w=[],S=this,y;S.indicator==="./2";){if(y=S.args[0].toJavaScript(),y===void 0)return;w.push(y),S=S.args[1]}if(S.indicator==="[]/0")return w}},He.prototype.singleton_variables=function(){var w=this.head.variables(),S={},y=[];this.body!==null&&(w=w.concat(this.body.variables()));for(var R=0;R<w.length;R++)S[w[R]]===void 0&&(S[w[R]]=0),S[w[R]]++;for(var J in S)J!=="_"&&S[J]===1&&y.push(J);return y};var x={__env:typeof hl<"u"&&hl.exports?global:window,module:{},version:t,parser:{tokenizer:U,expression:z},utils:{str_indicator:ee,codePointAt:n,fromCodePoint:u},statistics:{getCountTerms:function(){return ht}},fromJavaScript:{test:{boolean:function(w){return w===!0||w===!1},number:function(w){return typeof w=="number"},string:function(w){return typeof w=="string"},list:function(w){return w instanceof Array},variable:function(w){return w===void 0},any:function(w){return!0}},conversion:{boolean:function(w){return new H(w?"true":"false",[])},number:function(w){return new be(w,w%1!==0)},string:function(w){return new H(w,[])},list:function(w){for(var S=[],y,R=0;R<w.length;R++){if(y=x.fromJavaScript.apply(w[R]),y===void 0)return;S.push(y)}return g(S)},variable:function(w){return new we("_")},any:function(w){}},apply:function(w){for(var S in x.fromJavaScript.test)if(S!=="any"&&x.fromJavaScript.test[S](w))return x.fromJavaScript.conversion[S](w);return x.fromJavaScript.conversion.any(w)}},type:{Var:we,Num:be,Term:H,Rule:He,State:xe,Stream:Te,Module:je,Thread:ze,Session:Re,Substitution:ke,order:[we,be,H,Te],compare:function(w,S){var y=e(x.type.order,w.constructor),R=e(x.type.order,S.constructor);if(y<R)return-1;if(y>R)return 1;if(w.constructor===be){if(w.is_float&&S.is_float)return 0;if(w.is_float)return-1;if(S.is_float)return 1}return 0},is_substitution:function(w){return w instanceof ke},is_state:function(w){return w instanceof xe},is_rule:function(w){return w instanceof He},is_variable:function(w){return w instanceof we},is_stream:function(w){return w instanceof Te},is_anonymous_var:function(w){return w instanceof we&&w.id==="_"},is_callable:function(w){return w instanceof H},is_number:function(w){return w instanceof be},is_integer:function(w){return w instanceof be&&!w.is_float},is_float:function(w){return w instanceof be&&w.is_float},is_term:function(w){return w instanceof H},is_atom:function(w){return w instanceof H&&w.args.length===0},is_ground:function(w){if(w instanceof we)return!1;if(w instanceof H){for(var S=0;S<w.args.length;S++)if(!x.type.is_ground(w.args[S]))return!1}return!0},is_atomic:function(w){return w instanceof H&&w.args.length===0||w instanceof be},is_compound:function(w){return w instanceof H&&w.args.length>0},is_list:function(w){return w instanceof H&&(w.indicator==="[]/0"||w.indicator==="./2")},is_empty_list:function(w){return w instanceof H&&w.indicator==="[]/0"},is_non_empty_list:function(w){return w instanceof H&&w.indicator==="./2"},is_fully_list:function(w){for(;w instanceof H&&w.indicator==="./2";)w=w.args[1];return w instanceof we||w instanceof H&&w.indicator==="[]/0"},is_instantiated_list:function(w){for(;w instanceof H&&w.indicator==="./2";)w=w.args[1];return w instanceof H&&w.indicator==="[]/0"},is_unitary_list:function(w){return w instanceof H&&w.indicator==="./2"&&w.args[1]instanceof H&&w.args[1].indicator==="[]/0"},is_character:function(w){return w instanceof H&&(w.id.length===1||w.id.length>0&&w.id.length<=2&&n(w.id,0)>=65536)},is_character_code:function(w){return w instanceof be&&!w.is_float&&w.value>=0&&w.value<=1114111},is_byte:function(w){return w instanceof be&&!w.is_float&&w.value>=0&&w.value<=255},is_operator:function(w){return w instanceof H&&x.arithmetic.evaluation[w.indicator]},is_directive:function(w){return w instanceof H&&x.directive[w.indicator]!==void 0},is_builtin:function(w){return w instanceof H&&x.predicate[w.indicator]!==void 0},is_error:function(w){return w instanceof H&&w.indicator==="throw/1"},is_predicate_indicator:function(w){return w instanceof H&&w.indicator==="//2"&&w.args[0]instanceof H&&w.args[0].args.length===0&&w.args[1]instanceof be&&w.args[1].is_float===!1},is_flag:function(w){return w instanceof H&&w.args.length===0&&x.flag[w.id]!==void 0},is_value_flag:function(w,S){if(!x.type.is_flag(w))return!1;for(var y in x.flag[w.id].allowed)if(!!x.flag[w.id].allowed.hasOwnProperty(y)&&x.flag[w.id].allowed[y].equals(S))return!0;return!1},is_io_mode:function(w){return x.type.is_atom(w)&&["read","write","append"].indexOf(w.id)!==-1},is_stream_option:function(w){return x.type.is_term(w)&&(w.indicator==="alias/1"&&x.type.is_atom(w.args[0])||w.indicator==="reposition/1"&&x.type.is_atom(w.args[0])&&(w.args[0].id==="true"||w.args[0].id==="false")||w.indicator==="type/1"&&x.type.is_atom(w.args[0])&&(w.args[0].id==="text"||w.args[0].id==="binary")||w.indicator==="eof_action/1"&&x.type.is_atom(w.args[0])&&(w.args[0].id==="error"||w.args[0].id==="eof_code"||w.args[0].id==="reset"))},is_stream_position:function(w){return x.type.is_integer(w)&&w.value>=0||x.type.is_atom(w)&&(w.id==="end_of_stream"||w.id==="past_end_of_stream")},is_stream_property:function(w){return x.type.is_term(w)&&(w.indicator==="input/0"||w.indicator==="output/0"||w.indicator==="alias/1"&&(x.type.is_variable(w.args[0])||x.type.is_atom(w.args[0]))||w.indicator==="file_name/1"&&(x.type.is_variable(w.args[0])||x.type.is_atom(w.args[0]))||w.indicator==="position/1"&&(x.type.is_variable(w.args[0])||x.type.is_stream_position(w.args[0]))||w.indicator==="reposition/1"&&(x.type.is_variable(w.args[0])||x.type.is_atom(w.args[0])&&(w.args[0].id==="true"||w.args[0].id==="false"))||w.indicator==="type/1"&&(x.type.is_variable(w.args[0])||x.type.is_atom(w.args[0])&&(w.args[0].id==="text"||w.args[0].id==="binary"))||w.indicator==="mode/1"&&(x.type.is_variable(w.args[0])||x.type.is_atom(w.args[0])&&(w.args[0].id==="read"||w.args[0].id==="write"||w.args[0].id==="append"))||w.indicator==="eof_action/1"&&(x.type.is_variable(w.args[0])||x.type.is_atom(w.args[0])&&(w.args[0].id==="error"||w.args[0].id==="eof_code"||w.args[0].id==="reset"))||w.indicator==="end_of_stream/1"&&(x.type.is_variable(w.args[0])||x.type.is_atom(w.args[0])&&(w.args[0].id==="at"||w.args[0].id==="past"||w.args[0].id==="not")))},is_streamable:function(w){return w.__proto__.stream!==void 0},is_read_option:function(w){return x.type.is_term(w)&&["variables/1","variable_names/1","singletons/1"].indexOf(w.indicator)!==-1},is_write_option:function(w){return x.type.is_term(w)&&(w.indicator==="quoted/1"&&x.type.is_atom(w.args[0])&&(w.args[0].id==="true"||w.args[0].id==="false")||w.indicator==="ignore_ops/1"&&x.type.is_atom(w.args[0])&&(w.args[0].id==="true"||w.args[0].id==="false")||w.indicator==="numbervars/1"&&x.type.is_atom(w.args[0])&&(w.args[0].id==="true"||w.args[0].id==="false"))},is_close_option:function(w){return x.type.is_term(w)&&w.indicator==="force/1"&&x.type.is_atom(w.args[0])&&(w.args[0].id==="true"||w.args[0].id==="false")},is_modifiable_flag:function(w){return x.type.is_flag(w)&&x.flag[w.id].changeable},is_module:function(w){return w instanceof H&&w.indicator==="library/1"&&w.args[0]instanceof H&&w.args[0].args.length===0&&x.module[w.args[0].id]!==void 0}},arithmetic:{evaluation:{"e/0":{type_args:null,type_result:!0,fn:function(w){return Math.E}},"pi/0":{type_args:null,type_result:!0,fn:function(w){return Math.PI}},"tau/0":{type_args:null,type_result:!0,fn:function(w){return 2*Math.PI}},"epsilon/0":{type_args:null,type_result:!0,fn:function(w){return Number.EPSILON}},"+/1":{type_args:null,type_result:null,fn:function(w,S){return w}},"-/1":{type_args:null,type_result:null,fn:function(w,S){return-w}},"\\/1":{type_args:!1,type_result:!1,fn:function(w,S){return~w}},"abs/1":{type_args:null,type_result:null,fn:function(w,S){return Math.abs(w)}},"sign/1":{type_args:null,type_result:null,fn:function(w,S){return Math.sign(w)}},"float_integer_part/1":{type_args:!0,type_result:!1,fn:function(w,S){return parseInt(w)}},"float_fractional_part/1":{type_args:!0,type_result:!0,fn:function(w,S){return w-parseInt(w)}},"float/1":{type_args:null,type_result:!0,fn:function(w,S){return parseFloat(w)}},"floor/1":{type_args:!0,type_result:!1,fn:function(w,S){return Math.floor(w)}},"truncate/1":{type_args:!0,type_result:!1,fn:function(w,S){return parseInt(w)}},"round/1":{type_args:!0,type_result:!1,fn:function(w,S){return Math.round(w)}},"ceiling/1":{type_args:!0,type_result:!1,fn:function(w,S){return Math.ceil(w)}},"sin/1":{type_args:null,type_result:!0,fn:function(w,S){return Math.sin(w)}},"cos/1":{type_args:null,type_result:!0,fn:function(w,S){return Math.cos(w)}},"tan/1":{type_args:null,type_result:!0,fn:function(w,S){return Math.tan(w)}},"asin/1":{type_args:null,type_result:!0,fn:function(w,S){return Math.asin(w)}},"acos/1":{type_args:null,type_result:!0,fn:function(w,S){return Math.acos(w)}},"atan/1":{type_args:null,type_result:!0,fn:function(w,S){return Math.atan(w)}},"atan2/2":{type_args:null,type_result:!0,fn:function(w,S,y){return Math.atan2(w,S)}},"exp/1":{type_args:null,type_result:!0,fn:function(w,S){return Math.exp(w)}},"sqrt/1":{type_args:null,type_result:!0,fn:function(w,S){return Math.sqrt(w)}},"log/1":{type_args:null,type_result:!0,fn:function(w,S){return w>0?Math.log(w):x.error.evaluation("undefined",S.__call_indicator)}},"+/2":{type_args:null,type_result:null,fn:function(w,S,y){return w+S}},"-/2":{type_args:null,type_result:null,fn:function(w,S,y){return w-S}},"*/2":{type_args:null,type_result:null,fn:function(w,S,y){return w*S}},"//2":{type_args:null,type_result:!0,fn:function(w,S,y){return S?w/S:x.error.evaluation("zero_division",y.__call_indicator)}},"///2":{type_args:!1,type_result:!1,fn:function(w,S,y){return S?parseInt(w/S):x.error.evaluation("zero_division",y.__call_indicator)}},"**/2":{type_args:null,type_result:!0,fn:function(w,S,y){return Math.pow(w,S)}},"^/2":{type_args:null,type_result:null,fn:function(w,S,y){return Math.pow(w,S)}},"<</2":{type_args:!1,type_result:!1,fn:function(w,S,y){return w<<S}},">>/2":{type_args:!1,type_result:!1,fn:function(w,S,y){return w>>S}},"/\\/2":{type_args:!1,type_result:!1,fn:function(w,S,y){return w&S}},"\\//2":{type_args:!1,type_result:!1,fn:function(w,S,y){return w|S}},"xor/2":{type_args:!1,type_result:!1,fn:function(w,S,y){return w^S}},"rem/2":{type_args:!1,type_result:!1,fn:function(w,S,y){return S?w%S:x.error.evaluation("zero_division",y.__call_indicator)}},"mod/2":{type_args:!1,type_result:!1,fn:function(w,S,y){return S?w-parseInt(w/S)*S:x.error.evaluation("zero_division",y.__call_indicator)}},"max/2":{type_args:null,type_result:null,fn:function(w,S,y){return Math.max(w,S)}},"min/2":{type_args:null,type_result:null,fn:function(w,S,y){return Math.min(w,S)}}}},directive:{"dynamic/1":function(w,S){var y=S.args[0];if(x.type.is_variable(y))w.throw_error(x.error.instantiation(S.indicator));else if(!x.type.is_compound(y)||y.indicator!=="//2")w.throw_error(x.error.type("predicate_indicator",y,S.indicator));else if(x.type.is_variable(y.args[0])||x.type.is_variable(y.args[1]))w.throw_error(x.error.instantiation(S.indicator));else if(!x.type.is_atom(y.args[0]))w.throw_error(x.error.type("atom",y.args[0],S.indicator));else if(!x.type.is_integer(y.args[1]))w.throw_error(x.error.type("integer",y.args[1],S.indicator));else{var R=S.args[0].args[0].id+"/"+S.args[0].args[1].value;w.session.public_predicates[R]=!0,w.session.rules[R]||(w.session.rules[R]=[])}},"multifile/1":function(w,S){var y=S.args[0];x.type.is_variable(y)?w.throw_error(x.error.instantiation(S.indicator)):!x.type.is_compound(y)||y.indicator!=="//2"?w.throw_error(x.error.type("predicate_indicator",y,S.indicator)):x.type.is_variable(y.args[0])||x.type.is_variable(y.args[1])?w.throw_error(x.error.instantiation(S.indicator)):x.type.is_atom(y.args[0])?x.type.is_integer(y.args[1])?w.session.multifile_predicates[S.args[0].args[0].id+"/"+S.args[0].args[1].value]=!0:w.throw_error(x.error.type("integer",y.args[1],S.indicator)):w.throw_error(x.error.type("atom",y.args[0],S.indicator))},"set_prolog_flag/2":function(w,S){var y=S.args[0],R=S.args[1];x.type.is_variable(y)||x.type.is_variable(R)?w.throw_error(x.error.instantiation(S.indicator)):x.type.is_atom(y)?x.type.is_flag(y)?x.type.is_value_flag(y,R)?x.type.is_modifiable_flag(y)?w.session.flag[y.id]=R:w.throw_error(x.error.permission("modify","flag",y)):w.throw_error(x.error.domain("flag_value",new H("+",[y,R]),S.indicator)):w.throw_error(x.error.domain("prolog_flag",y,S.indicator)):w.throw_error(x.error.type("atom",y,S.indicator))},"use_module/1":function(w,S){var y=S.args[0];if(x.type.is_variable(y))w.throw_error(x.error.instantiation(S.indicator));else if(!x.type.is_term(y))w.throw_error(x.error.type("term",y,S.indicator));else if(x.type.is_module(y)){var R=y.args[0].id;e(w.session.modules,R)===-1&&w.session.modules.push(R)}},"char_conversion/2":function(w,S){var y=S.args[0],R=S.args[1];x.type.is_variable(y)||x.type.is_variable(R)?w.throw_error(x.error.instantiation(S.indicator)):x.type.is_character(y)?x.type.is_character(R)?y.id===R.id?delete w.session.__char_conversion[y.id]:w.session.__char_conversion[y.id]=R.id:w.throw_error(x.error.type("character",R,S.indicator)):w.throw_error(x.error.type("character",y,S.indicator))},"op/3":function(w,S){var y=S.args[0],R=S.args[1],J=S.args[2];if(x.type.is_variable(y)||x.type.is_variable(R)||x.type.is_variable(J))w.throw_error(x.error.instantiation(S.indicator));else if(!x.type.is_integer(y))w.throw_error(x.error.type("integer",y,S.indicator));else if(!x.type.is_atom(R))w.throw_error(x.error.type("atom",R,S.indicator));else if(!x.type.is_atom(J))w.throw_error(x.error.type("atom",J,S.indicator));else if(y.value<0||y.value>1200)w.throw_error(x.error.domain("operator_priority",y,S.indicator));else if(J.id===",")w.throw_error(x.error.permission("modify","operator",J,S.indicator));else if(J.id==="|"&&(y.value<1001||R.id.length!==3))w.throw_error(x.error.permission("modify","operator",J,S.indicator));else if(["fy","fx","yf","xf","xfx","yfx","xfy"].indexOf(R.id)===-1)w.throw_error(x.error.domain("operator_specifier",R,S.indicator));else{var X={prefix:null,infix:null,postfix:null};for(var Z in w.session.__operators)if(!!w.session.__operators.hasOwnProperty(Z)){var ie=w.session.__operators[Z][J.id];ie&&(e(ie,"fx")!==-1&&(X.prefix={priority:Z,type:"fx"}),e(ie,"fy")!==-1&&(X.prefix={priority:Z,type:"fy"}),e(ie,"xf")!==-1&&(X.postfix={priority:Z,type:"xf"}),e(ie,"yf")!==-1&&(X.postfix={priority:Z,type:"yf"}),e(ie,"xfx")!==-1&&(X.infix={priority:Z,type:"xfx"}),e(ie,"xfy")!==-1&&(X.infix={priority:Z,type:"xfy"}),e(ie,"yfx")!==-1&&(X.infix={priority:Z,type:"yfx"}))}var Pe;switch(R.id){case"fy":case"fx":Pe="prefix";break;case"yf":case"xf":Pe="postfix";break;default:Pe="infix";break}if(((X.prefix&&Pe==="prefix"||X.postfix&&Pe==="postfix"||X.infix&&Pe==="infix")&&X[Pe].type!==R.id||X.infix&&Pe==="postfix"||X.postfix&&Pe==="infix")&&y.value!==0)w.throw_error(x.error.permission("create","operator",J,S.indicator));else return X[Pe]&&(Ee(w.session.__operators[X[Pe].priority][J.id],R.id),w.session.__operators[X[Pe].priority][J.id].length===0&&delete w.session.__operators[X[Pe].priority][J.id]),y.value>0&&(w.session.__operators[y.value]||(w.session.__operators[y.value.toString()]={}),w.session.__operators[y.value][J.id]||(w.session.__operators[y.value][J.id]=[]),w.session.__operators[y.value][J.id].push(R.id)),!0}}},predicate:{"op/3":function(w,S,y){x.directive["op/3"](w,y)&&w.success(S)},"current_op/3":function(w,S,y){var R=y.args[0],J=y.args[1],X=y.args[2],Z=[];for(var ie in w.session.__operators)for(var Pe in w.session.__operators[ie])for(var Le=0;Le<w.session.__operators[ie][Pe].length;Le++)Z.push(new xe(S.goal.replace(new H(",",[new H("=",[new be(ie,!1),R]),new H(",",[new H("=",[new H(w.session.__operators[ie][Pe][Le],[]),J]),new H("=",[new H(Pe,[]),X])])])),S.substitution,S));w.prepend(Z)},";/2":function(w,S,y){if(x.type.is_term(y.args[0])&&y.args[0].indicator==="->/2"){var R=w.points,J=w.session.format_success,X=w.session.format_error;w.session.format_success=function(Le){return Le.substitution},w.session.format_error=function(Le){return Le.goal},w.points=[new xe(y.args[0].args[0],S.substitution,S)];var Z=function(Le){w.points=R,w.session.format_success=J,w.session.format_error=X,Le===!1?w.prepend([new xe(S.goal.replace(y.args[1]),S.substitution,S)]):x.type.is_error(Le)?w.throw_error(Le.args[0]):Le===null?(w.prepend([S]),w.__calls.shift()(null)):w.prepend([new xe(S.goal.replace(y.args[0].args[1]).apply(Le),S.substitution.apply(Le),S)])};w.__calls.unshift(Z)}else{var ie=new xe(S.goal.replace(y.args[0]),S.substitution,S),Pe=new xe(S.goal.replace(y.args[1]),S.substitution,S);w.prepend([ie,Pe])}},"!/0":function(w,S,y){var R,J,X=[];for(R=S,J=null;R.parent!==null&&R.parent.goal.search(y);)if(J=R,R=R.parent,R.goal!==null){var Z=R.goal.select();if(Z&&Z.id==="call"&&Z.search(y)){R=J;break}}for(var ie=w.points.length-1;ie>=0;ie--){for(var Pe=w.points[ie],Le=Pe.parent;Le!==null&&Le!==R.parent;)Le=Le.parent;Le===null&&Le!==R.parent&&X.push(Pe)}w.points=X.reverse(),w.success(S)},"\\+/1":function(w,S,y){var R=y.args[0];x.type.is_variable(R)?w.throw_error(x.error.instantiation(w.level)):x.type.is_callable(R)?w.prepend([new xe(S.goal.replace(new H(",",[new H(",",[new H("call",[R]),new H("!",[])]),new H("fail",[])])),S.substitution,S),new xe(S.goal.replace(null),S.substitution,S)]):w.throw_error(x.error.type("callable",R,w.level))},"->/2":function(w,S,y){var R=S.goal.replace(new H(",",[y.args[0],new H(",",[new H("!"),y.args[1]])]));w.prepend([new xe(R,S.substitution,S)])},"fail/0":function(w,S,y){},"false/0":function(w,S,y){},"true/0":function(w,S,y){w.success(S)},"call/1":ne(1),"call/2":ne(2),"call/3":ne(3),"call/4":ne(4),"call/5":ne(5),"call/6":ne(6),"call/7":ne(7),"call/8":ne(8),"once/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H(",",[new H("call",[R]),new H("!",[])])),S.substitution,S)])},"forall/2":function(w,S,y){var R=y.args[0],J=y.args[1];w.prepend([new xe(S.goal.replace(new H("\\+",[new H(",",[new H("call",[R]),new H("\\+",[new H("call",[J])])])])),S.substitution,S)])},"repeat/0":function(w,S,y){w.prepend([new xe(S.goal.replace(null),S.substitution,S),S])},"throw/1":function(w,S,y){x.type.is_variable(y.args[0])?w.throw_error(x.error.instantiation(w.level)):w.throw_error(y.args[0])},"catch/3":function(w,S,y){var R=w.points;w.points=[],w.prepend([new xe(y.args[0],S.substitution,S)]);var J=w.session.format_success,X=w.session.format_error;w.session.format_success=function(ie){return ie.substitution},w.session.format_error=function(ie){return ie.goal};var Z=function(ie){var Pe=w.points;if(w.points=R,w.session.format_success=J,w.session.format_error=X,x.type.is_error(ie)){for(var Le=[],ot=w.points.length-1;ot>=0;ot--){for(var $t=w.points[ot],dt=$t.parent;dt!==null&&dt!==S.parent;)dt=dt.parent;dt===null&&dt!==S.parent&&Le.push($t)}w.points=Le;var jt=w.get_flag("occurs_check").indicator==="true/0",$t=new xe,xt=x.unify(ie.args[0],y.args[1],jt);xt!==null?($t.substitution=S.substitution.apply(xt),$t.goal=S.goal.replace(y.args[2]).apply(xt),$t.parent=S,w.prepend([$t])):w.throw_error(ie.args[0])}else if(ie!==!1){for(var an=ie===null?[]:[new xe(S.goal.apply(ie).replace(null),S.substitution.apply(ie),S)],kr=[],ot=Pe.length-1;ot>=0;ot--){kr.push(Pe[ot]);var mr=Pe[ot].goal!==null?Pe[ot].goal.select():null;if(x.type.is_term(mr)&&mr.indicator==="!/0")break}var xr=o(kr,function(Wr){return Wr.goal===null&&(Wr.goal=new H("true",[])),Wr=new xe(S.goal.replace(new H("catch",[Wr.goal,y.args[1],y.args[2]])),S.substitution.apply(Wr.substitution),Wr.parent),Wr.exclude=y.args[0].variables(),Wr}).reverse();w.prepend(xr),w.prepend(an),ie===null&&(this.current_limit=0,w.__calls.shift()(null))}};w.__calls.unshift(Z)},"=/2":function(w,S,y){var R=w.get_flag("occurs_check").indicator==="true/0",J=new xe,X=x.unify(y.args[0],y.args[1],R);X!==null&&(J.goal=S.goal.apply(X).replace(null),J.substitution=S.substitution.apply(X),J.parent=S,w.prepend([J]))},"unify_with_occurs_check/2":function(w,S,y){var R=new xe,J=x.unify(y.args[0],y.args[1],!0);J!==null&&(R.goal=S.goal.apply(J).replace(null),R.substitution=S.substitution.apply(J),R.parent=S,w.prepend([R]))},"\\=/2":function(w,S,y){var R=w.get_flag("occurs_check").indicator==="true/0",J=x.unify(y.args[0],y.args[1],R);J===null&&w.success(S)},"subsumes_term/2":function(w,S,y){var R=w.get_flag("occurs_check").indicator==="true/0",J=x.unify(y.args[1],y.args[0],R);J!==null&&y.args[1].apply(J).equals(y.args[1])&&w.success(S)},"findall/3":function(w,S,y){var R=y.args[0],J=y.args[1],X=y.args[2];if(x.type.is_variable(J))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_callable(J))w.throw_error(x.error.type("callable",J,y.indicator));else if(!x.type.is_variable(X)&&!x.type.is_list(X))w.throw_error(x.error.type("list",X,y.indicator));else{var Z=w.next_free_variable(),ie=new H(",",[J,new H("=",[Z,R])]),Pe=w.points,Le=w.session.limit,ot=w.session.format_success;w.session.format_success=function($t){return $t.substitution},w.add_goal(ie,!0,S);var dt=[],jt=function($t){if($t!==!1&&$t!==null&&!x.type.is_error($t))w.__calls.unshift(jt),dt.push($t.links[Z.id]),w.session.limit=w.current_limit;else if(w.points=Pe,w.session.limit=Le,w.session.format_success=ot,x.type.is_error($t))w.throw_error($t.args[0]);else if(w.current_limit>0){for(var xt=new H("[]"),an=dt.length-1;an>=0;an--)xt=new H(".",[dt[an],xt]);w.prepend([new xe(S.goal.replace(new H("=",[X,xt])),S.substitution,S)])}};w.__calls.unshift(jt)}},"bagof/3":function(w,S,y){var R,J=y.args[0],X=y.args[1],Z=y.args[2];if(x.type.is_variable(X))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_callable(X))w.throw_error(x.error.type("callable",X,y.indicator));else if(!x.type.is_variable(Z)&&!x.type.is_list(Z))w.throw_error(x.error.type("list",Z,y.indicator));else{var ie=w.next_free_variable(),Pe;X.indicator==="^/2"?(Pe=X.args[0].variables(),X=X.args[1]):Pe=[],Pe=Pe.concat(J.variables());for(var Le=X.variables().filter(function(xr){return e(Pe,xr)===-1}),ot=new H("[]"),dt=Le.length-1;dt>=0;dt--)ot=new H(".",[new we(Le[dt]),ot]);var jt=new H(",",[X,new H("=",[ie,new H(",",[ot,J])])]),$t=w.points,xt=w.session.limit,an=w.session.format_success;w.session.format_success=function(xr){return xr.substitution},w.add_goal(jt,!0,S);var kr=[],mr=function(xr){if(xr!==!1&&xr!==null&&!x.type.is_error(xr)){w.__calls.unshift(mr);var Wr=!1,Kn=xr.links[ie.id].args[0],Ns=xr.links[ie.id].args[1];for(var Ti in kr)if(!!kr.hasOwnProperty(Ti)){var ps=kr[Ti];if(ps.variables.equals(Kn)){ps.answers.push(Ns),Wr=!0;break}}Wr||kr.push({variables:Kn,answers:[Ns]}),w.session.limit=w.current_limit}else if(w.points=$t,w.session.limit=xt,w.session.format_success=an,x.type.is_error(xr))w.throw_error(xr.args[0]);else if(w.current_limit>0){for(var io=[],Si=0;Si<kr.length;Si++){xr=kr[Si].answers;for(var Os=new H("[]"),so=xr.length-1;so>=0;so--)Os=new H(".",[xr[so],Os]);io.push(new xe(S.goal.replace(new H(",",[new H("=",[ot,kr[Si].variables]),new H("=",[Z,Os])])),S.substitution,S))}w.prepend(io)}};w.__calls.unshift(mr)}},"setof/3":function(w,S,y){var R,J=y.args[0],X=y.args[1],Z=y.args[2];if(x.type.is_variable(X))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_callable(X))w.throw_error(x.error.type("callable",X,y.indicator));else if(!x.type.is_variable(Z)&&!x.type.is_list(Z))w.throw_error(x.error.type("list",Z,y.indicator));else{var ie=w.next_free_variable(),Pe;X.indicator==="^/2"?(Pe=X.args[0].variables(),X=X.args[1]):Pe=[],Pe=Pe.concat(J.variables());for(var Le=X.variables().filter(function(xr){return e(Pe,xr)===-1}),ot=new H("[]"),dt=Le.length-1;dt>=0;dt--)ot=new H(".",[new we(Le[dt]),ot]);var jt=new H(",",[X,new H("=",[ie,new H(",",[ot,J])])]),$t=w.points,xt=w.session.limit,an=w.session.format_success;w.session.format_success=function(xr){return xr.substitution},w.add_goal(jt,!0,S);var kr=[],mr=function(xr){if(xr!==!1&&xr!==null&&!x.type.is_error(xr)){w.__calls.unshift(mr);var Wr=!1,Kn=xr.links[ie.id].args[0],Ns=xr.links[ie.id].args[1];for(var Ti in kr)if(!!kr.hasOwnProperty(Ti)){var ps=kr[Ti];if(ps.variables.equals(Kn)){ps.answers.push(Ns),Wr=!0;break}}Wr||kr.push({variables:Kn,answers:[Ns]}),w.session.limit=w.current_limit}else if(w.points=$t,w.session.limit=xt,w.session.format_success=an,x.type.is_error(xr))w.throw_error(xr.args[0]);else if(w.current_limit>0){for(var io=[],Si=0;Si<kr.length;Si++){xr=kr[Si].answers.sort(x.compare);for(var Os=new H("[]"),so=xr.length-1;so>=0;so--)Os=new H(".",[xr[so],Os]);io.push(new xe(S.goal.replace(new H(",",[new H("=",[ot,kr[Si].variables]),new H("=",[Z,Os])])),S.substitution,S))}w.prepend(io)}};w.__calls.unshift(mr)}},"functor/3":function(w,S,y){var R,J=y.args[0],X=y.args[1],Z=y.args[2];if(x.type.is_variable(J)&&(x.type.is_variable(X)||x.type.is_variable(Z)))w.throw_error(x.error.instantiation("functor/3"));else if(!x.type.is_variable(Z)&&!x.type.is_integer(Z))w.throw_error(x.error.type("integer",y.args[2],"functor/3"));else if(!x.type.is_variable(X)&&!x.type.is_atomic(X))w.throw_error(x.error.type("atomic",y.args[1],"functor/3"));else if(x.type.is_integer(X)&&x.type.is_integer(Z)&&Z.value!==0)w.throw_error(x.error.type("atom",y.args[1],"functor/3"));else if(x.type.is_variable(J)){if(y.args[2].value>=0){for(var ie=[],Pe=0;Pe<Z.value;Pe++)ie.push(w.next_free_variable());var Le=x.type.is_integer(X)?X:new H(X.id,ie);w.prepend([new xe(S.goal.replace(new H("=",[J,Le])),S.substitution,S)])}}else{var ot=x.type.is_integer(J)?J:new H(J.id,[]),dt=x.type.is_integer(J)?new be(0,!1):new be(J.args.length,!1),jt=new H(",",[new H("=",[ot,X]),new H("=",[dt,Z])]);w.prepend([new xe(S.goal.replace(jt),S.substitution,S)])}},"arg/3":function(w,S,y){if(x.type.is_variable(y.args[0])||x.type.is_variable(y.args[1]))w.throw_error(x.error.instantiation(y.indicator));else if(y.args[0].value<0)w.throw_error(x.error.domain("not_less_than_zero",y.args[0],y.indicator));else if(!x.type.is_compound(y.args[1]))w.throw_error(x.error.type("compound",y.args[1],y.indicator));else{var R=y.args[0].value;if(R>0&&R<=y.args[1].args.length){var J=new H("=",[y.args[1].args[R-1],y.args[2]]);w.prepend([new xe(S.goal.replace(J),S.substitution,S)])}}},"=../2":function(w,S,y){var R;if(x.type.is_variable(y.args[0])&&(x.type.is_variable(y.args[1])||x.type.is_non_empty_list(y.args[1])&&x.type.is_variable(y.args[1].args[0])))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_fully_list(y.args[1]))w.throw_error(x.error.type("list",y.args[1],y.indicator));else if(x.type.is_variable(y.args[0])){if(!x.type.is_variable(y.args[1])){var X=[];for(R=y.args[1].args[1];R.indicator==="./2";)X.push(R.args[0]),R=R.args[1];x.type.is_variable(y.args[0])&&x.type.is_variable(R)?w.throw_error(x.error.instantiation(y.indicator)):X.length===0&&x.type.is_compound(y.args[1].args[0])?w.throw_error(x.error.type("atomic",y.args[1].args[0],y.indicator)):X.length>0&&(x.type.is_compound(y.args[1].args[0])||x.type.is_number(y.args[1].args[0]))?w.throw_error(x.error.type("atom",y.args[1].args[0],y.indicator)):X.length===0?w.prepend([new xe(S.goal.replace(new H("=",[y.args[1].args[0],y.args[0]],S)),S.substitution,S)]):w.prepend([new xe(S.goal.replace(new H("=",[new H(y.args[1].args[0].id,X),y.args[0]])),S.substitution,S)])}}else{if(x.type.is_atomic(y.args[0]))R=new H(".",[y.args[0],new H("[]")]);else{R=new H("[]");for(var J=y.args[0].args.length-1;J>=0;J--)R=new H(".",[y.args[0].args[J],R]);R=new H(".",[new H(y.args[0].id),R])}w.prepend([new xe(S.goal.replace(new H("=",[R,y.args[1]])),S.substitution,S)])}},"copy_term/2":function(w,S,y){var R=y.args[0].rename(w);w.prepend([new xe(S.goal.replace(new H("=",[R,y.args[1]])),S.substitution,S.parent)])},"term_variables/2":function(w,S,y){var R=y.args[0],J=y.args[1];if(!x.type.is_fully_list(J))w.throw_error(x.error.type("list",J,y.indicator));else{var X=g(o(De(R.variables()),function(Z){return new we(Z)}));w.prepend([new xe(S.goal.replace(new H("=",[J,X])),S.substitution,S)])}},"clause/2":function(w,S,y){if(x.type.is_variable(y.args[0]))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_callable(y.args[0]))w.throw_error(x.error.type("callable",y.args[0],y.indicator));else if(!x.type.is_variable(y.args[1])&&!x.type.is_callable(y.args[1]))w.throw_error(x.error.type("callable",y.args[1],y.indicator));else if(w.session.rules[y.args[0].indicator]!==void 0)if(w.is_public_predicate(y.args[0].indicator)){var R=[];for(var J in w.session.rules[y.args[0].indicator])if(!!w.session.rules[y.args[0].indicator].hasOwnProperty(J)){var X=w.session.rules[y.args[0].indicator][J];w.session.renamed_variables={},X=X.rename(w),X.body===null&&(X.body=new H("true"));var Z=new H(",",[new H("=",[X.head,y.args[0]]),new H("=",[X.body,y.args[1]])]);R.push(new xe(S.goal.replace(Z),S.substitution,S))}w.prepend(R)}else w.throw_error(x.error.permission("access","private_procedure",y.args[0].indicator,y.indicator))},"current_predicate/1":function(w,S,y){var R=y.args[0];if(!x.type.is_variable(R)&&(!x.type.is_compound(R)||R.indicator!=="//2"))w.throw_error(x.error.type("predicate_indicator",R,y.indicator));else if(!x.type.is_variable(R)&&!x.type.is_variable(R.args[0])&&!x.type.is_atom(R.args[0]))w.throw_error(x.error.type("atom",R.args[0],y.indicator));else if(!x.type.is_variable(R)&&!x.type.is_variable(R.args[1])&&!x.type.is_integer(R.args[1]))w.throw_error(x.error.type("integer",R.args[1],y.indicator));else{var J=[];for(var X in w.session.rules)if(!!w.session.rules.hasOwnProperty(X)){var Z=X.lastIndexOf("/"),ie=X.substr(0,Z),Pe=parseInt(X.substr(Z+1,X.length-(Z+1))),Le=new H("/",[new H(ie),new be(Pe,!1)]),ot=new H("=",[Le,R]);J.push(new xe(S.goal.replace(ot),S.substitution,S))}w.prepend(J)}},"asserta/1":function(w,S,y){if(x.type.is_variable(y.args[0]))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_callable(y.args[0]))w.throw_error(x.error.type("callable",y.args[0],y.indicator));else{var R,J;y.args[0].indicator===":-/2"?(R=y.args[0].args[0],J=Fe(y.args[0].args[1])):(R=y.args[0],J=null),x.type.is_callable(R)?J!==null&&!x.type.is_callable(J)?w.throw_error(x.error.type("callable",J,y.indicator)):w.is_public_predicate(R.indicator)?(w.session.rules[R.indicator]===void 0&&(w.session.rules[R.indicator]=[]),w.session.public_predicates[R.indicator]=!0,w.session.rules[R.indicator]=[new He(R,J,!0)].concat(w.session.rules[R.indicator]),w.success(S)):w.throw_error(x.error.permission("modify","static_procedure",R.indicator,y.indicator)):w.throw_error(x.error.type("callable",R,y.indicator))}},"assertz/1":function(w,S,y){if(x.type.is_variable(y.args[0]))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_callable(y.args[0]))w.throw_error(x.error.type("callable",y.args[0],y.indicator));else{var R,J;y.args[0].indicator===":-/2"?(R=y.args[0].args[0],J=Fe(y.args[0].args[1])):(R=y.args[0],J=null),x.type.is_callable(R)?J!==null&&!x.type.is_callable(J)?w.throw_error(x.error.type("callable",J,y.indicator)):w.is_public_predicate(R.indicator)?(w.session.rules[R.indicator]===void 0&&(w.session.rules[R.indicator]=[]),w.session.public_predicates[R.indicator]=!0,w.session.rules[R.indicator].push(new He(R,J,!0)),w.success(S)):w.throw_error(x.error.permission("modify","static_procedure",R.indicator,y.indicator)):w.throw_error(x.error.type("callable",R,y.indicator))}},"retract/1":function(w,S,y){if(x.type.is_variable(y.args[0]))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_callable(y.args[0]))w.throw_error(x.error.type("callable",y.args[0],y.indicator));else{var R,J;if(y.args[0].indicator===":-/2"?(R=y.args[0].args[0],J=y.args[0].args[1]):(R=y.args[0],J=new H("true")),typeof S.retract>"u")if(w.is_public_predicate(R.indicator)){if(w.session.rules[R.indicator]!==void 0){for(var X=[],Z=0;Z<w.session.rules[R.indicator].length;Z++){w.session.renamed_variables={};var ie=w.session.rules[R.indicator][Z],Pe=ie.rename(w);Pe.body===null&&(Pe.body=new H("true",[]));var Le=w.get_flag("occurs_check").indicator==="true/0",ot=x.unify(new H(",",[R,J]),new H(",",[Pe.head,Pe.body]),Le);if(ot!==null){var dt=new xe(S.goal.replace(new H(",",[new H("retract",[new H(":-",[R,J])]),new H(",",[new H("=",[R,Pe.head]),new H("=",[J,Pe.body])])])),S.substitution,S);dt.retract=ie,X.push(dt)}}w.prepend(X)}}else w.throw_error(x.error.permission("modify","static_procedure",R.indicator,y.indicator));else ce(w,S,R.indicator,S.retract)}},"retractall/1":function(w,S,y){var R=y.args[0];x.type.is_variable(R)?w.throw_error(x.error.instantiation(y.indicator)):x.type.is_callable(R)?w.prepend([new xe(S.goal.replace(new H(",",[new H("retract",[new x.type.Term(":-",[R,new we("_")])]),new H("fail",[])])),S.substitution,S),new xe(S.goal.replace(null),S.substitution,S)]):w.throw_error(x.error.type("callable",R,y.indicator))},"abolish/1":function(w,S,y){if(x.type.is_variable(y.args[0])||x.type.is_term(y.args[0])&&y.args[0].indicator==="//2"&&(x.type.is_variable(y.args[0].args[0])||x.type.is_variable(y.args[0].args[1])))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_term(y.args[0])||y.args[0].indicator!=="//2")w.throw_error(x.error.type("predicate_indicator",y.args[0],y.indicator));else if(!x.type.is_atom(y.args[0].args[0]))w.throw_error(x.error.type("atom",y.args[0].args[0],y.indicator));else if(!x.type.is_integer(y.args[0].args[1]))w.throw_error(x.error.type("integer",y.args[0].args[1],y.indicator));else if(y.args[0].args[1].value<0)w.throw_error(x.error.domain("not_less_than_zero",y.args[0].args[1],y.indicator));else if(x.type.is_number(w.get_flag("max_arity"))&&y.args[0].args[1].value>w.get_flag("max_arity").value)w.throw_error(x.error.representation("max_arity",y.indicator));else{var R=y.args[0].args[0].id+"/"+y.args[0].args[1].value;w.is_public_predicate(R)?(delete w.session.rules[R],w.success(S)):w.throw_error(x.error.permission("modify","static_procedure",R,y.indicator))}},"atom_length/2":function(w,S,y){if(x.type.is_variable(y.args[0]))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_atom(y.args[0]))w.throw_error(x.error.type("atom",y.args[0],y.indicator));else if(!x.type.is_variable(y.args[1])&&!x.type.is_integer(y.args[1]))w.throw_error(x.error.type("integer",y.args[1],y.indicator));else if(x.type.is_integer(y.args[1])&&y.args[1].value<0)w.throw_error(x.error.domain("not_less_than_zero",y.args[1],y.indicator));else{var R=new be(y.args[0].id.length,!1);w.prepend([new xe(S.goal.replace(new H("=",[R,y.args[1]])),S.substitution,S)])}},"atom_concat/3":function(w,S,y){var R,J,X=y.args[0],Z=y.args[1],ie=y.args[2];if(x.type.is_variable(ie)&&(x.type.is_variable(X)||x.type.is_variable(Z)))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_variable(X)&&!x.type.is_atom(X))w.throw_error(x.error.type("atom",X,y.indicator));else if(!x.type.is_variable(Z)&&!x.type.is_atom(Z))w.throw_error(x.error.type("atom",Z,y.indicator));else if(!x.type.is_variable(ie)&&!x.type.is_atom(ie))w.throw_error(x.error.type("atom",ie,y.indicator));else{var Pe=x.type.is_variable(X),Le=x.type.is_variable(Z);if(!Pe&&!Le)J=new H("=",[ie,new H(X.id+Z.id)]),w.prepend([new xe(S.goal.replace(J),S.substitution,S)]);else if(Pe&&!Le)R=ie.id.substr(0,ie.id.length-Z.id.length),R+Z.id===ie.id&&(J=new H("=",[X,new H(R)]),w.prepend([new xe(S.goal.replace(J),S.substitution,S)]));else if(Le&&!Pe)R=ie.id.substr(X.id.length),X.id+R===ie.id&&(J=new H("=",[Z,new H(R)]),w.prepend([new xe(S.goal.replace(J),S.substitution,S)]));else{for(var ot=[],dt=0;dt<=ie.id.length;dt++){var jt=new H(ie.id.substr(0,dt)),$t=new H(ie.id.substr(dt));J=new H(",",[new H("=",[jt,X]),new H("=",[$t,Z])]),ot.push(new xe(S.goal.replace(J),S.substitution,S))}w.prepend(ot)}}},"sub_atom/5":function(w,S,y){var R,J=y.args[0],X=y.args[1],Z=y.args[2],ie=y.args[3],Pe=y.args[4];if(x.type.is_variable(J))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_variable(X)&&!x.type.is_integer(X))w.throw_error(x.error.type("integer",X,y.indicator));else if(!x.type.is_variable(Z)&&!x.type.is_integer(Z))w.throw_error(x.error.type("integer",Z,y.indicator));else if(!x.type.is_variable(ie)&&!x.type.is_integer(ie))w.throw_error(x.error.type("integer",ie,y.indicator));else if(x.type.is_integer(X)&&X.value<0)w.throw_error(x.error.domain("not_less_than_zero",X,y.indicator));else if(x.type.is_integer(Z)&&Z.value<0)w.throw_error(x.error.domain("not_less_than_zero",Z,y.indicator));else if(x.type.is_integer(ie)&&ie.value<0)w.throw_error(x.error.domain("not_less_than_zero",ie,y.indicator));else{var Le=[],ot=[],dt=[];if(x.type.is_variable(X))for(R=0;R<=J.id.length;R++)Le.push(R);else Le.push(X.value);if(x.type.is_variable(Z))for(R=0;R<=J.id.length;R++)ot.push(R);else ot.push(Z.value);if(x.type.is_variable(ie))for(R=0;R<=J.id.length;R++)dt.push(R);else dt.push(ie.value);var jt=[];for(var $t in Le)if(!!Le.hasOwnProperty($t)){R=Le[$t];for(var xt in ot)if(!!ot.hasOwnProperty(xt)){var an=ot[xt],kr=J.id.length-R-an;if(e(dt,kr)!==-1&&R+an+kr===J.id.length){var mr=J.id.substr(R,an);if(J.id===J.id.substr(0,R)+mr+J.id.substr(R+an,kr)){var xr=new H("=",[new H(mr),Pe]),Wr=new H("=",[X,new be(R)]),Kn=new H("=",[Z,new be(an)]),Ns=new H("=",[ie,new be(kr)]),Ti=new H(",",[new H(",",[new H(",",[Wr,Kn]),Ns]),xr]);jt.push(new xe(S.goal.replace(Ti),S.substitution,S))}}}}w.prepend(jt)}},"atom_chars/2":function(w,S,y){var R=y.args[0],J=y.args[1];if(x.type.is_variable(R)&&x.type.is_variable(J))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_variable(R)&&!x.type.is_atom(R))w.throw_error(x.error.type("atom",R,y.indicator));else if(x.type.is_variable(R)){for(var ie=J,Pe=x.type.is_variable(R),Le="";ie.indicator==="./2";){if(x.type.is_character(ie.args[0]))Le+=ie.args[0].id;else if(x.type.is_variable(ie.args[0])&&Pe){w.throw_error(x.error.instantiation(y.indicator));return}else if(!x.type.is_variable(ie.args[0])){w.throw_error(x.error.type("character",ie.args[0],y.indicator));return}ie=ie.args[1]}x.type.is_variable(ie)&&Pe?w.throw_error(x.error.instantiation(y.indicator)):!x.type.is_empty_list(ie)&&!x.type.is_variable(ie)?w.throw_error(x.error.type("list",J,y.indicator)):w.prepend([new xe(S.goal.replace(new H("=",[new H(Le),R])),S.substitution,S)])}else{for(var X=new H("[]"),Z=R.id.length-1;Z>=0;Z--)X=new H(".",[new H(R.id.charAt(Z)),X]);w.prepend([new xe(S.goal.replace(new H("=",[J,X])),S.substitution,S)])}},"atom_codes/2":function(w,S,y){var R=y.args[0],J=y.args[1];if(x.type.is_variable(R)&&x.type.is_variable(J))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_variable(R)&&!x.type.is_atom(R))w.throw_error(x.error.type("atom",R,y.indicator));else if(x.type.is_variable(R)){for(var ie=J,Pe=x.type.is_variable(R),Le="";ie.indicator==="./2";){if(x.type.is_character_code(ie.args[0]))Le+=u(ie.args[0].value);else if(x.type.is_variable(ie.args[0])&&Pe){w.throw_error(x.error.instantiation(y.indicator));return}else if(!x.type.is_variable(ie.args[0])){w.throw_error(x.error.representation("character_code",y.indicator));return}ie=ie.args[1]}x.type.is_variable(ie)&&Pe?w.throw_error(x.error.instantiation(y.indicator)):!x.type.is_empty_list(ie)&&!x.type.is_variable(ie)?w.throw_error(x.error.type("list",J,y.indicator)):w.prepend([new xe(S.goal.replace(new H("=",[new H(Le),R])),S.substitution,S)])}else{for(var X=new H("[]"),Z=R.id.length-1;Z>=0;Z--)X=new H(".",[new be(n(R.id,Z),!1),X]);w.prepend([new xe(S.goal.replace(new H("=",[J,X])),S.substitution,S)])}},"char_code/2":function(w,S,y){var R=y.args[0],J=y.args[1];if(x.type.is_variable(R)&&x.type.is_variable(J))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_variable(R)&&!x.type.is_character(R))w.throw_error(x.error.type("character",R,y.indicator));else if(!x.type.is_variable(J)&&!x.type.is_integer(J))w.throw_error(x.error.type("integer",J,y.indicator));else if(!x.type.is_variable(J)&&!x.type.is_character_code(J))w.throw_error(x.error.representation("character_code",y.indicator));else if(x.type.is_variable(J)){var X=new be(n(R.id,0),!1);w.prepend([new xe(S.goal.replace(new H("=",[X,J])),S.substitution,S)])}else{var Z=new H(u(J.value));w.prepend([new xe(S.goal.replace(new H("=",[Z,R])),S.substitution,S)])}},"number_chars/2":function(w,S,y){var R,J=y.args[0],X=y.args[1];if(x.type.is_variable(J)&&x.type.is_variable(X))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_variable(J)&&!x.type.is_number(J))w.throw_error(x.error.type("number",J,y.indicator));else if(!x.type.is_variable(X)&&!x.type.is_list(X))w.throw_error(x.error.type("list",X,y.indicator));else{var Z=x.type.is_variable(J);if(!x.type.is_variable(X)){var ie=X,Pe=!0;for(R="";ie.indicator==="./2";){if(x.type.is_character(ie.args[0]))R+=ie.args[0].id;else if(x.type.is_variable(ie.args[0]))Pe=!1;else if(!x.type.is_variable(ie.args[0])){w.throw_error(x.error.type("character",ie.args[0],y.indicator));return}ie=ie.args[1]}if(Pe=Pe&&x.type.is_empty_list(ie),!x.type.is_empty_list(ie)&&!x.type.is_variable(ie)){w.throw_error(x.error.type("list",X,y.indicator));return}if(!Pe&&Z){w.throw_error(x.error.instantiation(y.indicator));return}else if(Pe)if(x.type.is_variable(ie)&&Z){w.throw_error(x.error.instantiation(y.indicator));return}else{var Le=w.parse(R),ot=Le.value;!x.type.is_number(ot)||Le.tokens[Le.tokens.length-1].space?w.throw_error(x.error.syntax_by_predicate("parseable_number",y.indicator)):w.prepend([new xe(S.goal.replace(new H("=",[J,ot])),S.substitution,S)]);return}}if(!Z){R=J.toString();for(var dt=new H("[]"),jt=R.length-1;jt>=0;jt--)dt=new H(".",[new H(R.charAt(jt)),dt]);w.prepend([new xe(S.goal.replace(new H("=",[X,dt])),S.substitution,S)])}}},"number_codes/2":function(w,S,y){var R,J=y.args[0],X=y.args[1];if(x.type.is_variable(J)&&x.type.is_variable(X))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_variable(J)&&!x.type.is_number(J))w.throw_error(x.error.type("number",J,y.indicator));else if(!x.type.is_variable(X)&&!x.type.is_list(X))w.throw_error(x.error.type("list",X,y.indicator));else{var Z=x.type.is_variable(J);if(!x.type.is_variable(X)){var ie=X,Pe=!0;for(R="";ie.indicator==="./2";){if(x.type.is_character_code(ie.args[0]))R+=u(ie.args[0].value);else if(x.type.is_variable(ie.args[0]))Pe=!1;else if(!x.type.is_variable(ie.args[0])){w.throw_error(x.error.type("character_code",ie.args[0],y.indicator));return}ie=ie.args[1]}if(Pe=Pe&&x.type.is_empty_list(ie),!x.type.is_empty_list(ie)&&!x.type.is_variable(ie)){w.throw_error(x.error.type("list",X,y.indicator));return}if(!Pe&&Z){w.throw_error(x.error.instantiation(y.indicator));return}else if(Pe)if(x.type.is_variable(ie)&&Z){w.throw_error(x.error.instantiation(y.indicator));return}else{var Le=w.parse(R),ot=Le.value;!x.type.is_number(ot)||Le.tokens[Le.tokens.length-1].space?w.throw_error(x.error.syntax_by_predicate("parseable_number",y.indicator)):w.prepend([new xe(S.goal.replace(new H("=",[J,ot])),S.substitution,S)]);return}}if(!Z){R=J.toString();for(var dt=new H("[]"),jt=R.length-1;jt>=0;jt--)dt=new H(".",[new be(n(R,jt),!1),dt]);w.prepend([new xe(S.goal.replace(new H("=",[X,dt])),S.substitution,S)])}}},"upcase_atom/2":function(w,S,y){var R=y.args[0],J=y.args[1];x.type.is_variable(R)?w.throw_error(x.error.instantiation(y.indicator)):x.type.is_atom(R)?!x.type.is_variable(J)&&!x.type.is_atom(J)?w.throw_error(x.error.type("atom",J,y.indicator)):w.prepend([new xe(S.goal.replace(new H("=",[J,new H(R.id.toUpperCase(),[])])),S.substitution,S)]):w.throw_error(x.error.type("atom",R,y.indicator))},"downcase_atom/2":function(w,S,y){var R=y.args[0],J=y.args[1];x.type.is_variable(R)?w.throw_error(x.error.instantiation(y.indicator)):x.type.is_atom(R)?!x.type.is_variable(J)&&!x.type.is_atom(J)?w.throw_error(x.error.type("atom",J,y.indicator)):w.prepend([new xe(S.goal.replace(new H("=",[J,new H(R.id.toLowerCase(),[])])),S.substitution,S)]):w.throw_error(x.error.type("atom",R,y.indicator))},"atomic_list_concat/2":function(w,S,y){var R=y.args[0],J=y.args[1];w.prepend([new xe(S.goal.replace(new H("atomic_list_concat",[R,new H("",[]),J])),S.substitution,S)])},"atomic_list_concat/3":function(w,S,y){var R=y.args[0],J=y.args[1],X=y.args[2];if(x.type.is_variable(J)||x.type.is_variable(R)&&x.type.is_variable(X))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_variable(R)&&!x.type.is_list(R))w.throw_error(x.error.type("list",R,y.indicator));else if(!x.type.is_variable(X)&&!x.type.is_atom(X))w.throw_error(x.error.type("atom",X,y.indicator));else if(x.type.is_variable(X)){for(var ie="",Pe=R;x.type.is_term(Pe)&&Pe.indicator==="./2";){if(!x.type.is_atom(Pe.args[0])&&!x.type.is_number(Pe.args[0])){w.throw_error(x.error.type("atomic",Pe.args[0],y.indicator));return}ie!==""&&(ie+=J.id),x.type.is_atom(Pe.args[0])?ie+=Pe.args[0].id:ie+=""+Pe.args[0].value,Pe=Pe.args[1]}ie=new H(ie,[]),x.type.is_variable(Pe)?w.throw_error(x.error.instantiation(y.indicator)):!x.type.is_term(Pe)||Pe.indicator!=="[]/0"?w.throw_error(x.error.type("list",R,y.indicator)):w.prepend([new xe(S.goal.replace(new H("=",[ie,X])),S.substitution,S)])}else{var Z=g(o(X.id.split(J.id),function(Le){return new H(Le,[])}));w.prepend([new xe(S.goal.replace(new H("=",[Z,R])),S.substitution,S)])}},"@=</2":function(w,S,y){x.compare(y.args[0],y.args[1])<=0&&w.success(S)},"==/2":function(w,S,y){x.compare(y.args[0],y.args[1])===0&&w.success(S)},"\\==/2":function(w,S,y){x.compare(y.args[0],y.args[1])!==0&&w.success(S)},"@</2":function(w,S,y){x.compare(y.args[0],y.args[1])<0&&w.success(S)},"@>/2":function(w,S,y){x.compare(y.args[0],y.args[1])>0&&w.success(S)},"@>=/2":function(w,S,y){x.compare(y.args[0],y.args[1])>=0&&w.success(S)},"compare/3":function(w,S,y){var R=y.args[0],J=y.args[1],X=y.args[2];if(!x.type.is_variable(R)&&!x.type.is_atom(R))w.throw_error(x.error.type("atom",R,y.indicator));else if(x.type.is_atom(R)&&["<",">","="].indexOf(R.id)===-1)w.throw_error(x.type.domain("order",R,y.indicator));else{var Z=x.compare(J,X);Z=Z===0?"=":Z===-1?"<":">",w.prepend([new xe(S.goal.replace(new H("=",[R,new H(Z,[])])),S.substitution,S)])}},"is/2":function(w,S,y){var R=y.args[1].interpret(w);x.type.is_number(R)?w.prepend([new xe(S.goal.replace(new H("=",[y.args[0],R],w.level)),S.substitution,S)]):w.throw_error(R)},"between/3":function(w,S,y){var R=y.args[0],J=y.args[1],X=y.args[2];if(x.type.is_variable(R)||x.type.is_variable(J))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_integer(R))w.throw_error(x.error.type("integer",R,y.indicator));else if(!x.type.is_integer(J))w.throw_error(x.error.type("integer",J,y.indicator));else if(!x.type.is_variable(X)&&!x.type.is_integer(X))w.throw_error(x.error.type("integer",X,y.indicator));else if(x.type.is_variable(X)){var Z=[new xe(S.goal.replace(new H("=",[X,R])),S.substitution,S)];R.value<J.value&&Z.push(new xe(S.goal.replace(new H("between",[new be(R.value+1,!1),J,X])),S.substitution,S)),w.prepend(Z)}else R.value<=X.value&&J.value>=X.value&&w.success(S)},"succ/2":function(w,S,y){var R=y.args[0],J=y.args[1];x.type.is_variable(R)&&x.type.is_variable(J)?w.throw_error(x.error.instantiation(y.indicator)):!x.type.is_variable(R)&&!x.type.is_integer(R)?w.throw_error(x.error.type("integer",R,y.indicator)):!x.type.is_variable(J)&&!x.type.is_integer(J)?w.throw_error(x.error.type("integer",J,y.indicator)):!x.type.is_variable(R)&&R.value<0?w.throw_error(x.error.domain("not_less_than_zero",R,y.indicator)):!x.type.is_variable(J)&&J.value<0?w.throw_error(x.error.domain("not_less_than_zero",J,y.indicator)):(x.type.is_variable(J)||J.value>0)&&(x.type.is_variable(R)?w.prepend([new xe(S.goal.replace(new H("=",[R,new be(J.value-1,!1)])),S.substitution,S)]):w.prepend([new xe(S.goal.replace(new H("=",[J,new be(R.value+1,!1)])),S.substitution,S)]))},"=:=/2":function(w,S,y){var R=x.arithmetic_compare(w,y.args[0],y.args[1]);x.type.is_term(R)?w.throw_error(R):R===0&&w.success(S)},"=\\=/2":function(w,S,y){var R=x.arithmetic_compare(w,y.args[0],y.args[1]);x.type.is_term(R)?w.throw_error(R):R!==0&&w.success(S)},"</2":function(w,S,y){var R=x.arithmetic_compare(w,y.args[0],y.args[1]);x.type.is_term(R)?w.throw_error(R):R<0&&w.success(S)},"=</2":function(w,S,y){var R=x.arithmetic_compare(w,y.args[0],y.args[1]);x.type.is_term(R)?w.throw_error(R):R<=0&&w.success(S)},">/2":function(w,S,y){var R=x.arithmetic_compare(w,y.args[0],y.args[1]);x.type.is_term(R)?w.throw_error(R):R>0&&w.success(S)},">=/2":function(w,S,y){var R=x.arithmetic_compare(w,y.args[0],y.args[1]);x.type.is_term(R)?w.throw_error(R):R>=0&&w.success(S)},"var/1":function(w,S,y){x.type.is_variable(y.args[0])&&w.success(S)},"atom/1":function(w,S,y){x.type.is_atom(y.args[0])&&w.success(S)},"atomic/1":function(w,S,y){x.type.is_atomic(y.args[0])&&w.success(S)},"compound/1":function(w,S,y){x.type.is_compound(y.args[0])&&w.success(S)},"integer/1":function(w,S,y){x.type.is_integer(y.args[0])&&w.success(S)},"float/1":function(w,S,y){x.type.is_float(y.args[0])&&w.success(S)},"number/1":function(w,S,y){x.type.is_number(y.args[0])&&w.success(S)},"nonvar/1":function(w,S,y){x.type.is_variable(y.args[0])||w.success(S)},"ground/1":function(w,S,y){y.variables().length===0&&w.success(S)},"acyclic_term/1":function(w,S,y){for(var R=S.substitution.apply(S.substitution),J=y.args[0].variables(),X=0;X<J.length;X++)if(S.substitution.links[J[X]]!==void 0&&!S.substitution.links[J[X]].equals(R.links[J[X]]))return;w.success(S)},"callable/1":function(w,S,y){x.type.is_callable(y.args[0])&&w.success(S)},"is_list/1":function(w,S,y){for(var R=y.args[0];x.type.is_term(R)&&R.indicator==="./2";)R=R.args[1];x.type.is_term(R)&&R.indicator==="[]/0"&&w.success(S)},"current_input/1":function(w,S,y){var R=y.args[0];!x.type.is_variable(R)&&!x.type.is_stream(R)&&!x.type.is_atom(R)?w.throw_error(x.error.domain("stream",R,y.indicator)):(x.type.is_atom(R)&&w.get_stream_by_alias(R.id)&&(R=w.get_stream_by_alias(R.id)),w.prepend([new xe(S.goal.replace(new H("=",[R,w.get_current_input()])),S.substitution,S)]))},"current_output/1":function(w,S,y){var R=y.args[0];!x.type.is_variable(R)&&!x.type.is_stream(R)&&!x.type.is_atom(R)?w.throw_error(x.error.domain("stream_or_alias",R,y.indicator)):(x.type.is_atom(R)&&w.get_stream_by_alias(R.id)&&(R=w.get_stream_by_alias(R.id)),w.prepend([new xe(S.goal.replace(new H("=",[R,w.get_current_output()])),S.substitution,S)]))},"set_input/1":function(w,S,y){var R=y.args[0],J=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);x.type.is_variable(R)?w.throw_error(x.error.instantiation(y.indicator)):!x.type.is_variable(R)&&!x.type.is_stream(R)&&!x.type.is_atom(R)?w.throw_error(x.error.domain("stream_or_alias",R,y.indicator)):x.type.is_stream(J)?J.output===!0?w.throw_error(x.error.permission("input","stream",R,y.indicator)):(w.set_current_input(J),w.success(S)):w.throw_error(x.error.existence("stream",R,y.indicator))},"set_output/1":function(w,S,y){var R=y.args[0],J=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);x.type.is_variable(R)?w.throw_error(x.error.instantiation(y.indicator)):!x.type.is_variable(R)&&!x.type.is_stream(R)&&!x.type.is_atom(R)?w.throw_error(x.error.domain("stream_or_alias",R,y.indicator)):x.type.is_stream(J)?J.input===!0?w.throw_error(x.error.permission("output","stream",R,y.indicator)):(w.set_current_output(J),w.success(S)):w.throw_error(x.error.existence("stream",R,y.indicator))},"open/3":function(w,S,y){var R=y.args[0],J=y.args[1],X=y.args[2];w.prepend([new xe(S.goal.replace(new H("open",[R,J,X,new H("[]",[])])),S.substitution,S)])},"open/4":function(w,S,y){var R=y.args[0],J=y.args[1],X=y.args[2],Z=y.args[3];if(x.type.is_variable(R)||x.type.is_variable(J))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_variable(J)&&!x.type.is_atom(J))w.throw_error(x.error.type("atom",J,y.indicator));else if(!x.type.is_list(Z))w.throw_error(x.error.type("list",Z,y.indicator));else if(!x.type.is_variable(X))w.throw_error(x.error.type("variable",X,y.indicator));else if(!x.type.is_atom(R)&&!x.type.is_streamable(R))w.throw_error(x.error.domain("source_sink",R,y.indicator));else if(!x.type.is_io_mode(J))w.throw_error(x.error.domain("io_mode",J,y.indicator));else{for(var ie={},Pe=Z,Le;x.type.is_term(Pe)&&Pe.indicator==="./2";){if(Le=Pe.args[0],x.type.is_variable(Le)){w.throw_error(x.error.instantiation(y.indicator));return}else if(!x.type.is_stream_option(Le)){w.throw_error(x.error.domain("stream_option",Le,y.indicator));return}ie[Le.id]=Le.args[0].id,Pe=Pe.args[1]}if(Pe.indicator!=="[]/0"){x.type.is_variable(Pe)?w.throw_error(x.error.instantiation(y.indicator)):w.throw_error(x.error.type("list",Z,y.indicator));return}else{var ot=ie.alias;if(ot&&w.get_stream_by_alias(ot)){w.throw_error(x.error.permission("open","source_sink",new H("alias",[new H(ot,[])]),y.indicator));return}ie.type||(ie.type="text");var dt;if(x.type.is_atom(R)?dt=w.file_system_open(R.id,ie.type,J.id):dt=R.stream(ie.type,J.id),dt===!1){w.throw_error(x.error.permission("open","source_sink",R,y.indicator));return}else if(dt===null){w.throw_error(x.error.existence("source_sink",R,y.indicator));return}var jt=new Te(dt,J.id,ie.alias,ie.type,ie.reposition==="true",ie.eof_action);ot?w.session.streams[ot]=jt:w.session.streams[jt.id]=jt,w.prepend([new xe(S.goal.replace(new H("=",[X,jt])),S.substitution,S)])}}},"close/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H("close",[R,new H("[]",[])])),S.substitution,S)])},"close/2":function(w,S,y){var R=y.args[0],J=y.args[1],X=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);if(x.type.is_variable(R)||x.type.is_variable(J))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_list(J))w.throw_error(x.error.type("list",J,y.indicator));else if(!x.type.is_stream(R)&&!x.type.is_atom(R))w.throw_error(x.error.domain("stream_or_alias",R,y.indicator));else if(!x.type.is_stream(X)||X.stream===null)w.throw_error(x.error.existence("stream",R,y.indicator));else{for(var Z={},ie=J,Pe;x.type.is_term(ie)&&ie.indicator==="./2";){if(Pe=ie.args[0],x.type.is_variable(Pe)){w.throw_error(x.error.instantiation(y.indicator));return}else if(!x.type.is_close_option(Pe)){w.throw_error(x.error.domain("close_option",Pe,y.indicator));return}Z[Pe.id]=Pe.args[0].id==="true",ie=ie.args[1]}if(ie.indicator!=="[]/0"){x.type.is_variable(ie)?w.throw_error(x.error.instantiation(y.indicator)):w.throw_error(x.error.type("list",J,y.indicator));return}else{if(X===w.session.standard_input||X===w.session.standard_output){w.success(S);return}else X===w.session.current_input?w.session.current_input=w.session.standard_input:X===w.session.current_output&&(w.session.current_output=w.session.current_output);X.alias!==null?delete w.session.streams[X.alias]:delete w.session.streams[X.id],X.output&&X.stream.flush();var Le=X.stream.close();X.stream=null,(Z.force===!0||Le===!0)&&w.success(S)}}},"flush_output/0":function(w,S,y){w.prepend([new xe(S.goal.replace(new H(",",[new H("current_output",[new we("S")]),new H("flush_output",[new we("S")])])),S.substitution,S)])},"flush_output/1":function(w,S,y){var R=y.args[0],J=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);x.type.is_variable(R)?w.throw_error(x.error.instantiation(y.indicator)):!x.type.is_stream(R)&&!x.type.is_atom(R)?w.throw_error(x.error.domain("stream_or_alias",R,y.indicator)):!x.type.is_stream(J)||J.stream===null?w.throw_error(x.error.existence("stream",R,y.indicator)):R.input===!0?w.throw_error(x.error.permission("output","stream",output,y.indicator)):(J.stream.flush(),w.success(S))},"stream_property/2":function(w,S,y){var R=y.args[0],J=y.args[1],X=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);if(!x.type.is_variable(R)&&!x.type.is_stream(R)&&!x.type.is_atom(R))w.throw_error(x.error.domain("stream_or_alias",R,y.indicator));else if(!x.type.is_variable(R)&&(!x.type.is_stream(X)||X.stream===null))w.throw_error(x.error.existence("stream",R,y.indicator));else if(!x.type.is_variable(J)&&!x.type.is_stream_property(J))w.throw_error(x.error.domain("stream_property",J,y.indicator));else{var Z=[],ie=[];if(!x.type.is_variable(R))Z.push(X);else for(var Pe in w.session.streams)Z.push(w.session.streams[Pe]);for(var Le=0;Le<Z.length;Le++){var ot=[];Z[Le].filename&&ot.push(new H("file_name",[new H(Z[Le].file_name,[])])),ot.push(new H("mode",[new H(Z[Le].mode,[])])),ot.push(new H(Z[Le].input?"input":"output",[])),Z[Le].alias&&ot.push(new H("alias",[new H(Z[Le].alias,[])])),ot.push(new H("position",[typeof Z[Le].position=="number"?new be(Z[Le].position,!1):new H(Z[Le].position,[])])),ot.push(new H("end_of_stream",[new H(Z[Le].position==="end_of_stream"?"at":Z[Le].position==="past_end_of_stream"?"past":"not",[])])),ot.push(new H("eof_action",[new H(Z[Le].eof_action,[])])),ot.push(new H("reposition",[new H(Z[Le].reposition?"true":"false",[])])),ot.push(new H("type",[new H(Z[Le].type,[])]));for(var dt=0;dt<ot.length;dt++)ie.push(new xe(S.goal.replace(new H(",",[new H("=",[x.type.is_variable(R)?R:X,Z[Le]]),new H("=",[J,ot[dt]])])),S.substitution,S))}w.prepend(ie)}},"at_end_of_stream/0":function(w,S,y){w.prepend([new xe(S.goal.replace(new H(",",[new H("current_input",[new we("S")]),new H(",",[new H("stream_property",[new we("S"),new H("end_of_stream",[new we("E")])]),new H(",",[new H("!",[]),new H(";",[new H("=",[new we("E"),new H("at",[])]),new H("=",[new we("E"),new H("past",[])])])])])])),S.substitution,S)])},"at_end_of_stream/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H(",",[new H("stream_property",[R,new H("end_of_stream",[new we("E")])]),new H(",",[new H("!",[]),new H(";",[new H("=",[new we("E"),new H("at",[])]),new H("=",[new we("E"),new H("past",[])])])])])),S.substitution,S)])},"set_stream_position/2":function(w,S,y){var R=y.args[0],J=y.args[1],X=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);x.type.is_variable(R)||x.type.is_variable(J)?w.throw_error(x.error.instantiation(y.indicator)):!x.type.is_stream(R)&&!x.type.is_atom(R)?w.throw_error(x.error.domain("stream_or_alias",R,y.indicator)):!x.type.is_stream(X)||X.stream===null?w.throw_error(x.error.existence("stream",R,y.indicator)):x.type.is_stream_position(J)?X.reposition===!1?w.throw_error(x.error.permission("reposition","stream",R,y.indicator)):(x.type.is_integer(J)?X.position=J.value:X.position=J.id,w.success(S)):w.throw_error(x.error.domain("stream_position",J,y.indicator))},"get_char/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H(",",[new H("current_input",[new we("S")]),new H("get_char",[new we("S"),R])])),S.substitution,S)])},"get_char/2":function(w,S,y){var R=y.args[0],J=y.args[1],X=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);if(x.type.is_variable(R))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_variable(J)&&!x.type.is_character(J))w.throw_error(x.error.type("in_character",J,y.indicator));else if(!x.type.is_stream(R)&&!x.type.is_atom(R))w.throw_error(x.error.domain("stream_or_alias",R,y.indicator));else if(!x.type.is_stream(X)||X.stream===null)w.throw_error(x.error.existence("stream",R,y.indicator));else if(X.output)w.throw_error(x.error.permission("input","stream",R,y.indicator));else if(X.type==="binary")w.throw_error(x.error.permission("input","binary_stream",R,y.indicator));else if(X.position==="past_end_of_stream"&&X.eof_action==="error")w.throw_error(x.error.permission("input","past_end_of_stream",R,y.indicator));else{var Z;if(X.position==="end_of_stream")Z="end_of_file",X.position="past_end_of_stream";else{if(Z=X.stream.get(1,X.position),Z===null){w.throw_error(x.error.representation("character",y.indicator));return}X.position++}w.prepend([new xe(S.goal.replace(new H("=",[new H(Z,[]),J])),S.substitution,S)])}},"get_code/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H(",",[new H("current_input",[new we("S")]),new H("get_code",[new we("S"),R])])),S.substitution,S)])},"get_code/2":function(w,S,y){var R=y.args[0],J=y.args[1],X=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);if(x.type.is_variable(R))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_variable(J)&&!x.type.is_integer(J))w.throw_error(x.error.type("integer",char,y.indicator));else if(!x.type.is_variable(R)&&!x.type.is_stream(R)&&!x.type.is_atom(R))w.throw_error(x.error.domain("stream_or_alias",R,y.indicator));else if(!x.type.is_stream(X)||X.stream===null)w.throw_error(x.error.existence("stream",R,y.indicator));else if(X.output)w.throw_error(x.error.permission("input","stream",R,y.indicator));else if(X.type==="binary")w.throw_error(x.error.permission("input","binary_stream",R,y.indicator));else if(X.position==="past_end_of_stream"&&X.eof_action==="error")w.throw_error(x.error.permission("input","past_end_of_stream",R,y.indicator));else{var Z;if(X.position==="end_of_stream")Z=-1,X.position="past_end_of_stream";else{if(Z=X.stream.get(1,X.position),Z===null){w.throw_error(x.error.representation("character",y.indicator));return}Z=n(Z,0),X.position++}w.prepend([new xe(S.goal.replace(new H("=",[new be(Z,!1),J])),S.substitution,S)])}},"peek_char/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H(",",[new H("current_input",[new we("S")]),new H("peek_char",[new we("S"),R])])),S.substitution,S)])},"peek_char/2":function(w,S,y){var R=y.args[0],J=y.args[1],X=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);if(x.type.is_variable(R))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_variable(J)&&!x.type.is_character(J))w.throw_error(x.error.type("in_character",J,y.indicator));else if(!x.type.is_stream(R)&&!x.type.is_atom(R))w.throw_error(x.error.domain("stream_or_alias",R,y.indicator));else if(!x.type.is_stream(X)||X.stream===null)w.throw_error(x.error.existence("stream",R,y.indicator));else if(X.output)w.throw_error(x.error.permission("input","stream",R,y.indicator));else if(X.type==="binary")w.throw_error(x.error.permission("input","binary_stream",R,y.indicator));else if(X.position==="past_end_of_stream"&&X.eof_action==="error")w.throw_error(x.error.permission("input","past_end_of_stream",R,y.indicator));else{var Z;if(X.position==="end_of_stream")Z="end_of_file",X.position="past_end_of_stream";else if(Z=X.stream.get(1,X.position),Z===null){w.throw_error(x.error.representation("character",y.indicator));return}w.prepend([new xe(S.goal.replace(new H("=",[new H(Z,[]),J])),S.substitution,S)])}},"peek_code/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H(",",[new H("current_input",[new we("S")]),new H("peek_code",[new we("S"),R])])),S.substitution,S)])},"peek_code/2":function(w,S,y){var R=y.args[0],J=y.args[1],X=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);if(x.type.is_variable(R))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_variable(J)&&!x.type.is_integer(J))w.throw_error(x.error.type("integer",char,y.indicator));else if(!x.type.is_variable(R)&&!x.type.is_stream(R)&&!x.type.is_atom(R))w.throw_error(x.error.domain("stream_or_alias",R,y.indicator));else if(!x.type.is_stream(X)||X.stream===null)w.throw_error(x.error.existence("stream",R,y.indicator));else if(X.output)w.throw_error(x.error.permission("input","stream",R,y.indicator));else if(X.type==="binary")w.throw_error(x.error.permission("input","binary_stream",R,y.indicator));else if(X.position==="past_end_of_stream"&&X.eof_action==="error")w.throw_error(x.error.permission("input","past_end_of_stream",R,y.indicator));else{var Z;if(X.position==="end_of_stream")Z=-1,X.position="past_end_of_stream";else{if(Z=X.stream.get(1,X.position),Z===null){w.throw_error(x.error.representation("character",y.indicator));return}Z=n(Z,0)}w.prepend([new xe(S.goal.replace(new H("=",[new be(Z,!1),J])),S.substitution,S)])}},"put_char/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H(",",[new H("current_output",[new we("S")]),new H("put_char",[new we("S"),R])])),S.substitution,S)])},"put_char/2":function(w,S,y){var R=y.args[0],J=y.args[1],X=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);x.type.is_variable(R)||x.type.is_variable(J)?w.throw_error(x.error.instantiation(y.indicator)):x.type.is_character(J)?!x.type.is_variable(R)&&!x.type.is_stream(R)&&!x.type.is_atom(R)?w.throw_error(x.error.domain("stream_or_alias",R,y.indicator)):!x.type.is_stream(X)||X.stream===null?w.throw_error(x.error.existence("stream",R,y.indicator)):X.input?w.throw_error(x.error.permission("output","stream",R,y.indicator)):X.type==="binary"?w.throw_error(x.error.permission("output","binary_stream",R,y.indicator)):X.stream.put(J.id,X.position)&&(typeof X.position=="number"&&X.position++,w.success(S)):w.throw_error(x.error.type("character",J,y.indicator))},"put_code/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H(",",[new H("current_output",[new we("S")]),new H("put_code",[new we("S"),R])])),S.substitution,S)])},"put_code/2":function(w,S,y){var R=y.args[0],J=y.args[1],X=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);x.type.is_variable(R)||x.type.is_variable(J)?w.throw_error(x.error.instantiation(y.indicator)):x.type.is_integer(J)?x.type.is_character_code(J)?!x.type.is_variable(R)&&!x.type.is_stream(R)&&!x.type.is_atom(R)?w.throw_error(x.error.domain("stream_or_alias",R,y.indicator)):!x.type.is_stream(X)||X.stream===null?w.throw_error(x.error.existence("stream",R,y.indicator)):X.input?w.throw_error(x.error.permission("output","stream",R,y.indicator)):X.type==="binary"?w.throw_error(x.error.permission("output","binary_stream",R,y.indicator)):X.stream.put_char(u(J.value),X.position)&&(typeof X.position=="number"&&X.position++,w.success(S)):w.throw_error(x.error.representation("character_code",y.indicator)):w.throw_error(x.error.type("integer",J,y.indicator))},"nl/0":function(w,S,y){w.prepend([new xe(S.goal.replace(new H(",",[new H("current_output",[new we("S")]),new H("put_char",[new we("S"),new H(` -`,[])])])),S.substitution,S)])},"nl/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H("put_char",[R,new H(` -`,[])])),S.substitution,S)])},"get_byte/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H(",",[new H("current_input",[new we("S")]),new H("get_byte",[new we("S"),R])])),S.substitution,S)])},"get_byte/2":function(w,S,y){var R=y.args[0],J=y.args[1],X=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);if(x.type.is_variable(R))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_variable(J)&&!x.type.is_byte(J))w.throw_error(x.error.type("in_byte",char,y.indicator));else if(!x.type.is_stream(R)&&!x.type.is_atom(R))w.throw_error(x.error.domain("stream_or_alias",R,y.indicator));else if(!x.type.is_stream(X)||X.stream===null)w.throw_error(x.error.existence("stream",R,y.indicator));else if(X.output)w.throw_error(x.error.permission("input","stream",R,y.indicator));else if(X.type==="text")w.throw_error(x.error.permission("input","text_stream",R,y.indicator));else if(X.position==="past_end_of_stream"&&X.eof_action==="error")w.throw_error(x.error.permission("input","past_end_of_stream",R,y.indicator));else{var Z;if(X.position==="end_of_stream")Z="end_of_file",X.position="past_end_of_stream";else{if(Z=X.stream.get_byte(X.position),Z===null){w.throw_error(x.error.representation("byte",y.indicator));return}X.position++}w.prepend([new xe(S.goal.replace(new H("=",[new be(Z,!1),J])),S.substitution,S)])}},"peek_byte/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H(",",[new H("current_input",[new we("S")]),new H("peek_byte",[new we("S"),R])])),S.substitution,S)])},"peek_byte/2":function(w,S,y){var R=y.args[0],J=y.args[1],X=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);if(x.type.is_variable(R))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_variable(J)&&!x.type.is_byte(J))w.throw_error(x.error.type("in_byte",char,y.indicator));else if(!x.type.is_stream(R)&&!x.type.is_atom(R))w.throw_error(x.error.domain("stream_or_alias",R,y.indicator));else if(!x.type.is_stream(X)||X.stream===null)w.throw_error(x.error.existence("stream",R,y.indicator));else if(X.output)w.throw_error(x.error.permission("input","stream",R,y.indicator));else if(X.type==="text")w.throw_error(x.error.permission("input","text_stream",R,y.indicator));else if(X.position==="past_end_of_stream"&&X.eof_action==="error")w.throw_error(x.error.permission("input","past_end_of_stream",R,y.indicator));else{var Z;if(X.position==="end_of_stream")Z="end_of_file",X.position="past_end_of_stream";else if(Z=X.stream.get_byte(X.position),Z===null){w.throw_error(x.error.representation("byte",y.indicator));return}w.prepend([new xe(S.goal.replace(new H("=",[new be(Z,!1),J])),S.substitution,S)])}},"put_byte/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H(",",[new H("current_output",[new we("S")]),new H("put_byte",[new we("S"),R])])),S.substitution,S)])},"put_byte/2":function(w,S,y){var R=y.args[0],J=y.args[1],X=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);x.type.is_variable(R)||x.type.is_variable(J)?w.throw_error(x.error.instantiation(y.indicator)):x.type.is_byte(J)?!x.type.is_variable(R)&&!x.type.is_stream(R)&&!x.type.is_atom(R)?w.throw_error(x.error.domain("stream_or_alias",R,y.indicator)):!x.type.is_stream(X)||X.stream===null?w.throw_error(x.error.existence("stream",R,y.indicator)):X.input?w.throw_error(x.error.permission("output","stream",R,y.indicator)):X.type==="text"?w.throw_error(x.error.permission("output","text_stream",R,y.indicator)):X.stream.put_byte(J.value,X.position)&&(typeof X.position=="number"&&X.position++,w.success(S)):w.throw_error(x.error.type("byte",J,y.indicator))},"read/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H(",",[new H("current_input",[new we("S")]),new H("read_term",[new we("S"),R,new H("[]",[])])])),S.substitution,S)])},"read/2":function(w,S,y){var R=y.args[0],J=y.args[1];w.prepend([new xe(S.goal.replace(new H("read_term",[R,J,new H("[]",[])])),S.substitution,S)])},"read_term/2":function(w,S,y){var R=y.args[0],J=y.args[1];w.prepend([new xe(S.goal.replace(new H(",",[new H("current_input",[new we("S")]),new H("read_term",[new we("S"),R,J])])),S.substitution,S)])},"read_term/3":function(w,S,y){var R=y.args[0],J=y.args[1],X=y.args[2],Z=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);if(x.type.is_variable(R)||x.type.is_variable(X))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_list(X))w.throw_error(x.error.type("list",X,y.indicator));else if(!x.type.is_stream(R)&&!x.type.is_atom(R))w.throw_error(x.error.domain("stream_or_alias",R,y.indicator));else if(!x.type.is_stream(Z)||Z.stream===null)w.throw_error(x.error.existence("stream",R,y.indicator));else if(Z.output)w.throw_error(x.error.permission("input","stream",R,y.indicator));else if(Z.type==="binary")w.throw_error(x.error.permission("input","binary_stream",R,y.indicator));else if(Z.position==="past_end_of_stream"&&Z.eof_action==="error")w.throw_error(x.error.permission("input","past_end_of_stream",R,y.indicator));else{for(var ie={},Pe=X,Le;x.type.is_term(Pe)&&Pe.indicator==="./2";){if(Le=Pe.args[0],x.type.is_variable(Le)){w.throw_error(x.error.instantiation(y.indicator));return}else if(!x.type.is_read_option(Le)){w.throw_error(x.error.domain("read_option",Le,y.indicator));return}ie[Le.id]=Le.args[0],Pe=Pe.args[1]}if(Pe.indicator!=="[]/0"){x.type.is_variable(Pe)?w.throw_error(x.error.instantiation(y.indicator)):w.throw_error(x.error.type("list",X,y.indicator));return}else{for(var ot,dt,jt,$t="",xt=[],an=null;an===null||an.name!=="atom"||an.value!=="."||jt.type===A&&x.flatten_error(new H("throw",[jt.value])).found==="token_not_found";){if(ot=Z.stream.get(1,Z.position),ot===null){w.throw_error(x.error.representation("character",y.indicator));return}if(ot==="end_of_file"||ot==="past_end_of_file"){jt?w.throw_error(x.error.syntax(xt[jt.len-1],". or expression expected",!1)):w.throw_error(x.error.syntax(null,"token not found",!0));return}Z.position++,$t+=ot,dt=new U(w),dt.new_text($t),xt=dt.get_tokens(),an=xt!==null&&xt.length>0?xt[xt.length-1]:null,xt!==null&&(jt=z(w,xt,0,w.__get_max_priority(),!1))}if(jt.type===p&&jt.len===xt.length-1&&an.value==="."){jt=jt.value.rename(w);var kr=new H("=",[J,jt]);if(ie.variables){var mr=g(o(De(jt.variables()),function(xr){return new we(xr)}));kr=new H(",",[kr,new H("=",[ie.variables,mr])])}if(ie.variable_names){var mr=g(o(De(jt.variables()),function(Wr){var Kn;for(Kn in w.session.renamed_variables)if(w.session.renamed_variables.hasOwnProperty(Kn)&&w.session.renamed_variables[Kn]===Wr)break;return new H("=",[new H(Kn,[]),new we(Wr)])}));kr=new H(",",[kr,new H("=",[ie.variable_names,mr])])}if(ie.singletons){var mr=g(o(new He(jt,null).singleton_variables(),function(Wr){var Kn;for(Kn in w.session.renamed_variables)if(w.session.renamed_variables.hasOwnProperty(Kn)&&w.session.renamed_variables[Kn]===Wr)break;return new H("=",[new H(Kn,[]),new we(Wr)])}));kr=new H(",",[kr,new H("=",[ie.singletons,mr])])}w.prepend([new xe(S.goal.replace(kr),S.substitution,S)])}else jt.type===p?w.throw_error(x.error.syntax(xt[jt.len],"unexpected token",!1)):w.throw_error(jt.value)}}},"write/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H(",",[new H("current_output",[new we("S")]),new H("write",[new we("S"),R])])),S.substitution,S)])},"write/2":function(w,S,y){var R=y.args[0],J=y.args[1];w.prepend([new xe(S.goal.replace(new H("write_term",[R,J,new H(".",[new H("quoted",[new H("false",[])]),new H(".",[new H("ignore_ops",[new H("false")]),new H(".",[new H("numbervars",[new H("true")]),new H("[]",[])])])])])),S.substitution,S)])},"writeq/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H(",",[new H("current_output",[new we("S")]),new H("writeq",[new we("S"),R])])),S.substitution,S)])},"writeq/2":function(w,S,y){var R=y.args[0],J=y.args[1];w.prepend([new xe(S.goal.replace(new H("write_term",[R,J,new H(".",[new H("quoted",[new H("true",[])]),new H(".",[new H("ignore_ops",[new H("false")]),new H(".",[new H("numbervars",[new H("true")]),new H("[]",[])])])])])),S.substitution,S)])},"write_canonical/1":function(w,S,y){var R=y.args[0];w.prepend([new xe(S.goal.replace(new H(",",[new H("current_output",[new we("S")]),new H("write_canonical",[new we("S"),R])])),S.substitution,S)])},"write_canonical/2":function(w,S,y){var R=y.args[0],J=y.args[1];w.prepend([new xe(S.goal.replace(new H("write_term",[R,J,new H(".",[new H("quoted",[new H("true",[])]),new H(".",[new H("ignore_ops",[new H("true")]),new H(".",[new H("numbervars",[new H("false")]),new H("[]",[])])])])])),S.substitution,S)])},"write_term/2":function(w,S,y){var R=y.args[0],J=y.args[1];w.prepend([new xe(S.goal.replace(new H(",",[new H("current_output",[new we("S")]),new H("write_term",[new we("S"),R,J])])),S.substitution,S)])},"write_term/3":function(w,S,y){var R=y.args[0],J=y.args[1],X=y.args[2],Z=x.type.is_stream(R)?R:w.get_stream_by_alias(R.id);if(x.type.is_variable(R)||x.type.is_variable(X))w.throw_error(x.error.instantiation(y.indicator));else if(!x.type.is_list(X))w.throw_error(x.error.type("list",X,y.indicator));else if(!x.type.is_stream(R)&&!x.type.is_atom(R))w.throw_error(x.error.domain("stream_or_alias",R,y.indicator));else if(!x.type.is_stream(Z)||Z.stream===null)w.throw_error(x.error.existence("stream",R,y.indicator));else if(Z.input)w.throw_error(x.error.permission("output","stream",R,y.indicator));else if(Z.type==="binary")w.throw_error(x.error.permission("output","binary_stream",R,y.indicator));else if(Z.position==="past_end_of_stream"&&Z.eof_action==="error")w.throw_error(x.error.permission("output","past_end_of_stream",R,y.indicator));else{for(var ie={},Pe=X,Le;x.type.is_term(Pe)&&Pe.indicator==="./2";){if(Le=Pe.args[0],x.type.is_variable(Le)){w.throw_error(x.error.instantiation(y.indicator));return}else if(!x.type.is_write_option(Le)){w.throw_error(x.error.domain("write_option",Le,y.indicator));return}ie[Le.id]=Le.args[0].id==="true",Pe=Pe.args[1]}if(Pe.indicator!=="[]/0"){x.type.is_variable(Pe)?w.throw_error(x.error.instantiation(y.indicator)):w.throw_error(x.error.type("list",X,y.indicator));return}else{ie.session=w.session;var ot=J.toString(ie);Z.stream.put(ot,Z.position),typeof Z.position=="number"&&(Z.position+=ot.length),w.success(S)}}},"halt/0":function(w,S,y){w.points=[]},"halt/1":function(w,S,y){var R=y.args[0];x.type.is_variable(R)?w.throw_error(x.error.instantiation(y.indicator)):x.type.is_integer(R)?w.points=[]:w.throw_error(x.error.type("integer",R,y.indicator))},"current_prolog_flag/2":function(w,S,y){var R=y.args[0],J=y.args[1];if(!x.type.is_variable(R)&&!x.type.is_atom(R))w.throw_error(x.error.type("atom",R,y.indicator));else if(!x.type.is_variable(R)&&!x.type.is_flag(R))w.throw_error(x.error.domain("prolog_flag",R,y.indicator));else{var X=[];for(var Z in x.flag)if(!!x.flag.hasOwnProperty(Z)){var ie=new H(",",[new H("=",[new H(Z),R]),new H("=",[w.get_flag(Z),J])]);X.push(new xe(S.goal.replace(ie),S.substitution,S))}w.prepend(X)}},"set_prolog_flag/2":function(w,S,y){var R=y.args[0],J=y.args[1];x.type.is_variable(R)||x.type.is_variable(J)?w.throw_error(x.error.instantiation(y.indicator)):x.type.is_atom(R)?x.type.is_flag(R)?x.type.is_value_flag(R,J)?x.type.is_modifiable_flag(R)?(w.session.flag[R.id]=J,w.success(S)):w.throw_error(x.error.permission("modify","flag",R)):w.throw_error(x.error.domain("flag_value",new H("+",[R,J]),y.indicator)):w.throw_error(x.error.domain("prolog_flag",R,y.indicator)):w.throw_error(x.error.type("atom",R,y.indicator))}},flag:{bounded:{allowed:[new H("true"),new H("false")],value:new H("true"),changeable:!1},max_integer:{allowed:[new be(Number.MAX_SAFE_INTEGER)],value:new be(Number.MAX_SAFE_INTEGER),changeable:!1},min_integer:{allowed:[new be(Number.MIN_SAFE_INTEGER)],value:new be(Number.MIN_SAFE_INTEGER),changeable:!1},integer_rounding_function:{allowed:[new H("down"),new H("toward_zero")],value:new H("toward_zero"),changeable:!1},char_conversion:{allowed:[new H("on"),new H("off")],value:new H("on"),changeable:!0},debug:{allowed:[new H("on"),new H("off")],value:new H("off"),changeable:!0},max_arity:{allowed:[new H("unbounded")],value:new H("unbounded"),changeable:!1},unknown:{allowed:[new H("error"),new H("fail"),new H("warning")],value:new H("error"),changeable:!0},double_quotes:{allowed:[new H("chars"),new H("codes"),new H("atom")],value:new H("codes"),changeable:!0},occurs_check:{allowed:[new H("false"),new H("true")],value:new H("false"),changeable:!0},dialect:{allowed:[new H("tau")],value:new H("tau"),changeable:!1},version_data:{allowed:[new H("tau",[new be(t.major,!1),new be(t.minor,!1),new be(t.patch,!1),new H(t.status)])],value:new H("tau",[new be(t.major,!1),new be(t.minor,!1),new be(t.patch,!1),new H(t.status)]),changeable:!1},nodejs:{allowed:[new H("yes"),new H("no")],value:new H(typeof hl<"u"&&hl.exports?"yes":"no"),changeable:!1}},unify:function(w,S,y){y=y===void 0?!1:y;for(var R=[{left:w,right:S}],J={};R.length!==0;){var X=R.pop();if(w=X.left,S=X.right,x.type.is_term(w)&&x.type.is_term(S)){if(w.indicator!==S.indicator)return null;for(var Z=0;Z<w.args.length;Z++)R.push({left:w.args[Z],right:S.args[Z]})}else if(x.type.is_number(w)&&x.type.is_number(S)){if(w.value!==S.value||w.is_float!==S.is_float)return null}else if(x.type.is_variable(w)){if(x.type.is_variable(S)&&w.id===S.id)continue;if(y===!0&&S.variables().indexOf(w.id)!==-1)return null;if(w.id!=="_"){var ie=new ke;ie.add(w.id,S);for(var Z=0;Z<R.length;Z++)R[Z].left=R[Z].left.apply(ie),R[Z].right=R[Z].right.apply(ie);for(var Z in J)J[Z]=J[Z].apply(ie);J[w.id]=S}}else if(x.type.is_variable(S))R.push({left:S,right:w});else if(w.unify!==void 0){if(!w.unify(S))return null}else return null}return new ke(J)},compare:function(w,S){var y=x.type.compare(w,S);return y!==0?y:w.compare(S)},arithmetic_compare:function(w,S,y){var R=S.interpret(w);if(x.type.is_number(R)){var J=y.interpret(w);return x.type.is_number(J)?R.value<J.value?-1:R.value>J.value?1:0:J}else return R},operate:function(w,S){if(x.type.is_operator(S)){for(var y=x.type.is_operator(S),R=[],J,X=!1,Z=0;Z<S.args.length;Z++){if(J=S.args[Z].interpret(w),x.type.is_number(J)){if(y.type_args!==null&&J.is_float!==y.type_args)return x.error.type(y.type_args?"float":"integer",J,w.__call_indicator);R.push(J.value)}else return J;X=X||J.is_float}return R.push(w),J=x.arithmetic.evaluation[S.indicator].fn.apply(this,R),X=y.type_result===null?X:y.type_result,x.type.is_term(J)?J:J===Number.POSITIVE_INFINITY||J===Number.NEGATIVE_INFINITY?x.error.evaluation("overflow",w.__call_indicator):X===!1&&w.get_flag("bounded").id==="true"&&(J>w.get_flag("max_integer").value||J<w.get_flag("min_integer").value)?x.error.evaluation("int_overflow",w.__call_indicator):new be(J,X)}else return x.error.type("evaluable",S.indicator,w.__call_indicator)},error:{existence:function(w,S,y){return typeof S=="string"&&(S=ee(S)),new H("error",[new H("existence_error",[new H(w),S]),ee(y)])},type:function(w,S,y){return new H("error",[new H("type_error",[new H(w),S]),ee(y)])},instantiation:function(w){return new H("error",[new H("instantiation_error"),ee(w)])},domain:function(w,S,y){return new H("error",[new H("domain_error",[new H(w),S]),ee(y)])},representation:function(w,S){return new H("error",[new H("representation_error",[new H(w)]),ee(S)])},permission:function(w,S,y,R){return new H("error",[new H("permission_error",[new H(w),new H(S),y]),ee(R)])},evaluation:function(w,S){return new H("error",[new H("evaluation_error",[new H(w)]),ee(S)])},syntax:function(w,S,y){w=w||{value:"",line:0,column:0,matches:[""],start:0};var R=y&&w.matches.length>0?w.start+w.matches[0].length:w.start,J=y?new H("token_not_found"):new H("found",[new H(w.value.toString())]),X=new H(".",[new H("line",[new be(w.line+1)]),new H(".",[new H("column",[new be(R+1)]),new H(".",[J,new H("[]",[])])])]);return new H("error",[new H("syntax_error",[new H(S)]),X])},syntax_by_predicate:function(w,S){return new H("error",[new H("syntax_error",[new H(w)]),ee(S)])}},warning:{singleton:function(w,S,y){for(var R=new H("[]"),J=w.length-1;J>=0;J--)R=new H(".",[new we(w[J]),R]);return new H("warning",[new H("singleton_variables",[R,ee(S)]),new H(".",[new H("line",[new be(y,!1)]),new H("[]")])])},failed_goal:function(w,S){return new H("warning",[new H("failed_goal",[w]),new H(".",[new H("line",[new be(S,!1)]),new H("[]")])])}},format_variable:function(w){return"_"+w},format_answer:function(w,S,R){S instanceof Re&&(S=S.thread);var R=R||{};if(R.session=S?S.session:void 0,x.type.is_error(w))return"uncaught exception: "+w.args[0].toString();if(w===!1)return"false.";if(w===null)return"limit exceeded ;";var J=0,X="";if(x.type.is_substitution(w)){var Z=w.domain(!0);w=w.filter(function(Le,ot){return!x.type.is_variable(ot)||Z.indexOf(ot.id)!==-1&&Le!==ot.id})}for(var ie in w.links)!w.links.hasOwnProperty(ie)||(J++,X!==""&&(X+=", "),X+=ie.toString(R)+" = "+w.links[ie].toString(R));var Pe=typeof S>"u"||S.points.length>0?" ;":".";return J===0?"true"+Pe:X+Pe},flatten_error:function(w){if(!x.type.is_error(w))return null;w=w.args[0];var S={};return S.type=w.args[0].id,S.thrown=S.type==="syntax_error"?null:w.args[1].id,S.expected=null,S.found=null,S.representation=null,S.existence=null,S.existence_type=null,S.line=null,S.column=null,S.permission_operation=null,S.permission_type=null,S.evaluation_type=null,S.type==="type_error"||S.type==="domain_error"?(S.expected=w.args[0].args[0].id,S.found=w.args[0].args[1].toString()):S.type==="syntax_error"?w.args[1].indicator==="./2"?(S.expected=w.args[0].args[0].id,S.found=w.args[1].args[1].args[1].args[0],S.found=S.found.id==="token_not_found"?S.found.id:S.found.args[0].id,S.line=w.args[1].args[0].args[0].value,S.column=w.args[1].args[1].args[0].args[0].value):S.thrown=w.args[1].id:S.type==="permission_error"?(S.found=w.args[0].args[2].toString(),S.permission_operation=w.args[0].args[0].id,S.permission_type=w.args[0].args[1].id):S.type==="evaluation_error"?S.evaluation_type=w.args[0].args[0].id:S.type==="representation_error"?S.representation=w.args[0].args[0].id:S.type==="existence_error"&&(S.existence=w.args[0].args[1].toString(),S.existence_type=w.args[0].args[0].id),S},create:function(w){return new x.type.Session(w)}};typeof hl<"u"?hl.exports=x:window.pl=x})()});function ome(t,e,r){t.prepend(r.map(o=>new Ta.default.type.State(e.goal.replace(o),e.substitution,e)))}function yH(t){let e=lme.get(t.session);if(e==null)throw new Error("Assertion failed: A project should have been registered for the active session");return e}function cme(t,e){lme.set(t,e),t.consult(`:- use_module(library(${Jgt.id})).`)}var EH,Ta,ame,c0,Vgt,zgt,lme,Jgt,ume=yt(()=>{Ye();EH=$e(h2()),Ta=$e(mH()),ame=$e(Be("vm")),{is_atom:c0,is_variable:Vgt,is_instantiated_list:zgt}=Ta.default.type;lme=new WeakMap;Jgt=new Ta.default.type.Module("constraints",{["project_workspaces_by_descriptor/3"]:(t,e,r)=>{let[o,a,n]=r.args;if(!c0(o)||!c0(a)){t.throw_error(Ta.default.error.instantiation(r.indicator));return}let u=G.parseIdent(o.id),A=G.makeDescriptor(u,a.id),h=yH(t).tryWorkspaceByDescriptor(A);Vgt(n)&&h!==null&&ome(t,e,[new Ta.default.type.Term("=",[n,new Ta.default.type.Term(String(h.relativeCwd))])]),c0(n)&&h!==null&&h.relativeCwd===n.id&&t.success(e)},["workspace_field/3"]:(t,e,r)=>{let[o,a,n]=r.args;if(!c0(o)||!c0(a)){t.throw_error(Ta.default.error.instantiation(r.indicator));return}let A=yH(t).tryWorkspaceByCwd(o.id);if(A==null)return;let p=(0,EH.default)(A.manifest.raw,a.id);typeof p>"u"||ome(t,e,[new Ta.default.type.Term("=",[n,new Ta.default.type.Term(typeof p=="object"?JSON.stringify(p):p)])])},["workspace_field_test/3"]:(t,e,r)=>{let[o,a,n]=r.args;t.prepend([new Ta.default.type.State(e.goal.replace(new Ta.default.type.Term("workspace_field_test",[o,a,n,new Ta.default.type.Term("[]",[])])),e.substitution,e)])},["workspace_field_test/4"]:(t,e,r)=>{let[o,a,n,u]=r.args;if(!c0(o)||!c0(a)||!c0(n)||!zgt(u)){t.throw_error(Ta.default.error.instantiation(r.indicator));return}let p=yH(t).tryWorkspaceByCwd(o.id);if(p==null)return;let h=(0,EH.default)(p.manifest.raw,a.id);if(typeof h>"u")return;let C={$$:h};for(let[v,b]of u.toJavaScript().entries())C[`$${v}`]=b;ame.default.runInNewContext(n.id,C)&&t.success(e)}},["project_workspaces_by_descriptor/3","workspace_field/3","workspace_field_test/3","workspace_field_test/4"])});var P2={};Vt(P2,{Constraints:()=>D2,DependencyType:()=>hme});function to(t){if(t instanceof vC.default.type.Num)return t.value;if(t instanceof vC.default.type.Term)switch(t.indicator){case"throw/1":return to(t.args[0]);case"error/1":return to(t.args[0]);case"error/2":if(t.args[0]instanceof vC.default.type.Term&&t.args[0].indicator==="syntax_error/1")return Object.assign(to(t.args[0]),...to(t.args[1]));{let e=to(t.args[0]);return e.message+=` (in ${to(t.args[1])})`,e}case"syntax_error/1":return new Jt(43,`Syntax error: ${to(t.args[0])}`);case"existence_error/2":return new Jt(44,`Existence error: ${to(t.args[0])} ${to(t.args[1])} not found`);case"instantiation_error/0":return new Jt(75,"Instantiation error: an argument is variable when an instantiated argument was expected");case"line/1":return{line:to(t.args[0])};case"column/1":return{column:to(t.args[0])};case"found/1":return{found:to(t.args[0])};case"./2":return[to(t.args[0])].concat(to(t.args[1]));case"//2":return`${to(t.args[0])}/${to(t.args[1])}`;default:return t.id}throw`couldn't pretty print because of unsupported node ${t}`}function fme(t){let e;try{e=to(t)}catch(r){throw typeof r=="string"?new Jt(42,`Unknown error: ${t} (note: ${r})`):r}return typeof e.line<"u"&&typeof e.column<"u"&&(e.message+=` at line ${e.line}, column ${e.column}`),e}function $d(t){return t.id==="null"?null:`${t.toJavaScript()}`}function Xgt(t){if(t.id==="null")return null;{let e=t.toJavaScript();if(typeof e!="string")return JSON.stringify(e);try{return JSON.stringify(JSON.parse(e))}catch{return JSON.stringify(e)}}}function u0(t){return typeof t=="string"?`'${t}'`:"[]"}var pme,vC,hme,Ame,CH,D2,S2=yt(()=>{Ye();Ye();Pt();pme=$e(Gde()),vC=$e(mH());I2();ume();(0,pme.default)(vC.default);hme=(o=>(o.Dependencies="dependencies",o.DevDependencies="devDependencies",o.PeerDependencies="peerDependencies",o))(hme||{}),Ame=["dependencies","devDependencies","peerDependencies"];CH=class{constructor(e,r){let o=1e3*e.workspaces.length;this.session=vC.default.create(o),cme(this.session,e),this.session.consult(":- use_module(library(lists))."),this.session.consult(r)}fetchNextAnswer(){return new Promise(e=>{this.session.answer(r=>{e(r)})})}async*makeQuery(e){let r=this.session.query(e);if(r!==!0)throw fme(r);for(;;){let o=await this.fetchNextAnswer();if(o===null)throw new Jt(79,"Resolution limit exceeded");if(!o)break;if(o.id==="throw")throw fme(o);yield o}}};D2=class{constructor(e){this.source="";this.project=e;let r=e.configuration.get("constraintsPath");oe.existsSync(r)&&(this.source=oe.readFileSync(r,"utf8"))}static async find(e){return new D2(e)}getProjectDatabase(){let e="";for(let r of Ame)e+=`dependency_type(${r}). -`;for(let r of this.project.workspacesByCwd.values()){let o=r.relativeCwd;e+=`workspace(${u0(o)}). -`,e+=`workspace_ident(${u0(o)}, ${u0(G.stringifyIdent(r.anchoredLocator))}). -`,e+=`workspace_version(${u0(o)}, ${u0(r.manifest.version)}). -`;for(let a of Ame)for(let n of r.manifest[a].values())e+=`workspace_has_dependency(${u0(o)}, ${u0(G.stringifyIdent(n))}, ${u0(n.range)}, ${a}). -`}return e+=`workspace(_) :- false. -`,e+=`workspace_ident(_, _) :- false. -`,e+=`workspace_version(_, _) :- false. -`,e+=`workspace_has_dependency(_, _, _, _) :- false. -`,e}getDeclarations(){let e="";return e+=`gen_enforced_dependency(_, _, _, _) :- false. -`,e+=`gen_enforced_field(_, _, _) :- false. -`,e}get fullSource(){return`${this.getProjectDatabase()} -${this.source} -${this.getDeclarations()}`}createSession(){return new CH(this.project,this.fullSource)}async processClassic(){let e=this.createSession();return{enforcedDependencies:await this.genEnforcedDependencies(e),enforcedFields:await this.genEnforcedFields(e)}}async process(){let{enforcedDependencies:e,enforcedFields:r}=await this.processClassic(),o=new Map;for(let{workspace:a,dependencyIdent:n,dependencyRange:u,dependencyType:A}of e){let p=w2([A,G.stringifyIdent(n)]),h=_e.getMapWithDefault(o,a.cwd);_e.getMapWithDefault(h,p).set(u??void 0,new Set)}for(let{workspace:a,fieldPath:n,fieldValue:u}of r){let A=w2(n),p=_e.getMapWithDefault(o,a.cwd);_e.getMapWithDefault(p,A).set(JSON.parse(u)??void 0,new Set)}return{manifestUpdates:o,reportedErrors:new Map}}async genEnforcedDependencies(e){let r=[];for await(let o of e.makeQuery("workspace(WorkspaceCwd), dependency_type(DependencyType), gen_enforced_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType).")){let a=V.resolve(this.project.cwd,$d(o.links.WorkspaceCwd)),n=$d(o.links.DependencyIdent),u=$d(o.links.DependencyRange),A=$d(o.links.DependencyType);if(a===null||n===null)throw new Error("Invalid rule");let p=this.project.getWorkspaceByCwd(a),h=G.parseIdent(n);r.push({workspace:p,dependencyIdent:h,dependencyRange:u,dependencyType:A})}return _e.sortMap(r,[({dependencyRange:o})=>o!==null?"0":"1",({workspace:o})=>G.stringifyIdent(o.anchoredLocator),({dependencyIdent:o})=>G.stringifyIdent(o)])}async genEnforcedFields(e){let r=[];for await(let o of e.makeQuery("workspace(WorkspaceCwd), gen_enforced_field(WorkspaceCwd, FieldPath, FieldValue).")){let a=V.resolve(this.project.cwd,$d(o.links.WorkspaceCwd)),n=$d(o.links.FieldPath),u=Xgt(o.links.FieldValue);if(a===null||n===null)throw new Error("Invalid rule");let A=this.project.getWorkspaceByCwd(a);r.push({workspace:A,fieldPath:n,fieldValue:u})}return _e.sortMap(r,[({workspace:o})=>G.stringifyIdent(o.anchoredLocator),({fieldPath:o})=>o])}async*query(e){let r=this.createSession();for await(let o of r.makeQuery(e)){let a={};for(let[n,u]of Object.entries(o.links))n!=="_"&&(a[n]=$d(u));yield a}}}});var Ime=_(Ik=>{"use strict";Object.defineProperty(Ik,"__esModule",{value:!0});function q2(t){let e=[...t.caches],r=e.shift();return r===void 0?wme():{get(o,a,n={miss:()=>Promise.resolve()}){return r.get(o,a,n).catch(()=>q2({caches:e}).get(o,a,n))},set(o,a){return r.set(o,a).catch(()=>q2({caches:e}).set(o,a))},delete(o){return r.delete(o).catch(()=>q2({caches:e}).delete(o))},clear(){return r.clear().catch(()=>q2({caches:e}).clear())}}}function wme(){return{get(t,e,r={miss:()=>Promise.resolve()}){return e().then(a=>Promise.all([a,r.miss(a)])).then(([a])=>a)},set(t,e){return Promise.resolve(e)},delete(t){return Promise.resolve()},clear(){return Promise.resolve()}}}Ik.createFallbackableCache=q2;Ik.createNullCache=wme});var vme=_((xWt,Bme)=>{Bme.exports=Ime()});var Dme=_(TH=>{"use strict";Object.defineProperty(TH,"__esModule",{value:!0});function ddt(t={serializable:!0}){let e={};return{get(r,o,a={miss:()=>Promise.resolve()}){let n=JSON.stringify(r);if(n in e)return Promise.resolve(t.serializable?JSON.parse(e[n]):e[n]);let u=o(),A=a&&a.miss||(()=>Promise.resolve());return u.then(p=>A(p)).then(()=>u)},set(r,o){return e[JSON.stringify(r)]=t.serializable?JSON.stringify(o):o,Promise.resolve(o)},delete(r){return delete e[JSON.stringify(r)],Promise.resolve()},clear(){return e={},Promise.resolve()}}}TH.createInMemoryCache=ddt});var Sme=_((kWt,Pme)=>{Pme.exports=Dme()});var bme=_(Zc=>{"use strict";Object.defineProperty(Zc,"__esModule",{value:!0});function mdt(t,e,r){let o={"x-algolia-api-key":r,"x-algolia-application-id":e};return{headers(){return t===LH.WithinHeaders?o:{}},queryParameters(){return t===LH.WithinQueryParameters?o:{}}}}function ydt(t){let e=0,r=()=>(e++,new Promise(o=>{setTimeout(()=>{o(t(r))},Math.min(100*e,1e3))}));return t(r)}function xme(t,e=(r,o)=>Promise.resolve()){return Object.assign(t,{wait(r){return xme(t.then(o=>Promise.all([e(o,r),o])).then(o=>o[1]))}})}function Edt(t){let e=t.length-1;for(e;e>0;e--){let r=Math.floor(Math.random()*(e+1)),o=t[e];t[e]=t[r],t[r]=o}return t}function Cdt(t,e){return e&&Object.keys(e).forEach(r=>{t[r]=e[r](t)}),t}function wdt(t,...e){let r=0;return t.replace(/%s/g,()=>encodeURIComponent(e[r++]))}var Idt="4.14.2",Bdt=t=>()=>t.transporter.requester.destroy(),LH={WithinQueryParameters:0,WithinHeaders:1};Zc.AuthMode=LH;Zc.addMethods=Cdt;Zc.createAuth=mdt;Zc.createRetryablePromise=ydt;Zc.createWaitablePromise=xme;Zc.destroy=Bdt;Zc.encode=wdt;Zc.shuffle=Edt;Zc.version=Idt});var G2=_((FWt,kme)=>{kme.exports=bme()});var Qme=_(NH=>{"use strict";Object.defineProperty(NH,"__esModule",{value:!0});var vdt={Delete:"DELETE",Get:"GET",Post:"POST",Put:"PUT"};NH.MethodEnum=vdt});var Y2=_((TWt,Fme)=>{Fme.exports=Qme()});var Kme=_(Fi=>{"use strict";Object.defineProperty(Fi,"__esModule",{value:!0});var Tme=Y2();function OH(t,e){let r=t||{},o=r.data||{};return Object.keys(r).forEach(a=>{["timeout","headers","queryParameters","data","cacheable"].indexOf(a)===-1&&(o[a]=r[a])}),{data:Object.entries(o).length>0?o:void 0,timeout:r.timeout||e,headers:r.headers||{},queryParameters:r.queryParameters||{},cacheable:r.cacheable}}var W2={Read:1,Write:2,Any:3},xC={Up:1,Down:2,Timeouted:3},Lme=2*60*1e3;function UH(t,e=xC.Up){return{...t,status:e,lastUpdate:Date.now()}}function Nme(t){return t.status===xC.Up||Date.now()-t.lastUpdate>Lme}function Ome(t){return t.status===xC.Timeouted&&Date.now()-t.lastUpdate<=Lme}function _H(t){return typeof t=="string"?{protocol:"https",url:t,accept:W2.Any}:{protocol:t.protocol||"https",url:t.url,accept:t.accept||W2.Any}}function Ddt(t,e){return Promise.all(e.map(r=>t.get(r,()=>Promise.resolve(UH(r))))).then(r=>{let o=r.filter(A=>Nme(A)),a=r.filter(A=>Ome(A)),n=[...o,...a],u=n.length>0?n.map(A=>_H(A)):e;return{getTimeout(A,p){return(a.length===0&&A===0?1:a.length+3+A)*p},statelessHosts:u}})}var Pdt=({isTimedOut:t,status:e})=>!t&&~~e===0,Sdt=t=>{let e=t.status;return t.isTimedOut||Pdt(t)||~~(e/100)!==2&&~~(e/100)!==4},xdt=({status:t})=>~~(t/100)===2,bdt=(t,e)=>Sdt(t)?e.onRetry(t):xdt(t)?e.onSuccess(t):e.onFail(t);function Rme(t,e,r,o){let a=[],n=jme(r,o),u=qme(t,o),A=r.method,p=r.method!==Tme.MethodEnum.Get?{}:{...r.data,...o.data},h={"x-algolia-agent":t.userAgent.value,...t.queryParameters,...p,...o.queryParameters},C=0,I=(v,b)=>{let E=v.pop();if(E===void 0)throw Wme(MH(a));let F={data:n,headers:u,method:A,url:_me(E,r.path,h),connectTimeout:b(C,t.timeouts.connect),responseTimeout:b(C,o.timeout)},N=z=>{let te={request:F,response:z,host:E,triesLeft:v.length};return a.push(te),te},U={onSuccess:z=>Mme(z),onRetry(z){let te=N(z);return z.isTimedOut&&C++,Promise.all([t.logger.info("Retryable failure",HH(te)),t.hostsCache.set(E,UH(E,z.isTimedOut?xC.Timeouted:xC.Down))]).then(()=>I(v,b))},onFail(z){throw N(z),Ume(z,MH(a))}};return t.requester.send(F).then(z=>bdt(z,U))};return Ddt(t.hostsCache,e).then(v=>I([...v.statelessHosts].reverse(),v.getTimeout))}function kdt(t){let{hostsCache:e,logger:r,requester:o,requestsCache:a,responsesCache:n,timeouts:u,userAgent:A,hosts:p,queryParameters:h,headers:C}=t,I={hostsCache:e,logger:r,requester:o,requestsCache:a,responsesCache:n,timeouts:u,userAgent:A,headers:C,queryParameters:h,hosts:p.map(v=>_H(v)),read(v,b){let E=OH(b,I.timeouts.read),F=()=>Rme(I,I.hosts.filter(z=>(z.accept&W2.Read)!==0),v,E);if((E.cacheable!==void 0?E.cacheable:v.cacheable)!==!0)return F();let U={request:v,mappedRequestOptions:E,transporter:{queryParameters:I.queryParameters,headers:I.headers}};return I.responsesCache.get(U,()=>I.requestsCache.get(U,()=>I.requestsCache.set(U,F()).then(z=>Promise.all([I.requestsCache.delete(U),z]),z=>Promise.all([I.requestsCache.delete(U),Promise.reject(z)])).then(([z,te])=>te)),{miss:z=>I.responsesCache.set(U,z)})},write(v,b){return Rme(I,I.hosts.filter(E=>(E.accept&W2.Write)!==0),v,OH(b,I.timeouts.write))}};return I}function Qdt(t){let e={value:`Algolia for JavaScript (${t})`,add(r){let o=`; ${r.segment}${r.version!==void 0?` (${r.version})`:""}`;return e.value.indexOf(o)===-1&&(e.value=`${e.value}${o}`),e}};return e}function Mme(t){try{return JSON.parse(t.content)}catch(e){throw Yme(e.message,t)}}function Ume({content:t,status:e},r){let o=t;try{o=JSON.parse(t).message}catch{}return Gme(o,e,r)}function Fdt(t,...e){let r=0;return t.replace(/%s/g,()=>encodeURIComponent(e[r++]))}function _me(t,e,r){let o=Hme(r),a=`${t.protocol}://${t.url}/${e.charAt(0)==="/"?e.substr(1):e}`;return o.length&&(a+=`?${o}`),a}function Hme(t){let e=r=>Object.prototype.toString.call(r)==="[object Object]"||Object.prototype.toString.call(r)==="[object Array]";return Object.keys(t).map(r=>Fdt("%s=%s",r,e(t[r])?JSON.stringify(t[r]):t[r])).join("&")}function jme(t,e){if(t.method===Tme.MethodEnum.Get||t.data===void 0&&e.data===void 0)return;let r=Array.isArray(t.data)?t.data:{...t.data,...e.data};return JSON.stringify(r)}function qme(t,e){let r={...t.headers,...e.headers},o={};return Object.keys(r).forEach(a=>{let n=r[a];o[a.toLowerCase()]=n}),o}function MH(t){return t.map(e=>HH(e))}function HH(t){let e=t.request.headers["x-algolia-api-key"]?{"x-algolia-api-key":"*****"}:{};return{...t,request:{...t.request,headers:{...t.request.headers,...e}}}}function Gme(t,e,r){return{name:"ApiError",message:t,status:e,transporterStackTrace:r}}function Yme(t,e){return{name:"DeserializationError",message:t,response:e}}function Wme(t){return{name:"RetryError",message:"Unreachable hosts - your application id may be incorrect. If the error persists, contact support@algolia.com.",transporterStackTrace:t}}Fi.CallEnum=W2;Fi.HostStatusEnum=xC;Fi.createApiError=Gme;Fi.createDeserializationError=Yme;Fi.createMappedRequestOptions=OH;Fi.createRetryError=Wme;Fi.createStatefulHost=UH;Fi.createStatelessHost=_H;Fi.createTransporter=kdt;Fi.createUserAgent=Qdt;Fi.deserializeFailure=Ume;Fi.deserializeSuccess=Mme;Fi.isStatefulHostTimeouted=Ome;Fi.isStatefulHostUp=Nme;Fi.serializeData=jme;Fi.serializeHeaders=qme;Fi.serializeQueryParameters=Hme;Fi.serializeUrl=_me;Fi.stackFrameWithoutCredentials=HH;Fi.stackTraceWithoutCredentials=MH});var K2=_((NWt,Vme)=>{Vme.exports=Kme()});var zme=_(d0=>{"use strict";Object.defineProperty(d0,"__esModule",{value:!0});var bC=G2(),Rdt=K2(),V2=Y2(),Tdt=t=>{let e=t.region||"us",r=bC.createAuth(bC.AuthMode.WithinHeaders,t.appId,t.apiKey),o=Rdt.createTransporter({hosts:[{url:`analytics.${e}.algolia.com`}],...t,headers:{...r.headers(),"content-type":"application/json",...t.headers},queryParameters:{...r.queryParameters(),...t.queryParameters}}),a=t.appId;return bC.addMethods({appId:a,transporter:o},t.methods)},Ldt=t=>(e,r)=>t.transporter.write({method:V2.MethodEnum.Post,path:"2/abtests",data:e},r),Ndt=t=>(e,r)=>t.transporter.write({method:V2.MethodEnum.Delete,path:bC.encode("2/abtests/%s",e)},r),Odt=t=>(e,r)=>t.transporter.read({method:V2.MethodEnum.Get,path:bC.encode("2/abtests/%s",e)},r),Mdt=t=>e=>t.transporter.read({method:V2.MethodEnum.Get,path:"2/abtests"},e),Udt=t=>(e,r)=>t.transporter.write({method:V2.MethodEnum.Post,path:bC.encode("2/abtests/%s/stop",e)},r);d0.addABTest=Ldt;d0.createAnalyticsClient=Tdt;d0.deleteABTest=Ndt;d0.getABTest=Odt;d0.getABTests=Mdt;d0.stopABTest=Udt});var Xme=_((MWt,Jme)=>{Jme.exports=zme()});var $me=_(z2=>{"use strict";Object.defineProperty(z2,"__esModule",{value:!0});var jH=G2(),_dt=K2(),Zme=Y2(),Hdt=t=>{let e=t.region||"us",r=jH.createAuth(jH.AuthMode.WithinHeaders,t.appId,t.apiKey),o=_dt.createTransporter({hosts:[{url:`personalization.${e}.algolia.com`}],...t,headers:{...r.headers(),"content-type":"application/json",...t.headers},queryParameters:{...r.queryParameters(),...t.queryParameters}});return jH.addMethods({appId:t.appId,transporter:o},t.methods)},jdt=t=>e=>t.transporter.read({method:Zme.MethodEnum.Get,path:"1/strategies/personalization"},e),qdt=t=>(e,r)=>t.transporter.write({method:Zme.MethodEnum.Post,path:"1/strategies/personalization",data:e},r);z2.createPersonalizationClient=Hdt;z2.getPersonalizationStrategy=jdt;z2.setPersonalizationStrategy=qdt});var tye=_((_Wt,eye)=>{eye.exports=$me()});var gye=_(Ft=>{"use strict";Object.defineProperty(Ft,"__esModule",{value:!0});var Gt=G2(),La=K2(),Ir=Y2(),Gdt=Be("crypto");function Bk(t){let e=r=>t.request(r).then(o=>{if(t.batch!==void 0&&t.batch(o.hits),!t.shouldStop(o))return o.cursor?e({cursor:o.cursor}):e({page:(r.page||0)+1})});return e({})}var Ydt=t=>{let e=t.appId,r=Gt.createAuth(t.authMode!==void 0?t.authMode:Gt.AuthMode.WithinHeaders,e,t.apiKey),o=La.createTransporter({hosts:[{url:`${e}-dsn.algolia.net`,accept:La.CallEnum.Read},{url:`${e}.algolia.net`,accept:La.CallEnum.Write}].concat(Gt.shuffle([{url:`${e}-1.algolianet.com`},{url:`${e}-2.algolianet.com`},{url:`${e}-3.algolianet.com`}])),...t,headers:{...r.headers(),"content-type":"application/x-www-form-urlencoded",...t.headers},queryParameters:{...r.queryParameters(),...t.queryParameters}}),a={transporter:o,appId:e,addAlgoliaAgent(n,u){o.userAgent.add({segment:n,version:u})},clearCache(){return Promise.all([o.requestsCache.clear(),o.responsesCache.clear()]).then(()=>{})}};return Gt.addMethods(a,t.methods)};function rye(){return{name:"MissingObjectIDError",message:"All objects must have an unique objectID (like a primary key) to be valid. Algolia is also able to generate objectIDs automatically but *it's not recommended*. To do it, use the `{'autoGenerateObjectIDIfNotExist': true}` option."}}function nye(){return{name:"ObjectNotFoundError",message:"Object not found."}}function iye(){return{name:"ValidUntilNotFoundError",message:"ValidUntil not found in given secured api key."}}var Wdt=t=>(e,r)=>{let{queryParameters:o,...a}=r||{},n={acl:e,...o!==void 0?{queryParameters:o}:{}},u=(A,p)=>Gt.createRetryablePromise(h=>J2(t)(A.key,p).catch(C=>{if(C.status!==404)throw C;return h()}));return Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:"1/keys",data:n},a),u)},Kdt=t=>(e,r,o)=>{let a=La.createMappedRequestOptions(o);return a.queryParameters["X-Algolia-User-ID"]=e,t.transporter.write({method:Ir.MethodEnum.Post,path:"1/clusters/mapping",data:{cluster:r}},a)},Vdt=t=>(e,r,o)=>t.transporter.write({method:Ir.MethodEnum.Post,path:"1/clusters/mapping/batch",data:{users:e,cluster:r}},o),zdt=t=>(e,r)=>Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:Gt.encode("/1/dictionaries/%s/batch",e),data:{clearExistingDictionaryEntries:!0,requests:{action:"addEntry",body:[]}}},r),(o,a)=>kC(t)(o.taskID,a)),vk=t=>(e,r,o)=>{let a=(n,u)=>X2(t)(e,{methods:{waitTask:Zi}}).waitTask(n.taskID,u);return Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:Gt.encode("1/indexes/%s/operation",e),data:{operation:"copy",destination:r}},o),a)},Jdt=t=>(e,r,o)=>vk(t)(e,r,{...o,scope:[Pk.Rules]}),Xdt=t=>(e,r,o)=>vk(t)(e,r,{...o,scope:[Pk.Settings]}),Zdt=t=>(e,r,o)=>vk(t)(e,r,{...o,scope:[Pk.Synonyms]}),$dt=t=>(e,r)=>e.method===Ir.MethodEnum.Get?t.transporter.read(e,r):t.transporter.write(e,r),emt=t=>(e,r)=>{let o=(a,n)=>Gt.createRetryablePromise(u=>J2(t)(e,n).then(u).catch(A=>{if(A.status!==404)throw A}));return Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Delete,path:Gt.encode("1/keys/%s",e)},r),o)},tmt=t=>(e,r,o)=>{let a=r.map(n=>({action:"deleteEntry",body:{objectID:n}}));return Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:Gt.encode("/1/dictionaries/%s/batch",e),data:{clearExistingDictionaryEntries:!1,requests:a}},o),(n,u)=>kC(t)(n.taskID,u))},rmt=()=>(t,e)=>{let r=La.serializeQueryParameters(e),o=Gdt.createHmac("sha256",t).update(r).digest("hex");return Buffer.from(o+r).toString("base64")},J2=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Get,path:Gt.encode("1/keys/%s",e)},r),sye=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Get,path:Gt.encode("1/task/%s",e.toString())},r),nmt=t=>e=>t.transporter.read({method:Ir.MethodEnum.Get,path:"/1/dictionaries/*/settings"},e),imt=t=>e=>t.transporter.read({method:Ir.MethodEnum.Get,path:"1/logs"},e),smt=()=>t=>{let e=Buffer.from(t,"base64").toString("ascii"),r=/validUntil=(\d+)/,o=e.match(r);if(o===null)throw iye();return parseInt(o[1],10)-Math.round(new Date().getTime()/1e3)},omt=t=>e=>t.transporter.read({method:Ir.MethodEnum.Get,path:"1/clusters/mapping/top"},e),amt=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Get,path:Gt.encode("1/clusters/mapping/%s",e)},r),lmt=t=>e=>{let{retrieveMappings:r,...o}=e||{};return r===!0&&(o.getClusters=!0),t.transporter.read({method:Ir.MethodEnum.Get,path:"1/clusters/mapping/pending"},o)},X2=t=>(e,r={})=>{let o={transporter:t.transporter,appId:t.appId,indexName:e};return Gt.addMethods(o,r.methods)},cmt=t=>e=>t.transporter.read({method:Ir.MethodEnum.Get,path:"1/keys"},e),umt=t=>e=>t.transporter.read({method:Ir.MethodEnum.Get,path:"1/clusters"},e),Amt=t=>e=>t.transporter.read({method:Ir.MethodEnum.Get,path:"1/indexes"},e),fmt=t=>e=>t.transporter.read({method:Ir.MethodEnum.Get,path:"1/clusters/mapping"},e),pmt=t=>(e,r,o)=>{let a=(n,u)=>X2(t)(e,{methods:{waitTask:Zi}}).waitTask(n.taskID,u);return Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:Gt.encode("1/indexes/%s/operation",e),data:{operation:"move",destination:r}},o),a)},hmt=t=>(e,r)=>{let o=(a,n)=>Promise.all(Object.keys(a.taskID).map(u=>X2(t)(u,{methods:{waitTask:Zi}}).waitTask(a.taskID[u],n)));return Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:"1/indexes/*/batch",data:{requests:e}},r),o)},gmt=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Post,path:"1/indexes/*/objects",data:{requests:e}},r),dmt=t=>(e,r)=>{let o=e.map(a=>({...a,params:La.serializeQueryParameters(a.params||{})}));return t.transporter.read({method:Ir.MethodEnum.Post,path:"1/indexes/*/queries",data:{requests:o},cacheable:!0},r)},mmt=t=>(e,r)=>Promise.all(e.map(o=>{let{facetName:a,facetQuery:n,...u}=o.params;return X2(t)(o.indexName,{methods:{searchForFacetValues:fye}}).searchForFacetValues(a,n,{...r,...u})})),ymt=t=>(e,r)=>{let o=La.createMappedRequestOptions(r);return o.queryParameters["X-Algolia-User-ID"]=e,t.transporter.write({method:Ir.MethodEnum.Delete,path:"1/clusters/mapping"},o)},Emt=t=>(e,r,o)=>{let a=r.map(n=>({action:"addEntry",body:n}));return Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:Gt.encode("/1/dictionaries/%s/batch",e),data:{clearExistingDictionaryEntries:!0,requests:a}},o),(n,u)=>kC(t)(n.taskID,u))},Cmt=t=>(e,r)=>{let o=(a,n)=>Gt.createRetryablePromise(u=>J2(t)(e,n).catch(A=>{if(A.status!==404)throw A;return u()}));return Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:Gt.encode("1/keys/%s/restore",e)},r),o)},wmt=t=>(e,r,o)=>{let a=r.map(n=>({action:"addEntry",body:n}));return Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:Gt.encode("/1/dictionaries/%s/batch",e),data:{clearExistingDictionaryEntries:!1,requests:a}},o),(n,u)=>kC(t)(n.taskID,u))},Imt=t=>(e,r,o)=>t.transporter.read({method:Ir.MethodEnum.Post,path:Gt.encode("/1/dictionaries/%s/search",e),data:{query:r},cacheable:!0},o),Bmt=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Post,path:"1/clusters/mapping/search",data:{query:e}},r),vmt=t=>(e,r)=>Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Put,path:"/1/dictionaries/*/settings",data:e},r),(o,a)=>kC(t)(o.taskID,a)),Dmt=t=>(e,r)=>{let o=Object.assign({},r),{queryParameters:a,...n}=r||{},u=a?{queryParameters:a}:{},A=["acl","indexes","referers","restrictSources","queryParameters","description","maxQueriesPerIPPerHour","maxHitsPerQuery"],p=C=>Object.keys(o).filter(I=>A.indexOf(I)!==-1).every(I=>C[I]===o[I]),h=(C,I)=>Gt.createRetryablePromise(v=>J2(t)(e,I).then(b=>p(b)?Promise.resolve():v()));return Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Put,path:Gt.encode("1/keys/%s",e),data:u},n),h)},kC=t=>(e,r)=>Gt.createRetryablePromise(o=>sye(t)(e,r).then(a=>a.status!=="published"?o():void 0)),oye=t=>(e,r)=>{let o=(a,n)=>Zi(t)(a.taskID,n);return Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:Gt.encode("1/indexes/%s/batch",t.indexName),data:{requests:e}},r),o)},Pmt=t=>e=>Bk({shouldStop:r=>r.cursor===void 0,...e,request:r=>t.transporter.read({method:Ir.MethodEnum.Post,path:Gt.encode("1/indexes/%s/browse",t.indexName),data:r},e)}),Smt=t=>e=>{let r={hitsPerPage:1e3,...e};return Bk({shouldStop:o=>o.hits.length<r.hitsPerPage,...r,request(o){return pye(t)("",{...r,...o}).then(a=>({...a,hits:a.hits.map(n=>(delete n._highlightResult,n))}))}})},xmt=t=>e=>{let r={hitsPerPage:1e3,...e};return Bk({shouldStop:o=>o.hits.length<r.hitsPerPage,...r,request(o){return hye(t)("",{...r,...o}).then(a=>({...a,hits:a.hits.map(n=>(delete n._highlightResult,n))}))}})},Dk=t=>(e,r,o)=>{let{batchSize:a,...n}=o||{},u={taskIDs:[],objectIDs:[]},A=(p=0)=>{let h=[],C;for(C=p;C<e.length&&(h.push(e[C]),h.length!==(a||1e3));C++);return h.length===0?Promise.resolve(u):oye(t)(h.map(I=>({action:r,body:I})),n).then(I=>(u.objectIDs=u.objectIDs.concat(I.objectIDs),u.taskIDs.push(I.taskID),C++,A(C)))};return Gt.createWaitablePromise(A(),(p,h)=>Promise.all(p.taskIDs.map(C=>Zi(t)(C,h))))},bmt=t=>e=>Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:Gt.encode("1/indexes/%s/clear",t.indexName)},e),(r,o)=>Zi(t)(r.taskID,o)),kmt=t=>e=>{let{forwardToReplicas:r,...o}=e||{},a=La.createMappedRequestOptions(o);return r&&(a.queryParameters.forwardToReplicas=1),Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:Gt.encode("1/indexes/%s/rules/clear",t.indexName)},a),(n,u)=>Zi(t)(n.taskID,u))},Qmt=t=>e=>{let{forwardToReplicas:r,...o}=e||{},a=La.createMappedRequestOptions(o);return r&&(a.queryParameters.forwardToReplicas=1),Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:Gt.encode("1/indexes/%s/synonyms/clear",t.indexName)},a),(n,u)=>Zi(t)(n.taskID,u))},Fmt=t=>(e,r)=>Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:Gt.encode("1/indexes/%s/deleteByQuery",t.indexName),data:e},r),(o,a)=>Zi(t)(o.taskID,a)),Rmt=t=>e=>Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Delete,path:Gt.encode("1/indexes/%s",t.indexName)},e),(r,o)=>Zi(t)(r.taskID,o)),Tmt=t=>(e,r)=>Gt.createWaitablePromise(aye(t)([e],r).then(o=>({taskID:o.taskIDs[0]})),(o,a)=>Zi(t)(o.taskID,a)),aye=t=>(e,r)=>{let o=e.map(a=>({objectID:a}));return Dk(t)(o,rm.DeleteObject,r)},Lmt=t=>(e,r)=>{let{forwardToReplicas:o,...a}=r||{},n=La.createMappedRequestOptions(a);return o&&(n.queryParameters.forwardToReplicas=1),Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Delete,path:Gt.encode("1/indexes/%s/rules/%s",t.indexName,e)},n),(u,A)=>Zi(t)(u.taskID,A))},Nmt=t=>(e,r)=>{let{forwardToReplicas:o,...a}=r||{},n=La.createMappedRequestOptions(a);return o&&(n.queryParameters.forwardToReplicas=1),Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Delete,path:Gt.encode("1/indexes/%s/synonyms/%s",t.indexName,e)},n),(u,A)=>Zi(t)(u.taskID,A))},Omt=t=>e=>lye(t)(e).then(()=>!0).catch(r=>{if(r.status!==404)throw r;return!1}),Mmt=t=>(e,r,o)=>t.transporter.read({method:Ir.MethodEnum.Post,path:Gt.encode("1/answers/%s/prediction",t.indexName),data:{query:e,queryLanguages:r},cacheable:!0},o),Umt=t=>(e,r)=>{let{query:o,paginate:a,...n}=r||{},u=0,A=()=>Aye(t)(o||"",{...n,page:u}).then(p=>{for(let[h,C]of Object.entries(p.hits))if(e(C))return{object:C,position:parseInt(h,10),page:u};if(u++,a===!1||u>=p.nbPages)throw nye();return A()});return A()},_mt=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Get,path:Gt.encode("1/indexes/%s/%s",t.indexName,e)},r),Hmt=()=>(t,e)=>{for(let[r,o]of Object.entries(t.hits))if(o.objectID===e)return parseInt(r,10);return-1},jmt=t=>(e,r)=>{let{attributesToRetrieve:o,...a}=r||{},n=e.map(u=>({indexName:t.indexName,objectID:u,...o?{attributesToRetrieve:o}:{}}));return t.transporter.read({method:Ir.MethodEnum.Post,path:"1/indexes/*/objects",data:{requests:n}},a)},qmt=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Get,path:Gt.encode("1/indexes/%s/rules/%s",t.indexName,e)},r),lye=t=>e=>t.transporter.read({method:Ir.MethodEnum.Get,path:Gt.encode("1/indexes/%s/settings",t.indexName),data:{getVersion:2}},e),Gmt=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Get,path:Gt.encode("1/indexes/%s/synonyms/%s",t.indexName,e)},r),cye=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Get,path:Gt.encode("1/indexes/%s/task/%s",t.indexName,e.toString())},r),Ymt=t=>(e,r)=>Gt.createWaitablePromise(uye(t)([e],r).then(o=>({objectID:o.objectIDs[0],taskID:o.taskIDs[0]})),(o,a)=>Zi(t)(o.taskID,a)),uye=t=>(e,r)=>{let{createIfNotExists:o,...a}=r||{},n=o?rm.PartialUpdateObject:rm.PartialUpdateObjectNoCreate;return Dk(t)(e,n,a)},Wmt=t=>(e,r)=>{let{safe:o,autoGenerateObjectIDIfNotExist:a,batchSize:n,...u}=r||{},A=(E,F,N,U)=>Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:Gt.encode("1/indexes/%s/operation",E),data:{operation:N,destination:F}},U),(z,te)=>Zi(t)(z.taskID,te)),p=Math.random().toString(36).substring(7),h=`${t.indexName}_tmp_${p}`,C=qH({appId:t.appId,transporter:t.transporter,indexName:h}),I=[],v=A(t.indexName,h,"copy",{...u,scope:["settings","synonyms","rules"]});I.push(v);let b=(o?v.wait(u):v).then(()=>{let E=C(e,{...u,autoGenerateObjectIDIfNotExist:a,batchSize:n});return I.push(E),o?E.wait(u):E}).then(()=>{let E=A(h,t.indexName,"move",u);return I.push(E),o?E.wait(u):E}).then(()=>Promise.all(I)).then(([E,F,N])=>({objectIDs:F.objectIDs,taskIDs:[E.taskID,...F.taskIDs,N.taskID]}));return Gt.createWaitablePromise(b,(E,F)=>Promise.all(I.map(N=>N.wait(F))))},Kmt=t=>(e,r)=>GH(t)(e,{...r,clearExistingRules:!0}),Vmt=t=>(e,r)=>YH(t)(e,{...r,clearExistingSynonyms:!0}),zmt=t=>(e,r)=>Gt.createWaitablePromise(qH(t)([e],r).then(o=>({objectID:o.objectIDs[0],taskID:o.taskIDs[0]})),(o,a)=>Zi(t)(o.taskID,a)),qH=t=>(e,r)=>{let{autoGenerateObjectIDIfNotExist:o,...a}=r||{},n=o?rm.AddObject:rm.UpdateObject;if(n===rm.UpdateObject){for(let u of e)if(u.objectID===void 0)return Gt.createWaitablePromise(Promise.reject(rye()))}return Dk(t)(e,n,a)},Jmt=t=>(e,r)=>GH(t)([e],r),GH=t=>(e,r)=>{let{forwardToReplicas:o,clearExistingRules:a,...n}=r||{},u=La.createMappedRequestOptions(n);return o&&(u.queryParameters.forwardToReplicas=1),a&&(u.queryParameters.clearExistingRules=1),Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:Gt.encode("1/indexes/%s/rules/batch",t.indexName),data:e},u),(A,p)=>Zi(t)(A.taskID,p))},Xmt=t=>(e,r)=>YH(t)([e],r),YH=t=>(e,r)=>{let{forwardToReplicas:o,clearExistingSynonyms:a,replaceExistingSynonyms:n,...u}=r||{},A=La.createMappedRequestOptions(u);return o&&(A.queryParameters.forwardToReplicas=1),(n||a)&&(A.queryParameters.replaceExistingSynonyms=1),Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:Gt.encode("1/indexes/%s/synonyms/batch",t.indexName),data:e},A),(p,h)=>Zi(t)(p.taskID,h))},Aye=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Post,path:Gt.encode("1/indexes/%s/query",t.indexName),data:{query:e},cacheable:!0},r),fye=t=>(e,r,o)=>t.transporter.read({method:Ir.MethodEnum.Post,path:Gt.encode("1/indexes/%s/facets/%s/query",t.indexName,e),data:{facetQuery:r},cacheable:!0},o),pye=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Post,path:Gt.encode("1/indexes/%s/rules/search",t.indexName),data:{query:e}},r),hye=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Post,path:Gt.encode("1/indexes/%s/synonyms/search",t.indexName),data:{query:e}},r),Zmt=t=>(e,r)=>{let{forwardToReplicas:o,...a}=r||{},n=La.createMappedRequestOptions(a);return o&&(n.queryParameters.forwardToReplicas=1),Gt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Put,path:Gt.encode("1/indexes/%s/settings",t.indexName),data:e},n),(u,A)=>Zi(t)(u.taskID,A))},Zi=t=>(e,r)=>Gt.createRetryablePromise(o=>cye(t)(e,r).then(a=>a.status!=="published"?o():void 0)),$mt={AddObject:"addObject",Analytics:"analytics",Browser:"browse",DeleteIndex:"deleteIndex",DeleteObject:"deleteObject",EditSettings:"editSettings",ListIndexes:"listIndexes",Logs:"logs",Personalization:"personalization",Recommendation:"recommendation",Search:"search",SeeUnretrievableAttributes:"seeUnretrievableAttributes",Settings:"settings",Usage:"usage"},rm={AddObject:"addObject",UpdateObject:"updateObject",PartialUpdateObject:"partialUpdateObject",PartialUpdateObjectNoCreate:"partialUpdateObjectNoCreate",DeleteObject:"deleteObject",DeleteIndex:"delete",ClearIndex:"clear"},Pk={Settings:"settings",Synonyms:"synonyms",Rules:"rules"},eyt={None:"none",StopIfEnoughMatches:"stopIfEnoughMatches"},tyt={Synonym:"synonym",OneWaySynonym:"oneWaySynonym",AltCorrection1:"altCorrection1",AltCorrection2:"altCorrection2",Placeholder:"placeholder"};Ft.ApiKeyACLEnum=$mt;Ft.BatchActionEnum=rm;Ft.ScopeEnum=Pk;Ft.StrategyEnum=eyt;Ft.SynonymEnum=tyt;Ft.addApiKey=Wdt;Ft.assignUserID=Kdt;Ft.assignUserIDs=Vdt;Ft.batch=oye;Ft.browseObjects=Pmt;Ft.browseRules=Smt;Ft.browseSynonyms=xmt;Ft.chunkedBatch=Dk;Ft.clearDictionaryEntries=zdt;Ft.clearObjects=bmt;Ft.clearRules=kmt;Ft.clearSynonyms=Qmt;Ft.copyIndex=vk;Ft.copyRules=Jdt;Ft.copySettings=Xdt;Ft.copySynonyms=Zdt;Ft.createBrowsablePromise=Bk;Ft.createMissingObjectIDError=rye;Ft.createObjectNotFoundError=nye;Ft.createSearchClient=Ydt;Ft.createValidUntilNotFoundError=iye;Ft.customRequest=$dt;Ft.deleteApiKey=emt;Ft.deleteBy=Fmt;Ft.deleteDictionaryEntries=tmt;Ft.deleteIndex=Rmt;Ft.deleteObject=Tmt;Ft.deleteObjects=aye;Ft.deleteRule=Lmt;Ft.deleteSynonym=Nmt;Ft.exists=Omt;Ft.findAnswers=Mmt;Ft.findObject=Umt;Ft.generateSecuredApiKey=rmt;Ft.getApiKey=J2;Ft.getAppTask=sye;Ft.getDictionarySettings=nmt;Ft.getLogs=imt;Ft.getObject=_mt;Ft.getObjectPosition=Hmt;Ft.getObjects=jmt;Ft.getRule=qmt;Ft.getSecuredApiKeyRemainingValidity=smt;Ft.getSettings=lye;Ft.getSynonym=Gmt;Ft.getTask=cye;Ft.getTopUserIDs=omt;Ft.getUserID=amt;Ft.hasPendingMappings=lmt;Ft.initIndex=X2;Ft.listApiKeys=cmt;Ft.listClusters=umt;Ft.listIndices=Amt;Ft.listUserIDs=fmt;Ft.moveIndex=pmt;Ft.multipleBatch=hmt;Ft.multipleGetObjects=gmt;Ft.multipleQueries=dmt;Ft.multipleSearchForFacetValues=mmt;Ft.partialUpdateObject=Ymt;Ft.partialUpdateObjects=uye;Ft.removeUserID=ymt;Ft.replaceAllObjects=Wmt;Ft.replaceAllRules=Kmt;Ft.replaceAllSynonyms=Vmt;Ft.replaceDictionaryEntries=Emt;Ft.restoreApiKey=Cmt;Ft.saveDictionaryEntries=wmt;Ft.saveObject=zmt;Ft.saveObjects=qH;Ft.saveRule=Jmt;Ft.saveRules=GH;Ft.saveSynonym=Xmt;Ft.saveSynonyms=YH;Ft.search=Aye;Ft.searchDictionaryEntries=Imt;Ft.searchForFacetValues=fye;Ft.searchRules=pye;Ft.searchSynonyms=hye;Ft.searchUserIDs=Bmt;Ft.setDictionarySettings=vmt;Ft.setSettings=Zmt;Ft.updateApiKey=Dmt;Ft.waitAppTask=kC;Ft.waitTask=Zi});var mye=_((jWt,dye)=>{dye.exports=gye()});var yye=_(Sk=>{"use strict";Object.defineProperty(Sk,"__esModule",{value:!0});function ryt(){return{debug(t,e){return Promise.resolve()},info(t,e){return Promise.resolve()},error(t,e){return Promise.resolve()}}}var nyt={Debug:1,Info:2,Error:3};Sk.LogLevelEnum=nyt;Sk.createNullLogger=ryt});var Cye=_((GWt,Eye)=>{Eye.exports=yye()});var vye=_(WH=>{"use strict";Object.defineProperty(WH,"__esModule",{value:!0});var wye=Be("http"),Iye=Be("https"),iyt=Be("url"),Bye={keepAlive:!0},syt=new wye.Agent(Bye),oyt=new Iye.Agent(Bye);function ayt({agent:t,httpAgent:e,httpsAgent:r,requesterOptions:o={}}={}){let a=e||t||syt,n=r||t||oyt;return{send(u){return new Promise(A=>{let p=iyt.parse(u.url),h=p.query===null?p.pathname:`${p.pathname}?${p.query}`,C={...o,agent:p.protocol==="https:"?n:a,hostname:p.hostname,path:h,method:u.method,headers:{...o&&o.headers?o.headers:{},...u.headers},...p.port!==void 0?{port:p.port||""}:{}},I=(p.protocol==="https:"?Iye:wye).request(C,F=>{let N=[];F.on("data",U=>{N=N.concat(U)}),F.on("end",()=>{clearTimeout(b),clearTimeout(E),A({status:F.statusCode||0,content:Buffer.concat(N).toString(),isTimedOut:!1})})}),v=(F,N)=>setTimeout(()=>{I.abort(),A({status:0,content:N,isTimedOut:!0})},F*1e3),b=v(u.connectTimeout,"Connection timeout"),E;I.on("error",F=>{clearTimeout(b),clearTimeout(E),A({status:0,content:F.message,isTimedOut:!1})}),I.once("response",()=>{clearTimeout(b),E=v(u.responseTimeout,"Socket timeout")}),u.data!==void 0&&I.write(u.data),I.end()})},destroy(){return a.destroy(),n.destroy(),Promise.resolve()}}}WH.createNodeHttpRequester=ayt});var Pye=_((WWt,Dye)=>{Dye.exports=vye()});var kye=_((KWt,bye)=>{"use strict";var Sye=vme(),lyt=Sme(),QC=Xme(),VH=G2(),KH=tye(),Mt=mye(),cyt=Cye(),uyt=Pye(),Ayt=K2();function xye(t,e,r){let o={appId:t,apiKey:e,timeouts:{connect:2,read:5,write:30},requester:uyt.createNodeHttpRequester(),logger:cyt.createNullLogger(),responsesCache:Sye.createNullCache(),requestsCache:Sye.createNullCache(),hostsCache:lyt.createInMemoryCache(),userAgent:Ayt.createUserAgent(VH.version).add({segment:"Node.js",version:process.versions.node})},a={...o,...r},n=()=>u=>KH.createPersonalizationClient({...o,...u,methods:{getPersonalizationStrategy:KH.getPersonalizationStrategy,setPersonalizationStrategy:KH.setPersonalizationStrategy}});return Mt.createSearchClient({...a,methods:{search:Mt.multipleQueries,searchForFacetValues:Mt.multipleSearchForFacetValues,multipleBatch:Mt.multipleBatch,multipleGetObjects:Mt.multipleGetObjects,multipleQueries:Mt.multipleQueries,copyIndex:Mt.copyIndex,copySettings:Mt.copySettings,copyRules:Mt.copyRules,copySynonyms:Mt.copySynonyms,moveIndex:Mt.moveIndex,listIndices:Mt.listIndices,getLogs:Mt.getLogs,listClusters:Mt.listClusters,multipleSearchForFacetValues:Mt.multipleSearchForFacetValues,getApiKey:Mt.getApiKey,addApiKey:Mt.addApiKey,listApiKeys:Mt.listApiKeys,updateApiKey:Mt.updateApiKey,deleteApiKey:Mt.deleteApiKey,restoreApiKey:Mt.restoreApiKey,assignUserID:Mt.assignUserID,assignUserIDs:Mt.assignUserIDs,getUserID:Mt.getUserID,searchUserIDs:Mt.searchUserIDs,listUserIDs:Mt.listUserIDs,getTopUserIDs:Mt.getTopUserIDs,removeUserID:Mt.removeUserID,hasPendingMappings:Mt.hasPendingMappings,generateSecuredApiKey:Mt.generateSecuredApiKey,getSecuredApiKeyRemainingValidity:Mt.getSecuredApiKeyRemainingValidity,destroy:VH.destroy,clearDictionaryEntries:Mt.clearDictionaryEntries,deleteDictionaryEntries:Mt.deleteDictionaryEntries,getDictionarySettings:Mt.getDictionarySettings,getAppTask:Mt.getAppTask,replaceDictionaryEntries:Mt.replaceDictionaryEntries,saveDictionaryEntries:Mt.saveDictionaryEntries,searchDictionaryEntries:Mt.searchDictionaryEntries,setDictionarySettings:Mt.setDictionarySettings,waitAppTask:Mt.waitAppTask,customRequest:Mt.customRequest,initIndex:u=>A=>Mt.initIndex(u)(A,{methods:{batch:Mt.batch,delete:Mt.deleteIndex,findAnswers:Mt.findAnswers,getObject:Mt.getObject,getObjects:Mt.getObjects,saveObject:Mt.saveObject,saveObjects:Mt.saveObjects,search:Mt.search,searchForFacetValues:Mt.searchForFacetValues,waitTask:Mt.waitTask,setSettings:Mt.setSettings,getSettings:Mt.getSettings,partialUpdateObject:Mt.partialUpdateObject,partialUpdateObjects:Mt.partialUpdateObjects,deleteObject:Mt.deleteObject,deleteObjects:Mt.deleteObjects,deleteBy:Mt.deleteBy,clearObjects:Mt.clearObjects,browseObjects:Mt.browseObjects,getObjectPosition:Mt.getObjectPosition,findObject:Mt.findObject,exists:Mt.exists,saveSynonym:Mt.saveSynonym,saveSynonyms:Mt.saveSynonyms,getSynonym:Mt.getSynonym,searchSynonyms:Mt.searchSynonyms,browseSynonyms:Mt.browseSynonyms,deleteSynonym:Mt.deleteSynonym,clearSynonyms:Mt.clearSynonyms,replaceAllObjects:Mt.replaceAllObjects,replaceAllSynonyms:Mt.replaceAllSynonyms,searchRules:Mt.searchRules,getRule:Mt.getRule,deleteRule:Mt.deleteRule,saveRule:Mt.saveRule,saveRules:Mt.saveRules,replaceAllRules:Mt.replaceAllRules,browseRules:Mt.browseRules,clearRules:Mt.clearRules}}),initAnalytics:()=>u=>QC.createAnalyticsClient({...o,...u,methods:{addABTest:QC.addABTest,getABTest:QC.getABTest,getABTests:QC.getABTests,stopABTest:QC.stopABTest,deleteABTest:QC.deleteABTest}}),initPersonalization:n,initRecommendation:()=>u=>(a.logger.info("The `initRecommendation` method is deprecated. Use `initPersonalization` instead."),n()(u))}})}xye.version=VH.version;bye.exports=xye});var JH=_((VWt,zH)=>{var Qye=kye();zH.exports=Qye;zH.exports.default=Qye});var $H=_((JWt,Tye)=>{"use strict";var Rye=Object.getOwnPropertySymbols,pyt=Object.prototype.hasOwnProperty,hyt=Object.prototype.propertyIsEnumerable;function gyt(t){if(t==null)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(t)}function dyt(){try{if(!Object.assign)return!1;var t=new String("abc");if(t[5]="de",Object.getOwnPropertyNames(t)[0]==="5")return!1;for(var e={},r=0;r<10;r++)e["_"+String.fromCharCode(r)]=r;var o=Object.getOwnPropertyNames(e).map(function(n){return e[n]});if(o.join("")!=="0123456789")return!1;var a={};return"abcdefghijklmnopqrst".split("").forEach(function(n){a[n]=n}),Object.keys(Object.assign({},a)).join("")==="abcdefghijklmnopqrst"}catch{return!1}}Tye.exports=dyt()?Object.assign:function(t,e){for(var r,o=gyt(t),a,n=1;n<arguments.length;n++){r=Object(arguments[n]);for(var u in r)pyt.call(r,u)&&(o[u]=r[u]);if(Rye){a=Rye(r);for(var A=0;A<a.length;A++)hyt.call(r,a[A])&&(o[a[A]]=r[a[A]])}}return o}});var Wye=_(Ln=>{"use strict";var i6=$H(),$c=typeof Symbol=="function"&&Symbol.for,Z2=$c?Symbol.for("react.element"):60103,myt=$c?Symbol.for("react.portal"):60106,yyt=$c?Symbol.for("react.fragment"):60107,Eyt=$c?Symbol.for("react.strict_mode"):60108,Cyt=$c?Symbol.for("react.profiler"):60114,wyt=$c?Symbol.for("react.provider"):60109,Iyt=$c?Symbol.for("react.context"):60110,Byt=$c?Symbol.for("react.forward_ref"):60112,vyt=$c?Symbol.for("react.suspense"):60113,Dyt=$c?Symbol.for("react.memo"):60115,Pyt=$c?Symbol.for("react.lazy"):60116,Lye=typeof Symbol=="function"&&Symbol.iterator;function $2(t){for(var e="https://reactjs.org/docs/error-decoder.html?invariant="+t,r=1;r<arguments.length;r++)e+="&args[]="+encodeURIComponent(arguments[r]);return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}var Nye={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Oye={};function FC(t,e,r){this.props=t,this.context=e,this.refs=Oye,this.updater=r||Nye}FC.prototype.isReactComponent={};FC.prototype.setState=function(t,e){if(typeof t!="object"&&typeof t!="function"&&t!=null)throw Error($2(85));this.updater.enqueueSetState(this,t,e,"setState")};FC.prototype.forceUpdate=function(t){this.updater.enqueueForceUpdate(this,t,"forceUpdate")};function Mye(){}Mye.prototype=FC.prototype;function s6(t,e,r){this.props=t,this.context=e,this.refs=Oye,this.updater=r||Nye}var o6=s6.prototype=new Mye;o6.constructor=s6;i6(o6,FC.prototype);o6.isPureReactComponent=!0;var a6={current:null},Uye=Object.prototype.hasOwnProperty,_ye={key:!0,ref:!0,__self:!0,__source:!0};function Hye(t,e,r){var o,a={},n=null,u=null;if(e!=null)for(o in e.ref!==void 0&&(u=e.ref),e.key!==void 0&&(n=""+e.key),e)Uye.call(e,o)&&!_ye.hasOwnProperty(o)&&(a[o]=e[o]);var A=arguments.length-2;if(A===1)a.children=r;else if(1<A){for(var p=Array(A),h=0;h<A;h++)p[h]=arguments[h+2];a.children=p}if(t&&t.defaultProps)for(o in A=t.defaultProps,A)a[o]===void 0&&(a[o]=A[o]);return{$$typeof:Z2,type:t,key:n,ref:u,props:a,_owner:a6.current}}function Syt(t,e){return{$$typeof:Z2,type:t.type,key:e,ref:t.ref,props:t.props,_owner:t._owner}}function l6(t){return typeof t=="object"&&t!==null&&t.$$typeof===Z2}function xyt(t){var e={"=":"=0",":":"=2"};return"$"+(""+t).replace(/[=:]/g,function(r){return e[r]})}var jye=/\/+/g,xk=[];function qye(t,e,r,o){if(xk.length){var a=xk.pop();return a.result=t,a.keyPrefix=e,a.func=r,a.context=o,a.count=0,a}return{result:t,keyPrefix:e,func:r,context:o,count:0}}function Gye(t){t.result=null,t.keyPrefix=null,t.func=null,t.context=null,t.count=0,10>xk.length&&xk.push(t)}function t6(t,e,r,o){var a=typeof t;(a==="undefined"||a==="boolean")&&(t=null);var n=!1;if(t===null)n=!0;else switch(a){case"string":case"number":n=!0;break;case"object":switch(t.$$typeof){case Z2:case myt:n=!0}}if(n)return r(o,t,e===""?"."+e6(t,0):e),1;if(n=0,e=e===""?".":e+":",Array.isArray(t))for(var u=0;u<t.length;u++){a=t[u];var A=e+e6(a,u);n+=t6(a,A,r,o)}else if(t===null||typeof t!="object"?A=null:(A=Lye&&t[Lye]||t["@@iterator"],A=typeof A=="function"?A:null),typeof A=="function")for(t=A.call(t),u=0;!(a=t.next()).done;)a=a.value,A=e+e6(a,u++),n+=t6(a,A,r,o);else if(a==="object")throw r=""+t,Error($2(31,r==="[object Object]"?"object with keys {"+Object.keys(t).join(", ")+"}":r,""));return n}function r6(t,e,r){return t==null?0:t6(t,"",e,r)}function e6(t,e){return typeof t=="object"&&t!==null&&t.key!=null?xyt(t.key):e.toString(36)}function byt(t,e){t.func.call(t.context,e,t.count++)}function kyt(t,e,r){var o=t.result,a=t.keyPrefix;t=t.func.call(t.context,e,t.count++),Array.isArray(t)?n6(t,o,r,function(n){return n}):t!=null&&(l6(t)&&(t=Syt(t,a+(!t.key||e&&e.key===t.key?"":(""+t.key).replace(jye,"$&/")+"/")+r)),o.push(t))}function n6(t,e,r,o,a){var n="";r!=null&&(n=(""+r).replace(jye,"$&/")+"/"),e=qye(e,n,o,a),r6(t,kyt,e),Gye(e)}var Yye={current:null};function zf(){var t=Yye.current;if(t===null)throw Error($2(321));return t}var Qyt={ReactCurrentDispatcher:Yye,ReactCurrentBatchConfig:{suspense:null},ReactCurrentOwner:a6,IsSomeRendererActing:{current:!1},assign:i6};Ln.Children={map:function(t,e,r){if(t==null)return t;var o=[];return n6(t,o,null,e,r),o},forEach:function(t,e,r){if(t==null)return t;e=qye(null,null,e,r),r6(t,byt,e),Gye(e)},count:function(t){return r6(t,function(){return null},null)},toArray:function(t){var e=[];return n6(t,e,null,function(r){return r}),e},only:function(t){if(!l6(t))throw Error($2(143));return t}};Ln.Component=FC;Ln.Fragment=yyt;Ln.Profiler=Cyt;Ln.PureComponent=s6;Ln.StrictMode=Eyt;Ln.Suspense=vyt;Ln.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=Qyt;Ln.cloneElement=function(t,e,r){if(t==null)throw Error($2(267,t));var o=i6({},t.props),a=t.key,n=t.ref,u=t._owner;if(e!=null){if(e.ref!==void 0&&(n=e.ref,u=a6.current),e.key!==void 0&&(a=""+e.key),t.type&&t.type.defaultProps)var A=t.type.defaultProps;for(p in e)Uye.call(e,p)&&!_ye.hasOwnProperty(p)&&(o[p]=e[p]===void 0&&A!==void 0?A[p]:e[p])}var p=arguments.length-2;if(p===1)o.children=r;else if(1<p){A=Array(p);for(var h=0;h<p;h++)A[h]=arguments[h+2];o.children=A}return{$$typeof:Z2,type:t.type,key:a,ref:n,props:o,_owner:u}};Ln.createContext=function(t,e){return e===void 0&&(e=null),t={$$typeof:Iyt,_calculateChangedBits:e,_currentValue:t,_currentValue2:t,_threadCount:0,Provider:null,Consumer:null},t.Provider={$$typeof:wyt,_context:t},t.Consumer=t};Ln.createElement=Hye;Ln.createFactory=function(t){var e=Hye.bind(null,t);return e.type=t,e};Ln.createRef=function(){return{current:null}};Ln.forwardRef=function(t){return{$$typeof:Byt,render:t}};Ln.isValidElement=l6;Ln.lazy=function(t){return{$$typeof:Pyt,_ctor:t,_status:-1,_result:null}};Ln.memo=function(t,e){return{$$typeof:Dyt,type:t,compare:e===void 0?null:e}};Ln.useCallback=function(t,e){return zf().useCallback(t,e)};Ln.useContext=function(t,e){return zf().useContext(t,e)};Ln.useDebugValue=function(){};Ln.useEffect=function(t,e){return zf().useEffect(t,e)};Ln.useImperativeHandle=function(t,e,r){return zf().useImperativeHandle(t,e,r)};Ln.useLayoutEffect=function(t,e){return zf().useLayoutEffect(t,e)};Ln.useMemo=function(t,e){return zf().useMemo(t,e)};Ln.useReducer=function(t,e,r){return zf().useReducer(t,e,r)};Ln.useRef=function(t){return zf().useRef(t)};Ln.useState=function(t){return zf().useState(t)};Ln.version="16.13.1"});var sn=_((ZWt,Kye)=>{"use strict";Kye.exports=Wye()});var u6=_(($Wt,c6)=>{"use strict";var An=c6.exports;c6.exports.default=An;var Nn="\x1B[",eB="\x1B]",RC="\x07",bk=";",Vye=process.env.TERM_PROGRAM==="Apple_Terminal";An.cursorTo=(t,e)=>{if(typeof t!="number")throw new TypeError("The `x` argument is required");return typeof e!="number"?Nn+(t+1)+"G":Nn+(e+1)+";"+(t+1)+"H"};An.cursorMove=(t,e)=>{if(typeof t!="number")throw new TypeError("The `x` argument is required");let r="";return t<0?r+=Nn+-t+"D":t>0&&(r+=Nn+t+"C"),e<0?r+=Nn+-e+"A":e>0&&(r+=Nn+e+"B"),r};An.cursorUp=(t=1)=>Nn+t+"A";An.cursorDown=(t=1)=>Nn+t+"B";An.cursorForward=(t=1)=>Nn+t+"C";An.cursorBackward=(t=1)=>Nn+t+"D";An.cursorLeft=Nn+"G";An.cursorSavePosition=Vye?"\x1B7":Nn+"s";An.cursorRestorePosition=Vye?"\x1B8":Nn+"u";An.cursorGetPosition=Nn+"6n";An.cursorNextLine=Nn+"E";An.cursorPrevLine=Nn+"F";An.cursorHide=Nn+"?25l";An.cursorShow=Nn+"?25h";An.eraseLines=t=>{let e="";for(let r=0;r<t;r++)e+=An.eraseLine+(r<t-1?An.cursorUp():"");return t&&(e+=An.cursorLeft),e};An.eraseEndLine=Nn+"K";An.eraseStartLine=Nn+"1K";An.eraseLine=Nn+"2K";An.eraseDown=Nn+"J";An.eraseUp=Nn+"1J";An.eraseScreen=Nn+"2J";An.scrollUp=Nn+"S";An.scrollDown=Nn+"T";An.clearScreen="\x1Bc";An.clearTerminal=process.platform==="win32"?`${An.eraseScreen}${Nn}0f`:`${An.eraseScreen}${Nn}3J${Nn}H`;An.beep=RC;An.link=(t,e)=>[eB,"8",bk,bk,e,RC,t,eB,"8",bk,bk,RC].join("");An.image=(t,e={})=>{let r=`${eB}1337;File=inline=1`;return e.width&&(r+=`;width=${e.width}`),e.height&&(r+=`;height=${e.height}`),e.preserveAspectRatio===!1&&(r+=";preserveAspectRatio=0"),r+":"+t.toString("base64")+RC};An.iTerm={setCwd:(t=process.cwd())=>`${eB}50;CurrentDir=${t}${RC}`,annotation:(t,e={})=>{let r=`${eB}1337;`,o=typeof e.x<"u",a=typeof e.y<"u";if((o||a)&&!(o&&a&&typeof e.length<"u"))throw new Error("`x`, `y` and `length` must be defined when `x` or `y` is defined");return t=t.replace(/\|/g,""),r+=e.isHidden?"AddHiddenAnnotation=":"AddAnnotation=",e.length>0?r+=(o?[t,e.length,e.x,e.y]:[e.length,t]).join("|"):r+=t,r+RC}}});var Jye=_((eKt,A6)=>{"use strict";var zye=(t,e)=>{for(let r of Reflect.ownKeys(e))Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(e,r));return t};A6.exports=zye;A6.exports.default=zye});var Zye=_((tKt,Qk)=>{"use strict";var Fyt=Jye(),kk=new WeakMap,Xye=(t,e={})=>{if(typeof t!="function")throw new TypeError("Expected a function");let r,o=0,a=t.displayName||t.name||"<anonymous>",n=function(...u){if(kk.set(n,++o),o===1)r=t.apply(this,u),t=null;else if(e.throw===!0)throw new Error(`Function \`${a}\` can only be called once`);return r};return Fyt(n,t),kk.set(n,o),n};Qk.exports=Xye;Qk.exports.default=Xye;Qk.exports.callCount=t=>{if(!kk.has(t))throw new Error(`The given function \`${t.name}\` is not wrapped by the \`onetime\` package`);return kk.get(t)}});var $ye=_((rKt,Fk)=>{Fk.exports=["SIGABRT","SIGALRM","SIGHUP","SIGINT","SIGTERM"];process.platform!=="win32"&&Fk.exports.push("SIGVTALRM","SIGXCPU","SIGXFSZ","SIGUSR2","SIGTRAP","SIGSYS","SIGQUIT","SIGIOT");process.platform==="linux"&&Fk.exports.push("SIGIO","SIGPOLL","SIGPWR","SIGSTKFLT","SIGUNUSED")});var h6=_((nKt,NC)=>{var Ei=global.process,nm=function(t){return t&&typeof t=="object"&&typeof t.removeListener=="function"&&typeof t.emit=="function"&&typeof t.reallyExit=="function"&&typeof t.listeners=="function"&&typeof t.kill=="function"&&typeof t.pid=="number"&&typeof t.on=="function"};nm(Ei)?(eEe=Be("assert"),TC=$ye(),tEe=/^win/i.test(Ei.platform),tB=Be("events"),typeof tB!="function"&&(tB=tB.EventEmitter),Ei.__signal_exit_emitter__?Ls=Ei.__signal_exit_emitter__:(Ls=Ei.__signal_exit_emitter__=new tB,Ls.count=0,Ls.emitted={}),Ls.infinite||(Ls.setMaxListeners(1/0),Ls.infinite=!0),NC.exports=function(t,e){if(!nm(global.process))return function(){};eEe.equal(typeof t,"function","a callback must be provided for exit handler"),LC===!1&&f6();var r="exit";e&&e.alwaysLast&&(r="afterexit");var o=function(){Ls.removeListener(r,t),Ls.listeners("exit").length===0&&Ls.listeners("afterexit").length===0&&Rk()};return Ls.on(r,t),o},Rk=function(){!LC||!nm(global.process)||(LC=!1,TC.forEach(function(e){try{Ei.removeListener(e,Tk[e])}catch{}}),Ei.emit=Lk,Ei.reallyExit=p6,Ls.count-=1)},NC.exports.unload=Rk,im=function(e,r,o){Ls.emitted[e]||(Ls.emitted[e]=!0,Ls.emit(e,r,o))},Tk={},TC.forEach(function(t){Tk[t]=function(){if(!!nm(global.process)){var r=Ei.listeners(t);r.length===Ls.count&&(Rk(),im("exit",null,t),im("afterexit",null,t),tEe&&t==="SIGHUP"&&(t="SIGINT"),Ei.kill(Ei.pid,t))}}}),NC.exports.signals=function(){return TC},LC=!1,f6=function(){LC||!nm(global.process)||(LC=!0,Ls.count+=1,TC=TC.filter(function(e){try{return Ei.on(e,Tk[e]),!0}catch{return!1}}),Ei.emit=nEe,Ei.reallyExit=rEe)},NC.exports.load=f6,p6=Ei.reallyExit,rEe=function(e){!nm(global.process)||(Ei.exitCode=e||0,im("exit",Ei.exitCode,null),im("afterexit",Ei.exitCode,null),p6.call(Ei,Ei.exitCode))},Lk=Ei.emit,nEe=function(e,r){if(e==="exit"&&nm(global.process)){r!==void 0&&(Ei.exitCode=r);var o=Lk.apply(this,arguments);return im("exit",Ei.exitCode,null),im("afterexit",Ei.exitCode,null),o}else return Lk.apply(this,arguments)}):NC.exports=function(){return function(){}};var eEe,TC,tEe,tB,Ls,Rk,im,Tk,LC,f6,p6,rEe,Lk,nEe});var sEe=_((iKt,iEe)=>{"use strict";var Ryt=Zye(),Tyt=h6();iEe.exports=Ryt(()=>{Tyt(()=>{process.stderr.write("\x1B[?25h")},{alwaysLast:!0})})});var g6=_(OC=>{"use strict";var Lyt=sEe(),Nk=!1;OC.show=(t=process.stderr)=>{!t.isTTY||(Nk=!1,t.write("\x1B[?25h"))};OC.hide=(t=process.stderr)=>{!t.isTTY||(Lyt(),Nk=!0,t.write("\x1B[?25l"))};OC.toggle=(t,e)=>{t!==void 0&&(Nk=t),Nk?OC.show(e):OC.hide(e)}});var cEe=_(rB=>{"use strict";var lEe=rB&&rB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(rB,"__esModule",{value:!0});var oEe=lEe(u6()),aEe=lEe(g6()),Nyt=(t,{showCursor:e=!1}={})=>{let r=0,o="",a=!1,n=u=>{!e&&!a&&(aEe.default.hide(),a=!0);let A=u+` -`;A!==o&&(o=A,t.write(oEe.default.eraseLines(r)+A),r=A.split(` -`).length)};return n.clear=()=>{t.write(oEe.default.eraseLines(r)),o="",r=0},n.done=()=>{o="",r=0,e||(aEe.default.show(),a=!1)},n};rB.default={create:Nyt}});var uEe=_((aKt,Oyt)=>{Oyt.exports=[{name:"AppVeyor",constant:"APPVEYOR",env:"APPVEYOR",pr:"APPVEYOR_PULL_REQUEST_NUMBER"},{name:"Azure Pipelines",constant:"AZURE_PIPELINES",env:"SYSTEM_TEAMFOUNDATIONCOLLECTIONURI",pr:"SYSTEM_PULLREQUEST_PULLREQUESTID"},{name:"Bamboo",constant:"BAMBOO",env:"bamboo_planKey"},{name:"Bitbucket Pipelines",constant:"BITBUCKET",env:"BITBUCKET_COMMIT",pr:"BITBUCKET_PR_ID"},{name:"Bitrise",constant:"BITRISE",env:"BITRISE_IO",pr:"BITRISE_PULL_REQUEST"},{name:"Buddy",constant:"BUDDY",env:"BUDDY_WORKSPACE_ID",pr:"BUDDY_EXECUTION_PULL_REQUEST_ID"},{name:"Buildkite",constant:"BUILDKITE",env:"BUILDKITE",pr:{env:"BUILDKITE_PULL_REQUEST",ne:"false"}},{name:"CircleCI",constant:"CIRCLE",env:"CIRCLECI",pr:"CIRCLE_PULL_REQUEST"},{name:"Cirrus CI",constant:"CIRRUS",env:"CIRRUS_CI",pr:"CIRRUS_PR"},{name:"AWS CodeBuild",constant:"CODEBUILD",env:"CODEBUILD_BUILD_ARN"},{name:"Codeship",constant:"CODESHIP",env:{CI_NAME:"codeship"}},{name:"Drone",constant:"DRONE",env:"DRONE",pr:{DRONE_BUILD_EVENT:"pull_request"}},{name:"dsari",constant:"DSARI",env:"DSARI"},{name:"GitLab CI",constant:"GITLAB",env:"GITLAB_CI"},{name:"GoCD",constant:"GOCD",env:"GO_PIPELINE_LABEL"},{name:"Hudson",constant:"HUDSON",env:"HUDSON_URL"},{name:"Jenkins",constant:"JENKINS",env:["JENKINS_URL","BUILD_ID"],pr:{any:["ghprbPullId","CHANGE_ID"]}},{name:"Magnum CI",constant:"MAGNUM",env:"MAGNUM"},{name:"Netlify CI",constant:"NETLIFY",env:"NETLIFY_BUILD_BASE",pr:{env:"PULL_REQUEST",ne:"false"}},{name:"Sail CI",constant:"SAIL",env:"SAILCI",pr:"SAIL_PULL_REQUEST_NUMBER"},{name:"Semaphore",constant:"SEMAPHORE",env:"SEMAPHORE",pr:"PULL_REQUEST_NUMBER"},{name:"Shippable",constant:"SHIPPABLE",env:"SHIPPABLE",pr:{IS_PULL_REQUEST:"true"}},{name:"Solano CI",constant:"SOLANO",env:"TDDIUM",pr:"TDDIUM_PR_ID"},{name:"Strider CD",constant:"STRIDER",env:"STRIDER"},{name:"TaskCluster",constant:"TASKCLUSTER",env:["TASK_ID","RUN_ID"]},{name:"TeamCity",constant:"TEAMCITY",env:"TEAMCITY_VERSION"},{name:"Travis CI",constant:"TRAVIS",env:"TRAVIS",pr:{env:"TRAVIS_PULL_REQUEST",ne:"false"}}]});var pEe=_(gl=>{"use strict";var fEe=uEe(),pA=process.env;Object.defineProperty(gl,"_vendors",{value:fEe.map(function(t){return t.constant})});gl.name=null;gl.isPR=null;fEe.forEach(function(t){var e=Array.isArray(t.env)?t.env:[t.env],r=e.every(function(o){return AEe(o)});if(gl[t.constant]=r,r)switch(gl.name=t.name,typeof t.pr){case"string":gl.isPR=!!pA[t.pr];break;case"object":"env"in t.pr?gl.isPR=t.pr.env in pA&&pA[t.pr.env]!==t.pr.ne:"any"in t.pr?gl.isPR=t.pr.any.some(function(o){return!!pA[o]}):gl.isPR=AEe(t.pr);break;default:gl.isPR=null}});gl.isCI=!!(pA.CI||pA.CONTINUOUS_INTEGRATION||pA.BUILD_NUMBER||pA.RUN_ID||gl.name);function AEe(t){return typeof t=="string"?!!pA[t]:Object.keys(t).every(function(e){return pA[e]===t[e]})}});var gEe=_((cKt,hEe)=>{"use strict";hEe.exports=pEe().isCI});var mEe=_((uKt,dEe)=>{"use strict";var Myt=t=>{let e=new Set;do for(let r of Reflect.ownKeys(t))e.add([t,r]);while((t=Reflect.getPrototypeOf(t))&&t!==Object.prototype);return e};dEe.exports=(t,{include:e,exclude:r}={})=>{let o=a=>{let n=u=>typeof u=="string"?a===u:u.test(a);return e?e.some(n):r?!r.some(n):!0};for(let[a,n]of Myt(t.constructor.prototype)){if(n==="constructor"||!o(n))continue;let u=Reflect.getOwnPropertyDescriptor(a,n);u&&typeof u.value=="function"&&(t[n]=t[n].bind(t))}return t}});var vEe=_(kn=>{"use strict";Object.defineProperty(kn,"__esModule",{value:!0});var UC,sB,Hk,jk,I6;typeof window>"u"||typeof MessageChannel!="function"?(MC=null,d6=null,m6=function(){if(MC!==null)try{var t=kn.unstable_now();MC(!0,t),MC=null}catch(e){throw setTimeout(m6,0),e}},yEe=Date.now(),kn.unstable_now=function(){return Date.now()-yEe},UC=function(t){MC!==null?setTimeout(UC,0,t):(MC=t,setTimeout(m6,0))},sB=function(t,e){d6=setTimeout(t,e)},Hk=function(){clearTimeout(d6)},jk=function(){return!1},I6=kn.unstable_forceFrameRate=function(){}):(Ok=window.performance,y6=window.Date,EEe=window.setTimeout,CEe=window.clearTimeout,typeof console<"u"&&(wEe=window.cancelAnimationFrame,typeof window.requestAnimationFrame!="function"&&console.error("This browser doesn't support requestAnimationFrame. Make sure that you load a polyfill in older browsers. https://fb.me/react-polyfills"),typeof wEe!="function"&&console.error("This browser doesn't support cancelAnimationFrame. Make sure that you load a polyfill in older browsers. https://fb.me/react-polyfills")),typeof Ok=="object"&&typeof Ok.now=="function"?kn.unstable_now=function(){return Ok.now()}:(IEe=y6.now(),kn.unstable_now=function(){return y6.now()-IEe}),nB=!1,iB=null,Mk=-1,E6=5,C6=0,jk=function(){return kn.unstable_now()>=C6},I6=function(){},kn.unstable_forceFrameRate=function(t){0>t||125<t?console.error("forceFrameRate takes a positive int between 0 and 125, forcing framerates higher than 125 fps is not unsupported"):E6=0<t?Math.floor(1e3/t):5},w6=new MessageChannel,Uk=w6.port2,w6.port1.onmessage=function(){if(iB!==null){var t=kn.unstable_now();C6=t+E6;try{iB(!0,t)?Uk.postMessage(null):(nB=!1,iB=null)}catch(e){throw Uk.postMessage(null),e}}else nB=!1},UC=function(t){iB=t,nB||(nB=!0,Uk.postMessage(null))},sB=function(t,e){Mk=EEe(function(){t(kn.unstable_now())},e)},Hk=function(){CEe(Mk),Mk=-1});var MC,d6,m6,yEe,Ok,y6,EEe,CEe,wEe,IEe,nB,iB,Mk,E6,C6,w6,Uk;function B6(t,e){var r=t.length;t.push(e);e:for(;;){var o=Math.floor((r-1)/2),a=t[o];if(a!==void 0&&0<_k(a,e))t[o]=e,t[r]=a,r=o;else break e}}function nc(t){return t=t[0],t===void 0?null:t}function qk(t){var e=t[0];if(e!==void 0){var r=t.pop();if(r!==e){t[0]=r;e:for(var o=0,a=t.length;o<a;){var n=2*(o+1)-1,u=t[n],A=n+1,p=t[A];if(u!==void 0&&0>_k(u,r))p!==void 0&&0>_k(p,u)?(t[o]=p,t[A]=r,o=A):(t[o]=u,t[n]=r,o=n);else if(p!==void 0&&0>_k(p,r))t[o]=p,t[A]=r,o=A;else break e}}return e}return null}function _k(t,e){var r=t.sortIndex-e.sortIndex;return r!==0?r:t.id-e.id}var eu=[],m0=[],Uyt=1,na=null,No=3,Gk=!1,sm=!1,oB=!1;function Yk(t){for(var e=nc(m0);e!==null;){if(e.callback===null)qk(m0);else if(e.startTime<=t)qk(m0),e.sortIndex=e.expirationTime,B6(eu,e);else break;e=nc(m0)}}function v6(t){if(oB=!1,Yk(t),!sm)if(nc(eu)!==null)sm=!0,UC(D6);else{var e=nc(m0);e!==null&&sB(v6,e.startTime-t)}}function D6(t,e){sm=!1,oB&&(oB=!1,Hk()),Gk=!0;var r=No;try{for(Yk(e),na=nc(eu);na!==null&&(!(na.expirationTime>e)||t&&!jk());){var o=na.callback;if(o!==null){na.callback=null,No=na.priorityLevel;var a=o(na.expirationTime<=e);e=kn.unstable_now(),typeof a=="function"?na.callback=a:na===nc(eu)&&qk(eu),Yk(e)}else qk(eu);na=nc(eu)}if(na!==null)var n=!0;else{var u=nc(m0);u!==null&&sB(v6,u.startTime-e),n=!1}return n}finally{na=null,No=r,Gk=!1}}function BEe(t){switch(t){case 1:return-1;case 2:return 250;case 5:return 1073741823;case 4:return 1e4;default:return 5e3}}var _yt=I6;kn.unstable_ImmediatePriority=1;kn.unstable_UserBlockingPriority=2;kn.unstable_NormalPriority=3;kn.unstable_IdlePriority=5;kn.unstable_LowPriority=4;kn.unstable_runWithPriority=function(t,e){switch(t){case 1:case 2:case 3:case 4:case 5:break;default:t=3}var r=No;No=t;try{return e()}finally{No=r}};kn.unstable_next=function(t){switch(No){case 1:case 2:case 3:var e=3;break;default:e=No}var r=No;No=e;try{return t()}finally{No=r}};kn.unstable_scheduleCallback=function(t,e,r){var o=kn.unstable_now();if(typeof r=="object"&&r!==null){var a=r.delay;a=typeof a=="number"&&0<a?o+a:o,r=typeof r.timeout=="number"?r.timeout:BEe(t)}else r=BEe(t),a=o;return r=a+r,t={id:Uyt++,callback:e,priorityLevel:t,startTime:a,expirationTime:r,sortIndex:-1},a>o?(t.sortIndex=a,B6(m0,t),nc(eu)===null&&t===nc(m0)&&(oB?Hk():oB=!0,sB(v6,a-o))):(t.sortIndex=r,B6(eu,t),sm||Gk||(sm=!0,UC(D6))),t};kn.unstable_cancelCallback=function(t){t.callback=null};kn.unstable_wrapCallback=function(t){var e=No;return function(){var r=No;No=e;try{return t.apply(this,arguments)}finally{No=r}}};kn.unstable_getCurrentPriorityLevel=function(){return No};kn.unstable_shouldYield=function(){var t=kn.unstable_now();Yk(t);var e=nc(eu);return e!==na&&na!==null&&e!==null&&e.callback!==null&&e.startTime<=t&&e.expirationTime<na.expirationTime||jk()};kn.unstable_requestPaint=_yt;kn.unstable_continueExecution=function(){sm||Gk||(sm=!0,UC(D6))};kn.unstable_pauseExecution=function(){};kn.unstable_getFirstCallbackNode=function(){return nc(eu)};kn.unstable_Profiling=null});var P6=_((fKt,DEe)=>{"use strict";DEe.exports=vEe()});var PEe=_((pKt,aB)=>{aB.exports=function t(e){"use strict";var r=$H(),o=sn(),a=P6();function n(P){for(var D="https://reactjs.org/docs/error-decoder.html?invariant="+P,T=1;T<arguments.length;T++)D+="&args[]="+encodeURIComponent(arguments[T]);return"Minified React error #"+P+"; visit "+D+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}var u=o.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;u.hasOwnProperty("ReactCurrentDispatcher")||(u.ReactCurrentDispatcher={current:null}),u.hasOwnProperty("ReactCurrentBatchConfig")||(u.ReactCurrentBatchConfig={suspense:null});var A=typeof Symbol=="function"&&Symbol.for,p=A?Symbol.for("react.element"):60103,h=A?Symbol.for("react.portal"):60106,C=A?Symbol.for("react.fragment"):60107,I=A?Symbol.for("react.strict_mode"):60108,v=A?Symbol.for("react.profiler"):60114,b=A?Symbol.for("react.provider"):60109,E=A?Symbol.for("react.context"):60110,F=A?Symbol.for("react.concurrent_mode"):60111,N=A?Symbol.for("react.forward_ref"):60112,U=A?Symbol.for("react.suspense"):60113,z=A?Symbol.for("react.suspense_list"):60120,te=A?Symbol.for("react.memo"):60115,le=A?Symbol.for("react.lazy"):60116;A&&Symbol.for("react.fundamental"),A&&Symbol.for("react.responder"),A&&Symbol.for("react.scope");var pe=typeof Symbol=="function"&&Symbol.iterator;function ue(P){return P===null||typeof P!="object"?null:(P=pe&&P[pe]||P["@@iterator"],typeof P=="function"?P:null)}function ye(P){if(P._status===-1){P._status=0;var D=P._ctor;D=D(),P._result=D,D.then(function(T){P._status===0&&(T=T.default,P._status=1,P._result=T)},function(T){P._status===0&&(P._status=2,P._result=T)})}}function ae(P){if(P==null)return null;if(typeof P=="function")return P.displayName||P.name||null;if(typeof P=="string")return P;switch(P){case C:return"Fragment";case h:return"Portal";case v:return"Profiler";case I:return"StrictMode";case U:return"Suspense";case z:return"SuspenseList"}if(typeof P=="object")switch(P.$$typeof){case E:return"Context.Consumer";case b:return"Context.Provider";case N:var D=P.render;return D=D.displayName||D.name||"",P.displayName||(D!==""?"ForwardRef("+D+")":"ForwardRef");case te:return ae(P.type);case le:if(P=P._status===1?P._result:null)return ae(P)}return null}function Ie(P){var D=P,T=P;if(P.alternate)for(;D.return;)D=D.return;else{P=D;do D=P,(D.effectTag&1026)!==0&&(T=D.return),P=D.return;while(P)}return D.tag===3?T:null}function Fe(P){if(Ie(P)!==P)throw Error(n(188))}function g(P){var D=P.alternate;if(!D){if(D=Ie(P),D===null)throw Error(n(188));return D!==P?null:P}for(var T=P,j=D;;){var W=T.return;if(W===null)break;var Ae=W.alternate;if(Ae===null){if(j=W.return,j!==null){T=j;continue}break}if(W.child===Ae.child){for(Ae=W.child;Ae;){if(Ae===T)return Fe(W),P;if(Ae===j)return Fe(W),D;Ae=Ae.sibling}throw Error(n(188))}if(T.return!==j.return)T=W,j=Ae;else{for(var ve=!1,vt=W.child;vt;){if(vt===T){ve=!0,T=W,j=Ae;break}if(vt===j){ve=!0,j=W,T=Ae;break}vt=vt.sibling}if(!ve){for(vt=Ae.child;vt;){if(vt===T){ve=!0,T=Ae,j=W;break}if(vt===j){ve=!0,j=Ae,T=W;break}vt=vt.sibling}if(!ve)throw Error(n(189))}}if(T.alternate!==j)throw Error(n(190))}if(T.tag!==3)throw Error(n(188));return T.stateNode.current===T?P:D}function Ee(P){if(P=g(P),!P)return null;for(var D=P;;){if(D.tag===5||D.tag===6)return D;if(D.child)D.child.return=D,D=D.child;else{if(D===P)break;for(;!D.sibling;){if(!D.return||D.return===P)return null;D=D.return}D.sibling.return=D.return,D=D.sibling}}return null}function De(P){if(P=g(P),!P)return null;for(var D=P;;){if(D.tag===5||D.tag===6)return D;if(D.child&&D.tag!==4)D.child.return=D,D=D.child;else{if(D===P)break;for(;!D.sibling;){if(!D.return||D.return===P)return null;D=D.return}D.sibling.return=D.return,D=D.sibling}}return null}var ce=e.getPublicInstance,ne=e.getRootHostContext,ee=e.getChildHostContext,we=e.prepareForCommit,be=e.resetAfterCommit,ht=e.createInstance,H=e.appendInitialChild,lt=e.finalizeInitialChildren,Te=e.prepareUpdate,ke=e.shouldSetTextContent,xe=e.shouldDeprioritizeSubtree,He=e.createTextInstance,Re=e.setTimeout,ze=e.clearTimeout,je=e.noTimeout,x=e.isPrimaryRenderer,w=e.supportsMutation,S=e.supportsPersistence,y=e.supportsHydration,R=e.appendChild,J=e.appendChildToContainer,X=e.commitTextUpdate,Z=e.commitMount,ie=e.commitUpdate,Pe=e.insertBefore,Le=e.insertInContainerBefore,ot=e.removeChild,dt=e.removeChildFromContainer,jt=e.resetTextContent,$t=e.hideInstance,xt=e.hideTextInstance,an=e.unhideInstance,kr=e.unhideTextInstance,mr=e.cloneInstance,xr=e.createContainerChildSet,Wr=e.appendChildToContainerChildSet,Kn=e.finalizeContainerChildren,Ns=e.replaceContainerChildren,Ti=e.cloneHiddenInstance,ps=e.cloneHiddenTextInstance,io=e.canHydrateInstance,Si=e.canHydrateTextInstance,Os=e.isSuspenseInstancePending,so=e.isSuspenseInstanceFallback,cc=e.getNextHydratableSibling,cu=e.getFirstHydratableChild,op=e.hydrateInstance,ap=e.hydrateTextInstance,Ms=e.getNextHydratableInstanceAfterSuspenseInstance,Dn=e.commitHydratedContainer,oo=e.commitHydratedSuspenseInstance,Us=/^(.*)[\\\/]/;function ml(P){var D="";do{e:switch(P.tag){case 3:case 4:case 6:case 7:case 10:case 9:var T="";break e;default:var j=P._debugOwner,W=P._debugSource,Ae=ae(P.type);T=null,j&&(T=ae(j.type)),j=Ae,Ae="",W?Ae=" (at "+W.fileName.replace(Us,"")+":"+W.lineNumber+")":T&&(Ae=" (created by "+T+")"),T=` - in `+(j||"Unknown")+Ae}D+=T,P=P.return}while(P);return D}var yl=[],ao=-1;function Vn(P){0>ao||(P.current=yl[ao],yl[ao]=null,ao--)}function On(P,D){ao++,yl[ao]=P.current,P.current=D}var Li={},Mn={current:Li},_i={current:!1},tr=Li;function Oe(P,D){var T=P.type.contextTypes;if(!T)return Li;var j=P.stateNode;if(j&&j.__reactInternalMemoizedUnmaskedChildContext===D)return j.__reactInternalMemoizedMaskedChildContext;var W={},Ae;for(Ae in T)W[Ae]=D[Ae];return j&&(P=P.stateNode,P.__reactInternalMemoizedUnmaskedChildContext=D,P.__reactInternalMemoizedMaskedChildContext=W),W}function ii(P){return P=P.childContextTypes,P!=null}function Ma(P){Vn(_i,P),Vn(Mn,P)}function hr(P){Vn(_i,P),Vn(Mn,P)}function uc(P,D,T){if(Mn.current!==Li)throw Error(n(168));On(Mn,D,P),On(_i,T,P)}function uu(P,D,T){var j=P.stateNode;if(P=D.childContextTypes,typeof j.getChildContext!="function")return T;j=j.getChildContext();for(var W in j)if(!(W in P))throw Error(n(108,ae(D)||"Unknown",W));return r({},T,{},j)}function Ac(P){var D=P.stateNode;return D=D&&D.__reactInternalMemoizedMergedChildContext||Li,tr=Mn.current,On(Mn,D,P),On(_i,_i.current,P),!0}function El(P,D,T){var j=P.stateNode;if(!j)throw Error(n(169));T?(D=uu(P,D,tr),j.__reactInternalMemoizedMergedChildContext=D,Vn(_i,P),Vn(Mn,P),On(Mn,D,P)):Vn(_i,P),On(_i,T,P)}var vA=a.unstable_runWithPriority,Au=a.unstable_scheduleCallback,Ce=a.unstable_cancelCallback,Rt=a.unstable_shouldYield,fc=a.unstable_requestPaint,Hi=a.unstable_now,fu=a.unstable_getCurrentPriorityLevel,Yt=a.unstable_ImmediatePriority,Cl=a.unstable_UserBlockingPriority,DA=a.unstable_NormalPriority,lp=a.unstable_LowPriority,pc=a.unstable_IdlePriority,PA={},Qn=fc!==void 0?fc:function(){},hi=null,hc=null,SA=!1,sa=Hi(),Ni=1e4>sa?Hi:function(){return Hi()-sa};function _o(){switch(fu()){case Yt:return 99;case Cl:return 98;case DA:return 97;case lp:return 96;case pc:return 95;default:throw Error(n(332))}}function Ze(P){switch(P){case 99:return Yt;case 98:return Cl;case 97:return DA;case 96:return lp;case 95:return pc;default:throw Error(n(332))}}function lo(P,D){return P=Ze(P),vA(P,D)}function gc(P,D,T){return P=Ze(P),Au(P,D,T)}function pu(P){return hi===null?(hi=[P],hc=Au(Yt,hu)):hi.push(P),PA}function ji(){if(hc!==null){var P=hc;hc=null,Ce(P)}hu()}function hu(){if(!SA&&hi!==null){SA=!0;var P=0;try{var D=hi;lo(99,function(){for(;P<D.length;P++){var T=D[P];do T=T(!0);while(T!==null)}}),hi=null}catch(T){throw hi!==null&&(hi=hi.slice(P+1)),Au(Yt,ji),T}finally{SA=!1}}}var xA=3;function Ua(P,D,T){return T/=10,1073741821-(((1073741821-P+D/10)/T|0)+1)*T}function dc(P,D){return P===D&&(P!==0||1/P===1/D)||P!==P&&D!==D}var hs=typeof Object.is=="function"?Object.is:dc,Ut=Object.prototype.hasOwnProperty;function Fn(P,D){if(hs(P,D))return!0;if(typeof P!="object"||P===null||typeof D!="object"||D===null)return!1;var T=Object.keys(P),j=Object.keys(D);if(T.length!==j.length)return!1;for(j=0;j<T.length;j++)if(!Ut.call(D,T[j])||!hs(P[T[j]],D[T[j]]))return!1;return!0}function Ci(P,D){if(P&&P.defaultProps){D=r({},D),P=P.defaultProps;for(var T in P)D[T]===void 0&&(D[T]=P[T])}return D}var oa={current:null},co=null,_s=null,aa=null;function la(){aa=_s=co=null}function Ho(P,D){var T=P.type._context;x?(On(oa,T._currentValue,P),T._currentValue=D):(On(oa,T._currentValue2,P),T._currentValue2=D)}function wi(P){var D=oa.current;Vn(oa,P),P=P.type._context,x?P._currentValue=D:P._currentValue2=D}function gs(P,D){for(;P!==null;){var T=P.alternate;if(P.childExpirationTime<D)P.childExpirationTime=D,T!==null&&T.childExpirationTime<D&&(T.childExpirationTime=D);else if(T!==null&&T.childExpirationTime<D)T.childExpirationTime=D;else break;P=P.return}}function ds(P,D){co=P,aa=_s=null,P=P.dependencies,P!==null&&P.firstContext!==null&&(P.expirationTime>=D&&(qo=!0),P.firstContext=null)}function ms(P,D){if(aa!==P&&D!==!1&&D!==0)if((typeof D!="number"||D===1073741823)&&(aa=P,D=1073741823),D={context:P,observedBits:D,next:null},_s===null){if(co===null)throw Error(n(308));_s=D,co.dependencies={expirationTime:0,firstContext:D,responders:null}}else _s=_s.next=D;return x?P._currentValue:P._currentValue2}var Hs=!1;function Un(P){return{baseState:P,firstUpdate:null,lastUpdate:null,firstCapturedUpdate:null,lastCapturedUpdate:null,firstEffect:null,lastEffect:null,firstCapturedEffect:null,lastCapturedEffect:null}}function Pn(P){return{baseState:P.baseState,firstUpdate:P.firstUpdate,lastUpdate:P.lastUpdate,firstCapturedUpdate:null,lastCapturedUpdate:null,firstEffect:null,lastEffect:null,firstCapturedEffect:null,lastCapturedEffect:null}}function ys(P,D){return{expirationTime:P,suspenseConfig:D,tag:0,payload:null,callback:null,next:null,nextEffect:null}}function We(P,D){P.lastUpdate===null?P.firstUpdate=P.lastUpdate=D:(P.lastUpdate.next=D,P.lastUpdate=D)}function tt(P,D){var T=P.alternate;if(T===null){var j=P.updateQueue,W=null;j===null&&(j=P.updateQueue=Un(P.memoizedState))}else j=P.updateQueue,W=T.updateQueue,j===null?W===null?(j=P.updateQueue=Un(P.memoizedState),W=T.updateQueue=Un(T.memoizedState)):j=P.updateQueue=Pn(W):W===null&&(W=T.updateQueue=Pn(j));W===null||j===W?We(j,D):j.lastUpdate===null||W.lastUpdate===null?(We(j,D),We(W,D)):(We(j,D),W.lastUpdate=D)}function It(P,D){var T=P.updateQueue;T=T===null?P.updateQueue=Un(P.memoizedState):nr(P,T),T.lastCapturedUpdate===null?T.firstCapturedUpdate=T.lastCapturedUpdate=D:(T.lastCapturedUpdate.next=D,T.lastCapturedUpdate=D)}function nr(P,D){var T=P.alternate;return T!==null&&D===T.updateQueue&&(D=P.updateQueue=Pn(D)),D}function $(P,D,T,j,W,Ae){switch(T.tag){case 1:return P=T.payload,typeof P=="function"?P.call(Ae,j,W):P;case 3:P.effectTag=P.effectTag&-4097|64;case 0:if(P=T.payload,W=typeof P=="function"?P.call(Ae,j,W):P,W==null)break;return r({},j,W);case 2:Hs=!0}return j}function me(P,D,T,j,W){Hs=!1,D=nr(P,D);for(var Ae=D.baseState,ve=null,vt=0,wt=D.firstUpdate,bt=Ae;wt!==null;){var _r=wt.expirationTime;_r<W?(ve===null&&(ve=wt,Ae=bt),vt<_r&&(vt=_r)):(Pw(_r,wt.suspenseConfig),bt=$(P,D,wt,bt,T,j),wt.callback!==null&&(P.effectTag|=32,wt.nextEffect=null,D.lastEffect===null?D.firstEffect=D.lastEffect=wt:(D.lastEffect.nextEffect=wt,D.lastEffect=wt))),wt=wt.next}for(_r=null,wt=D.firstCapturedUpdate;wt!==null;){var is=wt.expirationTime;is<W?(_r===null&&(_r=wt,ve===null&&(Ae=bt)),vt<is&&(vt=is)):(bt=$(P,D,wt,bt,T,j),wt.callback!==null&&(P.effectTag|=32,wt.nextEffect=null,D.lastCapturedEffect===null?D.firstCapturedEffect=D.lastCapturedEffect=wt:(D.lastCapturedEffect.nextEffect=wt,D.lastCapturedEffect=wt))),wt=wt.next}ve===null&&(D.lastUpdate=null),_r===null?D.lastCapturedUpdate=null:P.effectTag|=32,ve===null&&_r===null&&(Ae=bt),D.baseState=Ae,D.firstUpdate=ve,D.firstCapturedUpdate=_r,_m(vt),P.expirationTime=vt,P.memoizedState=bt}function Ne(P,D,T){D.firstCapturedUpdate!==null&&(D.lastUpdate!==null&&(D.lastUpdate.next=D.firstCapturedUpdate,D.lastUpdate=D.lastCapturedUpdate),D.firstCapturedUpdate=D.lastCapturedUpdate=null),ft(D.firstEffect,T),D.firstEffect=D.lastEffect=null,ft(D.firstCapturedEffect,T),D.firstCapturedEffect=D.lastCapturedEffect=null}function ft(P,D){for(;P!==null;){var T=P.callback;if(T!==null){P.callback=null;var j=D;if(typeof T!="function")throw Error(n(191,T));T.call(j)}P=P.nextEffect}}var pt=u.ReactCurrentBatchConfig,Tt=new o.Component().refs;function er(P,D,T,j){D=P.memoizedState,T=T(j,D),T=T==null?D:r({},D,T),P.memoizedState=T,j=P.updateQueue,j!==null&&P.expirationTime===0&&(j.baseState=T)}var Zr={isMounted:function(P){return(P=P._reactInternalFiber)?Ie(P)===P:!1},enqueueSetState:function(P,D,T){P=P._reactInternalFiber;var j=ga(),W=pt.suspense;j=HA(j,P,W),W=ys(j,W),W.payload=D,T!=null&&(W.callback=T),tt(P,W),Sc(P,j)},enqueueReplaceState:function(P,D,T){P=P._reactInternalFiber;var j=ga(),W=pt.suspense;j=HA(j,P,W),W=ys(j,W),W.tag=1,W.payload=D,T!=null&&(W.callback=T),tt(P,W),Sc(P,j)},enqueueForceUpdate:function(P,D){P=P._reactInternalFiber;var T=ga(),j=pt.suspense;T=HA(T,P,j),j=ys(T,j),j.tag=2,D!=null&&(j.callback=D),tt(P,j),Sc(P,T)}};function qi(P,D,T,j,W,Ae,ve){return P=P.stateNode,typeof P.shouldComponentUpdate=="function"?P.shouldComponentUpdate(j,Ae,ve):D.prototype&&D.prototype.isPureReactComponent?!Fn(T,j)||!Fn(W,Ae):!0}function es(P,D,T){var j=!1,W=Li,Ae=D.contextType;return typeof Ae=="object"&&Ae!==null?Ae=ms(Ae):(W=ii(D)?tr:Mn.current,j=D.contextTypes,Ae=(j=j!=null)?Oe(P,W):Li),D=new D(T,Ae),P.memoizedState=D.state!==null&&D.state!==void 0?D.state:null,D.updater=Zr,P.stateNode=D,D._reactInternalFiber=P,j&&(P=P.stateNode,P.__reactInternalMemoizedUnmaskedChildContext=W,P.__reactInternalMemoizedMaskedChildContext=Ae),D}function xi(P,D,T,j){P=D.state,typeof D.componentWillReceiveProps=="function"&&D.componentWillReceiveProps(T,j),typeof D.UNSAFE_componentWillReceiveProps=="function"&&D.UNSAFE_componentWillReceiveProps(T,j),D.state!==P&&Zr.enqueueReplaceState(D,D.state,null)}function jo(P,D,T,j){var W=P.stateNode;W.props=T,W.state=P.memoizedState,W.refs=Tt;var Ae=D.contextType;typeof Ae=="object"&&Ae!==null?W.context=ms(Ae):(Ae=ii(D)?tr:Mn.current,W.context=Oe(P,Ae)),Ae=P.updateQueue,Ae!==null&&(me(P,Ae,T,W,j),W.state=P.memoizedState),Ae=D.getDerivedStateFromProps,typeof Ae=="function"&&(er(P,D,Ae,T),W.state=P.memoizedState),typeof D.getDerivedStateFromProps=="function"||typeof W.getSnapshotBeforeUpdate=="function"||typeof W.UNSAFE_componentWillMount!="function"&&typeof W.componentWillMount!="function"||(D=W.state,typeof W.componentWillMount=="function"&&W.componentWillMount(),typeof W.UNSAFE_componentWillMount=="function"&&W.UNSAFE_componentWillMount(),D!==W.state&&Zr.enqueueReplaceState(W,W.state,null),Ae=P.updateQueue,Ae!==null&&(me(P,Ae,T,W,j),W.state=P.memoizedState)),typeof W.componentDidMount=="function"&&(P.effectTag|=4)}var bA=Array.isArray;function kA(P,D,T){if(P=T.ref,P!==null&&typeof P!="function"&&typeof P!="object"){if(T._owner){if(T=T._owner,T){if(T.tag!==1)throw Error(n(309));var j=T.stateNode}if(!j)throw Error(n(147,P));var W=""+P;return D!==null&&D.ref!==null&&typeof D.ref=="function"&&D.ref._stringRef===W?D.ref:(D=function(Ae){var ve=j.refs;ve===Tt&&(ve=j.refs={}),Ae===null?delete ve[W]:ve[W]=Ae},D._stringRef=W,D)}if(typeof P!="string")throw Error(n(284));if(!T._owner)throw Error(n(290,P))}return P}function cp(P,D){if(P.type!=="textarea")throw Error(n(31,Object.prototype.toString.call(D)==="[object Object]"?"object with keys {"+Object.keys(D).join(", ")+"}":D,""))}function rg(P){function D(rt,Ve){if(P){var At=rt.lastEffect;At!==null?(At.nextEffect=Ve,rt.lastEffect=Ve):rt.firstEffect=rt.lastEffect=Ve,Ve.nextEffect=null,Ve.effectTag=8}}function T(rt,Ve){if(!P)return null;for(;Ve!==null;)D(rt,Ve),Ve=Ve.sibling;return null}function j(rt,Ve){for(rt=new Map;Ve!==null;)Ve.key!==null?rt.set(Ve.key,Ve):rt.set(Ve.index,Ve),Ve=Ve.sibling;return rt}function W(rt,Ve,At){return rt=YA(rt,Ve,At),rt.index=0,rt.sibling=null,rt}function Ae(rt,Ve,At){return rt.index=At,P?(At=rt.alternate,At!==null?(At=At.index,At<Ve?(rt.effectTag=2,Ve):At):(rt.effectTag=2,Ve)):Ve}function ve(rt){return P&&rt.alternate===null&&(rt.effectTag=2),rt}function vt(rt,Ve,At,Wt){return Ve===null||Ve.tag!==6?(Ve=Qw(At,rt.mode,Wt),Ve.return=rt,Ve):(Ve=W(Ve,At,Wt),Ve.return=rt,Ve)}function wt(rt,Ve,At,Wt){return Ve!==null&&Ve.elementType===At.type?(Wt=W(Ve,At.props,Wt),Wt.ref=kA(rt,Ve,At),Wt.return=rt,Wt):(Wt=Hm(At.type,At.key,At.props,null,rt.mode,Wt),Wt.ref=kA(rt,Ve,At),Wt.return=rt,Wt)}function bt(rt,Ve,At,Wt){return Ve===null||Ve.tag!==4||Ve.stateNode.containerInfo!==At.containerInfo||Ve.stateNode.implementation!==At.implementation?(Ve=Fw(At,rt.mode,Wt),Ve.return=rt,Ve):(Ve=W(Ve,At.children||[],Wt),Ve.return=rt,Ve)}function _r(rt,Ve,At,Wt,vr){return Ve===null||Ve.tag!==7?(Ve=xu(At,rt.mode,Wt,vr),Ve.return=rt,Ve):(Ve=W(Ve,At,Wt),Ve.return=rt,Ve)}function is(rt,Ve,At){if(typeof Ve=="string"||typeof Ve=="number")return Ve=Qw(""+Ve,rt.mode,At),Ve.return=rt,Ve;if(typeof Ve=="object"&&Ve!==null){switch(Ve.$$typeof){case p:return At=Hm(Ve.type,Ve.key,Ve.props,null,rt.mode,At),At.ref=kA(rt,null,Ve),At.return=rt,At;case h:return Ve=Fw(Ve,rt.mode,At),Ve.return=rt,Ve}if(bA(Ve)||ue(Ve))return Ve=xu(Ve,rt.mode,At,null),Ve.return=rt,Ve;cp(rt,Ve)}return null}function di(rt,Ve,At,Wt){var vr=Ve!==null?Ve.key:null;if(typeof At=="string"||typeof At=="number")return vr!==null?null:vt(rt,Ve,""+At,Wt);if(typeof At=="object"&&At!==null){switch(At.$$typeof){case p:return At.key===vr?At.type===C?_r(rt,Ve,At.props.children,Wt,vr):wt(rt,Ve,At,Wt):null;case h:return At.key===vr?bt(rt,Ve,At,Wt):null}if(bA(At)||ue(At))return vr!==null?null:_r(rt,Ve,At,Wt,null);cp(rt,At)}return null}function po(rt,Ve,At,Wt,vr){if(typeof Wt=="string"||typeof Wt=="number")return rt=rt.get(At)||null,vt(Ve,rt,""+Wt,vr);if(typeof Wt=="object"&&Wt!==null){switch(Wt.$$typeof){case p:return rt=rt.get(Wt.key===null?At:Wt.key)||null,Wt.type===C?_r(Ve,rt,Wt.props.children,vr,Wt.key):wt(Ve,rt,Wt,vr);case h:return rt=rt.get(Wt.key===null?At:Wt.key)||null,bt(Ve,rt,Wt,vr)}if(bA(Wt)||ue(Wt))return rt=rt.get(At)||null,_r(Ve,rt,Wt,vr,null);cp(Ve,Wt)}return null}function KA(rt,Ve,At,Wt){for(var vr=null,Sn=null,Fr=Ve,xn=Ve=0,ai=null;Fr!==null&&xn<At.length;xn++){Fr.index>xn?(ai=Fr,Fr=null):ai=Fr.sibling;var en=di(rt,Fr,At[xn],Wt);if(en===null){Fr===null&&(Fr=ai);break}P&&Fr&&en.alternate===null&&D(rt,Fr),Ve=Ae(en,Ve,xn),Sn===null?vr=en:Sn.sibling=en,Sn=en,Fr=ai}if(xn===At.length)return T(rt,Fr),vr;if(Fr===null){for(;xn<At.length;xn++)Fr=is(rt,At[xn],Wt),Fr!==null&&(Ve=Ae(Fr,Ve,xn),Sn===null?vr=Fr:Sn.sibling=Fr,Sn=Fr);return vr}for(Fr=j(rt,Fr);xn<At.length;xn++)ai=po(Fr,rt,xn,At[xn],Wt),ai!==null&&(P&&ai.alternate!==null&&Fr.delete(ai.key===null?xn:ai.key),Ve=Ae(ai,Ve,xn),Sn===null?vr=ai:Sn.sibling=ai,Sn=ai);return P&&Fr.forEach(function(ho){return D(rt,ho)}),vr}function Yo(rt,Ve,At,Wt){var vr=ue(At);if(typeof vr!="function")throw Error(n(150));if(At=vr.call(At),At==null)throw Error(n(151));for(var Sn=vr=null,Fr=Ve,xn=Ve=0,ai=null,en=At.next();Fr!==null&&!en.done;xn++,en=At.next()){Fr.index>xn?(ai=Fr,Fr=null):ai=Fr.sibling;var ho=di(rt,Fr,en.value,Wt);if(ho===null){Fr===null&&(Fr=ai);break}P&&Fr&&ho.alternate===null&&D(rt,Fr),Ve=Ae(ho,Ve,xn),Sn===null?vr=ho:Sn.sibling=ho,Sn=ho,Fr=ai}if(en.done)return T(rt,Fr),vr;if(Fr===null){for(;!en.done;xn++,en=At.next())en=is(rt,en.value,Wt),en!==null&&(Ve=Ae(en,Ve,xn),Sn===null?vr=en:Sn.sibling=en,Sn=en);return vr}for(Fr=j(rt,Fr);!en.done;xn++,en=At.next())en=po(Fr,rt,xn,en.value,Wt),en!==null&&(P&&en.alternate!==null&&Fr.delete(en.key===null?xn:en.key),Ve=Ae(en,Ve,xn),Sn===null?vr=en:Sn.sibling=en,Sn=en);return P&&Fr.forEach(function(vF){return D(rt,vF)}),vr}return function(rt,Ve,At,Wt){var vr=typeof At=="object"&&At!==null&&At.type===C&&At.key===null;vr&&(At=At.props.children);var Sn=typeof At=="object"&&At!==null;if(Sn)switch(At.$$typeof){case p:e:{for(Sn=At.key,vr=Ve;vr!==null;){if(vr.key===Sn)if(vr.tag===7?At.type===C:vr.elementType===At.type){T(rt,vr.sibling),Ve=W(vr,At.type===C?At.props.children:At.props,Wt),Ve.ref=kA(rt,vr,At),Ve.return=rt,rt=Ve;break e}else{T(rt,vr);break}else D(rt,vr);vr=vr.sibling}At.type===C?(Ve=xu(At.props.children,rt.mode,Wt,At.key),Ve.return=rt,rt=Ve):(Wt=Hm(At.type,At.key,At.props,null,rt.mode,Wt),Wt.ref=kA(rt,Ve,At),Wt.return=rt,rt=Wt)}return ve(rt);case h:e:{for(vr=At.key;Ve!==null;){if(Ve.key===vr)if(Ve.tag===4&&Ve.stateNode.containerInfo===At.containerInfo&&Ve.stateNode.implementation===At.implementation){T(rt,Ve.sibling),Ve=W(Ve,At.children||[],Wt),Ve.return=rt,rt=Ve;break e}else{T(rt,Ve);break}else D(rt,Ve);Ve=Ve.sibling}Ve=Fw(At,rt.mode,Wt),Ve.return=rt,rt=Ve}return ve(rt)}if(typeof At=="string"||typeof At=="number")return At=""+At,Ve!==null&&Ve.tag===6?(T(rt,Ve.sibling),Ve=W(Ve,At,Wt),Ve.return=rt,rt=Ve):(T(rt,Ve),Ve=Qw(At,rt.mode,Wt),Ve.return=rt,rt=Ve),ve(rt);if(bA(At))return KA(rt,Ve,At,Wt);if(ue(At))return Yo(rt,Ve,At,Wt);if(Sn&&cp(rt,At),typeof At>"u"&&!vr)switch(rt.tag){case 1:case 0:throw rt=rt.type,Error(n(152,rt.displayName||rt.name||"Component"))}return T(rt,Ve)}}var gu=rg(!0),ng=rg(!1),du={},uo={current:du},QA={current:du},mc={current:du};function ca(P){if(P===du)throw Error(n(174));return P}function ig(P,D){On(mc,D,P),On(QA,P,P),On(uo,du,P),D=ne(D),Vn(uo,P),On(uo,D,P)}function yc(P){Vn(uo,P),Vn(QA,P),Vn(mc,P)}function Pm(P){var D=ca(mc.current),T=ca(uo.current);D=ee(T,P.type,D),T!==D&&(On(QA,P,P),On(uo,D,P))}function sg(P){QA.current===P&&(Vn(uo,P),Vn(QA,P))}var $n={current:0};function up(P){for(var D=P;D!==null;){if(D.tag===13){var T=D.memoizedState;if(T!==null&&(T=T.dehydrated,T===null||Os(T)||so(T)))return D}else if(D.tag===19&&D.memoizedProps.revealOrder!==void 0){if((D.effectTag&64)!==0)return D}else if(D.child!==null){D.child.return=D,D=D.child;continue}if(D===P)break;for(;D.sibling===null;){if(D.return===null||D.return===P)return null;D=D.return}D.sibling.return=D.return,D=D.sibling}return null}function og(P,D){return{responder:P,props:D}}var FA=u.ReactCurrentDispatcher,js=u.ReactCurrentBatchConfig,mu=0,Ha=null,Gi=null,ua=null,yu=null,Es=null,Ec=null,Cc=0,Y=null,Dt=0,wl=!1,bi=null,wc=0;function ct(){throw Error(n(321))}function Eu(P,D){if(D===null)return!1;for(var T=0;T<D.length&&T<P.length;T++)if(!hs(P[T],D[T]))return!1;return!0}function ag(P,D,T,j,W,Ae){if(mu=Ae,Ha=D,ua=P!==null?P.memoizedState:null,FA.current=ua===null?yw:bm,D=T(j,W),wl){do wl=!1,wc+=1,ua=P!==null?P.memoizedState:null,Ec=yu,Y=Es=Gi=null,FA.current=bm,D=T(j,W);while(wl);bi=null,wc=0}if(FA.current=wu,P=Ha,P.memoizedState=yu,P.expirationTime=Cc,P.updateQueue=Y,P.effectTag|=Dt,P=Gi!==null&&Gi.next!==null,mu=0,Ec=Es=yu=ua=Gi=Ha=null,Cc=0,Y=null,Dt=0,P)throw Error(n(300));return D}function mw(){FA.current=wu,mu=0,Ec=Es=yu=ua=Gi=Ha=null,Cc=0,Y=null,Dt=0,wl=!1,bi=null,wc=0}function RA(){var P={memoizedState:null,baseState:null,queue:null,baseUpdate:null,next:null};return Es===null?yu=Es=P:Es=Es.next=P,Es}function Ap(){if(Ec!==null)Es=Ec,Ec=Es.next,Gi=ua,ua=Gi!==null?Gi.next:null;else{if(ua===null)throw Error(n(310));Gi=ua;var P={memoizedState:Gi.memoizedState,baseState:Gi.baseState,queue:Gi.queue,baseUpdate:Gi.baseUpdate,next:null};Es=Es===null?yu=P:Es.next=P,ua=Gi.next}return Es}function Br(P,D){return typeof D=="function"?D(P):D}function Cs(P){var D=Ap(),T=D.queue;if(T===null)throw Error(n(311));if(T.lastRenderedReducer=P,0<wc){var j=T.dispatch;if(bi!==null){var W=bi.get(T);if(W!==void 0){bi.delete(T);var Ae=D.memoizedState;do Ae=P(Ae,W.action),W=W.next;while(W!==null);return hs(Ae,D.memoizedState)||(qo=!0),D.memoizedState=Ae,D.baseUpdate===T.last&&(D.baseState=Ae),T.lastRenderedState=Ae,[Ae,j]}}return[D.memoizedState,j]}j=T.last;var ve=D.baseUpdate;if(Ae=D.baseState,ve!==null?(j!==null&&(j.next=null),j=ve.next):j=j!==null?j.next:null,j!==null){var vt=W=null,wt=j,bt=!1;do{var _r=wt.expirationTime;_r<mu?(bt||(bt=!0,vt=ve,W=Ae),_r>Cc&&(Cc=_r,_m(Cc))):(Pw(_r,wt.suspenseConfig),Ae=wt.eagerReducer===P?wt.eagerState:P(Ae,wt.action)),ve=wt,wt=wt.next}while(wt!==null&&wt!==j);bt||(vt=ve,W=Ae),hs(Ae,D.memoizedState)||(qo=!0),D.memoizedState=Ae,D.baseUpdate=vt,D.baseState=W,T.lastRenderedState=Ae}return[D.memoizedState,T.dispatch]}function lg(P){var D=RA();return typeof P=="function"&&(P=P()),D.memoizedState=D.baseState=P,P=D.queue={last:null,dispatch:null,lastRenderedReducer:Br,lastRenderedState:P},P=P.dispatch=pg.bind(null,Ha,P),[D.memoizedState,P]}function cg(P){return Cs(Br,P)}function ug(P,D,T,j){return P={tag:P,create:D,destroy:T,deps:j,next:null},Y===null?(Y={lastEffect:null},Y.lastEffect=P.next=P):(D=Y.lastEffect,D===null?Y.lastEffect=P.next=P:(T=D.next,D.next=P,P.next=T,Y.lastEffect=P)),P}function fp(P,D,T,j){var W=RA();Dt|=P,W.memoizedState=ug(D,T,void 0,j===void 0?null:j)}function Ic(P,D,T,j){var W=Ap();j=j===void 0?null:j;var Ae=void 0;if(Gi!==null){var ve=Gi.memoizedState;if(Ae=ve.destroy,j!==null&&Eu(j,ve.deps)){ug(0,T,Ae,j);return}}Dt|=P,W.memoizedState=ug(D,T,Ae,j)}function Ct(P,D){return fp(516,192,P,D)}function Sm(P,D){return Ic(516,192,P,D)}function Ag(P,D){if(typeof D=="function")return P=P(),D(P),function(){D(null)};if(D!=null)return P=P(),D.current=P,function(){D.current=null}}function fg(){}function Cu(P,D){return RA().memoizedState=[P,D===void 0?null:D],P}function xm(P,D){var T=Ap();D=D===void 0?null:D;var j=T.memoizedState;return j!==null&&D!==null&&Eu(D,j[1])?j[0]:(T.memoizedState=[P,D],P)}function pg(P,D,T){if(!(25>wc))throw Error(n(301));var j=P.alternate;if(P===Ha||j!==null&&j===Ha)if(wl=!0,P={expirationTime:mu,suspenseConfig:null,action:T,eagerReducer:null,eagerState:null,next:null},bi===null&&(bi=new Map),T=bi.get(D),T===void 0)bi.set(D,P);else{for(D=T;D.next!==null;)D=D.next;D.next=P}else{var W=ga(),Ae=pt.suspense;W=HA(W,P,Ae),Ae={expirationTime:W,suspenseConfig:Ae,action:T,eagerReducer:null,eagerState:null,next:null};var ve=D.last;if(ve===null)Ae.next=Ae;else{var vt=ve.next;vt!==null&&(Ae.next=vt),ve.next=Ae}if(D.last=Ae,P.expirationTime===0&&(j===null||j.expirationTime===0)&&(j=D.lastRenderedReducer,j!==null))try{var wt=D.lastRenderedState,bt=j(wt,T);if(Ae.eagerReducer=j,Ae.eagerState=bt,hs(bt,wt))return}catch{}finally{}Sc(P,W)}}var wu={readContext:ms,useCallback:ct,useContext:ct,useEffect:ct,useImperativeHandle:ct,useLayoutEffect:ct,useMemo:ct,useReducer:ct,useRef:ct,useState:ct,useDebugValue:ct,useResponder:ct,useDeferredValue:ct,useTransition:ct},yw={readContext:ms,useCallback:Cu,useContext:ms,useEffect:Ct,useImperativeHandle:function(P,D,T){return T=T!=null?T.concat([P]):null,fp(4,36,Ag.bind(null,D,P),T)},useLayoutEffect:function(P,D){return fp(4,36,P,D)},useMemo:function(P,D){var T=RA();return D=D===void 0?null:D,P=P(),T.memoizedState=[P,D],P},useReducer:function(P,D,T){var j=RA();return D=T!==void 0?T(D):D,j.memoizedState=j.baseState=D,P=j.queue={last:null,dispatch:null,lastRenderedReducer:P,lastRenderedState:D},P=P.dispatch=pg.bind(null,Ha,P),[j.memoizedState,P]},useRef:function(P){var D=RA();return P={current:P},D.memoizedState=P},useState:lg,useDebugValue:fg,useResponder:og,useDeferredValue:function(P,D){var T=lg(P),j=T[0],W=T[1];return Ct(function(){a.unstable_next(function(){var Ae=js.suspense;js.suspense=D===void 0?null:D;try{W(P)}finally{js.suspense=Ae}})},[P,D]),j},useTransition:function(P){var D=lg(!1),T=D[0],j=D[1];return[Cu(function(W){j(!0),a.unstable_next(function(){var Ae=js.suspense;js.suspense=P===void 0?null:P;try{j(!1),W()}finally{js.suspense=Ae}})},[P,T]),T]}},bm={readContext:ms,useCallback:xm,useContext:ms,useEffect:Sm,useImperativeHandle:function(P,D,T){return T=T!=null?T.concat([P]):null,Ic(4,36,Ag.bind(null,D,P),T)},useLayoutEffect:function(P,D){return Ic(4,36,P,D)},useMemo:function(P,D){var T=Ap();D=D===void 0?null:D;var j=T.memoizedState;return j!==null&&D!==null&&Eu(D,j[1])?j[0]:(P=P(),T.memoizedState=[P,D],P)},useReducer:Cs,useRef:function(){return Ap().memoizedState},useState:cg,useDebugValue:fg,useResponder:og,useDeferredValue:function(P,D){var T=cg(P),j=T[0],W=T[1];return Sm(function(){a.unstable_next(function(){var Ae=js.suspense;js.suspense=D===void 0?null:D;try{W(P)}finally{js.suspense=Ae}})},[P,D]),j},useTransition:function(P){var D=cg(!1),T=D[0],j=D[1];return[xm(function(W){j(!0),a.unstable_next(function(){var Ae=js.suspense;js.suspense=P===void 0?null:P;try{j(!1),W()}finally{js.suspense=Ae}})},[P,T]),T]}},Aa=null,Bc=null,Il=!1;function Iu(P,D){var T=Dl(5,null,null,0);T.elementType="DELETED",T.type="DELETED",T.stateNode=D,T.return=P,T.effectTag=8,P.lastEffect!==null?(P.lastEffect.nextEffect=T,P.lastEffect=T):P.firstEffect=P.lastEffect=T}function hg(P,D){switch(P.tag){case 5:return D=io(D,P.type,P.pendingProps),D!==null?(P.stateNode=D,!0):!1;case 6:return D=Si(D,P.pendingProps),D!==null?(P.stateNode=D,!0):!1;case 13:return!1;default:return!1}}function TA(P){if(Il){var D=Bc;if(D){var T=D;if(!hg(P,D)){if(D=cc(T),!D||!hg(P,D)){P.effectTag=P.effectTag&-1025|2,Il=!1,Aa=P;return}Iu(Aa,T)}Aa=P,Bc=cu(D)}else P.effectTag=P.effectTag&-1025|2,Il=!1,Aa=P}}function pp(P){for(P=P.return;P!==null&&P.tag!==5&&P.tag!==3&&P.tag!==13;)P=P.return;Aa=P}function ja(P){if(!y||P!==Aa)return!1;if(!Il)return pp(P),Il=!0,!1;var D=P.type;if(P.tag!==5||D!=="head"&&D!=="body"&&!ke(D,P.memoizedProps))for(D=Bc;D;)Iu(P,D),D=cc(D);if(pp(P),P.tag===13){if(!y)throw Error(n(316));if(P=P.memoizedState,P=P!==null?P.dehydrated:null,!P)throw Error(n(317));Bc=Ms(P)}else Bc=Aa?cc(P.stateNode):null;return!0}function gg(){y&&(Bc=Aa=null,Il=!1)}var hp=u.ReactCurrentOwner,qo=!1;function ws(P,D,T,j){D.child=P===null?ng(D,null,T,j):gu(D,P.child,T,j)}function Ii(P,D,T,j,W){T=T.render;var Ae=D.ref;return ds(D,W),j=ag(P,D,T,j,Ae,W),P!==null&&!qo?(D.updateQueue=P.updateQueue,D.effectTag&=-517,P.expirationTime<=W&&(P.expirationTime=0),si(P,D,W)):(D.effectTag|=1,ws(P,D,j,W),D.child)}function km(P,D,T,j,W,Ae){if(P===null){var ve=T.type;return typeof ve=="function"&&!kw(ve)&&ve.defaultProps===void 0&&T.compare===null&&T.defaultProps===void 0?(D.tag=15,D.type=ve,Qm(P,D,ve,j,W,Ae)):(P=Hm(T.type,null,j,null,D.mode,Ae),P.ref=D.ref,P.return=D,D.child=P)}return ve=P.child,W<Ae&&(W=ve.memoizedProps,T=T.compare,T=T!==null?T:Fn,T(W,j)&&P.ref===D.ref)?si(P,D,Ae):(D.effectTag|=1,P=YA(ve,j,Ae),P.ref=D.ref,P.return=D,D.child=P)}function Qm(P,D,T,j,W,Ae){return P!==null&&Fn(P.memoizedProps,j)&&P.ref===D.ref&&(qo=!1,W<Ae)?si(P,D,Ae):LA(P,D,T,j,Ae)}function Go(P,D){var T=D.ref;(P===null&&T!==null||P!==null&&P.ref!==T)&&(D.effectTag|=128)}function LA(P,D,T,j,W){var Ae=ii(T)?tr:Mn.current;return Ae=Oe(D,Ae),ds(D,W),T=ag(P,D,T,j,Ae,W),P!==null&&!qo?(D.updateQueue=P.updateQueue,D.effectTag&=-517,P.expirationTime<=W&&(P.expirationTime=0),si(P,D,W)):(D.effectTag|=1,ws(P,D,T,W),D.child)}function gp(P,D,T,j,W){if(ii(T)){var Ae=!0;Ac(D)}else Ae=!1;if(ds(D,W),D.stateNode===null)P!==null&&(P.alternate=null,D.alternate=null,D.effectTag|=2),es(D,T,j,W),jo(D,T,j,W),j=!0;else if(P===null){var ve=D.stateNode,vt=D.memoizedProps;ve.props=vt;var wt=ve.context,bt=T.contextType;typeof bt=="object"&&bt!==null?bt=ms(bt):(bt=ii(T)?tr:Mn.current,bt=Oe(D,bt));var _r=T.getDerivedStateFromProps,is=typeof _r=="function"||typeof ve.getSnapshotBeforeUpdate=="function";is||typeof ve.UNSAFE_componentWillReceiveProps!="function"&&typeof ve.componentWillReceiveProps!="function"||(vt!==j||wt!==bt)&&xi(D,ve,j,bt),Hs=!1;var di=D.memoizedState;wt=ve.state=di;var po=D.updateQueue;po!==null&&(me(D,po,j,ve,W),wt=D.memoizedState),vt!==j||di!==wt||_i.current||Hs?(typeof _r=="function"&&(er(D,T,_r,j),wt=D.memoizedState),(vt=Hs||qi(D,T,vt,j,di,wt,bt))?(is||typeof ve.UNSAFE_componentWillMount!="function"&&typeof ve.componentWillMount!="function"||(typeof ve.componentWillMount=="function"&&ve.componentWillMount(),typeof ve.UNSAFE_componentWillMount=="function"&&ve.UNSAFE_componentWillMount()),typeof ve.componentDidMount=="function"&&(D.effectTag|=4)):(typeof ve.componentDidMount=="function"&&(D.effectTag|=4),D.memoizedProps=j,D.memoizedState=wt),ve.props=j,ve.state=wt,ve.context=bt,j=vt):(typeof ve.componentDidMount=="function"&&(D.effectTag|=4),j=!1)}else ve=D.stateNode,vt=D.memoizedProps,ve.props=D.type===D.elementType?vt:Ci(D.type,vt),wt=ve.context,bt=T.contextType,typeof bt=="object"&&bt!==null?bt=ms(bt):(bt=ii(T)?tr:Mn.current,bt=Oe(D,bt)),_r=T.getDerivedStateFromProps,(is=typeof _r=="function"||typeof ve.getSnapshotBeforeUpdate=="function")||typeof ve.UNSAFE_componentWillReceiveProps!="function"&&typeof ve.componentWillReceiveProps!="function"||(vt!==j||wt!==bt)&&xi(D,ve,j,bt),Hs=!1,wt=D.memoizedState,di=ve.state=wt,po=D.updateQueue,po!==null&&(me(D,po,j,ve,W),di=D.memoizedState),vt!==j||wt!==di||_i.current||Hs?(typeof _r=="function"&&(er(D,T,_r,j),di=D.memoizedState),(_r=Hs||qi(D,T,vt,j,wt,di,bt))?(is||typeof ve.UNSAFE_componentWillUpdate!="function"&&typeof ve.componentWillUpdate!="function"||(typeof ve.componentWillUpdate=="function"&&ve.componentWillUpdate(j,di,bt),typeof ve.UNSAFE_componentWillUpdate=="function"&&ve.UNSAFE_componentWillUpdate(j,di,bt)),typeof ve.componentDidUpdate=="function"&&(D.effectTag|=4),typeof ve.getSnapshotBeforeUpdate=="function"&&(D.effectTag|=256)):(typeof ve.componentDidUpdate!="function"||vt===P.memoizedProps&&wt===P.memoizedState||(D.effectTag|=4),typeof ve.getSnapshotBeforeUpdate!="function"||vt===P.memoizedProps&&wt===P.memoizedState||(D.effectTag|=256),D.memoizedProps=j,D.memoizedState=di),ve.props=j,ve.state=di,ve.context=bt,j=_r):(typeof ve.componentDidUpdate!="function"||vt===P.memoizedProps&&wt===P.memoizedState||(D.effectTag|=4),typeof ve.getSnapshotBeforeUpdate!="function"||vt===P.memoizedProps&&wt===P.memoizedState||(D.effectTag|=256),j=!1);return dp(P,D,T,j,Ae,W)}function dp(P,D,T,j,W,Ae){Go(P,D);var ve=(D.effectTag&64)!==0;if(!j&&!ve)return W&&El(D,T,!1),si(P,D,Ae);j=D.stateNode,hp.current=D;var vt=ve&&typeof T.getDerivedStateFromError!="function"?null:j.render();return D.effectTag|=1,P!==null&&ve?(D.child=gu(D,P.child,null,Ae),D.child=gu(D,null,vt,Ae)):ws(P,D,vt,Ae),D.memoizedState=j.state,W&&El(D,T,!0),D.child}function dg(P){var D=P.stateNode;D.pendingContext?uc(P,D.pendingContext,D.pendingContext!==D.context):D.context&&uc(P,D.context,!1),ig(P,D.containerInfo)}var fa={dehydrated:null,retryTime:0};function ln(P,D,T){var j=D.mode,W=D.pendingProps,Ae=$n.current,ve=!1,vt;if((vt=(D.effectTag&64)!==0)||(vt=(Ae&2)!==0&&(P===null||P.memoizedState!==null)),vt?(ve=!0,D.effectTag&=-65):P!==null&&P.memoizedState===null||W.fallback===void 0||W.unstable_avoidThisFallback===!0||(Ae|=1),On($n,Ae&1,D),P===null){if(W.fallback!==void 0&&TA(D),ve){if(ve=W.fallback,W=xu(null,j,0,null),W.return=D,(D.mode&2)===0)for(P=D.memoizedState!==null?D.child.child:D.child,W.child=P;P!==null;)P.return=W,P=P.sibling;return T=xu(ve,j,T,null),T.return=D,W.sibling=T,D.memoizedState=fa,D.child=W,T}return j=W.children,D.memoizedState=null,D.child=ng(D,null,j,T)}if(P.memoizedState!==null){if(P=P.child,j=P.sibling,ve){if(W=W.fallback,T=YA(P,P.pendingProps,0),T.return=D,(D.mode&2)===0&&(ve=D.memoizedState!==null?D.child.child:D.child,ve!==P.child))for(T.child=ve;ve!==null;)ve.return=T,ve=ve.sibling;return j=YA(j,W,j.expirationTime),j.return=D,T.sibling=j,T.childExpirationTime=0,D.memoizedState=fa,D.child=T,j}return T=gu(D,P.child,W.children,T),D.memoizedState=null,D.child=T}if(P=P.child,ve){if(ve=W.fallback,W=xu(null,j,0,null),W.return=D,W.child=P,P!==null&&(P.return=W),(D.mode&2)===0)for(P=D.memoizedState!==null?D.child.child:D.child,W.child=P;P!==null;)P.return=W,P=P.sibling;return T=xu(ve,j,T,null),T.return=D,W.sibling=T,T.effectTag|=2,W.childExpirationTime=0,D.memoizedState=fa,D.child=W,T}return D.memoizedState=null,D.child=gu(D,P,W.children,T)}function Ao(P,D){P.expirationTime<D&&(P.expirationTime=D);var T=P.alternate;T!==null&&T.expirationTime<D&&(T.expirationTime=D),gs(P.return,D)}function NA(P,D,T,j,W,Ae){var ve=P.memoizedState;ve===null?P.memoizedState={isBackwards:D,rendering:null,last:j,tail:T,tailExpiration:0,tailMode:W,lastEffect:Ae}:(ve.isBackwards=D,ve.rendering=null,ve.last=j,ve.tail=T,ve.tailExpiration=0,ve.tailMode=W,ve.lastEffect=Ae)}function qa(P,D,T){var j=D.pendingProps,W=j.revealOrder,Ae=j.tail;if(ws(P,D,j.children,T),j=$n.current,(j&2)!==0)j=j&1|2,D.effectTag|=64;else{if(P!==null&&(P.effectTag&64)!==0)e:for(P=D.child;P!==null;){if(P.tag===13)P.memoizedState!==null&&Ao(P,T);else if(P.tag===19)Ao(P,T);else if(P.child!==null){P.child.return=P,P=P.child;continue}if(P===D)break e;for(;P.sibling===null;){if(P.return===null||P.return===D)break e;P=P.return}P.sibling.return=P.return,P=P.sibling}j&=1}if(On($n,j,D),(D.mode&2)===0)D.memoizedState=null;else switch(W){case"forwards":for(T=D.child,W=null;T!==null;)P=T.alternate,P!==null&&up(P)===null&&(W=T),T=T.sibling;T=W,T===null?(W=D.child,D.child=null):(W=T.sibling,T.sibling=null),NA(D,!1,W,T,Ae,D.lastEffect);break;case"backwards":for(T=null,W=D.child,D.child=null;W!==null;){if(P=W.alternate,P!==null&&up(P)===null){D.child=W;break}P=W.sibling,W.sibling=T,T=W,W=P}NA(D,!0,T,null,Ae,D.lastEffect);break;case"together":NA(D,!1,null,null,void 0,D.lastEffect);break;default:D.memoizedState=null}return D.child}function si(P,D,T){P!==null&&(D.dependencies=P.dependencies);var j=D.expirationTime;if(j!==0&&_m(j),D.childExpirationTime<T)return null;if(P!==null&&D.child!==P.child)throw Error(n(153));if(D.child!==null){for(P=D.child,T=YA(P,P.pendingProps,P.expirationTime),D.child=T,T.return=D;P.sibling!==null;)P=P.sibling,T=T.sibling=YA(P,P.pendingProps,P.expirationTime),T.return=D;T.sibling=null}return D.child}function pa(P){P.effectTag|=4}var vc,Bl,ts,Gr;if(w)vc=function(P,D){for(var T=D.child;T!==null;){if(T.tag===5||T.tag===6)H(P,T.stateNode);else if(T.tag!==4&&T.child!==null){T.child.return=T,T=T.child;continue}if(T===D)break;for(;T.sibling===null;){if(T.return===null||T.return===D)return;T=T.return}T.sibling.return=T.return,T=T.sibling}},Bl=function(){},ts=function(P,D,T,j,W){if(P=P.memoizedProps,P!==j){var Ae=D.stateNode,ve=ca(uo.current);T=Te(Ae,T,P,j,W,ve),(D.updateQueue=T)&&pa(D)}},Gr=function(P,D,T,j){T!==j&&pa(D)};else if(S){vc=function(P,D,T,j){for(var W=D.child;W!==null;){if(W.tag===5){var Ae=W.stateNode;T&&j&&(Ae=Ti(Ae,W.type,W.memoizedProps,W)),H(P,Ae)}else if(W.tag===6)Ae=W.stateNode,T&&j&&(Ae=ps(Ae,W.memoizedProps,W)),H(P,Ae);else if(W.tag!==4){if(W.tag===13&&(W.effectTag&4)!==0&&(Ae=W.memoizedState!==null)){var ve=W.child;if(ve!==null&&(ve.child!==null&&(ve.child.return=ve,vc(P,ve,!0,Ae)),Ae=ve.sibling,Ae!==null)){Ae.return=W,W=Ae;continue}}if(W.child!==null){W.child.return=W,W=W.child;continue}}if(W===D)break;for(;W.sibling===null;){if(W.return===null||W.return===D)return;W=W.return}W.sibling.return=W.return,W=W.sibling}};var mp=function(P,D,T,j){for(var W=D.child;W!==null;){if(W.tag===5){var Ae=W.stateNode;T&&j&&(Ae=Ti(Ae,W.type,W.memoizedProps,W)),Wr(P,Ae)}else if(W.tag===6)Ae=W.stateNode,T&&j&&(Ae=ps(Ae,W.memoizedProps,W)),Wr(P,Ae);else if(W.tag!==4){if(W.tag===13&&(W.effectTag&4)!==0&&(Ae=W.memoizedState!==null)){var ve=W.child;if(ve!==null&&(ve.child!==null&&(ve.child.return=ve,mp(P,ve,!0,Ae)),Ae=ve.sibling,Ae!==null)){Ae.return=W,W=Ae;continue}}if(W.child!==null){W.child.return=W,W=W.child;continue}}if(W===D)break;for(;W.sibling===null;){if(W.return===null||W.return===D)return;W=W.return}W.sibling.return=W.return,W=W.sibling}};Bl=function(P){var D=P.stateNode;if(P.firstEffect!==null){var T=D.containerInfo,j=xr(T);mp(j,P,!1,!1),D.pendingChildren=j,pa(P),Kn(T,j)}},ts=function(P,D,T,j,W){var Ae=P.stateNode,ve=P.memoizedProps;if((P=D.firstEffect===null)&&ve===j)D.stateNode=Ae;else{var vt=D.stateNode,wt=ca(uo.current),bt=null;ve!==j&&(bt=Te(vt,T,ve,j,W,wt)),P&&bt===null?D.stateNode=Ae:(Ae=mr(Ae,bt,T,ve,j,D,P,vt),lt(Ae,T,j,W,wt)&&pa(D),D.stateNode=Ae,P?pa(D):vc(Ae,D,!1,!1))}},Gr=function(P,D,T,j){T!==j&&(P=ca(mc.current),T=ca(uo.current),D.stateNode=He(j,P,T,D),pa(D))}}else Bl=function(){},ts=function(){},Gr=function(){};function Dc(P,D){switch(P.tailMode){case"hidden":D=P.tail;for(var T=null;D!==null;)D.alternate!==null&&(T=D),D=D.sibling;T===null?P.tail=null:T.sibling=null;break;case"collapsed":T=P.tail;for(var j=null;T!==null;)T.alternate!==null&&(j=T),T=T.sibling;j===null?D||P.tail===null?P.tail=null:P.tail.sibling=null:j.sibling=null}}function Ew(P){switch(P.tag){case 1:ii(P.type)&&Ma(P);var D=P.effectTag;return D&4096?(P.effectTag=D&-4097|64,P):null;case 3:if(yc(P),hr(P),D=P.effectTag,(D&64)!==0)throw Error(n(285));return P.effectTag=D&-4097|64,P;case 5:return sg(P),null;case 13:return Vn($n,P),D=P.effectTag,D&4096?(P.effectTag=D&-4097|64,P):null;case 19:return Vn($n,P),null;case 4:return yc(P),null;case 10:return wi(P),null;default:return null}}function mg(P,D){return{value:P,source:D,stack:ml(D)}}var yg=typeof WeakSet=="function"?WeakSet:Set;function Ga(P,D){var T=D.source,j=D.stack;j===null&&T!==null&&(j=ml(T)),T!==null&&ae(T.type),D=D.value,P!==null&&P.tag===1&&ae(P.type);try{console.error(D)}catch(W){setTimeout(function(){throw W})}}function Fm(P,D){try{D.props=P.memoizedProps,D.state=P.memoizedState,D.componentWillUnmount()}catch(T){GA(P,T)}}function Eg(P){var D=P.ref;if(D!==null)if(typeof D=="function")try{D(null)}catch(T){GA(P,T)}else D.current=null}function Qt(P,D){switch(D.tag){case 0:case 11:case 15:L(2,0,D);break;case 1:if(D.effectTag&256&&P!==null){var T=P.memoizedProps,j=P.memoizedState;P=D.stateNode,D=P.getSnapshotBeforeUpdate(D.elementType===D.type?T:Ci(D.type,T),j),P.__reactInternalSnapshotBeforeUpdate=D}break;case 3:case 5:case 6:case 4:case 17:break;default:throw Error(n(163))}}function L(P,D,T){if(T=T.updateQueue,T=T!==null?T.lastEffect:null,T!==null){var j=T=T.next;do{if((j.tag&P)!==0){var W=j.destroy;j.destroy=void 0,W!==void 0&&W()}(j.tag&D)!==0&&(W=j.create,j.destroy=W()),j=j.next}while(j!==T)}}function K(P,D,T){switch(typeof bw=="function"&&bw(D),D.tag){case 0:case 11:case 14:case 15:if(P=D.updateQueue,P!==null&&(P=P.lastEffect,P!==null)){var j=P.next;lo(97<T?97:T,function(){var W=j;do{var Ae=W.destroy;if(Ae!==void 0){var ve=D;try{Ae()}catch(vt){GA(ve,vt)}}W=W.next}while(W!==j)})}break;case 1:Eg(D),T=D.stateNode,typeof T.componentWillUnmount=="function"&&Fm(D,T);break;case 5:Eg(D);break;case 4:w?Cr(P,D,T):S&&Je(D)}}function re(P,D,T){for(var j=D;;)if(K(P,j,T),j.child===null||w&&j.tag===4){if(j===D)break;for(;j.sibling===null;){if(j.return===null||j.return===D)return;j=j.return}j.sibling.return=j.return,j=j.sibling}else j.child.return=j,j=j.child}function he(P){var D=P.alternate;P.return=null,P.child=null,P.memoizedState=null,P.updateQueue=null,P.dependencies=null,P.alternate=null,P.firstEffect=null,P.lastEffect=null,P.pendingProps=null,P.memoizedProps=null,D!==null&&he(D)}function Je(P){if(S){P=P.stateNode.containerInfo;var D=xr(P);Ns(P,D)}}function mt(P){return P.tag===5||P.tag===3||P.tag===4}function fr(P){if(w){e:{for(var D=P.return;D!==null;){if(mt(D)){var T=D;break e}D=D.return}throw Error(n(160))}switch(D=T.stateNode,T.tag){case 5:var j=!1;break;case 3:D=D.containerInfo,j=!0;break;case 4:D=D.containerInfo,j=!0;break;default:throw Error(n(161))}T.effectTag&16&&(jt(D),T.effectTag&=-17);e:t:for(T=P;;){for(;T.sibling===null;){if(T.return===null||mt(T.return)){T=null;break e}T=T.return}for(T.sibling.return=T.return,T=T.sibling;T.tag!==5&&T.tag!==6&&T.tag!==18;){if(T.effectTag&2||T.child===null||T.tag===4)continue t;T.child.return=T,T=T.child}if(!(T.effectTag&2)){T=T.stateNode;break e}}for(var W=P;;){var Ae=W.tag===5||W.tag===6;if(Ae)Ae=Ae?W.stateNode:W.stateNode.instance,T?j?Le(D,Ae,T):Pe(D,Ae,T):j?J(D,Ae):R(D,Ae);else if(W.tag!==4&&W.child!==null){W.child.return=W,W=W.child;continue}if(W===P)break;for(;W.sibling===null;){if(W.return===null||W.return===P)return;W=W.return}W.sibling.return=W.return,W=W.sibling}}}function Cr(P,D,T){for(var j=D,W=!1,Ae,ve;;){if(!W){W=j.return;e:for(;;){if(W===null)throw Error(n(160));switch(Ae=W.stateNode,W.tag){case 5:ve=!1;break e;case 3:Ae=Ae.containerInfo,ve=!0;break e;case 4:Ae=Ae.containerInfo,ve=!0;break e}W=W.return}W=!0}if(j.tag===5||j.tag===6)re(P,j,T),ve?dt(Ae,j.stateNode):ot(Ae,j.stateNode);else if(j.tag===4){if(j.child!==null){Ae=j.stateNode.containerInfo,ve=!0,j.child.return=j,j=j.child;continue}}else if(K(P,j,T),j.child!==null){j.child.return=j,j=j.child;continue}if(j===D)break;for(;j.sibling===null;){if(j.return===null||j.return===D)return;j=j.return,j.tag===4&&(W=!1)}j.sibling.return=j.return,j=j.sibling}}function yn(P,D){if(w)switch(D.tag){case 0:case 11:case 14:case 15:L(4,8,D);break;case 1:break;case 5:var T=D.stateNode;if(T!=null){var j=D.memoizedProps;P=P!==null?P.memoizedProps:j;var W=D.type,Ae=D.updateQueue;D.updateQueue=null,Ae!==null&&ie(T,Ae,W,P,j,D)}break;case 6:if(D.stateNode===null)throw Error(n(162));T=D.memoizedProps,X(D.stateNode,P!==null?P.memoizedProps:T,T);break;case 3:y&&(D=D.stateNode,D.hydrate&&(D.hydrate=!1,Dn(D.containerInfo)));break;case 12:break;case 13:oi(D),Oi(D);break;case 19:Oi(D);break;case 17:break;case 20:break;case 21:break;default:throw Error(n(163))}else{switch(D.tag){case 0:case 11:case 14:case 15:L(4,8,D);return;case 12:return;case 13:oi(D),Oi(D);return;case 19:Oi(D);return;case 3:y&&(T=D.stateNode,T.hydrate&&(T.hydrate=!1,Dn(T.containerInfo)))}e:if(S)switch(D.tag){case 1:case 5:case 6:case 20:break e;case 3:case 4:D=D.stateNode,Ns(D.containerInfo,D.pendingChildren);break e;default:throw Error(n(163))}}}function oi(P){var D=P;if(P.memoizedState===null)var T=!1;else T=!0,D=P.child,Iw=Ni();if(w&&D!==null){e:if(P=D,w)for(D=P;;){if(D.tag===5){var j=D.stateNode;T?$t(j):an(D.stateNode,D.memoizedProps)}else if(D.tag===6)j=D.stateNode,T?xt(j):kr(j,D.memoizedProps);else if(D.tag===13&&D.memoizedState!==null&&D.memoizedState.dehydrated===null){j=D.child.sibling,j.return=D,D=j;continue}else if(D.child!==null){D.child.return=D,D=D.child;continue}if(D===P)break e;for(;D.sibling===null;){if(D.return===null||D.return===P)break e;D=D.return}D.sibling.return=D.return,D=D.sibling}}}function Oi(P){var D=P.updateQueue;if(D!==null){P.updateQueue=null;var T=P.stateNode;T===null&&(T=P.stateNode=new yg),D.forEach(function(j){var W=yF.bind(null,P,j);T.has(j)||(T.add(j),j.then(W,W))})}}var Cg=typeof WeakMap=="function"?WeakMap:Map;function Gv(P,D,T){T=ys(T,null),T.tag=3,T.payload={element:null};var j=D.value;return T.callback=function(){vu||(vu=!0,Om=j),Ga(P,D)},T}function Yv(P,D,T){T=ys(T,null),T.tag=3;var j=P.type.getDerivedStateFromError;if(typeof j=="function"){var W=D.value;T.payload=function(){return Ga(P,D),j(W)}}var Ae=P.stateNode;return Ae!==null&&typeof Ae.componentDidCatch=="function"&&(T.callback=function(){typeof j!="function"&&(Du===null?Du=new Set([this]):Du.add(this),Ga(P,D));var ve=D.stack;this.componentDidCatch(D.value,{componentStack:ve!==null?ve:""})}),T}var Cw=Math.ceil,yp=u.ReactCurrentDispatcher,ww=u.ReactCurrentOwner,En=0,Rm=8,rs=16,qs=32,Bu=0,Tm=1,Bi=2,ha=3,vl=4,Pc=5,yr=En,gi=null,Or=null,ns=0,Yi=Bu,Lm=null,Ya=1073741823,OA=1073741823,Nm=null,Ep=0,MA=!1,Iw=0,Bw=500,sr=null,vu=!1,Om=null,Du=null,Cp=!1,wg=null,UA=90,_A=null,Ig=0,vw=null,Mm=0;function ga(){return(yr&(rs|qs))!==En?1073741821-(Ni()/10|0):Mm!==0?Mm:Mm=1073741821-(Ni()/10|0)}function HA(P,D,T){if(D=D.mode,(D&2)===0)return 1073741823;var j=_o();if((D&4)===0)return j===99?1073741823:1073741822;if((yr&rs)!==En)return ns;if(T!==null)P=Ua(P,T.timeoutMs|0||5e3,250);else switch(j){case 99:P=1073741823;break;case 98:P=Ua(P,150,100);break;case 97:case 96:P=Ua(P,5e3,250);break;case 95:P=2;break;default:throw Error(n(326))}return gi!==null&&P===ns&&--P,P}function Sc(P,D){if(50<Ig)throw Ig=0,vw=null,Error(n(185));if(P=Bg(P,D),P!==null){var T=_o();D===1073741823?(yr&Rm)!==En&&(yr&(rs|qs))===En?Dw(P):(fo(P),yr===En&&ji()):fo(P),(yr&4)===En||T!==98&&T!==99||(_A===null?_A=new Map([[P,D]]):(T=_A.get(P),(T===void 0||T>D)&&_A.set(P,D)))}}function Bg(P,D){P.expirationTime<D&&(P.expirationTime=D);var T=P.alternate;T!==null&&T.expirationTime<D&&(T.expirationTime=D);var j=P.return,W=null;if(j===null&&P.tag===3)W=P.stateNode;else for(;j!==null;){if(T=j.alternate,j.childExpirationTime<D&&(j.childExpirationTime=D),T!==null&&T.childExpirationTime<D&&(T.childExpirationTime=D),j.return===null&&j.tag===3){W=j.stateNode;break}j=j.return}return W!==null&&(gi===W&&(_m(D),Yi===vl&&WA(W,ns)),eD(W,D)),W}function Um(P){var D=P.lastExpiredTime;return D!==0||(D=P.firstPendingTime,!$v(P,D))?D:(D=P.lastPingedTime,P=P.nextKnownPendingLevel,D>P?D:P)}function fo(P){if(P.lastExpiredTime!==0)P.callbackExpirationTime=1073741823,P.callbackPriority=99,P.callbackNode=pu(Dw.bind(null,P));else{var D=Um(P),T=P.callbackNode;if(D===0)T!==null&&(P.callbackNode=null,P.callbackExpirationTime=0,P.callbackPriority=90);else{var j=ga();if(D===1073741823?j=99:D===1||D===2?j=95:(j=10*(1073741821-D)-10*(1073741821-j),j=0>=j?99:250>=j?98:5250>=j?97:95),T!==null){var W=P.callbackPriority;if(P.callbackExpirationTime===D&&W>=j)return;T!==PA&&Ce(T)}P.callbackExpirationTime=D,P.callbackPriority=j,D=D===1073741823?pu(Dw.bind(null,P)):gc(j,Wv.bind(null,P),{timeout:10*(1073741821-D)-Ni()}),P.callbackNode=D}}}function Wv(P,D){if(Mm=0,D)return D=ga(),jm(P,D),fo(P),null;var T=Um(P);if(T!==0){if(D=P.callbackNode,(yr&(rs|qs))!==En)throw Error(n(327));if(wp(),P===gi&&T===ns||Pu(P,T),Or!==null){var j=yr;yr|=rs;var W=qA(P);do try{pF();break}catch(vt){jA(P,vt)}while(1);if(la(),yr=j,yp.current=W,Yi===Tm)throw D=Lm,Pu(P,T),WA(P,T),fo(P),D;if(Or===null)switch(W=P.finishedWork=P.current.alternate,P.finishedExpirationTime=T,j=Yi,gi=null,j){case Bu:case Tm:throw Error(n(345));case Bi:jm(P,2<T?2:T);break;case ha:if(WA(P,T),j=P.lastSuspendedTime,T===j&&(P.nextKnownPendingLevel=Sw(W)),Ya===1073741823&&(W=Iw+Bw-Ni(),10<W)){if(MA){var Ae=P.lastPingedTime;if(Ae===0||Ae>=T){P.lastPingedTime=T,Pu(P,T);break}}if(Ae=Um(P),Ae!==0&&Ae!==T)break;if(j!==0&&j!==T){P.lastPingedTime=j;break}P.timeoutHandle=Re(Su.bind(null,P),W);break}Su(P);break;case vl:if(WA(P,T),j=P.lastSuspendedTime,T===j&&(P.nextKnownPendingLevel=Sw(W)),MA&&(W=P.lastPingedTime,W===0||W>=T)){P.lastPingedTime=T,Pu(P,T);break}if(W=Um(P),W!==0&&W!==T)break;if(j!==0&&j!==T){P.lastPingedTime=j;break}if(OA!==1073741823?j=10*(1073741821-OA)-Ni():Ya===1073741823?j=0:(j=10*(1073741821-Ya)-5e3,W=Ni(),T=10*(1073741821-T)-W,j=W-j,0>j&&(j=0),j=(120>j?120:480>j?480:1080>j?1080:1920>j?1920:3e3>j?3e3:4320>j?4320:1960*Cw(j/1960))-j,T<j&&(j=T)),10<j){P.timeoutHandle=Re(Su.bind(null,P),j);break}Su(P);break;case Pc:if(Ya!==1073741823&&Nm!==null){Ae=Ya;var ve=Nm;if(j=ve.busyMinDurationMs|0,0>=j?j=0:(W=ve.busyDelayMs|0,Ae=Ni()-(10*(1073741821-Ae)-(ve.timeoutMs|0||5e3)),j=Ae<=W?0:W+j-Ae),10<j){WA(P,T),P.timeoutHandle=Re(Su.bind(null,P),j);break}}Su(P);break;default:throw Error(n(329))}if(fo(P),P.callbackNode===D)return Wv.bind(null,P)}}return null}function Dw(P){var D=P.lastExpiredTime;if(D=D!==0?D:1073741823,P.finishedExpirationTime===D)Su(P);else{if((yr&(rs|qs))!==En)throw Error(n(327));if(wp(),P===gi&&D===ns||Pu(P,D),Or!==null){var T=yr;yr|=rs;var j=qA(P);do try{fF();break}catch(W){jA(P,W)}while(1);if(la(),yr=T,yp.current=j,Yi===Tm)throw T=Lm,Pu(P,D),WA(P,D),fo(P),T;if(Or!==null)throw Error(n(261));P.finishedWork=P.current.alternate,P.finishedExpirationTime=D,gi=null,Su(P),fo(P)}}return null}function Kv(P,D){jm(P,D),fo(P),(yr&(rs|qs))===En&&ji()}function AF(){if(_A!==null){var P=_A;_A=null,P.forEach(function(D,T){jm(T,D),fo(T)}),ji()}}function Vv(P,D){if((yr&(rs|qs))!==En)throw Error(n(187));var T=yr;yr|=1;try{return lo(99,P.bind(null,D))}finally{yr=T,ji()}}function Pu(P,D){P.finishedWork=null,P.finishedExpirationTime=0;var T=P.timeoutHandle;if(T!==je&&(P.timeoutHandle=je,ze(T)),Or!==null)for(T=Or.return;T!==null;){var j=T;switch(j.tag){case 1:var W=j.type.childContextTypes;W!=null&&Ma(j);break;case 3:yc(j),hr(j);break;case 5:sg(j);break;case 4:yc(j);break;case 13:Vn($n,j);break;case 19:Vn($n,j);break;case 10:wi(j)}T=T.return}gi=P,Or=YA(P.current,null,D),ns=D,Yi=Bu,Lm=null,OA=Ya=1073741823,Nm=null,Ep=0,MA=!1}function jA(P,D){do{try{if(la(),mw(),Or===null||Or.return===null)return Yi=Tm,Lm=D,null;e:{var T=P,j=Or.return,W=Or,Ae=D;if(D=ns,W.effectTag|=2048,W.firstEffect=W.lastEffect=null,Ae!==null&&typeof Ae=="object"&&typeof Ae.then=="function"){var ve=Ae,vt=($n.current&1)!==0,wt=j;do{var bt;if(bt=wt.tag===13){var _r=wt.memoizedState;if(_r!==null)bt=_r.dehydrated!==null;else{var is=wt.memoizedProps;bt=is.fallback===void 0?!1:is.unstable_avoidThisFallback!==!0?!0:!vt}}if(bt){var di=wt.updateQueue;if(di===null){var po=new Set;po.add(ve),wt.updateQueue=po}else di.add(ve);if((wt.mode&2)===0){if(wt.effectTag|=64,W.effectTag&=-2981,W.tag===1)if(W.alternate===null)W.tag=17;else{var KA=ys(1073741823,null);KA.tag=2,tt(W,KA)}W.expirationTime=1073741823;break e}Ae=void 0,W=D;var Yo=T.pingCache;if(Yo===null?(Yo=T.pingCache=new Cg,Ae=new Set,Yo.set(ve,Ae)):(Ae=Yo.get(ve),Ae===void 0&&(Ae=new Set,Yo.set(ve,Ae))),!Ae.has(W)){Ae.add(W);var rt=mF.bind(null,T,ve,W);ve.then(rt,rt)}wt.effectTag|=4096,wt.expirationTime=D;break e}wt=wt.return}while(wt!==null);Ae=Error((ae(W.type)||"A React component")+` suspended while rendering, but no fallback UI was specified. - -Add a <Suspense fallback=...> component higher in the tree to provide a loading indicator or placeholder to display.`+ml(W))}Yi!==Pc&&(Yi=Bi),Ae=mg(Ae,W),wt=j;do{switch(wt.tag){case 3:ve=Ae,wt.effectTag|=4096,wt.expirationTime=D;var Ve=Gv(wt,ve,D);It(wt,Ve);break e;case 1:ve=Ae;var At=wt.type,Wt=wt.stateNode;if((wt.effectTag&64)===0&&(typeof At.getDerivedStateFromError=="function"||Wt!==null&&typeof Wt.componentDidCatch=="function"&&(Du===null||!Du.has(Wt)))){wt.effectTag|=4096,wt.expirationTime=D;var vr=Yv(wt,ve,D);It(wt,vr);break e}}wt=wt.return}while(wt!==null)}Or=Jv(Or)}catch(Sn){D=Sn;continue}break}while(1)}function qA(){var P=yp.current;return yp.current=wu,P===null?wu:P}function Pw(P,D){P<Ya&&2<P&&(Ya=P),D!==null&&P<OA&&2<P&&(OA=P,Nm=D)}function _m(P){P>Ep&&(Ep=P)}function fF(){for(;Or!==null;)Or=zv(Or)}function pF(){for(;Or!==null&&!Rt();)Or=zv(Or)}function zv(P){var D=Zv(P.alternate,P,ns);return P.memoizedProps=P.pendingProps,D===null&&(D=Jv(P)),ww.current=null,D}function Jv(P){Or=P;do{var D=Or.alternate;if(P=Or.return,(Or.effectTag&2048)===0){e:{var T=D;D=Or;var j=ns,W=D.pendingProps;switch(D.tag){case 2:break;case 16:break;case 15:case 0:break;case 1:ii(D.type)&&Ma(D);break;case 3:yc(D),hr(D),W=D.stateNode,W.pendingContext&&(W.context=W.pendingContext,W.pendingContext=null),(T===null||T.child===null)&&ja(D)&&pa(D),Bl(D);break;case 5:sg(D);var Ae=ca(mc.current);if(j=D.type,T!==null&&D.stateNode!=null)ts(T,D,j,W,Ae),T.ref!==D.ref&&(D.effectTag|=128);else if(W){if(T=ca(uo.current),ja(D)){if(W=D,!y)throw Error(n(175));T=op(W.stateNode,W.type,W.memoizedProps,Ae,T,W),W.updateQueue=T,T=T!==null,T&&pa(D)}else{var ve=ht(j,W,Ae,T,D);vc(ve,D,!1,!1),D.stateNode=ve,lt(ve,j,W,Ae,T)&&pa(D)}D.ref!==null&&(D.effectTag|=128)}else if(D.stateNode===null)throw Error(n(166));break;case 6:if(T&&D.stateNode!=null)Gr(T,D,T.memoizedProps,W);else{if(typeof W!="string"&&D.stateNode===null)throw Error(n(166));if(T=ca(mc.current),Ae=ca(uo.current),ja(D)){if(T=D,!y)throw Error(n(176));(T=ap(T.stateNode,T.memoizedProps,T))&&pa(D)}else D.stateNode=He(W,T,Ae,D)}break;case 11:break;case 13:if(Vn($n,D),W=D.memoizedState,(D.effectTag&64)!==0){D.expirationTime=j;break e}W=W!==null,Ae=!1,T===null?D.memoizedProps.fallback!==void 0&&ja(D):(j=T.memoizedState,Ae=j!==null,W||j===null||(j=T.child.sibling,j!==null&&(ve=D.firstEffect,ve!==null?(D.firstEffect=j,j.nextEffect=ve):(D.firstEffect=D.lastEffect=j,j.nextEffect=null),j.effectTag=8))),W&&!Ae&&(D.mode&2)!==0&&(T===null&&D.memoizedProps.unstable_avoidThisFallback!==!0||($n.current&1)!==0?Yi===Bu&&(Yi=ha):((Yi===Bu||Yi===ha)&&(Yi=vl),Ep!==0&&gi!==null&&(WA(gi,ns),eD(gi,Ep)))),S&&W&&(D.effectTag|=4),w&&(W||Ae)&&(D.effectTag|=4);break;case 7:break;case 8:break;case 12:break;case 4:yc(D),Bl(D);break;case 10:wi(D);break;case 9:break;case 14:break;case 17:ii(D.type)&&Ma(D);break;case 19:if(Vn($n,D),W=D.memoizedState,W===null)break;if(Ae=(D.effectTag&64)!==0,ve=W.rendering,ve===null){if(Ae)Dc(W,!1);else if(Yi!==Bu||T!==null&&(T.effectTag&64)!==0)for(T=D.child;T!==null;){if(ve=up(T),ve!==null){for(D.effectTag|=64,Dc(W,!1),T=ve.updateQueue,T!==null&&(D.updateQueue=T,D.effectTag|=4),W.lastEffect===null&&(D.firstEffect=null),D.lastEffect=W.lastEffect,T=j,W=D.child;W!==null;)Ae=W,j=T,Ae.effectTag&=2,Ae.nextEffect=null,Ae.firstEffect=null,Ae.lastEffect=null,ve=Ae.alternate,ve===null?(Ae.childExpirationTime=0,Ae.expirationTime=j,Ae.child=null,Ae.memoizedProps=null,Ae.memoizedState=null,Ae.updateQueue=null,Ae.dependencies=null):(Ae.childExpirationTime=ve.childExpirationTime,Ae.expirationTime=ve.expirationTime,Ae.child=ve.child,Ae.memoizedProps=ve.memoizedProps,Ae.memoizedState=ve.memoizedState,Ae.updateQueue=ve.updateQueue,j=ve.dependencies,Ae.dependencies=j===null?null:{expirationTime:j.expirationTime,firstContext:j.firstContext,responders:j.responders}),W=W.sibling;On($n,$n.current&1|2,D),D=D.child;break e}T=T.sibling}}else{if(!Ae)if(T=up(ve),T!==null){if(D.effectTag|=64,Ae=!0,T=T.updateQueue,T!==null&&(D.updateQueue=T,D.effectTag|=4),Dc(W,!0),W.tail===null&&W.tailMode==="hidden"&&!ve.alternate){D=D.lastEffect=W.lastEffect,D!==null&&(D.nextEffect=null);break}}else Ni()>W.tailExpiration&&1<j&&(D.effectTag|=64,Ae=!0,Dc(W,!1),D.expirationTime=D.childExpirationTime=j-1);W.isBackwards?(ve.sibling=D.child,D.child=ve):(T=W.last,T!==null?T.sibling=ve:D.child=ve,W.last=ve)}if(W.tail!==null){W.tailExpiration===0&&(W.tailExpiration=Ni()+500),T=W.tail,W.rendering=T,W.tail=T.sibling,W.lastEffect=D.lastEffect,T.sibling=null,W=$n.current,W=Ae?W&1|2:W&1,On($n,W,D),D=T;break e}break;case 20:break;case 21:break;default:throw Error(n(156,D.tag))}D=null}if(T=Or,ns===1||T.childExpirationTime!==1){for(W=0,Ae=T.child;Ae!==null;)j=Ae.expirationTime,ve=Ae.childExpirationTime,j>W&&(W=j),ve>W&&(W=ve),Ae=Ae.sibling;T.childExpirationTime=W}if(D!==null)return D;P!==null&&(P.effectTag&2048)===0&&(P.firstEffect===null&&(P.firstEffect=Or.firstEffect),Or.lastEffect!==null&&(P.lastEffect!==null&&(P.lastEffect.nextEffect=Or.firstEffect),P.lastEffect=Or.lastEffect),1<Or.effectTag&&(P.lastEffect!==null?P.lastEffect.nextEffect=Or:P.firstEffect=Or,P.lastEffect=Or))}else{if(D=Ew(Or,ns),D!==null)return D.effectTag&=2047,D;P!==null&&(P.firstEffect=P.lastEffect=null,P.effectTag|=2048)}if(D=Or.sibling,D!==null)return D;Or=P}while(Or!==null);return Yi===Bu&&(Yi=Pc),null}function Sw(P){var D=P.expirationTime;return P=P.childExpirationTime,D>P?D:P}function Su(P){var D=_o();return lo(99,hF.bind(null,P,D)),null}function hF(P,D){do wp();while(wg!==null);if((yr&(rs|qs))!==En)throw Error(n(327));var T=P.finishedWork,j=P.finishedExpirationTime;if(T===null)return null;if(P.finishedWork=null,P.finishedExpirationTime=0,T===P.current)throw Error(n(177));P.callbackNode=null,P.callbackExpirationTime=0,P.callbackPriority=90,P.nextKnownPendingLevel=0;var W=Sw(T);if(P.firstPendingTime=W,j<=P.lastSuspendedTime?P.firstSuspendedTime=P.lastSuspendedTime=P.nextKnownPendingLevel=0:j<=P.firstSuspendedTime&&(P.firstSuspendedTime=j-1),j<=P.lastPingedTime&&(P.lastPingedTime=0),j<=P.lastExpiredTime&&(P.lastExpiredTime=0),P===gi&&(Or=gi=null,ns=0),1<T.effectTag?T.lastEffect!==null?(T.lastEffect.nextEffect=T,W=T.firstEffect):W=T:W=T.firstEffect,W!==null){var Ae=yr;yr|=qs,ww.current=null,we(P.containerInfo),sr=W;do try{gF()}catch(ho){if(sr===null)throw Error(n(330));GA(sr,ho),sr=sr.nextEffect}while(sr!==null);sr=W;do try{for(var ve=P,vt=D;sr!==null;){var wt=sr.effectTag;if(wt&16&&w&&jt(sr.stateNode),wt&128){var bt=sr.alternate;if(bt!==null){var _r=bt.ref;_r!==null&&(typeof _r=="function"?_r(null):_r.current=null)}}switch(wt&1038){case 2:fr(sr),sr.effectTag&=-3;break;case 6:fr(sr),sr.effectTag&=-3,yn(sr.alternate,sr);break;case 1024:sr.effectTag&=-1025;break;case 1028:sr.effectTag&=-1025,yn(sr.alternate,sr);break;case 4:yn(sr.alternate,sr);break;case 8:var is=ve,di=sr,po=vt;w?Cr(is,di,po):re(is,di,po),he(di)}sr=sr.nextEffect}}catch(ho){if(sr===null)throw Error(n(330));GA(sr,ho),sr=sr.nextEffect}while(sr!==null);be(P.containerInfo),P.current=T,sr=W;do try{for(wt=j;sr!==null;){var KA=sr.effectTag;if(KA&36){var Yo=sr.alternate;switch(bt=sr,_r=wt,bt.tag){case 0:case 11:case 15:L(16,32,bt);break;case 1:var rt=bt.stateNode;if(bt.effectTag&4)if(Yo===null)rt.componentDidMount();else{var Ve=bt.elementType===bt.type?Yo.memoizedProps:Ci(bt.type,Yo.memoizedProps);rt.componentDidUpdate(Ve,Yo.memoizedState,rt.__reactInternalSnapshotBeforeUpdate)}var At=bt.updateQueue;At!==null&&Ne(bt,At,rt,_r);break;case 3:var Wt=bt.updateQueue;if(Wt!==null){if(ve=null,bt.child!==null)switch(bt.child.tag){case 5:ve=ce(bt.child.stateNode);break;case 1:ve=bt.child.stateNode}Ne(bt,Wt,ve,_r)}break;case 5:var vr=bt.stateNode;Yo===null&&bt.effectTag&4&&Z(vr,bt.type,bt.memoizedProps,bt);break;case 6:break;case 4:break;case 12:break;case 13:if(y&&bt.memoizedState===null){var Sn=bt.alternate;if(Sn!==null){var Fr=Sn.memoizedState;if(Fr!==null){var xn=Fr.dehydrated;xn!==null&&oo(xn)}}}break;case 19:case 17:case 20:case 21:break;default:throw Error(n(163))}}if(KA&128){bt=void 0;var ai=sr.ref;if(ai!==null){var en=sr.stateNode;switch(sr.tag){case 5:bt=ce(en);break;default:bt=en}typeof ai=="function"?ai(bt):ai.current=bt}}sr=sr.nextEffect}}catch(ho){if(sr===null)throw Error(n(330));GA(sr,ho),sr=sr.nextEffect}while(sr!==null);sr=null,Qn(),yr=Ae}else P.current=T;if(Cp)Cp=!1,wg=P,UA=D;else for(sr=W;sr!==null;)D=sr.nextEffect,sr.nextEffect=null,sr=D;if(D=P.firstPendingTime,D===0&&(Du=null),D===1073741823?P===vw?Ig++:(Ig=0,vw=P):Ig=0,typeof xw=="function"&&xw(T.stateNode,j),fo(P),vu)throw vu=!1,P=Om,Om=null,P;return(yr&Rm)!==En||ji(),null}function gF(){for(;sr!==null;){var P=sr.effectTag;(P&256)!==0&&Qt(sr.alternate,sr),(P&512)===0||Cp||(Cp=!0,gc(97,function(){return wp(),null})),sr=sr.nextEffect}}function wp(){if(UA!==90){var P=97<UA?97:UA;return UA=90,lo(P,dF)}}function dF(){if(wg===null)return!1;var P=wg;if(wg=null,(yr&(rs|qs))!==En)throw Error(n(331));var D=yr;for(yr|=qs,P=P.current.firstEffect;P!==null;){try{var T=P;if((T.effectTag&512)!==0)switch(T.tag){case 0:case 11:case 15:L(128,0,T),L(0,64,T)}}catch(j){if(P===null)throw Error(n(330));GA(P,j)}T=P.nextEffect,P.nextEffect=null,P=T}return yr=D,ji(),!0}function Xv(P,D,T){D=mg(T,D),D=Gv(P,D,1073741823),tt(P,D),P=Bg(P,1073741823),P!==null&&fo(P)}function GA(P,D){if(P.tag===3)Xv(P,P,D);else for(var T=P.return;T!==null;){if(T.tag===3){Xv(T,P,D);break}else if(T.tag===1){var j=T.stateNode;if(typeof T.type.getDerivedStateFromError=="function"||typeof j.componentDidCatch=="function"&&(Du===null||!Du.has(j))){P=mg(D,P),P=Yv(T,P,1073741823),tt(T,P),T=Bg(T,1073741823),T!==null&&fo(T);break}}T=T.return}}function mF(P,D,T){var j=P.pingCache;j!==null&&j.delete(D),gi===P&&ns===T?Yi===vl||Yi===ha&&Ya===1073741823&&Ni()-Iw<Bw?Pu(P,ns):MA=!0:$v(P,T)&&(D=P.lastPingedTime,D!==0&&D<T||(P.lastPingedTime=T,P.finishedExpirationTime===T&&(P.finishedExpirationTime=0,P.finishedWork=null),fo(P)))}function yF(P,D){var T=P.stateNode;T!==null&&T.delete(D),D=0,D===0&&(D=ga(),D=HA(D,P,null)),P=Bg(P,D),P!==null&&fo(P)}var Zv;Zv=function(P,D,T){var j=D.expirationTime;if(P!==null){var W=D.pendingProps;if(P.memoizedProps!==W||_i.current)qo=!0;else{if(j<T){switch(qo=!1,D.tag){case 3:dg(D),gg();break;case 5:if(Pm(D),D.mode&4&&T!==1&&xe(D.type,W))return D.expirationTime=D.childExpirationTime=1,null;break;case 1:ii(D.type)&&Ac(D);break;case 4:ig(D,D.stateNode.containerInfo);break;case 10:Ho(D,D.memoizedProps.value);break;case 13:if(D.memoizedState!==null)return j=D.child.childExpirationTime,j!==0&&j>=T?ln(P,D,T):(On($n,$n.current&1,D),D=si(P,D,T),D!==null?D.sibling:null);On($n,$n.current&1,D);break;case 19:if(j=D.childExpirationTime>=T,(P.effectTag&64)!==0){if(j)return qa(P,D,T);D.effectTag|=64}if(W=D.memoizedState,W!==null&&(W.rendering=null,W.tail=null),On($n,$n.current,D),!j)return null}return si(P,D,T)}qo=!1}}else qo=!1;switch(D.expirationTime=0,D.tag){case 2:if(j=D.type,P!==null&&(P.alternate=null,D.alternate=null,D.effectTag|=2),P=D.pendingProps,W=Oe(D,Mn.current),ds(D,T),W=ag(null,D,j,P,W,T),D.effectTag|=1,typeof W=="object"&&W!==null&&typeof W.render=="function"&&W.$$typeof===void 0){if(D.tag=1,mw(),ii(j)){var Ae=!0;Ac(D)}else Ae=!1;D.memoizedState=W.state!==null&&W.state!==void 0?W.state:null;var ve=j.getDerivedStateFromProps;typeof ve=="function"&&er(D,j,ve,P),W.updater=Zr,D.stateNode=W,W._reactInternalFiber=D,jo(D,j,P,T),D=dp(null,D,j,!0,Ae,T)}else D.tag=0,ws(null,D,W,T),D=D.child;return D;case 16:if(W=D.elementType,P!==null&&(P.alternate=null,D.alternate=null,D.effectTag|=2),P=D.pendingProps,ye(W),W._status!==1)throw W._result;switch(W=W._result,D.type=W,Ae=D.tag=wF(W),P=Ci(W,P),Ae){case 0:D=LA(null,D,W,P,T);break;case 1:D=gp(null,D,W,P,T);break;case 11:D=Ii(null,D,W,P,T);break;case 14:D=km(null,D,W,Ci(W.type,P),j,T);break;default:throw Error(n(306,W,""))}return D;case 0:return j=D.type,W=D.pendingProps,W=D.elementType===j?W:Ci(j,W),LA(P,D,j,W,T);case 1:return j=D.type,W=D.pendingProps,W=D.elementType===j?W:Ci(j,W),gp(P,D,j,W,T);case 3:if(dg(D),j=D.updateQueue,j===null)throw Error(n(282));if(W=D.memoizedState,W=W!==null?W.element:null,me(D,j,D.pendingProps,null,T),j=D.memoizedState.element,j===W)gg(),D=si(P,D,T);else{if((W=D.stateNode.hydrate)&&(y?(Bc=cu(D.stateNode.containerInfo),Aa=D,W=Il=!0):W=!1),W)for(T=ng(D,null,j,T),D.child=T;T;)T.effectTag=T.effectTag&-3|1024,T=T.sibling;else ws(P,D,j,T),gg();D=D.child}return D;case 5:return Pm(D),P===null&&TA(D),j=D.type,W=D.pendingProps,Ae=P!==null?P.memoizedProps:null,ve=W.children,ke(j,W)?ve=null:Ae!==null&&ke(j,Ae)&&(D.effectTag|=16),Go(P,D),D.mode&4&&T!==1&&xe(j,W)?(D.expirationTime=D.childExpirationTime=1,D=null):(ws(P,D,ve,T),D=D.child),D;case 6:return P===null&&TA(D),null;case 13:return ln(P,D,T);case 4:return ig(D,D.stateNode.containerInfo),j=D.pendingProps,P===null?D.child=gu(D,null,j,T):ws(P,D,j,T),D.child;case 11:return j=D.type,W=D.pendingProps,W=D.elementType===j?W:Ci(j,W),Ii(P,D,j,W,T);case 7:return ws(P,D,D.pendingProps,T),D.child;case 8:return ws(P,D,D.pendingProps.children,T),D.child;case 12:return ws(P,D,D.pendingProps.children,T),D.child;case 10:e:{if(j=D.type._context,W=D.pendingProps,ve=D.memoizedProps,Ae=W.value,Ho(D,Ae),ve!==null){var vt=ve.value;if(Ae=hs(vt,Ae)?0:(typeof j._calculateChangedBits=="function"?j._calculateChangedBits(vt,Ae):1073741823)|0,Ae===0){if(ve.children===W.children&&!_i.current){D=si(P,D,T);break e}}else for(vt=D.child,vt!==null&&(vt.return=D);vt!==null;){var wt=vt.dependencies;if(wt!==null){ve=vt.child;for(var bt=wt.firstContext;bt!==null;){if(bt.context===j&&(bt.observedBits&Ae)!==0){vt.tag===1&&(bt=ys(T,null),bt.tag=2,tt(vt,bt)),vt.expirationTime<T&&(vt.expirationTime=T),bt=vt.alternate,bt!==null&&bt.expirationTime<T&&(bt.expirationTime=T),gs(vt.return,T),wt.expirationTime<T&&(wt.expirationTime=T);break}bt=bt.next}}else ve=vt.tag===10&&vt.type===D.type?null:vt.child;if(ve!==null)ve.return=vt;else for(ve=vt;ve!==null;){if(ve===D){ve=null;break}if(vt=ve.sibling,vt!==null){vt.return=ve.return,ve=vt;break}ve=ve.return}vt=ve}}ws(P,D,W.children,T),D=D.child}return D;case 9:return W=D.type,Ae=D.pendingProps,j=Ae.children,ds(D,T),W=ms(W,Ae.unstable_observedBits),j=j(W),D.effectTag|=1,ws(P,D,j,T),D.child;case 14:return W=D.type,Ae=Ci(W,D.pendingProps),Ae=Ci(W.type,Ae),km(P,D,W,Ae,j,T);case 15:return Qm(P,D,D.type,D.pendingProps,j,T);case 17:return j=D.type,W=D.pendingProps,W=D.elementType===j?W:Ci(j,W),P!==null&&(P.alternate=null,D.alternate=null,D.effectTag|=2),D.tag=1,ii(j)?(P=!0,Ac(D)):P=!1,ds(D,T),es(D,j,W,T),jo(D,j,W,T),dp(null,D,j,!0,P,T);case 19:return qa(P,D,T)}throw Error(n(156,D.tag))};var xw=null,bw=null;function EF(P){if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u")return!1;var D=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(D.isDisabled||!D.supportsFiber)return!0;try{var T=D.inject(P);xw=function(j){try{D.onCommitFiberRoot(T,j,void 0,(j.current.effectTag&64)===64)}catch{}},bw=function(j){try{D.onCommitFiberUnmount(T,j)}catch{}}}catch{}return!0}function CF(P,D,T,j){this.tag=P,this.key=T,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=D,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=j,this.effectTag=0,this.lastEffect=this.firstEffect=this.nextEffect=null,this.childExpirationTime=this.expirationTime=0,this.alternate=null}function Dl(P,D,T,j){return new CF(P,D,T,j)}function kw(P){return P=P.prototype,!(!P||!P.isReactComponent)}function wF(P){if(typeof P=="function")return kw(P)?1:0;if(P!=null){if(P=P.$$typeof,P===N)return 11;if(P===te)return 14}return 2}function YA(P,D){var T=P.alternate;return T===null?(T=Dl(P.tag,D,P.key,P.mode),T.elementType=P.elementType,T.type=P.type,T.stateNode=P.stateNode,T.alternate=P,P.alternate=T):(T.pendingProps=D,T.effectTag=0,T.nextEffect=null,T.firstEffect=null,T.lastEffect=null),T.childExpirationTime=P.childExpirationTime,T.expirationTime=P.expirationTime,T.child=P.child,T.memoizedProps=P.memoizedProps,T.memoizedState=P.memoizedState,T.updateQueue=P.updateQueue,D=P.dependencies,T.dependencies=D===null?null:{expirationTime:D.expirationTime,firstContext:D.firstContext,responders:D.responders},T.sibling=P.sibling,T.index=P.index,T.ref=P.ref,T}function Hm(P,D,T,j,W,Ae){var ve=2;if(j=P,typeof P=="function")kw(P)&&(ve=1);else if(typeof P=="string")ve=5;else e:switch(P){case C:return xu(T.children,W,Ae,D);case F:ve=8,W|=7;break;case I:ve=8,W|=1;break;case v:return P=Dl(12,T,D,W|8),P.elementType=v,P.type=v,P.expirationTime=Ae,P;case U:return P=Dl(13,T,D,W),P.type=U,P.elementType=U,P.expirationTime=Ae,P;case z:return P=Dl(19,T,D,W),P.elementType=z,P.expirationTime=Ae,P;default:if(typeof P=="object"&&P!==null)switch(P.$$typeof){case b:ve=10;break e;case E:ve=9;break e;case N:ve=11;break e;case te:ve=14;break e;case le:ve=16,j=null;break e}throw Error(n(130,P==null?P:typeof P,""))}return D=Dl(ve,T,D,W),D.elementType=P,D.type=j,D.expirationTime=Ae,D}function xu(P,D,T,j){return P=Dl(7,P,j,D),P.expirationTime=T,P}function Qw(P,D,T){return P=Dl(6,P,null,D),P.expirationTime=T,P}function Fw(P,D,T){return D=Dl(4,P.children!==null?P.children:[],P.key,D),D.expirationTime=T,D.stateNode={containerInfo:P.containerInfo,pendingChildren:null,implementation:P.implementation},D}function IF(P,D,T){this.tag=D,this.current=null,this.containerInfo=P,this.pingCache=this.pendingChildren=null,this.finishedExpirationTime=0,this.finishedWork=null,this.timeoutHandle=je,this.pendingContext=this.context=null,this.hydrate=T,this.callbackNode=null,this.callbackPriority=90,this.lastExpiredTime=this.lastPingedTime=this.nextKnownPendingLevel=this.lastSuspendedTime=this.firstSuspendedTime=this.firstPendingTime=0}function $v(P,D){var T=P.firstSuspendedTime;return P=P.lastSuspendedTime,T!==0&&T>=D&&P<=D}function WA(P,D){var T=P.firstSuspendedTime,j=P.lastSuspendedTime;T<D&&(P.firstSuspendedTime=D),(j>D||T===0)&&(P.lastSuspendedTime=D),D<=P.lastPingedTime&&(P.lastPingedTime=0),D<=P.lastExpiredTime&&(P.lastExpiredTime=0)}function eD(P,D){D>P.firstPendingTime&&(P.firstPendingTime=D);var T=P.firstSuspendedTime;T!==0&&(D>=T?P.firstSuspendedTime=P.lastSuspendedTime=P.nextKnownPendingLevel=0:D>=P.lastSuspendedTime&&(P.lastSuspendedTime=D+1),D>P.nextKnownPendingLevel&&(P.nextKnownPendingLevel=D))}function jm(P,D){var T=P.lastExpiredTime;(T===0||T>D)&&(P.lastExpiredTime=D)}function tD(P){var D=P._reactInternalFiber;if(D===void 0)throw typeof P.render=="function"?Error(n(188)):Error(n(268,Object.keys(P)));return P=Ee(D),P===null?null:P.stateNode}function rD(P,D){P=P.memoizedState,P!==null&&P.dehydrated!==null&&P.retryTime<D&&(P.retryTime=D)}function qm(P,D){rD(P,D),(P=P.alternate)&&rD(P,D)}var nD={createContainer:function(P,D,T){return P=new IF(P,D,T),D=Dl(3,null,null,D===2?7:D===1?3:0),P.current=D,D.stateNode=P},updateContainer:function(P,D,T,j){var W=D.current,Ae=ga(),ve=pt.suspense;Ae=HA(Ae,W,ve);e:if(T){T=T._reactInternalFiber;t:{if(Ie(T)!==T||T.tag!==1)throw Error(n(170));var vt=T;do{switch(vt.tag){case 3:vt=vt.stateNode.context;break t;case 1:if(ii(vt.type)){vt=vt.stateNode.__reactInternalMemoizedMergedChildContext;break t}}vt=vt.return}while(vt!==null);throw Error(n(171))}if(T.tag===1){var wt=T.type;if(ii(wt)){T=uu(T,wt,vt);break e}}T=vt}else T=Li;return D.context===null?D.context=T:D.pendingContext=T,D=ys(Ae,ve),D.payload={element:P},j=j===void 0?null:j,j!==null&&(D.callback=j),tt(W,D),Sc(W,Ae),Ae},batchedEventUpdates:function(P,D){var T=yr;yr|=2;try{return P(D)}finally{yr=T,yr===En&&ji()}},batchedUpdates:function(P,D){var T=yr;yr|=1;try{return P(D)}finally{yr=T,yr===En&&ji()}},unbatchedUpdates:function(P,D){var T=yr;yr&=-2,yr|=Rm;try{return P(D)}finally{yr=T,yr===En&&ji()}},deferredUpdates:function(P){return lo(97,P)},syncUpdates:function(P,D,T,j){return lo(99,P.bind(null,D,T,j))},discreteUpdates:function(P,D,T,j){var W=yr;yr|=4;try{return lo(98,P.bind(null,D,T,j))}finally{yr=W,yr===En&&ji()}},flushDiscreteUpdates:function(){(yr&(1|rs|qs))===En&&(AF(),wp())},flushControlled:function(P){var D=yr;yr|=1;try{lo(99,P)}finally{yr=D,yr===En&&ji()}},flushSync:Vv,flushPassiveEffects:wp,IsThisRendererActing:{current:!1},getPublicRootInstance:function(P){if(P=P.current,!P.child)return null;switch(P.child.tag){case 5:return ce(P.child.stateNode);default:return P.child.stateNode}},attemptSynchronousHydration:function(P){switch(P.tag){case 3:var D=P.stateNode;D.hydrate&&Kv(D,D.firstPendingTime);break;case 13:Vv(function(){return Sc(P,1073741823)}),D=Ua(ga(),150,100),qm(P,D)}},attemptUserBlockingHydration:function(P){if(P.tag===13){var D=Ua(ga(),150,100);Sc(P,D),qm(P,D)}},attemptContinuousHydration:function(P){if(P.tag===13){ga();var D=xA++;Sc(P,D),qm(P,D)}},attemptHydrationAtCurrentPriority:function(P){if(P.tag===13){var D=ga();D=HA(D,P,null),Sc(P,D),qm(P,D)}},findHostInstance:tD,findHostInstanceWithWarning:function(P){return tD(P)},findHostInstanceWithNoPortals:function(P){return P=De(P),P===null?null:P.tag===20?P.stateNode.instance:P.stateNode},shouldSuspend:function(){return!1},injectIntoDevTools:function(P){var D=P.findFiberByHostInstance;return EF(r({},P,{overrideHookState:null,overrideProps:null,setSuspenseHandler:null,scheduleUpdate:null,currentDispatcherRef:u.ReactCurrentDispatcher,findHostInstanceByFiber:function(T){return T=Ee(T),T===null?null:T.stateNode},findFiberByHostInstance:function(T){return D?D(T):null},findHostInstancesForRefresh:null,scheduleRefresh:null,scheduleRoot:null,setRefreshHandler:null,getCurrentFiber:null}))}};aB.exports=nD.default||nD;var BF=aB.exports;return aB.exports=t,BF}});var xEe=_((hKt,SEe)=>{"use strict";SEe.exports=PEe()});var kEe=_((gKt,bEe)=>{"use strict";var Hyt={ALIGN_COUNT:8,ALIGN_AUTO:0,ALIGN_FLEX_START:1,ALIGN_CENTER:2,ALIGN_FLEX_END:3,ALIGN_STRETCH:4,ALIGN_BASELINE:5,ALIGN_SPACE_BETWEEN:6,ALIGN_SPACE_AROUND:7,DIMENSION_COUNT:2,DIMENSION_WIDTH:0,DIMENSION_HEIGHT:1,DIRECTION_COUNT:3,DIRECTION_INHERIT:0,DIRECTION_LTR:1,DIRECTION_RTL:2,DISPLAY_COUNT:2,DISPLAY_FLEX:0,DISPLAY_NONE:1,EDGE_COUNT:9,EDGE_LEFT:0,EDGE_TOP:1,EDGE_RIGHT:2,EDGE_BOTTOM:3,EDGE_START:4,EDGE_END:5,EDGE_HORIZONTAL:6,EDGE_VERTICAL:7,EDGE_ALL:8,EXPERIMENTAL_FEATURE_COUNT:1,EXPERIMENTAL_FEATURE_WEB_FLEX_BASIS:0,FLEX_DIRECTION_COUNT:4,FLEX_DIRECTION_COLUMN:0,FLEX_DIRECTION_COLUMN_REVERSE:1,FLEX_DIRECTION_ROW:2,FLEX_DIRECTION_ROW_REVERSE:3,JUSTIFY_COUNT:6,JUSTIFY_FLEX_START:0,JUSTIFY_CENTER:1,JUSTIFY_FLEX_END:2,JUSTIFY_SPACE_BETWEEN:3,JUSTIFY_SPACE_AROUND:4,JUSTIFY_SPACE_EVENLY:5,LOG_LEVEL_COUNT:6,LOG_LEVEL_ERROR:0,LOG_LEVEL_WARN:1,LOG_LEVEL_INFO:2,LOG_LEVEL_DEBUG:3,LOG_LEVEL_VERBOSE:4,LOG_LEVEL_FATAL:5,MEASURE_MODE_COUNT:3,MEASURE_MODE_UNDEFINED:0,MEASURE_MODE_EXACTLY:1,MEASURE_MODE_AT_MOST:2,NODE_TYPE_COUNT:2,NODE_TYPE_DEFAULT:0,NODE_TYPE_TEXT:1,OVERFLOW_COUNT:3,OVERFLOW_VISIBLE:0,OVERFLOW_HIDDEN:1,OVERFLOW_SCROLL:2,POSITION_TYPE_COUNT:2,POSITION_TYPE_RELATIVE:0,POSITION_TYPE_ABSOLUTE:1,PRINT_OPTIONS_COUNT:3,PRINT_OPTIONS_LAYOUT:1,PRINT_OPTIONS_STYLE:2,PRINT_OPTIONS_CHILDREN:4,UNIT_COUNT:4,UNIT_UNDEFINED:0,UNIT_POINT:1,UNIT_PERCENT:2,UNIT_AUTO:3,WRAP_COUNT:3,WRAP_NO_WRAP:0,WRAP_WRAP:1,WRAP_WRAP_REVERSE:2};bEe.exports=Hyt});var TEe=_((dKt,REe)=>{"use strict";var jyt=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var r=arguments[e];for(var o in r)Object.prototype.hasOwnProperty.call(r,o)&&(t[o]=r[o])}return t},Wk=function(){function t(e,r){for(var o=0;o<r.length;o++){var a=r[o];a.enumerable=a.enumerable||!1,a.configurable=!0,"value"in a&&(a.writable=!0),Object.defineProperty(e,a.key,a)}}return function(e,r,o){return r&&t(e.prototype,r),o&&t(e,o),e}}();function S6(t,e,r){return e in t?Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[e]=r,t}function x6(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}var tu=kEe(),qyt=function(){function t(e,r,o,a,n,u){x6(this,t),this.left=e,this.right=r,this.top=o,this.bottom=a,this.width=n,this.height=u}return Wk(t,[{key:"fromJS",value:function(r){r(this.left,this.right,this.top,this.bottom,this.width,this.height)}},{key:"toString",value:function(){return"<Layout#"+this.left+":"+this.right+";"+this.top+":"+this.bottom+";"+this.width+":"+this.height+">"}}]),t}(),QEe=function(){Wk(t,null,[{key:"fromJS",value:function(r){var o=r.width,a=r.height;return new t(o,a)}}]);function t(e,r){x6(this,t),this.width=e,this.height=r}return Wk(t,[{key:"fromJS",value:function(r){r(this.width,this.height)}},{key:"toString",value:function(){return"<Size#"+this.width+"x"+this.height+">"}}]),t}(),FEe=function(){function t(e,r){x6(this,t),this.unit=e,this.value=r}return Wk(t,[{key:"fromJS",value:function(r){r(this.unit,this.value)}},{key:"toString",value:function(){switch(this.unit){case tu.UNIT_POINT:return String(this.value);case tu.UNIT_PERCENT:return this.value+"%";case tu.UNIT_AUTO:return"auto";default:return this.value+"?"}}},{key:"valueOf",value:function(){return this.value}}]),t}();REe.exports=function(t,e){function r(u,A,p){var h=u[A];u[A]=function(){for(var C=arguments.length,I=Array(C),v=0;v<C;v++)I[v]=arguments[v];return p.call.apply(p,[this,h].concat(I))}}for(var o=["setPosition","setMargin","setFlexBasis","setWidth","setHeight","setMinWidth","setMinHeight","setMaxWidth","setMaxHeight","setPadding"],a=function(){var A,p=o[n],h=(A={},S6(A,tu.UNIT_POINT,e.Node.prototype[p]),S6(A,tu.UNIT_PERCENT,e.Node.prototype[p+"Percent"]),S6(A,tu.UNIT_AUTO,e.Node.prototype[p+"Auto"]),A);r(e.Node.prototype,p,function(C){for(var I=arguments.length,v=Array(I>1?I-1:0),b=1;b<I;b++)v[b-1]=arguments[b];var E=v.pop(),F=void 0,N=void 0;if(E==="auto")F=tu.UNIT_AUTO,N=void 0;else if(E instanceof FEe)F=E.unit,N=E.valueOf();else if(F=typeof E=="string"&&E.endsWith("%")?tu.UNIT_PERCENT:tu.UNIT_POINT,N=parseFloat(E),!Number.isNaN(E)&&Number.isNaN(N))throw new Error("Invalid value "+E+" for "+p);if(!h[F])throw new Error('Failed to execute "'+p+`": Unsupported unit '`+E+"'");if(N!==void 0){var U;return(U=h[F]).call.apply(U,[this].concat(v,[N]))}else{var z;return(z=h[F]).call.apply(z,[this].concat(v))}})},n=0;n<o.length;n++)a();return r(e.Config.prototype,"free",function(){e.Config.destroy(this)}),r(e.Node,"create",function(u,A){return A?e.Node.createWithConfig(A):e.Node.createDefault()}),r(e.Node.prototype,"free",function(){e.Node.destroy(this)}),r(e.Node.prototype,"freeRecursive",function(){for(var u=0,A=this.getChildCount();u<A;++u)this.getChild(0).freeRecursive();this.free()}),r(e.Node.prototype,"setMeasureFunc",function(u,A){return A?u.call(this,function(){return QEe.fromJS(A.apply(void 0,arguments))}):this.unsetMeasureFunc()}),r(e.Node.prototype,"calculateLayout",function(u){var A=arguments.length>1&&arguments[1]!==void 0?arguments[1]:NaN,p=arguments.length>2&&arguments[2]!==void 0?arguments[2]:NaN,h=arguments.length>3&&arguments[3]!==void 0?arguments[3]:tu.DIRECTION_LTR;return u.call(this,A,p,h)}),jyt({Config:e.Config,Node:e.Node,Layout:t("Layout",qyt),Size:t("Size",QEe),Value:t("Value",FEe),getInstanceCount:function(){return e.getInstanceCount.apply(e,arguments)}},tu)}});var LEe=_((exports,module)=>{(function(t,e){typeof define=="function"&&define.amd?define([],function(){return e}):typeof module=="object"&&module.exports?module.exports=e:(t.nbind=t.nbind||{}).init=e})(exports,function(Module,cb){typeof Module=="function"&&(cb=Module,Module={}),Module.onRuntimeInitialized=function(t,e){return function(){t&&t.apply(this,arguments);try{Module.ccall("nbind_init")}catch(r){e(r);return}e(null,{bind:Module._nbind_value,reflect:Module.NBind.reflect,queryType:Module.NBind.queryType,toggleLightGC:Module.toggleLightGC,lib:Module})}}(Module.onRuntimeInitialized,cb);var Module;Module||(Module=(typeof Module<"u"?Module:null)||{});var moduleOverrides={};for(var key in Module)Module.hasOwnProperty(key)&&(moduleOverrides[key]=Module[key]);var ENVIRONMENT_IS_WEB=!1,ENVIRONMENT_IS_WORKER=!1,ENVIRONMENT_IS_NODE=!1,ENVIRONMENT_IS_SHELL=!1;if(Module.ENVIRONMENT)if(Module.ENVIRONMENT==="WEB")ENVIRONMENT_IS_WEB=!0;else if(Module.ENVIRONMENT==="WORKER")ENVIRONMENT_IS_WORKER=!0;else if(Module.ENVIRONMENT==="NODE")ENVIRONMENT_IS_NODE=!0;else if(Module.ENVIRONMENT==="SHELL")ENVIRONMENT_IS_SHELL=!0;else throw new Error("The provided Module['ENVIRONMENT'] value is not valid. It must be one of: WEB|WORKER|NODE|SHELL.");else ENVIRONMENT_IS_WEB=typeof window=="object",ENVIRONMENT_IS_WORKER=typeof importScripts=="function",ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof Be=="function"&&!ENVIRONMENT_IS_WEB&&!ENVIRONMENT_IS_WORKER,ENVIRONMENT_IS_SHELL=!ENVIRONMENT_IS_WEB&&!ENVIRONMENT_IS_NODE&&!ENVIRONMENT_IS_WORKER;if(ENVIRONMENT_IS_NODE){Module.print||(Module.print=console.log),Module.printErr||(Module.printErr=console.warn);var nodeFS,nodePath;Module.read=function(e,r){nodeFS||(nodeFS={}("")),nodePath||(nodePath={}("")),e=nodePath.normalize(e);var o=nodeFS.readFileSync(e);return r?o:o.toString()},Module.readBinary=function(e){var r=Module.read(e,!0);return r.buffer||(r=new Uint8Array(r)),assert(r.buffer),r},Module.load=function(e){globalEval(read(e))},Module.thisProgram||(process.argv.length>1?Module.thisProgram=process.argv[1].replace(/\\/g,"/"):Module.thisProgram="unknown-program"),Module.arguments=process.argv.slice(2),typeof module<"u"&&(module.exports=Module),Module.inspect=function(){return"[Emscripten Module object]"}}else if(ENVIRONMENT_IS_SHELL)Module.print||(Module.print=print),typeof printErr<"u"&&(Module.printErr=printErr),typeof read<"u"?Module.read=read:Module.read=function(){throw"no read() available"},Module.readBinary=function(e){if(typeof readbuffer=="function")return new Uint8Array(readbuffer(e));var r=read(e,"binary");return assert(typeof r=="object"),r},typeof scriptArgs<"u"?Module.arguments=scriptArgs:typeof arguments<"u"&&(Module.arguments=arguments),typeof quit=="function"&&(Module.quit=function(t,e){quit(t)});else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(Module.read=function(e){var r=new XMLHttpRequest;return r.open("GET",e,!1),r.send(null),r.responseText},ENVIRONMENT_IS_WORKER&&(Module.readBinary=function(e){var r=new XMLHttpRequest;return r.open("GET",e,!1),r.responseType="arraybuffer",r.send(null),new Uint8Array(r.response)}),Module.readAsync=function(e,r,o){var a=new XMLHttpRequest;a.open("GET",e,!0),a.responseType="arraybuffer",a.onload=function(){a.status==200||a.status==0&&a.response?r(a.response):o()},a.onerror=o,a.send(null)},typeof arguments<"u"&&(Module.arguments=arguments),typeof console<"u")Module.print||(Module.print=function(e){console.log(e)}),Module.printErr||(Module.printErr=function(e){console.warn(e)});else{var TRY_USE_DUMP=!1;Module.print||(Module.print=TRY_USE_DUMP&&typeof dump<"u"?function(t){dump(t)}:function(t){})}ENVIRONMENT_IS_WORKER&&(Module.load=importScripts),typeof Module.setWindowTitle>"u"&&(Module.setWindowTitle=function(t){document.title=t})}else throw"Unknown runtime environment. Where are we?";function globalEval(t){eval.call(null,t)}!Module.load&&Module.read&&(Module.load=function(e){globalEval(Module.read(e))}),Module.print||(Module.print=function(){}),Module.printErr||(Module.printErr=Module.print),Module.arguments||(Module.arguments=[]),Module.thisProgram||(Module.thisProgram="./this.program"),Module.quit||(Module.quit=function(t,e){throw e}),Module.print=Module.print,Module.printErr=Module.printErr,Module.preRun=[],Module.postRun=[];for(var key in moduleOverrides)moduleOverrides.hasOwnProperty(key)&&(Module[key]=moduleOverrides[key]);moduleOverrides=void 0;var Runtime={setTempRet0:function(t){return tempRet0=t,t},getTempRet0:function(){return tempRet0},stackSave:function(){return STACKTOP},stackRestore:function(t){STACKTOP=t},getNativeTypeSize:function(t){switch(t){case"i1":case"i8":return 1;case"i16":return 2;case"i32":return 4;case"i64":return 8;case"float":return 4;case"double":return 8;default:{if(t[t.length-1]==="*")return Runtime.QUANTUM_SIZE;if(t[0]==="i"){var e=parseInt(t.substr(1));return assert(e%8===0),e/8}else return 0}}},getNativeFieldSize:function(t){return Math.max(Runtime.getNativeTypeSize(t),Runtime.QUANTUM_SIZE)},STACK_ALIGN:16,prepVararg:function(t,e){return e==="double"||e==="i64"?t&7&&(assert((t&7)===4),t+=4):assert((t&3)===0),t},getAlignSize:function(t,e,r){return!r&&(t=="i64"||t=="double")?8:t?Math.min(e||(t?Runtime.getNativeFieldSize(t):0),Runtime.QUANTUM_SIZE):Math.min(e,8)},dynCall:function(t,e,r){return r&&r.length?Module["dynCall_"+t].apply(null,[e].concat(r)):Module["dynCall_"+t].call(null,e)},functionPointers:[],addFunction:function(t){for(var e=0;e<Runtime.functionPointers.length;e++)if(!Runtime.functionPointers[e])return Runtime.functionPointers[e]=t,2*(1+e);throw"Finished up all reserved function pointers. Use a higher value for RESERVED_FUNCTION_POINTERS."},removeFunction:function(t){Runtime.functionPointers[(t-2)/2]=null},warnOnce:function(t){Runtime.warnOnce.shown||(Runtime.warnOnce.shown={}),Runtime.warnOnce.shown[t]||(Runtime.warnOnce.shown[t]=1,Module.printErr(t))},funcWrappers:{},getFuncWrapper:function(t,e){if(!!t){assert(e),Runtime.funcWrappers[e]||(Runtime.funcWrappers[e]={});var r=Runtime.funcWrappers[e];return r[t]||(e.length===1?r[t]=function(){return Runtime.dynCall(e,t)}:e.length===2?r[t]=function(a){return Runtime.dynCall(e,t,[a])}:r[t]=function(){return Runtime.dynCall(e,t,Array.prototype.slice.call(arguments))}),r[t]}},getCompilerSetting:function(t){throw"You must build with -s RETAIN_COMPILER_SETTINGS=1 for Runtime.getCompilerSetting or emscripten_get_compiler_setting to work"},stackAlloc:function(t){var e=STACKTOP;return STACKTOP=STACKTOP+t|0,STACKTOP=STACKTOP+15&-16,e},staticAlloc:function(t){var e=STATICTOP;return STATICTOP=STATICTOP+t|0,STATICTOP=STATICTOP+15&-16,e},dynamicAlloc:function(t){var e=HEAP32[DYNAMICTOP_PTR>>2],r=(e+t+15|0)&-16;if(HEAP32[DYNAMICTOP_PTR>>2]=r,r>=TOTAL_MEMORY){var o=enlargeMemory();if(!o)return HEAP32[DYNAMICTOP_PTR>>2]=e,0}return e},alignMemory:function(t,e){var r=t=Math.ceil(t/(e||16))*(e||16);return r},makeBigInt:function(t,e,r){var o=r?+(t>>>0)+ +(e>>>0)*4294967296:+(t>>>0)+ +(e|0)*4294967296;return o},GLOBAL_BASE:8,QUANTUM_SIZE:4,__dummy__:0};Module.Runtime=Runtime;var ABORT=0,EXITSTATUS=0;function assert(t,e){t||abort("Assertion failed: "+e)}function getCFunc(ident){var func=Module["_"+ident];if(!func)try{func=eval("_"+ident)}catch(t){}return assert(func,"Cannot call unknown function "+ident+" (perhaps LLVM optimizations or closure removed it?)"),func}var cwrap,ccall;(function(){var JSfuncs={stackSave:function(){Runtime.stackSave()},stackRestore:function(){Runtime.stackRestore()},arrayToC:function(t){var e=Runtime.stackAlloc(t.length);return writeArrayToMemory(t,e),e},stringToC:function(t){var e=0;if(t!=null&&t!==0){var r=(t.length<<2)+1;e=Runtime.stackAlloc(r),stringToUTF8(t,e,r)}return e}},toC={string:JSfuncs.stringToC,array:JSfuncs.arrayToC};ccall=function(e,r,o,a,n){var u=getCFunc(e),A=[],p=0;if(a)for(var h=0;h<a.length;h++){var C=toC[o[h]];C?(p===0&&(p=Runtime.stackSave()),A[h]=C(a[h])):A[h]=a[h]}var I=u.apply(null,A);if(r==="string"&&(I=Pointer_stringify(I)),p!==0){if(n&&n.async){EmterpreterAsync.asyncFinalizers.push(function(){Runtime.stackRestore(p)});return}Runtime.stackRestore(p)}return I};var sourceRegex=/^function\s*[a-zA-Z$_0-9]*\s*\(([^)]*)\)\s*{\s*([^*]*?)[\s;]*(?:return\s*(.*?)[;\s]*)?}$/;function parseJSFunc(t){var e=t.toString().match(sourceRegex).slice(1);return{arguments:e[0],body:e[1],returnValue:e[2]}}var JSsource=null;function ensureJSsource(){if(!JSsource){JSsource={};for(var t in JSfuncs)JSfuncs.hasOwnProperty(t)&&(JSsource[t]=parseJSFunc(JSfuncs[t]))}}cwrap=function cwrap(ident,returnType,argTypes){argTypes=argTypes||[];var cfunc=getCFunc(ident),numericArgs=argTypes.every(function(t){return t==="number"}),numericRet=returnType!=="string";if(numericRet&&numericArgs)return cfunc;var argNames=argTypes.map(function(t,e){return"$"+e}),funcstr="(function("+argNames.join(",")+") {",nargs=argTypes.length;if(!numericArgs){ensureJSsource(),funcstr+="var stack = "+JSsource.stackSave.body+";";for(var i=0;i<nargs;i++){var arg=argNames[i],type=argTypes[i];if(type!=="number"){var convertCode=JSsource[type+"ToC"];funcstr+="var "+convertCode.arguments+" = "+arg+";",funcstr+=convertCode.body+";",funcstr+=arg+"=("+convertCode.returnValue+");"}}}var cfuncname=parseJSFunc(function(){return cfunc}).returnValue;if(funcstr+="var ret = "+cfuncname+"("+argNames.join(",")+");",!numericRet){var strgfy=parseJSFunc(function(){return Pointer_stringify}).returnValue;funcstr+="ret = "+strgfy+"(ret);"}return numericArgs||(ensureJSsource(),funcstr+=JSsource.stackRestore.body.replace("()","(stack)")+";"),funcstr+="return ret})",eval(funcstr)}})(),Module.ccall=ccall,Module.cwrap=cwrap;function setValue(t,e,r,o){switch(r=r||"i8",r.charAt(r.length-1)==="*"&&(r="i32"),r){case"i1":HEAP8[t>>0]=e;break;case"i8":HEAP8[t>>0]=e;break;case"i16":HEAP16[t>>1]=e;break;case"i32":HEAP32[t>>2]=e;break;case"i64":tempI64=[e>>>0,(tempDouble=e,+Math_abs(tempDouble)>=1?tempDouble>0?(Math_min(+Math_floor(tempDouble/4294967296),4294967295)|0)>>>0:~~+Math_ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[t>>2]=tempI64[0],HEAP32[t+4>>2]=tempI64[1];break;case"float":HEAPF32[t>>2]=e;break;case"double":HEAPF64[t>>3]=e;break;default:abort("invalid type for setValue: "+r)}}Module.setValue=setValue;function getValue(t,e,r){switch(e=e||"i8",e.charAt(e.length-1)==="*"&&(e="i32"),e){case"i1":return HEAP8[t>>0];case"i8":return HEAP8[t>>0];case"i16":return HEAP16[t>>1];case"i32":return HEAP32[t>>2];case"i64":return HEAP32[t>>2];case"float":return HEAPF32[t>>2];case"double":return HEAPF64[t>>3];default:abort("invalid type for setValue: "+e)}return null}Module.getValue=getValue;var ALLOC_NORMAL=0,ALLOC_STACK=1,ALLOC_STATIC=2,ALLOC_DYNAMIC=3,ALLOC_NONE=4;Module.ALLOC_NORMAL=ALLOC_NORMAL,Module.ALLOC_STACK=ALLOC_STACK,Module.ALLOC_STATIC=ALLOC_STATIC,Module.ALLOC_DYNAMIC=ALLOC_DYNAMIC,Module.ALLOC_NONE=ALLOC_NONE;function allocate(t,e,r,o){var a,n;typeof t=="number"?(a=!0,n=t):(a=!1,n=t.length);var u=typeof e=="string"?e:null,A;if(r==ALLOC_NONE?A=o:A=[typeof _malloc=="function"?_malloc:Runtime.staticAlloc,Runtime.stackAlloc,Runtime.staticAlloc,Runtime.dynamicAlloc][r===void 0?ALLOC_STATIC:r](Math.max(n,u?1:e.length)),a){var o=A,p;for(assert((A&3)==0),p=A+(n&-4);o<p;o+=4)HEAP32[o>>2]=0;for(p=A+n;o<p;)HEAP8[o++>>0]=0;return A}if(u==="i8")return t.subarray||t.slice?HEAPU8.set(t,A):HEAPU8.set(new Uint8Array(t),A),A;for(var h=0,C,I,v;h<n;){var b=t[h];if(typeof b=="function"&&(b=Runtime.getFunctionIndex(b)),C=u||e[h],C===0){h++;continue}C=="i64"&&(C="i32"),setValue(A+h,b,C),v!==C&&(I=Runtime.getNativeTypeSize(C),v=C),h+=I}return A}Module.allocate=allocate;function getMemory(t){return staticSealed?runtimeInitialized?_malloc(t):Runtime.dynamicAlloc(t):Runtime.staticAlloc(t)}Module.getMemory=getMemory;function Pointer_stringify(t,e){if(e===0||!t)return"";for(var r=0,o,a=0;o=HEAPU8[t+a>>0],r|=o,!(o==0&&!e||(a++,e&&a==e)););e||(e=a);var n="";if(r<128){for(var u=1024,A;e>0;)A=String.fromCharCode.apply(String,HEAPU8.subarray(t,t+Math.min(e,u))),n=n?n+A:A,t+=u,e-=u;return n}return Module.UTF8ToString(t)}Module.Pointer_stringify=Pointer_stringify;function AsciiToString(t){for(var e="";;){var r=HEAP8[t++>>0];if(!r)return e;e+=String.fromCharCode(r)}}Module.AsciiToString=AsciiToString;function stringToAscii(t,e){return writeAsciiToMemory(t,e,!1)}Module.stringToAscii=stringToAscii;var UTF8Decoder=typeof TextDecoder<"u"?new TextDecoder("utf8"):void 0;function UTF8ArrayToString(t,e){for(var r=e;t[r];)++r;if(r-e>16&&t.subarray&&UTF8Decoder)return UTF8Decoder.decode(t.subarray(e,r));for(var o,a,n,u,A,p,h="";;){if(o=t[e++],!o)return h;if(!(o&128)){h+=String.fromCharCode(o);continue}if(a=t[e++]&63,(o&224)==192){h+=String.fromCharCode((o&31)<<6|a);continue}if(n=t[e++]&63,(o&240)==224?o=(o&15)<<12|a<<6|n:(u=t[e++]&63,(o&248)==240?o=(o&7)<<18|a<<12|n<<6|u:(A=t[e++]&63,(o&252)==248?o=(o&3)<<24|a<<18|n<<12|u<<6|A:(p=t[e++]&63,o=(o&1)<<30|a<<24|n<<18|u<<12|A<<6|p))),o<65536)h+=String.fromCharCode(o);else{var C=o-65536;h+=String.fromCharCode(55296|C>>10,56320|C&1023)}}}Module.UTF8ArrayToString=UTF8ArrayToString;function UTF8ToString(t){return UTF8ArrayToString(HEAPU8,t)}Module.UTF8ToString=UTF8ToString;function stringToUTF8Array(t,e,r,o){if(!(o>0))return 0;for(var a=r,n=r+o-1,u=0;u<t.length;++u){var A=t.charCodeAt(u);if(A>=55296&&A<=57343&&(A=65536+((A&1023)<<10)|t.charCodeAt(++u)&1023),A<=127){if(r>=n)break;e[r++]=A}else if(A<=2047){if(r+1>=n)break;e[r++]=192|A>>6,e[r++]=128|A&63}else if(A<=65535){if(r+2>=n)break;e[r++]=224|A>>12,e[r++]=128|A>>6&63,e[r++]=128|A&63}else if(A<=2097151){if(r+3>=n)break;e[r++]=240|A>>18,e[r++]=128|A>>12&63,e[r++]=128|A>>6&63,e[r++]=128|A&63}else if(A<=67108863){if(r+4>=n)break;e[r++]=248|A>>24,e[r++]=128|A>>18&63,e[r++]=128|A>>12&63,e[r++]=128|A>>6&63,e[r++]=128|A&63}else{if(r+5>=n)break;e[r++]=252|A>>30,e[r++]=128|A>>24&63,e[r++]=128|A>>18&63,e[r++]=128|A>>12&63,e[r++]=128|A>>6&63,e[r++]=128|A&63}}return e[r]=0,r-a}Module.stringToUTF8Array=stringToUTF8Array;function stringToUTF8(t,e,r){return stringToUTF8Array(t,HEAPU8,e,r)}Module.stringToUTF8=stringToUTF8;function lengthBytesUTF8(t){for(var e=0,r=0;r<t.length;++r){var o=t.charCodeAt(r);o>=55296&&o<=57343&&(o=65536+((o&1023)<<10)|t.charCodeAt(++r)&1023),o<=127?++e:o<=2047?e+=2:o<=65535?e+=3:o<=2097151?e+=4:o<=67108863?e+=5:e+=6}return e}Module.lengthBytesUTF8=lengthBytesUTF8;var UTF16Decoder=typeof TextDecoder<"u"?new TextDecoder("utf-16le"):void 0;function demangle(t){var e=Module.___cxa_demangle||Module.__cxa_demangle;if(e){try{var r=t.substr(1),o=lengthBytesUTF8(r)+1,a=_malloc(o);stringToUTF8(r,a,o);var n=_malloc(4),u=e(a,0,0,n);if(getValue(n,"i32")===0&&u)return Pointer_stringify(u)}catch{}finally{a&&_free(a),n&&_free(n),u&&_free(u)}return t}return Runtime.warnOnce("warning: build with -s DEMANGLE_SUPPORT=1 to link in libcxxabi demangling"),t}function demangleAll(t){var e=/__Z[\w\d_]+/g;return t.replace(e,function(r){var o=demangle(r);return r===o?r:r+" ["+o+"]"})}function jsStackTrace(){var t=new Error;if(!t.stack){try{throw new Error(0)}catch(e){t=e}if(!t.stack)return"(no stack trace available)"}return t.stack.toString()}function stackTrace(){var t=jsStackTrace();return Module.extraStackTrace&&(t+=` -`+Module.extraStackTrace()),demangleAll(t)}Module.stackTrace=stackTrace;var HEAP,buffer,HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateGlobalBufferViews(){Module.HEAP8=HEAP8=new Int8Array(buffer),Module.HEAP16=HEAP16=new Int16Array(buffer),Module.HEAP32=HEAP32=new Int32Array(buffer),Module.HEAPU8=HEAPU8=new Uint8Array(buffer),Module.HEAPU16=HEAPU16=new Uint16Array(buffer),Module.HEAPU32=HEAPU32=new Uint32Array(buffer),Module.HEAPF32=HEAPF32=new Float32Array(buffer),Module.HEAPF64=HEAPF64=new Float64Array(buffer)}var STATIC_BASE,STATICTOP,staticSealed,STACK_BASE,STACKTOP,STACK_MAX,DYNAMIC_BASE,DYNAMICTOP_PTR;STATIC_BASE=STATICTOP=STACK_BASE=STACKTOP=STACK_MAX=DYNAMIC_BASE=DYNAMICTOP_PTR=0,staticSealed=!1;function abortOnCannotGrowMemory(){abort("Cannot enlarge memory arrays. Either (1) compile with -s TOTAL_MEMORY=X with X higher than the current value "+TOTAL_MEMORY+", (2) compile with -s ALLOW_MEMORY_GROWTH=1 which allows increasing the size at runtime but prevents some optimizations, (3) set Module.TOTAL_MEMORY to a higher value before the program runs, or (4) if you want malloc to return NULL (0) instead of this abort, compile with -s ABORTING_MALLOC=0 ")}function enlargeMemory(){abortOnCannotGrowMemory()}var TOTAL_STACK=Module.TOTAL_STACK||5242880,TOTAL_MEMORY=Module.TOTAL_MEMORY||134217728;TOTAL_MEMORY<TOTAL_STACK&&Module.printErr("TOTAL_MEMORY should be larger than TOTAL_STACK, was "+TOTAL_MEMORY+"! (TOTAL_STACK="+TOTAL_STACK+")"),Module.buffer?buffer=Module.buffer:buffer=new ArrayBuffer(TOTAL_MEMORY),updateGlobalBufferViews();function getTotalMemory(){return TOTAL_MEMORY}if(HEAP32[0]=1668509029,HEAP16[1]=25459,HEAPU8[2]!==115||HEAPU8[3]!==99)throw"Runtime error: expected the system to be little-endian!";Module.HEAP=HEAP,Module.buffer=buffer,Module.HEAP8=HEAP8,Module.HEAP16=HEAP16,Module.HEAP32=HEAP32,Module.HEAPU8=HEAPU8,Module.HEAPU16=HEAPU16,Module.HEAPU32=HEAPU32,Module.HEAPF32=HEAPF32,Module.HEAPF64=HEAPF64;function callRuntimeCallbacks(t){for(;t.length>0;){var e=t.shift();if(typeof e=="function"){e();continue}var r=e.func;typeof r=="number"?e.arg===void 0?Module.dynCall_v(r):Module.dynCall_vi(r,e.arg):r(e.arg===void 0?null:e.arg)}}var __ATPRERUN__=[],__ATINIT__=[],__ATMAIN__=[],__ATEXIT__=[],__ATPOSTRUN__=[],runtimeInitialized=!1,runtimeExited=!1;function preRun(){if(Module.preRun)for(typeof Module.preRun=="function"&&(Module.preRun=[Module.preRun]);Module.preRun.length;)addOnPreRun(Module.preRun.shift());callRuntimeCallbacks(__ATPRERUN__)}function ensureInitRuntime(){runtimeInitialized||(runtimeInitialized=!0,callRuntimeCallbacks(__ATINIT__))}function preMain(){callRuntimeCallbacks(__ATMAIN__)}function exitRuntime(){callRuntimeCallbacks(__ATEXIT__),runtimeExited=!0}function postRun(){if(Module.postRun)for(typeof Module.postRun=="function"&&(Module.postRun=[Module.postRun]);Module.postRun.length;)addOnPostRun(Module.postRun.shift());callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(t){__ATPRERUN__.unshift(t)}Module.addOnPreRun=addOnPreRun;function addOnInit(t){__ATINIT__.unshift(t)}Module.addOnInit=addOnInit;function addOnPreMain(t){__ATMAIN__.unshift(t)}Module.addOnPreMain=addOnPreMain;function addOnExit(t){__ATEXIT__.unshift(t)}Module.addOnExit=addOnExit;function addOnPostRun(t){__ATPOSTRUN__.unshift(t)}Module.addOnPostRun=addOnPostRun;function intArrayFromString(t,e,r){var o=r>0?r:lengthBytesUTF8(t)+1,a=new Array(o),n=stringToUTF8Array(t,a,0,a.length);return e&&(a.length=n),a}Module.intArrayFromString=intArrayFromString;function intArrayToString(t){for(var e=[],r=0;r<t.length;r++){var o=t[r];o>255&&(o&=255),e.push(String.fromCharCode(o))}return e.join("")}Module.intArrayToString=intArrayToString;function writeStringToMemory(t,e,r){Runtime.warnOnce("writeStringToMemory is deprecated and should not be called! Use stringToUTF8() instead!");var o,a;r&&(a=e+lengthBytesUTF8(t),o=HEAP8[a]),stringToUTF8(t,e,1/0),r&&(HEAP8[a]=o)}Module.writeStringToMemory=writeStringToMemory;function writeArrayToMemory(t,e){HEAP8.set(t,e)}Module.writeArrayToMemory=writeArrayToMemory;function writeAsciiToMemory(t,e,r){for(var o=0;o<t.length;++o)HEAP8[e++>>0]=t.charCodeAt(o);r||(HEAP8[e>>0]=0)}if(Module.writeAsciiToMemory=writeAsciiToMemory,(!Math.imul||Math.imul(4294967295,5)!==-5)&&(Math.imul=function t(e,r){var o=e>>>16,a=e&65535,n=r>>>16,u=r&65535;return a*u+(o*u+a*n<<16)|0}),Math.imul=Math.imul,!Math.fround){var froundBuffer=new Float32Array(1);Math.fround=function(t){return froundBuffer[0]=t,froundBuffer[0]}}Math.fround=Math.fround,Math.clz32||(Math.clz32=function(t){t=t>>>0;for(var e=0;e<32;e++)if(t&1<<31-e)return e;return 32}),Math.clz32=Math.clz32,Math.trunc||(Math.trunc=function(t){return t<0?Math.ceil(t):Math.floor(t)}),Math.trunc=Math.trunc;var Math_abs=Math.abs,Math_cos=Math.cos,Math_sin=Math.sin,Math_tan=Math.tan,Math_acos=Math.acos,Math_asin=Math.asin,Math_atan=Math.atan,Math_atan2=Math.atan2,Math_exp=Math.exp,Math_log=Math.log,Math_sqrt=Math.sqrt,Math_ceil=Math.ceil,Math_floor=Math.floor,Math_pow=Math.pow,Math_imul=Math.imul,Math_fround=Math.fround,Math_round=Math.round,Math_min=Math.min,Math_clz32=Math.clz32,Math_trunc=Math.trunc,runDependencies=0,runDependencyWatcher=null,dependenciesFulfilled=null;function getUniqueRunDependency(t){return t}function addRunDependency(t){runDependencies++,Module.monitorRunDependencies&&Module.monitorRunDependencies(runDependencies)}Module.addRunDependency=addRunDependency;function removeRunDependency(t){if(runDependencies--,Module.monitorRunDependencies&&Module.monitorRunDependencies(runDependencies),runDependencies==0&&(runDependencyWatcher!==null&&(clearInterval(runDependencyWatcher),runDependencyWatcher=null),dependenciesFulfilled)){var e=dependenciesFulfilled;dependenciesFulfilled=null,e()}}Module.removeRunDependency=removeRunDependency,Module.preloadedImages={},Module.preloadedAudios={};var ASM_CONSTS=[function(t,e,r,o,a,n,u,A){return _nbind.callbackSignatureList[t].apply(this,arguments)}];function _emscripten_asm_const_iiiiiiii(t,e,r,o,a,n,u,A){return ASM_CONSTS[t](e,r,o,a,n,u,A)}function _emscripten_asm_const_iiiii(t,e,r,o,a){return ASM_CONSTS[t](e,r,o,a)}function _emscripten_asm_const_iiidddddd(t,e,r,o,a,n,u,A,p){return ASM_CONSTS[t](e,r,o,a,n,u,A,p)}function _emscripten_asm_const_iiididi(t,e,r,o,a,n,u){return ASM_CONSTS[t](e,r,o,a,n,u)}function _emscripten_asm_const_iiii(t,e,r,o){return ASM_CONSTS[t](e,r,o)}function _emscripten_asm_const_iiiid(t,e,r,o,a){return ASM_CONSTS[t](e,r,o,a)}function _emscripten_asm_const_iiiiii(t,e,r,o,a,n){return ASM_CONSTS[t](e,r,o,a,n)}STATIC_BASE=Runtime.GLOBAL_BASE,STATICTOP=STATIC_BASE+12800,__ATINIT__.push({func:function(){__GLOBAL__sub_I_Yoga_cpp()}},{func:function(){__GLOBAL__sub_I_nbind_cc()}},{func:function(){__GLOBAL__sub_I_common_cc()}},{func:function(){__GLOBAL__sub_I_Binding_cc()}}),allocate([0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192,127,0,0,192,127,0,0,192,127,0,0,192,127,3,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,3,0,0,0,0,0,192,127,3,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192,127,0,0,192,127,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192,127,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192,127,0,0,192,127,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,0,0,128,191,0,0,128,191,0,0,192,127,0,0,0,0,0,0,0,0,0,0,128,63,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,3,0,0,0,1,0,0,0,2,0,0,0,0,0,0,0,190,12,0,0,200,12,0,0,208,12,0,0,216,12,0,0,230,12,0,0,242,12,0,0,1,0,0,0,3,0,0,0,0,0,0,0,2,0,0,0,0,0,192,127,3,0,0,0,180,45,0,0,181,45,0,0,182,45,0,0,181,45,0,0,182,45,0,0,0,0,0,0,0,0,0,0,1,0,0,0,2,0,0,0,3,0,0,0,1,0,0,0,4,0,0,0,183,45,0,0,181,45,0,0,181,45,0,0,181,45,0,0,181,45,0,0,181,45,0,0,181,45,0,0,184,45,0,0,185,45,0,0,181,45,0,0,181,45,0,0,182,45,0,0,186,45,0,0,185,45,0,0,148,4,0,0,3,0,0,0,187,45,0,0,164,4,0,0,188,45,0,0,2,0,0,0,189,45,0,0,164,4,0,0,188,45,0,0,185,45,0,0,164,4,0,0,185,45,0,0,164,4,0,0,188,45,0,0,181,45,0,0,182,45,0,0,181,45,0,0,0,0,0,0,0,0,0,0,1,0,0,0,5,0,0,0,6,0,0,0,1,0,0,0,7,0,0,0,183,45,0,0,182,45,0,0,181,45,0,0,190,45,0,0,190,45,0,0,182,45,0,0,182,45,0,0,185,45,0,0,181,45,0,0,185,45,0,0,182,45,0,0,181,45,0,0,185,45,0,0,182,45,0,0,185,45,0,0,48,5,0,0,3,0,0,0,56,5,0,0,1,0,0,0,189,45,0,0,185,45,0,0,164,4,0,0,76,5,0,0,2,0,0,0,191,45,0,0,186,45,0,0,182,45,0,0,185,45,0,0,192,45,0,0,185,45,0,0,182,45,0,0,186,45,0,0,185,45,0,0,76,5,0,0,76,5,0,0,136,5,0,0,182,45,0,0,181,45,0,0,2,0,0,0,190,45,0,0,136,5,0,0,56,19,0,0,156,5,0,0,2,0,0,0,184,45,0,0,0,0,0,0,0,0,0,0,1,0,0,0,8,0,0,0,9,0,0,0,1,0,0,0,10,0,0,0,204,5,0,0,181,45,0,0,181,45,0,0,2,0,0,0,180,45,0,0,204,5,0,0,2,0,0,0,195,45,0,0,236,5,0,0,97,19,0,0,198,45,0,0,211,45,0,0,212,45,0,0,213,45,0,0,214,45,0,0,215,45,0,0,188,45,0,0,182,45,0,0,216,45,0,0,217,45,0,0,218,45,0,0,219,45,0,0,192,45,0,0,181,45,0,0,0,0,0,0,185,45,0,0,110,19,0,0,186,45,0,0,115,19,0,0,221,45,0,0,120,19,0,0,148,4,0,0,132,19,0,0,96,6,0,0,145,19,0,0,222,45,0,0,164,19,0,0,223,45,0,0,173,19,0,0,0,0,0,0,3,0,0,0,104,6,0,0,1,0,0,0,187,45,0,0,0,0,0,0,0,0,0,0,1,0,0,0,11,0,0,0,12,0,0,0,1,0,0,0,13,0,0,0,185,45,0,0,224,45,0,0,164,6,0,0,188,45,0,0,172,6,0,0,180,6,0,0,2,0,0,0,188,6,0,0,7,0,0,0,224,45,0,0,7,0,0,0,164,6,0,0,1,0,0,0,213,45,0,0,185,45,0,0,224,45,0,0,172,6,0,0,185,45,0,0,224,45,0,0,164,6,0,0,185,45,0,0,224,45,0,0,211,45,0,0,211,45,0,0,222,45,0,0,211,45,0,0,224,45,0,0,222,45,0,0,211,45,0,0,224,45,0,0,172,6,0,0,222,45,0,0,211,45,0,0,224,45,0,0,188,45,0,0,222,45,0,0,211,45,0,0,40,7,0,0,188,45,0,0,2,0,0,0,224,45,0,0,185,45,0,0,188,45,0,0,188,45,0,0,188,45,0,0,188,45,0,0,222,45,0,0,224,45,0,0,148,4,0,0,185,45,0,0,148,4,0,0,148,4,0,0,148,4,0,0,148,4,0,0,148,4,0,0,185,45,0,0,164,6,0,0,148,4,0,0,0,0,0,0,0,0,0,0,1,0,0,0,14,0,0,0,15,0,0,0,1,0,0,0,16,0,0,0,148,7,0,0,2,0,0,0,225,45,0,0,183,45,0,0,188,45,0,0,168,7,0,0,5,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,2,0,0,0,234,45,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,148,45,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,28,9,0,0,5,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,2,0,0,0,242,45,0,0,0,4,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,67,111,117,108,100,32,110,111,116,32,97,108,108,111,99,97,116,101,32,109,101,109,111,114,121,32,102,111,114,32,110,111,100,101,0,67,97,110,110,111,116,32,114,101,115,101,116,32,97,32,110,111,100,101,32,119,104,105,99,104,32,115,116,105,108,108,32,104,97,115,32,99,104,105,108,100,114,101,110,32,97,116,116,97,99,104,101,100,0,67,97,110,110,111,116,32,114,101,115,101,116,32,97,32,110,111,100,101,32,115,116,105,108,108,32,97,116,116,97,99,104,101,100,32,116,111,32,97,32,112,97,114,101,110,116,0,67,111,117,108,100,32,110,111,116,32,97,108,108,111,99,97,116,101,32,109,101,109,111,114,121,32,102,111,114,32,99,111,110,102,105,103,0,67,97,110,110,111,116,32,115,101,116,32,109,101,97,115,117,114,101,32,102,117,110,99,116,105,111,110,58,32,78,111,100,101,115,32,119,105,116,104,32,109,101,97,115,117,114,101,32,102,117,110,99,116,105,111,110,115,32,99,97,110,110,111,116,32,104,97,118,101,32,99,104,105,108,100,114,101,110,46,0,67,104,105,108,100,32,97,108,114,101,97,100,121,32,104,97,115,32,97,32,112,97,114,101,110,116,44,32,105,116,32,109,117,115,116,32,98,101,32,114,101,109,111,118,101,100,32,102,105,114,115,116,46,0,67,97,110,110,111,116,32,97,100,100,32,99,104,105,108,100,58,32,78,111,100,101,115,32,119,105,116,104,32,109,101,97,115,117,114,101,32,102,117,110,99,116,105,111,110,115,32,99,97,110,110,111,116,32,104,97,118,101,32,99,104,105,108,100,114,101,110,46,0,79,110,108,121,32,108,101,97,102,32,110,111,100,101,115,32,119,105,116,104,32,99,117,115,116,111,109,32,109,101,97,115,117,114,101,32,102,117,110,99,116,105,111,110,115,115,104,111,117,108,100,32,109,97,110,117,97,108,108,121,32,109,97,114,107,32,116,104,101,109,115,101,108,118,101,115,32,97,115,32,100,105,114,116,121,0,67,97,110,110,111,116,32,103,101,116,32,108,97,121,111,117,116,32,112,114,111,112,101,114,116,105,101,115,32,111,102,32,109,117,108,116,105,45,101,100,103,101,32,115,104,111,114,116,104,97,110,100,115,0,37,115,37,100,46,123,91,115,107,105,112,112,101,100,93,32,0,119,109,58,32,37,115,44,32,104,109,58,32,37,115,44,32,97,119,58,32,37,102,32,97,104,58,32,37,102,32,61,62,32,100,58,32,40,37,102,44,32,37,102,41,32,37,115,10,0,37,115,37,100,46,123,37,115,0,42,0,119,109,58,32,37,115,44,32,104,109,58,32,37,115,44,32,97,119,58,32,37,102,32,97,104,58,32,37,102,32,37,115,10,0,37,115,37,100,46,125,37,115,0,119,109,58,32,37,115,44,32,104,109,58,32,37,115,44,32,100,58,32,40,37,102,44,32,37,102,41,32,37,115,10,0,79,117,116,32,111,102,32,99,97,99,104,101,32,101,110,116,114,105,101,115,33,10,0,83,99,97,108,101,32,102,97,99,116,111,114,32,115,104,111,117,108,100,32,110,111,116,32,98,101,32,108,101,115,115,32,116,104,97,110,32,122,101,114,111,0,105,110,105,116,105,97,108,0,37,115,10,0,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,0,85,78,68,69,70,73,78,69,68,0,69,88,65,67,84,76,89,0,65,84,95,77,79,83,84,0,76,65,89,95,85,78,68,69,70,73,78,69,68,0,76,65,89,95,69,88,65,67,84,76,89,0,76,65,89,95,65,84,95,77,79,83,84,0,97,118,97,105,108,97,98,108,101,87,105,100,116,104,32,105,115,32,105,110,100,101,102,105,110,105,116,101,32,115,111,32,119,105,100,116,104,77,101,97,115,117,114,101,77,111,100,101,32,109,117,115,116,32,98,101,32,89,71,77,101,97,115,117,114,101,77,111,100,101,85,110,100,101,102,105,110,101,100,0,97,118,97,105,108,97,98,108,101,72,101,105,103,104,116,32,105,115,32,105,110,100,101,102,105,110,105,116,101,32,115,111,32,104,101,105,103,104,116,77,101,97,115,117,114,101,77,111,100,101,32,109,117,115,116,32,98,101,32,89,71,77,101,97,115,117,114,101,77,111,100,101,85,110,100,101,102,105,110,101,100,0,102,108,101,120,0,115,116,114,101,116,99,104,0,109,117,108,116,105,108,105,110,101,45,115,116,114,101,116,99,104,0,69,120,112,101,99,116,101,100,32,110,111,100,101,32,116,111,32,104,97,118,101,32,99,117,115,116,111,109,32,109,101,97,115,117,114,101,32,102,117,110,99,116,105,111,110,0,109,101,97,115,117,114,101,0,69,120,112,101,99,116,32,99,117,115,116,111,109,32,98,97,115,101,108,105,110,101,32,102,117,110,99,116,105,111,110,32,116,111,32,110,111,116,32,114,101,116,117,114,110,32,78,97,78,0,97,98,115,45,109,101,97,115,117,114,101,0,97,98,115,45,108,97,121,111,117,116,0,78,111,100,101,0,99,114,101,97,116,101,68,101,102,97,117,108,116,0,99,114,101,97,116,101,87,105,116,104,67,111,110,102,105,103,0,100,101,115,116,114,111,121,0,114,101,115,101,116,0,99,111,112,121,83,116,121,108,101,0,115,101,116,80,111,115,105,116,105,111,110,84,121,112,101,0,115,101,116,80,111,115,105,116,105,111,110,0,115,101,116,80,111,115,105,116,105,111,110,80,101,114,99,101,110,116,0,115,101,116,65,108,105,103,110,67,111,110,116,101,110,116,0,115,101,116,65,108,105,103,110,73,116,101,109,115,0,115,101,116,65,108,105,103,110,83,101,108,102,0,115,101,116,70,108,101,120,68,105,114,101,99,116,105,111,110,0,115,101,116,70,108,101,120,87,114,97,112,0,115,101,116,74,117,115,116,105,102,121,67,111,110,116,101,110,116,0,115,101,116,77,97,114,103,105,110,0,115,101,116,77,97,114,103,105,110,80,101,114,99,101,110,116,0,115,101,116,77,97,114,103,105,110,65,117,116,111,0,115,101,116,79,118,101,114,102,108,111,119,0,115,101,116,68,105,115,112,108,97,121,0,115,101,116,70,108,101,120,0,115,101,116,70,108,101,120,66,97,115,105,115,0,115,101,116,70,108,101,120,66,97,115,105,115,80,101,114,99,101,110,116,0,115,101,116,70,108,101,120,71,114,111,119,0,115,101,116,70,108,101,120,83,104,114,105,110,107,0,115,101,116,87,105,100,116,104,0,115,101,116,87,105,100,116,104,80,101,114,99,101,110,116,0,115,101,116,87,105,100,116,104,65,117,116,111,0,115,101,116,72,101,105,103,104,116,0,115,101,116,72,101,105,103,104,116,80,101,114,99,101,110,116,0,115,101,116,72,101,105,103,104,116,65,117,116,111,0,115,101,116,77,105,110,87,105,100,116,104,0,115,101,116,77,105,110,87,105,100,116,104,80,101,114,99,101,110,116,0,115,101,116,77,105,110,72,101,105,103,104,116,0,115,101,116,77,105,110,72,101,105,103,104,116,80,101,114,99,101,110,116,0,115,101,116,77,97,120,87,105,100,116,104,0,115,101,116,77,97,120,87,105,100,116,104,80,101,114,99,101,110,116,0,115,101,116,77,97,120,72,101,105,103,104,116,0,115,101,116,77,97,120,72,101,105,103,104,116,80,101,114,99,101,110,116,0,115,101,116,65,115,112,101,99,116,82,97,116,105,111,0,115,101,116,66,111,114,100,101,114,0,115,101,116,80,97,100,100,105,110,103,0,115,101,116,80,97,100,100,105,110,103,80,101,114,99,101,110,116,0,103,101,116,80,111,115,105,116,105,111,110,84,121,112,101,0,103,101,116,80,111,115,105,116,105,111,110,0,103,101,116,65,108,105,103,110,67,111,110,116,101,110,116,0,103,101,116,65,108,105,103,110,73,116,101,109,115,0,103,101,116,65,108,105,103,110,83,101,108,102,0,103,101,116,70,108,101,120,68,105,114,101,99,116,105,111,110,0,103,101,116,70,108,101,120,87,114,97,112,0,103,101,116,74,117,115,116,105,102,121,67,111,110,116,101,110,116,0,103,101,116,77,97,114,103,105,110,0,103,101,116,70,108,101,120,66,97,115,105,115,0,103,101,116,70,108,101,120,71,114,111,119,0,103,101,116,70,108,101,120,83,104,114,105,110,107,0,103,101,116,87,105,100,116,104,0,103,101,116,72,101,105,103,104,116,0,103,101,116,77,105,110,87,105,100,116,104,0,103,101,116,77,105,110,72,101,105,103,104,116,0,103,101,116,77,97,120,87,105,100,116,104,0,103,101,116,77,97,120,72,101,105,103,104,116,0,103,101,116,65,115,112,101,99,116,82,97,116,105,111,0,103,101,116,66,111,114,100,101,114,0,103,101,116,79,118,101,114,102,108,111,119,0,103,101,116,68,105,115,112,108,97,121,0,103,101,116,80,97,100,100,105,110,103,0,105,110,115,101,114,116,67,104,105,108,100,0,114,101,109,111,118,101,67,104,105,108,100,0,103,101,116,67,104,105,108,100,67,111,117,110,116,0,103,101,116,80,97,114,101,110,116,0,103,101,116,67,104,105,108,100,0,115,101,116,77,101,97,115,117,114,101,70,117,110,99,0,117,110,115,101,116,77,101,97,115,117,114,101,70,117,110,99,0,109,97,114,107,68,105,114,116,121,0,105,115,68,105,114,116,121,0,99,97,108,99,117,108,97,116,101,76,97,121,111,117,116,0,103,101,116,67,111,109,112,117,116,101,100,76,101,102,116,0,103,101,116,67,111,109,112,117,116,101,100,82,105,103,104,116,0,103,101,116,67,111,109,112,117,116,101,100,84,111,112,0,103,101,116,67,111,109,112,117,116,101,100,66,111,116,116,111,109,0,103,101,116,67,111,109,112,117,116,101,100,87,105,100,116,104,0,103,101,116,67,111,109,112,117,116,101,100,72,101,105,103,104,116,0,103,101,116,67,111,109,112,117,116,101,100,76,97,121,111,117,116,0,103,101,116,67,111,109,112,117,116,101,100,77,97,114,103,105,110,0,103,101,116,67,111,109,112,117,116,101,100,66,111,114,100,101,114,0,103,101,116,67,111,109,112,117,116,101,100,80,97,100,100,105,110,103,0,67,111,110,102,105,103,0,99,114,101,97,116,101,0,115,101,116,69,120,112,101,114,105,109,101,110,116,97,108,70,101,97,116,117,114,101,69,110,97,98,108,101,100,0,115,101,116,80,111,105,110,116,83,99,97,108,101,70,97,99,116,111,114,0,105,115,69,120,112,101,114,105,109,101,110,116,97,108,70,101,97,116,117,114,101,69,110,97,98,108,101,100,0,86,97,108,117,101,0,76,97,121,111,117,116,0,83,105,122,101,0,103,101,116,73,110,115,116,97,110,99,101,67,111,117,110,116,0,73,110,116,54,52,0,1,1,1,2,2,4,4,4,4,8,8,4,8,118,111,105,100,0,98,111,111,108,0,115,116,100,58,58,115,116,114,105,110,103,0,99,98,70,117,110,99,116,105,111,110,32,38,0,99,111,110,115,116,32,99,98,70,117,110,99,116,105,111,110,32,38,0,69,120,116,101,114,110,97,108,0,66,117,102,102,101,114,0,78,66,105,110,100,73,68,0,78,66,105,110,100,0,98,105,110,100,95,118,97,108,117,101,0,114,101,102,108,101,99,116,0,113,117,101,114,121,84,121,112,101,0,108,97,108,108,111,99,0,108,114,101,115,101,116,0,123,114,101,116,117,114,110,40,95,110,98,105,110,100,46,99,97,108,108,98,97,99,107,83,105,103,110,97,116,117,114,101,76,105,115,116,91,36,48,93,46,97,112,112,108,121,40,116,104,105,115,44,97,114,103,117,109,101,110,116,115,41,41,59,125,0,95,110,98,105,110,100,95,110,101,119,0,17,0,10,0,17,17,17,0,0,0,0,5,0,0,0,0,0,0,9,0,0,0,0,11,0,0,0,0,0,0,0,0,17,0,15,10,17,17,17,3,10,7,0,1,19,9,11,11,0,0,9,6,11,0,0,11,0,6,17,0,0,0,17,17,17,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,11,0,0,0,0,0,0,0,0,17,0,10,10,17,17,17,0,10,0,0,2,0,9,11,0,0,0,9,0,11,0,0,11,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,12,0,0,0,0,0,0,0,0,0,0,0,12,0,0,0,0,12,0,0,0,0,9,12,0,0,0,0,0,12,0,0,12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,14,0,0,0,0,0,0,0,0,0,0,0,13,0,0,0,4,13,0,0,0,0,9,14,0,0,0,0,0,14,0,0,14,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,16,0,0,0,0,0,0,0,0,0,0,0,15,0,0,0,0,15,0,0,0,0,9,16,0,0,0,0,0,16,0,0,16,0,0,18,0,0,0,18,18,18,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,18,0,0,0,18,18,18,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,11,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,10,0,0,0,0,9,11,0,0,0,0,0,11,0,0,11,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,12,0,0,0,0,0,0,0,0,0,0,0,12,0,0,0,0,12,0,0,0,0,9,12,0,0,0,0,0,12,0,0,12,0,0,45,43,32,32,32,48,88,48,120,0,40,110,117,108,108,41,0,45,48,88,43,48,88,32,48,88,45,48,120,43,48,120,32,48,120,0,105,110,102,0,73,78,70,0,110,97,110,0,78,65,78,0,48,49,50,51,52,53,54,55,56,57,65,66,67,68,69,70,46,0,84,33,34,25,13,1,2,3,17,75,28,12,16,4,11,29,18,30,39,104,110,111,112,113,98,32,5,6,15,19,20,21,26,8,22,7,40,36,23,24,9,10,14,27,31,37,35,131,130,125,38,42,43,60,61,62,63,67,71,74,77,88,89,90,91,92,93,94,95,96,97,99,100,101,102,103,105,106,107,108,114,115,116,121,122,123,124,0,73,108,108,101,103,97,108,32,98,121,116,101,32,115,101,113,117,101,110,99,101,0,68,111,109,97,105,110,32,101,114,114,111,114,0,82,101,115,117,108,116,32,110,111,116,32,114,101,112,114,101,115,101,110,116,97,98,108,101,0,78,111,116,32,97,32,116,116,121,0,80,101,114,109,105,115,115,105,111,110,32,100,101,110,105,101,100,0,79,112,101,114,97,116,105,111,110,32,110,111,116,32,112,101,114,109,105,116,116,101,100,0,78,111,32,115,117,99,104,32,102,105,108,101,32,111,114,32,100,105,114,101,99,116,111,114,121,0,78,111,32,115,117,99,104,32,112,114,111,99,101,115,115,0,70,105,108,101,32,101,120,105,115,116,115,0,86,97,108,117,101,32,116,111,111,32,108,97,114,103,101,32,102,111,114,32,100,97,116,97,32,116,121,112,101,0,78,111,32,115,112,97,99,101,32,108,101,102,116,32,111,110,32,100,101,118,105,99,101,0,79,117,116,32,111,102,32,109,101,109,111,114,121,0,82,101,115,111,117,114,99,101,32,98,117,115,121,0,73,110,116,101,114,114,117,112,116,101,100,32,115,121,115,116,101,109,32,99,97,108,108,0,82,101,115,111,117,114,99,101,32,116,101,109,112,111,114,97,114,105,108,121,32,117,110,97,118,97,105,108,97,98,108,101,0,73,110,118,97,108,105,100,32,115,101,101,107,0,67,114,111,115,115,45,100,101,118,105,99,101,32,108,105,110,107,0,82,101,97,100,45,111,110,108,121,32,102,105,108,101,32,115,121,115,116,101,109,0,68,105,114,101,99,116,111,114,121,32,110,111,116,32,101,109,112,116,121,0,67,111,110,110,101,99,116,105,111,110,32,114,101,115,101,116,32,98,121,32,112,101,101,114,0,79,112,101,114,97,116,105,111,110,32,116,105,109,101,100,32,111,117,116,0,67,111,110,110,101,99,116,105,111,110,32,114,101,102,117,115,101,100,0,72,111,115,116,32,105,115,32,100,111,119,110,0,72,111,115,116,32,105,115,32,117,110,114,101,97,99,104,97,98,108,101,0,65,100,100,114,101,115,115,32,105,110,32,117,115,101,0,66,114,111,107,101,110,32,112,105,112,101,0,73,47,79,32,101,114,114,111,114,0,78,111,32,115,117,99,104,32,100,101,118,105,99,101,32,111,114,32,97,100,100,114,101,115,115,0,66,108,111,99,107,32,100,101,118,105,99,101,32,114,101,113,117,105,114,101,100,0,78,111,32,115,117,99,104,32,100,101,118,105,99,101,0,78,111,116,32,97,32,100,105,114,101,99,116,111,114,121,0,73,115,32,97,32,100,105,114,101,99,116,111,114,121,0,84,101,120,116,32,102,105,108,101,32,98,117,115,121,0,69,120,101,99,32,102,111,114,109,97,116,32,101,114,114,111,114,0,73,110,118,97,108,105,100,32,97,114,103,117,109,101,110,116,0,65,114,103,117,109,101,110,116,32,108,105,115,116,32,116,111,111,32,108,111,110,103,0,83,121,109,98,111,108,105,99,32,108,105,110,107,32,108,111,111,112,0,70,105,108,101,110,97,109,101,32,116,111,111,32,108,111,110,103,0,84,111,111,32,109,97,110,121,32,111,112,101,110,32,102,105,108,101,115,32,105,110,32,115,121,115,116,101,109,0,78,111,32,102,105,108,101,32,100,101,115,99,114,105,112,116,111,114,115,32,97,118,97,105,108,97,98,108,101,0,66,97,100,32,102,105,108,101,32,100,101,115,99,114,105,112,116,111,114,0,78,111,32,99,104,105,108,100,32,112,114,111,99,101,115,115,0,66,97,100,32,97,100,100,114,101,115,115,0,70,105,108,101,32,116,111,111,32,108,97,114,103,101,0,84,111,111,32,109,97,110,121,32,108,105,110,107,115,0,78,111,32,108,111,99,107,115,32,97,118,97,105,108,97,98,108,101,0,82,101,115,111,117,114,99,101,32,100,101,97,100,108,111,99,107,32,119,111,117,108,100,32,111,99,99,117,114,0,83,116,97,116,101,32,110,111,116,32,114,101,99,111,118,101,114,97,98,108,101,0,80,114,101,118,105,111,117,115,32,111,119,110,101,114,32,100,105,101,100,0,79,112,101,114,97,116,105,111,110,32,99,97,110,99,101,108,101,100,0,70,117,110,99,116,105,111,110,32,110,111,116,32,105,109,112,108,101,109,101,110,116,101,100,0,78,111,32,109,101,115,115,97,103,101,32,111,102,32,100,101,115,105,114,101,100,32,116,121,112,101,0,73,100,101,110,116,105,102,105,101,114,32,114,101,109,111,118,101,100,0,68,101,118,105,99,101,32,110,111,116,32,97,32,115,116,114,101,97,109,0,78,111,32,100,97,116,97,32,97,118,97,105,108,97,98,108,101,0,68,101,118,105,99,101,32,116,105,109,101,111,117,116,0,79,117,116,32,111,102,32,115,116,114,101,97,109,115,32,114,101,115,111,117,114,99,101,115,0,76,105,110,107,32,104,97,115,32,98,101,101,110,32,115,101,118,101,114,101,100,0,80,114,111,116,111,99,111,108,32,101,114,114,111,114,0,66,97,100,32,109,101,115,115,97,103,101,0,70,105,108,101,32,100,101,115,99,114,105,112,116,111,114,32,105,110,32,98,97,100,32,115,116,97,116,101,0,78,111,116,32,97,32,115,111,99,107,101,116,0,68,101,115,116,105,110,97,116,105,111,110,32,97,100,100,114,101,115,115,32,114,101,113,117,105,114,101,100,0,77,101,115,115,97,103,101,32,116,111,111,32,108,97,114,103,101,0,80,114,111,116,111,99,111,108,32,119,114,111,110,103,32,116,121,112,101,32,102,111,114,32,115,111,99,107,101,116,0,80,114,111,116,111,99,111,108,32,110,111,116,32,97,118,97,105,108,97,98,108,101,0,80,114,111,116,111,99,111,108,32,110,111,116,32,115,117,112,112,111,114,116,101,100,0,83,111,99,107,101,116,32,116,121,112,101,32,110,111,116,32,115,117,112,112,111,114,116,101,100,0,78,111,116,32,115,117,112,112,111,114,116,101,100,0,80,114,111,116,111,99,111,108,32,102,97,109,105,108,121,32,110,111,116,32,115,117,112,112,111,114,116,101,100,0,65,100,100,114,101,115,115,32,102,97,109,105,108,121,32,110,111,116,32,115,117,112,112,111,114,116,101,100,32,98,121,32,112,114,111,116,111,99,111,108,0,65,100,100,114,101,115,115,32,110,111,116,32,97,118,97,105,108,97,98,108,101,0,78,101,116,119,111,114,107,32,105,115,32,100,111,119,110,0,78,101,116,119,111,114,107,32,117,110,114,101,97,99,104,97,98,108,101,0,67,111,110,110,101,99,116,105,111,110,32,114,101,115,101,116,32,98,121,32,110,101,116,119,111,114,107,0,67,111,110,110,101,99,116,105,111,110,32,97,98,111,114,116,101,100,0,78,111,32,98,117,102,102,101,114,32,115,112,97,99,101,32,97,118,97,105,108,97,98,108,101,0,83,111,99,107,101,116,32,105,115,32,99,111,110,110,101,99,116,101,100,0,83,111,99,107,101,116,32,110,111,116,32,99,111,110,110,101,99,116,101,100,0,67,97,110,110,111,116,32,115,101,110,100,32,97,102,116,101,114,32,115,111,99,107,101,116,32,115,104,117,116,100,111,119,110,0,79,112,101,114,97,116,105,111,110,32,97,108,114,101,97,100,121,32,105,110,32,112,114,111,103,114,101,115,115,0,79,112,101,114,97,116,105,111,110,32,105,110,32,112,114,111,103,114,101,115,115,0,83,116,97,108,101,32,102,105,108,101,32,104,97,110,100,108,101,0,82,101,109,111,116,101,32,73,47,79,32,101,114,114,111,114,0,81,117,111,116,97,32,101,120,99,101,101,100,101,100,0,78,111,32,109,101,100,105,117,109,32,102,111,117,110,100,0,87,114,111,110,103,32,109,101,100,105,117,109,32,116,121,112,101,0,78,111,32,101,114,114,111,114,32,105,110,102,111,114,109,97,116,105,111,110,0,0],"i8",ALLOC_NONE,Runtime.GLOBAL_BASE);var tempDoublePtr=STATICTOP;STATICTOP+=16;function _atexit(t,e){__ATEXIT__.unshift({func:t,arg:e})}function ___cxa_atexit(){return _atexit.apply(null,arguments)}function _abort(){Module.abort()}function __ZN8facebook4yoga14YGNodeToStringEPNSt3__212basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEEP6YGNode14YGPrintOptionsj(){Module.printErr("missing function: _ZN8facebook4yoga14YGNodeToStringEPNSt3__212basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEEP6YGNode14YGPrintOptionsj"),abort(-1)}function __decorate(t,e,r,o){var a=arguments.length,n=a<3?e:o===null?o=Object.getOwnPropertyDescriptor(e,r):o,u;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")n=Reflect.decorate(t,e,r,o);else for(var A=t.length-1;A>=0;A--)(u=t[A])&&(n=(a<3?u(n):a>3?u(e,r,n):u(e,r))||n);return a>3&&n&&Object.defineProperty(e,r,n),n}function _defineHidden(t){return function(e,r){Object.defineProperty(e,r,{configurable:!1,enumerable:!1,value:t,writable:!0})}}var _nbind={};function __nbind_free_external(t){_nbind.externalList[t].dereference(t)}function __nbind_reference_external(t){_nbind.externalList[t].reference()}function _llvm_stackrestore(t){var e=_llvm_stacksave,r=e.LLVM_SAVEDSTACKS[t];e.LLVM_SAVEDSTACKS.splice(t,1),Runtime.stackRestore(r)}function __nbind_register_pool(t,e,r,o){_nbind.Pool.pageSize=t,_nbind.Pool.usedPtr=e/4,_nbind.Pool.rootPtr=r,_nbind.Pool.pagePtr=o/4,HEAP32[e/4]=16909060,HEAP8[e]==1&&(_nbind.bigEndian=!0),HEAP32[e/4]=0,_nbind.makeTypeKindTbl=(n={},n[1024]=_nbind.PrimitiveType,n[64]=_nbind.Int64Type,n[2048]=_nbind.BindClass,n[3072]=_nbind.BindClassPtr,n[4096]=_nbind.SharedClassPtr,n[5120]=_nbind.ArrayType,n[6144]=_nbind.ArrayType,n[7168]=_nbind.CStringType,n[9216]=_nbind.CallbackType,n[10240]=_nbind.BindType,n),_nbind.makeTypeNameTbl={Buffer:_nbind.BufferType,External:_nbind.ExternalType,Int64:_nbind.Int64Type,_nbind_new:_nbind.CreateValueType,bool:_nbind.BooleanType,"cbFunction &":_nbind.CallbackType,"const cbFunction &":_nbind.CallbackType,"const std::string &":_nbind.StringType,"std::string":_nbind.StringType},Module.toggleLightGC=_nbind.toggleLightGC,_nbind.callUpcast=Module.dynCall_ii;var a=_nbind.makeType(_nbind.constructType,{flags:2048,id:0,name:""});a.proto=Module,_nbind.BindClass.list.push(a);var n}function _emscripten_set_main_loop_timing(t,e){if(Browser.mainLoop.timingMode=t,Browser.mainLoop.timingValue=e,!Browser.mainLoop.func)return 1;if(t==0)Browser.mainLoop.scheduler=function(){var u=Math.max(0,Browser.mainLoop.tickStartTime+e-_emscripten_get_now())|0;setTimeout(Browser.mainLoop.runner,u)},Browser.mainLoop.method="timeout";else if(t==1)Browser.mainLoop.scheduler=function(){Browser.requestAnimationFrame(Browser.mainLoop.runner)},Browser.mainLoop.method="rAF";else if(t==2){if(!window.setImmediate){let n=function(u){u.source===window&&u.data===o&&(u.stopPropagation(),r.shift()())};var a=n,r=[],o="setimmediate";window.addEventListener("message",n,!0),window.setImmediate=function(A){r.push(A),ENVIRONMENT_IS_WORKER?(Module.setImmediates===void 0&&(Module.setImmediates=[]),Module.setImmediates.push(A),window.postMessage({target:o})):window.postMessage(o,"*")}}Browser.mainLoop.scheduler=function(){window.setImmediate(Browser.mainLoop.runner)},Browser.mainLoop.method="immediate"}return 0}function _emscripten_get_now(){abort()}function _emscripten_set_main_loop(t,e,r,o,a){Module.noExitRuntime=!0,assert(!Browser.mainLoop.func,"emscripten_set_main_loop: there can only be one main loop function at once: call emscripten_cancel_main_loop to cancel the previous one before setting a new one with different parameters."),Browser.mainLoop.func=t,Browser.mainLoop.arg=o;var n;typeof o<"u"?n=function(){Module.dynCall_vi(t,o)}:n=function(){Module.dynCall_v(t)};var u=Browser.mainLoop.currentlyRunningMainloop;if(Browser.mainLoop.runner=function(){if(!ABORT){if(Browser.mainLoop.queue.length>0){var p=Date.now(),h=Browser.mainLoop.queue.shift();if(h.func(h.arg),Browser.mainLoop.remainingBlockers){var C=Browser.mainLoop.remainingBlockers,I=C%1==0?C-1:Math.floor(C);h.counted?Browser.mainLoop.remainingBlockers=I:(I=I+.5,Browser.mainLoop.remainingBlockers=(8*C+I)/9)}if(console.log('main loop blocker "'+h.name+'" took '+(Date.now()-p)+" ms"),Browser.mainLoop.updateStatus(),u<Browser.mainLoop.currentlyRunningMainloop)return;setTimeout(Browser.mainLoop.runner,0);return}if(!(u<Browser.mainLoop.currentlyRunningMainloop)){if(Browser.mainLoop.currentFrameNumber=Browser.mainLoop.currentFrameNumber+1|0,Browser.mainLoop.timingMode==1&&Browser.mainLoop.timingValue>1&&Browser.mainLoop.currentFrameNumber%Browser.mainLoop.timingValue!=0){Browser.mainLoop.scheduler();return}else Browser.mainLoop.timingMode==0&&(Browser.mainLoop.tickStartTime=_emscripten_get_now());Browser.mainLoop.method==="timeout"&&Module.ctx&&(Module.printErr("Looks like you are rendering without using requestAnimationFrame for the main loop. You should use 0 for the frame rate in emscripten_set_main_loop in order to use requestAnimationFrame, as that can greatly improve your frame rates!"),Browser.mainLoop.method=""),Browser.mainLoop.runIter(n),!(u<Browser.mainLoop.currentlyRunningMainloop)&&(typeof SDL=="object"&&SDL.audio&&SDL.audio.queueNewAudioData&&SDL.audio.queueNewAudioData(),Browser.mainLoop.scheduler())}}},a||(e&&e>0?_emscripten_set_main_loop_timing(0,1e3/e):_emscripten_set_main_loop_timing(1,1),Browser.mainLoop.scheduler()),r)throw"SimulateInfiniteLoop"}var Browser={mainLoop:{scheduler:null,method:"",currentlyRunningMainloop:0,func:null,arg:0,timingMode:0,timingValue:0,currentFrameNumber:0,queue:[],pause:function(){Browser.mainLoop.scheduler=null,Browser.mainLoop.currentlyRunningMainloop++},resume:function(){Browser.mainLoop.currentlyRunningMainloop++;var t=Browser.mainLoop.timingMode,e=Browser.mainLoop.timingValue,r=Browser.mainLoop.func;Browser.mainLoop.func=null,_emscripten_set_main_loop(r,0,!1,Browser.mainLoop.arg,!0),_emscripten_set_main_loop_timing(t,e),Browser.mainLoop.scheduler()},updateStatus:function(){if(Module.setStatus){var t=Module.statusMessage||"Please wait...",e=Browser.mainLoop.remainingBlockers,r=Browser.mainLoop.expectedBlockers;e?e<r?Module.setStatus(t+" ("+(r-e)+"/"+r+")"):Module.setStatus(t):Module.setStatus("")}},runIter:function(t){if(!ABORT){if(Module.preMainLoop){var e=Module.preMainLoop();if(e===!1)return}try{t()}catch(r){if(r instanceof ExitStatus)return;throw r&&typeof r=="object"&&r.stack&&Module.printErr("exception thrown: "+[r,r.stack]),r}Module.postMainLoop&&Module.postMainLoop()}}},isFullscreen:!1,pointerLock:!1,moduleContextCreatedCallbacks:[],workers:[],init:function(){if(Module.preloadPlugins||(Module.preloadPlugins=[]),Browser.initted)return;Browser.initted=!0;try{new Blob,Browser.hasBlobConstructor=!0}catch{Browser.hasBlobConstructor=!1,console.log("warning: no blob constructor, cannot create blobs with mimetypes")}Browser.BlobBuilder=typeof MozBlobBuilder<"u"?MozBlobBuilder:typeof WebKitBlobBuilder<"u"?WebKitBlobBuilder:Browser.hasBlobConstructor?null:console.log("warning: no BlobBuilder"),Browser.URLObject=typeof window<"u"?window.URL?window.URL:window.webkitURL:void 0,!Module.noImageDecoding&&typeof Browser.URLObject>"u"&&(console.log("warning: Browser does not support creating object URLs. Built-in browser image decoding will not be available."),Module.noImageDecoding=!0);var t={};t.canHandle=function(n){return!Module.noImageDecoding&&/\.(jpg|jpeg|png|bmp)$/i.test(n)},t.handle=function(n,u,A,p){var h=null;if(Browser.hasBlobConstructor)try{h=new Blob([n],{type:Browser.getMimetype(u)}),h.size!==n.length&&(h=new Blob([new Uint8Array(n).buffer],{type:Browser.getMimetype(u)}))}catch(b){Runtime.warnOnce("Blob constructor present but fails: "+b+"; falling back to blob builder")}if(!h){var C=new Browser.BlobBuilder;C.append(new Uint8Array(n).buffer),h=C.getBlob()}var I=Browser.URLObject.createObjectURL(h),v=new Image;v.onload=function(){assert(v.complete,"Image "+u+" could not be decoded");var E=document.createElement("canvas");E.width=v.width,E.height=v.height;var F=E.getContext("2d");F.drawImage(v,0,0),Module.preloadedImages[u]=E,Browser.URLObject.revokeObjectURL(I),A&&A(n)},v.onerror=function(E){console.log("Image "+I+" could not be decoded"),p&&p()},v.src=I},Module.preloadPlugins.push(t);var e={};e.canHandle=function(n){return!Module.noAudioDecoding&&n.substr(-4)in{".ogg":1,".wav":1,".mp3":1}},e.handle=function(n,u,A,p){var h=!1;function C(F){h||(h=!0,Module.preloadedAudios[u]=F,A&&A(n))}function I(){h||(h=!0,Module.preloadedAudios[u]=new Audio,p&&p())}if(Browser.hasBlobConstructor){try{var v=new Blob([n],{type:Browser.getMimetype(u)})}catch{return I()}var b=Browser.URLObject.createObjectURL(v),E=new Audio;E.addEventListener("canplaythrough",function(){C(E)},!1),E.onerror=function(N){if(h)return;console.log("warning: browser could not fully decode audio "+u+", trying slower base64 approach");function U(z){for(var te="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",le="=",pe="",ue=0,ye=0,ae=0;ae<z.length;ae++)for(ue=ue<<8|z[ae],ye+=8;ye>=6;){var Ie=ue>>ye-6&63;ye-=6,pe+=te[Ie]}return ye==2?(pe+=te[(ue&3)<<4],pe+=le+le):ye==4&&(pe+=te[(ue&15)<<2],pe+=le),pe}E.src="data:audio/x-"+u.substr(-3)+";base64,"+U(n),C(E)},E.src=b,Browser.safeSetTimeout(function(){C(E)},1e4)}else return I()},Module.preloadPlugins.push(e);function r(){Browser.pointerLock=document.pointerLockElement===Module.canvas||document.mozPointerLockElement===Module.canvas||document.webkitPointerLockElement===Module.canvas||document.msPointerLockElement===Module.canvas}var o=Module.canvas;o&&(o.requestPointerLock=o.requestPointerLock||o.mozRequestPointerLock||o.webkitRequestPointerLock||o.msRequestPointerLock||function(){},o.exitPointerLock=document.exitPointerLock||document.mozExitPointerLock||document.webkitExitPointerLock||document.msExitPointerLock||function(){},o.exitPointerLock=o.exitPointerLock.bind(document),document.addEventListener("pointerlockchange",r,!1),document.addEventListener("mozpointerlockchange",r,!1),document.addEventListener("webkitpointerlockchange",r,!1),document.addEventListener("mspointerlockchange",r,!1),Module.elementPointerLock&&o.addEventListener("click",function(a){!Browser.pointerLock&&Module.canvas.requestPointerLock&&(Module.canvas.requestPointerLock(),a.preventDefault())},!1))},createContext:function(t,e,r,o){if(e&&Module.ctx&&t==Module.canvas)return Module.ctx;var a,n;if(e){var u={antialias:!1,alpha:!1};if(o)for(var A in o)u[A]=o[A];n=GL.createContext(t,u),n&&(a=GL.getContext(n).GLctx)}else a=t.getContext("2d");return a?(r&&(e||assert(typeof GLctx>"u","cannot set in module if GLctx is used, but we are a non-GL context that would replace it"),Module.ctx=a,e&&GL.makeContextCurrent(n),Module.useWebGL=e,Browser.moduleContextCreatedCallbacks.forEach(function(p){p()}),Browser.init()),a):null},destroyContext:function(t,e,r){},fullscreenHandlersInstalled:!1,lockPointer:void 0,resizeCanvas:void 0,requestFullscreen:function(t,e,r){Browser.lockPointer=t,Browser.resizeCanvas=e,Browser.vrDevice=r,typeof Browser.lockPointer>"u"&&(Browser.lockPointer=!0),typeof Browser.resizeCanvas>"u"&&(Browser.resizeCanvas=!1),typeof Browser.vrDevice>"u"&&(Browser.vrDevice=null);var o=Module.canvas;function a(){Browser.isFullscreen=!1;var u=o.parentNode;(document.fullscreenElement||document.mozFullScreenElement||document.msFullscreenElement||document.webkitFullscreenElement||document.webkitCurrentFullScreenElement)===u?(o.exitFullscreen=document.exitFullscreen||document.cancelFullScreen||document.mozCancelFullScreen||document.msExitFullscreen||document.webkitCancelFullScreen||function(){},o.exitFullscreen=o.exitFullscreen.bind(document),Browser.lockPointer&&o.requestPointerLock(),Browser.isFullscreen=!0,Browser.resizeCanvas&&Browser.setFullscreenCanvasSize()):(u.parentNode.insertBefore(o,u),u.parentNode.removeChild(u),Browser.resizeCanvas&&Browser.setWindowedCanvasSize()),Module.onFullScreen&&Module.onFullScreen(Browser.isFullscreen),Module.onFullscreen&&Module.onFullscreen(Browser.isFullscreen),Browser.updateCanvasDimensions(o)}Browser.fullscreenHandlersInstalled||(Browser.fullscreenHandlersInstalled=!0,document.addEventListener("fullscreenchange",a,!1),document.addEventListener("mozfullscreenchange",a,!1),document.addEventListener("webkitfullscreenchange",a,!1),document.addEventListener("MSFullscreenChange",a,!1));var n=document.createElement("div");o.parentNode.insertBefore(n,o),n.appendChild(o),n.requestFullscreen=n.requestFullscreen||n.mozRequestFullScreen||n.msRequestFullscreen||(n.webkitRequestFullscreen?function(){n.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT)}:null)||(n.webkitRequestFullScreen?function(){n.webkitRequestFullScreen(Element.ALLOW_KEYBOARD_INPUT)}:null),r?n.requestFullscreen({vrDisplay:r}):n.requestFullscreen()},requestFullScreen:function(t,e,r){return Module.printErr("Browser.requestFullScreen() is deprecated. Please call Browser.requestFullscreen instead."),Browser.requestFullScreen=function(o,a,n){return Browser.requestFullscreen(o,a,n)},Browser.requestFullscreen(t,e,r)},nextRAF:0,fakeRequestAnimationFrame:function(t){var e=Date.now();if(Browser.nextRAF===0)Browser.nextRAF=e+1e3/60;else for(;e+2>=Browser.nextRAF;)Browser.nextRAF+=1e3/60;var r=Math.max(Browser.nextRAF-e,0);setTimeout(t,r)},requestAnimationFrame:function t(e){typeof window>"u"?Browser.fakeRequestAnimationFrame(e):(window.requestAnimationFrame||(window.requestAnimationFrame=window.requestAnimationFrame||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame||window.msRequestAnimationFrame||window.oRequestAnimationFrame||Browser.fakeRequestAnimationFrame),window.requestAnimationFrame(e))},safeCallback:function(t){return function(){if(!ABORT)return t.apply(null,arguments)}},allowAsyncCallbacks:!0,queuedAsyncCallbacks:[],pauseAsyncCallbacks:function(){Browser.allowAsyncCallbacks=!1},resumeAsyncCallbacks:function(){if(Browser.allowAsyncCallbacks=!0,Browser.queuedAsyncCallbacks.length>0){var t=Browser.queuedAsyncCallbacks;Browser.queuedAsyncCallbacks=[],t.forEach(function(e){e()})}},safeRequestAnimationFrame:function(t){return Browser.requestAnimationFrame(function(){ABORT||(Browser.allowAsyncCallbacks?t():Browser.queuedAsyncCallbacks.push(t))})},safeSetTimeout:function(t,e){return Module.noExitRuntime=!0,setTimeout(function(){ABORT||(Browser.allowAsyncCallbacks?t():Browser.queuedAsyncCallbacks.push(t))},e)},safeSetInterval:function(t,e){return Module.noExitRuntime=!0,setInterval(function(){ABORT||Browser.allowAsyncCallbacks&&t()},e)},getMimetype:function(t){return{jpg:"image/jpeg",jpeg:"image/jpeg",png:"image/png",bmp:"image/bmp",ogg:"audio/ogg",wav:"audio/wav",mp3:"audio/mpeg"}[t.substr(t.lastIndexOf(".")+1)]},getUserMedia:function(t){window.getUserMedia||(window.getUserMedia=navigator.getUserMedia||navigator.mozGetUserMedia),window.getUserMedia(t)},getMovementX:function(t){return t.movementX||t.mozMovementX||t.webkitMovementX||0},getMovementY:function(t){return t.movementY||t.mozMovementY||t.webkitMovementY||0},getMouseWheelDelta:function(t){var e=0;switch(t.type){case"DOMMouseScroll":e=t.detail;break;case"mousewheel":e=t.wheelDelta;break;case"wheel":e=t.deltaY;break;default:throw"unrecognized mouse wheel event: "+t.type}return e},mouseX:0,mouseY:0,mouseMovementX:0,mouseMovementY:0,touches:{},lastTouches:{},calculateMouseEvent:function(t){if(Browser.pointerLock)t.type!="mousemove"&&"mozMovementX"in t?Browser.mouseMovementX=Browser.mouseMovementY=0:(Browser.mouseMovementX=Browser.getMovementX(t),Browser.mouseMovementY=Browser.getMovementY(t)),typeof SDL<"u"?(Browser.mouseX=SDL.mouseX+Browser.mouseMovementX,Browser.mouseY=SDL.mouseY+Browser.mouseMovementY):(Browser.mouseX+=Browser.mouseMovementX,Browser.mouseY+=Browser.mouseMovementY);else{var e=Module.canvas.getBoundingClientRect(),r=Module.canvas.width,o=Module.canvas.height,a=typeof window.scrollX<"u"?window.scrollX:window.pageXOffset,n=typeof window.scrollY<"u"?window.scrollY:window.pageYOffset;if(t.type==="touchstart"||t.type==="touchend"||t.type==="touchmove"){var u=t.touch;if(u===void 0)return;var A=u.pageX-(a+e.left),p=u.pageY-(n+e.top);A=A*(r/e.width),p=p*(o/e.height);var h={x:A,y:p};if(t.type==="touchstart")Browser.lastTouches[u.identifier]=h,Browser.touches[u.identifier]=h;else if(t.type==="touchend"||t.type==="touchmove"){var C=Browser.touches[u.identifier];C||(C=h),Browser.lastTouches[u.identifier]=C,Browser.touches[u.identifier]=h}return}var I=t.pageX-(a+e.left),v=t.pageY-(n+e.top);I=I*(r/e.width),v=v*(o/e.height),Browser.mouseMovementX=I-Browser.mouseX,Browser.mouseMovementY=v-Browser.mouseY,Browser.mouseX=I,Browser.mouseY=v}},asyncLoad:function(t,e,r,o){var a=o?"":"al "+t;Module.readAsync(t,function(n){assert(n,'Loading data file "'+t+'" failed (no arrayBuffer).'),e(new Uint8Array(n)),a&&removeRunDependency(a)},function(n){if(r)r();else throw'Loading data file "'+t+'" failed.'}),a&&addRunDependency(a)},resizeListeners:[],updateResizeListeners:function(){var t=Module.canvas;Browser.resizeListeners.forEach(function(e){e(t.width,t.height)})},setCanvasSize:function(t,e,r){var o=Module.canvas;Browser.updateCanvasDimensions(o,t,e),r||Browser.updateResizeListeners()},windowedWidth:0,windowedHeight:0,setFullscreenCanvasSize:function(){if(typeof SDL<"u"){var t=HEAPU32[SDL.screen+Runtime.QUANTUM_SIZE*0>>2];t=t|8388608,HEAP32[SDL.screen+Runtime.QUANTUM_SIZE*0>>2]=t}Browser.updateResizeListeners()},setWindowedCanvasSize:function(){if(typeof SDL<"u"){var t=HEAPU32[SDL.screen+Runtime.QUANTUM_SIZE*0>>2];t=t&-8388609,HEAP32[SDL.screen+Runtime.QUANTUM_SIZE*0>>2]=t}Browser.updateResizeListeners()},updateCanvasDimensions:function(t,e,r){e&&r?(t.widthNative=e,t.heightNative=r):(e=t.widthNative,r=t.heightNative);var o=e,a=r;if(Module.forcedAspectRatio&&Module.forcedAspectRatio>0&&(o/a<Module.forcedAspectRatio?o=Math.round(a*Module.forcedAspectRatio):a=Math.round(o/Module.forcedAspectRatio)),(document.fullscreenElement||document.mozFullScreenElement||document.msFullscreenElement||document.webkitFullscreenElement||document.webkitCurrentFullScreenElement)===t.parentNode&&typeof screen<"u"){var n=Math.min(screen.width/o,screen.height/a);o=Math.round(o*n),a=Math.round(a*n)}Browser.resizeCanvas?(t.width!=o&&(t.width=o),t.height!=a&&(t.height=a),typeof t.style<"u"&&(t.style.removeProperty("width"),t.style.removeProperty("height"))):(t.width!=e&&(t.width=e),t.height!=r&&(t.height=r),typeof t.style<"u"&&(o!=e||a!=r?(t.style.setProperty("width",o+"px","important"),t.style.setProperty("height",a+"px","important")):(t.style.removeProperty("width"),t.style.removeProperty("height"))))},wgetRequests:{},nextWgetRequestHandle:0,getNextWgetRequestHandle:function(){var t=Browser.nextWgetRequestHandle;return Browser.nextWgetRequestHandle++,t}},SYSCALLS={varargs:0,get:function(t){SYSCALLS.varargs+=4;var e=HEAP32[SYSCALLS.varargs-4>>2];return e},getStr:function(){var t=Pointer_stringify(SYSCALLS.get());return t},get64:function(){var t=SYSCALLS.get(),e=SYSCALLS.get();return t>=0?assert(e===0):assert(e===-1),t},getZero:function(){assert(SYSCALLS.get()===0)}};function ___syscall6(t,e){SYSCALLS.varargs=e;try{var r=SYSCALLS.getStreamFromFD();return FS.close(r),0}catch(o){return(typeof FS>"u"||!(o instanceof FS.ErrnoError))&&abort(o),-o.errno}}function ___syscall54(t,e){SYSCALLS.varargs=e;try{return 0}catch(r){return(typeof FS>"u"||!(r instanceof FS.ErrnoError))&&abort(r),-r.errno}}function _typeModule(t){var e=[[0,1,"X"],[1,1,"const X"],[128,1,"X *"],[256,1,"X &"],[384,1,"X &&"],[512,1,"std::shared_ptr<X>"],[640,1,"std::unique_ptr<X>"],[5120,1,"std::vector<X>"],[6144,2,"std::array<X, Y>"],[9216,-1,"std::function<X (Y)>"]];function r(p,h,C,I,v,b){if(h==1){var E=I&896;(E==128||E==256||E==384)&&(p="X const")}var F;return b?F=C.replace("X",p).replace("Y",v):F=p.replace("X",C).replace("Y",v),F.replace(/([*&]) (?=[*&])/g,"$1")}function o(p,h,C,I,v){throw new Error(p+" type "+C.replace("X",h+"?")+(I?" with flag "+I:"")+" in "+v)}function a(p,h,C,I,v,b,E,F){b===void 0&&(b="X"),F===void 0&&(F=1);var N=C(p);if(N)return N;var U=I(p),z=U.placeholderFlag,te=e[z];E&&te&&(b=r(E[2],E[0],b,te[0],"?",!0));var le;z==0&&(le="Unbound"),z>=10&&(le="Corrupt"),F>20&&(le="Deeply nested"),le&&o(le,p,b,z,v||"?");var pe=U.paramList[0],ue=a(pe,h,C,I,v,b,te,F+1),ye,ae={flags:te[0],id:p,name:"",paramList:[ue]},Ie=[],Fe="?";switch(U.placeholderFlag){case 1:ye=ue.spec;break;case 2:if((ue.flags&15360)==1024&&ue.spec.ptrSize==1){ae.flags=7168;break}case 3:case 6:case 5:ye=ue.spec,ue.flags&15360;break;case 8:Fe=""+U.paramList[1],ae.paramList.push(U.paramList[1]);break;case 9:for(var g=0,Ee=U.paramList[1];g<Ee.length;g++){var De=Ee[g],ce=a(De,h,C,I,v,b,te,F+1);Ie.push(ce.name),ae.paramList.push(ce)}Fe=Ie.join(", ");break;default:break}if(ae.name=r(te[2],te[0],ue.name,ue.flags,Fe),ye){for(var ne=0,ee=Object.keys(ye);ne<ee.length;ne++){var we=ee[ne];ae[we]=ae[we]||ye[we]}ae.flags|=ye.flags}return n(h,ae)}function n(p,h){var C=h.flags,I=C&896,v=C&15360;return!h.name&&v==1024&&(h.ptrSize==1?h.name=(C&16?"":(C&8?"un":"")+"signed ")+"char":h.name=(C&8?"u":"")+(C&32?"float":"int")+(h.ptrSize*8+"_t")),h.ptrSize==8&&!(C&32)&&(v=64),v==2048&&(I==512||I==640?v=4096:I&&(v=3072)),p(v,h)}var u=function(){function p(h){this.id=h.id,this.name=h.name,this.flags=h.flags,this.spec=h}return p.prototype.toString=function(){return this.name},p}(),A={Type:u,getComplexType:a,makeType:n,structureList:e};return t.output=A,t.output||A}function __nbind_register_type(t,e){var r=_nbind.readAsciiString(e),o={flags:10240,id:t,name:r};_nbind.makeType(_nbind.constructType,o)}function __nbind_register_callback_signature(t,e){var r=_nbind.readTypeIdList(t,e),o=_nbind.callbackSignatureList.length;return _nbind.callbackSignatureList[o]=_nbind.makeJSCaller(r),o}function __extends(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);function o(){this.constructor=t}o.prototype=e.prototype,t.prototype=new o}function __nbind_register_class(t,e,r,o,a,n,u){var A=_nbind.readAsciiString(u),p=_nbind.readPolicyList(e),h=HEAPU32.subarray(t/4,t/4+2),C={flags:2048|(p.Value?2:0),id:h[0],name:A},I=_nbind.makeType(_nbind.constructType,C);I.ptrType=_nbind.getComplexType(h[1],_nbind.constructType,_nbind.getType,_nbind.queryType),I.destroy=_nbind.makeMethodCaller(I.ptrType,{boundID:C.id,flags:0,name:"destroy",num:0,ptr:n,title:I.name+".free",typeList:["void","uint32_t","uint32_t"]}),a&&(I.superIdList=Array.prototype.slice.call(HEAPU32.subarray(r/4,r/4+a)),I.upcastList=Array.prototype.slice.call(HEAPU32.subarray(o/4,o/4+a))),Module[I.name]=I.makeBound(p),_nbind.BindClass.list.push(I)}function _removeAccessorPrefix(t){var e=/^[Gg]et_?([A-Z]?([A-Z]?))/;return t.replace(e,function(r,o,a){return a?o:o.toLowerCase()})}function __nbind_register_function(t,e,r,o,a,n,u,A,p,h){var C=_nbind.getType(t),I=_nbind.readPolicyList(e),v=_nbind.readTypeIdList(r,o),b;if(u==5)b=[{direct:a,name:"__nbindConstructor",ptr:0,title:C.name+" constructor",typeList:["uint32_t"].concat(v.slice(1))},{direct:n,name:"__nbindValueConstructor",ptr:0,title:C.name+" value constructor",typeList:["void","uint32_t"].concat(v.slice(1))}];else{var E=_nbind.readAsciiString(A),F=(C.name&&C.name+".")+E;(u==3||u==4)&&(E=_removeAccessorPrefix(E)),b=[{boundID:t,direct:n,name:E,ptr:a,title:F,typeList:v}]}for(var N=0,U=b;N<U.length;N++){var z=U[N];z.signatureType=u,z.policyTbl=I,z.num=p,z.flags=h,C.addMethod(z)}}function _nbind_value(t,e){_nbind.typeNameTbl[t]||_nbind.throwError("Unknown value type "+t),Module.NBind.bind_value(t,e),_defineHidden(_nbind.typeNameTbl[t].proto.prototype.__nbindValueConstructor)(e.prototype,"__nbindValueConstructor")}Module._nbind_value=_nbind_value;function __nbind_get_value_object(t,e){var r=_nbind.popValue(t);if(!r.fromJS)throw new Error("Object "+r+" has no fromJS function");r.fromJS(function(){r.__nbindValueConstructor.apply(this,Array.prototype.concat.apply([e],arguments))})}function _emscripten_memcpy_big(t,e,r){return HEAPU8.set(HEAPU8.subarray(e,e+r),t),t}function __nbind_register_primitive(t,e,r){var o={flags:1024|r,id:t,ptrSize:e};_nbind.makeType(_nbind.constructType,o)}var cttz_i8=allocate([8,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,4,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,5,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,4,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,6,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,4,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,5,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,4,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,7,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,4,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,5,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,4,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,6,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,4,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,5,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,4,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0],"i8",ALLOC_STATIC);function ___setErrNo(t){return Module.___errno_location&&(HEAP32[Module.___errno_location()>>2]=t),t}function _llvm_stacksave(){var t=_llvm_stacksave;return t.LLVM_SAVEDSTACKS||(t.LLVM_SAVEDSTACKS=[]),t.LLVM_SAVEDSTACKS.push(Runtime.stackSave()),t.LLVM_SAVEDSTACKS.length-1}function ___syscall140(t,e){SYSCALLS.varargs=e;try{var r=SYSCALLS.getStreamFromFD(),o=SYSCALLS.get(),a=SYSCALLS.get(),n=SYSCALLS.get(),u=SYSCALLS.get(),A=a;return FS.llseek(r,A,u),HEAP32[n>>2]=r.position,r.getdents&&A===0&&u===0&&(r.getdents=null),0}catch(p){return(typeof FS>"u"||!(p instanceof FS.ErrnoError))&&abort(p),-p.errno}}function ___syscall146(t,e){SYSCALLS.varargs=e;try{var r=SYSCALLS.get(),o=SYSCALLS.get(),a=SYSCALLS.get(),n=0;___syscall146.buffer||(___syscall146.buffers=[null,[],[]],___syscall146.printChar=function(C,I){var v=___syscall146.buffers[C];assert(v),I===0||I===10?((C===1?Module.print:Module.printErr)(UTF8ArrayToString(v,0)),v.length=0):v.push(I)});for(var u=0;u<a;u++){for(var A=HEAP32[o+u*8>>2],p=HEAP32[o+(u*8+4)>>2],h=0;h<p;h++)___syscall146.printChar(r,HEAPU8[A+h]);n+=p}return n}catch(C){return(typeof FS>"u"||!(C instanceof FS.ErrnoError))&&abort(C),-C.errno}}function __nbind_finish(){for(var t=0,e=_nbind.BindClass.list;t<e.length;t++){var r=e[t];r.finish()}}var ___dso_handle=STATICTOP;STATICTOP+=16,function(_nbind){var typeIdTbl={};_nbind.typeNameTbl={};var Pool=function(){function t(){}return t.lalloc=function(e){e=e+7&-8;var r=HEAPU32[t.usedPtr];if(e>t.pageSize/2||e>t.pageSize-r){var o=_nbind.typeNameTbl.NBind.proto;return o.lalloc(e)}else return HEAPU32[t.usedPtr]=r+e,t.rootPtr+r},t.lreset=function(e,r){var o=HEAPU32[t.pagePtr];if(o){var a=_nbind.typeNameTbl.NBind.proto;a.lreset(e,r)}else HEAPU32[t.usedPtr]=e},t}();_nbind.Pool=Pool;function constructType(t,e){var r=t==10240?_nbind.makeTypeNameTbl[e.name]||_nbind.BindType:_nbind.makeTypeKindTbl[t],o=new r(e);return typeIdTbl[e.id]=o,_nbind.typeNameTbl[e.name]=o,o}_nbind.constructType=constructType;function getType(t){return typeIdTbl[t]}_nbind.getType=getType;function queryType(t){var e=HEAPU8[t],r=_nbind.structureList[e][1];t/=4,r<0&&(++t,r=HEAPU32[t]+1);var o=Array.prototype.slice.call(HEAPU32.subarray(t+1,t+1+r));return e==9&&(o=[o[0],o.slice(1)]),{paramList:o,placeholderFlag:e}}_nbind.queryType=queryType;function getTypes(t,e){return t.map(function(r){return typeof r=="number"?_nbind.getComplexType(r,constructType,getType,queryType,e):_nbind.typeNameTbl[r]})}_nbind.getTypes=getTypes;function readTypeIdList(t,e){return Array.prototype.slice.call(HEAPU32,t/4,t/4+e)}_nbind.readTypeIdList=readTypeIdList;function readAsciiString(t){for(var e=t;HEAPU8[e++];);return String.fromCharCode.apply("",HEAPU8.subarray(t,e-1))}_nbind.readAsciiString=readAsciiString;function readPolicyList(t){var e={};if(t)for(;;){var r=HEAPU32[t/4];if(!r)break;e[readAsciiString(r)]=!0,t+=4}return e}_nbind.readPolicyList=readPolicyList;function getDynCall(t,e){var r={float32_t:"d",float64_t:"d",int64_t:"d",uint64_t:"d",void:"v"},o=t.map(function(n){return r[n.name]||"i"}).join(""),a=Module["dynCall_"+o];if(!a)throw new Error("dynCall_"+o+" not found for "+e+"("+t.map(function(n){return n.name}).join(", ")+")");return a}_nbind.getDynCall=getDynCall;function addMethod(t,e,r,o){var a=t[e];t.hasOwnProperty(e)&&a?((a.arity||a.arity===0)&&(a=_nbind.makeOverloader(a,a.arity),t[e]=a),a.addMethod(r,o)):(r.arity=o,t[e]=r)}_nbind.addMethod=addMethod;function throwError(t){throw new Error(t)}_nbind.throwError=throwError,_nbind.bigEndian=!1,_a=_typeModule(_typeModule),_nbind.Type=_a.Type,_nbind.makeType=_a.makeType,_nbind.getComplexType=_a.getComplexType,_nbind.structureList=_a.structureList;var BindType=function(t){__extends(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.heap=HEAPU32,r.ptrSize=4,r}return e.prototype.needsWireRead=function(r){return!!this.wireRead||!!this.makeWireRead},e.prototype.needsWireWrite=function(r){return!!this.wireWrite||!!this.makeWireWrite},e}(_nbind.Type);_nbind.BindType=BindType;var PrimitiveType=function(t){__extends(e,t);function e(r){var o=t.call(this,r)||this,a=r.flags&32?{32:HEAPF32,64:HEAPF64}:r.flags&8?{8:HEAPU8,16:HEAPU16,32:HEAPU32}:{8:HEAP8,16:HEAP16,32:HEAP32};return o.heap=a[r.ptrSize*8],o.ptrSize=r.ptrSize,o}return e.prototype.needsWireWrite=function(r){return!!r&&!!r.Strict},e.prototype.makeWireWrite=function(r,o){return o&&o.Strict&&function(a){if(typeof a=="number")return a;throw new Error("Type mismatch")}},e}(BindType);_nbind.PrimitiveType=PrimitiveType;function pushCString(t,e){if(t==null){if(e&&e.Nullable)return 0;throw new Error("Type mismatch")}if(e&&e.Strict){if(typeof t!="string")throw new Error("Type mismatch")}else t=t.toString();var r=Module.lengthBytesUTF8(t)+1,o=_nbind.Pool.lalloc(r);return Module.stringToUTF8Array(t,HEAPU8,o,r),o}_nbind.pushCString=pushCString;function popCString(t){return t===0?null:Module.Pointer_stringify(t)}_nbind.popCString=popCString;var CStringType=function(t){__extends(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.wireRead=popCString,r.wireWrite=pushCString,r.readResources=[_nbind.resources.pool],r.writeResources=[_nbind.resources.pool],r}return e.prototype.makeWireWrite=function(r,o){return function(a){return pushCString(a,o)}},e}(BindType);_nbind.CStringType=CStringType;var BooleanType=function(t){__extends(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.wireRead=function(o){return!!o},r}return e.prototype.needsWireWrite=function(r){return!!r&&!!r.Strict},e.prototype.makeWireRead=function(r){return"!!("+r+")"},e.prototype.makeWireWrite=function(r,o){return o&&o.Strict&&function(a){if(typeof a=="boolean")return a;throw new Error("Type mismatch")}||r},e}(BindType);_nbind.BooleanType=BooleanType;var Wrapper=function(){function t(){}return t.prototype.persist=function(){this.__nbindState|=1},t}();_nbind.Wrapper=Wrapper;function makeBound(t,e){var r=function(o){__extends(a,o);function a(n,u,A,p){var h=o.call(this)||this;if(!(h instanceof a))return new(Function.prototype.bind.apply(a,Array.prototype.concat.apply([null],arguments)));var C=u,I=A,v=p;if(n!==_nbind.ptrMarker){var b=h.__nbindConstructor.apply(h,arguments);C=4608,v=HEAPU32[b/4],I=HEAPU32[b/4+1]}var E={configurable:!0,enumerable:!1,value:null,writable:!1},F={__nbindFlags:C,__nbindPtr:I};v&&(F.__nbindShared=v,_nbind.mark(h));for(var N=0,U=Object.keys(F);N<U.length;N++){var z=U[N];E.value=F[z],Object.defineProperty(h,z,E)}return _defineHidden(0)(h,"__nbindState"),h}return a.prototype.free=function(){e.destroy.call(this,this.__nbindShared,this.__nbindFlags),this.__nbindState|=2,disableMember(this,"__nbindShared"),disableMember(this,"__nbindPtr")},a}(Wrapper);return __decorate([_defineHidden()],r.prototype,"__nbindConstructor",void 0),__decorate([_defineHidden()],r.prototype,"__nbindValueConstructor",void 0),__decorate([_defineHidden(t)],r.prototype,"__nbindPolicies",void 0),r}_nbind.makeBound=makeBound;function disableMember(t,e){function r(){throw new Error("Accessing deleted object")}Object.defineProperty(t,e,{configurable:!1,enumerable:!1,get:r,set:r})}_nbind.ptrMarker={};var BindClass=function(t){__extends(e,t);function e(r){var o=t.call(this,r)||this;return o.wireRead=function(a){return _nbind.popValue(a,o.ptrType)},o.wireWrite=function(a){return pushPointer(a,o.ptrType,!0)},o.pendingSuperCount=0,o.ready=!1,o.methodTbl={},r.paramList?(o.classType=r.paramList[0].classType,o.proto=o.classType.proto):o.classType=o,o}return e.prototype.makeBound=function(r){var o=_nbind.makeBound(r,this);return this.proto=o,this.ptrType.proto=o,o},e.prototype.addMethod=function(r){var o=this.methodTbl[r.name]||[];o.push(r),this.methodTbl[r.name]=o},e.prototype.registerMethods=function(r,o){for(var a,n=0,u=Object.keys(r.methodTbl);n<u.length;n++)for(var A=u[n],p=r.methodTbl[A],h=0,C=p;h<C.length;h++){var I=C[h],v=void 0,b=void 0;if(v=this.proto.prototype,!(o&&I.signatureType!=1))switch(I.signatureType){case 1:v=this.proto;case 5:b=_nbind.makeCaller(I),_nbind.addMethod(v,I.name,b,I.typeList.length-1);break;case 4:a=_nbind.makeMethodCaller(r.ptrType,I);break;case 3:Object.defineProperty(v,I.name,{configurable:!0,enumerable:!1,get:_nbind.makeMethodCaller(r.ptrType,I),set:a});break;case 2:b=_nbind.makeMethodCaller(r.ptrType,I),_nbind.addMethod(v,I.name,b,I.typeList.length-1);break;default:break}}},e.prototype.registerSuperMethods=function(r,o,a){if(!a[r.name]){a[r.name]=!0;for(var n=0,u,A=0,p=r.superIdList||[];A<p.length;A++){var h=p[A],C=_nbind.getType(h);n++<o||o<0?u=-1:u=0,this.registerSuperMethods(C,u,a)}this.registerMethods(r,o<0)}},e.prototype.finish=function(){if(this.ready)return this;this.ready=!0,this.superList=(this.superIdList||[]).map(function(a){return _nbind.getType(a).finish()});var r=this.proto;if(this.superList.length){var o=function(){this.constructor=r};o.prototype=this.superList[0].proto.prototype,r.prototype=new o}return r!=Module&&(r.prototype.__nbindType=this),this.registerSuperMethods(this,1,{}),this},e.prototype.upcastStep=function(r,o){if(r==this)return o;for(var a=0;a<this.superList.length;++a){var n=this.superList[a].upcastStep(r,_nbind.callUpcast(this.upcastList[a],o));if(n)return n}return 0},e}(_nbind.BindType);BindClass.list=[],_nbind.BindClass=BindClass;function popPointer(t,e){return t?new e.proto(_nbind.ptrMarker,e.flags,t):null}_nbind.popPointer=popPointer;function pushPointer(t,e,r){if(!(t instanceof _nbind.Wrapper)){if(r)return _nbind.pushValue(t);throw new Error("Type mismatch")}var o=t.__nbindPtr,a=t.__nbindType.classType,n=e.classType;if(t instanceof e.proto)for(;a!=n;)o=_nbind.callUpcast(a.upcastList[0],o),a=a.superList[0];else if(o=a.upcastStep(n,o),!o)throw new Error("Type mismatch");return o}_nbind.pushPointer=pushPointer;function pushMutablePointer(t,e){var r=pushPointer(t,e);if(t.__nbindFlags&1)throw new Error("Passing a const value as a non-const argument");return r}var BindClassPtr=function(t){__extends(e,t);function e(r){var o=t.call(this,r)||this;o.classType=r.paramList[0].classType,o.proto=o.classType.proto;var a=r.flags&1,n=(o.flags&896)==256&&r.flags&2,u=a?pushPointer:pushMutablePointer,A=n?_nbind.popValue:popPointer;return o.makeWireWrite=function(p,h){return h.Nullable?function(C){return C?u(C,o):0}:function(C){return u(C,o)}},o.wireRead=function(p){return A(p,o)},o.wireWrite=function(p){return u(p,o)},o}return e}(_nbind.BindType);_nbind.BindClassPtr=BindClassPtr;function popShared(t,e){var r=HEAPU32[t/4],o=HEAPU32[t/4+1];return o?new e.proto(_nbind.ptrMarker,e.flags,o,r):null}_nbind.popShared=popShared;function pushShared(t,e){if(!(t instanceof e.proto))throw new Error("Type mismatch");return t.__nbindShared}function pushMutableShared(t,e){if(!(t instanceof e.proto))throw new Error("Type mismatch");if(t.__nbindFlags&1)throw new Error("Passing a const value as a non-const argument");return t.__nbindShared}var SharedClassPtr=function(t){__extends(e,t);function e(r){var o=t.call(this,r)||this;o.readResources=[_nbind.resources.pool],o.classType=r.paramList[0].classType,o.proto=o.classType.proto;var a=r.flags&1,n=a?pushShared:pushMutableShared;return o.wireRead=function(u){return popShared(u,o)},o.wireWrite=function(u){return n(u,o)},o}return e}(_nbind.BindType);_nbind.SharedClassPtr=SharedClassPtr,_nbind.externalList=[0];var firstFreeExternal=0,External=function(){function t(e){this.refCount=1,this.data=e}return t.prototype.register=function(){var e=firstFreeExternal;return e?firstFreeExternal=_nbind.externalList[e]:e=_nbind.externalList.length,_nbind.externalList[e]=this,e},t.prototype.reference=function(){++this.refCount},t.prototype.dereference=function(e){--this.refCount==0&&(this.free&&this.free(),_nbind.externalList[e]=firstFreeExternal,firstFreeExternal=e)},t}();_nbind.External=External;function popExternal(t){var e=_nbind.externalList[t];return e.dereference(t),e.data}function pushExternal(t){var e=new External(t);return e.reference(),e.register()}var ExternalType=function(t){__extends(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.wireRead=popExternal,r.wireWrite=pushExternal,r}return e}(_nbind.BindType);_nbind.ExternalType=ExternalType,_nbind.callbackSignatureList=[];var CallbackType=function(t){__extends(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.wireWrite=function(o){return typeof o!="function"&&_nbind.throwError("Type mismatch"),new _nbind.External(o).register()},r}return e}(_nbind.BindType);_nbind.CallbackType=CallbackType,_nbind.valueList=[0];var firstFreeValue=0;function pushValue(t){var e=firstFreeValue;return e?firstFreeValue=_nbind.valueList[e]:e=_nbind.valueList.length,_nbind.valueList[e]=t,e*2+1}_nbind.pushValue=pushValue;function popValue(t,e){if(t||_nbind.throwError("Value type JavaScript class is missing or not registered"),t&1){t>>=1;var r=_nbind.valueList[t];return _nbind.valueList[t]=firstFreeValue,firstFreeValue=t,r}else{if(e)return _nbind.popShared(t,e);throw new Error("Invalid value slot "+t)}}_nbind.popValue=popValue;var valueBase=18446744073709552e3;function push64(t){return typeof t=="number"?t:pushValue(t)*4096+valueBase}function pop64(t){return t<valueBase?t:popValue((t-valueBase)/4096)}var CreateValueType=function(t){__extends(e,t);function e(){return t!==null&&t.apply(this,arguments)||this}return e.prototype.makeWireWrite=function(r){return"(_nbind.pushValue(new "+r+"))"},e}(_nbind.BindType);_nbind.CreateValueType=CreateValueType;var Int64Type=function(t){__extends(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.wireWrite=push64,r.wireRead=pop64,r}return e}(_nbind.BindType);_nbind.Int64Type=Int64Type;function pushArray(t,e){if(!t)return 0;var r=t.length;if((e.size||e.size===0)&&r<e.size)throw new Error("Type mismatch");var o=e.memberType.ptrSize,a=_nbind.Pool.lalloc(4+r*o);HEAPU32[a/4]=r;var n=e.memberType.heap,u=(a+4)/o,A=e.memberType.wireWrite,p=0;if(A)for(;p<r;)n[u++]=A(t[p++]);else for(;p<r;)n[u++]=t[p++];return a}_nbind.pushArray=pushArray;function popArray(t,e){if(t===0)return null;var r=HEAPU32[t/4],o=new Array(r),a=e.memberType.heap;t=(t+4)/e.memberType.ptrSize;var n=e.memberType.wireRead,u=0;if(n)for(;u<r;)o[u++]=n(a[t++]);else for(;u<r;)o[u++]=a[t++];return o}_nbind.popArray=popArray;var ArrayType=function(t){__extends(e,t);function e(r){var o=t.call(this,r)||this;return o.wireRead=function(a){return popArray(a,o)},o.wireWrite=function(a){return pushArray(a,o)},o.readResources=[_nbind.resources.pool],o.writeResources=[_nbind.resources.pool],o.memberType=r.paramList[0],r.paramList[1]&&(o.size=r.paramList[1]),o}return e}(_nbind.BindType);_nbind.ArrayType=ArrayType;function pushString(t,e){if(t==null)if(e&&e.Nullable)t="";else throw new Error("Type mismatch");if(e&&e.Strict){if(typeof t!="string")throw new Error("Type mismatch")}else t=t.toString();var r=Module.lengthBytesUTF8(t),o=_nbind.Pool.lalloc(4+r+1);return HEAPU32[o/4]=r,Module.stringToUTF8Array(t,HEAPU8,o+4,r+1),o}_nbind.pushString=pushString;function popString(t){if(t===0)return null;var e=HEAPU32[t/4];return Module.Pointer_stringify(t+4,e)}_nbind.popString=popString;var StringType=function(t){__extends(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.wireRead=popString,r.wireWrite=pushString,r.readResources=[_nbind.resources.pool],r.writeResources=[_nbind.resources.pool],r}return e.prototype.makeWireWrite=function(r,o){return function(a){return pushString(a,o)}},e}(_nbind.BindType);_nbind.StringType=StringType;function makeArgList(t){return Array.apply(null,Array(t)).map(function(e,r){return"a"+(r+1)})}function anyNeedsWireWrite(t,e){return t.reduce(function(r,o){return r||o.needsWireWrite(e)},!1)}function anyNeedsWireRead(t,e){return t.reduce(function(r,o){return r||!!o.needsWireRead(e)},!1)}function makeWireRead(t,e,r,o){var a=t.length;return r.makeWireRead?r.makeWireRead(o,t,a):r.wireRead?(t[a]=r.wireRead,"(convertParamList["+a+"]("+o+"))"):o}function makeWireWrite(t,e,r,o){var a,n=t.length;return r.makeWireWrite?a=r.makeWireWrite(o,e,t,n):a=r.wireWrite,a?typeof a=="string"?a:(t[n]=a,"(convertParamList["+n+"]("+o+"))"):o}function buildCallerFunction(dynCall,ptrType,ptr,num,policyTbl,needsWireWrite,prefix,returnType,argTypeList,mask,err){var argList=makeArgList(argTypeList.length),convertParamList=[],callExpression=makeWireRead(convertParamList,policyTbl,returnType,"dynCall("+[prefix].concat(argList.map(function(t,e){return makeWireWrite(convertParamList,policyTbl,argTypeList[e],t)})).join(",")+")"),resourceSet=_nbind.listResources([returnType],argTypeList),sourceCode="function("+argList.join(",")+"){"+(mask?"this.__nbindFlags&mask&&err();":"")+resourceSet.makeOpen()+"var r="+callExpression+";"+resourceSet.makeClose()+"return r;}";return eval("("+sourceCode+")")}function buildJSCallerFunction(returnType,argTypeList){var argList=makeArgList(argTypeList.length),convertParamList=[],callExpression=makeWireWrite(convertParamList,null,returnType,"_nbind.externalList[num].data("+argList.map(function(t,e){return makeWireRead(convertParamList,null,argTypeList[e],t)}).join(",")+")"),resourceSet=_nbind.listResources(argTypeList,[returnType]);resourceSet.remove(_nbind.resources.pool);var sourceCode="function("+["dummy","num"].concat(argList).join(",")+"){"+resourceSet.makeOpen()+"var r="+callExpression+";"+resourceSet.makeClose()+"return r;}";return eval("("+sourceCode+")")}_nbind.buildJSCallerFunction=buildJSCallerFunction;function makeJSCaller(t){var e=t.length-1,r=_nbind.getTypes(t,"callback"),o=r[0],a=r.slice(1),n=anyNeedsWireRead(a,null),u=o.needsWireWrite(null);if(!u&&!n)switch(e){case 0:return function(A,p){return _nbind.externalList[p].data()};case 1:return function(A,p,h){return _nbind.externalList[p].data(h)};case 2:return function(A,p,h,C){return _nbind.externalList[p].data(h,C)};case 3:return function(A,p,h,C,I){return _nbind.externalList[p].data(h,C,I)};default:break}return buildJSCallerFunction(o,a)}_nbind.makeJSCaller=makeJSCaller;function makeMethodCaller(t,e){var r=e.typeList.length-1,o=e.typeList.slice(0);o.splice(1,0,"uint32_t",e.boundID);var a=_nbind.getTypes(o,e.title),n=a[0],u=a.slice(3),A=n.needsWireRead(e.policyTbl),p=anyNeedsWireWrite(u,e.policyTbl),h=e.ptr,C=e.num,I=_nbind.getDynCall(a,e.title),v=~e.flags&1;function b(){throw new Error("Calling a non-const method on a const object")}if(!A&&!p)switch(r){case 0:return function(){return this.__nbindFlags&v?b():I(h,C,_nbind.pushPointer(this,t))};case 1:return function(E){return this.__nbindFlags&v?b():I(h,C,_nbind.pushPointer(this,t),E)};case 2:return function(E,F){return this.__nbindFlags&v?b():I(h,C,_nbind.pushPointer(this,t),E,F)};case 3:return function(E,F,N){return this.__nbindFlags&v?b():I(h,C,_nbind.pushPointer(this,t),E,F,N)};default:break}return buildCallerFunction(I,t,h,C,e.policyTbl,p,"ptr,num,pushPointer(this,ptrType)",n,u,v,b)}_nbind.makeMethodCaller=makeMethodCaller;function makeCaller(t){var e=t.typeList.length-1,r=_nbind.getTypes(t.typeList,t.title),o=r[0],a=r.slice(1),n=o.needsWireRead(t.policyTbl),u=anyNeedsWireWrite(a,t.policyTbl),A=t.direct,p=t.ptr;if(t.direct&&!n&&!u){var h=_nbind.getDynCall(r,t.title);switch(e){case 0:return function(){return h(A)};case 1:return function(b){return h(A,b)};case 2:return function(b,E){return h(A,b,E)};case 3:return function(b,E,F){return h(A,b,E,F)};default:break}p=0}var C;if(p){var I=t.typeList.slice(0);I.splice(1,0,"uint32_t"),r=_nbind.getTypes(I,t.title),C="ptr,num"}else p=A,C="ptr";var v=_nbind.getDynCall(r,t.title);return buildCallerFunction(v,null,p,t.num,t.policyTbl,u,C,o,a)}_nbind.makeCaller=makeCaller;function makeOverloader(t,e){var r=[];function o(){return r[arguments.length].apply(this,arguments)}return o.addMethod=function(a,n){r[n]=a},o.addMethod(t,e),o}_nbind.makeOverloader=makeOverloader;var Resource=function(){function t(e,r){var o=this;this.makeOpen=function(){return Object.keys(o.openTbl).join("")},this.makeClose=function(){return Object.keys(o.closeTbl).join("")},this.openTbl={},this.closeTbl={},e&&(this.openTbl[e]=!0),r&&(this.closeTbl[r]=!0)}return t.prototype.add=function(e){for(var r=0,o=Object.keys(e.openTbl);r<o.length;r++){var a=o[r];this.openTbl[a]=!0}for(var n=0,u=Object.keys(e.closeTbl);n<u.length;n++){var a=u[n];this.closeTbl[a]=!0}},t.prototype.remove=function(e){for(var r=0,o=Object.keys(e.openTbl);r<o.length;r++){var a=o[r];delete this.openTbl[a]}for(var n=0,u=Object.keys(e.closeTbl);n<u.length;n++){var a=u[n];delete this.closeTbl[a]}},t}();_nbind.Resource=Resource;function listResources(t,e){for(var r=new Resource,o=0,a=t;o<a.length;o++)for(var n=a[o],u=0,A=n.readResources||[];u<A.length;u++){var p=A[u];r.add(p)}for(var h=0,C=e;h<C.length;h++)for(var n=C[h],I=0,v=n.writeResources||[];I<v.length;I++){var p=v[I];r.add(p)}return r}_nbind.listResources=listResources,_nbind.resources={pool:new Resource("var used=HEAPU32[_nbind.Pool.usedPtr],page=HEAPU32[_nbind.Pool.pagePtr];","_nbind.Pool.lreset(used,page);")};var ExternalBuffer=function(t){__extends(e,t);function e(r,o){var a=t.call(this,r)||this;return a.ptr=o,a}return e.prototype.free=function(){_free(this.ptr)},e}(_nbind.External);function getBuffer(t){return t instanceof ArrayBuffer?new Uint8Array(t):t instanceof DataView?new Uint8Array(t.buffer,t.byteOffset,t.byteLength):t}function pushBuffer(t,e){if(t==null&&e&&e.Nullable&&(t=[]),typeof t!="object")throw new Error("Type mismatch");var r=t,o=r.byteLength||r.length;if(!o&&o!==0&&r.byteLength!==0)throw new Error("Type mismatch");var a=_nbind.Pool.lalloc(8),n=_malloc(o),u=a/4;return HEAPU32[u++]=o,HEAPU32[u++]=n,HEAPU32[u++]=new ExternalBuffer(t,n).register(),HEAPU8.set(getBuffer(t),n),a}var BufferType=function(t){__extends(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.wireWrite=pushBuffer,r.readResources=[_nbind.resources.pool],r.writeResources=[_nbind.resources.pool],r}return e.prototype.makeWireWrite=function(r,o){return function(a){return pushBuffer(a,o)}},e}(_nbind.BindType);_nbind.BufferType=BufferType;function commitBuffer(t,e,r){var o=_nbind.externalList[t].data,a=Buffer;if(typeof Buffer!="function"&&(a=function(){}),!(o instanceof Array)){var n=HEAPU8.subarray(e,e+r);if(o instanceof a){var u=void 0;typeof Buffer.from=="function"&&Buffer.from.length>=3?u=Buffer.from(n):u=new Buffer(n),u.copy(o)}else getBuffer(o).set(n)}}_nbind.commitBuffer=commitBuffer;var dirtyList=[],gcTimer=0;function sweep(){for(var t=0,e=dirtyList;t<e.length;t++){var r=e[t];r.__nbindState&3||r.free()}dirtyList=[],gcTimer=0}_nbind.mark=function(t){};function toggleLightGC(t){t?_nbind.mark=function(e){dirtyList.push(e),gcTimer||(gcTimer=setTimeout(sweep,0))}:_nbind.mark=function(e){}}_nbind.toggleLightGC=toggleLightGC}(_nbind),Module.requestFullScreen=function t(e,r,o){Module.printErr("Module.requestFullScreen is deprecated. Please call Module.requestFullscreen instead."),Module.requestFullScreen=Module.requestFullscreen,Browser.requestFullScreen(e,r,o)},Module.requestFullscreen=function t(e,r,o){Browser.requestFullscreen(e,r,o)},Module.requestAnimationFrame=function t(e){Browser.requestAnimationFrame(e)},Module.setCanvasSize=function t(e,r,o){Browser.setCanvasSize(e,r,o)},Module.pauseMainLoop=function t(){Browser.mainLoop.pause()},Module.resumeMainLoop=function t(){Browser.mainLoop.resume()},Module.getUserMedia=function t(){Browser.getUserMedia()},Module.createContext=function t(e,r,o,a){return Browser.createContext(e,r,o,a)},ENVIRONMENT_IS_NODE?_emscripten_get_now=function(){var e=process.hrtime();return e[0]*1e3+e[1]/1e6}:typeof dateNow<"u"?_emscripten_get_now=dateNow:typeof self=="object"&&self.performance&&typeof self.performance.now=="function"?_emscripten_get_now=function(){return self.performance.now()}:typeof performance=="object"&&typeof performance.now=="function"?_emscripten_get_now=function(){return performance.now()}:_emscripten_get_now=Date.now,__ATEXIT__.push(function(){var t=Module._fflush;t&&t(0);var e=___syscall146.printChar;if(!!e){var r=___syscall146.buffers;r[1].length&&e(1,10),r[2].length&&e(2,10)}}),DYNAMICTOP_PTR=allocate(1,"i32",ALLOC_STATIC),STACK_BASE=STACKTOP=Runtime.alignMemory(STATICTOP),STACK_MAX=STACK_BASE+TOTAL_STACK,DYNAMIC_BASE=Runtime.alignMemory(STACK_MAX),HEAP32[DYNAMICTOP_PTR>>2]=DYNAMIC_BASE,staticSealed=!0;function invoke_viiiii(t,e,r,o,a,n){try{Module.dynCall_viiiii(t,e,r,o,a,n)}catch(u){if(typeof u!="number"&&u!=="longjmp")throw u;Module.setThrew(1,0)}}function invoke_vif(t,e,r){try{Module.dynCall_vif(t,e,r)}catch(o){if(typeof o!="number"&&o!=="longjmp")throw o;Module.setThrew(1,0)}}function invoke_vid(t,e,r){try{Module.dynCall_vid(t,e,r)}catch(o){if(typeof o!="number"&&o!=="longjmp")throw o;Module.setThrew(1,0)}}function invoke_fiff(t,e,r,o){try{return Module.dynCall_fiff(t,e,r,o)}catch(a){if(typeof a!="number"&&a!=="longjmp")throw a;Module.setThrew(1,0)}}function invoke_vi(t,e){try{Module.dynCall_vi(t,e)}catch(r){if(typeof r!="number"&&r!=="longjmp")throw r;Module.setThrew(1,0)}}function invoke_vii(t,e,r){try{Module.dynCall_vii(t,e,r)}catch(o){if(typeof o!="number"&&o!=="longjmp")throw o;Module.setThrew(1,0)}}function invoke_ii(t,e){try{return Module.dynCall_ii(t,e)}catch(r){if(typeof r!="number"&&r!=="longjmp")throw r;Module.setThrew(1,0)}}function invoke_viddi(t,e,r,o,a){try{Module.dynCall_viddi(t,e,r,o,a)}catch(n){if(typeof n!="number"&&n!=="longjmp")throw n;Module.setThrew(1,0)}}function invoke_vidd(t,e,r,o){try{Module.dynCall_vidd(t,e,r,o)}catch(a){if(typeof a!="number"&&a!=="longjmp")throw a;Module.setThrew(1,0)}}function invoke_iiii(t,e,r,o){try{return Module.dynCall_iiii(t,e,r,o)}catch(a){if(typeof a!="number"&&a!=="longjmp")throw a;Module.setThrew(1,0)}}function invoke_diii(t,e,r,o){try{return Module.dynCall_diii(t,e,r,o)}catch(a){if(typeof a!="number"&&a!=="longjmp")throw a;Module.setThrew(1,0)}}function invoke_di(t,e){try{return Module.dynCall_di(t,e)}catch(r){if(typeof r!="number"&&r!=="longjmp")throw r;Module.setThrew(1,0)}}function invoke_iid(t,e,r){try{return Module.dynCall_iid(t,e,r)}catch(o){if(typeof o!="number"&&o!=="longjmp")throw o;Module.setThrew(1,0)}}function invoke_iii(t,e,r){try{return Module.dynCall_iii(t,e,r)}catch(o){if(typeof o!="number"&&o!=="longjmp")throw o;Module.setThrew(1,0)}}function invoke_viiddi(t,e,r,o,a,n){try{Module.dynCall_viiddi(t,e,r,o,a,n)}catch(u){if(typeof u!="number"&&u!=="longjmp")throw u;Module.setThrew(1,0)}}function invoke_viiiiii(t,e,r,o,a,n,u){try{Module.dynCall_viiiiii(t,e,r,o,a,n,u)}catch(A){if(typeof A!="number"&&A!=="longjmp")throw A;Module.setThrew(1,0)}}function invoke_dii(t,e,r){try{return Module.dynCall_dii(t,e,r)}catch(o){if(typeof o!="number"&&o!=="longjmp")throw o;Module.setThrew(1,0)}}function invoke_i(t){try{return Module.dynCall_i(t)}catch(e){if(typeof e!="number"&&e!=="longjmp")throw e;Module.setThrew(1,0)}}function invoke_iiiiii(t,e,r,o,a,n){try{return Module.dynCall_iiiiii(t,e,r,o,a,n)}catch(u){if(typeof u!="number"&&u!=="longjmp")throw u;Module.setThrew(1,0)}}function invoke_viiid(t,e,r,o,a){try{Module.dynCall_viiid(t,e,r,o,a)}catch(n){if(typeof n!="number"&&n!=="longjmp")throw n;Module.setThrew(1,0)}}function invoke_viififi(t,e,r,o,a,n,u){try{Module.dynCall_viififi(t,e,r,o,a,n,u)}catch(A){if(typeof A!="number"&&A!=="longjmp")throw A;Module.setThrew(1,0)}}function invoke_viii(t,e,r,o){try{Module.dynCall_viii(t,e,r,o)}catch(a){if(typeof a!="number"&&a!=="longjmp")throw a;Module.setThrew(1,0)}}function invoke_v(t){try{Module.dynCall_v(t)}catch(e){if(typeof e!="number"&&e!=="longjmp")throw e;Module.setThrew(1,0)}}function invoke_viid(t,e,r,o){try{Module.dynCall_viid(t,e,r,o)}catch(a){if(typeof a!="number"&&a!=="longjmp")throw a;Module.setThrew(1,0)}}function invoke_idd(t,e,r){try{return Module.dynCall_idd(t,e,r)}catch(o){if(typeof o!="number"&&o!=="longjmp")throw o;Module.setThrew(1,0)}}function invoke_viiii(t,e,r,o,a){try{Module.dynCall_viiii(t,e,r,o,a)}catch(n){if(typeof n!="number"&&n!=="longjmp")throw n;Module.setThrew(1,0)}}Module.asmGlobalArg={Math,Int8Array,Int16Array,Int32Array,Uint8Array,Uint16Array,Uint32Array,Float32Array,Float64Array,NaN:NaN,Infinity:1/0},Module.asmLibraryArg={abort,assert,enlargeMemory,getTotalMemory,abortOnCannotGrowMemory,invoke_viiiii,invoke_vif,invoke_vid,invoke_fiff,invoke_vi,invoke_vii,invoke_ii,invoke_viddi,invoke_vidd,invoke_iiii,invoke_diii,invoke_di,invoke_iid,invoke_iii,invoke_viiddi,invoke_viiiiii,invoke_dii,invoke_i,invoke_iiiiii,invoke_viiid,invoke_viififi,invoke_viii,invoke_v,invoke_viid,invoke_idd,invoke_viiii,_emscripten_asm_const_iiiii,_emscripten_asm_const_iiidddddd,_emscripten_asm_const_iiiid,__nbind_reference_external,_emscripten_asm_const_iiiiiiii,_removeAccessorPrefix,_typeModule,__nbind_register_pool,__decorate,_llvm_stackrestore,___cxa_atexit,__extends,__nbind_get_value_object,__ZN8facebook4yoga14YGNodeToStringEPNSt3__212basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEEP6YGNode14YGPrintOptionsj,_emscripten_set_main_loop_timing,__nbind_register_primitive,__nbind_register_type,_emscripten_memcpy_big,__nbind_register_function,___setErrNo,__nbind_register_class,__nbind_finish,_abort,_nbind_value,_llvm_stacksave,___syscall54,_defineHidden,_emscripten_set_main_loop,_emscripten_get_now,__nbind_register_callback_signature,_emscripten_asm_const_iiiiii,__nbind_free_external,_emscripten_asm_const_iiii,_emscripten_asm_const_iiididi,___syscall6,_atexit,___syscall140,___syscall146,DYNAMICTOP_PTR,tempDoublePtr,ABORT,STACKTOP,STACK_MAX,cttz_i8,___dso_handle};var asm=function(t,e,r){var o=new t.Int8Array(r),a=new t.Int16Array(r),n=new t.Int32Array(r),u=new t.Uint8Array(r),A=new t.Uint16Array(r),p=new t.Uint32Array(r),h=new t.Float32Array(r),C=new t.Float64Array(r),I=e.DYNAMICTOP_PTR|0,v=e.tempDoublePtr|0,b=e.ABORT|0,E=e.STACKTOP|0,F=e.STACK_MAX|0,N=e.cttz_i8|0,U=e.___dso_handle|0,z=0,te=0,le=0,pe=0,ue=t.NaN,ye=t.Infinity,ae=0,Ie=0,Fe=0,g=0,Ee=0,De=0,ce=t.Math.floor,ne=t.Math.abs,ee=t.Math.sqrt,we=t.Math.pow,be=t.Math.cos,ht=t.Math.sin,H=t.Math.tan,lt=t.Math.acos,Te=t.Math.asin,ke=t.Math.atan,xe=t.Math.atan2,He=t.Math.exp,Re=t.Math.log,ze=t.Math.ceil,je=t.Math.imul,x=t.Math.min,w=t.Math.max,S=t.Math.clz32,y=t.Math.fround,R=e.abort,J=e.assert,X=e.enlargeMemory,Z=e.getTotalMemory,ie=e.abortOnCannotGrowMemory,Pe=e.invoke_viiiii,Le=e.invoke_vif,ot=e.invoke_vid,dt=e.invoke_fiff,jt=e.invoke_vi,$t=e.invoke_vii,xt=e.invoke_ii,an=e.invoke_viddi,kr=e.invoke_vidd,mr=e.invoke_iiii,xr=e.invoke_diii,Wr=e.invoke_di,Kn=e.invoke_iid,Ns=e.invoke_iii,Ti=e.invoke_viiddi,ps=e.invoke_viiiiii,io=e.invoke_dii,Si=e.invoke_i,Os=e.invoke_iiiiii,so=e.invoke_viiid,cc=e.invoke_viififi,cu=e.invoke_viii,op=e.invoke_v,ap=e.invoke_viid,Ms=e.invoke_idd,Dn=e.invoke_viiii,oo=e._emscripten_asm_const_iiiii,Us=e._emscripten_asm_const_iiidddddd,ml=e._emscripten_asm_const_iiiid,yl=e.__nbind_reference_external,ao=e._emscripten_asm_const_iiiiiiii,Vn=e._removeAccessorPrefix,On=e._typeModule,Li=e.__nbind_register_pool,Mn=e.__decorate,_i=e._llvm_stackrestore,tr=e.___cxa_atexit,Oe=e.__extends,ii=e.__nbind_get_value_object,Ma=e.__ZN8facebook4yoga14YGNodeToStringEPNSt3__212basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEEP6YGNode14YGPrintOptionsj,hr=e._emscripten_set_main_loop_timing,uc=e.__nbind_register_primitive,uu=e.__nbind_register_type,Ac=e._emscripten_memcpy_big,El=e.__nbind_register_function,vA=e.___setErrNo,Au=e.__nbind_register_class,Ce=e.__nbind_finish,Rt=e._abort,fc=e._nbind_value,Hi=e._llvm_stacksave,fu=e.___syscall54,Yt=e._defineHidden,Cl=e._emscripten_set_main_loop,DA=e._emscripten_get_now,lp=e.__nbind_register_callback_signature,pc=e._emscripten_asm_const_iiiiii,PA=e.__nbind_free_external,Qn=e._emscripten_asm_const_iiii,hi=e._emscripten_asm_const_iiididi,hc=e.___syscall6,SA=e._atexit,sa=e.___syscall140,Ni=e.___syscall146,_o=y(0);let Ze=y(0);function lo(s){s=s|0;var l=0;return l=E,E=E+s|0,E=E+15&-16,l|0}function gc(){return E|0}function pu(s){s=s|0,E=s}function ji(s,l){s=s|0,l=l|0,E=s,F=l}function hu(s,l){s=s|0,l=l|0,z||(z=s,te=l)}function xA(s){s=s|0,De=s}function Ua(){return De|0}function dc(){var s=0,l=0;Dr(8104,8,400)|0,Dr(8504,408,540)|0,s=9044,l=s+44|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));o[9088]=0,o[9089]=1,n[2273]=0,n[2274]=948,n[2275]=948,tr(17,8104,U|0)|0}function hs(s){s=s|0,ft(s+948|0)}function Ut(s){return s=y(s),((Du(s)|0)&2147483647)>>>0>2139095040|0}function Fn(s,l,c){s=s|0,l=l|0,c=c|0;e:do if(n[s+(l<<3)+4>>2]|0)s=s+(l<<3)|0;else{if((l|2|0)==3&&n[s+60>>2]|0){s=s+56|0;break}switch(l|0){case 0:case 2:case 4:case 5:{if(n[s+52>>2]|0){s=s+48|0;break e}break}default:}if(n[s+68>>2]|0){s=s+64|0;break}else{s=(l|1|0)==5?948:c;break}}while(0);return s|0}function Ci(s){s=s|0;var l=0;return l=pD(1e3)|0,oa(s,(l|0)!=0,2456),n[2276]=(n[2276]|0)+1,Dr(l|0,8104,1e3)|0,o[s+2>>0]|0&&(n[l+4>>2]=2,n[l+12>>2]=4),n[l+976>>2]=s,l|0}function oa(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;d=E,E=E+16|0,f=d,l||(n[f>>2]=c,mg(s,5,3197,f)),E=d}function co(){return Ci(956)|0}function _s(s){s=s|0;var l=0;return l=Kt(1e3)|0,aa(l,s),oa(n[s+976>>2]|0,1,2456),n[2276]=(n[2276]|0)+1,n[l+944>>2]=0,l|0}function aa(s,l){s=s|0,l=l|0;var c=0;Dr(s|0,l|0,948)|0,Fm(s+948|0,l+948|0),c=s+960|0,s=l+960|0,l=c+40|0;do n[c>>2]=n[s>>2],c=c+4|0,s=s+4|0;while((c|0)<(l|0))}function la(s){s=s|0;var l=0,c=0,f=0,d=0;if(l=s+944|0,c=n[l>>2]|0,c|0&&(Ho(c+948|0,s)|0,n[l>>2]=0),c=wi(s)|0,c|0){l=0;do n[(gs(s,l)|0)+944>>2]=0,l=l+1|0;while((l|0)!=(c|0))}c=s+948|0,f=n[c>>2]|0,d=s+952|0,l=n[d>>2]|0,(l|0)!=(f|0)&&(n[d>>2]=l+(~((l+-4-f|0)>>>2)<<2)),ds(c),hD(s),n[2276]=(n[2276]|0)+-1}function Ho(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0;f=n[s>>2]|0,k=s+4|0,c=n[k>>2]|0,m=c;e:do if((f|0)==(c|0))d=f,B=4;else for(s=f;;){if((n[s>>2]|0)==(l|0)){d=s,B=4;break e}if(s=s+4|0,(s|0)==(c|0)){s=0;break}}while(0);return(B|0)==4&&((d|0)!=(c|0)?(f=d+4|0,s=m-f|0,l=s>>2,l&&(Ow(d|0,f|0,s|0)|0,c=n[k>>2]|0),s=d+(l<<2)|0,(c|0)==(s|0)||(n[k>>2]=c+(~((c+-4-s|0)>>>2)<<2)),s=1):s=0),s|0}function wi(s){return s=s|0,(n[s+952>>2]|0)-(n[s+948>>2]|0)>>2|0}function gs(s,l){s=s|0,l=l|0;var c=0;return c=n[s+948>>2]|0,(n[s+952>>2]|0)-c>>2>>>0>l>>>0?s=n[c+(l<<2)>>2]|0:s=0,s|0}function ds(s){s=s|0;var l=0,c=0,f=0,d=0;f=E,E=E+32|0,l=f,d=n[s>>2]|0,c=(n[s+4>>2]|0)-d|0,((n[s+8>>2]|0)-d|0)>>>0>c>>>0&&(d=c>>2,Cp(l,d,d,s+8|0),wg(s,l),UA(l)),E=f}function ms(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0;M=wi(s)|0;do if(M|0){if((n[(gs(s,0)|0)+944>>2]|0)==(s|0)){if(!(Ho(s+948|0,l)|0))break;Dr(l+400|0,8504,540)|0,n[l+944>>2]=0,Ne(s);break}B=n[(n[s+976>>2]|0)+12>>2]|0,k=s+948|0,Q=(B|0)==0,c=0,m=0;do f=n[(n[k>>2]|0)+(m<<2)>>2]|0,(f|0)==(l|0)?Ne(s):(d=_s(f)|0,n[(n[k>>2]|0)+(c<<2)>>2]=d,n[d+944>>2]=s,Q||TR[B&15](f,d,s,c),c=c+1|0),m=m+1|0;while((m|0)!=(M|0));if(c>>>0<M>>>0){Q=s+948|0,k=s+952|0,B=c,c=n[k>>2]|0;do m=(n[Q>>2]|0)+(B<<2)|0,f=m+4|0,d=c-f|0,l=d>>2,l&&(Ow(m|0,f|0,d|0)|0,c=n[k>>2]|0),d=c,f=m+(l<<2)|0,(d|0)!=(f|0)&&(c=d+(~((d+-4-f|0)>>>2)<<2)|0,n[k>>2]=c),B=B+1|0;while((B|0)!=(M|0))}}while(0)}function Hs(s){s=s|0;var l=0,c=0,f=0,d=0;Un(s,(wi(s)|0)==0,2491),Un(s,(n[s+944>>2]|0)==0,2545),l=s+948|0,c=n[l>>2]|0,f=s+952|0,d=n[f>>2]|0,(d|0)!=(c|0)&&(n[f>>2]=d+(~((d+-4-c|0)>>>2)<<2)),ds(l),l=s+976|0,c=n[l>>2]|0,Dr(s|0,8104,1e3)|0,o[c+2>>0]|0&&(n[s+4>>2]=2,n[s+12>>2]=4),n[l>>2]=c}function Un(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;d=E,E=E+16|0,f=d,l||(n[f>>2]=c,Ao(s,5,3197,f)),E=d}function Pn(){return n[2276]|0}function ys(){var s=0;return s=pD(20)|0,We((s|0)!=0,2592),n[2277]=(n[2277]|0)+1,n[s>>2]=n[239],n[s+4>>2]=n[240],n[s+8>>2]=n[241],n[s+12>>2]=n[242],n[s+16>>2]=n[243],s|0}function We(s,l){s=s|0,l=l|0;var c=0,f=0;f=E,E=E+16|0,c=f,s||(n[c>>2]=l,Ao(0,5,3197,c)),E=f}function tt(s){s=s|0,hD(s),n[2277]=(n[2277]|0)+-1}function It(s,l){s=s|0,l=l|0;var c=0;l?(Un(s,(wi(s)|0)==0,2629),c=1):(c=0,l=0),n[s+964>>2]=l,n[s+988>>2]=c}function nr(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;f=E,E=E+16|0,m=f+8|0,d=f+4|0,B=f,n[d>>2]=l,Un(s,(n[l+944>>2]|0)==0,2709),Un(s,(n[s+964>>2]|0)==0,2763),$(s),l=s+948|0,n[B>>2]=(n[l>>2]|0)+(c<<2),n[m>>2]=n[B>>2],me(l,m,d)|0,n[(n[d>>2]|0)+944>>2]=s,Ne(s),E=f}function $(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0;if(c=wi(s)|0,c|0&&(n[(gs(s,0)|0)+944>>2]|0)!=(s|0)){f=n[(n[s+976>>2]|0)+12>>2]|0,d=s+948|0,m=(f|0)==0,l=0;do B=n[(n[d>>2]|0)+(l<<2)>>2]|0,k=_s(B)|0,n[(n[d>>2]|0)+(l<<2)>>2]=k,n[k+944>>2]=s,m||TR[f&15](B,k,s,l),l=l+1|0;while((l|0)!=(c|0))}}function me(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0,Ge=0,Me=0,Qe=0,et=0,Xe=0;et=E,E=E+64|0,q=et+52|0,k=et+48|0,se=et+28|0,Ge=et+24|0,Me=et+20|0,Qe=et,f=n[s>>2]|0,m=f,l=f+((n[l>>2]|0)-m>>2<<2)|0,f=s+4|0,d=n[f>>2]|0,B=s+8|0;do if(d>>>0<(n[B>>2]|0)>>>0){if((l|0)==(d|0)){n[l>>2]=n[c>>2],n[f>>2]=(n[f>>2]|0)+4;break}_A(s,l,d,l+4|0),l>>>0<=c>>>0&&(c=(n[f>>2]|0)>>>0>c>>>0?c+4|0:c),n[l>>2]=n[c>>2]}else{f=(d-m>>2)+1|0,d=L(s)|0,d>>>0<f>>>0&&Jr(s),O=n[s>>2]|0,M=(n[B>>2]|0)-O|0,m=M>>1,Cp(Qe,M>>2>>>0<d>>>1>>>0?m>>>0<f>>>0?f:m:d,l-O>>2,s+8|0),O=Qe+8|0,f=n[O>>2]|0,m=Qe+12|0,M=n[m>>2]|0,B=M,Q=f;do if((f|0)==(M|0)){if(M=Qe+4|0,f=n[M>>2]|0,Xe=n[Qe>>2]|0,d=Xe,f>>>0<=Xe>>>0){f=B-d>>1,f=(f|0)==0?1:f,Cp(se,f,f>>>2,n[Qe+16>>2]|0),n[Ge>>2]=n[M>>2],n[Me>>2]=n[O>>2],n[k>>2]=n[Ge>>2],n[q>>2]=n[Me>>2],vw(se,k,q),f=n[Qe>>2]|0,n[Qe>>2]=n[se>>2],n[se>>2]=f,f=se+4|0,Xe=n[M>>2]|0,n[M>>2]=n[f>>2],n[f>>2]=Xe,f=se+8|0,Xe=n[O>>2]|0,n[O>>2]=n[f>>2],n[f>>2]=Xe,f=se+12|0,Xe=n[m>>2]|0,n[m>>2]=n[f>>2],n[f>>2]=Xe,UA(se),f=n[O>>2]|0;break}m=f,B=((m-d>>2)+1|0)/-2|0,k=f+(B<<2)|0,d=Q-m|0,m=d>>2,m&&(Ow(k|0,f|0,d|0)|0,f=n[M>>2]|0),Xe=k+(m<<2)|0,n[O>>2]=Xe,n[M>>2]=f+(B<<2),f=Xe}while(0);n[f>>2]=n[c>>2],n[O>>2]=(n[O>>2]|0)+4,l=Ig(s,Qe,l)|0,UA(Qe)}while(0);return E=et,l|0}function Ne(s){s=s|0;var l=0;do{if(l=s+984|0,o[l>>0]|0)break;o[l>>0]=1,h[s+504>>2]=y(ue),s=n[s+944>>2]|0}while((s|0)!=0)}function ft(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-4-f|0)>>>2)<<2)),gt(c))}function pt(s){return s=s|0,n[s+944>>2]|0}function Tt(s){s=s|0,Un(s,(n[s+964>>2]|0)!=0,2832),Ne(s)}function er(s){return s=s|0,(o[s+984>>0]|0)!=0|0}function Zr(s,l){s=s|0,l=l|0,TUe(s,l,400)|0&&(Dr(s|0,l|0,400)|0,Ne(s))}function qi(s){s=s|0;var l=Ze;return l=y(h[s+44>>2]),s=Ut(l)|0,y(s?y(0):l)}function es(s){s=s|0;var l=Ze;return l=y(h[s+48>>2]),Ut(l)|0&&(l=o[(n[s+976>>2]|0)+2>>0]|0?y(1):y(0)),y(l)}function xi(s,l){s=s|0,l=l|0,n[s+980>>2]=l}function jo(s){return s=s|0,n[s+980>>2]|0}function bA(s,l){s=s|0,l=l|0;var c=0;c=s+4|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Ne(s))}function kA(s){return s=s|0,n[s+4>>2]|0}function cp(s,l){s=s|0,l=l|0;var c=0;c=s+8|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Ne(s))}function rg(s){return s=s|0,n[s+8>>2]|0}function gu(s,l){s=s|0,l=l|0;var c=0;c=s+12|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Ne(s))}function ng(s){return s=s|0,n[s+12>>2]|0}function du(s,l){s=s|0,l=l|0;var c=0;c=s+16|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Ne(s))}function uo(s){return s=s|0,n[s+16>>2]|0}function QA(s,l){s=s|0,l=l|0;var c=0;c=s+20|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Ne(s))}function mc(s){return s=s|0,n[s+20>>2]|0}function ca(s,l){s=s|0,l=l|0;var c=0;c=s+24|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Ne(s))}function ig(s){return s=s|0,n[s+24>>2]|0}function yc(s,l){s=s|0,l=l|0;var c=0;c=s+28|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Ne(s))}function Pm(s){return s=s|0,n[s+28>>2]|0}function sg(s,l){s=s|0,l=l|0;var c=0;c=s+32|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Ne(s))}function $n(s){return s=s|0,n[s+32>>2]|0}function up(s,l){s=s|0,l=l|0;var c=0;c=s+36|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Ne(s))}function og(s){return s=s|0,n[s+36>>2]|0}function FA(s,l){s=s|0,l=y(l);var c=0;c=s+40|0,y(h[c>>2])!=l&&(h[c>>2]=l,Ne(s))}function js(s,l){s=s|0,l=y(l);var c=0;c=s+44|0,y(h[c>>2])!=l&&(h[c>>2]=l,Ne(s))}function mu(s,l){s=s|0,l=y(l);var c=0;c=s+48|0,y(h[c>>2])!=l&&(h[c>>2]=l,Ne(s))}function Ha(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=Ut(l)|0,c=(m^1)&1,f=s+52|0,d=s+56|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Ne(s))}function Gi(s,l){s=s|0,l=y(l);var c=0,f=0;f=s+52|0,c=s+56|0,y(h[f>>2])==l&&(n[c>>2]|0)==2||(h[f>>2]=l,f=Ut(l)|0,n[c>>2]=f?3:2,Ne(s))}function ua(s,l){s=s|0,l=l|0;var c=0,f=0;f=l+52|0,c=n[f+4>>2]|0,l=s,n[l>>2]=n[f>>2],n[l+4>>2]=c}function yu(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0,m=0;m=Ut(c)|0,f=(m^1)&1,d=s+132+(l<<3)|0,l=s+132+(l<<3)+4|0,m|y(h[d>>2])==c&&(n[l>>2]|0)==(f|0)||(h[d>>2]=c,n[l>>2]=f,Ne(s))}function Es(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0,m=0;m=Ut(c)|0,f=m?0:2,d=s+132+(l<<3)|0,l=s+132+(l<<3)+4|0,m|y(h[d>>2])==c&&(n[l>>2]|0)==(f|0)||(h[d>>2]=c,n[l>>2]=f,Ne(s))}function Ec(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=l+132+(c<<3)|0,l=n[f+4>>2]|0,c=s,n[c>>2]=n[f>>2],n[c+4>>2]=l}function Cc(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0,m=0;m=Ut(c)|0,f=(m^1)&1,d=s+60+(l<<3)|0,l=s+60+(l<<3)+4|0,m|y(h[d>>2])==c&&(n[l>>2]|0)==(f|0)||(h[d>>2]=c,n[l>>2]=f,Ne(s))}function Y(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0,m=0;m=Ut(c)|0,f=m?0:2,d=s+60+(l<<3)|0,l=s+60+(l<<3)+4|0,m|y(h[d>>2])==c&&(n[l>>2]|0)==(f|0)||(h[d>>2]=c,n[l>>2]=f,Ne(s))}function Dt(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=l+60+(c<<3)|0,l=n[f+4>>2]|0,c=s,n[c>>2]=n[f>>2],n[c+4>>2]=l}function wl(s,l){s=s|0,l=l|0;var c=0;c=s+60+(l<<3)+4|0,(n[c>>2]|0)!=3&&(h[s+60+(l<<3)>>2]=y(ue),n[c>>2]=3,Ne(s))}function bi(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0,m=0;m=Ut(c)|0,f=(m^1)&1,d=s+204+(l<<3)|0,l=s+204+(l<<3)+4|0,m|y(h[d>>2])==c&&(n[l>>2]|0)==(f|0)||(h[d>>2]=c,n[l>>2]=f,Ne(s))}function wc(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0,m=0;m=Ut(c)|0,f=m?0:2,d=s+204+(l<<3)|0,l=s+204+(l<<3)+4|0,m|y(h[d>>2])==c&&(n[l>>2]|0)==(f|0)||(h[d>>2]=c,n[l>>2]=f,Ne(s))}function ct(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=l+204+(c<<3)|0,l=n[f+4>>2]|0,c=s,n[c>>2]=n[f>>2],n[c+4>>2]=l}function Eu(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0,m=0;m=Ut(c)|0,f=(m^1)&1,d=s+276+(l<<3)|0,l=s+276+(l<<3)+4|0,m|y(h[d>>2])==c&&(n[l>>2]|0)==(f|0)||(h[d>>2]=c,n[l>>2]=f,Ne(s))}function ag(s,l){return s=s|0,l=l|0,y(h[s+276+(l<<3)>>2])}function mw(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=Ut(l)|0,c=(m^1)&1,f=s+348|0,d=s+352|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Ne(s))}function RA(s,l){s=s|0,l=y(l);var c=0,f=0;f=s+348|0,c=s+352|0,y(h[f>>2])==l&&(n[c>>2]|0)==2||(h[f>>2]=l,f=Ut(l)|0,n[c>>2]=f?3:2,Ne(s))}function Ap(s){s=s|0;var l=0;l=s+352|0,(n[l>>2]|0)!=3&&(h[s+348>>2]=y(ue),n[l>>2]=3,Ne(s))}function Br(s,l){s=s|0,l=l|0;var c=0,f=0;f=l+348|0,c=n[f+4>>2]|0,l=s,n[l>>2]=n[f>>2],n[l+4>>2]=c}function Cs(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=Ut(l)|0,c=(m^1)&1,f=s+356|0,d=s+360|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Ne(s))}function lg(s,l){s=s|0,l=y(l);var c=0,f=0;f=s+356|0,c=s+360|0,y(h[f>>2])==l&&(n[c>>2]|0)==2||(h[f>>2]=l,f=Ut(l)|0,n[c>>2]=f?3:2,Ne(s))}function cg(s){s=s|0;var l=0;l=s+360|0,(n[l>>2]|0)!=3&&(h[s+356>>2]=y(ue),n[l>>2]=3,Ne(s))}function ug(s,l){s=s|0,l=l|0;var c=0,f=0;f=l+356|0,c=n[f+4>>2]|0,l=s,n[l>>2]=n[f>>2],n[l+4>>2]=c}function fp(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=Ut(l)|0,c=(m^1)&1,f=s+364|0,d=s+368|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Ne(s))}function Ic(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=Ut(l)|0,c=m?0:2,f=s+364|0,d=s+368|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Ne(s))}function Ct(s,l){s=s|0,l=l|0;var c=0,f=0;f=l+364|0,c=n[f+4>>2]|0,l=s,n[l>>2]=n[f>>2],n[l+4>>2]=c}function Sm(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=Ut(l)|0,c=(m^1)&1,f=s+372|0,d=s+376|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Ne(s))}function Ag(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=Ut(l)|0,c=m?0:2,f=s+372|0,d=s+376|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Ne(s))}function fg(s,l){s=s|0,l=l|0;var c=0,f=0;f=l+372|0,c=n[f+4>>2]|0,l=s,n[l>>2]=n[f>>2],n[l+4>>2]=c}function Cu(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=Ut(l)|0,c=(m^1)&1,f=s+380|0,d=s+384|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Ne(s))}function xm(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=Ut(l)|0,c=m?0:2,f=s+380|0,d=s+384|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Ne(s))}function pg(s,l){s=s|0,l=l|0;var c=0,f=0;f=l+380|0,c=n[f+4>>2]|0,l=s,n[l>>2]=n[f>>2],n[l+4>>2]=c}function wu(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=Ut(l)|0,c=(m^1)&1,f=s+388|0,d=s+392|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Ne(s))}function yw(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=Ut(l)|0,c=m?0:2,f=s+388|0,d=s+392|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Ne(s))}function bm(s,l){s=s|0,l=l|0;var c=0,f=0;f=l+388|0,c=n[f+4>>2]|0,l=s,n[l>>2]=n[f>>2],n[l+4>>2]=c}function Aa(s,l){s=s|0,l=y(l);var c=0;c=s+396|0,y(h[c>>2])!=l&&(h[c>>2]=l,Ne(s))}function Bc(s){return s=s|0,y(h[s+396>>2])}function Il(s){return s=s|0,y(h[s+400>>2])}function Iu(s){return s=s|0,y(h[s+404>>2])}function hg(s){return s=s|0,y(h[s+408>>2])}function TA(s){return s=s|0,y(h[s+412>>2])}function pp(s){return s=s|0,y(h[s+416>>2])}function ja(s){return s=s|0,y(h[s+420>>2])}function gg(s,l){switch(s=s|0,l=l|0,Un(s,(l|0)<6,2918),l|0){case 0:{l=(n[s+496>>2]|0)==2?5:4;break}case 2:{l=(n[s+496>>2]|0)==2?4:5;break}default:}return y(h[s+424+(l<<2)>>2])}function hp(s,l){switch(s=s|0,l=l|0,Un(s,(l|0)<6,2918),l|0){case 0:{l=(n[s+496>>2]|0)==2?5:4;break}case 2:{l=(n[s+496>>2]|0)==2?4:5;break}default:}return y(h[s+448+(l<<2)>>2])}function qo(s,l){switch(s=s|0,l=l|0,Un(s,(l|0)<6,2918),l|0){case 0:{l=(n[s+496>>2]|0)==2?5:4;break}case 2:{l=(n[s+496>>2]|0)==2?4:5;break}default:}return y(h[s+472+(l<<2)>>2])}function ws(s,l){s=s|0,l=l|0;var c=0,f=Ze;return c=n[s+4>>2]|0,(c|0)==(n[l+4>>2]|0)?c?(f=y(h[s>>2]),s=y(ne(y(f-y(h[l>>2]))))<y(999999974e-13)):s=1:s=0,s|0}function Ii(s,l){s=y(s),l=y(l);var c=0;return Ut(s)|0?c=Ut(l)|0:c=y(ne(y(s-l)))<y(999999974e-13),c|0}function km(s,l){s=s|0,l=l|0,Qm(s,l)}function Qm(s,l){s=s|0,l=l|0;var c=0,f=0;c=E,E=E+16|0,f=c+4|0,n[f>>2]=0,n[f+4>>2]=0,n[f+8>>2]=0,Ma(f|0,s|0,l|0,0),Ao(s,3,(o[f+11>>0]|0)<0?n[f>>2]|0:f,c),n3e(f),E=c}function Go(s,l,c,f){s=y(s),l=y(l),c=c|0,f=f|0;var d=Ze;s=y(s*l),d=y(xR(s,y(1)));do if(Ii(d,y(0))|0)s=y(s-d);else{if(s=y(s-d),Ii(d,y(1))|0){s=y(s+y(1));break}if(c){s=y(s+y(1));break}f||(d>y(.5)?d=y(1):(f=Ii(d,y(.5))|0,d=y(f?1:0)),s=y(s+d))}while(0);return y(s/l)}function LA(s,l,c,f,d,m,B,k,Q,M,O,q,se){s=s|0,l=y(l),c=c|0,f=y(f),d=d|0,m=y(m),B=B|0,k=y(k),Q=y(Q),M=y(M),O=y(O),q=y(q),se=se|0;var Ge=0,Me=Ze,Qe=Ze,et=Ze,Xe=Ze,at=Ze,Ue=Ze;return Q<y(0)|M<y(0)?se=0:((se|0)!=0&&(Me=y(h[se+4>>2]),Me!=y(0))?(et=y(Go(l,Me,0,0)),Xe=y(Go(f,Me,0,0)),Qe=y(Go(m,Me,0,0)),Me=y(Go(k,Me,0,0))):(Qe=m,et=l,Me=k,Xe=f),(d|0)==(s|0)?Ge=Ii(Qe,et)|0:Ge=0,(B|0)==(c|0)?se=Ii(Me,Xe)|0:se=0,!Ge&&(at=y(l-O),!(gp(s,at,Q)|0))&&!(dp(s,at,d,Q)|0)?Ge=dg(s,at,d,m,Q)|0:Ge=1,!se&&(Ue=y(f-q),!(gp(c,Ue,M)|0))&&!(dp(c,Ue,B,M)|0)?se=dg(c,Ue,B,k,M)|0:se=1,se=Ge&se),se|0}function gp(s,l,c){return s=s|0,l=y(l),c=y(c),(s|0)==1?s=Ii(l,c)|0:s=0,s|0}function dp(s,l,c,f){return s=s|0,l=y(l),c=c|0,f=y(f),(s|0)==2&(c|0)==0?l>=f?s=1:s=Ii(l,f)|0:s=0,s|0}function dg(s,l,c,f,d){return s=s|0,l=y(l),c=c|0,f=y(f),d=y(d),(s|0)==2&(c|0)==2&f>l?d<=l?s=1:s=Ii(l,d)|0:s=0,s|0}function fa(s,l,c,f,d,m,B,k,Q,M,O){s=s|0,l=y(l),c=y(c),f=f|0,d=d|0,m=m|0,B=y(B),k=y(k),Q=Q|0,M=M|0,O=O|0;var q=0,se=0,Ge=0,Me=0,Qe=Ze,et=Ze,Xe=0,at=0,Ue=0,qe=0,Nt=0,Mr=0,or=0,Xt=0,Pr=0,Lr=0,ir=0,bn=Ze,go=Ze,mo=Ze,yo=0,ya=0;ir=E,E=E+160|0,Xt=ir+152|0,or=ir+120|0,Mr=ir+104|0,Ue=ir+72|0,Me=ir+56|0,Nt=ir+8|0,at=ir,qe=(n[2279]|0)+1|0,n[2279]=qe,Pr=s+984|0,(o[Pr>>0]|0)!=0&&(n[s+512>>2]|0)!=(n[2278]|0)?Xe=4:(n[s+516>>2]|0)==(f|0)?Lr=0:Xe=4,(Xe|0)==4&&(n[s+520>>2]=0,n[s+924>>2]=-1,n[s+928>>2]=-1,h[s+932>>2]=y(-1),h[s+936>>2]=y(-1),Lr=1);e:do if(n[s+964>>2]|0)if(Qe=y(ln(s,2,B)),et=y(ln(s,0,B)),q=s+916|0,mo=y(h[q>>2]),go=y(h[s+920>>2]),bn=y(h[s+932>>2]),LA(d,l,m,c,n[s+924>>2]|0,mo,n[s+928>>2]|0,go,bn,y(h[s+936>>2]),Qe,et,O)|0)Xe=22;else if(Ge=n[s+520>>2]|0,!Ge)Xe=21;else for(se=0;;){if(q=s+524+(se*24|0)|0,bn=y(h[q>>2]),go=y(h[s+524+(se*24|0)+4>>2]),mo=y(h[s+524+(se*24|0)+16>>2]),LA(d,l,m,c,n[s+524+(se*24|0)+8>>2]|0,bn,n[s+524+(se*24|0)+12>>2]|0,go,mo,y(h[s+524+(se*24|0)+20>>2]),Qe,et,O)|0){Xe=22;break e}if(se=se+1|0,se>>>0>=Ge>>>0){Xe=21;break}}else{if(Q){if(q=s+916|0,!(Ii(y(h[q>>2]),l)|0)){Xe=21;break}if(!(Ii(y(h[s+920>>2]),c)|0)){Xe=21;break}if((n[s+924>>2]|0)!=(d|0)){Xe=21;break}q=(n[s+928>>2]|0)==(m|0)?q:0,Xe=22;break}if(Ge=n[s+520>>2]|0,!Ge)Xe=21;else for(se=0;;){if(q=s+524+(se*24|0)|0,Ii(y(h[q>>2]),l)|0&&Ii(y(h[s+524+(se*24|0)+4>>2]),c)|0&&(n[s+524+(se*24|0)+8>>2]|0)==(d|0)&&(n[s+524+(se*24|0)+12>>2]|0)==(m|0)){Xe=22;break e}if(se=se+1|0,se>>>0>=Ge>>>0){Xe=21;break}}}while(0);do if((Xe|0)==21)o[11697]|0?(q=0,Xe=28):(q=0,Xe=31);else if((Xe|0)==22){if(se=(o[11697]|0)!=0,!((q|0)!=0&(Lr^1)))if(se){Xe=28;break}else{Xe=31;break}Me=q+16|0,n[s+908>>2]=n[Me>>2],Ge=q+20|0,n[s+912>>2]=n[Ge>>2],(o[11698]|0)==0|se^1||(n[at>>2]=NA(qe)|0,n[at+4>>2]=qe,Ao(s,4,2972,at),se=n[s+972>>2]|0,se|0&&ef[se&127](s),d=qa(d,Q)|0,m=qa(m,Q)|0,ya=+y(h[Me>>2]),yo=+y(h[Ge>>2]),n[Nt>>2]=d,n[Nt+4>>2]=m,C[Nt+8>>3]=+l,C[Nt+16>>3]=+c,C[Nt+24>>3]=ya,C[Nt+32>>3]=yo,n[Nt+40>>2]=M,Ao(s,4,2989,Nt))}while(0);return(Xe|0)==28&&(se=NA(qe)|0,n[Me>>2]=se,n[Me+4>>2]=qe,n[Me+8>>2]=Lr?3047:11699,Ao(s,4,3038,Me),se=n[s+972>>2]|0,se|0&&ef[se&127](s),Nt=qa(d,Q)|0,Xe=qa(m,Q)|0,n[Ue>>2]=Nt,n[Ue+4>>2]=Xe,C[Ue+8>>3]=+l,C[Ue+16>>3]=+c,n[Ue+24>>2]=M,Ao(s,4,3049,Ue),Xe=31),(Xe|0)==31&&(si(s,l,c,f,d,m,B,k,Q,O),o[11697]|0&&(se=n[2279]|0,Nt=NA(se)|0,n[Mr>>2]=Nt,n[Mr+4>>2]=se,n[Mr+8>>2]=Lr?3047:11699,Ao(s,4,3083,Mr),se=n[s+972>>2]|0,se|0&&ef[se&127](s),Nt=qa(d,Q)|0,Mr=qa(m,Q)|0,yo=+y(h[s+908>>2]),ya=+y(h[s+912>>2]),n[or>>2]=Nt,n[or+4>>2]=Mr,C[or+8>>3]=yo,C[or+16>>3]=ya,n[or+24>>2]=M,Ao(s,4,3092,or)),n[s+516>>2]=f,q||(se=s+520|0,q=n[se>>2]|0,(q|0)==16&&(o[11697]|0&&Ao(s,4,3124,Xt),n[se>>2]=0,q=0),Q?q=s+916|0:(n[se>>2]=q+1,q=s+524+(q*24|0)|0),h[q>>2]=l,h[q+4>>2]=c,n[q+8>>2]=d,n[q+12>>2]=m,n[q+16>>2]=n[s+908>>2],n[q+20>>2]=n[s+912>>2],q=0)),Q&&(n[s+416>>2]=n[s+908>>2],n[s+420>>2]=n[s+912>>2],o[s+985>>0]=1,o[Pr>>0]=0),n[2279]=(n[2279]|0)+-1,n[s+512>>2]=n[2278],E=ir,Lr|(q|0)==0|0}function ln(s,l,c){s=s|0,l=l|0,c=y(c);var f=Ze;return f=y(K(s,l,c)),y(f+y(re(s,l,c)))}function Ao(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=E,E=E+16|0,d=m,n[d>>2]=f,s?f=n[s+976>>2]|0:f=0,yg(f,s,l,c,d),E=m}function NA(s){return s=s|0,(s>>>0>60?3201:3201+(60-s)|0)|0}function qa(s,l){s=s|0,l=l|0;var c=0,f=0,d=0;return d=E,E=E+32|0,c=d+12|0,f=d,n[c>>2]=n[254],n[c+4>>2]=n[255],n[c+8>>2]=n[256],n[f>>2]=n[257],n[f+4>>2]=n[258],n[f+8>>2]=n[259],(s|0)>2?s=11699:s=n[(l?f:c)+(s<<2)>>2]|0,E=d,s|0}function si(s,l,c,f,d,m,B,k,Q,M){s=s|0,l=y(l),c=y(c),f=f|0,d=d|0,m=m|0,B=y(B),k=y(k),Q=Q|0,M=M|0;var O=0,q=0,se=0,Ge=0,Me=Ze,Qe=Ze,et=Ze,Xe=Ze,at=Ze,Ue=Ze,qe=Ze,Nt=0,Mr=0,or=0,Xt=Ze,Pr=Ze,Lr=0,ir=Ze,bn=0,go=0,mo=0,yo=0,ya=0,kp=0,Qp=0,xl=0,Fp=0,Fu=0,Ru=0,Rp=0,Tp=0,Lp=0,Xr=0,bl=0,Np=0,bc=0,Op=Ze,Mp=Ze,Tu=Ze,Lu=Ze,kc=Ze,Gs=0,Ja=0,Wo=0,kl=0,rf=0,nf=Ze,Nu=Ze,sf=Ze,of=Ze,Ys=Ze,vs=Ze,Ql=0,Rn=Ze,af=Ze,Eo=Ze,Qc=Ze,Co=Ze,Fc=Ze,lf=0,cf=0,Rc=Ze,Ws=Ze,Fl=0,uf=0,Af=0,ff=0,br=Ze,zn=0,Ds=0,wo=0,Ks=0,Rr=0,ur=0,Rl=0,zt=Ze,pf=0,li=0;Rl=E,E=E+16|0,Gs=Rl+12|0,Ja=Rl+8|0,Wo=Rl+4|0,kl=Rl,Un(s,(d|0)==0|(Ut(l)|0)^1,3326),Un(s,(m|0)==0|(Ut(c)|0)^1,3406),Ds=mt(s,f)|0,n[s+496>>2]=Ds,Rr=fr(2,Ds)|0,ur=fr(0,Ds)|0,h[s+440>>2]=y(K(s,Rr,B)),h[s+444>>2]=y(re(s,Rr,B)),h[s+428>>2]=y(K(s,ur,B)),h[s+436>>2]=y(re(s,ur,B)),h[s+464>>2]=y(Cr(s,Rr)),h[s+468>>2]=y(yn(s,Rr)),h[s+452>>2]=y(Cr(s,ur)),h[s+460>>2]=y(yn(s,ur)),h[s+488>>2]=y(oi(s,Rr,B)),h[s+492>>2]=y(Oi(s,Rr,B)),h[s+476>>2]=y(oi(s,ur,B)),h[s+484>>2]=y(Oi(s,ur,B));do if(n[s+964>>2]|0)Cg(s,l,c,d,m,B,k);else{if(wo=s+948|0,Ks=(n[s+952>>2]|0)-(n[wo>>2]|0)>>2,!Ks){Gv(s,l,c,d,m,B,k);break}if(!Q&&Yv(s,l,c,d,m,B,k)|0)break;$(s),bl=s+508|0,o[bl>>0]=0,Rr=fr(n[s+4>>2]|0,Ds)|0,ur=Cw(Rr,Ds)|0,zn=he(Rr)|0,Np=n[s+8>>2]|0,uf=s+28|0,bc=(n[uf>>2]|0)!=0,Co=zn?B:k,Rc=zn?k:B,Op=y(yp(s,Rr,B)),Mp=y(ww(s,Rr,B)),Me=y(yp(s,ur,B)),Fc=y(En(s,Rr,B)),Ws=y(En(s,ur,B)),or=zn?d:m,Fl=zn?m:d,br=zn?Fc:Ws,at=zn?Ws:Fc,Qc=y(ln(s,2,B)),Xe=y(ln(s,0,B)),Qe=y(y(Gr(s+364|0,B))-br),et=y(y(Gr(s+380|0,B))-br),Ue=y(y(Gr(s+372|0,k))-at),qe=y(y(Gr(s+388|0,k))-at),Tu=zn?Qe:Ue,Lu=zn?et:qe,Qc=y(l-Qc),l=y(Qc-br),Ut(l)|0?br=l:br=y(_n(y(Fg(l,et)),Qe)),af=y(c-Xe),l=y(af-at),Ut(l)|0?Eo=l:Eo=y(_n(y(Fg(l,qe)),Ue)),Qe=zn?br:Eo,Rn=zn?Eo:br;e:do if((or|0)==1)for(f=0,q=0;;){if(O=gs(s,q)|0,!f)y(rs(O))>y(0)&&y(qs(O))>y(0)?f=O:f=0;else if(Rm(O)|0){Ge=0;break e}if(q=q+1|0,q>>>0>=Ks>>>0){Ge=f;break}}else Ge=0;while(0);Nt=Ge+500|0,Mr=Ge+504|0,f=0,O=0,l=y(0),se=0;do{if(q=n[(n[wo>>2]|0)+(se<<2)>>2]|0,(n[q+36>>2]|0)==1)Bu(q),o[q+985>>0]=1,o[q+984>>0]=0;else{Bl(q),Q&&mp(q,mt(q,Ds)|0,Qe,Rn,br);do if((n[q+24>>2]|0)!=1)if((q|0)==(Ge|0)){n[Nt>>2]=n[2278],h[Mr>>2]=y(0);break}else{Tm(s,q,br,d,Eo,br,Eo,m,Ds,M);break}else O|0&&(n[O+960>>2]=q),n[q+960>>2]=0,O=q,f=(f|0)==0?q:f;while(0);vs=y(h[q+504>>2]),l=y(l+y(vs+y(ln(q,Rr,br))))}se=se+1|0}while((se|0)!=(Ks|0));for(mo=l>Qe,Ql=bc&((or|0)==2&mo)?1:or,bn=(Fl|0)==1,ya=bn&(Q^1),kp=(Ql|0)==1,Qp=(Ql|0)==2,xl=976+(Rr<<2)|0,Fp=(Fl|2|0)==2,Lp=bn&(bc^1),Fu=1040+(ur<<2)|0,Ru=1040+(Rr<<2)|0,Rp=976+(ur<<2)|0,Tp=(Fl|0)!=1,mo=bc&((or|0)!=0&mo),go=s+976|0,bn=bn^1,l=Qe,Lr=0,yo=0,vs=y(0),kc=y(0);;){e:do if(Lr>>>0<Ks>>>0)for(Mr=n[wo>>2]|0,se=0,qe=y(0),Ue=y(0),et=y(0),Qe=y(0),q=0,O=0,Ge=Lr;;){if(Nt=n[Mr+(Ge<<2)>>2]|0,(n[Nt+36>>2]|0)!=1&&(n[Nt+940>>2]=yo,(n[Nt+24>>2]|0)!=1)){if(Xe=y(ln(Nt,Rr,br)),Xr=n[xl>>2]|0,c=y(Gr(Nt+380+(Xr<<3)|0,Co)),at=y(h[Nt+504>>2]),c=y(Fg(c,at)),c=y(_n(y(Gr(Nt+364+(Xr<<3)|0,Co)),c)),bc&(se|0)!=0&y(Xe+y(Ue+c))>l){m=se,Xe=qe,or=Ge;break e}Xe=y(Xe+c),c=y(Ue+Xe),Xe=y(qe+Xe),Rm(Nt)|0&&(et=y(et+y(rs(Nt))),Qe=y(Qe-y(at*y(qs(Nt))))),O|0&&(n[O+960>>2]=Nt),n[Nt+960>>2]=0,se=se+1|0,O=Nt,q=(q|0)==0?Nt:q}else Xe=qe,c=Ue;if(Ge=Ge+1|0,Ge>>>0<Ks>>>0)qe=Xe,Ue=c;else{m=se,or=Ge;break}}else m=0,Xe=y(0),et=y(0),Qe=y(0),q=0,or=Lr;while(0);Xr=et>y(0)&et<y(1),Xt=Xr?y(1):et,Xr=Qe>y(0)&Qe<y(1),qe=Xr?y(1):Qe;do if(kp)Xr=51;else if(Xe<Tu&((Ut(Tu)|0)^1))l=Tu,Xr=51;else if(Xe>Lu&((Ut(Lu)|0)^1))l=Lu,Xr=51;else if(o[(n[go>>2]|0)+3>>0]|0)Xr=51;else{if(Xt!=y(0)&&y(rs(s))!=y(0)){Xr=53;break}l=Xe,Xr=53}while(0);if((Xr|0)==51&&(Xr=0,Ut(l)|0?Xr=53:(Pr=y(l-Xe),ir=l)),(Xr|0)==53&&(Xr=0,Xe<y(0)?(Pr=y(-Xe),ir=l):(Pr=y(0),ir=l)),!ya&&(rf=(q|0)==0,!rf)){se=n[xl>>2]|0,Ge=Pr<y(0),at=y(Pr/qe),Nt=Pr>y(0),Ue=y(Pr/Xt),et=y(0),Xe=y(0),l=y(0),O=q;do c=y(Gr(O+380+(se<<3)|0,Co)),Qe=y(Gr(O+364+(se<<3)|0,Co)),Qe=y(Fg(c,y(_n(Qe,y(h[O+504>>2]))))),Ge?(c=y(Qe*y(qs(O))),c!=y(-0)&&(zt=y(Qe-y(at*c)),nf=y(Bi(O,Rr,zt,ir,br)),zt!=nf)&&(et=y(et-y(nf-Qe)),l=y(l+c))):Nt&&(Nu=y(rs(O)),Nu!=y(0))&&(zt=y(Qe+y(Ue*Nu)),sf=y(Bi(O,Rr,zt,ir,br)),zt!=sf)&&(et=y(et-y(sf-Qe)),Xe=y(Xe-Nu)),O=n[O+960>>2]|0;while((O|0)!=0);if(l=y(qe+l),Qe=y(Pr+et),rf)l=y(0);else{at=y(Xt+Xe),Ge=n[xl>>2]|0,Nt=Qe<y(0),Mr=l==y(0),Ue=y(Qe/l),se=Qe>y(0),at=y(Qe/at),l=y(0);do{zt=y(Gr(q+380+(Ge<<3)|0,Co)),et=y(Gr(q+364+(Ge<<3)|0,Co)),et=y(Fg(zt,y(_n(et,y(h[q+504>>2]))))),Nt?(zt=y(et*y(qs(q))),Qe=y(-zt),zt!=y(-0)?(zt=y(Ue*Qe),Qe=y(Bi(q,Rr,y(et+(Mr?Qe:zt)),ir,br))):Qe=et):se&&(of=y(rs(q)),of!=y(0))?Qe=y(Bi(q,Rr,y(et+y(at*of)),ir,br)):Qe=et,l=y(l-y(Qe-et)),Xe=y(ln(q,Rr,br)),c=y(ln(q,ur,br)),Qe=y(Qe+Xe),h[Ja>>2]=Qe,n[kl>>2]=1,et=y(h[q+396>>2]);e:do if(Ut(et)|0){O=Ut(Rn)|0;do if(!O){if(mo|(ts(q,ur,Rn)|0|bn)||(ha(s,q)|0)!=4||(n[(vl(q,ur)|0)+4>>2]|0)==3||(n[(Pc(q,ur)|0)+4>>2]|0)==3)break;h[Gs>>2]=Rn,n[Wo>>2]=1;break e}while(0);if(ts(q,ur,Rn)|0){O=n[q+992+(n[Rp>>2]<<2)>>2]|0,zt=y(c+y(Gr(O,Rn))),h[Gs>>2]=zt,O=Tp&(n[O+4>>2]|0)==2,n[Wo>>2]=((Ut(zt)|0|O)^1)&1;break}else{h[Gs>>2]=Rn,n[Wo>>2]=O?0:2;break}}else zt=y(Qe-Xe),Xt=y(zt/et),zt=y(et*zt),n[Wo>>2]=1,h[Gs>>2]=y(c+(zn?Xt:zt));while(0);yr(q,Rr,ir,br,kl,Ja),yr(q,ur,Rn,br,Wo,Gs);do if(!(ts(q,ur,Rn)|0)&&(ha(s,q)|0)==4){if((n[(vl(q,ur)|0)+4>>2]|0)==3){O=0;break}O=(n[(Pc(q,ur)|0)+4>>2]|0)!=3}else O=0;while(0);zt=y(h[Ja>>2]),Xt=y(h[Gs>>2]),pf=n[kl>>2]|0,li=n[Wo>>2]|0,fa(q,zn?zt:Xt,zn?Xt:zt,Ds,zn?pf:li,zn?li:pf,br,Eo,Q&(O^1),3488,M)|0,o[bl>>0]=o[bl>>0]|o[q+508>>0],q=n[q+960>>2]|0}while((q|0)!=0)}}else l=y(0);if(l=y(Pr+l),li=l<y(0)&1,o[bl>>0]=li|u[bl>>0],Qp&l>y(0)?(O=n[xl>>2]|0,(n[s+364+(O<<3)+4>>2]|0)!=0&&(Ys=y(Gr(s+364+(O<<3)|0,Co)),Ys>=y(0))?Qe=y(_n(y(0),y(Ys-y(ir-l)))):Qe=y(0)):Qe=l,Nt=Lr>>>0<or>>>0,Nt){Ge=n[wo>>2]|0,se=Lr,O=0;do q=n[Ge+(se<<2)>>2]|0,n[q+24>>2]|0||(O=((n[(vl(q,Rr)|0)+4>>2]|0)==3&1)+O|0,O=O+((n[(Pc(q,Rr)|0)+4>>2]|0)==3&1)|0),se=se+1|0;while((se|0)!=(or|0));O?(Xe=y(0),c=y(0)):Xr=101}else Xr=101;e:do if((Xr|0)==101)switch(Xr=0,Np|0){case 1:{O=0,Xe=y(Qe*y(.5)),c=y(0);break e}case 2:{O=0,Xe=Qe,c=y(0);break e}case 3:{if(m>>>0<=1){O=0,Xe=y(0),c=y(0);break e}c=y((m+-1|0)>>>0),O=0,Xe=y(0),c=y(y(_n(Qe,y(0)))/c);break e}case 5:{c=y(Qe/y((m+1|0)>>>0)),O=0,Xe=c;break e}case 4:{c=y(Qe/y(m>>>0)),O=0,Xe=y(c*y(.5));break e}default:{O=0,Xe=y(0),c=y(0);break e}}while(0);if(l=y(Op+Xe),Nt){et=y(Qe/y(O|0)),se=n[wo>>2]|0,q=Lr,Qe=y(0);do{O=n[se+(q<<2)>>2]|0;e:do if((n[O+36>>2]|0)!=1){switch(n[O+24>>2]|0){case 1:{if(gi(O,Rr)|0){if(!Q)break e;zt=y(Or(O,Rr,ir)),zt=y(zt+y(Cr(s,Rr))),zt=y(zt+y(K(O,Rr,br))),h[O+400+(n[Ru>>2]<<2)>>2]=zt;break e}break}case 0:if(li=(n[(vl(O,Rr)|0)+4>>2]|0)==3,zt=y(et+l),l=li?zt:l,Q&&(li=O+400+(n[Ru>>2]<<2)|0,h[li>>2]=y(l+y(h[li>>2]))),li=(n[(Pc(O,Rr)|0)+4>>2]|0)==3,zt=y(et+l),l=li?zt:l,ya){zt=y(c+y(ln(O,Rr,br))),Qe=Rn,l=y(l+y(zt+y(h[O+504>>2])));break e}else{l=y(l+y(c+y(ns(O,Rr,br)))),Qe=y(_n(Qe,y(ns(O,ur,br))));break e}default:}Q&&(zt=y(Xe+y(Cr(s,Rr))),li=O+400+(n[Ru>>2]<<2)|0,h[li>>2]=y(zt+y(h[li>>2])))}while(0);q=q+1|0}while((q|0)!=(or|0))}else Qe=y(0);if(c=y(Mp+l),Fp?Xe=y(y(Bi(s,ur,y(Ws+Qe),Rc,B))-Ws):Xe=Rn,et=y(y(Bi(s,ur,y(Ws+(Lp?Rn:Qe)),Rc,B))-Ws),Nt&Q){q=Lr;do{se=n[(n[wo>>2]|0)+(q<<2)>>2]|0;do if((n[se+36>>2]|0)!=1){if((n[se+24>>2]|0)==1){if(gi(se,ur)|0){if(zt=y(Or(se,ur,Rn)),zt=y(zt+y(Cr(s,ur))),zt=y(zt+y(K(se,ur,br))),O=n[Fu>>2]|0,h[se+400+(O<<2)>>2]=zt,!(Ut(zt)|0))break}else O=n[Fu>>2]|0;zt=y(Cr(s,ur)),h[se+400+(O<<2)>>2]=y(zt+y(K(se,ur,br)));break}O=ha(s,se)|0;do if((O|0)==4){if((n[(vl(se,ur)|0)+4>>2]|0)==3){Xr=139;break}if((n[(Pc(se,ur)|0)+4>>2]|0)==3){Xr=139;break}if(ts(se,ur,Rn)|0){l=Me;break}pf=n[se+908+(n[xl>>2]<<2)>>2]|0,n[Gs>>2]=pf,l=y(h[se+396>>2]),li=Ut(l)|0,Qe=(n[v>>2]=pf,y(h[v>>2])),li?l=et:(Pr=y(ln(se,ur,br)),zt=y(Qe/l),l=y(l*Qe),l=y(Pr+(zn?zt:l))),h[Ja>>2]=l,h[Gs>>2]=y(y(ln(se,Rr,br))+Qe),n[Wo>>2]=1,n[kl>>2]=1,yr(se,Rr,ir,br,Wo,Gs),yr(se,ur,Rn,br,kl,Ja),l=y(h[Gs>>2]),Pr=y(h[Ja>>2]),zt=zn?l:Pr,l=zn?Pr:l,li=((Ut(zt)|0)^1)&1,fa(se,zt,l,Ds,li,((Ut(l)|0)^1)&1,br,Eo,1,3493,M)|0,l=Me}else Xr=139;while(0);e:do if((Xr|0)==139){Xr=0,l=y(Xe-y(ns(se,ur,br)));do if((n[(vl(se,ur)|0)+4>>2]|0)==3){if((n[(Pc(se,ur)|0)+4>>2]|0)!=3)break;l=y(Me+y(_n(y(0),y(l*y(.5)))));break e}while(0);if((n[(Pc(se,ur)|0)+4>>2]|0)==3){l=Me;break}if((n[(vl(se,ur)|0)+4>>2]|0)==3){l=y(Me+y(_n(y(0),l)));break}switch(O|0){case 1:{l=Me;break e}case 2:{l=y(Me+y(l*y(.5)));break e}default:{l=y(Me+l);break e}}}while(0);zt=y(vs+l),li=se+400+(n[Fu>>2]<<2)|0,h[li>>2]=y(zt+y(h[li>>2]))}while(0);q=q+1|0}while((q|0)!=(or|0))}if(vs=y(vs+et),kc=y(_n(kc,c)),m=yo+1|0,or>>>0>=Ks>>>0)break;l=ir,Lr=or,yo=m}do if(Q){if(O=m>>>0>1,!O&&!(Yi(s)|0))break;if(!(Ut(Rn)|0)){l=y(Rn-vs);e:do switch(n[s+12>>2]|0){case 3:{Me=y(Me+l),Ue=y(0);break}case 2:{Me=y(Me+y(l*y(.5))),Ue=y(0);break}case 4:{Rn>vs?Ue=y(l/y(m>>>0)):Ue=y(0);break}case 7:if(Rn>vs){Me=y(Me+y(l/y(m<<1>>>0))),Ue=y(l/y(m>>>0)),Ue=O?Ue:y(0);break e}else{Me=y(Me+y(l*y(.5))),Ue=y(0);break e}case 6:{Ue=y(l/y(yo>>>0)),Ue=Rn>vs&O?Ue:y(0);break}default:Ue=y(0)}while(0);if(m|0)for(Nt=1040+(ur<<2)|0,Mr=976+(ur<<2)|0,Ge=0,q=0;;){e:do if(q>>>0<Ks>>>0)for(Qe=y(0),et=y(0),l=y(0),se=q;;){O=n[(n[wo>>2]|0)+(se<<2)>>2]|0;do if((n[O+36>>2]|0)!=1&&(n[O+24>>2]|0)==0){if((n[O+940>>2]|0)!=(Ge|0))break e;if(Lm(O,ur)|0&&(zt=y(h[O+908+(n[Mr>>2]<<2)>>2]),l=y(_n(l,y(zt+y(ln(O,ur,br)))))),(ha(s,O)|0)!=5)break;Ys=y(Ya(O)),Ys=y(Ys+y(K(O,0,br))),zt=y(h[O+912>>2]),zt=y(y(zt+y(ln(O,0,br)))-Ys),Ys=y(_n(et,Ys)),zt=y(_n(Qe,zt)),Qe=zt,et=Ys,l=y(_n(l,y(Ys+zt)))}while(0);if(O=se+1|0,O>>>0<Ks>>>0)se=O;else{se=O;break}}else et=y(0),l=y(0),se=q;while(0);if(at=y(Ue+l),c=Me,Me=y(Me+at),q>>>0<se>>>0){Xe=y(c+et),O=q;do{q=n[(n[wo>>2]|0)+(O<<2)>>2]|0;e:do if((n[q+36>>2]|0)!=1&&(n[q+24>>2]|0)==0)switch(ha(s,q)|0){case 1:{zt=y(c+y(K(q,ur,br))),h[q+400+(n[Nt>>2]<<2)>>2]=zt;break e}case 3:{zt=y(y(Me-y(re(q,ur,br)))-y(h[q+908+(n[Mr>>2]<<2)>>2])),h[q+400+(n[Nt>>2]<<2)>>2]=zt;break e}case 2:{zt=y(c+y(y(at-y(h[q+908+(n[Mr>>2]<<2)>>2]))*y(.5))),h[q+400+(n[Nt>>2]<<2)>>2]=zt;break e}case 4:{if(zt=y(c+y(K(q,ur,br))),h[q+400+(n[Nt>>2]<<2)>>2]=zt,ts(q,ur,Rn)|0||(zn?(Qe=y(h[q+908>>2]),l=y(Qe+y(ln(q,Rr,br))),et=at):(et=y(h[q+912>>2]),et=y(et+y(ln(q,ur,br))),l=at,Qe=y(h[q+908>>2])),Ii(l,Qe)|0&&Ii(et,y(h[q+912>>2]))|0))break e;fa(q,l,et,Ds,1,1,br,Eo,1,3501,M)|0;break e}case 5:{h[q+404>>2]=y(y(Xe-y(Ya(q)))+y(Or(q,0,Rn)));break e}default:break e}while(0);O=O+1|0}while((O|0)!=(se|0))}if(Ge=Ge+1|0,(Ge|0)==(m|0))break;q=se}}}while(0);if(h[s+908>>2]=y(Bi(s,2,Qc,B,B)),h[s+912>>2]=y(Bi(s,0,af,k,B)),(Ql|0)!=0&&(lf=n[s+32>>2]|0,cf=(Ql|0)==2,!(cf&(lf|0)!=2))?cf&(lf|0)==2&&(l=y(Fc+ir),l=y(_n(y(Fg(l,y(OA(s,Rr,kc,Co)))),Fc)),Xr=198):(l=y(Bi(s,Rr,kc,Co,B)),Xr=198),(Xr|0)==198&&(h[s+908+(n[976+(Rr<<2)>>2]<<2)>>2]=l),(Fl|0)!=0&&(Af=n[s+32>>2]|0,ff=(Fl|0)==2,!(ff&(Af|0)!=2))?ff&(Af|0)==2&&(l=y(Ws+Rn),l=y(_n(y(Fg(l,y(OA(s,ur,y(Ws+vs),Rc)))),Ws)),Xr=204):(l=y(Bi(s,ur,y(Ws+vs),Rc,B)),Xr=204),(Xr|0)==204&&(h[s+908+(n[976+(ur<<2)>>2]<<2)>>2]=l),Q){if((n[uf>>2]|0)==2){q=976+(ur<<2)|0,se=1040+(ur<<2)|0,O=0;do Ge=gs(s,O)|0,n[Ge+24>>2]|0||(pf=n[q>>2]|0,zt=y(h[s+908+(pf<<2)>>2]),li=Ge+400+(n[se>>2]<<2)|0,zt=y(zt-y(h[li>>2])),h[li>>2]=y(zt-y(h[Ge+908+(pf<<2)>>2]))),O=O+1|0;while((O|0)!=(Ks|0))}if(f|0){O=zn?Ql:d;do Nm(s,f,br,O,Eo,Ds,M),f=n[f+960>>2]|0;while((f|0)!=0)}if(O=(Rr|2|0)==3,q=(ur|2|0)==3,O|q){f=0;do se=n[(n[wo>>2]|0)+(f<<2)>>2]|0,(n[se+36>>2]|0)!=1&&(O&&Ep(s,se,Rr),q&&Ep(s,se,ur)),f=f+1|0;while((f|0)!=(Ks|0))}}}while(0);E=Rl}function pa(s,l){s=s|0,l=y(l);var c=0;oa(s,l>=y(0),3147),c=l==y(0),h[s+4>>2]=c?y(0):l}function vc(s,l,c,f){s=s|0,l=y(l),c=y(c),f=f|0;var d=Ze,m=Ze,B=0,k=0,Q=0;n[2278]=(n[2278]|0)+1,Bl(s),ts(s,2,l)|0?(d=y(Gr(n[s+992>>2]|0,l)),Q=1,d=y(d+y(ln(s,2,l)))):(d=y(Gr(s+380|0,l)),d>=y(0)?Q=2:(Q=((Ut(l)|0)^1)&1,d=l)),ts(s,0,c)|0?(m=y(Gr(n[s+996>>2]|0,c)),k=1,m=y(m+y(ln(s,0,l)))):(m=y(Gr(s+388|0,c)),m>=y(0)?k=2:(k=((Ut(c)|0)^1)&1,m=c)),B=s+976|0,fa(s,d,m,f,Q,k,l,c,1,3189,n[B>>2]|0)|0&&(mp(s,n[s+496>>2]|0,l,c,l),Dc(s,y(h[(n[B>>2]|0)+4>>2]),y(0),y(0)),o[11696]|0)&&km(s,7)}function Bl(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;k=E,E=E+32|0,B=k+24|0,m=k+16|0,f=k+8|0,d=k,c=0;do l=s+380+(c<<3)|0,(n[s+380+(c<<3)+4>>2]|0)!=0&&(Q=l,M=n[Q+4>>2]|0,O=f,n[O>>2]=n[Q>>2],n[O+4>>2]=M,O=s+364+(c<<3)|0,M=n[O+4>>2]|0,Q=d,n[Q>>2]=n[O>>2],n[Q+4>>2]=M,n[m>>2]=n[f>>2],n[m+4>>2]=n[f+4>>2],n[B>>2]=n[d>>2],n[B+4>>2]=n[d+4>>2],ws(m,B)|0)||(l=s+348+(c<<3)|0),n[s+992+(c<<2)>>2]=l,c=c+1|0;while((c|0)!=2);E=k}function ts(s,l,c){s=s|0,l=l|0,c=y(c);var f=0;switch(s=n[s+992+(n[976+(l<<2)>>2]<<2)>>2]|0,n[s+4>>2]|0){case 0:case 3:{s=0;break}case 1:{y(h[s>>2])<y(0)?s=0:f=5;break}case 2:{y(h[s>>2])<y(0)?s=0:s=(Ut(c)|0)^1;break}default:f=5}return(f|0)==5&&(s=1),s|0}function Gr(s,l){switch(s=s|0,l=y(l),n[s+4>>2]|0){case 2:{l=y(y(y(h[s>>2])*l)/y(100));break}case 1:{l=y(h[s>>2]);break}default:l=y(ue)}return y(l)}function mp(s,l,c,f,d){s=s|0,l=l|0,c=y(c),f=y(f),d=y(d);var m=0,B=Ze;l=n[s+944>>2]|0?l:1,m=fr(n[s+4>>2]|0,l)|0,l=Cw(m,l)|0,c=y(Om(s,m,c)),f=y(Om(s,l,f)),B=y(c+y(K(s,m,d))),h[s+400+(n[1040+(m<<2)>>2]<<2)>>2]=B,c=y(c+y(re(s,m,d))),h[s+400+(n[1e3+(m<<2)>>2]<<2)>>2]=c,c=y(f+y(K(s,l,d))),h[s+400+(n[1040+(l<<2)>>2]<<2)>>2]=c,d=y(f+y(re(s,l,d))),h[s+400+(n[1e3+(l<<2)>>2]<<2)>>2]=d}function Dc(s,l,c,f){s=s|0,l=y(l),c=y(c),f=y(f);var d=0,m=0,B=Ze,k=Ze,Q=0,M=0,O=Ze,q=0,se=Ze,Ge=Ze,Me=Ze,Qe=Ze;if(l!=y(0)&&(d=s+400|0,Qe=y(h[d>>2]),m=s+404|0,Me=y(h[m>>2]),q=s+416|0,Ge=y(h[q>>2]),M=s+420|0,B=y(h[M>>2]),se=y(Qe+c),O=y(Me+f),f=y(se+Ge),k=y(O+B),Q=(n[s+988>>2]|0)==1,h[d>>2]=y(Go(Qe,l,0,Q)),h[m>>2]=y(Go(Me,l,0,Q)),c=y(xR(y(Ge*l),y(1))),Ii(c,y(0))|0?m=0:m=(Ii(c,y(1))|0)^1,c=y(xR(y(B*l),y(1))),Ii(c,y(0))|0?d=0:d=(Ii(c,y(1))|0)^1,Qe=y(Go(f,l,Q&m,Q&(m^1))),h[q>>2]=y(Qe-y(Go(se,l,0,Q))),Qe=y(Go(k,l,Q&d,Q&(d^1))),h[M>>2]=y(Qe-y(Go(O,l,0,Q))),m=(n[s+952>>2]|0)-(n[s+948>>2]|0)>>2,m|0)){d=0;do Dc(gs(s,d)|0,l,se,O),d=d+1|0;while((d|0)!=(m|0))}}function Ew(s,l,c,f,d){switch(s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,c|0){case 5:case 0:{s=o7(n[489]|0,f,d)|0;break}default:s=$Ue(f,d)|0}return s|0}function mg(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;d=E,E=E+16|0,m=d,n[m>>2]=f,yg(s,0,l,c,m),E=d}function yg(s,l,c,f,d){if(s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,s=s|0?s:956,S7[n[s+8>>2]&1](s,l,c,f,d)|0,(c|0)==5)Rt();else return}function Ga(s,l,c){s=s|0,l=l|0,c=c|0,o[s+l>>0]=c&1}function Fm(s,l){s=s|0,l=l|0;var c=0,f=0;n[s>>2]=0,n[s+4>>2]=0,n[s+8>>2]=0,c=l+4|0,f=(n[c>>2]|0)-(n[l>>2]|0)>>2,f|0&&(Eg(s,f),Qt(s,n[l>>2]|0,n[c>>2]|0,f))}function Eg(s,l){s=s|0,l=l|0;var c=0;if((L(s)|0)>>>0<l>>>0&&Jr(s),l>>>0>1073741823)Rt();else{c=Kt(l<<2)|0,n[s+4>>2]=c,n[s>>2]=c,n[s+8>>2]=c+(l<<2);return}}function Qt(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,f=s+4|0,s=c-l|0,(s|0)>0&&(Dr(n[f>>2]|0,l|0,s|0)|0,n[f>>2]=(n[f>>2]|0)+(s>>>2<<2))}function L(s){return s=s|0,1073741823}function K(s,l,c){return s=s|0,l=l|0,c=y(c),he(l)|0&&(n[s+96>>2]|0)!=0?s=s+92|0:s=Fn(s+60|0,n[1040+(l<<2)>>2]|0,992)|0,y(Je(s,c))}function re(s,l,c){return s=s|0,l=l|0,c=y(c),he(l)|0&&(n[s+104>>2]|0)!=0?s=s+100|0:s=Fn(s+60|0,n[1e3+(l<<2)>>2]|0,992)|0,y(Je(s,c))}function he(s){return s=s|0,(s|1|0)==3|0}function Je(s,l){return s=s|0,l=y(l),(n[s+4>>2]|0)==3?l=y(0):l=y(Gr(s,l)),y(l)}function mt(s,l){return s=s|0,l=l|0,s=n[s>>2]|0,((s|0)==0?(l|0)>1?l:1:s)|0}function fr(s,l){s=s|0,l=l|0;var c=0;e:do if((l|0)==2){switch(s|0){case 2:{s=3;break e}case 3:break;default:{c=4;break e}}s=2}else c=4;while(0);return s|0}function Cr(s,l){s=s|0,l=l|0;var c=Ze;return he(l)|0&&(n[s+312>>2]|0)!=0&&(c=y(h[s+308>>2]),c>=y(0))||(c=y(_n(y(h[(Fn(s+276|0,n[1040+(l<<2)>>2]|0,992)|0)>>2]),y(0)))),y(c)}function yn(s,l){s=s|0,l=l|0;var c=Ze;return he(l)|0&&(n[s+320>>2]|0)!=0&&(c=y(h[s+316>>2]),c>=y(0))||(c=y(_n(y(h[(Fn(s+276|0,n[1e3+(l<<2)>>2]|0,992)|0)>>2]),y(0)))),y(c)}function oi(s,l,c){s=s|0,l=l|0,c=y(c);var f=Ze;return he(l)|0&&(n[s+240>>2]|0)!=0&&(f=y(Gr(s+236|0,c)),f>=y(0))||(f=y(_n(y(Gr(Fn(s+204|0,n[1040+(l<<2)>>2]|0,992)|0,c)),y(0)))),y(f)}function Oi(s,l,c){s=s|0,l=l|0,c=y(c);var f=Ze;return he(l)|0&&(n[s+248>>2]|0)!=0&&(f=y(Gr(s+244|0,c)),f>=y(0))||(f=y(_n(y(Gr(Fn(s+204|0,n[1e3+(l<<2)>>2]|0,992)|0,c)),y(0)))),y(f)}function Cg(s,l,c,f,d,m,B){s=s|0,l=y(l),c=y(c),f=f|0,d=d|0,m=y(m),B=y(B);var k=Ze,Q=Ze,M=Ze,O=Ze,q=Ze,se=Ze,Ge=0,Me=0,Qe=0;Qe=E,E=E+16|0,Ge=Qe,Me=s+964|0,Un(s,(n[Me>>2]|0)!=0,3519),k=y(En(s,2,l)),Q=y(En(s,0,l)),M=y(ln(s,2,l)),O=y(ln(s,0,l)),Ut(l)|0?q=l:q=y(_n(y(0),y(y(l-M)-k))),Ut(c)|0?se=c:se=y(_n(y(0),y(y(c-O)-Q))),(f|0)==1&(d|0)==1?(h[s+908>>2]=y(Bi(s,2,y(l-M),m,m)),l=y(Bi(s,0,y(c-O),B,m))):(x7[n[Me>>2]&1](Ge,s,q,f,se,d),q=y(k+y(h[Ge>>2])),se=y(l-M),h[s+908>>2]=y(Bi(s,2,(f|2|0)==2?q:se,m,m)),se=y(Q+y(h[Ge+4>>2])),l=y(c-O),l=y(Bi(s,0,(d|2|0)==2?se:l,B,m))),h[s+912>>2]=l,E=Qe}function Gv(s,l,c,f,d,m,B){s=s|0,l=y(l),c=y(c),f=f|0,d=d|0,m=y(m),B=y(B);var k=Ze,Q=Ze,M=Ze,O=Ze;M=y(En(s,2,m)),k=y(En(s,0,m)),O=y(ln(s,2,m)),Q=y(ln(s,0,m)),l=y(l-O),h[s+908>>2]=y(Bi(s,2,(f|2|0)==2?M:l,m,m)),c=y(c-Q),h[s+912>>2]=y(Bi(s,0,(d|2|0)==2?k:c,B,m))}function Yv(s,l,c,f,d,m,B){s=s|0,l=y(l),c=y(c),f=f|0,d=d|0,m=y(m),B=y(B);var k=0,Q=Ze,M=Ze;return k=(f|0)==2,!(l<=y(0)&k)&&!(c<=y(0)&(d|0)==2)&&!((f|0)==1&(d|0)==1)?s=0:(Q=y(ln(s,0,m)),M=y(ln(s,2,m)),k=l<y(0)&k|(Ut(l)|0),l=y(l-M),h[s+908>>2]=y(Bi(s,2,k?y(0):l,m,m)),l=y(c-Q),k=c<y(0)&(d|0)==2|(Ut(c)|0),h[s+912>>2]=y(Bi(s,0,k?y(0):l,B,m)),s=1),s|0}function Cw(s,l){return s=s|0,l=l|0,MA(s)|0?s=fr(2,l)|0:s=0,s|0}function yp(s,l,c){return s=s|0,l=l|0,c=y(c),c=y(oi(s,l,c)),y(c+y(Cr(s,l)))}function ww(s,l,c){return s=s|0,l=l|0,c=y(c),c=y(Oi(s,l,c)),y(c+y(yn(s,l)))}function En(s,l,c){s=s|0,l=l|0,c=y(c);var f=Ze;return f=y(yp(s,l,c)),y(f+y(ww(s,l,c)))}function Rm(s){return s=s|0,n[s+24>>2]|0?s=0:y(rs(s))!=y(0)?s=1:s=y(qs(s))!=y(0),s|0}function rs(s){s=s|0;var l=Ze;if(n[s+944>>2]|0){if(l=y(h[s+44>>2]),Ut(l)|0)return l=y(h[s+40>>2]),s=l>y(0)&((Ut(l)|0)^1),y(s?l:y(0))}else l=y(0);return y(l)}function qs(s){s=s|0;var l=Ze,c=0,f=Ze;do if(n[s+944>>2]|0){if(l=y(h[s+48>>2]),Ut(l)|0){if(c=o[(n[s+976>>2]|0)+2>>0]|0,c<<24>>24==0&&(f=y(h[s+40>>2]),f<y(0)&((Ut(f)|0)^1))){l=y(-f);break}l=c<<24>>24?y(1):y(0)}}else l=y(0);while(0);return y(l)}function Bu(s){s=s|0;var l=0,c=0;if(Jm(s+400|0,0,540)|0,o[s+985>>0]=1,$(s),c=wi(s)|0,c|0){l=s+948|0,s=0;do Bu(n[(n[l>>2]|0)+(s<<2)>>2]|0),s=s+1|0;while((s|0)!=(c|0))}}function Tm(s,l,c,f,d,m,B,k,Q,M){s=s|0,l=l|0,c=y(c),f=f|0,d=y(d),m=y(m),B=y(B),k=k|0,Q=Q|0,M=M|0;var O=0,q=Ze,se=0,Ge=0,Me=Ze,Qe=Ze,et=0,Xe=Ze,at=0,Ue=Ze,qe=0,Nt=0,Mr=0,or=0,Xt=0,Pr=0,Lr=0,ir=0,bn=0,go=0;bn=E,E=E+16|0,Mr=bn+12|0,or=bn+8|0,Xt=bn+4|0,Pr=bn,ir=fr(n[s+4>>2]|0,Q)|0,qe=he(ir)|0,q=y(Gr(Iw(l)|0,qe?m:B)),Nt=ts(l,2,m)|0,Lr=ts(l,0,B)|0;do if(!(Ut(q)|0)&&!(Ut(qe?c:d)|0)){if(O=l+504|0,!(Ut(y(h[O>>2]))|0)&&(!(Bw(n[l+976>>2]|0,0)|0)||(n[l+500>>2]|0)==(n[2278]|0)))break;h[O>>2]=y(_n(q,y(En(l,ir,m))))}else se=7;while(0);do if((se|0)==7){if(at=qe^1,!(at|Nt^1)){B=y(Gr(n[l+992>>2]|0,m)),h[l+504>>2]=y(_n(B,y(En(l,2,m))));break}if(!(qe|Lr^1)){B=y(Gr(n[l+996>>2]|0,B)),h[l+504>>2]=y(_n(B,y(En(l,0,m))));break}h[Mr>>2]=y(ue),h[or>>2]=y(ue),n[Xt>>2]=0,n[Pr>>2]=0,Xe=y(ln(l,2,m)),Ue=y(ln(l,0,m)),Nt?(Me=y(Xe+y(Gr(n[l+992>>2]|0,m))),h[Mr>>2]=Me,n[Xt>>2]=1,Ge=1):(Ge=0,Me=y(ue)),Lr?(q=y(Ue+y(Gr(n[l+996>>2]|0,B))),h[or>>2]=q,n[Pr>>2]=1,O=1):(O=0,q=y(ue)),se=n[s+32>>2]|0,qe&(se|0)==2?se=2:Ut(Me)|0&&!(Ut(c)|0)&&(h[Mr>>2]=c,n[Xt>>2]=2,Ge=2,Me=c),!((se|0)==2&at)&&Ut(q)|0&&!(Ut(d)|0)&&(h[or>>2]=d,n[Pr>>2]=2,O=2,q=d),Qe=y(h[l+396>>2]),et=Ut(Qe)|0;do if(et)se=Ge;else{if((Ge|0)==1&at){h[or>>2]=y(y(Me-Xe)/Qe),n[Pr>>2]=1,O=1,se=1;break}qe&(O|0)==1?(h[Mr>>2]=y(Qe*y(q-Ue)),n[Xt>>2]=1,O=1,se=1):se=Ge}while(0);go=Ut(c)|0,Ge=(ha(s,l)|0)!=4,!(qe|Nt|((f|0)!=1|go)|(Ge|(se|0)==1))&&(h[Mr>>2]=c,n[Xt>>2]=1,!et)&&(h[or>>2]=y(y(c-Xe)/Qe),n[Pr>>2]=1,O=1),!(Lr|at|((k|0)!=1|(Ut(d)|0))|(Ge|(O|0)==1))&&(h[or>>2]=d,n[Pr>>2]=1,!et)&&(h[Mr>>2]=y(Qe*y(d-Ue)),n[Xt>>2]=1),yr(l,2,m,m,Xt,Mr),yr(l,0,B,m,Pr,or),c=y(h[Mr>>2]),d=y(h[or>>2]),fa(l,c,d,Q,n[Xt>>2]|0,n[Pr>>2]|0,m,B,0,3565,M)|0,B=y(h[l+908+(n[976+(ir<<2)>>2]<<2)>>2]),h[l+504>>2]=y(_n(B,y(En(l,ir,m))))}while(0);n[l+500>>2]=n[2278],E=bn}function Bi(s,l,c,f,d){return s=s|0,l=l|0,c=y(c),f=y(f),d=y(d),f=y(OA(s,l,c,f)),y(_n(f,y(En(s,l,d))))}function ha(s,l){return s=s|0,l=l|0,l=l+20|0,l=n[((n[l>>2]|0)==0?s+16|0:l)>>2]|0,(l|0)==5&&MA(n[s+4>>2]|0)|0&&(l=1),l|0}function vl(s,l){return s=s|0,l=l|0,he(l)|0&&(n[s+96>>2]|0)!=0?l=4:l=n[1040+(l<<2)>>2]|0,s+60+(l<<3)|0}function Pc(s,l){return s=s|0,l=l|0,he(l)|0&&(n[s+104>>2]|0)!=0?l=5:l=n[1e3+(l<<2)>>2]|0,s+60+(l<<3)|0}function yr(s,l,c,f,d,m){switch(s=s|0,l=l|0,c=y(c),f=y(f),d=d|0,m=m|0,c=y(Gr(s+380+(n[976+(l<<2)>>2]<<3)|0,c)),c=y(c+y(ln(s,l,f))),n[d>>2]|0){case 2:case 1:{d=Ut(c)|0,f=y(h[m>>2]),h[m>>2]=d|f<c?f:c;break}case 0:{Ut(c)|0||(n[d>>2]=2,h[m>>2]=c);break}default:}}function gi(s,l){return s=s|0,l=l|0,s=s+132|0,he(l)|0&&(n[(Fn(s,4,948)|0)+4>>2]|0)!=0?s=1:s=(n[(Fn(s,n[1040+(l<<2)>>2]|0,948)|0)+4>>2]|0)!=0,s|0}function Or(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0;return s=s+132|0,he(l)|0&&(f=Fn(s,4,948)|0,(n[f+4>>2]|0)!=0)?d=4:(f=Fn(s,n[1040+(l<<2)>>2]|0,948)|0,n[f+4>>2]|0?d=4:c=y(0)),(d|0)==4&&(c=y(Gr(f,c))),y(c)}function ns(s,l,c){s=s|0,l=l|0,c=y(c);var f=Ze;return f=y(h[s+908+(n[976+(l<<2)>>2]<<2)>>2]),f=y(f+y(K(s,l,c))),y(f+y(re(s,l,c)))}function Yi(s){s=s|0;var l=0,c=0,f=0;e:do if(MA(n[s+4>>2]|0)|0)l=0;else if((n[s+16>>2]|0)!=5)if(c=wi(s)|0,!c)l=0;else for(l=0;;){if(f=gs(s,l)|0,(n[f+24>>2]|0)==0&&(n[f+20>>2]|0)==5){l=1;break e}if(l=l+1|0,l>>>0>=c>>>0){l=0;break}}else l=1;while(0);return l|0}function Lm(s,l){s=s|0,l=l|0;var c=Ze;return c=y(h[s+908+(n[976+(l<<2)>>2]<<2)>>2]),c>=y(0)&((Ut(c)|0)^1)|0}function Ya(s){s=s|0;var l=Ze,c=0,f=0,d=0,m=0,B=0,k=0,Q=Ze;if(c=n[s+968>>2]|0,c)Q=y(h[s+908>>2]),l=y(h[s+912>>2]),l=y(B7[c&0](s,Q,l)),Un(s,(Ut(l)|0)^1,3573);else{m=wi(s)|0;do if(m|0){for(c=0,d=0;;){if(f=gs(s,d)|0,n[f+940>>2]|0){B=8;break}if((n[f+24>>2]|0)!=1)if(k=(ha(s,f)|0)==5,k){c=f;break}else c=(c|0)==0?f:c;if(d=d+1|0,d>>>0>=m>>>0){B=8;break}}if((B|0)==8&&!c)break;return l=y(Ya(c)),y(l+y(h[c+404>>2]))}while(0);l=y(h[s+912>>2])}return y(l)}function OA(s,l,c,f){s=s|0,l=l|0,c=y(c),f=y(f);var d=Ze,m=0;return MA(l)|0?(l=1,m=3):he(l)|0?(l=0,m=3):(f=y(ue),d=y(ue)),(m|0)==3&&(d=y(Gr(s+364+(l<<3)|0,f)),f=y(Gr(s+380+(l<<3)|0,f))),m=f<c&(f>=y(0)&((Ut(f)|0)^1)),c=m?f:c,m=d>=y(0)&((Ut(d)|0)^1)&c<d,y(m?d:c)}function Nm(s,l,c,f,d,m,B){s=s|0,l=l|0,c=y(c),f=f|0,d=y(d),m=m|0,B=B|0;var k=Ze,Q=Ze,M=0,O=0,q=Ze,se=Ze,Ge=Ze,Me=0,Qe=0,et=0,Xe=0,at=Ze,Ue=0;et=fr(n[s+4>>2]|0,m)|0,Me=Cw(et,m)|0,Qe=he(et)|0,q=y(ln(l,2,c)),se=y(ln(l,0,c)),ts(l,2,c)|0?k=y(q+y(Gr(n[l+992>>2]|0,c))):gi(l,2)|0&&sr(l,2)|0?(k=y(h[s+908>>2]),Q=y(Cr(s,2)),Q=y(k-y(Q+y(yn(s,2)))),k=y(Or(l,2,c)),k=y(Bi(l,2,y(Q-y(k+y(vu(l,2,c)))),c,c))):k=y(ue),ts(l,0,d)|0?Q=y(se+y(Gr(n[l+996>>2]|0,d))):gi(l,0)|0&&sr(l,0)|0?(Q=y(h[s+912>>2]),at=y(Cr(s,0)),at=y(Q-y(at+y(yn(s,0)))),Q=y(Or(l,0,d)),Q=y(Bi(l,0,y(at-y(Q+y(vu(l,0,d)))),d,c))):Q=y(ue),M=Ut(k)|0,O=Ut(Q)|0;do if(M^O&&(Ge=y(h[l+396>>2]),!(Ut(Ge)|0)))if(M){k=y(q+y(y(Q-se)*Ge));break}else{at=y(se+y(y(k-q)/Ge)),Q=O?at:Q;break}while(0);O=Ut(k)|0,M=Ut(Q)|0,O|M&&(Ue=(O^1)&1,f=c>y(0)&((f|0)!=0&O),k=Qe?k:f?c:k,fa(l,k,Q,m,Qe?Ue:f?2:Ue,O&(M^1)&1,k,Q,0,3623,B)|0,k=y(h[l+908>>2]),k=y(k+y(ln(l,2,c))),Q=y(h[l+912>>2]),Q=y(Q+y(ln(l,0,c)))),fa(l,k,Q,m,1,1,k,Q,1,3635,B)|0,sr(l,et)|0&&!(gi(l,et)|0)?(Ue=n[976+(et<<2)>>2]|0,at=y(h[s+908+(Ue<<2)>>2]),at=y(at-y(h[l+908+(Ue<<2)>>2])),at=y(at-y(yn(s,et))),at=y(at-y(re(l,et,c))),at=y(at-y(vu(l,et,Qe?c:d))),h[l+400+(n[1040+(et<<2)>>2]<<2)>>2]=at):Xe=21;do if((Xe|0)==21){if(!(gi(l,et)|0)&&(n[s+8>>2]|0)==1){Ue=n[976+(et<<2)>>2]|0,at=y(h[s+908+(Ue<<2)>>2]),at=y(y(at-y(h[l+908+(Ue<<2)>>2]))*y(.5)),h[l+400+(n[1040+(et<<2)>>2]<<2)>>2]=at;break}!(gi(l,et)|0)&&(n[s+8>>2]|0)==2&&(Ue=n[976+(et<<2)>>2]|0,at=y(h[s+908+(Ue<<2)>>2]),at=y(at-y(h[l+908+(Ue<<2)>>2])),h[l+400+(n[1040+(et<<2)>>2]<<2)>>2]=at)}while(0);sr(l,Me)|0&&!(gi(l,Me)|0)?(Ue=n[976+(Me<<2)>>2]|0,at=y(h[s+908+(Ue<<2)>>2]),at=y(at-y(h[l+908+(Ue<<2)>>2])),at=y(at-y(yn(s,Me))),at=y(at-y(re(l,Me,c))),at=y(at-y(vu(l,Me,Qe?d:c))),h[l+400+(n[1040+(Me<<2)>>2]<<2)>>2]=at):Xe=30;do if((Xe|0)==30&&!(gi(l,Me)|0)){if((ha(s,l)|0)==2){Ue=n[976+(Me<<2)>>2]|0,at=y(h[s+908+(Ue<<2)>>2]),at=y(y(at-y(h[l+908+(Ue<<2)>>2]))*y(.5)),h[l+400+(n[1040+(Me<<2)>>2]<<2)>>2]=at;break}Ue=(ha(s,l)|0)==3,Ue^(n[s+28>>2]|0)==2&&(Ue=n[976+(Me<<2)>>2]|0,at=y(h[s+908+(Ue<<2)>>2]),at=y(at-y(h[l+908+(Ue<<2)>>2])),h[l+400+(n[1040+(Me<<2)>>2]<<2)>>2]=at)}while(0)}function Ep(s,l,c){s=s|0,l=l|0,c=c|0;var f=Ze,d=0;d=n[976+(c<<2)>>2]|0,f=y(h[l+908+(d<<2)>>2]),f=y(y(h[s+908+(d<<2)>>2])-f),f=y(f-y(h[l+400+(n[1040+(c<<2)>>2]<<2)>>2])),h[l+400+(n[1e3+(c<<2)>>2]<<2)>>2]=f}function MA(s){return s=s|0,(s|1|0)==1|0}function Iw(s){s=s|0;var l=Ze;switch(n[s+56>>2]|0){case 0:case 3:{l=y(h[s+40>>2]),l>y(0)&((Ut(l)|0)^1)?s=o[(n[s+976>>2]|0)+2>>0]|0?1056:992:s=1056;break}default:s=s+52|0}return s|0}function Bw(s,l){return s=s|0,l=l|0,(o[s+l>>0]|0)!=0|0}function sr(s,l){return s=s|0,l=l|0,s=s+132|0,he(l)|0&&(n[(Fn(s,5,948)|0)+4>>2]|0)!=0?s=1:s=(n[(Fn(s,n[1e3+(l<<2)>>2]|0,948)|0)+4>>2]|0)!=0,s|0}function vu(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0;return s=s+132|0,he(l)|0&&(f=Fn(s,5,948)|0,(n[f+4>>2]|0)!=0)?d=4:(f=Fn(s,n[1e3+(l<<2)>>2]|0,948)|0,n[f+4>>2]|0?d=4:c=y(0)),(d|0)==4&&(c=y(Gr(f,c))),y(c)}function Om(s,l,c){return s=s|0,l=l|0,c=y(c),gi(s,l)|0?c=y(Or(s,l,c)):c=y(-y(vu(s,l,c))),y(c)}function Du(s){return s=y(s),h[v>>2]=s,n[v>>2]|0|0}function Cp(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>1073741823)Rt();else{d=Kt(l<<2)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<2)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<2)}function wg(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>2)<<2)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function UA(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-4-l|0)>>>2)<<2)),s=n[s>>2]|0,s|0&>(s)}function _A(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;if(B=s+4|0,k=n[B>>2]|0,d=k-f|0,m=d>>2,s=l+(m<<2)|0,s>>>0<c>>>0){f=k;do n[f>>2]=n[s>>2],s=s+4|0,f=(n[B>>2]|0)+4|0,n[B>>2]=f;while(s>>>0<c>>>0)}m|0&&Ow(k+(0-m<<2)|0,l|0,d|0)|0}function Ig(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0;return k=l+4|0,Q=n[k>>2]|0,d=n[s>>2]|0,B=c,m=B-d|0,f=Q+(0-(m>>2)<<2)|0,n[k>>2]=f,(m|0)>0&&Dr(f|0,d|0,m|0)|0,d=s+4|0,m=l+8|0,f=(n[d>>2]|0)-B|0,(f|0)>0&&(Dr(n[m>>2]|0,c|0,f|0)|0,n[m>>2]=(n[m>>2]|0)+(f>>>2<<2)),B=n[s>>2]|0,n[s>>2]=n[k>>2],n[k>>2]=B,B=n[d>>2]|0,n[d>>2]=n[m>>2],n[m>>2]=B,B=s+8|0,c=l+12|0,s=n[B>>2]|0,n[B>>2]=n[c>>2],n[c>>2]=s,n[l>>2]=n[k>>2],Q|0}function vw(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;if(B=n[l>>2]|0,m=n[c>>2]|0,(B|0)!=(m|0)){d=s+8|0,c=((m+-4-B|0)>>>2)+1|0,s=B,f=n[d>>2]|0;do n[f>>2]=n[s>>2],f=(n[d>>2]|0)+4|0,n[d>>2]=f,s=s+4|0;while((s|0)!=(m|0));n[l>>2]=B+(c<<2)}}function Mm(){dc()}function ga(){var s=0;return s=Kt(4)|0,HA(s),s|0}function HA(s){s=s|0,n[s>>2]=ys()|0}function Sc(s){s=s|0,s|0&&(Bg(s),gt(s))}function Bg(s){s=s|0,tt(n[s>>2]|0)}function Um(s,l,c){s=s|0,l=l|0,c=c|0,Ga(n[s>>2]|0,l,c)}function fo(s,l){s=s|0,l=y(l),pa(n[s>>2]|0,l)}function Wv(s,l){return s=s|0,l=l|0,Bw(n[s>>2]|0,l)|0}function Dw(){var s=0;return s=Kt(8)|0,Kv(s,0),s|0}function Kv(s,l){s=s|0,l=l|0,l?l=Ci(n[l>>2]|0)|0:l=co()|0,n[s>>2]=l,n[s+4>>2]=0,xi(l,s)}function AF(s){s=s|0;var l=0;return l=Kt(8)|0,Kv(l,s),l|0}function Vv(s){s=s|0,s|0&&(Pu(s),gt(s))}function Pu(s){s=s|0;var l=0;la(n[s>>2]|0),l=s+4|0,s=n[l>>2]|0,n[l>>2]=0,s|0&&(jA(s),gt(s))}function jA(s){s=s|0,qA(s)}function qA(s){s=s|0,s=n[s>>2]|0,s|0&&PA(s|0)}function Pw(s){return s=s|0,jo(s)|0}function _m(s){s=s|0;var l=0,c=0;c=s+4|0,l=n[c>>2]|0,n[c>>2]=0,l|0&&(jA(l),gt(l)),Hs(n[s>>2]|0)}function fF(s,l){s=s|0,l=l|0,Zr(n[s>>2]|0,n[l>>2]|0)}function pF(s,l){s=s|0,l=l|0,ca(n[s>>2]|0,l)}function zv(s,l,c){s=s|0,l=l|0,c=+c,yu(n[s>>2]|0,l,y(c))}function Jv(s,l,c){s=s|0,l=l|0,c=+c,Es(n[s>>2]|0,l,y(c))}function Sw(s,l){s=s|0,l=l|0,gu(n[s>>2]|0,l)}function Su(s,l){s=s|0,l=l|0,du(n[s>>2]|0,l)}function hF(s,l){s=s|0,l=l|0,QA(n[s>>2]|0,l)}function gF(s,l){s=s|0,l=l|0,bA(n[s>>2]|0,l)}function wp(s,l){s=s|0,l=l|0,yc(n[s>>2]|0,l)}function dF(s,l){s=s|0,l=l|0,cp(n[s>>2]|0,l)}function Xv(s,l,c){s=s|0,l=l|0,c=+c,Cc(n[s>>2]|0,l,y(c))}function GA(s,l,c){s=s|0,l=l|0,c=+c,Y(n[s>>2]|0,l,y(c))}function mF(s,l){s=s|0,l=l|0,wl(n[s>>2]|0,l)}function yF(s,l){s=s|0,l=l|0,sg(n[s>>2]|0,l)}function Zv(s,l){s=s|0,l=l|0,up(n[s>>2]|0,l)}function xw(s,l){s=s|0,l=+l,FA(n[s>>2]|0,y(l))}function bw(s,l){s=s|0,l=+l,Ha(n[s>>2]|0,y(l))}function EF(s,l){s=s|0,l=+l,Gi(n[s>>2]|0,y(l))}function CF(s,l){s=s|0,l=+l,js(n[s>>2]|0,y(l))}function Dl(s,l){s=s|0,l=+l,mu(n[s>>2]|0,y(l))}function kw(s,l){s=s|0,l=+l,mw(n[s>>2]|0,y(l))}function wF(s,l){s=s|0,l=+l,RA(n[s>>2]|0,y(l))}function YA(s){s=s|0,Ap(n[s>>2]|0)}function Hm(s,l){s=s|0,l=+l,Cs(n[s>>2]|0,y(l))}function xu(s,l){s=s|0,l=+l,lg(n[s>>2]|0,y(l))}function Qw(s){s=s|0,cg(n[s>>2]|0)}function Fw(s,l){s=s|0,l=+l,fp(n[s>>2]|0,y(l))}function IF(s,l){s=s|0,l=+l,Ic(n[s>>2]|0,y(l))}function $v(s,l){s=s|0,l=+l,Sm(n[s>>2]|0,y(l))}function WA(s,l){s=s|0,l=+l,Ag(n[s>>2]|0,y(l))}function eD(s,l){s=s|0,l=+l,Cu(n[s>>2]|0,y(l))}function jm(s,l){s=s|0,l=+l,xm(n[s>>2]|0,y(l))}function tD(s,l){s=s|0,l=+l,wu(n[s>>2]|0,y(l))}function rD(s,l){s=s|0,l=+l,yw(n[s>>2]|0,y(l))}function qm(s,l){s=s|0,l=+l,Aa(n[s>>2]|0,y(l))}function nD(s,l,c){s=s|0,l=l|0,c=+c,Eu(n[s>>2]|0,l,y(c))}function BF(s,l,c){s=s|0,l=l|0,c=+c,bi(n[s>>2]|0,l,y(c))}function P(s,l,c){s=s|0,l=l|0,c=+c,wc(n[s>>2]|0,l,y(c))}function D(s){return s=s|0,ig(n[s>>2]|0)|0}function T(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;f=E,E=E+16|0,d=f,Ec(d,n[l>>2]|0,c),j(s,d),E=f}function j(s,l){s=s|0,l=l|0,W(s,n[l+4>>2]|0,+y(h[l>>2]))}function W(s,l,c){s=s|0,l=l|0,c=+c,n[s>>2]=l,C[s+8>>3]=c}function Ae(s){return s=s|0,ng(n[s>>2]|0)|0}function ve(s){return s=s|0,uo(n[s>>2]|0)|0}function vt(s){return s=s|0,mc(n[s>>2]|0)|0}function wt(s){return s=s|0,kA(n[s>>2]|0)|0}function bt(s){return s=s|0,Pm(n[s>>2]|0)|0}function _r(s){return s=s|0,rg(n[s>>2]|0)|0}function is(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;f=E,E=E+16|0,d=f,Dt(d,n[l>>2]|0,c),j(s,d),E=f}function di(s){return s=s|0,$n(n[s>>2]|0)|0}function po(s){return s=s|0,og(n[s>>2]|0)|0}function KA(s,l){s=s|0,l=l|0;var c=0,f=0;c=E,E=E+16|0,f=c,ua(f,n[l>>2]|0),j(s,f),E=c}function Yo(s){return s=s|0,+ +y(qi(n[s>>2]|0))}function rt(s){return s=s|0,+ +y(es(n[s>>2]|0))}function Ve(s,l){s=s|0,l=l|0;var c=0,f=0;c=E,E=E+16|0,f=c,Br(f,n[l>>2]|0),j(s,f),E=c}function At(s,l){s=s|0,l=l|0;var c=0,f=0;c=E,E=E+16|0,f=c,ug(f,n[l>>2]|0),j(s,f),E=c}function Wt(s,l){s=s|0,l=l|0;var c=0,f=0;c=E,E=E+16|0,f=c,Ct(f,n[l>>2]|0),j(s,f),E=c}function vr(s,l){s=s|0,l=l|0;var c=0,f=0;c=E,E=E+16|0,f=c,fg(f,n[l>>2]|0),j(s,f),E=c}function Sn(s,l){s=s|0,l=l|0;var c=0,f=0;c=E,E=E+16|0,f=c,pg(f,n[l>>2]|0),j(s,f),E=c}function Fr(s,l){s=s|0,l=l|0;var c=0,f=0;c=E,E=E+16|0,f=c,bm(f,n[l>>2]|0),j(s,f),E=c}function xn(s){return s=s|0,+ +y(Bc(n[s>>2]|0))}function ai(s,l){return s=s|0,l=l|0,+ +y(ag(n[s>>2]|0,l))}function en(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;f=E,E=E+16|0,d=f,ct(d,n[l>>2]|0,c),j(s,d),E=f}function ho(s,l,c){s=s|0,l=l|0,c=c|0,nr(n[s>>2]|0,n[l>>2]|0,c)}function vF(s,l){s=s|0,l=l|0,ms(n[s>>2]|0,n[l>>2]|0)}function nve(s){return s=s|0,wi(n[s>>2]|0)|0}function ive(s){return s=s|0,s=pt(n[s>>2]|0)|0,s?s=Pw(s)|0:s=0,s|0}function sve(s,l){return s=s|0,l=l|0,s=gs(n[s>>2]|0,l)|0,s?s=Pw(s)|0:s=0,s|0}function ove(s,l){s=s|0,l=l|0;var c=0,f=0;f=Kt(4)|0,ZG(f,l),c=s+4|0,l=n[c>>2]|0,n[c>>2]=f,l|0&&(jA(l),gt(l)),It(n[s>>2]|0,1)}function ZG(s,l){s=s|0,l=l|0,yve(s,l)}function ave(s,l,c,f,d,m){s=s|0,l=l|0,c=y(c),f=f|0,d=y(d),m=m|0;var B=0,k=0;B=E,E=E+16|0,k=B,lve(k,jo(l)|0,+c,f,+d,m),h[s>>2]=y(+C[k>>3]),h[s+4>>2]=y(+C[k+8>>3]),E=B}function lve(s,l,c,f,d,m){s=s|0,l=l|0,c=+c,f=f|0,d=+d,m=m|0;var B=0,k=0,Q=0,M=0,O=0;B=E,E=E+32|0,O=B+8|0,M=B+20|0,Q=B,k=B+16|0,C[O>>3]=c,n[M>>2]=f,C[Q>>3]=d,n[k>>2]=m,cve(s,n[l+4>>2]|0,O,M,Q,k),E=B}function cve(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0;var B=0,k=0;B=E,E=E+16|0,k=B,Ka(k),l=da(l)|0,uve(s,l,+C[c>>3],n[f>>2]|0,+C[d>>3],n[m>>2]|0),Va(k),E=B}function da(s){return s=s|0,n[s>>2]|0}function uve(s,l,c,f,d,m){s=s|0,l=l|0,c=+c,f=f|0,d=+d,m=m|0;var B=0;B=Pl(Ave()|0)|0,c=+VA(c),f=DF(f)|0,d=+VA(d),fve(s,hi(0,B|0,l|0,+c,f|0,+d,DF(m)|0)|0)}function Ave(){var s=0;return o[7608]|0||(dve(9120),s=7608,n[s>>2]=1,n[s+4>>2]=0),9120}function Pl(s){return s=s|0,n[s+8>>2]|0}function VA(s){return s=+s,+ +PF(s)}function DF(s){return s=s|0,e5(s)|0}function fve(s,l){s=s|0,l=l|0;var c=0,f=0,d=0;d=E,E=E+32|0,c=d,f=l,f&1?(pve(c,0),ii(f|0,c|0)|0,hve(s,c),gve(c)):(n[s>>2]=n[l>>2],n[s+4>>2]=n[l+4>>2],n[s+8>>2]=n[l+8>>2],n[s+12>>2]=n[l+12>>2]),E=d}function pve(s,l){s=s|0,l=l|0,$G(s,l),n[s+8>>2]=0,o[s+24>>0]=0}function hve(s,l){s=s|0,l=l|0,l=l+8|0,n[s>>2]=n[l>>2],n[s+4>>2]=n[l+4>>2],n[s+8>>2]=n[l+8>>2],n[s+12>>2]=n[l+12>>2]}function gve(s){s=s|0,o[s+24>>0]=0}function $G(s,l){s=s|0,l=l|0,n[s>>2]=l}function e5(s){return s=s|0,s|0}function PF(s){return s=+s,+s}function dve(s){s=s|0,Sl(s,mve()|0,4)}function mve(){return 1064}function Sl(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c,n[s+8>>2]=lp(l|0,c+1|0)|0}function yve(s,l){s=s|0,l=l|0,l=n[l>>2]|0,n[s>>2]=l,yl(l|0)}function Eve(s){s=s|0;var l=0,c=0;c=s+4|0,l=n[c>>2]|0,n[c>>2]=0,l|0&&(jA(l),gt(l)),It(n[s>>2]|0,0)}function Cve(s){s=s|0,Tt(n[s>>2]|0)}function wve(s){return s=s|0,er(n[s>>2]|0)|0}function Ive(s,l,c,f){s=s|0,l=+l,c=+c,f=f|0,vc(n[s>>2]|0,y(l),y(c),f)}function Bve(s){return s=s|0,+ +y(Il(n[s>>2]|0))}function vve(s){return s=s|0,+ +y(hg(n[s>>2]|0))}function Dve(s){return s=s|0,+ +y(Iu(n[s>>2]|0))}function Pve(s){return s=s|0,+ +y(TA(n[s>>2]|0))}function Sve(s){return s=s|0,+ +y(pp(n[s>>2]|0))}function xve(s){return s=s|0,+ +y(ja(n[s>>2]|0))}function bve(s,l){s=s|0,l=l|0,C[s>>3]=+y(Il(n[l>>2]|0)),C[s+8>>3]=+y(hg(n[l>>2]|0)),C[s+16>>3]=+y(Iu(n[l>>2]|0)),C[s+24>>3]=+y(TA(n[l>>2]|0)),C[s+32>>3]=+y(pp(n[l>>2]|0)),C[s+40>>3]=+y(ja(n[l>>2]|0))}function kve(s,l){return s=s|0,l=l|0,+ +y(gg(n[s>>2]|0,l))}function Qve(s,l){return s=s|0,l=l|0,+ +y(hp(n[s>>2]|0,l))}function Fve(s,l){return s=s|0,l=l|0,+ +y(qo(n[s>>2]|0,l))}function Rve(){return Pn()|0}function Tve(){Lve(),Nve(),Ove(),Mve(),Uve(),_ve()}function Lve(){ULe(11713,4938,1)}function Nve(){iLe(10448)}function Ove(){UTe(10408)}function Mve(){lTe(10324)}function Uve(){dFe(10096)}function _ve(){Hve(9132)}function Hve(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0,Ge=0,Me=0,Qe=0,et=0,Xe=0,at=0,Ue=0,qe=0,Nt=0,Mr=0,or=0,Xt=0,Pr=0,Lr=0,ir=0,bn=0,go=0,mo=0,yo=0,ya=0,kp=0,Qp=0,xl=0,Fp=0,Fu=0,Ru=0,Rp=0,Tp=0,Lp=0,Xr=0,bl=0,Np=0,bc=0,Op=0,Mp=0,Tu=0,Lu=0,kc=0,Gs=0,Ja=0,Wo=0,kl=0,rf=0,nf=0,Nu=0,sf=0,of=0,Ys=0,vs=0,Ql=0,Rn=0,af=0,Eo=0,Qc=0,Co=0,Fc=0,lf=0,cf=0,Rc=0,Ws=0,Fl=0,uf=0,Af=0,ff=0,br=0,zn=0,Ds=0,wo=0,Ks=0,Rr=0,ur=0,Rl=0;l=E,E=E+672|0,c=l+656|0,Rl=l+648|0,ur=l+640|0,Rr=l+632|0,Ks=l+624|0,wo=l+616|0,Ds=l+608|0,zn=l+600|0,br=l+592|0,ff=l+584|0,Af=l+576|0,uf=l+568|0,Fl=l+560|0,Ws=l+552|0,Rc=l+544|0,cf=l+536|0,lf=l+528|0,Fc=l+520|0,Co=l+512|0,Qc=l+504|0,Eo=l+496|0,af=l+488|0,Rn=l+480|0,Ql=l+472|0,vs=l+464|0,Ys=l+456|0,of=l+448|0,sf=l+440|0,Nu=l+432|0,nf=l+424|0,rf=l+416|0,kl=l+408|0,Wo=l+400|0,Ja=l+392|0,Gs=l+384|0,kc=l+376|0,Lu=l+368|0,Tu=l+360|0,Mp=l+352|0,Op=l+344|0,bc=l+336|0,Np=l+328|0,bl=l+320|0,Xr=l+312|0,Lp=l+304|0,Tp=l+296|0,Rp=l+288|0,Ru=l+280|0,Fu=l+272|0,Fp=l+264|0,xl=l+256|0,Qp=l+248|0,kp=l+240|0,ya=l+232|0,yo=l+224|0,mo=l+216|0,go=l+208|0,bn=l+200|0,ir=l+192|0,Lr=l+184|0,Pr=l+176|0,Xt=l+168|0,or=l+160|0,Mr=l+152|0,Nt=l+144|0,qe=l+136|0,Ue=l+128|0,at=l+120|0,Xe=l+112|0,et=l+104|0,Qe=l+96|0,Me=l+88|0,Ge=l+80|0,se=l+72|0,q=l+64|0,O=l+56|0,M=l+48|0,Q=l+40|0,k=l+32|0,B=l+24|0,m=l+16|0,d=l+8|0,f=l,jve(s,3646),qve(s,3651,2)|0,Gve(s,3665,2)|0,Yve(s,3682,18)|0,n[Rl>>2]=19,n[Rl+4>>2]=0,n[c>>2]=n[Rl>>2],n[c+4>>2]=n[Rl+4>>2],Rw(s,3690,c)|0,n[ur>>2]=1,n[ur+4>>2]=0,n[c>>2]=n[ur>>2],n[c+4>>2]=n[ur+4>>2],Wve(s,3696,c)|0,n[Rr>>2]=2,n[Rr+4>>2]=0,n[c>>2]=n[Rr>>2],n[c+4>>2]=n[Rr+4>>2],bu(s,3706,c)|0,n[Ks>>2]=1,n[Ks+4>>2]=0,n[c>>2]=n[Ks>>2],n[c+4>>2]=n[Ks+4>>2],vg(s,3722,c)|0,n[wo>>2]=2,n[wo+4>>2]=0,n[c>>2]=n[wo>>2],n[c+4>>2]=n[wo+4>>2],vg(s,3734,c)|0,n[Ds>>2]=3,n[Ds+4>>2]=0,n[c>>2]=n[Ds>>2],n[c+4>>2]=n[Ds+4>>2],bu(s,3753,c)|0,n[zn>>2]=4,n[zn+4>>2]=0,n[c>>2]=n[zn>>2],n[c+4>>2]=n[zn+4>>2],bu(s,3769,c)|0,n[br>>2]=5,n[br+4>>2]=0,n[c>>2]=n[br>>2],n[c+4>>2]=n[br+4>>2],bu(s,3783,c)|0,n[ff>>2]=6,n[ff+4>>2]=0,n[c>>2]=n[ff>>2],n[c+4>>2]=n[ff+4>>2],bu(s,3796,c)|0,n[Af>>2]=7,n[Af+4>>2]=0,n[c>>2]=n[Af>>2],n[c+4>>2]=n[Af+4>>2],bu(s,3813,c)|0,n[uf>>2]=8,n[uf+4>>2]=0,n[c>>2]=n[uf>>2],n[c+4>>2]=n[uf+4>>2],bu(s,3825,c)|0,n[Fl>>2]=3,n[Fl+4>>2]=0,n[c>>2]=n[Fl>>2],n[c+4>>2]=n[Fl+4>>2],vg(s,3843,c)|0,n[Ws>>2]=4,n[Ws+4>>2]=0,n[c>>2]=n[Ws>>2],n[c+4>>2]=n[Ws+4>>2],vg(s,3853,c)|0,n[Rc>>2]=9,n[Rc+4>>2]=0,n[c>>2]=n[Rc>>2],n[c+4>>2]=n[Rc+4>>2],bu(s,3870,c)|0,n[cf>>2]=10,n[cf+4>>2]=0,n[c>>2]=n[cf>>2],n[c+4>>2]=n[cf+4>>2],bu(s,3884,c)|0,n[lf>>2]=11,n[lf+4>>2]=0,n[c>>2]=n[lf>>2],n[c+4>>2]=n[lf+4>>2],bu(s,3896,c)|0,n[Fc>>2]=1,n[Fc+4>>2]=0,n[c>>2]=n[Fc>>2],n[c+4>>2]=n[Fc+4>>2],Is(s,3907,c)|0,n[Co>>2]=2,n[Co+4>>2]=0,n[c>>2]=n[Co>>2],n[c+4>>2]=n[Co+4>>2],Is(s,3915,c)|0,n[Qc>>2]=3,n[Qc+4>>2]=0,n[c>>2]=n[Qc>>2],n[c+4>>2]=n[Qc+4>>2],Is(s,3928,c)|0,n[Eo>>2]=4,n[Eo+4>>2]=0,n[c>>2]=n[Eo>>2],n[c+4>>2]=n[Eo+4>>2],Is(s,3948,c)|0,n[af>>2]=5,n[af+4>>2]=0,n[c>>2]=n[af>>2],n[c+4>>2]=n[af+4>>2],Is(s,3960,c)|0,n[Rn>>2]=6,n[Rn+4>>2]=0,n[c>>2]=n[Rn>>2],n[c+4>>2]=n[Rn+4>>2],Is(s,3974,c)|0,n[Ql>>2]=7,n[Ql+4>>2]=0,n[c>>2]=n[Ql>>2],n[c+4>>2]=n[Ql+4>>2],Is(s,3983,c)|0,n[vs>>2]=20,n[vs+4>>2]=0,n[c>>2]=n[vs>>2],n[c+4>>2]=n[vs+4>>2],Rw(s,3999,c)|0,n[Ys>>2]=8,n[Ys+4>>2]=0,n[c>>2]=n[Ys>>2],n[c+4>>2]=n[Ys+4>>2],Is(s,4012,c)|0,n[of>>2]=9,n[of+4>>2]=0,n[c>>2]=n[of>>2],n[c+4>>2]=n[of+4>>2],Is(s,4022,c)|0,n[sf>>2]=21,n[sf+4>>2]=0,n[c>>2]=n[sf>>2],n[c+4>>2]=n[sf+4>>2],Rw(s,4039,c)|0,n[Nu>>2]=10,n[Nu+4>>2]=0,n[c>>2]=n[Nu>>2],n[c+4>>2]=n[Nu+4>>2],Is(s,4053,c)|0,n[nf>>2]=11,n[nf+4>>2]=0,n[c>>2]=n[nf>>2],n[c+4>>2]=n[nf+4>>2],Is(s,4065,c)|0,n[rf>>2]=12,n[rf+4>>2]=0,n[c>>2]=n[rf>>2],n[c+4>>2]=n[rf+4>>2],Is(s,4084,c)|0,n[kl>>2]=13,n[kl+4>>2]=0,n[c>>2]=n[kl>>2],n[c+4>>2]=n[kl+4>>2],Is(s,4097,c)|0,n[Wo>>2]=14,n[Wo+4>>2]=0,n[c>>2]=n[Wo>>2],n[c+4>>2]=n[Wo+4>>2],Is(s,4117,c)|0,n[Ja>>2]=15,n[Ja+4>>2]=0,n[c>>2]=n[Ja>>2],n[c+4>>2]=n[Ja+4>>2],Is(s,4129,c)|0,n[Gs>>2]=16,n[Gs+4>>2]=0,n[c>>2]=n[Gs>>2],n[c+4>>2]=n[Gs+4>>2],Is(s,4148,c)|0,n[kc>>2]=17,n[kc+4>>2]=0,n[c>>2]=n[kc>>2],n[c+4>>2]=n[kc+4>>2],Is(s,4161,c)|0,n[Lu>>2]=18,n[Lu+4>>2]=0,n[c>>2]=n[Lu>>2],n[c+4>>2]=n[Lu+4>>2],Is(s,4181,c)|0,n[Tu>>2]=5,n[Tu+4>>2]=0,n[c>>2]=n[Tu>>2],n[c+4>>2]=n[Tu+4>>2],vg(s,4196,c)|0,n[Mp>>2]=6,n[Mp+4>>2]=0,n[c>>2]=n[Mp>>2],n[c+4>>2]=n[Mp+4>>2],vg(s,4206,c)|0,n[Op>>2]=7,n[Op+4>>2]=0,n[c>>2]=n[Op>>2],n[c+4>>2]=n[Op+4>>2],vg(s,4217,c)|0,n[bc>>2]=3,n[bc+4>>2]=0,n[c>>2]=n[bc>>2],n[c+4>>2]=n[bc+4>>2],zA(s,4235,c)|0,n[Np>>2]=1,n[Np+4>>2]=0,n[c>>2]=n[Np>>2],n[c+4>>2]=n[Np+4>>2],SF(s,4251,c)|0,n[bl>>2]=4,n[bl+4>>2]=0,n[c>>2]=n[bl>>2],n[c+4>>2]=n[bl+4>>2],zA(s,4263,c)|0,n[Xr>>2]=5,n[Xr+4>>2]=0,n[c>>2]=n[Xr>>2],n[c+4>>2]=n[Xr+4>>2],zA(s,4279,c)|0,n[Lp>>2]=6,n[Lp+4>>2]=0,n[c>>2]=n[Lp>>2],n[c+4>>2]=n[Lp+4>>2],zA(s,4293,c)|0,n[Tp>>2]=7,n[Tp+4>>2]=0,n[c>>2]=n[Tp>>2],n[c+4>>2]=n[Tp+4>>2],zA(s,4306,c)|0,n[Rp>>2]=8,n[Rp+4>>2]=0,n[c>>2]=n[Rp>>2],n[c+4>>2]=n[Rp+4>>2],zA(s,4323,c)|0,n[Ru>>2]=9,n[Ru+4>>2]=0,n[c>>2]=n[Ru>>2],n[c+4>>2]=n[Ru+4>>2],zA(s,4335,c)|0,n[Fu>>2]=2,n[Fu+4>>2]=0,n[c>>2]=n[Fu>>2],n[c+4>>2]=n[Fu+4>>2],SF(s,4353,c)|0,n[Fp>>2]=12,n[Fp+4>>2]=0,n[c>>2]=n[Fp>>2],n[c+4>>2]=n[Fp+4>>2],Dg(s,4363,c)|0,n[xl>>2]=1,n[xl+4>>2]=0,n[c>>2]=n[xl>>2],n[c+4>>2]=n[xl+4>>2],JA(s,4376,c)|0,n[Qp>>2]=2,n[Qp+4>>2]=0,n[c>>2]=n[Qp>>2],n[c+4>>2]=n[Qp+4>>2],JA(s,4388,c)|0,n[kp>>2]=13,n[kp+4>>2]=0,n[c>>2]=n[kp>>2],n[c+4>>2]=n[kp+4>>2],Dg(s,4402,c)|0,n[ya>>2]=14,n[ya+4>>2]=0,n[c>>2]=n[ya>>2],n[c+4>>2]=n[ya+4>>2],Dg(s,4411,c)|0,n[yo>>2]=15,n[yo+4>>2]=0,n[c>>2]=n[yo>>2],n[c+4>>2]=n[yo+4>>2],Dg(s,4421,c)|0,n[mo>>2]=16,n[mo+4>>2]=0,n[c>>2]=n[mo>>2],n[c+4>>2]=n[mo+4>>2],Dg(s,4433,c)|0,n[go>>2]=17,n[go+4>>2]=0,n[c>>2]=n[go>>2],n[c+4>>2]=n[go+4>>2],Dg(s,4446,c)|0,n[bn>>2]=18,n[bn+4>>2]=0,n[c>>2]=n[bn>>2],n[c+4>>2]=n[bn+4>>2],Dg(s,4458,c)|0,n[ir>>2]=3,n[ir+4>>2]=0,n[c>>2]=n[ir>>2],n[c+4>>2]=n[ir+4>>2],JA(s,4471,c)|0,n[Lr>>2]=1,n[Lr+4>>2]=0,n[c>>2]=n[Lr>>2],n[c+4>>2]=n[Lr+4>>2],iD(s,4486,c)|0,n[Pr>>2]=10,n[Pr+4>>2]=0,n[c>>2]=n[Pr>>2],n[c+4>>2]=n[Pr+4>>2],zA(s,4496,c)|0,n[Xt>>2]=11,n[Xt+4>>2]=0,n[c>>2]=n[Xt>>2],n[c+4>>2]=n[Xt+4>>2],zA(s,4508,c)|0,n[or>>2]=3,n[or+4>>2]=0,n[c>>2]=n[or>>2],n[c+4>>2]=n[or+4>>2],SF(s,4519,c)|0,n[Mr>>2]=4,n[Mr+4>>2]=0,n[c>>2]=n[Mr>>2],n[c+4>>2]=n[Mr+4>>2],Kve(s,4530,c)|0,n[Nt>>2]=19,n[Nt+4>>2]=0,n[c>>2]=n[Nt>>2],n[c+4>>2]=n[Nt+4>>2],Vve(s,4542,c)|0,n[qe>>2]=12,n[qe+4>>2]=0,n[c>>2]=n[qe>>2],n[c+4>>2]=n[qe+4>>2],zve(s,4554,c)|0,n[Ue>>2]=13,n[Ue+4>>2]=0,n[c>>2]=n[Ue>>2],n[c+4>>2]=n[Ue+4>>2],Jve(s,4568,c)|0,n[at>>2]=2,n[at+4>>2]=0,n[c>>2]=n[at>>2],n[c+4>>2]=n[at+4>>2],Xve(s,4578,c)|0,n[Xe>>2]=20,n[Xe+4>>2]=0,n[c>>2]=n[Xe>>2],n[c+4>>2]=n[Xe+4>>2],Zve(s,4587,c)|0,n[et>>2]=22,n[et+4>>2]=0,n[c>>2]=n[et>>2],n[c+4>>2]=n[et+4>>2],Rw(s,4602,c)|0,n[Qe>>2]=23,n[Qe+4>>2]=0,n[c>>2]=n[Qe>>2],n[c+4>>2]=n[Qe+4>>2],Rw(s,4619,c)|0,n[Me>>2]=14,n[Me+4>>2]=0,n[c>>2]=n[Me>>2],n[c+4>>2]=n[Me+4>>2],$ve(s,4629,c)|0,n[Ge>>2]=1,n[Ge+4>>2]=0,n[c>>2]=n[Ge>>2],n[c+4>>2]=n[Ge+4>>2],eDe(s,4637,c)|0,n[se>>2]=4,n[se+4>>2]=0,n[c>>2]=n[se>>2],n[c+4>>2]=n[se+4>>2],JA(s,4653,c)|0,n[q>>2]=5,n[q+4>>2]=0,n[c>>2]=n[q>>2],n[c+4>>2]=n[q+4>>2],JA(s,4669,c)|0,n[O>>2]=6,n[O+4>>2]=0,n[c>>2]=n[O>>2],n[c+4>>2]=n[O+4>>2],JA(s,4686,c)|0,n[M>>2]=7,n[M+4>>2]=0,n[c>>2]=n[M>>2],n[c+4>>2]=n[M+4>>2],JA(s,4701,c)|0,n[Q>>2]=8,n[Q+4>>2]=0,n[c>>2]=n[Q>>2],n[c+4>>2]=n[Q+4>>2],JA(s,4719,c)|0,n[k>>2]=9,n[k+4>>2]=0,n[c>>2]=n[k>>2],n[c+4>>2]=n[k+4>>2],JA(s,4736,c)|0,n[B>>2]=21,n[B+4>>2]=0,n[c>>2]=n[B>>2],n[c+4>>2]=n[B+4>>2],tDe(s,4754,c)|0,n[m>>2]=2,n[m+4>>2]=0,n[c>>2]=n[m>>2],n[c+4>>2]=n[m+4>>2],iD(s,4772,c)|0,n[d>>2]=3,n[d+4>>2]=0,n[c>>2]=n[d>>2],n[c+4>>2]=n[d+4>>2],iD(s,4790,c)|0,n[f>>2]=4,n[f+4>>2]=0,n[c>>2]=n[f>>2],n[c+4>>2]=n[f+4>>2],iD(s,4808,c)|0,E=l}function jve(s,l){s=s|0,l=l|0;var c=0;c=aFe()|0,n[s>>2]=c,lFe(c,l),Sp(n[s>>2]|0)}function qve(s,l,c){return s=s|0,l=l|0,c=c|0,KQe(s,pn(l)|0,c,0),s|0}function Gve(s,l,c){return s=s|0,l=l|0,c=c|0,QQe(s,pn(l)|0,c,0),s|0}function Yve(s,l,c){return s=s|0,l=l|0,c=c|0,mQe(s,pn(l)|0,c,0),s|0}function Rw(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],tQe(s,l,d),E=f,s|0}function Wve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Nke(s,l,d),E=f,s|0}function bu(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Cke(s,l,d),E=f,s|0}function vg(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],ike(s,l,d),E=f,s|0}function Is(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],jbe(s,l,d),E=f,s|0}function zA(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Pbe(s,l,d),E=f,s|0}function SF(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],ube(s,l,d),E=f,s|0}function Dg(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Nxe(s,l,d),E=f,s|0}function JA(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Cxe(s,l,d),E=f,s|0}function iD(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],ixe(s,l,d),E=f,s|0}function Kve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],jSe(s,l,d),E=f,s|0}function Vve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],PSe(s,l,d),E=f,s|0}function zve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],ASe(s,l,d),E=f,s|0}function Jve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],JPe(s,l,d),E=f,s|0}function Xve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],RPe(s,l,d),E=f,s|0}function Zve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],dPe(s,l,d),E=f,s|0}function $ve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],ePe(s,l,d),E=f,s|0}function eDe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],NDe(s,l,d),E=f,s|0}function tDe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],rDe(s,l,d),E=f,s|0}function rDe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],nDe(s,c,d,1),E=f}function pn(s){return s=s|0,s|0}function nDe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=xF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=iDe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,sDe(m,f)|0,f),E=d}function xF(){var s=0,l=0;if(o[7616]|0||(n5(9136),tr(24,9136,U|0)|0,l=7616,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9136)|0)){s=9136,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));n5(9136)}return 9136}function iDe(s){return s=s|0,0}function sDe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=xF()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],r5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(lDe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function hn(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0;var B=0,k=0,Q=0,M=0,O=0,q=0,se=0,Ge=0;B=E,E=E+32|0,se=B+24|0,q=B+20|0,Q=B+16|0,O=B+12|0,M=B+8|0,k=B+4|0,Ge=B,n[q>>2]=l,n[Q>>2]=c,n[O>>2]=f,n[M>>2]=d,n[k>>2]=m,m=s+28|0,n[Ge>>2]=n[m>>2],n[se>>2]=n[Ge>>2],oDe(s+24|0,se,q,O,M,Q,k)|0,n[m>>2]=n[n[m>>2]>>2],E=B}function oDe(s,l,c,f,d,m,B){return s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,B=B|0,s=aDe(l)|0,l=Kt(24)|0,t5(l+4|0,n[c>>2]|0,n[f>>2]|0,n[d>>2]|0,n[m>>2]|0,n[B>>2]|0),n[l>>2]=n[s>>2],n[s>>2]=l,l|0}function aDe(s){return s=s|0,n[s>>2]|0}function t5(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,n[s>>2]=l,n[s+4>>2]=c,n[s+8>>2]=f,n[s+12>>2]=d,n[s+16>>2]=m}function gr(s,l){return s=s|0,l=l|0,l|s|0}function r5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function lDe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=cDe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,uDe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],r5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,ADe(s,k),fDe(k),E=M;return}}function cDe(s){return s=s|0,357913941}function uDe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function ADe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function fDe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function n5(s){s=s|0,gDe(s)}function pDe(s){s=s|0,hDe(s+24|0)}function Tr(s){return s=s|0,n[s>>2]|0}function hDe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function gDe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,3,l,dDe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function Kr(){return 9228}function dDe(){return 1140}function mDe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0;return c=E,E=E+16|0,f=c+8|0,d=c,m=yDe(s)|0,s=n[m+4>>2]|0,n[d>>2]=n[m>>2],n[d+4>>2]=s,n[f>>2]=n[d>>2],n[f+4>>2]=n[d+4>>2],l=EDe(l,f)|0,E=c,l|0}function Vr(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,n[s>>2]=l,n[s+4>>2]=c,n[s+8>>2]=f,n[s+12>>2]=d,n[s+16>>2]=m}function yDe(s){return s=s|0,(n[(xF()|0)+24>>2]|0)+(s*12|0)|0}function EDe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0;return d=E,E=E+48|0,f=d,c=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(c=n[(n[s>>2]|0)+c>>2]|0),tf[c&31](f,s),f=CDe(f)|0,E=d,f|0}function CDe(s){s=s|0;var l=0,c=0,f=0,d=0;return d=E,E=E+32|0,l=d+12|0,c=d,f=bF(i5()|0)|0,f?(kF(l,f),QF(c,l),wDe(s,c),s=FF(l)|0):s=IDe(s)|0,E=d,s|0}function i5(){var s=0;return o[7632]|0||(FDe(9184),tr(25,9184,U|0)|0,s=7632,n[s>>2]=1,n[s+4>>2]=0),9184}function bF(s){return s=s|0,n[s+36>>2]|0}function kF(s,l){s=s|0,l=l|0,n[s>>2]=l,n[s+4>>2]=s,n[s+8>>2]=0}function QF(s,l){s=s|0,l=l|0,n[s>>2]=n[l>>2],n[s+4>>2]=n[l+4>>2],n[s+8>>2]=0}function wDe(s,l){s=s|0,l=l|0,PDe(l,s,s+8|0,s+16|0,s+24|0,s+32|0,s+40|0)|0}function FF(s){return s=s|0,n[(n[s+4>>2]|0)+8>>2]|0}function IDe(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0,Q=0;Q=E,E=E+16|0,c=Q+4|0,f=Q,d=Wa(8)|0,m=d,B=Kt(48)|0,k=B,l=k+48|0;do n[k>>2]=n[s>>2],k=k+4|0,s=s+4|0;while((k|0)<(l|0));return l=m+4|0,n[l>>2]=B,k=Kt(8)|0,B=n[l>>2]|0,n[f>>2]=0,n[c>>2]=n[f>>2],s5(k,B,c),n[d>>2]=k,E=Q,m|0}function s5(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,c=Kt(16)|0,n[c+4>>2]=0,n[c+8>>2]=0,n[c>>2]=1092,n[c+12>>2]=l,n[s+4>>2]=c}function BDe(s){s=s|0,zm(s),gt(s)}function vDe(s){s=s|0,s=n[s+12>>2]|0,s|0&>(s)}function DDe(s){s=s|0,gt(s)}function PDe(s,l,c,f,d,m,B){return s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,B=B|0,m=SDe(n[s>>2]|0,l,c,f,d,m,B)|0,B=s+4|0,n[(n[B>>2]|0)+8>>2]=m,n[(n[B>>2]|0)+8>>2]|0}function SDe(s,l,c,f,d,m,B){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,B=B|0;var k=0,Q=0;return k=E,E=E+16|0,Q=k,Ka(Q),s=da(s)|0,B=xDe(s,+C[l>>3],+C[c>>3],+C[f>>3],+C[d>>3],+C[m>>3],+C[B>>3])|0,Va(Q),E=k,B|0}function xDe(s,l,c,f,d,m,B){s=s|0,l=+l,c=+c,f=+f,d=+d,m=+m,B=+B;var k=0;return k=Pl(bDe()|0)|0,l=+VA(l),c=+VA(c),f=+VA(f),d=+VA(d),m=+VA(m),Us(0,k|0,s|0,+l,+c,+f,+d,+m,+ +VA(B))|0}function bDe(){var s=0;return o[7624]|0||(kDe(9172),s=7624,n[s>>2]=1,n[s+4>>2]=0),9172}function kDe(s){s=s|0,Sl(s,QDe()|0,6)}function QDe(){return 1112}function FDe(s){s=s|0,Ip(s)}function RDe(s){s=s|0,o5(s+24|0),a5(s+16|0)}function o5(s){s=s|0,LDe(s)}function a5(s){s=s|0,TDe(s)}function TDe(s){s=s|0;var l=0,c=0;if(l=n[s>>2]|0,l|0)do c=l,l=n[l>>2]|0,gt(c);while((l|0)!=0);n[s>>2]=0}function LDe(s){s=s|0;var l=0,c=0;if(l=n[s>>2]|0,l|0)do c=l,l=n[l>>2]|0,gt(c);while((l|0)!=0);n[s>>2]=0}function Ip(s){s=s|0;var l=0;n[s+16>>2]=0,n[s+20>>2]=0,l=s+24|0,n[l>>2]=0,n[s+28>>2]=l,n[s+36>>2]=0,o[s+40>>0]=0,o[s+41>>0]=0}function NDe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],ODe(s,c,d,0),E=f}function ODe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=RF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=MDe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,UDe(m,f)|0,f),E=d}function RF(){var s=0,l=0;if(o[7640]|0||(c5(9232),tr(26,9232,U|0)|0,l=7640,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9232)|0)){s=9232,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));c5(9232)}return 9232}function MDe(s){return s=s|0,0}function UDe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=RF()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],l5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(_De(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function l5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function _De(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=HDe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,jDe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],l5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,qDe(s,k),GDe(k),E=M;return}}function HDe(s){return s=s|0,357913941}function jDe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function qDe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function GDe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function c5(s){s=s|0,KDe(s)}function YDe(s){s=s|0,WDe(s+24|0)}function WDe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function KDe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,1,l,VDe()|0,3),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function VDe(){return 1144}function zDe(s,l,c,f,d){s=s|0,l=l|0,c=+c,f=+f,d=d|0;var m=0,B=0,k=0,Q=0;m=E,E=E+16|0,B=m+8|0,k=m,Q=JDe(s)|0,s=n[Q+4>>2]|0,n[k>>2]=n[Q>>2],n[k+4>>2]=s,n[B>>2]=n[k>>2],n[B+4>>2]=n[k+4>>2],XDe(l,B,c,f,d),E=m}function JDe(s){return s=s|0,(n[(RF()|0)+24>>2]|0)+(s*12|0)|0}function XDe(s,l,c,f,d){s=s|0,l=l|0,c=+c,f=+f,d=d|0;var m=0,B=0,k=0,Q=0,M=0;M=E,E=E+16|0,B=M+2|0,k=M+1|0,Q=M,m=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(m=n[(n[s>>2]|0)+m>>2]|0),ku(B,c),c=+Qu(B,c),ku(k,f),f=+Qu(k,f),XA(Q,d),Q=ZA(Q,d)|0,v7[m&1](s,c,f,Q),E=M}function ku(s,l){s=s|0,l=+l}function Qu(s,l){return s=s|0,l=+l,+ +$De(l)}function XA(s,l){s=s|0,l=l|0}function ZA(s,l){return s=s|0,l=l|0,ZDe(l)|0}function ZDe(s){return s=s|0,s|0}function $De(s){return s=+s,+s}function ePe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],tPe(s,c,d,1),E=f}function tPe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=TF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=rPe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,nPe(m,f)|0,f),E=d}function TF(){var s=0,l=0;if(o[7648]|0||(A5(9268),tr(27,9268,U|0)|0,l=7648,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9268)|0)){s=9268,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));A5(9268)}return 9268}function rPe(s){return s=s|0,0}function nPe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=TF()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],u5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(iPe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function u5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function iPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=sPe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,oPe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],u5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,aPe(s,k),lPe(k),E=M;return}}function sPe(s){return s=s|0,357913941}function oPe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function aPe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function lPe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function A5(s){s=s|0,APe(s)}function cPe(s){s=s|0,uPe(s+24|0)}function uPe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function APe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,4,l,fPe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function fPe(){return 1160}function pPe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0;return c=E,E=E+16|0,f=c+8|0,d=c,m=hPe(s)|0,s=n[m+4>>2]|0,n[d>>2]=n[m>>2],n[d+4>>2]=s,n[f>>2]=n[d>>2],n[f+4>>2]=n[d+4>>2],l=gPe(l,f)|0,E=c,l|0}function hPe(s){return s=s|0,(n[(TF()|0)+24>>2]|0)+(s*12|0)|0}function gPe(s,l){s=s|0,l=l|0;var c=0;return c=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(c=n[(n[s>>2]|0)+c>>2]|0),f5(Tg[c&31](s)|0)|0}function f5(s){return s=s|0,s&1|0}function dPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],mPe(s,c,d,0),E=f}function mPe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=LF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=yPe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,EPe(m,f)|0,f),E=d}function LF(){var s=0,l=0;if(o[7656]|0||(h5(9304),tr(28,9304,U|0)|0,l=7656,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9304)|0)){s=9304,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));h5(9304)}return 9304}function yPe(s){return s=s|0,0}function EPe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=LF()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],p5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(CPe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function p5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function CPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=wPe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,IPe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],p5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,BPe(s,k),vPe(k),E=M;return}}function wPe(s){return s=s|0,357913941}function IPe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function BPe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function vPe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function h5(s){s=s|0,SPe(s)}function DPe(s){s=s|0,PPe(s+24|0)}function PPe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function SPe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,5,l,xPe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function xPe(){return 1164}function bPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;f=E,E=E+16|0,d=f+8|0,m=f,B=kPe(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],QPe(l,d,c),E=f}function kPe(s){return s=s|0,(n[(LF()|0)+24>>2]|0)+(s*12|0)|0}function QPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;m=E,E=E+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),Bp(d,c),c=vp(d,c)|0,tf[f&31](s,c),Dp(d),E=m}function Bp(s,l){s=s|0,l=l|0,FPe(s,l)}function vp(s,l){return s=s|0,l=l|0,s|0}function Dp(s){s=s|0,jA(s)}function FPe(s,l){s=s|0,l=l|0,NF(s,l)}function NF(s,l){s=s|0,l=l|0,n[s>>2]=l}function RPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],TPe(s,c,d,0),E=f}function TPe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=OF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=LPe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,NPe(m,f)|0,f),E=d}function OF(){var s=0,l=0;if(o[7664]|0||(d5(9340),tr(29,9340,U|0)|0,l=7664,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9340)|0)){s=9340,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));d5(9340)}return 9340}function LPe(s){return s=s|0,0}function NPe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=OF()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],g5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(OPe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function g5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function OPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=MPe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,UPe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],g5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,_Pe(s,k),HPe(k),E=M;return}}function MPe(s){return s=s|0,357913941}function UPe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function _Pe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function HPe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function d5(s){s=s|0,GPe(s)}function jPe(s){s=s|0,qPe(s+24|0)}function qPe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function GPe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,4,l,YPe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function YPe(){return 1180}function WPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=KPe(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],c=VPe(l,d,c)|0,E=f,c|0}function KPe(s){return s=s|0,(n[(OF()|0)+24>>2]|0)+(s*12|0)|0}function VPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;return m=E,E=E+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),Pg(d,c),d=Sg(d,c)|0,d=sD(RR[f&15](s,d)|0)|0,E=m,d|0}function Pg(s,l){s=s|0,l=l|0}function Sg(s,l){return s=s|0,l=l|0,zPe(l)|0}function sD(s){return s=s|0,s|0}function zPe(s){return s=s|0,s|0}function JPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],XPe(s,c,d,0),E=f}function XPe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=MF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=ZPe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,$Pe(m,f)|0,f),E=d}function MF(){var s=0,l=0;if(o[7672]|0||(y5(9376),tr(30,9376,U|0)|0,l=7672,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9376)|0)){s=9376,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));y5(9376)}return 9376}function ZPe(s){return s=s|0,0}function $Pe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=MF()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],m5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(eSe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function m5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function eSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=tSe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,rSe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],m5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,nSe(s,k),iSe(k),E=M;return}}function tSe(s){return s=s|0,357913941}function rSe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function nSe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function iSe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function y5(s){s=s|0,aSe(s)}function sSe(s){s=s|0,oSe(s+24|0)}function oSe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function aSe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,5,l,E5()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function E5(){return 1196}function lSe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0;return c=E,E=E+16|0,f=c+8|0,d=c,m=cSe(s)|0,s=n[m+4>>2]|0,n[d>>2]=n[m>>2],n[d+4>>2]=s,n[f>>2]=n[d>>2],n[f+4>>2]=n[d+4>>2],l=uSe(l,f)|0,E=c,l|0}function cSe(s){return s=s|0,(n[(MF()|0)+24>>2]|0)+(s*12|0)|0}function uSe(s,l){s=s|0,l=l|0;var c=0;return c=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(c=n[(n[s>>2]|0)+c>>2]|0),sD(Tg[c&31](s)|0)|0}function ASe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],fSe(s,c,d,1),E=f}function fSe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=UF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=pSe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,hSe(m,f)|0,f),E=d}function UF(){var s=0,l=0;if(o[7680]|0||(w5(9412),tr(31,9412,U|0)|0,l=7680,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9412)|0)){s=9412,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));w5(9412)}return 9412}function pSe(s){return s=s|0,0}function hSe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=UF()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],C5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(gSe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function C5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function gSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=dSe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,mSe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],C5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,ySe(s,k),ESe(k),E=M;return}}function dSe(s){return s=s|0,357913941}function mSe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function ySe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function ESe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function w5(s){s=s|0,ISe(s)}function CSe(s){s=s|0,wSe(s+24|0)}function wSe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function ISe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,6,l,I5()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function I5(){return 1200}function BSe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0;return c=E,E=E+16|0,f=c+8|0,d=c,m=vSe(s)|0,s=n[m+4>>2]|0,n[d>>2]=n[m>>2],n[d+4>>2]=s,n[f>>2]=n[d>>2],n[f+4>>2]=n[d+4>>2],l=DSe(l,f)|0,E=c,l|0}function vSe(s){return s=s|0,(n[(UF()|0)+24>>2]|0)+(s*12|0)|0}function DSe(s,l){s=s|0,l=l|0;var c=0;return c=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(c=n[(n[s>>2]|0)+c>>2]|0),oD(Tg[c&31](s)|0)|0}function oD(s){return s=s|0,s|0}function PSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],SSe(s,c,d,0),E=f}function SSe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=_F()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=xSe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,bSe(m,f)|0,f),E=d}function _F(){var s=0,l=0;if(o[7688]|0||(v5(9448),tr(32,9448,U|0)|0,l=7688,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9448)|0)){s=9448,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));v5(9448)}return 9448}function xSe(s){return s=s|0,0}function bSe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=_F()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],B5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(kSe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function B5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function kSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=QSe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,FSe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],B5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,RSe(s,k),TSe(k),E=M;return}}function QSe(s){return s=s|0,357913941}function FSe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function RSe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function TSe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function v5(s){s=s|0,OSe(s)}function LSe(s){s=s|0,NSe(s+24|0)}function NSe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function OSe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,6,l,D5()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function D5(){return 1204}function MSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;f=E,E=E+16|0,d=f+8|0,m=f,B=USe(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],_Se(l,d,c),E=f}function USe(s){return s=s|0,(n[(_F()|0)+24>>2]|0)+(s*12|0)|0}function _Se(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;m=E,E=E+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),HF(d,c),d=jF(d,c)|0,tf[f&31](s,d),E=m}function HF(s,l){s=s|0,l=l|0}function jF(s,l){return s=s|0,l=l|0,HSe(l)|0}function HSe(s){return s=s|0,s|0}function jSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],qSe(s,c,d,0),E=f}function qSe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=qF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=GSe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,YSe(m,f)|0,f),E=d}function qF(){var s=0,l=0;if(o[7696]|0||(S5(9484),tr(33,9484,U|0)|0,l=7696,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9484)|0)){s=9484,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));S5(9484)}return 9484}function GSe(s){return s=s|0,0}function YSe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=qF()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],P5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(WSe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function P5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function WSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=KSe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,VSe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],P5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,zSe(s,k),JSe(k),E=M;return}}function KSe(s){return s=s|0,357913941}function VSe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function zSe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function JSe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function S5(s){s=s|0,$Se(s)}function XSe(s){s=s|0,ZSe(s+24|0)}function ZSe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function $Se(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,1,l,exe()|0,2),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function exe(){return 1212}function txe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;d=E,E=E+16|0,m=d+8|0,B=d,k=rxe(s)|0,s=n[k+4>>2]|0,n[B>>2]=n[k>>2],n[B+4>>2]=s,n[m>>2]=n[B>>2],n[m+4>>2]=n[B+4>>2],nxe(l,m,c,f),E=d}function rxe(s){return s=s|0,(n[(qF()|0)+24>>2]|0)+(s*12|0)|0}function nxe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;k=E,E=E+16|0,m=k+1|0,B=k,d=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(d=n[(n[s>>2]|0)+d>>2]|0),HF(m,c),m=jF(m,c)|0,Pg(B,f),B=Sg(B,f)|0,_w[d&15](s,m,B),E=k}function ixe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],sxe(s,c,d,1),E=f}function sxe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=GF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=oxe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,axe(m,f)|0,f),E=d}function GF(){var s=0,l=0;if(o[7704]|0||(b5(9520),tr(34,9520,U|0)|0,l=7704,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9520)|0)){s=9520,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));b5(9520)}return 9520}function oxe(s){return s=s|0,0}function axe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=GF()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],x5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(lxe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function x5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function lxe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=cxe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,uxe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],x5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,Axe(s,k),fxe(k),E=M;return}}function cxe(s){return s=s|0,357913941}function uxe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function Axe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function fxe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function b5(s){s=s|0,gxe(s)}function pxe(s){s=s|0,hxe(s+24|0)}function hxe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function gxe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,1,l,dxe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function dxe(){return 1224}function mxe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;return d=E,E=E+16|0,m=d+8|0,B=d,k=yxe(s)|0,s=n[k+4>>2]|0,n[B>>2]=n[k>>2],n[B+4>>2]=s,n[m>>2]=n[B>>2],n[m+4>>2]=n[B+4>>2],f=+Exe(l,m,c),E=d,+f}function yxe(s){return s=s|0,(n[(GF()|0)+24>>2]|0)+(s*12|0)|0}function Exe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return m=E,E=E+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),XA(d,c),d=ZA(d,c)|0,B=+PF(+P7[f&7](s,d)),E=m,+B}function Cxe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],wxe(s,c,d,1),E=f}function wxe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=YF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=Ixe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,Bxe(m,f)|0,f),E=d}function YF(){var s=0,l=0;if(o[7712]|0||(Q5(9556),tr(35,9556,U|0)|0,l=7712,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9556)|0)){s=9556,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));Q5(9556)}return 9556}function Ixe(s){return s=s|0,0}function Bxe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=YF()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],k5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(vxe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function k5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function vxe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=Dxe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,Pxe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],k5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,Sxe(s,k),xxe(k),E=M;return}}function Dxe(s){return s=s|0,357913941}function Pxe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function Sxe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function xxe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function Q5(s){s=s|0,Qxe(s)}function bxe(s){s=s|0,kxe(s+24|0)}function kxe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function Qxe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,5,l,Fxe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function Fxe(){return 1232}function Rxe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=Txe(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],c=+Lxe(l,d),E=f,+c}function Txe(s){return s=s|0,(n[(YF()|0)+24>>2]|0)+(s*12|0)|0}function Lxe(s,l){s=s|0,l=l|0;var c=0;return c=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(c=n[(n[s>>2]|0)+c>>2]|0),+ +PF(+D7[c&15](s))}function Nxe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Oxe(s,c,d,1),E=f}function Oxe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=WF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=Mxe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,Uxe(m,f)|0,f),E=d}function WF(){var s=0,l=0;if(o[7720]|0||(R5(9592),tr(36,9592,U|0)|0,l=7720,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9592)|0)){s=9592,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));R5(9592)}return 9592}function Mxe(s){return s=s|0,0}function Uxe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=WF()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],F5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(_xe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function F5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function _xe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=Hxe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,jxe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],F5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,qxe(s,k),Gxe(k),E=M;return}}function Hxe(s){return s=s|0,357913941}function jxe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function qxe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function Gxe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function R5(s){s=s|0,Kxe(s)}function Yxe(s){s=s|0,Wxe(s+24|0)}function Wxe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function Kxe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,7,l,Vxe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function Vxe(){return 1276}function zxe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0;return c=E,E=E+16|0,f=c+8|0,d=c,m=Jxe(s)|0,s=n[m+4>>2]|0,n[d>>2]=n[m>>2],n[d+4>>2]=s,n[f>>2]=n[d>>2],n[f+4>>2]=n[d+4>>2],l=Xxe(l,f)|0,E=c,l|0}function Jxe(s){return s=s|0,(n[(WF()|0)+24>>2]|0)+(s*12|0)|0}function Xxe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0;return d=E,E=E+16|0,f=d,c=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(c=n[(n[s>>2]|0)+c>>2]|0),tf[c&31](f,s),f=T5(f)|0,E=d,f|0}function T5(s){s=s|0;var l=0,c=0,f=0,d=0;return d=E,E=E+32|0,l=d+12|0,c=d,f=bF(L5()|0)|0,f?(kF(l,f),QF(c,l),Zxe(s,c),s=FF(l)|0):s=$xe(s)|0,E=d,s|0}function L5(){var s=0;return o[7736]|0||(cbe(9640),tr(25,9640,U|0)|0,s=7736,n[s>>2]=1,n[s+4>>2]=0),9640}function Zxe(s,l){s=s|0,l=l|0,nbe(l,s,s+8|0)|0}function $xe(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0;return c=E,E=E+16|0,d=c+4|0,B=c,f=Wa(8)|0,l=f,k=Kt(16)|0,n[k>>2]=n[s>>2],n[k+4>>2]=n[s+4>>2],n[k+8>>2]=n[s+8>>2],n[k+12>>2]=n[s+12>>2],m=l+4|0,n[m>>2]=k,s=Kt(8)|0,m=n[m>>2]|0,n[B>>2]=0,n[d>>2]=n[B>>2],KF(s,m,d),n[f>>2]=s,E=c,l|0}function KF(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,c=Kt(16)|0,n[c+4>>2]=0,n[c+8>>2]=0,n[c>>2]=1244,n[c+12>>2]=l,n[s+4>>2]=c}function ebe(s){s=s|0,zm(s),gt(s)}function tbe(s){s=s|0,s=n[s+12>>2]|0,s|0&>(s)}function rbe(s){s=s|0,gt(s)}function nbe(s,l,c){return s=s|0,l=l|0,c=c|0,l=ibe(n[s>>2]|0,l,c)|0,c=s+4|0,n[(n[c>>2]|0)+8>>2]=l,n[(n[c>>2]|0)+8>>2]|0}function ibe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;return f=E,E=E+16|0,d=f,Ka(d),s=da(s)|0,c=sbe(s,n[l>>2]|0,+C[c>>3])|0,Va(d),E=f,c|0}function sbe(s,l,c){s=s|0,l=l|0,c=+c;var f=0;return f=Pl(obe()|0)|0,l=DF(l)|0,ml(0,f|0,s|0,l|0,+ +VA(c))|0}function obe(){var s=0;return o[7728]|0||(abe(9628),s=7728,n[s>>2]=1,n[s+4>>2]=0),9628}function abe(s){s=s|0,Sl(s,lbe()|0,2)}function lbe(){return 1264}function cbe(s){s=s|0,Ip(s)}function ube(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Abe(s,c,d,1),E=f}function Abe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=VF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=fbe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,pbe(m,f)|0,f),E=d}function VF(){var s=0,l=0;if(o[7744]|0||(O5(9684),tr(37,9684,U|0)|0,l=7744,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9684)|0)){s=9684,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));O5(9684)}return 9684}function fbe(s){return s=s|0,0}function pbe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=VF()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],N5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(hbe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function N5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function hbe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=gbe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,dbe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],N5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,mbe(s,k),ybe(k),E=M;return}}function gbe(s){return s=s|0,357913941}function dbe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function mbe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function ybe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function O5(s){s=s|0,wbe(s)}function Ebe(s){s=s|0,Cbe(s+24|0)}function Cbe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function wbe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,5,l,Ibe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function Ibe(){return 1280}function Bbe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=vbe(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],c=Dbe(l,d,c)|0,E=f,c|0}function vbe(s){return s=s|0,(n[(VF()|0)+24>>2]|0)+(s*12|0)|0}function Dbe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return B=E,E=E+32|0,d=B,m=B+16|0,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),XA(m,c),m=ZA(m,c)|0,_w[f&15](d,s,m),m=T5(d)|0,E=B,m|0}function Pbe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Sbe(s,c,d,1),E=f}function Sbe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=zF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=xbe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,bbe(m,f)|0,f),E=d}function zF(){var s=0,l=0;if(o[7752]|0||(U5(9720),tr(38,9720,U|0)|0,l=7752,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9720)|0)){s=9720,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));U5(9720)}return 9720}function xbe(s){return s=s|0,0}function bbe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=zF()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],M5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(kbe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function M5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function kbe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=Qbe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,Fbe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],M5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,Rbe(s,k),Tbe(k),E=M;return}}function Qbe(s){return s=s|0,357913941}function Fbe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function Rbe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function Tbe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function U5(s){s=s|0,Obe(s)}function Lbe(s){s=s|0,Nbe(s+24|0)}function Nbe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function Obe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,8,l,Mbe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function Mbe(){return 1288}function Ube(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0;return c=E,E=E+16|0,f=c+8|0,d=c,m=_be(s)|0,s=n[m+4>>2]|0,n[d>>2]=n[m>>2],n[d+4>>2]=s,n[f>>2]=n[d>>2],n[f+4>>2]=n[d+4>>2],l=Hbe(l,f)|0,E=c,l|0}function _be(s){return s=s|0,(n[(zF()|0)+24>>2]|0)+(s*12|0)|0}function Hbe(s,l){s=s|0,l=l|0;var c=0;return c=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(c=n[(n[s>>2]|0)+c>>2]|0),e5(Tg[c&31](s)|0)|0}function jbe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],qbe(s,c,d,0),E=f}function qbe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=JF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=Gbe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,Ybe(m,f)|0,f),E=d}function JF(){var s=0,l=0;if(o[7760]|0||(H5(9756),tr(39,9756,U|0)|0,l=7760,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9756)|0)){s=9756,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));H5(9756)}return 9756}function Gbe(s){return s=s|0,0}function Ybe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=JF()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],_5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(Wbe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function _5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function Wbe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=Kbe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,Vbe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],_5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,zbe(s,k),Jbe(k),E=M;return}}function Kbe(s){return s=s|0,357913941}function Vbe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function zbe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function Jbe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function H5(s){s=s|0,$be(s)}function Xbe(s){s=s|0,Zbe(s+24|0)}function Zbe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function $be(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,8,l,eke()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function eke(){return 1292}function tke(s,l,c){s=s|0,l=l|0,c=+c;var f=0,d=0,m=0,B=0;f=E,E=E+16|0,d=f+8|0,m=f,B=rke(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],nke(l,d,c),E=f}function rke(s){return s=s|0,(n[(JF()|0)+24>>2]|0)+(s*12|0)|0}function nke(s,l,c){s=s|0,l=l|0,c=+c;var f=0,d=0,m=0;m=E,E=E+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),ku(d,c),c=+Qu(d,c),I7[f&31](s,c),E=m}function ike(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],ske(s,c,d,0),E=f}function ske(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=XF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=oke(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,ake(m,f)|0,f),E=d}function XF(){var s=0,l=0;if(o[7768]|0||(q5(9792),tr(40,9792,U|0)|0,l=7768,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9792)|0)){s=9792,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));q5(9792)}return 9792}function oke(s){return s=s|0,0}function ake(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=XF()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],j5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(lke(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function j5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function lke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=cke(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,uke(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],j5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,Ake(s,k),fke(k),E=M;return}}function cke(s){return s=s|0,357913941}function uke(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function Ake(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function fke(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function q5(s){s=s|0,gke(s)}function pke(s){s=s|0,hke(s+24|0)}function hke(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function gke(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,1,l,dke()|0,2),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function dke(){return 1300}function mke(s,l,c,f){s=s|0,l=l|0,c=c|0,f=+f;var d=0,m=0,B=0,k=0;d=E,E=E+16|0,m=d+8|0,B=d,k=yke(s)|0,s=n[k+4>>2]|0,n[B>>2]=n[k>>2],n[B+4>>2]=s,n[m>>2]=n[B>>2],n[m+4>>2]=n[B+4>>2],Eke(l,m,c,f),E=d}function yke(s){return s=s|0,(n[(XF()|0)+24>>2]|0)+(s*12|0)|0}function Eke(s,l,c,f){s=s|0,l=l|0,c=c|0,f=+f;var d=0,m=0,B=0,k=0;k=E,E=E+16|0,m=k+1|0,B=k,d=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(d=n[(n[s>>2]|0)+d>>2]|0),XA(m,c),m=ZA(m,c)|0,ku(B,f),f=+Qu(B,f),k7[d&15](s,m,f),E=k}function Cke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],wke(s,c,d,0),E=f}function wke(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=ZF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=Ike(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,Bke(m,f)|0,f),E=d}function ZF(){var s=0,l=0;if(o[7776]|0||(Y5(9828),tr(41,9828,U|0)|0,l=7776,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9828)|0)){s=9828,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));Y5(9828)}return 9828}function Ike(s){return s=s|0,0}function Bke(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=ZF()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],G5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(vke(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function G5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function vke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=Dke(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,Pke(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],G5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,Ske(s,k),xke(k),E=M;return}}function Dke(s){return s=s|0,357913941}function Pke(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function Ske(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function xke(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function Y5(s){s=s|0,Qke(s)}function bke(s){s=s|0,kke(s+24|0)}function kke(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function Qke(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,7,l,Fke()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function Fke(){return 1312}function Rke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;f=E,E=E+16|0,d=f+8|0,m=f,B=Tke(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Lke(l,d,c),E=f}function Tke(s){return s=s|0,(n[(ZF()|0)+24>>2]|0)+(s*12|0)|0}function Lke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;m=E,E=E+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),XA(d,c),d=ZA(d,c)|0,tf[f&31](s,d),E=m}function Nke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Oke(s,c,d,0),E=f}function Oke(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=$F()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=Mke(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,Uke(m,f)|0,f),E=d}function $F(){var s=0,l=0;if(o[7784]|0||(K5(9864),tr(42,9864,U|0)|0,l=7784,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9864)|0)){s=9864,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));K5(9864)}return 9864}function Mke(s){return s=s|0,0}function Uke(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=$F()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],W5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(_ke(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function W5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function _ke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=Hke(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,jke(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],W5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,qke(s,k),Gke(k),E=M;return}}function Hke(s){return s=s|0,357913941}function jke(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function qke(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function Gke(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function K5(s){s=s|0,Kke(s)}function Yke(s){s=s|0,Wke(s+24|0)}function Wke(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function Kke(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,8,l,Vke()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function Vke(){return 1320}function zke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;f=E,E=E+16|0,d=f+8|0,m=f,B=Jke(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Xke(l,d,c),E=f}function Jke(s){return s=s|0,(n[($F()|0)+24>>2]|0)+(s*12|0)|0}function Xke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;m=E,E=E+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),Zke(d,c),d=$ke(d,c)|0,tf[f&31](s,d),E=m}function Zke(s,l){s=s|0,l=l|0}function $ke(s,l){return s=s|0,l=l|0,eQe(l)|0}function eQe(s){return s=s|0,s|0}function tQe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],rQe(s,c,d,0),E=f}function rQe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=eR()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=nQe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,iQe(m,f)|0,f),E=d}function eR(){var s=0,l=0;if(o[7792]|0||(z5(9900),tr(43,9900,U|0)|0,l=7792,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9900)|0)){s=9900,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));z5(9900)}return 9900}function nQe(s){return s=s|0,0}function iQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=eR()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],V5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(sQe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function V5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function sQe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=oQe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,aQe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],V5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,lQe(s,k),cQe(k),E=M;return}}function oQe(s){return s=s|0,357913941}function aQe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function lQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function cQe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function z5(s){s=s|0,fQe(s)}function uQe(s){s=s|0,AQe(s+24|0)}function AQe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function fQe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,22,l,pQe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function pQe(){return 1344}function hQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0;c=E,E=E+16|0,f=c+8|0,d=c,m=gQe(s)|0,s=n[m+4>>2]|0,n[d>>2]=n[m>>2],n[d+4>>2]=s,n[f>>2]=n[d>>2],n[f+4>>2]=n[d+4>>2],dQe(l,f),E=c}function gQe(s){return s=s|0,(n[(eR()|0)+24>>2]|0)+(s*12|0)|0}function dQe(s,l){s=s|0,l=l|0;var c=0;c=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(c=n[(n[s>>2]|0)+c>>2]|0),ef[c&127](s)}function mQe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=tR()|0,s=yQe(c)|0,hn(m,l,d,s,EQe(c,f)|0,f)}function tR(){var s=0,l=0;if(o[7800]|0||(X5(9936),tr(44,9936,U|0)|0,l=7800,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9936)|0)){s=9936,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));X5(9936)}return 9936}function yQe(s){return s=s|0,s|0}function EQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=E,E=E+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=tR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(J5(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(CQe(B,d,m),l=n[c>>2]|0),E=k,(l-(n[B>>2]|0)>>3)+-1|0}function J5(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function CQe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=E,E=E+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=wQe(s)|0,f>>>0<B>>>0)Jr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,IQe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,J5(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,BQe(s,d),vQe(d),E=k;return}}function wQe(s){return s=s|0,536870911}function IQe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function BQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function vQe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function X5(s){s=s|0,SQe(s)}function DQe(s){s=s|0,PQe(s+24|0)}function PQe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function SQe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,23,l,D5()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function xQe(s,l){s=s|0,l=l|0,kQe(n[(bQe(s)|0)>>2]|0,l)}function bQe(s){return s=s|0,(n[(tR()|0)+24>>2]|0)+(s<<3)|0}function kQe(s,l){s=s|0,l=l|0;var c=0,f=0;c=E,E=E+16|0,f=c,HF(f,l),l=jF(f,l)|0,ef[s&127](l),E=c}function QQe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=rR()|0,s=FQe(c)|0,hn(m,l,d,s,RQe(c,f)|0,f)}function rR(){var s=0,l=0;if(o[7808]|0||($5(9972),tr(45,9972,U|0)|0,l=7808,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9972)|0)){s=9972,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));$5(9972)}return 9972}function FQe(s){return s=s|0,s|0}function RQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=E,E=E+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=rR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(Z5(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(TQe(B,d,m),l=n[c>>2]|0),E=k,(l-(n[B>>2]|0)>>3)+-1|0}function Z5(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function TQe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=E,E=E+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=LQe(s)|0,f>>>0<B>>>0)Jr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,NQe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,Z5(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,OQe(s,d),MQe(d),E=k;return}}function LQe(s){return s=s|0,536870911}function NQe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function OQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function MQe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function $5(s){s=s|0,HQe(s)}function UQe(s){s=s|0,_Qe(s+24|0)}function _Qe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function HQe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,9,l,jQe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function jQe(){return 1348}function qQe(s,l){return s=s|0,l=l|0,YQe(n[(GQe(s)|0)>>2]|0,l)|0}function GQe(s){return s=s|0,(n[(rR()|0)+24>>2]|0)+(s<<3)|0}function YQe(s,l){s=s|0,l=l|0;var c=0,f=0;return c=E,E=E+16|0,f=c,e9(f,l),l=t9(f,l)|0,l=sD(Tg[s&31](l)|0)|0,E=c,l|0}function e9(s,l){s=s|0,l=l|0}function t9(s,l){return s=s|0,l=l|0,WQe(l)|0}function WQe(s){return s=s|0,s|0}function KQe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=nR()|0,s=VQe(c)|0,hn(m,l,d,s,zQe(c,f)|0,f)}function nR(){var s=0,l=0;if(o[7816]|0||(n9(10008),tr(46,10008,U|0)|0,l=7816,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10008)|0)){s=10008,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));n9(10008)}return 10008}function VQe(s){return s=s|0,s|0}function zQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=E,E=E+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=nR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(r9(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(JQe(B,d,m),l=n[c>>2]|0),E=k,(l-(n[B>>2]|0)>>3)+-1|0}function r9(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function JQe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=E,E=E+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=XQe(s)|0,f>>>0<B>>>0)Jr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,ZQe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,r9(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,$Qe(s,d),eFe(d),E=k;return}}function XQe(s){return s=s|0,536870911}function ZQe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function $Qe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function eFe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function n9(s){s=s|0,nFe(s)}function tFe(s){s=s|0,rFe(s+24|0)}function rFe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function nFe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,15,l,E5()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function iFe(s){return s=s|0,oFe(n[(sFe(s)|0)>>2]|0)|0}function sFe(s){return s=s|0,(n[(nR()|0)+24>>2]|0)+(s<<3)|0}function oFe(s){return s=s|0,sD(CD[s&7]()|0)|0}function aFe(){var s=0;return o[7832]|0||(gFe(10052),tr(25,10052,U|0)|0,s=7832,n[s>>2]=1,n[s+4>>2]=0),10052}function lFe(s,l){s=s|0,l=l|0,n[s>>2]=cFe()|0,n[s+4>>2]=uFe()|0,n[s+12>>2]=l,n[s+8>>2]=AFe()|0,n[s+32>>2]=2}function cFe(){return 11709}function uFe(){return 1188}function AFe(){return aD()|0}function fFe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,(Pp(f,896)|0)==512?c|0&&(pFe(c),gt(c)):l|0&&(Pu(l),gt(l))}function Pp(s,l){return s=s|0,l=l|0,l&s|0}function pFe(s){s=s|0,s=n[s+4>>2]|0,s|0&&xp(s)}function aD(){var s=0;return o[7824]|0||(n[2511]=hFe()|0,n[2512]=0,s=7824,n[s>>2]=1,n[s+4>>2]=0),10044}function hFe(){return 0}function gFe(s){s=s|0,Ip(s)}function dFe(s){s=s|0;var l=0,c=0,f=0,d=0,m=0;l=E,E=E+32|0,c=l+24|0,m=l+16|0,d=l+8|0,f=l,mFe(s,4827),yFe(s,4834,3)|0,EFe(s,3682,47)|0,n[m>>2]=9,n[m+4>>2]=0,n[c>>2]=n[m>>2],n[c+4>>2]=n[m+4>>2],CFe(s,4841,c)|0,n[d>>2]=1,n[d+4>>2]=0,n[c>>2]=n[d>>2],n[c+4>>2]=n[d+4>>2],wFe(s,4871,c)|0,n[f>>2]=10,n[f+4>>2]=0,n[c>>2]=n[f>>2],n[c+4>>2]=n[f+4>>2],IFe(s,4891,c)|0,E=l}function mFe(s,l){s=s|0,l=l|0;var c=0;c=eTe()|0,n[s>>2]=c,tTe(c,l),Sp(n[s>>2]|0)}function yFe(s,l,c){return s=s|0,l=l|0,c=c|0,MRe(s,pn(l)|0,c,0),s|0}function EFe(s,l,c){return s=s|0,l=l|0,c=c|0,BRe(s,pn(l)|0,c,0),s|0}function CFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],iRe(s,l,d),E=f,s|0}function wFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],UFe(s,l,d),E=f,s|0}function IFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],BFe(s,l,d),E=f,s|0}function BFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],vFe(s,c,d,1),E=f}function vFe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=iR()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=DFe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,PFe(m,f)|0,f),E=d}function iR(){var s=0,l=0;if(o[7840]|0||(s9(10100),tr(48,10100,U|0)|0,l=7840,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10100)|0)){s=10100,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));s9(10100)}return 10100}function DFe(s){return s=s|0,0}function PFe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=iR()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],i9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(SFe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function i9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function SFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=xFe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,bFe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],i9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,kFe(s,k),QFe(k),E=M;return}}function xFe(s){return s=s|0,357913941}function bFe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function kFe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function QFe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function s9(s){s=s|0,TFe(s)}function FFe(s){s=s|0,RFe(s+24|0)}function RFe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function TFe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,6,l,LFe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function LFe(){return 1364}function NFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=E,E=E+16|0,d=f+8|0,m=f,B=OFe(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],c=MFe(l,d,c)|0,E=f,c|0}function OFe(s){return s=s|0,(n[(iR()|0)+24>>2]|0)+(s*12|0)|0}function MFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;return m=E,E=E+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),XA(d,c),d=ZA(d,c)|0,d=f5(RR[f&15](s,d)|0)|0,E=m,d|0}function UFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],_Fe(s,c,d,0),E=f}function _Fe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=sR()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=HFe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,jFe(m,f)|0,f),E=d}function sR(){var s=0,l=0;if(o[7848]|0||(a9(10136),tr(49,10136,U|0)|0,l=7848,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10136)|0)){s=10136,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));a9(10136)}return 10136}function HFe(s){return s=s|0,0}function jFe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=sR()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],o9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(qFe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function o9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function qFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=GFe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,YFe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],o9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,WFe(s,k),KFe(k),E=M;return}}function GFe(s){return s=s|0,357913941}function YFe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function WFe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function KFe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function a9(s){s=s|0,JFe(s)}function VFe(s){s=s|0,zFe(s+24|0)}function zFe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function JFe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,9,l,XFe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function XFe(){return 1372}function ZFe(s,l,c){s=s|0,l=l|0,c=+c;var f=0,d=0,m=0,B=0;f=E,E=E+16|0,d=f+8|0,m=f,B=$Fe(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],eRe(l,d,c),E=f}function $Fe(s){return s=s|0,(n[(sR()|0)+24>>2]|0)+(s*12|0)|0}function eRe(s,l,c){s=s|0,l=l|0,c=+c;var f=0,d=0,m=0,B=Ze;m=E,E=E+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),tRe(d,c),B=y(rRe(d,c)),w7[f&1](s,B),E=m}function tRe(s,l){s=s|0,l=+l}function rRe(s,l){return s=s|0,l=+l,y(nRe(l))}function nRe(s){return s=+s,y(s)}function iRe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],sRe(s,c,d,0),E=f}function sRe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=E,E=E+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=oR()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=oRe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,aRe(m,f)|0,f),E=d}function oR(){var s=0,l=0;if(o[7856]|0||(c9(10172),tr(50,10172,U|0)|0,l=7856,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10172)|0)){s=10172,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));c9(10172)}return 10172}function oRe(s){return s=s|0,0}function aRe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0;return O=E,E=E+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,q=oR()|0,M=q+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=q+28|0,c=n[l>>2]|0,c>>>0<(n[q+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],l9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(lRe(M,k,Q),s=n[l>>2]|0),E=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function l9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function lRe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;if(M=E,E=E+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=cRe(s)|0,m>>>0<d>>>0)Jr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,q=se<<1,uRe(k,se>>>0<m>>>1>>>0?q>>>0<d>>>0?d:q:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],l9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,ARe(s,k),fRe(k),E=M;return}}function cRe(s){return s=s|0,357913941}function uRe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function ARe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function fRe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function c9(s){s=s|0,gRe(s)}function pRe(s){s=s|0,hRe(s+24|0)}function hRe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function gRe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,3,l,dRe()|0,2),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function dRe(){return 1380}function mRe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;d=E,E=E+16|0,m=d+8|0,B=d,k=yRe(s)|0,s=n[k+4>>2]|0,n[B>>2]=n[k>>2],n[B+4>>2]=s,n[m>>2]=n[B>>2],n[m+4>>2]=n[B+4>>2],ERe(l,m,c,f),E=d}function yRe(s){return s=s|0,(n[(oR()|0)+24>>2]|0)+(s*12|0)|0}function ERe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;k=E,E=E+16|0,m=k+1|0,B=k,d=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(d=n[(n[s>>2]|0)+d>>2]|0),XA(m,c),m=ZA(m,c)|0,CRe(B,f),B=wRe(B,f)|0,_w[d&15](s,m,B),E=k}function CRe(s,l){s=s|0,l=l|0}function wRe(s,l){return s=s|0,l=l|0,IRe(l)|0}function IRe(s){return s=s|0,(s|0)!=0|0}function BRe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=aR()|0,s=vRe(c)|0,hn(m,l,d,s,DRe(c,f)|0,f)}function aR(){var s=0,l=0;if(o[7864]|0||(A9(10208),tr(51,10208,U|0)|0,l=7864,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10208)|0)){s=10208,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));A9(10208)}return 10208}function vRe(s){return s=s|0,s|0}function DRe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=E,E=E+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=aR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(u9(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(PRe(B,d,m),l=n[c>>2]|0),E=k,(l-(n[B>>2]|0)>>3)+-1|0}function u9(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function PRe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=E,E=E+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=SRe(s)|0,f>>>0<B>>>0)Jr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,xRe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,u9(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,bRe(s,d),kRe(d),E=k;return}}function SRe(s){return s=s|0,536870911}function xRe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function bRe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function kRe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function A9(s){s=s|0,RRe(s)}function QRe(s){s=s|0,FRe(s+24|0)}function FRe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function RRe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,24,l,TRe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function TRe(){return 1392}function LRe(s,l){s=s|0,l=l|0,ORe(n[(NRe(s)|0)>>2]|0,l)}function NRe(s){return s=s|0,(n[(aR()|0)+24>>2]|0)+(s<<3)|0}function ORe(s,l){s=s|0,l=l|0;var c=0,f=0;c=E,E=E+16|0,f=c,e9(f,l),l=t9(f,l)|0,ef[s&127](l),E=c}function MRe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=lR()|0,s=URe(c)|0,hn(m,l,d,s,_Re(c,f)|0,f)}function lR(){var s=0,l=0;if(o[7872]|0||(p9(10244),tr(52,10244,U|0)|0,l=7872,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10244)|0)){s=10244,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));p9(10244)}return 10244}function URe(s){return s=s|0,s|0}function _Re(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=E,E=E+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=lR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(f9(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(HRe(B,d,m),l=n[c>>2]|0),E=k,(l-(n[B>>2]|0)>>3)+-1|0}function f9(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function HRe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=E,E=E+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=jRe(s)|0,f>>>0<B>>>0)Jr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,qRe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,f9(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,GRe(s,d),YRe(d),E=k;return}}function jRe(s){return s=s|0,536870911}function qRe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function GRe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function YRe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function p9(s){s=s|0,VRe(s)}function WRe(s){s=s|0,KRe(s+24|0)}function KRe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function VRe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,16,l,zRe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function zRe(){return 1400}function JRe(s){return s=s|0,ZRe(n[(XRe(s)|0)>>2]|0)|0}function XRe(s){return s=s|0,(n[(lR()|0)+24>>2]|0)+(s<<3)|0}function ZRe(s){return s=s|0,$Re(CD[s&7]()|0)|0}function $Re(s){return s=s|0,s|0}function eTe(){var s=0;return o[7880]|0||(aTe(10280),tr(25,10280,U|0)|0,s=7880,n[s>>2]=1,n[s+4>>2]=0),10280}function tTe(s,l){s=s|0,l=l|0,n[s>>2]=rTe()|0,n[s+4>>2]=nTe()|0,n[s+12>>2]=l,n[s+8>>2]=iTe()|0,n[s+32>>2]=4}function rTe(){return 11711}function nTe(){return 1356}function iTe(){return aD()|0}function sTe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,(Pp(f,896)|0)==512?c|0&&(oTe(c),gt(c)):l|0&&(Bg(l),gt(l))}function oTe(s){s=s|0,s=n[s+4>>2]|0,s|0&&xp(s)}function aTe(s){s=s|0,Ip(s)}function lTe(s){s=s|0,cTe(s,4920),uTe(s)|0,ATe(s)|0}function cTe(s,l){s=s|0,l=l|0;var c=0;c=L5()|0,n[s>>2]=c,FTe(c,l),Sp(n[s>>2]|0)}function uTe(s){s=s|0;var l=0;return l=n[s>>2]|0,xg(l,ITe()|0),s|0}function ATe(s){s=s|0;var l=0;return l=n[s>>2]|0,xg(l,fTe()|0),s|0}function fTe(){var s=0;return o[7888]|0||(h9(10328),tr(53,10328,U|0)|0,s=7888,n[s>>2]=1,n[s+4>>2]=0),Tr(10328)|0||h9(10328),10328}function xg(s,l){s=s|0,l=l|0,hn(s,0,l,0,0,0)}function h9(s){s=s|0,gTe(s),bg(s,10)}function pTe(s){s=s|0,hTe(s+24|0)}function hTe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function gTe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,5,1,l,ETe()|0,2),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function dTe(s,l,c){s=s|0,l=l|0,c=+c,mTe(s,l,c)}function bg(s,l){s=s|0,l=l|0,n[s+20>>2]=l}function mTe(s,l,c){s=s|0,l=l|0,c=+c;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+16|0,m=f+8|0,k=f+13|0,d=f,B=f+12|0,XA(k,l),n[m>>2]=ZA(k,l)|0,ku(B,c),C[d>>3]=+Qu(B,c),yTe(s,m,d),E=f}function yTe(s,l,c){s=s|0,l=l|0,c=c|0,W(s+8|0,n[l>>2]|0,+C[c>>3]),o[s+24>>0]=1}function ETe(){return 1404}function CTe(s,l){return s=s|0,l=+l,wTe(s,l)|0}function wTe(s,l){s=s|0,l=+l;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return f=E,E=E+16|0,m=f+4|0,B=f+8|0,k=f,d=Wa(8)|0,c=d,Q=Kt(16)|0,XA(m,s),s=ZA(m,s)|0,ku(B,l),W(Q,s,+Qu(B,l)),B=c+4|0,n[B>>2]=Q,s=Kt(8)|0,B=n[B>>2]|0,n[k>>2]=0,n[m>>2]=n[k>>2],KF(s,B,m),n[d>>2]=s,E=f,c|0}function ITe(){var s=0;return o[7896]|0||(g9(10364),tr(54,10364,U|0)|0,s=7896,n[s>>2]=1,n[s+4>>2]=0),Tr(10364)|0||g9(10364),10364}function g9(s){s=s|0,DTe(s),bg(s,55)}function BTe(s){s=s|0,vTe(s+24|0)}function vTe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function DTe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,5,4,l,bTe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function PTe(s){s=s|0,STe(s)}function STe(s){s=s|0,xTe(s)}function xTe(s){s=s|0,d9(s+8|0),o[s+24>>0]=1}function d9(s){s=s|0,n[s>>2]=0,C[s+8>>3]=0}function bTe(){return 1424}function kTe(){return QTe()|0}function QTe(){var s=0,l=0,c=0,f=0,d=0,m=0,B=0;return l=E,E=E+16|0,d=l+4|0,B=l,c=Wa(8)|0,s=c,f=Kt(16)|0,d9(f),m=s+4|0,n[m>>2]=f,f=Kt(8)|0,m=n[m>>2]|0,n[B>>2]=0,n[d>>2]=n[B>>2],KF(f,m,d),n[c>>2]=f,E=l,s|0}function FTe(s,l){s=s|0,l=l|0,n[s>>2]=RTe()|0,n[s+4>>2]=TTe()|0,n[s+12>>2]=l,n[s+8>>2]=LTe()|0,n[s+32>>2]=5}function RTe(){return 11710}function TTe(){return 1416}function LTe(){return lD()|0}function NTe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,(Pp(f,896)|0)==512?c|0&&(OTe(c),gt(c)):l|0&>(l)}function OTe(s){s=s|0,s=n[s+4>>2]|0,s|0&&xp(s)}function lD(){var s=0;return o[7904]|0||(n[2600]=MTe()|0,n[2601]=0,s=7904,n[s>>2]=1,n[s+4>>2]=0),10400}function MTe(){return n[357]|0}function UTe(s){s=s|0,_Te(s,4926),HTe(s)|0}function _Te(s,l){s=s|0,l=l|0;var c=0;c=i5()|0,n[s>>2]=c,ZTe(c,l),Sp(n[s>>2]|0)}function HTe(s){s=s|0;var l=0;return l=n[s>>2]|0,xg(l,jTe()|0),s|0}function jTe(){var s=0;return o[7912]|0||(m9(10412),tr(56,10412,U|0)|0,s=7912,n[s>>2]=1,n[s+4>>2]=0),Tr(10412)|0||m9(10412),10412}function m9(s){s=s|0,YTe(s),bg(s,57)}function qTe(s){s=s|0,GTe(s+24|0)}function GTe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function YTe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,5,5,l,zTe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function WTe(s){s=s|0,KTe(s)}function KTe(s){s=s|0,VTe(s)}function VTe(s){s=s|0;var l=0,c=0;l=s+8|0,c=l+48|0;do n[l>>2]=0,l=l+4|0;while((l|0)<(c|0));o[s+56>>0]=1}function zTe(){return 1432}function JTe(){return XTe()|0}function XTe(){var s=0,l=0,c=0,f=0,d=0,m=0,B=0,k=0;B=E,E=E+16|0,s=B+4|0,l=B,c=Wa(8)|0,f=c,d=Kt(48)|0,m=d,k=m+48|0;do n[m>>2]=0,m=m+4|0;while((m|0)<(k|0));return m=f+4|0,n[m>>2]=d,k=Kt(8)|0,m=n[m>>2]|0,n[l>>2]=0,n[s>>2]=n[l>>2],s5(k,m,s),n[c>>2]=k,E=B,f|0}function ZTe(s,l){s=s|0,l=l|0,n[s>>2]=$Te()|0,n[s+4>>2]=eLe()|0,n[s+12>>2]=l,n[s+8>>2]=tLe()|0,n[s+32>>2]=6}function $Te(){return 11704}function eLe(){return 1436}function tLe(){return lD()|0}function rLe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,(Pp(f,896)|0)==512?c|0&&(nLe(c),gt(c)):l|0&>(l)}function nLe(s){s=s|0,s=n[s+4>>2]|0,s|0&&xp(s)}function iLe(s){s=s|0,sLe(s,4933),oLe(s)|0,aLe(s)|0}function sLe(s,l){s=s|0,l=l|0;var c=0;c=QLe()|0,n[s>>2]=c,FLe(c,l),Sp(n[s>>2]|0)}function oLe(s){s=s|0;var l=0;return l=n[s>>2]|0,xg(l,wLe()|0),s|0}function aLe(s){s=s|0;var l=0;return l=n[s>>2]|0,xg(l,lLe()|0),s|0}function lLe(){var s=0;return o[7920]|0||(y9(10452),tr(58,10452,U|0)|0,s=7920,n[s>>2]=1,n[s+4>>2]=0),Tr(10452)|0||y9(10452),10452}function y9(s){s=s|0,ALe(s),bg(s,1)}function cLe(s){s=s|0,uLe(s+24|0)}function uLe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function ALe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,5,1,l,gLe()|0,2),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function fLe(s,l,c){s=s|0,l=+l,c=+c,pLe(s,l,c)}function pLe(s,l,c){s=s|0,l=+l,c=+c;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+32|0,m=f+8|0,k=f+17|0,d=f,B=f+16|0,ku(k,l),C[m>>3]=+Qu(k,l),ku(B,c),C[d>>3]=+Qu(B,c),hLe(s,m,d),E=f}function hLe(s,l,c){s=s|0,l=l|0,c=c|0,E9(s+8|0,+C[l>>3],+C[c>>3]),o[s+24>>0]=1}function E9(s,l,c){s=s|0,l=+l,c=+c,C[s>>3]=l,C[s+8>>3]=c}function gLe(){return 1472}function dLe(s,l){return s=+s,l=+l,mLe(s,l)|0}function mLe(s,l){s=+s,l=+l;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return f=E,E=E+16|0,B=f+4|0,k=f+8|0,Q=f,d=Wa(8)|0,c=d,m=Kt(16)|0,ku(B,s),s=+Qu(B,s),ku(k,l),E9(m,s,+Qu(k,l)),k=c+4|0,n[k>>2]=m,m=Kt(8)|0,k=n[k>>2]|0,n[Q>>2]=0,n[B>>2]=n[Q>>2],C9(m,k,B),n[d>>2]=m,E=f,c|0}function C9(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,c=Kt(16)|0,n[c+4>>2]=0,n[c+8>>2]=0,n[c>>2]=1452,n[c+12>>2]=l,n[s+4>>2]=c}function yLe(s){s=s|0,zm(s),gt(s)}function ELe(s){s=s|0,s=n[s+12>>2]|0,s|0&>(s)}function CLe(s){s=s|0,gt(s)}function wLe(){var s=0;return o[7928]|0||(w9(10488),tr(59,10488,U|0)|0,s=7928,n[s>>2]=1,n[s+4>>2]=0),Tr(10488)|0||w9(10488),10488}function w9(s){s=s|0,vLe(s),bg(s,60)}function ILe(s){s=s|0,BLe(s+24|0)}function BLe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function vLe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,5,6,l,xLe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function DLe(s){s=s|0,PLe(s)}function PLe(s){s=s|0,SLe(s)}function SLe(s){s=s|0,I9(s+8|0),o[s+24>>0]=1}function I9(s){s=s|0,n[s>>2]=0,n[s+4>>2]=0,n[s+8>>2]=0,n[s+12>>2]=0}function xLe(){return 1492}function bLe(){return kLe()|0}function kLe(){var s=0,l=0,c=0,f=0,d=0,m=0,B=0;return l=E,E=E+16|0,d=l+4|0,B=l,c=Wa(8)|0,s=c,f=Kt(16)|0,I9(f),m=s+4|0,n[m>>2]=f,f=Kt(8)|0,m=n[m>>2]|0,n[B>>2]=0,n[d>>2]=n[B>>2],C9(f,m,d),n[c>>2]=f,E=l,s|0}function QLe(){var s=0;return o[7936]|0||(MLe(10524),tr(25,10524,U|0)|0,s=7936,n[s>>2]=1,n[s+4>>2]=0),10524}function FLe(s,l){s=s|0,l=l|0,n[s>>2]=RLe()|0,n[s+4>>2]=TLe()|0,n[s+12>>2]=l,n[s+8>>2]=LLe()|0,n[s+32>>2]=7}function RLe(){return 11700}function TLe(){return 1484}function LLe(){return lD()|0}function NLe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,(Pp(f,896)|0)==512?c|0&&(OLe(c),gt(c)):l|0&>(l)}function OLe(s){s=s|0,s=n[s+4>>2]|0,s|0&&xp(s)}function MLe(s){s=s|0,Ip(s)}function ULe(s,l,c){s=s|0,l=l|0,c=c|0,s=pn(l)|0,l=_Le(c)|0,c=HLe(c,0)|0,mNe(s,l,c,cR()|0,0)}function _Le(s){return s=s|0,s|0}function HLe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=E,E=E+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=cR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(v9(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(VLe(B,d,m),l=n[c>>2]|0),E=k,(l-(n[B>>2]|0)>>3)+-1|0}function cR(){var s=0,l=0;if(o[7944]|0||(B9(10568),tr(61,10568,U|0)|0,l=7944,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10568)|0)){s=10568,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));B9(10568)}return 10568}function B9(s){s=s|0,GLe(s)}function jLe(s){s=s|0,qLe(s+24|0)}function qLe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function GLe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,17,l,I5()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function YLe(s){return s=s|0,KLe(n[(WLe(s)|0)>>2]|0)|0}function WLe(s){return s=s|0,(n[(cR()|0)+24>>2]|0)+(s<<3)|0}function KLe(s){return s=s|0,oD(CD[s&7]()|0)|0}function v9(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function VLe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=E,E=E+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=zLe(s)|0,f>>>0<B>>>0)Jr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,JLe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,v9(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,XLe(s,d),ZLe(d),E=k;return}}function zLe(s){return s=s|0,536870911}function JLe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function XLe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function ZLe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function $Le(){eNe()}function eNe(){tNe(10604)}function tNe(s){s=s|0,rNe(s,4955)}function rNe(s,l){s=s|0,l=l|0;var c=0;c=nNe()|0,n[s>>2]=c,iNe(c,l),Sp(n[s>>2]|0)}function nNe(){var s=0;return o[7952]|0||(pNe(10612),tr(25,10612,U|0)|0,s=7952,n[s>>2]=1,n[s+4>>2]=0),10612}function iNe(s,l){s=s|0,l=l|0,n[s>>2]=lNe()|0,n[s+4>>2]=cNe()|0,n[s+12>>2]=l,n[s+8>>2]=uNe()|0,n[s+32>>2]=8}function Sp(s){s=s|0;var l=0,c=0;l=E,E=E+16|0,c=l,Gm()|0,n[c>>2]=s,sNe(10608,c),E=l}function Gm(){return o[11714]|0||(n[2652]=0,tr(62,10608,U|0)|0,o[11714]=1),10608}function sNe(s,l){s=s|0,l=l|0;var c=0;c=Kt(8)|0,n[c+4>>2]=n[l>>2],n[c>>2]=n[s>>2],n[s>>2]=c}function oNe(s){s=s|0,aNe(s)}function aNe(s){s=s|0;var l=0,c=0;if(l=n[s>>2]|0,l|0)do c=l,l=n[l>>2]|0,gt(c);while((l|0)!=0);n[s>>2]=0}function lNe(){return 11715}function cNe(){return 1496}function uNe(){return aD()|0}function ANe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,(Pp(f,896)|0)==512?c|0&&(fNe(c),gt(c)):l|0&>(l)}function fNe(s){s=s|0,s=n[s+4>>2]|0,s|0&&xp(s)}function pNe(s){s=s|0,Ip(s)}function hNe(s,l){s=s|0,l=l|0;var c=0,f=0;Gm()|0,c=n[2652]|0;e:do if(c|0){for(;f=n[c+4>>2]|0,!(f|0&&(s7(uR(f)|0,s)|0)==0);)if(c=n[c>>2]|0,!c)break e;gNe(f,l)}while(0)}function uR(s){return s=s|0,n[s+12>>2]|0}function gNe(s,l){s=s|0,l=l|0;var c=0;s=s+36|0,c=n[s>>2]|0,c|0&&(jA(c),gt(c)),c=Kt(4)|0,ZG(c,l),n[s>>2]=c}function AR(){return o[11716]|0||(n[2664]=0,tr(63,10656,U|0)|0,o[11716]=1),10656}function D9(){var s=0;return o[11717]|0?s=n[2665]|0:(dNe(),n[2665]=1504,o[11717]=1,s=1504),s|0}function dNe(){o[11740]|0||(o[11718]=gr(gr(8,0)|0,0)|0,o[11719]=gr(gr(0,0)|0,0)|0,o[11720]=gr(gr(0,16)|0,0)|0,o[11721]=gr(gr(8,0)|0,0)|0,o[11722]=gr(gr(0,0)|0,0)|0,o[11723]=gr(gr(8,0)|0,0)|0,o[11724]=gr(gr(0,0)|0,0)|0,o[11725]=gr(gr(8,0)|0,0)|0,o[11726]=gr(gr(0,0)|0,0)|0,o[11727]=gr(gr(8,0)|0,0)|0,o[11728]=gr(gr(0,0)|0,0)|0,o[11729]=gr(gr(0,0)|0,32)|0,o[11730]=gr(gr(0,0)|0,32)|0,o[11740]=1)}function P9(){return 1572}function mNe(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0,k=0,Q=0,M=0,O=0;m=E,E=E+32|0,O=m+16|0,M=m+12|0,Q=m+8|0,k=m+4|0,B=m,n[O>>2]=s,n[M>>2]=l,n[Q>>2]=c,n[k>>2]=f,n[B>>2]=d,AR()|0,yNe(10656,O,M,Q,k,B),E=m}function yNe(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0;var B=0;B=Kt(24)|0,t5(B+4|0,n[l>>2]|0,n[c>>2]|0,n[f>>2]|0,n[d>>2]|0,n[m>>2]|0),n[B>>2]=n[s>>2],n[s>>2]=B}function S9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0,Ge=0,Me=0,Qe=0,et=0,Xe=0,at=0;if(at=E,E=E+32|0,Me=at+20|0,Qe=at+8|0,et=at+4|0,Xe=at,l=n[l>>2]|0,l|0){Ge=Me+4|0,Q=Me+8|0,M=Qe+4|0,O=Qe+8|0,q=Qe+8|0,se=Me+8|0;do{if(B=l+4|0,k=fR(B)|0,k|0){if(d=Tw(k)|0,n[Me>>2]=0,n[Ge>>2]=0,n[Q>>2]=0,f=(Lw(k)|0)+1|0,ENe(Me,f),f|0)for(;f=f+-1|0,xc(Qe,n[d>>2]|0),m=n[Ge>>2]|0,m>>>0<(n[se>>2]|0)>>>0?(n[m>>2]=n[Qe>>2],n[Ge>>2]=(n[Ge>>2]|0)+4):pR(Me,Qe),f;)d=d+4|0;f=Nw(k)|0,n[Qe>>2]=0,n[M>>2]=0,n[O>>2]=0;e:do if(n[f>>2]|0)for(d=0,m=0;;){if((d|0)==(m|0)?CNe(Qe,f):(n[d>>2]=n[f>>2],n[M>>2]=(n[M>>2]|0)+4),f=f+4|0,!(n[f>>2]|0))break e;d=n[M>>2]|0,m=n[q>>2]|0}while(0);n[et>>2]=cD(B)|0,n[Xe>>2]=Tr(k)|0,wNe(c,s,et,Xe,Me,Qe),hR(Qe),$A(Me)}l=n[l>>2]|0}while((l|0)!=0)}E=at}function fR(s){return s=s|0,n[s+12>>2]|0}function Tw(s){return s=s|0,n[s+12>>2]|0}function Lw(s){return s=s|0,n[s+16>>2]|0}function ENe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0;d=E,E=E+32|0,c=d,f=n[s>>2]|0,(n[s+8>>2]|0)-f>>2>>>0<l>>>0&&(L9(c,l,(n[s+4>>2]|0)-f>>2,s+8|0),N9(s,c),O9(c)),E=d}function pR(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0;if(B=E,E=E+32|0,c=B,f=s+4|0,d=((n[f>>2]|0)-(n[s>>2]|0)>>2)+1|0,m=T9(s)|0,m>>>0<d>>>0)Jr(s);else{k=n[s>>2]|0,M=(n[s+8>>2]|0)-k|0,Q=M>>1,L9(c,M>>2>>>0<m>>>1>>>0?Q>>>0<d>>>0?d:Q:m,(n[f>>2]|0)-k>>2,s+8|0),m=c+8|0,n[n[m>>2]>>2]=n[l>>2],n[m>>2]=(n[m>>2]|0)+4,N9(s,c),O9(c),E=B;return}}function Nw(s){return s=s|0,n[s+8>>2]|0}function CNe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0;if(B=E,E=E+32|0,c=B,f=s+4|0,d=((n[f>>2]|0)-(n[s>>2]|0)>>2)+1|0,m=R9(s)|0,m>>>0<d>>>0)Jr(s);else{k=n[s>>2]|0,M=(n[s+8>>2]|0)-k|0,Q=M>>1,_Ne(c,M>>2>>>0<m>>>1>>>0?Q>>>0<d>>>0?d:Q:m,(n[f>>2]|0)-k>>2,s+8|0),m=c+8|0,n[n[m>>2]>>2]=n[l>>2],n[m>>2]=(n[m>>2]|0)+4,HNe(s,c),jNe(c),E=B;return}}function cD(s){return s=s|0,n[s>>2]|0}function wNe(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,INe(s,l,c,f,d,m)}function hR(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-4-f|0)>>>2)<<2)),gt(c))}function $A(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-4-f|0)>>>2)<<2)),gt(c))}function INe(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0;var B=0,k=0,Q=0,M=0,O=0,q=0;B=E,E=E+48|0,O=B+40|0,k=B+32|0,q=B+24|0,Q=B+12|0,M=B,Ka(k),s=da(s)|0,n[q>>2]=n[l>>2],c=n[c>>2]|0,f=n[f>>2]|0,gR(Q,d),BNe(M,m),n[O>>2]=n[q>>2],vNe(s,O,c,f,Q,M),hR(M),$A(Q),Va(k),E=B}function gR(s,l){s=s|0,l=l|0;var c=0,f=0;n[s>>2]=0,n[s+4>>2]=0,n[s+8>>2]=0,c=l+4|0,f=(n[c>>2]|0)-(n[l>>2]|0)>>2,f|0&&(MNe(s,f),UNe(s,n[l>>2]|0,n[c>>2]|0,f))}function BNe(s,l){s=s|0,l=l|0;var c=0,f=0;n[s>>2]=0,n[s+4>>2]=0,n[s+8>>2]=0,c=l+4|0,f=(n[c>>2]|0)-(n[l>>2]|0)>>2,f|0&&(NNe(s,f),ONe(s,n[l>>2]|0,n[c>>2]|0,f))}function vNe(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0;var B=0,k=0,Q=0,M=0,O=0,q=0;B=E,E=E+32|0,O=B+28|0,q=B+24|0,k=B+12|0,Q=B,M=Pl(DNe()|0)|0,n[q>>2]=n[l>>2],n[O>>2]=n[q>>2],l=kg(O)|0,c=x9(c)|0,f=dR(f)|0,n[k>>2]=n[d>>2],O=d+4|0,n[k+4>>2]=n[O>>2],q=d+8|0,n[k+8>>2]=n[q>>2],n[q>>2]=0,n[O>>2]=0,n[d>>2]=0,d=mR(k)|0,n[Q>>2]=n[m>>2],O=m+4|0,n[Q+4>>2]=n[O>>2],q=m+8|0,n[Q+8>>2]=n[q>>2],n[q>>2]=0,n[O>>2]=0,n[m>>2]=0,ao(0,M|0,s|0,l|0,c|0,f|0,d|0,PNe(Q)|0)|0,hR(Q),$A(k),E=B}function DNe(){var s=0;return o[7968]|0||(TNe(10708),s=7968,n[s>>2]=1,n[s+4>>2]=0),10708}function kg(s){return s=s|0,k9(s)|0}function x9(s){return s=s|0,b9(s)|0}function dR(s){return s=s|0,oD(s)|0}function mR(s){return s=s|0,xNe(s)|0}function PNe(s){return s=s|0,SNe(s)|0}function SNe(s){s=s|0;var l=0,c=0,f=0;if(f=(n[s+4>>2]|0)-(n[s>>2]|0)|0,c=f>>2,f=Wa(f+4|0)|0,n[f>>2]=c,c|0){l=0;do n[f+4+(l<<2)>>2]=b9(n[(n[s>>2]|0)+(l<<2)>>2]|0)|0,l=l+1|0;while((l|0)!=(c|0))}return f|0}function b9(s){return s=s|0,s|0}function xNe(s){s=s|0;var l=0,c=0,f=0;if(f=(n[s+4>>2]|0)-(n[s>>2]|0)|0,c=f>>2,f=Wa(f+4|0)|0,n[f>>2]=c,c|0){l=0;do n[f+4+(l<<2)>>2]=k9((n[s>>2]|0)+(l<<2)|0)|0,l=l+1|0;while((l|0)!=(c|0))}return f|0}function k9(s){s=s|0;var l=0,c=0,f=0,d=0;return d=E,E=E+32|0,l=d+12|0,c=d,f=bF(Q9()|0)|0,f?(kF(l,f),QF(c,l),uUe(s,c),s=FF(l)|0):s=bNe(s)|0,E=d,s|0}function Q9(){var s=0;return o[7960]|0||(RNe(10664),tr(25,10664,U|0)|0,s=7960,n[s>>2]=1,n[s+4>>2]=0),10664}function bNe(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0;return c=E,E=E+16|0,d=c+4|0,B=c,f=Wa(8)|0,l=f,k=Kt(4)|0,n[k>>2]=n[s>>2],m=l+4|0,n[m>>2]=k,s=Kt(8)|0,m=n[m>>2]|0,n[B>>2]=0,n[d>>2]=n[B>>2],F9(s,m,d),n[f>>2]=s,E=c,l|0}function F9(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,c=Kt(16)|0,n[c+4>>2]=0,n[c+8>>2]=0,n[c>>2]=1656,n[c+12>>2]=l,n[s+4>>2]=c}function kNe(s){s=s|0,zm(s),gt(s)}function QNe(s){s=s|0,s=n[s+12>>2]|0,s|0&>(s)}function FNe(s){s=s|0,gt(s)}function RNe(s){s=s|0,Ip(s)}function TNe(s){s=s|0,Sl(s,LNe()|0,5)}function LNe(){return 1676}function NNe(s,l){s=s|0,l=l|0;var c=0;if((R9(s)|0)>>>0<l>>>0&&Jr(s),l>>>0>1073741823)Rt();else{c=Kt(l<<2)|0,n[s+4>>2]=c,n[s>>2]=c,n[s+8>>2]=c+(l<<2);return}}function ONe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,f=s+4|0,s=c-l|0,(s|0)>0&&(Dr(n[f>>2]|0,l|0,s|0)|0,n[f>>2]=(n[f>>2]|0)+(s>>>2<<2))}function R9(s){return s=s|0,1073741823}function MNe(s,l){s=s|0,l=l|0;var c=0;if((T9(s)|0)>>>0<l>>>0&&Jr(s),l>>>0>1073741823)Rt();else{c=Kt(l<<2)|0,n[s+4>>2]=c,n[s>>2]=c,n[s+8>>2]=c+(l<<2);return}}function UNe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,f=s+4|0,s=c-l|0,(s|0)>0&&(Dr(n[f>>2]|0,l|0,s|0)|0,n[f>>2]=(n[f>>2]|0)+(s>>>2<<2))}function T9(s){return s=s|0,1073741823}function _Ne(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>1073741823)Rt();else{d=Kt(l<<2)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<2)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<2)}function HNe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>2)<<2)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function jNe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-4-l|0)>>>2)<<2)),s=n[s>>2]|0,s|0&>(s)}function L9(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>1073741823)Rt();else{d=Kt(l<<2)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<2)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<2)}function N9(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>2)<<2)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function O9(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-4-l|0)>>>2)<<2)),s=n[s>>2]|0,s|0&>(s)}function qNe(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0,Ge=0,Me=0,Qe=0;if(Qe=E,E=E+32|0,O=Qe+20|0,q=Qe+12|0,M=Qe+16|0,se=Qe+4|0,Ge=Qe,Me=Qe+8|0,k=D9()|0,m=n[k>>2]|0,B=n[m>>2]|0,B|0)for(Q=n[k+8>>2]|0,k=n[k+4>>2]|0;xc(O,B),GNe(s,O,k,Q),m=m+4|0,B=n[m>>2]|0,B;)Q=Q+1|0,k=k+1|0;if(m=P9()|0,B=n[m>>2]|0,B|0)do xc(O,B),n[q>>2]=n[m+4>>2],YNe(l,O,q),m=m+8|0,B=n[m>>2]|0;while((B|0)!=0);if(m=n[(Gm()|0)>>2]|0,m|0)do l=n[m+4>>2]|0,xc(O,n[(Ym(l)|0)>>2]|0),n[q>>2]=uR(l)|0,WNe(c,O,q),m=n[m>>2]|0;while((m|0)!=0);if(xc(M,0),m=AR()|0,n[O>>2]=n[M>>2],S9(O,m,d),m=n[(Gm()|0)>>2]|0,m|0){s=O+4|0,l=O+8|0,c=O+8|0;do{if(Q=n[m+4>>2]|0,xc(q,n[(Ym(Q)|0)>>2]|0),KNe(se,M9(Q)|0),B=n[se>>2]|0,B|0){n[O>>2]=0,n[s>>2]=0,n[l>>2]=0;do xc(Ge,n[(Ym(n[B+4>>2]|0)|0)>>2]|0),k=n[s>>2]|0,k>>>0<(n[c>>2]|0)>>>0?(n[k>>2]=n[Ge>>2],n[s>>2]=(n[s>>2]|0)+4):pR(O,Ge),B=n[B>>2]|0;while((B|0)!=0);VNe(f,q,O),$A(O)}n[Me>>2]=n[q>>2],M=U9(Q)|0,n[O>>2]=n[Me>>2],S9(O,M,d),a5(se),m=n[m>>2]|0}while((m|0)!=0)}E=Qe}function GNe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,oOe(s,l,c,f)}function YNe(s,l,c){s=s|0,l=l|0,c=c|0,sOe(s,l,c)}function Ym(s){return s=s|0,s|0}function WNe(s,l,c){s=s|0,l=l|0,c=c|0,tOe(s,l,c)}function M9(s){return s=s|0,s+16|0}function KNe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;if(m=E,E=E+16|0,d=m+8|0,c=m,n[s>>2]=0,f=n[l>>2]|0,n[d>>2]=f,n[c>>2]=s,c=eOe(c)|0,f|0){if(f=Kt(12)|0,B=(_9(d)|0)+4|0,s=n[B+4>>2]|0,l=f+4|0,n[l>>2]=n[B>>2],n[l+4>>2]=s,l=n[n[d>>2]>>2]|0,n[d>>2]=l,!l)s=f;else for(l=f;s=Kt(12)|0,Q=(_9(d)|0)+4|0,k=n[Q+4>>2]|0,B=s+4|0,n[B>>2]=n[Q>>2],n[B+4>>2]=k,n[l>>2]=s,B=n[n[d>>2]>>2]|0,n[d>>2]=B,B;)l=s;n[s>>2]=n[c>>2],n[c>>2]=f}E=m}function VNe(s,l,c){s=s|0,l=l|0,c=c|0,zNe(s,l,c)}function U9(s){return s=s|0,s+24|0}function zNe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+32|0,B=f+24|0,d=f+16|0,k=f+12|0,m=f,Ka(d),s=da(s)|0,n[k>>2]=n[l>>2],gR(m,c),n[B>>2]=n[k>>2],JNe(s,B,m),$A(m),Va(d),E=f}function JNe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=E,E=E+32|0,B=f+16|0,k=f+12|0,d=f,m=Pl(XNe()|0)|0,n[k>>2]=n[l>>2],n[B>>2]=n[k>>2],l=kg(B)|0,n[d>>2]=n[c>>2],B=c+4|0,n[d+4>>2]=n[B>>2],k=c+8|0,n[d+8>>2]=n[k>>2],n[k>>2]=0,n[B>>2]=0,n[c>>2]=0,oo(0,m|0,s|0,l|0,mR(d)|0)|0,$A(d),E=f}function XNe(){var s=0;return o[7976]|0||(ZNe(10720),s=7976,n[s>>2]=1,n[s+4>>2]=0),10720}function ZNe(s){s=s|0,Sl(s,$Ne()|0,2)}function $Ne(){return 1732}function eOe(s){return s=s|0,n[s>>2]|0}function _9(s){return s=s|0,n[s>>2]|0}function tOe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;f=E,E=E+32|0,m=f+16|0,d=f+8|0,B=f,Ka(d),s=da(s)|0,n[B>>2]=n[l>>2],c=n[c>>2]|0,n[m>>2]=n[B>>2],H9(s,m,c),Va(d),E=f}function H9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;f=E,E=E+16|0,m=f+4|0,B=f,d=Pl(rOe()|0)|0,n[B>>2]=n[l>>2],n[m>>2]=n[B>>2],l=kg(m)|0,oo(0,d|0,s|0,l|0,x9(c)|0)|0,E=f}function rOe(){var s=0;return o[7984]|0||(nOe(10732),s=7984,n[s>>2]=1,n[s+4>>2]=0),10732}function nOe(s){s=s|0,Sl(s,iOe()|0,2)}function iOe(){return 1744}function sOe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;f=E,E=E+32|0,m=f+16|0,d=f+8|0,B=f,Ka(d),s=da(s)|0,n[B>>2]=n[l>>2],c=n[c>>2]|0,n[m>>2]=n[B>>2],H9(s,m,c),Va(d),E=f}function oOe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;d=E,E=E+32|0,B=d+16|0,m=d+8|0,k=d,Ka(m),s=da(s)|0,n[k>>2]=n[l>>2],c=o[c>>0]|0,f=o[f>>0]|0,n[B>>2]=n[k>>2],aOe(s,B,c,f),Va(m),E=d}function aOe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;d=E,E=E+16|0,B=d+4|0,k=d,m=Pl(lOe()|0)|0,n[k>>2]=n[l>>2],n[B>>2]=n[k>>2],l=kg(B)|0,c=Wm(c)|0,pc(0,m|0,s|0,l|0,c|0,Wm(f)|0)|0,E=d}function lOe(){var s=0;return o[7992]|0||(uOe(10744),s=7992,n[s>>2]=1,n[s+4>>2]=0),10744}function Wm(s){return s=s|0,cOe(s)|0}function cOe(s){return s=s|0,s&255|0}function uOe(s){s=s|0,Sl(s,AOe()|0,3)}function AOe(){return 1756}function fOe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;switch(se=E,E=E+32|0,k=se+8|0,Q=se+4|0,M=se+20|0,O=se,NF(s,0),f=cUe(l)|0,n[k>>2]=0,q=k+4|0,n[q>>2]=0,n[k+8>>2]=0,f<<24>>24){case 0:{o[M>>0]=0,pOe(Q,c,M),uD(s,Q)|0,qA(Q);break}case 8:{q=BR(l)|0,o[M>>0]=8,xc(O,n[q+4>>2]|0),hOe(Q,c,M,O,q+8|0),uD(s,Q)|0,qA(Q);break}case 9:{if(m=BR(l)|0,l=n[m+4>>2]|0,l|0)for(B=k+8|0,d=m+12|0;l=l+-1|0,xc(Q,n[d>>2]|0),f=n[q>>2]|0,f>>>0<(n[B>>2]|0)>>>0?(n[f>>2]=n[Q>>2],n[q>>2]=(n[q>>2]|0)+4):pR(k,Q),l;)d=d+4|0;o[M>>0]=9,xc(O,n[m+8>>2]|0),gOe(Q,c,M,O,k),uD(s,Q)|0,qA(Q);break}default:q=BR(l)|0,o[M>>0]=f,xc(O,n[q+4>>2]|0),dOe(Q,c,M,O),uD(s,Q)|0,qA(Q)}$A(k),E=se}function pOe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;f=E,E=E+16|0,d=f,Ka(d),l=da(l)|0,bOe(s,l,o[c>>0]|0),Va(d),E=f}function uD(s,l){s=s|0,l=l|0;var c=0;return c=n[s>>2]|0,c|0&&PA(c|0),n[s>>2]=n[l>>2],n[l>>2]=0,s|0}function hOe(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0,k=0,Q=0;m=E,E=E+32|0,k=m+16|0,B=m+8|0,Q=m,Ka(B),l=da(l)|0,c=o[c>>0]|0,n[Q>>2]=n[f>>2],d=n[d>>2]|0,n[k>>2]=n[Q>>2],DOe(s,l,c,k,d),Va(B),E=m}function gOe(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0,k=0,Q=0,M=0;m=E,E=E+32|0,Q=m+24|0,B=m+16|0,M=m+12|0,k=m,Ka(B),l=da(l)|0,c=o[c>>0]|0,n[M>>2]=n[f>>2],gR(k,d),n[Q>>2]=n[M>>2],wOe(s,l,c,Q,k),$A(k),Va(B),E=m}function dOe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;d=E,E=E+32|0,B=d+16|0,m=d+8|0,k=d,Ka(m),l=da(l)|0,c=o[c>>0]|0,n[k>>2]=n[f>>2],n[B>>2]=n[k>>2],mOe(s,l,c,B),Va(m),E=d}function mOe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;d=E,E=E+16|0,m=d+4|0,k=d,B=Pl(yOe()|0)|0,c=Wm(c)|0,n[k>>2]=n[f>>2],n[m>>2]=n[k>>2],AD(s,oo(0,B|0,l|0,c|0,kg(m)|0)|0),E=d}function yOe(){var s=0;return o[8e3]|0||(EOe(10756),s=8e3,n[s>>2]=1,n[s+4>>2]=0),10756}function AD(s,l){s=s|0,l=l|0,NF(s,l)}function EOe(s){s=s|0,Sl(s,COe()|0,2)}function COe(){return 1772}function wOe(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0,k=0,Q=0,M=0;m=E,E=E+32|0,Q=m+16|0,M=m+12|0,B=m,k=Pl(IOe()|0)|0,c=Wm(c)|0,n[M>>2]=n[f>>2],n[Q>>2]=n[M>>2],f=kg(Q)|0,n[B>>2]=n[d>>2],Q=d+4|0,n[B+4>>2]=n[Q>>2],M=d+8|0,n[B+8>>2]=n[M>>2],n[M>>2]=0,n[Q>>2]=0,n[d>>2]=0,AD(s,pc(0,k|0,l|0,c|0,f|0,mR(B)|0)|0),$A(B),E=m}function IOe(){var s=0;return o[8008]|0||(BOe(10768),s=8008,n[s>>2]=1,n[s+4>>2]=0),10768}function BOe(s){s=s|0,Sl(s,vOe()|0,3)}function vOe(){return 1784}function DOe(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0,k=0,Q=0;m=E,E=E+16|0,k=m+4|0,Q=m,B=Pl(POe()|0)|0,c=Wm(c)|0,n[Q>>2]=n[f>>2],n[k>>2]=n[Q>>2],f=kg(k)|0,AD(s,pc(0,B|0,l|0,c|0,f|0,dR(d)|0)|0),E=m}function POe(){var s=0;return o[8016]|0||(SOe(10780),s=8016,n[s>>2]=1,n[s+4>>2]=0),10780}function SOe(s){s=s|0,Sl(s,xOe()|0,3)}function xOe(){return 1800}function bOe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=Pl(kOe()|0)|0,AD(s,Qn(0,f|0,l|0,Wm(c)|0)|0)}function kOe(){var s=0;return o[8024]|0||(QOe(10792),s=8024,n[s>>2]=1,n[s+4>>2]=0),10792}function QOe(s){s=s|0,Sl(s,FOe()|0,1)}function FOe(){return 1816}function ROe(){TOe(),LOe(),NOe()}function TOe(){n[2702]=g7(65536)|0}function LOe(){rMe(10856)}function NOe(){OOe(10816)}function OOe(s){s=s|0,MOe(s,5044),UOe(s)|0}function MOe(s,l){s=s|0,l=l|0;var c=0;c=Q9()|0,n[s>>2]=c,JOe(c,l),Sp(n[s>>2]|0)}function UOe(s){s=s|0;var l=0;return l=n[s>>2]|0,xg(l,_Oe()|0),s|0}function _Oe(){var s=0;return o[8032]|0||(j9(10820),tr(64,10820,U|0)|0,s=8032,n[s>>2]=1,n[s+4>>2]=0),Tr(10820)|0||j9(10820),10820}function j9(s){s=s|0,qOe(s),bg(s,25)}function HOe(s){s=s|0,jOe(s+24|0)}function jOe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function qOe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,5,18,l,KOe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function GOe(s,l){s=s|0,l=l|0,YOe(s,l)}function YOe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0;c=E,E=E+16|0,f=c,d=c+4|0,Pg(d,l),n[f>>2]=Sg(d,l)|0,WOe(s,f),E=c}function WOe(s,l){s=s|0,l=l|0,q9(s+4|0,n[l>>2]|0),o[s+8>>0]=1}function q9(s,l){s=s|0,l=l|0,n[s>>2]=l}function KOe(){return 1824}function VOe(s){return s=s|0,zOe(s)|0}function zOe(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0;return c=E,E=E+16|0,d=c+4|0,B=c,f=Wa(8)|0,l=f,k=Kt(4)|0,Pg(d,s),q9(k,Sg(d,s)|0),m=l+4|0,n[m>>2]=k,s=Kt(8)|0,m=n[m>>2]|0,n[B>>2]=0,n[d>>2]=n[B>>2],F9(s,m,d),n[f>>2]=s,E=c,l|0}function Wa(s){s=s|0;var l=0,c=0;return s=s+7&-8,s>>>0<=32768&&(l=n[2701]|0,s>>>0<=(65536-l|0)>>>0)?(c=(n[2702]|0)+l|0,n[2701]=l+s,s=c):(s=g7(s+8|0)|0,n[s>>2]=n[2703],n[2703]=s,s=s+8|0),s|0}function JOe(s,l){s=s|0,l=l|0,n[s>>2]=XOe()|0,n[s+4>>2]=ZOe()|0,n[s+12>>2]=l,n[s+8>>2]=$Oe()|0,n[s+32>>2]=9}function XOe(){return 11744}function ZOe(){return 1832}function $Oe(){return lD()|0}function eMe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,(Pp(f,896)|0)==512?c|0&&(tMe(c),gt(c)):l|0&>(l)}function tMe(s){s=s|0,s=n[s+4>>2]|0,s|0&&xp(s)}function rMe(s){s=s|0,nMe(s,5052),iMe(s)|0,sMe(s,5058,26)|0,oMe(s,5069,1)|0,aMe(s,5077,10)|0,lMe(s,5087,19)|0,cMe(s,5094,27)|0}function nMe(s,l){s=s|0,l=l|0;var c=0;c=tUe()|0,n[s>>2]=c,rUe(c,l),Sp(n[s>>2]|0)}function iMe(s){s=s|0;var l=0;return l=n[s>>2]|0,xg(l,H4e()|0),s|0}function sMe(s,l,c){return s=s|0,l=l|0,c=c|0,B4e(s,pn(l)|0,c,0),s|0}function oMe(s,l,c){return s=s|0,l=l|0,c=c|0,l4e(s,pn(l)|0,c,0),s|0}function aMe(s,l,c){return s=s|0,l=l|0,c=c|0,_Me(s,pn(l)|0,c,0),s|0}function lMe(s,l,c){return s=s|0,l=l|0,c=c|0,DMe(s,pn(l)|0,c,0),s|0}function G9(s,l){s=s|0,l=l|0;var c=0,f=0;e:for(;;){for(c=n[2703]|0;;){if((c|0)==(l|0))break e;if(f=n[c>>2]|0,n[2703]=f,!c)c=f;else break}gt(c)}n[2701]=s}function cMe(s,l,c){return s=s|0,l=l|0,c=c|0,uMe(s,pn(l)|0,c,0),s|0}function uMe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=yR()|0,s=AMe(c)|0,hn(m,l,d,s,fMe(c,f)|0,f)}function yR(){var s=0,l=0;if(o[8040]|0||(W9(10860),tr(65,10860,U|0)|0,l=8040,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10860)|0)){s=10860,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));W9(10860)}return 10860}function AMe(s){return s=s|0,s|0}function fMe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=E,E=E+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=yR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(Y9(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(pMe(B,d,m),l=n[c>>2]|0),E=k,(l-(n[B>>2]|0)>>3)+-1|0}function Y9(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function pMe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=E,E=E+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=hMe(s)|0,f>>>0<B>>>0)Jr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,gMe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,Y9(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,dMe(s,d),mMe(d),E=k;return}}function hMe(s){return s=s|0,536870911}function gMe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function dMe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function mMe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function W9(s){s=s|0,CMe(s)}function yMe(s){s=s|0,EMe(s+24|0)}function EMe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function CMe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,11,l,wMe()|0,2),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function wMe(){return 1840}function IMe(s,l,c){s=s|0,l=l|0,c=c|0,vMe(n[(BMe(s)|0)>>2]|0,l,c)}function BMe(s){return s=s|0,(n[(yR()|0)+24>>2]|0)+(s<<3)|0}function vMe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;f=E,E=E+16|0,m=f+1|0,d=f,Pg(m,l),l=Sg(m,l)|0,Pg(d,c),c=Sg(d,c)|0,tf[s&31](l,c),E=f}function DMe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=ER()|0,s=PMe(c)|0,hn(m,l,d,s,SMe(c,f)|0,f)}function ER(){var s=0,l=0;if(o[8048]|0||(V9(10896),tr(66,10896,U|0)|0,l=8048,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10896)|0)){s=10896,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));V9(10896)}return 10896}function PMe(s){return s=s|0,s|0}function SMe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=E,E=E+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=ER()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(K9(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(xMe(B,d,m),l=n[c>>2]|0),E=k,(l-(n[B>>2]|0)>>3)+-1|0}function K9(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function xMe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=E,E=E+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=bMe(s)|0,f>>>0<B>>>0)Jr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,kMe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,K9(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,QMe(s,d),FMe(d),E=k;return}}function bMe(s){return s=s|0,536870911}function kMe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function QMe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function FMe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function V9(s){s=s|0,LMe(s)}function RMe(s){s=s|0,TMe(s+24|0)}function TMe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function LMe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,11,l,NMe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function NMe(){return 1852}function OMe(s,l){return s=s|0,l=l|0,UMe(n[(MMe(s)|0)>>2]|0,l)|0}function MMe(s){return s=s|0,(n[(ER()|0)+24>>2]|0)+(s<<3)|0}function UMe(s,l){s=s|0,l=l|0;var c=0,f=0;return c=E,E=E+16|0,f=c,Pg(f,l),l=Sg(f,l)|0,l=oD(Tg[s&31](l)|0)|0,E=c,l|0}function _Me(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=CR()|0,s=HMe(c)|0,hn(m,l,d,s,jMe(c,f)|0,f)}function CR(){var s=0,l=0;if(o[8056]|0||(J9(10932),tr(67,10932,U|0)|0,l=8056,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10932)|0)){s=10932,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));J9(10932)}return 10932}function HMe(s){return s=s|0,s|0}function jMe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=E,E=E+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=CR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(z9(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(qMe(B,d,m),l=n[c>>2]|0),E=k,(l-(n[B>>2]|0)>>3)+-1|0}function z9(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function qMe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=E,E=E+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=GMe(s)|0,f>>>0<B>>>0)Jr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,YMe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,z9(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,WMe(s,d),KMe(d),E=k;return}}function GMe(s){return s=s|0,536870911}function YMe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function WMe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function KMe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function J9(s){s=s|0,JMe(s)}function VMe(s){s=s|0,zMe(s+24|0)}function zMe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function JMe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,7,l,XMe()|0,2),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function XMe(){return 1860}function ZMe(s,l,c){return s=s|0,l=l|0,c=c|0,e4e(n[($Me(s)|0)>>2]|0,l,c)|0}function $Me(s){return s=s|0,(n[(CR()|0)+24>>2]|0)+(s<<3)|0}function e4e(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0;return f=E,E=E+32|0,B=f+12|0,m=f+8|0,k=f,Q=f+16|0,d=f+4|0,t4e(Q,l),r4e(k,Q,l),Bp(d,c),c=vp(d,c)|0,n[B>>2]=n[k>>2],_w[s&15](m,B,c),c=n4e(m)|0,qA(m),Dp(d),E=f,c|0}function t4e(s,l){s=s|0,l=l|0}function r4e(s,l,c){s=s|0,l=l|0,c=c|0,i4e(s,c)}function n4e(s){return s=s|0,da(s)|0}function i4e(s,l){s=s|0,l=l|0;var c=0,f=0,d=0;d=E,E=E+16|0,c=d,f=l,f&1?(s4e(c,0),ii(f|0,c|0)|0,o4e(s,c),a4e(c)):n[s>>2]=n[l>>2],E=d}function s4e(s,l){s=s|0,l=l|0,$G(s,l),n[s+4>>2]=0,o[s+8>>0]=0}function o4e(s,l){s=s|0,l=l|0,n[s>>2]=n[l+4>>2]}function a4e(s){s=s|0,o[s+8>>0]=0}function l4e(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=wR()|0,s=c4e(c)|0,hn(m,l,d,s,u4e(c,f)|0,f)}function wR(){var s=0,l=0;if(o[8064]|0||(Z9(10968),tr(68,10968,U|0)|0,l=8064,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10968)|0)){s=10968,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));Z9(10968)}return 10968}function c4e(s){return s=s|0,s|0}function u4e(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=E,E=E+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=wR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(X9(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(A4e(B,d,m),l=n[c>>2]|0),E=k,(l-(n[B>>2]|0)>>3)+-1|0}function X9(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function A4e(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=E,E=E+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=f4e(s)|0,f>>>0<B>>>0)Jr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,p4e(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,X9(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,h4e(s,d),g4e(d),E=k;return}}function f4e(s){return s=s|0,536870911}function p4e(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function h4e(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function g4e(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function Z9(s){s=s|0,y4e(s)}function d4e(s){s=s|0,m4e(s+24|0)}function m4e(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function y4e(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,1,l,E4e()|0,5),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function E4e(){return 1872}function C4e(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,I4e(n[(w4e(s)|0)>>2]|0,l,c,f,d,m)}function w4e(s){return s=s|0,(n[(wR()|0)+24>>2]|0)+(s<<3)|0}function I4e(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0;var B=0,k=0,Q=0,M=0,O=0,q=0;B=E,E=E+32|0,k=B+16|0,Q=B+12|0,M=B+8|0,O=B+4|0,q=B,Bp(k,l),l=vp(k,l)|0,Bp(Q,c),c=vp(Q,c)|0,Bp(M,f),f=vp(M,f)|0,Bp(O,d),d=vp(O,d)|0,Bp(q,m),m=vp(q,m)|0,C7[s&1](l,c,f,d,m),Dp(q),Dp(O),Dp(M),Dp(Q),Dp(k),E=B}function B4e(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=IR()|0,s=v4e(c)|0,hn(m,l,d,s,D4e(c,f)|0,f)}function IR(){var s=0,l=0;if(o[8072]|0||(e7(11004),tr(69,11004,U|0)|0,l=8072,n[l>>2]=1,n[l+4>>2]=0),!(Tr(11004)|0)){s=11004,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));e7(11004)}return 11004}function v4e(s){return s=s|0,s|0}function D4e(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=E,E=E+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=IR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?($9(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(P4e(B,d,m),l=n[c>>2]|0),E=k,(l-(n[B>>2]|0)>>3)+-1|0}function $9(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function P4e(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=E,E=E+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=S4e(s)|0,f>>>0<B>>>0)Jr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,x4e(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,$9(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,b4e(s,d),k4e(d),E=k;return}}function S4e(s){return s=s|0,536870911}function x4e(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function b4e(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function k4e(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function e7(s){s=s|0,R4e(s)}function Q4e(s){s=s|0,F4e(s+24|0)}function F4e(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function R4e(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,12,l,T4e()|0,2),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function T4e(){return 1896}function L4e(s,l,c){s=s|0,l=l|0,c=c|0,O4e(n[(N4e(s)|0)>>2]|0,l,c)}function N4e(s){return s=s|0,(n[(IR()|0)+24>>2]|0)+(s<<3)|0}function O4e(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;f=E,E=E+16|0,m=f+4|0,d=f,M4e(m,l),l=U4e(m,l)|0,Bp(d,c),c=vp(d,c)|0,tf[s&31](l,c),Dp(d),E=f}function M4e(s,l){s=s|0,l=l|0}function U4e(s,l){return s=s|0,l=l|0,_4e(l)|0}function _4e(s){return s=s|0,s|0}function H4e(){var s=0;return o[8080]|0||(t7(11040),tr(70,11040,U|0)|0,s=8080,n[s>>2]=1,n[s+4>>2]=0),Tr(11040)|0||t7(11040),11040}function t7(s){s=s|0,G4e(s),bg(s,71)}function j4e(s){s=s|0,q4e(s+24|0)}function q4e(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function G4e(s){s=s|0;var l=0;l=Kr()|0,Vr(s,5,7,l,V4e()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function Y4e(s){s=s|0,W4e(s)}function W4e(s){s=s|0,K4e(s)}function K4e(s){s=s|0,o[s+8>>0]=1}function V4e(){return 1936}function z4e(){return J4e()|0}function J4e(){var s=0,l=0,c=0,f=0,d=0,m=0,B=0;return l=E,E=E+16|0,d=l+4|0,B=l,c=Wa(8)|0,s=c,m=s+4|0,n[m>>2]=Kt(1)|0,f=Kt(8)|0,m=n[m>>2]|0,n[B>>2]=0,n[d>>2]=n[B>>2],X4e(f,m,d),n[c>>2]=f,E=l,s|0}function X4e(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,c=Kt(16)|0,n[c+4>>2]=0,n[c+8>>2]=0,n[c>>2]=1916,n[c+12>>2]=l,n[s+4>>2]=c}function Z4e(s){s=s|0,zm(s),gt(s)}function $4e(s){s=s|0,s=n[s+12>>2]|0,s|0&>(s)}function eUe(s){s=s|0,gt(s)}function tUe(){var s=0;return o[8088]|0||(lUe(11076),tr(25,11076,U|0)|0,s=8088,n[s>>2]=1,n[s+4>>2]=0),11076}function rUe(s,l){s=s|0,l=l|0,n[s>>2]=nUe()|0,n[s+4>>2]=iUe()|0,n[s+12>>2]=l,n[s+8>>2]=sUe()|0,n[s+32>>2]=10}function nUe(){return 11745}function iUe(){return 1940}function sUe(){return aD()|0}function oUe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,(Pp(f,896)|0)==512?c|0&&(aUe(c),gt(c)):l|0&>(l)}function aUe(s){s=s|0,s=n[s+4>>2]|0,s|0&&xp(s)}function lUe(s){s=s|0,Ip(s)}function xc(s,l){s=s|0,l=l|0,n[s>>2]=l}function BR(s){return s=s|0,n[s>>2]|0}function cUe(s){return s=s|0,o[n[s>>2]>>0]|0}function uUe(s,l){s=s|0,l=l|0;var c=0,f=0;c=E,E=E+16|0,f=c,n[f>>2]=n[s>>2],AUe(l,f)|0,E=c}function AUe(s,l){s=s|0,l=l|0;var c=0;return c=fUe(n[s>>2]|0,l)|0,l=s+4|0,n[(n[l>>2]|0)+8>>2]=c,n[(n[l>>2]|0)+8>>2]|0}function fUe(s,l){s=s|0,l=l|0;var c=0,f=0;return c=E,E=E+16|0,f=c,Ka(f),s=da(s)|0,l=pUe(s,n[l>>2]|0)|0,Va(f),E=c,l|0}function Ka(s){s=s|0,n[s>>2]=n[2701],n[s+4>>2]=n[2703]}function pUe(s,l){s=s|0,l=l|0;var c=0;return c=Pl(hUe()|0)|0,Qn(0,c|0,s|0,dR(l)|0)|0}function Va(s){s=s|0,G9(n[s>>2]|0,n[s+4>>2]|0)}function hUe(){var s=0;return o[8096]|0||(gUe(11120),s=8096,n[s>>2]=1,n[s+4>>2]=0),11120}function gUe(s){s=s|0,Sl(s,dUe()|0,1)}function dUe(){return 1948}function mUe(){yUe()}function yUe(){var s=0,l=0,c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0,Ge=0,Me=0,Qe=0;if(Me=E,E=E+16|0,O=Me+4|0,q=Me,Li(65536,10804,n[2702]|0,10812),c=D9()|0,l=n[c>>2]|0,s=n[l>>2]|0,s|0)for(f=n[c+8>>2]|0,c=n[c+4>>2]|0;uc(s|0,u[c>>0]|0|0,o[f>>0]|0),l=l+4|0,s=n[l>>2]|0,s;)f=f+1|0,c=c+1|0;if(s=P9()|0,l=n[s>>2]|0,l|0)do uu(l|0,n[s+4>>2]|0),s=s+8|0,l=n[s>>2]|0;while((l|0)!=0);uu(EUe()|0,5167),M=Gm()|0,s=n[M>>2]|0;e:do if(s|0){do CUe(n[s+4>>2]|0),s=n[s>>2]|0;while((s|0)!=0);if(s=n[M>>2]|0,s|0){Q=M;do{for(;d=s,s=n[s>>2]|0,d=n[d+4>>2]|0,!!(wUe(d)|0);)if(n[q>>2]=Q,n[O>>2]=n[q>>2],IUe(M,O)|0,!s)break e;if(BUe(d),Q=n[Q>>2]|0,l=r7(d)|0,m=Hi()|0,B=E,E=E+((1*(l<<2)|0)+15&-16)|0,k=E,E=E+((1*(l<<2)|0)+15&-16)|0,l=n[(M9(d)|0)>>2]|0,l|0)for(c=B,f=k;n[c>>2]=n[(Ym(n[l+4>>2]|0)|0)>>2],n[f>>2]=n[l+8>>2],l=n[l>>2]|0,l;)c=c+4|0,f=f+4|0;Qe=Ym(d)|0,l=vUe(d)|0,c=r7(d)|0,f=DUe(d)|0,Au(Qe|0,l|0,B|0,k|0,c|0,f|0,uR(d)|0),_i(m|0)}while((s|0)!=0)}}while(0);if(s=n[(AR()|0)>>2]|0,s|0)do Qe=s+4|0,M=fR(Qe)|0,d=Nw(M)|0,m=Tw(M)|0,B=(Lw(M)|0)+1|0,k=fD(M)|0,Q=n7(Qe)|0,M=Tr(M)|0,O=cD(Qe)|0,q=vR(Qe)|0,El(0,d|0,m|0,B|0,k|0,Q|0,M|0,O|0,q|0,DR(Qe)|0),s=n[s>>2]|0;while((s|0)!=0);s=n[(Gm()|0)>>2]|0;e:do if(s|0){t:for(;;){if(l=n[s+4>>2]|0,l|0&&(se=n[(Ym(l)|0)>>2]|0,Ge=n[(U9(l)|0)>>2]|0,Ge|0)){c=Ge;do{l=c+4|0,f=fR(l)|0;r:do if(f|0)switch(Tr(f)|0){case 0:break t;case 4:case 3:case 2:{k=Nw(f)|0,Q=Tw(f)|0,M=(Lw(f)|0)+1|0,O=fD(f)|0,q=Tr(f)|0,Qe=cD(l)|0,El(se|0,k|0,Q|0,M|0,O|0,0,q|0,Qe|0,vR(l)|0,DR(l)|0);break r}case 1:{B=Nw(f)|0,k=Tw(f)|0,Q=(Lw(f)|0)+1|0,M=fD(f)|0,O=n7(l)|0,q=Tr(f)|0,Qe=cD(l)|0,El(se|0,B|0,k|0,Q|0,M|0,O|0,q|0,Qe|0,vR(l)|0,DR(l)|0);break r}case 5:{M=Nw(f)|0,O=Tw(f)|0,q=(Lw(f)|0)+1|0,Qe=fD(f)|0,El(se|0,M|0,O|0,q|0,Qe|0,PUe(f)|0,Tr(f)|0,0,0,0);break r}default:break r}while(0);c=n[c>>2]|0}while((c|0)!=0)}if(s=n[s>>2]|0,!s)break e}Rt()}while(0);Ce(),E=Me}function EUe(){return 11703}function CUe(s){s=s|0,o[s+40>>0]=0}function wUe(s){return s=s|0,(o[s+40>>0]|0)!=0|0}function IUe(s,l){return s=s|0,l=l|0,l=SUe(l)|0,s=n[l>>2]|0,n[l>>2]=n[s>>2],gt(s),n[l>>2]|0}function BUe(s){s=s|0,o[s+40>>0]=1}function r7(s){return s=s|0,n[s+20>>2]|0}function vUe(s){return s=s|0,n[s+8>>2]|0}function DUe(s){return s=s|0,n[s+32>>2]|0}function fD(s){return s=s|0,n[s+4>>2]|0}function n7(s){return s=s|0,n[s+4>>2]|0}function vR(s){return s=s|0,n[s+8>>2]|0}function DR(s){return s=s|0,n[s+16>>2]|0}function PUe(s){return s=s|0,n[s+20>>2]|0}function SUe(s){return s=s|0,n[s>>2]|0}function pD(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0,Ge=0,Me=0,Qe=0,et=0,Xe=0,at=0,Ue=0,qe=0,Nt=0;Nt=E,E=E+16|0,se=Nt;do if(s>>>0<245){if(M=s>>>0<11?16:s+11&-8,s=M>>>3,q=n[2783]|0,c=q>>>s,c&3|0)return l=(c&1^1)+s|0,s=11172+(l<<1<<2)|0,c=s+8|0,f=n[c>>2]|0,d=f+8|0,m=n[d>>2]|0,(s|0)==(m|0)?n[2783]=q&~(1<<l):(n[m+12>>2]=s,n[c>>2]=m),qe=l<<3,n[f+4>>2]=qe|3,qe=f+qe+4|0,n[qe>>2]=n[qe>>2]|1,qe=d,E=Nt,qe|0;if(O=n[2785]|0,M>>>0>O>>>0){if(c|0)return l=2<<s,l=c<<s&(l|0-l),l=(l&0-l)+-1|0,B=l>>>12&16,l=l>>>B,c=l>>>5&8,l=l>>>c,d=l>>>2&4,l=l>>>d,s=l>>>1&2,l=l>>>s,f=l>>>1&1,f=(c|B|d|s|f)+(l>>>f)|0,l=11172+(f<<1<<2)|0,s=l+8|0,d=n[s>>2]|0,B=d+8|0,c=n[B>>2]|0,(l|0)==(c|0)?(s=q&~(1<<f),n[2783]=s):(n[c+12>>2]=l,n[s>>2]=c,s=q),m=(f<<3)-M|0,n[d+4>>2]=M|3,f=d+M|0,n[f+4>>2]=m|1,n[f+m>>2]=m,O|0&&(d=n[2788]|0,l=O>>>3,c=11172+(l<<1<<2)|0,l=1<<l,s&l?(s=c+8|0,l=n[s>>2]|0):(n[2783]=s|l,l=c,s=c+8|0),n[s>>2]=d,n[l+12>>2]=d,n[d+8>>2]=l,n[d+12>>2]=c),n[2785]=m,n[2788]=f,qe=B,E=Nt,qe|0;if(k=n[2784]|0,k){if(c=(k&0-k)+-1|0,B=c>>>12&16,c=c>>>B,m=c>>>5&8,c=c>>>m,Q=c>>>2&4,c=c>>>Q,f=c>>>1&2,c=c>>>f,s=c>>>1&1,s=n[11436+((m|B|Q|f|s)+(c>>>s)<<2)>>2]|0,c=(n[s+4>>2]&-8)-M|0,f=n[s+16+(((n[s+16>>2]|0)==0&1)<<2)>>2]|0,!f)Q=s,m=c;else{do B=(n[f+4>>2]&-8)-M|0,Q=B>>>0<c>>>0,c=Q?B:c,s=Q?f:s,f=n[f+16+(((n[f+16>>2]|0)==0&1)<<2)>>2]|0;while((f|0)!=0);Q=s,m=c}if(B=Q+M|0,Q>>>0<B>>>0){d=n[Q+24>>2]|0,l=n[Q+12>>2]|0;do if((l|0)==(Q|0)){if(s=Q+20|0,l=n[s>>2]|0,!l&&(s=Q+16|0,l=n[s>>2]|0,!l)){c=0;break}for(;;){if(c=l+20|0,f=n[c>>2]|0,f|0){l=f,s=c;continue}if(c=l+16|0,f=n[c>>2]|0,f)l=f,s=c;else break}n[s>>2]=0,c=l}else c=n[Q+8>>2]|0,n[c+12>>2]=l,n[l+8>>2]=c,c=l;while(0);do if(d|0){if(l=n[Q+28>>2]|0,s=11436+(l<<2)|0,(Q|0)==(n[s>>2]|0)){if(n[s>>2]=c,!c){n[2784]=k&~(1<<l);break}}else if(n[d+16+(((n[d+16>>2]|0)!=(Q|0)&1)<<2)>>2]=c,!c)break;n[c+24>>2]=d,l=n[Q+16>>2]|0,l|0&&(n[c+16>>2]=l,n[l+24>>2]=c),l=n[Q+20>>2]|0,l|0&&(n[c+20>>2]=l,n[l+24>>2]=c)}while(0);return m>>>0<16?(qe=m+M|0,n[Q+4>>2]=qe|3,qe=Q+qe+4|0,n[qe>>2]=n[qe>>2]|1):(n[Q+4>>2]=M|3,n[B+4>>2]=m|1,n[B+m>>2]=m,O|0&&(f=n[2788]|0,l=O>>>3,c=11172+(l<<1<<2)|0,l=1<<l,q&l?(s=c+8|0,l=n[s>>2]|0):(n[2783]=q|l,l=c,s=c+8|0),n[s>>2]=f,n[l+12>>2]=f,n[f+8>>2]=l,n[f+12>>2]=c),n[2785]=m,n[2788]=B),qe=Q+8|0,E=Nt,qe|0}else q=M}else q=M}else q=M}else if(s>>>0<=4294967231)if(s=s+11|0,M=s&-8,Q=n[2784]|0,Q){f=0-M|0,s=s>>>8,s?M>>>0>16777215?k=31:(q=(s+1048320|0)>>>16&8,Ue=s<<q,O=(Ue+520192|0)>>>16&4,Ue=Ue<<O,k=(Ue+245760|0)>>>16&2,k=14-(O|q|k)+(Ue<<k>>>15)|0,k=M>>>(k+7|0)&1|k<<1):k=0,c=n[11436+(k<<2)>>2]|0;e:do if(!c)c=0,s=0,Ue=57;else for(s=0,B=M<<((k|0)==31?0:25-(k>>>1)|0),m=0;;){if(d=(n[c+4>>2]&-8)-M|0,d>>>0<f>>>0)if(d)s=c,f=d;else{s=c,f=0,d=c,Ue=61;break e}if(d=n[c+20>>2]|0,c=n[c+16+(B>>>31<<2)>>2]|0,m=(d|0)==0|(d|0)==(c|0)?m:d,d=(c|0)==0,d){c=m,Ue=57;break}else B=B<<((d^1)&1)}while(0);if((Ue|0)==57){if((c|0)==0&(s|0)==0){if(s=2<<k,s=Q&(s|0-s),!s){q=M;break}q=(s&0-s)+-1|0,B=q>>>12&16,q=q>>>B,m=q>>>5&8,q=q>>>m,k=q>>>2&4,q=q>>>k,O=q>>>1&2,q=q>>>O,c=q>>>1&1,s=0,c=n[11436+((m|B|k|O|c)+(q>>>c)<<2)>>2]|0}c?(d=c,Ue=61):(k=s,B=f)}if((Ue|0)==61)for(;;)if(Ue=0,c=(n[d+4>>2]&-8)-M|0,q=c>>>0<f>>>0,c=q?c:f,s=q?d:s,d=n[d+16+(((n[d+16>>2]|0)==0&1)<<2)>>2]|0,d)f=c,Ue=61;else{k=s,B=c;break}if((k|0)!=0&&B>>>0<((n[2785]|0)-M|0)>>>0){if(m=k+M|0,k>>>0>=m>>>0)return qe=0,E=Nt,qe|0;d=n[k+24>>2]|0,l=n[k+12>>2]|0;do if((l|0)==(k|0)){if(s=k+20|0,l=n[s>>2]|0,!l&&(s=k+16|0,l=n[s>>2]|0,!l)){l=0;break}for(;;){if(c=l+20|0,f=n[c>>2]|0,f|0){l=f,s=c;continue}if(c=l+16|0,f=n[c>>2]|0,f)l=f,s=c;else break}n[s>>2]=0}else qe=n[k+8>>2]|0,n[qe+12>>2]=l,n[l+8>>2]=qe;while(0);do if(d){if(s=n[k+28>>2]|0,c=11436+(s<<2)|0,(k|0)==(n[c>>2]|0)){if(n[c>>2]=l,!l){f=Q&~(1<<s),n[2784]=f;break}}else if(n[d+16+(((n[d+16>>2]|0)!=(k|0)&1)<<2)>>2]=l,!l){f=Q;break}n[l+24>>2]=d,s=n[k+16>>2]|0,s|0&&(n[l+16>>2]=s,n[s+24>>2]=l),s=n[k+20>>2]|0,s&&(n[l+20>>2]=s,n[s+24>>2]=l),f=Q}else f=Q;while(0);do if(B>>>0>=16){if(n[k+4>>2]=M|3,n[m+4>>2]=B|1,n[m+B>>2]=B,l=B>>>3,B>>>0<256){c=11172+(l<<1<<2)|0,s=n[2783]|0,l=1<<l,s&l?(s=c+8|0,l=n[s>>2]|0):(n[2783]=s|l,l=c,s=c+8|0),n[s>>2]=m,n[l+12>>2]=m,n[m+8>>2]=l,n[m+12>>2]=c;break}if(l=B>>>8,l?B>>>0>16777215?l=31:(Ue=(l+1048320|0)>>>16&8,qe=l<<Ue,at=(qe+520192|0)>>>16&4,qe=qe<<at,l=(qe+245760|0)>>>16&2,l=14-(at|Ue|l)+(qe<<l>>>15)|0,l=B>>>(l+7|0)&1|l<<1):l=0,c=11436+(l<<2)|0,n[m+28>>2]=l,s=m+16|0,n[s+4>>2]=0,n[s>>2]=0,s=1<<l,!(f&s)){n[2784]=f|s,n[c>>2]=m,n[m+24>>2]=c,n[m+12>>2]=m,n[m+8>>2]=m;break}for(s=B<<((l|0)==31?0:25-(l>>>1)|0),c=n[c>>2]|0;;){if((n[c+4>>2]&-8|0)==(B|0)){Ue=97;break}if(f=c+16+(s>>>31<<2)|0,l=n[f>>2]|0,l)s=s<<1,c=l;else{Ue=96;break}}if((Ue|0)==96){n[f>>2]=m,n[m+24>>2]=c,n[m+12>>2]=m,n[m+8>>2]=m;break}else if((Ue|0)==97){Ue=c+8|0,qe=n[Ue>>2]|0,n[qe+12>>2]=m,n[Ue>>2]=m,n[m+8>>2]=qe,n[m+12>>2]=c,n[m+24>>2]=0;break}}else qe=B+M|0,n[k+4>>2]=qe|3,qe=k+qe+4|0,n[qe>>2]=n[qe>>2]|1;while(0);return qe=k+8|0,E=Nt,qe|0}else q=M}else q=M;else q=-1;while(0);if(c=n[2785]|0,c>>>0>=q>>>0)return l=c-q|0,s=n[2788]|0,l>>>0>15?(qe=s+q|0,n[2788]=qe,n[2785]=l,n[qe+4>>2]=l|1,n[qe+l>>2]=l,n[s+4>>2]=q|3):(n[2785]=0,n[2788]=0,n[s+4>>2]=c|3,qe=s+c+4|0,n[qe>>2]=n[qe>>2]|1),qe=s+8|0,E=Nt,qe|0;if(B=n[2786]|0,B>>>0>q>>>0)return at=B-q|0,n[2786]=at,qe=n[2789]|0,Ue=qe+q|0,n[2789]=Ue,n[Ue+4>>2]=at|1,n[qe+4>>2]=q|3,qe=qe+8|0,E=Nt,qe|0;if(n[2901]|0?s=n[2903]|0:(n[2903]=4096,n[2902]=4096,n[2904]=-1,n[2905]=-1,n[2906]=0,n[2894]=0,s=se&-16^1431655768,n[se>>2]=s,n[2901]=s,s=4096),k=q+48|0,Q=q+47|0,m=s+Q|0,d=0-s|0,M=m&d,M>>>0<=q>>>0||(s=n[2893]|0,s|0&&(O=n[2891]|0,se=O+M|0,se>>>0<=O>>>0|se>>>0>s>>>0)))return qe=0,E=Nt,qe|0;e:do if(n[2894]&4)l=0,Ue=133;else{c=n[2789]|0;t:do if(c){for(f=11580;s=n[f>>2]|0,!(s>>>0<=c>>>0&&(Qe=f+4|0,(s+(n[Qe>>2]|0)|0)>>>0>c>>>0));)if(s=n[f+8>>2]|0,s)f=s;else{Ue=118;break t}if(l=m-B&d,l>>>0<2147483647)if(s=bp(l|0)|0,(s|0)==((n[f>>2]|0)+(n[Qe>>2]|0)|0)){if((s|0)!=-1){B=l,m=s,Ue=135;break e}}else f=s,Ue=126;else l=0}else Ue=118;while(0);do if((Ue|0)==118)if(c=bp(0)|0,(c|0)!=-1&&(l=c,Ge=n[2902]|0,Me=Ge+-1|0,l=((Me&l|0)==0?0:(Me+l&0-Ge)-l|0)+M|0,Ge=n[2891]|0,Me=l+Ge|0,l>>>0>q>>>0&l>>>0<2147483647)){if(Qe=n[2893]|0,Qe|0&&Me>>>0<=Ge>>>0|Me>>>0>Qe>>>0){l=0;break}if(s=bp(l|0)|0,(s|0)==(c|0)){B=l,m=c,Ue=135;break e}else f=s,Ue=126}else l=0;while(0);do if((Ue|0)==126){if(c=0-l|0,!(k>>>0>l>>>0&(l>>>0<2147483647&(f|0)!=-1)))if((f|0)==-1){l=0;break}else{B=l,m=f,Ue=135;break e}if(s=n[2903]|0,s=Q-l+s&0-s,s>>>0>=2147483647){B=l,m=f,Ue=135;break e}if((bp(s|0)|0)==-1){bp(c|0)|0,l=0;break}else{B=s+l|0,m=f,Ue=135;break e}}while(0);n[2894]=n[2894]|4,Ue=133}while(0);if((Ue|0)==133&&M>>>0<2147483647&&(at=bp(M|0)|0,Qe=bp(0)|0,et=Qe-at|0,Xe=et>>>0>(q+40|0)>>>0,!((at|0)==-1|Xe^1|at>>>0<Qe>>>0&((at|0)!=-1&(Qe|0)!=-1)^1))&&(B=Xe?et:l,m=at,Ue=135),(Ue|0)==135){l=(n[2891]|0)+B|0,n[2891]=l,l>>>0>(n[2892]|0)>>>0&&(n[2892]=l),Q=n[2789]|0;do if(Q){for(l=11580;;){if(s=n[l>>2]|0,c=l+4|0,f=n[c>>2]|0,(m|0)==(s+f|0)){Ue=145;break}if(d=n[l+8>>2]|0,d)l=d;else break}if((Ue|0)==145&&(n[l+12>>2]&8|0)==0&&Q>>>0<m>>>0&Q>>>0>=s>>>0){n[c>>2]=f+B,qe=Q+8|0,qe=(qe&7|0)==0?0:0-qe&7,Ue=Q+qe|0,qe=(n[2786]|0)+(B-qe)|0,n[2789]=Ue,n[2786]=qe,n[Ue+4>>2]=qe|1,n[Ue+qe+4>>2]=40,n[2790]=n[2905];break}for(m>>>0<(n[2787]|0)>>>0&&(n[2787]=m),c=m+B|0,l=11580;;){if((n[l>>2]|0)==(c|0)){Ue=153;break}if(s=n[l+8>>2]|0,s)l=s;else break}if((Ue|0)==153&&(n[l+12>>2]&8|0)==0){n[l>>2]=m,O=l+4|0,n[O>>2]=(n[O>>2]|0)+B,O=m+8|0,O=m+((O&7|0)==0?0:0-O&7)|0,l=c+8|0,l=c+((l&7|0)==0?0:0-l&7)|0,M=O+q|0,k=l-O-q|0,n[O+4>>2]=q|3;do if((l|0)!=(Q|0)){if((l|0)==(n[2788]|0)){qe=(n[2785]|0)+k|0,n[2785]=qe,n[2788]=M,n[M+4>>2]=qe|1,n[M+qe>>2]=qe;break}if(s=n[l+4>>2]|0,(s&3|0)==1){B=s&-8,f=s>>>3;e:do if(s>>>0<256)if(s=n[l+8>>2]|0,c=n[l+12>>2]|0,(c|0)==(s|0)){n[2783]=n[2783]&~(1<<f);break}else{n[s+12>>2]=c,n[c+8>>2]=s;break}else{m=n[l+24>>2]|0,s=n[l+12>>2]|0;do if((s|0)==(l|0)){if(f=l+16|0,c=f+4|0,s=n[c>>2]|0,!s)if(s=n[f>>2]|0,s)c=f;else{s=0;break}for(;;){if(f=s+20|0,d=n[f>>2]|0,d|0){s=d,c=f;continue}if(f=s+16|0,d=n[f>>2]|0,d)s=d,c=f;else break}n[c>>2]=0}else qe=n[l+8>>2]|0,n[qe+12>>2]=s,n[s+8>>2]=qe;while(0);if(!m)break;c=n[l+28>>2]|0,f=11436+(c<<2)|0;do if((l|0)!=(n[f>>2]|0)){if(n[m+16+(((n[m+16>>2]|0)!=(l|0)&1)<<2)>>2]=s,!s)break e}else{if(n[f>>2]=s,s|0)break;n[2784]=n[2784]&~(1<<c);break e}while(0);if(n[s+24>>2]=m,c=l+16|0,f=n[c>>2]|0,f|0&&(n[s+16>>2]=f,n[f+24>>2]=s),c=n[c+4>>2]|0,!c)break;n[s+20>>2]=c,n[c+24>>2]=s}while(0);l=l+B|0,d=B+k|0}else d=k;if(l=l+4|0,n[l>>2]=n[l>>2]&-2,n[M+4>>2]=d|1,n[M+d>>2]=d,l=d>>>3,d>>>0<256){c=11172+(l<<1<<2)|0,s=n[2783]|0,l=1<<l,s&l?(s=c+8|0,l=n[s>>2]|0):(n[2783]=s|l,l=c,s=c+8|0),n[s>>2]=M,n[l+12>>2]=M,n[M+8>>2]=l,n[M+12>>2]=c;break}l=d>>>8;do if(!l)l=0;else{if(d>>>0>16777215){l=31;break}Ue=(l+1048320|0)>>>16&8,qe=l<<Ue,at=(qe+520192|0)>>>16&4,qe=qe<<at,l=(qe+245760|0)>>>16&2,l=14-(at|Ue|l)+(qe<<l>>>15)|0,l=d>>>(l+7|0)&1|l<<1}while(0);if(f=11436+(l<<2)|0,n[M+28>>2]=l,s=M+16|0,n[s+4>>2]=0,n[s>>2]=0,s=n[2784]|0,c=1<<l,!(s&c)){n[2784]=s|c,n[f>>2]=M,n[M+24>>2]=f,n[M+12>>2]=M,n[M+8>>2]=M;break}for(s=d<<((l|0)==31?0:25-(l>>>1)|0),c=n[f>>2]|0;;){if((n[c+4>>2]&-8|0)==(d|0)){Ue=194;break}if(f=c+16+(s>>>31<<2)|0,l=n[f>>2]|0,l)s=s<<1,c=l;else{Ue=193;break}}if((Ue|0)==193){n[f>>2]=M,n[M+24>>2]=c,n[M+12>>2]=M,n[M+8>>2]=M;break}else if((Ue|0)==194){Ue=c+8|0,qe=n[Ue>>2]|0,n[qe+12>>2]=M,n[Ue>>2]=M,n[M+8>>2]=qe,n[M+12>>2]=c,n[M+24>>2]=0;break}}else qe=(n[2786]|0)+k|0,n[2786]=qe,n[2789]=M,n[M+4>>2]=qe|1;while(0);return qe=O+8|0,E=Nt,qe|0}for(l=11580;s=n[l>>2]|0,!(s>>>0<=Q>>>0&&(qe=s+(n[l+4>>2]|0)|0,qe>>>0>Q>>>0));)l=n[l+8>>2]|0;d=qe+-47|0,s=d+8|0,s=d+((s&7|0)==0?0:0-s&7)|0,d=Q+16|0,s=s>>>0<d>>>0?Q:s,l=s+8|0,c=m+8|0,c=(c&7|0)==0?0:0-c&7,Ue=m+c|0,c=B+-40-c|0,n[2789]=Ue,n[2786]=c,n[Ue+4>>2]=c|1,n[Ue+c+4>>2]=40,n[2790]=n[2905],c=s+4|0,n[c>>2]=27,n[l>>2]=n[2895],n[l+4>>2]=n[2896],n[l+8>>2]=n[2897],n[l+12>>2]=n[2898],n[2895]=m,n[2896]=B,n[2898]=0,n[2897]=l,l=s+24|0;do Ue=l,l=l+4|0,n[l>>2]=7;while((Ue+8|0)>>>0<qe>>>0);if((s|0)!=(Q|0)){if(m=s-Q|0,n[c>>2]=n[c>>2]&-2,n[Q+4>>2]=m|1,n[s>>2]=m,l=m>>>3,m>>>0<256){c=11172+(l<<1<<2)|0,s=n[2783]|0,l=1<<l,s&l?(s=c+8|0,l=n[s>>2]|0):(n[2783]=s|l,l=c,s=c+8|0),n[s>>2]=Q,n[l+12>>2]=Q,n[Q+8>>2]=l,n[Q+12>>2]=c;break}if(l=m>>>8,l?m>>>0>16777215?c=31:(Ue=(l+1048320|0)>>>16&8,qe=l<<Ue,at=(qe+520192|0)>>>16&4,qe=qe<<at,c=(qe+245760|0)>>>16&2,c=14-(at|Ue|c)+(qe<<c>>>15)|0,c=m>>>(c+7|0)&1|c<<1):c=0,f=11436+(c<<2)|0,n[Q+28>>2]=c,n[Q+20>>2]=0,n[d>>2]=0,l=n[2784]|0,s=1<<c,!(l&s)){n[2784]=l|s,n[f>>2]=Q,n[Q+24>>2]=f,n[Q+12>>2]=Q,n[Q+8>>2]=Q;break}for(s=m<<((c|0)==31?0:25-(c>>>1)|0),c=n[f>>2]|0;;){if((n[c+4>>2]&-8|0)==(m|0)){Ue=216;break}if(f=c+16+(s>>>31<<2)|0,l=n[f>>2]|0,l)s=s<<1,c=l;else{Ue=215;break}}if((Ue|0)==215){n[f>>2]=Q,n[Q+24>>2]=c,n[Q+12>>2]=Q,n[Q+8>>2]=Q;break}else if((Ue|0)==216){Ue=c+8|0,qe=n[Ue>>2]|0,n[qe+12>>2]=Q,n[Ue>>2]=Q,n[Q+8>>2]=qe,n[Q+12>>2]=c,n[Q+24>>2]=0;break}}}else{qe=n[2787]|0,(qe|0)==0|m>>>0<qe>>>0&&(n[2787]=m),n[2895]=m,n[2896]=B,n[2898]=0,n[2792]=n[2901],n[2791]=-1,l=0;do qe=11172+(l<<1<<2)|0,n[qe+12>>2]=qe,n[qe+8>>2]=qe,l=l+1|0;while((l|0)!=32);qe=m+8|0,qe=(qe&7|0)==0?0:0-qe&7,Ue=m+qe|0,qe=B+-40-qe|0,n[2789]=Ue,n[2786]=qe,n[Ue+4>>2]=qe|1,n[Ue+qe+4>>2]=40,n[2790]=n[2905]}while(0);if(l=n[2786]|0,l>>>0>q>>>0)return at=l-q|0,n[2786]=at,qe=n[2789]|0,Ue=qe+q|0,n[2789]=Ue,n[Ue+4>>2]=at|1,n[qe+4>>2]=q|3,qe=qe+8|0,E=Nt,qe|0}return n[(Km()|0)>>2]=12,qe=0,E=Nt,qe|0}function hD(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0,Q=0;if(!!s){c=s+-8|0,d=n[2787]|0,s=n[s+-4>>2]|0,l=s&-8,Q=c+l|0;do if(s&1)k=c,B=c;else{if(f=n[c>>2]|0,!(s&3)||(B=c+(0-f)|0,m=f+l|0,B>>>0<d>>>0))return;if((B|0)==(n[2788]|0)){if(s=Q+4|0,l=n[s>>2]|0,(l&3|0)!=3){k=B,l=m;break}n[2785]=m,n[s>>2]=l&-2,n[B+4>>2]=m|1,n[B+m>>2]=m;return}if(c=f>>>3,f>>>0<256)if(s=n[B+8>>2]|0,l=n[B+12>>2]|0,(l|0)==(s|0)){n[2783]=n[2783]&~(1<<c),k=B,l=m;break}else{n[s+12>>2]=l,n[l+8>>2]=s,k=B,l=m;break}d=n[B+24>>2]|0,s=n[B+12>>2]|0;do if((s|0)==(B|0)){if(c=B+16|0,l=c+4|0,s=n[l>>2]|0,!s)if(s=n[c>>2]|0,s)l=c;else{s=0;break}for(;;){if(c=s+20|0,f=n[c>>2]|0,f|0){s=f,l=c;continue}if(c=s+16|0,f=n[c>>2]|0,f)s=f,l=c;else break}n[l>>2]=0}else k=n[B+8>>2]|0,n[k+12>>2]=s,n[s+8>>2]=k;while(0);if(d){if(l=n[B+28>>2]|0,c=11436+(l<<2)|0,(B|0)==(n[c>>2]|0)){if(n[c>>2]=s,!s){n[2784]=n[2784]&~(1<<l),k=B,l=m;break}}else if(n[d+16+(((n[d+16>>2]|0)!=(B|0)&1)<<2)>>2]=s,!s){k=B,l=m;break}n[s+24>>2]=d,l=B+16|0,c=n[l>>2]|0,c|0&&(n[s+16>>2]=c,n[c+24>>2]=s),l=n[l+4>>2]|0,l?(n[s+20>>2]=l,n[l+24>>2]=s,k=B,l=m):(k=B,l=m)}else k=B,l=m}while(0);if(!(B>>>0>=Q>>>0)&&(s=Q+4|0,f=n[s>>2]|0,!!(f&1))){if(f&2)n[s>>2]=f&-2,n[k+4>>2]=l|1,n[B+l>>2]=l,d=l;else{if(s=n[2788]|0,(Q|0)==(n[2789]|0)){if(Q=(n[2786]|0)+l|0,n[2786]=Q,n[2789]=k,n[k+4>>2]=Q|1,(k|0)!=(s|0))return;n[2788]=0,n[2785]=0;return}if((Q|0)==(s|0)){Q=(n[2785]|0)+l|0,n[2785]=Q,n[2788]=B,n[k+4>>2]=Q|1,n[B+Q>>2]=Q;return}d=(f&-8)+l|0,c=f>>>3;do if(f>>>0<256)if(l=n[Q+8>>2]|0,s=n[Q+12>>2]|0,(s|0)==(l|0)){n[2783]=n[2783]&~(1<<c);break}else{n[l+12>>2]=s,n[s+8>>2]=l;break}else{m=n[Q+24>>2]|0,s=n[Q+12>>2]|0;do if((s|0)==(Q|0)){if(c=Q+16|0,l=c+4|0,s=n[l>>2]|0,!s)if(s=n[c>>2]|0,s)l=c;else{c=0;break}for(;;){if(c=s+20|0,f=n[c>>2]|0,f|0){s=f,l=c;continue}if(c=s+16|0,f=n[c>>2]|0,f)s=f,l=c;else break}n[l>>2]=0,c=s}else c=n[Q+8>>2]|0,n[c+12>>2]=s,n[s+8>>2]=c,c=s;while(0);if(m|0){if(s=n[Q+28>>2]|0,l=11436+(s<<2)|0,(Q|0)==(n[l>>2]|0)){if(n[l>>2]=c,!c){n[2784]=n[2784]&~(1<<s);break}}else if(n[m+16+(((n[m+16>>2]|0)!=(Q|0)&1)<<2)>>2]=c,!c)break;n[c+24>>2]=m,s=Q+16|0,l=n[s>>2]|0,l|0&&(n[c+16>>2]=l,n[l+24>>2]=c),s=n[s+4>>2]|0,s|0&&(n[c+20>>2]=s,n[s+24>>2]=c)}}while(0);if(n[k+4>>2]=d|1,n[B+d>>2]=d,(k|0)==(n[2788]|0)){n[2785]=d;return}}if(s=d>>>3,d>>>0<256){c=11172+(s<<1<<2)|0,l=n[2783]|0,s=1<<s,l&s?(l=c+8|0,s=n[l>>2]|0):(n[2783]=l|s,s=c,l=c+8|0),n[l>>2]=k,n[s+12>>2]=k,n[k+8>>2]=s,n[k+12>>2]=c;return}s=d>>>8,s?d>>>0>16777215?s=31:(B=(s+1048320|0)>>>16&8,Q=s<<B,m=(Q+520192|0)>>>16&4,Q=Q<<m,s=(Q+245760|0)>>>16&2,s=14-(m|B|s)+(Q<<s>>>15)|0,s=d>>>(s+7|0)&1|s<<1):s=0,f=11436+(s<<2)|0,n[k+28>>2]=s,n[k+20>>2]=0,n[k+16>>2]=0,l=n[2784]|0,c=1<<s;do if(l&c){for(l=d<<((s|0)==31?0:25-(s>>>1)|0),c=n[f>>2]|0;;){if((n[c+4>>2]&-8|0)==(d|0)){s=73;break}if(f=c+16+(l>>>31<<2)|0,s=n[f>>2]|0,s)l=l<<1,c=s;else{s=72;break}}if((s|0)==72){n[f>>2]=k,n[k+24>>2]=c,n[k+12>>2]=k,n[k+8>>2]=k;break}else if((s|0)==73){B=c+8|0,Q=n[B>>2]|0,n[Q+12>>2]=k,n[B>>2]=k,n[k+8>>2]=Q,n[k+12>>2]=c,n[k+24>>2]=0;break}}else n[2784]=l|c,n[f>>2]=k,n[k+24>>2]=f,n[k+12>>2]=k,n[k+8>>2]=k;while(0);if(Q=(n[2791]|0)+-1|0,n[2791]=Q,!Q)s=11588;else return;for(;s=n[s>>2]|0,s;)s=s+8|0;n[2791]=-1}}}function xUe(){return 11628}function bUe(s){s=s|0;var l=0,c=0;return l=E,E=E+16|0,c=l,n[c>>2]=FUe(n[s+60>>2]|0)|0,s=gD(hc(6,c|0)|0)|0,E=l,s|0}function i7(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0,Ge=0;q=E,E=E+48|0,M=q+16|0,m=q,d=q+32|0,k=s+28|0,f=n[k>>2]|0,n[d>>2]=f,Q=s+20|0,f=(n[Q>>2]|0)-f|0,n[d+4>>2]=f,n[d+8>>2]=l,n[d+12>>2]=c,f=f+c|0,B=s+60|0,n[m>>2]=n[B>>2],n[m+4>>2]=d,n[m+8>>2]=2,m=gD(Ni(146,m|0)|0)|0;e:do if((f|0)!=(m|0)){for(l=2;!((m|0)<0);)if(f=f-m|0,Ge=n[d+4>>2]|0,se=m>>>0>Ge>>>0,d=se?d+8|0:d,l=(se<<31>>31)+l|0,Ge=m-(se?Ge:0)|0,n[d>>2]=(n[d>>2]|0)+Ge,se=d+4|0,n[se>>2]=(n[se>>2]|0)-Ge,n[M>>2]=n[B>>2],n[M+4>>2]=d,n[M+8>>2]=l,m=gD(Ni(146,M|0)|0)|0,(f|0)==(m|0)){O=3;break e}n[s+16>>2]=0,n[k>>2]=0,n[Q>>2]=0,n[s>>2]=n[s>>2]|32,(l|0)==2?c=0:c=c-(n[d+4>>2]|0)|0}else O=3;while(0);return(O|0)==3&&(Ge=n[s+44>>2]|0,n[s+16>>2]=Ge+(n[s+48>>2]|0),n[k>>2]=Ge,n[Q>>2]=Ge),E=q,c|0}function kUe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;return d=E,E=E+32|0,m=d,f=d+20|0,n[m>>2]=n[s+60>>2],n[m+4>>2]=0,n[m+8>>2]=l,n[m+12>>2]=f,n[m+16>>2]=c,(gD(sa(140,m|0)|0)|0)<0?(n[f>>2]=-1,s=-1):s=n[f>>2]|0,E=d,s|0}function gD(s){return s=s|0,s>>>0>4294963200&&(n[(Km()|0)>>2]=0-s,s=-1),s|0}function Km(){return(QUe()|0)+64|0}function QUe(){return PR()|0}function PR(){return 2084}function FUe(s){return s=s|0,s|0}function RUe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;return d=E,E=E+32|0,f=d,n[s+36>>2]=1,(n[s>>2]&64|0)==0&&(n[f>>2]=n[s+60>>2],n[f+4>>2]=21523,n[f+8>>2]=d+16,fu(54,f|0)|0)&&(o[s+75>>0]=-1),f=i7(s,l,c)|0,E=d,f|0}function s7(s,l){s=s|0,l=l|0;var c=0,f=0;if(c=o[s>>0]|0,f=o[l>>0]|0,c<<24>>24==0||c<<24>>24!=f<<24>>24)s=f;else{do s=s+1|0,l=l+1|0,c=o[s>>0]|0,f=o[l>>0]|0;while(!(c<<24>>24==0||c<<24>>24!=f<<24>>24));s=f}return(c&255)-(s&255)|0}function TUe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;e:do if(!c)s=0;else{for(;f=o[s>>0]|0,d=o[l>>0]|0,f<<24>>24==d<<24>>24;)if(c=c+-1|0,c)s=s+1|0,l=l+1|0;else{s=0;break e}s=(f&255)-(d&255)|0}while(0);return s|0}function o7(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0,Ge=0,Me=0,Qe=0;Qe=E,E=E+224|0,O=Qe+120|0,q=Qe+80|0,Ge=Qe,Me=Qe+136|0,f=q,d=f+40|0;do n[f>>2]=0,f=f+4|0;while((f|0)<(d|0));return n[O>>2]=n[c>>2],(SR(0,l,O,Ge,q)|0)<0?c=-1:((n[s+76>>2]|0)>-1?se=LUe(s)|0:se=0,c=n[s>>2]|0,M=c&32,(o[s+74>>0]|0)<1&&(n[s>>2]=c&-33),f=s+48|0,n[f>>2]|0?c=SR(s,l,O,Ge,q)|0:(d=s+44|0,m=n[d>>2]|0,n[d>>2]=Me,B=s+28|0,n[B>>2]=Me,k=s+20|0,n[k>>2]=Me,n[f>>2]=80,Q=s+16|0,n[Q>>2]=Me+80,c=SR(s,l,O,Ge,q)|0,m&&(ED[n[s+36>>2]&7](s,0,0)|0,c=(n[k>>2]|0)==0?-1:c,n[d>>2]=m,n[f>>2]=0,n[Q>>2]=0,n[B>>2]=0,n[k>>2]=0)),f=n[s>>2]|0,n[s>>2]=f|M,se|0&&NUe(s),c=(f&32|0)==0?c:-1),E=Qe,c|0}function SR(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0,Ge=0,Me=0,Qe=0,et=0,Xe=0,at=0,Ue=0,qe=0,Nt=0,Mr=0,or=0,Xt=0,Pr=0,Lr=0,ir=0;ir=E,E=E+64|0,or=ir+16|0,Xt=ir,Nt=ir+24|0,Pr=ir+8|0,Lr=ir+20|0,n[or>>2]=l,at=(s|0)!=0,Ue=Nt+40|0,qe=Ue,Nt=Nt+39|0,Mr=Pr+4|0,B=0,m=0,O=0;e:for(;;){do if((m|0)>-1)if((B|0)>(2147483647-m|0)){n[(Km()|0)>>2]=75,m=-1;break}else{m=B+m|0;break}while(0);if(B=o[l>>0]|0,B<<24>>24)k=l;else{Xe=87;break}t:for(;;){switch(B<<24>>24){case 37:{B=k,Xe=9;break t}case 0:{B=k;break t}default:}et=k+1|0,n[or>>2]=et,B=o[et>>0]|0,k=et}t:do if((Xe|0)==9)for(;;){if(Xe=0,(o[k+1>>0]|0)!=37)break t;if(B=B+1|0,k=k+2|0,n[or>>2]=k,(o[k>>0]|0)==37)Xe=9;else break}while(0);if(B=B-l|0,at&&ss(s,l,B),B|0){l=k;continue}Q=k+1|0,B=(o[Q>>0]|0)+-48|0,B>>>0<10?(et=(o[k+2>>0]|0)==36,Qe=et?B:-1,O=et?1:O,Q=et?k+3|0:Q):Qe=-1,n[or>>2]=Q,B=o[Q>>0]|0,k=(B<<24>>24)+-32|0;t:do if(k>>>0<32)for(M=0,q=B;;){if(B=1<<k,!(B&75913)){B=q;break t}if(M=B|M,Q=Q+1|0,n[or>>2]=Q,B=o[Q>>0]|0,k=(B<<24>>24)+-32|0,k>>>0>=32)break;q=B}else M=0;while(0);if(B<<24>>24==42){if(k=Q+1|0,B=(o[k>>0]|0)+-48|0,B>>>0<10&&(o[Q+2>>0]|0)==36)n[d+(B<<2)>>2]=10,B=n[f+((o[k>>0]|0)+-48<<3)>>2]|0,O=1,Q=Q+3|0;else{if(O|0){m=-1;break}at?(O=(n[c>>2]|0)+(4-1)&~(4-1),B=n[O>>2]|0,n[c>>2]=O+4,O=0,Q=k):(B=0,O=0,Q=k)}n[or>>2]=Q,et=(B|0)<0,B=et?0-B|0:B,M=et?M|8192:M}else{if(B=a7(or)|0,(B|0)<0){m=-1;break}Q=n[or>>2]|0}do if((o[Q>>0]|0)==46){if((o[Q+1>>0]|0)!=42){n[or>>2]=Q+1,k=a7(or)|0,Q=n[or>>2]|0;break}if(q=Q+2|0,k=(o[q>>0]|0)+-48|0,k>>>0<10&&(o[Q+3>>0]|0)==36){n[d+(k<<2)>>2]=10,k=n[f+((o[q>>0]|0)+-48<<3)>>2]|0,Q=Q+4|0,n[or>>2]=Q;break}if(O|0){m=-1;break e}at?(et=(n[c>>2]|0)+(4-1)&~(4-1),k=n[et>>2]|0,n[c>>2]=et+4):k=0,n[or>>2]=q,Q=q}else k=-1;while(0);for(Me=0;;){if(((o[Q>>0]|0)+-65|0)>>>0>57){m=-1;break e}if(et=Q+1|0,n[or>>2]=et,q=o[(o[Q>>0]|0)+-65+(5178+(Me*58|0))>>0]|0,se=q&255,(se+-1|0)>>>0<8)Me=se,Q=et;else break}if(!(q<<24>>24)){m=-1;break}Ge=(Qe|0)>-1;do if(q<<24>>24==19)if(Ge){m=-1;break e}else Xe=49;else{if(Ge){n[d+(Qe<<2)>>2]=se,Ge=f+(Qe<<3)|0,Qe=n[Ge+4>>2]|0,Xe=Xt,n[Xe>>2]=n[Ge>>2],n[Xe+4>>2]=Qe,Xe=49;break}if(!at){m=0;break e}l7(Xt,se,c)}while(0);if((Xe|0)==49&&(Xe=0,!at)){B=0,l=et;continue}Q=o[Q>>0]|0,Q=(Me|0)!=0&(Q&15|0)==3?Q&-33:Q,Ge=M&-65537,Qe=(M&8192|0)==0?M:Ge;t:do switch(Q|0){case 110:switch((Me&255)<<24>>24){case 0:{n[n[Xt>>2]>>2]=m,B=0,l=et;continue e}case 1:{n[n[Xt>>2]>>2]=m,B=0,l=et;continue e}case 2:{B=n[Xt>>2]|0,n[B>>2]=m,n[B+4>>2]=((m|0)<0)<<31>>31,B=0,l=et;continue e}case 3:{a[n[Xt>>2]>>1]=m,B=0,l=et;continue e}case 4:{o[n[Xt>>2]>>0]=m,B=0,l=et;continue e}case 6:{n[n[Xt>>2]>>2]=m,B=0,l=et;continue e}case 7:{B=n[Xt>>2]|0,n[B>>2]=m,n[B+4>>2]=((m|0)<0)<<31>>31,B=0,l=et;continue e}default:{B=0,l=et;continue e}}case 112:{Q=120,k=k>>>0>8?k:8,l=Qe|8,Xe=61;break}case 88:case 120:{l=Qe,Xe=61;break}case 111:{Q=Xt,l=n[Q>>2]|0,Q=n[Q+4>>2]|0,se=MUe(l,Q,Ue)|0,Ge=qe-se|0,M=0,q=5642,k=(Qe&8|0)==0|(k|0)>(Ge|0)?k:Ge+1|0,Ge=Qe,Xe=67;break}case 105:case 100:if(Q=Xt,l=n[Q>>2]|0,Q=n[Q+4>>2]|0,(Q|0)<0){l=dD(0,0,l|0,Q|0)|0,Q=De,M=Xt,n[M>>2]=l,n[M+4>>2]=Q,M=1,q=5642,Xe=66;break t}else{M=(Qe&2049|0)!=0&1,q=(Qe&2048|0)==0?(Qe&1|0)==0?5642:5644:5643,Xe=66;break t}case 117:{Q=Xt,M=0,q=5642,l=n[Q>>2]|0,Q=n[Q+4>>2]|0,Xe=66;break}case 99:{o[Nt>>0]=n[Xt>>2],l=Nt,M=0,q=5642,se=Ue,Q=1,k=Ge;break}case 109:{Q=UUe(n[(Km()|0)>>2]|0)|0,Xe=71;break}case 115:{Q=n[Xt>>2]|0,Q=Q|0?Q:5652,Xe=71;break}case 67:{n[Pr>>2]=n[Xt>>2],n[Mr>>2]=0,n[Xt>>2]=Pr,se=-1,Q=Pr,Xe=75;break}case 83:{l=n[Xt>>2]|0,k?(se=k,Q=l,Xe=75):(Bs(s,32,B,0,Qe),l=0,Xe=84);break}case 65:case 71:case 70:case 69:case 97:case 103:case 102:case 101:{B=HUe(s,+C[Xt>>3],B,k,Qe,Q)|0,l=et;continue e}default:M=0,q=5642,se=Ue,Q=k,k=Qe}while(0);t:do if((Xe|0)==61)Qe=Xt,Me=n[Qe>>2]|0,Qe=n[Qe+4>>2]|0,se=OUe(Me,Qe,Ue,Q&32)|0,q=(l&8|0)==0|(Me|0)==0&(Qe|0)==0,M=q?0:2,q=q?5642:5642+(Q>>4)|0,Ge=l,l=Me,Q=Qe,Xe=67;else if((Xe|0)==66)se=Vm(l,Q,Ue)|0,Ge=Qe,Xe=67;else if((Xe|0)==71)Xe=0,Qe=_Ue(Q,0,k)|0,Me=(Qe|0)==0,l=Q,M=0,q=5642,se=Me?Q+k|0:Qe,Q=Me?k:Qe-Q|0,k=Ge;else if((Xe|0)==75){for(Xe=0,q=Q,l=0,k=0;M=n[q>>2]|0,!(!M||(k=c7(Lr,M)|0,(k|0)<0|k>>>0>(se-l|0)>>>0));)if(l=k+l|0,se>>>0>l>>>0)q=q+4|0;else break;if((k|0)<0){m=-1;break e}if(Bs(s,32,B,l,Qe),!l)l=0,Xe=84;else for(M=0;;){if(k=n[Q>>2]|0,!k){Xe=84;break t}if(k=c7(Lr,k)|0,M=k+M|0,(M|0)>(l|0)){Xe=84;break t}if(ss(s,Lr,k),M>>>0>=l>>>0){Xe=84;break}else Q=Q+4|0}}while(0);if((Xe|0)==67)Xe=0,Q=(l|0)!=0|(Q|0)!=0,Qe=(k|0)!=0|Q,Q=((Q^1)&1)+(qe-se)|0,l=Qe?se:Ue,se=Ue,Q=Qe?(k|0)>(Q|0)?k:Q:k,k=(k|0)>-1?Ge&-65537:Ge;else if((Xe|0)==84){Xe=0,Bs(s,32,B,l,Qe^8192),B=(B|0)>(l|0)?B:l,l=et;continue}Me=se-l|0,Ge=(Q|0)<(Me|0)?Me:Q,Qe=Ge+M|0,B=(B|0)<(Qe|0)?Qe:B,Bs(s,32,B,Qe,k),ss(s,q,M),Bs(s,48,B,Qe,k^65536),Bs(s,48,Ge,Me,0),ss(s,l,Me),Bs(s,32,B,Qe,k^8192),l=et}e:do if((Xe|0)==87&&!s)if(!O)m=0;else{for(m=1;l=n[d+(m<<2)>>2]|0,!!l;)if(l7(f+(m<<3)|0,l,c),m=m+1|0,(m|0)>=10){m=1;break e}for(;;){if(n[d+(m<<2)>>2]|0){m=-1;break e}if(m=m+1|0,(m|0)>=10){m=1;break}}}while(0);return E=ir,m|0}function LUe(s){return s=s|0,0}function NUe(s){s=s|0}function ss(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]&32||JUe(l,c,s)|0}function a7(s){s=s|0;var l=0,c=0,f=0;if(c=n[s>>2]|0,f=(o[c>>0]|0)+-48|0,f>>>0<10){l=0;do l=f+(l*10|0)|0,c=c+1|0,n[s>>2]=c,f=(o[c>>0]|0)+-48|0;while(f>>>0<10)}else l=0;return l|0}function l7(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;e:do if(l>>>0<=20)do switch(l|0){case 9:{f=(n[c>>2]|0)+(4-1)&~(4-1),l=n[f>>2]|0,n[c>>2]=f+4,n[s>>2]=l;break e}case 10:{f=(n[c>>2]|0)+(4-1)&~(4-1),l=n[f>>2]|0,n[c>>2]=f+4,f=s,n[f>>2]=l,n[f+4>>2]=((l|0)<0)<<31>>31;break e}case 11:{f=(n[c>>2]|0)+(4-1)&~(4-1),l=n[f>>2]|0,n[c>>2]=f+4,f=s,n[f>>2]=l,n[f+4>>2]=0;break e}case 12:{f=(n[c>>2]|0)+(8-1)&~(8-1),l=f,d=n[l>>2]|0,l=n[l+4>>2]|0,n[c>>2]=f+8,f=s,n[f>>2]=d,n[f+4>>2]=l;break e}case 13:{d=(n[c>>2]|0)+(4-1)&~(4-1),f=n[d>>2]|0,n[c>>2]=d+4,f=(f&65535)<<16>>16,d=s,n[d>>2]=f,n[d+4>>2]=((f|0)<0)<<31>>31;break e}case 14:{d=(n[c>>2]|0)+(4-1)&~(4-1),f=n[d>>2]|0,n[c>>2]=d+4,d=s,n[d>>2]=f&65535,n[d+4>>2]=0;break e}case 15:{d=(n[c>>2]|0)+(4-1)&~(4-1),f=n[d>>2]|0,n[c>>2]=d+4,f=(f&255)<<24>>24,d=s,n[d>>2]=f,n[d+4>>2]=((f|0)<0)<<31>>31;break e}case 16:{d=(n[c>>2]|0)+(4-1)&~(4-1),f=n[d>>2]|0,n[c>>2]=d+4,d=s,n[d>>2]=f&255,n[d+4>>2]=0;break e}case 17:{d=(n[c>>2]|0)+(8-1)&~(8-1),m=+C[d>>3],n[c>>2]=d+8,C[s>>3]=m;break e}case 18:{d=(n[c>>2]|0)+(8-1)&~(8-1),m=+C[d>>3],n[c>>2]=d+8,C[s>>3]=m;break e}default:break e}while(0);while(0)}function OUe(s,l,c,f){if(s=s|0,l=l|0,c=c|0,f=f|0,!((s|0)==0&(l|0)==0))do c=c+-1|0,o[c>>0]=u[5694+(s&15)>>0]|0|f,s=mD(s|0,l|0,4)|0,l=De;while(!((s|0)==0&(l|0)==0));return c|0}function MUe(s,l,c){if(s=s|0,l=l|0,c=c|0,!((s|0)==0&(l|0)==0))do c=c+-1|0,o[c>>0]=s&7|48,s=mD(s|0,l|0,3)|0,l=De;while(!((s|0)==0&(l|0)==0));return c|0}function Vm(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;if(l>>>0>0|(l|0)==0&s>>>0>4294967295){for(;f=QR(s|0,l|0,10,0)|0,c=c+-1|0,o[c>>0]=f&255|48,f=s,s=kR(s|0,l|0,10,0)|0,l>>>0>9|(l|0)==9&f>>>0>4294967295;)l=De;l=s}else l=s;if(l)for(;c=c+-1|0,o[c>>0]=(l>>>0)%10|0|48,!(l>>>0<10);)l=(l>>>0)/10|0;return c|0}function UUe(s){return s=s|0,WUe(s,n[(YUe()|0)+188>>2]|0)|0}function _Ue(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;m=l&255,f=(c|0)!=0;e:do if(f&(s&3|0)!=0)for(d=l&255;;){if((o[s>>0]|0)==d<<24>>24){B=6;break e}if(s=s+1|0,c=c+-1|0,f=(c|0)!=0,!(f&(s&3|0)!=0)){B=5;break}}else B=5;while(0);(B|0)==5&&(f?B=6:c=0);e:do if((B|0)==6&&(d=l&255,(o[s>>0]|0)!=d<<24>>24)){f=je(m,16843009)|0;t:do if(c>>>0>3){for(;m=n[s>>2]^f,!((m&-2139062144^-2139062144)&m+-16843009|0);)if(s=s+4|0,c=c+-4|0,c>>>0<=3){B=11;break t}}else B=11;while(0);if((B|0)==11&&!c){c=0;break}for(;;){if((o[s>>0]|0)==d<<24>>24)break e;if(s=s+1|0,c=c+-1|0,!c){c=0;break}}}while(0);return(c|0?s:0)|0}function Bs(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0;if(B=E,E=E+256|0,m=B,(c|0)>(f|0)&(d&73728|0)==0){if(d=c-f|0,Jm(m|0,l|0,(d>>>0<256?d:256)|0)|0,d>>>0>255){l=c-f|0;do ss(s,m,256),d=d+-256|0;while(d>>>0>255);d=l&255}ss(s,m,d)}E=B}function c7(s,l){return s=s|0,l=l|0,s?s=qUe(s,l,0)|0:s=0,s|0}function HUe(s,l,c,f,d,m){s=s|0,l=+l,c=c|0,f=f|0,d=d|0,m=m|0;var B=0,k=0,Q=0,M=0,O=0,q=0,se=0,Ge=0,Me=0,Qe=0,et=0,Xe=0,at=0,Ue=0,qe=0,Nt=0,Mr=0,or=0,Xt=0,Pr=0,Lr=0,ir=0,bn=0;bn=E,E=E+560|0,Q=bn+8|0,et=bn,ir=bn+524|0,Lr=ir,M=bn+512|0,n[et>>2]=0,Pr=M+12|0,u7(l)|0,(De|0)<0?(l=-l,or=1,Mr=5659):(or=(d&2049|0)!=0&1,Mr=(d&2048|0)==0?(d&1|0)==0?5660:5665:5662),u7(l)|0,Xt=De&2146435072;do if(Xt>>>0<2146435072|(Xt|0)==2146435072&0<0){if(Ge=+jUe(l,et)*2,B=Ge!=0,B&&(n[et>>2]=(n[et>>2]|0)+-1),at=m|32,(at|0)==97){Me=m&32,se=(Me|0)==0?Mr:Mr+9|0,q=or|2,B=12-f|0;do if(f>>>0>11|(B|0)==0)l=Ge;else{l=8;do B=B+-1|0,l=l*16;while((B|0)!=0);if((o[se>>0]|0)==45){l=-(l+(-Ge-l));break}else{l=Ge+l-l;break}}while(0);k=n[et>>2]|0,B=(k|0)<0?0-k|0:k,B=Vm(B,((B|0)<0)<<31>>31,Pr)|0,(B|0)==(Pr|0)&&(B=M+11|0,o[B>>0]=48),o[B+-1>>0]=(k>>31&2)+43,O=B+-2|0,o[O>>0]=m+15,M=(f|0)<1,Q=(d&8|0)==0,B=ir;do Xt=~~l,k=B+1|0,o[B>>0]=u[5694+Xt>>0]|Me,l=(l-+(Xt|0))*16,(k-Lr|0)==1&&!(Q&(M&l==0))?(o[k>>0]=46,B=B+2|0):B=k;while(l!=0);Xt=B-Lr|0,Lr=Pr-O|0,Pr=(f|0)!=0&(Xt+-2|0)<(f|0)?f+2|0:Xt,B=Lr+q+Pr|0,Bs(s,32,c,B,d),ss(s,se,q),Bs(s,48,c,B,d^65536),ss(s,ir,Xt),Bs(s,48,Pr-Xt|0,0,0),ss(s,O,Lr),Bs(s,32,c,B,d^8192);break}k=(f|0)<0?6:f,B?(B=(n[et>>2]|0)+-28|0,n[et>>2]=B,l=Ge*268435456):(l=Ge,B=n[et>>2]|0),Xt=(B|0)<0?Q:Q+288|0,Q=Xt;do qe=~~l>>>0,n[Q>>2]=qe,Q=Q+4|0,l=(l-+(qe>>>0))*1e9;while(l!=0);if((B|0)>0)for(M=Xt,q=Q;;){if(O=(B|0)<29?B:29,B=q+-4|0,B>>>0>=M>>>0){Q=0;do Ue=d7(n[B>>2]|0,0,O|0)|0,Ue=bR(Ue|0,De|0,Q|0,0)|0,qe=De,Xe=QR(Ue|0,qe|0,1e9,0)|0,n[B>>2]=Xe,Q=kR(Ue|0,qe|0,1e9,0)|0,B=B+-4|0;while(B>>>0>=M>>>0);Q&&(M=M+-4|0,n[M>>2]=Q)}for(Q=q;!(Q>>>0<=M>>>0);)if(B=Q+-4|0,!(n[B>>2]|0))Q=B;else break;if(B=(n[et>>2]|0)-O|0,n[et>>2]=B,(B|0)>0)q=Q;else break}else M=Xt;if((B|0)<0){f=((k+25|0)/9|0)+1|0,Qe=(at|0)==102;do{if(Me=0-B|0,Me=(Me|0)<9?Me:9,M>>>0<Q>>>0){O=(1<<Me)+-1|0,q=1e9>>>Me,se=0,B=M;do qe=n[B>>2]|0,n[B>>2]=(qe>>>Me)+se,se=je(qe&O,q)|0,B=B+4|0;while(B>>>0<Q>>>0);B=(n[M>>2]|0)==0?M+4|0:M,se?(n[Q>>2]=se,M=B,B=Q+4|0):(M=B,B=Q)}else M=(n[M>>2]|0)==0?M+4|0:M,B=Q;Q=Qe?Xt:M,Q=(B-Q>>2|0)>(f|0)?Q+(f<<2)|0:B,B=(n[et>>2]|0)+Me|0,n[et>>2]=B}while((B|0)<0);B=M,f=Q}else B=M,f=Q;if(qe=Xt,B>>>0<f>>>0){if(Q=(qe-B>>2)*9|0,O=n[B>>2]|0,O>>>0>=10){M=10;do M=M*10|0,Q=Q+1|0;while(O>>>0>=M>>>0)}}else Q=0;if(Qe=(at|0)==103,Xe=(k|0)!=0,M=k-((at|0)!=102?Q:0)+((Xe&Qe)<<31>>31)|0,(M|0)<(((f-qe>>2)*9|0)+-9|0)){if(M=M+9216|0,Me=Xt+4+(((M|0)/9|0)+-1024<<2)|0,M=((M|0)%9|0)+1|0,(M|0)<9){O=10;do O=O*10|0,M=M+1|0;while((M|0)!=9)}else O=10;if(q=n[Me>>2]|0,se=(q>>>0)%(O>>>0)|0,M=(Me+4|0)==(f|0),M&(se|0)==0)M=Me;else if(Ge=(((q>>>0)/(O>>>0)|0)&1|0)==0?9007199254740992:9007199254740994,Ue=(O|0)/2|0,l=se>>>0<Ue>>>0?.5:M&(se|0)==(Ue|0)?1:1.5,or&&(Ue=(o[Mr>>0]|0)==45,l=Ue?-l:l,Ge=Ue?-Ge:Ge),M=q-se|0,n[Me>>2]=M,Ge+l!=Ge){if(Ue=M+O|0,n[Me>>2]=Ue,Ue>>>0>999999999)for(Q=Me;M=Q+-4|0,n[Q>>2]=0,M>>>0<B>>>0&&(B=B+-4|0,n[B>>2]=0),Ue=(n[M>>2]|0)+1|0,n[M>>2]=Ue,Ue>>>0>999999999;)Q=M;else M=Me;if(Q=(qe-B>>2)*9|0,q=n[B>>2]|0,q>>>0>=10){O=10;do O=O*10|0,Q=Q+1|0;while(q>>>0>=O>>>0)}}else M=Me;M=M+4|0,M=f>>>0>M>>>0?M:f,Ue=B}else M=f,Ue=B;for(at=M;;){if(at>>>0<=Ue>>>0){et=0;break}if(B=at+-4|0,!(n[B>>2]|0))at=B;else{et=1;break}}f=0-Q|0;do if(Qe)if(B=((Xe^1)&1)+k|0,(B|0)>(Q|0)&(Q|0)>-5?(O=m+-1|0,k=B+-1-Q|0):(O=m+-2|0,k=B+-1|0),B=d&8,B)Me=B;else{if(et&&(Nt=n[at+-4>>2]|0,(Nt|0)!=0))if((Nt>>>0)%10|0)M=0;else{M=0,B=10;do B=B*10|0,M=M+1|0;while(!((Nt>>>0)%(B>>>0)|0|0))}else M=9;if(B=((at-qe>>2)*9|0)+-9|0,(O|32|0)==102){Me=B-M|0,Me=(Me|0)>0?Me:0,k=(k|0)<(Me|0)?k:Me,Me=0;break}else{Me=B+Q-M|0,Me=(Me|0)>0?Me:0,k=(k|0)<(Me|0)?k:Me,Me=0;break}}else O=m,Me=d&8;while(0);if(Qe=k|Me,q=(Qe|0)!=0&1,se=(O|32|0)==102,se)Xe=0,B=(Q|0)>0?Q:0;else{if(B=(Q|0)<0?f:Q,B=Vm(B,((B|0)<0)<<31>>31,Pr)|0,M=Pr,(M-B|0)<2)do B=B+-1|0,o[B>>0]=48;while((M-B|0)<2);o[B+-1>>0]=(Q>>31&2)+43,B=B+-2|0,o[B>>0]=O,Xe=B,B=M-B|0}if(B=or+1+k+q+B|0,Bs(s,32,c,B,d),ss(s,Mr,or),Bs(s,48,c,B,d^65536),se){O=Ue>>>0>Xt>>>0?Xt:Ue,Me=ir+9|0,q=Me,se=ir+8|0,M=O;do{if(Q=Vm(n[M>>2]|0,0,Me)|0,(M|0)==(O|0))(Q|0)==(Me|0)&&(o[se>>0]=48,Q=se);else if(Q>>>0>ir>>>0){Jm(ir|0,48,Q-Lr|0)|0;do Q=Q+-1|0;while(Q>>>0>ir>>>0)}ss(s,Q,q-Q|0),M=M+4|0}while(M>>>0<=Xt>>>0);if(Qe|0&&ss(s,5710,1),M>>>0<at>>>0&(k|0)>0)for(;;){if(Q=Vm(n[M>>2]|0,0,Me)|0,Q>>>0>ir>>>0){Jm(ir|0,48,Q-Lr|0)|0;do Q=Q+-1|0;while(Q>>>0>ir>>>0)}if(ss(s,Q,(k|0)<9?k:9),M=M+4|0,Q=k+-9|0,M>>>0<at>>>0&(k|0)>9)k=Q;else{k=Q;break}}Bs(s,48,k+9|0,9,0)}else{if(Qe=et?at:Ue+4|0,(k|0)>-1){et=ir+9|0,Me=(Me|0)==0,f=et,q=0-Lr|0,se=ir+8|0,O=Ue;do{Q=Vm(n[O>>2]|0,0,et)|0,(Q|0)==(et|0)&&(o[se>>0]=48,Q=se);do if((O|0)==(Ue|0)){if(M=Q+1|0,ss(s,Q,1),Me&(k|0)<1){Q=M;break}ss(s,5710,1),Q=M}else{if(Q>>>0<=ir>>>0)break;Jm(ir|0,48,Q+q|0)|0;do Q=Q+-1|0;while(Q>>>0>ir>>>0)}while(0);Lr=f-Q|0,ss(s,Q,(k|0)>(Lr|0)?Lr:k),k=k-Lr|0,O=O+4|0}while(O>>>0<Qe>>>0&(k|0)>-1)}Bs(s,48,k+18|0,18,0),ss(s,Xe,Pr-Xe|0)}Bs(s,32,c,B,d^8192)}else ir=(m&32|0)!=0,B=or+3|0,Bs(s,32,c,B,d&-65537),ss(s,Mr,or),ss(s,l!=l|!1?ir?5686:5690:ir?5678:5682,3),Bs(s,32,c,B,d^8192);while(0);return E=bn,((B|0)<(c|0)?c:B)|0}function u7(s){s=+s;var l=0;return C[v>>3]=s,l=n[v>>2]|0,De=n[v+4>>2]|0,l|0}function jUe(s,l){return s=+s,l=l|0,+ +A7(s,l)}function A7(s,l){s=+s,l=l|0;var c=0,f=0,d=0;switch(C[v>>3]=s,c=n[v>>2]|0,f=n[v+4>>2]|0,d=mD(c|0,f|0,52)|0,d&2047){case 0:{s!=0?(s=+A7(s*18446744073709552e3,l),c=(n[l>>2]|0)+-64|0):c=0,n[l>>2]=c;break}case 2047:break;default:n[l>>2]=(d&2047)+-1022,n[v>>2]=c,n[v+4>>2]=f&-2146435073|1071644672,s=+C[v>>3]}return+s}function qUe(s,l,c){s=s|0,l=l|0,c=c|0;do if(s){if(l>>>0<128){o[s>>0]=l,s=1;break}if(!(n[n[(GUe()|0)+188>>2]>>2]|0))if((l&-128|0)==57216){o[s>>0]=l,s=1;break}else{n[(Km()|0)>>2]=84,s=-1;break}if(l>>>0<2048){o[s>>0]=l>>>6|192,o[s+1>>0]=l&63|128,s=2;break}if(l>>>0<55296|(l&-8192|0)==57344){o[s>>0]=l>>>12|224,o[s+1>>0]=l>>>6&63|128,o[s+2>>0]=l&63|128,s=3;break}if((l+-65536|0)>>>0<1048576){o[s>>0]=l>>>18|240,o[s+1>>0]=l>>>12&63|128,o[s+2>>0]=l>>>6&63|128,o[s+3>>0]=l&63|128,s=4;break}else{n[(Km()|0)>>2]=84,s=-1;break}}else s=1;while(0);return s|0}function GUe(){return PR()|0}function YUe(){return PR()|0}function WUe(s,l){s=s|0,l=l|0;var c=0,f=0;for(f=0;;){if((u[5712+f>>0]|0)==(s|0)){s=2;break}if(c=f+1|0,(c|0)==87){c=5800,f=87,s=5;break}else f=c}if((s|0)==2&&(f?(c=5800,s=5):c=5800),(s|0)==5)for(;;){do s=c,c=c+1|0;while((o[s>>0]|0)!=0);if(f=f+-1|0,f)s=5;else break}return KUe(c,n[l+20>>2]|0)|0}function KUe(s,l){return s=s|0,l=l|0,VUe(s,l)|0}function VUe(s,l){return s=s|0,l=l|0,l?l=zUe(n[l>>2]|0,n[l+4>>2]|0,s)|0:l=0,(l|0?l:s)|0}function zUe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0;se=(n[s>>2]|0)+1794895138|0,m=Qg(n[s+8>>2]|0,se)|0,f=Qg(n[s+12>>2]|0,se)|0,d=Qg(n[s+16>>2]|0,se)|0;e:do if(m>>>0<l>>>2>>>0&&(q=l-(m<<2)|0,f>>>0<q>>>0&d>>>0<q>>>0)&&((d|f)&3|0)==0){for(q=f>>>2,O=d>>>2,M=0;;){if(k=m>>>1,Q=M+k|0,B=Q<<1,d=B+q|0,f=Qg(n[s+(d<<2)>>2]|0,se)|0,d=Qg(n[s+(d+1<<2)>>2]|0,se)|0,!(d>>>0<l>>>0&f>>>0<(l-d|0)>>>0)){f=0;break e}if(o[s+(d+f)>>0]|0){f=0;break e}if(f=s7(c,s+d|0)|0,!f)break;if(f=(f|0)<0,(m|0)==1){f=0;break e}else M=f?M:Q,m=f?k:m-k|0}f=B+O|0,d=Qg(n[s+(f<<2)>>2]|0,se)|0,f=Qg(n[s+(f+1<<2)>>2]|0,se)|0,f>>>0<l>>>0&d>>>0<(l-f|0)>>>0?f=(o[s+(f+d)>>0]|0)==0?s+f|0:0:f=0}else f=0;while(0);return f|0}function Qg(s,l){s=s|0,l=l|0;var c=0;return c=E7(s|0)|0,((l|0)==0?s:c)|0}function JUe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=c+16|0,d=n[f>>2]|0,d?m=5:XUe(c)|0?f=0:(d=n[f>>2]|0,m=5);e:do if((m|0)==5){if(k=c+20|0,B=n[k>>2]|0,f=B,(d-B|0)>>>0<l>>>0){f=ED[n[c+36>>2]&7](c,s,l)|0;break}t:do if((o[c+75>>0]|0)>-1){for(B=l;;){if(!B){m=0,d=s;break t}if(d=B+-1|0,(o[s+d>>0]|0)==10)break;B=d}if(f=ED[n[c+36>>2]&7](c,s,B)|0,f>>>0<B>>>0)break e;m=B,d=s+B|0,l=l-B|0,f=n[k>>2]|0}else m=0,d=s;while(0);Dr(f|0,d|0,l|0)|0,n[k>>2]=(n[k>>2]|0)+l,f=m+l|0}while(0);return f|0}function XUe(s){s=s|0;var l=0,c=0;return l=s+74|0,c=o[l>>0]|0,o[l>>0]=c+255|c,l=n[s>>2]|0,l&8?(n[s>>2]=l|32,s=-1):(n[s+8>>2]=0,n[s+4>>2]=0,c=n[s+44>>2]|0,n[s+28>>2]=c,n[s+20>>2]=c,n[s+16>>2]=c+(n[s+48>>2]|0),s=0),s|0}function _n(s,l){s=y(s),l=y(l);var c=0,f=0;c=f7(s)|0;do if((c&2147483647)>>>0<=2139095040){if(f=f7(l)|0,(f&2147483647)>>>0<=2139095040)if((f^c|0)<0){s=(c|0)<0?l:s;break}else{s=s<l?l:s;break}}else s=l;while(0);return y(s)}function f7(s){return s=y(s),h[v>>2]=s,n[v>>2]|0|0}function Fg(s,l){s=y(s),l=y(l);var c=0,f=0;c=p7(s)|0;do if((c&2147483647)>>>0<=2139095040){if(f=p7(l)|0,(f&2147483647)>>>0<=2139095040)if((f^c|0)<0){s=(c|0)<0?s:l;break}else{s=s<l?s:l;break}}else s=l;while(0);return y(s)}function p7(s){return s=y(s),h[v>>2]=s,n[v>>2]|0|0}function xR(s,l){s=y(s),l=y(l);var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0;m=(h[v>>2]=s,n[v>>2]|0),k=(h[v>>2]=l,n[v>>2]|0),c=m>>>23&255,B=k>>>23&255,Q=m&-2147483648,d=k<<1;e:do if((d|0)!=0&&!((c|0)==255|((ZUe(l)|0)&2147483647)>>>0>2139095040)){if(f=m<<1,f>>>0<=d>>>0)return l=y(s*y(0)),y((f|0)==(d|0)?l:s);if(c)f=m&8388607|8388608;else{if(c=m<<9,(c|0)>-1){f=c,c=0;do c=c+-1|0,f=f<<1;while((f|0)>-1)}else c=0;f=m<<1-c}if(B)k=k&8388607|8388608;else{if(m=k<<9,(m|0)>-1){d=0;do d=d+-1|0,m=m<<1;while((m|0)>-1)}else d=0;B=d,k=k<<1-d}d=f-k|0,m=(d|0)>-1;t:do if((c|0)>(B|0)){for(;;){if(m)if(d)f=d;else break;if(f=f<<1,c=c+-1|0,d=f-k|0,m=(d|0)>-1,(c|0)<=(B|0))break t}l=y(s*y(0));break e}while(0);if(m)if(d)f=d;else{l=y(s*y(0));break}if(f>>>0<8388608)do f=f<<1,c=c+-1|0;while(f>>>0<8388608);(c|0)>0?c=f+-8388608|c<<23:c=f>>>(1-c|0),l=(n[v>>2]=c|Q,y(h[v>>2]))}else M=3;while(0);return(M|0)==3&&(l=y(s*l),l=y(l/l)),y(l)}function ZUe(s){return s=y(s),h[v>>2]=s,n[v>>2]|0|0}function $Ue(s,l){return s=s|0,l=l|0,o7(n[582]|0,s,l)|0}function Jr(s){s=s|0,Rt()}function zm(s){s=s|0}function e3e(s,l){return s=s|0,l=l|0,0}function t3e(s){return s=s|0,(h7(s+4|0)|0)==-1?(ef[n[(n[s>>2]|0)+8>>2]&127](s),s=1):s=0,s|0}function h7(s){s=s|0;var l=0;return l=n[s>>2]|0,n[s>>2]=l+-1,l+-1|0}function xp(s){s=s|0,t3e(s)|0&&r3e(s)}function r3e(s){s=s|0;var l=0;l=s+8|0,(n[l>>2]|0)!=0&&(h7(l)|0)!=-1||ef[n[(n[s>>2]|0)+16>>2]&127](s)}function Kt(s){s=s|0;var l=0;for(l=(s|0)==0?1:s;s=pD(l)|0,!(s|0);){if(s=i3e()|0,!s){s=0;break}b7[s&0]()}return s|0}function g7(s){return s=s|0,Kt(s)|0}function gt(s){s=s|0,hD(s)}function n3e(s){s=s|0,(o[s+11>>0]|0)<0&>(n[s>>2]|0)}function i3e(){var s=0;return s=n[2923]|0,n[2923]=s+0,s|0}function s3e(){}function dD(s,l,c,f){return s=s|0,l=l|0,c=c|0,f=f|0,f=l-f-(c>>>0>s>>>0|0)>>>0,De=f,s-c>>>0|0|0}function bR(s,l,c,f){return s=s|0,l=l|0,c=c|0,f=f|0,c=s+c>>>0,De=l+f+(c>>>0<s>>>0|0)>>>0,c|0|0}function Jm(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;if(m=s+c|0,l=l&255,(c|0)>=67){for(;s&3;)o[s>>0]=l,s=s+1|0;for(f=m&-4|0,d=f-64|0,B=l|l<<8|l<<16|l<<24;(s|0)<=(d|0);)n[s>>2]=B,n[s+4>>2]=B,n[s+8>>2]=B,n[s+12>>2]=B,n[s+16>>2]=B,n[s+20>>2]=B,n[s+24>>2]=B,n[s+28>>2]=B,n[s+32>>2]=B,n[s+36>>2]=B,n[s+40>>2]=B,n[s+44>>2]=B,n[s+48>>2]=B,n[s+52>>2]=B,n[s+56>>2]=B,n[s+60>>2]=B,s=s+64|0;for(;(s|0)<(f|0);)n[s>>2]=B,s=s+4|0}for(;(s|0)<(m|0);)o[s>>0]=l,s=s+1|0;return m-c|0}function d7(s,l,c){return s=s|0,l=l|0,c=c|0,(c|0)<32?(De=l<<c|(s&(1<<c)-1<<32-c)>>>32-c,s<<c):(De=s<<c-32,0)}function mD(s,l,c){return s=s|0,l=l|0,c=c|0,(c|0)<32?(De=l>>>c,s>>>c|(l&(1<<c)-1)<<32-c):(De=0,l>>>c-32|0)}function Dr(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;if((c|0)>=8192)return Ac(s|0,l|0,c|0)|0;if(m=s|0,d=s+c|0,(s&3)==(l&3)){for(;s&3;){if(!c)return m|0;o[s>>0]=o[l>>0]|0,s=s+1|0,l=l+1|0,c=c-1|0}for(c=d&-4|0,f=c-64|0;(s|0)<=(f|0);)n[s>>2]=n[l>>2],n[s+4>>2]=n[l+4>>2],n[s+8>>2]=n[l+8>>2],n[s+12>>2]=n[l+12>>2],n[s+16>>2]=n[l+16>>2],n[s+20>>2]=n[l+20>>2],n[s+24>>2]=n[l+24>>2],n[s+28>>2]=n[l+28>>2],n[s+32>>2]=n[l+32>>2],n[s+36>>2]=n[l+36>>2],n[s+40>>2]=n[l+40>>2],n[s+44>>2]=n[l+44>>2],n[s+48>>2]=n[l+48>>2],n[s+52>>2]=n[l+52>>2],n[s+56>>2]=n[l+56>>2],n[s+60>>2]=n[l+60>>2],s=s+64|0,l=l+64|0;for(;(s|0)<(c|0);)n[s>>2]=n[l>>2],s=s+4|0,l=l+4|0}else for(c=d-4|0;(s|0)<(c|0);)o[s>>0]=o[l>>0]|0,o[s+1>>0]=o[l+1>>0]|0,o[s+2>>0]=o[l+2>>0]|0,o[s+3>>0]=o[l+3>>0]|0,s=s+4|0,l=l+4|0;for(;(s|0)<(d|0);)o[s>>0]=o[l>>0]|0,s=s+1|0,l=l+1|0;return m|0}function m7(s){s=s|0;var l=0;return l=o[N+(s&255)>>0]|0,(l|0)<8?l|0:(l=o[N+(s>>8&255)>>0]|0,(l|0)<8?l+8|0:(l=o[N+(s>>16&255)>>0]|0,(l|0)<8?l+16|0:(o[N+(s>>>24)>>0]|0)+24|0))}function y7(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0,k=0,Q=0,M=0,O=0,q=0,se=0,Ge=0,Me=0;if(O=s,Q=l,M=Q,B=c,se=f,k=se,!M)return m=(d|0)!=0,k?m?(n[d>>2]=s|0,n[d+4>>2]=l&0,se=0,d=0,De=se,d|0):(se=0,d=0,De=se,d|0):(m&&(n[d>>2]=(O>>>0)%(B>>>0),n[d+4>>2]=0),se=0,d=(O>>>0)/(B>>>0)>>>0,De=se,d|0);m=(k|0)==0;do if(B){if(!m){if(m=(S(k|0)|0)-(S(M|0)|0)|0,m>>>0<=31){q=m+1|0,k=31-m|0,l=m-31>>31,B=q,s=O>>>(q>>>0)&l|M<<k,l=M>>>(q>>>0)&l,m=0,k=O<<k;break}return d?(n[d>>2]=s|0,n[d+4>>2]=Q|l&0,se=0,d=0,De=se,d|0):(se=0,d=0,De=se,d|0)}if(m=B-1|0,m&B|0){k=(S(B|0)|0)+33-(S(M|0)|0)|0,Me=64-k|0,q=32-k|0,Q=q>>31,Ge=k-32|0,l=Ge>>31,B=k,s=q-1>>31&M>>>(Ge>>>0)|(M<<q|O>>>(k>>>0))&l,l=l&M>>>(k>>>0),m=O<<Me&Q,k=(M<<Me|O>>>(Ge>>>0))&Q|O<<q&k-33>>31;break}return d|0&&(n[d>>2]=m&O,n[d+4>>2]=0),(B|0)==1?(Ge=Q|l&0,Me=s|0|0,De=Ge,Me|0):(Me=m7(B|0)|0,Ge=M>>>(Me>>>0)|0,Me=M<<32-Me|O>>>(Me>>>0)|0,De=Ge,Me|0)}else{if(m)return d|0&&(n[d>>2]=(M>>>0)%(B>>>0),n[d+4>>2]=0),Ge=0,Me=(M>>>0)/(B>>>0)>>>0,De=Ge,Me|0;if(!O)return d|0&&(n[d>>2]=0,n[d+4>>2]=(M>>>0)%(k>>>0)),Ge=0,Me=(M>>>0)/(k>>>0)>>>0,De=Ge,Me|0;if(m=k-1|0,!(m&k))return d|0&&(n[d>>2]=s|0,n[d+4>>2]=m&M|l&0),Ge=0,Me=M>>>((m7(k|0)|0)>>>0),De=Ge,Me|0;if(m=(S(k|0)|0)-(S(M|0)|0)|0,m>>>0<=30){l=m+1|0,k=31-m|0,B=l,s=M<<k|O>>>(l>>>0),l=M>>>(l>>>0),m=0,k=O<<k;break}return d?(n[d>>2]=s|0,n[d+4>>2]=Q|l&0,Ge=0,Me=0,De=Ge,Me|0):(Ge=0,Me=0,De=Ge,Me|0)}while(0);if(!B)M=k,Q=0,k=0;else{q=c|0|0,O=se|f&0,M=bR(q|0,O|0,-1,-1)|0,c=De,Q=k,k=0;do f=Q,Q=m>>>31|Q<<1,m=k|m<<1,f=s<<1|f>>>31|0,se=s>>>31|l<<1|0,dD(M|0,c|0,f|0,se|0)|0,Me=De,Ge=Me>>31|((Me|0)<0?-1:0)<<1,k=Ge&1,s=dD(f|0,se|0,Ge&q|0,(((Me|0)<0?-1:0)>>31|((Me|0)<0?-1:0)<<1)&O|0)|0,l=De,B=B-1|0;while((B|0)!=0);M=Q,Q=0}return B=0,d|0&&(n[d>>2]=s,n[d+4>>2]=l),Ge=(m|0)>>>31|(M|B)<<1|(B<<1|m>>>31)&0|Q,Me=(m<<1|0>>>31)&-2|k,De=Ge,Me|0}function kR(s,l,c,f){return s=s|0,l=l|0,c=c|0,f=f|0,y7(s,l,c,f,0)|0}function bp(s){s=s|0;var l=0,c=0;return c=s+15&-16|0,l=n[I>>2]|0,s=l+c|0,(c|0)>0&(s|0)<(l|0)|(s|0)<0?(ie()|0,vA(12),-1):(n[I>>2]=s,(s|0)>(Z()|0)&&(X()|0)==0?(n[I>>2]=l,vA(12),-1):l|0)}function Ow(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;if((l|0)<(s|0)&(s|0)<(l+c|0)){for(f=s,l=l+c|0,s=s+c|0;(c|0)>0;)s=s-1|0,l=l-1|0,c=c-1|0,o[s>>0]=o[l>>0]|0;s=f}else Dr(s,l,c)|0;return s|0}function QR(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;return m=E,E=E+16|0,d=m|0,y7(s,l,c,f,d)|0,E=m,De=n[d+4>>2]|0,n[d>>2]|0|0}function E7(s){return s=s|0,(s&255)<<24|(s>>8&255)<<16|(s>>16&255)<<8|s>>>24|0}function o3e(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,C7[s&1](l|0,c|0,f|0,d|0,m|0)}function a3e(s,l,c){s=s|0,l=l|0,c=y(c),w7[s&1](l|0,y(c))}function l3e(s,l,c){s=s|0,l=l|0,c=+c,I7[s&31](l|0,+c)}function c3e(s,l,c,f){return s=s|0,l=l|0,c=y(c),f=y(f),y(B7[s&0](l|0,y(c),y(f)))}function u3e(s,l){s=s|0,l=l|0,ef[s&127](l|0)}function A3e(s,l,c){s=s|0,l=l|0,c=c|0,tf[s&31](l|0,c|0)}function f3e(s,l){return s=s|0,l=l|0,Tg[s&31](l|0)|0}function p3e(s,l,c,f,d){s=s|0,l=l|0,c=+c,f=+f,d=d|0,v7[s&1](l|0,+c,+f,d|0)}function h3e(s,l,c,f){s=s|0,l=l|0,c=+c,f=+f,V3e[s&1](l|0,+c,+f)}function g3e(s,l,c,f){return s=s|0,l=l|0,c=c|0,f=f|0,ED[s&7](l|0,c|0,f|0)|0}function d3e(s,l,c,f){return s=s|0,l=l|0,c=c|0,f=f|0,+z3e[s&1](l|0,c|0,f|0)}function m3e(s,l){return s=s|0,l=l|0,+D7[s&15](l|0)}function y3e(s,l,c){return s=s|0,l=l|0,c=+c,J3e[s&1](l|0,+c)|0}function E3e(s,l,c){return s=s|0,l=l|0,c=c|0,RR[s&15](l|0,c|0)|0}function C3e(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=+f,d=+d,m=m|0,X3e[s&1](l|0,c|0,+f,+d,m|0)}function w3e(s,l,c,f,d,m,B){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,B=B|0,Z3e[s&1](l|0,c|0,f|0,d|0,m|0,B|0)}function I3e(s,l,c){return s=s|0,l=l|0,c=c|0,+P7[s&7](l|0,c|0)}function B3e(s){return s=s|0,CD[s&7]()|0}function v3e(s,l,c,f,d,m){return s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,S7[s&1](l|0,c|0,f|0,d|0,m|0)|0}function D3e(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=+d,$3e[s&1](l|0,c|0,f|0,+d)}function P3e(s,l,c,f,d,m,B){s=s|0,l=l|0,c=c|0,f=y(f),d=d|0,m=y(m),B=B|0,x7[s&1](l|0,c|0,y(f),d|0,y(m),B|0)}function S3e(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,_w[s&15](l|0,c|0,f|0)}function x3e(s){s=s|0,b7[s&0]()}function b3e(s,l,c,f){s=s|0,l=l|0,c=c|0,f=+f,k7[s&15](l|0,c|0,+f)}function k3e(s,l,c){return s=s|0,l=+l,c=+c,e_e[s&1](+l,+c)|0}function Q3e(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,TR[s&15](l|0,c|0,f|0,d|0)}function F3e(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,R(0)}function R3e(s,l){s=s|0,l=y(l),R(1)}function ma(s,l){s=s|0,l=+l,R(2)}function T3e(s,l,c){return s=s|0,l=y(l),c=y(c),R(3),Ze}function Er(s){s=s|0,R(4)}function Mw(s,l){s=s|0,l=l|0,R(5)}function za(s){return s=s|0,R(6),0}function L3e(s,l,c,f){s=s|0,l=+l,c=+c,f=f|0,R(7)}function N3e(s,l,c){s=s|0,l=+l,c=+c,R(8)}function O3e(s,l,c){return s=s|0,l=l|0,c=c|0,R(9),0}function M3e(s,l,c){return s=s|0,l=l|0,c=c|0,R(10),0}function Rg(s){return s=s|0,R(11),0}function U3e(s,l){return s=s|0,l=+l,R(12),0}function Uw(s,l){return s=s|0,l=l|0,R(13),0}function _3e(s,l,c,f,d){s=s|0,l=l|0,c=+c,f=+f,d=d|0,R(14)}function H3e(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,R(15)}function FR(s,l){return s=s|0,l=l|0,R(16),0}function j3e(){return R(17),0}function q3e(s,l,c,f,d){return s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,R(18),0}function G3e(s,l,c,f){s=s|0,l=l|0,c=c|0,f=+f,R(19)}function Y3e(s,l,c,f,d,m){s=s|0,l=l|0,c=y(c),f=f|0,d=y(d),m=m|0,R(20)}function yD(s,l,c){s=s|0,l=l|0,c=c|0,R(21)}function W3e(){R(22)}function Xm(s,l,c){s=s|0,l=l|0,c=+c,R(23)}function K3e(s,l){return s=+s,l=+l,R(24),0}function Zm(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,R(25)}var C7=[F3e,qNe],w7=[R3e,fo],I7=[ma,xw,bw,EF,CF,Dl,kw,wF,Hm,xu,Fw,IF,$v,WA,eD,jm,tD,rD,qm,ma,ma,ma,ma,ma,ma,ma,ma,ma,ma,ma,ma,ma],B7=[T3e],ef=[Er,zm,BDe,vDe,DDe,ebe,tbe,rbe,yLe,ELe,CLe,kNe,QNe,FNe,Z4e,$4e,eUe,hs,Vv,_m,YA,Qw,Eve,Cve,pDe,RDe,YDe,cPe,DPe,jPe,sSe,CSe,LSe,XSe,pxe,bxe,Yxe,Ebe,Lbe,Xbe,pke,bke,Yke,uQe,DQe,UQe,tFe,Sc,FFe,VFe,pRe,QRe,WRe,pTe,BTe,PTe,qTe,WTe,cLe,ILe,DLe,jLe,oNe,o5,HOe,yMe,RMe,VMe,d4e,Q4e,j4e,Y4e,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er],tf=[Mw,fF,pF,Sw,Su,hF,gF,wp,dF,mF,yF,Zv,KA,Ve,At,Wt,vr,Sn,Fr,vF,ove,bve,hQe,xQe,LRe,GOe,hNe,G9,Mw,Mw,Mw,Mw],Tg=[za,bUe,AF,D,Ae,ve,vt,wt,bt,_r,di,po,nve,ive,wve,iFe,JRe,YLe,VOe,Wa,za,za,za,za,za,za,za,za,za,za,za,za],v7=[L3e,Ive],V3e=[N3e,fLe],ED=[O3e,i7,kUe,RUe,WPe,Bbe,NFe,ZMe],z3e=[M3e,mxe],D7=[Rg,Yo,rt,xn,Bve,vve,Dve,Pve,Sve,xve,Rg,Rg,Rg,Rg,Rg,Rg],J3e=[U3e,CTe],RR=[Uw,e3e,sve,mDe,pPe,lSe,BSe,zxe,Ube,qQe,Wv,OMe,Uw,Uw,Uw,Uw],X3e=[_3e,zDe],Z3e=[H3e,C4e],P7=[FR,ai,kve,Qve,Fve,Rxe,FR,FR],CD=[j3e,Rve,Dw,ga,kTe,JTe,bLe,z4e],S7=[q3e,Ew],$3e=[G3e,mke],x7=[Y3e,ave],_w=[yD,T,is,en,ho,bPe,MSe,Rke,zke,Um,fOe,IMe,L4e,yD,yD,yD],b7=[W3e],k7=[Xm,zv,Jv,Xv,GA,nD,BF,P,tke,ZFe,dTe,Xm,Xm,Xm,Xm,Xm],e_e=[K3e,dLe],TR=[Zm,txe,fFe,mRe,sTe,NTe,rLe,NLe,ANe,eMe,oUe,Zm,Zm,Zm,Zm,Zm];return{_llvm_bswap_i32:E7,dynCall_idd:k3e,dynCall_i:B3e,_i64Subtract:dD,___udivdi3:kR,dynCall_vif:a3e,setThrew:hu,dynCall_viii:S3e,_bitshift64Lshr:mD,_bitshift64Shl:d7,dynCall_vi:u3e,dynCall_viiddi:C3e,dynCall_diii:d3e,dynCall_iii:E3e,_memset:Jm,_sbrk:bp,_memcpy:Dr,__GLOBAL__sub_I_Yoga_cpp:Mm,dynCall_vii:A3e,___uremdi3:QR,dynCall_vid:l3e,stackAlloc:lo,_nbind_init:mUe,getTempRet0:Ua,dynCall_di:m3e,dynCall_iid:y3e,setTempRet0:xA,_i64Add:bR,dynCall_fiff:c3e,dynCall_iiii:g3e,_emscripten_get_global_libc:xUe,dynCall_viid:b3e,dynCall_viiid:D3e,dynCall_viififi:P3e,dynCall_ii:f3e,__GLOBAL__sub_I_Binding_cc:ROe,dynCall_viiii:Q3e,dynCall_iiiiii:v3e,stackSave:gc,dynCall_viiiii:o3e,__GLOBAL__sub_I_nbind_cc:Tve,dynCall_vidd:h3e,_free:hD,runPostSets:s3e,dynCall_viiiiii:w3e,establishStackSpace:ji,_memmove:Ow,stackRestore:pu,_malloc:pD,__GLOBAL__sub_I_common_cc:$Le,dynCall_viddi:p3e,dynCall_dii:I3e,dynCall_v:x3e}}(Module.asmGlobalArg,Module.asmLibraryArg,buffer),_llvm_bswap_i32=Module._llvm_bswap_i32=asm._llvm_bswap_i32,getTempRet0=Module.getTempRet0=asm.getTempRet0,___udivdi3=Module.___udivdi3=asm.___udivdi3,setThrew=Module.setThrew=asm.setThrew,_bitshift64Lshr=Module._bitshift64Lshr=asm._bitshift64Lshr,_bitshift64Shl=Module._bitshift64Shl=asm._bitshift64Shl,_memset=Module._memset=asm._memset,_sbrk=Module._sbrk=asm._sbrk,_memcpy=Module._memcpy=asm._memcpy,stackAlloc=Module.stackAlloc=asm.stackAlloc,___uremdi3=Module.___uremdi3=asm.___uremdi3,_nbind_init=Module._nbind_init=asm._nbind_init,_i64Subtract=Module._i64Subtract=asm._i64Subtract,setTempRet0=Module.setTempRet0=asm.setTempRet0,_i64Add=Module._i64Add=asm._i64Add,_emscripten_get_global_libc=Module._emscripten_get_global_libc=asm._emscripten_get_global_libc,__GLOBAL__sub_I_Yoga_cpp=Module.__GLOBAL__sub_I_Yoga_cpp=asm.__GLOBAL__sub_I_Yoga_cpp,__GLOBAL__sub_I_Binding_cc=Module.__GLOBAL__sub_I_Binding_cc=asm.__GLOBAL__sub_I_Binding_cc,stackSave=Module.stackSave=asm.stackSave,__GLOBAL__sub_I_nbind_cc=Module.__GLOBAL__sub_I_nbind_cc=asm.__GLOBAL__sub_I_nbind_cc,_free=Module._free=asm._free,runPostSets=Module.runPostSets=asm.runPostSets,establishStackSpace=Module.establishStackSpace=asm.establishStackSpace,_memmove=Module._memmove=asm._memmove,stackRestore=Module.stackRestore=asm.stackRestore,_malloc=Module._malloc=asm._malloc,__GLOBAL__sub_I_common_cc=Module.__GLOBAL__sub_I_common_cc=asm.__GLOBAL__sub_I_common_cc,dynCall_viiiii=Module.dynCall_viiiii=asm.dynCall_viiiii,dynCall_vif=Module.dynCall_vif=asm.dynCall_vif,dynCall_vid=Module.dynCall_vid=asm.dynCall_vid,dynCall_fiff=Module.dynCall_fiff=asm.dynCall_fiff,dynCall_vi=Module.dynCall_vi=asm.dynCall_vi,dynCall_vii=Module.dynCall_vii=asm.dynCall_vii,dynCall_ii=Module.dynCall_ii=asm.dynCall_ii,dynCall_viddi=Module.dynCall_viddi=asm.dynCall_viddi,dynCall_vidd=Module.dynCall_vidd=asm.dynCall_vidd,dynCall_iiii=Module.dynCall_iiii=asm.dynCall_iiii,dynCall_diii=Module.dynCall_diii=asm.dynCall_diii,dynCall_di=Module.dynCall_di=asm.dynCall_di,dynCall_iid=Module.dynCall_iid=asm.dynCall_iid,dynCall_iii=Module.dynCall_iii=asm.dynCall_iii,dynCall_viiddi=Module.dynCall_viiddi=asm.dynCall_viiddi,dynCall_viiiiii=Module.dynCall_viiiiii=asm.dynCall_viiiiii,dynCall_dii=Module.dynCall_dii=asm.dynCall_dii,dynCall_i=Module.dynCall_i=asm.dynCall_i,dynCall_iiiiii=Module.dynCall_iiiiii=asm.dynCall_iiiiii,dynCall_viiid=Module.dynCall_viiid=asm.dynCall_viiid,dynCall_viififi=Module.dynCall_viififi=asm.dynCall_viififi,dynCall_viii=Module.dynCall_viii=asm.dynCall_viii,dynCall_v=Module.dynCall_v=asm.dynCall_v,dynCall_viid=Module.dynCall_viid=asm.dynCall_viid,dynCall_idd=Module.dynCall_idd=asm.dynCall_idd,dynCall_viiii=Module.dynCall_viiii=asm.dynCall_viiii;Runtime.stackAlloc=Module.stackAlloc,Runtime.stackSave=Module.stackSave,Runtime.stackRestore=Module.stackRestore,Runtime.establishStackSpace=Module.establishStackSpace,Runtime.setTempRet0=Module.setTempRet0,Runtime.getTempRet0=Module.getTempRet0,Module.asm=asm;function ExitStatus(t){this.name="ExitStatus",this.message="Program terminated with exit("+t+")",this.status=t}ExitStatus.prototype=new Error,ExitStatus.prototype.constructor=ExitStatus;var initialStackTop,preloadStartTime=null,calledMain=!1;dependenciesFulfilled=function t(){Module.calledRun||run(),Module.calledRun||(dependenciesFulfilled=t)},Module.callMain=Module.callMain=function t(e){e=e||[],ensureInitRuntime();var r=e.length+1;function o(){for(var p=0;p<4-1;p++)a.push(0)}var a=[allocate(intArrayFromString(Module.thisProgram),"i8",ALLOC_NORMAL)];o();for(var n=0;n<r-1;n=n+1)a.push(allocate(intArrayFromString(e[n]),"i8",ALLOC_NORMAL)),o();a.push(0),a=allocate(a,"i32",ALLOC_NORMAL);try{var u=Module._main(r,a,0);exit(u,!0)}catch(p){if(p instanceof ExitStatus)return;if(p=="SimulateInfiniteLoop"){Module.noExitRuntime=!0;return}else{var A=p;p&&typeof p=="object"&&p.stack&&(A=[p,p.stack]),Module.printErr("exception thrown: "+A),Module.quit(1,p)}}finally{calledMain=!0}};function run(t){if(t=t||Module.arguments,preloadStartTime===null&&(preloadStartTime=Date.now()),runDependencies>0||(preRun(),runDependencies>0)||Module.calledRun)return;function e(){Module.calledRun||(Module.calledRun=!0,!ABORT&&(ensureInitRuntime(),preMain(),Module.onRuntimeInitialized&&Module.onRuntimeInitialized(),Module._main&&shouldRunNow&&Module.callMain(t),postRun()))}Module.setStatus?(Module.setStatus("Running..."),setTimeout(function(){setTimeout(function(){Module.setStatus("")},1),e()},1)):e()}Module.run=Module.run=run;function exit(t,e){e&&Module.noExitRuntime||(Module.noExitRuntime||(ABORT=!0,EXITSTATUS=t,STACKTOP=initialStackTop,exitRuntime(),Module.onExit&&Module.onExit(t)),ENVIRONMENT_IS_NODE&&process.exit(t),Module.quit(t,new ExitStatus(t)))}Module.exit=Module.exit=exit;var abortDecorators=[];function abort(t){Module.onAbort&&Module.onAbort(t),t!==void 0?(Module.print(t),Module.printErr(t),t=JSON.stringify(t)):t="",ABORT=!0,EXITSTATUS=1;var e=` -If this abort() is unexpected, build with -s ASSERTIONS=1 which can give more information.`,r="abort("+t+") at "+stackTrace()+e;throw abortDecorators&&abortDecorators.forEach(function(o){r=o(r,t)}),r}if(Module.abort=Module.abort=abort,Module.preInit)for(typeof Module.preInit=="function"&&(Module.preInit=[Module.preInit]);Module.preInit.length>0;)Module.preInit.pop()();var shouldRunNow=!0;Module.noInitialRun&&(shouldRunNow=!1),run()})});var om=_((yKt,NEe)=>{"use strict";var Gyt=TEe(),Yyt=LEe(),b6=!1,k6=null;Yyt({},function(t,e){if(!b6){if(b6=!0,t)throw t;k6=e}});if(!b6)throw new Error("Failed to load the yoga module - it needed to be loaded synchronously, but didn't");NEe.exports=Gyt(k6.bind,k6.lib)});var F6=_((EKt,Q6)=>{"use strict";var OEe=t=>Number.isNaN(t)?!1:t>=4352&&(t<=4447||t===9001||t===9002||11904<=t&&t<=12871&&t!==12351||12880<=t&&t<=19903||19968<=t&&t<=42182||43360<=t&&t<=43388||44032<=t&&t<=55203||63744<=t&&t<=64255||65040<=t&&t<=65049||65072<=t&&t<=65131||65281<=t&&t<=65376||65504<=t&&t<=65510||110592<=t&&t<=110593||127488<=t&&t<=127569||131072<=t&&t<=262141);Q6.exports=OEe;Q6.exports.default=OEe});var UEe=_((CKt,MEe)=>{"use strict";MEe.exports=function(){return/\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62(?:\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDB40\uDC73\uDB40\uDC63\uDB40\uDC74|\uDB40\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F|\uD83D\uDC68(?:\uD83C\uDFFC\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68\uD83C\uDFFB|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFE])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFE\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFD])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFC])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83D\uDC68|(?:\uD83D[\uDC68\uDC69])\u200D(?:\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67]))|\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67])|(?:\uD83D[\uDC68\uDC69])\u200D(?:\uD83D[\uDC66\uDC67])|[\u2695\u2696\u2708]\uFE0F|\uD83D[\uDC66\uDC67]|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|(?:\uD83C\uDFFB\u200D[\u2695\u2696\u2708]|\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708])\uFE0F|\uD83C\uDFFB\u200D(?:\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C[\uDFFB-\uDFFF])|(?:\uD83E\uDDD1\uD83C\uDFFB\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDD1D\u200D\uD83D\uDC69)\uD83C\uDFFB|\uD83E\uDDD1(?:\uD83C\uDFFF\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1(?:\uD83C[\uDFFB-\uDFFF])|\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1)|(?:\uD83E\uDDD1\uD83C\uDFFE\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB-\uDFFE])|(?:\uD83E\uDDD1\uD83C\uDFFC\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDD1D\u200D\uD83D\uDC69)(?:\uD83C[\uDFFB\uDFFC])|\uD83D\uDC69(?:\uD83C\uDFFE\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFD\uDFFF])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFC\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFD-\uDFFF])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFB\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFC-\uDFFF])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D(?:\uD83D[\uDC68\uDC69])|\uD83D[\uDC68\uDC69])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD]))|\uD83D\uDC69\u200D\uD83D\uDC69\u200D(?:\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67]))|(?:\uD83E\uDDD1\uD83C\uDFFD\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDD1D\u200D\uD83D\uDC69)(?:\uD83C[\uDFFB-\uDFFD])|\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC69\u200D\uD83D\uDC69\u200D(?:\uD83D[\uDC66\uDC67])|(?:\uD83D\uDC41\uFE0F\u200D\uD83D\uDDE8|\uD83D\uDC69(?:\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708]|\uD83C\uDFFB\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\u200D[\u2695\u2696\u2708])|(?:(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)\uFE0F|\uD83D\uDC6F|\uD83E[\uDD3C\uDDDE\uDDDF])\u200D[\u2640\u2642]|(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)(?:\uD83C[\uDFFB-\uDFFF])\u200D[\u2640\u2642]|(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD6-\uDDDD])(?:(?:\uD83C[\uDFFB-\uDFFF])\u200D[\u2640\u2642]|\u200D[\u2640\u2642])|\uD83C\uDFF4\u200D\u2620)\uFE0F|\uD83D\uDC69\u200D\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67])|\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08|\uD83D\uDC15\u200D\uD83E\uDDBA|\uD83D\uDC69\u200D\uD83D\uDC66|\uD83D\uDC69\u200D\uD83D\uDC67|\uD83C\uDDFD\uD83C\uDDF0|\uD83C\uDDF4\uD83C\uDDF2|\uD83C\uDDF6\uD83C\uDDE6|[#\*0-9]\uFE0F\u20E3|\uD83C\uDDE7(?:\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF])|\uD83C\uDDF9(?:\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF])|\uD83C\uDDEA(?:\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA])|\uD83E\uDDD1(?:\uD83C[\uDFFB-\uDFFF])|\uD83C\uDDF7(?:\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC])|\uD83D\uDC69(?:\uD83C[\uDFFB-\uDFFF])|\uD83C\uDDF2(?:\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF])|\uD83C\uDDE6(?:\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF])|\uD83C\uDDF0(?:\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF])|\uD83C\uDDED(?:\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA])|\uD83C\uDDE9(?:\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF])|\uD83C\uDDFE(?:\uD83C[\uDDEA\uDDF9])|\uD83C\uDDEC(?:\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE])|\uD83C\uDDF8(?:\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF])|\uD83C\uDDEB(?:\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7])|\uD83C\uDDF5(?:\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE])|\uD83C\uDDFB(?:\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA])|\uD83C\uDDF3(?:\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF])|\uD83C\uDDE8(?:\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF])|\uD83C\uDDF1(?:\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE])|\uD83C\uDDFF(?:\uD83C[\uDDE6\uDDF2\uDDFC])|\uD83C\uDDFC(?:\uD83C[\uDDEB\uDDF8])|\uD83C\uDDFA(?:\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF])|\uD83C\uDDEE(?:\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9])|\uD83C\uDDEF(?:\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5])|(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD6-\uDDDD])(?:\uD83C[\uDFFB-\uDFFF])|(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)(?:\uD83C[\uDFFB-\uDFFF])|(?:[\u261D\u270A-\u270D]|\uD83C[\uDF85\uDFC2\uDFC7]|\uD83D[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC70\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDCAA\uDD74\uDD7A\uDD90\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC]|\uD83E[\uDD0F\uDD18-\uDD1C\uDD1E\uDD1F\uDD30-\uDD36\uDDB5\uDDB6\uDDBB\uDDD2-\uDDD5])(?:\uD83C[\uDFFB-\uDFFF])|(?:[\u231A\u231B\u23E9-\u23EC\u23F0\u23F3\u25FD\u25FE\u2614\u2615\u2648-\u2653\u267F\u2693\u26A1\u26AA\u26AB\u26BD\u26BE\u26C4\u26C5\u26CE\u26D4\u26EA\u26F2\u26F3\u26F5\u26FA\u26FD\u2705\u270A\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B\u2B1C\u2B50\u2B55]|\uD83C[\uDC04\uDCCF\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF93\uDFA0-\uDFCA\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF4\uDFF8-\uDFFF]|\uD83D[\uDC00-\uDC3E\uDC40\uDC42-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDD7A\uDD95\uDD96\uDDA4\uDDFB-\uDE4F\uDE80-\uDEC5\uDECC\uDED0-\uDED2\uDED5\uDEEB\uDEEC\uDEF4-\uDEFA\uDFE0-\uDFEB]|\uD83E[\uDD0D-\uDD3A\uDD3C-\uDD45\uDD47-\uDD71\uDD73-\uDD76\uDD7A-\uDDA2\uDDA5-\uDDAA\uDDAE-\uDDCA\uDDCD-\uDDFF\uDE70-\uDE73\uDE78-\uDE7A\uDE80-\uDE82\uDE90-\uDE95])|(?:[#\*0-9\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23E9-\u23F3\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u261D\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692-\u2697\u2699\u269B\u269C\u26A0\u26A1\u26AA\u26AB\u26B0\u26B1\u26BD\u26BE\u26C4\u26C5\u26C8\u26CE\u26CF\u26D1\u26D3\u26D4\u26E9\u26EA\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2728\u2733\u2734\u2744\u2747\u274C\u274E\u2753-\u2755\u2757\u2763\u2764\u2795-\u2797\u27A1\u27B0\u27BF\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299]|\uD83C[\uDC04\uDCCF\uDD70\uDD71\uDD7E\uDD7F\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE02\uDE1A\uDE2F\uDE32-\uDE3A\uDE50\uDE51\uDF00-\uDF21\uDF24-\uDF93\uDF96\uDF97\uDF99-\uDF9B\uDF9E-\uDFF0\uDFF3-\uDFF5\uDFF7-\uDFFF]|\uD83D[\uDC00-\uDCFD\uDCFF-\uDD3D\uDD49-\uDD4E\uDD50-\uDD67\uDD6F\uDD70\uDD73-\uDD7A\uDD87\uDD8A-\uDD8D\uDD90\uDD95\uDD96\uDDA4\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA-\uDE4F\uDE80-\uDEC5\uDECB-\uDED2\uDED5\uDEE0-\uDEE5\uDEE9\uDEEB\uDEEC\uDEF0\uDEF3-\uDEFA\uDFE0-\uDFEB]|\uD83E[\uDD0D-\uDD3A\uDD3C-\uDD45\uDD47-\uDD71\uDD73-\uDD76\uDD7A-\uDDA2\uDDA5-\uDDAA\uDDAE-\uDDCA\uDDCD-\uDDFF\uDE70-\uDE73\uDE78-\uDE7A\uDE80-\uDE82\uDE90-\uDE95])\uFE0F|(?:[\u261D\u26F9\u270A-\u270D]|\uD83C[\uDF85\uDFC2-\uDFC4\uDFC7\uDFCA-\uDFCC]|\uD83D[\uDC42\uDC43\uDC46-\uDC50\uDC66-\uDC78\uDC7C\uDC81-\uDC83\uDC85-\uDC87\uDC8F\uDC91\uDCAA\uDD74\uDD75\uDD7A\uDD90\uDD95\uDD96\uDE45-\uDE47\uDE4B-\uDE4F\uDEA3\uDEB4-\uDEB6\uDEC0\uDECC]|\uD83E[\uDD0F\uDD18-\uDD1F\uDD26\uDD30-\uDD39\uDD3C-\uDD3E\uDDB5\uDDB6\uDDB8\uDDB9\uDDBB\uDDCD-\uDDCF\uDDD1-\uDDDD])/g}});var Kk=_((wKt,R6)=>{"use strict";var Wyt=NP(),Kyt=F6(),Vyt=UEe(),_Ee=t=>{if(typeof t!="string"||t.length===0||(t=Wyt(t),t.length===0))return 0;t=t.replace(Vyt()," ");let e=0;for(let r=0;r<t.length;r++){let o=t.codePointAt(r);o<=31||o>=127&&o<=159||o>=768&&o<=879||(o>65535&&r++,e+=Kyt(o)?2:1)}return e};R6.exports=_Ee;R6.exports.default=_Ee});var L6=_((IKt,T6)=>{"use strict";var zyt=Kk(),HEe=t=>{let e=0;for(let r of t.split(` -`))e=Math.max(e,zyt(r));return e};T6.exports=HEe;T6.exports.default=HEe});var jEe=_(lB=>{"use strict";var Jyt=lB&&lB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(lB,"__esModule",{value:!0});var Xyt=Jyt(L6()),N6={};lB.default=t=>{if(t.length===0)return{width:0,height:0};if(N6[t])return N6[t];let e=Xyt.default(t),r=t.split(` -`).length;return N6[t]={width:e,height:r},{width:e,height:r}}});var qEe=_(cB=>{"use strict";var Zyt=cB&&cB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(cB,"__esModule",{value:!0});var dn=Zyt(om()),$yt=(t,e)=>{"position"in e&&t.setPositionType(e.position==="absolute"?dn.default.POSITION_TYPE_ABSOLUTE:dn.default.POSITION_TYPE_RELATIVE)},eEt=(t,e)=>{"marginLeft"in e&&t.setMargin(dn.default.EDGE_START,e.marginLeft||0),"marginRight"in e&&t.setMargin(dn.default.EDGE_END,e.marginRight||0),"marginTop"in e&&t.setMargin(dn.default.EDGE_TOP,e.marginTop||0),"marginBottom"in e&&t.setMargin(dn.default.EDGE_BOTTOM,e.marginBottom||0)},tEt=(t,e)=>{"paddingLeft"in e&&t.setPadding(dn.default.EDGE_LEFT,e.paddingLeft||0),"paddingRight"in e&&t.setPadding(dn.default.EDGE_RIGHT,e.paddingRight||0),"paddingTop"in e&&t.setPadding(dn.default.EDGE_TOP,e.paddingTop||0),"paddingBottom"in e&&t.setPadding(dn.default.EDGE_BOTTOM,e.paddingBottom||0)},rEt=(t,e)=>{var r;"flexGrow"in e&&t.setFlexGrow((r=e.flexGrow)!==null&&r!==void 0?r:0),"flexShrink"in e&&t.setFlexShrink(typeof e.flexShrink=="number"?e.flexShrink:1),"flexDirection"in e&&(e.flexDirection==="row"&&t.setFlexDirection(dn.default.FLEX_DIRECTION_ROW),e.flexDirection==="row-reverse"&&t.setFlexDirection(dn.default.FLEX_DIRECTION_ROW_REVERSE),e.flexDirection==="column"&&t.setFlexDirection(dn.default.FLEX_DIRECTION_COLUMN),e.flexDirection==="column-reverse"&&t.setFlexDirection(dn.default.FLEX_DIRECTION_COLUMN_REVERSE)),"flexBasis"in e&&(typeof e.flexBasis=="number"?t.setFlexBasis(e.flexBasis):typeof e.flexBasis=="string"?t.setFlexBasisPercent(Number.parseInt(e.flexBasis,10)):t.setFlexBasis(NaN)),"alignItems"in e&&((e.alignItems==="stretch"||!e.alignItems)&&t.setAlignItems(dn.default.ALIGN_STRETCH),e.alignItems==="flex-start"&&t.setAlignItems(dn.default.ALIGN_FLEX_START),e.alignItems==="center"&&t.setAlignItems(dn.default.ALIGN_CENTER),e.alignItems==="flex-end"&&t.setAlignItems(dn.default.ALIGN_FLEX_END)),"alignSelf"in e&&((e.alignSelf==="auto"||!e.alignSelf)&&t.setAlignSelf(dn.default.ALIGN_AUTO),e.alignSelf==="flex-start"&&t.setAlignSelf(dn.default.ALIGN_FLEX_START),e.alignSelf==="center"&&t.setAlignSelf(dn.default.ALIGN_CENTER),e.alignSelf==="flex-end"&&t.setAlignSelf(dn.default.ALIGN_FLEX_END)),"justifyContent"in e&&((e.justifyContent==="flex-start"||!e.justifyContent)&&t.setJustifyContent(dn.default.JUSTIFY_FLEX_START),e.justifyContent==="center"&&t.setJustifyContent(dn.default.JUSTIFY_CENTER),e.justifyContent==="flex-end"&&t.setJustifyContent(dn.default.JUSTIFY_FLEX_END),e.justifyContent==="space-between"&&t.setJustifyContent(dn.default.JUSTIFY_SPACE_BETWEEN),e.justifyContent==="space-around"&&t.setJustifyContent(dn.default.JUSTIFY_SPACE_AROUND))},nEt=(t,e)=>{var r,o;"width"in e&&(typeof e.width=="number"?t.setWidth(e.width):typeof e.width=="string"?t.setWidthPercent(Number.parseInt(e.width,10)):t.setWidthAuto()),"height"in e&&(typeof e.height=="number"?t.setHeight(e.height):typeof e.height=="string"?t.setHeightPercent(Number.parseInt(e.height,10)):t.setHeightAuto()),"minWidth"in e&&(typeof e.minWidth=="string"?t.setMinWidthPercent(Number.parseInt(e.minWidth,10)):t.setMinWidth((r=e.minWidth)!==null&&r!==void 0?r:0)),"minHeight"in e&&(typeof e.minHeight=="string"?t.setMinHeightPercent(Number.parseInt(e.minHeight,10)):t.setMinHeight((o=e.minHeight)!==null&&o!==void 0?o:0))},iEt=(t,e)=>{"display"in e&&t.setDisplay(e.display==="flex"?dn.default.DISPLAY_FLEX:dn.default.DISPLAY_NONE)},sEt=(t,e)=>{if("borderStyle"in e){let r=typeof e.borderStyle=="string"?1:0;t.setBorder(dn.default.EDGE_TOP,r),t.setBorder(dn.default.EDGE_BOTTOM,r),t.setBorder(dn.default.EDGE_LEFT,r),t.setBorder(dn.default.EDGE_RIGHT,r)}};cB.default=(t,e={})=>{$yt(t,e),eEt(t,e),tEt(t,e),rEt(t,e),nEt(t,e),iEt(t,e),sEt(t,e)}});var WEe=_((DKt,YEe)=>{"use strict";var uB=Kk(),oEt=NP(),aEt=BI(),M6=new Set(["\x1B","\x9B"]),lEt=39,GEe=t=>`${M6.values().next().value}[${t}m`,cEt=t=>t.split(" ").map(e=>uB(e)),O6=(t,e,r)=>{let o=[...e],a=!1,n=uB(oEt(t[t.length-1]));for(let[u,A]of o.entries()){let p=uB(A);if(n+p<=r?t[t.length-1]+=A:(t.push(A),n=0),M6.has(A))a=!0;else if(a&&A==="m"){a=!1;continue}a||(n+=p,n===r&&u<o.length-1&&(t.push(""),n=0))}!n&&t[t.length-1].length>0&&t.length>1&&(t[t.length-2]+=t.pop())},uEt=t=>{let e=t.split(" "),r=e.length;for(;r>0&&!(uB(e[r-1])>0);)r--;return r===e.length?t:e.slice(0,r).join(" ")+e.slice(r).join("")},AEt=(t,e,r={})=>{if(r.trim!==!1&&t.trim()==="")return"";let o="",a="",n,u=cEt(t),A=[""];for(let[p,h]of t.split(" ").entries()){r.trim!==!1&&(A[A.length-1]=A[A.length-1].trimLeft());let C=uB(A[A.length-1]);if(p!==0&&(C>=e&&(r.wordWrap===!1||r.trim===!1)&&(A.push(""),C=0),(C>0||r.trim===!1)&&(A[A.length-1]+=" ",C++)),r.hard&&u[p]>e){let I=e-C,v=1+Math.floor((u[p]-I-1)/e);Math.floor((u[p]-1)/e)<v&&A.push(""),O6(A,h,e);continue}if(C+u[p]>e&&C>0&&u[p]>0){if(r.wordWrap===!1&&C<e){O6(A,h,e);continue}A.push("")}if(C+u[p]>e&&r.wordWrap===!1){O6(A,h,e);continue}A[A.length-1]+=h}r.trim!==!1&&(A=A.map(uEt)),o=A.join(` -`);for(let[p,h]of[...o].entries()){if(a+=h,M6.has(h)){let I=parseFloat(/\d[^m]*/.exec(o.slice(p,p+4)));n=I===lEt?null:I}let C=aEt.codes.get(Number(n));n&&C&&(o[p+1]===` -`?a+=GEe(C):h===` -`&&(a+=GEe(n)))}return a};YEe.exports=(t,e,r)=>String(t).normalize().replace(/\r\n/g,` -`).split(` -`).map(o=>AEt(o,e,r)).join(` -`)});var zEe=_((PKt,VEe)=>{"use strict";var KEe="[\uD800-\uDBFF][\uDC00-\uDFFF]",fEt=t=>t&&t.exact?new RegExp(`^${KEe}$`):new RegExp(KEe,"g");VEe.exports=fEt});var U6=_((SKt,$Ee)=>{"use strict";var pEt=F6(),hEt=zEe(),JEe=BI(),ZEe=["\x1B","\x9B"],Vk=t=>`${ZEe[0]}[${t}m`,XEe=(t,e,r)=>{let o=[];t=[...t];for(let a of t){let n=a;a.match(";")&&(a=a.split(";")[0][0]+"0");let u=JEe.codes.get(parseInt(a,10));if(u){let A=t.indexOf(u.toString());A>=0?t.splice(A,1):o.push(Vk(e?u:n))}else if(e){o.push(Vk(0));break}else o.push(Vk(n))}if(e&&(o=o.filter((a,n)=>o.indexOf(a)===n),r!==void 0)){let a=Vk(JEe.codes.get(parseInt(r,10)));o=o.reduce((n,u)=>u===a?[u,...n]:[...n,u],[])}return o.join("")};$Ee.exports=(t,e,r)=>{let o=[...t.normalize()],a=[];r=typeof r=="number"?r:o.length;let n=!1,u,A=0,p="";for(let[h,C]of o.entries()){let I=!1;if(ZEe.includes(C)){let v=/\d[^m]*/.exec(t.slice(h,h+18));u=v&&v.length>0?v[0]:void 0,A<r&&(n=!0,u!==void 0&&a.push(u))}else n&&C==="m"&&(n=!1,I=!0);if(!n&&!I&&++A,!hEt({exact:!0}).test(C)&&pEt(C.codePointAt())&&++A,A>e&&A<=r)p+=C;else if(A===e&&!n&&u!==void 0)p=XEe(a);else if(A>=r){p+=XEe(a,!0,u);break}}return p}});var tCe=_((xKt,eCe)=>{"use strict";var y0=U6(),gEt=Kk();function zk(t,e,r){if(t.charAt(e)===" ")return e;for(let o=1;o<=3;o++)if(r){if(t.charAt(e+o)===" ")return e+o}else if(t.charAt(e-o)===" ")return e-o;return e}eCe.exports=(t,e,r)=>{r={position:"end",preferTruncationOnSpace:!1,...r};let{position:o,space:a,preferTruncationOnSpace:n}=r,u="\u2026",A=1;if(typeof t!="string")throw new TypeError(`Expected \`input\` to be a string, got ${typeof t}`);if(typeof e!="number")throw new TypeError(`Expected \`columns\` to be a number, got ${typeof e}`);if(e<1)return"";if(e===1)return u;let p=gEt(t);if(p<=e)return t;if(o==="start"){if(n){let h=zk(t,p-e+1,!0);return u+y0(t,h,p).trim()}return a===!0&&(u+=" ",A=2),u+y0(t,p-e+A,p)}if(o==="middle"){a===!0&&(u=" "+u+" ",A=3);let h=Math.floor(e/2);if(n){let C=zk(t,h),I=zk(t,p-(e-h)+1,!0);return y0(t,0,C)+u+y0(t,I,p).trim()}return y0(t,0,h)+u+y0(t,p-(e-h)+A,p)}if(o==="end"){if(n){let h=zk(t,e-1);return y0(t,0,h)+u}return a===!0&&(u=" "+u,A=2),y0(t,0,e-A)+u}throw new Error(`Expected \`options.position\` to be either \`start\`, \`middle\` or \`end\`, got ${o}`)}});var H6=_(AB=>{"use strict";var rCe=AB&&AB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(AB,"__esModule",{value:!0});var dEt=rCe(WEe()),mEt=rCe(tCe()),_6={};AB.default=(t,e,r)=>{let o=t+String(e)+String(r);if(_6[o])return _6[o];let a=t;if(r==="wrap"&&(a=dEt.default(t,e,{trim:!1,hard:!0})),r.startsWith("truncate")){let n="end";r==="truncate-middle"&&(n="middle"),r==="truncate-start"&&(n="start"),a=mEt.default(t,e,{position:n})}return _6[o]=a,a}});var q6=_(j6=>{"use strict";Object.defineProperty(j6,"__esModule",{value:!0});var nCe=t=>{let e="";if(t.childNodes.length>0)for(let r of t.childNodes){let o="";r.nodeName==="#text"?o=r.nodeValue:((r.nodeName==="ink-text"||r.nodeName==="ink-virtual-text")&&(o=nCe(r)),o.length>0&&typeof r.internal_transform=="function"&&(o=r.internal_transform(o))),e+=o}return e};j6.default=nCe});var G6=_(pi=>{"use strict";var fB=pi&&pi.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(pi,"__esModule",{value:!0});pi.setTextNodeValue=pi.createTextNode=pi.setStyle=pi.setAttribute=pi.removeChildNode=pi.insertBeforeNode=pi.appendChildNode=pi.createNode=pi.TEXT_NAME=void 0;var yEt=fB(om()),iCe=fB(jEe()),EEt=fB(qEe()),CEt=fB(H6()),wEt=fB(q6());pi.TEXT_NAME="#text";pi.createNode=t=>{var e;let r={nodeName:t,style:{},attributes:{},childNodes:[],parentNode:null,yogaNode:t==="ink-virtual-text"?void 0:yEt.default.Node.create()};return t==="ink-text"&&((e=r.yogaNode)===null||e===void 0||e.setMeasureFunc(IEt.bind(null,r))),r};pi.appendChildNode=(t,e)=>{var r;e.parentNode&&pi.removeChildNode(e.parentNode,e),e.parentNode=t,t.childNodes.push(e),e.yogaNode&&((r=t.yogaNode)===null||r===void 0||r.insertChild(e.yogaNode,t.yogaNode.getChildCount())),(t.nodeName==="ink-text"||t.nodeName==="ink-virtual-text")&&Jk(t)};pi.insertBeforeNode=(t,e,r)=>{var o,a;e.parentNode&&pi.removeChildNode(e.parentNode,e),e.parentNode=t;let n=t.childNodes.indexOf(r);if(n>=0){t.childNodes.splice(n,0,e),e.yogaNode&&((o=t.yogaNode)===null||o===void 0||o.insertChild(e.yogaNode,n));return}t.childNodes.push(e),e.yogaNode&&((a=t.yogaNode)===null||a===void 0||a.insertChild(e.yogaNode,t.yogaNode.getChildCount())),(t.nodeName==="ink-text"||t.nodeName==="ink-virtual-text")&&Jk(t)};pi.removeChildNode=(t,e)=>{var r,o;e.yogaNode&&((o=(r=e.parentNode)===null||r===void 0?void 0:r.yogaNode)===null||o===void 0||o.removeChild(e.yogaNode)),e.parentNode=null;let a=t.childNodes.indexOf(e);a>=0&&t.childNodes.splice(a,1),(t.nodeName==="ink-text"||t.nodeName==="ink-virtual-text")&&Jk(t)};pi.setAttribute=(t,e,r)=>{t.attributes[e]=r};pi.setStyle=(t,e)=>{t.style=e,t.yogaNode&&EEt.default(t.yogaNode,e)};pi.createTextNode=t=>{let e={nodeName:"#text",nodeValue:t,yogaNode:void 0,parentNode:null,style:{}};return pi.setTextNodeValue(e,t),e};var IEt=function(t,e){var r,o;let a=t.nodeName==="#text"?t.nodeValue:wEt.default(t),n=iCe.default(a);if(n.width<=e||n.width>=1&&e>0&&e<1)return n;let u=(o=(r=t.style)===null||r===void 0?void 0:r.textWrap)!==null&&o!==void 0?o:"wrap",A=CEt.default(a,e,u);return iCe.default(A)},sCe=t=>{var e;if(!(!t||!t.parentNode))return(e=t.yogaNode)!==null&&e!==void 0?e:sCe(t.parentNode)},Jk=t=>{let e=sCe(t);e?.markDirty()};pi.setTextNodeValue=(t,e)=>{typeof e!="string"&&(e=String(e)),t.nodeValue=e,Jk(t)}});var uCe=_(pB=>{"use strict";var cCe=pB&&pB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(pB,"__esModule",{value:!0});var oCe=P6(),BEt=cCe(xEe()),aCe=cCe(om()),Oo=G6(),lCe=t=>{t?.unsetMeasureFunc(),t?.freeRecursive()};pB.default=BEt.default({schedulePassiveEffects:oCe.unstable_scheduleCallback,cancelPassiveEffects:oCe.unstable_cancelCallback,now:Date.now,getRootHostContext:()=>({isInsideText:!1}),prepareForCommit:()=>{},resetAfterCommit:t=>{if(t.isStaticDirty){t.isStaticDirty=!1,typeof t.onImmediateRender=="function"&&t.onImmediateRender();return}typeof t.onRender=="function"&&t.onRender()},getChildHostContext:(t,e)=>{let r=t.isInsideText,o=e==="ink-text"||e==="ink-virtual-text";return r===o?t:{isInsideText:o}},shouldSetTextContent:()=>!1,createInstance:(t,e,r,o)=>{if(o.isInsideText&&t==="ink-box")throw new Error("<Box> can\u2019t be nested inside <Text> component");let a=t==="ink-text"&&o.isInsideText?"ink-virtual-text":t,n=Oo.createNode(a);for(let[u,A]of Object.entries(e))u!=="children"&&(u==="style"?Oo.setStyle(n,A):u==="internal_transform"?n.internal_transform=A:u==="internal_static"?n.internal_static=!0:Oo.setAttribute(n,u,A));return n},createTextInstance:(t,e,r)=>{if(!r.isInsideText)throw new Error(`Text string "${t}" must be rendered inside <Text> component`);return Oo.createTextNode(t)},resetTextContent:()=>{},hideTextInstance:t=>{Oo.setTextNodeValue(t,"")},unhideTextInstance:(t,e)=>{Oo.setTextNodeValue(t,e)},getPublicInstance:t=>t,hideInstance:t=>{var e;(e=t.yogaNode)===null||e===void 0||e.setDisplay(aCe.default.DISPLAY_NONE)},unhideInstance:t=>{var e;(e=t.yogaNode)===null||e===void 0||e.setDisplay(aCe.default.DISPLAY_FLEX)},appendInitialChild:Oo.appendChildNode,appendChild:Oo.appendChildNode,insertBefore:Oo.insertBeforeNode,finalizeInitialChildren:(t,e,r,o)=>(t.internal_static&&(o.isStaticDirty=!0,o.staticNode=t),!1),supportsMutation:!0,appendChildToContainer:Oo.appendChildNode,insertInContainerBefore:Oo.insertBeforeNode,removeChildFromContainer:(t,e)=>{Oo.removeChildNode(t,e),lCe(e.yogaNode)},prepareUpdate:(t,e,r,o,a)=>{t.internal_static&&(a.isStaticDirty=!0);let n={},u=Object.keys(o);for(let A of u)if(o[A]!==r[A]){if(A==="style"&&typeof o.style=="object"&&typeof r.style=="object"){let h=o.style,C=r.style,I=Object.keys(h);for(let v of I){if(v==="borderStyle"||v==="borderColor"){if(typeof n.style!="object"){let b={};n.style=b}n.style.borderStyle=h.borderStyle,n.style.borderColor=h.borderColor}if(h[v]!==C[v]){if(typeof n.style!="object"){let b={};n.style=b}n.style[v]=h[v]}}continue}n[A]=o[A]}return n},commitUpdate:(t,e)=>{for(let[r,o]of Object.entries(e))r!=="children"&&(r==="style"?Oo.setStyle(t,o):r==="internal_transform"?t.internal_transform=o:r==="internal_static"?t.internal_static=!0:Oo.setAttribute(t,r,o))},commitTextUpdate:(t,e,r)=>{Oo.setTextNodeValue(t,r)},removeChild:(t,e)=>{Oo.removeChildNode(t,e),lCe(e.yogaNode)}})});var fCe=_((RKt,ACe)=>{"use strict";ACe.exports=(t,e=1,r)=>{if(r={indent:" ",includeEmptyLines:!1,...r},typeof t!="string")throw new TypeError(`Expected \`input\` to be a \`string\`, got \`${typeof t}\``);if(typeof e!="number")throw new TypeError(`Expected \`count\` to be a \`number\`, got \`${typeof e}\``);if(typeof r.indent!="string")throw new TypeError(`Expected \`options.indent\` to be a \`string\`, got \`${typeof r.indent}\``);if(e===0)return t;let o=r.includeEmptyLines?/^/gm:/^(?!\s*$)/gm;return t.replace(o,r.indent.repeat(e))}});var pCe=_(hB=>{"use strict";var vEt=hB&&hB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(hB,"__esModule",{value:!0});var Xk=vEt(om());hB.default=t=>t.getComputedWidth()-t.getComputedPadding(Xk.default.EDGE_LEFT)-t.getComputedPadding(Xk.default.EDGE_RIGHT)-t.getComputedBorder(Xk.default.EDGE_LEFT)-t.getComputedBorder(Xk.default.EDGE_RIGHT)});var hCe=_((LKt,DEt)=>{DEt.exports={single:{topLeft:"\u250C",topRight:"\u2510",bottomRight:"\u2518",bottomLeft:"\u2514",vertical:"\u2502",horizontal:"\u2500"},double:{topLeft:"\u2554",topRight:"\u2557",bottomRight:"\u255D",bottomLeft:"\u255A",vertical:"\u2551",horizontal:"\u2550"},round:{topLeft:"\u256D",topRight:"\u256E",bottomRight:"\u256F",bottomLeft:"\u2570",vertical:"\u2502",horizontal:"\u2500"},bold:{topLeft:"\u250F",topRight:"\u2513",bottomRight:"\u251B",bottomLeft:"\u2517",vertical:"\u2503",horizontal:"\u2501"},singleDouble:{topLeft:"\u2553",topRight:"\u2556",bottomRight:"\u255C",bottomLeft:"\u2559",vertical:"\u2551",horizontal:"\u2500"},doubleSingle:{topLeft:"\u2552",topRight:"\u2555",bottomRight:"\u255B",bottomLeft:"\u2558",vertical:"\u2502",horizontal:"\u2550"},classic:{topLeft:"+",topRight:"+",bottomRight:"+",bottomLeft:"+",vertical:"|",horizontal:"-"}}});var dCe=_((NKt,Y6)=>{"use strict";var gCe=hCe();Y6.exports=gCe;Y6.exports.default=gCe});var yCe=_((OKt,mCe)=>{"use strict";var PEt=(t,e,r)=>{let o=t.indexOf(e);if(o===-1)return t;let a=e.length,n=0,u="";do u+=t.substr(n,o-n)+e+r,n=o+a,o=t.indexOf(e,n);while(o!==-1);return u+=t.substr(n),u},SEt=(t,e,r,o)=>{let a=0,n="";do{let u=t[o-1]==="\r";n+=t.substr(a,(u?o-1:o)-a)+e+(u?`\r -`:` -`)+r,a=o+1,o=t.indexOf(` -`,a)}while(o!==-1);return n+=t.substr(a),n};mCe.exports={stringReplaceAll:PEt,stringEncaseCRLFWithFirstIndex:SEt}});var BCe=_((MKt,ICe)=>{"use strict";var xEt=/(?:\\(u(?:[a-f\d]{4}|\{[a-f\d]{1,6}\})|x[a-f\d]{2}|.))|(?:\{(~)?(\w+(?:\([^)]*\))?(?:\.\w+(?:\([^)]*\))?)*)(?:[ \t]|(?=\r?\n)))|(\})|((?:.|[\r\n\f])+?)/gi,ECe=/(?:^|\.)(\w+)(?:\(([^)]*)\))?/g,bEt=/^(['"])((?:\\.|(?!\1)[^\\])*)\1$/,kEt=/\\(u(?:[a-f\d]{4}|{[a-f\d]{1,6}})|x[a-f\d]{2}|.)|([^\\])/gi,QEt=new Map([["n",` -`],["r","\r"],["t"," "],["b","\b"],["f","\f"],["v","\v"],["0","\0"],["\\","\\"],["e","\x1B"],["a","\x07"]]);function wCe(t){let e=t[0]==="u",r=t[1]==="{";return e&&!r&&t.length===5||t[0]==="x"&&t.length===3?String.fromCharCode(parseInt(t.slice(1),16)):e&&r?String.fromCodePoint(parseInt(t.slice(2,-1),16)):QEt.get(t)||t}function FEt(t,e){let r=[],o=e.trim().split(/\s*,\s*/g),a;for(let n of o){let u=Number(n);if(!Number.isNaN(u))r.push(u);else if(a=n.match(bEt))r.push(a[2].replace(kEt,(A,p,h)=>p?wCe(p):h));else throw new Error(`Invalid Chalk template style argument: ${n} (in style '${t}')`)}return r}function REt(t){ECe.lastIndex=0;let e=[],r;for(;(r=ECe.exec(t))!==null;){let o=r[1];if(r[2]){let a=FEt(o,r[2]);e.push([o].concat(a))}else e.push([o])}return e}function CCe(t,e){let r={};for(let a of e)for(let n of a.styles)r[n[0]]=a.inverse?null:n.slice(1);let o=t;for(let[a,n]of Object.entries(r))if(!!Array.isArray(n)){if(!(a in o))throw new Error(`Unknown Chalk style: ${a}`);o=n.length>0?o[a](...n):o[a]}return o}ICe.exports=(t,e)=>{let r=[],o=[],a=[];if(e.replace(xEt,(n,u,A,p,h,C)=>{if(u)a.push(wCe(u));else if(p){let I=a.join("");a=[],o.push(r.length===0?I:CCe(t,r)(I)),r.push({inverse:A,styles:REt(p)})}else if(h){if(r.length===0)throw new Error("Found extraneous } in Chalk template literal");o.push(CCe(t,r)(a.join(""))),a=[],r.pop()}else a.push(C)}),o.push(a.join("")),r.length>0){let n=`Chalk template literal is missing ${r.length} closing bracket${r.length===1?"":"s"} (\`}\`)`;throw new Error(n)}return o.join("")}});var rQ=_((UKt,bCe)=>{"use strict";var gB=BI(),{stdout:K6,stderr:V6}=dL(),{stringReplaceAll:TEt,stringEncaseCRLFWithFirstIndex:LEt}=yCe(),{isArray:Zk}=Array,DCe=["ansi","ansi","ansi256","ansi16m"],_C=Object.create(null),NEt=(t,e={})=>{if(e.level&&!(Number.isInteger(e.level)&&e.level>=0&&e.level<=3))throw new Error("The `level` option should be an integer from 0 to 3");let r=K6?K6.level:0;t.level=e.level===void 0?r:e.level},z6=class{constructor(e){return PCe(e)}},PCe=t=>{let e={};return NEt(e,t),e.template=(...r)=>xCe(e.template,...r),Object.setPrototypeOf(e,$k.prototype),Object.setPrototypeOf(e.template,e),e.template.constructor=()=>{throw new Error("`chalk.constructor()` is deprecated. Use `new chalk.Instance()` instead.")},e.template.Instance=z6,e.template};function $k(t){return PCe(t)}for(let[t,e]of Object.entries(gB))_C[t]={get(){let r=eQ(this,J6(e.open,e.close,this._styler),this._isEmpty);return Object.defineProperty(this,t,{value:r}),r}};_C.visible={get(){let t=eQ(this,this._styler,!0);return Object.defineProperty(this,"visible",{value:t}),t}};var SCe=["rgb","hex","keyword","hsl","hsv","hwb","ansi","ansi256"];for(let t of SCe)_C[t]={get(){let{level:e}=this;return function(...r){let o=J6(gB.color[DCe[e]][t](...r),gB.color.close,this._styler);return eQ(this,o,this._isEmpty)}}};for(let t of SCe){let e="bg"+t[0].toUpperCase()+t.slice(1);_C[e]={get(){let{level:r}=this;return function(...o){let a=J6(gB.bgColor[DCe[r]][t](...o),gB.bgColor.close,this._styler);return eQ(this,a,this._isEmpty)}}}}var OEt=Object.defineProperties(()=>{},{..._C,level:{enumerable:!0,get(){return this._generator.level},set(t){this._generator.level=t}}}),J6=(t,e,r)=>{let o,a;return r===void 0?(o=t,a=e):(o=r.openAll+t,a=e+r.closeAll),{open:t,close:e,openAll:o,closeAll:a,parent:r}},eQ=(t,e,r)=>{let o=(...a)=>Zk(a[0])&&Zk(a[0].raw)?vCe(o,xCe(o,...a)):vCe(o,a.length===1?""+a[0]:a.join(" "));return Object.setPrototypeOf(o,OEt),o._generator=t,o._styler=e,o._isEmpty=r,o},vCe=(t,e)=>{if(t.level<=0||!e)return t._isEmpty?"":e;let r=t._styler;if(r===void 0)return e;let{openAll:o,closeAll:a}=r;if(e.indexOf("\x1B")!==-1)for(;r!==void 0;)e=TEt(e,r.close,r.open),r=r.parent;let n=e.indexOf(` -`);return n!==-1&&(e=LEt(e,a,o,n)),o+e+a},W6,xCe=(t,...e)=>{let[r]=e;if(!Zk(r)||!Zk(r.raw))return e.join(" ");let o=e.slice(1),a=[r.raw[0]];for(let n=1;n<r.length;n++)a.push(String(o[n-1]).replace(/[{}\\]/g,"\\$&"),String(r.raw[n]));return W6===void 0&&(W6=BCe()),W6(t,a.join(""))};Object.defineProperties($k.prototype,_C);var tQ=$k();tQ.supportsColor=K6;tQ.stderr=$k({level:V6?V6.level:0});tQ.stderr.supportsColor=V6;bCe.exports=tQ});var X6=_(mB=>{"use strict";var MEt=mB&&mB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(mB,"__esModule",{value:!0});var dB=MEt(rQ()),UEt=/^(rgb|hsl|hsv|hwb)\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/,_Et=/^(ansi|ansi256)\(\s?(\d+)\s?\)$/,nQ=(t,e)=>e==="foreground"?t:"bg"+t[0].toUpperCase()+t.slice(1);mB.default=(t,e,r)=>{if(!e)return t;if(e in dB.default){let a=nQ(e,r);return dB.default[a](t)}if(e.startsWith("#")){let a=nQ("hex",r);return dB.default[a](e)(t)}if(e.startsWith("ansi")){let a=_Et.exec(e);if(!a)return t;let n=nQ(a[1],r),u=Number(a[2]);return dB.default[n](u)(t)}if(e.startsWith("rgb")||e.startsWith("hsl")||e.startsWith("hsv")||e.startsWith("hwb")){let a=UEt.exec(e);if(!a)return t;let n=nQ(a[1],r),u=Number(a[2]),A=Number(a[3]),p=Number(a[4]);return dB.default[n](u,A,p)(t)}return t}});var QCe=_(yB=>{"use strict";var kCe=yB&&yB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(yB,"__esModule",{value:!0});var HEt=kCe(dCe()),Z6=kCe(X6());yB.default=(t,e,r,o)=>{if(typeof r.style.borderStyle=="string"){let a=r.yogaNode.getComputedWidth(),n=r.yogaNode.getComputedHeight(),u=r.style.borderColor,A=HEt.default[r.style.borderStyle],p=Z6.default(A.topLeft+A.horizontal.repeat(a-2)+A.topRight,u,"foreground"),h=(Z6.default(A.vertical,u,"foreground")+` -`).repeat(n-2),C=Z6.default(A.bottomLeft+A.horizontal.repeat(a-2)+A.bottomRight,u,"foreground");o.write(t,e,p,{transformers:[]}),o.write(t,e+1,h,{transformers:[]}),o.write(t+a-1,e+1,h,{transformers:[]}),o.write(t,e+n-1,C,{transformers:[]})}}});var RCe=_(EB=>{"use strict";var am=EB&&EB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(EB,"__esModule",{value:!0});var jEt=am(om()),qEt=am(L6()),GEt=am(fCe()),YEt=am(H6()),WEt=am(pCe()),KEt=am(q6()),VEt=am(QCe()),zEt=(t,e)=>{var r;let o=(r=t.childNodes[0])===null||r===void 0?void 0:r.yogaNode;if(o){let a=o.getComputedLeft(),n=o.getComputedTop();e=` -`.repeat(n)+GEt.default(e,a)}return e},FCe=(t,e,r)=>{var o;let{offsetX:a=0,offsetY:n=0,transformers:u=[],skipStaticElements:A}=r;if(A&&t.internal_static)return;let{yogaNode:p}=t;if(p){if(p.getDisplay()===jEt.default.DISPLAY_NONE)return;let h=a+p.getComputedLeft(),C=n+p.getComputedTop(),I=u;if(typeof t.internal_transform=="function"&&(I=[t.internal_transform,...u]),t.nodeName==="ink-text"){let v=KEt.default(t);if(v.length>0){let b=qEt.default(v),E=WEt.default(p);if(b>E){let F=(o=t.style.textWrap)!==null&&o!==void 0?o:"wrap";v=YEt.default(v,E,F)}v=zEt(t,v),e.write(h,C,v,{transformers:I})}return}if(t.nodeName==="ink-box"&&VEt.default(h,C,t,e),t.nodeName==="ink-root"||t.nodeName==="ink-box")for(let v of t.childNodes)FCe(v,e,{offsetX:h,offsetY:C,transformers:I,skipStaticElements:A})}};EB.default=FCe});var LCe=_((qKt,TCe)=>{"use strict";TCe.exports=t=>{t=Object.assign({onlyFirst:!1},t);let e=["[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)","(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"].join("|");return new RegExp(e,t.onlyFirst?void 0:"g")}});var OCe=_((GKt,$6)=>{"use strict";var JEt=LCe(),NCe=t=>typeof t=="string"?t.replace(JEt(),""):t;$6.exports=NCe;$6.exports.default=NCe});var _Ce=_((YKt,UCe)=>{"use strict";var MCe="[\uD800-\uDBFF][\uDC00-\uDFFF]";UCe.exports=t=>t&&t.exact?new RegExp(`^${MCe}$`):new RegExp(MCe,"g")});var jCe=_((WKt,ej)=>{"use strict";var XEt=OCe(),ZEt=_Ce(),HCe=t=>XEt(t).replace(ZEt()," ").length;ej.exports=HCe;ej.exports.default=HCe});var YCe=_(CB=>{"use strict";var GCe=CB&&CB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(CB,"__esModule",{value:!0});var qCe=GCe(U6()),$Et=GCe(jCe()),tj=class{constructor(e){this.writes=[];let{width:r,height:o}=e;this.width=r,this.height=o}write(e,r,o,a){let{transformers:n}=a;!o||this.writes.push({x:e,y:r,text:o,transformers:n})}get(){let e=[];for(let o=0;o<this.height;o++)e.push(" ".repeat(this.width));for(let o of this.writes){let{x:a,y:n,text:u,transformers:A}=o,p=u.split(` -`),h=0;for(let C of p){let I=e[n+h];if(!I)continue;let v=$Et.default(C);for(let b of A)C=b(C);e[n+h]=qCe.default(I,0,a)+C+qCe.default(I,a+v),h++}}return{output:e.map(o=>o.trimRight()).join(` -`),height:e.length}}};CB.default=tj});var VCe=_(wB=>{"use strict";var rj=wB&&wB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(wB,"__esModule",{value:!0});var eCt=rj(om()),WCe=rj(RCe()),KCe=rj(YCe());wB.default=(t,e)=>{var r;if(t.yogaNode.setWidth(e),t.yogaNode){t.yogaNode.calculateLayout(void 0,void 0,eCt.default.DIRECTION_LTR);let o=new KCe.default({width:t.yogaNode.getComputedWidth(),height:t.yogaNode.getComputedHeight()});WCe.default(t,o,{skipStaticElements:!0});let a;!((r=t.staticNode)===null||r===void 0)&&r.yogaNode&&(a=new KCe.default({width:t.staticNode.yogaNode.getComputedWidth(),height:t.staticNode.yogaNode.getComputedHeight()}),WCe.default(t.staticNode,a,{skipStaticElements:!1}));let{output:n,height:u}=o.get();return{output:n,outputHeight:u,staticOutput:a?`${a.get().output} -`:""}}return{output:"",outputHeight:0,staticOutput:""}}});var ZCe=_((zKt,XCe)=>{"use strict";var zCe=Be("stream"),JCe=["assert","count","countReset","debug","dir","dirxml","error","group","groupCollapsed","groupEnd","info","log","table","time","timeEnd","timeLog","trace","warn"],nj={},tCt=t=>{let e=new zCe.PassThrough,r=new zCe.PassThrough;e.write=a=>t("stdout",a),r.write=a=>t("stderr",a);let o=new console.Console(e,r);for(let a of JCe)nj[a]=console[a],console[a]=o[a];return()=>{for(let a of JCe)console[a]=nj[a];nj={}}};XCe.exports=tCt});var sj=_(ij=>{"use strict";Object.defineProperty(ij,"__esModule",{value:!0});ij.default=new WeakMap});var aj=_(oj=>{"use strict";Object.defineProperty(oj,"__esModule",{value:!0});var rCt=sn(),$Ce=rCt.createContext({exit:()=>{}});$Ce.displayName="InternalAppContext";oj.default=$Ce});var cj=_(lj=>{"use strict";Object.defineProperty(lj,"__esModule",{value:!0});var nCt=sn(),ewe=nCt.createContext({stdin:void 0,setRawMode:()=>{},isRawModeSupported:!1,internal_exitOnCtrlC:!0});ewe.displayName="InternalStdinContext";lj.default=ewe});var Aj=_(uj=>{"use strict";Object.defineProperty(uj,"__esModule",{value:!0});var iCt=sn(),twe=iCt.createContext({stdout:void 0,write:()=>{}});twe.displayName="InternalStdoutContext";uj.default=twe});var pj=_(fj=>{"use strict";Object.defineProperty(fj,"__esModule",{value:!0});var sCt=sn(),rwe=sCt.createContext({stderr:void 0,write:()=>{}});rwe.displayName="InternalStderrContext";fj.default=rwe});var iQ=_(hj=>{"use strict";Object.defineProperty(hj,"__esModule",{value:!0});var oCt=sn(),nwe=oCt.createContext({activeId:void 0,add:()=>{},remove:()=>{},activate:()=>{},deactivate:()=>{},enableFocus:()=>{},disableFocus:()=>{},focusNext:()=>{},focusPrevious:()=>{}});nwe.displayName="InternalFocusContext";hj.default=nwe});var swe=_((rVt,iwe)=>{"use strict";var aCt=/[|\\{}()[\]^$+*?.-]/g;iwe.exports=t=>{if(typeof t!="string")throw new TypeError("Expected a string");return t.replace(aCt,"\\$&")}});var cwe=_((nVt,lwe)=>{"use strict";var lCt=swe(),cCt=typeof process=="object"&&process&&typeof process.cwd=="function"?process.cwd():".",awe=[].concat(Be("module").builtinModules,"bootstrap_node","node").map(t=>new RegExp(`(?:\\((?:node:)?${t}(?:\\.js)?:\\d+:\\d+\\)$|^\\s*at (?:node:)?${t}(?:\\.js)?:\\d+:\\d+$)`));awe.push(/\((?:node:)?internal\/[^:]+:\d+:\d+\)$/,/\s*at (?:node:)?internal\/[^:]+:\d+:\d+$/,/\/\.node-spawn-wrap-\w+-\w+\/node:\d+:\d+\)?$/);var IB=class{constructor(e){e={ignoredPackages:[],...e},"internals"in e||(e.internals=IB.nodeInternals()),"cwd"in e||(e.cwd=cCt),this._cwd=e.cwd.replace(/\\/g,"/"),this._internals=[].concat(e.internals,uCt(e.ignoredPackages)),this._wrapCallSite=e.wrapCallSite||!1}static nodeInternals(){return[...awe]}clean(e,r=0){r=" ".repeat(r),Array.isArray(e)||(e=e.split(` -`)),!/^\s*at /.test(e[0])&&/^\s*at /.test(e[1])&&(e=e.slice(1));let o=!1,a=null,n=[];return e.forEach(u=>{if(u=u.replace(/\\/g,"/"),this._internals.some(p=>p.test(u)))return;let A=/^\s*at /.test(u);o?u=u.trimEnd().replace(/^(\s+)at /,"$1"):(u=u.trim(),A&&(u=u.slice(3))),u=u.replace(`${this._cwd}/`,""),u&&(A?(a&&(n.push(a),a=null),n.push(u)):(o=!0,a=u))}),n.map(u=>`${r}${u} -`).join("")}captureString(e,r=this.captureString){typeof e=="function"&&(r=e,e=1/0);let{stackTraceLimit:o}=Error;e&&(Error.stackTraceLimit=e);let a={};Error.captureStackTrace(a,r);let{stack:n}=a;return Error.stackTraceLimit=o,this.clean(n)}capture(e,r=this.capture){typeof e=="function"&&(r=e,e=1/0);let{prepareStackTrace:o,stackTraceLimit:a}=Error;Error.prepareStackTrace=(A,p)=>this._wrapCallSite?p.map(this._wrapCallSite):p,e&&(Error.stackTraceLimit=e);let n={};Error.captureStackTrace(n,r);let{stack:u}=n;return Object.assign(Error,{prepareStackTrace:o,stackTraceLimit:a}),u}at(e=this.at){let[r]=this.capture(1,e);if(!r)return{};let o={line:r.getLineNumber(),column:r.getColumnNumber()};owe(o,r.getFileName(),this._cwd),r.isConstructor()&&(o.constructor=!0),r.isEval()&&(o.evalOrigin=r.getEvalOrigin()),r.isNative()&&(o.native=!0);let a;try{a=r.getTypeName()}catch{}a&&a!=="Object"&&a!=="[object Object]"&&(o.type=a);let n=r.getFunctionName();n&&(o.function=n);let u=r.getMethodName();return u&&n!==u&&(o.method=u),o}parseLine(e){let r=e&&e.match(ACt);if(!r)return null;let o=r[1]==="new",a=r[2],n=r[3],u=r[4],A=Number(r[5]),p=Number(r[6]),h=r[7],C=r[8],I=r[9],v=r[10]==="native",b=r[11]===")",E,F={};if(C&&(F.line=Number(C)),I&&(F.column=Number(I)),b&&h){let N=0;for(let U=h.length-1;U>0;U--)if(h.charAt(U)===")")N++;else if(h.charAt(U)==="("&&h.charAt(U-1)===" "&&(N--,N===-1&&h.charAt(U-1)===" ")){let z=h.slice(0,U-1);h=h.slice(U+1),a+=` (${z}`;break}}if(a){let N=a.match(fCt);N&&(a=N[1],E=N[2])}return owe(F,h,this._cwd),o&&(F.constructor=!0),n&&(F.evalOrigin=n,F.evalLine=A,F.evalColumn=p,F.evalFile=u&&u.replace(/\\/g,"/")),v&&(F.native=!0),a&&(F.function=a),E&&a!==E&&(F.method=E),F}};function owe(t,e,r){e&&(e=e.replace(/\\/g,"/"),e.startsWith(`${r}/`)&&(e=e.slice(r.length+1)),t.file=e)}function uCt(t){if(t.length===0)return[];let e=t.map(r=>lCt(r));return new RegExp(`[/\\\\]node_modules[/\\\\](?:${e.join("|")})[/\\\\][^:]+:\\d+:\\d+`)}var ACt=new RegExp("^(?:\\s*at )?(?:(new) )?(?:(.*?) \\()?(?:eval at ([^ ]+) \\((.+?):(\\d+):(\\d+)\\), )?(?:(.+?):(\\d+):(\\d+)|(native))(\\)?)$"),fCt=/^(.*?) \[as (.*?)\]$/;lwe.exports=IB});var Awe=_((iVt,uwe)=>{"use strict";uwe.exports=(t,e)=>t.replace(/^\t+/gm,r=>" ".repeat(r.length*(e||2)))});var pwe=_((sVt,fwe)=>{"use strict";var pCt=Awe(),hCt=(t,e)=>{let r=[],o=t-e,a=t+e;for(let n=o;n<=a;n++)r.push(n);return r};fwe.exports=(t,e,r)=>{if(typeof t!="string")throw new TypeError("Source code is missing.");if(!e||e<1)throw new TypeError("Line number must start from `1`.");if(t=pCt(t).split(/\r?\n/),!(e>t.length))return r={around:3,...r},hCt(e,r.around).filter(o=>t[o-1]!==void 0).map(o=>({line:o,value:t[o-1]}))}});var sQ=_(ru=>{"use strict";var gCt=ru&&ru.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),dCt=ru&&ru.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),mCt=ru&&ru.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(t!=null)for(var r in t)r!=="default"&&Object.hasOwnProperty.call(t,r)&&gCt(e,t,r);return dCt(e,t),e},yCt=ru&&ru.__rest||function(t,e){var r={};for(var o in t)Object.prototype.hasOwnProperty.call(t,o)&&e.indexOf(o)<0&&(r[o]=t[o]);if(t!=null&&typeof Object.getOwnPropertySymbols=="function")for(var a=0,o=Object.getOwnPropertySymbols(t);a<o.length;a++)e.indexOf(o[a])<0&&Object.prototype.propertyIsEnumerable.call(t,o[a])&&(r[o[a]]=t[o[a]]);return r};Object.defineProperty(ru,"__esModule",{value:!0});var hwe=mCt(sn()),gj=hwe.forwardRef((t,e)=>{var{children:r}=t,o=yCt(t,["children"]);let a=Object.assign(Object.assign({},o),{marginLeft:o.marginLeft||o.marginX||o.margin||0,marginRight:o.marginRight||o.marginX||o.margin||0,marginTop:o.marginTop||o.marginY||o.margin||0,marginBottom:o.marginBottom||o.marginY||o.margin||0,paddingLeft:o.paddingLeft||o.paddingX||o.padding||0,paddingRight:o.paddingRight||o.paddingX||o.padding||0,paddingTop:o.paddingTop||o.paddingY||o.padding||0,paddingBottom:o.paddingBottom||o.paddingY||o.padding||0});return hwe.default.createElement("ink-box",{ref:e,style:a},r)});gj.displayName="Box";gj.defaultProps={flexDirection:"row",flexGrow:0,flexShrink:1};ru.default=gj});var yj=_(BB=>{"use strict";var dj=BB&&BB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(BB,"__esModule",{value:!0});var ECt=dj(sn()),HC=dj(rQ()),gwe=dj(X6()),mj=({color:t,backgroundColor:e,dimColor:r,bold:o,italic:a,underline:n,strikethrough:u,inverse:A,wrap:p,children:h})=>{if(h==null)return null;let C=I=>(r&&(I=HC.default.dim(I)),t&&(I=gwe.default(I,t,"foreground")),e&&(I=gwe.default(I,e,"background")),o&&(I=HC.default.bold(I)),a&&(I=HC.default.italic(I)),n&&(I=HC.default.underline(I)),u&&(I=HC.default.strikethrough(I)),A&&(I=HC.default.inverse(I)),I);return ECt.default.createElement("ink-text",{style:{flexGrow:0,flexShrink:1,flexDirection:"row",textWrap:p},internal_transform:C},h)};mj.displayName="Text";mj.defaultProps={dimColor:!1,bold:!1,italic:!1,underline:!1,strikethrough:!1,wrap:"wrap"};BB.default=mj});var Ewe=_(nu=>{"use strict";var CCt=nu&&nu.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),wCt=nu&&nu.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),ICt=nu&&nu.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(t!=null)for(var r in t)r!=="default"&&Object.hasOwnProperty.call(t,r)&&CCt(e,t,r);return wCt(e,t),e},vB=nu&&nu.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(nu,"__esModule",{value:!0});var dwe=ICt(Be("fs")),fs=vB(sn()),mwe=vB(cwe()),BCt=vB(pwe()),Jf=vB(sQ()),hA=vB(yj()),ywe=new mwe.default({cwd:process.cwd(),internals:mwe.default.nodeInternals()}),vCt=({error:t})=>{let e=t.stack?t.stack.split(` -`).slice(1):void 0,r=e?ywe.parseLine(e[0]):void 0,o,a=0;if(r?.file&&r?.line&&dwe.existsSync(r.file)){let n=dwe.readFileSync(r.file,"utf8");if(o=BCt.default(n,r.line),o)for(let{line:u}of o)a=Math.max(a,String(u).length)}return fs.default.createElement(Jf.default,{flexDirection:"column",padding:1},fs.default.createElement(Jf.default,null,fs.default.createElement(hA.default,{backgroundColor:"red",color:"white"}," ","ERROR"," "),fs.default.createElement(hA.default,null," ",t.message)),r&&fs.default.createElement(Jf.default,{marginTop:1},fs.default.createElement(hA.default,{dimColor:!0},r.file,":",r.line,":",r.column)),r&&o&&fs.default.createElement(Jf.default,{marginTop:1,flexDirection:"column"},o.map(({line:n,value:u})=>fs.default.createElement(Jf.default,{key:n},fs.default.createElement(Jf.default,{width:a+1},fs.default.createElement(hA.default,{dimColor:n!==r.line,backgroundColor:n===r.line?"red":void 0,color:n===r.line?"white":void 0},String(n).padStart(a," "),":")),fs.default.createElement(hA.default,{key:n,backgroundColor:n===r.line?"red":void 0,color:n===r.line?"white":void 0}," "+u)))),t.stack&&fs.default.createElement(Jf.default,{marginTop:1,flexDirection:"column"},t.stack.split(` -`).slice(1).map(n=>{let u=ywe.parseLine(n);return u?fs.default.createElement(Jf.default,{key:n},fs.default.createElement(hA.default,{dimColor:!0},"- "),fs.default.createElement(hA.default,{dimColor:!0,bold:!0},u.function),fs.default.createElement(hA.default,{dimColor:!0,color:"gray"}," ","(",u.file,":",u.line,":",u.column,")")):fs.default.createElement(Jf.default,{key:n},fs.default.createElement(hA.default,{dimColor:!0},"- "),fs.default.createElement(hA.default,{dimColor:!0,bold:!0},n))})))};nu.default=vCt});var wwe=_(iu=>{"use strict";var DCt=iu&&iu.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),PCt=iu&&iu.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),SCt=iu&&iu.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(t!=null)for(var r in t)r!=="default"&&Object.hasOwnProperty.call(t,r)&&DCt(e,t,r);return PCt(e,t),e},cm=iu&&iu.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(iu,"__esModule",{value:!0});var lm=SCt(sn()),Cwe=cm(g6()),xCt=cm(aj()),bCt=cm(cj()),kCt=cm(Aj()),QCt=cm(pj()),FCt=cm(iQ()),RCt=cm(Ewe()),TCt=" ",LCt="\x1B[Z",NCt="\x1B",oQ=class extends lm.PureComponent{constructor(){super(...arguments),this.state={isFocusEnabled:!0,activeFocusId:void 0,focusables:[],error:void 0},this.rawModeEnabledCount=0,this.handleSetRawMode=e=>{let{stdin:r}=this.props;if(!this.isRawModeSupported())throw r===process.stdin?new Error(`Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default. -Read about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported`):new Error(`Raw mode is not supported on the stdin provided to Ink. -Read about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported`);if(r.setEncoding("utf8"),e){this.rawModeEnabledCount===0&&(r.addListener("data",this.handleInput),r.resume(),r.setRawMode(!0)),this.rawModeEnabledCount++;return}--this.rawModeEnabledCount===0&&(r.setRawMode(!1),r.removeListener("data",this.handleInput),r.pause())},this.handleInput=e=>{e===""&&this.props.exitOnCtrlC&&this.handleExit(),e===NCt&&this.state.activeFocusId&&this.setState({activeFocusId:void 0}),this.state.isFocusEnabled&&this.state.focusables.length>0&&(e===TCt&&this.focusNext(),e===LCt&&this.focusPrevious())},this.handleExit=e=>{this.isRawModeSupported()&&this.handleSetRawMode(!1),this.props.onExit(e)},this.enableFocus=()=>{this.setState({isFocusEnabled:!0})},this.disableFocus=()=>{this.setState({isFocusEnabled:!1})},this.focusNext=()=>{this.setState(e=>{let r=e.focusables[0].id;return{activeFocusId:this.findNextFocusable(e)||r}})},this.focusPrevious=()=>{this.setState(e=>{let r=e.focusables[e.focusables.length-1].id;return{activeFocusId:this.findPreviousFocusable(e)||r}})},this.addFocusable=(e,{autoFocus:r})=>{this.setState(o=>{let a=o.activeFocusId;return!a&&r&&(a=e),{activeFocusId:a,focusables:[...o.focusables,{id:e,isActive:!0}]}})},this.removeFocusable=e=>{this.setState(r=>({activeFocusId:r.activeFocusId===e?void 0:r.activeFocusId,focusables:r.focusables.filter(o=>o.id!==e)}))},this.activateFocusable=e=>{this.setState(r=>({focusables:r.focusables.map(o=>o.id!==e?o:{id:e,isActive:!0})}))},this.deactivateFocusable=e=>{this.setState(r=>({activeFocusId:r.activeFocusId===e?void 0:r.activeFocusId,focusables:r.focusables.map(o=>o.id!==e?o:{id:e,isActive:!1})}))},this.findNextFocusable=e=>{let r=e.focusables.findIndex(o=>o.id===e.activeFocusId);for(let o=r+1;o<e.focusables.length;o++)if(e.focusables[o].isActive)return e.focusables[o].id},this.findPreviousFocusable=e=>{let r=e.focusables.findIndex(o=>o.id===e.activeFocusId);for(let o=r-1;o>=0;o--)if(e.focusables[o].isActive)return e.focusables[o].id}}static getDerivedStateFromError(e){return{error:e}}isRawModeSupported(){return this.props.stdin.isTTY}render(){return lm.default.createElement(xCt.default.Provider,{value:{exit:this.handleExit}},lm.default.createElement(bCt.default.Provider,{value:{stdin:this.props.stdin,setRawMode:this.handleSetRawMode,isRawModeSupported:this.isRawModeSupported(),internal_exitOnCtrlC:this.props.exitOnCtrlC}},lm.default.createElement(kCt.default.Provider,{value:{stdout:this.props.stdout,write:this.props.writeToStdout}},lm.default.createElement(QCt.default.Provider,{value:{stderr:this.props.stderr,write:this.props.writeToStderr}},lm.default.createElement(FCt.default.Provider,{value:{activeId:this.state.activeFocusId,add:this.addFocusable,remove:this.removeFocusable,activate:this.activateFocusable,deactivate:this.deactivateFocusable,enableFocus:this.enableFocus,disableFocus:this.disableFocus,focusNext:this.focusNext,focusPrevious:this.focusPrevious}},this.state.error?lm.default.createElement(RCt.default,{error:this.state.error}):this.props.children)))))}componentDidMount(){Cwe.default.hide(this.props.stdout)}componentWillUnmount(){Cwe.default.show(this.props.stdout),this.isRawModeSupported()&&this.handleSetRawMode(!1)}componentDidCatch(e){this.handleExit(e)}};iu.default=oQ;oQ.displayName="InternalApp"});var vwe=_(su=>{"use strict";var OCt=su&&su.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),MCt=su&&su.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),UCt=su&&su.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(t!=null)for(var r in t)r!=="default"&&Object.hasOwnProperty.call(t,r)&&OCt(e,t,r);return MCt(e,t),e},ou=su&&su.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(su,"__esModule",{value:!0});var _Ct=ou(sn()),Iwe=lM(),HCt=ou(cEe()),jCt=ou(u6()),qCt=ou(gEe()),GCt=ou(mEe()),Ej=ou(uCe()),YCt=ou(VCe()),WCt=ou(h6()),KCt=ou(ZCe()),VCt=UCt(G6()),zCt=ou(sj()),JCt=ou(wwe()),jC=process.env.CI==="false"?!1:qCt.default,Bwe=()=>{},Cj=class{constructor(e){this.resolveExitPromise=()=>{},this.rejectExitPromise=()=>{},this.unsubscribeExit=()=>{},this.onRender=()=>{if(this.isUnmounted)return;let{output:r,outputHeight:o,staticOutput:a}=YCt.default(this.rootNode,this.options.stdout.columns||80),n=a&&a!==` -`;if(this.options.debug){n&&(this.fullStaticOutput+=a),this.options.stdout.write(this.fullStaticOutput+r);return}if(jC){n&&this.options.stdout.write(a),this.lastOutput=r;return}if(n&&(this.fullStaticOutput+=a),o>=this.options.stdout.rows){this.options.stdout.write(jCt.default.clearTerminal+this.fullStaticOutput+r),this.lastOutput=r;return}n&&(this.log.clear(),this.options.stdout.write(a),this.log(r)),!n&&r!==this.lastOutput&&this.throttledLog(r),this.lastOutput=r},GCt.default(this),this.options=e,this.rootNode=VCt.createNode("ink-root"),this.rootNode.onRender=e.debug?this.onRender:Iwe(this.onRender,32,{leading:!0,trailing:!0}),this.rootNode.onImmediateRender=this.onRender,this.log=HCt.default.create(e.stdout),this.throttledLog=e.debug?this.log:Iwe(this.log,void 0,{leading:!0,trailing:!0}),this.isUnmounted=!1,this.lastOutput="",this.fullStaticOutput="",this.container=Ej.default.createContainer(this.rootNode,!1,!1),this.unsubscribeExit=WCt.default(this.unmount,{alwaysLast:!1}),e.patchConsole&&this.patchConsole(),jC||(e.stdout.on("resize",this.onRender),this.unsubscribeResize=()=>{e.stdout.off("resize",this.onRender)})}render(e){let r=_Ct.default.createElement(JCt.default,{stdin:this.options.stdin,stdout:this.options.stdout,stderr:this.options.stderr,writeToStdout:this.writeToStdout,writeToStderr:this.writeToStderr,exitOnCtrlC:this.options.exitOnCtrlC,onExit:this.unmount},e);Ej.default.updateContainer(r,this.container,null,Bwe)}writeToStdout(e){if(!this.isUnmounted){if(this.options.debug){this.options.stdout.write(e+this.fullStaticOutput+this.lastOutput);return}if(jC){this.options.stdout.write(e);return}this.log.clear(),this.options.stdout.write(e),this.log(this.lastOutput)}}writeToStderr(e){if(!this.isUnmounted){if(this.options.debug){this.options.stderr.write(e),this.options.stdout.write(this.fullStaticOutput+this.lastOutput);return}if(jC){this.options.stderr.write(e);return}this.log.clear(),this.options.stderr.write(e),this.log(this.lastOutput)}}unmount(e){this.isUnmounted||(this.onRender(),this.unsubscribeExit(),typeof this.restoreConsole=="function"&&this.restoreConsole(),typeof this.unsubscribeResize=="function"&&this.unsubscribeResize(),jC?this.options.stdout.write(this.lastOutput+` -`):this.options.debug||this.log.done(),this.isUnmounted=!0,Ej.default.updateContainer(null,this.container,null,Bwe),zCt.default.delete(this.options.stdout),e instanceof Error?this.rejectExitPromise(e):this.resolveExitPromise())}waitUntilExit(){return this.exitPromise||(this.exitPromise=new Promise((e,r)=>{this.resolveExitPromise=e,this.rejectExitPromise=r})),this.exitPromise}clear(){!jC&&!this.options.debug&&this.log.clear()}patchConsole(){this.options.debug||(this.restoreConsole=KCt.default((e,r)=>{e==="stdout"&&this.writeToStdout(r),e==="stderr"&&(r.startsWith("The above error occurred")||this.writeToStderr(r))}))}};su.default=Cj});var Pwe=_(DB=>{"use strict";var Dwe=DB&&DB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(DB,"__esModule",{value:!0});var XCt=Dwe(vwe()),aQ=Dwe(sj()),ZCt=Be("stream"),$Ct=(t,e)=>{let r=Object.assign({stdout:process.stdout,stdin:process.stdin,stderr:process.stderr,debug:!1,exitOnCtrlC:!0,patchConsole:!0},ewt(e)),o=twt(r.stdout,()=>new XCt.default(r));return o.render(t),{rerender:o.render,unmount:()=>o.unmount(),waitUntilExit:o.waitUntilExit,cleanup:()=>aQ.default.delete(r.stdout),clear:o.clear}};DB.default=$Ct;var ewt=(t={})=>t instanceof ZCt.Stream?{stdout:t,stdin:process.stdin}:t,twt=(t,e)=>{let r;return aQ.default.has(t)?r=aQ.default.get(t):(r=e(),aQ.default.set(t,r)),r}});var xwe=_(Xf=>{"use strict";var rwt=Xf&&Xf.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),nwt=Xf&&Xf.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),iwt=Xf&&Xf.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(t!=null)for(var r in t)r!=="default"&&Object.hasOwnProperty.call(t,r)&&rwt(e,t,r);return nwt(e,t),e};Object.defineProperty(Xf,"__esModule",{value:!0});var PB=iwt(sn()),Swe=t=>{let{items:e,children:r,style:o}=t,[a,n]=PB.useState(0),u=PB.useMemo(()=>e.slice(a),[e,a]);PB.useLayoutEffect(()=>{n(e.length)},[e.length]);let A=u.map((h,C)=>r(h,a+C)),p=PB.useMemo(()=>Object.assign({position:"absolute",flexDirection:"column"},o),[o]);return PB.default.createElement("ink-box",{internal_static:!0,style:p},A)};Swe.displayName="Static";Xf.default=Swe});var kwe=_(SB=>{"use strict";var swt=SB&&SB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(SB,"__esModule",{value:!0});var owt=swt(sn()),bwe=({children:t,transform:e})=>t==null?null:owt.default.createElement("ink-text",{style:{flexGrow:0,flexShrink:1,flexDirection:"row"},internal_transform:e},t);bwe.displayName="Transform";SB.default=bwe});var Fwe=_(xB=>{"use strict";var awt=xB&&xB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(xB,"__esModule",{value:!0});var lwt=awt(sn()),Qwe=({count:t=1})=>lwt.default.createElement("ink-text",null,` -`.repeat(t));Qwe.displayName="Newline";xB.default=Qwe});var Lwe=_(bB=>{"use strict";var Rwe=bB&&bB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(bB,"__esModule",{value:!0});var cwt=Rwe(sn()),uwt=Rwe(sQ()),Twe=()=>cwt.default.createElement(uwt.default,{flexGrow:1});Twe.displayName="Spacer";bB.default=Twe});var lQ=_(kB=>{"use strict";var Awt=kB&&kB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(kB,"__esModule",{value:!0});var fwt=sn(),pwt=Awt(cj()),hwt=()=>fwt.useContext(pwt.default);kB.default=hwt});var Owe=_(QB=>{"use strict";var gwt=QB&&QB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(QB,"__esModule",{value:!0});var Nwe=sn(),dwt=gwt(lQ()),mwt=(t,e={})=>{let{stdin:r,setRawMode:o,internal_exitOnCtrlC:a}=dwt.default();Nwe.useEffect(()=>{if(e.isActive!==!1)return o(!0),()=>{o(!1)}},[e.isActive,o]),Nwe.useEffect(()=>{if(e.isActive===!1)return;let n=u=>{let A=String(u),p={upArrow:A==="\x1B[A",downArrow:A==="\x1B[B",leftArrow:A==="\x1B[D",rightArrow:A==="\x1B[C",pageDown:A==="\x1B[6~",pageUp:A==="\x1B[5~",return:A==="\r",escape:A==="\x1B",ctrl:!1,shift:!1,tab:A===" "||A==="\x1B[Z",backspace:A==="\b",delete:A==="\x7F"||A==="\x1B[3~",meta:!1};A<=""&&!p.return&&(A=String.fromCharCode(A.charCodeAt(0)+"a".charCodeAt(0)-1),p.ctrl=!0),A.startsWith("\x1B")&&(A=A.slice(1),p.meta=!0);let h=A>="A"&&A<="Z",C=A>="\u0410"&&A<="\u042F";A.length===1&&(h||C)&&(p.shift=!0),p.tab&&A==="[Z"&&(p.shift=!0),(p.tab||p.backspace||p.delete)&&(A=""),(!(A==="c"&&p.ctrl)||!a)&&t(A,p)};return r?.on("data",n),()=>{r?.off("data",n)}},[e.isActive,r,a,t])};QB.default=mwt});var Mwe=_(FB=>{"use strict";var ywt=FB&&FB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(FB,"__esModule",{value:!0});var Ewt=sn(),Cwt=ywt(aj()),wwt=()=>Ewt.useContext(Cwt.default);FB.default=wwt});var Uwe=_(RB=>{"use strict";var Iwt=RB&&RB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(RB,"__esModule",{value:!0});var Bwt=sn(),vwt=Iwt(Aj()),Dwt=()=>Bwt.useContext(vwt.default);RB.default=Dwt});var _we=_(TB=>{"use strict";var Pwt=TB&&TB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(TB,"__esModule",{value:!0});var Swt=sn(),xwt=Pwt(pj()),bwt=()=>Swt.useContext(xwt.default);TB.default=bwt});var jwe=_(NB=>{"use strict";var Hwe=NB&&NB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(NB,"__esModule",{value:!0});var LB=sn(),kwt=Hwe(iQ()),Qwt=Hwe(lQ()),Fwt=({isActive:t=!0,autoFocus:e=!1}={})=>{let{isRawModeSupported:r,setRawMode:o}=Qwt.default(),{activeId:a,add:n,remove:u,activate:A,deactivate:p}=LB.useContext(kwt.default),h=LB.useMemo(()=>Math.random().toString().slice(2,7),[]);return LB.useEffect(()=>(n(h,{autoFocus:e}),()=>{u(h)}),[h,e]),LB.useEffect(()=>{t?A(h):p(h)},[t,h]),LB.useEffect(()=>{if(!(!r||!t))return o(!0),()=>{o(!1)}},[t]),{isFocused:Boolean(h)&&a===h}};NB.default=Fwt});var qwe=_(OB=>{"use strict";var Rwt=OB&&OB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(OB,"__esModule",{value:!0});var Twt=sn(),Lwt=Rwt(iQ()),Nwt=()=>{let t=Twt.useContext(Lwt.default);return{enableFocus:t.enableFocus,disableFocus:t.disableFocus,focusNext:t.focusNext,focusPrevious:t.focusPrevious}};OB.default=Nwt});var Gwe=_(wj=>{"use strict";Object.defineProperty(wj,"__esModule",{value:!0});wj.default=t=>{var e,r,o,a;return{width:(r=(e=t.yogaNode)===null||e===void 0?void 0:e.getComputedWidth())!==null&&r!==void 0?r:0,height:(a=(o=t.yogaNode)===null||o===void 0?void 0:o.getComputedHeight())!==null&&a!==void 0?a:0}}});var ic=_(ro=>{"use strict";Object.defineProperty(ro,"__esModule",{value:!0});var Owt=Pwe();Object.defineProperty(ro,"render",{enumerable:!0,get:function(){return Owt.default}});var Mwt=sQ();Object.defineProperty(ro,"Box",{enumerable:!0,get:function(){return Mwt.default}});var Uwt=yj();Object.defineProperty(ro,"Text",{enumerable:!0,get:function(){return Uwt.default}});var _wt=xwe();Object.defineProperty(ro,"Static",{enumerable:!0,get:function(){return _wt.default}});var Hwt=kwe();Object.defineProperty(ro,"Transform",{enumerable:!0,get:function(){return Hwt.default}});var jwt=Fwe();Object.defineProperty(ro,"Newline",{enumerable:!0,get:function(){return jwt.default}});var qwt=Lwe();Object.defineProperty(ro,"Spacer",{enumerable:!0,get:function(){return qwt.default}});var Gwt=Owe();Object.defineProperty(ro,"useInput",{enumerable:!0,get:function(){return Gwt.default}});var Ywt=Mwe();Object.defineProperty(ro,"useApp",{enumerable:!0,get:function(){return Ywt.default}});var Wwt=lQ();Object.defineProperty(ro,"useStdin",{enumerable:!0,get:function(){return Wwt.default}});var Kwt=Uwe();Object.defineProperty(ro,"useStdout",{enumerable:!0,get:function(){return Kwt.default}});var Vwt=_we();Object.defineProperty(ro,"useStderr",{enumerable:!0,get:function(){return Vwt.default}});var zwt=jwe();Object.defineProperty(ro,"useFocus",{enumerable:!0,get:function(){return zwt.default}});var Jwt=qwe();Object.defineProperty(ro,"useFocusManager",{enumerable:!0,get:function(){return Jwt.default}});var Xwt=Gwe();Object.defineProperty(ro,"measureElement",{enumerable:!0,get:function(){return Xwt.default}})});var Bj={};Vt(Bj,{Gem:()=>Ij});var Ywe,um,Ij,cQ=yt(()=>{Ywe=$e(ic()),um=$e(sn()),Ij=(0,um.memo)(({active:t})=>{let e=(0,um.useMemo)(()=>t?"\u25C9":"\u25EF",[t]),r=(0,um.useMemo)(()=>t?"green":"yellow",[t]);return um.default.createElement(Ywe.Text,{color:r},e)})});var Kwe={};Vt(Kwe,{useKeypress:()=>Am});function Am({active:t},e,r){let{stdin:o}=(0,Wwe.useStdin)(),a=(0,uQ.useCallback)((n,u)=>e(n,u),r);(0,uQ.useEffect)(()=>{if(!(!t||!o))return o.on("keypress",a),()=>{o.off("keypress",a)}},[t,a,o])}var Wwe,uQ,MB=yt(()=>{Wwe=$e(ic()),uQ=$e(sn())});var zwe={};Vt(zwe,{FocusRequest:()=>Vwe,useFocusRequest:()=>vj});var Vwe,vj,Dj=yt(()=>{MB();Vwe=(r=>(r.BEFORE="before",r.AFTER="after",r))(Vwe||{}),vj=function({active:t},e,r){Am({active:t},(o,a)=>{a.name==="tab"&&(a.shift?e("before"):e("after"))},r)}});var Jwe={};Vt(Jwe,{useListInput:()=>UB});var UB,AQ=yt(()=>{MB();UB=function(t,e,{active:r,minus:o,plus:a,set:n,loop:u=!0}){Am({active:r},(A,p)=>{let h=e.indexOf(t);switch(p.name){case o:{let C=h-1;if(u){n(e[(e.length+C)%e.length]);return}if(C<0)return;n(e[C])}break;case a:{let C=h+1;if(u){n(e[C%e.length]);return}if(C>=e.length)return;n(e[C])}break}},[e,t,a,n,u])}});var fQ={};Vt(fQ,{ScrollableItems:()=>Zwt});var E0,Na,Zwt,pQ=yt(()=>{E0=$e(ic()),Na=$e(sn());Dj();AQ();Zwt=({active:t=!0,children:e=[],radius:r=10,size:o=1,loop:a=!0,onFocusRequest:n,willReachEnd:u})=>{let A=N=>{if(N.key===null)throw new Error("Expected all children to have a key");return N.key},p=Na.default.Children.map(e,N=>A(N)),h=p[0],[C,I]=(0,Na.useState)(h),v=p.indexOf(C);(0,Na.useEffect)(()=>{p.includes(C)||I(h)},[e]),(0,Na.useEffect)(()=>{u&&v>=p.length-2&&u()},[v]),vj({active:t&&!!n},N=>{n?.(N)},[n]),UB(C,p,{active:t,minus:"up",plus:"down",set:I,loop:a});let b=v-r,E=v+r;E>p.length&&(b-=E-p.length,E=p.length),b<0&&(E+=-b,b=0),E>=p.length&&(E=p.length-1);let F=[];for(let N=b;N<=E;++N){let U=p[N],z=t&&U===C;F.push(Na.default.createElement(E0.Box,{key:U,height:o},Na.default.createElement(E0.Box,{marginLeft:1,marginRight:1},Na.default.createElement(E0.Text,null,z?Na.default.createElement(E0.Text,{color:"cyan",bold:!0},">"):" ")),Na.default.createElement(E0.Box,null,Na.default.cloneElement(e[N],{active:z}))))}return Na.default.createElement(E0.Box,{flexDirection:"column",width:"100%"},F)}});var Xwe,Zf,Zwe,Pj,$we,Sj=yt(()=>{Xwe=$e(ic()),Zf=$e(sn()),Zwe=Be("readline"),Pj=Zf.default.createContext(null),$we=({children:t})=>{let{stdin:e,setRawMode:r}=(0,Xwe.useStdin)();(0,Zf.useEffect)(()=>{r&&r(!0),e&&(0,Zwe.emitKeypressEvents)(e)},[e,r]);let[o,a]=(0,Zf.useState)(new Map),n=(0,Zf.useMemo)(()=>({getAll:()=>o,get:u=>o.get(u),set:(u,A)=>a(new Map([...o,[u,A]]))}),[o,a]);return Zf.default.createElement(Pj.Provider,{value:n,children:t})}});var xj={};Vt(xj,{useMinistore:()=>$wt});function $wt(t,e){let r=(0,hQ.useContext)(Pj);if(r===null)throw new Error("Expected this hook to run with a ministore context attached");if(typeof t>"u")return r.getAll();let o=(0,hQ.useCallback)(n=>{r.set(t,n)},[t,r.set]),a=r.get(t);return typeof a>"u"&&(a=e),[a,o]}var hQ,bj=yt(()=>{hQ=$e(sn());Sj()});var dQ={};Vt(dQ,{renderForm:()=>eIt});async function eIt(t,e,{stdin:r,stdout:o,stderr:a}){let n,u=p=>{let{exit:h}=(0,gQ.useApp)();Am({active:!0},(C,I)=>{I.name==="return"&&(n=p,h())},[h,p])},{waitUntilExit:A}=(0,gQ.render)(kj.default.createElement($we,null,kj.default.createElement(t,{...e,useSubmit:u})),{stdin:r,stdout:o,stderr:a});return await A(),n}var gQ,kj,mQ=yt(()=>{gQ=$e(ic()),kj=$e(sn());Sj();MB()});var nIe=_(_B=>{"use strict";Object.defineProperty(_B,"__esModule",{value:!0});_B.UncontrolledTextInput=void 0;var tIe=sn(),Qj=sn(),eIe=ic(),fm=rQ(),rIe=({value:t,placeholder:e="",focus:r=!0,mask:o,highlightPastedText:a=!1,showCursor:n=!0,onChange:u,onSubmit:A})=>{let[{cursorOffset:p,cursorWidth:h},C]=Qj.useState({cursorOffset:(t||"").length,cursorWidth:0});Qj.useEffect(()=>{C(F=>{if(!r||!n)return F;let N=t||"";return F.cursorOffset>N.length-1?{cursorOffset:N.length,cursorWidth:0}:F})},[t,r,n]);let I=a?h:0,v=o?o.repeat(t.length):t,b=v,E=e?fm.grey(e):void 0;if(n&&r){E=e.length>0?fm.inverse(e[0])+fm.grey(e.slice(1)):fm.inverse(" "),b=v.length>0?"":fm.inverse(" ");let F=0;for(let N of v)F>=p-I&&F<=p?b+=fm.inverse(N):b+=N,F++;v.length>0&&p===v.length&&(b+=fm.inverse(" "))}return eIe.useInput((F,N)=>{if(N.upArrow||N.downArrow||N.ctrl&&F==="c"||N.tab||N.shift&&N.tab)return;if(N.return){A&&A(t);return}let U=p,z=t,te=0;N.leftArrow?n&&U--:N.rightArrow?n&&U++:N.backspace||N.delete?p>0&&(z=t.slice(0,p-1)+t.slice(p,t.length),U--):(z=t.slice(0,p)+F+t.slice(p,t.length),U+=F.length,F.length>1&&(te=F.length)),p<0&&(U=0),p>t.length&&(U=t.length),C({cursorOffset:U,cursorWidth:te}),z!==t&&u(z)},{isActive:r}),tIe.createElement(eIe.Text,null,e?v.length>0?b:E:b)};_B.default=rIe;_B.UncontrolledTextInput=t=>{let[e,r]=Qj.useState("");return tIe.createElement(rIe,Object.assign({},t,{value:e,onChange:r}))}});var oIe={};Vt(oIe,{Pad:()=>Fj});var iIe,sIe,Fj,Rj=yt(()=>{iIe=$e(ic()),sIe=$e(sn()),Fj=({length:t,active:e})=>{if(t===0)return null;let r=t>1?` ${"-".repeat(t-1)}`:" ";return sIe.default.createElement(iIe.Text,{dimColor:!e},r)}});var aIe={};Vt(aIe,{ItemOptions:()=>tIt});var jB,w0,tIt,lIe=yt(()=>{jB=$e(ic()),w0=$e(sn());AQ();cQ();Rj();tIt=function({active:t,skewer:e,options:r,value:o,onChange:a,sizes:n=[]}){let u=r.filter(({label:p})=>!!p).map(({value:p})=>p),A=r.findIndex(p=>p.value===o&&p.label!="");return UB(o,u,{active:t,minus:"left",plus:"right",set:a}),w0.default.createElement(w0.default.Fragment,null,r.map(({label:p},h)=>{let C=h===A,I=n[h]-1||0,v=p.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,""),b=Math.max(0,I-v.length-2);return p?w0.default.createElement(jB.Box,{key:p,width:I,marginLeft:1},w0.default.createElement(jB.Text,{wrap:"truncate"},w0.default.createElement(Ij,{active:C})," ",p),e?w0.default.createElement(Fj,{active:t,length:b}):null):w0.default.createElement(jB.Box,{key:`spacer-${h}`,width:I,marginLeft:1})}))}});var vIe=_((Kzt,BIe)=>{var jj;BIe.exports=()=>(typeof jj>"u"&&(jj=Be("zlib").brotliDecompressSync(Buffer.from("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","base64")).toString()),jj)});var YIe=_((yJt,GIe)=>{var Xj=Symbol("arg flag"),Oa=class extends Error{constructor(e,r){super(e),this.name="ArgError",this.code=r,Object.setPrototypeOf(this,Oa.prototype)}};function iv(t,{argv:e=process.argv.slice(2),permissive:r=!1,stopAtPositional:o=!1}={}){if(!t)throw new Oa("argument specification object is required","ARG_CONFIG_NO_SPEC");let a={_:[]},n={},u={};for(let A of Object.keys(t)){if(!A)throw new Oa("argument key cannot be an empty string","ARG_CONFIG_EMPTY_KEY");if(A[0]!=="-")throw new Oa(`argument key must start with '-' but found: '${A}'`,"ARG_CONFIG_NONOPT_KEY");if(A.length===1)throw new Oa(`argument key must have a name; singular '-' keys are not allowed: ${A}`,"ARG_CONFIG_NONAME_KEY");if(typeof t[A]=="string"){n[A]=t[A];continue}let p=t[A],h=!1;if(Array.isArray(p)&&p.length===1&&typeof p[0]=="function"){let[C]=p;p=(I,v,b=[])=>(b.push(C(I,v,b[b.length-1])),b),h=C===Boolean||C[Xj]===!0}else if(typeof p=="function")h=p===Boolean||p[Xj]===!0;else throw new Oa(`type missing or not a function or valid array type: ${A}`,"ARG_CONFIG_VAD_TYPE");if(A[1]!=="-"&&A.length>2)throw new Oa(`short argument keys (with a single hyphen) must have only one character: ${A}`,"ARG_CONFIG_SHORTOPT_TOOLONG");u[A]=[p,h]}for(let A=0,p=e.length;A<p;A++){let h=e[A];if(o&&a._.length>0){a._=a._.concat(e.slice(A));break}if(h==="--"){a._=a._.concat(e.slice(A+1));break}if(h.length>1&&h[0]==="-"){let C=h[1]==="-"||h.length===2?[h]:h.slice(1).split("").map(I=>`-${I}`);for(let I=0;I<C.length;I++){let v=C[I],[b,E]=v[1]==="-"?v.split(/=(.*)/,2):[v,void 0],F=b;for(;F in n;)F=n[F];if(!(F in u))if(r){a._.push(v);continue}else throw new Oa(`unknown or unexpected option: ${b}`,"ARG_UNKNOWN_OPTION");let[N,U]=u[F];if(!U&&I+1<C.length)throw new Oa(`option requires argument (but was followed by another short argument): ${b}`,"ARG_MISSING_REQUIRED_SHORTARG");if(U)a[F]=N(!0,F,a[F]);else if(E===void 0){if(e.length<A+2||e[A+1].length>1&&e[A+1][0]==="-"&&!(e[A+1].match(/^-?\d*(\.(?=\d))?\d*$/)&&(N===Number||typeof BigInt<"u"&&N===BigInt))){let z=b===F?"":` (alias for ${F})`;throw new Oa(`option requires argument: ${b}${z}`,"ARG_MISSING_REQUIRED_LONGARG")}a[F]=N(e[A+1],F,a[F]),++A}else a[F]=N(E,F,a[F])}}else a._.push(h)}return a}iv.flag=t=>(t[Xj]=!0,t);iv.COUNT=iv.flag((t,e,r)=>(r||0)+1);iv.ArgError=Oa;GIe.exports=iv});var $Ie=_((GJt,ZIe)=>{var rq;ZIe.exports=()=>(typeof rq>"u"&&(rq=Be("zlib").brotliDecompressSync(Buffer.from("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","base64")).toString()),rq)});var i1e=_((lq,cq)=>{(function(t){lq&&typeof lq=="object"&&typeof cq<"u"?cq.exports=t():typeof define=="function"&&define.amd?define([],t):typeof window<"u"?window.isWindows=t():typeof global<"u"?global.isWindows=t():typeof self<"u"?self.isWindows=t():this.isWindows=t()})(function(){"use strict";return function(){return process&&(process.platform==="win32"||/^(msys|cygwin)$/.test(process.env.OSTYPE))}})});var l1e=_((jXt,a1e)=>{"use strict";uq.ifExists=ZIt;var GC=Be("util"),sc=Be("path"),s1e=i1e(),zIt=/^#!\s*(?:\/usr\/bin\/env)?\s*([^ \t]+)(.*)$/,JIt={createPwshFile:!0,createCmdFile:s1e(),fs:Be("fs")},XIt=new Map([[".js","node"],[".cjs","node"],[".mjs","node"],[".cmd","cmd"],[".bat","cmd"],[".ps1","pwsh"],[".sh","sh"]]);function o1e(t){let e={...JIt,...t},r=e.fs;return e.fs_={chmod:r.chmod?GC.promisify(r.chmod):async()=>{},mkdir:GC.promisify(r.mkdir),readFile:GC.promisify(r.readFile),stat:GC.promisify(r.stat),unlink:GC.promisify(r.unlink),writeFile:GC.promisify(r.writeFile)},e}async function uq(t,e,r){let o=o1e(r);await o.fs_.stat(t),await e1t(t,e,o)}function ZIt(t,e,r){return uq(t,e,r).catch(()=>{})}function $It(t,e){return e.fs_.unlink(t).catch(()=>{})}async function e1t(t,e,r){let o=await s1t(t,r);return await t1t(e,r),r1t(t,e,o,r)}function t1t(t,e){return e.fs_.mkdir(sc.dirname(t),{recursive:!0})}function r1t(t,e,r,o){let a=o1e(o),n=[{generator:l1t,extension:""}];return a.createCmdFile&&n.push({generator:a1t,extension:".cmd"}),a.createPwshFile&&n.push({generator:c1t,extension:".ps1"}),Promise.all(n.map(u=>o1t(t,e+u.extension,r,u.generator,a)))}function n1t(t,e){return $It(t,e)}function i1t(t,e){return u1t(t,e)}async function s1t(t,e){let a=(await e.fs_.readFile(t,"utf8")).trim().split(/\r*\n/)[0].match(zIt);if(!a){let n=sc.extname(t).toLowerCase();return{program:XIt.get(n)||null,additionalArgs:""}}return{program:a[1],additionalArgs:a[2]}}async function o1t(t,e,r,o,a){let n=a.preserveSymlinks?"--preserve-symlinks":"",u=[r.additionalArgs,n].filter(A=>A).join(" ");return a=Object.assign({},a,{prog:r.program,args:u}),await n1t(e,a),await a.fs_.writeFile(e,o(t,e,a),"utf8"),i1t(e,a)}function a1t(t,e,r){let a=sc.relative(sc.dirname(e),t).split("/").join("\\"),n=sc.isAbsolute(a)?`"${a}"`:`"%~dp0\\${a}"`,u,A=r.prog,p=r.args||"",h=Aq(r.nodePath).win32;A?(u=`"%~dp0\\${A}.exe"`,a=n):(A=n,p="",a="");let C=r.progArgs?`${r.progArgs.join(" ")} `:"",I=h?`@SET NODE_PATH=${h}\r -`:"";return u?I+=`@IF EXIST ${u} (\r - ${u} ${p} ${a} ${C}%*\r -) ELSE (\r - @SETLOCAL\r - @SET PATHEXT=%PATHEXT:;.JS;=;%\r - ${A} ${p} ${a} ${C}%*\r -)\r -`:I+=`@${A} ${p} ${a} ${C}%*\r -`,I}function l1t(t,e,r){let o=sc.relative(sc.dirname(e),t),a=r.prog&&r.prog.split("\\").join("/"),n;o=o.split("\\").join("/");let u=sc.isAbsolute(o)?`"${o}"`:`"$basedir/${o}"`,A=r.args||"",p=Aq(r.nodePath).posix;a?(n=`"$basedir/${r.prog}"`,o=u):(a=u,A="",o="");let h=r.progArgs?`${r.progArgs.join(" ")} `:"",C=`#!/bin/sh -basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')") - -case \`uname\` in - *CYGWIN*) basedir=\`cygpath -w "$basedir"\`;; -esac - -`,I=r.nodePath?`export NODE_PATH="${p}" -`:"";return n?C+=`${I}if [ -x ${n} ]; then - exec ${n} ${A} ${o} ${h}"$@" -else - exec ${a} ${A} ${o} ${h}"$@" -fi -`:C+=`${I}${a} ${A} ${o} ${h}"$@" -exit $? -`,C}function c1t(t,e,r){let o=sc.relative(sc.dirname(e),t),a=r.prog&&r.prog.split("\\").join("/"),n=a&&`"${a}$exe"`,u;o=o.split("\\").join("/");let A=sc.isAbsolute(o)?`"${o}"`:`"$basedir/${o}"`,p=r.args||"",h=Aq(r.nodePath),C=h.win32,I=h.posix;n?(u=`"$basedir/${r.prog}$exe"`,o=A):(n=A,p="",o="");let v=r.progArgs?`${r.progArgs.join(" ")} `:"",b=`#!/usr/bin/env pwsh -$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent - -$exe="" -${r.nodePath?`$env_node_path=$env:NODE_PATH -$env:NODE_PATH="${C}" -`:""}if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { - # Fix case when both the Windows and Linux builds of Node - # are installed in the same directory - $exe=".exe" -}`;return r.nodePath&&(b+=` else { - $env:NODE_PATH="${I}" -}`),u?b+=` -$ret=0 -if (Test-Path ${u}) { - # Support pipeline input - if ($MyInvocation.ExpectingInput) { - $input | & ${u} ${p} ${o} ${v}$args - } else { - & ${u} ${p} ${o} ${v}$args - } - $ret=$LASTEXITCODE -} else { - # Support pipeline input - if ($MyInvocation.ExpectingInput) { - $input | & ${n} ${p} ${o} ${v}$args - } else { - & ${n} ${p} ${o} ${v}$args - } - $ret=$LASTEXITCODE -} -${r.nodePath?`$env:NODE_PATH=$env_node_path -`:""}exit $ret -`:b+=` -# Support pipeline input -if ($MyInvocation.ExpectingInput) { - $input | & ${n} ${p} ${o} ${v}$args -} else { - & ${n} ${p} ${o} ${v}$args -} -${r.nodePath?`$env:NODE_PATH=$env_node_path -`:""}exit $LASTEXITCODE -`,b}function u1t(t,e){return e.fs_.chmod(t,493)}function Aq(t){if(!t)return{win32:"",posix:""};let e=typeof t=="string"?t.split(sc.delimiter):Array.from(t),r={};for(let o=0;o<e.length;o++){let a=e[o].split("/").join("\\"),n=s1e()?e[o].split("\\").join("/").replace(/^([^:\\/]*):/,(u,A)=>`/mnt/${A.toLowerCase()}`):e[o];r.win32=r.win32?`${r.win32};${a}`:a,r.posix=r.posix?`${r.posix}:${n}`:n,r[o]={win32:a,posix:n}}return r}a1e.exports=uq});var Pq=_((u$t,k1e)=>{k1e.exports=Be("stream")});var T1e=_((A$t,R1e)=>{"use strict";function Q1e(t,e){var r=Object.keys(t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(t);e&&(o=o.filter(function(a){return Object.getOwnPropertyDescriptor(t,a).enumerable})),r.push.apply(r,o)}return r}function T1t(t){for(var e=1;e<arguments.length;e++){var r=arguments[e]!=null?arguments[e]:{};e%2?Q1e(Object(r),!0).forEach(function(o){L1t(t,o,r[o])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(r)):Q1e(Object(r)).forEach(function(o){Object.defineProperty(t,o,Object.getOwnPropertyDescriptor(r,o))})}return t}function L1t(t,e,r){return e in t?Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[e]=r,t}function N1t(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function F1e(t,e){for(var r=0;r<e.length;r++){var o=e[r];o.enumerable=o.enumerable||!1,o.configurable=!0,"value"in o&&(o.writable=!0),Object.defineProperty(t,o.key,o)}}function O1t(t,e,r){return e&&F1e(t.prototype,e),r&&F1e(t,r),t}var M1t=Be("buffer"),bQ=M1t.Buffer,U1t=Be("util"),Sq=U1t.inspect,_1t=Sq&&Sq.custom||"inspect";function H1t(t,e,r){bQ.prototype.copy.call(t,e,r)}R1e.exports=function(){function t(){N1t(this,t),this.head=null,this.tail=null,this.length=0}return O1t(t,[{key:"push",value:function(r){var o={data:r,next:null};this.length>0?this.tail.next=o:this.head=o,this.tail=o,++this.length}},{key:"unshift",value:function(r){var o={data:r,next:this.head};this.length===0&&(this.tail=o),this.head=o,++this.length}},{key:"shift",value:function(){if(this.length!==0){var r=this.head.data;return this.length===1?this.head=this.tail=null:this.head=this.head.next,--this.length,r}}},{key:"clear",value:function(){this.head=this.tail=null,this.length=0}},{key:"join",value:function(r){if(this.length===0)return"";for(var o=this.head,a=""+o.data;o=o.next;)a+=r+o.data;return a}},{key:"concat",value:function(r){if(this.length===0)return bQ.alloc(0);for(var o=bQ.allocUnsafe(r>>>0),a=this.head,n=0;a;)H1t(a.data,o,n),n+=a.data.length,a=a.next;return o}},{key:"consume",value:function(r,o){var a;return r<this.head.data.length?(a=this.head.data.slice(0,r),this.head.data=this.head.data.slice(r)):r===this.head.data.length?a=this.shift():a=o?this._getString(r):this._getBuffer(r),a}},{key:"first",value:function(){return this.head.data}},{key:"_getString",value:function(r){var o=this.head,a=1,n=o.data;for(r-=n.length;o=o.next;){var u=o.data,A=r>u.length?u.length:r;if(A===u.length?n+=u:n+=u.slice(0,r),r-=A,r===0){A===u.length?(++a,o.next?this.head=o.next:this.head=this.tail=null):(this.head=o,o.data=u.slice(A));break}++a}return this.length-=a,n}},{key:"_getBuffer",value:function(r){var o=bQ.allocUnsafe(r),a=this.head,n=1;for(a.data.copy(o),r-=a.data.length;a=a.next;){var u=a.data,A=r>u.length?u.length:r;if(u.copy(o,o.length-r,0,A),r-=A,r===0){A===u.length?(++n,a.next?this.head=a.next:this.head=this.tail=null):(this.head=a,a.data=u.slice(A));break}++n}return this.length-=n,o}},{key:_1t,value:function(r,o){return Sq(this,T1t({},o,{depth:0,customInspect:!1}))}}]),t}()});var bq=_((f$t,N1e)=>{"use strict";function j1t(t,e){var r=this,o=this._readableState&&this._readableState.destroyed,a=this._writableState&&this._writableState.destroyed;return o||a?(e?e(t):t&&(this._writableState?this._writableState.errorEmitted||(this._writableState.errorEmitted=!0,process.nextTick(xq,this,t)):process.nextTick(xq,this,t)),this):(this._readableState&&(this._readableState.destroyed=!0),this._writableState&&(this._writableState.destroyed=!0),this._destroy(t||null,function(n){!e&&n?r._writableState?r._writableState.errorEmitted?process.nextTick(kQ,r):(r._writableState.errorEmitted=!0,process.nextTick(L1e,r,n)):process.nextTick(L1e,r,n):e?(process.nextTick(kQ,r),e(n)):process.nextTick(kQ,r)}),this)}function L1e(t,e){xq(t,e),kQ(t)}function kQ(t){t._writableState&&!t._writableState.emitClose||t._readableState&&!t._readableState.emitClose||t.emit("close")}function q1t(){this._readableState&&(this._readableState.destroyed=!1,this._readableState.reading=!1,this._readableState.ended=!1,this._readableState.endEmitted=!1),this._writableState&&(this._writableState.destroyed=!1,this._writableState.ended=!1,this._writableState.ending=!1,this._writableState.finalCalled=!1,this._writableState.prefinished=!1,this._writableState.finished=!1,this._writableState.errorEmitted=!1)}function xq(t,e){t.emit("error",e)}function G1t(t,e){var r=t._readableState,o=t._writableState;r&&r.autoDestroy||o&&o.autoDestroy?t.destroy(e):t.emit("error",e)}N1e.exports={destroy:j1t,undestroy:q1t,errorOrDestroy:G1t}});var b0=_((p$t,U1e)=>{"use strict";var M1e={};function ac(t,e,r){r||(r=Error);function o(n,u,A){return typeof e=="string"?e:e(n,u,A)}class a extends r{constructor(u,A,p){super(o(u,A,p))}}a.prototype.name=r.name,a.prototype.code=t,M1e[t]=a}function O1e(t,e){if(Array.isArray(t)){let r=t.length;return t=t.map(o=>String(o)),r>2?`one of ${e} ${t.slice(0,r-1).join(", ")}, or `+t[r-1]:r===2?`one of ${e} ${t[0]} or ${t[1]}`:`of ${e} ${t[0]}`}else return`of ${e} ${String(t)}`}function Y1t(t,e,r){return t.substr(!r||r<0?0:+r,e.length)===e}function W1t(t,e,r){return(r===void 0||r>t.length)&&(r=t.length),t.substring(r-e.length,r)===e}function K1t(t,e,r){return typeof r!="number"&&(r=0),r+e.length>t.length?!1:t.indexOf(e,r)!==-1}ac("ERR_INVALID_OPT_VALUE",function(t,e){return'The value "'+e+'" is invalid for option "'+t+'"'},TypeError);ac("ERR_INVALID_ARG_TYPE",function(t,e,r){let o;typeof e=="string"&&Y1t(e,"not ")?(o="must not be",e=e.replace(/^not /,"")):o="must be";let a;if(W1t(t," argument"))a=`The ${t} ${o} ${O1e(e,"type")}`;else{let n=K1t(t,".")?"property":"argument";a=`The "${t}" ${n} ${o} ${O1e(e,"type")}`}return a+=`. Received type ${typeof r}`,a},TypeError);ac("ERR_STREAM_PUSH_AFTER_EOF","stream.push() after EOF");ac("ERR_METHOD_NOT_IMPLEMENTED",function(t){return"The "+t+" method is not implemented"});ac("ERR_STREAM_PREMATURE_CLOSE","Premature close");ac("ERR_STREAM_DESTROYED",function(t){return"Cannot call "+t+" after a stream was destroyed"});ac("ERR_MULTIPLE_CALLBACK","Callback called multiple times");ac("ERR_STREAM_CANNOT_PIPE","Cannot pipe, not readable");ac("ERR_STREAM_WRITE_AFTER_END","write after end");ac("ERR_STREAM_NULL_VALUES","May not write null values to stream",TypeError);ac("ERR_UNKNOWN_ENCODING",function(t){return"Unknown encoding: "+t},TypeError);ac("ERR_STREAM_UNSHIFT_AFTER_END_EVENT","stream.unshift() after end event");U1e.exports.codes=M1e});var kq=_((h$t,_1e)=>{"use strict";var V1t=b0().codes.ERR_INVALID_OPT_VALUE;function z1t(t,e,r){return t.highWaterMark!=null?t.highWaterMark:e?t[r]:null}function J1t(t,e,r,o){var a=z1t(e,o,r);if(a!=null){if(!(isFinite(a)&&Math.floor(a)===a)||a<0){var n=o?r:"highWaterMark";throw new V1t(n,a)}return Math.floor(a)}return t.objectMode?16:16*1024}_1e.exports={getHighWaterMark:J1t}});var H1e=_((g$t,Qq)=>{typeof Object.create=="function"?Qq.exports=function(e,r){r&&(e.super_=r,e.prototype=Object.create(r.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}))}:Qq.exports=function(e,r){if(r){e.super_=r;var o=function(){};o.prototype=r.prototype,e.prototype=new o,e.prototype.constructor=e}}});var k0=_((d$t,Rq)=>{try{if(Fq=Be("util"),typeof Fq.inherits!="function")throw"";Rq.exports=Fq.inherits}catch{Rq.exports=H1e()}var Fq});var q1e=_((m$t,j1e)=>{j1e.exports=Be("util").deprecate});var Nq=_((y$t,z1e)=>{"use strict";z1e.exports=Ri;function Y1e(t){var e=this;this.next=null,this.entry=null,this.finish=function(){B2t(e,t)}}var zC;Ri.WritableState=mv;var X1t={deprecate:q1e()},W1e=Pq(),FQ=Be("buffer").Buffer,Z1t=global.Uint8Array||function(){};function $1t(t){return FQ.from(t)}function e2t(t){return FQ.isBuffer(t)||t instanceof Z1t}var Lq=bq(),t2t=kq(),r2t=t2t.getHighWaterMark,Q0=b0().codes,n2t=Q0.ERR_INVALID_ARG_TYPE,i2t=Q0.ERR_METHOD_NOT_IMPLEMENTED,s2t=Q0.ERR_MULTIPLE_CALLBACK,o2t=Q0.ERR_STREAM_CANNOT_PIPE,a2t=Q0.ERR_STREAM_DESTROYED,l2t=Q0.ERR_STREAM_NULL_VALUES,c2t=Q0.ERR_STREAM_WRITE_AFTER_END,u2t=Q0.ERR_UNKNOWN_ENCODING,JC=Lq.errorOrDestroy;k0()(Ri,W1e);function A2t(){}function mv(t,e,r){zC=zC||Em(),t=t||{},typeof r!="boolean"&&(r=e instanceof zC),this.objectMode=!!t.objectMode,r&&(this.objectMode=this.objectMode||!!t.writableObjectMode),this.highWaterMark=r2t(this,t,"writableHighWaterMark",r),this.finalCalled=!1,this.needDrain=!1,this.ending=!1,this.ended=!1,this.finished=!1,this.destroyed=!1;var o=t.decodeStrings===!1;this.decodeStrings=!o,this.defaultEncoding=t.defaultEncoding||"utf8",this.length=0,this.writing=!1,this.corked=0,this.sync=!0,this.bufferProcessing=!1,this.onwrite=function(a){y2t(e,a)},this.writecb=null,this.writelen=0,this.bufferedRequest=null,this.lastBufferedRequest=null,this.pendingcb=0,this.prefinished=!1,this.errorEmitted=!1,this.emitClose=t.emitClose!==!1,this.autoDestroy=!!t.autoDestroy,this.bufferedRequestCount=0,this.corkedRequestsFree=new Y1e(this)}mv.prototype.getBuffer=function(){for(var e=this.bufferedRequest,r=[];e;)r.push(e),e=e.next;return r};(function(){try{Object.defineProperty(mv.prototype,"buffer",{get:X1t.deprecate(function(){return this.getBuffer()},"_writableState.buffer is deprecated. Use _writableState.getBuffer instead.","DEP0003")})}catch{}})();var QQ;typeof Symbol=="function"&&Symbol.hasInstance&&typeof Function.prototype[Symbol.hasInstance]=="function"?(QQ=Function.prototype[Symbol.hasInstance],Object.defineProperty(Ri,Symbol.hasInstance,{value:function(e){return QQ.call(this,e)?!0:this!==Ri?!1:e&&e._writableState instanceof mv}})):QQ=function(e){return e instanceof this};function Ri(t){zC=zC||Em();var e=this instanceof zC;if(!e&&!QQ.call(Ri,this))return new Ri(t);this._writableState=new mv(t,this,e),this.writable=!0,t&&(typeof t.write=="function"&&(this._write=t.write),typeof t.writev=="function"&&(this._writev=t.writev),typeof t.destroy=="function"&&(this._destroy=t.destroy),typeof t.final=="function"&&(this._final=t.final)),W1e.call(this)}Ri.prototype.pipe=function(){JC(this,new o2t)};function f2t(t,e){var r=new c2t;JC(t,r),process.nextTick(e,r)}function p2t(t,e,r,o){var a;return r===null?a=new l2t:typeof r!="string"&&!e.objectMode&&(a=new n2t("chunk",["string","Buffer"],r)),a?(JC(t,a),process.nextTick(o,a),!1):!0}Ri.prototype.write=function(t,e,r){var o=this._writableState,a=!1,n=!o.objectMode&&e2t(t);return n&&!FQ.isBuffer(t)&&(t=$1t(t)),typeof e=="function"&&(r=e,e=null),n?e="buffer":e||(e=o.defaultEncoding),typeof r!="function"&&(r=A2t),o.ending?f2t(this,r):(n||p2t(this,o,t,r))&&(o.pendingcb++,a=g2t(this,o,n,t,e,r)),a};Ri.prototype.cork=function(){this._writableState.corked++};Ri.prototype.uncork=function(){var t=this._writableState;t.corked&&(t.corked--,!t.writing&&!t.corked&&!t.bufferProcessing&&t.bufferedRequest&&K1e(this,t))};Ri.prototype.setDefaultEncoding=function(e){if(typeof e=="string"&&(e=e.toLowerCase()),!(["hex","utf8","utf-8","ascii","binary","base64","ucs2","ucs-2","utf16le","utf-16le","raw"].indexOf((e+"").toLowerCase())>-1))throw new u2t(e);return this._writableState.defaultEncoding=e,this};Object.defineProperty(Ri.prototype,"writableBuffer",{enumerable:!1,get:function(){return this._writableState&&this._writableState.getBuffer()}});function h2t(t,e,r){return!t.objectMode&&t.decodeStrings!==!1&&typeof e=="string"&&(e=FQ.from(e,r)),e}Object.defineProperty(Ri.prototype,"writableHighWaterMark",{enumerable:!1,get:function(){return this._writableState.highWaterMark}});function g2t(t,e,r,o,a,n){if(!r){var u=h2t(e,o,a);o!==u&&(r=!0,a="buffer",o=u)}var A=e.objectMode?1:o.length;e.length+=A;var p=e.length<e.highWaterMark;if(p||(e.needDrain=!0),e.writing||e.corked){var h=e.lastBufferedRequest;e.lastBufferedRequest={chunk:o,encoding:a,isBuf:r,callback:n,next:null},h?h.next=e.lastBufferedRequest:e.bufferedRequest=e.lastBufferedRequest,e.bufferedRequestCount+=1}else Tq(t,e,!1,A,o,a,n);return p}function Tq(t,e,r,o,a,n,u){e.writelen=o,e.writecb=u,e.writing=!0,e.sync=!0,e.destroyed?e.onwrite(new a2t("write")):r?t._writev(a,e.onwrite):t._write(a,n,e.onwrite),e.sync=!1}function d2t(t,e,r,o,a){--e.pendingcb,r?(process.nextTick(a,o),process.nextTick(dv,t,e),t._writableState.errorEmitted=!0,JC(t,o)):(a(o),t._writableState.errorEmitted=!0,JC(t,o),dv(t,e))}function m2t(t){t.writing=!1,t.writecb=null,t.length-=t.writelen,t.writelen=0}function y2t(t,e){var r=t._writableState,o=r.sync,a=r.writecb;if(typeof a!="function")throw new s2t;if(m2t(r),e)d2t(t,r,o,e,a);else{var n=V1e(r)||t.destroyed;!n&&!r.corked&&!r.bufferProcessing&&r.bufferedRequest&&K1e(t,r),o?process.nextTick(G1e,t,r,n,a):G1e(t,r,n,a)}}function G1e(t,e,r,o){r||E2t(t,e),e.pendingcb--,o(),dv(t,e)}function E2t(t,e){e.length===0&&e.needDrain&&(e.needDrain=!1,t.emit("drain"))}function K1e(t,e){e.bufferProcessing=!0;var r=e.bufferedRequest;if(t._writev&&r&&r.next){var o=e.bufferedRequestCount,a=new Array(o),n=e.corkedRequestsFree;n.entry=r;for(var u=0,A=!0;r;)a[u]=r,r.isBuf||(A=!1),r=r.next,u+=1;a.allBuffers=A,Tq(t,e,!0,e.length,a,"",n.finish),e.pendingcb++,e.lastBufferedRequest=null,n.next?(e.corkedRequestsFree=n.next,n.next=null):e.corkedRequestsFree=new Y1e(e),e.bufferedRequestCount=0}else{for(;r;){var p=r.chunk,h=r.encoding,C=r.callback,I=e.objectMode?1:p.length;if(Tq(t,e,!1,I,p,h,C),r=r.next,e.bufferedRequestCount--,e.writing)break}r===null&&(e.lastBufferedRequest=null)}e.bufferedRequest=r,e.bufferProcessing=!1}Ri.prototype._write=function(t,e,r){r(new i2t("_write()"))};Ri.prototype._writev=null;Ri.prototype.end=function(t,e,r){var o=this._writableState;return typeof t=="function"?(r=t,t=null,e=null):typeof e=="function"&&(r=e,e=null),t!=null&&this.write(t,e),o.corked&&(o.corked=1,this.uncork()),o.ending||I2t(this,o,r),this};Object.defineProperty(Ri.prototype,"writableLength",{enumerable:!1,get:function(){return this._writableState.length}});function V1e(t){return t.ending&&t.length===0&&t.bufferedRequest===null&&!t.finished&&!t.writing}function C2t(t,e){t._final(function(r){e.pendingcb--,r&&JC(t,r),e.prefinished=!0,t.emit("prefinish"),dv(t,e)})}function w2t(t,e){!e.prefinished&&!e.finalCalled&&(typeof t._final=="function"&&!e.destroyed?(e.pendingcb++,e.finalCalled=!0,process.nextTick(C2t,t,e)):(e.prefinished=!0,t.emit("prefinish")))}function dv(t,e){var r=V1e(e);if(r&&(w2t(t,e),e.pendingcb===0&&(e.finished=!0,t.emit("finish"),e.autoDestroy))){var o=t._readableState;(!o||o.autoDestroy&&o.endEmitted)&&t.destroy()}return r}function I2t(t,e,r){e.ending=!0,dv(t,e),r&&(e.finished?process.nextTick(r):t.once("finish",r)),e.ended=!0,t.writable=!1}function B2t(t,e,r){var o=t.entry;for(t.entry=null;o;){var a=o.callback;e.pendingcb--,a(r),o=o.next}e.corkedRequestsFree.next=t}Object.defineProperty(Ri.prototype,"destroyed",{enumerable:!1,get:function(){return this._writableState===void 0?!1:this._writableState.destroyed},set:function(e){!this._writableState||(this._writableState.destroyed=e)}});Ri.prototype.destroy=Lq.destroy;Ri.prototype._undestroy=Lq.undestroy;Ri.prototype._destroy=function(t,e){e(t)}});var Em=_((E$t,X1e)=>{"use strict";var v2t=Object.keys||function(t){var e=[];for(var r in t)e.push(r);return e};X1e.exports=yA;var J1e=Uq(),Mq=Nq();k0()(yA,J1e);for(Oq=v2t(Mq.prototype),RQ=0;RQ<Oq.length;RQ++)TQ=Oq[RQ],yA.prototype[TQ]||(yA.prototype[TQ]=Mq.prototype[TQ]);var Oq,TQ,RQ;function yA(t){if(!(this instanceof yA))return new yA(t);J1e.call(this,t),Mq.call(this,t),this.allowHalfOpen=!0,t&&(t.readable===!1&&(this.readable=!1),t.writable===!1&&(this.writable=!1),t.allowHalfOpen===!1&&(this.allowHalfOpen=!1,this.once("end",D2t)))}Object.defineProperty(yA.prototype,"writableHighWaterMark",{enumerable:!1,get:function(){return this._writableState.highWaterMark}});Object.defineProperty(yA.prototype,"writableBuffer",{enumerable:!1,get:function(){return this._writableState&&this._writableState.getBuffer()}});Object.defineProperty(yA.prototype,"writableLength",{enumerable:!1,get:function(){return this._writableState.length}});function D2t(){this._writableState.ended||process.nextTick(P2t,this)}function P2t(t){t.end()}Object.defineProperty(yA.prototype,"destroyed",{enumerable:!1,get:function(){return this._readableState===void 0||this._writableState===void 0?!1:this._readableState.destroyed&&this._writableState.destroyed},set:function(e){this._readableState===void 0||this._writableState===void 0||(this._readableState.destroyed=e,this._writableState.destroyed=e)}})});var e2e=_((_q,$1e)=>{var LQ=Be("buffer"),rp=LQ.Buffer;function Z1e(t,e){for(var r in t)e[r]=t[r]}rp.from&&rp.alloc&&rp.allocUnsafe&&rp.allocUnsafeSlow?$1e.exports=LQ:(Z1e(LQ,_q),_q.Buffer=XC);function XC(t,e,r){return rp(t,e,r)}Z1e(rp,XC);XC.from=function(t,e,r){if(typeof t=="number")throw new TypeError("Argument must not be a number");return rp(t,e,r)};XC.alloc=function(t,e,r){if(typeof t!="number")throw new TypeError("Argument must be a number");var o=rp(t);return e!==void 0?typeof r=="string"?o.fill(e,r):o.fill(e):o.fill(0),o};XC.allocUnsafe=function(t){if(typeof t!="number")throw new TypeError("Argument must be a number");return rp(t)};XC.allocUnsafeSlow=function(t){if(typeof t!="number")throw new TypeError("Argument must be a number");return LQ.SlowBuffer(t)}});var qq=_(r2e=>{"use strict";var jq=e2e().Buffer,t2e=jq.isEncoding||function(t){switch(t=""+t,t&&t.toLowerCase()){case"hex":case"utf8":case"utf-8":case"ascii":case"binary":case"base64":case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":case"raw":return!0;default:return!1}};function S2t(t){if(!t)return"utf8";for(var e;;)switch(t){case"utf8":case"utf-8":return"utf8";case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return"utf16le";case"latin1":case"binary":return"latin1";case"base64":case"ascii":case"hex":return t;default:if(e)return;t=(""+t).toLowerCase(),e=!0}}function x2t(t){var e=S2t(t);if(typeof e!="string"&&(jq.isEncoding===t2e||!t2e(t)))throw new Error("Unknown encoding: "+t);return e||t}r2e.StringDecoder=yv;function yv(t){this.encoding=x2t(t);var e;switch(this.encoding){case"utf16le":this.text=T2t,this.end=L2t,e=4;break;case"utf8":this.fillLast=Q2t,e=4;break;case"base64":this.text=N2t,this.end=O2t,e=3;break;default:this.write=M2t,this.end=U2t;return}this.lastNeed=0,this.lastTotal=0,this.lastChar=jq.allocUnsafe(e)}yv.prototype.write=function(t){if(t.length===0)return"";var e,r;if(this.lastNeed){if(e=this.fillLast(t),e===void 0)return"";r=this.lastNeed,this.lastNeed=0}else r=0;return r<t.length?e?e+this.text(t,r):this.text(t,r):e||""};yv.prototype.end=R2t;yv.prototype.text=F2t;yv.prototype.fillLast=function(t){if(this.lastNeed<=t.length)return t.copy(this.lastChar,this.lastTotal-this.lastNeed,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal);t.copy(this.lastChar,this.lastTotal-this.lastNeed,0,t.length),this.lastNeed-=t.length};function Hq(t){return t<=127?0:t>>5===6?2:t>>4===14?3:t>>3===30?4:t>>6===2?-1:-2}function b2t(t,e,r){var o=e.length-1;if(o<r)return 0;var a=Hq(e[o]);return a>=0?(a>0&&(t.lastNeed=a-1),a):--o<r||a===-2?0:(a=Hq(e[o]),a>=0?(a>0&&(t.lastNeed=a-2),a):--o<r||a===-2?0:(a=Hq(e[o]),a>=0?(a>0&&(a===2?a=0:t.lastNeed=a-3),a):0))}function k2t(t,e,r){if((e[0]&192)!==128)return t.lastNeed=0,"\uFFFD";if(t.lastNeed>1&&e.length>1){if((e[1]&192)!==128)return t.lastNeed=1,"\uFFFD";if(t.lastNeed>2&&e.length>2&&(e[2]&192)!==128)return t.lastNeed=2,"\uFFFD"}}function Q2t(t){var e=this.lastTotal-this.lastNeed,r=k2t(this,t,e);if(r!==void 0)return r;if(this.lastNeed<=t.length)return t.copy(this.lastChar,e,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal);t.copy(this.lastChar,e,0,t.length),this.lastNeed-=t.length}function F2t(t,e){var r=b2t(this,t,e);if(!this.lastNeed)return t.toString("utf8",e);this.lastTotal=r;var o=t.length-(r-this.lastNeed);return t.copy(this.lastChar,0,o),t.toString("utf8",e,o)}function R2t(t){var e=t&&t.length?this.write(t):"";return this.lastNeed?e+"\uFFFD":e}function T2t(t,e){if((t.length-e)%2===0){var r=t.toString("utf16le",e);if(r){var o=r.charCodeAt(r.length-1);if(o>=55296&&o<=56319)return this.lastNeed=2,this.lastTotal=4,this.lastChar[0]=t[t.length-2],this.lastChar[1]=t[t.length-1],r.slice(0,-1)}return r}return this.lastNeed=1,this.lastTotal=2,this.lastChar[0]=t[t.length-1],t.toString("utf16le",e,t.length-1)}function L2t(t){var e=t&&t.length?this.write(t):"";if(this.lastNeed){var r=this.lastTotal-this.lastNeed;return e+this.lastChar.toString("utf16le",0,r)}return e}function N2t(t,e){var r=(t.length-e)%3;return r===0?t.toString("base64",e):(this.lastNeed=3-r,this.lastTotal=3,r===1?this.lastChar[0]=t[t.length-1]:(this.lastChar[0]=t[t.length-2],this.lastChar[1]=t[t.length-1]),t.toString("base64",e,t.length-r))}function O2t(t){var e=t&&t.length?this.write(t):"";return this.lastNeed?e+this.lastChar.toString("base64",0,3-this.lastNeed):e}function M2t(t){return t.toString(this.encoding)}function U2t(t){return t&&t.length?this.write(t):""}});var NQ=_((w$t,s2e)=>{"use strict";var n2e=b0().codes.ERR_STREAM_PREMATURE_CLOSE;function _2t(t){var e=!1;return function(){if(!e){e=!0;for(var r=arguments.length,o=new Array(r),a=0;a<r;a++)o[a]=arguments[a];t.apply(this,o)}}}function H2t(){}function j2t(t){return t.setHeader&&typeof t.abort=="function"}function i2e(t,e,r){if(typeof e=="function")return i2e(t,null,e);e||(e={}),r=_2t(r||H2t);var o=e.readable||e.readable!==!1&&t.readable,a=e.writable||e.writable!==!1&&t.writable,n=function(){t.writable||A()},u=t._writableState&&t._writableState.finished,A=function(){a=!1,u=!0,o||r.call(t)},p=t._readableState&&t._readableState.endEmitted,h=function(){o=!1,p=!0,a||r.call(t)},C=function(E){r.call(t,E)},I=function(){var E;if(o&&!p)return(!t._readableState||!t._readableState.ended)&&(E=new n2e),r.call(t,E);if(a&&!u)return(!t._writableState||!t._writableState.ended)&&(E=new n2e),r.call(t,E)},v=function(){t.req.on("finish",A)};return j2t(t)?(t.on("complete",A),t.on("abort",I),t.req?v():t.on("request",v)):a&&!t._writableState&&(t.on("end",n),t.on("close",n)),t.on("end",h),t.on("finish",A),e.error!==!1&&t.on("error",C),t.on("close",I),function(){t.removeListener("complete",A),t.removeListener("abort",I),t.removeListener("request",v),t.req&&t.req.removeListener("finish",A),t.removeListener("end",n),t.removeListener("close",n),t.removeListener("finish",A),t.removeListener("end",h),t.removeListener("error",C),t.removeListener("close",I)}}s2e.exports=i2e});var a2e=_((I$t,o2e)=>{"use strict";var OQ;function F0(t,e,r){return e in t?Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[e]=r,t}var q2t=NQ(),R0=Symbol("lastResolve"),Cm=Symbol("lastReject"),Ev=Symbol("error"),MQ=Symbol("ended"),wm=Symbol("lastPromise"),Gq=Symbol("handlePromise"),Im=Symbol("stream");function T0(t,e){return{value:t,done:e}}function G2t(t){var e=t[R0];if(e!==null){var r=t[Im].read();r!==null&&(t[wm]=null,t[R0]=null,t[Cm]=null,e(T0(r,!1)))}}function Y2t(t){process.nextTick(G2t,t)}function W2t(t,e){return function(r,o){t.then(function(){if(e[MQ]){r(T0(void 0,!0));return}e[Gq](r,o)},o)}}var K2t=Object.getPrototypeOf(function(){}),V2t=Object.setPrototypeOf((OQ={get stream(){return this[Im]},next:function(){var e=this,r=this[Ev];if(r!==null)return Promise.reject(r);if(this[MQ])return Promise.resolve(T0(void 0,!0));if(this[Im].destroyed)return new Promise(function(u,A){process.nextTick(function(){e[Ev]?A(e[Ev]):u(T0(void 0,!0))})});var o=this[wm],a;if(o)a=new Promise(W2t(o,this));else{var n=this[Im].read();if(n!==null)return Promise.resolve(T0(n,!1));a=new Promise(this[Gq])}return this[wm]=a,a}},F0(OQ,Symbol.asyncIterator,function(){return this}),F0(OQ,"return",function(){var e=this;return new Promise(function(r,o){e[Im].destroy(null,function(a){if(a){o(a);return}r(T0(void 0,!0))})})}),OQ),K2t),z2t=function(e){var r,o=Object.create(V2t,(r={},F0(r,Im,{value:e,writable:!0}),F0(r,R0,{value:null,writable:!0}),F0(r,Cm,{value:null,writable:!0}),F0(r,Ev,{value:null,writable:!0}),F0(r,MQ,{value:e._readableState.endEmitted,writable:!0}),F0(r,Gq,{value:function(n,u){var A=o[Im].read();A?(o[wm]=null,o[R0]=null,o[Cm]=null,n(T0(A,!1))):(o[R0]=n,o[Cm]=u)},writable:!0}),r));return o[wm]=null,q2t(e,function(a){if(a&&a.code!=="ERR_STREAM_PREMATURE_CLOSE"){var n=o[Cm];n!==null&&(o[wm]=null,o[R0]=null,o[Cm]=null,n(a)),o[Ev]=a;return}var u=o[R0];u!==null&&(o[wm]=null,o[R0]=null,o[Cm]=null,u(T0(void 0,!0))),o[MQ]=!0}),e.on("readable",Y2t.bind(null,o)),o};o2e.exports=z2t});var A2e=_((B$t,u2e)=>{"use strict";function l2e(t,e,r,o,a,n,u){try{var A=t[n](u),p=A.value}catch(h){r(h);return}A.done?e(p):Promise.resolve(p).then(o,a)}function J2t(t){return function(){var e=this,r=arguments;return new Promise(function(o,a){var n=t.apply(e,r);function u(p){l2e(n,o,a,u,A,"next",p)}function A(p){l2e(n,o,a,u,A,"throw",p)}u(void 0)})}}function c2e(t,e){var r=Object.keys(t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(t);e&&(o=o.filter(function(a){return Object.getOwnPropertyDescriptor(t,a).enumerable})),r.push.apply(r,o)}return r}function X2t(t){for(var e=1;e<arguments.length;e++){var r=arguments[e]!=null?arguments[e]:{};e%2?c2e(Object(r),!0).forEach(function(o){Z2t(t,o,r[o])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(r)):c2e(Object(r)).forEach(function(o){Object.defineProperty(t,o,Object.getOwnPropertyDescriptor(r,o))})}return t}function Z2t(t,e,r){return e in t?Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[e]=r,t}var $2t=b0().codes.ERR_INVALID_ARG_TYPE;function eBt(t,e,r){var o;if(e&&typeof e.next=="function")o=e;else if(e&&e[Symbol.asyncIterator])o=e[Symbol.asyncIterator]();else if(e&&e[Symbol.iterator])o=e[Symbol.iterator]();else throw new $2t("iterable",["Iterable"],e);var a=new t(X2t({objectMode:!0},r)),n=!1;a._read=function(){n||(n=!0,u())};function u(){return A.apply(this,arguments)}function A(){return A=J2t(function*(){try{var p=yield o.next(),h=p.value,C=p.done;C?a.push(null):a.push(yield h)?u():n=!1}catch(I){a.destroy(I)}}),A.apply(this,arguments)}return a}u2e.exports=eBt});var Uq=_((D$t,w2e)=>{"use strict";w2e.exports=mn;var ZC;mn.ReadableState=g2e;var v$t=Be("events").EventEmitter,h2e=function(e,r){return e.listeners(r).length},wv=Pq(),UQ=Be("buffer").Buffer,tBt=global.Uint8Array||function(){};function rBt(t){return UQ.from(t)}function nBt(t){return UQ.isBuffer(t)||t instanceof tBt}var Yq=Be("util"),$r;Yq&&Yq.debuglog?$r=Yq.debuglog("stream"):$r=function(){};var iBt=T1e(),Zq=bq(),sBt=kq(),oBt=sBt.getHighWaterMark,_Q=b0().codes,aBt=_Q.ERR_INVALID_ARG_TYPE,lBt=_Q.ERR_STREAM_PUSH_AFTER_EOF,cBt=_Q.ERR_METHOD_NOT_IMPLEMENTED,uBt=_Q.ERR_STREAM_UNSHIFT_AFTER_END_EVENT,$C,Wq,Kq;k0()(mn,wv);var Cv=Zq.errorOrDestroy,Vq=["error","close","destroy","pause","resume"];function ABt(t,e,r){if(typeof t.prependListener=="function")return t.prependListener(e,r);!t._events||!t._events[e]?t.on(e,r):Array.isArray(t._events[e])?t._events[e].unshift(r):t._events[e]=[r,t._events[e]]}function g2e(t,e,r){ZC=ZC||Em(),t=t||{},typeof r!="boolean"&&(r=e instanceof ZC),this.objectMode=!!t.objectMode,r&&(this.objectMode=this.objectMode||!!t.readableObjectMode),this.highWaterMark=oBt(this,t,"readableHighWaterMark",r),this.buffer=new iBt,this.length=0,this.pipes=null,this.pipesCount=0,this.flowing=null,this.ended=!1,this.endEmitted=!1,this.reading=!1,this.sync=!0,this.needReadable=!1,this.emittedReadable=!1,this.readableListening=!1,this.resumeScheduled=!1,this.paused=!0,this.emitClose=t.emitClose!==!1,this.autoDestroy=!!t.autoDestroy,this.destroyed=!1,this.defaultEncoding=t.defaultEncoding||"utf8",this.awaitDrain=0,this.readingMore=!1,this.decoder=null,this.encoding=null,t.encoding&&($C||($C=qq().StringDecoder),this.decoder=new $C(t.encoding),this.encoding=t.encoding)}function mn(t){if(ZC=ZC||Em(),!(this instanceof mn))return new mn(t);var e=this instanceof ZC;this._readableState=new g2e(t,this,e),this.readable=!0,t&&(typeof t.read=="function"&&(this._read=t.read),typeof t.destroy=="function"&&(this._destroy=t.destroy)),wv.call(this)}Object.defineProperty(mn.prototype,"destroyed",{enumerable:!1,get:function(){return this._readableState===void 0?!1:this._readableState.destroyed},set:function(e){!this._readableState||(this._readableState.destroyed=e)}});mn.prototype.destroy=Zq.destroy;mn.prototype._undestroy=Zq.undestroy;mn.prototype._destroy=function(t,e){e(t)};mn.prototype.push=function(t,e){var r=this._readableState,o;return r.objectMode?o=!0:typeof t=="string"&&(e=e||r.defaultEncoding,e!==r.encoding&&(t=UQ.from(t,e),e=""),o=!0),d2e(this,t,e,!1,o)};mn.prototype.unshift=function(t){return d2e(this,t,null,!0,!1)};function d2e(t,e,r,o,a){$r("readableAddChunk",e);var n=t._readableState;if(e===null)n.reading=!1,hBt(t,n);else{var u;if(a||(u=fBt(n,e)),u)Cv(t,u);else if(n.objectMode||e&&e.length>0)if(typeof e!="string"&&!n.objectMode&&Object.getPrototypeOf(e)!==UQ.prototype&&(e=rBt(e)),o)n.endEmitted?Cv(t,new uBt):zq(t,n,e,!0);else if(n.ended)Cv(t,new lBt);else{if(n.destroyed)return!1;n.reading=!1,n.decoder&&!r?(e=n.decoder.write(e),n.objectMode||e.length!==0?zq(t,n,e,!1):Xq(t,n)):zq(t,n,e,!1)}else o||(n.reading=!1,Xq(t,n))}return!n.ended&&(n.length<n.highWaterMark||n.length===0)}function zq(t,e,r,o){e.flowing&&e.length===0&&!e.sync?(e.awaitDrain=0,t.emit("data",r)):(e.length+=e.objectMode?1:r.length,o?e.buffer.unshift(r):e.buffer.push(r),e.needReadable&&HQ(t)),Xq(t,e)}function fBt(t,e){var r;return!nBt(e)&&typeof e!="string"&&e!==void 0&&!t.objectMode&&(r=new aBt("chunk",["string","Buffer","Uint8Array"],e)),r}mn.prototype.isPaused=function(){return this._readableState.flowing===!1};mn.prototype.setEncoding=function(t){$C||($C=qq().StringDecoder);var e=new $C(t);this._readableState.decoder=e,this._readableState.encoding=this._readableState.decoder.encoding;for(var r=this._readableState.buffer.head,o="";r!==null;)o+=e.write(r.data),r=r.next;return this._readableState.buffer.clear(),o!==""&&this._readableState.buffer.push(o),this._readableState.length=o.length,this};var f2e=1073741824;function pBt(t){return t>=f2e?t=f2e:(t--,t|=t>>>1,t|=t>>>2,t|=t>>>4,t|=t>>>8,t|=t>>>16,t++),t}function p2e(t,e){return t<=0||e.length===0&&e.ended?0:e.objectMode?1:t!==t?e.flowing&&e.length?e.buffer.head.data.length:e.length:(t>e.highWaterMark&&(e.highWaterMark=pBt(t)),t<=e.length?t:e.ended?e.length:(e.needReadable=!0,0))}mn.prototype.read=function(t){$r("read",t),t=parseInt(t,10);var e=this._readableState,r=t;if(t!==0&&(e.emittedReadable=!1),t===0&&e.needReadable&&((e.highWaterMark!==0?e.length>=e.highWaterMark:e.length>0)||e.ended))return $r("read: emitReadable",e.length,e.ended),e.length===0&&e.ended?Jq(this):HQ(this),null;if(t=p2e(t,e),t===0&&e.ended)return e.length===0&&Jq(this),null;var o=e.needReadable;$r("need readable",o),(e.length===0||e.length-t<e.highWaterMark)&&(o=!0,$r("length less than watermark",o)),e.ended||e.reading?(o=!1,$r("reading or ended",o)):o&&($r("do read"),e.reading=!0,e.sync=!0,e.length===0&&(e.needReadable=!0),this._read(e.highWaterMark),e.sync=!1,e.reading||(t=p2e(r,e)));var a;return t>0?a=E2e(t,e):a=null,a===null?(e.needReadable=e.length<=e.highWaterMark,t=0):(e.length-=t,e.awaitDrain=0),e.length===0&&(e.ended||(e.needReadable=!0),r!==t&&e.ended&&Jq(this)),a!==null&&this.emit("data",a),a};function hBt(t,e){if($r("onEofChunk"),!e.ended){if(e.decoder){var r=e.decoder.end();r&&r.length&&(e.buffer.push(r),e.length+=e.objectMode?1:r.length)}e.ended=!0,e.sync?HQ(t):(e.needReadable=!1,e.emittedReadable||(e.emittedReadable=!0,m2e(t)))}}function HQ(t){var e=t._readableState;$r("emitReadable",e.needReadable,e.emittedReadable),e.needReadable=!1,e.emittedReadable||($r("emitReadable",e.flowing),e.emittedReadable=!0,process.nextTick(m2e,t))}function m2e(t){var e=t._readableState;$r("emitReadable_",e.destroyed,e.length,e.ended),!e.destroyed&&(e.length||e.ended)&&(t.emit("readable"),e.emittedReadable=!1),e.needReadable=!e.flowing&&!e.ended&&e.length<=e.highWaterMark,$q(t)}function Xq(t,e){e.readingMore||(e.readingMore=!0,process.nextTick(gBt,t,e))}function gBt(t,e){for(;!e.reading&&!e.ended&&(e.length<e.highWaterMark||e.flowing&&e.length===0);){var r=e.length;if($r("maybeReadMore read 0"),t.read(0),r===e.length)break}e.readingMore=!1}mn.prototype._read=function(t){Cv(this,new cBt("_read()"))};mn.prototype.pipe=function(t,e){var r=this,o=this._readableState;switch(o.pipesCount){case 0:o.pipes=t;break;case 1:o.pipes=[o.pipes,t];break;default:o.pipes.push(t);break}o.pipesCount+=1,$r("pipe count=%d opts=%j",o.pipesCount,e);var a=(!e||e.end!==!1)&&t!==process.stdout&&t!==process.stderr,n=a?A:F;o.endEmitted?process.nextTick(n):r.once("end",n),t.on("unpipe",u);function u(N,U){$r("onunpipe"),N===r&&U&&U.hasUnpiped===!1&&(U.hasUnpiped=!0,C())}function A(){$r("onend"),t.end()}var p=dBt(r);t.on("drain",p);var h=!1;function C(){$r("cleanup"),t.removeListener("close",b),t.removeListener("finish",E),t.removeListener("drain",p),t.removeListener("error",v),t.removeListener("unpipe",u),r.removeListener("end",A),r.removeListener("end",F),r.removeListener("data",I),h=!0,o.awaitDrain&&(!t._writableState||t._writableState.needDrain)&&p()}r.on("data",I);function I(N){$r("ondata");var U=t.write(N);$r("dest.write",U),U===!1&&((o.pipesCount===1&&o.pipes===t||o.pipesCount>1&&C2e(o.pipes,t)!==-1)&&!h&&($r("false write response, pause",o.awaitDrain),o.awaitDrain++),r.pause())}function v(N){$r("onerror",N),F(),t.removeListener("error",v),h2e(t,"error")===0&&Cv(t,N)}ABt(t,"error",v);function b(){t.removeListener("finish",E),F()}t.once("close",b);function E(){$r("onfinish"),t.removeListener("close",b),F()}t.once("finish",E);function F(){$r("unpipe"),r.unpipe(t)}return t.emit("pipe",r),o.flowing||($r("pipe resume"),r.resume()),t};function dBt(t){return function(){var r=t._readableState;$r("pipeOnDrain",r.awaitDrain),r.awaitDrain&&r.awaitDrain--,r.awaitDrain===0&&h2e(t,"data")&&(r.flowing=!0,$q(t))}}mn.prototype.unpipe=function(t){var e=this._readableState,r={hasUnpiped:!1};if(e.pipesCount===0)return this;if(e.pipesCount===1)return t&&t!==e.pipes?this:(t||(t=e.pipes),e.pipes=null,e.pipesCount=0,e.flowing=!1,t&&t.emit("unpipe",this,r),this);if(!t){var o=e.pipes,a=e.pipesCount;e.pipes=null,e.pipesCount=0,e.flowing=!1;for(var n=0;n<a;n++)o[n].emit("unpipe",this,{hasUnpiped:!1});return this}var u=C2e(e.pipes,t);return u===-1?this:(e.pipes.splice(u,1),e.pipesCount-=1,e.pipesCount===1&&(e.pipes=e.pipes[0]),t.emit("unpipe",this,r),this)};mn.prototype.on=function(t,e){var r=wv.prototype.on.call(this,t,e),o=this._readableState;return t==="data"?(o.readableListening=this.listenerCount("readable")>0,o.flowing!==!1&&this.resume()):t==="readable"&&!o.endEmitted&&!o.readableListening&&(o.readableListening=o.needReadable=!0,o.flowing=!1,o.emittedReadable=!1,$r("on readable",o.length,o.reading),o.length?HQ(this):o.reading||process.nextTick(mBt,this)),r};mn.prototype.addListener=mn.prototype.on;mn.prototype.removeListener=function(t,e){var r=wv.prototype.removeListener.call(this,t,e);return t==="readable"&&process.nextTick(y2e,this),r};mn.prototype.removeAllListeners=function(t){var e=wv.prototype.removeAllListeners.apply(this,arguments);return(t==="readable"||t===void 0)&&process.nextTick(y2e,this),e};function y2e(t){var e=t._readableState;e.readableListening=t.listenerCount("readable")>0,e.resumeScheduled&&!e.paused?e.flowing=!0:t.listenerCount("data")>0&&t.resume()}function mBt(t){$r("readable nexttick read 0"),t.read(0)}mn.prototype.resume=function(){var t=this._readableState;return t.flowing||($r("resume"),t.flowing=!t.readableListening,yBt(this,t)),t.paused=!1,this};function yBt(t,e){e.resumeScheduled||(e.resumeScheduled=!0,process.nextTick(EBt,t,e))}function EBt(t,e){$r("resume",e.reading),e.reading||t.read(0),e.resumeScheduled=!1,t.emit("resume"),$q(t),e.flowing&&!e.reading&&t.read(0)}mn.prototype.pause=function(){return $r("call pause flowing=%j",this._readableState.flowing),this._readableState.flowing!==!1&&($r("pause"),this._readableState.flowing=!1,this.emit("pause")),this._readableState.paused=!0,this};function $q(t){var e=t._readableState;for($r("flow",e.flowing);e.flowing&&t.read()!==null;);}mn.prototype.wrap=function(t){var e=this,r=this._readableState,o=!1;t.on("end",function(){if($r("wrapped end"),r.decoder&&!r.ended){var u=r.decoder.end();u&&u.length&&e.push(u)}e.push(null)}),t.on("data",function(u){if($r("wrapped data"),r.decoder&&(u=r.decoder.write(u)),!(r.objectMode&&u==null)&&!(!r.objectMode&&(!u||!u.length))){var A=e.push(u);A||(o=!0,t.pause())}});for(var a in t)this[a]===void 0&&typeof t[a]=="function"&&(this[a]=function(A){return function(){return t[A].apply(t,arguments)}}(a));for(var n=0;n<Vq.length;n++)t.on(Vq[n],this.emit.bind(this,Vq[n]));return this._read=function(u){$r("wrapped _read",u),o&&(o=!1,t.resume())},this};typeof Symbol=="function"&&(mn.prototype[Symbol.asyncIterator]=function(){return Wq===void 0&&(Wq=a2e()),Wq(this)});Object.defineProperty(mn.prototype,"readableHighWaterMark",{enumerable:!1,get:function(){return this._readableState.highWaterMark}});Object.defineProperty(mn.prototype,"readableBuffer",{enumerable:!1,get:function(){return this._readableState&&this._readableState.buffer}});Object.defineProperty(mn.prototype,"readableFlowing",{enumerable:!1,get:function(){return this._readableState.flowing},set:function(e){this._readableState&&(this._readableState.flowing=e)}});mn._fromList=E2e;Object.defineProperty(mn.prototype,"readableLength",{enumerable:!1,get:function(){return this._readableState.length}});function E2e(t,e){if(e.length===0)return null;var r;return e.objectMode?r=e.buffer.shift():!t||t>=e.length?(e.decoder?r=e.buffer.join(""):e.buffer.length===1?r=e.buffer.first():r=e.buffer.concat(e.length),e.buffer.clear()):r=e.buffer.consume(t,e.decoder),r}function Jq(t){var e=t._readableState;$r("endReadable",e.endEmitted),e.endEmitted||(e.ended=!0,process.nextTick(CBt,e,t))}function CBt(t,e){if($r("endReadableNT",t.endEmitted,t.length),!t.endEmitted&&t.length===0&&(t.endEmitted=!0,e.readable=!1,e.emit("end"),t.autoDestroy)){var r=e._writableState;(!r||r.autoDestroy&&r.finished)&&e.destroy()}}typeof Symbol=="function"&&(mn.from=function(t,e){return Kq===void 0&&(Kq=A2e()),Kq(mn,t,e)});function C2e(t,e){for(var r=0,o=t.length;r<o;r++)if(t[r]===e)return r;return-1}});var eG=_((P$t,B2e)=>{"use strict";B2e.exports=np;var jQ=b0().codes,wBt=jQ.ERR_METHOD_NOT_IMPLEMENTED,IBt=jQ.ERR_MULTIPLE_CALLBACK,BBt=jQ.ERR_TRANSFORM_ALREADY_TRANSFORMING,vBt=jQ.ERR_TRANSFORM_WITH_LENGTH_0,qQ=Em();k0()(np,qQ);function DBt(t,e){var r=this._transformState;r.transforming=!1;var o=r.writecb;if(o===null)return this.emit("error",new IBt);r.writechunk=null,r.writecb=null,e!=null&&this.push(e),o(t);var a=this._readableState;a.reading=!1,(a.needReadable||a.length<a.highWaterMark)&&this._read(a.highWaterMark)}function np(t){if(!(this instanceof np))return new np(t);qQ.call(this,t),this._transformState={afterTransform:DBt.bind(this),needTransform:!1,transforming:!1,writecb:null,writechunk:null,writeencoding:null},this._readableState.needReadable=!0,this._readableState.sync=!1,t&&(typeof t.transform=="function"&&(this._transform=t.transform),typeof t.flush=="function"&&(this._flush=t.flush)),this.on("prefinish",PBt)}function PBt(){var t=this;typeof this._flush=="function"&&!this._readableState.destroyed?this._flush(function(e,r){I2e(t,e,r)}):I2e(this,null,null)}np.prototype.push=function(t,e){return this._transformState.needTransform=!1,qQ.prototype.push.call(this,t,e)};np.prototype._transform=function(t,e,r){r(new wBt("_transform()"))};np.prototype._write=function(t,e,r){var o=this._transformState;if(o.writecb=r,o.writechunk=t,o.writeencoding=e,!o.transforming){var a=this._readableState;(o.needTransform||a.needReadable||a.length<a.highWaterMark)&&this._read(a.highWaterMark)}};np.prototype._read=function(t){var e=this._transformState;e.writechunk!==null&&!e.transforming?(e.transforming=!0,this._transform(e.writechunk,e.writeencoding,e.afterTransform)):e.needTransform=!0};np.prototype._destroy=function(t,e){qQ.prototype._destroy.call(this,t,function(r){e(r)})};function I2e(t,e,r){if(e)return t.emit("error",e);if(r!=null&&t.push(r),t._writableState.length)throw new vBt;if(t._transformState.transforming)throw new BBt;return t.push(null)}});var P2e=_((S$t,D2e)=>{"use strict";D2e.exports=Iv;var v2e=eG();k0()(Iv,v2e);function Iv(t){if(!(this instanceof Iv))return new Iv(t);v2e.call(this,t)}Iv.prototype._transform=function(t,e,r){r(null,t)}});var Q2e=_((x$t,k2e)=>{"use strict";var tG;function SBt(t){var e=!1;return function(){e||(e=!0,t.apply(void 0,arguments))}}var b2e=b0().codes,xBt=b2e.ERR_MISSING_ARGS,bBt=b2e.ERR_STREAM_DESTROYED;function S2e(t){if(t)throw t}function kBt(t){return t.setHeader&&typeof t.abort=="function"}function QBt(t,e,r,o){o=SBt(o);var a=!1;t.on("close",function(){a=!0}),tG===void 0&&(tG=NQ()),tG(t,{readable:e,writable:r},function(u){if(u)return o(u);a=!0,o()});var n=!1;return function(u){if(!a&&!n){if(n=!0,kBt(t))return t.abort();if(typeof t.destroy=="function")return t.destroy();o(u||new bBt("pipe"))}}}function x2e(t){t()}function FBt(t,e){return t.pipe(e)}function RBt(t){return!t.length||typeof t[t.length-1]!="function"?S2e:t.pop()}function TBt(){for(var t=arguments.length,e=new Array(t),r=0;r<t;r++)e[r]=arguments[r];var o=RBt(e);if(Array.isArray(e[0])&&(e=e[0]),e.length<2)throw new xBt("streams");var a,n=e.map(function(u,A){var p=A<e.length-1,h=A>0;return QBt(u,p,h,function(C){a||(a=C),C&&n.forEach(x2e),!p&&(n.forEach(x2e),o(a))})});return e.reduce(FBt)}k2e.exports=TBt});var ew=_((lc,vv)=>{var Bv=Be("stream");process.env.READABLE_STREAM==="disable"&&Bv?(vv.exports=Bv.Readable,Object.assign(vv.exports,Bv),vv.exports.Stream=Bv):(lc=vv.exports=Uq(),lc.Stream=Bv||lc,lc.Readable=lc,lc.Writable=Nq(),lc.Duplex=Em(),lc.Transform=eG(),lc.PassThrough=P2e(),lc.finished=NQ(),lc.pipeline=Q2e())});var T2e=_((b$t,R2e)=>{"use strict";var{Buffer:lu}=Be("buffer"),F2e=Symbol.for("BufferList");function ni(t){if(!(this instanceof ni))return new ni(t);ni._init.call(this,t)}ni._init=function(e){Object.defineProperty(this,F2e,{value:!0}),this._bufs=[],this.length=0,e&&this.append(e)};ni.prototype._new=function(e){return new ni(e)};ni.prototype._offset=function(e){if(e===0)return[0,0];let r=0;for(let o=0;o<this._bufs.length;o++){let a=r+this._bufs[o].length;if(e<a||o===this._bufs.length-1)return[o,e-r];r=a}};ni.prototype._reverseOffset=function(t){let e=t[0],r=t[1];for(let o=0;o<e;o++)r+=this._bufs[o].length;return r};ni.prototype.get=function(e){if(e>this.length||e<0)return;let r=this._offset(e);return this._bufs[r[0]][r[1]]};ni.prototype.slice=function(e,r){return typeof e=="number"&&e<0&&(e+=this.length),typeof r=="number"&&r<0&&(r+=this.length),this.copy(null,0,e,r)};ni.prototype.copy=function(e,r,o,a){if((typeof o!="number"||o<0)&&(o=0),(typeof a!="number"||a>this.length)&&(a=this.length),o>=this.length||a<=0)return e||lu.alloc(0);let n=!!e,u=this._offset(o),A=a-o,p=A,h=n&&r||0,C=u[1];if(o===0&&a===this.length){if(!n)return this._bufs.length===1?this._bufs[0]:lu.concat(this._bufs,this.length);for(let I=0;I<this._bufs.length;I++)this._bufs[I].copy(e,h),h+=this._bufs[I].length;return e}if(p<=this._bufs[u[0]].length-C)return n?this._bufs[u[0]].copy(e,r,C,C+p):this._bufs[u[0]].slice(C,C+p);n||(e=lu.allocUnsafe(A));for(let I=u[0];I<this._bufs.length;I++){let v=this._bufs[I].length-C;if(p>v)this._bufs[I].copy(e,h,C),h+=v;else{this._bufs[I].copy(e,h,C,C+p),h+=v;break}p-=v,C&&(C=0)}return e.length>h?e.slice(0,h):e};ni.prototype.shallowSlice=function(e,r){if(e=e||0,r=typeof r!="number"?this.length:r,e<0&&(e+=this.length),r<0&&(r+=this.length),e===r)return this._new();let o=this._offset(e),a=this._offset(r),n=this._bufs.slice(o[0],a[0]+1);return a[1]===0?n.pop():n[n.length-1]=n[n.length-1].slice(0,a[1]),o[1]!==0&&(n[0]=n[0].slice(o[1])),this._new(n)};ni.prototype.toString=function(e,r,o){return this.slice(r,o).toString(e)};ni.prototype.consume=function(e){if(e=Math.trunc(e),Number.isNaN(e)||e<=0)return this;for(;this._bufs.length;)if(e>=this._bufs[0].length)e-=this._bufs[0].length,this.length-=this._bufs[0].length,this._bufs.shift();else{this._bufs[0]=this._bufs[0].slice(e),this.length-=e;break}return this};ni.prototype.duplicate=function(){let e=this._new();for(let r=0;r<this._bufs.length;r++)e.append(this._bufs[r]);return e};ni.prototype.append=function(e){if(e==null)return this;if(e.buffer)this._appendBuffer(lu.from(e.buffer,e.byteOffset,e.byteLength));else if(Array.isArray(e))for(let r=0;r<e.length;r++)this.append(e[r]);else if(this._isBufferList(e))for(let r=0;r<e._bufs.length;r++)this.append(e._bufs[r]);else typeof e=="number"&&(e=e.toString()),this._appendBuffer(lu.from(e));return this};ni.prototype._appendBuffer=function(e){this._bufs.push(e),this.length+=e.length};ni.prototype.indexOf=function(t,e,r){if(r===void 0&&typeof e=="string"&&(r=e,e=void 0),typeof t=="function"||Array.isArray(t))throw new TypeError('The "value" argument must be one of type string, Buffer, BufferList, or Uint8Array.');if(typeof t=="number"?t=lu.from([t]):typeof t=="string"?t=lu.from(t,r):this._isBufferList(t)?t=t.slice():Array.isArray(t.buffer)?t=lu.from(t.buffer,t.byteOffset,t.byteLength):lu.isBuffer(t)||(t=lu.from(t)),e=Number(e||0),isNaN(e)&&(e=0),e<0&&(e=this.length+e),e<0&&(e=0),t.length===0)return e>this.length?this.length:e;let o=this._offset(e),a=o[0],n=o[1];for(;a<this._bufs.length;a++){let u=this._bufs[a];for(;n<u.length;)if(u.length-n>=t.length){let p=u.indexOf(t,n);if(p!==-1)return this._reverseOffset([a,p]);n=u.length-t.length+1}else{let p=this._reverseOffset([a,n]);if(this._match(p,t))return p;n++}n=0}return-1};ni.prototype._match=function(t,e){if(this.length-t<e.length)return!1;for(let r=0;r<e.length;r++)if(this.get(t+r)!==e[r])return!1;return!0};(function(){let t={readDoubleBE:8,readDoubleLE:8,readFloatBE:4,readFloatLE:4,readInt32BE:4,readInt32LE:4,readUInt32BE:4,readUInt32LE:4,readInt16BE:2,readInt16LE:2,readUInt16BE:2,readUInt16LE:2,readInt8:1,readUInt8:1,readIntBE:null,readIntLE:null,readUIntBE:null,readUIntLE:null};for(let e in t)(function(r){t[r]===null?ni.prototype[r]=function(o,a){return this.slice(o,o+a)[r](0,a)}:ni.prototype[r]=function(o=0){return this.slice(o,o+t[r])[r](0)}})(e)})();ni.prototype._isBufferList=function(e){return e instanceof ni||ni.isBufferList(e)};ni.isBufferList=function(e){return e!=null&&e[F2e]};R2e.exports=ni});var L2e=_((k$t,GQ)=>{"use strict";var rG=ew().Duplex,LBt=k0(),Dv=T2e();function Uo(t){if(!(this instanceof Uo))return new Uo(t);if(typeof t=="function"){this._callback=t;let e=function(o){this._callback&&(this._callback(o),this._callback=null)}.bind(this);this.on("pipe",function(o){o.on("error",e)}),this.on("unpipe",function(o){o.removeListener("error",e)}),t=null}Dv._init.call(this,t),rG.call(this)}LBt(Uo,rG);Object.assign(Uo.prototype,Dv.prototype);Uo.prototype._new=function(e){return new Uo(e)};Uo.prototype._write=function(e,r,o){this._appendBuffer(e),typeof o=="function"&&o()};Uo.prototype._read=function(e){if(!this.length)return this.push(null);e=Math.min(e,this.length),this.push(this.slice(0,e)),this.consume(e)};Uo.prototype.end=function(e){rG.prototype.end.call(this,e),this._callback&&(this._callback(null,this.slice()),this._callback=null)};Uo.prototype._destroy=function(e,r){this._bufs.length=0,this.length=0,r(e)};Uo.prototype._isBufferList=function(e){return e instanceof Uo||e instanceof Dv||Uo.isBufferList(e)};Uo.isBufferList=Dv.isBufferList;GQ.exports=Uo;GQ.exports.BufferListStream=Uo;GQ.exports.BufferList=Dv});var sG=_(rw=>{var NBt=Buffer.alloc,OBt="0000000000000000000",MBt="7777777777777777777",N2e="0".charCodeAt(0),O2e=Buffer.from("ustar\0","binary"),UBt=Buffer.from("00","binary"),_Bt=Buffer.from("ustar ","binary"),HBt=Buffer.from(" \0","binary"),jBt=parseInt("7777",8),Pv=257,iG=263,qBt=function(t,e,r){return typeof t!="number"?r:(t=~~t,t>=e?e:t>=0||(t+=e,t>=0)?t:0)},GBt=function(t){switch(t){case 0:return"file";case 1:return"link";case 2:return"symlink";case 3:return"character-device";case 4:return"block-device";case 5:return"directory";case 6:return"fifo";case 7:return"contiguous-file";case 72:return"pax-header";case 55:return"pax-global-header";case 27:return"gnu-long-link-path";case 28:case 30:return"gnu-long-path"}return null},YBt=function(t){switch(t){case"file":return 0;case"link":return 1;case"symlink":return 2;case"character-device":return 3;case"block-device":return 4;case"directory":return 5;case"fifo":return 6;case"contiguous-file":return 7;case"pax-header":return 72}return 0},M2e=function(t,e,r,o){for(;r<o;r++)if(t[r]===e)return r;return o},U2e=function(t){for(var e=256,r=0;r<148;r++)e+=t[r];for(var o=156;o<512;o++)e+=t[o];return e},L0=function(t,e){return t=t.toString(8),t.length>e?MBt.slice(0,e)+" ":OBt.slice(0,e-t.length)+t+" "};function WBt(t){var e;if(t[0]===128)e=!0;else if(t[0]===255)e=!1;else return null;for(var r=[],o=t.length-1;o>0;o--){var a=t[o];e?r.push(a):r.push(255-a)}var n=0,u=r.length;for(o=0;o<u;o++)n+=r[o]*Math.pow(256,o);return e?n:-1*n}var N0=function(t,e,r){if(t=t.slice(e,e+r),e=0,t[e]&128)return WBt(t);for(;e<t.length&&t[e]===32;)e++;for(var o=qBt(M2e(t,32,e,t.length),t.length,t.length);e<o&&t[e]===0;)e++;return o===e?0:parseInt(t.slice(e,o).toString(),8)},tw=function(t,e,r,o){return t.slice(e,M2e(t,0,e,e+r)).toString(o)},nG=function(t){var e=Buffer.byteLength(t),r=Math.floor(Math.log(e)/Math.log(10))+1;return e+r>=Math.pow(10,r)&&r++,e+r+t};rw.decodeLongPath=function(t,e){return tw(t,0,t.length,e)};rw.encodePax=function(t){var e="";t.name&&(e+=nG(" path="+t.name+` -`)),t.linkname&&(e+=nG(" linkpath="+t.linkname+` -`));var r=t.pax;if(r)for(var o in r)e+=nG(" "+o+"="+r[o]+` -`);return Buffer.from(e)};rw.decodePax=function(t){for(var e={};t.length;){for(var r=0;r<t.length&&t[r]!==32;)r++;var o=parseInt(t.slice(0,r).toString(),10);if(!o)return e;var a=t.slice(r+1,o-1).toString(),n=a.indexOf("=");if(n===-1)return e;e[a.slice(0,n)]=a.slice(n+1),t=t.slice(o)}return e};rw.encode=function(t){var e=NBt(512),r=t.name,o="";if(t.typeflag===5&&r[r.length-1]!=="/"&&(r+="/"),Buffer.byteLength(r)!==r.length)return null;for(;Buffer.byteLength(r)>100;){var a=r.indexOf("/");if(a===-1)return null;o+=o?"/"+r.slice(0,a):r.slice(0,a),r=r.slice(a+1)}return Buffer.byteLength(r)>100||Buffer.byteLength(o)>155||t.linkname&&Buffer.byteLength(t.linkname)>100?null:(e.write(r),e.write(L0(t.mode&jBt,6),100),e.write(L0(t.uid,6),108),e.write(L0(t.gid,6),116),e.write(L0(t.size,11),124),e.write(L0(t.mtime.getTime()/1e3|0,11),136),e[156]=N2e+YBt(t.type),t.linkname&&e.write(t.linkname,157),O2e.copy(e,Pv),UBt.copy(e,iG),t.uname&&e.write(t.uname,265),t.gname&&e.write(t.gname,297),e.write(L0(t.devmajor||0,6),329),e.write(L0(t.devminor||0,6),337),o&&e.write(o,345),e.write(L0(U2e(e),6),148),e)};rw.decode=function(t,e,r){var o=t[156]===0?0:t[156]-N2e,a=tw(t,0,100,e),n=N0(t,100,8),u=N0(t,108,8),A=N0(t,116,8),p=N0(t,124,12),h=N0(t,136,12),C=GBt(o),I=t[157]===0?null:tw(t,157,100,e),v=tw(t,265,32),b=tw(t,297,32),E=N0(t,329,8),F=N0(t,337,8),N=U2e(t);if(N===8*32)return null;if(N!==N0(t,148,8))throw new Error("Invalid tar header. Maybe the tar is corrupted or it needs to be gunzipped?");if(O2e.compare(t,Pv,Pv+6)===0)t[345]&&(a=tw(t,345,155,e)+"/"+a);else if(!(_Bt.compare(t,Pv,Pv+6)===0&&HBt.compare(t,iG,iG+2)===0)){if(!r)throw new Error("Invalid tar header: unknown format.")}return o===0&&a&&a[a.length-1]==="/"&&(o=5),{name:a,mode:n,uid:u,gid:A,size:p,mtime:new Date(1e3*h),type:C,linkname:I,uname:v,gname:b,devmajor:E,devminor:F}}});var W2e=_((F$t,Y2e)=>{var H2e=Be("util"),KBt=L2e(),Sv=sG(),j2e=ew().Writable,q2e=ew().PassThrough,G2e=function(){},_2e=function(t){return t&=511,t&&512-t},VBt=function(t,e){var r=new YQ(t,e);return r.end(),r},zBt=function(t,e){return e.path&&(t.name=e.path),e.linkpath&&(t.linkname=e.linkpath),e.size&&(t.size=parseInt(e.size,10)),t.pax=e,t},YQ=function(t,e){this._parent=t,this.offset=e,q2e.call(this,{autoDestroy:!1})};H2e.inherits(YQ,q2e);YQ.prototype.destroy=function(t){this._parent.destroy(t)};var ip=function(t){if(!(this instanceof ip))return new ip(t);j2e.call(this,t),t=t||{},this._offset=0,this._buffer=KBt(),this._missing=0,this._partial=!1,this._onparse=G2e,this._header=null,this._stream=null,this._overflow=null,this._cb=null,this._locked=!1,this._destroyed=!1,this._pax=null,this._paxGlobal=null,this._gnuLongPath=null,this._gnuLongLinkPath=null;var e=this,r=e._buffer,o=function(){e._continue()},a=function(v){if(e._locked=!1,v)return e.destroy(v);e._stream||o()},n=function(){e._stream=null;var v=_2e(e._header.size);v?e._parse(v,u):e._parse(512,I),e._locked||o()},u=function(){e._buffer.consume(_2e(e._header.size)),e._parse(512,I),o()},A=function(){var v=e._header.size;e._paxGlobal=Sv.decodePax(r.slice(0,v)),r.consume(v),n()},p=function(){var v=e._header.size;e._pax=Sv.decodePax(r.slice(0,v)),e._paxGlobal&&(e._pax=Object.assign({},e._paxGlobal,e._pax)),r.consume(v),n()},h=function(){var v=e._header.size;this._gnuLongPath=Sv.decodeLongPath(r.slice(0,v),t.filenameEncoding),r.consume(v),n()},C=function(){var v=e._header.size;this._gnuLongLinkPath=Sv.decodeLongPath(r.slice(0,v),t.filenameEncoding),r.consume(v),n()},I=function(){var v=e._offset,b;try{b=e._header=Sv.decode(r.slice(0,512),t.filenameEncoding,t.allowUnknownFormat)}catch(E){e.emit("error",E)}if(r.consume(512),!b){e._parse(512,I),o();return}if(b.type==="gnu-long-path"){e._parse(b.size,h),o();return}if(b.type==="gnu-long-link-path"){e._parse(b.size,C),o();return}if(b.type==="pax-global-header"){e._parse(b.size,A),o();return}if(b.type==="pax-header"){e._parse(b.size,p),o();return}if(e._gnuLongPath&&(b.name=e._gnuLongPath,e._gnuLongPath=null),e._gnuLongLinkPath&&(b.linkname=e._gnuLongLinkPath,e._gnuLongLinkPath=null),e._pax&&(e._header=b=zBt(b,e._pax),e._pax=null),e._locked=!0,!b.size||b.type==="directory"){e._parse(512,I),e.emit("entry",b,VBt(e,v),a);return}e._stream=new YQ(e,v),e.emit("entry",b,e._stream,a),e._parse(b.size,n),o()};this._onheader=I,this._parse(512,I)};H2e.inherits(ip,j2e);ip.prototype.destroy=function(t){this._destroyed||(this._destroyed=!0,t&&this.emit("error",t),this.emit("close"),this._stream&&this._stream.emit("close"))};ip.prototype._parse=function(t,e){this._destroyed||(this._offset+=t,this._missing=t,e===this._onheader&&(this._partial=!1),this._onparse=e)};ip.prototype._continue=function(){if(!this._destroyed){var t=this._cb;this._cb=G2e,this._overflow?this._write(this._overflow,void 0,t):t()}};ip.prototype._write=function(t,e,r){if(!this._destroyed){var o=this._stream,a=this._buffer,n=this._missing;if(t.length&&(this._partial=!0),t.length<n)return this._missing-=t.length,this._overflow=null,o?o.write(t,r):(a.append(t),r());this._cb=r,this._missing=0;var u=null;t.length>n&&(u=t.slice(n),t=t.slice(0,n)),o?o.end(t):a.append(t),this._overflow=u,this._onparse()}};ip.prototype._final=function(t){if(this._partial)return this.destroy(new Error("Unexpected end of data"));t()};Y2e.exports=ip});var V2e=_((R$t,K2e)=>{K2e.exports=Be("fs").constants||Be("constants")});var $2e=_((T$t,Z2e)=>{var nw=V2e(),z2e=NM(),KQ=k0(),JBt=Buffer.alloc,J2e=ew().Readable,iw=ew().Writable,XBt=Be("string_decoder").StringDecoder,WQ=sG(),ZBt=parseInt("755",8),$Bt=parseInt("644",8),X2e=JBt(1024),aG=function(){},oG=function(t,e){e&=511,e&&t.push(X2e.slice(0,512-e))};function evt(t){switch(t&nw.S_IFMT){case nw.S_IFBLK:return"block-device";case nw.S_IFCHR:return"character-device";case nw.S_IFDIR:return"directory";case nw.S_IFIFO:return"fifo";case nw.S_IFLNK:return"symlink"}return"file"}var VQ=function(t){iw.call(this),this.written=0,this._to=t,this._destroyed=!1};KQ(VQ,iw);VQ.prototype._write=function(t,e,r){if(this.written+=t.length,this._to.push(t))return r();this._to._drain=r};VQ.prototype.destroy=function(){this._destroyed||(this._destroyed=!0,this.emit("close"))};var zQ=function(){iw.call(this),this.linkname="",this._decoder=new XBt("utf-8"),this._destroyed=!1};KQ(zQ,iw);zQ.prototype._write=function(t,e,r){this.linkname+=this._decoder.write(t),r()};zQ.prototype.destroy=function(){this._destroyed||(this._destroyed=!0,this.emit("close"))};var xv=function(){iw.call(this),this._destroyed=!1};KQ(xv,iw);xv.prototype._write=function(t,e,r){r(new Error("No body allowed for this entry"))};xv.prototype.destroy=function(){this._destroyed||(this._destroyed=!0,this.emit("close"))};var EA=function(t){if(!(this instanceof EA))return new EA(t);J2e.call(this,t),this._drain=aG,this._finalized=!1,this._finalizing=!1,this._destroyed=!1,this._stream=null};KQ(EA,J2e);EA.prototype.entry=function(t,e,r){if(this._stream)throw new Error("already piping an entry");if(!(this._finalized||this._destroyed)){typeof e=="function"&&(r=e,e=null),r||(r=aG);var o=this;if((!t.size||t.type==="symlink")&&(t.size=0),t.type||(t.type=evt(t.mode)),t.mode||(t.mode=t.type==="directory"?ZBt:$Bt),t.uid||(t.uid=0),t.gid||(t.gid=0),t.mtime||(t.mtime=new Date),typeof e=="string"&&(e=Buffer.from(e)),Buffer.isBuffer(e)){t.size=e.length,this._encode(t);var a=this.push(e);return oG(o,t.size),a?process.nextTick(r):this._drain=r,new xv}if(t.type==="symlink"&&!t.linkname){var n=new zQ;return z2e(n,function(A){if(A)return o.destroy(),r(A);t.linkname=n.linkname,o._encode(t),r()}),n}if(this._encode(t),t.type!=="file"&&t.type!=="contiguous-file")return process.nextTick(r),new xv;var u=new VQ(this);return this._stream=u,z2e(u,function(A){if(o._stream=null,A)return o.destroy(),r(A);if(u.written!==t.size)return o.destroy(),r(new Error("size mismatch"));oG(o,t.size),o._finalizing&&o.finalize(),r()}),u}};EA.prototype.finalize=function(){if(this._stream){this._finalizing=!0;return}this._finalized||(this._finalized=!0,this.push(X2e),this.push(null))};EA.prototype.destroy=function(t){this._destroyed||(this._destroyed=!0,t&&this.emit("error",t),this.emit("close"),this._stream&&this._stream.destroy&&this._stream.destroy())};EA.prototype._encode=function(t){if(!t.pax){var e=WQ.encode(t);if(e){this.push(e);return}}this._encodePax(t)};EA.prototype._encodePax=function(t){var e=WQ.encodePax({name:t.name,linkname:t.linkname,pax:t.pax}),r={name:"PaxHeader",mode:t.mode,uid:t.uid,gid:t.gid,size:e.length,mtime:t.mtime,type:"pax-header",linkname:t.linkname&&"PaxHeader",uname:t.uname,gname:t.gname,devmajor:t.devmajor,devminor:t.devminor};this.push(WQ.encode(r)),this.push(e),oG(this,e.length),r.size=t.size,r.type=t.type,this.push(WQ.encode(r))};EA.prototype._read=function(t){var e=this._drain;this._drain=aG,e()};Z2e.exports=EA});var eBe=_(lG=>{lG.extract=W2e();lG.pack=$2e()});var fBe=_((eer,ABe)=>{"use strict";var Bm=class{constructor(e,r,o){this.__specs=e||{},Object.keys(this.__specs).forEach(a=>{if(typeof this.__specs[a]=="string"){let n=this.__specs[a],u=this.__specs[n];if(u){let A=u.aliases||[];A.push(a,n),u.aliases=[...new Set(A)],this.__specs[a]=u}else throw new Error(`Alias refers to invalid key: ${n} -> ${a}`)}}),this.__opts=r||{},this.__providers=cBe(o.filter(a=>a!=null&&typeof a=="object")),this.__isFiggyPudding=!0}get(e){return hG(this,e,!0)}get[Symbol.toStringTag](){return"FiggyPudding"}forEach(e,r=this){for(let[o,a]of this.entries())e.call(r,a,o,this)}toJSON(){let e={};return this.forEach((r,o)=>{e[o]=r}),e}*entries(e){for(let o of Object.keys(this.__specs))yield[o,this.get(o)];let r=e||this.__opts.other;if(r){let o=new Set;for(let a of this.__providers){let n=a.entries?a.entries(r):gvt(a);for(let[u,A]of n)r(u)&&!o.has(u)&&(o.add(u),yield[u,A])}}}*[Symbol.iterator](){for(let[e,r]of this.entries())yield[e,r]}*keys(){for(let[e]of this.entries())yield e}*values(){for(let[,e]of this.entries())yield e}concat(...e){return new Proxy(new Bm(this.__specs,this.__opts,cBe(this.__providers).concat(e)),uBe)}};try{let t=Be("util");Bm.prototype[t.inspect.custom]=function(e,r){return this[Symbol.toStringTag]+" "+t.inspect(this.toJSON(),r)}}catch{}function pvt(t){throw Object.assign(new Error(`invalid config key requested: ${t}`),{code:"EBADKEY"})}function hG(t,e,r){let o=t.__specs[e];if(r&&!o&&(!t.__opts.other||!t.__opts.other(e)))pvt(e);else{o||(o={});let a;for(let n of t.__providers){if(a=lBe(e,n),a===void 0&&o.aliases&&o.aliases.length){for(let u of o.aliases)if(u!==e&&(a=lBe(u,n),a!==void 0))break}if(a!==void 0)break}return a===void 0&&o.default!==void 0?typeof o.default=="function"?o.default(t):o.default:a}}function lBe(t,e){let r;return e.__isFiggyPudding?r=hG(e,t,!1):typeof e.get=="function"?r=e.get(t):r=e[t],r}var uBe={has(t,e){return e in t.__specs&&hG(t,e,!1)!==void 0},ownKeys(t){return Object.keys(t.__specs)},get(t,e){return typeof e=="symbol"||e.slice(0,2)==="__"||e in Bm.prototype?t[e]:t.get(e)},set(t,e,r){if(typeof e=="symbol"||e.slice(0,2)==="__")return t[e]=r,!0;throw new Error("figgyPudding options cannot be modified. Use .concat() instead.")},deleteProperty(){throw new Error("figgyPudding options cannot be deleted. Use .concat() and shadow them instead.")}};ABe.exports=hvt;function hvt(t,e){function r(...o){return new Proxy(new Bm(t,e,o),uBe)}return r}function cBe(t){let e=[];return t.forEach(r=>e.unshift(r)),e}function gvt(t){return Object.keys(t).map(e=>[e,t[e]])}});var gBe=_((ter,IA)=>{"use strict";var kv=Be("crypto"),dvt=fBe(),mvt=Be("stream").Transform,pBe=["sha256","sha384","sha512"],yvt=/^[a-z0-9+/]+(?:=?=?)$/i,Evt=/^([^-]+)-([^?]+)([?\S*]*)$/,Cvt=/^([^-]+)-([A-Za-z0-9+/=]{44,88})(\?[\x21-\x7E]*)*$/,wvt=/^[\x21-\x7E]+$/,ia=dvt({algorithms:{default:["sha512"]},error:{default:!1},integrity:{},options:{default:[]},pickAlgorithm:{default:()=>bvt},Promise:{default:()=>Promise},sep:{default:" "},single:{default:!1},size:{},strict:{default:!1}}),M0=class{get isHash(){return!0}constructor(e,r){r=ia(r);let o=!!r.strict;this.source=e.trim();let a=this.source.match(o?Cvt:Evt);if(!a||o&&!pBe.some(u=>u===a[1]))return;this.algorithm=a[1],this.digest=a[2];let n=a[3];this.options=n?n.slice(1).split("?"):[]}hexDigest(){return this.digest&&Buffer.from(this.digest,"base64").toString("hex")}toJSON(){return this.toString()}toString(e){if(e=ia(e),e.strict&&!(pBe.some(o=>o===this.algorithm)&&this.digest.match(yvt)&&(this.options||[]).every(o=>o.match(wvt))))return"";let r=this.options&&this.options.length?`?${this.options.join("?")}`:"";return`${this.algorithm}-${this.digest}${r}`}},vm=class{get isIntegrity(){return!0}toJSON(){return this.toString()}toString(e){e=ia(e);let r=e.sep||" ";return e.strict&&(r=r.replace(/\S+/g," ")),Object.keys(this).map(o=>this[o].map(a=>M0.prototype.toString.call(a,e)).filter(a=>a.length).join(r)).filter(o=>o.length).join(r)}concat(e,r){r=ia(r);let o=typeof e=="string"?e:bv(e,r);return wA(`${this.toString(r)} ${o}`,r)}hexDigest(){return wA(this,{single:!0}).hexDigest()}match(e,r){r=ia(r);let o=wA(e,r),a=o.pickAlgorithm(r);return this[a]&&o[a]&&this[a].find(n=>o[a].find(u=>n.digest===u.digest))||!1}pickAlgorithm(e){e=ia(e);let r=e.pickAlgorithm,o=Object.keys(this);if(!o.length)throw new Error(`No algorithms available for ${JSON.stringify(this.toString())}`);return o.reduce((a,n)=>r(a,n)||a)}};IA.exports.parse=wA;function wA(t,e){if(e=ia(e),typeof t=="string")return gG(t,e);if(t.algorithm&&t.digest){let r=new vm;return r[t.algorithm]=[t],gG(bv(r,e),e)}else return gG(bv(t,e),e)}function gG(t,e){return e.single?new M0(t,e):t.trim().split(/\s+/).reduce((r,o)=>{let a=new M0(o,e);if(a.algorithm&&a.digest){let n=a.algorithm;r[n]||(r[n]=[]),r[n].push(a)}return r},new vm)}IA.exports.stringify=bv;function bv(t,e){return e=ia(e),t.algorithm&&t.digest?M0.prototype.toString.call(t,e):typeof t=="string"?bv(wA(t,e),e):vm.prototype.toString.call(t,e)}IA.exports.fromHex=Ivt;function Ivt(t,e,r){r=ia(r);let o=r.options&&r.options.length?`?${r.options.join("?")}`:"";return wA(`${e}-${Buffer.from(t,"hex").toString("base64")}${o}`,r)}IA.exports.fromData=Bvt;function Bvt(t,e){e=ia(e);let r=e.algorithms,o=e.options&&e.options.length?`?${e.options.join("?")}`:"";return r.reduce((a,n)=>{let u=kv.createHash(n).update(t).digest("base64"),A=new M0(`${n}-${u}${o}`,e);if(A.algorithm&&A.digest){let p=A.algorithm;a[p]||(a[p]=[]),a[p].push(A)}return a},new vm)}IA.exports.fromStream=vvt;function vvt(t,e){e=ia(e);let r=e.Promise||Promise,o=dG(e);return new r((a,n)=>{t.pipe(o),t.on("error",n),o.on("error",n);let u;o.on("integrity",A=>{u=A}),o.on("end",()=>a(u)),o.on("data",()=>{})})}IA.exports.checkData=Dvt;function Dvt(t,e,r){if(r=ia(r),e=wA(e,r),!Object.keys(e).length){if(r.error)throw Object.assign(new Error("No valid integrity hashes to check against"),{code:"EINTEGRITY"});return!1}let o=e.pickAlgorithm(r),a=kv.createHash(o).update(t).digest("base64"),n=wA({algorithm:o,digest:a}),u=n.match(e,r);if(u||!r.error)return u;if(typeof r.size=="number"&&t.length!==r.size){let A=new Error(`data size mismatch when checking ${e}. - Wanted: ${r.size} - Found: ${t.length}`);throw A.code="EBADSIZE",A.found=t.length,A.expected=r.size,A.sri=e,A}else{let A=new Error(`Integrity checksum failed when using ${o}: Wanted ${e}, but got ${n}. (${t.length} bytes)`);throw A.code="EINTEGRITY",A.found=n,A.expected=e,A.algorithm=o,A.sri=e,A}}IA.exports.checkStream=Pvt;function Pvt(t,e,r){r=ia(r);let o=r.Promise||Promise,a=dG(r.concat({integrity:e}));return new o((n,u)=>{t.pipe(a),t.on("error",u),a.on("error",u);let A;a.on("verified",p=>{A=p}),a.on("end",()=>n(A)),a.on("data",()=>{})})}IA.exports.integrityStream=dG;function dG(t){t=ia(t);let e=t.integrity&&wA(t.integrity,t),r=e&&Object.keys(e).length,o=r&&e.pickAlgorithm(t),a=r&&e[o],n=Array.from(new Set(t.algorithms.concat(o?[o]:[]))),u=n.map(kv.createHash),A=0,p=new mvt({transform(h,C,I){A+=h.length,u.forEach(v=>v.update(h,C)),I(null,h,C)}}).on("end",()=>{let h=t.options&&t.options.length?`?${t.options.join("?")}`:"",C=wA(u.map((v,b)=>`${n[b]}-${v.digest("base64")}${h}`).join(" "),t),I=r&&C.match(e,t);if(typeof t.size=="number"&&A!==t.size){let v=new Error(`stream size mismatch when checking ${e}. - Wanted: ${t.size} - Found: ${A}`);v.code="EBADSIZE",v.found=A,v.expected=t.size,v.sri=e,p.emit("error",v)}else if(t.integrity&&!I){let v=new Error(`${e} integrity checksum failed when using ${o}: wanted ${a} but got ${C}. (${A} bytes)`);v.code="EINTEGRITY",v.found=C,v.expected=a,v.algorithm=o,v.sri=e,p.emit("error",v)}else p.emit("size",A),p.emit("integrity",C),I&&p.emit("verified",I)});return p}IA.exports.create=Svt;function Svt(t){t=ia(t);let e=t.algorithms,r=t.options.length?`?${t.options.join("?")}`:"",o=e.map(kv.createHash);return{update:function(a,n){return o.forEach(u=>u.update(a,n)),this},digest:function(a){return e.reduce((u,A)=>{let p=o.shift().digest("base64"),h=new M0(`${A}-${p}${r}`,t);if(h.algorithm&&h.digest){let C=h.algorithm;u[C]||(u[C]=[]),u[C].push(h)}return u},new vm)}}}var xvt=new Set(kv.getHashes()),hBe=["md5","whirlpool","sha1","sha224","sha256","sha384","sha512","sha3","sha3-256","sha3-384","sha3-512","sha3_256","sha3_384","sha3_512"].filter(t=>xvt.has(t));function bvt(t,e){return hBe.indexOf(t.toLowerCase())>=hBe.indexOf(e.toLowerCase())?t:e}});var YBe=_((iir,GBe)=>{var xDt=cN();function bDt(t){return xDt(t)?void 0:t}GBe.exports=bDt});var KBe=_((sir,WBe)=>{var kDt=Hx(),QDt=b8(),FDt=R8(),RDt=jd(),TDt=gd(),LDt=YBe(),NDt=v_(),ODt=x8(),MDt=1,UDt=2,_Dt=4,HDt=NDt(function(t,e){var r={};if(t==null)return r;var o=!1;e=kDt(e,function(n){return n=RDt(n,t),o||(o=n.length>1),n}),TDt(t,ODt(t),r),o&&(r=QDt(r,MDt|UDt|_Dt,LDt));for(var a=e.length;a--;)FDt(r,e[a]);return r});WBe.exports=HDt});Pt();Ye();Pt();var ZBe=Be("child_process"),$Be=$e($g());qt();var uC=new Map([]);var s2={};Vt(s2,{BaseCommand:()=>ut,WorkspaceRequiredError:()=>rr,getCli:()=>ehe,getDynamicLibs:()=>$pe,getPluginConfiguration:()=>fC,openWorkspace:()=>AC,pluginCommands:()=>uC,runExit:()=>nk});qt();var ut=class extends nt{constructor(){super(...arguments);this.cwd=ge.String("--cwd",{hidden:!0})}validateAndExecute(){if(typeof this.cwd<"u")throw new it("The --cwd option is ambiguous when used anywhere else than the very first parameter provided in the command line, before even the command path");return super.validateAndExecute()}};Ye();Pt();qt();var rr=class extends it{constructor(e,r){let o=V.relative(e,r),a=V.join(e,Ot.fileName);super(`This command can only be run from within a workspace of your project (${o} isn't a workspace of ${a}).`)}};Ye();Pt();nA();Ll();x1();qt();var TAt=$e(Jn());Za();var $pe=()=>new Map([["@yarnpkg/cli",s2],["@yarnpkg/core",i2],["@yarnpkg/fslib",Kw],["@yarnpkg/libzip",S1],["@yarnpkg/parsers",tI],["@yarnpkg/shell",F1],["clipanion",fI],["semver",TAt],["typanion",Vo]]);Ye();async function AC(t,e){let{project:r,workspace:o}=await St.find(t,e);if(!o)throw new rr(r.cwd,e);return o}Ye();Pt();nA();Ll();x1();qt();var JDt=$e(Jn());Za();var $8={};Vt($8,{AddCommand:()=>bh,BinCommand:()=>kh,CacheCleanCommand:()=>Qh,ClipanionCommand:()=>Wd,ConfigCommand:()=>Lh,ConfigGetCommand:()=>Fh,ConfigSetCommand:()=>Rh,ConfigUnsetCommand:()=>Th,DedupeCommand:()=>Nh,EntryCommand:()=>dC,ExecCommand:()=>Oh,ExplainCommand:()=>_h,ExplainPeerRequirementsCommand:()=>Mh,HelpCommand:()=>Kd,InfoCommand:()=>Hh,LinkCommand:()=>qh,NodeCommand:()=>Gh,PluginCheckCommand:()=>Yh,PluginImportCommand:()=>Vh,PluginImportSourcesCommand:()=>zh,PluginListCommand:()=>Wh,PluginRemoveCommand:()=>Jh,PluginRuntimeCommand:()=>Xh,RebuildCommand:()=>Zh,RemoveCommand:()=>$h,RunCommand:()=>e0,RunIndexCommand:()=>Jd,SetResolutionCommand:()=>t0,SetVersionCommand:()=>Uh,SetVersionSourcesCommand:()=>Kh,UnlinkCommand:()=>r0,UpCommand:()=>Kf,VersionCommand:()=>Vd,WhyCommand:()=>n0,WorkspaceCommand:()=>o0,WorkspacesListCommand:()=>s0,YarnCommand:()=>jh,dedupeUtils:()=>pk,default:()=>Pgt,suggestUtils:()=>Jc});var Fde=$e($g());Ye();Ye();Ye();qt();var H0e=$e(u2());Za();var Jc={};Vt(Jc,{Modifier:()=>B8,Strategy:()=>uk,Target:()=>A2,WorkspaceModifier:()=>N0e,applyModifier:()=>ept,extractDescriptorFromPath:()=>v8,extractRangeModifier:()=>O0e,fetchDescriptorFrom:()=>D8,findProjectDescriptors:()=>_0e,getModifier:()=>f2,getSuggestedDescriptors:()=>p2,makeWorkspaceDescriptor:()=>U0e,toWorkspaceModifier:()=>M0e});Ye();Ye();Pt();var I8=$e(Jn()),Zft="workspace:",A2=(o=>(o.REGULAR="dependencies",o.DEVELOPMENT="devDependencies",o.PEER="peerDependencies",o))(A2||{}),B8=(o=>(o.CARET="^",o.TILDE="~",o.EXACT="",o))(B8||{}),N0e=(o=>(o.CARET="^",o.TILDE="~",o.EXACT="*",o))(N0e||{}),uk=(n=>(n.KEEP="keep",n.REUSE="reuse",n.PROJECT="project",n.LATEST="latest",n.CACHE="cache",n))(uk||{});function f2(t,e){return t.exact?"":t.caret?"^":t.tilde?"~":e.configuration.get("defaultSemverRangePrefix")}var $ft=/^([\^~]?)[0-9]+(?:\.[0-9]+){0,2}(?:-\S+)?$/;function O0e(t,{project:e}){let r=t.match($ft);return r?r[1]:e.configuration.get("defaultSemverRangePrefix")}function ept(t,e){let{protocol:r,source:o,params:a,selector:n}=G.parseRange(t.range);return I8.default.valid(n)&&(n=`${e}${t.range}`),G.makeDescriptor(t,G.makeRange({protocol:r,source:o,params:a,selector:n}))}function M0e(t){switch(t){case"^":return"^";case"~":return"~";case"":return"*";default:throw new Error(`Assertion failed: Unknown modifier: "${t}"`)}}function U0e(t,e){return G.makeDescriptor(t.anchoredDescriptor,`${Zft}${M0e(e)}`)}async function _0e(t,{project:e,target:r}){let o=new Map,a=n=>{let u=o.get(n.descriptorHash);return u||o.set(n.descriptorHash,u={descriptor:n,locators:[]}),u};for(let n of e.workspaces)if(r==="peerDependencies"){let u=n.manifest.peerDependencies.get(t.identHash);u!==void 0&&a(u).locators.push(n.anchoredLocator)}else{let u=n.manifest.dependencies.get(t.identHash),A=n.manifest.devDependencies.get(t.identHash);r==="devDependencies"?A!==void 0?a(A).locators.push(n.anchoredLocator):u!==void 0&&a(u).locators.push(n.anchoredLocator):u!==void 0?a(u).locators.push(n.anchoredLocator):A!==void 0&&a(A).locators.push(n.anchoredLocator)}return o}async function v8(t,{cwd:e,workspace:r}){return await tpt(async o=>{V.isAbsolute(t)||(t=V.relative(r.cwd,V.resolve(e,t)),t.match(/^\.{0,2}\//)||(t=`./${t}`));let{project:a}=r,n=await D8(G.makeIdent(null,"archive"),t,{project:r.project,cache:o,workspace:r});if(!n)throw new Error("Assertion failed: The descriptor should have been found");let u=new Qi,A=a.configuration.makeResolver(),p=a.configuration.makeFetcher(),h={checksums:a.storedChecksums,project:a,cache:o,fetcher:p,report:u,resolver:A},C=A.bindDescriptor(n,r.anchoredLocator,h),I=G.convertDescriptorToLocator(C),v=await p.fetch(I,h),b=await Ot.find(v.prefixPath,{baseFs:v.packageFs});if(!b.name)throw new Error("Target path doesn't have a name");return G.makeDescriptor(b.name,t)})}async function p2(t,{project:e,workspace:r,cache:o,target:a,fixed:n,modifier:u,strategies:A,maxResults:p=1/0}){if(!(p>=0))throw new Error(`Invalid maxResults (${p})`);let[h,C]=t.range!=="unknown"?n||Qr.validRange(t.range)||!t.range.match(/^[a-z0-9._-]+$/i)?[t.range,"latest"]:["unknown",t.range]:["unknown","latest"];if(h!=="unknown")return{suggestions:[{descriptor:t,name:`Use ${G.prettyDescriptor(e.configuration,t)}`,reason:"(unambiguous explicit request)"}],rejections:[]};let I=typeof r<"u"&&r!==null&&r.manifest[a].get(t.identHash)||null,v=[],b=[],E=async F=>{try{await F()}catch(N){b.push(N)}};for(let F of A){if(v.length>=p)break;switch(F){case"keep":await E(async()=>{I&&v.push({descriptor:I,name:`Keep ${G.prettyDescriptor(e.configuration,I)}`,reason:"(no changes)"})});break;case"reuse":await E(async()=>{for(let{descriptor:N,locators:U}of(await _0e(t,{project:e,target:a})).values()){if(U.length===1&&U[0].locatorHash===r.anchoredLocator.locatorHash&&A.includes("keep"))continue;let z=`(originally used by ${G.prettyLocator(e.configuration,U[0])}`;z+=U.length>1?` and ${U.length-1} other${U.length>2?"s":""})`:")",v.push({descriptor:N,name:`Reuse ${G.prettyDescriptor(e.configuration,N)}`,reason:z})}});break;case"cache":await E(async()=>{for(let N of e.storedDescriptors.values())N.identHash===t.identHash&&v.push({descriptor:N,name:`Reuse ${G.prettyDescriptor(e.configuration,N)}`,reason:"(already used somewhere in the lockfile)"})});break;case"project":await E(async()=>{if(r.manifest.name!==null&&t.identHash===r.manifest.name.identHash)return;let N=e.tryWorkspaceByIdent(t);if(N===null)return;let U=U0e(N,u);v.push({descriptor:U,name:`Attach ${G.prettyDescriptor(e.configuration,U)}`,reason:`(local workspace at ${de.pretty(e.configuration,N.relativeCwd,de.Type.PATH)})`})});break;case"latest":{let N=e.configuration.get("enableNetwork"),U=e.configuration.get("enableOfflineMode");await E(async()=>{if(a==="peerDependencies")v.push({descriptor:G.makeDescriptor(t,"*"),name:"Use *",reason:"(catch-all peer dependency pattern)"});else if(!N&&!U)v.push({descriptor:null,name:"Resolve from latest",reason:de.pretty(e.configuration,"(unavailable because enableNetwork is toggled off)","grey")});else{let z=await D8(t,C,{project:e,cache:o,workspace:r,modifier:u});z&&v.push({descriptor:z,name:`Use ${G.prettyDescriptor(e.configuration,z)}`,reason:`(resolved from ${U?"the cache":"latest"})`})}})}break}}return{suggestions:v.slice(0,p),rejections:b.slice(0,p)}}async function D8(t,e,{project:r,cache:o,workspace:a,preserveModifier:n=!0,modifier:u}){let A=r.configuration.normalizeDependency(G.makeDescriptor(t,e)),p=new Qi,h=r.configuration.makeFetcher(),C=r.configuration.makeResolver(),I={project:r,fetcher:h,cache:o,checksums:r.storedChecksums,report:p,cacheOptions:{skipIntegrityCheck:!0}},v={...I,resolver:C,fetchOptions:I},b=C.bindDescriptor(A,a.anchoredLocator,v),E=await C.getCandidates(b,{},v);if(E.length===0)return null;let F=E[0],{protocol:N,source:U,params:z,selector:te}=G.parseRange(G.convertToManifestRange(F.reference));if(N===r.configuration.get("defaultProtocol")&&(N=null),I8.default.valid(te)){let le=te;if(typeof u<"u")te=u+te;else if(n!==!1){let ye=typeof n=="string"?n:A.range;te=O0e(ye,{project:r})+te}let pe=G.makeDescriptor(F,G.makeRange({protocol:N,source:U,params:z,selector:te}));(await C.getCandidates(r.configuration.normalizeDependency(pe),{},v)).length!==1&&(te=le)}return G.makeDescriptor(F,G.makeRange({protocol:N,source:U,params:z,selector:te}))}async function tpt(t){return await oe.mktempPromise(async e=>{let r=Ke.create(e);return r.useWithSource(e,{enableMirror:!1,compressionLevel:0},e,{overwrite:!0}),await t(new Nr(e,{configuration:r,check:!1,immutable:!1}))})}var bh=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.fixed=ge.Boolean("-F,--fixed",!1,{description:"Store dependency tags as-is instead of resolving them"});this.exact=ge.Boolean("-E,--exact",!1,{description:"Don't use any semver modifier on the resolved range"});this.tilde=ge.Boolean("-T,--tilde",!1,{description:"Use the `~` semver modifier on the resolved range"});this.caret=ge.Boolean("-C,--caret",!1,{description:"Use the `^` semver modifier on the resolved range"});this.dev=ge.Boolean("-D,--dev",!1,{description:"Add a package as a dev dependency"});this.peer=ge.Boolean("-P,--peer",!1,{description:"Add a package as a peer dependency"});this.optional=ge.Boolean("-O,--optional",!1,{description:"Add / upgrade a package to an optional regular / peer dependency"});this.preferDev=ge.Boolean("--prefer-dev",!1,{description:"Add / upgrade a package to a dev dependency"});this.interactive=ge.Boolean("-i,--interactive",{description:"Reuse the specified package from other workspaces in the project"});this.cached=ge.Boolean("--cached",!1,{description:"Reuse the highest version already used somewhere within the project"});this.mode=ge.String("--mode",{description:"Change what artifacts installs generate",validator:Vs(pl)});this.silent=ge.Boolean("--silent",{hidden:!0});this.packages=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd),n=await Nr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState({restoreResolutions:!1});let u=this.fixed,A=this.interactive??r.get("preferInteractive"),p=A||r.get("preferReuse"),h=f2(this,o),C=[p?"reuse":void 0,"project",this.cached?"cache":void 0,"latest"].filter(U=>typeof U<"u"),I=A?1/0:1,v=await Promise.all(this.packages.map(async U=>{let z=U.match(/^\.{0,2}\//)?await v8(U,{cwd:this.context.cwd,workspace:a}):G.tryParseDescriptor(U),te=U.match(/^(https?:|git@github)/);if(te)throw new it(`It seems you are trying to add a package using a ${de.pretty(r,`${te[0]}...`,de.Type.RANGE)} url; we now require package names to be explicitly specified. -Try running the command again with the package name prefixed: ${de.pretty(r,"yarn add",de.Type.CODE)} ${de.pretty(r,G.makeDescriptor(G.makeIdent(null,"my-package"),`${te[0]}...`),de.Type.DESCRIPTOR)}`);if(!z)throw new it(`The ${de.pretty(r,U,de.Type.CODE)} string didn't match the required format (package-name@range). Did you perhaps forget to explicitly reference the package name?`);let le=rpt(a,z,{dev:this.dev,peer:this.peer,preferDev:this.preferDev,optional:this.optional});return await Promise.all(le.map(async ue=>{let ye=await p2(z,{project:o,workspace:a,cache:n,fixed:u,target:ue,modifier:h,strategies:C,maxResults:I});return{request:z,suggestedDescriptors:ye,target:ue}}))})).then(U=>U.flat()),b=await AA.start({configuration:r,stdout:this.context.stdout,suggestInstall:!1},async U=>{for(let{request:z,suggestedDescriptors:{suggestions:te,rejections:le}}of v)if(te.filter(ue=>ue.descriptor!==null).length===0){let[ue]=le;if(typeof ue>"u")throw new Error("Assertion failed: Expected an error to have been set");o.configuration.get("enableNetwork")?U.reportError(27,`${G.prettyDescriptor(r,z)} can't be resolved to a satisfying range`):U.reportError(27,`${G.prettyDescriptor(r,z)} can't be resolved to a satisfying range (note: network resolution has been disabled)`),U.reportSeparator(),U.reportExceptionOnce(ue)}});if(b.hasErrors())return b.exitCode();let E=!1,F=[],N=[];for(let{suggestedDescriptors:{suggestions:U},target:z}of v){let te,le=U.filter(ae=>ae.descriptor!==null),pe=le[0].descriptor,ue=le.every(ae=>G.areDescriptorsEqual(ae.descriptor,pe));le.length===1||ue?te=pe:(E=!0,{answer:te}=await(0,H0e.prompt)({type:"select",name:"answer",message:"Which range do you want to use?",choices:U.map(({descriptor:ae,name:Ie,reason:Fe})=>ae?{name:Ie,hint:Fe,descriptor:ae}:{name:Ie,hint:Fe,disabled:!0}),onCancel:()=>process.exit(130),result(ae){return this.find(ae,"descriptor")},stdin:this.context.stdin,stdout:this.context.stdout}));let ye=a.manifest[z].get(te.identHash);(typeof ye>"u"||ye.descriptorHash!==te.descriptorHash)&&(a.manifest[z].set(te.identHash,te),this.optional&&(z==="dependencies"?a.manifest.ensureDependencyMeta({...te,range:"unknown"}).optional=!0:z==="peerDependencies"&&(a.manifest.ensurePeerDependencyMeta({...te,range:"unknown"}).optional=!0)),typeof ye>"u"?F.push([a,z,te,C]):N.push([a,z,ye,te]))}return await r.triggerMultipleHooks(U=>U.afterWorkspaceDependencyAddition,F),await r.triggerMultipleHooks(U=>U.afterWorkspaceDependencyReplacement,N),E&&this.context.stdout.write(` -`),await o.installWithNewReport({json:this.json,stdout:this.context.stdout,quiet:this.context.quiet},{cache:n,mode:this.mode})}};bh.paths=[["add"]],bh.usage=nt.Usage({description:"add dependencies to the project",details:"\n This command adds a package to the package.json for the nearest workspace.\n\n - If it didn't exist before, the package will by default be added to the regular `dependencies` field, but this behavior can be overriden thanks to the `-D,--dev` flag (which will cause the dependency to be added to the `devDependencies` field instead) and the `-P,--peer` flag (which will do the same but for `peerDependencies`).\n\n - If the package was already listed in your dependencies, it will by default be upgraded whether it's part of your `dependencies` or `devDependencies` (it won't ever update `peerDependencies`, though).\n\n - If set, the `--prefer-dev` flag will operate as a more flexible `-D,--dev` in that it will add the package to your `devDependencies` if it isn't already listed in either `dependencies` or `devDependencies`, but it will also happily upgrade your `dependencies` if that's what you already use (whereas `-D,--dev` would throw an exception).\n\n - If set, the `-O,--optional` flag will add the package to the `optionalDependencies` field and, in combination with the `-P,--peer` flag, it will add the package as an optional peer dependency. If the package was already listed in your `dependencies`, it will be upgraded to `optionalDependencies`. If the package was already listed in your `peerDependencies`, in combination with the `-P,--peer` flag, it will be upgraded to an optional peer dependency: `\"peerDependenciesMeta\": { \"<package>\": { \"optional\": true } }`\n\n - If the added package doesn't specify a range at all its `latest` tag will be resolved and the returned version will be used to generate a new semver range (using the `^` modifier by default unless otherwise configured via the `defaultSemverRangePrefix` configuration, or the `~` modifier if `-T,--tilde` is specified, or no modifier at all if `-E,--exact` is specified). Two exceptions to this rule: the first one is that if the package is a workspace then its local version will be used, and the second one is that if you use `-P,--peer` the default range will be `*` and won't be resolved at all.\n\n - If the added package specifies a range (such as `^1.0.0`, `latest`, or `rc`), Yarn will add this range as-is in the resulting package.json entry (in particular, tags such as `rc` will be encoded as-is rather than being converted into a semver range).\n\n If the `--cached` option is used, Yarn will preferably reuse the highest version already used somewhere within the project, even if through a transitive dependency.\n\n If the `-i,--interactive` option is used (or if the `preferInteractive` settings is toggled on) the command will first try to check whether other workspaces in the project use the specified package and, if so, will offer to reuse them.\n\n If the `--mode=<mode>` option is set, Yarn will change which artifacts are generated. The modes currently supported are:\n\n - `skip-build` will not run the build scripts at all. Note that this is different from setting `enableScripts` to false because the latter will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run.\n\n - `update-lockfile` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost.\n\n For a compilation of all the supported protocols, please consult the dedicated page from our website: https://yarnpkg.com/features/protocols.\n ",examples:[["Add a regular package to the current workspace","$0 add lodash"],["Add a specific version for a package to the current workspace","$0 add lodash@1.2.3"],["Add a package from a GitHub repository (the master branch) to the current workspace using a URL","$0 add lodash@https://github.com/lodash/lodash"],["Add a package from a GitHub repository (the master branch) to the current workspace using the GitHub protocol","$0 add lodash@github:lodash/lodash"],["Add a package from a GitHub repository (the master branch) to the current workspace using the GitHub protocol (shorthand)","$0 add lodash@lodash/lodash"],["Add a package from a specific branch of a GitHub repository to the current workspace using the GitHub protocol (shorthand)","$0 add lodash-es@lodash/lodash#es"]]});function rpt(t,e,{dev:r,peer:o,preferDev:a,optional:n}){let u=t.manifest["dependencies"].has(e.identHash),A=t.manifest["devDependencies"].has(e.identHash),p=t.manifest["peerDependencies"].has(e.identHash);if((r||o)&&u)throw new it(`Package "${G.prettyIdent(t.project.configuration,e)}" is already listed as a regular dependency - remove the -D,-P flags or remove it from your dependencies first`);if(!r&&!o&&p)throw new it(`Package "${G.prettyIdent(t.project.configuration,e)}" is already listed as a peer dependency - use either of -D or -P, or remove it from your peer dependencies first`);if(n&&A)throw new it(`Package "${G.prettyIdent(t.project.configuration,e)}" is already listed as a dev dependency - remove the -O flag or remove it from your dev dependencies first`);if(n&&!o&&p)throw new it(`Package "${G.prettyIdent(t.project.configuration,e)}" is already listed as a peer dependency - remove the -O flag or add the -P flag or remove it from your peer dependencies first`);if((r||a)&&n)throw new it(`Package "${G.prettyIdent(t.project.configuration,e)}" cannot simultaneously be a dev dependency and an optional dependency`);let h=[];return o&&h.push("peerDependencies"),(r||a)&&h.push("devDependencies"),n&&h.push("dependencies"),h.length>0?h:A?["devDependencies"]:p?["peerDependencies"]:["dependencies"]}Ye();Ye();qt();var kh=class extends ut{constructor(){super(...arguments);this.verbose=ge.Boolean("-v,--verbose",!1,{description:"Print both the binary name and the locator of the package that provides the binary"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.name=ge.String({required:!1})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,locator:a}=await St.find(r,this.context.cwd);if(await o.restoreInstallState(),this.name){let A=(await un.getPackageAccessibleBinaries(a,{project:o})).get(this.name);if(!A)throw new it(`Couldn't find a binary named "${this.name}" for package "${G.prettyLocator(r,a)}"`);let[,p]=A;return this.context.stdout.write(`${p} -`),0}return(await Lt.start({configuration:r,json:this.json,stdout:this.context.stdout},async u=>{let A=await un.getPackageAccessibleBinaries(a,{project:o}),h=Array.from(A.keys()).reduce((C,I)=>Math.max(C,I.length),0);for(let[C,[I,v]]of A)u.reportJson({name:C,source:G.stringifyIdent(I),path:v});if(this.verbose)for(let[C,[I]]of A)u.reportInfo(null,`${C.padEnd(h," ")} ${G.prettyLocator(r,I)}`);else for(let C of A.keys())u.reportInfo(null,C)})).exitCode()}};kh.paths=[["bin"]],kh.usage=nt.Usage({description:"get the path to a binary script",details:` - When used without arguments, this command will print the list of all the binaries available in the current workspace. Adding the \`-v,--verbose\` flag will cause the output to contain both the binary name and the locator of the package that provides the binary. - - When an argument is specified, this command will just print the path to the binary on the standard output and exit. Note that the reported path may be stored within a zip archive. - `,examples:[["List all the available binaries","$0 bin"],["Print the path to a specific binary","$0 bin eslint"]]});Ye();Pt();qt();var Qh=class extends ut{constructor(){super(...arguments);this.mirror=ge.Boolean("--mirror",!1,{description:"Remove the global cache files instead of the local cache files"});this.all=ge.Boolean("--all",!1,{description:"Remove both the global cache files and the local cache files of the current project"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=await Nr.find(r);return(await Lt.start({configuration:r,stdout:this.context.stdout},async()=>{let n=(this.all||this.mirror)&&o.mirrorCwd!==null,u=!this.mirror;n&&(await oe.removePromise(o.mirrorCwd),await r.triggerHook(A=>A.cleanGlobalArtifacts,r)),u&&await oe.removePromise(o.cwd)})).exitCode()}};Qh.paths=[["cache","clean"],["cache","clear"]],Qh.usage=nt.Usage({description:"remove the shared cache files",details:` - This command will remove all the files from the cache. - `,examples:[["Remove all the local archives","$0 cache clean"],["Remove all the archives stored in the ~/.yarn directory","$0 cache clean --mirror"]]});Ye();qt();var q0e=$e(h2()),P8=Be("util"),Fh=class extends ut{constructor(){super(...arguments);this.why=ge.Boolean("--why",!1,{description:"Print the explanation for why a setting has its value"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.unsafe=ge.Boolean("--no-redacted",!1,{description:"Don't redact secrets (such as tokens) from the output"});this.name=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=this.name.replace(/[.[].*$/,""),a=this.name.replace(/^[^.[]*/,"");if(typeof r.settings.get(o)>"u")throw new it(`Couldn't find a configuration settings named "${o}"`);let u=r.getSpecial(o,{hideSecrets:!this.unsafe,getNativePaths:!0}),A=_e.convertMapsToIndexableObjects(u),p=a?(0,q0e.default)(A,a):A,h=await Lt.start({configuration:r,includeFooter:!1,json:this.json,stdout:this.context.stdout},async C=>{C.reportJson(p)});if(!this.json){if(typeof p=="string")return this.context.stdout.write(`${p} -`),h.exitCode();P8.inspect.styles.name="cyan",this.context.stdout.write(`${(0,P8.inspect)(p,{depth:1/0,colors:r.get("enableColors"),compact:!1})} -`)}return h.exitCode()}};Fh.paths=[["config","get"]],Fh.usage=nt.Usage({description:"read a configuration settings",details:` - This command will print a configuration setting. - - Secrets (such as tokens) will be redacted from the output by default. If this behavior isn't desired, set the \`--no-redacted\` to get the untransformed value. - `,examples:[["Print a simple configuration setting","yarn config get yarnPath"],["Print a complex configuration setting","yarn config get packageExtensions"],["Print a nested field from the configuration",`yarn config get 'npmScopes["my-company"].npmRegistryServer'`],["Print a token from the configuration","yarn config get npmAuthToken --no-redacted"],["Print a configuration setting as JSON","yarn config get packageExtensions --json"]]});Ye();qt();var Rge=$e(k8()),Tge=$e(h2()),Lge=$e(Q8()),F8=Be("util"),Rh=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Set complex configuration settings to JSON values"});this.home=ge.Boolean("-H,--home",!1,{description:"Update the home configuration instead of the project configuration"});this.name=ge.String();this.value=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=()=>{if(!r.projectCwd)throw new it("This command must be run from within a project folder");return r.projectCwd},a=this.name.replace(/[.[].*$/,""),n=this.name.replace(/^[^.[]*\.?/,"");if(typeof r.settings.get(a)>"u")throw new it(`Couldn't find a configuration settings named "${a}"`);if(a==="enableStrictSettings")throw new it("This setting only affects the file it's in, and thus cannot be set from the CLI");let A=this.json?JSON.parse(this.value):this.value;await(this.home?E=>Ke.updateHomeConfiguration(E):E=>Ke.updateConfiguration(o(),E))(E=>{if(n){let F=(0,Rge.default)(E);return(0,Lge.default)(F,this.name,A),F}else return{...E,[a]:A}});let C=(await Ke.find(this.context.cwd,this.context.plugins)).getSpecial(a,{hideSecrets:!0,getNativePaths:!0}),I=_e.convertMapsToIndexableObjects(C),v=n?(0,Tge.default)(I,n):I;return(await Lt.start({configuration:r,includeFooter:!1,stdout:this.context.stdout},async E=>{F8.inspect.styles.name="cyan",E.reportInfo(0,`Successfully set ${this.name} to ${(0,F8.inspect)(v,{depth:1/0,colors:r.get("enableColors"),compact:!1})}`)})).exitCode()}};Rh.paths=[["config","set"]],Rh.usage=nt.Usage({description:"change a configuration settings",details:` - This command will set a configuration setting. - - When used without the \`--json\` flag, it can only set a simple configuration setting (a string, a number, or a boolean). - - When used with the \`--json\` flag, it can set both simple and complex configuration settings, including Arrays and Objects. - `,examples:[["Set a simple configuration setting (a string, a number, or a boolean)","yarn config set initScope myScope"],["Set a simple configuration setting (a string, a number, or a boolean) using the `--json` flag",'yarn config set initScope --json \\"myScope\\"'],["Set a complex configuration setting (an Array) using the `--json` flag",`yarn config set unsafeHttpWhitelist --json '["*.example.com", "example.com"]'`],["Set a complex configuration setting (an Object) using the `--json` flag",`yarn config set packageExtensions --json '{ "@babel/parser@*": { "dependencies": { "@babel/types": "*" } } }'`],["Set a nested configuration setting",'yarn config set npmScopes.company.npmRegistryServer "https://npm.example.com"'],["Set a nested configuration setting using indexed access for non-simple keys",`yarn config set 'npmRegistries["//npm.example.com"].npmAuthToken' "ffffffff-ffff-ffff-ffff-ffffffffffff"`]]});Ye();qt();var Wge=$e(k8()),Kge=$e(Uge()),Vge=$e(T8()),Th=class extends ut{constructor(){super(...arguments);this.home=ge.Boolean("-H,--home",!1,{description:"Update the home configuration instead of the project configuration"});this.name=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=()=>{if(!r.projectCwd)throw new it("This command must be run from within a project folder");return r.projectCwd},a=this.name.replace(/[.[].*$/,""),n=this.name.replace(/^[^.[]*\.?/,"");if(typeof r.settings.get(a)>"u")throw new it(`Couldn't find a configuration settings named "${a}"`);let A=this.home?h=>Ke.updateHomeConfiguration(h):h=>Ke.updateConfiguration(o(),h);return(await Lt.start({configuration:r,includeFooter:!1,stdout:this.context.stdout},async h=>{let C=!1;await A(I=>{if(!(0,Kge.default)(I,this.name))return h.reportWarning(0,`Configuration doesn't contain setting ${this.name}; there is nothing to unset`),C=!0,I;let v=n?(0,Wge.default)(I):{...I};return(0,Vge.default)(v,this.name),v}),C||h.reportInfo(0,`Successfully unset ${this.name}`)})).exitCode()}};Th.paths=[["config","unset"]],Th.usage=nt.Usage({description:"unset a configuration setting",details:` - This command will unset a configuration setting. - `,examples:[["Unset a simple configuration setting","yarn config unset initScope"],["Unset a complex configuration setting","yarn config unset packageExtensions"],["Unset a nested configuration setting","yarn config unset npmScopes.company.npmRegistryServer"]]});Ye();Pt();qt();var fk=Be("util"),Lh=class extends ut{constructor(){super(...arguments);this.noDefaults=ge.Boolean("--no-defaults",!1,{description:"Omit the default values from the display"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.verbose=ge.Boolean("-v,--verbose",{hidden:!0});this.why=ge.Boolean("--why",{hidden:!0});this.names=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins,{strict:!1}),o=await LE({configuration:r,stdout:this.context.stdout,forceError:this.json},[{option:this.verbose,message:"The --verbose option is deprecated, the settings' descriptions are now always displayed"},{option:this.why,message:"The --why option is deprecated, the settings' sources are now always displayed"}]);if(o!==null)return o;let a=this.names.length>0?[...new Set(this.names)].sort():[...r.settings.keys()].sort(),n,u=await Lt.start({configuration:r,json:this.json,stdout:this.context.stdout,includeFooter:!1},async A=>{if(r.invalid.size>0&&!this.json){for(let[p,h]of r.invalid)A.reportError(34,`Invalid configuration key "${p}" in ${h}`);A.reportSeparator()}if(this.json)for(let p of a){let h=r.settings.get(p);typeof h>"u"&&A.reportError(34,`No configuration key named "${p}"`);let C=r.getSpecial(p,{hideSecrets:!0,getNativePaths:!0}),I=r.sources.get(p)??"<default>",v=I&&I[0]!=="<"?fe.fromPortablePath(I):I;A.reportJson({key:p,effective:C,source:v,...h})}else{let p={breakLength:1/0,colors:r.get("enableColors"),maxArrayLength:2},h={},C={children:h};for(let I of a){if(this.noDefaults&&!r.sources.has(I))continue;let v=r.settings.get(I),b=r.sources.get(I)??"<default>",E=r.getSpecial(I,{hideSecrets:!0,getNativePaths:!0}),F={Description:{label:"Description",value:de.tuple(de.Type.MARKDOWN,{text:v.description,format:this.cli.format(),paragraphs:!1})},Source:{label:"Source",value:de.tuple(b[0]==="<"?de.Type.CODE:de.Type.PATH,b)}};h[I]={value:de.tuple(de.Type.CODE,I),children:F};let N=(U,z)=>{for(let[te,le]of z)if(le instanceof Map){let pe={};U[te]={children:pe},N(pe,le)}else U[te]={label:te,value:de.tuple(de.Type.NO_HINT,(0,fk.inspect)(le,p))}};E instanceof Map?N(F,E):F.Value={label:"Value",value:de.tuple(de.Type.NO_HINT,(0,fk.inspect)(E,p))}}a.length!==1&&(n=void 0),$s.emitTree(C,{configuration:r,json:this.json,stdout:this.context.stdout,separators:2})}});if(!this.json&&typeof n<"u"){let A=a[0],p=(0,fk.inspect)(r.getSpecial(A,{hideSecrets:!0,getNativePaths:!0}),{colors:r.get("enableColors")});this.context.stdout.write(` -`),this.context.stdout.write(`${p} -`)}return u.exitCode()}};Lh.paths=[["config"]],Lh.usage=nt.Usage({description:"display the current configuration",details:` - This command prints the current active configuration settings. - `,examples:[["Print the active configuration settings","$0 config"]]});Ye();qt();Za();var pk={};Vt(pk,{Strategy:()=>g2,acceptedStrategies:()=>M0t,dedupe:()=>L8});Ye();Ye();var zge=$e(Zo()),g2=(e=>(e.HIGHEST="highest",e))(g2||{}),M0t=new Set(Object.values(g2)),U0t={highest:async(t,e,{resolver:r,fetcher:o,resolveOptions:a,fetchOptions:n})=>{let u=new Map;for(let[p,h]of t.storedResolutions){let C=t.storedDescriptors.get(p);if(typeof C>"u")throw new Error(`Assertion failed: The descriptor (${p}) should have been registered`);_e.getSetWithDefault(u,C.identHash).add(h)}let A=new Map(_e.mapAndFilter(t.storedDescriptors.values(),p=>G.isVirtualDescriptor(p)?_e.mapAndFilter.skip:[p.descriptorHash,_e.makeDeferred()]));for(let p of t.storedDescriptors.values()){let h=A.get(p.descriptorHash);if(typeof h>"u")throw new Error(`Assertion failed: The descriptor (${p.descriptorHash}) should have been registered`);let C=t.storedResolutions.get(p.descriptorHash);if(typeof C>"u")throw new Error(`Assertion failed: The resolution (${p.descriptorHash}) should have been registered`);let I=t.originalPackages.get(C);if(typeof I>"u")throw new Error(`Assertion failed: The package (${C}) should have been registered`);Promise.resolve().then(async()=>{let v=r.getResolutionDependencies(p,a),b=Object.fromEntries(await _e.allSettledSafe(Object.entries(v).map(async([te,le])=>{let pe=A.get(le.descriptorHash);if(typeof pe>"u")throw new Error(`Assertion failed: The descriptor (${le.descriptorHash}) should have been registered`);let ue=await pe.promise;if(!ue)throw new Error("Assertion failed: Expected the dependency to have been through the dedupe process itself");return[te,ue.updatedPackage]})));if(e.length&&!zge.default.isMatch(G.stringifyIdent(p),e)||!r.shouldPersistResolution(I,a))return I;let E=u.get(p.identHash);if(typeof E>"u")throw new Error(`Assertion failed: The resolutions (${p.identHash}) should have been registered`);if(E.size===1)return I;let F=[...E].map(te=>{let le=t.originalPackages.get(te);if(typeof le>"u")throw new Error(`Assertion failed: The package (${te}) should have been registered`);return le}),N=await r.getSatisfying(p,b,F,a),U=N.locators?.[0];if(typeof U>"u"||!N.sorted)return I;let z=t.originalPackages.get(U.locatorHash);if(typeof z>"u")throw new Error(`Assertion failed: The package (${U.locatorHash}) should have been registered`);return z}).then(async v=>{let b=await t.preparePackage(v,{resolver:r,resolveOptions:a});h.resolve({descriptor:p,currentPackage:I,updatedPackage:v,resolvedPackage:b})}).catch(v=>{h.reject(v)})}return[...A.values()].map(p=>p.promise)}};async function L8(t,{strategy:e,patterns:r,cache:o,report:a}){let{configuration:n}=t,u=new Qi,A=n.makeResolver(),p=n.makeFetcher(),h={cache:o,checksums:t.storedChecksums,fetcher:p,project:t,report:u,cacheOptions:{skipIntegrityCheck:!0}},C={project:t,resolver:A,report:u,fetchOptions:h};return await a.startTimerPromise("Deduplication step",async()=>{let I=U0t[e],v=await I(t,r,{resolver:A,resolveOptions:C,fetcher:p,fetchOptions:h}),b=Xs.progressViaCounter(v.length);await a.reportProgress(b);let E=0;await Promise.all(v.map(U=>U.then(z=>{if(z===null||z.currentPackage.locatorHash===z.updatedPackage.locatorHash)return;E++;let{descriptor:te,currentPackage:le,updatedPackage:pe}=z;a.reportInfo(0,`${G.prettyDescriptor(n,te)} can be deduped from ${G.prettyLocator(n,le)} to ${G.prettyLocator(n,pe)}`),a.reportJson({descriptor:G.stringifyDescriptor(te),currentResolution:G.stringifyLocator(le),updatedResolution:G.stringifyLocator(pe)}),t.storedResolutions.set(te.descriptorHash,pe.locatorHash)}).finally(()=>b.tick())));let F;switch(E){case 0:F="No packages";break;case 1:F="One package";break;default:F=`${E} packages`}let N=de.pretty(n,e,de.Type.CODE);return a.reportInfo(0,`${F} can be deduped using the ${N} strategy`),E})}var Nh=class extends ut{constructor(){super(...arguments);this.strategy=ge.String("-s,--strategy","highest",{description:"The strategy to use when deduping dependencies",validator:Vs(g2)});this.check=ge.Boolean("-c,--check",!1,{description:"Exit with exit code 1 when duplicates are found, without persisting the dependency tree"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.mode=ge.String("--mode",{description:"Change what artifacts installs generate",validator:Vs(pl)});this.patterns=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o}=await St.find(r,this.context.cwd),a=await Nr.find(r);await o.restoreInstallState({restoreResolutions:!1});let n=0,u=await Lt.start({configuration:r,includeFooter:!1,stdout:this.context.stdout,json:this.json},async A=>{n=await L8(o,{strategy:this.strategy,patterns:this.patterns,cache:a,report:A})});return u.hasErrors()?u.exitCode():this.check?n?1:0:await o.installWithNewReport({json:this.json,stdout:this.context.stdout},{cache:a,mode:this.mode})}};Nh.paths=[["dedupe"]],Nh.usage=nt.Usage({description:"deduplicate dependencies with overlapping ranges",details:"\n Duplicates are defined as descriptors with overlapping ranges being resolved and locked to different locators. They are a natural consequence of Yarn's deterministic installs, but they can sometimes pile up and unnecessarily increase the size of your project.\n\n This command dedupes dependencies in the current project using different strategies (only one is implemented at the moment):\n\n - `highest`: Reuses (where possible) the locators with the highest versions. This means that dependencies can only be upgraded, never downgraded. It's also guaranteed that it never takes more than a single pass to dedupe the entire dependency tree.\n\n **Note:** Even though it never produces a wrong dependency tree, this command should be used with caution, as it modifies the dependency tree, which can sometimes cause problems when packages don't strictly follow semver recommendations. Because of this, it is recommended to also review the changes manually.\n\n If set, the `-c,--check` flag will only report the found duplicates, without persisting the modified dependency tree. If changes are found, the command will exit with a non-zero exit code, making it suitable for CI purposes.\n\n If the `--mode=<mode>` option is set, Yarn will change which artifacts are generated. The modes currently supported are:\n\n - `skip-build` will not run the build scripts at all. Note that this is different from setting `enableScripts` to false because the latter will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run.\n\n - `update-lockfile` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost.\n\n This command accepts glob patterns as arguments (if valid Idents and supported by [micromatch](https://github.com/micromatch/micromatch)). Make sure to escape the patterns, to prevent your own shell from trying to expand them.\n\n ### In-depth explanation:\n\n Yarn doesn't deduplicate dependencies by default, otherwise installs wouldn't be deterministic and the lockfile would be useless. What it actually does is that it tries to not duplicate dependencies in the first place.\n\n **Example:** If `foo@^2.3.4` (a dependency of a dependency) has already been resolved to `foo@2.3.4`, running `yarn add foo@*`will cause Yarn to reuse `foo@2.3.4`, even if the latest `foo` is actually `foo@2.10.14`, thus preventing unnecessary duplication.\n\n Duplication happens when Yarn can't unlock dependencies that have already been locked inside the lockfile.\n\n **Example:** If `foo@^2.3.4` (a dependency of a dependency) has already been resolved to `foo@2.3.4`, running `yarn add foo@2.10.14` will cause Yarn to install `foo@2.10.14` because the existing resolution doesn't satisfy the range `2.10.14`. This behavior can lead to (sometimes) unwanted duplication, since now the lockfile contains 2 separate resolutions for the 2 `foo` descriptors, even though they have overlapping ranges, which means that the lockfile can be simplified so that both descriptors resolve to `foo@2.10.14`.\n ",examples:[["Dedupe all packages","$0 dedupe"],["Dedupe all packages using a specific strategy","$0 dedupe --strategy highest"],["Dedupe a specific package","$0 dedupe lodash"],["Dedupe all packages with the `@babel/*` scope","$0 dedupe '@babel/*'"],["Check for duplicates (can be used as a CI step)","$0 dedupe --check"]]});Ye();qt();var Wd=class extends ut{async execute(){let{plugins:e}=await Ke.find(this.context.cwd,this.context.plugins),r=[];for(let u of e){let{commands:A}=u[1];if(A){let h=as.from(A).definitions();r.push([u[0],h])}}let o=this.cli.definitions(),a=(u,A)=>u.split(" ").slice(1).join()===A.split(" ").slice(1).join(),n=Jge()["@yarnpkg/builder"].bundles.standard;for(let u of r){let A=u[1];for(let p of A)o.find(h=>a(h.path,p.path)).plugin={name:u[0],isDefault:n.includes(u[0])}}this.context.stdout.write(`${JSON.stringify(o,null,2)} -`)}};Wd.paths=[["--clipanion=definitions"]];var Kd=class extends ut{async execute(){this.context.stdout.write(this.cli.usage(null))}};Kd.paths=[["help"],["--help"],["-h"]];Ye();Pt();qt();var dC=class extends ut{constructor(){super(...arguments);this.leadingArgument=ge.String();this.args=ge.Proxy()}async execute(){if(this.leadingArgument.match(/[\\/]/)&&!G.tryParseIdent(this.leadingArgument)){let r=V.resolve(this.context.cwd,fe.toPortablePath(this.leadingArgument));return await this.cli.run(this.args,{cwd:r})}else return await this.cli.run(["run",this.leadingArgument,...this.args])}};Ye();var Vd=class extends ut{async execute(){this.context.stdout.write(`${tn||"<unknown>"} -`)}};Vd.paths=[["-v"],["--version"]];Ye();Ye();qt();var Oh=class extends ut{constructor(){super(...arguments);this.commandName=ge.String();this.args=ge.Proxy()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,locator:a}=await St.find(r,this.context.cwd);return await o.restoreInstallState(),await un.executePackageShellcode(a,this.commandName,this.args,{cwd:this.context.cwd,stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr,project:o})}};Oh.paths=[["exec"]],Oh.usage=nt.Usage({description:"execute a shell script",details:` - This command simply executes a shell script within the context of the root directory of the active workspace using the portable shell. - - It also makes sure to call it in a way that's compatible with the current project (for example, on PnP projects the environment will be setup in such a way that PnP will be correctly injected into the environment). - `,examples:[["Execute a single shell command","$0 exec echo Hello World"],["Execute a shell script",'$0 exec "tsc & babel src --out-dir lib"']]});Ye();qt();Za();var Mh=class extends ut{constructor(){super(...arguments);this.hash=ge.String({required:!1,validator:rd(Ey(),[sI(/^p[0-9a-f]{5}$/)])})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o}=await St.find(r,this.context.cwd);return await o.restoreInstallState({restoreResolutions:!1}),await o.applyLightResolution(),typeof this.hash<"u"?await H0t(this.hash,o,{stdout:this.context.stdout}):(await Lt.start({configuration:r,stdout:this.context.stdout,includeFooter:!1},async n=>{let u=[([,A])=>G.stringifyLocator(o.storedPackages.get(A.subject)),([,A])=>G.stringifyIdent(A.requested)];for(let[A,p]of _e.sortMap(o.peerRequirements,u)){let h=o.storedPackages.get(p.subject);if(typeof h>"u")throw new Error("Assertion failed: Expected the subject package to have been registered");let C=o.storedPackages.get(p.rootRequester);if(typeof C>"u")throw new Error("Assertion failed: Expected the root package to have been registered");let I=h.dependencies.get(p.requested.identHash)??null,v=de.pretty(r,A,de.Type.CODE),b=G.prettyLocator(r,h),E=G.prettyIdent(r,p.requested),F=G.prettyIdent(r,C),N=p.allRequesters.length-1,U=`descendant${N===1?"":"s"}`,z=N>0?` and ${N} ${U}`:"",te=I!==null?"provides":"doesn't provide";n.reportInfo(null,`${v} \u2192 ${b} ${te} ${E} to ${F}${z}`)}})).exitCode()}};Mh.paths=[["explain","peer-requirements"]],Mh.usage=nt.Usage({description:"explain a set of peer requirements",details:` - A set of peer requirements represents all peer requirements that a dependent must satisfy when providing a given peer request to a requester and its descendants. - - When the hash argument is specified, this command prints a detailed explanation of all requirements of the set corresponding to the hash and whether they're satisfied or not. - - When used without arguments, this command lists all sets of peer requirements and the corresponding hash that can be used to get detailed information about a given set. - - **Note:** A hash is a six-letter p-prefixed code that can be obtained from peer dependency warnings or from the list of all peer requirements (\`yarn explain peer-requirements\`). - `,examples:[["Explain the corresponding set of peer requirements for a hash","$0 explain peer-requirements p1a4ed"],["List all sets of peer requirements","$0 explain peer-requirements"]]});async function H0t(t,e,r){let{configuration:o}=e,a=e.peerRequirements.get(t);if(typeof a>"u")throw new Error(`No peerDependency requirements found for hash: "${t}"`);return(await Lt.start({configuration:o,stdout:r.stdout,includeFooter:!1},async u=>{let A=e.storedPackages.get(a.subject);if(typeof A>"u")throw new Error("Assertion failed: Expected the subject package to have been registered");let p=e.storedPackages.get(a.rootRequester);if(typeof p>"u")throw new Error("Assertion failed: Expected the root package to have been registered");let h=A.dependencies.get(a.requested.identHash)??null,C=h!==null?e.storedResolutions.get(h.descriptorHash):null;if(typeof C>"u")throw new Error("Assertion failed: Expected the resolution to have been registered");let I=C!==null?e.storedPackages.get(C):null;if(typeof I>"u")throw new Error("Assertion failed: Expected the provided package to have been registered");let v=[...a.allRequesters.values()].map(U=>{let z=e.storedPackages.get(U);if(typeof z>"u")throw new Error("Assertion failed: Expected the package to be registered");let te=G.devirtualizeLocator(z),le=e.storedPackages.get(te.locatorHash);if(typeof le>"u")throw new Error("Assertion failed: Expected the package to be registered");let pe=le.peerDependencies.get(a.requested.identHash);if(typeof pe>"u")throw new Error("Assertion failed: Expected the peer dependency to be registered");return{pkg:z,peerDependency:pe}});if(I!==null){let U=v.every(({peerDependency:z})=>Qr.satisfiesWithPrereleases(I.version,z.range));u.reportInfo(0,`${G.prettyLocator(o,A)} provides ${G.prettyLocator(o,I)} with version ${G.prettyReference(o,I.version??"<missing>")}, which ${U?"satisfies":"doesn't satisfy"} the following requirements:`)}else u.reportInfo(0,`${G.prettyLocator(o,A)} doesn't provide ${G.prettyIdent(o,a.requested)}, breaking the following requirements:`);u.reportSeparator();let b=de.mark(o),E=[];for(let{pkg:U,peerDependency:z}of _e.sortMap(v,te=>G.stringifyLocator(te.pkg))){let le=(I!==null?Qr.satisfiesWithPrereleases(I.version,z.range):!1)?b.Check:b.Cross;E.push({stringifiedLocator:G.stringifyLocator(U),prettyLocator:G.prettyLocator(o,U),prettyRange:G.prettyRange(o,z.range),mark:le})}let F=Math.max(...E.map(({stringifiedLocator:U})=>U.length)),N=Math.max(...E.map(({prettyRange:U})=>U.length));for(let{stringifiedLocator:U,prettyLocator:z,prettyRange:te,mark:le}of _e.sortMap(E,({stringifiedLocator:pe})=>pe))u.reportInfo(null,`${z.padEnd(F+(z.length-U.length)," ")} \u2192 ${te.padEnd(N," ")} ${le}`);E.length>1&&(u.reportSeparator(),u.reportInfo(0,`Note: these requirements start with ${G.prettyLocator(e.configuration,p)}`))})).exitCode()}Ye();qt();Za();Ye();Ye();Pt();qt();var Xge=$e(Jn()),Uh=class extends ut{constructor(){super(...arguments);this.useYarnPath=ge.Boolean("--yarn-path",{description:"Set the yarnPath setting even if the version can be accessed by Corepack"});this.onlyIfNeeded=ge.Boolean("--only-if-needed",!1,{description:"Only lock the Yarn version if it isn't already locked"});this.version=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins);if(this.onlyIfNeeded&&r.get("yarnPath")){let A=r.sources.get("yarnPath");if(!A)throw new Error("Assertion failed: Expected 'yarnPath' to have a source");let p=r.projectCwd??r.startingCwd;if(V.contains(p,A))return 0}let o=()=>{if(typeof tn>"u")throw new it("The --install flag can only be used without explicit version specifier from the Yarn CLI");return`file://${process.argv[1]}`},a,n=(A,p)=>({version:p,url:A.replace(/\{\}/g,p)});if(this.version==="self")a={url:o(),version:tn??"self"};else if(this.version==="latest"||this.version==="berry"||this.version==="stable")a=n("https://repo.yarnpkg.com/{}/packages/yarnpkg-cli/bin/yarn.js",await d2(r,"stable"));else if(this.version==="canary")a=n("https://repo.yarnpkg.com/{}/packages/yarnpkg-cli/bin/yarn.js",await d2(r,"canary"));else if(this.version==="classic")a={url:"https://classic.yarnpkg.com/latest.js",version:"classic"};else if(this.version.match(/^https?:/))a={url:this.version,version:"remote"};else if(this.version.match(/^\.{0,2}[\\/]/)||fe.isAbsolute(this.version))a={url:`file://${V.resolve(fe.toPortablePath(this.version))}`,version:"file"};else if(Qr.satisfiesWithPrereleases(this.version,">=2.0.0"))a=n("https://repo.yarnpkg.com/{}/packages/yarnpkg-cli/bin/yarn.js",this.version);else if(Qr.satisfiesWithPrereleases(this.version,"^0.x || ^1.x"))a=n("https://github.com/yarnpkg/yarn/releases/download/v{}/yarn-{}.js",this.version);else if(Qr.validRange(this.version))a=n("https://repo.yarnpkg.com/{}/packages/yarnpkg-cli/bin/yarn.js",await j0t(r,this.version));else throw new it(`Invalid version descriptor "${this.version}"`);return(await Lt.start({configuration:r,stdout:this.context.stdout,includeLogs:!this.context.quiet},async A=>{let p=async()=>{let h="file://";return a.url.startsWith(h)?(A.reportInfo(0,`Retrieving ${de.pretty(r,a.url,de.Type.PATH)}`),await oe.readFilePromise(a.url.slice(h.length))):(A.reportInfo(0,`Downloading ${de.pretty(r,a.url,de.Type.URL)}`),await rn.get(a.url,{configuration:r}))};await N8(r,a.version,p,{report:A,useYarnPath:this.useYarnPath})})).exitCode()}};Uh.paths=[["set","version"]],Uh.usage=nt.Usage({description:"lock the Yarn version used by the project",details:"\n This command will set a specific release of Yarn to be used by Corepack: https://nodejs.org/api/corepack.html.\n\n By default it only will set the `packageManager` field at the root of your project, but if the referenced release cannot be represented this way, if you already have `yarnPath` configured, or if you set the `--yarn-path` command line flag, then the release will also be downloaded from the Yarn GitHub repository, stored inside your project, and referenced via the `yarnPath` settings from your project `.yarnrc.yml` file.\n\n A very good use case for this command is to enforce the version of Yarn used by any single member of your team inside the same project - by doing this you ensure that you have control over Yarn upgrades and downgrades (including on your deployment servers), and get rid of most of the headaches related to someone using a slightly different version and getting different behavior.\n\n The version specifier can be:\n\n - a tag:\n - `latest` / `berry` / `stable` -> the most recent stable berry (`>=2.0.0`) release\n - `canary` -> the most recent canary (release candidate) berry (`>=2.0.0`) release\n - `classic` -> the most recent classic (`^0.x || ^1.x`) release\n\n - a semver range (e.g. `2.x`) -> the most recent version satisfying the range (limited to berry releases)\n\n - a semver version (e.g. `2.4.1`, `1.22.1`)\n\n - a local file referenced through either a relative or absolute path\n\n - `self` -> the version used to invoke the command\n ",examples:[["Download the latest release from the Yarn repository","$0 set version latest"],["Download the latest canary release from the Yarn repository","$0 set version canary"],["Download the latest classic release from the Yarn repository","$0 set version classic"],["Download the most recent Yarn 3 build","$0 set version 3.x"],["Download a specific Yarn 2 build","$0 set version 2.0.0-rc.30"],["Switch back to a specific Yarn 1 release","$0 set version 1.22.1"],["Use a release from the local filesystem","$0 set version ./yarn.cjs"],["Use a release from a URL","$0 set version https://repo.yarnpkg.com/3.1.0/packages/yarnpkg-cli/bin/yarn.js"],["Download the version used to invoke the command","$0 set version self"]]});async function j0t(t,e){let o=(await rn.get("https://repo.yarnpkg.com/tags",{configuration:t,jsonResponse:!0})).tags.filter(a=>Qr.satisfiesWithPrereleases(a,e));if(o.length===0)throw new it(`No matching release found for range ${de.pretty(t,e,de.Type.RANGE)}.`);return o[0]}async function d2(t,e){let r=await rn.get("https://repo.yarnpkg.com/tags",{configuration:t,jsonResponse:!0});if(!r.latest[e])throw new it(`Tag ${de.pretty(t,e,de.Type.RANGE)} not found`);return r.latest[e]}async function N8(t,e,r,{report:o,useYarnPath:a}){let n,u=async()=>(typeof n>"u"&&(n=await r()),n);if(e===null){let te=await u();await oe.mktempPromise(async le=>{let pe=V.join(le,"yarn.cjs");await oe.writeFilePromise(pe,te);let{stdout:ue}=await Ur.execvp(process.execPath,[fe.fromPortablePath(pe),"--version"],{cwd:le,env:{...t.env,YARN_IGNORE_PATH:"1"}});if(e=ue.trim(),!Xge.default.valid(e))throw new Error(`Invalid semver version. ${de.pretty(t,"yarn --version",de.Type.CODE)} returned: -${e}`)})}let A=t.projectCwd??t.startingCwd,p=V.resolve(A,".yarn/releases"),h=V.resolve(p,`yarn-${e}.cjs`),C=V.relative(t.startingCwd,h),I=_e.isTaggedYarnVersion(e),v=t.get("yarnPath"),b=!I,E=b||!!v||!!a;if(a===!1){if(b)throw new Jt(0,"You explicitly opted out of yarnPath usage in your command line, but the version you specified cannot be represented by Corepack");E=!1}else!E&&!process.env.COREPACK_ROOT&&(o.reportWarning(0,`You don't seem to have ${de.applyHyperlink(t,"Corepack","https://nodejs.org/api/corepack.html")} enabled; we'll have to rely on ${de.applyHyperlink(t,"yarnPath","https://yarnpkg.com/configuration/yarnrc#yarnPath")} instead`),E=!0);if(E){let te=await u();o.reportInfo(0,`Saving the new release in ${de.pretty(t,C,"magenta")}`),await oe.removePromise(V.dirname(h)),await oe.mkdirPromise(V.dirname(h),{recursive:!0}),await oe.writeFilePromise(h,te,{mode:493}),await Ke.updateConfiguration(A,{yarnPath:V.relative(A,h)})}else await oe.removePromise(V.dirname(h)),await Ke.updateConfiguration(A,{yarnPath:Ke.deleteProperty});let F=await Ot.tryFind(A)||new Ot;F.packageManager=`yarn@${I?e:await d2(t,"stable")}`;let N={};F.exportTo(N);let U=V.join(A,Ot.fileName),z=`${JSON.stringify(N,null,F.indent)} -`;return await oe.changeFilePromise(U,z,{automaticNewlines:!0}),{bundleVersion:e}}function Zge(t){return wr[AP(t)]}var q0t=/## (?<code>YN[0-9]{4}) - `(?<name>[A-Z_]+)`\n\n(?<details>(?:.(?!##))+)/gs;async function G0t(t){let r=`https://repo.yarnpkg.com/${_e.isTaggedYarnVersion(tn)?tn:await d2(t,"canary")}/packages/gatsby/content/advanced/error-codes.md`,o=await rn.get(r,{configuration:t});return new Map(Array.from(o.toString().matchAll(q0t),({groups:a})=>{if(!a)throw new Error("Assertion failed: Expected the match to have been successful");let n=Zge(a.code);if(a.name!==n)throw new Error(`Assertion failed: Invalid error code data: Expected "${a.name}" to be named "${n}"`);return[a.code,a.details]}))}var _h=class extends ut{constructor(){super(...arguments);this.code=ge.String({required:!1,validator:rd(Ey(),[sI(/^YN[0-9]{4}$/)])});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins);if(typeof this.code<"u"){let o=Zge(this.code),a=de.pretty(r,o,de.Type.CODE),n=this.cli.format().header(`${this.code} - ${a}`),A=(await G0t(r)).get(this.code),p=typeof A<"u"?de.jsonOrPretty(this.json,r,de.tuple(de.Type.MARKDOWN,{text:A,format:this.cli.format(),paragraphs:!0})):`This error code does not have a description. - -You can help us by editing this page on GitHub \u{1F642}: -${de.jsonOrPretty(this.json,r,de.tuple(de.Type.URL,"https://github.com/yarnpkg/berry/blob/master/packages/gatsby/content/advanced/error-codes.md"))} -`;this.json?this.context.stdout.write(`${JSON.stringify({code:this.code,name:o,details:p})} -`):this.context.stdout.write(`${n} - -${p} -`)}else{let o={children:_e.mapAndFilter(Object.entries(wr),([a,n])=>Number.isNaN(Number(a))?_e.mapAndFilter.skip:{label:Wu(Number(a)),value:de.tuple(de.Type.CODE,n)})};$s.emitTree(o,{configuration:r,stdout:this.context.stdout,json:this.json})}}};_h.paths=[["explain"]],_h.usage=nt.Usage({description:"explain an error code",details:` - When the code argument is specified, this command prints its name and its details. - - When used without arguments, this command lists all error codes and their names. - `,examples:[["Explain an error code","$0 explain YN0006"],["List all error codes","$0 explain"]]});Ye();Pt();qt();var $ge=$e(Zo()),Hh=class extends ut{constructor(){super(...arguments);this.all=ge.Boolean("-A,--all",!1,{description:"Print versions of a package from the whole project"});this.recursive=ge.Boolean("-R,--recursive",!1,{description:"Print information for all packages, including transitive dependencies"});this.extra=ge.Array("-X,--extra",[],{description:"An array of requests of extra data provided by plugins"});this.cache=ge.Boolean("--cache",!1,{description:"Print information about the cache entry of a package (path, size, checksum)"});this.dependents=ge.Boolean("--dependents",!1,{description:"Print all dependents for each matching package"});this.manifest=ge.Boolean("--manifest",!1,{description:"Print data obtained by looking at the package archive (license, homepage, ...)"});this.nameOnly=ge.Boolean("--name-only",!1,{description:"Only print the name for the matching packages"});this.virtuals=ge.Boolean("--virtuals",!1,{description:"Print each instance of the virtual packages"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.patterns=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd),n=await Nr.find(r);if(!a&&!this.all)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState();let u=new Set(this.extra);this.cache&&u.add("cache"),this.dependents&&u.add("dependents"),this.manifest&&u.add("manifest");let A=(le,{recursive:pe})=>{let ue=le.anchoredLocator.locatorHash,ye=new Map,ae=[ue];for(;ae.length>0;){let Ie=ae.shift();if(ye.has(Ie))continue;let Fe=o.storedPackages.get(Ie);if(typeof Fe>"u")throw new Error("Assertion failed: Expected the package to be registered");if(ye.set(Ie,Fe),G.isVirtualLocator(Fe)&&ae.push(G.devirtualizeLocator(Fe).locatorHash),!(!pe&&Ie!==ue))for(let g of Fe.dependencies.values()){let Ee=o.storedResolutions.get(g.descriptorHash);if(typeof Ee>"u")throw new Error("Assertion failed: Expected the resolution to be registered");ae.push(Ee)}}return ye.values()},p=({recursive:le})=>{let pe=new Map;for(let ue of o.workspaces)for(let ye of A(ue,{recursive:le}))pe.set(ye.locatorHash,ye);return pe.values()},h=({all:le,recursive:pe})=>le&&pe?o.storedPackages.values():le?p({recursive:pe}):A(a,{recursive:pe}),C=({all:le,recursive:pe})=>{let ue=h({all:le,recursive:pe}),ye=this.patterns.map(Fe=>{let g=G.parseLocator(Fe),Ee=$ge.default.makeRe(G.stringifyIdent(g)),De=G.isVirtualLocator(g),ce=De?G.devirtualizeLocator(g):g;return ne=>{let ee=G.stringifyIdent(ne);if(!Ee.test(ee))return!1;if(g.reference==="unknown")return!0;let we=G.isVirtualLocator(ne),be=we?G.devirtualizeLocator(ne):ne;return!(De&&we&&g.reference!==ne.reference||ce.reference!==be.reference)}}),ae=_e.sortMap([...ue],Fe=>G.stringifyLocator(Fe));return{selection:ae.filter(Fe=>ye.length===0||ye.some(g=>g(Fe))),sortedLookup:ae}},{selection:I,sortedLookup:v}=C({all:this.all,recursive:this.recursive});if(I.length===0)throw new it("No package matched your request");let b=new Map;if(this.dependents)for(let le of v)for(let pe of le.dependencies.values()){let ue=o.storedResolutions.get(pe.descriptorHash);if(typeof ue>"u")throw new Error("Assertion failed: Expected the resolution to be registered");_e.getArrayWithDefault(b,ue).push(le)}let E=new Map;for(let le of v){if(!G.isVirtualLocator(le))continue;let pe=G.devirtualizeLocator(le);_e.getArrayWithDefault(E,pe.locatorHash).push(le)}let F={},N={children:F},U=r.makeFetcher(),z={project:o,fetcher:U,cache:n,checksums:o.storedChecksums,report:new Qi,cacheOptions:{skipIntegrityCheck:!0}},te=[async(le,pe,ue)=>{if(!pe.has("manifest"))return;let ye=await U.fetch(le,z),ae;try{ae=await Ot.find(ye.prefixPath,{baseFs:ye.packageFs})}finally{ye.releaseFs?.()}ue("Manifest",{License:de.tuple(de.Type.NO_HINT,ae.license),Homepage:de.tuple(de.Type.URL,ae.raw.homepage??null)})},async(le,pe,ue)=>{if(!pe.has("cache"))return;let ye=o.storedChecksums.get(le.locatorHash)??null,ae=n.getLocatorPath(le,ye),Ie;if(ae!==null)try{Ie=await oe.statPromise(ae)}catch{}let Fe=typeof Ie<"u"?[Ie.size,de.Type.SIZE]:void 0;ue("Cache",{Checksum:de.tuple(de.Type.NO_HINT,ye),Path:de.tuple(de.Type.PATH,ae),Size:Fe})}];for(let le of I){let pe=G.isVirtualLocator(le);if(!this.virtuals&&pe)continue;let ue={},ye={value:[le,de.Type.LOCATOR],children:ue};if(F[G.stringifyLocator(le)]=ye,this.nameOnly){delete ye.children;continue}let ae=E.get(le.locatorHash);typeof ae<"u"&&(ue.Instances={label:"Instances",value:de.tuple(de.Type.NUMBER,ae.length)}),ue.Version={label:"Version",value:de.tuple(de.Type.NO_HINT,le.version)};let Ie=(g,Ee)=>{let De={};if(ue[g]=De,Array.isArray(Ee))De.children=Ee.map(ce=>({value:ce}));else{let ce={};De.children=ce;for(let[ne,ee]of Object.entries(Ee))typeof ee>"u"||(ce[ne]={label:ne,value:ee})}};if(!pe){for(let g of te)await g(le,u,Ie);await r.triggerHook(g=>g.fetchPackageInfo,le,u,Ie)}le.bin.size>0&&!pe&&Ie("Exported Binaries",[...le.bin.keys()].map(g=>de.tuple(de.Type.PATH,g)));let Fe=b.get(le.locatorHash);typeof Fe<"u"&&Fe.length>0&&Ie("Dependents",Fe.map(g=>de.tuple(de.Type.LOCATOR,g))),le.dependencies.size>0&&!pe&&Ie("Dependencies",[...le.dependencies.values()].map(g=>{let Ee=o.storedResolutions.get(g.descriptorHash),De=typeof Ee<"u"?o.storedPackages.get(Ee)??null:null;return de.tuple(de.Type.RESOLUTION,{descriptor:g,locator:De})})),le.peerDependencies.size>0&&pe&&Ie("Peer dependencies",[...le.peerDependencies.values()].map(g=>{let Ee=le.dependencies.get(g.identHash),De=typeof Ee<"u"?o.storedResolutions.get(Ee.descriptorHash)??null:null,ce=De!==null?o.storedPackages.get(De)??null:null;return de.tuple(de.Type.RESOLUTION,{descriptor:g,locator:ce})}))}$s.emitTree(N,{configuration:r,json:this.json,stdout:this.context.stdout,separators:this.nameOnly?0:2})}};Hh.paths=[["info"]],Hh.usage=nt.Usage({description:"see information related to packages",details:"\n This command prints various information related to the specified packages, accepting glob patterns.\n\n By default, if the locator reference is missing, Yarn will default to print the information about all the matching direct dependencies of the package for the active workspace. To instead print all versions of the package that are direct dependencies of any of your workspaces, use the `-A,--all` flag. Adding the `-R,--recursive` flag will also report transitive dependencies.\n\n Some fields will be hidden by default in order to keep the output readable, but can be selectively displayed by using additional options (`--dependents`, `--manifest`, `--virtuals`, ...) described in the option descriptions.\n\n Note that this command will only print the information directly related to the selected packages - if you wish to know why the package is there in the first place, use `yarn why` which will do just that (it also provides a `-R,--recursive` flag that may be of some help).\n ",examples:[["Show information about Lodash","$0 info lodash"]]});Ye();Pt();Ll();var hk=$e($g());qt();var O8=$e(Jn());Za();var Y0t=[{selector:t=>t===-1,name:"nodeLinker",value:"node-modules"},{selector:t=>t!==-1&&t<8,name:"enableGlobalCache",value:!1},{selector:t=>t!==-1&&t<8,name:"compressionLevel",value:"mixed"}],jh=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.immutable=ge.Boolean("--immutable",{description:"Abort with an error exit code if the lockfile was to be modified"});this.immutableCache=ge.Boolean("--immutable-cache",{description:"Abort with an error exit code if the cache folder was to be modified"});this.refreshLockfile=ge.Boolean("--refresh-lockfile",{description:"Refresh the package metadata stored in the lockfile"});this.checkCache=ge.Boolean("--check-cache",{description:"Always refetch the packages and ensure that their checksums are consistent"});this.checkResolutions=ge.Boolean("--check-resolutions",{description:"Validates that the package resolutions are coherent"});this.inlineBuilds=ge.Boolean("--inline-builds",{description:"Verbosely print the output of the build steps of dependencies"});this.mode=ge.String("--mode",{description:"Change what artifacts installs generate",validator:Vs(pl)});this.cacheFolder=ge.String("--cache-folder",{hidden:!0});this.frozenLockfile=ge.Boolean("--frozen-lockfile",{hidden:!0});this.ignoreEngines=ge.Boolean("--ignore-engines",{hidden:!0});this.nonInteractive=ge.Boolean("--non-interactive",{hidden:!0});this.preferOffline=ge.Boolean("--prefer-offline",{hidden:!0});this.production=ge.Boolean("--production",{hidden:!0});this.registry=ge.String("--registry",{hidden:!0});this.silent=ge.Boolean("--silent",{hidden:!0});this.networkTimeout=ge.String("--network-timeout",{hidden:!0})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins);typeof this.inlineBuilds<"u"&&r.useWithSource("<cli>",{enableInlineBuilds:this.inlineBuilds},r.startingCwd,{overwrite:!0});let o=!!process.env.FUNCTION_TARGET||!!process.env.GOOGLE_RUNTIME,a=await LE({configuration:r,stdout:this.context.stdout},[{option:this.ignoreEngines,message:"The --ignore-engines option is deprecated; engine checking isn't a core feature anymore",error:!hk.default.VERCEL},{option:this.registry,message:"The --registry option is deprecated; prefer setting npmRegistryServer in your .yarnrc.yml file"},{option:this.preferOffline,message:"The --prefer-offline flag is deprecated; use the --cached flag with 'yarn add' instead",error:!hk.default.VERCEL},{option:this.production,message:"The --production option is deprecated on 'install'; use 'yarn workspaces focus' instead",error:!0},{option:this.nonInteractive,message:"The --non-interactive option is deprecated",error:!o},{option:this.frozenLockfile,message:"The --frozen-lockfile option is deprecated; use --immutable and/or --immutable-cache instead",callback:()=>this.immutable=this.frozenLockfile},{option:this.cacheFolder,message:"The cache-folder option has been deprecated; use rc settings instead",error:!hk.default.NETLIFY}]);if(a!==null)return a;let n=this.mode==="update-lockfile";if(n&&(this.immutable||this.immutableCache))throw new it(`${de.pretty(r,"--immutable",de.Type.CODE)} and ${de.pretty(r,"--immutable-cache",de.Type.CODE)} cannot be used with ${de.pretty(r,"--mode=update-lockfile",de.Type.CODE)}`);let u=(this.immutable??r.get("enableImmutableInstalls"))&&!n,A=this.immutableCache&&!n;if(r.projectCwd!==null){let E=await Lt.start({configuration:r,json:this.json,stdout:this.context.stdout,includeFooter:!1},async F=>{let N=!1;await V0t(r,u)&&(F.reportInfo(48,"Automatically removed core plugins that are now builtins \u{1F44D}"),N=!0),await K0t(r,u)&&(F.reportInfo(48,"Automatically fixed merge conflicts \u{1F44D}"),N=!0),N&&F.reportSeparator()});if(E.hasErrors())return E.exitCode()}if(r.projectCwd!==null){let E=await Lt.start({configuration:r,json:this.json,stdout:this.context.stdout,includeFooter:!1},async F=>{if(Ke.telemetry?.isNew)Ke.telemetry.commitTips(),F.reportInfo(65,"Yarn will periodically gather anonymous telemetry: https://yarnpkg.com/advanced/telemetry"),F.reportInfo(65,`Run ${de.pretty(r,"yarn config set --home enableTelemetry 0",de.Type.CODE)} to disable`),F.reportSeparator();else if(Ke.telemetry?.shouldShowTips){let N=await rn.get("https://repo.yarnpkg.com/tags",{configuration:r,jsonResponse:!0}).catch(()=>null);if(N!==null){let U=null;if(tn!==null){let te=O8.default.prerelease(tn)?"canary":"stable",le=N.latest[te];O8.default.gt(le,tn)&&(U=[te,le])}if(U)Ke.telemetry.commitTips(),F.reportInfo(88,`${de.applyStyle(r,`A new ${U[0]} version of Yarn is available:`,de.Style.BOLD)} ${G.prettyReference(r,U[1])}!`),F.reportInfo(88,`Upgrade now by running ${de.pretty(r,`yarn set version ${U[1]}`,de.Type.CODE)}`),F.reportSeparator();else{let z=Ke.telemetry.selectTip(N.tips);z&&(F.reportInfo(89,de.pretty(r,z.message,de.Type.MARKDOWN_INLINE)),z.url&&F.reportInfo(89,`Learn more at ${z.url}`),F.reportSeparator())}}}});if(E.hasErrors())return E.exitCode()}let{project:p,workspace:h}=await St.find(r,this.context.cwd),C=p.lockfileLastVersion;if(C!==null){let E=await Lt.start({configuration:r,json:this.json,stdout:this.context.stdout,includeFooter:!1},async F=>{let N={};for(let U of Y0t)U.selector(C)&&typeof r.sources.get(U.name)>"u"&&(r.use("<compat>",{[U.name]:U.value},p.cwd,{overwrite:!0}),N[U.name]=U.value);Object.keys(N).length>0&&(await Ke.updateConfiguration(p.cwd,N),F.reportInfo(87,"Migrated your project to the latest Yarn version \u{1F680}"),F.reportSeparator())});if(E.hasErrors())return E.exitCode()}let I=await Nr.find(r,{immutable:A,check:this.checkCache});if(!h)throw new rr(p.cwd,this.context.cwd);await p.restoreInstallState({restoreResolutions:!1});let v=r.get("enableHardenedMode");(this.refreshLockfile??v)&&(p.lockfileNeedsRefresh=!0);let b=this.checkResolutions??v;return await p.installWithNewReport({json:this.json,stdout:this.context.stdout},{cache:I,immutable:u,checkResolutions:b,mode:this.mode})}};jh.paths=[["install"],nt.Default],jh.usage=nt.Usage({description:"install the project dependencies",details:"\n This command sets up your project if needed. The installation is split into four different steps that each have their own characteristics:\n\n - **Resolution:** First the package manager will resolve your dependencies. The exact way a dependency version is privileged over another isn't standardized outside of the regular semver guarantees. If a package doesn't resolve to what you would expect, check that all dependencies are correctly declared (also check our website for more information: ).\n\n - **Fetch:** Then we download all the dependencies if needed, and make sure that they're all stored within our cache (check the value of `cacheFolder` in `yarn config` to see where the cache files are stored).\n\n - **Link:** Then we send the dependency tree information to internal plugins tasked with writing them on the disk in some form (for example by generating the .pnp.cjs file you might know).\n\n - **Build:** Once the dependency tree has been written on the disk, the package manager will now be free to run the build scripts for all packages that might need it, in a topological order compatible with the way they depend on one another. See https://yarnpkg.com/advanced/lifecycle-scripts for detail.\n\n Note that running this command is not part of the recommended workflow. Yarn supports zero-installs, which means that as long as you store your cache and your .pnp.cjs file inside your repository, everything will work without requiring any install right after cloning your repository or switching branches.\n\n If the `--immutable` option is set (defaults to true on CI), Yarn will abort with an error exit code if the lockfile was to be modified (other paths can be added using the `immutablePatterns` configuration setting). For backward compatibility we offer an alias under the name of `--frozen-lockfile`, but it will be removed in a later release.\n\n If the `--immutable-cache` option is set, Yarn will abort with an error exit code if the cache folder was to be modified (either because files would be added, or because they'd be removed).\n\n If the `--refresh-lockfile` option is set, Yarn will keep the same resolution for the packages currently in the lockfile but will refresh their metadata. If used together with `--immutable`, it can validate that the lockfile information are consistent. This flag is enabled by default when Yarn detects it runs within a pull request context.\n\n If the `--check-cache` option is set, Yarn will always refetch the packages and will ensure that their checksum matches what's 1/ described in the lockfile 2/ inside the existing cache files (if present). This is recommended as part of your CI workflow if you're both following the Zero-Installs model and accepting PRs from third-parties, as they'd otherwise have the ability to alter the checked-in packages before submitting them.\n\n If the `--inline-builds` option is set, Yarn will verbosely print the output of the build steps of your dependencies (instead of writing them into individual files). This is likely useful mostly for debug purposes only when using Docker-like environments.\n\n If the `--mode=<mode>` option is set, Yarn will change which artifacts are generated. The modes currently supported are:\n\n - `skip-build` will not run the build scripts at all. Note that this is different from setting `enableScripts` to false because the latter will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run.\n\n - `update-lockfile` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost.\n ",examples:[["Install the project","$0 install"],["Validate a project when using Zero-Installs","$0 install --immutable --immutable-cache"],["Validate a project when using Zero-Installs (slightly safer if you accept external PRs)","$0 install --immutable --immutable-cache --check-cache"]]});var W0t="<<<<<<<";async function K0t(t,e){if(!t.projectCwd)return!1;let r=V.join(t.projectCwd,dr.lockfile);if(!await oe.existsPromise(r)||!(await oe.readFilePromise(r,"utf8")).includes(W0t))return!1;if(e)throw new Jt(47,"Cannot autofix a lockfile when running an immutable install");let a=await Ur.execvp("git",["rev-parse","MERGE_HEAD","HEAD"],{cwd:t.projectCwd});if(a.code!==0&&(a=await Ur.execvp("git",["rev-parse","REBASE_HEAD","HEAD"],{cwd:t.projectCwd})),a.code!==0&&(a=await Ur.execvp("git",["rev-parse","CHERRY_PICK_HEAD","HEAD"],{cwd:t.projectCwd})),a.code!==0)throw new Jt(83,"Git returned an error when trying to find the commits pertaining to the conflict");let n=await Promise.all(a.stdout.trim().split(/\n/).map(async A=>{let p=await Ur.execvp("git",["show",`${A}:./${dr.lockfile}`],{cwd:t.projectCwd});if(p.code!==0)throw new Jt(83,`Git returned an error when trying to access the lockfile content in ${A}`);try{return Ki(p.stdout)}catch{throw new Jt(46,"A variant of the conflicting lockfile failed to parse")}}));n=n.filter(A=>!!A.__metadata);for(let A of n){if(A.__metadata.version<7)for(let p of Object.keys(A)){if(p==="__metadata")continue;let h=G.parseDescriptor(p,!0),C=t.normalizeDependency(h),I=G.stringifyDescriptor(C);I!==p&&(A[I]=A[p],delete A[p])}for(let p of Object.keys(A)){if(p==="__metadata")continue;let h=A[p].checksum;typeof h=="string"&&h.includes("/")||(A[p].checksum=`${A.__metadata.cacheKey}/${h}`)}}let u=Object.assign({},...n);u.__metadata.version=`${Math.min(...n.map(A=>parseInt(A.__metadata.version??0)))}`,u.__metadata.cacheKey="merged";for(let[A,p]of Object.entries(u))typeof p=="string"&&delete u[A];return await oe.changeFilePromise(r,Ba(u),{automaticNewlines:!0}),!0}async function V0t(t,e){if(!t.projectCwd)return!1;let r=[],o=V.join(t.projectCwd,".yarn/plugins/@yarnpkg");return await Ke.updateConfiguration(t.projectCwd,{plugins:n=>{if(!Array.isArray(n))return n;let u=n.filter(A=>{if(!A.path)return!0;let p=V.resolve(t.projectCwd,A.path),h=I1.has(A.spec)&&V.contains(o,p);return h&&r.push(p),!h});return u.length===0?Ke.deleteProperty:u.length===n.length?n:u}},{immutable:e})?(await Promise.all(r.map(async n=>{await oe.removePromise(n)})),!0):!1}Ye();Pt();qt();var qh=class extends ut{constructor(){super(...arguments);this.all=ge.Boolean("-A,--all",!1,{description:"Link all workspaces belonging to the target projects to the current one"});this.private=ge.Boolean("-p,--private",!1,{description:"Also link private workspaces belonging to the target projects to the current one"});this.relative=ge.Boolean("-r,--relative",!1,{description:"Link workspaces using relative paths instead of absolute paths"});this.destinations=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd),n=await Nr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState({restoreResolutions:!1});let u=o.topLevelWorkspace,A=[];for(let p of this.destinations){let h=V.resolve(this.context.cwd,fe.toPortablePath(p)),C=await Ke.find(h,this.context.plugins,{useRc:!1,strict:!1}),{project:I,workspace:v}=await St.find(C,h);if(o.cwd===I.cwd)throw new it(`Invalid destination '${p}'; Can't link the project to itself`);if(!v)throw new rr(I.cwd,h);if(this.all){let b=!1;for(let E of I.workspaces)E.manifest.name&&(!E.manifest.private||this.private)&&(A.push(E),b=!0);if(!b)throw new it(`No workspace found to be linked in the target project: ${p}`)}else{if(!v.manifest.name)throw new it(`The target workspace at '${p}' doesn't have a name and thus cannot be linked`);if(v.manifest.private&&!this.private)throw new it(`The target workspace at '${p}' is marked private - use the --private flag to link it anyway`);A.push(v)}}for(let p of A){let h=G.stringifyIdent(p.anchoredLocator),C=this.relative?V.relative(o.cwd,p.cwd):p.cwd;u.manifest.resolutions.push({pattern:{descriptor:{fullName:h}},reference:`portal:${C}`})}return await o.installWithNewReport({stdout:this.context.stdout},{cache:n})}};qh.paths=[["link"]],qh.usage=nt.Usage({description:"connect the local project to another one",details:"\n This command will set a new `resolutions` field in the project-level manifest and point it to the workspace at the specified location (even if part of another project).\n ",examples:[["Register one or more remote workspaces for use in the current project","$0 link ~/ts-loader ~/jest"],["Register all workspaces from a remote project for use in the current project","$0 link ~/jest --all"]]});qt();var Gh=class extends ut{constructor(){super(...arguments);this.args=ge.Proxy()}async execute(){return this.cli.run(["exec","node",...this.args])}};Gh.paths=[["node"]],Gh.usage=nt.Usage({description:"run node with the hook already setup",details:` - This command simply runs Node. It also makes sure to call it in a way that's compatible with the current project (for example, on PnP projects the environment will be setup in such a way that PnP will be correctly injected into the environment). - - The Node process will use the exact same version of Node as the one used to run Yarn itself, which might be a good way to ensure that your commands always use a consistent Node version. - `,examples:[["Run a Node script","$0 node ./my-script.js"]]});Ye();qt();var Yh=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=await Ke.findRcFiles(this.context.cwd);return(await Lt.start({configuration:r,json:this.json,stdout:this.context.stdout},async n=>{for(let u of o)if(!!u.data?.plugins)for(let A of u.data.plugins){if(!A.checksum||!A.spec.match(/^https?:/))continue;let p=await rn.get(A.spec,{configuration:r}),h=wn.makeHash(p);if(A.checksum===h)continue;let C=de.pretty(r,A.path,de.Type.PATH),I=de.pretty(r,A.spec,de.Type.URL),v=`${C} is different from the file provided by ${I}`;n.reportJson({...A,newChecksum:h}),n.reportError(0,v)}})).exitCode()}};Yh.paths=[["plugin","check"]],Yh.usage=nt.Usage({category:"Plugin-related commands",description:"find all third-party plugins that differ from their own spec",details:` - Check only the plugins from https. - - If this command detects any plugin differences in the CI environment, it will throw an error. - `,examples:[["find all third-party plugins that differ from their own spec","$0 plugin check"]]});Ye();Ye();Pt();qt();var sde=Be("os");Ye();Pt();qt();var ede=Be("os");Ye();Ll();qt();var z0t="https://raw.githubusercontent.com/yarnpkg/berry/master/plugins.yml";async function zd(t,e){let r=await rn.get(z0t,{configuration:t}),o=Ki(r.toString());return Object.fromEntries(Object.entries(o).filter(([a,n])=>!e||Qr.satisfiesWithPrereleases(e,n.range??"<4.0.0-rc.1")))}var Wh=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins);return(await Lt.start({configuration:r,json:this.json,stdout:this.context.stdout},async a=>{let n=await zd(r,tn);for(let[u,{experimental:A,...p}]of Object.entries(n)){let h=u;A&&(h+=" [experimental]"),a.reportJson({name:u,experimental:A,...p}),a.reportInfo(null,h)}})).exitCode()}};Wh.paths=[["plugin","list"]],Wh.usage=nt.Usage({category:"Plugin-related commands",description:"list the available official plugins",details:"\n This command prints the plugins available directly from the Yarn repository. Only those plugins can be referenced by name in `yarn plugin import`.\n ",examples:[["List the official plugins","$0 plugin list"]]});var J0t=/^[0-9]+$/;function tde(t){return J0t.test(t)?`pull/${t}/head`:t}var X0t=({repository:t,branch:e},r)=>[["git","init",fe.fromPortablePath(r)],["git","remote","add","origin",t],["git","fetch","origin","--depth=1",tde(e)],["git","reset","--hard","FETCH_HEAD"]],Z0t=({branch:t})=>[["git","fetch","origin","--depth=1",tde(t),"--force"],["git","reset","--hard","FETCH_HEAD"],["git","clean","-dfx","-e","packages/yarnpkg-cli/bundles"]],$0t=({plugins:t,noMinify:e},r,o)=>[["yarn","build:cli",...new Array().concat(...t.map(a=>["--plugin",V.resolve(o,a)])),...e?["--no-minify"]:[],"|"],["mv","packages/yarnpkg-cli/bundles/yarn.js",fe.fromPortablePath(r),"|"]],Kh=class extends ut{constructor(){super(...arguments);this.installPath=ge.String("--path",{description:"The path where the repository should be cloned to"});this.repository=ge.String("--repository","https://github.com/yarnpkg/berry.git",{description:"The repository that should be cloned"});this.branch=ge.String("--branch","master",{description:"The branch of the repository that should be cloned"});this.plugins=ge.Array("--plugin",[],{description:"An array of additional plugins that should be included in the bundle"});this.dryRun=ge.Boolean("-n,--dry-run",!1,{description:"If set, the bundle will be built but not added to the project"});this.noMinify=ge.Boolean("--no-minify",!1,{description:"Build a bundle for development (debugging) - non-minified and non-mangled"});this.force=ge.Boolean("-f,--force",!1,{description:"Always clone the repository instead of trying to fetch the latest commits"});this.skipPlugins=ge.Boolean("--skip-plugins",!1,{description:"Skip updating the contrib plugins"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o}=await St.find(r,this.context.cwd),a=typeof this.installPath<"u"?V.resolve(this.context.cwd,fe.toPortablePath(this.installPath)):V.resolve(fe.toPortablePath((0,ede.tmpdir)()),"yarnpkg-sources",wn.makeHash(this.repository).slice(0,6));return(await Lt.start({configuration:r,stdout:this.context.stdout},async u=>{await M8(this,{configuration:r,report:u,target:a}),u.reportSeparator(),u.reportInfo(0,"Building a fresh bundle"),u.reportSeparator();let A=await Ur.execvp("git",["rev-parse","--short","HEAD"],{cwd:a,strict:!0}),p=V.join(a,`packages/yarnpkg-cli/bundles/yarn-${A.stdout.trim()}.js`);oe.existsSync(p)||(await m2($0t(this,p,a),{configuration:r,context:this.context,target:a}),u.reportSeparator());let h=await oe.readFilePromise(p);if(!this.dryRun){let{bundleVersion:C}=await N8(r,null,async()=>h,{report:u});this.skipPlugins||await egt(this,C,{project:o,report:u,target:a})}})).exitCode()}};Kh.paths=[["set","version","from","sources"]],Kh.usage=nt.Usage({description:"build Yarn from master",details:` - This command will clone the Yarn repository into a temporary folder, then build it. The resulting bundle will then be copied into the local project. - - By default, it also updates all contrib plugins to the same commit the bundle is built from. This behavior can be disabled by using the \`--skip-plugins\` flag. - `,examples:[["Build Yarn from master","$0 set version from sources"]]});async function m2(t,{configuration:e,context:r,target:o}){for(let[a,...n]of t){let u=n[n.length-1]==="|";if(u&&n.pop(),u)await Ur.pipevp(a,n,{cwd:o,stdin:r.stdin,stdout:r.stdout,stderr:r.stderr,strict:!0});else{r.stdout.write(`${de.pretty(e,` $ ${[a,...n].join(" ")}`,"grey")} -`);try{await Ur.execvp(a,n,{cwd:o,strict:!0})}catch(A){throw r.stdout.write(A.stdout||A.stack),A}}}}async function M8(t,{configuration:e,report:r,target:o}){let a=!1;if(!t.force&&oe.existsSync(V.join(o,".git"))){r.reportInfo(0,"Fetching the latest commits"),r.reportSeparator();try{await m2(Z0t(t),{configuration:e,context:t.context,target:o}),a=!0}catch{r.reportSeparator(),r.reportWarning(0,"Repository update failed; we'll try to regenerate it")}}a||(r.reportInfo(0,"Cloning the remote repository"),r.reportSeparator(),await oe.removePromise(o),await oe.mkdirPromise(o,{recursive:!0}),await m2(X0t(t,o),{configuration:e,context:t.context,target:o}))}async function egt(t,e,{project:r,report:o,target:a}){let n=await zd(r.configuration,e),u=new Set(Object.keys(n));for(let A of r.configuration.plugins.keys())!u.has(A)||await U8(A,t,{project:r,report:o,target:a})}Ye();Ye();Pt();qt();var rde=$e(Jn()),nde=Be("url"),ide=Be("vm");var Vh=class extends ut{constructor(){super(...arguments);this.name=ge.String();this.checksum=ge.Boolean("--checksum",!0,{description:"Whether to care if this plugin is modified"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins);return(await Lt.start({configuration:r,stdout:this.context.stdout},async a=>{let{project:n}=await St.find(r,this.context.cwd),u,A;if(this.name.match(/^\.{0,2}[\\/]/)||fe.isAbsolute(this.name)){let p=V.resolve(this.context.cwd,fe.toPortablePath(this.name));a.reportInfo(0,`Reading ${de.pretty(r,p,de.Type.PATH)}`),u=V.relative(n.cwd,p),A=await oe.readFilePromise(p)}else{let p;if(this.name.match(/^https?:/)){try{new nde.URL(this.name)}catch{throw new Jt(52,`Plugin specifier "${this.name}" is neither a plugin name nor a valid url`)}u=this.name,p=this.name}else{let h=G.parseLocator(this.name.replace(/^((@yarnpkg\/)?plugin-)?/,"@yarnpkg/plugin-"));if(h.reference!=="unknown"&&!rde.default.valid(h.reference))throw new Jt(0,"Official plugins only accept strict version references. Use an explicit URL if you wish to download them from another location.");let C=G.stringifyIdent(h),I=await zd(r,tn);if(!Object.hasOwn(I,C)){let v=`Couldn't find a plugin named ${G.prettyIdent(r,h)} on the remote registry. -`;throw r.plugins.has(C)?v+=`A plugin named ${G.prettyIdent(r,h)} is already installed; possibly attempting to import a built-in plugin.`:v+=`Note that only the plugins referenced on our website (${de.pretty(r,"https://github.com/yarnpkg/berry/blob/master/plugins.yml",de.Type.URL)}) can be referenced by their name; any other plugin will have to be referenced through its public url (for example ${de.pretty(r,"https://github.com/yarnpkg/berry/raw/master/packages/plugin-typescript/bin/%40yarnpkg/plugin-typescript.js",de.Type.URL)}).`,new Jt(51,v)}u=C,p=I[C].url,h.reference!=="unknown"?p=p.replace(/\/master\//,`/${C}/${h.reference}/`):tn!==null&&(p=p.replace(/\/master\//,`/@yarnpkg/cli/${tn}/`))}a.reportInfo(0,`Downloading ${de.pretty(r,p,"green")}`),A=await rn.get(p,{configuration:r})}await _8(u,A,{checksum:this.checksum,project:n,report:a})})).exitCode()}};Vh.paths=[["plugin","import"]],Vh.usage=nt.Usage({category:"Plugin-related commands",description:"download a plugin",details:` - This command downloads the specified plugin from its remote location and updates the configuration to reference it in further CLI invocations. - - Three types of plugin references are accepted: - - - If the plugin is stored within the Yarn repository, it can be referenced by name. - - Third-party plugins can be referenced directly through their public urls. - - Local plugins can be referenced by their path on the disk. - - If the \`--no-checksum\` option is set, Yarn will no longer care if the plugin is modified. - - Plugins cannot be downloaded from the npm registry, and aren't allowed to have dependencies (they need to be bundled into a single file, possibly thanks to the \`@yarnpkg/builder\` package). - `,examples:[['Download and activate the "@yarnpkg/plugin-exec" plugin',"$0 plugin import @yarnpkg/plugin-exec"],['Download and activate the "@yarnpkg/plugin-exec" plugin (shorthand)',"$0 plugin import exec"],["Download and activate a community plugin","$0 plugin import https://example.org/path/to/plugin.js"],["Activate a local plugin","$0 plugin import ./path/to/plugin.js"]]});async function _8(t,e,{checksum:r=!0,project:o,report:a}){let{configuration:n}=o,u={},A={exports:u};(0,ide.runInNewContext)(e.toString(),{module:A,exports:u});let h=`.yarn/plugins/${A.exports.name}.cjs`,C=V.resolve(o.cwd,h);a.reportInfo(0,`Saving the new plugin in ${de.pretty(n,h,"magenta")}`),await oe.mkdirPromise(V.dirname(C),{recursive:!0}),await oe.writeFilePromise(C,e);let I={path:h,spec:t};r&&(I.checksum=wn.makeHash(e)),await Ke.addPlugin(o.cwd,[I])}var tgt=({pluginName:t,noMinify:e},r)=>[["yarn",`build:${t}`,...e?["--no-minify"]:[],"|"]],zh=class extends ut{constructor(){super(...arguments);this.installPath=ge.String("--path",{description:"The path where the repository should be cloned to"});this.repository=ge.String("--repository","https://github.com/yarnpkg/berry.git",{description:"The repository that should be cloned"});this.branch=ge.String("--branch","master",{description:"The branch of the repository that should be cloned"});this.noMinify=ge.Boolean("--no-minify",!1,{description:"Build a plugin for development (debugging) - non-minified and non-mangled"});this.force=ge.Boolean("-f,--force",!1,{description:"Always clone the repository instead of trying to fetch the latest commits"});this.name=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=typeof this.installPath<"u"?V.resolve(this.context.cwd,fe.toPortablePath(this.installPath)):V.resolve(fe.toPortablePath((0,sde.tmpdir)()),"yarnpkg-sources",wn.makeHash(this.repository).slice(0,6));return(await Lt.start({configuration:r,stdout:this.context.stdout},async n=>{let{project:u}=await St.find(r,this.context.cwd),A=G.parseIdent(this.name.replace(/^((@yarnpkg\/)?plugin-)?/,"@yarnpkg/plugin-")),p=G.stringifyIdent(A),h=await zd(r,tn);if(!Object.hasOwn(h,p))throw new Jt(51,`Couldn't find a plugin named "${p}" on the remote registry. Note that only the plugins referenced on our website (https://github.com/yarnpkg/berry/blob/master/plugins.yml) can be built and imported from sources.`);let C=p;await M8(this,{configuration:r,report:n,target:o}),await U8(C,this,{project:u,report:n,target:o})})).exitCode()}};zh.paths=[["plugin","import","from","sources"]],zh.usage=nt.Usage({category:"Plugin-related commands",description:"build a plugin from sources",details:` - This command clones the Yarn repository into a temporary folder, builds the specified contrib plugin and updates the configuration to reference it in further CLI invocations. - - The plugins can be referenced by their short name if sourced from the official Yarn repository. - `,examples:[['Build and activate the "@yarnpkg/plugin-exec" plugin',"$0 plugin import from sources @yarnpkg/plugin-exec"],['Build and activate the "@yarnpkg/plugin-exec" plugin (shorthand)',"$0 plugin import from sources exec"]]});async function U8(t,{context:e,noMinify:r},{project:o,report:a,target:n}){let u=t.replace(/@yarnpkg\//,""),{configuration:A}=o;a.reportSeparator(),a.reportInfo(0,`Building a fresh ${u}`),a.reportSeparator(),await m2(tgt({pluginName:u,noMinify:r},n),{configuration:A,context:e,target:n}),a.reportSeparator();let p=V.resolve(n,`packages/${u}/bundles/${t}.js`),h=await oe.readFilePromise(p);await _8(t,h,{project:o,report:a})}Ye();Pt();qt();var Jh=class extends ut{constructor(){super(...arguments);this.name=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o}=await St.find(r,this.context.cwd);return(await Lt.start({configuration:r,stdout:this.context.stdout},async n=>{let u=this.name,A=G.parseIdent(u);if(!r.plugins.has(u))throw new it(`${G.prettyIdent(r,A)} isn't referenced by the current configuration`);let p=`.yarn/plugins/${u}.cjs`,h=V.resolve(o.cwd,p);oe.existsSync(h)&&(n.reportInfo(0,`Removing ${de.pretty(r,p,de.Type.PATH)}...`),await oe.removePromise(h)),n.reportInfo(0,"Updating the configuration..."),await Ke.updateConfiguration(o.cwd,{plugins:C=>{if(!Array.isArray(C))return C;let I=C.filter(v=>v.path!==p);return I.length===0?Ke.deleteProperty:I.length===C.length?C:I}})})).exitCode()}};Jh.paths=[["plugin","remove"]],Jh.usage=nt.Usage({category:"Plugin-related commands",description:"remove a plugin",details:` - This command deletes the specified plugin from the .yarn/plugins folder and removes it from the configuration. - - **Note:** The plugins have to be referenced by their name property, which can be obtained using the \`yarn plugin runtime\` command. Shorthands are not allowed. - `,examples:[["Remove a plugin imported from the Yarn repository","$0 plugin remove @yarnpkg/plugin-typescript"],["Remove a plugin imported from a local file","$0 plugin remove my-local-plugin"]]});Ye();qt();var Xh=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins);return(await Lt.start({configuration:r,json:this.json,stdout:this.context.stdout},async a=>{for(let n of r.plugins.keys()){let u=this.context.plugins.plugins.has(n),A=n;u&&(A+=" [builtin]"),a.reportJson({name:n,builtin:u}),a.reportInfo(null,`${A}`)}})).exitCode()}};Xh.paths=[["plugin","runtime"]],Xh.usage=nt.Usage({category:"Plugin-related commands",description:"list the active plugins",details:` - This command prints the currently active plugins. Will be displayed both builtin plugins and external plugins. - `,examples:[["List the currently active plugins","$0 plugin runtime"]]});Ye();Ye();qt();var Zh=class extends ut{constructor(){super(...arguments);this.idents=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd),n=await Nr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);let u=new Set;for(let A of this.idents)u.add(G.parseIdent(A).identHash);if(await o.restoreInstallState({restoreResolutions:!1}),await o.resolveEverything({cache:n,report:new Qi}),u.size>0)for(let A of o.storedPackages.values())u.has(A.identHash)&&(o.storedBuildState.delete(A.locatorHash),o.skippedBuilds.delete(A.locatorHash));else o.storedBuildState.clear(),o.skippedBuilds.clear();return await o.installWithNewReport({stdout:this.context.stdout,quiet:this.context.quiet},{cache:n})}};Zh.paths=[["rebuild"]],Zh.usage=nt.Usage({description:"rebuild the project's native packages",details:` - This command will automatically cause Yarn to forget about previous compilations of the given packages and to run them again. - - Note that while Yarn forgets the compilation, the previous artifacts aren't erased from the filesystem and may affect the next builds (in good or bad). To avoid this, you may remove the .yarn/unplugged folder, or any other relevant location where packages might have been stored (Yarn may offer a way to do that automatically in the future). - - By default all packages will be rebuilt, but you can filter the list by specifying the names of the packages you want to clear from memory. - `,examples:[["Rebuild all packages","$0 rebuild"],["Rebuild fsevents only","$0 rebuild fsevents"]]});Ye();Ye();Ye();qt();var H8=$e(Zo());Za();var $h=class extends ut{constructor(){super(...arguments);this.all=ge.Boolean("-A,--all",!1,{description:"Apply the operation to all workspaces from the current project"});this.mode=ge.String("--mode",{description:"Change what artifacts installs generate",validator:Vs(pl)});this.patterns=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd),n=await Nr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState({restoreResolutions:!1});let u=this.all?o.workspaces:[a],A=["dependencies","devDependencies","peerDependencies"],p=[],h=!1,C=[];for(let E of this.patterns){let F=!1,N=G.parseIdent(E);for(let U of u){let z=[...U.manifest.peerDependenciesMeta.keys()];for(let te of(0,H8.default)(z,E))U.manifest.peerDependenciesMeta.delete(te),h=!0,F=!0;for(let te of A){let le=U.manifest.getForScope(te),pe=[...le.values()].map(ue=>G.stringifyIdent(ue));for(let ue of(0,H8.default)(pe,G.stringifyIdent(N))){let{identHash:ye}=G.parseIdent(ue),ae=le.get(ye);if(typeof ae>"u")throw new Error("Assertion failed: Expected the descriptor to be registered");U.manifest[te].delete(ye),C.push([U,te,ae]),h=!0,F=!0}}}F||p.push(E)}let I=p.length>1?"Patterns":"Pattern",v=p.length>1?"don't":"doesn't",b=this.all?"any":"this";if(p.length>0)throw new it(`${I} ${de.prettyList(r,p,de.Type.CODE)} ${v} match any packages referenced by ${b} workspace`);return h?(await r.triggerMultipleHooks(E=>E.afterWorkspaceDependencyRemoval,C),await o.installWithNewReport({stdout:this.context.stdout},{cache:n,mode:this.mode})):0}};$h.paths=[["remove"]],$h.usage=nt.Usage({description:"remove dependencies from the project",details:` - This command will remove the packages matching the specified patterns from the current workspace. - - If the \`--mode=<mode>\` option is set, Yarn will change which artifacts are generated. The modes currently supported are: - - - \`skip-build\` will not run the build scripts at all. Note that this is different from setting \`enableScripts\` to false because the latter will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run. - - - \`update-lockfile\` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost. - - This command accepts glob patterns as arguments (if valid Idents and supported by [micromatch](https://github.com/micromatch/micromatch)). Make sure to escape the patterns, to prevent your own shell from trying to expand them. - `,examples:[["Remove a dependency from the current project","$0 remove lodash"],["Remove a dependency from all workspaces at once","$0 remove lodash --all"],["Remove all dependencies starting with `eslint-`","$0 remove 'eslint-*'"],["Remove all dependencies with the `@babel` scope","$0 remove '@babel/*'"],["Remove all dependencies matching `react-dom` or `react-helmet`","$0 remove 'react-{dom,helmet}'"]]});Ye();Ye();var ode=Be("util"),Jd=class extends ut{async execute(){let e=await Ke.find(this.context.cwd,this.context.plugins),{project:r,workspace:o}=await St.find(e,this.context.cwd);if(!o)throw new rr(r.cwd,this.context.cwd);return(await Lt.start({configuration:e,stdout:this.context.stdout},async n=>{let u=o.manifest.scripts,A=_e.sortMap(u.keys(),C=>C),p={breakLength:1/0,colors:e.get("enableColors"),maxArrayLength:2},h=A.reduce((C,I)=>Math.max(C,I.length),0);for(let[C,I]of u.entries())n.reportInfo(null,`${C.padEnd(h," ")} ${(0,ode.inspect)(I,p)}`)})).exitCode()}};Jd.paths=[["run"]];Ye();Ye();qt();var e0=class extends ut{constructor(){super(...arguments);this.inspect=ge.String("--inspect",!1,{tolerateBoolean:!0,description:"Forwarded to the underlying Node process when executing a binary"});this.inspectBrk=ge.String("--inspect-brk",!1,{tolerateBoolean:!0,description:"Forwarded to the underlying Node process when executing a binary"});this.topLevel=ge.Boolean("-T,--top-level",!1,{description:"Check the root workspace for scripts and/or binaries instead of the current one"});this.binariesOnly=ge.Boolean("-B,--binaries-only",!1,{description:"Ignore any user defined scripts and only check for binaries"});this.require=ge.String("--require",{description:"Forwarded to the underlying Node process when executing a binary"});this.silent=ge.Boolean("--silent",{hidden:!0});this.scriptName=ge.String();this.args=ge.Proxy()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a,locator:n}=await St.find(r,this.context.cwd);await o.restoreInstallState();let u=this.topLevel?o.topLevelWorkspace.anchoredLocator:n;if(!this.binariesOnly&&await un.hasPackageScript(u,this.scriptName,{project:o}))return await un.executePackageScript(u,this.scriptName,this.args,{project:o,stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr});let A=await un.getPackageAccessibleBinaries(u,{project:o});if(A.get(this.scriptName)){let h=[];return this.inspect&&(typeof this.inspect=="string"?h.push(`--inspect=${this.inspect}`):h.push("--inspect")),this.inspectBrk&&(typeof this.inspectBrk=="string"?h.push(`--inspect-brk=${this.inspectBrk}`):h.push("--inspect-brk")),this.require&&h.push(`--require=${this.require}`),await un.executePackageAccessibleBinary(u,this.scriptName,this.args,{cwd:this.context.cwd,project:o,stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr,nodeArgs:h,packageAccessibleBinaries:A})}if(!this.topLevel&&!this.binariesOnly&&a&&this.scriptName.includes(":")){let C=(await Promise.all(o.workspaces.map(async I=>I.manifest.scripts.has(this.scriptName)?I:null))).filter(I=>I!==null);if(C.length===1)return await un.executeWorkspaceScript(C[0],this.scriptName,this.args,{stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr})}if(this.topLevel)throw this.scriptName==="node-gyp"?new it(`Couldn't find a script name "${this.scriptName}" in the top-level (used by ${G.prettyLocator(r,n)}). This typically happens because some package depends on "node-gyp" to build itself, but didn't list it in their dependencies. To fix that, please run "yarn add node-gyp" into your top-level workspace. You also can open an issue on the repository of the specified package to suggest them to use an optional peer dependency.`):new it(`Couldn't find a script name "${this.scriptName}" in the top-level (used by ${G.prettyLocator(r,n)}).`);{if(this.scriptName==="global")throw new it("The 'yarn global' commands have been removed in 2.x - consider using 'yarn dlx' or a third-party plugin instead");let h=[this.scriptName].concat(this.args);for(let[C,I]of uC)for(let v of I)if(h.length>=v.length&&JSON.stringify(h.slice(0,v.length))===JSON.stringify(v))throw new it(`Couldn't find a script named "${this.scriptName}", but a matching command can be found in the ${C} plugin. You can install it with "yarn plugin import ${C}".`);throw new it(`Couldn't find a script named "${this.scriptName}".`)}}};e0.paths=[["run"]],e0.usage=nt.Usage({description:"run a script defined in the package.json",details:` - This command will run a tool. The exact tool that will be executed will depend on the current state of your workspace: - - - If the \`scripts\` field from your local package.json contains a matching script name, its definition will get executed. - - - Otherwise, if one of the local workspace's dependencies exposes a binary with a matching name, this binary will get executed. - - - Otherwise, if the specified name contains a colon character and if one of the workspaces in the project contains exactly one script with a matching name, then this script will get executed. - - Whatever happens, the cwd of the spawned process will be the workspace that declares the script (which makes it possible to call commands cross-workspaces using the third syntax). - `,examples:[["Run the tests from the local workspace","$0 run test"],['Same thing, but without the "run" keyword',"$0 test"],["Inspect Webpack while running","$0 run --inspect-brk webpack"]]});Ye();Ye();qt();var t0=class extends ut{constructor(){super(...arguments);this.save=ge.Boolean("-s,--save",!1,{description:"Persist the resolution inside the top-level manifest"});this.descriptor=ge.String();this.resolution=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd),n=await Nr.find(r);if(await o.restoreInstallState({restoreResolutions:!1}),!a)throw new rr(o.cwd,this.context.cwd);let u=G.parseDescriptor(this.descriptor,!0),A=G.makeDescriptor(u,this.resolution);return o.storedDescriptors.set(u.descriptorHash,u),o.storedDescriptors.set(A.descriptorHash,A),o.resolutionAliases.set(u.descriptorHash,A.descriptorHash),await o.installWithNewReport({stdout:this.context.stdout},{cache:n})}};t0.paths=[["set","resolution"]],t0.usage=nt.Usage({description:"enforce a package resolution",details:'\n This command updates the resolution table so that `descriptor` is resolved by `resolution`.\n\n Note that by default this command only affect the current resolution table - meaning that this "manual override" will disappear if you remove the lockfile, or if the package disappear from the table. If you wish to make the enforced resolution persist whatever happens, add the `-s,--save` flag which will also edit the `resolutions` field from your top-level manifest.\n\n Note that no attempt is made at validating that `resolution` is a valid resolution entry for `descriptor`.\n ',examples:[["Force all instances of lodash@npm:^1.2.3 to resolve to 1.5.0","$0 set resolution lodash@npm:^1.2.3 1.5.0"]]});Ye();Pt();qt();var ade=$e(Zo()),r0=class extends ut{constructor(){super(...arguments);this.all=ge.Boolean("-A,--all",!1,{description:"Unlink all workspaces belonging to the target project from the current one"});this.leadingArguments=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd),n=await Nr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);let u=o.topLevelWorkspace,A=new Set;if(this.leadingArguments.length===0&&this.all)for(let{pattern:p,reference:h}of u.manifest.resolutions)h.startsWith("portal:")&&A.add(p.descriptor.fullName);if(this.leadingArguments.length>0)for(let p of this.leadingArguments){let h=V.resolve(this.context.cwd,fe.toPortablePath(p));if(_e.isPathLike(p)){let C=await Ke.find(h,this.context.plugins,{useRc:!1,strict:!1}),{project:I,workspace:v}=await St.find(C,h);if(!v)throw new rr(I.cwd,h);if(this.all){for(let b of I.workspaces)b.manifest.name&&A.add(G.stringifyIdent(b.anchoredLocator));if(A.size===0)throw new it("No workspace found to be unlinked in the target project")}else{if(!v.manifest.name)throw new it("The target workspace doesn't have a name and thus cannot be unlinked");A.add(G.stringifyIdent(v.anchoredLocator))}}else{let C=[...u.manifest.resolutions.map(({pattern:I})=>I.descriptor.fullName)];for(let I of(0,ade.default)(C,p))A.add(I)}}return u.manifest.resolutions=u.manifest.resolutions.filter(({pattern:p})=>!A.has(p.descriptor.fullName)),await o.installWithNewReport({stdout:this.context.stdout,quiet:this.context.quiet},{cache:n})}};r0.paths=[["unlink"]],r0.usage=nt.Usage({description:"disconnect the local project from another one",details:` - This command will remove any resolutions in the project-level manifest that would have been added via a yarn link with similar arguments. - `,examples:[["Unregister a remote workspace in the current project","$0 unlink ~/ts-loader"],["Unregister all workspaces from a remote project in the current project","$0 unlink ~/jest --all"],["Unregister all previously linked workspaces","$0 unlink --all"],["Unregister all workspaces matching a glob","$0 unlink '@babel/*' 'pkg-{a,b}'"]]});Ye();Ye();Ye();qt();var lde=$e(u2()),j8=$e(Zo());Za();var Kf=class extends ut{constructor(){super(...arguments);this.interactive=ge.Boolean("-i,--interactive",{description:"Offer various choices, depending on the detected upgrade paths"});this.fixed=ge.Boolean("-F,--fixed",!1,{description:"Store dependency tags as-is instead of resolving them"});this.exact=ge.Boolean("-E,--exact",!1,{description:"Don't use any semver modifier on the resolved range"});this.tilde=ge.Boolean("-T,--tilde",!1,{description:"Use the `~` semver modifier on the resolved range"});this.caret=ge.Boolean("-C,--caret",!1,{description:"Use the `^` semver modifier on the resolved range"});this.recursive=ge.Boolean("-R,--recursive",!1,{description:"Resolve again ALL resolutions for those packages"});this.mode=ge.String("--mode",{description:"Change what artifacts installs generate",validator:Vs(pl)});this.patterns=ge.Rest()}async execute(){return this.recursive?await this.executeUpRecursive():await this.executeUpClassic()}async executeUpRecursive(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd),n=await Nr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState({restoreResolutions:!1});let u=[...o.storedDescriptors.values()],A=u.map(C=>G.stringifyIdent(C)),p=new Set;for(let C of this.patterns){if(G.parseDescriptor(C).range!=="unknown")throw new it("Ranges aren't allowed when using --recursive");for(let I of(0,j8.default)(A,C)){let v=G.parseIdent(I);p.add(v.identHash)}}let h=u.filter(C=>p.has(C.identHash));for(let C of h)o.storedDescriptors.delete(C.descriptorHash),o.storedResolutions.delete(C.descriptorHash);return await o.installWithNewReport({stdout:this.context.stdout},{cache:n,mode:this.mode})}async executeUpClassic(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd),n=await Nr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState({restoreResolutions:!1});let u=this.fixed,A=this.interactive??r.get("preferInteractive"),p=f2(this,o),h=A?["keep","reuse","project","latest"]:["project","latest"],C=[],I=[];for(let N of this.patterns){let U=!1,z=G.parseDescriptor(N),te=G.stringifyIdent(z);for(let le of o.workspaces)for(let pe of["dependencies","devDependencies"]){let ye=[...le.manifest.getForScope(pe).values()].map(Ie=>G.stringifyIdent(Ie)),ae=te==="*"?ye:(0,j8.default)(ye,te);for(let Ie of ae){let Fe=G.parseIdent(Ie),g=le.manifest[pe].get(Fe.identHash);if(typeof g>"u")throw new Error("Assertion failed: Expected the descriptor to be registered");let Ee=G.makeDescriptor(Fe,z.range);C.push(Promise.resolve().then(async()=>[le,pe,g,await p2(Ee,{project:o,workspace:le,cache:n,target:pe,fixed:u,modifier:p,strategies:h})])),U=!0}}U||I.push(N)}if(I.length>1)throw new it(`Patterns ${de.prettyList(r,I,de.Type.CODE)} don't match any packages referenced by any workspace`);if(I.length>0)throw new it(`Pattern ${de.prettyList(r,I,de.Type.CODE)} doesn't match any packages referenced by any workspace`);let v=await Promise.all(C),b=await AA.start({configuration:r,stdout:this.context.stdout,suggestInstall:!1},async N=>{for(let[,,U,{suggestions:z,rejections:te}]of v){let le=z.filter(pe=>pe.descriptor!==null);if(le.length===0){let[pe]=te;if(typeof pe>"u")throw new Error("Assertion failed: Expected an error to have been set");let ue=this.cli.error(pe);o.configuration.get("enableNetwork")?N.reportError(27,`${G.prettyDescriptor(r,U)} can't be resolved to a satisfying range - -${ue}`):N.reportError(27,`${G.prettyDescriptor(r,U)} can't be resolved to a satisfying range (note: network resolution has been disabled) - -${ue}`)}else le.length>1&&!A&&N.reportError(27,`${G.prettyDescriptor(r,U)} has multiple possible upgrade strategies; use -i to disambiguate manually`)}});if(b.hasErrors())return b.exitCode();let E=!1,F=[];for(let[N,U,,{suggestions:z}]of v){let te,le=z.filter(ae=>ae.descriptor!==null),pe=le[0].descriptor,ue=le.every(ae=>G.areDescriptorsEqual(ae.descriptor,pe));le.length===1||ue?te=pe:(E=!0,{answer:te}=await(0,lde.prompt)({type:"select",name:"answer",message:`Which range do you want to use in ${G.prettyWorkspace(r,N)} \u276F ${U}?`,choices:z.map(({descriptor:ae,name:Ie,reason:Fe})=>ae?{name:Ie,hint:Fe,descriptor:ae}:{name:Ie,hint:Fe,disabled:!0}),onCancel:()=>process.exit(130),result(ae){return this.find(ae,"descriptor")},stdin:this.context.stdin,stdout:this.context.stdout}));let ye=N.manifest[U].get(te.identHash);if(typeof ye>"u")throw new Error("Assertion failed: This descriptor should have a matching entry");if(ye.descriptorHash!==te.descriptorHash)N.manifest[U].set(te.identHash,te),F.push([N,U,ye,te]);else{let ae=r.makeResolver(),Ie={project:o,resolver:ae},Fe=r.normalizeDependency(ye),g=ae.bindDescriptor(Fe,N.anchoredLocator,Ie);o.forgetResolution(g)}}return await r.triggerMultipleHooks(N=>N.afterWorkspaceDependencyReplacement,F),E&&this.context.stdout.write(` -`),await o.installWithNewReport({stdout:this.context.stdout},{cache:n,mode:this.mode})}};Kf.paths=[["up"]],Kf.usage=nt.Usage({description:"upgrade dependencies across the project",details:"\n This command upgrades the packages matching the list of specified patterns to their latest available version across the whole project (regardless of whether they're part of `dependencies` or `devDependencies` - `peerDependencies` won't be affected). This is a project-wide command: all workspaces will be upgraded in the process.\n\n If `-R,--recursive` is set the command will change behavior and no other switch will be allowed. When operating under this mode `yarn up` will force all ranges matching the selected packages to be resolved again (often to the highest available versions) before being stored in the lockfile. It however won't touch your manifests anymore, so depending on your needs you might want to run both `yarn up` and `yarn up -R` to cover all bases.\n\n If `-i,--interactive` is set (or if the `preferInteractive` settings is toggled on) the command will offer various choices, depending on the detected upgrade paths. Some upgrades require this flag in order to resolve ambiguities.\n\n The, `-C,--caret`, `-E,--exact` and `-T,--tilde` options have the same meaning as in the `add` command (they change the modifier used when the range is missing or a tag, and are ignored when the range is explicitly set).\n\n If the `--mode=<mode>` option is set, Yarn will change which artifacts are generated. The modes currently supported are:\n\n - `skip-build` will not run the build scripts at all. Note that this is different from setting `enableScripts` to false because the latter will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run.\n\n - `update-lockfile` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost.\n\n Generally you can see `yarn up` as a counterpart to what was `yarn upgrade --latest` in Yarn 1 (ie it ignores the ranges previously listed in your manifests), but unlike `yarn upgrade` which only upgraded dependencies in the current workspace, `yarn up` will upgrade all workspaces at the same time.\n\n This command accepts glob patterns as arguments (if valid Descriptors and supported by [micromatch](https://github.com/micromatch/micromatch)). Make sure to escape the patterns, to prevent your own shell from trying to expand them.\n\n **Note:** The ranges have to be static, only the package scopes and names can contain glob patterns.\n ",examples:[["Upgrade all instances of lodash to the latest release","$0 up lodash"],["Upgrade all instances of lodash to the latest release, but ask confirmation for each","$0 up lodash -i"],["Upgrade all instances of lodash to 1.2.3","$0 up lodash@1.2.3"],["Upgrade all instances of packages with the `@babel` scope to the latest release","$0 up '@babel/*'"],["Upgrade all instances of packages containing the word `jest` to the latest release","$0 up '*jest*'"],["Upgrade all instances of packages with the `@babel` scope to 7.0.0","$0 up '@babel/*@7.0.0'"]]}),Kf.schema=[aI("recursive",Gu.Forbids,["interactive","exact","tilde","caret"],{ignore:[void 0,!1]})];Ye();Ye();Ye();qt();var n0=class extends ut{constructor(){super(...arguments);this.recursive=ge.Boolean("-R,--recursive",!1,{description:"List, for each workspace, what are all the paths that lead to the dependency"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.peers=ge.Boolean("--peers",!1,{description:"Also print the peer dependencies that match the specified name"});this.package=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState();let n=G.parseIdent(this.package).identHash,u=this.recursive?ngt(o,n,{configuration:r,peers:this.peers}):rgt(o,n,{configuration:r,peers:this.peers});$s.emitTree(u,{configuration:r,stdout:this.context.stdout,json:this.json,separators:1})}};n0.paths=[["why"]],n0.usage=nt.Usage({description:"display the reason why a package is needed",details:` - This command prints the exact reasons why a package appears in the dependency tree. - - If \`-R,--recursive\` is set, the listing will go in depth and will list, for each workspaces, what are all the paths that lead to the dependency. Note that the display is somewhat optimized in that it will not print the package listing twice for a single package, so if you see a leaf named "Foo" when looking for "Bar", it means that "Foo" already got printed higher in the tree. - `,examples:[["Explain why lodash is used in your project","$0 why lodash"]]});function rgt(t,e,{configuration:r,peers:o}){let a=_e.sortMap(t.storedPackages.values(),A=>G.stringifyLocator(A)),n={},u={children:n};for(let A of a){let p={};for(let C of A.dependencies.values()){if(!o&&A.peerDependencies.has(C.identHash))continue;let I=t.storedResolutions.get(C.descriptorHash);if(!I)throw new Error("Assertion failed: The resolution should have been registered");let v=t.storedPackages.get(I);if(!v)throw new Error("Assertion failed: The package should have been registered");if(v.identHash!==e)continue;{let E=G.stringifyLocator(A);n[E]={value:[A,de.Type.LOCATOR],children:p}}let b=G.stringifyLocator(v);p[b]={value:[{descriptor:C,locator:v},de.Type.DEPENDENT]}}}return u}function ngt(t,e,{configuration:r,peers:o}){let a=_e.sortMap(t.workspaces,v=>G.stringifyLocator(v.anchoredLocator)),n=new Set,u=new Set,A=v=>{if(n.has(v.locatorHash))return u.has(v.locatorHash);if(n.add(v.locatorHash),v.identHash===e)return u.add(v.locatorHash),!0;let b=!1;v.identHash===e&&(b=!0);for(let E of v.dependencies.values()){if(!o&&v.peerDependencies.has(E.identHash))continue;let F=t.storedResolutions.get(E.descriptorHash);if(!F)throw new Error("Assertion failed: The resolution should have been registered");let N=t.storedPackages.get(F);if(!N)throw new Error("Assertion failed: The package should have been registered");A(N)&&(b=!0)}return b&&u.add(v.locatorHash),b};for(let v of a)A(v.anchoredPackage);let p=new Set,h={},C={children:h},I=(v,b,E)=>{if(!u.has(v.locatorHash))return;let F=E!==null?de.tuple(de.Type.DEPENDENT,{locator:v,descriptor:E}):de.tuple(de.Type.LOCATOR,v),N={},U={value:F,children:N},z=G.stringifyLocator(v);if(b[z]=U,!p.has(v.locatorHash)&&(p.add(v.locatorHash),!(E!==null&&t.tryWorkspaceByLocator(v))))for(let te of v.dependencies.values()){if(!o&&v.peerDependencies.has(te.identHash))continue;let le=t.storedResolutions.get(te.descriptorHash);if(!le)throw new Error("Assertion failed: The resolution should have been registered");let pe=t.storedPackages.get(le);if(!pe)throw new Error("Assertion failed: The package should have been registered");I(pe,N,te)}};for(let v of a)I(v.anchoredPackage,h,null);return C}Ye();var Z8={};Vt(Z8,{GitFetcher:()=>E2,GitResolver:()=>C2,default:()=>vgt,gitUtils:()=>ra});Ye();Pt();var ra={};Vt(ra,{TreeishProtocols:()=>y2,clone:()=>X8,fetchBase:()=>kde,fetchChangedFiles:()=>Qde,fetchChangedWorkspaces:()=>Igt,fetchRoot:()=>bde,isGitUrl:()=>EC,lsRemote:()=>xde,normalizeLocator:()=>wgt,normalizeRepoUrl:()=>mC,resolveUrl:()=>J8,splitRepoUrl:()=>i0,validateRepoUrl:()=>z8});Ye();Pt();qt();var Dde=$e(Ide()),Pde=$e(mU()),yC=$e(Be("querystring")),K8=$e(Jn());function W8(t,e,r){let o=t.indexOf(r);return t.lastIndexOf(e,o>-1?o:1/0)}function Bde(t){try{return new URL(t)}catch{return}}function Egt(t){let e=W8(t,"@","#"),r=W8(t,":","#");return r>e&&(t=`${t.slice(0,r)}/${t.slice(r+1)}`),W8(t,":","#")===-1&&t.indexOf("//")===-1&&(t=`ssh://${t}`),t}function vde(t){return Bde(t)||Bde(Egt(t))}function mC(t,{git:e=!1}={}){if(t=t.replace(/^git\+https:/,"https:"),t=t.replace(/^(?:github:|https:\/\/github\.com\/|git:\/\/github\.com\/)?(?!\.{1,2}\/)([a-zA-Z0-9._-]+)\/(?!\.{1,2}(?:#|$))([a-zA-Z0-9._-]+?)(?:\.git)?(#.*)?$/,"https://github.com/$1/$2.git$3"),t=t.replace(/^https:\/\/github\.com\/(?!\.{1,2}\/)([a-zA-Z0-9._-]+)\/(?!\.{1,2}(?:#|$))([a-zA-Z0-9._-]+?)\/tarball\/(.+)?$/,"https://github.com/$1/$2.git#$3"),e){let r=vde(t);r&&(t=r.href),t=t.replace(/^git\+([^:]+):/,"$1:")}return t}function Sde(){return{...process.env,GIT_SSH_COMMAND:process.env.GIT_SSH_COMMAND||`${process.env.GIT_SSH||"ssh"} -o BatchMode=yes`}}var Cgt=[/^ssh:/,/^git(?:\+[^:]+)?:/,/^(?:git\+)?https?:[^#]+\/[^#]+(?:\.git)(?:#.*)?$/,/^git@[^#]+\/[^#]+\.git(?:#.*)?$/,/^(?:github:|https:\/\/github\.com\/)?(?!\.{1,2}\/)([a-zA-Z._0-9-]+)\/(?!\.{1,2}(?:#|$))([a-zA-Z._0-9-]+?)(?:\.git)?(?:#.*)?$/,/^https:\/\/github\.com\/(?!\.{1,2}\/)([a-zA-Z0-9._-]+)\/(?!\.{1,2}(?:#|$))([a-zA-Z0-9._-]+?)\/tarball\/(.+)?$/],y2=(a=>(a.Commit="commit",a.Head="head",a.Tag="tag",a.Semver="semver",a))(y2||{});function EC(t){return t?Cgt.some(e=>!!t.match(e)):!1}function i0(t){t=mC(t);let e=t.indexOf("#");if(e===-1)return{repo:t,treeish:{protocol:"head",request:"HEAD"},extra:{}};let r=t.slice(0,e),o=t.slice(e+1);if(o.match(/^[a-z]+=/)){let a=yC.default.parse(o);for(let[p,h]of Object.entries(a))if(typeof h!="string")throw new Error(`Assertion failed: The ${p} parameter must be a literal string`);let n=Object.values(y2).find(p=>Object.hasOwn(a,p)),[u,A]=typeof n<"u"?[n,a[n]]:["head","HEAD"];for(let p of Object.values(y2))delete a[p];return{repo:r,treeish:{protocol:u,request:A},extra:a}}else{let a=o.indexOf(":"),[n,u]=a===-1?[null,o]:[o.slice(0,a),o.slice(a+1)];return{repo:r,treeish:{protocol:n,request:u},extra:{}}}}function wgt(t){return G.makeLocator(t,mC(t.reference))}function z8(t,{configuration:e}){let r=mC(t,{git:!0});if(!rn.getNetworkSettings(`https://${(0,Dde.default)(r).resource}`,{configuration:e}).enableNetwork)throw new Jt(80,`Request to '${r}' has been blocked because of your configuration settings`);return r}async function xde(t,e){let r=z8(t,{configuration:e}),o=await V8("listing refs",["ls-remote",r],{cwd:e.startingCwd,env:Sde()},{configuration:e,normalizedRepoUrl:r}),a=new Map,n=/^([a-f0-9]{40})\t([^\n]+)/gm,u;for(;(u=n.exec(o.stdout))!==null;)a.set(u[2],u[1]);return a}async function J8(t,e){let{repo:r,treeish:{protocol:o,request:a},extra:n}=i0(t),u=await xde(r,e),A=(h,C)=>{switch(h){case"commit":{if(!C.match(/^[a-f0-9]{40}$/))throw new Error("Invalid commit hash");return yC.default.stringify({...n,commit:C})}case"head":{let I=u.get(C==="HEAD"?C:`refs/heads/${C}`);if(typeof I>"u")throw new Error(`Unknown head ("${C}")`);return yC.default.stringify({...n,commit:I})}case"tag":{let I=u.get(`refs/tags/${C}`);if(typeof I>"u")throw new Error(`Unknown tag ("${C}")`);return yC.default.stringify({...n,commit:I})}case"semver":{let I=Qr.validRange(C);if(!I)throw new Error(`Invalid range ("${C}")`);let v=new Map([...u.entries()].filter(([E])=>E.startsWith("refs/tags/")).map(([E,F])=>[K8.default.parse(E.slice(10)),F]).filter(E=>E[0]!==null)),b=K8.default.maxSatisfying([...v.keys()],I);if(b===null)throw new Error(`No matching range ("${C}")`);return yC.default.stringify({...n,commit:v.get(b)})}case null:{let I;if((I=p("commit",C))!==null||(I=p("tag",C))!==null||(I=p("head",C))!==null)return I;throw C.match(/^[a-f0-9]+$/)?new Error(`Couldn't resolve "${C}" as either a commit, a tag, or a head - if a commit, use the 40-characters commit hash`):new Error(`Couldn't resolve "${C}" as either a commit, a tag, or a head`)}default:throw new Error(`Invalid Git resolution protocol ("${h}")`)}},p=(h,C)=>{try{return A(h,C)}catch{return null}};return mC(`${r}#${A(o,a)}`)}async function X8(t,e){return await e.getLimit("cloneConcurrency")(async()=>{let{repo:r,treeish:{protocol:o,request:a}}=i0(t);if(o!=="commit")throw new Error("Invalid treeish protocol when cloning");let n=z8(r,{configuration:e}),u=await oe.mktempPromise(),A={cwd:u,env:Sde()};return await V8("cloning the repository",["clone","-c core.autocrlf=false",n,fe.fromPortablePath(u)],A,{configuration:e,normalizedRepoUrl:n}),await V8("switching branch",["checkout",`${a}`],A,{configuration:e,normalizedRepoUrl:n}),u})}async function bde(t){let e,r=t;do{if(e=r,await oe.existsPromise(V.join(e,".git")))return e;r=V.dirname(e)}while(r!==e);return null}async function kde(t,{baseRefs:e}){if(e.length===0)throw new it("Can't run this command with zero base refs specified.");let r=[];for(let A of e){let{code:p}=await Ur.execvp("git",["merge-base",A,"HEAD"],{cwd:t});p===0&&r.push(A)}if(r.length===0)throw new it(`No ancestor could be found between any of HEAD and ${e.join(", ")}`);let{stdout:o}=await Ur.execvp("git",["merge-base","HEAD",...r],{cwd:t,strict:!0}),a=o.trim(),{stdout:n}=await Ur.execvp("git",["show","--quiet","--pretty=format:%s",a],{cwd:t,strict:!0}),u=n.trim();return{hash:a,title:u}}async function Qde(t,{base:e,project:r}){let o=_e.buildIgnorePattern(r.configuration.get("changesetIgnorePatterns")),{stdout:a}=await Ur.execvp("git",["diff","--name-only",`${e}`],{cwd:t,strict:!0}),n=a.split(/\r\n|\r|\n/).filter(h=>h.length>0).map(h=>V.resolve(t,fe.toPortablePath(h))),{stdout:u}=await Ur.execvp("git",["ls-files","--others","--exclude-standard"],{cwd:t,strict:!0}),A=u.split(/\r\n|\r|\n/).filter(h=>h.length>0).map(h=>V.resolve(t,fe.toPortablePath(h))),p=[...new Set([...n,...A].sort())];return o?p.filter(h=>!V.relative(r.cwd,h).match(o)):p}async function Igt({ref:t,project:e}){if(e.configuration.projectCwd===null)throw new it("This command can only be run from within a Yarn project");let r=[V.resolve(e.cwd,dr.lockfile),V.resolve(e.cwd,e.configuration.get("cacheFolder")),V.resolve(e.cwd,e.configuration.get("installStatePath")),V.resolve(e.cwd,e.configuration.get("virtualFolder"))];await e.configuration.triggerHook(u=>u.populateYarnPaths,e,u=>{u!=null&&r.push(u)});let o=await bde(e.configuration.projectCwd);if(o==null)throw new it("This command can only be run on Git repositories");let a=await kde(o,{baseRefs:typeof t=="string"?[t]:e.configuration.get("changesetBaseRefs")}),n=await Qde(o,{base:a.hash,project:e});return new Set(_e.mapAndFilter(n,u=>{let A=e.tryWorkspaceByFilePath(u);return A===null?_e.mapAndFilter.skip:r.some(p=>u.startsWith(p))?_e.mapAndFilter.skip:A}))}async function V8(t,e,r,{configuration:o,normalizedRepoUrl:a}){try{return await Ur.execvp("git",e,{...r,strict:!0})}catch(n){if(!(n instanceof Ur.ExecError))throw n;let u=n.reportExtra,A=n.stderr.toString();throw new Jt(1,`Failed ${t}`,p=>{p.reportError(1,` ${de.prettyField(o,{label:"Repository URL",value:de.tuple(de.Type.URL,a)})}`);for(let h of A.matchAll(/^(.+?): (.*)$/gm)){let[,C,I]=h;C=C.toLowerCase();let v=C==="error"?"Error":`${(0,Pde.default)(C)} Error`;p.reportError(1,` ${de.prettyField(o,{label:v,value:de.tuple(de.Type.NO_HINT,I)})}`)}u?.(p)})}}var E2=class{supports(e,r){return EC(e.reference)}getLocalPath(e,r){return null}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,a=new Map(r.checksums);a.set(e.locatorHash,o);let n={...r,checksums:a},u=await this.downloadHosted(e,n);if(u!==null)return u;let[A,p,h]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${G.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the remote repository`),loader:()=>this.cloneFromRemote(e,n),...r.cacheOptions});return{packageFs:A,releaseFs:p,prefixPath:G.getIdentVendorPath(e),checksum:h}}async downloadHosted(e,r){return r.project.configuration.reduceHook(o=>o.fetchHostedRepository,null,e,r)}async cloneFromRemote(e,r){let o=await X8(e.reference,r.project.configuration),a=i0(e.reference),n=V.join(o,"package.tgz");await un.prepareExternalProject(o,n,{configuration:r.project.configuration,report:r.report,workspace:a.extra.workspace,locator:e});let u=await oe.readFilePromise(n);return await _e.releaseAfterUseAsync(async()=>await Xi.convertToZip(u,{configuration:r.project.configuration,prefixPath:G.getIdentVendorPath(e),stripComponents:1}))}};Ye();Ye();var C2=class{supportsDescriptor(e,r){return EC(e.range)}supportsLocator(e,r){return EC(e.reference)}shouldPersistResolution(e,r){return!0}bindDescriptor(e,r,o){return e}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){let a=await J8(e.range,o.project.configuration);return[G.makeLocator(e,a)]}async getSatisfying(e,r,o,a){let n=i0(e.range);return{locators:o.filter(A=>{if(A.identHash!==e.identHash)return!1;let p=i0(A.reference);return!(n.repo!==p.repo||n.treeish.protocol==="commit"&&n.treeish.request!==p.treeish.request)}),sorted:!1}}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let o=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),a=await _e.releaseAfterUseAsync(async()=>await Ot.find(o.prefixPath,{baseFs:o.packageFs}),o.releaseFs);return{...e,version:a.version||"0.0.0",languageName:a.languageName||r.project.configuration.get("defaultLanguageName"),linkType:"HARD",conditions:a.getConditions(),dependencies:r.project.configuration.normalizeDependencyMap(a.dependencies),peerDependencies:a.peerDependencies,dependenciesMeta:a.dependenciesMeta,peerDependenciesMeta:a.peerDependenciesMeta,bin:a.bin}}};var Bgt={configuration:{changesetBaseRefs:{description:"The base git refs that the current HEAD is compared against when detecting changes. Supports git branches, tags, and commits.",type:"STRING",isArray:!0,isNullable:!1,default:["master","origin/master","upstream/master","main","origin/main","upstream/main"]},changesetIgnorePatterns:{description:"Array of glob patterns; files matching them will be ignored when fetching the changed files",type:"STRING",default:[],isArray:!0},cloneConcurrency:{description:"Maximal number of concurrent clones",type:"NUMBER",default:2}},fetchers:[E2],resolvers:[C2]};var vgt=Bgt;qt();var s0=class extends ut{constructor(){super(...arguments);this.since=ge.String("--since",{description:"Only include workspaces that have been changed since the specified ref.",tolerateBoolean:!0});this.recursive=ge.Boolean("-R,--recursive",!1,{description:"Find packages via dependencies/devDependencies instead of using the workspaces field"});this.noPrivate=ge.Boolean("--no-private",{description:"Exclude workspaces that have the private field set to true"});this.verbose=ge.Boolean("-v,--verbose",!1,{description:"Also return the cross-dependencies between workspaces"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o}=await St.find(r,this.context.cwd);return(await Lt.start({configuration:r,json:this.json,stdout:this.context.stdout},async n=>{let u=this.since?await ra.fetchChangedWorkspaces({ref:this.since,project:o}):o.workspaces,A=new Set(u);if(this.recursive)for(let p of[...u].map(h=>h.getRecursiveWorkspaceDependents()))for(let h of p)A.add(h);for(let p of A){let{manifest:h}=p;if(h.private&&this.noPrivate)continue;let C;if(this.verbose){let I=new Set,v=new Set;for(let b of Ot.hardDependencies)for(let[E,F]of h.getForScope(b)){let N=o.tryWorkspaceByDescriptor(F);N===null?o.workspacesByIdent.has(E)&&v.add(F):I.add(N)}C={workspaceDependencies:Array.from(I).map(b=>b.relativeCwd),mismatchedWorkspaceDependencies:Array.from(v).map(b=>G.stringifyDescriptor(b))}}n.reportInfo(null,`${p.relativeCwd}`),n.reportJson({location:p.relativeCwd,name:h.name?G.stringifyIdent(h.name):null,...C})}})).exitCode()}};s0.paths=[["workspaces","list"]],s0.usage=nt.Usage({category:"Workspace-related commands",description:"list all available workspaces",details:"\n This command will print the list of all workspaces in the project.\n\n - If `--since` is set, Yarn will only list workspaces that have been modified since the specified ref. By default Yarn will use the refs specified by the `changesetBaseRefs` configuration option.\n\n - If `-R,--recursive` is set, Yarn will find workspaces to run the command on by recursively evaluating `dependencies` and `devDependencies` fields, instead of looking at the `workspaces` fields.\n\n - If `--no-private` is set, Yarn will not list any workspaces that have the `private` field set to `true`.\n\n - If both the `-v,--verbose` and `--json` options are set, Yarn will also return the cross-dependencies between each workspaces (useful when you wish to automatically generate Buck / Bazel rules).\n "});Ye();Ye();qt();var o0=class extends ut{constructor(){super(...arguments);this.workspaceName=ge.String();this.commandName=ge.String();this.args=ge.Proxy()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);let n=o.workspaces,u=new Map(n.map(p=>[G.stringifyIdent(p.anchoredLocator),p])),A=u.get(this.workspaceName);if(A===void 0){let p=Array.from(u.keys()).sort();throw new it(`Workspace '${this.workspaceName}' not found. Did you mean any of the following: - - ${p.join(` - - `)}?`)}return this.cli.run([this.commandName,...this.args],{cwd:A.cwd})}};o0.paths=[["workspace"]],o0.usage=nt.Usage({category:"Workspace-related commands",description:"run a command within the specified workspace",details:` - This command will run a given sub-command on a single workspace. - `,examples:[["Add a package to a single workspace","yarn workspace components add -D react"],["Run build script on a single workspace","yarn workspace components run build"]]});var Dgt={configuration:{enableImmutableInstalls:{description:"If true (the default on CI), prevents the install command from modifying the lockfile",type:"BOOLEAN",default:Fde.isCI},defaultSemverRangePrefix:{description:"The default save prefix: '^', '~' or ''",type:"STRING",values:["^","~",""],default:"^"},preferReuse:{description:"If true, `yarn add` will attempt to reuse the most common dependency range in other workspaces.",type:"BOOLEAN",default:!1}},commands:[Qh,Fh,Rh,Th,t0,Kh,Uh,s0,Wd,Kd,dC,Vd,bh,kh,Lh,Nh,Oh,Mh,_h,Hh,jh,qh,r0,Gh,Yh,zh,Vh,Jh,Wh,Xh,Zh,$h,Jd,e0,Kf,n0,o0]},Pgt=Dgt;var iH={};Vt(iH,{default:()=>xgt});Ye();var kt={optional:!0},eH=[["@tailwindcss/aspect-ratio@<0.2.1",{peerDependencies:{tailwindcss:"^2.0.2"}}],["@tailwindcss/line-clamp@<0.2.1",{peerDependencies:{tailwindcss:"^2.0.2"}}],["@fullhuman/postcss-purgecss@3.1.3 || 3.1.3-alpha.0",{peerDependencies:{postcss:"^8.0.0"}}],["@samverschueren/stream-to-observable@<0.3.1",{peerDependenciesMeta:{rxjs:kt,zenObservable:kt}}],["any-observable@<0.5.1",{peerDependenciesMeta:{rxjs:kt,zenObservable:kt}}],["@pm2/agent@<1.0.4",{dependencies:{debug:"*"}}],["debug@<4.2.0",{peerDependenciesMeta:{["supports-color"]:kt}}],["got@<11",{dependencies:{["@types/responselike"]:"^1.0.0",["@types/keyv"]:"^3.1.1"}}],["cacheable-lookup@<4.1.2",{dependencies:{["@types/keyv"]:"^3.1.1"}}],["http-link-dataloader@*",{peerDependencies:{graphql:"^0.13.1 || ^14.0.0"}}],["typescript-language-server@*",{dependencies:{["vscode-jsonrpc"]:"^5.0.1",["vscode-languageserver-protocol"]:"^3.15.0"}}],["postcss-syntax@*",{peerDependenciesMeta:{["postcss-html"]:kt,["postcss-jsx"]:kt,["postcss-less"]:kt,["postcss-markdown"]:kt,["postcss-scss"]:kt}}],["jss-plugin-rule-value-function@<=10.1.1",{dependencies:{["tiny-warning"]:"^1.0.2"}}],["ink-select-input@<4.1.0",{peerDependencies:{react:"^16.8.2"}}],["license-webpack-plugin@<2.3.18",{peerDependenciesMeta:{webpack:kt}}],["snowpack@>=3.3.0",{dependencies:{["node-gyp"]:"^7.1.0"}}],["promise-inflight@*",{peerDependenciesMeta:{bluebird:kt}}],["reactcss@*",{peerDependencies:{react:"*"}}],["react-color@<=2.19.0",{peerDependencies:{react:"*"}}],["gatsby-plugin-i18n@*",{dependencies:{ramda:"^0.24.1"}}],["useragent@^2.0.0",{dependencies:{request:"^2.88.0",yamlparser:"0.0.x",semver:"5.5.x"}}],["@apollographql/apollo-tools@<=0.5.2",{peerDependencies:{graphql:"^14.2.1 || ^15.0.0"}}],["material-table@^2.0.0",{dependencies:{"@babel/runtime":"^7.11.2"}}],["@babel/parser@*",{dependencies:{"@babel/types":"^7.8.3"}}],["fork-ts-checker-webpack-plugin@<=6.3.4",{peerDependencies:{eslint:">= 6",typescript:">= 2.7",webpack:">= 4","vue-template-compiler":"*"},peerDependenciesMeta:{eslint:kt,"vue-template-compiler":kt}}],["rc-animate@<=3.1.1",{peerDependencies:{react:">=16.9.0","react-dom":">=16.9.0"}}],["react-bootstrap-table2-paginator@*",{dependencies:{classnames:"^2.2.6"}}],["react-draggable@<=4.4.3",{peerDependencies:{react:">= 16.3.0","react-dom":">= 16.3.0"}}],["apollo-upload-client@<14",{peerDependencies:{graphql:"14 - 15"}}],["react-instantsearch-core@<=6.7.0",{peerDependencies:{algoliasearch:">= 3.1 < 5"}}],["react-instantsearch-dom@<=6.7.0",{dependencies:{"react-fast-compare":"^3.0.0"}}],["ws@<7.2.1",{peerDependencies:{bufferutil:"^4.0.1","utf-8-validate":"^5.0.2"},peerDependenciesMeta:{bufferutil:kt,"utf-8-validate":kt}}],["react-portal@<4.2.2",{peerDependencies:{"react-dom":"^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0"}}],["react-scripts@<=4.0.1",{peerDependencies:{react:"*"}}],["testcafe@<=1.10.1",{dependencies:{"@babel/plugin-transform-for-of":"^7.12.1","@babel/runtime":"^7.12.5"}}],["testcafe-legacy-api@<=4.2.0",{dependencies:{"testcafe-hammerhead":"^17.0.1","read-file-relative":"^1.2.0"}}],["@google-cloud/firestore@<=4.9.3",{dependencies:{protobufjs:"^6.8.6"}}],["gatsby-source-apiserver@*",{dependencies:{["babel-polyfill"]:"^6.26.0"}}],["@webpack-cli/package-utils@<=1.0.1-alpha.4",{dependencies:{["cross-spawn"]:"^7.0.3"}}],["gatsby-remark-prismjs@<3.3.28",{dependencies:{lodash:"^4"}}],["gatsby-plugin-favicon@*",{peerDependencies:{webpack:"*"}}],["gatsby-plugin-sharp@<=4.6.0-next.3",{dependencies:{debug:"^4.3.1"}}],["gatsby-react-router-scroll@<=5.6.0-next.0",{dependencies:{["prop-types"]:"^15.7.2"}}],["@rebass/forms@*",{dependencies:{["@styled-system/should-forward-prop"]:"^5.0.0"},peerDependencies:{react:"^16.8.6"}}],["rebass@*",{peerDependencies:{react:"^16.8.6"}}],["@ant-design/react-slick@<=0.28.3",{peerDependencies:{react:">=16.0.0"}}],["mqtt@<4.2.7",{dependencies:{duplexify:"^4.1.1"}}],["vue-cli-plugin-vuetify@<=2.0.3",{dependencies:{semver:"^6.3.0"},peerDependenciesMeta:{"sass-loader":kt,"vuetify-loader":kt}}],["vue-cli-plugin-vuetify@<=2.0.4",{dependencies:{"null-loader":"^3.0.0"}}],["vue-cli-plugin-vuetify@>=2.4.3",{peerDependencies:{vue:"*"}}],["@vuetify/cli-plugin-utils@<=0.0.4",{dependencies:{semver:"^6.3.0"},peerDependenciesMeta:{"sass-loader":kt}}],["@vue/cli-plugin-typescript@<=5.0.0-alpha.0",{dependencies:{"babel-loader":"^8.1.0"}}],["@vue/cli-plugin-typescript@<=5.0.0-beta.0",{dependencies:{"@babel/core":"^7.12.16"},peerDependencies:{"vue-template-compiler":"^2.0.0"},peerDependenciesMeta:{"vue-template-compiler":kt}}],["cordova-ios@<=6.3.0",{dependencies:{underscore:"^1.9.2"}}],["cordova-lib@<=10.0.1",{dependencies:{underscore:"^1.9.2"}}],["git-node-fs@*",{peerDependencies:{"js-git":"^0.7.8"},peerDependenciesMeta:{"js-git":kt}}],["consolidate@<0.16.0",{peerDependencies:{mustache:"^3.0.0"},peerDependenciesMeta:{mustache:kt}}],["consolidate@<=0.16.0",{peerDependencies:{velocityjs:"^2.0.1",tinyliquid:"^0.2.34","liquid-node":"^3.0.1",jade:"^1.11.0","then-jade":"*",dust:"^0.3.0","dustjs-helpers":"^1.7.4","dustjs-linkedin":"^2.7.5",swig:"^1.4.2","swig-templates":"^2.0.3","razor-tmpl":"^1.3.1",atpl:">=0.7.6",liquor:"^0.0.5",twig:"^1.15.2",ejs:"^3.1.5",eco:"^1.1.0-rc-3",jazz:"^0.0.18",jqtpl:"~1.1.0",hamljs:"^0.6.2",hamlet:"^0.3.3",whiskers:"^0.4.0","haml-coffee":"^1.14.1","hogan.js":"^3.0.2",templayed:">=0.2.3",handlebars:"^4.7.6",underscore:"^1.11.0",lodash:"^4.17.20",pug:"^3.0.0","then-pug":"*",qejs:"^3.0.5",walrus:"^0.10.1",mustache:"^4.0.1",just:"^0.1.8",ect:"^0.5.9",mote:"^0.2.0",toffee:"^0.3.6",dot:"^1.1.3","bracket-template":"^1.1.5",ractive:"^1.3.12",nunjucks:"^3.2.2",htmling:"^0.0.8","babel-core":"^6.26.3",plates:"~0.4.11","react-dom":"^16.13.1",react:"^16.13.1","arc-templates":"^0.5.3",vash:"^0.13.0",slm:"^2.0.0",marko:"^3.14.4",teacup:"^2.0.0","coffee-script":"^1.12.7",squirrelly:"^5.1.0",twing:"^5.0.2"},peerDependenciesMeta:{velocityjs:kt,tinyliquid:kt,"liquid-node":kt,jade:kt,"then-jade":kt,dust:kt,"dustjs-helpers":kt,"dustjs-linkedin":kt,swig:kt,"swig-templates":kt,"razor-tmpl":kt,atpl:kt,liquor:kt,twig:kt,ejs:kt,eco:kt,jazz:kt,jqtpl:kt,hamljs:kt,hamlet:kt,whiskers:kt,"haml-coffee":kt,"hogan.js":kt,templayed:kt,handlebars:kt,underscore:kt,lodash:kt,pug:kt,"then-pug":kt,qejs:kt,walrus:kt,mustache:kt,just:kt,ect:kt,mote:kt,toffee:kt,dot:kt,"bracket-template":kt,ractive:kt,nunjucks:kt,htmling:kt,"babel-core":kt,plates:kt,"react-dom":kt,react:kt,"arc-templates":kt,vash:kt,slm:kt,marko:kt,teacup:kt,"coffee-script":kt,squirrelly:kt,twing:kt}}],["vue-loader@<=16.3.3",{peerDependencies:{"@vue/compiler-sfc":"^3.0.8",webpack:"^4.1.0 || ^5.0.0-0"},peerDependenciesMeta:{"@vue/compiler-sfc":kt}}],["vue-loader@^16.7.0",{peerDependencies:{"@vue/compiler-sfc":"^3.0.8",vue:"^3.2.13"},peerDependenciesMeta:{"@vue/compiler-sfc":kt,vue:kt}}],["scss-parser@<=1.0.5",{dependencies:{lodash:"^4.17.21"}}],["query-ast@<1.0.5",{dependencies:{lodash:"^4.17.21"}}],["redux-thunk@<=2.3.0",{peerDependencies:{redux:"^4.0.0"}}],["skypack@<=0.3.2",{dependencies:{tar:"^6.1.0"}}],["@npmcli/metavuln-calculator@<2.0.0",{dependencies:{"json-parse-even-better-errors":"^2.3.1"}}],["bin-links@<2.3.0",{dependencies:{"mkdirp-infer-owner":"^1.0.2"}}],["rollup-plugin-polyfill-node@<=0.8.0",{peerDependencies:{rollup:"^1.20.0 || ^2.0.0"}}],["snowpack@<3.8.6",{dependencies:{"magic-string":"^0.25.7"}}],["elm-webpack-loader@*",{dependencies:{temp:"^0.9.4"}}],["winston-transport@<=4.4.0",{dependencies:{logform:"^2.2.0"}}],["jest-vue-preprocessor@*",{dependencies:{"@babel/core":"7.8.7","@babel/template":"7.8.6"},peerDependencies:{pug:"^2.0.4"},peerDependenciesMeta:{pug:kt}}],["redux-persist@*",{peerDependencies:{react:">=16"},peerDependenciesMeta:{react:kt}}],["sodium@>=3",{dependencies:{"node-gyp":"^3.8.0"}}],["babel-plugin-graphql-tag@<=3.1.0",{peerDependencies:{graphql:"^14.0.0 || ^15.0.0"}}],["@playwright/test@<=1.14.1",{dependencies:{"jest-matcher-utils":"^26.4.2"}}],...["babel-plugin-remove-graphql-queries@<3.14.0-next.1","babel-preset-gatsby-package@<1.14.0-next.1","create-gatsby@<1.14.0-next.1","gatsby-admin@<0.24.0-next.1","gatsby-cli@<3.14.0-next.1","gatsby-core-utils@<2.14.0-next.1","gatsby-design-tokens@<3.14.0-next.1","gatsby-legacy-polyfills@<1.14.0-next.1","gatsby-plugin-benchmark-reporting@<1.14.0-next.1","gatsby-plugin-graphql-config@<0.23.0-next.1","gatsby-plugin-image@<1.14.0-next.1","gatsby-plugin-mdx@<2.14.0-next.1","gatsby-plugin-netlify-cms@<5.14.0-next.1","gatsby-plugin-no-sourcemaps@<3.14.0-next.1","gatsby-plugin-page-creator@<3.14.0-next.1","gatsby-plugin-preact@<5.14.0-next.1","gatsby-plugin-preload-fonts@<2.14.0-next.1","gatsby-plugin-schema-snapshot@<2.14.0-next.1","gatsby-plugin-styletron@<6.14.0-next.1","gatsby-plugin-subfont@<3.14.0-next.1","gatsby-plugin-utils@<1.14.0-next.1","gatsby-recipes@<0.25.0-next.1","gatsby-source-shopify@<5.6.0-next.1","gatsby-source-wikipedia@<3.14.0-next.1","gatsby-transformer-screenshot@<3.14.0-next.1","gatsby-worker@<0.5.0-next.1"].map(t=>[t,{dependencies:{"@babel/runtime":"^7.14.8"}}]),["gatsby-core-utils@<2.14.0-next.1",{dependencies:{got:"8.3.2"}}],["gatsby-plugin-gatsby-cloud@<=3.1.0-next.0",{dependencies:{"gatsby-core-utils":"^2.13.0-next.0"}}],["gatsby-plugin-gatsby-cloud@<=3.2.0-next.1",{peerDependencies:{webpack:"*"}}],["babel-plugin-remove-graphql-queries@<=3.14.0-next.1",{dependencies:{"gatsby-core-utils":"^2.8.0-next.1"}}],["gatsby-plugin-netlify@3.13.0-next.1",{dependencies:{"gatsby-core-utils":"^2.13.0-next.0"}}],["clipanion-v3-codemod@<=0.2.0",{peerDependencies:{jscodeshift:"^0.11.0"}}],["react-live@*",{peerDependencies:{"react-dom":"*",react:"*"}}],["webpack@<4.44.1",{peerDependenciesMeta:{"webpack-cli":kt,"webpack-command":kt}}],["webpack@<5.0.0-beta.23",{peerDependenciesMeta:{"webpack-cli":kt}}],["webpack-dev-server@<3.10.2",{peerDependenciesMeta:{"webpack-cli":kt}}],["@docusaurus/responsive-loader@<1.5.0",{peerDependenciesMeta:{sharp:kt,jimp:kt}}],["eslint-module-utils@*",{peerDependenciesMeta:{"eslint-import-resolver-node":kt,"eslint-import-resolver-typescript":kt,"eslint-import-resolver-webpack":kt,"@typescript-eslint/parser":kt}}],["eslint-plugin-import@*",{peerDependenciesMeta:{"@typescript-eslint/parser":kt}}],["critters-webpack-plugin@<3.0.2",{peerDependenciesMeta:{"html-webpack-plugin":kt}}],["terser@<=5.10.0",{dependencies:{acorn:"^8.5.0"}}],["babel-preset-react-app@10.0.x",{dependencies:{"@babel/plugin-proposal-private-property-in-object":"^7.16.0"}}],["eslint-config-react-app@*",{peerDependenciesMeta:{typescript:kt}}],["@vue/eslint-config-typescript@<11.0.0",{peerDependenciesMeta:{typescript:kt}}],["unplugin-vue2-script-setup@<0.9.1",{peerDependencies:{"@vue/composition-api":"^1.4.3","@vue/runtime-dom":"^3.2.26"}}],["@cypress/snapshot@*",{dependencies:{debug:"^3.2.7"}}],["auto-relay@<=0.14.0",{peerDependencies:{"reflect-metadata":"^0.1.13"}}],["vue-template-babel-compiler@<1.2.0",{peerDependencies:{["vue-template-compiler"]:"^2.6.0"}}],["@parcel/transformer-image@<2.5.0",{peerDependencies:{["@parcel/core"]:"*"}}],["@parcel/transformer-js@<2.5.0",{peerDependencies:{["@parcel/core"]:"*"}}],["parcel@*",{peerDependenciesMeta:{["@parcel/core"]:kt}}],["react-scripts@*",{peerDependencies:{eslint:"*"}}],["focus-trap-react@^8.0.0",{dependencies:{tabbable:"^5.3.2"}}],["react-rnd@<10.3.7",{peerDependencies:{react:">=16.3.0","react-dom":">=16.3.0"}}],["connect-mongo@*",{peerDependencies:{"express-session":"^1.17.1"}}],["vue-i18n@<9",{peerDependencies:{vue:"^2"}}],["vue-router@<4",{peerDependencies:{vue:"^2"}}],["unified@<10",{dependencies:{"@types/unist":"^2.0.0"}}],["react-github-btn@<=1.3.0",{peerDependencies:{react:">=16.3.0"}}],["react-dev-utils@*",{peerDependencies:{typescript:">=2.7",webpack:">=4"},peerDependenciesMeta:{typescript:kt}}],["@asyncapi/react-component@<=1.0.0-next.39",{peerDependencies:{react:">=16.8.0","react-dom":">=16.8.0"}}],["xo@*",{peerDependencies:{webpack:">=1.11.0"},peerDependenciesMeta:{webpack:kt}}],["babel-plugin-remove-graphql-queries@<=4.20.0-next.0",{dependencies:{"@babel/types":"^7.15.4"}}],["gatsby-plugin-page-creator@<=4.20.0-next.1",{dependencies:{"fs-extra":"^10.1.0"}}],["gatsby-plugin-utils@<=3.14.0-next.1",{dependencies:{fastq:"^1.13.0"},peerDependencies:{graphql:"^15.0.0"}}],["gatsby-plugin-mdx@<3.1.0-next.1",{dependencies:{mkdirp:"^1.0.4"}}],["gatsby-plugin-mdx@^2",{peerDependencies:{gatsby:"^3.0.0-next"}}],["fdir@<=5.2.0",{peerDependencies:{picomatch:"2.x"},peerDependenciesMeta:{picomatch:kt}}],["babel-plugin-transform-typescript-metadata@<=0.3.2",{peerDependencies:{"@babel/core":"^7","@babel/traverse":"^7"},peerDependenciesMeta:{"@babel/traverse":kt}}],["graphql-compose@>=9.0.10",{peerDependencies:{graphql:"^14.2.0 || ^15.0.0 || ^16.0.0"}}]];var tH;function Rde(){return typeof tH>"u"&&(tH=Be("zlib").brotliDecompressSync(Buffer.from("G7weAByFTVk3Vs7UfHhq4yykgEM7pbW7TI43SG2S5tvGrwHBAzdz+s/npQ6tgEvobvxisrPIadkXeUAJotBn5bDZ5kAhcRqsIHe3F75Walet5hNalwgFDtxb0BiDUjiUQkjG0yW2hto9HPgiCkm316d6bC0kST72YN7D7rfkhCE9x4J0XwB0yavalxpUu2t9xszHrmtwalOxT7VslsxWcB1qpqZwERUra4psWhTV8BgwWeizurec82Caf1ABL11YMfbf8FJ9JBceZOkgmvrQPbC9DUldX/yMbmX06UQluCEjSwUoyO+EZPIjofr+/oAZUck2enraRD+oWLlnlYnj8xB+gwSo9lmmks4fXv574qSqcWA6z21uYkzMu3EWj+K23RxeQlLqiE35/rC8GcS4CGkKHKKq+zAIQwD9iRDNfiAqueLLpicFFrNsAI4zeTD/eO9MHcnRa5m8UT+M2+V+AkFST4BlKneiAQRSdST8KEAIyFlULt6wa9EBd0Ds28VmpaxquJdVt+nwdEs5xUskI13OVtFyY0UrQIRAlCuvvWivvlSKQfTO+2Q8OyUR1W5RvetaPz4jD27hdtwHFFA1Ptx6Ee/t2cY2rg2G46M1pNDRf2pWhvpy8pqMnuI3++4OF3+7OFIWXGjh+o7Nr2jNvbiYcQdQS1h903/jVFgOpA0yJ78z+x759bFA0rq+6aY5qPB4FzS3oYoLupDUhD9nDz6F6H7hpnlMf18KNKDu4IKjTWwrAnY6MFQw1W6ymOALHlFyCZmQhldg1MQHaMVVQTVgDC60TfaBqG++Y8PEoFhN/PBTZT175KNP/BlHDYGOOBmnBdzqJKplZ/ljiVG0ZBzfqeBRrrUkn6rA54462SgiliKoYVnbeptMdXNfAuaupIEi0bApF10TlgHfmEJAPUVidRVFyDupSem5po5vErPqWKhKbUIp0LozpYsIKK57dM/HKr+nguF+7924IIWMICkQ8JUigs9D+W+c4LnNoRtPPKNRUiCYmP+Jfo2lfKCKw8qpraEeWU3uiNRO6zcyKQoXPR5htmzzLznke7b4YbXW3I1lIRzmgG02Udb58U+7TpwyN7XymCgH+wuPDthZVQvRZuEP+SnLtMicz9m5zASWOBiAcLmkuFlTKuHspSIhCBD0yUPKcxu81A+4YD78rA2vtwsUEday9WNyrShyrl60rWmA+SmbYZkQOwFJWArxRYYc5jGhA5ikxYw1rx3ei4NmeX/lKiwpZ9Ln1tV2Ae7sArvxuVLbJjqJRjW1vFXAyHpvLG+8MJ6T2Ubx5M2KDa2SN6vuIGxJ9WQM9Mk3Q7aCNiZONXllhqq24DmoLbQfW2rYWsOgHWjtOmIQMyMKdiHZDjoyIq5+U700nZ6odJAoYXPQBvFNiQ78d5jaXliBqLTJEqUCwi+LiH2mx92EmNKDsJL74Z613+3lf20pxkV1+erOrjj8pW00vsPaahKUM+05ssd5uwM7K482KWEf3TCwlg/o3e5ngto7qSMz7YteIgCsF1UOcsLk7F7MxWbvrPMY473ew0G+noVL8EPbkmEMftMSeL6HFub/zy+2JQ==","base64")).toString()),tH}var rH;function Tde(){return typeof rH>"u"&&(rH=Be("zlib").brotliDecompressSync(Buffer.from("G8MSIIzURnVBnObTcvb3XE6v2S9Qgc2K801Oa5otNKEtK8BINZNcaQHy+9/vf/WXBimwutXC33P2DPc64pps5rz7NGGWaOKNSPL4Y2KRE8twut2lFOIN+OXPtRmPMRhMTILib2bEQx43az2I5d3YS8Roa5UZpF/ujHb3Djd3GDvYUfvFYSUQ39vb2cmifp/rgB4J/65JK3wRBTvMBoNBmn3mbXC63/gbBkW/2IRPri0O8bcsRBsmarF328pAln04nyJFkwUAvNu934supAqLtyerZZpJ8I8suJHhf/ocMV+scKwa8NOiDKIPXw6Ex/EEZD6TEGaW8N5zvNHYF10l6Lfooj7D5W2k3dgvQSbp2Wv8TGOayS978gxlOLVjTGXs66ozewbrjwElLtyrYNnWTfzzdEutgROUFPVMhnMoy8EjJLLlWwIEoySxliim9kYW30JUHiPVyjt0iAw/ZpPmCbUCltYPnq6ZNblIKhTNhqS/oqC9iya5sGKZTOVsTEg34n92uZTf2iPpcZih8rPW8CzA+adIGmyCPcKdLMsBLShd+zuEbTrqpwuh+DLmracZcjPC5Sdf5odDAhKpFuOsQS67RT+1VgWWygSv3YwxDnylc04/PYuaMeIzhBkLrvs7e/OUzRTF56MmfY6rI63QtEjEQzq637zQqJ39nNhu3NmoRRhW/086bHGBUtx0PE0j3aEGvkdh9WJC8y8j8mqqke9/dQ5la+Q3ba4RlhvTbnfQhPDDab3tUifkjKuOsp13mXEmO00Mu88F/M67R7LXfoFDFLNtgCSWjWX+3Jn1371pJTK9xPBiMJafvDjtFyAzu8rxeQ0TKMQXNPs5xxiBOd+BRJP8KP88XPtJIbZKh/cdW8KvBUkpqKpGoiIaA32c3/JnQr4efXt85mXvidOvn/eU3Pase1typLYBalJ14mCso9h79nuMOuCa/kZAOkJHmTjP5RM2WNoPasZUAnT1TAE/NH25hUxcQv6hQWR/m1PKk4ooXMcM4SR1iYU3fUohvqk4RY2hbmTVVIXv6TvqO+0doOjgeVFAcom+RlwJQmOVH7pr1Q9LoJT6n1DeQEB+NHygsATbIwTcOKZlJsY8G4+suX1uQLjUWwLjjs0mvSvZcLTpIGAekeR7GCgl8eo3ndAqEe2XCav4huliHjdbIPBsGJuPX7lrO9HX1UbXRH5opOe1x6JsOSgHZR+EaxuXVhpLLxm6jk1LJtZfHSc6BKPun3CpYYVMJGwEUyk8MTGG0XL5MfEwaXpnc9TKnBmlGn6nHiGREc3ysn47XIBDzA+YvFdjZzVIEDcKGpS6PbUJehFRjEne8D0lVU1XuRtlgszq6pTNlQ/3MzNOEgCWPyTct22V2mEi2krizn5VDo9B19/X2DB3hCGRMM7ONbtnAcIx/OWB1u5uPbW1gsH8irXxT/IzG0PoXWYjhbMsH3KTuoOl5o17PulcgvsfTSnKFM354GWI8luqZnrswWjiXy3G+Vbyo1KMopFmmvBwNELgaS8z8dNZchx/Cl/xjddxhMcyqtzFyONb2Zdu90NkI8pAeufe7YlXrp53v8Dj/l8vWeVspRKBGXScBBPI/HinSTGmLDOGGOCIyH0JFdOZx0gWsacNlQLJMIrBhqRxXxHF/5pseWwejlAAvZ3klZSDSYY8mkToaWejXhgNomeGtx1DTLEUFMRkgF5yFB22WYdJnaWN14r1YJj81hGi45+jrADS5nYRhCiSlCJJ1nL8pYX+HDSMhdTEWyRcgHVp/IsUIZYMfT+YYncUQPgcxNGCHfZ88vDdrcUuaGIl6zhAsiaq7R5dfqrqXH/JcBhfjT8D0azayIyEz75Nxp6YkcyDxlJq3EXnJUpqDohJJOysL1t1uNiHESlvsxPb5cpbW0+ICZqJmUZus1BMW0F5IVBODLIo2zHHjA0=","base64")).toString()),rH}var nH;function Lde(){return typeof nH>"u"&&(nH=Be("zlib").brotliDecompressSync(Buffer.from("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","base64")).toString()),nH}var Nde=new Map([[G.makeIdent(null,"fsevents").identHash,Rde],[G.makeIdent(null,"resolve").identHash,Tde],[G.makeIdent(null,"typescript").identHash,Lde]]),Sgt={hooks:{registerPackageExtensions:async(t,e)=>{for(let[r,o]of eH)e(G.parseDescriptor(r,!0),o)},getBuiltinPatch:async(t,e)=>{let r="compat/";if(!e.startsWith(r))return;let o=G.parseIdent(e.slice(r.length)),a=Nde.get(o.identHash)?.();return typeof a<"u"?a:null},reduceDependency:async(t,e,r,o)=>typeof Nde.get(t.identHash)>"u"?t:G.makeDescriptor(t,G.makeRange({protocol:"patch:",source:G.stringifyDescriptor(t),selector:`optional!builtin<compat/${G.stringifyIdent(t)}>`,params:null}))}},xgt=Sgt;var wH={};Vt(wH,{ConstraintsCheckCommand:()=>p0,ConstraintsQueryCommand:()=>A0,ConstraintsSourceCommand:()=>f0,default:()=>tdt});Ye();Ye();I2();var wC=class{constructor(e){this.project=e}createEnvironment(){let e=new CC(["cwd","ident"]),r=new CC(["workspace","type","ident"]),o=new CC(["ident"]),a={manifestUpdates:new Map,reportedErrors:new Map},n=new Map,u=new Map;for(let A of this.project.storedPackages.values()){let p=Array.from(A.peerDependencies.values(),h=>[G.stringifyIdent(h),h.range]);n.set(A.locatorHash,{workspace:null,ident:G.stringifyIdent(A),version:A.version,dependencies:new Map,peerDependencies:new Map(p.filter(([h])=>A.peerDependenciesMeta.get(h)?.optional!==!0)),optionalPeerDependencies:new Map(p.filter(([h])=>A.peerDependenciesMeta.get(h)?.optional===!0))})}for(let A of this.project.storedPackages.values()){let p=n.get(A.locatorHash);p.dependencies=new Map(Array.from(A.dependencies.values(),h=>{let C=this.project.storedResolutions.get(h.descriptorHash);if(typeof C>"u")throw new Error("Assertion failed: The resolution should have been registered");let I=n.get(C);if(typeof I>"u")throw new Error("Assertion failed: The package should have been registered");return[G.stringifyIdent(h),I]})),p.dependencies.delete(p.ident)}for(let A of this.project.workspaces){let p=G.stringifyIdent(A.anchoredLocator),h=A.manifest.exportTo({}),C=n.get(A.anchoredLocator.locatorHash);if(typeof C>"u")throw new Error("Assertion failed: The package should have been registered");let I=(F,N,{caller:U=zi.getCaller()}={})=>{let z=w2(F),te=_e.getMapWithDefault(a.manifestUpdates,A.cwd),le=_e.getMapWithDefault(te,z),pe=_e.getSetWithDefault(le,N);U!==null&&pe.add(U)},v=F=>I(F,void 0,{caller:zi.getCaller()}),b=F=>{_e.getArrayWithDefault(a.reportedErrors,A.cwd).push(F)},E=e.insert({cwd:A.relativeCwd,ident:p,manifest:h,pkg:C,set:I,unset:v,error:b});u.set(A,E);for(let F of Ot.allDependencies)for(let N of A.manifest[F].values()){let U=G.stringifyIdent(N),z=()=>{I([F,U],void 0,{caller:zi.getCaller()})},te=pe=>{I([F,U],pe,{caller:zi.getCaller()})},le=null;if(F!=="peerDependencies"&&(F!=="dependencies"||!A.manifest.devDependencies.has(N.identHash))){let pe=A.anchoredPackage.dependencies.get(N.identHash);if(pe){if(typeof pe>"u")throw new Error("Assertion failed: The dependency should have been registered");let ue=this.project.storedResolutions.get(pe.descriptorHash);if(typeof ue>"u")throw new Error("Assertion failed: The resolution should have been registered");let ye=n.get(ue);if(typeof ye>"u")throw new Error("Assertion failed: The package should have been registered");le=ye}}r.insert({workspace:E,ident:U,range:N.range,type:F,resolution:le,update:te,delete:z,error:b})}}for(let A of this.project.storedPackages.values()){let p=this.project.tryWorkspaceByLocator(A);if(!p)continue;let h=u.get(p);if(typeof h>"u")throw new Error("Assertion failed: The workspace should have been registered");let C=n.get(A.locatorHash);if(typeof C>"u")throw new Error("Assertion failed: The package should have been registered");C.workspace=h}return{workspaces:e,dependencies:r,packages:o,result:a}}async process(){let e=this.createEnvironment(),r={Yarn:{workspace:a=>e.workspaces.find(a)[0]??null,workspaces:a=>e.workspaces.find(a),dependency:a=>e.dependencies.find(a)[0]??null,dependencies:a=>e.dependencies.find(a),package:a=>e.packages.find(a)[0]??null,packages:a=>e.packages.find(a)}},o=await this.project.loadUserConfig();return o?.constraints?(await o.constraints(r),e.result):null}};Ye();Ye();qt();var A0=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.query=ge.String()}async execute(){let{Constraints:r}=await Promise.resolve().then(()=>(S2(),P2)),o=await Ke.find(this.context.cwd,this.context.plugins),{project:a}=await St.find(o,this.context.cwd),n=await r.find(a),u=this.query;return u.endsWith(".")||(u=`${u}.`),(await Lt.start({configuration:o,json:this.json,stdout:this.context.stdout},async p=>{for await(let h of n.query(u)){let C=Array.from(Object.entries(h)),I=C.length,v=C.reduce((b,[E])=>Math.max(b,E.length),0);for(let b=0;b<I;b++){let[E,F]=C[b];p.reportInfo(null,`${$gt(b,I)}${E.padEnd(v," ")} = ${Zgt(F)}`)}p.reportJson(h)}})).exitCode()}};A0.paths=[["constraints","query"]],A0.usage=nt.Usage({category:"Constraints-related commands",description:"query the constraints fact database",details:` - This command will output all matches to the given prolog query. - `,examples:[["List all dependencies throughout the workspace","yarn constraints query 'workspace_has_dependency(_, DependencyName, _, _).'"]]});function Zgt(t){return typeof t!="string"?`${t}`:t.match(/^[a-zA-Z][a-zA-Z0-9_]+$/)?t:`'${t}'`}function $gt(t,e){let r=t===0,o=t===e-1;return r&&o?"":r?"\u250C ":o?"\u2514 ":"\u2502 "}Ye();qt();var f0=class extends ut{constructor(){super(...arguments);this.verbose=ge.Boolean("-v,--verbose",!1,{description:"Also print the fact database automatically compiled from the workspace manifests"})}async execute(){let{Constraints:r}=await Promise.resolve().then(()=>(S2(),P2)),o=await Ke.find(this.context.cwd,this.context.plugins),{project:a}=await St.find(o,this.context.cwd),n=await r.find(a);this.context.stdout.write(this.verbose?n.fullSource:n.source)}};f0.paths=[["constraints","source"]],f0.usage=nt.Usage({category:"Constraints-related commands",description:"print the source code for the constraints",details:"\n This command will print the Prolog source code used by the constraints engine. Adding the `-v,--verbose` flag will print the *full* source code, including the fact database automatically compiled from the workspace manifests.\n ",examples:[["Prints the source code","yarn constraints source"],["Print the source code and the fact database","yarn constraints source -v"]]});Ye();Ye();qt();I2();var p0=class extends ut{constructor(){super(...arguments);this.fix=ge.Boolean("--fix",!1,{description:"Attempt to automatically fix unambiguous issues, following a multi-pass process"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o}=await St.find(r,this.context.cwd);await o.restoreInstallState();let a=await o.loadUserConfig(),n;if(a?.constraints)n=new wC(o);else{let{Constraints:h}=await Promise.resolve().then(()=>(S2(),P2));n=await h.find(o)}let u,A=!1,p=!1;for(let h=this.fix?10:1;h>0;--h){let C=await n.process();if(!C)break;let{changedWorkspaces:I,remainingErrors:v}=gk(o,C,{fix:this.fix}),b=[];for(let[E,F]of I){let N=E.manifest.indent;E.manifest=new Ot,E.manifest.indent=N,E.manifest.load(F),b.push(E.persistManifest())}if(!(I.size>0&&h>1)){u=qde(v,{configuration:r}),A=!1,p=!0;for(let[,E]of v)for(let F of E)F.fixable?A=!0:p=!1}}if(u.children.length===0)return 0;if(A){let h=p?`Those errors can all be fixed by running ${de.pretty(r,"yarn constraints --fix",de.Type.CODE)}`:`Errors prefixed by '\u2699' can be fixed by running ${de.pretty(r,"yarn constraints --fix",de.Type.CODE)}`;await Lt.start({configuration:r,stdout:this.context.stdout,includeNames:!1,includeFooter:!1},async C=>{C.reportInfo(0,h),C.reportSeparator()})}return u.children=_e.sortMap(u.children,h=>h.value[1]),$s.emitTree(u,{configuration:r,stdout:this.context.stdout,json:this.json,separators:1}),1}};p0.paths=[["constraints"]],p0.usage=nt.Usage({category:"Constraints-related commands",description:"check that the project constraints are met",details:` - This command will run constraints on your project and emit errors for each one that is found but isn't met. If any error is emitted the process will exit with a non-zero exit code. - - If the \`--fix\` flag is used, Yarn will attempt to automatically fix the issues the best it can, following a multi-pass process (with a maximum of 10 iterations). Some ambiguous patterns cannot be autofixed, in which case you'll have to manually specify the right resolution. - - For more information as to how to write constraints, please consult our dedicated page on our website: https://yarnpkg.com/features/constraints. - `,examples:[["Check that all constraints are satisfied","yarn constraints"],["Autofix all unmet constraints","yarn constraints --fix"]]});I2();var edt={configuration:{enableConstraintsChecks:{description:"If true, constraints will run during installs",type:"BOOLEAN",default:!1},constraintsPath:{description:"The path of the constraints file.",type:"ABSOLUTE_PATH",default:"./constraints.pro"}},commands:[A0,f0,p0],hooks:{async validateProjectAfterInstall(t,{reportError:e}){if(!t.configuration.get("enableConstraintsChecks"))return;let r=await t.loadUserConfig(),o;if(r?.constraints)o=new wC(t);else{let{Constraints:u}=await Promise.resolve().then(()=>(S2(),P2));o=await u.find(t)}let a=await o.process();if(!a)return;let{remainingErrors:n}=gk(t,a);if(n.size!==0)if(t.configuration.isCI)for(let[u,A]of n)for(let p of A)e(84,`${de.pretty(t.configuration,u.anchoredLocator,de.Type.IDENT)}: ${p.text}`);else e(84,`Constraint check failed; run ${de.pretty(t.configuration,"yarn constraints",de.Type.CODE)} for more details`)}}},tdt=edt;var IH={};Vt(IH,{CreateCommand:()=>em,DlxCommand:()=>h0,default:()=>ndt});Ye();qt();var em=class extends ut{constructor(){super(...arguments);this.pkg=ge.String("-p,--package",{description:"The package to run the provided command from"});this.quiet=ge.Boolean("-q,--quiet",!1,{description:"Only report critical errors instead of printing the full install logs"});this.command=ge.String();this.args=ge.Proxy()}async execute(){let r=[];this.pkg&&r.push("--package",this.pkg),this.quiet&&r.push("--quiet");let o=this.command.replace(/^(@[^@/]+)(@|$)/,"$1/create$2"),a=G.parseDescriptor(o),n=a.name.match(/^create(-|$)/)?a:a.scope?G.makeIdent(a.scope,`create-${a.name}`):G.makeIdent(null,`create-${a.name}`),u=G.stringifyIdent(n);return a.range!=="unknown"&&(u+=`@${a.range}`),this.cli.run(["dlx",...r,u,...this.args])}};em.paths=[["create"]];Ye();Ye();Pt();qt();var h0=class extends ut{constructor(){super(...arguments);this.packages=ge.Array("-p,--package",{description:"The package(s) to install before running the command"});this.quiet=ge.Boolean("-q,--quiet",!1,{description:"Only report critical errors instead of printing the full install logs"});this.command=ge.String();this.args=ge.Proxy()}async execute(){return Ke.telemetry=null,await oe.mktempPromise(async r=>{let o=V.join(r,`dlx-${process.pid}`);await oe.mkdirPromise(o),await oe.writeFilePromise(V.join(o,"package.json"),`{} -`),await oe.writeFilePromise(V.join(o,"yarn.lock"),"");let a=V.join(o,".yarnrc.yml"),n=await Ke.findProjectCwd(this.context.cwd),A={enableGlobalCache:!(await Ke.find(this.context.cwd,null,{strict:!1})).get("enableGlobalCache"),enableTelemetry:!1,logFilters:[{code:Wu(68),level:de.LogLevel.Discard}]},p=n!==null?V.join(n,".yarnrc.yml"):null;p!==null&&oe.existsSync(p)?(await oe.copyFilePromise(p,a),await Ke.updateConfiguration(o,N=>{let U=_e.toMerged(N,A);return Array.isArray(N.plugins)&&(U.plugins=N.plugins.map(z=>{let te=typeof z=="string"?z:z.path,le=fe.isAbsolute(te)?te:fe.resolve(fe.fromPortablePath(n),te);return typeof z=="string"?le:{path:le,spec:z.spec}})),U})):await oe.writeJsonPromise(a,A);let h=this.packages??[this.command],C=G.parseDescriptor(this.command).name,I=await this.cli.run(["add","--fixed","--",...h],{cwd:o,quiet:this.quiet});if(I!==0)return I;this.quiet||this.context.stdout.write(` -`);let v=await Ke.find(o,this.context.plugins),{project:b,workspace:E}=await St.find(v,o);if(E===null)throw new rr(b.cwd,o);await b.restoreInstallState();let F=await un.getWorkspaceAccessibleBinaries(E);return F.has(C)===!1&&F.size===1&&typeof this.packages>"u"&&(C=Array.from(F)[0][0]),await un.executeWorkspaceAccessibleBinary(E,C,this.args,{packageAccessibleBinaries:F,cwd:this.context.cwd,stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr})})}};h0.paths=[["dlx"]],h0.usage=nt.Usage({description:"run a package in a temporary environment",details:"\n This command will install a package within a temporary environment, and run its binary script if it contains any. The binary will run within the current cwd.\n\n By default Yarn will download the package named `command`, but this can be changed through the use of the `-p,--package` flag which will instruct Yarn to still run the same command but from a different package.\n\n Using `yarn dlx` as a replacement of `yarn add` isn't recommended, as it makes your project non-deterministic (Yarn doesn't keep track of the packages installed through `dlx` - neither their name, nor their version).\n ",examples:[["Use create-react-app to create a new React app","yarn dlx create-react-app ./my-app"],["Install multiple packages for a single command",`yarn dlx -p typescript -p ts-node ts-node --transpile-only -e "console.log('hello!')"`]]});var rdt={commands:[em,h0]},ndt=rdt;var DH={};Vt(DH,{ExecFetcher:()=>b2,ExecResolver:()=>k2,default:()=>odt,execUtils:()=>Ek});Ye();Ye();Pt();var fA="exec:";var Ek={};Vt(Ek,{loadGeneratorFile:()=>x2,makeLocator:()=>vH,makeSpec:()=>gme,parseSpec:()=>BH});Ye();Pt();function BH(t){let{params:e,selector:r}=G.parseRange(t),o=fe.toPortablePath(r);return{parentLocator:e&&typeof e.locator=="string"?G.parseLocator(e.locator):null,path:o}}function gme({parentLocator:t,path:e,generatorHash:r,protocol:o}){let a=t!==null?{locator:G.stringifyLocator(t)}:{},n=typeof r<"u"?{hash:r}:{};return G.makeRange({protocol:o,source:e,selector:e,params:{...n,...a}})}function vH(t,{parentLocator:e,path:r,generatorHash:o,protocol:a}){return G.makeLocator(t,gme({parentLocator:e,path:r,generatorHash:o,protocol:a}))}async function x2(t,e,r){let{parentLocator:o,path:a}=G.parseFileStyleRange(t,{protocol:e}),n=V.isAbsolute(a)?{packageFs:new gn(Bt.root),prefixPath:Bt.dot,localPath:Bt.root}:await r.fetcher.fetch(o,r),u=n.localPath?{packageFs:new gn(Bt.root),prefixPath:V.relative(Bt.root,n.localPath)}:n;n!==u&&n.releaseFs&&n.releaseFs();let A=u.packageFs,p=V.join(u.prefixPath,a);return await A.readFilePromise(p,"utf8")}var b2=class{supports(e,r){return!!e.reference.startsWith(fA)}getLocalPath(e,r){let{parentLocator:o,path:a}=G.parseFileStyleRange(e.reference,{protocol:fA});if(V.isAbsolute(a))return a;let n=r.fetcher.getLocalPath(o,r);return n===null?null:V.resolve(n,a)}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,[a,n,u]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e),loader:()=>this.fetchFromDisk(e,r),...r.cacheOptions});return{packageFs:a,releaseFs:n,prefixPath:G.getIdentVendorPath(e),localPath:this.getLocalPath(e,r),checksum:u}}async fetchFromDisk(e,r){let o=await x2(e.reference,fA,r);return oe.mktempPromise(async a=>{let n=V.join(a,"generator.js");return await oe.writeFilePromise(n,o),oe.mktempPromise(async u=>{if(await this.generatePackage(u,e,n,r),!oe.existsSync(V.join(u,"build")))throw new Error("The script should have generated a build directory");return await Xi.makeArchiveFromDirectory(V.join(u,"build"),{prefixPath:G.getIdentVendorPath(e),compressionLevel:r.project.configuration.get("compressionLevel")})})})}async generatePackage(e,r,o,a){return await oe.mktempPromise(async n=>{let u=await un.makeScriptEnv({project:a.project,binFolder:n}),A=V.join(e,"runtime.js");return await oe.mktempPromise(async p=>{let h=V.join(p,"buildfile.log"),C=V.join(e,"generator"),I=V.join(e,"build");await oe.mkdirPromise(C),await oe.mkdirPromise(I);let v={tempDir:fe.fromPortablePath(C),buildDir:fe.fromPortablePath(I),locator:G.stringifyLocator(r)};await oe.writeFilePromise(A,` - // Expose 'Module' as a global variable - Object.defineProperty(global, 'Module', { - get: () => require('module'), - configurable: true, - enumerable: false, - }); - - // Expose non-hidden built-in modules as global variables - for (const name of Module.builtinModules.filter((name) => name !== 'module' && !name.startsWith('_'))) { - Object.defineProperty(global, name, { - get: () => require(name), - configurable: true, - enumerable: false, - }); - } - - // Expose the 'execEnv' global variable - Object.defineProperty(global, 'execEnv', { - value: { - ...${JSON.stringify(v)}, - }, - enumerable: true, - }); - `);let b=u.NODE_OPTIONS||"",E=/\s*--require\s+\S*\.pnp\.c?js\s*/g;b=b.replace(E," ").trim(),u.NODE_OPTIONS=b;let{stdout:F,stderr:N}=a.project.configuration.getSubprocessStreams(h,{header:`# This file contains the result of Yarn generating a package (${G.stringifyLocator(r)}) -`,prefix:G.prettyLocator(a.project.configuration,r),report:a.report}),{code:U}=await Ur.pipevp(process.execPath,["--require",fe.fromPortablePath(A),fe.fromPortablePath(o),G.stringifyIdent(r)],{cwd:e,env:u,stdin:null,stdout:F,stderr:N});if(U!==0)throw oe.detachTemp(p),new Error(`Package generation failed (exit code ${U}, logs can be found here: ${de.pretty(a.project.configuration,h,de.Type.PATH)})`)})})}};Ye();Ye();var idt=2,k2=class{supportsDescriptor(e,r){return!!e.range.startsWith(fA)}supportsLocator(e,r){return!!e.reference.startsWith(fA)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,o){return G.bindDescriptor(e,{locator:G.stringifyLocator(r)})}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){if(!o.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let{path:a,parentLocator:n}=BH(e.range);if(n===null)throw new Error("Assertion failed: The descriptor should have been bound");let u=await x2(G.makeRange({protocol:fA,source:a,selector:a,params:{locator:G.stringifyLocator(n)}}),fA,o.fetchOptions),A=wn.makeHash(`${idt}`,u).slice(0,6);return[vH(e,{parentLocator:n,path:a,generatorHash:A,protocol:fA})]}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let o=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),a=await _e.releaseAfterUseAsync(async()=>await Ot.find(o.prefixPath,{baseFs:o.packageFs}),o.releaseFs);return{...e,version:a.version||"0.0.0",languageName:a.languageName||r.project.configuration.get("defaultLanguageName"),linkType:"HARD",conditions:a.getConditions(),dependencies:r.project.configuration.normalizeDependencyMap(a.dependencies),peerDependencies:a.peerDependencies,dependenciesMeta:a.dependenciesMeta,peerDependenciesMeta:a.peerDependenciesMeta,bin:a.bin}}};var sdt={fetchers:[b2],resolvers:[k2]},odt=sdt;var SH={};Vt(SH,{FileFetcher:()=>T2,FileResolver:()=>L2,TarballFileFetcher:()=>N2,TarballFileResolver:()=>O2,default:()=>cdt,fileUtils:()=>tm});Ye();Pt();var DC=/^(?:[a-zA-Z]:[\\/]|\.{0,2}\/)/,Q2=/^[^?]*\.(?:tar\.gz|tgz)(?:::.*)?$/,Ui="file:";var tm={};Vt(tm,{fetchArchiveFromLocator:()=>R2,makeArchiveFromLocator:()=>Ck,makeBufferFromLocator:()=>PH,makeLocator:()=>PC,makeSpec:()=>dme,parseSpec:()=>F2});Ye();Pt();function F2(t){let{params:e,selector:r}=G.parseRange(t),o=fe.toPortablePath(r);return{parentLocator:e&&typeof e.locator=="string"?G.parseLocator(e.locator):null,path:o}}function dme({parentLocator:t,path:e,hash:r,protocol:o}){let a=t!==null?{locator:G.stringifyLocator(t)}:{},n=typeof r<"u"?{hash:r}:{};return G.makeRange({protocol:o,source:e,selector:e,params:{...n,...a}})}function PC(t,{parentLocator:e,path:r,hash:o,protocol:a}){return G.makeLocator(t,dme({parentLocator:e,path:r,hash:o,protocol:a}))}async function R2(t,e){let{parentLocator:r,path:o}=G.parseFileStyleRange(t.reference,{protocol:Ui}),a=V.isAbsolute(o)?{packageFs:new gn(Bt.root),prefixPath:Bt.dot,localPath:Bt.root}:await e.fetcher.fetch(r,e),n=a.localPath?{packageFs:new gn(Bt.root),prefixPath:V.relative(Bt.root,a.localPath)}:a;a!==n&&a.releaseFs&&a.releaseFs();let u=n.packageFs,A=V.join(n.prefixPath,o);return await _e.releaseAfterUseAsync(async()=>await u.readFilePromise(A),n.releaseFs)}async function Ck(t,{protocol:e,fetchOptions:r,inMemory:o=!1}){let{parentLocator:a,path:n}=G.parseFileStyleRange(t.reference,{protocol:e}),u=V.isAbsolute(n)?{packageFs:new gn(Bt.root),prefixPath:Bt.dot,localPath:Bt.root}:await r.fetcher.fetch(a,r),A=u.localPath?{packageFs:new gn(Bt.root),prefixPath:V.relative(Bt.root,u.localPath)}:u;u!==A&&u.releaseFs&&u.releaseFs();let p=A.packageFs,h=V.join(A.prefixPath,n);return await _e.releaseAfterUseAsync(async()=>await Xi.makeArchiveFromDirectory(h,{baseFs:p,prefixPath:G.getIdentVendorPath(t),compressionLevel:r.project.configuration.get("compressionLevel"),inMemory:o}),A.releaseFs)}async function PH(t,{protocol:e,fetchOptions:r}){return(await Ck(t,{protocol:e,fetchOptions:r,inMemory:!0})).getBufferAndClose()}var T2=class{supports(e,r){return!!e.reference.startsWith(Ui)}getLocalPath(e,r){let{parentLocator:o,path:a}=G.parseFileStyleRange(e.reference,{protocol:Ui});if(V.isAbsolute(a))return a;let n=r.fetcher.getLocalPath(o,r);return n===null?null:V.resolve(n,a)}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,[a,n,u]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${G.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the disk`),loader:()=>this.fetchFromDisk(e,r),...r.cacheOptions});return{packageFs:a,releaseFs:n,prefixPath:G.getIdentVendorPath(e),localPath:this.getLocalPath(e,r),checksum:u}}async fetchFromDisk(e,r){return Ck(e,{protocol:Ui,fetchOptions:r})}};Ye();Ye();var adt=2,L2=class{supportsDescriptor(e,r){return e.range.match(DC)?!0:!!e.range.startsWith(Ui)}supportsLocator(e,r){return!!e.reference.startsWith(Ui)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,o){return DC.test(e.range)&&(e=G.makeDescriptor(e,`${Ui}${e.range}`)),G.bindDescriptor(e,{locator:G.stringifyLocator(r)})}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){if(!o.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let{path:a,parentLocator:n}=F2(e.range);if(n===null)throw new Error("Assertion failed: The descriptor should have been bound");let u=await PH(G.makeLocator(e,G.makeRange({protocol:Ui,source:a,selector:a,params:{locator:G.stringifyLocator(n)}})),{protocol:Ui,fetchOptions:o.fetchOptions}),A=wn.makeHash(`${adt}`,u).slice(0,6);return[PC(e,{parentLocator:n,path:a,hash:A,protocol:Ui})]}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let o=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),a=await _e.releaseAfterUseAsync(async()=>await Ot.find(o.prefixPath,{baseFs:o.packageFs}),o.releaseFs);return{...e,version:a.version||"0.0.0",languageName:a.languageName||r.project.configuration.get("defaultLanguageName"),linkType:"HARD",conditions:a.getConditions(),dependencies:r.project.configuration.normalizeDependencyMap(a.dependencies),peerDependencies:a.peerDependencies,dependenciesMeta:a.dependenciesMeta,peerDependenciesMeta:a.peerDependenciesMeta,bin:a.bin}}};Ye();var N2=class{supports(e,r){return Q2.test(e.reference)?!!e.reference.startsWith(Ui):!1}getLocalPath(e,r){return null}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,[a,n,u]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${G.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the disk`),loader:()=>this.fetchFromDisk(e,r),...r.cacheOptions});return{packageFs:a,releaseFs:n,prefixPath:G.getIdentVendorPath(e),checksum:u}}async fetchFromDisk(e,r){let o=await R2(e,r);return await Xi.convertToZip(o,{configuration:r.project.configuration,prefixPath:G.getIdentVendorPath(e),stripComponents:1})}};Ye();Ye();Ye();var O2=class{supportsDescriptor(e,r){return Q2.test(e.range)?!!(e.range.startsWith(Ui)||DC.test(e.range)):!1}supportsLocator(e,r){return Q2.test(e.reference)?!!e.reference.startsWith(Ui):!1}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,o){return DC.test(e.range)&&(e=G.makeDescriptor(e,`${Ui}${e.range}`)),G.bindDescriptor(e,{locator:G.stringifyLocator(r)})}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){if(!o.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let{path:a,parentLocator:n}=F2(e.range);if(n===null)throw new Error("Assertion failed: The descriptor should have been bound");let u=PC(e,{parentLocator:n,path:a,hash:"",protocol:Ui}),A=await R2(u,o.fetchOptions),p=wn.makeHash(A).slice(0,6);return[PC(e,{parentLocator:n,path:a,hash:p,protocol:Ui})]}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let o=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),a=await _e.releaseAfterUseAsync(async()=>await Ot.find(o.prefixPath,{baseFs:o.packageFs}),o.releaseFs);return{...e,version:a.version||"0.0.0",languageName:a.languageName||r.project.configuration.get("defaultLanguageName"),linkType:"HARD",conditions:a.getConditions(),dependencies:r.project.configuration.normalizeDependencyMap(a.dependencies),peerDependencies:a.peerDependencies,dependenciesMeta:a.dependenciesMeta,peerDependenciesMeta:a.peerDependenciesMeta,bin:a.bin}}};var ldt={fetchers:[N2,T2],resolvers:[O2,L2]},cdt=ldt;var kH={};Vt(kH,{GithubFetcher:()=>M2,default:()=>Adt,githubUtils:()=>wk});Ye();Pt();var wk={};Vt(wk,{invalidGithubUrlMessage:()=>Eme,isGithubUrl:()=>xH,parseGithubUrl:()=>bH});var mme=$e(Be("querystring")),yme=[/^https?:\/\/(?:([^/]+?)@)?github.com\/([^/#]+)\/([^/#]+)\/tarball\/([^/#]+)(?:#(.*))?$/,/^https?:\/\/(?:([^/]+?)@)?github.com\/([^/#]+)\/([^/#]+?)(?:\.git)?(?:#(.*))?$/];function xH(t){return t?yme.some(e=>!!t.match(e)):!1}function bH(t){let e;for(let A of yme)if(e=t.match(A),e)break;if(!e)throw new Error(Eme(t));let[,r,o,a,n="master"]=e,{commit:u}=mme.default.parse(n);return n=u||n.replace(/[^:]*:/,""),{auth:r,username:o,reponame:a,treeish:n}}function Eme(t){return`Input cannot be parsed as a valid GitHub URL ('${t}').`}var M2=class{supports(e,r){return!!xH(e.reference)}getLocalPath(e,r){return null}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,[a,n,u]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${G.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from GitHub`),loader:()=>this.fetchFromNetwork(e,r),...r.cacheOptions});return{packageFs:a,releaseFs:n,prefixPath:G.getIdentVendorPath(e),checksum:u}}async fetchFromNetwork(e,r){let o=await rn.get(this.getLocatorUrl(e,r),{configuration:r.project.configuration});return await oe.mktempPromise(async a=>{let n=new gn(a);await Xi.extractArchiveTo(o,n,{stripComponents:1});let u=ra.splitRepoUrl(e.reference),A=V.join(a,"package.tgz");await un.prepareExternalProject(a,A,{configuration:r.project.configuration,report:r.report,workspace:u.extra.workspace,locator:e});let p=await oe.readFilePromise(A);return await Xi.convertToZip(p,{configuration:r.project.configuration,prefixPath:G.getIdentVendorPath(e),stripComponents:1})})}getLocatorUrl(e,r){let{auth:o,username:a,reponame:n,treeish:u}=bH(e.reference);return`https://${o?`${o}@`:""}github.com/${a}/${n}/archive/${u}.tar.gz`}};var udt={hooks:{async fetchHostedRepository(t,e,r){if(t!==null)return t;let o=new M2;if(!o.supports(e,r))return null;try{return await o.fetch(e,r)}catch{return null}}}},Adt=udt;var QH={};Vt(QH,{TarballHttpFetcher:()=>H2,TarballHttpResolver:()=>j2,default:()=>pdt});Ye();var U2=/^[^?]*\.(?:tar\.gz|tgz)(?:\?.*)?(?:#.*)?$/,_2=/^https?:/;var H2=class{supports(e,r){return U2.test(e.reference)?!!_2.test(e.reference):!1}getLocalPath(e,r){return null}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,[a,n,u]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${G.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the remote server`),loader:()=>this.fetchFromNetwork(e,r),...r.cacheOptions});return{packageFs:a,releaseFs:n,prefixPath:G.getIdentVendorPath(e),checksum:u}}async fetchFromNetwork(e,r){let o=await rn.get(e.reference,{configuration:r.project.configuration});return await Xi.convertToZip(o,{configuration:r.project.configuration,prefixPath:G.getIdentVendorPath(e),stripComponents:1})}};Ye();Ye();var j2=class{supportsDescriptor(e,r){return U2.test(e.range)?!!_2.test(e.range):!1}supportsLocator(e,r){return U2.test(e.reference)?!!_2.test(e.reference):!1}shouldPersistResolution(e,r){return!0}bindDescriptor(e,r,o){return e}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){return[G.convertDescriptorToLocator(e)]}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let o=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),a=await _e.releaseAfterUseAsync(async()=>await Ot.find(o.prefixPath,{baseFs:o.packageFs}),o.releaseFs);return{...e,version:a.version||"0.0.0",languageName:a.languageName||r.project.configuration.get("defaultLanguageName"),linkType:"HARD",conditions:a.getConditions(),dependencies:r.project.configuration.normalizeDependencyMap(a.dependencies),peerDependencies:a.peerDependencies,dependenciesMeta:a.dependenciesMeta,peerDependenciesMeta:a.peerDependenciesMeta,bin:a.bin}}};var fdt={fetchers:[H2],resolvers:[j2]},pdt=fdt;var FH={};Vt(FH,{InitCommand:()=>g0,default:()=>gdt});Ye();Ye();Pt();qt();var g0=class extends ut{constructor(){super(...arguments);this.private=ge.Boolean("-p,--private",!1,{description:"Initialize a private package"});this.workspace=ge.Boolean("-w,--workspace",!1,{description:"Initialize a workspace root with a `packages/` directory"});this.install=ge.String("-i,--install",!1,{tolerateBoolean:!0,description:"Initialize a package with a specific bundle that will be locked in the project"});this.name=ge.String("-n,--name",{description:"Initialize a package with the given name"});this.usev2=ge.Boolean("-2",!1,{hidden:!0});this.yes=ge.Boolean("-y,--yes",{hidden:!0})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=typeof this.install=="string"?this.install:this.usev2||this.install===!0?"latest":null;return o!==null?await this.executeProxy(r,o):await this.executeRegular(r)}async executeProxy(r,o){if(r.projectCwd!==null&&r.projectCwd!==this.context.cwd)throw new it("Cannot use the --install flag from within a project subdirectory");oe.existsSync(this.context.cwd)||await oe.mkdirPromise(this.context.cwd,{recursive:!0});let a=V.join(this.context.cwd,dr.lockfile);oe.existsSync(a)||await oe.writeFilePromise(a,"");let n=await this.cli.run(["set","version",o],{quiet:!0});if(n!==0)return n;let u=[];return this.private&&u.push("-p"),this.workspace&&u.push("-w"),this.name&&u.push(`-n=${this.name}`),this.yes&&u.push("-y"),await oe.mktempPromise(async A=>{let{code:p}=await Ur.pipevp("yarn",["init",...u],{cwd:this.context.cwd,stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr,env:await un.makeScriptEnv({binFolder:A})});return p})}async executeRegular(r){let o=null;try{o=(await St.find(r,this.context.cwd)).project}catch{o=null}oe.existsSync(this.context.cwd)||await oe.mkdirPromise(this.context.cwd,{recursive:!0});let a=await Ot.tryFind(this.context.cwd),n=a??new Ot,u=Object.fromEntries(r.get("initFields").entries());n.load(u),n.name=n.name??G.makeIdent(r.get("initScope"),this.name??V.basename(this.context.cwd)),n.packageManager=tn&&_e.isTaggedYarnVersion(tn)?`yarn@${tn}`:null,(!a&&this.workspace||this.private)&&(n.private=!0),this.workspace&&n.workspaceDefinitions.length===0&&(await oe.mkdirPromise(V.join(this.context.cwd,"packages"),{recursive:!0}),n.workspaceDefinitions=[{pattern:"packages/*"}]);let A={};n.exportTo(A);let p=V.join(this.context.cwd,Ot.fileName);await oe.changeFilePromise(p,`${JSON.stringify(A,null,2)} -`,{automaticNewlines:!0});let h=[p],C=V.join(this.context.cwd,"README.md");if(oe.existsSync(C)||(await oe.writeFilePromise(C,`# ${G.stringifyIdent(n.name)} -`),h.push(C)),!o||o.cwd===this.context.cwd){let I=V.join(this.context.cwd,dr.lockfile);oe.existsSync(I)||(await oe.writeFilePromise(I,""),h.push(I));let b=[".yarn/*","!.yarn/patches","!.yarn/plugins","!.yarn/releases","!.yarn/sdks","!.yarn/versions","","# Swap the comments on the following lines if you wish to use zero-installs","# In that case, don't forget to run `yarn config set enableGlobalCache false`!","# Documentation here: https://yarnpkg.com/features/zero-installs","","#!.yarn/cache",".pnp.*"].map(pe=>`${pe} -`).join(""),E=V.join(this.context.cwd,".gitignore");oe.existsSync(E)||(await oe.writeFilePromise(E,b),h.push(E));let N=["/.yarn/** linguist-vendored","/.yarn/releases/* binary","/.yarn/plugins/**/* binary","/.pnp.* binary linguist-generated"].map(pe=>`${pe} -`).join(""),U=V.join(this.context.cwd,".gitattributes");oe.existsSync(U)||(await oe.writeFilePromise(U,N),h.push(U));let z={["*"]:{endOfLine:"lf",insertFinalNewline:!0},["*.{js,json,yml}"]:{charset:"utf-8",indentStyle:"space",indentSize:2}};_e.mergeIntoTarget(z,r.get("initEditorConfig"));let te=`root = true -`;for(let[pe,ue]of Object.entries(z)){te+=` -[${pe}] -`;for(let[ye,ae]of Object.entries(ue)){let Ie=ye.replace(/[A-Z]/g,Fe=>`_${Fe.toLowerCase()}`);te+=`${Ie} = ${ae} -`}}let le=V.join(this.context.cwd,".editorconfig");oe.existsSync(le)||(await oe.writeFilePromise(le,te),h.push(le)),await this.cli.run(["install"],{quiet:!0}),oe.existsSync(V.join(this.context.cwd,".git"))||(await Ur.execvp("git",["init"],{cwd:this.context.cwd}),await Ur.execvp("git",["add","--",...h],{cwd:this.context.cwd}),await Ur.execvp("git",["commit","--allow-empty","-m","First commit"],{cwd:this.context.cwd}))}}};g0.paths=[["init"]],g0.usage=nt.Usage({description:"create a new package",details:"\n This command will setup a new package in your local directory.\n\n If the `-p,--private` or `-w,--workspace` options are set, the package will be private by default.\n\n If the `-w,--workspace` option is set, the package will be configured to accept a set of workspaces in the `packages/` directory.\n\n If the `-i,--install` option is given a value, Yarn will first download it using `yarn set version` and only then forward the init call to the newly downloaded bundle. Without arguments, the downloaded bundle will be `latest`.\n\n The initial settings of the manifest can be changed by using the `initScope` and `initFields` configuration values. Additionally, Yarn will generate an EditorConfig file whose rules can be altered via `initEditorConfig`, and will initialize a Git repository in the current directory.\n ",examples:[["Create a new package in the local directory","yarn init"],["Create a new private package in the local directory","yarn init -p"],["Create a new package and store the Yarn release inside","yarn init -i=latest"],["Create a new private package and defines it as a workspace root","yarn init -w"]]});var hdt={configuration:{initScope:{description:"Scope used when creating packages via the init command",type:"STRING",default:null},initFields:{description:"Additional fields to set when creating packages via the init command",type:"MAP",valueDefinition:{description:"",type:"ANY"}},initEditorConfig:{description:"Extra rules to define in the generator editorconfig",type:"MAP",valueDefinition:{description:"",type:"ANY"}}},commands:[g0]},gdt=hdt;var Tj={};Vt(Tj,{SearchCommand:()=>C0,UpgradeInteractiveCommand:()=>I0,default:()=>nIt});Ye();var Cme=$e(Be("os"));function SC({stdout:t}){if(Cme.default.endianness()==="BE")throw new Error("Interactive commands cannot be used on big-endian systems because ink depends on yoga-layout-prebuilt which only supports little-endian architectures");if(!t.isTTY)throw new Error("Interactive commands can only be used inside a TTY environment")}qt();var Fye=$e(JH()),XH={appId:"OFCNCOG2CU",apiKey:"6fe4476ee5a1832882e326b506d14126",indexName:"npm-search"},fyt=(0,Fye.default)(XH.appId,XH.apiKey).initIndex(XH.indexName),ZH=async(t,e=0)=>await fyt.search(t,{analyticsTags:["yarn-plugin-interactive-tools"],attributesToRetrieve:["name","version","owner","repository","humanDownloadsLast30Days"],page:e,hitsPerPage:10});var HB=["regular","dev","peer"],C0=class extends ut{async execute(){SC(this.context);let{Gem:e}=await Promise.resolve().then(()=>(cQ(),Bj)),{ScrollableItems:r}=await Promise.resolve().then(()=>(pQ(),fQ)),{useKeypress:o}=await Promise.resolve().then(()=>(MB(),Kwe)),{useMinistore:a}=await Promise.resolve().then(()=>(bj(),xj)),{renderForm:n}=await Promise.resolve().then(()=>(mQ(),dQ)),{default:u}=await Promise.resolve().then(()=>$e(nIe())),{Box:A,Text:p}=await Promise.resolve().then(()=>$e(ic())),{default:h,useEffect:C,useState:I}=await Promise.resolve().then(()=>$e(sn())),v=await Ke.find(this.context.cwd,this.context.plugins),b=()=>h.createElement(A,{flexDirection:"row"},h.createElement(A,{flexDirection:"column",width:48},h.createElement(A,null,h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<up>"),"/",h.createElement(p,{bold:!0,color:"cyanBright"},"<down>")," to move between packages.")),h.createElement(A,null,h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<space>")," to select a package.")),h.createElement(A,null,h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<space>")," again to change the target."))),h.createElement(A,{flexDirection:"column"},h.createElement(A,{marginLeft:1},h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<enter>")," to install the selected packages.")),h.createElement(A,{marginLeft:1},h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<ctrl+c>")," to abort.")))),E=()=>h.createElement(h.Fragment,null,h.createElement(A,{width:15},h.createElement(p,{bold:!0,underline:!0,color:"gray"},"Owner")),h.createElement(A,{width:11},h.createElement(p,{bold:!0,underline:!0,color:"gray"},"Version")),h.createElement(A,{width:10},h.createElement(p,{bold:!0,underline:!0,color:"gray"},"Downloads"))),F=()=>h.createElement(A,{width:17},h.createElement(p,{bold:!0,underline:!0,color:"gray"},"Target")),N=({hit:ae,active:Ie})=>{let[Fe,g]=a(ae.name,null);o({active:Ie},(ce,ne)=>{if(ne.name!=="space")return;if(!Fe){g(HB[0]);return}let ee=HB.indexOf(Fe)+1;ee===HB.length?g(null):g(HB[ee])},[Fe,g]);let Ee=G.parseIdent(ae.name),De=G.prettyIdent(v,Ee);return h.createElement(A,null,h.createElement(A,{width:45},h.createElement(p,{bold:!0,wrap:"wrap"},De)),h.createElement(A,{width:14,marginLeft:1},h.createElement(p,{bold:!0,wrap:"truncate"},ae.owner.name)),h.createElement(A,{width:10,marginLeft:1},h.createElement(p,{italic:!0,wrap:"truncate"},ae.version)),h.createElement(A,{width:16,marginLeft:1},h.createElement(p,null,ae.humanDownloadsLast30Days)))},U=({name:ae,active:Ie})=>{let[Fe]=a(ae,null),g=G.parseIdent(ae);return h.createElement(A,null,h.createElement(A,{width:47},h.createElement(p,{bold:!0}," - ",G.prettyIdent(v,g))),HB.map(Ee=>h.createElement(A,{key:Ee,width:14,marginLeft:1},h.createElement(p,null," ",h.createElement(e,{active:Fe===Ee})," ",h.createElement(p,{bold:!0},Ee)))))},z=()=>h.createElement(A,{marginTop:1},h.createElement(p,null,"Powered by Algolia.")),le=await n(({useSubmit:ae})=>{let Ie=a();ae(Ie);let Fe=Array.from(Ie.keys()).filter(H=>Ie.get(H)!==null),[g,Ee]=I(""),[De,ce]=I(0),[ne,ee]=I([]),we=H=>{H.match(/\t| /)||Ee(H)},be=async()=>{ce(0);let H=await ZH(g);H.query===g&&ee(H.hits)},ht=async()=>{let H=await ZH(g,De+1);H.query===g&&H.page-1===De&&(ce(H.page),ee([...ne,...H.hits]))};return C(()=>{g?be():ee([])},[g]),h.createElement(A,{flexDirection:"column"},h.createElement(b,null),h.createElement(A,{flexDirection:"row",marginTop:1},h.createElement(p,{bold:!0},"Search: "),h.createElement(A,{width:41},h.createElement(u,{value:g,onChange:we,placeholder:"i.e. babel, webpack, react...",showCursor:!1})),h.createElement(E,null)),ne.length?h.createElement(r,{radius:2,loop:!1,children:ne.map(H=>h.createElement(N,{key:H.name,hit:H,active:!1})),willReachEnd:ht}):h.createElement(p,{color:"gray"},"Start typing..."),h.createElement(A,{flexDirection:"row",marginTop:1},h.createElement(A,{width:49},h.createElement(p,{bold:!0},"Selected:")),h.createElement(F,null)),Fe.length?Fe.map(H=>h.createElement(U,{key:H,name:H,active:!1})):h.createElement(p,{color:"gray"},"No selected packages..."),h.createElement(z,null))},{},{stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr});if(typeof le>"u")return 1;let pe=Array.from(le.keys()).filter(ae=>le.get(ae)==="regular"),ue=Array.from(le.keys()).filter(ae=>le.get(ae)==="dev"),ye=Array.from(le.keys()).filter(ae=>le.get(ae)==="peer");return pe.length&&await this.cli.run(["add",...pe]),ue.length&&await this.cli.run(["add","--dev",...ue]),ye&&await this.cli.run(["add","--peer",...ye]),0}};C0.paths=[["search"]],C0.usage=nt.Usage({category:"Interactive commands",description:"open the search interface",details:` - This command opens a fullscreen terminal interface where you can search for and install packages from the npm registry. - `,examples:[["Open the search window","yarn search"]]});Ye();qt();E_();var uIe=$e(Jn()),cIe=/^((?:[\^~]|>=?)?)([0-9]+)(\.[0-9]+)(\.[0-9]+)((?:-\S+)?)$/,AIe=(t,e)=>t.length>0?[t.slice(0,e)].concat(AIe(t.slice(e),e)):[],I0=class extends ut{async execute(){SC(this.context);let{ItemOptions:e}=await Promise.resolve().then(()=>(lIe(),aIe)),{Pad:r}=await Promise.resolve().then(()=>(Rj(),oIe)),{ScrollableItems:o}=await Promise.resolve().then(()=>(pQ(),fQ)),{useMinistore:a}=await Promise.resolve().then(()=>(bj(),xj)),{renderForm:n}=await Promise.resolve().then(()=>(mQ(),dQ)),{Box:u,Text:A}=await Promise.resolve().then(()=>$e(ic())),{default:p,useEffect:h,useRef:C,useState:I}=await Promise.resolve().then(()=>$e(sn())),v=await Ke.find(this.context.cwd,this.context.plugins),{project:b,workspace:E}=await St.find(v,this.context.cwd),F=await Nr.find(v);if(!E)throw new rr(b.cwd,this.context.cwd);await b.restoreInstallState({restoreResolutions:!1});let N=this.context.stdout.rows-7,U=(Ee,De)=>{let ce=Ape(Ee,De),ne="";for(let ee of ce)ee.added?ne+=de.pretty(v,ee.value,"green"):ee.removed||(ne+=ee.value);return ne},z=(Ee,De)=>{if(Ee===De)return De;let ce=G.parseRange(Ee),ne=G.parseRange(De),ee=ce.selector.match(cIe),we=ne.selector.match(cIe);if(!ee||!we)return U(Ee,De);let be=["gray","red","yellow","green","magenta"],ht=null,H="";for(let lt=1;lt<be.length;++lt)ht!==null||ee[lt]!==we[lt]?(ht===null&&(ht=be[lt-1]),H+=de.pretty(v,we[lt],ht)):H+=we[lt];return H},te=async(Ee,De,ce)=>{let ne=await Jc.fetchDescriptorFrom(Ee,ce,{project:b,cache:F,preserveModifier:De,workspace:E});return ne!==null?ne.range:Ee.range},le=async Ee=>{let De=uIe.default.valid(Ee.range)?`^${Ee.range}`:Ee.range,[ce,ne]=await Promise.all([te(Ee,Ee.range,De).catch(()=>null),te(Ee,Ee.range,"latest").catch(()=>null)]),ee=[{value:null,label:Ee.range}];return ce&&ce!==Ee.range?ee.push({value:ce,label:z(Ee.range,ce)}):ee.push({value:null,label:""}),ne&&ne!==ce&&ne!==Ee.range?ee.push({value:ne,label:z(Ee.range,ne)}):ee.push({value:null,label:""}),ee},pe=()=>p.createElement(u,{flexDirection:"row"},p.createElement(u,{flexDirection:"column",width:49},p.createElement(u,{marginLeft:1},p.createElement(A,null,"Press ",p.createElement(A,{bold:!0,color:"cyanBright"},"<up>"),"/",p.createElement(A,{bold:!0,color:"cyanBright"},"<down>")," to select packages.")),p.createElement(u,{marginLeft:1},p.createElement(A,null,"Press ",p.createElement(A,{bold:!0,color:"cyanBright"},"<left>"),"/",p.createElement(A,{bold:!0,color:"cyanBright"},"<right>")," to select versions."))),p.createElement(u,{flexDirection:"column"},p.createElement(u,{marginLeft:1},p.createElement(A,null,"Press ",p.createElement(A,{bold:!0,color:"cyanBright"},"<enter>")," to install.")),p.createElement(u,{marginLeft:1},p.createElement(A,null,"Press ",p.createElement(A,{bold:!0,color:"cyanBright"},"<ctrl+c>")," to abort.")))),ue=()=>p.createElement(u,{flexDirection:"row",paddingTop:1,paddingBottom:1},p.createElement(u,{width:50},p.createElement(A,{bold:!0},p.createElement(A,{color:"greenBright"},"?")," Pick the packages you want to upgrade.")),p.createElement(u,{width:17},p.createElement(A,{bold:!0,underline:!0,color:"gray"},"Current")),p.createElement(u,{width:17},p.createElement(A,{bold:!0,underline:!0,color:"gray"},"Range")),p.createElement(u,{width:17},p.createElement(A,{bold:!0,underline:!0,color:"gray"},"Latest"))),ye=({active:Ee,descriptor:De,suggestions:ce})=>{let[ne,ee]=a(De.descriptorHash,null),we=G.stringifyIdent(De),be=Math.max(0,45-we.length);return p.createElement(p.Fragment,null,p.createElement(u,null,p.createElement(u,{width:45},p.createElement(A,{bold:!0},G.prettyIdent(v,De)),p.createElement(r,{active:Ee,length:be})),p.createElement(e,{active:Ee,options:ce,value:ne,skewer:!0,onChange:ee,sizes:[17,17,17]})))},ae=({dependencies:Ee})=>{let[De,ce]=I(Ee.map(()=>null)),ne=C(!0),ee=async we=>{let be=await le(we);return be.filter(ht=>ht.label!=="").length<=1?null:{descriptor:we,suggestions:be}};return h(()=>()=>{ne.current=!1},[]),h(()=>{let we=Math.trunc(N*1.75),be=Ee.slice(0,we),ht=Ee.slice(we),H=AIe(ht,N),lt=be.map(ee).reduce(async(Te,ke)=>{await Te;let xe=await ke;xe!==null&&(!ne.current||ce(He=>{let Re=He.findIndex(je=>je===null),ze=[...He];return ze[Re]=xe,ze}))},Promise.resolve());H.reduce((Te,ke)=>Promise.all(ke.map(xe=>Promise.resolve().then(()=>ee(xe)))).then(async xe=>{xe=xe.filter(He=>He!==null),await Te,ne.current&&ce(He=>{let Re=He.findIndex(ze=>ze===null);return He.slice(0,Re).concat(xe).concat(He.slice(Re+xe.length))})}),lt).then(()=>{ne.current&&ce(Te=>Te.filter(ke=>ke!==null))})},[]),De.length?p.createElement(o,{radius:N>>1,children:De.map((we,be)=>we!==null?p.createElement(ye,{key:be,active:!1,descriptor:we.descriptor,suggestions:we.suggestions}):p.createElement(A,{key:be},"Loading..."))}):p.createElement(A,null,"No upgrades found")},Fe=await n(({useSubmit:Ee})=>{Ee(a());let De=new Map;for(let ne of b.workspaces)for(let ee of["dependencies","devDependencies"])for(let we of ne.manifest[ee].values())b.tryWorkspaceByDescriptor(we)===null&&(we.range.startsWith("link:")||De.set(we.descriptorHash,we));let ce=_e.sortMap(De.values(),ne=>G.stringifyDescriptor(ne));return p.createElement(u,{flexDirection:"column"},p.createElement(pe,null),p.createElement(ue,null),p.createElement(ae,{dependencies:ce}))},{},{stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr});if(typeof Fe>"u")return 1;let g=!1;for(let Ee of b.workspaces)for(let De of["dependencies","devDependencies"]){let ce=Ee.manifest[De];for(let ne of ce.values()){let ee=Fe.get(ne.descriptorHash);typeof ee<"u"&&ee!==null&&(ce.set(ne.identHash,G.makeDescriptor(ne,ee)),g=!0)}}return g?await b.installWithNewReport({quiet:this.context.quiet,stdout:this.context.stdout},{cache:F}):0}};I0.paths=[["upgrade-interactive"]],I0.usage=nt.Usage({category:"Interactive commands",description:"open the upgrade interface",details:` - This command opens a fullscreen terminal interface where you can see any out of date packages used by your application, their status compared to the latest versions available on the remote registry, and select packages to upgrade. - `,examples:[["Open the upgrade window","yarn upgrade-interactive"]]});var rIt={commands:[C0,I0]},nIt=rIt;var Lj={};Vt(Lj,{LinkFetcher:()=>qB,LinkResolver:()=>GB,PortalFetcher:()=>YB,PortalResolver:()=>WB,default:()=>sIt});Ye();Pt();var $f="portal:",ep="link:";var qB=class{supports(e,r){return!!e.reference.startsWith(ep)}getLocalPath(e,r){let{parentLocator:o,path:a}=G.parseFileStyleRange(e.reference,{protocol:ep});if(V.isAbsolute(a))return a;let n=r.fetcher.getLocalPath(o,r);return n===null?null:V.resolve(n,a)}async fetch(e,r){let{parentLocator:o,path:a}=G.parseFileStyleRange(e.reference,{protocol:ep}),n=V.isAbsolute(a)?{packageFs:new gn(Bt.root),prefixPath:Bt.dot,localPath:Bt.root}:await r.fetcher.fetch(o,r),u=n.localPath?{packageFs:new gn(Bt.root),prefixPath:V.relative(Bt.root,n.localPath),localPath:Bt.root}:n;n!==u&&n.releaseFs&&n.releaseFs();let A=u.packageFs,p=V.resolve(u.localPath??u.packageFs.getRealPath(),u.prefixPath,a);return n.localPath?{packageFs:new gn(p,{baseFs:A}),releaseFs:u.releaseFs,prefixPath:Bt.dot,discardFromLookup:!0,localPath:p}:{packageFs:new _u(p,{baseFs:A}),releaseFs:u.releaseFs,prefixPath:Bt.dot,discardFromLookup:!0}}};Ye();Pt();var GB=class{supportsDescriptor(e,r){return!!e.range.startsWith(ep)}supportsLocator(e,r){return!!e.reference.startsWith(ep)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,o){return G.bindDescriptor(e,{locator:G.stringifyLocator(r)})}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){let a=e.range.slice(ep.length);return[G.makeLocator(e,`${ep}${fe.toPortablePath(a)}`)]}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){return{...e,version:"0.0.0",languageName:r.project.configuration.get("defaultLanguageName"),linkType:"SOFT",conditions:null,dependencies:new Map,peerDependencies:new Map,dependenciesMeta:new Map,peerDependenciesMeta:new Map,bin:new Map}}};Ye();Pt();var YB=class{supports(e,r){return!!e.reference.startsWith($f)}getLocalPath(e,r){let{parentLocator:o,path:a}=G.parseFileStyleRange(e.reference,{protocol:$f});if(V.isAbsolute(a))return a;let n=r.fetcher.getLocalPath(o,r);return n===null?null:V.resolve(n,a)}async fetch(e,r){let{parentLocator:o,path:a}=G.parseFileStyleRange(e.reference,{protocol:$f}),n=V.isAbsolute(a)?{packageFs:new gn(Bt.root),prefixPath:Bt.dot,localPath:Bt.root}:await r.fetcher.fetch(o,r),u=n.localPath?{packageFs:new gn(Bt.root),prefixPath:V.relative(Bt.root,n.localPath),localPath:Bt.root}:n;n!==u&&n.releaseFs&&n.releaseFs();let A=u.packageFs,p=V.resolve(u.localPath??u.packageFs.getRealPath(),u.prefixPath,a);return n.localPath?{packageFs:new gn(p,{baseFs:A}),releaseFs:u.releaseFs,prefixPath:Bt.dot,localPath:p}:{packageFs:new _u(p,{baseFs:A}),releaseFs:u.releaseFs,prefixPath:Bt.dot}}};Ye();Ye();Pt();var WB=class{supportsDescriptor(e,r){return!!e.range.startsWith($f)}supportsLocator(e,r){return!!e.reference.startsWith($f)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,o){return G.bindDescriptor(e,{locator:G.stringifyLocator(r)})}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){let a=e.range.slice($f.length);return[G.makeLocator(e,`${$f}${fe.toPortablePath(a)}`)]}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let o=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),a=await _e.releaseAfterUseAsync(async()=>await Ot.find(o.prefixPath,{baseFs:o.packageFs}),o.releaseFs);return{...e,version:a.version||"0.0.0",languageName:a.languageName||r.project.configuration.get("defaultLanguageName"),linkType:"SOFT",conditions:a.getConditions(),dependencies:r.project.configuration.normalizeDependencyMap(a.dependencies),peerDependencies:a.peerDependencies,dependenciesMeta:a.dependenciesMeta,peerDependenciesMeta:a.peerDependenciesMeta,bin:a.bin}}};var iIt={fetchers:[qB,YB],resolvers:[GB,WB]},sIt=iIt;var Eq={};Vt(Eq,{NodeModulesLinker:()=>lv,NodeModulesMode:()=>gq,PnpLooseLinker:()=>cv,default:()=>w1t});Pt();Ye();Pt();Pt();var Oj=(t,e)=>`${t}@${e}`,fIe=(t,e)=>{let r=e.indexOf("#"),o=r>=0?e.substring(r+1):e;return Oj(t,o)};var gIe=(t,e={})=>{let r=e.debugLevel||Number(process.env.NM_DEBUG_LEVEL||-1),o=e.check||r>=9,a=e.hoistingLimits||new Map,n={check:o,debugLevel:r,hoistingLimits:a,fastLookupPossible:!0},u;n.debugLevel>=0&&(u=Date.now());let A=fIt(t,n),p=!1,h=0;do p=Mj(A,[A],new Set([A.locator]),new Map,n).anotherRoundNeeded,n.fastLookupPossible=!1,h++;while(p);if(n.debugLevel>=0&&console.log(`hoist time: ${Date.now()-u}ms, rounds: ${h}`),n.debugLevel>=1){let C=KB(A);if(Mj(A,[A],new Set([A.locator]),new Map,n).isGraphChanged)throw new Error(`The hoisting result is not terminal, prev tree: -${C}, next tree: -${KB(A)}`);let v=dIe(A);if(v)throw new Error(`${v}, after hoisting finished: -${KB(A)}`)}return n.debugLevel>=2&&console.log(KB(A)),pIt(A)},oIt=t=>{let e=t[t.length-1],r=new Map,o=new Set,a=n=>{if(!o.has(n)){o.add(n);for(let u of n.hoistedDependencies.values())r.set(u.name,u);for(let u of n.dependencies.values())n.peerNames.has(u.name)||a(u)}};return a(e),r},aIt=t=>{let e=t[t.length-1],r=new Map,o=new Set,a=new Set,n=(u,A)=>{if(o.has(u))return;o.add(u);for(let h of u.hoistedDependencies.values())if(!A.has(h.name)){let C;for(let I of t)C=I.dependencies.get(h.name),C&&r.set(C.name,C)}let p=new Set;for(let h of u.dependencies.values())p.add(h.name);for(let h of u.dependencies.values())u.peerNames.has(h.name)||n(h,p)};return n(e,a),r},pIe=(t,e)=>{if(e.decoupled)return e;let{name:r,references:o,ident:a,locator:n,dependencies:u,originalDependencies:A,hoistedDependencies:p,peerNames:h,reasons:C,isHoistBorder:I,hoistPriority:v,dependencyKind:b,hoistedFrom:E,hoistedTo:F}=e,N={name:r,references:new Set(o),ident:a,locator:n,dependencies:new Map(u),originalDependencies:new Map(A),hoistedDependencies:new Map(p),peerNames:new Set(h),reasons:new Map(C),decoupled:!0,isHoistBorder:I,hoistPriority:v,dependencyKind:b,hoistedFrom:new Map(E),hoistedTo:new Map(F)},U=N.dependencies.get(r);return U&&U.ident==N.ident&&N.dependencies.set(r,N),t.dependencies.set(N.name,N),N},lIt=(t,e)=>{let r=new Map([[t.name,[t.ident]]]);for(let a of t.dependencies.values())t.peerNames.has(a.name)||r.set(a.name,[a.ident]);let o=Array.from(e.keys());o.sort((a,n)=>{let u=e.get(a),A=e.get(n);return A.hoistPriority!==u.hoistPriority?A.hoistPriority-u.hoistPriority:A.peerDependents.size!==u.peerDependents.size?A.peerDependents.size-u.peerDependents.size:A.dependents.size-u.dependents.size});for(let a of o){let n=a.substring(0,a.indexOf("@",1)),u=a.substring(n.length+1);if(!t.peerNames.has(n)){let A=r.get(n);A||(A=[],r.set(n,A)),A.indexOf(u)<0&&A.push(u)}}return r},Nj=t=>{let e=new Set,r=(o,a=new Set)=>{if(!a.has(o)){a.add(o);for(let n of o.peerNames)if(!t.peerNames.has(n)){let u=t.dependencies.get(n);u&&!e.has(u)&&r(u,a)}e.add(o)}};for(let o of t.dependencies.values())t.peerNames.has(o.name)||r(o);return e},Mj=(t,e,r,o,a,n=new Set)=>{let u=e[e.length-1];if(n.has(u))return{anotherRoundNeeded:!1,isGraphChanged:!1};n.add(u);let A=hIt(u),p=lIt(u,A),h=t==u?new Map:a.fastLookupPossible?oIt(e):aIt(e),C,I=!1,v=!1,b=new Map(Array.from(p.entries()).map(([F,N])=>[F,N[0]])),E=new Map;do{let F=AIt(t,e,r,h,b,p,o,E,a);F.isGraphChanged&&(v=!0),F.anotherRoundNeeded&&(I=!0),C=!1;for(let[N,U]of p)U.length>1&&!u.dependencies.has(N)&&(b.delete(N),U.shift(),b.set(N,U[0]),C=!0)}while(C);for(let F of u.dependencies.values())if(!u.peerNames.has(F.name)&&!r.has(F.locator)){r.add(F.locator);let N=Mj(t,[...e,F],r,E,a);N.isGraphChanged&&(v=!0),N.anotherRoundNeeded&&(I=!0),r.delete(F.locator)}return{anotherRoundNeeded:I,isGraphChanged:v}},cIt=t=>{for(let[e,r]of t.dependencies)if(!t.peerNames.has(e)&&r.ident!==t.ident)return!0;return!1},uIt=(t,e,r,o,a,n,u,A,{outputReason:p,fastLookupPossible:h})=>{let C,I=null,v=new Set;p&&(C=`${Array.from(e).map(N=>no(N)).join("\u2192")}`);let b=r[r.length-1],F=!(o.ident===b.ident);if(p&&!F&&(I="- self-reference"),F&&(F=o.dependencyKind!==1,p&&!F&&(I="- workspace")),F&&o.dependencyKind===2&&(F=!cIt(o),p&&!F&&(I="- external soft link with unhoisted dependencies")),F&&(F=b.dependencyKind!==1||b.hoistedFrom.has(o.name)||e.size===1,p&&!F&&(I=b.reasons.get(o.name))),F&&(F=!t.peerNames.has(o.name),p&&!F&&(I=`- cannot shadow peer: ${no(t.originalDependencies.get(o.name).locator)} at ${C}`)),F){let N=!1,U=a.get(o.name);if(N=!U||U.ident===o.ident,p&&!N&&(I=`- filled by: ${no(U.locator)} at ${C}`),N)for(let z=r.length-1;z>=1;z--){let le=r[z].dependencies.get(o.name);if(le&&le.ident!==o.ident){N=!1;let pe=A.get(b);pe||(pe=new Set,A.set(b,pe)),pe.add(o.name),p&&(I=`- filled by ${no(le.locator)} at ${r.slice(0,z).map(ue=>no(ue.locator)).join("\u2192")}`);break}}F=N}if(F&&(F=n.get(o.name)===o.ident,p&&!F&&(I=`- filled by: ${no(u.get(o.name)[0])} at ${C}`)),F){let N=!0,U=new Set(o.peerNames);for(let z=r.length-1;z>=1;z--){let te=r[z];for(let le of U){if(te.peerNames.has(le)&&te.originalDependencies.has(le))continue;let pe=te.dependencies.get(le);pe&&t.dependencies.get(le)!==pe&&(z===r.length-1?v.add(pe):(v=null,N=!1,p&&(I=`- peer dependency ${no(pe.locator)} from parent ${no(te.locator)} was not hoisted to ${C}`))),U.delete(le)}if(!N)break}F=N}if(F&&!h)for(let N of o.hoistedDependencies.values()){let U=a.get(N.name)||t.dependencies.get(N.name);if(!U||N.ident!==U.ident){F=!1,p&&(I=`- previously hoisted dependency mismatch, needed: ${no(N.locator)}, available: ${no(U?.locator)}`);break}}return v!==null&&v.size>0?{isHoistable:2,dependsOn:v,reason:I}:{isHoistable:F?0:1,reason:I}},yQ=t=>`${t.name}@${t.locator}`,AIt=(t,e,r,o,a,n,u,A,p)=>{let h=e[e.length-1],C=new Set,I=!1,v=!1,b=(U,z,te,le,pe)=>{if(C.has(le))return;let ue=[...z,yQ(le)],ye=[...te,yQ(le)],ae=new Map,Ie=new Map;for(let ce of Nj(le)){let ne=uIt(h,r,[h,...U,le],ce,o,a,n,A,{outputReason:p.debugLevel>=2,fastLookupPossible:p.fastLookupPossible});if(Ie.set(ce,ne),ne.isHoistable===2)for(let ee of ne.dependsOn){let we=ae.get(ee.name)||new Set;we.add(ce.name),ae.set(ee.name,we)}}let Fe=new Set,g=(ce,ne,ee)=>{if(!Fe.has(ce)){Fe.add(ce),Ie.set(ce,{isHoistable:1,reason:ee});for(let we of ae.get(ce.name)||[])g(le.dependencies.get(we),ne,p.debugLevel>=2?`- peer dependency ${no(ce.locator)} from parent ${no(le.locator)} was not hoisted`:"")}};for(let[ce,ne]of Ie)ne.isHoistable===1&&g(ce,ne,ne.reason);let Ee=!1;for(let ce of Ie.keys())if(!Fe.has(ce)){v=!0;let ne=u.get(le);ne&&ne.has(ce.name)&&(I=!0),Ee=!0,le.dependencies.delete(ce.name),le.hoistedDependencies.set(ce.name,ce),le.reasons.delete(ce.name);let ee=h.dependencies.get(ce.name);if(p.debugLevel>=2){let we=Array.from(z).concat([le.locator]).map(ht=>no(ht)).join("\u2192"),be=h.hoistedFrom.get(ce.name);be||(be=[],h.hoistedFrom.set(ce.name,be)),be.push(we),le.hoistedTo.set(ce.name,Array.from(e).map(ht=>no(ht.locator)).join("\u2192"))}if(!ee)h.ident!==ce.ident&&(h.dependencies.set(ce.name,ce),pe.add(ce));else for(let we of ce.references)ee.references.add(we)}if(le.dependencyKind===2&&Ee&&(I=!0),p.check){let ce=dIe(t);if(ce)throw new Error(`${ce}, after hoisting dependencies of ${[h,...U,le].map(ne=>no(ne.locator)).join("\u2192")}: -${KB(t)}`)}let De=Nj(le);for(let ce of De)if(Fe.has(ce)){let ne=Ie.get(ce);if((a.get(ce.name)===ce.ident||!le.reasons.has(ce.name))&&ne.isHoistable!==0&&le.reasons.set(ce.name,ne.reason),!ce.isHoistBorder&&ye.indexOf(yQ(ce))<0){C.add(le);let we=pIe(le,ce);b([...U,le],ue,ye,we,F),C.delete(le)}}},E,F=new Set(Nj(h)),N=Array.from(e).map(U=>yQ(U));do{E=F,F=new Set;for(let U of E){if(U.locator===h.locator||U.isHoistBorder)continue;let z=pIe(h,U);b([],Array.from(r),N,z,F)}}while(F.size>0);return{anotherRoundNeeded:I,isGraphChanged:v}},dIe=t=>{let e=[],r=new Set,o=new Set,a=(n,u,A)=>{if(r.has(n)||(r.add(n),o.has(n)))return;let p=new Map(u);for(let h of n.dependencies.values())n.peerNames.has(h.name)||p.set(h.name,h);for(let h of n.originalDependencies.values()){let C=p.get(h.name),I=()=>`${Array.from(o).concat([n]).map(v=>no(v.locator)).join("\u2192")}`;if(n.peerNames.has(h.name)){let v=u.get(h.name);(v!==C||!v||v.ident!==h.ident)&&e.push(`${I()} - broken peer promise: expected ${h.ident} but found ${v&&v.ident}`)}else{let v=A.hoistedFrom.get(n.name),b=n.hoistedTo.get(h.name),E=`${v?` hoisted from ${v.join(", ")}`:""}`,F=`${b?` hoisted to ${b}`:""}`,N=`${I()}${E}`;C?C.ident!==h.ident&&e.push(`${N} - broken require promise for ${h.name}${F}: expected ${h.ident}, but found: ${C.ident}`):e.push(`${N} - broken require promise: no required dependency ${h.name}${F} found`)}}o.add(n);for(let h of n.dependencies.values())n.peerNames.has(h.name)||a(h,p,n);o.delete(n)};return a(t,t.dependencies,t),e.join(` -`)},fIt=(t,e)=>{let{identName:r,name:o,reference:a,peerNames:n}=t,u={name:o,references:new Set([a]),locator:Oj(r,a),ident:fIe(r,a),dependencies:new Map,originalDependencies:new Map,hoistedDependencies:new Map,peerNames:new Set(n),reasons:new Map,decoupled:!0,isHoistBorder:!0,hoistPriority:0,dependencyKind:1,hoistedFrom:new Map,hoistedTo:new Map},A=new Map([[t,u]]),p=(h,C)=>{let I=A.get(h),v=!!I;if(!I){let{name:b,identName:E,reference:F,peerNames:N,hoistPriority:U,dependencyKind:z}=h,te=e.hoistingLimits.get(C.locator);I={name:b,references:new Set([F]),locator:Oj(E,F),ident:fIe(E,F),dependencies:new Map,originalDependencies:new Map,hoistedDependencies:new Map,peerNames:new Set(N),reasons:new Map,decoupled:!0,isHoistBorder:te?te.has(b):!1,hoistPriority:U||0,dependencyKind:z||0,hoistedFrom:new Map,hoistedTo:new Map},A.set(h,I)}if(C.dependencies.set(h.name,I),C.originalDependencies.set(h.name,I),v){let b=new Set,E=F=>{if(!b.has(F)){b.add(F),F.decoupled=!1;for(let N of F.dependencies.values())F.peerNames.has(N.name)||E(N)}};E(I)}else for(let b of h.dependencies)p(b,I)};for(let h of t.dependencies)p(h,u);return u},Uj=t=>t.substring(0,t.indexOf("@",1)),pIt=t=>{let e={name:t.name,identName:Uj(t.locator),references:new Set(t.references),dependencies:new Set},r=new Set([t]),o=(a,n,u)=>{let A=r.has(a),p;if(n===a)p=u;else{let{name:h,references:C,locator:I}=a;p={name:h,identName:Uj(I),references:C,dependencies:new Set}}if(u.dependencies.add(p),!A){r.add(a);for(let h of a.dependencies.values())a.peerNames.has(h.name)||o(h,a,p);r.delete(a)}};for(let a of t.dependencies.values())o(a,t,e);return e},hIt=t=>{let e=new Map,r=new Set([t]),o=u=>`${u.name}@${u.ident}`,a=u=>{let A=o(u),p=e.get(A);return p||(p={dependents:new Set,peerDependents:new Set,hoistPriority:0},e.set(A,p)),p},n=(u,A)=>{let p=!!r.has(A);if(a(A).dependents.add(u.ident),!p){r.add(A);for(let C of A.dependencies.values()){let I=a(C);I.hoistPriority=Math.max(I.hoistPriority,C.hoistPriority),A.peerNames.has(C.name)?I.peerDependents.add(A.ident):n(A,C)}}};for(let u of t.dependencies.values())t.peerNames.has(u.name)||n(t,u);return e},no=t=>{if(!t)return"none";let e=t.indexOf("@",1),r=t.substring(0,e);r.endsWith("$wsroot$")&&(r=`wh:${r.replace("$wsroot$","")}`);let o=t.substring(e+1);if(o==="workspace:.")return".";if(o){let a=(o.indexOf("#")>0?o.split("#")[1]:o).replace("npm:","");return o.startsWith("virtual")&&(r=`v:${r}`),a.startsWith("workspace")&&(r=`w:${r}`,a=""),`${r}${a?`@${a}`:""}`}else return`${r}`},hIe=5e4,KB=t=>{let e=0,r=(a,n,u="")=>{if(e>hIe||n.has(a))return"";e++;let A=Array.from(a.dependencies.values()).sort((h,C)=>h.name===C.name?0:h.name>C.name?1:-1),p="";n.add(a);for(let h=0;h<A.length;h++){let C=A[h];if(!a.peerNames.has(C.name)&&C!==a){let I=a.reasons.get(C.name),v=Uj(C.locator);p+=`${u}${h<A.length-1?"\u251C\u2500":"\u2514\u2500"}${(n.has(C)?">":"")+(v!==C.name?`a:${C.name}:`:"")+no(C.locator)+(I?` ${I}`:"")} -`,p+=r(C,n,`${u}${h<A.length-1?"\u2502 ":" "}`)}}return n.delete(a),p};return r(t,new Set)+(e>hIe?` -Tree is too large, part of the tree has been dunped -`:"")};var VB=(o=>(o.WORKSPACES="workspaces",o.DEPENDENCIES="dependencies",o.NONE="none",o))(VB||{}),mIe="node_modules",pm="$wsroot$";var zB=(t,e)=>{let{packageTree:r,hoistingLimits:o,errors:a,preserveSymlinksRequired:n}=dIt(t,e),u=null;if(a.length===0){let A=gIe(r,{hoistingLimits:o});u=yIt(t,A,e)}return{tree:u,errors:a,preserveSymlinksRequired:n}},gA=t=>`${t.name}@${t.reference}`,Hj=t=>{let e=new Map;for(let[r,o]of t.entries())if(!o.dirList){let a=e.get(o.locator);a||(a={target:o.target,linkType:o.linkType,locations:[],aliases:o.aliases},e.set(o.locator,a)),a.locations.push(r)}for(let r of e.values())r.locations=r.locations.sort((o,a)=>{let n=o.split(V.delimiter).length,u=a.split(V.delimiter).length;return a===o?0:n!==u?u-n:a>o?1:-1});return e},yIe=(t,e)=>{let r=G.isVirtualLocator(t)?G.devirtualizeLocator(t):t,o=G.isVirtualLocator(e)?G.devirtualizeLocator(e):e;return G.areLocatorsEqual(r,o)},_j=(t,e,r,o)=>{if(t.linkType!=="SOFT")return!1;let a=fe.toPortablePath(r.resolveVirtual&&e.reference&&e.reference.startsWith("virtual:")?r.resolveVirtual(t.packageLocation):t.packageLocation);return V.contains(o,a)===null},gIt=t=>{let e=t.getPackageInformation(t.topLevel);if(e===null)throw new Error("Assertion failed: Expected the top-level package to have been registered");if(t.findPackageLocator(e.packageLocation)===null)throw new Error("Assertion failed: Expected the top-level package to have a physical locator");let o=fe.toPortablePath(e.packageLocation.slice(0,-1)),a=new Map,n={children:new Map},u=t.getDependencyTreeRoots(),A=new Map,p=new Set,h=(v,b)=>{let E=gA(v);if(p.has(E))return;p.add(E);let F=t.getPackageInformation(v);if(F){let N=b?gA(b):"";if(gA(v)!==N&&F.linkType==="SOFT"&&!_j(F,v,t,o)){let U=EIe(F,v,t);(!A.get(U)||v.reference.startsWith("workspace:"))&&A.set(U,v)}for(let[U,z]of F.packageDependencies)z!==null&&(F.packagePeers.has(U)||h(t.getLocator(U,z),v))}};for(let v of u)h(v,null);let C=o.split(V.sep);for(let v of A.values()){let b=t.getPackageInformation(v),F=fe.toPortablePath(b.packageLocation.slice(0,-1)).split(V.sep).slice(C.length),N=n;for(let U of F){let z=N.children.get(U);z||(z={children:new Map},N.children.set(U,z)),N=z}N.workspaceLocator=v}let I=(v,b)=>{if(v.workspaceLocator){let E=gA(b),F=a.get(E);F||(F=new Set,a.set(E,F)),F.add(v.workspaceLocator)}for(let E of v.children.values())I(E,v.workspaceLocator||b)};for(let v of n.children.values())I(v,n.workspaceLocator);return a},dIt=(t,e)=>{let r=[],o=!1,a=new Map,n=gIt(t),u=t.getPackageInformation(t.topLevel);if(u===null)throw new Error("Assertion failed: Expected the top-level package to have been registered");let A=t.findPackageLocator(u.packageLocation);if(A===null)throw new Error("Assertion failed: Expected the top-level package to have a physical locator");let p=fe.toPortablePath(u.packageLocation.slice(0,-1)),h={name:A.name,identName:A.name,reference:A.reference,peerNames:u.packagePeers,dependencies:new Set,dependencyKind:1},C=new Map,I=(b,E)=>`${gA(E)}:${b}`,v=(b,E,F,N,U,z,te,le)=>{let pe=I(b,F),ue=C.get(pe),ye=!!ue;!ye&&F.name===A.name&&F.reference===A.reference&&(ue=h,C.set(pe,h));let ae=_j(E,F,t,p);if(!ue){let ce=0;ae?ce=2:E.linkType==="SOFT"&&F.name.endsWith(pm)&&(ce=1),ue={name:b,identName:F.name,reference:F.reference,dependencies:new Set,peerNames:ce===1?new Set:E.packagePeers,dependencyKind:ce},C.set(pe,ue)}let Ie;if(ae?Ie=2:U.linkType==="SOFT"?Ie=1:Ie=0,ue.hoistPriority=Math.max(ue.hoistPriority||0,Ie),le&&!ae){let ce=gA({name:N.identName,reference:N.reference}),ne=a.get(ce)||new Set;a.set(ce,ne),ne.add(ue.name)}let Fe=new Map(E.packageDependencies);if(e.project){let ce=e.project.workspacesByCwd.get(fe.toPortablePath(E.packageLocation.slice(0,-1)));if(ce){let ne=new Set([...Array.from(ce.manifest.peerDependencies.values(),ee=>G.stringifyIdent(ee)),...Array.from(ce.manifest.peerDependenciesMeta.keys())]);for(let ee of ne)Fe.has(ee)||(Fe.set(ee,z.get(ee)||null),ue.peerNames.add(ee))}}let g=gA({name:F.name.replace(pm,""),reference:F.reference}),Ee=n.get(g);if(Ee)for(let ce of Ee)Fe.set(`${ce.name}${pm}`,ce.reference);(E!==U||E.linkType!=="SOFT"||!ae&&(!e.selfReferencesByCwd||e.selfReferencesByCwd.get(te)))&&N.dependencies.add(ue);let De=F!==A&&E.linkType==="SOFT"&&!F.name.endsWith(pm)&&!ae;if(!ye&&!De){let ce=new Map;for(let[ne,ee]of Fe)if(ee!==null){let we=t.getLocator(ne,ee),be=t.getLocator(ne.replace(pm,""),ee),ht=t.getPackageInformation(be);if(ht===null)throw new Error("Assertion failed: Expected the package to have been registered");let H=_j(ht,we,t,p);if(e.validateExternalSoftLinks&&e.project&&H){ht.packageDependencies.size>0&&(o=!0);for(let[He,Re]of ht.packageDependencies)if(Re!==null){let ze=G.parseLocator(Array.isArray(Re)?`${Re[0]}@${Re[1]}`:`${He}@${Re}`);if(gA(ze)!==gA(we)){let je=Fe.get(He);if(je){let x=G.parseLocator(Array.isArray(je)?`${je[0]}@${je[1]}`:`${He}@${je}`);yIe(x,ze)||r.push({messageName:71,text:`Cannot link ${G.prettyIdent(e.project.configuration,G.parseIdent(we.name))} into ${G.prettyLocator(e.project.configuration,G.parseLocator(`${F.name}@${F.reference}`))} dependency ${G.prettyLocator(e.project.configuration,ze)} conflicts with parent dependency ${G.prettyLocator(e.project.configuration,x)}`})}else{let x=ce.get(He);if(x){let w=x.target,S=G.parseLocator(Array.isArray(w)?`${w[0]}@${w[1]}`:`${He}@${w}`);yIe(S,ze)||r.push({messageName:71,text:`Cannot link ${G.prettyIdent(e.project.configuration,G.parseIdent(we.name))} into ${G.prettyLocator(e.project.configuration,G.parseLocator(`${F.name}@${F.reference}`))} dependency ${G.prettyLocator(e.project.configuration,ze)} conflicts with dependency ${G.prettyLocator(e.project.configuration,S)} from sibling portal ${G.prettyIdent(e.project.configuration,G.parseIdent(x.portal.name))}`})}else ce.set(He,{target:ze.reference,portal:we})}}}}let lt=e.hoistingLimitsByCwd?.get(te),Te=H?te:V.relative(p,fe.toPortablePath(ht.packageLocation))||Bt.dot,ke=e.hoistingLimitsByCwd?.get(Te);v(ne,ht,we,ue,E,Fe,Te,lt==="dependencies"||ke==="dependencies"||ke==="workspaces")}}};return v(A.name,u,A,h,u,u.packageDependencies,Bt.dot,!1),{packageTree:h,hoistingLimits:a,errors:r,preserveSymlinksRequired:o}};function EIe(t,e,r){let o=r.resolveVirtual&&e.reference&&e.reference.startsWith("virtual:")?r.resolveVirtual(t.packageLocation):t.packageLocation;return fe.toPortablePath(o||t.packageLocation)}function mIt(t,e,r){let o=e.getLocator(t.name.replace(pm,""),t.reference),a=e.getPackageInformation(o);if(a===null)throw new Error("Assertion failed: Expected the package to be registered");return r.pnpifyFs?{linkType:"SOFT",target:fe.toPortablePath(a.packageLocation)}:{linkType:a.linkType,target:EIe(a,t,e)}}var yIt=(t,e,r)=>{let o=new Map,a=(C,I,v)=>{let{linkType:b,target:E}=mIt(C,t,r);return{locator:gA(C),nodePath:I,target:E,linkType:b,aliases:v}},n=C=>{let[I,v]=C.split("/");return v?{scope:I,name:v}:{scope:null,name:I}},u=new Set,A=(C,I,v)=>{if(u.has(C))return;u.add(C);let b=Array.from(C.references).sort().join("#");for(let E of C.dependencies){let F=Array.from(E.references).sort().join("#");if(E.identName===C.identName&&F===b)continue;let N=Array.from(E.references).sort(),U={name:E.identName,reference:N[0]},{name:z,scope:te}=n(E.name),le=te?[te,z]:[z],pe=V.join(I,mIe),ue=V.join(pe,...le),ye=`${v}/${U.name}`,ae=a(U,v,N.slice(1)),Ie=!1;if(ae.linkType==="SOFT"&&r.project){let g=r.project.workspacesByCwd.get(ae.target.slice(0,-1));Ie=!!(g&&!g.manifest.name)}let Fe=ae.linkType==="SOFT"&&ue.startsWith(ae.target);if(!E.name.endsWith(pm)&&!Ie&&!Fe){let g=o.get(ue);if(g){if(g.dirList)throw new Error(`Assertion failed: ${ue} cannot merge dir node with leaf node`);{let ce=G.parseLocator(g.locator),ne=G.parseLocator(ae.locator);if(g.linkType!==ae.linkType)throw new Error(`Assertion failed: ${ue} cannot merge nodes with different link types ${g.nodePath}/${G.stringifyLocator(ce)} and ${v}/${G.stringifyLocator(ne)}`);if(ce.identHash!==ne.identHash)throw new Error(`Assertion failed: ${ue} cannot merge nodes with different idents ${g.nodePath}/${G.stringifyLocator(ce)} and ${v}/s${G.stringifyLocator(ne)}`);ae.aliases=[...ae.aliases,...g.aliases,G.parseLocator(g.locator).reference]}}o.set(ue,ae);let Ee=ue.split("/"),De=Ee.indexOf(mIe);for(let ce=Ee.length-1;De>=0&&ce>De;ce--){let ne=fe.toPortablePath(Ee.slice(0,ce).join(V.sep)),ee=Ee[ce],we=o.get(ne);if(!we)o.set(ne,{dirList:new Set([ee])});else if(we.dirList){if(we.dirList.has(ee))break;we.dirList.add(ee)}}}A(E,ae.linkType==="SOFT"?ae.target:ue,ye)}},p=a({name:e.name,reference:Array.from(e.references)[0]},"",[]),h=p.target;return o.set(h,p),A(e,h,""),o};Ye();Ye();Pt();Pt();nA();Ll();var aq={};Vt(aq,{PnpInstaller:()=>dm,PnpLinker:()=>D0,UnplugCommand:()=>S0,default:()=>VIt,getPnpPath:()=>P0,jsInstallUtils:()=>mA,pnpUtils:()=>av,quotePathIfNeeded:()=>n1e});Pt();var r1e=Be("url");Ye();Ye();Pt();Pt();var CIe={["DEFAULT"]:{collapsed:!1,next:{["*"]:"DEFAULT"}},["TOP_LEVEL"]:{collapsed:!1,next:{fallbackExclusionList:"FALLBACK_EXCLUSION_LIST",packageRegistryData:"PACKAGE_REGISTRY_DATA",["*"]:"DEFAULT"}},["FALLBACK_EXCLUSION_LIST"]:{collapsed:!1,next:{["*"]:"FALLBACK_EXCLUSION_ENTRIES"}},["FALLBACK_EXCLUSION_ENTRIES"]:{collapsed:!0,next:{["*"]:"FALLBACK_EXCLUSION_DATA"}},["FALLBACK_EXCLUSION_DATA"]:{collapsed:!0,next:{["*"]:"DEFAULT"}},["PACKAGE_REGISTRY_DATA"]:{collapsed:!1,next:{["*"]:"PACKAGE_REGISTRY_ENTRIES"}},["PACKAGE_REGISTRY_ENTRIES"]:{collapsed:!0,next:{["*"]:"PACKAGE_STORE_DATA"}},["PACKAGE_STORE_DATA"]:{collapsed:!1,next:{["*"]:"PACKAGE_STORE_ENTRIES"}},["PACKAGE_STORE_ENTRIES"]:{collapsed:!0,next:{["*"]:"PACKAGE_INFORMATION_DATA"}},["PACKAGE_INFORMATION_DATA"]:{collapsed:!1,next:{packageDependencies:"PACKAGE_DEPENDENCIES",["*"]:"DEFAULT"}},["PACKAGE_DEPENDENCIES"]:{collapsed:!1,next:{["*"]:"PACKAGE_DEPENDENCY"}},["PACKAGE_DEPENDENCY"]:{collapsed:!0,next:{["*"]:"DEFAULT"}}};function EIt(t,e,r){let o="";o+="[";for(let a=0,n=t.length;a<n;++a)o+=EQ(String(a),t[a],e,r).replace(/^ +/g,""),a+1<n&&(o+=", ");return o+="]",o}function CIt(t,e,r){let o=`${r} `,a="";a+=r,a+=`[ -`;for(let n=0,u=t.length;n<u;++n)a+=o+EQ(String(n),t[n],e,o).replace(/^ +/,""),n+1<u&&(a+=","),a+=` -`;return a+=r,a+="]",a}function wIt(t,e,r){let o=Object.keys(t),a="";a+="{";for(let n=0,u=o.length,A=0;n<u;++n){let p=o[n],h=t[p];typeof h>"u"||(A!==0&&(a+=", "),a+=JSON.stringify(p),a+=": ",a+=EQ(p,h,e,r).replace(/^ +/g,""),A+=1)}return a+="}",a}function IIt(t,e,r){let o=Object.keys(t),a=`${r} `,n="";n+=r,n+=`{ -`;let u=0;for(let A=0,p=o.length;A<p;++A){let h=o[A],C=t[h];typeof C>"u"||(u!==0&&(n+=",",n+=` -`),n+=a,n+=JSON.stringify(h),n+=": ",n+=EQ(h,C,e,a).replace(/^ +/g,""),u+=1)}return u!==0&&(n+=` -`),n+=r,n+="}",n}function EQ(t,e,r,o){let{next:a}=CIe[r],n=a[t]||a["*"];return wIe(e,n,o)}function wIe(t,e,r){let{collapsed:o}=CIe[e];return Array.isArray(t)?o?EIt(t,e,r):CIt(t,e,r):typeof t=="object"&&t!==null?o?wIt(t,e,r):IIt(t,e,r):JSON.stringify(t)}function IIe(t){return wIe(t,"TOP_LEVEL","")}function JB(t,e){let r=Array.from(t);Array.isArray(e)||(e=[e]);let o=[];for(let n of e)o.push(r.map(u=>n(u)));let a=r.map((n,u)=>u);return a.sort((n,u)=>{for(let A of o){let p=A[n]<A[u]?-1:A[n]>A[u]?1:0;if(p!==0)return p}return 0}),a.map(n=>r[n])}function BIt(t){let e=new Map,r=JB(t.fallbackExclusionList||[],[({name:o,reference:a})=>o,({name:o,reference:a})=>a]);for(let{name:o,reference:a}of r){let n=e.get(o);typeof n>"u"&&e.set(o,n=new Set),n.add(a)}return Array.from(e).map(([o,a])=>[o,Array.from(a)])}function vIt(t){return JB(t.fallbackPool||[],([e])=>e)}function DIt(t){let e=[];for(let[r,o]of JB(t.packageRegistry,([a])=>a===null?"0":`1${a}`)){let a=[];e.push([r,a]);for(let[n,{packageLocation:u,packageDependencies:A,packagePeers:p,linkType:h,discardFromLookup:C}]of JB(o,([I])=>I===null?"0":`1${I}`)){let I=[];r!==null&&n!==null&&!A.has(r)&&I.push([r,n]);for(let[E,F]of JB(A.entries(),([N])=>N))I.push([E,F]);let v=p&&p.size>0?Array.from(p):void 0,b=C||void 0;a.push([n,{packageLocation:u,packageDependencies:I,packagePeers:v,linkType:h,discardFromLookup:b}])}}return e}function XB(t){return{__info:["This file is automatically generated. Do not touch it, or risk","your modifications being lost."],dependencyTreeRoots:t.dependencyTreeRoots,enableTopLevelFallback:t.enableTopLevelFallback||!1,ignorePatternData:t.ignorePattern||null,fallbackExclusionList:BIt(t),fallbackPool:vIt(t),packageRegistryData:DIt(t)}}var DIe=$e(vIe());function PIe(t,e){return[t?`${t} -`:"",`/* eslint-disable */ -`,`"use strict"; -`,` -`,e,` -`,(0,DIe.default)()].join("")}function PIt(t){return JSON.stringify(t,null,2)}function SIt(t){return`'${t.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/\n/g,`\\ -`)}'`}function xIt(t){return[`const RAW_RUNTIME_STATE = -`,`${SIt(IIe(t))}; - -`,`function $$SETUP_STATE(hydrateRuntimeState, basePath) { -`,` return hydrateRuntimeState(JSON.parse(RAW_RUNTIME_STATE), {basePath: basePath || __dirname}); -`,`} -`].join("")}function bIt(){return[`function $$SETUP_STATE(hydrateRuntimeState, basePath) { -`,` const fs = require('fs'); -`,` const path = require('path'); -`,` const pnpDataFilepath = path.resolve(__dirname, ${JSON.stringify(dr.pnpData)}); -`,` return hydrateRuntimeState(JSON.parse(fs.readFileSync(pnpDataFilepath, 'utf8')), {basePath: basePath || __dirname}); -`,`} -`].join("")}function SIe(t){let e=XB(t),r=xIt(e);return PIe(t.shebang,r)}function xIe(t){let e=XB(t),r=bIt(),o=PIe(t.shebang,r);return{dataFile:PIt(e),loaderFile:o}}Pt();function qj(t,{basePath:e}){let r=fe.toPortablePath(e),o=V.resolve(r),a=t.ignorePatternData!==null?new RegExp(t.ignorePatternData):null,n=new Map,u=new Map(t.packageRegistryData.map(([I,v])=>[I,new Map(v.map(([b,E])=>{if(I===null!=(b===null))throw new Error("Assertion failed: The name and reference should be null, or neither should");let F=E.discardFromLookup??!1,N={name:I,reference:b},U=n.get(E.packageLocation);U?(U.discardFromLookup=U.discardFromLookup&&F,F||(U.locator=N)):n.set(E.packageLocation,{locator:N,discardFromLookup:F});let z=null;return[b,{packageDependencies:new Map(E.packageDependencies),packagePeers:new Set(E.packagePeers),linkType:E.linkType,discardFromLookup:F,get packageLocation(){return z||(z=V.join(o,E.packageLocation))}}]}))])),A=new Map(t.fallbackExclusionList.map(([I,v])=>[I,new Set(v)])),p=new Map(t.fallbackPool),h=t.dependencyTreeRoots,C=t.enableTopLevelFallback;return{basePath:r,dependencyTreeRoots:h,enableTopLevelFallback:C,fallbackExclusionList:A,fallbackPool:p,ignorePattern:a,packageLocatorsByLocations:n,packageRegistry:u}}Pt();Pt();var tp=Be("module"),gm=Be("url"),eq=Be("util");var Mo=Be("url");var FIe=$e(Be("assert"));var Gj=Array.isArray,ZB=JSON.stringify,$B=Object.getOwnPropertyNames,hm=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),Yj=(t,e)=>RegExp.prototype.exec.call(t,e),Wj=(t,...e)=>RegExp.prototype[Symbol.replace].apply(t,e),B0=(t,...e)=>String.prototype.endsWith.apply(t,e),Kj=(t,...e)=>String.prototype.includes.apply(t,e),Vj=(t,...e)=>String.prototype.lastIndexOf.apply(t,e),ev=(t,...e)=>String.prototype.indexOf.apply(t,e),bIe=(t,...e)=>String.prototype.replace.apply(t,e),v0=(t,...e)=>String.prototype.slice.apply(t,e),dA=(t,...e)=>String.prototype.startsWith.apply(t,e),kIe=Map,QIe=JSON.parse;function tv(t,e,r){return class extends r{constructor(...o){super(e(...o)),this.code=t,this.name=`${r.name} [${t}]`}}}var RIe=tv("ERR_PACKAGE_IMPORT_NOT_DEFINED",(t,e,r)=>`Package import specifier "${t}" is not defined${e?` in package ${e}package.json`:""} imported from ${r}`,TypeError),zj=tv("ERR_INVALID_MODULE_SPECIFIER",(t,e,r=void 0)=>`Invalid module "${t}" ${e}${r?` imported from ${r}`:""}`,TypeError),TIe=tv("ERR_INVALID_PACKAGE_TARGET",(t,e,r,o=!1,a=void 0)=>{let n=typeof r=="string"&&!o&&r.length&&!dA(r,"./");return e==="."?((0,FIe.default)(o===!1),`Invalid "exports" main target ${ZB(r)} defined in the package config ${t}package.json${a?` imported from ${a}`:""}${n?'; targets must start with "./"':""}`):`Invalid "${o?"imports":"exports"}" target ${ZB(r)} defined for '${e}' in the package config ${t}package.json${a?` imported from ${a}`:""}${n?'; targets must start with "./"':""}`},Error),rv=tv("ERR_INVALID_PACKAGE_CONFIG",(t,e,r)=>`Invalid package config ${t}${e?` while importing ${e}`:""}${r?`. ${r}`:""}`,Error),LIe=tv("ERR_PACKAGE_PATH_NOT_EXPORTED",(t,e,r=void 0)=>e==="."?`No "exports" main defined in ${t}package.json${r?` imported from ${r}`:""}`:`Package subpath '${e}' is not defined by "exports" in ${t}package.json${r?` imported from ${r}`:""}`,Error);var wQ=Be("url");function NIe(t,e){let r=Object.create(null);for(let o=0;o<e.length;o++){let a=e[o];hm(t,a)&&(r[a]=t[a])}return r}var CQ=new kIe;function kIt(t,e,r,o){let a=CQ.get(t);if(a!==void 0)return a;let n=o(t);if(n===void 0){let b={pjsonPath:t,exists:!1,main:void 0,name:void 0,type:"none",exports:void 0,imports:void 0};return CQ.set(t,b),b}let u;try{u=QIe(n)}catch(b){throw new rv(t,(r?`"${e}" from `:"")+(0,wQ.fileURLToPath)(r||e),b.message)}let{imports:A,main:p,name:h,type:C}=NIe(u,["imports","main","name","type"]),I=hm(u,"exports")?u.exports:void 0;(typeof A!="object"||A===null)&&(A=void 0),typeof p!="string"&&(p=void 0),typeof h!="string"&&(h=void 0),C!=="module"&&C!=="commonjs"&&(C="none");let v={pjsonPath:t,exists:!0,main:p,name:h,type:C,exports:I,imports:A};return CQ.set(t,v),v}function OIe(t,e){let r=new URL("./package.json",t);for(;;){let n=r.pathname;if(B0(n,"node_modules/package.json"))break;let u=kIt((0,wQ.fileURLToPath)(r),t,void 0,e);if(u.exists)return u;let A=r;if(r=new URL("../package.json",r),r.pathname===A.pathname)break}let o=(0,wQ.fileURLToPath)(r),a={pjsonPath:o,exists:!1,main:void 0,name:void 0,type:"none",exports:void 0,imports:void 0};return CQ.set(o,a),a}function QIt(t,e,r){throw new RIe(t,e&&(0,Mo.fileURLToPath)(new URL(".",e)),(0,Mo.fileURLToPath)(r))}function FIt(t,e,r,o){let a=`request is not a valid subpath for the "${r?"imports":"exports"}" resolution of ${(0,Mo.fileURLToPath)(e)}`;throw new zj(t,a,o&&(0,Mo.fileURLToPath)(o))}function nv(t,e,r,o,a){throw typeof e=="object"&&e!==null?e=ZB(e,null,""):e=`${e}`,new TIe((0,Mo.fileURLToPath)(new URL(".",r)),t,e,o,a&&(0,Mo.fileURLToPath)(a))}var MIe=/(^|\\|\/)((\.|%2e)(\.|%2e)?|(n|%6e|%4e)(o|%6f|%4f)(d|%64|%44)(e|%65|%45)(_|%5f)(m|%6d|%4d)(o|%6f|%4f)(d|%64|%44)(u|%75|%55)(l|%6c|%4c)(e|%65|%45)(s|%73|%53))(\\|\/|$)/i,UIe=/\*/g;function RIt(t,e,r,o,a,n,u,A){if(e!==""&&!n&&t[t.length-1]!=="/"&&nv(r,t,o,u,a),!dA(t,"./")){if(u&&!dA(t,"../")&&!dA(t,"/")){let I=!1;try{new URL(t),I=!0}catch{}if(!I)return n?Wj(UIe,t,()=>e):t+e}nv(r,t,o,u,a)}Yj(MIe,v0(t,2))!==null&&nv(r,t,o,u,a);let p=new URL(t,o),h=p.pathname,C=new URL(".",o).pathname;if(dA(h,C)||nv(r,t,o,u,a),e==="")return p;if(Yj(MIe,e)!==null){let I=n?bIe(r,"*",()=>e):r+e;FIt(I,o,u,a)}return n?new URL(Wj(UIe,p.href,()=>e)):new URL(e,p)}function TIt(t){let e=+t;return`${e}`!==t?!1:e>=0&&e<4294967295}function qC(t,e,r,o,a,n,u,A){if(typeof e=="string")return RIt(e,r,o,t,a,n,u,A);if(Gj(e)){if(e.length===0)return null;let p;for(let h=0;h<e.length;h++){let C=e[h],I;try{I=qC(t,C,r,o,a,n,u,A)}catch(v){if(p=v,v.code==="ERR_INVALID_PACKAGE_TARGET")continue;throw v}if(I!==void 0){if(I===null){p=null;continue}return I}}if(p==null)return p;throw p}else if(typeof e=="object"&&e!==null){let p=$B(e);for(let h=0;h<p.length;h++){let C=p[h];if(TIt(C))throw new rv((0,Mo.fileURLToPath)(t),a,'"exports" cannot contain numeric property keys.')}for(let h=0;h<p.length;h++){let C=p[h];if(C==="default"||A.has(C)){let I=e[C],v=qC(t,I,r,o,a,n,u,A);if(v===void 0)continue;return v}}return}else if(e===null)return null;nv(o,e,t,u,a)}function HIe(t,e){let r=ev(t,"*"),o=ev(e,"*"),a=r===-1?t.length:r+1,n=o===-1?e.length:o+1;return a>n?-1:n>a||r===-1?1:o===-1||t.length>e.length?-1:e.length>t.length?1:0}function LIt(t,e,r){if(typeof t=="string"||Gj(t))return!0;if(typeof t!="object"||t===null)return!1;let o=$B(t),a=!1,n=0;for(let u=0;u<o.length;u++){let A=o[u],p=A===""||A[0]!==".";if(n++===0)a=p;else if(a!==p)throw new rv((0,Mo.fileURLToPath)(e),r,`"exports" cannot contain some keys starting with '.' and some not. The exports object must either be an object of package subpath keys or an object of main entry condition name keys only.`)}return a}function Jj(t,e,r){throw new LIe((0,Mo.fileURLToPath)(new URL(".",e)),t,r&&(0,Mo.fileURLToPath)(r))}var _Ie=new Set;function NIt(t,e,r){let o=(0,Mo.fileURLToPath)(e);_Ie.has(o+"|"+t)||(_Ie.add(o+"|"+t),process.emitWarning(`Use of deprecated trailing slash pattern mapping "${t}" in the "exports" field module resolution of the package at ${o}${r?` imported from ${(0,Mo.fileURLToPath)(r)}`:""}. Mapping specifiers ending in "/" is no longer supported.`,"DeprecationWarning","DEP0155"))}function jIe({packageJSONUrl:t,packageSubpath:e,exports:r,base:o,conditions:a}){if(LIt(r,t,o)&&(r={".":r}),hm(r,e)&&!Kj(e,"*")&&!B0(e,"/")){let p=r[e],h=qC(t,p,"",e,o,!1,!1,a);return h==null&&Jj(e,t,o),h}let n="",u,A=$B(r);for(let p=0;p<A.length;p++){let h=A[p],C=ev(h,"*");if(C!==-1&&dA(e,v0(h,0,C))){B0(e,"/")&&NIt(e,t,o);let I=v0(h,C+1);e.length>=h.length&&B0(e,I)&&HIe(n,h)===1&&Vj(h,"*")===C&&(n=h,u=v0(e,C,e.length-I.length))}}if(n){let p=r[n],h=qC(t,p,u,n,o,!0,!1,a);return h==null&&Jj(e,t,o),h}Jj(e,t,o)}function qIe({name:t,base:e,conditions:r,readFileSyncFn:o}){if(t==="#"||dA(t,"#/")||B0(t,"/")){let u="is not a valid internal imports specifier name";throw new zj(t,u,(0,Mo.fileURLToPath)(e))}let a,n=OIe(e,o);if(n.exists){a=(0,Mo.pathToFileURL)(n.pjsonPath);let u=n.imports;if(u)if(hm(u,t)&&!Kj(t,"*")){let A=qC(a,u[t],"",t,e,!1,!0,r);if(A!=null)return A}else{let A="",p,h=$B(u);for(let C=0;C<h.length;C++){let I=h[C],v=ev(I,"*");if(v!==-1&&dA(t,v0(I,0,v))){let b=v0(I,v+1);t.length>=I.length&&B0(t,b)&&HIe(A,I)===1&&Vj(I,"*")===v&&(A=I,p=v0(t,v,t.length-b.length))}}if(A){let C=u[A],I=qC(a,C,p,A,e,!0,!0,r);if(I!=null)return I}}}QIt(t,a,e)}Pt();var OIt=new Set(["BUILTIN_NODE_RESOLUTION_FAILED","MISSING_DEPENDENCY","MISSING_PEER_DEPENDENCY","QUALIFIED_PATH_RESOLUTION_FAILED","UNDECLARED_DEPENDENCY"]);function $i(t,e,r={},o){o??=OIt.has(t)?"MODULE_NOT_FOUND":t;let a={configurable:!0,writable:!0,enumerable:!1};return Object.defineProperties(new Error(e),{code:{...a,value:o},pnpCode:{...a,value:t},data:{...a,value:r}})}function au(t){return fe.normalize(fe.fromPortablePath(t))}var KIe=$e(YIe());function VIe(t){return MIt(),Zj[t]}var Zj;function MIt(){Zj||(Zj={"--conditions":[],...WIe(UIt()),...WIe(process.execArgv)})}function WIe(t){return(0,KIe.default)({"--conditions":[String],"-C":"--conditions"},{argv:t,permissive:!0})}function UIt(){let t=[],e=_It(process.env.NODE_OPTIONS||"",t);return t.length,e}function _It(t,e){let r=[],o=!1,a=!0;for(let n=0;n<t.length;++n){let u=t[n];if(u==="\\"&&o){if(n+1===t.length)return e.push(`invalid value for NODE_OPTIONS (invalid escape) -`),r;u=t[++n]}else if(u===" "&&!o){a=!0;continue}else if(u==='"'){o=!o;continue}a?(r.push(u),a=!1):r[r.length-1]+=u}return o&&e.push(`invalid value for NODE_OPTIONS (unterminated string) -`),r}Pt();var[sv,$j]=process.versions.node.split(".").map(t=>parseInt(t,10)),zIe=sv>19||sv===19&&$j>=2||sv===18&&$j>=13,CJt=sv>19||sv===19&&$j>=3;function JIe(t){if(process.env.WATCH_REPORT_DEPENDENCIES&&process.send)if(t=t.map(e=>fe.fromPortablePath(mi.resolveVirtual(fe.toPortablePath(e)))),zIe)process.send({"watch:require":t});else for(let e of t)process.send({"watch:require":e})}function tq(t,e){let r=Number(process.env.PNP_ALWAYS_WARN_ON_FALLBACK)>0,o=Number(process.env.PNP_DEBUG_LEVEL),a=/^(?![a-zA-Z]:[\\/]|\\\\|\.{0,2}(?:\/|$))((?:node:)?(?:@[^/]+\/)?[^/]+)\/*(.*|)$/,n=/^(\/|\.{1,2}(\/|$))/,u=/\/$/,A=/^\.{0,2}\//,p={name:null,reference:null},h=[],C=new Set;if(t.enableTopLevelFallback===!0&&h.push(p),e.compatibilityMode!==!1)for(let Te of["react-scripts","gatsby"]){let ke=t.packageRegistry.get(Te);if(ke)for(let xe of ke.keys()){if(xe===null)throw new Error("Assertion failed: This reference shouldn't be null");h.push({name:Te,reference:xe})}}let{ignorePattern:I,packageRegistry:v,packageLocatorsByLocations:b}=t;function E(Te,ke){return{fn:Te,args:ke,error:null,result:null}}function F(Te){let ke=process.stderr?.hasColors?.()??process.stdout.isTTY,xe=(ze,je)=>`\x1B[${ze}m${je}\x1B[0m`,He=Te.error;console.error(He?xe("31;1",`\u2716 ${Te.error?.message.replace(/\n.*/s,"")}`):xe("33;1","\u203C Resolution")),Te.args.length>0&&console.error();for(let ze of Te.args)console.error(` ${xe("37;1","In \u2190")} ${(0,eq.inspect)(ze,{colors:ke,compact:!0})}`);Te.result&&(console.error(),console.error(` ${xe("37;1","Out \u2192")} ${(0,eq.inspect)(Te.result,{colors:ke,compact:!0})}`));let Re=new Error().stack.match(/(?<=^ +)at.*/gm)?.slice(2)??[];if(Re.length>0){console.error();for(let ze of Re)console.error(` ${xe("38;5;244",ze)}`)}console.error()}function N(Te,ke){if(e.allowDebug===!1)return ke;if(Number.isFinite(o)){if(o>=2)return(...xe)=>{let He=E(Te,xe);try{return He.result=ke(...xe)}catch(Re){throw He.error=Re}finally{F(He)}};if(o>=1)return(...xe)=>{try{return ke(...xe)}catch(He){let Re=E(Te,xe);throw Re.error=He,F(Re),He}}}return ke}function U(Te){let ke=g(Te);if(!ke)throw $i("INTERNAL","Couldn't find a matching entry in the dependency tree for the specified parent (this is probably an internal error)");return ke}function z(Te){if(Te.name===null)return!0;for(let ke of t.dependencyTreeRoots)if(ke.name===Te.name&&ke.reference===Te.reference)return!0;return!1}let te=new Set(["node","require",...VIe("--conditions")]);function le(Te,ke=te,xe){let He=ce(V.join(Te,"internal.js"),{resolveIgnored:!0,includeDiscardFromLookup:!0});if(He===null)throw $i("INTERNAL",`The locator that owns the "${Te}" path can't be found inside the dependency tree (this is probably an internal error)`);let{packageLocation:Re}=U(He),ze=V.join(Re,dr.manifest);if(!e.fakeFs.existsSync(ze))return null;let je=JSON.parse(e.fakeFs.readFileSync(ze,"utf8"));if(je.exports==null)return null;let x=V.contains(Re,Te);if(x===null)throw $i("INTERNAL","unqualifiedPath doesn't contain the packageLocation (this is probably an internal error)");x!=="."&&!A.test(x)&&(x=`./${x}`);try{let w=jIe({packageJSONUrl:(0,gm.pathToFileURL)(fe.fromPortablePath(ze)),packageSubpath:x,exports:je.exports,base:xe?(0,gm.pathToFileURL)(fe.fromPortablePath(xe)):null,conditions:ke});return fe.toPortablePath((0,gm.fileURLToPath)(w))}catch(w){throw $i("EXPORTS_RESOLUTION_FAILED",w.message,{unqualifiedPath:au(Te),locator:He,pkgJson:je,subpath:au(x),conditions:ke},w.code)}}function pe(Te,ke,{extensions:xe}){let He;try{ke.push(Te),He=e.fakeFs.statSync(Te)}catch{}if(He&&!He.isDirectory())return e.fakeFs.realpathSync(Te);if(He&&He.isDirectory()){let Re;try{Re=JSON.parse(e.fakeFs.readFileSync(V.join(Te,dr.manifest),"utf8"))}catch{}let ze;if(Re&&Re.main&&(ze=V.resolve(Te,Re.main)),ze&&ze!==Te){let je=pe(ze,ke,{extensions:xe});if(je!==null)return je}}for(let Re=0,ze=xe.length;Re<ze;Re++){let je=`${Te}${xe[Re]}`;if(ke.push(je),e.fakeFs.existsSync(je))return je}if(He&&He.isDirectory())for(let Re=0,ze=xe.length;Re<ze;Re++){let je=V.format({dir:Te,name:"index",ext:xe[Re]});if(ke.push(je),e.fakeFs.existsSync(je))return je}return null}function ue(Te){let ke=new tp.Module(Te,null);return ke.filename=Te,ke.paths=tp.Module._nodeModulePaths(Te),ke}function ye(Te,ke){return ke.endsWith("/")&&(ke=V.join(ke,"internal.js")),tp.Module._resolveFilename(fe.fromPortablePath(Te),ue(fe.fromPortablePath(ke)),!1,{plugnplay:!1})}function ae(Te){if(I===null)return!1;let ke=V.contains(t.basePath,Te);return ke===null?!1:!!I.test(ke.replace(/\/$/,""))}let Ie={std:3,resolveVirtual:1,getAllLocators:1},Fe=p;function g({name:Te,reference:ke}){let xe=v.get(Te);if(!xe)return null;let He=xe.get(ke);return He||null}function Ee({name:Te,reference:ke}){let xe=[];for(let[He,Re]of v)if(He!==null)for(let[ze,je]of Re)ze===null||je.packageDependencies.get(Te)!==ke||He===Te&&ze===ke||xe.push({name:He,reference:ze});return xe}function De(Te,ke){let xe=new Map,He=new Set,Re=je=>{let x=JSON.stringify(je.name);if(He.has(x))return;He.add(x);let w=Ee(je);for(let S of w)if(U(S).packagePeers.has(Te))Re(S);else{let R=xe.get(S.name);typeof R>"u"&&xe.set(S.name,R=new Set),R.add(S.reference)}};Re(ke);let ze=[];for(let je of[...xe.keys()].sort())for(let x of[...xe.get(je)].sort())ze.push({name:je,reference:x});return ze}function ce(Te,{resolveIgnored:ke=!1,includeDiscardFromLookup:xe=!1}={}){if(ae(Te)&&!ke)return null;let He=V.relative(t.basePath,Te);He.match(n)||(He=`./${He}`),He.endsWith("/")||(He=`${He}/`);do{let Re=b.get(He);if(typeof Re>"u"||Re.discardFromLookup&&!xe){He=He.substring(0,He.lastIndexOf("/",He.length-2)+1);continue}return Re.locator}while(He!=="");return null}function ne(Te){try{return e.fakeFs.readFileSync(fe.toPortablePath(Te),"utf8")}catch(ke){if(ke.code==="ENOENT")return;throw ke}}function ee(Te,ke,{considerBuiltins:xe=!0}={}){if(Te.startsWith("#"))throw new Error("resolveToUnqualified can not handle private import mappings");if(Te==="pnpapi")return fe.toPortablePath(e.pnpapiResolution);if(xe&&(0,tp.isBuiltin)(Te))return null;let He=au(Te),Re=ke&&au(ke);if(ke&&ae(ke)&&(!V.isAbsolute(Te)||ce(Te)===null)){let x=ye(Te,ke);if(x===!1)throw $i("BUILTIN_NODE_RESOLUTION_FAILED",`The builtin node resolution algorithm was unable to resolve the requested module (it didn't go through the pnp resolver because the issuer was explicitely ignored by the regexp) - -Require request: "${He}" -Required by: ${Re} -`,{request:He,issuer:Re});return fe.toPortablePath(x)}let ze,je=Te.match(a);if(je){if(!ke)throw $i("API_ERROR","The resolveToUnqualified function must be called with a valid issuer when the path isn't a builtin nor absolute",{request:He,issuer:Re});let[,x,w]=je,S=ce(ke);if(!S){let Le=ye(Te,ke);if(Le===!1)throw $i("BUILTIN_NODE_RESOLUTION_FAILED",`The builtin node resolution algorithm was unable to resolve the requested module (it didn't go through the pnp resolver because the issuer doesn't seem to be part of the Yarn-managed dependency tree). - -Require path: "${He}" -Required by: ${Re} -`,{request:He,issuer:Re});return fe.toPortablePath(Le)}let R=U(S).packageDependencies.get(x),J=null;if(R==null&&S.name!==null){let Le=t.fallbackExclusionList.get(S.name);if(!Le||!Le.has(S.reference)){for(let dt=0,jt=h.length;dt<jt;++dt){let xt=U(h[dt]).packageDependencies.get(x);if(xt!=null){r?J=xt:R=xt;break}}if(t.enableTopLevelFallback&&R==null&&J===null){let dt=t.fallbackPool.get(x);dt!=null&&(J=dt)}}}let X=null;if(R===null)if(z(S))X=$i("MISSING_PEER_DEPENDENCY",`Your application tried to access ${x} (a peer dependency); this isn't allowed as there is no ancestor to satisfy the requirement. Use a devDependency if needed. - -Required package: ${x}${x!==He?` (via "${He}")`:""} -Required by: ${Re} -`,{request:He,issuer:Re,dependencyName:x});else{let Le=De(x,S);Le.every(ot=>z(ot))?X=$i("MISSING_PEER_DEPENDENCY",`${S.name} tried to access ${x} (a peer dependency) but it isn't provided by your application; this makes the require call ambiguous and unsound. - -Required package: ${x}${x!==He?` (via "${He}")`:""} -Required by: ${S.name}@${S.reference} (via ${Re}) -${Le.map(ot=>`Ancestor breaking the chain: ${ot.name}@${ot.reference} -`).join("")} -`,{request:He,issuer:Re,issuerLocator:Object.assign({},S),dependencyName:x,brokenAncestors:Le}):X=$i("MISSING_PEER_DEPENDENCY",`${S.name} tried to access ${x} (a peer dependency) but it isn't provided by its ancestors; this makes the require call ambiguous and unsound. - -Required package: ${x}${x!==He?` (via "${He}")`:""} -Required by: ${S.name}@${S.reference} (via ${Re}) - -${Le.map(ot=>`Ancestor breaking the chain: ${ot.name}@${ot.reference} -`).join("")} -`,{request:He,issuer:Re,issuerLocator:Object.assign({},S),dependencyName:x,brokenAncestors:Le})}else R===void 0&&(!xe&&(0,tp.isBuiltin)(Te)?z(S)?X=$i("UNDECLARED_DEPENDENCY",`Your application tried to access ${x}. While this module is usually interpreted as a Node builtin, your resolver is running inside a non-Node resolution context where such builtins are ignored. Since ${x} isn't otherwise declared in your dependencies, this makes the require call ambiguous and unsound. - -Required package: ${x}${x!==He?` (via "${He}")`:""} -Required by: ${Re} -`,{request:He,issuer:Re,dependencyName:x}):X=$i("UNDECLARED_DEPENDENCY",`${S.name} tried to access ${x}. While this module is usually interpreted as a Node builtin, your resolver is running inside a non-Node resolution context where such builtins are ignored. Since ${x} isn't otherwise declared in ${S.name}'s dependencies, this makes the require call ambiguous and unsound. - -Required package: ${x}${x!==He?` (via "${He}")`:""} -Required by: ${Re} -`,{request:He,issuer:Re,issuerLocator:Object.assign({},S),dependencyName:x}):z(S)?X=$i("UNDECLARED_DEPENDENCY",`Your application tried to access ${x}, but it isn't declared in your dependencies; this makes the require call ambiguous and unsound. - -Required package: ${x}${x!==He?` (via "${He}")`:""} -Required by: ${Re} -`,{request:He,issuer:Re,dependencyName:x}):X=$i("UNDECLARED_DEPENDENCY",`${S.name} tried to access ${x}, but it isn't declared in its dependencies; this makes the require call ambiguous and unsound. - -Required package: ${x}${x!==He?` (via "${He}")`:""} -Required by: ${S.name}@${S.reference} (via ${Re}) -`,{request:He,issuer:Re,issuerLocator:Object.assign({},S),dependencyName:x}));if(R==null){if(J===null||X===null)throw X||new Error("Assertion failed: Expected an error to have been set");R=J;let Le=X.message.replace(/\n.*/g,"");X.message=Le,!C.has(Le)&&o!==0&&(C.add(Le),process.emitWarning(X))}let Z=Array.isArray(R)?{name:R[0],reference:R[1]}:{name:x,reference:R},ie=U(Z);if(!ie.packageLocation)throw $i("MISSING_DEPENDENCY",`A dependency seems valid but didn't get installed for some reason. This might be caused by a partial install, such as dev vs prod. - -Required package: ${Z.name}@${Z.reference}${Z.name!==He?` (via "${He}")`:""} -Required by: ${S.name}@${S.reference} (via ${Re}) -`,{request:He,issuer:Re,dependencyLocator:Object.assign({},Z)});let Pe=ie.packageLocation;w?ze=V.join(Pe,w):ze=Pe}else if(V.isAbsolute(Te))ze=V.normalize(Te);else{if(!ke)throw $i("API_ERROR","The resolveToUnqualified function must be called with a valid issuer when the path isn't a builtin nor absolute",{request:He,issuer:Re});let x=V.resolve(ke);ke.match(u)?ze=V.normalize(V.join(x,Te)):ze=V.normalize(V.join(V.dirname(x),Te))}return V.normalize(ze)}function we(Te,ke,xe=te,He){if(n.test(Te))return ke;let Re=le(ke,xe,He);return Re?V.normalize(Re):ke}function be(Te,{extensions:ke=Object.keys(tp.Module._extensions)}={}){let xe=[],He=pe(Te,xe,{extensions:ke});if(He)return V.normalize(He);{JIe(xe.map(je=>fe.fromPortablePath(je)));let Re=au(Te),ze=ce(Te);if(ze){let{packageLocation:je}=U(ze),x=!0;try{e.fakeFs.accessSync(je)}catch(w){if(w?.code==="ENOENT")x=!1;else{let S=(w?.message??w??"empty exception thrown").replace(/^[A-Z]/,y=>y.toLowerCase());throw $i("QUALIFIED_PATH_RESOLUTION_FAILED",`Required package exists but could not be accessed (${S}). - -Missing package: ${ze.name}@${ze.reference} -Expected package location: ${au(je)} -`,{unqualifiedPath:Re,extensions:ke})}}if(!x){let w=je.includes("/unplugged/")?"Required unplugged package missing from disk. This may happen when switching branches without running installs (unplugged packages must be fully materialized on disk to work).":"Required package missing from disk. If you keep your packages inside your repository then restarting the Node process may be enough. Otherwise, try to run an install first.";throw $i("QUALIFIED_PATH_RESOLUTION_FAILED",`${w} - -Missing package: ${ze.name}@${ze.reference} -Expected package location: ${au(je)} -`,{unqualifiedPath:Re,extensions:ke})}}throw $i("QUALIFIED_PATH_RESOLUTION_FAILED",`Qualified path resolution failed: we looked for the following paths, but none could be accessed. - -Source path: ${Re} -${xe.map(je=>`Not found: ${au(je)} -`).join("")}`,{unqualifiedPath:Re,extensions:ke})}}function ht(Te,ke,xe){if(!ke)throw new Error("Assertion failed: An issuer is required to resolve private import mappings");let He=qIe({name:Te,base:(0,gm.pathToFileURL)(fe.fromPortablePath(ke)),conditions:xe.conditions??te,readFileSyncFn:ne});if(He instanceof URL)return be(fe.toPortablePath((0,gm.fileURLToPath)(He)),{extensions:xe.extensions});if(He.startsWith("#"))throw new Error("Mapping from one private import to another isn't allowed");return H(He,ke,xe)}function H(Te,ke,xe={}){try{if(Te.startsWith("#"))return ht(Te,ke,xe);let{considerBuiltins:He,extensions:Re,conditions:ze}=xe,je=ee(Te,ke,{considerBuiltins:He});if(Te==="pnpapi")return je;if(je===null)return null;let x=()=>ke!==null?ae(ke):!1,w=(!He||!(0,tp.isBuiltin)(Te))&&!x()?we(Te,je,ze,ke):je;return be(w,{extensions:Re})}catch(He){throw Object.hasOwn(He,"pnpCode")&&Object.assign(He.data,{request:au(Te),issuer:ke&&au(ke)}),He}}function lt(Te){let ke=V.normalize(Te),xe=mi.resolveVirtual(ke);return xe!==ke?xe:null}return{VERSIONS:Ie,topLevel:Fe,getLocator:(Te,ke)=>Array.isArray(ke)?{name:ke[0],reference:ke[1]}:{name:Te,reference:ke},getDependencyTreeRoots:()=>[...t.dependencyTreeRoots],getAllLocators(){let Te=[];for(let[ke,xe]of v)for(let He of xe.keys())ke!==null&&He!==null&&Te.push({name:ke,reference:He});return Te},getPackageInformation:Te=>{let ke=g(Te);if(ke===null)return null;let xe=fe.fromPortablePath(ke.packageLocation);return{...ke,packageLocation:xe}},findPackageLocator:Te=>ce(fe.toPortablePath(Te)),resolveToUnqualified:N("resolveToUnqualified",(Te,ke,xe)=>{let He=ke!==null?fe.toPortablePath(ke):null,Re=ee(fe.toPortablePath(Te),He,xe);return Re===null?null:fe.fromPortablePath(Re)}),resolveUnqualified:N("resolveUnqualified",(Te,ke)=>fe.fromPortablePath(be(fe.toPortablePath(Te),ke))),resolveRequest:N("resolveRequest",(Te,ke,xe)=>{let He=ke!==null?fe.toPortablePath(ke):null,Re=H(fe.toPortablePath(Te),He,xe);return Re===null?null:fe.fromPortablePath(Re)}),resolveVirtual:N("resolveVirtual",Te=>{let ke=lt(fe.toPortablePath(Te));return ke!==null?fe.fromPortablePath(ke):null})}}Pt();var XIe=(t,e,r)=>{let o=XB(t),a=qj(o,{basePath:e}),n=fe.join(e,dr.pnpCjs);return tq(a,{fakeFs:r,pnpapiResolution:n})};var nq=$e($Ie());qt();var mA={};Vt(mA,{checkManifestCompatibility:()=>e1e,extractBuildRequest:()=>IQ,getExtractHint:()=>iq,hasBindingGyp:()=>sq});Ye();Pt();function e1e(t){return G.isPackageCompatible(t,zi.getArchitectureSet())}function IQ(t,e,r,{configuration:o}){let a=[];for(let n of["preinstall","install","postinstall"])e.manifest.scripts.has(n)&&a.push({type:0,script:n});return!e.manifest.scripts.has("install")&&e.misc.hasBindingGyp&&a.push({type:1,script:"node-gyp rebuild"}),a.length===0?null:t.linkType!=="HARD"?{skipped:!0,explain:n=>n.reportWarningOnce(6,`${G.prettyLocator(o,t)} lists build scripts, but is referenced through a soft link. Soft links don't support build scripts, so they'll be ignored.`)}:r&&r.built===!1?{skipped:!0,explain:n=>n.reportInfoOnce(5,`${G.prettyLocator(o,t)} lists build scripts, but its build has been explicitly disabled through configuration.`)}:!o.get("enableScripts")&&!r.built?{skipped:!0,explain:n=>n.reportWarningOnce(4,`${G.prettyLocator(o,t)} lists build scripts, but all build scripts have been disabled.`)}:e1e(t)?{skipped:!1,directives:a}:{skipped:!0,explain:n=>n.reportWarningOnce(76,`${G.prettyLocator(o,t)} The ${zi.getArchitectureName()} architecture is incompatible with this package, build skipped.`)}}var jIt=new Set([".exe",".bin",".h",".hh",".hpp",".c",".cc",".cpp",".java",".jar",".node"]);function iq(t){return t.packageFs.getExtractHint({relevantExtensions:jIt})}function sq(t){let e=V.join(t.prefixPath,"binding.gyp");return t.packageFs.existsSync(e)}var av={};Vt(av,{getUnpluggedPath:()=>ov});Ye();Pt();function ov(t,{configuration:e}){return V.resolve(e.get("pnpUnpluggedFolder"),G.slugifyLocator(t))}var qIt=new Set([G.makeIdent(null,"open").identHash,G.makeIdent(null,"opn").identHash]),D0=class{constructor(){this.mode="strict";this.pnpCache=new Map}getCustomDataKey(){return JSON.stringify({name:"PnpLinker",version:2})}supportsPackage(e,r){return this.isEnabled(r)}async findPackageLocation(e,r){if(!this.isEnabled(r))throw new Error("Assertion failed: Expected the PnP linker to be enabled");let o=P0(r.project).cjs;if(!oe.existsSync(o))throw new it(`The project in ${de.pretty(r.project.configuration,`${r.project.cwd}/package.json`,de.Type.PATH)} doesn't seem to have been installed - running an install there might help`);let a=_e.getFactoryWithDefault(this.pnpCache,o,()=>_e.dynamicRequire(o,{cachingStrategy:_e.CachingStrategy.FsTime})),n={name:G.stringifyIdent(e),reference:e.reference},u=a.getPackageInformation(n);if(!u)throw new it(`Couldn't find ${G.prettyLocator(r.project.configuration,e)} in the currently installed PnP map - running an install might help`);return fe.toPortablePath(u.packageLocation)}async findPackageLocator(e,r){if(!this.isEnabled(r))return null;let o=P0(r.project).cjs;if(!oe.existsSync(o))return null;let n=_e.getFactoryWithDefault(this.pnpCache,o,()=>_e.dynamicRequire(o,{cachingStrategy:_e.CachingStrategy.FsTime})).findPackageLocator(fe.fromPortablePath(e));return n?G.makeLocator(G.parseIdent(n.name),n.reference):null}makeInstaller(e){return new dm(e)}isEnabled(e){return!(e.project.configuration.get("nodeLinker")!=="pnp"||e.project.configuration.get("pnpMode")!==this.mode)}},dm=class{constructor(e){this.opts=e;this.mode="strict";this.asyncActions=new _e.AsyncActions(10);this.packageRegistry=new Map;this.virtualTemplates=new Map;this.isESMLoaderRequired=!1;this.customData={store:new Map};this.unpluggedPaths=new Set;this.opts=e}attachCustomData(e){this.customData=e}async installPackage(e,r,o){let a=G.stringifyIdent(e),n=e.reference,u=!!this.opts.project.tryWorkspaceByLocator(e),A=G.isVirtualLocator(e),p=e.peerDependencies.size>0&&!A,h=!p&&!u,C=!p&&e.linkType!=="SOFT",I,v;if(h||C){let te=A?G.devirtualizeLocator(e):e;I=this.customData.store.get(te.locatorHash),typeof I>"u"&&(I=await GIt(r),e.linkType==="HARD"&&this.customData.store.set(te.locatorHash,I)),I.manifest.type==="module"&&(this.isESMLoaderRequired=!0),v=this.opts.project.getDependencyMeta(te,e.version)}let b=h?IQ(e,I,v,{configuration:this.opts.project.configuration}):null,E=C?await this.unplugPackageIfNeeded(e,I,r,v,o):r.packageFs;if(V.isAbsolute(r.prefixPath))throw new Error(`Assertion failed: Expected the prefix path (${r.prefixPath}) to be relative to the parent`);let F=V.resolve(E.getRealPath(),r.prefixPath),N=oq(this.opts.project.cwd,F),U=new Map,z=new Set;if(A){for(let te of e.peerDependencies.values())U.set(G.stringifyIdent(te),null),z.add(G.stringifyIdent(te));if(!u){let te=G.devirtualizeLocator(e);this.virtualTemplates.set(te.locatorHash,{location:oq(this.opts.project.cwd,mi.resolveVirtual(F)),locator:te})}}return _e.getMapWithDefault(this.packageRegistry,a).set(n,{packageLocation:N,packageDependencies:U,packagePeers:z,linkType:e.linkType,discardFromLookup:r.discardFromLookup||!1}),{packageLocation:F,buildRequest:b}}async attachInternalDependencies(e,r){let o=this.getPackageInformation(e);for(let[a,n]of r){let u=G.areIdentsEqual(a,n)?n.reference:[G.stringifyIdent(n),n.reference];o.packageDependencies.set(G.stringifyIdent(a),u)}}async attachExternalDependents(e,r){for(let o of r)this.getDiskInformation(o).packageDependencies.set(G.stringifyIdent(e),e.reference)}async finalizeInstall(){if(this.opts.project.configuration.get("pnpMode")!==this.mode)return;let e=P0(this.opts.project);if(this.isEsmEnabled()||await oe.removePromise(e.esmLoader),this.opts.project.configuration.get("nodeLinker")!=="pnp"){await oe.removePromise(e.cjs),await oe.removePromise(e.data),await oe.removePromise(e.esmLoader),await oe.removePromise(this.opts.project.configuration.get("pnpUnpluggedFolder"));return}for(let{locator:C,location:I}of this.virtualTemplates.values())_e.getMapWithDefault(this.packageRegistry,G.stringifyIdent(C)).set(C.reference,{packageLocation:I,packageDependencies:new Map,packagePeers:new Set,linkType:"SOFT",discardFromLookup:!1});this.packageRegistry.set(null,new Map([[null,this.getPackageInformation(this.opts.project.topLevelWorkspace.anchoredLocator)]]));let r=this.opts.project.configuration.get("pnpFallbackMode"),o=this.opts.project.workspaces.map(({anchoredLocator:C})=>({name:G.stringifyIdent(C),reference:C.reference})),a=r!=="none",n=[],u=new Map,A=_e.buildIgnorePattern([".yarn/sdks/**",...this.opts.project.configuration.get("pnpIgnorePatterns")]),p=this.packageRegistry,h=this.opts.project.configuration.get("pnpShebang");if(r==="dependencies-only")for(let C of this.opts.project.storedPackages.values())this.opts.project.tryWorkspaceByLocator(C)&&n.push({name:G.stringifyIdent(C),reference:C.reference});return await this.asyncActions.wait(),await this.finalizeInstallWithPnp({dependencyTreeRoots:o,enableTopLevelFallback:a,fallbackExclusionList:n,fallbackPool:u,ignorePattern:A,packageRegistry:p,shebang:h}),{customData:this.customData}}async transformPnpSettings(e){}isEsmEnabled(){if(this.opts.project.configuration.sources.has("pnpEnableEsmLoader"))return this.opts.project.configuration.get("pnpEnableEsmLoader");if(this.isESMLoaderRequired)return!0;for(let e of this.opts.project.workspaces)if(e.manifest.type==="module")return!0;return!1}async finalizeInstallWithPnp(e){let r=P0(this.opts.project),o=await this.locateNodeModules(e.ignorePattern);if(o.length>0){this.opts.report.reportWarning(31,"One or more node_modules have been detected and will be removed. This operation may take some time.");for(let n of o)await oe.removePromise(n)}if(await this.transformPnpSettings(e),this.opts.project.configuration.get("pnpEnableInlining")){let n=SIe(e);await oe.changeFilePromise(r.cjs,n,{automaticNewlines:!0,mode:493}),await oe.removePromise(r.data)}else{let{dataFile:n,loaderFile:u}=xIe(e);await oe.changeFilePromise(r.cjs,u,{automaticNewlines:!0,mode:493}),await oe.changeFilePromise(r.data,n,{automaticNewlines:!0,mode:420})}this.isEsmEnabled()&&(this.opts.report.reportWarning(0,"ESM support for PnP uses the experimental loader API and is therefore experimental"),await oe.changeFilePromise(r.esmLoader,(0,nq.default)(),{automaticNewlines:!0,mode:420}));let a=this.opts.project.configuration.get("pnpUnpluggedFolder");if(this.unpluggedPaths.size===0)await oe.removePromise(a);else for(let n of await oe.readdirPromise(a)){let u=V.resolve(a,n);this.unpluggedPaths.has(u)||await oe.removePromise(u)}}async locateNodeModules(e){let r=[],o=e?new RegExp(e):null;for(let a of this.opts.project.workspaces){let n=V.join(a.cwd,"node_modules");if(o&&o.test(V.relative(this.opts.project.cwd,a.cwd))||!oe.existsSync(n))continue;let u=await oe.readdirPromise(n,{withFileTypes:!0}),A=u.filter(p=>!p.isDirectory()||p.name===".bin"||!p.name.startsWith("."));if(A.length===u.length)r.push(n);else for(let p of A)r.push(V.join(n,p.name))}return r}async unplugPackageIfNeeded(e,r,o,a,n){return this.shouldBeUnplugged(e,r,a)?this.unplugPackage(e,o,n):o.packageFs}shouldBeUnplugged(e,r,o){return typeof o.unplugged<"u"?o.unplugged:qIt.has(e.identHash)||e.conditions!=null?!0:r.manifest.preferUnplugged!==null?r.manifest.preferUnplugged:!!(IQ(e,r,o,{configuration:this.opts.project.configuration})?.skipped===!1||r.misc.extractHint)}async unplugPackage(e,r,o){let a=ov(e,{configuration:this.opts.project.configuration});return this.opts.project.disabledLocators.has(e.locatorHash)?new Uu(a,{baseFs:r.packageFs,pathUtils:V}):(this.unpluggedPaths.add(a),o.holdFetchResult(this.asyncActions.set(e.locatorHash,async()=>{let n=V.join(a,r.prefixPath,".ready");await oe.existsPromise(n)||(this.opts.project.storedBuildState.delete(e.locatorHash),await oe.mkdirPromise(a,{recursive:!0}),await oe.copyPromise(a,Bt.dot,{baseFs:r.packageFs,overwrite:!1}),await oe.writeFilePromise(n,""))})),new gn(a))}getPackageInformation(e){let r=G.stringifyIdent(e),o=e.reference,a=this.packageRegistry.get(r);if(!a)throw new Error(`Assertion failed: The package information store should have been available (for ${G.prettyIdent(this.opts.project.configuration,e)})`);let n=a.get(o);if(!n)throw new Error(`Assertion failed: The package information should have been available (for ${G.prettyLocator(this.opts.project.configuration,e)})`);return n}getDiskInformation(e){let r=_e.getMapWithDefault(this.packageRegistry,"@@disk"),o=oq(this.opts.project.cwd,e);return _e.getFactoryWithDefault(r,o,()=>({packageLocation:o,packageDependencies:new Map,packagePeers:new Set,linkType:"SOFT",discardFromLookup:!1}))}};function oq(t,e){let r=V.relative(t,e);return r.match(/^\.{0,2}\//)||(r=`./${r}`),r.replace(/\/?$/,"/")}async function GIt(t){let e=await Ot.tryFind(t.prefixPath,{baseFs:t.packageFs})??new Ot,r=new Set(["preinstall","install","postinstall"]);for(let o of e.scripts.keys())r.has(o)||e.scripts.delete(o);return{manifest:{scripts:e.scripts,preferUnplugged:e.preferUnplugged,type:e.type},misc:{extractHint:iq(t),hasBindingGyp:sq(t)}}}Ye();Ye();qt();var t1e=$e(Zo());var S0=class extends ut{constructor(){super(...arguments);this.all=ge.Boolean("-A,--all",!1,{description:"Unplug direct dependencies from the entire project"});this.recursive=ge.Boolean("-R,--recursive",!1,{description:"Unplug both direct and transitive dependencies"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.patterns=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd),n=await Nr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);if(r.get("nodeLinker")!=="pnp")throw new it("This command can only be used if the `nodeLinker` option is set to `pnp`");await o.restoreInstallState();let u=new Set(this.patterns),A=this.patterns.map(b=>{let E=G.parseDescriptor(b),F=E.range!=="unknown"?E:G.makeDescriptor(E,"*");if(!Qr.validRange(F.range))throw new it(`The range of the descriptor patterns must be a valid semver range (${G.prettyDescriptor(r,F)})`);return N=>{let U=G.stringifyIdent(N);return!t1e.default.isMatch(U,G.stringifyIdent(F))||N.version&&!Qr.satisfiesWithPrereleases(N.version,F.range)?!1:(u.delete(b),!0)}}),p=()=>{let b=[];for(let E of o.storedPackages.values())!o.tryWorkspaceByLocator(E)&&!G.isVirtualLocator(E)&&A.some(F=>F(E))&&b.push(E);return b},h=b=>{let E=new Set,F=[],N=(U,z)=>{if(E.has(U.locatorHash))return;let te=!!o.tryWorkspaceByLocator(U);if(!(z>0&&!this.recursive&&te)&&(E.add(U.locatorHash),!o.tryWorkspaceByLocator(U)&&A.some(le=>le(U))&&F.push(U),!(z>0&&!this.recursive)))for(let le of U.dependencies.values()){let pe=o.storedResolutions.get(le.descriptorHash);if(!pe)throw new Error("Assertion failed: The resolution should have been registered");let ue=o.storedPackages.get(pe);if(!ue)throw new Error("Assertion failed: The package should have been registered");N(ue,z+1)}};for(let U of b)N(U.anchoredPackage,0);return F},C,I;if(this.all&&this.recursive?(C=p(),I="the project"):this.all?(C=h(o.workspaces),I="any workspace"):(C=h([a]),I="this workspace"),u.size>1)throw new it(`Patterns ${de.prettyList(r,u,de.Type.CODE)} don't match any packages referenced by ${I}`);if(u.size>0)throw new it(`Pattern ${de.prettyList(r,u,de.Type.CODE)} doesn't match any packages referenced by ${I}`);C=_e.sortMap(C,b=>G.stringifyLocator(b));let v=await Lt.start({configuration:r,stdout:this.context.stdout,json:this.json},async b=>{for(let E of C){let F=E.version??"unknown",N=o.topLevelWorkspace.manifest.ensureDependencyMeta(G.makeDescriptor(E,F));N.unplugged=!0,b.reportInfo(0,`Will unpack ${G.prettyLocator(r,E)} to ${de.pretty(r,ov(E,{configuration:r}),de.Type.PATH)}`),b.reportJson({locator:G.stringifyLocator(E),version:F})}await o.topLevelWorkspace.persistManifest(),this.json||b.reportSeparator()});return v.hasErrors()?v.exitCode():await o.installWithNewReport({json:this.json,stdout:this.context.stdout},{cache:n})}};S0.paths=[["unplug"]],S0.usage=nt.Usage({description:"force the unpacking of a list of packages",details:"\n This command will add the selectors matching the specified patterns to the list of packages that must be unplugged when installed.\n\n A package being unplugged means that instead of being referenced directly through its archive, it will be unpacked at install time in the directory configured via `pnpUnpluggedFolder`. Note that unpacking packages this way is generally not recommended because it'll make it harder to store your packages within the repository. However, it's a good approach to quickly and safely debug some packages, and can even sometimes be required depending on the context (for example when the package contains shellscripts).\n\n Running the command will set a persistent flag inside your top-level `package.json`, in the `dependenciesMeta` field. As such, to undo its effects, you'll need to revert the changes made to the manifest and run `yarn install` to apply the modification.\n\n By default, only direct dependencies from the current workspace are affected. If `-A,--all` is set, direct dependencies from the entire project are affected. Using the `-R,--recursive` flag will affect transitive dependencies as well as direct ones.\n\n This command accepts glob patterns inside the scope and name components (not the range). Make sure to escape the patterns to prevent your own shell from trying to expand them.\n ",examples:[["Unplug the lodash dependency from the active workspace","yarn unplug lodash"],["Unplug all instances of lodash referenced by any workspace","yarn unplug lodash -A"],["Unplug all instances of lodash referenced by the active workspace and its dependencies","yarn unplug lodash -R"],["Unplug all instances of lodash, anywhere","yarn unplug lodash -AR"],["Unplug one specific version of lodash","yarn unplug lodash@1.2.3"],["Unplug all packages with the `@babel` scope","yarn unplug '@babel/*'"],["Unplug all packages (only for testing, not recommended)","yarn unplug -R '*'"]]});var P0=t=>({cjs:V.join(t.cwd,dr.pnpCjs),data:V.join(t.cwd,dr.pnpData),esmLoader:V.join(t.cwd,dr.pnpEsmLoader)}),n1e=t=>/\s/.test(t)?JSON.stringify(t):t;async function YIt(t,e,r){let o=/\s*--require\s+\S*\.pnp\.c?js\s*/g,a=/\s*--experimental-loader\s+\S*\.pnp\.loader\.mjs\s*/,n=(e.NODE_OPTIONS??"").replace(o," ").replace(a," ").trim();if(t.configuration.get("nodeLinker")!=="pnp"){e.NODE_OPTIONS=n;return}let u=P0(t),A=`--require ${n1e(fe.fromPortablePath(u.cjs))}`;oe.existsSync(u.esmLoader)&&(A=`${A} --experimental-loader ${(0,r1e.pathToFileURL)(fe.fromPortablePath(u.esmLoader)).href}`),oe.existsSync(u.cjs)&&(e.NODE_OPTIONS=n?`${A} ${n}`:A)}async function WIt(t,e){let r=P0(t);e(r.cjs),e(r.data),e(r.esmLoader),e(t.configuration.get("pnpUnpluggedFolder"))}var KIt={hooks:{populateYarnPaths:WIt,setupScriptEnvironment:YIt},configuration:{nodeLinker:{description:'The linker used for installing Node packages, one of: "pnp", "node-modules"',type:"STRING",default:"pnp"},winLinkType:{description:"Whether Yarn should use Windows Junctions or symlinks when creating links on Windows.",type:"STRING",values:["junctions","symlinks"],default:"junctions"},pnpMode:{description:"If 'strict', generates standard PnP maps. If 'loose', merges them with the n_m resolution.",type:"STRING",default:"strict"},pnpShebang:{description:"String to prepend to the generated PnP script",type:"STRING",default:"#!/usr/bin/env node"},pnpIgnorePatterns:{description:"Array of glob patterns; files matching them will use the classic resolution",type:"STRING",default:[],isArray:!0},pnpEnableEsmLoader:{description:"If true, Yarn will generate an ESM loader (`.pnp.loader.mjs`). If this is not explicitly set Yarn tries to automatically detect whether ESM support is required.",type:"BOOLEAN",default:!1},pnpEnableInlining:{description:"If true, the PnP data will be inlined along with the generated loader",type:"BOOLEAN",default:!0},pnpFallbackMode:{description:"If true, the generated PnP loader will follow the top-level fallback rule",type:"STRING",default:"dependencies-only"},pnpUnpluggedFolder:{description:"Folder where the unplugged packages must be stored",type:"ABSOLUTE_PATH",default:"./.yarn/unplugged"}},linkers:[D0],commands:[S0]},VIt=KIt;var A1e=$e(l1e());qt();var hq=$e(Be("crypto")),f1e=$e(Be("fs")),p1e=1,Pi="node_modules",BQ=".bin",h1e=".yarn-state.yml",A1t=1e3,gq=(o=>(o.CLASSIC="classic",o.HARDLINKS_LOCAL="hardlinks-local",o.HARDLINKS_GLOBAL="hardlinks-global",o))(gq||{}),lv=class{constructor(){this.installStateCache=new Map}getCustomDataKey(){return JSON.stringify({name:"NodeModulesLinker",version:3})}supportsPackage(e,r){return this.isEnabled(r)}async findPackageLocation(e,r){if(!this.isEnabled(r))throw new Error("Assertion failed: Expected the node-modules linker to be enabled");let o=r.project.tryWorkspaceByLocator(e);if(o)return o.cwd;let a=await _e.getFactoryWithDefault(this.installStateCache,r.project.cwd,async()=>await pq(r.project,{unrollAliases:!0}));if(a===null)throw new it("Couldn't find the node_modules state file - running an install might help (findPackageLocation)");let n=a.locatorMap.get(G.stringifyLocator(e));if(!n){let p=new it(`Couldn't find ${G.prettyLocator(r.project.configuration,e)} in the currently installed node_modules map - running an install might help`);throw p.code="LOCATOR_NOT_INSTALLED",p}let u=n.locations.sort((p,h)=>p.split(V.sep).length-h.split(V.sep).length),A=V.join(r.project.configuration.startingCwd,Pi);return u.find(p=>V.contains(A,p))||n.locations[0]}async findPackageLocator(e,r){if(!this.isEnabled(r))return null;let o=await _e.getFactoryWithDefault(this.installStateCache,r.project.cwd,async()=>await pq(r.project,{unrollAliases:!0}));if(o===null)return null;let{locationRoot:a,segments:n}=vQ(V.resolve(e),{skipPrefix:r.project.cwd}),u=o.locationTree.get(a);if(!u)return null;let A=u.locator;for(let p of n){if(u=u.children.get(p),!u)break;A=u.locator||A}return G.parseLocator(A)}makeInstaller(e){return new fq(e)}isEnabled(e){return e.project.configuration.get("nodeLinker")==="node-modules"}},fq=class{constructor(e){this.opts=e;this.localStore=new Map;this.realLocatorChecksums=new Map;this.customData={store:new Map}}attachCustomData(e){this.customData=e}async installPackage(e,r){let o=V.resolve(r.packageFs.getRealPath(),r.prefixPath),a=this.customData.store.get(e.locatorHash);if(typeof a>"u"&&(a=await f1t(e,r),e.linkType==="HARD"&&this.customData.store.set(e.locatorHash,a)),!G.isPackageCompatible(e,this.opts.project.configuration.getSupportedArchitectures()))return{packageLocation:null,buildRequest:null};let n=new Map,u=new Set;n.has(G.stringifyIdent(e))||n.set(G.stringifyIdent(e),e.reference);let A=e;if(G.isVirtualLocator(e)){A=G.devirtualizeLocator(e);for(let C of e.peerDependencies.values())n.set(G.stringifyIdent(C),null),u.add(G.stringifyIdent(C))}let p={packageLocation:`${fe.fromPortablePath(o)}/`,packageDependencies:n,packagePeers:u,linkType:e.linkType,discardFromLookup:r.discardFromLookup??!1};this.localStore.set(e.locatorHash,{pkg:e,customPackageData:a,dependencyMeta:this.opts.project.getDependencyMeta(e,e.version),pnpNode:p});let h=r.checksum?r.checksum.substring(r.checksum.indexOf("/")+1):null;return this.realLocatorChecksums.set(A.locatorHash,h),{packageLocation:o,buildRequest:null}}async attachInternalDependencies(e,r){let o=this.localStore.get(e.locatorHash);if(typeof o>"u")throw new Error("Assertion failed: Expected information object to have been registered");for(let[a,n]of r){let u=G.areIdentsEqual(a,n)?n.reference:[G.stringifyIdent(n),n.reference];o.pnpNode.packageDependencies.set(G.stringifyIdent(a),u)}}async attachExternalDependents(e,r){throw new Error("External dependencies haven't been implemented for the node-modules linker")}async finalizeInstall(){if(this.opts.project.configuration.get("nodeLinker")!=="node-modules")return;let e=new mi({baseFs:new zl({maxOpenFiles:80,readOnlyArchives:!0})}),r=await pq(this.opts.project),o=this.opts.project.configuration.get("nmMode");(r===null||o!==r.nmMode)&&(this.opts.project.storedBuildState.clear(),r={locatorMap:new Map,binSymlinks:new Map,locationTree:new Map,nmMode:o,mtimeMs:0});let a=new Map(this.opts.project.workspaces.map(v=>{let b=this.opts.project.configuration.get("nmHoistingLimits");try{b=_e.validateEnum(VB,v.manifest.installConfig?.hoistingLimits??b)}catch{let F=G.prettyWorkspace(this.opts.project.configuration,v);this.opts.report.reportWarning(57,`${F}: Invalid 'installConfig.hoistingLimits' value. Expected one of ${Object.values(VB).join(", ")}, using default: "${b}"`)}return[v.relativeCwd,b]})),n=new Map(this.opts.project.workspaces.map(v=>{let b=this.opts.project.configuration.get("nmSelfReferences");return b=v.manifest.installConfig?.selfReferences??b,[v.relativeCwd,b]})),u={VERSIONS:{std:1},topLevel:{name:null,reference:null},getLocator:(v,b)=>Array.isArray(b)?{name:b[0],reference:b[1]}:{name:v,reference:b},getDependencyTreeRoots:()=>this.opts.project.workspaces.map(v=>{let b=v.anchoredLocator;return{name:G.stringifyIdent(b),reference:b.reference}}),getPackageInformation:v=>{let b=v.reference===null?this.opts.project.topLevelWorkspace.anchoredLocator:G.makeLocator(G.parseIdent(v.name),v.reference),E=this.localStore.get(b.locatorHash);if(typeof E>"u")throw new Error("Assertion failed: Expected the package reference to have been registered");return E.pnpNode},findPackageLocator:v=>{let b=this.opts.project.tryWorkspaceByCwd(fe.toPortablePath(v));if(b!==null){let E=b.anchoredLocator;return{name:G.stringifyIdent(E),reference:E.reference}}throw new Error("Assertion failed: Unimplemented")},resolveToUnqualified:()=>{throw new Error("Assertion failed: Unimplemented")},resolveUnqualified:()=>{throw new Error("Assertion failed: Unimplemented")},resolveRequest:()=>{throw new Error("Assertion failed: Unimplemented")},resolveVirtual:v=>fe.fromPortablePath(mi.resolveVirtual(fe.toPortablePath(v)))},{tree:A,errors:p,preserveSymlinksRequired:h}=zB(u,{pnpifyFs:!1,validateExternalSoftLinks:!0,hoistingLimitsByCwd:a,project:this.opts.project,selfReferencesByCwd:n});if(!A){for(let{messageName:v,text:b}of p)this.opts.report.reportError(v,b);return}let C=Hj(A);await y1t(r,C,{baseFs:e,project:this.opts.project,report:this.opts.report,realLocatorChecksums:this.realLocatorChecksums,loadManifest:async v=>{let b=G.parseLocator(v),E=this.localStore.get(b.locatorHash);if(typeof E>"u")throw new Error("Assertion failed: Expected the slot to exist");return E.customPackageData.manifest}});let I=[];for(let[v,b]of C.entries()){if(y1e(v))continue;let E=G.parseLocator(v),F=this.localStore.get(E.locatorHash);if(typeof F>"u")throw new Error("Assertion failed: Expected the slot to exist");if(this.opts.project.tryWorkspaceByLocator(F.pkg))continue;let N=mA.extractBuildRequest(F.pkg,F.customPackageData,F.dependencyMeta,{configuration:this.opts.project.configuration});!N||I.push({buildLocations:b.locations,locator:E,buildRequest:N})}return h&&this.opts.report.reportWarning(72,`The application uses portals and that's why ${de.pretty(this.opts.project.configuration,"--preserve-symlinks",de.Type.CODE)} Node option is required for launching it`),{customData:this.customData,records:I}}};async function f1t(t,e){let r=await Ot.tryFind(e.prefixPath,{baseFs:e.packageFs})??new Ot,o=new Set(["preinstall","install","postinstall"]);for(let a of r.scripts.keys())o.has(a)||r.scripts.delete(a);return{manifest:{bin:r.bin,scripts:r.scripts},misc:{hasBindingGyp:mA.hasBindingGyp(e)}}}async function p1t(t,e,r,o,{installChangedByUser:a}){let n="";n+=`# Warning: This file is automatically generated. Removing it is fine, but will -`,n+=`# cause your node_modules installation to become invalidated. -`,n+=` -`,n+=`__metadata: -`,n+=` version: ${p1e} -`,n+=` nmMode: ${o.value} -`;let u=Array.from(e.keys()).sort(),A=G.stringifyLocator(t.topLevelWorkspace.anchoredLocator);for(let C of u){let I=e.get(C);n+=` -`,n+=`${JSON.stringify(C)}: -`,n+=` locations: -`;for(let v of I.locations){let b=V.contains(t.cwd,v);if(b===null)throw new Error(`Assertion failed: Expected the path to be within the project (${v})`);n+=` - ${JSON.stringify(b)} -`}if(I.aliases.length>0){n+=` aliases: -`;for(let v of I.aliases)n+=` - ${JSON.stringify(v)} -`}if(C===A&&r.size>0){n+=` bin: -`;for(let[v,b]of r){let E=V.contains(t.cwd,v);if(E===null)throw new Error(`Assertion failed: Expected the path to be within the project (${v})`);n+=` ${JSON.stringify(E)}: -`;for(let[F,N]of b){let U=V.relative(V.join(v,Pi),N);n+=` ${JSON.stringify(F)}: ${JSON.stringify(U)} -`}}}}let p=t.cwd,h=V.join(p,Pi,h1e);a&&await oe.removePromise(h),await oe.changeFilePromise(h,n,{automaticNewlines:!0})}async function pq(t,{unrollAliases:e=!1}={}){let r=t.cwd,o=V.join(r,Pi,h1e),a;try{a=await oe.statPromise(o)}catch{}if(!a)return null;let n=Ki(await oe.readFilePromise(o,"utf8"));if(n.__metadata.version>p1e)return null;let u=n.__metadata.nmMode||"classic",A=new Map,p=new Map;delete n.__metadata;for(let[h,C]of Object.entries(n)){let I=C.locations.map(b=>V.join(r,b)),v=C.bin;if(v)for(let[b,E]of Object.entries(v)){let F=V.join(r,fe.toPortablePath(b)),N=_e.getMapWithDefault(p,F);for(let[U,z]of Object.entries(E))N.set(U,fe.toPortablePath([F,Pi,z].join(V.sep)))}if(A.set(h,{target:Bt.dot,linkType:"HARD",locations:I,aliases:C.aliases||[]}),e&&C.aliases)for(let b of C.aliases){let{scope:E,name:F}=G.parseLocator(h),N=G.makeLocator(G.makeIdent(E,F),b),U=G.stringifyLocator(N);A.set(U,{target:Bt.dot,linkType:"HARD",locations:I,aliases:[]})}}return{locatorMap:A,binSymlinks:p,locationTree:g1e(A,{skipPrefix:t.cwd}),nmMode:u,mtimeMs:a.mtimeMs}}var YC=async(t,e)=>{if(t.split(V.sep).indexOf(Pi)<0)throw new Error(`Assertion failed: trying to remove dir that doesn't contain node_modules: ${t}`);try{if(!e.innerLoop){let o=e.allowSymlink?await oe.statPromise(t):await oe.lstatPromise(t);if(e.allowSymlink&&!o.isDirectory()||!e.allowSymlink&&o.isSymbolicLink()){await oe.unlinkPromise(t);return}}let r=await oe.readdirPromise(t,{withFileTypes:!0});for(let o of r){let a=V.join(t,o.name);o.isDirectory()?(o.name!==Pi||e&&e.innerLoop)&&await YC(a,{innerLoop:!0,contentsOnly:!1}):await oe.unlinkPromise(a)}e.contentsOnly||await oe.rmdirPromise(t)}catch(r){if(r.code!=="ENOENT"&&r.code!=="ENOTEMPTY")throw r}},c1e=4,vQ=(t,{skipPrefix:e})=>{let r=V.contains(e,t);if(r===null)throw new Error(`Assertion failed: Writing attempt prevented to ${t} which is outside project root: ${e}`);let o=r.split(V.sep).filter(p=>p!==""),a=o.indexOf(Pi),n=o.slice(0,a).join(V.sep),u=V.join(e,n),A=o.slice(a);return{locationRoot:u,segments:A}},g1e=(t,{skipPrefix:e})=>{let r=new Map;if(t===null)return r;let o=()=>({children:new Map,linkType:"HARD"});for(let[a,n]of t.entries()){if(n.linkType==="SOFT"&&V.contains(e,n.target)!==null){let A=_e.getFactoryWithDefault(r,n.target,o);A.locator=a,A.linkType=n.linkType}for(let u of n.locations){let{locationRoot:A,segments:p}=vQ(u,{skipPrefix:e}),h=_e.getFactoryWithDefault(r,A,o);for(let C=0;C<p.length;++C){let I=p[C];if(I!=="."){let v=_e.getFactoryWithDefault(h.children,I,o);h.children.set(I,v),h=v}C===p.length-1&&(h.locator=a,h.linkType=n.linkType)}}}return r},dq=async(t,e,r)=>{if(process.platform==="win32"&&r==="junctions"){let o;try{o=await oe.lstatPromise(t)}catch{}if(!o||o.isDirectory()){await oe.symlinkPromise(t,e,"junction");return}}await oe.symlinkPromise(V.relative(V.dirname(e),t),e)};async function d1e(t,e,r){let o=V.join(t,`${hq.default.randomBytes(16).toString("hex")}.tmp`);try{await oe.writeFilePromise(o,r);try{await oe.linkPromise(o,e)}catch{}}finally{await oe.unlinkPromise(o)}}async function h1t({srcPath:t,dstPath:e,entry:r,globalHardlinksStore:o,baseFs:a,nmMode:n}){if(r.kind===m1e.FILE){if(n.value==="hardlinks-global"&&o&&r.digest){let A=V.join(o,r.digest.substring(0,2),`${r.digest.substring(2)}.dat`),p;try{let h=await oe.statPromise(A);if(h&&(!r.mtimeMs||h.mtimeMs>r.mtimeMs||h.mtimeMs<r.mtimeMs-A1t))if(await wn.checksumFile(A,{baseFs:oe,algorithm:"sha1"})!==r.digest){let I=V.join(o,`${hq.default.randomBytes(16).toString("hex")}.tmp`);await oe.renamePromise(A,I);let v=await a.readFilePromise(t);await oe.writeFilePromise(I,v);try{await oe.linkPromise(I,A),r.mtimeMs=new Date().getTime(),await oe.unlinkPromise(I)}catch{}}else r.mtimeMs||(r.mtimeMs=Math.ceil(h.mtimeMs));await oe.linkPromise(A,e),p=!0}catch{p=!1}if(!p){let h=await a.readFilePromise(t);await d1e(o,A,h),r.mtimeMs=new Date().getTime();try{await oe.linkPromise(A,e)}catch(C){C&&C.code&&C.code=="EXDEV"&&(n.value="hardlinks-local",await a.copyFilePromise(t,e))}}}else await a.copyFilePromise(t,e);let u=r.mode&511;u!==420&&await oe.chmodPromise(e,u)}}var m1e=(o=>(o.FILE="file",o.DIRECTORY="directory",o.SYMLINK="symlink",o))(m1e||{}),g1t=async(t,e,{baseFs:r,globalHardlinksStore:o,nmMode:a,windowsLinkType:n,packageChecksum:u})=>{await oe.mkdirPromise(t,{recursive:!0});let A=async(C=Bt.dot)=>{let I=V.join(e,C),v=await r.readdirPromise(I,{withFileTypes:!0}),b=new Map;for(let E of v){let F=V.join(C,E.name),N,U=V.join(I,E.name);if(E.isFile()){if(N={kind:"file",mode:(await r.lstatPromise(U)).mode},a.value==="hardlinks-global"){let z=await wn.checksumFile(U,{baseFs:r,algorithm:"sha1"});N.digest=z}}else if(E.isDirectory())N={kind:"directory"};else if(E.isSymbolicLink())N={kind:"symlink",symlinkTo:await r.readlinkPromise(U)};else throw new Error(`Unsupported file type (file: ${U}, mode: 0o${await r.statSync(U).mode.toString(8).padStart(6,"0")})`);if(b.set(F,N),E.isDirectory()&&F!==Pi){let z=await A(F);for(let[te,le]of z)b.set(te,le)}}return b},p;if(a.value==="hardlinks-global"&&o&&u){let C=V.join(o,u.substring(0,2),`${u.substring(2)}.json`);try{p=new Map(Object.entries(JSON.parse(await oe.readFilePromise(C,"utf8"))))}catch{p=await A()}}else p=await A();let h=!1;for(let[C,I]of p){let v=V.join(e,C),b=V.join(t,C);if(I.kind==="directory")await oe.mkdirPromise(b,{recursive:!0});else if(I.kind==="file"){let E=I.mtimeMs;await h1t({srcPath:v,dstPath:b,entry:I,nmMode:a,baseFs:r,globalHardlinksStore:o}),I.mtimeMs!==E&&(h=!0)}else I.kind==="symlink"&&await dq(V.resolve(V.dirname(b),I.symlinkTo),b,n)}if(a.value==="hardlinks-global"&&o&&h&&u){let C=V.join(o,u.substring(0,2),`${u.substring(2)}.json`);await oe.removePromise(C),await d1e(o,C,Buffer.from(JSON.stringify(Object.fromEntries(p))))}};function d1t(t,e,r,o){let a=new Map,n=new Map,u=new Map,A=!1,p=(h,C,I,v,b)=>{let E=!0,F=V.join(h,C),N=new Set;if(C===Pi||C.startsWith("@")){let z;try{z=oe.statSync(F)}catch{}E=!!z,z?z.mtimeMs>r?(A=!0,N=new Set(oe.readdirSync(F))):N=new Set(I.children.get(C).children.keys()):A=!0;let te=e.get(h);if(te){let le=V.join(h,Pi,BQ),pe;try{pe=oe.statSync(le)}catch{}if(!pe)A=!0;else if(pe.mtimeMs>r){A=!0;let ue=new Set(oe.readdirSync(le)),ye=new Map;n.set(h,ye);for(let[ae,Ie]of te)ue.has(ae)&&ye.set(ae,Ie)}else n.set(h,te)}}else E=b.has(C);let U=I.children.get(C);if(E){let{linkType:z,locator:te}=U,le={children:new Map,linkType:z,locator:te};if(v.children.set(C,le),te){let pe=_e.getSetWithDefault(u,te);pe.add(F),u.set(te,pe)}for(let pe of U.children.keys())p(F,pe,U,le,N)}else U.locator&&o.storedBuildState.delete(G.parseLocator(U.locator).locatorHash)};for(let[h,C]of t){let{linkType:I,locator:v}=C,b={children:new Map,linkType:I,locator:v};if(a.set(h,b),v){let E=_e.getSetWithDefault(u,C.locator);E.add(h),u.set(C.locator,E)}C.children.has(Pi)&&p(h,Pi,C,b,new Set)}return{locationTree:a,binSymlinks:n,locatorLocations:u,installChangedByUser:A}}function y1e(t){let e=G.parseDescriptor(t);return G.isVirtualDescriptor(e)&&(e=G.devirtualizeDescriptor(e)),e.range.startsWith("link:")}async function m1t(t,e,r,{loadManifest:o}){let a=new Map;for(let[A,{locations:p}]of t){let h=y1e(A)?null:await o(A,p[0]),C=new Map;if(h)for(let[I,v]of h.bin){let b=V.join(p[0],v);v!==""&&oe.existsSync(b)&&C.set(I,v)}a.set(A,C)}let n=new Map,u=(A,p,h)=>{let C=new Map,I=V.contains(r,A);if(h.locator&&I!==null){let v=a.get(h.locator);for(let[b,E]of v){let F=V.join(A,fe.toPortablePath(E));C.set(b,F)}for(let[b,E]of h.children){let F=V.join(A,b),N=u(F,F,E);N.size>0&&n.set(A,new Map([...n.get(A)||new Map,...N]))}}else for(let[v,b]of h.children){let E=u(V.join(A,v),p,b);for(let[F,N]of E)C.set(F,N)}return C};for(let[A,p]of e){let h=u(A,A,p);h.size>0&&n.set(A,new Map([...n.get(A)||new Map,...h]))}return n}var u1e=(t,e)=>{if(!t||!e)return t===e;let r=G.parseLocator(t);G.isVirtualLocator(r)&&(r=G.devirtualizeLocator(r));let o=G.parseLocator(e);return G.isVirtualLocator(o)&&(o=G.devirtualizeLocator(o)),G.areLocatorsEqual(r,o)};function mq(t){return V.join(t.get("globalFolder"),"store")}async function y1t(t,e,{baseFs:r,project:o,report:a,loadManifest:n,realLocatorChecksums:u}){let A=V.join(o.cwd,Pi),{locationTree:p,binSymlinks:h,locatorLocations:C,installChangedByUser:I}=d1t(t.locationTree,t.binSymlinks,t.mtimeMs,o),v=g1e(e,{skipPrefix:o.cwd}),b=[],E=async({srcDir:Ie,dstDir:Fe,linkType:g,globalHardlinksStore:Ee,nmMode:De,windowsLinkType:ce,packageChecksum:ne})=>{let ee=(async()=>{try{g==="SOFT"?(await oe.mkdirPromise(V.dirname(Fe),{recursive:!0}),await dq(V.resolve(Ie),Fe,ce)):await g1t(Fe,Ie,{baseFs:r,globalHardlinksStore:Ee,nmMode:De,windowsLinkType:ce,packageChecksum:ne})}catch(we){throw we.message=`While persisting ${Ie} -> ${Fe} ${we.message}`,we}finally{le.tick()}})().then(()=>b.splice(b.indexOf(ee),1));b.push(ee),b.length>c1e&&await Promise.race(b)},F=async(Ie,Fe,g)=>{let Ee=(async()=>{let De=async(ce,ne,ee)=>{try{ee.innerLoop||await oe.mkdirPromise(ne,{recursive:!0});let we=await oe.readdirPromise(ce,{withFileTypes:!0});for(let be of we){if(!ee.innerLoop&&be.name===BQ)continue;let ht=V.join(ce,be.name),H=V.join(ne,be.name);be.isDirectory()?(be.name!==Pi||ee&&ee.innerLoop)&&(await oe.mkdirPromise(H,{recursive:!0}),await De(ht,H,{...ee,innerLoop:!0})):ye.value==="hardlinks-local"||ye.value==="hardlinks-global"?await oe.linkPromise(ht,H):await oe.copyFilePromise(ht,H,f1e.default.constants.COPYFILE_FICLONE)}}catch(we){throw ee.innerLoop||(we.message=`While cloning ${ce} -> ${ne} ${we.message}`),we}finally{ee.innerLoop||le.tick()}};await De(Ie,Fe,g)})().then(()=>b.splice(b.indexOf(Ee),1));b.push(Ee),b.length>c1e&&await Promise.race(b)},N=async(Ie,Fe,g)=>{if(g)for(let[Ee,De]of Fe.children){let ce=g.children.get(Ee);await N(V.join(Ie,Ee),De,ce)}else{Fe.children.has(Pi)&&await YC(V.join(Ie,Pi),{contentsOnly:!1});let Ee=V.basename(Ie)===Pi&&v.has(V.join(V.dirname(Ie),V.sep));await YC(Ie,{contentsOnly:Ie===A,allowSymlink:Ee})}};for(let[Ie,Fe]of p){let g=v.get(Ie);for(let[Ee,De]of Fe.children){if(Ee===".")continue;let ce=g&&g.children.get(Ee),ne=V.join(Ie,Ee);await N(ne,De,ce)}}let U=async(Ie,Fe,g)=>{if(g){u1e(Fe.locator,g.locator)||await YC(Ie,{contentsOnly:Fe.linkType==="HARD"});for(let[Ee,De]of Fe.children){let ce=g.children.get(Ee);await U(V.join(Ie,Ee),De,ce)}}else{Fe.children.has(Pi)&&await YC(V.join(Ie,Pi),{contentsOnly:!0});let Ee=V.basename(Ie)===Pi&&v.has(V.join(V.dirname(Ie),V.sep));await YC(Ie,{contentsOnly:Fe.linkType==="HARD",allowSymlink:Ee})}};for(let[Ie,Fe]of v){let g=p.get(Ie);for(let[Ee,De]of Fe.children){if(Ee===".")continue;let ce=g&&g.children.get(Ee);await U(V.join(Ie,Ee),De,ce)}}let z=new Map,te=[];for(let[Ie,Fe]of C)for(let g of Fe){let{locationRoot:Ee,segments:De}=vQ(g,{skipPrefix:o.cwd}),ce=v.get(Ee),ne=Ee;if(ce){for(let ee of De)if(ne=V.join(ne,ee),ce=ce.children.get(ee),!ce)break;if(ce){let ee=u1e(ce.locator,Ie),we=e.get(ce.locator),be=we.target,ht=ne,H=we.linkType;if(ee)z.has(be)||z.set(be,ht);else if(be!==ht){let lt=G.parseLocator(ce.locator);G.isVirtualLocator(lt)&&(lt=G.devirtualizeLocator(lt)),te.push({srcDir:be,dstDir:ht,linkType:H,realLocatorHash:lt.locatorHash})}}}}for(let[Ie,{locations:Fe}]of e.entries())for(let g of Fe){let{locationRoot:Ee,segments:De}=vQ(g,{skipPrefix:o.cwd}),ce=p.get(Ee),ne=v.get(Ee),ee=Ee,we=e.get(Ie),be=G.parseLocator(Ie);G.isVirtualLocator(be)&&(be=G.devirtualizeLocator(be));let ht=be.locatorHash,H=we.target,lt=g;if(H===lt)continue;let Te=we.linkType;for(let ke of De)ne=ne.children.get(ke);if(!ce)te.push({srcDir:H,dstDir:lt,linkType:Te,realLocatorHash:ht});else for(let ke of De)if(ee=V.join(ee,ke),ce=ce.children.get(ke),!ce){te.push({srcDir:H,dstDir:lt,linkType:Te,realLocatorHash:ht});break}}let le=Xs.progressViaCounter(te.length),pe=a.reportProgress(le),ue=o.configuration.get("nmMode"),ye={value:ue},ae=o.configuration.get("winLinkType");try{let Ie=ye.value==="hardlinks-global"?`${mq(o.configuration)}/v1`:null;if(Ie&&!await oe.existsPromise(Ie)){await oe.mkdirpPromise(Ie);for(let g=0;g<256;g++)await oe.mkdirPromise(V.join(Ie,g.toString(16).padStart(2,"0")))}for(let g of te)(g.linkType==="SOFT"||!z.has(g.srcDir))&&(z.set(g.srcDir,g.dstDir),await E({...g,globalHardlinksStore:Ie,nmMode:ye,windowsLinkType:ae,packageChecksum:u.get(g.realLocatorHash)||null}));await Promise.all(b),b.length=0;for(let g of te){let Ee=z.get(g.srcDir);g.linkType!=="SOFT"&&g.dstDir!==Ee&&await F(Ee,g.dstDir,{nmMode:ye})}await Promise.all(b),await oe.mkdirPromise(A,{recursive:!0});let Fe=await m1t(e,v,o.cwd,{loadManifest:n});await E1t(h,Fe,o.cwd,ae),await p1t(o,e,Fe,ye,{installChangedByUser:I}),ue=="hardlinks-global"&&ye.value=="hardlinks-local"&&a.reportWarningOnce(74,"'nmMode' has been downgraded to 'hardlinks-local' due to global cache and install folder being on different devices")}finally{pe.stop()}}async function E1t(t,e,r,o){for(let a of t.keys()){if(V.contains(r,a)===null)throw new Error(`Assertion failed. Excepted bin symlink location to be inside project dir, instead it was at ${a}`);if(!e.has(a)){let n=V.join(a,Pi,BQ);await oe.removePromise(n)}}for(let[a,n]of e){if(V.contains(r,a)===null)throw new Error(`Assertion failed. Excepted bin symlink location to be inside project dir, instead it was at ${a}`);let u=V.join(a,Pi,BQ),A=t.get(a)||new Map;await oe.mkdirPromise(u,{recursive:!0});for(let p of A.keys())n.has(p)||(await oe.removePromise(V.join(u,p)),process.platform==="win32"&&await oe.removePromise(V.join(u,`${p}.cmd`)));for(let[p,h]of n){let C=A.get(p),I=V.join(u,p);C!==h&&(process.platform==="win32"?await(0,A1e.default)(fe.fromPortablePath(h),fe.fromPortablePath(I),{createPwshFile:!1}):(await oe.removePromise(I),await dq(h,I,o),V.contains(r,await oe.realpathPromise(h))!==null&&await oe.chmodPromise(h,493)))}}}Ye();Pt();nA();var cv=class extends D0{constructor(){super(...arguments);this.mode="loose"}makeInstaller(r){return new yq(r)}},yq=class extends dm{constructor(){super(...arguments);this.mode="loose"}async transformPnpSettings(r){let o=new mi({baseFs:new zl({maxOpenFiles:80,readOnlyArchives:!0})}),a=XIe(r,this.opts.project.cwd,o),{tree:n,errors:u}=zB(a,{pnpifyFs:!1,project:this.opts.project});if(!n){for(let{messageName:I,text:v}of u)this.opts.report.reportError(I,v);return}let A=new Map;r.fallbackPool=A;let p=(I,v)=>{let b=G.parseLocator(v.locator),E=G.stringifyIdent(b);E===I?A.set(I,b.reference):A.set(I,[E,b.reference])},h=V.join(this.opts.project.cwd,dr.nodeModules),C=n.get(h);if(!(typeof C>"u")){if("target"in C)throw new Error("Assertion failed: Expected the root junction point to be a directory");for(let I of C.dirList){let v=V.join(h,I),b=n.get(v);if(typeof b>"u")throw new Error("Assertion failed: Expected the child to have been registered");if("target"in b)p(I,b);else for(let E of b.dirList){let F=V.join(v,E),N=n.get(F);if(typeof N>"u")throw new Error("Assertion failed: Expected the subchild to have been registered");if("target"in N)p(`${I}/${E}`,N);else throw new Error("Assertion failed: Expected the leaf junction to be a package")}}}}};var C1t={hooks:{cleanGlobalArtifacts:async t=>{let e=mq(t);await oe.removePromise(e)}},configuration:{nmHoistingLimits:{description:"Prevents packages to be hoisted past specific levels",type:"STRING",values:["workspaces","dependencies","none"],default:"none"},nmMode:{description:"Defines in which measure Yarn must use hardlinks and symlinks when generated `node_modules` directories.",type:"STRING",values:["classic","hardlinks-local","hardlinks-global"],default:"classic"},nmSelfReferences:{description:"Defines whether the linker should generate self-referencing symlinks for workspaces.",type:"BOOLEAN",default:!0}},linkers:[lv,cv]},w1t=C1t;var yG={};Vt(yG,{NpmHttpFetcher:()=>fv,NpmRemapResolver:()=>pv,NpmSemverFetcher:()=>dl,NpmSemverResolver:()=>hv,NpmTagResolver:()=>gv,default:()=>Rvt,npmConfigUtils:()=>Zn,npmHttpUtils:()=>on,npmPublishUtils:()=>sw});Ye();var P1e=$e(Jn());var Wn="npm:";var on={};Vt(on,{AuthType:()=>v1e,customPackageError:()=>mm,del:()=>k1t,get:()=>ym,getIdentUrl:()=>DQ,getPackageMetadata:()=>VC,handleInvalidAuthenticationError:()=>x0,post:()=>x1t,put:()=>b1t});Ye();Ye();Pt();var Iq=$e(u2()),I1e=$e(D_()),B1e=$e(Jn()),Bq=Be("url");var Zn={};Vt(Zn,{RegistryType:()=>E1e,getAuditRegistry:()=>I1t,getAuthConfiguration:()=>wq,getDefaultRegistry:()=>uv,getPublishRegistry:()=>B1t,getRegistryConfiguration:()=>C1e,getScopeConfiguration:()=>Cq,getScopeRegistry:()=>WC,normalizeRegistry:()=>oc});var E1e=(o=>(o.AUDIT_REGISTRY="npmAuditRegistry",o.FETCH_REGISTRY="npmRegistryServer",o.PUBLISH_REGISTRY="npmPublishRegistry",o))(E1e||{});function oc(t){return t.replace(/\/$/,"")}function I1t({configuration:t}){return uv({configuration:t,type:"npmAuditRegistry"})}function B1t(t,{configuration:e}){return t.publishConfig?.registry?oc(t.publishConfig.registry):t.name?WC(t.name.scope,{configuration:e,type:"npmPublishRegistry"}):uv({configuration:e,type:"npmPublishRegistry"})}function WC(t,{configuration:e,type:r="npmRegistryServer"}){let o=Cq(t,{configuration:e});if(o===null)return uv({configuration:e,type:r});let a=o.get(r);return a===null?uv({configuration:e,type:r}):oc(a)}function uv({configuration:t,type:e="npmRegistryServer"}){let r=t.get(e);return oc(r!==null?r:t.get("npmRegistryServer"))}function C1e(t,{configuration:e}){let r=e.get("npmRegistries"),o=oc(t),a=r.get(o);if(typeof a<"u")return a;let n=r.get(o.replace(/^[a-z]+:/,""));return typeof n<"u"?n:null}function Cq(t,{configuration:e}){if(t===null)return null;let o=e.get("npmScopes").get(t);return o||null}function wq(t,{configuration:e,ident:r}){let o=r&&Cq(r.scope,{configuration:e});return o?.get("npmAuthIdent")||o?.get("npmAuthToken")?o:C1e(t,{configuration:e})||e}var v1e=(a=>(a[a.NO_AUTH=0]="NO_AUTH",a[a.BEST_EFFORT=1]="BEST_EFFORT",a[a.CONFIGURATION=2]="CONFIGURATION",a[a.ALWAYS_AUTH=3]="ALWAYS_AUTH",a))(v1e||{});async function x0(t,{attemptedAs:e,registry:r,headers:o,configuration:a}){if(SQ(t))throw new Jt(41,"Invalid OTP token");if(t.originalError?.name==="HTTPError"&&t.originalError?.response.statusCode===401)throw new Jt(41,`Invalid authentication (${typeof e!="string"?`as ${await F1t(r,o,{configuration:a})}`:`attempted as ${e}`})`)}function mm(t,e){let r=t.response?.statusCode;return r?r===404?"Package not found":r>=500&&r<600?`The registry appears to be down (using a ${de.applyHyperlink(e,"local cache","https://yarnpkg.com/advanced/lexicon#local-cache")} might have protected you against such outages)`:null:null}function DQ(t){return t.scope?`/@${t.scope}%2f${t.name}`:`/${t.name}`}var w1e=new Map;async function VC(t,{cache:e,project:r,registry:o,headers:a,version:n,...u}){return await _e.getFactoryWithDefault(w1e,t.identHash,async()=>{let{configuration:A}=r;o=Av(A,{ident:t,registry:o});let p=P1t(A,o),h=V.join(p,`${G.slugifyIdent(t)}.json`),C=null;if(!r.lockfileNeedsRefresh){try{C=await oe.readJsonPromise(h)}catch{}if(C){if(typeof n<"u"&&typeof C.metadata.versions[n]<"u")return C.metadata;if(A.get("enableOfflineMode")){let I=structuredClone(C.metadata),v=new Set;if(e){for(let E of Object.keys(I.versions)){let F=G.makeLocator(t,`npm:${E}`),N=e.getLocatorMirrorPath(F);(!N||!oe.existsSync(N))&&(delete I.versions[E],v.add(E))}let b=I["dist-tags"].latest;if(v.has(b)){let E=Object.keys(C.metadata.versions).sort(B1e.default.compare),F=E.indexOf(b);for(;v.has(E[F])&&F>=0;)F-=1;F>=0?I["dist-tags"].latest=E[F]:delete I["dist-tags"].latest}}return I}}}return await ym(DQ(t),{...u,customErrorMessage:mm,configuration:A,registry:o,ident:t,headers:{...a,["If-None-Match"]:C?.etag,["If-Modified-Since"]:C?.lastModified},wrapNetworkRequest:async I=>async()=>{let v=await I();if(v.statusCode===304){if(C===null)throw new Error("Assertion failed: cachedMetadata should not be null");return{...v,body:C.metadata}}let b=v1t(JSON.parse(v.body.toString()));w1e.set(t.identHash,b);let E={metadata:b,etag:v.headers.etag,lastModified:v.headers["last-modified"]},F=`${h}-${process.pid}.tmp`;return await oe.mkdirPromise(p,{recursive:!0}),await oe.writeJsonPromise(F,E,{compact:!0}),await oe.renamePromise(F,h),{...v,body:b}}})})}var D1e=["name","dist.tarball","bin","scripts","os","cpu","libc","dependencies","dependenciesMeta","optionalDependencies","peerDependencies","peerDependenciesMeta","deprecated"];function v1t(t){return{"dist-tags":t["dist-tags"],versions:Object.fromEntries(Object.entries(t.versions).map(([e,r])=>[e,(0,I1e.default)(r,D1e)]))}}var D1t=wn.makeHash(...D1e).slice(0,6);function P1t(t,e){let r=S1t(t),o=new Bq.URL(e);return V.join(r,D1t,o.hostname)}function S1t(t){return V.join(t.get("globalFolder"),"metadata/npm")}async function ym(t,{configuration:e,headers:r,ident:o,authType:a,registry:n,...u}){n=Av(e,{ident:o,registry:n}),o&&o.scope&&typeof a>"u"&&(a=1);let A=await PQ(n,{authType:a,configuration:e,ident:o});A&&(r={...r,authorization:A});try{return await rn.get(t.charAt(0)==="/"?`${n}${t}`:t,{configuration:e,headers:r,...u})}catch(p){throw await x0(p,{registry:n,configuration:e,headers:r}),p}}async function x1t(t,e,{attemptedAs:r,configuration:o,headers:a,ident:n,authType:u=3,registry:A,otp:p,...h}){A=Av(o,{ident:n,registry:A});let C=await PQ(A,{authType:u,configuration:o,ident:n});C&&(a={...a,authorization:C}),p&&(a={...a,...KC(p)});try{return await rn.post(A+t,e,{configuration:o,headers:a,...h})}catch(I){if(!SQ(I)||p)throw await x0(I,{attemptedAs:r,registry:A,configuration:o,headers:a}),I;p=await vq(I,{configuration:o});let v={...a,...KC(p)};try{return await rn.post(`${A}${t}`,e,{configuration:o,headers:v,...h})}catch(b){throw await x0(b,{attemptedAs:r,registry:A,configuration:o,headers:a}),b}}}async function b1t(t,e,{attemptedAs:r,configuration:o,headers:a,ident:n,authType:u=3,registry:A,otp:p,...h}){A=Av(o,{ident:n,registry:A});let C=await PQ(A,{authType:u,configuration:o,ident:n});C&&(a={...a,authorization:C}),p&&(a={...a,...KC(p)});try{return await rn.put(A+t,e,{configuration:o,headers:a,...h})}catch(I){if(!SQ(I))throw await x0(I,{attemptedAs:r,registry:A,configuration:o,headers:a}),I;p=await vq(I,{configuration:o});let v={...a,...KC(p)};try{return await rn.put(`${A}${t}`,e,{configuration:o,headers:v,...h})}catch(b){throw await x0(b,{attemptedAs:r,registry:A,configuration:o,headers:a}),b}}}async function k1t(t,{attemptedAs:e,configuration:r,headers:o,ident:a,authType:n=3,registry:u,otp:A,...p}){u=Av(r,{ident:a,registry:u});let h=await PQ(u,{authType:n,configuration:r,ident:a});h&&(o={...o,authorization:h}),A&&(o={...o,...KC(A)});try{return await rn.del(u+t,{configuration:r,headers:o,...p})}catch(C){if(!SQ(C)||A)throw await x0(C,{attemptedAs:e,registry:u,configuration:r,headers:o}),C;A=await vq(C,{configuration:r});let I={...o,...KC(A)};try{return await rn.del(`${u}${t}`,{configuration:r,headers:I,...p})}catch(v){throw await x0(v,{attemptedAs:e,registry:u,configuration:r,headers:o}),v}}}function Av(t,{ident:e,registry:r}){if(typeof r>"u"&&e)return WC(e.scope,{configuration:t});if(typeof r!="string")throw new Error("Assertion failed: The registry should be a string");return oc(r)}async function PQ(t,{authType:e=2,configuration:r,ident:o}){let a=wq(t,{configuration:r,ident:o}),n=Q1t(a,e);if(!n)return null;let u=await r.reduceHook(A=>A.getNpmAuthenticationHeader,void 0,t,{configuration:r,ident:o});if(u)return u;if(a.get("npmAuthToken"))return`Bearer ${a.get("npmAuthToken")}`;if(a.get("npmAuthIdent")){let A=a.get("npmAuthIdent");return A.includes(":")?`Basic ${Buffer.from(A).toString("base64")}`:`Basic ${A}`}if(n&&e!==1)throw new Jt(33,"No authentication configured for request");return null}function Q1t(t,e){switch(e){case 2:return t.get("npmAlwaysAuth");case 1:case 3:return!0;case 0:return!1;default:throw new Error("Unreachable")}}async function F1t(t,e,{configuration:r}){if(typeof e>"u"||typeof e.authorization>"u")return"an anonymous user";try{return(await rn.get(new Bq.URL(`${t}/-/whoami`).href,{configuration:r,headers:e,jsonResponse:!0})).username??"an unknown user"}catch{return"an unknown user"}}async function vq(t,{configuration:e}){let r=t.originalError?.response.headers["npm-notice"];if(r&&(await Lt.start({configuration:e,stdout:process.stdout,includeFooter:!1},async a=>{if(a.reportInfo(0,r.replace(/(https?:\/\/\S+)/g,de.pretty(e,"$1",de.Type.URL))),!process.env.YARN_IS_TEST_ENV){let n=r.match(/open (https?:\/\/\S+)/i);if(n&&zi.openUrl){let{openNow:u}=await(0,Iq.prompt)({type:"confirm",name:"openNow",message:"Do you want to try to open this url now?",required:!0,initial:!0,onCancel:()=>process.exit(130)});u&&(await zi.openUrl(n[1])||(a.reportSeparator(),a.reportWarning(0,"We failed to automatically open the url; you'll have to open it yourself in your browser of choice.")))}}}),process.stdout.write(` -`)),process.env.YARN_IS_TEST_ENV)return process.env.YARN_INJECT_NPM_2FA_TOKEN||"";let{otp:o}=await(0,Iq.prompt)({type:"password",name:"otp",message:"One-time password:",required:!0,onCancel:()=>process.exit(130)});return process.stdout.write(` -`),o}function SQ(t){if(t.originalError?.name!=="HTTPError")return!1;try{return(t.originalError?.response.headers["www-authenticate"].split(/,\s*/).map(r=>r.toLowerCase())).includes("otp")}catch{return!1}}function KC(t){return{["npm-otp"]:t}}var fv=class{supports(e,r){if(!e.reference.startsWith(Wn))return!1;let{selector:o,params:a}=G.parseRange(e.reference);return!(!P1e.default.valid(o)||a===null||typeof a.__archiveUrl!="string")}getLocalPath(e,r){return null}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,[a,n,u]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${G.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the remote server`),loader:()=>this.fetchFromNetwork(e,r),...r.cacheOptions});return{packageFs:a,releaseFs:n,prefixPath:G.getIdentVendorPath(e),checksum:u}}async fetchFromNetwork(e,r){let{params:o}=G.parseRange(e.reference);if(o===null||typeof o.__archiveUrl!="string")throw new Error("Assertion failed: The archiveUrl querystring parameter should have been available");let a=await ym(o.__archiveUrl,{customErrorMessage:mm,configuration:r.project.configuration,ident:e});return await Xi.convertToZip(a,{configuration:r.project.configuration,prefixPath:G.getIdentVendorPath(e),stripComponents:1})}};Ye();var pv=class{supportsDescriptor(e,r){return!(!e.range.startsWith(Wn)||!G.tryParseDescriptor(e.range.slice(Wn.length),!0))}supportsLocator(e,r){return!1}shouldPersistResolution(e,r){throw new Error("Unreachable")}bindDescriptor(e,r,o){return e}getResolutionDependencies(e,r){let o=r.project.configuration.normalizeDependency(G.parseDescriptor(e.range.slice(Wn.length),!0));return r.resolver.getResolutionDependencies(o,r)}async getCandidates(e,r,o){let a=o.project.configuration.normalizeDependency(G.parseDescriptor(e.range.slice(Wn.length),!0));return await o.resolver.getCandidates(a,r,o)}async getSatisfying(e,r,o,a){let n=a.project.configuration.normalizeDependency(G.parseDescriptor(e.range.slice(Wn.length),!0));return a.resolver.getSatisfying(n,r,o,a)}resolve(e,r){throw new Error("Unreachable")}};Ye();Ye();var S1e=$e(Jn()),x1e=Be("url");var dl=class{supports(e,r){if(!e.reference.startsWith(Wn))return!1;let o=new x1e.URL(e.reference);return!(!S1e.default.valid(o.pathname)||o.searchParams.has("__archiveUrl"))}getLocalPath(e,r){return null}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,[a,n,u]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${G.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the remote registry`),loader:()=>this.fetchFromNetwork(e,r),...r.cacheOptions});return{packageFs:a,releaseFs:n,prefixPath:G.getIdentVendorPath(e),checksum:u}}async fetchFromNetwork(e,r){let o;try{o=await ym(dl.getLocatorUrl(e),{customErrorMessage:mm,configuration:r.project.configuration,ident:e})}catch{o=await ym(dl.getLocatorUrl(e).replace(/%2f/g,"/"),{customErrorMessage:mm,configuration:r.project.configuration,ident:e})}return await Xi.convertToZip(o,{configuration:r.project.configuration,prefixPath:G.getIdentVendorPath(e),stripComponents:1})}static isConventionalTarballUrl(e,r,{configuration:o}){let a=WC(e.scope,{configuration:o}),n=dl.getLocatorUrl(e);return r=r.replace(/^https?:(\/\/(?:[^/]+\.)?npmjs.org(?:$|\/))/,"https:$1"),a=a.replace(/^https:\/\/registry\.npmjs\.org($|\/)/,"https://registry.yarnpkg.com$1"),r=r.replace(/^https:\/\/registry\.npmjs\.org($|\/)/,"https://registry.yarnpkg.com$1"),r===a+n||r===a+n.replace(/%2f/g,"/")}static getLocatorUrl(e){let r=Qr.clean(e.reference.slice(Wn.length));if(r===null)throw new Jt(10,"The npm semver resolver got selected, but the version isn't semver");return`${DQ(e)}/-/${e.name}-${r}.tgz`}};Ye();Ye();Ye();var Dq=$e(Jn());var xQ=G.makeIdent(null,"node-gyp"),R1t=/\b(node-gyp|prebuild-install)\b/,hv=class{supportsDescriptor(e,r){return e.range.startsWith(Wn)?!!Qr.validRange(e.range.slice(Wn.length)):!1}supportsLocator(e,r){if(!e.reference.startsWith(Wn))return!1;let{selector:o}=G.parseRange(e.reference);return!!Dq.default.valid(o)}shouldPersistResolution(e,r){return!0}bindDescriptor(e,r,o){return e}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){let a=Qr.validRange(e.range.slice(Wn.length));if(a===null)throw new Error(`Expected a valid range, got ${e.range.slice(Wn.length)}`);let n=await VC(e,{cache:o.fetchOptions?.cache,project:o.project,version:Dq.default.valid(a.raw)?a.raw:void 0}),u=_e.mapAndFilter(Object.keys(n.versions),h=>{try{let C=new Qr.SemVer(h);if(a.test(C))return C}catch{}return _e.mapAndFilter.skip}),A=u.filter(h=>!n.versions[h.raw].deprecated),p=A.length>0?A:u;return p.sort((h,C)=>-h.compare(C)),p.map(h=>{let C=G.makeLocator(e,`${Wn}${h.raw}`),I=n.versions[h.raw].dist.tarball;return dl.isConventionalTarballUrl(C,I,{configuration:o.project.configuration})?C:G.bindLocator(C,{__archiveUrl:I})})}async getSatisfying(e,r,o,a){let n=Qr.validRange(e.range.slice(Wn.length));if(n===null)throw new Error(`Expected a valid range, got ${e.range.slice(Wn.length)}`);return{locators:_e.mapAndFilter(o,p=>{if(p.identHash!==e.identHash)return _e.mapAndFilter.skip;let h=G.tryParseRange(p.reference,{requireProtocol:Wn});if(!h)return _e.mapAndFilter.skip;let C=new Qr.SemVer(h.selector);return n.test(C)?{locator:p,version:C}:_e.mapAndFilter.skip}).sort((p,h)=>-p.version.compare(h.version)).map(({locator:p})=>p),sorted:!0}}async resolve(e,r){let{selector:o}=G.parseRange(e.reference),a=Qr.clean(o);if(a===null)throw new Jt(10,"The npm semver resolver got selected, but the version isn't semver");let n=await VC(e,{cache:r.fetchOptions?.cache,project:r.project,version:a});if(!Object.hasOwn(n,"versions"))throw new Jt(15,'Registry returned invalid data for - missing "versions" field');if(!Object.hasOwn(n.versions,a))throw new Jt(16,`Registry failed to return reference "${a}"`);let u=new Ot;if(u.load(n.versions[a]),!u.dependencies.has(xQ.identHash)&&!u.peerDependencies.has(xQ.identHash)){for(let A of u.scripts.values())if(A.match(R1t)){u.dependencies.set(xQ.identHash,G.makeDescriptor(xQ,"latest"));break}}return{...e,version:a,languageName:"node",linkType:"HARD",conditions:u.getConditions(),dependencies:r.project.configuration.normalizeDependencyMap(u.dependencies),peerDependencies:u.peerDependencies,dependenciesMeta:u.dependenciesMeta,peerDependenciesMeta:u.peerDependenciesMeta,bin:u.bin}}};Ye();Ye();var b1e=$e(Jn());var gv=class{supportsDescriptor(e,r){return!(!e.range.startsWith(Wn)||!QE.test(e.range.slice(Wn.length)))}supportsLocator(e,r){return!1}shouldPersistResolution(e,r){throw new Error("Unreachable")}bindDescriptor(e,r,o){return e}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){let a=e.range.slice(Wn.length),n=await VC(e,{cache:o.fetchOptions?.cache,project:o.project});if(!Object.hasOwn(n,"dist-tags"))throw new Jt(15,'Registry returned invalid data - missing "dist-tags" field');let u=n["dist-tags"];if(!Object.hasOwn(u,a))throw new Jt(16,`Registry failed to return tag "${a}"`);let A=u[a],p=G.makeLocator(e,`${Wn}${A}`),h=n.versions[A].dist.tarball;return dl.isConventionalTarballUrl(p,h,{configuration:o.project.configuration})?[p]:[G.bindLocator(p,{__archiveUrl:h})]}async getSatisfying(e,r,o,a){let n=[];for(let u of o){if(u.identHash!==e.identHash)continue;let A=G.tryParseRange(u.reference,{requireProtocol:Wn});if(!(!A||!b1e.default.valid(A.selector))){if(A.params?.__archiveUrl){let p=G.makeRange({protocol:Wn,selector:A.selector,source:null,params:null}),[h]=await a.resolver.getCandidates(G.makeDescriptor(e,p),r,a);if(u.reference!==h.reference)continue}n.push(u)}}return{locators:n,sorted:!1}}async resolve(e,r){throw new Error("Unreachable")}};var sw={};Vt(sw,{getGitHead:()=>Qvt,getPublishAccess:()=>EBe,getReadmeContent:()=>CBe,makePublishBody:()=>kvt});Ye();Ye();Pt();var pG={};Vt(pG,{PackCommand:()=>O0,default:()=>fvt,packUtils:()=>CA});Ye();Ye();Ye();Pt();qt();var CA={};Vt(CA,{genPackList:()=>XQ,genPackStream:()=>fG,genPackageManifest:()=>oBe,hasPackScripts:()=>uG,prepareForPack:()=>AG});Ye();Pt();var cG=$e(Zo()),iBe=$e(eBe()),sBe=Be("zlib"),tvt=["/package.json","/readme","/readme.*","/license","/license.*","/licence","/licence.*","/changelog","/changelog.*"],rvt=["/package.tgz",".github",".git",".hg","node_modules",".npmignore",".gitignore",".#*",".DS_Store"];async function uG(t){return!!(un.hasWorkspaceScript(t,"prepack")||un.hasWorkspaceScript(t,"postpack"))}async function AG(t,{report:e},r){await un.maybeExecuteWorkspaceLifecycleScript(t,"prepack",{report:e});try{let o=V.join(t.cwd,Ot.fileName);await oe.existsPromise(o)&&await t.manifest.loadFile(o,{baseFs:oe}),await r()}finally{await un.maybeExecuteWorkspaceLifecycleScript(t,"postpack",{report:e})}}async function fG(t,e){typeof e>"u"&&(e=await XQ(t));let r=new Set;for(let n of t.manifest.publishConfig?.executableFiles??new Set)r.add(V.normalize(n));for(let n of t.manifest.bin.values())r.add(V.normalize(n));let o=iBe.default.pack();process.nextTick(async()=>{for(let n of e){let u=V.normalize(n),A=V.resolve(t.cwd,u),p=V.join("package",u),h=await oe.lstatPromise(A),C={name:p,mtime:new Date(vi.SAFE_TIME*1e3)},I=r.has(u)?493:420,v,b,E=new Promise((N,U)=>{v=N,b=U}),F=N=>{N?b(N):v()};if(h.isFile()){let N;u==="package.json"?N=Buffer.from(JSON.stringify(await oBe(t),null,2)):N=await oe.readFilePromise(A),o.entry({...C,mode:I,type:"file"},N,F)}else h.isSymbolicLink()?o.entry({...C,mode:I,type:"symlink",linkname:await oe.readlinkPromise(A)},F):F(new Error(`Unsupported file type ${h.mode} for ${fe.fromPortablePath(u)}`));await E}o.finalize()});let a=(0,sBe.createGzip)();return o.pipe(a),a}async function oBe(t){let e=JSON.parse(JSON.stringify(t.manifest.raw));return await t.project.configuration.triggerHook(r=>r.beforeWorkspacePacking,t,e),e}async function XQ(t){let e=t.project,r=e.configuration,o={accept:[],reject:[]};for(let I of rvt)o.reject.push(I);for(let I of tvt)o.accept.push(I);o.reject.push(r.get("rcFilename"));let a=I=>{if(I===null||!I.startsWith(`${t.cwd}/`))return;let v=V.relative(t.cwd,I),b=V.resolve(Bt.root,v);o.reject.push(b)};a(V.resolve(e.cwd,dr.lockfile)),a(r.get("cacheFolder")),a(r.get("globalFolder")),a(r.get("installStatePath")),a(r.get("virtualFolder")),a(r.get("yarnPath")),await r.triggerHook(I=>I.populateYarnPaths,e,I=>{a(I)});for(let I of e.workspaces){let v=V.relative(t.cwd,I.cwd);v!==""&&!v.match(/^(\.\.)?\//)&&o.reject.push(`/${v}`)}let n={accept:[],reject:[]},u=t.manifest.publishConfig?.main??t.manifest.main,A=t.manifest.publishConfig?.module??t.manifest.module,p=t.manifest.publishConfig?.browser??t.manifest.browser,h=t.manifest.publishConfig?.bin??t.manifest.bin;u!=null&&n.accept.push(V.resolve(Bt.root,u)),A!=null&&n.accept.push(V.resolve(Bt.root,A)),typeof p=="string"&&n.accept.push(V.resolve(Bt.root,p));for(let I of h.values())n.accept.push(V.resolve(Bt.root,I));if(p instanceof Map)for(let[I,v]of p.entries())n.accept.push(V.resolve(Bt.root,I)),typeof v=="string"&&n.accept.push(V.resolve(Bt.root,v));let C=t.manifest.files!==null;if(C){n.reject.push("/*");for(let I of t.manifest.files)aBe(n.accept,I,{cwd:Bt.root})}return await nvt(t.cwd,{hasExplicitFileList:C,globalList:o,ignoreList:n})}async function nvt(t,{hasExplicitFileList:e,globalList:r,ignoreList:o}){let a=[],n=new _u(t),u=[[Bt.root,[o]]];for(;u.length>0;){let[A,p]=u.pop(),h=await n.lstatPromise(A);if(!rBe(A,{globalList:r,ignoreLists:h.isDirectory()?null:p}))if(h.isDirectory()){let C=await n.readdirPromise(A),I=!1,v=!1;if(!e||A!==Bt.root)for(let F of C)I=I||F===".gitignore",v=v||F===".npmignore";let b=v?await tBe(n,A,".npmignore"):I?await tBe(n,A,".gitignore"):null,E=b!==null?[b].concat(p):p;rBe(A,{globalList:r,ignoreLists:p})&&(E=[...p,{accept:[],reject:["**/*"]}]);for(let F of C)u.push([V.resolve(A,F),E])}else(h.isFile()||h.isSymbolicLink())&&a.push(V.relative(Bt.root,A))}return a.sort()}async function tBe(t,e,r){let o={accept:[],reject:[]},a=await t.readFilePromise(V.join(e,r),"utf8");for(let n of a.split(/\n/g))aBe(o.reject,n,{cwd:e});return o}function ivt(t,{cwd:e}){let r=t[0]==="!";return r&&(t=t.slice(1)),t.match(/\.{0,1}\//)&&(t=V.resolve(e,t)),r&&(t=`!${t}`),t}function aBe(t,e,{cwd:r}){let o=e.trim();o===""||o[0]==="#"||t.push(ivt(o,{cwd:r}))}function rBe(t,{globalList:e,ignoreLists:r}){let o=JQ(t,e.accept);if(o!==0)return o===2;let a=JQ(t,e.reject);if(a!==0)return a===1;if(r!==null)for(let n of r){let u=JQ(t,n.accept);if(u!==0)return u===2;let A=JQ(t,n.reject);if(A!==0)return A===1}return!1}function JQ(t,e){let r=e,o=[];for(let a=0;a<e.length;++a)e[a][0]!=="!"?r!==e&&r.push(e[a]):(r===e&&(r=e.slice(0,a)),o.push(e[a].slice(1)));return nBe(t,o)?2:nBe(t,r)?1:0}function nBe(t,e){let r=e,o=[];for(let a=0;a<e.length;++a)e[a].includes("/")?r!==e&&r.push(e[a]):(r===e&&(r=e.slice(0,a)),o.push(e[a]));return!!(cG.default.isMatch(t,r,{dot:!0,nocase:!0})||cG.default.isMatch(t,o,{dot:!0,basename:!0,nocase:!0}))}var O0=class extends ut{constructor(){super(...arguments);this.installIfNeeded=ge.Boolean("--install-if-needed",!1,{description:"Run a preliminary `yarn install` if the package contains build scripts"});this.dryRun=ge.Boolean("-n,--dry-run",!1,{description:"Print the file paths without actually generating the package archive"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.out=ge.String("-o,--out",{description:"Create the archive at the specified path"});this.filename=ge.String("--filename",{hidden:!0})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);await uG(a)&&(this.installIfNeeded?await o.install({cache:await Nr.find(r),report:new Qi}):await o.restoreInstallState());let n=this.out??this.filename,u=typeof n<"u"?V.resolve(this.context.cwd,svt(n,{workspace:a})):V.resolve(a.cwd,"package.tgz");return(await Lt.start({configuration:r,stdout:this.context.stdout,json:this.json},async p=>{await AG(a,{report:p},async()=>{p.reportJson({base:fe.fromPortablePath(a.cwd)});let h=await XQ(a);for(let C of h)p.reportInfo(null,fe.fromPortablePath(C)),p.reportJson({location:fe.fromPortablePath(C)});if(!this.dryRun){let C=await fG(a,h),I=oe.createWriteStream(u);C.pipe(I),await new Promise(v=>{I.on("finish",v)})}}),this.dryRun||(p.reportInfo(0,`Package archive generated in ${de.pretty(r,u,de.Type.PATH)}`),p.reportJson({output:fe.fromPortablePath(u)}))})).exitCode()}};O0.paths=[["pack"]],O0.usage=nt.Usage({description:"generate a tarball from the active workspace",details:"\n This command will turn the active workspace into a compressed archive suitable for publishing. The archive will by default be stored at the root of the workspace (`package.tgz`).\n\n If the `-o,---out` is set the archive will be created at the specified path. The `%s` and `%v` variables can be used within the path and will be respectively replaced by the package name and version.\n ",examples:[["Create an archive from the active workspace","yarn pack"],["List the files that would be made part of the workspace's archive","yarn pack --dry-run"],["Name and output the archive in a dedicated folder","yarn pack --out /artifacts/%s-%v.tgz"]]});function svt(t,{workspace:e}){let r=t.replace("%s",ovt(e)).replace("%v",avt(e));return fe.toPortablePath(r)}function ovt(t){return t.manifest.name!==null?G.slugifyIdent(t.manifest.name):"package"}function avt(t){return t.manifest.version!==null?t.manifest.version:"unknown"}var lvt=["dependencies","devDependencies","peerDependencies"],cvt="workspace:",uvt=(t,e)=>{e.publishConfig&&(e.publishConfig.type&&(e.type=e.publishConfig.type),e.publishConfig.main&&(e.main=e.publishConfig.main),e.publishConfig.browser&&(e.browser=e.publishConfig.browser),e.publishConfig.module&&(e.module=e.publishConfig.module),e.publishConfig.exports&&(e.exports=e.publishConfig.exports),e.publishConfig.imports&&(e.imports=e.publishConfig.imports),e.publishConfig.bin&&(e.bin=e.publishConfig.bin));let r=t.project;for(let o of lvt)for(let a of t.manifest.getForScope(o).values()){let n=r.tryWorkspaceByDescriptor(a),u=G.parseRange(a.range);if(u.protocol===cvt)if(n===null){if(r.tryWorkspaceByIdent(a)===null)throw new Jt(21,`${G.prettyDescriptor(r.configuration,a)}: No local workspace found for this range`)}else{let A;G.areDescriptorsEqual(a,n.anchoredDescriptor)||u.selector==="*"?A=n.manifest.version??"0.0.0":u.selector==="~"||u.selector==="^"?A=`${u.selector}${n.manifest.version??"0.0.0"}`:A=u.selector;let p=o==="dependencies"?G.makeDescriptor(a,"unknown"):null,h=p!==null&&t.manifest.ensureDependencyMeta(p).optional?"optionalDependencies":o;e[h][G.stringifyIdent(a)]=A}}},Avt={hooks:{beforeWorkspacePacking:uvt},commands:[O0]},fvt=Avt;var dBe=Be("crypto"),mBe=$e(gBe()),yBe=Be("url");async function kvt(t,e,{access:r,tag:o,registry:a,gitHead:n}){let u=t.manifest.name,A=t.manifest.version,p=G.stringifyIdent(u),h=(0,dBe.createHash)("sha1").update(e).digest("hex"),C=mBe.default.fromData(e).toString(),I=r??EBe(t,u),v=await CBe(t),b=await CA.genPackageManifest(t),E=`${p}-${A}.tgz`,F=new yBe.URL(`${oc(a)}/${p}/-/${E}`);return{_id:p,_attachments:{[E]:{content_type:"application/octet-stream",data:e.toString("base64"),length:e.length}},name:p,access:I,["dist-tags"]:{[o]:A},versions:{[A]:{...b,_id:`${p}@${A}`,name:p,version:A,gitHead:n,dist:{shasum:h,integrity:C,tarball:F.toString()}}},readme:v}}async function Qvt(t){try{let{stdout:e}=await Ur.execvp("git",["rev-parse","--revs-only","HEAD"],{cwd:t});return e.trim()===""?void 0:e.trim()}catch{return}}function EBe(t,e){let r=t.project.configuration;return t.manifest.publishConfig&&typeof t.manifest.publishConfig.access=="string"?t.manifest.publishConfig.access:r.get("npmPublishAccess")!==null?r.get("npmPublishAccess"):e.scope?"restricted":"public"}async function CBe(t){let e=fe.toPortablePath(`${t.cwd}/README.md`),r=t.manifest.name,a=`# ${G.stringifyIdent(r)} -`;try{a=await oe.readFilePromise(e,"utf8")}catch(n){if(n.code==="ENOENT")return a;throw n}return a}var mG={npmAlwaysAuth:{description:"URL of the selected npm registry (note: npm enterprise isn't supported)",type:"BOOLEAN",default:!1},npmAuthIdent:{description:"Authentication identity for the npm registry (_auth in npm and yarn v1)",type:"SECRET",default:null},npmAuthToken:{description:"Authentication token for the npm registry (_authToken in npm and yarn v1)",type:"SECRET",default:null}},wBe={npmAuditRegistry:{description:"Registry to query for audit reports",type:"STRING",default:null},npmPublishRegistry:{description:"Registry to push packages to",type:"STRING",default:null},npmRegistryServer:{description:"URL of the selected npm registry (note: npm enterprise isn't supported)",type:"STRING",default:"https://registry.yarnpkg.com"}},Fvt={configuration:{...mG,...wBe,npmScopes:{description:"Settings per package scope",type:"MAP",valueDefinition:{description:"",type:"SHAPE",properties:{...mG,...wBe}}},npmRegistries:{description:"Settings per registry",type:"MAP",normalizeKeys:oc,valueDefinition:{description:"",type:"SHAPE",properties:{...mG}}}},fetchers:[fv,dl],resolvers:[pv,hv,gv]},Rvt=Fvt;var SG={};Vt(SG,{NpmAuditCommand:()=>U0,NpmInfoCommand:()=>_0,NpmLoginCommand:()=>H0,NpmLogoutCommand:()=>j0,NpmPublishCommand:()=>q0,NpmTagAddCommand:()=>Y0,NpmTagListCommand:()=>G0,NpmTagRemoveCommand:()=>W0,NpmWhoamiCommand:()=>K0,default:()=>Uvt,npmAuditTypes:()=>Rv,npmAuditUtils:()=>ZQ});Ye();Ye();qt();var BG=$e(Zo());Za();var Rv={};Vt(Rv,{Environment:()=>Qv,Severity:()=>Fv});var Qv=(o=>(o.All="all",o.Production="production",o.Development="development",o))(Qv||{}),Fv=(n=>(n.Info="info",n.Low="low",n.Moderate="moderate",n.High="high",n.Critical="critical",n))(Fv||{});var ZQ={};Vt(ZQ,{allSeverities:()=>ow,getPackages:()=>IG,getReportTree:()=>CG,getSeverityInclusions:()=>EG,getTopLevelDependencies:()=>wG});Ye();var IBe=$e(Jn());var ow=["info","low","moderate","high","critical"];function EG(t){if(typeof t>"u")return new Set(ow);let e=ow.indexOf(t),r=ow.slice(e);return new Set(r)}function CG(t){let e={},r={children:e};for(let[o,a]of _e.sortMap(Object.entries(t),n=>n[0]))for(let n of _e.sortMap(a,u=>`${u.id}`))e[`${o}/${n.id}`]={value:de.tuple(de.Type.IDENT,G.parseIdent(o)),children:{ID:typeof n.id<"u"&&{label:"ID",value:de.tuple(de.Type.ID,n.id)},Issue:{label:"Issue",value:de.tuple(de.Type.NO_HINT,n.title)},URL:typeof n.url<"u"&&{label:"URL",value:de.tuple(de.Type.URL,n.url)},Severity:{label:"Severity",value:de.tuple(de.Type.NO_HINT,n.severity)},["Vulnerable Versions"]:{label:"Vulnerable Versions",value:de.tuple(de.Type.RANGE,n.vulnerable_versions)},["Tree Versions"]:{label:"Tree Versions",children:[...n.versions].sort(IBe.default.compare).map(u=>({value:de.tuple(de.Type.REFERENCE,u)}))},Dependents:{label:"Dependents",children:_e.sortMap(n.dependents,u=>G.stringifyLocator(u)).map(u=>({value:de.tuple(de.Type.LOCATOR,u)}))}}};return r}function wG(t,e,{all:r,environment:o}){let a=[],n=r?t.workspaces:[e],u=["all","production"].includes(o),A=["all","development"].includes(o);for(let p of n)for(let h of p.anchoredPackage.dependencies.values())(p.manifest.devDependencies.has(h.identHash)?!A:!u)||a.push({workspace:p,dependency:h});return a}function IG(t,e,{recursive:r}){let o=new Map,a=new Set,n=[],u=(A,p)=>{let h=t.storedResolutions.get(p.descriptorHash);if(typeof h>"u")throw new Error("Assertion failed: The resolution should have been registered");if(!a.has(h))a.add(h);else return;let C=t.storedPackages.get(h);if(typeof C>"u")throw new Error("Assertion failed: The package should have been registered");if(G.ensureDevirtualizedLocator(C).reference.startsWith("npm:")&&C.version!==null){let v=G.stringifyIdent(C),b=_e.getMapWithDefault(o,v);_e.getArrayWithDefault(b,C.version).push(A)}if(r)for(let v of C.dependencies.values())n.push([C,v])};for(let{workspace:A,dependency:p}of e)n.push([A.anchoredLocator,p]);for(;n.length>0;){let[A,p]=n.shift();u(A,p)}return o}var U0=class extends ut{constructor(){super(...arguments);this.all=ge.Boolean("-A,--all",!1,{description:"Audit dependencies from all workspaces"});this.recursive=ge.Boolean("-R,--recursive",!1,{description:"Audit transitive dependencies as well"});this.environment=ge.String("--environment","all",{description:"Which environments to cover",validator:Vs(Qv)});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.noDeprecations=ge.Boolean("--no-deprecations",!1,{description:"Don't warn about deprecated packages"});this.severity=ge.String("--severity","info",{description:"Minimal severity requested for packages to be displayed",validator:Vs(Fv)});this.excludes=ge.Array("--exclude",[],{description:"Array of glob patterns of packages to exclude from audit"});this.ignores=ge.Array("--ignore",[],{description:"Array of glob patterns of advisory ID's to ignore in the audit report"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState();let n=wG(o,a,{all:this.all,environment:this.environment}),u=IG(o,n,{recursive:this.recursive}),A=Array.from(new Set([...r.get("npmAuditExcludePackages"),...this.excludes])),p=Object.create(null);for(let[N,U]of u)A.some(z=>BG.default.isMatch(N,z))||(p[N]=[...U.keys()]);let h=Zn.getAuditRegistry({configuration:r}),C,I=await AA.start({configuration:r,stdout:this.context.stdout},async()=>{let N=on.post("/-/npm/v1/security/advisories/bulk",p,{authType:on.AuthType.BEST_EFFORT,configuration:r,jsonResponse:!0,registry:h}),U=await Promise.all(this.noDeprecations?[]:Array.from(u,async([te,le])=>{let pe=await on.getPackageMetadata(G.parseIdent(te),{project:o});return _e.mapAndFilter(le.keys(),ue=>{let{deprecated:ye}=pe.versions[ue];return ye?[te,ue,ye]:_e.mapAndFilter.skip})})),z=await N;for(let[te,le,pe]of U.flat(1))Object.hasOwn(z,te)&&z[te].some(ue=>Qr.satisfiesWithPrereleases(le,ue.vulnerable_versions))||(z[te]??=[],z[te].push({id:`${te} (deprecation)`,title:pe.trim()||"This package has been deprecated.",severity:"moderate",vulnerable_versions:le}));C=z});if(I.hasErrors())return I.exitCode();let v=EG(this.severity),b=Array.from(new Set([...r.get("npmAuditIgnoreAdvisories"),...this.ignores])),E=Object.create(null);for(let[N,U]of Object.entries(C)){let z=U.filter(te=>!BG.default.isMatch(`${te.id}`,b)&&v.has(te.severity));z.length>0&&(E[N]=z.map(te=>{let le=u.get(N);if(typeof le>"u")throw new Error("Assertion failed: Expected the registry to only return packages that were requested");let pe=[...le.keys()].filter(ye=>Qr.satisfiesWithPrereleases(ye,te.vulnerable_versions)),ue=new Map;for(let ye of pe)for(let ae of le.get(ye))ue.set(ae.locatorHash,ae);return{...te,versions:pe,dependents:[...ue.values()]}}))}let F=Object.keys(E).length>0;return!this.json&&F?($s.emitTree(CG(E),{configuration:r,json:this.json,stdout:this.context.stdout,separators:2}),1):(await Lt.start({configuration:r,includeFooter:!1,json:this.json,stdout:this.context.stdout},async N=>{N.reportJson(C),F||N.reportInfo(1,"No audit suggestions")}),F?1:0)}};U0.paths=[["npm","audit"]],U0.usage=nt.Usage({description:"perform a vulnerability audit against the installed packages",details:` - This command checks for known security reports on the packages you use. The reports are by default extracted from the npm registry, and may or may not be relevant to your actual program (not all vulnerabilities affect all code paths). - - For consistency with our other commands the default is to only check the direct dependencies for the active workspace. To extend this search to all workspaces, use \`-A,--all\`. To extend this search to both direct and transitive dependencies, use \`-R,--recursive\`. - - Applying the \`--severity\` flag will limit the audit table to vulnerabilities of the corresponding severity and above. Valid values are ${ow.map(r=>`\`${r}\``).join(", ")}. - - If the \`--json\` flag is set, Yarn will print the output exactly as received from the registry. Regardless of this flag, the process will exit with a non-zero exit code if a report is found for the selected packages. - - If certain packages produce false positives for a particular environment, the \`--exclude\` flag can be used to exclude any number of packages from the audit. This can also be set in the configuration file with the \`npmAuditExcludePackages\` option. - - If particular advisories are needed to be ignored, the \`--ignore\` flag can be used with Advisory ID's to ignore any number of advisories in the audit report. This can also be set in the configuration file with the \`npmAuditIgnoreAdvisories\` option. - - To understand the dependency tree requiring vulnerable packages, check the raw report with the \`--json\` flag or use \`yarn why package\` to get more information as to who depends on them. - `,examples:[["Checks for known security issues with the installed packages. The output is a list of known issues.","yarn npm audit"],["Audit dependencies in all workspaces","yarn npm audit --all"],["Limit auditing to `dependencies` (excludes `devDependencies`)","yarn npm audit --environment production"],["Show audit report as valid JSON","yarn npm audit --json"],["Audit all direct and transitive dependencies","yarn npm audit --recursive"],["Output moderate (or more severe) vulnerabilities","yarn npm audit --severity moderate"],["Exclude certain packages","yarn npm audit --exclude package1 --exclude package2"],["Ignore specific advisories","yarn npm audit --ignore 1234567 --ignore 7654321"]]});Ye();Ye();Pt();qt();var vG=$e(Jn()),DG=Be("util"),_0=class extends ut{constructor(){super(...arguments);this.fields=ge.String("-f,--fields",{description:"A comma-separated list of manifest fields that should be displayed"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.packages=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o}=await St.find(r,this.context.cwd),a=typeof this.fields<"u"?new Set(["name",...this.fields.split(/\s*,\s*/)]):null,n=[],u=!1,A=await Lt.start({configuration:r,includeFooter:!1,json:this.json,stdout:this.context.stdout},async p=>{for(let h of this.packages){let C;if(h==="."){let le=o.topLevelWorkspace;if(!le.manifest.name)throw new it(`Missing ${de.pretty(r,"name",de.Type.CODE)} field in ${fe.fromPortablePath(V.join(le.cwd,dr.manifest))}`);C=G.makeDescriptor(le.manifest.name,"unknown")}else C=G.parseDescriptor(h);let I=on.getIdentUrl(C),v=PG(await on.get(I,{configuration:r,ident:C,jsonResponse:!0,customErrorMessage:on.customPackageError})),b=Object.keys(v.versions).sort(vG.default.compareLoose),F=v["dist-tags"].latest||b[b.length-1],N=Qr.validRange(C.range);if(N){let le=vG.default.maxSatisfying(b,N);le!==null?F=le:(p.reportWarning(0,`Unmet range ${G.prettyRange(r,C.range)}; falling back to the latest version`),u=!0)}else Object.hasOwn(v["dist-tags"],C.range)?F=v["dist-tags"][C.range]:C.range!=="unknown"&&(p.reportWarning(0,`Unknown tag ${G.prettyRange(r,C.range)}; falling back to the latest version`),u=!0);let U=v.versions[F],z={...v,...U,version:F,versions:b},te;if(a!==null){te={};for(let le of a){let pe=z[le];if(typeof pe<"u")te[le]=pe;else{p.reportWarning(1,`The ${de.pretty(r,le,de.Type.CODE)} field doesn't exist inside ${G.prettyIdent(r,C)}'s information`),u=!0;continue}}}else this.json||(delete z.dist,delete z.readme,delete z.users),te=z;p.reportJson(te),this.json||n.push(te)}});DG.inspect.styles.name="cyan";for(let p of n)(p!==n[0]||u)&&this.context.stdout.write(` -`),this.context.stdout.write(`${(0,DG.inspect)(p,{depth:1/0,colors:!0,compact:!1})} -`);return A.exitCode()}};_0.paths=[["npm","info"]],_0.usage=nt.Usage({category:"Npm-related commands",description:"show information about a package",details:"\n This command fetches information about a package from the npm registry and prints it in a tree format.\n\n The package does not have to be installed locally, but needs to have been published (in particular, local changes will be ignored even for workspaces).\n\n Append `@<range>` to the package argument to provide information specific to the latest version that satisfies the range or to the corresponding tagged version. If the range is invalid or if there is no version satisfying the range, the command will print a warning and fall back to the latest version.\n\n If the `-f,--fields` option is set, it's a comma-separated list of fields which will be used to only display part of the package information.\n\n By default, this command won't return the `dist`, `readme`, and `users` fields, since they are often very long. To explicitly request those fields, explicitly list them with the `--fields` flag or request the output in JSON mode.\n ",examples:[["Show all available information about react (except the `dist`, `readme`, and `users` fields)","yarn npm info react"],["Show all available information about react as valid JSON (including the `dist`, `readme`, and `users` fields)","yarn npm info react --json"],["Show all available information about react@16.12.0","yarn npm info react@16.12.0"],["Show all available information about react@next","yarn npm info react@next"],["Show the description of react","yarn npm info react --fields description"],["Show all available versions of react","yarn npm info react --fields versions"],["Show the readme of react","yarn npm info react --fields readme"],["Show a few fields of react","yarn npm info react --fields homepage,repository"]]});function PG(t){if(Array.isArray(t)){let e=[];for(let r of t)r=PG(r),r&&e.push(r);return e}else if(typeof t=="object"&&t!==null){let e={};for(let r of Object.keys(t)){if(r.startsWith("_"))continue;let o=PG(t[r]);o&&(e[r]=o)}return e}else return t||null}Ye();Ye();qt();var BBe=$e(u2()),H0=class extends ut{constructor(){super(...arguments);this.scope=ge.String("-s,--scope",{description:"Login to the registry configured for a given scope"});this.publish=ge.Boolean("--publish",!1,{description:"Login to the publish registry"});this.alwaysAuth=ge.Boolean("--always-auth",{description:"Set the npmAlwaysAuth configuration"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=await $Q({configuration:r,cwd:this.context.cwd,publish:this.publish,scope:this.scope});return(await Lt.start({configuration:r,stdout:this.context.stdout,includeFooter:!1},async n=>{let u=await Lvt({configuration:r,registry:o,report:n,stdin:this.context.stdin,stdout:this.context.stdout}),A=`/-/user/org.couchdb.user:${encodeURIComponent(u.name)}`,p=await on.put(A,u,{attemptedAs:u.name,configuration:r,registry:o,jsonResponse:!0,authType:on.AuthType.NO_AUTH});return await Tvt(o,p.token,{alwaysAuth:this.alwaysAuth,scope:this.scope}),n.reportInfo(0,"Successfully logged in")})).exitCode()}};H0.paths=[["npm","login"]],H0.usage=nt.Usage({category:"Npm-related commands",description:"store new login info to access the npm registry",details:"\n This command will ask you for your username, password, and 2FA One-Time-Password (when it applies). It will then modify your local configuration (in your home folder, never in the project itself) to reference the new tokens thus generated.\n\n Adding the `-s,--scope` flag will cause the authentication to be done against whatever registry is configured for the associated scope (see also `npmScopes`).\n\n Adding the `--publish` flag will cause the authentication to be done against the registry used when publishing the package (see also `publishConfig.registry` and `npmPublishRegistry`).\n ",examples:[["Login to the default registry","yarn npm login"],["Login to the registry linked to the @my-scope registry","yarn npm login --scope my-scope"],["Login to the publish registry for the current package","yarn npm login --publish"]]});async function $Q({scope:t,publish:e,configuration:r,cwd:o}){return t&&e?Zn.getScopeRegistry(t,{configuration:r,type:Zn.RegistryType.PUBLISH_REGISTRY}):t?Zn.getScopeRegistry(t,{configuration:r}):e?Zn.getPublishRegistry((await AC(r,o)).manifest,{configuration:r}):Zn.getDefaultRegistry({configuration:r})}async function Tvt(t,e,{alwaysAuth:r,scope:o}){let a=u=>A=>{let p=_e.isIndexableObject(A)?A:{},h=p[u],C=_e.isIndexableObject(h)?h:{};return{...p,[u]:{...C,...r!==void 0?{npmAlwaysAuth:r}:{},npmAuthToken:e}}},n=o?{npmScopes:a(o)}:{npmRegistries:a(t)};return await Ke.updateHomeConfiguration(n)}async function Lvt({configuration:t,registry:e,report:r,stdin:o,stdout:a}){r.reportInfo(0,`Logging in to ${de.pretty(t,e,de.Type.URL)}`);let n=!1;if(e.match(/^https:\/\/npm\.pkg\.github\.com(\/|$)/)&&(r.reportInfo(0,"You seem to be using the GitHub Package Registry. Tokens must be generated with the 'repo', 'write:packages', and 'read:packages' permissions."),n=!0),r.reportSeparator(),t.env.YARN_IS_TEST_ENV)return{name:t.env.YARN_INJECT_NPM_USER||"",password:t.env.YARN_INJECT_NPM_PASSWORD||""};let{username:u,password:A}=await(0,BBe.prompt)([{type:"input",name:"username",message:"Username:",required:!0,onCancel:()=>process.exit(130),stdin:o,stdout:a},{type:"password",name:"password",message:n?"Token:":"Password:",required:!0,onCancel:()=>process.exit(130),stdin:o,stdout:a}]);return r.reportSeparator(),{name:u,password:A}}Ye();Ye();qt();var aw=new Set(["npmAuthIdent","npmAuthToken"]),j0=class extends ut{constructor(){super(...arguments);this.scope=ge.String("-s,--scope",{description:"Logout of the registry configured for a given scope"});this.publish=ge.Boolean("--publish",!1,{description:"Logout of the publish registry"});this.all=ge.Boolean("-A,--all",!1,{description:"Logout of all registries"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=async()=>{let n=await $Q({configuration:r,cwd:this.context.cwd,publish:this.publish,scope:this.scope}),u=await Ke.find(this.context.cwd,this.context.plugins),A=G.makeIdent(this.scope??null,"pkg");return!Zn.getAuthConfiguration(n,{configuration:u,ident:A}).get("npmAuthToken")};return(await Lt.start({configuration:r,stdout:this.context.stdout},async n=>{if(this.all&&(await Ovt(),n.reportInfo(0,"Successfully logged out from everything")),this.scope){await vBe("npmScopes",this.scope),await o()?n.reportInfo(0,`Successfully logged out from ${this.scope}`):n.reportWarning(0,"Scope authentication settings removed, but some other ones settings still apply to it");return}let u=await $Q({configuration:r,cwd:this.context.cwd,publish:this.publish});await vBe("npmRegistries",u),await o()?n.reportInfo(0,`Successfully logged out from ${u}`):n.reportWarning(0,"Registry authentication settings removed, but some other ones settings still apply to it")})).exitCode()}};j0.paths=[["npm","logout"]],j0.usage=nt.Usage({category:"Npm-related commands",description:"logout of the npm registry",details:"\n This command will log you out by modifying your local configuration (in your home folder, never in the project itself) to delete all credentials linked to a registry.\n\n Adding the `-s,--scope` flag will cause the deletion to be done against whatever registry is configured for the associated scope (see also `npmScopes`).\n\n Adding the `--publish` flag will cause the deletion to be done against the registry used when publishing the package (see also `publishConfig.registry` and `npmPublishRegistry`).\n\n Adding the `-A,--all` flag will cause the deletion to be done against all registries and scopes.\n ",examples:[["Logout of the default registry","yarn npm logout"],["Logout of the @my-scope scope","yarn npm logout --scope my-scope"],["Logout of the publish registry for the current package","yarn npm logout --publish"],["Logout of all registries","yarn npm logout --all"]]});function Nvt(t,e){let r=t[e];if(!_e.isIndexableObject(r))return!1;let o=new Set(Object.keys(r));if([...aw].every(n=>!o.has(n)))return!1;for(let n of aw)o.delete(n);if(o.size===0)return t[e]=void 0,!0;let a={...r};for(let n of aw)delete a[n];return t[e]=a,!0}async function Ovt(){let t=e=>{let r=!1,o=_e.isIndexableObject(e)?{...e}:{};o.npmAuthToken&&(delete o.npmAuthToken,r=!0);for(let a of Object.keys(o))Nvt(o,a)&&(r=!0);if(Object.keys(o).length!==0)return r?o:e};return await Ke.updateHomeConfiguration({npmRegistries:t,npmScopes:t})}async function vBe(t,e){return await Ke.updateHomeConfiguration({[t]:r=>{let o=_e.isIndexableObject(r)?r:{};if(!Object.hasOwn(o,e))return r;let a=o[e],n=_e.isIndexableObject(a)?a:{},u=new Set(Object.keys(n));if([...aw].every(p=>!u.has(p)))return r;for(let p of aw)u.delete(p);if(u.size===0)return Object.keys(o).length===1?void 0:{...o,[e]:void 0};let A={};for(let p of aw)A[p]=void 0;return{...o,[e]:{...n,...A}}}})}Ye();qt();var q0=class extends ut{constructor(){super(...arguments);this.access=ge.String("--access",{description:"The access for the published package (public or restricted)"});this.tag=ge.String("--tag","latest",{description:"The tag on the registry that the package should be attached to"});this.tolerateRepublish=ge.Boolean("--tolerate-republish",!1,{description:"Warn and exit when republishing an already existing version of a package"});this.otp=ge.String("--otp",{description:"The OTP token to use with the command"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);if(a.manifest.private)throw new it("Private workspaces cannot be published");if(a.manifest.name===null||a.manifest.version===null)throw new it("Workspaces must have valid names and versions to be published on an external registry");await o.restoreInstallState();let n=a.manifest.name,u=a.manifest.version,A=Zn.getPublishRegistry(a.manifest,{configuration:r});return(await Lt.start({configuration:r,stdout:this.context.stdout},async h=>{if(this.tolerateRepublish)try{let C=await on.get(on.getIdentUrl(n),{configuration:r,registry:A,ident:n,jsonResponse:!0});if(!Object.hasOwn(C,"versions"))throw new Jt(15,'Registry returned invalid data for - missing "versions" field');if(Object.hasOwn(C.versions,u)){h.reportWarning(0,`Registry already knows about version ${u}; skipping.`);return}}catch(C){if(C.originalError?.response?.statusCode!==404)throw C}await un.maybeExecuteWorkspaceLifecycleScript(a,"prepublish",{report:h}),await CA.prepareForPack(a,{report:h},async()=>{let C=await CA.genPackList(a);for(let F of C)h.reportInfo(null,F);let I=await CA.genPackStream(a,C),v=await _e.bufferStream(I),b=await sw.getGitHead(a.cwd),E=await sw.makePublishBody(a,v,{access:this.access,tag:this.tag,registry:A,gitHead:b});await on.put(on.getIdentUrl(n),E,{configuration:r,registry:A,ident:n,otp:this.otp,jsonResponse:!0})}),h.reportInfo(0,"Package archive published")})).exitCode()}};q0.paths=[["npm","publish"]],q0.usage=nt.Usage({category:"Npm-related commands",description:"publish the active workspace to the npm registry",details:'\n This command will pack the active workspace into a fresh archive and upload it to the npm registry.\n\n The package will by default be attached to the `latest` tag on the registry, but this behavior can be overriden by using the `--tag` option.\n\n Note that for legacy reasons scoped packages are by default published with an access set to `restricted` (aka "private packages"). This requires you to register for a paid npm plan. In case you simply wish to publish a public scoped package to the registry (for free), just add the `--access public` flag. This behavior can be enabled by default through the `npmPublishAccess` settings.\n ',examples:[["Publish the active workspace","yarn npm publish"]]});Ye();qt();var DBe=$e(Jn());Ye();Pt();qt();var G0=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.package=ge.String({required:!1})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd),n;if(typeof this.package<"u")n=G.parseIdent(this.package);else{if(!a)throw new rr(o.cwd,this.context.cwd);if(!a.manifest.name)throw new it(`Missing 'name' field in ${fe.fromPortablePath(V.join(a.cwd,dr.manifest))}`);n=a.manifest.name}let u=await Tv(n,r),p={children:_e.sortMap(Object.entries(u),([h])=>h).map(([h,C])=>({value:de.tuple(de.Type.RESOLUTION,{descriptor:G.makeDescriptor(n,h),locator:G.makeLocator(n,C)})}))};return $s.emitTree(p,{configuration:r,json:this.json,stdout:this.context.stdout})}};G0.paths=[["npm","tag","list"]],G0.usage=nt.Usage({category:"Npm-related commands",description:"list all dist-tags of a package",details:` - This command will list all tags of a package from the npm registry. - - If the package is not specified, Yarn will default to the current workspace. - `,examples:[["List all tags of package `my-pkg`","yarn npm tag list my-pkg"]]});async function Tv(t,e){let r=`/-/package${on.getIdentUrl(t)}/dist-tags`;return on.get(r,{configuration:e,ident:t,jsonResponse:!0,customErrorMessage:on.customPackageError})}var Y0=class extends ut{constructor(){super(...arguments);this.package=ge.String();this.tag=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);let n=G.parseDescriptor(this.package,!0),u=n.range;if(!DBe.default.valid(u))throw new it(`The range ${de.pretty(r,n.range,de.Type.RANGE)} must be a valid semver version`);let A=Zn.getPublishRegistry(a.manifest,{configuration:r}),p=de.pretty(r,n,de.Type.IDENT),h=de.pretty(r,u,de.Type.RANGE),C=de.pretty(r,this.tag,de.Type.CODE);return(await Lt.start({configuration:r,stdout:this.context.stdout},async v=>{let b=await Tv(n,r);Object.hasOwn(b,this.tag)&&b[this.tag]===u&&v.reportWarning(0,`Tag ${C} is already set to version ${h}`);let E=`/-/package${on.getIdentUrl(n)}/dist-tags/${encodeURIComponent(this.tag)}`;await on.put(E,u,{configuration:r,registry:A,ident:n,jsonRequest:!0,jsonResponse:!0}),v.reportInfo(0,`Tag ${C} added to version ${h} of package ${p}`)})).exitCode()}};Y0.paths=[["npm","tag","add"]],Y0.usage=nt.Usage({category:"Npm-related commands",description:"add a tag for a specific version of a package",details:` - This command will add a tag to the npm registry for a specific version of a package. If the tag already exists, it will be overwritten. - `,examples:[["Add a `beta` tag for version `2.3.4-beta.4` of package `my-pkg`","yarn npm tag add my-pkg@2.3.4-beta.4 beta"]]});Ye();qt();var W0=class extends ut{constructor(){super(...arguments);this.package=ge.String();this.tag=ge.String()}async execute(){if(this.tag==="latest")throw new it("The 'latest' tag cannot be removed.");let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);let n=G.parseIdent(this.package),u=Zn.getPublishRegistry(a.manifest,{configuration:r}),A=de.pretty(r,this.tag,de.Type.CODE),p=de.pretty(r,n,de.Type.IDENT),h=await Tv(n,r);if(!Object.hasOwn(h,this.tag))throw new it(`${A} is not a tag of package ${p}`);return(await Lt.start({configuration:r,stdout:this.context.stdout},async I=>{let v=`/-/package${on.getIdentUrl(n)}/dist-tags/${encodeURIComponent(this.tag)}`;await on.del(v,{configuration:r,registry:u,ident:n,jsonResponse:!0}),I.reportInfo(0,`Tag ${A} removed from package ${p}`)})).exitCode()}};W0.paths=[["npm","tag","remove"]],W0.usage=nt.Usage({category:"Npm-related commands",description:"remove a tag from a package",details:` - This command will remove a tag from a package from the npm registry. - `,examples:[["Remove the `beta` tag from package `my-pkg`","yarn npm tag remove my-pkg beta"]]});Ye();Ye();qt();var K0=class extends ut{constructor(){super(...arguments);this.scope=ge.String("-s,--scope",{description:"Print username for the registry configured for a given scope"});this.publish=ge.Boolean("--publish",!1,{description:"Print username for the publish registry"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o;return this.scope&&this.publish?o=Zn.getScopeRegistry(this.scope,{configuration:r,type:Zn.RegistryType.PUBLISH_REGISTRY}):this.scope?o=Zn.getScopeRegistry(this.scope,{configuration:r}):this.publish?o=Zn.getPublishRegistry((await AC(r,this.context.cwd)).manifest,{configuration:r}):o=Zn.getDefaultRegistry({configuration:r}),(await Lt.start({configuration:r,stdout:this.context.stdout},async n=>{let u;try{u=await on.get("/-/whoami",{configuration:r,registry:o,authType:on.AuthType.ALWAYS_AUTH,jsonResponse:!0,ident:this.scope?G.makeIdent(this.scope,""):void 0})}catch(A){if(A.response?.statusCode===401||A.response?.statusCode===403){n.reportError(41,"Authentication failed - your credentials may have expired");return}else throw A}n.reportInfo(0,u.username)})).exitCode()}};K0.paths=[["npm","whoami"]],K0.usage=nt.Usage({category:"Npm-related commands",description:"display the name of the authenticated user",details:"\n Print the username associated with the current authentication settings to the standard output.\n\n When using `-s,--scope`, the username printed will be the one that matches the authentication settings of the registry associated with the given scope (those settings can be overriden using the `npmRegistries` map, and the registry associated with the scope is configured via the `npmScopes` map).\n\n When using `--publish`, the registry we'll select will by default be the one used when publishing packages (`publishConfig.registry` or `npmPublishRegistry` if available, otherwise we'll fallback to the regular `npmRegistryServer`).\n ",examples:[["Print username for the default registry","yarn npm whoami"],["Print username for the registry on a given scope","yarn npm whoami --scope company"]]});var Mvt={configuration:{npmPublishAccess:{description:"Default access of the published packages",type:"STRING",default:null},npmAuditExcludePackages:{description:"Array of glob patterns of packages to exclude from npm audit",type:"STRING",default:[],isArray:!0},npmAuditIgnoreAdvisories:{description:"Array of glob patterns of advisory IDs to exclude from npm audit",type:"STRING",default:[],isArray:!0}},commands:[U0,_0,H0,j0,q0,Y0,G0,W0,K0]},Uvt=Mvt;var TG={};Vt(TG,{PatchCommand:()=>J0,PatchCommitCommand:()=>z0,PatchFetcher:()=>Uv,PatchResolver:()=>_v,default:()=>nDt,patchUtils:()=>Dm});Ye();Ye();Pt();nA();var Dm={};Vt(Dm,{applyPatchFile:()=>tF,diffFolders:()=>FG,ensureUnpatchedDescriptor:()=>xG,ensureUnpatchedLocator:()=>nF,extractPackageToDisk:()=>QG,extractPatchFlags:()=>FBe,isParentRequired:()=>kG,isPatchDescriptor:()=>rF,isPatchLocator:()=>V0,loadPatchFiles:()=>Mv,makeDescriptor:()=>iF,makeLocator:()=>bG,makePatchHash:()=>RG,parseDescriptor:()=>Nv,parseLocator:()=>Ov,parsePatchFile:()=>Lv,unpatchDescriptor:()=>eDt,unpatchLocator:()=>tDt});Ye();Pt();Ye();Pt();var _vt=/^@@ -(\d+)(,(\d+))? \+(\d+)(,(\d+))? @@.*/;function lw(t){return V.relative(Bt.root,V.resolve(Bt.root,fe.toPortablePath(t)))}function Hvt(t){let e=t.trim().match(_vt);if(!e)throw new Error(`Bad header line: '${t}'`);return{original:{start:Math.max(Number(e[1]),1),length:Number(e[3]||1)},patched:{start:Math.max(Number(e[4]),1),length:Number(e[6]||1)}}}var jvt=420,qvt=493;var PBe=()=>({semverExclusivity:null,diffLineFromPath:null,diffLineToPath:null,oldMode:null,newMode:null,deletedFileMode:null,newFileMode:null,renameFrom:null,renameTo:null,beforeHash:null,afterHash:null,fromPath:null,toPath:null,hunks:null}),Gvt=t=>({header:Hvt(t),parts:[]}),Yvt={["@"]:"header",["-"]:"deletion",["+"]:"insertion",[" "]:"context",["\\"]:"pragma",undefined:"context"};function Wvt(t){let e=[],r=PBe(),o="parsing header",a=null,n=null;function u(){a&&(n&&(a.parts.push(n),n=null),r.hunks.push(a),a=null)}function A(){u(),e.push(r),r=PBe()}for(let p=0;p<t.length;p++){let h=t[p];if(o==="parsing header")if(h.startsWith("@@"))o="parsing hunks",r.hunks=[],p-=1;else if(h.startsWith("diff --git ")){r&&r.diffLineFromPath&&A();let C=h.match(/^diff --git a\/(.*?) b\/(.*?)\s*$/);if(!C)throw new Error(`Bad diff line: ${h}`);r.diffLineFromPath=C[1],r.diffLineToPath=C[2]}else if(h.startsWith("old mode "))r.oldMode=h.slice(9).trim();else if(h.startsWith("new mode "))r.newMode=h.slice(9).trim();else if(h.startsWith("deleted file mode "))r.deletedFileMode=h.slice(18).trim();else if(h.startsWith("new file mode "))r.newFileMode=h.slice(14).trim();else if(h.startsWith("rename from "))r.renameFrom=h.slice(12).trim();else if(h.startsWith("rename to "))r.renameTo=h.slice(10).trim();else if(h.startsWith("index ")){let C=h.match(/(\w+)\.\.(\w+)/);if(!C)continue;r.beforeHash=C[1],r.afterHash=C[2]}else h.startsWith("semver exclusivity ")?r.semverExclusivity=h.slice(19).trim():h.startsWith("--- ")?r.fromPath=h.slice(6).trim():h.startsWith("+++ ")&&(r.toPath=h.slice(6).trim());else{let C=Yvt[h[0]]||null;switch(C){case"header":u(),a=Gvt(h);break;case null:o="parsing header",A(),p-=1;break;case"pragma":{if(!h.startsWith("\\ No newline at end of file"))throw new Error(`Unrecognized pragma in patch file: ${h}`);if(!n)throw new Error("Bad parser state: No newline at EOF pragma encountered without context");n.noNewlineAtEndOfFile=!0}break;case"context":case"deletion":case"insertion":{if(!a)throw new Error("Bad parser state: Hunk lines encountered before hunk header");n&&n.type!==C&&(a.parts.push(n),n=null),n||(n={type:C,lines:[],noNewlineAtEndOfFile:!1}),n.lines.push(h.slice(1))}break;default:_e.assertNever(C);break}}}A();for(let{hunks:p}of e)if(p)for(let h of p)Vvt(h);return e}function Kvt(t){let e=[];for(let r of t){let{semverExclusivity:o,diffLineFromPath:a,diffLineToPath:n,oldMode:u,newMode:A,deletedFileMode:p,newFileMode:h,renameFrom:C,renameTo:I,beforeHash:v,afterHash:b,fromPath:E,toPath:F,hunks:N}=r,U=C?"rename":p?"file deletion":h?"file creation":N&&N.length>0?"patch":"mode change",z=null;switch(U){case"rename":{if(!C||!I)throw new Error("Bad parser state: rename from & to not given");e.push({type:"rename",semverExclusivity:o,fromPath:lw(C),toPath:lw(I)}),z=I}break;case"file deletion":{let te=a||E;if(!te)throw new Error("Bad parse state: no path given for file deletion");e.push({type:"file deletion",semverExclusivity:o,hunk:N&&N[0]||null,path:lw(te),mode:eF(p),hash:v})}break;case"file creation":{let te=n||F;if(!te)throw new Error("Bad parse state: no path given for file creation");e.push({type:"file creation",semverExclusivity:o,hunk:N&&N[0]||null,path:lw(te),mode:eF(h),hash:b})}break;case"patch":case"mode change":z=F||n;break;default:_e.assertNever(U);break}z&&u&&A&&u!==A&&e.push({type:"mode change",semverExclusivity:o,path:lw(z),oldMode:eF(u),newMode:eF(A)}),z&&N&&N.length&&e.push({type:"patch",semverExclusivity:o,path:lw(z),hunks:N,beforeHash:v,afterHash:b})}if(e.length===0)throw new Error("Unable to parse patch file: No changes found. Make sure the patch is a valid UTF8 encoded string");return e}function eF(t){let e=parseInt(t,8)&511;if(e!==jvt&&e!==qvt)throw new Error(`Unexpected file mode string: ${t}`);return e}function Lv(t){let e=t.split(/\n/g);return e[e.length-1]===""&&e.pop(),Kvt(Wvt(e))}function Vvt(t){let e=0,r=0;for(let{type:o,lines:a}of t.parts)switch(o){case"context":r+=a.length,e+=a.length;break;case"deletion":e+=a.length;break;case"insertion":r+=a.length;break;default:_e.assertNever(o);break}if(e!==t.header.original.length||r!==t.header.patched.length){let o=a=>a<0?a:`+${a}`;throw new Error(`hunk header integrity check failed (expected @@ ${o(t.header.original.length)} ${o(t.header.patched.length)} @@, got @@ ${o(e)} ${o(r)} @@)`)}}Ye();Pt();var cw=class extends Error{constructor(r,o){super(`Cannot apply hunk #${r+1}`);this.hunk=o}};async function uw(t,e,r){let o=await t.lstatPromise(e),a=await r();typeof a<"u"&&(e=a),await t.lutimesPromise(e,o.atime,o.mtime)}async function tF(t,{baseFs:e=new Tn,dryRun:r=!1,version:o=null}={}){for(let a of t)if(!(a.semverExclusivity!==null&&o!==null&&!Qr.satisfiesWithPrereleases(o,a.semverExclusivity)))switch(a.type){case"file deletion":if(r){if(!e.existsSync(a.path))throw new Error(`Trying to delete a file that doesn't exist: ${a.path}`)}else await uw(e,V.dirname(a.path),async()=>{await e.unlinkPromise(a.path)});break;case"rename":if(r){if(!e.existsSync(a.fromPath))throw new Error(`Trying to move a file that doesn't exist: ${a.fromPath}`)}else await uw(e,V.dirname(a.fromPath),async()=>{await uw(e,V.dirname(a.toPath),async()=>{await uw(e,a.fromPath,async()=>(await e.movePromise(a.fromPath,a.toPath),a.toPath))})});break;case"file creation":if(r){if(e.existsSync(a.path))throw new Error(`Trying to create a file that already exists: ${a.path}`)}else{let n=a.hunk?a.hunk.parts[0].lines.join(` -`)+(a.hunk.parts[0].noNewlineAtEndOfFile?"":` -`):"";await e.mkdirpPromise(V.dirname(a.path),{chmod:493,utimes:[vi.SAFE_TIME,vi.SAFE_TIME]}),await e.writeFilePromise(a.path,n,{mode:a.mode}),await e.utimesPromise(a.path,vi.SAFE_TIME,vi.SAFE_TIME)}break;case"patch":await uw(e,a.path,async()=>{await Xvt(a,{baseFs:e,dryRun:r})});break;case"mode change":{let u=(await e.statPromise(a.path)).mode;if(SBe(a.newMode)!==SBe(u))continue;await uw(e,a.path,async()=>{await e.chmodPromise(a.path,a.newMode)})}break;default:_e.assertNever(a);break}}function SBe(t){return(t&64)>0}function xBe(t){return t.replace(/\s+$/,"")}function Jvt(t,e){return xBe(t)===xBe(e)}async function Xvt({hunks:t,path:e},{baseFs:r,dryRun:o=!1}){let a=await r.statSync(e).mode,u=(await r.readFileSync(e,"utf8")).split(/\n/),A=[],p=0,h=0;for(let I of t){let v=Math.max(h,I.header.patched.start+p),b=Math.max(0,v-h),E=Math.max(0,u.length-v-I.header.original.length),F=Math.max(b,E),N=0,U=0,z=null;for(;N<=F;){if(N<=b&&(U=v-N,z=bBe(I,u,U),z!==null)){N=-N;break}if(N<=E&&(U=v+N,z=bBe(I,u,U),z!==null))break;N+=1}if(z===null)throw new cw(t.indexOf(I),I);A.push(z),p+=N,h=U+I.header.original.length}if(o)return;let C=0;for(let I of A)for(let v of I)switch(v.type){case"splice":{let b=v.index+C;u.splice(b,v.numToDelete,...v.linesToInsert),C+=v.linesToInsert.length-v.numToDelete}break;case"pop":u.pop();break;case"push":u.push(v.line);break;default:_e.assertNever(v);break}await r.writeFilePromise(e,u.join(` -`),{mode:a})}function bBe(t,e,r){let o=[];for(let a of t.parts)switch(a.type){case"context":case"deletion":{for(let n of a.lines){let u=e[r];if(u==null||!Jvt(u,n))return null;r+=1}a.type==="deletion"&&(o.push({type:"splice",index:r-a.lines.length,numToDelete:a.lines.length,linesToInsert:[]}),a.noNewlineAtEndOfFile&&o.push({type:"push",line:""}))}break;case"insertion":o.push({type:"splice",index:r,numToDelete:0,linesToInsert:a.lines}),a.noNewlineAtEndOfFile&&o.push({type:"pop"});break;default:_e.assertNever(a.type);break}return o}var $vt=/^builtin<([^>]+)>$/;function Aw(t,e){let{protocol:r,source:o,selector:a,params:n}=G.parseRange(t);if(r!=="patch:")throw new Error("Invalid patch range");if(o===null)throw new Error("Patch locators must explicitly define their source");let u=a?a.split(/&/).map(C=>fe.toPortablePath(C)):[],A=n&&typeof n.locator=="string"?G.parseLocator(n.locator):null,p=n&&typeof n.version=="string"?n.version:null,h=e(o);return{parentLocator:A,sourceItem:h,patchPaths:u,sourceVersion:p}}function rF(t){return t.range.startsWith("patch:")}function V0(t){return t.reference.startsWith("patch:")}function Nv(t){let{sourceItem:e,...r}=Aw(t.range,G.parseDescriptor);return{...r,sourceDescriptor:e}}function Ov(t){let{sourceItem:e,...r}=Aw(t.reference,G.parseLocator);return{...r,sourceLocator:e}}function eDt(t){let{sourceItem:e}=Aw(t.range,G.parseDescriptor);return e}function tDt(t){let{sourceItem:e}=Aw(t.reference,G.parseLocator);return e}function xG(t){if(!rF(t))return t;let{sourceItem:e}=Aw(t.range,G.parseDescriptor);return e}function nF(t){if(!V0(t))return t;let{sourceItem:e}=Aw(t.reference,G.parseLocator);return e}function kBe({parentLocator:t,sourceItem:e,patchPaths:r,sourceVersion:o,patchHash:a},n){let u=t!==null?{locator:G.stringifyLocator(t)}:{},A=typeof o<"u"?{version:o}:{},p=typeof a<"u"?{hash:a}:{};return G.makeRange({protocol:"patch:",source:n(e),selector:r.join("&"),params:{...A,...p,...u}})}function iF(t,{parentLocator:e,sourceDescriptor:r,patchPaths:o}){return G.makeDescriptor(t,kBe({parentLocator:e,sourceItem:r,patchPaths:o},G.stringifyDescriptor))}function bG(t,{parentLocator:e,sourcePackage:r,patchPaths:o,patchHash:a}){return G.makeLocator(t,kBe({parentLocator:e,sourceItem:r,sourceVersion:r.version,patchPaths:o,patchHash:a},G.stringifyLocator))}function QBe({onAbsolute:t,onRelative:e,onProject:r,onBuiltin:o},a){let n=a.lastIndexOf("!");n!==-1&&(a=a.slice(n+1));let u=a.match($vt);return u!==null?o(u[1]):a.startsWith("~/")?r(a.slice(2)):V.isAbsolute(a)?t(a):e(a)}function FBe(t){let e=t.lastIndexOf("!");return{optional:(e!==-1?new Set(t.slice(0,e).split(/!/)):new Set).has("optional")}}function kG(t){return QBe({onAbsolute:()=>!1,onRelative:()=>!0,onProject:()=>!1,onBuiltin:()=>!1},t)}async function Mv(t,e,r){let o=t!==null?await r.fetcher.fetch(t,r):null,a=o&&o.localPath?{packageFs:new gn(Bt.root),prefixPath:V.relative(Bt.root,o.localPath)}:o;o&&o!==a&&o.releaseFs&&o.releaseFs();let n=await _e.releaseAfterUseAsync(async()=>await Promise.all(e.map(async u=>{let A=FBe(u),p=await QBe({onAbsolute:async h=>await oe.readFilePromise(h,"utf8"),onRelative:async h=>{if(a===null)throw new Error("Assertion failed: The parent locator should have been fetched");return await a.packageFs.readFilePromise(V.join(a.prefixPath,h),"utf8")},onProject:async h=>await oe.readFilePromise(V.join(r.project.cwd,h),"utf8"),onBuiltin:async h=>await r.project.configuration.firstHook(C=>C.getBuiltinPatch,r.project,h)},u);return{...A,source:p}})));for(let u of n)typeof u.source=="string"&&(u.source=u.source.replace(/\r\n?/g,` -`));return n}async function QG(t,{cache:e,project:r}){let o=r.storedPackages.get(t.locatorHash);if(typeof o>"u")throw new Error("Assertion failed: Expected the package to be registered");let a=nF(t),n=r.storedChecksums,u=new Qi,A=await oe.mktempPromise(),p=V.join(A,"source"),h=V.join(A,"user"),C=V.join(A,".yarn-patch.json"),I=r.configuration.makeFetcher(),v=[];try{let b,E;if(t.locatorHash===a.locatorHash){let F=await I.fetch(t,{cache:e,project:r,fetcher:I,checksums:n,report:u});v.push(()=>F.releaseFs?.()),b=F,E=F}else b=await I.fetch(t,{cache:e,project:r,fetcher:I,checksums:n,report:u}),v.push(()=>b.releaseFs?.()),E=await I.fetch(t,{cache:e,project:r,fetcher:I,checksums:n,report:u}),v.push(()=>E.releaseFs?.());await Promise.all([oe.copyPromise(p,b.prefixPath,{baseFs:b.packageFs}),oe.copyPromise(h,E.prefixPath,{baseFs:E.packageFs}),oe.writeJsonPromise(C,{locator:G.stringifyLocator(t),version:o.version})])}finally{for(let b of v)b()}return oe.detachTemp(A),h}async function FG(t,e){let r=fe.fromPortablePath(t).replace(/\\/g,"/"),o=fe.fromPortablePath(e).replace(/\\/g,"/"),{stdout:a,stderr:n}=await Ur.execvp("git",["-c","core.safecrlf=false","diff","--src-prefix=a/","--dst-prefix=b/","--ignore-cr-at-eol","--full-index","--no-index","--no-renames","--text",r,o],{cwd:fe.toPortablePath(process.cwd()),env:{...process.env,GIT_CONFIG_NOSYSTEM:"1",HOME:"",XDG_CONFIG_HOME:"",USERPROFILE:""}});if(n.length>0)throw new Error(`Unable to diff directories. Make sure you have a recent version of 'git' available in PATH. -The following error was reported by 'git': -${n}`);let u=r.startsWith("/")?A=>A.slice(1):A=>A;return a.replace(new RegExp(`(a|b)(${_e.escapeRegExp(`/${u(r)}/`)})`,"g"),"$1/").replace(new RegExp(`(a|b)${_e.escapeRegExp(`/${u(o)}/`)}`,"g"),"$1/").replace(new RegExp(_e.escapeRegExp(`${r}/`),"g"),"").replace(new RegExp(_e.escapeRegExp(`${o}/`),"g"),"")}function RG(t,e){let r=[];for(let{source:o}of t){if(o===null)continue;let a=Lv(o);for(let n of a){let{semverExclusivity:u,...A}=n;u!==null&&e!==null&&!Qr.satisfiesWithPrereleases(e,u)||r.push(JSON.stringify(A))}}return wn.makeHash(`${3}`,...r).slice(0,6)}Ye();function RBe(t,{configuration:e,report:r}){for(let o of t.parts)for(let a of o.lines)switch(o.type){case"context":r.reportInfo(null,` ${de.pretty(e,a,"grey")}`);break;case"deletion":r.reportError(28,`- ${de.pretty(e,a,de.Type.REMOVED)}`);break;case"insertion":r.reportError(28,`+ ${de.pretty(e,a,de.Type.ADDED)}`);break;default:_e.assertNever(o.type)}}var Uv=class{supports(e,r){return!!V0(e)}getLocalPath(e,r){return null}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,[a,n,u]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${G.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the disk`),loader:()=>this.patchPackage(e,r),...r.cacheOptions});return{packageFs:a,releaseFs:n,prefixPath:G.getIdentVendorPath(e),localPath:this.getLocalPath(e,r),checksum:u}}async patchPackage(e,r){let{parentLocator:o,sourceLocator:a,sourceVersion:n,patchPaths:u}=Ov(e),A=await Mv(o,u,r),p=await oe.mktempPromise(),h=V.join(p,"current.zip"),C=await r.fetcher.fetch(a,r),I=G.getIdentVendorPath(e),v=new Ji(h,{create:!0,level:r.project.configuration.get("compressionLevel")});await _e.releaseAfterUseAsync(async()=>{await v.copyPromise(I,C.prefixPath,{baseFs:C.packageFs,stableSort:!0})},C.releaseFs),v.saveAndClose();for(let{source:b,optional:E}of A){if(b===null)continue;let F=new Ji(h,{level:r.project.configuration.get("compressionLevel")}),N=new gn(V.resolve(Bt.root,I),{baseFs:F});try{await tF(Lv(b),{baseFs:N,version:n})}catch(U){if(!(U instanceof cw))throw U;let z=r.project.configuration.get("enableInlineHunks"),te=!z&&!E?" (set enableInlineHunks for details)":"",le=`${G.prettyLocator(r.project.configuration,e)}: ${U.message}${te}`,pe=ue=>{!z||RBe(U.hunk,{configuration:r.project.configuration,report:ue})};if(F.discardAndClose(),E){r.report.reportWarningOnce(66,le,{reportExtra:pe});continue}else throw new Jt(66,le,pe)}F.saveAndClose()}return new Ji(h,{level:r.project.configuration.get("compressionLevel")})}};Ye();var _v=class{supportsDescriptor(e,r){return!!rF(e)}supportsLocator(e,r){return!!V0(e)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,o){let{patchPaths:a}=Nv(e);return a.every(n=>!kG(n))?e:G.bindDescriptor(e,{locator:G.stringifyLocator(r)})}getResolutionDependencies(e,r){let{sourceDescriptor:o}=Nv(e);return{sourceDescriptor:r.project.configuration.normalizeDependency(o)}}async getCandidates(e,r,o){if(!o.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let{parentLocator:a,patchPaths:n}=Nv(e),u=await Mv(a,n,o.fetchOptions),A=r.sourceDescriptor;if(typeof A>"u")throw new Error("Assertion failed: The dependency should have been resolved");let p=RG(u,A.version);return[bG(e,{parentLocator:a,sourcePackage:A,patchPaths:n,patchHash:p})]}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){let{sourceLocator:o}=Ov(e);return{...await r.resolver.resolve(o,r),...e}}};Ye();Pt();qt();var z0=class extends ut{constructor(){super(...arguments);this.save=ge.Boolean("-s,--save",!1,{description:"Add the patch to your resolution entries"});this.patchFolder=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState();let n=V.resolve(this.context.cwd,fe.toPortablePath(this.patchFolder)),u=V.join(n,"../source"),A=V.join(n,"../.yarn-patch.json");if(!oe.existsSync(u))throw new it("The argument folder didn't get created by 'yarn patch'");let p=await FG(u,n),h=await oe.readJsonPromise(A),C=G.parseLocator(h.locator,!0);if(!o.storedPackages.has(C.locatorHash))throw new it("No package found in the project for the given locator");if(!this.save){this.context.stdout.write(p);return}let I=r.get("patchFolder"),v=V.join(I,`${G.slugifyLocator(C)}.patch`);await oe.mkdirPromise(I,{recursive:!0}),await oe.writeFilePromise(v,p);let b=[],E=new Map;for(let F of o.storedPackages.values()){if(G.isVirtualLocator(F))continue;let N=F.dependencies.get(C.identHash);if(!N)continue;let U=G.ensureDevirtualizedDescriptor(N),z=xG(U),te=o.storedResolutions.get(z.descriptorHash);if(!te)throw new Error("Assertion failed: Expected the resolution to have been registered");if(!o.storedPackages.get(te))throw new Error("Assertion failed: Expected the package to have been registered");let pe=o.tryWorkspaceByLocator(F);if(pe)b.push(pe);else{let ue=o.originalPackages.get(F.locatorHash);if(!ue)throw new Error("Assertion failed: Expected the original package to have been registered");let ye=ue.dependencies.get(N.identHash);if(!ye)throw new Error("Assertion failed: Expected the original dependency to have been registered");E.set(ye.descriptorHash,ye)}}for(let F of b)for(let N of Ot.hardDependencies){let U=F.manifest[N].get(C.identHash);if(!U)continue;let z=iF(U,{parentLocator:null,sourceDescriptor:G.convertLocatorToDescriptor(C),patchPaths:[V.join(dr.home,V.relative(o.cwd,v))]});F.manifest[N].set(U.identHash,z)}for(let F of E.values()){let N=iF(F,{parentLocator:null,sourceDescriptor:G.convertLocatorToDescriptor(C),patchPaths:[V.join(dr.home,V.relative(o.cwd,v))]});o.topLevelWorkspace.manifest.resolutions.push({pattern:{descriptor:{fullName:G.stringifyIdent(N),description:F.range}},reference:N.range})}await o.persist()}};z0.paths=[["patch-commit"]],z0.usage=nt.Usage({description:"generate a patch out of a directory",details:"\n By default, this will print a patchfile on stdout based on the diff between the folder passed in and the original version of the package. Such file is suitable for consumption with the `patch:` protocol.\n\n With the `-s,--save` option set, the patchfile won't be printed on stdout anymore and will instead be stored within a local file (by default kept within `.yarn/patches`, but configurable via the `patchFolder` setting). A `resolutions` entry will also be added to your top-level manifest, referencing the patched package via the `patch:` protocol.\n\n Note that only folders generated by `yarn patch` are accepted as valid input for `yarn patch-commit`.\n "});Ye();Pt();qt();var J0=class extends ut{constructor(){super(...arguments);this.update=ge.Boolean("-u,--update",!1,{description:"Reapply local patches that already apply to this packages"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.package=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd),n=await Nr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState();let u=G.parseLocator(this.package);if(u.reference==="unknown"){let A=_e.mapAndFilter([...o.storedPackages.values()],p=>p.identHash!==u.identHash?_e.mapAndFilter.skip:G.isVirtualLocator(p)?_e.mapAndFilter.skip:V0(p)!==this.update?_e.mapAndFilter.skip:p);if(A.length===0)throw new it("No package found in the project for the given locator");if(A.length>1)throw new it(`Multiple candidate packages found; explicitly choose one of them (use \`yarn why <package>\` to get more information as to who depends on them): -${A.map(p=>` -- ${G.prettyLocator(r,p)}`).join("")}`);u=A[0]}if(!o.storedPackages.has(u.locatorHash))throw new it("No package found in the project for the given locator");await Lt.start({configuration:r,json:this.json,stdout:this.context.stdout},async A=>{let p=nF(u),h=await QG(u,{cache:n,project:o});A.reportJson({locator:G.stringifyLocator(p),path:fe.fromPortablePath(h)});let C=this.update?" along with its current modifications":"";A.reportInfo(0,`Package ${G.prettyLocator(r,p)} got extracted with success${C}!`),A.reportInfo(0,`You can now edit the following folder: ${de.pretty(r,fe.fromPortablePath(h),"magenta")}`),A.reportInfo(0,`Once you are done run ${de.pretty(r,`yarn patch-commit -s ${process.platform==="win32"?'"':""}${fe.fromPortablePath(h)}${process.platform==="win32"?'"':""}`,"cyan")} and Yarn will store a patchfile based on your changes.`)})}};J0.paths=[["patch"]],J0.usage=nt.Usage({description:"prepare a package for patching",details:"\n This command will cause a package to be extracted in a temporary directory intended to be editable at will.\n\n Once you're done with your changes, run `yarn patch-commit -s path` (with `path` being the temporary directory you received) to generate a patchfile and register it into your top-level manifest via the `patch:` protocol. Run `yarn patch-commit -h` for more details.\n\n Calling the command when you already have a patch won't import it by default (in other words, the default behavior is to reset existing patches). However, adding the `-u,--update` flag will import any current patch.\n "});var rDt={configuration:{enableInlineHunks:{description:"If true, the installs will print unmatched patch hunks",type:"BOOLEAN",default:!1},patchFolder:{description:"Folder where the patch files must be written",type:"ABSOLUTE_PATH",default:"./.yarn/patches"}},commands:[z0,J0],fetchers:[Uv],resolvers:[_v]},nDt=rDt;var OG={};Vt(OG,{PnpmLinker:()=>Hv,default:()=>lDt});Ye();Pt();qt();var Hv=class{getCustomDataKey(){return JSON.stringify({name:"PnpmLinker",version:3})}supportsPackage(e,r){return this.isEnabled(r)}async findPackageLocation(e,r){if(!this.isEnabled(r))throw new Error("Assertion failed: Expected the pnpm linker to be enabled");let o=this.getCustomDataKey(),a=r.project.linkersCustomData.get(o);if(!a)throw new it(`The project in ${de.pretty(r.project.configuration,`${r.project.cwd}/package.json`,de.Type.PATH)} doesn't seem to have been installed - running an install there might help`);let n=a.pathsByLocator.get(e.locatorHash);if(typeof n>"u")throw new it(`Couldn't find ${G.prettyLocator(r.project.configuration,e)} in the currently installed pnpm map - running an install might help`);return n.packageLocation}async findPackageLocator(e,r){if(!this.isEnabled(r))return null;let o=this.getCustomDataKey(),a=r.project.linkersCustomData.get(o);if(!a)throw new it(`The project in ${de.pretty(r.project.configuration,`${r.project.cwd}/package.json`,de.Type.PATH)} doesn't seem to have been installed - running an install there might help`);let n=e.match(/(^.*\/node_modules\/(@[^/]*\/)?[^/]+)(\/.*$)/);if(n){let p=a.locatorByPath.get(n[1]);if(p)return p}let u=e,A=e;do{A=u,u=V.dirname(A);let p=a.locatorByPath.get(A);if(p)return p}while(u!==A);return null}makeInstaller(e){return new LG(e)}isEnabled(e){return e.project.configuration.get("nodeLinker")==="pnpm"}},LG=class{constructor(e){this.opts=e;this.asyncActions=new _e.AsyncActions(10);this.customData={pathsByLocator:new Map,locatorByPath:new Map};this.indexFolderPromise=PD(oe,{indexPath:V.join(e.project.configuration.get("globalFolder"),"index")})}attachCustomData(e){}async installPackage(e,r,o){switch(e.linkType){case"SOFT":return this.installPackageSoft(e,r,o);case"HARD":return this.installPackageHard(e,r,o)}throw new Error("Assertion failed: Unsupported package link type")}async installPackageSoft(e,r,o){let a=V.resolve(r.packageFs.getRealPath(),r.prefixPath),n=this.opts.project.tryWorkspaceByLocator(e)?V.join(a,dr.nodeModules):null;return this.customData.pathsByLocator.set(e.locatorHash,{packageLocation:a,dependenciesLocation:n}),{packageLocation:a,buildRequest:null}}async installPackageHard(e,r,o){let a=iDt(e,{project:this.opts.project}),n=a.packageLocation;this.customData.locatorByPath.set(n,G.stringifyLocator(e)),this.customData.pathsByLocator.set(e.locatorHash,a),o.holdFetchResult(this.asyncActions.set(e.locatorHash,async()=>{await oe.mkdirPromise(n,{recursive:!0}),await oe.copyPromise(n,r.prefixPath,{baseFs:r.packageFs,overwrite:!1,linkStrategy:{type:"HardlinkFromIndex",indexPath:await this.indexFolderPromise,autoRepair:!0}})}));let A=G.isVirtualLocator(e)?G.devirtualizeLocator(e):e,p={manifest:await Ot.tryFind(r.prefixPath,{baseFs:r.packageFs})??new Ot,misc:{hasBindingGyp:mA.hasBindingGyp(r)}},h=this.opts.project.getDependencyMeta(A,e.version),C=mA.extractBuildRequest(e,p,h,{configuration:this.opts.project.configuration});return{packageLocation:n,buildRequest:C}}async attachInternalDependencies(e,r){if(this.opts.project.configuration.get("nodeLinker")!=="pnpm"||!TBe(e,{project:this.opts.project}))return;let o=this.customData.pathsByLocator.get(e.locatorHash);if(typeof o>"u")throw new Error(`Assertion failed: Expected the package to have been registered (${G.stringifyLocator(e)})`);let{dependenciesLocation:a}=o;!a||this.asyncActions.reduce(e.locatorHash,async n=>{await oe.mkdirPromise(a,{recursive:!0});let u=await sDt(a),A=new Map(u),p=[n],h=(I,v)=>{let b=v;TBe(v,{project:this.opts.project})||(this.opts.report.reportWarningOnce(0,"The pnpm linker doesn't support providing different versions to workspaces' peer dependencies"),b=G.devirtualizeLocator(v));let E=this.customData.pathsByLocator.get(b.locatorHash);if(typeof E>"u")throw new Error(`Assertion failed: Expected the package to have been registered (${G.stringifyLocator(v)})`);let F=G.stringifyIdent(I),N=V.join(a,F),U=V.relative(V.dirname(N),E.packageLocation),z=A.get(F);A.delete(F),p.push(Promise.resolve().then(async()=>{if(z){if(z.isSymbolicLink()&&await oe.readlinkPromise(N)===U)return;await oe.removePromise(N)}await oe.mkdirpPromise(V.dirname(N)),process.platform=="win32"&&this.opts.project.configuration.get("winLinkType")==="junctions"?await oe.symlinkPromise(E.packageLocation,N,"junction"):await oe.symlinkPromise(U,N)}))},C=!1;for(let[I,v]of r)I.identHash===e.identHash&&(C=!0),h(I,v);!C&&!this.opts.project.tryWorkspaceByLocator(e)&&h(G.convertLocatorToDescriptor(e),e),p.push(oDt(a,A)),await Promise.all(p)})}async attachExternalDependents(e,r){throw new Error("External dependencies haven't been implemented for the pnpm linker")}async finalizeInstall(){let e=NBe(this.opts.project);if(this.opts.project.configuration.get("nodeLinker")!=="pnpm")await oe.removePromise(e);else{let r;try{r=new Set(await oe.readdirPromise(e))}catch{r=new Set}for(let{dependenciesLocation:o}of this.customData.pathsByLocator.values()){if(!o)continue;let a=V.contains(e,o);if(a===null)continue;let[n]=a.split(V.sep);r.delete(n)}await Promise.all([...r].map(async o=>{await oe.removePromise(V.join(e,o))}))}return await this.asyncActions.wait(),await NG(e),this.opts.project.configuration.get("nodeLinker")!=="node-modules"&&await NG(LBe(this.opts.project)),{customData:this.customData}}};function LBe(t){return V.join(t.cwd,dr.nodeModules)}function NBe(t){return V.join(LBe(t),".store")}function iDt(t,{project:e}){let r=G.slugifyLocator(t),o=NBe(e),a=V.join(o,r,"package"),n=V.join(o,r,dr.nodeModules);return{packageLocation:a,dependenciesLocation:n}}function TBe(t,{project:e}){return!G.isVirtualLocator(t)||!e.tryWorkspaceByLocator(t)}async function sDt(t){let e=new Map,r=[];try{r=await oe.readdirPromise(t,{withFileTypes:!0})}catch(o){if(o.code!=="ENOENT")throw o}try{for(let o of r)if(!o.name.startsWith("."))if(o.name.startsWith("@")){let a=await oe.readdirPromise(V.join(t,o.name),{withFileTypes:!0});if(a.length===0)e.set(o.name,o);else for(let n of a)e.set(`${o.name}/${n.name}`,n)}else e.set(o.name,o)}catch(o){if(o.code!=="ENOENT")throw o}return e}async function oDt(t,e){let r=[],o=new Set;for(let a of e.keys()){r.push(oe.removePromise(V.join(t,a)));let n=G.tryParseIdent(a)?.scope;n&&o.add(`@${n}`)}return Promise.all(r).then(()=>Promise.all([...o].map(a=>NG(V.join(t,a)))))}async function NG(t){try{await oe.rmdirPromise(t)}catch(e){if(e.code!=="ENOENT"&&e.code!=="ENOTEMPTY")throw e}}var aDt={linkers:[Hv]},lDt=aDt;var GG={};Vt(GG,{StageCommand:()=>X0,default:()=>EDt,stageUtils:()=>oF});Ye();Pt();qt();Ye();Pt();var oF={};Vt(oF,{ActionType:()=>MG,checkConsensus:()=>sF,expandDirectory:()=>HG,findConsensus:()=>jG,findVcsRoot:()=>UG,genCommitMessage:()=>qG,getCommitPrefix:()=>OBe,isYarnFile:()=>_G});Pt();var MG=(n=>(n[n.CREATE=0]="CREATE",n[n.DELETE=1]="DELETE",n[n.ADD=2]="ADD",n[n.REMOVE=3]="REMOVE",n[n.MODIFY=4]="MODIFY",n))(MG||{});async function UG(t,{marker:e}){do if(!oe.existsSync(V.join(t,e)))t=V.dirname(t);else return t;while(t!=="/");return null}function _G(t,{roots:e,names:r}){if(r.has(V.basename(t)))return!0;do if(!e.has(t))t=V.dirname(t);else return!0;while(t!=="/");return!1}function HG(t){let e=[],r=[t];for(;r.length>0;){let o=r.pop(),a=oe.readdirSync(o);for(let n of a){let u=V.resolve(o,n);oe.lstatSync(u).isDirectory()?r.push(u):e.push(u)}}return e}function sF(t,e){let r=0,o=0;for(let a of t)a!=="wip"&&(e.test(a)?r+=1:o+=1);return r>=o}function jG(t){let e=sF(t,/^(\w\(\w+\):\s*)?\w+s/),r=sF(t,/^(\w\(\w+\):\s*)?[A-Z]/),o=sF(t,/^\w\(\w+\):/);return{useThirdPerson:e,useUpperCase:r,useComponent:o}}function OBe(t){return t.useComponent?"chore(yarn): ":""}var cDt=new Map([[0,"create"],[1,"delete"],[2,"add"],[3,"remove"],[4,"update"]]);function qG(t,e){let r=OBe(t),o=[],a=e.slice().sort((n,u)=>n[0]-u[0]);for(;a.length>0;){let[n,u]=a.shift(),A=cDt.get(n);t.useUpperCase&&o.length===0&&(A=`${A[0].toUpperCase()}${A.slice(1)}`),t.useThirdPerson&&(A+="s");let p=[u];for(;a.length>0&&a[0][0]===n;){let[,C]=a.shift();p.push(C)}p.sort();let h=p.shift();p.length===1?h+=" (and one other)":p.length>1&&(h+=` (and ${p.length} others)`),o.push(`${A} ${h}`)}return`${r}${o.join(", ")}`}var uDt="Commit generated via `yarn stage`",ADt=11;async function MBe(t){let{code:e,stdout:r}=await Ur.execvp("git",["log","-1","--pretty=format:%H"],{cwd:t});return e===0?r.trim():null}async function fDt(t,e){let r=[],o=e.filter(h=>V.basename(h.path)==="package.json");for(let{action:h,path:C}of o){let I=V.relative(t,C);if(h===4){let v=await MBe(t),{stdout:b}=await Ur.execvp("git",["show",`${v}:${I}`],{cwd:t,strict:!0}),E=await Ot.fromText(b),F=await Ot.fromFile(C),N=new Map([...F.dependencies,...F.devDependencies]),U=new Map([...E.dependencies,...E.devDependencies]);for(let[z,te]of U){let le=G.stringifyIdent(te),pe=N.get(z);pe?pe.range!==te.range&&r.push([4,`${le} to ${pe.range}`]):r.push([3,le])}for(let[z,te]of N)U.has(z)||r.push([2,G.stringifyIdent(te)])}else if(h===0){let v=await Ot.fromFile(C);v.name?r.push([0,G.stringifyIdent(v.name)]):r.push([0,"a package"])}else if(h===1){let v=await MBe(t),{stdout:b}=await Ur.execvp("git",["show",`${v}:${I}`],{cwd:t,strict:!0}),E=await Ot.fromText(b);E.name?r.push([1,G.stringifyIdent(E.name)]):r.push([1,"a package"])}else throw new Error("Assertion failed: Unsupported action type")}let{code:a,stdout:n}=await Ur.execvp("git",["log",`-${ADt}`,"--pretty=format:%s"],{cwd:t}),u=a===0?n.split(/\n/g).filter(h=>h!==""):[],A=jG(u);return qG(A,r)}var pDt={[0]:[" A ","?? "],[4]:[" M "],[1]:[" D "]},hDt={[0]:["A "],[4]:["M "],[1]:["D "]},UBe={async findRoot(t){return await UG(t,{marker:".git"})},async filterChanges(t,e,r,o){let{stdout:a}=await Ur.execvp("git",["status","-s"],{cwd:t,strict:!0}),n=a.toString().split(/\n/g),u=o?.staged?hDt:pDt;return[].concat(...n.map(p=>{if(p==="")return[];let h=p.slice(0,3),C=V.resolve(t,p.slice(3));if(!o?.staged&&h==="?? "&&p.endsWith("/"))return HG(C).map(I=>({action:0,path:I}));{let v=[0,4,1].find(b=>u[b].includes(h));return v!==void 0?[{action:v,path:C}]:[]}})).filter(p=>_G(p.path,{roots:e,names:r}))},async genCommitMessage(t,e){return await fDt(t,e)},async makeStage(t,e){let r=e.map(o=>fe.fromPortablePath(o.path));await Ur.execvp("git",["add","--",...r],{cwd:t,strict:!0})},async makeCommit(t,e,r){let o=e.map(a=>fe.fromPortablePath(a.path));await Ur.execvp("git",["add","-N","--",...o],{cwd:t,strict:!0}),await Ur.execvp("git",["commit","-m",`${r} - -${uDt} -`,"--",...o],{cwd:t,strict:!0})},async makeReset(t,e){let r=e.map(o=>fe.fromPortablePath(o.path));await Ur.execvp("git",["reset","HEAD","--",...r],{cwd:t,strict:!0})}};var gDt=[UBe],X0=class extends ut{constructor(){super(...arguments);this.commit=ge.Boolean("-c,--commit",!1,{description:"Commit the staged files"});this.reset=ge.Boolean("-r,--reset",!1,{description:"Remove all files from the staging area"});this.dryRun=ge.Boolean("-n,--dry-run",!1,{description:"Print the commit message and the list of modified files without staging / committing"});this.update=ge.Boolean("-u,--update",!1,{hidden:!0})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o}=await St.find(r,this.context.cwd),{driver:a,root:n}=await dDt(o.cwd),u=[r.get("cacheFolder"),r.get("globalFolder"),r.get("virtualFolder"),r.get("yarnPath")];await r.triggerHook(I=>I.populateYarnPaths,o,I=>{u.push(I)});let A=new Set;for(let I of u)for(let v of mDt(n,I))A.add(v);let p=new Set([r.get("rcFilename"),dr.lockfile,dr.manifest]),h=await a.filterChanges(n,A,p),C=await a.genCommitMessage(n,h);if(this.dryRun)if(this.commit)this.context.stdout.write(`${C} -`);else for(let I of h)this.context.stdout.write(`${fe.fromPortablePath(I.path)} -`);else if(this.reset){let I=await a.filterChanges(n,A,p,{staged:!0});I.length===0?this.context.stdout.write("No staged changes found!"):await a.makeReset(n,I)}else h.length===0?this.context.stdout.write("No changes found!"):this.commit?await a.makeCommit(n,h,C):(await a.makeStage(n,h),this.context.stdout.write(C))}};X0.paths=[["stage"]],X0.usage=nt.Usage({description:"add all yarn files to your vcs",details:"\n This command will add to your staging area the files belonging to Yarn (typically any modified `package.json` and `.yarnrc.yml` files, but also linker-generated files, cache data, etc). It will take your ignore list into account, so the cache files won't be added if the cache is ignored in a `.gitignore` file (assuming you use Git).\n\n Running `--reset` will instead remove them from the staging area (the changes will still be there, but won't be committed until you stage them back).\n\n Since the staging area is a non-existent concept in Mercurial, Yarn will always create a new commit when running this command on Mercurial repositories. You can get this behavior when using Git by using the `--commit` flag which will directly create a commit.\n ",examples:[["Adds all modified project files to the staging area","yarn stage"],["Creates a new commit containing all modified project files","yarn stage --commit"]]});async function dDt(t){let e=null,r=null;for(let o of gDt)if((r=await o.findRoot(t))!==null){e=o;break}if(e===null||r===null)throw new it("No stage driver has been found for your current project");return{driver:e,root:r}}function mDt(t,e){let r=[];if(e===null)return r;for(;;){(e===t||e.startsWith(`${t}/`))&&r.push(e);let o;try{o=oe.statSync(e)}catch{break}if(o.isSymbolicLink())e=V.resolve(V.dirname(e),oe.readlinkSync(e));else break}return r}var yDt={commands:[X0]},EDt=yDt;var YG={};Vt(YG,{default:()=>SDt});Ye();Ye();Pt();var jBe=$e(Jn());Ye();var _Be=$e(JH()),CDt="e8e1bd300d860104bb8c58453ffa1eb4",wDt="OFCNCOG2CU",HBe=async(t,e)=>{let r=G.stringifyIdent(t),a=IDt(e).initIndex("npm-search");try{return(await a.getObject(r,{attributesToRetrieve:["types"]})).types?.ts==="definitely-typed"}catch{return!1}},IDt=t=>(0,_Be.default)(wDt,CDt,{requester:{async send(r){try{let o=await rn.request(r.url,r.data||null,{configuration:t,headers:r.headers});return{content:o.body,isTimedOut:!1,status:o.statusCode}}catch(o){return{content:o.response.body,isTimedOut:!1,status:o.response.statusCode}}}}});var qBe=t=>t.scope?`${t.scope}__${t.name}`:`${t.name}`,BDt=async(t,e,r,o)=>{if(r.scope==="types")return;let{project:a}=t,{configuration:n}=a;if(!(n.get("tsEnableAutoTypes")??oe.existsSync(V.join(a.cwd,"tsconfig.json"))))return;let A=n.makeResolver(),p={project:a,resolver:A,report:new Qi};if(!await HBe(r,n))return;let C=qBe(r),I=G.parseRange(r.range).selector;if(!Qr.validRange(I)){let N=n.normalizeDependency(r),U=await A.getCandidates(N,{},p);I=G.parseRange(U[0].reference).selector}let v=jBe.default.coerce(I);if(v===null)return;let b=`${Jc.Modifier.CARET}${v.major}`,E=G.makeDescriptor(G.makeIdent("types",C),b),F=_e.mapAndFind(a.workspaces,N=>{let U=N.manifest.dependencies.get(r.identHash)?.descriptorHash,z=N.manifest.devDependencies.get(r.identHash)?.descriptorHash;if(U!==r.descriptorHash&&z!==r.descriptorHash)return _e.mapAndFind.skip;let te=[];for(let le of Ot.allDependencies){let pe=N.manifest[le].get(E.identHash);typeof pe>"u"||te.push([le,pe])}return te.length===0?_e.mapAndFind.skip:te});if(typeof F<"u")for(let[N,U]of F)t.manifest[N].set(U.identHash,U);else{try{let N=n.normalizeDependency(E);if((await A.getCandidates(N,{},p)).length===0)return}catch{return}t.manifest[Jc.Target.DEVELOPMENT].set(E.identHash,E)}},vDt=async(t,e,r)=>{if(r.scope==="types")return;let{project:o}=t,{configuration:a}=o;if(!(a.get("tsEnableAutoTypes")??oe.existsSync(V.join(o.cwd,"tsconfig.json"))))return;let u=qBe(r),A=G.makeIdent("types",u);for(let p of Ot.allDependencies)typeof t.manifest[p].get(A.identHash)>"u"||t.manifest[p].delete(A.identHash)},DDt=(t,e)=>{e.publishConfig&&e.publishConfig.typings&&(e.typings=e.publishConfig.typings),e.publishConfig&&e.publishConfig.types&&(e.types=e.publishConfig.types)},PDt={configuration:{tsEnableAutoTypes:{description:"Whether Yarn should auto-install @types/ dependencies on 'yarn add'",type:"BOOLEAN",isNullable:!0,default:null}},hooks:{afterWorkspaceDependencyAddition:BDt,afterWorkspaceDependencyRemoval:vDt,beforeWorkspacePacking:DDt}},SDt=PDt;var JG={};Vt(JG,{VersionApplyCommand:()=>Z0,VersionCheckCommand:()=>$0,VersionCommand:()=>eg,default:()=>WDt,versionUtils:()=>gw});Ye();Ye();qt();var gw={};Vt(gw,{Decision:()=>pw,applyPrerelease:()=>zBe,applyReleases:()=>zG,applyStrategy:()=>lF,clearVersionFiles:()=>WG,getUndecidedDependentWorkspaces:()=>qv,getUndecidedWorkspaces:()=>aF,openVersionFile:()=>hw,requireMoreDecisions:()=>qDt,resolveVersionFiles:()=>jv,suggestStrategy:()=>VG,updateVersionFiles:()=>KG,validateReleaseDecision:()=>fw});Ye();Pt();Ll();qt();var VBe=$e(KBe()),BA=$e(Jn()),jDt=/^(>=|[~^]|)(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$/,pw=(u=>(u.UNDECIDED="undecided",u.DECLINE="decline",u.MAJOR="major",u.MINOR="minor",u.PATCH="patch",u.PRERELEASE="prerelease",u))(pw||{});function fw(t){let e=BA.default.valid(t);return e||_e.validateEnum((0,VBe.default)(pw,"UNDECIDED"),t)}async function jv(t,{prerelease:e=null}={}){let r=new Map,o=t.configuration.get("deferredVersionFolder");if(!oe.existsSync(o))return r;let a=await oe.readdirPromise(o);for(let n of a){if(!n.endsWith(".yml"))continue;let u=V.join(o,n),A=await oe.readFilePromise(u,"utf8"),p=Ki(A);for(let[h,C]of Object.entries(p.releases||{})){if(C==="decline")continue;let I=G.parseIdent(h),v=t.tryWorkspaceByIdent(I);if(v===null)throw new Error(`Assertion failed: Expected a release definition file to only reference existing workspaces (${V.basename(u)} references ${h})`);if(v.manifest.version===null)throw new Error(`Assertion failed: Expected the workspace to have a version (${G.prettyLocator(t.configuration,v.anchoredLocator)})`);let b=v.manifest.raw.stableVersion??v.manifest.version,E=r.get(v),F=lF(b,fw(C));if(F===null)throw new Error(`Assertion failed: Expected ${b} to support being bumped via strategy ${C}`);let N=typeof E<"u"?BA.default.gt(F,E)?F:E:F;r.set(v,N)}}return e&&(r=new Map([...r].map(([n,u])=>[n,zBe(u,{current:n.manifest.version,prerelease:e})]))),r}async function WG(t){let e=t.configuration.get("deferredVersionFolder");!oe.existsSync(e)||await oe.removePromise(e)}async function KG(t,e){let r=new Set(e),o=t.configuration.get("deferredVersionFolder");if(!oe.existsSync(o))return;let a=await oe.readdirPromise(o);for(let n of a){if(!n.endsWith(".yml"))continue;let u=V.join(o,n),A=await oe.readFilePromise(u,"utf8"),p=Ki(A),h=p?.releases;if(!!h){for(let C of Object.keys(h)){let I=G.parseIdent(C),v=t.tryWorkspaceByIdent(I);(v===null||r.has(v))&&delete p.releases[C]}Object.keys(p.releases).length>0?await oe.changeFilePromise(u,Ba(new Ba.PreserveOrdering(p))):await oe.unlinkPromise(u)}}}async function hw(t,{allowEmpty:e=!1}={}){let r=t.configuration;if(r.projectCwd===null)throw new it("This command can only be run from within a Yarn project");let o=await ra.fetchRoot(r.projectCwd),a=o!==null?await ra.fetchBase(o,{baseRefs:r.get("changesetBaseRefs")}):null,n=o!==null?await ra.fetchChangedFiles(o,{base:a.hash,project:t}):[],u=r.get("deferredVersionFolder"),A=n.filter(b=>V.contains(u,b)!==null);if(A.length>1)throw new it(`Your current branch contains multiple versioning files; this isn't supported: -- ${A.map(b=>fe.fromPortablePath(b)).join(` -- `)}`);let p=new Set(_e.mapAndFilter(n,b=>{let E=t.tryWorkspaceByFilePath(b);return E===null?_e.mapAndFilter.skip:E}));if(A.length===0&&p.size===0&&!e)return null;let h=A.length===1?A[0]:V.join(u,`${wn.makeHash(Math.random().toString()).slice(0,8)}.yml`),C=oe.existsSync(h)?await oe.readFilePromise(h,"utf8"):"{}",I=Ki(C),v=new Map;for(let b of I.declined||[]){let E=G.parseIdent(b),F=t.getWorkspaceByIdent(E);v.set(F,"decline")}for(let[b,E]of Object.entries(I.releases||{})){let F=G.parseIdent(b),N=t.getWorkspaceByIdent(F);v.set(N,fw(E))}return{project:t,root:o,baseHash:a!==null?a.hash:null,baseTitle:a!==null?a.title:null,changedFiles:new Set(n),changedWorkspaces:p,releaseRoots:new Set([...p].filter(b=>b.manifest.version!==null)),releases:v,async saveAll(){let b={},E=[],F=[];for(let N of t.workspaces){if(N.manifest.version===null)continue;let U=G.stringifyIdent(N.anchoredLocator),z=v.get(N);z==="decline"?E.push(U):typeof z<"u"?b[U]=fw(z):p.has(N)&&F.push(U)}await oe.mkdirPromise(V.dirname(h),{recursive:!0}),await oe.changeFilePromise(h,Ba(new Ba.PreserveOrdering({releases:Object.keys(b).length>0?b:void 0,declined:E.length>0?E:void 0,undecided:F.length>0?F:void 0})))}}}function qDt(t){return aF(t).size>0||qv(t).length>0}function aF(t){let e=new Set;for(let r of t.changedWorkspaces)r.manifest.version!==null&&(t.releases.has(r)||e.add(r));return e}function qv(t,{include:e=new Set}={}){let r=[],o=new Map(_e.mapAndFilter([...t.releases],([n,u])=>u==="decline"?_e.mapAndFilter.skip:[n.anchoredLocator.locatorHash,n])),a=new Map(_e.mapAndFilter([...t.releases],([n,u])=>u!=="decline"?_e.mapAndFilter.skip:[n.anchoredLocator.locatorHash,n]));for(let n of t.project.workspaces)if(!(!e.has(n)&&(a.has(n.anchoredLocator.locatorHash)||o.has(n.anchoredLocator.locatorHash)))&&n.manifest.version!==null)for(let u of Ot.hardDependencies)for(let A of n.manifest.getForScope(u).values()){let p=t.project.tryWorkspaceByDescriptor(A);p!==null&&o.has(p.anchoredLocator.locatorHash)&&r.push([n,p])}return r}function VG(t,e){let r=BA.default.clean(e);for(let o of Object.values(pw))if(o!=="undecided"&&o!=="decline"&&BA.default.inc(t,o)===r)return o;return null}function lF(t,e){if(BA.default.valid(e))return e;if(t===null)throw new it(`Cannot apply the release strategy "${e}" unless the workspace already has a valid version`);if(!BA.default.valid(t))throw new it(`Cannot apply the release strategy "${e}" on a non-semver version (${t})`);let r=BA.default.inc(t,e);if(r===null)throw new it(`Cannot apply the release strategy "${e}" on the specified version (${t})`);return r}function zG(t,e,{report:r}){let o=new Map;for(let a of t.workspaces)for(let n of Ot.allDependencies)for(let u of a.manifest[n].values()){let A=t.tryWorkspaceByDescriptor(u);if(A===null||!e.has(A))continue;_e.getArrayWithDefault(o,A).push([a,n,u.identHash])}for(let[a,n]of e){let u=a.manifest.version;a.manifest.version=n,BA.default.prerelease(n)===null?delete a.manifest.raw.stableVersion:a.manifest.raw.stableVersion||(a.manifest.raw.stableVersion=u);let A=a.manifest.name!==null?G.stringifyIdent(a.manifest.name):null;r.reportInfo(0,`${G.prettyLocator(t.configuration,a.anchoredLocator)}: Bumped to ${n}`),r.reportJson({cwd:fe.fromPortablePath(a.cwd),ident:A,oldVersion:u,newVersion:n});let p=o.get(a);if(!(typeof p>"u"))for(let[h,C,I]of p){let v=h.manifest[C].get(I);if(typeof v>"u")throw new Error("Assertion failed: The dependency should have existed");let b=v.range,E=!1;if(b.startsWith(Xn.protocol)&&(b=b.slice(Xn.protocol.length),E=!0,b===a.relativeCwd))continue;let F=b.match(jDt);if(!F){r.reportWarning(0,`Couldn't auto-upgrade range ${b} (in ${G.prettyLocator(t.configuration,h.anchoredLocator)})`);continue}let N=`${F[1]}${n}`;E&&(N=`${Xn.protocol}${N}`);let U=G.makeDescriptor(v,N);h.manifest[C].set(I,U)}}}var GDt=new Map([["%n",{extract:t=>t.length>=1?[t[0],t.slice(1)]:null,generate:(t=0)=>`${t+1}`}]]);function zBe(t,{current:e,prerelease:r}){let o=new BA.default.SemVer(e),a=o.prerelease.slice(),n=[];o.prerelease=[],o.format()!==t&&(a.length=0);let u=!0,A=r.split(/\./g);for(let p of A){let h=GDt.get(p);if(typeof h>"u")n.push(p),a[0]===p?a.shift():u=!1;else{let C=u?h.extract(a):null;C!==null&&typeof C[0]=="number"?(n.push(h.generate(C[0])),a=C[1]):(n.push(h.generate()),u=!1)}}return o.prerelease&&(o.prerelease=[]),`${t}-${n.join(".")}`}var Z0=class extends ut{constructor(){super(...arguments);this.all=ge.Boolean("--all",!1,{description:"Apply the deferred version changes on all workspaces"});this.dryRun=ge.Boolean("--dry-run",!1,{description:"Print the versions without actually generating the package archive"});this.prerelease=ge.String("--prerelease",{description:"Add a prerelease identifier to new versions",tolerateBoolean:!0});this.recursive=ge.Boolean("-R,--recursive",{description:"Release the transitive workspaces as well"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd),n=await Nr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState({restoreResolutions:!1});let u=await Lt.start({configuration:r,json:this.json,stdout:this.context.stdout},async A=>{let p=this.prerelease?typeof this.prerelease!="boolean"?this.prerelease:"rc.%n":null,h=await jv(o,{prerelease:p}),C=new Map;if(this.all)C=h;else{let I=this.recursive?a.getRecursiveWorkspaceDependencies():[a];for(let v of I){let b=h.get(v);typeof b<"u"&&C.set(v,b)}}if(C.size===0){let I=h.size>0?" Did you want to add --all?":"";A.reportWarning(0,`The current workspace doesn't seem to require a version bump.${I}`);return}zG(o,C,{report:A}),this.dryRun||(p||(this.all?await WG(o):await KG(o,[...C.keys()])),A.reportSeparator())});return u.hasErrors()?u.exitCode():await o.installWithNewReport({json:this.json,stdout:this.context.stdout},{cache:n})}};Z0.paths=[["version","apply"]],Z0.usage=nt.Usage({category:"Release-related commands",description:"apply all the deferred version bumps at once",details:` - This command will apply the deferred version changes and remove their definitions from the repository. - - Note that if \`--prerelease\` is set, the given prerelease identifier (by default \`rc.%d\`) will be used on all new versions and the version definitions will be kept as-is. - - By default only the current workspace will be bumped, but you can configure this behavior by using one of: - - - \`--recursive\` to also apply the version bump on its dependencies - - \`--all\` to apply the version bump on all packages in the repository - - Note that this command will also update the \`workspace:\` references across all your local workspaces, thus ensuring that they keep referring to the same workspaces even after the version bump. - `,examples:[["Apply the version change to the local workspace","yarn version apply"],["Apply the version change to all the workspaces in the local workspace","yarn version apply --all"]]});Ye();Pt();qt();var cF=$e(Jn());var $0=class extends ut{constructor(){super(...arguments);this.interactive=ge.Boolean("-i,--interactive",{description:"Open an interactive interface used to set version bumps"})}async execute(){return this.interactive?await this.executeInteractive():await this.executeStandard()}async executeInteractive(){SC(this.context);let{Gem:r}=await Promise.resolve().then(()=>(cQ(),Bj)),{ScrollableItems:o}=await Promise.resolve().then(()=>(pQ(),fQ)),{FocusRequest:a}=await Promise.resolve().then(()=>(Dj(),zwe)),{useListInput:n}=await Promise.resolve().then(()=>(AQ(),Jwe)),{renderForm:u}=await Promise.resolve().then(()=>(mQ(),dQ)),{Box:A,Text:p}=await Promise.resolve().then(()=>$e(ic())),{default:h,useCallback:C,useState:I}=await Promise.resolve().then(()=>$e(sn())),v=await Ke.find(this.context.cwd,this.context.plugins),{project:b,workspace:E}=await St.find(v,this.context.cwd);if(!E)throw new rr(b.cwd,this.context.cwd);await b.restoreInstallState();let F=await hw(b);if(F===null||F.releaseRoots.size===0)return 0;if(F.root===null)throw new it("This command can only be run on Git repositories");let N=()=>h.createElement(A,{flexDirection:"row",paddingBottom:1},h.createElement(A,{flexDirection:"column",width:60},h.createElement(A,null,h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<up>"),"/",h.createElement(p,{bold:!0,color:"cyanBright"},"<down>")," to select workspaces.")),h.createElement(A,null,h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<left>"),"/",h.createElement(p,{bold:!0,color:"cyanBright"},"<right>")," to select release strategies."))),h.createElement(A,{flexDirection:"column"},h.createElement(A,{marginLeft:1},h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<enter>")," to save.")),h.createElement(A,{marginLeft:1},h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<ctrl+c>")," to abort.")))),U=({workspace:ye,active:ae,decision:Ie,setDecision:Fe})=>{let g=ye.manifest.raw.stableVersion??ye.manifest.version;if(g===null)throw new Error(`Assertion failed: The version should have been set (${G.prettyLocator(v,ye.anchoredLocator)})`);if(cF.default.prerelease(g)!==null)throw new Error(`Assertion failed: Prerelease identifiers shouldn't be found (${g})`);let Ee=["undecided","decline","patch","minor","major"];n(Ie,Ee,{active:ae,minus:"left",plus:"right",set:Fe});let De=Ie==="undecided"?h.createElement(p,{color:"yellow"},g):Ie==="decline"?h.createElement(p,{color:"green"},g):h.createElement(p,null,h.createElement(p,{color:"magenta"},g)," \u2192 ",h.createElement(p,{color:"green"},cF.default.valid(Ie)?Ie:cF.default.inc(g,Ie)));return h.createElement(A,{flexDirection:"column"},h.createElement(A,null,h.createElement(p,null,G.prettyLocator(v,ye.anchoredLocator)," - ",De)),h.createElement(A,null,Ee.map(ce=>h.createElement(A,{key:ce,paddingLeft:2},h.createElement(p,null,h.createElement(r,{active:ce===Ie})," ",ce)))))},z=ye=>{let ae=new Set(F.releaseRoots),Ie=new Map([...ye].filter(([Fe])=>ae.has(Fe)));for(;;){let Fe=qv({project:F.project,releases:Ie}),g=!1;if(Fe.length>0){for(let[Ee]of Fe)if(!ae.has(Ee)){ae.add(Ee),g=!0;let De=ye.get(Ee);typeof De<"u"&&Ie.set(Ee,De)}}if(!g)break}return{relevantWorkspaces:ae,relevantReleases:Ie}},te=()=>{let[ye,ae]=I(()=>new Map(F.releases)),Ie=C((Fe,g)=>{let Ee=new Map(ye);g!=="undecided"?Ee.set(Fe,g):Ee.delete(Fe);let{relevantReleases:De}=z(Ee);ae(De)},[ye,ae]);return[ye,Ie]},le=({workspaces:ye,releases:ae})=>{let Ie=[];Ie.push(`${ye.size} total`);let Fe=0,g=0;for(let Ee of ye){let De=ae.get(Ee);typeof De>"u"?g+=1:De!=="decline"&&(Fe+=1)}return Ie.push(`${Fe} release${Fe===1?"":"s"}`),Ie.push(`${g} remaining`),h.createElement(p,{color:"yellow"},Ie.join(", "))},ue=await u(({useSubmit:ye})=>{let[ae,Ie]=te();ye(ae);let{relevantWorkspaces:Fe}=z(ae),g=new Set([...Fe].filter(ne=>!F.releaseRoots.has(ne))),[Ee,De]=I(0),ce=C(ne=>{switch(ne){case a.BEFORE:De(Ee-1);break;case a.AFTER:De(Ee+1);break}},[Ee,De]);return h.createElement(A,{flexDirection:"column"},h.createElement(N,null),h.createElement(A,null,h.createElement(p,{wrap:"wrap"},"The following files have been modified in your local checkout.")),h.createElement(A,{flexDirection:"column",marginTop:1,paddingLeft:2},[...F.changedFiles].map(ne=>h.createElement(A,{key:ne},h.createElement(p,null,h.createElement(p,{color:"grey"},fe.fromPortablePath(F.root)),fe.sep,fe.relative(fe.fromPortablePath(F.root),fe.fromPortablePath(ne)))))),F.releaseRoots.size>0&&h.createElement(h.Fragment,null,h.createElement(A,{marginTop:1},h.createElement(p,{wrap:"wrap"},"Because of those files having been modified, the following workspaces may need to be released again (note that private workspaces are also shown here, because even though they won't be published, releasing them will allow us to flag their dependents for potential re-release):")),g.size>3?h.createElement(A,{marginTop:1},h.createElement(le,{workspaces:F.releaseRoots,releases:ae})):null,h.createElement(A,{marginTop:1,flexDirection:"column"},h.createElement(o,{active:Ee%2===0,radius:1,size:2,onFocusRequest:ce},[...F.releaseRoots].map(ne=>h.createElement(U,{key:ne.cwd,workspace:ne,decision:ae.get(ne)||"undecided",setDecision:ee=>Ie(ne,ee)}))))),g.size>0?h.createElement(h.Fragment,null,h.createElement(A,{marginTop:1},h.createElement(p,{wrap:"wrap"},"The following workspaces depend on other workspaces that have been marked for release, and thus may need to be released as well:")),h.createElement(A,null,h.createElement(p,null,"(Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<tab>")," to move the focus between the workspace groups.)")),g.size>5?h.createElement(A,{marginTop:1},h.createElement(le,{workspaces:g,releases:ae})):null,h.createElement(A,{marginTop:1,flexDirection:"column"},h.createElement(o,{active:Ee%2===1,radius:2,size:2,onFocusRequest:ce},[...g].map(ne=>h.createElement(U,{key:ne.cwd,workspace:ne,decision:ae.get(ne)||"undecided",setDecision:ee=>Ie(ne,ee)}))))):null)},{versionFile:F},{stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr});if(typeof ue>"u")return 1;F.releases.clear();for(let[ye,ae]of ue)F.releases.set(ye,ae);await F.saveAll()}async executeStandard(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);return await o.restoreInstallState(),(await Lt.start({configuration:r,stdout:this.context.stdout},async u=>{let A=await hw(o);if(A===null||A.releaseRoots.size===0)return;if(A.root===null)throw new it("This command can only be run on Git repositories");if(u.reportInfo(0,`Your PR was started right after ${de.pretty(r,A.baseHash.slice(0,7),"yellow")} ${de.pretty(r,A.baseTitle,"magenta")}`),A.changedFiles.size>0){u.reportInfo(0,"You have changed the following files since then:"),u.reportSeparator();for(let v of A.changedFiles)u.reportInfo(null,`${de.pretty(r,fe.fromPortablePath(A.root),"gray")}${fe.sep}${fe.relative(fe.fromPortablePath(A.root),fe.fromPortablePath(v))}`)}let p=!1,h=!1,C=aF(A);if(C.size>0){p||u.reportSeparator();for(let v of C)u.reportError(0,`${G.prettyLocator(r,v.anchoredLocator)} has been modified but doesn't have a release strategy attached`);p=!0}let I=qv(A);for(let[v,b]of I)h||u.reportSeparator(),u.reportError(0,`${G.prettyLocator(r,v.anchoredLocator)} doesn't have a release strategy attached, but depends on ${G.prettyWorkspace(r,b)} which is planned for release.`),h=!0;(p||h)&&(u.reportSeparator(),u.reportInfo(0,"This command detected that at least some workspaces have received modifications without explicit instructions as to how they had to be released (if needed)."),u.reportInfo(0,"To correct these errors, run `yarn version check --interactive` then follow the instructions."))})).exitCode()}};$0.paths=[["version","check"]],$0.usage=nt.Usage({category:"Release-related commands",description:"check that all the relevant packages have been bumped",details:"\n **Warning:** This command currently requires Git.\n\n This command will check that all the packages covered by the files listed in argument have been properly bumped or declined to bump.\n\n In the case of a bump, the check will also cover transitive packages - meaning that should `Foo` be bumped, a package `Bar` depending on `Foo` will require a decision as to whether `Bar` will need to be bumped. This check doesn't cross packages that have declined to bump.\n\n In case no arguments are passed to the function, the list of modified files will be generated by comparing the HEAD against `master`.\n ",examples:[["Check whether the modified packages need a bump","yarn version check"]]});Ye();qt();var uF=$e(Jn());var eg=class extends ut{constructor(){super(...arguments);this.deferred=ge.Boolean("-d,--deferred",{description:"Prepare the version to be bumped during the next release cycle"});this.immediate=ge.Boolean("-i,--immediate",{description:"Bump the version immediately"});this.strategy=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);let n=r.get("preferDeferredVersions");this.deferred&&(n=!0),this.immediate&&(n=!1);let u=uF.default.valid(this.strategy),A=this.strategy==="decline",p;if(u)if(a.manifest.version!==null){let C=VG(a.manifest.version,this.strategy);C!==null?p=C:p=this.strategy}else p=this.strategy;else{let C=a.manifest.version;if(!A){if(C===null)throw new it("Can't bump the version if there wasn't a version to begin with - use 0.0.0 as initial version then run the command again.");if(typeof C!="string"||!uF.default.valid(C))throw new it(`Can't bump the version (${C}) if it's not valid semver`)}p=fw(this.strategy)}if(!n){let I=(await jv(o)).get(a);if(typeof I<"u"&&p!=="decline"){let v=lF(a.manifest.version,p);if(uF.default.lt(v,I))throw new it(`Can't bump the version to one that would be lower than the current deferred one (${I})`)}}let h=await hw(o,{allowEmpty:!0});return h.releases.set(a,p),await h.saveAll(),n?0:await this.cli.run(["version","apply"])}};eg.paths=[["version"]],eg.usage=nt.Usage({category:"Release-related commands",description:"apply a new version to the current package",details:"\n This command will bump the version number for the given package, following the specified strategy:\n\n - If `major`, the first number from the semver range will be increased (`X.0.0`).\n - If `minor`, the second number from the semver range will be increased (`0.X.0`).\n - If `patch`, the third number from the semver range will be increased (`0.0.X`).\n - If prefixed by `pre` (`premajor`, ...), a `-0` suffix will be set (`0.0.0-0`).\n - If `prerelease`, the suffix will be increased (`0.0.0-X`); the third number from the semver range will also be increased if there was no suffix in the previous version.\n - If `decline`, the nonce will be increased for `yarn version check` to pass without version bump.\n - If a valid semver range, it will be used as new version.\n - If unspecified, Yarn will ask you for guidance.\n\n For more information about the `--deferred` flag, consult our documentation (https://yarnpkg.com/features/release-workflow#deferred-versioning).\n ",examples:[["Immediately bump the version to the next major","yarn version major"],["Prepare the version to be bumped to the next major","yarn version major --deferred"]]});var YDt={configuration:{deferredVersionFolder:{description:"Folder where are stored the versioning files",type:"ABSOLUTE_PATH",default:"./.yarn/versions"},preferDeferredVersions:{description:"If true, running `yarn version` will assume the `--deferred` flag unless `--immediate` is set",type:"BOOLEAN",default:!1}},commands:[Z0,$0,eg]},WDt=YDt;var XG={};Vt(XG,{WorkspacesFocusCommand:()=>tg,WorkspacesForeachCommand:()=>sp,default:()=>zDt});Ye();Ye();qt();var tg=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.production=ge.Boolean("--production",!1,{description:"Only install regular dependencies by omitting dev dependencies"});this.all=ge.Boolean("-A,--all",!1,{description:"Install the entire project"});this.workspaces=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd),n=await Nr.find(r);await o.restoreInstallState({restoreResolutions:!1});let u;if(this.all)u=new Set(o.workspaces);else if(this.workspaces.length===0){if(!a)throw new rr(o.cwd,this.context.cwd);u=new Set([a])}else u=new Set(this.workspaces.map(A=>o.getWorkspaceByIdent(G.parseIdent(A))));for(let A of u)for(let p of this.production?["dependencies"]:Ot.hardDependencies)for(let h of A.manifest.getForScope(p).values()){let C=o.tryWorkspaceByDescriptor(h);C!==null&&u.add(C)}for(let A of o.workspaces)u.has(A)?this.production&&A.manifest.devDependencies.clear():(A.manifest.installConfig=A.manifest.installConfig||{},A.manifest.installConfig.selfReferences=!1,A.manifest.dependencies.clear(),A.manifest.devDependencies.clear(),A.manifest.peerDependencies.clear(),A.manifest.scripts.clear());return await o.installWithNewReport({json:this.json,stdout:this.context.stdout},{cache:n,persistProject:!1})}};tg.paths=[["workspaces","focus"]],tg.usage=nt.Usage({category:"Workspace-related commands",description:"install a single workspace and its dependencies",details:"\n This command will run an install as if the specified workspaces (and all other workspaces they depend on) were the only ones in the project. If no workspaces are explicitly listed, the active one will be assumed.\n\n Note that this command is only very moderately useful when using zero-installs, since the cache will contain all the packages anyway - meaning that the only difference between a full install and a focused install would just be a few extra lines in the `.pnp.cjs` file, at the cost of introducing an extra complexity.\n\n If the `-A,--all` flag is set, the entire project will be installed. Combine with `--production` to replicate the old `yarn install --production`.\n "});Ye();Ye();Ye();qt();var dw=$e(Zo()),XBe=$e(nd());Za();var sp=class extends ut{constructor(){super(...arguments);this.from=ge.Array("--from",{description:"An array of glob pattern idents or paths from which to base any recursion"});this.all=ge.Boolean("-A,--all",{description:"Run the command on all workspaces of a project"});this.recursive=ge.Boolean("-R,--recursive",{description:"Run the command on the current workspace and all of its recursive dependencies"});this.worktree=ge.Boolean("-W,--worktree",{description:"Run the command on all workspaces of the current worktree"});this.verbose=ge.Boolean("-v,--verbose",{description:"Prefix each output line with the name of the originating workspace"});this.parallel=ge.Boolean("-p,--parallel",!1,{description:"Run the commands in parallel"});this.interlaced=ge.Boolean("-i,--interlaced",!1,{description:"Print the output of commands in real-time instead of buffering it"});this.jobs=ge.String("-j,--jobs",{description:"The maximum number of parallel tasks that the execution will be limited to; or `unlimited`",validator:TT([Vs(["unlimited"]),rd(RT(),[NT(),LT(1)])])});this.topological=ge.Boolean("-t,--topological",!1,{description:"Run the command after all workspaces it depends on (regular) have finished"});this.topologicalDev=ge.Boolean("--topological-dev",!1,{description:"Run the command after all workspaces it depends on (regular + dev) have finished"});this.include=ge.Array("--include",[],{description:"An array of glob pattern idents or paths; only matching workspaces will be traversed"});this.exclude=ge.Array("--exclude",[],{description:"An array of glob pattern idents or paths; matching workspaces won't be traversed"});this.publicOnly=ge.Boolean("--no-private",{description:"Avoid running the command on private workspaces"});this.since=ge.String("--since",{description:"Only include workspaces that have been changed since the specified ref.",tolerateBoolean:!0});this.dryRun=ge.Boolean("-n,--dry-run",{description:"Print the commands that would be run, without actually running them"});this.commandName=ge.String();this.args=ge.Proxy()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await St.find(r,this.context.cwd);if(!this.all&&!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState();let n=this.cli.process([this.commandName,...this.args]),u=n.path.length===1&&n.path[0]==="run"&&typeof n.scriptName<"u"?n.scriptName:null;if(n.path.length===0)throw new it("Invalid subcommand name for iteration - use the 'run' keyword if you wish to execute a script");let A=ae=>{!this.dryRun||this.context.stdout.write(`${ae} -`)},p=()=>{let ae=this.from.map(Ie=>dw.default.matcher(Ie));return o.workspaces.filter(Ie=>{let Fe=G.stringifyIdent(Ie.anchoredLocator),g=Ie.relativeCwd;return ae.some(Ee=>Ee(Fe)||Ee(g))})},h=[];if(this.since?(A("Option --since is set; selecting the changed workspaces as root for workspace selection"),h=Array.from(await ra.fetchChangedWorkspaces({ref:this.since,project:o}))):this.from?(A("Option --from is set; selecting the specified workspaces"),h=[...p()]):this.worktree?(A("Option --worktree is set; selecting the current workspace"),h=[a]):this.recursive?(A("Option --recursive is set; selecting the current workspace"),h=[a]):this.all&&(A("Option --all is set; selecting all workspaces"),h=[...o.workspaces]),this.dryRun&&!this.all){for(let ae of h)A(` -- ${ae.relativeCwd} - ${G.prettyLocator(r,ae.anchoredLocator)}`);h.length>0&&A("")}let C;if(this.recursive?this.since?(A("Option --recursive --since is set; recursively selecting all dependent workspaces"),C=new Set(h.map(ae=>[...ae.getRecursiveWorkspaceDependents()]).flat())):(A("Option --recursive is set; recursively selecting all transitive dependencies"),C=new Set(h.map(ae=>[...ae.getRecursiveWorkspaceDependencies()]).flat())):this.worktree?(A("Option --worktree is set; recursively selecting all nested workspaces"),C=new Set(h.map(ae=>[...ae.getRecursiveWorkspaceChildren()]).flat())):C=null,C!==null&&(h=[...new Set([...h,...C])],this.dryRun))for(let ae of C)A(` -- ${ae.relativeCwd} - ${G.prettyLocator(r,ae.anchoredLocator)}`);let I=[],v=!1;if(u?.includes(":")){for(let ae of o.workspaces)if(ae.manifest.scripts.has(u)&&(v=!v,v===!1))break}for(let ae of h){if(u&&!ae.manifest.scripts.has(u)&&!v&&!(await un.getWorkspaceAccessibleBinaries(ae)).has(u)){A(`Excluding ${ae.relativeCwd} because it doesn't have a "${u}" script`);continue}if(!(u===r.env.npm_lifecycle_event&&ae.cwd===a.cwd)){if(this.include.length>0&&!dw.default.isMatch(G.stringifyIdent(ae.anchoredLocator),this.include)&&!dw.default.isMatch(ae.relativeCwd,this.include)){A(`Excluding ${ae.relativeCwd} because it doesn't match the --include filter`);continue}if(this.exclude.length>0&&(dw.default.isMatch(G.stringifyIdent(ae.anchoredLocator),this.exclude)||dw.default.isMatch(ae.relativeCwd,this.exclude))){A(`Excluding ${ae.relativeCwd} because it matches the --include filter`);continue}if(this.publicOnly&&ae.manifest.private===!0){A(`Excluding ${ae.relativeCwd} because it's a private workspace and --no-private was set`);continue}I.push(ae)}}if(this.dryRun)return 0;let b=this.verbose??this.context.stdout.isTTY,E=this.parallel?this.jobs==="unlimited"?1/0:Number(this.jobs)||Math.ceil(zi.availableParallelism()/2):1,F=E===1?!1:this.parallel,N=F?this.interlaced:!0,U=(0,XBe.default)(E),z=new Map,te=new Set,le=0,pe=null,ue=!1,ye=await Lt.start({configuration:r,stdout:this.context.stdout,includePrefix:!1},async ae=>{let Ie=async(Fe,{commandIndex:g})=>{if(ue)return-1;!F&&b&&g>1&&ae.reportSeparator();let Ee=KDt(Fe,{configuration:r,verbose:b,commandIndex:g}),[De,ce]=JBe(ae,{prefix:Ee,interlaced:N}),[ne,ee]=JBe(ae,{prefix:Ee,interlaced:N});try{b&&ae.reportInfo(null,`${Ee} Process started`);let we=Date.now(),be=await this.cli.run([this.commandName,...this.args],{cwd:Fe.cwd,stdout:De,stderr:ne})||0;De.end(),ne.end(),await ce,await ee;let ht=Date.now();if(b){let H=r.get("enableTimers")?`, completed in ${de.pretty(r,ht-we,de.Type.DURATION)}`:"";ae.reportInfo(null,`${Ee} Process exited (exit code ${be})${H}`)}return be===130&&(ue=!0,pe=be),be}catch(we){throw De.end(),ne.end(),await ce,await ee,we}};for(let Fe of I)z.set(Fe.anchoredLocator.locatorHash,Fe);for(;z.size>0&&!ae.hasErrors();){let Fe=[];for(let[De,ce]of z){if(te.has(ce.anchoredDescriptor.descriptorHash))continue;let ne=!0;if(this.topological||this.topologicalDev){let ee=this.topologicalDev?new Map([...ce.manifest.dependencies,...ce.manifest.devDependencies]):ce.manifest.dependencies;for(let we of ee.values()){let be=o.tryWorkspaceByDescriptor(we);if(ne=be===null||!z.has(be.anchoredLocator.locatorHash),!ne)break}}if(!!ne&&(te.add(ce.anchoredDescriptor.descriptorHash),Fe.push(U(async()=>{let ee=await Ie(ce,{commandIndex:++le});return z.delete(De),te.delete(ce.anchoredDescriptor.descriptorHash),ee})),!F))break}if(Fe.length===0){let De=Array.from(z.values()).map(ce=>G.prettyLocator(r,ce.anchoredLocator)).join(", ");ae.reportError(3,`Dependency cycle detected (${De})`);return}let Ee=(await Promise.all(Fe)).find(De=>De!==0);pe===null&&(pe=typeof Ee<"u"?1:pe),(this.topological||this.topologicalDev)&&typeof Ee<"u"&&ae.reportError(0,"The command failed for workspaces that are depended upon by other workspaces; can't satisfy the dependency graph")}});return pe!==null?pe:ye.exitCode()}};sp.paths=[["workspaces","foreach"]],sp.usage=nt.Usage({category:"Workspace-related commands",description:"run a command on all workspaces",details:"\n This command will run a given sub-command on current and all its descendant workspaces. Various flags can alter the exact behavior of the command:\n\n - If `-p,--parallel` is set, the commands will be ran in parallel; they'll by default be limited to a number of parallel tasks roughly equal to half your core number, but that can be overridden via `-j,--jobs`, or disabled by setting `-j unlimited`.\n\n - If `-p,--parallel` and `-i,--interlaced` are both set, Yarn will print the lines from the output as it receives them. If `-i,--interlaced` wasn't set, it would instead buffer the output from each process and print the resulting buffers only after their source processes have exited.\n\n - If `-t,--topological` is set, Yarn will only run the command after all workspaces that it depends on through the `dependencies` field have successfully finished executing. If `--topological-dev` is set, both the `dependencies` and `devDependencies` fields will be considered when figuring out the wait points.\n\n - If `-A,--all` is set, Yarn will run the command on all the workspaces of a project. This is the default behavior.\n\n - If `-R,--recursive` is set, Yarn will find workspaces to run the command on by recursively evaluating `dependencies` and `devDependencies` fields, instead of looking at the `workspaces` fields.\n\n - If `-W,--worktree` is set, Yarn will find workspaces to run the command on by looking at the current worktree.\n\n - If `--from` is set, Yarn will use the packages matching the 'from' glob as the starting point for any recursive search.\n\n - If `--since` is set, Yarn will only run the command on workspaces that have been modified since the specified ref. By default Yarn will use the refs specified by the `changesetBaseRefs` configuration option.\n\n - If `--dry-run` is set, Yarn will explain what it would do without actually doing anything.\n\n - The command may apply to only some workspaces through the use of `--include` which acts as a whitelist. The `--exclude` flag will do the opposite and will be a list of packages that mustn't execute the script. Both flags accept glob patterns (if valid Idents and supported by [micromatch](https://github.com/micromatch/micromatch)). Make sure to escape the patterns, to prevent your own shell from trying to expand them.\n\n Adding the `-v,--verbose` flag (automatically enabled in interactive terminal environments) will cause Yarn to print more information; in particular the name of the workspace that generated the output will be printed at the front of each line.\n\n If the command is `run` and the script being run does not exist the child workspace will be skipped without error.\n ",examples:[["Publish current and all descendant packages","yarn workspaces foreach npm publish --tolerate-republish"],["Run build script on current and all descendant packages","yarn workspaces foreach run build"],["Run build script on current and all descendant packages in parallel, building package dependencies first","yarn workspaces foreach -pt run build"],["Run build script on several packages and all their dependencies, building dependencies first","yarn workspaces foreach -ptR --from '{workspace-a,workspace-b}' run build"]]}),sp.schema=[aI("all",Gu.Forbids,["from","recursive","since","worktree"],{missingIf:"undefined"}),OT(["all","recursive","since","worktree"],{missingIf:"undefined"})];function JBe(t,{prefix:e,interlaced:r}){let o=t.createStreamReporter(e),a=new _e.DefaultStream;a.pipe(o,{end:!1}),a.on("finish",()=>{o.end()});let n=new Promise(A=>{o.on("finish",()=>{A(a.active)})});if(r)return[a,n];let u=new _e.BufferStream;return u.pipe(a,{end:!1}),u.on("finish",()=>{a.end()}),[u,n]}function KDt(t,{configuration:e,commandIndex:r,verbose:o}){if(!o)return null;let n=`[${G.stringifyIdent(t.anchoredLocator)}]:`,u=["#2E86AB","#A23B72","#F18F01","#C73E1D","#CCE2A3"],A=u[r%u.length];return de.pretty(e,n,A)}var VDt={commands:[tg,sp]},zDt=VDt;var fC=()=>({modules:new Map([["@yarnpkg/cli",s2],["@yarnpkg/core",i2],["@yarnpkg/fslib",Kw],["@yarnpkg/libzip",S1],["@yarnpkg/parsers",tI],["@yarnpkg/shell",F1],["clipanion",fI],["semver",JDt],["typanion",Vo],["@yarnpkg/plugin-essentials",$8],["@yarnpkg/plugin-compat",iH],["@yarnpkg/plugin-constraints",wH],["@yarnpkg/plugin-dlx",IH],["@yarnpkg/plugin-exec",DH],["@yarnpkg/plugin-file",SH],["@yarnpkg/plugin-git",Z8],["@yarnpkg/plugin-github",kH],["@yarnpkg/plugin-http",QH],["@yarnpkg/plugin-init",FH],["@yarnpkg/plugin-interactive-tools",Tj],["@yarnpkg/plugin-link",Lj],["@yarnpkg/plugin-nm",Eq],["@yarnpkg/plugin-npm",yG],["@yarnpkg/plugin-npm-cli",SG],["@yarnpkg/plugin-pack",pG],["@yarnpkg/plugin-patch",TG],["@yarnpkg/plugin-pnp",aq],["@yarnpkg/plugin-pnpm",OG],["@yarnpkg/plugin-stage",GG],["@yarnpkg/plugin-typescript",YG],["@yarnpkg/plugin-version",JG],["@yarnpkg/plugin-workspace-tools",XG]]),plugins:new Set(["@yarnpkg/plugin-essentials","@yarnpkg/plugin-compat","@yarnpkg/plugin-constraints","@yarnpkg/plugin-dlx","@yarnpkg/plugin-exec","@yarnpkg/plugin-file","@yarnpkg/plugin-git","@yarnpkg/plugin-github","@yarnpkg/plugin-http","@yarnpkg/plugin-init","@yarnpkg/plugin-interactive-tools","@yarnpkg/plugin-link","@yarnpkg/plugin-nm","@yarnpkg/plugin-npm","@yarnpkg/plugin-npm-cli","@yarnpkg/plugin-pack","@yarnpkg/plugin-patch","@yarnpkg/plugin-pnp","@yarnpkg/plugin-pnpm","@yarnpkg/plugin-stage","@yarnpkg/plugin-typescript","@yarnpkg/plugin-version","@yarnpkg/plugin-workspace-tools"])});function eve({cwd:t,pluginConfiguration:e}){let r=new as({binaryLabel:"Yarn Package Manager",binaryName:"yarn",binaryVersion:tn??"<unknown>"});return Object.assign(r,{defaultContext:{...as.defaultContext,cwd:t,plugins:e,quiet:!1,stdin:process.stdin,stdout:process.stdout,stderr:process.stderr}})}function XDt(t){if(_e.parseOptionalBoolean(process.env.YARN_IGNORE_NODE))return!0;let r=process.versions.node,o=">=18.12.0";if(Qr.satisfiesWithPrereleases(r,o))return!0;let a=new it(`This tool requires a Node version compatible with ${o} (got ${r}). Upgrade Node, or set \`YARN_IGNORE_NODE=1\` in your environment.`);return as.defaultContext.stdout.write(t.error(a)),!1}async function tve({selfPath:t,pluginConfiguration:e}){return await Ke.find(fe.toPortablePath(process.cwd()),e,{strict:!1,usePathCheck:t})}function ZDt(t,e,{yarnPath:r}){if(!oe.existsSync(r))return t.error(new Error(`The "yarn-path" option has been set, but the specified location doesn't exist (${r}).`)),1;process.on("SIGINT",()=>{});let o={stdio:"inherit",env:{...process.env,YARN_IGNORE_PATH:"1"}};try{(0,ZBe.execFileSync)(process.execPath,[fe.fromPortablePath(r),...e],o)}catch(a){return a.status??1}return 0}function $Dt(t,e){let r=null,o=e;return e.length>=2&&e[0]==="--cwd"?(r=fe.toPortablePath(e[1]),o=e.slice(2)):e.length>=1&&e[0].startsWith("--cwd=")?(r=fe.toPortablePath(e[0].slice(6)),o=e.slice(1)):e[0]==="add"&&e[e.length-2]==="--cwd"&&(r=fe.toPortablePath(e[e.length-1]),o=e.slice(0,e.length-2)),t.defaultContext.cwd=r!==null?V.resolve(r):V.cwd(),o}function ePt(t,{configuration:e}){if(!e.get("enableTelemetry")||$Be.isCI||!process.stdout.isTTY)return;Ke.telemetry=new cC(e,"puba9cdc10ec5790a2cf4969dd413a47270");let o=/^@yarnpkg\/plugin-(.*)$/;for(let a of e.plugins.keys())uC.has(a.match(o)?.[1]??"")&&Ke.telemetry?.reportPluginName(a);t.binaryVersion&&Ke.telemetry.reportVersion(t.binaryVersion)}function rve(t,{configuration:e}){for(let r of e.plugins.values())for(let o of r.commands||[])t.register(o)}async function tPt(t,e,{selfPath:r,pluginConfiguration:o}){if(!XDt(t))return 1;let a=await tve({selfPath:r,pluginConfiguration:o}),n=a.get("yarnPath"),u=a.get("ignorePath");if(n&&!u)return ZDt(t,e,{yarnPath:n});delete process.env.YARN_IGNORE_PATH;let A=$Dt(t,e);ePt(t,{configuration:a}),rve(t,{configuration:a});let p=t.process(A,t.defaultContext);return p.help||Ke.telemetry?.reportCommandName(p.path.join(" ")),await t.run(p,t.defaultContext)}async function ehe({cwd:t=V.cwd(),pluginConfiguration:e=fC()}={}){let r=eve({cwd:t,pluginConfiguration:e}),o=await tve({pluginConfiguration:e,selfPath:null});return rve(r,{configuration:o}),r}async function nk(t,{cwd:e=V.cwd(),selfPath:r,pluginConfiguration:o}){let a=eve({cwd:e,pluginConfiguration:o});try{process.exitCode=await tPt(a,t,{selfPath:r,pluginConfiguration:o})}catch(n){as.defaultContext.stdout.write(a.error(n)),process.exitCode=1}finally{await oe.rmtempPromise()}}nk(process.argv.slice(2),{cwd:V.cwd(),selfPath:fe.toPortablePath(fe.resolve(process.argv[1])),pluginConfiguration:fC()});})(); -/* -object-assign -(c) Sindre Sorhus -@license MIT -*/ -/*! - * buildToken - * Builds OAuth token prefix (helper function) - * - * @name buildToken - * @function - * @param {GitUrl} obj The parsed Git url object. - * @return {String} token prefix - */ -/*! - * fill-range <https://github.com/jonschlinkert/fill-range> - * - * Copyright (c) 2014-present, Jon Schlinkert. - * Licensed under the MIT License. - */ -/*! - * is-extglob <https://github.com/jonschlinkert/is-extglob> - * - * Copyright (c) 2014-2016, Jon Schlinkert. - * Licensed under the MIT License. - */ -/*! - * is-glob <https://github.com/jonschlinkert/is-glob> - * - * Copyright (c) 2014-2017, Jon Schlinkert. - * Released under the MIT License. - */ -/*! - * is-number <https://github.com/jonschlinkert/is-number> - * - * Copyright (c) 2014-present, Jon Schlinkert. - * Released under the MIT License. - */ -/*! - * is-windows <https://github.com/jonschlinkert/is-windows> - * - * Copyright © 2015-2018, Jon Schlinkert. - * Released under the MIT License. - */ -/*! - * to-regex-range <https://github.com/micromatch/to-regex-range> - * - * Copyright (c) 2015-present, Jon Schlinkert. - * Released under the MIT License. - */ -/** - @license - Copyright (c) 2015, Rebecca Turner - - Permission to use, copy, modify, and/or distribute this software for any - purpose with or without fee is hereby granted, provided that the above - copyright notice and this permission notice appear in all copies. - - THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH - REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND - FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, - INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM - LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR - OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR - PERFORMANCE OF THIS SOFTWARE. - */ -/** - @license - Copyright Joyent, Inc. and other Node contributors. - - 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. -*/ -/** - @license - Copyright Node.js contributors. All rights reserved. - - 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. -*/ -/** - @license - The MIT License (MIT) - - Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) - - 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. -*/ -/** @license React v0.18.0 - * scheduler.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ -/** @license React v0.24.0 - * react-reconciler.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ -/** @license React v16.13.1 - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ diff --git a/.yarn/releases/yarn-4.1.0.cjs b/.yarn/releases/yarn-4.1.0.cjs new file mode 100755 index 0000000000000..738adce5914a0 --- /dev/null +++ b/.yarn/releases/yarn-4.1.0.cjs @@ -0,0 +1,893 @@ +#!/usr/bin/env node +/* eslint-disable */ +//prettier-ignore +(()=>{var Z3e=Object.create;var NR=Object.defineProperty;var $3e=Object.getOwnPropertyDescriptor;var e_e=Object.getOwnPropertyNames;var t_e=Object.getPrototypeOf,r_e=Object.prototype.hasOwnProperty;var ve=(t=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(t,{get:(e,r)=>(typeof require<"u"?require:e)[r]}):t)(function(t){if(typeof require<"u")return require.apply(this,arguments);throw new Error('Dynamic require of "'+t+'" is not supported')});var Et=(t,e)=>()=>(t&&(e=t(t=0)),e);var _=(t,e)=>()=>(e||t((e={exports:{}}).exports,e),e.exports),Vt=(t,e)=>{for(var r in e)NR(t,r,{get:e[r],enumerable:!0})},n_e=(t,e,r,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let a of e_e(e))!r_e.call(t,a)&&a!==r&&NR(t,a,{get:()=>e[a],enumerable:!(o=$3e(e,a))||o.enumerable});return t};var $e=(t,e,r)=>(r=t!=null?Z3e(t_e(t)):{},n_e(e||!t||!t.__esModule?NR(r,"default",{value:t,enumerable:!0}):r,t));var vi={};Vt(vi,{SAFE_TIME:()=>x7,S_IFDIR:()=>wD,S_IFLNK:()=>ID,S_IFMT:()=>Ou,S_IFREG:()=>jw});var Ou,wD,jw,ID,x7,k7=Et(()=>{Ou=61440,wD=16384,jw=32768,ID=40960,x7=456789e3});var ar={};Vt(ar,{EBADF:()=>Io,EBUSY:()=>i_e,EEXIST:()=>u_e,EINVAL:()=>o_e,EISDIR:()=>c_e,ENOENT:()=>a_e,ENOSYS:()=>s_e,ENOTDIR:()=>l_e,ENOTEMPTY:()=>f_e,EOPNOTSUPP:()=>p_e,EROFS:()=>A_e,ERR_DIR_CLOSED:()=>LR});function Tl(t,e){return Object.assign(new Error(`${t}: ${e}`),{code:t})}function i_e(t){return Tl("EBUSY",t)}function s_e(t,e){return Tl("ENOSYS",`${t}, ${e}`)}function o_e(t){return Tl("EINVAL",`invalid argument, ${t}`)}function Io(t){return Tl("EBADF",`bad file descriptor, ${t}`)}function a_e(t){return Tl("ENOENT",`no such file or directory, ${t}`)}function l_e(t){return Tl("ENOTDIR",`not a directory, ${t}`)}function c_e(t){return Tl("EISDIR",`illegal operation on a directory, ${t}`)}function u_e(t){return Tl("EEXIST",`file already exists, ${t}`)}function A_e(t){return Tl("EROFS",`read-only filesystem, ${t}`)}function f_e(t){return Tl("ENOTEMPTY",`directory not empty, ${t}`)}function p_e(t){return Tl("EOPNOTSUPP",`operation not supported, ${t}`)}function LR(){return Tl("ERR_DIR_CLOSED","Directory handle was closed")}var BD=Et(()=>{});var Ea={};Vt(Ea,{BigIntStatsEntry:()=>ty,DEFAULT_MODE:()=>UR,DirEntry:()=>OR,StatEntry:()=>ey,areStatsEqual:()=>_R,clearStats:()=>vD,convertToBigIntStats:()=>g_e,makeDefaultStats:()=>Q7,makeEmptyStats:()=>h_e});function Q7(){return new ey}function h_e(){return vD(Q7())}function vD(t){for(let e in t)if(Object.hasOwn(t,e)){let r=t[e];typeof r=="number"?t[e]=0:typeof r=="bigint"?t[e]=BigInt(0):MR.types.isDate(r)&&(t[e]=new Date(0))}return t}function g_e(t){let e=new ty;for(let r in t)if(Object.hasOwn(t,r)){let o=t[r];typeof o=="number"?e[r]=BigInt(o):MR.types.isDate(o)&&(e[r]=new Date(o))}return e.atimeNs=e.atimeMs*BigInt(1e6),e.mtimeNs=e.mtimeMs*BigInt(1e6),e.ctimeNs=e.ctimeMs*BigInt(1e6),e.birthtimeNs=e.birthtimeMs*BigInt(1e6),e}function _R(t,e){if(t.atimeMs!==e.atimeMs||t.birthtimeMs!==e.birthtimeMs||t.blksize!==e.blksize||t.blocks!==e.blocks||t.ctimeMs!==e.ctimeMs||t.dev!==e.dev||t.gid!==e.gid||t.ino!==e.ino||t.isBlockDevice()!==e.isBlockDevice()||t.isCharacterDevice()!==e.isCharacterDevice()||t.isDirectory()!==e.isDirectory()||t.isFIFO()!==e.isFIFO()||t.isFile()!==e.isFile()||t.isSocket()!==e.isSocket()||t.isSymbolicLink()!==e.isSymbolicLink()||t.mode!==e.mode||t.mtimeMs!==e.mtimeMs||t.nlink!==e.nlink||t.rdev!==e.rdev||t.size!==e.size||t.uid!==e.uid)return!1;let r=t,o=e;return!(r.atimeNs!==o.atimeNs||r.mtimeNs!==o.mtimeNs||r.ctimeNs!==o.ctimeNs||r.birthtimeNs!==o.birthtimeNs)}var MR,UR,OR,ey,ty,HR=Et(()=>{MR=$e(ve("util")),UR=33188,OR=class{constructor(){this.name="";this.path="";this.mode=0}isBlockDevice(){return!1}isCharacterDevice(){return!1}isDirectory(){return(this.mode&61440)===16384}isFIFO(){return!1}isFile(){return(this.mode&61440)===32768}isSocket(){return!1}isSymbolicLink(){return(this.mode&61440)===40960}},ey=class{constructor(){this.uid=0;this.gid=0;this.size=0;this.blksize=0;this.atimeMs=0;this.mtimeMs=0;this.ctimeMs=0;this.birthtimeMs=0;this.atime=new Date(0);this.mtime=new Date(0);this.ctime=new Date(0);this.birthtime=new Date(0);this.dev=0;this.ino=0;this.mode=UR;this.nlink=1;this.rdev=0;this.blocks=1}isBlockDevice(){return!1}isCharacterDevice(){return!1}isDirectory(){return(this.mode&61440)===16384}isFIFO(){return!1}isFile(){return(this.mode&61440)===32768}isSocket(){return!1}isSymbolicLink(){return(this.mode&61440)===40960}},ty=class{constructor(){this.uid=BigInt(0);this.gid=BigInt(0);this.size=BigInt(0);this.blksize=BigInt(0);this.atimeMs=BigInt(0);this.mtimeMs=BigInt(0);this.ctimeMs=BigInt(0);this.birthtimeMs=BigInt(0);this.atimeNs=BigInt(0);this.mtimeNs=BigInt(0);this.ctimeNs=BigInt(0);this.birthtimeNs=BigInt(0);this.atime=new Date(0);this.mtime=new Date(0);this.ctime=new Date(0);this.birthtime=new Date(0);this.dev=BigInt(0);this.ino=BigInt(0);this.mode=BigInt(UR);this.nlink=BigInt(1);this.rdev=BigInt(0);this.blocks=BigInt(1)}isBlockDevice(){return!1}isCharacterDevice(){return!1}isDirectory(){return(this.mode&BigInt(61440))===BigInt(16384)}isFIFO(){return!1}isFile(){return(this.mode&BigInt(61440))===BigInt(32768)}isSocket(){return!1}isSymbolicLink(){return(this.mode&BigInt(61440))===BigInt(40960)}}});function C_e(t){let e,r;if(e=t.match(y_e))t=e[1];else if(r=t.match(E_e))t=`\\\\${r[1]?".\\":""}${r[2]}`;else return t;return t.replace(/\//g,"\\")}function w_e(t){t=t.replace(/\\/g,"/");let e,r;return(e=t.match(d_e))?t=`/${e[1]}`:(r=t.match(m_e))&&(t=`/unc/${r[1]?".dot/":""}${r[2]}`),t}function DD(t,e){return t===ue?R7(e):jR(e)}var Gw,Bt,dr,ue,V,F7,d_e,m_e,y_e,E_e,jR,R7,Ca=Et(()=>{Gw=$e(ve("path")),Bt={root:"/",dot:".",parent:".."},dr={home:"~",nodeModules:"node_modules",manifest:"package.json",lockfile:"yarn.lock",virtual:"__virtual__",pnpJs:".pnp.js",pnpCjs:".pnp.cjs",pnpData:".pnp.data.json",pnpEsmLoader:".pnp.loader.mjs",rc:".yarnrc.yml",env:".env"},ue=Object.create(Gw.default),V=Object.create(Gw.default.posix);ue.cwd=()=>process.cwd();V.cwd=process.platform==="win32"?()=>jR(process.cwd()):process.cwd;process.platform==="win32"&&(V.resolve=(...t)=>t.length>0&&V.isAbsolute(t[0])?Gw.default.posix.resolve(...t):Gw.default.posix.resolve(V.cwd(),...t));F7=function(t,e,r){return e=t.normalize(e),r=t.normalize(r),e===r?".":(e.endsWith(t.sep)||(e=e+t.sep),r.startsWith(e)?r.slice(e.length):null)};ue.contains=(t,e)=>F7(ue,t,e);V.contains=(t,e)=>F7(V,t,e);d_e=/^([a-zA-Z]:.*)$/,m_e=/^\/\/(\.\/)?(.*)$/,y_e=/^\/([a-zA-Z]:.*)$/,E_e=/^\/unc\/(\.dot\/)?(.*)$/;jR=process.platform==="win32"?w_e:t=>t,R7=process.platform==="win32"?C_e:t=>t;ue.fromPortablePath=R7;ue.toPortablePath=jR});async function SD(t,e){let r="0123456789abcdef";await t.mkdirPromise(e.indexPath,{recursive:!0});let o=[];for(let a of r)for(let n of r)o.push(t.mkdirPromise(t.pathUtils.join(e.indexPath,`${a}${n}`),{recursive:!0}));return await Promise.all(o),e.indexPath}async function T7(t,e,r,o,a){let n=t.pathUtils.normalize(e),u=r.pathUtils.normalize(o),A=[],p=[],{atime:h,mtime:E}=a.stableTime?{atime:Og,mtime:Og}:await r.lstatPromise(u);await t.mkdirpPromise(t.pathUtils.dirname(e),{utimes:[h,E]}),await GR(A,p,t,n,r,u,{...a,didParentExist:!0});for(let I of A)await I();await Promise.all(p.map(I=>I()))}async function GR(t,e,r,o,a,n,u){let A=u.didParentExist?await N7(r,o):null,p=await a.lstatPromise(n),{atime:h,mtime:E}=u.stableTime?{atime:Og,mtime:Og}:p,I;switch(!0){case p.isDirectory():I=await B_e(t,e,r,o,A,a,n,p,u);break;case p.isFile():I=await S_e(t,e,r,o,A,a,n,p,u);break;case p.isSymbolicLink():I=await P_e(t,e,r,o,A,a,n,p,u);break;default:throw new Error(`Unsupported file type (${p.mode})`)}return(u.linkStrategy?.type!=="HardlinkFromIndex"||!p.isFile())&&((I||A?.mtime?.getTime()!==E.getTime()||A?.atime?.getTime()!==h.getTime())&&(e.push(()=>r.lutimesPromise(o,h,E)),I=!0),(A===null||(A.mode&511)!==(p.mode&511))&&(e.push(()=>r.chmodPromise(o,p.mode&511)),I=!0)),I}async function N7(t,e){try{return await t.lstatPromise(e)}catch{return null}}async function B_e(t,e,r,o,a,n,u,A,p){if(a!==null&&!a.isDirectory())if(p.overwrite)t.push(async()=>r.removePromise(o)),a=null;else return!1;let h=!1;a===null&&(t.push(async()=>{try{await r.mkdirPromise(o,{mode:A.mode})}catch(v){if(v.code!=="EEXIST")throw v}}),h=!0);let E=await n.readdirPromise(u),I=p.didParentExist&&!a?{...p,didParentExist:!1}:p;if(p.stableSort)for(let v of E.sort())await GR(t,e,r,r.pathUtils.join(o,v),n,n.pathUtils.join(u,v),I)&&(h=!0);else(await Promise.all(E.map(async x=>{await GR(t,e,r,r.pathUtils.join(o,x),n,n.pathUtils.join(u,x),I)}))).some(x=>x)&&(h=!0);return h}async function v_e(t,e,r,o,a,n,u,A,p,h){let E=await n.checksumFilePromise(u,{algorithm:"sha1"}),I=420,v=A.mode&511,x=`${E}${v!==I?v.toString(8):""}`,C=r.pathUtils.join(h.indexPath,E.slice(0,2),`${x}.dat`),R;(ce=>(ce[ce.Lock=0]="Lock",ce[ce.Rename=1]="Rename"))(R||={});let L=1,U=await N7(r,C);if(a){let ae=U&&a.dev===U.dev&&a.ino===U.ino,fe=U?.mtimeMs!==I_e;if(ae&&fe&&h.autoRepair&&(L=0,U=null),!ae)if(p.overwrite)t.push(async()=>r.removePromise(o)),a=null;else return!1}let J=!U&&L===1?`${C}.${Math.floor(Math.random()*4294967296).toString(16).padStart(8,"0")}`:null,te=!1;return t.push(async()=>{if(!U&&(L===0&&await r.lockPromise(C,async()=>{let ae=await n.readFilePromise(u);await r.writeFilePromise(C,ae)}),L===1&&J)){let ae=await n.readFilePromise(u);await r.writeFilePromise(J,ae);try{await r.linkPromise(J,C)}catch(fe){if(fe.code==="EEXIST")te=!0,await r.unlinkPromise(J);else throw fe}}a||await r.linkPromise(C,o)}),e.push(async()=>{U||(await r.lutimesPromise(C,Og,Og),v!==I&&await r.chmodPromise(C,v)),J&&!te&&await r.unlinkPromise(J)}),!1}async function D_e(t,e,r,o,a,n,u,A,p){if(a!==null)if(p.overwrite)t.push(async()=>r.removePromise(o)),a=null;else return!1;return t.push(async()=>{let h=await n.readFilePromise(u);await r.writeFilePromise(o,h)}),!0}async function S_e(t,e,r,o,a,n,u,A,p){return p.linkStrategy?.type==="HardlinkFromIndex"?v_e(t,e,r,o,a,n,u,A,p,p.linkStrategy):D_e(t,e,r,o,a,n,u,A,p)}async function P_e(t,e,r,o,a,n,u,A,p){if(a!==null)if(p.overwrite)t.push(async()=>r.removePromise(o)),a=null;else return!1;return t.push(async()=>{await r.symlinkPromise(DD(r.pathUtils,await n.readlinkPromise(u)),o)}),!0}var Og,I_e,qR=Et(()=>{Ca();Og=new Date(456789e3*1e3),I_e=Og.getTime()});function PD(t,e,r,o){let a=()=>{let n=r.shift();if(typeof n>"u")return null;let u=t.pathUtils.join(e,n);return Object.assign(t.statSync(u),{name:n,path:void 0})};return new qw(e,a,o)}var qw,L7=Et(()=>{BD();qw=class{constructor(e,r,o={}){this.path=e;this.nextDirent=r;this.opts=o;this.closed=!1}throwIfClosed(){if(this.closed)throw LR()}async*[Symbol.asyncIterator](){try{let e;for(;(e=await this.read())!==null;)yield e}finally{await this.close()}}read(e){let r=this.readSync();return typeof e<"u"?e(null,r):Promise.resolve(r)}readSync(){return this.throwIfClosed(),this.nextDirent()}close(e){return this.closeSync(),typeof e<"u"?e(null):Promise.resolve()}closeSync(){this.throwIfClosed(),this.opts.onClose?.(),this.closed=!0}}});function O7(t,e){if(t!==e)throw new Error(`Invalid StatWatcher status: expected '${e}', got '${t}'`)}var M7,ry,U7=Et(()=>{M7=ve("events");HR();ry=class extends M7.EventEmitter{constructor(r,o,{bigint:a=!1}={}){super();this.status="ready";this.changeListeners=new Map;this.startTimeout=null;this.fakeFs=r,this.path=o,this.bigint=a,this.lastStats=this.stat()}static create(r,o,a){let n=new ry(r,o,a);return n.start(),n}start(){O7(this.status,"ready"),this.status="running",this.startTimeout=setTimeout(()=>{this.startTimeout=null,this.fakeFs.existsSync(this.path)||this.emit("change",this.lastStats,this.lastStats)},3)}stop(){O7(this.status,"running"),this.status="stopped",this.startTimeout!==null&&(clearTimeout(this.startTimeout),this.startTimeout=null),this.emit("stop")}stat(){try{return this.fakeFs.statSync(this.path,{bigint:this.bigint})}catch{let o=this.bigint?new ty:new ey;return vD(o)}}makeInterval(r){let o=setInterval(()=>{let a=this.stat(),n=this.lastStats;_R(a,n)||(this.lastStats=a,this.emit("change",a,n))},r.interval);return r.persistent?o:o.unref()}registerChangeListener(r,o){this.addListener("change",r),this.changeListeners.set(r,this.makeInterval(o))}unregisterChangeListener(r){this.removeListener("change",r);let o=this.changeListeners.get(r);typeof o<"u"&&clearInterval(o),this.changeListeners.delete(r)}unregisterAllChangeListeners(){for(let r of this.changeListeners.keys())this.unregisterChangeListener(r)}hasChangeListeners(){return this.changeListeners.size>0}ref(){for(let r of this.changeListeners.values())r.ref();return this}unref(){for(let r of this.changeListeners.values())r.unref();return this}}});function ny(t,e,r,o){let a,n,u,A;switch(typeof r){case"function":a=!1,n=!0,u=5007,A=r;break;default:({bigint:a=!1,persistent:n=!0,interval:u=5007}=r),A=o;break}let p=bD.get(t);typeof p>"u"&&bD.set(t,p=new Map);let h=p.get(e);return typeof h>"u"&&(h=ry.create(t,e,{bigint:a}),p.set(e,h)),h.registerChangeListener(A,{persistent:n,interval:u}),h}function Mg(t,e,r){let o=bD.get(t);if(typeof o>"u")return;let a=o.get(e);typeof a>"u"||(typeof r>"u"?a.unregisterAllChangeListeners():a.unregisterChangeListener(r),a.hasChangeListeners()||(a.stop(),o.delete(e)))}function Ug(t){let e=bD.get(t);if(!(typeof e>"u"))for(let r of e.keys())Mg(t,r)}var bD,YR=Et(()=>{U7();bD=new WeakMap});function b_e(t){let e=t.match(/\r?\n/g);if(e===null)return H7.EOL;let r=e.filter(a=>a===`\r +`).length,o=e.length-r;return r>o?`\r +`:` +`}function _g(t,e){return e.replace(/\r?\n/g,b_e(t))}var _7,H7,gf,Mu,Hg=Et(()=>{_7=ve("crypto"),H7=ve("os");qR();Ca();gf=class{constructor(e){this.pathUtils=e}async*genTraversePromise(e,{stableSort:r=!1}={}){let o=[e];for(;o.length>0;){let a=o.shift();if((await this.lstatPromise(a)).isDirectory()){let u=await this.readdirPromise(a);if(r)for(let A of u.sort())o.push(this.pathUtils.join(a,A));else throw new Error("Not supported")}else yield a}}async checksumFilePromise(e,{algorithm:r="sha512"}={}){let o=await this.openPromise(e,"r");try{let n=Buffer.allocUnsafeSlow(65536),u=(0,_7.createHash)(r),A=0;for(;(A=await this.readPromise(o,n,0,65536))!==0;)u.update(A===65536?n:n.slice(0,A));return u.digest("hex")}finally{await this.closePromise(o)}}async removePromise(e,{recursive:r=!0,maxRetries:o=5}={}){let a;try{a=await this.lstatPromise(e)}catch(n){if(n.code==="ENOENT")return;throw n}if(a.isDirectory()){if(r){let n=await this.readdirPromise(e);await Promise.all(n.map(u=>this.removePromise(this.pathUtils.resolve(e,u))))}for(let n=0;n<=o;n++)try{await this.rmdirPromise(e);break}catch(u){if(u.code!=="EBUSY"&&u.code!=="ENOTEMPTY")throw u;n<o&&await new Promise(A=>setTimeout(A,n*100))}}else await this.unlinkPromise(e)}removeSync(e,{recursive:r=!0}={}){let o;try{o=this.lstatSync(e)}catch(a){if(a.code==="ENOENT")return;throw a}if(o.isDirectory()){if(r)for(let a of this.readdirSync(e))this.removeSync(this.pathUtils.resolve(e,a));this.rmdirSync(e)}else this.unlinkSync(e)}async mkdirpPromise(e,{chmod:r,utimes:o}={}){if(e=this.resolve(e),e===this.pathUtils.dirname(e))return;let a=e.split(this.pathUtils.sep),n;for(let u=2;u<=a.length;++u){let A=a.slice(0,u).join(this.pathUtils.sep);if(!this.existsSync(A)){try{await this.mkdirPromise(A)}catch(p){if(p.code==="EEXIST")continue;throw p}if(n??=A,r!=null&&await this.chmodPromise(A,r),o!=null)await this.utimesPromise(A,o[0],o[1]);else{let p=await this.statPromise(this.pathUtils.dirname(A));await this.utimesPromise(A,p.atime,p.mtime)}}}return n}mkdirpSync(e,{chmod:r,utimes:o}={}){if(e=this.resolve(e),e===this.pathUtils.dirname(e))return;let a=e.split(this.pathUtils.sep),n;for(let u=2;u<=a.length;++u){let A=a.slice(0,u).join(this.pathUtils.sep);if(!this.existsSync(A)){try{this.mkdirSync(A)}catch(p){if(p.code==="EEXIST")continue;throw p}if(n??=A,r!=null&&this.chmodSync(A,r),o!=null)this.utimesSync(A,o[0],o[1]);else{let p=this.statSync(this.pathUtils.dirname(A));this.utimesSync(A,p.atime,p.mtime)}}}return n}async copyPromise(e,r,{baseFs:o=this,overwrite:a=!0,stableSort:n=!1,stableTime:u=!1,linkStrategy:A=null}={}){return await T7(this,e,o,r,{overwrite:a,stableSort:n,stableTime:u,linkStrategy:A})}copySync(e,r,{baseFs:o=this,overwrite:a=!0}={}){let n=o.lstatSync(r),u=this.existsSync(e);if(n.isDirectory()){this.mkdirpSync(e);let p=o.readdirSync(r);for(let h of p)this.copySync(this.pathUtils.join(e,h),o.pathUtils.join(r,h),{baseFs:o,overwrite:a})}else if(n.isFile()){if(!u||a){u&&this.removeSync(e);let p=o.readFileSync(r);this.writeFileSync(e,p)}}else if(n.isSymbolicLink()){if(!u||a){u&&this.removeSync(e);let p=o.readlinkSync(r);this.symlinkSync(DD(this.pathUtils,p),e)}}else throw new Error(`Unsupported file type (file: ${r}, mode: 0o${n.mode.toString(8).padStart(6,"0")})`);let A=n.mode&511;this.chmodSync(e,A)}async changeFilePromise(e,r,o={}){return Buffer.isBuffer(r)?this.changeFileBufferPromise(e,r,o):this.changeFileTextPromise(e,r,o)}async changeFileBufferPromise(e,r,{mode:o}={}){let a=Buffer.alloc(0);try{a=await this.readFilePromise(e)}catch{}Buffer.compare(a,r)!==0&&await this.writeFilePromise(e,r,{mode:o})}async changeFileTextPromise(e,r,{automaticNewlines:o,mode:a}={}){let n="";try{n=await this.readFilePromise(e,"utf8")}catch{}let u=o?_g(n,r):r;n!==u&&await this.writeFilePromise(e,u,{mode:a})}changeFileSync(e,r,o={}){return Buffer.isBuffer(r)?this.changeFileBufferSync(e,r,o):this.changeFileTextSync(e,r,o)}changeFileBufferSync(e,r,{mode:o}={}){let a=Buffer.alloc(0);try{a=this.readFileSync(e)}catch{}Buffer.compare(a,r)!==0&&this.writeFileSync(e,r,{mode:o})}changeFileTextSync(e,r,{automaticNewlines:o=!1,mode:a}={}){let n="";try{n=this.readFileSync(e,"utf8")}catch{}let u=o?_g(n,r):r;n!==u&&this.writeFileSync(e,u,{mode:a})}async movePromise(e,r){try{await this.renamePromise(e,r)}catch(o){if(o.code==="EXDEV")await this.copyPromise(r,e),await this.removePromise(e);else throw o}}moveSync(e,r){try{this.renameSync(e,r)}catch(o){if(o.code==="EXDEV")this.copySync(r,e),this.removeSync(e);else throw o}}async lockPromise(e,r){let o=`${e}.flock`,a=1e3/60,n=Date.now(),u=null,A=async()=>{let p;try{[p]=await this.readJsonPromise(o)}catch{return Date.now()-n<500}try{return process.kill(p,0),!0}catch{return!1}};for(;u===null;)try{u=await this.openPromise(o,"wx")}catch(p){if(p.code==="EEXIST"){if(!await A())try{await this.unlinkPromise(o);continue}catch{}if(Date.now()-n<60*1e3)await new Promise(h=>setTimeout(h,a));else throw new Error(`Couldn't acquire a lock in a reasonable time (via ${o})`)}else throw p}await this.writePromise(u,JSON.stringify([process.pid]));try{return await r()}finally{try{await this.closePromise(u),await this.unlinkPromise(o)}catch{}}}async readJsonPromise(e){let r=await this.readFilePromise(e,"utf8");try{return JSON.parse(r)}catch(o){throw o.message+=` (in ${e})`,o}}readJsonSync(e){let r=this.readFileSync(e,"utf8");try{return JSON.parse(r)}catch(o){throw o.message+=` (in ${e})`,o}}async writeJsonPromise(e,r,{compact:o=!1}={}){let a=o?0:2;return await this.writeFilePromise(e,`${JSON.stringify(r,null,a)} +`)}writeJsonSync(e,r,{compact:o=!1}={}){let a=o?0:2;return this.writeFileSync(e,`${JSON.stringify(r,null,a)} +`)}async preserveTimePromise(e,r){let o=await this.lstatPromise(e),a=await r();typeof a<"u"&&(e=a),await this.lutimesPromise(e,o.atime,o.mtime)}async preserveTimeSync(e,r){let o=this.lstatSync(e),a=r();typeof a<"u"&&(e=a),this.lutimesSync(e,o.atime,o.mtime)}},Mu=class extends gf{constructor(){super(V)}}});var Ss,df=Et(()=>{Hg();Ss=class extends gf{getExtractHint(e){return this.baseFs.getExtractHint(e)}resolve(e){return this.mapFromBase(this.baseFs.resolve(this.mapToBase(e)))}getRealPath(){return this.mapFromBase(this.baseFs.getRealPath())}async openPromise(e,r,o){return this.baseFs.openPromise(this.mapToBase(e),r,o)}openSync(e,r,o){return this.baseFs.openSync(this.mapToBase(e),r,o)}async opendirPromise(e,r){return Object.assign(await this.baseFs.opendirPromise(this.mapToBase(e),r),{path:e})}opendirSync(e,r){return Object.assign(this.baseFs.opendirSync(this.mapToBase(e),r),{path:e})}async readPromise(e,r,o,a,n){return await this.baseFs.readPromise(e,r,o,a,n)}readSync(e,r,o,a,n){return this.baseFs.readSync(e,r,o,a,n)}async writePromise(e,r,o,a,n){return typeof r=="string"?await this.baseFs.writePromise(e,r,o):await this.baseFs.writePromise(e,r,o,a,n)}writeSync(e,r,o,a,n){return typeof r=="string"?this.baseFs.writeSync(e,r,o):this.baseFs.writeSync(e,r,o,a,n)}async closePromise(e){return this.baseFs.closePromise(e)}closeSync(e){this.baseFs.closeSync(e)}createReadStream(e,r){return this.baseFs.createReadStream(e!==null?this.mapToBase(e):e,r)}createWriteStream(e,r){return this.baseFs.createWriteStream(e!==null?this.mapToBase(e):e,r)}async realpathPromise(e){return this.mapFromBase(await this.baseFs.realpathPromise(this.mapToBase(e)))}realpathSync(e){return this.mapFromBase(this.baseFs.realpathSync(this.mapToBase(e)))}async existsPromise(e){return this.baseFs.existsPromise(this.mapToBase(e))}existsSync(e){return this.baseFs.existsSync(this.mapToBase(e))}accessSync(e,r){return this.baseFs.accessSync(this.mapToBase(e),r)}async accessPromise(e,r){return this.baseFs.accessPromise(this.mapToBase(e),r)}async statPromise(e,r){return this.baseFs.statPromise(this.mapToBase(e),r)}statSync(e,r){return this.baseFs.statSync(this.mapToBase(e),r)}async fstatPromise(e,r){return this.baseFs.fstatPromise(e,r)}fstatSync(e,r){return this.baseFs.fstatSync(e,r)}lstatPromise(e,r){return this.baseFs.lstatPromise(this.mapToBase(e),r)}lstatSync(e,r){return this.baseFs.lstatSync(this.mapToBase(e),r)}async fchmodPromise(e,r){return this.baseFs.fchmodPromise(e,r)}fchmodSync(e,r){return this.baseFs.fchmodSync(e,r)}async chmodPromise(e,r){return this.baseFs.chmodPromise(this.mapToBase(e),r)}chmodSync(e,r){return this.baseFs.chmodSync(this.mapToBase(e),r)}async fchownPromise(e,r,o){return this.baseFs.fchownPromise(e,r,o)}fchownSync(e,r,o){return this.baseFs.fchownSync(e,r,o)}async chownPromise(e,r,o){return this.baseFs.chownPromise(this.mapToBase(e),r,o)}chownSync(e,r,o){return this.baseFs.chownSync(this.mapToBase(e),r,o)}async renamePromise(e,r){return this.baseFs.renamePromise(this.mapToBase(e),this.mapToBase(r))}renameSync(e,r){return this.baseFs.renameSync(this.mapToBase(e),this.mapToBase(r))}async copyFilePromise(e,r,o=0){return this.baseFs.copyFilePromise(this.mapToBase(e),this.mapToBase(r),o)}copyFileSync(e,r,o=0){return this.baseFs.copyFileSync(this.mapToBase(e),this.mapToBase(r),o)}async appendFilePromise(e,r,o){return this.baseFs.appendFilePromise(this.fsMapToBase(e),r,o)}appendFileSync(e,r,o){return this.baseFs.appendFileSync(this.fsMapToBase(e),r,o)}async writeFilePromise(e,r,o){return this.baseFs.writeFilePromise(this.fsMapToBase(e),r,o)}writeFileSync(e,r,o){return this.baseFs.writeFileSync(this.fsMapToBase(e),r,o)}async unlinkPromise(e){return this.baseFs.unlinkPromise(this.mapToBase(e))}unlinkSync(e){return this.baseFs.unlinkSync(this.mapToBase(e))}async utimesPromise(e,r,o){return this.baseFs.utimesPromise(this.mapToBase(e),r,o)}utimesSync(e,r,o){return this.baseFs.utimesSync(this.mapToBase(e),r,o)}async lutimesPromise(e,r,o){return this.baseFs.lutimesPromise(this.mapToBase(e),r,o)}lutimesSync(e,r,o){return this.baseFs.lutimesSync(this.mapToBase(e),r,o)}async mkdirPromise(e,r){return this.baseFs.mkdirPromise(this.mapToBase(e),r)}mkdirSync(e,r){return this.baseFs.mkdirSync(this.mapToBase(e),r)}async rmdirPromise(e,r){return this.baseFs.rmdirPromise(this.mapToBase(e),r)}rmdirSync(e,r){return this.baseFs.rmdirSync(this.mapToBase(e),r)}async linkPromise(e,r){return this.baseFs.linkPromise(this.mapToBase(e),this.mapToBase(r))}linkSync(e,r){return this.baseFs.linkSync(this.mapToBase(e),this.mapToBase(r))}async symlinkPromise(e,r,o){let a=this.mapToBase(r);if(this.pathUtils.isAbsolute(e))return this.baseFs.symlinkPromise(this.mapToBase(e),a,o);let n=this.mapToBase(this.pathUtils.join(this.pathUtils.dirname(r),e)),u=this.baseFs.pathUtils.relative(this.baseFs.pathUtils.dirname(a),n);return this.baseFs.symlinkPromise(u,a,o)}symlinkSync(e,r,o){let a=this.mapToBase(r);if(this.pathUtils.isAbsolute(e))return this.baseFs.symlinkSync(this.mapToBase(e),a,o);let n=this.mapToBase(this.pathUtils.join(this.pathUtils.dirname(r),e)),u=this.baseFs.pathUtils.relative(this.baseFs.pathUtils.dirname(a),n);return this.baseFs.symlinkSync(u,a,o)}async readFilePromise(e,r){return this.baseFs.readFilePromise(this.fsMapToBase(e),r)}readFileSync(e,r){return this.baseFs.readFileSync(this.fsMapToBase(e),r)}readdirPromise(e,r){return this.baseFs.readdirPromise(this.mapToBase(e),r)}readdirSync(e,r){return this.baseFs.readdirSync(this.mapToBase(e),r)}async readlinkPromise(e){return this.mapFromBase(await this.baseFs.readlinkPromise(this.mapToBase(e)))}readlinkSync(e){return this.mapFromBase(this.baseFs.readlinkSync(this.mapToBase(e)))}async truncatePromise(e,r){return this.baseFs.truncatePromise(this.mapToBase(e),r)}truncateSync(e,r){return this.baseFs.truncateSync(this.mapToBase(e),r)}async ftruncatePromise(e,r){return this.baseFs.ftruncatePromise(e,r)}ftruncateSync(e,r){return this.baseFs.ftruncateSync(e,r)}watch(e,r,o){return this.baseFs.watch(this.mapToBase(e),r,o)}watchFile(e,r,o){return this.baseFs.watchFile(this.mapToBase(e),r,o)}unwatchFile(e,r){return this.baseFs.unwatchFile(this.mapToBase(e),r)}fsMapToBase(e){return typeof e=="number"?e:this.mapToBase(e)}}});var Uu,j7=Et(()=>{df();Uu=class extends Ss{constructor(r,{baseFs:o,pathUtils:a}){super(a);this.target=r,this.baseFs=o}getRealPath(){return this.target}getBaseFs(){return this.baseFs}mapFromBase(r){return r}mapToBase(r){return r}}});function G7(t){let e=t;return typeof t.path=="string"&&(e.path=ue.toPortablePath(t.path)),e}var q7,Tn,jg=Et(()=>{q7=$e(ve("fs"));Hg();Ca();Tn=class extends Mu{constructor(r=q7.default){super();this.realFs=r}getExtractHint(){return!1}getRealPath(){return Bt.root}resolve(r){return V.resolve(r)}async openPromise(r,o,a){return await new Promise((n,u)=>{this.realFs.open(ue.fromPortablePath(r),o,a,this.makeCallback(n,u))})}openSync(r,o,a){return this.realFs.openSync(ue.fromPortablePath(r),o,a)}async opendirPromise(r,o){return await new Promise((a,n)=>{typeof o<"u"?this.realFs.opendir(ue.fromPortablePath(r),o,this.makeCallback(a,n)):this.realFs.opendir(ue.fromPortablePath(r),this.makeCallback(a,n))}).then(a=>{let n=a;return Object.defineProperty(n,"path",{value:r,configurable:!0,writable:!0}),n})}opendirSync(r,o){let n=typeof o<"u"?this.realFs.opendirSync(ue.fromPortablePath(r),o):this.realFs.opendirSync(ue.fromPortablePath(r));return Object.defineProperty(n,"path",{value:r,configurable:!0,writable:!0}),n}async readPromise(r,o,a=0,n=0,u=-1){return await new Promise((A,p)=>{this.realFs.read(r,o,a,n,u,(h,E)=>{h?p(h):A(E)})})}readSync(r,o,a,n,u){return this.realFs.readSync(r,o,a,n,u)}async writePromise(r,o,a,n,u){return await new Promise((A,p)=>typeof o=="string"?this.realFs.write(r,o,a,this.makeCallback(A,p)):this.realFs.write(r,o,a,n,u,this.makeCallback(A,p)))}writeSync(r,o,a,n,u){return typeof o=="string"?this.realFs.writeSync(r,o,a):this.realFs.writeSync(r,o,a,n,u)}async closePromise(r){await new Promise((o,a)=>{this.realFs.close(r,this.makeCallback(o,a))})}closeSync(r){this.realFs.closeSync(r)}createReadStream(r,o){let a=r!==null?ue.fromPortablePath(r):r;return this.realFs.createReadStream(a,o)}createWriteStream(r,o){let a=r!==null?ue.fromPortablePath(r):r;return this.realFs.createWriteStream(a,o)}async realpathPromise(r){return await new Promise((o,a)=>{this.realFs.realpath(ue.fromPortablePath(r),{},this.makeCallback(o,a))}).then(o=>ue.toPortablePath(o))}realpathSync(r){return ue.toPortablePath(this.realFs.realpathSync(ue.fromPortablePath(r),{}))}async existsPromise(r){return await new Promise(o=>{this.realFs.exists(ue.fromPortablePath(r),o)})}accessSync(r,o){return this.realFs.accessSync(ue.fromPortablePath(r),o)}async accessPromise(r,o){return await new Promise((a,n)=>{this.realFs.access(ue.fromPortablePath(r),o,this.makeCallback(a,n))})}existsSync(r){return this.realFs.existsSync(ue.fromPortablePath(r))}async statPromise(r,o){return await new Promise((a,n)=>{o?this.realFs.stat(ue.fromPortablePath(r),o,this.makeCallback(a,n)):this.realFs.stat(ue.fromPortablePath(r),this.makeCallback(a,n))})}statSync(r,o){return o?this.realFs.statSync(ue.fromPortablePath(r),o):this.realFs.statSync(ue.fromPortablePath(r))}async fstatPromise(r,o){return await new Promise((a,n)=>{o?this.realFs.fstat(r,o,this.makeCallback(a,n)):this.realFs.fstat(r,this.makeCallback(a,n))})}fstatSync(r,o){return o?this.realFs.fstatSync(r,o):this.realFs.fstatSync(r)}async lstatPromise(r,o){return await new Promise((a,n)=>{o?this.realFs.lstat(ue.fromPortablePath(r),o,this.makeCallback(a,n)):this.realFs.lstat(ue.fromPortablePath(r),this.makeCallback(a,n))})}lstatSync(r,o){return o?this.realFs.lstatSync(ue.fromPortablePath(r),o):this.realFs.lstatSync(ue.fromPortablePath(r))}async fchmodPromise(r,o){return await new Promise((a,n)=>{this.realFs.fchmod(r,o,this.makeCallback(a,n))})}fchmodSync(r,o){return this.realFs.fchmodSync(r,o)}async chmodPromise(r,o){return await new Promise((a,n)=>{this.realFs.chmod(ue.fromPortablePath(r),o,this.makeCallback(a,n))})}chmodSync(r,o){return this.realFs.chmodSync(ue.fromPortablePath(r),o)}async fchownPromise(r,o,a){return await new Promise((n,u)=>{this.realFs.fchown(r,o,a,this.makeCallback(n,u))})}fchownSync(r,o,a){return this.realFs.fchownSync(r,o,a)}async chownPromise(r,o,a){return await new Promise((n,u)=>{this.realFs.chown(ue.fromPortablePath(r),o,a,this.makeCallback(n,u))})}chownSync(r,o,a){return this.realFs.chownSync(ue.fromPortablePath(r),o,a)}async renamePromise(r,o){return await new Promise((a,n)=>{this.realFs.rename(ue.fromPortablePath(r),ue.fromPortablePath(o),this.makeCallback(a,n))})}renameSync(r,o){return this.realFs.renameSync(ue.fromPortablePath(r),ue.fromPortablePath(o))}async copyFilePromise(r,o,a=0){return await new Promise((n,u)=>{this.realFs.copyFile(ue.fromPortablePath(r),ue.fromPortablePath(o),a,this.makeCallback(n,u))})}copyFileSync(r,o,a=0){return this.realFs.copyFileSync(ue.fromPortablePath(r),ue.fromPortablePath(o),a)}async appendFilePromise(r,o,a){return await new Promise((n,u)=>{let A=typeof r=="string"?ue.fromPortablePath(r):r;a?this.realFs.appendFile(A,o,a,this.makeCallback(n,u)):this.realFs.appendFile(A,o,this.makeCallback(n,u))})}appendFileSync(r,o,a){let n=typeof r=="string"?ue.fromPortablePath(r):r;a?this.realFs.appendFileSync(n,o,a):this.realFs.appendFileSync(n,o)}async writeFilePromise(r,o,a){return await new Promise((n,u)=>{let A=typeof r=="string"?ue.fromPortablePath(r):r;a?this.realFs.writeFile(A,o,a,this.makeCallback(n,u)):this.realFs.writeFile(A,o,this.makeCallback(n,u))})}writeFileSync(r,o,a){let n=typeof r=="string"?ue.fromPortablePath(r):r;a?this.realFs.writeFileSync(n,o,a):this.realFs.writeFileSync(n,o)}async unlinkPromise(r){return await new Promise((o,a)=>{this.realFs.unlink(ue.fromPortablePath(r),this.makeCallback(o,a))})}unlinkSync(r){return this.realFs.unlinkSync(ue.fromPortablePath(r))}async utimesPromise(r,o,a){return await new Promise((n,u)=>{this.realFs.utimes(ue.fromPortablePath(r),o,a,this.makeCallback(n,u))})}utimesSync(r,o,a){this.realFs.utimesSync(ue.fromPortablePath(r),o,a)}async lutimesPromise(r,o,a){return await new Promise((n,u)=>{this.realFs.lutimes(ue.fromPortablePath(r),o,a,this.makeCallback(n,u))})}lutimesSync(r,o,a){this.realFs.lutimesSync(ue.fromPortablePath(r),o,a)}async mkdirPromise(r,o){return await new Promise((a,n)=>{this.realFs.mkdir(ue.fromPortablePath(r),o,this.makeCallback(a,n))})}mkdirSync(r,o){return this.realFs.mkdirSync(ue.fromPortablePath(r),o)}async rmdirPromise(r,o){return await new Promise((a,n)=>{o?this.realFs.rmdir(ue.fromPortablePath(r),o,this.makeCallback(a,n)):this.realFs.rmdir(ue.fromPortablePath(r),this.makeCallback(a,n))})}rmdirSync(r,o){return this.realFs.rmdirSync(ue.fromPortablePath(r),o)}async linkPromise(r,o){return await new Promise((a,n)=>{this.realFs.link(ue.fromPortablePath(r),ue.fromPortablePath(o),this.makeCallback(a,n))})}linkSync(r,o){return this.realFs.linkSync(ue.fromPortablePath(r),ue.fromPortablePath(o))}async symlinkPromise(r,o,a){return await new Promise((n,u)=>{this.realFs.symlink(ue.fromPortablePath(r.replace(/\/+$/,"")),ue.fromPortablePath(o),a,this.makeCallback(n,u))})}symlinkSync(r,o,a){return this.realFs.symlinkSync(ue.fromPortablePath(r.replace(/\/+$/,"")),ue.fromPortablePath(o),a)}async readFilePromise(r,o){return await new Promise((a,n)=>{let u=typeof r=="string"?ue.fromPortablePath(r):r;this.realFs.readFile(u,o,this.makeCallback(a,n))})}readFileSync(r,o){let a=typeof r=="string"?ue.fromPortablePath(r):r;return this.realFs.readFileSync(a,o)}async readdirPromise(r,o){return await new Promise((a,n)=>{o?o.recursive&&process.platform==="win32"?o.withFileTypes?this.realFs.readdir(ue.fromPortablePath(r),o,this.makeCallback(u=>a(u.map(G7)),n)):this.realFs.readdir(ue.fromPortablePath(r),o,this.makeCallback(u=>a(u.map(ue.toPortablePath)),n)):this.realFs.readdir(ue.fromPortablePath(r),o,this.makeCallback(a,n)):this.realFs.readdir(ue.fromPortablePath(r),this.makeCallback(a,n))})}readdirSync(r,o){return o?o.recursive&&process.platform==="win32"?o.withFileTypes?this.realFs.readdirSync(ue.fromPortablePath(r),o).map(G7):this.realFs.readdirSync(ue.fromPortablePath(r),o).map(ue.toPortablePath):this.realFs.readdirSync(ue.fromPortablePath(r),o):this.realFs.readdirSync(ue.fromPortablePath(r))}async readlinkPromise(r){return await new Promise((o,a)=>{this.realFs.readlink(ue.fromPortablePath(r),this.makeCallback(o,a))}).then(o=>ue.toPortablePath(o))}readlinkSync(r){return ue.toPortablePath(this.realFs.readlinkSync(ue.fromPortablePath(r)))}async truncatePromise(r,o){return await new Promise((a,n)=>{this.realFs.truncate(ue.fromPortablePath(r),o,this.makeCallback(a,n))})}truncateSync(r,o){return this.realFs.truncateSync(ue.fromPortablePath(r),o)}async ftruncatePromise(r,o){return await new Promise((a,n)=>{this.realFs.ftruncate(r,o,this.makeCallback(a,n))})}ftruncateSync(r,o){return this.realFs.ftruncateSync(r,o)}watch(r,o,a){return this.realFs.watch(ue.fromPortablePath(r),o,a)}watchFile(r,o,a){return this.realFs.watchFile(ue.fromPortablePath(r),o,a)}unwatchFile(r,o){return this.realFs.unwatchFile(ue.fromPortablePath(r),o)}makeCallback(r,o){return(a,n)=>{a?o(a):r(n)}}}});var gn,Y7=Et(()=>{jg();df();Ca();gn=class extends Ss{constructor(r,{baseFs:o=new Tn}={}){super(V);this.target=this.pathUtils.normalize(r),this.baseFs=o}getRealPath(){return this.pathUtils.resolve(this.baseFs.getRealPath(),this.target)}resolve(r){return this.pathUtils.isAbsolute(r)?V.normalize(r):this.baseFs.resolve(V.join(this.target,r))}mapFromBase(r){return r}mapToBase(r){return this.pathUtils.isAbsolute(r)?r:this.pathUtils.join(this.target,r)}}});var W7,_u,K7=Et(()=>{jg();df();Ca();W7=Bt.root,_u=class extends Ss{constructor(r,{baseFs:o=new Tn}={}){super(V);this.target=this.pathUtils.resolve(Bt.root,r),this.baseFs=o}getRealPath(){return this.pathUtils.resolve(this.baseFs.getRealPath(),this.pathUtils.relative(Bt.root,this.target))}getTarget(){return this.target}getBaseFs(){return this.baseFs}mapToBase(r){let o=this.pathUtils.normalize(r);if(this.pathUtils.isAbsolute(r))return this.pathUtils.resolve(this.target,this.pathUtils.relative(W7,r));if(o.match(/^\.\.\/?/))throw new Error(`Resolving this path (${r}) would escape the jail`);return this.pathUtils.resolve(this.target,r)}mapFromBase(r){return this.pathUtils.resolve(W7,this.pathUtils.relative(this.target,r))}}});var iy,V7=Et(()=>{df();iy=class extends Ss{constructor(r,o){super(o);this.instance=null;this.factory=r}get baseFs(){return this.instance||(this.instance=this.factory()),this.instance}set baseFs(r){this.instance=r}mapFromBase(r){return r}mapToBase(r){return r}}});var Gg,wa,Hp,J7=Et(()=>{Gg=ve("fs");Hg();jg();YR();BD();Ca();wa=4278190080,Hp=class extends Mu{constructor({baseFs:r=new Tn,filter:o=null,magicByte:a=42,maxOpenFiles:n=1/0,useCache:u=!0,maxAge:A=5e3,typeCheck:p=Gg.constants.S_IFREG,getMountPoint:h,factoryPromise:E,factorySync:I}){if(Math.floor(a)!==a||!(a>1&&a<=127))throw new Error("The magic byte must be set to a round value between 1 and 127 included");super();this.fdMap=new Map;this.nextFd=3;this.isMount=new Set;this.notMount=new Set;this.realPaths=new Map;this.limitOpenFilesTimeout=null;this.baseFs=r,this.mountInstances=u?new Map:null,this.factoryPromise=E,this.factorySync=I,this.filter=o,this.getMountPoint=h,this.magic=a<<24,this.maxAge=A,this.maxOpenFiles=n,this.typeCheck=p}getExtractHint(r){return this.baseFs.getExtractHint(r)}getRealPath(){return this.baseFs.getRealPath()}saveAndClose(){if(Ug(this),this.mountInstances)for(let[r,{childFs:o}]of this.mountInstances.entries())o.saveAndClose?.(),this.mountInstances.delete(r)}discardAndClose(){if(Ug(this),this.mountInstances)for(let[r,{childFs:o}]of this.mountInstances.entries())o.discardAndClose?.(),this.mountInstances.delete(r)}resolve(r){return this.baseFs.resolve(r)}remapFd(r,o){let a=this.nextFd++|this.magic;return this.fdMap.set(a,[r,o]),a}async openPromise(r,o,a){return await this.makeCallPromise(r,async()=>await this.baseFs.openPromise(r,o,a),async(n,{subPath:u})=>this.remapFd(n,await n.openPromise(u,o,a)))}openSync(r,o,a){return this.makeCallSync(r,()=>this.baseFs.openSync(r,o,a),(n,{subPath:u})=>this.remapFd(n,n.openSync(u,o,a)))}async opendirPromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.opendirPromise(r,o),async(a,{subPath:n})=>await a.opendirPromise(n,o),{requireSubpath:!1})}opendirSync(r,o){return this.makeCallSync(r,()=>this.baseFs.opendirSync(r,o),(a,{subPath:n})=>a.opendirSync(n,o),{requireSubpath:!1})}async readPromise(r,o,a,n,u){if((r&wa)!==this.magic)return await this.baseFs.readPromise(r,o,a,n,u);let A=this.fdMap.get(r);if(typeof A>"u")throw Io("read");let[p,h]=A;return await p.readPromise(h,o,a,n,u)}readSync(r,o,a,n,u){if((r&wa)!==this.magic)return this.baseFs.readSync(r,o,a,n,u);let A=this.fdMap.get(r);if(typeof A>"u")throw Io("readSync");let[p,h]=A;return p.readSync(h,o,a,n,u)}async writePromise(r,o,a,n,u){if((r&wa)!==this.magic)return typeof o=="string"?await this.baseFs.writePromise(r,o,a):await this.baseFs.writePromise(r,o,a,n,u);let A=this.fdMap.get(r);if(typeof A>"u")throw Io("write");let[p,h]=A;return typeof o=="string"?await p.writePromise(h,o,a):await p.writePromise(h,o,a,n,u)}writeSync(r,o,a,n,u){if((r&wa)!==this.magic)return typeof o=="string"?this.baseFs.writeSync(r,o,a):this.baseFs.writeSync(r,o,a,n,u);let A=this.fdMap.get(r);if(typeof A>"u")throw Io("writeSync");let[p,h]=A;return typeof o=="string"?p.writeSync(h,o,a):p.writeSync(h,o,a,n,u)}async closePromise(r){if((r&wa)!==this.magic)return await this.baseFs.closePromise(r);let o=this.fdMap.get(r);if(typeof o>"u")throw Io("close");this.fdMap.delete(r);let[a,n]=o;return await a.closePromise(n)}closeSync(r){if((r&wa)!==this.magic)return this.baseFs.closeSync(r);let o=this.fdMap.get(r);if(typeof o>"u")throw Io("closeSync");this.fdMap.delete(r);let[a,n]=o;return a.closeSync(n)}createReadStream(r,o){return r===null?this.baseFs.createReadStream(r,o):this.makeCallSync(r,()=>this.baseFs.createReadStream(r,o),(a,{archivePath:n,subPath:u})=>{let A=a.createReadStream(u,o);return A.path=ue.fromPortablePath(this.pathUtils.join(n,u)),A})}createWriteStream(r,o){return r===null?this.baseFs.createWriteStream(r,o):this.makeCallSync(r,()=>this.baseFs.createWriteStream(r,o),(a,{subPath:n})=>a.createWriteStream(n,o))}async realpathPromise(r){return await this.makeCallPromise(r,async()=>await this.baseFs.realpathPromise(r),async(o,{archivePath:a,subPath:n})=>{let u=this.realPaths.get(a);return typeof u>"u"&&(u=await this.baseFs.realpathPromise(a),this.realPaths.set(a,u)),this.pathUtils.join(u,this.pathUtils.relative(Bt.root,await o.realpathPromise(n)))})}realpathSync(r){return this.makeCallSync(r,()=>this.baseFs.realpathSync(r),(o,{archivePath:a,subPath:n})=>{let u=this.realPaths.get(a);return typeof u>"u"&&(u=this.baseFs.realpathSync(a),this.realPaths.set(a,u)),this.pathUtils.join(u,this.pathUtils.relative(Bt.root,o.realpathSync(n)))})}async existsPromise(r){return await this.makeCallPromise(r,async()=>await this.baseFs.existsPromise(r),async(o,{subPath:a})=>await o.existsPromise(a))}existsSync(r){return this.makeCallSync(r,()=>this.baseFs.existsSync(r),(o,{subPath:a})=>o.existsSync(a))}async accessPromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.accessPromise(r,o),async(a,{subPath:n})=>await a.accessPromise(n,o))}accessSync(r,o){return this.makeCallSync(r,()=>this.baseFs.accessSync(r,o),(a,{subPath:n})=>a.accessSync(n,o))}async statPromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.statPromise(r,o),async(a,{subPath:n})=>await a.statPromise(n,o))}statSync(r,o){return this.makeCallSync(r,()=>this.baseFs.statSync(r,o),(a,{subPath:n})=>a.statSync(n,o))}async fstatPromise(r,o){if((r&wa)!==this.magic)return this.baseFs.fstatPromise(r,o);let a=this.fdMap.get(r);if(typeof a>"u")throw Io("fstat");let[n,u]=a;return n.fstatPromise(u,o)}fstatSync(r,o){if((r&wa)!==this.magic)return this.baseFs.fstatSync(r,o);let a=this.fdMap.get(r);if(typeof a>"u")throw Io("fstatSync");let[n,u]=a;return n.fstatSync(u,o)}async lstatPromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.lstatPromise(r,o),async(a,{subPath:n})=>await a.lstatPromise(n,o))}lstatSync(r,o){return this.makeCallSync(r,()=>this.baseFs.lstatSync(r,o),(a,{subPath:n})=>a.lstatSync(n,o))}async fchmodPromise(r,o){if((r&wa)!==this.magic)return this.baseFs.fchmodPromise(r,o);let a=this.fdMap.get(r);if(typeof a>"u")throw Io("fchmod");let[n,u]=a;return n.fchmodPromise(u,o)}fchmodSync(r,o){if((r&wa)!==this.magic)return this.baseFs.fchmodSync(r,o);let a=this.fdMap.get(r);if(typeof a>"u")throw Io("fchmodSync");let[n,u]=a;return n.fchmodSync(u,o)}async chmodPromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.chmodPromise(r,o),async(a,{subPath:n})=>await a.chmodPromise(n,o))}chmodSync(r,o){return this.makeCallSync(r,()=>this.baseFs.chmodSync(r,o),(a,{subPath:n})=>a.chmodSync(n,o))}async fchownPromise(r,o,a){if((r&wa)!==this.magic)return this.baseFs.fchownPromise(r,o,a);let n=this.fdMap.get(r);if(typeof n>"u")throw Io("fchown");let[u,A]=n;return u.fchownPromise(A,o,a)}fchownSync(r,o,a){if((r&wa)!==this.magic)return this.baseFs.fchownSync(r,o,a);let n=this.fdMap.get(r);if(typeof n>"u")throw Io("fchownSync");let[u,A]=n;return u.fchownSync(A,o,a)}async chownPromise(r,o,a){return await this.makeCallPromise(r,async()=>await this.baseFs.chownPromise(r,o,a),async(n,{subPath:u})=>await n.chownPromise(u,o,a))}chownSync(r,o,a){return this.makeCallSync(r,()=>this.baseFs.chownSync(r,o,a),(n,{subPath:u})=>n.chownSync(u,o,a))}async renamePromise(r,o){return await this.makeCallPromise(r,async()=>await this.makeCallPromise(o,async()=>await this.baseFs.renamePromise(r,o),async()=>{throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"})}),async(a,{subPath:n})=>await this.makeCallPromise(o,async()=>{throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"})},async(u,{subPath:A})=>{if(a!==u)throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"});return await a.renamePromise(n,A)}))}renameSync(r,o){return this.makeCallSync(r,()=>this.makeCallSync(o,()=>this.baseFs.renameSync(r,o),()=>{throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"})}),(a,{subPath:n})=>this.makeCallSync(o,()=>{throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"})},(u,{subPath:A})=>{if(a!==u)throw Object.assign(new Error("EEXDEV: cross-device link not permitted"),{code:"EEXDEV"});return a.renameSync(n,A)}))}async copyFilePromise(r,o,a=0){let n=async(u,A,p,h)=>{if((a&Gg.constants.COPYFILE_FICLONE_FORCE)!==0)throw Object.assign(new Error(`EXDEV: cross-device clone not permitted, copyfile '${A}' -> ${h}'`),{code:"EXDEV"});if(a&Gg.constants.COPYFILE_EXCL&&await this.existsPromise(A))throw Object.assign(new Error(`EEXIST: file already exists, copyfile '${A}' -> '${h}'`),{code:"EEXIST"});let E;try{E=await u.readFilePromise(A)}catch{throw Object.assign(new Error(`EINVAL: invalid argument, copyfile '${A}' -> '${h}'`),{code:"EINVAL"})}await p.writeFilePromise(h,E)};return await this.makeCallPromise(r,async()=>await this.makeCallPromise(o,async()=>await this.baseFs.copyFilePromise(r,o,a),async(u,{subPath:A})=>await n(this.baseFs,r,u,A)),async(u,{subPath:A})=>await this.makeCallPromise(o,async()=>await n(u,A,this.baseFs,o),async(p,{subPath:h})=>u!==p?await n(u,A,p,h):await u.copyFilePromise(A,h,a)))}copyFileSync(r,o,a=0){let n=(u,A,p,h)=>{if((a&Gg.constants.COPYFILE_FICLONE_FORCE)!==0)throw Object.assign(new Error(`EXDEV: cross-device clone not permitted, copyfile '${A}' -> ${h}'`),{code:"EXDEV"});if(a&Gg.constants.COPYFILE_EXCL&&this.existsSync(A))throw Object.assign(new Error(`EEXIST: file already exists, copyfile '${A}' -> '${h}'`),{code:"EEXIST"});let E;try{E=u.readFileSync(A)}catch{throw Object.assign(new Error(`EINVAL: invalid argument, copyfile '${A}' -> '${h}'`),{code:"EINVAL"})}p.writeFileSync(h,E)};return this.makeCallSync(r,()=>this.makeCallSync(o,()=>this.baseFs.copyFileSync(r,o,a),(u,{subPath:A})=>n(this.baseFs,r,u,A)),(u,{subPath:A})=>this.makeCallSync(o,()=>n(u,A,this.baseFs,o),(p,{subPath:h})=>u!==p?n(u,A,p,h):u.copyFileSync(A,h,a)))}async appendFilePromise(r,o,a){return await this.makeCallPromise(r,async()=>await this.baseFs.appendFilePromise(r,o,a),async(n,{subPath:u})=>await n.appendFilePromise(u,o,a))}appendFileSync(r,o,a){return this.makeCallSync(r,()=>this.baseFs.appendFileSync(r,o,a),(n,{subPath:u})=>n.appendFileSync(u,o,a))}async writeFilePromise(r,o,a){return await this.makeCallPromise(r,async()=>await this.baseFs.writeFilePromise(r,o,a),async(n,{subPath:u})=>await n.writeFilePromise(u,o,a))}writeFileSync(r,o,a){return this.makeCallSync(r,()=>this.baseFs.writeFileSync(r,o,a),(n,{subPath:u})=>n.writeFileSync(u,o,a))}async unlinkPromise(r){return await this.makeCallPromise(r,async()=>await this.baseFs.unlinkPromise(r),async(o,{subPath:a})=>await o.unlinkPromise(a))}unlinkSync(r){return this.makeCallSync(r,()=>this.baseFs.unlinkSync(r),(o,{subPath:a})=>o.unlinkSync(a))}async utimesPromise(r,o,a){return await this.makeCallPromise(r,async()=>await this.baseFs.utimesPromise(r,o,a),async(n,{subPath:u})=>await n.utimesPromise(u,o,a))}utimesSync(r,o,a){return this.makeCallSync(r,()=>this.baseFs.utimesSync(r,o,a),(n,{subPath:u})=>n.utimesSync(u,o,a))}async lutimesPromise(r,o,a){return await this.makeCallPromise(r,async()=>await this.baseFs.lutimesPromise(r,o,a),async(n,{subPath:u})=>await n.lutimesPromise(u,o,a))}lutimesSync(r,o,a){return this.makeCallSync(r,()=>this.baseFs.lutimesSync(r,o,a),(n,{subPath:u})=>n.lutimesSync(u,o,a))}async mkdirPromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.mkdirPromise(r,o),async(a,{subPath:n})=>await a.mkdirPromise(n,o))}mkdirSync(r,o){return this.makeCallSync(r,()=>this.baseFs.mkdirSync(r,o),(a,{subPath:n})=>a.mkdirSync(n,o))}async rmdirPromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.rmdirPromise(r,o),async(a,{subPath:n})=>await a.rmdirPromise(n,o))}rmdirSync(r,o){return this.makeCallSync(r,()=>this.baseFs.rmdirSync(r,o),(a,{subPath:n})=>a.rmdirSync(n,o))}async linkPromise(r,o){return await this.makeCallPromise(o,async()=>await this.baseFs.linkPromise(r,o),async(a,{subPath:n})=>await a.linkPromise(r,n))}linkSync(r,o){return this.makeCallSync(o,()=>this.baseFs.linkSync(r,o),(a,{subPath:n})=>a.linkSync(r,n))}async symlinkPromise(r,o,a){return await this.makeCallPromise(o,async()=>await this.baseFs.symlinkPromise(r,o,a),async(n,{subPath:u})=>await n.symlinkPromise(r,u))}symlinkSync(r,o,a){return this.makeCallSync(o,()=>this.baseFs.symlinkSync(r,o,a),(n,{subPath:u})=>n.symlinkSync(r,u))}async readFilePromise(r,o){return this.makeCallPromise(r,async()=>await this.baseFs.readFilePromise(r,o),async(a,{subPath:n})=>await a.readFilePromise(n,o))}readFileSync(r,o){return this.makeCallSync(r,()=>this.baseFs.readFileSync(r,o),(a,{subPath:n})=>a.readFileSync(n,o))}async readdirPromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.readdirPromise(r,o),async(a,{subPath:n})=>await a.readdirPromise(n,o),{requireSubpath:!1})}readdirSync(r,o){return this.makeCallSync(r,()=>this.baseFs.readdirSync(r,o),(a,{subPath:n})=>a.readdirSync(n,o),{requireSubpath:!1})}async readlinkPromise(r){return await this.makeCallPromise(r,async()=>await this.baseFs.readlinkPromise(r),async(o,{subPath:a})=>await o.readlinkPromise(a))}readlinkSync(r){return this.makeCallSync(r,()=>this.baseFs.readlinkSync(r),(o,{subPath:a})=>o.readlinkSync(a))}async truncatePromise(r,o){return await this.makeCallPromise(r,async()=>await this.baseFs.truncatePromise(r,o),async(a,{subPath:n})=>await a.truncatePromise(n,o))}truncateSync(r,o){return this.makeCallSync(r,()=>this.baseFs.truncateSync(r,o),(a,{subPath:n})=>a.truncateSync(n,o))}async ftruncatePromise(r,o){if((r&wa)!==this.magic)return this.baseFs.ftruncatePromise(r,o);let a=this.fdMap.get(r);if(typeof a>"u")throw Io("ftruncate");let[n,u]=a;return n.ftruncatePromise(u,o)}ftruncateSync(r,o){if((r&wa)!==this.magic)return this.baseFs.ftruncateSync(r,o);let a=this.fdMap.get(r);if(typeof a>"u")throw Io("ftruncateSync");let[n,u]=a;return n.ftruncateSync(u,o)}watch(r,o,a){return this.makeCallSync(r,()=>this.baseFs.watch(r,o,a),(n,{subPath:u})=>n.watch(u,o,a))}watchFile(r,o,a){return this.makeCallSync(r,()=>this.baseFs.watchFile(r,o,a),()=>ny(this,r,o,a))}unwatchFile(r,o){return this.makeCallSync(r,()=>this.baseFs.unwatchFile(r,o),()=>Mg(this,r,o))}async makeCallPromise(r,o,a,{requireSubpath:n=!0}={}){if(typeof r!="string")return await o();let u=this.resolve(r),A=this.findMount(u);return A?n&&A.subPath==="/"?await o():await this.getMountPromise(A.archivePath,async p=>await a(p,A)):await o()}makeCallSync(r,o,a,{requireSubpath:n=!0}={}){if(typeof r!="string")return o();let u=this.resolve(r),A=this.findMount(u);return!A||n&&A.subPath==="/"?o():this.getMountSync(A.archivePath,p=>a(p,A))}findMount(r){if(this.filter&&!this.filter.test(r))return null;let o="";for(;;){let a=r.substring(o.length),n=this.getMountPoint(a,o);if(!n)return null;if(o=this.pathUtils.join(o,n),!this.isMount.has(o)){if(this.notMount.has(o))continue;try{if(this.typeCheck!==null&&(this.baseFs.lstatSync(o).mode&Gg.constants.S_IFMT)!==this.typeCheck){this.notMount.add(o);continue}}catch{return null}this.isMount.add(o)}return{archivePath:o,subPath:this.pathUtils.join(Bt.root,r.substring(o.length))}}}limitOpenFiles(r){if(this.mountInstances===null)return;let o=Date.now(),a=o+this.maxAge,n=r===null?0:this.mountInstances.size-r;for(let[u,{childFs:A,expiresAt:p,refCount:h}]of this.mountInstances.entries())if(!(h!==0||A.hasOpenFileHandles?.())){if(o>=p){A.saveAndClose?.(),this.mountInstances.delete(u),n-=1;continue}else if(r===null||n<=0){a=p;break}A.saveAndClose?.(),this.mountInstances.delete(u),n-=1}this.limitOpenFilesTimeout===null&&(r===null&&this.mountInstances.size>0||r!==null)&&isFinite(a)&&(this.limitOpenFilesTimeout=setTimeout(()=>{this.limitOpenFilesTimeout=null,this.limitOpenFiles(null)},a-o).unref())}async getMountPromise(r,o){if(this.mountInstances){let a=this.mountInstances.get(r);if(!a){let n=await this.factoryPromise(this.baseFs,r);a=this.mountInstances.get(r),a||(a={childFs:n(),expiresAt:0,refCount:0})}this.mountInstances.delete(r),this.limitOpenFiles(this.maxOpenFiles-1),this.mountInstances.set(r,a),a.expiresAt=Date.now()+this.maxAge,a.refCount+=1;try{return await o(a.childFs)}finally{a.refCount-=1}}else{let a=(await this.factoryPromise(this.baseFs,r))();try{return await o(a)}finally{a.saveAndClose?.()}}}getMountSync(r,o){if(this.mountInstances){let a=this.mountInstances.get(r);return a||(a={childFs:this.factorySync(this.baseFs,r),expiresAt:0,refCount:0}),this.mountInstances.delete(r),this.limitOpenFiles(this.maxOpenFiles-1),this.mountInstances.set(r,a),a.expiresAt=Date.now()+this.maxAge,o(a.childFs)}else{let a=this.factorySync(this.baseFs,r);try{return o(a)}finally{a.saveAndClose?.()}}}}});var Zt,WR,Yw,z7=Et(()=>{Hg();Ca();Zt=()=>Object.assign(new Error("ENOSYS: unsupported filesystem access"),{code:"ENOSYS"}),WR=class extends gf{constructor(){super(V)}getExtractHint(){throw Zt()}getRealPath(){throw Zt()}resolve(){throw Zt()}async openPromise(){throw Zt()}openSync(){throw Zt()}async opendirPromise(){throw Zt()}opendirSync(){throw Zt()}async readPromise(){throw Zt()}readSync(){throw Zt()}async writePromise(){throw Zt()}writeSync(){throw Zt()}async closePromise(){throw Zt()}closeSync(){throw Zt()}createWriteStream(){throw Zt()}createReadStream(){throw Zt()}async realpathPromise(){throw Zt()}realpathSync(){throw Zt()}async readdirPromise(){throw Zt()}readdirSync(){throw Zt()}async existsPromise(e){throw Zt()}existsSync(e){throw Zt()}async accessPromise(){throw Zt()}accessSync(){throw Zt()}async statPromise(){throw Zt()}statSync(){throw Zt()}async fstatPromise(e){throw Zt()}fstatSync(e){throw Zt()}async lstatPromise(e){throw Zt()}lstatSync(e){throw Zt()}async fchmodPromise(){throw Zt()}fchmodSync(){throw Zt()}async chmodPromise(){throw Zt()}chmodSync(){throw Zt()}async fchownPromise(){throw Zt()}fchownSync(){throw Zt()}async chownPromise(){throw Zt()}chownSync(){throw Zt()}async mkdirPromise(){throw Zt()}mkdirSync(){throw Zt()}async rmdirPromise(){throw Zt()}rmdirSync(){throw Zt()}async linkPromise(){throw Zt()}linkSync(){throw Zt()}async symlinkPromise(){throw Zt()}symlinkSync(){throw Zt()}async renamePromise(){throw Zt()}renameSync(){throw Zt()}async copyFilePromise(){throw Zt()}copyFileSync(){throw Zt()}async appendFilePromise(){throw Zt()}appendFileSync(){throw Zt()}async writeFilePromise(){throw Zt()}writeFileSync(){throw Zt()}async unlinkPromise(){throw Zt()}unlinkSync(){throw Zt()}async utimesPromise(){throw Zt()}utimesSync(){throw Zt()}async lutimesPromise(){throw Zt()}lutimesSync(){throw Zt()}async readFilePromise(){throw Zt()}readFileSync(){throw Zt()}async readlinkPromise(){throw Zt()}readlinkSync(){throw Zt()}async truncatePromise(){throw Zt()}truncateSync(){throw Zt()}async ftruncatePromise(e,r){throw Zt()}ftruncateSync(e,r){throw Zt()}watch(){throw Zt()}watchFile(){throw Zt()}unwatchFile(){throw Zt()}},Yw=WR;Yw.instance=new WR});var jp,X7=Et(()=>{df();Ca();jp=class extends Ss{constructor(r){super(ue);this.baseFs=r}mapFromBase(r){return ue.fromPortablePath(r)}mapToBase(r){return ue.toPortablePath(r)}}});var x_e,KR,k_e,mi,Z7=Et(()=>{jg();df();Ca();x_e=/^[0-9]+$/,KR=/^(\/(?:[^/]+\/)*?(?:\$\$virtual|__virtual__))((?:\/((?:[^/]+-)?[a-f0-9]+)(?:\/([^/]+))?)?((?:\/.*)?))$/,k_e=/^([^/]+-)?[a-f0-9]+$/,mi=class extends Ss{constructor({baseFs:r=new Tn}={}){super(V);this.baseFs=r}static makeVirtualPath(r,o,a){if(V.basename(r)!=="__virtual__")throw new Error('Assertion failed: Virtual folders must be named "__virtual__"');if(!V.basename(o).match(k_e))throw new Error("Assertion failed: Virtual components must be ended by an hexadecimal hash");let u=V.relative(V.dirname(r),a).split("/"),A=0;for(;A<u.length&&u[A]==="..";)A+=1;let p=u.slice(A);return V.join(r,o,String(A),...p)}static resolveVirtual(r){let o=r.match(KR);if(!o||!o[3]&&o[5])return r;let a=V.dirname(o[1]);if(!o[3]||!o[4])return a;if(!x_e.test(o[4]))return r;let u=Number(o[4]),A="../".repeat(u),p=o[5]||".";return mi.resolveVirtual(V.join(a,A,p))}getExtractHint(r){return this.baseFs.getExtractHint(r)}getRealPath(){return this.baseFs.getRealPath()}realpathSync(r){let o=r.match(KR);if(!o)return this.baseFs.realpathSync(r);if(!o[5])return r;let a=this.baseFs.realpathSync(this.mapToBase(r));return mi.makeVirtualPath(o[1],o[3],a)}async realpathPromise(r){let o=r.match(KR);if(!o)return await this.baseFs.realpathPromise(r);if(!o[5])return r;let a=await this.baseFs.realpathPromise(this.mapToBase(r));return mi.makeVirtualPath(o[1],o[3],a)}mapToBase(r){if(r==="")return r;if(this.pathUtils.isAbsolute(r))return mi.resolveVirtual(r);let o=mi.resolveVirtual(this.baseFs.resolve(Bt.dot)),a=mi.resolveVirtual(this.baseFs.resolve(r));return V.relative(o,a)||Bt.dot}mapFromBase(r){return r}}});function Q_e(t,e){return typeof VR.default.isUtf8<"u"?VR.default.isUtf8(t):Buffer.byteLength(e)===t.byteLength}var VR,$7,eY,xD,tY=Et(()=>{VR=$e(ve("buffer")),$7=ve("url"),eY=ve("util");df();Ca();xD=class extends Ss{constructor(r){super(ue);this.baseFs=r}mapFromBase(r){return r}mapToBase(r){if(typeof r=="string")return r;if(r instanceof URL)return(0,$7.fileURLToPath)(r);if(Buffer.isBuffer(r)){let o=r.toString();if(!Q_e(r,o))throw new Error("Non-utf8 buffers are not supported at the moment. Please upvote the following issue if you encounter this error: https://github.com/yarnpkg/berry/issues/4942");return o}throw new Error(`Unsupported path type: ${(0,eY.inspect)(r)}`)}}});var rY,Bo,mf,Gp,kD,QD,sy,Tc,Nc,F_e,R_e,T_e,N_e,Ww,nY=Et(()=>{rY=ve("readline"),Bo=Symbol("kBaseFs"),mf=Symbol("kFd"),Gp=Symbol("kClosePromise"),kD=Symbol("kCloseResolve"),QD=Symbol("kCloseReject"),sy=Symbol("kRefs"),Tc=Symbol("kRef"),Nc=Symbol("kUnref"),Ww=class{constructor(e,r){this[F_e]=1;this[R_e]=void 0;this[T_e]=void 0;this[N_e]=void 0;this[Bo]=r,this[mf]=e}get fd(){return this[mf]}async appendFile(e,r){try{this[Tc](this.appendFile);let o=(typeof r=="string"?r:r?.encoding)??void 0;return await this[Bo].appendFilePromise(this.fd,e,o?{encoding:o}:void 0)}finally{this[Nc]()}}async chown(e,r){try{return this[Tc](this.chown),await this[Bo].fchownPromise(this.fd,e,r)}finally{this[Nc]()}}async chmod(e){try{return this[Tc](this.chmod),await this[Bo].fchmodPromise(this.fd,e)}finally{this[Nc]()}}createReadStream(e){return this[Bo].createReadStream(null,{...e,fd:this.fd})}createWriteStream(e){return this[Bo].createWriteStream(null,{...e,fd:this.fd})}datasync(){throw new Error("Method not implemented.")}sync(){throw new Error("Method not implemented.")}async read(e,r,o,a){try{this[Tc](this.read);let n;return Buffer.isBuffer(e)?n=e:(e??={},n=e.buffer??Buffer.alloc(16384),r=e.offset||0,o=e.length??n.byteLength,a=e.position??null),r??=0,o??=0,o===0?{bytesRead:o,buffer:n}:{bytesRead:await this[Bo].readPromise(this.fd,n,r,o,a),buffer:n}}finally{this[Nc]()}}async readFile(e){try{this[Tc](this.readFile);let r=(typeof e=="string"?e:e?.encoding)??void 0;return await this[Bo].readFilePromise(this.fd,r)}finally{this[Nc]()}}readLines(e){return(0,rY.createInterface)({input:this.createReadStream(e),crlfDelay:1/0})}async stat(e){try{return this[Tc](this.stat),await this[Bo].fstatPromise(this.fd,e)}finally{this[Nc]()}}async truncate(e){try{return this[Tc](this.truncate),await this[Bo].ftruncatePromise(this.fd,e)}finally{this[Nc]()}}utimes(e,r){throw new Error("Method not implemented.")}async writeFile(e,r){try{this[Tc](this.writeFile);let o=(typeof r=="string"?r:r?.encoding)??void 0;await this[Bo].writeFilePromise(this.fd,e,o)}finally{this[Nc]()}}async write(...e){try{if(this[Tc](this.write),ArrayBuffer.isView(e[0])){let[r,o,a,n]=e;return{bytesWritten:await this[Bo].writePromise(this.fd,r,o??void 0,a??void 0,n??void 0),buffer:r}}else{let[r,o,a]=e;return{bytesWritten:await this[Bo].writePromise(this.fd,r,o,a),buffer:r}}}finally{this[Nc]()}}async writev(e,r){try{this[Tc](this.writev);let o=0;if(typeof r<"u")for(let a of e){let n=await this.write(a,void 0,void 0,r);o+=n.bytesWritten,r+=n.bytesWritten}else for(let a of e){let n=await this.write(a);o+=n.bytesWritten}return{buffers:e,bytesWritten:o}}finally{this[Nc]()}}readv(e,r){throw new Error("Method not implemented.")}close(){if(this[mf]===-1)return Promise.resolve();if(this[Gp])return this[Gp];if(this[sy]--,this[sy]===0){let e=this[mf];this[mf]=-1,this[Gp]=this[Bo].closePromise(e).finally(()=>{this[Gp]=void 0})}else this[Gp]=new Promise((e,r)=>{this[kD]=e,this[QD]=r}).finally(()=>{this[Gp]=void 0,this[QD]=void 0,this[kD]=void 0});return this[Gp]}[(Bo,mf,F_e=sy,R_e=Gp,T_e=kD,N_e=QD,Tc)](e){if(this[mf]===-1){let r=new Error("file closed");throw r.code="EBADF",r.syscall=e.name,r}this[sy]++}[Nc](){if(this[sy]--,this[sy]===0){let e=this[mf];this[mf]=-1,this[Bo].closePromise(e).then(this[kD],this[QD])}}}});function Kw(t,e){e=new xD(e);let r=(o,a,n)=>{let u=o[a];o[a]=n,typeof u?.[oy.promisify.custom]<"u"&&(n[oy.promisify.custom]=u[oy.promisify.custom])};{r(t,"exists",(o,...a)=>{let u=typeof a[a.length-1]=="function"?a.pop():()=>{};process.nextTick(()=>{e.existsPromise(o).then(A=>{u(A)},()=>{u(!1)})})}),r(t,"read",(...o)=>{let[a,n,u,A,p,h]=o;if(o.length<=3){let E={};o.length<3?h=o[1]:(E=o[1],h=o[2]),{buffer:n=Buffer.alloc(16384),offset:u=0,length:A=n.byteLength,position:p}=E}if(u==null&&(u=0),A|=0,A===0){process.nextTick(()=>{h(null,0,n)});return}p==null&&(p=-1),process.nextTick(()=>{e.readPromise(a,n,u,A,p).then(E=>{h(null,E,n)},E=>{h(E,0,n)})})});for(let o of iY){let a=o.replace(/Promise$/,"");if(typeof t[a]>"u")continue;let n=e[o];if(typeof n>"u")continue;r(t,a,(...A)=>{let h=typeof A[A.length-1]=="function"?A.pop():()=>{};process.nextTick(()=>{n.apply(e,A).then(E=>{h(null,E)},E=>{h(E)})})})}t.realpath.native=t.realpath}{r(t,"existsSync",o=>{try{return e.existsSync(o)}catch{return!1}}),r(t,"readSync",(...o)=>{let[a,n,u,A,p]=o;return o.length<=3&&({offset:u=0,length:A=n.byteLength,position:p}=o[2]||{}),u==null&&(u=0),A|=0,A===0?0:(p==null&&(p=-1),e.readSync(a,n,u,A,p))});for(let o of L_e){let a=o;if(typeof t[a]>"u")continue;let n=e[o];typeof n>"u"||r(t,a,n.bind(e))}t.realpathSync.native=t.realpathSync}{let o=t.promises;for(let a of iY){let n=a.replace(/Promise$/,"");if(typeof o[n]>"u")continue;let u=e[a];typeof u>"u"||a!=="open"&&r(o,n,(A,...p)=>A instanceof Ww?A[n].apply(A,p):u.call(e,A,...p))}r(o,"open",async(...a)=>{let n=await e.openPromise(...a);return new Ww(n,e)})}t.read[oy.promisify.custom]=async(o,a,...n)=>({bytesRead:await e.readPromise(o,a,...n),buffer:a}),t.write[oy.promisify.custom]=async(o,a,...n)=>({bytesWritten:await e.writePromise(o,a,...n),buffer:a})}function FD(t,e){let r=Object.create(t);return Kw(r,e),r}var oy,L_e,iY,sY=Et(()=>{oy=ve("util");tY();nY();L_e=new Set(["accessSync","appendFileSync","createReadStream","createWriteStream","chmodSync","fchmodSync","chownSync","fchownSync","closeSync","copyFileSync","linkSync","lstatSync","fstatSync","lutimesSync","mkdirSync","openSync","opendirSync","readlinkSync","readFileSync","readdirSync","readlinkSync","realpathSync","renameSync","rmdirSync","statSync","symlinkSync","truncateSync","ftruncateSync","unlinkSync","unwatchFile","utimesSync","watch","watchFile","writeFileSync","writeSync"]),iY=new Set(["accessPromise","appendFilePromise","fchmodPromise","chmodPromise","fchownPromise","chownPromise","closePromise","copyFilePromise","linkPromise","fstatPromise","lstatPromise","lutimesPromise","mkdirPromise","openPromise","opendirPromise","readdirPromise","realpathPromise","readFilePromise","readdirPromise","readlinkPromise","renamePromise","rmdirPromise","statPromise","symlinkPromise","truncatePromise","ftruncatePromise","unlinkPromise","utimesPromise","writeFilePromise","writeSync"])});function oY(t){let e=Math.ceil(Math.random()*4294967296).toString(16).padStart(8,"0");return`${t}${e}`}function aY(){if(JR)return JR;let t=ue.toPortablePath(lY.default.tmpdir()),e=oe.realpathSync(t);return process.once("exit",()=>{oe.rmtempSync()}),JR={tmpdir:t,realTmpdir:e}}var lY,Lc,JR,oe,cY=Et(()=>{lY=$e(ve("os"));jg();Ca();Lc=new Set,JR=null;oe=Object.assign(new Tn,{detachTemp(t){Lc.delete(t)},mktempSync(t){let{tmpdir:e,realTmpdir:r}=aY();for(;;){let o=oY("xfs-");try{this.mkdirSync(V.join(e,o))}catch(n){if(n.code==="EEXIST")continue;throw n}let a=V.join(r,o);if(Lc.add(a),typeof t>"u")return a;try{return t(a)}finally{if(Lc.has(a)){Lc.delete(a);try{this.removeSync(a)}catch{}}}}},async mktempPromise(t){let{tmpdir:e,realTmpdir:r}=aY();for(;;){let o=oY("xfs-");try{await this.mkdirPromise(V.join(e,o))}catch(n){if(n.code==="EEXIST")continue;throw n}let a=V.join(r,o);if(Lc.add(a),typeof t>"u")return a;try{return await t(a)}finally{if(Lc.has(a)){Lc.delete(a);try{await this.removePromise(a)}catch{}}}}},async rmtempPromise(){await Promise.all(Array.from(Lc.values()).map(async t=>{try{await oe.removePromise(t,{maxRetries:0}),Lc.delete(t)}catch{}}))},rmtempSync(){for(let t of Lc)try{oe.removeSync(t),Lc.delete(t)}catch{}}})});var Vw={};Vt(Vw,{AliasFS:()=>Uu,BasePortableFakeFS:()=>Mu,CustomDir:()=>qw,CwdFS:()=>gn,FakeFS:()=>gf,Filename:()=>dr,JailFS:()=>_u,LazyFS:()=>iy,MountFS:()=>Hp,NoFS:()=>Yw,NodeFS:()=>Tn,PortablePath:()=>Bt,PosixFS:()=>jp,ProxiedFS:()=>Ss,VirtualFS:()=>mi,constants:()=>vi,errors:()=>ar,extendFs:()=>FD,normalizeLineEndings:()=>_g,npath:()=>ue,opendir:()=>PD,patchFs:()=>Kw,ppath:()=>V,setupCopyIndex:()=>SD,statUtils:()=>Ea,unwatchAllFiles:()=>Ug,unwatchFile:()=>Mg,watchFile:()=>ny,xfs:()=>oe});var St=Et(()=>{k7();BD();HR();qR();L7();YR();Hg();Ca();Ca();j7();Hg();Y7();K7();V7();J7();z7();jg();X7();df();Z7();sY();cY()});var hY=_((obt,pY)=>{pY.exports=fY;fY.sync=M_e;var uY=ve("fs");function O_e(t,e){var r=e.pathExt!==void 0?e.pathExt:process.env.PATHEXT;if(!r||(r=r.split(";"),r.indexOf("")!==-1))return!0;for(var o=0;o<r.length;o++){var a=r[o].toLowerCase();if(a&&t.substr(-a.length).toLowerCase()===a)return!0}return!1}function AY(t,e,r){return!t.isSymbolicLink()&&!t.isFile()?!1:O_e(e,r)}function fY(t,e,r){uY.stat(t,function(o,a){r(o,o?!1:AY(a,t,e))})}function M_e(t,e){return AY(uY.statSync(t),t,e)}});var EY=_((abt,yY)=>{yY.exports=dY;dY.sync=U_e;var gY=ve("fs");function dY(t,e,r){gY.stat(t,function(o,a){r(o,o?!1:mY(a,e))})}function U_e(t,e){return mY(gY.statSync(t),e)}function mY(t,e){return t.isFile()&&__e(t,e)}function __e(t,e){var r=t.mode,o=t.uid,a=t.gid,n=e.uid!==void 0?e.uid:process.getuid&&process.getuid(),u=e.gid!==void 0?e.gid:process.getgid&&process.getgid(),A=parseInt("100",8),p=parseInt("010",8),h=parseInt("001",8),E=A|p,I=r&h||r&p&&a===u||r&A&&o===n||r&E&&n===0;return I}});var wY=_((cbt,CY)=>{var lbt=ve("fs"),RD;process.platform==="win32"||global.TESTING_WINDOWS?RD=hY():RD=EY();CY.exports=zR;zR.sync=H_e;function zR(t,e,r){if(typeof e=="function"&&(r=e,e={}),!r){if(typeof Promise!="function")throw new TypeError("callback not provided");return new Promise(function(o,a){zR(t,e||{},function(n,u){n?a(n):o(u)})})}RD(t,e||{},function(o,a){o&&(o.code==="EACCES"||e&&e.ignoreErrors)&&(o=null,a=!1),r(o,a)})}function H_e(t,e){try{return RD.sync(t,e||{})}catch(r){if(e&&e.ignoreErrors||r.code==="EACCES")return!1;throw r}}});var bY=_((ubt,PY)=>{var ay=process.platform==="win32"||process.env.OSTYPE==="cygwin"||process.env.OSTYPE==="msys",IY=ve("path"),j_e=ay?";":":",BY=wY(),vY=t=>Object.assign(new Error(`not found: ${t}`),{code:"ENOENT"}),DY=(t,e)=>{let r=e.colon||j_e,o=t.match(/\//)||ay&&t.match(/\\/)?[""]:[...ay?[process.cwd()]:[],...(e.path||process.env.PATH||"").split(r)],a=ay?e.pathExt||process.env.PATHEXT||".EXE;.CMD;.BAT;.COM":"",n=ay?a.split(r):[""];return ay&&t.indexOf(".")!==-1&&n[0]!==""&&n.unshift(""),{pathEnv:o,pathExt:n,pathExtExe:a}},SY=(t,e,r)=>{typeof e=="function"&&(r=e,e={}),e||(e={});let{pathEnv:o,pathExt:a,pathExtExe:n}=DY(t,e),u=[],A=h=>new Promise((E,I)=>{if(h===o.length)return e.all&&u.length?E(u):I(vY(t));let v=o[h],x=/^".*"$/.test(v)?v.slice(1,-1):v,C=IY.join(x,t),R=!x&&/^\.[\\\/]/.test(t)?t.slice(0,2)+C:C;E(p(R,h,0))}),p=(h,E,I)=>new Promise((v,x)=>{if(I===a.length)return v(A(E+1));let C=a[I];BY(h+C,{pathExt:n},(R,L)=>{if(!R&&L)if(e.all)u.push(h+C);else return v(h+C);return v(p(h,E,I+1))})});return r?A(0).then(h=>r(null,h),r):A(0)},G_e=(t,e)=>{e=e||{};let{pathEnv:r,pathExt:o,pathExtExe:a}=DY(t,e),n=[];for(let u=0;u<r.length;u++){let A=r[u],p=/^".*"$/.test(A)?A.slice(1,-1):A,h=IY.join(p,t),E=!p&&/^\.[\\\/]/.test(t)?t.slice(0,2)+h:h;for(let I=0;I<o.length;I++){let v=E+o[I];try{if(BY.sync(v,{pathExt:a}))if(e.all)n.push(v);else return v}catch{}}}if(e.all&&n.length)return n;if(e.nothrow)return null;throw vY(t)};PY.exports=SY;SY.sync=G_e});var kY=_((Abt,XR)=>{"use strict";var xY=(t={})=>{let e=t.env||process.env;return(t.platform||process.platform)!=="win32"?"PATH":Object.keys(e).reverse().find(o=>o.toUpperCase()==="PATH")||"Path"};XR.exports=xY;XR.exports.default=xY});var TY=_((fbt,RY)=>{"use strict";var QY=ve("path"),q_e=bY(),Y_e=kY();function FY(t,e){let r=t.options.env||process.env,o=process.cwd(),a=t.options.cwd!=null,n=a&&process.chdir!==void 0&&!process.chdir.disabled;if(n)try{process.chdir(t.options.cwd)}catch{}let u;try{u=q_e.sync(t.command,{path:r[Y_e({env:r})],pathExt:e?QY.delimiter:void 0})}catch{}finally{n&&process.chdir(o)}return u&&(u=QY.resolve(a?t.options.cwd:"",u)),u}function W_e(t){return FY(t)||FY(t,!0)}RY.exports=W_e});var NY=_((pbt,$R)=>{"use strict";var ZR=/([()\][%!^"`<>&|;, *?])/g;function K_e(t){return t=t.replace(ZR,"^$1"),t}function V_e(t,e){return t=`${t}`,t=t.replace(/(\\*)"/g,'$1$1\\"'),t=t.replace(/(\\*)$/,"$1$1"),t=`"${t}"`,t=t.replace(ZR,"^$1"),e&&(t=t.replace(ZR,"^$1")),t}$R.exports.command=K_e;$R.exports.argument=V_e});var OY=_((hbt,LY)=>{"use strict";LY.exports=/^#!(.*)/});var UY=_((gbt,MY)=>{"use strict";var J_e=OY();MY.exports=(t="")=>{let e=t.match(J_e);if(!e)return null;let[r,o]=e[0].replace(/#! ?/,"").split(" "),a=r.split("/").pop();return a==="env"?o:o?`${a} ${o}`:a}});var HY=_((dbt,_Y)=>{"use strict";var eT=ve("fs"),z_e=UY();function X_e(t){let r=Buffer.alloc(150),o;try{o=eT.openSync(t,"r"),eT.readSync(o,r,0,150,0),eT.closeSync(o)}catch{}return z_e(r.toString())}_Y.exports=X_e});var YY=_((mbt,qY)=>{"use strict";var Z_e=ve("path"),jY=TY(),GY=NY(),$_e=HY(),e8e=process.platform==="win32",t8e=/\.(?:com|exe)$/i,r8e=/node_modules[\\/].bin[\\/][^\\/]+\.cmd$/i;function n8e(t){t.file=jY(t);let e=t.file&&$_e(t.file);return e?(t.args.unshift(t.file),t.command=e,jY(t)):t.file}function i8e(t){if(!e8e)return t;let e=n8e(t),r=!t8e.test(e);if(t.options.forceShell||r){let o=r8e.test(e);t.command=Z_e.normalize(t.command),t.command=GY.command(t.command),t.args=t.args.map(n=>GY.argument(n,o));let a=[t.command].concat(t.args).join(" ");t.args=["/d","/s","/c",`"${a}"`],t.command=process.env.comspec||"cmd.exe",t.options.windowsVerbatimArguments=!0}return t}function s8e(t,e,r){e&&!Array.isArray(e)&&(r=e,e=null),e=e?e.slice(0):[],r=Object.assign({},r);let o={command:t,args:e,options:r,file:void 0,original:{command:t,args:e}};return r.shell?o:i8e(o)}qY.exports=s8e});var VY=_((ybt,KY)=>{"use strict";var tT=process.platform==="win32";function rT(t,e){return Object.assign(new Error(`${e} ${t.command} ENOENT`),{code:"ENOENT",errno:"ENOENT",syscall:`${e} ${t.command}`,path:t.command,spawnargs:t.args})}function o8e(t,e){if(!tT)return;let r=t.emit;t.emit=function(o,a){if(o==="exit"){let n=WY(a,e,"spawn");if(n)return r.call(t,"error",n)}return r.apply(t,arguments)}}function WY(t,e){return tT&&t===1&&!e.file?rT(e.original,"spawn"):null}function a8e(t,e){return tT&&t===1&&!e.file?rT(e.original,"spawnSync"):null}KY.exports={hookChildProcess:o8e,verifyENOENT:WY,verifyENOENTSync:a8e,notFoundError:rT}});var sT=_((Ebt,ly)=>{"use strict";var JY=ve("child_process"),nT=YY(),iT=VY();function zY(t,e,r){let o=nT(t,e,r),a=JY.spawn(o.command,o.args,o.options);return iT.hookChildProcess(a,o),a}function l8e(t,e,r){let o=nT(t,e,r),a=JY.spawnSync(o.command,o.args,o.options);return a.error=a.error||iT.verifyENOENTSync(a.status,o),a}ly.exports=zY;ly.exports.spawn=zY;ly.exports.sync=l8e;ly.exports._parse=nT;ly.exports._enoent=iT});var ZY=_((Cbt,XY)=>{"use strict";function c8e(t,e){function r(){this.constructor=t}r.prototype=e.prototype,t.prototype=new r}function qg(t,e,r,o){this.message=t,this.expected=e,this.found=r,this.location=o,this.name="SyntaxError",typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(this,qg)}c8e(qg,Error);qg.buildMessage=function(t,e){var r={literal:function(h){return'"'+a(h.text)+'"'},class:function(h){var E="",I;for(I=0;I<h.parts.length;I++)E+=h.parts[I]instanceof Array?n(h.parts[I][0])+"-"+n(h.parts[I][1]):n(h.parts[I]);return"["+(h.inverted?"^":"")+E+"]"},any:function(h){return"any character"},end:function(h){return"end of input"},other:function(h){return h.description}};function o(h){return h.charCodeAt(0).toString(16).toUpperCase()}function a(h){return h.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(E){return"\\x0"+o(E)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(E){return"\\x"+o(E)})}function n(h){return h.replace(/\\/g,"\\\\").replace(/\]/g,"\\]").replace(/\^/g,"\\^").replace(/-/g,"\\-").replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(E){return"\\x0"+o(E)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(E){return"\\x"+o(E)})}function u(h){return r[h.type](h)}function A(h){var E=new Array(h.length),I,v;for(I=0;I<h.length;I++)E[I]=u(h[I]);if(E.sort(),E.length>0){for(I=1,v=1;I<E.length;I++)E[I-1]!==E[I]&&(E[v]=E[I],v++);E.length=v}switch(E.length){case 1:return E[0];case 2:return E[0]+" or "+E[1];default:return E.slice(0,-1).join(", ")+", or "+E[E.length-1]}}function p(h){return h?'"'+a(h)+'"':"end of input"}return"Expected "+A(t)+" but "+p(e)+" found."};function u8e(t,e){e=e!==void 0?e:{};var r={},o={Start:hg},a=hg,n=function(N){return N||[]},u=function(N,K,re){return[{command:N,type:K}].concat(re||[])},A=function(N,K){return[{command:N,type:K||";"}]},p=function(N){return N},h=";",E=Br(";",!1),I="&",v=Br("&",!1),x=function(N,K){return K?{chain:N,then:K}:{chain:N}},C=function(N,K){return{type:N,line:K}},R="&&",L=Br("&&",!1),U="||",J=Br("||",!1),te=function(N,K){return K?{...N,then:K}:N},ae=function(N,K){return{type:N,chain:K}},fe="|&",ce=Br("|&",!1),me="|",he=Br("|",!1),Be="=",we=Br("=",!1),g=function(N,K){return{name:N,args:[K]}},Ee=function(N){return{name:N,args:[]}},Se="(",le=Br("(",!1),ne=")",ee=Br(")",!1),Ie=function(N,K){return{type:"subshell",subshell:N,args:K}},Fe="{",At=Br("{",!1),H="}",at=Br("}",!1),Re=function(N,K){return{type:"group",group:N,args:K}},ke=function(N,K){return{type:"command",args:K,envs:N}},xe=function(N){return{type:"envs",envs:N}},He=function(N){return N},Te=function(N){return N},Je=/^[0-9]/,je=Cs([["0","9"]],!1,!1),b=function(N,K,re){return{type:"redirection",subtype:K,fd:N!==null?parseInt(N):null,args:[re]}},w=">>",P=Br(">>",!1),y=">&",F=Br(">&",!1),z=">",X=Br(">",!1),Z="<<<",ie=Br("<<<",!1),Pe="<&",Ne=Br("<&",!1),ot="<",dt=Br("<",!1),Gt=function(N){return{type:"argument",segments:[].concat(...N)}},$t=function(N){return N},bt="$'",an=Br("$'",!1),Qr="'",mr=Br("'",!1),br=function(N){return[{type:"text",text:N}]},Wr='""',Kn=Br('""',!1),Ns=function(){return{type:"text",text:""}},Ti='"',ps=Br('"',!1),io=function(N){return N},Pi=function(N){return{type:"arithmetic",arithmetic:N,quoted:!0}},Ls=function(N){return{type:"shell",shell:N,quoted:!0}},so=function(N){return{type:"variable",...N,quoted:!0}},cc=function(N){return{type:"text",text:N}},cu=function(N){return{type:"arithmetic",arithmetic:N,quoted:!1}},lp=function(N){return{type:"shell",shell:N,quoted:!1}},cp=function(N){return{type:"variable",...N,quoted:!1}},Os=function(N){return{type:"glob",pattern:N}},Dn=/^[^']/,oo=Cs(["'"],!0,!1),Ms=function(N){return N.join("")},ml=/^[^$"]/,yl=Cs(["$",'"'],!0,!1),ao=`\\ +`,Vn=Br(`\\ +`,!1),On=function(){return""},Ni="\\",Mn=Br("\\",!1),_i=/^[\\$"`]/,tr=Cs(["\\","$",'"',"`"],!1,!1),Oe=function(N){return N},ii="\\a",Ma=Br("\\a",!1),hr=function(){return"a"},uc="\\b",uu=Br("\\b",!1),Ac=function(){return"\b"},El=/^[Ee]/,DA=Cs(["E","e"],!1,!1),Au=function(){return"\x1B"},Ce="\\f",Rt=Br("\\f",!1),fc=function(){return"\f"},Hi="\\n",fu=Br("\\n",!1),Yt=function(){return` +`},Cl="\\r",SA=Br("\\r",!1),up=function(){return"\r"},pc="\\t",PA=Br("\\t",!1),Qn=function(){return" "},hi="\\v",hc=Br("\\v",!1),bA=function(){return"\v"},sa=/^[\\'"?]/,Li=Cs(["\\","'",'"',"?"],!1,!1),_o=function(N){return String.fromCharCode(parseInt(N,16))},Ze="\\x",lo=Br("\\x",!1),gc="\\u",pu=Br("\\u",!1),ji="\\U",hu=Br("\\U",!1),xA=function(N){return String.fromCodePoint(parseInt(N,16))},Ua=/^[0-7]/,dc=Cs([["0","7"]],!1,!1),hs=/^[0-9a-fA-f]/,_t=Cs([["0","9"],["a","f"],["A","f"]],!1,!1),Fn=ug(),Ci="{}",oa=Br("{}",!1),co=function(){return"{}"},Us="-",aa=Br("-",!1),la="+",Ho=Br("+",!1),wi=".",gs=Br(".",!1),ds=function(N,K,re){return{type:"number",value:(N==="-"?-1:1)*parseFloat(K.join("")+"."+re.join(""))}},ms=function(N,K){return{type:"number",value:(N==="-"?-1:1)*parseInt(K.join(""))}},_s=function(N){return{type:"variable",...N}},Un=function(N){return{type:"variable",name:N}},Sn=function(N){return N},ys="*",We=Br("*",!1),tt="/",It=Br("/",!1),nr=function(N,K,re){return{type:K==="*"?"multiplication":"division",right:re}},$=function(N,K){return K.reduce((re,pe)=>({left:re,...pe}),N)},ye=function(N,K,re){return{type:K==="+"?"addition":"subtraction",right:re}},Le="$((",pt=Br("$((",!1),ht="))",Tt=Br("))",!1),er=function(N){return N},$r="$(",Gi=Br("$(",!1),es=function(N){return N},bi="${",jo=Br("${",!1),kA=":-",QA=Br(":-",!1),Ap=function(N,K){return{name:N,defaultValue:K}},ig=":-}",gu=Br(":-}",!1),sg=function(N){return{name:N,defaultValue:[]}},du=":+",uo=Br(":+",!1),FA=function(N,K){return{name:N,alternativeValue:K}},mc=":+}",ca=Br(":+}",!1),og=function(N){return{name:N,alternativeValue:[]}},yc=function(N){return{name:N}},Pm="$",ag=Br("$",!1),$n=function(N){return e.isGlobPattern(N)},fp=function(N){return N},lg=/^[a-zA-Z0-9_]/,RA=Cs([["a","z"],["A","Z"],["0","9"],"_"],!1,!1),Hs=function(){return cg()},mu=/^[$@*?#a-zA-Z0-9_\-]/,Ha=Cs(["$","@","*","?","#",["a","z"],["A","Z"],["0","9"],"_","-"],!1,!1),qi=/^[()}<>$|&; \t"']/,ua=Cs(["(",")","}","<",">","$","|","&",";"," "," ",'"',"'"],!1,!1),yu=/^[<>&; \t"']/,Es=Cs(["<",">","&",";"," "," ",'"',"'"],!1,!1),Ec=/^[ \t]/,Cc=Cs([" "," "],!1,!1),q=0,Dt=0,wl=[{line:1,column:1}],xi=0,wc=[],ct=0,Eu;if("startRule"in e){if(!(e.startRule in o))throw new Error(`Can't start parsing from rule "`+e.startRule+'".');a=o[e.startRule]}function cg(){return t.substring(Dt,q)}function yw(){return Ic(Dt,q)}function TA(N,K){throw K=K!==void 0?K:Ic(Dt,q),pg([fg(N)],t.substring(Dt,q),K)}function pp(N,K){throw K=K!==void 0?K:Ic(Dt,q),bm(N,K)}function Br(N,K){return{type:"literal",text:N,ignoreCase:K}}function Cs(N,K,re){return{type:"class",parts:N,inverted:K,ignoreCase:re}}function ug(){return{type:"any"}}function Ag(){return{type:"end"}}function fg(N){return{type:"other",description:N}}function hp(N){var K=wl[N],re;if(K)return K;for(re=N-1;!wl[re];)re--;for(K=wl[re],K={line:K.line,column:K.column};re<N;)t.charCodeAt(re)===10?(K.line++,K.column=1):K.column++,re++;return wl[N]=K,K}function Ic(N,K){var re=hp(N),pe=hp(K);return{start:{offset:N,line:re.line,column:re.column},end:{offset:K,line:pe.line,column:pe.column}}}function Ct(N){q<xi||(q>xi&&(xi=q,wc=[]),wc.push(N))}function bm(N,K){return new qg(N,null,null,K)}function pg(N,K,re){return new qg(qg.buildMessage(N,K),N,K,re)}function hg(){var N,K,re;for(N=q,K=[],re=Qt();re!==r;)K.push(re),re=Qt();return K!==r?(re=Cu(),re===r&&(re=null),re!==r?(Dt=N,K=n(re),N=K):(q=N,N=r)):(q=N,N=r),N}function Cu(){var N,K,re,pe,ze;if(N=q,K=wu(),K!==r){for(re=[],pe=Qt();pe!==r;)re.push(pe),pe=Qt();re!==r?(pe=gg(),pe!==r?(ze=xm(),ze===r&&(ze=null),ze!==r?(Dt=N,K=u(K,pe,ze),N=K):(q=N,N=r)):(q=N,N=r)):(q=N,N=r)}else q=N,N=r;if(N===r)if(N=q,K=wu(),K!==r){for(re=[],pe=Qt();pe!==r;)re.push(pe),pe=Qt();re!==r?(pe=gg(),pe===r&&(pe=null),pe!==r?(Dt=N,K=A(K,pe),N=K):(q=N,N=r)):(q=N,N=r)}else q=N,N=r;return N}function xm(){var N,K,re,pe,ze;for(N=q,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r)if(re=Cu(),re!==r){for(pe=[],ze=Qt();ze!==r;)pe.push(ze),ze=Qt();pe!==r?(Dt=N,K=p(re),N=K):(q=N,N=r)}else q=N,N=r;else q=N,N=r;return N}function gg(){var N;return t.charCodeAt(q)===59?(N=h,q++):(N=r,ct===0&&Ct(E)),N===r&&(t.charCodeAt(q)===38?(N=I,q++):(N=r,ct===0&&Ct(v))),N}function wu(){var N,K,re;return N=q,K=Aa(),K!==r?(re=Ew(),re===r&&(re=null),re!==r?(Dt=N,K=x(K,re),N=K):(q=N,N=r)):(q=N,N=r),N}function Ew(){var N,K,re,pe,ze,mt,fr;for(N=q,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r)if(re=km(),re!==r){for(pe=[],ze=Qt();ze!==r;)pe.push(ze),ze=Qt();if(pe!==r)if(ze=wu(),ze!==r){for(mt=[],fr=Qt();fr!==r;)mt.push(fr),fr=Qt();mt!==r?(Dt=N,K=C(re,ze),N=K):(q=N,N=r)}else q=N,N=r;else q=N,N=r}else q=N,N=r;else q=N,N=r;return N}function km(){var N;return t.substr(q,2)===R?(N=R,q+=2):(N=r,ct===0&&Ct(L)),N===r&&(t.substr(q,2)===U?(N=U,q+=2):(N=r,ct===0&&Ct(J))),N}function Aa(){var N,K,re;return N=q,K=dg(),K!==r?(re=Bc(),re===r&&(re=null),re!==r?(Dt=N,K=te(K,re),N=K):(q=N,N=r)):(q=N,N=r),N}function Bc(){var N,K,re,pe,ze,mt,fr;for(N=q,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r)if(re=Il(),re!==r){for(pe=[],ze=Qt();ze!==r;)pe.push(ze),ze=Qt();if(pe!==r)if(ze=Aa(),ze!==r){for(mt=[],fr=Qt();fr!==r;)mt.push(fr),fr=Qt();mt!==r?(Dt=N,K=ae(re,ze),N=K):(q=N,N=r)}else q=N,N=r;else q=N,N=r}else q=N,N=r;else q=N,N=r;return N}function Il(){var N;return t.substr(q,2)===fe?(N=fe,q+=2):(N=r,ct===0&&Ct(ce)),N===r&&(t.charCodeAt(q)===124?(N=me,q++):(N=r,ct===0&&Ct(he))),N}function Iu(){var N,K,re,pe,ze,mt;if(N=q,K=Cg(),K!==r)if(t.charCodeAt(q)===61?(re=Be,q++):(re=r,ct===0&&Ct(we)),re!==r)if(pe=Go(),pe!==r){for(ze=[],mt=Qt();mt!==r;)ze.push(mt),mt=Qt();ze!==r?(Dt=N,K=g(K,pe),N=K):(q=N,N=r)}else q=N,N=r;else q=N,N=r;else q=N,N=r;if(N===r)if(N=q,K=Cg(),K!==r)if(t.charCodeAt(q)===61?(re=Be,q++):(re=r,ct===0&&Ct(we)),re!==r){for(pe=[],ze=Qt();ze!==r;)pe.push(ze),ze=Qt();pe!==r?(Dt=N,K=Ee(K),N=K):(q=N,N=r)}else q=N,N=r;else q=N,N=r;return N}function dg(){var N,K,re,pe,ze,mt,fr,Cr,yn,oi,Oi;for(N=q,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r)if(t.charCodeAt(q)===40?(re=Se,q++):(re=r,ct===0&&Ct(le)),re!==r){for(pe=[],ze=Qt();ze!==r;)pe.push(ze),ze=Qt();if(pe!==r)if(ze=Cu(),ze!==r){for(mt=[],fr=Qt();fr!==r;)mt.push(fr),fr=Qt();if(mt!==r)if(t.charCodeAt(q)===41?(fr=ne,q++):(fr=r,ct===0&&Ct(ee)),fr!==r){for(Cr=[],yn=Qt();yn!==r;)Cr.push(yn),yn=Qt();if(Cr!==r){for(yn=[],oi=ja();oi!==r;)yn.push(oi),oi=ja();if(yn!==r){for(oi=[],Oi=Qt();Oi!==r;)oi.push(Oi),Oi=Qt();oi!==r?(Dt=N,K=Ie(ze,yn),N=K):(q=N,N=r)}else q=N,N=r}else q=N,N=r}else q=N,N=r;else q=N,N=r}else q=N,N=r;else q=N,N=r}else q=N,N=r;else q=N,N=r;if(N===r){for(N=q,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r)if(t.charCodeAt(q)===123?(re=Fe,q++):(re=r,ct===0&&Ct(At)),re!==r){for(pe=[],ze=Qt();ze!==r;)pe.push(ze),ze=Qt();if(pe!==r)if(ze=Cu(),ze!==r){for(mt=[],fr=Qt();fr!==r;)mt.push(fr),fr=Qt();if(mt!==r)if(t.charCodeAt(q)===125?(fr=H,q++):(fr=r,ct===0&&Ct(at)),fr!==r){for(Cr=[],yn=Qt();yn!==r;)Cr.push(yn),yn=Qt();if(Cr!==r){for(yn=[],oi=ja();oi!==r;)yn.push(oi),oi=ja();if(yn!==r){for(oi=[],Oi=Qt();Oi!==r;)oi.push(Oi),Oi=Qt();oi!==r?(Dt=N,K=Re(ze,yn),N=K):(q=N,N=r)}else q=N,N=r}else q=N,N=r}else q=N,N=r;else q=N,N=r}else q=N,N=r;else q=N,N=r}else q=N,N=r;else q=N,N=r;if(N===r){for(N=q,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r){for(re=[],pe=Iu();pe!==r;)re.push(pe),pe=Iu();if(re!==r){for(pe=[],ze=Qt();ze!==r;)pe.push(ze),ze=Qt();if(pe!==r){if(ze=[],mt=gp(),mt!==r)for(;mt!==r;)ze.push(mt),mt=gp();else ze=r;if(ze!==r){for(mt=[],fr=Qt();fr!==r;)mt.push(fr),fr=Qt();mt!==r?(Dt=N,K=ke(re,ze),N=K):(q=N,N=r)}else q=N,N=r}else q=N,N=r}else q=N,N=r}else q=N,N=r;if(N===r){for(N=q,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r){if(re=[],pe=Iu(),pe!==r)for(;pe!==r;)re.push(pe),pe=Iu();else re=r;if(re!==r){for(pe=[],ze=Qt();ze!==r;)pe.push(ze),ze=Qt();pe!==r?(Dt=N,K=xe(re),N=K):(q=N,N=r)}else q=N,N=r}else q=N,N=r}}}return N}function NA(){var N,K,re,pe,ze;for(N=q,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r){if(re=[],pe=dp(),pe!==r)for(;pe!==r;)re.push(pe),pe=dp();else re=r;if(re!==r){for(pe=[],ze=Qt();ze!==r;)pe.push(ze),ze=Qt();pe!==r?(Dt=N,K=He(re),N=K):(q=N,N=r)}else q=N,N=r}else q=N,N=r;return N}function gp(){var N,K,re;for(N=q,K=[],re=Qt();re!==r;)K.push(re),re=Qt();if(K!==r?(re=ja(),re!==r?(Dt=N,K=Te(re),N=K):(q=N,N=r)):(q=N,N=r),N===r){for(N=q,K=[],re=Qt();re!==r;)K.push(re),re=Qt();K!==r?(re=dp(),re!==r?(Dt=N,K=Te(re),N=K):(q=N,N=r)):(q=N,N=r)}return N}function ja(){var N,K,re,pe,ze;for(N=q,K=[],re=Qt();re!==r;)K.push(re),re=Qt();return K!==r?(Je.test(t.charAt(q))?(re=t.charAt(q),q++):(re=r,ct===0&&Ct(je)),re===r&&(re=null),re!==r?(pe=mg(),pe!==r?(ze=dp(),ze!==r?(Dt=N,K=b(re,pe,ze),N=K):(q=N,N=r)):(q=N,N=r)):(q=N,N=r)):(q=N,N=r),N}function mg(){var N;return t.substr(q,2)===w?(N=w,q+=2):(N=r,ct===0&&Ct(P)),N===r&&(t.substr(q,2)===y?(N=y,q+=2):(N=r,ct===0&&Ct(F)),N===r&&(t.charCodeAt(q)===62?(N=z,q++):(N=r,ct===0&&Ct(X)),N===r&&(t.substr(q,3)===Z?(N=Z,q+=3):(N=r,ct===0&&Ct(ie)),N===r&&(t.substr(q,2)===Pe?(N=Pe,q+=2):(N=r,ct===0&&Ct(Ne)),N===r&&(t.charCodeAt(q)===60?(N=ot,q++):(N=r,ct===0&&Ct(dt))))))),N}function dp(){var N,K,re;for(N=q,K=[],re=Qt();re!==r;)K.push(re),re=Qt();return K!==r?(re=Go(),re!==r?(Dt=N,K=Te(re),N=K):(q=N,N=r)):(q=N,N=r),N}function Go(){var N,K,re;if(N=q,K=[],re=ws(),re!==r)for(;re!==r;)K.push(re),re=ws();else K=r;return K!==r&&(Dt=N,K=Gt(K)),N=K,N}function ws(){var N,K;return N=q,K=Ii(),K!==r&&(Dt=N,K=$t(K)),N=K,N===r&&(N=q,K=Qm(),K!==r&&(Dt=N,K=$t(K)),N=K,N===r&&(N=q,K=Fm(),K!==r&&(Dt=N,K=$t(K)),N=K,N===r&&(N=q,K=qo(),K!==r&&(Dt=N,K=$t(K)),N=K))),N}function Ii(){var N,K,re,pe;return N=q,t.substr(q,2)===bt?(K=bt,q+=2):(K=r,ct===0&&Ct(an)),K!==r?(re=ln(),re!==r?(t.charCodeAt(q)===39?(pe=Qr,q++):(pe=r,ct===0&&Ct(mr)),pe!==r?(Dt=N,K=br(re),N=K):(q=N,N=r)):(q=N,N=r)):(q=N,N=r),N}function Qm(){var N,K,re,pe;return N=q,t.charCodeAt(q)===39?(K=Qr,q++):(K=r,ct===0&&Ct(mr)),K!==r?(re=yp(),re!==r?(t.charCodeAt(q)===39?(pe=Qr,q++):(pe=r,ct===0&&Ct(mr)),pe!==r?(Dt=N,K=br(re),N=K):(q=N,N=r)):(q=N,N=r)):(q=N,N=r),N}function Fm(){var N,K,re,pe;if(N=q,t.substr(q,2)===Wr?(K=Wr,q+=2):(K=r,ct===0&&Ct(Kn)),K!==r&&(Dt=N,K=Ns()),N=K,N===r)if(N=q,t.charCodeAt(q)===34?(K=Ti,q++):(K=r,ct===0&&Ct(ps)),K!==r){for(re=[],pe=LA();pe!==r;)re.push(pe),pe=LA();re!==r?(t.charCodeAt(q)===34?(pe=Ti,q++):(pe=r,ct===0&&Ct(ps)),pe!==r?(Dt=N,K=io(re),N=K):(q=N,N=r)):(q=N,N=r)}else q=N,N=r;return N}function qo(){var N,K,re;if(N=q,K=[],re=mp(),re!==r)for(;re!==r;)K.push(re),re=mp();else K=r;return K!==r&&(Dt=N,K=io(K)),N=K,N}function LA(){var N,K;return N=q,K=qr(),K!==r&&(Dt=N,K=Pi(K)),N=K,N===r&&(N=q,K=Ep(),K!==r&&(Dt=N,K=Ls(K)),N=K,N===r&&(N=q,K=Dc(),K!==r&&(Dt=N,K=so(K)),N=K,N===r&&(N=q,K=yg(),K!==r&&(Dt=N,K=cc(K)),N=K))),N}function mp(){var N,K;return N=q,K=qr(),K!==r&&(Dt=N,K=cu(K)),N=K,N===r&&(N=q,K=Ep(),K!==r&&(Dt=N,K=lp(K)),N=K,N===r&&(N=q,K=Dc(),K!==r&&(Dt=N,K=cp(K)),N=K,N===r&&(N=q,K=Cw(),K!==r&&(Dt=N,K=Os(K)),N=K,N===r&&(N=q,K=pa(),K!==r&&(Dt=N,K=cc(K)),N=K)))),N}function yp(){var N,K,re;for(N=q,K=[],Dn.test(t.charAt(q))?(re=t.charAt(q),q++):(re=r,ct===0&&Ct(oo));re!==r;)K.push(re),Dn.test(t.charAt(q))?(re=t.charAt(q),q++):(re=r,ct===0&&Ct(oo));return K!==r&&(Dt=N,K=Ms(K)),N=K,N}function yg(){var N,K,re;if(N=q,K=[],re=fa(),re===r&&(ml.test(t.charAt(q))?(re=t.charAt(q),q++):(re=r,ct===0&&Ct(yl))),re!==r)for(;re!==r;)K.push(re),re=fa(),re===r&&(ml.test(t.charAt(q))?(re=t.charAt(q),q++):(re=r,ct===0&&Ct(yl)));else K=r;return K!==r&&(Dt=N,K=Ms(K)),N=K,N}function fa(){var N,K,re;return N=q,t.substr(q,2)===ao?(K=ao,q+=2):(K=r,ct===0&&Ct(Vn)),K!==r&&(Dt=N,K=On()),N=K,N===r&&(N=q,t.charCodeAt(q)===92?(K=Ni,q++):(K=r,ct===0&&Ct(Mn)),K!==r?(_i.test(t.charAt(q))?(re=t.charAt(q),q++):(re=r,ct===0&&Ct(tr)),re!==r?(Dt=N,K=Oe(re),N=K):(q=N,N=r)):(q=N,N=r)),N}function ln(){var N,K,re;for(N=q,K=[],re=Ao(),re===r&&(Dn.test(t.charAt(q))?(re=t.charAt(q),q++):(re=r,ct===0&&Ct(oo)));re!==r;)K.push(re),re=Ao(),re===r&&(Dn.test(t.charAt(q))?(re=t.charAt(q),q++):(re=r,ct===0&&Ct(oo)));return K!==r&&(Dt=N,K=Ms(K)),N=K,N}function Ao(){var N,K,re;return N=q,t.substr(q,2)===ii?(K=ii,q+=2):(K=r,ct===0&&Ct(Ma)),K!==r&&(Dt=N,K=hr()),N=K,N===r&&(N=q,t.substr(q,2)===uc?(K=uc,q+=2):(K=r,ct===0&&Ct(uu)),K!==r&&(Dt=N,K=Ac()),N=K,N===r&&(N=q,t.charCodeAt(q)===92?(K=Ni,q++):(K=r,ct===0&&Ct(Mn)),K!==r?(El.test(t.charAt(q))?(re=t.charAt(q),q++):(re=r,ct===0&&Ct(DA)),re!==r?(Dt=N,K=Au(),N=K):(q=N,N=r)):(q=N,N=r),N===r&&(N=q,t.substr(q,2)===Ce?(K=Ce,q+=2):(K=r,ct===0&&Ct(Rt)),K!==r&&(Dt=N,K=fc()),N=K,N===r&&(N=q,t.substr(q,2)===Hi?(K=Hi,q+=2):(K=r,ct===0&&Ct(fu)),K!==r&&(Dt=N,K=Yt()),N=K,N===r&&(N=q,t.substr(q,2)===Cl?(K=Cl,q+=2):(K=r,ct===0&&Ct(SA)),K!==r&&(Dt=N,K=up()),N=K,N===r&&(N=q,t.substr(q,2)===pc?(K=pc,q+=2):(K=r,ct===0&&Ct(PA)),K!==r&&(Dt=N,K=Qn()),N=K,N===r&&(N=q,t.substr(q,2)===hi?(K=hi,q+=2):(K=r,ct===0&&Ct(hc)),K!==r&&(Dt=N,K=bA()),N=K,N===r&&(N=q,t.charCodeAt(q)===92?(K=Ni,q++):(K=r,ct===0&&Ct(Mn)),K!==r?(sa.test(t.charAt(q))?(re=t.charAt(q),q++):(re=r,ct===0&&Ct(Li)),re!==r?(Dt=N,K=Oe(re),N=K):(q=N,N=r)):(q=N,N=r),N===r&&(N=OA()))))))))),N}function OA(){var N,K,re,pe,ze,mt,fr,Cr,yn,oi,Oi,Ig;return N=q,t.charCodeAt(q)===92?(K=Ni,q++):(K=r,ct===0&&Ct(Mn)),K!==r?(re=Ga(),re!==r?(Dt=N,K=_o(re),N=K):(q=N,N=r)):(q=N,N=r),N===r&&(N=q,t.substr(q,2)===Ze?(K=Ze,q+=2):(K=r,ct===0&&Ct(lo)),K!==r?(re=q,pe=q,ze=Ga(),ze!==r?(mt=si(),mt!==r?(ze=[ze,mt],pe=ze):(q=pe,pe=r)):(q=pe,pe=r),pe===r&&(pe=Ga()),pe!==r?re=t.substring(re,q):re=pe,re!==r?(Dt=N,K=_o(re),N=K):(q=N,N=r)):(q=N,N=r),N===r&&(N=q,t.substr(q,2)===gc?(K=gc,q+=2):(K=r,ct===0&&Ct(pu)),K!==r?(re=q,pe=q,ze=si(),ze!==r?(mt=si(),mt!==r?(fr=si(),fr!==r?(Cr=si(),Cr!==r?(ze=[ze,mt,fr,Cr],pe=ze):(q=pe,pe=r)):(q=pe,pe=r)):(q=pe,pe=r)):(q=pe,pe=r),pe!==r?re=t.substring(re,q):re=pe,re!==r?(Dt=N,K=_o(re),N=K):(q=N,N=r)):(q=N,N=r),N===r&&(N=q,t.substr(q,2)===ji?(K=ji,q+=2):(K=r,ct===0&&Ct(hu)),K!==r?(re=q,pe=q,ze=si(),ze!==r?(mt=si(),mt!==r?(fr=si(),fr!==r?(Cr=si(),Cr!==r?(yn=si(),yn!==r?(oi=si(),oi!==r?(Oi=si(),Oi!==r?(Ig=si(),Ig!==r?(ze=[ze,mt,fr,Cr,yn,oi,Oi,Ig],pe=ze):(q=pe,pe=r)):(q=pe,pe=r)):(q=pe,pe=r)):(q=pe,pe=r)):(q=pe,pe=r)):(q=pe,pe=r)):(q=pe,pe=r)):(q=pe,pe=r),pe!==r?re=t.substring(re,q):re=pe,re!==r?(Dt=N,K=xA(re),N=K):(q=N,N=r)):(q=N,N=r)))),N}function Ga(){var N;return Ua.test(t.charAt(q))?(N=t.charAt(q),q++):(N=r,ct===0&&Ct(dc)),N}function si(){var N;return hs.test(t.charAt(q))?(N=t.charAt(q),q++):(N=r,ct===0&&Ct(_t)),N}function pa(){var N,K,re,pe,ze;if(N=q,K=[],re=q,t.charCodeAt(q)===92?(pe=Ni,q++):(pe=r,ct===0&&Ct(Mn)),pe!==r?(t.length>q?(ze=t.charAt(q),q++):(ze=r,ct===0&&Ct(Fn)),ze!==r?(Dt=re,pe=Oe(ze),re=pe):(q=re,re=r)):(q=re,re=r),re===r&&(re=q,t.substr(q,2)===Ci?(pe=Ci,q+=2):(pe=r,ct===0&&Ct(oa)),pe!==r&&(Dt=re,pe=co()),re=pe,re===r&&(re=q,pe=q,ct++,ze=Rm(),ct--,ze===r?pe=void 0:(q=pe,pe=r),pe!==r?(t.length>q?(ze=t.charAt(q),q++):(ze=r,ct===0&&Ct(Fn)),ze!==r?(Dt=re,pe=Oe(ze),re=pe):(q=re,re=r)):(q=re,re=r))),re!==r)for(;re!==r;)K.push(re),re=q,t.charCodeAt(q)===92?(pe=Ni,q++):(pe=r,ct===0&&Ct(Mn)),pe!==r?(t.length>q?(ze=t.charAt(q),q++):(ze=r,ct===0&&Ct(Fn)),ze!==r?(Dt=re,pe=Oe(ze),re=pe):(q=re,re=r)):(q=re,re=r),re===r&&(re=q,t.substr(q,2)===Ci?(pe=Ci,q+=2):(pe=r,ct===0&&Ct(oa)),pe!==r&&(Dt=re,pe=co()),re=pe,re===r&&(re=q,pe=q,ct++,ze=Rm(),ct--,ze===r?pe=void 0:(q=pe,pe=r),pe!==r?(t.length>q?(ze=t.charAt(q),q++):(ze=r,ct===0&&Ct(Fn)),ze!==r?(Dt=re,pe=Oe(ze),re=pe):(q=re,re=r)):(q=re,re=r)));else K=r;return K!==r&&(Dt=N,K=Ms(K)),N=K,N}function vc(){var N,K,re,pe,ze,mt;if(N=q,t.charCodeAt(q)===45?(K=Us,q++):(K=r,ct===0&&Ct(aa)),K===r&&(t.charCodeAt(q)===43?(K=la,q++):(K=r,ct===0&&Ct(Ho))),K===r&&(K=null),K!==r){if(re=[],Je.test(t.charAt(q))?(pe=t.charAt(q),q++):(pe=r,ct===0&&Ct(je)),pe!==r)for(;pe!==r;)re.push(pe),Je.test(t.charAt(q))?(pe=t.charAt(q),q++):(pe=r,ct===0&&Ct(je));else re=r;if(re!==r)if(t.charCodeAt(q)===46?(pe=wi,q++):(pe=r,ct===0&&Ct(gs)),pe!==r){if(ze=[],Je.test(t.charAt(q))?(mt=t.charAt(q),q++):(mt=r,ct===0&&Ct(je)),mt!==r)for(;mt!==r;)ze.push(mt),Je.test(t.charAt(q))?(mt=t.charAt(q),q++):(mt=r,ct===0&&Ct(je));else ze=r;ze!==r?(Dt=N,K=ds(K,re,ze),N=K):(q=N,N=r)}else q=N,N=r;else q=N,N=r}else q=N,N=r;if(N===r){if(N=q,t.charCodeAt(q)===45?(K=Us,q++):(K=r,ct===0&&Ct(aa)),K===r&&(t.charCodeAt(q)===43?(K=la,q++):(K=r,ct===0&&Ct(Ho))),K===r&&(K=null),K!==r){if(re=[],Je.test(t.charAt(q))?(pe=t.charAt(q),q++):(pe=r,ct===0&&Ct(je)),pe!==r)for(;pe!==r;)re.push(pe),Je.test(t.charAt(q))?(pe=t.charAt(q),q++):(pe=r,ct===0&&Ct(je));else re=r;re!==r?(Dt=N,K=ms(K,re),N=K):(q=N,N=r)}else q=N,N=r;if(N===r&&(N=q,K=Dc(),K!==r&&(Dt=N,K=_s(K)),N=K,N===r&&(N=q,K=qa(),K!==r&&(Dt=N,K=Un(K)),N=K,N===r)))if(N=q,t.charCodeAt(q)===40?(K=Se,q++):(K=r,ct===0&&Ct(le)),K!==r){for(re=[],pe=Qt();pe!==r;)re.push(pe),pe=Qt();if(re!==r)if(pe=ts(),pe!==r){for(ze=[],mt=Qt();mt!==r;)ze.push(mt),mt=Qt();ze!==r?(t.charCodeAt(q)===41?(mt=ne,q++):(mt=r,ct===0&&Ct(ee)),mt!==r?(Dt=N,K=Sn(pe),N=K):(q=N,N=r)):(q=N,N=r)}else q=N,N=r;else q=N,N=r}else q=N,N=r}return N}function Bl(){var N,K,re,pe,ze,mt,fr,Cr;if(N=q,K=vc(),K!==r){for(re=[],pe=q,ze=[],mt=Qt();mt!==r;)ze.push(mt),mt=Qt();if(ze!==r)if(t.charCodeAt(q)===42?(mt=ys,q++):(mt=r,ct===0&&Ct(We)),mt===r&&(t.charCodeAt(q)===47?(mt=tt,q++):(mt=r,ct===0&&Ct(It))),mt!==r){for(fr=[],Cr=Qt();Cr!==r;)fr.push(Cr),Cr=Qt();fr!==r?(Cr=vc(),Cr!==r?(Dt=pe,ze=nr(K,mt,Cr),pe=ze):(q=pe,pe=r)):(q=pe,pe=r)}else q=pe,pe=r;else q=pe,pe=r;for(;pe!==r;){for(re.push(pe),pe=q,ze=[],mt=Qt();mt!==r;)ze.push(mt),mt=Qt();if(ze!==r)if(t.charCodeAt(q)===42?(mt=ys,q++):(mt=r,ct===0&&Ct(We)),mt===r&&(t.charCodeAt(q)===47?(mt=tt,q++):(mt=r,ct===0&&Ct(It))),mt!==r){for(fr=[],Cr=Qt();Cr!==r;)fr.push(Cr),Cr=Qt();fr!==r?(Cr=vc(),Cr!==r?(Dt=pe,ze=nr(K,mt,Cr),pe=ze):(q=pe,pe=r)):(q=pe,pe=r)}else q=pe,pe=r;else q=pe,pe=r}re!==r?(Dt=N,K=$(K,re),N=K):(q=N,N=r)}else q=N,N=r;return N}function ts(){var N,K,re,pe,ze,mt,fr,Cr;if(N=q,K=Bl(),K!==r){for(re=[],pe=q,ze=[],mt=Qt();mt!==r;)ze.push(mt),mt=Qt();if(ze!==r)if(t.charCodeAt(q)===43?(mt=la,q++):(mt=r,ct===0&&Ct(Ho)),mt===r&&(t.charCodeAt(q)===45?(mt=Us,q++):(mt=r,ct===0&&Ct(aa))),mt!==r){for(fr=[],Cr=Qt();Cr!==r;)fr.push(Cr),Cr=Qt();fr!==r?(Cr=Bl(),Cr!==r?(Dt=pe,ze=ye(K,mt,Cr),pe=ze):(q=pe,pe=r)):(q=pe,pe=r)}else q=pe,pe=r;else q=pe,pe=r;for(;pe!==r;){for(re.push(pe),pe=q,ze=[],mt=Qt();mt!==r;)ze.push(mt),mt=Qt();if(ze!==r)if(t.charCodeAt(q)===43?(mt=la,q++):(mt=r,ct===0&&Ct(Ho)),mt===r&&(t.charCodeAt(q)===45?(mt=Us,q++):(mt=r,ct===0&&Ct(aa))),mt!==r){for(fr=[],Cr=Qt();Cr!==r;)fr.push(Cr),Cr=Qt();fr!==r?(Cr=Bl(),Cr!==r?(Dt=pe,ze=ye(K,mt,Cr),pe=ze):(q=pe,pe=r)):(q=pe,pe=r)}else q=pe,pe=r;else q=pe,pe=r}re!==r?(Dt=N,K=$(K,re),N=K):(q=N,N=r)}else q=N,N=r;return N}function qr(){var N,K,re,pe,ze,mt;if(N=q,t.substr(q,3)===Le?(K=Le,q+=3):(K=r,ct===0&&Ct(pt)),K!==r){for(re=[],pe=Qt();pe!==r;)re.push(pe),pe=Qt();if(re!==r)if(pe=ts(),pe!==r){for(ze=[],mt=Qt();mt!==r;)ze.push(mt),mt=Qt();ze!==r?(t.substr(q,2)===ht?(mt=ht,q+=2):(mt=r,ct===0&&Ct(Tt)),mt!==r?(Dt=N,K=er(pe),N=K):(q=N,N=r)):(q=N,N=r)}else q=N,N=r;else q=N,N=r}else q=N,N=r;return N}function Ep(){var N,K,re,pe;return N=q,t.substr(q,2)===$r?(K=$r,q+=2):(K=r,ct===0&&Ct(Gi)),K!==r?(re=Cu(),re!==r?(t.charCodeAt(q)===41?(pe=ne,q++):(pe=r,ct===0&&Ct(ee)),pe!==r?(Dt=N,K=es(re),N=K):(q=N,N=r)):(q=N,N=r)):(q=N,N=r),N}function Dc(){var N,K,re,pe,ze,mt;return N=q,t.substr(q,2)===bi?(K=bi,q+=2):(K=r,ct===0&&Ct(jo)),K!==r?(re=qa(),re!==r?(t.substr(q,2)===kA?(pe=kA,q+=2):(pe=r,ct===0&&Ct(QA)),pe!==r?(ze=NA(),ze!==r?(t.charCodeAt(q)===125?(mt=H,q++):(mt=r,ct===0&&Ct(at)),mt!==r?(Dt=N,K=Ap(re,ze),N=K):(q=N,N=r)):(q=N,N=r)):(q=N,N=r)):(q=N,N=r)):(q=N,N=r),N===r&&(N=q,t.substr(q,2)===bi?(K=bi,q+=2):(K=r,ct===0&&Ct(jo)),K!==r?(re=qa(),re!==r?(t.substr(q,3)===ig?(pe=ig,q+=3):(pe=r,ct===0&&Ct(gu)),pe!==r?(Dt=N,K=sg(re),N=K):(q=N,N=r)):(q=N,N=r)):(q=N,N=r),N===r&&(N=q,t.substr(q,2)===bi?(K=bi,q+=2):(K=r,ct===0&&Ct(jo)),K!==r?(re=qa(),re!==r?(t.substr(q,2)===du?(pe=du,q+=2):(pe=r,ct===0&&Ct(uo)),pe!==r?(ze=NA(),ze!==r?(t.charCodeAt(q)===125?(mt=H,q++):(mt=r,ct===0&&Ct(at)),mt!==r?(Dt=N,K=FA(re,ze),N=K):(q=N,N=r)):(q=N,N=r)):(q=N,N=r)):(q=N,N=r)):(q=N,N=r),N===r&&(N=q,t.substr(q,2)===bi?(K=bi,q+=2):(K=r,ct===0&&Ct(jo)),K!==r?(re=qa(),re!==r?(t.substr(q,3)===mc?(pe=mc,q+=3):(pe=r,ct===0&&Ct(ca)),pe!==r?(Dt=N,K=og(re),N=K):(q=N,N=r)):(q=N,N=r)):(q=N,N=r),N===r&&(N=q,t.substr(q,2)===bi?(K=bi,q+=2):(K=r,ct===0&&Ct(jo)),K!==r?(re=qa(),re!==r?(t.charCodeAt(q)===125?(pe=H,q++):(pe=r,ct===0&&Ct(at)),pe!==r?(Dt=N,K=yc(re),N=K):(q=N,N=r)):(q=N,N=r)):(q=N,N=r),N===r&&(N=q,t.charCodeAt(q)===36?(K=Pm,q++):(K=r,ct===0&&Ct(ag)),K!==r?(re=qa(),re!==r?(Dt=N,K=yc(re),N=K):(q=N,N=r)):(q=N,N=r)))))),N}function Cw(){var N,K,re;return N=q,K=Eg(),K!==r?(Dt=q,re=$n(K),re?re=void 0:re=r,re!==r?(Dt=N,K=fp(K),N=K):(q=N,N=r)):(q=N,N=r),N}function Eg(){var N,K,re,pe,ze;if(N=q,K=[],re=q,pe=q,ct++,ze=wg(),ct--,ze===r?pe=void 0:(q=pe,pe=r),pe!==r?(t.length>q?(ze=t.charAt(q),q++):(ze=r,ct===0&&Ct(Fn)),ze!==r?(Dt=re,pe=Oe(ze),re=pe):(q=re,re=r)):(q=re,re=r),re!==r)for(;re!==r;)K.push(re),re=q,pe=q,ct++,ze=wg(),ct--,ze===r?pe=void 0:(q=pe,pe=r),pe!==r?(t.length>q?(ze=t.charAt(q),q++):(ze=r,ct===0&&Ct(Fn)),ze!==r?(Dt=re,pe=Oe(ze),re=pe):(q=re,re=r)):(q=re,re=r);else K=r;return K!==r&&(Dt=N,K=Ms(K)),N=K,N}function Cg(){var N,K,re;if(N=q,K=[],lg.test(t.charAt(q))?(re=t.charAt(q),q++):(re=r,ct===0&&Ct(RA)),re!==r)for(;re!==r;)K.push(re),lg.test(t.charAt(q))?(re=t.charAt(q),q++):(re=r,ct===0&&Ct(RA));else K=r;return K!==r&&(Dt=N,K=Hs()),N=K,N}function qa(){var N,K,re;if(N=q,K=[],mu.test(t.charAt(q))?(re=t.charAt(q),q++):(re=r,ct===0&&Ct(Ha)),re!==r)for(;re!==r;)K.push(re),mu.test(t.charAt(q))?(re=t.charAt(q),q++):(re=r,ct===0&&Ct(Ha));else K=r;return K!==r&&(Dt=N,K=Hs()),N=K,N}function Rm(){var N;return qi.test(t.charAt(q))?(N=t.charAt(q),q++):(N=r,ct===0&&Ct(ua)),N}function wg(){var N;return yu.test(t.charAt(q))?(N=t.charAt(q),q++):(N=r,ct===0&&Ct(Es)),N}function Qt(){var N,K;if(N=[],Ec.test(t.charAt(q))?(K=t.charAt(q),q++):(K=r,ct===0&&Ct(Cc)),K!==r)for(;K!==r;)N.push(K),Ec.test(t.charAt(q))?(K=t.charAt(q),q++):(K=r,ct===0&&Ct(Cc));else N=r;return N}if(Eu=a(),Eu!==r&&q===t.length)return Eu;throw Eu!==r&&q<t.length&&Ct(Ag()),pg(wc,xi<t.length?t.charAt(xi):null,xi<t.length?Ic(xi,xi+1):Ic(xi,xi))}XY.exports={SyntaxError:qg,parse:u8e}});function ND(t,e={isGlobPattern:()=>!1}){try{return(0,$Y.parse)(t,e)}catch(r){throw r.location&&(r.message=r.message.replace(/(\.)?$/,` (line ${r.location.start.line}, column ${r.location.start.column})$1`)),r}}function cy(t,{endSemicolon:e=!1}={}){return t.map(({command:r,type:o},a)=>`${LD(r)}${o===";"?a!==t.length-1||e?";":"":" &"}`).join(" ")}function LD(t){return`${uy(t.chain)}${t.then?` ${oT(t.then)}`:""}`}function oT(t){return`${t.type} ${LD(t.line)}`}function uy(t){return`${lT(t)}${t.then?` ${aT(t.then)}`:""}`}function aT(t){return`${t.type} ${uy(t.chain)}`}function lT(t){switch(t.type){case"command":return`${t.envs.length>0?`${t.envs.map(e=>TD(e)).join(" ")} `:""}${t.args.map(e=>cT(e)).join(" ")}`;case"subshell":return`(${cy(t.subshell)})${t.args.length>0?` ${t.args.map(e=>Jw(e)).join(" ")}`:""}`;case"group":return`{ ${cy(t.group,{endSemicolon:!0})} }${t.args.length>0?` ${t.args.map(e=>Jw(e)).join(" ")}`:""}`;case"envs":return t.envs.map(e=>TD(e)).join(" ");default:throw new Error(`Unsupported command type: "${t.type}"`)}}function TD(t){return`${t.name}=${t.args[0]?Yg(t.args[0]):""}`}function cT(t){switch(t.type){case"redirection":return Jw(t);case"argument":return Yg(t);default:throw new Error(`Unsupported argument type: "${t.type}"`)}}function Jw(t){return`${t.subtype} ${t.args.map(e=>Yg(e)).join(" ")}`}function Yg(t){return t.segments.map(e=>uT(e)).join("")}function uT(t){let e=(o,a)=>a?`"${o}"`:o,r=o=>o===""?"''":o.match(/[()}<>$|&;"'\n\t ]/)?o.match(/['\t\p{C}]/u)?o.match(/'/)?`"${o.replace(/["$\t\p{C}]/u,f8e)}"`:`$'${o.replace(/[\t\p{C}]/u,tW)}'`:`'${o}'`:o;switch(t.type){case"text":return r(t.text);case"glob":return t.pattern;case"shell":return e(`\${${cy(t.shell)}}`,t.quoted);case"variable":return e(typeof t.defaultValue>"u"?typeof t.alternativeValue>"u"?`\${${t.name}}`:t.alternativeValue.length===0?`\${${t.name}:+}`:`\${${t.name}:+${t.alternativeValue.map(o=>Yg(o)).join(" ")}}`:t.defaultValue.length===0?`\${${t.name}:-}`:`\${${t.name}:-${t.defaultValue.map(o=>Yg(o)).join(" ")}}`,t.quoted);case"arithmetic":return`$(( ${OD(t.arithmetic)} ))`;default:throw new Error(`Unsupported argument segment type: "${t.type}"`)}}function OD(t){let e=a=>{switch(a){case"addition":return"+";case"subtraction":return"-";case"multiplication":return"*";case"division":return"/";default:throw new Error(`Can't extract operator from arithmetic expression of type "${a}"`)}},r=(a,n)=>n?`( ${a} )`:a,o=a=>r(OD(a),!["number","variable"].includes(a.type));switch(t.type){case"number":return String(t.value);case"variable":return t.name;default:return`${o(t.left)} ${e(t.type)} ${o(t.right)}`}}var $Y,eW,A8e,tW,f8e,rW=Et(()=>{$Y=$e(ZY());eW=new Map([["\f","\\f"],[` +`,"\\n"],["\r","\\r"],[" ","\\t"],["\v","\\v"],["\0","\\0"]]),A8e=new Map([["\\","\\\\"],["$","\\$"],['"','\\"'],...Array.from(eW,([t,e])=>[t,`"$'${e}'"`])]),tW=t=>eW.get(t)??`\\x${t.charCodeAt(0).toString(16).padStart(2,"0")}`,f8e=t=>A8e.get(t)??`"$'${tW(t)}'"`});var iW=_((Tbt,nW)=>{"use strict";function p8e(t,e){function r(){this.constructor=t}r.prototype=e.prototype,t.prototype=new r}function Wg(t,e,r,o){this.message=t,this.expected=e,this.found=r,this.location=o,this.name="SyntaxError",typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(this,Wg)}p8e(Wg,Error);Wg.buildMessage=function(t,e){var r={literal:function(h){return'"'+a(h.text)+'"'},class:function(h){var E="",I;for(I=0;I<h.parts.length;I++)E+=h.parts[I]instanceof Array?n(h.parts[I][0])+"-"+n(h.parts[I][1]):n(h.parts[I]);return"["+(h.inverted?"^":"")+E+"]"},any:function(h){return"any character"},end:function(h){return"end of input"},other:function(h){return h.description}};function o(h){return h.charCodeAt(0).toString(16).toUpperCase()}function a(h){return h.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(E){return"\\x0"+o(E)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(E){return"\\x"+o(E)})}function n(h){return h.replace(/\\/g,"\\\\").replace(/\]/g,"\\]").replace(/\^/g,"\\^").replace(/-/g,"\\-").replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(E){return"\\x0"+o(E)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(E){return"\\x"+o(E)})}function u(h){return r[h.type](h)}function A(h){var E=new Array(h.length),I,v;for(I=0;I<h.length;I++)E[I]=u(h[I]);if(E.sort(),E.length>0){for(I=1,v=1;I<E.length;I++)E[I-1]!==E[I]&&(E[v]=E[I],v++);E.length=v}switch(E.length){case 1:return E[0];case 2:return E[0]+" or "+E[1];default:return E.slice(0,-1).join(", ")+", or "+E[E.length-1]}}function p(h){return h?'"'+a(h)+'"':"end of input"}return"Expected "+A(t)+" but "+p(e)+" found."};function h8e(t,e){e=e!==void 0?e:{};var r={},o={resolution:ke},a=ke,n="/",u=Se("/",!1),A=function(je,b){return{from:je,descriptor:b}},p=function(je){return{descriptor:je}},h="@",E=Se("@",!1),I=function(je,b){return{fullName:je,description:b}},v=function(je){return{fullName:je}},x=function(){return Be()},C=/^[^\/@]/,R=le(["/","@"],!0,!1),L=/^[^\/]/,U=le(["/"],!0,!1),J=0,te=0,ae=[{line:1,column:1}],fe=0,ce=[],me=0,he;if("startRule"in e){if(!(e.startRule in o))throw new Error(`Can't start parsing from rule "`+e.startRule+'".');a=o[e.startRule]}function Be(){return t.substring(te,J)}function we(){return At(te,J)}function g(je,b){throw b=b!==void 0?b:At(te,J),Re([Ie(je)],t.substring(te,J),b)}function Ee(je,b){throw b=b!==void 0?b:At(te,J),at(je,b)}function Se(je,b){return{type:"literal",text:je,ignoreCase:b}}function le(je,b,w){return{type:"class",parts:je,inverted:b,ignoreCase:w}}function ne(){return{type:"any"}}function ee(){return{type:"end"}}function Ie(je){return{type:"other",description:je}}function Fe(je){var b=ae[je],w;if(b)return b;for(w=je-1;!ae[w];)w--;for(b=ae[w],b={line:b.line,column:b.column};w<je;)t.charCodeAt(w)===10?(b.line++,b.column=1):b.column++,w++;return ae[je]=b,b}function At(je,b){var w=Fe(je),P=Fe(b);return{start:{offset:je,line:w.line,column:w.column},end:{offset:b,line:P.line,column:P.column}}}function H(je){J<fe||(J>fe&&(fe=J,ce=[]),ce.push(je))}function at(je,b){return new Wg(je,null,null,b)}function Re(je,b,w){return new Wg(Wg.buildMessage(je,b),je,b,w)}function ke(){var je,b,w,P;return je=J,b=xe(),b!==r?(t.charCodeAt(J)===47?(w=n,J++):(w=r,me===0&&H(u)),w!==r?(P=xe(),P!==r?(te=je,b=A(b,P),je=b):(J=je,je=r)):(J=je,je=r)):(J=je,je=r),je===r&&(je=J,b=xe(),b!==r&&(te=je,b=p(b)),je=b),je}function xe(){var je,b,w,P;return je=J,b=He(),b!==r?(t.charCodeAt(J)===64?(w=h,J++):(w=r,me===0&&H(E)),w!==r?(P=Je(),P!==r?(te=je,b=I(b,P),je=b):(J=je,je=r)):(J=je,je=r)):(J=je,je=r),je===r&&(je=J,b=He(),b!==r&&(te=je,b=v(b)),je=b),je}function He(){var je,b,w,P,y;return je=J,t.charCodeAt(J)===64?(b=h,J++):(b=r,me===0&&H(E)),b!==r?(w=Te(),w!==r?(t.charCodeAt(J)===47?(P=n,J++):(P=r,me===0&&H(u)),P!==r?(y=Te(),y!==r?(te=je,b=x(),je=b):(J=je,je=r)):(J=je,je=r)):(J=je,je=r)):(J=je,je=r),je===r&&(je=J,b=Te(),b!==r&&(te=je,b=x()),je=b),je}function Te(){var je,b,w;if(je=J,b=[],C.test(t.charAt(J))?(w=t.charAt(J),J++):(w=r,me===0&&H(R)),w!==r)for(;w!==r;)b.push(w),C.test(t.charAt(J))?(w=t.charAt(J),J++):(w=r,me===0&&H(R));else b=r;return b!==r&&(te=je,b=x()),je=b,je}function Je(){var je,b,w;if(je=J,b=[],L.test(t.charAt(J))?(w=t.charAt(J),J++):(w=r,me===0&&H(U)),w!==r)for(;w!==r;)b.push(w),L.test(t.charAt(J))?(w=t.charAt(J),J++):(w=r,me===0&&H(U));else b=r;return b!==r&&(te=je,b=x()),je=b,je}if(he=a(),he!==r&&J===t.length)return he;throw he!==r&&J<t.length&&H(ee()),Re(ce,fe<t.length?t.charAt(fe):null,fe<t.length?At(fe,fe+1):At(fe,fe))}nW.exports={SyntaxError:Wg,parse:h8e}});function MD(t){let e=t.match(/^\*{1,2}\/(.*)/);if(e)throw new Error(`The override for '${t}' includes a glob pattern. Glob patterns have been removed since their behaviours don't match what you'd expect. Set the override to '${e[1]}' instead.`);try{return(0,sW.parse)(t)}catch(r){throw r.location&&(r.message=r.message.replace(/(\.)?$/,` (line ${r.location.start.line}, column ${r.location.start.column})$1`)),r}}function UD(t){let e="";return t.from&&(e+=t.from.fullName,t.from.description&&(e+=`@${t.from.description}`),e+="/"),e+=t.descriptor.fullName,t.descriptor.description&&(e+=`@${t.descriptor.description}`),e}var sW,oW=Et(()=>{sW=$e(iW())});var Vg=_((Lbt,Kg)=>{"use strict";function aW(t){return typeof t>"u"||t===null}function g8e(t){return typeof t=="object"&&t!==null}function d8e(t){return Array.isArray(t)?t:aW(t)?[]:[t]}function m8e(t,e){var r,o,a,n;if(e)for(n=Object.keys(e),r=0,o=n.length;r<o;r+=1)a=n[r],t[a]=e[a];return t}function y8e(t,e){var r="",o;for(o=0;o<e;o+=1)r+=t;return r}function E8e(t){return t===0&&Number.NEGATIVE_INFINITY===1/t}Kg.exports.isNothing=aW;Kg.exports.isObject=g8e;Kg.exports.toArray=d8e;Kg.exports.repeat=y8e;Kg.exports.isNegativeZero=E8e;Kg.exports.extend=m8e});var Ay=_((Obt,lW)=>{"use strict";function zw(t,e){Error.call(this),this.name="YAMLException",this.reason=t,this.mark=e,this.message=(this.reason||"(unknown reason)")+(this.mark?" "+this.mark.toString():""),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error().stack||""}zw.prototype=Object.create(Error.prototype);zw.prototype.constructor=zw;zw.prototype.toString=function(e){var r=this.name+": ";return r+=this.reason||"(unknown reason)",!e&&this.mark&&(r+=" "+this.mark.toString()),r};lW.exports=zw});var AW=_((Mbt,uW)=>{"use strict";var cW=Vg();function AT(t,e,r,o,a){this.name=t,this.buffer=e,this.position=r,this.line=o,this.column=a}AT.prototype.getSnippet=function(e,r){var o,a,n,u,A;if(!this.buffer)return null;for(e=e||4,r=r||75,o="",a=this.position;a>0&&`\0\r +\x85\u2028\u2029`.indexOf(this.buffer.charAt(a-1))===-1;)if(a-=1,this.position-a>r/2-1){o=" ... ",a+=5;break}for(n="",u=this.position;u<this.buffer.length&&`\0\r +\x85\u2028\u2029`.indexOf(this.buffer.charAt(u))===-1;)if(u+=1,u-this.position>r/2-1){n=" ... ",u-=5;break}return A=this.buffer.slice(a,u),cW.repeat(" ",e)+o+A+n+` +`+cW.repeat(" ",e+this.position-a+o.length)+"^"};AT.prototype.toString=function(e){var r,o="";return this.name&&(o+='in "'+this.name+'" '),o+="at line "+(this.line+1)+", column "+(this.column+1),e||(r=this.getSnippet(),r&&(o+=`: +`+r)),o};uW.exports=AT});var os=_((Ubt,pW)=>{"use strict";var fW=Ay(),C8e=["kind","resolve","construct","instanceOf","predicate","represent","defaultStyle","styleAliases"],w8e=["scalar","sequence","mapping"];function I8e(t){var e={};return t!==null&&Object.keys(t).forEach(function(r){t[r].forEach(function(o){e[String(o)]=r})}),e}function B8e(t,e){if(e=e||{},Object.keys(e).forEach(function(r){if(C8e.indexOf(r)===-1)throw new fW('Unknown option "'+r+'" is met in definition of "'+t+'" YAML type.')}),this.tag=t,this.kind=e.kind||null,this.resolve=e.resolve||function(){return!0},this.construct=e.construct||function(r){return r},this.instanceOf=e.instanceOf||null,this.predicate=e.predicate||null,this.represent=e.represent||null,this.defaultStyle=e.defaultStyle||null,this.styleAliases=I8e(e.styleAliases||null),w8e.indexOf(this.kind)===-1)throw new fW('Unknown kind "'+this.kind+'" is specified for "'+t+'" YAML type.')}pW.exports=B8e});var Jg=_((_bt,gW)=>{"use strict";var hW=Vg(),_D=Ay(),v8e=os();function fT(t,e,r){var o=[];return t.include.forEach(function(a){r=fT(a,e,r)}),t[e].forEach(function(a){r.forEach(function(n,u){n.tag===a.tag&&n.kind===a.kind&&o.push(u)}),r.push(a)}),r.filter(function(a,n){return o.indexOf(n)===-1})}function D8e(){var t={scalar:{},sequence:{},mapping:{},fallback:{}},e,r;function o(a){t[a.kind][a.tag]=t.fallback[a.tag]=a}for(e=0,r=arguments.length;e<r;e+=1)arguments[e].forEach(o);return t}function fy(t){this.include=t.include||[],this.implicit=t.implicit||[],this.explicit=t.explicit||[],this.implicit.forEach(function(e){if(e.loadKind&&e.loadKind!=="scalar")throw new _D("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.")}),this.compiledImplicit=fT(this,"implicit",[]),this.compiledExplicit=fT(this,"explicit",[]),this.compiledTypeMap=D8e(this.compiledImplicit,this.compiledExplicit)}fy.DEFAULT=null;fy.create=function(){var e,r;switch(arguments.length){case 1:e=fy.DEFAULT,r=arguments[0];break;case 2:e=arguments[0],r=arguments[1];break;default:throw new _D("Wrong number of arguments for Schema.create function")}if(e=hW.toArray(e),r=hW.toArray(r),!e.every(function(o){return o instanceof fy}))throw new _D("Specified list of super schemas (or a single Schema object) contains a non-Schema object.");if(!r.every(function(o){return o instanceof v8e}))throw new _D("Specified list of YAML types (or a single Type object) contains a non-Type object.");return new fy({include:e,explicit:r})};gW.exports=fy});var mW=_((Hbt,dW)=>{"use strict";var S8e=os();dW.exports=new S8e("tag:yaml.org,2002:str",{kind:"scalar",construct:function(t){return t!==null?t:""}})});var EW=_((jbt,yW)=>{"use strict";var P8e=os();yW.exports=new P8e("tag:yaml.org,2002:seq",{kind:"sequence",construct:function(t){return t!==null?t:[]}})});var wW=_((Gbt,CW)=>{"use strict";var b8e=os();CW.exports=new b8e("tag:yaml.org,2002:map",{kind:"mapping",construct:function(t){return t!==null?t:{}}})});var HD=_((qbt,IW)=>{"use strict";var x8e=Jg();IW.exports=new x8e({explicit:[mW(),EW(),wW()]})});var vW=_((Ybt,BW)=>{"use strict";var k8e=os();function Q8e(t){if(t===null)return!0;var e=t.length;return e===1&&t==="~"||e===4&&(t==="null"||t==="Null"||t==="NULL")}function F8e(){return null}function R8e(t){return t===null}BW.exports=new k8e("tag:yaml.org,2002:null",{kind:"scalar",resolve:Q8e,construct:F8e,predicate:R8e,represent:{canonical:function(){return"~"},lowercase:function(){return"null"},uppercase:function(){return"NULL"},camelcase:function(){return"Null"}},defaultStyle:"lowercase"})});var SW=_((Wbt,DW)=>{"use strict";var T8e=os();function N8e(t){if(t===null)return!1;var e=t.length;return e===4&&(t==="true"||t==="True"||t==="TRUE")||e===5&&(t==="false"||t==="False"||t==="FALSE")}function L8e(t){return t==="true"||t==="True"||t==="TRUE"}function O8e(t){return Object.prototype.toString.call(t)==="[object Boolean]"}DW.exports=new T8e("tag:yaml.org,2002:bool",{kind:"scalar",resolve:N8e,construct:L8e,predicate:O8e,represent:{lowercase:function(t){return t?"true":"false"},uppercase:function(t){return t?"TRUE":"FALSE"},camelcase:function(t){return t?"True":"False"}},defaultStyle:"lowercase"})});var bW=_((Kbt,PW)=>{"use strict";var M8e=Vg(),U8e=os();function _8e(t){return 48<=t&&t<=57||65<=t&&t<=70||97<=t&&t<=102}function H8e(t){return 48<=t&&t<=55}function j8e(t){return 48<=t&&t<=57}function G8e(t){if(t===null)return!1;var e=t.length,r=0,o=!1,a;if(!e)return!1;if(a=t[r],(a==="-"||a==="+")&&(a=t[++r]),a==="0"){if(r+1===e)return!0;if(a=t[++r],a==="b"){for(r++;r<e;r++)if(a=t[r],a!=="_"){if(a!=="0"&&a!=="1")return!1;o=!0}return o&&a!=="_"}if(a==="x"){for(r++;r<e;r++)if(a=t[r],a!=="_"){if(!_8e(t.charCodeAt(r)))return!1;o=!0}return o&&a!=="_"}for(;r<e;r++)if(a=t[r],a!=="_"){if(!H8e(t.charCodeAt(r)))return!1;o=!0}return o&&a!=="_"}if(a==="_")return!1;for(;r<e;r++)if(a=t[r],a!=="_"){if(a===":")break;if(!j8e(t.charCodeAt(r)))return!1;o=!0}return!o||a==="_"?!1:a!==":"?!0:/^(:[0-5]?[0-9])+$/.test(t.slice(r))}function q8e(t){var e=t,r=1,o,a,n=[];return e.indexOf("_")!==-1&&(e=e.replace(/_/g,"")),o=e[0],(o==="-"||o==="+")&&(o==="-"&&(r=-1),e=e.slice(1),o=e[0]),e==="0"?0:o==="0"?e[1]==="b"?r*parseInt(e.slice(2),2):e[1]==="x"?r*parseInt(e,16):r*parseInt(e,8):e.indexOf(":")!==-1?(e.split(":").forEach(function(u){n.unshift(parseInt(u,10))}),e=0,a=1,n.forEach(function(u){e+=u*a,a*=60}),r*e):r*parseInt(e,10)}function Y8e(t){return Object.prototype.toString.call(t)==="[object Number]"&&t%1===0&&!M8e.isNegativeZero(t)}PW.exports=new U8e("tag:yaml.org,2002:int",{kind:"scalar",resolve:G8e,construct:q8e,predicate:Y8e,represent:{binary:function(t){return t>=0?"0b"+t.toString(2):"-0b"+t.toString(2).slice(1)},octal:function(t){return t>=0?"0"+t.toString(8):"-0"+t.toString(8).slice(1)},decimal:function(t){return t.toString(10)},hexadecimal:function(t){return t>=0?"0x"+t.toString(16).toUpperCase():"-0x"+t.toString(16).toUpperCase().slice(1)}},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}})});var QW=_((Vbt,kW)=>{"use strict";var xW=Vg(),W8e=os(),K8e=new RegExp("^(?:[-+]?(?:0|[1-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\\.[0-9_]*|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");function V8e(t){return!(t===null||!K8e.test(t)||t[t.length-1]==="_")}function J8e(t){var e,r,o,a;return e=t.replace(/_/g,"").toLowerCase(),r=e[0]==="-"?-1:1,a=[],"+-".indexOf(e[0])>=0&&(e=e.slice(1)),e===".inf"?r===1?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:e===".nan"?NaN:e.indexOf(":")>=0?(e.split(":").forEach(function(n){a.unshift(parseFloat(n,10))}),e=0,o=1,a.forEach(function(n){e+=n*o,o*=60}),r*e):r*parseFloat(e,10)}var z8e=/^[-+]?[0-9]+e/;function X8e(t,e){var r;if(isNaN(t))switch(e){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===t)switch(e){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===t)switch(e){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(xW.isNegativeZero(t))return"-0.0";return r=t.toString(10),z8e.test(r)?r.replace("e",".e"):r}function Z8e(t){return Object.prototype.toString.call(t)==="[object Number]"&&(t%1!==0||xW.isNegativeZero(t))}kW.exports=new W8e("tag:yaml.org,2002:float",{kind:"scalar",resolve:V8e,construct:J8e,predicate:Z8e,represent:X8e,defaultStyle:"lowercase"})});var pT=_((Jbt,FW)=>{"use strict";var $8e=Jg();FW.exports=new $8e({include:[HD()],implicit:[vW(),SW(),bW(),QW()]})});var hT=_((zbt,RW)=>{"use strict";var eHe=Jg();RW.exports=new eHe({include:[pT()]})});var OW=_((Xbt,LW)=>{"use strict";var tHe=os(),TW=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),NW=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");function rHe(t){return t===null?!1:TW.exec(t)!==null||NW.exec(t)!==null}function nHe(t){var e,r,o,a,n,u,A,p=0,h=null,E,I,v;if(e=TW.exec(t),e===null&&(e=NW.exec(t)),e===null)throw new Error("Date resolve error");if(r=+e[1],o=+e[2]-1,a=+e[3],!e[4])return new Date(Date.UTC(r,o,a));if(n=+e[4],u=+e[5],A=+e[6],e[7]){for(p=e[7].slice(0,3);p.length<3;)p+="0";p=+p}return e[9]&&(E=+e[10],I=+(e[11]||0),h=(E*60+I)*6e4,e[9]==="-"&&(h=-h)),v=new Date(Date.UTC(r,o,a,n,u,A,p)),h&&v.setTime(v.getTime()-h),v}function iHe(t){return t.toISOString()}LW.exports=new tHe("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:rHe,construct:nHe,instanceOf:Date,represent:iHe})});var UW=_((Zbt,MW)=>{"use strict";var sHe=os();function oHe(t){return t==="<<"||t===null}MW.exports=new sHe("tag:yaml.org,2002:merge",{kind:"scalar",resolve:oHe})});var jW=_(($bt,HW)=>{"use strict";var zg;try{_W=ve,zg=_W("buffer").Buffer}catch{}var _W,aHe=os(),gT=`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/= +\r`;function lHe(t){if(t===null)return!1;var e,r,o=0,a=t.length,n=gT;for(r=0;r<a;r++)if(e=n.indexOf(t.charAt(r)),!(e>64)){if(e<0)return!1;o+=6}return o%8===0}function cHe(t){var e,r,o=t.replace(/[\r\n=]/g,""),a=o.length,n=gT,u=0,A=[];for(e=0;e<a;e++)e%4===0&&e&&(A.push(u>>16&255),A.push(u>>8&255),A.push(u&255)),u=u<<6|n.indexOf(o.charAt(e));return r=a%4*6,r===0?(A.push(u>>16&255),A.push(u>>8&255),A.push(u&255)):r===18?(A.push(u>>10&255),A.push(u>>2&255)):r===12&&A.push(u>>4&255),zg?zg.from?zg.from(A):new zg(A):A}function uHe(t){var e="",r=0,o,a,n=t.length,u=gT;for(o=0;o<n;o++)o%3===0&&o&&(e+=u[r>>18&63],e+=u[r>>12&63],e+=u[r>>6&63],e+=u[r&63]),r=(r<<8)+t[o];return a=n%3,a===0?(e+=u[r>>18&63],e+=u[r>>12&63],e+=u[r>>6&63],e+=u[r&63]):a===2?(e+=u[r>>10&63],e+=u[r>>4&63],e+=u[r<<2&63],e+=u[64]):a===1&&(e+=u[r>>2&63],e+=u[r<<4&63],e+=u[64],e+=u[64]),e}function AHe(t){return zg&&zg.isBuffer(t)}HW.exports=new aHe("tag:yaml.org,2002:binary",{kind:"scalar",resolve:lHe,construct:cHe,predicate:AHe,represent:uHe})});var qW=_((txt,GW)=>{"use strict";var fHe=os(),pHe=Object.prototype.hasOwnProperty,hHe=Object.prototype.toString;function gHe(t){if(t===null)return!0;var e=[],r,o,a,n,u,A=t;for(r=0,o=A.length;r<o;r+=1){if(a=A[r],u=!1,hHe.call(a)!=="[object Object]")return!1;for(n in a)if(pHe.call(a,n))if(!u)u=!0;else return!1;if(!u)return!1;if(e.indexOf(n)===-1)e.push(n);else return!1}return!0}function dHe(t){return t!==null?t:[]}GW.exports=new fHe("tag:yaml.org,2002:omap",{kind:"sequence",resolve:gHe,construct:dHe})});var WW=_((rxt,YW)=>{"use strict";var mHe=os(),yHe=Object.prototype.toString;function EHe(t){if(t===null)return!0;var e,r,o,a,n,u=t;for(n=new Array(u.length),e=0,r=u.length;e<r;e+=1){if(o=u[e],yHe.call(o)!=="[object Object]"||(a=Object.keys(o),a.length!==1))return!1;n[e]=[a[0],o[a[0]]]}return!0}function CHe(t){if(t===null)return[];var e,r,o,a,n,u=t;for(n=new Array(u.length),e=0,r=u.length;e<r;e+=1)o=u[e],a=Object.keys(o),n[e]=[a[0],o[a[0]]];return n}YW.exports=new mHe("tag:yaml.org,2002:pairs",{kind:"sequence",resolve:EHe,construct:CHe})});var VW=_((nxt,KW)=>{"use strict";var wHe=os(),IHe=Object.prototype.hasOwnProperty;function BHe(t){if(t===null)return!0;var e,r=t;for(e in r)if(IHe.call(r,e)&&r[e]!==null)return!1;return!0}function vHe(t){return t!==null?t:{}}KW.exports=new wHe("tag:yaml.org,2002:set",{kind:"mapping",resolve:BHe,construct:vHe})});var py=_((ixt,JW)=>{"use strict";var DHe=Jg();JW.exports=new DHe({include:[hT()],implicit:[OW(),UW()],explicit:[jW(),qW(),WW(),VW()]})});var XW=_((sxt,zW)=>{"use strict";var SHe=os();function PHe(){return!0}function bHe(){}function xHe(){return""}function kHe(t){return typeof t>"u"}zW.exports=new SHe("tag:yaml.org,2002:js/undefined",{kind:"scalar",resolve:PHe,construct:bHe,predicate:kHe,represent:xHe})});var $W=_((oxt,ZW)=>{"use strict";var QHe=os();function FHe(t){if(t===null||t.length===0)return!1;var e=t,r=/\/([gim]*)$/.exec(t),o="";return!(e[0]==="/"&&(r&&(o=r[1]),o.length>3||e[e.length-o.length-1]!=="/"))}function RHe(t){var e=t,r=/\/([gim]*)$/.exec(t),o="";return e[0]==="/"&&(r&&(o=r[1]),e=e.slice(1,e.length-o.length-1)),new RegExp(e,o)}function THe(t){var e="/"+t.source+"/";return t.global&&(e+="g"),t.multiline&&(e+="m"),t.ignoreCase&&(e+="i"),e}function NHe(t){return Object.prototype.toString.call(t)==="[object RegExp]"}ZW.exports=new QHe("tag:yaml.org,2002:js/regexp",{kind:"scalar",resolve:FHe,construct:RHe,predicate:NHe,represent:THe})});var rK=_((axt,tK)=>{"use strict";var jD;try{eK=ve,jD=eK("esprima")}catch{typeof window<"u"&&(jD=window.esprima)}var eK,LHe=os();function OHe(t){if(t===null)return!1;try{var e="("+t+")",r=jD.parse(e,{range:!0});return!(r.type!=="Program"||r.body.length!==1||r.body[0].type!=="ExpressionStatement"||r.body[0].expression.type!=="ArrowFunctionExpression"&&r.body[0].expression.type!=="FunctionExpression")}catch{return!1}}function MHe(t){var e="("+t+")",r=jD.parse(e,{range:!0}),o=[],a;if(r.type!=="Program"||r.body.length!==1||r.body[0].type!=="ExpressionStatement"||r.body[0].expression.type!=="ArrowFunctionExpression"&&r.body[0].expression.type!=="FunctionExpression")throw new Error("Failed to resolve function");return r.body[0].expression.params.forEach(function(n){o.push(n.name)}),a=r.body[0].expression.body.range,r.body[0].expression.body.type==="BlockStatement"?new Function(o,e.slice(a[0]+1,a[1]-1)):new Function(o,"return "+e.slice(a[0],a[1]))}function UHe(t){return t.toString()}function _He(t){return Object.prototype.toString.call(t)==="[object Function]"}tK.exports=new LHe("tag:yaml.org,2002:js/function",{kind:"scalar",resolve:OHe,construct:MHe,predicate:_He,represent:UHe})});var Xw=_((cxt,iK)=>{"use strict";var nK=Jg();iK.exports=nK.DEFAULT=new nK({include:[py()],explicit:[XW(),$W(),rK()]})});var BK=_((uxt,Zw)=>{"use strict";var yf=Vg(),AK=Ay(),HHe=AW(),fK=py(),jHe=Xw(),Yp=Object.prototype.hasOwnProperty,GD=1,pK=2,hK=3,qD=4,dT=1,GHe=2,sK=3,qHe=/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/,YHe=/[\x85\u2028\u2029]/,WHe=/[,\[\]\{\}]/,gK=/^(?:!|!!|![a-z\-]+!)$/i,dK=/^(?:!|[^,\[\]\{\}])(?:%[0-9a-f]{2}|[0-9a-z\-#;\/\?:@&=\+\$,_\.!~\*'\(\)\[\]])*$/i;function oK(t){return Object.prototype.toString.call(t)}function Hu(t){return t===10||t===13}function Zg(t){return t===9||t===32}function Ia(t){return t===9||t===32||t===10||t===13}function hy(t){return t===44||t===91||t===93||t===123||t===125}function KHe(t){var e;return 48<=t&&t<=57?t-48:(e=t|32,97<=e&&e<=102?e-97+10:-1)}function VHe(t){return t===120?2:t===117?4:t===85?8:0}function JHe(t){return 48<=t&&t<=57?t-48:-1}function aK(t){return t===48?"\0":t===97?"\x07":t===98?"\b":t===116||t===9?" ":t===110?` +`:t===118?"\v":t===102?"\f":t===114?"\r":t===101?"\x1B":t===32?" ":t===34?'"':t===47?"/":t===92?"\\":t===78?"\x85":t===95?"\xA0":t===76?"\u2028":t===80?"\u2029":""}function zHe(t){return t<=65535?String.fromCharCode(t):String.fromCharCode((t-65536>>10)+55296,(t-65536&1023)+56320)}var mK=new Array(256),yK=new Array(256);for(Xg=0;Xg<256;Xg++)mK[Xg]=aK(Xg)?1:0,yK[Xg]=aK(Xg);var Xg;function XHe(t,e){this.input=t,this.filename=e.filename||null,this.schema=e.schema||jHe,this.onWarning=e.onWarning||null,this.legacy=e.legacy||!1,this.json=e.json||!1,this.listener=e.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=t.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.documents=[]}function EK(t,e){return new AK(e,new HHe(t.filename,t.input,t.position,t.line,t.position-t.lineStart))}function Pr(t,e){throw EK(t,e)}function YD(t,e){t.onWarning&&t.onWarning.call(null,EK(t,e))}var lK={YAML:function(e,r,o){var a,n,u;e.version!==null&&Pr(e,"duplication of %YAML directive"),o.length!==1&&Pr(e,"YAML directive accepts exactly one argument"),a=/^([0-9]+)\.([0-9]+)$/.exec(o[0]),a===null&&Pr(e,"ill-formed argument of the YAML directive"),n=parseInt(a[1],10),u=parseInt(a[2],10),n!==1&&Pr(e,"unacceptable YAML version of the document"),e.version=o[0],e.checkLineBreaks=u<2,u!==1&&u!==2&&YD(e,"unsupported YAML version of the document")},TAG:function(e,r,o){var a,n;o.length!==2&&Pr(e,"TAG directive accepts exactly two arguments"),a=o[0],n=o[1],gK.test(a)||Pr(e,"ill-formed tag handle (first argument) of the TAG directive"),Yp.call(e.tagMap,a)&&Pr(e,'there is a previously declared suffix for "'+a+'" tag handle'),dK.test(n)||Pr(e,"ill-formed tag prefix (second argument) of the TAG directive"),e.tagMap[a]=n}};function qp(t,e,r,o){var a,n,u,A;if(e<r){if(A=t.input.slice(e,r),o)for(a=0,n=A.length;a<n;a+=1)u=A.charCodeAt(a),u===9||32<=u&&u<=1114111||Pr(t,"expected valid JSON character");else qHe.test(A)&&Pr(t,"the stream contains non-printable characters");t.result+=A}}function cK(t,e,r,o){var a,n,u,A;for(yf.isObject(r)||Pr(t,"cannot merge mappings; the provided source object is unacceptable"),a=Object.keys(r),u=0,A=a.length;u<A;u+=1)n=a[u],Yp.call(e,n)||(e[n]=r[n],o[n]=!0)}function gy(t,e,r,o,a,n,u,A){var p,h;if(Array.isArray(a))for(a=Array.prototype.slice.call(a),p=0,h=a.length;p<h;p+=1)Array.isArray(a[p])&&Pr(t,"nested arrays are not supported inside keys"),typeof a=="object"&&oK(a[p])==="[object Object]"&&(a[p]="[object Object]");if(typeof a=="object"&&oK(a)==="[object Object]"&&(a="[object Object]"),a=String(a),e===null&&(e={}),o==="tag:yaml.org,2002:merge")if(Array.isArray(n))for(p=0,h=n.length;p<h;p+=1)cK(t,e,n[p],r);else cK(t,e,n,r);else!t.json&&!Yp.call(r,a)&&Yp.call(e,a)&&(t.line=u||t.line,t.position=A||t.position,Pr(t,"duplicated mapping key")),e[a]=n,delete r[a];return e}function mT(t){var e;e=t.input.charCodeAt(t.position),e===10?t.position++:e===13?(t.position++,t.input.charCodeAt(t.position)===10&&t.position++):Pr(t,"a line break is expected"),t.line+=1,t.lineStart=t.position}function Wi(t,e,r){for(var o=0,a=t.input.charCodeAt(t.position);a!==0;){for(;Zg(a);)a=t.input.charCodeAt(++t.position);if(e&&a===35)do a=t.input.charCodeAt(++t.position);while(a!==10&&a!==13&&a!==0);if(Hu(a))for(mT(t),a=t.input.charCodeAt(t.position),o++,t.lineIndent=0;a===32;)t.lineIndent++,a=t.input.charCodeAt(++t.position);else break}return r!==-1&&o!==0&&t.lineIndent<r&&YD(t,"deficient indentation"),o}function WD(t){var e=t.position,r;return r=t.input.charCodeAt(e),!!((r===45||r===46)&&r===t.input.charCodeAt(e+1)&&r===t.input.charCodeAt(e+2)&&(e+=3,r=t.input.charCodeAt(e),r===0||Ia(r)))}function yT(t,e){e===1?t.result+=" ":e>1&&(t.result+=yf.repeat(` +`,e-1))}function ZHe(t,e,r){var o,a,n,u,A,p,h,E,I=t.kind,v=t.result,x;if(x=t.input.charCodeAt(t.position),Ia(x)||hy(x)||x===35||x===38||x===42||x===33||x===124||x===62||x===39||x===34||x===37||x===64||x===96||(x===63||x===45)&&(a=t.input.charCodeAt(t.position+1),Ia(a)||r&&hy(a)))return!1;for(t.kind="scalar",t.result="",n=u=t.position,A=!1;x!==0;){if(x===58){if(a=t.input.charCodeAt(t.position+1),Ia(a)||r&&hy(a))break}else if(x===35){if(o=t.input.charCodeAt(t.position-1),Ia(o))break}else{if(t.position===t.lineStart&&WD(t)||r&&hy(x))break;if(Hu(x))if(p=t.line,h=t.lineStart,E=t.lineIndent,Wi(t,!1,-1),t.lineIndent>=e){A=!0,x=t.input.charCodeAt(t.position);continue}else{t.position=u,t.line=p,t.lineStart=h,t.lineIndent=E;break}}A&&(qp(t,n,u,!1),yT(t,t.line-p),n=u=t.position,A=!1),Zg(x)||(u=t.position+1),x=t.input.charCodeAt(++t.position)}return qp(t,n,u,!1),t.result?!0:(t.kind=I,t.result=v,!1)}function $He(t,e){var r,o,a;if(r=t.input.charCodeAt(t.position),r!==39)return!1;for(t.kind="scalar",t.result="",t.position++,o=a=t.position;(r=t.input.charCodeAt(t.position))!==0;)if(r===39)if(qp(t,o,t.position,!0),r=t.input.charCodeAt(++t.position),r===39)o=t.position,t.position++,a=t.position;else return!0;else Hu(r)?(qp(t,o,a,!0),yT(t,Wi(t,!1,e)),o=a=t.position):t.position===t.lineStart&&WD(t)?Pr(t,"unexpected end of the document within a single quoted scalar"):(t.position++,a=t.position);Pr(t,"unexpected end of the stream within a single quoted scalar")}function e6e(t,e){var r,o,a,n,u,A;if(A=t.input.charCodeAt(t.position),A!==34)return!1;for(t.kind="scalar",t.result="",t.position++,r=o=t.position;(A=t.input.charCodeAt(t.position))!==0;){if(A===34)return qp(t,r,t.position,!0),t.position++,!0;if(A===92){if(qp(t,r,t.position,!0),A=t.input.charCodeAt(++t.position),Hu(A))Wi(t,!1,e);else if(A<256&&mK[A])t.result+=yK[A],t.position++;else if((u=VHe(A))>0){for(a=u,n=0;a>0;a--)A=t.input.charCodeAt(++t.position),(u=KHe(A))>=0?n=(n<<4)+u:Pr(t,"expected hexadecimal character");t.result+=zHe(n),t.position++}else Pr(t,"unknown escape sequence");r=o=t.position}else Hu(A)?(qp(t,r,o,!0),yT(t,Wi(t,!1,e)),r=o=t.position):t.position===t.lineStart&&WD(t)?Pr(t,"unexpected end of the document within a double quoted scalar"):(t.position++,o=t.position)}Pr(t,"unexpected end of the stream within a double quoted scalar")}function t6e(t,e){var r=!0,o,a=t.tag,n,u=t.anchor,A,p,h,E,I,v={},x,C,R,L;if(L=t.input.charCodeAt(t.position),L===91)p=93,I=!1,n=[];else if(L===123)p=125,I=!0,n={};else return!1;for(t.anchor!==null&&(t.anchorMap[t.anchor]=n),L=t.input.charCodeAt(++t.position);L!==0;){if(Wi(t,!0,e),L=t.input.charCodeAt(t.position),L===p)return t.position++,t.tag=a,t.anchor=u,t.kind=I?"mapping":"sequence",t.result=n,!0;r||Pr(t,"missed comma between flow collection entries"),C=x=R=null,h=E=!1,L===63&&(A=t.input.charCodeAt(t.position+1),Ia(A)&&(h=E=!0,t.position++,Wi(t,!0,e))),o=t.line,dy(t,e,GD,!1,!0),C=t.tag,x=t.result,Wi(t,!0,e),L=t.input.charCodeAt(t.position),(E||t.line===o)&&L===58&&(h=!0,L=t.input.charCodeAt(++t.position),Wi(t,!0,e),dy(t,e,GD,!1,!0),R=t.result),I?gy(t,n,v,C,x,R):h?n.push(gy(t,null,v,C,x,R)):n.push(x),Wi(t,!0,e),L=t.input.charCodeAt(t.position),L===44?(r=!0,L=t.input.charCodeAt(++t.position)):r=!1}Pr(t,"unexpected end of the stream within a flow collection")}function r6e(t,e){var r,o,a=dT,n=!1,u=!1,A=e,p=0,h=!1,E,I;if(I=t.input.charCodeAt(t.position),I===124)o=!1;else if(I===62)o=!0;else return!1;for(t.kind="scalar",t.result="";I!==0;)if(I=t.input.charCodeAt(++t.position),I===43||I===45)dT===a?a=I===43?sK:GHe:Pr(t,"repeat of a chomping mode identifier");else if((E=JHe(I))>=0)E===0?Pr(t,"bad explicit indentation width of a block scalar; it cannot be less than one"):u?Pr(t,"repeat of an indentation width identifier"):(A=e+E-1,u=!0);else break;if(Zg(I)){do I=t.input.charCodeAt(++t.position);while(Zg(I));if(I===35)do I=t.input.charCodeAt(++t.position);while(!Hu(I)&&I!==0)}for(;I!==0;){for(mT(t),t.lineIndent=0,I=t.input.charCodeAt(t.position);(!u||t.lineIndent<A)&&I===32;)t.lineIndent++,I=t.input.charCodeAt(++t.position);if(!u&&t.lineIndent>A&&(A=t.lineIndent),Hu(I)){p++;continue}if(t.lineIndent<A){a===sK?t.result+=yf.repeat(` +`,n?1+p:p):a===dT&&n&&(t.result+=` +`);break}for(o?Zg(I)?(h=!0,t.result+=yf.repeat(` +`,n?1+p:p)):h?(h=!1,t.result+=yf.repeat(` +`,p+1)):p===0?n&&(t.result+=" "):t.result+=yf.repeat(` +`,p):t.result+=yf.repeat(` +`,n?1+p:p),n=!0,u=!0,p=0,r=t.position;!Hu(I)&&I!==0;)I=t.input.charCodeAt(++t.position);qp(t,r,t.position,!1)}return!0}function uK(t,e){var r,o=t.tag,a=t.anchor,n=[],u,A=!1,p;for(t.anchor!==null&&(t.anchorMap[t.anchor]=n),p=t.input.charCodeAt(t.position);p!==0&&!(p!==45||(u=t.input.charCodeAt(t.position+1),!Ia(u)));){if(A=!0,t.position++,Wi(t,!0,-1)&&t.lineIndent<=e){n.push(null),p=t.input.charCodeAt(t.position);continue}if(r=t.line,dy(t,e,hK,!1,!0),n.push(t.result),Wi(t,!0,-1),p=t.input.charCodeAt(t.position),(t.line===r||t.lineIndent>e)&&p!==0)Pr(t,"bad indentation of a sequence entry");else if(t.lineIndent<e)break}return A?(t.tag=o,t.anchor=a,t.kind="sequence",t.result=n,!0):!1}function n6e(t,e,r){var o,a,n,u,A=t.tag,p=t.anchor,h={},E={},I=null,v=null,x=null,C=!1,R=!1,L;for(t.anchor!==null&&(t.anchorMap[t.anchor]=h),L=t.input.charCodeAt(t.position);L!==0;){if(o=t.input.charCodeAt(t.position+1),n=t.line,u=t.position,(L===63||L===58)&&Ia(o))L===63?(C&&(gy(t,h,E,I,v,null),I=v=x=null),R=!0,C=!0,a=!0):C?(C=!1,a=!0):Pr(t,"incomplete explicit mapping pair; a key node is missed; or followed by a non-tabulated empty line"),t.position+=1,L=o;else if(dy(t,r,pK,!1,!0))if(t.line===n){for(L=t.input.charCodeAt(t.position);Zg(L);)L=t.input.charCodeAt(++t.position);if(L===58)L=t.input.charCodeAt(++t.position),Ia(L)||Pr(t,"a whitespace character is expected after the key-value separator within a block mapping"),C&&(gy(t,h,E,I,v,null),I=v=x=null),R=!0,C=!1,a=!1,I=t.tag,v=t.result;else if(R)Pr(t,"can not read an implicit mapping pair; a colon is missed");else return t.tag=A,t.anchor=p,!0}else if(R)Pr(t,"can not read a block mapping entry; a multiline key may not be an implicit key");else return t.tag=A,t.anchor=p,!0;else break;if((t.line===n||t.lineIndent>e)&&(dy(t,e,qD,!0,a)&&(C?v=t.result:x=t.result),C||(gy(t,h,E,I,v,x,n,u),I=v=x=null),Wi(t,!0,-1),L=t.input.charCodeAt(t.position)),t.lineIndent>e&&L!==0)Pr(t,"bad indentation of a mapping entry");else if(t.lineIndent<e)break}return C&&gy(t,h,E,I,v,null),R&&(t.tag=A,t.anchor=p,t.kind="mapping",t.result=h),R}function i6e(t){var e,r=!1,o=!1,a,n,u;if(u=t.input.charCodeAt(t.position),u!==33)return!1;if(t.tag!==null&&Pr(t,"duplication of a tag property"),u=t.input.charCodeAt(++t.position),u===60?(r=!0,u=t.input.charCodeAt(++t.position)):u===33?(o=!0,a="!!",u=t.input.charCodeAt(++t.position)):a="!",e=t.position,r){do u=t.input.charCodeAt(++t.position);while(u!==0&&u!==62);t.position<t.length?(n=t.input.slice(e,t.position),u=t.input.charCodeAt(++t.position)):Pr(t,"unexpected end of the stream within a verbatim tag")}else{for(;u!==0&&!Ia(u);)u===33&&(o?Pr(t,"tag suffix cannot contain exclamation marks"):(a=t.input.slice(e-1,t.position+1),gK.test(a)||Pr(t,"named tag handle cannot contain such characters"),o=!0,e=t.position+1)),u=t.input.charCodeAt(++t.position);n=t.input.slice(e,t.position),WHe.test(n)&&Pr(t,"tag suffix cannot contain flow indicator characters")}return n&&!dK.test(n)&&Pr(t,"tag name cannot contain such characters: "+n),r?t.tag=n:Yp.call(t.tagMap,a)?t.tag=t.tagMap[a]+n:a==="!"?t.tag="!"+n:a==="!!"?t.tag="tag:yaml.org,2002:"+n:Pr(t,'undeclared tag handle "'+a+'"'),!0}function s6e(t){var e,r;if(r=t.input.charCodeAt(t.position),r!==38)return!1;for(t.anchor!==null&&Pr(t,"duplication of an anchor property"),r=t.input.charCodeAt(++t.position),e=t.position;r!==0&&!Ia(r)&&!hy(r);)r=t.input.charCodeAt(++t.position);return t.position===e&&Pr(t,"name of an anchor node must contain at least one character"),t.anchor=t.input.slice(e,t.position),!0}function o6e(t){var e,r,o;if(o=t.input.charCodeAt(t.position),o!==42)return!1;for(o=t.input.charCodeAt(++t.position),e=t.position;o!==0&&!Ia(o)&&!hy(o);)o=t.input.charCodeAt(++t.position);return t.position===e&&Pr(t,"name of an alias node must contain at least one character"),r=t.input.slice(e,t.position),Yp.call(t.anchorMap,r)||Pr(t,'unidentified alias "'+r+'"'),t.result=t.anchorMap[r],Wi(t,!0,-1),!0}function dy(t,e,r,o,a){var n,u,A,p=1,h=!1,E=!1,I,v,x,C,R;if(t.listener!==null&&t.listener("open",t),t.tag=null,t.anchor=null,t.kind=null,t.result=null,n=u=A=qD===r||hK===r,o&&Wi(t,!0,-1)&&(h=!0,t.lineIndent>e?p=1:t.lineIndent===e?p=0:t.lineIndent<e&&(p=-1)),p===1)for(;i6e(t)||s6e(t);)Wi(t,!0,-1)?(h=!0,A=n,t.lineIndent>e?p=1:t.lineIndent===e?p=0:t.lineIndent<e&&(p=-1)):A=!1;if(A&&(A=h||a),(p===1||qD===r)&&(GD===r||pK===r?C=e:C=e+1,R=t.position-t.lineStart,p===1?A&&(uK(t,R)||n6e(t,R,C))||t6e(t,C)?E=!0:(u&&r6e(t,C)||$He(t,C)||e6e(t,C)?E=!0:o6e(t)?(E=!0,(t.tag!==null||t.anchor!==null)&&Pr(t,"alias node should not have any properties")):ZHe(t,C,GD===r)&&(E=!0,t.tag===null&&(t.tag="?")),t.anchor!==null&&(t.anchorMap[t.anchor]=t.result)):p===0&&(E=A&&uK(t,R))),t.tag!==null&&t.tag!=="!")if(t.tag==="?"){for(t.result!==null&&t.kind!=="scalar"&&Pr(t,'unacceptable node kind for !<?> tag; it should be "scalar", not "'+t.kind+'"'),I=0,v=t.implicitTypes.length;I<v;I+=1)if(x=t.implicitTypes[I],x.resolve(t.result)){t.result=x.construct(t.result),t.tag=x.tag,t.anchor!==null&&(t.anchorMap[t.anchor]=t.result);break}}else Yp.call(t.typeMap[t.kind||"fallback"],t.tag)?(x=t.typeMap[t.kind||"fallback"][t.tag],t.result!==null&&x.kind!==t.kind&&Pr(t,"unacceptable node kind for !<"+t.tag+'> tag; it should be "'+x.kind+'", not "'+t.kind+'"'),x.resolve(t.result)?(t.result=x.construct(t.result),t.anchor!==null&&(t.anchorMap[t.anchor]=t.result)):Pr(t,"cannot resolve a node with !<"+t.tag+"> explicit tag")):Pr(t,"unknown tag !<"+t.tag+">");return t.listener!==null&&t.listener("close",t),t.tag!==null||t.anchor!==null||E}function a6e(t){var e=t.position,r,o,a,n=!1,u;for(t.version=null,t.checkLineBreaks=t.legacy,t.tagMap={},t.anchorMap={};(u=t.input.charCodeAt(t.position))!==0&&(Wi(t,!0,-1),u=t.input.charCodeAt(t.position),!(t.lineIndent>0||u!==37));){for(n=!0,u=t.input.charCodeAt(++t.position),r=t.position;u!==0&&!Ia(u);)u=t.input.charCodeAt(++t.position);for(o=t.input.slice(r,t.position),a=[],o.length<1&&Pr(t,"directive name must not be less than one character in length");u!==0;){for(;Zg(u);)u=t.input.charCodeAt(++t.position);if(u===35){do u=t.input.charCodeAt(++t.position);while(u!==0&&!Hu(u));break}if(Hu(u))break;for(r=t.position;u!==0&&!Ia(u);)u=t.input.charCodeAt(++t.position);a.push(t.input.slice(r,t.position))}u!==0&&mT(t),Yp.call(lK,o)?lK[o](t,o,a):YD(t,'unknown document directive "'+o+'"')}if(Wi(t,!0,-1),t.lineIndent===0&&t.input.charCodeAt(t.position)===45&&t.input.charCodeAt(t.position+1)===45&&t.input.charCodeAt(t.position+2)===45?(t.position+=3,Wi(t,!0,-1)):n&&Pr(t,"directives end mark is expected"),dy(t,t.lineIndent-1,qD,!1,!0),Wi(t,!0,-1),t.checkLineBreaks&&YHe.test(t.input.slice(e,t.position))&&YD(t,"non-ASCII line breaks are interpreted as content"),t.documents.push(t.result),t.position===t.lineStart&&WD(t)){t.input.charCodeAt(t.position)===46&&(t.position+=3,Wi(t,!0,-1));return}if(t.position<t.length-1)Pr(t,"end of the stream or a document separator is expected");else return}function CK(t,e){t=String(t),e=e||{},t.length!==0&&(t.charCodeAt(t.length-1)!==10&&t.charCodeAt(t.length-1)!==13&&(t+=` +`),t.charCodeAt(0)===65279&&(t=t.slice(1)));var r=new XHe(t,e),o=t.indexOf("\0");for(o!==-1&&(r.position=o,Pr(r,"null byte is not allowed in input")),r.input+="\0";r.input.charCodeAt(r.position)===32;)r.lineIndent+=1,r.position+=1;for(;r.position<r.length-1;)a6e(r);return r.documents}function wK(t,e,r){e!==null&&typeof e=="object"&&typeof r>"u"&&(r=e,e=null);var o=CK(t,r);if(typeof e!="function")return o;for(var a=0,n=o.length;a<n;a+=1)e(o[a])}function IK(t,e){var r=CK(t,e);if(r.length!==0){if(r.length===1)return r[0];throw new AK("expected a single document in the stream, but found more")}}function l6e(t,e,r){return typeof e=="object"&&e!==null&&typeof r>"u"&&(r=e,e=null),wK(t,e,yf.extend({schema:fK},r))}function c6e(t,e){return IK(t,yf.extend({schema:fK},e))}Zw.exports.loadAll=wK;Zw.exports.load=IK;Zw.exports.safeLoadAll=l6e;Zw.exports.safeLoad=c6e});var WK=_((Axt,IT)=>{"use strict";var eI=Vg(),tI=Ay(),u6e=Xw(),A6e=py(),QK=Object.prototype.toString,FK=Object.prototype.hasOwnProperty,f6e=9,$w=10,p6e=13,h6e=32,g6e=33,d6e=34,RK=35,m6e=37,y6e=38,E6e=39,C6e=42,TK=44,w6e=45,NK=58,I6e=61,B6e=62,v6e=63,D6e=64,LK=91,OK=93,S6e=96,MK=123,P6e=124,UK=125,vo={};vo[0]="\\0";vo[7]="\\a";vo[8]="\\b";vo[9]="\\t";vo[10]="\\n";vo[11]="\\v";vo[12]="\\f";vo[13]="\\r";vo[27]="\\e";vo[34]='\\"';vo[92]="\\\\";vo[133]="\\N";vo[160]="\\_";vo[8232]="\\L";vo[8233]="\\P";var b6e=["y","Y","yes","Yes","YES","on","On","ON","n","N","no","No","NO","off","Off","OFF"];function x6e(t,e){var r,o,a,n,u,A,p;if(e===null)return{};for(r={},o=Object.keys(e),a=0,n=o.length;a<n;a+=1)u=o[a],A=String(e[u]),u.slice(0,2)==="!!"&&(u="tag:yaml.org,2002:"+u.slice(2)),p=t.compiledTypeMap.fallback[u],p&&FK.call(p.styleAliases,A)&&(A=p.styleAliases[A]),r[u]=A;return r}function vK(t){var e,r,o;if(e=t.toString(16).toUpperCase(),t<=255)r="x",o=2;else if(t<=65535)r="u",o=4;else if(t<=4294967295)r="U",o=8;else throw new tI("code point within a string may not be greater than 0xFFFFFFFF");return"\\"+r+eI.repeat("0",o-e.length)+e}function k6e(t){this.schema=t.schema||u6e,this.indent=Math.max(1,t.indent||2),this.noArrayIndent=t.noArrayIndent||!1,this.skipInvalid=t.skipInvalid||!1,this.flowLevel=eI.isNothing(t.flowLevel)?-1:t.flowLevel,this.styleMap=x6e(this.schema,t.styles||null),this.sortKeys=t.sortKeys||!1,this.lineWidth=t.lineWidth||80,this.noRefs=t.noRefs||!1,this.noCompatMode=t.noCompatMode||!1,this.condenseFlow=t.condenseFlow||!1,this.implicitTypes=this.schema.compiledImplicit,this.explicitTypes=this.schema.compiledExplicit,this.tag=null,this.result="",this.duplicates=[],this.usedDuplicates=null}function DK(t,e){for(var r=eI.repeat(" ",e),o=0,a=-1,n="",u,A=t.length;o<A;)a=t.indexOf(` +`,o),a===-1?(u=t.slice(o),o=A):(u=t.slice(o,a+1),o=a+1),u.length&&u!==` +`&&(n+=r),n+=u;return n}function ET(t,e){return` +`+eI.repeat(" ",t.indent*e)}function Q6e(t,e){var r,o,a;for(r=0,o=t.implicitTypes.length;r<o;r+=1)if(a=t.implicitTypes[r],a.resolve(e))return!0;return!1}function wT(t){return t===h6e||t===f6e}function my(t){return 32<=t&&t<=126||161<=t&&t<=55295&&t!==8232&&t!==8233||57344<=t&&t<=65533&&t!==65279||65536<=t&&t<=1114111}function F6e(t){return my(t)&&!wT(t)&&t!==65279&&t!==p6e&&t!==$w}function SK(t,e){return my(t)&&t!==65279&&t!==TK&&t!==LK&&t!==OK&&t!==MK&&t!==UK&&t!==NK&&(t!==RK||e&&F6e(e))}function R6e(t){return my(t)&&t!==65279&&!wT(t)&&t!==w6e&&t!==v6e&&t!==NK&&t!==TK&&t!==LK&&t!==OK&&t!==MK&&t!==UK&&t!==RK&&t!==y6e&&t!==C6e&&t!==g6e&&t!==P6e&&t!==I6e&&t!==B6e&&t!==E6e&&t!==d6e&&t!==m6e&&t!==D6e&&t!==S6e}function _K(t){var e=/^\n* /;return e.test(t)}var HK=1,jK=2,GK=3,qK=4,KD=5;function T6e(t,e,r,o,a){var n,u,A,p=!1,h=!1,E=o!==-1,I=-1,v=R6e(t.charCodeAt(0))&&!wT(t.charCodeAt(t.length-1));if(e)for(n=0;n<t.length;n++){if(u=t.charCodeAt(n),!my(u))return KD;A=n>0?t.charCodeAt(n-1):null,v=v&&SK(u,A)}else{for(n=0;n<t.length;n++){if(u=t.charCodeAt(n),u===$w)p=!0,E&&(h=h||n-I-1>o&&t[I+1]!==" ",I=n);else if(!my(u))return KD;A=n>0?t.charCodeAt(n-1):null,v=v&&SK(u,A)}h=h||E&&n-I-1>o&&t[I+1]!==" "}return!p&&!h?v&&!a(t)?HK:jK:r>9&&_K(t)?KD:h?qK:GK}function N6e(t,e,r,o){t.dump=function(){if(e.length===0)return"''";if(!t.noCompatMode&&b6e.indexOf(e)!==-1)return"'"+e+"'";var a=t.indent*Math.max(1,r),n=t.lineWidth===-1?-1:Math.max(Math.min(t.lineWidth,40),t.lineWidth-a),u=o||t.flowLevel>-1&&r>=t.flowLevel;function A(p){return Q6e(t,p)}switch(T6e(e,u,t.indent,n,A)){case HK:return e;case jK:return"'"+e.replace(/'/g,"''")+"'";case GK:return"|"+PK(e,t.indent)+bK(DK(e,a));case qK:return">"+PK(e,t.indent)+bK(DK(L6e(e,n),a));case KD:return'"'+O6e(e,n)+'"';default:throw new tI("impossible error: invalid scalar style")}}()}function PK(t,e){var r=_K(t)?String(e):"",o=t[t.length-1]===` +`,a=o&&(t[t.length-2]===` +`||t===` +`),n=a?"+":o?"":"-";return r+n+` +`}function bK(t){return t[t.length-1]===` +`?t.slice(0,-1):t}function L6e(t,e){for(var r=/(\n+)([^\n]*)/g,o=function(){var h=t.indexOf(` +`);return h=h!==-1?h:t.length,r.lastIndex=h,xK(t.slice(0,h),e)}(),a=t[0]===` +`||t[0]===" ",n,u;u=r.exec(t);){var A=u[1],p=u[2];n=p[0]===" ",o+=A+(!a&&!n&&p!==""?` +`:"")+xK(p,e),a=n}return o}function xK(t,e){if(t===""||t[0]===" ")return t;for(var r=/ [^ ]/g,o,a=0,n,u=0,A=0,p="";o=r.exec(t);)A=o.index,A-a>e&&(n=u>a?u:A,p+=` +`+t.slice(a,n),a=n+1),u=A;return p+=` +`,t.length-a>e&&u>a?p+=t.slice(a,u)+` +`+t.slice(u+1):p+=t.slice(a),p.slice(1)}function O6e(t){for(var e="",r,o,a,n=0;n<t.length;n++){if(r=t.charCodeAt(n),r>=55296&&r<=56319&&(o=t.charCodeAt(n+1),o>=56320&&o<=57343)){e+=vK((r-55296)*1024+o-56320+65536),n++;continue}a=vo[r],e+=!a&&my(r)?t[n]:a||vK(r)}return e}function M6e(t,e,r){var o="",a=t.tag,n,u;for(n=0,u=r.length;n<u;n+=1)$g(t,e,r[n],!1,!1)&&(n!==0&&(o+=","+(t.condenseFlow?"":" ")),o+=t.dump);t.tag=a,t.dump="["+o+"]"}function U6e(t,e,r,o){var a="",n=t.tag,u,A;for(u=0,A=r.length;u<A;u+=1)$g(t,e+1,r[u],!0,!0)&&((!o||u!==0)&&(a+=ET(t,e)),t.dump&&$w===t.dump.charCodeAt(0)?a+="-":a+="- ",a+=t.dump);t.tag=n,t.dump=a||"[]"}function _6e(t,e,r){var o="",a=t.tag,n=Object.keys(r),u,A,p,h,E;for(u=0,A=n.length;u<A;u+=1)E="",u!==0&&(E+=", "),t.condenseFlow&&(E+='"'),p=n[u],h=r[p],$g(t,e,p,!1,!1)&&(t.dump.length>1024&&(E+="? "),E+=t.dump+(t.condenseFlow?'"':"")+":"+(t.condenseFlow?"":" "),$g(t,e,h,!1,!1)&&(E+=t.dump,o+=E));t.tag=a,t.dump="{"+o+"}"}function H6e(t,e,r,o){var a="",n=t.tag,u=Object.keys(r),A,p,h,E,I,v;if(t.sortKeys===!0)u.sort();else if(typeof t.sortKeys=="function")u.sort(t.sortKeys);else if(t.sortKeys)throw new tI("sortKeys must be a boolean or a function");for(A=0,p=u.length;A<p;A+=1)v="",(!o||A!==0)&&(v+=ET(t,e)),h=u[A],E=r[h],$g(t,e+1,h,!0,!0,!0)&&(I=t.tag!==null&&t.tag!=="?"||t.dump&&t.dump.length>1024,I&&(t.dump&&$w===t.dump.charCodeAt(0)?v+="?":v+="? "),v+=t.dump,I&&(v+=ET(t,e)),$g(t,e+1,E,!0,I)&&(t.dump&&$w===t.dump.charCodeAt(0)?v+=":":v+=": ",v+=t.dump,a+=v));t.tag=n,t.dump=a||"{}"}function kK(t,e,r){var o,a,n,u,A,p;for(a=r?t.explicitTypes:t.implicitTypes,n=0,u=a.length;n<u;n+=1)if(A=a[n],(A.instanceOf||A.predicate)&&(!A.instanceOf||typeof e=="object"&&e instanceof A.instanceOf)&&(!A.predicate||A.predicate(e))){if(t.tag=r?A.tag:"?",A.represent){if(p=t.styleMap[A.tag]||A.defaultStyle,QK.call(A.represent)==="[object Function]")o=A.represent(e,p);else if(FK.call(A.represent,p))o=A.represent[p](e,p);else throw new tI("!<"+A.tag+'> tag resolver accepts not "'+p+'" style');t.dump=o}return!0}return!1}function $g(t,e,r,o,a,n){t.tag=null,t.dump=r,kK(t,r,!1)||kK(t,r,!0);var u=QK.call(t.dump);o&&(o=t.flowLevel<0||t.flowLevel>e);var A=u==="[object Object]"||u==="[object Array]",p,h;if(A&&(p=t.duplicates.indexOf(r),h=p!==-1),(t.tag!==null&&t.tag!=="?"||h||t.indent!==2&&e>0)&&(a=!1),h&&t.usedDuplicates[p])t.dump="*ref_"+p;else{if(A&&h&&!t.usedDuplicates[p]&&(t.usedDuplicates[p]=!0),u==="[object Object]")o&&Object.keys(t.dump).length!==0?(H6e(t,e,t.dump,a),h&&(t.dump="&ref_"+p+t.dump)):(_6e(t,e,t.dump),h&&(t.dump="&ref_"+p+" "+t.dump));else if(u==="[object Array]"){var E=t.noArrayIndent&&e>0?e-1:e;o&&t.dump.length!==0?(U6e(t,E,t.dump,a),h&&(t.dump="&ref_"+p+t.dump)):(M6e(t,E,t.dump),h&&(t.dump="&ref_"+p+" "+t.dump))}else if(u==="[object String]")t.tag!=="?"&&N6e(t,t.dump,e,n);else{if(t.skipInvalid)return!1;throw new tI("unacceptable kind of an object to dump "+u)}t.tag!==null&&t.tag!=="?"&&(t.dump="!<"+t.tag+"> "+t.dump)}return!0}function j6e(t,e){var r=[],o=[],a,n;for(CT(t,r,o),a=0,n=o.length;a<n;a+=1)e.duplicates.push(r[o[a]]);e.usedDuplicates=new Array(n)}function CT(t,e,r){var o,a,n;if(t!==null&&typeof t=="object")if(a=e.indexOf(t),a!==-1)r.indexOf(a)===-1&&r.push(a);else if(e.push(t),Array.isArray(t))for(a=0,n=t.length;a<n;a+=1)CT(t[a],e,r);else for(o=Object.keys(t),a=0,n=o.length;a<n;a+=1)CT(t[o[a]],e,r)}function YK(t,e){e=e||{};var r=new k6e(e);return r.noRefs||j6e(t,r),$g(r,0,t,!0,!0)?r.dump+` +`:""}function G6e(t,e){return YK(t,eI.extend({schema:A6e},e))}IT.exports.dump=YK;IT.exports.safeDump=G6e});var VK=_((fxt,ki)=>{"use strict";var VD=BK(),KK=WK();function JD(t){return function(){throw new Error("Function "+t+" is deprecated and cannot be used.")}}ki.exports.Type=os();ki.exports.Schema=Jg();ki.exports.FAILSAFE_SCHEMA=HD();ki.exports.JSON_SCHEMA=pT();ki.exports.CORE_SCHEMA=hT();ki.exports.DEFAULT_SAFE_SCHEMA=py();ki.exports.DEFAULT_FULL_SCHEMA=Xw();ki.exports.load=VD.load;ki.exports.loadAll=VD.loadAll;ki.exports.safeLoad=VD.safeLoad;ki.exports.safeLoadAll=VD.safeLoadAll;ki.exports.dump=KK.dump;ki.exports.safeDump=KK.safeDump;ki.exports.YAMLException=Ay();ki.exports.MINIMAL_SCHEMA=HD();ki.exports.SAFE_SCHEMA=py();ki.exports.DEFAULT_SCHEMA=Xw();ki.exports.scan=JD("scan");ki.exports.parse=JD("parse");ki.exports.compose=JD("compose");ki.exports.addConstructor=JD("addConstructor")});var zK=_((pxt,JK)=>{"use strict";var q6e=VK();JK.exports=q6e});var ZK=_((hxt,XK)=>{"use strict";function Y6e(t,e){function r(){this.constructor=t}r.prototype=e.prototype,t.prototype=new r}function ed(t,e,r,o){this.message=t,this.expected=e,this.found=r,this.location=o,this.name="SyntaxError",typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(this,ed)}Y6e(ed,Error);ed.buildMessage=function(t,e){var r={literal:function(h){return'"'+a(h.text)+'"'},class:function(h){var E="",I;for(I=0;I<h.parts.length;I++)E+=h.parts[I]instanceof Array?n(h.parts[I][0])+"-"+n(h.parts[I][1]):n(h.parts[I]);return"["+(h.inverted?"^":"")+E+"]"},any:function(h){return"any character"},end:function(h){return"end of input"},other:function(h){return h.description}};function o(h){return h.charCodeAt(0).toString(16).toUpperCase()}function a(h){return h.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(E){return"\\x0"+o(E)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(E){return"\\x"+o(E)})}function n(h){return h.replace(/\\/g,"\\\\").replace(/\]/g,"\\]").replace(/\^/g,"\\^").replace(/-/g,"\\-").replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(E){return"\\x0"+o(E)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(E){return"\\x"+o(E)})}function u(h){return r[h.type](h)}function A(h){var E=new Array(h.length),I,v;for(I=0;I<h.length;I++)E[I]=u(h[I]);if(E.sort(),E.length>0){for(I=1,v=1;I<E.length;I++)E[I-1]!==E[I]&&(E[v]=E[I],v++);E.length=v}switch(E.length){case 1:return E[0];case 2:return E[0]+" or "+E[1];default:return E.slice(0,-1).join(", ")+", or "+E[E.length-1]}}function p(h){return h?'"'+a(h)+'"':"end of input"}return"Expected "+A(t)+" but "+p(e)+" found."};function W6e(t,e){e=e!==void 0?e:{};var r={},o={Start:pu},a=pu,n=function($){return[].concat(...$)},u="-",A=Qn("-",!1),p=function($){return $},h=function($){return Object.assign({},...$)},E="#",I=Qn("#",!1),v=hc(),x=function(){return{}},C=":",R=Qn(":",!1),L=function($,ye){return{[$]:ye}},U=",",J=Qn(",",!1),te=function($,ye){return ye},ae=function($,ye,Le){return Object.assign({},...[$].concat(ye).map(pt=>({[pt]:Le})))},fe=function($){return $},ce=function($){return $},me=sa("correct indentation"),he=" ",Be=Qn(" ",!1),we=function($){return $.length===nr*It},g=function($){return $.length===(nr+1)*It},Ee=function(){return nr++,!0},Se=function(){return nr--,!0},le=function(){return SA()},ne=sa("pseudostring"),ee=/^[^\r\n\t ?:,\][{}#&*!|>'"%@`\-]/,Ie=hi(["\r",` +`," "," ","?",":",",","]","[","{","}","#","&","*","!","|",">","'",'"',"%","@","`","-"],!0,!1),Fe=/^[^\r\n\t ,\][{}:#"']/,At=hi(["\r",` +`," "," ",",","]","[","{","}",":","#",'"',"'"],!0,!1),H=function(){return SA().replace(/^ *| *$/g,"")},at="--",Re=Qn("--",!1),ke=/^[a-zA-Z\/0-9]/,xe=hi([["a","z"],["A","Z"],"/",["0","9"]],!1,!1),He=/^[^\r\n\t :,]/,Te=hi(["\r",` +`," "," ",":",","],!0,!1),Je="null",je=Qn("null",!1),b=function(){return null},w="true",P=Qn("true",!1),y=function(){return!0},F="false",z=Qn("false",!1),X=function(){return!1},Z=sa("string"),ie='"',Pe=Qn('"',!1),Ne=function(){return""},ot=function($){return $},dt=function($){return $.join("")},Gt=/^[^"\\\0-\x1F\x7F]/,$t=hi(['"',"\\",["\0",""],"\x7F"],!0,!1),bt='\\"',an=Qn('\\"',!1),Qr=function(){return'"'},mr="\\\\",br=Qn("\\\\",!1),Wr=function(){return"\\"},Kn="\\/",Ns=Qn("\\/",!1),Ti=function(){return"/"},ps="\\b",io=Qn("\\b",!1),Pi=function(){return"\b"},Ls="\\f",so=Qn("\\f",!1),cc=function(){return"\f"},cu="\\n",lp=Qn("\\n",!1),cp=function(){return` +`},Os="\\r",Dn=Qn("\\r",!1),oo=function(){return"\r"},Ms="\\t",ml=Qn("\\t",!1),yl=function(){return" "},ao="\\u",Vn=Qn("\\u",!1),On=function($,ye,Le,pt){return String.fromCharCode(parseInt(`0x${$}${ye}${Le}${pt}`))},Ni=/^[0-9a-fA-F]/,Mn=hi([["0","9"],["a","f"],["A","F"]],!1,!1),_i=sa("blank space"),tr=/^[ \t]/,Oe=hi([" "," "],!1,!1),ii=sa("white space"),Ma=/^[ \t\n\r]/,hr=hi([" "," ",` +`,"\r"],!1,!1),uc=`\r +`,uu=Qn(`\r +`,!1),Ac=` +`,El=Qn(` +`,!1),DA="\r",Au=Qn("\r",!1),Ce=0,Rt=0,fc=[{line:1,column:1}],Hi=0,fu=[],Yt=0,Cl;if("startRule"in e){if(!(e.startRule in o))throw new Error(`Can't start parsing from rule "`+e.startRule+'".');a=o[e.startRule]}function SA(){return t.substring(Rt,Ce)}function up(){return _o(Rt,Ce)}function pc($,ye){throw ye=ye!==void 0?ye:_o(Rt,Ce),gc([sa($)],t.substring(Rt,Ce),ye)}function PA($,ye){throw ye=ye!==void 0?ye:_o(Rt,Ce),lo($,ye)}function Qn($,ye){return{type:"literal",text:$,ignoreCase:ye}}function hi($,ye,Le){return{type:"class",parts:$,inverted:ye,ignoreCase:Le}}function hc(){return{type:"any"}}function bA(){return{type:"end"}}function sa($){return{type:"other",description:$}}function Li($){var ye=fc[$],Le;if(ye)return ye;for(Le=$-1;!fc[Le];)Le--;for(ye=fc[Le],ye={line:ye.line,column:ye.column};Le<$;)t.charCodeAt(Le)===10?(ye.line++,ye.column=1):ye.column++,Le++;return fc[$]=ye,ye}function _o($,ye){var Le=Li($),pt=Li(ye);return{start:{offset:$,line:Le.line,column:Le.column},end:{offset:ye,line:pt.line,column:pt.column}}}function Ze($){Ce<Hi||(Ce>Hi&&(Hi=Ce,fu=[]),fu.push($))}function lo($,ye){return new ed($,null,null,ye)}function gc($,ye,Le){return new ed(ed.buildMessage($,ye),$,ye,Le)}function pu(){var $;return $=xA(),$}function ji(){var $,ye,Le;for($=Ce,ye=[],Le=hu();Le!==r;)ye.push(Le),Le=hu();return ye!==r&&(Rt=$,ye=n(ye)),$=ye,$}function hu(){var $,ye,Le,pt,ht;return $=Ce,ye=hs(),ye!==r?(t.charCodeAt(Ce)===45?(Le=u,Ce++):(Le=r,Yt===0&&Ze(A)),Le!==r?(pt=Sn(),pt!==r?(ht=dc(),ht!==r?(Rt=$,ye=p(ht),$=ye):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r),$}function xA(){var $,ye,Le;for($=Ce,ye=[],Le=Ua();Le!==r;)ye.push(Le),Le=Ua();return ye!==r&&(Rt=$,ye=h(ye)),$=ye,$}function Ua(){var $,ye,Le,pt,ht,Tt,er,$r,Gi;if($=Ce,ye=Sn(),ye===r&&(ye=null),ye!==r){if(Le=Ce,t.charCodeAt(Ce)===35?(pt=E,Ce++):(pt=r,Yt===0&&Ze(I)),pt!==r){if(ht=[],Tt=Ce,er=Ce,Yt++,$r=tt(),Yt--,$r===r?er=void 0:(Ce=er,er=r),er!==r?(t.length>Ce?($r=t.charAt(Ce),Ce++):($r=r,Yt===0&&Ze(v)),$r!==r?(er=[er,$r],Tt=er):(Ce=Tt,Tt=r)):(Ce=Tt,Tt=r),Tt!==r)for(;Tt!==r;)ht.push(Tt),Tt=Ce,er=Ce,Yt++,$r=tt(),Yt--,$r===r?er=void 0:(Ce=er,er=r),er!==r?(t.length>Ce?($r=t.charAt(Ce),Ce++):($r=r,Yt===0&&Ze(v)),$r!==r?(er=[er,$r],Tt=er):(Ce=Tt,Tt=r)):(Ce=Tt,Tt=r);else ht=r;ht!==r?(pt=[pt,ht],Le=pt):(Ce=Le,Le=r)}else Ce=Le,Le=r;if(Le===r&&(Le=null),Le!==r){if(pt=[],ht=We(),ht!==r)for(;ht!==r;)pt.push(ht),ht=We();else pt=r;pt!==r?(Rt=$,ye=x(),$=ye):(Ce=$,$=r)}else Ce=$,$=r}else Ce=$,$=r;if($===r&&($=Ce,ye=hs(),ye!==r?(Le=oa(),Le!==r?(pt=Sn(),pt===r&&(pt=null),pt!==r?(t.charCodeAt(Ce)===58?(ht=C,Ce++):(ht=r,Yt===0&&Ze(R)),ht!==r?(Tt=Sn(),Tt===r&&(Tt=null),Tt!==r?(er=dc(),er!==r?(Rt=$,ye=L(Le,er),$=ye):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r),$===r&&($=Ce,ye=hs(),ye!==r?(Le=co(),Le!==r?(pt=Sn(),pt===r&&(pt=null),pt!==r?(t.charCodeAt(Ce)===58?(ht=C,Ce++):(ht=r,Yt===0&&Ze(R)),ht!==r?(Tt=Sn(),Tt===r&&(Tt=null),Tt!==r?(er=dc(),er!==r?(Rt=$,ye=L(Le,er),$=ye):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r),$===r))){if($=Ce,ye=hs(),ye!==r)if(Le=co(),Le!==r)if(pt=Sn(),pt!==r)if(ht=aa(),ht!==r){if(Tt=[],er=We(),er!==r)for(;er!==r;)Tt.push(er),er=We();else Tt=r;Tt!==r?(Rt=$,ye=L(Le,ht),$=ye):(Ce=$,$=r)}else Ce=$,$=r;else Ce=$,$=r;else Ce=$,$=r;else Ce=$,$=r;if($===r)if($=Ce,ye=hs(),ye!==r)if(Le=co(),Le!==r){if(pt=[],ht=Ce,Tt=Sn(),Tt===r&&(Tt=null),Tt!==r?(t.charCodeAt(Ce)===44?(er=U,Ce++):(er=r,Yt===0&&Ze(J)),er!==r?($r=Sn(),$r===r&&($r=null),$r!==r?(Gi=co(),Gi!==r?(Rt=ht,Tt=te(Le,Gi),ht=Tt):(Ce=ht,ht=r)):(Ce=ht,ht=r)):(Ce=ht,ht=r)):(Ce=ht,ht=r),ht!==r)for(;ht!==r;)pt.push(ht),ht=Ce,Tt=Sn(),Tt===r&&(Tt=null),Tt!==r?(t.charCodeAt(Ce)===44?(er=U,Ce++):(er=r,Yt===0&&Ze(J)),er!==r?($r=Sn(),$r===r&&($r=null),$r!==r?(Gi=co(),Gi!==r?(Rt=ht,Tt=te(Le,Gi),ht=Tt):(Ce=ht,ht=r)):(Ce=ht,ht=r)):(Ce=ht,ht=r)):(Ce=ht,ht=r);else pt=r;pt!==r?(ht=Sn(),ht===r&&(ht=null),ht!==r?(t.charCodeAt(Ce)===58?(Tt=C,Ce++):(Tt=r,Yt===0&&Ze(R)),Tt!==r?(er=Sn(),er===r&&(er=null),er!==r?($r=dc(),$r!==r?(Rt=$,ye=ae(Le,pt,$r),$=ye):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)}else Ce=$,$=r;else Ce=$,$=r}return $}function dc(){var $,ye,Le,pt,ht,Tt,er;if($=Ce,ye=Ce,Yt++,Le=Ce,pt=tt(),pt!==r?(ht=_t(),ht!==r?(t.charCodeAt(Ce)===45?(Tt=u,Ce++):(Tt=r,Yt===0&&Ze(A)),Tt!==r?(er=Sn(),er!==r?(pt=[pt,ht,Tt,er],Le=pt):(Ce=Le,Le=r)):(Ce=Le,Le=r)):(Ce=Le,Le=r)):(Ce=Le,Le=r),Yt--,Le!==r?(Ce=ye,ye=void 0):ye=r,ye!==r?(Le=We(),Le!==r?(pt=Fn(),pt!==r?(ht=ji(),ht!==r?(Tt=Ci(),Tt!==r?(Rt=$,ye=fe(ht),$=ye):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r),$===r&&($=Ce,ye=tt(),ye!==r?(Le=Fn(),Le!==r?(pt=xA(),pt!==r?(ht=Ci(),ht!==r?(Rt=$,ye=fe(pt),$=ye):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r),$===r))if($=Ce,ye=Us(),ye!==r){if(Le=[],pt=We(),pt!==r)for(;pt!==r;)Le.push(pt),pt=We();else Le=r;Le!==r?(Rt=$,ye=ce(ye),$=ye):(Ce=$,$=r)}else Ce=$,$=r;return $}function hs(){var $,ye,Le;for(Yt++,$=Ce,ye=[],t.charCodeAt(Ce)===32?(Le=he,Ce++):(Le=r,Yt===0&&Ze(Be));Le!==r;)ye.push(Le),t.charCodeAt(Ce)===32?(Le=he,Ce++):(Le=r,Yt===0&&Ze(Be));return ye!==r?(Rt=Ce,Le=we(ye),Le?Le=void 0:Le=r,Le!==r?(ye=[ye,Le],$=ye):(Ce=$,$=r)):(Ce=$,$=r),Yt--,$===r&&(ye=r,Yt===0&&Ze(me)),$}function _t(){var $,ye,Le;for($=Ce,ye=[],t.charCodeAt(Ce)===32?(Le=he,Ce++):(Le=r,Yt===0&&Ze(Be));Le!==r;)ye.push(Le),t.charCodeAt(Ce)===32?(Le=he,Ce++):(Le=r,Yt===0&&Ze(Be));return ye!==r?(Rt=Ce,Le=g(ye),Le?Le=void 0:Le=r,Le!==r?(ye=[ye,Le],$=ye):(Ce=$,$=r)):(Ce=$,$=r),$}function Fn(){var $;return Rt=Ce,$=Ee(),$?$=void 0:$=r,$}function Ci(){var $;return Rt=Ce,$=Se(),$?$=void 0:$=r,$}function oa(){var $;return $=ds(),$===r&&($=la()),$}function co(){var $,ye,Le;if($=ds(),$===r){if($=Ce,ye=[],Le=Ho(),Le!==r)for(;Le!==r;)ye.push(Le),Le=Ho();else ye=r;ye!==r&&(Rt=$,ye=le()),$=ye}return $}function Us(){var $;return $=wi(),$===r&&($=gs(),$===r&&($=ds(),$===r&&($=la()))),$}function aa(){var $;return $=wi(),$===r&&($=ds(),$===r&&($=Ho())),$}function la(){var $,ye,Le,pt,ht,Tt;if(Yt++,$=Ce,ee.test(t.charAt(Ce))?(ye=t.charAt(Ce),Ce++):(ye=r,Yt===0&&Ze(Ie)),ye!==r){for(Le=[],pt=Ce,ht=Sn(),ht===r&&(ht=null),ht!==r?(Fe.test(t.charAt(Ce))?(Tt=t.charAt(Ce),Ce++):(Tt=r,Yt===0&&Ze(At)),Tt!==r?(ht=[ht,Tt],pt=ht):(Ce=pt,pt=r)):(Ce=pt,pt=r);pt!==r;)Le.push(pt),pt=Ce,ht=Sn(),ht===r&&(ht=null),ht!==r?(Fe.test(t.charAt(Ce))?(Tt=t.charAt(Ce),Ce++):(Tt=r,Yt===0&&Ze(At)),Tt!==r?(ht=[ht,Tt],pt=ht):(Ce=pt,pt=r)):(Ce=pt,pt=r);Le!==r?(Rt=$,ye=H(),$=ye):(Ce=$,$=r)}else Ce=$,$=r;return Yt--,$===r&&(ye=r,Yt===0&&Ze(ne)),$}function Ho(){var $,ye,Le,pt,ht;if($=Ce,t.substr(Ce,2)===at?(ye=at,Ce+=2):(ye=r,Yt===0&&Ze(Re)),ye===r&&(ye=null),ye!==r)if(ke.test(t.charAt(Ce))?(Le=t.charAt(Ce),Ce++):(Le=r,Yt===0&&Ze(xe)),Le!==r){for(pt=[],He.test(t.charAt(Ce))?(ht=t.charAt(Ce),Ce++):(ht=r,Yt===0&&Ze(Te));ht!==r;)pt.push(ht),He.test(t.charAt(Ce))?(ht=t.charAt(Ce),Ce++):(ht=r,Yt===0&&Ze(Te));pt!==r?(Rt=$,ye=H(),$=ye):(Ce=$,$=r)}else Ce=$,$=r;else Ce=$,$=r;return $}function wi(){var $,ye;return $=Ce,t.substr(Ce,4)===Je?(ye=Je,Ce+=4):(ye=r,Yt===0&&Ze(je)),ye!==r&&(Rt=$,ye=b()),$=ye,$}function gs(){var $,ye;return $=Ce,t.substr(Ce,4)===w?(ye=w,Ce+=4):(ye=r,Yt===0&&Ze(P)),ye!==r&&(Rt=$,ye=y()),$=ye,$===r&&($=Ce,t.substr(Ce,5)===F?(ye=F,Ce+=5):(ye=r,Yt===0&&Ze(z)),ye!==r&&(Rt=$,ye=X()),$=ye),$}function ds(){var $,ye,Le,pt;return Yt++,$=Ce,t.charCodeAt(Ce)===34?(ye=ie,Ce++):(ye=r,Yt===0&&Ze(Pe)),ye!==r?(t.charCodeAt(Ce)===34?(Le=ie,Ce++):(Le=r,Yt===0&&Ze(Pe)),Le!==r?(Rt=$,ye=Ne(),$=ye):(Ce=$,$=r)):(Ce=$,$=r),$===r&&($=Ce,t.charCodeAt(Ce)===34?(ye=ie,Ce++):(ye=r,Yt===0&&Ze(Pe)),ye!==r?(Le=ms(),Le!==r?(t.charCodeAt(Ce)===34?(pt=ie,Ce++):(pt=r,Yt===0&&Ze(Pe)),pt!==r?(Rt=$,ye=ot(Le),$=ye):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)),Yt--,$===r&&(ye=r,Yt===0&&Ze(Z)),$}function ms(){var $,ye,Le;if($=Ce,ye=[],Le=_s(),Le!==r)for(;Le!==r;)ye.push(Le),Le=_s();else ye=r;return ye!==r&&(Rt=$,ye=dt(ye)),$=ye,$}function _s(){var $,ye,Le,pt,ht,Tt;return Gt.test(t.charAt(Ce))?($=t.charAt(Ce),Ce++):($=r,Yt===0&&Ze($t)),$===r&&($=Ce,t.substr(Ce,2)===bt?(ye=bt,Ce+=2):(ye=r,Yt===0&&Ze(an)),ye!==r&&(Rt=$,ye=Qr()),$=ye,$===r&&($=Ce,t.substr(Ce,2)===mr?(ye=mr,Ce+=2):(ye=r,Yt===0&&Ze(br)),ye!==r&&(Rt=$,ye=Wr()),$=ye,$===r&&($=Ce,t.substr(Ce,2)===Kn?(ye=Kn,Ce+=2):(ye=r,Yt===0&&Ze(Ns)),ye!==r&&(Rt=$,ye=Ti()),$=ye,$===r&&($=Ce,t.substr(Ce,2)===ps?(ye=ps,Ce+=2):(ye=r,Yt===0&&Ze(io)),ye!==r&&(Rt=$,ye=Pi()),$=ye,$===r&&($=Ce,t.substr(Ce,2)===Ls?(ye=Ls,Ce+=2):(ye=r,Yt===0&&Ze(so)),ye!==r&&(Rt=$,ye=cc()),$=ye,$===r&&($=Ce,t.substr(Ce,2)===cu?(ye=cu,Ce+=2):(ye=r,Yt===0&&Ze(lp)),ye!==r&&(Rt=$,ye=cp()),$=ye,$===r&&($=Ce,t.substr(Ce,2)===Os?(ye=Os,Ce+=2):(ye=r,Yt===0&&Ze(Dn)),ye!==r&&(Rt=$,ye=oo()),$=ye,$===r&&($=Ce,t.substr(Ce,2)===Ms?(ye=Ms,Ce+=2):(ye=r,Yt===0&&Ze(ml)),ye!==r&&(Rt=$,ye=yl()),$=ye,$===r&&($=Ce,t.substr(Ce,2)===ao?(ye=ao,Ce+=2):(ye=r,Yt===0&&Ze(Vn)),ye!==r?(Le=Un(),Le!==r?(pt=Un(),pt!==r?(ht=Un(),ht!==r?(Tt=Un(),Tt!==r?(Rt=$,ye=On(Le,pt,ht,Tt),$=ye):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)):(Ce=$,$=r)))))))))),$}function Un(){var $;return Ni.test(t.charAt(Ce))?($=t.charAt(Ce),Ce++):($=r,Yt===0&&Ze(Mn)),$}function Sn(){var $,ye;if(Yt++,$=[],tr.test(t.charAt(Ce))?(ye=t.charAt(Ce),Ce++):(ye=r,Yt===0&&Ze(Oe)),ye!==r)for(;ye!==r;)$.push(ye),tr.test(t.charAt(Ce))?(ye=t.charAt(Ce),Ce++):(ye=r,Yt===0&&Ze(Oe));else $=r;return Yt--,$===r&&(ye=r,Yt===0&&Ze(_i)),$}function ys(){var $,ye;if(Yt++,$=[],Ma.test(t.charAt(Ce))?(ye=t.charAt(Ce),Ce++):(ye=r,Yt===0&&Ze(hr)),ye!==r)for(;ye!==r;)$.push(ye),Ma.test(t.charAt(Ce))?(ye=t.charAt(Ce),Ce++):(ye=r,Yt===0&&Ze(hr));else $=r;return Yt--,$===r&&(ye=r,Yt===0&&Ze(ii)),$}function We(){var $,ye,Le,pt,ht,Tt;if($=Ce,ye=tt(),ye!==r){for(Le=[],pt=Ce,ht=Sn(),ht===r&&(ht=null),ht!==r?(Tt=tt(),Tt!==r?(ht=[ht,Tt],pt=ht):(Ce=pt,pt=r)):(Ce=pt,pt=r);pt!==r;)Le.push(pt),pt=Ce,ht=Sn(),ht===r&&(ht=null),ht!==r?(Tt=tt(),Tt!==r?(ht=[ht,Tt],pt=ht):(Ce=pt,pt=r)):(Ce=pt,pt=r);Le!==r?(ye=[ye,Le],$=ye):(Ce=$,$=r)}else Ce=$,$=r;return $}function tt(){var $;return t.substr(Ce,2)===uc?($=uc,Ce+=2):($=r,Yt===0&&Ze(uu)),$===r&&(t.charCodeAt(Ce)===10?($=Ac,Ce++):($=r,Yt===0&&Ze(El)),$===r&&(t.charCodeAt(Ce)===13?($=DA,Ce++):($=r,Yt===0&&Ze(Au)))),$}let It=2,nr=0;if(Cl=a(),Cl!==r&&Ce===t.length)return Cl;throw Cl!==r&&Ce<t.length&&Ze(bA()),gc(fu,Hi<t.length?t.charAt(Hi):null,Hi<t.length?_o(Hi,Hi+1):_o(Hi,Hi))}XK.exports={SyntaxError:ed,parse:W6e}});function eV(t){return t.match(K6e)?t:JSON.stringify(t)}function rV(t){return typeof t>"u"?!0:typeof t=="object"&&t!==null&&!Array.isArray(t)?Object.keys(t).every(e=>rV(t[e])):!1}function BT(t,e,r){if(t===null)return`null +`;if(typeof t=="number"||typeof t=="boolean")return`${t.toString()} +`;if(typeof t=="string")return`${eV(t)} +`;if(Array.isArray(t)){if(t.length===0)return`[] +`;let o=" ".repeat(e);return` +${t.map(n=>`${o}- ${BT(n,e+1,!1)}`).join("")}`}if(typeof t=="object"&&t){let[o,a]=t instanceof zD?[t.data,!1]:[t,!0],n=" ".repeat(e),u=Object.keys(o);a&&u.sort((p,h)=>{let E=$K.indexOf(p),I=$K.indexOf(h);return E===-1&&I===-1?p<h?-1:p>h?1:0:E!==-1&&I===-1?-1:E===-1&&I!==-1?1:E-I});let A=u.filter(p=>!rV(o[p])).map((p,h)=>{let E=o[p],I=eV(p),v=BT(E,e+1,!0),x=h>0||r?n:"",C=I.length>1024?`? ${I} +${x}:`:`${I}:`,R=v.startsWith(` +`)?v:` ${v}`;return`${x}${C}${R}`}).join(e===0?` +`:"")||` +`;return r?` +${A}`:`${A}`}throw new Error(`Unsupported value type (${t})`)}function Ba(t){try{let e=BT(t,0,!1);return e!==` +`?e:""}catch(e){throw e.location&&(e.message=e.message.replace(/(\.)?$/,` (line ${e.location.start.line}, column ${e.location.start.column})$1`)),e}}function V6e(t){return t.endsWith(` +`)||(t+=` +`),(0,tV.parse)(t)}function z6e(t){if(J6e.test(t))return V6e(t);let e=(0,XD.safeLoad)(t,{schema:XD.FAILSAFE_SCHEMA,json:!0});if(e==null)return{};if(typeof e!="object")throw new Error(`Expected an indexed object, got a ${typeof e} instead. Does your file follow Yaml's rules?`);if(Array.isArray(e))throw new Error("Expected an indexed object, got an array instead. Does your file follow Yaml's rules?");return e}function Ki(t){return z6e(t)}var XD,tV,K6e,$K,zD,J6e,nV=Et(()=>{XD=$e(zK()),tV=$e(ZK()),K6e=/^(?![-?:,\][{}#&*!|>'"%@` \t\r\n]).([ \t]*(?![,\][{}:# \t\r\n]).)*$/,$K=["__metadata","version","resolution","dependencies","peerDependencies","dependenciesMeta","peerDependenciesMeta","binaries"],zD=class{constructor(e){this.data=e}};Ba.PreserveOrdering=zD;J6e=/^(#.*(\r?\n))*?#\s+yarn\s+lockfile\s+v1\r?\n/i});var rI={};Vt(rI,{parseResolution:()=>MD,parseShell:()=>ND,parseSyml:()=>Ki,stringifyArgument:()=>cT,stringifyArgumentSegment:()=>uT,stringifyArithmeticExpression:()=>OD,stringifyCommand:()=>lT,stringifyCommandChain:()=>uy,stringifyCommandChainThen:()=>aT,stringifyCommandLine:()=>LD,stringifyCommandLineThen:()=>oT,stringifyEnvSegment:()=>TD,stringifyRedirectArgument:()=>Jw,stringifyResolution:()=>UD,stringifyShell:()=>cy,stringifyShellLine:()=>cy,stringifySyml:()=>Ba,stringifyValueArgument:()=>Yg});var Nl=Et(()=>{rW();oW();nV()});var sV=_((Ext,vT)=>{"use strict";var X6e=t=>{let e=!1,r=!1,o=!1;for(let a=0;a<t.length;a++){let n=t[a];e&&/[a-zA-Z]/.test(n)&&n.toUpperCase()===n?(t=t.slice(0,a)+"-"+t.slice(a),e=!1,o=r,r=!0,a++):r&&o&&/[a-zA-Z]/.test(n)&&n.toLowerCase()===n?(t=t.slice(0,a-1)+"-"+t.slice(a-1),o=r,r=!1,e=!0):(e=n.toLowerCase()===n&&n.toUpperCase()!==n,o=r,r=n.toUpperCase()===n&&n.toLowerCase()!==n)}return t},iV=(t,e)=>{if(!(typeof t=="string"||Array.isArray(t)))throw new TypeError("Expected the input to be `string | string[]`");e=Object.assign({pascalCase:!1},e);let r=a=>e.pascalCase?a.charAt(0).toUpperCase()+a.slice(1):a;return Array.isArray(t)?t=t.map(a=>a.trim()).filter(a=>a.length).join("-"):t=t.trim(),t.length===0?"":t.length===1?e.pascalCase?t.toUpperCase():t.toLowerCase():(t!==t.toLowerCase()&&(t=X6e(t)),t=t.replace(/^[_.\- ]+/,"").toLowerCase().replace(/[_.\- ]+(\w|$)/g,(a,n)=>n.toUpperCase()).replace(/\d+(\w|$)/g,a=>a.toUpperCase()),r(t))};vT.exports=iV;vT.exports.default=iV});var oV=_((Cxt,Z6e)=>{Z6e.exports=[{name:"AppVeyor",constant:"APPVEYOR",env:"APPVEYOR",pr:"APPVEYOR_PULL_REQUEST_NUMBER"},{name:"Azure Pipelines",constant:"AZURE_PIPELINES",env:"SYSTEM_TEAMFOUNDATIONCOLLECTIONURI",pr:"SYSTEM_PULLREQUEST_PULLREQUESTID"},{name:"Appcircle",constant:"APPCIRCLE",env:"AC_APPCIRCLE"},{name:"Bamboo",constant:"BAMBOO",env:"bamboo_planKey"},{name:"Bitbucket Pipelines",constant:"BITBUCKET",env:"BITBUCKET_COMMIT",pr:"BITBUCKET_PR_ID"},{name:"Bitrise",constant:"BITRISE",env:"BITRISE_IO",pr:"BITRISE_PULL_REQUEST"},{name:"Buddy",constant:"BUDDY",env:"BUDDY_WORKSPACE_ID",pr:"BUDDY_EXECUTION_PULL_REQUEST_ID"},{name:"Buildkite",constant:"BUILDKITE",env:"BUILDKITE",pr:{env:"BUILDKITE_PULL_REQUEST",ne:"false"}},{name:"CircleCI",constant:"CIRCLE",env:"CIRCLECI",pr:"CIRCLE_PULL_REQUEST"},{name:"Cirrus CI",constant:"CIRRUS",env:"CIRRUS_CI",pr:"CIRRUS_PR"},{name:"AWS CodeBuild",constant:"CODEBUILD",env:"CODEBUILD_BUILD_ARN"},{name:"Codefresh",constant:"CODEFRESH",env:"CF_BUILD_ID",pr:{any:["CF_PULL_REQUEST_NUMBER","CF_PULL_REQUEST_ID"]}},{name:"Codeship",constant:"CODESHIP",env:{CI_NAME:"codeship"}},{name:"Drone",constant:"DRONE",env:"DRONE",pr:{DRONE_BUILD_EVENT:"pull_request"}},{name:"dsari",constant:"DSARI",env:"DSARI"},{name:"GitHub Actions",constant:"GITHUB_ACTIONS",env:"GITHUB_ACTIONS",pr:{GITHUB_EVENT_NAME:"pull_request"}},{name:"GitLab CI",constant:"GITLAB",env:"GITLAB_CI",pr:"CI_MERGE_REQUEST_ID"},{name:"GoCD",constant:"GOCD",env:"GO_PIPELINE_LABEL"},{name:"LayerCI",constant:"LAYERCI",env:"LAYERCI",pr:"LAYERCI_PULL_REQUEST"},{name:"Hudson",constant:"HUDSON",env:"HUDSON_URL"},{name:"Jenkins",constant:"JENKINS",env:["JENKINS_URL","BUILD_ID"],pr:{any:["ghprbPullId","CHANGE_ID"]}},{name:"Magnum CI",constant:"MAGNUM",env:"MAGNUM"},{name:"Netlify CI",constant:"NETLIFY",env:"NETLIFY",pr:{env:"PULL_REQUEST",ne:"false"}},{name:"Nevercode",constant:"NEVERCODE",env:"NEVERCODE",pr:{env:"NEVERCODE_PULL_REQUEST",ne:"false"}},{name:"Render",constant:"RENDER",env:"RENDER",pr:{IS_PULL_REQUEST:"true"}},{name:"Sail CI",constant:"SAIL",env:"SAILCI",pr:"SAIL_PULL_REQUEST_NUMBER"},{name:"Semaphore",constant:"SEMAPHORE",env:"SEMAPHORE",pr:"PULL_REQUEST_NUMBER"},{name:"Screwdriver",constant:"SCREWDRIVER",env:"SCREWDRIVER",pr:{env:"SD_PULL_REQUEST",ne:"false"}},{name:"Shippable",constant:"SHIPPABLE",env:"SHIPPABLE",pr:{IS_PULL_REQUEST:"true"}},{name:"Solano CI",constant:"SOLANO",env:"TDDIUM",pr:"TDDIUM_PR_ID"},{name:"Strider CD",constant:"STRIDER",env:"STRIDER"},{name:"TaskCluster",constant:"TASKCLUSTER",env:["TASK_ID","RUN_ID"]},{name:"TeamCity",constant:"TEAMCITY",env:"TEAMCITY_VERSION"},{name:"Travis CI",constant:"TRAVIS",env:"TRAVIS",pr:{env:"TRAVIS_PULL_REQUEST",ne:"false"}},{name:"Vercel",constant:"VERCEL",env:"NOW_BUILDER"},{name:"Visual Studio App Center",constant:"APPCENTER",env:"APPCENTER_BUILD_ID"}]});var td=_(Xa=>{"use strict";var lV=oV(),ju=process.env;Object.defineProperty(Xa,"_vendors",{value:lV.map(function(t){return t.constant})});Xa.name=null;Xa.isPR=null;lV.forEach(function(t){let r=(Array.isArray(t.env)?t.env:[t.env]).every(function(o){return aV(o)});if(Xa[t.constant]=r,r)switch(Xa.name=t.name,typeof t.pr){case"string":Xa.isPR=!!ju[t.pr];break;case"object":"env"in t.pr?Xa.isPR=t.pr.env in ju&&ju[t.pr.env]!==t.pr.ne:"any"in t.pr?Xa.isPR=t.pr.any.some(function(o){return!!ju[o]}):Xa.isPR=aV(t.pr);break;default:Xa.isPR=null}});Xa.isCI=!!(ju.CI||ju.CONTINUOUS_INTEGRATION||ju.BUILD_NUMBER||ju.RUN_ID||Xa.name);function aV(t){return typeof t=="string"?!!ju[t]:Object.keys(t).every(function(e){return ju[e]===t[e]})}});var Hn,cn,rd,DT,ZD,cV,ST,PT,$D=Et(()=>{(function(t){t.StartOfInput="\0",t.EndOfInput="",t.EndOfPartialInput=""})(Hn||(Hn={}));(function(t){t[t.InitialNode=0]="InitialNode",t[t.SuccessNode=1]="SuccessNode",t[t.ErrorNode=2]="ErrorNode",t[t.CustomNode=3]="CustomNode"})(cn||(cn={}));rd=-1,DT=/^(-h|--help)(?:=([0-9]+))?$/,ZD=/^(--[a-z]+(?:-[a-z]+)*|-[a-zA-Z]+)$/,cV=/^-[a-zA-Z]{2,}$/,ST=/^([^=]+)=([\s\S]*)$/,PT=process.env.DEBUG_CLI==="1"});var it,yy,eS,bT,tS=Et(()=>{$D();it=class extends Error{constructor(e){super(e),this.clipanion={type:"usage"},this.name="UsageError"}},yy=class extends Error{constructor(e,r){if(super(),this.input=e,this.candidates=r,this.clipanion={type:"none"},this.name="UnknownSyntaxError",this.candidates.length===0)this.message="Command not found, but we're not sure what's the alternative.";else if(this.candidates.every(o=>o.reason!==null&&o.reason===r[0].reason)){let[{reason:o}]=this.candidates;this.message=`${o} + +${this.candidates.map(({usage:a})=>`$ ${a}`).join(` +`)}`}else if(this.candidates.length===1){let[{usage:o}]=this.candidates;this.message=`Command not found; did you mean: + +$ ${o} +${bT(e)}`}else this.message=`Command not found; did you mean one of: + +${this.candidates.map(({usage:o},a)=>`${`${a}.`.padStart(4)} ${o}`).join(` +`)} + +${bT(e)}`}},eS=class extends Error{constructor(e,r){super(),this.input=e,this.usages=r,this.clipanion={type:"none"},this.name="AmbiguousSyntaxError",this.message=`Cannot find which to pick amongst the following alternatives: + +${this.usages.map((o,a)=>`${`${a}.`.padStart(4)} ${o}`).join(` +`)} + +${bT(e)}`}},bT=t=>`While running ${t.filter(e=>e!==Hn.EndOfInput&&e!==Hn.EndOfPartialInput).map(e=>{let r=JSON.stringify(e);return e.match(/\s/)||e.length===0||r!==`"${e}"`?r:e}).join(" ")}`});function $6e(t){let e=t.split(` +`),r=e.filter(a=>a.match(/\S/)),o=r.length>0?r.reduce((a,n)=>Math.min(a,n.length-n.trimStart().length),Number.MAX_VALUE):0;return e.map(a=>a.slice(o).trimRight()).join(` +`)}function Do(t,{format:e,paragraphs:r}){return t=t.replace(/\r\n?/g,` +`),t=$6e(t),t=t.replace(/^\n+|\n+$/g,""),t=t.replace(/^(\s*)-([^\n]*?)\n+/gm,`$1-$2 + +`),t=t.replace(/\n(\n)?\n*/g,(o,a)=>a||" "),r&&(t=t.split(/\n/).map(o=>{let a=o.match(/^\s*[*-][\t ]+(.*)/);if(!a)return o.match(/(.{1,80})(?: |$)/g).join(` +`);let n=o.length-o.trimStart().length;return a[1].match(new RegExp(`(.{1,${78-n}})(?: |$)`,"g")).map((u,A)=>" ".repeat(n)+(A===0?"- ":" ")+u).join(` +`)}).join(` + +`)),t=t.replace(/(`+)((?:.|[\n])*?)\1/g,(o,a,n)=>e.code(a+n+a)),t=t.replace(/(\*\*)((?:.|[\n])*?)\1/g,(o,a,n)=>e.bold(a+n+a)),t?`${t} +`:""}var xT,uV,AV,kT=Et(()=>{xT=Array(80).fill("\u2501");for(let t=0;t<=24;++t)xT[xT.length-t]=`\x1B[38;5;${232+t}m\u2501`;uV={header:t=>`\x1B[1m\u2501\u2501\u2501 ${t}${t.length<80-5?` ${xT.slice(t.length+5).join("")}`:":"}\x1B[0m`,bold:t=>`\x1B[1m${t}\x1B[22m`,error:t=>`\x1B[31m\x1B[1m${t}\x1B[22m\x1B[39m`,code:t=>`\x1B[36m${t}\x1B[39m`},AV={header:t=>t,bold:t=>t,error:t=>t,code:t=>t}});function Ko(t){return{...t,[nI]:!0}}function Gu(t,e){return typeof t>"u"?[t,e]:typeof t=="object"&&t!==null&&!Array.isArray(t)?[void 0,t]:[t,e]}function rS(t,{mergeName:e=!1}={}){let r=t.match(/^([^:]+): (.*)$/m);if(!r)return"validation failed";let[,o,a]=r;return e&&(a=a[0].toLowerCase()+a.slice(1)),a=o!=="."||!e?`${o.replace(/^\.(\[|$)/,"$1")}: ${a}`:`: ${a}`,a}function iI(t,e){return e.length===1?new it(`${t}${rS(e[0],{mergeName:!0})}`):new it(`${t}: +${e.map(r=>` +- ${rS(r)}`).join("")}`)}function nd(t,e,r){if(typeof r>"u")return e;let o=[],a=[],n=A=>{let p=e;return e=A,n.bind(null,p)};if(!r(e,{errors:o,coercions:a,coercion:n}))throw iI(`Invalid value for ${t}`,o);for(let[,A]of a)A();return e}var nI,Ef=Et(()=>{tS();nI=Symbol("clipanion/isOption")});var Vo={};Vt(Vo,{KeyRelationship:()=>qu,TypeAssertionError:()=>Kp,applyCascade:()=>aI,as:()=>yje,assert:()=>gje,assertWithErrors:()=>dje,cascade:()=>oS,fn:()=>Eje,hasAtLeastOneKey:()=>OT,hasExactLength:()=>dV,hasForbiddenKeys:()=>Mje,hasKeyRelationship:()=>cI,hasMaxLength:()=>wje,hasMinLength:()=>Cje,hasMutuallyExclusiveKeys:()=>Uje,hasRequiredKeys:()=>Oje,hasUniqueItems:()=>Ije,isArray:()=>nS,isAtLeast:()=>NT,isAtMost:()=>Dje,isBase64:()=>Rje,isBoolean:()=>aje,isDate:()=>cje,isDict:()=>fje,isEnum:()=>Ks,isHexColor:()=>Fje,isISO8601:()=>Qje,isInExclusiveRange:()=>Pje,isInInclusiveRange:()=>Sje,isInstanceOf:()=>hje,isInteger:()=>LT,isJSON:()=>Tje,isLiteral:()=>pV,isLowerCase:()=>bje,isMap:()=>Aje,isNegative:()=>Bje,isNullable:()=>Lje,isNumber:()=>RT,isObject:()=>hV,isOneOf:()=>TT,isOptional:()=>Nje,isPartial:()=>pje,isPayload:()=>lje,isPositive:()=>vje,isRecord:()=>sS,isSet:()=>uje,isString:()=>Cy,isTuple:()=>iS,isUUID4:()=>kje,isUnknown:()=>FT,isUpperCase:()=>xje,makeTrait:()=>gV,makeValidator:()=>Hr,matchesRegExp:()=>oI,softAssert:()=>mje});function jn(t){return t===null?"null":t===void 0?"undefined":t===""?"an empty string":typeof t=="symbol"?`<${t.toString()}>`:Array.isArray(t)?"an array":JSON.stringify(t)}function Ey(t,e){if(t.length===0)return"nothing";if(t.length===1)return jn(t[0]);let r=t.slice(0,-1),o=t[t.length-1],a=t.length>2?`, ${e} `:` ${e} `;return`${r.map(n=>jn(n)).join(", ")}${a}${jn(o)}`}function Wp(t,e){var r,o,a;return typeof e=="number"?`${(r=t?.p)!==null&&r!==void 0?r:"."}[${e}]`:eje.test(e)?`${(o=t?.p)!==null&&o!==void 0?o:""}.${e}`:`${(a=t?.p)!==null&&a!==void 0?a:"."}[${JSON.stringify(e)}]`}function QT(t,e,r){return t===1?e:r}function pr({errors:t,p:e}={},r){return t?.push(`${e??"."}: ${r}`),!1}function sje(t,e){return r=>{t[e]=r}}function Yu(t,e){return r=>{let o=t[e];return t[e]=r,Yu(t,e).bind(null,o)}}function sI(t,e,r){let o=()=>(t(r()),a),a=()=>(t(e),o);return o}function FT(){return Hr({test:(t,e)=>!0})}function pV(t){return Hr({test:(e,r)=>e!==t?pr(r,`Expected ${jn(t)} (got ${jn(e)})`):!0})}function Cy(){return Hr({test:(t,e)=>typeof t!="string"?pr(e,`Expected a string (got ${jn(t)})`):!0})}function Ks(t){let e=Array.isArray(t)?t:Object.values(t),r=e.every(a=>typeof a=="string"||typeof a=="number"),o=new Set(e);return o.size===1?pV([...o][0]):Hr({test:(a,n)=>o.has(a)?!0:r?pr(n,`Expected one of ${Ey(e,"or")} (got ${jn(a)})`):pr(n,`Expected a valid enumeration value (got ${jn(a)})`)})}function aje(){return Hr({test:(t,e)=>{var r;if(typeof t!="boolean"){if(typeof e?.coercions<"u"){if(typeof e?.coercion>"u")return pr(e,"Unbound coercion result");let o=oje.get(t);if(typeof o<"u")return e.coercions.push([(r=e.p)!==null&&r!==void 0?r:".",e.coercion.bind(null,o)]),!0}return pr(e,`Expected a boolean (got ${jn(t)})`)}return!0}})}function RT(){return Hr({test:(t,e)=>{var r;if(typeof t!="number"){if(typeof e?.coercions<"u"){if(typeof e?.coercion>"u")return pr(e,"Unbound coercion result");let o;if(typeof t=="string"){let a;try{a=JSON.parse(t)}catch{}if(typeof a=="number")if(JSON.stringify(a)===t)o=a;else return pr(e,`Received a number that can't be safely represented by the runtime (${t})`)}if(typeof o<"u")return e.coercions.push([(r=e.p)!==null&&r!==void 0?r:".",e.coercion.bind(null,o)]),!0}return pr(e,`Expected a number (got ${jn(t)})`)}return!0}})}function lje(t){return Hr({test:(e,r)=>{var o;if(typeof r?.coercions>"u")return pr(r,"The isPayload predicate can only be used with coercion enabled");if(typeof r.coercion>"u")return pr(r,"Unbound coercion result");if(typeof e!="string")return pr(r,`Expected a string (got ${jn(e)})`);let a;try{a=JSON.parse(e)}catch{return pr(r,`Expected a JSON string (got ${jn(e)})`)}let n={value:a};return t(a,Object.assign(Object.assign({},r),{coercion:Yu(n,"value")}))?(r.coercions.push([(o=r.p)!==null&&o!==void 0?o:".",r.coercion.bind(null,n.value)]),!0):!1}})}function cje(){return Hr({test:(t,e)=>{var r;if(!(t instanceof Date)){if(typeof e?.coercions<"u"){if(typeof e?.coercion>"u")return pr(e,"Unbound coercion result");let o;if(typeof t=="string"&&fV.test(t))o=new Date(t);else{let a;if(typeof t=="string"){let n;try{n=JSON.parse(t)}catch{}typeof n=="number"&&(a=n)}else typeof t=="number"&&(a=t);if(typeof a<"u")if(Number.isSafeInteger(a)||!Number.isSafeInteger(a*1e3))o=new Date(a*1e3);else return pr(e,`Received a timestamp that can't be safely represented by the runtime (${t})`)}if(typeof o<"u")return e.coercions.push([(r=e.p)!==null&&r!==void 0?r:".",e.coercion.bind(null,o)]),!0}return pr(e,`Expected a date (got ${jn(t)})`)}return!0}})}function nS(t,{delimiter:e}={}){return Hr({test:(r,o)=>{var a;let n=r;if(typeof r=="string"&&typeof e<"u"&&typeof o?.coercions<"u"){if(typeof o?.coercion>"u")return pr(o,"Unbound coercion result");r=r.split(e)}if(!Array.isArray(r))return pr(o,`Expected an array (got ${jn(r)})`);let u=!0;for(let A=0,p=r.length;A<p&&(u=t(r[A],Object.assign(Object.assign({},o),{p:Wp(o,A),coercion:Yu(r,A)}))&&u,!(!u&&o?.errors==null));++A);return r!==n&&o.coercions.push([(a=o.p)!==null&&a!==void 0?a:".",o.coercion.bind(null,r)]),u}})}function uje(t,{delimiter:e}={}){let r=nS(t,{delimiter:e});return Hr({test:(o,a)=>{var n,u;if(Object.getPrototypeOf(o).toString()==="[object Set]")if(typeof a?.coercions<"u"){if(typeof a?.coercion>"u")return pr(a,"Unbound coercion result");let A=[...o],p=[...o];if(!r(p,Object.assign(Object.assign({},a),{coercion:void 0})))return!1;let h=()=>p.some((E,I)=>E!==A[I])?new Set(p):o;return a.coercions.push([(n=a.p)!==null&&n!==void 0?n:".",sI(a.coercion,o,h)]),!0}else{let A=!0;for(let p of o)if(A=t(p,Object.assign({},a))&&A,!A&&a?.errors==null)break;return A}if(typeof a?.coercions<"u"){if(typeof a?.coercion>"u")return pr(a,"Unbound coercion result");let A={value:o};return r(o,Object.assign(Object.assign({},a),{coercion:Yu(A,"value")}))?(a.coercions.push([(u=a.p)!==null&&u!==void 0?u:".",sI(a.coercion,o,()=>new Set(A.value))]),!0):!1}return pr(a,`Expected a set (got ${jn(o)})`)}})}function Aje(t,e){let r=nS(iS([t,e])),o=sS(e,{keys:t});return Hr({test:(a,n)=>{var u,A,p;if(Object.getPrototypeOf(a).toString()==="[object Map]")if(typeof n?.coercions<"u"){if(typeof n?.coercion>"u")return pr(n,"Unbound coercion result");let h=[...a],E=[...a];if(!r(E,Object.assign(Object.assign({},n),{coercion:void 0})))return!1;let I=()=>E.some((v,x)=>v[0]!==h[x][0]||v[1]!==h[x][1])?new Map(E):a;return n.coercions.push([(u=n.p)!==null&&u!==void 0?u:".",sI(n.coercion,a,I)]),!0}else{let h=!0;for(let[E,I]of a)if(h=t(E,Object.assign({},n))&&h,!h&&n?.errors==null||(h=e(I,Object.assign(Object.assign({},n),{p:Wp(n,E)}))&&h,!h&&n?.errors==null))break;return h}if(typeof n?.coercions<"u"){if(typeof n?.coercion>"u")return pr(n,"Unbound coercion result");let h={value:a};return Array.isArray(a)?r(a,Object.assign(Object.assign({},n),{coercion:void 0}))?(n.coercions.push([(A=n.p)!==null&&A!==void 0?A:".",sI(n.coercion,a,()=>new Map(h.value))]),!0):!1:o(a,Object.assign(Object.assign({},n),{coercion:Yu(h,"value")}))?(n.coercions.push([(p=n.p)!==null&&p!==void 0?p:".",sI(n.coercion,a,()=>new Map(Object.entries(h.value)))]),!0):!1}return pr(n,`Expected a map (got ${jn(a)})`)}})}function iS(t,{delimiter:e}={}){let r=dV(t.length);return Hr({test:(o,a)=>{var n;if(typeof o=="string"&&typeof e<"u"&&typeof a?.coercions<"u"){if(typeof a?.coercion>"u")return pr(a,"Unbound coercion result");o=o.split(e),a.coercions.push([(n=a.p)!==null&&n!==void 0?n:".",a.coercion.bind(null,o)])}if(!Array.isArray(o))return pr(a,`Expected a tuple (got ${jn(o)})`);let u=r(o,Object.assign({},a));for(let A=0,p=o.length;A<p&&A<t.length&&(u=t[A](o[A],Object.assign(Object.assign({},a),{p:Wp(a,A),coercion:Yu(o,A)}))&&u,!(!u&&a?.errors==null));++A);return u}})}function sS(t,{keys:e=null}={}){let r=nS(iS([e??Cy(),t]));return Hr({test:(o,a)=>{var n;if(Array.isArray(o)&&typeof a?.coercions<"u")return typeof a?.coercion>"u"?pr(a,"Unbound coercion result"):r(o,Object.assign(Object.assign({},a),{coercion:void 0}))?(o=Object.fromEntries(o),a.coercions.push([(n=a.p)!==null&&n!==void 0?n:".",a.coercion.bind(null,o)]),!0):!1;if(typeof o!="object"||o===null)return pr(a,`Expected an object (got ${jn(o)})`);let u=Object.keys(o),A=!0;for(let p=0,h=u.length;p<h&&(A||a?.errors!=null);++p){let E=u[p],I=o[E];if(E==="__proto__"||E==="constructor"){A=pr(Object.assign(Object.assign({},a),{p:Wp(a,E)}),"Unsafe property name");continue}if(e!==null&&!e(E,a)){A=!1;continue}if(!t(I,Object.assign(Object.assign({},a),{p:Wp(a,E),coercion:Yu(o,E)}))){A=!1;continue}}return A}})}function fje(t,e={}){return sS(t,e)}function hV(t,{extra:e=null}={}){let r=Object.keys(t),o=Hr({test:(a,n)=>{if(typeof a!="object"||a===null)return pr(n,`Expected an object (got ${jn(a)})`);let u=new Set([...r,...Object.keys(a)]),A={},p=!0;for(let h of u){if(h==="constructor"||h==="__proto__")p=pr(Object.assign(Object.assign({},n),{p:Wp(n,h)}),"Unsafe property name");else{let E=Object.prototype.hasOwnProperty.call(t,h)?t[h]:void 0,I=Object.prototype.hasOwnProperty.call(a,h)?a[h]:void 0;typeof E<"u"?p=E(I,Object.assign(Object.assign({},n),{p:Wp(n,h),coercion:Yu(a,h)}))&&p:e===null?p=pr(Object.assign(Object.assign({},n),{p:Wp(n,h)}),`Extraneous property (got ${jn(I)})`):Object.defineProperty(A,h,{enumerable:!0,get:()=>I,set:sje(a,h)})}if(!p&&n?.errors==null)break}return e!==null&&(p||n?.errors!=null)&&(p=e(A,n)&&p),p}});return Object.assign(o,{properties:t})}function pje(t){return hV(t,{extra:sS(FT())})}function gV(t){return()=>t}function Hr({test:t}){return gV(t)()}function gje(t,e){if(!e(t))throw new Kp}function dje(t,e){let r=[];if(!e(t,{errors:r}))throw new Kp({errors:r})}function mje(t,e){}function yje(t,e,{coerce:r=!1,errors:o,throw:a}={}){let n=o?[]:void 0;if(!r){if(e(t,{errors:n}))return a?t:{value:t,errors:void 0};if(a)throw new Kp({errors:n});return{value:void 0,errors:n??!0}}let u={value:t},A=Yu(u,"value"),p=[];if(!e(t,{errors:n,coercion:A,coercions:p})){if(a)throw new Kp({errors:n});return{value:void 0,errors:n??!0}}for(let[,h]of p)h();return a?u.value:{value:u.value,errors:void 0}}function Eje(t,e){let r=iS(t);return(...o)=>{if(!r(o))throw new Kp;return e(...o)}}function Cje(t){return Hr({test:(e,r)=>e.length>=t?!0:pr(r,`Expected to have a length of at least ${t} elements (got ${e.length})`)})}function wje(t){return Hr({test:(e,r)=>e.length<=t?!0:pr(r,`Expected to have a length of at most ${t} elements (got ${e.length})`)})}function dV(t){return Hr({test:(e,r)=>e.length!==t?pr(r,`Expected to have a length of exactly ${t} elements (got ${e.length})`):!0})}function Ije({map:t}={}){return Hr({test:(e,r)=>{let o=new Set,a=new Set;for(let n=0,u=e.length;n<u;++n){let A=e[n],p=typeof t<"u"?t(A):A;if(o.has(p)){if(a.has(p))continue;pr(r,`Expected to contain unique elements; got a duplicate with ${jn(e)}`),a.add(p)}else o.add(p)}return a.size===0}})}function Bje(){return Hr({test:(t,e)=>t<=0?!0:pr(e,`Expected to be negative (got ${t})`)})}function vje(){return Hr({test:(t,e)=>t>=0?!0:pr(e,`Expected to be positive (got ${t})`)})}function NT(t){return Hr({test:(e,r)=>e>=t?!0:pr(r,`Expected to be at least ${t} (got ${e})`)})}function Dje(t){return Hr({test:(e,r)=>e<=t?!0:pr(r,`Expected to be at most ${t} (got ${e})`)})}function Sje(t,e){return Hr({test:(r,o)=>r>=t&&r<=e?!0:pr(o,`Expected to be in the [${t}; ${e}] range (got ${r})`)})}function Pje(t,e){return Hr({test:(r,o)=>r>=t&&r<e?!0:pr(o,`Expected to be in the [${t}; ${e}[ range (got ${r})`)})}function LT({unsafe:t=!1}={}){return Hr({test:(e,r)=>e!==Math.round(e)?pr(r,`Expected to be an integer (got ${e})`):!t&&!Number.isSafeInteger(e)?pr(r,`Expected to be a safe integer (got ${e})`):!0})}function oI(t){return Hr({test:(e,r)=>t.test(e)?!0:pr(r,`Expected to match the pattern ${t.toString()} (got ${jn(e)})`)})}function bje(){return Hr({test:(t,e)=>t!==t.toLowerCase()?pr(e,`Expected to be all-lowercase (got ${t})`):!0})}function xje(){return Hr({test:(t,e)=>t!==t.toUpperCase()?pr(e,`Expected to be all-uppercase (got ${t})`):!0})}function kje(){return Hr({test:(t,e)=>ije.test(t)?!0:pr(e,`Expected to be a valid UUID v4 (got ${jn(t)})`)})}function Qje(){return Hr({test:(t,e)=>fV.test(t)?!0:pr(e,`Expected to be a valid ISO 8601 date string (got ${jn(t)})`)})}function Fje({alpha:t=!1}){return Hr({test:(e,r)=>(t?tje.test(e):rje.test(e))?!0:pr(r,`Expected to be a valid hexadecimal color string (got ${jn(e)})`)})}function Rje(){return Hr({test:(t,e)=>nje.test(t)?!0:pr(e,`Expected to be a valid base 64 string (got ${jn(t)})`)})}function Tje(t=FT()){return Hr({test:(e,r)=>{let o;try{o=JSON.parse(e)}catch{return pr(r,`Expected to be a valid JSON string (got ${jn(e)})`)}return t(o,r)}})}function oS(t,...e){let r=Array.isArray(e[0])?e[0]:e;return Hr({test:(o,a)=>{var n,u;let A={value:o},p=typeof a?.coercions<"u"?Yu(A,"value"):void 0,h=typeof a?.coercions<"u"?[]:void 0;if(!t(o,Object.assign(Object.assign({},a),{coercion:p,coercions:h})))return!1;let E=[];if(typeof h<"u")for(let[,I]of h)E.push(I());try{if(typeof a?.coercions<"u"){if(A.value!==o){if(typeof a?.coercion>"u")return pr(a,"Unbound coercion result");a.coercions.push([(n=a.p)!==null&&n!==void 0?n:".",a.coercion.bind(null,A.value)])}(u=a?.coercions)===null||u===void 0||u.push(...h)}return r.every(I=>I(A.value,a))}finally{for(let I of E)I()}}})}function aI(t,...e){let r=Array.isArray(e[0])?e[0]:e;return oS(t,r)}function Nje(t){return Hr({test:(e,r)=>typeof e>"u"?!0:t(e,r)})}function Lje(t){return Hr({test:(e,r)=>e===null?!0:t(e,r)})}function Oje(t,e){var r;let o=new Set(t),a=lI[(r=e?.missingIf)!==null&&r!==void 0?r:"missing"];return Hr({test:(n,u)=>{let A=new Set(Object.keys(n)),p=[];for(let h of o)a(A,h,n)||p.push(h);return p.length>0?pr(u,`Missing required ${QT(p.length,"property","properties")} ${Ey(p,"and")}`):!0}})}function OT(t,e){var r;let o=new Set(t),a=lI[(r=e?.missingIf)!==null&&r!==void 0?r:"missing"];return Hr({test:(n,u)=>Object.keys(n).some(h=>a(o,h,n))?!0:pr(u,`Missing at least one property from ${Ey(Array.from(o),"or")}`)})}function Mje(t,e){var r;let o=new Set(t),a=lI[(r=e?.missingIf)!==null&&r!==void 0?r:"missing"];return Hr({test:(n,u)=>{let A=new Set(Object.keys(n)),p=[];for(let h of o)a(A,h,n)&&p.push(h);return p.length>0?pr(u,`Forbidden ${QT(p.length,"property","properties")} ${Ey(p,"and")}`):!0}})}function Uje(t,e){var r;let o=new Set(t),a=lI[(r=e?.missingIf)!==null&&r!==void 0?r:"missing"];return Hr({test:(n,u)=>{let A=new Set(Object.keys(n)),p=[];for(let h of o)a(A,h,n)&&p.push(h);return p.length>1?pr(u,`Mutually exclusive properties ${Ey(p,"and")}`):!0}})}function cI(t,e,r,o){var a,n;let u=new Set((a=o?.ignore)!==null&&a!==void 0?a:[]),A=lI[(n=o?.missingIf)!==null&&n!==void 0?n:"missing"],p=new Set(r),h=_je[e],E=e===qu.Forbids?"or":"and";return Hr({test:(I,v)=>{let x=new Set(Object.keys(I));if(!A(x,t,I)||u.has(I[t]))return!0;let C=[];for(let R of p)(A(x,R,I)&&!u.has(I[R]))!==h.expect&&C.push(R);return C.length>=1?pr(v,`Property "${t}" ${h.message} ${QT(C.length,"property","properties")} ${Ey(C,E)}`):!0}})}var eje,tje,rje,nje,ije,fV,oje,hje,TT,Kp,lI,qu,_je,Za=Et(()=>{eje=/^[a-zA-Z_][a-zA-Z0-9_]*$/;tje=/^#[0-9a-f]{6}$/i,rje=/^#[0-9a-f]{6}([0-9a-f]{2})?$/i,nje=/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/,ije=/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}$/i,fV=/^(?:[1-9]\d{3}(-?)(?:(?:0[1-9]|1[0-2])\1(?:0[1-9]|1\d|2[0-8])|(?:0[13-9]|1[0-2])\1(?:29|30)|(?:0[13578]|1[02])(?:\1)31|00[1-9]|0[1-9]\d|[12]\d{2}|3(?:[0-5]\d|6[0-5]))|(?:[1-9]\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)(?:(-?)02(?:\2)29|-?366))T(?:[01]\d|2[0-3])(:?)[0-5]\d(?:\3[0-5]\d)?(?:Z|[+-][01]\d(?:\3[0-5]\d)?)$/;oje=new Map([["true",!0],["True",!0],["1",!0],[1,!0],["false",!1],["False",!1],["0",!1],[0,!1]]);hje=t=>Hr({test:(e,r)=>e instanceof t?!0:pr(r,`Expected an instance of ${t.name} (got ${jn(e)})`)}),TT=(t,{exclusive:e=!1}={})=>Hr({test:(r,o)=>{var a,n,u;let A=[],p=typeof o?.errors<"u"?[]:void 0;for(let h=0,E=t.length;h<E;++h){let I=typeof o?.errors<"u"?[]:void 0,v=typeof o?.coercions<"u"?[]:void 0;if(t[h](r,Object.assign(Object.assign({},o),{errors:I,coercions:v,p:`${(a=o?.p)!==null&&a!==void 0?a:"."}#${h+1}`}))){if(A.push([`#${h+1}`,v]),!e)break}else p?.push(I[0])}if(A.length===1){let[,h]=A[0];return typeof h<"u"&&((n=o?.coercions)===null||n===void 0||n.push(...h)),!0}return A.length>1?pr(o,`Expected to match exactly a single predicate (matched ${A.join(", ")})`):(u=o?.errors)===null||u===void 0||u.push(...p),!1}});Kp=class extends Error{constructor({errors:e}={}){let r="Type mismatch";if(e&&e.length>0){r+=` +`;for(let o of e)r+=` +- ${o}`}super(r)}};lI={missing:(t,e)=>t.has(e),undefined:(t,e,r)=>t.has(e)&&typeof r[e]<"u",nil:(t,e,r)=>t.has(e)&&r[e]!=null,falsy:(t,e,r)=>t.has(e)&&!!r[e]};(function(t){t.Forbids="Forbids",t.Requires="Requires"})(qu||(qu={}));_je={[qu.Forbids]:{expect:!1,message:"forbids using"},[qu.Requires]:{expect:!0,message:"requires using"}}});var nt,Vp=Et(()=>{Ef();nt=class{constructor(){this.help=!1}static Usage(e){return e}async catch(e){throw e}async validateAndExecute(){let r=this.constructor.schema;if(Array.isArray(r)){let{isDict:a,isUnknown:n,applyCascade:u}=await Promise.resolve().then(()=>(Za(),Vo)),A=u(a(n()),r),p=[],h=[];if(!A(this,{errors:p,coercions:h}))throw iI("Invalid option schema",p);for(let[,I]of h)I()}else if(r!=null)throw new Error("Invalid command schema");let o=await this.execute();return typeof o<"u"?o:0}};nt.isOption=nI;nt.Default=[]});function va(t){PT&&console.log(t)}function yV(){let t={nodes:[]};for(let e=0;e<cn.CustomNode;++e)t.nodes.push($a());return t}function Hje(t){let e=yV(),r=[],o=e.nodes.length;for(let a of t){r.push(o);for(let n=0;n<a.nodes.length;++n)CV(n)||e.nodes.push(Jje(a.nodes[n],o));o+=a.nodes.length-cn.CustomNode+1}for(let a of r)wy(e,cn.InitialNode,a);return e}function Oc(t,e){return t.nodes.push(e),t.nodes.length-1}function jje(t){let e=new Set,r=o=>{if(e.has(o))return;e.add(o);let a=t.nodes[o];for(let u of Object.values(a.statics))for(let{to:A}of u)r(A);for(let[,{to:u}]of a.dynamics)r(u);for(let{to:u}of a.shortcuts)r(u);let n=new Set(a.shortcuts.map(({to:u})=>u));for(;a.shortcuts.length>0;){let{to:u}=a.shortcuts.shift(),A=t.nodes[u];for(let[p,h]of Object.entries(A.statics)){let E=Object.prototype.hasOwnProperty.call(a.statics,p)?a.statics[p]:a.statics[p]=[];for(let I of h)E.some(({to:v})=>I.to===v)||E.push(I)}for(let[p,h]of A.dynamics)a.dynamics.some(([E,{to:I}])=>p===E&&h.to===I)||a.dynamics.push([p,h]);for(let p of A.shortcuts)n.has(p.to)||(a.shortcuts.push(p),n.add(p.to))}};r(cn.InitialNode)}function Gje(t,{prefix:e=""}={}){if(PT){va(`${e}Nodes are:`);for(let r=0;r<t.nodes.length;++r)va(`${e} ${r}: ${JSON.stringify(t.nodes[r])}`)}}function qje(t,e,r=!1){va(`Running a vm on ${JSON.stringify(e)}`);let o=[{node:cn.InitialNode,state:{candidateUsage:null,requiredOptions:[],errorMessage:null,ignoreOptions:!1,options:[],path:[],positionals:[],remainder:null,selectedIndex:null,partial:!1,tokens:[]}}];Gje(t,{prefix:" "});let a=[Hn.StartOfInput,...e];for(let n=0;n<a.length;++n){let u=a[n],A=u===Hn.EndOfInput||u===Hn.EndOfPartialInput,p=n-1;va(` Processing ${JSON.stringify(u)}`);let h=[];for(let{node:E,state:I}of o){va(` Current node is ${E}`);let v=t.nodes[E];if(E===cn.ErrorNode){h.push({node:E,state:I});continue}console.assert(v.shortcuts.length===0,"Shortcuts should have been eliminated by now");let x=Object.prototype.hasOwnProperty.call(v.statics,u);if(!r||n<a.length-1||x)if(x){let C=v.statics[u];for(let{to:R,reducer:L}of C)h.push({node:R,state:typeof L<"u"?aS(UT,L,I,u,p):I}),va(` Static transition to ${R} found`)}else va(" No static transition found");else{let C=!1;for(let R of Object.keys(v.statics))if(!!R.startsWith(u)){if(u===R)for(let{to:L,reducer:U}of v.statics[R])h.push({node:L,state:typeof U<"u"?aS(UT,U,I,u,p):I}),va(` Static transition to ${L} found`);else for(let{to:L}of v.statics[R])h.push({node:L,state:{...I,remainder:R.slice(u.length)}}),va(` Static transition to ${L} found (partial match)`);C=!0}C||va(" No partial static transition found")}if(!A)for(let[C,{to:R,reducer:L}]of v.dynamics)aS(zje,C,I,u,p)&&(h.push({node:R,state:typeof L<"u"?aS(UT,L,I,u,p):I}),va(` Dynamic transition to ${R} found (via ${C})`))}if(h.length===0&&A&&e.length===1)return[{node:cn.InitialNode,state:mV}];if(h.length===0)throw new yy(e,o.filter(({node:E})=>E!==cn.ErrorNode).map(({state:E})=>({usage:E.candidateUsage,reason:null})));if(h.every(({node:E})=>E===cn.ErrorNode))throw new yy(e,h.map(({state:E})=>({usage:E.candidateUsage,reason:E.errorMessage})));o=Wje(h)}if(o.length>0){va(" Results:");for(let n of o)va(` - ${n.node} -> ${JSON.stringify(n.state)}`)}else va(" No results");return o}function Yje(t,e,{endToken:r=Hn.EndOfInput}={}){let o=qje(t,[...e,r]);return Kje(e,o.map(({state:a})=>a))}function Wje(t){let e=0;for(let{state:r}of t)r.path.length>e&&(e=r.path.length);return t.filter(({state:r})=>r.path.length===e)}function Kje(t,e){let r=e.filter(v=>v.selectedIndex!==null),o=r.filter(v=>!v.partial);if(o.length>0&&(r=o),r.length===0)throw new Error;let a=r.filter(v=>v.selectedIndex===rd||v.requiredOptions.every(x=>x.some(C=>v.options.find(R=>R.name===C))));if(a.length===0)throw new yy(t,r.map(v=>({usage:v.candidateUsage,reason:null})));let n=0;for(let v of a)v.path.length>n&&(n=v.path.length);let u=a.filter(v=>v.path.length===n),A=v=>v.positionals.filter(({extra:x})=>!x).length+v.options.length,p=u.map(v=>({state:v,positionalCount:A(v)})),h=0;for(let{positionalCount:v}of p)v>h&&(h=v);let E=p.filter(({positionalCount:v})=>v===h).map(({state:v})=>v),I=Vje(E);if(I.length>1)throw new eS(t,I.map(v=>v.candidateUsage));return I[0]}function Vje(t){let e=[],r=[];for(let o of t)o.selectedIndex===rd?r.push(o):e.push(o);return r.length>0&&e.push({...mV,path:EV(...r.map(o=>o.path)),options:r.reduce((o,a)=>o.concat(a.options),[])}),e}function EV(t,e,...r){return e===void 0?Array.from(t):EV(t.filter((o,a)=>o===e[a]),...r)}function $a(){return{dynamics:[],shortcuts:[],statics:{}}}function CV(t){return t===cn.SuccessNode||t===cn.ErrorNode}function MT(t,e=0){return{to:CV(t.to)?t.to:t.to>=cn.CustomNode?t.to+e-cn.CustomNode+1:t.to+e,reducer:t.reducer}}function Jje(t,e=0){let r=$a();for(let[o,a]of t.dynamics)r.dynamics.push([o,MT(a,e)]);for(let o of t.shortcuts)r.shortcuts.push(MT(o,e));for(let[o,a]of Object.entries(t.statics))r.statics[o]=a.map(n=>MT(n,e));return r}function Ps(t,e,r,o,a){t.nodes[e].dynamics.push([r,{to:o,reducer:a}])}function wy(t,e,r,o){t.nodes[e].shortcuts.push({to:r,reducer:o})}function Jo(t,e,r,o,a){(Object.prototype.hasOwnProperty.call(t.nodes[e].statics,r)?t.nodes[e].statics[r]:t.nodes[e].statics[r]=[]).push({to:o,reducer:a})}function aS(t,e,r,o,a){if(Array.isArray(e)){let[n,...u]=e;return t[n](r,o,a,...u)}else return t[e](r,o,a)}var mV,zje,UT,el,_T,Iy,lS=Et(()=>{$D();tS();mV={candidateUsage:null,requiredOptions:[],errorMessage:null,ignoreOptions:!1,path:[],positionals:[],options:[],remainder:null,selectedIndex:rd,partial:!1,tokens:[]};zje={always:()=>!0,isOptionLike:(t,e)=>!t.ignoreOptions&&e!=="-"&&e.startsWith("-"),isNotOptionLike:(t,e)=>t.ignoreOptions||e==="-"||!e.startsWith("-"),isOption:(t,e,r,o)=>!t.ignoreOptions&&e===o,isBatchOption:(t,e,r,o)=>!t.ignoreOptions&&cV.test(e)&&[...e.slice(1)].every(a=>o.has(`-${a}`)),isBoundOption:(t,e,r,o,a)=>{let n=e.match(ST);return!t.ignoreOptions&&!!n&&ZD.test(n[1])&&o.has(n[1])&&a.filter(u=>u.nameSet.includes(n[1])).every(u=>u.allowBinding)},isNegatedOption:(t,e,r,o)=>!t.ignoreOptions&&e===`--no-${o.slice(2)}`,isHelp:(t,e)=>!t.ignoreOptions&&DT.test(e),isUnsupportedOption:(t,e,r,o)=>!t.ignoreOptions&&e.startsWith("-")&&ZD.test(e)&&!o.has(e),isInvalidOption:(t,e)=>!t.ignoreOptions&&e.startsWith("-")&&!ZD.test(e)},UT={setCandidateState:(t,e,r,o)=>({...t,...o}),setSelectedIndex:(t,e,r,o)=>({...t,selectedIndex:o}),setPartialIndex:(t,e,r,o)=>({...t,selectedIndex:o,partial:!0}),pushBatch:(t,e,r,o)=>{let a=t.options.slice(),n=t.tokens.slice();for(let u=1;u<e.length;++u){let A=o.get(`-${e[u]}`),p=u===1?[0,2]:[u,u+1];a.push({name:A,value:!0}),n.push({segmentIndex:r,type:"option",option:A,slice:p})}return{...t,options:a,tokens:n}},pushBound:(t,e,r)=>{let[,o,a]=e.match(ST),n=t.options.concat({name:o,value:a}),u=t.tokens.concat([{segmentIndex:r,type:"option",slice:[0,o.length],option:o},{segmentIndex:r,type:"assign",slice:[o.length,o.length+1]},{segmentIndex:r,type:"value",slice:[o.length+1,o.length+a.length+1]}]);return{...t,options:n,tokens:u}},pushPath:(t,e,r)=>{let o=t.path.concat(e),a=t.tokens.concat({segmentIndex:r,type:"path"});return{...t,path:o,tokens:a}},pushPositional:(t,e,r)=>{let o=t.positionals.concat({value:e,extra:!1}),a=t.tokens.concat({segmentIndex:r,type:"positional"});return{...t,positionals:o,tokens:a}},pushExtra:(t,e,r)=>{let o=t.positionals.concat({value:e,extra:!0}),a=t.tokens.concat({segmentIndex:r,type:"positional"});return{...t,positionals:o,tokens:a}},pushExtraNoLimits:(t,e,r)=>{let o=t.positionals.concat({value:e,extra:el}),a=t.tokens.concat({segmentIndex:r,type:"positional"});return{...t,positionals:o,tokens:a}},pushTrue:(t,e,r,o)=>{let a=t.options.concat({name:o,value:!0}),n=t.tokens.concat({segmentIndex:r,type:"option",option:o});return{...t,options:a,tokens:n}},pushFalse:(t,e,r,o)=>{let a=t.options.concat({name:o,value:!1}),n=t.tokens.concat({segmentIndex:r,type:"option",option:o});return{...t,options:a,tokens:n}},pushUndefined:(t,e,r,o)=>{let a=t.options.concat({name:e,value:void 0}),n=t.tokens.concat({segmentIndex:r,type:"option",option:e});return{...t,options:a,tokens:n}},pushStringValue:(t,e,r)=>{var o;let a=t.options[t.options.length-1],n=t.options.slice(),u=t.tokens.concat({segmentIndex:r,type:"value"});return a.value=((o=a.value)!==null&&o!==void 0?o:[]).concat([e]),{...t,options:n,tokens:u}},setStringValue:(t,e,r)=>{let o=t.options[t.options.length-1],a=t.options.slice(),n=t.tokens.concat({segmentIndex:r,type:"value"});return o.value=e,{...t,options:a,tokens:n}},inhibateOptions:t=>({...t,ignoreOptions:!0}),useHelp:(t,e,r,o)=>{let[,,a]=e.match(DT);return typeof a<"u"?{...t,options:[{name:"-c",value:String(o)},{name:"-i",value:a}]}:{...t,options:[{name:"-c",value:String(o)}]}},setError:(t,e,r,o)=>e===Hn.EndOfInput||e===Hn.EndOfPartialInput?{...t,errorMessage:`${o}.`}:{...t,errorMessage:`${o} ("${e}").`},setOptionArityError:(t,e)=>{let r=t.options[t.options.length-1];return{...t,errorMessage:`Not enough arguments to option ${r.name}.`}}},el=Symbol(),_T=class{constructor(e,r){this.allOptionNames=new Map,this.arity={leading:[],trailing:[],extra:[],proxy:!1},this.options=[],this.paths=[],this.cliIndex=e,this.cliOpts=r}addPath(e){this.paths.push(e)}setArity({leading:e=this.arity.leading,trailing:r=this.arity.trailing,extra:o=this.arity.extra,proxy:a=this.arity.proxy}){Object.assign(this.arity,{leading:e,trailing:r,extra:o,proxy:a})}addPositional({name:e="arg",required:r=!0}={}){if(!r&&this.arity.extra===el)throw new Error("Optional parameters cannot be declared when using .rest() or .proxy()");if(!r&&this.arity.trailing.length>0)throw new Error("Optional parameters cannot be declared after the required trailing positional arguments");!r&&this.arity.extra!==el?this.arity.extra.push(e):this.arity.extra!==el&&this.arity.extra.length===0?this.arity.leading.push(e):this.arity.trailing.push(e)}addRest({name:e="arg",required:r=0}={}){if(this.arity.extra===el)throw new Error("Infinite lists cannot be declared multiple times in the same command");if(this.arity.trailing.length>0)throw new Error("Infinite lists cannot be declared after the required trailing positional arguments");for(let o=0;o<r;++o)this.addPositional({name:e});this.arity.extra=el}addProxy({required:e=0}={}){this.addRest({required:e}),this.arity.proxy=!0}addOption({names:e,description:r,arity:o=0,hidden:a=!1,required:n=!1,allowBinding:u=!0}){if(!u&&o>1)throw new Error("The arity cannot be higher than 1 when the option only supports the --arg=value syntax");if(!Number.isInteger(o))throw new Error(`The arity must be an integer, got ${o}`);if(o<0)throw new Error(`The arity must be positive, got ${o}`);let A=e.reduce((p,h)=>h.length>p.length?h:p,"");for(let p of e)this.allOptionNames.set(p,A);this.options.push({preferredName:A,nameSet:e,description:r,arity:o,hidden:a,required:n,allowBinding:u})}setContext(e){this.context=e}usage({detailed:e=!0,inlineOptions:r=!0}={}){let o=[this.cliOpts.binaryName],a=[];if(this.paths.length>0&&o.push(...this.paths[0]),e){for(let{preferredName:u,nameSet:A,arity:p,hidden:h,description:E,required:I}of this.options){if(h)continue;let v=[];for(let C=0;C<p;++C)v.push(` #${C}`);let x=`${A.join(",")}${v.join("")}`;!r&&E?a.push({preferredName:u,nameSet:A,definition:x,description:E,required:I}):o.push(I?`<${x}>`:`[${x}]`)}o.push(...this.arity.leading.map(u=>`<${u}>`)),this.arity.extra===el?o.push("..."):o.push(...this.arity.extra.map(u=>`[${u}]`)),o.push(...this.arity.trailing.map(u=>`<${u}>`))}return{usage:o.join(" "),options:a}}compile(){if(typeof this.context>"u")throw new Error("Assertion failed: No context attached");let e=yV(),r=cn.InitialNode,o=this.usage().usage,a=this.options.filter(A=>A.required).map(A=>A.nameSet);r=Oc(e,$a()),Jo(e,cn.InitialNode,Hn.StartOfInput,r,["setCandidateState",{candidateUsage:o,requiredOptions:a}]);let n=this.arity.proxy?"always":"isNotOptionLike",u=this.paths.length>0?this.paths:[[]];for(let A of u){let p=r;if(A.length>0){let v=Oc(e,$a());wy(e,p,v),this.registerOptions(e,v),p=v}for(let v=0;v<A.length;++v){let x=Oc(e,$a());Jo(e,p,A[v],x,"pushPath"),p=x}if(this.arity.leading.length>0||!this.arity.proxy){let v=Oc(e,$a());Ps(e,p,"isHelp",v,["useHelp",this.cliIndex]),Ps(e,v,"always",v,"pushExtra"),Jo(e,v,Hn.EndOfInput,cn.SuccessNode,["setSelectedIndex",rd]),this.registerOptions(e,p)}this.arity.leading.length>0&&(Jo(e,p,Hn.EndOfInput,cn.ErrorNode,["setError","Not enough positional arguments"]),Jo(e,p,Hn.EndOfPartialInput,cn.SuccessNode,["setPartialIndex",this.cliIndex]));let h=p;for(let v=0;v<this.arity.leading.length;++v){let x=Oc(e,$a());(!this.arity.proxy||v+1!==this.arity.leading.length)&&this.registerOptions(e,x),(this.arity.trailing.length>0||v+1!==this.arity.leading.length)&&(Jo(e,x,Hn.EndOfInput,cn.ErrorNode,["setError","Not enough positional arguments"]),Jo(e,x,Hn.EndOfPartialInput,cn.SuccessNode,["setPartialIndex",this.cliIndex])),Ps(e,h,"isNotOptionLike",x,"pushPositional"),h=x}let E=h;if(this.arity.extra===el||this.arity.extra.length>0){let v=Oc(e,$a());if(wy(e,h,v),this.arity.extra===el){let x=Oc(e,$a());this.arity.proxy||this.registerOptions(e,x),Ps(e,h,n,x,"pushExtraNoLimits"),Ps(e,x,n,x,"pushExtraNoLimits"),wy(e,x,v)}else for(let x=0;x<this.arity.extra.length;++x){let C=Oc(e,$a());(!this.arity.proxy||x>0)&&this.registerOptions(e,C),Ps(e,E,n,C,"pushExtra"),wy(e,C,v),E=C}E=v}this.arity.trailing.length>0&&(Jo(e,E,Hn.EndOfInput,cn.ErrorNode,["setError","Not enough positional arguments"]),Jo(e,E,Hn.EndOfPartialInput,cn.SuccessNode,["setPartialIndex",this.cliIndex]));let I=E;for(let v=0;v<this.arity.trailing.length;++v){let x=Oc(e,$a());this.arity.proxy||this.registerOptions(e,x),v+1<this.arity.trailing.length&&(Jo(e,x,Hn.EndOfInput,cn.ErrorNode,["setError","Not enough positional arguments"]),Jo(e,x,Hn.EndOfPartialInput,cn.SuccessNode,["setPartialIndex",this.cliIndex])),Ps(e,I,"isNotOptionLike",x,"pushPositional"),I=x}Ps(e,I,n,cn.ErrorNode,["setError","Extraneous positional argument"]),Jo(e,I,Hn.EndOfInput,cn.SuccessNode,["setSelectedIndex",this.cliIndex]),Jo(e,I,Hn.EndOfPartialInput,cn.SuccessNode,["setSelectedIndex",this.cliIndex])}return{machine:e,context:this.context}}registerOptions(e,r){Ps(e,r,["isOption","--"],r,"inhibateOptions"),Ps(e,r,["isBatchOption",this.allOptionNames],r,["pushBatch",this.allOptionNames]),Ps(e,r,["isBoundOption",this.allOptionNames,this.options],r,"pushBound"),Ps(e,r,["isUnsupportedOption",this.allOptionNames],cn.ErrorNode,["setError","Unsupported option name"]),Ps(e,r,["isInvalidOption"],cn.ErrorNode,["setError","Invalid option name"]);for(let o of this.options)if(o.arity===0)for(let a of o.nameSet)Ps(e,r,["isOption",a],r,["pushTrue",o.preferredName]),a.startsWith("--")&&!a.startsWith("--no-")&&Ps(e,r,["isNegatedOption",a],r,["pushFalse",o.preferredName]);else{let a=Oc(e,$a());for(let n of o.nameSet)Ps(e,r,["isOption",n],a,["pushUndefined",o.preferredName]);for(let n=0;n<o.arity;++n){let u=Oc(e,$a());Jo(e,a,Hn.EndOfInput,cn.ErrorNode,"setOptionArityError"),Jo(e,a,Hn.EndOfPartialInput,cn.ErrorNode,"setOptionArityError"),Ps(e,a,"isOptionLike",cn.ErrorNode,"setOptionArityError");let A=o.arity===1?"setStringValue":"pushStringValue";Ps(e,a,"isNotOptionLike",u,A),a=u}wy(e,a,r)}}},Iy=class{constructor({binaryName:e="..."}={}){this.builders=[],this.opts={binaryName:e}}static build(e,r={}){return new Iy(r).commands(e).compile()}getBuilderByIndex(e){if(!(e>=0&&e<this.builders.length))throw new Error(`Assertion failed: Out-of-bound command index (${e})`);return this.builders[e]}commands(e){for(let r of e)r(this.command());return this}command(){let e=new _T(this.builders.length,this.opts);return this.builders.push(e),e}compile(){let e=[],r=[];for(let a of this.builders){let{machine:n,context:u}=a.compile();e.push(n),r.push(u)}let o=Hje(e);return jje(o),{machine:o,contexts:r,process:(a,{partial:n}={})=>{let u=n?Hn.EndOfPartialInput:Hn.EndOfInput;return Yje(o,a,{endToken:u})}}}}});function IV(){return cS.default&&"getColorDepth"in cS.default.WriteStream.prototype?cS.default.WriteStream.prototype.getColorDepth():process.env.FORCE_COLOR==="0"?1:process.env.FORCE_COLOR==="1"||typeof process.stdout<"u"&&process.stdout.isTTY?8:1}function BV(t){let e=wV;if(typeof e>"u"){if(t.stdout===process.stdout&&t.stderr===process.stderr)return null;let{AsyncLocalStorage:r}=ve("async_hooks");e=wV=new r;let o=process.stdout._write;process.stdout._write=function(n,u,A){let p=e.getStore();return typeof p>"u"?o.call(this,n,u,A):p.stdout.write(n,u,A)};let a=process.stderr._write;process.stderr._write=function(n,u,A){let p=e.getStore();return typeof p>"u"?a.call(this,n,u,A):p.stderr.write(n,u,A)}}return r=>e.run(t,r)}var cS,wV,vV=Et(()=>{cS=$e(ve("tty"),1)});var By,DV=Et(()=>{Vp();By=class extends nt{constructor(e){super(),this.contexts=e,this.commands=[]}static from(e,r){let o=new By(r);o.path=e.path;for(let a of e.options)switch(a.name){case"-c":o.commands.push(Number(a.value));break;case"-i":o.index=Number(a.value);break}return o}async execute(){let e=this.commands;if(typeof this.index<"u"&&this.index>=0&&this.index<e.length&&(e=[e[this.index]]),e.length===0)this.context.stdout.write(this.cli.usage());else if(e.length===1)this.context.stdout.write(this.cli.usage(this.contexts[e[0]].commandClass,{detailed:!0}));else if(e.length>1){this.context.stdout.write(`Multiple commands match your selection: +`),this.context.stdout.write(` +`);let r=0;for(let o of this.commands)this.context.stdout.write(this.cli.usage(this.contexts[o].commandClass,{prefix:`${r++}. `.padStart(5)}));this.context.stdout.write(` +`),this.context.stdout.write(`Run again with -h=<index> to see the longer details of any of those commands. +`)}}}});async function bV(...t){let{resolvedOptions:e,resolvedCommandClasses:r,resolvedArgv:o,resolvedContext:a}=kV(t);return as.from(r,e).runExit(o,a)}async function xV(...t){let{resolvedOptions:e,resolvedCommandClasses:r,resolvedArgv:o,resolvedContext:a}=kV(t);return as.from(r,e).run(o,a)}function kV(t){let e,r,o,a;switch(typeof process<"u"&&typeof process.argv<"u"&&(o=process.argv.slice(2)),t.length){case 1:r=t[0];break;case 2:t[0]&&t[0].prototype instanceof nt||Array.isArray(t[0])?(r=t[0],Array.isArray(t[1])?o=t[1]:a=t[1]):(e=t[0],r=t[1]);break;case 3:Array.isArray(t[2])?(e=t[0],r=t[1],o=t[2]):t[0]&&t[0].prototype instanceof nt||Array.isArray(t[0])?(r=t[0],o=t[1],a=t[2]):(e=t[0],r=t[1],a=t[2]);break;default:e=t[0],r=t[1],o=t[2],a=t[3];break}if(typeof o>"u")throw new Error("The argv parameter must be provided when running Clipanion outside of a Node context");return{resolvedOptions:e,resolvedCommandClasses:r,resolvedArgv:o,resolvedContext:a}}function PV(t){return t()}var SV,as,QV=Et(()=>{$D();lS();kT();vV();Vp();DV();SV=Symbol("clipanion/errorCommand");as=class{constructor({binaryLabel:e,binaryName:r="...",binaryVersion:o,enableCapture:a=!1,enableColors:n}={}){this.registrations=new Map,this.builder=new Iy({binaryName:r}),this.binaryLabel=e,this.binaryName=r,this.binaryVersion=o,this.enableCapture=a,this.enableColors=n}static from(e,r={}){let o=new as(r),a=Array.isArray(e)?e:[e];for(let n of a)o.register(n);return o}register(e){var r;let o=new Map,a=new e;for(let p in a){let h=a[p];typeof h=="object"&&h!==null&&h[nt.isOption]&&o.set(p,h)}let n=this.builder.command(),u=n.cliIndex,A=(r=e.paths)!==null&&r!==void 0?r:a.paths;if(typeof A<"u")for(let p of A)n.addPath(p);this.registrations.set(e,{specs:o,builder:n,index:u});for(let[p,{definition:h}]of o.entries())h(n,p);n.setContext({commandClass:e})}process(e,r){let{input:o,context:a,partial:n}=typeof e=="object"&&Array.isArray(e)?{input:e,context:r}:e,{contexts:u,process:A}=this.builder.compile(),p=A(o,{partial:n}),h={...as.defaultContext,...a};switch(p.selectedIndex){case rd:{let E=By.from(p,u);return E.context=h,E.tokens=p.tokens,E}default:{let{commandClass:E}=u[p.selectedIndex],I=this.registrations.get(E);if(typeof I>"u")throw new Error("Assertion failed: Expected the command class to have been registered.");let v=new E;v.context=h,v.tokens=p.tokens,v.path=p.path;try{for(let[x,{transformer:C}]of I.specs.entries())v[x]=C(I.builder,x,p,h);return v}catch(x){throw x[SV]=v,x}}break}}async run(e,r){var o,a;let n,u={...as.defaultContext,...r},A=(o=this.enableColors)!==null&&o!==void 0?o:u.colorDepth>1;if(!Array.isArray(e))n=e;else try{n=this.process(e,u)}catch(E){return u.stdout.write(this.error(E,{colored:A})),1}if(n.help)return u.stdout.write(this.usage(n,{colored:A,detailed:!0})),0;n.context=u,n.cli={binaryLabel:this.binaryLabel,binaryName:this.binaryName,binaryVersion:this.binaryVersion,enableCapture:this.enableCapture,enableColors:this.enableColors,definitions:()=>this.definitions(),definition:E=>this.definition(E),error:(E,I)=>this.error(E,I),format:E=>this.format(E),process:(E,I)=>this.process(E,{...u,...I}),run:(E,I)=>this.run(E,{...u,...I}),usage:(E,I)=>this.usage(E,I)};let p=this.enableCapture&&(a=BV(u))!==null&&a!==void 0?a:PV,h;try{h=await p(()=>n.validateAndExecute().catch(E=>n.catch(E).then(()=>0)))}catch(E){return u.stdout.write(this.error(E,{colored:A,command:n})),1}return h}async runExit(e,r){process.exitCode=await this.run(e,r)}definition(e,{colored:r=!1}={}){if(!e.usage)return null;let{usage:o}=this.getUsageByRegistration(e,{detailed:!1}),{usage:a,options:n}=this.getUsageByRegistration(e,{detailed:!0,inlineOptions:!1}),u=typeof e.usage.category<"u"?Do(e.usage.category,{format:this.format(r),paragraphs:!1}):void 0,A=typeof e.usage.description<"u"?Do(e.usage.description,{format:this.format(r),paragraphs:!1}):void 0,p=typeof e.usage.details<"u"?Do(e.usage.details,{format:this.format(r),paragraphs:!0}):void 0,h=typeof e.usage.examples<"u"?e.usage.examples.map(([E,I])=>[Do(E,{format:this.format(r),paragraphs:!1}),I.replace(/\$0/g,this.binaryName)]):void 0;return{path:o,usage:a,category:u,description:A,details:p,examples:h,options:n}}definitions({colored:e=!1}={}){let r=[];for(let o of this.registrations.keys()){let a=this.definition(o,{colored:e});!a||r.push(a)}return r}usage(e=null,{colored:r,detailed:o=!1,prefix:a="$ "}={}){var n;if(e===null){for(let p of this.registrations.keys()){let h=p.paths,E=typeof p.usage<"u";if(!h||h.length===0||h.length===1&&h[0].length===0||((n=h?.some(x=>x.length===0))!==null&&n!==void 0?n:!1))if(e){e=null;break}else e=p;else if(E){e=null;continue}}e&&(o=!0)}let u=e!==null&&e instanceof nt?e.constructor:e,A="";if(u)if(o){let{description:p="",details:h="",examples:E=[]}=u.usage||{};p!==""&&(A+=Do(p,{format:this.format(r),paragraphs:!1}).replace(/^./,x=>x.toUpperCase()),A+=` +`),(h!==""||E.length>0)&&(A+=`${this.format(r).header("Usage")} +`,A+=` +`);let{usage:I,options:v}=this.getUsageByRegistration(u,{inlineOptions:!1});if(A+=`${this.format(r).bold(a)}${I} +`,v.length>0){A+=` +`,A+=`${this.format(r).header("Options")} +`;let x=v.reduce((C,R)=>Math.max(C,R.definition.length),0);A+=` +`;for(let{definition:C,description:R}of v)A+=` ${this.format(r).bold(C.padEnd(x))} ${Do(R,{format:this.format(r),paragraphs:!1})}`}if(h!==""&&(A+=` +`,A+=`${this.format(r).header("Details")} +`,A+=` +`,A+=Do(h,{format:this.format(r),paragraphs:!0})),E.length>0){A+=` +`,A+=`${this.format(r).header("Examples")} +`;for(let[x,C]of E)A+=` +`,A+=Do(x,{format:this.format(r),paragraphs:!1}),A+=`${C.replace(/^/m,` ${this.format(r).bold(a)}`).replace(/\$0/g,this.binaryName)} +`}}else{let{usage:p}=this.getUsageByRegistration(u);A+=`${this.format(r).bold(a)}${p} +`}else{let p=new Map;for(let[v,{index:x}]of this.registrations.entries()){if(typeof v.usage>"u")continue;let C=typeof v.usage.category<"u"?Do(v.usage.category,{format:this.format(r),paragraphs:!1}):null,R=p.get(C);typeof R>"u"&&p.set(C,R=[]);let{usage:L}=this.getUsageByIndex(x);R.push({commandClass:v,usage:L})}let h=Array.from(p.keys()).sort((v,x)=>v===null?-1:x===null?1:v.localeCompare(x,"en",{usage:"sort",caseFirst:"upper"})),E=typeof this.binaryLabel<"u",I=typeof this.binaryVersion<"u";E||I?(E&&I?A+=`${this.format(r).header(`${this.binaryLabel} - ${this.binaryVersion}`)} + +`:E?A+=`${this.format(r).header(`${this.binaryLabel}`)} +`:A+=`${this.format(r).header(`${this.binaryVersion}`)} +`,A+=` ${this.format(r).bold(a)}${this.binaryName} <command> +`):A+=`${this.format(r).bold(a)}${this.binaryName} <command> +`;for(let v of h){let x=p.get(v).slice().sort((R,L)=>R.usage.localeCompare(L.usage,"en",{usage:"sort",caseFirst:"upper"})),C=v!==null?v.trim():"General commands";A+=` +`,A+=`${this.format(r).header(`${C}`)} +`;for(let{commandClass:R,usage:L}of x){let U=R.usage.description||"undocumented";A+=` +`,A+=` ${this.format(r).bold(L)} +`,A+=` ${Do(U,{format:this.format(r),paragraphs:!1})}`}}A+=` +`,A+=Do("You can also print more details about any of these commands by calling them with the `-h,--help` flag right after the command name.",{format:this.format(r),paragraphs:!0})}return A}error(e,r){var o,{colored:a,command:n=(o=e[SV])!==null&&o!==void 0?o:null}=r===void 0?{}:r;(!e||typeof e!="object"||!("stack"in e))&&(e=new Error(`Execution failed with a non-error rejection (rejected value: ${JSON.stringify(e)})`));let u="",A=e.name.replace(/([a-z])([A-Z])/g,"$1 $2");A==="Error"&&(A="Internal Error"),u+=`${this.format(a).error(A)}: ${e.message} +`;let p=e.clipanion;return typeof p<"u"?p.type==="usage"&&(u+=` +`,u+=this.usage(n)):e.stack&&(u+=`${e.stack.replace(/^.*\n/,"")} +`),u}format(e){var r;return((r=e??this.enableColors)!==null&&r!==void 0?r:as.defaultContext.colorDepth>1)?uV:AV}getUsageByRegistration(e,r){let o=this.registrations.get(e);if(typeof o>"u")throw new Error("Assertion failed: Unregistered command");return this.getUsageByIndex(o.index,r)}getUsageByIndex(e,r){return this.builder.getBuilderByIndex(e).usage(r)}};as.defaultContext={env:process.env,stdin:process.stdin,stdout:process.stdout,stderr:process.stderr,colorDepth:IV()}});var uI,FV=Et(()=>{Vp();uI=class extends nt{async execute(){this.context.stdout.write(`${JSON.stringify(this.cli.definitions(),null,2)} +`)}};uI.paths=[["--clipanion=definitions"]]});var AI,RV=Et(()=>{Vp();AI=class extends nt{async execute(){this.context.stdout.write(this.cli.usage())}};AI.paths=[["-h"],["--help"]]});function uS(t={}){return Ko({definition(e,r){var o;e.addProxy({name:(o=t.name)!==null&&o!==void 0?o:r,required:t.required})},transformer(e,r,o){return o.positionals.map(({value:a})=>a)}})}var HT=Et(()=>{Ef()});var fI,TV=Et(()=>{Vp();HT();fI=class extends nt{constructor(){super(...arguments),this.args=uS()}async execute(){this.context.stdout.write(`${JSON.stringify(this.cli.process(this.args).tokens,null,2)} +`)}};fI.paths=[["--clipanion=tokens"]]});var pI,NV=Et(()=>{Vp();pI=class extends nt{async execute(){var e;this.context.stdout.write(`${(e=this.cli.binaryVersion)!==null&&e!==void 0?e:"<unknown>"} +`)}};pI.paths=[["-v"],["--version"]]});var jT={};Vt(jT,{DefinitionsCommand:()=>uI,HelpCommand:()=>AI,TokensCommand:()=>fI,VersionCommand:()=>pI});var LV=Et(()=>{FV();RV();TV();NV()});function OV(t,e,r){let[o,a]=Gu(e,r??{}),{arity:n=1}=a,u=t.split(","),A=new Set(u);return Ko({definition(p){p.addOption({names:u,arity:n,hidden:a?.hidden,description:a?.description,required:a.required})},transformer(p,h,E){let I,v=typeof o<"u"?[...o]:void 0;for(let{name:x,value:C}of E.options)!A.has(x)||(I=x,v=v??[],v.push(C));return typeof v<"u"?nd(I??h,v,a.validator):v}})}var MV=Et(()=>{Ef()});function UV(t,e,r){let[o,a]=Gu(e,r??{}),n=t.split(","),u=new Set(n);return Ko({definition(A){A.addOption({names:n,allowBinding:!1,arity:0,hidden:a.hidden,description:a.description,required:a.required})},transformer(A,p,h){let E=o;for(let{name:I,value:v}of h.options)!u.has(I)||(E=v);return E}})}var _V=Et(()=>{Ef()});function HV(t,e,r){let[o,a]=Gu(e,r??{}),n=t.split(","),u=new Set(n);return Ko({definition(A){A.addOption({names:n,allowBinding:!1,arity:0,hidden:a.hidden,description:a.description,required:a.required})},transformer(A,p,h){let E=o;for(let{name:I,value:v}of h.options)!u.has(I)||(E??(E=0),v?E+=1:E=0);return E}})}var jV=Et(()=>{Ef()});function GV(t={}){return Ko({definition(e,r){var o;e.addRest({name:(o=t.name)!==null&&o!==void 0?o:r,required:t.required})},transformer(e,r,o){let a=u=>{let A=o.positionals[u];return A.extra===el||A.extra===!1&&u<e.arity.leading.length},n=0;for(;n<o.positionals.length&&a(n);)n+=1;return o.positionals.splice(0,n).map(({value:u})=>u)}})}var qV=Et(()=>{lS();Ef()});function Xje(t,e,r){let[o,a]=Gu(e,r??{}),{arity:n=1}=a,u=t.split(","),A=new Set(u);return Ko({definition(p){p.addOption({names:u,arity:a.tolerateBoolean?0:n,hidden:a.hidden,description:a.description,required:a.required})},transformer(p,h,E,I){let v,x=o;typeof a.env<"u"&&I.env[a.env]&&(v=a.env,x=I.env[a.env]);for(let{name:C,value:R}of E.options)!A.has(C)||(v=C,x=R);return typeof x=="string"?nd(v??h,x,a.validator):x}})}function Zje(t={}){let{required:e=!0}=t;return Ko({definition(r,o){var a;r.addPositional({name:(a=t.name)!==null&&a!==void 0?a:o,required:t.required})},transformer(r,o,a){var n;for(let u=0;u<a.positionals.length;++u){if(a.positionals[u].extra===el||e&&a.positionals[u].extra===!0||!e&&a.positionals[u].extra===!1)continue;let[A]=a.positionals.splice(u,1);return nd((n=t.name)!==null&&n!==void 0?n:o,A.value,t.validator)}}})}function YV(t,...e){return typeof t=="string"?Xje(t,...e):Zje(t)}var WV=Et(()=>{lS();Ef()});var ge={};Vt(ge,{Array:()=>OV,Boolean:()=>UV,Counter:()=>HV,Proxy:()=>uS,Rest:()=>GV,String:()=>YV,applyValidator:()=>nd,cleanValidationError:()=>rS,formatError:()=>iI,isOptionSymbol:()=>nI,makeCommandOption:()=>Ko,rerouteArguments:()=>Gu});var KV=Et(()=>{Ef();HT();MV();_V();jV();qV();WV()});var hI={};Vt(hI,{Builtins:()=>jT,Cli:()=>as,Command:()=>nt,Option:()=>ge,UsageError:()=>it,formatMarkdownish:()=>Do,run:()=>xV,runExit:()=>bV});var jt=Et(()=>{tS();kT();Vp();QV();LV();KV()});var VV=_((Pkt,$je)=>{$je.exports={name:"dotenv",version:"16.3.1",description:"Loads environment variables from .env file",main:"lib/main.js",types:"lib/main.d.ts",exports:{".":{types:"./lib/main.d.ts",require:"./lib/main.js",default:"./lib/main.js"},"./config":"./config.js","./config.js":"./config.js","./lib/env-options":"./lib/env-options.js","./lib/env-options.js":"./lib/env-options.js","./lib/cli-options":"./lib/cli-options.js","./lib/cli-options.js":"./lib/cli-options.js","./package.json":"./package.json"},scripts:{"dts-check":"tsc --project tests/types/tsconfig.json",lint:"standard","lint-readme":"standard-markdown",pretest:"npm run lint && npm run dts-check",test:"tap tests/*.js --100 -Rspec",prerelease:"npm test",release:"standard-version"},repository:{type:"git",url:"git://github.com/motdotla/dotenv.git"},funding:"https://github.com/motdotla/dotenv?sponsor=1",keywords:["dotenv","env",".env","environment","variables","config","settings"],readmeFilename:"README.md",license:"BSD-2-Clause",devDependencies:{"@definitelytyped/dtslint":"^0.0.133","@types/node":"^18.11.3",decache:"^4.6.1",sinon:"^14.0.1",standard:"^17.0.0","standard-markdown":"^7.1.0","standard-version":"^9.5.0",tap:"^16.3.0",tar:"^6.1.11",typescript:"^4.8.4"},engines:{node:">=12"},browser:{fs:!1}}});var ZV=_((bkt,Cf)=>{var JV=ve("fs"),qT=ve("path"),eGe=ve("os"),tGe=ve("crypto"),rGe=VV(),YT=rGe.version,nGe=/(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg;function iGe(t){let e={},r=t.toString();r=r.replace(/\r\n?/mg,` +`);let o;for(;(o=nGe.exec(r))!=null;){let a=o[1],n=o[2]||"";n=n.trim();let u=n[0];n=n.replace(/^(['"`])([\s\S]*)\1$/mg,"$2"),u==='"'&&(n=n.replace(/\\n/g,` +`),n=n.replace(/\\r/g,"\r")),e[a]=n}return e}function sGe(t){let e=XV(t),r=bs.configDotenv({path:e});if(!r.parsed)throw new Error(`MISSING_DATA: Cannot parse ${e} for an unknown reason`);let o=zV(t).split(","),a=o.length,n;for(let u=0;u<a;u++)try{let A=o[u].trim(),p=lGe(r,A);n=bs.decrypt(p.ciphertext,p.key);break}catch(A){if(u+1>=a)throw A}return bs.parse(n)}function oGe(t){console.log(`[dotenv@${YT}][INFO] ${t}`)}function aGe(t){console.log(`[dotenv@${YT}][WARN] ${t}`)}function GT(t){console.log(`[dotenv@${YT}][DEBUG] ${t}`)}function zV(t){return t&&t.DOTENV_KEY&&t.DOTENV_KEY.length>0?t.DOTENV_KEY:process.env.DOTENV_KEY&&process.env.DOTENV_KEY.length>0?process.env.DOTENV_KEY:""}function lGe(t,e){let r;try{r=new URL(e)}catch(A){throw A.code==="ERR_INVALID_URL"?new Error("INVALID_DOTENV_KEY: Wrong format. Must be in valid uri format like dotenv://:key_1234@dotenv.org/vault/.env.vault?environment=development"):A}let o=r.password;if(!o)throw new Error("INVALID_DOTENV_KEY: Missing key part");let a=r.searchParams.get("environment");if(!a)throw new Error("INVALID_DOTENV_KEY: Missing environment part");let n=`DOTENV_VAULT_${a.toUpperCase()}`,u=t.parsed[n];if(!u)throw new Error(`NOT_FOUND_DOTENV_ENVIRONMENT: Cannot locate environment ${n} in your .env.vault file.`);return{ciphertext:u,key:o}}function XV(t){let e=qT.resolve(process.cwd(),".env");return t&&t.path&&t.path.length>0&&(e=t.path),e.endsWith(".vault")?e:`${e}.vault`}function cGe(t){return t[0]==="~"?qT.join(eGe.homedir(),t.slice(1)):t}function uGe(t){oGe("Loading env from encrypted .env.vault");let e=bs._parseVault(t),r=process.env;return t&&t.processEnv!=null&&(r=t.processEnv),bs.populate(r,e,t),{parsed:e}}function AGe(t){let e=qT.resolve(process.cwd(),".env"),r="utf8",o=Boolean(t&&t.debug);t&&(t.path!=null&&(e=cGe(t.path)),t.encoding!=null&&(r=t.encoding));try{let a=bs.parse(JV.readFileSync(e,{encoding:r})),n=process.env;return t&&t.processEnv!=null&&(n=t.processEnv),bs.populate(n,a,t),{parsed:a}}catch(a){return o&>(`Failed to load ${e} ${a.message}`),{error:a}}}function fGe(t){let e=XV(t);return zV(t).length===0?bs.configDotenv(t):JV.existsSync(e)?bs._configVault(t):(aGe(`You set DOTENV_KEY but you are missing a .env.vault file at ${e}. Did you forget to build it?`),bs.configDotenv(t))}function pGe(t,e){let r=Buffer.from(e.slice(-64),"hex"),o=Buffer.from(t,"base64"),a=o.slice(0,12),n=o.slice(-16);o=o.slice(12,-16);try{let u=tGe.createDecipheriv("aes-256-gcm",r,a);return u.setAuthTag(n),`${u.update(o)}${u.final()}`}catch(u){let A=u instanceof RangeError,p=u.message==="Invalid key length",h=u.message==="Unsupported state or unable to authenticate data";if(A||p){let E="INVALID_DOTENV_KEY: It must be 64 characters long (or more)";throw new Error(E)}else if(h){let E="DECRYPTION_FAILED: Please check your DOTENV_KEY";throw new Error(E)}else throw console.error("Error: ",u.code),console.error("Error: ",u.message),u}}function hGe(t,e,r={}){let o=Boolean(r&&r.debug),a=Boolean(r&&r.override);if(typeof e!="object")throw new Error("OBJECT_REQUIRED: Please check the processEnv argument being passed to populate");for(let n of Object.keys(e))Object.prototype.hasOwnProperty.call(t,n)?(a===!0&&(t[n]=e[n]),o&>(a===!0?`"${n}" is already defined and WAS overwritten`:`"${n}" is already defined and was NOT overwritten`)):t[n]=e[n]}var bs={configDotenv:AGe,_configVault:uGe,_parseVault:sGe,config:fGe,decrypt:pGe,parse:iGe,populate:hGe};Cf.exports.configDotenv=bs.configDotenv;Cf.exports._configVault=bs._configVault;Cf.exports._parseVault=bs._parseVault;Cf.exports.config=bs.config;Cf.exports.decrypt=bs.decrypt;Cf.exports.parse=bs.parse;Cf.exports.populate=bs.populate;Cf.exports=bs});var eJ=_((xkt,$V)=>{"use strict";$V.exports=(t,...e)=>new Promise(r=>{r(t(...e))})});var id=_((kkt,WT)=>{"use strict";var gGe=eJ(),tJ=t=>{if(t<1)throw new TypeError("Expected `concurrency` to be a number from 1 and up");let e=[],r=0,o=()=>{r--,e.length>0&&e.shift()()},a=(A,p,...h)=>{r++;let E=gGe(A,...h);p(E),E.then(o,o)},n=(A,p,...h)=>{r<t?a(A,p,...h):e.push(a.bind(null,A,p,...h))},u=(A,...p)=>new Promise(h=>n(A,h,...p));return Object.defineProperties(u,{activeCount:{get:()=>r},pendingCount:{get:()=>e.length}}),u};WT.exports=tJ;WT.exports.default=tJ});function Wu(t){return`YN${t.toString(10).padStart(4,"0")}`}function AS(t){let e=Number(t.slice(2));if(typeof wr[e]>"u")throw new Error(`Unknown message name: "${t}"`);return e}var wr,fS=Et(()=>{wr=(Oe=>(Oe[Oe.UNNAMED=0]="UNNAMED",Oe[Oe.EXCEPTION=1]="EXCEPTION",Oe[Oe.MISSING_PEER_DEPENDENCY=2]="MISSING_PEER_DEPENDENCY",Oe[Oe.CYCLIC_DEPENDENCIES=3]="CYCLIC_DEPENDENCIES",Oe[Oe.DISABLED_BUILD_SCRIPTS=4]="DISABLED_BUILD_SCRIPTS",Oe[Oe.BUILD_DISABLED=5]="BUILD_DISABLED",Oe[Oe.SOFT_LINK_BUILD=6]="SOFT_LINK_BUILD",Oe[Oe.MUST_BUILD=7]="MUST_BUILD",Oe[Oe.MUST_REBUILD=8]="MUST_REBUILD",Oe[Oe.BUILD_FAILED=9]="BUILD_FAILED",Oe[Oe.RESOLVER_NOT_FOUND=10]="RESOLVER_NOT_FOUND",Oe[Oe.FETCHER_NOT_FOUND=11]="FETCHER_NOT_FOUND",Oe[Oe.LINKER_NOT_FOUND=12]="LINKER_NOT_FOUND",Oe[Oe.FETCH_NOT_CACHED=13]="FETCH_NOT_CACHED",Oe[Oe.YARN_IMPORT_FAILED=14]="YARN_IMPORT_FAILED",Oe[Oe.REMOTE_INVALID=15]="REMOTE_INVALID",Oe[Oe.REMOTE_NOT_FOUND=16]="REMOTE_NOT_FOUND",Oe[Oe.RESOLUTION_PACK=17]="RESOLUTION_PACK",Oe[Oe.CACHE_CHECKSUM_MISMATCH=18]="CACHE_CHECKSUM_MISMATCH",Oe[Oe.UNUSED_CACHE_ENTRY=19]="UNUSED_CACHE_ENTRY",Oe[Oe.MISSING_LOCKFILE_ENTRY=20]="MISSING_LOCKFILE_ENTRY",Oe[Oe.WORKSPACE_NOT_FOUND=21]="WORKSPACE_NOT_FOUND",Oe[Oe.TOO_MANY_MATCHING_WORKSPACES=22]="TOO_MANY_MATCHING_WORKSPACES",Oe[Oe.CONSTRAINTS_MISSING_DEPENDENCY=23]="CONSTRAINTS_MISSING_DEPENDENCY",Oe[Oe.CONSTRAINTS_INCOMPATIBLE_DEPENDENCY=24]="CONSTRAINTS_INCOMPATIBLE_DEPENDENCY",Oe[Oe.CONSTRAINTS_EXTRANEOUS_DEPENDENCY=25]="CONSTRAINTS_EXTRANEOUS_DEPENDENCY",Oe[Oe.CONSTRAINTS_INVALID_DEPENDENCY=26]="CONSTRAINTS_INVALID_DEPENDENCY",Oe[Oe.CANT_SUGGEST_RESOLUTIONS=27]="CANT_SUGGEST_RESOLUTIONS",Oe[Oe.FROZEN_LOCKFILE_EXCEPTION=28]="FROZEN_LOCKFILE_EXCEPTION",Oe[Oe.CROSS_DRIVE_VIRTUAL_LOCAL=29]="CROSS_DRIVE_VIRTUAL_LOCAL",Oe[Oe.FETCH_FAILED=30]="FETCH_FAILED",Oe[Oe.DANGEROUS_NODE_MODULES=31]="DANGEROUS_NODE_MODULES",Oe[Oe.NODE_GYP_INJECTED=32]="NODE_GYP_INJECTED",Oe[Oe.AUTHENTICATION_NOT_FOUND=33]="AUTHENTICATION_NOT_FOUND",Oe[Oe.INVALID_CONFIGURATION_KEY=34]="INVALID_CONFIGURATION_KEY",Oe[Oe.NETWORK_ERROR=35]="NETWORK_ERROR",Oe[Oe.LIFECYCLE_SCRIPT=36]="LIFECYCLE_SCRIPT",Oe[Oe.CONSTRAINTS_MISSING_FIELD=37]="CONSTRAINTS_MISSING_FIELD",Oe[Oe.CONSTRAINTS_INCOMPATIBLE_FIELD=38]="CONSTRAINTS_INCOMPATIBLE_FIELD",Oe[Oe.CONSTRAINTS_EXTRANEOUS_FIELD=39]="CONSTRAINTS_EXTRANEOUS_FIELD",Oe[Oe.CONSTRAINTS_INVALID_FIELD=40]="CONSTRAINTS_INVALID_FIELD",Oe[Oe.AUTHENTICATION_INVALID=41]="AUTHENTICATION_INVALID",Oe[Oe.PROLOG_UNKNOWN_ERROR=42]="PROLOG_UNKNOWN_ERROR",Oe[Oe.PROLOG_SYNTAX_ERROR=43]="PROLOG_SYNTAX_ERROR",Oe[Oe.PROLOG_EXISTENCE_ERROR=44]="PROLOG_EXISTENCE_ERROR",Oe[Oe.STACK_OVERFLOW_RESOLUTION=45]="STACK_OVERFLOW_RESOLUTION",Oe[Oe.AUTOMERGE_FAILED_TO_PARSE=46]="AUTOMERGE_FAILED_TO_PARSE",Oe[Oe.AUTOMERGE_IMMUTABLE=47]="AUTOMERGE_IMMUTABLE",Oe[Oe.AUTOMERGE_SUCCESS=48]="AUTOMERGE_SUCCESS",Oe[Oe.AUTOMERGE_REQUIRED=49]="AUTOMERGE_REQUIRED",Oe[Oe.DEPRECATED_CLI_SETTINGS=50]="DEPRECATED_CLI_SETTINGS",Oe[Oe.PLUGIN_NAME_NOT_FOUND=51]="PLUGIN_NAME_NOT_FOUND",Oe[Oe.INVALID_PLUGIN_REFERENCE=52]="INVALID_PLUGIN_REFERENCE",Oe[Oe.CONSTRAINTS_AMBIGUITY=53]="CONSTRAINTS_AMBIGUITY",Oe[Oe.CACHE_OUTSIDE_PROJECT=54]="CACHE_OUTSIDE_PROJECT",Oe[Oe.IMMUTABLE_INSTALL=55]="IMMUTABLE_INSTALL",Oe[Oe.IMMUTABLE_CACHE=56]="IMMUTABLE_CACHE",Oe[Oe.INVALID_MANIFEST=57]="INVALID_MANIFEST",Oe[Oe.PACKAGE_PREPARATION_FAILED=58]="PACKAGE_PREPARATION_FAILED",Oe[Oe.INVALID_RANGE_PEER_DEPENDENCY=59]="INVALID_RANGE_PEER_DEPENDENCY",Oe[Oe.INCOMPATIBLE_PEER_DEPENDENCY=60]="INCOMPATIBLE_PEER_DEPENDENCY",Oe[Oe.DEPRECATED_PACKAGE=61]="DEPRECATED_PACKAGE",Oe[Oe.INCOMPATIBLE_OS=62]="INCOMPATIBLE_OS",Oe[Oe.INCOMPATIBLE_CPU=63]="INCOMPATIBLE_CPU",Oe[Oe.FROZEN_ARTIFACT_EXCEPTION=64]="FROZEN_ARTIFACT_EXCEPTION",Oe[Oe.TELEMETRY_NOTICE=65]="TELEMETRY_NOTICE",Oe[Oe.PATCH_HUNK_FAILED=66]="PATCH_HUNK_FAILED",Oe[Oe.INVALID_CONFIGURATION_VALUE=67]="INVALID_CONFIGURATION_VALUE",Oe[Oe.UNUSED_PACKAGE_EXTENSION=68]="UNUSED_PACKAGE_EXTENSION",Oe[Oe.REDUNDANT_PACKAGE_EXTENSION=69]="REDUNDANT_PACKAGE_EXTENSION",Oe[Oe.AUTO_NM_SUCCESS=70]="AUTO_NM_SUCCESS",Oe[Oe.NM_CANT_INSTALL_EXTERNAL_SOFT_LINK=71]="NM_CANT_INSTALL_EXTERNAL_SOFT_LINK",Oe[Oe.NM_PRESERVE_SYMLINKS_REQUIRED=72]="NM_PRESERVE_SYMLINKS_REQUIRED",Oe[Oe.UPDATE_LOCKFILE_ONLY_SKIP_LINK=73]="UPDATE_LOCKFILE_ONLY_SKIP_LINK",Oe[Oe.NM_HARDLINKS_MODE_DOWNGRADED=74]="NM_HARDLINKS_MODE_DOWNGRADED",Oe[Oe.PROLOG_INSTANTIATION_ERROR=75]="PROLOG_INSTANTIATION_ERROR",Oe[Oe.INCOMPATIBLE_ARCHITECTURE=76]="INCOMPATIBLE_ARCHITECTURE",Oe[Oe.GHOST_ARCHITECTURE=77]="GHOST_ARCHITECTURE",Oe[Oe.RESOLUTION_MISMATCH=78]="RESOLUTION_MISMATCH",Oe[Oe.PROLOG_LIMIT_EXCEEDED=79]="PROLOG_LIMIT_EXCEEDED",Oe[Oe.NETWORK_DISABLED=80]="NETWORK_DISABLED",Oe[Oe.NETWORK_UNSAFE_HTTP=81]="NETWORK_UNSAFE_HTTP",Oe[Oe.RESOLUTION_FAILED=82]="RESOLUTION_FAILED",Oe[Oe.AUTOMERGE_GIT_ERROR=83]="AUTOMERGE_GIT_ERROR",Oe[Oe.CONSTRAINTS_CHECK_FAILED=84]="CONSTRAINTS_CHECK_FAILED",Oe[Oe.UPDATED_RESOLUTION_RECORD=85]="UPDATED_RESOLUTION_RECORD",Oe[Oe.EXPLAIN_PEER_DEPENDENCIES_CTA=86]="EXPLAIN_PEER_DEPENDENCIES_CTA",Oe[Oe.MIGRATION_SUCCESS=87]="MIGRATION_SUCCESS",Oe[Oe.VERSION_NOTICE=88]="VERSION_NOTICE",Oe[Oe.TIPS_NOTICE=89]="TIPS_NOTICE",Oe[Oe.OFFLINE_MODE_ENABLED=90]="OFFLINE_MODE_ENABLED",Oe))(wr||{})});var gI=_((Fkt,rJ)=>{var dGe="2.0.0",mGe=Number.MAX_SAFE_INTEGER||9007199254740991,yGe=16,EGe=256-6,CGe=["major","premajor","minor","preminor","patch","prepatch","prerelease"];rJ.exports={MAX_LENGTH:256,MAX_SAFE_COMPONENT_LENGTH:yGe,MAX_SAFE_BUILD_LENGTH:EGe,MAX_SAFE_INTEGER:mGe,RELEASE_TYPES:CGe,SEMVER_SPEC_VERSION:dGe,FLAG_INCLUDE_PRERELEASE:1,FLAG_LOOSE:2}});var dI=_((Rkt,nJ)=>{var wGe=typeof process=="object"&&process.env&&process.env.NODE_DEBUG&&/\bsemver\b/i.test(process.env.NODE_DEBUG)?(...t)=>console.error("SEMVER",...t):()=>{};nJ.exports=wGe});var vy=_((wf,iJ)=>{var{MAX_SAFE_COMPONENT_LENGTH:KT,MAX_SAFE_BUILD_LENGTH:IGe,MAX_LENGTH:BGe}=gI(),vGe=dI();wf=iJ.exports={};var DGe=wf.re=[],SGe=wf.safeRe=[],lr=wf.src=[],cr=wf.t={},PGe=0,VT="[a-zA-Z0-9-]",bGe=[["\\s",1],["\\d",BGe],[VT,IGe]],xGe=t=>{for(let[e,r]of bGe)t=t.split(`${e}*`).join(`${e}{0,${r}}`).split(`${e}+`).join(`${e}{1,${r}}`);return t},Jr=(t,e,r)=>{let o=xGe(e),a=PGe++;vGe(t,a,e),cr[t]=a,lr[a]=e,DGe[a]=new RegExp(e,r?"g":void 0),SGe[a]=new RegExp(o,r?"g":void 0)};Jr("NUMERICIDENTIFIER","0|[1-9]\\d*");Jr("NUMERICIDENTIFIERLOOSE","\\d+");Jr("NONNUMERICIDENTIFIER",`\\d*[a-zA-Z-]${VT}*`);Jr("MAINVERSION",`(${lr[cr.NUMERICIDENTIFIER]})\\.(${lr[cr.NUMERICIDENTIFIER]})\\.(${lr[cr.NUMERICIDENTIFIER]})`);Jr("MAINVERSIONLOOSE",`(${lr[cr.NUMERICIDENTIFIERLOOSE]})\\.(${lr[cr.NUMERICIDENTIFIERLOOSE]})\\.(${lr[cr.NUMERICIDENTIFIERLOOSE]})`);Jr("PRERELEASEIDENTIFIER",`(?:${lr[cr.NUMERICIDENTIFIER]}|${lr[cr.NONNUMERICIDENTIFIER]})`);Jr("PRERELEASEIDENTIFIERLOOSE",`(?:${lr[cr.NUMERICIDENTIFIERLOOSE]}|${lr[cr.NONNUMERICIDENTIFIER]})`);Jr("PRERELEASE",`(?:-(${lr[cr.PRERELEASEIDENTIFIER]}(?:\\.${lr[cr.PRERELEASEIDENTIFIER]})*))`);Jr("PRERELEASELOOSE",`(?:-?(${lr[cr.PRERELEASEIDENTIFIERLOOSE]}(?:\\.${lr[cr.PRERELEASEIDENTIFIERLOOSE]})*))`);Jr("BUILDIDENTIFIER",`${VT}+`);Jr("BUILD",`(?:\\+(${lr[cr.BUILDIDENTIFIER]}(?:\\.${lr[cr.BUILDIDENTIFIER]})*))`);Jr("FULLPLAIN",`v?${lr[cr.MAINVERSION]}${lr[cr.PRERELEASE]}?${lr[cr.BUILD]}?`);Jr("FULL",`^${lr[cr.FULLPLAIN]}$`);Jr("LOOSEPLAIN",`[v=\\s]*${lr[cr.MAINVERSIONLOOSE]}${lr[cr.PRERELEASELOOSE]}?${lr[cr.BUILD]}?`);Jr("LOOSE",`^${lr[cr.LOOSEPLAIN]}$`);Jr("GTLT","((?:<|>)?=?)");Jr("XRANGEIDENTIFIERLOOSE",`${lr[cr.NUMERICIDENTIFIERLOOSE]}|x|X|\\*`);Jr("XRANGEIDENTIFIER",`${lr[cr.NUMERICIDENTIFIER]}|x|X|\\*`);Jr("XRANGEPLAIN",`[v=\\s]*(${lr[cr.XRANGEIDENTIFIER]})(?:\\.(${lr[cr.XRANGEIDENTIFIER]})(?:\\.(${lr[cr.XRANGEIDENTIFIER]})(?:${lr[cr.PRERELEASE]})?${lr[cr.BUILD]}?)?)?`);Jr("XRANGEPLAINLOOSE",`[v=\\s]*(${lr[cr.XRANGEIDENTIFIERLOOSE]})(?:\\.(${lr[cr.XRANGEIDENTIFIERLOOSE]})(?:\\.(${lr[cr.XRANGEIDENTIFIERLOOSE]})(?:${lr[cr.PRERELEASELOOSE]})?${lr[cr.BUILD]}?)?)?`);Jr("XRANGE",`^${lr[cr.GTLT]}\\s*${lr[cr.XRANGEPLAIN]}$`);Jr("XRANGELOOSE",`^${lr[cr.GTLT]}\\s*${lr[cr.XRANGEPLAINLOOSE]}$`);Jr("COERCE",`(^|[^\\d])(\\d{1,${KT}})(?:\\.(\\d{1,${KT}}))?(?:\\.(\\d{1,${KT}}))?(?:$|[^\\d])`);Jr("COERCERTL",lr[cr.COERCE],!0);Jr("LONETILDE","(?:~>?)");Jr("TILDETRIM",`(\\s*)${lr[cr.LONETILDE]}\\s+`,!0);wf.tildeTrimReplace="$1~";Jr("TILDE",`^${lr[cr.LONETILDE]}${lr[cr.XRANGEPLAIN]}$`);Jr("TILDELOOSE",`^${lr[cr.LONETILDE]}${lr[cr.XRANGEPLAINLOOSE]}$`);Jr("LONECARET","(?:\\^)");Jr("CARETTRIM",`(\\s*)${lr[cr.LONECARET]}\\s+`,!0);wf.caretTrimReplace="$1^";Jr("CARET",`^${lr[cr.LONECARET]}${lr[cr.XRANGEPLAIN]}$`);Jr("CARETLOOSE",`^${lr[cr.LONECARET]}${lr[cr.XRANGEPLAINLOOSE]}$`);Jr("COMPARATORLOOSE",`^${lr[cr.GTLT]}\\s*(${lr[cr.LOOSEPLAIN]})$|^$`);Jr("COMPARATOR",`^${lr[cr.GTLT]}\\s*(${lr[cr.FULLPLAIN]})$|^$`);Jr("COMPARATORTRIM",`(\\s*)${lr[cr.GTLT]}\\s*(${lr[cr.LOOSEPLAIN]}|${lr[cr.XRANGEPLAIN]})`,!0);wf.comparatorTrimReplace="$1$2$3";Jr("HYPHENRANGE",`^\\s*(${lr[cr.XRANGEPLAIN]})\\s+-\\s+(${lr[cr.XRANGEPLAIN]})\\s*$`);Jr("HYPHENRANGELOOSE",`^\\s*(${lr[cr.XRANGEPLAINLOOSE]})\\s+-\\s+(${lr[cr.XRANGEPLAINLOOSE]})\\s*$`);Jr("STAR","(<|>)?=?\\s*\\*");Jr("GTE0","^\\s*>=\\s*0\\.0\\.0\\s*$");Jr("GTE0PRE","^\\s*>=\\s*0\\.0\\.0-0\\s*$")});var pS=_((Tkt,sJ)=>{var kGe=Object.freeze({loose:!0}),QGe=Object.freeze({}),FGe=t=>t?typeof t!="object"?kGe:t:QGe;sJ.exports=FGe});var JT=_((Nkt,lJ)=>{var oJ=/^[0-9]+$/,aJ=(t,e)=>{let r=oJ.test(t),o=oJ.test(e);return r&&o&&(t=+t,e=+e),t===e?0:r&&!o?-1:o&&!r?1:t<e?-1:1},RGe=(t,e)=>aJ(e,t);lJ.exports={compareIdentifiers:aJ,rcompareIdentifiers:RGe}});var So=_((Lkt,fJ)=>{var hS=dI(),{MAX_LENGTH:cJ,MAX_SAFE_INTEGER:gS}=gI(),{safeRe:uJ,t:AJ}=vy(),TGe=pS(),{compareIdentifiers:Dy}=JT(),tl=class{constructor(e,r){if(r=TGe(r),e instanceof tl){if(e.loose===!!r.loose&&e.includePrerelease===!!r.includePrerelease)return e;e=e.version}else if(typeof e!="string")throw new TypeError(`Invalid version. Must be a string. Got type "${typeof e}".`);if(e.length>cJ)throw new TypeError(`version is longer than ${cJ} characters`);hS("SemVer",e,r),this.options=r,this.loose=!!r.loose,this.includePrerelease=!!r.includePrerelease;let o=e.trim().match(r.loose?uJ[AJ.LOOSE]:uJ[AJ.FULL]);if(!o)throw new TypeError(`Invalid Version: ${e}`);if(this.raw=e,this.major=+o[1],this.minor=+o[2],this.patch=+o[3],this.major>gS||this.major<0)throw new TypeError("Invalid major version");if(this.minor>gS||this.minor<0)throw new TypeError("Invalid minor version");if(this.patch>gS||this.patch<0)throw new TypeError("Invalid patch version");o[4]?this.prerelease=o[4].split(".").map(a=>{if(/^[0-9]+$/.test(a)){let n=+a;if(n>=0&&n<gS)return n}return a}):this.prerelease=[],this.build=o[5]?o[5].split("."):[],this.format()}format(){return this.version=`${this.major}.${this.minor}.${this.patch}`,this.prerelease.length&&(this.version+=`-${this.prerelease.join(".")}`),this.version}toString(){return this.version}compare(e){if(hS("SemVer.compare",this.version,this.options,e),!(e instanceof tl)){if(typeof e=="string"&&e===this.version)return 0;e=new tl(e,this.options)}return e.version===this.version?0:this.compareMain(e)||this.comparePre(e)}compareMain(e){return e instanceof tl||(e=new tl(e,this.options)),Dy(this.major,e.major)||Dy(this.minor,e.minor)||Dy(this.patch,e.patch)}comparePre(e){if(e instanceof tl||(e=new tl(e,this.options)),this.prerelease.length&&!e.prerelease.length)return-1;if(!this.prerelease.length&&e.prerelease.length)return 1;if(!this.prerelease.length&&!e.prerelease.length)return 0;let r=0;do{let o=this.prerelease[r],a=e.prerelease[r];if(hS("prerelease compare",r,o,a),o===void 0&&a===void 0)return 0;if(a===void 0)return 1;if(o===void 0)return-1;if(o===a)continue;return Dy(o,a)}while(++r)}compareBuild(e){e instanceof tl||(e=new tl(e,this.options));let r=0;do{let o=this.build[r],a=e.build[r];if(hS("prerelease compare",r,o,a),o===void 0&&a===void 0)return 0;if(a===void 0)return 1;if(o===void 0)return-1;if(o===a)continue;return Dy(o,a)}while(++r)}inc(e,r,o){switch(e){case"premajor":this.prerelease.length=0,this.patch=0,this.minor=0,this.major++,this.inc("pre",r,o);break;case"preminor":this.prerelease.length=0,this.patch=0,this.minor++,this.inc("pre",r,o);break;case"prepatch":this.prerelease.length=0,this.inc("patch",r,o),this.inc("pre",r,o);break;case"prerelease":this.prerelease.length===0&&this.inc("patch",r,o),this.inc("pre",r,o);break;case"major":(this.minor!==0||this.patch!==0||this.prerelease.length===0)&&this.major++,this.minor=0,this.patch=0,this.prerelease=[];break;case"minor":(this.patch!==0||this.prerelease.length===0)&&this.minor++,this.patch=0,this.prerelease=[];break;case"patch":this.prerelease.length===0&&this.patch++,this.prerelease=[];break;case"pre":{let a=Number(o)?1:0;if(!r&&o===!1)throw new Error("invalid increment argument: identifier is empty");if(this.prerelease.length===0)this.prerelease=[a];else{let n=this.prerelease.length;for(;--n>=0;)typeof this.prerelease[n]=="number"&&(this.prerelease[n]++,n=-2);if(n===-1){if(r===this.prerelease.join(".")&&o===!1)throw new Error("invalid increment argument: identifier already exists");this.prerelease.push(a)}}if(r){let n=[r,a];o===!1&&(n=[r]),Dy(this.prerelease[0],r)===0?isNaN(this.prerelease[1])&&(this.prerelease=n):this.prerelease=n}break}default:throw new Error(`invalid increment argument: ${e}`)}return this.raw=this.format(),this.build.length&&(this.raw+=`+${this.build.join(".")}`),this}};fJ.exports=tl});var sd=_((Okt,hJ)=>{var pJ=So(),NGe=(t,e,r=!1)=>{if(t instanceof pJ)return t;try{return new pJ(t,e)}catch(o){if(!r)return null;throw o}};hJ.exports=NGe});var dJ=_((Mkt,gJ)=>{var LGe=sd(),OGe=(t,e)=>{let r=LGe(t,e);return r?r.version:null};gJ.exports=OGe});var yJ=_((Ukt,mJ)=>{var MGe=sd(),UGe=(t,e)=>{let r=MGe(t.trim().replace(/^[=v]+/,""),e);return r?r.version:null};mJ.exports=UGe});var wJ=_((_kt,CJ)=>{var EJ=So(),_Ge=(t,e,r,o,a)=>{typeof r=="string"&&(a=o,o=r,r=void 0);try{return new EJ(t instanceof EJ?t.version:t,r).inc(e,o,a).version}catch{return null}};CJ.exports=_Ge});var vJ=_((Hkt,BJ)=>{var IJ=sd(),HGe=(t,e)=>{let r=IJ(t,null,!0),o=IJ(e,null,!0),a=r.compare(o);if(a===0)return null;let n=a>0,u=n?r:o,A=n?o:r,p=!!u.prerelease.length;if(!!A.prerelease.length&&!p)return!A.patch&&!A.minor?"major":u.patch?"patch":u.minor?"minor":"major";let E=p?"pre":"";return r.major!==o.major?E+"major":r.minor!==o.minor?E+"minor":r.patch!==o.patch?E+"patch":"prerelease"};BJ.exports=HGe});var SJ=_((jkt,DJ)=>{var jGe=So(),GGe=(t,e)=>new jGe(t,e).major;DJ.exports=GGe});var bJ=_((Gkt,PJ)=>{var qGe=So(),YGe=(t,e)=>new qGe(t,e).minor;PJ.exports=YGe});var kJ=_((qkt,xJ)=>{var WGe=So(),KGe=(t,e)=>new WGe(t,e).patch;xJ.exports=KGe});var FJ=_((Ykt,QJ)=>{var VGe=sd(),JGe=(t,e)=>{let r=VGe(t,e);return r&&r.prerelease.length?r.prerelease:null};QJ.exports=JGe});var Ll=_((Wkt,TJ)=>{var RJ=So(),zGe=(t,e,r)=>new RJ(t,r).compare(new RJ(e,r));TJ.exports=zGe});var LJ=_((Kkt,NJ)=>{var XGe=Ll(),ZGe=(t,e,r)=>XGe(e,t,r);NJ.exports=ZGe});var MJ=_((Vkt,OJ)=>{var $Ge=Ll(),eqe=(t,e)=>$Ge(t,e,!0);OJ.exports=eqe});var dS=_((Jkt,_J)=>{var UJ=So(),tqe=(t,e,r)=>{let o=new UJ(t,r),a=new UJ(e,r);return o.compare(a)||o.compareBuild(a)};_J.exports=tqe});var jJ=_((zkt,HJ)=>{var rqe=dS(),nqe=(t,e)=>t.sort((r,o)=>rqe(r,o,e));HJ.exports=nqe});var qJ=_((Xkt,GJ)=>{var iqe=dS(),sqe=(t,e)=>t.sort((r,o)=>iqe(o,r,e));GJ.exports=sqe});var mI=_((Zkt,YJ)=>{var oqe=Ll(),aqe=(t,e,r)=>oqe(t,e,r)>0;YJ.exports=aqe});var mS=_(($kt,WJ)=>{var lqe=Ll(),cqe=(t,e,r)=>lqe(t,e,r)<0;WJ.exports=cqe});var zT=_((eQt,KJ)=>{var uqe=Ll(),Aqe=(t,e,r)=>uqe(t,e,r)===0;KJ.exports=Aqe});var XT=_((tQt,VJ)=>{var fqe=Ll(),pqe=(t,e,r)=>fqe(t,e,r)!==0;VJ.exports=pqe});var yS=_((rQt,JJ)=>{var hqe=Ll(),gqe=(t,e,r)=>hqe(t,e,r)>=0;JJ.exports=gqe});var ES=_((nQt,zJ)=>{var dqe=Ll(),mqe=(t,e,r)=>dqe(t,e,r)<=0;zJ.exports=mqe});var ZT=_((iQt,XJ)=>{var yqe=zT(),Eqe=XT(),Cqe=mI(),wqe=yS(),Iqe=mS(),Bqe=ES(),vqe=(t,e,r,o)=>{switch(e){case"===":return typeof t=="object"&&(t=t.version),typeof r=="object"&&(r=r.version),t===r;case"!==":return typeof t=="object"&&(t=t.version),typeof r=="object"&&(r=r.version),t!==r;case"":case"=":case"==":return yqe(t,r,o);case"!=":return Eqe(t,r,o);case">":return Cqe(t,r,o);case">=":return wqe(t,r,o);case"<":return Iqe(t,r,o);case"<=":return Bqe(t,r,o);default:throw new TypeError(`Invalid operator: ${e}`)}};XJ.exports=vqe});var $J=_((sQt,ZJ)=>{var Dqe=So(),Sqe=sd(),{safeRe:CS,t:wS}=vy(),Pqe=(t,e)=>{if(t instanceof Dqe)return t;if(typeof t=="number"&&(t=String(t)),typeof t!="string")return null;e=e||{};let r=null;if(!e.rtl)r=t.match(CS[wS.COERCE]);else{let o;for(;(o=CS[wS.COERCERTL].exec(t))&&(!r||r.index+r[0].length!==t.length);)(!r||o.index+o[0].length!==r.index+r[0].length)&&(r=o),CS[wS.COERCERTL].lastIndex=o.index+o[1].length+o[2].length;CS[wS.COERCERTL].lastIndex=-1}return r===null?null:Sqe(`${r[2]}.${r[3]||"0"}.${r[4]||"0"}`,e)};ZJ.exports=Pqe});var tz=_((oQt,ez)=>{"use strict";ez.exports=function(t){t.prototype[Symbol.iterator]=function*(){for(let e=this.head;e;e=e.next)yield e.value}}});var IS=_((aQt,rz)=>{"use strict";rz.exports=Cn;Cn.Node=od;Cn.create=Cn;function Cn(t){var e=this;if(e instanceof Cn||(e=new Cn),e.tail=null,e.head=null,e.length=0,t&&typeof t.forEach=="function")t.forEach(function(a){e.push(a)});else if(arguments.length>0)for(var r=0,o=arguments.length;r<o;r++)e.push(arguments[r]);return e}Cn.prototype.removeNode=function(t){if(t.list!==this)throw new Error("removing node which does not belong to this list");var e=t.next,r=t.prev;return e&&(e.prev=r),r&&(r.next=e),t===this.head&&(this.head=e),t===this.tail&&(this.tail=r),t.list.length--,t.next=null,t.prev=null,t.list=null,e};Cn.prototype.unshiftNode=function(t){if(t!==this.head){t.list&&t.list.removeNode(t);var e=this.head;t.list=this,t.next=e,e&&(e.prev=t),this.head=t,this.tail||(this.tail=t),this.length++}};Cn.prototype.pushNode=function(t){if(t!==this.tail){t.list&&t.list.removeNode(t);var e=this.tail;t.list=this,t.prev=e,e&&(e.next=t),this.tail=t,this.head||(this.head=t),this.length++}};Cn.prototype.push=function(){for(var t=0,e=arguments.length;t<e;t++)xqe(this,arguments[t]);return this.length};Cn.prototype.unshift=function(){for(var t=0,e=arguments.length;t<e;t++)kqe(this,arguments[t]);return this.length};Cn.prototype.pop=function(){if(!!this.tail){var t=this.tail.value;return this.tail=this.tail.prev,this.tail?this.tail.next=null:this.head=null,this.length--,t}};Cn.prototype.shift=function(){if(!!this.head){var t=this.head.value;return this.head=this.head.next,this.head?this.head.prev=null:this.tail=null,this.length--,t}};Cn.prototype.forEach=function(t,e){e=e||this;for(var r=this.head,o=0;r!==null;o++)t.call(e,r.value,o,this),r=r.next};Cn.prototype.forEachReverse=function(t,e){e=e||this;for(var r=this.tail,o=this.length-1;r!==null;o--)t.call(e,r.value,o,this),r=r.prev};Cn.prototype.get=function(t){for(var e=0,r=this.head;r!==null&&e<t;e++)r=r.next;if(e===t&&r!==null)return r.value};Cn.prototype.getReverse=function(t){for(var e=0,r=this.tail;r!==null&&e<t;e++)r=r.prev;if(e===t&&r!==null)return r.value};Cn.prototype.map=function(t,e){e=e||this;for(var r=new Cn,o=this.head;o!==null;)r.push(t.call(e,o.value,this)),o=o.next;return r};Cn.prototype.mapReverse=function(t,e){e=e||this;for(var r=new Cn,o=this.tail;o!==null;)r.push(t.call(e,o.value,this)),o=o.prev;return r};Cn.prototype.reduce=function(t,e){var r,o=this.head;if(arguments.length>1)r=e;else if(this.head)o=this.head.next,r=this.head.value;else throw new TypeError("Reduce of empty list with no initial value");for(var a=0;o!==null;a++)r=t(r,o.value,a),o=o.next;return r};Cn.prototype.reduceReverse=function(t,e){var r,o=this.tail;if(arguments.length>1)r=e;else if(this.tail)o=this.tail.prev,r=this.tail.value;else throw new TypeError("Reduce of empty list with no initial value");for(var a=this.length-1;o!==null;a--)r=t(r,o.value,a),o=o.prev;return r};Cn.prototype.toArray=function(){for(var t=new Array(this.length),e=0,r=this.head;r!==null;e++)t[e]=r.value,r=r.next;return t};Cn.prototype.toArrayReverse=function(){for(var t=new Array(this.length),e=0,r=this.tail;r!==null;e++)t[e]=r.value,r=r.prev;return t};Cn.prototype.slice=function(t,e){e=e||this.length,e<0&&(e+=this.length),t=t||0,t<0&&(t+=this.length);var r=new Cn;if(e<t||e<0)return r;t<0&&(t=0),e>this.length&&(e=this.length);for(var o=0,a=this.head;a!==null&&o<t;o++)a=a.next;for(;a!==null&&o<e;o++,a=a.next)r.push(a.value);return r};Cn.prototype.sliceReverse=function(t,e){e=e||this.length,e<0&&(e+=this.length),t=t||0,t<0&&(t+=this.length);var r=new Cn;if(e<t||e<0)return r;t<0&&(t=0),e>this.length&&(e=this.length);for(var o=this.length,a=this.tail;a!==null&&o>e;o--)a=a.prev;for(;a!==null&&o>t;o--,a=a.prev)r.push(a.value);return r};Cn.prototype.splice=function(t,e,...r){t>this.length&&(t=this.length-1),t<0&&(t=this.length+t);for(var o=0,a=this.head;a!==null&&o<t;o++)a=a.next;for(var n=[],o=0;a&&o<e;o++)n.push(a.value),a=this.removeNode(a);a===null&&(a=this.tail),a!==this.head&&a!==this.tail&&(a=a.prev);for(var o=0;o<r.length;o++)a=bqe(this,a,r[o]);return n};Cn.prototype.reverse=function(){for(var t=this.head,e=this.tail,r=t;r!==null;r=r.prev){var o=r.prev;r.prev=r.next,r.next=o}return this.head=e,this.tail=t,this};function bqe(t,e,r){var o=e===t.head?new od(r,null,e,t):new od(r,e,e.next,t);return o.next===null&&(t.tail=o),o.prev===null&&(t.head=o),t.length++,o}function xqe(t,e){t.tail=new od(e,t.tail,null,t),t.head||(t.head=t.tail),t.length++}function kqe(t,e){t.head=new od(e,null,t.head,t),t.tail||(t.tail=t.head),t.length++}function od(t,e,r,o){if(!(this instanceof od))return new od(t,e,r,o);this.list=o,this.value=t,e?(e.next=this,this.prev=e):this.prev=null,r?(r.prev=this,this.next=r):this.next=null}try{tz()(Cn)}catch{}});var az=_((lQt,oz)=>{"use strict";var Qqe=IS(),ad=Symbol("max"),Bf=Symbol("length"),Sy=Symbol("lengthCalculator"),EI=Symbol("allowStale"),ld=Symbol("maxAge"),If=Symbol("dispose"),nz=Symbol("noDisposeOnSet"),xs=Symbol("lruList"),Mc=Symbol("cache"),sz=Symbol("updateAgeOnGet"),$T=()=>1,tN=class{constructor(e){if(typeof e=="number"&&(e={max:e}),e||(e={}),e.max&&(typeof e.max!="number"||e.max<0))throw new TypeError("max must be a non-negative number");let r=this[ad]=e.max||1/0,o=e.length||$T;if(this[Sy]=typeof o!="function"?$T:o,this[EI]=e.stale||!1,e.maxAge&&typeof e.maxAge!="number")throw new TypeError("maxAge must be a number");this[ld]=e.maxAge||0,this[If]=e.dispose,this[nz]=e.noDisposeOnSet||!1,this[sz]=e.updateAgeOnGet||!1,this.reset()}set max(e){if(typeof e!="number"||e<0)throw new TypeError("max must be a non-negative number");this[ad]=e||1/0,yI(this)}get max(){return this[ad]}set allowStale(e){this[EI]=!!e}get allowStale(){return this[EI]}set maxAge(e){if(typeof e!="number")throw new TypeError("maxAge must be a non-negative number");this[ld]=e,yI(this)}get maxAge(){return this[ld]}set lengthCalculator(e){typeof e!="function"&&(e=$T),e!==this[Sy]&&(this[Sy]=e,this[Bf]=0,this[xs].forEach(r=>{r.length=this[Sy](r.value,r.key),this[Bf]+=r.length})),yI(this)}get lengthCalculator(){return this[Sy]}get length(){return this[Bf]}get itemCount(){return this[xs].length}rforEach(e,r){r=r||this;for(let o=this[xs].tail;o!==null;){let a=o.prev;iz(this,e,o,r),o=a}}forEach(e,r){r=r||this;for(let o=this[xs].head;o!==null;){let a=o.next;iz(this,e,o,r),o=a}}keys(){return this[xs].toArray().map(e=>e.key)}values(){return this[xs].toArray().map(e=>e.value)}reset(){this[If]&&this[xs]&&this[xs].length&&this[xs].forEach(e=>this[If](e.key,e.value)),this[Mc]=new Map,this[xs]=new Qqe,this[Bf]=0}dump(){return this[xs].map(e=>BS(this,e)?!1:{k:e.key,v:e.value,e:e.now+(e.maxAge||0)}).toArray().filter(e=>e)}dumpLru(){return this[xs]}set(e,r,o){if(o=o||this[ld],o&&typeof o!="number")throw new TypeError("maxAge must be a number");let a=o?Date.now():0,n=this[Sy](r,e);if(this[Mc].has(e)){if(n>this[ad])return Py(this,this[Mc].get(e)),!1;let p=this[Mc].get(e).value;return this[If]&&(this[nz]||this[If](e,p.value)),p.now=a,p.maxAge=o,p.value=r,this[Bf]+=n-p.length,p.length=n,this.get(e),yI(this),!0}let u=new rN(e,r,n,a,o);return u.length>this[ad]?(this[If]&&this[If](e,r),!1):(this[Bf]+=u.length,this[xs].unshift(u),this[Mc].set(e,this[xs].head),yI(this),!0)}has(e){if(!this[Mc].has(e))return!1;let r=this[Mc].get(e).value;return!BS(this,r)}get(e){return eN(this,e,!0)}peek(e){return eN(this,e,!1)}pop(){let e=this[xs].tail;return e?(Py(this,e),e.value):null}del(e){Py(this,this[Mc].get(e))}load(e){this.reset();let r=Date.now();for(let o=e.length-1;o>=0;o--){let a=e[o],n=a.e||0;if(n===0)this.set(a.k,a.v);else{let u=n-r;u>0&&this.set(a.k,a.v,u)}}}prune(){this[Mc].forEach((e,r)=>eN(this,r,!1))}},eN=(t,e,r)=>{let o=t[Mc].get(e);if(o){let a=o.value;if(BS(t,a)){if(Py(t,o),!t[EI])return}else r&&(t[sz]&&(o.value.now=Date.now()),t[xs].unshiftNode(o));return a.value}},BS=(t,e)=>{if(!e||!e.maxAge&&!t[ld])return!1;let r=Date.now()-e.now;return e.maxAge?r>e.maxAge:t[ld]&&r>t[ld]},yI=t=>{if(t[Bf]>t[ad])for(let e=t[xs].tail;t[Bf]>t[ad]&&e!==null;){let r=e.prev;Py(t,e),e=r}},Py=(t,e)=>{if(e){let r=e.value;t[If]&&t[If](r.key,r.value),t[Bf]-=r.length,t[Mc].delete(r.key),t[xs].removeNode(e)}},rN=class{constructor(e,r,o,a,n){this.key=e,this.value=r,this.length=o,this.now=a,this.maxAge=n||0}},iz=(t,e,r,o)=>{let a=r.value;BS(t,a)&&(Py(t,r),t[EI]||(a=void 0)),a&&e.call(o,a.value,a.key,t)};oz.exports=tN});var Ol=_((cQt,Az)=>{var cd=class{constructor(e,r){if(r=Rqe(r),e instanceof cd)return e.loose===!!r.loose&&e.includePrerelease===!!r.includePrerelease?e:new cd(e.raw,r);if(e instanceof nN)return this.raw=e.value,this.set=[[e]],this.format(),this;if(this.options=r,this.loose=!!r.loose,this.includePrerelease=!!r.includePrerelease,this.raw=e.trim().split(/\s+/).join(" "),this.set=this.raw.split("||").map(o=>this.parseRange(o.trim())).filter(o=>o.length),!this.set.length)throw new TypeError(`Invalid SemVer Range: ${this.raw}`);if(this.set.length>1){let o=this.set[0];if(this.set=this.set.filter(a=>!cz(a[0])),this.set.length===0)this.set=[o];else if(this.set.length>1){for(let a of this.set)if(a.length===1&&_qe(a[0])){this.set=[a];break}}}this.format()}format(){return this.range=this.set.map(e=>e.join(" ").trim()).join("||").trim(),this.range}toString(){return this.range}parseRange(e){let o=((this.options.includePrerelease&&Mqe)|(this.options.loose&&Uqe))+":"+e,a=lz.get(o);if(a)return a;let n=this.options.loose,u=n?Da[zo.HYPHENRANGELOOSE]:Da[zo.HYPHENRANGE];e=e.replace(u,zqe(this.options.includePrerelease)),ci("hyphen replace",e),e=e.replace(Da[zo.COMPARATORTRIM],Nqe),ci("comparator trim",e),e=e.replace(Da[zo.TILDETRIM],Lqe),ci("tilde trim",e),e=e.replace(Da[zo.CARETTRIM],Oqe),ci("caret trim",e);let A=e.split(" ").map(I=>Hqe(I,this.options)).join(" ").split(/\s+/).map(I=>Jqe(I,this.options));n&&(A=A.filter(I=>(ci("loose invalid filter",I,this.options),!!I.match(Da[zo.COMPARATORLOOSE])))),ci("range list",A);let p=new Map,h=A.map(I=>new nN(I,this.options));for(let I of h){if(cz(I))return[I];p.set(I.value,I)}p.size>1&&p.has("")&&p.delete("");let E=[...p.values()];return lz.set(o,E),E}intersects(e,r){if(!(e instanceof cd))throw new TypeError("a Range is required");return this.set.some(o=>uz(o,r)&&e.set.some(a=>uz(a,r)&&o.every(n=>a.every(u=>n.intersects(u,r)))))}test(e){if(!e)return!1;if(typeof e=="string")try{e=new Tqe(e,this.options)}catch{return!1}for(let r=0;r<this.set.length;r++)if(Xqe(this.set[r],e,this.options))return!0;return!1}};Az.exports=cd;var Fqe=az(),lz=new Fqe({max:1e3}),Rqe=pS(),nN=CI(),ci=dI(),Tqe=So(),{safeRe:Da,t:zo,comparatorTrimReplace:Nqe,tildeTrimReplace:Lqe,caretTrimReplace:Oqe}=vy(),{FLAG_INCLUDE_PRERELEASE:Mqe,FLAG_LOOSE:Uqe}=gI(),cz=t=>t.value==="<0.0.0-0",_qe=t=>t.value==="",uz=(t,e)=>{let r=!0,o=t.slice(),a=o.pop();for(;r&&o.length;)r=o.every(n=>a.intersects(n,e)),a=o.pop();return r},Hqe=(t,e)=>(ci("comp",t,e),t=qqe(t,e),ci("caret",t),t=jqe(t,e),ci("tildes",t),t=Wqe(t,e),ci("xrange",t),t=Vqe(t,e),ci("stars",t),t),Xo=t=>!t||t.toLowerCase()==="x"||t==="*",jqe=(t,e)=>t.trim().split(/\s+/).map(r=>Gqe(r,e)).join(" "),Gqe=(t,e)=>{let r=e.loose?Da[zo.TILDELOOSE]:Da[zo.TILDE];return t.replace(r,(o,a,n,u,A)=>{ci("tilde",t,o,a,n,u,A);let p;return Xo(a)?p="":Xo(n)?p=`>=${a}.0.0 <${+a+1}.0.0-0`:Xo(u)?p=`>=${a}.${n}.0 <${a}.${+n+1}.0-0`:A?(ci("replaceTilde pr",A),p=`>=${a}.${n}.${u}-${A} <${a}.${+n+1}.0-0`):p=`>=${a}.${n}.${u} <${a}.${+n+1}.0-0`,ci("tilde return",p),p})},qqe=(t,e)=>t.trim().split(/\s+/).map(r=>Yqe(r,e)).join(" "),Yqe=(t,e)=>{ci("caret",t,e);let r=e.loose?Da[zo.CARETLOOSE]:Da[zo.CARET],o=e.includePrerelease?"-0":"";return t.replace(r,(a,n,u,A,p)=>{ci("caret",t,a,n,u,A,p);let h;return Xo(n)?h="":Xo(u)?h=`>=${n}.0.0${o} <${+n+1}.0.0-0`:Xo(A)?n==="0"?h=`>=${n}.${u}.0${o} <${n}.${+u+1}.0-0`:h=`>=${n}.${u}.0${o} <${+n+1}.0.0-0`:p?(ci("replaceCaret pr",p),n==="0"?u==="0"?h=`>=${n}.${u}.${A}-${p} <${n}.${u}.${+A+1}-0`:h=`>=${n}.${u}.${A}-${p} <${n}.${+u+1}.0-0`:h=`>=${n}.${u}.${A}-${p} <${+n+1}.0.0-0`):(ci("no pr"),n==="0"?u==="0"?h=`>=${n}.${u}.${A}${o} <${n}.${u}.${+A+1}-0`:h=`>=${n}.${u}.${A}${o} <${n}.${+u+1}.0-0`:h=`>=${n}.${u}.${A} <${+n+1}.0.0-0`),ci("caret return",h),h})},Wqe=(t,e)=>(ci("replaceXRanges",t,e),t.split(/\s+/).map(r=>Kqe(r,e)).join(" ")),Kqe=(t,e)=>{t=t.trim();let r=e.loose?Da[zo.XRANGELOOSE]:Da[zo.XRANGE];return t.replace(r,(o,a,n,u,A,p)=>{ci("xRange",t,o,a,n,u,A,p);let h=Xo(n),E=h||Xo(u),I=E||Xo(A),v=I;return a==="="&&v&&(a=""),p=e.includePrerelease?"-0":"",h?a===">"||a==="<"?o="<0.0.0-0":o="*":a&&v?(E&&(u=0),A=0,a===">"?(a=">=",E?(n=+n+1,u=0,A=0):(u=+u+1,A=0)):a==="<="&&(a="<",E?n=+n+1:u=+u+1),a==="<"&&(p="-0"),o=`${a+n}.${u}.${A}${p}`):E?o=`>=${n}.0.0${p} <${+n+1}.0.0-0`:I&&(o=`>=${n}.${u}.0${p} <${n}.${+u+1}.0-0`),ci("xRange return",o),o})},Vqe=(t,e)=>(ci("replaceStars",t,e),t.trim().replace(Da[zo.STAR],"")),Jqe=(t,e)=>(ci("replaceGTE0",t,e),t.trim().replace(Da[e.includePrerelease?zo.GTE0PRE:zo.GTE0],"")),zqe=t=>(e,r,o,a,n,u,A,p,h,E,I,v,x)=>(Xo(o)?r="":Xo(a)?r=`>=${o}.0.0${t?"-0":""}`:Xo(n)?r=`>=${o}.${a}.0${t?"-0":""}`:u?r=`>=${r}`:r=`>=${r}${t?"-0":""}`,Xo(h)?p="":Xo(E)?p=`<${+h+1}.0.0-0`:Xo(I)?p=`<${h}.${+E+1}.0-0`:v?p=`<=${h}.${E}.${I}-${v}`:t?p=`<${h}.${E}.${+I+1}-0`:p=`<=${p}`,`${r} ${p}`.trim()),Xqe=(t,e,r)=>{for(let o=0;o<t.length;o++)if(!t[o].test(e))return!1;if(e.prerelease.length&&!r.includePrerelease){for(let o=0;o<t.length;o++)if(ci(t[o].semver),t[o].semver!==nN.ANY&&t[o].semver.prerelease.length>0){let a=t[o].semver;if(a.major===e.major&&a.minor===e.minor&&a.patch===e.patch)return!0}return!1}return!0}});var CI=_((uQt,mz)=>{var wI=Symbol("SemVer ANY"),by=class{static get ANY(){return wI}constructor(e,r){if(r=fz(r),e instanceof by){if(e.loose===!!r.loose)return e;e=e.value}e=e.trim().split(/\s+/).join(" "),sN("comparator",e,r),this.options=r,this.loose=!!r.loose,this.parse(e),this.semver===wI?this.value="":this.value=this.operator+this.semver.version,sN("comp",this)}parse(e){let r=this.options.loose?pz[hz.COMPARATORLOOSE]:pz[hz.COMPARATOR],o=e.match(r);if(!o)throw new TypeError(`Invalid comparator: ${e}`);this.operator=o[1]!==void 0?o[1]:"",this.operator==="="&&(this.operator=""),o[2]?this.semver=new gz(o[2],this.options.loose):this.semver=wI}toString(){return this.value}test(e){if(sN("Comparator.test",e,this.options.loose),this.semver===wI||e===wI)return!0;if(typeof e=="string")try{e=new gz(e,this.options)}catch{return!1}return iN(e,this.operator,this.semver,this.options)}intersects(e,r){if(!(e instanceof by))throw new TypeError("a Comparator is required");return this.operator===""?this.value===""?!0:new dz(e.value,r).test(this.value):e.operator===""?e.value===""?!0:new dz(this.value,r).test(e.semver):(r=fz(r),r.includePrerelease&&(this.value==="<0.0.0-0"||e.value==="<0.0.0-0")||!r.includePrerelease&&(this.value.startsWith("<0.0.0")||e.value.startsWith("<0.0.0"))?!1:!!(this.operator.startsWith(">")&&e.operator.startsWith(">")||this.operator.startsWith("<")&&e.operator.startsWith("<")||this.semver.version===e.semver.version&&this.operator.includes("=")&&e.operator.includes("=")||iN(this.semver,"<",e.semver,r)&&this.operator.startsWith(">")&&e.operator.startsWith("<")||iN(this.semver,">",e.semver,r)&&this.operator.startsWith("<")&&e.operator.startsWith(">")))}};mz.exports=by;var fz=pS(),{safeRe:pz,t:hz}=vy(),iN=ZT(),sN=dI(),gz=So(),dz=Ol()});var II=_((AQt,yz)=>{var Zqe=Ol(),$qe=(t,e,r)=>{try{e=new Zqe(e,r)}catch{return!1}return e.test(t)};yz.exports=$qe});var Cz=_((fQt,Ez)=>{var e9e=Ol(),t9e=(t,e)=>new e9e(t,e).set.map(r=>r.map(o=>o.value).join(" ").trim().split(" "));Ez.exports=t9e});var Iz=_((pQt,wz)=>{var r9e=So(),n9e=Ol(),i9e=(t,e,r)=>{let o=null,a=null,n=null;try{n=new n9e(e,r)}catch{return null}return t.forEach(u=>{n.test(u)&&(!o||a.compare(u)===-1)&&(o=u,a=new r9e(o,r))}),o};wz.exports=i9e});var vz=_((hQt,Bz)=>{var s9e=So(),o9e=Ol(),a9e=(t,e,r)=>{let o=null,a=null,n=null;try{n=new o9e(e,r)}catch{return null}return t.forEach(u=>{n.test(u)&&(!o||a.compare(u)===1)&&(o=u,a=new s9e(o,r))}),o};Bz.exports=a9e});var Pz=_((gQt,Sz)=>{var oN=So(),l9e=Ol(),Dz=mI(),c9e=(t,e)=>{t=new l9e(t,e);let r=new oN("0.0.0");if(t.test(r)||(r=new oN("0.0.0-0"),t.test(r)))return r;r=null;for(let o=0;o<t.set.length;++o){let a=t.set[o],n=null;a.forEach(u=>{let A=new oN(u.semver.version);switch(u.operator){case">":A.prerelease.length===0?A.patch++:A.prerelease.push(0),A.raw=A.format();case"":case">=":(!n||Dz(A,n))&&(n=A);break;case"<":case"<=":break;default:throw new Error(`Unexpected operation: ${u.operator}`)}}),n&&(!r||Dz(r,n))&&(r=n)}return r&&t.test(r)?r:null};Sz.exports=c9e});var xz=_((dQt,bz)=>{var u9e=Ol(),A9e=(t,e)=>{try{return new u9e(t,e).range||"*"}catch{return null}};bz.exports=A9e});var vS=_((mQt,Rz)=>{var f9e=So(),Fz=CI(),{ANY:p9e}=Fz,h9e=Ol(),g9e=II(),kz=mI(),Qz=mS(),d9e=ES(),m9e=yS(),y9e=(t,e,r,o)=>{t=new f9e(t,o),e=new h9e(e,o);let a,n,u,A,p;switch(r){case">":a=kz,n=d9e,u=Qz,A=">",p=">=";break;case"<":a=Qz,n=m9e,u=kz,A="<",p="<=";break;default:throw new TypeError('Must provide a hilo val of "<" or ">"')}if(g9e(t,e,o))return!1;for(let h=0;h<e.set.length;++h){let E=e.set[h],I=null,v=null;if(E.forEach(x=>{x.semver===p9e&&(x=new Fz(">=0.0.0")),I=I||x,v=v||x,a(x.semver,I.semver,o)?I=x:u(x.semver,v.semver,o)&&(v=x)}),I.operator===A||I.operator===p||(!v.operator||v.operator===A)&&n(t,v.semver))return!1;if(v.operator===p&&u(t,v.semver))return!1}return!0};Rz.exports=y9e});var Nz=_((yQt,Tz)=>{var E9e=vS(),C9e=(t,e,r)=>E9e(t,e,">",r);Tz.exports=C9e});var Oz=_((EQt,Lz)=>{var w9e=vS(),I9e=(t,e,r)=>w9e(t,e,"<",r);Lz.exports=I9e});var _z=_((CQt,Uz)=>{var Mz=Ol(),B9e=(t,e,r)=>(t=new Mz(t,r),e=new Mz(e,r),t.intersects(e,r));Uz.exports=B9e});var jz=_((wQt,Hz)=>{var v9e=II(),D9e=Ll();Hz.exports=(t,e,r)=>{let o=[],a=null,n=null,u=t.sort((E,I)=>D9e(E,I,r));for(let E of u)v9e(E,e,r)?(n=E,a||(a=E)):(n&&o.push([a,n]),n=null,a=null);a&&o.push([a,null]);let A=[];for(let[E,I]of o)E===I?A.push(E):!I&&E===u[0]?A.push("*"):I?E===u[0]?A.push(`<=${I}`):A.push(`${E} - ${I}`):A.push(`>=${E}`);let p=A.join(" || "),h=typeof e.raw=="string"?e.raw:String(e);return p.length<h.length?p:e}});var Vz=_((IQt,Kz)=>{var Gz=Ol(),lN=CI(),{ANY:aN}=lN,BI=II(),cN=Ll(),S9e=(t,e,r={})=>{if(t===e)return!0;t=new Gz(t,r),e=new Gz(e,r);let o=!1;e:for(let a of t.set){for(let n of e.set){let u=b9e(a,n,r);if(o=o||u!==null,u)continue e}if(o)return!1}return!0},P9e=[new lN(">=0.0.0-0")],qz=[new lN(">=0.0.0")],b9e=(t,e,r)=>{if(t===e)return!0;if(t.length===1&&t[0].semver===aN){if(e.length===1&&e[0].semver===aN)return!0;r.includePrerelease?t=P9e:t=qz}if(e.length===1&&e[0].semver===aN){if(r.includePrerelease)return!0;e=qz}let o=new Set,a,n;for(let x of t)x.operator===">"||x.operator===">="?a=Yz(a,x,r):x.operator==="<"||x.operator==="<="?n=Wz(n,x,r):o.add(x.semver);if(o.size>1)return null;let u;if(a&&n){if(u=cN(a.semver,n.semver,r),u>0)return null;if(u===0&&(a.operator!==">="||n.operator!=="<="))return null}for(let x of o){if(a&&!BI(x,String(a),r)||n&&!BI(x,String(n),r))return null;for(let C of e)if(!BI(x,String(C),r))return!1;return!0}let A,p,h,E,I=n&&!r.includePrerelease&&n.semver.prerelease.length?n.semver:!1,v=a&&!r.includePrerelease&&a.semver.prerelease.length?a.semver:!1;I&&I.prerelease.length===1&&n.operator==="<"&&I.prerelease[0]===0&&(I=!1);for(let x of e){if(E=E||x.operator===">"||x.operator===">=",h=h||x.operator==="<"||x.operator==="<=",a){if(v&&x.semver.prerelease&&x.semver.prerelease.length&&x.semver.major===v.major&&x.semver.minor===v.minor&&x.semver.patch===v.patch&&(v=!1),x.operator===">"||x.operator===">="){if(A=Yz(a,x,r),A===x&&A!==a)return!1}else if(a.operator===">="&&!BI(a.semver,String(x),r))return!1}if(n){if(I&&x.semver.prerelease&&x.semver.prerelease.length&&x.semver.major===I.major&&x.semver.minor===I.minor&&x.semver.patch===I.patch&&(I=!1),x.operator==="<"||x.operator==="<="){if(p=Wz(n,x,r),p===x&&p!==n)return!1}else if(n.operator==="<="&&!BI(n.semver,String(x),r))return!1}if(!x.operator&&(n||a)&&u!==0)return!1}return!(a&&h&&!n&&u!==0||n&&E&&!a&&u!==0||v||I)},Yz=(t,e,r)=>{if(!t)return e;let o=cN(t.semver,e.semver,r);return o>0?t:o<0||e.operator===">"&&t.operator===">="?e:t},Wz=(t,e,r)=>{if(!t)return e;let o=cN(t.semver,e.semver,r);return o<0?t:o>0||e.operator==="<"&&t.operator==="<="?e:t};Kz.exports=S9e});var zn=_((BQt,Xz)=>{var uN=vy(),Jz=gI(),x9e=So(),zz=JT(),k9e=sd(),Q9e=dJ(),F9e=yJ(),R9e=wJ(),T9e=vJ(),N9e=SJ(),L9e=bJ(),O9e=kJ(),M9e=FJ(),U9e=Ll(),_9e=LJ(),H9e=MJ(),j9e=dS(),G9e=jJ(),q9e=qJ(),Y9e=mI(),W9e=mS(),K9e=zT(),V9e=XT(),J9e=yS(),z9e=ES(),X9e=ZT(),Z9e=$J(),$9e=CI(),e5e=Ol(),t5e=II(),r5e=Cz(),n5e=Iz(),i5e=vz(),s5e=Pz(),o5e=xz(),a5e=vS(),l5e=Nz(),c5e=Oz(),u5e=_z(),A5e=jz(),f5e=Vz();Xz.exports={parse:k9e,valid:Q9e,clean:F9e,inc:R9e,diff:T9e,major:N9e,minor:L9e,patch:O9e,prerelease:M9e,compare:U9e,rcompare:_9e,compareLoose:H9e,compareBuild:j9e,sort:G9e,rsort:q9e,gt:Y9e,lt:W9e,eq:K9e,neq:V9e,gte:J9e,lte:z9e,cmp:X9e,coerce:Z9e,Comparator:$9e,Range:e5e,satisfies:t5e,toComparators:r5e,maxSatisfying:n5e,minSatisfying:i5e,minVersion:s5e,validRange:o5e,outside:a5e,gtr:l5e,ltr:c5e,intersects:u5e,simplifyRange:A5e,subset:f5e,SemVer:x9e,re:uN.re,src:uN.src,tokens:uN.t,SEMVER_SPEC_VERSION:Jz.SEMVER_SPEC_VERSION,RELEASE_TYPES:Jz.RELEASE_TYPES,compareIdentifiers:zz.compareIdentifiers,rcompareIdentifiers:zz.rcompareIdentifiers}});var $z=_((vQt,Zz)=>{"use strict";function p5e(t,e){function r(){this.constructor=t}r.prototype=e.prototype,t.prototype=new r}function ud(t,e,r,o){this.message=t,this.expected=e,this.found=r,this.location=o,this.name="SyntaxError",typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(this,ud)}p5e(ud,Error);ud.buildMessage=function(t,e){var r={literal:function(h){return'"'+a(h.text)+'"'},class:function(h){var E="",I;for(I=0;I<h.parts.length;I++)E+=h.parts[I]instanceof Array?n(h.parts[I][0])+"-"+n(h.parts[I][1]):n(h.parts[I]);return"["+(h.inverted?"^":"")+E+"]"},any:function(h){return"any character"},end:function(h){return"end of input"},other:function(h){return h.description}};function o(h){return h.charCodeAt(0).toString(16).toUpperCase()}function a(h){return h.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(E){return"\\x0"+o(E)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(E){return"\\x"+o(E)})}function n(h){return h.replace(/\\/g,"\\\\").replace(/\]/g,"\\]").replace(/\^/g,"\\^").replace(/-/g,"\\-").replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(E){return"\\x0"+o(E)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(E){return"\\x"+o(E)})}function u(h){return r[h.type](h)}function A(h){var E=new Array(h.length),I,v;for(I=0;I<h.length;I++)E[I]=u(h[I]);if(E.sort(),E.length>0){for(I=1,v=1;I<E.length;I++)E[I-1]!==E[I]&&(E[v]=E[I],v++);E.length=v}switch(E.length){case 1:return E[0];case 2:return E[0]+" or "+E[1];default:return E.slice(0,-1).join(", ")+", or "+E[E.length-1]}}function p(h){return h?'"'+a(h)+'"':"end of input"}return"Expected "+A(t)+" but "+p(e)+" found."};function h5e(t,e){e=e!==void 0?e:{};var r={},o={Expression:y},a=y,n="|",u=Re("|",!1),A="&",p=Re("&",!1),h="^",E=Re("^",!1),I=function(Z,ie){return!!ie.reduce((Pe,Ne)=>{switch(Ne[1]){case"|":return Pe|Ne[3];case"&":return Pe&Ne[3];case"^":return Pe^Ne[3]}},Z)},v="!",x=Re("!",!1),C=function(Z){return!Z},R="(",L=Re("(",!1),U=")",J=Re(")",!1),te=function(Z){return Z},ae=/^[^ \t\n\r()!|&\^]/,fe=ke([" "," ",` +`,"\r","(",")","!","|","&","^"],!0,!1),ce=function(Z){return e.queryPattern.test(Z)},me=function(Z){return e.checkFn(Z)},he=Te("whitespace"),Be=/^[ \t\n\r]/,we=ke([" "," ",` +`,"\r"],!1,!1),g=0,Ee=0,Se=[{line:1,column:1}],le=0,ne=[],ee=0,Ie;if("startRule"in e){if(!(e.startRule in o))throw new Error(`Can't start parsing from rule "`+e.startRule+'".');a=o[e.startRule]}function Fe(){return t.substring(Ee,g)}function At(){return je(Ee,g)}function H(Z,ie){throw ie=ie!==void 0?ie:je(Ee,g),P([Te(Z)],t.substring(Ee,g),ie)}function at(Z,ie){throw ie=ie!==void 0?ie:je(Ee,g),w(Z,ie)}function Re(Z,ie){return{type:"literal",text:Z,ignoreCase:ie}}function ke(Z,ie,Pe){return{type:"class",parts:Z,inverted:ie,ignoreCase:Pe}}function xe(){return{type:"any"}}function He(){return{type:"end"}}function Te(Z){return{type:"other",description:Z}}function Je(Z){var ie=Se[Z],Pe;if(ie)return ie;for(Pe=Z-1;!Se[Pe];)Pe--;for(ie=Se[Pe],ie={line:ie.line,column:ie.column};Pe<Z;)t.charCodeAt(Pe)===10?(ie.line++,ie.column=1):ie.column++,Pe++;return Se[Z]=ie,ie}function je(Z,ie){var Pe=Je(Z),Ne=Je(ie);return{start:{offset:Z,line:Pe.line,column:Pe.column},end:{offset:ie,line:Ne.line,column:Ne.column}}}function b(Z){g<le||(g>le&&(le=g,ne=[]),ne.push(Z))}function w(Z,ie){return new ud(Z,null,null,ie)}function P(Z,ie,Pe){return new ud(ud.buildMessage(Z,ie),Z,ie,Pe)}function y(){var Z,ie,Pe,Ne,ot,dt,Gt,$t;if(Z=g,ie=F(),ie!==r){for(Pe=[],Ne=g,ot=X(),ot!==r?(t.charCodeAt(g)===124?(dt=n,g++):(dt=r,ee===0&&b(u)),dt===r&&(t.charCodeAt(g)===38?(dt=A,g++):(dt=r,ee===0&&b(p)),dt===r&&(t.charCodeAt(g)===94?(dt=h,g++):(dt=r,ee===0&&b(E)))),dt!==r?(Gt=X(),Gt!==r?($t=F(),$t!==r?(ot=[ot,dt,Gt,$t],Ne=ot):(g=Ne,Ne=r)):(g=Ne,Ne=r)):(g=Ne,Ne=r)):(g=Ne,Ne=r);Ne!==r;)Pe.push(Ne),Ne=g,ot=X(),ot!==r?(t.charCodeAt(g)===124?(dt=n,g++):(dt=r,ee===0&&b(u)),dt===r&&(t.charCodeAt(g)===38?(dt=A,g++):(dt=r,ee===0&&b(p)),dt===r&&(t.charCodeAt(g)===94?(dt=h,g++):(dt=r,ee===0&&b(E)))),dt!==r?(Gt=X(),Gt!==r?($t=F(),$t!==r?(ot=[ot,dt,Gt,$t],Ne=ot):(g=Ne,Ne=r)):(g=Ne,Ne=r)):(g=Ne,Ne=r)):(g=Ne,Ne=r);Pe!==r?(Ee=Z,ie=I(ie,Pe),Z=ie):(g=Z,Z=r)}else g=Z,Z=r;return Z}function F(){var Z,ie,Pe,Ne,ot,dt;return Z=g,t.charCodeAt(g)===33?(ie=v,g++):(ie=r,ee===0&&b(x)),ie!==r?(Pe=F(),Pe!==r?(Ee=Z,ie=C(Pe),Z=ie):(g=Z,Z=r)):(g=Z,Z=r),Z===r&&(Z=g,t.charCodeAt(g)===40?(ie=R,g++):(ie=r,ee===0&&b(L)),ie!==r?(Pe=X(),Pe!==r?(Ne=y(),Ne!==r?(ot=X(),ot!==r?(t.charCodeAt(g)===41?(dt=U,g++):(dt=r,ee===0&&b(J)),dt!==r?(Ee=Z,ie=te(Ne),Z=ie):(g=Z,Z=r)):(g=Z,Z=r)):(g=Z,Z=r)):(g=Z,Z=r)):(g=Z,Z=r),Z===r&&(Z=z())),Z}function z(){var Z,ie,Pe,Ne,ot;if(Z=g,ie=X(),ie!==r){if(Pe=g,Ne=[],ae.test(t.charAt(g))?(ot=t.charAt(g),g++):(ot=r,ee===0&&b(fe)),ot!==r)for(;ot!==r;)Ne.push(ot),ae.test(t.charAt(g))?(ot=t.charAt(g),g++):(ot=r,ee===0&&b(fe));else Ne=r;Ne!==r?Pe=t.substring(Pe,g):Pe=Ne,Pe!==r?(Ee=g,Ne=ce(Pe),Ne?Ne=void 0:Ne=r,Ne!==r?(Ee=Z,ie=me(Pe),Z=ie):(g=Z,Z=r)):(g=Z,Z=r)}else g=Z,Z=r;return Z}function X(){var Z,ie;for(ee++,Z=[],Be.test(t.charAt(g))?(ie=t.charAt(g),g++):(ie=r,ee===0&&b(we));ie!==r;)Z.push(ie),Be.test(t.charAt(g))?(ie=t.charAt(g),g++):(ie=r,ee===0&&b(we));return ee--,Z===r&&(ie=r,ee===0&&b(he)),Z}if(Ie=a(),Ie!==r&&g===t.length)return Ie;throw Ie!==r&&g<t.length&&b(He()),P(ne,le<t.length?t.charAt(le):null,le<t.length?je(le,le+1):je(le,le))}Zz.exports={SyntaxError:ud,parse:h5e}});var eX=_(DS=>{var{parse:g5e}=$z();DS.makeParser=(t=/[a-z]+/)=>(e,r)=>g5e(e,{queryPattern:t,checkFn:r});DS.parse=DS.makeParser()});var rX=_((SQt,tX)=>{"use strict";tX.exports={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]}});var AN=_((PQt,iX)=>{var vI=rX(),nX={};for(let t of Object.keys(vI))nX[vI[t]]=t;var Ar={rgb:{channels:3,labels:"rgb"},hsl:{channels:3,labels:"hsl"},hsv:{channels:3,labels:"hsv"},hwb:{channels:3,labels:"hwb"},cmyk:{channels:4,labels:"cmyk"},xyz:{channels:3,labels:"xyz"},lab:{channels:3,labels:"lab"},lch:{channels:3,labels:"lch"},hex:{channels:1,labels:["hex"]},keyword:{channels:1,labels:["keyword"]},ansi16:{channels:1,labels:["ansi16"]},ansi256:{channels:1,labels:["ansi256"]},hcg:{channels:3,labels:["h","c","g"]},apple:{channels:3,labels:["r16","g16","b16"]},gray:{channels:1,labels:["gray"]}};iX.exports=Ar;for(let t of Object.keys(Ar)){if(!("channels"in Ar[t]))throw new Error("missing channels property: "+t);if(!("labels"in Ar[t]))throw new Error("missing channel labels property: "+t);if(Ar[t].labels.length!==Ar[t].channels)throw new Error("channel and label counts mismatch: "+t);let{channels:e,labels:r}=Ar[t];delete Ar[t].channels,delete Ar[t].labels,Object.defineProperty(Ar[t],"channels",{value:e}),Object.defineProperty(Ar[t],"labels",{value:r})}Ar.rgb.hsl=function(t){let e=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(e,r,o),n=Math.max(e,r,o),u=n-a,A,p;n===a?A=0:e===n?A=(r-o)/u:r===n?A=2+(o-e)/u:o===n&&(A=4+(e-r)/u),A=Math.min(A*60,360),A<0&&(A+=360);let h=(a+n)/2;return n===a?p=0:h<=.5?p=u/(n+a):p=u/(2-n-a),[A,p*100,h*100]};Ar.rgb.hsv=function(t){let e,r,o,a,n,u=t[0]/255,A=t[1]/255,p=t[2]/255,h=Math.max(u,A,p),E=h-Math.min(u,A,p),I=function(v){return(h-v)/6/E+1/2};return E===0?(a=0,n=0):(n=E/h,e=I(u),r=I(A),o=I(p),u===h?a=o-r:A===h?a=1/3+e-o:p===h&&(a=2/3+r-e),a<0?a+=1:a>1&&(a-=1)),[a*360,n*100,h*100]};Ar.rgb.hwb=function(t){let e=t[0],r=t[1],o=t[2],a=Ar.rgb.hsl(t)[0],n=1/255*Math.min(e,Math.min(r,o));return o=1-1/255*Math.max(e,Math.max(r,o)),[a,n*100,o*100]};Ar.rgb.cmyk=function(t){let e=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(1-e,1-r,1-o),n=(1-e-a)/(1-a)||0,u=(1-r-a)/(1-a)||0,A=(1-o-a)/(1-a)||0;return[n*100,u*100,A*100,a*100]};function d5e(t,e){return(t[0]-e[0])**2+(t[1]-e[1])**2+(t[2]-e[2])**2}Ar.rgb.keyword=function(t){let e=nX[t];if(e)return e;let r=1/0,o;for(let a of Object.keys(vI)){let n=vI[a],u=d5e(t,n);u<r&&(r=u,o=a)}return o};Ar.keyword.rgb=function(t){return vI[t]};Ar.rgb.xyz=function(t){let e=t[0]/255,r=t[1]/255,o=t[2]/255;e=e>.04045?((e+.055)/1.055)**2.4:e/12.92,r=r>.04045?((r+.055)/1.055)**2.4:r/12.92,o=o>.04045?((o+.055)/1.055)**2.4:o/12.92;let a=e*.4124+r*.3576+o*.1805,n=e*.2126+r*.7152+o*.0722,u=e*.0193+r*.1192+o*.9505;return[a*100,n*100,u*100]};Ar.rgb.lab=function(t){let e=Ar.rgb.xyz(t),r=e[0],o=e[1],a=e[2];r/=95.047,o/=100,a/=108.883,r=r>.008856?r**(1/3):7.787*r+16/116,o=o>.008856?o**(1/3):7.787*o+16/116,a=a>.008856?a**(1/3):7.787*a+16/116;let n=116*o-16,u=500*(r-o),A=200*(o-a);return[n,u,A]};Ar.hsl.rgb=function(t){let e=t[0]/360,r=t[1]/100,o=t[2]/100,a,n,u;if(r===0)return u=o*255,[u,u,u];o<.5?a=o*(1+r):a=o+r-o*r;let A=2*o-a,p=[0,0,0];for(let h=0;h<3;h++)n=e+1/3*-(h-1),n<0&&n++,n>1&&n--,6*n<1?u=A+(a-A)*6*n:2*n<1?u=a:3*n<2?u=A+(a-A)*(2/3-n)*6:u=A,p[h]=u*255;return p};Ar.hsl.hsv=function(t){let e=t[0],r=t[1]/100,o=t[2]/100,a=r,n=Math.max(o,.01);o*=2,r*=o<=1?o:2-o,a*=n<=1?n:2-n;let u=(o+r)/2,A=o===0?2*a/(n+a):2*r/(o+r);return[e,A*100,u*100]};Ar.hsv.rgb=function(t){let e=t[0]/60,r=t[1]/100,o=t[2]/100,a=Math.floor(e)%6,n=e-Math.floor(e),u=255*o*(1-r),A=255*o*(1-r*n),p=255*o*(1-r*(1-n));switch(o*=255,a){case 0:return[o,p,u];case 1:return[A,o,u];case 2:return[u,o,p];case 3:return[u,A,o];case 4:return[p,u,o];case 5:return[o,u,A]}};Ar.hsv.hsl=function(t){let e=t[0],r=t[1]/100,o=t[2]/100,a=Math.max(o,.01),n,u;u=(2-r)*o;let A=(2-r)*a;return n=r*a,n/=A<=1?A:2-A,n=n||0,u/=2,[e,n*100,u*100]};Ar.hwb.rgb=function(t){let e=t[0]/360,r=t[1]/100,o=t[2]/100,a=r+o,n;a>1&&(r/=a,o/=a);let u=Math.floor(6*e),A=1-o;n=6*e-u,(u&1)!==0&&(n=1-n);let p=r+n*(A-r),h,E,I;switch(u){default:case 6:case 0:h=A,E=p,I=r;break;case 1:h=p,E=A,I=r;break;case 2:h=r,E=A,I=p;break;case 3:h=r,E=p,I=A;break;case 4:h=p,E=r,I=A;break;case 5:h=A,E=r,I=p;break}return[h*255,E*255,I*255]};Ar.cmyk.rgb=function(t){let e=t[0]/100,r=t[1]/100,o=t[2]/100,a=t[3]/100,n=1-Math.min(1,e*(1-a)+a),u=1-Math.min(1,r*(1-a)+a),A=1-Math.min(1,o*(1-a)+a);return[n*255,u*255,A*255]};Ar.xyz.rgb=function(t){let e=t[0]/100,r=t[1]/100,o=t[2]/100,a,n,u;return a=e*3.2406+r*-1.5372+o*-.4986,n=e*-.9689+r*1.8758+o*.0415,u=e*.0557+r*-.204+o*1.057,a=a>.0031308?1.055*a**(1/2.4)-.055:a*12.92,n=n>.0031308?1.055*n**(1/2.4)-.055:n*12.92,u=u>.0031308?1.055*u**(1/2.4)-.055:u*12.92,a=Math.min(Math.max(0,a),1),n=Math.min(Math.max(0,n),1),u=Math.min(Math.max(0,u),1),[a*255,n*255,u*255]};Ar.xyz.lab=function(t){let e=t[0],r=t[1],o=t[2];e/=95.047,r/=100,o/=108.883,e=e>.008856?e**(1/3):7.787*e+16/116,r=r>.008856?r**(1/3):7.787*r+16/116,o=o>.008856?o**(1/3):7.787*o+16/116;let a=116*r-16,n=500*(e-r),u=200*(r-o);return[a,n,u]};Ar.lab.xyz=function(t){let e=t[0],r=t[1],o=t[2],a,n,u;n=(e+16)/116,a=r/500+n,u=n-o/200;let A=n**3,p=a**3,h=u**3;return n=A>.008856?A:(n-16/116)/7.787,a=p>.008856?p:(a-16/116)/7.787,u=h>.008856?h:(u-16/116)/7.787,a*=95.047,n*=100,u*=108.883,[a,n,u]};Ar.lab.lch=function(t){let e=t[0],r=t[1],o=t[2],a;a=Math.atan2(o,r)*360/2/Math.PI,a<0&&(a+=360);let u=Math.sqrt(r*r+o*o);return[e,u,a]};Ar.lch.lab=function(t){let e=t[0],r=t[1],a=t[2]/360*2*Math.PI,n=r*Math.cos(a),u=r*Math.sin(a);return[e,n,u]};Ar.rgb.ansi16=function(t,e=null){let[r,o,a]=t,n=e===null?Ar.rgb.hsv(t)[2]:e;if(n=Math.round(n/50),n===0)return 30;let u=30+(Math.round(a/255)<<2|Math.round(o/255)<<1|Math.round(r/255));return n===2&&(u+=60),u};Ar.hsv.ansi16=function(t){return Ar.rgb.ansi16(Ar.hsv.rgb(t),t[2])};Ar.rgb.ansi256=function(t){let e=t[0],r=t[1],o=t[2];return e===r&&r===o?e<8?16:e>248?231:Math.round((e-8)/247*24)+232:16+36*Math.round(e/255*5)+6*Math.round(r/255*5)+Math.round(o/255*5)};Ar.ansi16.rgb=function(t){let e=t%10;if(e===0||e===7)return t>50&&(e+=3.5),e=e/10.5*255,[e,e,e];let r=(~~(t>50)+1)*.5,o=(e&1)*r*255,a=(e>>1&1)*r*255,n=(e>>2&1)*r*255;return[o,a,n]};Ar.ansi256.rgb=function(t){if(t>=232){let n=(t-232)*10+8;return[n,n,n]}t-=16;let e,r=Math.floor(t/36)/5*255,o=Math.floor((e=t%36)/6)/5*255,a=e%6/5*255;return[r,o,a]};Ar.rgb.hex=function(t){let r=(((Math.round(t[0])&255)<<16)+((Math.round(t[1])&255)<<8)+(Math.round(t[2])&255)).toString(16).toUpperCase();return"000000".substring(r.length)+r};Ar.hex.rgb=function(t){let e=t.toString(16).match(/[a-f0-9]{6}|[a-f0-9]{3}/i);if(!e)return[0,0,0];let r=e[0];e[0].length===3&&(r=r.split("").map(A=>A+A).join(""));let o=parseInt(r,16),a=o>>16&255,n=o>>8&255,u=o&255;return[a,n,u]};Ar.rgb.hcg=function(t){let e=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.max(Math.max(e,r),o),n=Math.min(Math.min(e,r),o),u=a-n,A,p;return u<1?A=n/(1-u):A=0,u<=0?p=0:a===e?p=(r-o)/u%6:a===r?p=2+(o-e)/u:p=4+(e-r)/u,p/=6,p%=1,[p*360,u*100,A*100]};Ar.hsl.hcg=function(t){let e=t[1]/100,r=t[2]/100,o=r<.5?2*e*r:2*e*(1-r),a=0;return o<1&&(a=(r-.5*o)/(1-o)),[t[0],o*100,a*100]};Ar.hsv.hcg=function(t){let e=t[1]/100,r=t[2]/100,o=e*r,a=0;return o<1&&(a=(r-o)/(1-o)),[t[0],o*100,a*100]};Ar.hcg.rgb=function(t){let e=t[0]/360,r=t[1]/100,o=t[2]/100;if(r===0)return[o*255,o*255,o*255];let a=[0,0,0],n=e%1*6,u=n%1,A=1-u,p=0;switch(Math.floor(n)){case 0:a[0]=1,a[1]=u,a[2]=0;break;case 1:a[0]=A,a[1]=1,a[2]=0;break;case 2:a[0]=0,a[1]=1,a[2]=u;break;case 3:a[0]=0,a[1]=A,a[2]=1;break;case 4:a[0]=u,a[1]=0,a[2]=1;break;default:a[0]=1,a[1]=0,a[2]=A}return p=(1-r)*o,[(r*a[0]+p)*255,(r*a[1]+p)*255,(r*a[2]+p)*255]};Ar.hcg.hsv=function(t){let e=t[1]/100,r=t[2]/100,o=e+r*(1-e),a=0;return o>0&&(a=e/o),[t[0],a*100,o*100]};Ar.hcg.hsl=function(t){let e=t[1]/100,o=t[2]/100*(1-e)+.5*e,a=0;return o>0&&o<.5?a=e/(2*o):o>=.5&&o<1&&(a=e/(2*(1-o))),[t[0],a*100,o*100]};Ar.hcg.hwb=function(t){let e=t[1]/100,r=t[2]/100,o=e+r*(1-e);return[t[0],(o-e)*100,(1-o)*100]};Ar.hwb.hcg=function(t){let e=t[1]/100,o=1-t[2]/100,a=o-e,n=0;return a<1&&(n=(o-a)/(1-a)),[t[0],a*100,n*100]};Ar.apple.rgb=function(t){return[t[0]/65535*255,t[1]/65535*255,t[2]/65535*255]};Ar.rgb.apple=function(t){return[t[0]/255*65535,t[1]/255*65535,t[2]/255*65535]};Ar.gray.rgb=function(t){return[t[0]/100*255,t[0]/100*255,t[0]/100*255]};Ar.gray.hsl=function(t){return[0,0,t[0]]};Ar.gray.hsv=Ar.gray.hsl;Ar.gray.hwb=function(t){return[0,100,t[0]]};Ar.gray.cmyk=function(t){return[0,0,0,t[0]]};Ar.gray.lab=function(t){return[t[0],0,0]};Ar.gray.hex=function(t){let e=Math.round(t[0]/100*255)&255,o=((e<<16)+(e<<8)+e).toString(16).toUpperCase();return"000000".substring(o.length)+o};Ar.rgb.gray=function(t){return[(t[0]+t[1]+t[2])/3/255*100]}});var oX=_((bQt,sX)=>{var SS=AN();function m5e(){let t={},e=Object.keys(SS);for(let r=e.length,o=0;o<r;o++)t[e[o]]={distance:-1,parent:null};return t}function y5e(t){let e=m5e(),r=[t];for(e[t].distance=0;r.length;){let o=r.pop(),a=Object.keys(SS[o]);for(let n=a.length,u=0;u<n;u++){let A=a[u],p=e[A];p.distance===-1&&(p.distance=e[o].distance+1,p.parent=o,r.unshift(A))}}return e}function E5e(t,e){return function(r){return e(t(r))}}function C5e(t,e){let r=[e[t].parent,t],o=SS[e[t].parent][t],a=e[t].parent;for(;e[a].parent;)r.unshift(e[a].parent),o=E5e(SS[e[a].parent][a],o),a=e[a].parent;return o.conversion=r,o}sX.exports=function(t){let e=y5e(t),r={},o=Object.keys(e);for(let a=o.length,n=0;n<a;n++){let u=o[n];e[u].parent!==null&&(r[u]=C5e(u,e))}return r}});var lX=_((xQt,aX)=>{var fN=AN(),w5e=oX(),xy={},I5e=Object.keys(fN);function B5e(t){let e=function(...r){let o=r[0];return o==null?o:(o.length>1&&(r=o),t(r))};return"conversion"in t&&(e.conversion=t.conversion),e}function v5e(t){let e=function(...r){let o=r[0];if(o==null)return o;o.length>1&&(r=o);let a=t(r);if(typeof a=="object")for(let n=a.length,u=0;u<n;u++)a[u]=Math.round(a[u]);return a};return"conversion"in t&&(e.conversion=t.conversion),e}I5e.forEach(t=>{xy[t]={},Object.defineProperty(xy[t],"channels",{value:fN[t].channels}),Object.defineProperty(xy[t],"labels",{value:fN[t].labels});let e=w5e(t);Object.keys(e).forEach(o=>{let a=e[o];xy[t][o]=v5e(a),xy[t][o].raw=B5e(a)})});aX.exports=xy});var DI=_((kQt,pX)=>{"use strict";var cX=(t,e)=>(...r)=>`\x1B[${t(...r)+e}m`,uX=(t,e)=>(...r)=>{let o=t(...r);return`\x1B[${38+e};5;${o}m`},AX=(t,e)=>(...r)=>{let o=t(...r);return`\x1B[${38+e};2;${o[0]};${o[1]};${o[2]}m`},PS=t=>t,fX=(t,e,r)=>[t,e,r],ky=(t,e,r)=>{Object.defineProperty(t,e,{get:()=>{let o=r();return Object.defineProperty(t,e,{value:o,enumerable:!0,configurable:!0}),o},enumerable:!0,configurable:!0})},pN,Qy=(t,e,r,o)=>{pN===void 0&&(pN=lX());let a=o?10:0,n={};for(let[u,A]of Object.entries(pN)){let p=u==="ansi16"?"ansi":u;u===e?n[p]=t(r,a):typeof A=="object"&&(n[p]=t(A[e],a))}return n};function D5e(){let t=new Map,e={modifier:{reset:[0,0],bold:[1,22],dim:[2,22],italic:[3,23],underline:[4,24],inverse:[7,27],hidden:[8,28],strikethrough:[9,29]},color:{black:[30,39],red:[31,39],green:[32,39],yellow:[33,39],blue:[34,39],magenta:[35,39],cyan:[36,39],white:[37,39],blackBright:[90,39],redBright:[91,39],greenBright:[92,39],yellowBright:[93,39],blueBright:[94,39],magentaBright:[95,39],cyanBright:[96,39],whiteBright:[97,39]},bgColor:{bgBlack:[40,49],bgRed:[41,49],bgGreen:[42,49],bgYellow:[43,49],bgBlue:[44,49],bgMagenta:[45,49],bgCyan:[46,49],bgWhite:[47,49],bgBlackBright:[100,49],bgRedBright:[101,49],bgGreenBright:[102,49],bgYellowBright:[103,49],bgBlueBright:[104,49],bgMagentaBright:[105,49],bgCyanBright:[106,49],bgWhiteBright:[107,49]}};e.color.gray=e.color.blackBright,e.bgColor.bgGray=e.bgColor.bgBlackBright,e.color.grey=e.color.blackBright,e.bgColor.bgGrey=e.bgColor.bgBlackBright;for(let[r,o]of Object.entries(e)){for(let[a,n]of Object.entries(o))e[a]={open:`\x1B[${n[0]}m`,close:`\x1B[${n[1]}m`},o[a]=e[a],t.set(n[0],n[1]);Object.defineProperty(e,r,{value:o,enumerable:!1})}return Object.defineProperty(e,"codes",{value:t,enumerable:!1}),e.color.close="\x1B[39m",e.bgColor.close="\x1B[49m",ky(e.color,"ansi",()=>Qy(cX,"ansi16",PS,!1)),ky(e.color,"ansi256",()=>Qy(uX,"ansi256",PS,!1)),ky(e.color,"ansi16m",()=>Qy(AX,"rgb",fX,!1)),ky(e.bgColor,"ansi",()=>Qy(cX,"ansi16",PS,!0)),ky(e.bgColor,"ansi256",()=>Qy(uX,"ansi256",PS,!0)),ky(e.bgColor,"ansi16m",()=>Qy(AX,"rgb",fX,!0)),e}Object.defineProperty(pX,"exports",{enumerable:!0,get:D5e})});var gX=_((QQt,hX)=>{"use strict";hX.exports=(t,e=process.argv)=>{let r=t.startsWith("-")?"":t.length===1?"-":"--",o=e.indexOf(r+t),a=e.indexOf("--");return o!==-1&&(a===-1||o<a)}});var dN=_((FQt,mX)=>{"use strict";var S5e=ve("os"),dX=ve("tty"),Ml=gX(),{env:ls}=process,Jp;Ml("no-color")||Ml("no-colors")||Ml("color=false")||Ml("color=never")?Jp=0:(Ml("color")||Ml("colors")||Ml("color=true")||Ml("color=always"))&&(Jp=1);"FORCE_COLOR"in ls&&(ls.FORCE_COLOR==="true"?Jp=1:ls.FORCE_COLOR==="false"?Jp=0:Jp=ls.FORCE_COLOR.length===0?1:Math.min(parseInt(ls.FORCE_COLOR,10),3));function hN(t){return t===0?!1:{level:t,hasBasic:!0,has256:t>=2,has16m:t>=3}}function gN(t,e){if(Jp===0)return 0;if(Ml("color=16m")||Ml("color=full")||Ml("color=truecolor"))return 3;if(Ml("color=256"))return 2;if(t&&!e&&Jp===void 0)return 0;let r=Jp||0;if(ls.TERM==="dumb")return r;if(process.platform==="win32"){let o=S5e.release().split(".");return Number(o[0])>=10&&Number(o[2])>=10586?Number(o[2])>=14931?3:2:1}if("CI"in ls)return["TRAVIS","CIRCLECI","APPVEYOR","GITLAB_CI"].some(o=>o in ls)||ls.CI_NAME==="codeship"?1:r;if("TEAMCITY_VERSION"in ls)return/^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(ls.TEAMCITY_VERSION)?1:0;if("GITHUB_ACTIONS"in ls)return 1;if(ls.COLORTERM==="truecolor")return 3;if("TERM_PROGRAM"in ls){let o=parseInt((ls.TERM_PROGRAM_VERSION||"").split(".")[0],10);switch(ls.TERM_PROGRAM){case"iTerm.app":return o>=3?3:2;case"Apple_Terminal":return 2}}return/-256(color)?$/i.test(ls.TERM)?2:/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(ls.TERM)||"COLORTERM"in ls?1:r}function P5e(t){let e=gN(t,t&&t.isTTY);return hN(e)}mX.exports={supportsColor:P5e,stdout:hN(gN(!0,dX.isatty(1))),stderr:hN(gN(!0,dX.isatty(2)))}});var EX=_((RQt,yX)=>{"use strict";var b5e=(t,e,r)=>{let o=t.indexOf(e);if(o===-1)return t;let a=e.length,n=0,u="";do u+=t.substr(n,o-n)+e+r,n=o+a,o=t.indexOf(e,n);while(o!==-1);return u+=t.substr(n),u},x5e=(t,e,r,o)=>{let a=0,n="";do{let u=t[o-1]==="\r";n+=t.substr(a,(u?o-1:o)-a)+e+(u?`\r +`:` +`)+r,a=o+1,o=t.indexOf(` +`,a)}while(o!==-1);return n+=t.substr(a),n};yX.exports={stringReplaceAll:b5e,stringEncaseCRLFWithFirstIndex:x5e}});var vX=_((TQt,BX)=>{"use strict";var k5e=/(?:\\(u(?:[a-f\d]{4}|\{[a-f\d]{1,6}\})|x[a-f\d]{2}|.))|(?:\{(~)?(\w+(?:\([^)]*\))?(?:\.\w+(?:\([^)]*\))?)*)(?:[ \t]|(?=\r?\n)))|(\})|((?:.|[\r\n\f])+?)/gi,CX=/(?:^|\.)(\w+)(?:\(([^)]*)\))?/g,Q5e=/^(['"])((?:\\.|(?!\1)[^\\])*)\1$/,F5e=/\\(u(?:[a-f\d]{4}|\{[a-f\d]{1,6}\})|x[a-f\d]{2}|.)|([^\\])/gi,R5e=new Map([["n",` +`],["r","\r"],["t"," "],["b","\b"],["f","\f"],["v","\v"],["0","\0"],["\\","\\"],["e","\x1B"],["a","\x07"]]);function IX(t){let e=t[0]==="u",r=t[1]==="{";return e&&!r&&t.length===5||t[0]==="x"&&t.length===3?String.fromCharCode(parseInt(t.slice(1),16)):e&&r?String.fromCodePoint(parseInt(t.slice(2,-1),16)):R5e.get(t)||t}function T5e(t,e){let r=[],o=e.trim().split(/\s*,\s*/g),a;for(let n of o){let u=Number(n);if(!Number.isNaN(u))r.push(u);else if(a=n.match(Q5e))r.push(a[2].replace(F5e,(A,p,h)=>p?IX(p):h));else throw new Error(`Invalid Chalk template style argument: ${n} (in style '${t}')`)}return r}function N5e(t){CX.lastIndex=0;let e=[],r;for(;(r=CX.exec(t))!==null;){let o=r[1];if(r[2]){let a=T5e(o,r[2]);e.push([o].concat(a))}else e.push([o])}return e}function wX(t,e){let r={};for(let a of e)for(let n of a.styles)r[n[0]]=a.inverse?null:n.slice(1);let o=t;for(let[a,n]of Object.entries(r))if(!!Array.isArray(n)){if(!(a in o))throw new Error(`Unknown Chalk style: ${a}`);o=n.length>0?o[a](...n):o[a]}return o}BX.exports=(t,e)=>{let r=[],o=[],a=[];if(e.replace(k5e,(n,u,A,p,h,E)=>{if(u)a.push(IX(u));else if(p){let I=a.join("");a=[],o.push(r.length===0?I:wX(t,r)(I)),r.push({inverse:A,styles:N5e(p)})}else if(h){if(r.length===0)throw new Error("Found extraneous } in Chalk template literal");o.push(wX(t,r)(a.join(""))),a=[],r.pop()}else a.push(E)}),o.push(a.join("")),r.length>0){let n=`Chalk template literal is missing ${r.length} closing bracket${r.length===1?"":"s"} (\`}\`)`;throw new Error(n)}return o.join("")}});var IN=_((NQt,bX)=>{"use strict";var SI=DI(),{stdout:yN,stderr:EN}=dN(),{stringReplaceAll:L5e,stringEncaseCRLFWithFirstIndex:O5e}=EX(),DX=["ansi","ansi","ansi256","ansi16m"],Fy=Object.create(null),M5e=(t,e={})=>{if(e.level>3||e.level<0)throw new Error("The `level` option should be an integer from 0 to 3");let r=yN?yN.level:0;t.level=e.level===void 0?r:e.level},CN=class{constructor(e){return SX(e)}},SX=t=>{let e={};return M5e(e,t),e.template=(...r)=>H5e(e.template,...r),Object.setPrototypeOf(e,bS.prototype),Object.setPrototypeOf(e.template,e),e.template.constructor=()=>{throw new Error("`chalk.constructor()` is deprecated. Use `new chalk.Instance()` instead.")},e.template.Instance=CN,e.template};function bS(t){return SX(t)}for(let[t,e]of Object.entries(SI))Fy[t]={get(){let r=xS(this,wN(e.open,e.close,this._styler),this._isEmpty);return Object.defineProperty(this,t,{value:r}),r}};Fy.visible={get(){let t=xS(this,this._styler,!0);return Object.defineProperty(this,"visible",{value:t}),t}};var PX=["rgb","hex","keyword","hsl","hsv","hwb","ansi","ansi256"];for(let t of PX)Fy[t]={get(){let{level:e}=this;return function(...r){let o=wN(SI.color[DX[e]][t](...r),SI.color.close,this._styler);return xS(this,o,this._isEmpty)}}};for(let t of PX){let e="bg"+t[0].toUpperCase()+t.slice(1);Fy[e]={get(){let{level:r}=this;return function(...o){let a=wN(SI.bgColor[DX[r]][t](...o),SI.bgColor.close,this._styler);return xS(this,a,this._isEmpty)}}}}var U5e=Object.defineProperties(()=>{},{...Fy,level:{enumerable:!0,get(){return this._generator.level},set(t){this._generator.level=t}}}),wN=(t,e,r)=>{let o,a;return r===void 0?(o=t,a=e):(o=r.openAll+t,a=e+r.closeAll),{open:t,close:e,openAll:o,closeAll:a,parent:r}},xS=(t,e,r)=>{let o=(...a)=>_5e(o,a.length===1?""+a[0]:a.join(" "));return o.__proto__=U5e,o._generator=t,o._styler=e,o._isEmpty=r,o},_5e=(t,e)=>{if(t.level<=0||!e)return t._isEmpty?"":e;let r=t._styler;if(r===void 0)return e;let{openAll:o,closeAll:a}=r;if(e.indexOf("\x1B")!==-1)for(;r!==void 0;)e=L5e(e,r.close,r.open),r=r.parent;let n=e.indexOf(` +`);return n!==-1&&(e=O5e(e,a,o,n)),o+e+a},mN,H5e=(t,...e)=>{let[r]=e;if(!Array.isArray(r))return e.join(" ");let o=e.slice(1),a=[r.raw[0]];for(let n=1;n<r.length;n++)a.push(String(o[n-1]).replace(/[{}\\]/g,"\\$&"),String(r.raw[n]));return mN===void 0&&(mN=vX()),mN(t,a.join(""))};Object.defineProperties(bS.prototype,Fy);var PI=bS();PI.supportsColor=yN;PI.stderr=bS({level:EN?EN.level:0});PI.stderr.supportsColor=EN;PI.Level={None:0,Basic:1,Ansi256:2,TrueColor:3,0:"None",1:"Basic",2:"Ansi256",3:"TrueColor"};bX.exports=PI});var kS=_(Ul=>{"use strict";Ul.isInteger=t=>typeof t=="number"?Number.isInteger(t):typeof t=="string"&&t.trim()!==""?Number.isInteger(Number(t)):!1;Ul.find=(t,e)=>t.nodes.find(r=>r.type===e);Ul.exceedsLimit=(t,e,r=1,o)=>o===!1||!Ul.isInteger(t)||!Ul.isInteger(e)?!1:(Number(e)-Number(t))/Number(r)>=o;Ul.escapeNode=(t,e=0,r)=>{let o=t.nodes[e];!o||(r&&o.type===r||o.type==="open"||o.type==="close")&&o.escaped!==!0&&(o.value="\\"+o.value,o.escaped=!0)};Ul.encloseBrace=t=>t.type!=="brace"?!1:t.commas>>0+t.ranges>>0===0?(t.invalid=!0,!0):!1;Ul.isInvalidBrace=t=>t.type!=="brace"?!1:t.invalid===!0||t.dollar?!0:t.commas>>0+t.ranges>>0===0||t.open!==!0||t.close!==!0?(t.invalid=!0,!0):!1;Ul.isOpenOrClose=t=>t.type==="open"||t.type==="close"?!0:t.open===!0||t.close===!0;Ul.reduce=t=>t.reduce((e,r)=>(r.type==="text"&&e.push(r.value),r.type==="range"&&(r.type="text"),e),[]);Ul.flatten=(...t)=>{let e=[],r=o=>{for(let a=0;a<o.length;a++){let n=o[a];Array.isArray(n)?r(n,e):n!==void 0&&e.push(n)}return e};return r(t),e}});var QS=_((OQt,kX)=>{"use strict";var xX=kS();kX.exports=(t,e={})=>{let r=(o,a={})=>{let n=e.escapeInvalid&&xX.isInvalidBrace(a),u=o.invalid===!0&&e.escapeInvalid===!0,A="";if(o.value)return(n||u)&&xX.isOpenOrClose(o)?"\\"+o.value:o.value;if(o.value)return o.value;if(o.nodes)for(let p of o.nodes)A+=r(p);return A};return r(t)}});var FX=_((MQt,QX)=>{"use strict";QX.exports=function(t){return typeof t=="number"?t-t===0:typeof t=="string"&&t.trim()!==""?Number.isFinite?Number.isFinite(+t):isFinite(+t):!1}});var HX=_((UQt,_X)=>{"use strict";var RX=FX(),Ad=(t,e,r)=>{if(RX(t)===!1)throw new TypeError("toRegexRange: expected the first argument to be a number");if(e===void 0||t===e)return String(t);if(RX(e)===!1)throw new TypeError("toRegexRange: expected the second argument to be a number.");let o={relaxZeros:!0,...r};typeof o.strictZeros=="boolean"&&(o.relaxZeros=o.strictZeros===!1);let a=String(o.relaxZeros),n=String(o.shorthand),u=String(o.capture),A=String(o.wrap),p=t+":"+e+"="+a+n+u+A;if(Ad.cache.hasOwnProperty(p))return Ad.cache[p].result;let h=Math.min(t,e),E=Math.max(t,e);if(Math.abs(h-E)===1){let R=t+"|"+e;return o.capture?`(${R})`:o.wrap===!1?R:`(?:${R})`}let I=UX(t)||UX(e),v={min:t,max:e,a:h,b:E},x=[],C=[];if(I&&(v.isPadded=I,v.maxLen=String(v.max).length),h<0){let R=E<0?Math.abs(E):1;C=TX(R,Math.abs(h),v,o),h=v.a=0}return E>=0&&(x=TX(h,E,v,o)),v.negatives=C,v.positives=x,v.result=j5e(C,x,o),o.capture===!0?v.result=`(${v.result})`:o.wrap!==!1&&x.length+C.length>1&&(v.result=`(?:${v.result})`),Ad.cache[p]=v,v.result};function j5e(t,e,r){let o=BN(t,e,"-",!1,r)||[],a=BN(e,t,"",!1,r)||[],n=BN(t,e,"-?",!0,r)||[];return o.concat(n).concat(a).join("|")}function G5e(t,e){let r=1,o=1,a=LX(t,r),n=new Set([e]);for(;t<=a&&a<=e;)n.add(a),r+=1,a=LX(t,r);for(a=OX(e+1,o)-1;t<a&&a<=e;)n.add(a),o+=1,a=OX(e+1,o)-1;return n=[...n],n.sort(W5e),n}function q5e(t,e,r){if(t===e)return{pattern:t,count:[],digits:0};let o=Y5e(t,e),a=o.length,n="",u=0;for(let A=0;A<a;A++){let[p,h]=o[A];p===h?n+=p:p!=="0"||h!=="9"?n+=K5e(p,h,r):u++}return u&&(n+=r.shorthand===!0?"\\d":"[0-9]"),{pattern:n,count:[u],digits:a}}function TX(t,e,r,o){let a=G5e(t,e),n=[],u=t,A;for(let p=0;p<a.length;p++){let h=a[p],E=q5e(String(u),String(h),o),I="";if(!r.isPadded&&A&&A.pattern===E.pattern){A.count.length>1&&A.count.pop(),A.count.push(E.count[0]),A.string=A.pattern+MX(A.count),u=h+1;continue}r.isPadded&&(I=V5e(h,r,o)),E.string=I+E.pattern+MX(E.count),n.push(E),u=h+1,A=E}return n}function BN(t,e,r,o,a){let n=[];for(let u of t){let{string:A}=u;!o&&!NX(e,"string",A)&&n.push(r+A),o&&NX(e,"string",A)&&n.push(r+A)}return n}function Y5e(t,e){let r=[];for(let o=0;o<t.length;o++)r.push([t[o],e[o]]);return r}function W5e(t,e){return t>e?1:e>t?-1:0}function NX(t,e,r){return t.some(o=>o[e]===r)}function LX(t,e){return Number(String(t).slice(0,-e)+"9".repeat(e))}function OX(t,e){return t-t%Math.pow(10,e)}function MX(t){let[e=0,r=""]=t;return r||e>1?`{${e+(r?","+r:"")}}`:""}function K5e(t,e,r){return`[${t}${e-t===1?"":"-"}${e}]`}function UX(t){return/^-?(0+)\d/.test(t)}function V5e(t,e,r){if(!e.isPadded)return t;let o=Math.abs(e.maxLen-String(t).length),a=r.relaxZeros!==!1;switch(o){case 0:return"";case 1:return a?"0?":"0";case 2:return a?"0{0,2}":"00";default:return a?`0{0,${o}}`:`0{${o}}`}}Ad.cache={};Ad.clearCache=()=>Ad.cache={};_X.exports=Ad});var SN=_((_Qt,JX)=>{"use strict";var J5e=ve("util"),qX=HX(),jX=t=>t!==null&&typeof t=="object"&&!Array.isArray(t),z5e=t=>e=>t===!0?Number(e):String(e),vN=t=>typeof t=="number"||typeof t=="string"&&t!=="",bI=t=>Number.isInteger(+t),DN=t=>{let e=`${t}`,r=-1;if(e[0]==="-"&&(e=e.slice(1)),e==="0")return!1;for(;e[++r]==="0";);return r>0},X5e=(t,e,r)=>typeof t=="string"||typeof e=="string"?!0:r.stringify===!0,Z5e=(t,e,r)=>{if(e>0){let o=t[0]==="-"?"-":"";o&&(t=t.slice(1)),t=o+t.padStart(o?e-1:e,"0")}return r===!1?String(t):t},GX=(t,e)=>{let r=t[0]==="-"?"-":"";for(r&&(t=t.slice(1),e--);t.length<e;)t="0"+t;return r?"-"+t:t},$5e=(t,e)=>{t.negatives.sort((u,A)=>u<A?-1:u>A?1:0),t.positives.sort((u,A)=>u<A?-1:u>A?1:0);let r=e.capture?"":"?:",o="",a="",n;return t.positives.length&&(o=t.positives.join("|")),t.negatives.length&&(a=`-(${r}${t.negatives.join("|")})`),o&&a?n=`${o}|${a}`:n=o||a,e.wrap?`(${r}${n})`:n},YX=(t,e,r,o)=>{if(r)return qX(t,e,{wrap:!1,...o});let a=String.fromCharCode(t);if(t===e)return a;let n=String.fromCharCode(e);return`[${a}-${n}]`},WX=(t,e,r)=>{if(Array.isArray(t)){let o=r.wrap===!0,a=r.capture?"":"?:";return o?`(${a}${t.join("|")})`:t.join("|")}return qX(t,e,r)},KX=(...t)=>new RangeError("Invalid range arguments: "+J5e.inspect(...t)),VX=(t,e,r)=>{if(r.strictRanges===!0)throw KX([t,e]);return[]},e7e=(t,e)=>{if(e.strictRanges===!0)throw new TypeError(`Expected step "${t}" to be a number`);return[]},t7e=(t,e,r=1,o={})=>{let a=Number(t),n=Number(e);if(!Number.isInteger(a)||!Number.isInteger(n)){if(o.strictRanges===!0)throw KX([t,e]);return[]}a===0&&(a=0),n===0&&(n=0);let u=a>n,A=String(t),p=String(e),h=String(r);r=Math.max(Math.abs(r),1);let E=DN(A)||DN(p)||DN(h),I=E?Math.max(A.length,p.length,h.length):0,v=E===!1&&X5e(t,e,o)===!1,x=o.transform||z5e(v);if(o.toRegex&&r===1)return YX(GX(t,I),GX(e,I),!0,o);let C={negatives:[],positives:[]},R=J=>C[J<0?"negatives":"positives"].push(Math.abs(J)),L=[],U=0;for(;u?a>=n:a<=n;)o.toRegex===!0&&r>1?R(a):L.push(Z5e(x(a,U),I,v)),a=u?a-r:a+r,U++;return o.toRegex===!0?r>1?$5e(C,o):WX(L,null,{wrap:!1,...o}):L},r7e=(t,e,r=1,o={})=>{if(!bI(t)&&t.length>1||!bI(e)&&e.length>1)return VX(t,e,o);let a=o.transform||(v=>String.fromCharCode(v)),n=`${t}`.charCodeAt(0),u=`${e}`.charCodeAt(0),A=n>u,p=Math.min(n,u),h=Math.max(n,u);if(o.toRegex&&r===1)return YX(p,h,!1,o);let E=[],I=0;for(;A?n>=u:n<=u;)E.push(a(n,I)),n=A?n-r:n+r,I++;return o.toRegex===!0?WX(E,null,{wrap:!1,options:o}):E},RS=(t,e,r,o={})=>{if(e==null&&vN(t))return[t];if(!vN(t)||!vN(e))return VX(t,e,o);if(typeof r=="function")return RS(t,e,1,{transform:r});if(jX(r))return RS(t,e,0,r);let a={...o};return a.capture===!0&&(a.wrap=!0),r=r||a.step||1,bI(r)?bI(t)&&bI(e)?t7e(t,e,r,a):r7e(t,e,Math.max(Math.abs(r),1),a):r!=null&&!jX(r)?e7e(r,a):RS(t,e,1,r)};JX.exports=RS});var ZX=_((HQt,XX)=>{"use strict";var n7e=SN(),zX=kS(),i7e=(t,e={})=>{let r=(o,a={})=>{let n=zX.isInvalidBrace(a),u=o.invalid===!0&&e.escapeInvalid===!0,A=n===!0||u===!0,p=e.escapeInvalid===!0?"\\":"",h="";if(o.isOpen===!0||o.isClose===!0)return p+o.value;if(o.type==="open")return A?p+o.value:"(";if(o.type==="close")return A?p+o.value:")";if(o.type==="comma")return o.prev.type==="comma"?"":A?o.value:"|";if(o.value)return o.value;if(o.nodes&&o.ranges>0){let E=zX.reduce(o.nodes),I=n7e(...E,{...e,wrap:!1,toRegex:!0});if(I.length!==0)return E.length>1&&I.length>1?`(${I})`:I}if(o.nodes)for(let E of o.nodes)h+=r(E,o);return h};return r(t)};XX.exports=i7e});var tZ=_((jQt,eZ)=>{"use strict";var s7e=SN(),$X=QS(),Ry=kS(),fd=(t="",e="",r=!1)=>{let o=[];if(t=[].concat(t),e=[].concat(e),!e.length)return t;if(!t.length)return r?Ry.flatten(e).map(a=>`{${a}}`):e;for(let a of t)if(Array.isArray(a))for(let n of a)o.push(fd(n,e,r));else for(let n of e)r===!0&&typeof n=="string"&&(n=`{${n}}`),o.push(Array.isArray(n)?fd(a,n,r):a+n);return Ry.flatten(o)},o7e=(t,e={})=>{let r=e.rangeLimit===void 0?1e3:e.rangeLimit,o=(a,n={})=>{a.queue=[];let u=n,A=n.queue;for(;u.type!=="brace"&&u.type!=="root"&&u.parent;)u=u.parent,A=u.queue;if(a.invalid||a.dollar){A.push(fd(A.pop(),$X(a,e)));return}if(a.type==="brace"&&a.invalid!==!0&&a.nodes.length===2){A.push(fd(A.pop(),["{}"]));return}if(a.nodes&&a.ranges>0){let I=Ry.reduce(a.nodes);if(Ry.exceedsLimit(...I,e.step,r))throw new RangeError("expanded array length exceeds range limit. Use options.rangeLimit to increase or disable the limit.");let v=s7e(...I,e);v.length===0&&(v=$X(a,e)),A.push(fd(A.pop(),v)),a.nodes=[];return}let p=Ry.encloseBrace(a),h=a.queue,E=a;for(;E.type!=="brace"&&E.type!=="root"&&E.parent;)E=E.parent,h=E.queue;for(let I=0;I<a.nodes.length;I++){let v=a.nodes[I];if(v.type==="comma"&&a.type==="brace"){I===1&&h.push(""),h.push("");continue}if(v.type==="close"){A.push(fd(A.pop(),h,p));continue}if(v.value&&v.type!=="open"){h.push(fd(h.pop(),v.value));continue}v.nodes&&o(v,a)}return h};return Ry.flatten(o(t))};eZ.exports=o7e});var nZ=_((GQt,rZ)=>{"use strict";rZ.exports={MAX_LENGTH:1024*64,CHAR_0:"0",CHAR_9:"9",CHAR_UPPERCASE_A:"A",CHAR_LOWERCASE_A:"a",CHAR_UPPERCASE_Z:"Z",CHAR_LOWERCASE_Z:"z",CHAR_LEFT_PARENTHESES:"(",CHAR_RIGHT_PARENTHESES:")",CHAR_ASTERISK:"*",CHAR_AMPERSAND:"&",CHAR_AT:"@",CHAR_BACKSLASH:"\\",CHAR_BACKTICK:"`",CHAR_CARRIAGE_RETURN:"\r",CHAR_CIRCUMFLEX_ACCENT:"^",CHAR_COLON:":",CHAR_COMMA:",",CHAR_DOLLAR:"$",CHAR_DOT:".",CHAR_DOUBLE_QUOTE:'"',CHAR_EQUAL:"=",CHAR_EXCLAMATION_MARK:"!",CHAR_FORM_FEED:"\f",CHAR_FORWARD_SLASH:"/",CHAR_HASH:"#",CHAR_HYPHEN_MINUS:"-",CHAR_LEFT_ANGLE_BRACKET:"<",CHAR_LEFT_CURLY_BRACE:"{",CHAR_LEFT_SQUARE_BRACKET:"[",CHAR_LINE_FEED:` +`,CHAR_NO_BREAK_SPACE:"\xA0",CHAR_PERCENT:"%",CHAR_PLUS:"+",CHAR_QUESTION_MARK:"?",CHAR_RIGHT_ANGLE_BRACKET:">",CHAR_RIGHT_CURLY_BRACE:"}",CHAR_RIGHT_SQUARE_BRACKET:"]",CHAR_SEMICOLON:";",CHAR_SINGLE_QUOTE:"'",CHAR_SPACE:" ",CHAR_TAB:" ",CHAR_UNDERSCORE:"_",CHAR_VERTICAL_LINE:"|",CHAR_ZERO_WIDTH_NOBREAK_SPACE:"\uFEFF"}});var lZ=_((qQt,aZ)=>{"use strict";var a7e=QS(),{MAX_LENGTH:iZ,CHAR_BACKSLASH:PN,CHAR_BACKTICK:l7e,CHAR_COMMA:c7e,CHAR_DOT:u7e,CHAR_LEFT_PARENTHESES:A7e,CHAR_RIGHT_PARENTHESES:f7e,CHAR_LEFT_CURLY_BRACE:p7e,CHAR_RIGHT_CURLY_BRACE:h7e,CHAR_LEFT_SQUARE_BRACKET:sZ,CHAR_RIGHT_SQUARE_BRACKET:oZ,CHAR_DOUBLE_QUOTE:g7e,CHAR_SINGLE_QUOTE:d7e,CHAR_NO_BREAK_SPACE:m7e,CHAR_ZERO_WIDTH_NOBREAK_SPACE:y7e}=nZ(),E7e=(t,e={})=>{if(typeof t!="string")throw new TypeError("Expected a string");let r=e||{},o=typeof r.maxLength=="number"?Math.min(iZ,r.maxLength):iZ;if(t.length>o)throw new SyntaxError(`Input length (${t.length}), exceeds max characters (${o})`);let a={type:"root",input:t,nodes:[]},n=[a],u=a,A=a,p=0,h=t.length,E=0,I=0,v,x={},C=()=>t[E++],R=L=>{if(L.type==="text"&&A.type==="dot"&&(A.type="text"),A&&A.type==="text"&&L.type==="text"){A.value+=L.value;return}return u.nodes.push(L),L.parent=u,L.prev=A,A=L,L};for(R({type:"bos"});E<h;)if(u=n[n.length-1],v=C(),!(v===y7e||v===m7e)){if(v===PN){R({type:"text",value:(e.keepEscaping?v:"")+C()});continue}if(v===oZ){R({type:"text",value:"\\"+v});continue}if(v===sZ){p++;let L=!0,U;for(;E<h&&(U=C());){if(v+=U,U===sZ){p++;continue}if(U===PN){v+=C();continue}if(U===oZ&&(p--,p===0))break}R({type:"text",value:v});continue}if(v===A7e){u=R({type:"paren",nodes:[]}),n.push(u),R({type:"text",value:v});continue}if(v===f7e){if(u.type!=="paren"){R({type:"text",value:v});continue}u=n.pop(),R({type:"text",value:v}),u=n[n.length-1];continue}if(v===g7e||v===d7e||v===l7e){let L=v,U;for(e.keepQuotes!==!0&&(v="");E<h&&(U=C());){if(U===PN){v+=U+C();continue}if(U===L){e.keepQuotes===!0&&(v+=U);break}v+=U}R({type:"text",value:v});continue}if(v===p7e){I++;let U={type:"brace",open:!0,close:!1,dollar:A.value&&A.value.slice(-1)==="$"||u.dollar===!0,depth:I,commas:0,ranges:0,nodes:[]};u=R(U),n.push(u),R({type:"open",value:v});continue}if(v===h7e){if(u.type!=="brace"){R({type:"text",value:v});continue}let L="close";u=n.pop(),u.close=!0,R({type:L,value:v}),I--,u=n[n.length-1];continue}if(v===c7e&&I>0){if(u.ranges>0){u.ranges=0;let L=u.nodes.shift();u.nodes=[L,{type:"text",value:a7e(u)}]}R({type:"comma",value:v}),u.commas++;continue}if(v===u7e&&I>0&&u.commas===0){let L=u.nodes;if(I===0||L.length===0){R({type:"text",value:v});continue}if(A.type==="dot"){if(u.range=[],A.value+=v,A.type="range",u.nodes.length!==3&&u.nodes.length!==5){u.invalid=!0,u.ranges=0,A.type="text";continue}u.ranges++,u.args=[];continue}if(A.type==="range"){L.pop();let U=L[L.length-1];U.value+=A.value+v,A=U,u.ranges--;continue}R({type:"dot",value:v});continue}R({type:"text",value:v})}do if(u=n.pop(),u.type!=="root"){u.nodes.forEach(J=>{J.nodes||(J.type==="open"&&(J.isOpen=!0),J.type==="close"&&(J.isClose=!0),J.nodes||(J.type="text"),J.invalid=!0)});let L=n[n.length-1],U=L.nodes.indexOf(u);L.nodes.splice(U,1,...u.nodes)}while(n.length>0);return R({type:"eos"}),a};aZ.exports=E7e});var AZ=_((YQt,uZ)=>{"use strict";var cZ=QS(),C7e=ZX(),w7e=tZ(),I7e=lZ(),rl=(t,e={})=>{let r=[];if(Array.isArray(t))for(let o of t){let a=rl.create(o,e);Array.isArray(a)?r.push(...a):r.push(a)}else r=[].concat(rl.create(t,e));return e&&e.expand===!0&&e.nodupes===!0&&(r=[...new Set(r)]),r};rl.parse=(t,e={})=>I7e(t,e);rl.stringify=(t,e={})=>cZ(typeof t=="string"?rl.parse(t,e):t,e);rl.compile=(t,e={})=>(typeof t=="string"&&(t=rl.parse(t,e)),C7e(t,e));rl.expand=(t,e={})=>{typeof t=="string"&&(t=rl.parse(t,e));let r=w7e(t,e);return e.noempty===!0&&(r=r.filter(Boolean)),e.nodupes===!0&&(r=[...new Set(r)]),r};rl.create=(t,e={})=>t===""||t.length<3?[t]:e.expand!==!0?rl.compile(t,e):rl.expand(t,e);uZ.exports=rl});var xI=_((WQt,dZ)=>{"use strict";var B7e=ve("path"),Ku="\\\\/",fZ=`[^${Ku}]`,vf="\\.",v7e="\\+",D7e="\\?",TS="\\/",S7e="(?=.)",pZ="[^/]",bN=`(?:${TS}|$)`,hZ=`(?:^|${TS})`,xN=`${vf}{1,2}${bN}`,P7e=`(?!${vf})`,b7e=`(?!${hZ}${xN})`,x7e=`(?!${vf}{0,1}${bN})`,k7e=`(?!${xN})`,Q7e=`[^.${TS}]`,F7e=`${pZ}*?`,gZ={DOT_LITERAL:vf,PLUS_LITERAL:v7e,QMARK_LITERAL:D7e,SLASH_LITERAL:TS,ONE_CHAR:S7e,QMARK:pZ,END_ANCHOR:bN,DOTS_SLASH:xN,NO_DOT:P7e,NO_DOTS:b7e,NO_DOT_SLASH:x7e,NO_DOTS_SLASH:k7e,QMARK_NO_DOT:Q7e,STAR:F7e,START_ANCHOR:hZ},R7e={...gZ,SLASH_LITERAL:`[${Ku}]`,QMARK:fZ,STAR:`${fZ}*?`,DOTS_SLASH:`${vf}{1,2}(?:[${Ku}]|$)`,NO_DOT:`(?!${vf})`,NO_DOTS:`(?!(?:^|[${Ku}])${vf}{1,2}(?:[${Ku}]|$))`,NO_DOT_SLASH:`(?!${vf}{0,1}(?:[${Ku}]|$))`,NO_DOTS_SLASH:`(?!${vf}{1,2}(?:[${Ku}]|$))`,QMARK_NO_DOT:`[^.${Ku}]`,START_ANCHOR:`(?:^|[${Ku}])`,END_ANCHOR:`(?:[${Ku}]|$)`},T7e={alnum:"a-zA-Z0-9",alpha:"a-zA-Z",ascii:"\\x00-\\x7F",blank:" \\t",cntrl:"\\x00-\\x1F\\x7F",digit:"0-9",graph:"\\x21-\\x7E",lower:"a-z",print:"\\x20-\\x7E ",punct:"\\-!\"#$%&'()\\*+,./:;<=>?@[\\]^_`{|}~",space:" \\t\\r\\n\\v\\f",upper:"A-Z",word:"A-Za-z0-9_",xdigit:"A-Fa-f0-9"};dZ.exports={MAX_LENGTH:1024*64,POSIX_REGEX_SOURCE:T7e,REGEX_BACKSLASH:/\\(?![*+?^${}(|)[\]])/g,REGEX_NON_SPECIAL_CHARS:/^[^@![\].,$*+?^{}()|\\/]+/,REGEX_SPECIAL_CHARS:/[-*+?.^${}(|)[\]]/,REGEX_SPECIAL_CHARS_BACKREF:/(\\?)((\W)(\3*))/g,REGEX_SPECIAL_CHARS_GLOBAL:/([-*+?.^${}(|)[\]])/g,REGEX_REMOVE_BACKSLASH:/(?:\[.*?[^\\]\]|\\(?=.))/g,REPLACEMENTS:{"***":"*","**/**":"**","**/**/**":"**"},CHAR_0:48,CHAR_9:57,CHAR_UPPERCASE_A:65,CHAR_LOWERCASE_A:97,CHAR_UPPERCASE_Z:90,CHAR_LOWERCASE_Z:122,CHAR_LEFT_PARENTHESES:40,CHAR_RIGHT_PARENTHESES:41,CHAR_ASTERISK:42,CHAR_AMPERSAND:38,CHAR_AT:64,CHAR_BACKWARD_SLASH:92,CHAR_CARRIAGE_RETURN:13,CHAR_CIRCUMFLEX_ACCENT:94,CHAR_COLON:58,CHAR_COMMA:44,CHAR_DOT:46,CHAR_DOUBLE_QUOTE:34,CHAR_EQUAL:61,CHAR_EXCLAMATION_MARK:33,CHAR_FORM_FEED:12,CHAR_FORWARD_SLASH:47,CHAR_GRAVE_ACCENT:96,CHAR_HASH:35,CHAR_HYPHEN_MINUS:45,CHAR_LEFT_ANGLE_BRACKET:60,CHAR_LEFT_CURLY_BRACE:123,CHAR_LEFT_SQUARE_BRACKET:91,CHAR_LINE_FEED:10,CHAR_NO_BREAK_SPACE:160,CHAR_PERCENT:37,CHAR_PLUS:43,CHAR_QUESTION_MARK:63,CHAR_RIGHT_ANGLE_BRACKET:62,CHAR_RIGHT_CURLY_BRACE:125,CHAR_RIGHT_SQUARE_BRACKET:93,CHAR_SEMICOLON:59,CHAR_SINGLE_QUOTE:39,CHAR_SPACE:32,CHAR_TAB:9,CHAR_UNDERSCORE:95,CHAR_VERTICAL_LINE:124,CHAR_ZERO_WIDTH_NOBREAK_SPACE:65279,SEP:B7e.sep,extglobChars(t){return{"!":{type:"negate",open:"(?:(?!(?:",close:`))${t.STAR})`},"?":{type:"qmark",open:"(?:",close:")?"},"+":{type:"plus",open:"(?:",close:")+"},"*":{type:"star",open:"(?:",close:")*"},"@":{type:"at",open:"(?:",close:")"}}},globChars(t){return t===!0?R7e:gZ}}});var kI=_(Sa=>{"use strict";var N7e=ve("path"),L7e=process.platform==="win32",{REGEX_BACKSLASH:O7e,REGEX_REMOVE_BACKSLASH:M7e,REGEX_SPECIAL_CHARS:U7e,REGEX_SPECIAL_CHARS_GLOBAL:_7e}=xI();Sa.isObject=t=>t!==null&&typeof t=="object"&&!Array.isArray(t);Sa.hasRegexChars=t=>U7e.test(t);Sa.isRegexChar=t=>t.length===1&&Sa.hasRegexChars(t);Sa.escapeRegex=t=>t.replace(_7e,"\\$1");Sa.toPosixSlashes=t=>t.replace(O7e,"/");Sa.removeBackslashes=t=>t.replace(M7e,e=>e==="\\"?"":e);Sa.supportsLookbehinds=()=>{let t=process.version.slice(1).split(".").map(Number);return t.length===3&&t[0]>=9||t[0]===8&&t[1]>=10};Sa.isWindows=t=>t&&typeof t.windows=="boolean"?t.windows:L7e===!0||N7e.sep==="\\";Sa.escapeLast=(t,e,r)=>{let o=t.lastIndexOf(e,r);return o===-1?t:t[o-1]==="\\"?Sa.escapeLast(t,e,o-1):`${t.slice(0,o)}\\${t.slice(o)}`};Sa.removePrefix=(t,e={})=>{let r=t;return r.startsWith("./")&&(r=r.slice(2),e.prefix="./"),r};Sa.wrapOutput=(t,e={},r={})=>{let o=r.contains?"":"^",a=r.contains?"":"$",n=`${o}(?:${t})${a}`;return e.negated===!0&&(n=`(?:^(?!${n}).*$)`),n}});var vZ=_((VQt,BZ)=>{"use strict";var mZ=kI(),{CHAR_ASTERISK:kN,CHAR_AT:H7e,CHAR_BACKWARD_SLASH:QI,CHAR_COMMA:j7e,CHAR_DOT:QN,CHAR_EXCLAMATION_MARK:FN,CHAR_FORWARD_SLASH:IZ,CHAR_LEFT_CURLY_BRACE:RN,CHAR_LEFT_PARENTHESES:TN,CHAR_LEFT_SQUARE_BRACKET:G7e,CHAR_PLUS:q7e,CHAR_QUESTION_MARK:yZ,CHAR_RIGHT_CURLY_BRACE:Y7e,CHAR_RIGHT_PARENTHESES:EZ,CHAR_RIGHT_SQUARE_BRACKET:W7e}=xI(),CZ=t=>t===IZ||t===QI,wZ=t=>{t.isPrefix!==!0&&(t.depth=t.isGlobstar?1/0:1)},K7e=(t,e)=>{let r=e||{},o=t.length-1,a=r.parts===!0||r.scanToEnd===!0,n=[],u=[],A=[],p=t,h=-1,E=0,I=0,v=!1,x=!1,C=!1,R=!1,L=!1,U=!1,J=!1,te=!1,ae=!1,fe=!1,ce=0,me,he,Be={value:"",depth:0,isGlob:!1},we=()=>h>=o,g=()=>p.charCodeAt(h+1),Ee=()=>(me=he,p.charCodeAt(++h));for(;h<o;){he=Ee();let Ie;if(he===QI){J=Be.backslashes=!0,he=Ee(),he===RN&&(U=!0);continue}if(U===!0||he===RN){for(ce++;we()!==!0&&(he=Ee());){if(he===QI){J=Be.backslashes=!0,Ee();continue}if(he===RN){ce++;continue}if(U!==!0&&he===QN&&(he=Ee())===QN){if(v=Be.isBrace=!0,C=Be.isGlob=!0,fe=!0,a===!0)continue;break}if(U!==!0&&he===j7e){if(v=Be.isBrace=!0,C=Be.isGlob=!0,fe=!0,a===!0)continue;break}if(he===Y7e&&(ce--,ce===0)){U=!1,v=Be.isBrace=!0,fe=!0;break}}if(a===!0)continue;break}if(he===IZ){if(n.push(h),u.push(Be),Be={value:"",depth:0,isGlob:!1},fe===!0)continue;if(me===QN&&h===E+1){E+=2;continue}I=h+1;continue}if(r.noext!==!0&&(he===q7e||he===H7e||he===kN||he===yZ||he===FN)===!0&&g()===TN){if(C=Be.isGlob=!0,R=Be.isExtglob=!0,fe=!0,he===FN&&h===E&&(ae=!0),a===!0){for(;we()!==!0&&(he=Ee());){if(he===QI){J=Be.backslashes=!0,he=Ee();continue}if(he===EZ){C=Be.isGlob=!0,fe=!0;break}}continue}break}if(he===kN){if(me===kN&&(L=Be.isGlobstar=!0),C=Be.isGlob=!0,fe=!0,a===!0)continue;break}if(he===yZ){if(C=Be.isGlob=!0,fe=!0,a===!0)continue;break}if(he===G7e){for(;we()!==!0&&(Ie=Ee());){if(Ie===QI){J=Be.backslashes=!0,Ee();continue}if(Ie===W7e){x=Be.isBracket=!0,C=Be.isGlob=!0,fe=!0;break}}if(a===!0)continue;break}if(r.nonegate!==!0&&he===FN&&h===E){te=Be.negated=!0,E++;continue}if(r.noparen!==!0&&he===TN){if(C=Be.isGlob=!0,a===!0){for(;we()!==!0&&(he=Ee());){if(he===TN){J=Be.backslashes=!0,he=Ee();continue}if(he===EZ){fe=!0;break}}continue}break}if(C===!0){if(fe=!0,a===!0)continue;break}}r.noext===!0&&(R=!1,C=!1);let Se=p,le="",ne="";E>0&&(le=p.slice(0,E),p=p.slice(E),I-=E),Se&&C===!0&&I>0?(Se=p.slice(0,I),ne=p.slice(I)):C===!0?(Se="",ne=p):Se=p,Se&&Se!==""&&Se!=="/"&&Se!==p&&CZ(Se.charCodeAt(Se.length-1))&&(Se=Se.slice(0,-1)),r.unescape===!0&&(ne&&(ne=mZ.removeBackslashes(ne)),Se&&J===!0&&(Se=mZ.removeBackslashes(Se)));let ee={prefix:le,input:t,start:E,base:Se,glob:ne,isBrace:v,isBracket:x,isGlob:C,isExtglob:R,isGlobstar:L,negated:te,negatedExtglob:ae};if(r.tokens===!0&&(ee.maxDepth=0,CZ(he)||u.push(Be),ee.tokens=u),r.parts===!0||r.tokens===!0){let Ie;for(let Fe=0;Fe<n.length;Fe++){let At=Ie?Ie+1:E,H=n[Fe],at=t.slice(At,H);r.tokens&&(Fe===0&&E!==0?(u[Fe].isPrefix=!0,u[Fe].value=le):u[Fe].value=at,wZ(u[Fe]),ee.maxDepth+=u[Fe].depth),(Fe!==0||at!=="")&&A.push(at),Ie=H}if(Ie&&Ie+1<t.length){let Fe=t.slice(Ie+1);A.push(Fe),r.tokens&&(u[u.length-1].value=Fe,wZ(u[u.length-1]),ee.maxDepth+=u[u.length-1].depth)}ee.slashes=n,ee.parts=A}return ee};BZ.exports=K7e});var PZ=_((JQt,SZ)=>{"use strict";var NS=xI(),nl=kI(),{MAX_LENGTH:LS,POSIX_REGEX_SOURCE:V7e,REGEX_NON_SPECIAL_CHARS:J7e,REGEX_SPECIAL_CHARS_BACKREF:z7e,REPLACEMENTS:DZ}=NS,X7e=(t,e)=>{if(typeof e.expandRange=="function")return e.expandRange(...t,e);t.sort();let r=`[${t.join("-")}]`;try{new RegExp(r)}catch{return t.map(a=>nl.escapeRegex(a)).join("..")}return r},Ty=(t,e)=>`Missing ${t}: "${e}" - use "\\\\${e}" to match literal characters`,NN=(t,e)=>{if(typeof t!="string")throw new TypeError("Expected a string");t=DZ[t]||t;let r={...e},o=typeof r.maxLength=="number"?Math.min(LS,r.maxLength):LS,a=t.length;if(a>o)throw new SyntaxError(`Input length: ${a}, exceeds maximum allowed length: ${o}`);let n={type:"bos",value:"",output:r.prepend||""},u=[n],A=r.capture?"":"?:",p=nl.isWindows(e),h=NS.globChars(p),E=NS.extglobChars(h),{DOT_LITERAL:I,PLUS_LITERAL:v,SLASH_LITERAL:x,ONE_CHAR:C,DOTS_SLASH:R,NO_DOT:L,NO_DOT_SLASH:U,NO_DOTS_SLASH:J,QMARK:te,QMARK_NO_DOT:ae,STAR:fe,START_ANCHOR:ce}=h,me=b=>`(${A}(?:(?!${ce}${b.dot?R:I}).)*?)`,he=r.dot?"":L,Be=r.dot?te:ae,we=r.bash===!0?me(r):fe;r.capture&&(we=`(${we})`),typeof r.noext=="boolean"&&(r.noextglob=r.noext);let g={input:t,index:-1,start:0,dot:r.dot===!0,consumed:"",output:"",prefix:"",backtrack:!1,negated:!1,brackets:0,braces:0,parens:0,quotes:0,globstar:!1,tokens:u};t=nl.removePrefix(t,g),a=t.length;let Ee=[],Se=[],le=[],ne=n,ee,Ie=()=>g.index===a-1,Fe=g.peek=(b=1)=>t[g.index+b],At=g.advance=()=>t[++g.index]||"",H=()=>t.slice(g.index+1),at=(b="",w=0)=>{g.consumed+=b,g.index+=w},Re=b=>{g.output+=b.output!=null?b.output:b.value,at(b.value)},ke=()=>{let b=1;for(;Fe()==="!"&&(Fe(2)!=="("||Fe(3)==="?");)At(),g.start++,b++;return b%2===0?!1:(g.negated=!0,g.start++,!0)},xe=b=>{g[b]++,le.push(b)},He=b=>{g[b]--,le.pop()},Te=b=>{if(ne.type==="globstar"){let w=g.braces>0&&(b.type==="comma"||b.type==="brace"),P=b.extglob===!0||Ee.length&&(b.type==="pipe"||b.type==="paren");b.type!=="slash"&&b.type!=="paren"&&!w&&!P&&(g.output=g.output.slice(0,-ne.output.length),ne.type="star",ne.value="*",ne.output=we,g.output+=ne.output)}if(Ee.length&&b.type!=="paren"&&(Ee[Ee.length-1].inner+=b.value),(b.value||b.output)&&Re(b),ne&&ne.type==="text"&&b.type==="text"){ne.value+=b.value,ne.output=(ne.output||"")+b.value;return}b.prev=ne,u.push(b),ne=b},Je=(b,w)=>{let P={...E[w],conditions:1,inner:""};P.prev=ne,P.parens=g.parens,P.output=g.output;let y=(r.capture?"(":"")+P.open;xe("parens"),Te({type:b,value:w,output:g.output?"":C}),Te({type:"paren",extglob:!0,value:At(),output:y}),Ee.push(P)},je=b=>{let w=b.close+(r.capture?")":""),P;if(b.type==="negate"){let y=we;if(b.inner&&b.inner.length>1&&b.inner.includes("/")&&(y=me(r)),(y!==we||Ie()||/^\)+$/.test(H()))&&(w=b.close=`)$))${y}`),b.inner.includes("*")&&(P=H())&&/^\.[^\\/.]+$/.test(P)){let F=NN(P,{...e,fastpaths:!1}).output;w=b.close=`)${F})${y})`}b.prev.type==="bos"&&(g.negatedExtglob=!0)}Te({type:"paren",extglob:!0,value:ee,output:w}),He("parens")};if(r.fastpaths!==!1&&!/(^[*!]|[/()[\]{}"])/.test(t)){let b=!1,w=t.replace(z7e,(P,y,F,z,X,Z)=>z==="\\"?(b=!0,P):z==="?"?y?y+z+(X?te.repeat(X.length):""):Z===0?Be+(X?te.repeat(X.length):""):te.repeat(F.length):z==="."?I.repeat(F.length):z==="*"?y?y+z+(X?we:""):we:y?P:`\\${P}`);return b===!0&&(r.unescape===!0?w=w.replace(/\\/g,""):w=w.replace(/\\+/g,P=>P.length%2===0?"\\\\":P?"\\":"")),w===t&&r.contains===!0?(g.output=t,g):(g.output=nl.wrapOutput(w,g,e),g)}for(;!Ie();){if(ee=At(),ee==="\0")continue;if(ee==="\\"){let P=Fe();if(P==="/"&&r.bash!==!0||P==="."||P===";")continue;if(!P){ee+="\\",Te({type:"text",value:ee});continue}let y=/^\\+/.exec(H()),F=0;if(y&&y[0].length>2&&(F=y[0].length,g.index+=F,F%2!==0&&(ee+="\\")),r.unescape===!0?ee=At():ee+=At(),g.brackets===0){Te({type:"text",value:ee});continue}}if(g.brackets>0&&(ee!=="]"||ne.value==="["||ne.value==="[^")){if(r.posix!==!1&&ee===":"){let P=ne.value.slice(1);if(P.includes("[")&&(ne.posix=!0,P.includes(":"))){let y=ne.value.lastIndexOf("["),F=ne.value.slice(0,y),z=ne.value.slice(y+2),X=V7e[z];if(X){ne.value=F+X,g.backtrack=!0,At(),!n.output&&u.indexOf(ne)===1&&(n.output=C);continue}}}(ee==="["&&Fe()!==":"||ee==="-"&&Fe()==="]")&&(ee=`\\${ee}`),ee==="]"&&(ne.value==="["||ne.value==="[^")&&(ee=`\\${ee}`),r.posix===!0&&ee==="!"&&ne.value==="["&&(ee="^"),ne.value+=ee,Re({value:ee});continue}if(g.quotes===1&&ee!=='"'){ee=nl.escapeRegex(ee),ne.value+=ee,Re({value:ee});continue}if(ee==='"'){g.quotes=g.quotes===1?0:1,r.keepQuotes===!0&&Te({type:"text",value:ee});continue}if(ee==="("){xe("parens"),Te({type:"paren",value:ee});continue}if(ee===")"){if(g.parens===0&&r.strictBrackets===!0)throw new SyntaxError(Ty("opening","("));let P=Ee[Ee.length-1];if(P&&g.parens===P.parens+1){je(Ee.pop());continue}Te({type:"paren",value:ee,output:g.parens?")":"\\)"}),He("parens");continue}if(ee==="["){if(r.nobracket===!0||!H().includes("]")){if(r.nobracket!==!0&&r.strictBrackets===!0)throw new SyntaxError(Ty("closing","]"));ee=`\\${ee}`}else xe("brackets");Te({type:"bracket",value:ee});continue}if(ee==="]"){if(r.nobracket===!0||ne&&ne.type==="bracket"&&ne.value.length===1){Te({type:"text",value:ee,output:`\\${ee}`});continue}if(g.brackets===0){if(r.strictBrackets===!0)throw new SyntaxError(Ty("opening","["));Te({type:"text",value:ee,output:`\\${ee}`});continue}He("brackets");let P=ne.value.slice(1);if(ne.posix!==!0&&P[0]==="^"&&!P.includes("/")&&(ee=`/${ee}`),ne.value+=ee,Re({value:ee}),r.literalBrackets===!1||nl.hasRegexChars(P))continue;let y=nl.escapeRegex(ne.value);if(g.output=g.output.slice(0,-ne.value.length),r.literalBrackets===!0){g.output+=y,ne.value=y;continue}ne.value=`(${A}${y}|${ne.value})`,g.output+=ne.value;continue}if(ee==="{"&&r.nobrace!==!0){xe("braces");let P={type:"brace",value:ee,output:"(",outputIndex:g.output.length,tokensIndex:g.tokens.length};Se.push(P),Te(P);continue}if(ee==="}"){let P=Se[Se.length-1];if(r.nobrace===!0||!P){Te({type:"text",value:ee,output:ee});continue}let y=")";if(P.dots===!0){let F=u.slice(),z=[];for(let X=F.length-1;X>=0&&(u.pop(),F[X].type!=="brace");X--)F[X].type!=="dots"&&z.unshift(F[X].value);y=X7e(z,r),g.backtrack=!0}if(P.comma!==!0&&P.dots!==!0){let F=g.output.slice(0,P.outputIndex),z=g.tokens.slice(P.tokensIndex);P.value=P.output="\\{",ee=y="\\}",g.output=F;for(let X of z)g.output+=X.output||X.value}Te({type:"brace",value:ee,output:y}),He("braces"),Se.pop();continue}if(ee==="|"){Ee.length>0&&Ee[Ee.length-1].conditions++,Te({type:"text",value:ee});continue}if(ee===","){let P=ee,y=Se[Se.length-1];y&&le[le.length-1]==="braces"&&(y.comma=!0,P="|"),Te({type:"comma",value:ee,output:P});continue}if(ee==="/"){if(ne.type==="dot"&&g.index===g.start+1){g.start=g.index+1,g.consumed="",g.output="",u.pop(),ne=n;continue}Te({type:"slash",value:ee,output:x});continue}if(ee==="."){if(g.braces>0&&ne.type==="dot"){ne.value==="."&&(ne.output=I);let P=Se[Se.length-1];ne.type="dots",ne.output+=ee,ne.value+=ee,P.dots=!0;continue}if(g.braces+g.parens===0&&ne.type!=="bos"&&ne.type!=="slash"){Te({type:"text",value:ee,output:I});continue}Te({type:"dot",value:ee,output:I});continue}if(ee==="?"){if(!(ne&&ne.value==="(")&&r.noextglob!==!0&&Fe()==="("&&Fe(2)!=="?"){Je("qmark",ee);continue}if(ne&&ne.type==="paren"){let y=Fe(),F=ee;if(y==="<"&&!nl.supportsLookbehinds())throw new Error("Node.js v10 or higher is required for regex lookbehinds");(ne.value==="("&&!/[!=<:]/.test(y)||y==="<"&&!/<([!=]|\w+>)/.test(H()))&&(F=`\\${ee}`),Te({type:"text",value:ee,output:F});continue}if(r.dot!==!0&&(ne.type==="slash"||ne.type==="bos")){Te({type:"qmark",value:ee,output:ae});continue}Te({type:"qmark",value:ee,output:te});continue}if(ee==="!"){if(r.noextglob!==!0&&Fe()==="("&&(Fe(2)!=="?"||!/[!=<:]/.test(Fe(3)))){Je("negate",ee);continue}if(r.nonegate!==!0&&g.index===0){ke();continue}}if(ee==="+"){if(r.noextglob!==!0&&Fe()==="("&&Fe(2)!=="?"){Je("plus",ee);continue}if(ne&&ne.value==="("||r.regex===!1){Te({type:"plus",value:ee,output:v});continue}if(ne&&(ne.type==="bracket"||ne.type==="paren"||ne.type==="brace")||g.parens>0){Te({type:"plus",value:ee});continue}Te({type:"plus",value:v});continue}if(ee==="@"){if(r.noextglob!==!0&&Fe()==="("&&Fe(2)!=="?"){Te({type:"at",extglob:!0,value:ee,output:""});continue}Te({type:"text",value:ee});continue}if(ee!=="*"){(ee==="$"||ee==="^")&&(ee=`\\${ee}`);let P=J7e.exec(H());P&&(ee+=P[0],g.index+=P[0].length),Te({type:"text",value:ee});continue}if(ne&&(ne.type==="globstar"||ne.star===!0)){ne.type="star",ne.star=!0,ne.value+=ee,ne.output=we,g.backtrack=!0,g.globstar=!0,at(ee);continue}let b=H();if(r.noextglob!==!0&&/^\([^?]/.test(b)){Je("star",ee);continue}if(ne.type==="star"){if(r.noglobstar===!0){at(ee);continue}let P=ne.prev,y=P.prev,F=P.type==="slash"||P.type==="bos",z=y&&(y.type==="star"||y.type==="globstar");if(r.bash===!0&&(!F||b[0]&&b[0]!=="/")){Te({type:"star",value:ee,output:""});continue}let X=g.braces>0&&(P.type==="comma"||P.type==="brace"),Z=Ee.length&&(P.type==="pipe"||P.type==="paren");if(!F&&P.type!=="paren"&&!X&&!Z){Te({type:"star",value:ee,output:""});continue}for(;b.slice(0,3)==="/**";){let ie=t[g.index+4];if(ie&&ie!=="/")break;b=b.slice(3),at("/**",3)}if(P.type==="bos"&&Ie()){ne.type="globstar",ne.value+=ee,ne.output=me(r),g.output=ne.output,g.globstar=!0,at(ee);continue}if(P.type==="slash"&&P.prev.type!=="bos"&&!z&&Ie()){g.output=g.output.slice(0,-(P.output+ne.output).length),P.output=`(?:${P.output}`,ne.type="globstar",ne.output=me(r)+(r.strictSlashes?")":"|$)"),ne.value+=ee,g.globstar=!0,g.output+=P.output+ne.output,at(ee);continue}if(P.type==="slash"&&P.prev.type!=="bos"&&b[0]==="/"){let ie=b[1]!==void 0?"|$":"";g.output=g.output.slice(0,-(P.output+ne.output).length),P.output=`(?:${P.output}`,ne.type="globstar",ne.output=`${me(r)}${x}|${x}${ie})`,ne.value+=ee,g.output+=P.output+ne.output,g.globstar=!0,at(ee+At()),Te({type:"slash",value:"/",output:""});continue}if(P.type==="bos"&&b[0]==="/"){ne.type="globstar",ne.value+=ee,ne.output=`(?:^|${x}|${me(r)}${x})`,g.output=ne.output,g.globstar=!0,at(ee+At()),Te({type:"slash",value:"/",output:""});continue}g.output=g.output.slice(0,-ne.output.length),ne.type="globstar",ne.output=me(r),ne.value+=ee,g.output+=ne.output,g.globstar=!0,at(ee);continue}let w={type:"star",value:ee,output:we};if(r.bash===!0){w.output=".*?",(ne.type==="bos"||ne.type==="slash")&&(w.output=he+w.output),Te(w);continue}if(ne&&(ne.type==="bracket"||ne.type==="paren")&&r.regex===!0){w.output=ee,Te(w);continue}(g.index===g.start||ne.type==="slash"||ne.type==="dot")&&(ne.type==="dot"?(g.output+=U,ne.output+=U):r.dot===!0?(g.output+=J,ne.output+=J):(g.output+=he,ne.output+=he),Fe()!=="*"&&(g.output+=C,ne.output+=C)),Te(w)}for(;g.brackets>0;){if(r.strictBrackets===!0)throw new SyntaxError(Ty("closing","]"));g.output=nl.escapeLast(g.output,"["),He("brackets")}for(;g.parens>0;){if(r.strictBrackets===!0)throw new SyntaxError(Ty("closing",")"));g.output=nl.escapeLast(g.output,"("),He("parens")}for(;g.braces>0;){if(r.strictBrackets===!0)throw new SyntaxError(Ty("closing","}"));g.output=nl.escapeLast(g.output,"{"),He("braces")}if(r.strictSlashes!==!0&&(ne.type==="star"||ne.type==="bracket")&&Te({type:"maybe_slash",value:"",output:`${x}?`}),g.backtrack===!0){g.output="";for(let b of g.tokens)g.output+=b.output!=null?b.output:b.value,b.suffix&&(g.output+=b.suffix)}return g};NN.fastpaths=(t,e)=>{let r={...e},o=typeof r.maxLength=="number"?Math.min(LS,r.maxLength):LS,a=t.length;if(a>o)throw new SyntaxError(`Input length: ${a}, exceeds maximum allowed length: ${o}`);t=DZ[t]||t;let n=nl.isWindows(e),{DOT_LITERAL:u,SLASH_LITERAL:A,ONE_CHAR:p,DOTS_SLASH:h,NO_DOT:E,NO_DOTS:I,NO_DOTS_SLASH:v,STAR:x,START_ANCHOR:C}=NS.globChars(n),R=r.dot?I:E,L=r.dot?v:E,U=r.capture?"":"?:",J={negated:!1,prefix:""},te=r.bash===!0?".*?":x;r.capture&&(te=`(${te})`);let ae=he=>he.noglobstar===!0?te:`(${U}(?:(?!${C}${he.dot?h:u}).)*?)`,fe=he=>{switch(he){case"*":return`${R}${p}${te}`;case".*":return`${u}${p}${te}`;case"*.*":return`${R}${te}${u}${p}${te}`;case"*/*":return`${R}${te}${A}${p}${L}${te}`;case"**":return R+ae(r);case"**/*":return`(?:${R}${ae(r)}${A})?${L}${p}${te}`;case"**/*.*":return`(?:${R}${ae(r)}${A})?${L}${te}${u}${p}${te}`;case"**/.*":return`(?:${R}${ae(r)}${A})?${u}${p}${te}`;default:{let Be=/^(.*?)\.(\w+)$/.exec(he);if(!Be)return;let we=fe(Be[1]);return we?we+u+Be[2]:void 0}}},ce=nl.removePrefix(t,J),me=fe(ce);return me&&r.strictSlashes!==!0&&(me+=`${A}?`),me};SZ.exports=NN});var xZ=_((zQt,bZ)=>{"use strict";var Z7e=ve("path"),$7e=vZ(),LN=PZ(),ON=kI(),eYe=xI(),tYe=t=>t&&typeof t=="object"&&!Array.isArray(t),Mi=(t,e,r=!1)=>{if(Array.isArray(t)){let E=t.map(v=>Mi(v,e,r));return v=>{for(let x of E){let C=x(v);if(C)return C}return!1}}let o=tYe(t)&&t.tokens&&t.input;if(t===""||typeof t!="string"&&!o)throw new TypeError("Expected pattern to be a non-empty string");let a=e||{},n=ON.isWindows(e),u=o?Mi.compileRe(t,e):Mi.makeRe(t,e,!1,!0),A=u.state;delete u.state;let p=()=>!1;if(a.ignore){let E={...e,ignore:null,onMatch:null,onResult:null};p=Mi(a.ignore,E,r)}let h=(E,I=!1)=>{let{isMatch:v,match:x,output:C}=Mi.test(E,u,e,{glob:t,posix:n}),R={glob:t,state:A,regex:u,posix:n,input:E,output:C,match:x,isMatch:v};return typeof a.onResult=="function"&&a.onResult(R),v===!1?(R.isMatch=!1,I?R:!1):p(E)?(typeof a.onIgnore=="function"&&a.onIgnore(R),R.isMatch=!1,I?R:!1):(typeof a.onMatch=="function"&&a.onMatch(R),I?R:!0)};return r&&(h.state=A),h};Mi.test=(t,e,r,{glob:o,posix:a}={})=>{if(typeof t!="string")throw new TypeError("Expected input to be a string");if(t==="")return{isMatch:!1,output:""};let n=r||{},u=n.format||(a?ON.toPosixSlashes:null),A=t===o,p=A&&u?u(t):t;return A===!1&&(p=u?u(t):t,A=p===o),(A===!1||n.capture===!0)&&(n.matchBase===!0||n.basename===!0?A=Mi.matchBase(t,e,r,a):A=e.exec(p)),{isMatch:Boolean(A),match:A,output:p}};Mi.matchBase=(t,e,r,o=ON.isWindows(r))=>(e instanceof RegExp?e:Mi.makeRe(e,r)).test(Z7e.basename(t));Mi.isMatch=(t,e,r)=>Mi(e,r)(t);Mi.parse=(t,e)=>Array.isArray(t)?t.map(r=>Mi.parse(r,e)):LN(t,{...e,fastpaths:!1});Mi.scan=(t,e)=>$7e(t,e);Mi.compileRe=(t,e,r=!1,o=!1)=>{if(r===!0)return t.output;let a=e||{},n=a.contains?"":"^",u=a.contains?"":"$",A=`${n}(?:${t.output})${u}`;t&&t.negated===!0&&(A=`^(?!${A}).*$`);let p=Mi.toRegex(A,e);return o===!0&&(p.state=t),p};Mi.makeRe=(t,e={},r=!1,o=!1)=>{if(!t||typeof t!="string")throw new TypeError("Expected a non-empty string");let a={negated:!1,fastpaths:!0};return e.fastpaths!==!1&&(t[0]==="."||t[0]==="*")&&(a.output=LN.fastpaths(t,e)),a.output||(a=LN(t,e)),Mi.compileRe(a,e,r,o)};Mi.toRegex=(t,e)=>{try{let r=e||{};return new RegExp(t,r.flags||(r.nocase?"i":""))}catch(r){if(e&&e.debug===!0)throw r;return/$^/}};Mi.constants=eYe;bZ.exports=Mi});var QZ=_((XQt,kZ)=>{"use strict";kZ.exports=xZ()});var Zo=_((ZQt,NZ)=>{"use strict";var RZ=ve("util"),TZ=AZ(),Vu=QZ(),MN=kI(),FZ=t=>t===""||t==="./",yi=(t,e,r)=>{e=[].concat(e),t=[].concat(t);let o=new Set,a=new Set,n=new Set,u=0,A=E=>{n.add(E.output),r&&r.onResult&&r.onResult(E)};for(let E=0;E<e.length;E++){let I=Vu(String(e[E]),{...r,onResult:A},!0),v=I.state.negated||I.state.negatedExtglob;v&&u++;for(let x of t){let C=I(x,!0);!(v?!C.isMatch:C.isMatch)||(v?o.add(C.output):(o.delete(C.output),a.add(C.output)))}}let h=(u===e.length?[...n]:[...a]).filter(E=>!o.has(E));if(r&&h.length===0){if(r.failglob===!0)throw new Error(`No matches found for "${e.join(", ")}"`);if(r.nonull===!0||r.nullglob===!0)return r.unescape?e.map(E=>E.replace(/\\/g,"")):e}return h};yi.match=yi;yi.matcher=(t,e)=>Vu(t,e);yi.isMatch=(t,e,r)=>Vu(e,r)(t);yi.any=yi.isMatch;yi.not=(t,e,r={})=>{e=[].concat(e).map(String);let o=new Set,a=[],n=A=>{r.onResult&&r.onResult(A),a.push(A.output)},u=new Set(yi(t,e,{...r,onResult:n}));for(let A of a)u.has(A)||o.add(A);return[...o]};yi.contains=(t,e,r)=>{if(typeof t!="string")throw new TypeError(`Expected a string: "${RZ.inspect(t)}"`);if(Array.isArray(e))return e.some(o=>yi.contains(t,o,r));if(typeof e=="string"){if(FZ(t)||FZ(e))return!1;if(t.includes(e)||t.startsWith("./")&&t.slice(2).includes(e))return!0}return yi.isMatch(t,e,{...r,contains:!0})};yi.matchKeys=(t,e,r)=>{if(!MN.isObject(t))throw new TypeError("Expected the first argument to be an object");let o=yi(Object.keys(t),e,r),a={};for(let n of o)a[n]=t[n];return a};yi.some=(t,e,r)=>{let o=[].concat(t);for(let a of[].concat(e)){let n=Vu(String(a),r);if(o.some(u=>n(u)))return!0}return!1};yi.every=(t,e,r)=>{let o=[].concat(t);for(let a of[].concat(e)){let n=Vu(String(a),r);if(!o.every(u=>n(u)))return!1}return!0};yi.all=(t,e,r)=>{if(typeof t!="string")throw new TypeError(`Expected a string: "${RZ.inspect(t)}"`);return[].concat(e).every(o=>Vu(o,r)(t))};yi.capture=(t,e,r)=>{let o=MN.isWindows(r),n=Vu.makeRe(String(t),{...r,capture:!0}).exec(o?MN.toPosixSlashes(e):e);if(n)return n.slice(1).map(u=>u===void 0?"":u)};yi.makeRe=(...t)=>Vu.makeRe(...t);yi.scan=(...t)=>Vu.scan(...t);yi.parse=(t,e)=>{let r=[];for(let o of[].concat(t||[]))for(let a of TZ(String(o),e))r.push(Vu.parse(a,e));return r};yi.braces=(t,e)=>{if(typeof t!="string")throw new TypeError("Expected a string");return e&&e.nobrace===!0||!/\{.*\}/.test(t)?[t]:TZ(t,e)};yi.braceExpand=(t,e)=>{if(typeof t!="string")throw new TypeError("Expected a string");return yi.braces(t,{...e,expand:!0})};NZ.exports=yi});var OZ=_(($Qt,LZ)=>{"use strict";LZ.exports=({onlyFirst:t=!1}={})=>{let e=["[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)","(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"].join("|");return new RegExp(e,t?void 0:"g")}});var OS=_((eFt,MZ)=>{"use strict";var rYe=OZ();MZ.exports=t=>typeof t=="string"?t.replace(rYe(),""):t});var _Z=_((tFt,UZ)=>{function nYe(){this.__data__=[],this.size=0}UZ.exports=nYe});var Ny=_((rFt,HZ)=>{function iYe(t,e){return t===e||t!==t&&e!==e}HZ.exports=iYe});var FI=_((nFt,jZ)=>{var sYe=Ny();function oYe(t,e){for(var r=t.length;r--;)if(sYe(t[r][0],e))return r;return-1}jZ.exports=oYe});var qZ=_((iFt,GZ)=>{var aYe=FI(),lYe=Array.prototype,cYe=lYe.splice;function uYe(t){var e=this.__data__,r=aYe(e,t);if(r<0)return!1;var o=e.length-1;return r==o?e.pop():cYe.call(e,r,1),--this.size,!0}GZ.exports=uYe});var WZ=_((sFt,YZ)=>{var AYe=FI();function fYe(t){var e=this.__data__,r=AYe(e,t);return r<0?void 0:e[r][1]}YZ.exports=fYe});var VZ=_((oFt,KZ)=>{var pYe=FI();function hYe(t){return pYe(this.__data__,t)>-1}KZ.exports=hYe});var zZ=_((aFt,JZ)=>{var gYe=FI();function dYe(t,e){var r=this.__data__,o=gYe(r,t);return o<0?(++this.size,r.push([t,e])):r[o][1]=e,this}JZ.exports=dYe});var RI=_((lFt,XZ)=>{var mYe=_Z(),yYe=qZ(),EYe=WZ(),CYe=VZ(),wYe=zZ();function Ly(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e<r;){var o=t[e];this.set(o[0],o[1])}}Ly.prototype.clear=mYe;Ly.prototype.delete=yYe;Ly.prototype.get=EYe;Ly.prototype.has=CYe;Ly.prototype.set=wYe;XZ.exports=Ly});var $Z=_((cFt,ZZ)=>{var IYe=RI();function BYe(){this.__data__=new IYe,this.size=0}ZZ.exports=BYe});var t$=_((uFt,e$)=>{function vYe(t){var e=this.__data__,r=e.delete(t);return this.size=e.size,r}e$.exports=vYe});var n$=_((AFt,r$)=>{function DYe(t){return this.__data__.get(t)}r$.exports=DYe});var s$=_((fFt,i$)=>{function SYe(t){return this.__data__.has(t)}i$.exports=SYe});var UN=_((pFt,o$)=>{var PYe=typeof global=="object"&&global&&global.Object===Object&&global;o$.exports=PYe});var _l=_((hFt,a$)=>{var bYe=UN(),xYe=typeof self=="object"&&self&&self.Object===Object&&self,kYe=bYe||xYe||Function("return this")();a$.exports=kYe});var pd=_((gFt,l$)=>{var QYe=_l(),FYe=QYe.Symbol;l$.exports=FYe});var f$=_((dFt,A$)=>{var c$=pd(),u$=Object.prototype,RYe=u$.hasOwnProperty,TYe=u$.toString,TI=c$?c$.toStringTag:void 0;function NYe(t){var e=RYe.call(t,TI),r=t[TI];try{t[TI]=void 0;var o=!0}catch{}var a=TYe.call(t);return o&&(e?t[TI]=r:delete t[TI]),a}A$.exports=NYe});var h$=_((mFt,p$)=>{var LYe=Object.prototype,OYe=LYe.toString;function MYe(t){return OYe.call(t)}p$.exports=MYe});var hd=_((yFt,m$)=>{var g$=pd(),UYe=f$(),_Ye=h$(),HYe="[object Null]",jYe="[object Undefined]",d$=g$?g$.toStringTag:void 0;function GYe(t){return t==null?t===void 0?jYe:HYe:d$&&d$ in Object(t)?UYe(t):_Ye(t)}m$.exports=GYe});var il=_((EFt,y$)=>{function qYe(t){var e=typeof t;return t!=null&&(e=="object"||e=="function")}y$.exports=qYe});var MS=_((CFt,E$)=>{var YYe=hd(),WYe=il(),KYe="[object AsyncFunction]",VYe="[object Function]",JYe="[object GeneratorFunction]",zYe="[object Proxy]";function XYe(t){if(!WYe(t))return!1;var e=YYe(t);return e==VYe||e==JYe||e==KYe||e==zYe}E$.exports=XYe});var w$=_((wFt,C$)=>{var ZYe=_l(),$Ye=ZYe["__core-js_shared__"];C$.exports=$Ye});var v$=_((IFt,B$)=>{var _N=w$(),I$=function(){var t=/[^.]+$/.exec(_N&&_N.keys&&_N.keys.IE_PROTO||"");return t?"Symbol(src)_1."+t:""}();function eWe(t){return!!I$&&I$ in t}B$.exports=eWe});var HN=_((BFt,D$)=>{var tWe=Function.prototype,rWe=tWe.toString;function nWe(t){if(t!=null){try{return rWe.call(t)}catch{}try{return t+""}catch{}}return""}D$.exports=nWe});var P$=_((vFt,S$)=>{var iWe=MS(),sWe=v$(),oWe=il(),aWe=HN(),lWe=/[\\^$.*+?()[\]{}|]/g,cWe=/^\[object .+?Constructor\]$/,uWe=Function.prototype,AWe=Object.prototype,fWe=uWe.toString,pWe=AWe.hasOwnProperty,hWe=RegExp("^"+fWe.call(pWe).replace(lWe,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");function gWe(t){if(!oWe(t)||sWe(t))return!1;var e=iWe(t)?hWe:cWe;return e.test(aWe(t))}S$.exports=gWe});var x$=_((DFt,b$)=>{function dWe(t,e){return t?.[e]}b$.exports=dWe});var zp=_((SFt,k$)=>{var mWe=P$(),yWe=x$();function EWe(t,e){var r=yWe(t,e);return mWe(r)?r:void 0}k$.exports=EWe});var US=_((PFt,Q$)=>{var CWe=zp(),wWe=_l(),IWe=CWe(wWe,"Map");Q$.exports=IWe});var NI=_((bFt,F$)=>{var BWe=zp(),vWe=BWe(Object,"create");F$.exports=vWe});var N$=_((xFt,T$)=>{var R$=NI();function DWe(){this.__data__=R$?R$(null):{},this.size=0}T$.exports=DWe});var O$=_((kFt,L$)=>{function SWe(t){var e=this.has(t)&&delete this.__data__[t];return this.size-=e?1:0,e}L$.exports=SWe});var U$=_((QFt,M$)=>{var PWe=NI(),bWe="__lodash_hash_undefined__",xWe=Object.prototype,kWe=xWe.hasOwnProperty;function QWe(t){var e=this.__data__;if(PWe){var r=e[t];return r===bWe?void 0:r}return kWe.call(e,t)?e[t]:void 0}M$.exports=QWe});var H$=_((FFt,_$)=>{var FWe=NI(),RWe=Object.prototype,TWe=RWe.hasOwnProperty;function NWe(t){var e=this.__data__;return FWe?e[t]!==void 0:TWe.call(e,t)}_$.exports=NWe});var G$=_((RFt,j$)=>{var LWe=NI(),OWe="__lodash_hash_undefined__";function MWe(t,e){var r=this.__data__;return this.size+=this.has(t)?0:1,r[t]=LWe&&e===void 0?OWe:e,this}j$.exports=MWe});var Y$=_((TFt,q$)=>{var UWe=N$(),_We=O$(),HWe=U$(),jWe=H$(),GWe=G$();function Oy(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e<r;){var o=t[e];this.set(o[0],o[1])}}Oy.prototype.clear=UWe;Oy.prototype.delete=_We;Oy.prototype.get=HWe;Oy.prototype.has=jWe;Oy.prototype.set=GWe;q$.exports=Oy});var V$=_((NFt,K$)=>{var W$=Y$(),qWe=RI(),YWe=US();function WWe(){this.size=0,this.__data__={hash:new W$,map:new(YWe||qWe),string:new W$}}K$.exports=WWe});var z$=_((LFt,J$)=>{function KWe(t){var e=typeof t;return e=="string"||e=="number"||e=="symbol"||e=="boolean"?t!=="__proto__":t===null}J$.exports=KWe});var LI=_((OFt,X$)=>{var VWe=z$();function JWe(t,e){var r=t.__data__;return VWe(e)?r[typeof e=="string"?"string":"hash"]:r.map}X$.exports=JWe});var $$=_((MFt,Z$)=>{var zWe=LI();function XWe(t){var e=zWe(this,t).delete(t);return this.size-=e?1:0,e}Z$.exports=XWe});var tee=_((UFt,eee)=>{var ZWe=LI();function $We(t){return ZWe(this,t).get(t)}eee.exports=$We});var nee=_((_Ft,ree)=>{var eKe=LI();function tKe(t){return eKe(this,t).has(t)}ree.exports=tKe});var see=_((HFt,iee)=>{var rKe=LI();function nKe(t,e){var r=rKe(this,t),o=r.size;return r.set(t,e),this.size+=r.size==o?0:1,this}iee.exports=nKe});var _S=_((jFt,oee)=>{var iKe=V$(),sKe=$$(),oKe=tee(),aKe=nee(),lKe=see();function My(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e<r;){var o=t[e];this.set(o[0],o[1])}}My.prototype.clear=iKe;My.prototype.delete=sKe;My.prototype.get=oKe;My.prototype.has=aKe;My.prototype.set=lKe;oee.exports=My});var lee=_((GFt,aee)=>{var cKe=RI(),uKe=US(),AKe=_S(),fKe=200;function pKe(t,e){var r=this.__data__;if(r instanceof cKe){var o=r.__data__;if(!uKe||o.length<fKe-1)return o.push([t,e]),this.size=++r.size,this;r=this.__data__=new AKe(o)}return r.set(t,e),this.size=r.size,this}aee.exports=pKe});var HS=_((qFt,cee)=>{var hKe=RI(),gKe=$Z(),dKe=t$(),mKe=n$(),yKe=s$(),EKe=lee();function Uy(t){var e=this.__data__=new hKe(t);this.size=e.size}Uy.prototype.clear=gKe;Uy.prototype.delete=dKe;Uy.prototype.get=mKe;Uy.prototype.has=yKe;Uy.prototype.set=EKe;cee.exports=Uy});var Aee=_((YFt,uee)=>{var CKe="__lodash_hash_undefined__";function wKe(t){return this.__data__.set(t,CKe),this}uee.exports=wKe});var pee=_((WFt,fee)=>{function IKe(t){return this.__data__.has(t)}fee.exports=IKe});var gee=_((KFt,hee)=>{var BKe=_S(),vKe=Aee(),DKe=pee();function jS(t){var e=-1,r=t==null?0:t.length;for(this.__data__=new BKe;++e<r;)this.add(t[e])}jS.prototype.add=jS.prototype.push=vKe;jS.prototype.has=DKe;hee.exports=jS});var mee=_((VFt,dee)=>{function SKe(t,e){for(var r=-1,o=t==null?0:t.length;++r<o;)if(e(t[r],r,t))return!0;return!1}dee.exports=SKe});var Eee=_((JFt,yee)=>{function PKe(t,e){return t.has(e)}yee.exports=PKe});var jN=_((zFt,Cee)=>{var bKe=gee(),xKe=mee(),kKe=Eee(),QKe=1,FKe=2;function RKe(t,e,r,o,a,n){var u=r&QKe,A=t.length,p=e.length;if(A!=p&&!(u&&p>A))return!1;var h=n.get(t),E=n.get(e);if(h&&E)return h==e&&E==t;var I=-1,v=!0,x=r&FKe?new bKe:void 0;for(n.set(t,e),n.set(e,t);++I<A;){var C=t[I],R=e[I];if(o)var L=u?o(R,C,I,e,t,n):o(C,R,I,t,e,n);if(L!==void 0){if(L)continue;v=!1;break}if(x){if(!xKe(e,function(U,J){if(!kKe(x,J)&&(C===U||a(C,U,r,o,n)))return x.push(J)})){v=!1;break}}else if(!(C===R||a(C,R,r,o,n))){v=!1;break}}return n.delete(t),n.delete(e),v}Cee.exports=RKe});var GN=_((XFt,wee)=>{var TKe=_l(),NKe=TKe.Uint8Array;wee.exports=NKe});var Bee=_((ZFt,Iee)=>{function LKe(t){var e=-1,r=Array(t.size);return t.forEach(function(o,a){r[++e]=[a,o]}),r}Iee.exports=LKe});var Dee=_(($Ft,vee)=>{function OKe(t){var e=-1,r=Array(t.size);return t.forEach(function(o){r[++e]=o}),r}vee.exports=OKe});var kee=_((eRt,xee)=>{var See=pd(),Pee=GN(),MKe=Ny(),UKe=jN(),_Ke=Bee(),HKe=Dee(),jKe=1,GKe=2,qKe="[object Boolean]",YKe="[object Date]",WKe="[object Error]",KKe="[object Map]",VKe="[object Number]",JKe="[object RegExp]",zKe="[object Set]",XKe="[object String]",ZKe="[object Symbol]",$Ke="[object ArrayBuffer]",eVe="[object DataView]",bee=See?See.prototype:void 0,qN=bee?bee.valueOf:void 0;function tVe(t,e,r,o,a,n,u){switch(r){case eVe:if(t.byteLength!=e.byteLength||t.byteOffset!=e.byteOffset)return!1;t=t.buffer,e=e.buffer;case $Ke:return!(t.byteLength!=e.byteLength||!n(new Pee(t),new Pee(e)));case qKe:case YKe:case VKe:return MKe(+t,+e);case WKe:return t.name==e.name&&t.message==e.message;case JKe:case XKe:return t==e+"";case KKe:var A=_Ke;case zKe:var p=o&jKe;if(A||(A=HKe),t.size!=e.size&&!p)return!1;var h=u.get(t);if(h)return h==e;o|=GKe,u.set(t,e);var E=UKe(A(t),A(e),o,a,n,u);return u.delete(t),E;case ZKe:if(qN)return qN.call(t)==qN.call(e)}return!1}xee.exports=tVe});var GS=_((tRt,Qee)=>{function rVe(t,e){for(var r=-1,o=e.length,a=t.length;++r<o;)t[a+r]=e[r];return t}Qee.exports=rVe});var Hl=_((rRt,Fee)=>{var nVe=Array.isArray;Fee.exports=nVe});var YN=_((nRt,Ree)=>{var iVe=GS(),sVe=Hl();function oVe(t,e,r){var o=e(t);return sVe(t)?o:iVe(o,r(t))}Ree.exports=oVe});var Nee=_((iRt,Tee)=>{function aVe(t,e){for(var r=-1,o=t==null?0:t.length,a=0,n=[];++r<o;){var u=t[r];e(u,r,t)&&(n[a++]=u)}return n}Tee.exports=aVe});var WN=_((sRt,Lee)=>{function lVe(){return[]}Lee.exports=lVe});var qS=_((oRt,Mee)=>{var cVe=Nee(),uVe=WN(),AVe=Object.prototype,fVe=AVe.propertyIsEnumerable,Oee=Object.getOwnPropertySymbols,pVe=Oee?function(t){return t==null?[]:(t=Object(t),cVe(Oee(t),function(e){return fVe.call(t,e)}))}:uVe;Mee.exports=pVe});var _ee=_((aRt,Uee)=>{function hVe(t,e){for(var r=-1,o=Array(t);++r<t;)o[r]=e(r);return o}Uee.exports=hVe});var Ju=_((lRt,Hee)=>{function gVe(t){return t!=null&&typeof t=="object"}Hee.exports=gVe});var Gee=_((cRt,jee)=>{var dVe=hd(),mVe=Ju(),yVe="[object Arguments]";function EVe(t){return mVe(t)&&dVe(t)==yVe}jee.exports=EVe});var OI=_((uRt,Wee)=>{var qee=Gee(),CVe=Ju(),Yee=Object.prototype,wVe=Yee.hasOwnProperty,IVe=Yee.propertyIsEnumerable,BVe=qee(function(){return arguments}())?qee:function(t){return CVe(t)&&wVe.call(t,"callee")&&!IVe.call(t,"callee")};Wee.exports=BVe});var Vee=_((ARt,Kee)=>{function vVe(){return!1}Kee.exports=vVe});var UI=_((MI,_y)=>{var DVe=_l(),SVe=Vee(),Xee=typeof MI=="object"&&MI&&!MI.nodeType&&MI,Jee=Xee&&typeof _y=="object"&&_y&&!_y.nodeType&&_y,PVe=Jee&&Jee.exports===Xee,zee=PVe?DVe.Buffer:void 0,bVe=zee?zee.isBuffer:void 0,xVe=bVe||SVe;_y.exports=xVe});var _I=_((fRt,Zee)=>{var kVe=9007199254740991,QVe=/^(?:0|[1-9]\d*)$/;function FVe(t,e){var r=typeof t;return e=e??kVe,!!e&&(r=="number"||r!="symbol"&&QVe.test(t))&&t>-1&&t%1==0&&t<e}Zee.exports=FVe});var YS=_((pRt,$ee)=>{var RVe=9007199254740991;function TVe(t){return typeof t=="number"&&t>-1&&t%1==0&&t<=RVe}$ee.exports=TVe});var tte=_((hRt,ete)=>{var NVe=hd(),LVe=YS(),OVe=Ju(),MVe="[object Arguments]",UVe="[object Array]",_Ve="[object Boolean]",HVe="[object Date]",jVe="[object Error]",GVe="[object Function]",qVe="[object Map]",YVe="[object Number]",WVe="[object Object]",KVe="[object RegExp]",VVe="[object Set]",JVe="[object String]",zVe="[object WeakMap]",XVe="[object ArrayBuffer]",ZVe="[object DataView]",$Ve="[object Float32Array]",eJe="[object Float64Array]",tJe="[object Int8Array]",rJe="[object Int16Array]",nJe="[object Int32Array]",iJe="[object Uint8Array]",sJe="[object Uint8ClampedArray]",oJe="[object Uint16Array]",aJe="[object Uint32Array]",ui={};ui[$Ve]=ui[eJe]=ui[tJe]=ui[rJe]=ui[nJe]=ui[iJe]=ui[sJe]=ui[oJe]=ui[aJe]=!0;ui[MVe]=ui[UVe]=ui[XVe]=ui[_Ve]=ui[ZVe]=ui[HVe]=ui[jVe]=ui[GVe]=ui[qVe]=ui[YVe]=ui[WVe]=ui[KVe]=ui[VVe]=ui[JVe]=ui[zVe]=!1;function lJe(t){return OVe(t)&&LVe(t.length)&&!!ui[NVe(t)]}ete.exports=lJe});var WS=_((gRt,rte)=>{function cJe(t){return function(e){return t(e)}}rte.exports=cJe});var KS=_((HI,Hy)=>{var uJe=UN(),nte=typeof HI=="object"&&HI&&!HI.nodeType&&HI,jI=nte&&typeof Hy=="object"&&Hy&&!Hy.nodeType&&Hy,AJe=jI&&jI.exports===nte,KN=AJe&&uJe.process,fJe=function(){try{var t=jI&&jI.require&&jI.require("util").types;return t||KN&&KN.binding&&KN.binding("util")}catch{}}();Hy.exports=fJe});var VS=_((dRt,ote)=>{var pJe=tte(),hJe=WS(),ite=KS(),ste=ite&&ite.isTypedArray,gJe=ste?hJe(ste):pJe;ote.exports=gJe});var VN=_((mRt,ate)=>{var dJe=_ee(),mJe=OI(),yJe=Hl(),EJe=UI(),CJe=_I(),wJe=VS(),IJe=Object.prototype,BJe=IJe.hasOwnProperty;function vJe(t,e){var r=yJe(t),o=!r&&mJe(t),a=!r&&!o&&EJe(t),n=!r&&!o&&!a&&wJe(t),u=r||o||a||n,A=u?dJe(t.length,String):[],p=A.length;for(var h in t)(e||BJe.call(t,h))&&!(u&&(h=="length"||a&&(h=="offset"||h=="parent")||n&&(h=="buffer"||h=="byteLength"||h=="byteOffset")||CJe(h,p)))&&A.push(h);return A}ate.exports=vJe});var JS=_((yRt,lte)=>{var DJe=Object.prototype;function SJe(t){var e=t&&t.constructor,r=typeof e=="function"&&e.prototype||DJe;return t===r}lte.exports=SJe});var JN=_((ERt,cte)=>{function PJe(t,e){return function(r){return t(e(r))}}cte.exports=PJe});var Ate=_((CRt,ute)=>{var bJe=JN(),xJe=bJe(Object.keys,Object);ute.exports=xJe});var pte=_((wRt,fte)=>{var kJe=JS(),QJe=Ate(),FJe=Object.prototype,RJe=FJe.hasOwnProperty;function TJe(t){if(!kJe(t))return QJe(t);var e=[];for(var r in Object(t))RJe.call(t,r)&&r!="constructor"&&e.push(r);return e}fte.exports=TJe});var GI=_((IRt,hte)=>{var NJe=MS(),LJe=YS();function OJe(t){return t!=null&&LJe(t.length)&&!NJe(t)}hte.exports=OJe});var zS=_((BRt,gte)=>{var MJe=VN(),UJe=pte(),_Je=GI();function HJe(t){return _Je(t)?MJe(t):UJe(t)}gte.exports=HJe});var zN=_((vRt,dte)=>{var jJe=YN(),GJe=qS(),qJe=zS();function YJe(t){return jJe(t,qJe,GJe)}dte.exports=YJe});var Ete=_((DRt,yte)=>{var mte=zN(),WJe=1,KJe=Object.prototype,VJe=KJe.hasOwnProperty;function JJe(t,e,r,o,a,n){var u=r&WJe,A=mte(t),p=A.length,h=mte(e),E=h.length;if(p!=E&&!u)return!1;for(var I=p;I--;){var v=A[I];if(!(u?v in e:VJe.call(e,v)))return!1}var x=n.get(t),C=n.get(e);if(x&&C)return x==e&&C==t;var R=!0;n.set(t,e),n.set(e,t);for(var L=u;++I<p;){v=A[I];var U=t[v],J=e[v];if(o)var te=u?o(J,U,v,e,t,n):o(U,J,v,t,e,n);if(!(te===void 0?U===J||a(U,J,r,o,n):te)){R=!1;break}L||(L=v=="constructor")}if(R&&!L){var ae=t.constructor,fe=e.constructor;ae!=fe&&"constructor"in t&&"constructor"in e&&!(typeof ae=="function"&&ae instanceof ae&&typeof fe=="function"&&fe instanceof fe)&&(R=!1)}return n.delete(t),n.delete(e),R}yte.exports=JJe});var wte=_((SRt,Cte)=>{var zJe=zp(),XJe=_l(),ZJe=zJe(XJe,"DataView");Cte.exports=ZJe});var Bte=_((PRt,Ite)=>{var $Je=zp(),eze=_l(),tze=$Je(eze,"Promise");Ite.exports=tze});var Dte=_((bRt,vte)=>{var rze=zp(),nze=_l(),ize=rze(nze,"Set");vte.exports=ize});var Pte=_((xRt,Ste)=>{var sze=zp(),oze=_l(),aze=sze(oze,"WeakMap");Ste.exports=aze});var qI=_((kRt,Tte)=>{var XN=wte(),ZN=US(),$N=Bte(),eL=Dte(),tL=Pte(),Rte=hd(),jy=HN(),bte="[object Map]",lze="[object Object]",xte="[object Promise]",kte="[object Set]",Qte="[object WeakMap]",Fte="[object DataView]",cze=jy(XN),uze=jy(ZN),Aze=jy($N),fze=jy(eL),pze=jy(tL),gd=Rte;(XN&&gd(new XN(new ArrayBuffer(1)))!=Fte||ZN&&gd(new ZN)!=bte||$N&&gd($N.resolve())!=xte||eL&&gd(new eL)!=kte||tL&&gd(new tL)!=Qte)&&(gd=function(t){var e=Rte(t),r=e==lze?t.constructor:void 0,o=r?jy(r):"";if(o)switch(o){case cze:return Fte;case uze:return bte;case Aze:return xte;case fze:return kte;case pze:return Qte}return e});Tte.exports=gd});var jte=_((QRt,Hte)=>{var rL=HS(),hze=jN(),gze=kee(),dze=Ete(),Nte=qI(),Lte=Hl(),Ote=UI(),mze=VS(),yze=1,Mte="[object Arguments]",Ute="[object Array]",XS="[object Object]",Eze=Object.prototype,_te=Eze.hasOwnProperty;function Cze(t,e,r,o,a,n){var u=Lte(t),A=Lte(e),p=u?Ute:Nte(t),h=A?Ute:Nte(e);p=p==Mte?XS:p,h=h==Mte?XS:h;var E=p==XS,I=h==XS,v=p==h;if(v&&Ote(t)){if(!Ote(e))return!1;u=!0,E=!1}if(v&&!E)return n||(n=new rL),u||mze(t)?hze(t,e,r,o,a,n):gze(t,e,p,r,o,a,n);if(!(r&yze)){var x=E&&_te.call(t,"__wrapped__"),C=I&&_te.call(e,"__wrapped__");if(x||C){var R=x?t.value():t,L=C?e.value():e;return n||(n=new rL),a(R,L,r,o,n)}}return v?(n||(n=new rL),dze(t,e,r,o,a,n)):!1}Hte.exports=Cze});var Wte=_((FRt,Yte)=>{var wze=jte(),Gte=Ju();function qte(t,e,r,o,a){return t===e?!0:t==null||e==null||!Gte(t)&&!Gte(e)?t!==t&&e!==e:wze(t,e,r,o,qte,a)}Yte.exports=qte});var Vte=_((RRt,Kte)=>{var Ize=Wte();function Bze(t,e){return Ize(t,e)}Kte.exports=Bze});var nL=_((TRt,Jte)=>{var vze=zp(),Dze=function(){try{var t=vze(Object,"defineProperty");return t({},"",{}),t}catch{}}();Jte.exports=Dze});var ZS=_((NRt,Xte)=>{var zte=nL();function Sze(t,e,r){e=="__proto__"&&zte?zte(t,e,{configurable:!0,enumerable:!0,value:r,writable:!0}):t[e]=r}Xte.exports=Sze});var iL=_((LRt,Zte)=>{var Pze=ZS(),bze=Ny();function xze(t,e,r){(r!==void 0&&!bze(t[e],r)||r===void 0&&!(e in t))&&Pze(t,e,r)}Zte.exports=xze});var ere=_((ORt,$te)=>{function kze(t){return function(e,r,o){for(var a=-1,n=Object(e),u=o(e),A=u.length;A--;){var p=u[t?A:++a];if(r(n[p],p,n)===!1)break}return e}}$te.exports=kze});var rre=_((MRt,tre)=>{var Qze=ere(),Fze=Qze();tre.exports=Fze});var sL=_((YI,Gy)=>{var Rze=_l(),ore=typeof YI=="object"&&YI&&!YI.nodeType&&YI,nre=ore&&typeof Gy=="object"&&Gy&&!Gy.nodeType&&Gy,Tze=nre&&nre.exports===ore,ire=Tze?Rze.Buffer:void 0,sre=ire?ire.allocUnsafe:void 0;function Nze(t,e){if(e)return t.slice();var r=t.length,o=sre?sre(r):new t.constructor(r);return t.copy(o),o}Gy.exports=Nze});var $S=_((URt,lre)=>{var are=GN();function Lze(t){var e=new t.constructor(t.byteLength);return new are(e).set(new are(t)),e}lre.exports=Lze});var oL=_((_Rt,cre)=>{var Oze=$S();function Mze(t,e){var r=e?Oze(t.buffer):t.buffer;return new t.constructor(r,t.byteOffset,t.length)}cre.exports=Mze});var eP=_((HRt,ure)=>{function Uze(t,e){var r=-1,o=t.length;for(e||(e=Array(o));++r<o;)e[r]=t[r];return e}ure.exports=Uze});var pre=_((jRt,fre)=>{var _ze=il(),Are=Object.create,Hze=function(){function t(){}return function(e){if(!_ze(e))return{};if(Are)return Are(e);t.prototype=e;var r=new t;return t.prototype=void 0,r}}();fre.exports=Hze});var tP=_((GRt,hre)=>{var jze=JN(),Gze=jze(Object.getPrototypeOf,Object);hre.exports=Gze});var aL=_((qRt,gre)=>{var qze=pre(),Yze=tP(),Wze=JS();function Kze(t){return typeof t.constructor=="function"&&!Wze(t)?qze(Yze(t)):{}}gre.exports=Kze});var mre=_((YRt,dre)=>{var Vze=GI(),Jze=Ju();function zze(t){return Jze(t)&&Vze(t)}dre.exports=zze});var lL=_((WRt,Ere)=>{var Xze=hd(),Zze=tP(),$ze=Ju(),eXe="[object Object]",tXe=Function.prototype,rXe=Object.prototype,yre=tXe.toString,nXe=rXe.hasOwnProperty,iXe=yre.call(Object);function sXe(t){if(!$ze(t)||Xze(t)!=eXe)return!1;var e=Zze(t);if(e===null)return!0;var r=nXe.call(e,"constructor")&&e.constructor;return typeof r=="function"&&r instanceof r&&yre.call(r)==iXe}Ere.exports=sXe});var cL=_((KRt,Cre)=>{function oXe(t,e){if(!(e==="constructor"&&typeof t[e]=="function")&&e!="__proto__")return t[e]}Cre.exports=oXe});var rP=_((VRt,wre)=>{var aXe=ZS(),lXe=Ny(),cXe=Object.prototype,uXe=cXe.hasOwnProperty;function AXe(t,e,r){var o=t[e];(!(uXe.call(t,e)&&lXe(o,r))||r===void 0&&!(e in t))&&aXe(t,e,r)}wre.exports=AXe});var dd=_((JRt,Ire)=>{var fXe=rP(),pXe=ZS();function hXe(t,e,r,o){var a=!r;r||(r={});for(var n=-1,u=e.length;++n<u;){var A=e[n],p=o?o(r[A],t[A],A,r,t):void 0;p===void 0&&(p=t[A]),a?pXe(r,A,p):fXe(r,A,p)}return r}Ire.exports=hXe});var vre=_((zRt,Bre)=>{function gXe(t){var e=[];if(t!=null)for(var r in Object(t))e.push(r);return e}Bre.exports=gXe});var Sre=_((XRt,Dre)=>{var dXe=il(),mXe=JS(),yXe=vre(),EXe=Object.prototype,CXe=EXe.hasOwnProperty;function wXe(t){if(!dXe(t))return yXe(t);var e=mXe(t),r=[];for(var o in t)o=="constructor"&&(e||!CXe.call(t,o))||r.push(o);return r}Dre.exports=wXe});var qy=_((ZRt,Pre)=>{var IXe=VN(),BXe=Sre(),vXe=GI();function DXe(t){return vXe(t)?IXe(t,!0):BXe(t)}Pre.exports=DXe});var xre=_(($Rt,bre)=>{var SXe=dd(),PXe=qy();function bXe(t){return SXe(t,PXe(t))}bre.exports=bXe});var Nre=_((eTt,Tre)=>{var kre=iL(),xXe=sL(),kXe=oL(),QXe=eP(),FXe=aL(),Qre=OI(),Fre=Hl(),RXe=mre(),TXe=UI(),NXe=MS(),LXe=il(),OXe=lL(),MXe=VS(),Rre=cL(),UXe=xre();function _Xe(t,e,r,o,a,n,u){var A=Rre(t,r),p=Rre(e,r),h=u.get(p);if(h){kre(t,r,h);return}var E=n?n(A,p,r+"",t,e,u):void 0,I=E===void 0;if(I){var v=Fre(p),x=!v&&TXe(p),C=!v&&!x&&MXe(p);E=p,v||x||C?Fre(A)?E=A:RXe(A)?E=QXe(A):x?(I=!1,E=xXe(p,!0)):C?(I=!1,E=kXe(p,!0)):E=[]:OXe(p)||Qre(p)?(E=A,Qre(A)?E=UXe(A):(!LXe(A)||NXe(A))&&(E=FXe(p))):I=!1}I&&(u.set(p,E),a(E,p,o,n,u),u.delete(p)),kre(t,r,E)}Tre.exports=_Xe});var Mre=_((tTt,Ore)=>{var HXe=HS(),jXe=iL(),GXe=rre(),qXe=Nre(),YXe=il(),WXe=qy(),KXe=cL();function Lre(t,e,r,o,a){t!==e&&GXe(e,function(n,u){if(a||(a=new HXe),YXe(n))qXe(t,e,u,r,Lre,o,a);else{var A=o?o(KXe(t,u),n,u+"",t,e,a):void 0;A===void 0&&(A=n),jXe(t,u,A)}},WXe)}Ore.exports=Lre});var uL=_((rTt,Ure)=>{function VXe(t){return t}Ure.exports=VXe});var Hre=_((nTt,_re)=>{function JXe(t,e,r){switch(r.length){case 0:return t.call(e);case 1:return t.call(e,r[0]);case 2:return t.call(e,r[0],r[1]);case 3:return t.call(e,r[0],r[1],r[2])}return t.apply(e,r)}_re.exports=JXe});var AL=_((iTt,Gre)=>{var zXe=Hre(),jre=Math.max;function XXe(t,e,r){return e=jre(e===void 0?t.length-1:e,0),function(){for(var o=arguments,a=-1,n=jre(o.length-e,0),u=Array(n);++a<n;)u[a]=o[e+a];a=-1;for(var A=Array(e+1);++a<e;)A[a]=o[a];return A[e]=r(u),zXe(t,this,A)}}Gre.exports=XXe});var Yre=_((sTt,qre)=>{function ZXe(t){return function(){return t}}qre.exports=ZXe});var Vre=_((oTt,Kre)=>{var $Xe=Yre(),Wre=nL(),eZe=uL(),tZe=Wre?function(t,e){return Wre(t,"toString",{configurable:!0,enumerable:!1,value:$Xe(e),writable:!0})}:eZe;Kre.exports=tZe});var zre=_((aTt,Jre)=>{var rZe=800,nZe=16,iZe=Date.now;function sZe(t){var e=0,r=0;return function(){var o=iZe(),a=nZe-(o-r);if(r=o,a>0){if(++e>=rZe)return arguments[0]}else e=0;return t.apply(void 0,arguments)}}Jre.exports=sZe});var fL=_((lTt,Xre)=>{var oZe=Vre(),aZe=zre(),lZe=aZe(oZe);Xre.exports=lZe});var $re=_((cTt,Zre)=>{var cZe=uL(),uZe=AL(),AZe=fL();function fZe(t,e){return AZe(uZe(t,e,cZe),t+"")}Zre.exports=fZe});var tne=_((uTt,ene)=>{var pZe=Ny(),hZe=GI(),gZe=_I(),dZe=il();function mZe(t,e,r){if(!dZe(r))return!1;var o=typeof e;return(o=="number"?hZe(r)&&gZe(e,r.length):o=="string"&&e in r)?pZe(r[e],t):!1}ene.exports=mZe});var nne=_((ATt,rne)=>{var yZe=$re(),EZe=tne();function CZe(t){return yZe(function(e,r){var o=-1,a=r.length,n=a>1?r[a-1]:void 0,u=a>2?r[2]:void 0;for(n=t.length>3&&typeof n=="function"?(a--,n):void 0,u&&EZe(r[0],r[1],u)&&(n=a<3?void 0:n,a=1),e=Object(e);++o<a;){var A=r[o];A&&t(e,A,o,n)}return e})}rne.exports=CZe});var sne=_((fTt,ine)=>{var wZe=Mre(),IZe=nne(),BZe=IZe(function(t,e,r,o){wZe(t,e,r,o)});ine.exports=BZe});var _e={};Vt(_e,{AsyncActions:()=>gL,BufferStream:()=>hL,CachingStrategy:()=>mne,DefaultStream:()=>dL,allSettledSafe:()=>Uc,assertNever:()=>yL,bufferStream:()=>Vy,buildIgnorePattern:()=>kZe,convertMapsToIndexableObjects:()=>iP,dynamicRequire:()=>Df,escapeRegExp:()=>DZe,getArrayWithDefault:()=>Yy,getFactoryWithDefault:()=>ol,getMapWithDefault:()=>Wy,getSetWithDefault:()=>md,groupBy:()=>wL,isIndexableObject:()=>pL,isPathLike:()=>QZe,isTaggedYarnVersion:()=>vZe,makeDeferred:()=>hne,mapAndFilter:()=>sl,mapAndFind:()=>KI,mergeIntoTarget:()=>Ene,overrideType:()=>SZe,parseBoolean:()=>VI,parseInt:()=>Jy,parseOptionalBoolean:()=>yne,plural:()=>nP,prettifyAsyncErrors:()=>Ky,prettifySyncErrors:()=>EL,releaseAfterUseAsync:()=>bZe,replaceEnvVariables:()=>sP,sortMap:()=>ks,toMerged:()=>FZe,tryParseOptionalBoolean:()=>CL,validateEnum:()=>PZe});function vZe(t){return!!(Ane.default.valid(t)&&t.match(/^[^-]+(-rc\.[0-9]+)?$/))}function nP(t,{one:e,more:r,zero:o=r}){return t===0?o:t===1?e:r}function DZe(t){return t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function SZe(t){}function yL(t){throw new Error(`Assertion failed: Unexpected object '${t}'`)}function PZe(t,e){let r=Object.values(t);if(!r.includes(e))throw new it(`Invalid value for enumeration: ${JSON.stringify(e)} (expected one of ${r.map(o=>JSON.stringify(o)).join(", ")})`);return e}function sl(t,e){let r=[];for(let o of t){let a=e(o);a!==fne&&r.push(a)}return r}function KI(t,e){for(let r of t){let o=e(r);if(o!==pne)return o}}function pL(t){return typeof t=="object"&&t!==null}async function Uc(t){let e=await Promise.allSettled(t),r=[];for(let o of e){if(o.status==="rejected")throw o.reason;r.push(o.value)}return r}function iP(t){if(t instanceof Map&&(t=Object.fromEntries(t)),pL(t))for(let e of Object.keys(t)){let r=t[e];pL(r)&&(t[e]=iP(r))}return t}function ol(t,e,r){let o=t.get(e);return typeof o>"u"&&t.set(e,o=r()),o}function Yy(t,e){let r=t.get(e);return typeof r>"u"&&t.set(e,r=[]),r}function md(t,e){let r=t.get(e);return typeof r>"u"&&t.set(e,r=new Set),r}function Wy(t,e){let r=t.get(e);return typeof r>"u"&&t.set(e,r=new Map),r}async function bZe(t,e){if(e==null)return await t();try{return await t()}finally{await e()}}async function Ky(t,e){try{return await t()}catch(r){throw r.message=e(r.message),r}}function EL(t,e){try{return t()}catch(r){throw r.message=e(r.message),r}}async function Vy(t){return await new Promise((e,r)=>{let o=[];t.on("error",a=>{r(a)}),t.on("data",a=>{o.push(a)}),t.on("end",()=>{e(Buffer.concat(o))})})}function hne(){let t,e;return{promise:new Promise((o,a)=>{t=o,e=a}),resolve:t,reject:e}}function gne(t){return WI(ue.fromPortablePath(t))}function dne(path){let physicalPath=ue.fromPortablePath(path),currentCacheEntry=WI.cache[physicalPath];delete WI.cache[physicalPath];let result;try{result=gne(physicalPath);let freshCacheEntry=WI.cache[physicalPath],dynamicModule=eval("module"),freshCacheIndex=dynamicModule.children.indexOf(freshCacheEntry);freshCacheIndex!==-1&&dynamicModule.children.splice(freshCacheIndex,1)}finally{WI.cache[physicalPath]=currentCacheEntry}return result}function xZe(t){let e=one.get(t),r=oe.statSync(t);if(e?.mtime===r.mtimeMs)return e.instance;let o=dne(t);return one.set(t,{mtime:r.mtimeMs,instance:o}),o}function Df(t,{cachingStrategy:e=2}={}){switch(e){case 0:return dne(t);case 1:return xZe(t);case 2:return gne(t);default:throw new Error("Unsupported caching strategy")}}function ks(t,e){let r=Array.from(t);Array.isArray(e)||(e=[e]);let o=[];for(let n of e)o.push(r.map(u=>n(u)));let a=r.map((n,u)=>u);return a.sort((n,u)=>{for(let A of o){let p=A[n]<A[u]?-1:A[n]>A[u]?1:0;if(p!==0)return p}return 0}),a.map(n=>r[n])}function kZe(t){return t.length===0?null:t.map(e=>`(${cne.default.makeRe(e,{windows:!1,dot:!0}).source})`).join("|")}function sP(t,{env:e}){let r=/\${(?<variableName>[\d\w_]+)(?<colon>:)?(?:-(?<fallback>[^}]*))?}/g;return t.replace(r,(...o)=>{let{variableName:a,colon:n,fallback:u}=o[o.length-1],A=Object.hasOwn(e,a),p=e[a];if(p||A&&!n)return p;if(u!=null)return u;throw new it(`Environment variable not found (${a})`)})}function VI(t){switch(t){case"true":case"1":case 1:case!0:return!0;case"false":case"0":case 0:case!1:return!1;default:throw new Error(`Couldn't parse "${t}" as a boolean`)}}function yne(t){return typeof t>"u"?t:VI(t)}function CL(t){try{return yne(t)}catch{return null}}function QZe(t){return!!(ue.isAbsolute(t)||t.match(/^(\.{1,2}|~)\//))}function Ene(t,...e){let r=u=>({value:u}),o=r(t),a=e.map(u=>r(u)),{value:n}=(0,lne.default)(o,...a,(u,A)=>{if(Array.isArray(u)&&Array.isArray(A)){for(let p of A)u.find(h=>(0,ane.default)(h,p))||u.push(p);return u}});return n}function FZe(...t){return Ene({},...t)}function wL(t,e){let r=Object.create(null);for(let o of t){let a=o[e];r[a]??=[],r[a].push(o)}return r}function Jy(t){return typeof t=="string"?Number.parseInt(t,10):t}var ane,lne,cne,une,Ane,mL,fne,pne,hL,gL,dL,WI,one,mne,jl=Et(()=>{St();jt();ane=$e(Vte()),lne=$e(sne()),cne=$e(Zo()),une=$e(id()),Ane=$e(zn()),mL=ve("stream");fne=Symbol();sl.skip=fne;pne=Symbol();KI.skip=pne;hL=class extends mL.Transform{constructor(){super(...arguments);this.chunks=[]}_transform(r,o,a){if(o!=="buffer"||!Buffer.isBuffer(r))throw new Error("Assertion failed: BufferStream only accept buffers");this.chunks.push(r),a(null,null)}_flush(r){r(null,Buffer.concat(this.chunks))}};gL=class{constructor(e){this.deferred=new Map;this.promises=new Map;this.limit=(0,une.default)(e)}set(e,r){let o=this.deferred.get(e);typeof o>"u"&&this.deferred.set(e,o=hne());let a=this.limit(()=>r());return this.promises.set(e,a),a.then(()=>{this.promises.get(e)===a&&o.resolve()},n=>{this.promises.get(e)===a&&o.reject(n)}),o.promise}reduce(e,r){let o=this.promises.get(e)??Promise.resolve();this.set(e,()=>r(o))}async wait(){await Promise.all(this.promises.values())}},dL=class extends mL.Transform{constructor(r=Buffer.alloc(0)){super();this.active=!0;this.ifEmpty=r}_transform(r,o,a){if(o!=="buffer"||!Buffer.isBuffer(r))throw new Error("Assertion failed: DefaultStream only accept buffers");this.active=!1,a(null,r)}_flush(r){this.active&&this.ifEmpty.length>0?r(null,this.ifEmpty):r(null)}},WI=eval("require");one=new Map;mne=(o=>(o[o.NoCache=0]="NoCache",o[o.FsTime=1]="FsTime",o[o.Node=2]="Node",o))(mne||{})});var zy,IL,BL,Cne=Et(()=>{zy=(r=>(r.HARD="HARD",r.SOFT="SOFT",r))(zy||{}),IL=(o=>(o.Dependency="Dependency",o.PeerDependency="PeerDependency",o.PeerDependencyMeta="PeerDependencyMeta",o))(IL||{}),BL=(o=>(o.Inactive="inactive",o.Redundant="redundant",o.Active="active",o))(BL||{})});var de={};Vt(de,{LogLevel:()=>uP,Style:()=>aP,Type:()=>yt,addLogFilterSupport:()=>XI,applyColor:()=>Vs,applyHyperlink:()=>Zy,applyStyle:()=>yd,json:()=>Ed,jsonOrPretty:()=>NZe,mark:()=>bL,pretty:()=>Mt,prettyField:()=>zu,prettyList:()=>PL,prettyTruncatedLocatorList:()=>cP,stripAnsi:()=>Xy.default,supportsColor:()=>lP,supportsHyperlinks:()=>SL,tuple:()=>_c});function wne(t){let e=["KiB","MiB","GiB","TiB"],r=e.length;for(;r>1&&t<1024**r;)r-=1;let o=1024**r;return`${Math.floor(t*100/o)/100} ${e[r-1]}`}function _c(t,e){return[e,t]}function yd(t,e,r){return t.get("enableColors")&&r&2&&(e=zI.default.bold(e)),e}function Vs(t,e,r){if(!t.get("enableColors"))return e;let o=RZe.get(r);if(o===null)return e;let a=typeof o>"u"?r:DL.level>=3?o[0]:o[1],n=typeof a=="number"?vL.ansi256(a):a.startsWith("#")?vL.hex(a):vL[a];if(typeof n!="function")throw new Error(`Invalid format type ${a}`);return n(e)}function Zy(t,e,r){return t.get("enableHyperlinks")?TZe?`\x1B]8;;${r}\x1B\\${e}\x1B]8;;\x1B\\`:`\x1B]8;;${r}\x07${e}\x1B]8;;\x07`:e}function Mt(t,e,r){if(e===null)return Vs(t,"null",yt.NULL);if(Object.hasOwn(oP,r))return oP[r].pretty(t,e);if(typeof e!="string")throw new Error(`Assertion failed: Expected the value to be a string, got ${typeof e}`);return Vs(t,e,r)}function PL(t,e,r,{separator:o=", "}={}){return[...e].map(a=>Mt(t,a,r)).join(o)}function Ed(t,e){if(t===null)return null;if(Object.hasOwn(oP,e))return oP[e].json(t);if(typeof t!="string")throw new Error(`Assertion failed: Expected the value to be a string, got ${typeof t}`);return t}function NZe(t,e,[r,o]){return t?Ed(r,o):Mt(e,r,o)}function bL(t){return{Check:Vs(t,"\u2713","green"),Cross:Vs(t,"\u2718","red"),Question:Vs(t,"?","cyan")}}function zu(t,{label:e,value:[r,o]}){return`${Mt(t,e,yt.CODE)}: ${Mt(t,r,o)}`}function cP(t,e,r){let o=[],a=[...e],n=r;for(;a.length>0;){let h=a[0],E=`${jr(t,h)}, `,I=xL(h).length+2;if(o.length>0&&n<I)break;o.push([E,I]),n-=I,a.shift()}if(a.length===0)return o.map(([h])=>h).join("").slice(0,-2);let u="X".repeat(a.length.toString().length),A=`and ${u} more.`,p=a.length;for(;o.length>1&&n<A.length;)n+=o[o.length-1][1],p+=1,o.pop();return[o.map(([h])=>h).join(""),A.replace(u,Mt(t,p,yt.NUMBER))].join("")}function XI(t,{configuration:e}){let r=e.get("logFilters"),o=new Map,a=new Map,n=[];for(let I of r){let v=I.get("level");if(typeof v>"u")continue;let x=I.get("code");typeof x<"u"&&o.set(x,v);let C=I.get("text");typeof C<"u"&&a.set(C,v);let R=I.get("pattern");typeof R<"u"&&n.push([Ine.default.matcher(R,{contains:!0}),v])}n.reverse();let u=(I,v,x)=>{if(I===null||I===0)return x;let C=a.size>0||n.length>0?(0,Xy.default)(v):v;if(a.size>0){let R=a.get(C);if(typeof R<"u")return R??x}if(n.length>0){for(let[R,L]of n)if(R(C))return L??x}if(o.size>0){let R=o.get(Wu(I));if(typeof R<"u")return R??x}return x},A=t.reportInfo,p=t.reportWarning,h=t.reportError,E=function(I,v,x,C){switch(u(v,x,C)){case"info":A.call(I,v,x);break;case"warning":p.call(I,v??0,x);break;case"error":h.call(I,v??0,x);break}};t.reportInfo=function(...I){return E(this,...I,"info")},t.reportWarning=function(...I){return E(this,...I,"warning")},t.reportError=function(...I){return E(this,...I,"error")}}var zI,JI,Ine,Xy,Bne,yt,aP,DL,lP,SL,vL,RZe,Po,oP,TZe,uP,Gl=Et(()=>{St();zI=$e(IN()),JI=$e(td());jt();Ine=$e(Zo()),Xy=$e(OS()),Bne=ve("util");fS();bo();yt={NO_HINT:"NO_HINT",ID:"ID",NULL:"NULL",SCOPE:"SCOPE",NAME:"NAME",RANGE:"RANGE",REFERENCE:"REFERENCE",NUMBER:"NUMBER",PATH:"PATH",URL:"URL",ADDED:"ADDED",REMOVED:"REMOVED",CODE:"CODE",INSPECT:"INSPECT",DURATION:"DURATION",SIZE:"SIZE",SIZE_DIFF:"SIZE_DIFF",IDENT:"IDENT",DESCRIPTOR:"DESCRIPTOR",LOCATOR:"LOCATOR",RESOLUTION:"RESOLUTION",DEPENDENT:"DEPENDENT",PACKAGE_EXTENSION:"PACKAGE_EXTENSION",SETTING:"SETTING",MARKDOWN:"MARKDOWN",MARKDOWN_INLINE:"MARKDOWN_INLINE"},aP=(e=>(e[e.BOLD=2]="BOLD",e))(aP||{}),DL=JI.default.GITHUB_ACTIONS?{level:2}:zI.default.supportsColor?{level:zI.default.supportsColor.level}:{level:0},lP=DL.level!==0,SL=lP&&!JI.default.GITHUB_ACTIONS&&!JI.default.CIRCLE&&!JI.default.GITLAB,vL=new zI.default.Instance(DL),RZe=new Map([[yt.NO_HINT,null],[yt.NULL,["#a853b5",129]],[yt.SCOPE,["#d75f00",166]],[yt.NAME,["#d7875f",173]],[yt.RANGE,["#00afaf",37]],[yt.REFERENCE,["#87afff",111]],[yt.NUMBER,["#ffd700",220]],[yt.PATH,["#d75fd7",170]],[yt.URL,["#d75fd7",170]],[yt.ADDED,["#5faf00",70]],[yt.REMOVED,["#ff3131",160]],[yt.CODE,["#87afff",111]],[yt.SIZE,["#ffd700",220]]]),Po=t=>t;oP={[yt.ID]:Po({pretty:(t,e)=>typeof e=="number"?Vs(t,`${e}`,yt.NUMBER):Vs(t,e,yt.CODE),json:t=>t}),[yt.INSPECT]:Po({pretty:(t,e)=>(0,Bne.inspect)(e,{depth:1/0,colors:t.get("enableColors"),compact:!0,breakLength:1/0}),json:t=>t}),[yt.NUMBER]:Po({pretty:(t,e)=>Vs(t,`${e}`,yt.NUMBER),json:t=>t}),[yt.IDENT]:Po({pretty:(t,e)=>cs(t,e),json:t=>fn(t)}),[yt.LOCATOR]:Po({pretty:(t,e)=>jr(t,e),json:t=>ba(t)}),[yt.DESCRIPTOR]:Po({pretty:(t,e)=>Gn(t,e),json:t=>Pa(t)}),[yt.RESOLUTION]:Po({pretty:(t,{descriptor:e,locator:r})=>ZI(t,e,r),json:({descriptor:t,locator:e})=>({descriptor:Pa(t),locator:e!==null?ba(e):null})}),[yt.DEPENDENT]:Po({pretty:(t,{locator:e,descriptor:r})=>kL(t,e,r),json:({locator:t,descriptor:e})=>({locator:ba(t),descriptor:Pa(e)})}),[yt.PACKAGE_EXTENSION]:Po({pretty:(t,e)=>{switch(e.type){case"Dependency":return`${cs(t,e.parentDescriptor)} \u27A4 ${Vs(t,"dependencies",yt.CODE)} \u27A4 ${cs(t,e.descriptor)}`;case"PeerDependency":return`${cs(t,e.parentDescriptor)} \u27A4 ${Vs(t,"peerDependencies",yt.CODE)} \u27A4 ${cs(t,e.descriptor)}`;case"PeerDependencyMeta":return`${cs(t,e.parentDescriptor)} \u27A4 ${Vs(t,"peerDependenciesMeta",yt.CODE)} \u27A4 ${cs(t,Js(e.selector))} \u27A4 ${Vs(t,e.key,yt.CODE)}`;default:throw new Error(`Assertion failed: Unsupported package extension type: ${e.type}`)}},json:t=>{switch(t.type){case"Dependency":return`${fn(t.parentDescriptor)} > ${fn(t.descriptor)}`;case"PeerDependency":return`${fn(t.parentDescriptor)} >> ${fn(t.descriptor)}`;case"PeerDependencyMeta":return`${fn(t.parentDescriptor)} >> ${t.selector} / ${t.key}`;default:throw new Error(`Assertion failed: Unsupported package extension type: ${t.type}`)}}}),[yt.SETTING]:Po({pretty:(t,e)=>(t.get(e),Zy(t,Vs(t,e,yt.CODE),`https://yarnpkg.com/configuration/yarnrc#${e}`)),json:t=>t}),[yt.DURATION]:Po({pretty:(t,e)=>{if(e>1e3*60){let r=Math.floor(e/1e3/60),o=Math.ceil((e-r*60*1e3)/1e3);return o===0?`${r}m`:`${r}m ${o}s`}else{let r=Math.floor(e/1e3),o=e-r*1e3;return o===0?`${r}s`:`${r}s ${o}ms`}},json:t=>t}),[yt.SIZE]:Po({pretty:(t,e)=>Vs(t,wne(e),yt.NUMBER),json:t=>t}),[yt.SIZE_DIFF]:Po({pretty:(t,e)=>{let r=e>=0?"+":"-",o=r==="+"?yt.REMOVED:yt.ADDED;return Vs(t,`${r} ${wne(Math.max(Math.abs(e),1))}`,o)},json:t=>t}),[yt.PATH]:Po({pretty:(t,e)=>Vs(t,ue.fromPortablePath(e),yt.PATH),json:t=>ue.fromPortablePath(t)}),[yt.MARKDOWN]:Po({pretty:(t,{text:e,format:r,paragraphs:o})=>Do(e,{format:r,paragraphs:o}),json:({text:t})=>t}),[yt.MARKDOWN_INLINE]:Po({pretty:(t,e)=>(e=e.replace(/(`+)((?:.|[\n])*?)\1/g,(r,o,a)=>Mt(t,o+a+o,yt.CODE)),e=e.replace(/(\*\*)((?:.|[\n])*?)\1/g,(r,o,a)=>yd(t,a,2)),e),json:t=>t})};TZe=!!process.env.KONSOLE_VERSION;uP=(a=>(a.Error="error",a.Warning="warning",a.Info="info",a.Discard="discard",a))(uP||{})});var vne=_($y=>{"use strict";Object.defineProperty($y,"__esModule",{value:!0});$y.splitWhen=$y.flatten=void 0;function LZe(t){return t.reduce((e,r)=>[].concat(e,r),[])}$y.flatten=LZe;function OZe(t,e){let r=[[]],o=0;for(let a of t)e(a)?(o++,r[o]=[]):r[o].push(a);return r}$y.splitWhen=OZe});var Dne=_(AP=>{"use strict";Object.defineProperty(AP,"__esModule",{value:!0});AP.isEnoentCodeError=void 0;function MZe(t){return t.code==="ENOENT"}AP.isEnoentCodeError=MZe});var Sne=_(fP=>{"use strict";Object.defineProperty(fP,"__esModule",{value:!0});fP.createDirentFromStats=void 0;var QL=class{constructor(e,r){this.name=e,this.isBlockDevice=r.isBlockDevice.bind(r),this.isCharacterDevice=r.isCharacterDevice.bind(r),this.isDirectory=r.isDirectory.bind(r),this.isFIFO=r.isFIFO.bind(r),this.isFile=r.isFile.bind(r),this.isSocket=r.isSocket.bind(r),this.isSymbolicLink=r.isSymbolicLink.bind(r)}};function UZe(t,e){return new QL(t,e)}fP.createDirentFromStats=UZe});var Pne=_(Xu=>{"use strict";Object.defineProperty(Xu,"__esModule",{value:!0});Xu.removeLeadingDotSegment=Xu.escape=Xu.makeAbsolute=Xu.unixify=void 0;var _Ze=ve("path"),HZe=2,jZe=/(\\?)([()*?[\]{|}]|^!|[!+@](?=\())/g;function GZe(t){return t.replace(/\\/g,"/")}Xu.unixify=GZe;function qZe(t,e){return _Ze.resolve(t,e)}Xu.makeAbsolute=qZe;function YZe(t){return t.replace(jZe,"\\$2")}Xu.escape=YZe;function WZe(t){if(t.charAt(0)==="."){let e=t.charAt(1);if(e==="/"||e==="\\")return t.slice(HZe)}return t}Xu.removeLeadingDotSegment=WZe});var xne=_((PTt,bne)=>{bne.exports=function(e){if(typeof e!="string"||e==="")return!1;for(var r;r=/(\\).|([@?!+*]\(.*\))/g.exec(e);){if(r[2])return!0;e=e.slice(r.index+r[0].length)}return!1}});var Fne=_((bTt,Qne)=>{var KZe=xne(),kne={"{":"}","(":")","[":"]"},VZe=function(t){if(t[0]==="!")return!0;for(var e=0,r=-2,o=-2,a=-2,n=-2,u=-2;e<t.length;){if(t[e]==="*"||t[e+1]==="?"&&/[\].+)]/.test(t[e])||o!==-1&&t[e]==="["&&t[e+1]!=="]"&&(o<e&&(o=t.indexOf("]",e)),o>e&&(u===-1||u>o||(u=t.indexOf("\\",e),u===-1||u>o)))||a!==-1&&t[e]==="{"&&t[e+1]!=="}"&&(a=t.indexOf("}",e),a>e&&(u=t.indexOf("\\",e),u===-1||u>a))||n!==-1&&t[e]==="("&&t[e+1]==="?"&&/[:!=]/.test(t[e+2])&&t[e+3]!==")"&&(n=t.indexOf(")",e),n>e&&(u=t.indexOf("\\",e),u===-1||u>n))||r!==-1&&t[e]==="("&&t[e+1]!=="|"&&(r<e&&(r=t.indexOf("|",e)),r!==-1&&t[r+1]!==")"&&(n=t.indexOf(")",r),n>r&&(u=t.indexOf("\\",r),u===-1||u>n))))return!0;if(t[e]==="\\"){var A=t[e+1];e+=2;var p=kne[A];if(p){var h=t.indexOf(p,e);h!==-1&&(e=h+1)}if(t[e]==="!")return!0}else e++}return!1},JZe=function(t){if(t[0]==="!")return!0;for(var e=0;e<t.length;){if(/[*?{}()[\]]/.test(t[e]))return!0;if(t[e]==="\\"){var r=t[e+1];e+=2;var o=kne[r];if(o){var a=t.indexOf(o,e);a!==-1&&(e=a+1)}if(t[e]==="!")return!0}else e++}return!1};Qne.exports=function(e,r){if(typeof e!="string"||e==="")return!1;if(KZe(e))return!0;var o=VZe;return r&&r.strict===!1&&(o=JZe),o(e)}});var Tne=_((xTt,Rne)=>{"use strict";var zZe=Fne(),XZe=ve("path").posix.dirname,ZZe=ve("os").platform()==="win32",FL="/",$Ze=/\\/g,e$e=/[\{\[].*[\}\]]$/,t$e=/(^|[^\\])([\{\[]|\([^\)]+$)/,r$e=/\\([\!\*\?\|\[\]\(\)\{\}])/g;Rne.exports=function(e,r){var o=Object.assign({flipBackslashes:!0},r);o.flipBackslashes&&ZZe&&e.indexOf(FL)<0&&(e=e.replace($Ze,FL)),e$e.test(e)&&(e+=FL),e+="a";do e=XZe(e);while(zZe(e)||t$e.test(e));return e.replace(r$e,"$1")}});var jne=_(Gr=>{"use strict";Object.defineProperty(Gr,"__esModule",{value:!0});Gr.matchAny=Gr.convertPatternsToRe=Gr.makeRe=Gr.getPatternParts=Gr.expandBraceExpansion=Gr.expandPatternsWithBraceExpansion=Gr.isAffectDepthOfReadingPattern=Gr.endsWithSlashGlobStar=Gr.hasGlobStar=Gr.getBaseDirectory=Gr.isPatternRelatedToParentDirectory=Gr.getPatternsOutsideCurrentDirectory=Gr.getPatternsInsideCurrentDirectory=Gr.getPositivePatterns=Gr.getNegativePatterns=Gr.isPositivePattern=Gr.isNegativePattern=Gr.convertToNegativePattern=Gr.convertToPositivePattern=Gr.isDynamicPattern=Gr.isStaticPattern=void 0;var n$e=ve("path"),i$e=Tne(),RL=Zo(),Nne="**",s$e="\\",o$e=/[*?]|^!/,a$e=/\[[^[]*]/,l$e=/(?:^|[^!*+?@])\([^(]*\|[^|]*\)/,c$e=/[!*+?@]\([^(]*\)/,u$e=/,|\.\./;function Lne(t,e={}){return!One(t,e)}Gr.isStaticPattern=Lne;function One(t,e={}){return t===""?!1:!!(e.caseSensitiveMatch===!1||t.includes(s$e)||o$e.test(t)||a$e.test(t)||l$e.test(t)||e.extglob!==!1&&c$e.test(t)||e.braceExpansion!==!1&&A$e(t))}Gr.isDynamicPattern=One;function A$e(t){let e=t.indexOf("{");if(e===-1)return!1;let r=t.indexOf("}",e+1);if(r===-1)return!1;let o=t.slice(e,r);return u$e.test(o)}function f$e(t){return pP(t)?t.slice(1):t}Gr.convertToPositivePattern=f$e;function p$e(t){return"!"+t}Gr.convertToNegativePattern=p$e;function pP(t){return t.startsWith("!")&&t[1]!=="("}Gr.isNegativePattern=pP;function Mne(t){return!pP(t)}Gr.isPositivePattern=Mne;function h$e(t){return t.filter(pP)}Gr.getNegativePatterns=h$e;function g$e(t){return t.filter(Mne)}Gr.getPositivePatterns=g$e;function d$e(t){return t.filter(e=>!TL(e))}Gr.getPatternsInsideCurrentDirectory=d$e;function m$e(t){return t.filter(TL)}Gr.getPatternsOutsideCurrentDirectory=m$e;function TL(t){return t.startsWith("..")||t.startsWith("./..")}Gr.isPatternRelatedToParentDirectory=TL;function y$e(t){return i$e(t,{flipBackslashes:!1})}Gr.getBaseDirectory=y$e;function E$e(t){return t.includes(Nne)}Gr.hasGlobStar=E$e;function Une(t){return t.endsWith("/"+Nne)}Gr.endsWithSlashGlobStar=Une;function C$e(t){let e=n$e.basename(t);return Une(t)||Lne(e)}Gr.isAffectDepthOfReadingPattern=C$e;function w$e(t){return t.reduce((e,r)=>e.concat(_ne(r)),[])}Gr.expandPatternsWithBraceExpansion=w$e;function _ne(t){return RL.braces(t,{expand:!0,nodupes:!0})}Gr.expandBraceExpansion=_ne;function I$e(t,e){let{parts:r}=RL.scan(t,Object.assign(Object.assign({},e),{parts:!0}));return r.length===0&&(r=[t]),r[0].startsWith("/")&&(r[0]=r[0].slice(1),r.unshift("")),r}Gr.getPatternParts=I$e;function Hne(t,e){return RL.makeRe(t,e)}Gr.makeRe=Hne;function B$e(t,e){return t.map(r=>Hne(r,e))}Gr.convertPatternsToRe=B$e;function v$e(t,e){return e.some(r=>r.test(t))}Gr.matchAny=v$e});var Wne=_((QTt,Yne)=>{"use strict";var D$e=ve("stream"),Gne=D$e.PassThrough,S$e=Array.prototype.slice;Yne.exports=P$e;function P$e(){let t=[],e=S$e.call(arguments),r=!1,o=e[e.length-1];o&&!Array.isArray(o)&&o.pipe==null?e.pop():o={};let a=o.end!==!1,n=o.pipeError===!0;o.objectMode==null&&(o.objectMode=!0),o.highWaterMark==null&&(o.highWaterMark=64*1024);let u=Gne(o);function A(){for(let E=0,I=arguments.length;E<I;E++)t.push(qne(arguments[E],o));return p(),this}function p(){if(r)return;r=!0;let E=t.shift();if(!E){process.nextTick(h);return}Array.isArray(E)||(E=[E]);let I=E.length+1;function v(){--I>0||(r=!1,p())}function x(C){function R(){C.removeListener("merge2UnpipeEnd",R),C.removeListener("end",R),n&&C.removeListener("error",L),v()}function L(U){u.emit("error",U)}if(C._readableState.endEmitted)return v();C.on("merge2UnpipeEnd",R),C.on("end",R),n&&C.on("error",L),C.pipe(u,{end:!1}),C.resume()}for(let C=0;C<E.length;C++)x(E[C]);v()}function h(){r=!1,u.emit("queueDrain"),a&&u.end()}return u.setMaxListeners(0),u.add=A,u.on("unpipe",function(E){E.emit("merge2UnpipeEnd")}),e.length&&A.apply(null,e),u}function qne(t,e){if(Array.isArray(t))for(let r=0,o=t.length;r<o;r++)t[r]=qne(t[r],e);else{if(!t._readableState&&t.pipe&&(t=t.pipe(Gne(e))),!t._readableState||!t.pause||!t.pipe)throw new Error("Only readable stream can be merged.");t.pause()}return t}});var Vne=_(hP=>{"use strict";Object.defineProperty(hP,"__esModule",{value:!0});hP.merge=void 0;var b$e=Wne();function x$e(t){let e=b$e(t);return t.forEach(r=>{r.once("error",o=>e.emit("error",o))}),e.once("close",()=>Kne(t)),e.once("end",()=>Kne(t)),e}hP.merge=x$e;function Kne(t){t.forEach(e=>e.emit("close"))}});var Jne=_(eE=>{"use strict";Object.defineProperty(eE,"__esModule",{value:!0});eE.isEmpty=eE.isString=void 0;function k$e(t){return typeof t=="string"}eE.isString=k$e;function Q$e(t){return t===""}eE.isEmpty=Q$e});var Sf=_(xo=>{"use strict";Object.defineProperty(xo,"__esModule",{value:!0});xo.string=xo.stream=xo.pattern=xo.path=xo.fs=xo.errno=xo.array=void 0;var F$e=vne();xo.array=F$e;var R$e=Dne();xo.errno=R$e;var T$e=Sne();xo.fs=T$e;var N$e=Pne();xo.path=N$e;var L$e=jne();xo.pattern=L$e;var O$e=Vne();xo.stream=O$e;var M$e=Jne();xo.string=M$e});var Zne=_(ko=>{"use strict";Object.defineProperty(ko,"__esModule",{value:!0});ko.convertPatternGroupToTask=ko.convertPatternGroupsToTasks=ko.groupPatternsByBaseDirectory=ko.getNegativePatternsAsPositive=ko.getPositivePatterns=ko.convertPatternsToTasks=ko.generate=void 0;var Pf=Sf();function U$e(t,e){let r=zne(t),o=Xne(t,e.ignore),a=r.filter(p=>Pf.pattern.isStaticPattern(p,e)),n=r.filter(p=>Pf.pattern.isDynamicPattern(p,e)),u=NL(a,o,!1),A=NL(n,o,!0);return u.concat(A)}ko.generate=U$e;function NL(t,e,r){let o=[],a=Pf.pattern.getPatternsOutsideCurrentDirectory(t),n=Pf.pattern.getPatternsInsideCurrentDirectory(t),u=LL(a),A=LL(n);return o.push(...OL(u,e,r)),"."in A?o.push(ML(".",n,e,r)):o.push(...OL(A,e,r)),o}ko.convertPatternsToTasks=NL;function zne(t){return Pf.pattern.getPositivePatterns(t)}ko.getPositivePatterns=zne;function Xne(t,e){return Pf.pattern.getNegativePatterns(t).concat(e).map(Pf.pattern.convertToPositivePattern)}ko.getNegativePatternsAsPositive=Xne;function LL(t){let e={};return t.reduce((r,o)=>{let a=Pf.pattern.getBaseDirectory(o);return a in r?r[a].push(o):r[a]=[o],r},e)}ko.groupPatternsByBaseDirectory=LL;function OL(t,e,r){return Object.keys(t).map(o=>ML(o,t[o],e,r))}ko.convertPatternGroupsToTasks=OL;function ML(t,e,r,o){return{dynamic:o,positive:e,negative:r,base:t,patterns:[].concat(e,r.map(Pf.pattern.convertToNegativePattern))}}ko.convertPatternGroupToTask=ML});var eie=_(tE=>{"use strict";Object.defineProperty(tE,"__esModule",{value:!0});tE.removeDuplicateSlashes=tE.transform=void 0;var _$e=/(?!^)\/{2,}/g;function H$e(t){return t.map(e=>$ne(e))}tE.transform=H$e;function $ne(t){return t.replace(_$e,"/")}tE.removeDuplicateSlashes=$ne});var rie=_(gP=>{"use strict";Object.defineProperty(gP,"__esModule",{value:!0});gP.read=void 0;function j$e(t,e,r){e.fs.lstat(t,(o,a)=>{if(o!==null){tie(r,o);return}if(!a.isSymbolicLink()||!e.followSymbolicLink){UL(r,a);return}e.fs.stat(t,(n,u)=>{if(n!==null){if(e.throwErrorOnBrokenSymbolicLink){tie(r,n);return}UL(r,a);return}e.markSymbolicLink&&(u.isSymbolicLink=()=>!0),UL(r,u)})})}gP.read=j$e;function tie(t,e){t(e)}function UL(t,e){t(null,e)}});var nie=_(dP=>{"use strict";Object.defineProperty(dP,"__esModule",{value:!0});dP.read=void 0;function G$e(t,e){let r=e.fs.lstatSync(t);if(!r.isSymbolicLink()||!e.followSymbolicLink)return r;try{let o=e.fs.statSync(t);return e.markSymbolicLink&&(o.isSymbolicLink=()=>!0),o}catch(o){if(!e.throwErrorOnBrokenSymbolicLink)return r;throw o}}dP.read=G$e});var iie=_(Xp=>{"use strict";Object.defineProperty(Xp,"__esModule",{value:!0});Xp.createFileSystemAdapter=Xp.FILE_SYSTEM_ADAPTER=void 0;var mP=ve("fs");Xp.FILE_SYSTEM_ADAPTER={lstat:mP.lstat,stat:mP.stat,lstatSync:mP.lstatSync,statSync:mP.statSync};function q$e(t){return t===void 0?Xp.FILE_SYSTEM_ADAPTER:Object.assign(Object.assign({},Xp.FILE_SYSTEM_ADAPTER),t)}Xp.createFileSystemAdapter=q$e});var sie=_(HL=>{"use strict";Object.defineProperty(HL,"__esModule",{value:!0});var Y$e=iie(),_L=class{constructor(e={}){this._options=e,this.followSymbolicLink=this._getValue(this._options.followSymbolicLink,!0),this.fs=Y$e.createFileSystemAdapter(this._options.fs),this.markSymbolicLink=this._getValue(this._options.markSymbolicLink,!1),this.throwErrorOnBrokenSymbolicLink=this._getValue(this._options.throwErrorOnBrokenSymbolicLink,!0)}_getValue(e,r){return e??r}};HL.default=_L});var Cd=_(Zp=>{"use strict";Object.defineProperty(Zp,"__esModule",{value:!0});Zp.statSync=Zp.stat=Zp.Settings=void 0;var oie=rie(),W$e=nie(),jL=sie();Zp.Settings=jL.default;function K$e(t,e,r){if(typeof e=="function"){oie.read(t,qL(),e);return}oie.read(t,qL(e),r)}Zp.stat=K$e;function V$e(t,e){let r=qL(e);return W$e.read(t,r)}Zp.statSync=V$e;function qL(t={}){return t instanceof jL.default?t:new jL.default(t)}});var lie=_((jTt,aie)=>{aie.exports=J$e;function J$e(t,e){var r,o,a,n=!0;Array.isArray(t)?(r=[],o=t.length):(a=Object.keys(t),r={},o=a.length);function u(p){function h(){e&&e(p,r),e=null}n?process.nextTick(h):h()}function A(p,h,E){r[p]=E,(--o===0||h)&&u(h)}o?a?a.forEach(function(p){t[p](function(h,E){A(p,h,E)})}):t.forEach(function(p,h){p(function(E,I){A(h,E,I)})}):u(null),n=!1}});var YL=_(EP=>{"use strict";Object.defineProperty(EP,"__esModule",{value:!0});EP.IS_SUPPORT_READDIR_WITH_FILE_TYPES=void 0;var yP=process.versions.node.split(".");if(yP[0]===void 0||yP[1]===void 0)throw new Error(`Unexpected behavior. The 'process.versions.node' variable has invalid value: ${process.versions.node}`);var cie=Number.parseInt(yP[0],10),z$e=Number.parseInt(yP[1],10),uie=10,X$e=10,Z$e=cie>uie,$$e=cie===uie&&z$e>=X$e;EP.IS_SUPPORT_READDIR_WITH_FILE_TYPES=Z$e||$$e});var Aie=_(CP=>{"use strict";Object.defineProperty(CP,"__esModule",{value:!0});CP.createDirentFromStats=void 0;var WL=class{constructor(e,r){this.name=e,this.isBlockDevice=r.isBlockDevice.bind(r),this.isCharacterDevice=r.isCharacterDevice.bind(r),this.isDirectory=r.isDirectory.bind(r),this.isFIFO=r.isFIFO.bind(r),this.isFile=r.isFile.bind(r),this.isSocket=r.isSocket.bind(r),this.isSymbolicLink=r.isSymbolicLink.bind(r)}};function eet(t,e){return new WL(t,e)}CP.createDirentFromStats=eet});var KL=_(wP=>{"use strict";Object.defineProperty(wP,"__esModule",{value:!0});wP.fs=void 0;var tet=Aie();wP.fs=tet});var VL=_(IP=>{"use strict";Object.defineProperty(IP,"__esModule",{value:!0});IP.joinPathSegments=void 0;function ret(t,e,r){return t.endsWith(r)?t+e:t+r+e}IP.joinPathSegments=ret});var mie=_($p=>{"use strict";Object.defineProperty($p,"__esModule",{value:!0});$p.readdir=$p.readdirWithFileTypes=$p.read=void 0;var net=Cd(),fie=lie(),iet=YL(),pie=KL(),hie=VL();function set(t,e,r){if(!e.stats&&iet.IS_SUPPORT_READDIR_WITH_FILE_TYPES){gie(t,e,r);return}die(t,e,r)}$p.read=set;function gie(t,e,r){e.fs.readdir(t,{withFileTypes:!0},(o,a)=>{if(o!==null){BP(r,o);return}let n=a.map(A=>({dirent:A,name:A.name,path:hie.joinPathSegments(t,A.name,e.pathSegmentSeparator)}));if(!e.followSymbolicLinks){JL(r,n);return}let u=n.map(A=>oet(A,e));fie(u,(A,p)=>{if(A!==null){BP(r,A);return}JL(r,p)})})}$p.readdirWithFileTypes=gie;function oet(t,e){return r=>{if(!t.dirent.isSymbolicLink()){r(null,t);return}e.fs.stat(t.path,(o,a)=>{if(o!==null){if(e.throwErrorOnBrokenSymbolicLink){r(o);return}r(null,t);return}t.dirent=pie.fs.createDirentFromStats(t.name,a),r(null,t)})}}function die(t,e,r){e.fs.readdir(t,(o,a)=>{if(o!==null){BP(r,o);return}let n=a.map(u=>{let A=hie.joinPathSegments(t,u,e.pathSegmentSeparator);return p=>{net.stat(A,e.fsStatSettings,(h,E)=>{if(h!==null){p(h);return}let I={name:u,path:A,dirent:pie.fs.createDirentFromStats(u,E)};e.stats&&(I.stats=E),p(null,I)})}});fie(n,(u,A)=>{if(u!==null){BP(r,u);return}JL(r,A)})})}$p.readdir=die;function BP(t,e){t(e)}function JL(t,e){t(null,e)}});var Iie=_(eh=>{"use strict";Object.defineProperty(eh,"__esModule",{value:!0});eh.readdir=eh.readdirWithFileTypes=eh.read=void 0;var aet=Cd(),cet=YL(),yie=KL(),Eie=VL();function uet(t,e){return!e.stats&&cet.IS_SUPPORT_READDIR_WITH_FILE_TYPES?Cie(t,e):wie(t,e)}eh.read=uet;function Cie(t,e){return e.fs.readdirSync(t,{withFileTypes:!0}).map(o=>{let a={dirent:o,name:o.name,path:Eie.joinPathSegments(t,o.name,e.pathSegmentSeparator)};if(a.dirent.isSymbolicLink()&&e.followSymbolicLinks)try{let n=e.fs.statSync(a.path);a.dirent=yie.fs.createDirentFromStats(a.name,n)}catch(n){if(e.throwErrorOnBrokenSymbolicLink)throw n}return a})}eh.readdirWithFileTypes=Cie;function wie(t,e){return e.fs.readdirSync(t).map(o=>{let a=Eie.joinPathSegments(t,o,e.pathSegmentSeparator),n=aet.statSync(a,e.fsStatSettings),u={name:o,path:a,dirent:yie.fs.createDirentFromStats(o,n)};return e.stats&&(u.stats=n),u})}eh.readdir=wie});var Bie=_(th=>{"use strict";Object.defineProperty(th,"__esModule",{value:!0});th.createFileSystemAdapter=th.FILE_SYSTEM_ADAPTER=void 0;var rE=ve("fs");th.FILE_SYSTEM_ADAPTER={lstat:rE.lstat,stat:rE.stat,lstatSync:rE.lstatSync,statSync:rE.statSync,readdir:rE.readdir,readdirSync:rE.readdirSync};function Aet(t){return t===void 0?th.FILE_SYSTEM_ADAPTER:Object.assign(Object.assign({},th.FILE_SYSTEM_ADAPTER),t)}th.createFileSystemAdapter=Aet});var vie=_(XL=>{"use strict";Object.defineProperty(XL,"__esModule",{value:!0});var fet=ve("path"),pet=Cd(),het=Bie(),zL=class{constructor(e={}){this._options=e,this.followSymbolicLinks=this._getValue(this._options.followSymbolicLinks,!1),this.fs=het.createFileSystemAdapter(this._options.fs),this.pathSegmentSeparator=this._getValue(this._options.pathSegmentSeparator,fet.sep),this.stats=this._getValue(this._options.stats,!1),this.throwErrorOnBrokenSymbolicLink=this._getValue(this._options.throwErrorOnBrokenSymbolicLink,!0),this.fsStatSettings=new pet.Settings({followSymbolicLink:this.followSymbolicLinks,fs:this.fs,throwErrorOnBrokenSymbolicLink:this.throwErrorOnBrokenSymbolicLink})}_getValue(e,r){return e??r}};XL.default=zL});var vP=_(rh=>{"use strict";Object.defineProperty(rh,"__esModule",{value:!0});rh.Settings=rh.scandirSync=rh.scandir=void 0;var Die=mie(),get=Iie(),ZL=vie();rh.Settings=ZL.default;function det(t,e,r){if(typeof e=="function"){Die.read(t,$L(),e);return}Die.read(t,$L(e),r)}rh.scandir=det;function met(t,e){let r=$L(e);return get.read(t,r)}rh.scandirSync=met;function $L(t={}){return t instanceof ZL.default?t:new ZL.default(t)}});var Pie=_((ZTt,Sie)=>{"use strict";function yet(t){var e=new t,r=e;function o(){var n=e;return n.next?e=n.next:(e=new t,r=e),n.next=null,n}function a(n){r.next=n,r=n}return{get:o,release:a}}Sie.exports=yet});var xie=_(($Tt,eO)=>{"use strict";var Eet=Pie();function bie(t,e,r){if(typeof t=="function"&&(r=e,e=t,t=null),r<1)throw new Error("fastqueue concurrency must be greater than 1");var o=Eet(Cet),a=null,n=null,u=0,A=null,p={push:R,drain:ql,saturated:ql,pause:E,paused:!1,concurrency:r,running:h,resume:x,idle:C,length:I,getQueue:v,unshift:L,empty:ql,kill:J,killAndDrain:te,error:ae};return p;function h(){return u}function E(){p.paused=!0}function I(){for(var fe=a,ce=0;fe;)fe=fe.next,ce++;return ce}function v(){for(var fe=a,ce=[];fe;)ce.push(fe.value),fe=fe.next;return ce}function x(){if(!!p.paused){p.paused=!1;for(var fe=0;fe<p.concurrency;fe++)u++,U()}}function C(){return u===0&&p.length()===0}function R(fe,ce){var me=o.get();me.context=t,me.release=U,me.value=fe,me.callback=ce||ql,me.errorHandler=A,u===p.concurrency||p.paused?n?(n.next=me,n=me):(a=me,n=me,p.saturated()):(u++,e.call(t,me.value,me.worked))}function L(fe,ce){var me=o.get();me.context=t,me.release=U,me.value=fe,me.callback=ce||ql,u===p.concurrency||p.paused?a?(me.next=a,a=me):(a=me,n=me,p.saturated()):(u++,e.call(t,me.value,me.worked))}function U(fe){fe&&o.release(fe);var ce=a;ce?p.paused?u--:(n===a&&(n=null),a=ce.next,ce.next=null,e.call(t,ce.value,ce.worked),n===null&&p.empty()):--u===0&&p.drain()}function J(){a=null,n=null,p.drain=ql}function te(){a=null,n=null,p.drain(),p.drain=ql}function ae(fe){A=fe}}function ql(){}function Cet(){this.value=null,this.callback=ql,this.next=null,this.release=ql,this.context=null,this.errorHandler=null;var t=this;this.worked=function(r,o){var a=t.callback,n=t.errorHandler,u=t.value;t.value=null,t.callback=ql,t.errorHandler&&n(r,u),a.call(t.context,r,o),t.release(t)}}function wet(t,e,r){typeof t=="function"&&(r=e,e=t,t=null);function o(E,I){e.call(this,E).then(function(v){I(null,v)},I)}var a=bie(t,o,r),n=a.push,u=a.unshift;return a.push=A,a.unshift=p,a.drained=h,a;function A(E){var I=new Promise(function(v,x){n(E,function(C,R){if(C){x(C);return}v(R)})});return I.catch(ql),I}function p(E){var I=new Promise(function(v,x){u(E,function(C,R){if(C){x(C);return}v(R)})});return I.catch(ql),I}function h(){var E=a.drain,I=new Promise(function(v){a.drain=function(){E(),v()}});return I}}eO.exports=bie;eO.exports.promise=wet});var DP=_(Zu=>{"use strict";Object.defineProperty(Zu,"__esModule",{value:!0});Zu.joinPathSegments=Zu.replacePathSegmentSeparator=Zu.isAppliedFilter=Zu.isFatalError=void 0;function Iet(t,e){return t.errorFilter===null?!0:!t.errorFilter(e)}Zu.isFatalError=Iet;function Bet(t,e){return t===null||t(e)}Zu.isAppliedFilter=Bet;function vet(t,e){return t.split(/[/\\]/).join(e)}Zu.replacePathSegmentSeparator=vet;function Det(t,e,r){return t===""?e:t.endsWith(r)?t+e:t+r+e}Zu.joinPathSegments=Det});var nO=_(rO=>{"use strict";Object.defineProperty(rO,"__esModule",{value:!0});var Pet=DP(),tO=class{constructor(e,r){this._root=e,this._settings=r,this._root=Pet.replacePathSegmentSeparator(e,r.pathSegmentSeparator)}};rO.default=tO});var oO=_(sO=>{"use strict";Object.defineProperty(sO,"__esModule",{value:!0});var bet=ve("events"),xet=vP(),ket=xie(),SP=DP(),Qet=nO(),iO=class extends Qet.default{constructor(e,r){super(e,r),this._settings=r,this._scandir=xet.scandir,this._emitter=new bet.EventEmitter,this._queue=ket(this._worker.bind(this),this._settings.concurrency),this._isFatalError=!1,this._isDestroyed=!1,this._queue.drain=()=>{this._isFatalError||this._emitter.emit("end")}}read(){return this._isFatalError=!1,this._isDestroyed=!1,setImmediate(()=>{this._pushToQueue(this._root,this._settings.basePath)}),this._emitter}get isDestroyed(){return this._isDestroyed}destroy(){if(this._isDestroyed)throw new Error("The reader is already destroyed");this._isDestroyed=!0,this._queue.killAndDrain()}onEntry(e){this._emitter.on("entry",e)}onError(e){this._emitter.once("error",e)}onEnd(e){this._emitter.once("end",e)}_pushToQueue(e,r){let o={directory:e,base:r};this._queue.push(o,a=>{a!==null&&this._handleError(a)})}_worker(e,r){this._scandir(e.directory,this._settings.fsScandirSettings,(o,a)=>{if(o!==null){r(o,void 0);return}for(let n of a)this._handleEntry(n,e.base);r(null,void 0)})}_handleError(e){this._isDestroyed||!SP.isFatalError(this._settings,e)||(this._isFatalError=!0,this._isDestroyed=!0,this._emitter.emit("error",e))}_handleEntry(e,r){if(this._isDestroyed||this._isFatalError)return;let o=e.path;r!==void 0&&(e.path=SP.joinPathSegments(r,e.name,this._settings.pathSegmentSeparator)),SP.isAppliedFilter(this._settings.entryFilter,e)&&this._emitEntry(e),e.dirent.isDirectory()&&SP.isAppliedFilter(this._settings.deepFilter,e)&&this._pushToQueue(o,r===void 0?void 0:e.path)}_emitEntry(e){this._emitter.emit("entry",e)}};sO.default=iO});var kie=_(lO=>{"use strict";Object.defineProperty(lO,"__esModule",{value:!0});var Fet=oO(),aO=class{constructor(e,r){this._root=e,this._settings=r,this._reader=new Fet.default(this._root,this._settings),this._storage=[]}read(e){this._reader.onError(r=>{Ret(e,r)}),this._reader.onEntry(r=>{this._storage.push(r)}),this._reader.onEnd(()=>{Tet(e,this._storage)}),this._reader.read()}};lO.default=aO;function Ret(t,e){t(e)}function Tet(t,e){t(null,e)}});var Qie=_(uO=>{"use strict";Object.defineProperty(uO,"__esModule",{value:!0});var Net=ve("stream"),Let=oO(),cO=class{constructor(e,r){this._root=e,this._settings=r,this._reader=new Let.default(this._root,this._settings),this._stream=new Net.Readable({objectMode:!0,read:()=>{},destroy:()=>{this._reader.isDestroyed||this._reader.destroy()}})}read(){return this._reader.onError(e=>{this._stream.emit("error",e)}),this._reader.onEntry(e=>{this._stream.push(e)}),this._reader.onEnd(()=>{this._stream.push(null)}),this._reader.read(),this._stream}};uO.default=cO});var Fie=_(fO=>{"use strict";Object.defineProperty(fO,"__esModule",{value:!0});var Oet=vP(),PP=DP(),Met=nO(),AO=class extends Met.default{constructor(){super(...arguments),this._scandir=Oet.scandirSync,this._storage=[],this._queue=new Set}read(){return this._pushToQueue(this._root,this._settings.basePath),this._handleQueue(),this._storage}_pushToQueue(e,r){this._queue.add({directory:e,base:r})}_handleQueue(){for(let e of this._queue.values())this._handleDirectory(e.directory,e.base)}_handleDirectory(e,r){try{let o=this._scandir(e,this._settings.fsScandirSettings);for(let a of o)this._handleEntry(a,r)}catch(o){this._handleError(o)}}_handleError(e){if(!!PP.isFatalError(this._settings,e))throw e}_handleEntry(e,r){let o=e.path;r!==void 0&&(e.path=PP.joinPathSegments(r,e.name,this._settings.pathSegmentSeparator)),PP.isAppliedFilter(this._settings.entryFilter,e)&&this._pushToStorage(e),e.dirent.isDirectory()&&PP.isAppliedFilter(this._settings.deepFilter,e)&&this._pushToQueue(o,r===void 0?void 0:e.path)}_pushToStorage(e){this._storage.push(e)}};fO.default=AO});var Rie=_(hO=>{"use strict";Object.defineProperty(hO,"__esModule",{value:!0});var Uet=Fie(),pO=class{constructor(e,r){this._root=e,this._settings=r,this._reader=new Uet.default(this._root,this._settings)}read(){return this._reader.read()}};hO.default=pO});var Tie=_(dO=>{"use strict";Object.defineProperty(dO,"__esModule",{value:!0});var _et=ve("path"),Het=vP(),gO=class{constructor(e={}){this._options=e,this.basePath=this._getValue(this._options.basePath,void 0),this.concurrency=this._getValue(this._options.concurrency,Number.POSITIVE_INFINITY),this.deepFilter=this._getValue(this._options.deepFilter,null),this.entryFilter=this._getValue(this._options.entryFilter,null),this.errorFilter=this._getValue(this._options.errorFilter,null),this.pathSegmentSeparator=this._getValue(this._options.pathSegmentSeparator,_et.sep),this.fsScandirSettings=new Het.Settings({followSymbolicLinks:this._options.followSymbolicLinks,fs:this._options.fs,pathSegmentSeparator:this._options.pathSegmentSeparator,stats:this._options.stats,throwErrorOnBrokenSymbolicLink:this._options.throwErrorOnBrokenSymbolicLink})}_getValue(e,r){return e??r}};dO.default=gO});var xP=_($u=>{"use strict";Object.defineProperty($u,"__esModule",{value:!0});$u.Settings=$u.walkStream=$u.walkSync=$u.walk=void 0;var Nie=kie(),jet=Qie(),Get=Rie(),mO=Tie();$u.Settings=mO.default;function qet(t,e,r){if(typeof e=="function"){new Nie.default(t,bP()).read(e);return}new Nie.default(t,bP(e)).read(r)}$u.walk=qet;function Yet(t,e){let r=bP(e);return new Get.default(t,r).read()}$u.walkSync=Yet;function Wet(t,e){let r=bP(e);return new jet.default(t,r).read()}$u.walkStream=Wet;function bP(t={}){return t instanceof mO.default?t:new mO.default(t)}});var kP=_(EO=>{"use strict";Object.defineProperty(EO,"__esModule",{value:!0});var Ket=ve("path"),Vet=Cd(),Lie=Sf(),yO=class{constructor(e){this._settings=e,this._fsStatSettings=new Vet.Settings({followSymbolicLink:this._settings.followSymbolicLinks,fs:this._settings.fs,throwErrorOnBrokenSymbolicLink:this._settings.followSymbolicLinks})}_getFullEntryPath(e){return Ket.resolve(this._settings.cwd,e)}_makeEntry(e,r){let o={name:r,path:r,dirent:Lie.fs.createDirentFromStats(r,e)};return this._settings.stats&&(o.stats=e),o}_isFatalError(e){return!Lie.errno.isEnoentCodeError(e)&&!this._settings.suppressErrors}};EO.default=yO});var IO=_(wO=>{"use strict";Object.defineProperty(wO,"__esModule",{value:!0});var Jet=ve("stream"),zet=Cd(),Xet=xP(),Zet=kP(),CO=class extends Zet.default{constructor(){super(...arguments),this._walkStream=Xet.walkStream,this._stat=zet.stat}dynamic(e,r){return this._walkStream(e,r)}static(e,r){let o=e.map(this._getFullEntryPath,this),a=new Jet.PassThrough({objectMode:!0});a._write=(n,u,A)=>this._getEntry(o[n],e[n],r).then(p=>{p!==null&&r.entryFilter(p)&&a.push(p),n===o.length-1&&a.end(),A()}).catch(A);for(let n=0;n<o.length;n++)a.write(n);return a}_getEntry(e,r,o){return this._getStat(e).then(a=>this._makeEntry(a,r)).catch(a=>{if(o.errorFilter(a))return null;throw a})}_getStat(e){return new Promise((r,o)=>{this._stat(e,this._fsStatSettings,(a,n)=>a===null?r(n):o(a))})}};wO.default=CO});var Oie=_(vO=>{"use strict";Object.defineProperty(vO,"__esModule",{value:!0});var $et=xP(),ett=kP(),ttt=IO(),BO=class extends ett.default{constructor(){super(...arguments),this._walkAsync=$et.walk,this._readerStream=new ttt.default(this._settings)}dynamic(e,r){return new Promise((o,a)=>{this._walkAsync(e,r,(n,u)=>{n===null?o(u):a(n)})})}async static(e,r){let o=[],a=this._readerStream.static(e,r);return new Promise((n,u)=>{a.once("error",u),a.on("data",A=>o.push(A)),a.once("end",()=>n(o))})}};vO.default=BO});var Mie=_(SO=>{"use strict";Object.defineProperty(SO,"__esModule",{value:!0});var nE=Sf(),DO=class{constructor(e,r,o){this._patterns=e,this._settings=r,this._micromatchOptions=o,this._storage=[],this._fillStorage()}_fillStorage(){let e=nE.pattern.expandPatternsWithBraceExpansion(this._patterns);for(let r of e){let o=this._getPatternSegments(r),a=this._splitSegmentsIntoSections(o);this._storage.push({complete:a.length<=1,pattern:r,segments:o,sections:a})}}_getPatternSegments(e){return nE.pattern.getPatternParts(e,this._micromatchOptions).map(o=>nE.pattern.isDynamicPattern(o,this._settings)?{dynamic:!0,pattern:o,patternRe:nE.pattern.makeRe(o,this._micromatchOptions)}:{dynamic:!1,pattern:o})}_splitSegmentsIntoSections(e){return nE.array.splitWhen(e,r=>r.dynamic&&nE.pattern.hasGlobStar(r.pattern))}};SO.default=DO});var Uie=_(bO=>{"use strict";Object.defineProperty(bO,"__esModule",{value:!0});var rtt=Mie(),PO=class extends rtt.default{match(e){let r=e.split("/"),o=r.length,a=this._storage.filter(n=>!n.complete||n.segments.length>o);for(let n of a){let u=n.sections[0];if(!n.complete&&o>u.length||r.every((p,h)=>{let E=n.segments[h];return!!(E.dynamic&&E.patternRe.test(p)||!E.dynamic&&E.pattern===p)}))return!0}return!1}};bO.default=PO});var _ie=_(kO=>{"use strict";Object.defineProperty(kO,"__esModule",{value:!0});var QP=Sf(),ntt=Uie(),xO=class{constructor(e,r){this._settings=e,this._micromatchOptions=r}getFilter(e,r,o){let a=this._getMatcher(r),n=this._getNegativePatternsRe(o);return u=>this._filter(e,u,a,n)}_getMatcher(e){return new ntt.default(e,this._settings,this._micromatchOptions)}_getNegativePatternsRe(e){let r=e.filter(QP.pattern.isAffectDepthOfReadingPattern);return QP.pattern.convertPatternsToRe(r,this._micromatchOptions)}_filter(e,r,o,a){if(this._isSkippedByDeep(e,r.path)||this._isSkippedSymbolicLink(r))return!1;let n=QP.path.removeLeadingDotSegment(r.path);return this._isSkippedByPositivePatterns(n,o)?!1:this._isSkippedByNegativePatterns(n,a)}_isSkippedByDeep(e,r){return this._settings.deep===1/0?!1:this._getEntryLevel(e,r)>=this._settings.deep}_getEntryLevel(e,r){let o=r.split("/").length;if(e==="")return o;let a=e.split("/").length;return o-a}_isSkippedSymbolicLink(e){return!this._settings.followSymbolicLinks&&e.dirent.isSymbolicLink()}_isSkippedByPositivePatterns(e,r){return!this._settings.baseNameMatch&&!r.match(e)}_isSkippedByNegativePatterns(e,r){return!QP.pattern.matchAny(e,r)}};kO.default=xO});var Hie=_(FO=>{"use strict";Object.defineProperty(FO,"__esModule",{value:!0});var wd=Sf(),QO=class{constructor(e,r){this._settings=e,this._micromatchOptions=r,this.index=new Map}getFilter(e,r){let o=wd.pattern.convertPatternsToRe(e,this._micromatchOptions),a=wd.pattern.convertPatternsToRe(r,this._micromatchOptions);return n=>this._filter(n,o,a)}_filter(e,r,o){if(this._settings.unique&&this._isDuplicateEntry(e)||this._onlyFileFilter(e)||this._onlyDirectoryFilter(e)||this._isSkippedByAbsoluteNegativePatterns(e.path,o))return!1;let a=this._settings.baseNameMatch?e.name:e.path,n=e.dirent.isDirectory(),u=this._isMatchToPatterns(a,r,n)&&!this._isMatchToPatterns(e.path,o,n);return this._settings.unique&&u&&this._createIndexRecord(e),u}_isDuplicateEntry(e){return this.index.has(e.path)}_createIndexRecord(e){this.index.set(e.path,void 0)}_onlyFileFilter(e){return this._settings.onlyFiles&&!e.dirent.isFile()}_onlyDirectoryFilter(e){return this._settings.onlyDirectories&&!e.dirent.isDirectory()}_isSkippedByAbsoluteNegativePatterns(e,r){if(!this._settings.absolute)return!1;let o=wd.path.makeAbsolute(this._settings.cwd,e);return wd.pattern.matchAny(o,r)}_isMatchToPatterns(e,r,o){let a=wd.path.removeLeadingDotSegment(e),n=wd.pattern.matchAny(a,r);return!n&&o?wd.pattern.matchAny(a+"/",r):n}};FO.default=QO});var jie=_(TO=>{"use strict";Object.defineProperty(TO,"__esModule",{value:!0});var itt=Sf(),RO=class{constructor(e){this._settings=e}getFilter(){return e=>this._isNonFatalError(e)}_isNonFatalError(e){return itt.errno.isEnoentCodeError(e)||this._settings.suppressErrors}};TO.default=RO});var qie=_(LO=>{"use strict";Object.defineProperty(LO,"__esModule",{value:!0});var Gie=Sf(),NO=class{constructor(e){this._settings=e}getTransformer(){return e=>this._transform(e)}_transform(e){let r=e.path;return this._settings.absolute&&(r=Gie.path.makeAbsolute(this._settings.cwd,r),r=Gie.path.unixify(r)),this._settings.markDirectories&&e.dirent.isDirectory()&&(r+="/"),this._settings.objectMode?Object.assign(Object.assign({},e),{path:r}):r}};LO.default=NO});var FP=_(MO=>{"use strict";Object.defineProperty(MO,"__esModule",{value:!0});var stt=ve("path"),ott=_ie(),att=Hie(),ltt=jie(),ctt=qie(),OO=class{constructor(e){this._settings=e,this.errorFilter=new ltt.default(this._settings),this.entryFilter=new att.default(this._settings,this._getMicromatchOptions()),this.deepFilter=new ott.default(this._settings,this._getMicromatchOptions()),this.entryTransformer=new ctt.default(this._settings)}_getRootDirectory(e){return stt.resolve(this._settings.cwd,e.base)}_getReaderOptions(e){let r=e.base==="."?"":e.base;return{basePath:r,pathSegmentSeparator:"/",concurrency:this._settings.concurrency,deepFilter:this.deepFilter.getFilter(r,e.positive,e.negative),entryFilter:this.entryFilter.getFilter(e.positive,e.negative),errorFilter:this.errorFilter.getFilter(),followSymbolicLinks:this._settings.followSymbolicLinks,fs:this._settings.fs,stats:this._settings.stats,throwErrorOnBrokenSymbolicLink:this._settings.throwErrorOnBrokenSymbolicLink,transform:this.entryTransformer.getTransformer()}}_getMicromatchOptions(){return{dot:this._settings.dot,matchBase:this._settings.baseNameMatch,nobrace:!this._settings.braceExpansion,nocase:!this._settings.caseSensitiveMatch,noext:!this._settings.extglob,noglobstar:!this._settings.globstar,posix:!0,strictSlashes:!1}}};MO.default=OO});var Yie=_(_O=>{"use strict";Object.defineProperty(_O,"__esModule",{value:!0});var utt=Oie(),Att=FP(),UO=class extends Att.default{constructor(){super(...arguments),this._reader=new utt.default(this._settings)}async read(e){let r=this._getRootDirectory(e),o=this._getReaderOptions(e);return(await this.api(r,e,o)).map(n=>o.transform(n))}api(e,r,o){return r.dynamic?this._reader.dynamic(e,o):this._reader.static(r.patterns,o)}};_O.default=UO});var Wie=_(jO=>{"use strict";Object.defineProperty(jO,"__esModule",{value:!0});var ftt=ve("stream"),ptt=IO(),htt=FP(),HO=class extends htt.default{constructor(){super(...arguments),this._reader=new ptt.default(this._settings)}read(e){let r=this._getRootDirectory(e),o=this._getReaderOptions(e),a=this.api(r,e,o),n=new ftt.Readable({objectMode:!0,read:()=>{}});return a.once("error",u=>n.emit("error",u)).on("data",u=>n.emit("data",o.transform(u))).once("end",()=>n.emit("end")),n.once("close",()=>a.destroy()),n}api(e,r,o){return r.dynamic?this._reader.dynamic(e,o):this._reader.static(r.patterns,o)}};jO.default=HO});var Kie=_(qO=>{"use strict";Object.defineProperty(qO,"__esModule",{value:!0});var gtt=Cd(),dtt=xP(),mtt=kP(),GO=class extends mtt.default{constructor(){super(...arguments),this._walkSync=dtt.walkSync,this._statSync=gtt.statSync}dynamic(e,r){return this._walkSync(e,r)}static(e,r){let o=[];for(let a of e){let n=this._getFullEntryPath(a),u=this._getEntry(n,a,r);u===null||!r.entryFilter(u)||o.push(u)}return o}_getEntry(e,r,o){try{let a=this._getStat(e);return this._makeEntry(a,r)}catch(a){if(o.errorFilter(a))return null;throw a}}_getStat(e){return this._statSync(e,this._fsStatSettings)}};qO.default=GO});var Vie=_(WO=>{"use strict";Object.defineProperty(WO,"__esModule",{value:!0});var ytt=Kie(),Ett=FP(),YO=class extends Ett.default{constructor(){super(...arguments),this._reader=new ytt.default(this._settings)}read(e){let r=this._getRootDirectory(e),o=this._getReaderOptions(e);return this.api(r,e,o).map(o.transform)}api(e,r,o){return r.dynamic?this._reader.dynamic(e,o):this._reader.static(r.patterns,o)}};WO.default=YO});var Jie=_(sE=>{"use strict";Object.defineProperty(sE,"__esModule",{value:!0});sE.DEFAULT_FILE_SYSTEM_ADAPTER=void 0;var iE=ve("fs"),Ctt=ve("os"),wtt=Math.max(Ctt.cpus().length,1);sE.DEFAULT_FILE_SYSTEM_ADAPTER={lstat:iE.lstat,lstatSync:iE.lstatSync,stat:iE.stat,statSync:iE.statSync,readdir:iE.readdir,readdirSync:iE.readdirSync};var KO=class{constructor(e={}){this._options=e,this.absolute=this._getValue(this._options.absolute,!1),this.baseNameMatch=this._getValue(this._options.baseNameMatch,!1),this.braceExpansion=this._getValue(this._options.braceExpansion,!0),this.caseSensitiveMatch=this._getValue(this._options.caseSensitiveMatch,!0),this.concurrency=this._getValue(this._options.concurrency,wtt),this.cwd=this._getValue(this._options.cwd,process.cwd()),this.deep=this._getValue(this._options.deep,1/0),this.dot=this._getValue(this._options.dot,!1),this.extglob=this._getValue(this._options.extglob,!0),this.followSymbolicLinks=this._getValue(this._options.followSymbolicLinks,!0),this.fs=this._getFileSystemMethods(this._options.fs),this.globstar=this._getValue(this._options.globstar,!0),this.ignore=this._getValue(this._options.ignore,[]),this.markDirectories=this._getValue(this._options.markDirectories,!1),this.objectMode=this._getValue(this._options.objectMode,!1),this.onlyDirectories=this._getValue(this._options.onlyDirectories,!1),this.onlyFiles=this._getValue(this._options.onlyFiles,!0),this.stats=this._getValue(this._options.stats,!1),this.suppressErrors=this._getValue(this._options.suppressErrors,!1),this.throwErrorOnBrokenSymbolicLink=this._getValue(this._options.throwErrorOnBrokenSymbolicLink,!1),this.unique=this._getValue(this._options.unique,!0),this.onlyDirectories&&(this.onlyFiles=!1),this.stats&&(this.objectMode=!0)}_getValue(e,r){return e===void 0?r:e}_getFileSystemMethods(e={}){return Object.assign(Object.assign({},sE.DEFAULT_FILE_SYSTEM_ADAPTER),e)}};sE.default=KO});var RP=_((vNt,Zie)=>{"use strict";var zie=Zne(),Xie=eie(),Itt=Yie(),Btt=Wie(),vtt=Vie(),VO=Jie(),Id=Sf();async function JO(t,e){oE(t);let r=zO(t,Itt.default,e),o=await Promise.all(r);return Id.array.flatten(o)}(function(t){function e(u,A){oE(u);let p=zO(u,vtt.default,A);return Id.array.flatten(p)}t.sync=e;function r(u,A){oE(u);let p=zO(u,Btt.default,A);return Id.stream.merge(p)}t.stream=r;function o(u,A){oE(u);let p=Xie.transform([].concat(u)),h=new VO.default(A);return zie.generate(p,h)}t.generateTasks=o;function a(u,A){oE(u);let p=new VO.default(A);return Id.pattern.isDynamicPattern(u,p)}t.isDynamicPattern=a;function n(u){return oE(u),Id.path.escape(u)}t.escapePath=n})(JO||(JO={}));function zO(t,e,r){let o=Xie.transform([].concat(t)),a=new VO.default(r),n=zie.generate(o,a),u=new e(a);return n.map(u.read,u)}function oE(t){if(![].concat(t).every(o=>Id.string.isString(o)&&!Id.string.isEmpty(o)))throw new TypeError("Patterns must be a string (non empty) or an array of strings")}Zie.exports=JO});var wn={};Vt(wn,{checksumFile:()=>NP,checksumPattern:()=>LP,makeHash:()=>zs});function zs(...t){let e=(0,TP.createHash)("sha512"),r="";for(let o of t)typeof o=="string"?r+=o:o&&(r&&(e.update(r),r=""),e.update(o));return r&&e.update(r),e.digest("hex")}async function NP(t,{baseFs:e,algorithm:r}={baseFs:oe,algorithm:"sha512"}){let o=await e.openPromise(t,"r");try{let n=Buffer.allocUnsafeSlow(65536),u=(0,TP.createHash)(r),A=0;for(;(A=await e.readPromise(o,n,0,65536))!==0;)u.update(A===65536?n:n.slice(0,A));return u.digest("hex")}finally{await e.closePromise(o)}}async function LP(t,{cwd:e}){let o=(await(0,XO.default)(t,{cwd:ue.fromPortablePath(e),onlyDirectories:!0})).map(A=>`${A}/**/*`),a=await(0,XO.default)([t,...o],{cwd:ue.fromPortablePath(e),onlyFiles:!1});a.sort();let n=await Promise.all(a.map(async A=>{let p=[Buffer.from(A)],h=ue.toPortablePath(A),E=await oe.lstatPromise(h);return E.isSymbolicLink()?p.push(Buffer.from(await oe.readlinkPromise(h))):E.isFile()&&p.push(await oe.readFilePromise(h)),p.join("\0")})),u=(0,TP.createHash)("sha512");for(let A of n)u.update(A);return u.digest("hex")}var TP,XO,nh=Et(()=>{St();TP=ve("crypto"),XO=$e(RP())});var W={};Vt(W,{areDescriptorsEqual:()=>nse,areIdentsEqual:()=>n1,areLocatorsEqual:()=>i1,areVirtualPackagesEquivalent:()=>Rtt,bindDescriptor:()=>Qtt,bindLocator:()=>Ftt,convertDescriptorToLocator:()=>OP,convertLocatorToDescriptor:()=>$O,convertPackageToLocator:()=>btt,convertToIdent:()=>Ptt,convertToManifestRange:()=>Gtt,copyPackage:()=>e1,devirtualizeDescriptor:()=>t1,devirtualizeLocator:()=>r1,ensureDevirtualizedDescriptor:()=>xtt,ensureDevirtualizedLocator:()=>ktt,getIdentVendorPath:()=>nM,isPackageCompatible:()=>jP,isVirtualDescriptor:()=>bf,isVirtualLocator:()=>Hc,makeDescriptor:()=>In,makeIdent:()=>eA,makeLocator:()=>Qs,makeRange:()=>_P,parseDescriptor:()=>ih,parseFileStyleRange:()=>Htt,parseIdent:()=>Js,parseLocator:()=>xf,parseRange:()=>Bd,prettyDependent:()=>kL,prettyDescriptor:()=>Gn,prettyIdent:()=>cs,prettyLocator:()=>jr,prettyLocatorNoColors:()=>xL,prettyRange:()=>cE,prettyReference:()=>o1,prettyResolution:()=>ZI,prettyWorkspace:()=>a1,renamePackage:()=>eM,slugifyIdent:()=>ZO,slugifyLocator:()=>lE,sortDescriptors:()=>uE,stringifyDescriptor:()=>Pa,stringifyIdent:()=>fn,stringifyLocator:()=>ba,tryParseDescriptor:()=>s1,tryParseIdent:()=>ise,tryParseLocator:()=>UP,tryParseRange:()=>_tt,virtualizeDescriptor:()=>tM,virtualizePackage:()=>rM});function eA(t,e){if(t?.startsWith("@"))throw new Error("Invalid scope: don't prefix it with '@'");return{identHash:zs(t,e),scope:t,name:e}}function In(t,e){return{identHash:t.identHash,scope:t.scope,name:t.name,descriptorHash:zs(t.identHash,e),range:e}}function Qs(t,e){return{identHash:t.identHash,scope:t.scope,name:t.name,locatorHash:zs(t.identHash,e),reference:e}}function Ptt(t){return{identHash:t.identHash,scope:t.scope,name:t.name}}function OP(t){return{identHash:t.identHash,scope:t.scope,name:t.name,locatorHash:t.descriptorHash,reference:t.range}}function $O(t){return{identHash:t.identHash,scope:t.scope,name:t.name,descriptorHash:t.locatorHash,range:t.reference}}function btt(t){return{identHash:t.identHash,scope:t.scope,name:t.name,locatorHash:t.locatorHash,reference:t.reference}}function eM(t,e){return{identHash:e.identHash,scope:e.scope,name:e.name,locatorHash:e.locatorHash,reference:e.reference,version:t.version,languageName:t.languageName,linkType:t.linkType,conditions:t.conditions,dependencies:new Map(t.dependencies),peerDependencies:new Map(t.peerDependencies),dependenciesMeta:new Map(t.dependenciesMeta),peerDependenciesMeta:new Map(t.peerDependenciesMeta),bin:new Map(t.bin)}}function e1(t){return eM(t,t)}function tM(t,e){if(e.includes("#"))throw new Error("Invalid entropy");return In(t,`virtual:${e}#${t.range}`)}function rM(t,e){if(e.includes("#"))throw new Error("Invalid entropy");return eM(t,Qs(t,`virtual:${e}#${t.reference}`))}function bf(t){return t.range.startsWith($I)}function Hc(t){return t.reference.startsWith($I)}function t1(t){if(!bf(t))throw new Error("Not a virtual descriptor");return In(t,t.range.replace(MP,""))}function r1(t){if(!Hc(t))throw new Error("Not a virtual descriptor");return Qs(t,t.reference.replace(MP,""))}function xtt(t){return bf(t)?In(t,t.range.replace(MP,"")):t}function ktt(t){return Hc(t)?Qs(t,t.reference.replace(MP,"")):t}function Qtt(t,e){return t.range.includes("::")?t:In(t,`${t.range}::${aE.default.stringify(e)}`)}function Ftt(t,e){return t.reference.includes("::")?t:Qs(t,`${t.reference}::${aE.default.stringify(e)}`)}function n1(t,e){return t.identHash===e.identHash}function nse(t,e){return t.descriptorHash===e.descriptorHash}function i1(t,e){return t.locatorHash===e.locatorHash}function Rtt(t,e){if(!Hc(t))throw new Error("Invalid package type");if(!Hc(e))throw new Error("Invalid package type");if(!n1(t,e)||t.dependencies.size!==e.dependencies.size)return!1;for(let r of t.dependencies.values()){let o=e.dependencies.get(r.identHash);if(!o||!nse(r,o))return!1}return!0}function Js(t){let e=ise(t);if(!e)throw new Error(`Invalid ident (${t})`);return e}function ise(t){let e=t.match(Ttt);if(!e)return null;let[,r,o]=e;return eA(typeof r<"u"?r:null,o)}function ih(t,e=!1){let r=s1(t,e);if(!r)throw new Error(`Invalid descriptor (${t})`);return r}function s1(t,e=!1){let r=e?t.match(Ntt):t.match(Ltt);if(!r)return null;let[,o,a,n]=r;if(n==="unknown")throw new Error(`Invalid range (${t})`);let u=typeof o<"u"?o:null,A=typeof n<"u"?n:"unknown";return In(eA(u,a),A)}function xf(t,e=!1){let r=UP(t,e);if(!r)throw new Error(`Invalid locator (${t})`);return r}function UP(t,e=!1){let r=e?t.match(Ott):t.match(Mtt);if(!r)return null;let[,o,a,n]=r;if(n==="unknown")throw new Error(`Invalid reference (${t})`);let u=typeof o<"u"?o:null,A=typeof n<"u"?n:"unknown";return Qs(eA(u,a),A)}function Bd(t,e){let r=t.match(Utt);if(r===null)throw new Error(`Invalid range (${t})`);let o=typeof r[1]<"u"?r[1]:null;if(typeof e?.requireProtocol=="string"&&o!==e.requireProtocol)throw new Error(`Invalid protocol (${o})`);if(e?.requireProtocol&&o===null)throw new Error(`Missing protocol (${o})`);let a=typeof r[3]<"u"?decodeURIComponent(r[2]):null;if(e?.requireSource&&a===null)throw new Error(`Missing source (${t})`);let n=typeof r[3]<"u"?decodeURIComponent(r[3]):decodeURIComponent(r[2]),u=e?.parseSelector?aE.default.parse(n):n,A=typeof r[4]<"u"?aE.default.parse(r[4]):null;return{protocol:o,source:a,selector:u,params:A}}function _tt(t,e){try{return Bd(t,e)}catch{return null}}function Htt(t,{protocol:e}){let{selector:r,params:o}=Bd(t,{requireProtocol:e,requireBindings:!0});if(typeof o.locator!="string")throw new Error(`Assertion failed: Invalid bindings for ${t}`);return{parentLocator:xf(o.locator,!0),path:r}}function $ie(t){return t=t.replaceAll("%","%25"),t=t.replaceAll(":","%3A"),t=t.replaceAll("#","%23"),t}function jtt(t){return t===null?!1:Object.entries(t).length>0}function _P({protocol:t,source:e,selector:r,params:o}){let a="";return t!==null&&(a+=`${t}`),e!==null&&(a+=`${$ie(e)}#`),a+=$ie(r),jtt(o)&&(a+=`::${aE.default.stringify(o)}`),a}function Gtt(t){let{params:e,protocol:r,source:o,selector:a}=Bd(t);for(let n in e)n.startsWith("__")&&delete e[n];return _P({protocol:r,source:o,params:e,selector:a})}function fn(t){return t.scope?`@${t.scope}/${t.name}`:`${t.name}`}function Pa(t){return t.scope?`@${t.scope}/${t.name}@${t.range}`:`${t.name}@${t.range}`}function ba(t){return t.scope?`@${t.scope}/${t.name}@${t.reference}`:`${t.name}@${t.reference}`}function ZO(t){return t.scope!==null?`@${t.scope}-${t.name}`:t.name}function lE(t){let{protocol:e,selector:r}=Bd(t.reference),o=e!==null?e.replace(qtt,""):"exotic",a=ese.default.valid(r),n=a!==null?`${o}-${a}`:`${o}`,u=10;return t.scope?`${ZO(t)}-${n}-${t.locatorHash.slice(0,u)}`:`${ZO(t)}-${n}-${t.locatorHash.slice(0,u)}`}function cs(t,e){return e.scope?`${Mt(t,`@${e.scope}/`,yt.SCOPE)}${Mt(t,e.name,yt.NAME)}`:`${Mt(t,e.name,yt.NAME)}`}function HP(t){if(t.startsWith($I)){let e=HP(t.substring(t.indexOf("#")+1)),r=t.substring($I.length,$I.length+Dtt);return`${e} [${r}]`}else return t.replace(Ytt,"?[...]")}function cE(t,e){return`${Mt(t,HP(e),yt.RANGE)}`}function Gn(t,e){return`${cs(t,e)}${Mt(t,"@",yt.RANGE)}${cE(t,e.range)}`}function o1(t,e){return`${Mt(t,HP(e),yt.REFERENCE)}`}function jr(t,e){return`${cs(t,e)}${Mt(t,"@",yt.REFERENCE)}${o1(t,e.reference)}`}function xL(t){return`${fn(t)}@${HP(t.reference)}`}function uE(t){return ks(t,[e=>fn(e),e=>e.range])}function a1(t,e){return cs(t,e.anchoredLocator)}function ZI(t,e,r){let o=bf(e)?t1(e):e;return r===null?`${Gn(t,o)} \u2192 ${bL(t).Cross}`:o.identHash===r.identHash?`${Gn(t,o)} \u2192 ${o1(t,r.reference)}`:`${Gn(t,o)} \u2192 ${jr(t,r)}`}function kL(t,e,r){return r===null?`${jr(t,e)}`:`${jr(t,e)} (via ${cE(t,r.range)})`}function nM(t){return`node_modules/${fn(t)}`}function jP(t,e){return t.conditions?Stt(t.conditions,r=>{let[,o,a]=r.match(rse),n=e[o];return n?n.includes(a):!0}):!0}var aE,ese,tse,$I,Dtt,rse,Stt,MP,Ttt,Ntt,Ltt,Ott,Mtt,Utt,qtt,Ytt,bo=Et(()=>{aE=$e(ve("querystring")),ese=$e(zn()),tse=$e(eX());Gl();nh();jl();bo();$I="virtual:",Dtt=5,rse=/(os|cpu|libc)=([a-z0-9_-]+)/,Stt=(0,tse.makeParser)(rse);MP=/^[^#]*#/;Ttt=/^(?:@([^/]+?)\/)?([^@/]+)$/;Ntt=/^(?:@([^/]+?)\/)?([^@/]+?)(?:@(.+))$/,Ltt=/^(?:@([^/]+?)\/)?([^@/]+?)(?:@(.+))?$/;Ott=/^(?:@([^/]+?)\/)?([^@/]+?)(?:@(.+))$/,Mtt=/^(?:@([^/]+?)\/)?([^@/]+?)(?:@(.+))?$/;Utt=/^([^#:]*:)?((?:(?!::)[^#])*)(?:#((?:(?!::).)*))?(?:::(.*))?$/;qtt=/:$/;Ytt=/\?.*/});var sse,ose=Et(()=>{bo();sse={hooks:{reduceDependency:(t,e,r,o,{resolver:a,resolveOptions:n})=>{for(let{pattern:u,reference:A}of e.topLevelWorkspace.manifest.resolutions){if(u.from&&(u.from.fullName!==fn(r)||e.configuration.normalizeLocator(Qs(Js(u.from.fullName),u.from.description??r.reference)).locatorHash!==r.locatorHash)||u.descriptor.fullName!==fn(t)||e.configuration.normalizeDependency(In(xf(u.descriptor.fullName),u.descriptor.description??t.range)).descriptorHash!==t.descriptorHash)continue;return a.bindDescriptor(e.configuration.normalizeDependency(In(t,A)),e.topLevelWorkspace.anchoredLocator,n)}return t},validateProject:async(t,e)=>{for(let r of t.workspaces){let o=a1(t.configuration,r);await t.configuration.triggerHook(a=>a.validateWorkspace,r,{reportWarning:(a,n)=>e.reportWarning(a,`${o}: ${n}`),reportError:(a,n)=>e.reportError(a,`${o}: ${n}`)})}},validateWorkspace:async(t,e)=>{let{manifest:r}=t;r.resolutions.length&&t.cwd!==t.project.cwd&&r.errors.push(new Error("Resolutions field will be ignored"));for(let o of r.errors)e.reportWarning(57,o.message)}}}});var l1,Xn,vd=Et(()=>{l1=class{supportsDescriptor(e,r){return!!(e.range.startsWith(l1.protocol)||r.project.tryWorkspaceByDescriptor(e)!==null)}supportsLocator(e,r){return!!e.reference.startsWith(l1.protocol)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,o){return e}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){return[o.project.getWorkspaceByDescriptor(e).anchoredLocator]}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){let o=r.project.getWorkspaceByCwd(e.reference.slice(l1.protocol.length));return{...e,version:o.manifest.version||"0.0.0",languageName:"unknown",linkType:"SOFT",conditions:null,dependencies:r.project.configuration.normalizeDependencyMap(new Map([...o.manifest.dependencies,...o.manifest.devDependencies])),peerDependencies:new Map([...o.manifest.peerDependencies]),dependenciesMeta:o.manifest.dependenciesMeta,peerDependenciesMeta:o.manifest.peerDependenciesMeta,bin:o.manifest.bin}}},Xn=l1;Xn.protocol="workspace:"});var kr={};Vt(kr,{SemVer:()=>Ase.SemVer,clean:()=>Ktt,getComparator:()=>cse,mergeComparators:()=>iM,satisfiesWithPrereleases:()=>kf,simplifyRanges:()=>sM,stringifyComparator:()=>use,validRange:()=>xa});function kf(t,e,r=!1){if(!t)return!1;let o=`${e}${r}`,a=ase.get(o);if(typeof a>"u")try{a=new sh.default.Range(e,{includePrerelease:!0,loose:r})}catch{return!1}finally{ase.set(o,a||null)}else if(a===null)return!1;let n;try{n=new sh.default.SemVer(t,a)}catch{return!1}return a.test(n)?!0:(n.prerelease&&(n.prerelease=[]),a.set.some(u=>{for(let A of u)A.semver.prerelease&&(A.semver.prerelease=[]);return u.every(A=>A.test(n))}))}function xa(t){if(t.indexOf(":")!==-1)return null;let e=lse.get(t);if(typeof e<"u")return e;try{e=new sh.default.Range(t)}catch{e=null}return lse.set(t,e),e}function Ktt(t){let e=Wtt.exec(t);return e?e[1]:null}function cse(t){if(t.semver===sh.default.Comparator.ANY)return{gt:null,lt:null};switch(t.operator){case"":return{gt:[">=",t.semver],lt:["<=",t.semver]};case">":case">=":return{gt:[t.operator,t.semver],lt:null};case"<":case"<=":return{gt:null,lt:[t.operator,t.semver]};default:throw new Error(`Assertion failed: Unexpected comparator operator (${t.operator})`)}}function iM(t){if(t.length===0)return null;let e=null,r=null;for(let o of t){if(o.gt){let a=e!==null?sh.default.compare(o.gt[1],e[1]):null;(a===null||a>0||a===0&&o.gt[0]===">")&&(e=o.gt)}if(o.lt){let a=r!==null?sh.default.compare(o.lt[1],r[1]):null;(a===null||a<0||a===0&&o.lt[0]==="<")&&(r=o.lt)}}if(e&&r){let o=sh.default.compare(e[1],r[1]);if(o===0&&(e[0]===">"||r[0]==="<")||o>0)return null}return{gt:e,lt:r}}function use(t){if(t.gt&&t.lt){if(t.gt[0]===">="&&t.lt[0]==="<="&&t.gt[1].version===t.lt[1].version)return t.gt[1].version;if(t.gt[0]===">="&&t.lt[0]==="<"){if(t.lt[1].version===`${t.gt[1].major+1}.0.0-0`)return`^${t.gt[1].version}`;if(t.lt[1].version===`${t.gt[1].major}.${t.gt[1].minor+1}.0-0`)return`~${t.gt[1].version}`}}let e=[];return t.gt&&e.push(t.gt[0]+t.gt[1].version),t.lt&&e.push(t.lt[0]+t.lt[1].version),e.length?e.join(" "):"*"}function sM(t){let e=t.map(o=>xa(o).set.map(a=>a.map(n=>cse(n)))),r=e.shift().map(o=>iM(o)).filter(o=>o!==null);for(let o of e){let a=[];for(let n of r)for(let u of o){let A=iM([n,...u]);A!==null&&a.push(A)}r=a}return r.length===0?null:r.map(o=>use(o)).join(" || ")}var sh,Ase,ase,lse,Wtt,Qf=Et(()=>{sh=$e(zn()),Ase=$e(zn()),ase=new Map;lse=new Map;Wtt=/^(?:[\sv=]*?)((0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\s*)$/});function fse(t){let e=t.match(/^[ \t]+/m);return e?e[0]:" "}function pse(t){return t.charCodeAt(0)===65279?t.slice(1):t}function $o(t){return t.replace(/\\/g,"/")}function GP(t,{yamlCompatibilityMode:e}){return e?CL(t):typeof t>"u"||typeof t=="boolean"?t:null}function hse(t,e){let r=e.search(/[^!]/);if(r===-1)return"invalid";let o=r%2===0?"":"!",a=e.slice(r);return`${o}${t}=${a}`}function oM(t,e){return e.length===1?hse(t,e[0]):`(${e.map(r=>hse(t,r)).join(" | ")})`}var gse,AE,Ot,fE=Et(()=>{St();Nl();gse=$e(zn());vd();jl();Qf();bo();AE=class{constructor(){this.indent=" ";this.name=null;this.version=null;this.os=null;this.cpu=null;this.libc=null;this.type=null;this.packageManager=null;this.private=!1;this.license=null;this.main=null;this.module=null;this.browser=null;this.languageName=null;this.bin=new Map;this.scripts=new Map;this.dependencies=new Map;this.devDependencies=new Map;this.peerDependencies=new Map;this.workspaceDefinitions=[];this.dependenciesMeta=new Map;this.peerDependenciesMeta=new Map;this.resolutions=[];this.files=null;this.publishConfig=null;this.installConfig=null;this.preferUnplugged=null;this.raw={};this.errors=[]}static async tryFind(e,{baseFs:r=new Tn}={}){let o=V.join(e,"package.json");try{return await AE.fromFile(o,{baseFs:r})}catch(a){if(a.code==="ENOENT")return null;throw a}}static async find(e,{baseFs:r}={}){let o=await AE.tryFind(e,{baseFs:r});if(o===null)throw new Error("Manifest not found");return o}static async fromFile(e,{baseFs:r=new Tn}={}){let o=new AE;return await o.loadFile(e,{baseFs:r}),o}static fromText(e){let r=new AE;return r.loadFromText(e),r}loadFromText(e){let r;try{r=JSON.parse(pse(e)||"{}")}catch(o){throw o.message+=` (when parsing ${e})`,o}this.load(r),this.indent=fse(e)}async loadFile(e,{baseFs:r=new Tn}){let o=await r.readFilePromise(e,"utf8"),a;try{a=JSON.parse(pse(o)||"{}")}catch(n){throw n.message+=` (when parsing ${e})`,n}this.load(a),this.indent=fse(o)}load(e,{yamlCompatibilityMode:r=!1}={}){if(typeof e!="object"||e===null)throw new Error(`Utterly invalid manifest data (${e})`);this.raw=e;let o=[];if(this.name=null,typeof e.name=="string")try{this.name=Js(e.name)}catch{o.push(new Error("Parsing failed for the 'name' field"))}if(typeof e.version=="string"?this.version=e.version:this.version=null,Array.isArray(e.os)){let n=[];this.os=n;for(let u of e.os)typeof u!="string"?o.push(new Error("Parsing failed for the 'os' field")):n.push(u)}else this.os=null;if(Array.isArray(e.cpu)){let n=[];this.cpu=n;for(let u of e.cpu)typeof u!="string"?o.push(new Error("Parsing failed for the 'cpu' field")):n.push(u)}else this.cpu=null;if(Array.isArray(e.libc)){let n=[];this.libc=n;for(let u of e.libc)typeof u!="string"?o.push(new Error("Parsing failed for the 'libc' field")):n.push(u)}else this.libc=null;if(typeof e.type=="string"?this.type=e.type:this.type=null,typeof e.packageManager=="string"?this.packageManager=e.packageManager:this.packageManager=null,typeof e.private=="boolean"?this.private=e.private:this.private=!1,typeof e.license=="string"?this.license=e.license:this.license=null,typeof e.languageName=="string"?this.languageName=e.languageName:this.languageName=null,typeof e.main=="string"?this.main=$o(e.main):this.main=null,typeof e.module=="string"?this.module=$o(e.module):this.module=null,e.browser!=null)if(typeof e.browser=="string")this.browser=$o(e.browser);else{this.browser=new Map;for(let[n,u]of Object.entries(e.browser))this.browser.set($o(n),typeof u=="string"?$o(u):u)}else this.browser=null;if(this.bin=new Map,typeof e.bin=="string")e.bin.trim()===""?o.push(new Error("Invalid bin field")):this.name!==null?this.bin.set(this.name.name,$o(e.bin)):o.push(new Error("String bin field, but no attached package name"));else if(typeof e.bin=="object"&&e.bin!==null)for(let[n,u]of Object.entries(e.bin)){if(typeof u!="string"||u.trim()===""){o.push(new Error(`Invalid bin definition for '${n}'`));continue}let A=Js(n);this.bin.set(A.name,$o(u))}if(this.scripts=new Map,typeof e.scripts=="object"&&e.scripts!==null)for(let[n,u]of Object.entries(e.scripts)){if(typeof u!="string"){o.push(new Error(`Invalid script definition for '${n}'`));continue}this.scripts.set(n,u)}if(this.dependencies=new Map,typeof e.dependencies=="object"&&e.dependencies!==null)for(let[n,u]of Object.entries(e.dependencies)){if(typeof u!="string"){o.push(new Error(`Invalid dependency range for '${n}'`));continue}let A;try{A=Js(n)}catch{o.push(new Error(`Parsing failed for the dependency name '${n}'`));continue}let p=In(A,u);this.dependencies.set(p.identHash,p)}if(this.devDependencies=new Map,typeof e.devDependencies=="object"&&e.devDependencies!==null)for(let[n,u]of Object.entries(e.devDependencies)){if(typeof u!="string"){o.push(new Error(`Invalid dependency range for '${n}'`));continue}let A;try{A=Js(n)}catch{o.push(new Error(`Parsing failed for the dependency name '${n}'`));continue}let p=In(A,u);this.devDependencies.set(p.identHash,p)}if(this.peerDependencies=new Map,typeof e.peerDependencies=="object"&&e.peerDependencies!==null)for(let[n,u]of Object.entries(e.peerDependencies)){let A;try{A=Js(n)}catch{o.push(new Error(`Parsing failed for the dependency name '${n}'`));continue}(typeof u!="string"||!u.startsWith(Xn.protocol)&&!xa(u))&&(o.push(new Error(`Invalid dependency range for '${n}'`)),u="*");let p=In(A,u);this.peerDependencies.set(p.identHash,p)}typeof e.workspaces=="object"&&e.workspaces!==null&&e.workspaces.nohoist&&o.push(new Error("'nohoist' is deprecated, please use 'installConfig.hoistingLimits' instead"));let a=Array.isArray(e.workspaces)?e.workspaces:typeof e.workspaces=="object"&&e.workspaces!==null&&Array.isArray(e.workspaces.packages)?e.workspaces.packages:[];this.workspaceDefinitions=[];for(let n of a){if(typeof n!="string"){o.push(new Error(`Invalid workspace definition for '${n}'`));continue}this.workspaceDefinitions.push({pattern:n})}if(this.dependenciesMeta=new Map,typeof e.dependenciesMeta=="object"&&e.dependenciesMeta!==null)for(let[n,u]of Object.entries(e.dependenciesMeta)){if(typeof u!="object"||u===null){o.push(new Error(`Invalid meta field for '${n}`));continue}let A=ih(n),p=this.ensureDependencyMeta(A),h=GP(u.built,{yamlCompatibilityMode:r});if(h===null){o.push(new Error(`Invalid built meta field for '${n}'`));continue}let E=GP(u.optional,{yamlCompatibilityMode:r});if(E===null){o.push(new Error(`Invalid optional meta field for '${n}'`));continue}let I=GP(u.unplugged,{yamlCompatibilityMode:r});if(I===null){o.push(new Error(`Invalid unplugged meta field for '${n}'`));continue}Object.assign(p,{built:h,optional:E,unplugged:I})}if(this.peerDependenciesMeta=new Map,typeof e.peerDependenciesMeta=="object"&&e.peerDependenciesMeta!==null)for(let[n,u]of Object.entries(e.peerDependenciesMeta)){if(typeof u!="object"||u===null){o.push(new Error(`Invalid meta field for '${n}'`));continue}let A=ih(n),p=this.ensurePeerDependencyMeta(A),h=GP(u.optional,{yamlCompatibilityMode:r});if(h===null){o.push(new Error(`Invalid optional meta field for '${n}'`));continue}Object.assign(p,{optional:h})}if(this.resolutions=[],typeof e.resolutions=="object"&&e.resolutions!==null)for(let[n,u]of Object.entries(e.resolutions)){if(typeof u!="string"){o.push(new Error(`Invalid resolution entry for '${n}'`));continue}try{this.resolutions.push({pattern:MD(n),reference:u})}catch(A){o.push(A);continue}}if(Array.isArray(e.files)){this.files=new Set;for(let n of e.files){if(typeof n!="string"){o.push(new Error(`Invalid files entry for '${n}'`));continue}this.files.add(n)}}else this.files=null;if(typeof e.publishConfig=="object"&&e.publishConfig!==null){if(this.publishConfig={},typeof e.publishConfig.access=="string"&&(this.publishConfig.access=e.publishConfig.access),typeof e.publishConfig.main=="string"&&(this.publishConfig.main=$o(e.publishConfig.main)),typeof e.publishConfig.module=="string"&&(this.publishConfig.module=$o(e.publishConfig.module)),e.publishConfig.browser!=null)if(typeof e.publishConfig.browser=="string")this.publishConfig.browser=$o(e.publishConfig.browser);else{this.publishConfig.browser=new Map;for(let[n,u]of Object.entries(e.publishConfig.browser))this.publishConfig.browser.set($o(n),typeof u=="string"?$o(u):u)}if(typeof e.publishConfig.registry=="string"&&(this.publishConfig.registry=e.publishConfig.registry),typeof e.publishConfig.bin=="string")this.name!==null?this.publishConfig.bin=new Map([[this.name.name,$o(e.publishConfig.bin)]]):o.push(new Error("String bin field, but no attached package name"));else if(typeof e.publishConfig.bin=="object"&&e.publishConfig.bin!==null){this.publishConfig.bin=new Map;for(let[n,u]of Object.entries(e.publishConfig.bin)){if(typeof u!="string"){o.push(new Error(`Invalid bin definition for '${n}'`));continue}this.publishConfig.bin.set(n,$o(u))}}if(Array.isArray(e.publishConfig.executableFiles)){this.publishConfig.executableFiles=new Set;for(let n of e.publishConfig.executableFiles){if(typeof n!="string"){o.push(new Error("Invalid executable file definition"));continue}this.publishConfig.executableFiles.add($o(n))}}}else this.publishConfig=null;if(typeof e.installConfig=="object"&&e.installConfig!==null){this.installConfig={};for(let n of Object.keys(e.installConfig))n==="hoistingLimits"?typeof e.installConfig.hoistingLimits=="string"?this.installConfig.hoistingLimits=e.installConfig.hoistingLimits:o.push(new Error("Invalid hoisting limits definition")):n=="selfReferences"?typeof e.installConfig.selfReferences=="boolean"?this.installConfig.selfReferences=e.installConfig.selfReferences:o.push(new Error("Invalid selfReferences definition, must be a boolean value")):o.push(new Error(`Unrecognized installConfig key: ${n}`))}else this.installConfig=null;if(typeof e.optionalDependencies=="object"&&e.optionalDependencies!==null)for(let[n,u]of Object.entries(e.optionalDependencies)){if(typeof u!="string"){o.push(new Error(`Invalid dependency range for '${n}'`));continue}let A;try{A=Js(n)}catch{o.push(new Error(`Parsing failed for the dependency name '${n}'`));continue}let p=In(A,u);this.dependencies.set(p.identHash,p);let h=In(A,"unknown"),E=this.ensureDependencyMeta(h);Object.assign(E,{optional:!0})}typeof e.preferUnplugged=="boolean"?this.preferUnplugged=e.preferUnplugged:this.preferUnplugged=null,this.errors=o}getForScope(e){switch(e){case"dependencies":return this.dependencies;case"devDependencies":return this.devDependencies;case"peerDependencies":return this.peerDependencies;default:throw new Error(`Unsupported value ("${e}")`)}}hasConsumerDependency(e){return!!(this.dependencies.has(e.identHash)||this.peerDependencies.has(e.identHash))}hasHardDependency(e){return!!(this.dependencies.has(e.identHash)||this.devDependencies.has(e.identHash))}hasSoftDependency(e){return!!this.peerDependencies.has(e.identHash)}hasDependency(e){return!!(this.hasHardDependency(e)||this.hasSoftDependency(e))}getConditions(){let e=[];return this.os&&this.os.length>0&&e.push(oM("os",this.os)),this.cpu&&this.cpu.length>0&&e.push(oM("cpu",this.cpu)),this.libc&&this.libc.length>0&&e.push(oM("libc",this.libc)),e.length>0?e.join(" & "):null}ensureDependencyMeta(e){if(e.range!=="unknown"&&!gse.default.valid(e.range))throw new Error(`Invalid meta field range for '${Pa(e)}'`);let r=fn(e),o=e.range!=="unknown"?e.range:null,a=this.dependenciesMeta.get(r);a||this.dependenciesMeta.set(r,a=new Map);let n=a.get(o);return n||a.set(o,n={}),n}ensurePeerDependencyMeta(e){if(e.range!=="unknown")throw new Error(`Invalid meta field range for '${Pa(e)}'`);let r=fn(e),o=this.peerDependenciesMeta.get(r);return o||this.peerDependenciesMeta.set(r,o={}),o}setRawField(e,r,{after:o=[]}={}){let a=new Set(o.filter(n=>Object.hasOwn(this.raw,n)));if(a.size===0||Object.hasOwn(this.raw,e))this.raw[e]=r;else{let n=this.raw,u=this.raw={},A=!1;for(let p of Object.keys(n))u[p]=n[p],A||(a.delete(p),a.size===0&&(u[e]=r,A=!0))}}exportTo(e,{compatibilityMode:r=!0}={}){if(Object.assign(e,this.raw),this.name!==null?e.name=fn(this.name):delete e.name,this.version!==null?e.version=this.version:delete e.version,this.os!==null?e.os=this.os:delete e.os,this.cpu!==null?e.cpu=this.cpu:delete e.cpu,this.type!==null?e.type=this.type:delete e.type,this.packageManager!==null?e.packageManager=this.packageManager:delete e.packageManager,this.private?e.private=!0:delete e.private,this.license!==null?e.license=this.license:delete e.license,this.languageName!==null?e.languageName=this.languageName:delete e.languageName,this.main!==null?e.main=this.main:delete e.main,this.module!==null?e.module=this.module:delete e.module,this.browser!==null){let n=this.browser;typeof n=="string"?e.browser=n:n instanceof Map&&(e.browser=Object.assign({},...Array.from(n.keys()).sort().map(u=>({[u]:n.get(u)}))))}else delete e.browser;this.bin.size===1&&this.name!==null&&this.bin.has(this.name.name)?e.bin=this.bin.get(this.name.name):this.bin.size>0?e.bin=Object.assign({},...Array.from(this.bin.keys()).sort().map(n=>({[n]:this.bin.get(n)}))):delete e.bin,this.workspaceDefinitions.length>0?this.raw.workspaces&&!Array.isArray(this.raw.workspaces)?e.workspaces={...this.raw.workspaces,packages:this.workspaceDefinitions.map(({pattern:n})=>n)}:e.workspaces=this.workspaceDefinitions.map(({pattern:n})=>n):this.raw.workspaces&&!Array.isArray(this.raw.workspaces)&&Object.keys(this.raw.workspaces).length>0?e.workspaces=this.raw.workspaces:delete e.workspaces;let o=[],a=[];for(let n of this.dependencies.values()){let u=this.dependenciesMeta.get(fn(n)),A=!1;if(r&&u){let p=u.get(null);p&&p.optional&&(A=!0)}A?a.push(n):o.push(n)}o.length>0?e.dependencies=Object.assign({},...uE(o).map(n=>({[fn(n)]:n.range}))):delete e.dependencies,a.length>0?e.optionalDependencies=Object.assign({},...uE(a).map(n=>({[fn(n)]:n.range}))):delete e.optionalDependencies,this.devDependencies.size>0?e.devDependencies=Object.assign({},...uE(this.devDependencies.values()).map(n=>({[fn(n)]:n.range}))):delete e.devDependencies,this.peerDependencies.size>0?e.peerDependencies=Object.assign({},...uE(this.peerDependencies.values()).map(n=>({[fn(n)]:n.range}))):delete e.peerDependencies,e.dependenciesMeta={};for(let[n,u]of ks(this.dependenciesMeta.entries(),([A,p])=>A))for(let[A,p]of ks(u.entries(),([h,E])=>h!==null?`0${h}`:"1")){let h=A!==null?Pa(In(Js(n),A)):n,E={...p};r&&A===null&&delete E.optional,Object.keys(E).length!==0&&(e.dependenciesMeta[h]=E)}if(Object.keys(e.dependenciesMeta).length===0&&delete e.dependenciesMeta,this.peerDependenciesMeta.size>0?e.peerDependenciesMeta=Object.assign({},...ks(this.peerDependenciesMeta.entries(),([n,u])=>n).map(([n,u])=>({[n]:u}))):delete e.peerDependenciesMeta,this.resolutions.length>0?e.resolutions=Object.assign({},...this.resolutions.map(({pattern:n,reference:u})=>({[UD(n)]:u}))):delete e.resolutions,this.files!==null?e.files=Array.from(this.files):delete e.files,this.preferUnplugged!==null?e.preferUnplugged=this.preferUnplugged:delete e.preferUnplugged,this.scripts!==null&&this.scripts.size>0){e.scripts??={};for(let n of Object.keys(e.scripts))this.scripts.has(n)||delete e.scripts[n];for(let[n,u]of this.scripts.entries())e.scripts[n]=u}else delete e.scripts;return e}},Ot=AE;Ot.fileName="package.json",Ot.allDependencies=["dependencies","devDependencies","peerDependencies"],Ot.hardDependencies=["dependencies","devDependencies"]});var mse=_((UNt,dse)=>{var Vtt=_l(),Jtt=function(){return Vtt.Date.now()};dse.exports=Jtt});var Ese=_((_Nt,yse)=>{var ztt=/\s/;function Xtt(t){for(var e=t.length;e--&&ztt.test(t.charAt(e)););return e}yse.exports=Xtt});var wse=_((HNt,Cse)=>{var Ztt=Ese(),$tt=/^\s+/;function ert(t){return t&&t.slice(0,Ztt(t)+1).replace($tt,"")}Cse.exports=ert});var pE=_((jNt,Ise)=>{var trt=hd(),rrt=Ju(),nrt="[object Symbol]";function irt(t){return typeof t=="symbol"||rrt(t)&&trt(t)==nrt}Ise.exports=irt});var Sse=_((GNt,Dse)=>{var srt=wse(),Bse=il(),ort=pE(),vse=0/0,art=/^[-+]0x[0-9a-f]+$/i,lrt=/^0b[01]+$/i,crt=/^0o[0-7]+$/i,urt=parseInt;function Art(t){if(typeof t=="number")return t;if(ort(t))return vse;if(Bse(t)){var e=typeof t.valueOf=="function"?t.valueOf():t;t=Bse(e)?e+"":e}if(typeof t!="string")return t===0?t:+t;t=srt(t);var r=lrt.test(t);return r||crt.test(t)?urt(t.slice(2),r?2:8):art.test(t)?vse:+t}Dse.exports=Art});var xse=_((qNt,bse)=>{var frt=il(),aM=mse(),Pse=Sse(),prt="Expected a function",hrt=Math.max,grt=Math.min;function drt(t,e,r){var o,a,n,u,A,p,h=0,E=!1,I=!1,v=!0;if(typeof t!="function")throw new TypeError(prt);e=Pse(e)||0,frt(r)&&(E=!!r.leading,I="maxWait"in r,n=I?hrt(Pse(r.maxWait)||0,e):n,v="trailing"in r?!!r.trailing:v);function x(ce){var me=o,he=a;return o=a=void 0,h=ce,u=t.apply(he,me),u}function C(ce){return h=ce,A=setTimeout(U,e),E?x(ce):u}function R(ce){var me=ce-p,he=ce-h,Be=e-me;return I?grt(Be,n-he):Be}function L(ce){var me=ce-p,he=ce-h;return p===void 0||me>=e||me<0||I&&he>=n}function U(){var ce=aM();if(L(ce))return J(ce);A=setTimeout(U,R(ce))}function J(ce){return A=void 0,v&&o?x(ce):(o=a=void 0,u)}function te(){A!==void 0&&clearTimeout(A),h=0,o=p=a=A=void 0}function ae(){return A===void 0?u:J(aM())}function fe(){var ce=aM(),me=L(ce);if(o=arguments,a=this,p=ce,me){if(A===void 0)return C(p);if(I)return clearTimeout(A),A=setTimeout(U,e),x(p)}return A===void 0&&(A=setTimeout(U,e)),u}return fe.cancel=te,fe.flush=ae,fe}bse.exports=drt});var lM=_((YNt,kse)=>{var mrt=xse(),yrt=il(),Ert="Expected a function";function Crt(t,e,r){var o=!0,a=!0;if(typeof t!="function")throw new TypeError(Ert);return yrt(r)&&(o="leading"in r?!!r.leading:o,a="trailing"in r?!!r.trailing:a),mrt(t,e,{leading:o,maxWait:e,trailing:a})}kse.exports=Crt});function Irt(t){return typeof t.reportCode<"u"}var Qse,Fse,Rse,wrt,zt,Xs,Yl=Et(()=>{Qse=$e(lM()),Fse=ve("stream"),Rse=ve("string_decoder"),wrt=15,zt=class extends Error{constructor(r,o,a){super(o);this.reportExtra=a;this.reportCode=r}};Xs=class{constructor(){this.cacheHits=new Set;this.cacheMisses=new Set;this.reportedInfos=new Set;this.reportedWarnings=new Set;this.reportedErrors=new Set}getRecommendedLength(){return 180}reportCacheHit(e){this.cacheHits.add(e.locatorHash)}reportCacheMiss(e,r){this.cacheMisses.add(e.locatorHash)}static progressViaCounter(e){let r=0,o,a=new Promise(p=>{o=p}),n=p=>{let h=o;a=new Promise(E=>{o=E}),r=p,h()},u=(p=0)=>{n(r+1)},A=async function*(){for(;r<e;)await a,yield{progress:r/e}}();return{[Symbol.asyncIterator](){return A},hasProgress:!0,hasTitle:!1,set:n,tick:u}}static progressViaTitle(){let e,r,o=new Promise(u=>{r=u}),a=(0,Qse.default)(u=>{let A=r;o=new Promise(p=>{r=p}),e=u,A()},1e3/wrt),n=async function*(){for(;;)await o,yield{title:e}}();return{[Symbol.asyncIterator](){return n},hasProgress:!1,hasTitle:!0,setTitle:a}}async startProgressPromise(e,r){let o=this.reportProgress(e);try{return await r(e)}finally{o.stop()}}startProgressSync(e,r){let o=this.reportProgress(e);try{return r(e)}finally{o.stop()}}reportInfoOnce(e,r,o){let a=o&&o.key?o.key:r;this.reportedInfos.has(a)||(this.reportedInfos.add(a),this.reportInfo(e,r),o?.reportExtra?.(this))}reportWarningOnce(e,r,o){let a=o&&o.key?o.key:r;this.reportedWarnings.has(a)||(this.reportedWarnings.add(a),this.reportWarning(e,r),o?.reportExtra?.(this))}reportErrorOnce(e,r,o){let a=o&&o.key?o.key:r;this.reportedErrors.has(a)||(this.reportedErrors.add(a),this.reportError(e,r),o?.reportExtra?.(this))}reportExceptionOnce(e){Irt(e)?this.reportErrorOnce(e.reportCode,e.message,{key:e,reportExtra:e.reportExtra}):this.reportErrorOnce(1,e.stack||e.message,{key:e})}createStreamReporter(e=null){let r=new Fse.PassThrough,o=new Rse.StringDecoder,a="";return r.on("data",n=>{let u=o.write(n),A;do if(A=u.indexOf(` +`),A!==-1){let p=a+u.substring(0,A);u=u.substring(A+1),a="",e!==null?this.reportInfo(null,`${e} ${p}`):this.reportInfo(null,p)}while(A!==-1);a+=u}),r.on("end",()=>{let n=o.end();n!==""&&(e!==null?this.reportInfo(null,`${e} ${n}`):this.reportInfo(null,n))}),r}}});var hE,cM=Et(()=>{Yl();bo();hE=class{constructor(e){this.fetchers=e}supports(e,r){return!!this.tryFetcher(e,r)}getLocalPath(e,r){return this.getFetcher(e,r).getLocalPath(e,r)}async fetch(e,r){return await this.getFetcher(e,r).fetch(e,r)}tryFetcher(e,r){let o=this.fetchers.find(a=>a.supports(e,r));return o||null}getFetcher(e,r){let o=this.fetchers.find(a=>a.supports(e,r));if(!o)throw new zt(11,`${jr(r.project.configuration,e)} isn't supported by any available fetcher`);return o}}});var Dd,uM=Et(()=>{bo();Dd=class{constructor(e){this.resolvers=e.filter(r=>r)}supportsDescriptor(e,r){return!!this.tryResolverByDescriptor(e,r)}supportsLocator(e,r){return!!this.tryResolverByLocator(e,r)}shouldPersistResolution(e,r){return this.getResolverByLocator(e,r).shouldPersistResolution(e,r)}bindDescriptor(e,r,o){return this.getResolverByDescriptor(e,o).bindDescriptor(e,r,o)}getResolutionDependencies(e,r){return this.getResolverByDescriptor(e,r).getResolutionDependencies(e,r)}async getCandidates(e,r,o){return await this.getResolverByDescriptor(e,o).getCandidates(e,r,o)}async getSatisfying(e,r,o,a){return this.getResolverByDescriptor(e,a).getSatisfying(e,r,o,a)}async resolve(e,r){return await this.getResolverByLocator(e,r).resolve(e,r)}tryResolverByDescriptor(e,r){let o=this.resolvers.find(a=>a.supportsDescriptor(e,r));return o||null}getResolverByDescriptor(e,r){let o=this.resolvers.find(a=>a.supportsDescriptor(e,r));if(!o)throw new Error(`${Gn(r.project.configuration,e)} isn't supported by any available resolver`);return o}tryResolverByLocator(e,r){let o=this.resolvers.find(a=>a.supportsLocator(e,r));return o||null}getResolverByLocator(e,r){let o=this.resolvers.find(a=>a.supportsLocator(e,r));if(!o)throw new Error(`${jr(r.project.configuration,e)} isn't supported by any available resolver`);return o}}});var gE,AM=Et(()=>{St();bo();gE=class{supports(e){return!!e.reference.startsWith("virtual:")}getLocalPath(e,r){let o=e.reference.indexOf("#");if(o===-1)throw new Error("Invalid virtual package reference");let a=e.reference.slice(o+1),n=Qs(e,a);return r.fetcher.getLocalPath(n,r)}async fetch(e,r){let o=e.reference.indexOf("#");if(o===-1)throw new Error("Invalid virtual package reference");let a=e.reference.slice(o+1),n=Qs(e,a),u=await r.fetcher.fetch(n,r);return await this.ensureVirtualLink(e,u,r)}getLocatorFilename(e){return lE(e)}async ensureVirtualLink(e,r,o){let a=r.packageFs.getRealPath(),n=o.project.configuration.get("virtualFolder"),u=this.getLocatorFilename(e),A=mi.makeVirtualPath(n,u,a),p=new Uu(A,{baseFs:r.packageFs,pathUtils:V});return{...r,packageFs:p}}}});var dE,c1,Tse=Et(()=>{dE=class{static isVirtualDescriptor(e){return!!e.range.startsWith(dE.protocol)}static isVirtualLocator(e){return!!e.reference.startsWith(dE.protocol)}supportsDescriptor(e,r){return dE.isVirtualDescriptor(e)}supportsLocator(e,r){return dE.isVirtualLocator(e)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,o){throw new Error('Assertion failed: calling "bindDescriptor" on a virtual descriptor is unsupported')}getResolutionDependencies(e,r){throw new Error('Assertion failed: calling "getResolutionDependencies" on a virtual descriptor is unsupported')}async getCandidates(e,r,o){throw new Error('Assertion failed: calling "getCandidates" on a virtual descriptor is unsupported')}async getSatisfying(e,r,o,a){throw new Error('Assertion failed: calling "getSatisfying" on a virtual descriptor is unsupported')}async resolve(e,r){throw new Error('Assertion failed: calling "resolve" on a virtual locator is unsupported')}},c1=dE;c1.protocol="virtual:"});var mE,fM=Et(()=>{St();vd();mE=class{supports(e){return!!e.reference.startsWith(Xn.protocol)}getLocalPath(e,r){return this.getWorkspace(e,r).cwd}async fetch(e,r){let o=this.getWorkspace(e,r).cwd;return{packageFs:new gn(o),prefixPath:Bt.dot,localPath:o}}getWorkspace(e,r){return r.project.getWorkspaceByCwd(e.reference.slice(Xn.protocol.length))}}});function u1(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}function Nse(t){return typeof t>"u"?3:u1(t)?0:Array.isArray(t)?1:2}function gM(t,e){return Object.hasOwn(t,e)}function vrt(t){return u1(t)&&gM(t,"onConflict")&&typeof t.onConflict=="string"}function Drt(t){if(typeof t>"u")return{onConflict:"default",value:t};if(!vrt(t))return{onConflict:"default",value:t};if(gM(t,"value"))return t;let{onConflict:e,...r}=t;return{onConflict:e,value:r}}function Lse(t,e){let r=u1(t)&&gM(t,e)?t[e]:void 0;return Drt(r)}function yE(t,e){return[t,e,Ose]}function dM(t){return Array.isArray(t)?t[2]===Ose:!1}function pM(t,e){if(u1(t)){let r={};for(let o of Object.keys(t))r[o]=pM(t[o],e);return yE(e,r)}return Array.isArray(t)?yE(e,t.map(r=>pM(r,e))):yE(e,t)}function hM(t,e,r,o,a){let n,u=[],A=a,p=0;for(let E=a-1;E>=o;--E){let[I,v]=t[E],{onConflict:x,value:C}=Lse(v,r),R=Nse(C);if(R!==3){if(n??=R,R!==n||x==="hardReset"){p=A;break}if(R===2)return yE(I,C);if(u.unshift([I,C]),x==="reset"){p=E;break}x==="extend"&&E===o&&(o=0),A=E}}if(typeof n>"u")return null;let h=u.map(([E])=>E).join(", ");switch(n){case 1:return yE(h,new Array().concat(...u.map(([E,I])=>I.map(v=>pM(v,E)))));case 0:{let E=Object.assign({},...u.map(([,R])=>R)),I=Object.keys(E),v={},x=t.map(([R,L])=>[R,Lse(L,r).value]),C=Brt(x,([R,L])=>{let U=Nse(L);return U!==0&&U!==3});if(C!==-1){let R=x.slice(C+1);for(let L of I)v[L]=hM(R,e,L,0,R.length)}else for(let R of I)v[R]=hM(x,e,R,p,x.length);return yE(h,v)}default:throw new Error("Assertion failed: Non-extendable value type")}}function Mse(t){return hM(t.map(([e,r])=>[e,{["."]:r}]),[],".",0,t.length)}function A1(t){return dM(t)?t[1]:t}function qP(t){let e=dM(t)?t[1]:t;if(Array.isArray(e))return e.map(r=>qP(r));if(u1(e)){let r={};for(let[o,a]of Object.entries(e))r[o]=qP(a);return r}return e}function mM(t){return dM(t)?t[0]:null}var Brt,Ose,Use=Et(()=>{Brt=(t,e,r)=>{let o=[...t];return o.reverse(),o.findIndex(e,r)};Ose=Symbol()});var YP={};Vt(YP,{getDefaultGlobalFolder:()=>EM,getHomeFolder:()=>EE,isFolderInside:()=>CM});function EM(){if(process.platform==="win32"){let t=ue.toPortablePath(process.env.LOCALAPPDATA||ue.join((0,yM.homedir)(),"AppData","Local"));return V.resolve(t,"Yarn/Berry")}if(process.env.XDG_DATA_HOME){let t=ue.toPortablePath(process.env.XDG_DATA_HOME);return V.resolve(t,"yarn/berry")}return V.resolve(EE(),".yarn/berry")}function EE(){return ue.toPortablePath((0,yM.homedir)()||"/usr/local/share")}function CM(t,e){let r=V.relative(e,t);return r&&!r.startsWith("..")&&!V.isAbsolute(r)}var yM,WP=Et(()=>{St();yM=ve("os")});var Gse=_(CE=>{"use strict";var iLt=ve("net"),Prt=ve("tls"),wM=ve("http"),_se=ve("https"),brt=ve("events"),sLt=ve("assert"),xrt=ve("util");CE.httpOverHttp=krt;CE.httpsOverHttp=Qrt;CE.httpOverHttps=Frt;CE.httpsOverHttps=Rrt;function krt(t){var e=new Ff(t);return e.request=wM.request,e}function Qrt(t){var e=new Ff(t);return e.request=wM.request,e.createSocket=Hse,e.defaultPort=443,e}function Frt(t){var e=new Ff(t);return e.request=_se.request,e}function Rrt(t){var e=new Ff(t);return e.request=_se.request,e.createSocket=Hse,e.defaultPort=443,e}function Ff(t){var e=this;e.options=t||{},e.proxyOptions=e.options.proxy||{},e.maxSockets=e.options.maxSockets||wM.Agent.defaultMaxSockets,e.requests=[],e.sockets=[],e.on("free",function(o,a,n,u){for(var A=jse(a,n,u),p=0,h=e.requests.length;p<h;++p){var E=e.requests[p];if(E.host===A.host&&E.port===A.port){e.requests.splice(p,1),E.request.onSocket(o);return}}o.destroy(),e.removeSocket(o)})}xrt.inherits(Ff,brt.EventEmitter);Ff.prototype.addRequest=function(e,r,o,a){var n=this,u=IM({request:e},n.options,jse(r,o,a));if(n.sockets.length>=this.maxSockets){n.requests.push(u);return}n.createSocket(u,function(A){A.on("free",p),A.on("close",h),A.on("agentRemove",h),e.onSocket(A);function p(){n.emit("free",A,u)}function h(E){n.removeSocket(A),A.removeListener("free",p),A.removeListener("close",h),A.removeListener("agentRemove",h)}})};Ff.prototype.createSocket=function(e,r){var o=this,a={};o.sockets.push(a);var n=IM({},o.proxyOptions,{method:"CONNECT",path:e.host+":"+e.port,agent:!1,headers:{host:e.host+":"+e.port}});e.localAddress&&(n.localAddress=e.localAddress),n.proxyAuth&&(n.headers=n.headers||{},n.headers["Proxy-Authorization"]="Basic "+new Buffer(n.proxyAuth).toString("base64")),oh("making CONNECT request");var u=o.request(n);u.useChunkedEncodingByDefault=!1,u.once("response",A),u.once("upgrade",p),u.once("connect",h),u.once("error",E),u.end();function A(I){I.upgrade=!0}function p(I,v,x){process.nextTick(function(){h(I,v,x)})}function h(I,v,x){if(u.removeAllListeners(),v.removeAllListeners(),I.statusCode!==200){oh("tunneling socket could not be established, statusCode=%d",I.statusCode),v.destroy();var C=new Error("tunneling socket could not be established, statusCode="+I.statusCode);C.code="ECONNRESET",e.request.emit("error",C),o.removeSocket(a);return}if(x.length>0){oh("got illegal response body from proxy"),v.destroy();var C=new Error("got illegal response body from proxy");C.code="ECONNRESET",e.request.emit("error",C),o.removeSocket(a);return}return oh("tunneling connection has established"),o.sockets[o.sockets.indexOf(a)]=v,r(v)}function E(I){u.removeAllListeners(),oh(`tunneling socket could not be established, cause=%s +`,I.message,I.stack);var v=new Error("tunneling socket could not be established, cause="+I.message);v.code="ECONNRESET",e.request.emit("error",v),o.removeSocket(a)}};Ff.prototype.removeSocket=function(e){var r=this.sockets.indexOf(e);if(r!==-1){this.sockets.splice(r,1);var o=this.requests.shift();o&&this.createSocket(o,function(a){o.request.onSocket(a)})}};function Hse(t,e){var r=this;Ff.prototype.createSocket.call(r,t,function(o){var a=t.request.getHeader("host"),n=IM({},r.options,{socket:o,servername:a?a.replace(/:.*$/,""):t.host}),u=Prt.connect(0,n);r.sockets[r.sockets.indexOf(o)]=u,e(u)})}function jse(t,e,r){return typeof t=="string"?{host:t,port:e,localAddress:r}:t}function IM(t){for(var e=1,r=arguments.length;e<r;++e){var o=arguments[e];if(typeof o=="object")for(var a=Object.keys(o),n=0,u=a.length;n<u;++n){var A=a[n];o[A]!==void 0&&(t[A]=o[A])}}return t}var oh;process.env.NODE_DEBUG&&/\btunnel\b/.test(process.env.NODE_DEBUG)?oh=function(){var t=Array.prototype.slice.call(arguments);typeof t[0]=="string"?t[0]="TUNNEL: "+t[0]:t.unshift("TUNNEL:"),console.error.apply(console,t)}:oh=function(){};CE.debug=oh});var Yse=_((aLt,qse)=>{qse.exports=Gse()});var Tf=_((Rf,KP)=>{"use strict";Object.defineProperty(Rf,"__esModule",{value:!0});var Wse=["Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Uint16Array","Int32Array","Uint32Array","Float32Array","Float64Array","BigInt64Array","BigUint64Array"];function Trt(t){return Wse.includes(t)}var Nrt=["Function","Generator","AsyncGenerator","GeneratorFunction","AsyncGeneratorFunction","AsyncFunction","Observable","Array","Buffer","Object","RegExp","Date","Error","Map","Set","WeakMap","WeakSet","ArrayBuffer","SharedArrayBuffer","DataView","Promise","URL","FormData","URLSearchParams","HTMLElement",...Wse];function Lrt(t){return Nrt.includes(t)}var Ort=["null","undefined","string","number","bigint","boolean","symbol"];function Mrt(t){return Ort.includes(t)}function wE(t){return e=>typeof e===t}var{toString:Kse}=Object.prototype,f1=t=>{let e=Kse.call(t).slice(8,-1);if(/HTML\w+Element/.test(e)&&be.domElement(t))return"HTMLElement";if(Lrt(e))return e},ei=t=>e=>f1(e)===t;function be(t){if(t===null)return"null";switch(typeof t){case"undefined":return"undefined";case"string":return"string";case"number":return"number";case"boolean":return"boolean";case"function":return"Function";case"bigint":return"bigint";case"symbol":return"symbol";default:}if(be.observable(t))return"Observable";if(be.array(t))return"Array";if(be.buffer(t))return"Buffer";let e=f1(t);if(e)return e;if(t instanceof String||t instanceof Boolean||t instanceof Number)throw new TypeError("Please don't use object wrappers for primitive types");return"Object"}be.undefined=wE("undefined");be.string=wE("string");var Urt=wE("number");be.number=t=>Urt(t)&&!be.nan(t);be.bigint=wE("bigint");be.function_=wE("function");be.null_=t=>t===null;be.class_=t=>be.function_(t)&&t.toString().startsWith("class ");be.boolean=t=>t===!0||t===!1;be.symbol=wE("symbol");be.numericString=t=>be.string(t)&&!be.emptyStringOrWhitespace(t)&&!Number.isNaN(Number(t));be.array=(t,e)=>Array.isArray(t)?be.function_(e)?t.every(e):!0:!1;be.buffer=t=>{var e,r,o,a;return(a=(o=(r=(e=t)===null||e===void 0?void 0:e.constructor)===null||r===void 0?void 0:r.isBuffer)===null||o===void 0?void 0:o.call(r,t))!==null&&a!==void 0?a:!1};be.nullOrUndefined=t=>be.null_(t)||be.undefined(t);be.object=t=>!be.null_(t)&&(typeof t=="object"||be.function_(t));be.iterable=t=>{var e;return be.function_((e=t)===null||e===void 0?void 0:e[Symbol.iterator])};be.asyncIterable=t=>{var e;return be.function_((e=t)===null||e===void 0?void 0:e[Symbol.asyncIterator])};be.generator=t=>be.iterable(t)&&be.function_(t.next)&&be.function_(t.throw);be.asyncGenerator=t=>be.asyncIterable(t)&&be.function_(t.next)&&be.function_(t.throw);be.nativePromise=t=>ei("Promise")(t);var _rt=t=>{var e,r;return be.function_((e=t)===null||e===void 0?void 0:e.then)&&be.function_((r=t)===null||r===void 0?void 0:r.catch)};be.promise=t=>be.nativePromise(t)||_rt(t);be.generatorFunction=ei("GeneratorFunction");be.asyncGeneratorFunction=t=>f1(t)==="AsyncGeneratorFunction";be.asyncFunction=t=>f1(t)==="AsyncFunction";be.boundFunction=t=>be.function_(t)&&!t.hasOwnProperty("prototype");be.regExp=ei("RegExp");be.date=ei("Date");be.error=ei("Error");be.map=t=>ei("Map")(t);be.set=t=>ei("Set")(t);be.weakMap=t=>ei("WeakMap")(t);be.weakSet=t=>ei("WeakSet")(t);be.int8Array=ei("Int8Array");be.uint8Array=ei("Uint8Array");be.uint8ClampedArray=ei("Uint8ClampedArray");be.int16Array=ei("Int16Array");be.uint16Array=ei("Uint16Array");be.int32Array=ei("Int32Array");be.uint32Array=ei("Uint32Array");be.float32Array=ei("Float32Array");be.float64Array=ei("Float64Array");be.bigInt64Array=ei("BigInt64Array");be.bigUint64Array=ei("BigUint64Array");be.arrayBuffer=ei("ArrayBuffer");be.sharedArrayBuffer=ei("SharedArrayBuffer");be.dataView=ei("DataView");be.directInstanceOf=(t,e)=>Object.getPrototypeOf(t)===e.prototype;be.urlInstance=t=>ei("URL")(t);be.urlString=t=>{if(!be.string(t))return!1;try{return new URL(t),!0}catch{return!1}};be.truthy=t=>Boolean(t);be.falsy=t=>!t;be.nan=t=>Number.isNaN(t);be.primitive=t=>be.null_(t)||Mrt(typeof t);be.integer=t=>Number.isInteger(t);be.safeInteger=t=>Number.isSafeInteger(t);be.plainObject=t=>{if(Kse.call(t)!=="[object Object]")return!1;let e=Object.getPrototypeOf(t);return e===null||e===Object.getPrototypeOf({})};be.typedArray=t=>Trt(f1(t));var Hrt=t=>be.safeInteger(t)&&t>=0;be.arrayLike=t=>!be.nullOrUndefined(t)&&!be.function_(t)&&Hrt(t.length);be.inRange=(t,e)=>{if(be.number(e))return t>=Math.min(0,e)&&t<=Math.max(e,0);if(be.array(e)&&e.length===2)return t>=Math.min(...e)&&t<=Math.max(...e);throw new TypeError(`Invalid range: ${JSON.stringify(e)}`)};var jrt=1,Grt=["innerHTML","ownerDocument","style","attributes","nodeValue"];be.domElement=t=>be.object(t)&&t.nodeType===jrt&&be.string(t.nodeName)&&!be.plainObject(t)&&Grt.every(e=>e in t);be.observable=t=>{var e,r,o,a;return t?t===((r=(e=t)[Symbol.observable])===null||r===void 0?void 0:r.call(e))||t===((a=(o=t)["@@observable"])===null||a===void 0?void 0:a.call(o)):!1};be.nodeStream=t=>be.object(t)&&be.function_(t.pipe)&&!be.observable(t);be.infinite=t=>t===1/0||t===-1/0;var Vse=t=>e=>be.integer(e)&&Math.abs(e%2)===t;be.evenInteger=Vse(0);be.oddInteger=Vse(1);be.emptyArray=t=>be.array(t)&&t.length===0;be.nonEmptyArray=t=>be.array(t)&&t.length>0;be.emptyString=t=>be.string(t)&&t.length===0;be.nonEmptyString=t=>be.string(t)&&t.length>0;var qrt=t=>be.string(t)&&!/\S/.test(t);be.emptyStringOrWhitespace=t=>be.emptyString(t)||qrt(t);be.emptyObject=t=>be.object(t)&&!be.map(t)&&!be.set(t)&&Object.keys(t).length===0;be.nonEmptyObject=t=>be.object(t)&&!be.map(t)&&!be.set(t)&&Object.keys(t).length>0;be.emptySet=t=>be.set(t)&&t.size===0;be.nonEmptySet=t=>be.set(t)&&t.size>0;be.emptyMap=t=>be.map(t)&&t.size===0;be.nonEmptyMap=t=>be.map(t)&&t.size>0;be.propertyKey=t=>be.any([be.string,be.number,be.symbol],t);be.formData=t=>ei("FormData")(t);be.urlSearchParams=t=>ei("URLSearchParams")(t);var Jse=(t,e,r)=>{if(!be.function_(e))throw new TypeError(`Invalid predicate: ${JSON.stringify(e)}`);if(r.length===0)throw new TypeError("Invalid number of values");return t.call(r,e)};be.any=(t,...e)=>(be.array(t)?t:[t]).some(o=>Jse(Array.prototype.some,o,e));be.all=(t,...e)=>Jse(Array.prototype.every,t,e);var Ht=(t,e,r,o={})=>{if(!t){let{multipleValues:a}=o,n=a?`received values of types ${[...new Set(r.map(u=>`\`${be(u)}\``))].join(", ")}`:`received value of type \`${be(r)}\``;throw new TypeError(`Expected value which is \`${e}\`, ${n}.`)}};Rf.assert={undefined:t=>Ht(be.undefined(t),"undefined",t),string:t=>Ht(be.string(t),"string",t),number:t=>Ht(be.number(t),"number",t),bigint:t=>Ht(be.bigint(t),"bigint",t),function_:t=>Ht(be.function_(t),"Function",t),null_:t=>Ht(be.null_(t),"null",t),class_:t=>Ht(be.class_(t),"Class",t),boolean:t=>Ht(be.boolean(t),"boolean",t),symbol:t=>Ht(be.symbol(t),"symbol",t),numericString:t=>Ht(be.numericString(t),"string with a number",t),array:(t,e)=>{Ht(be.array(t),"Array",t),e&&t.forEach(e)},buffer:t=>Ht(be.buffer(t),"Buffer",t),nullOrUndefined:t=>Ht(be.nullOrUndefined(t),"null or undefined",t),object:t=>Ht(be.object(t),"Object",t),iterable:t=>Ht(be.iterable(t),"Iterable",t),asyncIterable:t=>Ht(be.asyncIterable(t),"AsyncIterable",t),generator:t=>Ht(be.generator(t),"Generator",t),asyncGenerator:t=>Ht(be.asyncGenerator(t),"AsyncGenerator",t),nativePromise:t=>Ht(be.nativePromise(t),"native Promise",t),promise:t=>Ht(be.promise(t),"Promise",t),generatorFunction:t=>Ht(be.generatorFunction(t),"GeneratorFunction",t),asyncGeneratorFunction:t=>Ht(be.asyncGeneratorFunction(t),"AsyncGeneratorFunction",t),asyncFunction:t=>Ht(be.asyncFunction(t),"AsyncFunction",t),boundFunction:t=>Ht(be.boundFunction(t),"Function",t),regExp:t=>Ht(be.regExp(t),"RegExp",t),date:t=>Ht(be.date(t),"Date",t),error:t=>Ht(be.error(t),"Error",t),map:t=>Ht(be.map(t),"Map",t),set:t=>Ht(be.set(t),"Set",t),weakMap:t=>Ht(be.weakMap(t),"WeakMap",t),weakSet:t=>Ht(be.weakSet(t),"WeakSet",t),int8Array:t=>Ht(be.int8Array(t),"Int8Array",t),uint8Array:t=>Ht(be.uint8Array(t),"Uint8Array",t),uint8ClampedArray:t=>Ht(be.uint8ClampedArray(t),"Uint8ClampedArray",t),int16Array:t=>Ht(be.int16Array(t),"Int16Array",t),uint16Array:t=>Ht(be.uint16Array(t),"Uint16Array",t),int32Array:t=>Ht(be.int32Array(t),"Int32Array",t),uint32Array:t=>Ht(be.uint32Array(t),"Uint32Array",t),float32Array:t=>Ht(be.float32Array(t),"Float32Array",t),float64Array:t=>Ht(be.float64Array(t),"Float64Array",t),bigInt64Array:t=>Ht(be.bigInt64Array(t),"BigInt64Array",t),bigUint64Array:t=>Ht(be.bigUint64Array(t),"BigUint64Array",t),arrayBuffer:t=>Ht(be.arrayBuffer(t),"ArrayBuffer",t),sharedArrayBuffer:t=>Ht(be.sharedArrayBuffer(t),"SharedArrayBuffer",t),dataView:t=>Ht(be.dataView(t),"DataView",t),urlInstance:t=>Ht(be.urlInstance(t),"URL",t),urlString:t=>Ht(be.urlString(t),"string with a URL",t),truthy:t=>Ht(be.truthy(t),"truthy",t),falsy:t=>Ht(be.falsy(t),"falsy",t),nan:t=>Ht(be.nan(t),"NaN",t),primitive:t=>Ht(be.primitive(t),"primitive",t),integer:t=>Ht(be.integer(t),"integer",t),safeInteger:t=>Ht(be.safeInteger(t),"integer",t),plainObject:t=>Ht(be.plainObject(t),"plain object",t),typedArray:t=>Ht(be.typedArray(t),"TypedArray",t),arrayLike:t=>Ht(be.arrayLike(t),"array-like",t),domElement:t=>Ht(be.domElement(t),"HTMLElement",t),observable:t=>Ht(be.observable(t),"Observable",t),nodeStream:t=>Ht(be.nodeStream(t),"Node.js Stream",t),infinite:t=>Ht(be.infinite(t),"infinite number",t),emptyArray:t=>Ht(be.emptyArray(t),"empty array",t),nonEmptyArray:t=>Ht(be.nonEmptyArray(t),"non-empty array",t),emptyString:t=>Ht(be.emptyString(t),"empty string",t),nonEmptyString:t=>Ht(be.nonEmptyString(t),"non-empty string",t),emptyStringOrWhitespace:t=>Ht(be.emptyStringOrWhitespace(t),"empty string or whitespace",t),emptyObject:t=>Ht(be.emptyObject(t),"empty object",t),nonEmptyObject:t=>Ht(be.nonEmptyObject(t),"non-empty object",t),emptySet:t=>Ht(be.emptySet(t),"empty set",t),nonEmptySet:t=>Ht(be.nonEmptySet(t),"non-empty set",t),emptyMap:t=>Ht(be.emptyMap(t),"empty map",t),nonEmptyMap:t=>Ht(be.nonEmptyMap(t),"non-empty map",t),propertyKey:t=>Ht(be.propertyKey(t),"PropertyKey",t),formData:t=>Ht(be.formData(t),"FormData",t),urlSearchParams:t=>Ht(be.urlSearchParams(t),"URLSearchParams",t),evenInteger:t=>Ht(be.evenInteger(t),"even integer",t),oddInteger:t=>Ht(be.oddInteger(t),"odd integer",t),directInstanceOf:(t,e)=>Ht(be.directInstanceOf(t,e),"T",t),inRange:(t,e)=>Ht(be.inRange(t,e),"in range",t),any:(t,...e)=>Ht(be.any(t,...e),"predicate returns truthy for any value",e,{multipleValues:!0}),all:(t,...e)=>Ht(be.all(t,...e),"predicate returns truthy for all values",e,{multipleValues:!0})};Object.defineProperties(be,{class:{value:be.class_},function:{value:be.function_},null:{value:be.null_}});Object.defineProperties(Rf.assert,{class:{value:Rf.assert.class_},function:{value:Rf.assert.function_},null:{value:Rf.assert.null_}});Rf.default=be;KP.exports=be;KP.exports.default=be;KP.exports.assert=Rf.assert});var zse=_((lLt,BM)=>{"use strict";var VP=class extends Error{constructor(e){super(e||"Promise was canceled"),this.name="CancelError"}get isCanceled(){return!0}},IE=class{static fn(e){return(...r)=>new IE((o,a,n)=>{r.push(n),e(...r).then(o,a)})}constructor(e){this._cancelHandlers=[],this._isPending=!0,this._isCanceled=!1,this._rejectOnCancel=!0,this._promise=new Promise((r,o)=>{this._reject=o;let a=A=>{this._isPending=!1,r(A)},n=A=>{this._isPending=!1,o(A)},u=A=>{if(!this._isPending)throw new Error("The `onCancel` handler was attached after the promise settled.");this._cancelHandlers.push(A)};return Object.defineProperties(u,{shouldReject:{get:()=>this._rejectOnCancel,set:A=>{this._rejectOnCancel=A}}}),e(a,n,u)})}then(e,r){return this._promise.then(e,r)}catch(e){return this._promise.catch(e)}finally(e){return this._promise.finally(e)}cancel(e){if(!(!this._isPending||this._isCanceled)){if(this._cancelHandlers.length>0)try{for(let r of this._cancelHandlers)r()}catch(r){this._reject(r)}this._isCanceled=!0,this._rejectOnCancel&&this._reject(new VP(e))}}get isCanceled(){return this._isCanceled}};Object.setPrototypeOf(IE.prototype,Promise.prototype);BM.exports=IE;BM.exports.CancelError=VP});var Xse=_((DM,SM)=>{"use strict";Object.defineProperty(DM,"__esModule",{value:!0});var Yrt=ve("tls"),vM=(t,e)=>{let r;typeof e=="function"?r={connect:e}:r=e;let o=typeof r.connect=="function",a=typeof r.secureConnect=="function",n=typeof r.close=="function",u=()=>{o&&r.connect(),t instanceof Yrt.TLSSocket&&a&&(t.authorized?r.secureConnect():t.authorizationError||t.once("secureConnect",r.secureConnect)),n&&t.once("close",r.close)};t.writable&&!t.connecting?u():t.connecting?t.once("connect",u):t.destroyed&&n&&r.close(t._hadError)};DM.default=vM;SM.exports=vM;SM.exports.default=vM});var Zse=_((bM,xM)=>{"use strict";Object.defineProperty(bM,"__esModule",{value:!0});var Wrt=Xse(),Krt=Number(process.versions.node.split(".")[0]),PM=t=>{let e={start:Date.now(),socket:void 0,lookup:void 0,connect:void 0,secureConnect:void 0,upload:void 0,response:void 0,end:void 0,error:void 0,abort:void 0,phases:{wait:void 0,dns:void 0,tcp:void 0,tls:void 0,request:void 0,firstByte:void 0,download:void 0,total:void 0}};t.timings=e;let r=u=>{let A=u.emit.bind(u);u.emit=(p,...h)=>(p==="error"&&(e.error=Date.now(),e.phases.total=e.error-e.start,u.emit=A),A(p,...h))};r(t),t.prependOnceListener("abort",()=>{e.abort=Date.now(),(!e.response||Krt>=13)&&(e.phases.total=Date.now()-e.start)});let o=u=>{e.socket=Date.now(),e.phases.wait=e.socket-e.start;let A=()=>{e.lookup=Date.now(),e.phases.dns=e.lookup-e.socket};u.prependOnceListener("lookup",A),Wrt.default(u,{connect:()=>{e.connect=Date.now(),e.lookup===void 0&&(u.removeListener("lookup",A),e.lookup=e.connect,e.phases.dns=e.lookup-e.socket),e.phases.tcp=e.connect-e.lookup},secureConnect:()=>{e.secureConnect=Date.now(),e.phases.tls=e.secureConnect-e.connect}})};t.socket?o(t.socket):t.prependOnceListener("socket",o);let a=()=>{var u;e.upload=Date.now(),e.phases.request=e.upload-(u=e.secureConnect,u??e.connect)};return(()=>typeof t.writableFinished=="boolean"?t.writableFinished:t.finished&&t.outputSize===0&&(!t.socket||t.socket.writableLength===0))()?a():t.prependOnceListener("finish",a),t.prependOnceListener("response",u=>{e.response=Date.now(),e.phases.firstByte=e.response-e.upload,u.timings=e,r(u),u.prependOnceListener("end",()=>{e.end=Date.now(),e.phases.download=e.end-e.response,e.phases.total=e.end-e.start})}),e};bM.default=PM;xM.exports=PM;xM.exports.default=PM});var soe=_((cLt,FM)=>{"use strict";var{V4MAPPED:Vrt,ADDRCONFIG:Jrt,ALL:ioe,promises:{Resolver:$se},lookup:zrt}=ve("dns"),{promisify:kM}=ve("util"),Xrt=ve("os"),BE=Symbol("cacheableLookupCreateConnection"),QM=Symbol("cacheableLookupInstance"),eoe=Symbol("expires"),Zrt=typeof ioe=="number",toe=t=>{if(!(t&&typeof t.createConnection=="function"))throw new Error("Expected an Agent instance as the first argument")},$rt=t=>{for(let e of t)e.family!==6&&(e.address=`::ffff:${e.address}`,e.family=6)},roe=()=>{let t=!1,e=!1;for(let r of Object.values(Xrt.networkInterfaces()))for(let o of r)if(!o.internal&&(o.family==="IPv6"?e=!0:t=!0,t&&e))return{has4:t,has6:e};return{has4:t,has6:e}},ent=t=>Symbol.iterator in t,noe={ttl:!0},tnt={all:!0},JP=class{constructor({cache:e=new Map,maxTtl:r=1/0,fallbackDuration:o=3600,errorTtl:a=.15,resolver:n=new $se,lookup:u=zrt}={}){if(this.maxTtl=r,this.errorTtl=a,this._cache=e,this._resolver=n,this._dnsLookup=kM(u),this._resolver instanceof $se?(this._resolve4=this._resolver.resolve4.bind(this._resolver),this._resolve6=this._resolver.resolve6.bind(this._resolver)):(this._resolve4=kM(this._resolver.resolve4.bind(this._resolver)),this._resolve6=kM(this._resolver.resolve6.bind(this._resolver))),this._iface=roe(),this._pending={},this._nextRemovalTime=!1,this._hostnamesToFallback=new Set,o<1)this._fallback=!1;else{this._fallback=!0;let A=setInterval(()=>{this._hostnamesToFallback.clear()},o*1e3);A.unref&&A.unref()}this.lookup=this.lookup.bind(this),this.lookupAsync=this.lookupAsync.bind(this)}set servers(e){this.clear(),this._resolver.setServers(e)}get servers(){return this._resolver.getServers()}lookup(e,r,o){if(typeof r=="function"?(o=r,r={}):typeof r=="number"&&(r={family:r}),!o)throw new Error("Callback must be a function.");this.lookupAsync(e,r).then(a=>{r.all?o(null,a):o(null,a.address,a.family,a.expires,a.ttl)},o)}async lookupAsync(e,r={}){typeof r=="number"&&(r={family:r});let o=await this.query(e);if(r.family===6){let a=o.filter(n=>n.family===6);r.hints&Vrt&&(Zrt&&r.hints&ioe||a.length===0)?$rt(o):o=a}else r.family===4&&(o=o.filter(a=>a.family===4));if(r.hints&Jrt){let{_iface:a}=this;o=o.filter(n=>n.family===6?a.has6:a.has4)}if(o.length===0){let a=new Error(`cacheableLookup ENOTFOUND ${e}`);throw a.code="ENOTFOUND",a.hostname=e,a}return r.all?o:o[0]}async query(e){let r=await this._cache.get(e);if(!r){let o=this._pending[e];if(o)r=await o;else{let a=this.queryAndCache(e);this._pending[e]=a,r=await a}}return r=r.map(o=>({...o})),r}async _resolve(e){let r=async h=>{try{return await h}catch(E){if(E.code==="ENODATA"||E.code==="ENOTFOUND")return[];throw E}},[o,a]=await Promise.all([this._resolve4(e,noe),this._resolve6(e,noe)].map(h=>r(h))),n=0,u=0,A=0,p=Date.now();for(let h of o)h.family=4,h.expires=p+h.ttl*1e3,n=Math.max(n,h.ttl);for(let h of a)h.family=6,h.expires=p+h.ttl*1e3,u=Math.max(u,h.ttl);return o.length>0?a.length>0?A=Math.min(n,u):A=n:A=u,{entries:[...o,...a],cacheTtl:A}}async _lookup(e){try{return{entries:await this._dnsLookup(e,{all:!0}),cacheTtl:0}}catch{return{entries:[],cacheTtl:0}}}async _set(e,r,o){if(this.maxTtl>0&&o>0){o=Math.min(o,this.maxTtl)*1e3,r[eoe]=Date.now()+o;try{await this._cache.set(e,r,o)}catch(a){this.lookupAsync=async()=>{let n=new Error("Cache Error. Please recreate the CacheableLookup instance.");throw n.cause=a,n}}ent(this._cache)&&this._tick(o)}}async queryAndCache(e){if(this._hostnamesToFallback.has(e))return this._dnsLookup(e,tnt);try{let r=await this._resolve(e);r.entries.length===0&&this._fallback&&(r=await this._lookup(e),r.entries.length!==0&&this._hostnamesToFallback.add(e));let o=r.entries.length===0?this.errorTtl:r.cacheTtl;return await this._set(e,r.entries,o),delete this._pending[e],r.entries}catch(r){throw delete this._pending[e],r}}_tick(e){let r=this._nextRemovalTime;(!r||e<r)&&(clearTimeout(this._removalTimeout),this._nextRemovalTime=e,this._removalTimeout=setTimeout(()=>{this._nextRemovalTime=!1;let o=1/0,a=Date.now();for(let[n,u]of this._cache){let A=u[eoe];a>=A?this._cache.delete(n):A<o&&(o=A)}o!==1/0&&this._tick(o-a)},e),this._removalTimeout.unref&&this._removalTimeout.unref())}install(e){if(toe(e),BE in e)throw new Error("CacheableLookup has been already installed");e[BE]=e.createConnection,e[QM]=this,e.createConnection=(r,o)=>("lookup"in r||(r.lookup=this.lookup),e[BE](r,o))}uninstall(e){if(toe(e),e[BE]){if(e[QM]!==this)throw new Error("The agent is not owned by this CacheableLookup instance");e.createConnection=e[BE],delete e[BE],delete e[QM]}}updateInterfaceInfo(){let{_iface:e}=this;this._iface=roe(),(e.has4&&!this._iface.has4||e.has6&&!this._iface.has6)&&this._cache.clear()}clear(e){if(e){this._cache.delete(e);return}this._cache.clear()}};FM.exports=JP;FM.exports.default=JP});var loe=_((uLt,RM)=>{"use strict";var rnt=typeof URL>"u"?ve("url").URL:URL,nnt="text/plain",int="us-ascii",ooe=(t,e)=>e.some(r=>r instanceof RegExp?r.test(t):r===t),snt=(t,{stripHash:e})=>{let r=t.match(/^data:([^,]*?),([^#]*?)(?:#(.*))?$/);if(!r)throw new Error(`Invalid URL: ${t}`);let o=r[1].split(";"),a=r[2],n=e?"":r[3],u=!1;o[o.length-1]==="base64"&&(o.pop(),u=!0);let A=(o.shift()||"").toLowerCase(),h=[...o.map(E=>{let[I,v=""]=E.split("=").map(x=>x.trim());return I==="charset"&&(v=v.toLowerCase(),v===int)?"":`${I}${v?`=${v}`:""}`}).filter(Boolean)];return u&&h.push("base64"),(h.length!==0||A&&A!==nnt)&&h.unshift(A),`data:${h.join(";")},${u?a.trim():a}${n?`#${n}`:""}`},aoe=(t,e)=>{if(e={defaultProtocol:"http:",normalizeProtocol:!0,forceHttp:!1,forceHttps:!1,stripAuthentication:!0,stripHash:!1,stripWWW:!0,removeQueryParameters:[/^utm_\w+/i],removeTrailingSlash:!0,removeDirectoryIndex:!1,sortQueryParameters:!0,...e},Reflect.has(e,"normalizeHttps"))throw new Error("options.normalizeHttps is renamed to options.forceHttp");if(Reflect.has(e,"normalizeHttp"))throw new Error("options.normalizeHttp is renamed to options.forceHttps");if(Reflect.has(e,"stripFragment"))throw new Error("options.stripFragment is renamed to options.stripHash");if(t=t.trim(),/^data:/i.test(t))return snt(t,e);let r=t.startsWith("//");!r&&/^\.*\//.test(t)||(t=t.replace(/^(?!(?:\w+:)?\/\/)|^\/\//,e.defaultProtocol));let a=new rnt(t);if(e.forceHttp&&e.forceHttps)throw new Error("The `forceHttp` and `forceHttps` options cannot be used together");if(e.forceHttp&&a.protocol==="https:"&&(a.protocol="http:"),e.forceHttps&&a.protocol==="http:"&&(a.protocol="https:"),e.stripAuthentication&&(a.username="",a.password=""),e.stripHash&&(a.hash=""),a.pathname&&(a.pathname=a.pathname.replace(/((?!:).|^)\/{2,}/g,(n,u)=>/^(?!\/)/g.test(u)?`${u}/`:"/")),a.pathname&&(a.pathname=decodeURI(a.pathname)),e.removeDirectoryIndex===!0&&(e.removeDirectoryIndex=[/^index\.[a-z]+$/]),Array.isArray(e.removeDirectoryIndex)&&e.removeDirectoryIndex.length>0){let n=a.pathname.split("/"),u=n[n.length-1];ooe(u,e.removeDirectoryIndex)&&(n=n.slice(0,n.length-1),a.pathname=n.slice(1).join("/")+"/")}if(a.hostname&&(a.hostname=a.hostname.replace(/\.$/,""),e.stripWWW&&/^www\.([a-z\-\d]{2,63})\.([a-z.]{2,5})$/.test(a.hostname)&&(a.hostname=a.hostname.replace(/^www\./,""))),Array.isArray(e.removeQueryParameters))for(let n of[...a.searchParams.keys()])ooe(n,e.removeQueryParameters)&&a.searchParams.delete(n);return e.sortQueryParameters&&a.searchParams.sort(),e.removeTrailingSlash&&(a.pathname=a.pathname.replace(/\/$/,"")),t=a.toString(),(e.removeTrailingSlash||a.pathname==="/")&&a.hash===""&&(t=t.replace(/\/$/,"")),r&&!e.normalizeProtocol&&(t=t.replace(/^http:\/\//,"//")),e.stripProtocol&&(t=t.replace(/^(?:https?:)?\/\//,"")),t};RM.exports=aoe;RM.exports.default=aoe});var Aoe=_((ALt,uoe)=>{uoe.exports=coe;function coe(t,e){if(t&&e)return coe(t)(e);if(typeof t!="function")throw new TypeError("need wrapper function");return Object.keys(t).forEach(function(o){r[o]=t[o]}),r;function r(){for(var o=new Array(arguments.length),a=0;a<o.length;a++)o[a]=arguments[a];var n=t.apply(this,o),u=o[o.length-1];return typeof n=="function"&&n!==u&&Object.keys(u).forEach(function(A){n[A]=u[A]}),n}}});var NM=_((fLt,TM)=>{var foe=Aoe();TM.exports=foe(zP);TM.exports.strict=foe(poe);zP.proto=zP(function(){Object.defineProperty(Function.prototype,"once",{value:function(){return zP(this)},configurable:!0}),Object.defineProperty(Function.prototype,"onceStrict",{value:function(){return poe(this)},configurable:!0})});function zP(t){var e=function(){return e.called?e.value:(e.called=!0,e.value=t.apply(this,arguments))};return e.called=!1,e}function poe(t){var e=function(){if(e.called)throw new Error(e.onceError);return e.called=!0,e.value=t.apply(this,arguments)},r=t.name||"Function wrapped with `once`";return e.onceError=r+" shouldn't be called more than once",e.called=!1,e}});var LM=_((pLt,goe)=>{var ont=NM(),ant=function(){},lnt=function(t){return t.setHeader&&typeof t.abort=="function"},cnt=function(t){return t.stdio&&Array.isArray(t.stdio)&&t.stdio.length===3},hoe=function(t,e,r){if(typeof e=="function")return hoe(t,null,e);e||(e={}),r=ont(r||ant);var o=t._writableState,a=t._readableState,n=e.readable||e.readable!==!1&&t.readable,u=e.writable||e.writable!==!1&&t.writable,A=function(){t.writable||p()},p=function(){u=!1,n||r.call(t)},h=function(){n=!1,u||r.call(t)},E=function(C){r.call(t,C?new Error("exited with error code: "+C):null)},I=function(C){r.call(t,C)},v=function(){if(n&&!(a&&a.ended))return r.call(t,new Error("premature close"));if(u&&!(o&&o.ended))return r.call(t,new Error("premature close"))},x=function(){t.req.on("finish",p)};return lnt(t)?(t.on("complete",p),t.on("abort",v),t.req?x():t.on("request",x)):u&&!o&&(t.on("end",A),t.on("close",A)),cnt(t)&&t.on("exit",E),t.on("end",h),t.on("finish",p),e.error!==!1&&t.on("error",I),t.on("close",v),function(){t.removeListener("complete",p),t.removeListener("abort",v),t.removeListener("request",x),t.req&&t.req.removeListener("finish",p),t.removeListener("end",A),t.removeListener("close",A),t.removeListener("finish",p),t.removeListener("exit",E),t.removeListener("end",h),t.removeListener("error",I),t.removeListener("close",v)}};goe.exports=hoe});var yoe=_((hLt,moe)=>{var unt=NM(),Ant=LM(),OM=ve("fs"),p1=function(){},fnt=/^v?\.0/.test(process.version),XP=function(t){return typeof t=="function"},pnt=function(t){return!fnt||!OM?!1:(t instanceof(OM.ReadStream||p1)||t instanceof(OM.WriteStream||p1))&&XP(t.close)},hnt=function(t){return t.setHeader&&XP(t.abort)},gnt=function(t,e,r,o){o=unt(o);var a=!1;t.on("close",function(){a=!0}),Ant(t,{readable:e,writable:r},function(u){if(u)return o(u);a=!0,o()});var n=!1;return function(u){if(!a&&!n){if(n=!0,pnt(t))return t.close(p1);if(hnt(t))return t.abort();if(XP(t.destroy))return t.destroy();o(u||new Error("stream was destroyed"))}}},doe=function(t){t()},dnt=function(t,e){return t.pipe(e)},mnt=function(){var t=Array.prototype.slice.call(arguments),e=XP(t[t.length-1]||p1)&&t.pop()||p1;if(Array.isArray(t[0])&&(t=t[0]),t.length<2)throw new Error("pump requires two streams per minimum");var r,o=t.map(function(a,n){var u=n<t.length-1,A=n>0;return gnt(a,u,A,function(p){r||(r=p),p&&o.forEach(doe),!u&&(o.forEach(doe),e(r))})});return t.reduce(dnt)};moe.exports=mnt});var Coe=_((gLt,Eoe)=>{"use strict";var{PassThrough:ynt}=ve("stream");Eoe.exports=t=>{t={...t};let{array:e}=t,{encoding:r}=t,o=r==="buffer",a=!1;e?a=!(r||o):r=r||"utf8",o&&(r=null);let n=new ynt({objectMode:a});r&&n.setEncoding(r);let u=0,A=[];return n.on("data",p=>{A.push(p),a?u=A.length:u+=p.length}),n.getBufferedValue=()=>e?A:o?Buffer.concat(A,u):A.join(""),n.getBufferedLength=()=>u,n}});var woe=_((dLt,vE)=>{"use strict";var Ent=yoe(),Cnt=Coe(),ZP=class extends Error{constructor(){super("maxBuffer exceeded"),this.name="MaxBufferError"}};async function $P(t,e){if(!t)return Promise.reject(new Error("Expected a stream"));e={maxBuffer:1/0,...e};let{maxBuffer:r}=e,o;return await new Promise((a,n)=>{let u=A=>{A&&(A.bufferedData=o.getBufferedValue()),n(A)};o=Ent(t,Cnt(e),A=>{if(A){u(A);return}a()}),o.on("data",()=>{o.getBufferedLength()>r&&u(new ZP)})}),o.getBufferedValue()}vE.exports=$P;vE.exports.default=$P;vE.exports.buffer=(t,e)=>$P(t,{...e,encoding:"buffer"});vE.exports.array=(t,e)=>$P(t,{...e,array:!0});vE.exports.MaxBufferError=ZP});var Boe=_((yLt,Ioe)=>{"use strict";var wnt=new Set([200,203,204,206,300,301,404,405,410,414,501]),Int=new Set([200,203,204,300,301,302,303,307,308,404,405,410,414,501]),Bnt=new Set([500,502,503,504]),vnt={date:!0,connection:!0,"keep-alive":!0,"proxy-authenticate":!0,"proxy-authorization":!0,te:!0,trailer:!0,"transfer-encoding":!0,upgrade:!0},Dnt={"content-length":!0,"content-encoding":!0,"transfer-encoding":!0,"content-range":!0};function Sd(t){let e=parseInt(t,10);return isFinite(e)?e:0}function Snt(t){return t?Bnt.has(t.status):!0}function MM(t){let e={};if(!t)return e;let r=t.trim().split(/\s*,\s*/);for(let o of r){let[a,n]=o.split(/\s*=\s*/,2);e[a]=n===void 0?!0:n.replace(/^"|"$/g,"")}return e}function Pnt(t){let e=[];for(let r in t){let o=t[r];e.push(o===!0?r:r+"="+o)}if(!!e.length)return e.join(", ")}Ioe.exports=class{constructor(e,r,{shared:o,cacheHeuristic:a,immutableMinTimeToLive:n,ignoreCargoCult:u,_fromObject:A}={}){if(A){this._fromObject(A);return}if(!r||!r.headers)throw Error("Response headers missing");this._assertRequestHasHeaders(e),this._responseTime=this.now(),this._isShared=o!==!1,this._cacheHeuristic=a!==void 0?a:.1,this._immutableMinTtl=n!==void 0?n:24*3600*1e3,this._status="status"in r?r.status:200,this._resHeaders=r.headers,this._rescc=MM(r.headers["cache-control"]),this._method="method"in e?e.method:"GET",this._url=e.url,this._host=e.headers.host,this._noAuthorization=!e.headers.authorization,this._reqHeaders=r.headers.vary?e.headers:null,this._reqcc=MM(e.headers["cache-control"]),u&&"pre-check"in this._rescc&&"post-check"in this._rescc&&(delete this._rescc["pre-check"],delete this._rescc["post-check"],delete this._rescc["no-cache"],delete this._rescc["no-store"],delete this._rescc["must-revalidate"],this._resHeaders=Object.assign({},this._resHeaders,{"cache-control":Pnt(this._rescc)}),delete this._resHeaders.expires,delete this._resHeaders.pragma),r.headers["cache-control"]==null&&/no-cache/.test(r.headers.pragma)&&(this._rescc["no-cache"]=!0)}now(){return Date.now()}storable(){return!!(!this._reqcc["no-store"]&&(this._method==="GET"||this._method==="HEAD"||this._method==="POST"&&this._hasExplicitExpiration())&&Int.has(this._status)&&!this._rescc["no-store"]&&(!this._isShared||!this._rescc.private)&&(!this._isShared||this._noAuthorization||this._allowsStoringAuthenticated())&&(this._resHeaders.expires||this._rescc["max-age"]||this._isShared&&this._rescc["s-maxage"]||this._rescc.public||wnt.has(this._status)))}_hasExplicitExpiration(){return this._isShared&&this._rescc["s-maxage"]||this._rescc["max-age"]||this._resHeaders.expires}_assertRequestHasHeaders(e){if(!e||!e.headers)throw Error("Request headers missing")}satisfiesWithoutRevalidation(e){this._assertRequestHasHeaders(e);let r=MM(e.headers["cache-control"]);return r["no-cache"]||/no-cache/.test(e.headers.pragma)||r["max-age"]&&this.age()>r["max-age"]||r["min-fresh"]&&this.timeToLive()<1e3*r["min-fresh"]||this.stale()&&!(r["max-stale"]&&!this._rescc["must-revalidate"]&&(r["max-stale"]===!0||r["max-stale"]>this.age()-this.maxAge()))?!1:this._requestMatches(e,!1)}_requestMatches(e,r){return(!this._url||this._url===e.url)&&this._host===e.headers.host&&(!e.method||this._method===e.method||r&&e.method==="HEAD")&&this._varyMatches(e)}_allowsStoringAuthenticated(){return this._rescc["must-revalidate"]||this._rescc.public||this._rescc["s-maxage"]}_varyMatches(e){if(!this._resHeaders.vary)return!0;if(this._resHeaders.vary==="*")return!1;let r=this._resHeaders.vary.trim().toLowerCase().split(/\s*,\s*/);for(let o of r)if(e.headers[o]!==this._reqHeaders[o])return!1;return!0}_copyWithoutHopByHopHeaders(e){let r={};for(let o in e)vnt[o]||(r[o]=e[o]);if(e.connection){let o=e.connection.trim().split(/\s*,\s*/);for(let a of o)delete r[a]}if(r.warning){let o=r.warning.split(/,/).filter(a=>!/^\s*1[0-9][0-9]/.test(a));o.length?r.warning=o.join(",").trim():delete r.warning}return r}responseHeaders(){let e=this._copyWithoutHopByHopHeaders(this._resHeaders),r=this.age();return r>3600*24&&!this._hasExplicitExpiration()&&this.maxAge()>3600*24&&(e.warning=(e.warning?`${e.warning}, `:"")+'113 - "rfc7234 5.5.4"'),e.age=`${Math.round(r)}`,e.date=new Date(this.now()).toUTCString(),e}date(){let e=Date.parse(this._resHeaders.date);return isFinite(e)?e:this._responseTime}age(){let e=this._ageValue(),r=(this.now()-this._responseTime)/1e3;return e+r}_ageValue(){return Sd(this._resHeaders.age)}maxAge(){if(!this.storable()||this._rescc["no-cache"]||this._isShared&&this._resHeaders["set-cookie"]&&!this._rescc.public&&!this._rescc.immutable||this._resHeaders.vary==="*")return 0;if(this._isShared){if(this._rescc["proxy-revalidate"])return 0;if(this._rescc["s-maxage"])return Sd(this._rescc["s-maxage"])}if(this._rescc["max-age"])return Sd(this._rescc["max-age"]);let e=this._rescc.immutable?this._immutableMinTtl:0,r=this.date();if(this._resHeaders.expires){let o=Date.parse(this._resHeaders.expires);return Number.isNaN(o)||o<r?0:Math.max(e,(o-r)/1e3)}if(this._resHeaders["last-modified"]){let o=Date.parse(this._resHeaders["last-modified"]);if(isFinite(o)&&r>o)return Math.max(e,(r-o)/1e3*this._cacheHeuristic)}return e}timeToLive(){let e=this.maxAge()-this.age(),r=e+Sd(this._rescc["stale-if-error"]),o=e+Sd(this._rescc["stale-while-revalidate"]);return Math.max(0,e,r,o)*1e3}stale(){return this.maxAge()<=this.age()}_useStaleIfError(){return this.maxAge()+Sd(this._rescc["stale-if-error"])>this.age()}useStaleWhileRevalidate(){return this.maxAge()+Sd(this._rescc["stale-while-revalidate"])>this.age()}static fromObject(e){return new this(void 0,void 0,{_fromObject:e})}_fromObject(e){if(this._responseTime)throw Error("Reinitialized");if(!e||e.v!==1)throw Error("Invalid serialization");this._responseTime=e.t,this._isShared=e.sh,this._cacheHeuristic=e.ch,this._immutableMinTtl=e.imm!==void 0?e.imm:24*3600*1e3,this._status=e.st,this._resHeaders=e.resh,this._rescc=e.rescc,this._method=e.m,this._url=e.u,this._host=e.h,this._noAuthorization=e.a,this._reqHeaders=e.reqh,this._reqcc=e.reqcc}toObject(){return{v:1,t:this._responseTime,sh:this._isShared,ch:this._cacheHeuristic,imm:this._immutableMinTtl,st:this._status,resh:this._resHeaders,rescc:this._rescc,m:this._method,u:this._url,h:this._host,a:this._noAuthorization,reqh:this._reqHeaders,reqcc:this._reqcc}}revalidationHeaders(e){this._assertRequestHasHeaders(e);let r=this._copyWithoutHopByHopHeaders(e.headers);if(delete r["if-range"],!this._requestMatches(e,!0)||!this.storable())return delete r["if-none-match"],delete r["if-modified-since"],r;if(this._resHeaders.etag&&(r["if-none-match"]=r["if-none-match"]?`${r["if-none-match"]}, ${this._resHeaders.etag}`:this._resHeaders.etag),r["accept-ranges"]||r["if-match"]||r["if-unmodified-since"]||this._method&&this._method!="GET"){if(delete r["if-modified-since"],r["if-none-match"]){let a=r["if-none-match"].split(/,/).filter(n=>!/^\s*W\//.test(n));a.length?r["if-none-match"]=a.join(",").trim():delete r["if-none-match"]}}else this._resHeaders["last-modified"]&&!r["if-modified-since"]&&(r["if-modified-since"]=this._resHeaders["last-modified"]);return r}revalidatedPolicy(e,r){if(this._assertRequestHasHeaders(e),this._useStaleIfError()&&Snt(r))return{modified:!1,matches:!1,policy:this};if(!r||!r.headers)throw Error("Response headers missing");let o=!1;if(r.status!==void 0&&r.status!=304?o=!1:r.headers.etag&&!/^\s*W\//.test(r.headers.etag)?o=this._resHeaders.etag&&this._resHeaders.etag.replace(/^\s*W\//,"")===r.headers.etag:this._resHeaders.etag&&r.headers.etag?o=this._resHeaders.etag.replace(/^\s*W\//,"")===r.headers.etag.replace(/^\s*W\//,""):this._resHeaders["last-modified"]?o=this._resHeaders["last-modified"]===r.headers["last-modified"]:!this._resHeaders.etag&&!this._resHeaders["last-modified"]&&!r.headers.etag&&!r.headers["last-modified"]&&(o=!0),!o)return{policy:new this.constructor(e,r),modified:r.status!=304,matches:!1};let a={};for(let u in this._resHeaders)a[u]=u in r.headers&&!Dnt[u]?r.headers[u]:this._resHeaders[u];let n=Object.assign({},r,{status:this._status,method:this._method,headers:a});return{policy:new this.constructor(e,n,{shared:this._isShared,cacheHeuristic:this._cacheHeuristic,immutableMinTimeToLive:this._immutableMinTtl}),modified:!1,matches:!0}}}});var eb=_((ELt,voe)=>{"use strict";voe.exports=t=>{let e={};for(let[r,o]of Object.entries(t))e[r.toLowerCase()]=o;return e}});var Soe=_((CLt,Doe)=>{"use strict";var bnt=ve("stream").Readable,xnt=eb(),UM=class extends bnt{constructor(e,r,o,a){if(typeof e!="number")throw new TypeError("Argument `statusCode` should be a number");if(typeof r!="object")throw new TypeError("Argument `headers` should be an object");if(!(o instanceof Buffer))throw new TypeError("Argument `body` should be a buffer");if(typeof a!="string")throw new TypeError("Argument `url` should be a string");super(),this.statusCode=e,this.headers=xnt(r),this.body=o,this.url=a}_read(){this.push(this.body),this.push(null)}};Doe.exports=UM});var boe=_((wLt,Poe)=>{"use strict";var knt=["destroy","setTimeout","socket","headers","trailers","rawHeaders","statusCode","httpVersion","httpVersionMinor","httpVersionMajor","rawTrailers","statusMessage"];Poe.exports=(t,e)=>{let r=new Set(Object.keys(t).concat(knt));for(let o of r)o in e||(e[o]=typeof t[o]=="function"?t[o].bind(t):t[o])}});var koe=_((ILt,xoe)=>{"use strict";var Qnt=ve("stream").PassThrough,Fnt=boe(),Rnt=t=>{if(!(t&&t.pipe))throw new TypeError("Parameter `response` must be a response stream.");let e=new Qnt;return Fnt(t,e),t.pipe(e)};xoe.exports=Rnt});var Qoe=_(_M=>{_M.stringify=function t(e){if(typeof e>"u")return e;if(e&&Buffer.isBuffer(e))return JSON.stringify(":base64:"+e.toString("base64"));if(e&&e.toJSON&&(e=e.toJSON()),e&&typeof e=="object"){var r="",o=Array.isArray(e);r=o?"[":"{";var a=!0;for(var n in e){var u=typeof e[n]=="function"||!o&&typeof e[n]>"u";Object.hasOwnProperty.call(e,n)&&!u&&(a||(r+=","),a=!1,o?e[n]==null?r+="null":r+=t(e[n]):e[n]!==void 0&&(r+=t(n)+":"+t(e[n])))}return r+=o?"]":"}",r}else return typeof e=="string"?JSON.stringify(/^:/.test(e)?":"+e:e):typeof e>"u"?"null":JSON.stringify(e)};_M.parse=function(t){return JSON.parse(t,function(e,r){return typeof r=="string"?/^:base64:/.test(r)?Buffer.from(r.substring(8),"base64"):/^:/.test(r)?r.substring(1):r:r})}});var Toe=_((vLt,Roe)=>{"use strict";var Tnt=ve("events"),Foe=Qoe(),Nnt=t=>{let e={redis:"@keyv/redis",mongodb:"@keyv/mongo",mongo:"@keyv/mongo",sqlite:"@keyv/sqlite",postgresql:"@keyv/postgres",postgres:"@keyv/postgres",mysql:"@keyv/mysql"};if(t.adapter||t.uri){let r=t.adapter||/^[^:]*/.exec(t.uri)[0];return new(ve(e[r]))(t)}return new Map},HM=class extends Tnt{constructor(e,r){if(super(),this.opts=Object.assign({namespace:"keyv",serialize:Foe.stringify,deserialize:Foe.parse},typeof e=="string"?{uri:e}:e,r),!this.opts.store){let o=Object.assign({},this.opts);this.opts.store=Nnt(o)}typeof this.opts.store.on=="function"&&this.opts.store.on("error",o=>this.emit("error",o)),this.opts.store.namespace=this.opts.namespace}_getKeyPrefix(e){return`${this.opts.namespace}:${e}`}get(e,r){e=this._getKeyPrefix(e);let{store:o}=this.opts;return Promise.resolve().then(()=>o.get(e)).then(a=>typeof a=="string"?this.opts.deserialize(a):a).then(a=>{if(a!==void 0){if(typeof a.expires=="number"&&Date.now()>a.expires){this.delete(e);return}return r&&r.raw?a:a.value}})}set(e,r,o){e=this._getKeyPrefix(e),typeof o>"u"&&(o=this.opts.ttl),o===0&&(o=void 0);let{store:a}=this.opts;return Promise.resolve().then(()=>{let n=typeof o=="number"?Date.now()+o:null;return r={value:r,expires:n},this.opts.serialize(r)}).then(n=>a.set(e,n,o)).then(()=>!0)}delete(e){e=this._getKeyPrefix(e);let{store:r}=this.opts;return Promise.resolve().then(()=>r.delete(e))}clear(){let{store:e}=this.opts;return Promise.resolve().then(()=>e.clear())}};Roe.exports=HM});var Ooe=_((SLt,Loe)=>{"use strict";var Lnt=ve("events"),tb=ve("url"),Ont=loe(),Mnt=woe(),jM=Boe(),Noe=Soe(),Unt=eb(),_nt=koe(),Hnt=Toe(),jc=class{constructor(e,r){if(typeof e!="function")throw new TypeError("Parameter `request` must be a function");return this.cache=new Hnt({uri:typeof r=="string"&&r,store:typeof r!="string"&&r,namespace:"cacheable-request"}),this.createCacheableRequest(e)}createCacheableRequest(e){return(r,o)=>{let a;if(typeof r=="string")a=GM(tb.parse(r)),r={};else if(r instanceof tb.URL)a=GM(tb.parse(r.toString())),r={};else{let[I,...v]=(r.path||"").split("?"),x=v.length>0?`?${v.join("?")}`:"";a=GM({...r,pathname:I,search:x})}r={headers:{},method:"GET",cache:!0,strictTtl:!1,automaticFailover:!1,...r,...jnt(a)},r.headers=Unt(r.headers);let n=new Lnt,u=Ont(tb.format(a),{stripWWW:!1,removeTrailingSlash:!1,stripAuthentication:!1}),A=`${r.method}:${u}`,p=!1,h=!1,E=I=>{h=!0;let v=!1,x,C=new Promise(L=>{x=()=>{v||(v=!0,L())}}),R=L=>{if(p&&!I.forceRefresh){L.status=L.statusCode;let J=jM.fromObject(p.cachePolicy).revalidatedPolicy(I,L);if(!J.modified){let te=J.policy.responseHeaders();L=new Noe(p.statusCode,te,p.body,p.url),L.cachePolicy=J.policy,L.fromCache=!0}}L.fromCache||(L.cachePolicy=new jM(I,L,I),L.fromCache=!1);let U;I.cache&&L.cachePolicy.storable()?(U=_nt(L),(async()=>{try{let J=Mnt.buffer(L);if(await Promise.race([C,new Promise(ce=>L.once("end",ce))]),v)return;let te=await J,ae={cachePolicy:L.cachePolicy.toObject(),url:L.url,statusCode:L.fromCache?p.statusCode:L.statusCode,body:te},fe=I.strictTtl?L.cachePolicy.timeToLive():void 0;I.maxTtl&&(fe=fe?Math.min(fe,I.maxTtl):I.maxTtl),await this.cache.set(A,ae,fe)}catch(J){n.emit("error",new jc.CacheError(J))}})()):I.cache&&p&&(async()=>{try{await this.cache.delete(A)}catch(J){n.emit("error",new jc.CacheError(J))}})(),n.emit("response",U||L),typeof o=="function"&&o(U||L)};try{let L=e(I,R);L.once("error",x),L.once("abort",x),n.emit("request",L)}catch(L){n.emit("error",new jc.RequestError(L))}};return(async()=>{let I=async x=>{await Promise.resolve();let C=x.cache?await this.cache.get(A):void 0;if(typeof C>"u")return E(x);let R=jM.fromObject(C.cachePolicy);if(R.satisfiesWithoutRevalidation(x)&&!x.forceRefresh){let L=R.responseHeaders(),U=new Noe(C.statusCode,L,C.body,C.url);U.cachePolicy=R,U.fromCache=!0,n.emit("response",U),typeof o=="function"&&o(U)}else p=C,x.headers=R.revalidationHeaders(x),E(x)},v=x=>n.emit("error",new jc.CacheError(x));this.cache.once("error",v),n.on("response",()=>this.cache.removeListener("error",v));try{await I(r)}catch(x){r.automaticFailover&&!h&&E(r),n.emit("error",new jc.CacheError(x))}})(),n}}};function jnt(t){let e={...t};return e.path=`${t.pathname||"/"}${t.search||""}`,delete e.pathname,delete e.search,e}function GM(t){return{protocol:t.protocol,auth:t.auth,hostname:t.hostname||t.host||"localhost",port:t.port,pathname:t.pathname,search:t.search}}jc.RequestError=class extends Error{constructor(t){super(t.message),this.name="RequestError",Object.assign(this,t)}};jc.CacheError=class extends Error{constructor(t){super(t.message),this.name="CacheError",Object.assign(this,t)}};Loe.exports=jc});var Uoe=_((xLt,Moe)=>{"use strict";var Gnt=["aborted","complete","headers","httpVersion","httpVersionMinor","httpVersionMajor","method","rawHeaders","rawTrailers","setTimeout","socket","statusCode","statusMessage","trailers","url"];Moe.exports=(t,e)=>{if(e._readableState.autoDestroy)throw new Error("The second stream must have the `autoDestroy` option set to `false`");let r=new Set(Object.keys(t).concat(Gnt)),o={};for(let a of r)a in e||(o[a]={get(){let n=t[a];return typeof n=="function"?n.bind(t):n},set(n){t[a]=n},enumerable:!0,configurable:!1});return Object.defineProperties(e,o),t.once("aborted",()=>{e.destroy(),e.emit("aborted")}),t.once("close",()=>{t.complete&&e.readable?e.once("end",()=>{e.emit("close")}):e.emit("close")}),e}});var Hoe=_((kLt,_oe)=>{"use strict";var{Transform:qnt,PassThrough:Ynt}=ve("stream"),qM=ve("zlib"),Wnt=Uoe();_oe.exports=t=>{let e=(t.headers["content-encoding"]||"").toLowerCase();if(!["gzip","deflate","br"].includes(e))return t;let r=e==="br";if(r&&typeof qM.createBrotliDecompress!="function")return t.destroy(new Error("Brotli is not supported on Node.js < 12")),t;let o=!0,a=new qnt({transform(A,p,h){o=!1,h(null,A)},flush(A){A()}}),n=new Ynt({autoDestroy:!1,destroy(A,p){t.destroy(),p(A)}}),u=r?qM.createBrotliDecompress():qM.createUnzip();return u.once("error",A=>{if(o&&!t.readable){n.end();return}n.destroy(A)}),Wnt(t,n),t.pipe(a).pipe(u).pipe(n),n}});var WM=_((QLt,joe)=>{"use strict";var YM=class{constructor(e={}){if(!(e.maxSize&&e.maxSize>0))throw new TypeError("`maxSize` must be a number greater than 0");this.maxSize=e.maxSize,this.onEviction=e.onEviction,this.cache=new Map,this.oldCache=new Map,this._size=0}_set(e,r){if(this.cache.set(e,r),this._size++,this._size>=this.maxSize){if(this._size=0,typeof this.onEviction=="function")for(let[o,a]of this.oldCache.entries())this.onEviction(o,a);this.oldCache=this.cache,this.cache=new Map}}get(e){if(this.cache.has(e))return this.cache.get(e);if(this.oldCache.has(e)){let r=this.oldCache.get(e);return this.oldCache.delete(e),this._set(e,r),r}}set(e,r){return this.cache.has(e)?this.cache.set(e,r):this._set(e,r),this}has(e){return this.cache.has(e)||this.oldCache.has(e)}peek(e){if(this.cache.has(e))return this.cache.get(e);if(this.oldCache.has(e))return this.oldCache.get(e)}delete(e){let r=this.cache.delete(e);return r&&this._size--,this.oldCache.delete(e)||r}clear(){this.cache.clear(),this.oldCache.clear(),this._size=0}*keys(){for(let[e]of this)yield e}*values(){for(let[,e]of this)yield e}*[Symbol.iterator](){for(let e of this.cache)yield e;for(let e of this.oldCache){let[r]=e;this.cache.has(r)||(yield e)}}get size(){let e=0;for(let r of this.oldCache.keys())this.cache.has(r)||e++;return Math.min(this._size+e,this.maxSize)}};joe.exports=YM});var VM=_((FLt,Woe)=>{"use strict";var Knt=ve("events"),Vnt=ve("tls"),Jnt=ve("http2"),znt=WM(),ea=Symbol("currentStreamsCount"),Goe=Symbol("request"),Wl=Symbol("cachedOriginSet"),DE=Symbol("gracefullyClosing"),Xnt=["maxDeflateDynamicTableSize","maxSessionMemory","maxHeaderListPairs","maxOutstandingPings","maxReservedRemoteStreams","maxSendHeaderBlockLength","paddingStrategy","localAddress","path","rejectUnauthorized","minDHSize","ca","cert","clientCertEngine","ciphers","key","pfx","servername","minVersion","maxVersion","secureProtocol","crl","honorCipherOrder","ecdhCurve","dhparam","secureOptions","sessionIdContext"],Znt=(t,e,r)=>{let o=0,a=t.length;for(;o<a;){let n=o+a>>>1;r(t[n],e)?o=n+1:a=n}return o},$nt=(t,e)=>t.remoteSettings.maxConcurrentStreams>e.remoteSettings.maxConcurrentStreams,KM=(t,e)=>{for(let r of t)r[Wl].length<e[Wl].length&&r[Wl].every(o=>e[Wl].includes(o))&&r[ea]+e[ea]<=e.remoteSettings.maxConcurrentStreams&&Yoe(r)},eit=(t,e)=>{for(let r of t)e[Wl].length<r[Wl].length&&e[Wl].every(o=>r[Wl].includes(o))&&e[ea]+r[ea]<=r.remoteSettings.maxConcurrentStreams&&Yoe(e)},qoe=({agent:t,isFree:e})=>{let r={};for(let o in t.sessions){let n=t.sessions[o].filter(u=>{let A=u[tA.kCurrentStreamsCount]<u.remoteSettings.maxConcurrentStreams;return e?A:!A});n.length!==0&&(r[o]=n)}return r},Yoe=t=>{t[DE]=!0,t[ea]===0&&t.close()},tA=class extends Knt{constructor({timeout:e=6e4,maxSessions:r=1/0,maxFreeSessions:o=10,maxCachedTlsSessions:a=100}={}){super(),this.sessions={},this.queue={},this.timeout=e,this.maxSessions=r,this.maxFreeSessions=o,this._freeSessionsCount=0,this._sessionsCount=0,this.settings={enablePush:!1},this.tlsSessionCache=new znt({maxSize:a})}static normalizeOrigin(e,r){return typeof e=="string"&&(e=new URL(e)),r&&e.hostname!==r&&(e.hostname=r),e.origin}normalizeOptions(e){let r="";if(e)for(let o of Xnt)e[o]&&(r+=`:${e[o]}`);return r}_tryToCreateNewSession(e,r){if(!(e in this.queue)||!(r in this.queue[e]))return;let o=this.queue[e][r];this._sessionsCount<this.maxSessions&&!o.completed&&(o.completed=!0,o())}getSession(e,r,o){return new Promise((a,n)=>{Array.isArray(o)?(o=[...o],a()):o=[{resolve:a,reject:n}];let u=this.normalizeOptions(r),A=tA.normalizeOrigin(e,r&&r.servername);if(A===void 0){for(let{reject:E}of o)E(new TypeError("The `origin` argument needs to be a string or an URL object"));return}if(u in this.sessions){let E=this.sessions[u],I=-1,v=-1,x;for(let C of E){let R=C.remoteSettings.maxConcurrentStreams;if(R<I)break;if(C[Wl].includes(A)){let L=C[ea];if(L>=R||C[DE]||C.destroyed)continue;x||(I=R),L>v&&(x=C,v=L)}}if(x){if(o.length!==1){for(let{reject:C}of o){let R=new Error(`Expected the length of listeners to be 1, got ${o.length}. +Please report this to https://github.com/szmarczak/http2-wrapper/`);C(R)}return}o[0].resolve(x);return}}if(u in this.queue){if(A in this.queue[u]){this.queue[u][A].listeners.push(...o),this._tryToCreateNewSession(u,A);return}}else this.queue[u]={};let p=()=>{u in this.queue&&this.queue[u][A]===h&&(delete this.queue[u][A],Object.keys(this.queue[u]).length===0&&delete this.queue[u])},h=()=>{let E=`${A}:${u}`,I=!1;try{let v=Jnt.connect(e,{createConnection:this.createConnection,settings:this.settings,session:this.tlsSessionCache.get(E),...r});v[ea]=0,v[DE]=!1;let x=()=>v[ea]<v.remoteSettings.maxConcurrentStreams,C=!0;v.socket.once("session",L=>{this.tlsSessionCache.set(E,L)}),v.once("error",L=>{for(let{reject:U}of o)U(L);this.tlsSessionCache.delete(E)}),v.setTimeout(this.timeout,()=>{v.destroy()}),v.once("close",()=>{if(I){C&&this._freeSessionsCount--,this._sessionsCount--;let L=this.sessions[u];L.splice(L.indexOf(v),1),L.length===0&&delete this.sessions[u]}else{let L=new Error("Session closed without receiving a SETTINGS frame");L.code="HTTP2WRAPPER_NOSETTINGS";for(let{reject:U}of o)U(L);p()}this._tryToCreateNewSession(u,A)});let R=()=>{if(!(!(u in this.queue)||!x())){for(let L of v[Wl])if(L in this.queue[u]){let{listeners:U}=this.queue[u][L];for(;U.length!==0&&x();)U.shift().resolve(v);let J=this.queue[u];if(J[L].listeners.length===0&&(delete J[L],Object.keys(J).length===0)){delete this.queue[u];break}if(!x())break}}};v.on("origin",()=>{v[Wl]=v.originSet,x()&&(R(),KM(this.sessions[u],v))}),v.once("remoteSettings",()=>{if(v.ref(),v.unref(),this._sessionsCount++,h.destroyed){let L=new Error("Agent has been destroyed");for(let U of o)U.reject(L);v.destroy();return}v[Wl]=v.originSet;{let L=this.sessions;if(u in L){let U=L[u];U.splice(Znt(U,v,$nt),0,v)}else L[u]=[v]}this._freeSessionsCount+=1,I=!0,this.emit("session",v),R(),p(),v[ea]===0&&this._freeSessionsCount>this.maxFreeSessions&&v.close(),o.length!==0&&(this.getSession(A,r,o),o.length=0),v.on("remoteSettings",()=>{R(),KM(this.sessions[u],v)})}),v[Goe]=v.request,v.request=(L,U)=>{if(v[DE])throw new Error("The session is gracefully closing. No new streams are allowed.");let J=v[Goe](L,U);return v.ref(),++v[ea],v[ea]===v.remoteSettings.maxConcurrentStreams&&this._freeSessionsCount--,J.once("close",()=>{if(C=x(),--v[ea],!v.destroyed&&!v.closed&&(eit(this.sessions[u],v),x()&&!v.closed)){C||(this._freeSessionsCount++,C=!0);let te=v[ea]===0;te&&v.unref(),te&&(this._freeSessionsCount>this.maxFreeSessions||v[DE])?v.close():(KM(this.sessions[u],v),R())}}),J}}catch(v){for(let x of o)x.reject(v);p()}};h.listeners=o,h.completed=!1,h.destroyed=!1,this.queue[u][A]=h,this._tryToCreateNewSession(u,A)})}request(e,r,o,a){return new Promise((n,u)=>{this.getSession(e,r,[{reject:u,resolve:A=>{try{n(A.request(o,a))}catch(p){u(p)}}}])})}createConnection(e,r){return tA.connect(e,r)}static connect(e,r){r.ALPNProtocols=["h2"];let o=e.port||443,a=e.hostname||e.host;return typeof r.servername>"u"&&(r.servername=a),Vnt.connect(o,a,r)}closeFreeSessions(){for(let e of Object.values(this.sessions))for(let r of e)r[ea]===0&&r.close()}destroy(e){for(let r of Object.values(this.sessions))for(let o of r)o.destroy(e);for(let r of Object.values(this.queue))for(let o of Object.values(r))o.destroyed=!0;this.queue={}}get freeSessions(){return qoe({agent:this,isFree:!0})}get busySessions(){return qoe({agent:this,isFree:!1})}};tA.kCurrentStreamsCount=ea;tA.kGracefullyClosing=DE;Woe.exports={Agent:tA,globalAgent:new tA}});var zM=_((RLt,Koe)=>{"use strict";var{Readable:tit}=ve("stream"),JM=class extends tit{constructor(e,r){super({highWaterMark:r,autoDestroy:!1}),this.statusCode=null,this.statusMessage="",this.httpVersion="2.0",this.httpVersionMajor=2,this.httpVersionMinor=0,this.headers={},this.trailers={},this.req=null,this.aborted=!1,this.complete=!1,this.upgrade=null,this.rawHeaders=[],this.rawTrailers=[],this.socket=e,this.connection=e,this._dumped=!1}_destroy(e){this.req._request.destroy(e)}setTimeout(e,r){return this.req.setTimeout(e,r),this}_dump(){this._dumped||(this._dumped=!0,this.removeAllListeners("data"),this.resume())}_read(){this.req&&this.req._request.resume()}};Koe.exports=JM});var XM=_((TLt,Voe)=>{"use strict";Voe.exports=t=>{let e={protocol:t.protocol,hostname:typeof t.hostname=="string"&&t.hostname.startsWith("[")?t.hostname.slice(1,-1):t.hostname,host:t.host,hash:t.hash,search:t.search,pathname:t.pathname,href:t.href,path:`${t.pathname||""}${t.search||""}`};return typeof t.port=="string"&&t.port.length!==0&&(e.port=Number(t.port)),(t.username||t.password)&&(e.auth=`${t.username||""}:${t.password||""}`),e}});var zoe=_((NLt,Joe)=>{"use strict";Joe.exports=(t,e,r)=>{for(let o of r)t.on(o,(...a)=>e.emit(o,...a))}});var Zoe=_((LLt,Xoe)=>{"use strict";Xoe.exports=t=>{switch(t){case":method":case":scheme":case":authority":case":path":return!0;default:return!1}}});var eae=_((MLt,$oe)=>{"use strict";var SE=(t,e,r)=>{$oe.exports[e]=class extends t{constructor(...a){super(typeof r=="string"?r:r(a)),this.name=`${super.name} [${e}]`,this.code=e}}};SE(TypeError,"ERR_INVALID_ARG_TYPE",t=>{let e=t[0].includes(".")?"property":"argument",r=t[1],o=Array.isArray(r);return o&&(r=`${r.slice(0,-1).join(", ")} or ${r.slice(-1)}`),`The "${t[0]}" ${e} must be ${o?"one of":"of"} type ${r}. Received ${typeof t[2]}`});SE(TypeError,"ERR_INVALID_PROTOCOL",t=>`Protocol "${t[0]}" not supported. Expected "${t[1]}"`);SE(Error,"ERR_HTTP_HEADERS_SENT",t=>`Cannot ${t[0]} headers after they are sent to the client`);SE(TypeError,"ERR_INVALID_HTTP_TOKEN",t=>`${t[0]} must be a valid HTTP token [${t[1]}]`);SE(TypeError,"ERR_HTTP_INVALID_HEADER_VALUE",t=>`Invalid value "${t[0]} for header "${t[1]}"`);SE(TypeError,"ERR_INVALID_CHAR",t=>`Invalid character in ${t[0]} [${t[1]}]`)});var r4=_((ULt,aae)=>{"use strict";var rit=ve("http2"),{Writable:nit}=ve("stream"),{Agent:tae,globalAgent:iit}=VM(),sit=zM(),oit=XM(),ait=zoe(),lit=Zoe(),{ERR_INVALID_ARG_TYPE:ZM,ERR_INVALID_PROTOCOL:cit,ERR_HTTP_HEADERS_SENT:rae,ERR_INVALID_HTTP_TOKEN:uit,ERR_HTTP_INVALID_HEADER_VALUE:Ait,ERR_INVALID_CHAR:fit}=eae(),{HTTP2_HEADER_STATUS:nae,HTTP2_HEADER_METHOD:iae,HTTP2_HEADER_PATH:sae,HTTP2_METHOD_CONNECT:pit}=rit.constants,Qo=Symbol("headers"),$M=Symbol("origin"),e4=Symbol("session"),oae=Symbol("options"),rb=Symbol("flushedHeaders"),h1=Symbol("jobs"),hit=/^[\^`\-\w!#$%&*+.|~]+$/,git=/[^\t\u0020-\u007E\u0080-\u00FF]/,t4=class extends nit{constructor(e,r,o){super({autoDestroy:!1});let a=typeof e=="string"||e instanceof URL;if(a&&(e=oit(e instanceof URL?e:new URL(e))),typeof r=="function"||r===void 0?(o=r,r=a?e:{...e}):r={...e,...r},r.h2session)this[e4]=r.h2session;else if(r.agent===!1)this.agent=new tae({maxFreeSessions:0});else if(typeof r.agent>"u"||r.agent===null)typeof r.createConnection=="function"?(this.agent=new tae({maxFreeSessions:0}),this.agent.createConnection=r.createConnection):this.agent=iit;else if(typeof r.agent.request=="function")this.agent=r.agent;else throw new ZM("options.agent",["Agent-like Object","undefined","false"],r.agent);if(r.protocol&&r.protocol!=="https:")throw new cit(r.protocol,"https:");let n=r.port||r.defaultPort||this.agent&&this.agent.defaultPort||443,u=r.hostname||r.host||"localhost";delete r.hostname,delete r.host,delete r.port;let{timeout:A}=r;if(r.timeout=void 0,this[Qo]=Object.create(null),this[h1]=[],this.socket=null,this.connection=null,this.method=r.method||"GET",this.path=r.path,this.res=null,this.aborted=!1,this.reusedSocket=!1,r.headers)for(let[p,h]of Object.entries(r.headers))this.setHeader(p,h);r.auth&&!("authorization"in this[Qo])&&(this[Qo].authorization="Basic "+Buffer.from(r.auth).toString("base64")),r.session=r.tlsSession,r.path=r.socketPath,this[oae]=r,n===443?(this[$M]=`https://${u}`,":authority"in this[Qo]||(this[Qo][":authority"]=u)):(this[$M]=`https://${u}:${n}`,":authority"in this[Qo]||(this[Qo][":authority"]=`${u}:${n}`)),A&&this.setTimeout(A),o&&this.once("response",o),this[rb]=!1}get method(){return this[Qo][iae]}set method(e){e&&(this[Qo][iae]=e.toUpperCase())}get path(){return this[Qo][sae]}set path(e){e&&(this[Qo][sae]=e)}get _mustNotHaveABody(){return this.method==="GET"||this.method==="HEAD"||this.method==="DELETE"}_write(e,r,o){if(this._mustNotHaveABody){o(new Error("The GET, HEAD and DELETE methods must NOT have a body"));return}this.flushHeaders();let a=()=>this._request.write(e,r,o);this._request?a():this[h1].push(a)}_final(e){if(this.destroyed)return;this.flushHeaders();let r=()=>{if(this._mustNotHaveABody){e();return}this._request.end(e)};this._request?r():this[h1].push(r)}abort(){this.res&&this.res.complete||(this.aborted||process.nextTick(()=>this.emit("abort")),this.aborted=!0,this.destroy())}_destroy(e,r){this.res&&this.res._dump(),this._request&&this._request.destroy(),r(e)}async flushHeaders(){if(this[rb]||this.destroyed)return;this[rb]=!0;let e=this.method===pit,r=o=>{if(this._request=o,this.destroyed){o.destroy();return}e||ait(o,this,["timeout","continue","close","error"]);let a=u=>(...A)=>{!this.writable&&!this.destroyed?u(...A):this.once("finish",()=>{u(...A)})};o.once("response",a((u,A,p)=>{let h=new sit(this.socket,o.readableHighWaterMark);this.res=h,h.req=this,h.statusCode=u[nae],h.headers=u,h.rawHeaders=p,h.once("end",()=>{this.aborted?(h.aborted=!0,h.emit("aborted")):(h.complete=!0,h.socket=null,h.connection=null)}),e?(h.upgrade=!0,this.emit("connect",h,o,Buffer.alloc(0))?this.emit("close"):o.destroy()):(o.on("data",E=>{!h._dumped&&!h.push(E)&&o.pause()}),o.once("end",()=>{h.push(null)}),this.emit("response",h)||h._dump())})),o.once("headers",a(u=>this.emit("information",{statusCode:u[nae]}))),o.once("trailers",a((u,A,p)=>{let{res:h}=this;h.trailers=u,h.rawTrailers=p}));let{socket:n}=o.session;this.socket=n,this.connection=n;for(let u of this[h1])u();this.emit("socket",this.socket)};if(this[e4])try{r(this[e4].request(this[Qo]))}catch(o){this.emit("error",o)}else{this.reusedSocket=!0;try{r(await this.agent.request(this[$M],this[oae],this[Qo]))}catch(o){this.emit("error",o)}}}getHeader(e){if(typeof e!="string")throw new ZM("name","string",e);return this[Qo][e.toLowerCase()]}get headersSent(){return this[rb]}removeHeader(e){if(typeof e!="string")throw new ZM("name","string",e);if(this.headersSent)throw new rae("remove");delete this[Qo][e.toLowerCase()]}setHeader(e,r){if(this.headersSent)throw new rae("set");if(typeof e!="string"||!hit.test(e)&&!lit(e))throw new uit("Header name",e);if(typeof r>"u")throw new Ait(r,e);if(git.test(r))throw new fit("header content",e);this[Qo][e.toLowerCase()]=r}setNoDelay(){}setSocketKeepAlive(){}setTimeout(e,r){let o=()=>this._request.setTimeout(e,r);return this._request?o():this[h1].push(o),this}get maxHeadersCount(){if(!this.destroyed&&this._request)return this._request.session.localSettings.maxHeaderListSize}set maxHeadersCount(e){}};aae.exports=t4});var cae=_((_Lt,lae)=>{"use strict";var dit=ve("tls");lae.exports=(t={})=>new Promise((e,r)=>{let o=dit.connect(t,()=>{t.resolveSocket?(o.off("error",r),e({alpnProtocol:o.alpnProtocol,socket:o})):(o.destroy(),e({alpnProtocol:o.alpnProtocol}))});o.on("error",r)})});var Aae=_((HLt,uae)=>{"use strict";var mit=ve("net");uae.exports=t=>{let e=t.host,r=t.headers&&t.headers.host;return r&&(r.startsWith("[")?r.indexOf("]")===-1?e=r:e=r.slice(1,-1):e=r.split(":",1)[0]),mit.isIP(e)?"":e}});var hae=_((jLt,i4)=>{"use strict";var fae=ve("http"),n4=ve("https"),yit=cae(),Eit=WM(),Cit=r4(),wit=Aae(),Iit=XM(),nb=new Eit({maxSize:100}),g1=new Map,pae=(t,e,r)=>{e._httpMessage={shouldKeepAlive:!0};let o=()=>{t.emit("free",e,r)};e.on("free",o);let a=()=>{t.removeSocket(e,r)};e.on("close",a);let n=()=>{t.removeSocket(e,r),e.off("close",a),e.off("free",o),e.off("agentRemove",n)};e.on("agentRemove",n),t.emit("free",e,r)},Bit=async t=>{let e=`${t.host}:${t.port}:${t.ALPNProtocols.sort()}`;if(!nb.has(e)){if(g1.has(e))return(await g1.get(e)).alpnProtocol;let{path:r,agent:o}=t;t.path=t.socketPath;let a=yit(t);g1.set(e,a);try{let{socket:n,alpnProtocol:u}=await a;if(nb.set(e,u),t.path=r,u==="h2")n.destroy();else{let{globalAgent:A}=n4,p=n4.Agent.prototype.createConnection;o?o.createConnection===p?pae(o,n,t):n.destroy():A.createConnection===p?pae(A,n,t):n.destroy()}return g1.delete(e),u}catch(n){throw g1.delete(e),n}}return nb.get(e)};i4.exports=async(t,e,r)=>{if((typeof t=="string"||t instanceof URL)&&(t=Iit(new URL(t))),typeof e=="function"&&(r=e,e=void 0),e={ALPNProtocols:["h2","http/1.1"],...t,...e,resolveSocket:!0},!Array.isArray(e.ALPNProtocols)||e.ALPNProtocols.length===0)throw new Error("The `ALPNProtocols` option must be an Array with at least one entry");e.protocol=e.protocol||"https:";let o=e.protocol==="https:";e.host=e.hostname||e.host||"localhost",e.session=e.tlsSession,e.servername=e.servername||wit(e),e.port=e.port||(o?443:80),e._defaultAgent=o?n4.globalAgent:fae.globalAgent;let a=e.agent;if(a){if(a.addRequest)throw new Error("The `options.agent` object can contain only `http`, `https` or `http2` properties");e.agent=a[o?"https":"http"]}return o&&await Bit(e)==="h2"?(a&&(e.agent=a.http2),new Cit(e,r)):fae.request(e,r)};i4.exports.protocolCache=nb});var dae=_((GLt,gae)=>{"use strict";var vit=ve("http2"),Dit=VM(),s4=r4(),Sit=zM(),Pit=hae(),bit=(t,e,r)=>new s4(t,e,r),xit=(t,e,r)=>{let o=new s4(t,e,r);return o.end(),o};gae.exports={...vit,ClientRequest:s4,IncomingMessage:Sit,...Dit,request:bit,get:xit,auto:Pit}});var a4=_(o4=>{"use strict";Object.defineProperty(o4,"__esModule",{value:!0});var mae=Tf();o4.default=t=>mae.default.nodeStream(t)&&mae.default.function_(t.getBoundary)});var wae=_(l4=>{"use strict";Object.defineProperty(l4,"__esModule",{value:!0});var Eae=ve("fs"),Cae=ve("util"),yae=Tf(),kit=a4(),Qit=Cae.promisify(Eae.stat);l4.default=async(t,e)=>{if(e&&"content-length"in e)return Number(e["content-length"]);if(!t)return 0;if(yae.default.string(t))return Buffer.byteLength(t);if(yae.default.buffer(t))return t.length;if(kit.default(t))return Cae.promisify(t.getLength.bind(t))();if(t instanceof Eae.ReadStream){let{size:r}=await Qit(t.path);return r===0?void 0:r}}});var u4=_(c4=>{"use strict";Object.defineProperty(c4,"__esModule",{value:!0});function Fit(t,e,r){let o={};for(let a of r)o[a]=(...n)=>{e.emit(a,...n)},t.on(a,o[a]);return()=>{for(let a of r)t.off(a,o[a])}}c4.default=Fit});var Iae=_(A4=>{"use strict";Object.defineProperty(A4,"__esModule",{value:!0});A4.default=()=>{let t=[];return{once(e,r,o){e.once(r,o),t.push({origin:e,event:r,fn:o})},unhandleAll(){for(let e of t){let{origin:r,event:o,fn:a}=e;r.removeListener(o,a)}t.length=0}}}});var vae=_(d1=>{"use strict";Object.defineProperty(d1,"__esModule",{value:!0});d1.TimeoutError=void 0;var Rit=ve("net"),Tit=Iae(),Bae=Symbol("reentry"),Nit=()=>{},ib=class extends Error{constructor(e,r){super(`Timeout awaiting '${r}' for ${e}ms`),this.event=r,this.name="TimeoutError",this.code="ETIMEDOUT"}};d1.TimeoutError=ib;d1.default=(t,e,r)=>{if(Bae in t)return Nit;t[Bae]=!0;let o=[],{once:a,unhandleAll:n}=Tit.default(),u=(I,v,x)=>{var C;let R=setTimeout(v,I,I,x);(C=R.unref)===null||C===void 0||C.call(R);let L=()=>{clearTimeout(R)};return o.push(L),L},{host:A,hostname:p}=r,h=(I,v)=>{t.destroy(new ib(I,v))},E=()=>{for(let I of o)I();n()};if(t.once("error",I=>{if(E(),t.listenerCount("error")===0)throw I}),t.once("close",E),a(t,"response",I=>{a(I,"end",E)}),typeof e.request<"u"&&u(e.request,h,"request"),typeof e.socket<"u"){let I=()=>{h(e.socket,"socket")};t.setTimeout(e.socket,I),o.push(()=>{t.removeListener("timeout",I)})}return a(t,"socket",I=>{var v;let{socketPath:x}=t;if(I.connecting){let C=Boolean(x??Rit.isIP((v=p??A)!==null&&v!==void 0?v:"")!==0);if(typeof e.lookup<"u"&&!C&&typeof I.address().address>"u"){let R=u(e.lookup,h,"lookup");a(I,"lookup",R)}if(typeof e.connect<"u"){let R=()=>u(e.connect,h,"connect");C?a(I,"connect",R()):a(I,"lookup",L=>{L===null&&a(I,"connect",R())})}typeof e.secureConnect<"u"&&r.protocol==="https:"&&a(I,"connect",()=>{let R=u(e.secureConnect,h,"secureConnect");a(I,"secureConnect",R)})}if(typeof e.send<"u"){let C=()=>u(e.send,h,"send");I.connecting?a(I,"connect",()=>{a(t,"upload-complete",C())}):a(t,"upload-complete",C())}}),typeof e.response<"u"&&a(t,"upload-complete",()=>{let I=u(e.response,h,"response");a(t,"response",I)}),E}});var Sae=_(f4=>{"use strict";Object.defineProperty(f4,"__esModule",{value:!0});var Dae=Tf();f4.default=t=>{t=t;let e={protocol:t.protocol,hostname:Dae.default.string(t.hostname)&&t.hostname.startsWith("[")?t.hostname.slice(1,-1):t.hostname,host:t.host,hash:t.hash,search:t.search,pathname:t.pathname,href:t.href,path:`${t.pathname||""}${t.search||""}`};return Dae.default.string(t.port)&&t.port.length>0&&(e.port=Number(t.port)),(t.username||t.password)&&(e.auth=`${t.username||""}:${t.password||""}`),e}});var Pae=_(p4=>{"use strict";Object.defineProperty(p4,"__esModule",{value:!0});var Lit=ve("url"),Oit=["protocol","host","hostname","port","pathname","search"];p4.default=(t,e)=>{var r,o;if(e.path){if(e.pathname)throw new TypeError("Parameters `path` and `pathname` are mutually exclusive.");if(e.search)throw new TypeError("Parameters `path` and `search` are mutually exclusive.");if(e.searchParams)throw new TypeError("Parameters `path` and `searchParams` are mutually exclusive.")}if(e.search&&e.searchParams)throw new TypeError("Parameters `search` and `searchParams` are mutually exclusive.");if(!t){if(!e.protocol)throw new TypeError("No URL protocol specified");t=`${e.protocol}//${(o=(r=e.hostname)!==null&&r!==void 0?r:e.host)!==null&&o!==void 0?o:""}`}let a=new Lit.URL(t);if(e.path){let n=e.path.indexOf("?");n===-1?e.pathname=e.path:(e.pathname=e.path.slice(0,n),e.search=e.path.slice(n+1)),delete e.path}for(let n of Oit)e[n]&&(a[n]=e[n].toString());return a}});var bae=_(g4=>{"use strict";Object.defineProperty(g4,"__esModule",{value:!0});var h4=class{constructor(){this.weakMap=new WeakMap,this.map=new Map}set(e,r){typeof e=="object"?this.weakMap.set(e,r):this.map.set(e,r)}get(e){return typeof e=="object"?this.weakMap.get(e):this.map.get(e)}has(e){return typeof e=="object"?this.weakMap.has(e):this.map.has(e)}};g4.default=h4});var m4=_(d4=>{"use strict";Object.defineProperty(d4,"__esModule",{value:!0});var Mit=async t=>{let e=[],r=0;for await(let o of t)e.push(o),r+=Buffer.byteLength(o);return Buffer.isBuffer(e[0])?Buffer.concat(e,r):Buffer.from(e.join(""))};d4.default=Mit});var kae=_(Pd=>{"use strict";Object.defineProperty(Pd,"__esModule",{value:!0});Pd.dnsLookupIpVersionToFamily=Pd.isDnsLookupIpVersion=void 0;var xae={auto:0,ipv4:4,ipv6:6};Pd.isDnsLookupIpVersion=t=>t in xae;Pd.dnsLookupIpVersionToFamily=t=>{if(Pd.isDnsLookupIpVersion(t))return xae[t];throw new Error("Invalid DNS lookup IP version")}});var y4=_(sb=>{"use strict";Object.defineProperty(sb,"__esModule",{value:!0});sb.isResponseOk=void 0;sb.isResponseOk=t=>{let{statusCode:e}=t,r=t.request.options.followRedirect?299:399;return e>=200&&e<=r||e===304}});var Fae=_(E4=>{"use strict";Object.defineProperty(E4,"__esModule",{value:!0});var Qae=new Set;E4.default=t=>{Qae.has(t)||(Qae.add(t),process.emitWarning(`Got: ${t}`,{type:"DeprecationWarning"}))}});var Rae=_(C4=>{"use strict";Object.defineProperty(C4,"__esModule",{value:!0});var Ai=Tf(),Uit=(t,e)=>{if(Ai.default.null_(t.encoding))throw new TypeError("To get a Buffer, set `options.responseType` to `buffer` instead");Ai.assert.any([Ai.default.string,Ai.default.undefined],t.encoding),Ai.assert.any([Ai.default.boolean,Ai.default.undefined],t.resolveBodyOnly),Ai.assert.any([Ai.default.boolean,Ai.default.undefined],t.methodRewriting),Ai.assert.any([Ai.default.boolean,Ai.default.undefined],t.isStream),Ai.assert.any([Ai.default.string,Ai.default.undefined],t.responseType),t.responseType===void 0&&(t.responseType="text");let{retry:r}=t;if(e?t.retry={...e.retry}:t.retry={calculateDelay:o=>o.computedValue,limit:0,methods:[],statusCodes:[],errorCodes:[],maxRetryAfter:void 0},Ai.default.object(r)?(t.retry={...t.retry,...r},t.retry.methods=[...new Set(t.retry.methods.map(o=>o.toUpperCase()))],t.retry.statusCodes=[...new Set(t.retry.statusCodes)],t.retry.errorCodes=[...new Set(t.retry.errorCodes)]):Ai.default.number(r)&&(t.retry.limit=r),Ai.default.undefined(t.retry.maxRetryAfter)&&(t.retry.maxRetryAfter=Math.min(...[t.timeout.request,t.timeout.connect].filter(Ai.default.number))),Ai.default.object(t.pagination)){e&&(t.pagination={...e.pagination,...t.pagination});let{pagination:o}=t;if(!Ai.default.function_(o.transform))throw new Error("`options.pagination.transform` must be implemented");if(!Ai.default.function_(o.shouldContinue))throw new Error("`options.pagination.shouldContinue` must be implemented");if(!Ai.default.function_(o.filter))throw new TypeError("`options.pagination.filter` must be implemented");if(!Ai.default.function_(o.paginate))throw new Error("`options.pagination.paginate` must be implemented")}return t.responseType==="json"&&t.headers.accept===void 0&&(t.headers.accept="application/json"),t};C4.default=Uit});var Tae=_(m1=>{"use strict";Object.defineProperty(m1,"__esModule",{value:!0});m1.retryAfterStatusCodes=void 0;m1.retryAfterStatusCodes=new Set([413,429,503]);var _it=({attemptCount:t,retryOptions:e,error:r,retryAfter:o})=>{if(t>e.limit)return 0;let a=e.methods.includes(r.options.method),n=e.errorCodes.includes(r.code),u=r.response&&e.statusCodes.includes(r.response.statusCode);if(!a||!n&&!u)return 0;if(r.response){if(o)return e.maxRetryAfter===void 0||o>e.maxRetryAfter?0:o;if(r.response.statusCode===413)return 0}let A=Math.random()*100;return 2**(t-1)*1e3+A};m1.default=_it});var C1=_(Bn=>{"use strict";Object.defineProperty(Bn,"__esModule",{value:!0});Bn.UnsupportedProtocolError=Bn.ReadError=Bn.TimeoutError=Bn.UploadError=Bn.CacheError=Bn.HTTPError=Bn.MaxRedirectsError=Bn.RequestError=Bn.setNonEnumerableProperties=Bn.knownHookEvents=Bn.withoutBody=Bn.kIsNormalizedAlready=void 0;var Nae=ve("util"),Lae=ve("stream"),Hit=ve("fs"),ah=ve("url"),Oae=ve("http"),w4=ve("http"),jit=ve("https"),Git=Zse(),qit=soe(),Mae=Ooe(),Yit=Hoe(),Wit=dae(),Kit=eb(),st=Tf(),Vit=wae(),Uae=a4(),Jit=u4(),_ae=vae(),zit=Sae(),Hae=Pae(),Xit=bae(),Zit=m4(),jae=kae(),$it=y4(),lh=Fae(),est=Rae(),tst=Tae(),I4,Zs=Symbol("request"),lb=Symbol("response"),PE=Symbol("responseSize"),bE=Symbol("downloadedSize"),xE=Symbol("bodySize"),kE=Symbol("uploadedSize"),ob=Symbol("serverResponsesPiped"),Gae=Symbol("unproxyEvents"),qae=Symbol("isFromCache"),B4=Symbol("cancelTimeouts"),Yae=Symbol("startedReading"),QE=Symbol("stopReading"),ab=Symbol("triggerRead"),ch=Symbol("body"),y1=Symbol("jobs"),Wae=Symbol("originalResponse"),Kae=Symbol("retryTimeout");Bn.kIsNormalizedAlready=Symbol("isNormalizedAlready");var rst=st.default.string(process.versions.brotli);Bn.withoutBody=new Set(["GET","HEAD"]);Bn.knownHookEvents=["init","beforeRequest","beforeRedirect","beforeError","beforeRetry","afterResponse"];function nst(t){for(let e in t){let r=t[e];if(!st.default.string(r)&&!st.default.number(r)&&!st.default.boolean(r)&&!st.default.null_(r)&&!st.default.undefined(r))throw new TypeError(`The \`searchParams\` value '${String(r)}' must be a string, number, boolean or null`)}}function ist(t){return st.default.object(t)&&!("statusCode"in t)}var v4=new Xit.default,sst=async t=>new Promise((e,r)=>{let o=a=>{r(a)};t.pending||e(),t.once("error",o),t.once("ready",()=>{t.off("error",o),e()})}),ost=new Set([300,301,302,303,304,307,308]),ast=["context","body","json","form"];Bn.setNonEnumerableProperties=(t,e)=>{let r={};for(let o of t)if(!!o)for(let a of ast)a in o&&(r[a]={writable:!0,configurable:!0,enumerable:!1,value:o[a]});Object.defineProperties(e,r)};var Vi=class extends Error{constructor(e,r,o){var a;if(super(e),Error.captureStackTrace(this,this.constructor),this.name="RequestError",this.code=r.code,o instanceof db?(Object.defineProperty(this,"request",{enumerable:!1,value:o}),Object.defineProperty(this,"response",{enumerable:!1,value:o[lb]}),Object.defineProperty(this,"options",{enumerable:!1,value:o.options})):Object.defineProperty(this,"options",{enumerable:!1,value:o}),this.timings=(a=this.request)===null||a===void 0?void 0:a.timings,st.default.string(r.stack)&&st.default.string(this.stack)){let n=this.stack.indexOf(this.message)+this.message.length,u=this.stack.slice(n).split(` +`).reverse(),A=r.stack.slice(r.stack.indexOf(r.message)+r.message.length).split(` +`).reverse();for(;A.length!==0&&A[0]===u[0];)u.shift();this.stack=`${this.stack.slice(0,n)}${u.reverse().join(` +`)}${A.reverse().join(` +`)}`}}};Bn.RequestError=Vi;var ub=class extends Vi{constructor(e){super(`Redirected ${e.options.maxRedirects} times. Aborting.`,{},e),this.name="MaxRedirectsError"}};Bn.MaxRedirectsError=ub;var Ab=class extends Vi{constructor(e){super(`Response code ${e.statusCode} (${e.statusMessage})`,{},e.request),this.name="HTTPError"}};Bn.HTTPError=Ab;var fb=class extends Vi{constructor(e,r){super(e.message,e,r),this.name="CacheError"}};Bn.CacheError=fb;var pb=class extends Vi{constructor(e,r){super(e.message,e,r),this.name="UploadError"}};Bn.UploadError=pb;var hb=class extends Vi{constructor(e,r,o){super(e.message,e,o),this.name="TimeoutError",this.event=e.event,this.timings=r}};Bn.TimeoutError=hb;var E1=class extends Vi{constructor(e,r){super(e.message,e,r),this.name="ReadError"}};Bn.ReadError=E1;var gb=class extends Vi{constructor(e){super(`Unsupported protocol "${e.url.protocol}"`,{},e),this.name="UnsupportedProtocolError"}};Bn.UnsupportedProtocolError=gb;var lst=["socket","connect","continue","information","upgrade","timeout"],db=class extends Lae.Duplex{constructor(e,r={},o){super({autoDestroy:!1,highWaterMark:0}),this[bE]=0,this[kE]=0,this.requestInitialized=!1,this[ob]=new Set,this.redirects=[],this[QE]=!1,this[ab]=!1,this[y1]=[],this.retryCount=0,this._progressCallbacks=[];let a=()=>this._unlockWrite(),n=()=>this._lockWrite();this.on("pipe",h=>{h.prependListener("data",a),h.on("data",n),h.prependListener("end",a),h.on("end",n)}),this.on("unpipe",h=>{h.off("data",a),h.off("data",n),h.off("end",a),h.off("end",n)}),this.on("pipe",h=>{h instanceof w4.IncomingMessage&&(this.options.headers={...h.headers,...this.options.headers})});let{json:u,body:A,form:p}=r;if((u||A||p)&&this._lockWrite(),Bn.kIsNormalizedAlready in r)this.options=r;else try{this.options=this.constructor.normalizeArguments(e,r,o)}catch(h){st.default.nodeStream(r.body)&&r.body.destroy(),this.destroy(h);return}(async()=>{var h;try{this.options.body instanceof Hit.ReadStream&&await sst(this.options.body);let{url:E}=this.options;if(!E)throw new TypeError("Missing `url` property");if(this.requestUrl=E.toString(),decodeURI(this.requestUrl),await this._finalizeBody(),await this._makeRequest(),this.destroyed){(h=this[Zs])===null||h===void 0||h.destroy();return}for(let I of this[y1])I();this[y1].length=0,this.requestInitialized=!0}catch(E){if(E instanceof Vi){this._beforeError(E);return}this.destroyed||this.destroy(E)}})()}static normalizeArguments(e,r,o){var a,n,u,A,p;let h=r;if(st.default.object(e)&&!st.default.urlInstance(e))r={...o,...e,...r};else{if(e&&r&&r.url!==void 0)throw new TypeError("The `url` option is mutually exclusive with the `input` argument");r={...o,...r},e!==void 0&&(r.url=e),st.default.urlInstance(r.url)&&(r.url=new ah.URL(r.url.toString()))}if(r.cache===!1&&(r.cache=void 0),r.dnsCache===!1&&(r.dnsCache=void 0),st.assert.any([st.default.string,st.default.undefined],r.method),st.assert.any([st.default.object,st.default.undefined],r.headers),st.assert.any([st.default.string,st.default.urlInstance,st.default.undefined],r.prefixUrl),st.assert.any([st.default.object,st.default.undefined],r.cookieJar),st.assert.any([st.default.object,st.default.string,st.default.undefined],r.searchParams),st.assert.any([st.default.object,st.default.string,st.default.undefined],r.cache),st.assert.any([st.default.object,st.default.number,st.default.undefined],r.timeout),st.assert.any([st.default.object,st.default.undefined],r.context),st.assert.any([st.default.object,st.default.undefined],r.hooks),st.assert.any([st.default.boolean,st.default.undefined],r.decompress),st.assert.any([st.default.boolean,st.default.undefined],r.ignoreInvalidCookies),st.assert.any([st.default.boolean,st.default.undefined],r.followRedirect),st.assert.any([st.default.number,st.default.undefined],r.maxRedirects),st.assert.any([st.default.boolean,st.default.undefined],r.throwHttpErrors),st.assert.any([st.default.boolean,st.default.undefined],r.http2),st.assert.any([st.default.boolean,st.default.undefined],r.allowGetBody),st.assert.any([st.default.string,st.default.undefined],r.localAddress),st.assert.any([jae.isDnsLookupIpVersion,st.default.undefined],r.dnsLookupIpVersion),st.assert.any([st.default.object,st.default.undefined],r.https),st.assert.any([st.default.boolean,st.default.undefined],r.rejectUnauthorized),r.https&&(st.assert.any([st.default.boolean,st.default.undefined],r.https.rejectUnauthorized),st.assert.any([st.default.function_,st.default.undefined],r.https.checkServerIdentity),st.assert.any([st.default.string,st.default.object,st.default.array,st.default.undefined],r.https.certificateAuthority),st.assert.any([st.default.string,st.default.object,st.default.array,st.default.undefined],r.https.key),st.assert.any([st.default.string,st.default.object,st.default.array,st.default.undefined],r.https.certificate),st.assert.any([st.default.string,st.default.undefined],r.https.passphrase),st.assert.any([st.default.string,st.default.buffer,st.default.array,st.default.undefined],r.https.pfx)),st.assert.any([st.default.object,st.default.undefined],r.cacheOptions),st.default.string(r.method)?r.method=r.method.toUpperCase():r.method="GET",r.headers===o?.headers?r.headers={...r.headers}:r.headers=Kit({...o?.headers,...r.headers}),"slashes"in r)throw new TypeError("The legacy `url.Url` has been deprecated. Use `URL` instead.");if("auth"in r)throw new TypeError("Parameter `auth` is deprecated. Use `username` / `password` instead.");if("searchParams"in r&&r.searchParams&&r.searchParams!==o?.searchParams){let x;if(st.default.string(r.searchParams)||r.searchParams instanceof ah.URLSearchParams)x=new ah.URLSearchParams(r.searchParams);else{nst(r.searchParams),x=new ah.URLSearchParams;for(let C in r.searchParams){let R=r.searchParams[C];R===null?x.append(C,""):R!==void 0&&x.append(C,R)}}(a=o?.searchParams)===null||a===void 0||a.forEach((C,R)=>{x.has(R)||x.append(R,C)}),r.searchParams=x}if(r.username=(n=r.username)!==null&&n!==void 0?n:"",r.password=(u=r.password)!==null&&u!==void 0?u:"",st.default.undefined(r.prefixUrl)?r.prefixUrl=(A=o?.prefixUrl)!==null&&A!==void 0?A:"":(r.prefixUrl=r.prefixUrl.toString(),r.prefixUrl!==""&&!r.prefixUrl.endsWith("/")&&(r.prefixUrl+="/")),st.default.string(r.url)){if(r.url.startsWith("/"))throw new Error("`input` must not start with a slash when using `prefixUrl`");r.url=Hae.default(r.prefixUrl+r.url,r)}else(st.default.undefined(r.url)&&r.prefixUrl!==""||r.protocol)&&(r.url=Hae.default(r.prefixUrl,r));if(r.url){"port"in r&&delete r.port;let{prefixUrl:x}=r;Object.defineProperty(r,"prefixUrl",{set:R=>{let L=r.url;if(!L.href.startsWith(R))throw new Error(`Cannot change \`prefixUrl\` from ${x} to ${R}: ${L.href}`);r.url=new ah.URL(R+L.href.slice(x.length)),x=R},get:()=>x});let{protocol:C}=r.url;if(C==="unix:"&&(C="http:",r.url=new ah.URL(`http://unix${r.url.pathname}${r.url.search}`)),r.searchParams&&(r.url.search=r.searchParams.toString()),C!=="http:"&&C!=="https:")throw new gb(r);r.username===""?r.username=r.url.username:r.url.username=r.username,r.password===""?r.password=r.url.password:r.url.password=r.password}let{cookieJar:E}=r;if(E){let{setCookie:x,getCookieString:C}=E;st.assert.function_(x),st.assert.function_(C),x.length===4&&C.length===0&&(x=Nae.promisify(x.bind(r.cookieJar)),C=Nae.promisify(C.bind(r.cookieJar)),r.cookieJar={setCookie:x,getCookieString:C})}let{cache:I}=r;if(I&&(v4.has(I)||v4.set(I,new Mae((x,C)=>{let R=x[Zs](x,C);return st.default.promise(R)&&(R.once=(L,U)=>{if(L==="error")R.catch(U);else if(L==="abort")(async()=>{try{(await R).once("abort",U)}catch{}})();else throw new Error(`Unknown HTTP2 promise event: ${L}`);return R}),R},I))),r.cacheOptions={...r.cacheOptions},r.dnsCache===!0)I4||(I4=new qit.default),r.dnsCache=I4;else if(!st.default.undefined(r.dnsCache)&&!r.dnsCache.lookup)throw new TypeError(`Parameter \`dnsCache\` must be a CacheableLookup instance or a boolean, got ${st.default(r.dnsCache)}`);st.default.number(r.timeout)?r.timeout={request:r.timeout}:o&&r.timeout!==o.timeout?r.timeout={...o.timeout,...r.timeout}:r.timeout={...r.timeout},r.context||(r.context={});let v=r.hooks===o?.hooks;r.hooks={...r.hooks};for(let x of Bn.knownHookEvents)if(x in r.hooks)if(st.default.array(r.hooks[x]))r.hooks[x]=[...r.hooks[x]];else throw new TypeError(`Parameter \`${x}\` must be an Array, got ${st.default(r.hooks[x])}`);else r.hooks[x]=[];if(o&&!v)for(let x of Bn.knownHookEvents)o.hooks[x].length>0&&(r.hooks[x]=[...o.hooks[x],...r.hooks[x]]);if("family"in r&&lh.default('"options.family" was never documented, please use "options.dnsLookupIpVersion"'),o?.https&&(r.https={...o.https,...r.https}),"rejectUnauthorized"in r&&lh.default('"options.rejectUnauthorized" is now deprecated, please use "options.https.rejectUnauthorized"'),"checkServerIdentity"in r&&lh.default('"options.checkServerIdentity" was never documented, please use "options.https.checkServerIdentity"'),"ca"in r&&lh.default('"options.ca" was never documented, please use "options.https.certificateAuthority"'),"key"in r&&lh.default('"options.key" was never documented, please use "options.https.key"'),"cert"in r&&lh.default('"options.cert" was never documented, please use "options.https.certificate"'),"passphrase"in r&&lh.default('"options.passphrase" was never documented, please use "options.https.passphrase"'),"pfx"in r&&lh.default('"options.pfx" was never documented, please use "options.https.pfx"'),"followRedirects"in r)throw new TypeError("The `followRedirects` option does not exist. Use `followRedirect` instead.");if(r.agent){for(let x in r.agent)if(x!=="http"&&x!=="https"&&x!=="http2")throw new TypeError(`Expected the \`options.agent\` properties to be \`http\`, \`https\` or \`http2\`, got \`${x}\``)}return r.maxRedirects=(p=r.maxRedirects)!==null&&p!==void 0?p:0,Bn.setNonEnumerableProperties([o,h],r),est.default(r,o)}_lockWrite(){let e=()=>{throw new TypeError("The payload has been already provided")};this.write=e,this.end=e}_unlockWrite(){this.write=super.write,this.end=super.end}async _finalizeBody(){let{options:e}=this,{headers:r}=e,o=!st.default.undefined(e.form),a=!st.default.undefined(e.json),n=!st.default.undefined(e.body),u=o||a||n,A=Bn.withoutBody.has(e.method)&&!(e.method==="GET"&&e.allowGetBody);if(this._cannotHaveBody=A,u){if(A)throw new TypeError(`The \`${e.method}\` method cannot be used with a body`);if([n,o,a].filter(p=>p).length>1)throw new TypeError("The `body`, `json` and `form` options are mutually exclusive");if(n&&!(e.body instanceof Lae.Readable)&&!st.default.string(e.body)&&!st.default.buffer(e.body)&&!Uae.default(e.body))throw new TypeError("The `body` option must be a stream.Readable, string or Buffer");if(o&&!st.default.object(e.form))throw new TypeError("The `form` option must be an Object");{let p=!st.default.string(r["content-type"]);n?(Uae.default(e.body)&&p&&(r["content-type"]=`multipart/form-data; boundary=${e.body.getBoundary()}`),this[ch]=e.body):o?(p&&(r["content-type"]="application/x-www-form-urlencoded"),this[ch]=new ah.URLSearchParams(e.form).toString()):(p&&(r["content-type"]="application/json"),this[ch]=e.stringifyJson(e.json));let h=await Vit.default(this[ch],e.headers);st.default.undefined(r["content-length"])&&st.default.undefined(r["transfer-encoding"])&&!A&&!st.default.undefined(h)&&(r["content-length"]=String(h))}}else A?this._lockWrite():this._unlockWrite();this[xE]=Number(r["content-length"])||void 0}async _onResponseBase(e){let{options:r}=this,{url:o}=r;this[Wae]=e,r.decompress&&(e=Yit(e));let a=e.statusCode,n=e;n.statusMessage=n.statusMessage?n.statusMessage:Oae.STATUS_CODES[a],n.url=r.url.toString(),n.requestUrl=this.requestUrl,n.redirectUrls=this.redirects,n.request=this,n.isFromCache=e.fromCache||!1,n.ip=this.ip,n.retryCount=this.retryCount,this[qae]=n.isFromCache,this[PE]=Number(e.headers["content-length"])||void 0,this[lb]=e,e.once("end",()=>{this[PE]=this[bE],this.emit("downloadProgress",this.downloadProgress)}),e.once("error",A=>{e.destroy(),this._beforeError(new E1(A,this))}),e.once("aborted",()=>{this._beforeError(new E1({name:"Error",message:"The server aborted pending request",code:"ECONNRESET"},this))}),this.emit("downloadProgress",this.downloadProgress);let u=e.headers["set-cookie"];if(st.default.object(r.cookieJar)&&u){let A=u.map(async p=>r.cookieJar.setCookie(p,o.toString()));r.ignoreInvalidCookies&&(A=A.map(async p=>p.catch(()=>{})));try{await Promise.all(A)}catch(p){this._beforeError(p);return}}if(r.followRedirect&&e.headers.location&&ost.has(a)){if(e.resume(),this[Zs]&&(this[B4](),delete this[Zs],this[Gae]()),(a===303&&r.method!=="GET"&&r.method!=="HEAD"||!r.methodRewriting)&&(r.method="GET","body"in r&&delete r.body,"json"in r&&delete r.json,"form"in r&&delete r.form,this[ch]=void 0,delete r.headers["content-length"]),this.redirects.length>=r.maxRedirects){this._beforeError(new ub(this));return}try{let p=Buffer.from(e.headers.location,"binary").toString(),h=new ah.URL(p,o),E=h.toString();decodeURI(E),h.hostname!==o.hostname||h.port!==o.port?("host"in r.headers&&delete r.headers.host,"cookie"in r.headers&&delete r.headers.cookie,"authorization"in r.headers&&delete r.headers.authorization,(r.username||r.password)&&(r.username="",r.password="")):(h.username=r.username,h.password=r.password),this.redirects.push(E),r.url=h;for(let I of r.hooks.beforeRedirect)await I(r,n);this.emit("redirect",n,r),await this._makeRequest()}catch(p){this._beforeError(p);return}return}if(r.isStream&&r.throwHttpErrors&&!$it.isResponseOk(n)){this._beforeError(new Ab(n));return}e.on("readable",()=>{this[ab]&&this._read()}),this.on("resume",()=>{e.resume()}),this.on("pause",()=>{e.pause()}),e.once("end",()=>{this.push(null)}),this.emit("response",e);for(let A of this[ob])if(!A.headersSent){for(let p in e.headers){let h=r.decompress?p!=="content-encoding":!0,E=e.headers[p];h&&A.setHeader(p,E)}A.statusCode=a}}async _onResponse(e){try{await this._onResponseBase(e)}catch(r){this._beforeError(r)}}_onRequest(e){let{options:r}=this,{timeout:o,url:a}=r;Git.default(e),this[B4]=_ae.default(e,o,a);let n=r.cache?"cacheableResponse":"response";e.once(n,p=>{this._onResponse(p)}),e.once("error",p=>{var h;e.destroy(),(h=e.res)===null||h===void 0||h.removeAllListeners("end"),p=p instanceof _ae.TimeoutError?new hb(p,this.timings,this):new Vi(p.message,p,this),this._beforeError(p)}),this[Gae]=Jit.default(e,this,lst),this[Zs]=e,this.emit("uploadProgress",this.uploadProgress);let u=this[ch],A=this.redirects.length===0?this:e;st.default.nodeStream(u)?(u.pipe(A),u.once("error",p=>{this._beforeError(new pb(p,this))})):(this._unlockWrite(),st.default.undefined(u)?(this._cannotHaveBody||this._noPipe)&&(A.end(),this._lockWrite()):(this._writeRequest(u,void 0,()=>{}),A.end(),this._lockWrite())),this.emit("request",e)}async _createCacheableRequest(e,r){return new Promise((o,a)=>{Object.assign(r,zit.default(e)),delete r.url;let n,u=v4.get(r.cache)(r,async A=>{A._readableState.autoDestroy=!1,n&&(await n).emit("cacheableResponse",A),o(A)});r.url=e,u.once("error",a),u.once("request",async A=>{n=A,o(n)})})}async _makeRequest(){var e,r,o,a,n;let{options:u}=this,{headers:A}=u;for(let U in A)if(st.default.undefined(A[U]))delete A[U];else if(st.default.null_(A[U]))throw new TypeError(`Use \`undefined\` instead of \`null\` to delete the \`${U}\` header`);if(u.decompress&&st.default.undefined(A["accept-encoding"])&&(A["accept-encoding"]=rst?"gzip, deflate, br":"gzip, deflate"),u.cookieJar){let U=await u.cookieJar.getCookieString(u.url.toString());st.default.nonEmptyString(U)&&(u.headers.cookie=U)}for(let U of u.hooks.beforeRequest){let J=await U(u);if(!st.default.undefined(J)){u.request=()=>J;break}}u.body&&this[ch]!==u.body&&(this[ch]=u.body);let{agent:p,request:h,timeout:E,url:I}=u;if(u.dnsCache&&!("lookup"in u)&&(u.lookup=u.dnsCache.lookup),I.hostname==="unix"){let U=/(?<socketPath>.+?):(?<path>.+)/.exec(`${I.pathname}${I.search}`);if(U?.groups){let{socketPath:J,path:te}=U.groups;Object.assign(u,{socketPath:J,path:te,host:""})}}let v=I.protocol==="https:",x;u.http2?x=Wit.auto:x=v?jit.request:Oae.request;let C=(e=u.request)!==null&&e!==void 0?e:x,R=u.cache?this._createCacheableRequest:C;p&&!u.http2&&(u.agent=p[v?"https":"http"]),u[Zs]=C,delete u.request,delete u.timeout;let L=u;if(L.shared=(r=u.cacheOptions)===null||r===void 0?void 0:r.shared,L.cacheHeuristic=(o=u.cacheOptions)===null||o===void 0?void 0:o.cacheHeuristic,L.immutableMinTimeToLive=(a=u.cacheOptions)===null||a===void 0?void 0:a.immutableMinTimeToLive,L.ignoreCargoCult=(n=u.cacheOptions)===null||n===void 0?void 0:n.ignoreCargoCult,u.dnsLookupIpVersion!==void 0)try{L.family=jae.dnsLookupIpVersionToFamily(u.dnsLookupIpVersion)}catch{throw new Error("Invalid `dnsLookupIpVersion` option value")}u.https&&("rejectUnauthorized"in u.https&&(L.rejectUnauthorized=u.https.rejectUnauthorized),u.https.checkServerIdentity&&(L.checkServerIdentity=u.https.checkServerIdentity),u.https.certificateAuthority&&(L.ca=u.https.certificateAuthority),u.https.certificate&&(L.cert=u.https.certificate),u.https.key&&(L.key=u.https.key),u.https.passphrase&&(L.passphrase=u.https.passphrase),u.https.pfx&&(L.pfx=u.https.pfx));try{let U=await R(I,L);st.default.undefined(U)&&(U=x(I,L)),u.request=h,u.timeout=E,u.agent=p,u.https&&("rejectUnauthorized"in u.https&&delete L.rejectUnauthorized,u.https.checkServerIdentity&&delete L.checkServerIdentity,u.https.certificateAuthority&&delete L.ca,u.https.certificate&&delete L.cert,u.https.key&&delete L.key,u.https.passphrase&&delete L.passphrase,u.https.pfx&&delete L.pfx),ist(U)?this._onRequest(U):this.writable?(this.once("finish",()=>{this._onResponse(U)}),this._unlockWrite(),this.end(),this._lockWrite()):this._onResponse(U)}catch(U){throw U instanceof Mae.CacheError?new fb(U,this):new Vi(U.message,U,this)}}async _error(e){try{for(let r of this.options.hooks.beforeError)e=await r(e)}catch(r){e=new Vi(r.message,r,this)}this.destroy(e)}_beforeError(e){if(this[QE])return;let{options:r}=this,o=this.retryCount+1;this[QE]=!0,e instanceof Vi||(e=new Vi(e.message,e,this));let a=e,{response:n}=a;(async()=>{if(n&&!n.body){n.setEncoding(this._readableState.encoding);try{n.rawBody=await Zit.default(n),n.body=n.rawBody.toString()}catch{}}if(this.listenerCount("retry")!==0){let u;try{let A;n&&"retry-after"in n.headers&&(A=Number(n.headers["retry-after"]),Number.isNaN(A)?(A=Date.parse(n.headers["retry-after"])-Date.now(),A<=0&&(A=1)):A*=1e3),u=await r.retry.calculateDelay({attemptCount:o,retryOptions:r.retry,error:a,retryAfter:A,computedValue:tst.default({attemptCount:o,retryOptions:r.retry,error:a,retryAfter:A,computedValue:0})})}catch(A){this._error(new Vi(A.message,A,this));return}if(u){let A=async()=>{try{for(let p of this.options.hooks.beforeRetry)await p(this.options,a,o)}catch(p){this._error(new Vi(p.message,e,this));return}this.destroyed||(this.destroy(),this.emit("retry",o,e))};this[Kae]=setTimeout(A,u);return}}this._error(a)})()}_read(){this[ab]=!0;let e=this[lb];if(e&&!this[QE]){e.readableLength&&(this[ab]=!1);let r;for(;(r=e.read())!==null;){this[bE]+=r.length,this[Yae]=!0;let o=this.downloadProgress;o.percent<1&&this.emit("downloadProgress",o),this.push(r)}}}_write(e,r,o){let a=()=>{this._writeRequest(e,r,o)};this.requestInitialized?a():this[y1].push(a)}_writeRequest(e,r,o){this[Zs].destroyed||(this._progressCallbacks.push(()=>{this[kE]+=Buffer.byteLength(e,r);let a=this.uploadProgress;a.percent<1&&this.emit("uploadProgress",a)}),this[Zs].write(e,r,a=>{!a&&this._progressCallbacks.length>0&&this._progressCallbacks.shift()(),o(a)}))}_final(e){let r=()=>{for(;this._progressCallbacks.length!==0;)this._progressCallbacks.shift()();if(!(Zs in this)){e();return}if(this[Zs].destroyed){e();return}this[Zs].end(o=>{o||(this[xE]=this[kE],this.emit("uploadProgress",this.uploadProgress),this[Zs].emit("upload-complete")),e(o)})};this.requestInitialized?r():this[y1].push(r)}_destroy(e,r){var o;this[QE]=!0,clearTimeout(this[Kae]),Zs in this&&(this[B4](),!((o=this[lb])===null||o===void 0)&&o.complete||this[Zs].destroy()),e!==null&&!st.default.undefined(e)&&!(e instanceof Vi)&&(e=new Vi(e.message,e,this)),r(e)}get _isAboutToError(){return this[QE]}get ip(){var e;return(e=this.socket)===null||e===void 0?void 0:e.remoteAddress}get aborted(){var e,r,o;return((r=(e=this[Zs])===null||e===void 0?void 0:e.destroyed)!==null&&r!==void 0?r:this.destroyed)&&!(!((o=this[Wae])===null||o===void 0)&&o.complete)}get socket(){var e,r;return(r=(e=this[Zs])===null||e===void 0?void 0:e.socket)!==null&&r!==void 0?r:void 0}get downloadProgress(){let e;return this[PE]?e=this[bE]/this[PE]:this[PE]===this[bE]?e=1:e=0,{percent:e,transferred:this[bE],total:this[PE]}}get uploadProgress(){let e;return this[xE]?e=this[kE]/this[xE]:this[xE]===this[kE]?e=1:e=0,{percent:e,transferred:this[kE],total:this[xE]}}get timings(){var e;return(e=this[Zs])===null||e===void 0?void 0:e.timings}get isFromCache(){return this[qae]}pipe(e,r){if(this[Yae])throw new Error("Failed to pipe. The response has been emitted already.");return e instanceof w4.ServerResponse&&this[ob].add(e),super.pipe(e,r)}unpipe(e){return e instanceof w4.ServerResponse&&this[ob].delete(e),super.unpipe(e),this}};Bn.default=db});var w1=_(Gc=>{"use strict";var cst=Gc&&Gc.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),ust=Gc&&Gc.__exportStar||function(t,e){for(var r in t)r!=="default"&&!Object.prototype.hasOwnProperty.call(e,r)&&cst(e,t,r)};Object.defineProperty(Gc,"__esModule",{value:!0});Gc.CancelError=Gc.ParseError=void 0;var Vae=C1(),D4=class extends Vae.RequestError{constructor(e,r){let{options:o}=r.request;super(`${e.message} in "${o.url.toString()}"`,e,r.request),this.name="ParseError"}};Gc.ParseError=D4;var S4=class extends Vae.RequestError{constructor(e){super("Promise was canceled",{},e),this.name="CancelError"}get isCanceled(){return!0}};Gc.CancelError=S4;ust(C1(),Gc)});var zae=_(P4=>{"use strict";Object.defineProperty(P4,"__esModule",{value:!0});var Jae=w1(),Ast=(t,e,r,o)=>{let{rawBody:a}=t;try{if(e==="text")return a.toString(o);if(e==="json")return a.length===0?"":r(a.toString());if(e==="buffer")return a;throw new Jae.ParseError({message:`Unknown body type '${e}'`,name:"Error"},t)}catch(n){throw new Jae.ParseError(n,t)}};P4.default=Ast});var b4=_(uh=>{"use strict";var fst=uh&&uh.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),pst=uh&&uh.__exportStar||function(t,e){for(var r in t)r!=="default"&&!Object.prototype.hasOwnProperty.call(e,r)&&fst(e,t,r)};Object.defineProperty(uh,"__esModule",{value:!0});var hst=ve("events"),gst=Tf(),dst=zse(),mb=w1(),Xae=zae(),Zae=C1(),mst=u4(),yst=m4(),$ae=y4(),Est=["request","response","redirect","uploadProgress","downloadProgress"];function ele(t){let e,r,o=new hst.EventEmitter,a=new dst((u,A,p)=>{let h=E=>{let I=new Zae.default(void 0,t);I.retryCount=E,I._noPipe=!0,p(()=>I.destroy()),p.shouldReject=!1,p(()=>A(new mb.CancelError(I))),e=I,I.once("response",async C=>{var R;if(C.retryCount=E,C.request.aborted)return;let L;try{L=await yst.default(I),C.rawBody=L}catch{return}if(I._isAboutToError)return;let U=((R=C.headers["content-encoding"])!==null&&R!==void 0?R:"").toLowerCase(),J=["gzip","deflate","br"].includes(U),{options:te}=I;if(J&&!te.decompress)C.body=L;else try{C.body=Xae.default(C,te.responseType,te.parseJson,te.encoding)}catch(ae){if(C.body=L.toString(),$ae.isResponseOk(C)){I._beforeError(ae);return}}try{for(let[ae,fe]of te.hooks.afterResponse.entries())C=await fe(C,async ce=>{let me=Zae.default.normalizeArguments(void 0,{...ce,retry:{calculateDelay:()=>0},throwHttpErrors:!1,resolveBodyOnly:!1},te);me.hooks.afterResponse=me.hooks.afterResponse.slice(0,ae);for(let Be of me.hooks.beforeRetry)await Be(me);let he=ele(me);return p(()=>{he.catch(()=>{}),he.cancel()}),he})}catch(ae){I._beforeError(new mb.RequestError(ae.message,ae,I));return}if(!$ae.isResponseOk(C)){I._beforeError(new mb.HTTPError(C));return}r=C,u(I.options.resolveBodyOnly?C.body:C)});let v=C=>{if(a.isCanceled)return;let{options:R}=I;if(C instanceof mb.HTTPError&&!R.throwHttpErrors){let{response:L}=C;u(I.options.resolveBodyOnly?L.body:L);return}A(C)};I.once("error",v);let x=I.options.body;I.once("retry",(C,R)=>{var L,U;if(x===((L=R.request)===null||L===void 0?void 0:L.options.body)&&gst.default.nodeStream((U=R.request)===null||U===void 0?void 0:U.options.body)){v(R);return}h(C)}),mst.default(I,o,Est)};h(0)});a.on=(u,A)=>(o.on(u,A),a);let n=u=>{let A=(async()=>{await a;let{options:p}=r.request;return Xae.default(r,u,p.parseJson,p.encoding)})();return Object.defineProperties(A,Object.getOwnPropertyDescriptors(a)),A};return a.json=()=>{let{headers:u}=e.options;return!e.writableFinished&&u.accept===void 0&&(u.accept="application/json"),n("json")},a.buffer=()=>n("buffer"),a.text=()=>n("text"),a}uh.default=ele;pst(w1(),uh)});var tle=_(x4=>{"use strict";Object.defineProperty(x4,"__esModule",{value:!0});var Cst=w1();function wst(t,...e){let r=(async()=>{if(t instanceof Cst.RequestError)try{for(let a of e)if(a)for(let n of a)t=await n(t)}catch(a){t=a}throw t})(),o=()=>r;return r.json=o,r.text=o,r.buffer=o,r.on=o,r}x4.default=wst});var ile=_(k4=>{"use strict";Object.defineProperty(k4,"__esModule",{value:!0});var rle=Tf();function nle(t){for(let e of Object.values(t))(rle.default.plainObject(e)||rle.default.array(e))&&nle(e);return Object.freeze(t)}k4.default=nle});var ole=_(sle=>{"use strict";Object.defineProperty(sle,"__esModule",{value:!0})});var Q4=_(Vl=>{"use strict";var Ist=Vl&&Vl.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),Bst=Vl&&Vl.__exportStar||function(t,e){for(var r in t)r!=="default"&&!Object.prototype.hasOwnProperty.call(e,r)&&Ist(e,t,r)};Object.defineProperty(Vl,"__esModule",{value:!0});Vl.defaultHandler=void 0;var ale=Tf(),Kl=b4(),vst=tle(),Eb=C1(),Dst=ile(),Sst={RequestError:Kl.RequestError,CacheError:Kl.CacheError,ReadError:Kl.ReadError,HTTPError:Kl.HTTPError,MaxRedirectsError:Kl.MaxRedirectsError,TimeoutError:Kl.TimeoutError,ParseError:Kl.ParseError,CancelError:Kl.CancelError,UnsupportedProtocolError:Kl.UnsupportedProtocolError,UploadError:Kl.UploadError},Pst=async t=>new Promise(e=>{setTimeout(e,t)}),{normalizeArguments:yb}=Eb.default,lle=(...t)=>{let e;for(let r of t)e=yb(void 0,r,e);return e},bst=t=>t.isStream?new Eb.default(void 0,t):Kl.default(t),xst=t=>"defaults"in t&&"options"in t.defaults,kst=["get","post","put","patch","head","delete"];Vl.defaultHandler=(t,e)=>e(t);var cle=(t,e)=>{if(t)for(let r of t)r(e)},ule=t=>{t._rawHandlers=t.handlers,t.handlers=t.handlers.map(o=>(a,n)=>{let u,A=o(a,p=>(u=n(p),u));if(A!==u&&!a.isStream&&u){let p=A,{then:h,catch:E,finally:I}=p;Object.setPrototypeOf(p,Object.getPrototypeOf(u)),Object.defineProperties(p,Object.getOwnPropertyDescriptors(u)),p.then=h,p.catch=E,p.finally=I}return A});let e=(o,a={},n)=>{var u,A;let p=0,h=E=>t.handlers[p++](E,p===t.handlers.length?bst:h);if(ale.default.plainObject(o)){let E={...o,...a};Eb.setNonEnumerableProperties([o,a],E),a=E,o=void 0}try{let E;try{cle(t.options.hooks.init,a),cle((u=a.hooks)===null||u===void 0?void 0:u.init,a)}catch(v){E=v}let I=yb(o,a,n??t.options);if(I[Eb.kIsNormalizedAlready]=!0,E)throw new Kl.RequestError(E.message,E,I);return h(I)}catch(E){if(a.isStream)throw E;return vst.default(E,t.options.hooks.beforeError,(A=a.hooks)===null||A===void 0?void 0:A.beforeError)}};e.extend=(...o)=>{let a=[t.options],n=[...t._rawHandlers],u;for(let A of o)xst(A)?(a.push(A.defaults.options),n.push(...A.defaults._rawHandlers),u=A.defaults.mutableDefaults):(a.push(A),"handlers"in A&&n.push(...A.handlers),u=A.mutableDefaults);return n=n.filter(A=>A!==Vl.defaultHandler),n.length===0&&n.push(Vl.defaultHandler),ule({options:lle(...a),handlers:n,mutableDefaults:Boolean(u)})};let r=async function*(o,a){let n=yb(o,a,t.options);n.resolveBodyOnly=!1;let u=n.pagination;if(!ale.default.object(u))throw new TypeError("`options.pagination` must be implemented");let A=[],{countLimit:p}=u,h=0;for(;h<u.requestLimit;){h!==0&&await Pst(u.backoff);let E=await e(void 0,void 0,n),I=await u.transform(E),v=[];for(let C of I)if(u.filter(C,A,v)&&(!u.shouldContinue(C,A,v)||(yield C,u.stackAllItems&&A.push(C),v.push(C),--p<=0)))return;let x=u.paginate(E,A,v);if(x===!1)return;x===E.request.options?n=E.request.options:x!==void 0&&(n=yb(void 0,x,n)),h++}};e.paginate=r,e.paginate.all=async(o,a)=>{let n=[];for await(let u of r(o,a))n.push(u);return n},e.paginate.each=r,e.stream=(o,a)=>e(o,{...a,isStream:!0});for(let o of kst)e[o]=(a,n)=>e(a,{...n,method:o}),e.stream[o]=(a,n)=>e(a,{...n,method:o,isStream:!0});return Object.assign(e,Sst),Object.defineProperty(e,"defaults",{value:t.mutableDefaults?t:Dst.default(t),writable:t.mutableDefaults,configurable:t.mutableDefaults,enumerable:!0}),e.mergeOptions=lle,e};Vl.default=ule;Bst(ole(),Vl)});var ple=_((Nf,Cb)=>{"use strict";var Qst=Nf&&Nf.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),Ale=Nf&&Nf.__exportStar||function(t,e){for(var r in t)r!=="default"&&!Object.prototype.hasOwnProperty.call(e,r)&&Qst(e,t,r)};Object.defineProperty(Nf,"__esModule",{value:!0});var Fst=ve("url"),fle=Q4(),Rst={options:{method:"GET",retry:{limit:2,methods:["GET","PUT","HEAD","DELETE","OPTIONS","TRACE"],statusCodes:[408,413,429,500,502,503,504,521,522,524],errorCodes:["ETIMEDOUT","ECONNRESET","EADDRINUSE","ECONNREFUSED","EPIPE","ENOTFOUND","ENETUNREACH","EAI_AGAIN"],maxRetryAfter:void 0,calculateDelay:({computedValue:t})=>t},timeout:{},headers:{"user-agent":"got (https://github.com/sindresorhus/got)"},hooks:{init:[],beforeRequest:[],beforeRedirect:[],beforeRetry:[],beforeError:[],afterResponse:[]},cache:void 0,dnsCache:void 0,decompress:!0,throwHttpErrors:!0,followRedirect:!0,isStream:!1,responseType:"text",resolveBodyOnly:!1,maxRedirects:10,prefixUrl:"",methodRewriting:!0,ignoreInvalidCookies:!1,context:{},http2:!1,allowGetBody:!1,https:void 0,pagination:{transform:t=>t.request.options.responseType==="json"?t.body:JSON.parse(t.body),paginate:t=>{if(!Reflect.has(t.headers,"link"))return!1;let e=t.headers.link.split(","),r;for(let o of e){let a=o.split(";");if(a[1].includes("next")){r=a[0].trimStart().trim(),r=r.slice(1,-1);break}}return r?{url:new Fst.URL(r)}:!1},filter:()=>!0,shouldContinue:()=>!0,countLimit:1/0,backoff:0,requestLimit:1e4,stackAllItems:!0},parseJson:t=>JSON.parse(t),stringifyJson:t=>JSON.stringify(t),cacheOptions:{}},handlers:[fle.defaultHandler],mutableDefaults:!1},F4=fle.default(Rst);Nf.default=F4;Cb.exports=F4;Cb.exports.default=F4;Cb.exports.__esModule=!0;Ale(Q4(),Nf);Ale(b4(),Nf)});var nn={};Vt(nn,{Method:()=>Cle,del:()=>Mst,get:()=>L4,getNetworkSettings:()=>Ele,post:()=>O4,put:()=>Ost,request:()=>I1});function dle(t){let e=new URL(t),r={host:e.hostname,headers:{}};return e.port&&(r.port=Number(e.port)),e.username&&e.password&&(r.proxyAuth=`${e.username}:${e.password}`),{proxy:r}}async function R4(t){return ol(gle,t,()=>oe.readFilePromise(t).then(e=>(gle.set(t,e),e)))}function Lst({statusCode:t,statusMessage:e},r){let o=Mt(r,t,yt.NUMBER),a=`https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/${t}`;return Zy(r,`${o}${e?` (${e})`:""}`,a)}async function wb(t,{configuration:e,customErrorMessage:r}){try{return await t}catch(o){if(o.name!=="HTTPError")throw o;let a=r?.(o,e)??o.response.body?.error;a==null&&(o.message.startsWith("Response code")?a="The remote server failed to provide the requested resource":a=o.message),o.code==="ETIMEDOUT"&&o.event==="socket"&&(a+=`(can be increased via ${Mt(e,"httpTimeout",yt.SETTING)})`);let n=new zt(35,a,u=>{o.response&&u.reportError(35,` ${zu(e,{label:"Response Code",value:_c(yt.NO_HINT,Lst(o.response,e))})}`),o.request&&(u.reportError(35,` ${zu(e,{label:"Request Method",value:_c(yt.NO_HINT,o.request.options.method)})}`),u.reportError(35,` ${zu(e,{label:"Request URL",value:_c(yt.URL,o.request.requestUrl)})}`)),o.request.redirects.length>0&&u.reportError(35,` ${zu(e,{label:"Request Redirects",value:_c(yt.NO_HINT,PL(e,o.request.redirects,yt.URL))})}`),o.request.retryCount===o.request.options.retry.limit&&u.reportError(35,` ${zu(e,{label:"Request Retry Count",value:_c(yt.NO_HINT,`${Mt(e,o.request.retryCount,yt.NUMBER)} (can be increased via ${Mt(e,"httpRetry",yt.SETTING)})`)})}`)});throw n.originalError=o,n}}function Ele(t,e){let r=[...e.configuration.get("networkSettings")].sort(([u],[A])=>A.length-u.length),o={enableNetwork:void 0,httpsCaFilePath:void 0,httpProxy:void 0,httpsProxy:void 0,httpsKeyFilePath:void 0,httpsCertFilePath:void 0},a=Object.keys(o),n=typeof t=="string"?new URL(t):t;for(let[u,A]of r)if(N4.default.isMatch(n.hostname,u))for(let p of a){let h=A.get(p);h!==null&&typeof o[p]>"u"&&(o[p]=h)}for(let u of a)typeof o[u]>"u"&&(o[u]=e.configuration.get(u));return o}async function I1(t,e,{configuration:r,headers:o,jsonRequest:a,jsonResponse:n,method:u="GET",wrapNetworkRequest:A}){let p={target:t,body:e,configuration:r,headers:o,jsonRequest:a,jsonResponse:n,method:u},h=async()=>await Ust(t,e,p),E=typeof A<"u"?await A(h,p):h;return await(await r.reduceHook(v=>v.wrapNetworkRequest,E,p))()}async function L4(t,{configuration:e,jsonResponse:r,customErrorMessage:o,wrapNetworkRequest:a,...n}){let u=()=>wb(I1(t,null,{configuration:e,wrapNetworkRequest:a,...n}),{configuration:e,customErrorMessage:o}).then(p=>p.body),A=await(typeof a<"u"?u():ol(hle,t,()=>u().then(p=>(hle.set(t,p),p))));return r?JSON.parse(A.toString()):A}async function Ost(t,e,{customErrorMessage:r,...o}){return(await wb(I1(t,e,{...o,method:"PUT"}),{customErrorMessage:r,configuration:o.configuration})).body}async function O4(t,e,{customErrorMessage:r,...o}){return(await wb(I1(t,e,{...o,method:"POST"}),{customErrorMessage:r,configuration:o.configuration})).body}async function Mst(t,{customErrorMessage:e,...r}){return(await wb(I1(t,null,{...r,method:"DELETE"}),{customErrorMessage:e,configuration:r.configuration})).body}async function Ust(t,e,{configuration:r,headers:o,jsonRequest:a,jsonResponse:n,method:u="GET"}){let A=typeof t=="string"?new URL(t):t,p=Ele(A,{configuration:r});if(p.enableNetwork===!1)throw new zt(80,`Request to '${A.href}' has been blocked because of your configuration settings`);if(A.protocol==="http:"&&!N4.default.isMatch(A.hostname,r.get("unsafeHttpWhitelist")))throw new zt(81,`Unsafe http requests must be explicitly whitelisted in your configuration (${A.hostname})`);let E={agent:{http:p.httpProxy?T4.default.httpOverHttp(dle(p.httpProxy)):Tst,https:p.httpsProxy?T4.default.httpsOverHttp(dle(p.httpsProxy)):Nst},headers:o,method:u};E.responseType=n?"json":"buffer",e!==null&&(Buffer.isBuffer(e)||!a&&typeof e=="string"?E.body=e:E.json=e);let I=r.get("httpTimeout"),v=r.get("httpRetry"),x=r.get("enableStrictSsl"),C=p.httpsCaFilePath,R=p.httpsCertFilePath,L=p.httpsKeyFilePath,{default:U}=await Promise.resolve().then(()=>$e(ple())),J=C?await R4(C):void 0,te=R?await R4(R):void 0,ae=L?await R4(L):void 0,fe=U.extend({timeout:{socket:I},retry:v,https:{rejectUnauthorized:x,certificateAuthority:J,certificate:te,key:ae},...E});return r.getLimit("networkConcurrency")(()=>fe(A))}var mle,yle,N4,T4,hle,gle,Tst,Nst,Cle,Ib=Et(()=>{St();mle=ve("https"),yle=ve("http"),N4=$e(Zo()),T4=$e(Yse());Yl();Gl();jl();hle=new Map,gle=new Map,Tst=new yle.Agent({keepAlive:!0}),Nst=new mle.Agent({keepAlive:!0});Cle=(a=>(a.GET="GET",a.PUT="PUT",a.POST="POST",a.DELETE="DELETE",a))(Cle||{})});var Ji={};Vt(Ji,{availableParallelism:()=>U4,getArchitecture:()=>B1,getArchitectureName:()=>qst,getArchitectureSet:()=>M4,getCaller:()=>Vst,major:()=>_st,openUrl:()=>Hst});function Gst(){if(process.platform==="darwin"||process.platform==="win32")return null;let t;try{t=oe.readFileSync(jst)}catch{}if(typeof t<"u"){if(t&&t.includes("GLIBC"))return"glibc";if(t&&t.includes("musl"))return"musl"}let r=(process.report?.getReport()??{}).sharedObjects??[],o=/\/(?:(ld-linux-|[^/]+-linux-gnu\/)|(libc.musl-|ld-musl-))/;return KI(r,a=>{let n=a.match(o);if(!n)return KI.skip;if(n[1])return"glibc";if(n[2])return"musl";throw new Error("Assertion failed: Expected the libc variant to have been detected")})??null}function B1(){return Ile=Ile??{os:process.platform,cpu:process.arch,libc:Gst()}}function qst(t=B1()){return t.libc?`${t.os}-${t.cpu}-${t.libc}`:`${t.os}-${t.cpu}`}function M4(){let t=B1();return Ble=Ble??{os:[t.os],cpu:[t.cpu],libc:t.libc?[t.libc]:[]}}function Kst(t){let e=Yst.exec(t);if(!e)return null;let r=e[2]&&e[2].indexOf("native")===0,o=e[2]&&e[2].indexOf("eval")===0,a=Wst.exec(e[2]);return o&&a!=null&&(e[2]=a[1],e[3]=a[2],e[4]=a[3]),{file:r?null:e[2],methodName:e[1]||"<unknown>",arguments:r?[e[2]]:[],line:e[3]?+e[3]:null,column:e[4]?+e[4]:null}}function Vst(){let e=new Error().stack.split(` +`)[3];return Kst(e)}function U4(){return typeof Bb.default.availableParallelism<"u"?Bb.default.availableParallelism():Math.max(1,Bb.default.cpus().length)}var Bb,_st,wle,Hst,jst,Ile,Ble,Yst,Wst,vb=Et(()=>{St();Bb=$e(ve("os"));Db();jl();_st=Number(process.versions.node.split(".")[0]),wle=new Map([["darwin","open"],["linux","xdg-open"],["win32","explorer.exe"]]).get(process.platform),Hst=typeof wle<"u"?async t=>{try{return await _4(wle,[t],{cwd:V.cwd()}),!0}catch{return!1}}:void 0,jst="/usr/bin/ldd";Yst=/^\s*at (.*?) ?\(((?:file|https?|blob|chrome-extension|native|eval|webpack|<anonymous>|\/|[a-z]:\\|\\\\).*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i,Wst=/\((\S*)(?::(\d+))(?::(\d+))\)/});function Y4(t,e,r,o,a){let n=A1(r);if(o.isArray||o.type==="ANY"&&Array.isArray(n))return Array.isArray(n)?n.map((u,A)=>H4(t,`${e}[${A}]`,u,o,a)):String(n).split(/,/).map(u=>H4(t,e,u,o,a));if(Array.isArray(n))throw new Error(`Non-array configuration settings "${e}" cannot be an array`);return H4(t,e,r,o,a)}function H4(t,e,r,o,a){let n=A1(r);switch(o.type){case"ANY":return qP(n);case"SHAPE":return Zst(t,e,r,o,a);case"MAP":return $st(t,e,r,o,a)}if(n===null&&!o.isNullable&&o.default!==null)throw new Error(`Non-nullable configuration settings "${e}" cannot be set to null`);if(o.values?.includes(n))return n;let A=(()=>{if(o.type==="BOOLEAN"&&typeof n!="string")return VI(n);if(typeof n!="string")throw new Error(`Expected configuration setting "${e}" to be a string, got ${typeof n}`);let p=sP(n,{env:t.env});switch(o.type){case"ABSOLUTE_PATH":{let h=a,E=mM(r);return E&&E[0]!=="<"&&(h=V.dirname(E)),V.resolve(h,ue.toPortablePath(p))}case"LOCATOR_LOOSE":return xf(p,!1);case"NUMBER":return parseInt(p);case"LOCATOR":return xf(p);case"BOOLEAN":return VI(p);default:return p}})();if(o.values&&!o.values.includes(A))throw new Error(`Invalid value, expected one of ${o.values.join(", ")}`);return A}function Zst(t,e,r,o,a){let n=A1(r);if(typeof n!="object"||Array.isArray(n))throw new it(`Object configuration settings "${e}" must be an object`);let u=W4(t,o,{ignoreArrays:!0});if(n===null)return u;for(let[A,p]of Object.entries(n)){let h=`${e}.${A}`;if(!o.properties[A])throw new it(`Unrecognized configuration settings found: ${e}.${A} - run "yarn config -v" to see the list of settings supported in Yarn`);u.set(A,Y4(t,h,p,o.properties[A],a))}return u}function $st(t,e,r,o,a){let n=A1(r),u=new Map;if(typeof n!="object"||Array.isArray(n))throw new it(`Map configuration settings "${e}" must be an object`);if(n===null)return u;for(let[A,p]of Object.entries(n)){let h=o.normalizeKeys?o.normalizeKeys(A):A,E=`${e}['${h}']`,I=o.valueDefinition;u.set(h,Y4(t,E,p,I,a))}return u}function W4(t,e,{ignoreArrays:r=!1}={}){switch(e.type){case"SHAPE":{if(e.isArray&&!r)return[];let o=new Map;for(let[a,n]of Object.entries(e.properties))o.set(a,W4(t,n));return o}case"MAP":return e.isArray&&!r?[]:new Map;case"ABSOLUTE_PATH":return e.default===null?null:t.projectCwd===null?Array.isArray(e.default)?e.default.map(o=>V.normalize(o)):V.isAbsolute(e.default)?V.normalize(e.default):e.isNullable?null:void 0:Array.isArray(e.default)?e.default.map(o=>V.resolve(t.projectCwd,o)):V.resolve(t.projectCwd,e.default);default:return e.default}}function Pb(t,e,r){if(e.type==="SECRET"&&typeof t=="string"&&r.hideSecrets)return Xst;if(e.type==="ABSOLUTE_PATH"&&typeof t=="string"&&r.getNativePaths)return ue.fromPortablePath(t);if(e.isArray&&Array.isArray(t)){let o=[];for(let a of t)o.push(Pb(a,e,r));return o}if(e.type==="MAP"&&t instanceof Map){if(t.size===0)return;let o=new Map;for(let[a,n]of t.entries()){let u=Pb(n,e.valueDefinition,r);typeof u<"u"&&o.set(a,u)}return o}if(e.type==="SHAPE"&&t instanceof Map){if(t.size===0)return;let o=new Map;for(let[a,n]of t.entries()){let u=e.properties[a],A=Pb(n,u,r);typeof A<"u"&&o.set(a,A)}return o}return t}function eot(){let t={};for(let[e,r]of Object.entries(process.env))e=e.toLowerCase(),e.startsWith(bb)&&(e=(0,Dle.default)(e.slice(bb.length)),t[e]=r);return t}function G4(){let t=`${bb}rc_filename`;for(let[e,r]of Object.entries(process.env))if(e.toLowerCase()===t&&typeof r=="string")return r;return q4}async function vle(t){try{return await oe.readFilePromise(t)}catch{return Buffer.of()}}async function tot(t,e){return Buffer.compare(...await Promise.all([vle(t),vle(e)]))===0}async function rot(t,e){let[r,o]=await Promise.all([oe.statPromise(t),oe.statPromise(e)]);return r.dev===o.dev&&r.ino===o.ino}async function iot({configuration:t,selfPath:e}){let r=t.get("yarnPath");return t.get("ignorePath")||r===null||r===e||await not(r,e)?null:r}var Dle,Lf,Sle,Ple,ble,j4,Jst,v1,zst,FE,bb,q4,Xst,D1,xle,xb,Sb,not,rA,Ke,S1=Et(()=>{St();Nl();Dle=$e(sV()),Lf=$e(td());jt();Sle=$e(ZV()),Ple=ve("module"),ble=$e(id()),j4=ve("stream");ose();fE();cM();uM();AM();Tse();fM();vd();Use();WP();Gl();nh();Ib();jl();vb();Qf();bo();Jst=function(){if(!Lf.GITHUB_ACTIONS||!process.env.GITHUB_EVENT_PATH)return!1;let t=ue.toPortablePath(process.env.GITHUB_EVENT_PATH),e;try{e=oe.readJsonSync(t)}catch{return!1}return!(!("repository"in e)||!e.repository||(e.repository.private??!0))}(),v1=new Set(["@yarnpkg/plugin-constraints","@yarnpkg/plugin-exec","@yarnpkg/plugin-interactive-tools","@yarnpkg/plugin-stage","@yarnpkg/plugin-typescript","@yarnpkg/plugin-version","@yarnpkg/plugin-workspace-tools"]),zst=new Set(["isTestEnv","injectNpmUser","injectNpmPassword","injectNpm2FaToken","zipDataEpilogue","cacheCheckpointOverride","cacheVersionOverride","lockfileVersionOverride","binFolder","version","flags","profile","gpg","ignoreNode","wrapOutput","home","confDir","registry","ignoreCwd"]),FE=/^(?!v)[a-z0-9._-]+$/i,bb="yarn_",q4=".yarnrc.yml",Xst="********",D1=(E=>(E.ANY="ANY",E.BOOLEAN="BOOLEAN",E.ABSOLUTE_PATH="ABSOLUTE_PATH",E.LOCATOR="LOCATOR",E.LOCATOR_LOOSE="LOCATOR_LOOSE",E.NUMBER="NUMBER",E.STRING="STRING",E.SECRET="SECRET",E.SHAPE="SHAPE",E.MAP="MAP",E))(D1||{}),xle=yt,xb=(r=>(r.JUNCTIONS="junctions",r.SYMLINKS="symlinks",r))(xb||{}),Sb={lastUpdateCheck:{description:"Last timestamp we checked whether new Yarn versions were available",type:"STRING",default:null},yarnPath:{description:"Path to the local executable that must be used over the global one",type:"ABSOLUTE_PATH",default:null},ignorePath:{description:"If true, the local executable will be ignored when using the global one",type:"BOOLEAN",default:!1},globalFolder:{description:"Folder where all system-global files are stored",type:"ABSOLUTE_PATH",default:EM()},cacheFolder:{description:"Folder where the cache files must be written",type:"ABSOLUTE_PATH",default:"./.yarn/cache"},compressionLevel:{description:"Zip files compression level, from 0 to 9 or mixed (a variant of 9, which stores some files uncompressed, when compression doesn't yield good results)",type:"NUMBER",values:["mixed",0,1,2,3,4,5,6,7,8,9],default:0},virtualFolder:{description:"Folder where the virtual packages (cf doc) will be mapped on the disk (must be named __virtual__)",type:"ABSOLUTE_PATH",default:"./.yarn/__virtual__"},installStatePath:{description:"Path of the file where the install state will be persisted",type:"ABSOLUTE_PATH",default:"./.yarn/install-state.gz"},immutablePatterns:{description:"Array of glob patterns; files matching them won't be allowed to change during immutable installs",type:"STRING",default:[],isArray:!0},rcFilename:{description:"Name of the files where the configuration can be found",type:"STRING",default:G4()},enableGlobalCache:{description:"If true, the system-wide cache folder will be used regardless of `cache-folder`",type:"BOOLEAN",default:!0},cacheMigrationMode:{description:"Defines the conditions under which Yarn upgrades should cause the cache archives to be regenerated.",type:"STRING",values:["always","match-spec","required-only"],default:"always"},enableColors:{description:"If true, the CLI is allowed to use colors in its output",type:"BOOLEAN",default:lP,defaultText:"<dynamic>"},enableHyperlinks:{description:"If true, the CLI is allowed to use hyperlinks in its output",type:"BOOLEAN",default:SL,defaultText:"<dynamic>"},enableInlineBuilds:{description:"If true, the CLI will print the build output on the command line",type:"BOOLEAN",default:Lf.isCI,defaultText:"<dynamic>"},enableMessageNames:{description:"If true, the CLI will prefix most messages with codes suitable for search engines",type:"BOOLEAN",default:!0},enableProgressBars:{description:"If true, the CLI is allowed to show a progress bar for long-running events",type:"BOOLEAN",default:!Lf.isCI,defaultText:"<dynamic>"},enableTimers:{description:"If true, the CLI is allowed to print the time spent executing commands",type:"BOOLEAN",default:!0},enableTips:{description:"If true, installs will print a helpful message every day of the week",type:"BOOLEAN",default:!Lf.isCI,defaultText:"<dynamic>"},preferInteractive:{description:"If true, the CLI will automatically use the interactive mode when called from a TTY",type:"BOOLEAN",default:!1},preferTruncatedLines:{description:"If true, the CLI will truncate lines that would go beyond the size of the terminal",type:"BOOLEAN",default:!1},progressBarStyle:{description:"Which style of progress bar should be used (only when progress bars are enabled)",type:"STRING",default:void 0,defaultText:"<dynamic>"},defaultLanguageName:{description:"Default language mode that should be used when a package doesn't offer any insight",type:"STRING",default:"node"},defaultProtocol:{description:"Default resolution protocol used when resolving pure semver and tag ranges",type:"STRING",default:"npm:"},enableTransparentWorkspaces:{description:"If false, Yarn won't automatically resolve workspace dependencies unless they use the `workspace:` protocol",type:"BOOLEAN",default:!0},supportedArchitectures:{description:"Architectures that Yarn will fetch and inject into the resolver",type:"SHAPE",properties:{os:{description:"Array of supported process.platform strings, or null to target them all",type:"STRING",isArray:!0,isNullable:!0,default:["current"]},cpu:{description:"Array of supported process.arch strings, or null to target them all",type:"STRING",isArray:!0,isNullable:!0,default:["current"]},libc:{description:"Array of supported libc libraries, or null to target them all",type:"STRING",isArray:!0,isNullable:!0,default:["current"]}}},enableMirror:{description:"If true, the downloaded packages will be retrieved and stored in both the local and global folders",type:"BOOLEAN",default:!0},enableNetwork:{description:"If false, Yarn will refuse to use the network if required to",type:"BOOLEAN",default:!0},enableOfflineMode:{description:"If true, Yarn will attempt to retrieve files and metadata from the global cache rather than the network",type:"BOOLEAN",default:!1},httpProxy:{description:"URL of the http proxy that must be used for outgoing http requests",type:"STRING",default:null},httpsProxy:{description:"URL of the http proxy that must be used for outgoing https requests",type:"STRING",default:null},unsafeHttpWhitelist:{description:"List of the hostnames for which http queries are allowed (glob patterns are supported)",type:"STRING",default:[],isArray:!0},httpTimeout:{description:"Timeout of each http request in milliseconds",type:"NUMBER",default:6e4},httpRetry:{description:"Retry times on http failure",type:"NUMBER",default:3},networkConcurrency:{description:"Maximal number of concurrent requests",type:"NUMBER",default:50},taskPoolConcurrency:{description:"Maximal amount of concurrent heavy task processing",type:"NUMBER",default:U4()},taskPoolMode:{description:"Execution strategy for heavy tasks",type:"STRING",values:["async","workers"],default:"workers"},networkSettings:{description:"Network settings per hostname (glob patterns are supported)",type:"MAP",valueDefinition:{description:"",type:"SHAPE",properties:{httpsCaFilePath:{description:"Path to file containing one or multiple Certificate Authority signing certificates",type:"ABSOLUTE_PATH",default:null},enableNetwork:{description:"If false, the package manager will refuse to use the network if required to",type:"BOOLEAN",default:null},httpProxy:{description:"URL of the http proxy that must be used for outgoing http requests",type:"STRING",default:null},httpsProxy:{description:"URL of the http proxy that must be used for outgoing https requests",type:"STRING",default:null},httpsKeyFilePath:{description:"Path to file containing private key in PEM format",type:"ABSOLUTE_PATH",default:null},httpsCertFilePath:{description:"Path to file containing certificate chain in PEM format",type:"ABSOLUTE_PATH",default:null}}}},httpsCaFilePath:{description:"A path to a file containing one or multiple Certificate Authority signing certificates",type:"ABSOLUTE_PATH",default:null},httpsKeyFilePath:{description:"Path to file containing private key in PEM format",type:"ABSOLUTE_PATH",default:null},httpsCertFilePath:{description:"Path to file containing certificate chain in PEM format",type:"ABSOLUTE_PATH",default:null},enableStrictSsl:{description:"If false, SSL certificate errors will be ignored",type:"BOOLEAN",default:!0},logFilters:{description:"Overrides for log levels",type:"SHAPE",isArray:!0,concatenateValues:!0,properties:{code:{description:"Code of the messages covered by this override",type:"STRING",default:void 0},text:{description:"Code of the texts covered by this override",type:"STRING",default:void 0},pattern:{description:"Code of the patterns covered by this override",type:"STRING",default:void 0},level:{description:"Log level override, set to null to remove override",type:"STRING",values:Object.values(uP),isNullable:!0,default:void 0}}},enableTelemetry:{description:"If true, telemetry will be periodically sent, following the rules in https://yarnpkg.com/advanced/telemetry",type:"BOOLEAN",default:!0},telemetryInterval:{description:"Minimal amount of time between two telemetry uploads, in days",type:"NUMBER",default:7},telemetryUserId:{description:"If you desire to tell us which project you are, you can set this field. Completely optional and opt-in.",type:"STRING",default:null},enableHardenedMode:{description:"If true, automatically enable --check-resolutions --refresh-lockfile on installs",type:"BOOLEAN",default:Lf.isPR&&Jst,defaultText:"<true on public PRs>"},enableScripts:{description:"If true, packages are allowed to have install scripts by default",type:"BOOLEAN",default:!0},enableStrictSettings:{description:"If true, unknown settings will cause Yarn to abort",type:"BOOLEAN",default:!0},enableImmutableCache:{description:"If true, the cache is reputed immutable and actions that would modify it will throw",type:"BOOLEAN",default:!1},checksumBehavior:{description:"Enumeration defining what to do when a checksum doesn't match expectations",type:"STRING",default:"throw"},injectEnvironmentFiles:{description:"List of all the environment files that Yarn should inject inside the process when it starts",type:"ABSOLUTE_PATH",default:[".env.yarn?"],isArray:!0},packageExtensions:{description:"Map of package corrections to apply on the dependency tree",type:"MAP",valueDefinition:{description:"The extension that will be applied to any package whose version matches the specified range",type:"SHAPE",properties:{dependencies:{description:"The set of dependencies that must be made available to the current package in order for it to work properly",type:"MAP",valueDefinition:{description:"A range",type:"STRING"}},peerDependencies:{description:"Inherited dependencies - the consumer of the package will be tasked to provide them",type:"MAP",valueDefinition:{description:"A semver range",type:"STRING"}},peerDependenciesMeta:{description:"Extra information related to the dependencies listed in the peerDependencies field",type:"MAP",valueDefinition:{description:"The peerDependency meta",type:"SHAPE",properties:{optional:{description:"If true, the selected peer dependency will be marked as optional by the package manager and the consumer omitting it won't be reported as an error",type:"BOOLEAN",default:!1}}}}}}}};not=process.platform==="win32"?tot:rot;rA=class{constructor(e){this.isCI=Lf.isCI;this.projectCwd=null;this.plugins=new Map;this.settings=new Map;this.values=new Map;this.sources=new Map;this.invalid=new Map;this.env={};this.limits=new Map;this.packageExtensions=null;this.startingCwd=e}static create(e,r,o){let a=new rA(e);typeof r<"u"&&!(r instanceof Map)&&(a.projectCwd=r),a.importSettings(Sb);let n=typeof o<"u"?o:r instanceof Map?r:new Map;for(let[u,A]of n)a.activatePlugin(u,A);return a}static async find(e,r,{strict:o=!0,usePathCheck:a=null,useRc:n=!0}={}){let u=eot();delete u.rcFilename;let A=new rA(e),p=await rA.findRcFiles(e),h=await rA.findFolderRcFile(EE());h&&(p.find(me=>me.path===h.path)||p.unshift(h));let E=Mse(p.map(ce=>[ce.path,ce.data])),I=Bt.dot,v=new Set(Object.keys(Sb)),x=({yarnPath:ce,ignorePath:me,injectEnvironmentFiles:he})=>({yarnPath:ce,ignorePath:me,injectEnvironmentFiles:he}),C=({yarnPath:ce,ignorePath:me,injectEnvironmentFiles:he,...Be})=>{let we={};for(let[g,Ee]of Object.entries(Be))v.has(g)&&(we[g]=Ee);return we},R=({yarnPath:ce,ignorePath:me,...he})=>{let Be={};for(let[we,g]of Object.entries(he))v.has(we)||(Be[we]=g);return Be};if(A.importSettings(x(Sb)),A.useWithSource("<environment>",x(u),e,{strict:!1}),E){let[ce,me]=E;A.useWithSource(ce,x(me),I,{strict:!1})}if(a){if(await iot({configuration:A,selfPath:a})!==null)return A;A.useWithSource("<override>",{ignorePath:!0},e,{strict:!1,overwrite:!0})}let L=await rA.findProjectCwd(e);A.startingCwd=e,A.projectCwd=L;let U=Object.assign(Object.create(null),process.env);A.env=U;let J=await Promise.all(A.get("injectEnvironmentFiles").map(async ce=>{let me=ce.endsWith("?")?await oe.readFilePromise(ce.slice(0,-1),"utf8").catch(()=>""):await oe.readFilePromise(ce,"utf8");return(0,Sle.parse)(me)}));for(let ce of J)for(let[me,he]of Object.entries(ce))A.env[me]=sP(he,{env:U});if(A.importSettings(C(Sb)),A.useWithSource("<environment>",C(u),e,{strict:o}),E){let[ce,me]=E;A.useWithSource(ce,C(me),I,{strict:o})}let te=ce=>"default"in ce?ce.default:ce,ae=new Map([["@@core",sse]]);if(r!==null)for(let ce of r.plugins.keys())ae.set(ce,te(r.modules.get(ce)));for(let[ce,me]of ae)A.activatePlugin(ce,me);let fe=new Map([]);if(r!==null){let ce=new Map;for(let Be of Ple.builtinModules)ce.set(Be,()=>Df(Be));for(let[Be,we]of r.modules)ce.set(Be,()=>we);let me=new Set,he=async(Be,we)=>{let{factory:g,name:Ee}=Df(Be);if(!g||me.has(Ee))return;let Se=new Map(ce),le=ee=>{if(Se.has(ee))return Se.get(ee)();throw new it(`This plugin cannot access the package referenced via ${ee} which is neither a builtin, nor an exposed entry`)},ne=await Ky(async()=>te(await g(le)),ee=>`${ee} (when initializing ${Ee}, defined in ${we})`);ce.set(Ee,()=>ne),me.add(Ee),fe.set(Ee,ne)};if(u.plugins)for(let Be of u.plugins.split(";")){let we=V.resolve(e,ue.toPortablePath(Be));await he(we,"<environment>")}for(let{path:Be,cwd:we,data:g}of p)if(!!n&&!!Array.isArray(g.plugins))for(let Ee of g.plugins){let Se=typeof Ee!="string"?Ee.path:Ee,le=Ee?.spec??"",ne=Ee?.checksum??"";if(v1.has(le))continue;let ee=V.resolve(we,ue.toPortablePath(Se));if(!await oe.existsPromise(ee)){if(!le){let At=Mt(A,V.basename(ee,".cjs"),yt.NAME),H=Mt(A,".gitignore",yt.NAME),at=Mt(A,A.values.get("rcFilename"),yt.NAME),Re=Mt(A,"https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored",yt.URL);throw new it(`Missing source for the ${At} plugin - please try to remove the plugin from ${at} then reinstall it manually. This error usually occurs because ${H} is incorrect, check ${Re} to make sure your plugin folder isn't gitignored.`)}if(!le.match(/^https?:/)){let At=Mt(A,V.basename(ee,".cjs"),yt.NAME),H=Mt(A,A.values.get("rcFilename"),yt.NAME);throw new it(`Failed to recognize the source for the ${At} plugin - please try to delete the plugin from ${H} then reinstall it manually.`)}let Ie=await L4(le,{configuration:A}),Fe=zs(Ie);if(ne&&ne!==Fe){let At=Mt(A,V.basename(ee,".cjs"),yt.NAME),H=Mt(A,A.values.get("rcFilename"),yt.NAME),at=Mt(A,`yarn plugin import ${le}`,yt.CODE);throw new it(`Failed to fetch the ${At} plugin from its remote location: its checksum seems to have changed. If this is expected, please remove the plugin from ${H} then run ${at} to reimport it.`)}await oe.mkdirPromise(V.dirname(ee),{recursive:!0}),await oe.writeFilePromise(ee,Ie)}await he(ee,Be)}}for(let[ce,me]of fe)A.activatePlugin(ce,me);if(A.useWithSource("<environment>",R(u),e,{strict:o}),E){let[ce,me]=E;A.useWithSource(ce,R(me),I,{strict:o})}return A.get("enableGlobalCache")&&(A.values.set("cacheFolder",`${A.get("globalFolder")}/cache`),A.sources.set("cacheFolder","<internal>")),A}static async findRcFiles(e){let r=G4(),o=[],a=e,n=null;for(;a!==n;){n=a;let u=V.join(n,r);if(oe.existsSync(u)){let A=await oe.readFilePromise(u,"utf8"),p;try{p=Ki(A)}catch{let E="";throw A.match(/^\s+(?!-)[^:]+\s+\S+/m)&&(E=" (in particular, make sure you list the colons after each key name)"),new it(`Parse error when loading ${u}; please check it's proper Yaml${E}`)}o.unshift({path:u,cwd:n,data:p})}a=V.dirname(n)}return o}static async findFolderRcFile(e){let r=V.join(e,dr.rc),o;try{o=await oe.readFilePromise(r,"utf8")}catch(n){if(n.code==="ENOENT")return null;throw n}let a=Ki(o);return{path:r,cwd:e,data:a}}static async findProjectCwd(e){let r=null,o=e,a=null;for(;o!==a;){if(a=o,oe.existsSync(V.join(a,dr.lockfile)))return a;oe.existsSync(V.join(a,dr.manifest))&&(r=a),o=V.dirname(a)}return r}static async updateConfiguration(e,r,o={}){let a=G4(),n=V.join(e,a),u=oe.existsSync(n)?Ki(await oe.readFilePromise(n,"utf8")):{},A=!1,p;if(typeof r=="function"){try{p=r(u)}catch{p=r({})}if(p===u)return!1}else{p=u;for(let h of Object.keys(r)){let E=u[h],I=r[h],v;if(typeof I=="function")try{v=I(E)}catch{v=I(void 0)}else v=I;E!==v&&(v===rA.deleteProperty?delete p[h]:p[h]=v,A=!0)}if(!A)return!1}return await oe.changeFilePromise(n,Ba(p),{automaticNewlines:!0}),!0}static async addPlugin(e,r){r.length!==0&&await rA.updateConfiguration(e,o=>{let a=o.plugins??[];if(a.length===0)return{...o,plugins:r};let n=[],u=[...r];for(let A of a){let p=typeof A!="string"?A.path:A,h=u.find(E=>E.path===p);h?(n.push(h),u=u.filter(E=>E!==h)):n.push(A)}return n.push(...u),{...o,plugins:n}})}static async updateHomeConfiguration(e){let r=EE();return await rA.updateConfiguration(r,e)}activatePlugin(e,r){this.plugins.set(e,r),typeof r.configuration<"u"&&this.importSettings(r.configuration)}importSettings(e){for(let[r,o]of Object.entries(e))if(o!=null){if(this.settings.has(r))throw new Error(`Cannot redefine settings "${r}"`);this.settings.set(r,o),this.values.set(r,W4(this,o))}}useWithSource(e,r,o,a){try{this.use(e,r,o,a)}catch(n){throw n.message+=` (in ${Mt(this,e,yt.PATH)})`,n}}use(e,r,o,{strict:a=!0,overwrite:n=!1}={}){a=a&&this.get("enableStrictSettings");for(let u of["enableStrictSettings",...Object.keys(r)]){let A=r[u],p=mM(A);if(p&&(e=p),typeof A>"u"||u==="plugins"||e==="<environment>"&&zst.has(u))continue;if(u==="rcFilename")throw new it(`The rcFilename settings can only be set via ${`${bb}RC_FILENAME`.toUpperCase()}, not via a rc file`);let h=this.settings.get(u);if(!h){let I=EE(),v=e[0]!=="<"?V.dirname(e):null;if(a&&!(v!==null?I===v:!1))throw new it(`Unrecognized or legacy configuration settings found: ${u} - run "yarn config -v" to see the list of settings supported in Yarn`);this.invalid.set(u,e);continue}if(this.sources.has(u)&&!(n||h.type==="MAP"||h.isArray&&h.concatenateValues))continue;let E;try{E=Y4(this,u,A,h,o)}catch(I){throw I.message+=` in ${Mt(this,e,yt.PATH)}`,I}if(u==="enableStrictSettings"&&e!=="<environment>"){a=E;continue}if(h.type==="MAP"){let I=this.values.get(u);this.values.set(u,new Map(n?[...I,...E]:[...E,...I])),this.sources.set(u,`${this.sources.get(u)}, ${e}`)}else if(h.isArray&&h.concatenateValues){let I=this.values.get(u);this.values.set(u,n?[...I,...E]:[...E,...I]),this.sources.set(u,`${this.sources.get(u)}, ${e}`)}else this.values.set(u,E),this.sources.set(u,e)}}get(e){if(!this.values.has(e))throw new Error(`Invalid configuration key "${e}"`);return this.values.get(e)}getSpecial(e,{hideSecrets:r=!1,getNativePaths:o=!1}){let a=this.get(e),n=this.settings.get(e);if(typeof n>"u")throw new it(`Couldn't find a configuration settings named "${e}"`);return Pb(a,n,{hideSecrets:r,getNativePaths:o})}getSubprocessStreams(e,{header:r,prefix:o,report:a}){let n,u,A=oe.createWriteStream(e);if(this.get("enableInlineBuilds")){let p=a.createStreamReporter(`${o} ${Mt(this,"STDOUT","green")}`),h=a.createStreamReporter(`${o} ${Mt(this,"STDERR","red")}`);n=new j4.PassThrough,n.pipe(p),n.pipe(A),u=new j4.PassThrough,u.pipe(h),u.pipe(A)}else n=A,u=A,typeof r<"u"&&n.write(`${r} +`);return{stdout:n,stderr:u}}makeResolver(){let e=[];for(let r of this.plugins.values())for(let o of r.resolvers||[])e.push(new o);return new Dd([new c1,new Xn,...e])}makeFetcher(){let e=[];for(let r of this.plugins.values())for(let o of r.fetchers||[])e.push(new o);return new hE([new gE,new mE,...e])}getLinkers(){let e=[];for(let r of this.plugins.values())for(let o of r.linkers||[])e.push(new o);return e}getSupportedArchitectures(){let e=B1(),r=this.get("supportedArchitectures"),o=r.get("os");o!==null&&(o=o.map(u=>u==="current"?e.os:u));let a=r.get("cpu");a!==null&&(a=a.map(u=>u==="current"?e.cpu:u));let n=r.get("libc");return n!==null&&(n=sl(n,u=>u==="current"?e.libc??sl.skip:u)),{os:o,cpu:a,libc:n}}async getPackageExtensions(){if(this.packageExtensions!==null)return this.packageExtensions;this.packageExtensions=new Map;let e=this.packageExtensions,r=(o,a,{userProvided:n=!1}={})=>{if(!xa(o.range))throw new Error("Only semver ranges are allowed as keys for the packageExtensions setting");let u=new Ot;u.load(a,{yamlCompatibilityMode:!0});let A=Yy(e,o.identHash),p=[];A.push([o.range,p]);let h={status:"inactive",userProvided:n,parentDescriptor:o};for(let E of u.dependencies.values())p.push({...h,type:"Dependency",descriptor:E});for(let E of u.peerDependencies.values())p.push({...h,type:"PeerDependency",descriptor:E});for(let[E,I]of u.peerDependenciesMeta)for(let[v,x]of Object.entries(I))p.push({...h,type:"PeerDependencyMeta",selector:E,key:v,value:x})};await this.triggerHook(o=>o.registerPackageExtensions,this,r);for(let[o,a]of this.get("packageExtensions"))r(ih(o,!0),iP(a),{userProvided:!0});return e}normalizeLocator(e){return xa(e.reference)?Qs(e,`${this.get("defaultProtocol")}${e.reference}`):FE.test(e.reference)?Qs(e,`${this.get("defaultProtocol")}${e.reference}`):e}normalizeDependency(e){return xa(e.range)?In(e,`${this.get("defaultProtocol")}${e.range}`):FE.test(e.range)?In(e,`${this.get("defaultProtocol")}${e.range}`):e}normalizeDependencyMap(e){return new Map([...e].map(([r,o])=>[r,this.normalizeDependency(o)]))}normalizePackage(e,{packageExtensions:r}){let o=e1(e),a=r.get(e.identHash);if(typeof a<"u"){let u=e.version;if(u!==null){for(let[A,p]of a)if(!!kf(u,A))for(let h of p)switch(h.status==="inactive"&&(h.status="redundant"),h.type){case"Dependency":typeof o.dependencies.get(h.descriptor.identHash)>"u"&&(h.status="active",o.dependencies.set(h.descriptor.identHash,this.normalizeDependency(h.descriptor)));break;case"PeerDependency":typeof o.peerDependencies.get(h.descriptor.identHash)>"u"&&(h.status="active",o.peerDependencies.set(h.descriptor.identHash,h.descriptor));break;case"PeerDependencyMeta":{let E=o.peerDependenciesMeta.get(h.selector);(typeof E>"u"||!Object.hasOwn(E,h.key)||E[h.key]!==h.value)&&(h.status="active",ol(o.peerDependenciesMeta,h.selector,()=>({}))[h.key]=h.value)}break;default:yL(h)}}}let n=u=>u.scope?`${u.scope}__${u.name}`:`${u.name}`;for(let u of o.peerDependenciesMeta.keys()){let A=Js(u);o.peerDependencies.has(A.identHash)||o.peerDependencies.set(A.identHash,In(A,"*"))}for(let u of o.peerDependencies.values()){if(u.scope==="types")continue;let A=n(u),p=eA("types",A),h=fn(p);o.peerDependencies.has(p.identHash)||o.peerDependenciesMeta.has(h)||(o.peerDependencies.set(p.identHash,In(p,"*")),o.peerDependenciesMeta.set(h,{optional:!0}))}return o.dependencies=new Map(ks(o.dependencies,([,u])=>Pa(u))),o.peerDependencies=new Map(ks(o.peerDependencies,([,u])=>Pa(u))),o}getLimit(e){return ol(this.limits,e,()=>(0,ble.default)(this.get(e)))}async triggerHook(e,...r){for(let o of this.plugins.values()){let a=o.hooks;if(!a)continue;let n=e(a);!n||await n(...r)}}async triggerMultipleHooks(e,r){for(let o of r)await this.triggerHook(e,...o)}async reduceHook(e,r,...o){let a=r;for(let n of this.plugins.values()){let u=n.hooks;if(!u)continue;let A=e(u);!A||(a=await A(a,...o))}return a}async firstHook(e,...r){for(let o of this.plugins.values()){let a=o.hooks;if(!a)continue;let n=e(a);if(!n)continue;let u=await n(...r);if(typeof u<"u")return u}return null}},Ke=rA;Ke.deleteProperty=Symbol(),Ke.telemetry=null});var Ur={};Vt(Ur,{EndStrategy:()=>z4,ExecError:()=>kb,PipeError:()=>P1,execvp:()=>_4,pipevp:()=>qc});function bd(t){return t!==null&&typeof t.fd=="number"}function K4(){}function V4(){for(let t of xd)t.kill()}async function qc(t,e,{cwd:r,env:o=process.env,strict:a=!1,stdin:n=null,stdout:u,stderr:A,end:p=2}){let h=["pipe","pipe","pipe"];n===null?h[0]="ignore":bd(n)&&(h[0]=n),bd(u)&&(h[1]=u),bd(A)&&(h[2]=A);let E=(0,J4.default)(t,e,{cwd:ue.fromPortablePath(r),env:{...o,PWD:ue.fromPortablePath(r)},stdio:h});xd.add(E),xd.size===1&&(process.on("SIGINT",K4),process.on("SIGTERM",V4)),!bd(n)&&n!==null&&n.pipe(E.stdin),bd(u)||E.stdout.pipe(u,{end:!1}),bd(A)||E.stderr.pipe(A,{end:!1});let I=()=>{for(let v of new Set([u,A]))bd(v)||v.end()};return new Promise((v,x)=>{E.on("error",C=>{xd.delete(E),xd.size===0&&(process.off("SIGINT",K4),process.off("SIGTERM",V4)),(p===2||p===1)&&I(),x(C)}),E.on("close",(C,R)=>{xd.delete(E),xd.size===0&&(process.off("SIGINT",K4),process.off("SIGTERM",V4)),(p===2||p===1&&C!==0)&&I(),C===0||!a?v({code:X4(C,R)}):x(new P1({fileName:t,code:C,signal:R}))})})}async function _4(t,e,{cwd:r,env:o=process.env,encoding:a="utf8",strict:n=!1}){let u=["ignore","pipe","pipe"],A=[],p=[],h=ue.fromPortablePath(r);typeof o.PWD<"u"&&(o={...o,PWD:h});let E=(0,J4.default)(t,e,{cwd:h,env:o,stdio:u});return E.stdout.on("data",I=>{A.push(I)}),E.stderr.on("data",I=>{p.push(I)}),await new Promise((I,v)=>{E.on("error",x=>{let C=Ke.create(r),R=Mt(C,t,yt.PATH);v(new zt(1,`Process ${R} failed to spawn`,L=>{L.reportError(1,` ${zu(C,{label:"Thrown Error",value:_c(yt.NO_HINT,x.message)})}`)}))}),E.on("close",(x,C)=>{let R=a==="buffer"?Buffer.concat(A):Buffer.concat(A).toString(a),L=a==="buffer"?Buffer.concat(p):Buffer.concat(p).toString(a);x===0||!n?I({code:X4(x,C),stdout:R,stderr:L}):v(new kb({fileName:t,code:x,signal:C,stdout:R,stderr:L}))})})}function X4(t,e){let r=sot.get(e);return typeof r<"u"?128+r:t??1}function oot(t,e,{configuration:r,report:o}){o.reportError(1,` ${zu(r,t!==null?{label:"Exit Code",value:_c(yt.NUMBER,t)}:{label:"Exit Signal",value:_c(yt.CODE,e)})}`)}var J4,z4,P1,kb,xd,sot,Db=Et(()=>{St();J4=$e(sT());S1();Yl();Gl();z4=(o=>(o[o.Never=0]="Never",o[o.ErrorCode=1]="ErrorCode",o[o.Always=2]="Always",o))(z4||{}),P1=class extends zt{constructor({fileName:r,code:o,signal:a}){let n=Ke.create(V.cwd()),u=Mt(n,r,yt.PATH);super(1,`Child ${u} reported an error`,A=>{oot(o,a,{configuration:n,report:A})});this.code=X4(o,a)}},kb=class extends P1{constructor({fileName:r,code:o,signal:a,stdout:n,stderr:u}){super({fileName:r,code:o,signal:a});this.stdout=n,this.stderr=u}};xd=new Set;sot=new Map([["SIGINT",2],["SIGQUIT",3],["SIGKILL",9],["SIGTERM",15]])});function Qle(t){kle=t}function b1(){return typeof Z4>"u"&&(Z4=kle()),Z4}var Z4,kle,$4=Et(()=>{kle=()=>{throw new Error("Assertion failed: No libzip instance is available, and no factory was configured")}});var Fle=_((Qb,tU)=>{var aot=Object.assign({},ve("fs")),eU=function(){var t=typeof document<"u"&&document.currentScript?document.currentScript.src:void 0;return typeof __filename<"u"&&(t=t||__filename),function(e){e=e||{};var r=typeof e<"u"?e:{},o,a;r.ready=new Promise(function(We,tt){o=We,a=tt});var n={},u;for(u in r)r.hasOwnProperty(u)&&(n[u]=r[u]);var A=[],p="./this.program",h=function(We,tt){throw tt},E=!1,I=!0,v="";function x(We){return r.locateFile?r.locateFile(We,v):v+We}var C,R,L,U;I&&(E?v=ve("path").dirname(v)+"/":v=__dirname+"/",C=function(tt,It){var nr=ii(tt);return nr?It?nr:nr.toString():(L||(L=aot),U||(U=ve("path")),tt=U.normalize(tt),L.readFileSync(tt,It?null:"utf8"))},R=function(tt){var It=C(tt,!0);return It.buffer||(It=new Uint8Array(It)),Ee(It.buffer),It},process.argv.length>1&&(p=process.argv[1].replace(/\\/g,"/")),A=process.argv.slice(2),h=function(We){process.exit(We)},r.inspect=function(){return"[Emscripten Module object]"});var J=r.print||console.log.bind(console),te=r.printErr||console.warn.bind(console);for(u in n)n.hasOwnProperty(u)&&(r[u]=n[u]);n=null,r.arguments&&(A=r.arguments),r.thisProgram&&(p=r.thisProgram),r.quit&&(h=r.quit);var ae=0,fe=function(We){ae=We},ce;r.wasmBinary&&(ce=r.wasmBinary);var me=r.noExitRuntime||!0;typeof WebAssembly!="object"&&Ti("no native wasm support detected");function he(We,tt,It){switch(tt=tt||"i8",tt.charAt(tt.length-1)==="*"&&(tt="i32"),tt){case"i1":return He[We>>0];case"i8":return He[We>>0];case"i16":return cp((We>>1)*2);case"i32":return Os((We>>2)*4);case"i64":return Os((We>>2)*4);case"float":return cu((We>>2)*4);case"double":return lp((We>>3)*8);default:Ti("invalid type for getValue: "+tt)}return null}var Be,we=!1,g;function Ee(We,tt){We||Ti("Assertion failed: "+tt)}function Se(We){var tt=r["_"+We];return Ee(tt,"Cannot call unknown function "+We+", make sure it is exported"),tt}function le(We,tt,It,nr,$){var ye={string:function(es){var bi=0;if(es!=null&&es!==0){var jo=(es.length<<2)+1;bi=Un(jo),At(es,bi,jo)}return bi},array:function(es){var bi=Un(es.length);return Re(es,bi),bi}};function Le(es){return tt==="string"?Ie(es):tt==="boolean"?Boolean(es):es}var pt=Se(We),ht=[],Tt=0;if(nr)for(var er=0;er<nr.length;er++){var $r=ye[It[er]];$r?(Tt===0&&(Tt=ms()),ht[er]=$r(nr[er])):ht[er]=nr[er]}var Gi=pt.apply(null,ht);return Gi=Le(Gi),Tt!==0&&_s(Tt),Gi}function ne(We,tt,It,nr){It=It||[];var $=It.every(function(Le){return Le==="number"}),ye=tt!=="string";return ye&&$&&!nr?Se(We):function(){return le(We,tt,It,arguments,nr)}}var ee=new TextDecoder("utf8");function Ie(We,tt){if(!We)return"";for(var It=We+tt,nr=We;!(nr>=It)&&Te[nr];)++nr;return ee.decode(Te.subarray(We,nr))}function Fe(We,tt,It,nr){if(!(nr>0))return 0;for(var $=It,ye=It+nr-1,Le=0;Le<We.length;++Le){var pt=We.charCodeAt(Le);if(pt>=55296&&pt<=57343){var ht=We.charCodeAt(++Le);pt=65536+((pt&1023)<<10)|ht&1023}if(pt<=127){if(It>=ye)break;tt[It++]=pt}else if(pt<=2047){if(It+1>=ye)break;tt[It++]=192|pt>>6,tt[It++]=128|pt&63}else if(pt<=65535){if(It+2>=ye)break;tt[It++]=224|pt>>12,tt[It++]=128|pt>>6&63,tt[It++]=128|pt&63}else{if(It+3>=ye)break;tt[It++]=240|pt>>18,tt[It++]=128|pt>>12&63,tt[It++]=128|pt>>6&63,tt[It++]=128|pt&63}}return tt[It]=0,It-$}function At(We,tt,It){return Fe(We,Te,tt,It)}function H(We){for(var tt=0,It=0;It<We.length;++It){var nr=We.charCodeAt(It);nr>=55296&&nr<=57343&&(nr=65536+((nr&1023)<<10)|We.charCodeAt(++It)&1023),nr<=127?++tt:nr<=2047?tt+=2:nr<=65535?tt+=3:tt+=4}return tt}function at(We){var tt=H(We)+1,It=Li(tt);return It&&Fe(We,He,It,tt),It}function Re(We,tt){He.set(We,tt)}function ke(We,tt){return We%tt>0&&(We+=tt-We%tt),We}var xe,He,Te,Je,je,b,w,P,y,F;function z(We){xe=We,r.HEAP_DATA_VIEW=F=new DataView(We),r.HEAP8=He=new Int8Array(We),r.HEAP16=Je=new Int16Array(We),r.HEAP32=b=new Int32Array(We),r.HEAPU8=Te=new Uint8Array(We),r.HEAPU16=je=new Uint16Array(We),r.HEAPU32=w=new Uint32Array(We),r.HEAPF32=P=new Float32Array(We),r.HEAPF64=y=new Float64Array(We)}var X=r.INITIAL_MEMORY||16777216,Z,ie=[],Pe=[],Ne=[],ot=!1;function dt(){if(r.preRun)for(typeof r.preRun=="function"&&(r.preRun=[r.preRun]);r.preRun.length;)bt(r.preRun.shift());oo(ie)}function Gt(){ot=!0,oo(Pe)}function $t(){if(r.postRun)for(typeof r.postRun=="function"&&(r.postRun=[r.postRun]);r.postRun.length;)Qr(r.postRun.shift());oo(Ne)}function bt(We){ie.unshift(We)}function an(We){Pe.unshift(We)}function Qr(We){Ne.unshift(We)}var mr=0,br=null,Wr=null;function Kn(We){mr++,r.monitorRunDependencies&&r.monitorRunDependencies(mr)}function Ns(We){if(mr--,r.monitorRunDependencies&&r.monitorRunDependencies(mr),mr==0&&(br!==null&&(clearInterval(br),br=null),Wr)){var tt=Wr;Wr=null,tt()}}r.preloadedImages={},r.preloadedAudios={};function Ti(We){r.onAbort&&r.onAbort(We),We+="",te(We),we=!0,g=1,We="abort("+We+"). Build with -s ASSERTIONS=1 for more info.";var tt=new WebAssembly.RuntimeError(We);throw a(tt),tt}var ps="data:application/octet-stream;base64,";function io(We){return We.startsWith(ps)}var Pi="data:application/octet-stream;base64,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QCAAKAJIIgEgASADaiADEAcaAkAgAyAAKAJsIgFNBEAgACABIANrNgJsDAELIABCADcCbAsgACAAKAJoIANrIgE2AmggACAAKAJYIANrNgJYIAEgACgChC5JBEAgACABNgKELgsgAEH8gAEoAgARAwAgAiADaiECCwJAIAAoAgAiASgCBCIERQ0AIAAoAjwhBSAAIAIgBCACIARJGyICBH8gACgCSCAAKAJoaiAFaiEFIAEgBCACazYCBAJAAkACQAJAIAEoAhwiBCgCFEEBaw4CAQACCyAEQaABaiAFIAEoAgAgAkHcgAEoAgARCAAMAgsgASABKAIwIAUgASgCACACQcSAASgCABEEADYCMAwBCyAFIAEoAgAgAhAHGgsgASABKAIAIAJqNgIAIAEgASgCCCACajYCCCAAKAI8BSAFCyACaiICNgI8AkAgACgChC4iASACakEDSQ0AIAAoAmggAWshAQJAIAAoAnRBgQhPBEAgACAAIAAoAkggAWoiAi0AACACLQABIAAoAnwRAAA2AlQMAQsgAUUNACAAIAFBAWsgACgChAERAgAaCyAAKAKELiAAKAI8IgJBAUZrIgRFDQAgACABIAQgACgCgAERBQAgACAAKAKELiAEazYChC4gACgCPCECCyACQYUCSw0AIAAoAgAoAgRFDQAgACgCMCEBDAELCwJAIAAoAkQiAiAAKAJAIgNNDQAgAAJ/IAAoAjwgACgCaGoiASADSwRAIAAoAkggAWpBACACIAFrIgNBggIgA0GCAkkbIgMQGSABIANqDAELIAFBggJqIgEgA00NASAAKAJIIANqQQAgAiADayICIAEgA2siAyACIANJGyIDEBkgACgCQCADags2AkALC50CAQF/AkAgAAJ/IAAoAqAuIgFBwABGBEAgACgCBCAAKAIQaiAAKQOYLjcAACAAQgA3A5guIAAgACgCEEEIajYCEEEADAELIAFBIE4EQCAAKAIEIAAoAhBqIAApA5guPgAAIAAgAEGcLmo1AgA3A5guIAAgACgCEEEEajYCECAAIAAoAqAuQSBrIgE2AqAuCyABQRBOBEAgACgCBCAAKAIQaiAAKQOYLj0AACAAIAAoAhBBAmo2AhAgACAAKQOYLkIQiDcDmC4gACAAKAKgLkEQayIBNgKgLgsgAUEISA0BIAAgACgCECIBQQFqNgIQ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KAJUIgEEfyABQQAgARAiEBkgACgCVAVBAAsQBiAAQQA2AlQLC9wMAgl/AX4jAEFAaiIGJAACQAJAAkACQAJAIAEoAjBBABAjIgVBAkZBACABKAI4QQAQIyIEQQFGGw0AIAVBAUZBACAEQQJGGw0AIAVBAkciAw0BIARBAkcNAQsgASABLwEMQYAQcjsBDEEAIQMMAQsgASABLwEMQf/vA3E7AQxBACEFIANFBEBB9eABIAEoAjAgAEEIahBpIgVFDQILIAJBgAJxBEAgBSEDDAELIARBAkcEQCAFIQMMAQtB9cYBIAEoAjggAEEIahBpIgNFBEAgBRAcDAILIAMgBTYCAAsgASABLwEMQf7/A3EgAS8BUiIFQQBHcjsBDAJAAkACQAJAAn8CQAJAIAEpAyhC/v///w9WDQAgASkDIEL+////D1YNACACQYAEcUUNASABKQNIQv////8PVA0BCyAFQYECa0H//wNxQQNJIQdBAQwBCyAFQYECa0H//wNxIQQgAkGACnFBgApHDQEgBEEDSSEHQQALIQkgBkIcEBciBEUEQCAAQQhqIgAEQCAAQQA2AgQgAEEONgIACyADEBwMBQsgAkGACHEhBQJAAkAgAkGAAnEEQAJAIAUNACABKQMgQv////8PVg0AIAEpAyhCgICAgBBUDQMLIAQgASkDKBAYIAEpAyAhDAwBCwJAAkACQCAFDQAgASkDIEL/////D1YNACABKQMoIgxC/////w9WDQEgASkDSEKAgICAEFQNBAsgASkDKCIMQv////8PVA0BCyAEIAwQGAsgASkDICIMQv////8PWgRAIAQgDBAYCyABKQNIIgxC/////w9UDQELIAQgDBAYCyAELQAARQRAIABBCGoiAARAIABBADYCBCAAQRQ2AgALIAQQCCADEBwMBQtBASEKQQEgBC0AAAR+IAQpAxAFQgALp0H//wNxIAYQRyEFIAQQCCAFIAM2AgAgBw0BDAILIAMhBSAEQQJLDQELIAZCBxAXIgRFBEAgAEEIaiIABEAgAEEANgIEIABBDjYCAAsgBRAcDAMLIARBAhANIARBhxJBAhAsIAQgAS0AUhBwIAQgAS8BEBANIAQtAABFBEAgAEEIaiIABEAgAEEANgIEIABBFDYCAAsgBBAIDAILQYGyAkEHIAYQRyEDIAQQCCADIAU2AgBBASEL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NgIEIABB7A42AhggGiEdDAMLIARB/wFxIgJBwABxRQRAIAggDSADLwECQQJ0aiAdp0F/IAJ0QX9zcUECdGoiAy0AASICayEIIB0gAq2IIR0gAy0AACIERQ0HDAELCyAEQSBxBEAgB0G//gA2AgQgASECDAgLIAdB0f4ANgIEIABB0A42AhggASECDAcLIB1BfyAGdEF/c62DIBt8IhunIQUgCCAEQQ9xIgNrIQggGiAErUIPg4ghHSABIBdrIgYgAjMBAiAaQX8gA3RBf3Otg3ynIgRPDQIgBCAGayIGIBlNDQEgBygCjEdFDQEgB0HR/gA2AgQgAEG5DDYCGAsgASECIAshBQwFCwJAIA5FBEAgDCARIAZraiEDDAELIAYgDk0EQCAMIA4gBmtqIQMMAQsgDCARIAYgDmsiBmtqIQMgBSAGTQ0AIAUgBmshBQJAAkAgASADTSABIA8gAWusIhogBq0iGyAaIBtUGyIapyIGaiICIANLcQ0AIAMgBmogAUsgASADT3ENACABIAMgBhAHGiACIQEMAQsgASADIAMgAWsiASABQR91IgFqIAFzIgIQByACaiEBIBogAq0iHn0iHFANACACIANqIQIDQAJAIBwgHiAcIB5UGyIbQiBUBEAgGyEaDAELIBsiGkIgfSIgQgWIQgF8QgODIh9QRQRAA0AgASACKQAANwAAIAEgAikAGDcAGCABIAIpABA3ABAgASACKQAINwAIIBpCIH0hGiACQSBqIQIgAUEgaiEBIB9CAX0iH0IAUg0ACwsgIELgAFQNAANAIAEgAikAADcAACABIAIpABg3ABggASACKQAQNwAQIAEgAikACDcACCABIAIpADg3ADggASACKQAwNwAwIAEgAikAKDcAKCABIAIpACA3ACAgASACKQBYNwBYIAEgAikAUDcAUCABIAIpAEg3AEggASACKQBANwBAIAEgAikAYDcAYCABIAIpAGg3AGggASACKQBwNwBwIAEgAikAeDcAeCACQYABaiECIAFBgAFqIQEgGkKAAX0iGkIfVg0ACwsgGkIQWgRAIAEgAikAADcAACABIAIpAAg3AAggGkIQfSEaIAJBEGohAiABQRBqIQELIBpCCFoEQCABIAIpAAA3AAAgGkIIfSEaIAJBCGohAiABQQhqIQELIBpCBFoEQCABIAIoAAA2AAAgGkIEfSEaIAJBBGohAiABQQRqIQELIBpCAloEQCABIAIvAAA7AAAgGkICfSEaIAJBAmohAiABQQJqIQELIBwgG30hHCAaUEUEQCABIAItAAA6AAAgAkEBaiECIAFBAWohAQsgHEIAUg0ACwsgDiEGIAwhAwsgBSAGSwRAAkACQCABIANNIAEgDyABa6wiGiAGrSIbIBogG1QbIhqnIglqIgIgA0txDQAgAyAJaiABSyABIANPcQ0AIAEgAyAJEAcaDAELIAEgAyADIAFrIgEgAUEfdSIBaiABcyIBEAcgAWohAiAaIAGtIh59IhxQDQAgASADaiEBA0ACQCAcIB4gHCAeVBsiG0IgVARAIBshGgwBCyAbIhpCIH0iIEIFiEIBfEIDgyIfUEUEQANAIAIgASkAADcAACACIAEpABg3ABggAiABKQAQNwAQIAIgASkACDcACCAaQiB9IRogAUEgaiEBIAJBIGohAiAfQgF9Ih9CAFINAAsLICBC4ABUDQADQCACIAEpAAA3AAAgAiABKQAYNwAYIAIgASkAEDcAECACIAEpAAg3AAggAiABKQA4NwA4IAIgASkAMDcAMCACIAEpACg3ACggAiABKQAgNwAgIAIgASkAWDcAWCACIAEpAFA3AFAgAiABKQBINwBIIAIgASkAQDcAQCACIAEpAGA3AGAgAiABKQBoNwBoIAIgASkAcDcAcCACIAEpAHg3AHggAUGAAWohASACQYABaiECIBpCgAF9IhpCH1YNAAsLIBpCEFoEQCACIAEpAAA3AAAgAiABKQAINwAIIBpCEH0hGiACQRBqIQIgAUEQaiEBCyAaQghaBEAgAiABKQAANwAAIBpCCH0hGiACQQhqIQIgAUEIaiEBCyAaQgRaBEAgAiABKAAANgAAIBpCBH0hGiACQQRqIQIgAUEEaiEBCyAaQgJaBEAgAiABLwAAOwAAIBpCAn0hGiACQQJqIQIgAUECaiEBCyAcIBt9IRwgGlBFBEAgAiABLQAAOgAAIAJBAWohAiABQQFqIQELIBxCAFINAAsLIAUgBmshAUEAIARrIQUCQCAEQQdLBEAgBCEDDAELIAEgBE0EQCAEIQMMAQsgAiAEayEFA0ACQCACIAUpAAA3AAAgBEEBdCEDIAEgBGshASACIARqIQIgBEEDSw0AIAMhBCABIANLDQELC0EAIANrIQULIAIgBWohBAJAIAUgDyACa6wiGiABrSIbIBogG1QbIhqnIgFIIAVBf0pxDQAgBUEBSCABIARqIAJLcQ0AIAIgBCABEAcgAWohAgwDCyACIAQgAyADQR91IgFqIAFzIgEQByABaiECIBogAa0iHn0iHFANAiABIARqIQEDQAJAIBwgHiAcIB5UGyIbQiBUBEAgGyEaDAELIBsiGkIgfSIgQgWIQgF8QgODIh9QRQRAA0AgAiABKQAANwAAIAIgASkAGDcAGCACIAEpABA3ABAgAiABKQAINwAIIBpCIH0hGiABQSBqIQEgAkEgaiECIB9CAX0iH0IAUg0ACwsgIELgAFQNAANAIAIgASkAADcAACACIAEpABg3ABggAiABKQAQNwAQIAIgASkACDcACCACIAEpADg3ADggAiABKQAwNwAwIAIgASkAKDcAKCACIAEpACA3ACAgAiABKQBYNwBYIAIgASkAUDcAUCACIAEpAEg3AEggAiABKQBANwBAIAIgASkAYDcAYCACIAEpAGg3AGggAiABKQBwNwBwIAIgASkAeDcAeCABQYABaiEBIAJBgAFqIQIgGkKAAX0iGkIfVg0ACwsgGkIQWgRAIAIgASkAADcAACACIAEpAAg3AAggGkIQfSEaIAJBEGohAiABQRBqIQELIBpCCFoEQCACIAEpAAA3AAAgGkIIfSEaIAJBCGohAiABQQhqIQELIBpCBFoEQCACIAEoAAA2AAAgGkIEfSEaIAJBBGohAiABQQRqIQELIBpCAloEQCACIAEvAAA7AAAgGkICfSEaIAJBAmohAiABQQJqIQELIBwgG30hHCAaUEUEQCACIAEtAAA6AAAgAkEBaiECIAFBAWohAQsgHFBFDQALDAILAkAgASADTSABIA8gAWusIhogBa0iGyAaIBtUGyIapyIEaiICIANLcQ0AIAMgBGogAUsgASADT3ENACABIAMgBBAHGgwCCyABIAMgAyABayIBIAFBH3UiAWogAXMiARAHIAFqIQIgGiABrSIefSIcUA0BIAEgA2ohAQNAAkAgHCAeIBwgHlQbIhtCIFQEQCAbIRoMAQsgGyIaQiB9IiBCBYhCAXxCA4MiH1BFBEADQCACIAEpAAA3AAAgAiABKQAYNwAYIAIgASkAEDcAECACIAEpAAg3AAggGkIgfSEaIAFBIGohASACQSBqIQIgH0IBfSIfQgBSDQALCyAgQuAAVA0AA0AgAiABKQAANwAAIAIgASkAGDcAGCACIAEpABA3ABAgAiABKQAINwAIIAIgASkAODcAOCACIAEpADA3ADAgAiABKQAoNwAoIAIgASkAIDcAICACIAEpAFg3AFggAiABKQBQNwBQIAIgASkASDcASCACIAEpAEA3AEAgAiABKQBgNwBgIAIgASkAaDcAaCACIAEpAHA3AHAgAiABKQB4NwB4IAFBgAFqIQEgAkGAAWohAiAaQoABfSIaQh9WDQALCyAaQhBaBEAgAiABKQAANwAAIAIgASkACDcACCAaQhB9IRogAkEQaiECIAFBEGohAQsgGkIIWgRAIAIgASkAADcAACAaQgh9IRogAkEIaiECIAFBCGohAQsgGkIEWgRAIAIgASgAADYAACAaQgR9IRogAkEEaiECIAFBBGohAQsgGkICWgRAIAIgAS8AADsAACAaQgJ9IRogAkECaiECIAFBAmohAQsgHCAbfSEcIBpQRQRAIAIgAS0AADoAACACQQFqIQIgAUEBaiEBCyAcUEUNAAsMAQsCQAJAIBYEQAJAIAQgBUkEQCAHKAKYRyAESw0BCyABIARrIQMCQEEAIARrIgVBf0ogDyABa6wiGiAbIBogG1QbIhqnIgIgBUpxDQAgBUEBSCACIANqIAFLcQ0AIAEgAyACEAcgAmohAgwFCyABIAMgBCAEQR91IgFqIAFzIgEQByABaiECIBogAa0iHn0iHFANBCABIANqIQEDQAJAIBwgHiAcIB5UGyIbQiBUBEAgGyEaDAELIBsiGkIgfSIgQgWIQgF8QgODIh9QRQRAA0AgAiABKQAANwAAIAIgASkAGDcAGCACIAEpABA3ABAgAiABKQAINwAIIBpCIH0hGiABQSBqIQEgAkEgaiECIB9CAX0iH0IAUg0ACwsgIELgAFQNAANAIAIgASkAADcAACACIAEpABg3ABggAiABKQAQNwAQIAIgASkACDcACCACIAEpADg3ADggAiABKQAwNwAwIAIgASkAKDcAKCACIAEpACA3ACAgAiABKQBYNwBYIAIgASkAUDcAUCACIAEpAEg3AEggAiABKQBANwBAIAIgASkAYDcAYCACIAEpAGg3AGggAiABKQBwNwBwIAIgASkAeDcAeCABQYABaiEBIAJBgAFqIQIgGkKAAX0iGkIfVg0ACwsgGkIQWgRAIAIgASkAADcAACACIAEpAAg3AAggGkIQfSEaIAJBEGohAiABQRBqIQELIBpCCFoEQCACIAEpAAA3AAAgGkIIfSEaIAJBCGohAiABQQhqIQELIBpCBFoEQCACIAEoAAA2AAAgGkIEfSEaIAJBBGohAiABQQRqIQELIBpCAloEQCACIAEvAAA7AAAgGkICfSEaIAJBAmohAiABQQJqIQELIBwgG30hHCAaUEUEQCACIAEtAAA6AAAgAkEBaiECIAFBAWohAQsgHFBFDQALDAQLIBAgAWsiCUEBaiIGIAUgBSAGSxshAyABIARrIQIgAUEHcUUNAiADRQ0CIAEgAi0AADoAACACQQFqIQIgAUEBaiIGQQdxQQAgA0EBayIFGw0BIAYhASAFIQMgCSEGDAILAkAgBCAFSQRAIAcoAphHIARLDQELIAEgASAEayIGKQAANwAAIAEgBUEBa0EHcUEBaiIDaiECIAUgA2siBEUNAyADIAZqIQEDQCACIAEpAAA3AAAgAUEIaiEBIAJBCGohAiAEQQhrIgQNAAsMAwsgASAEIAUQPyECDAILIAEgAi0AADoAASAJQQFrIQYgA0ECayEFIAJBAWohAgJAIAFBAmoiCkEHcUUNACAFRQ0AIAEgAi0AADoAAiAJQQJrIQYgA0EDayEFIAJBAWohAgJAIAFBA2oiCkEHcUUNACAFRQ0AIAEgAi0AADoAAyAJQQNrIQYgA0EEayEFIAJBAWohAgJAIAFBBGoiCkEHcUUNACAFRQ0AIAEgAi0AADoABCAJQQRrIQYgA0EFayEFIAJBAWohAgJAIAFBBWoiCkEHcUUNACAFRQ0AIAEgAi0AADoABSAJQQVrIQYgA0EGayEFIAJBAWohAgJAIAFBBmoiCkEHcUUNACAFRQ0AIAEgAi0AADoABiAJQQZrIQYgA0EHayEFIAJBAWohAgJAIAFBB2oiCkEHcUUNACAFRQ0AIAEgAi0AADoAByAJQQdrIQYgA0EIayEDIAFBCGohASACQQFqIQIMBgsgCiEBIAUhAwwFCyAKIQEgBSEDDAQLIAohASAFIQMMAwsgCiEBIAUhAwwCCyAKIQEgBSEDDAELIAohASAFIQMLAkACQCAGQRdNBEAgA0UNASADQQFrIQUgA0EHcSIEBEADQCABIAItAAA6AAAgA0EBayEDIAFBAWohASACQQFqIQIgBEEBayIEDQALCyAFQQdJDQEDQCABIAItAAA6AAAgASACLQABOgABIAEgAi0AAjoAAiABIAItAAM6AAMgASACLQAEOgAEIAEgAi0ABToABSABIAItAAY6AAYgASACLQAHOgAHIAFBCGohASACQQhqIQIgA0EIayIDDQALDAELIAMNAQsgASECDAELIAEgBCADED8hAgsgCyEFDAELIAEgAy0AAjoAACABQQFqIQILIAUgFE8NACACIBNJDQELCyAAIAI2AgwgACAFIAhBA3ZrIgE2AgAgACATIAJrQYMCajYCECAAIBQgAWtBDmo2AgQgByAIQQdxIgA2AogBIAcgHUJ/IACthkJ/hYM+AoQBC+cFAQR/IAMgAiACIANLGyEEIAAgAWshAgJAIABBB3FFDQAgBEUNACAAIAItAAA6AAAgA0EBayEGIAJBAWohAiAAQQFqIgdBB3FBACAEQQFrIgUbRQRAIAchACAFIQQgBiEDDAELIAAgAi0AADoAASADQQJrIQYgBEECayEFIAJBAWohAgJAIABBAmoiB0EHcUUNACAFRQ0AIAAgAi0AADoAAiADQQNrIQYgBEEDayEFIAJBAWohAgJAIABBA2oiB0EHcUUNACAFRQ0AIAAgAi0AADoAAyADQQRrIQYgBEEEayEFIAJBAWohAgJAIABBBGoiB0EHcUUNACAFRQ0AIAAgAi0AADoABCADQQVrIQYgBEEFayEFIAJBAWohAgJAIABBBWoiB0EHcUUNACAFRQ0AIAAgAi0AADoABSADQQZrIQYgBEEGayEFIAJBAWohAgJAIABBBmoiB0EHcUUNACAFRQ0AIAAgAi0AADoABiADQQdrIQYgBEEHayEFIAJBAWohAgJAIABBB2oiB0EHcUUNACAFRQ0AIAAgAi0AADoAByADQQhrIQMgBEEIayEEIABBCGohACACQQFqIQIMBgsgByEAIAUhBCAGIQMMBQsgByEAIAUhBCAGIQMMBAsgByEAIAUhBCAGIQMMAwsgByEAIAUhBCAGIQMMAgsgByEAIAUhBCAGIQMMAQsgByEAIAUhBCAGIQMLAkAgA0EXTQRAIARFDQEgBEEBayEBIARBB3EiAwRAA0AgACACLQAAOgAAIARBAWshBCAAQQFqIQAgAkEBaiECIANBAWsi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CyAAIAMtAAAEfiADKQMQBUIAC6dB//8DcSADKAIEEEcLIQUgAxAICyAEQRBqJAAgBQvgAQICfwF+QTAQCSICRQRAIAEEQCABQQA2AgQgAUEONgIAC0EADwsgAkIANwMIIAJBADYCACACQgA3AxAgAkIANwMYIAJCADcDICACQgA3ACUgAFAEQCACDwsCQCAAQv////8AVg0AIACnQQR0EAkiA0UNACACIAM2AgBBACEBQgEhBANAIAMgAUEEdGoiAUIANwIAIAFCADcABSAAIARSBEAgBKchASAEQgF8IQQMAQsLIAIgADcDCCACIAA3AxAgAg8LIAEEQCABQQA2AgQgAUEONgIAC0EAEBAgAhAGQQAL7gECA38BfiMAQRBrIgQkAAJAIARBDGpCBBAXIgNFBEBBfyECDAELAkAgAQRAIAJBgAZxIQUDQAJAIAUgASgCBHFFDQACQCADKQMIQgBUBEAgA0EAOgAADAELIANCADcDECADQQE6AAALIAMgAS8BCBANIAMgAS8BChANIAMtAABFBEAgAEEIaiIABEAgAEEANgIEIABBFDYCAAtBfyECDAQLQX8hAiAAIARBDGpCBBAbQQBIDQMgATMBCiIGUA0AIAAgASgCDCAGEBtBAEgNAwsgASgCACIBDQALC0EAIQILIAMQCAsgBEEQaiQAIAILPAEBfyAABEAgAUGABnEhAQNAIAEgACgCBHEEQCACIAAvAQpqQQRqIQILIAAoAgAiAA0ACwsgAkH//wNxC5wBAQN/IABFBEBBAA8LIAAhAwNAAn8CQAJAIAAvAQgiAUH04AFNBEAgAUEBRg0BIAFB9cYBRg0BDAILIAFBgbICRg0AIAFB9eABRw0BCyAAKAIAIQEgAEEANgIAIAAoAgwQBiAAEAYgASADIAAgA0YbIQMCQCACRQRAQQAhAgwBCyACIAE2AgALIAEMAQsgACICKAIACyIADQALIAMLsgQCBX8BfgJAAkACQCAAIAGtEBciAQRAIAEtAAANAUEAIQAMAgsgBARAIARBADYCBCAEQQ42AgALQQAPC0EAIQADQCABLQAABH4gASkDCCABKQMQfQVCAAtCBFQNASABEAwhByABIAEQDCIGrRATIghFBEBBACECIAQEQCAEQQA2AgQgBEEVNgIACyABEAggAEUNAwNAIAAoAgAhASAAKAIMEAYg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DCAAQRg2AggLDAMLIAAgBiAFLwFqQQAgBCABEQYAIQEgBhALIAFFDQILAkAgDUUEQCABIQMMAQsgACABIAUvAWgQgQEhAyABEAsgA0UNAQsCQCAJRSAMckUEQCADIQEMAQsgACADQQEQgAEhASADEAsgAUUNAQsgASEDDAELQQAhAwsgBUHwAGokACADC4UBAQF/IAFFBEAgAEEIaiIABEAgAEEANgIEIABBEjYCAAtBAA8LQTgQCSIDRQRAIABBCGoiAARAIABBADYCBCAAQQ42AgALQQAPCyADQQA2AhAgA0IANwIIIANCADcDKCADQQA2AgQgAyACNgIAIANCADcDGCADQQA2AjAgACABQTsgAxBCCw8AIAAgASACQQBBABCCAQusAgECfyABRQRAIABBCGoiAARAIABBADYCBCAAQRI2AgALQQAPCwJAIAJBfUsNACACQf//A3FBCEYNACAAQQhqIgAEQCAAQQA2AgQgAEEQNgIAC0EADwsCQEGwwAAQCSIFBEAgBUEANgIIIAVCADcCACAFQYiBAUGogQEgAxs2AqhAIAUgAjYCFCAFIAM6ABAgBUEAOgAPIAVBADsBDCAFIAMgAkF9SyIGcToADiAFQQggAiAGG0H//wNxIAQgBUGIgQFBqIEBIAMbKAIAEQAAIgI2AqxAIAINASAFEDEgBRAGCyAAQQhqIgAEQCAAQQA2AgQgAEEONgIAC0EADwsgACABQTogBRBCIgAEfyAABSAFKAKsQCAFKAKoQCgCBBEDACAFEDEgBRAGQQALC6ABAQF/IAIgACgCBCIDIAIgA0kbIgIEQCAAIAMgAms2AgQCQAJAAkACQCAAKAIcIgMoAhRBAWsOAgEAAgsgA0GgAWogASAAKAIAIAJB3IABKAIAEQgADAILIAAgACgCMCABIAAoAgAgAkHEgAEoAgARBAA2AjAMAQsgASAAKAIAIAIQBxoLIAAgACgCACACajYCACAAIAAoAgggAmo2AggLC7cCAQR/QX4hAgJAIABFDQAgACgCIEUNACAAKAIkIgRFDQAgACgCHCIBRQ0AIAEoAgAgAEcNAAJAAkAgASgCICIDQTlrDjkBAgICAgICAgICAgIBAgICAQICAgICAgICAgICAgICAgICAQICAgICAgICAgICAQICAgICAgICAgEACyADQZoFRg0AIANBKkcN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DCABajYCDCAGIAYoAgggAWo2AgggBSAFKAIUIAFqNgIUIAUgBSgCECABazYCECAGIAYoAhAgAWsiATYCECABDQAgBiAGKAIENgIIC0EAIQRBACEJIAMoAhBFDQAMCgsgAygCHCgCJCEGIAMgAygCGCIBQQFqNgIYIAEgBmotAAAhASADIARBAWo2AhAgAygCBCAEaiABOgAAIAEEQCADKAIQIQQMAQsLIAMoAhwoAixFDQAgAygCECIGIAlNDQAgBQJ/IAUoAjAhBEEAIAMoAgQgCWoiAUUNABogBCABIAYgCWtB1IABKAIAEQAACzYCMAsgA0HnADYCIAsCQCADKAIcKAIsBEAgAygCDCADKAIQIgFBAmpJBH8gBRAKIAMoAhANAkEABSABCyADKAIEaiAFKAIwOwAAIAMgAygCEEECajYCECADQaABakHkgAEoAgARAQAaCyADQfEANgIgIAUQCiADKAIQRQ0BDAcLDAYLIAUoAgQNAQsgAygCPA0AIApFDQEgAygCIEGaBUYNAQsCfyADKAKIASIBRQRAIAMgChCFAQwBCwJAAkACQCADKAKMAUECaw4CAAECCwJ/AkADQAJAAkAgAygCPA0AIAMQLyADKAI8DQAgCg0BQQAMBAsgAygCSCADKAJoai0AACEEIAMgAygC8C0iAUEBajYC8C0gASADKALsLWpBADoAACADIAMoAvAtIgFBAWo2AvAtIAEgAygC7C1qQQA6AAAgAyADKALwLSIBQQFqNgLwLSABIAMoAuwtaiAEOgAAIAMgBEECdGoiASABLwHkAUEBajsB5AEgAyADKAI8QQFrNgI8IAMgAygCaEEBaiIBNgJoIAMoAvAtIAMoAvQtRw0BQQAhBCADIAMoAlgiBkEATgR/IAMoAkggBmoFQQALIAEgBmtBABAPIAMgAygCaDYCWCADKAIAEAogAygCACgCEA0BDAILCyADQQA2AoQuIApBBEYEQCADIAMoAlgiAUEATgR/IAMoAkggAWoFQQALIAMoAmggAWtBARAPIAMgAygCaDYCWCADKAIAEApBA0ECIAMoAgAoAhAbDAILIAMoAvAtBEBBACEEIAMgAygCWCIBQQBOBH8gAygCSCABagVBAAsgAygCaCABa0EAEA8gAyADKAJoNgJYIAMoAgAQCiADKAIAKAIQRQ0BC0EBIQQLIAQL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bGl0ZXJhbC9sZW5ndGhzIHNldABpbnZhbGlkIGNvZGUgbGVuZ3RocyBzZXQAdW5rbm93biBoZWFkZXIgZmxhZ3Mgc2V0AGludmFsaWQgZGlzdGFuY2VzIHNldABpbnZhbGlkIGJpdCBsZW5ndGggcmVwZWF0AEZpbGUgYWxyZWFkeSBleGlzdHMAdG9vIG1hbnkgbGVuZ3RoIG9yIGRpc3RhbmNlIHN5bWJvbHMAaW52YWxpZCBzdG9yZWQgYmxvY2sgbGVuZ3RocwAlcyVzJXMAYnVmZmVyIGVycm9yAE5vIGVycm9yAHN0cmVhbSBlcnJvcgBUZWxsIGVycm9yAEludGVybmFsIGVycm9yAFNlZWsgZXJyb3IAV3JpdGUgZXJyb3IAZmlsZSBlcnJvcgBSZWFkIGVycm9yAFpsaWIgZXJyb3IAZGF0YSBlcnJvcgBDUkMgZXJyb3IAaW5jb21wYXRpYmxlIHZlcnNpb24AaW52YWxpZCBjb2RlIC0tIG1pc3NpbmcgZW5kLW9mLWJsb2NrAGluY29ycmVjdCBoZWFkZXIgY2hlY2sAaW5jb3JyZWN0IGxlbmd0aCBjaGVjawBpbmNvcnJlY3QgZGF0YSBjaGVjawBpbnZhbGlkIGRpc3RhbmNlIHRvbyBmYXIgYmFjawBoZWFkZXIgY3JjIG1pc21hdGNoADEuMi4xMy56bGliLW5nAGludmFsaWQgd2luZG93IHNpemUAUmVhZC1vbmx5IGFyY2hpdmUATm90IGEgemlwIGFyY2hpdmUAUmVzb3VyY2Ugc3RpbGwgaW4gdXNlAE1hbGxvYyBmYWlsdXJlAGludmFsaWQgYmxvY2sgdHlwZQBGYWlsdXJlIHRvIGNyZWF0ZSB0ZW1wb3JhcnkgZmlsZQBDYW4ndCBvcGVuIGZpbGUATm8gc3VjaCBmaWxlAFByZW1hdHVyZSBlbmQgb2YgZmlsZQBDYW4ndCByZW1vdmUgZmlsZQBpbnZhbGlkIGxpdGVyYWwvbGVuZ3RoIGNvZGUAaW52YWxpZCBkaXN0YW5jZSBjb2RlAHVua25vd24gY29tcHJlc3Npb24gbWV0aG9kAHN0cmVhbSBlbmQAQ29tcHJlc3NlZCBkYXRhIGludmFsaWQATXVsdGktZGlzayB6aXAgYXJjaGl2ZXMgbm90IHN1cHBvcnRl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UgAIANIACAAyAAgAsgAIAHIACADyAAgACgAIAIoACABKAAgAygAIACoACACqAAgAagAIAOoACAAaAAgAmgAIAFoACADaAAgAOgAIALoACAB6AAgA+gAIAAYACACGAAgARgAIAMYACAAmAAgApgAIAGYACADmAAgAFgAIAJYACABWAAgA1gAIADYACAC2AAgAdgAIAPYACAAOAAgAjgAIAE4ACADOAAgALgAIAK4ACABuAAgA7gAIAB4ACACeAAgAXgAIAN4ACAA+AAgAvgAIAH4ACAD+AAgAAQAIAIEACABBAAgAwQAIACEACAChAAgAYQAIAOEACAARAAgAkQAIAFEACADRAAgAMQAIALEACABxAAgA8QAIAAkACACJAAgASQAIAMkACAApAAgAqQAIAGkACADpAAgAGQAIAJkACABZAAgA2QAIADkACAC5AAgAeQAIAPkACAAFAAgAhQAIAEUACADFAAgAJQAIAKUACABlAAgA5QAIABUACACVAAgAVQAIANUACAA1AAgAtQAIAHUACAD1AAgADQAIAI0ACABNAAgAzQAIAC0ACACtAAgAbQAIAO0ACAAdAAgAnQAIAF0ACADdAAgAPQAIAL0ACAB9AAgA/QAIABMACQATAQkAkwAJAJMBCQBTAAkAUwEJANMACQDTAQkAMwAJADMBCQCzAAkAswEJAHMACQBzAQkA8wAJAPMBCQALAAkACwEJAIsACQCLAQkASwAJAEsBCQDLAAkAywEJACsACQArAQkAqwAJAKsBCQBrAAkAawEJAOsACQDrAQkAGwAJABsBCQCbAAkAmwEJAFsACQBbAQkA2wAJANsBCQA7AAkAOwEJALsACQC7AQkAewAJAHsBCQD7AAkA+wEJAAcACQAHAQkAhwAJAIcBCQBHAAkARwEJAMcACQDHAQkAJwAJACcBCQCnAAkApwEJAGcACQBnAQkA5wAJAOcBCQAXAAkAFwEJAJcACQCXAQkAVwAJAFcBCQDXAAkA1wEJADcACQA3AQkAtwAJALcBCQB3AAkAdwEJAPcACQD3AQkADwAJAA8BCQCPAAkAjwEJAE8ACQBPAQkAzwAJAM8BCQAvAAkALwEJAK8ACQCvAQkAbwAJAG8BCQDvAAkA7wEJAB8ACQAfAQkAnwAJAJ8BCQBfAAkAXwEJAN8ACQDfAQkAPwAJAD8BCQC/AAkA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SXMgYSBkaXJlY3RvcnkAVGV4dCBmaWxlIGJ1c3kARXhlYyBmb3JtYXQgZXJyb3IASW52YWxpZCBhcmd1bWVudABBcmd1bWVudCBsaXN0IHRvbyBsb25nAFN5bWJvbGljIGxpbmsgbG9vcABGaWxlbmFtZSB0b28gbG9uZwBUb28gbWFueSBvcGVuIGZpbGVzIGluIHN5c3RlbQBObyBmaWxlIGRlc2NyaXB0b3JzIGF2YWlsYWJsZQBCYWQgZmlsZSBkZXNjcmlwdG9yAE5vIGNoaWxkIHByb2Nlc3MAQmFkIGFkZHJlc3MARmlsZSB0b28gbGFyZ2UAVG9vIG1hbnkgbGlua3MATm8gbG9ja3MgYXZhaWxhYmxlAFJlc291cmNlIGRlYWRsb2NrIHdvdWxkIG9jY3VyAFN0YXRlIG5vdCByZWNvdmVyYWJsZQBQcmV2aW91cyBvd25lciBkaWVkAE9wZXJhdGlvbiBjYW5jZWxlZABGdW5jdGlvbiBub3QgaW1wbGVtZW50ZWQATm8gbWVzc2FnZSBvZiBkZXNpcmVkIHR5cGUASWRlbnRpZmllciByZW1vdmVkAERldmljZSBub3QgYSBzdHJlYW0ATm8gZGF0YSBhdmFpbGFibGUARGV2aWNlIHRpbWVvdXQAT3V0IG9mIHN0cmVhbXMgcmVzb3VyY2VzAExpbmsgaGFzIGJlZW4gc2V2ZXJlZABQcm90b2NvbCBlcnJvcgBCYWQgbWVzc2FnZQBGaWxlIGRlc2NyaXB0b3IgaW4gYmFkIHN0YXRlAE5vdCBhIHNvY2tldABEZXN0aW5hdGlvbiBhZGRyZXNzIHJlcXVpcmVkAE1lc3NhZ2UgdG9vIGxhcmdlAFByb3RvY29sIHdyb25nIHR5cGUgZm9yIHNvY2tldABQcm90b2NvbCBub3QgYXZhaWxhYmxlAFByb3RvY29sIG5vdCBzdXBwb3J0ZWQAU29ja2V0IHR5cGUgbm90IHN1cHBvcnRlZABOb3Qgc3VwcG9ydGVkAFByb3RvY29sIGZhbWlseSBub3Qgc3VwcG9ydGVkAEFkZHJlc3MgZmFtaWx5IG5vdCBzdXBwb3J0ZWQgYnkgcHJvdG9jb2wAQWRkcmVzcyBub3QgYXZhaWxhYmxlAE5ldHdvcmsgaXMgZG93bgBOZXR3b3JrIHVucmVhY2hhYmxlAENvbm5lY3Rpb24gcmVzZXQgYnkgbmV0d29yawBDb25uZWN0aW9uIGFib3J0ZWQATm8gYnVmZmVyIHNwYWNlIGF2YWlsYWJsZQBTb2NrZXQgaXMgY29ubmVjdGVkAFNvY2tldCBub3QgY29ubmVjdGVkAENhbm5vdCBzZW5kIGFmdGVyIHNvY2tldCBzaHV0ZG93bgBPcGVyYXRpb24gYWxyZWFkeSBpbiBwcm9ncmVzcwBPcGVyYXRpb24gaW4gcHJvZ3Jlc3MAU3RhbGUgZmlsZSBoYW5kbGUAUmVtb3RlIEkvTyBlcnJvcgBRdW90YSBleGNlZWRlZABObyBtZWRpdW0gZm91bmQAV3JvbmcgbWVkaXVtIHR5cGUATm8gZXJyb3IgaW5mb3JtYXRpb24AQcCAAQuFARMAAAAUAAAAFQAAABYAAAAXAAAAGAAAABkAAAAaAAAAGwAAABwAAAAdAAAAHgAAAB8AAAAgAAAAIQAAACIAAAAjAAAAgERQADEAAAAyAAAAMwAAADQAAAA1AAAANgAAADcAAAA4AAAAOQAAADIAAAAzAAAANAAAADUAAAA2AAAANwAAADgAQfSCAQsCXEQAQbCDAQsQ/////////////////////w==";io(Pi)||(Pi=x(Pi));function Ls(We){try{if(We==Pi&&ce)return new Uint8Array(ce);var tt=ii(We);if(tt)return tt;if(R)return R(We);throw"sync fetching of the wasm failed: you can preload it to Module['wasmBinary'] manually, or emcc.py will do that for you when generating HTML (but not JS)"}catch(It){Ti(It)}}function so(We,tt){var It,nr,$;try{$=Ls(We),nr=new WebAssembly.Module($),It=new WebAssembly.Instance(nr,tt)}catch(Le){var ye=Le.toString();throw te("failed to compile wasm module: "+ye),(ye.includes("imported Memory")||ye.includes("memory import"))&&te("Memory size incompatibility issues may be due to changing INITIAL_MEMORY at runtime to something too large. Use ALLOW_MEMORY_GROWTH to allow any size memory (and also make sure not to set INITIAL_MEMORY at runtime to something smaller than it was at compile time)."),Le}return[It,nr]}function cc(){var We={a:Ma};function tt($,ye){var Le=$.exports;r.asm=Le,Be=r.asm.g,z(Be.buffer),Z=r.asm.W,an(r.asm.h),Ns("wasm-instantiate")}if(Kn("wasm-instantiate"),r.instantiateWasm)try{var It=r.instantiateWasm(We,tt);return It}catch($){return te("Module.instantiateWasm callback failed with error: "+$),!1}var nr=so(Pi,We);return tt(nr[0]),r.asm}function cu(We){return F.getFloat32(We,!0)}function lp(We){return F.getFloat64(We,!0)}function cp(We){return F.getInt16(We,!0)}function Os(We){return F.getInt32(We,!0)}function Dn(We,tt){F.setInt32(We,tt,!0)}function oo(We){for(;We.length>0;){var tt=We.shift();if(typeof tt=="function"){tt(r);continue}var It=tt.func;typeof It=="number"?tt.arg===void 0?Z.get(It)():Z.get(It)(tt.arg):It(tt.arg===void 0?null:tt.arg)}}function Ms(We,tt){var It=new Date(Os((We>>2)*4)*1e3);Dn((tt>>2)*4,It.getUTCSeconds()),Dn((tt+4>>2)*4,It.getUTCMinutes()),Dn((tt+8>>2)*4,It.getUTCHours()),Dn((tt+12>>2)*4,It.getUTCDate()),Dn((tt+16>>2)*4,It.getUTCMonth()),Dn((tt+20>>2)*4,It.getUTCFullYear()-1900),Dn((tt+24>>2)*4,It.getUTCDay()),Dn((tt+36>>2)*4,0),Dn((tt+32>>2)*4,0);var nr=Date.UTC(It.getUTCFullYear(),0,1,0,0,0,0),$=(It.getTime()-nr)/(1e3*60*60*24)|0;return Dn((tt+28>>2)*4,$),Ms.GMTString||(Ms.GMTString=at("GMT")),Dn((tt+40>>2)*4,Ms.GMTString),tt}function ml(We,tt){return Ms(We,tt)}function yl(We,tt,It){Te.copyWithin(We,tt,tt+It)}function ao(We){try{return Be.grow(We-xe.byteLength+65535>>>16),z(Be.buffer),1}catch{}}function Vn(We){var tt=Te.length;We=We>>>0;var It=2147483648;if(We>It)return!1;for(var nr=1;nr<=4;nr*=2){var $=tt*(1+.2/nr);$=Math.min($,We+100663296);var ye=Math.min(It,ke(Math.max(We,$),65536)),Le=ao(ye);if(Le)return!0}return!1}function On(We){fe(We)}function Ni(We){var tt=Date.now()/1e3|0;return We&&Dn((We>>2)*4,tt),tt}function Mn(){if(Mn.called)return;Mn.called=!0;var We=new Date().getFullYear(),tt=new Date(We,0,1),It=new Date(We,6,1),nr=tt.getTimezoneOffset(),$=It.getTimezoneOffset(),ye=Math.max(nr,$);Dn((ds()>>2)*4,ye*60),Dn((gs()>>2)*4,Number(nr!=$));function Le($r){var Gi=$r.toTimeString().match(/\(([A-Za-z ]+)\)$/);return Gi?Gi[1]:"GMT"}var pt=Le(tt),ht=Le(It),Tt=at(pt),er=at(ht);$<nr?(Dn((wi()>>2)*4,Tt),Dn((wi()+4>>2)*4,er)):(Dn((wi()>>2)*4,er),Dn((wi()+4>>2)*4,Tt))}function _i(We){Mn();var tt=Date.UTC(Os((We+20>>2)*4)+1900,Os((We+16>>2)*4),Os((We+12>>2)*4),Os((We+8>>2)*4),Os((We+4>>2)*4),Os((We>>2)*4),0),It=new Date(tt);Dn((We+24>>2)*4,It.getUTCDay());var nr=Date.UTC(It.getUTCFullYear(),0,1,0,0,0,0),$=(It.getTime()-nr)/(1e3*60*60*24)|0;return Dn((We+28>>2)*4,$),It.getTime()/1e3|0}var tr=typeof atob=="function"?atob:function(We){var tt="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",It="",nr,$,ye,Le,pt,ht,Tt,er=0;We=We.replace(/[^A-Za-z0-9\+\/\=]/g,"");do Le=tt.indexOf(We.charAt(er++)),pt=tt.indexOf(We.charAt(er++)),ht=tt.indexOf(We.charAt(er++)),Tt=tt.indexOf(We.charAt(er++)),nr=Le<<2|pt>>4,$=(pt&15)<<4|ht>>2,ye=(ht&3)<<6|Tt,It=It+String.fromCharCode(nr),ht!==64&&(It=It+String.fromCharCode($)),Tt!==64&&(It=It+String.fromCharCode(ye));while(er<We.length);return It};function Oe(We){if(typeof I=="boolean"&&I){var tt;try{tt=Buffer.from(We,"base64")}catch{tt=new Buffer(We,"base64")}return new Uint8Array(tt.buffer,tt.byteOffset,tt.byteLength)}try{for(var It=tr(We),nr=new Uint8Array(It.length),$=0;$<It.length;++$)nr[$]=It.charCodeAt($);return nr}catch{throw new Error("Converting base64 string to bytes failed.")}}function ii(We){if(!!io(We))return Oe(We.slice(ps.length))}var Ma={e:ml,c:yl,d:Vn,a:On,b:Ni,f:_i},hr=cc(),uc=r.___wasm_call_ctors=hr.h,uu=r._zip_ext_count_symlinks=hr.i,Ac=r._zip_file_get_external_attributes=hr.j,El=r._zipstruct_statS=hr.k,DA=r._zipstruct_stat_size=hr.l,Au=r._zipstruct_stat_mtime=hr.m,Ce=r._zipstruct_stat_crc=hr.n,Rt=r._zipstruct_errorS=hr.o,fc=r._zipstruct_error_code_zip=hr.p,Hi=r._zipstruct_stat_comp_size=hr.q,fu=r._zipstruct_stat_comp_method=hr.r,Yt=r._zip_close=hr.s,Cl=r._zip_delete=hr.t,SA=r._zip_dir_add=hr.u,up=r._zip_discard=hr.v,pc=r._zip_error_init_with_code=hr.w,PA=r._zip_get_error=hr.x,Qn=r._zip_file_get_error=hr.y,hi=r._zip_error_strerror=hr.z,hc=r._zip_fclose=hr.A,bA=r._zip_file_add=hr.B,sa=r._free=hr.C,Li=r._malloc=hr.D,_o=r._zip_source_error=hr.E,Ze=r._zip_source_seek=hr.F,lo=r._zip_file_set_external_attributes=hr.G,gc=r._zip_file_set_mtime=hr.H,pu=r._zip_fopen_index=hr.I,ji=r._zip_fread=hr.J,hu=r._zip_get_name=hr.K,xA=r._zip_get_num_entries=hr.L,Ua=r._zip_source_read=hr.M,dc=r._zip_name_locate=hr.N,hs=r._zip_open_from_source=hr.O,_t=r._zip_set_file_compression=hr.P,Fn=r._zip_source_buffer=hr.Q,Ci=r._zip_source_buffer_create=hr.R,oa=r._zip_source_close=hr.S,co=r._zip_source_free=hr.T,Us=r._zip_source_keep=hr.U,aa=r._zip_source_open=hr.V,la=r._zip_source_tell=hr.X,Ho=r._zip_stat_index=hr.Y,wi=r.__get_tzname=hr.Z,gs=r.__get_daylight=hr._,ds=r.__get_timezone=hr.$,ms=r.stackSave=hr.aa,_s=r.stackRestore=hr.ba,Un=r.stackAlloc=hr.ca;r.cwrap=ne,r.getValue=he;var Sn;Wr=function We(){Sn||ys(),Sn||(Wr=We)};function ys(We){if(We=We||A,mr>0||(dt(),mr>0))return;function tt(){Sn||(Sn=!0,r.calledRun=!0,!we&&(Gt(),o(r),r.onRuntimeInitialized&&r.onRuntimeInitialized(),$t()))}r.setStatus?(r.setStatus("Running..."),setTimeout(function(){setTimeout(function(){r.setStatus("")},1),tt()},1)):tt()}if(r.run=ys,r.preInit)for(typeof r.preInit=="function"&&(r.preInit=[r.preInit]);r.preInit.length>0;)r.preInit.pop()();return ys(),e}}();typeof Qb=="object"&&typeof tU=="object"?tU.exports=eU:typeof define=="function"&&define.amd?define([],function(){return eU}):typeof Qb=="object"&&(Qb.createModule=eU)});var Of,Rle,Tle,Nle=Et(()=>{Of=["number","number"],Rle=(ee=>(ee[ee.ZIP_ER_OK=0]="ZIP_ER_OK",ee[ee.ZIP_ER_MULTIDISK=1]="ZIP_ER_MULTIDISK",ee[ee.ZIP_ER_RENAME=2]="ZIP_ER_RENAME",ee[ee.ZIP_ER_CLOSE=3]="ZIP_ER_CLOSE",ee[ee.ZIP_ER_SEEK=4]="ZIP_ER_SEEK",ee[ee.ZIP_ER_READ=5]="ZIP_ER_READ",ee[ee.ZIP_ER_WRITE=6]="ZIP_ER_WRITE",ee[ee.ZIP_ER_CRC=7]="ZIP_ER_CRC",ee[ee.ZIP_ER_ZIPCLOSED=8]="ZIP_ER_ZIPCLOSED",ee[ee.ZIP_ER_NOENT=9]="ZIP_ER_NOENT",ee[ee.ZIP_ER_EXISTS=10]="ZIP_ER_EXISTS",ee[ee.ZIP_ER_OPEN=11]="ZIP_ER_OPEN",ee[ee.ZIP_ER_TMPOPEN=12]="ZIP_ER_TMPOPEN",ee[ee.ZIP_ER_ZLIB=13]="ZIP_ER_ZLIB",ee[ee.ZIP_ER_MEMORY=14]="ZIP_ER_MEMORY",ee[ee.ZIP_ER_CHANGED=15]="ZIP_ER_CHANGED",ee[ee.ZIP_ER_COMPNOTSUPP=16]="ZIP_ER_COMPNOTSUPP",ee[ee.ZIP_ER_EOF=17]="ZIP_ER_EOF",ee[ee.ZIP_ER_INVAL=18]="ZIP_ER_INVAL",ee[ee.ZIP_ER_NOZIP=19]="ZIP_ER_NOZIP",ee[ee.ZIP_ER_INTERNAL=20]="ZIP_ER_INTERNAL",ee[ee.ZIP_ER_INCONS=21]="ZIP_ER_INCONS",ee[ee.ZIP_ER_REMOVE=22]="ZIP_ER_REMOVE",ee[ee.ZIP_ER_DELETED=23]="ZIP_ER_DELETED",ee[ee.ZIP_ER_ENCRNOTSUPP=24]="ZIP_ER_ENCRNOTSUPP",ee[ee.ZIP_ER_RDONLY=25]="ZIP_ER_RDONLY",ee[ee.ZIP_ER_NOPASSWD=26]="ZIP_ER_NOPASSWD",ee[ee.ZIP_ER_WRONGPASSWD=27]="ZIP_ER_WRONGPASSWD",ee[ee.ZIP_ER_OPNOTSUPP=28]="ZIP_ER_OPNOTSUPP",ee[ee.ZIP_ER_INUSE=29]="ZIP_ER_INUSE",ee[ee.ZIP_ER_TELL=30]="ZIP_ER_TELL",ee[ee.ZIP_ER_COMPRESSED_DATA=31]="ZIP_ER_COMPRESSED_DATA",ee))(Rle||{}),Tle=t=>({get HEAPU8(){return t.HEAPU8},errors:Rle,SEEK_SET:0,SEEK_CUR:1,SEEK_END:2,ZIP_CHECKCONS:4,ZIP_EXCL:2,ZIP_RDONLY:16,ZIP_FL_OVERWRITE:8192,ZIP_FL_COMPRESSED:4,ZIP_OPSYS_DOS:0,ZIP_OPSYS_AMIGA:1,ZIP_OPSYS_OPENVMS:2,ZIP_OPSYS_UNIX:3,ZIP_OPSYS_VM_CMS:4,ZIP_OPSYS_ATARI_ST:5,ZIP_OPSYS_OS_2:6,ZIP_OPSYS_MACINTOSH:7,ZIP_OPSYS_Z_SYSTEM:8,ZIP_OPSYS_CPM:9,ZIP_OPSYS_WINDOWS_NTFS:10,ZIP_OPSYS_MVS:11,ZIP_OPSYS_VSE:12,ZIP_OPSYS_ACORN_RISC:13,ZIP_OPSYS_VFAT:14,ZIP_OPSYS_ALTERNATE_MVS:15,ZIP_OPSYS_BEOS:16,ZIP_OPSYS_TANDEM:17,ZIP_OPSYS_OS_400:18,ZIP_OPSYS_OS_X:19,ZIP_CM_DEFAULT:-1,ZIP_CM_STORE:0,ZIP_CM_DEFLATE:8,uint08S:t._malloc(1),uint32S:t._malloc(4),malloc:t._malloc,free:t._free,getValue:t.getValue,openFromSource:t.cwrap("zip_open_from_source","number",["number","number","number"]),close:t.cwrap("zip_close","number",["number"]),discard:t.cwrap("zip_discard",null,["number"]),getError:t.cwrap("zip_get_error","number",["number"]),getName:t.cwrap("zip_get_name","string",["number","number","number"]),getNumEntries:t.cwrap("zip_get_num_entries","number",["number","number"]),delete:t.cwrap("zip_delete","number",["number","number"]),statIndex:t.cwrap("zip_stat_index","number",["number",...Of,"number","number"]),fopenIndex:t.cwrap("zip_fopen_index","number",["number",...Of,"number"]),fread:t.cwrap("zip_fread","number",["number","number","number","number"]),fclose:t.cwrap("zip_fclose","number",["number"]),dir:{add:t.cwrap("zip_dir_add","number",["number","string"])},file:{add:t.cwrap("zip_file_add","number",["number","string","number","number"]),getError:t.cwrap("zip_file_get_error","number",["number"]),getExternalAttributes:t.cwrap("zip_file_get_external_attributes","number",["number",...Of,"number","number","number"]),setExternalAttributes:t.cwrap("zip_file_set_external_attributes","number",["number",...Of,"number","number","number"]),setMtime:t.cwrap("zip_file_set_mtime","number",["number",...Of,"number","number"]),setCompression:t.cwrap("zip_set_file_compression","number",["number",...Of,"number","number"])},ext:{countSymlinks:t.cwrap("zip_ext_count_symlinks","number",["number"])},error:{initWithCode:t.cwrap("zip_error_init_with_code",null,["number","number"]),strerror:t.cwrap("zip_error_strerror","string",["number"])},name:{locate:t.cwrap("zip_name_locate","number",["number","string","number"])},source:{fromUnattachedBuffer:t.cwrap("zip_source_buffer_create","number",["number",...Of,"number","number"]),fromBuffer:t.cwrap("zip_source_buffer","number",["number","number",...Of,"number"]),free:t.cwrap("zip_source_free",null,["number"]),keep:t.cwrap("zip_source_keep",null,["number"]),open:t.cwrap("zip_source_open","number",["number"]),close:t.cwrap("zip_source_close","number",["number"]),seek:t.cwrap("zip_source_seek","number",["number",...Of,"number"]),tell:t.cwrap("zip_source_tell","number",["number"]),read:t.cwrap("zip_source_read","number",["number","number","number"]),error:t.cwrap("zip_source_error","number",["number"])},struct:{statS:t.cwrap("zipstruct_statS","number",[]),statSize:t.cwrap("zipstruct_stat_size","number",["number"]),statCompSize:t.cwrap("zipstruct_stat_comp_size","number",["number"]),statCompMethod:t.cwrap("zipstruct_stat_comp_method","number",["number"]),statMtime:t.cwrap("zipstruct_stat_mtime","number",["number"]),statCrc:t.cwrap("zipstruct_stat_crc","number",["number"]),errorS:t.cwrap("zipstruct_errorS","number",[]),errorCodeZip:t.cwrap("zipstruct_error_code_zip","number",["number"])}})});function rU(t,e){let r=t.indexOf(e);if(r<=0)return null;let o=r;for(;r>=0&&(o=r+e.length,t[o]!==V.sep);){if(t[r-1]===V.sep)return null;r=t.indexOf(e,o)}return t.length>o&&t[o]!==V.sep?null:t.slice(0,o)}var Jl,Lle=Et(()=>{St();St();nA();Jl=class extends Hp{static async openPromise(e,r){let o=new Jl(r);try{return await e(o)}finally{o.saveAndClose()}}constructor(e={}){let r=e.fileExtensions,o=e.readOnlyArchives,a=typeof r>"u"?A=>rU(A,".zip"):A=>{for(let p of r){let h=rU(A,p);if(h)return h}return null},n=(A,p)=>new zi(p,{baseFs:A,readOnly:o,stats:A.statSync(p)}),u=async(A,p)=>{let h={baseFs:A,readOnly:o,stats:await A.statPromise(p)};return()=>new zi(p,h)};super({...e,factorySync:n,factoryPromise:u,getMountPoint:a})}}});function lot(t){if(typeof t=="string"&&String(+t)===t)return+t;if(typeof t=="number"&&Number.isFinite(t))return t<0?Date.now()/1e3:t;if(Ole.types.isDate(t))return t.getTime()/1e3;throw new Error("Invalid time")}function Fb(){return Buffer.from([80,75,5,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0])}var ta,nU,Ole,iU,Mle,Rb,zi,sU=Et(()=>{St();St();St();St();St();St();ta=ve("fs"),nU=ve("stream"),Ole=ve("util"),iU=$e(ve("zlib"));$4();Mle="mixed";Rb=class extends Error{constructor(r,o){super(r);this.name="Libzip Error",this.code=o}},zi=class extends Mu{constructor(r,o={}){super();this.listings=new Map;this.entries=new Map;this.fileSources=new Map;this.fds=new Map;this.nextFd=0;this.ready=!1;this.readOnly=!1;let a=o;if(this.level=typeof a.level<"u"?a.level:Mle,r??=Fb(),typeof r=="string"){let{baseFs:A=new Tn}=a;this.baseFs=A,this.path=r}else this.path=null,this.baseFs=null;if(o.stats)this.stats=o.stats;else if(typeof r=="string")try{this.stats=this.baseFs.statSync(r)}catch(A){if(A.code==="ENOENT"&&a.create)this.stats=Ea.makeDefaultStats();else throw A}else this.stats=Ea.makeDefaultStats();this.libzip=b1();let n=this.libzip.malloc(4);try{let A=0;o.readOnly&&(A|=this.libzip.ZIP_RDONLY,this.readOnly=!0),typeof r=="string"&&(r=a.create?Fb():this.baseFs.readFileSync(r));let p=this.allocateUnattachedSource(r);try{this.zip=this.libzip.openFromSource(p,A,n),this.lzSource=p}catch(h){throw this.libzip.source.free(p),h}if(this.zip===0){let h=this.libzip.struct.errorS();throw this.libzip.error.initWithCode(h,this.libzip.getValue(n,"i32")),this.makeLibzipError(h)}}finally{this.libzip.free(n)}this.listings.set(Bt.root,new Set);let u=this.libzip.getNumEntries(this.zip,0);for(let A=0;A<u;++A){let p=this.libzip.getName(this.zip,A,0);if(V.isAbsolute(p))continue;let h=V.resolve(Bt.root,p);this.registerEntry(h,A),p.endsWith("/")&&this.registerListing(h)}if(this.symlinkCount=this.libzip.ext.countSymlinks(this.zip),this.symlinkCount===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));this.ready=!0}makeLibzipError(r){let o=this.libzip.struct.errorCodeZip(r),a=this.libzip.error.strerror(r),n=new Rb(a,this.libzip.errors[o]);if(o===this.libzip.errors.ZIP_ER_CHANGED)throw new Error(`Assertion failed: Unexpected libzip error: ${n.message}`);return n}getExtractHint(r){for(let o of this.entries.keys()){let a=this.pathUtils.extname(o);if(r.relevantExtensions.has(a))return!0}return!1}getAllFiles(){return Array.from(this.entries.keys())}getRealPath(){if(!this.path)throw new Error("ZipFS don't have real paths when loaded from a buffer");return this.path}prepareClose(){if(!this.ready)throw ar.EBUSY("archive closed, close");Ug(this)}getBufferAndClose(){if(this.prepareClose(),this.entries.size===0)return this.discardAndClose(),Fb();try{if(this.libzip.source.keep(this.lzSource),this.libzip.close(this.zip)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));if(this.libzip.source.open(this.lzSource)===-1)throw this.makeLibzipError(this.libzip.source.error(this.lzSource));if(this.libzip.source.seek(this.lzSource,0,0,this.libzip.SEEK_END)===-1)throw this.makeLibzipError(this.libzip.source.error(this.lzSource));let r=this.libzip.source.tell(this.lzSource);if(r===-1)throw this.makeLibzipError(this.libzip.source.error(this.lzSource));if(this.libzip.source.seek(this.lzSource,0,0,this.libzip.SEEK_SET)===-1)throw this.makeLibzipError(this.libzip.source.error(this.lzSource));let o=this.libzip.malloc(r);if(!o)throw new Error("Couldn't allocate enough memory");try{let a=this.libzip.source.read(this.lzSource,o,r);if(a===-1)throw this.makeLibzipError(this.libzip.source.error(this.lzSource));if(a<r)throw new Error("Incomplete read");if(a>r)throw new Error("Overread");let n=Buffer.from(this.libzip.HEAPU8.subarray(o,o+r));return process.env.YARN_IS_TEST_ENV&&process.env.YARN_ZIP_DATA_EPILOGUE&&(n=Buffer.concat([n,Buffer.from(process.env.YARN_ZIP_DATA_EPILOGUE)])),n}finally{this.libzip.free(o)}}finally{this.libzip.source.close(this.lzSource),this.libzip.source.free(this.lzSource),this.ready=!1}}discardAndClose(){this.prepareClose(),this.libzip.discard(this.zip),this.ready=!1}saveAndClose(){if(!this.path||!this.baseFs)throw new Error("ZipFS cannot be saved and must be discarded when loaded from a buffer");if(this.readOnly){this.discardAndClose();return}let r=this.baseFs.existsSync(this.path)||this.stats.mode===Ea.DEFAULT_MODE?void 0:this.stats.mode;this.baseFs.writeFileSync(this.path,this.getBufferAndClose(),{mode:r}),this.ready=!1}resolve(r){return V.resolve(Bt.root,r)}async openPromise(r,o,a){return this.openSync(r,o,a)}openSync(r,o,a){let n=this.nextFd++;return this.fds.set(n,{cursor:0,p:r}),n}hasOpenFileHandles(){return!!this.fds.size}async opendirPromise(r,o){return this.opendirSync(r,o)}opendirSync(r,o={}){let a=this.resolveFilename(`opendir '${r}'`,r);if(!this.entries.has(a)&&!this.listings.has(a))throw ar.ENOENT(`opendir '${r}'`);let n=this.listings.get(a);if(!n)throw ar.ENOTDIR(`opendir '${r}'`);let u=[...n],A=this.openSync(a,"r");return PD(this,a,u,{onClose:()=>{this.closeSync(A)}})}async readPromise(r,o,a,n,u){return this.readSync(r,o,a,n,u)}readSync(r,o,a=0,n=o.byteLength,u=-1){let A=this.fds.get(r);if(typeof A>"u")throw ar.EBADF("read");let p=u===-1||u===null?A.cursor:u,h=this.readFileSync(A.p);h.copy(o,a,p,p+n);let E=Math.max(0,Math.min(h.length-p,n));return(u===-1||u===null)&&(A.cursor+=E),E}async writePromise(r,o,a,n,u){return typeof o=="string"?this.writeSync(r,o,u):this.writeSync(r,o,a,n,u)}writeSync(r,o,a,n,u){throw typeof this.fds.get(r)>"u"?ar.EBADF("read"):new Error("Unimplemented")}async closePromise(r){return this.closeSync(r)}closeSync(r){if(typeof this.fds.get(r)>"u")throw ar.EBADF("read");this.fds.delete(r)}createReadStream(r,{encoding:o}={}){if(r===null)throw new Error("Unimplemented");let a=this.openSync(r,"r"),n=Object.assign(new nU.PassThrough({emitClose:!0,autoDestroy:!0,destroy:(A,p)=>{clearImmediate(u),this.closeSync(a),p(A)}}),{close(){n.destroy()},bytesRead:0,path:r,pending:!1}),u=setImmediate(async()=>{try{let A=await this.readFilePromise(r,o);n.bytesRead=A.length,n.end(A)}catch(A){n.destroy(A)}});return n}createWriteStream(r,{encoding:o}={}){if(this.readOnly)throw ar.EROFS(`open '${r}'`);if(r===null)throw new Error("Unimplemented");let a=[],n=this.openSync(r,"w"),u=Object.assign(new nU.PassThrough({autoDestroy:!0,emitClose:!0,destroy:(A,p)=>{try{A?p(A):(this.writeFileSync(r,Buffer.concat(a),o),p(null))}catch(h){p(h)}finally{this.closeSync(n)}}}),{close(){u.destroy()},bytesWritten:0,path:r,pending:!1});return u.on("data",A=>{let p=Buffer.from(A);u.bytesWritten+=p.length,a.push(p)}),u}async realpathPromise(r){return this.realpathSync(r)}realpathSync(r){let o=this.resolveFilename(`lstat '${r}'`,r);if(!this.entries.has(o)&&!this.listings.has(o))throw ar.ENOENT(`lstat '${r}'`);return o}async existsPromise(r){return this.existsSync(r)}existsSync(r){if(!this.ready)throw ar.EBUSY(`archive closed, existsSync '${r}'`);if(this.symlinkCount===0){let a=V.resolve(Bt.root,r);return this.entries.has(a)||this.listings.has(a)}let o;try{o=this.resolveFilename(`stat '${r}'`,r,void 0,!1)}catch{return!1}return o===void 0?!1:this.entries.has(o)||this.listings.has(o)}async accessPromise(r,o){return this.accessSync(r,o)}accessSync(r,o=ta.constants.F_OK){let a=this.resolveFilename(`access '${r}'`,r);if(!this.entries.has(a)&&!this.listings.has(a))throw ar.ENOENT(`access '${r}'`);if(this.readOnly&&o&ta.constants.W_OK)throw ar.EROFS(`access '${r}'`)}async statPromise(r,o={bigint:!1}){return o.bigint?this.statSync(r,{bigint:!0}):this.statSync(r)}statSync(r,o={bigint:!1,throwIfNoEntry:!0}){let a=this.resolveFilename(`stat '${r}'`,r,void 0,o.throwIfNoEntry);if(a!==void 0){if(!this.entries.has(a)&&!this.listings.has(a)){if(o.throwIfNoEntry===!1)return;throw ar.ENOENT(`stat '${r}'`)}if(r[r.length-1]==="/"&&!this.listings.has(a))throw ar.ENOTDIR(`stat '${r}'`);return this.statImpl(`stat '${r}'`,a,o)}}async fstatPromise(r,o){return this.fstatSync(r,o)}fstatSync(r,o){let a=this.fds.get(r);if(typeof a>"u")throw ar.EBADF("fstatSync");let{p:n}=a,u=this.resolveFilename(`stat '${n}'`,n);if(!this.entries.has(u)&&!this.listings.has(u))throw ar.ENOENT(`stat '${n}'`);if(n[n.length-1]==="/"&&!this.listings.has(u))throw ar.ENOTDIR(`stat '${n}'`);return this.statImpl(`fstat '${n}'`,u,o)}async lstatPromise(r,o={bigint:!1}){return o.bigint?this.lstatSync(r,{bigint:!0}):this.lstatSync(r)}lstatSync(r,o={bigint:!1,throwIfNoEntry:!0}){let a=this.resolveFilename(`lstat '${r}'`,r,!1,o.throwIfNoEntry);if(a!==void 0){if(!this.entries.has(a)&&!this.listings.has(a)){if(o.throwIfNoEntry===!1)return;throw ar.ENOENT(`lstat '${r}'`)}if(r[r.length-1]==="/"&&!this.listings.has(a))throw ar.ENOTDIR(`lstat '${r}'`);return this.statImpl(`lstat '${r}'`,a,o)}}statImpl(r,o,a={}){let n=this.entries.get(o);if(typeof n<"u"){let u=this.libzip.struct.statS();if(this.libzip.statIndex(this.zip,n,0,0,u)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));let p=this.stats.uid,h=this.stats.gid,E=this.libzip.struct.statSize(u)>>>0,I=512,v=Math.ceil(E/I),x=(this.libzip.struct.statMtime(u)>>>0)*1e3,C=x,R=x,L=x,U=new Date(C),J=new Date(R),te=new Date(L),ae=new Date(x),fe=this.listings.has(o)?ta.constants.S_IFDIR:this.isSymbolicLink(n)?ta.constants.S_IFLNK:ta.constants.S_IFREG,ce=fe===ta.constants.S_IFDIR?493:420,me=fe|this.getUnixMode(n,ce)&511,he=this.libzip.struct.statCrc(u),Be=Object.assign(new Ea.StatEntry,{uid:p,gid:h,size:E,blksize:I,blocks:v,atime:U,birthtime:J,ctime:te,mtime:ae,atimeMs:C,birthtimeMs:R,ctimeMs:L,mtimeMs:x,mode:me,crc:he});return a.bigint===!0?Ea.convertToBigIntStats(Be):Be}if(this.listings.has(o)){let u=this.stats.uid,A=this.stats.gid,p=0,h=512,E=0,I=this.stats.mtimeMs,v=this.stats.mtimeMs,x=this.stats.mtimeMs,C=this.stats.mtimeMs,R=new Date(I),L=new Date(v),U=new Date(x),J=new Date(C),te=ta.constants.S_IFDIR|493,ae=0,fe=Object.assign(new Ea.StatEntry,{uid:u,gid:A,size:p,blksize:h,blocks:E,atime:R,birthtime:L,ctime:U,mtime:J,atimeMs:I,birthtimeMs:v,ctimeMs:x,mtimeMs:C,mode:te,crc:ae});return a.bigint===!0?Ea.convertToBigIntStats(fe):fe}throw new Error("Unreachable")}getUnixMode(r,o){if(this.libzip.file.getExternalAttributes(this.zip,r,0,0,this.libzip.uint08S,this.libzip.uint32S)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));return this.libzip.getValue(this.libzip.uint08S,"i8")>>>0!==this.libzip.ZIP_OPSYS_UNIX?o:this.libzip.getValue(this.libzip.uint32S,"i32")>>>16}registerListing(r){let o=this.listings.get(r);if(o)return o;this.registerListing(V.dirname(r)).add(V.basename(r));let n=new Set;return this.listings.set(r,n),n}registerEntry(r,o){this.registerListing(V.dirname(r)).add(V.basename(r)),this.entries.set(r,o)}unregisterListing(r){this.listings.delete(r),this.listings.get(V.dirname(r))?.delete(V.basename(r))}unregisterEntry(r){this.unregisterListing(r);let o=this.entries.get(r);this.entries.delete(r),!(typeof o>"u")&&(this.fileSources.delete(o),this.isSymbolicLink(o)&&this.symlinkCount--)}deleteEntry(r,o){if(this.unregisterEntry(r),this.libzip.delete(this.zip,o)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip))}resolveFilename(r,o,a=!0,n=!0){if(!this.ready)throw ar.EBUSY(`archive closed, ${r}`);let u=V.resolve(Bt.root,o);if(u==="/")return Bt.root;let A=this.entries.get(u);if(a&&A!==void 0)if(this.symlinkCount!==0&&this.isSymbolicLink(A)){let p=this.getFileSource(A).toString();return this.resolveFilename(r,V.resolve(V.dirname(u),p),!0,n)}else return u;for(;;){let p=this.resolveFilename(r,V.dirname(u),!0,n);if(p===void 0)return p;let h=this.listings.has(p),E=this.entries.has(p);if(!h&&!E){if(n===!1)return;throw ar.ENOENT(r)}if(!h)throw ar.ENOTDIR(r);if(u=V.resolve(p,V.basename(u)),!a||this.symlinkCount===0)break;let I=this.libzip.name.locate(this.zip,u.slice(1),0);if(I===-1)break;if(this.isSymbolicLink(I)){let v=this.getFileSource(I).toString();u=V.resolve(V.dirname(u),v)}else break}return u}allocateBuffer(r){Buffer.isBuffer(r)||(r=Buffer.from(r));let o=this.libzip.malloc(r.byteLength);if(!o)throw new Error("Couldn't allocate enough memory");return new Uint8Array(this.libzip.HEAPU8.buffer,o,r.byteLength).set(r),{buffer:o,byteLength:r.byteLength}}allocateUnattachedSource(r){let o=this.libzip.struct.errorS(),{buffer:a,byteLength:n}=this.allocateBuffer(r),u=this.libzip.source.fromUnattachedBuffer(a,n,0,1,o);if(u===0)throw this.libzip.free(o),this.makeLibzipError(o);return u}allocateSource(r){let{buffer:o,byteLength:a}=this.allocateBuffer(r),n=this.libzip.source.fromBuffer(this.zip,o,a,0,1);if(n===0)throw this.libzip.free(o),this.makeLibzipError(this.libzip.getError(this.zip));return n}setFileSource(r,o){let a=Buffer.isBuffer(o)?o:Buffer.from(o),n=V.relative(Bt.root,r),u=this.allocateSource(o);try{let A=this.libzip.file.add(this.zip,n,u,this.libzip.ZIP_FL_OVERWRITE);if(A===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));if(this.level!=="mixed"){let p=this.level===0?this.libzip.ZIP_CM_STORE:this.libzip.ZIP_CM_DEFLATE;if(this.libzip.file.setCompression(this.zip,A,0,p,this.level)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip))}return this.fileSources.set(A,a),A}catch(A){throw this.libzip.source.free(u),A}}isSymbolicLink(r){if(this.symlinkCount===0)return!1;if(this.libzip.file.getExternalAttributes(this.zip,r,0,0,this.libzip.uint08S,this.libzip.uint32S)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));return this.libzip.getValue(this.libzip.uint08S,"i8")>>>0!==this.libzip.ZIP_OPSYS_UNIX?!1:(this.libzip.getValue(this.libzip.uint32S,"i32")>>>16&ta.constants.S_IFMT)===ta.constants.S_IFLNK}getFileSource(r,o={asyncDecompress:!1}){let a=this.fileSources.get(r);if(typeof a<"u")return a;let n=this.libzip.struct.statS();if(this.libzip.statIndex(this.zip,r,0,0,n)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));let A=this.libzip.struct.statCompSize(n),p=this.libzip.struct.statCompMethod(n),h=this.libzip.malloc(A);try{let E=this.libzip.fopenIndex(this.zip,r,0,this.libzip.ZIP_FL_COMPRESSED);if(E===0)throw this.makeLibzipError(this.libzip.getError(this.zip));try{let I=this.libzip.fread(E,h,A,0);if(I===-1)throw this.makeLibzipError(this.libzip.file.getError(E));if(I<A)throw new Error("Incomplete read");if(I>A)throw new Error("Overread");let v=this.libzip.HEAPU8.subarray(h,h+A),x=Buffer.from(v);if(p===0)return this.fileSources.set(r,x),x;if(o.asyncDecompress)return new Promise((C,R)=>{iU.default.inflateRaw(x,(L,U)=>{L?R(L):(this.fileSources.set(r,U),C(U))})});{let C=iU.default.inflateRawSync(x);return this.fileSources.set(r,C),C}}finally{this.libzip.fclose(E)}}finally{this.libzip.free(h)}}async fchmodPromise(r,o){return this.chmodPromise(this.fdToPath(r,"fchmod"),o)}fchmodSync(r,o){return this.chmodSync(this.fdToPath(r,"fchmodSync"),o)}async chmodPromise(r,o){return this.chmodSync(r,o)}chmodSync(r,o){if(this.readOnly)throw ar.EROFS(`chmod '${r}'`);o&=493;let a=this.resolveFilename(`chmod '${r}'`,r,!1),n=this.entries.get(a);if(typeof n>"u")throw new Error(`Assertion failed: The entry should have been registered (${a})`);let A=this.getUnixMode(n,ta.constants.S_IFREG|0)&-512|o;if(this.libzip.file.setExternalAttributes(this.zip,n,0,0,this.libzip.ZIP_OPSYS_UNIX,A<<16)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip))}async fchownPromise(r,o,a){return this.chownPromise(this.fdToPath(r,"fchown"),o,a)}fchownSync(r,o,a){return this.chownSync(this.fdToPath(r,"fchownSync"),o,a)}async chownPromise(r,o,a){return this.chownSync(r,o,a)}chownSync(r,o,a){throw new Error("Unimplemented")}async renamePromise(r,o){return this.renameSync(r,o)}renameSync(r,o){throw new Error("Unimplemented")}async copyFilePromise(r,o,a){let{indexSource:n,indexDest:u,resolvedDestP:A}=this.prepareCopyFile(r,o,a),p=await this.getFileSource(n,{asyncDecompress:!0}),h=this.setFileSource(A,p);h!==u&&this.registerEntry(A,h)}copyFileSync(r,o,a=0){let{indexSource:n,indexDest:u,resolvedDestP:A}=this.prepareCopyFile(r,o,a),p=this.getFileSource(n),h=this.setFileSource(A,p);h!==u&&this.registerEntry(A,h)}prepareCopyFile(r,o,a=0){if(this.readOnly)throw ar.EROFS(`copyfile '${r} -> '${o}'`);if((a&ta.constants.COPYFILE_FICLONE_FORCE)!==0)throw ar.ENOSYS("unsupported clone operation",`copyfile '${r}' -> ${o}'`);let n=this.resolveFilename(`copyfile '${r} -> ${o}'`,r),u=this.entries.get(n);if(typeof u>"u")throw ar.EINVAL(`copyfile '${r}' -> '${o}'`);let A=this.resolveFilename(`copyfile '${r}' -> ${o}'`,o),p=this.entries.get(A);if((a&(ta.constants.COPYFILE_EXCL|ta.constants.COPYFILE_FICLONE_FORCE))!==0&&typeof p<"u")throw ar.EEXIST(`copyfile '${r}' -> '${o}'`);return{indexSource:u,resolvedDestP:A,indexDest:p}}async appendFilePromise(r,o,a){if(this.readOnly)throw ar.EROFS(`open '${r}'`);return typeof a>"u"?a={flag:"a"}:typeof a=="string"?a={flag:"a",encoding:a}:typeof a.flag>"u"&&(a={flag:"a",...a}),this.writeFilePromise(r,o,a)}appendFileSync(r,o,a={}){if(this.readOnly)throw ar.EROFS(`open '${r}'`);return typeof a>"u"?a={flag:"a"}:typeof a=="string"?a={flag:"a",encoding:a}:typeof a.flag>"u"&&(a={flag:"a",...a}),this.writeFileSync(r,o,a)}fdToPath(r,o){let a=this.fds.get(r)?.p;if(typeof a>"u")throw ar.EBADF(o);return a}async writeFilePromise(r,o,a){let{encoding:n,mode:u,index:A,resolvedP:p}=this.prepareWriteFile(r,a);A!==void 0&&typeof a=="object"&&a.flag&&a.flag.includes("a")&&(o=Buffer.concat([await this.getFileSource(A,{asyncDecompress:!0}),Buffer.from(o)])),n!==null&&(o=o.toString(n));let h=this.setFileSource(p,o);h!==A&&this.registerEntry(p,h),u!==null&&await this.chmodPromise(p,u)}writeFileSync(r,o,a){let{encoding:n,mode:u,index:A,resolvedP:p}=this.prepareWriteFile(r,a);A!==void 0&&typeof a=="object"&&a.flag&&a.flag.includes("a")&&(o=Buffer.concat([this.getFileSource(A),Buffer.from(o)])),n!==null&&(o=o.toString(n));let h=this.setFileSource(p,o);h!==A&&this.registerEntry(p,h),u!==null&&this.chmodSync(p,u)}prepareWriteFile(r,o){if(typeof r=="number"&&(r=this.fdToPath(r,"read")),this.readOnly)throw ar.EROFS(`open '${r}'`);let a=this.resolveFilename(`open '${r}'`,r);if(this.listings.has(a))throw ar.EISDIR(`open '${r}'`);let n=null,u=null;typeof o=="string"?n=o:typeof o=="object"&&({encoding:n=null,mode:u=null}=o);let A=this.entries.get(a);return{encoding:n,mode:u,resolvedP:a,index:A}}async unlinkPromise(r){return this.unlinkSync(r)}unlinkSync(r){if(this.readOnly)throw ar.EROFS(`unlink '${r}'`);let o=this.resolveFilename(`unlink '${r}'`,r);if(this.listings.has(o))throw ar.EISDIR(`unlink '${r}'`);let a=this.entries.get(o);if(typeof a>"u")throw ar.EINVAL(`unlink '${r}'`);this.deleteEntry(o,a)}async utimesPromise(r,o,a){return this.utimesSync(r,o,a)}utimesSync(r,o,a){if(this.readOnly)throw ar.EROFS(`utimes '${r}'`);let n=this.resolveFilename(`utimes '${r}'`,r);this.utimesImpl(n,a)}async lutimesPromise(r,o,a){return this.lutimesSync(r,o,a)}lutimesSync(r,o,a){if(this.readOnly)throw ar.EROFS(`lutimes '${r}'`);let n=this.resolveFilename(`utimes '${r}'`,r,!1);this.utimesImpl(n,a)}utimesImpl(r,o){this.listings.has(r)&&(this.entries.has(r)||this.hydrateDirectory(r));let a=this.entries.get(r);if(a===void 0)throw new Error("Unreachable");if(this.libzip.file.setMtime(this.zip,a,0,lot(o),0)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip))}async mkdirPromise(r,o){return this.mkdirSync(r,o)}mkdirSync(r,{mode:o=493,recursive:a=!1}={}){if(a)return this.mkdirpSync(r,{chmod:o});if(this.readOnly)throw ar.EROFS(`mkdir '${r}'`);let n=this.resolveFilename(`mkdir '${r}'`,r);if(this.entries.has(n)||this.listings.has(n))throw ar.EEXIST(`mkdir '${r}'`);this.hydrateDirectory(n),this.chmodSync(n,o)}async rmdirPromise(r,o){return this.rmdirSync(r,o)}rmdirSync(r,{recursive:o=!1}={}){if(this.readOnly)throw ar.EROFS(`rmdir '${r}'`);if(o){this.removeSync(r);return}let a=this.resolveFilename(`rmdir '${r}'`,r),n=this.listings.get(a);if(!n)throw ar.ENOTDIR(`rmdir '${r}'`);if(n.size>0)throw ar.ENOTEMPTY(`rmdir '${r}'`);let u=this.entries.get(a);if(typeof u>"u")throw ar.EINVAL(`rmdir '${r}'`);this.deleteEntry(r,u)}hydrateDirectory(r){let o=this.libzip.dir.add(this.zip,V.relative(Bt.root,r));if(o===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));return this.registerListing(r),this.registerEntry(r,o),o}async linkPromise(r,o){return this.linkSync(r,o)}linkSync(r,o){throw ar.EOPNOTSUPP(`link '${r}' -> '${o}'`)}async symlinkPromise(r,o){return this.symlinkSync(r,o)}symlinkSync(r,o){if(this.readOnly)throw ar.EROFS(`symlink '${r}' -> '${o}'`);let a=this.resolveFilename(`symlink '${r}' -> '${o}'`,o);if(this.listings.has(a))throw ar.EISDIR(`symlink '${r}' -> '${o}'`);if(this.entries.has(a))throw ar.EEXIST(`symlink '${r}' -> '${o}'`);let n=this.setFileSource(a,r);if(this.registerEntry(a,n),this.libzip.file.setExternalAttributes(this.zip,n,0,0,this.libzip.ZIP_OPSYS_UNIX,(ta.constants.S_IFLNK|511)<<16)===-1)throw this.makeLibzipError(this.libzip.getError(this.zip));this.symlinkCount+=1}async readFilePromise(r,o){typeof o=="object"&&(o=o?o.encoding:void 0);let a=await this.readFileBuffer(r,{asyncDecompress:!0});return o?a.toString(o):a}readFileSync(r,o){typeof o=="object"&&(o=o?o.encoding:void 0);let a=this.readFileBuffer(r);return o?a.toString(o):a}readFileBuffer(r,o={asyncDecompress:!1}){typeof r=="number"&&(r=this.fdToPath(r,"read"));let a=this.resolveFilename(`open '${r}'`,r);if(!this.entries.has(a)&&!this.listings.has(a))throw ar.ENOENT(`open '${r}'`);if(r[r.length-1]==="/"&&!this.listings.has(a))throw ar.ENOTDIR(`open '${r}'`);if(this.listings.has(a))throw ar.EISDIR("read");let n=this.entries.get(a);if(n===void 0)throw new Error("Unreachable");return this.getFileSource(n,o)}async readdirPromise(r,o){return this.readdirSync(r,o)}readdirSync(r,o){let a=this.resolveFilename(`scandir '${r}'`,r);if(!this.entries.has(a)&&!this.listings.has(a))throw ar.ENOENT(`scandir '${r}'`);let n=this.listings.get(a);if(!n)throw ar.ENOTDIR(`scandir '${r}'`);if(o?.recursive)if(o?.withFileTypes){let u=Array.from(n,A=>Object.assign(this.statImpl("lstat",V.join(r,A)),{name:A,path:Bt.dot}));for(let A of u){if(!A.isDirectory())continue;let p=V.join(A.path,A.name),h=this.listings.get(V.join(a,p));for(let E of h)u.push(Object.assign(this.statImpl("lstat",V.join(r,p,E)),{name:E,path:p}))}return u}else{let u=[...n];for(let A of u){let p=this.listings.get(V.join(a,A));if(!(typeof p>"u"))for(let h of p)u.push(V.join(A,h))}return u}else return o?.withFileTypes?Array.from(n,u=>Object.assign(this.statImpl("lstat",V.join(r,u)),{name:u,path:void 0})):[...n]}async readlinkPromise(r){let o=this.prepareReadlink(r);return(await this.getFileSource(o,{asyncDecompress:!0})).toString()}readlinkSync(r){let o=this.prepareReadlink(r);return this.getFileSource(o).toString()}prepareReadlink(r){let o=this.resolveFilename(`readlink '${r}'`,r,!1);if(!this.entries.has(o)&&!this.listings.has(o))throw ar.ENOENT(`readlink '${r}'`);if(r[r.length-1]==="/"&&!this.listings.has(o))throw ar.ENOTDIR(`open '${r}'`);if(this.listings.has(o))throw ar.EINVAL(`readlink '${r}'`);let a=this.entries.get(o);if(a===void 0)throw new Error("Unreachable");if(!this.isSymbolicLink(a))throw ar.EINVAL(`readlink '${r}'`);return a}async truncatePromise(r,o=0){let a=this.resolveFilename(`open '${r}'`,r),n=this.entries.get(a);if(typeof n>"u")throw ar.EINVAL(`open '${r}'`);let u=await this.getFileSource(n,{asyncDecompress:!0}),A=Buffer.alloc(o,0);return u.copy(A),await this.writeFilePromise(r,A)}truncateSync(r,o=0){let a=this.resolveFilename(`open '${r}'`,r),n=this.entries.get(a);if(typeof n>"u")throw ar.EINVAL(`open '${r}'`);let u=this.getFileSource(n),A=Buffer.alloc(o,0);return u.copy(A),this.writeFileSync(r,A)}async ftruncatePromise(r,o){return this.truncatePromise(this.fdToPath(r,"ftruncate"),o)}ftruncateSync(r,o){return this.truncateSync(this.fdToPath(r,"ftruncateSync"),o)}watch(r,o,a){let n;switch(typeof o){case"function":case"string":case"undefined":n=!0;break;default:({persistent:n=!0}=o);break}if(!n)return{on:()=>{},close:()=>{}};let u=setInterval(()=>{},24*60*60*1e3);return{on:()=>{},close:()=>{clearInterval(u)}}}watchFile(r,o,a){let n=V.resolve(Bt.root,r);return ny(this,n,o,a)}unwatchFile(r,o){let a=V.resolve(Bt.root,r);return Mg(this,a,o)}}});function _le(t,e,r=Buffer.alloc(0),o){let a=new zi(r),n=I=>I===e||I.startsWith(`${e}/`)?I.slice(0,e.length):null,u=async(I,v)=>()=>a,A=(I,v)=>a,p={...t},h=new Tn(p),E=new Hp({baseFs:h,getMountPoint:n,factoryPromise:u,factorySync:A,magicByte:21,maxAge:1/0,typeCheck:o?.typeCheck});return Kw(Ule.default,new jp(E)),a}var Ule,Hle=Et(()=>{St();Ule=$e(ve("fs"));sU()});var jle=Et(()=>{Lle();sU();Hle()});var x1={};Vt(x1,{DEFAULT_COMPRESSION_LEVEL:()=>Mle,LibzipError:()=>Rb,ZipFS:()=>zi,ZipOpenFS:()=>Jl,getArchivePart:()=>rU,getLibzipPromise:()=>uot,getLibzipSync:()=>cot,makeEmptyArchive:()=>Fb,mountMemoryDrive:()=>_le});function cot(){return b1()}async function uot(){return b1()}var Gle,nA=Et(()=>{$4();Gle=$e(Fle());Nle();jle();Qle(()=>{let t=(0,Gle.default)();return Tle(t)})});var RE,qle=Et(()=>{St();jt();k1();RE=class extends nt{constructor(){super(...arguments);this.cwd=ge.String("--cwd",process.cwd(),{description:"The directory to run the command in"});this.commandName=ge.String();this.args=ge.Proxy()}async execute(){let r=this.args.length>0?`${this.commandName} ${this.args.join(" ")}`:this.commandName;return await TE(r,[],{cwd:ue.toPortablePath(this.cwd),stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr})}};RE.usage={description:"run a command using yarn's portable shell",details:` + This command will run a command using Yarn's portable shell. + + Make sure to escape glob patterns, redirections, and other features that might be expanded by your own shell. + + Note: To escape something from Yarn's shell, you might have to escape it twice, the first time from your own shell. + + Note: Don't use this command in Yarn scripts, as Yarn's shell is automatically used. + + For a list of features, visit: https://github.com/yarnpkg/berry/blob/master/packages/yarnpkg-shell/README.md. + `,examples:[["Run a simple command","$0 echo Hello"],["Run a command with a glob pattern","$0 echo '*.js'"],["Run a command with a redirection","$0 echo Hello World '>' hello.txt"],["Run a command with an escaped glob pattern (The double escape is needed in Unix shells)",`$0 echo '"*.js"'`],["Run a command with a variable (Double quotes are needed in Unix shells, to prevent them from expanding the variable)",'$0 "GREETING=Hello echo $GREETING World"']]}});var al,Yle=Et(()=>{al=class extends Error{constructor(e){super(e),this.name="ShellError"}}});var Lb={};Vt(Lb,{fastGlobOptions:()=>Vle,isBraceExpansion:()=>oU,isGlobPattern:()=>Aot,match:()=>fot,micromatchOptions:()=>Nb});function Aot(t){if(!Tb.default.scan(t,Nb).isGlob)return!1;try{Tb.default.parse(t,Nb)}catch{return!1}return!0}function fot(t,{cwd:e,baseFs:r}){return(0,Wle.default)(t,{...Vle,cwd:ue.fromPortablePath(e),fs:FD(Kle.default,new jp(r))})}function oU(t){return Tb.default.scan(t,Nb).isBrace}var Wle,Kle,Tb,Nb,Vle,Jle=Et(()=>{St();Wle=$e(RP()),Kle=$e(ve("fs")),Tb=$e(Zo()),Nb={strictBrackets:!0},Vle={onlyDirectories:!1,onlyFiles:!1}});function aU(){}function lU(){for(let t of kd)t.kill()}function $le(t,e,r,o){return a=>{let n=a[0]instanceof iA.Transform?"pipe":a[0],u=a[1]instanceof iA.Transform?"pipe":a[1],A=a[2]instanceof iA.Transform?"pipe":a[2],p=(0,Xle.default)(t,e,{...o,stdio:[n,u,A]});return kd.add(p),kd.size===1&&(process.on("SIGINT",aU),process.on("SIGTERM",lU)),a[0]instanceof iA.Transform&&a[0].pipe(p.stdin),a[1]instanceof iA.Transform&&p.stdout.pipe(a[1],{end:!1}),a[2]instanceof iA.Transform&&p.stderr.pipe(a[2],{end:!1}),{stdin:p.stdin,promise:new Promise(h=>{p.on("error",E=>{switch(kd.delete(p),kd.size===0&&(process.off("SIGINT",aU),process.off("SIGTERM",lU)),E.code){case"ENOENT":a[2].write(`command not found: ${t} +`),h(127);break;case"EACCES":a[2].write(`permission denied: ${t} +`),h(128);break;default:a[2].write(`uncaught error: ${E.message} +`),h(1);break}}),p.on("close",E=>{kd.delete(p),kd.size===0&&(process.off("SIGINT",aU),process.off("SIGTERM",lU)),h(E!==null?E:129)})})}}}function ece(t){return e=>{let r=e[0]==="pipe"?new iA.PassThrough:e[0];return{stdin:r,promise:Promise.resolve().then(()=>t({stdin:r,stdout:e[1],stderr:e[2]}))}}}function Ob(t,e){return NE.start(t,e)}function zle(t,e=null){let r=new iA.PassThrough,o=new Zle.StringDecoder,a="";return r.on("data",n=>{let u=o.write(n),A;do if(A=u.indexOf(` +`),A!==-1){let p=a+u.substring(0,A);u=u.substring(A+1),a="",t(e!==null?`${e} ${p}`:p)}while(A!==-1);a+=u}),r.on("end",()=>{let n=o.end();n!==""&&t(e!==null?`${e} ${n}`:n)}),r}function tce(t,{prefix:e}){return{stdout:zle(r=>t.stdout.write(`${r} +`),t.stdout.isTTY?e:null),stderr:zle(r=>t.stderr.write(`${r} +`),t.stderr.isTTY?e:null)}}var Xle,iA,Zle,kd,zl,cU,NE,uU=Et(()=>{Xle=$e(sT()),iA=ve("stream"),Zle=ve("string_decoder"),kd=new Set;zl=class{constructor(e){this.stream=e}close(){}get(){return this.stream}},cU=class{constructor(){this.stream=null}close(){if(this.stream===null)throw new Error("Assertion failed: No stream attached");this.stream.end()}attach(e){this.stream=e}get(){if(this.stream===null)throw new Error("Assertion failed: No stream attached");return this.stream}},NE=class{constructor(e,r){this.stdin=null;this.stdout=null;this.stderr=null;this.pipe=null;this.ancestor=e,this.implementation=r}static start(e,{stdin:r,stdout:o,stderr:a}){let n=new NE(null,e);return n.stdin=r,n.stdout=o,n.stderr=a,n}pipeTo(e,r=1){let o=new NE(this,e),a=new cU;return o.pipe=a,o.stdout=this.stdout,o.stderr=this.stderr,(r&1)===1?this.stdout=a:this.ancestor!==null&&(this.stderr=this.ancestor.stdout),(r&2)===2?this.stderr=a:this.ancestor!==null&&(this.stderr=this.ancestor.stderr),o}async exec(){let e=["ignore","ignore","ignore"];if(this.pipe)e[0]="pipe";else{if(this.stdin===null)throw new Error("Assertion failed: No input stream registered");e[0]=this.stdin.get()}let r;if(this.stdout===null)throw new Error("Assertion failed: No output stream registered");r=this.stdout,e[1]=r.get();let o;if(this.stderr===null)throw new Error("Assertion failed: No error stream registered");o=this.stderr,e[2]=o.get();let a=this.implementation(e);return this.pipe&&this.pipe.attach(a.stdin),await a.promise.then(n=>(r.close(),o.close(),n))}async run(){let e=[];for(let o=this;o;o=o.ancestor)e.push(o.exec());return(await Promise.all(e))[0]}}});var T1={};Vt(T1,{EntryCommand:()=>RE,ShellError:()=>al,execute:()=>TE,globUtils:()=>Lb});function rce(t,e,r){let o=new ll.PassThrough({autoDestroy:!0});switch(t){case 0:(e&1)===1&&r.stdin.pipe(o,{end:!1}),(e&2)===2&&r.stdin instanceof ll.Writable&&o.pipe(r.stdin,{end:!1});break;case 1:(e&1)===1&&r.stdout.pipe(o,{end:!1}),(e&2)===2&&o.pipe(r.stdout,{end:!1});break;case 2:(e&1)===1&&r.stderr.pipe(o,{end:!1}),(e&2)===2&&o.pipe(r.stderr,{end:!1});break;default:throw new al(`Bad file descriptor: "${t}"`)}return o}function Ub(t,e={}){let r={...t,...e};return r.environment={...t.environment,...e.environment},r.variables={...t.variables,...e.variables},r}async function hot(t,e,r){let o=[],a=new ll.PassThrough;return a.on("data",n=>o.push(n)),await _b(t,e,Ub(r,{stdout:a})),Buffer.concat(o).toString().replace(/[\r\n]+$/,"")}async function nce(t,e,r){let o=t.map(async n=>{let u=await Qd(n.args,e,r);return{name:n.name,value:u.join(" ")}});return(await Promise.all(o)).reduce((n,u)=>(n[u.name]=u.value,n),{})}function Mb(t){return t.match(/[^ \r\n\t]+/g)||[]}async function cce(t,e,r,o,a=o){switch(t.name){case"$":o(String(process.pid));break;case"#":o(String(e.args.length));break;case"@":if(t.quoted)for(let n of e.args)a(n);else for(let n of e.args){let u=Mb(n);for(let A=0;A<u.length-1;++A)a(u[A]);o(u[u.length-1])}break;case"*":{let n=e.args.join(" ");if(t.quoted)o(n);else for(let u of Mb(n))a(u)}break;case"PPID":o(String(process.ppid));break;case"RANDOM":o(String(Math.floor(Math.random()*32768)));break;default:{let n=parseInt(t.name,10),u,A=Number.isFinite(n);if(A?n>=0&&n<e.args.length&&(u=e.args[n]):Object.hasOwn(r.variables,t.name)?u=r.variables[t.name]:Object.hasOwn(r.environment,t.name)&&(u=r.environment[t.name]),typeof u<"u"&&t.alternativeValue?u=(await Qd(t.alternativeValue,e,r)).join(" "):typeof u>"u"&&(t.defaultValue?u=(await Qd(t.defaultValue,e,r)).join(" "):t.alternativeValue&&(u="")),typeof u>"u")throw A?new al(`Unbound argument #${n}`):new al(`Unbound variable "${t.name}"`);if(t.quoted)o(u);else{let p=Mb(u);for(let E=0;E<p.length-1;++E)a(p[E]);let h=p[p.length-1];typeof h<"u"&&o(h)}}break}}async function Q1(t,e,r){if(t.type==="number"){if(Number.isInteger(t.value))return t.value;throw new Error(`Invalid number: "${t.value}", only integers are allowed`)}else if(t.type==="variable"){let o=[];await cce({...t,quoted:!0},e,r,n=>o.push(n));let a=Number(o.join(" "));return Number.isNaN(a)?Q1({type:"variable",name:o.join(" ")},e,r):Q1({type:"number",value:a},e,r)}else return got[t.type](await Q1(t.left,e,r),await Q1(t.right,e,r))}async function Qd(t,e,r){let o=new Map,a=[],n=[],u=E=>{n.push(E)},A=()=>{n.length>0&&a.push(n.join("")),n=[]},p=E=>{u(E),A()},h=(E,I,v)=>{let x=JSON.stringify({type:E,fd:I}),C=o.get(x);typeof C>"u"&&o.set(x,C=[]),C.push(v)};for(let E of t){let I=!1;switch(E.type){case"redirection":{let v=await Qd(E.args,e,r);for(let x of v)h(E.subtype,E.fd,x)}break;case"argument":for(let v of E.segments)switch(v.type){case"text":u(v.text);break;case"glob":u(v.pattern),I=!0;break;case"shell":{let x=await hot(v.shell,e,r);if(v.quoted)u(x);else{let C=Mb(x);for(let R=0;R<C.length-1;++R)p(C[R]);u(C[C.length-1])}}break;case"variable":await cce(v,e,r,u,p);break;case"arithmetic":u(String(await Q1(v.arithmetic,e,r)));break}break}if(A(),I){let v=a.pop();if(typeof v>"u")throw new Error("Assertion failed: Expected a glob pattern to have been set");let x=await e.glob.match(v,{cwd:r.cwd,baseFs:e.baseFs});if(x.length===0){let C=oU(v)?". Note: Brace expansion of arbitrary strings isn't currently supported. For more details, please read this issue: https://github.com/yarnpkg/berry/issues/22":"";throw new al(`No matches found: "${v}"${C}`)}for(let C of x.sort())p(C)}}if(o.size>0){let E=[];for(let[I,v]of o.entries())E.splice(E.length,0,I,String(v.length),...v);a.splice(0,0,"__ysh_set_redirects",...E,"--")}return a}function F1(t,e,r){e.builtins.has(t[0])||(t=["command",...t]);let o=ue.fromPortablePath(r.cwd),a=r.environment;typeof a.PWD<"u"&&(a={...a,PWD:o});let[n,...u]=t;if(n==="command")return $le(u[0],u.slice(1),e,{cwd:o,env:a});let A=e.builtins.get(n);if(typeof A>"u")throw new Error(`Assertion failed: A builtin should exist for "${n}"`);return ece(async({stdin:p,stdout:h,stderr:E})=>{let{stdin:I,stdout:v,stderr:x}=r;r.stdin=p,r.stdout=h,r.stderr=E;try{return await A(u,e,r)}finally{r.stdin=I,r.stdout=v,r.stderr=x}})}function dot(t,e,r){return o=>{let a=new ll.PassThrough,n=_b(t,e,Ub(r,{stdin:a}));return{stdin:a,promise:n}}}function mot(t,e,r){return o=>{let a=new ll.PassThrough,n=_b(t,e,r);return{stdin:a,promise:n}}}function ice(t,e,r,o){if(e.length===0)return t;{let a;do a=String(Math.random());while(Object.hasOwn(o.procedures,a));return o.procedures={...o.procedures},o.procedures[a]=t,F1([...e,"__ysh_run_procedure",a],r,o)}}async function sce(t,e,r){let o=t,a=null,n=null;for(;o;){let u=o.then?{...r}:r,A;switch(o.type){case"command":{let p=await Qd(o.args,e,r),h=await nce(o.envs,e,r);A=o.envs.length?F1(p,e,Ub(u,{environment:h})):F1(p,e,u)}break;case"subshell":{let p=await Qd(o.args,e,r),h=dot(o.subshell,e,u);A=ice(h,p,e,u)}break;case"group":{let p=await Qd(o.args,e,r),h=mot(o.group,e,u);A=ice(h,p,e,u)}break;case"envs":{let p=await nce(o.envs,e,r);u.environment={...u.environment,...p},A=F1(["true"],e,u)}break}if(typeof A>"u")throw new Error("Assertion failed: An action should have been generated");if(a===null)n=Ob(A,{stdin:new zl(u.stdin),stdout:new zl(u.stdout),stderr:new zl(u.stderr)});else{if(n===null)throw new Error("Assertion failed: The execution pipeline should have been setup");switch(a){case"|":n=n.pipeTo(A,1);break;case"|&":n=n.pipeTo(A,3);break}}o.then?(a=o.then.type,o=o.then.chain):o=null}if(n===null)throw new Error("Assertion failed: The execution pipeline should have been setup");return await n.run()}async function yot(t,e,r,{background:o=!1}={}){function a(n){let u=["#2E86AB","#A23B72","#F18F01","#C73E1D","#CCE2A3"],A=u[n%u.length];return oce.default.hex(A)}if(o){let n=r.nextBackgroundJobIndex++,u=a(n),A=`[${n}]`,p=u(A),{stdout:h,stderr:E}=tce(r,{prefix:p});return r.backgroundJobs.push(sce(t,e,Ub(r,{stdout:h,stderr:E})).catch(I=>E.write(`${I.message} +`)).finally(()=>{r.stdout.isTTY&&r.stdout.write(`Job ${p}, '${u(uy(t))}' has ended +`)})),0}return await sce(t,e,r)}async function Eot(t,e,r,{background:o=!1}={}){let a,n=A=>{a=A,r.variables["?"]=String(A)},u=async A=>{try{return await yot(A.chain,e,r,{background:o&&typeof A.then>"u"})}catch(p){if(!(p instanceof al))throw p;return r.stderr.write(`${p.message} +`),1}};for(n(await u(t));t.then;){if(r.exitCode!==null)return r.exitCode;switch(t.then.type){case"&&":a===0&&n(await u(t.then.line));break;case"||":a!==0&&n(await u(t.then.line));break;default:throw new Error(`Assertion failed: Unsupported command type: "${t.then.type}"`)}t=t.then.line}return a}async function _b(t,e,r){let o=r.backgroundJobs;r.backgroundJobs=[];let a=0;for(let{command:n,type:u}of t){if(a=await Eot(n,e,r,{background:u==="&"}),r.exitCode!==null)return r.exitCode;r.variables["?"]=String(a)}return await Promise.all(r.backgroundJobs),r.backgroundJobs=o,a}function uce(t){switch(t.type){case"variable":return t.name==="@"||t.name==="#"||t.name==="*"||Number.isFinite(parseInt(t.name,10))||"defaultValue"in t&&!!t.defaultValue&&t.defaultValue.some(e=>R1(e))||"alternativeValue"in t&&!!t.alternativeValue&&t.alternativeValue.some(e=>R1(e));case"arithmetic":return AU(t.arithmetic);case"shell":return fU(t.shell);default:return!1}}function R1(t){switch(t.type){case"redirection":return t.args.some(e=>R1(e));case"argument":return t.segments.some(e=>uce(e));default:throw new Error(`Assertion failed: Unsupported argument type: "${t.type}"`)}}function AU(t){switch(t.type){case"variable":return uce(t);case"number":return!1;default:return AU(t.left)||AU(t.right)}}function fU(t){return t.some(({command:e})=>{for(;e;){let r=e.chain;for(;r;){let o;switch(r.type){case"subshell":o=fU(r.subshell);break;case"command":o=r.envs.some(a=>a.args.some(n=>R1(n)))||r.args.some(a=>R1(a));break}if(o)return!0;if(!r.then)break;r=r.then.chain}if(!e.then)break;e=e.then.line}return!1})}async function TE(t,e=[],{baseFs:r=new Tn,builtins:o={},cwd:a=ue.toPortablePath(process.cwd()),env:n=process.env,stdin:u=process.stdin,stdout:A=process.stdout,stderr:p=process.stderr,variables:h={},glob:E=Lb}={}){let I={};for(let[C,R]of Object.entries(n))typeof R<"u"&&(I[C]=R);let v=new Map(pot);for(let[C,R]of Object.entries(o))v.set(C,R);u===null&&(u=new ll.PassThrough,u.end());let x=ND(t,E);if(!fU(x)&&x.length>0&&e.length>0){let{command:C}=x[x.length-1];for(;C.then;)C=C.then.line;let R=C.chain;for(;R.then;)R=R.then.chain;R.type==="command"&&(R.args=R.args.concat(e.map(L=>({type:"argument",segments:[{type:"text",text:L}]}))))}return await _b(x,{args:e,baseFs:r,builtins:v,initialStdin:u,initialStdout:A,initialStderr:p,glob:E},{cwd:a,environment:I,exitCode:null,procedures:{},stdin:u,stdout:A,stderr:p,variables:Object.assign({},h,{["?"]:0}),nextBackgroundJobIndex:1,backgroundJobs:[]})}var oce,ace,ll,lce,pot,got,k1=Et(()=>{St();Nl();oce=$e(IN()),ace=ve("os"),ll=ve("stream"),lce=ve("timers/promises");qle();Yle();Jle();uU();uU();pot=new Map([["cd",async([t=(0,ace.homedir)(),...e],r,o)=>{let a=V.resolve(o.cwd,ue.toPortablePath(t));if(!(await r.baseFs.statPromise(a).catch(u=>{throw u.code==="ENOENT"?new al(`cd: no such file or directory: ${t}`):u})).isDirectory())throw new al(`cd: not a directory: ${t}`);return o.cwd=a,0}],["pwd",async(t,e,r)=>(r.stdout.write(`${ue.fromPortablePath(r.cwd)} +`),0)],[":",async(t,e,r)=>0],["true",async(t,e,r)=>0],["false",async(t,e,r)=>1],["exit",async([t,...e],r,o)=>o.exitCode=parseInt(t??o.variables["?"],10)],["echo",async(t,e,r)=>(r.stdout.write(`${t.join(" ")} +`),0)],["sleep",async([t],e,r)=>{if(typeof t>"u")throw new al("sleep: missing operand");let o=Number(t);if(Number.isNaN(o))throw new al(`sleep: invalid time interval '${t}'`);return await(0,lce.setTimeout)(1e3*o,0)}],["__ysh_run_procedure",async(t,e,r)=>{let o=r.procedures[t[0]];return await Ob(o,{stdin:new zl(r.stdin),stdout:new zl(r.stdout),stderr:new zl(r.stderr)}).run()}],["__ysh_set_redirects",async(t,e,r)=>{let o=r.stdin,a=r.stdout,n=r.stderr,u=[],A=[],p=[],h=0;for(;t[h]!=="--";){let I=t[h++],{type:v,fd:x}=JSON.parse(I),C=J=>{switch(x){case null:case 0:u.push(J);break;default:throw new Error(`Unsupported file descriptor: "${x}"`)}},R=J=>{switch(x){case null:case 1:A.push(J);break;case 2:p.push(J);break;default:throw new Error(`Unsupported file descriptor: "${x}"`)}},L=Number(t[h++]),U=h+L;for(let J=h;J<U;++h,++J)switch(v){case"<":C(()=>e.baseFs.createReadStream(V.resolve(r.cwd,ue.toPortablePath(t[J]))));break;case"<<<":C(()=>{let te=new ll.PassThrough;return process.nextTick(()=>{te.write(`${t[J]} +`),te.end()}),te});break;case"<&":C(()=>rce(Number(t[J]),1,r));break;case">":case">>":{let te=V.resolve(r.cwd,ue.toPortablePath(t[J]));R(te==="/dev/null"?new ll.Writable({autoDestroy:!0,emitClose:!0,write(ae,fe,ce){setImmediate(ce)}}):e.baseFs.createWriteStream(te,v===">>"?{flags:"a"}:void 0))}break;case">&":R(rce(Number(t[J]),2,r));break;default:throw new Error(`Assertion failed: Unsupported redirection type: "${v}"`)}}if(u.length>0){let I=new ll.PassThrough;o=I;let v=x=>{if(x===u.length)I.end();else{let C=u[x]();C.pipe(I,{end:!1}),C.on("end",()=>{v(x+1)})}};v(0)}if(A.length>0){let I=new ll.PassThrough;a=I;for(let v of A)I.pipe(v)}if(p.length>0){let I=new ll.PassThrough;n=I;for(let v of p)I.pipe(v)}let E=await Ob(F1(t.slice(h+1),e,r),{stdin:new zl(o),stdout:new zl(a),stderr:new zl(n)}).run();return await Promise.all(A.map(I=>new Promise((v,x)=>{I.on("error",C=>{x(C)}),I.on("close",()=>{v()}),I.end()}))),await Promise.all(p.map(I=>new Promise((v,x)=>{I.on("error",C=>{x(C)}),I.on("close",()=>{v()}),I.end()}))),E}]]);got={addition:(t,e)=>t+e,subtraction:(t,e)=>t-e,multiplication:(t,e)=>t*e,division:(t,e)=>Math.trunc(t/e)}});var Hb=_((r4t,Ace)=>{function Cot(t,e){for(var r=-1,o=t==null?0:t.length,a=Array(o);++r<o;)a[r]=e(t[r],r,t);return a}Ace.exports=Cot});var mce=_((n4t,dce)=>{var fce=pd(),wot=Hb(),Iot=Hl(),Bot=pE(),vot=1/0,pce=fce?fce.prototype:void 0,hce=pce?pce.toString:void 0;function gce(t){if(typeof t=="string")return t;if(Iot(t))return wot(t,gce)+"";if(Bot(t))return hce?hce.call(t):"";var e=t+"";return e=="0"&&1/t==-vot?"-0":e}dce.exports=gce});var N1=_((i4t,yce)=>{var Dot=mce();function Sot(t){return t==null?"":Dot(t)}yce.exports=Sot});var pU=_((s4t,Ece)=>{function Pot(t,e,r){var o=-1,a=t.length;e<0&&(e=-e>a?0:a+e),r=r>a?a:r,r<0&&(r+=a),a=e>r?0:r-e>>>0,e>>>=0;for(var n=Array(a);++o<a;)n[o]=t[o+e];return n}Ece.exports=Pot});var wce=_((o4t,Cce)=>{var bot=pU();function xot(t,e,r){var o=t.length;return r=r===void 0?o:r,!e&&r>=o?t:bot(t,e,r)}Cce.exports=xot});var hU=_((a4t,Ice)=>{var kot="\\ud800-\\udfff",Qot="\\u0300-\\u036f",Fot="\\ufe20-\\ufe2f",Rot="\\u20d0-\\u20ff",Tot=Qot+Fot+Rot,Not="\\ufe0e\\ufe0f",Lot="\\u200d",Oot=RegExp("["+Lot+kot+Tot+Not+"]");function Mot(t){return Oot.test(t)}Ice.exports=Mot});var vce=_((l4t,Bce)=>{function Uot(t){return t.split("")}Bce.exports=Uot});var Fce=_((c4t,Qce)=>{var Dce="\\ud800-\\udfff",_ot="\\u0300-\\u036f",Hot="\\ufe20-\\ufe2f",jot="\\u20d0-\\u20ff",Got=_ot+Hot+jot,qot="\\ufe0e\\ufe0f",Yot="["+Dce+"]",gU="["+Got+"]",dU="\\ud83c[\\udffb-\\udfff]",Wot="(?:"+gU+"|"+dU+")",Sce="[^"+Dce+"]",Pce="(?:\\ud83c[\\udde6-\\uddff]){2}",bce="[\\ud800-\\udbff][\\udc00-\\udfff]",Kot="\\u200d",xce=Wot+"?",kce="["+qot+"]?",Vot="(?:"+Kot+"(?:"+[Sce,Pce,bce].join("|")+")"+kce+xce+")*",Jot=kce+xce+Vot,zot="(?:"+[Sce+gU+"?",gU,Pce,bce,Yot].join("|")+")",Xot=RegExp(dU+"(?="+dU+")|"+zot+Jot,"g");function Zot(t){return t.match(Xot)||[]}Qce.exports=Zot});var Tce=_((u4t,Rce)=>{var $ot=vce(),eat=hU(),tat=Fce();function rat(t){return eat(t)?tat(t):$ot(t)}Rce.exports=rat});var Lce=_((A4t,Nce)=>{var nat=wce(),iat=hU(),sat=Tce(),oat=N1();function aat(t){return function(e){e=oat(e);var r=iat(e)?sat(e):void 0,o=r?r[0]:e.charAt(0),a=r?nat(r,1).join(""):e.slice(1);return o[t]()+a}}Nce.exports=aat});var Mce=_((f4t,Oce)=>{var lat=Lce(),cat=lat("toUpperCase");Oce.exports=cat});var mU=_((p4t,Uce)=>{var uat=N1(),Aat=Mce();function fat(t){return Aat(uat(t).toLowerCase())}Uce.exports=fat});var _ce=_((h4t,jb)=>{function pat(){var t=0,e=1,r=2,o=3,a=4,n=5,u=6,A=7,p=8,h=9,E=10,I=11,v=12,x=13,C=14,R=15,L=16,U=17,J=0,te=1,ae=2,fe=3,ce=4;function me(g,Ee){return 55296<=g.charCodeAt(Ee)&&g.charCodeAt(Ee)<=56319&&56320<=g.charCodeAt(Ee+1)&&g.charCodeAt(Ee+1)<=57343}function he(g,Ee){Ee===void 0&&(Ee=0);var Se=g.charCodeAt(Ee);if(55296<=Se&&Se<=56319&&Ee<g.length-1){var le=Se,ne=g.charCodeAt(Ee+1);return 56320<=ne&&ne<=57343?(le-55296)*1024+(ne-56320)+65536:le}if(56320<=Se&&Se<=57343&&Ee>=1){var le=g.charCodeAt(Ee-1),ne=Se;return 55296<=le&&le<=56319?(le-55296)*1024+(ne-56320)+65536:ne}return Se}function Be(g,Ee,Se){var le=[g].concat(Ee).concat([Se]),ne=le[le.length-2],ee=Se,Ie=le.lastIndexOf(C);if(Ie>1&&le.slice(1,Ie).every(function(H){return H==o})&&[o,x,U].indexOf(g)==-1)return ae;var Fe=le.lastIndexOf(a);if(Fe>0&&le.slice(1,Fe).every(function(H){return H==a})&&[v,a].indexOf(ne)==-1)return le.filter(function(H){return H==a}).length%2==1?fe:ce;if(ne==t&&ee==e)return J;if(ne==r||ne==t||ne==e)return ee==C&&Ee.every(function(H){return H==o})?ae:te;if(ee==r||ee==t||ee==e)return te;if(ne==u&&(ee==u||ee==A||ee==h||ee==E))return J;if((ne==h||ne==A)&&(ee==A||ee==p))return J;if((ne==E||ne==p)&&ee==p)return J;if(ee==o||ee==R)return J;if(ee==n)return J;if(ne==v)return J;var At=le.indexOf(o)!=-1?le.lastIndexOf(o)-1:le.length-2;return[x,U].indexOf(le[At])!=-1&&le.slice(At+1,-1).every(function(H){return H==o})&&ee==C||ne==R&&[L,U].indexOf(ee)!=-1?J:Ee.indexOf(a)!=-1?ae:ne==a&&ee==a?J:te}this.nextBreak=function(g,Ee){if(Ee===void 0&&(Ee=0),Ee<0)return 0;if(Ee>=g.length-1)return g.length;for(var Se=we(he(g,Ee)),le=[],ne=Ee+1;ne<g.length;ne++)if(!me(g,ne-1)){var ee=we(he(g,ne));if(Be(Se,le,ee))return ne;le.push(ee)}return g.length},this.splitGraphemes=function(g){for(var Ee=[],Se=0,le;(le=this.nextBreak(g,Se))<g.length;)Ee.push(g.slice(Se,le)),Se=le;return Se<g.length&&Ee.push(g.slice(Se)),Ee},this.iterateGraphemes=function(g){var Ee=0,Se={next:function(){var le,ne;return(ne=this.nextBreak(g,Ee))<g.length?(le=g.slice(Ee,ne),Ee=ne,{value:le,done:!1}):Ee<g.length?(le=g.slice(Ee),Ee=g.length,{value:le,done:!1}):{value:void 0,done:!0}}.bind(this)};return typeof Symbol<"u"&&Symbol.iterator&&(Se[Symbol.iterator]=function(){return Se}),Se},this.countGraphemes=function(g){for(var Ee=0,Se=0,le;(le=this.nextBreak(g,Se))<g.length;)Se=le,Ee++;return Se<g.length&&Ee++,Ee};function we(g){return 1536<=g&&g<=1541||g==1757||g==1807||g==2274||g==3406||g==69821||70082<=g&&g<=70083||g==72250||72326<=g&&g<=72329||g==73030?v:g==13?t:g==10?e:0<=g&&g<=9||11<=g&&g<=12||14<=g&&g<=31||127<=g&&g<=159||g==173||g==1564||g==6158||g==8203||8206<=g&&g<=8207||g==8232||g==8233||8234<=g&&g<=8238||8288<=g&&g<=8292||g==8293||8294<=g&&g<=8303||55296<=g&&g<=57343||g==65279||65520<=g&&g<=65528||65529<=g&&g<=65531||113824<=g&&g<=113827||119155<=g&&g<=119162||g==917504||g==917505||917506<=g&&g<=917535||917632<=g&&g<=917759||918e3<=g&&g<=921599?r:768<=g&&g<=879||1155<=g&&g<=1159||1160<=g&&g<=1161||1425<=g&&g<=1469||g==1471||1473<=g&&g<=1474||1476<=g&&g<=1477||g==1479||1552<=g&&g<=1562||1611<=g&&g<=1631||g==1648||1750<=g&&g<=1756||1759<=g&&g<=1764||1767<=g&&g<=1768||1770<=g&&g<=1773||g==1809||1840<=g&&g<=1866||1958<=g&&g<=1968||2027<=g&&g<=2035||2070<=g&&g<=2073||2075<=g&&g<=2083||2085<=g&&g<=2087||2089<=g&&g<=2093||2137<=g&&g<=2139||2260<=g&&g<=2273||2275<=g&&g<=2306||g==2362||g==2364||2369<=g&&g<=2376||g==2381||2385<=g&&g<=2391||2402<=g&&g<=2403||g==2433||g==2492||g==2494||2497<=g&&g<=2500||g==2509||g==2519||2530<=g&&g<=2531||2561<=g&&g<=2562||g==2620||2625<=g&&g<=2626||2631<=g&&g<=2632||2635<=g&&g<=2637||g==2641||2672<=g&&g<=2673||g==2677||2689<=g&&g<=2690||g==2748||2753<=g&&g<=2757||2759<=g&&g<=2760||g==2765||2786<=g&&g<=2787||2810<=g&&g<=2815||g==2817||g==2876||g==2878||g==2879||2881<=g&&g<=2884||g==2893||g==2902||g==2903||2914<=g&&g<=2915||g==2946||g==3006||g==3008||g==3021||g==3031||g==3072||3134<=g&&g<=3136||3142<=g&&g<=3144||3146<=g&&g<=3149||3157<=g&&g<=3158||3170<=g&&g<=3171||g==3201||g==3260||g==3263||g==3266||g==3270||3276<=g&&g<=3277||3285<=g&&g<=3286||3298<=g&&g<=3299||3328<=g&&g<=3329||3387<=g&&g<=3388||g==3390||3393<=g&&g<=3396||g==3405||g==3415||3426<=g&&g<=3427||g==3530||g==3535||3538<=g&&g<=3540||g==3542||g==3551||g==3633||3636<=g&&g<=3642||3655<=g&&g<=3662||g==3761||3764<=g&&g<=3769||3771<=g&&g<=3772||3784<=g&&g<=3789||3864<=g&&g<=3865||g==3893||g==3895||g==3897||3953<=g&&g<=3966||3968<=g&&g<=3972||3974<=g&&g<=3975||3981<=g&&g<=3991||3993<=g&&g<=4028||g==4038||4141<=g&&g<=4144||4146<=g&&g<=4151||4153<=g&&g<=4154||4157<=g&&g<=4158||4184<=g&&g<=4185||4190<=g&&g<=4192||4209<=g&&g<=4212||g==4226||4229<=g&&g<=4230||g==4237||g==4253||4957<=g&&g<=4959||5906<=g&&g<=5908||5938<=g&&g<=5940||5970<=g&&g<=5971||6002<=g&&g<=6003||6068<=g&&g<=6069||6071<=g&&g<=6077||g==6086||6089<=g&&g<=6099||g==6109||6155<=g&&g<=6157||6277<=g&&g<=6278||g==6313||6432<=g&&g<=6434||6439<=g&&g<=6440||g==6450||6457<=g&&g<=6459||6679<=g&&g<=6680||g==6683||g==6742||6744<=g&&g<=6750||g==6752||g==6754||6757<=g&&g<=6764||6771<=g&&g<=6780||g==6783||6832<=g&&g<=6845||g==6846||6912<=g&&g<=6915||g==6964||6966<=g&&g<=6970||g==6972||g==6978||7019<=g&&g<=7027||7040<=g&&g<=7041||7074<=g&&g<=7077||7080<=g&&g<=7081||7083<=g&&g<=7085||g==7142||7144<=g&&g<=7145||g==7149||7151<=g&&g<=7153||7212<=g&&g<=7219||7222<=g&&g<=7223||7376<=g&&g<=7378||7380<=g&&g<=7392||7394<=g&&g<=7400||g==7405||g==7412||7416<=g&&g<=7417||7616<=g&&g<=7673||7675<=g&&g<=7679||g==8204||8400<=g&&g<=8412||8413<=g&&g<=8416||g==8417||8418<=g&&g<=8420||8421<=g&&g<=8432||11503<=g&&g<=11505||g==11647||11744<=g&&g<=11775||12330<=g&&g<=12333||12334<=g&&g<=12335||12441<=g&&g<=12442||g==42607||42608<=g&&g<=42610||42612<=g&&g<=42621||42654<=g&&g<=42655||42736<=g&&g<=42737||g==43010||g==43014||g==43019||43045<=g&&g<=43046||43204<=g&&g<=43205||43232<=g&&g<=43249||43302<=g&&g<=43309||43335<=g&&g<=43345||43392<=g&&g<=43394||g==43443||43446<=g&&g<=43449||g==43452||g==43493||43561<=g&&g<=43566||43569<=g&&g<=43570||43573<=g&&g<=43574||g==43587||g==43596||g==43644||g==43696||43698<=g&&g<=43700||43703<=g&&g<=43704||43710<=g&&g<=43711||g==43713||43756<=g&&g<=43757||g==43766||g==44005||g==44008||g==44013||g==64286||65024<=g&&g<=65039||65056<=g&&g<=65071||65438<=g&&g<=65439||g==66045||g==66272||66422<=g&&g<=66426||68097<=g&&g<=68099||68101<=g&&g<=68102||68108<=g&&g<=68111||68152<=g&&g<=68154||g==68159||68325<=g&&g<=68326||g==69633||69688<=g&&g<=69702||69759<=g&&g<=69761||69811<=g&&g<=69814||69817<=g&&g<=69818||69888<=g&&g<=69890||69927<=g&&g<=69931||69933<=g&&g<=69940||g==70003||70016<=g&&g<=70017||70070<=g&&g<=70078||70090<=g&&g<=70092||70191<=g&&g<=70193||g==70196||70198<=g&&g<=70199||g==70206||g==70367||70371<=g&&g<=70378||70400<=g&&g<=70401||g==70460||g==70462||g==70464||g==70487||70502<=g&&g<=70508||70512<=g&&g<=70516||70712<=g&&g<=70719||70722<=g&&g<=70724||g==70726||g==70832||70835<=g&&g<=70840||g==70842||g==70845||70847<=g&&g<=70848||70850<=g&&g<=70851||g==71087||71090<=g&&g<=71093||71100<=g&&g<=71101||71103<=g&&g<=71104||71132<=g&&g<=71133||71219<=g&&g<=71226||g==71229||71231<=g&&g<=71232||g==71339||g==71341||71344<=g&&g<=71349||g==71351||71453<=g&&g<=71455||71458<=g&&g<=71461||71463<=g&&g<=71467||72193<=g&&g<=72198||72201<=g&&g<=72202||72243<=g&&g<=72248||72251<=g&&g<=72254||g==72263||72273<=g&&g<=72278||72281<=g&&g<=72283||72330<=g&&g<=72342||72344<=g&&g<=72345||72752<=g&&g<=72758||72760<=g&&g<=72765||g==72767||72850<=g&&g<=72871||72874<=g&&g<=72880||72882<=g&&g<=72883||72885<=g&&g<=72886||73009<=g&&g<=73014||g==73018||73020<=g&&g<=73021||73023<=g&&g<=73029||g==73031||92912<=g&&g<=92916||92976<=g&&g<=92982||94095<=g&&g<=94098||113821<=g&&g<=113822||g==119141||119143<=g&&g<=119145||119150<=g&&g<=119154||119163<=g&&g<=119170||119173<=g&&g<=119179||119210<=g&&g<=119213||119362<=g&&g<=119364||121344<=g&&g<=121398||121403<=g&&g<=121452||g==121461||g==121476||121499<=g&&g<=121503||121505<=g&&g<=121519||122880<=g&&g<=122886||122888<=g&&g<=122904||122907<=g&&g<=122913||122915<=g&&g<=122916||122918<=g&&g<=122922||125136<=g&&g<=125142||125252<=g&&g<=125258||917536<=g&&g<=917631||917760<=g&&g<=917999?o:127462<=g&&g<=127487?a:g==2307||g==2363||2366<=g&&g<=2368||2377<=g&&g<=2380||2382<=g&&g<=2383||2434<=g&&g<=2435||2495<=g&&g<=2496||2503<=g&&g<=2504||2507<=g&&g<=2508||g==2563||2622<=g&&g<=2624||g==2691||2750<=g&&g<=2752||g==2761||2763<=g&&g<=2764||2818<=g&&g<=2819||g==2880||2887<=g&&g<=2888||2891<=g&&g<=2892||g==3007||3009<=g&&g<=3010||3014<=g&&g<=3016||3018<=g&&g<=3020||3073<=g&&g<=3075||3137<=g&&g<=3140||3202<=g&&g<=3203||g==3262||3264<=g&&g<=3265||3267<=g&&g<=3268||3271<=g&&g<=3272||3274<=g&&g<=3275||3330<=g&&g<=3331||3391<=g&&g<=3392||3398<=g&&g<=3400||3402<=g&&g<=3404||3458<=g&&g<=3459||3536<=g&&g<=3537||3544<=g&&g<=3550||3570<=g&&g<=3571||g==3635||g==3763||3902<=g&&g<=3903||g==3967||g==4145||4155<=g&&g<=4156||4182<=g&&g<=4183||g==4228||g==6070||6078<=g&&g<=6085||6087<=g&&g<=6088||6435<=g&&g<=6438||6441<=g&&g<=6443||6448<=g&&g<=6449||6451<=g&&g<=6456||6681<=g&&g<=6682||g==6741||g==6743||6765<=g&&g<=6770||g==6916||g==6965||g==6971||6973<=g&&g<=6977||6979<=g&&g<=6980||g==7042||g==7073||7078<=g&&g<=7079||g==7082||g==7143||7146<=g&&g<=7148||g==7150||7154<=g&&g<=7155||7204<=g&&g<=7211||7220<=g&&g<=7221||g==7393||7410<=g&&g<=7411||g==7415||43043<=g&&g<=43044||g==43047||43136<=g&&g<=43137||43188<=g&&g<=43203||43346<=g&&g<=43347||g==43395||43444<=g&&g<=43445||43450<=g&&g<=43451||43453<=g&&g<=43456||43567<=g&&g<=43568||43571<=g&&g<=43572||g==43597||g==43755||43758<=g&&g<=43759||g==43765||44003<=g&&g<=44004||44006<=g&&g<=44007||44009<=g&&g<=44010||g==44012||g==69632||g==69634||g==69762||69808<=g&&g<=69810||69815<=g&&g<=69816||g==69932||g==70018||70067<=g&&g<=70069||70079<=g&&g<=70080||70188<=g&&g<=70190||70194<=g&&g<=70195||g==70197||70368<=g&&g<=70370||70402<=g&&g<=70403||g==70463||70465<=g&&g<=70468||70471<=g&&g<=70472||70475<=g&&g<=70477||70498<=g&&g<=70499||70709<=g&&g<=70711||70720<=g&&g<=70721||g==70725||70833<=g&&g<=70834||g==70841||70843<=g&&g<=70844||g==70846||g==70849||71088<=g&&g<=71089||71096<=g&&g<=71099||g==71102||71216<=g&&g<=71218||71227<=g&&g<=71228||g==71230||g==71340||71342<=g&&g<=71343||g==71350||71456<=g&&g<=71457||g==71462||72199<=g&&g<=72200||g==72249||72279<=g&&g<=72280||g==72343||g==72751||g==72766||g==72873||g==72881||g==72884||94033<=g&&g<=94078||g==119142||g==119149?n:4352<=g&&g<=4447||43360<=g&&g<=43388?u:4448<=g&&g<=4519||55216<=g&&g<=55238?A:4520<=g&&g<=4607||55243<=g&&g<=55291?p:g==44032||g==44060||g==44088||g==44116||g==44144||g==44172||g==44200||g==44228||g==44256||g==44284||g==44312||g==44340||g==44368||g==44396||g==44424||g==44452||g==44480||g==44508||g==44536||g==44564||g==44592||g==44620||g==44648||g==44676||g==44704||g==44732||g==44760||g==44788||g==44816||g==44844||g==44872||g==44900||g==44928||g==44956||g==44984||g==45012||g==45040||g==45068||g==45096||g==45124||g==45152||g==45180||g==45208||g==45236||g==45264||g==45292||g==45320||g==45348||g==45376||g==45404||g==45432||g==45460||g==45488||g==45516||g==45544||g==45572||g==45600||g==45628||g==45656||g==45684||g==45712||g==45740||g==45768||g==45796||g==45824||g==45852||g==45880||g==45908||g==45936||g==45964||g==45992||g==46020||g==46048||g==46076||g==46104||g==46132||g==46160||g==46188||g==46216||g==46244||g==46272||g==46300||g==46328||g==46356||g==46384||g==46412||g==46440||g==46468||g==46496||g==46524||g==46552||g==46580||g==46608||g==46636||g==46664||g==46692||g==46720||g==46748||g==46776||g==46804||g==46832||g==46860||g==46888||g==46916||g==46944||g==46972||g==47e3||g==47028||g==47056||g==47084||g==47112||g==47140||g==47168||g==47196||g==47224||g==47252||g==47280||g==47308||g==47336||g==47364||g==47392||g==47420||g==47448||g==47476||g==47504||g==47532||g==47560||g==47588||g==47616||g==47644||g==47672||g==47700||g==47728||g==47756||g==47784||g==47812||g==47840||g==47868||g==47896||g==47924||g==47952||g==47980||g==48008||g==48036||g==48064||g==48092||g==48120||g==48148||g==48176||g==48204||g==48232||g==48260||g==48288||g==48316||g==48344||g==48372||g==48400||g==48428||g==48456||g==48484||g==48512||g==48540||g==48568||g==48596||g==48624||g==48652||g==48680||g==48708||g==48736||g==48764||g==48792||g==48820||g==48848||g==48876||g==48904||g==48932||g==48960||g==48988||g==49016||g==49044||g==49072||g==49100||g==49128||g==49156||g==49184||g==49212||g==49240||g==49268||g==49296||g==49324||g==49352||g==49380||g==49408||g==49436||g==49464||g==49492||g==49520||g==49548||g==49576||g==49604||g==49632||g==49660||g==49688||g==49716||g==49744||g==49772||g==49800||g==49828||g==49856||g==49884||g==49912||g==49940||g==49968||g==49996||g==50024||g==50052||g==50080||g==50108||g==50136||g==50164||g==50192||g==50220||g==50248||g==50276||g==50304||g==50332||g==50360||g==50388||g==50416||g==50444||g==50472||g==50500||g==50528||g==50556||g==50584||g==50612||g==50640||g==50668||g==50696||g==50724||g==50752||g==50780||g==50808||g==50836||g==50864||g==50892||g==50920||g==50948||g==50976||g==51004||g==51032||g==51060||g==51088||g==51116||g==51144||g==51172||g==51200||g==51228||g==51256||g==51284||g==51312||g==51340||g==51368||g==51396||g==51424||g==51452||g==51480||g==51508||g==51536||g==51564||g==51592||g==51620||g==51648||g==51676||g==51704||g==51732||g==51760||g==51788||g==51816||g==51844||g==51872||g==51900||g==51928||g==51956||g==51984||g==52012||g==52040||g==52068||g==52096||g==52124||g==52152||g==52180||g==52208||g==52236||g==52264||g==52292||g==52320||g==52348||g==52376||g==52404||g==52432||g==52460||g==52488||g==52516||g==52544||g==52572||g==52600||g==52628||g==52656||g==52684||g==52712||g==52740||g==52768||g==52796||g==52824||g==52852||g==52880||g==52908||g==52936||g==52964||g==52992||g==53020||g==53048||g==53076||g==53104||g==53132||g==53160||g==53188||g==53216||g==53244||g==53272||g==53300||g==53328||g==53356||g==53384||g==53412||g==53440||g==53468||g==53496||g==53524||g==53552||g==53580||g==53608||g==53636||g==53664||g==53692||g==53720||g==53748||g==53776||g==53804||g==53832||g==53860||g==53888||g==53916||g==53944||g==53972||g==54e3||g==54028||g==54056||g==54084||g==54112||g==54140||g==54168||g==54196||g==54224||g==54252||g==54280||g==54308||g==54336||g==54364||g==54392||g==54420||g==54448||g==54476||g==54504||g==54532||g==54560||g==54588||g==54616||g==54644||g==54672||g==54700||g==54728||g==54756||g==54784||g==54812||g==54840||g==54868||g==54896||g==54924||g==54952||g==54980||g==55008||g==55036||g==55064||g==55092||g==55120||g==55148||g==55176?h:44033<=g&&g<=44059||44061<=g&&g<=44087||44089<=g&&g<=44115||44117<=g&&g<=44143||44145<=g&&g<=44171||44173<=g&&g<=44199||44201<=g&&g<=44227||44229<=g&&g<=44255||44257<=g&&g<=44283||44285<=g&&g<=44311||44313<=g&&g<=44339||44341<=g&&g<=44367||44369<=g&&g<=44395||44397<=g&&g<=44423||44425<=g&&g<=44451||44453<=g&&g<=44479||44481<=g&&g<=44507||44509<=g&&g<=44535||44537<=g&&g<=44563||44565<=g&&g<=44591||44593<=g&&g<=44619||44621<=g&&g<=44647||44649<=g&&g<=44675||44677<=g&&g<=44703||44705<=g&&g<=44731||44733<=g&&g<=44759||44761<=g&&g<=44787||44789<=g&&g<=44815||44817<=g&&g<=44843||44845<=g&&g<=44871||44873<=g&&g<=44899||44901<=g&&g<=44927||44929<=g&&g<=44955||44957<=g&&g<=44983||44985<=g&&g<=45011||45013<=g&&g<=45039||45041<=g&&g<=45067||45069<=g&&g<=45095||45097<=g&&g<=45123||45125<=g&&g<=45151||45153<=g&&g<=45179||45181<=g&&g<=45207||45209<=g&&g<=45235||45237<=g&&g<=45263||45265<=g&&g<=45291||45293<=g&&g<=45319||45321<=g&&g<=45347||45349<=g&&g<=45375||45377<=g&&g<=45403||45405<=g&&g<=45431||45433<=g&&g<=45459||45461<=g&&g<=45487||45489<=g&&g<=45515||45517<=g&&g<=45543||45545<=g&&g<=45571||45573<=g&&g<=45599||45601<=g&&g<=45627||45629<=g&&g<=45655||45657<=g&&g<=45683||45685<=g&&g<=45711||45713<=g&&g<=45739||45741<=g&&g<=45767||45769<=g&&g<=45795||45797<=g&&g<=45823||45825<=g&&g<=45851||45853<=g&&g<=45879||45881<=g&&g<=45907||45909<=g&&g<=45935||45937<=g&&g<=45963||45965<=g&&g<=45991||45993<=g&&g<=46019||46021<=g&&g<=46047||46049<=g&&g<=46075||46077<=g&&g<=46103||46105<=g&&g<=46131||46133<=g&&g<=46159||46161<=g&&g<=46187||46189<=g&&g<=46215||46217<=g&&g<=46243||46245<=g&&g<=46271||46273<=g&&g<=46299||46301<=g&&g<=46327||46329<=g&&g<=46355||46357<=g&&g<=46383||46385<=g&&g<=46411||46413<=g&&g<=46439||46441<=g&&g<=46467||46469<=g&&g<=46495||46497<=g&&g<=46523||46525<=g&&g<=46551||46553<=g&&g<=46579||46581<=g&&g<=46607||46609<=g&&g<=46635||46637<=g&&g<=46663||46665<=g&&g<=46691||46693<=g&&g<=46719||46721<=g&&g<=46747||46749<=g&&g<=46775||46777<=g&&g<=46803||46805<=g&&g<=46831||46833<=g&&g<=46859||46861<=g&&g<=46887||46889<=g&&g<=46915||46917<=g&&g<=46943||46945<=g&&g<=46971||46973<=g&&g<=46999||47001<=g&&g<=47027||47029<=g&&g<=47055||47057<=g&&g<=47083||47085<=g&&g<=47111||47113<=g&&g<=47139||47141<=g&&g<=47167||47169<=g&&g<=47195||47197<=g&&g<=47223||47225<=g&&g<=47251||47253<=g&&g<=47279||47281<=g&&g<=47307||47309<=g&&g<=47335||47337<=g&&g<=47363||47365<=g&&g<=47391||47393<=g&&g<=47419||47421<=g&&g<=47447||47449<=g&&g<=47475||47477<=g&&g<=47503||47505<=g&&g<=47531||47533<=g&&g<=47559||47561<=g&&g<=47587||47589<=g&&g<=47615||47617<=g&&g<=47643||47645<=g&&g<=47671||47673<=g&&g<=47699||47701<=g&&g<=47727||47729<=g&&g<=47755||47757<=g&&g<=47783||47785<=g&&g<=47811||47813<=g&&g<=47839||47841<=g&&g<=47867||47869<=g&&g<=47895||47897<=g&&g<=47923||47925<=g&&g<=47951||47953<=g&&g<=47979||47981<=g&&g<=48007||48009<=g&&g<=48035||48037<=g&&g<=48063||48065<=g&&g<=48091||48093<=g&&g<=48119||48121<=g&&g<=48147||48149<=g&&g<=48175||48177<=g&&g<=48203||48205<=g&&g<=48231||48233<=g&&g<=48259||48261<=g&&g<=48287||48289<=g&&g<=48315||48317<=g&&g<=48343||48345<=g&&g<=48371||48373<=g&&g<=48399||48401<=g&&g<=48427||48429<=g&&g<=48455||48457<=g&&g<=48483||48485<=g&&g<=48511||48513<=g&&g<=48539||48541<=g&&g<=48567||48569<=g&&g<=48595||48597<=g&&g<=48623||48625<=g&&g<=48651||48653<=g&&g<=48679||48681<=g&&g<=48707||48709<=g&&g<=48735||48737<=g&&g<=48763||48765<=g&&g<=48791||48793<=g&&g<=48819||48821<=g&&g<=48847||48849<=g&&g<=48875||48877<=g&&g<=48903||48905<=g&&g<=48931||48933<=g&&g<=48959||48961<=g&&g<=48987||48989<=g&&g<=49015||49017<=g&&g<=49043||49045<=g&&g<=49071||49073<=g&&g<=49099||49101<=g&&g<=49127||49129<=g&&g<=49155||49157<=g&&g<=49183||49185<=g&&g<=49211||49213<=g&&g<=49239||49241<=g&&g<=49267||49269<=g&&g<=49295||49297<=g&&g<=49323||49325<=g&&g<=49351||49353<=g&&g<=49379||49381<=g&&g<=49407||49409<=g&&g<=49435||49437<=g&&g<=49463||49465<=g&&g<=49491||49493<=g&&g<=49519||49521<=g&&g<=49547||49549<=g&&g<=49575||49577<=g&&g<=49603||49605<=g&&g<=49631||49633<=g&&g<=49659||49661<=g&&g<=49687||49689<=g&&g<=49715||49717<=g&&g<=49743||49745<=g&&g<=49771||49773<=g&&g<=49799||49801<=g&&g<=49827||49829<=g&&g<=49855||49857<=g&&g<=49883||49885<=g&&g<=49911||49913<=g&&g<=49939||49941<=g&&g<=49967||49969<=g&&g<=49995||49997<=g&&g<=50023||50025<=g&&g<=50051||50053<=g&&g<=50079||50081<=g&&g<=50107||50109<=g&&g<=50135||50137<=g&&g<=50163||50165<=g&&g<=50191||50193<=g&&g<=50219||50221<=g&&g<=50247||50249<=g&&g<=50275||50277<=g&&g<=50303||50305<=g&&g<=50331||50333<=g&&g<=50359||50361<=g&&g<=50387||50389<=g&&g<=50415||50417<=g&&g<=50443||50445<=g&&g<=50471||50473<=g&&g<=50499||50501<=g&&g<=50527||50529<=g&&g<=50555||50557<=g&&g<=50583||50585<=g&&g<=50611||50613<=g&&g<=50639||50641<=g&&g<=50667||50669<=g&&g<=50695||50697<=g&&g<=50723||50725<=g&&g<=50751||50753<=g&&g<=50779||50781<=g&&g<=50807||50809<=g&&g<=50835||50837<=g&&g<=50863||50865<=g&&g<=50891||50893<=g&&g<=50919||50921<=g&&g<=50947||50949<=g&&g<=50975||50977<=g&&g<=51003||51005<=g&&g<=51031||51033<=g&&g<=51059||51061<=g&&g<=51087||51089<=g&&g<=51115||51117<=g&&g<=51143||51145<=g&&g<=51171||51173<=g&&g<=51199||51201<=g&&g<=51227||51229<=g&&g<=51255||51257<=g&&g<=51283||51285<=g&&g<=51311||51313<=g&&g<=51339||51341<=g&&g<=51367||51369<=g&&g<=51395||51397<=g&&g<=51423||51425<=g&&g<=51451||51453<=g&&g<=51479||51481<=g&&g<=51507||51509<=g&&g<=51535||51537<=g&&g<=51563||51565<=g&&g<=51591||51593<=g&&g<=51619||51621<=g&&g<=51647||51649<=g&&g<=51675||51677<=g&&g<=51703||51705<=g&&g<=51731||51733<=g&&g<=51759||51761<=g&&g<=51787||51789<=g&&g<=51815||51817<=g&&g<=51843||51845<=g&&g<=51871||51873<=g&&g<=51899||51901<=g&&g<=51927||51929<=g&&g<=51955||51957<=g&&g<=51983||51985<=g&&g<=52011||52013<=g&&g<=52039||52041<=g&&g<=52067||52069<=g&&g<=52095||52097<=g&&g<=52123||52125<=g&&g<=52151||52153<=g&&g<=52179||52181<=g&&g<=52207||52209<=g&&g<=52235||52237<=g&&g<=52263||52265<=g&&g<=52291||52293<=g&&g<=52319||52321<=g&&g<=52347||52349<=g&&g<=52375||52377<=g&&g<=52403||52405<=g&&g<=52431||52433<=g&&g<=52459||52461<=g&&g<=52487||52489<=g&&g<=52515||52517<=g&&g<=52543||52545<=g&&g<=52571||52573<=g&&g<=52599||52601<=g&&g<=52627||52629<=g&&g<=52655||52657<=g&&g<=52683||52685<=g&&g<=52711||52713<=g&&g<=52739||52741<=g&&g<=52767||52769<=g&&g<=52795||52797<=g&&g<=52823||52825<=g&&g<=52851||52853<=g&&g<=52879||52881<=g&&g<=52907||52909<=g&&g<=52935||52937<=g&&g<=52963||52965<=g&&g<=52991||52993<=g&&g<=53019||53021<=g&&g<=53047||53049<=g&&g<=53075||53077<=g&&g<=53103||53105<=g&&g<=53131||53133<=g&&g<=53159||53161<=g&&g<=53187||53189<=g&&g<=53215||53217<=g&&g<=53243||53245<=g&&g<=53271||53273<=g&&g<=53299||53301<=g&&g<=53327||53329<=g&&g<=53355||53357<=g&&g<=53383||53385<=g&&g<=53411||53413<=g&&g<=53439||53441<=g&&g<=53467||53469<=g&&g<=53495||53497<=g&&g<=53523||53525<=g&&g<=53551||53553<=g&&g<=53579||53581<=g&&g<=53607||53609<=g&&g<=53635||53637<=g&&g<=53663||53665<=g&&g<=53691||53693<=g&&g<=53719||53721<=g&&g<=53747||53749<=g&&g<=53775||53777<=g&&g<=53803||53805<=g&&g<=53831||53833<=g&&g<=53859||53861<=g&&g<=53887||53889<=g&&g<=53915||53917<=g&&g<=53943||53945<=g&&g<=53971||53973<=g&&g<=53999||54001<=g&&g<=54027||54029<=g&&g<=54055||54057<=g&&g<=54083||54085<=g&&g<=54111||54113<=g&&g<=54139||54141<=g&&g<=54167||54169<=g&&g<=54195||54197<=g&&g<=54223||54225<=g&&g<=54251||54253<=g&&g<=54279||54281<=g&&g<=54307||54309<=g&&g<=54335||54337<=g&&g<=54363||54365<=g&&g<=54391||54393<=g&&g<=54419||54421<=g&&g<=54447||54449<=g&&g<=54475||54477<=g&&g<=54503||54505<=g&&g<=54531||54533<=g&&g<=54559||54561<=g&&g<=54587||54589<=g&&g<=54615||54617<=g&&g<=54643||54645<=g&&g<=54671||54673<=g&&g<=54699||54701<=g&&g<=54727||54729<=g&&g<=54755||54757<=g&&g<=54783||54785<=g&&g<=54811||54813<=g&&g<=54839||54841<=g&&g<=54867||54869<=g&&g<=54895||54897<=g&&g<=54923||54925<=g&&g<=54951||54953<=g&&g<=54979||54981<=g&&g<=55007||55009<=g&&g<=55035||55037<=g&&g<=55063||55065<=g&&g<=55091||55093<=g&&g<=55119||55121<=g&&g<=55147||55149<=g&&g<=55175||55177<=g&&g<=55203?E:g==9757||g==9977||9994<=g&&g<=9997||g==127877||127938<=g&&g<=127940||g==127943||127946<=g&&g<=127948||128066<=g&&g<=128067||128070<=g&&g<=128080||g==128110||128112<=g&&g<=128120||g==128124||128129<=g&&g<=128131||128133<=g&&g<=128135||g==128170||128372<=g&&g<=128373||g==128378||g==128400||128405<=g&&g<=128406||128581<=g&&g<=128583||128587<=g&&g<=128591||g==128675||128692<=g&&g<=128694||g==128704||g==128716||129304<=g&&g<=129308||129310<=g&&g<=129311||g==129318||129328<=g&&g<=129337||129341<=g&&g<=129342||129489<=g&&g<=129501?x:127995<=g&&g<=127999?C:g==8205?R:g==9792||g==9794||9877<=g&&g<=9878||g==9992||g==10084||g==127752||g==127806||g==127859||g==127891||g==127908||g==127912||g==127979||g==127981||g==128139||128187<=g&&g<=128188||g==128295||g==128300||g==128488||g==128640||g==128658?L:128102<=g&&g<=128105?U:I}return this}typeof jb<"u"&&jb.exports&&(jb.exports=pat)});var jce=_((g4t,Hce)=>{var hat=/^(.*?)(\x1b\[[^m]+m|\x1b\]8;;.*?(\x1b\\|\u0007))/,Gb;function gat(){if(Gb)return Gb;if(typeof Intl.Segmenter<"u"){let t=new Intl.Segmenter("en",{granularity:"grapheme"});return Gb=e=>Array.from(t.segment(e),({segment:r})=>r)}else{let t=_ce(),e=new t;return Gb=r=>e.splitGraphemes(r)}}Hce.exports=(t,e=0,r=t.length)=>{if(e<0||r<0)throw new RangeError("Negative indices aren't supported by this implementation");let o=r-e,a="",n=0,u=0;for(;t.length>0;){let A=t.match(hat)||[t,t,void 0],p=gat()(A[1]),h=Math.min(e-n,p.length);p=p.slice(h);let E=Math.min(o-u,p.length);a+=p.slice(0,E).join(""),n+=h,u+=E,typeof A[2]<"u"&&(a+=A[2]),t=t.slice(A[0].length)}return a}});var rn,L1=Et(()=>{rn=process.env.YARN_IS_TEST_ENV?"0.0.0":"4.1.0"});function Vce(t,{configuration:e,json:r}){if(!e.get("enableMessageNames"))return"";let a=Wu(t===null?0:t);return!r&&t===null?Mt(e,a,"grey"):a}function yU(t,{configuration:e,json:r}){let o=Vce(t,{configuration:e,json:r});if(!o||t===null||t===0)return o;let a=wr[t],n=`https://yarnpkg.com/advanced/error-codes#${o}---${a}`.toLowerCase();return Zy(e,o,n)}async function LE({configuration:t,stdout:e,forceError:r},o){let a=await Nt.start({configuration:t,stdout:e,includeFooter:!1},async n=>{let u=!1,A=!1;for(let p of o)typeof p.option<"u"&&(p.error||r?(A=!0,n.reportError(50,p.message)):(u=!0,n.reportWarning(50,p.message)),p.callback?.());u&&!A&&n.reportSeparator()});return a.hasErrors()?a.exitCode():null}var Wce,qb,dat,Gce,qce,Ah,Kce,Yce,mat,yat,Yb,Eat,Nt,O1=Et(()=>{Wce=$e(jce()),qb=$e(td());fS();Yl();L1();Gl();dat="\xB7",Gce=["\u280B","\u2819","\u2839","\u2838","\u283C","\u2834","\u2826","\u2827","\u2807","\u280F"],qce=80,Ah=qb.default.GITHUB_ACTIONS?{start:t=>`::group::${t} +`,end:t=>`::endgroup:: +`}:qb.default.TRAVIS?{start:t=>`travis_fold:start:${t} +`,end:t=>`travis_fold:end:${t} +`}:qb.default.GITLAB?{start:t=>`section_start:${Math.floor(Date.now()/1e3)}:${t.toLowerCase().replace(/\W+/g,"_")}[collapsed=true]\r\x1B[0K${t} +`,end:t=>`section_end:${Math.floor(Date.now()/1e3)}:${t.toLowerCase().replace(/\W+/g,"_")}\r\x1B[0K`}:null,Kce=Ah!==null,Yce=new Date,mat=["iTerm.app","Apple_Terminal","WarpTerminal","vscode"].includes(process.env.TERM_PROGRAM)||!!process.env.WT_SESSION,yat=t=>t,Yb=yat({patrick:{date:[17,3],chars:["\u{1F340}","\u{1F331}"],size:40},simba:{date:[19,7],chars:["\u{1F981}","\u{1F334}"],size:40},jack:{date:[31,10],chars:["\u{1F383}","\u{1F987}"],size:40},hogsfather:{date:[31,12],chars:["\u{1F389}","\u{1F384}"],size:40},default:{chars:["=","-"],size:80}}),Eat=mat&&Object.keys(Yb).find(t=>{let e=Yb[t];return!(e.date&&(e.date[0]!==Yce.getDate()||e.date[1]!==Yce.getMonth()+1))})||"default";Nt=class extends Xs{constructor({configuration:r,stdout:o,json:a=!1,forceSectionAlignment:n=!1,includeNames:u=!0,includePrefix:A=!0,includeFooter:p=!0,includeLogs:h=!a,includeInfos:E=h,includeWarnings:I=h}){super();this.uncommitted=new Set;this.warningCount=0;this.errorCount=0;this.timerFooter=[];this.startTime=Date.now();this.indent=0;this.level=0;this.progress=new Map;this.progressTime=0;this.progressFrame=0;this.progressTimeout=null;this.progressStyle=null;this.progressMaxScaledSize=null;if(XI(this,{configuration:r}),this.configuration=r,this.forceSectionAlignment=n,this.includeNames=u,this.includePrefix=A,this.includeFooter=p,this.includeInfos=E,this.includeWarnings=I,this.json=a,this.stdout=o,r.get("enableProgressBars")&&!a&&o.isTTY&&o.columns>22){let v=r.get("progressBarStyle")||Eat;if(!Object.hasOwn(Yb,v))throw new Error("Assertion failed: Invalid progress bar style");this.progressStyle=Yb[v];let x=Math.min(this.getRecommendedLength(),80);this.progressMaxScaledSize=Math.floor(this.progressStyle.size*x/80)}}static async start(r,o){let a=new this(r),n=process.emitWarning;process.emitWarning=(u,A)=>{if(typeof u!="string"){let h=u;u=h.message,A=A??h.name}let p=typeof A<"u"?`${A}: ${u}`:u;a.reportWarning(0,p)},r.includeVersion&&a.reportInfo(0,yd(r.configuration,`Yarn ${rn}`,2));try{await o(a)}catch(u){a.reportExceptionOnce(u)}finally{await a.finalize(),process.emitWarning=n}return a}hasErrors(){return this.errorCount>0}exitCode(){return this.hasErrors()?1:0}getRecommendedLength(){let o=this.progressStyle!==null?this.stdout.columns-1:super.getRecommendedLength();return Math.max(40,o-12-this.indent*2)}startSectionSync({reportHeader:r,reportFooter:o,skipIfEmpty:a},n){let u={committed:!1,action:()=>{r?.()}};a?this.uncommitted.add(u):(u.action(),u.committed=!0);let A=Date.now();try{return n()}catch(p){throw this.reportExceptionOnce(p),p}finally{let p=Date.now();this.uncommitted.delete(u),u.committed&&o?.(p-A)}}async startSectionPromise({reportHeader:r,reportFooter:o,skipIfEmpty:a},n){let u={committed:!1,action:()=>{r?.()}};a?this.uncommitted.add(u):(u.action(),u.committed=!0);let A=Date.now();try{return await n()}catch(p){throw this.reportExceptionOnce(p),p}finally{let p=Date.now();this.uncommitted.delete(u),u.committed&&o?.(p-A)}}startTimerImpl(r,o,a){return{cb:typeof o=="function"?o:a,reportHeader:()=>{this.level+=1,this.reportInfo(null,`\u250C ${r}`),this.indent+=1,Ah!==null&&!this.json&&this.includeInfos&&this.stdout.write(Ah.start(r))},reportFooter:A=>{if(this.indent-=1,Ah!==null&&!this.json&&this.includeInfos){this.stdout.write(Ah.end(r));for(let p of this.timerFooter)p()}this.configuration.get("enableTimers")&&A>200?this.reportInfo(null,`\u2514 Completed in ${Mt(this.configuration,A,yt.DURATION)}`):this.reportInfo(null,"\u2514 Completed"),this.level-=1},skipIfEmpty:(typeof o=="function"?{}:o).skipIfEmpty}}startTimerSync(r,o,a){let{cb:n,...u}=this.startTimerImpl(r,o,a);return this.startSectionSync(u,n)}async startTimerPromise(r,o,a){let{cb:n,...u}=this.startTimerImpl(r,o,a);return this.startSectionPromise(u,n)}reportSeparator(){this.indent===0?this.writeLine(""):this.reportInfo(null,"")}reportInfo(r,o){if(!this.includeInfos)return;this.commit();let a=this.formatNameWithHyperlink(r),n=a?`${a}: `:"",u=`${this.formatPrefix(n,"blueBright")}${o}`;this.json?this.reportJson({type:"info",name:r,displayName:this.formatName(r),indent:this.formatIndent(),data:o}):this.writeLine(u)}reportWarning(r,o){if(this.warningCount+=1,!this.includeWarnings)return;this.commit();let a=this.formatNameWithHyperlink(r),n=a?`${a}: `:"";this.json?this.reportJson({type:"warning",name:r,displayName:this.formatName(r),indent:this.formatIndent(),data:o}):this.writeLine(`${this.formatPrefix(n,"yellowBright")}${o}`)}reportError(r,o){this.errorCount+=1,this.timerFooter.push(()=>this.reportErrorImpl(r,o)),this.reportErrorImpl(r,o)}reportErrorImpl(r,o){this.commit();let a=this.formatNameWithHyperlink(r),n=a?`${a}: `:"";this.json?this.reportJson({type:"error",name:r,displayName:this.formatName(r),indent:this.formatIndent(),data:o}):this.writeLine(`${this.formatPrefix(n,"redBright")}${o}`,{truncate:!1})}reportFold(r,o){if(!Ah)return;let a=`${Ah.start(r)}${o}${Ah.end(r)}`;this.timerFooter.push(()=>this.stdout.write(a))}reportProgress(r){if(this.progressStyle===null)return{...Promise.resolve(),stop:()=>{}};if(r.hasProgress&&r.hasTitle)throw new Error("Unimplemented: Progress bars can't have both progress and titles.");let o=!1,a=Promise.resolve().then(async()=>{let u={progress:r.hasProgress?0:void 0,title:r.hasTitle?"":void 0};this.progress.set(r,{definition:u,lastScaledSize:r.hasProgress?-1:void 0,lastTitle:void 0}),this.refreshProgress({delta:-1});for await(let{progress:A,title:p}of r)o||u.progress===A&&u.title===p||(u.progress=A,u.title=p,this.refreshProgress());n()}),n=()=>{o||(o=!0,this.progress.delete(r),this.refreshProgress({delta:1}))};return{...a,stop:n}}reportJson(r){this.json&&this.writeLine(`${JSON.stringify(r)}`)}async finalize(){if(!this.includeFooter)return;let r="";this.errorCount>0?r="Failed with errors":this.warningCount>0?r="Done with warnings":r="Done";let o=Mt(this.configuration,Date.now()-this.startTime,yt.DURATION),a=this.configuration.get("enableTimers")?`${r} in ${o}`:r;this.errorCount>0?this.reportError(0,a):this.warningCount>0?this.reportWarning(0,a):this.reportInfo(0,a)}writeLine(r,{truncate:o}={}){this.clearProgress({clear:!0}),this.stdout.write(`${this.truncate(r,{truncate:o})} +`),this.writeProgress()}writeLines(r,{truncate:o}={}){this.clearProgress({delta:r.length});for(let a of r)this.stdout.write(`${this.truncate(a,{truncate:o})} +`);this.writeProgress()}commit(){let r=this.uncommitted;this.uncommitted=new Set;for(let o of r)o.committed=!0,o.action()}clearProgress({delta:r=0,clear:o=!1}){this.progressStyle!==null&&this.progress.size+r>0&&(this.stdout.write(`\x1B[${this.progress.size+r}A`),(r>0||o)&&this.stdout.write("\x1B[0J"))}writeProgress(){if(this.progressStyle===null||(this.progressTimeout!==null&&clearTimeout(this.progressTimeout),this.progressTimeout=null,this.progress.size===0))return;let r=Date.now();r-this.progressTime>qce&&(this.progressFrame=(this.progressFrame+1)%Gce.length,this.progressTime=r);let o=Gce[this.progressFrame];for(let a of this.progress.values()){let n="";if(typeof a.lastScaledSize<"u"){let h=this.progressStyle.chars[0].repeat(a.lastScaledSize),E=this.progressStyle.chars[1].repeat(this.progressMaxScaledSize-a.lastScaledSize);n=` ${h}${E}`}let u=this.formatName(null),A=u?`${u}: `:"",p=a.definition.title?` ${a.definition.title}`:"";this.stdout.write(`${Mt(this.configuration,"\u27A4","blueBright")} ${A}${o}${n}${p} +`)}this.progressTimeout=setTimeout(()=>{this.refreshProgress({force:!0})},qce)}refreshProgress({delta:r=0,force:o=!1}={}){let a=!1,n=!1;if(o||this.progress.size===0)a=!0;else for(let u of this.progress.values()){let A=typeof u.definition.progress<"u"?Math.trunc(this.progressMaxScaledSize*u.definition.progress):void 0,p=u.lastScaledSize;u.lastScaledSize=A;let h=u.lastTitle;if(u.lastTitle=u.definition.title,A!==p||(n=h!==u.definition.title)){a=!0;break}}a&&(this.clearProgress({delta:r,clear:n}),this.writeProgress())}truncate(r,{truncate:o}={}){return this.progressStyle===null&&(o=!1),typeof o>"u"&&(o=this.configuration.get("preferTruncatedLines")),o&&(r=(0,Wce.default)(r,0,this.stdout.columns-1)),r}formatName(r){return this.includeNames?Vce(r,{configuration:this.configuration,json:this.json}):""}formatPrefix(r,o){return this.includePrefix?`${Mt(this.configuration,"\u27A4",o)} ${r}${this.formatIndent()}`:""}formatNameWithHyperlink(r){return this.includeNames?yU(r,{configuration:this.configuration,json:this.json}):""}formatIndent(){return this.level>0||!this.forceSectionAlignment?"\u2502 ".repeat(this.indent):`${dat} `}}});var un={};Vt(un,{PackageManager:()=>Xce,detectPackageManager:()=>Zce,executePackageAccessibleBinary:()=>nue,executePackageScript:()=>Wb,executePackageShellcode:()=>EU,executeWorkspaceAccessibleBinary:()=>Sat,executeWorkspaceLifecycleScript:()=>tue,executeWorkspaceScript:()=>eue,getPackageAccessibleBinaries:()=>Kb,getWorkspaceAccessibleBinaries:()=>rue,hasPackageScript:()=>Bat,hasWorkspaceScript:()=>CU,isNodeScript:()=>wU,makeScriptEnv:()=>M1,maybeExecuteWorkspaceLifecycleScript:()=>Dat,prepareExternalProject:()=>Iat});async function fh(t,e,r,o=[]){if(process.platform==="win32"){let a=`@goto #_undefined_# 2>NUL || @title %COMSPEC% & @setlocal & @"${r}" ${o.map(n=>`"${n.replace('"','""')}"`).join(" ")} %*`;await oe.writeFilePromise(V.format({dir:t,name:e,ext:".cmd"}),a)}await oe.writeFilePromise(V.join(t,e),`#!/bin/sh +exec "${r}" ${o.map(a=>`'${a.replace(/'/g,`'"'"'`)}'`).join(" ")} "$@" +`,{mode:493})}async function Zce(t){let e=await Ot.tryFind(t);if(e?.packageManager){let o=UP(e.packageManager);if(o?.name){let a=`found ${JSON.stringify({packageManager:e.packageManager})} in manifest`,[n]=o.reference.split(".");switch(o.name){case"yarn":return{packageManagerField:!0,packageManager:Number(n)===1?"Yarn Classic":"Yarn",reason:a};case"npm":return{packageManagerField:!0,packageManager:"npm",reason:a};case"pnpm":return{packageManagerField:!0,packageManager:"pnpm",reason:a}}}}let r;try{r=await oe.readFilePromise(V.join(t,dr.lockfile),"utf8")}catch{}return r!==void 0?r.match(/^__metadata:$/m)?{packageManager:"Yarn",reason:'"__metadata" key found in yarn.lock'}:{packageManager:"Yarn Classic",reason:'"__metadata" key not found in yarn.lock, must be a Yarn classic lockfile'}:oe.existsSync(V.join(t,"package-lock.json"))?{packageManager:"npm",reason:`found npm's "package-lock.json" lockfile`}:oe.existsSync(V.join(t,"pnpm-lock.yaml"))?{packageManager:"pnpm",reason:`found pnpm's "pnpm-lock.yaml" lockfile`}:null}async function M1({project:t,locator:e,binFolder:r,ignoreCorepack:o,lifecycleScript:a,baseEnv:n=t?.configuration.env??process.env}){let u={};for(let[E,I]of Object.entries(n))typeof I<"u"&&(u[E.toLowerCase()!=="path"?E:"PATH"]=I);let A=ue.fromPortablePath(r);u.BERRY_BIN_FOLDER=ue.fromPortablePath(A);let p=process.env.COREPACK_ROOT&&!o?ue.join(process.env.COREPACK_ROOT,"dist/yarn.js"):process.argv[1];if(await Promise.all([fh(r,"node",process.execPath),...rn!==null?[fh(r,"run",process.execPath,[p,"run"]),fh(r,"yarn",process.execPath,[p]),fh(r,"yarnpkg",process.execPath,[p]),fh(r,"node-gyp",process.execPath,[p,"run","--top-level","node-gyp"])]:[]]),t&&(u.INIT_CWD=ue.fromPortablePath(t.configuration.startingCwd),u.PROJECT_CWD=ue.fromPortablePath(t.cwd)),u.PATH=u.PATH?`${A}${ue.delimiter}${u.PATH}`:`${A}`,u.npm_execpath=`${A}${ue.sep}yarn`,u.npm_node_execpath=`${A}${ue.sep}node`,e){if(!t)throw new Error("Assertion failed: Missing project");let E=t.tryWorkspaceByLocator(e),I=E?E.manifest.version??"":t.storedPackages.get(e.locatorHash).version??"";u.npm_package_name=fn(e),u.npm_package_version=I;let v;if(E)v=E.cwd;else{let x=t.storedPackages.get(e.locatorHash);if(!x)throw new Error(`Package for ${jr(t.configuration,e)} not found in the project`);let C=t.configuration.getLinkers(),R={project:t,report:new Nt({stdout:new ph.PassThrough,configuration:t.configuration})},L=C.find(U=>U.supportsPackage(x,R));if(!L)throw new Error(`The package ${jr(t.configuration,x)} isn't supported by any of the available linkers`);v=await L.findPackageLocation(x,R)}u.npm_package_json=ue.fromPortablePath(V.join(v,dr.manifest))}let h=rn!==null?`yarn/${rn}`:`yarn/${Df("@yarnpkg/core").version}-core`;return u.npm_config_user_agent=`${h} npm/? node/${process.version} ${process.platform} ${process.arch}`,a&&(u.npm_lifecycle_event=a),t&&await t.configuration.triggerHook(E=>E.setupScriptEnvironment,t,u,async(E,I,v)=>await fh(r,E,I,v)),u}async function Iat(t,e,{configuration:r,report:o,workspace:a=null,locator:n=null}){await wat(async()=>{await oe.mktempPromise(async u=>{let A=V.join(u,"pack.log"),p=null,{stdout:h,stderr:E}=r.getSubprocessStreams(A,{prefix:ue.fromPortablePath(t),report:o}),I=n&&Hc(n)?r1(n):n,v=I?ba(I):"an external project";h.write(`Packing ${v} from sources +`);let x=await Zce(t),C;x!==null?(h.write(`Using ${x.packageManager} for bootstrap. Reason: ${x.reason} + +`),C=x.packageManager):(h.write(`No package manager configuration detected; defaulting to Yarn + +`),C="Yarn");let R=C==="Yarn"&&!x?.packageManagerField;await oe.mktempPromise(async L=>{let U=await M1({binFolder:L,ignoreCorepack:R}),te=new Map([["Yarn Classic",async()=>{let fe=a!==null?["workspace",a]:[],ce=V.join(t,dr.manifest),me=await oe.readFilePromise(ce),he=await qc(process.execPath,[process.argv[1],"set","version","classic","--only-if-needed","--yarn-path"],{cwd:t,env:U,stdin:p,stdout:h,stderr:E,end:1});if(he.code!==0)return he.code;await oe.writeFilePromise(ce,me),await oe.appendFilePromise(V.join(t,".npmignore"),`/.yarn +`),h.write(` +`),delete U.NODE_ENV;let Be=await qc("yarn",["install"],{cwd:t,env:U,stdin:p,stdout:h,stderr:E,end:1});if(Be.code!==0)return Be.code;h.write(` +`);let we=await qc("yarn",[...fe,"pack","--filename",ue.fromPortablePath(e)],{cwd:t,env:U,stdin:p,stdout:h,stderr:E});return we.code!==0?we.code:0}],["Yarn",async()=>{let fe=a!==null?["workspace",a]:[];U.YARN_ENABLE_INLINE_BUILDS="1";let ce=V.join(t,dr.lockfile);await oe.existsPromise(ce)||await oe.writeFilePromise(ce,"");let me=await qc("yarn",[...fe,"pack","--install-if-needed","--filename",ue.fromPortablePath(e)],{cwd:t,env:U,stdin:p,stdout:h,stderr:E});return me.code!==0?me.code:0}],["npm",async()=>{if(a!==null){let Ee=new ph.PassThrough,Se=Vy(Ee);Ee.pipe(h,{end:!1});let le=await qc("npm",["--version"],{cwd:t,env:U,stdin:p,stdout:Ee,stderr:E,end:0});if(Ee.end(),le.code!==0)return h.end(),E.end(),le.code;let ne=(await Se).toString().trim();if(!kf(ne,">=7.x")){let ee=eA(null,"npm"),Ie=In(ee,ne),Fe=In(ee,">=7.x");throw new Error(`Workspaces aren't supported by ${Gn(r,Ie)}; please upgrade to ${Gn(r,Fe)} (npm has been detected as the primary package manager for ${Mt(r,t,yt.PATH)})`)}}let fe=a!==null?["--workspace",a]:[];delete U.npm_config_user_agent,delete U.npm_config_production,delete U.NPM_CONFIG_PRODUCTION,delete U.NODE_ENV;let ce=await qc("npm",["install","--legacy-peer-deps"],{cwd:t,env:U,stdin:p,stdout:h,stderr:E,end:1});if(ce.code!==0)return ce.code;let me=new ph.PassThrough,he=Vy(me);me.pipe(h);let Be=await qc("npm",["pack","--silent",...fe],{cwd:t,env:U,stdin:p,stdout:me,stderr:E});if(Be.code!==0)return Be.code;let we=(await he).toString().trim().replace(/^.*\n/s,""),g=V.resolve(t,ue.toPortablePath(we));return await oe.renamePromise(g,e),0}]]).get(C);if(typeof te>"u")throw new Error("Assertion failed: Unsupported workflow");let ae=await te();if(!(ae===0||typeof ae>"u"))throw oe.detachTemp(u),new zt(58,`Packing the package failed (exit code ${ae}, logs can be found here: ${Mt(r,A,yt.PATH)})`)})})})}async function Bat(t,e,{project:r}){let o=r.tryWorkspaceByLocator(t);if(o!==null)return CU(o,e);let a=r.storedPackages.get(t.locatorHash);if(!a)throw new Error(`Package for ${jr(r.configuration,t)} not found in the project`);return await Jl.openPromise(async n=>{let u=r.configuration,A=r.configuration.getLinkers(),p={project:r,report:new Nt({stdout:new ph.PassThrough,configuration:u})},h=A.find(x=>x.supportsPackage(a,p));if(!h)throw new Error(`The package ${jr(r.configuration,a)} isn't supported by any of the available linkers`);let E=await h.findPackageLocation(a,p),I=new gn(E,{baseFs:n});return(await Ot.find(Bt.dot,{baseFs:I})).scripts.has(e)})}async function Wb(t,e,r,{cwd:o,project:a,stdin:n,stdout:u,stderr:A}){return await oe.mktempPromise(async p=>{let{manifest:h,env:E,cwd:I}=await $ce(t,{project:a,binFolder:p,cwd:o,lifecycleScript:e}),v=h.scripts.get(e);if(typeof v>"u")return 1;let x=async()=>await TE(v,r,{cwd:I,env:E,stdin:n,stdout:u,stderr:A});return await(await a.configuration.reduceHook(R=>R.wrapScriptExecution,x,a,t,e,{script:v,args:r,cwd:I,env:E,stdin:n,stdout:u,stderr:A}))()})}async function EU(t,e,r,{cwd:o,project:a,stdin:n,stdout:u,stderr:A}){return await oe.mktempPromise(async p=>{let{env:h,cwd:E}=await $ce(t,{project:a,binFolder:p,cwd:o});return await TE(e,r,{cwd:E,env:h,stdin:n,stdout:u,stderr:A})})}async function vat(t,{binFolder:e,cwd:r,lifecycleScript:o}){let a=await M1({project:t.project,locator:t.anchoredLocator,binFolder:e,lifecycleScript:o});return await IU(e,await rue(t)),typeof r>"u"&&(r=V.dirname(await oe.realpathPromise(V.join(t.cwd,"package.json")))),{manifest:t.manifest,binFolder:e,env:a,cwd:r}}async function $ce(t,{project:e,binFolder:r,cwd:o,lifecycleScript:a}){let n=e.tryWorkspaceByLocator(t);if(n!==null)return vat(n,{binFolder:r,cwd:o,lifecycleScript:a});let u=e.storedPackages.get(t.locatorHash);if(!u)throw new Error(`Package for ${jr(e.configuration,t)} not found in the project`);return await Jl.openPromise(async A=>{let p=e.configuration,h=e.configuration.getLinkers(),E={project:e,report:new Nt({stdout:new ph.PassThrough,configuration:p})},I=h.find(L=>L.supportsPackage(u,E));if(!I)throw new Error(`The package ${jr(e.configuration,u)} isn't supported by any of the available linkers`);let v=await M1({project:e,locator:t,binFolder:r,lifecycleScript:a});await IU(r,await Kb(t,{project:e}));let x=await I.findPackageLocation(u,E),C=new gn(x,{baseFs:A}),R=await Ot.find(Bt.dot,{baseFs:C});return typeof o>"u"&&(o=x),{manifest:R,binFolder:r,env:v,cwd:o}})}async function eue(t,e,r,{cwd:o,stdin:a,stdout:n,stderr:u}){return await Wb(t.anchoredLocator,e,r,{cwd:o,project:t.project,stdin:a,stdout:n,stderr:u})}function CU(t,e){return t.manifest.scripts.has(e)}async function tue(t,e,{cwd:r,report:o}){let{configuration:a}=t.project,n=null;await oe.mktempPromise(async u=>{let A=V.join(u,`${e}.log`),p=`# This file contains the result of Yarn calling the "${e}" lifecycle script inside a workspace ("${ue.fromPortablePath(t.cwd)}") +`,{stdout:h,stderr:E}=a.getSubprocessStreams(A,{report:o,prefix:jr(a,t.anchoredLocator),header:p});o.reportInfo(36,`Calling the "${e}" lifecycle script`);let I=await eue(t,e,[],{cwd:r,stdin:n,stdout:h,stderr:E});if(h.end(),E.end(),I!==0)throw oe.detachTemp(u),new zt(36,`${(0,Jce.default)(e)} script failed (exit code ${Mt(a,I,yt.NUMBER)}, logs can be found here: ${Mt(a,A,yt.PATH)}); run ${Mt(a,`yarn ${e}`,yt.CODE)} to investigate`)})}async function Dat(t,e,r){CU(t,e)&&await tue(t,e,r)}function wU(t){let e=V.extname(t);if(e.match(/\.[cm]?[jt]sx?$/))return!0;if(e===".exe"||e===".bin")return!1;let r=Buffer.alloc(4),o;try{o=oe.openSync(t,"r")}catch{return!0}try{oe.readSync(o,r,0,r.length,0)}finally{oe.closeSync(o)}let a=r.readUint32BE();return!(a===3405691582||a===3489328638||a===2135247942||(a&4294901760)===1297743872)}async function Kb(t,{project:e}){let r=e.configuration,o=new Map,a=e.storedPackages.get(t.locatorHash);if(!a)throw new Error(`Package for ${jr(r,t)} not found in the project`);let n=new ph.Writable,u=r.getLinkers(),A={project:e,report:new Nt({configuration:r,stdout:n})},p=new Set([t.locatorHash]);for(let E of a.dependencies.values()){let I=e.storedResolutions.get(E.descriptorHash);if(!I)throw new Error(`Assertion failed: The resolution (${Gn(r,E)}) should have been registered`);p.add(I)}let h=await Promise.all(Array.from(p,async E=>{let I=e.storedPackages.get(E);if(!I)throw new Error(`Assertion failed: The package (${E}) should have been registered`);if(I.bin.size===0)return sl.skip;let v=u.find(C=>C.supportsPackage(I,A));if(!v)return sl.skip;let x=null;try{x=await v.findPackageLocation(I,A)}catch(C){if(C.code==="LOCATOR_NOT_INSTALLED")return sl.skip;throw C}return{dependency:I,packageLocation:x}}));for(let E of h){if(E===sl.skip)continue;let{dependency:I,packageLocation:v}=E;for(let[x,C]of I.bin){let R=V.resolve(v,C);o.set(x,[I,ue.fromPortablePath(R),wU(R)])}}return o}async function rue(t){return await Kb(t.anchoredLocator,{project:t.project})}async function IU(t,e){await Promise.all(Array.from(e,([r,[,o,a]])=>a?fh(t,r,process.execPath,[o]):fh(t,r,o,[])))}async function nue(t,e,r,{cwd:o,project:a,stdin:n,stdout:u,stderr:A,nodeArgs:p=[],packageAccessibleBinaries:h}){h??=await Kb(t,{project:a});let E=h.get(e);if(!E)throw new Error(`Binary not found (${e}) for ${jr(a.configuration,t)}`);return await oe.mktempPromise(async I=>{let[,v]=E,x=await M1({project:a,locator:t,binFolder:I});await IU(x.BERRY_BIN_FOLDER,h);let C=wU(ue.toPortablePath(v))?qc(process.execPath,[...p,v,...r],{cwd:o,env:x,stdin:n,stdout:u,stderr:A}):qc(v,r,{cwd:o,env:x,stdin:n,stdout:u,stderr:A}),R;try{R=await C}finally{await oe.removePromise(x.BERRY_BIN_FOLDER)}return R.code})}async function Sat(t,e,r,{cwd:o,stdin:a,stdout:n,stderr:u,packageAccessibleBinaries:A}){return await nue(t.anchoredLocator,e,r,{project:t.project,cwd:o,stdin:a,stdout:n,stderr:u,packageAccessibleBinaries:A})}var Jce,zce,ph,Xce,Cat,wat,BU=Et(()=>{St();St();nA();k1();Jce=$e(mU()),zce=$e(id()),ph=ve("stream");fE();Yl();O1();L1();Db();Gl();jl();Qf();bo();Xce=(a=>(a.Yarn1="Yarn Classic",a.Yarn2="Yarn",a.Npm="npm",a.Pnpm="pnpm",a))(Xce||{});Cat=2,wat=(0,zce.default)(Cat)});var OE=_((L4t,sue)=>{"use strict";var iue=new Map([["C","cwd"],["f","file"],["z","gzip"],["P","preservePaths"],["U","unlink"],["strip-components","strip"],["stripComponents","strip"],["keep-newer","newer"],["keepNewer","newer"],["keep-newer-files","newer"],["keepNewerFiles","newer"],["k","keep"],["keep-existing","keep"],["keepExisting","keep"],["m","noMtime"],["no-mtime","noMtime"],["p","preserveOwner"],["L","follow"],["h","follow"]]);sue.exports=t=>t?Object.keys(t).map(e=>[iue.has(e)?iue.get(e):e,t[e]]).reduce((e,r)=>(e[r[0]]=r[1],e),Object.create(null)):{}});var UE=_((O4t,hue)=>{"use strict";var oue=typeof process=="object"&&process?process:{stdout:null,stderr:null},Pat=ve("events"),aue=ve("stream"),lue=ve("string_decoder").StringDecoder,Mf=Symbol("EOF"),Uf=Symbol("maybeEmitEnd"),hh=Symbol("emittedEnd"),Vb=Symbol("emittingEnd"),U1=Symbol("emittedError"),Jb=Symbol("closed"),cue=Symbol("read"),zb=Symbol("flush"),uue=Symbol("flushChunk"),ka=Symbol("encoding"),_f=Symbol("decoder"),Xb=Symbol("flowing"),_1=Symbol("paused"),ME=Symbol("resume"),Fs=Symbol("bufferLength"),vU=Symbol("bufferPush"),DU=Symbol("bufferShift"),Fo=Symbol("objectMode"),Ro=Symbol("destroyed"),SU=Symbol("emitData"),Aue=Symbol("emitEnd"),PU=Symbol("emitEnd2"),Hf=Symbol("async"),H1=t=>Promise.resolve().then(t),fue=global._MP_NO_ITERATOR_SYMBOLS_!=="1",bat=fue&&Symbol.asyncIterator||Symbol("asyncIterator not implemented"),xat=fue&&Symbol.iterator||Symbol("iterator not implemented"),kat=t=>t==="end"||t==="finish"||t==="prefinish",Qat=t=>t instanceof ArrayBuffer||typeof t=="object"&&t.constructor&&t.constructor.name==="ArrayBuffer"&&t.byteLength>=0,Fat=t=>!Buffer.isBuffer(t)&&ArrayBuffer.isView(t),Zb=class{constructor(e,r,o){this.src=e,this.dest=r,this.opts=o,this.ondrain=()=>e[ME](),r.on("drain",this.ondrain)}unpipe(){this.dest.removeListener("drain",this.ondrain)}proxyErrors(){}end(){this.unpipe(),this.opts.end&&this.dest.end()}},bU=class extends Zb{unpipe(){this.src.removeListener("error",this.proxyErrors),super.unpipe()}constructor(e,r,o){super(e,r,o),this.proxyErrors=a=>r.emit("error",a),e.on("error",this.proxyErrors)}};hue.exports=class pue extends aue{constructor(e){super(),this[Xb]=!1,this[_1]=!1,this.pipes=[],this.buffer=[],this[Fo]=e&&e.objectMode||!1,this[Fo]?this[ka]=null:this[ka]=e&&e.encoding||null,this[ka]==="buffer"&&(this[ka]=null),this[Hf]=e&&!!e.async||!1,this[_f]=this[ka]?new lue(this[ka]):null,this[Mf]=!1,this[hh]=!1,this[Vb]=!1,this[Jb]=!1,this[U1]=null,this.writable=!0,this.readable=!0,this[Fs]=0,this[Ro]=!1}get bufferLength(){return this[Fs]}get encoding(){return this[ka]}set encoding(e){if(this[Fo])throw new Error("cannot set encoding in objectMode");if(this[ka]&&e!==this[ka]&&(this[_f]&&this[_f].lastNeed||this[Fs]))throw new Error("cannot change encoding");this[ka]!==e&&(this[_f]=e?new lue(e):null,this.buffer.length&&(this.buffer=this.buffer.map(r=>this[_f].write(r)))),this[ka]=e}setEncoding(e){this.encoding=e}get objectMode(){return this[Fo]}set objectMode(e){this[Fo]=this[Fo]||!!e}get async(){return this[Hf]}set async(e){this[Hf]=this[Hf]||!!e}write(e,r,o){if(this[Mf])throw new Error("write after end");if(this[Ro])return this.emit("error",Object.assign(new Error("Cannot call write after a stream was destroyed"),{code:"ERR_STREAM_DESTROYED"})),!0;typeof r=="function"&&(o=r,r="utf8"),r||(r="utf8");let a=this[Hf]?H1:n=>n();return!this[Fo]&&!Buffer.isBuffer(e)&&(Fat(e)?e=Buffer.from(e.buffer,e.byteOffset,e.byteLength):Qat(e)?e=Buffer.from(e):typeof e!="string"&&(this.objectMode=!0)),this[Fo]?(this.flowing&&this[Fs]!==0&&this[zb](!0),this.flowing?this.emit("data",e):this[vU](e),this[Fs]!==0&&this.emit("readable"),o&&a(o),this.flowing):e.length?(typeof e=="string"&&!(r===this[ka]&&!this[_f].lastNeed)&&(e=Buffer.from(e,r)),Buffer.isBuffer(e)&&this[ka]&&(e=this[_f].write(e)),this.flowing&&this[Fs]!==0&&this[zb](!0),this.flowing?this.emit("data",e):this[vU](e),this[Fs]!==0&&this.emit("readable"),o&&a(o),this.flowing):(this[Fs]!==0&&this.emit("readable"),o&&a(o),this.flowing)}read(e){if(this[Ro])return null;if(this[Fs]===0||e===0||e>this[Fs])return this[Uf](),null;this[Fo]&&(e=null),this.buffer.length>1&&!this[Fo]&&(this.encoding?this.buffer=[this.buffer.join("")]:this.buffer=[Buffer.concat(this.buffer,this[Fs])]);let r=this[cue](e||null,this.buffer[0]);return this[Uf](),r}[cue](e,r){return e===r.length||e===null?this[DU]():(this.buffer[0]=r.slice(e),r=r.slice(0,e),this[Fs]-=e),this.emit("data",r),!this.buffer.length&&!this[Mf]&&this.emit("drain"),r}end(e,r,o){return typeof e=="function"&&(o=e,e=null),typeof r=="function"&&(o=r,r="utf8"),e&&this.write(e,r),o&&this.once("end",o),this[Mf]=!0,this.writable=!1,(this.flowing||!this[_1])&&this[Uf](),this}[ME](){this[Ro]||(this[_1]=!1,this[Xb]=!0,this.emit("resume"),this.buffer.length?this[zb]():this[Mf]?this[Uf]():this.emit("drain"))}resume(){return this[ME]()}pause(){this[Xb]=!1,this[_1]=!0}get destroyed(){return this[Ro]}get flowing(){return this[Xb]}get paused(){return this[_1]}[vU](e){this[Fo]?this[Fs]+=1:this[Fs]+=e.length,this.buffer.push(e)}[DU](){return this.buffer.length&&(this[Fo]?this[Fs]-=1:this[Fs]-=this.buffer[0].length),this.buffer.shift()}[zb](e){do;while(this[uue](this[DU]()));!e&&!this.buffer.length&&!this[Mf]&&this.emit("drain")}[uue](e){return e?(this.emit("data",e),this.flowing):!1}pipe(e,r){if(this[Ro])return;let o=this[hh];return r=r||{},e===oue.stdout||e===oue.stderr?r.end=!1:r.end=r.end!==!1,r.proxyErrors=!!r.proxyErrors,o?r.end&&e.end():(this.pipes.push(r.proxyErrors?new bU(this,e,r):new Zb(this,e,r)),this[Hf]?H1(()=>this[ME]()):this[ME]()),e}unpipe(e){let r=this.pipes.find(o=>o.dest===e);r&&(this.pipes.splice(this.pipes.indexOf(r),1),r.unpipe())}addListener(e,r){return this.on(e,r)}on(e,r){let o=super.on(e,r);return e==="data"&&!this.pipes.length&&!this.flowing?this[ME]():e==="readable"&&this[Fs]!==0?super.emit("readable"):kat(e)&&this[hh]?(super.emit(e),this.removeAllListeners(e)):e==="error"&&this[U1]&&(this[Hf]?H1(()=>r.call(this,this[U1])):r.call(this,this[U1])),o}get emittedEnd(){return this[hh]}[Uf](){!this[Vb]&&!this[hh]&&!this[Ro]&&this.buffer.length===0&&this[Mf]&&(this[Vb]=!0,this.emit("end"),this.emit("prefinish"),this.emit("finish"),this[Jb]&&this.emit("close"),this[Vb]=!1)}emit(e,r,...o){if(e!=="error"&&e!=="close"&&e!==Ro&&this[Ro])return;if(e==="data")return r?this[Hf]?H1(()=>this[SU](r)):this[SU](r):!1;if(e==="end")return this[Aue]();if(e==="close"){if(this[Jb]=!0,!this[hh]&&!this[Ro])return;let n=super.emit("close");return this.removeAllListeners("close"),n}else if(e==="error"){this[U1]=r;let n=super.emit("error",r);return this[Uf](),n}else if(e==="resume"){let n=super.emit("resume");return this[Uf](),n}else if(e==="finish"||e==="prefinish"){let n=super.emit(e);return this.removeAllListeners(e),n}let a=super.emit(e,r,...o);return this[Uf](),a}[SU](e){for(let o of this.pipes)o.dest.write(e)===!1&&this.pause();let r=super.emit("data",e);return this[Uf](),r}[Aue](){this[hh]||(this[hh]=!0,this.readable=!1,this[Hf]?H1(()=>this[PU]()):this[PU]())}[PU](){if(this[_f]){let r=this[_f].end();if(r){for(let o of this.pipes)o.dest.write(r);super.emit("data",r)}}for(let r of this.pipes)r.end();let e=super.emit("end");return this.removeAllListeners("end"),e}collect(){let e=[];this[Fo]||(e.dataLength=0);let r=this.promise();return this.on("data",o=>{e.push(o),this[Fo]||(e.dataLength+=o.length)}),r.then(()=>e)}concat(){return this[Fo]?Promise.reject(new Error("cannot concat in objectMode")):this.collect().then(e=>this[Fo]?Promise.reject(new Error("cannot concat in objectMode")):this[ka]?e.join(""):Buffer.concat(e,e.dataLength))}promise(){return new Promise((e,r)=>{this.on(Ro,()=>r(new Error("stream destroyed"))),this.on("error",o=>r(o)),this.on("end",()=>e())})}[bat](){return{next:()=>{let r=this.read();if(r!==null)return Promise.resolve({done:!1,value:r});if(this[Mf])return Promise.resolve({done:!0});let o=null,a=null,n=h=>{this.removeListener("data",u),this.removeListener("end",A),a(h)},u=h=>{this.removeListener("error",n),this.removeListener("end",A),this.pause(),o({value:h,done:!!this[Mf]})},A=()=>{this.removeListener("error",n),this.removeListener("data",u),o({done:!0})},p=()=>n(new Error("stream destroyed"));return new Promise((h,E)=>{a=E,o=h,this.once(Ro,p),this.once("error",n),this.once("end",A),this.once("data",u)})}}}[xat](){return{next:()=>{let r=this.read();return{value:r,done:r===null}}}}destroy(e){return this[Ro]?(e?this.emit("error",e):this.emit(Ro),this):(this[Ro]=!0,this.buffer.length=0,this[Fs]=0,typeof this.close=="function"&&!this[Jb]&&this.close(),e?this.emit("error",e):this.emit(Ro),this)}static isStream(e){return!!e&&(e instanceof pue||e instanceof aue||e instanceof Pat&&(typeof e.pipe=="function"||typeof e.write=="function"&&typeof e.end=="function"))}}});var due=_((M4t,gue)=>{var Rat=ve("zlib").constants||{ZLIB_VERNUM:4736};gue.exports=Object.freeze(Object.assign(Object.create(null),{Z_NO_FLUSH:0,Z_PARTIAL_FLUSH:1,Z_SYNC_FLUSH:2,Z_FULL_FLUSH:3,Z_FINISH:4,Z_BLOCK:5,Z_OK:0,Z_STREAM_END:1,Z_NEED_DICT:2,Z_ERRNO:-1,Z_STREAM_ERROR:-2,Z_DATA_ERROR:-3,Z_MEM_ERROR:-4,Z_BUF_ERROR:-5,Z_VERSION_ERROR:-6,Z_NO_COMPRESSION:0,Z_BEST_SPEED:1,Z_BEST_COMPRESSION:9,Z_DEFAULT_COMPRESSION:-1,Z_FILTERED:1,Z_HUFFMAN_ONLY:2,Z_RLE:3,Z_FIXED:4,Z_DEFAULT_STRATEGY:0,DEFLATE:1,INFLATE:2,GZIP:3,GUNZIP:4,DEFLATERAW:5,INFLATERAW:6,UNZIP:7,BROTLI_DECODE:8,BROTLI_ENCODE:9,Z_MIN_WINDOWBITS:8,Z_MAX_WINDOWBITS:15,Z_DEFAULT_WINDOWBITS:15,Z_MIN_CHUNK:64,Z_MAX_CHUNK:1/0,Z_DEFAULT_CHUNK:16384,Z_MIN_MEMLEVEL:1,Z_MAX_MEMLEVEL:9,Z_DEFAULT_MEMLEVEL:8,Z_MIN_LEVEL:-1,Z_MAX_LEVEL:9,Z_DEFAULT_LEVEL:-1,BROTLI_OPERATION_PROCESS:0,BROTLI_OPERATION_FLUSH:1,BROTLI_OPERATION_FINISH:2,BROTLI_OPERATION_EMIT_METADATA:3,BROTLI_MODE_GENERIC:0,BROTLI_MODE_TEXT:1,BROTLI_MODE_FONT:2,BROTLI_DEFAULT_MODE:0,BROTLI_MIN_QUALITY:0,BROTLI_MAX_QUALITY:11,BROTLI_DEFAULT_QUALITY:11,BROTLI_MIN_WINDOW_BITS:10,BROTLI_MAX_WINDOW_BITS:24,BROTLI_LARGE_MAX_WINDOW_BITS:30,BROTLI_DEFAULT_WINDOW:22,BROTLI_MIN_INPUT_BLOCK_BITS:16,BROTLI_MAX_INPUT_BLOCK_BITS:24,BROTLI_PARAM_MODE:0,BROTLI_PARAM_QUALITY:1,BROTLI_PARAM_LGWIN:2,BROTLI_PARAM_LGBLOCK:3,BROTLI_PARAM_DISABLE_LITERAL_CONTEXT_MODELING:4,BROTLI_PARAM_SIZE_HINT:5,BROTLI_PARAM_LARGE_WINDOW:6,BROTLI_PARAM_NPOSTFIX:7,BROTLI_PARAM_NDIRECT:8,BROTLI_DECODER_RESULT_ERROR:0,BROTLI_DECODER_RESULT_SUCCESS:1,BROTLI_DECODER_RESULT_NEEDS_MORE_INPUT:2,BROTLI_DECODER_RESULT_NEEDS_MORE_OUTPUT:3,BROTLI_DECODER_PARAM_DISABLE_RING_BUFFER_REALLOCATION:0,BROTLI_DECODER_PARAM_LARGE_WINDOW:1,BROTLI_DECODER_NO_ERROR:0,BROTLI_DECODER_SUCCESS:1,BROTLI_DECODER_NEEDS_MORE_INPUT:2,BROTLI_DECODER_NEEDS_MORE_OUTPUT:3,BROTLI_DECODER_ERROR_FORMAT_EXUBERANT_NIBBLE:-1,BROTLI_DECODER_ERROR_FORMAT_RESERVED:-2,BROTLI_DECODER_ERROR_FORMAT_EXUBERANT_META_NIBBLE:-3,BROTLI_DECODER_ERROR_FORMAT_SIMPLE_HUFFMAN_ALPHABET:-4,BROTLI_DECODER_ERROR_FORMAT_SIMPLE_HUFFMAN_SAME:-5,BROTLI_DECODER_ERROR_FORMAT_CL_SPACE:-6,BROTLI_DECODER_ERROR_FORMAT_HUFFMAN_SPACE:-7,BROTLI_DECODER_ERROR_FORMAT_CONTEXT_MAP_REPEAT:-8,BROTLI_DECODER_ERROR_FORMAT_BLOCK_LENGTH_1:-9,BROTLI_DECODER_ERROR_FORMAT_BLOCK_LENGTH_2:-10,BROTLI_DECODER_ERROR_FORMAT_TRANSFORM:-11,BROTLI_DECODER_ERROR_FORMAT_DICTIONARY:-12,BROTLI_DECODER_ERROR_FORMAT_WINDOW_BITS:-13,BROTLI_DECODER_ERROR_FORMAT_PADDING_1:-14,BROTLI_DECODER_ERROR_FORMAT_PADDING_2:-15,BROTLI_DECODER_ERROR_FORMAT_DISTANCE:-16,BROTLI_DECODER_ERROR_DICTIONARY_NOT_SET:-19,BROTLI_DECODER_ERROR_INVALID_ARGUMENTS:-20,BROTLI_DECODER_ERROR_ALLOC_CONTEXT_MODES:-21,BROTLI_DECODER_ERROR_ALLOC_TREE_GROUPS:-22,BROTLI_DECODER_ERROR_ALLOC_CONTEXT_MAP:-25,BROTLI_DECODER_ERROR_ALLOC_RING_BUFFER_1:-26,BROTLI_DECODER_ERROR_ALLOC_RING_BUFFER_2:-27,BROTLI_DECODER_ERROR_ALLOC_BLOCK_TYPE_TREES:-30,BROTLI_DECODER_ERROR_UNREACHABLE:-31},Rat))});var qU=_(cl=>{"use strict";var RU=ve("assert"),gh=ve("buffer").Buffer,Eue=ve("zlib"),Fd=cl.constants=due(),Tat=UE(),mue=gh.concat,Rd=Symbol("_superWrite"),HE=class extends Error{constructor(e){super("zlib: "+e.message),this.code=e.code,this.errno=e.errno,this.code||(this.code="ZLIB_ERROR"),this.message="zlib: "+e.message,Error.captureStackTrace(this,this.constructor)}get name(){return"ZlibError"}},Nat=Symbol("opts"),j1=Symbol("flushFlag"),yue=Symbol("finishFlushFlag"),GU=Symbol("fullFlushFlag"),ti=Symbol("handle"),$b=Symbol("onError"),_E=Symbol("sawError"),xU=Symbol("level"),kU=Symbol("strategy"),QU=Symbol("ended"),U4t=Symbol("_defaultFullFlush"),ex=class extends Tat{constructor(e,r){if(!e||typeof e!="object")throw new TypeError("invalid options for ZlibBase constructor");super(e),this[_E]=!1,this[QU]=!1,this[Nat]=e,this[j1]=e.flush,this[yue]=e.finishFlush;try{this[ti]=new Eue[r](e)}catch(o){throw new HE(o)}this[$b]=o=>{this[_E]||(this[_E]=!0,this.close(),this.emit("error",o))},this[ti].on("error",o=>this[$b](new HE(o))),this.once("end",()=>this.close)}close(){this[ti]&&(this[ti].close(),this[ti]=null,this.emit("close"))}reset(){if(!this[_E])return RU(this[ti],"zlib binding closed"),this[ti].reset()}flush(e){this.ended||(typeof e!="number"&&(e=this[GU]),this.write(Object.assign(gh.alloc(0),{[j1]:e})))}end(e,r,o){return e&&this.write(e,r),this.flush(this[yue]),this[QU]=!0,super.end(null,null,o)}get ended(){return this[QU]}write(e,r,o){if(typeof r=="function"&&(o=r,r="utf8"),typeof e=="string"&&(e=gh.from(e,r)),this[_E])return;RU(this[ti],"zlib binding closed");let a=this[ti]._handle,n=a.close;a.close=()=>{};let u=this[ti].close;this[ti].close=()=>{},gh.concat=h=>h;let A;try{let h=typeof e[j1]=="number"?e[j1]:this[j1];A=this[ti]._processChunk(e,h),gh.concat=mue}catch(h){gh.concat=mue,this[$b](new HE(h))}finally{this[ti]&&(this[ti]._handle=a,a.close=n,this[ti].close=u,this[ti].removeAllListeners("error"))}this[ti]&&this[ti].on("error",h=>this[$b](new HE(h)));let p;if(A)if(Array.isArray(A)&&A.length>0){p=this[Rd](gh.from(A[0]));for(let h=1;h<A.length;h++)p=this[Rd](A[h])}else p=this[Rd](gh.from(A));return o&&o(),p}[Rd](e){return super.write(e)}},jf=class extends ex{constructor(e,r){e=e||{},e.flush=e.flush||Fd.Z_NO_FLUSH,e.finishFlush=e.finishFlush||Fd.Z_FINISH,super(e,r),this[GU]=Fd.Z_FULL_FLUSH,this[xU]=e.level,this[kU]=e.strategy}params(e,r){if(!this[_E]){if(!this[ti])throw new Error("cannot switch params when binding is closed");if(!this[ti].params)throw new Error("not supported in this implementation");if(this[xU]!==e||this[kU]!==r){this.flush(Fd.Z_SYNC_FLUSH),RU(this[ti],"zlib binding closed");let o=this[ti].flush;this[ti].flush=(a,n)=>{this.flush(a),n()};try{this[ti].params(e,r)}finally{this[ti].flush=o}this[ti]&&(this[xU]=e,this[kU]=r)}}}},TU=class extends jf{constructor(e){super(e,"Deflate")}},NU=class extends jf{constructor(e){super(e,"Inflate")}},FU=Symbol("_portable"),LU=class extends jf{constructor(e){super(e,"Gzip"),this[FU]=e&&!!e.portable}[Rd](e){return this[FU]?(this[FU]=!1,e[9]=255,super[Rd](e)):super[Rd](e)}},OU=class extends jf{constructor(e){super(e,"Gunzip")}},MU=class extends jf{constructor(e){super(e,"DeflateRaw")}},UU=class extends jf{constructor(e){super(e,"InflateRaw")}},_U=class extends jf{constructor(e){super(e,"Unzip")}},tx=class extends ex{constructor(e,r){e=e||{},e.flush=e.flush||Fd.BROTLI_OPERATION_PROCESS,e.finishFlush=e.finishFlush||Fd.BROTLI_OPERATION_FINISH,super(e,r),this[GU]=Fd.BROTLI_OPERATION_FLUSH}},HU=class extends tx{constructor(e){super(e,"BrotliCompress")}},jU=class extends tx{constructor(e){super(e,"BrotliDecompress")}};cl.Deflate=TU;cl.Inflate=NU;cl.Gzip=LU;cl.Gunzip=OU;cl.DeflateRaw=MU;cl.InflateRaw=UU;cl.Unzip=_U;typeof Eue.BrotliCompress=="function"?(cl.BrotliCompress=HU,cl.BrotliDecompress=jU):cl.BrotliCompress=cl.BrotliDecompress=class{constructor(){throw new Error("Brotli is not supported in this version of Node.js")}}});var jE=_((j4t,Cue)=>{var Lat=process.env.TESTING_TAR_FAKE_PLATFORM||process.platform;Cue.exports=Lat!=="win32"?t=>t:t=>t&&t.replace(/\\/g,"/")});var rx=_((q4t,wue)=>{"use strict";var Oat=UE(),YU=jE(),WU=Symbol("slurp");wue.exports=class extends Oat{constructor(e,r,o){switch(super(),this.pause(),this.extended=r,this.globalExtended=o,this.header=e,this.startBlockSize=512*Math.ceil(e.size/512),this.blockRemain=this.startBlockSize,this.remain=e.size,this.type=e.type,this.meta=!1,this.ignore=!1,this.type){case"File":case"OldFile":case"Link":case"SymbolicLink":case"CharacterDevice":case"BlockDevice":case"Directory":case"FIFO":case"ContiguousFile":case"GNUDumpDir":break;case"NextFileHasLongLinkpath":case"NextFileHasLongPath":case"OldGnuLongPath":case"GlobalExtendedHeader":case"ExtendedHeader":case"OldExtendedHeader":this.meta=!0;break;default:this.ignore=!0}this.path=YU(e.path),this.mode=e.mode,this.mode&&(this.mode=this.mode&4095),this.uid=e.uid,this.gid=e.gid,this.uname=e.uname,this.gname=e.gname,this.size=e.size,this.mtime=e.mtime,this.atime=e.atime,this.ctime=e.ctime,this.linkpath=YU(e.linkpath),this.uname=e.uname,this.gname=e.gname,r&&this[WU](r),o&&this[WU](o,!0)}write(e){let r=e.length;if(r>this.blockRemain)throw new Error("writing more to entry than is appropriate");let o=this.remain,a=this.blockRemain;return this.remain=Math.max(0,o-r),this.blockRemain=Math.max(0,a-r),this.ignore?!0:o>=r?super.write(e):super.write(e.slice(0,o))}[WU](e,r){for(let o in e)e[o]!==null&&e[o]!==void 0&&!(r&&o==="path")&&(this[o]=o==="path"||o==="linkpath"?YU(e[o]):e[o])}}});var KU=_(nx=>{"use strict";nx.name=new Map([["0","File"],["","OldFile"],["1","Link"],["2","SymbolicLink"],["3","CharacterDevice"],["4","BlockDevice"],["5","Directory"],["6","FIFO"],["7","ContiguousFile"],["g","GlobalExtendedHeader"],["x","ExtendedHeader"],["A","SolarisACL"],["D","GNUDumpDir"],["I","Inode"],["K","NextFileHasLongLinkpath"],["L","NextFileHasLongPath"],["M","ContinuationFile"],["N","OldGnuLongPath"],["S","SparseFile"],["V","TapeVolumeHeader"],["X","OldExtendedHeader"]]);nx.code=new Map(Array.from(nx.name).map(t=>[t[1],t[0]]))});var Due=_((W4t,vue)=>{"use strict";var Mat=(t,e)=>{if(Number.isSafeInteger(t))t<0?_at(t,e):Uat(t,e);else throw Error("cannot encode number outside of javascript safe integer range");return e},Uat=(t,e)=>{e[0]=128;for(var r=e.length;r>1;r--)e[r-1]=t&255,t=Math.floor(t/256)},_at=(t,e)=>{e[0]=255;var r=!1;t=t*-1;for(var o=e.length;o>1;o--){var a=t&255;t=Math.floor(t/256),r?e[o-1]=Iue(a):a===0?e[o-1]=0:(r=!0,e[o-1]=Bue(a))}},Hat=t=>{let e=t[0],r=e===128?Gat(t.slice(1,t.length)):e===255?jat(t):null;if(r===null)throw Error("invalid base256 encoding");if(!Number.isSafeInteger(r))throw Error("parsed number outside of javascript safe integer range");return r},jat=t=>{for(var e=t.length,r=0,o=!1,a=e-1;a>-1;a--){var n=t[a],u;o?u=Iue(n):n===0?u=n:(o=!0,u=Bue(n)),u!==0&&(r-=u*Math.pow(256,e-a-1))}return r},Gat=t=>{for(var e=t.length,r=0,o=e-1;o>-1;o--){var a=t[o];a!==0&&(r+=a*Math.pow(256,e-o-1))}return r},Iue=t=>(255^t)&255,Bue=t=>(255^t)+1&255;vue.exports={encode:Mat,parse:Hat}});var qE=_((K4t,Pue)=>{"use strict";var VU=KU(),GE=ve("path").posix,Sue=Due(),JU=Symbol("slurp"),ul=Symbol("type"),ZU=class{constructor(e,r,o,a){this.cksumValid=!1,this.needPax=!1,this.nullBlock=!1,this.block=null,this.path=null,this.mode=null,this.uid=null,this.gid=null,this.size=null,this.mtime=null,this.cksum=null,this[ul]="0",this.linkpath=null,this.uname=null,this.gname=null,this.devmaj=0,this.devmin=0,this.atime=null,this.ctime=null,Buffer.isBuffer(e)?this.decode(e,r||0,o,a):e&&this.set(e)}decode(e,r,o,a){if(r||(r=0),!e||!(e.length>=r+512))throw new Error("need 512 bytes for header");if(this.path=Td(e,r,100),this.mode=dh(e,r+100,8),this.uid=dh(e,r+108,8),this.gid=dh(e,r+116,8),this.size=dh(e,r+124,12),this.mtime=zU(e,r+136,12),this.cksum=dh(e,r+148,12),this[JU](o),this[JU](a,!0),this[ul]=Td(e,r+156,1),this[ul]===""&&(this[ul]="0"),this[ul]==="0"&&this.path.substr(-1)==="/"&&(this[ul]="5"),this[ul]==="5"&&(this.size=0),this.linkpath=Td(e,r+157,100),e.slice(r+257,r+265).toString()==="ustar\x0000")if(this.uname=Td(e,r+265,32),this.gname=Td(e,r+297,32),this.devmaj=dh(e,r+329,8),this.devmin=dh(e,r+337,8),e[r+475]!==0){let u=Td(e,r+345,155);this.path=u+"/"+this.path}else{let u=Td(e,r+345,130);u&&(this.path=u+"/"+this.path),this.atime=zU(e,r+476,12),this.ctime=zU(e,r+488,12)}let n=8*32;for(let u=r;u<r+148;u++)n+=e[u];for(let u=r+156;u<r+512;u++)n+=e[u];this.cksumValid=n===this.cksum,this.cksum===null&&n===8*32&&(this.nullBlock=!0)}[JU](e,r){for(let o in e)e[o]!==null&&e[o]!==void 0&&!(r&&o==="path")&&(this[o]=e[o])}encode(e,r){if(e||(e=this.block=Buffer.alloc(512),r=0),r||(r=0),!(e.length>=r+512))throw new Error("need 512 bytes for header");let o=this.ctime||this.atime?130:155,a=qat(this.path||"",o),n=a[0],u=a[1];this.needPax=a[2],this.needPax=Nd(e,r,100,n)||this.needPax,this.needPax=mh(e,r+100,8,this.mode)||this.needPax,this.needPax=mh(e,r+108,8,this.uid)||this.needPax,this.needPax=mh(e,r+116,8,this.gid)||this.needPax,this.needPax=mh(e,r+124,12,this.size)||this.needPax,this.needPax=XU(e,r+136,12,this.mtime)||this.needPax,e[r+156]=this[ul].charCodeAt(0),this.needPax=Nd(e,r+157,100,this.linkpath)||this.needPax,e.write("ustar\x0000",r+257,8),this.needPax=Nd(e,r+265,32,this.uname)||this.needPax,this.needPax=Nd(e,r+297,32,this.gname)||this.needPax,this.needPax=mh(e,r+329,8,this.devmaj)||this.needPax,this.needPax=mh(e,r+337,8,this.devmin)||this.needPax,this.needPax=Nd(e,r+345,o,u)||this.needPax,e[r+475]!==0?this.needPax=Nd(e,r+345,155,u)||this.needPax:(this.needPax=Nd(e,r+345,130,u)||this.needPax,this.needPax=XU(e,r+476,12,this.atime)||this.needPax,this.needPax=XU(e,r+488,12,this.ctime)||this.needPax);let A=8*32;for(let p=r;p<r+148;p++)A+=e[p];for(let p=r+156;p<r+512;p++)A+=e[p];return this.cksum=A,mh(e,r+148,8,this.cksum),this.cksumValid=!0,this.needPax}set(e){for(let r in e)e[r]!==null&&e[r]!==void 0&&(this[r]=e[r])}get type(){return VU.name.get(this[ul])||this[ul]}get typeKey(){return this[ul]}set type(e){VU.code.has(e)?this[ul]=VU.code.get(e):this[ul]=e}},qat=(t,e)=>{let o=t,a="",n,u=GE.parse(t).root||".";if(Buffer.byteLength(o)<100)n=[o,a,!1];else{a=GE.dirname(o),o=GE.basename(o);do Buffer.byteLength(o)<=100&&Buffer.byteLength(a)<=e?n=[o,a,!1]:Buffer.byteLength(o)>100&&Buffer.byteLength(a)<=e?n=[o.substr(0,100-1),a,!0]:(o=GE.join(GE.basename(a),o),a=GE.dirname(a));while(a!==u&&!n);n||(n=[t.substr(0,100-1),"",!0])}return n},Td=(t,e,r)=>t.slice(e,e+r).toString("utf8").replace(/\0.*/,""),zU=(t,e,r)=>Yat(dh(t,e,r)),Yat=t=>t===null?null:new Date(t*1e3),dh=(t,e,r)=>t[e]&128?Sue.parse(t.slice(e,e+r)):Kat(t,e,r),Wat=t=>isNaN(t)?null:t,Kat=(t,e,r)=>Wat(parseInt(t.slice(e,e+r).toString("utf8").replace(/\0.*$/,"").trim(),8)),Vat={12:8589934591,8:2097151},mh=(t,e,r,o)=>o===null?!1:o>Vat[r]||o<0?(Sue.encode(o,t.slice(e,e+r)),!0):(Jat(t,e,r,o),!1),Jat=(t,e,r,o)=>t.write(zat(o,r),e,r,"ascii"),zat=(t,e)=>Xat(Math.floor(t).toString(8),e),Xat=(t,e)=>(t.length===e-1?t:new Array(e-t.length-1).join("0")+t+" ")+"\0",XU=(t,e,r,o)=>o===null?!1:mh(t,e,r,o.getTime()/1e3),Zat=new Array(156).join("\0"),Nd=(t,e,r,o)=>o===null?!1:(t.write(o+Zat,e,r,"utf8"),o.length!==Buffer.byteLength(o)||o.length>r);Pue.exports=ZU});var ix=_((V4t,bue)=>{"use strict";var $at=qE(),elt=ve("path"),G1=class{constructor(e,r){this.atime=e.atime||null,this.charset=e.charset||null,this.comment=e.comment||null,this.ctime=e.ctime||null,this.gid=e.gid||null,this.gname=e.gname||null,this.linkpath=e.linkpath||null,this.mtime=e.mtime||null,this.path=e.path||null,this.size=e.size||null,this.uid=e.uid||null,this.uname=e.uname||null,this.dev=e.dev||null,this.ino=e.ino||null,this.nlink=e.nlink||null,this.global=r||!1}encode(){let e=this.encodeBody();if(e==="")return null;let r=Buffer.byteLength(e),o=512*Math.ceil(1+r/512),a=Buffer.allocUnsafe(o);for(let n=0;n<512;n++)a[n]=0;new $at({path:("PaxHeader/"+elt.basename(this.path)).slice(0,99),mode:this.mode||420,uid:this.uid||null,gid:this.gid||null,size:r,mtime:this.mtime||null,type:this.global?"GlobalExtendedHeader":"ExtendedHeader",linkpath:"",uname:this.uname||"",gname:this.gname||"",devmaj:0,devmin:0,atime:this.atime||null,ctime:this.ctime||null}).encode(a),a.write(e,512,r,"utf8");for(let n=r+512;n<a.length;n++)a[n]=0;return a}encodeBody(){return this.encodeField("path")+this.encodeField("ctime")+this.encodeField("atime")+this.encodeField("dev")+this.encodeField("ino")+this.encodeField("nlink")+this.encodeField("charset")+this.encodeField("comment")+this.encodeField("gid")+this.encodeField("gname")+this.encodeField("linkpath")+this.encodeField("mtime")+this.encodeField("size")+this.encodeField("uid")+this.encodeField("uname")}encodeField(e){if(this[e]===null||this[e]===void 0)return"";let r=this[e]instanceof Date?this[e].getTime()/1e3:this[e],o=" "+(e==="dev"||e==="ino"||e==="nlink"?"SCHILY.":"")+e+"="+r+` +`,a=Buffer.byteLength(o),n=Math.floor(Math.log(a)/Math.log(10))+1;return a+n>=Math.pow(10,n)&&(n+=1),n+a+o}};G1.parse=(t,e,r)=>new G1(tlt(rlt(t),e),r);var tlt=(t,e)=>e?Object.keys(t).reduce((r,o)=>(r[o]=t[o],r),e):t,rlt=t=>t.replace(/\n$/,"").split(` +`).reduce(nlt,Object.create(null)),nlt=(t,e)=>{let r=parseInt(e,10);if(r!==Buffer.byteLength(e)+1)return t;e=e.substr((r+" ").length);let o=e.split("="),a=o.shift().replace(/^SCHILY\.(dev|ino|nlink)/,"$1");if(!a)return t;let n=o.join("=");return t[a]=/^([A-Z]+\.)?([mac]|birth|creation)time$/.test(a)?new Date(n*1e3):/^[0-9]+$/.test(n)?+n:n,t};bue.exports=G1});var YE=_((J4t,xue)=>{xue.exports=t=>{let e=t.length-1,r=-1;for(;e>-1&&t.charAt(e)==="/";)r=e,e--;return r===-1?t:t.slice(0,r)}});var sx=_((z4t,kue)=>{"use strict";kue.exports=t=>class extends t{warn(e,r,o={}){this.file&&(o.file=this.file),this.cwd&&(o.cwd=this.cwd),o.code=r instanceof Error&&r.code||e,o.tarCode=e,!this.strict&&o.recoverable!==!1?(r instanceof Error&&(o=Object.assign(r,o),r=r.message),this.emit("warn",o.tarCode,r,o)):r instanceof Error?this.emit("error",Object.assign(r,o)):this.emit("error",Object.assign(new Error(`${e}: ${r}`),o))}}});var e3=_((Z4t,Que)=>{"use strict";var ox=["|","<",">","?",":"],$U=ox.map(t=>String.fromCharCode(61440+t.charCodeAt(0))),ilt=new Map(ox.map((t,e)=>[t,$U[e]])),slt=new Map($U.map((t,e)=>[t,ox[e]]));Que.exports={encode:t=>ox.reduce((e,r)=>e.split(r).join(ilt.get(r)),t),decode:t=>$U.reduce((e,r)=>e.split(r).join(slt.get(r)),t)}});var t3=_(($4t,Rue)=>{var{isAbsolute:olt,parse:Fue}=ve("path").win32;Rue.exports=t=>{let e="",r=Fue(t);for(;olt(t)||r.root;){let o=t.charAt(0)==="/"&&t.slice(0,4)!=="//?/"?"/":r.root;t=t.substr(o.length),e+=o,r=Fue(t)}return[e,t]}});var Nue=_((eUt,Tue)=>{"use strict";Tue.exports=(t,e,r)=>(t&=4095,r&&(t=(t|384)&-19),e&&(t&256&&(t|=64),t&32&&(t|=8),t&4&&(t|=1)),t)});var A3=_((nUt,Jue)=>{"use strict";var jue=UE(),Gue=ix(),que=qE(),oA=ve("fs"),Lue=ve("path"),sA=jE(),alt=YE(),Yue=(t,e)=>e?(t=sA(t).replace(/^\.(\/|$)/,""),alt(e)+"/"+t):sA(t),llt=16*1024*1024,Oue=Symbol("process"),Mue=Symbol("file"),Uue=Symbol("directory"),n3=Symbol("symlink"),_ue=Symbol("hardlink"),q1=Symbol("header"),ax=Symbol("read"),i3=Symbol("lstat"),lx=Symbol("onlstat"),s3=Symbol("onread"),o3=Symbol("onreadlink"),a3=Symbol("openfile"),l3=Symbol("onopenfile"),yh=Symbol("close"),cx=Symbol("mode"),c3=Symbol("awaitDrain"),r3=Symbol("ondrain"),aA=Symbol("prefix"),Hue=Symbol("hadError"),Wue=sx(),clt=e3(),Kue=t3(),Vue=Nue(),ux=Wue(class extends jue{constructor(e,r){if(r=r||{},super(r),typeof e!="string")throw new TypeError("path is required");this.path=sA(e),this.portable=!!r.portable,this.myuid=process.getuid&&process.getuid()||0,this.myuser=process.env.USER||"",this.maxReadSize=r.maxReadSize||llt,this.linkCache=r.linkCache||new Map,this.statCache=r.statCache||new Map,this.preservePaths=!!r.preservePaths,this.cwd=sA(r.cwd||process.cwd()),this.strict=!!r.strict,this.noPax=!!r.noPax,this.noMtime=!!r.noMtime,this.mtime=r.mtime||null,this.prefix=r.prefix?sA(r.prefix):null,this.fd=null,this.blockLen=null,this.blockRemain=null,this.buf=null,this.offset=null,this.length=null,this.pos=null,this.remain=null,typeof r.onwarn=="function"&&this.on("warn",r.onwarn);let o=!1;if(!this.preservePaths){let[a,n]=Kue(this.path);a&&(this.path=n,o=a)}this.win32=!!r.win32||process.platform==="win32",this.win32&&(this.path=clt.decode(this.path.replace(/\\/g,"/")),e=e.replace(/\\/g,"/")),this.absolute=sA(r.absolute||Lue.resolve(this.cwd,e)),this.path===""&&(this.path="./"),o&&this.warn("TAR_ENTRY_INFO",`stripping ${o} from absolute path`,{entry:this,path:o+this.path}),this.statCache.has(this.absolute)?this[lx](this.statCache.get(this.absolute)):this[i3]()}emit(e,...r){return e==="error"&&(this[Hue]=!0),super.emit(e,...r)}[i3](){oA.lstat(this.absolute,(e,r)=>{if(e)return this.emit("error",e);this[lx](r)})}[lx](e){this.statCache.set(this.absolute,e),this.stat=e,e.isFile()||(e.size=0),this.type=Alt(e),this.emit("stat",e),this[Oue]()}[Oue](){switch(this.type){case"File":return this[Mue]();case"Directory":return this[Uue]();case"SymbolicLink":return this[n3]();default:return this.end()}}[cx](e){return Vue(e,this.type==="Directory",this.portable)}[aA](e){return Yue(e,this.prefix)}[q1](){this.type==="Directory"&&this.portable&&(this.noMtime=!0),this.header=new que({path:this[aA](this.path),linkpath:this.type==="Link"?this[aA](this.linkpath):this.linkpath,mode:this[cx](this.stat.mode),uid:this.portable?null:this.stat.uid,gid:this.portable?null:this.stat.gid,size:this.stat.size,mtime:this.noMtime?null:this.mtime||this.stat.mtime,type:this.type,uname:this.portable?null:this.stat.uid===this.myuid?this.myuser:"",atime:this.portable?null:this.stat.atime,ctime:this.portable?null:this.stat.ctime}),this.header.encode()&&!this.noPax&&super.write(new Gue({atime:this.portable?null:this.header.atime,ctime:this.portable?null:this.header.ctime,gid:this.portable?null:this.header.gid,mtime:this.noMtime?null:this.mtime||this.header.mtime,path:this[aA](this.path),linkpath:this.type==="Link"?this[aA](this.linkpath):this.linkpath,size:this.header.size,uid:this.portable?null:this.header.uid,uname:this.portable?null:this.header.uname,dev:this.portable?null:this.stat.dev,ino:this.portable?null:this.stat.ino,nlink:this.portable?null:this.stat.nlink}).encode()),super.write(this.header.block)}[Uue](){this.path.substr(-1)!=="/"&&(this.path+="/"),this.stat.size=0,this[q1](),this.end()}[n3](){oA.readlink(this.absolute,(e,r)=>{if(e)return this.emit("error",e);this[o3](r)})}[o3](e){this.linkpath=sA(e),this[q1](),this.end()}[_ue](e){this.type="Link",this.linkpath=sA(Lue.relative(this.cwd,e)),this.stat.size=0,this[q1](),this.end()}[Mue](){if(this.stat.nlink>1){let e=this.stat.dev+":"+this.stat.ino;if(this.linkCache.has(e)){let r=this.linkCache.get(e);if(r.indexOf(this.cwd)===0)return this[_ue](r)}this.linkCache.set(e,this.absolute)}if(this[q1](),this.stat.size===0)return this.end();this[a3]()}[a3](){oA.open(this.absolute,"r",(e,r)=>{if(e)return this.emit("error",e);this[l3](r)})}[l3](e){if(this.fd=e,this[Hue])return this[yh]();this.blockLen=512*Math.ceil(this.stat.size/512),this.blockRemain=this.blockLen;let r=Math.min(this.blockLen,this.maxReadSize);this.buf=Buffer.allocUnsafe(r),this.offset=0,this.pos=0,this.remain=this.stat.size,this.length=this.buf.length,this[ax]()}[ax](){let{fd:e,buf:r,offset:o,length:a,pos:n}=this;oA.read(e,r,o,a,n,(u,A)=>{if(u)return this[yh](()=>this.emit("error",u));this[s3](A)})}[yh](e){oA.close(this.fd,e)}[s3](e){if(e<=0&&this.remain>0){let a=new Error("encountered unexpected EOF");return a.path=this.absolute,a.syscall="read",a.code="EOF",this[yh](()=>this.emit("error",a))}if(e>this.remain){let a=new Error("did not encounter expected EOF");return a.path=this.absolute,a.syscall="read",a.code="EOF",this[yh](()=>this.emit("error",a))}if(e===this.remain)for(let a=e;a<this.length&&e<this.blockRemain;a++)this.buf[a+this.offset]=0,e++,this.remain++;let r=this.offset===0&&e===this.buf.length?this.buf:this.buf.slice(this.offset,this.offset+e);this.write(r)?this[r3]():this[c3](()=>this[r3]())}[c3](e){this.once("drain",e)}write(e){if(this.blockRemain<e.length){let r=new Error("writing more data than expected");return r.path=this.absolute,this.emit("error",r)}return this.remain-=e.length,this.blockRemain-=e.length,this.pos+=e.length,this.offset+=e.length,super.write(e)}[r3](){if(!this.remain)return this.blockRemain&&super.write(Buffer.alloc(this.blockRemain)),this[yh](e=>e?this.emit("error",e):this.end());this.offset>=this.length&&(this.buf=Buffer.allocUnsafe(Math.min(this.blockRemain,this.buf.length)),this.offset=0),this.length=this.buf.length-this.offset,this[ax]()}}),u3=class extends ux{[i3](){this[lx](oA.lstatSync(this.absolute))}[n3](){this[o3](oA.readlinkSync(this.absolute))}[a3](){this[l3](oA.openSync(this.absolute,"r"))}[ax](){let e=!0;try{let{fd:r,buf:o,offset:a,length:n,pos:u}=this,A=oA.readSync(r,o,a,n,u);this[s3](A),e=!1}finally{if(e)try{this[yh](()=>{})}catch{}}}[c3](e){e()}[yh](e){oA.closeSync(this.fd),e()}},ult=Wue(class extends jue{constructor(e,r){r=r||{},super(r),this.preservePaths=!!r.preservePaths,this.portable=!!r.portable,this.strict=!!r.strict,this.noPax=!!r.noPax,this.noMtime=!!r.noMtime,this.readEntry=e,this.type=e.type,this.type==="Directory"&&this.portable&&(this.noMtime=!0),this.prefix=r.prefix||null,this.path=sA(e.path),this.mode=this[cx](e.mode),this.uid=this.portable?null:e.uid,this.gid=this.portable?null:e.gid,this.uname=this.portable?null:e.uname,this.gname=this.portable?null:e.gname,this.size=e.size,this.mtime=this.noMtime?null:r.mtime||e.mtime,this.atime=this.portable?null:e.atime,this.ctime=this.portable?null:e.ctime,this.linkpath=sA(e.linkpath),typeof r.onwarn=="function"&&this.on("warn",r.onwarn);let o=!1;if(!this.preservePaths){let[a,n]=Kue(this.path);a&&(this.path=n,o=a)}this.remain=e.size,this.blockRemain=e.startBlockSize,this.header=new que({path:this[aA](this.path),linkpath:this.type==="Link"?this[aA](this.linkpath):this.linkpath,mode:this.mode,uid:this.portable?null:this.uid,gid:this.portable?null:this.gid,size:this.size,mtime:this.noMtime?null:this.mtime,type:this.type,uname:this.portable?null:this.uname,atime:this.portable?null:this.atime,ctime:this.portable?null:this.ctime}),o&&this.warn("TAR_ENTRY_INFO",`stripping ${o} from absolute path`,{entry:this,path:o+this.path}),this.header.encode()&&!this.noPax&&super.write(new Gue({atime:this.portable?null:this.atime,ctime:this.portable?null:this.ctime,gid:this.portable?null:this.gid,mtime:this.noMtime?null:this.mtime,path:this[aA](this.path),linkpath:this.type==="Link"?this[aA](this.linkpath):this.linkpath,size:this.size,uid:this.portable?null:this.uid,uname:this.portable?null:this.uname,dev:this.portable?null:this.readEntry.dev,ino:this.portable?null:this.readEntry.ino,nlink:this.portable?null:this.readEntry.nlink}).encode()),super.write(this.header.block),e.pipe(this)}[aA](e){return Yue(e,this.prefix)}[cx](e){return Vue(e,this.type==="Directory",this.portable)}write(e){let r=e.length;if(r>this.blockRemain)throw new Error("writing more to entry than is appropriate");return this.blockRemain-=r,super.write(e)}end(){return this.blockRemain&&super.write(Buffer.alloc(this.blockRemain)),super.end()}});ux.Sync=u3;ux.Tar=ult;var Alt=t=>t.isFile()?"File":t.isDirectory()?"Directory":t.isSymbolicLink()?"SymbolicLink":"Unsupported";Jue.exports=ux});var Ex=_((sUt,rAe)=>{"use strict";var mx=class{constructor(e,r){this.path=e||"./",this.absolute=r,this.entry=null,this.stat=null,this.readdir=null,this.pending=!1,this.ignore=!1,this.piped=!1}},flt=UE(),plt=qU(),hlt=rx(),C3=A3(),glt=C3.Sync,dlt=C3.Tar,mlt=IS(),zue=Buffer.alloc(1024),px=Symbol("onStat"),Ax=Symbol("ended"),lA=Symbol("queue"),WE=Symbol("current"),Ld=Symbol("process"),fx=Symbol("processing"),Xue=Symbol("processJob"),cA=Symbol("jobs"),f3=Symbol("jobDone"),hx=Symbol("addFSEntry"),Zue=Symbol("addTarEntry"),d3=Symbol("stat"),m3=Symbol("readdir"),gx=Symbol("onreaddir"),dx=Symbol("pipe"),$ue=Symbol("entry"),p3=Symbol("entryOpt"),y3=Symbol("writeEntryClass"),tAe=Symbol("write"),h3=Symbol("ondrain"),yx=ve("fs"),eAe=ve("path"),ylt=sx(),g3=jE(),w3=ylt(class extends flt{constructor(e){super(e),e=e||Object.create(null),this.opt=e,this.file=e.file||"",this.cwd=e.cwd||process.cwd(),this.maxReadSize=e.maxReadSize,this.preservePaths=!!e.preservePaths,this.strict=!!e.strict,this.noPax=!!e.noPax,this.prefix=g3(e.prefix||""),this.linkCache=e.linkCache||new Map,this.statCache=e.statCache||new Map,this.readdirCache=e.readdirCache||new Map,this[y3]=C3,typeof e.onwarn=="function"&&this.on("warn",e.onwarn),this.portable=!!e.portable,this.zip=null,e.gzip?(typeof e.gzip!="object"&&(e.gzip={}),this.portable&&(e.gzip.portable=!0),this.zip=new plt.Gzip(e.gzip),this.zip.on("data",r=>super.write(r)),this.zip.on("end",r=>super.end()),this.zip.on("drain",r=>this[h3]()),this.on("resume",r=>this.zip.resume())):this.on("drain",this[h3]),this.noDirRecurse=!!e.noDirRecurse,this.follow=!!e.follow,this.noMtime=!!e.noMtime,this.mtime=e.mtime||null,this.filter=typeof e.filter=="function"?e.filter:r=>!0,this[lA]=new mlt,this[cA]=0,this.jobs=+e.jobs||4,this[fx]=!1,this[Ax]=!1}[tAe](e){return super.write(e)}add(e){return this.write(e),this}end(e){return e&&this.write(e),this[Ax]=!0,this[Ld](),this}write(e){if(this[Ax])throw new Error("write after end");return e instanceof hlt?this[Zue](e):this[hx](e),this.flowing}[Zue](e){let r=g3(eAe.resolve(this.cwd,e.path));if(!this.filter(e.path,e))e.resume();else{let o=new mx(e.path,r,!1);o.entry=new dlt(e,this[p3](o)),o.entry.on("end",a=>this[f3](o)),this[cA]+=1,this[lA].push(o)}this[Ld]()}[hx](e){let r=g3(eAe.resolve(this.cwd,e));this[lA].push(new mx(e,r)),this[Ld]()}[d3](e){e.pending=!0,this[cA]+=1;let r=this.follow?"stat":"lstat";yx[r](e.absolute,(o,a)=>{e.pending=!1,this[cA]-=1,o?this.emit("error",o):this[px](e,a)})}[px](e,r){this.statCache.set(e.absolute,r),e.stat=r,this.filter(e.path,r)||(e.ignore=!0),this[Ld]()}[m3](e){e.pending=!0,this[cA]+=1,yx.readdir(e.absolute,(r,o)=>{if(e.pending=!1,this[cA]-=1,r)return this.emit("error",r);this[gx](e,o)})}[gx](e,r){this.readdirCache.set(e.absolute,r),e.readdir=r,this[Ld]()}[Ld](){if(!this[fx]){this[fx]=!0;for(let e=this[lA].head;e!==null&&this[cA]<this.jobs;e=e.next)if(this[Xue](e.value),e.value.ignore){let r=e.next;this[lA].removeNode(e),e.next=r}this[fx]=!1,this[Ax]&&!this[lA].length&&this[cA]===0&&(this.zip?this.zip.end(zue):(super.write(zue),super.end()))}}get[WE](){return this[lA]&&this[lA].head&&this[lA].head.value}[f3](e){this[lA].shift(),this[cA]-=1,this[Ld]()}[Xue](e){if(!e.pending){if(e.entry){e===this[WE]&&!e.piped&&this[dx](e);return}if(e.stat||(this.statCache.has(e.absolute)?this[px](e,this.statCache.get(e.absolute)):this[d3](e)),!!e.stat&&!e.ignore&&!(!this.noDirRecurse&&e.stat.isDirectory()&&!e.readdir&&(this.readdirCache.has(e.absolute)?this[gx](e,this.readdirCache.get(e.absolute)):this[m3](e),!e.readdir))){if(e.entry=this[$ue](e),!e.entry){e.ignore=!0;return}e===this[WE]&&!e.piped&&this[dx](e)}}}[p3](e){return{onwarn:(r,o,a)=>this.warn(r,o,a),noPax:this.noPax,cwd:this.cwd,absolute:e.absolute,preservePaths:this.preservePaths,maxReadSize:this.maxReadSize,strict:this.strict,portable:this.portable,linkCache:this.linkCache,statCache:this.statCache,noMtime:this.noMtime,mtime:this.mtime,prefix:this.prefix}}[$ue](e){this[cA]+=1;try{return new this[y3](e.path,this[p3](e)).on("end",()=>this[f3](e)).on("error",r=>this.emit("error",r))}catch(r){this.emit("error",r)}}[h3](){this[WE]&&this[WE].entry&&this[WE].entry.resume()}[dx](e){e.piped=!0,e.readdir&&e.readdir.forEach(a=>{let n=e.path,u=n==="./"?"":n.replace(/\/*$/,"/");this[hx](u+a)});let r=e.entry,o=this.zip;o?r.on("data",a=>{o.write(a)||r.pause()}):r.on("data",a=>{super.write(a)||r.pause()})}pause(){return this.zip&&this.zip.pause(),super.pause()}}),E3=class extends w3{constructor(e){super(e),this[y3]=glt}pause(){}resume(){}[d3](e){let r=this.follow?"statSync":"lstatSync";this[px](e,yx[r](e.absolute))}[m3](e,r){this[gx](e,yx.readdirSync(e.absolute))}[dx](e){let r=e.entry,o=this.zip;e.readdir&&e.readdir.forEach(a=>{let n=e.path,u=n==="./"?"":n.replace(/\/*$/,"/");this[hx](u+a)}),o?r.on("data",a=>{o.write(a)}):r.on("data",a=>{super[tAe](a)})}};w3.Sync=E3;rAe.exports=w3});var eC=_(W1=>{"use strict";var Elt=UE(),Clt=ve("events").EventEmitter,Qa=ve("fs"),v3=Qa.writev;if(!v3){let t=process.binding("fs"),e=t.FSReqWrap||t.FSReqCallback;v3=(r,o,a,n)=>{let u=(p,h)=>n(p,h,o),A=new e;A.oncomplete=u,t.writeBuffers(r,o,a,A)}}var ZE=Symbol("_autoClose"),Yc=Symbol("_close"),Y1=Symbol("_ended"),qn=Symbol("_fd"),nAe=Symbol("_finished"),Ch=Symbol("_flags"),I3=Symbol("_flush"),D3=Symbol("_handleChunk"),S3=Symbol("_makeBuf"),vx=Symbol("_mode"),Cx=Symbol("_needDrain"),zE=Symbol("_onerror"),$E=Symbol("_onopen"),B3=Symbol("_onread"),VE=Symbol("_onwrite"),wh=Symbol("_open"),Gf=Symbol("_path"),Od=Symbol("_pos"),uA=Symbol("_queue"),JE=Symbol("_read"),iAe=Symbol("_readSize"),Eh=Symbol("_reading"),wx=Symbol("_remain"),sAe=Symbol("_size"),Ix=Symbol("_write"),KE=Symbol("_writing"),Bx=Symbol("_defaultFlag"),XE=Symbol("_errored"),Dx=class extends Elt{constructor(e,r){if(r=r||{},super(r),this.readable=!0,this.writable=!1,typeof e!="string")throw new TypeError("path must be a string");this[XE]=!1,this[qn]=typeof r.fd=="number"?r.fd:null,this[Gf]=e,this[iAe]=r.readSize||16*1024*1024,this[Eh]=!1,this[sAe]=typeof r.size=="number"?r.size:1/0,this[wx]=this[sAe],this[ZE]=typeof r.autoClose=="boolean"?r.autoClose:!0,typeof this[qn]=="number"?this[JE]():this[wh]()}get fd(){return this[qn]}get path(){return this[Gf]}write(){throw new TypeError("this is a readable stream")}end(){throw new TypeError("this is a readable stream")}[wh](){Qa.open(this[Gf],"r",(e,r)=>this[$E](e,r))}[$E](e,r){e?this[zE](e):(this[qn]=r,this.emit("open",r),this[JE]())}[S3](){return Buffer.allocUnsafe(Math.min(this[iAe],this[wx]))}[JE](){if(!this[Eh]){this[Eh]=!0;let e=this[S3]();if(e.length===0)return process.nextTick(()=>this[B3](null,0,e));Qa.read(this[qn],e,0,e.length,null,(r,o,a)=>this[B3](r,o,a))}}[B3](e,r,o){this[Eh]=!1,e?this[zE](e):this[D3](r,o)&&this[JE]()}[Yc](){if(this[ZE]&&typeof this[qn]=="number"){let e=this[qn];this[qn]=null,Qa.close(e,r=>r?this.emit("error",r):this.emit("close"))}}[zE](e){this[Eh]=!0,this[Yc](),this.emit("error",e)}[D3](e,r){let o=!1;return this[wx]-=e,e>0&&(o=super.write(e<r.length?r.slice(0,e):r)),(e===0||this[wx]<=0)&&(o=!1,this[Yc](),super.end()),o}emit(e,r){switch(e){case"prefinish":case"finish":break;case"drain":typeof this[qn]=="number"&&this[JE]();break;case"error":return this[XE]?void 0:(this[XE]=!0,super.emit(e,r));default:return super.emit(e,r)}}},P3=class extends Dx{[wh](){let e=!0;try{this[$E](null,Qa.openSync(this[Gf],"r")),e=!1}finally{e&&this[Yc]()}}[JE](){let e=!0;try{if(!this[Eh]){this[Eh]=!0;do{let r=this[S3](),o=r.length===0?0:Qa.readSync(this[qn],r,0,r.length,null);if(!this[D3](o,r))break}while(!0);this[Eh]=!1}e=!1}finally{e&&this[Yc]()}}[Yc](){if(this[ZE]&&typeof this[qn]=="number"){let e=this[qn];this[qn]=null,Qa.closeSync(e),this.emit("close")}}},Sx=class extends Clt{constructor(e,r){r=r||{},super(r),this.readable=!1,this.writable=!0,this[XE]=!1,this[KE]=!1,this[Y1]=!1,this[Cx]=!1,this[uA]=[],this[Gf]=e,this[qn]=typeof r.fd=="number"?r.fd:null,this[vx]=r.mode===void 0?438:r.mode,this[Od]=typeof r.start=="number"?r.start:null,this[ZE]=typeof r.autoClose=="boolean"?r.autoClose:!0;let o=this[Od]!==null?"r+":"w";this[Bx]=r.flags===void 0,this[Ch]=this[Bx]?o:r.flags,this[qn]===null&&this[wh]()}emit(e,r){if(e==="error"){if(this[XE])return;this[XE]=!0}return super.emit(e,r)}get fd(){return this[qn]}get path(){return this[Gf]}[zE](e){this[Yc](),this[KE]=!0,this.emit("error",e)}[wh](){Qa.open(this[Gf],this[Ch],this[vx],(e,r)=>this[$E](e,r))}[$E](e,r){this[Bx]&&this[Ch]==="r+"&&e&&e.code==="ENOENT"?(this[Ch]="w",this[wh]()):e?this[zE](e):(this[qn]=r,this.emit("open",r),this[I3]())}end(e,r){return e&&this.write(e,r),this[Y1]=!0,!this[KE]&&!this[uA].length&&typeof this[qn]=="number"&&this[VE](null,0),this}write(e,r){return typeof e=="string"&&(e=Buffer.from(e,r)),this[Y1]?(this.emit("error",new Error("write() after end()")),!1):this[qn]===null||this[KE]||this[uA].length?(this[uA].push(e),this[Cx]=!0,!1):(this[KE]=!0,this[Ix](e),!0)}[Ix](e){Qa.write(this[qn],e,0,e.length,this[Od],(r,o)=>this[VE](r,o))}[VE](e,r){e?this[zE](e):(this[Od]!==null&&(this[Od]+=r),this[uA].length?this[I3]():(this[KE]=!1,this[Y1]&&!this[nAe]?(this[nAe]=!0,this[Yc](),this.emit("finish")):this[Cx]&&(this[Cx]=!1,this.emit("drain"))))}[I3](){if(this[uA].length===0)this[Y1]&&this[VE](null,0);else if(this[uA].length===1)this[Ix](this[uA].pop());else{let e=this[uA];this[uA]=[],v3(this[qn],e,this[Od],(r,o)=>this[VE](r,o))}}[Yc](){if(this[ZE]&&typeof this[qn]=="number"){let e=this[qn];this[qn]=null,Qa.close(e,r=>r?this.emit("error",r):this.emit("close"))}}},b3=class extends Sx{[wh](){let e;if(this[Bx]&&this[Ch]==="r+")try{e=Qa.openSync(this[Gf],this[Ch],this[vx])}catch(r){if(r.code==="ENOENT")return this[Ch]="w",this[wh]();throw r}else e=Qa.openSync(this[Gf],this[Ch],this[vx]);this[$E](null,e)}[Yc](){if(this[ZE]&&typeof this[qn]=="number"){let e=this[qn];this[qn]=null,Qa.closeSync(e),this.emit("close")}}[Ix](e){let r=!0;try{this[VE](null,Qa.writeSync(this[qn],e,0,e.length,this[Od])),r=!1}finally{if(r)try{this[Yc]()}catch{}}}};W1.ReadStream=Dx;W1.ReadStreamSync=P3;W1.WriteStream=Sx;W1.WriteStreamSync=b3});var Rx=_((lUt,fAe)=>{"use strict";var wlt=sx(),Ilt=qE(),Blt=ve("events"),vlt=IS(),Dlt=1024*1024,Slt=rx(),oAe=ix(),Plt=qU(),x3=Buffer.from([31,139]),Xl=Symbol("state"),Md=Symbol("writeEntry"),qf=Symbol("readEntry"),k3=Symbol("nextEntry"),aAe=Symbol("processEntry"),Zl=Symbol("extendedHeader"),K1=Symbol("globalExtendedHeader"),Ih=Symbol("meta"),lAe=Symbol("emitMeta"),fi=Symbol("buffer"),Yf=Symbol("queue"),Ud=Symbol("ended"),cAe=Symbol("emittedEnd"),_d=Symbol("emit"),Fa=Symbol("unzip"),Px=Symbol("consumeChunk"),bx=Symbol("consumeChunkSub"),Q3=Symbol("consumeBody"),uAe=Symbol("consumeMeta"),AAe=Symbol("consumeHeader"),xx=Symbol("consuming"),F3=Symbol("bufferConcat"),R3=Symbol("maybeEnd"),V1=Symbol("writing"),Bh=Symbol("aborted"),kx=Symbol("onDone"),Hd=Symbol("sawValidEntry"),Qx=Symbol("sawNullBlock"),Fx=Symbol("sawEOF"),blt=t=>!0;fAe.exports=wlt(class extends Blt{constructor(e){e=e||{},super(e),this.file=e.file||"",this[Hd]=null,this.on(kx,r=>{(this[Xl]==="begin"||this[Hd]===!1)&&this.warn("TAR_BAD_ARCHIVE","Unrecognized archive format")}),e.ondone?this.on(kx,e.ondone):this.on(kx,r=>{this.emit("prefinish"),this.emit("finish"),this.emit("end"),this.emit("close")}),this.strict=!!e.strict,this.maxMetaEntrySize=e.maxMetaEntrySize||Dlt,this.filter=typeof e.filter=="function"?e.filter:blt,this.writable=!0,this.readable=!1,this[Yf]=new vlt,this[fi]=null,this[qf]=null,this[Md]=null,this[Xl]="begin",this[Ih]="",this[Zl]=null,this[K1]=null,this[Ud]=!1,this[Fa]=null,this[Bh]=!1,this[Qx]=!1,this[Fx]=!1,typeof e.onwarn=="function"&&this.on("warn",e.onwarn),typeof e.onentry=="function"&&this.on("entry",e.onentry)}[AAe](e,r){this[Hd]===null&&(this[Hd]=!1);let o;try{o=new Ilt(e,r,this[Zl],this[K1])}catch(a){return this.warn("TAR_ENTRY_INVALID",a)}if(o.nullBlock)this[Qx]?(this[Fx]=!0,this[Xl]==="begin"&&(this[Xl]="header"),this[_d]("eof")):(this[Qx]=!0,this[_d]("nullBlock"));else if(this[Qx]=!1,!o.cksumValid)this.warn("TAR_ENTRY_INVALID","checksum failure",{header:o});else if(!o.path)this.warn("TAR_ENTRY_INVALID","path is required",{header:o});else{let a=o.type;if(/^(Symbolic)?Link$/.test(a)&&!o.linkpath)this.warn("TAR_ENTRY_INVALID","linkpath required",{header:o});else if(!/^(Symbolic)?Link$/.test(a)&&o.linkpath)this.warn("TAR_ENTRY_INVALID","linkpath forbidden",{header:o});else{let n=this[Md]=new Slt(o,this[Zl],this[K1]);if(!this[Hd])if(n.remain){let u=()=>{n.invalid||(this[Hd]=!0)};n.on("end",u)}else this[Hd]=!0;n.meta?n.size>this.maxMetaEntrySize?(n.ignore=!0,this[_d]("ignoredEntry",n),this[Xl]="ignore",n.resume()):n.size>0&&(this[Ih]="",n.on("data",u=>this[Ih]+=u),this[Xl]="meta"):(this[Zl]=null,n.ignore=n.ignore||!this.filter(n.path,n),n.ignore?(this[_d]("ignoredEntry",n),this[Xl]=n.remain?"ignore":"header",n.resume()):(n.remain?this[Xl]="body":(this[Xl]="header",n.end()),this[qf]?this[Yf].push(n):(this[Yf].push(n),this[k3]())))}}}[aAe](e){let r=!0;return e?Array.isArray(e)?this.emit.apply(this,e):(this[qf]=e,this.emit("entry",e),e.emittedEnd||(e.on("end",o=>this[k3]()),r=!1)):(this[qf]=null,r=!1),r}[k3](){do;while(this[aAe](this[Yf].shift()));if(!this[Yf].length){let e=this[qf];!e||e.flowing||e.size===e.remain?this[V1]||this.emit("drain"):e.once("drain",o=>this.emit("drain"))}}[Q3](e,r){let o=this[Md],a=o.blockRemain,n=a>=e.length&&r===0?e:e.slice(r,r+a);return o.write(n),o.blockRemain||(this[Xl]="header",this[Md]=null,o.end()),n.length}[uAe](e,r){let o=this[Md],a=this[Q3](e,r);return this[Md]||this[lAe](o),a}[_d](e,r,o){!this[Yf].length&&!this[qf]?this.emit(e,r,o):this[Yf].push([e,r,o])}[lAe](e){switch(this[_d]("meta",this[Ih]),e.type){case"ExtendedHeader":case"OldExtendedHeader":this[Zl]=oAe.parse(this[Ih],this[Zl],!1);break;case"GlobalExtendedHeader":this[K1]=oAe.parse(this[Ih],this[K1],!0);break;case"NextFileHasLongPath":case"OldGnuLongPath":this[Zl]=this[Zl]||Object.create(null),this[Zl].path=this[Ih].replace(/\0.*/,"");break;case"NextFileHasLongLinkpath":this[Zl]=this[Zl]||Object.create(null),this[Zl].linkpath=this[Ih].replace(/\0.*/,"");break;default:throw new Error("unknown meta: "+e.type)}}abort(e){this[Bh]=!0,this.emit("abort",e),this.warn("TAR_ABORT",e,{recoverable:!1})}write(e){if(this[Bh])return;if(this[Fa]===null&&e){if(this[fi]&&(e=Buffer.concat([this[fi],e]),this[fi]=null),e.length<x3.length)return this[fi]=e,!0;for(let o=0;this[Fa]===null&&o<x3.length;o++)e[o]!==x3[o]&&(this[Fa]=!1);if(this[Fa]===null){let o=this[Ud];this[Ud]=!1,this[Fa]=new Plt.Unzip,this[Fa].on("data",n=>this[Px](n)),this[Fa].on("error",n=>this.abort(n)),this[Fa].on("end",n=>{this[Ud]=!0,this[Px]()}),this[V1]=!0;let a=this[Fa][o?"end":"write"](e);return this[V1]=!1,a}}this[V1]=!0,this[Fa]?this[Fa].write(e):this[Px](e),this[V1]=!1;let r=this[Yf].length?!1:this[qf]?this[qf].flowing:!0;return!r&&!this[Yf].length&&this[qf].once("drain",o=>this.emit("drain")),r}[F3](e){e&&!this[Bh]&&(this[fi]=this[fi]?Buffer.concat([this[fi],e]):e)}[R3](){if(this[Ud]&&!this[cAe]&&!this[Bh]&&!this[xx]){this[cAe]=!0;let e=this[Md];if(e&&e.blockRemain){let r=this[fi]?this[fi].length:0;this.warn("TAR_BAD_ARCHIVE",`Truncated input (needed ${e.blockRemain} more bytes, only ${r} available)`,{entry:e}),this[fi]&&e.write(this[fi]),e.end()}this[_d](kx)}}[Px](e){if(this[xx])this[F3](e);else if(!e&&!this[fi])this[R3]();else{if(this[xx]=!0,this[fi]){this[F3](e);let r=this[fi];this[fi]=null,this[bx](r)}else this[bx](e);for(;this[fi]&&this[fi].length>=512&&!this[Bh]&&!this[Fx];){let r=this[fi];this[fi]=null,this[bx](r)}this[xx]=!1}(!this[fi]||this[Ud])&&this[R3]()}[bx](e){let r=0,o=e.length;for(;r+512<=o&&!this[Bh]&&!this[Fx];)switch(this[Xl]){case"begin":case"header":this[AAe](e,r),r+=512;break;case"ignore":case"body":r+=this[Q3](e,r);break;case"meta":r+=this[uAe](e,r);break;default:throw new Error("invalid state: "+this[Xl])}r<o&&(this[fi]?this[fi]=Buffer.concat([e.slice(r),this[fi]]):this[fi]=e.slice(r))}end(e){this[Bh]||(this[Fa]?this[Fa].end(e):(this[Ud]=!0,this.write(e)))}})});var Tx=_((cUt,dAe)=>{"use strict";var xlt=OE(),hAe=Rx(),tC=ve("fs"),klt=eC(),pAe=ve("path"),T3=YE();dAe.exports=(t,e,r)=>{typeof t=="function"?(r=t,e=null,t={}):Array.isArray(t)&&(e=t,t={}),typeof e=="function"&&(r=e,e=null),e?e=Array.from(e):e=[];let o=xlt(t);if(o.sync&&typeof r=="function")throw new TypeError("callback not supported for sync tar functions");if(!o.file&&typeof r=="function")throw new TypeError("callback only supported with file option");return e.length&&Flt(o,e),o.noResume||Qlt(o),o.file&&o.sync?Rlt(o):o.file?Tlt(o,r):gAe(o)};var Qlt=t=>{let e=t.onentry;t.onentry=e?r=>{e(r),r.resume()}:r=>r.resume()},Flt=(t,e)=>{let r=new Map(e.map(n=>[T3(n),!0])),o=t.filter,a=(n,u)=>{let A=u||pAe.parse(n).root||".",p=n===A?!1:r.has(n)?r.get(n):a(pAe.dirname(n),A);return r.set(n,p),p};t.filter=o?(n,u)=>o(n,u)&&a(T3(n)):n=>a(T3(n))},Rlt=t=>{let e=gAe(t),r=t.file,o=!0,a;try{let n=tC.statSync(r),u=t.maxReadSize||16*1024*1024;if(n.size<u)e.end(tC.readFileSync(r));else{let A=0,p=Buffer.allocUnsafe(u);for(a=tC.openSync(r,"r");A<n.size;){let h=tC.readSync(a,p,0,u,A);A+=h,e.write(p.slice(0,h))}e.end()}o=!1}finally{if(o&&a)try{tC.closeSync(a)}catch{}}},Tlt=(t,e)=>{let r=new hAe(t),o=t.maxReadSize||16*1024*1024,a=t.file,n=new Promise((u,A)=>{r.on("error",A),r.on("end",u),tC.stat(a,(p,h)=>{if(p)A(p);else{let E=new klt.ReadStream(a,{readSize:o,size:h.size});E.on("error",A),E.pipe(r)}})});return e?n.then(e,e):n},gAe=t=>new hAe(t)});var IAe=_((uUt,wAe)=>{"use strict";var Nlt=OE(),Nx=Ex(),mAe=eC(),yAe=Tx(),EAe=ve("path");wAe.exports=(t,e,r)=>{if(typeof e=="function"&&(r=e),Array.isArray(t)&&(e=t,t={}),!e||!Array.isArray(e)||!e.length)throw new TypeError("no files or directories specified");e=Array.from(e);let o=Nlt(t);if(o.sync&&typeof r=="function")throw new TypeError("callback not supported for sync tar functions");if(!o.file&&typeof r=="function")throw new TypeError("callback only supported with file option");return o.file&&o.sync?Llt(o,e):o.file?Olt(o,e,r):o.sync?Mlt(o,e):Ult(o,e)};var Llt=(t,e)=>{let r=new Nx.Sync(t),o=new mAe.WriteStreamSync(t.file,{mode:t.mode||438});r.pipe(o),CAe(r,e)},Olt=(t,e,r)=>{let o=new Nx(t),a=new mAe.WriteStream(t.file,{mode:t.mode||438});o.pipe(a);let n=new Promise((u,A)=>{a.on("error",A),a.on("close",u),o.on("error",A)});return N3(o,e),r?n.then(r,r):n},CAe=(t,e)=>{e.forEach(r=>{r.charAt(0)==="@"?yAe({file:EAe.resolve(t.cwd,r.substr(1)),sync:!0,noResume:!0,onentry:o=>t.add(o)}):t.add(r)}),t.end()},N3=(t,e)=>{for(;e.length;){let r=e.shift();if(r.charAt(0)==="@")return yAe({file:EAe.resolve(t.cwd,r.substr(1)),noResume:!0,onentry:o=>t.add(o)}).then(o=>N3(t,e));t.add(r)}t.end()},Mlt=(t,e)=>{let r=new Nx.Sync(t);return CAe(r,e),r},Ult=(t,e)=>{let r=new Nx(t);return N3(r,e),r}});var L3=_((AUt,xAe)=>{"use strict";var _lt=OE(),BAe=Ex(),Al=ve("fs"),vAe=eC(),DAe=Tx(),SAe=ve("path"),PAe=qE();xAe.exports=(t,e,r)=>{let o=_lt(t);if(!o.file)throw new TypeError("file is required");if(o.gzip)throw new TypeError("cannot append to compressed archives");if(!e||!Array.isArray(e)||!e.length)throw new TypeError("no files or directories specified");return e=Array.from(e),o.sync?Hlt(o,e):Glt(o,e,r)};var Hlt=(t,e)=>{let r=new BAe.Sync(t),o=!0,a,n;try{try{a=Al.openSync(t.file,"r+")}catch(p){if(p.code==="ENOENT")a=Al.openSync(t.file,"w+");else throw p}let u=Al.fstatSync(a),A=Buffer.alloc(512);e:for(n=0;n<u.size;n+=512){for(let E=0,I=0;E<512;E+=I){if(I=Al.readSync(a,A,E,A.length-E,n+E),n===0&&A[0]===31&&A[1]===139)throw new Error("cannot append to compressed archives");if(!I)break e}let p=new PAe(A);if(!p.cksumValid)break;let h=512*Math.ceil(p.size/512);if(n+h+512>u.size)break;n+=h,t.mtimeCache&&t.mtimeCache.set(p.path,p.mtime)}o=!1,jlt(t,r,n,a,e)}finally{if(o)try{Al.closeSync(a)}catch{}}},jlt=(t,e,r,o,a)=>{let n=new vAe.WriteStreamSync(t.file,{fd:o,start:r});e.pipe(n),qlt(e,a)},Glt=(t,e,r)=>{e=Array.from(e);let o=new BAe(t),a=(u,A,p)=>{let h=(C,R)=>{C?Al.close(u,L=>p(C)):p(null,R)},E=0;if(A===0)return h(null,0);let I=0,v=Buffer.alloc(512),x=(C,R)=>{if(C)return h(C);if(I+=R,I<512&&R)return Al.read(u,v,I,v.length-I,E+I,x);if(E===0&&v[0]===31&&v[1]===139)return h(new Error("cannot append to compressed archives"));if(I<512)return h(null,E);let L=new PAe(v);if(!L.cksumValid)return h(null,E);let U=512*Math.ceil(L.size/512);if(E+U+512>A||(E+=U+512,E>=A))return h(null,E);t.mtimeCache&&t.mtimeCache.set(L.path,L.mtime),I=0,Al.read(u,v,0,512,E,x)};Al.read(u,v,0,512,E,x)},n=new Promise((u,A)=>{o.on("error",A);let p="r+",h=(E,I)=>{if(E&&E.code==="ENOENT"&&p==="r+")return p="w+",Al.open(t.file,p,h);if(E)return A(E);Al.fstat(I,(v,x)=>{if(v)return Al.close(I,()=>A(v));a(I,x.size,(C,R)=>{if(C)return A(C);let L=new vAe.WriteStream(t.file,{fd:I,start:R});o.pipe(L),L.on("error",A),L.on("close",u),bAe(o,e)})})};Al.open(t.file,p,h)});return r?n.then(r,r):n},qlt=(t,e)=>{e.forEach(r=>{r.charAt(0)==="@"?DAe({file:SAe.resolve(t.cwd,r.substr(1)),sync:!0,noResume:!0,onentry:o=>t.add(o)}):t.add(r)}),t.end()},bAe=(t,e)=>{for(;e.length;){let r=e.shift();if(r.charAt(0)==="@")return DAe({file:SAe.resolve(t.cwd,r.substr(1)),noResume:!0,onentry:o=>t.add(o)}).then(o=>bAe(t,e));t.add(r)}t.end()}});var QAe=_((fUt,kAe)=>{"use strict";var Ylt=OE(),Wlt=L3();kAe.exports=(t,e,r)=>{let o=Ylt(t);if(!o.file)throw new TypeError("file is required");if(o.gzip)throw new TypeError("cannot append to compressed archives");if(!e||!Array.isArray(e)||!e.length)throw new TypeError("no files or directories specified");return e=Array.from(e),Klt(o),Wlt(o,e,r)};var Klt=t=>{let e=t.filter;t.mtimeCache||(t.mtimeCache=new Map),t.filter=e?(r,o)=>e(r,o)&&!(t.mtimeCache.get(r)>o.mtime):(r,o)=>!(t.mtimeCache.get(r)>o.mtime)}});var TAe=_((pUt,RAe)=>{var{promisify:FAe}=ve("util"),vh=ve("fs"),Vlt=t=>{if(!t)t={mode:511,fs:vh};else if(typeof t=="object")t={mode:511,fs:vh,...t};else if(typeof t=="number")t={mode:t,fs:vh};else if(typeof t=="string")t={mode:parseInt(t,8),fs:vh};else throw new TypeError("invalid options argument");return t.mkdir=t.mkdir||t.fs.mkdir||vh.mkdir,t.mkdirAsync=FAe(t.mkdir),t.stat=t.stat||t.fs.stat||vh.stat,t.statAsync=FAe(t.stat),t.statSync=t.statSync||t.fs.statSync||vh.statSync,t.mkdirSync=t.mkdirSync||t.fs.mkdirSync||vh.mkdirSync,t};RAe.exports=Vlt});var LAe=_((hUt,NAe)=>{var Jlt=process.platform,{resolve:zlt,parse:Xlt}=ve("path"),Zlt=t=>{if(/\0/.test(t))throw Object.assign(new TypeError("path must be a string without null bytes"),{path:t,code:"ERR_INVALID_ARG_VALUE"});if(t=zlt(t),Jlt==="win32"){let e=/[*|"<>?:]/,{root:r}=Xlt(t);if(e.test(t.substr(r.length)))throw Object.assign(new Error("Illegal characters in path."),{path:t,code:"EINVAL"})}return t};NAe.exports=Zlt});var HAe=_((gUt,_Ae)=>{var{dirname:OAe}=ve("path"),MAe=(t,e,r=void 0)=>r===e?Promise.resolve():t.statAsync(e).then(o=>o.isDirectory()?r:void 0,o=>o.code==="ENOENT"?MAe(t,OAe(e),e):void 0),UAe=(t,e,r=void 0)=>{if(r!==e)try{return t.statSync(e).isDirectory()?r:void 0}catch(o){return o.code==="ENOENT"?UAe(t,OAe(e),e):void 0}};_Ae.exports={findMade:MAe,findMadeSync:UAe}});var U3=_((dUt,GAe)=>{var{dirname:jAe}=ve("path"),O3=(t,e,r)=>{e.recursive=!1;let o=jAe(t);return o===t?e.mkdirAsync(t,e).catch(a=>{if(a.code!=="EISDIR")throw a}):e.mkdirAsync(t,e).then(()=>r||t,a=>{if(a.code==="ENOENT")return O3(o,e).then(n=>O3(t,e,n));if(a.code!=="EEXIST"&&a.code!=="EROFS")throw a;return e.statAsync(t).then(n=>{if(n.isDirectory())return r;throw a},()=>{throw a})})},M3=(t,e,r)=>{let o=jAe(t);if(e.recursive=!1,o===t)try{return e.mkdirSync(t,e)}catch(a){if(a.code!=="EISDIR")throw a;return}try{return e.mkdirSync(t,e),r||t}catch(a){if(a.code==="ENOENT")return M3(t,e,M3(o,e,r));if(a.code!=="EEXIST"&&a.code!=="EROFS")throw a;try{if(!e.statSync(t).isDirectory())throw a}catch{throw a}}};GAe.exports={mkdirpManual:O3,mkdirpManualSync:M3}});var WAe=_((mUt,YAe)=>{var{dirname:qAe}=ve("path"),{findMade:$lt,findMadeSync:ect}=HAe(),{mkdirpManual:tct,mkdirpManualSync:rct}=U3(),nct=(t,e)=>(e.recursive=!0,qAe(t)===t?e.mkdirAsync(t,e):$lt(e,t).then(o=>e.mkdirAsync(t,e).then(()=>o).catch(a=>{if(a.code==="ENOENT")return tct(t,e);throw a}))),ict=(t,e)=>{if(e.recursive=!0,qAe(t)===t)return e.mkdirSync(t,e);let o=ect(e,t);try{return e.mkdirSync(t,e),o}catch(a){if(a.code==="ENOENT")return rct(t,e);throw a}};YAe.exports={mkdirpNative:nct,mkdirpNativeSync:ict}});var zAe=_((yUt,JAe)=>{var KAe=ve("fs"),sct=process.version,_3=sct.replace(/^v/,"").split("."),VAe=+_3[0]>10||+_3[0]==10&&+_3[1]>=12,oct=VAe?t=>t.mkdir===KAe.mkdir:()=>!1,act=VAe?t=>t.mkdirSync===KAe.mkdirSync:()=>!1;JAe.exports={useNative:oct,useNativeSync:act}});var rfe=_((EUt,tfe)=>{var rC=TAe(),nC=LAe(),{mkdirpNative:XAe,mkdirpNativeSync:ZAe}=WAe(),{mkdirpManual:$Ae,mkdirpManualSync:efe}=U3(),{useNative:lct,useNativeSync:cct}=zAe(),iC=(t,e)=>(t=nC(t),e=rC(e),lct(e)?XAe(t,e):$Ae(t,e)),uct=(t,e)=>(t=nC(t),e=rC(e),cct(e)?ZAe(t,e):efe(t,e));iC.sync=uct;iC.native=(t,e)=>XAe(nC(t),rC(e));iC.manual=(t,e)=>$Ae(nC(t),rC(e));iC.nativeSync=(t,e)=>ZAe(nC(t),rC(e));iC.manualSync=(t,e)=>efe(nC(t),rC(e));tfe.exports=iC});var cfe=_((CUt,lfe)=>{"use strict";var $l=ve("fs"),jd=ve("path"),Act=$l.lchown?"lchown":"chown",fct=$l.lchownSync?"lchownSync":"chownSync",ife=$l.lchown&&!process.version.match(/v1[1-9]+\./)&&!process.version.match(/v10\.[6-9]/),nfe=(t,e,r)=>{try{return $l[fct](t,e,r)}catch(o){if(o.code!=="ENOENT")throw o}},pct=(t,e,r)=>{try{return $l.chownSync(t,e,r)}catch(o){if(o.code!=="ENOENT")throw o}},hct=ife?(t,e,r,o)=>a=>{!a||a.code!=="EISDIR"?o(a):$l.chown(t,e,r,o)}:(t,e,r,o)=>o,H3=ife?(t,e,r)=>{try{return nfe(t,e,r)}catch(o){if(o.code!=="EISDIR")throw o;pct(t,e,r)}}:(t,e,r)=>nfe(t,e,r),gct=process.version,sfe=(t,e,r)=>$l.readdir(t,e,r),dct=(t,e)=>$l.readdirSync(t,e);/^v4\./.test(gct)&&(sfe=(t,e,r)=>$l.readdir(t,r));var Lx=(t,e,r,o)=>{$l[Act](t,e,r,hct(t,e,r,a=>{o(a&&a.code!=="ENOENT"?a:null)}))},ofe=(t,e,r,o,a)=>{if(typeof e=="string")return $l.lstat(jd.resolve(t,e),(n,u)=>{if(n)return a(n.code!=="ENOENT"?n:null);u.name=e,ofe(t,u,r,o,a)});if(e.isDirectory())j3(jd.resolve(t,e.name),r,o,n=>{if(n)return a(n);let u=jd.resolve(t,e.name);Lx(u,r,o,a)});else{let n=jd.resolve(t,e.name);Lx(n,r,o,a)}},j3=(t,e,r,o)=>{sfe(t,{withFileTypes:!0},(a,n)=>{if(a){if(a.code==="ENOENT")return o();if(a.code!=="ENOTDIR"&&a.code!=="ENOTSUP")return o(a)}if(a||!n.length)return Lx(t,e,r,o);let u=n.length,A=null,p=h=>{if(!A){if(h)return o(A=h);if(--u===0)return Lx(t,e,r,o)}};n.forEach(h=>ofe(t,h,e,r,p))})},mct=(t,e,r,o)=>{if(typeof e=="string")try{let a=$l.lstatSync(jd.resolve(t,e));a.name=e,e=a}catch(a){if(a.code==="ENOENT")return;throw a}e.isDirectory()&&afe(jd.resolve(t,e.name),r,o),H3(jd.resolve(t,e.name),r,o)},afe=(t,e,r)=>{let o;try{o=dct(t,{withFileTypes:!0})}catch(a){if(a.code==="ENOENT")return;if(a.code==="ENOTDIR"||a.code==="ENOTSUP")return H3(t,e,r);throw a}return o&&o.length&&o.forEach(a=>mct(t,a,e,r)),H3(t,e,r)};lfe.exports=j3;j3.sync=afe});var pfe=_((wUt,G3)=>{"use strict";var ufe=rfe(),ec=ve("fs"),Ox=ve("path"),Afe=cfe(),Wc=jE(),Mx=class extends Error{constructor(e,r){super("Cannot extract through symbolic link"),this.path=r,this.symlink=e}get name(){return"SylinkError"}},Ux=class extends Error{constructor(e,r){super(r+": Cannot cd into '"+e+"'"),this.path=e,this.code=r}get name(){return"CwdError"}},_x=(t,e)=>t.get(Wc(e)),J1=(t,e,r)=>t.set(Wc(e),r),yct=(t,e)=>{ec.stat(t,(r,o)=>{(r||!o.isDirectory())&&(r=new Ux(t,r&&r.code||"ENOTDIR")),e(r)})};G3.exports=(t,e,r)=>{t=Wc(t);let o=e.umask,a=e.mode|448,n=(a&o)!==0,u=e.uid,A=e.gid,p=typeof u=="number"&&typeof A=="number"&&(u!==e.processUid||A!==e.processGid),h=e.preserve,E=e.unlink,I=e.cache,v=Wc(e.cwd),x=(L,U)=>{L?r(L):(J1(I,t,!0),U&&p?Afe(U,u,A,J=>x(J)):n?ec.chmod(t,a,r):r())};if(I&&_x(I,t)===!0)return x();if(t===v)return yct(t,x);if(h)return ufe(t,{mode:a}).then(L=>x(null,L),x);let R=Wc(Ox.relative(v,t)).split("/");Hx(v,R,a,I,E,v,null,x)};var Hx=(t,e,r,o,a,n,u,A)=>{if(!e.length)return A(null,u);let p=e.shift(),h=Wc(Ox.resolve(t+"/"+p));if(_x(o,h))return Hx(h,e,r,o,a,n,u,A);ec.mkdir(h,r,ffe(h,e,r,o,a,n,u,A))},ffe=(t,e,r,o,a,n,u,A)=>p=>{p?ec.lstat(t,(h,E)=>{if(h)h.path=h.path&&Wc(h.path),A(h);else if(E.isDirectory())Hx(t,e,r,o,a,n,u,A);else if(a)ec.unlink(t,I=>{if(I)return A(I);ec.mkdir(t,r,ffe(t,e,r,o,a,n,u,A))});else{if(E.isSymbolicLink())return A(new Mx(t,t+"/"+e.join("/")));A(p)}}):(u=u||t,Hx(t,e,r,o,a,n,u,A))},Ect=t=>{let e=!1,r="ENOTDIR";try{e=ec.statSync(t).isDirectory()}catch(o){r=o.code}finally{if(!e)throw new Ux(t,r)}};G3.exports.sync=(t,e)=>{t=Wc(t);let r=e.umask,o=e.mode|448,a=(o&r)!==0,n=e.uid,u=e.gid,A=typeof n=="number"&&typeof u=="number"&&(n!==e.processUid||u!==e.processGid),p=e.preserve,h=e.unlink,E=e.cache,I=Wc(e.cwd),v=L=>{J1(E,t,!0),L&&A&&Afe.sync(L,n,u),a&&ec.chmodSync(t,o)};if(E&&_x(E,t)===!0)return v();if(t===I)return Ect(I),v();if(p)return v(ufe.sync(t,o));let C=Wc(Ox.relative(I,t)).split("/"),R=null;for(let L=C.shift(),U=I;L&&(U+="/"+L);L=C.shift())if(U=Wc(Ox.resolve(U)),!_x(E,U))try{ec.mkdirSync(U,o),R=R||U,J1(E,U,!0)}catch{let te=ec.lstatSync(U);if(te.isDirectory()){J1(E,U,!0);continue}else if(h){ec.unlinkSync(U),ec.mkdirSync(U,o),R=R||U,J1(E,U,!0);continue}else if(te.isSymbolicLink())return new Mx(U,U+"/"+C.join("/"))}return v(R)}});var Y3=_((IUt,hfe)=>{var q3=Object.create(null),{hasOwnProperty:Cct}=Object.prototype;hfe.exports=t=>(Cct.call(q3,t)||(q3[t]=t.normalize("NFKD")),q3[t])});var yfe=_((BUt,mfe)=>{var gfe=ve("assert"),wct=Y3(),Ict=YE(),{join:dfe}=ve("path"),Bct=process.env.TESTING_TAR_FAKE_PLATFORM||process.platform,vct=Bct==="win32";mfe.exports=()=>{let t=new Map,e=new Map,r=h=>h.split("/").slice(0,-1).reduce((I,v)=>(I.length&&(v=dfe(I[I.length-1],v)),I.push(v||"/"),I),[]),o=new Set,a=h=>{let E=e.get(h);if(!E)throw new Error("function does not have any path reservations");return{paths:E.paths.map(I=>t.get(I)),dirs:[...E.dirs].map(I=>t.get(I))}},n=h=>{let{paths:E,dirs:I}=a(h);return E.every(v=>v[0]===h)&&I.every(v=>v[0]instanceof Set&&v[0].has(h))},u=h=>o.has(h)||!n(h)?!1:(o.add(h),h(()=>A(h)),!0),A=h=>{if(!o.has(h))return!1;let{paths:E,dirs:I}=e.get(h),v=new Set;return E.forEach(x=>{let C=t.get(x);gfe.equal(C[0],h),C.length===1?t.delete(x):(C.shift(),typeof C[0]=="function"?v.add(C[0]):C[0].forEach(R=>v.add(R)))}),I.forEach(x=>{let C=t.get(x);gfe(C[0]instanceof Set),C[0].size===1&&C.length===1?t.delete(x):C[0].size===1?(C.shift(),v.add(C[0])):C[0].delete(h)}),o.delete(h),v.forEach(x=>u(x)),!0};return{check:n,reserve:(h,E)=>{h=vct?["win32 parallelization disabled"]:h.map(v=>wct(Ict(dfe(v))).toLowerCase());let I=new Set(h.map(v=>r(v)).reduce((v,x)=>v.concat(x)));return e.set(E,{dirs:I,paths:h}),h.forEach(v=>{let x=t.get(v);x?x.push(E):t.set(v,[E])}),I.forEach(v=>{let x=t.get(v);x?x[x.length-1]instanceof Set?x[x.length-1].add(E):x.push(new Set([E])):t.set(v,[new Set([E])])}),u(E)}}}});var wfe=_((vUt,Cfe)=>{var Dct=process.platform,Sct=Dct==="win32",Pct=global.__FAKE_TESTING_FS__||ve("fs"),{O_CREAT:bct,O_TRUNC:xct,O_WRONLY:kct,UV_FS_O_FILEMAP:Efe=0}=Pct.constants,Qct=Sct&&!!Efe,Fct=512*1024,Rct=Efe|xct|bct|kct;Cfe.exports=Qct?t=>t<Fct?Rct:"w":()=>"w"});var e_=_((DUt,Nfe)=>{"use strict";var Tct=ve("assert"),Nct=Rx(),vn=ve("fs"),Lct=eC(),Wf=ve("path"),Ffe=pfe(),Ife=e3(),Oct=yfe(),Mct=t3(),fl=jE(),Uct=YE(),_ct=Y3(),Bfe=Symbol("onEntry"),V3=Symbol("checkFs"),vfe=Symbol("checkFs2"),qx=Symbol("pruneCache"),J3=Symbol("isReusable"),tc=Symbol("makeFs"),z3=Symbol("file"),X3=Symbol("directory"),Yx=Symbol("link"),Dfe=Symbol("symlink"),Sfe=Symbol("hardlink"),Pfe=Symbol("unsupported"),bfe=Symbol("checkPath"),Dh=Symbol("mkdir"),To=Symbol("onError"),jx=Symbol("pending"),xfe=Symbol("pend"),sC=Symbol("unpend"),W3=Symbol("ended"),K3=Symbol("maybeClose"),Z3=Symbol("skip"),z1=Symbol("doChown"),X1=Symbol("uid"),Z1=Symbol("gid"),$1=Symbol("checkedCwd"),Rfe=ve("crypto"),Tfe=wfe(),Hct=process.env.TESTING_TAR_FAKE_PLATFORM||process.platform,e2=Hct==="win32",jct=(t,e)=>{if(!e2)return vn.unlink(t,e);let r=t+".DELETE."+Rfe.randomBytes(16).toString("hex");vn.rename(t,r,o=>{if(o)return e(o);vn.unlink(r,e)})},Gct=t=>{if(!e2)return vn.unlinkSync(t);let e=t+".DELETE."+Rfe.randomBytes(16).toString("hex");vn.renameSync(t,e),vn.unlinkSync(e)},kfe=(t,e,r)=>t===t>>>0?t:e===e>>>0?e:r,Qfe=t=>_ct(Uct(fl(t))).toLowerCase(),qct=(t,e)=>{e=Qfe(e);for(let r of t.keys()){let o=Qfe(r);(o===e||o.indexOf(e+"/")===0)&&t.delete(r)}},Yct=t=>{for(let e of t.keys())t.delete(e)},t2=class extends Nct{constructor(e){if(e||(e={}),e.ondone=r=>{this[W3]=!0,this[K3]()},super(e),this[$1]=!1,this.reservations=Oct(),this.transform=typeof e.transform=="function"?e.transform:null,this.writable=!0,this.readable=!1,this[jx]=0,this[W3]=!1,this.dirCache=e.dirCache||new Map,typeof e.uid=="number"||typeof e.gid=="number"){if(typeof e.uid!="number"||typeof e.gid!="number")throw new TypeError("cannot set owner without number uid and gid");if(e.preserveOwner)throw new TypeError("cannot preserve owner in archive and also set owner explicitly");this.uid=e.uid,this.gid=e.gid,this.setOwner=!0}else this.uid=null,this.gid=null,this.setOwner=!1;e.preserveOwner===void 0&&typeof e.uid!="number"?this.preserveOwner=process.getuid&&process.getuid()===0:this.preserveOwner=!!e.preserveOwner,this.processUid=(this.preserveOwner||this.setOwner)&&process.getuid?process.getuid():null,this.processGid=(this.preserveOwner||this.setOwner)&&process.getgid?process.getgid():null,this.forceChown=e.forceChown===!0,this.win32=!!e.win32||e2,this.newer=!!e.newer,this.keep=!!e.keep,this.noMtime=!!e.noMtime,this.preservePaths=!!e.preservePaths,this.unlink=!!e.unlink,this.cwd=fl(Wf.resolve(e.cwd||process.cwd())),this.strip=+e.strip||0,this.processUmask=e.noChmod?0:process.umask(),this.umask=typeof e.umask=="number"?e.umask:this.processUmask,this.dmode=e.dmode||511&~this.umask,this.fmode=e.fmode||438&~this.umask,this.on("entry",r=>this[Bfe](r))}warn(e,r,o={}){return(e==="TAR_BAD_ARCHIVE"||e==="TAR_ABORT")&&(o.recoverable=!1),super.warn(e,r,o)}[K3](){this[W3]&&this[jx]===0&&(this.emit("prefinish"),this.emit("finish"),this.emit("end"),this.emit("close"))}[bfe](e){if(this.strip){let r=fl(e.path).split("/");if(r.length<this.strip)return!1;if(e.path=r.slice(this.strip).join("/"),e.type==="Link"){let o=fl(e.linkpath).split("/");if(o.length>=this.strip)e.linkpath=o.slice(this.strip).join("/");else return!1}}if(!this.preservePaths){let r=fl(e.path),o=r.split("/");if(o.includes("..")||e2&&/^[a-z]:\.\.$/i.test(o[0]))return this.warn("TAR_ENTRY_ERROR","path contains '..'",{entry:e,path:r}),!1;let[a,n]=Mct(r);a&&(e.path=n,this.warn("TAR_ENTRY_INFO",`stripping ${a} from absolute path`,{entry:e,path:r}))}if(Wf.isAbsolute(e.path)?e.absolute=fl(Wf.resolve(e.path)):e.absolute=fl(Wf.resolve(this.cwd,e.path)),!this.preservePaths&&e.absolute.indexOf(this.cwd+"/")!==0&&e.absolute!==this.cwd)return this.warn("TAR_ENTRY_ERROR","path escaped extraction target",{entry:e,path:fl(e.path),resolvedPath:e.absolute,cwd:this.cwd}),!1;if(e.absolute===this.cwd&&e.type!=="Directory"&&e.type!=="GNUDumpDir")return!1;if(this.win32){let{root:r}=Wf.win32.parse(e.absolute);e.absolute=r+Ife.encode(e.absolute.substr(r.length));let{root:o}=Wf.win32.parse(e.path);e.path=o+Ife.encode(e.path.substr(o.length))}return!0}[Bfe](e){if(!this[bfe](e))return e.resume();switch(Tct.equal(typeof e.absolute,"string"),e.type){case"Directory":case"GNUDumpDir":e.mode&&(e.mode=e.mode|448);case"File":case"OldFile":case"ContiguousFile":case"Link":case"SymbolicLink":return this[V3](e);case"CharacterDevice":case"BlockDevice":case"FIFO":default:return this[Pfe](e)}}[To](e,r){e.name==="CwdError"?this.emit("error",e):(this.warn("TAR_ENTRY_ERROR",e,{entry:r}),this[sC](),r.resume())}[Dh](e,r,o){Ffe(fl(e),{uid:this.uid,gid:this.gid,processUid:this.processUid,processGid:this.processGid,umask:this.processUmask,preserve:this.preservePaths,unlink:this.unlink,cache:this.dirCache,cwd:this.cwd,mode:r,noChmod:this.noChmod},o)}[z1](e){return this.forceChown||this.preserveOwner&&(typeof e.uid=="number"&&e.uid!==this.processUid||typeof e.gid=="number"&&e.gid!==this.processGid)||typeof this.uid=="number"&&this.uid!==this.processUid||typeof this.gid=="number"&&this.gid!==this.processGid}[X1](e){return kfe(this.uid,e.uid,this.processUid)}[Z1](e){return kfe(this.gid,e.gid,this.processGid)}[z3](e,r){let o=e.mode&4095||this.fmode,a=new Lct.WriteStream(e.absolute,{flags:Tfe(e.size),mode:o,autoClose:!1});a.on("error",p=>{a.fd&&vn.close(a.fd,()=>{}),a.write=()=>!0,this[To](p,e),r()});let n=1,u=p=>{if(p){a.fd&&vn.close(a.fd,()=>{}),this[To](p,e),r();return}--n===0&&vn.close(a.fd,h=>{h?this[To](h,e):this[sC](),r()})};a.on("finish",p=>{let h=e.absolute,E=a.fd;if(e.mtime&&!this.noMtime){n++;let I=e.atime||new Date,v=e.mtime;vn.futimes(E,I,v,x=>x?vn.utimes(h,I,v,C=>u(C&&x)):u())}if(this[z1](e)){n++;let I=this[X1](e),v=this[Z1](e);vn.fchown(E,I,v,x=>x?vn.chown(h,I,v,C=>u(C&&x)):u())}u()});let A=this.transform&&this.transform(e)||e;A!==e&&(A.on("error",p=>{this[To](p,e),r()}),e.pipe(A)),A.pipe(a)}[X3](e,r){let o=e.mode&4095||this.dmode;this[Dh](e.absolute,o,a=>{if(a){this[To](a,e),r();return}let n=1,u=A=>{--n===0&&(r(),this[sC](),e.resume())};e.mtime&&!this.noMtime&&(n++,vn.utimes(e.absolute,e.atime||new Date,e.mtime,u)),this[z1](e)&&(n++,vn.chown(e.absolute,this[X1](e),this[Z1](e),u)),u()})}[Pfe](e){e.unsupported=!0,this.warn("TAR_ENTRY_UNSUPPORTED",`unsupported entry type: ${e.type}`,{entry:e}),e.resume()}[Dfe](e,r){this[Yx](e,e.linkpath,"symlink",r)}[Sfe](e,r){let o=fl(Wf.resolve(this.cwd,e.linkpath));this[Yx](e,o,"link",r)}[xfe](){this[jx]++}[sC](){this[jx]--,this[K3]()}[Z3](e){this[sC](),e.resume()}[J3](e,r){return e.type==="File"&&!this.unlink&&r.isFile()&&r.nlink<=1&&!e2}[V3](e){this[xfe]();let r=[e.path];e.linkpath&&r.push(e.linkpath),this.reservations.reserve(r,o=>this[vfe](e,o))}[qx](e){e.type==="SymbolicLink"?Yct(this.dirCache):e.type!=="Directory"&&qct(this.dirCache,e.absolute)}[vfe](e,r){this[qx](e);let o=A=>{this[qx](e),r(A)},a=()=>{this[Dh](this.cwd,this.dmode,A=>{if(A){this[To](A,e),o();return}this[$1]=!0,n()})},n=()=>{if(e.absolute!==this.cwd){let A=fl(Wf.dirname(e.absolute));if(A!==this.cwd)return this[Dh](A,this.dmode,p=>{if(p){this[To](p,e),o();return}u()})}u()},u=()=>{vn.lstat(e.absolute,(A,p)=>{if(p&&(this.keep||this.newer&&p.mtime>e.mtime)){this[Z3](e),o();return}if(A||this[J3](e,p))return this[tc](null,e,o);if(p.isDirectory()){if(e.type==="Directory"){let h=!this.noChmod&&e.mode&&(p.mode&4095)!==e.mode,E=I=>this[tc](I,e,o);return h?vn.chmod(e.absolute,e.mode,E):E()}if(e.absolute!==this.cwd)return vn.rmdir(e.absolute,h=>this[tc](h,e,o))}if(e.absolute===this.cwd)return this[tc](null,e,o);jct(e.absolute,h=>this[tc](h,e,o))})};this[$1]?n():a()}[tc](e,r,o){if(e){this[To](e,r),o();return}switch(r.type){case"File":case"OldFile":case"ContiguousFile":return this[z3](r,o);case"Link":return this[Sfe](r,o);case"SymbolicLink":return this[Dfe](r,o);case"Directory":case"GNUDumpDir":return this[X3](r,o)}}[Yx](e,r,o,a){vn[o](r,e.absolute,n=>{n?this[To](n,e):(this[sC](),e.resume()),a()})}},Gx=t=>{try{return[null,t()]}catch(e){return[e,null]}},$3=class extends t2{[tc](e,r){return super[tc](e,r,()=>{})}[V3](e){if(this[qx](e),!this[$1]){let n=this[Dh](this.cwd,this.dmode);if(n)return this[To](n,e);this[$1]=!0}if(e.absolute!==this.cwd){let n=fl(Wf.dirname(e.absolute));if(n!==this.cwd){let u=this[Dh](n,this.dmode);if(u)return this[To](u,e)}}let[r,o]=Gx(()=>vn.lstatSync(e.absolute));if(o&&(this.keep||this.newer&&o.mtime>e.mtime))return this[Z3](e);if(r||this[J3](e,o))return this[tc](null,e);if(o.isDirectory()){if(e.type==="Directory"){let u=!this.noChmod&&e.mode&&(o.mode&4095)!==e.mode,[A]=u?Gx(()=>{vn.chmodSync(e.absolute,e.mode)}):[];return this[tc](A,e)}let[n]=Gx(()=>vn.rmdirSync(e.absolute));this[tc](n,e)}let[a]=e.absolute===this.cwd?[]:Gx(()=>Gct(e.absolute));this[tc](a,e)}[z3](e,r){let o=e.mode&4095||this.fmode,a=A=>{let p;try{vn.closeSync(n)}catch(h){p=h}(A||p)&&this[To](A||p,e),r()},n;try{n=vn.openSync(e.absolute,Tfe(e.size),o)}catch(A){return a(A)}let u=this.transform&&this.transform(e)||e;u!==e&&(u.on("error",A=>this[To](A,e)),e.pipe(u)),u.on("data",A=>{try{vn.writeSync(n,A,0,A.length)}catch(p){a(p)}}),u.on("end",A=>{let p=null;if(e.mtime&&!this.noMtime){let h=e.atime||new Date,E=e.mtime;try{vn.futimesSync(n,h,E)}catch(I){try{vn.utimesSync(e.absolute,h,E)}catch{p=I}}}if(this[z1](e)){let h=this[X1](e),E=this[Z1](e);try{vn.fchownSync(n,h,E)}catch(I){try{vn.chownSync(e.absolute,h,E)}catch{p=p||I}}}a(p)})}[X3](e,r){let o=e.mode&4095||this.dmode,a=this[Dh](e.absolute,o);if(a){this[To](a,e),r();return}if(e.mtime&&!this.noMtime)try{vn.utimesSync(e.absolute,e.atime||new Date,e.mtime)}catch{}if(this[z1](e))try{vn.chownSync(e.absolute,this[X1](e),this[Z1](e))}catch{}r(),e.resume()}[Dh](e,r){try{return Ffe.sync(fl(e),{uid:this.uid,gid:this.gid,processUid:this.processUid,processGid:this.processGid,umask:this.processUmask,preserve:this.preservePaths,unlink:this.unlink,cache:this.dirCache,cwd:this.cwd,mode:r})}catch(o){return o}}[Yx](e,r,o,a){try{vn[o+"Sync"](r,e.absolute),a(),e.resume()}catch(n){return this[To](n,e)}}};t2.Sync=$3;Nfe.exports=t2});var _fe=_((SUt,Ufe)=>{"use strict";var Wct=OE(),Wx=e_(),Ofe=ve("fs"),Mfe=eC(),Lfe=ve("path"),t_=YE();Ufe.exports=(t,e,r)=>{typeof t=="function"?(r=t,e=null,t={}):Array.isArray(t)&&(e=t,t={}),typeof e=="function"&&(r=e,e=null),e?e=Array.from(e):e=[];let o=Wct(t);if(o.sync&&typeof r=="function")throw new TypeError("callback not supported for sync tar functions");if(!o.file&&typeof r=="function")throw new TypeError("callback only supported with file option");return e.length&&Kct(o,e),o.file&&o.sync?Vct(o):o.file?Jct(o,r):o.sync?zct(o):Xct(o)};var Kct=(t,e)=>{let r=new Map(e.map(n=>[t_(n),!0])),o=t.filter,a=(n,u)=>{let A=u||Lfe.parse(n).root||".",p=n===A?!1:r.has(n)?r.get(n):a(Lfe.dirname(n),A);return r.set(n,p),p};t.filter=o?(n,u)=>o(n,u)&&a(t_(n)):n=>a(t_(n))},Vct=t=>{let e=new Wx.Sync(t),r=t.file,o=Ofe.statSync(r),a=t.maxReadSize||16*1024*1024;new Mfe.ReadStreamSync(r,{readSize:a,size:o.size}).pipe(e)},Jct=(t,e)=>{let r=new Wx(t),o=t.maxReadSize||16*1024*1024,a=t.file,n=new Promise((u,A)=>{r.on("error",A),r.on("close",u),Ofe.stat(a,(p,h)=>{if(p)A(p);else{let E=new Mfe.ReadStream(a,{readSize:o,size:h.size});E.on("error",A),E.pipe(r)}})});return e?n.then(e,e):n},zct=t=>new Wx.Sync(t),Xct=t=>new Wx(t)});var Hfe=_(us=>{"use strict";us.c=us.create=IAe();us.r=us.replace=L3();us.t=us.list=Tx();us.u=us.update=QAe();us.x=us.extract=_fe();us.Pack=Ex();us.Unpack=e_();us.Parse=Rx();us.ReadEntry=rx();us.WriteEntry=A3();us.Header=qE();us.Pax=ix();us.types=KU()});var r_,jfe,Sh,r2,n2,Gfe=Et(()=>{r_=$e(id()),jfe=ve("worker_threads"),Sh=Symbol("kTaskInfo"),r2=class{constructor(e,r){this.fn=e;this.limit=(0,r_.default)(r.poolSize)}run(e){return this.limit(()=>this.fn(e))}},n2=class{constructor(e,r){this.source=e;this.workers=[];this.limit=(0,r_.default)(r.poolSize),this.cleanupInterval=setInterval(()=>{if(this.limit.pendingCount===0&&this.limit.activeCount===0){let o=this.workers.pop();o?o.terminate():clearInterval(this.cleanupInterval)}},5e3).unref()}createWorker(){this.cleanupInterval.refresh();let e=new jfe.Worker(this.source,{eval:!0,execArgv:[...process.execArgv,"--unhandled-rejections=strict"]});return e.on("message",r=>{if(!e[Sh])throw new Error("Assertion failed: Worker sent a result without having a task assigned");e[Sh].resolve(r),e[Sh]=null,e.unref(),this.workers.push(e)}),e.on("error",r=>{e[Sh]?.reject(r),e[Sh]=null}),e.on("exit",r=>{r!==0&&e[Sh]?.reject(new Error(`Worker exited with code ${r}`)),e[Sh]=null}),e}run(e){return this.limit(()=>{let r=this.workers.pop()??this.createWorker();return r.ref(),new Promise((o,a)=>{r[Sh]={resolve:o,reject:a},r.postMessage(e)})})}}});var Yfe=_((kUt,qfe)=>{var n_;qfe.exports.getContent=()=>(typeof n_>"u"&&(n_=ve("zlib").brotliDecompressSync(Buffer.from("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","base64")).toString()),n_)});var Xi={};Vt(Xi,{convertToZip:()=>tut,convertToZipWorker:()=>o_,extractArchiveTo:()=>zfe,getDefaultTaskPool:()=>Vfe,getTaskPoolForConfiguration:()=>Jfe,makeArchiveFromDirectory:()=>eut});function Zct(t,e){switch(t){case"async":return new r2(o_,{poolSize:e});case"workers":return new n2((0,s_.getContent)(),{poolSize:e});default:throw new Error(`Assertion failed: Unknown value ${t} for taskPoolMode`)}}function Vfe(){return typeof i_>"u"&&(i_=Zct("workers",Ji.availableParallelism())),i_}function Jfe(t){return typeof t>"u"?Vfe():ol($ct,t,()=>{let e=t.get("taskPoolMode"),r=t.get("taskPoolConcurrency");switch(e){case"async":return new r2(o_,{poolSize:r});case"workers":return new n2((0,s_.getContent)(),{poolSize:r});default:throw new Error(`Assertion failed: Unknown value ${e} for taskPoolMode`)}})}async function o_(t){let{tmpFile:e,tgz:r,compressionLevel:o,extractBufferOpts:a}=t,n=new zi(e,{create:!0,level:o,stats:Ea.makeDefaultStats()}),u=Buffer.from(r.buffer,r.byteOffset,r.byteLength);return await zfe(u,n,a),n.saveAndClose(),e}async function eut(t,{baseFs:e=new Tn,prefixPath:r=Bt.root,compressionLevel:o,inMemory:a=!1}={}){let n;if(a)n=new zi(null,{level:o});else{let A=await oe.mktempPromise(),p=V.join(A,"archive.zip");n=new zi(p,{create:!0,level:o})}let u=V.resolve(Bt.root,r);return await n.copyPromise(u,t,{baseFs:e,stableTime:!0,stableSort:!0}),n}async function tut(t,e={}){let r=await oe.mktempPromise(),o=V.join(r,"archive.zip"),a=e.compressionLevel??e.configuration?.get("compressionLevel")??"mixed",n={prefixPath:e.prefixPath,stripComponents:e.stripComponents};return await(e.taskPool??Jfe(e.configuration)).run({tmpFile:o,tgz:t,compressionLevel:a,extractBufferOpts:n}),new zi(o,{level:e.compressionLevel})}async function*rut(t){let e=new Kfe.default.Parse,r=new Wfe.PassThrough({objectMode:!0,autoDestroy:!0,emitClose:!0});e.on("entry",o=>{r.write(o)}),e.on("error",o=>{r.destroy(o)}),e.on("close",()=>{r.destroyed||r.end()}),e.end(t);for await(let o of r){let a=o;yield a,a.resume()}}async function zfe(t,e,{stripComponents:r=0,prefixPath:o=Bt.dot}={}){function a(n){if(n.path[0]==="/")return!0;let u=n.path.split(/\//g);return!!(u.some(A=>A==="..")||u.length<=r)}for await(let n of rut(t)){if(a(n))continue;let u=V.normalize(ue.toPortablePath(n.path)).replace(/\/$/,"").split(/\//g);if(u.length<=r)continue;let A=u.slice(r).join("/"),p=V.join(o,A),h=420;switch((n.type==="Directory"||((n.mode??0)&73)!==0)&&(h|=73),n.type){case"Directory":e.mkdirpSync(V.dirname(p),{chmod:493,utimes:[vi.SAFE_TIME,vi.SAFE_TIME]}),e.mkdirSync(p,{mode:h}),e.utimesSync(p,vi.SAFE_TIME,vi.SAFE_TIME);break;case"OldFile":case"File":e.mkdirpSync(V.dirname(p),{chmod:493,utimes:[vi.SAFE_TIME,vi.SAFE_TIME]}),e.writeFileSync(p,await Vy(n),{mode:h}),e.utimesSync(p,vi.SAFE_TIME,vi.SAFE_TIME);break;case"SymbolicLink":e.mkdirpSync(V.dirname(p),{chmod:493,utimes:[vi.SAFE_TIME,vi.SAFE_TIME]}),e.symlinkSync(n.linkpath,p),e.lutimesSync(p,vi.SAFE_TIME,vi.SAFE_TIME);break}}return e}var Wfe,Kfe,s_,i_,$ct,Xfe=Et(()=>{Ye();St();nA();Wfe=ve("stream"),Kfe=$e(Hfe());Gfe();jl();s_=$e(Yfe());$ct=new WeakMap});var $fe=_((a_,Zfe)=>{(function(t,e){typeof a_=="object"?Zfe.exports=e():typeof define=="function"&&define.amd?define(e):t.treeify=e()})(a_,function(){function t(a,n){var u=n?"\u2514":"\u251C";return a?u+="\u2500 ":u+="\u2500\u2500\u2510",u}function e(a,n){var u=[];for(var A in a)!a.hasOwnProperty(A)||n&&typeof a[A]=="function"||u.push(A);return u}function r(a,n,u,A,p,h,E){var I="",v=0,x,C,R=A.slice(0);if(R.push([n,u])&&A.length>0&&(A.forEach(function(U,J){J>0&&(I+=(U[1]?" ":"\u2502")+" "),!C&&U[0]===n&&(C=!0)}),I+=t(a,u)+a,p&&(typeof n!="object"||n instanceof Date)&&(I+=": "+n),C&&(I+=" (circular ref.)"),E(I)),!C&&typeof n=="object"){var L=e(n,h);L.forEach(function(U){x=++v===L.length,r(U,n[U],x,R,p,h,E)})}}var o={};return o.asLines=function(a,n,u,A){var p=typeof u!="function"?u:!1;r(".",a,!1,[],n,p,A||u)},o.asTree=function(a,n,u){var A="";return r(".",a,!1,[],n,u,function(p){A+=p+` +`}),A},o})});var $s={};Vt($s,{emitList:()=>nut,emitTree:()=>npe,treeNodeToJson:()=>rpe,treeNodeToTreeify:()=>tpe});function tpe(t,{configuration:e}){let r={},o=0,a=(n,u)=>{let A=Array.isArray(n)?n.entries():Object.entries(n);for(let[p,h]of A){if(!h)continue;let{label:E,value:I,children:v}=h,x=[];typeof E<"u"&&x.push(yd(e,E,2)),typeof I<"u"&&x.push(Mt(e,I[0],I[1])),x.length===0&&x.push(yd(e,`${p}`,2));let C=x.join(": ").trim(),R=`\0${o++}\0`,L=u[`${R}${C}`]={};typeof v<"u"&&a(v,L)}};if(typeof t.children>"u")throw new Error("The root node must only contain children");return a(t.children,r),r}function rpe(t){let e=r=>{if(typeof r.children>"u"){if(typeof r.value>"u")throw new Error("Assertion failed: Expected a value to be set if the children are missing");return Ed(r.value[0],r.value[1])}let o=Array.isArray(r.children)?r.children.entries():Object.entries(r.children??{}),a=Array.isArray(r.children)?[]:{};for(let[n,u]of o)u&&(a[iut(n)]=e(u));return typeof r.value>"u"?a:{value:Ed(r.value[0],r.value[1]),children:a}};return e(t)}function nut(t,{configuration:e,stdout:r,json:o}){let a=t.map(n=>({value:n}));npe({children:a},{configuration:e,stdout:r,json:o})}function npe(t,{configuration:e,stdout:r,json:o,separators:a=0}){if(o){let u=Array.isArray(t.children)?t.children.values():Object.values(t.children??{});for(let A of u)A&&r.write(`${JSON.stringify(rpe(A))} +`);return}let n=(0,epe.asTree)(tpe(t,{configuration:e}),!1,!1);if(n=n.replace(/\0[0-9]+\0/g,""),a>=1&&(n=n.replace(/^([├└]─)/gm,`\u2502 +$1`).replace(/^│\n/,"")),a>=2)for(let u=0;u<2;++u)n=n.replace(/^([│ ].{2}[├│ ].{2}[^\n]+\n)(([│ ]).{2}[├└].{2}[^\n]*\n[│ ].{2}[│ ].{2}[├└]─)/gm,`$1$3 \u2502 +$2`).replace(/^│\n/,"");if(a>=3)throw new Error("Only the first two levels are accepted by treeUtils.emitTree");r.write(n)}function iut(t){return typeof t=="string"?t.replace(/^\0[0-9]+\0/,""):t}var epe,ipe=Et(()=>{epe=$e($fe());Gl()});function i2(t){let e=t.match(sut);if(!e?.groups)throw new Error("Assertion failed: Expected the checksum to match the requested pattern");let r=e.groups.cacheVersion?parseInt(e.groups.cacheVersion):null;return{cacheKey:e.groups.cacheKey??null,cacheVersion:r,cacheSpec:e.groups.cacheSpec??null,hash:e.groups.hash}}var spe,l_,c_,Kx,Lr,sut,u_=Et(()=>{Ye();St();St();nA();spe=ve("crypto"),l_=$e(ve("fs"));Yl();nh();jl();bo();c_=Jy(process.env.YARN_CACHE_CHECKPOINT_OVERRIDE??process.env.YARN_CACHE_VERSION_OVERRIDE??9),Kx=Jy(process.env.YARN_CACHE_VERSION_OVERRIDE??10),Lr=class{constructor(e,{configuration:r,immutable:o=r.get("enableImmutableCache"),check:a=!1}){this.markedFiles=new Set;this.mutexes=new Map;this.cacheId=`-${(0,spe.randomBytes)(8).toString("hex")}.tmp`;this.configuration=r,this.cwd=e,this.immutable=o,this.check=a;let{cacheSpec:n,cacheKey:u}=Lr.getCacheKey(r);this.cacheSpec=n,this.cacheKey=u}static async find(e,{immutable:r,check:o}={}){let a=new Lr(e.get("cacheFolder"),{configuration:e,immutable:r,check:o});return await a.setup(),a}static getCacheKey(e){let r=e.get("compressionLevel"),o=r!=="mixed"?`c${r}`:"";return{cacheKey:[Kx,o].join(""),cacheSpec:o}}get mirrorCwd(){if(!this.configuration.get("enableMirror"))return null;let e=`${this.configuration.get("globalFolder")}/cache`;return e!==this.cwd?e:null}getVersionFilename(e){return`${lE(e)}-${this.cacheKey}.zip`}getChecksumFilename(e,r){let a=i2(r).hash.slice(0,10);return`${lE(e)}-${a}.zip`}isChecksumCompatible(e){if(e===null)return!1;let{cacheVersion:r,cacheSpec:o}=i2(e);if(r===null||r<c_)return!1;let a=this.configuration.get("cacheMigrationMode");return!(r<Kx&&a==="always"||o!==this.cacheSpec&&a!=="required-only")}getLocatorPath(e,r){return this.mirrorCwd===null?V.resolve(this.cwd,this.getVersionFilename(e)):r===null?V.resolve(this.cwd,this.getVersionFilename(e)):V.resolve(this.cwd,this.getChecksumFilename(e,r))}getLocatorMirrorPath(e){let r=this.mirrorCwd;return r!==null?V.resolve(r,this.getVersionFilename(e)):null}async setup(){if(!this.configuration.get("enableGlobalCache"))if(this.immutable){if(!await oe.existsPromise(this.cwd))throw new zt(56,"Cache path does not exist.")}else{await oe.mkdirPromise(this.cwd,{recursive:!0});let e=V.resolve(this.cwd,".gitignore");await oe.changeFilePromise(e,`/.gitignore +*.flock +*.tmp +`)}(this.mirrorCwd||!this.immutable)&&await oe.mkdirPromise(this.mirrorCwd||this.cwd,{recursive:!0})}async fetchPackageFromCache(e,r,{onHit:o,onMiss:a,loader:n,...u}){let A=this.getLocatorMirrorPath(e),p=new Tn,h=()=>{let he=new zi,Be=V.join(Bt.root,nM(e));return he.mkdirSync(Be,{recursive:!0}),he.writeJsonSync(V.join(Be,dr.manifest),{name:fn(e),mocked:!0}),he},E=async(he,{isColdHit:Be,controlPath:we=null})=>{if(we===null&&u.unstablePackages?.has(e.locatorHash))return{isValid:!0,hash:null};let g=r&&!Be?i2(r).cacheKey:this.cacheKey,Ee=!u.skipIntegrityCheck||!r?`${g}/${await NP(he)}`:r;if(we!==null){let le=!u.skipIntegrityCheck||!r?`${this.cacheKey}/${await NP(we)}`:r;if(Ee!==le)throw new zt(18,"The remote archive doesn't match the local checksum - has the local cache been corrupted?")}let Se=null;switch(r!==null&&Ee!==r&&(this.check?Se="throw":i2(r).cacheKey!==i2(Ee).cacheKey?Se="update":Se=this.configuration.get("checksumBehavior")),Se){case null:case"update":return{isValid:!0,hash:Ee};case"ignore":return{isValid:!0,hash:r};case"reset":return{isValid:!1,hash:r};default:case"throw":throw new zt(18,"The remote archive doesn't match the expected checksum")}},I=async he=>{if(!n)throw new Error(`Cache check required but no loader configured for ${jr(this.configuration,e)}`);let Be=await n(),we=Be.getRealPath();Be.saveAndClose(),await oe.chmodPromise(we,420);let g=await E(he,{controlPath:we,isColdHit:!1});if(!g.isValid)throw new Error("Assertion failed: Expected a valid checksum");return g.hash},v=async()=>{if(A===null||!await oe.existsPromise(A)){let he=await n(),Be=he.getRealPath();return he.saveAndClose(),{source:"loader",path:Be}}return{source:"mirror",path:A}},x=async()=>{if(!n)throw new Error(`Cache entry required but missing for ${jr(this.configuration,e)}`);if(this.immutable)throw new zt(56,`Cache entry required but missing for ${jr(this.configuration,e)}`);let{path:he,source:Be}=await v(),{hash:we}=await E(he,{isColdHit:!0}),g=this.getLocatorPath(e,we),Ee=[];Be!=="mirror"&&A!==null&&Ee.push(async()=>{let le=`${A}${this.cacheId}`;await oe.copyFilePromise(he,le,l_.default.constants.COPYFILE_FICLONE),await oe.chmodPromise(le,420),await oe.renamePromise(le,A)}),(!u.mirrorWriteOnly||A===null)&&Ee.push(async()=>{let le=`${g}${this.cacheId}`;await oe.copyFilePromise(he,le,l_.default.constants.COPYFILE_FICLONE),await oe.chmodPromise(le,420),await oe.renamePromise(le,g)});let Se=u.mirrorWriteOnly?A??g:g;return await Promise.all(Ee.map(le=>le())),[!1,Se,we]},C=async()=>{let Be=(async()=>{let we=u.unstablePackages?.has(e.locatorHash),g=we||!r||this.isChecksumCompatible(r)?this.getLocatorPath(e,r):null,Ee=g!==null?this.markedFiles.has(g)||await p.existsPromise(g):!1,Se=!!u.mockedPackages?.has(e.locatorHash)&&(!this.check||!Ee),le=Se||Ee,ne=le?o:a;if(ne&&ne(),le){let ee=null,Ie=g;if(!Se)if(this.check)ee=await I(Ie);else{let Fe=await E(Ie,{isColdHit:!1});if(Fe.isValid)ee=Fe.hash;else return x()}return[Se,Ie,ee]}else{if(this.immutable&&we)throw new zt(56,`Cache entry required but missing for ${jr(this.configuration,e)}; consider defining ${de.pretty(this.configuration,"supportedArchitectures",de.Type.CODE)} to cache packages for multiple systems`);return x()}})();this.mutexes.set(e.locatorHash,Be);try{return await Be}finally{this.mutexes.delete(e.locatorHash)}};for(let he;he=this.mutexes.get(e.locatorHash);)await he;let[R,L,U]=await C();R||this.markedFiles.add(L);let J,te=R?()=>h():()=>new zi(L,{baseFs:p,readOnly:!0}),ae=new iy(()=>EL(()=>J=te(),he=>`Failed to open the cache entry for ${jr(this.configuration,e)}: ${he}`),V),fe=new Uu(L,{baseFs:ae,pathUtils:V}),ce=()=>{J?.discardAndClose()},me=u.unstablePackages?.has(e.locatorHash)?null:U;return[fe,ce,me]}},sut=/^(?:(?<cacheKey>(?<cacheVersion>[0-9]+)(?<cacheSpec>.*))\/)?(?<hash>.*)$/});var Vx,ope=Et(()=>{Vx=(r=>(r[r.SCRIPT=0]="SCRIPT",r[r.SHELLCODE=1]="SHELLCODE",r))(Vx||{})});var out,oC,A_=Et(()=>{St();Nl();Qf();bo();out=[[/^(git(?:\+(?:https|ssh))?:\/\/.*(?:\.git)?)#(.*)$/,(t,e,r,o)=>`${r}#commit=${o}`],[/^https:\/\/((?:[^/]+?)@)?codeload\.github\.com\/([^/]+\/[^/]+)\/tar\.gz\/([0-9a-f]+)$/,(t,e,r="",o,a)=>`https://${r}github.com/${o}.git#commit=${a}`],[/^https:\/\/((?:[^/]+?)@)?github\.com\/([^/]+\/[^/]+?)(?:\.git)?#([0-9a-f]+)$/,(t,e,r="",o,a)=>`https://${r}github.com/${o}.git#commit=${a}`],[/^https?:\/\/[^/]+\/(?:[^/]+\/)*(?:@.+(?:\/|(?:%2f)))?([^/]+)\/(?:-|download)\/\1-[^/]+\.tgz(?:#|$)/,t=>`npm:${t}`],[/^https:\/\/npm\.pkg\.github\.com\/download\/(?:@[^/]+)\/(?:[^/]+)\/(?:[^/]+)\/(?:[0-9a-f]+)(?:#|$)/,t=>`npm:${t}`],[/^https:\/\/npm\.fontawesome\.com\/(?:@[^/]+)\/([^/]+)\/-\/([^/]+)\/\1-\2.tgz(?:#|$)/,t=>`npm:${t}`],[/^https?:\/\/[^/]+\/.*\/(@[^/]+)\/([^/]+)\/-\/\1\/\2-(?:[.\d\w-]+)\.tgz(?:#|$)/,(t,e)=>_P({protocol:"npm:",source:null,selector:t,params:{__archiveUrl:e}})],[/^[^/]+\.tgz#[0-9a-f]+$/,t=>`npm:${t}`]],oC=class{constructor(e){this.resolver=e;this.resolutions=null}async setup(e,{report:r}){let o=V.join(e.cwd,dr.lockfile);if(!oe.existsSync(o))return;let a=await oe.readFilePromise(o,"utf8"),n=Ki(a);if(Object.hasOwn(n,"__metadata"))return;let u=this.resolutions=new Map;for(let A of Object.keys(n)){let p=s1(A);if(!p){r.reportWarning(14,`Failed to parse the string "${A}" into a proper descriptor`);continue}let h=xa(p.range)?In(p,`npm:${p.range}`):p,{version:E,resolved:I}=n[A];if(!I)continue;let v;for(let[C,R]of out){let L=I.match(C);if(L){v=R(E,...L);break}}if(!v){r.reportWarning(14,`${Gn(e.configuration,h)}: Only some patterns can be imported from legacy lockfiles (not "${I}")`);continue}let x=h;try{let C=Bd(h.range),R=s1(C.selector,!0);R&&(x=R)}catch{}u.set(h.descriptorHash,Qs(x,v))}}supportsDescriptor(e,r){return this.resolutions?this.resolutions.has(e.descriptorHash):!1}supportsLocator(e,r){return!1}shouldPersistResolution(e,r){throw new Error("Assertion failed: This resolver doesn't support resolving locators to packages")}bindDescriptor(e,r,o){return e}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){if(!this.resolutions)throw new Error("Assertion failed: The resolution store should have been setup");let a=this.resolutions.get(e.descriptorHash);if(!a)throw new Error("Assertion failed: The resolution should have been registered");let n=$O(a),u=o.project.configuration.normalizeDependency(n);return await this.resolver.getCandidates(u,r,o)}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){throw new Error("Assertion failed: This resolver doesn't support resolving locators to packages")}}});var AA,ape=Et(()=>{Yl();O1();Gl();AA=class extends Xs{constructor({configuration:r,stdout:o,suggestInstall:a=!0}){super();this.errorCount=0;XI(this,{configuration:r}),this.configuration=r,this.stdout=o,this.suggestInstall=a}static async start(r,o){let a=new this(r);try{await o(a)}catch(n){a.reportExceptionOnce(n)}finally{await a.finalize()}return a}hasErrors(){return this.errorCount>0}exitCode(){return this.hasErrors()?1:0}reportCacheHit(r){}reportCacheMiss(r){}startSectionSync(r,o){return o()}async startSectionPromise(r,o){return await o()}startTimerSync(r,o,a){return(typeof o=="function"?o:a)()}async startTimerPromise(r,o,a){return await(typeof o=="function"?o:a)()}reportSeparator(){}reportInfo(r,o){}reportWarning(r,o){}reportError(r,o){this.errorCount+=1,this.stdout.write(`${Mt(this.configuration,"\u27A4","redBright")} ${this.formatNameWithHyperlink(r)}: ${o} +`)}reportProgress(r){return{...Promise.resolve().then(async()=>{for await(let{}of r);}),stop:()=>{}}}reportJson(r){}reportFold(r,o){}async finalize(){this.errorCount>0&&(this.stdout.write(` +`),this.stdout.write(`${Mt(this.configuration,"\u27A4","redBright")} Errors happened when preparing the environment required to run this command. +`),this.suggestInstall&&this.stdout.write(`${Mt(this.configuration,"\u27A4","redBright")} This might be caused by packages being missing from the lockfile, in which case running "yarn install" might help. +`))}formatNameWithHyperlink(r){return yU(r,{configuration:this.configuration,json:!1})}}});var aC,f_=Et(()=>{bo();aC=class{constructor(e){this.resolver=e}supportsDescriptor(e,r){return!!(r.project.storedResolutions.get(e.descriptorHash)||r.project.originalPackages.has(OP(e).locatorHash))}supportsLocator(e,r){return!!(r.project.originalPackages.has(e.locatorHash)&&!r.project.lockfileNeedsRefresh)}shouldPersistResolution(e,r){throw new Error("The shouldPersistResolution method shouldn't be called on the lockfile resolver, which would always answer yes")}bindDescriptor(e,r,o){return e}getResolutionDependencies(e,r){return this.resolver.getResolutionDependencies(e,r)}async getCandidates(e,r,o){let a=o.project.storedResolutions.get(e.descriptorHash);if(a){let u=o.project.originalPackages.get(a);if(u)return[u]}let n=o.project.originalPackages.get(OP(e).locatorHash);if(n)return[n];throw new Error("Resolution expected from the lockfile data")}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){let o=r.project.originalPackages.get(e.locatorHash);if(!o)throw new Error("The lockfile resolver isn't meant to resolve packages - they should already have been stored into a cache");return o}}});function Kf(){}function aut(t,e,r,o,a){for(var n=0,u=e.length,A=0,p=0;n<u;n++){var h=e[n];if(h.removed){if(h.value=t.join(o.slice(p,p+h.count)),p+=h.count,n&&e[n-1].added){var I=e[n-1];e[n-1]=e[n],e[n]=I}}else{if(!h.added&&a){var E=r.slice(A,A+h.count);E=E.map(function(x,C){var R=o[p+C];return R.length>x.length?R:x}),h.value=t.join(E)}else h.value=t.join(r.slice(A,A+h.count));A+=h.count,h.added||(p+=h.count)}}var v=e[u-1];return u>1&&typeof v.value=="string"&&(v.added||v.removed)&&t.equals("",v.value)&&(e[u-2].value+=v.value,e.pop()),e}function lut(t){return{newPos:t.newPos,components:t.components.slice(0)}}function cut(t,e){if(typeof t=="function")e.callback=t;else if(t)for(var r in t)t.hasOwnProperty(r)&&(e[r]=t[r]);return e}function upe(t,e,r){return r=cut(r,{ignoreWhitespace:!0}),m_.diff(t,e,r)}function uut(t,e,r){return y_.diff(t,e,r)}function Jx(t){return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?Jx=function(e){return typeof e}:Jx=function(e){return e&&typeof Symbol=="function"&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Jx(t)}function p_(t){return put(t)||hut(t)||gut(t)||dut()}function put(t){if(Array.isArray(t))return h_(t)}function hut(t){if(typeof Symbol<"u"&&Symbol.iterator in Object(t))return Array.from(t)}function gut(t,e){if(!!t){if(typeof t=="string")return h_(t,e);var r=Object.prototype.toString.call(t).slice(8,-1);if(r==="Object"&&t.constructor&&(r=t.constructor.name),r==="Map"||r==="Set")return Array.from(t);if(r==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r))return h_(t,e)}}function h_(t,e){(e==null||e>t.length)&&(e=t.length);for(var r=0,o=new Array(e);r<e;r++)o[r]=t[r];return o}function dut(){throw new TypeError(`Invalid attempt to spread non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function g_(t,e,r,o,a){e=e||[],r=r||[],o&&(t=o(a,t));var n;for(n=0;n<e.length;n+=1)if(e[n]===t)return r[n];var u;if(mut.call(t)==="[object Array]"){for(e.push(t),u=new Array(t.length),r.push(u),n=0;n<t.length;n+=1)u[n]=g_(t[n],e,r,o,a);return e.pop(),r.pop(),u}if(t&&t.toJSON&&(t=t.toJSON()),Jx(t)==="object"&&t!==null){e.push(t),u={},r.push(u);var A=[],p;for(p in t)t.hasOwnProperty(p)&&A.push(p);for(A.sort(),n=0;n<A.length;n+=1)p=A[n],u[p]=g_(t[p],e,r,o,p);e.pop(),r.pop()}else u=t;return u}function Ape(t,e,r,o,a,n,u){u||(u={}),typeof u.context>"u"&&(u.context=4);var A=uut(r,o,u);if(!A)return;A.push({value:"",lines:[]});function p(U){return U.map(function(J){return" "+J})}for(var h=[],E=0,I=0,v=[],x=1,C=1,R=function(J){var te=A[J],ae=te.lines||te.value.replace(/\n$/,"").split(` +`);if(te.lines=ae,te.added||te.removed){var fe;if(!E){var ce=A[J-1];E=x,I=C,ce&&(v=u.context>0?p(ce.lines.slice(-u.context)):[],E-=v.length,I-=v.length)}(fe=v).push.apply(fe,p_(ae.map(function(le){return(te.added?"+":"-")+le}))),te.added?C+=ae.length:x+=ae.length}else{if(E)if(ae.length<=u.context*2&&J<A.length-2){var me;(me=v).push.apply(me,p_(p(ae)))}else{var he,Be=Math.min(ae.length,u.context);(he=v).push.apply(he,p_(p(ae.slice(0,Be))));var we={oldStart:E,oldLines:x-E+Be,newStart:I,newLines:C-I+Be,lines:v};if(J>=A.length-2&&ae.length<=u.context){var g=/\n$/.test(r),Ee=/\n$/.test(o),Se=ae.length==0&&v.length>we.oldLines;!g&&Se&&r.length>0&&v.splice(we.oldLines,0,"\\ No newline at end of file"),(!g&&!Se||!Ee)&&v.push("\\ No newline at end of file")}h.push(we),E=0,I=0,v=[]}x+=ae.length,C+=ae.length}},L=0;L<A.length;L++)R(L);return{oldFileName:t,newFileName:e,oldHeader:a,newHeader:n,hunks:h}}var n3t,lpe,cpe,m_,y_,Aut,fut,mut,s2,d_,E_=Et(()=>{Kf.prototype={diff:function(e,r){var o=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{},a=o.callback;typeof o=="function"&&(a=o,o={}),this.options=o;var n=this;function u(R){return a?(setTimeout(function(){a(void 0,R)},0),!0):R}e=this.castInput(e),r=this.castInput(r),e=this.removeEmpty(this.tokenize(e)),r=this.removeEmpty(this.tokenize(r));var A=r.length,p=e.length,h=1,E=A+p;o.maxEditLength&&(E=Math.min(E,o.maxEditLength));var I=[{newPos:-1,components:[]}],v=this.extractCommon(I[0],r,e,0);if(I[0].newPos+1>=A&&v+1>=p)return u([{value:this.join(r),count:r.length}]);function x(){for(var R=-1*h;R<=h;R+=2){var L=void 0,U=I[R-1],J=I[R+1],te=(J?J.newPos:0)-R;U&&(I[R-1]=void 0);var ae=U&&U.newPos+1<A,fe=J&&0<=te&&te<p;if(!ae&&!fe){I[R]=void 0;continue}if(!ae||fe&&U.newPos<J.newPos?(L=lut(J),n.pushComponent(L.components,void 0,!0)):(L=U,L.newPos++,n.pushComponent(L.components,!0,void 0)),te=n.extractCommon(L,r,e,R),L.newPos+1>=A&&te+1>=p)return u(aut(n,L.components,r,e,n.useLongestToken));I[R]=L}h++}if(a)(function R(){setTimeout(function(){if(h>E)return a();x()||R()},0)})();else for(;h<=E;){var C=x();if(C)return C}},pushComponent:function(e,r,o){var a=e[e.length-1];a&&a.added===r&&a.removed===o?e[e.length-1]={count:a.count+1,added:r,removed:o}:e.push({count:1,added:r,removed:o})},extractCommon:function(e,r,o,a){for(var n=r.length,u=o.length,A=e.newPos,p=A-a,h=0;A+1<n&&p+1<u&&this.equals(r[A+1],o[p+1]);)A++,p++,h++;return h&&e.components.push({count:h}),e.newPos=A,p},equals:function(e,r){return this.options.comparator?this.options.comparator(e,r):e===r||this.options.ignoreCase&&e.toLowerCase()===r.toLowerCase()},removeEmpty:function(e){for(var r=[],o=0;o<e.length;o++)e[o]&&r.push(e[o]);return r},castInput:function(e){return e},tokenize:function(e){return e.split("")},join:function(e){return e.join("")}};n3t=new Kf;lpe=/^[A-Za-z\xC0-\u02C6\u02C8-\u02D7\u02DE-\u02FF\u1E00-\u1EFF]+$/,cpe=/\S/,m_=new Kf;m_.equals=function(t,e){return this.options.ignoreCase&&(t=t.toLowerCase(),e=e.toLowerCase()),t===e||this.options.ignoreWhitespace&&!cpe.test(t)&&!cpe.test(e)};m_.tokenize=function(t){for(var e=t.split(/([^\S\r\n]+|[()[\]{}'"\r\n]|\b)/),r=0;r<e.length-1;r++)!e[r+1]&&e[r+2]&&lpe.test(e[r])&&lpe.test(e[r+2])&&(e[r]+=e[r+2],e.splice(r+1,2),r--);return e};y_=new Kf;y_.tokenize=function(t){var e=[],r=t.split(/(\n|\r\n)/);r[r.length-1]||r.pop();for(var o=0;o<r.length;o++){var a=r[o];o%2&&!this.options.newlineIsToken?e[e.length-1]+=a:(this.options.ignoreWhitespace&&(a=a.trim()),e.push(a))}return e};Aut=new Kf;Aut.tokenize=function(t){return t.split(/(\S.+?[.!?])(?=\s+|$)/)};fut=new Kf;fut.tokenize=function(t){return t.split(/([{}:;,]|\s+)/)};mut=Object.prototype.toString,s2=new Kf;s2.useLongestToken=!0;s2.tokenize=y_.tokenize;s2.castInput=function(t){var e=this.options,r=e.undefinedReplacement,o=e.stringifyReplacer,a=o===void 0?function(n,u){return typeof u>"u"?r:u}:o;return typeof t=="string"?t:JSON.stringify(g_(t,null,null,a),a," ")};s2.equals=function(t,e){return Kf.prototype.equals.call(s2,t.replace(/,([\r\n])/g,"$1"),e.replace(/,([\r\n])/g,"$1"))};d_=new Kf;d_.tokenize=function(t){return t.slice()};d_.join=d_.removeEmpty=function(t){return t}});var ppe=_((s3t,fpe)=>{var yut=Hl(),Eut=pE(),Cut=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,wut=/^\w*$/;function Iut(t,e){if(yut(t))return!1;var r=typeof t;return r=="number"||r=="symbol"||r=="boolean"||t==null||Eut(t)?!0:wut.test(t)||!Cut.test(t)||e!=null&&t in Object(e)}fpe.exports=Iut});var dpe=_((o3t,gpe)=>{var hpe=_S(),But="Expected a function";function C_(t,e){if(typeof t!="function"||e!=null&&typeof e!="function")throw new TypeError(But);var r=function(){var o=arguments,a=e?e.apply(this,o):o[0],n=r.cache;if(n.has(a))return n.get(a);var u=t.apply(this,o);return r.cache=n.set(a,u)||n,u};return r.cache=new(C_.Cache||hpe),r}C_.Cache=hpe;gpe.exports=C_});var ype=_((a3t,mpe)=>{var vut=dpe(),Dut=500;function Sut(t){var e=vut(t,function(o){return r.size===Dut&&r.clear(),o}),r=e.cache;return e}mpe.exports=Sut});var w_=_((l3t,Epe)=>{var Put=ype(),but=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,xut=/\\(\\)?/g,kut=Put(function(t){var e=[];return t.charCodeAt(0)===46&&e.push(""),t.replace(but,function(r,o,a,n){e.push(a?n.replace(xut,"$1"):o||r)}),e});Epe.exports=kut});var Gd=_((c3t,Cpe)=>{var Qut=Hl(),Fut=ppe(),Rut=w_(),Tut=N1();function Nut(t,e){return Qut(t)?t:Fut(t,e)?[t]:Rut(Tut(t))}Cpe.exports=Nut});var lC=_((u3t,wpe)=>{var Lut=pE(),Out=1/0;function Mut(t){if(typeof t=="string"||Lut(t))return t;var e=t+"";return e=="0"&&1/t==-Out?"-0":e}wpe.exports=Mut});var zx=_((A3t,Ipe)=>{var Uut=Gd(),_ut=lC();function Hut(t,e){e=Uut(e,t);for(var r=0,o=e.length;t!=null&&r<o;)t=t[_ut(e[r++])];return r&&r==o?t:void 0}Ipe.exports=Hut});var I_=_((f3t,vpe)=>{var jut=rP(),Gut=Gd(),qut=_I(),Bpe=il(),Yut=lC();function Wut(t,e,r,o){if(!Bpe(t))return t;e=Gut(e,t);for(var a=-1,n=e.length,u=n-1,A=t;A!=null&&++a<n;){var p=Yut(e[a]),h=r;if(p==="__proto__"||p==="constructor"||p==="prototype")return t;if(a!=u){var E=A[p];h=o?o(E,p,A):void 0,h===void 0&&(h=Bpe(E)?E:qut(e[a+1])?[]:{})}jut(A,p,h),A=A[p]}return t}vpe.exports=Wut});var Spe=_((p3t,Dpe)=>{var Kut=zx(),Vut=I_(),Jut=Gd();function zut(t,e,r){for(var o=-1,a=e.length,n={};++o<a;){var u=e[o],A=Kut(t,u);r(A,u)&&Vut(n,Jut(u,t),A)}return n}Dpe.exports=zut});var bpe=_((h3t,Ppe)=>{function Xut(t,e){return t!=null&&e in Object(t)}Ppe.exports=Xut});var B_=_((g3t,xpe)=>{var Zut=Gd(),$ut=OI(),eAt=Hl(),tAt=_I(),rAt=YS(),nAt=lC();function iAt(t,e,r){e=Zut(e,t);for(var o=-1,a=e.length,n=!1;++o<a;){var u=nAt(e[o]);if(!(n=t!=null&&r(t,u)))break;t=t[u]}return n||++o!=a?n:(a=t==null?0:t.length,!!a&&rAt(a)&&tAt(u,a)&&(eAt(t)||$ut(t)))}xpe.exports=iAt});var Qpe=_((d3t,kpe)=>{var sAt=bpe(),oAt=B_();function aAt(t,e){return t!=null&&oAt(t,e,sAt)}kpe.exports=aAt});var Rpe=_((m3t,Fpe)=>{var lAt=Spe(),cAt=Qpe();function uAt(t,e){return lAt(t,e,function(r,o){return cAt(t,o)})}Fpe.exports=uAt});var Ope=_((y3t,Lpe)=>{var Tpe=pd(),AAt=OI(),fAt=Hl(),Npe=Tpe?Tpe.isConcatSpreadable:void 0;function pAt(t){return fAt(t)||AAt(t)||!!(Npe&&t&&t[Npe])}Lpe.exports=pAt});var _pe=_((E3t,Upe)=>{var hAt=GS(),gAt=Ope();function Mpe(t,e,r,o,a){var n=-1,u=t.length;for(r||(r=gAt),a||(a=[]);++n<u;){var A=t[n];e>0&&r(A)?e>1?Mpe(A,e-1,r,o,a):hAt(a,A):o||(a[a.length]=A)}return a}Upe.exports=Mpe});var jpe=_((C3t,Hpe)=>{var dAt=_pe();function mAt(t){var e=t==null?0:t.length;return e?dAt(t,1):[]}Hpe.exports=mAt});var v_=_((w3t,Gpe)=>{var yAt=jpe(),EAt=AL(),CAt=fL();function wAt(t){return CAt(EAt(t,void 0,yAt),t+"")}Gpe.exports=wAt});var D_=_((I3t,qpe)=>{var IAt=Rpe(),BAt=v_(),vAt=BAt(function(t,e){return t==null?{}:IAt(t,e)});qpe.exports=vAt});var Xx,Ype=Et(()=>{Yl();Xx=class{constructor(e){this.resolver=e}supportsDescriptor(e,r){return this.resolver.supportsDescriptor(e,r)}supportsLocator(e,r){return this.resolver.supportsLocator(e,r)}shouldPersistResolution(e,r){return this.resolver.shouldPersistResolution(e,r)}bindDescriptor(e,r,o){return this.resolver.bindDescriptor(e,r,o)}getResolutionDependencies(e,r){return this.resolver.getResolutionDependencies(e,r)}async getCandidates(e,r,o){throw new zt(20,`This package doesn't seem to be present in your lockfile; run "yarn install" to update the lockfile`)}async getSatisfying(e,r,o,a){throw new zt(20,`This package doesn't seem to be present in your lockfile; run "yarn install" to update the lockfile`)}async resolve(e,r){throw new zt(20,`This package doesn't seem to be present in your lockfile; run "yarn install" to update the lockfile`)}}});var Qi,S_=Et(()=>{Yl();Qi=class extends Xs{reportCacheHit(e){}reportCacheMiss(e){}startSectionSync(e,r){return r()}async startSectionPromise(e,r){return await r()}startTimerSync(e,r,o){return(typeof r=="function"?r:o)()}async startTimerPromise(e,r,o){return await(typeof r=="function"?r:o)()}reportSeparator(){}reportInfo(e,r){}reportWarning(e,r){}reportError(e,r){}reportProgress(e){return{...Promise.resolve().then(async()=>{for await(let{}of e);}),stop:()=>{}}}reportJson(e){}reportFold(e,r){}async finalize(){}}});var Wpe,cC,P_=Et(()=>{St();Wpe=$e(RP());fE();vd();Gl();nh();Qf();bo();cC=class{constructor(e,{project:r}){this.workspacesCwds=new Set;this.project=r,this.cwd=e}async setup(){this.manifest=await Ot.tryFind(this.cwd)??new Ot,this.relativeCwd=V.relative(this.project.cwd,this.cwd)||Bt.dot;let e=this.manifest.name?this.manifest.name:eA(null,`${this.computeCandidateName()}-${zs(this.relativeCwd).substring(0,6)}`);this.anchoredDescriptor=In(e,`${Xn.protocol}${this.relativeCwd}`),this.anchoredLocator=Qs(e,`${Xn.protocol}${this.relativeCwd}`);let r=this.manifest.workspaceDefinitions.map(({pattern:a})=>a);if(r.length===0)return;let o=await(0,Wpe.default)(r,{cwd:ue.fromPortablePath(this.cwd),onlyDirectories:!0,ignore:["**/node_modules","**/.git","**/.yarn"]});o.sort(),await o.reduce(async(a,n)=>{let u=V.resolve(this.cwd,ue.toPortablePath(n)),A=await oe.existsPromise(V.join(u,"package.json"));await a,A&&this.workspacesCwds.add(u)},Promise.resolve())}get anchoredPackage(){let e=this.project.storedPackages.get(this.anchoredLocator.locatorHash);if(!e)throw new Error(`Assertion failed: Expected workspace ${a1(this.project.configuration,this)} (${Mt(this.project.configuration,V.join(this.cwd,dr.manifest),yt.PATH)}) to have been resolved. Run "yarn install" to update the lockfile`);return e}accepts(e){let r=e.indexOf(":"),o=r!==-1?e.slice(0,r+1):null,a=r!==-1?e.slice(r+1):e;if(o===Xn.protocol&&V.normalize(a)===this.relativeCwd||o===Xn.protocol&&(a==="*"||a==="^"||a==="~"))return!0;let n=xa(a);return n?o===Xn.protocol?n.test(this.manifest.version??"0.0.0"):this.project.configuration.get("enableTransparentWorkspaces")&&this.manifest.version!==null?n.test(this.manifest.version):!1:!1}computeCandidateName(){return this.cwd===this.project.cwd?"root-workspace":`${V.basename(this.cwd)}`||"unnamed-workspace"}getRecursiveWorkspaceDependencies({dependencies:e=Ot.hardDependencies}={}){let r=new Set,o=a=>{for(let n of e)for(let u of a.manifest[n].values()){let A=this.project.tryWorkspaceByDescriptor(u);A===null||r.has(A)||(r.add(A),o(A))}};return o(this),r}getRecursiveWorkspaceDependents({dependencies:e=Ot.hardDependencies}={}){let r=new Set,o=a=>{for(let n of this.project.workspaces)e.some(A=>[...n.manifest[A].values()].some(p=>{let h=this.project.tryWorkspaceByDescriptor(p);return h!==null&&i1(h.anchoredLocator,a.anchoredLocator)}))&&!r.has(n)&&(r.add(n),o(n))};return o(this),r}getRecursiveWorkspaceChildren(){let e=new Set([this]);for(let r of e)for(let o of r.workspacesCwds){let a=this.project.workspacesByCwd.get(o);a&&e.add(a)}return e.delete(this),Array.from(e)}async persistManifest(){let e={};this.manifest.exportTo(e);let r=V.join(this.cwd,Ot.fileName),o=`${JSON.stringify(e,null,this.manifest.indent)} +`;await oe.changeFilePromise(r,o,{automaticNewlines:!0}),this.manifest.raw=e}}});function kAt({project:t,allDescriptors:e,allResolutions:r,allPackages:o,accessibleLocators:a=new Set,optionalBuilds:n=new Set,peerRequirements:u=new Map,peerWarnings:A=[],volatileDescriptors:p=new Set}){let h=new Map,E=[],I=new Map,v=new Map,x=new Map,C=new Map,R=new Map,L=new Map(t.workspaces.map(ce=>{let me=ce.anchoredLocator.locatorHash,he=o.get(me);if(typeof he>"u")throw new Error("Assertion failed: The workspace should have an associated package");return[me,e1(he)]})),U=()=>{let ce=oe.mktempSync(),me=V.join(ce,"stacktrace.log"),he=String(E.length+1).length,Be=E.map((we,g)=>`${`${g+1}.`.padStart(he," ")} ${ba(we)} +`).join("");throw oe.writeFileSync(me,Be),oe.detachTemp(ce),new zt(45,`Encountered a stack overflow when resolving peer dependencies; cf ${ue.fromPortablePath(me)}`)},J=ce=>{let me=r.get(ce.descriptorHash);if(typeof me>"u")throw new Error("Assertion failed: The resolution should have been registered");let he=o.get(me);if(!he)throw new Error("Assertion failed: The package could not be found");return he},te=(ce,me,he,{top:Be,optional:we})=>{E.length>1e3&&U(),E.push(me);let g=ae(ce,me,he,{top:Be,optional:we});return E.pop(),g},ae=(ce,me,he,{top:Be,optional:we})=>{if(we||n.delete(me.locatorHash),a.has(me.locatorHash))return;a.add(me.locatorHash);let g=o.get(me.locatorHash);if(!g)throw new Error(`Assertion failed: The package (${jr(t.configuration,me)}) should have been registered`);let Ee=[],Se=[],le=[],ne=[],ee=[];for(let Fe of Array.from(g.dependencies.values())){if(g.peerDependencies.has(Fe.identHash)&&g.locatorHash!==Be)continue;if(bf(Fe))throw new Error("Assertion failed: Virtual packages shouldn't be encountered when virtualizing a branch");p.delete(Fe.descriptorHash);let At=we;if(!At){let Te=g.dependenciesMeta.get(fn(Fe));if(typeof Te<"u"){let Je=Te.get(null);typeof Je<"u"&&Je.optional&&(At=!0)}}let H=r.get(Fe.descriptorHash);if(!H)throw new Error(`Assertion failed: The resolution (${Gn(t.configuration,Fe)}) should have been registered`);let at=L.get(H)||o.get(H);if(!at)throw new Error(`Assertion failed: The package (${H}, resolved from ${Gn(t.configuration,Fe)}) should have been registered`);if(at.peerDependencies.size===0){te(Fe,at,new Map,{top:Be,optional:At});continue}let Re,ke,xe=new Set,He;Se.push(()=>{Re=tM(Fe,me.locatorHash),ke=rM(at,me.locatorHash),g.dependencies.delete(Fe.identHash),g.dependencies.set(Re.identHash,Re),r.set(Re.descriptorHash,ke.locatorHash),e.set(Re.descriptorHash,Re),o.set(ke.locatorHash,ke),Ee.push([at,Re,ke])}),le.push(()=>{He=new Map;for(let Te of ke.peerDependencies.values()){let Je=g.dependencies.get(Te.identHash);if(!Je&&n1(me,Te)&&(ce.identHash===me.identHash?Je=ce:(Je=In(me,ce.range),e.set(Je.descriptorHash,Je),r.set(Je.descriptorHash,me.locatorHash),p.delete(Je.descriptorHash))),(!Je||Je.range==="missing:")&&ke.dependencies.has(Te.identHash)){ke.peerDependencies.delete(Te.identHash);continue}Je||(Je=In(Te,"missing:")),ke.dependencies.set(Je.identHash,Je),bf(Je)&&md(x,Je.descriptorHash).add(ke.locatorHash),I.set(Je.identHash,Je),Je.range==="missing:"&&xe.add(Je.identHash),He.set(Te.identHash,he.get(Te.identHash)??ke.locatorHash)}ke.dependencies=new Map(ks(ke.dependencies,([Te,Je])=>fn(Je)))}),ne.push(()=>{if(!o.has(ke.locatorHash))return;let Te=h.get(at.locatorHash);typeof Te=="number"&&Te>=2&&U();let Je=h.get(at.locatorHash),je=typeof Je<"u"?Je+1:1;h.set(at.locatorHash,je),te(Re,ke,He,{top:Be,optional:At}),h.set(at.locatorHash,je-1)}),ee.push(()=>{let Te=g.dependencies.get(Fe.identHash);if(typeof Te>"u")throw new Error("Assertion failed: Expected the peer dependency to have been turned into a dependency");let Je=r.get(Te.descriptorHash);if(typeof Je>"u")throw new Error("Assertion failed: Expected the descriptor to be registered");if(md(R,Je).add(me.locatorHash),!!o.has(ke.locatorHash)){for(let je of ke.peerDependencies.values()){let b=He.get(je.identHash);if(typeof b>"u")throw new Error("Assertion failed: Expected the peer dependency ident to be registered");Yy(Wy(C,b),fn(je)).push(ke.locatorHash)}for(let je of xe)ke.dependencies.delete(je)}})}for(let Fe of[...Se,...le])Fe();let Ie;do{Ie=!0;for(let[Fe,At,H]of Ee){let at=Wy(v,Fe.locatorHash),Re=zs(...[...H.dependencies.values()].map(Te=>{let Je=Te.range!=="missing:"?r.get(Te.descriptorHash):"missing:";if(typeof Je>"u")throw new Error(`Assertion failed: Expected the resolution for ${Gn(t.configuration,Te)} to have been registered`);return Je===Be?`${Je} (top)`:Je}),At.identHash),ke=at.get(Re);if(typeof ke>"u"){at.set(Re,At);continue}if(ke===At)continue;o.delete(H.locatorHash),e.delete(At.descriptorHash),r.delete(At.descriptorHash),a.delete(H.locatorHash);let xe=x.get(At.descriptorHash)||[],He=[g.locatorHash,...xe];x.delete(At.descriptorHash);for(let Te of He){let Je=o.get(Te);typeof Je>"u"||(Je.dependencies.get(At.identHash).descriptorHash!==ke.descriptorHash&&(Ie=!1),Je.dependencies.set(At.identHash,ke))}}}while(!Ie);for(let Fe of[...ne,...ee])Fe()};for(let ce of t.workspaces){let me=ce.anchoredLocator;p.delete(ce.anchoredDescriptor.descriptorHash),te(ce.anchoredDescriptor,me,new Map,{top:me.locatorHash,optional:!1})}let fe=new Map;for(let[ce,me]of R){let he=o.get(ce);if(typeof he>"u")throw new Error("Assertion failed: Expected the root to be registered");let Be=C.get(ce);if(!(typeof Be>"u"))for(let we of me){let g=o.get(we);if(!(typeof g>"u")&&!!t.tryWorkspaceByLocator(g))for(let[Ee,Se]of Be){let le=Js(Ee);if(g.peerDependencies.has(le.identHash))continue;let ne=`p${zs(we,Ee,ce).slice(0,5)}`;u.set(ne,{subject:we,requested:le,rootRequester:ce,allRequesters:Se});let ee=he.dependencies.get(le.identHash);if(typeof ee<"u"){let Ie=J(ee),Fe=Ie.version??"0.0.0",At=new Set;for(let at of Se){let Re=o.get(at);if(typeof Re>"u")throw new Error("Assertion failed: Expected the link to be registered");let ke=Re.peerDependencies.get(le.identHash);if(typeof ke>"u")throw new Error("Assertion failed: Expected the ident to be registered");At.add(ke.range)}if(![...At].every(at=>{if(at.startsWith(Xn.protocol)){if(!t.tryWorkspaceByLocator(Ie))return!1;at=at.slice(Xn.protocol.length),(at==="^"||at==="~")&&(at="*")}return kf(Fe,at)})){let at=ol(fe,Ie.locatorHash,()=>({type:2,requested:le,subject:Ie,dependents:new Map,requesters:new Map,links:new Map,version:Fe,hash:`p${Ie.locatorHash.slice(0,5)}`}));at.dependents.set(g.locatorHash,g),at.requesters.set(he.locatorHash,he);for(let Re of Se)at.links.set(Re,o.get(Re));A.push({type:1,subject:g,requested:le,requester:he,version:Fe,hash:ne,requirementCount:Se.length})}}else he.peerDependenciesMeta.get(Ee)?.optional||A.push({type:0,subject:g,requested:le,requester:he,hash:ne})}}}A.push(...fe.values())}function QAt(t,e){let r=wL(t.peerWarnings,"type"),o=r[2]?.map(n=>{let u=Array.from(n.links.values(),E=>{let I=t.storedPackages.get(E.locatorHash);if(typeof I>"u")throw new Error("Assertion failed: Expected the package to be registered");let v=I.peerDependencies.get(n.requested.identHash);if(typeof v>"u")throw new Error("Assertion failed: Expected the ident to be registered");return v.range}),A=n.links.size>1?"and other dependencies request":"requests",p=sM(u),h=p?cE(t.configuration,p):Mt(t.configuration,"but they have non-overlapping ranges!","redBright");return`${cs(t.configuration,n.requested)} is listed by your project with version ${o1(t.configuration,n.version)}, which doesn't satisfy what ${cs(t.configuration,n.requesters.values().next().value)} (${Mt(t.configuration,n.hash,yt.CODE)}) ${A} (${h}).`})??[],a=r[0]?.map(n=>`${jr(t.configuration,n.subject)} doesn't provide ${cs(t.configuration,n.requested)} (${Mt(t.configuration,n.hash,yt.CODE)}), requested by ${cs(t.configuration,n.requester)}.`)??[];e.startSectionSync({reportFooter:()=>{e.reportWarning(86,`Some peer dependencies are incorrectly met; run ${Mt(t.configuration,"yarn explain peer-requirements <hash>",yt.CODE)} for details, where ${Mt(t.configuration,"<hash>",yt.CODE)} is the six-letter p-prefixed code.`)},skipIfEmpty:!0},()=>{for(let n of ks(o,u=>Xy.default(u)))e.reportWarning(60,n);for(let n of ks(a,u=>Xy.default(u)))e.reportWarning(2,n)})}var Zx,$x,ek,Jpe,k_,x_,Q_,tk,DAt,SAt,Kpe,PAt,bAt,xAt,pl,b_,rk,Vpe,Pt,zpe=Et(()=>{St();St();Nl();jt();Zx=ve("crypto");E_();$x=$e(D_()),ek=$e(id()),Jpe=$e(zn()),k_=ve("util"),x_=$e(ve("v8")),Q_=$e(ve("zlib"));u_();S1();A_();f_();fE();uM();Yl();Ype();O1();S_();vd();P_();WP();Gl();nh();jl();vb();BU();Qf();bo();tk=Jy(process.env.YARN_LOCKFILE_VERSION_OVERRIDE??8),DAt=3,SAt=/ *, */g,Kpe=/\/$/,PAt=32,bAt=(0,k_.promisify)(Q_.default.gzip),xAt=(0,k_.promisify)(Q_.default.gunzip),pl=(r=>(r.UpdateLockfile="update-lockfile",r.SkipBuild="skip-build",r))(pl||{}),b_={restoreLinkersCustomData:["linkersCustomData"],restoreResolutions:["accessibleLocators","conditionalLocators","disabledLocators","optionalBuilds","storedDescriptors","storedResolutions","storedPackages","lockFileChecksum"],restoreBuildState:["skippedBuilds","storedBuildState"]},rk=(o=>(o[o.NotProvided=0]="NotProvided",o[o.NotCompatible=1]="NotCompatible",o[o.NotCompatibleAggregate=2]="NotCompatibleAggregate",o))(rk||{}),Vpe=t=>zs(`${DAt}`,t),Pt=class{constructor(e,{configuration:r}){this.resolutionAliases=new Map;this.workspaces=[];this.workspacesByCwd=new Map;this.workspacesByIdent=new Map;this.storedResolutions=new Map;this.storedDescriptors=new Map;this.storedPackages=new Map;this.storedChecksums=new Map;this.storedBuildState=new Map;this.accessibleLocators=new Set;this.conditionalLocators=new Set;this.disabledLocators=new Set;this.originalPackages=new Map;this.optionalBuilds=new Set;this.skippedBuilds=new Set;this.lockfileLastVersion=null;this.lockfileNeedsRefresh=!1;this.peerRequirements=new Map;this.peerWarnings=[];this.linkersCustomData=new Map;this.lockFileChecksum=null;this.installStateChecksum=null;this.configuration=r,this.cwd=e}static async find(e,r){if(!e.projectCwd)throw new it(`No project found in ${r}`);let o=e.projectCwd,a=r,n=null;for(;n!==e.projectCwd;){if(n=a,oe.existsSync(V.join(n,dr.manifest))){o=n;break}a=V.dirname(n)}let u=new Pt(e.projectCwd,{configuration:e});Ke.telemetry?.reportProject(u.cwd),await u.setupResolutions(),await u.setupWorkspaces(),Ke.telemetry?.reportWorkspaceCount(u.workspaces.length),Ke.telemetry?.reportDependencyCount(u.workspaces.reduce((C,R)=>C+R.manifest.dependencies.size+R.manifest.devDependencies.size,0));let A=u.tryWorkspaceByCwd(o);if(A)return{project:u,workspace:A,locator:A.anchoredLocator};let p=await u.findLocatorForLocation(`${o}/`,{strict:!0});if(p)return{project:u,locator:p,workspace:null};let h=Mt(e,u.cwd,yt.PATH),E=Mt(e,V.relative(u.cwd,o),yt.PATH),I=`- If ${h} isn't intended to be a project, remove any yarn.lock and/or package.json file there.`,v=`- If ${h} is intended to be a project, it might be that you forgot to list ${E} in its workspace configuration.`,x=`- Finally, if ${h} is fine and you intend ${E} to be treated as a completely separate project (not even a workspace), create an empty yarn.lock file in it.`;throw new it(`The nearest package directory (${Mt(e,o,yt.PATH)}) doesn't seem to be part of the project declared in ${Mt(e,u.cwd,yt.PATH)}. + +${[I,v,x].join(` +`)}`)}async setupResolutions(){this.storedResolutions=new Map,this.storedDescriptors=new Map,this.storedPackages=new Map,this.lockFileChecksum=null;let e=V.join(this.cwd,dr.lockfile),r=this.configuration.get("defaultLanguageName");if(oe.existsSync(e)){let o=await oe.readFilePromise(e,"utf8");this.lockFileChecksum=Vpe(o);let a=Ki(o);if(a.__metadata){let n=a.__metadata.version,u=a.__metadata.cacheKey;this.lockfileLastVersion=n,this.lockfileNeedsRefresh=n<tk;for(let A of Object.keys(a)){if(A==="__metadata")continue;let p=a[A];if(typeof p.resolution>"u")throw new Error(`Assertion failed: Expected the lockfile entry to have a resolution field (${A})`);let h=xf(p.resolution,!0),E=new Ot;E.load(p,{yamlCompatibilityMode:!0});let I=E.version,v=E.languageName||r,x=p.linkType.toUpperCase(),C=p.conditions??null,R=E.dependencies,L=E.peerDependencies,U=E.dependenciesMeta,J=E.peerDependenciesMeta,te=E.bin;if(p.checksum!=null){let fe=typeof u<"u"&&!p.checksum.includes("/")?`${u}/${p.checksum}`:p.checksum;this.storedChecksums.set(h.locatorHash,fe)}let ae={...h,version:I,languageName:v,linkType:x,conditions:C,dependencies:R,peerDependencies:L,dependenciesMeta:U,peerDependenciesMeta:J,bin:te};this.originalPackages.set(ae.locatorHash,ae);for(let fe of A.split(SAt)){let ce=ih(fe);n<=6&&(ce=this.configuration.normalizeDependency(ce),ce=In(ce,ce.range.replace(/^patch:[^@]+@(?!npm(:|%3A))/,"$1npm%3A"))),this.storedDescriptors.set(ce.descriptorHash,ce),this.storedResolutions.set(ce.descriptorHash,h.locatorHash)}}}else o.includes("yarn lockfile v1")&&(this.lockfileLastVersion=-1)}}async setupWorkspaces(){this.workspaces=[],this.workspacesByCwd=new Map,this.workspacesByIdent=new Map;let e=new Set,r=(0,ek.default)(4),o=async(a,n)=>{if(e.has(n))return a;e.add(n);let u=new cC(n,{project:this});await r(()=>u.setup());let A=a.then(()=>{this.addWorkspace(u)});return Array.from(u.workspacesCwds).reduce(o,A)};await o(Promise.resolve(),this.cwd)}addWorkspace(e){let r=this.workspacesByIdent.get(e.anchoredLocator.identHash);if(typeof r<"u")throw new Error(`Duplicate workspace name ${cs(this.configuration,e.anchoredLocator)}: ${ue.fromPortablePath(e.cwd)} conflicts with ${ue.fromPortablePath(r.cwd)}`);this.workspaces.push(e),this.workspacesByCwd.set(e.cwd,e),this.workspacesByIdent.set(e.anchoredLocator.identHash,e)}get topLevelWorkspace(){return this.getWorkspaceByCwd(this.cwd)}tryWorkspaceByCwd(e){V.isAbsolute(e)||(e=V.resolve(this.cwd,e)),e=V.normalize(e).replace(/\/+$/,"");let r=this.workspacesByCwd.get(e);return r||null}getWorkspaceByCwd(e){let r=this.tryWorkspaceByCwd(e);if(!r)throw new Error(`Workspace not found (${e})`);return r}tryWorkspaceByFilePath(e){let r=null;for(let o of this.workspaces)V.relative(o.cwd,e).startsWith("../")||r&&r.cwd.length>=o.cwd.length||(r=o);return r||null}getWorkspaceByFilePath(e){let r=this.tryWorkspaceByFilePath(e);if(!r)throw new Error(`Workspace not found (${e})`);return r}tryWorkspaceByIdent(e){let r=this.workspacesByIdent.get(e.identHash);return typeof r>"u"?null:r}getWorkspaceByIdent(e){let r=this.tryWorkspaceByIdent(e);if(!r)throw new Error(`Workspace not found (${cs(this.configuration,e)})`);return r}tryWorkspaceByDescriptor(e){if(e.range.startsWith(Xn.protocol)){let o=e.range.slice(Xn.protocol.length);if(o!=="^"&&o!=="~"&&o!=="*"&&!xa(o))return this.tryWorkspaceByCwd(o)}let r=this.tryWorkspaceByIdent(e);return r===null||(bf(e)&&(e=t1(e)),!r.accepts(e.range))?null:r}getWorkspaceByDescriptor(e){let r=this.tryWorkspaceByDescriptor(e);if(r===null)throw new Error(`Workspace not found (${Gn(this.configuration,e)})`);return r}tryWorkspaceByLocator(e){let r=this.tryWorkspaceByIdent(e);return r===null||(Hc(e)&&(e=r1(e)),r.anchoredLocator.locatorHash!==e.locatorHash)?null:r}getWorkspaceByLocator(e){let r=this.tryWorkspaceByLocator(e);if(!r)throw new Error(`Workspace not found (${jr(this.configuration,e)})`);return r}deleteDescriptor(e){this.storedResolutions.delete(e),this.storedDescriptors.delete(e)}deleteLocator(e){this.originalPackages.delete(e),this.storedPackages.delete(e),this.accessibleLocators.delete(e)}forgetResolution(e){if("descriptorHash"in e){let r=this.storedResolutions.get(e.descriptorHash);this.deleteDescriptor(e.descriptorHash);let o=new Set(this.storedResolutions.values());typeof r<"u"&&!o.has(r)&&this.deleteLocator(r)}if("locatorHash"in e){this.deleteLocator(e.locatorHash);for(let[r,o]of this.storedResolutions)o===e.locatorHash&&this.deleteDescriptor(r)}}forgetTransientResolutions(){let e=this.configuration.makeResolver(),r=new Map;for(let[o,a]of this.storedResolutions.entries()){let n=r.get(a);n||r.set(a,n=new Set),n.add(o)}for(let o of this.originalPackages.values()){let a;try{a=e.shouldPersistResolution(o,{project:this,resolver:e})}catch{a=!1}if(!a){this.deleteLocator(o.locatorHash);let n=r.get(o.locatorHash);if(n){r.delete(o.locatorHash);for(let u of n)this.deleteDescriptor(u)}}}}forgetVirtualResolutions(){for(let e of this.storedPackages.values())for(let[r,o]of e.dependencies)bf(o)&&e.dependencies.set(r,t1(o))}getDependencyMeta(e,r){let o={},n=this.topLevelWorkspace.manifest.dependenciesMeta.get(fn(e));if(!n)return o;let u=n.get(null);if(u&&Object.assign(o,u),r===null||!Jpe.default.valid(r))return o;for(let[A,p]of n)A!==null&&A===r&&Object.assign(o,p);return o}async findLocatorForLocation(e,{strict:r=!1}={}){let o=new Qi,a=this.configuration.getLinkers(),n={project:this,report:o};for(let u of a){let A=await u.findPackageLocator(e,n);if(A){if(r&&(await u.findPackageLocation(A,n)).replace(Kpe,"")!==e.replace(Kpe,""))continue;return A}}return null}async loadUserConfig(){let e=V.join(this.cwd,".pnp.cjs");await oe.existsPromise(e)&&Df(e).setup();let r=V.join(this.cwd,"yarn.config.cjs");return await oe.existsPromise(r)?Df(r):null}async preparePackage(e,{resolver:r,resolveOptions:o}){let a=await this.configuration.getPackageExtensions(),n=this.configuration.normalizePackage(e,{packageExtensions:a});for(let[u,A]of n.dependencies){let p=await this.configuration.reduceHook(E=>E.reduceDependency,A,this,n,A,{resolver:r,resolveOptions:o});if(!n1(A,p))throw new Error("Assertion failed: The descriptor ident cannot be changed through aliases");let h=r.bindDescriptor(p,n,o);n.dependencies.set(u,h)}return n}async resolveEverything(e){if(!this.workspacesByCwd||!this.workspacesByIdent)throw new Error("Workspaces must have been setup before calling this function");this.forgetVirtualResolutions();let r=new Map(this.originalPackages),o=[];e.lockfileOnly||this.forgetTransientResolutions();let a=e.resolver||this.configuration.makeResolver(),n=new oC(a);await n.setup(this,{report:e.report});let u=e.lockfileOnly?[new Xx(a)]:[n,a],A=new Dd([new aC(a),...u]),p=new Dd([...u]),h=this.configuration.makeFetcher(),E=e.lockfileOnly?{project:this,report:e.report,resolver:A}:{project:this,report:e.report,resolver:A,fetchOptions:{project:this,cache:e.cache,checksums:this.storedChecksums,report:e.report,fetcher:h,cacheOptions:{mirrorWriteOnly:!0}}},I=new Map,v=new Map,x=new Map,C=new Map,R=new Map,L=new Map,U=this.topLevelWorkspace.anchoredLocator,J=new Set,te=[],ae=M4(),fe=this.configuration.getSupportedArchitectures();await e.report.startProgressPromise(Xs.progressViaTitle(),async le=>{let ne=async H=>{let at=await Ky(async()=>await A.resolve(H,E),He=>`${jr(this.configuration,H)}: ${He}`);if(!i1(H,at))throw new Error(`Assertion failed: The locator cannot be changed by the resolver (went from ${jr(this.configuration,H)} to ${jr(this.configuration,at)})`);C.set(at.locatorHash,at),!r.delete(at.locatorHash)&&!this.tryWorkspaceByLocator(at)&&o.push(at);let ke=await this.preparePackage(at,{resolver:A,resolveOptions:E}),xe=Uc([...ke.dependencies.values()].map(He=>At(He)));return te.push(xe),xe.catch(()=>{}),v.set(ke.locatorHash,ke),ke},ee=async H=>{let at=R.get(H.locatorHash);if(typeof at<"u")return at;let Re=Promise.resolve().then(()=>ne(H));return R.set(H.locatorHash,Re),Re},Ie=async(H,at)=>{let Re=await At(at);return I.set(H.descriptorHash,H),x.set(H.descriptorHash,Re.locatorHash),Re},Fe=async H=>{le.setTitle(Gn(this.configuration,H));let at=this.resolutionAliases.get(H.descriptorHash);if(typeof at<"u")return Ie(H,this.storedDescriptors.get(at));let Re=A.getResolutionDependencies(H,E),ke=Object.fromEntries(await Uc(Object.entries(Re).map(async([Te,Je])=>{let je=A.bindDescriptor(Je,U,E),b=await At(je);return J.add(b.locatorHash),[Te,b]}))),He=(await Ky(async()=>await A.getCandidates(H,ke,E),Te=>`${Gn(this.configuration,H)}: ${Te}`))[0];if(typeof He>"u")throw new zt(82,`${Gn(this.configuration,H)}: No candidates found`);if(e.checkResolutions){let{locators:Te}=await p.getSatisfying(H,ke,[He],{...E,resolver:p});if(!Te.find(Je=>Je.locatorHash===He.locatorHash))throw new zt(78,`Invalid resolution ${ZI(this.configuration,H,He)}`)}return I.set(H.descriptorHash,H),x.set(H.descriptorHash,He.locatorHash),ee(He)},At=H=>{let at=L.get(H.descriptorHash);if(typeof at<"u")return at;I.set(H.descriptorHash,H);let Re=Promise.resolve().then(()=>Fe(H));return L.set(H.descriptorHash,Re),Re};for(let H of this.workspaces){let at=H.anchoredDescriptor;te.push(At(at))}for(;te.length>0;){let H=[...te];te.length=0,await Uc(H)}});let ce=sl(r.values(),le=>this.tryWorkspaceByLocator(le)?sl.skip:le);if(o.length>0||ce.length>0){let le=new Set(this.workspaces.flatMap(H=>{let at=v.get(H.anchoredLocator.locatorHash);if(!at)throw new Error("Assertion failed: The workspace should have been resolved");return Array.from(at.dependencies.values(),Re=>{let ke=x.get(Re.descriptorHash);if(!ke)throw new Error("Assertion failed: The resolution should have been registered");return ke})})),ne=H=>le.has(H.locatorHash)?"0":"1",ee=H=>ba(H),Ie=ks(o,[ne,ee]),Fe=ks(ce,[ne,ee]),At=e.report.getRecommendedLength();Ie.length>0&&e.report.reportInfo(85,`${Mt(this.configuration,"+",yt.ADDED)} ${cP(this.configuration,Ie,At)}`),Fe.length>0&&e.report.reportInfo(85,`${Mt(this.configuration,"-",yt.REMOVED)} ${cP(this.configuration,Fe,At)}`)}let me=new Set(this.resolutionAliases.values()),he=new Set(v.keys()),Be=new Set,we=new Map,g=[];kAt({project:this,accessibleLocators:Be,volatileDescriptors:me,optionalBuilds:he,peerRequirements:we,peerWarnings:g,allDescriptors:I,allResolutions:x,allPackages:v});for(let le of J)he.delete(le);for(let le of me)I.delete(le),x.delete(le);let Ee=new Set,Se=new Set;for(let le of v.values())le.conditions!=null&&(!he.has(le.locatorHash)||(jP(le,fe)||(jP(le,ae)&&e.report.reportWarningOnce(77,`${jr(this.configuration,le)}: Your current architecture (${process.platform}-${process.arch}) is supported by this package, but is missing from the ${Mt(this.configuration,"supportedArchitectures",yt.SETTING)} setting`),Se.add(le.locatorHash)),Ee.add(le.locatorHash)));this.storedResolutions=x,this.storedDescriptors=I,this.storedPackages=v,this.accessibleLocators=Be,this.conditionalLocators=Ee,this.disabledLocators=Se,this.originalPackages=C,this.optionalBuilds=he,this.peerRequirements=we,this.peerWarnings=g}async fetchEverything({cache:e,report:r,fetcher:o,mode:a,persistProject:n=!0}){let u={mockedPackages:this.disabledLocators,unstablePackages:this.conditionalLocators},A=o||this.configuration.makeFetcher(),p={checksums:this.storedChecksums,project:this,cache:e,fetcher:A,report:r,cacheOptions:u},h=Array.from(new Set(ks(this.storedResolutions.values(),[C=>{let R=this.storedPackages.get(C);if(!R)throw new Error("Assertion failed: The locator should have been registered");return ba(R)}])));a==="update-lockfile"&&(h=h.filter(C=>!this.storedChecksums.has(C)));let E=!1,I=Xs.progressViaCounter(h.length);await r.reportProgress(I);let v=(0,ek.default)(PAt);if(await Uc(h.map(C=>v(async()=>{let R=this.storedPackages.get(C);if(!R)throw new Error("Assertion failed: The locator should have been registered");if(Hc(R))return;let L;try{L=await A.fetch(R,p)}catch(U){U.message=`${jr(this.configuration,R)}: ${U.message}`,r.reportExceptionOnce(U),E=U;return}L.checksum!=null?this.storedChecksums.set(R.locatorHash,L.checksum):this.storedChecksums.delete(R.locatorHash),L.releaseFs&&L.releaseFs()}).finally(()=>{I.tick()}))),E)throw E;let x=n&&a!=="update-lockfile"?await this.cacheCleanup({cache:e,report:r}):null;if(r.cacheMisses.size>0||x){let R=(await Promise.all([...r.cacheMisses].map(async ce=>{let me=this.storedPackages.get(ce),he=this.storedChecksums.get(ce)??null,Be=e.getLocatorPath(me,he);return(await oe.statPromise(Be)).size}))).reduce((ce,me)=>ce+me,0)-(x?.size??0),L=r.cacheMisses.size,U=x?.count??0,J=`${nP(L,{zero:"No new packages",one:"A package was",more:`${Mt(this.configuration,L,yt.NUMBER)} packages were`})} added to the project`,te=`${nP(U,{zero:"none were",one:"one was",more:`${Mt(this.configuration,U,yt.NUMBER)} were`})} removed`,ae=R!==0?` (${Mt(this.configuration,R,yt.SIZE_DIFF)})`:"",fe=U>0?L>0?`${J}, and ${te}${ae}.`:`${J}, but ${te}${ae}.`:`${J}${ae}.`;r.reportInfo(13,fe)}}async linkEverything({cache:e,report:r,fetcher:o,mode:a}){let n={mockedPackages:this.disabledLocators,unstablePackages:this.conditionalLocators,skipIntegrityCheck:!0},u=o||this.configuration.makeFetcher(),A={checksums:this.storedChecksums,project:this,cache:e,fetcher:u,report:r,cacheOptions:n},p=this.configuration.getLinkers(),h={project:this,report:r},E=new Map(p.map(le=>{let ne=le.makeInstaller(h),ee=le.getCustomDataKey(),Ie=this.linkersCustomData.get(ee);return typeof Ie<"u"&&ne.attachCustomData(Ie),[le,ne]})),I=new Map,v=new Map,x=new Map,C=new Map(await Uc([...this.accessibleLocators].map(async le=>{let ne=this.storedPackages.get(le);if(!ne)throw new Error("Assertion failed: The locator should have been registered");return[le,await u.fetch(ne,A)]}))),R=[],L=new Set,U=[];for(let le of this.accessibleLocators){let ne=this.storedPackages.get(le);if(typeof ne>"u")throw new Error("Assertion failed: The locator should have been registered");let ee=C.get(ne.locatorHash);if(typeof ee>"u")throw new Error("Assertion failed: The fetch result should have been registered");let Ie=[],Fe=H=>{Ie.push(H)},At=this.tryWorkspaceByLocator(ne);if(At!==null){let H=[],{scripts:at}=At.manifest;for(let ke of["preinstall","install","postinstall"])at.has(ke)&&H.push({type:0,script:ke});try{for(let[ke,xe]of E)if(ke.supportsPackage(ne,h)&&(await xe.installPackage(ne,ee,{holdFetchResult:Fe})).buildRequest!==null)throw new Error("Assertion failed: Linkers can't return build directives for workspaces; this responsibility befalls to the Yarn core")}finally{Ie.length===0?ee.releaseFs?.():R.push(Uc(Ie).catch(()=>{}).then(()=>{ee.releaseFs?.()}))}let Re=V.join(ee.packageFs.getRealPath(),ee.prefixPath);v.set(ne.locatorHash,Re),!Hc(ne)&&H.length>0&&x.set(ne.locatorHash,{buildDirectives:H,buildLocations:[Re]})}else{let H=p.find(ke=>ke.supportsPackage(ne,h));if(!H)throw new zt(12,`${jr(this.configuration,ne)} isn't supported by any available linker`);let at=E.get(H);if(!at)throw new Error("Assertion failed: The installer should have been registered");let Re;try{Re=await at.installPackage(ne,ee,{holdFetchResult:Fe})}finally{Ie.length===0?ee.releaseFs?.():R.push(Uc(Ie).then(()=>{}).then(()=>{ee.releaseFs?.()}))}I.set(ne.locatorHash,H),v.set(ne.locatorHash,Re.packageLocation),Re.buildRequest&&Re.packageLocation&&(Re.buildRequest.skipped?(L.add(ne.locatorHash),this.skippedBuilds.has(ne.locatorHash)||U.push([ne,Re.buildRequest.explain])):x.set(ne.locatorHash,{buildDirectives:Re.buildRequest.directives,buildLocations:[Re.packageLocation]}))}}let J=new Map;for(let le of this.accessibleLocators){let ne=this.storedPackages.get(le);if(!ne)throw new Error("Assertion failed: The locator should have been registered");let ee=this.tryWorkspaceByLocator(ne)!==null,Ie=async(Fe,At)=>{let H=v.get(ne.locatorHash);if(typeof H>"u")throw new Error(`Assertion failed: The package (${jr(this.configuration,ne)}) should have been registered`);let at=[];for(let Re of ne.dependencies.values()){let ke=this.storedResolutions.get(Re.descriptorHash);if(typeof ke>"u")throw new Error(`Assertion failed: The resolution (${Gn(this.configuration,Re)}, from ${jr(this.configuration,ne)})should have been registered`);let xe=this.storedPackages.get(ke);if(typeof xe>"u")throw new Error(`Assertion failed: The package (${ke}, resolved from ${Gn(this.configuration,Re)}) should have been registered`);let He=this.tryWorkspaceByLocator(xe)===null?I.get(ke):null;if(typeof He>"u")throw new Error(`Assertion failed: The package (${ke}, resolved from ${Gn(this.configuration,Re)}) should have been registered`);He===Fe||He===null?v.get(xe.locatorHash)!==null&&at.push([Re,xe]):!ee&&H!==null&&Yy(J,ke).push(H)}H!==null&&await At.attachInternalDependencies(ne,at)};if(ee)for(let[Fe,At]of E)Fe.supportsPackage(ne,h)&&await Ie(Fe,At);else{let Fe=I.get(ne.locatorHash);if(!Fe)throw new Error("Assertion failed: The linker should have been found");let At=E.get(Fe);if(!At)throw new Error("Assertion failed: The installer should have been registered");await Ie(Fe,At)}}for(let[le,ne]of J){let ee=this.storedPackages.get(le);if(!ee)throw new Error("Assertion failed: The package should have been registered");let Ie=I.get(ee.locatorHash);if(!Ie)throw new Error("Assertion failed: The linker should have been found");let Fe=E.get(Ie);if(!Fe)throw new Error("Assertion failed: The installer should have been registered");await Fe.attachExternalDependents(ee,ne)}let te=new Map;for(let[le,ne]of E){let ee=await ne.finalizeInstall();for(let Ie of ee?.records??[])Ie.buildRequest.skipped?(L.add(Ie.locator.locatorHash),this.skippedBuilds.has(Ie.locator.locatorHash)||U.push([Ie.locator,Ie.buildRequest.explain])):x.set(Ie.locator.locatorHash,{buildDirectives:Ie.buildRequest.directives,buildLocations:Ie.buildLocations});typeof ee?.customData<"u"&&te.set(le.getCustomDataKey(),ee.customData)}if(this.linkersCustomData=te,await Uc(R),a==="skip-build")return;for(let[,le]of ks(U,([ne])=>ba(ne)))le(r);let ae=new Set(this.storedPackages.keys()),fe=new Set(x.keys());for(let le of fe)ae.delete(le);let ce=(0,Zx.createHash)("sha512");ce.update(process.versions.node),await this.configuration.triggerHook(le=>le.globalHashGeneration,this,le=>{ce.update("\0"),ce.update(le)});let me=ce.digest("hex"),he=new Map,Be=le=>{let ne=he.get(le.locatorHash);if(typeof ne<"u")return ne;let ee=this.storedPackages.get(le.locatorHash);if(typeof ee>"u")throw new Error("Assertion failed: The package should have been registered");let Ie=(0,Zx.createHash)("sha512");Ie.update(le.locatorHash),he.set(le.locatorHash,"<recursive>");for(let Fe of ee.dependencies.values()){let At=this.storedResolutions.get(Fe.descriptorHash);if(typeof At>"u")throw new Error(`Assertion failed: The resolution (${Gn(this.configuration,Fe)}) should have been registered`);let H=this.storedPackages.get(At);if(typeof H>"u")throw new Error("Assertion failed: The package should have been registered");Ie.update(Be(H))}return ne=Ie.digest("hex"),he.set(le.locatorHash,ne),ne},we=(le,ne)=>{let ee=(0,Zx.createHash)("sha512");ee.update(me),ee.update(Be(le));for(let Ie of ne)ee.update(Ie);return ee.digest("hex")},g=new Map,Ee=!1,Se=le=>{let ne=new Set([le.locatorHash]);for(let ee of ne){let Ie=this.storedPackages.get(ee);if(!Ie)throw new Error("Assertion failed: The package should have been registered");for(let Fe of Ie.dependencies.values()){let At=this.storedResolutions.get(Fe.descriptorHash);if(!At)throw new Error(`Assertion failed: The resolution (${Gn(this.configuration,Fe)}) should have been registered`);if(At!==le.locatorHash&&fe.has(At))return!1;let H=this.storedPackages.get(At);if(!H)throw new Error("Assertion failed: The package should have been registered");let at=this.tryWorkspaceByLocator(H);if(at){if(at.anchoredLocator.locatorHash!==le.locatorHash&&fe.has(at.anchoredLocator.locatorHash))return!1;ne.add(at.anchoredLocator.locatorHash)}ne.add(At)}}return!0};for(;fe.size>0;){let le=fe.size,ne=[];for(let ee of fe){let Ie=this.storedPackages.get(ee);if(!Ie)throw new Error("Assertion failed: The package should have been registered");if(!Se(Ie))continue;let Fe=x.get(Ie.locatorHash);if(!Fe)throw new Error("Assertion failed: The build directive should have been registered");let At=we(Ie,Fe.buildLocations);if(this.storedBuildState.get(Ie.locatorHash)===At){g.set(Ie.locatorHash,At),fe.delete(ee);continue}Ee||(await this.persistInstallStateFile(),Ee=!0),this.storedBuildState.has(Ie.locatorHash)?r.reportInfo(8,`${jr(this.configuration,Ie)} must be rebuilt because its dependency tree changed`):r.reportInfo(7,`${jr(this.configuration,Ie)} must be built because it never has been before or the last one failed`);let H=Fe.buildLocations.map(async at=>{if(!V.isAbsolute(at))throw new Error(`Assertion failed: Expected the build location to be absolute (not ${at})`);for(let Re of Fe.buildDirectives){let ke=`# This file contains the result of Yarn building a package (${ba(Ie)}) +`;switch(Re.type){case 0:ke+=`# Script name: ${Re.script} +`;break;case 1:ke+=`# Script code: ${Re.script} +`;break}let xe=null;if(!await oe.mktempPromise(async Te=>{let Je=V.join(Te,"build.log"),{stdout:je,stderr:b}=this.configuration.getSubprocessStreams(Je,{header:ke,prefix:jr(this.configuration,Ie),report:r}),w;try{switch(Re.type){case 0:w=await Wb(Ie,Re.script,[],{cwd:at,project:this,stdin:xe,stdout:je,stderr:b});break;case 1:w=await EU(Ie,Re.script,[],{cwd:at,project:this,stdin:xe,stdout:je,stderr:b});break}}catch(F){b.write(F.stack),w=1}if(je.end(),b.end(),w===0)return!0;oe.detachTemp(Te);let P=`${jr(this.configuration,Ie)} couldn't be built successfully (exit code ${Mt(this.configuration,w,yt.NUMBER)}, logs can be found here: ${Mt(this.configuration,Je,yt.PATH)})`,y=this.optionalBuilds.has(Ie.locatorHash);return y?r.reportInfo(9,P):r.reportError(9,P),Kce&&r.reportFold(ue.fromPortablePath(Je),oe.readFileSync(Je,"utf8")),y}))return!1}return!0});ne.push(...H,Promise.allSettled(H).then(at=>{fe.delete(ee),at.every(Re=>Re.status==="fulfilled"&&Re.value===!0)&&g.set(Ie.locatorHash,At)}))}if(await Uc(ne),le===fe.size){let ee=Array.from(fe).map(Ie=>{let Fe=this.storedPackages.get(Ie);if(!Fe)throw new Error("Assertion failed: The package should have been registered");return jr(this.configuration,Fe)}).join(", ");r.reportError(3,`Some packages have circular dependencies that make their build order unsatisfiable - as a result they won't be built (affected packages are: ${ee})`);break}}this.storedBuildState=g,this.skippedBuilds=L}async installWithNewReport(e,r){return(await Nt.start({configuration:this.configuration,json:e.json,stdout:e.stdout,forceSectionAlignment:!0,includeLogs:!e.json&&!e.quiet,includeVersion:!0},async a=>{await this.install({...r,report:a})})).exitCode()}async install(e){let r=this.configuration.get("nodeLinker");Ke.telemetry?.reportInstall(r);let o=!1;if(await e.report.startTimerPromise("Project validation",{skipIfEmpty:!0},async()=>{this.configuration.get("enableOfflineMode")&&e.report.reportWarning(90,"Offline work is enabled; Yarn won't fetch packages from the remote registry if it can avoid it"),await this.configuration.triggerHook(E=>E.validateProject,this,{reportWarning:(E,I)=>{e.report.reportWarning(E,I)},reportError:(E,I)=>{e.report.reportError(E,I),o=!0}})}),o)return;let a=await this.configuration.getPackageExtensions();for(let E of a.values())for(let[,I]of E)for(let v of I)v.status="inactive";let n=V.join(this.cwd,dr.lockfile),u=null;if(e.immutable)try{u=await oe.readFilePromise(n,"utf8")}catch(E){throw E.code==="ENOENT"?new zt(28,"The lockfile would have been created by this install, which is explicitly forbidden."):E}await e.report.startTimerPromise("Resolution step",async()=>{await this.resolveEverything(e)}),await e.report.startTimerPromise("Post-resolution validation",{skipIfEmpty:!0},async()=>{QAt(this,e.report);for(let[,E]of a)for(let[,I]of E)for(let v of I)if(v.userProvided){let x=Mt(this.configuration,v,yt.PACKAGE_EXTENSION);switch(v.status){case"inactive":e.report.reportWarning(68,`${x}: No matching package in the dependency tree; you may not need this rule anymore.`);break;case"redundant":e.report.reportWarning(69,`${x}: This rule seems redundant when applied on the original package; the extension may have been applied upstream.`);break}}if(u!==null){let E=_g(u,this.generateLockfile());if(E!==u){let I=Ape(n,n,u,E,void 0,void 0,{maxEditLength:100});if(I){e.report.reportSeparator();for(let v of I.hunks){e.report.reportInfo(null,`@@ -${v.oldStart},${v.oldLines} +${v.newStart},${v.newLines} @@`);for(let x of v.lines)x.startsWith("+")?e.report.reportError(28,Mt(this.configuration,x,yt.ADDED)):x.startsWith("-")?e.report.reportError(28,Mt(this.configuration,x,yt.REMOVED)):e.report.reportInfo(null,Mt(this.configuration,x,"grey"))}e.report.reportSeparator()}throw new zt(28,"The lockfile would have been modified by this install, which is explicitly forbidden.")}}});for(let E of a.values())for(let[,I]of E)for(let v of I)v.userProvided&&v.status==="active"&&Ke.telemetry?.reportPackageExtension(Ed(v,yt.PACKAGE_EXTENSION));await e.report.startTimerPromise("Fetch step",async()=>{await this.fetchEverything(e)});let A=e.immutable?[...new Set(this.configuration.get("immutablePatterns"))].sort():[],p=await Promise.all(A.map(async E=>LP(E,{cwd:this.cwd})));(typeof e.persistProject>"u"||e.persistProject)&&await this.persist(),await e.report.startTimerPromise("Link step",async()=>{if(e.mode==="update-lockfile"){e.report.reportWarning(73,`Skipped due to ${Mt(this.configuration,"mode=update-lockfile",yt.CODE)}`);return}await this.linkEverything(e);let E=await Promise.all(A.map(async I=>LP(I,{cwd:this.cwd})));for(let I=0;I<A.length;++I)p[I]!==E[I]&&e.report.reportError(64,`The checksum for ${A[I]} has been modified by this install, which is explicitly forbidden.`)}),await this.persistInstallStateFile();let h=!1;await e.report.startTimerPromise("Post-install validation",{skipIfEmpty:!0},async()=>{await this.configuration.triggerHook(E=>E.validateProjectAfterInstall,this,{reportWarning:(E,I)=>{e.report.reportWarning(E,I)},reportError:(E,I)=>{e.report.reportError(E,I),h=!0}})}),!h&&await this.configuration.triggerHook(E=>E.afterAllInstalled,this,e)}generateLockfile(){let e=new Map;for(let[n,u]of this.storedResolutions.entries()){let A=e.get(u);A||e.set(u,A=new Set),A.add(n)}let r={},{cacheKey:o}=Lr.getCacheKey(this.configuration);r.__metadata={version:tk,cacheKey:o};for(let[n,u]of e.entries()){let A=this.originalPackages.get(n);if(!A)continue;let p=[];for(let I of u){let v=this.storedDescriptors.get(I);if(!v)throw new Error("Assertion failed: The descriptor should have been registered");p.push(v)}let h=p.map(I=>Pa(I)).sort().join(", "),E=new Ot;E.version=A.linkType==="HARD"?A.version:"0.0.0-use.local",E.languageName=A.languageName,E.dependencies=new Map(A.dependencies),E.peerDependencies=new Map(A.peerDependencies),E.dependenciesMeta=new Map(A.dependenciesMeta),E.peerDependenciesMeta=new Map(A.peerDependenciesMeta),E.bin=new Map(A.bin),r[h]={...E.exportTo({},{compatibilityMode:!1}),linkType:A.linkType.toLowerCase(),resolution:ba(A),checksum:this.storedChecksums.get(A.locatorHash),conditions:A.conditions||void 0}}return`${[`# This file is generated by running "yarn install" inside your project. +`,`# Manual changes might be lost - proceed with caution! +`].join("")} +`+Ba(r)}async persistLockfile(){let e=V.join(this.cwd,dr.lockfile),r="";try{r=await oe.readFilePromise(e,"utf8")}catch{}let o=this.generateLockfile(),a=_g(r,o);a!==r&&(await oe.writeFilePromise(e,a),this.lockFileChecksum=Vpe(a),this.lockfileNeedsRefresh=!1)}async persistInstallStateFile(){let e=[];for(let u of Object.values(b_))e.push(...u);let r=(0,$x.default)(this,e),o=x_.default.serialize(r),a=zs(o);if(this.installStateChecksum===a)return;let n=this.configuration.get("installStatePath");await oe.mkdirPromise(V.dirname(n),{recursive:!0}),await oe.writeFilePromise(n,await bAt(o)),this.installStateChecksum=a}async restoreInstallState({restoreLinkersCustomData:e=!0,restoreResolutions:r=!0,restoreBuildState:o=!0}={}){let a=this.configuration.get("installStatePath"),n;try{let u=await xAt(await oe.readFilePromise(a));n=x_.default.deserialize(u),this.installStateChecksum=zs(u)}catch{r&&await this.applyLightResolution();return}e&&typeof n.linkersCustomData<"u"&&(this.linkersCustomData=n.linkersCustomData),o&&Object.assign(this,(0,$x.default)(n,b_.restoreBuildState)),r&&(n.lockFileChecksum===this.lockFileChecksum?Object.assign(this,(0,$x.default)(n,b_.restoreResolutions)):await this.applyLightResolution())}async applyLightResolution(){await this.resolveEverything({lockfileOnly:!0,report:new Qi}),await this.persistInstallStateFile()}async persist(){let e=(0,ek.default)(4);await Promise.all([this.persistLockfile(),...this.workspaces.map(r=>e(()=>r.persistManifest()))])}async cacheCleanup({cache:e,report:r}){if(this.configuration.get("enableGlobalCache"))return null;let o=new Set([".gitignore"]);if(!CM(e.cwd,this.cwd)||!await oe.existsPromise(e.cwd))return null;let a=[];for(let u of await oe.readdirPromise(e.cwd)){if(o.has(u))continue;let A=V.resolve(e.cwd,u);e.markedFiles.has(A)||(e.immutable?r.reportError(56,`${Mt(this.configuration,V.basename(A),"magenta")} appears to be unused and would be marked for deletion, but the cache is immutable`):a.push(oe.lstatPromise(A).then(async p=>(await oe.removePromise(A),p.size))))}if(a.length===0)return null;let n=await Promise.all(a);return{count:a.length,size:n.reduce((u,A)=>u+A,0)}}}});function FAt(t){let o=Math.floor(t.timeNow/864e5),a=t.updateInterval*864e5,n=t.state.lastUpdate??t.timeNow+a+Math.floor(a*t.randomInitialInterval),u=n+a,A=t.state.lastTips??o*864e5,p=A+864e5+8*36e5-t.timeZone,h=u<=t.timeNow,E=p<=t.timeNow,I=null;return(h||E||!t.state.lastUpdate||!t.state.lastTips)&&(I={},I.lastUpdate=h?t.timeNow:n,I.lastTips=A,I.blocks=h?{}:t.state.blocks,I.displayedTips=t.state.displayedTips),{nextState:I,triggerUpdate:h,triggerTips:E,nextTips:E?o*864e5:A}}var uC,Xpe=Et(()=>{St();L1();nh();Ib();jl();Qf();uC=class{constructor(e,r){this.values=new Map;this.hits=new Map;this.enumerators=new Map;this.nextTips=0;this.displayedTips=[];this.shouldCommitTips=!1;this.configuration=e;let o=this.getRegistryPath();this.isNew=!oe.existsSync(o),this.shouldShowTips=!1,this.sendReport(r),this.startBuffer()}commitTips(){this.shouldShowTips&&(this.shouldCommitTips=!0)}selectTip(e){let r=new Set(this.displayedTips),o=A=>A&&rn?kf(rn,A):!1,a=e.map((A,p)=>p).filter(A=>e[A]&&o(e[A]?.selector));if(a.length===0)return null;let n=a.filter(A=>!r.has(A));if(n.length===0){let A=Math.floor(a.length*.2);this.displayedTips=A>0?this.displayedTips.slice(-A):[],n=a.filter(p=>!r.has(p))}let u=n[Math.floor(Math.random()*n.length)];return this.displayedTips.push(u),this.commitTips(),e[u]}reportVersion(e){this.reportValue("version",e.replace(/-git\..*/,"-git"))}reportCommandName(e){this.reportValue("commandName",e||"<none>")}reportPluginName(e){this.reportValue("pluginName",e)}reportProject(e){this.reportEnumerator("projectCount",e)}reportInstall(e){this.reportHit("installCount",e)}reportPackageExtension(e){this.reportValue("packageExtension",e)}reportWorkspaceCount(e){this.reportValue("workspaceCount",String(e))}reportDependencyCount(e){this.reportValue("dependencyCount",String(e))}reportValue(e,r){md(this.values,e).add(r)}reportEnumerator(e,r){md(this.enumerators,e).add(zs(r))}reportHit(e,r="*"){let o=Wy(this.hits,e),a=ol(o,r,()=>0);o.set(r,a+1)}getRegistryPath(){let e=this.configuration.get("globalFolder");return V.join(e,"telemetry.json")}sendReport(e){let r=this.getRegistryPath(),o;try{o=oe.readJsonSync(r)}catch{o={}}let{nextState:a,triggerUpdate:n,triggerTips:u,nextTips:A}=FAt({state:o,timeNow:Date.now(),timeZone:new Date().getTimezoneOffset()*60*1e3,randomInitialInterval:Math.random(),updateInterval:this.configuration.get("telemetryInterval")});if(this.nextTips=A,this.displayedTips=o.displayedTips??[],a!==null)try{oe.mkdirSync(V.dirname(r),{recursive:!0}),oe.writeJsonSync(r,a)}catch{return!1}if(u&&this.configuration.get("enableTips")&&(this.shouldShowTips=!0),n){let p=o.blocks??{};if(Object.keys(p).length===0){let h=`https://browser-http-intake.logs.datadoghq.eu/v1/input/${e}?ddsource=yarn`,E=I=>O4(h,I,{configuration:this.configuration}).catch(()=>{});for(let[I,v]of Object.entries(o.blocks??{})){if(Object.keys(v).length===0)continue;let x=v;x.userId=I,x.reportType="primary";for(let L of Object.keys(x.enumerators??{}))x.enumerators[L]=x.enumerators[L].length;E(x);let C=new Map,R=20;for(let[L,U]of Object.entries(x.values))U.length>0&&C.set(L,U.slice(0,R));for(;C.size>0;){let L={};L.userId=I,L.reportType="secondary",L.metrics={};for(let[U,J]of C)L.metrics[U]=J.shift(),J.length===0&&C.delete(U);E(L)}}}}return!0}applyChanges(){let e=this.getRegistryPath(),r;try{r=oe.readJsonSync(e)}catch{r={}}let o=this.configuration.get("telemetryUserId")??"*",a=r.blocks=r.blocks??{},n=a[o]=a[o]??{};for(let u of this.hits.keys()){let A=n.hits=n.hits??{},p=A[u]=A[u]??{};for(let[h,E]of this.hits.get(u))p[h]=(p[h]??0)+E}for(let u of["values","enumerators"])for(let A of this[u].keys()){let p=n[u]=n[u]??{};p[A]=[...new Set([...p[A]??[],...this[u].get(A)??[]])]}this.shouldCommitTips&&(r.lastTips=this.nextTips,r.displayedTips=this.displayedTips),oe.mkdirSync(V.dirname(e),{recursive:!0}),oe.writeJsonSync(e,r)}startBuffer(){process.on("exit",()=>{try{this.applyChanges()}catch{}})}}});var o2={};Vt(o2,{BuildDirectiveType:()=>Vx,CACHE_CHECKPOINT:()=>c_,CACHE_VERSION:()=>Kx,Cache:()=>Lr,Configuration:()=>Ke,DEFAULT_RC_FILENAME:()=>q4,FormatType:()=>xle,InstallMode:()=>pl,LEGACY_PLUGINS:()=>v1,LOCKFILE_VERSION:()=>tk,LegacyMigrationResolver:()=>oC,LightReport:()=>AA,LinkType:()=>zy,LockfileResolver:()=>aC,Manifest:()=>Ot,MessageName:()=>wr,MultiFetcher:()=>hE,PackageExtensionStatus:()=>BL,PackageExtensionType:()=>IL,PeerWarningType:()=>rk,Project:()=>Pt,Report:()=>Xs,ReportError:()=>zt,SettingsType:()=>D1,StreamReport:()=>Nt,TAG_REGEXP:()=>FE,TelemetryManager:()=>uC,ThrowReport:()=>Qi,VirtualFetcher:()=>gE,WindowsLinkType:()=>xb,Workspace:()=>cC,WorkspaceFetcher:()=>mE,WorkspaceResolver:()=>Xn,YarnVersion:()=>rn,execUtils:()=>Ur,folderUtils:()=>YP,formatUtils:()=>de,hashUtils:()=>wn,httpUtils:()=>nn,miscUtils:()=>_e,nodeUtils:()=>Ji,parseMessageName:()=>AS,reportOptionDeprecations:()=>LE,scriptUtils:()=>un,semverUtils:()=>kr,stringifyMessageName:()=>Wu,structUtils:()=>W,tgzUtils:()=>Xi,treeUtils:()=>$s});var Ye=Et(()=>{Db();WP();Gl();nh();Ib();jl();vb();BU();Qf();bo();Xfe();ipe();u_();S1();S1();ope();A_();ape();f_();fE();fS();cM();zpe();Yl();O1();Xpe();S_();AM();fM();vd();P_();L1();Cne()});var nhe=_((K_t,l2)=>{"use strict";var TAt=process.env.TERM_PROGRAM==="Hyper",NAt=process.platform==="win32",ehe=process.platform==="linux",F_={ballotDisabled:"\u2612",ballotOff:"\u2610",ballotOn:"\u2611",bullet:"\u2022",bulletWhite:"\u25E6",fullBlock:"\u2588",heart:"\u2764",identicalTo:"\u2261",line:"\u2500",mark:"\u203B",middot:"\xB7",minus:"\uFF0D",multiplication:"\xD7",obelus:"\xF7",pencilDownRight:"\u270E",pencilRight:"\u270F",pencilUpRight:"\u2710",percent:"%",pilcrow2:"\u2761",pilcrow:"\xB6",plusMinus:"\xB1",section:"\xA7",starsOff:"\u2606",starsOn:"\u2605",upDownArrow:"\u2195"},the=Object.assign({},F_,{check:"\u221A",cross:"\xD7",ellipsisLarge:"...",ellipsis:"...",info:"i",question:"?",questionSmall:"?",pointer:">",pointerSmall:"\xBB",radioOff:"( )",radioOn:"(*)",warning:"\u203C"}),rhe=Object.assign({},F_,{ballotCross:"\u2718",check:"\u2714",cross:"\u2716",ellipsisLarge:"\u22EF",ellipsis:"\u2026",info:"\u2139",question:"?",questionFull:"\uFF1F",questionSmall:"\uFE56",pointer:ehe?"\u25B8":"\u276F",pointerSmall:ehe?"\u2023":"\u203A",radioOff:"\u25EF",radioOn:"\u25C9",warning:"\u26A0"});l2.exports=NAt&&!TAt?the:rhe;Reflect.defineProperty(l2.exports,"common",{enumerable:!1,value:F_});Reflect.defineProperty(l2.exports,"windows",{enumerable:!1,value:the});Reflect.defineProperty(l2.exports,"other",{enumerable:!1,value:rhe})});var Kc=_((V_t,R_)=>{"use strict";var LAt=t=>t!==null&&typeof t=="object"&&!Array.isArray(t),OAt=/[\u001b\u009b][[\]#;?()]*(?:(?:(?:[^\W_]*;?[^\W_]*)\u0007)|(?:(?:[0-9]{1,4}(;[0-9]{0,4})*)?[~0-9=<>cf-nqrtyA-PRZ]))/g,ihe=()=>{let t={enabled:!0,visible:!0,styles:{},keys:{}};"FORCE_COLOR"in process.env&&(t.enabled=process.env.FORCE_COLOR!=="0");let e=n=>{let u=n.open=`\x1B[${n.codes[0]}m`,A=n.close=`\x1B[${n.codes[1]}m`,p=n.regex=new RegExp(`\\u001b\\[${n.codes[1]}m`,"g");return n.wrap=(h,E)=>{h.includes(A)&&(h=h.replace(p,A+u));let I=u+h+A;return E?I.replace(/\r*\n/g,`${A}$&${u}`):I},n},r=(n,u,A)=>typeof n=="function"?n(u):n.wrap(u,A),o=(n,u)=>{if(n===""||n==null)return"";if(t.enabled===!1)return n;if(t.visible===!1)return"";let A=""+n,p=A.includes(` +`),h=u.length;for(h>0&&u.includes("unstyle")&&(u=[...new Set(["unstyle",...u])].reverse());h-- >0;)A=r(t.styles[u[h]],A,p);return A},a=(n,u,A)=>{t.styles[n]=e({name:n,codes:u}),(t.keys[A]||(t.keys[A]=[])).push(n),Reflect.defineProperty(t,n,{configurable:!0,enumerable:!0,set(h){t.alias(n,h)},get(){let h=E=>o(E,h.stack);return Reflect.setPrototypeOf(h,t),h.stack=this.stack?this.stack.concat(n):[n],h}})};return a("reset",[0,0],"modifier"),a("bold",[1,22],"modifier"),a("dim",[2,22],"modifier"),a("italic",[3,23],"modifier"),a("underline",[4,24],"modifier"),a("inverse",[7,27],"modifier"),a("hidden",[8,28],"modifier"),a("strikethrough",[9,29],"modifier"),a("black",[30,39],"color"),a("red",[31,39],"color"),a("green",[32,39],"color"),a("yellow",[33,39],"color"),a("blue",[34,39],"color"),a("magenta",[35,39],"color"),a("cyan",[36,39],"color"),a("white",[37,39],"color"),a("gray",[90,39],"color"),a("grey",[90,39],"color"),a("bgBlack",[40,49],"bg"),a("bgRed",[41,49],"bg"),a("bgGreen",[42,49],"bg"),a("bgYellow",[43,49],"bg"),a("bgBlue",[44,49],"bg"),a("bgMagenta",[45,49],"bg"),a("bgCyan",[46,49],"bg"),a("bgWhite",[47,49],"bg"),a("blackBright",[90,39],"bright"),a("redBright",[91,39],"bright"),a("greenBright",[92,39],"bright"),a("yellowBright",[93,39],"bright"),a("blueBright",[94,39],"bright"),a("magentaBright",[95,39],"bright"),a("cyanBright",[96,39],"bright"),a("whiteBright",[97,39],"bright"),a("bgBlackBright",[100,49],"bgBright"),a("bgRedBright",[101,49],"bgBright"),a("bgGreenBright",[102,49],"bgBright"),a("bgYellowBright",[103,49],"bgBright"),a("bgBlueBright",[104,49],"bgBright"),a("bgMagentaBright",[105,49],"bgBright"),a("bgCyanBright",[106,49],"bgBright"),a("bgWhiteBright",[107,49],"bgBright"),t.ansiRegex=OAt,t.hasColor=t.hasAnsi=n=>(t.ansiRegex.lastIndex=0,typeof n=="string"&&n!==""&&t.ansiRegex.test(n)),t.alias=(n,u)=>{let A=typeof u=="string"?t[u]:u;if(typeof A!="function")throw new TypeError("Expected alias to be the name of an existing color (string) or a function");A.stack||(Reflect.defineProperty(A,"name",{value:n}),t.styles[n]=A,A.stack=[n]),Reflect.defineProperty(t,n,{configurable:!0,enumerable:!0,set(p){t.alias(n,p)},get(){let p=h=>o(h,p.stack);return Reflect.setPrototypeOf(p,t),p.stack=this.stack?this.stack.concat(A.stack):A.stack,p}})},t.theme=n=>{if(!LAt(n))throw new TypeError("Expected theme to be an object");for(let u of Object.keys(n))t.alias(u,n[u]);return t},t.alias("unstyle",n=>typeof n=="string"&&n!==""?(t.ansiRegex.lastIndex=0,n.replace(t.ansiRegex,"")):""),t.alias("noop",n=>n),t.none=t.clear=t.noop,t.stripColor=t.unstyle,t.symbols=nhe(),t.define=a,t};R_.exports=ihe();R_.exports.create=ihe});var No=_(sn=>{"use strict";var MAt=Object.prototype.toString,rc=Kc(),she=!1,T_=[],ohe={yellow:"blue",cyan:"red",green:"magenta",black:"white",blue:"yellow",red:"cyan",magenta:"green",white:"black"};sn.longest=(t,e)=>t.reduce((r,o)=>Math.max(r,e?o[e].length:o.length),0);sn.hasColor=t=>!!t&&rc.hasColor(t);var ik=sn.isObject=t=>t!==null&&typeof t=="object"&&!Array.isArray(t);sn.nativeType=t=>MAt.call(t).slice(8,-1).toLowerCase().replace(/\s/g,"");sn.isAsyncFn=t=>sn.nativeType(t)==="asyncfunction";sn.isPrimitive=t=>t!=null&&typeof t!="object"&&typeof t!="function";sn.resolve=(t,e,...r)=>typeof e=="function"?e.call(t,...r):e;sn.scrollDown=(t=[])=>[...t.slice(1),t[0]];sn.scrollUp=(t=[])=>[t.pop(),...t];sn.reorder=(t=[])=>{let e=t.slice();return e.sort((r,o)=>r.index>o.index?1:r.index<o.index?-1:0),e};sn.swap=(t,e,r)=>{let o=t.length,a=r===o?0:r<0?o-1:r,n=t[e];t[e]=t[a],t[a]=n};sn.width=(t,e=80)=>{let r=t&&t.columns?t.columns:e;return t&&typeof t.getWindowSize=="function"&&(r=t.getWindowSize()[0]),process.platform==="win32"?r-1:r};sn.height=(t,e=20)=>{let r=t&&t.rows?t.rows:e;return t&&typeof t.getWindowSize=="function"&&(r=t.getWindowSize()[1]),r};sn.wordWrap=(t,e={})=>{if(!t)return t;typeof e=="number"&&(e={width:e});let{indent:r="",newline:o=` +`+r,width:a=80}=e,n=(o+r).match(/[^\S\n]/g)||[];a-=n.length;let u=`.{1,${a}}([\\s\\u200B]+|$)|[^\\s\\u200B]+?([\\s\\u200B]+|$)`,A=t.trim(),p=new RegExp(u,"g"),h=A.match(p)||[];return h=h.map(E=>E.replace(/\n$/,"")),e.padEnd&&(h=h.map(E=>E.padEnd(a," "))),e.padStart&&(h=h.map(E=>E.padStart(a," "))),r+h.join(o)};sn.unmute=t=>{let e=t.stack.find(o=>rc.keys.color.includes(o));return e?rc[e]:t.stack.find(o=>o.slice(2)==="bg")?rc[e.slice(2)]:o=>o};sn.pascal=t=>t?t[0].toUpperCase()+t.slice(1):"";sn.inverse=t=>{if(!t||!t.stack)return t;let e=t.stack.find(o=>rc.keys.color.includes(o));if(e){let o=rc["bg"+sn.pascal(e)];return o?o.black:t}let r=t.stack.find(o=>o.slice(0,2)==="bg");return r?rc[r.slice(2).toLowerCase()]||t:rc.none};sn.complement=t=>{if(!t||!t.stack)return t;let e=t.stack.find(o=>rc.keys.color.includes(o)),r=t.stack.find(o=>o.slice(0,2)==="bg");if(e&&!r)return rc[ohe[e]||e];if(r){let o=r.slice(2).toLowerCase(),a=ohe[o];return a&&rc["bg"+sn.pascal(a)]||t}return rc.none};sn.meridiem=t=>{let e=t.getHours(),r=t.getMinutes(),o=e>=12?"pm":"am";e=e%12;let a=e===0?12:e,n=r<10?"0"+r:r;return a+":"+n+" "+o};sn.set=(t={},e="",r)=>e.split(".").reduce((o,a,n,u)=>{let A=u.length-1>n?o[a]||{}:r;return!sn.isObject(A)&&n<u.length-1&&(A={}),o[a]=A},t);sn.get=(t={},e="",r)=>{let o=t[e]==null?e.split(".").reduce((a,n)=>a&&a[n],t):t[e];return o??r};sn.mixin=(t,e)=>{if(!ik(t))return e;if(!ik(e))return t;for(let r of Object.keys(e)){let o=Object.getOwnPropertyDescriptor(e,r);if(o.hasOwnProperty("value"))if(t.hasOwnProperty(r)&&ik(o.value)){let a=Object.getOwnPropertyDescriptor(t,r);ik(a.value)?t[r]=sn.merge({},t[r],e[r]):Reflect.defineProperty(t,r,o)}else Reflect.defineProperty(t,r,o);else Reflect.defineProperty(t,r,o)}return t};sn.merge=(...t)=>{let e={};for(let r of t)sn.mixin(e,r);return e};sn.mixinEmitter=(t,e)=>{let r=e.constructor.prototype;for(let o of Object.keys(r)){let a=r[o];typeof a=="function"?sn.define(t,o,a.bind(e)):sn.define(t,o,a)}};sn.onExit=t=>{let e=(r,o)=>{she||(she=!0,T_.forEach(a=>a()),r===!0&&process.exit(128+o))};T_.length===0&&(process.once("SIGTERM",e.bind(null,!0,15)),process.once("SIGINT",e.bind(null,!0,2)),process.once("exit",e)),T_.push(t)};sn.define=(t,e,r)=>{Reflect.defineProperty(t,e,{value:r})};sn.defineExport=(t,e,r)=>{let o;Reflect.defineProperty(t,e,{enumerable:!0,configurable:!0,set(a){o=a},get(){return o?o():r()}})}});var ahe=_(hC=>{"use strict";hC.ctrl={a:"first",b:"backward",c:"cancel",d:"deleteForward",e:"last",f:"forward",g:"reset",i:"tab",k:"cutForward",l:"reset",n:"newItem",m:"cancel",j:"submit",p:"search",r:"remove",s:"save",u:"undo",w:"cutLeft",x:"toggleCursor",v:"paste"};hC.shift={up:"shiftUp",down:"shiftDown",left:"shiftLeft",right:"shiftRight",tab:"prev"};hC.fn={up:"pageUp",down:"pageDown",left:"pageLeft",right:"pageRight",delete:"deleteForward"};hC.option={b:"backward",f:"forward",d:"cutRight",left:"cutLeft",up:"altUp",down:"altDown"};hC.keys={pageup:"pageUp",pagedown:"pageDown",home:"home",end:"end",cancel:"cancel",delete:"deleteForward",backspace:"delete",down:"down",enter:"submit",escape:"cancel",left:"left",space:"space",number:"number",return:"submit",right:"right",tab:"next",up:"up"}});var uhe=_((X_t,che)=>{"use strict";var lhe=ve("readline"),UAt=ahe(),_At=/^(?:\x1b)([a-zA-Z0-9])$/,HAt=/^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/,jAt={OP:"f1",OQ:"f2",OR:"f3",OS:"f4","[11~":"f1","[12~":"f2","[13~":"f3","[14~":"f4","[[A":"f1","[[B":"f2","[[C":"f3","[[D":"f4","[[E":"f5","[15~":"f5","[17~":"f6","[18~":"f7","[19~":"f8","[20~":"f9","[21~":"f10","[23~":"f11","[24~":"f12","[A":"up","[B":"down","[C":"right","[D":"left","[E":"clear","[F":"end","[H":"home",OA:"up",OB:"down",OC:"right",OD:"left",OE:"clear",OF:"end",OH:"home","[1~":"home","[2~":"insert","[3~":"delete","[4~":"end","[5~":"pageup","[6~":"pagedown","[[5~":"pageup","[[6~":"pagedown","[7~":"home","[8~":"end","[a":"up","[b":"down","[c":"right","[d":"left","[e":"clear","[2$":"insert","[3$":"delete","[5$":"pageup","[6$":"pagedown","[7$":"home","[8$":"end",Oa:"up",Ob:"down",Oc:"right",Od:"left",Oe:"clear","[2^":"insert","[3^":"delete","[5^":"pageup","[6^":"pagedown","[7^":"home","[8^":"end","[Z":"tab"};function GAt(t){return["[a","[b","[c","[d","[e","[2$","[3$","[5$","[6$","[7$","[8$","[Z"].includes(t)}function qAt(t){return["Oa","Ob","Oc","Od","Oe","[2^","[3^","[5^","[6^","[7^","[8^"].includes(t)}var sk=(t="",e={})=>{let r,o={name:e.name,ctrl:!1,meta:!1,shift:!1,option:!1,sequence:t,raw:t,...e};if(Buffer.isBuffer(t)?t[0]>127&&t[1]===void 0?(t[0]-=128,t="\x1B"+String(t)):t=String(t):t!==void 0&&typeof t!="string"?t=String(t):t||(t=o.sequence||""),o.sequence=o.sequence||t||o.name,t==="\r")o.raw=void 0,o.name="return";else if(t===` +`)o.name="enter";else if(t===" ")o.name="tab";else if(t==="\b"||t==="\x7F"||t==="\x1B\x7F"||t==="\x1B\b")o.name="backspace",o.meta=t.charAt(0)==="\x1B";else if(t==="\x1B"||t==="\x1B\x1B")o.name="escape",o.meta=t.length===2;else if(t===" "||t==="\x1B ")o.name="space",o.meta=t.length===2;else if(t<="")o.name=String.fromCharCode(t.charCodeAt(0)+"a".charCodeAt(0)-1),o.ctrl=!0;else if(t.length===1&&t>="0"&&t<="9")o.name="number";else if(t.length===1&&t>="a"&&t<="z")o.name=t;else if(t.length===1&&t>="A"&&t<="Z")o.name=t.toLowerCase(),o.shift=!0;else if(r=_At.exec(t))o.meta=!0,o.shift=/^[A-Z]$/.test(r[1]);else if(r=HAt.exec(t)){let a=[...t];a[0]==="\x1B"&&a[1]==="\x1B"&&(o.option=!0);let n=[r[1],r[2],r[4],r[6]].filter(Boolean).join(""),u=(r[3]||r[5]||1)-1;o.ctrl=!!(u&4),o.meta=!!(u&10),o.shift=!!(u&1),o.code=n,o.name=jAt[n],o.shift=GAt(n)||o.shift,o.ctrl=qAt(n)||o.ctrl}return o};sk.listen=(t={},e)=>{let{stdin:r}=t;if(!r||r!==process.stdin&&!r.isTTY)throw new Error("Invalid stream passed");let o=lhe.createInterface({terminal:!0,input:r});lhe.emitKeypressEvents(r,o);let a=(A,p)=>e(A,sk(A,p),o),n=r.isRaw;return r.isTTY&&r.setRawMode(!0),r.on("keypress",a),o.resume(),()=>{r.isTTY&&r.setRawMode(n),r.removeListener("keypress",a),o.pause(),o.close()}};sk.action=(t,e,r)=>{let o={...UAt,...r};return e.ctrl?(e.action=o.ctrl[e.name],e):e.option&&o.option?(e.action=o.option[e.name],e):e.shift?(e.action=o.shift[e.name],e):(e.action=o.keys[e.name],e)};che.exports=sk});var fhe=_((Z_t,Ahe)=>{"use strict";Ahe.exports=t=>{t.timers=t.timers||{};let e=t.options.timers;if(!!e)for(let r of Object.keys(e)){let o=e[r];typeof o=="number"&&(o={interval:o}),YAt(t,r,o)}};function YAt(t,e,r={}){let o=t.timers[e]={name:e,start:Date.now(),ms:0,tick:0},a=r.interval||120;o.frames=r.frames||[],o.loading=!0;let n=setInterval(()=>{o.ms=Date.now()-o.start,o.tick++,t.render()},a);return o.stop=()=>{o.loading=!1,clearInterval(n)},Reflect.defineProperty(o,"interval",{value:n}),t.once("close",()=>o.stop()),o.stop}});var hhe=_(($_t,phe)=>{"use strict";var{define:WAt,width:KAt}=No(),N_=class{constructor(e){let r=e.options;WAt(this,"_prompt",e),this.type=e.type,this.name=e.name,this.message="",this.header="",this.footer="",this.error="",this.hint="",this.input="",this.cursor=0,this.index=0,this.lines=0,this.tick=0,this.prompt="",this.buffer="",this.width=KAt(r.stdout||process.stdout),Object.assign(this,r),this.name=this.name||this.message,this.message=this.message||this.name,this.symbols=e.symbols,this.styles=e.styles,this.required=new Set,this.cancelled=!1,this.submitted=!1}clone(){let e={...this};return e.status=this.status,e.buffer=Buffer.from(e.buffer),delete e.clone,e}set color(e){this._color=e}get color(){let e=this.prompt.styles;if(this.cancelled)return e.cancelled;if(this.submitted)return e.submitted;let r=this._color||e[this.status];return typeof r=="function"?r:e.pending}set loading(e){this._loading=e}get loading(){return typeof this._loading=="boolean"?this._loading:this.loadingChoices?"choices":!1}get status(){return this.cancelled?"cancelled":this.submitted?"submitted":"pending"}};phe.exports=N_});var dhe=_((e8t,ghe)=>{"use strict";var L_=No(),eo=Kc(),O_={default:eo.noop,noop:eo.noop,set inverse(t){this._inverse=t},get inverse(){return this._inverse||L_.inverse(this.primary)},set complement(t){this._complement=t},get complement(){return this._complement||L_.complement(this.primary)},primary:eo.cyan,success:eo.green,danger:eo.magenta,strong:eo.bold,warning:eo.yellow,muted:eo.dim,disabled:eo.gray,dark:eo.dim.gray,underline:eo.underline,set info(t){this._info=t},get info(){return this._info||this.primary},set em(t){this._em=t},get em(){return this._em||this.primary.underline},set heading(t){this._heading=t},get heading(){return this._heading||this.muted.underline},set pending(t){this._pending=t},get pending(){return this._pending||this.primary},set submitted(t){this._submitted=t},get submitted(){return this._submitted||this.success},set cancelled(t){this._cancelled=t},get cancelled(){return this._cancelled||this.danger},set typing(t){this._typing=t},get typing(){return this._typing||this.dim},set placeholder(t){this._placeholder=t},get placeholder(){return this._placeholder||this.primary.dim},set highlight(t){this._highlight=t},get highlight(){return this._highlight||this.inverse}};O_.merge=(t={})=>{t.styles&&typeof t.styles.enabled=="boolean"&&(eo.enabled=t.styles.enabled),t.styles&&typeof t.styles.visible=="boolean"&&(eo.visible=t.styles.visible);let e=L_.merge({},O_,t.styles);delete e.merge;for(let r of Object.keys(eo))e.hasOwnProperty(r)||Reflect.defineProperty(e,r,{get:()=>eo[r]});for(let r of Object.keys(eo.styles))e.hasOwnProperty(r)||Reflect.defineProperty(e,r,{get:()=>eo[r]});return e};ghe.exports=O_});var yhe=_((t8t,mhe)=>{"use strict";var M_=process.platform==="win32",Vf=Kc(),VAt=No(),U_={...Vf.symbols,upDownDoubleArrow:"\u21D5",upDownDoubleArrow2:"\u2B0D",upDownArrow:"\u2195",asterisk:"*",asterism:"\u2042",bulletWhite:"\u25E6",electricArrow:"\u2301",ellipsisLarge:"\u22EF",ellipsisSmall:"\u2026",fullBlock:"\u2588",identicalTo:"\u2261",indicator:Vf.symbols.check,leftAngle:"\u2039",mark:"\u203B",minus:"\u2212",multiplication:"\xD7",obelus:"\xF7",percent:"%",pilcrow:"\xB6",pilcrow2:"\u2761",pencilUpRight:"\u2710",pencilDownRight:"\u270E",pencilRight:"\u270F",plus:"+",plusMinus:"\xB1",pointRight:"\u261E",rightAngle:"\u203A",section:"\xA7",hexagon:{off:"\u2B21",on:"\u2B22",disabled:"\u2B22"},ballot:{on:"\u2611",off:"\u2610",disabled:"\u2612"},stars:{on:"\u2605",off:"\u2606",disabled:"\u2606"},folder:{on:"\u25BC",off:"\u25B6",disabled:"\u25B6"},prefix:{pending:Vf.symbols.question,submitted:Vf.symbols.check,cancelled:Vf.symbols.cross},separator:{pending:Vf.symbols.pointerSmall,submitted:Vf.symbols.middot,cancelled:Vf.symbols.middot},radio:{off:M_?"( )":"\u25EF",on:M_?"(*)":"\u25C9",disabled:M_?"(|)":"\u24BE"},numbers:["\u24EA","\u2460","\u2461","\u2462","\u2463","\u2464","\u2465","\u2466","\u2467","\u2468","\u2469","\u246A","\u246B","\u246C","\u246D","\u246E","\u246F","\u2470","\u2471","\u2472","\u2473","\u3251","\u3252","\u3253","\u3254","\u3255","\u3256","\u3257","\u3258","\u3259","\u325A","\u325B","\u325C","\u325D","\u325E","\u325F","\u32B1","\u32B2","\u32B3","\u32B4","\u32B5","\u32B6","\u32B7","\u32B8","\u32B9","\u32BA","\u32BB","\u32BC","\u32BD","\u32BE","\u32BF"]};U_.merge=t=>{let e=VAt.merge({},Vf.symbols,U_,t.symbols);return delete e.merge,e};mhe.exports=U_});var Che=_((r8t,Ehe)=>{"use strict";var JAt=dhe(),zAt=yhe(),XAt=No();Ehe.exports=t=>{t.options=XAt.merge({},t.options.theme,t.options),t.symbols=zAt.merge(t.options),t.styles=JAt.merge(t.options)}});var Dhe=_((Bhe,vhe)=>{"use strict";var whe=process.env.TERM_PROGRAM==="Apple_Terminal",ZAt=Kc(),__=No(),Vc=vhe.exports=Bhe,Di="\x1B[",Ihe="\x07",H_=!1,Ph=Vc.code={bell:Ihe,beep:Ihe,beginning:`${Di}G`,down:`${Di}J`,esc:Di,getPosition:`${Di}6n`,hide:`${Di}?25l`,line:`${Di}2K`,lineEnd:`${Di}K`,lineStart:`${Di}1K`,restorePosition:Di+(whe?"8":"u"),savePosition:Di+(whe?"7":"s"),screen:`${Di}2J`,show:`${Di}?25h`,up:`${Di}1J`},qd=Vc.cursor={get hidden(){return H_},hide(){return H_=!0,Ph.hide},show(){return H_=!1,Ph.show},forward:(t=1)=>`${Di}${t}C`,backward:(t=1)=>`${Di}${t}D`,nextLine:(t=1)=>`${Di}E`.repeat(t),prevLine:(t=1)=>`${Di}F`.repeat(t),up:(t=1)=>t?`${Di}${t}A`:"",down:(t=1)=>t?`${Di}${t}B`:"",right:(t=1)=>t?`${Di}${t}C`:"",left:(t=1)=>t?`${Di}${t}D`:"",to(t,e){return e?`${Di}${e+1};${t+1}H`:`${Di}${t+1}G`},move(t=0,e=0){let r="";return r+=t<0?qd.left(-t):t>0?qd.right(t):"",r+=e<0?qd.up(-e):e>0?qd.down(e):"",r},restore(t={}){let{after:e,cursor:r,initial:o,input:a,prompt:n,size:u,value:A}=t;if(o=__.isPrimitive(o)?String(o):"",a=__.isPrimitive(a)?String(a):"",A=__.isPrimitive(A)?String(A):"",u){let p=Vc.cursor.up(u)+Vc.cursor.to(n.length),h=a.length-r;return h>0&&(p+=Vc.cursor.left(h)),p}if(A||e){let p=!a&&!!o?-o.length:-a.length+r;return e&&(p-=e.length),a===""&&o&&!n.includes(o)&&(p+=o.length),Vc.cursor.move(p)}}},j_=Vc.erase={screen:Ph.screen,up:Ph.up,down:Ph.down,line:Ph.line,lineEnd:Ph.lineEnd,lineStart:Ph.lineStart,lines(t){let e="";for(let r=0;r<t;r++)e+=Vc.erase.line+(r<t-1?Vc.cursor.up(1):"");return t&&(e+=Vc.code.beginning),e}};Vc.clear=(t="",e=process.stdout.columns)=>{if(!e)return j_.line+qd.to(0);let r=n=>[...ZAt.unstyle(n)].length,o=t.split(/\r?\n/),a=0;for(let n of o)a+=1+Math.floor(Math.max(r(n)-1,0)/e);return(j_.line+qd.prevLine()).repeat(a-1)+j_.line+qd.to(0)}});var gC=_((n8t,Phe)=>{"use strict";var $At=ve("events"),She=Kc(),G_=uhe(),eft=fhe(),tft=hhe(),rft=Che(),Ra=No(),Yd=Dhe(),c2=class extends $At{constructor(e={}){super(),this.name=e.name,this.type=e.type,this.options=e,rft(this),eft(this),this.state=new tft(this),this.initial=[e.initial,e.default].find(r=>r!=null),this.stdout=e.stdout||process.stdout,this.stdin=e.stdin||process.stdin,this.scale=e.scale||1,this.term=this.options.term||process.env.TERM_PROGRAM,this.margin=ift(this.options.margin),this.setMaxListeners(0),nft(this)}async keypress(e,r={}){this.keypressed=!0;let o=G_.action(e,G_(e,r),this.options.actions);this.state.keypress=o,this.emit("keypress",e,o),this.emit("state",this.state.clone());let a=this.options[o.action]||this[o.action]||this.dispatch;if(typeof a=="function")return await a.call(this,e,o);this.alert()}alert(){delete this.state.alert,this.options.show===!1?this.emit("alert"):this.stdout.write(Yd.code.beep)}cursorHide(){this.stdout.write(Yd.cursor.hide()),Ra.onExit(()=>this.cursorShow())}cursorShow(){this.stdout.write(Yd.cursor.show())}write(e){!e||(this.stdout&&this.state.show!==!1&&this.stdout.write(e),this.state.buffer+=e)}clear(e=0){let r=this.state.buffer;this.state.buffer="",!(!r&&!e||this.options.show===!1)&&this.stdout.write(Yd.cursor.down(e)+Yd.clear(r,this.width))}restore(){if(this.state.closed||this.options.show===!1)return;let{prompt:e,after:r,rest:o}=this.sections(),{cursor:a,initial:n="",input:u="",value:A=""}=this,p=this.state.size=o.length,h={after:r,cursor:a,initial:n,input:u,prompt:e,size:p,value:A},E=Yd.cursor.restore(h);E&&this.stdout.write(E)}sections(){let{buffer:e,input:r,prompt:o}=this.state;o=She.unstyle(o);let a=She.unstyle(e),n=a.indexOf(o),u=a.slice(0,n),p=a.slice(n).split(` +`),h=p[0],E=p[p.length-1],v=(o+(r?" "+r:"")).length,x=v<h.length?h.slice(v+1):"";return{header:u,prompt:h,after:x,rest:p.slice(1),last:E}}async submit(){this.state.submitted=!0,this.state.validating=!0,this.options.onSubmit&&await this.options.onSubmit.call(this,this.name,this.value,this);let e=this.state.error||await this.validate(this.value,this.state);if(e!==!0){let r=` +`+this.symbols.pointer+" ";typeof e=="string"?r+=e.trim():r+="Invalid input",this.state.error=` +`+this.styles.danger(r),this.state.submitted=!1,await this.render(),await this.alert(),this.state.validating=!1,this.state.error=void 0;return}this.state.validating=!1,await this.render(),await this.close(),this.value=await this.result(this.value),this.emit("submit",this.value)}async cancel(e){this.state.cancelled=this.state.submitted=!0,await this.render(),await this.close(),typeof this.options.onCancel=="function"&&await this.options.onCancel.call(this,this.name,this.value,this),this.emit("cancel",await this.error(e))}async close(){this.state.closed=!0;try{let e=this.sections(),r=Math.ceil(e.prompt.length/this.width);e.rest&&this.write(Yd.cursor.down(e.rest.length)),this.write(` +`.repeat(r))}catch{}this.emit("close")}start(){!this.stop&&this.options.show!==!1&&(this.stop=G_.listen(this,this.keypress.bind(this)),this.once("close",this.stop))}async skip(){return this.skipped=this.options.skip===!0,typeof this.options.skip=="function"&&(this.skipped=await this.options.skip.call(this,this.name,this.value)),this.skipped}async initialize(){let{format:e,options:r,result:o}=this;if(this.format=()=>e.call(this,this.value),this.result=()=>o.call(this,this.value),typeof r.initial=="function"&&(this.initial=await r.initial.call(this,this)),typeof r.onRun=="function"&&await r.onRun.call(this,this),typeof r.onSubmit=="function"){let a=r.onSubmit.bind(this),n=this.submit.bind(this);delete this.options.onSubmit,this.submit=async()=>(await a(this.name,this.value,this),n())}await this.start(),await this.render()}render(){throw new Error("expected prompt to have a custom render method")}run(){return new Promise(async(e,r)=>{if(this.once("submit",e),this.once("cancel",r),await this.skip())return this.render=()=>{},this.submit();await this.initialize(),this.emit("run")})}async element(e,r,o){let{options:a,state:n,symbols:u,timers:A}=this,p=A&&A[e];n.timer=p;let h=a[e]||n[e]||u[e],E=r&&r[e]!=null?r[e]:await h;if(E==="")return E;let I=await this.resolve(E,n,r,o);return!I&&r&&r[e]?this.resolve(h,n,r,o):I}async prefix(){let e=await this.element("prefix")||this.symbols,r=this.timers&&this.timers.prefix,o=this.state;return o.timer=r,Ra.isObject(e)&&(e=e[o.status]||e.pending),Ra.hasColor(e)?e:(this.styles[o.status]||this.styles.pending)(e)}async message(){let e=await this.element("message");return Ra.hasColor(e)?e:this.styles.strong(e)}async separator(){let e=await this.element("separator")||this.symbols,r=this.timers&&this.timers.separator,o=this.state;o.timer=r;let a=e[o.status]||e.pending||o.separator,n=await this.resolve(a,o);return Ra.isObject(n)&&(n=n[o.status]||n.pending),Ra.hasColor(n)?n:this.styles.muted(n)}async pointer(e,r){let o=await this.element("pointer",e,r);if(typeof o=="string"&&Ra.hasColor(o))return o;if(o){let a=this.styles,n=this.index===r,u=n?a.primary:h=>h,A=await this.resolve(o[n?"on":"off"]||o,this.state),p=Ra.hasColor(A)?A:u(A);return n?p:" ".repeat(A.length)}}async indicator(e,r){let o=await this.element("indicator",e,r);if(typeof o=="string"&&Ra.hasColor(o))return o;if(o){let a=this.styles,n=e.enabled===!0,u=n?a.success:a.dark,A=o[n?"on":"off"]||o;return Ra.hasColor(A)?A:u(A)}return""}body(){return null}footer(){if(this.state.status==="pending")return this.element("footer")}header(){if(this.state.status==="pending")return this.element("header")}async hint(){if(this.state.status==="pending"&&!this.isValue(this.state.input)){let e=await this.element("hint");return Ra.hasColor(e)?e:this.styles.muted(e)}}error(e){return this.state.submitted?"":e||this.state.error}format(e){return e}result(e){return e}validate(e){return this.options.required===!0?this.isValue(e):!0}isValue(e){return e!=null&&e!==""}resolve(e,...r){return Ra.resolve(this,e,...r)}get base(){return c2.prototype}get style(){return this.styles[this.state.status]}get height(){return this.options.rows||Ra.height(this.stdout,25)}get width(){return this.options.columns||Ra.width(this.stdout,80)}get size(){return{width:this.width,height:this.height}}set cursor(e){this.state.cursor=e}get cursor(){return this.state.cursor}set input(e){this.state.input=e}get input(){return this.state.input}set value(e){this.state.value=e}get value(){let{input:e,value:r}=this.state,o=[r,e].find(this.isValue.bind(this));return this.isValue(o)?o:this.initial}static get prompt(){return e=>new this(e).run()}};function nft(t){let e=a=>t[a]===void 0||typeof t[a]=="function",r=["actions","choices","initial","margin","roles","styles","symbols","theme","timers","value"],o=["body","footer","error","header","hint","indicator","message","prefix","separator","skip"];for(let a of Object.keys(t.options)){if(r.includes(a)||/^on[A-Z]/.test(a))continue;let n=t.options[a];typeof n=="function"&&e(a)?o.includes(a)||(t[a]=n.bind(t)):typeof t[a]!="function"&&(t[a]=n)}}function ift(t){typeof t=="number"&&(t=[t,t,t,t]);let e=[].concat(t||[]),r=a=>a%2===0?` +`:" ",o=[];for(let a=0;a<4;a++){let n=r(a);e[a]?o.push(n.repeat(e[a])):o.push("")}return o}Phe.exports=c2});var khe=_((i8t,xhe)=>{"use strict";var sft=No(),bhe={default(t,e){return e},checkbox(t,e){throw new Error("checkbox role is not implemented yet")},editable(t,e){throw new Error("editable role is not implemented yet")},expandable(t,e){throw new Error("expandable role is not implemented yet")},heading(t,e){return e.disabled="",e.indicator=[e.indicator," "].find(r=>r!=null),e.message=e.message||"",e},input(t,e){throw new Error("input role is not implemented yet")},option(t,e){return bhe.default(t,e)},radio(t,e){throw new Error("radio role is not implemented yet")},separator(t,e){return e.disabled="",e.indicator=[e.indicator," "].find(r=>r!=null),e.message=e.message||t.symbols.line.repeat(5),e},spacer(t,e){return e}};xhe.exports=(t,e={})=>{let r=sft.merge({},bhe,e.roles);return r[t]||r.default}});var u2=_((s8t,Rhe)=>{"use strict";var oft=Kc(),aft=gC(),lft=khe(),ok=No(),{reorder:q_,scrollUp:cft,scrollDown:uft,isObject:Qhe,swap:Aft}=ok,Y_=class extends aft{constructor(e){super(e),this.cursorHide(),this.maxSelected=e.maxSelected||1/0,this.multiple=e.multiple||!1,this.initial=e.initial||0,this.delay=e.delay||0,this.longest=0,this.num=""}async initialize(){typeof this.options.initial=="function"&&(this.initial=await this.options.initial.call(this)),await this.reset(!0),await super.initialize()}async reset(){let{choices:e,initial:r,autofocus:o,suggest:a}=this.options;if(this.state._choices=[],this.state.choices=[],this.choices=await Promise.all(await this.toChoices(e)),this.choices.forEach(n=>n.enabled=!1),typeof a!="function"&&this.selectable.length===0)throw new Error("At least one choice must be selectable");Qhe(r)&&(r=Object.keys(r)),Array.isArray(r)?(o!=null&&(this.index=this.findIndex(o)),r.forEach(n=>this.enable(this.find(n))),await this.render()):(o!=null&&(r=o),typeof r=="string"&&(r=this.findIndex(r)),typeof r=="number"&&r>-1&&(this.index=Math.max(0,Math.min(r,this.choices.length)),this.enable(this.find(this.index)))),this.isDisabled(this.focused)&&await this.down()}async toChoices(e,r){this.state.loadingChoices=!0;let o=[],a=0,n=async(u,A)=>{typeof u=="function"&&(u=await u.call(this)),u instanceof Promise&&(u=await u);for(let p=0;p<u.length;p++){let h=u[p]=await this.toChoice(u[p],a++,A);o.push(h),h.choices&&await n(h.choices,h)}return o};return n(e,r).then(u=>(this.state.loadingChoices=!1,u))}async toChoice(e,r,o){if(typeof e=="function"&&(e=await e.call(this,this)),e instanceof Promise&&(e=await e),typeof e=="string"&&(e={name:e}),e.normalized)return e;e.normalized=!0;let a=e.value;if(e=lft(e.role,this.options)(this,e),typeof e.disabled=="string"&&!e.hint&&(e.hint=e.disabled,e.disabled=!0),e.disabled===!0&&e.hint==null&&(e.hint="(disabled)"),e.index!=null)return e;e.name=e.name||e.key||e.title||e.value||e.message,e.message=e.message||e.name||"",e.value=[e.value,e.name].find(this.isValue.bind(this)),e.input="",e.index=r,e.cursor=0,ok.define(e,"parent",o),e.level=o?o.level+1:1,e.indent==null&&(e.indent=o?o.indent+" ":e.indent||""),e.path=o?o.path+"."+e.name:e.name,e.enabled=!!(this.multiple&&!this.isDisabled(e)&&(e.enabled||this.isSelected(e))),this.isDisabled(e)||(this.longest=Math.max(this.longest,oft.unstyle(e.message).length));let u={...e};return e.reset=(A=u.input,p=u.value)=>{for(let h of Object.keys(u))e[h]=u[h];e.input=A,e.value=p},a==null&&typeof e.initial=="function"&&(e.input=await e.initial.call(this,this.state,e,r)),e}async onChoice(e,r){this.emit("choice",e,r,this),typeof e.onChoice=="function"&&await e.onChoice.call(this,this.state,e,r)}async addChoice(e,r,o){let a=await this.toChoice(e,r,o);return this.choices.push(a),this.index=this.choices.length-1,this.limit=this.choices.length,a}async newItem(e,r,o){let a={name:"New choice name?",editable:!0,newChoice:!0,...e},n=await this.addChoice(a,r,o);return n.updateChoice=()=>{delete n.newChoice,n.name=n.message=n.input,n.input="",n.cursor=0},this.render()}indent(e){return e.indent==null?e.level>1?" ".repeat(e.level-1):"":e.indent}dispatch(e,r){if(this.multiple&&this[r.name])return this[r.name]();this.alert()}focus(e,r){return typeof r!="boolean"&&(r=e.enabled),r&&!e.enabled&&this.selected.length>=this.maxSelected?this.alert():(this.index=e.index,e.enabled=r&&!this.isDisabled(e),e)}space(){return this.multiple?(this.toggle(this.focused),this.render()):this.alert()}a(){if(this.maxSelected<this.choices.length)return this.alert();let e=this.selectable.every(r=>r.enabled);return this.choices.forEach(r=>r.enabled=!e),this.render()}i(){return this.choices.length-this.selected.length>this.maxSelected?this.alert():(this.choices.forEach(e=>e.enabled=!e.enabled),this.render())}g(e=this.focused){return this.choices.some(r=>!!r.parent)?(this.toggle(e.parent&&!e.choices?e.parent:e),this.render()):this.a()}toggle(e,r){if(!e.enabled&&this.selected.length>=this.maxSelected)return this.alert();typeof r!="boolean"&&(r=!e.enabled),e.enabled=r,e.choices&&e.choices.forEach(a=>this.toggle(a,r));let o=e.parent;for(;o;){let a=o.choices.filter(n=>this.isDisabled(n));o.enabled=a.every(n=>n.enabled===!0),o=o.parent}return Fhe(this,this.choices),this.emit("toggle",e,this),e}enable(e){return this.selected.length>=this.maxSelected?this.alert():(e.enabled=!this.isDisabled(e),e.choices&&e.choices.forEach(this.enable.bind(this)),e)}disable(e){return e.enabled=!1,e.choices&&e.choices.forEach(this.disable.bind(this)),e}number(e){this.num+=e;let r=o=>{let a=Number(o);if(a>this.choices.length-1)return this.alert();let n=this.focused,u=this.choices.find(A=>a===A.index);if(!u.enabled&&this.selected.length>=this.maxSelected)return this.alert();if(this.visible.indexOf(u)===-1){let A=q_(this.choices),p=A.indexOf(u);if(n.index>p){let h=A.slice(p,p+this.limit),E=A.filter(I=>!h.includes(I));this.choices=h.concat(E)}else{let h=p-this.limit+1;this.choices=A.slice(h).concat(A.slice(0,h))}}return this.index=this.choices.indexOf(u),this.toggle(this.focused),this.render()};return clearTimeout(this.numberTimeout),new Promise(o=>{let a=this.choices.length,n=this.num,u=(A=!1,p)=>{clearTimeout(this.numberTimeout),A&&(p=r(n)),this.num="",o(p)};if(n==="0"||n.length===1&&Number(n+"0")>a)return u(!0);if(Number(n)>a)return u(!1,this.alert());this.numberTimeout=setTimeout(()=>u(!0),this.delay)})}home(){return this.choices=q_(this.choices),this.index=0,this.render()}end(){let e=this.choices.length-this.limit,r=q_(this.choices);return this.choices=r.slice(e).concat(r.slice(0,e)),this.index=this.limit-1,this.render()}first(){return this.index=0,this.render()}last(){return this.index=this.visible.length-1,this.render()}prev(){return this.visible.length<=1?this.alert():this.up()}next(){return this.visible.length<=1?this.alert():this.down()}right(){return this.cursor>=this.input.length?this.alert():(this.cursor++,this.render())}left(){return this.cursor<=0?this.alert():(this.cursor--,this.render())}up(){let e=this.choices.length,r=this.visible.length,o=this.index;return this.options.scroll===!1&&o===0?this.alert():e>r&&o===0?this.scrollUp():(this.index=(o-1%e+e)%e,this.isDisabled()?this.up():this.render())}down(){let e=this.choices.length,r=this.visible.length,o=this.index;return this.options.scroll===!1&&o===r-1?this.alert():e>r&&o===r-1?this.scrollDown():(this.index=(o+1)%e,this.isDisabled()?this.down():this.render())}scrollUp(e=0){return this.choices=cft(this.choices),this.index=e,this.isDisabled()?this.up():this.render()}scrollDown(e=this.visible.length-1){return this.choices=uft(this.choices),this.index=e,this.isDisabled()?this.down():this.render()}async shiftUp(){if(this.options.sort===!0){this.sorting=!0,this.swap(this.index-1),await this.up(),this.sorting=!1;return}return this.scrollUp(this.index)}async shiftDown(){if(this.options.sort===!0){this.sorting=!0,this.swap(this.index+1),await this.down(),this.sorting=!1;return}return this.scrollDown(this.index)}pageUp(){return this.visible.length<=1?this.alert():(this.limit=Math.max(this.limit-1,0),this.index=Math.min(this.limit-1,this.index),this._limit=this.limit,this.isDisabled()?this.up():this.render())}pageDown(){return this.visible.length>=this.choices.length?this.alert():(this.index=Math.max(0,this.index),this.limit=Math.min(this.limit+1,this.choices.length),this._limit=this.limit,this.isDisabled()?this.down():this.render())}swap(e){Aft(this.choices,this.index,e)}isDisabled(e=this.focused){return e&&["disabled","collapsed","hidden","completing","readonly"].some(o=>e[o]===!0)?!0:e&&e.role==="heading"}isEnabled(e=this.focused){if(Array.isArray(e))return e.every(r=>this.isEnabled(r));if(e.choices){let r=e.choices.filter(o=>!this.isDisabled(o));return e.enabled&&r.every(o=>this.isEnabled(o))}return e.enabled&&!this.isDisabled(e)}isChoice(e,r){return e.name===r||e.index===Number(r)}isSelected(e){return Array.isArray(this.initial)?this.initial.some(r=>this.isChoice(e,r)):this.isChoice(e,this.initial)}map(e=[],r="value"){return[].concat(e||[]).reduce((o,a)=>(o[a]=this.find(a,r),o),{})}filter(e,r){let a=typeof e=="function"?e:(A,p)=>[A.name,p].includes(e),u=(this.options.multiple?this.state._choices:this.choices).filter(a);return r?u.map(A=>A[r]):u}find(e,r){if(Qhe(e))return r?e[r]:e;let a=typeof e=="function"?e:(u,A)=>[u.name,A].includes(e),n=this.choices.find(a);if(n)return r?n[r]:n}findIndex(e){return this.choices.indexOf(this.find(e))}async submit(){let e=this.focused;if(!e)return this.alert();if(e.newChoice)return e.input?(e.updateChoice(),this.render()):this.alert();if(this.choices.some(u=>u.newChoice))return this.alert();let{reorder:r,sort:o}=this.options,a=this.multiple===!0,n=this.selected;return n===void 0?this.alert():(Array.isArray(n)&&r!==!1&&o!==!0&&(n=ok.reorder(n)),this.value=a?n.map(u=>u.name):n.name,super.submit())}set choices(e=[]){this.state._choices=this.state._choices||[],this.state.choices=e;for(let r of e)this.state._choices.some(o=>o.name===r.name)||this.state._choices.push(r);if(!this._initial&&this.options.initial){this._initial=!0;let r=this.initial;if(typeof r=="string"||typeof r=="number"){let o=this.find(r);o&&(this.initial=o.index,this.focus(o,!0))}}}get choices(){return Fhe(this,this.state.choices||[])}set visible(e){this.state.visible=e}get visible(){return(this.state.visible||this.choices).slice(0,this.limit)}set limit(e){this.state.limit=e}get limit(){let{state:e,options:r,choices:o}=this,a=e.limit||this._limit||r.limit||o.length;return Math.min(a,this.height)}set value(e){super.value=e}get value(){return typeof super.value!="string"&&super.value===this.initial?this.input:super.value}set index(e){this.state.index=e}get index(){return Math.max(0,this.state?this.state.index:0)}get enabled(){return this.filter(this.isEnabled.bind(this))}get focused(){let e=this.choices[this.index];return e&&this.state.submitted&&this.multiple!==!0&&(e.enabled=!0),e}get selectable(){return this.choices.filter(e=>!this.isDisabled(e))}get selected(){return this.multiple?this.enabled:this.focused}};function Fhe(t,e){if(e instanceof Promise)return e;if(typeof e=="function"){if(ok.isAsyncFn(e))return e;e=e.call(t,t)}for(let r of e){if(Array.isArray(r.choices)){let o=r.choices.filter(a=>!t.isDisabled(a));r.enabled=o.every(a=>a.enabled===!0)}t.isDisabled(r)===!0&&delete r.enabled}return e}Rhe.exports=Y_});var bh=_((o8t,The)=>{"use strict";var fft=u2(),W_=No(),K_=class extends fft{constructor(e){super(e),this.emptyError=this.options.emptyError||"No items were selected"}async dispatch(e,r){if(this.multiple)return this[r.name]?await this[r.name](e,r):await super.dispatch(e,r);this.alert()}separator(){if(this.options.separator)return super.separator();let e=this.styles.muted(this.symbols.ellipsis);return this.state.submitted?super.separator():e}pointer(e,r){return!this.multiple||this.options.pointer?super.pointer(e,r):""}indicator(e,r){return this.multiple?super.indicator(e,r):""}choiceMessage(e,r){let o=this.resolve(e.message,this.state,e,r);return e.role==="heading"&&!W_.hasColor(o)&&(o=this.styles.strong(o)),this.resolve(o,this.state,e,r)}choiceSeparator(){return":"}async renderChoice(e,r){await this.onChoice(e,r);let o=this.index===r,a=await this.pointer(e,r),n=await this.indicator(e,r)+(e.pad||""),u=await this.resolve(e.hint,this.state,e,r);u&&!W_.hasColor(u)&&(u=this.styles.muted(u));let A=this.indent(e),p=await this.choiceMessage(e,r),h=()=>[this.margin[3],A+a+n,p,this.margin[1],u].filter(Boolean).join(" ");return e.role==="heading"?h():e.disabled?(W_.hasColor(p)||(p=this.styles.disabled(p)),h()):(o&&(p=this.styles.em(p)),h())}async renderChoices(){if(this.state.loading==="choices")return this.styles.warning("Loading choices");if(this.state.submitted)return"";let e=this.visible.map(async(n,u)=>await this.renderChoice(n,u)),r=await Promise.all(e);r.length||r.push(this.styles.danger("No matching choices"));let o=this.margin[0]+r.join(` +`),a;return this.options.choicesHeader&&(a=await this.resolve(this.options.choicesHeader,this.state)),[a,o].filter(Boolean).join(` +`)}format(){return!this.state.submitted||this.state.cancelled?"":Array.isArray(this.selected)?this.selected.map(e=>this.styles.primary(e.name)).join(", "):this.styles.primary(this.selected.name)}async render(){let{submitted:e,size:r}=this.state,o="",a=await this.header(),n=await this.prefix(),u=await this.separator(),A=await this.message();this.options.promptLine!==!1&&(o=[n,A,u,""].join(" "),this.state.prompt=o);let p=await this.format(),h=await this.error()||await this.hint(),E=await this.renderChoices(),I=await this.footer();p&&(o+=p),h&&!o.includes(h)&&(o+=" "+h),e&&!p&&!E.trim()&&this.multiple&&this.emptyError!=null&&(o+=this.styles.danger(this.emptyError)),this.clear(r),this.write([a,o,E,I].filter(Boolean).join(` +`)),this.write(this.margin[2]),this.restore()}};The.exports=K_});var Lhe=_((a8t,Nhe)=>{"use strict";var pft=bh(),hft=(t,e)=>{let r=t.toLowerCase();return o=>{let n=o.toLowerCase().indexOf(r),u=e(o.slice(n,n+r.length));return n>=0?o.slice(0,n)+u+o.slice(n+r.length):o}},V_=class extends pft{constructor(e){super(e),this.cursorShow()}moveCursor(e){this.state.cursor+=e}dispatch(e){return this.append(e)}space(e){return this.options.multiple?super.space(e):this.append(e)}append(e){let{cursor:r,input:o}=this.state;return this.input=o.slice(0,r)+e+o.slice(r),this.moveCursor(1),this.complete()}delete(){let{cursor:e,input:r}=this.state;return r?(this.input=r.slice(0,e-1)+r.slice(e),this.moveCursor(-1),this.complete()):this.alert()}deleteForward(){let{cursor:e,input:r}=this.state;return r[e]===void 0?this.alert():(this.input=`${r}`.slice(0,e)+`${r}`.slice(e+1),this.complete())}number(e){return this.append(e)}async complete(){this.completing=!0,this.choices=await this.suggest(this.input,this.state._choices),this.state.limit=void 0,this.index=Math.min(Math.max(this.visible.length-1,0),this.index),await this.render(),this.completing=!1}suggest(e=this.input,r=this.state._choices){if(typeof this.options.suggest=="function")return this.options.suggest.call(this,e,r);let o=e.toLowerCase();return r.filter(a=>a.message.toLowerCase().includes(o))}pointer(){return""}format(){if(!this.focused)return this.input;if(this.options.multiple&&this.state.submitted)return this.selected.map(e=>this.styles.primary(e.message)).join(", ");if(this.state.submitted){let e=this.value=this.input=this.focused.value;return this.styles.primary(e)}return this.input}async render(){if(this.state.status!=="pending")return super.render();let e=this.options.highlight?this.options.highlight.bind(this):this.styles.placeholder,r=hft(this.input,e),o=this.choices;this.choices=o.map(a=>({...a,message:r(a.message)})),await super.render(),this.choices=o}submit(){return this.options.multiple&&(this.value=this.selected.map(e=>e.name)),super.submit()}};Nhe.exports=V_});var z_=_((l8t,Ohe)=>{"use strict";var J_=No();Ohe.exports=(t,e={})=>{t.cursorHide();let{input:r="",initial:o="",pos:a,showCursor:n=!0,color:u}=e,A=u||t.styles.placeholder,p=J_.inverse(t.styles.primary),h=R=>p(t.styles.black(R)),E=r,I=" ",v=h(I);if(t.blink&&t.blink.off===!0&&(h=R=>R,v=""),n&&a===0&&o===""&&r==="")return h(I);if(n&&a===0&&(r===o||r===""))return h(o[0])+A(o.slice(1));o=J_.isPrimitive(o)?`${o}`:"",r=J_.isPrimitive(r)?`${r}`:"";let x=o&&o.startsWith(r)&&o!==r,C=x?h(o[r.length]):v;if(a!==r.length&&n===!0&&(E=r.slice(0,a)+h(r[a])+r.slice(a+1),C=""),n===!1&&(C=""),x){let R=t.styles.unstyle(E+C);return E+C+A(o.slice(R.length))}return E+C}});var ak=_((c8t,Mhe)=>{"use strict";var gft=Kc(),dft=bh(),mft=z_(),X_=class extends dft{constructor(e){super({...e,multiple:!0}),this.type="form",this.initial=this.options.initial,this.align=[this.options.align,"right"].find(r=>r!=null),this.emptyError="",this.values={}}async reset(e){return await super.reset(),e===!0&&(this._index=this.index),this.index=this._index,this.values={},this.choices.forEach(r=>r.reset&&r.reset()),this.render()}dispatch(e){return!!e&&this.append(e)}append(e){let r=this.focused;if(!r)return this.alert();let{cursor:o,input:a}=r;return r.value=r.input=a.slice(0,o)+e+a.slice(o),r.cursor++,this.render()}delete(){let e=this.focused;if(!e||e.cursor<=0)return this.alert();let{cursor:r,input:o}=e;return e.value=e.input=o.slice(0,r-1)+o.slice(r),e.cursor--,this.render()}deleteForward(){let e=this.focused;if(!e)return this.alert();let{cursor:r,input:o}=e;if(o[r]===void 0)return this.alert();let a=`${o}`.slice(0,r)+`${o}`.slice(r+1);return e.value=e.input=a,this.render()}right(){let e=this.focused;return e?e.cursor>=e.input.length?this.alert():(e.cursor++,this.render()):this.alert()}left(){let e=this.focused;return e?e.cursor<=0?this.alert():(e.cursor--,this.render()):this.alert()}space(e,r){return this.dispatch(e,r)}number(e,r){return this.dispatch(e,r)}next(){let e=this.focused;if(!e)return this.alert();let{initial:r,input:o}=e;return r&&r.startsWith(o)&&o!==r?(e.value=e.input=r,e.cursor=e.value.length,this.render()):super.next()}prev(){let e=this.focused;return e?e.cursor===0?super.prev():(e.value=e.input="",e.cursor=0,this.render()):this.alert()}separator(){return""}format(e){return this.state.submitted?"":super.format(e)}pointer(){return""}indicator(e){return e.input?"\u29BF":"\u2299"}async choiceSeparator(e,r){let o=await this.resolve(e.separator,this.state,e,r)||":";return o?" "+this.styles.disabled(o):""}async renderChoice(e,r){await this.onChoice(e,r);let{state:o,styles:a}=this,{cursor:n,initial:u="",name:A,hint:p,input:h=""}=e,{muted:E,submitted:I,primary:v,danger:x}=a,C=p,R=this.index===r,L=e.validate||(()=>!0),U=await this.choiceSeparator(e,r),J=e.message;this.align==="right"&&(J=J.padStart(this.longest+1," ")),this.align==="left"&&(J=J.padEnd(this.longest+1," "));let te=this.values[A]=h||u,ae=h?"success":"dark";await L.call(e,te,this.state)!==!0&&(ae="danger");let fe=a[ae],ce=fe(await this.indicator(e,r))+(e.pad||""),me=this.indent(e),he=()=>[me,ce,J+U,h,C].filter(Boolean).join(" ");if(o.submitted)return J=gft.unstyle(J),h=I(h),C="",he();if(e.format)h=await e.format.call(this,h,e,r);else{let Be=this.styles.muted;h=mft(this,{input:h,initial:u,pos:n,showCursor:R,color:Be})}return this.isValue(h)||(h=this.styles.muted(this.symbols.ellipsis)),e.result&&(this.values[A]=await e.result.call(this,te,e,r)),R&&(J=v(J)),e.error?h+=(h?" ":"")+x(e.error.trim()):e.hint&&(h+=(h?" ":"")+E(e.hint.trim())),he()}async submit(){return this.value=this.values,super.base.submit.call(this)}};Mhe.exports=X_});var Z_=_((u8t,_he)=>{"use strict";var yft=ak(),Eft=()=>{throw new Error("expected prompt to have a custom authenticate method")},Uhe=(t=Eft)=>{class e extends yft{constructor(o){super(o)}async submit(){this.value=await t.call(this,this.values,this.state),super.base.submit.call(this)}static create(o){return Uhe(o)}}return e};_he.exports=Uhe()});var Ghe=_((A8t,jhe)=>{"use strict";var Cft=Z_();function wft(t,e){return t.username===this.options.username&&t.password===this.options.password}var Hhe=(t=wft)=>{let e=[{name:"username",message:"username"},{name:"password",message:"password",format(o){return this.options.showPassword?o:(this.state.submitted?this.styles.primary:this.styles.muted)(this.symbols.asterisk.repeat(o.length))}}];class r extends Cft.create(t){constructor(a){super({...a,choices:e})}static create(a){return Hhe(a)}}return r};jhe.exports=Hhe()});var lk=_((f8t,qhe)=>{"use strict";var Ift=gC(),{isPrimitive:Bft,hasColor:vft}=No(),$_=class extends Ift{constructor(e){super(e),this.cursorHide()}async initialize(){let e=await this.resolve(this.initial,this.state);this.input=await this.cast(e),await super.initialize()}dispatch(e){return this.isValue(e)?(this.input=e,this.submit()):this.alert()}format(e){let{styles:r,state:o}=this;return o.submitted?r.success(e):r.primary(e)}cast(e){return this.isTrue(e)}isTrue(e){return/^[ty1]/i.test(e)}isFalse(e){return/^[fn0]/i.test(e)}isValue(e){return Bft(e)&&(this.isTrue(e)||this.isFalse(e))}async hint(){if(this.state.status==="pending"){let e=await this.element("hint");return vft(e)?e:this.styles.muted(e)}}async render(){let{input:e,size:r}=this.state,o=await this.prefix(),a=await this.separator(),n=await this.message(),u=this.styles.muted(this.default),A=[o,n,u,a].filter(Boolean).join(" ");this.state.prompt=A;let p=await this.header(),h=this.value=this.cast(e),E=await this.format(h),I=await this.error()||await this.hint(),v=await this.footer();I&&!A.includes(I)&&(E+=" "+I),A+=" "+E,this.clear(r),this.write([p,A,v].filter(Boolean).join(` +`)),this.restore()}set value(e){super.value=e}get value(){return this.cast(super.value)}};qhe.exports=$_});var Whe=_((p8t,Yhe)=>{"use strict";var Dft=lk(),e8=class extends Dft{constructor(e){super(e),this.default=this.options.default||(this.initial?"(Y/n)":"(y/N)")}};Yhe.exports=e8});var Vhe=_((h8t,Khe)=>{"use strict";var Sft=bh(),Pft=ak(),dC=Pft.prototype,t8=class extends Sft{constructor(e){super({...e,multiple:!0}),this.align=[this.options.align,"left"].find(r=>r!=null),this.emptyError="",this.values={}}dispatch(e,r){let o=this.focused,a=o.parent||{};return!o.editable&&!a.editable&&(e==="a"||e==="i")?super[e]():dC.dispatch.call(this,e,r)}append(e,r){return dC.append.call(this,e,r)}delete(e,r){return dC.delete.call(this,e,r)}space(e){return this.focused.editable?this.append(e):super.space()}number(e){return this.focused.editable?this.append(e):super.number(e)}next(){return this.focused.editable?dC.next.call(this):super.next()}prev(){return this.focused.editable?dC.prev.call(this):super.prev()}async indicator(e,r){let o=e.indicator||"",a=e.editable?o:super.indicator(e,r);return await this.resolve(a,this.state,e,r)||""}indent(e){return e.role==="heading"?"":e.editable?" ":" "}async renderChoice(e,r){return e.indent="",e.editable?dC.renderChoice.call(this,e,r):super.renderChoice(e,r)}error(){return""}footer(){return this.state.error}async validate(){let e=!0;for(let r of this.choices){if(typeof r.validate!="function"||r.role==="heading")continue;let o=r.parent?this.value[r.parent.name]:this.value;if(r.editable?o=r.value===r.name?r.initial||"":r.value:this.isDisabled(r)||(o=r.enabled===!0),e=await r.validate(o,this.state),e!==!0)break}return e!==!0&&(this.state.error=typeof e=="string"?e:"Invalid Input"),e}submit(){if(this.focused.newChoice===!0)return super.submit();if(this.choices.some(e=>e.newChoice))return this.alert();this.value={};for(let e of this.choices){let r=e.parent?this.value[e.parent.name]:this.value;if(e.role==="heading"){this.value[e.name]={};continue}e.editable?r[e.name]=e.value===e.name?e.initial||"":e.value:this.isDisabled(e)||(r[e.name]=e.enabled===!0)}return this.base.submit.call(this)}};Khe.exports=t8});var Wd=_((g8t,Jhe)=>{"use strict";var bft=gC(),xft=z_(),{isPrimitive:kft}=No(),r8=class extends bft{constructor(e){super(e),this.initial=kft(this.initial)?String(this.initial):"",this.initial&&this.cursorHide(),this.state.prevCursor=0,this.state.clipboard=[]}async keypress(e,r={}){let o=this.state.prevKeypress;return this.state.prevKeypress=r,this.options.multiline===!0&&r.name==="return"&&(!o||o.name!=="return")?this.append(` +`,r):super.keypress(e,r)}moveCursor(e){this.cursor+=e}reset(){return this.input=this.value="",this.cursor=0,this.render()}dispatch(e,r){if(!e||r.ctrl||r.code)return this.alert();this.append(e)}append(e){let{cursor:r,input:o}=this.state;this.input=`${o}`.slice(0,r)+e+`${o}`.slice(r),this.moveCursor(String(e).length),this.render()}insert(e){this.append(e)}delete(){let{cursor:e,input:r}=this.state;if(e<=0)return this.alert();this.input=`${r}`.slice(0,e-1)+`${r}`.slice(e),this.moveCursor(-1),this.render()}deleteForward(){let{cursor:e,input:r}=this.state;if(r[e]===void 0)return this.alert();this.input=`${r}`.slice(0,e)+`${r}`.slice(e+1),this.render()}cutForward(){let e=this.cursor;if(this.input.length<=e)return this.alert();this.state.clipboard.push(this.input.slice(e)),this.input=this.input.slice(0,e),this.render()}cutLeft(){let e=this.cursor;if(e===0)return this.alert();let r=this.input.slice(0,e),o=this.input.slice(e),a=r.split(" ");this.state.clipboard.push(a.pop()),this.input=a.join(" "),this.cursor=this.input.length,this.input+=o,this.render()}paste(){if(!this.state.clipboard.length)return this.alert();this.insert(this.state.clipboard.pop()),this.render()}toggleCursor(){this.state.prevCursor?(this.cursor=this.state.prevCursor,this.state.prevCursor=0):(this.state.prevCursor=this.cursor,this.cursor=0),this.render()}first(){this.cursor=0,this.render()}last(){this.cursor=this.input.length-1,this.render()}next(){let e=this.initial!=null?String(this.initial):"";if(!e||!e.startsWith(this.input))return this.alert();this.input=this.initial,this.cursor=this.initial.length,this.render()}prev(){if(!this.input)return this.alert();this.reset()}backward(){return this.left()}forward(){return this.right()}right(){return this.cursor>=this.input.length?this.alert():(this.moveCursor(1),this.render())}left(){return this.cursor<=0?this.alert():(this.moveCursor(-1),this.render())}isValue(e){return!!e}async format(e=this.value){let r=await this.resolve(this.initial,this.state);return this.state.submitted?this.styles.submitted(e||r):xft(this,{input:e,initial:r,pos:this.cursor})}async render(){let e=this.state.size,r=await this.prefix(),o=await this.separator(),a=await this.message(),n=[r,a,o].filter(Boolean).join(" ");this.state.prompt=n;let u=await this.header(),A=await this.format(),p=await this.error()||await this.hint(),h=await this.footer();p&&!A.includes(p)&&(A+=" "+p),n+=" "+A,this.clear(e),this.write([u,n,h].filter(Boolean).join(` +`)),this.restore()}};Jhe.exports=r8});var Xhe=_((d8t,zhe)=>{"use strict";var Qft=t=>t.filter((e,r)=>t.lastIndexOf(e)===r),ck=t=>Qft(t).filter(Boolean);zhe.exports=(t,e={},r="")=>{let{past:o=[],present:a=""}=e,n,u;switch(t){case"prev":case"undo":return n=o.slice(0,o.length-1),u=o[o.length-1]||"",{past:ck([r,...n]),present:u};case"next":case"redo":return n=o.slice(1),u=o[0]||"",{past:ck([...n,r]),present:u};case"save":return{past:ck([...o,r]),present:""};case"remove":return u=ck(o.filter(A=>A!==r)),a="",u.length&&(a=u.pop()),{past:u,present:a};default:throw new Error(`Invalid action: "${t}"`)}}});var i8=_((m8t,$he)=>{"use strict";var Fft=Wd(),Zhe=Xhe(),n8=class extends Fft{constructor(e){super(e);let r=this.options.history;if(r&&r.store){let o=r.values||this.initial;this.autosave=!!r.autosave,this.store=r.store,this.data=this.store.get("values")||{past:[],present:o},this.initial=this.data.present||this.data.past[this.data.past.length-1]}}completion(e){return this.store?(this.data=Zhe(e,this.data,this.input),this.data.present?(this.input=this.data.present,this.cursor=this.input.length,this.render()):this.alert()):this.alert()}altUp(){return this.completion("prev")}altDown(){return this.completion("next")}prev(){return this.save(),super.prev()}save(){!this.store||(this.data=Zhe("save",this.data,this.input),this.store.set("values",this.data))}submit(){return this.store&&this.autosave===!0&&this.save(),super.submit()}};$he.exports=n8});var t0e=_((y8t,e0e)=>{"use strict";var Rft=Wd(),s8=class extends Rft{format(){return""}};e0e.exports=s8});var n0e=_((E8t,r0e)=>{"use strict";var Tft=Wd(),o8=class extends Tft{constructor(e={}){super(e),this.sep=this.options.separator||/, */,this.initial=e.initial||""}split(e=this.value){return e?String(e).split(this.sep):[]}format(){let e=this.state.submitted?this.styles.primary:r=>r;return this.list.map(e).join(", ")}async submit(e){let r=this.state.error||await this.validate(this.list,this.state);return r!==!0?(this.state.error=r,super.submit()):(this.value=this.list,super.submit())}get list(){return this.split()}};r0e.exports=o8});var s0e=_((C8t,i0e)=>{"use strict";var Nft=bh(),a8=class extends Nft{constructor(e){super({...e,multiple:!0})}};i0e.exports=a8});var c8=_((w8t,o0e)=>{"use strict";var Lft=Wd(),l8=class extends Lft{constructor(e={}){super({style:"number",...e}),this.min=this.isValue(e.min)?this.toNumber(e.min):-1/0,this.max=this.isValue(e.max)?this.toNumber(e.max):1/0,this.delay=e.delay!=null?e.delay:1e3,this.float=e.float!==!1,this.round=e.round===!0||e.float===!1,this.major=e.major||10,this.minor=e.minor||1,this.initial=e.initial!=null?e.initial:"",this.input=String(this.initial),this.cursor=this.input.length,this.cursorShow()}append(e){return!/[-+.]/.test(e)||e==="."&&this.input.includes(".")?this.alert("invalid number"):super.append(e)}number(e){return super.append(e)}next(){return this.input&&this.input!==this.initial?this.alert():this.isValue(this.initial)?(this.input=this.initial,this.cursor=String(this.initial).length,this.render()):this.alert()}up(e){let r=e||this.minor,o=this.toNumber(this.input);return o>this.max+r?this.alert():(this.input=`${o+r}`,this.render())}down(e){let r=e||this.minor,o=this.toNumber(this.input);return o<this.min-r?this.alert():(this.input=`${o-r}`,this.render())}shiftDown(){return this.down(this.major)}shiftUp(){return this.up(this.major)}format(e=this.input){return typeof this.options.format=="function"?this.options.format.call(this,e):this.styles.info(e)}toNumber(e=""){return this.float?+e:Math.round(+e)}isValue(e){return/^[-+]?[0-9]+((\.)|(\.[0-9]+))?$/.test(e)}submit(){let e=[this.input,this.initial].find(r=>this.isValue(r));return this.value=this.toNumber(e||0),super.submit()}};o0e.exports=l8});var l0e=_((I8t,a0e)=>{a0e.exports=c8()});var u0e=_((B8t,c0e)=>{"use strict";var Oft=Wd(),u8=class extends Oft{constructor(e){super(e),this.cursorShow()}format(e=this.input){return this.keypressed?(this.state.submitted?this.styles.primary:this.styles.muted)(this.symbols.asterisk.repeat(e.length)):""}};c0e.exports=u8});var p0e=_((v8t,f0e)=>{"use strict";var Mft=Kc(),Uft=u2(),A0e=No(),A8=class extends Uft{constructor(e={}){super(e),this.widths=[].concat(e.messageWidth||50),this.align=[].concat(e.align||"left"),this.linebreak=e.linebreak||!1,this.edgeLength=e.edgeLength||3,this.newline=e.newline||` + `;let r=e.startNumber||1;typeof this.scale=="number"&&(this.scaleKey=!1,this.scale=Array(this.scale).fill(0).map((o,a)=>({name:a+r})))}async reset(){return this.tableized=!1,await super.reset(),this.render()}tableize(){if(this.tableized===!0)return;this.tableized=!0;let e=0;for(let r of this.choices){e=Math.max(e,r.message.length),r.scaleIndex=r.initial||2,r.scale=[];for(let o=0;o<this.scale.length;o++)r.scale.push({index:o})}this.widths[0]=Math.min(this.widths[0],e+3)}async dispatch(e,r){if(this.multiple)return this[r.name]?await this[r.name](e,r):await super.dispatch(e,r);this.alert()}heading(e,r,o){return this.styles.strong(e)}separator(){return this.styles.muted(this.symbols.ellipsis)}right(){let e=this.focused;return e.scaleIndex>=this.scale.length-1?this.alert():(e.scaleIndex++,this.render())}left(){let e=this.focused;return e.scaleIndex<=0?this.alert():(e.scaleIndex--,this.render())}indent(){return""}format(){return this.state.submitted?this.choices.map(r=>this.styles.info(r.index)).join(", "):""}pointer(){return""}renderScaleKey(){return this.scaleKey===!1||this.state.submitted?"":["",...this.scale.map(o=>` ${o.name} - ${o.message}`)].map(o=>this.styles.muted(o)).join(` +`)}renderScaleHeading(e){let r=this.scale.map(p=>p.name);typeof this.options.renderScaleHeading=="function"&&(r=this.options.renderScaleHeading.call(this,e));let o=this.scaleLength-r.join("").length,a=Math.round(o/(r.length-1)),u=r.map(p=>this.styles.strong(p)).join(" ".repeat(a)),A=" ".repeat(this.widths[0]);return this.margin[3]+A+this.margin[1]+u}scaleIndicator(e,r,o){if(typeof this.options.scaleIndicator=="function")return this.options.scaleIndicator.call(this,e,r,o);let a=e.scaleIndex===r.index;return r.disabled?this.styles.hint(this.symbols.radio.disabled):a?this.styles.success(this.symbols.radio.on):this.symbols.radio.off}renderScale(e,r){let o=e.scale.map(n=>this.scaleIndicator(e,n,r)),a=this.term==="Hyper"?"":" ";return o.join(a+this.symbols.line.repeat(this.edgeLength))}async renderChoice(e,r){await this.onChoice(e,r);let o=this.index===r,a=await this.pointer(e,r),n=await e.hint;n&&!A0e.hasColor(n)&&(n=this.styles.muted(n));let u=C=>this.margin[3]+C.replace(/\s+$/,"").padEnd(this.widths[0]," "),A=this.newline,p=this.indent(e),h=await this.resolve(e.message,this.state,e,r),E=await this.renderScale(e,r),I=this.margin[1]+this.margin[3];this.scaleLength=Mft.unstyle(E).length,this.widths[0]=Math.min(this.widths[0],this.width-this.scaleLength-I.length);let x=A0e.wordWrap(h,{width:this.widths[0],newline:A}).split(` +`).map(C=>u(C)+this.margin[1]);return o&&(E=this.styles.info(E),x=x.map(C=>this.styles.info(C))),x[0]+=E,this.linebreak&&x.push(""),[p+a,x.join(` +`)].filter(Boolean)}async renderChoices(){if(this.state.submitted)return"";this.tableize();let e=this.visible.map(async(a,n)=>await this.renderChoice(a,n)),r=await Promise.all(e),o=await this.renderScaleHeading();return this.margin[0]+[o,...r.map(a=>a.join(" "))].join(` +`)}async render(){let{submitted:e,size:r}=this.state,o=await this.prefix(),a=await this.separator(),n=await this.message(),u="";this.options.promptLine!==!1&&(u=[o,n,a,""].join(" "),this.state.prompt=u);let A=await this.header(),p=await this.format(),h=await this.renderScaleKey(),E=await this.error()||await this.hint(),I=await this.renderChoices(),v=await this.footer(),x=this.emptyError;p&&(u+=p),E&&!u.includes(E)&&(u+=" "+E),e&&!p&&!I.trim()&&this.multiple&&x!=null&&(u+=this.styles.danger(x)),this.clear(r),this.write([A,u,h,I,v].filter(Boolean).join(` +`)),this.state.submitted||this.write(this.margin[2]),this.restore()}submit(){this.value={};for(let e of this.choices)this.value[e.name]=e.scaleIndex;return this.base.submit.call(this)}};f0e.exports=A8});var d0e=_((D8t,g0e)=>{"use strict";var h0e=Kc(),_ft=(t="")=>typeof t=="string"?t.replace(/^['"]|['"]$/g,""):"",p8=class{constructor(e){this.name=e.key,this.field=e.field||{},this.value=_ft(e.initial||this.field.initial||""),this.message=e.message||this.name,this.cursor=0,this.input="",this.lines=[]}},Hft=async(t={},e={},r=o=>o)=>{let o=new Set,a=t.fields||[],n=t.template,u=[],A=[],p=[],h=1;typeof n=="function"&&(n=await n());let E=-1,I=()=>n[++E],v=()=>n[E+1],x=C=>{C.line=h,u.push(C)};for(x({type:"bos",value:""});E<n.length-1;){let C=I();if(/^[^\S\n ]$/.test(C)){x({type:"text",value:C});continue}if(C===` +`){x({type:"newline",value:C}),h++;continue}if(C==="\\"){C+=I(),x({type:"text",value:C});continue}if((C==="$"||C==="#"||C==="{")&&v()==="{"){let L=I();C+=L;let U={type:"template",open:C,inner:"",close:"",value:C},J;for(;J=I();){if(J==="}"){v()==="}"&&(J+=I()),U.value+=J,U.close=J;break}J===":"?(U.initial="",U.key=U.inner):U.initial!==void 0&&(U.initial+=J),U.value+=J,U.inner+=J}U.template=U.open+(U.initial||U.inner)+U.close,U.key=U.key||U.inner,e.hasOwnProperty(U.key)&&(U.initial=e[U.key]),U=r(U),x(U),p.push(U.key),o.add(U.key);let te=A.find(ae=>ae.name===U.key);U.field=a.find(ae=>ae.name===U.key),te||(te=new p8(U),A.push(te)),te.lines.push(U.line-1);continue}let R=u[u.length-1];R.type==="text"&&R.line===h?R.value+=C:x({type:"text",value:C})}return x({type:"eos",value:""}),{input:n,tabstops:u,unique:o,keys:p,items:A}};g0e.exports=async t=>{let e=t.options,r=new Set(e.required===!0?[]:e.required||[]),o={...e.values,...e.initial},{tabstops:a,items:n,keys:u}=await Hft(e,o),A=f8("result",t,e),p=f8("format",t,e),h=f8("validate",t,e,!0),E=t.isValue.bind(t);return async(I={},v=!1)=>{let x=0;I.required=r,I.items=n,I.keys=u,I.output="";let C=async(J,te,ae,fe)=>{let ce=await h(J,te,ae,fe);return ce===!1?"Invalid field "+ae.name:ce};for(let J of a){let te=J.value,ae=J.key;if(J.type!=="template"){te&&(I.output+=te);continue}if(J.type==="template"){let fe=n.find(we=>we.name===ae);e.required===!0&&I.required.add(fe.name);let ce=[fe.input,I.values[fe.value],fe.value,te].find(E),he=(fe.field||{}).message||J.inner;if(v){let we=await C(I.values[ae],I,fe,x);if(we&&typeof we=="string"||we===!1){I.invalid.set(ae,we);continue}I.invalid.delete(ae);let g=await A(I.values[ae],I,fe,x);I.output+=h0e.unstyle(g);continue}fe.placeholder=!1;let Be=te;te=await p(te,I,fe,x),ce!==te?(I.values[ae]=ce,te=t.styles.typing(ce),I.missing.delete(he)):(I.values[ae]=void 0,ce=`<${he}>`,te=t.styles.primary(ce),fe.placeholder=!0,I.required.has(ae)&&I.missing.add(he)),I.missing.has(he)&&I.validating&&(te=t.styles.warning(ce)),I.invalid.has(ae)&&I.validating&&(te=t.styles.danger(ce)),x===I.index&&(Be!==te?te=t.styles.underline(te):te=t.styles.heading(h0e.unstyle(te))),x++}te&&(I.output+=te)}let R=I.output.split(` +`).map(J=>" "+J),L=n.length,U=0;for(let J of n)I.invalid.has(J.name)&&J.lines.forEach(te=>{R[te][0]===" "&&(R[te]=I.styles.danger(I.symbols.bullet)+R[te].slice(1))}),t.isValue(I.values[J.name])&&U++;return I.completed=(U/L*100).toFixed(0),I.output=R.join(` +`),I.output}};function f8(t,e,r,o){return(a,n,u,A)=>typeof u.field[t]=="function"?u.field[t].call(e,a,n,u,A):[o,a].find(p=>e.isValue(p))}});var y0e=_((S8t,m0e)=>{"use strict";var jft=Kc(),Gft=d0e(),qft=gC(),h8=class extends qft{constructor(e){super(e),this.cursorHide(),this.reset(!0)}async initialize(){this.interpolate=await Gft(this),await super.initialize()}async reset(e){this.state.keys=[],this.state.invalid=new Map,this.state.missing=new Set,this.state.completed=0,this.state.values={},e!==!0&&(await this.initialize(),await this.render())}moveCursor(e){let r=this.getItem();this.cursor+=e,r.cursor+=e}dispatch(e,r){if(!r.code&&!r.ctrl&&e!=null&&this.getItem()){this.append(e,r);return}this.alert()}append(e,r){let o=this.getItem(),a=o.input.slice(0,this.cursor),n=o.input.slice(this.cursor);this.input=o.input=`${a}${e}${n}`,this.moveCursor(1),this.render()}delete(){let e=this.getItem();if(this.cursor<=0||!e.input)return this.alert();let r=e.input.slice(this.cursor),o=e.input.slice(0,this.cursor-1);this.input=e.input=`${o}${r}`,this.moveCursor(-1),this.render()}increment(e){return e>=this.state.keys.length-1?0:e+1}decrement(e){return e<=0?this.state.keys.length-1:e-1}first(){this.state.index=0,this.render()}last(){this.state.index=this.state.keys.length-1,this.render()}right(){if(this.cursor>=this.input.length)return this.alert();this.moveCursor(1),this.render()}left(){if(this.cursor<=0)return this.alert();this.moveCursor(-1),this.render()}prev(){this.state.index=this.decrement(this.state.index),this.getItem(),this.render()}next(){this.state.index=this.increment(this.state.index),this.getItem(),this.render()}up(){this.prev()}down(){this.next()}format(e){let r=this.state.completed<100?this.styles.warning:this.styles.success;return this.state.submitted===!0&&this.state.completed!==100&&(r=this.styles.danger),r(`${this.state.completed}% completed`)}async render(){let{index:e,keys:r=[],submitted:o,size:a}=this.state,n=[this.options.newline,` +`].find(J=>J!=null),u=await this.prefix(),A=await this.separator(),p=await this.message(),h=[u,p,A].filter(Boolean).join(" ");this.state.prompt=h;let E=await this.header(),I=await this.error()||"",v=await this.hint()||"",x=o?"":await this.interpolate(this.state),C=this.state.key=r[e]||"",R=await this.format(C),L=await this.footer();R&&(h+=" "+R),v&&!R&&this.state.completed===0&&(h+=" "+v),this.clear(a);let U=[E,h,x,L,I.trim()];this.write(U.filter(Boolean).join(n)),this.restore()}getItem(e){let{items:r,keys:o,index:a}=this.state,n=r.find(u=>u.name===o[a]);return n&&n.input!=null&&(this.input=n.input,this.cursor=n.cursor),n}async submit(){typeof this.interpolate!="function"&&await this.initialize(),await this.interpolate(this.state,!0);let{invalid:e,missing:r,output:o,values:a}=this.state;if(e.size){let A="";for(let[p,h]of e)A+=`Invalid ${p}: ${h} +`;return this.state.error=A,super.submit()}if(r.size)return this.state.error="Required: "+[...r.keys()].join(", "),super.submit();let u=jft.unstyle(o).split(` +`).map(A=>A.slice(1)).join(` +`);return this.value={values:a,result:u},super.submit()}};m0e.exports=h8});var C0e=_((P8t,E0e)=>{"use strict";var Yft="(Use <shift>+<up/down> to sort)",Wft=bh(),g8=class extends Wft{constructor(e){super({...e,reorder:!1,sort:!0,multiple:!0}),this.state.hint=[this.options.hint,Yft].find(this.isValue.bind(this))}indicator(){return""}async renderChoice(e,r){let o=await super.renderChoice(e,r),a=this.symbols.identicalTo+" ",n=this.index===r&&this.sorting?this.styles.muted(a):" ";return this.options.drag===!1&&(n=""),this.options.numbered===!0?n+`${r+1} - `+o:n+o}get selected(){return this.choices}submit(){return this.value=this.choices.map(e=>e.value),super.submit()}};E0e.exports=g8});var I0e=_((b8t,w0e)=>{"use strict";var Kft=u2(),d8=class extends Kft{constructor(e={}){if(super(e),this.emptyError=e.emptyError||"No items were selected",this.term=process.env.TERM_PROGRAM,!this.options.header){let r=["","4 - Strongly Agree","3 - Agree","2 - Neutral","1 - Disagree","0 - Strongly Disagree",""];r=r.map(o=>this.styles.muted(o)),this.state.header=r.join(` + `)}}async toChoices(...e){if(this.createdScales)return!1;this.createdScales=!0;let r=await super.toChoices(...e);for(let o of r)o.scale=Vft(5,this.options),o.scaleIdx=2;return r}dispatch(){this.alert()}space(){let e=this.focused,r=e.scale[e.scaleIdx],o=r.selected;return e.scale.forEach(a=>a.selected=!1),r.selected=!o,this.render()}indicator(){return""}pointer(){return""}separator(){return this.styles.muted(this.symbols.ellipsis)}right(){let e=this.focused;return e.scaleIdx>=e.scale.length-1?this.alert():(e.scaleIdx++,this.render())}left(){let e=this.focused;return e.scaleIdx<=0?this.alert():(e.scaleIdx--,this.render())}indent(){return" "}async renderChoice(e,r){await this.onChoice(e,r);let o=this.index===r,a=this.term==="Hyper",n=a?9:8,u=a?"":" ",A=this.symbols.line.repeat(n),p=" ".repeat(n+(a?0:1)),h=te=>(te?this.styles.success("\u25C9"):"\u25EF")+u,E=r+1+".",I=o?this.styles.heading:this.styles.noop,v=await this.resolve(e.message,this.state,e,r),x=this.indent(e),C=x+e.scale.map((te,ae)=>h(ae===e.scaleIdx)).join(A),R=te=>te===e.scaleIdx?I(te):te,L=x+e.scale.map((te,ae)=>R(ae)).join(p),U=()=>[E,v].filter(Boolean).join(" "),J=()=>[U(),C,L," "].filter(Boolean).join(` +`);return o&&(C=this.styles.cyan(C),L=this.styles.cyan(L)),J()}async renderChoices(){if(this.state.submitted)return"";let e=this.visible.map(async(o,a)=>await this.renderChoice(o,a)),r=await Promise.all(e);return r.length||r.push(this.styles.danger("No matching choices")),r.join(` +`)}format(){return this.state.submitted?this.choices.map(r=>this.styles.info(r.scaleIdx)).join(", "):""}async render(){let{submitted:e,size:r}=this.state,o=await this.prefix(),a=await this.separator(),n=await this.message(),u=[o,n,a].filter(Boolean).join(" ");this.state.prompt=u;let A=await this.header(),p=await this.format(),h=await this.error()||await this.hint(),E=await this.renderChoices(),I=await this.footer();(p||!h)&&(u+=" "+p),h&&!u.includes(h)&&(u+=" "+h),e&&!p&&!E&&this.multiple&&this.type!=="form"&&(u+=this.styles.danger(this.emptyError)),this.clear(r),this.write([u,A,E,I].filter(Boolean).join(` +`)),this.restore()}submit(){this.value={};for(let e of this.choices)this.value[e.name]=e.scaleIdx;return this.base.submit.call(this)}};function Vft(t,e={}){if(Array.isArray(e.scale))return e.scale.map(o=>({...o}));let r=[];for(let o=1;o<t+1;o++)r.push({i:o,selected:!1});return r}w0e.exports=d8});var v0e=_((x8t,B0e)=>{B0e.exports=i8()});var S0e=_((k8t,D0e)=>{"use strict";var Jft=lk(),m8=class extends Jft{async initialize(){await super.initialize(),this.value=this.initial=!!this.options.initial,this.disabled=this.options.disabled||"no",this.enabled=this.options.enabled||"yes",await this.render()}reset(){this.value=this.initial,this.render()}delete(){this.alert()}toggle(){this.value=!this.value,this.render()}enable(){if(this.value===!0)return this.alert();this.value=!0,this.render()}disable(){if(this.value===!1)return this.alert();this.value=!1,this.render()}up(){this.toggle()}down(){this.toggle()}right(){this.toggle()}left(){this.toggle()}next(){this.toggle()}prev(){this.toggle()}dispatch(e="",r){switch(e.toLowerCase()){case" ":return this.toggle();case"1":case"y":case"t":return this.enable();case"0":case"n":case"f":return this.disable();default:return this.alert()}}format(){let e=o=>this.styles.primary.underline(o);return[this.value?this.disabled:e(this.disabled),this.value?e(this.enabled):this.enabled].join(this.styles.muted(" / "))}async render(){let{size:e}=this.state,r=await this.header(),o=await this.prefix(),a=await this.separator(),n=await this.message(),u=await this.format(),A=await this.error()||await this.hint(),p=await this.footer(),h=[o,n,a,u].join(" ");this.state.prompt=h,A&&!h.includes(A)&&(h+=" "+A),this.clear(e),this.write([r,h,p].filter(Boolean).join(` +`)),this.write(this.margin[2]),this.restore()}};D0e.exports=m8});var b0e=_((Q8t,P0e)=>{"use strict";var zft=bh(),y8=class extends zft{constructor(e){if(super(e),typeof this.options.correctChoice!="number"||this.options.correctChoice<0)throw new Error("Please specify the index of the correct answer from the list of choices")}async toChoices(e,r){let o=await super.toChoices(e,r);if(o.length<2)throw new Error("Please give at least two choices to the user");if(this.options.correctChoice>o.length)throw new Error("Please specify the index of the correct answer from the list of choices");return o}check(e){return e.index===this.options.correctChoice}async result(e){return{selectedAnswer:e,correctAnswer:this.options.choices[this.options.correctChoice].value,correct:await this.check(this.state)}}};P0e.exports=y8});var k0e=_(E8=>{"use strict";var x0e=No(),As=(t,e)=>{x0e.defineExport(E8,t,e),x0e.defineExport(E8,t.toLowerCase(),e)};As("AutoComplete",()=>Lhe());As("BasicAuth",()=>Ghe());As("Confirm",()=>Whe());As("Editable",()=>Vhe());As("Form",()=>ak());As("Input",()=>i8());As("Invisible",()=>t0e());As("List",()=>n0e());As("MultiSelect",()=>s0e());As("Numeral",()=>l0e());As("Password",()=>u0e());As("Scale",()=>p0e());As("Select",()=>bh());As("Snippet",()=>y0e());As("Sort",()=>C0e());As("Survey",()=>I0e());As("Text",()=>v0e());As("Toggle",()=>S0e());As("Quiz",()=>b0e())});var F0e=_((R8t,Q0e)=>{Q0e.exports={ArrayPrompt:u2(),AuthPrompt:Z_(),BooleanPrompt:lk(),NumberPrompt:c8(),StringPrompt:Wd()}});var f2=_((T8t,T0e)=>{"use strict";var R0e=ve("assert"),w8=ve("events"),xh=No(),Jc=class extends w8{constructor(e,r){super(),this.options=xh.merge({},e),this.answers={...r}}register(e,r){if(xh.isObject(e)){for(let a of Object.keys(e))this.register(a,e[a]);return this}R0e.equal(typeof r,"function","expected a function");let o=e.toLowerCase();return r.prototype instanceof this.Prompt?this.prompts[o]=r:this.prompts[o]=r(this.Prompt,this),this}async prompt(e=[]){for(let r of[].concat(e))try{typeof r=="function"&&(r=await r.call(this)),await this.ask(xh.merge({},this.options,r))}catch(o){return Promise.reject(o)}return this.answers}async ask(e){typeof e=="function"&&(e=await e.call(this));let r=xh.merge({},this.options,e),{type:o,name:a}=e,{set:n,get:u}=xh;if(typeof o=="function"&&(o=await o.call(this,e,this.answers)),!o)return this.answers[a];R0e(this.prompts[o],`Prompt "${o}" is not registered`);let A=new this.prompts[o](r),p=u(this.answers,a);A.state.answers=this.answers,A.enquirer=this,a&&A.on("submit",E=>{this.emit("answer",a,E,A),n(this.answers,a,E)});let h=A.emit.bind(A);return A.emit=(...E)=>(this.emit.call(this,...E),h(...E)),this.emit("prompt",A,this),r.autofill&&p!=null?(A.value=A.input=p,r.autofill==="show"&&await A.submit()):p=A.value=await A.run(),p}use(e){return e.call(this,this),this}set Prompt(e){this._Prompt=e}get Prompt(){return this._Prompt||this.constructor.Prompt}get prompts(){return this.constructor.prompts}static set Prompt(e){this._Prompt=e}static get Prompt(){return this._Prompt||gC()}static get prompts(){return k0e()}static get types(){return F0e()}static get prompt(){let e=(r,...o)=>{let a=new this(...o),n=a.emit.bind(a);return a.emit=(...u)=>(e.emit(...u),n(...u)),a.prompt(r)};return xh.mixinEmitter(e,new w8),e}};xh.mixinEmitter(Jc,new w8);var C8=Jc.prompts;for(let t of Object.keys(C8)){let e=t.toLowerCase(),r=o=>new C8[t](o).run();Jc.prompt[e]=r,Jc[e]=r,Jc[t]||Reflect.defineProperty(Jc,t,{get:()=>C8[t]})}var A2=t=>{xh.defineExport(Jc,t,()=>Jc.types[t])};A2("ArrayPrompt");A2("AuthPrompt");A2("BooleanPrompt");A2("NumberPrompt");A2("StringPrompt");T0e.exports=Jc});var d2=_((dHt,H0e)=>{var rpt=zx();function npt(t,e,r){var o=t==null?void 0:rpt(t,e);return o===void 0?r:o}H0e.exports=npt});var q0e=_((IHt,G0e)=>{function ipt(t,e){for(var r=-1,o=t==null?0:t.length;++r<o&&e(t[r],r,t)!==!1;);return t}G0e.exports=ipt});var W0e=_((BHt,Y0e)=>{var spt=dd(),opt=zS();function apt(t,e){return t&&spt(e,opt(e),t)}Y0e.exports=apt});var V0e=_((vHt,K0e)=>{var lpt=dd(),cpt=qy();function upt(t,e){return t&&lpt(e,cpt(e),t)}K0e.exports=upt});var z0e=_((DHt,J0e)=>{var Apt=dd(),fpt=qS();function ppt(t,e){return Apt(t,fpt(t),e)}J0e.exports=ppt});var P8=_((SHt,X0e)=>{var hpt=GS(),gpt=tP(),dpt=qS(),mpt=WN(),ypt=Object.getOwnPropertySymbols,Ept=ypt?function(t){for(var e=[];t;)hpt(e,dpt(t)),t=gpt(t);return e}:mpt;X0e.exports=Ept});var $0e=_((PHt,Z0e)=>{var Cpt=dd(),wpt=P8();function Ipt(t,e){return Cpt(t,wpt(t),e)}Z0e.exports=Ipt});var b8=_((bHt,ege)=>{var Bpt=YN(),vpt=P8(),Dpt=qy();function Spt(t){return Bpt(t,Dpt,vpt)}ege.exports=Spt});var rge=_((xHt,tge)=>{var Ppt=Object.prototype,bpt=Ppt.hasOwnProperty;function xpt(t){var e=t.length,r=new t.constructor(e);return e&&typeof t[0]=="string"&&bpt.call(t,"index")&&(r.index=t.index,r.input=t.input),r}tge.exports=xpt});var ige=_((kHt,nge)=>{var kpt=$S();function Qpt(t,e){var r=e?kpt(t.buffer):t.buffer;return new t.constructor(r,t.byteOffset,t.byteLength)}nge.exports=Qpt});var oge=_((QHt,sge)=>{var Fpt=/\w*$/;function Rpt(t){var e=new t.constructor(t.source,Fpt.exec(t));return e.lastIndex=t.lastIndex,e}sge.exports=Rpt});var Age=_((FHt,uge)=>{var age=pd(),lge=age?age.prototype:void 0,cge=lge?lge.valueOf:void 0;function Tpt(t){return cge?Object(cge.call(t)):{}}uge.exports=Tpt});var pge=_((RHt,fge)=>{var Npt=$S(),Lpt=ige(),Opt=oge(),Mpt=Age(),Upt=oL(),_pt="[object Boolean]",Hpt="[object Date]",jpt="[object Map]",Gpt="[object Number]",qpt="[object RegExp]",Ypt="[object Set]",Wpt="[object String]",Kpt="[object Symbol]",Vpt="[object ArrayBuffer]",Jpt="[object DataView]",zpt="[object Float32Array]",Xpt="[object Float64Array]",Zpt="[object Int8Array]",$pt="[object Int16Array]",eht="[object Int32Array]",tht="[object Uint8Array]",rht="[object Uint8ClampedArray]",nht="[object Uint16Array]",iht="[object Uint32Array]";function sht(t,e,r){var o=t.constructor;switch(e){case Vpt:return Npt(t);case _pt:case Hpt:return new o(+t);case Jpt:return Lpt(t,r);case zpt:case Xpt:case Zpt:case $pt:case eht:case tht:case rht:case nht:case iht:return Upt(t,r);case jpt:return new o;case Gpt:case Wpt:return new o(t);case qpt:return Opt(t);case Ypt:return new o;case Kpt:return Mpt(t)}}fge.exports=sht});var gge=_((THt,hge)=>{var oht=qI(),aht=Ju(),lht="[object Map]";function cht(t){return aht(t)&&oht(t)==lht}hge.exports=cht});var Ege=_((NHt,yge)=>{var uht=gge(),Aht=WS(),dge=KS(),mge=dge&&dge.isMap,fht=mge?Aht(mge):uht;yge.exports=fht});var wge=_((LHt,Cge)=>{var pht=qI(),hht=Ju(),ght="[object Set]";function dht(t){return hht(t)&&pht(t)==ght}Cge.exports=dht});var Dge=_((OHt,vge)=>{var mht=wge(),yht=WS(),Ige=KS(),Bge=Ige&&Ige.isSet,Eht=Bge?yht(Bge):mht;vge.exports=Eht});var x8=_((MHt,xge)=>{var Cht=HS(),wht=q0e(),Iht=rP(),Bht=W0e(),vht=V0e(),Dht=sL(),Sht=eP(),Pht=z0e(),bht=$0e(),xht=zN(),kht=b8(),Qht=qI(),Fht=rge(),Rht=pge(),Tht=aL(),Nht=Hl(),Lht=UI(),Oht=Ege(),Mht=il(),Uht=Dge(),_ht=zS(),Hht=qy(),jht=1,Ght=2,qht=4,Sge="[object Arguments]",Yht="[object Array]",Wht="[object Boolean]",Kht="[object Date]",Vht="[object Error]",Pge="[object Function]",Jht="[object GeneratorFunction]",zht="[object Map]",Xht="[object Number]",bge="[object Object]",Zht="[object RegExp]",$ht="[object Set]",e0t="[object String]",t0t="[object Symbol]",r0t="[object WeakMap]",n0t="[object ArrayBuffer]",i0t="[object DataView]",s0t="[object Float32Array]",o0t="[object Float64Array]",a0t="[object Int8Array]",l0t="[object Int16Array]",c0t="[object Int32Array]",u0t="[object Uint8Array]",A0t="[object Uint8ClampedArray]",f0t="[object Uint16Array]",p0t="[object Uint32Array]",ri={};ri[Sge]=ri[Yht]=ri[n0t]=ri[i0t]=ri[Wht]=ri[Kht]=ri[s0t]=ri[o0t]=ri[a0t]=ri[l0t]=ri[c0t]=ri[zht]=ri[Xht]=ri[bge]=ri[Zht]=ri[$ht]=ri[e0t]=ri[t0t]=ri[u0t]=ri[A0t]=ri[f0t]=ri[p0t]=!0;ri[Vht]=ri[Pge]=ri[r0t]=!1;function Ak(t,e,r,o,a,n){var u,A=e&jht,p=e&Ght,h=e&qht;if(r&&(u=a?r(t,o,a,n):r(t)),u!==void 0)return u;if(!Mht(t))return t;var E=Nht(t);if(E){if(u=Fht(t),!A)return Sht(t,u)}else{var I=Qht(t),v=I==Pge||I==Jht;if(Lht(t))return Dht(t,A);if(I==bge||I==Sge||v&&!a){if(u=p||v?{}:Tht(t),!A)return p?bht(t,vht(u,t)):Pht(t,Bht(u,t))}else{if(!ri[I])return a?t:{};u=Rht(t,I,A)}}n||(n=new Cht);var x=n.get(t);if(x)return x;n.set(t,u),Uht(t)?t.forEach(function(L){u.add(Ak(L,e,r,L,t,n))}):Oht(t)&&t.forEach(function(L,U){u.set(U,Ak(L,e,r,U,t,n))});var C=h?p?kht:xht:p?Hht:_ht,R=E?void 0:C(t);return wht(R||t,function(L,U){R&&(U=L,L=t[U]),Iht(u,U,Ak(L,e,r,U,t,n))}),u}xge.exports=Ak});var k8=_((UHt,kge)=>{var h0t=x8(),g0t=1,d0t=4;function m0t(t){return h0t(t,g0t|d0t)}kge.exports=m0t});var Q8=_((_Ht,Qge)=>{var y0t=I_();function E0t(t,e,r){return t==null?t:y0t(t,e,r)}Qge.exports=E0t});var Lge=_((WHt,Nge)=>{var C0t=Object.prototype,w0t=C0t.hasOwnProperty;function I0t(t,e){return t!=null&&w0t.call(t,e)}Nge.exports=I0t});var Mge=_((KHt,Oge)=>{var B0t=Lge(),v0t=B_();function D0t(t,e){return t!=null&&v0t(t,e,B0t)}Oge.exports=D0t});var _ge=_((VHt,Uge)=>{function S0t(t){var e=t==null?0:t.length;return e?t[e-1]:void 0}Uge.exports=S0t});var jge=_((JHt,Hge)=>{var P0t=zx(),b0t=pU();function x0t(t,e){return e.length<2?t:P0t(t,b0t(e,0,-1))}Hge.exports=x0t});var R8=_((zHt,Gge)=>{var k0t=Gd(),Q0t=_ge(),F0t=jge(),R0t=lC();function T0t(t,e){return e=k0t(e,t),t=F0t(t,e),t==null||delete t[R0t(Q0t(e))]}Gge.exports=T0t});var T8=_((XHt,qge)=>{var N0t=R8();function L0t(t,e){return t==null?!0:N0t(t,e)}qge.exports=L0t});var Jge=_((S6t,U0t)=>{U0t.exports={name:"@yarnpkg/cli",version:"4.1.0",license:"BSD-2-Clause",main:"./sources/index.ts",exports:{".":"./sources/index.ts","./polyfills":"./sources/polyfills.ts","./package.json":"./package.json"},dependencies:{"@yarnpkg/core":"workspace:^","@yarnpkg/fslib":"workspace:^","@yarnpkg/libzip":"workspace:^","@yarnpkg/parsers":"workspace:^","@yarnpkg/plugin-compat":"workspace:^","@yarnpkg/plugin-constraints":"workspace:^","@yarnpkg/plugin-dlx":"workspace:^","@yarnpkg/plugin-essentials":"workspace:^","@yarnpkg/plugin-exec":"workspace:^","@yarnpkg/plugin-file":"workspace:^","@yarnpkg/plugin-git":"workspace:^","@yarnpkg/plugin-github":"workspace:^","@yarnpkg/plugin-http":"workspace:^","@yarnpkg/plugin-init":"workspace:^","@yarnpkg/plugin-interactive-tools":"workspace:^","@yarnpkg/plugin-link":"workspace:^","@yarnpkg/plugin-nm":"workspace:^","@yarnpkg/plugin-npm":"workspace:^","@yarnpkg/plugin-npm-cli":"workspace:^","@yarnpkg/plugin-pack":"workspace:^","@yarnpkg/plugin-patch":"workspace:^","@yarnpkg/plugin-pnp":"workspace:^","@yarnpkg/plugin-pnpm":"workspace:^","@yarnpkg/plugin-stage":"workspace:^","@yarnpkg/plugin-typescript":"workspace:^","@yarnpkg/plugin-version":"workspace:^","@yarnpkg/plugin-workspace-tools":"workspace:^","@yarnpkg/shell":"workspace:^","ci-info":"^3.2.0",clipanion:"^4.0.0-rc.2",semver:"^7.1.2",tslib:"^2.4.0",typanion:"^3.14.0"},devDependencies:{"@types/semver":"^7.1.0","@yarnpkg/builder":"workspace:^","@yarnpkg/monorepo":"workspace:^","@yarnpkg/pnpify":"workspace:^"},peerDependencies:{"@yarnpkg/core":"workspace:^"},scripts:{postpack:"rm -rf lib",prepack:'run build:compile "$(pwd)"',"build:cli+hook":"run build:pnp:hook && builder build bundle","build:cli":"builder build bundle","run:cli":"builder run","update-local":"run build:cli --no-git-hash && rsync -a --delete bundles/ bin/"},publishConfig:{main:"./lib/index.js",bin:null,exports:{".":"./lib/index.js","./package.json":"./package.json"}},files:["/lib/**/*","!/lib/pluginConfiguration.*","!/lib/cli.*"],"@yarnpkg/builder":{bundles:{standard:["@yarnpkg/plugin-essentials","@yarnpkg/plugin-compat","@yarnpkg/plugin-constraints","@yarnpkg/plugin-dlx","@yarnpkg/plugin-exec","@yarnpkg/plugin-file","@yarnpkg/plugin-git","@yarnpkg/plugin-github","@yarnpkg/plugin-http","@yarnpkg/plugin-init","@yarnpkg/plugin-interactive-tools","@yarnpkg/plugin-link","@yarnpkg/plugin-nm","@yarnpkg/plugin-npm","@yarnpkg/plugin-npm-cli","@yarnpkg/plugin-pack","@yarnpkg/plugin-patch","@yarnpkg/plugin-pnp","@yarnpkg/plugin-pnpm","@yarnpkg/plugin-stage","@yarnpkg/plugin-typescript","@yarnpkg/plugin-version","@yarnpkg/plugin-workspace-tools"]}},repository:{type:"git",url:"ssh://git@github.com/yarnpkg/berry.git",directory:"packages/yarnpkg-cli"},engines:{node:">=18.12.0"}}});var G8=_((n9t,ade)=>{"use strict";ade.exports=function(e,r){r===!0&&(r=0);var o="";if(typeof e=="string")try{o=new URL(e).protocol}catch{}else e&&e.constructor===URL&&(o=e.protocol);var a=o.split(/\:|\+/).filter(Boolean);return typeof r=="number"?a[r]:a}});var cde=_((i9t,lde)=>{"use strict";var igt=G8();function sgt(t){var e={protocols:[],protocol:null,port:null,resource:"",host:"",user:"",password:"",pathname:"",hash:"",search:"",href:t,query:{},parse_failed:!1};try{var r=new URL(t);e.protocols=igt(r),e.protocol=e.protocols[0],e.port=r.port,e.resource=r.hostname,e.host=r.host,e.user=r.username||"",e.password=r.password||"",e.pathname=r.pathname,e.hash=r.hash.slice(1),e.search=r.search.slice(1),e.href=r.href,e.query=Object.fromEntries(r.searchParams)}catch{e.protocols=["file"],e.protocol=e.protocols[0],e.port="",e.resource="",e.user="",e.pathname="",e.hash="",e.search="",e.href=t,e.query={},e.parse_failed=!0}return e}lde.exports=sgt});var fde=_((s9t,Ade)=>{"use strict";var ogt=cde();function agt(t){return t&&typeof t=="object"&&"default"in t?t:{default:t}}var lgt=agt(ogt),cgt="text/plain",ugt="us-ascii",ude=(t,e)=>e.some(r=>r instanceof RegExp?r.test(t):r===t),Agt=(t,{stripHash:e})=>{let r=/^data:(?<type>[^,]*?),(?<data>[^#]*?)(?:#(?<hash>.*))?$/.exec(t);if(!r)throw new Error(`Invalid URL: ${t}`);let{type:o,data:a,hash:n}=r.groups,u=o.split(";");n=e?"":n;let A=!1;u[u.length-1]==="base64"&&(u.pop(),A=!0);let p=(u.shift()||"").toLowerCase(),E=[...u.map(I=>{let[v,x=""]=I.split("=").map(C=>C.trim());return v==="charset"&&(x=x.toLowerCase(),x===ugt)?"":`${v}${x?`=${x}`:""}`}).filter(Boolean)];return A&&E.push("base64"),(E.length>0||p&&p!==cgt)&&E.unshift(p),`data:${E.join(";")},${A?a.trim():a}${n?`#${n}`:""}`};function fgt(t,e){if(e={defaultProtocol:"http:",normalizeProtocol:!0,forceHttp:!1,forceHttps:!1,stripAuthentication:!0,stripHash:!1,stripTextFragment:!0,stripWWW:!0,removeQueryParameters:[/^utm_\w+/i],removeTrailingSlash:!0,removeSingleSlash:!0,removeDirectoryIndex:!1,sortQueryParameters:!0,...e},t=t.trim(),/^data:/i.test(t))return Agt(t,e);if(/^view-source:/i.test(t))throw new Error("`view-source:` is not supported as it is a non-standard protocol");let r=t.startsWith("//");!r&&/^\.*\//.test(t)||(t=t.replace(/^(?!(?:\w+:)?\/\/)|^\/\//,e.defaultProtocol));let a=new URL(t);if(e.forceHttp&&e.forceHttps)throw new Error("The `forceHttp` and `forceHttps` options cannot be used together");if(e.forceHttp&&a.protocol==="https:"&&(a.protocol="http:"),e.forceHttps&&a.protocol==="http:"&&(a.protocol="https:"),e.stripAuthentication&&(a.username="",a.password=""),e.stripHash?a.hash="":e.stripTextFragment&&(a.hash=a.hash.replace(/#?:~:text.*?$/i,"")),a.pathname){let u=/\b[a-z][a-z\d+\-.]{1,50}:\/\//g,A=0,p="";for(;;){let E=u.exec(a.pathname);if(!E)break;let I=E[0],v=E.index,x=a.pathname.slice(A,v);p+=x.replace(/\/{2,}/g,"/"),p+=I,A=v+I.length}let h=a.pathname.slice(A,a.pathname.length);p+=h.replace(/\/{2,}/g,"/"),a.pathname=p}if(a.pathname)try{a.pathname=decodeURI(a.pathname)}catch{}if(e.removeDirectoryIndex===!0&&(e.removeDirectoryIndex=[/^index\.[a-z]+$/]),Array.isArray(e.removeDirectoryIndex)&&e.removeDirectoryIndex.length>0){let u=a.pathname.split("/"),A=u[u.length-1];ude(A,e.removeDirectoryIndex)&&(u=u.slice(0,-1),a.pathname=u.slice(1).join("/")+"/")}if(a.hostname&&(a.hostname=a.hostname.replace(/\.$/,""),e.stripWWW&&/^www\.(?!www\.)[a-z\-\d]{1,63}\.[a-z.\-\d]{2,63}$/.test(a.hostname)&&(a.hostname=a.hostname.replace(/^www\./,""))),Array.isArray(e.removeQueryParameters))for(let u of[...a.searchParams.keys()])ude(u,e.removeQueryParameters)&&a.searchParams.delete(u);if(e.removeQueryParameters===!0&&(a.search=""),e.sortQueryParameters){a.searchParams.sort();try{a.search=decodeURIComponent(a.search)}catch{}}e.removeTrailingSlash&&(a.pathname=a.pathname.replace(/\/$/,""));let n=t;return t=a.toString(),!e.removeSingleSlash&&a.pathname==="/"&&!n.endsWith("/")&&a.hash===""&&(t=t.replace(/\/$/,"")),(e.removeTrailingSlash||a.pathname==="/")&&a.hash===""&&e.removeSingleSlash&&(t=t.replace(/\/$/,"")),r&&!e.normalizeProtocol&&(t=t.replace(/^http:\/\//,"//")),e.stripProtocol&&(t=t.replace(/^(?:https?:)?\/\//,"")),t}var q8=(t,e=!1)=>{let r=/^(?:([a-z_][a-z0-9_-]{0,31})@|https?:\/\/)([\w\.\-@]+)[\/:]([\~,\.\w,\-,\_,\/]+?(?:\.git|\/)?)$/,o=n=>{let u=new Error(n);throw u.subject_url=t,u};(typeof t!="string"||!t.trim())&&o("Invalid url."),t.length>q8.MAX_INPUT_LENGTH&&o("Input exceeds maximum length. If needed, change the value of parseUrl.MAX_INPUT_LENGTH."),e&&(typeof e!="object"&&(e={stripHash:!1}),t=fgt(t,e));let a=lgt.default(t);if(a.parse_failed){let n=a.href.match(r);n?(a.protocols=["ssh"],a.protocol="ssh",a.resource=n[2],a.host=n[2],a.user=n[1],a.pathname=`/${n[3]}`,a.parse_failed=!1):o("URL parsing failed.")}return a};q8.MAX_INPUT_LENGTH=2048;Ade.exports=q8});var gde=_((o9t,hde)=>{"use strict";var pgt=G8();function pde(t){if(Array.isArray(t))return t.indexOf("ssh")!==-1||t.indexOf("rsync")!==-1;if(typeof t!="string")return!1;var e=pgt(t);if(t=t.substring(t.indexOf("://")+3),pde(e))return!0;var r=new RegExp(".([a-zA-Z\\d]+):(\\d+)/");return!t.match(r)&&t.indexOf("@")<t.indexOf(":")}hde.exports=pde});var yde=_((a9t,mde)=>{"use strict";var hgt=fde(),dde=gde();function ggt(t){var e=hgt(t);return e.token="",e.password==="x-oauth-basic"?e.token=e.user:e.user==="x-token-auth"&&(e.token=e.password),dde(e.protocols)||e.protocols.length===0&&dde(t)?e.protocol="ssh":e.protocols.length?e.protocol=e.protocols[0]:(e.protocol="file",e.protocols=["file"]),e.href=e.href.replace(/\/$/,""),e}mde.exports=ggt});var Cde=_((l9t,Ede)=>{"use strict";var dgt=yde();function Y8(t){if(typeof t!="string")throw new Error("The url must be a string.");var e=/^([a-z\d-]{1,39})\/([-\.\w]{1,100})$/i;e.test(t)&&(t="https://github.com/"+t);var r=dgt(t),o=r.resource.split("."),a=null;switch(r.toString=function(L){return Y8.stringify(this,L)},r.source=o.length>2?o.slice(1-o.length).join("."):r.source=r.resource,r.git_suffix=/\.git$/.test(r.pathname),r.name=decodeURIComponent((r.pathname||r.href).replace(/(^\/)|(\/$)/g,"").replace(/\.git$/,"")),r.owner=decodeURIComponent(r.user),r.source){case"git.cloudforge.com":r.owner=r.user,r.organization=o[0],r.source="cloudforge.com";break;case"visualstudio.com":if(r.resource==="vs-ssh.visualstudio.com"){a=r.name.split("/"),a.length===4&&(r.organization=a[1],r.owner=a[2],r.name=a[3],r.full_name=a[2]+"/"+a[3]);break}else{a=r.name.split("/"),a.length===2?(r.owner=a[1],r.name=a[1],r.full_name="_git/"+r.name):a.length===3?(r.name=a[2],a[0]==="DefaultCollection"?(r.owner=a[2],r.organization=a[0],r.full_name=r.organization+"/_git/"+r.name):(r.owner=a[0],r.full_name=r.owner+"/_git/"+r.name)):a.length===4&&(r.organization=a[0],r.owner=a[1],r.name=a[3],r.full_name=r.organization+"/"+r.owner+"/_git/"+r.name);break}case"dev.azure.com":case"azure.com":if(r.resource==="ssh.dev.azure.com"){a=r.name.split("/"),a.length===4&&(r.organization=a[1],r.owner=a[2],r.name=a[3]);break}else{a=r.name.split("/"),a.length===5?(r.organization=a[0],r.owner=a[1],r.name=a[4],r.full_name="_git/"+r.name):a.length===3?(r.name=a[2],a[0]==="DefaultCollection"?(r.owner=a[2],r.organization=a[0],r.full_name=r.organization+"/_git/"+r.name):(r.owner=a[0],r.full_name=r.owner+"/_git/"+r.name)):a.length===4&&(r.organization=a[0],r.owner=a[1],r.name=a[3],r.full_name=r.organization+"/"+r.owner+"/_git/"+r.name),r.query&&r.query.path&&(r.filepath=r.query.path.replace(/^\/+/g,"")),r.query&&r.query.version&&(r.ref=r.query.version.replace(/^GB/,""));break}default:a=r.name.split("/");var n=a.length-1;if(a.length>=2){var u=a.indexOf("-",2),A=a.indexOf("blob",2),p=a.indexOf("tree",2),h=a.indexOf("commit",2),E=a.indexOf("src",2),I=a.indexOf("raw",2),v=a.indexOf("edit",2);n=u>0?u-1:A>0?A-1:p>0?p-1:h>0?h-1:E>0?E-1:I>0?I-1:v>0?v-1:n,r.owner=a.slice(0,n).join("/"),r.name=a[n],h&&(r.commit=a[n+2])}r.ref="",r.filepathtype="",r.filepath="";var x=a.length>n&&a[n+1]==="-"?n+1:n;a.length>x+2&&["raw","src","blob","tree","edit"].indexOf(a[x+1])>=0&&(r.filepathtype=a[x+1],r.ref=a[x+2],a.length>x+3&&(r.filepath=a.slice(x+3).join("/"))),r.organization=r.owner;break}r.full_name||(r.full_name=r.owner,r.name&&(r.full_name&&(r.full_name+="/"),r.full_name+=r.name)),r.owner.startsWith("scm/")&&(r.source="bitbucket-server",r.owner=r.owner.replace("scm/",""),r.organization=r.owner,r.full_name=r.owner+"/"+r.name);var C=/(projects|users)\/(.*?)\/repos\/(.*?)((\/.*$)|$)/,R=C.exec(r.pathname);return R!=null&&(r.source="bitbucket-server",R[1]==="users"?r.owner="~"+R[2]:r.owner=R[2],r.organization=r.owner,r.name=R[3],a=R[4].split("/"),a.length>1&&(["raw","browse"].indexOf(a[1])>=0?(r.filepathtype=a[1],a.length>2&&(r.filepath=a.slice(2).join("/"))):a[1]==="commits"&&a.length>2&&(r.commit=a[2])),r.full_name=r.owner+"/"+r.name,r.query.at?r.ref=r.query.at:r.ref=""),r}Y8.stringify=function(t,e){e=e||(t.protocols&&t.protocols.length?t.protocols.join("+"):t.protocol);var r=t.port?":"+t.port:"",o=t.user||"git",a=t.git_suffix?".git":"";switch(e){case"ssh":return r?"ssh://"+o+"@"+t.resource+r+"/"+t.full_name+a:o+"@"+t.resource+":"+t.full_name+a;case"git+ssh":case"ssh+git":case"ftp":case"ftps":return e+"://"+o+"@"+t.resource+r+"/"+t.full_name+a;case"http":case"https":var n=t.token?mgt(t):t.user&&(t.protocols.includes("http")||t.protocols.includes("https"))?t.user+"@":"";return e+"://"+n+t.resource+r+"/"+ygt(t)+a;default:return t.href}};function mgt(t){switch(t.source){case"bitbucket.org":return"x-token-auth:"+t.token+"@";default:return t.token+"@"}}function ygt(t){switch(t.source){case"bitbucket-server":return"scm/"+t.full_name;default:return""+t.full_name}}Ede.exports=Y8});var Lde=_((H5t,Nde)=>{var xgt=Hb(),kgt=eP(),Qgt=Hl(),Fgt=pE(),Rgt=w_(),Tgt=lC(),Ngt=N1();function Lgt(t){return Qgt(t)?xgt(t,Tgt):Fgt(t)?[t]:kgt(Rgt(Ngt(t)))}Nde.exports=Lgt});function _gt(t,e){return e===1&&Ugt.has(t[0])}function B2(t){let e=Array.isArray(t)?t:(0,Ude.default)(t);return e.map((o,a)=>Ogt.test(o)?`[${o}]`:Mgt.test(o)&&!_gt(e,a)?`.${o}`:`[${JSON.stringify(o)}]`).join("").replace(/^\./,"")}function Hgt(t,e){let r=[];if(e.methodName!==null&&r.push(de.pretty(t,e.methodName,de.Type.CODE)),e.file!==null){let o=[];o.push(de.pretty(t,e.file,de.Type.PATH)),e.line!==null&&(o.push(de.pretty(t,e.line,de.Type.NUMBER)),e.column!==null&&o.push(de.pretty(t,e.column,de.Type.NUMBER))),r.push(`(${o.join(de.pretty(t,":","grey"))})`)}return r.join(" ")}function gk(t,{manifestUpdates:e,reportedErrors:r},{fix:o}={}){let a=new Map,n=new Map,u=[...r.keys()].map(A=>[A,new Map]);for(let[A,p]of[...u,...e]){let h=r.get(A)?.map(x=>({text:x,fixable:!1}))??[],E=!1,I=t.getWorkspaceByCwd(A),v=I.manifest.exportTo({});for(let[x,C]of p){if(C.size>1){let R=[...C].map(([L,U])=>{let J=de.pretty(t.configuration,L,de.Type.INSPECT),te=U.size>0?Hgt(t.configuration,U.values().next().value):null;return te!==null?` +${J} at ${te}`:` +${J}`}).join("");h.push({text:`Conflict detected in constraint targeting ${de.pretty(t.configuration,x,de.Type.CODE)}; conflicting values are:${R}`,fixable:!1})}else{let[[R]]=C,L=(0,Ode.default)(v,x);if(JSON.stringify(L)===JSON.stringify(R))continue;if(!o){let U=typeof L>"u"?`Missing field ${de.pretty(t.configuration,x,de.Type.CODE)}; expected ${de.pretty(t.configuration,R,de.Type.INSPECT)}`:typeof R>"u"?`Extraneous field ${de.pretty(t.configuration,x,de.Type.CODE)} currently set to ${de.pretty(t.configuration,L,de.Type.INSPECT)}`:`Invalid field ${de.pretty(t.configuration,x,de.Type.CODE)}; expected ${de.pretty(t.configuration,R,de.Type.INSPECT)}, found ${de.pretty(t.configuration,L,de.Type.INSPECT)}`;h.push({text:U,fixable:!0});continue}typeof R>"u"?(0,_de.default)(v,x):(0,Mde.default)(v,x,R),E=!0}E&&a.set(I,v)}h.length>0&&n.set(I,h)}return{changedWorkspaces:a,remainingErrors:n}}function Hde(t,{configuration:e}){let r={children:[]};for(let[o,a]of t){let n=[];for(let A of a){let p=A.text.split(/\n/);A.fixable&&(p[0]=`${de.pretty(e,"\u2699","gray")} ${p[0]}`),n.push({value:de.tuple(de.Type.NO_HINT,p[0]),children:p.slice(1).map(h=>({value:de.tuple(de.Type.NO_HINT,h)}))})}let u={value:de.tuple(de.Type.LOCATOR,o.anchoredLocator),children:_e.sortMap(n,A=>A.value[1])};r.children.push(u)}return r.children=_e.sortMap(r.children,o=>o.value[1]),r}var Ode,Mde,Ude,_de,wC,Ogt,Mgt,Ugt,v2=Et(()=>{Ye();Ode=$e(d2()),Mde=$e(Q8()),Ude=$e(Lde()),_de=$e(T8()),wC=class{constructor(e){this.indexedFields=e;this.items=[];this.indexes={};this.clear()}clear(){this.items=[];for(let e of this.indexedFields)this.indexes[e]=new Map}insert(e){this.items.push(e);for(let r of this.indexedFields){let o=Object.hasOwn(e,r)?e[r]:void 0;if(typeof o>"u")continue;_e.getArrayWithDefault(this.indexes[r],o).push(e)}return e}find(e){if(typeof e>"u")return this.items;let r=Object.entries(e);if(r.length===0)return this.items;let o=[],a;for(let[u,A]of r){let p=u,h=Object.hasOwn(this.indexes,p)?this.indexes[p]:void 0;if(typeof h>"u"){o.push([p,A]);continue}let E=new Set(h.get(A)??[]);if(E.size===0)return[];if(typeof a>"u")a=E;else for(let I of a)E.has(I)||a.delete(I);if(a.size===0)break}let n=[...a??[]];return o.length>0&&(n=n.filter(u=>{for(let[A,p]of o)if(!(typeof p<"u"?Object.hasOwn(u,A)&&u[A]===p:Object.hasOwn(u,A)===!1))return!1;return!0})),n}},Ogt=/^[0-9]+$/,Mgt=/^[a-zA-Z0-9_]+$/,Ugt=new Set(["scripts",...Ot.allDependencies])});var jde=_(($5t,sH)=>{var jgt;(function(t){var e=function(){return{"append/2":[new t.type.Rule(new t.type.Term("append",[new t.type.Var("X"),new t.type.Var("L")]),new t.type.Term("foldl",[new t.type.Term("append",[]),new t.type.Var("X"),new t.type.Term("[]",[]),new t.type.Var("L")]))],"append/3":[new t.type.Rule(new t.type.Term("append",[new t.type.Term("[]",[]),new t.type.Var("X"),new t.type.Var("X")]),null),new t.type.Rule(new t.type.Term("append",[new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("T")]),new t.type.Var("X"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("S")])]),new t.type.Term("append",[new t.type.Var("T"),new t.type.Var("X"),new t.type.Var("S")]))],"member/2":[new t.type.Rule(new t.type.Term("member",[new t.type.Var("X"),new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("_")])]),null),new t.type.Rule(new t.type.Term("member",[new t.type.Var("X"),new t.type.Term(".",[new t.type.Var("_"),new t.type.Var("Xs")])]),new t.type.Term("member",[new t.type.Var("X"),new t.type.Var("Xs")]))],"permutation/2":[new t.type.Rule(new t.type.Term("permutation",[new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("permutation",[new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("T")]),new t.type.Var("S")]),new t.type.Term(",",[new t.type.Term("permutation",[new t.type.Var("T"),new t.type.Var("P")]),new t.type.Term(",",[new t.type.Term("append",[new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("P")]),new t.type.Term("append",[new t.type.Var("X"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("Y")]),new t.type.Var("S")])])]))],"maplist/2":[new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("_"),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Xs")])]),new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("P"),new t.type.Var("X")]),new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Var("Xs")])]))],"maplist/3":[new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("A"),new t.type.Var("As")]),new t.type.Term(".",[new t.type.Var("B"),new t.type.Var("Bs")])]),new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("P"),new t.type.Var("A"),new t.type.Var("B")]),new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Var("As"),new t.type.Var("Bs")])]))],"maplist/4":[new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("A"),new t.type.Var("As")]),new t.type.Term(".",[new t.type.Var("B"),new t.type.Var("Bs")]),new t.type.Term(".",[new t.type.Var("C"),new t.type.Var("Cs")])]),new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("P"),new t.type.Var("A"),new t.type.Var("B"),new t.type.Var("C")]),new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Var("As"),new t.type.Var("Bs"),new t.type.Var("Cs")])]))],"maplist/5":[new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("A"),new t.type.Var("As")]),new t.type.Term(".",[new t.type.Var("B"),new t.type.Var("Bs")]),new t.type.Term(".",[new t.type.Var("C"),new t.type.Var("Cs")]),new t.type.Term(".",[new t.type.Var("D"),new t.type.Var("Ds")])]),new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("P"),new t.type.Var("A"),new t.type.Var("B"),new t.type.Var("C"),new t.type.Var("D")]),new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Var("As"),new t.type.Var("Bs"),new t.type.Var("Cs"),new t.type.Var("Ds")])]))],"maplist/6":[new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("A"),new t.type.Var("As")]),new t.type.Term(".",[new t.type.Var("B"),new t.type.Var("Bs")]),new t.type.Term(".",[new t.type.Var("C"),new t.type.Var("Cs")]),new t.type.Term(".",[new t.type.Var("D"),new t.type.Var("Ds")]),new t.type.Term(".",[new t.type.Var("E"),new t.type.Var("Es")])]),new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("P"),new t.type.Var("A"),new t.type.Var("B"),new t.type.Var("C"),new t.type.Var("D"),new t.type.Var("E")]),new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Var("As"),new t.type.Var("Bs"),new t.type.Var("Cs"),new t.type.Var("Ds"),new t.type.Var("Es")])]))],"maplist/7":[new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("A"),new t.type.Var("As")]),new t.type.Term(".",[new t.type.Var("B"),new t.type.Var("Bs")]),new t.type.Term(".",[new t.type.Var("C"),new t.type.Var("Cs")]),new t.type.Term(".",[new t.type.Var("D"),new t.type.Var("Ds")]),new t.type.Term(".",[new t.type.Var("E"),new t.type.Var("Es")]),new t.type.Term(".",[new t.type.Var("F"),new t.type.Var("Fs")])]),new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("P"),new t.type.Var("A"),new t.type.Var("B"),new t.type.Var("C"),new t.type.Var("D"),new t.type.Var("E"),new t.type.Var("F")]),new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Var("As"),new t.type.Var("Bs"),new t.type.Var("Cs"),new t.type.Var("Ds"),new t.type.Var("Es"),new t.type.Var("Fs")])]))],"maplist/8":[new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("A"),new t.type.Var("As")]),new t.type.Term(".",[new t.type.Var("B"),new t.type.Var("Bs")]),new t.type.Term(".",[new t.type.Var("C"),new t.type.Var("Cs")]),new t.type.Term(".",[new t.type.Var("D"),new t.type.Var("Ds")]),new t.type.Term(".",[new t.type.Var("E"),new t.type.Var("Es")]),new t.type.Term(".",[new t.type.Var("F"),new t.type.Var("Fs")]),new t.type.Term(".",[new t.type.Var("G"),new t.type.Var("Gs")])]),new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("P"),new t.type.Var("A"),new t.type.Var("B"),new t.type.Var("C"),new t.type.Var("D"),new t.type.Var("E"),new t.type.Var("F"),new t.type.Var("G")]),new t.type.Term("maplist",[new t.type.Var("P"),new t.type.Var("As"),new t.type.Var("Bs"),new t.type.Var("Cs"),new t.type.Var("Ds"),new t.type.Var("Es"),new t.type.Var("Fs"),new t.type.Var("Gs")])]))],"include/3":[new t.type.Rule(new t.type.Term("include",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("include",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("T")]),new t.type.Var("L")]),new t.type.Term(",",[new t.type.Term("=..",[new t.type.Var("P"),new t.type.Var("A")]),new t.type.Term(",",[new t.type.Term("append",[new t.type.Var("A"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Term("[]",[])]),new t.type.Var("B")]),new t.type.Term(",",[new t.type.Term("=..",[new t.type.Var("F"),new t.type.Var("B")]),new t.type.Term(",",[new t.type.Term(";",[new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("F")]),new t.type.Term(",",[new t.type.Term("=",[new t.type.Var("L"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("S")])]),new t.type.Term("!",[])])]),new t.type.Term("=",[new t.type.Var("L"),new t.type.Var("S")])]),new t.type.Term("include",[new t.type.Var("P"),new t.type.Var("T"),new t.type.Var("S")])])])])]))],"exclude/3":[new t.type.Rule(new t.type.Term("exclude",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Term("[]",[])]),null),new t.type.Rule(new t.type.Term("exclude",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("T")]),new t.type.Var("S")]),new t.type.Term(",",[new t.type.Term("exclude",[new t.type.Var("P"),new t.type.Var("T"),new t.type.Var("E")]),new t.type.Term(",",[new t.type.Term("=..",[new t.type.Var("P"),new t.type.Var("L")]),new t.type.Term(",",[new t.type.Term("append",[new t.type.Var("L"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Term("[]",[])]),new t.type.Var("Q")]),new t.type.Term(",",[new t.type.Term("=..",[new t.type.Var("R"),new t.type.Var("Q")]),new t.type.Term(";",[new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("R")]),new t.type.Term(",",[new t.type.Term("!",[]),new t.type.Term("=",[new t.type.Var("S"),new t.type.Var("E")])])]),new t.type.Term("=",[new t.type.Var("S"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("E")])])])])])])]))],"foldl/4":[new t.type.Rule(new t.type.Term("foldl",[new t.type.Var("_"),new t.type.Term("[]",[]),new t.type.Var("I"),new t.type.Var("I")]),null),new t.type.Rule(new t.type.Term("foldl",[new t.type.Var("P"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Var("T")]),new t.type.Var("I"),new t.type.Var("R")]),new t.type.Term(",",[new t.type.Term("=..",[new t.type.Var("P"),new t.type.Var("L")]),new t.type.Term(",",[new t.type.Term("append",[new t.type.Var("L"),new t.type.Term(".",[new t.type.Var("I"),new t.type.Term(".",[new t.type.Var("H"),new t.type.Term(".",[new t.type.Var("X"),new t.type.Term("[]",[])])])]),new t.type.Var("L2")]),new t.type.Term(",",[new t.type.Term("=..",[new t.type.Var("P2"),new t.type.Var("L2")]),new t.type.Term(",",[new t.type.Term("call",[new t.type.Var("P2")]),new t.type.Term("foldl",[new t.type.Var("P"),new t.type.Var("T"),new t.type.Var("X"),new t.type.Var("R")])])])])]))],"select/3":[new t.type.Rule(new t.type.Term("select",[new t.type.Var("E"),new t.type.Term(".",[new t.type.Var("E"),new t.type.Var("Xs")]),new t.type.Var("Xs")]),null),new t.type.Rule(new t.type.Term("select",[new t.type.Var("E"),new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Xs")]),new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Ys")])]),new t.type.Term("select",[new t.type.Var("E"),new t.type.Var("Xs"),new t.type.Var("Ys")]))],"sum_list/2":[new t.type.Rule(new t.type.Term("sum_list",[new t.type.Term("[]",[]),new t.type.Num(0,!1)]),null),new t.type.Rule(new t.type.Term("sum_list",[new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Xs")]),new t.type.Var("S")]),new t.type.Term(",",[new t.type.Term("sum_list",[new t.type.Var("Xs"),new t.type.Var("Y")]),new t.type.Term("is",[new t.type.Var("S"),new t.type.Term("+",[new t.type.Var("X"),new t.type.Var("Y")])])]))],"max_list/2":[new t.type.Rule(new t.type.Term("max_list",[new t.type.Term(".",[new t.type.Var("X"),new t.type.Term("[]",[])]),new t.type.Var("X")]),null),new t.type.Rule(new t.type.Term("max_list",[new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Xs")]),new t.type.Var("S")]),new t.type.Term(",",[new t.type.Term("max_list",[new t.type.Var("Xs"),new t.type.Var("Y")]),new t.type.Term(";",[new t.type.Term(",",[new t.type.Term(">=",[new t.type.Var("X"),new t.type.Var("Y")]),new t.type.Term(",",[new t.type.Term("=",[new t.type.Var("S"),new t.type.Var("X")]),new t.type.Term("!",[])])]),new t.type.Term("=",[new t.type.Var("S"),new t.type.Var("Y")])])]))],"min_list/2":[new t.type.Rule(new t.type.Term("min_list",[new t.type.Term(".",[new t.type.Var("X"),new t.type.Term("[]",[])]),new t.type.Var("X")]),null),new t.type.Rule(new t.type.Term("min_list",[new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Xs")]),new t.type.Var("S")]),new t.type.Term(",",[new t.type.Term("min_list",[new t.type.Var("Xs"),new t.type.Var("Y")]),new t.type.Term(";",[new t.type.Term(",",[new t.type.Term("=<",[new t.type.Var("X"),new t.type.Var("Y")]),new t.type.Term(",",[new t.type.Term("=",[new t.type.Var("S"),new t.type.Var("X")]),new t.type.Term("!",[])])]),new t.type.Term("=",[new t.type.Var("S"),new t.type.Var("Y")])])]))],"prod_list/2":[new t.type.Rule(new t.type.Term("prod_list",[new t.type.Term("[]",[]),new t.type.Num(1,!1)]),null),new t.type.Rule(new t.type.Term("prod_list",[new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Xs")]),new t.type.Var("S")]),new t.type.Term(",",[new t.type.Term("prod_list",[new t.type.Var("Xs"),new t.type.Var("Y")]),new t.type.Term("is",[new t.type.Var("S"),new t.type.Term("*",[new t.type.Var("X"),new t.type.Var("Y")])])]))],"last/2":[new t.type.Rule(new t.type.Term("last",[new t.type.Term(".",[new t.type.Var("X"),new t.type.Term("[]",[])]),new t.type.Var("X")]),null),new t.type.Rule(new t.type.Term("last",[new t.type.Term(".",[new t.type.Var("_"),new t.type.Var("Xs")]),new t.type.Var("X")]),new t.type.Term("last",[new t.type.Var("Xs"),new t.type.Var("X")]))],"prefix/2":[new t.type.Rule(new t.type.Term("prefix",[new t.type.Var("Part"),new t.type.Var("Whole")]),new t.type.Term("append",[new t.type.Var("Part"),new t.type.Var("_"),new t.type.Var("Whole")]))],"nth0/3":[new t.type.Rule(new t.type.Term("nth0",[new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z")]),new t.type.Term(";",[new t.type.Term("->",[new t.type.Term("var",[new t.type.Var("X")]),new t.type.Term("nth",[new t.type.Num(0,!1),new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("_")])]),new t.type.Term(",",[new t.type.Term(">=",[new t.type.Var("X"),new t.type.Num(0,!1)]),new t.type.Term(",",[new t.type.Term("nth",[new t.type.Num(0,!1),new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("_")]),new t.type.Term("!",[])])])]))],"nth1/3":[new t.type.Rule(new t.type.Term("nth1",[new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z")]),new t.type.Term(";",[new t.type.Term("->",[new t.type.Term("var",[new t.type.Var("X")]),new t.type.Term("nth",[new t.type.Num(1,!1),new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("_")])]),new t.type.Term(",",[new t.type.Term(">",[new t.type.Var("X"),new t.type.Num(0,!1)]),new t.type.Term(",",[new t.type.Term("nth",[new t.type.Num(1,!1),new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("_")]),new t.type.Term("!",[])])])]))],"nth0/4":[new t.type.Rule(new t.type.Term("nth0",[new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("W")]),new t.type.Term(";",[new t.type.Term("->",[new t.type.Term("var",[new t.type.Var("X")]),new t.type.Term("nth",[new t.type.Num(0,!1),new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("W")])]),new t.type.Term(",",[new t.type.Term(">=",[new t.type.Var("X"),new t.type.Num(0,!1)]),new t.type.Term(",",[new t.type.Term("nth",[new t.type.Num(0,!1),new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("W")]),new t.type.Term("!",[])])])]))],"nth1/4":[new t.type.Rule(new t.type.Term("nth1",[new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("W")]),new t.type.Term(";",[new t.type.Term("->",[new t.type.Term("var",[new t.type.Var("X")]),new t.type.Term("nth",[new t.type.Num(1,!1),new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("W")])]),new t.type.Term(",",[new t.type.Term(">",[new t.type.Var("X"),new t.type.Num(0,!1)]),new t.type.Term(",",[new t.type.Term("nth",[new t.type.Num(1,!1),new t.type.Var("X"),new t.type.Var("Y"),new t.type.Var("Z"),new t.type.Var("W")]),new t.type.Term("!",[])])])]))],"nth/5":[new t.type.Rule(new t.type.Term("nth",[new t.type.Var("N"),new t.type.Var("N"),new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Xs")]),new t.type.Var("X"),new t.type.Var("Xs")]),null),new t.type.Rule(new t.type.Term("nth",[new t.type.Var("N"),new t.type.Var("O"),new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Xs")]),new t.type.Var("Y"),new t.type.Term(".",[new t.type.Var("X"),new t.type.Var("Ys")])]),new t.type.Term(",",[new t.type.Term("is",[new t.type.Var("M"),new t.type.Term("+",[new t.type.Var("N"),new t.type.Num(1,!1)])]),new t.type.Term("nth",[new t.type.Var("M"),new t.type.Var("O"),new t.type.Var("Xs"),new t.type.Var("Y"),new t.type.Var("Ys")])]))],"length/2":function(o,a,n){var u=n.args[0],A=n.args[1];if(!t.type.is_variable(A)&&!t.type.is_integer(A))o.throw_error(t.error.type("integer",A,n.indicator));else if(t.type.is_integer(A)&&A.value<0)o.throw_error(t.error.domain("not_less_than_zero",A,n.indicator));else{var p=new t.type.Term("length",[u,new t.type.Num(0,!1),A]);t.type.is_integer(A)&&(p=new t.type.Term(",",[p,new t.type.Term("!",[])])),o.prepend([new t.type.State(a.goal.replace(p),a.substitution,a)])}},"length/3":[new t.type.Rule(new t.type.Term("length",[new t.type.Term("[]",[]),new t.type.Var("N"),new t.type.Var("N")]),null),new t.type.Rule(new t.type.Term("length",[new t.type.Term(".",[new t.type.Var("_"),new t.type.Var("X")]),new t.type.Var("A"),new t.type.Var("N")]),new t.type.Term(",",[new t.type.Term("succ",[new t.type.Var("A"),new t.type.Var("B")]),new t.type.Term("length",[new t.type.Var("X"),new t.type.Var("B"),new t.type.Var("N")])]))],"replicate/3":function(o,a,n){var u=n.args[0],A=n.args[1],p=n.args[2];if(t.type.is_variable(A))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_integer(A))o.throw_error(t.error.type("integer",A,n.indicator));else if(A.value<0)o.throw_error(t.error.domain("not_less_than_zero",A,n.indicator));else if(!t.type.is_variable(p)&&!t.type.is_list(p))o.throw_error(t.error.type("list",p,n.indicator));else{for(var h=new t.type.Term("[]"),E=0;E<A.value;E++)h=new t.type.Term(".",[u,h]);o.prepend([new t.type.State(a.goal.replace(new t.type.Term("=",[h,p])),a.substitution,a)])}},"sort/2":function(o,a,n){var u=n.args[0],A=n.args[1];if(t.type.is_variable(u))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_variable(A)&&!t.type.is_fully_list(A))o.throw_error(t.error.type("list",A,n.indicator));else{for(var p=[],h=u;h.indicator==="./2";)p.push(h.args[0]),h=h.args[1];if(t.type.is_variable(h))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_empty_list(h))o.throw_error(t.error.type("list",u,n.indicator));else{for(var E=p.sort(t.compare),I=E.length-1;I>0;I--)E[I].equals(E[I-1])&&E.splice(I,1);for(var v=new t.type.Term("[]"),I=E.length-1;I>=0;I--)v=new t.type.Term(".",[E[I],v]);o.prepend([new t.type.State(a.goal.replace(new t.type.Term("=",[v,A])),a.substitution,a)])}}},"msort/2":function(o,a,n){var u=n.args[0],A=n.args[1];if(t.type.is_variable(u))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_variable(A)&&!t.type.is_fully_list(A))o.throw_error(t.error.type("list",A,n.indicator));else{for(var p=[],h=u;h.indicator==="./2";)p.push(h.args[0]),h=h.args[1];if(t.type.is_variable(h))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_empty_list(h))o.throw_error(t.error.type("list",u,n.indicator));else{for(var E=p.sort(t.compare),I=new t.type.Term("[]"),v=E.length-1;v>=0;v--)I=new t.type.Term(".",[E[v],I]);o.prepend([new t.type.State(a.goal.replace(new t.type.Term("=",[I,A])),a.substitution,a)])}}},"keysort/2":function(o,a,n){var u=n.args[0],A=n.args[1];if(t.type.is_variable(u))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_variable(A)&&!t.type.is_fully_list(A))o.throw_error(t.error.type("list",A,n.indicator));else{for(var p=[],h,E=u;E.indicator==="./2";){if(h=E.args[0],t.type.is_variable(h)){o.throw_error(t.error.instantiation(n.indicator));return}else if(!t.type.is_term(h)||h.indicator!=="-/2"){o.throw_error(t.error.type("pair",h,n.indicator));return}h.args[0].pair=h.args[1],p.push(h.args[0]),E=E.args[1]}if(t.type.is_variable(E))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_empty_list(E))o.throw_error(t.error.type("list",u,n.indicator));else{for(var I=p.sort(t.compare),v=new t.type.Term("[]"),x=I.length-1;x>=0;x--)v=new t.type.Term(".",[new t.type.Term("-",[I[x],I[x].pair]),v]),delete I[x].pair;o.prepend([new t.type.State(a.goal.replace(new t.type.Term("=",[v,A])),a.substitution,a)])}}},"take/3":function(o,a,n){var u=n.args[0],A=n.args[1],p=n.args[2];if(t.type.is_variable(A)||t.type.is_variable(u))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_list(A))o.throw_error(t.error.type("list",A,n.indicator));else if(!t.type.is_integer(u))o.throw_error(t.error.type("integer",u,n.indicator));else if(!t.type.is_variable(p)&&!t.type.is_list(p))o.throw_error(t.error.type("list",p,n.indicator));else{for(var h=u.value,E=[],I=A;h>0&&I.indicator==="./2";)E.push(I.args[0]),I=I.args[1],h--;if(h===0){for(var v=new t.type.Term("[]"),h=E.length-1;h>=0;h--)v=new t.type.Term(".",[E[h],v]);o.prepend([new t.type.State(a.goal.replace(new t.type.Term("=",[v,p])),a.substitution,a)])}}},"drop/3":function(o,a,n){var u=n.args[0],A=n.args[1],p=n.args[2];if(t.type.is_variable(A)||t.type.is_variable(u))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_list(A))o.throw_error(t.error.type("list",A,n.indicator));else if(!t.type.is_integer(u))o.throw_error(t.error.type("integer",u,n.indicator));else if(!t.type.is_variable(p)&&!t.type.is_list(p))o.throw_error(t.error.type("list",p,n.indicator));else{for(var h=u.value,E=[],I=A;h>0&&I.indicator==="./2";)E.push(I.args[0]),I=I.args[1],h--;h===0&&o.prepend([new t.type.State(a.goal.replace(new t.type.Term("=",[I,p])),a.substitution,a)])}},"reverse/2":function(o,a,n){var u=n.args[0],A=n.args[1],p=t.type.is_instantiated_list(u),h=t.type.is_instantiated_list(A);if(t.type.is_variable(u)&&t.type.is_variable(A))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_variable(u)&&!t.type.is_fully_list(u))o.throw_error(t.error.type("list",u,n.indicator));else if(!t.type.is_variable(A)&&!t.type.is_fully_list(A))o.throw_error(t.error.type("list",A,n.indicator));else if(!p&&!h)o.throw_error(t.error.instantiation(n.indicator));else{for(var E=p?u:A,I=new t.type.Term("[]",[]);E.indicator==="./2";)I=new t.type.Term(".",[E.args[0],I]),E=E.args[1];o.prepend([new t.type.State(a.goal.replace(new t.type.Term("=",[I,p?A:u])),a.substitution,a)])}},"list_to_set/2":function(o,a,n){var u=n.args[0],A=n.args[1];if(t.type.is_variable(u))o.throw_error(t.error.instantiation(n.indicator));else{for(var p=u,h=[];p.indicator==="./2";)h.push(p.args[0]),p=p.args[1];if(t.type.is_variable(p))o.throw_error(t.error.instantiation(n.indicator));else if(!t.type.is_term(p)||p.indicator!=="[]/0")o.throw_error(t.error.type("list",u,n.indicator));else{for(var E=[],I=new t.type.Term("[]",[]),v,x=0;x<h.length;x++){v=!1;for(var C=0;C<E.length&&!v;C++)v=t.compare(h[x],E[C])===0;v||E.push(h[x])}for(x=E.length-1;x>=0;x--)I=new t.type.Term(".",[E[x],I]);o.prepend([new t.type.State(a.goal.replace(new t.type.Term("=",[A,I])),a.substitution,a)])}}}}},r=["append/2","append/3","member/2","permutation/2","maplist/2","maplist/3","maplist/4","maplist/5","maplist/6","maplist/7","maplist/8","include/3","exclude/3","foldl/4","sum_list/2","max_list/2","min_list/2","prod_list/2","last/2","prefix/2","nth0/3","nth1/3","nth0/4","nth1/4","length/2","replicate/3","select/3","sort/2","msort/2","keysort/2","take/3","drop/3","reverse/2","list_to_set/2"];typeof sH<"u"?sH.exports=function(o){t=o,new t.type.Module("lists",e(),r)}:new t.type.Module("lists",e(),r)})(jgt)});var nme=_(Yr=>{"use strict";var $d=process.platform==="win32",oH="aes-256-cbc",Ggt="sha256",Yde="The current environment doesn't support interactive reading from TTY.",Yn=ve("fs"),Gde=process.binding("tty_wrap").TTY,lH=ve("child_process"),c0=ve("path"),cH={prompt:"> ",hideEchoBack:!1,mask:"*",limit:[],limitMessage:"Input another, please.$<( [)limit(])>",defaultInput:"",trueValue:[],falseValue:[],caseSensitive:!1,keepWhitespace:!1,encoding:"utf8",bufferSize:1024,print:void 0,history:!0,cd:!1,phContent:void 0,preCheck:void 0},zf="none",Xc,BC,qde=!1,l0,mk,aH,qgt=0,hH="",Zd=[],yk,Wde=!1,uH=!1,D2=!1;function Kde(t){function e(r){return r.replace(/[^\w\u0080-\uFFFF]/g,function(o){return"#"+o.charCodeAt(0)+";"})}return mk.concat(function(r){var o=[];return Object.keys(r).forEach(function(a){r[a]==="boolean"?t[a]&&o.push("--"+a):r[a]==="string"&&t[a]&&o.push("--"+a,e(t[a]))}),o}({display:"string",displayOnly:"boolean",keyIn:"boolean",hideEchoBack:"boolean",mask:"string",limit:"string",caseSensitive:"boolean"}))}function Ygt(t,e){function r(U){var J,te="",ae;for(aH=aH||ve("os").tmpdir();;){J=c0.join(aH,U+te);try{ae=Yn.openSync(J,"wx")}catch(fe){if(fe.code==="EEXIST"){te++;continue}else throw fe}Yn.closeSync(ae);break}return J}var o,a,n,u={},A,p,h=r("readline-sync.stdout"),E=r("readline-sync.stderr"),I=r("readline-sync.exit"),v=r("readline-sync.done"),x=ve("crypto"),C,R,L;C=x.createHash(Ggt),C.update(""+process.pid+qgt+++Math.random()),L=C.digest("hex"),R=x.createDecipher(oH,L),o=Kde(t),$d?(a=process.env.ComSpec||"cmd.exe",process.env.Q='"',n=["/V:ON","/S","/C","(%Q%"+a+"%Q% /V:ON /S /C %Q%%Q%"+l0+"%Q%"+o.map(function(U){return" %Q%"+U+"%Q%"}).join("")+" & (echo !ERRORLEVEL!)>%Q%"+I+"%Q%%Q%) 2>%Q%"+E+"%Q% |%Q%"+process.execPath+"%Q% %Q%"+__dirname+"\\encrypt.js%Q% %Q%"+oH+"%Q% %Q%"+L+"%Q% >%Q%"+h+"%Q% & (echo 1)>%Q%"+v+"%Q%"]):(a="/bin/sh",n=["-c",'("'+l0+'"'+o.map(function(U){return" '"+U.replace(/'/g,"'\\''")+"'"}).join("")+'; echo $?>"'+I+'") 2>"'+E+'" |"'+process.execPath+'" "'+__dirname+'/encrypt.js" "'+oH+'" "'+L+'" >"'+h+'"; echo 1 >"'+v+'"']),D2&&D2("_execFileSync",o);try{lH.spawn(a,n,e)}catch(U){u.error=new Error(U.message),u.error.method="_execFileSync - spawn",u.error.program=a,u.error.args=n}for(;Yn.readFileSync(v,{encoding:t.encoding}).trim()!=="1";);return(A=Yn.readFileSync(I,{encoding:t.encoding}).trim())==="0"?u.input=R.update(Yn.readFileSync(h,{encoding:"binary"}),"hex",t.encoding)+R.final(t.encoding):(p=Yn.readFileSync(E,{encoding:t.encoding}).trim(),u.error=new Error(Yde+(p?` +`+p:"")),u.error.method="_execFileSync",u.error.program=a,u.error.args=n,u.error.extMessage=p,u.error.exitCode=+A),Yn.unlinkSync(h),Yn.unlinkSync(E),Yn.unlinkSync(I),Yn.unlinkSync(v),u}function Wgt(t){var e,r={},o,a={env:process.env,encoding:t.encoding};if(l0||($d?process.env.PSModulePath?(l0="powershell.exe",mk=["-ExecutionPolicy","Bypass","-File",__dirname+"\\read.ps1"]):(l0="cscript.exe",mk=["//nologo",__dirname+"\\read.cs.js"]):(l0="/bin/sh",mk=[__dirname+"/read.sh"])),$d&&!process.env.PSModulePath&&(a.stdio=[process.stdin]),lH.execFileSync){e=Kde(t),D2&&D2("execFileSync",e);try{r.input=lH.execFileSync(l0,e,a)}catch(n){o=n.stderr?(n.stderr+"").trim():"",r.error=new Error(Yde+(o?` +`+o:"")),r.error.method="execFileSync",r.error.program=l0,r.error.args=e,r.error.extMessage=o,r.error.exitCode=n.status,r.error.code=n.code,r.error.signal=n.signal}}else r=Ygt(t,a);return r.error||(r.input=r.input.replace(/^\s*'|'\s*$/g,""),t.display=""),r}function AH(t){var e="",r=t.display,o=!t.display&&t.keyIn&&t.hideEchoBack&&!t.mask;function a(){var n=Wgt(t);if(n.error)throw n.error;return n.input}return uH&&uH(t),function(){var n,u,A;function p(){return n||(n=process.binding("fs"),u=process.binding("constants")),n}if(typeof zf=="string")if(zf=null,$d){if(A=function(h){var E=h.replace(/^\D+/,"").split("."),I=0;return(E[0]=+E[0])&&(I+=E[0]*1e4),(E[1]=+E[1])&&(I+=E[1]*100),(E[2]=+E[2])&&(I+=E[2]),I}(process.version),!(A>=20302&&A<40204||A>=5e4&&A<50100||A>=50600&&A<60200)&&process.stdin.isTTY)process.stdin.pause(),zf=process.stdin.fd,BC=process.stdin._handle;else try{zf=p().open("CONIN$",u.O_RDWR,parseInt("0666",8)),BC=new Gde(zf,!0)}catch{}if(process.stdout.isTTY)Xc=process.stdout.fd;else{try{Xc=Yn.openSync("\\\\.\\CON","w")}catch{}if(typeof Xc!="number")try{Xc=p().open("CONOUT$",u.O_RDWR,parseInt("0666",8))}catch{}}}else{if(process.stdin.isTTY){process.stdin.pause();try{zf=Yn.openSync("/dev/tty","r"),BC=process.stdin._handle}catch{}}else try{zf=Yn.openSync("/dev/tty","r"),BC=new Gde(zf,!1)}catch{}if(process.stdout.isTTY)Xc=process.stdout.fd;else try{Xc=Yn.openSync("/dev/tty","w")}catch{}}}(),function(){var n,u,A=!t.hideEchoBack&&!t.keyIn,p,h,E,I,v;yk="";function x(C){return C===qde?!0:BC.setRawMode(C)!==0?!1:(qde=C,!0)}if(Wde||!BC||typeof Xc!="number"&&(t.display||!A)){e=a();return}if(t.display&&(Yn.writeSync(Xc,t.display),t.display=""),!t.displayOnly){if(!x(!A)){e=a();return}for(h=t.keyIn?1:t.bufferSize,p=Buffer.allocUnsafe&&Buffer.alloc?Buffer.alloc(h):new Buffer(h),t.keyIn&&t.limit&&(u=new RegExp("[^"+t.limit+"]","g"+(t.caseSensitive?"":"i")));;){E=0;try{E=Yn.readSync(zf,p,0,h)}catch(C){if(C.code!=="EOF"){x(!1),e+=a();return}}if(E>0?(I=p.toString(t.encoding,0,E),yk+=I):(I=` +`,yk+=String.fromCharCode(0)),I&&typeof(v=(I.match(/^(.*?)[\r\n]/)||[])[1])=="string"&&(I=v,n=!0),I&&(I=I.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g,"")),I&&u&&(I=I.replace(u,"")),I&&(A||(t.hideEchoBack?t.mask&&Yn.writeSync(Xc,new Array(I.length+1).join(t.mask)):Yn.writeSync(Xc,I)),e+=I),!t.keyIn&&n||t.keyIn&&e.length>=h)break}!A&&!o&&Yn.writeSync(Xc,` +`),x(!1)}}(),t.print&&!o&&t.print(r+(t.displayOnly?"":(t.hideEchoBack?new Array(e.length+1).join(t.mask):e)+` +`),t.encoding),t.displayOnly?"":hH=t.keepWhitespace||t.keyIn?e:e.trim()}function Kgt(t,e){var r=[];function o(a){a!=null&&(Array.isArray(a)?a.forEach(o):(!e||e(a))&&r.push(a))}return o(t),r}function gH(t){return t.replace(/[\x00-\x7f]/g,function(e){return"\\x"+("00"+e.charCodeAt().toString(16)).substr(-2)})}function Rs(){var t=Array.prototype.slice.call(arguments),e,r;return t.length&&typeof t[0]=="boolean"&&(r=t.shift(),r&&(e=Object.keys(cH),t.unshift(cH))),t.reduce(function(o,a){return a==null||(a.hasOwnProperty("noEchoBack")&&!a.hasOwnProperty("hideEchoBack")&&(a.hideEchoBack=a.noEchoBack,delete a.noEchoBack),a.hasOwnProperty("noTrim")&&!a.hasOwnProperty("keepWhitespace")&&(a.keepWhitespace=a.noTrim,delete a.noTrim),r||(e=Object.keys(a)),e.forEach(function(n){var u;if(!!a.hasOwnProperty(n))switch(u=a[n],n){case"mask":case"limitMessage":case"defaultInput":case"encoding":u=u!=null?u+"":"",u&&n!=="limitMessage"&&(u=u.replace(/[\r\n]/g,"")),o[n]=u;break;case"bufferSize":!isNaN(u=parseInt(u,10))&&typeof u=="number"&&(o[n]=u);break;case"displayOnly":case"keyIn":case"hideEchoBack":case"caseSensitive":case"keepWhitespace":case"history":case"cd":o[n]=!!u;break;case"limit":case"trueValue":case"falseValue":o[n]=Kgt(u,function(A){var p=typeof A;return p==="string"||p==="number"||p==="function"||A instanceof RegExp}).map(function(A){return typeof A=="string"?A.replace(/[\r\n]/g,""):A});break;case"print":case"phContent":case"preCheck":o[n]=typeof u=="function"?u:void 0;break;case"prompt":case"display":o[n]=u??"";break}})),o},{})}function fH(t,e,r){return e.some(function(o){var a=typeof o;return a==="string"?r?t===o:t.toLowerCase()===o.toLowerCase():a==="number"?parseFloat(t)===o:a==="function"?o(t):o instanceof RegExp?o.test(t):!1})}function dH(t,e){var r=c0.normalize($d?(process.env.HOMEDRIVE||"")+(process.env.HOMEPATH||""):process.env.HOME||"").replace(/[\/\\]+$/,"");return t=c0.normalize(t),e?t.replace(/^~(?=\/|\\|$)/,r):t.replace(new RegExp("^"+gH(r)+"(?=\\/|\\\\|$)",$d?"i":""),"~")}function vC(t,e){var r="(?:\\(([\\s\\S]*?)\\))?(\\w+|.-.)(?:\\(([\\s\\S]*?)\\))?",o=new RegExp("(\\$)?(\\$<"+r+">)","g"),a=new RegExp("(\\$)?(\\$\\{"+r+"\\})","g");function n(u,A,p,h,E,I){var v;return A||typeof(v=e(E))!="string"?p:v?(h||"")+v+(I||""):""}return t.replace(o,n).replace(a,n)}function Vde(t,e,r){var o,a=[],n=-1,u=0,A="",p;function h(E,I){return I.length>3?(E.push(I[0]+"..."+I[I.length-1]),p=!0):I.length&&(E=E.concat(I)),E}return o=t.reduce(function(E,I){return E.concat((I+"").split(""))},[]).reduce(function(E,I){var v,x;return e||(I=I.toLowerCase()),v=/^\d$/.test(I)?1:/^[A-Z]$/.test(I)?2:/^[a-z]$/.test(I)?3:0,r&&v===0?A+=I:(x=I.charCodeAt(0),v&&v===n&&x===u+1?a.push(I):(E=h(E,a),a=[I],n=v),u=x),E},[]),o=h(o,a),A&&(o.push(A),p=!0),{values:o,suppressed:p}}function Jde(t,e){return t.join(t.length>2?", ":e?" / ":"/")}function zde(t,e){var r,o,a={},n;if(e.phContent&&(r=e.phContent(t,e)),typeof r!="string")switch(t){case"hideEchoBack":case"mask":case"defaultInput":case"caseSensitive":case"keepWhitespace":case"encoding":case"bufferSize":case"history":case"cd":r=e.hasOwnProperty(t)?typeof e[t]=="boolean"?e[t]?"on":"off":e[t]+"":"";break;case"limit":case"trueValue":case"falseValue":o=e[e.hasOwnProperty(t+"Src")?t+"Src":t],e.keyIn?(a=Vde(o,e.caseSensitive),o=a.values):o=o.filter(function(u){var A=typeof u;return A==="string"||A==="number"}),r=Jde(o,a.suppressed);break;case"limitCount":case"limitCountNotZero":r=e[e.hasOwnProperty("limitSrc")?"limitSrc":"limit"].length,r=r||t!=="limitCountNotZero"?r+"":"";break;case"lastInput":r=hH;break;case"cwd":case"CWD":case"cwdHome":r=process.cwd(),t==="CWD"?r=c0.basename(r):t==="cwdHome"&&(r=dH(r));break;case"date":case"time":case"localeDate":case"localeTime":r=new Date()["to"+t.replace(/^./,function(u){return u.toUpperCase()})+"String"]();break;default:typeof(n=(t.match(/^history_m(\d+)$/)||[])[1])=="string"&&(r=Zd[Zd.length-n]||"")}return r}function Xde(t){var e=/^(.)-(.)$/.exec(t),r="",o,a,n,u;if(!e)return null;for(o=e[1].charCodeAt(0),a=e[2].charCodeAt(0),u=o<a?1:-1,n=o;n!==a+u;n+=u)r+=String.fromCharCode(n);return r}function pH(t){var e=new RegExp(/(\s*)(?:("|')(.*?)(?:\2|$)|(\S+))/g),r,o="",a=[],n;for(t=t.trim();r=e.exec(t);)n=r[3]||r[4]||"",r[1]&&(a.push(o),o=""),o+=n;return o&&a.push(o),a}function Zde(t,e){return e.trueValue.length&&fH(t,e.trueValue,e.caseSensitive)?!0:e.falseValue.length&&fH(t,e.falseValue,e.caseSensitive)?!1:t}function $de(t){var e,r,o,a,n,u,A;function p(E){return zde(E,t)}function h(E){t.display+=(/[^\r\n]$/.test(t.display)?` +`:"")+E}for(t.limitSrc=t.limit,t.displaySrc=t.display,t.limit="",t.display=vC(t.display+"",p);;){if(e=AH(t),r=!1,o="",t.defaultInput&&!e&&(e=t.defaultInput),t.history&&((a=/^\s*\!(?:\!|-1)(:p)?\s*$/.exec(e))?(n=Zd[0]||"",a[1]?r=!0:e=n,h(n+` +`),r||(t.displayOnly=!0,AH(t),t.displayOnly=!1)):e&&e!==Zd[Zd.length-1]&&(Zd=[e])),!r&&t.cd&&e)switch(u=pH(e),u[0].toLowerCase()){case"cd":if(u[1])try{process.chdir(dH(u[1],!0))}catch(E){h(E+"")}r=!0;break;case"pwd":h(process.cwd()),r=!0;break}if(!r&&t.preCheck&&(A=t.preCheck(e,t),e=A.res,A.forceNext&&(r=!0)),!r){if(!t.limitSrc.length||fH(e,t.limitSrc,t.caseSensitive))break;t.limitMessage&&(o=vC(t.limitMessage,p))}h((o?o+` +`:"")+vC(t.displaySrc+"",p))}return Zde(e,t)}Yr._DBG_set_useExt=function(t){Wde=t};Yr._DBG_set_checkOptions=function(t){uH=t};Yr._DBG_set_checkMethod=function(t){D2=t};Yr._DBG_clearHistory=function(){hH="",Zd=[]};Yr.setDefaultOptions=function(t){return cH=Rs(!0,t),Rs(!0)};Yr.question=function(t,e){return $de(Rs(Rs(!0,e),{display:t}))};Yr.prompt=function(t){var e=Rs(!0,t);return e.display=e.prompt,$de(e)};Yr.keyIn=function(t,e){var r=Rs(Rs(!0,e),{display:t,keyIn:!0,keepWhitespace:!0});return r.limitSrc=r.limit.filter(function(o){var a=typeof o;return a==="string"||a==="number"}).map(function(o){return vC(o+"",Xde)}),r.limit=gH(r.limitSrc.join("")),["trueValue","falseValue"].forEach(function(o){r[o]=r[o].reduce(function(a,n){var u=typeof n;return u==="string"||u==="number"?a=a.concat((n+"").split("")):a.push(n),a},[])}),r.display=vC(r.display+"",function(o){return zde(o,r)}),Zde(AH(r),r)};Yr.questionEMail=function(t,e){return t==null&&(t="Input e-mail address: "),Yr.question(t,Rs({hideEchoBack:!1,limit:/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,limitMessage:"Input valid e-mail address, please.",trueValue:null,falseValue:null},e,{keepWhitespace:!1,cd:!1}))};Yr.questionNewPassword=function(t,e){var r,o,a,n=Rs({hideEchoBack:!0,mask:"*",limitMessage:`It can include: $<charlist> +And the length must be: $<length>`,trueValue:null,falseValue:null,caseSensitive:!0},e,{history:!1,cd:!1,phContent:function(x){return x==="charlist"?r.text:x==="length"?o+"..."+a:null}}),u,A,p,h,E,I,v;for(e=e||{},u=vC(e.charlist?e.charlist+"":"$<!-~>",Xde),(isNaN(o=parseInt(e.min,10))||typeof o!="number")&&(o=12),(isNaN(a=parseInt(e.max,10))||typeof a!="number")&&(a=24),h=new RegExp("^["+gH(u)+"]{"+o+","+a+"}$"),r=Vde([u],n.caseSensitive,!0),r.text=Jde(r.values,r.suppressed),A=e.confirmMessage!=null?e.confirmMessage:"Reinput a same one to confirm it: ",p=e.unmatchMessage!=null?e.unmatchMessage:"It differs from first one. Hit only the Enter key if you want to retry from first one.",t==null&&(t="Input new password: "),E=n.limitMessage;!v;)n.limit=h,n.limitMessage=E,I=Yr.question(t,n),n.limit=[I,""],n.limitMessage=p,v=Yr.question(A,n);return I};function eme(t,e,r){var o;function a(n){return o=r(n),!isNaN(o)&&typeof o=="number"}return Yr.question(t,Rs({limitMessage:"Input valid number, please."},e,{limit:a,cd:!1})),o}Yr.questionInt=function(t,e){return eme(t,e,function(r){return parseInt(r,10)})};Yr.questionFloat=function(t,e){return eme(t,e,parseFloat)};Yr.questionPath=function(t,e){var r,o="",a=Rs({hideEchoBack:!1,limitMessage:`$<error( +)>Input valid path, please.$<( Min:)min>$<( Max:)max>`,history:!0,cd:!0},e,{keepWhitespace:!1,limit:function(n){var u,A,p;n=dH(n,!0),o="";function h(E){E.split(/\/|\\/).reduce(function(I,v){var x=c0.resolve(I+=v+c0.sep);if(!Yn.existsSync(x))Yn.mkdirSync(x);else if(!Yn.statSync(x).isDirectory())throw new Error("Non directory already exists: "+x);return I},"")}try{if(u=Yn.existsSync(n),r=u?Yn.realpathSync(n):c0.resolve(n),!e.hasOwnProperty("exists")&&!u||typeof e.exists=="boolean"&&e.exists!==u)return o=(u?"Already exists":"No such file or directory")+": "+r,!1;if(!u&&e.create&&(e.isDirectory?h(r):(h(c0.dirname(r)),Yn.closeSync(Yn.openSync(r,"w"))),r=Yn.realpathSync(r)),u&&(e.min||e.max||e.isFile||e.isDirectory)){if(A=Yn.statSync(r),e.isFile&&!A.isFile())return o="Not file: "+r,!1;if(e.isDirectory&&!A.isDirectory())return o="Not directory: "+r,!1;if(e.min&&A.size<+e.min||e.max&&A.size>+e.max)return o="Size "+A.size+" is out of range: "+r,!1}if(typeof e.validate=="function"&&(p=e.validate(r))!==!0)return typeof p=="string"&&(o=p),!1}catch(E){return o=E+"",!1}return!0},phContent:function(n){return n==="error"?o:n!=="min"&&n!=="max"?null:e.hasOwnProperty(n)?e[n]+"":""}});return e=e||{},t==null&&(t='Input path (you can "cd" and "pwd"): '),Yr.question(t,a),r};function tme(t,e){var r={},o={};return typeof t=="object"?(Object.keys(t).forEach(function(a){typeof t[a]=="function"&&(o[e.caseSensitive?a:a.toLowerCase()]=t[a])}),r.preCheck=function(a){var n;return r.args=pH(a),n=r.args[0]||"",e.caseSensitive||(n=n.toLowerCase()),r.hRes=n!=="_"&&o.hasOwnProperty(n)?o[n].apply(a,r.args.slice(1)):o.hasOwnProperty("_")?o._.apply(a,r.args):null,{res:a,forceNext:!1}},o.hasOwnProperty("_")||(r.limit=function(){var a=r.args[0]||"";return e.caseSensitive||(a=a.toLowerCase()),o.hasOwnProperty(a)})):r.preCheck=function(a){return r.args=pH(a),r.hRes=typeof t=="function"?t.apply(a,r.args):!0,{res:a,forceNext:!1}},r}Yr.promptCL=function(t,e){var r=Rs({hideEchoBack:!1,limitMessage:"Requested command is not available.",caseSensitive:!1,history:!0},e),o=tme(t,r);return r.limit=o.limit,r.preCheck=o.preCheck,Yr.prompt(r),o.args};Yr.promptLoop=function(t,e){for(var r=Rs({hideEchoBack:!1,trueValue:null,falseValue:null,caseSensitive:!1,history:!0},e);!t(Yr.prompt(r)););};Yr.promptCLLoop=function(t,e){var r=Rs({hideEchoBack:!1,limitMessage:"Requested command is not available.",caseSensitive:!1,history:!0},e),o=tme(t,r);for(r.limit=o.limit,r.preCheck=o.preCheck;Yr.prompt(r),!o.hRes;);};Yr.promptSimShell=function(t){return Yr.prompt(Rs({hideEchoBack:!1,history:!0},t,{prompt:function(){return $d?"$<cwd>>":(process.env.USER||"")+(process.env.HOSTNAME?"@"+process.env.HOSTNAME.replace(/\..*$/,""):"")+":$<cwdHome>$ "}()}))};function rme(t,e,r){var o;return t==null&&(t="Are you sure? "),(!e||e.guide!==!1)&&(t+="")&&(t=t.replace(/\s*:?\s*$/,"")+" [y/n]: "),o=Yr.keyIn(t,Rs(e,{hideEchoBack:!1,limit:r,trueValue:"y",falseValue:"n",caseSensitive:!1})),typeof o=="boolean"?o:""}Yr.keyInYN=function(t,e){return rme(t,e)};Yr.keyInYNStrict=function(t,e){return rme(t,e,"yn")};Yr.keyInPause=function(t,e){t==null&&(t="Continue..."),(!e||e.guide!==!1)&&(t+="")&&(t=t.replace(/\s+$/,"")+" (Hit any key)"),Yr.keyIn(t,Rs({limit:null},e,{hideEchoBack:!0,mask:""}))};Yr.keyInSelect=function(t,e,r){var o=Rs({hideEchoBack:!1},r,{trueValue:null,falseValue:null,caseSensitive:!1,phContent:function(p){return p==="itemsCount"?t.length+"":p==="firstItem"?(t[0]+"").trim():p==="lastItem"?(t[t.length-1]+"").trim():null}}),a="",n={},u=49,A=` +`;if(!Array.isArray(t)||!t.length||t.length>35)throw"`items` must be Array (max length: 35).";return t.forEach(function(p,h){var E=String.fromCharCode(u);a+=E,n[E]=h,A+="["+E+"] "+(p+"").trim()+` +`,u=u===57?97:u+1}),(!r||r.cancel!==!1)&&(a+="0",n[0]=-1,A+="[0] "+(r&&r.cancel!=null&&typeof r.cancel!="boolean"?(r.cancel+"").trim():"CANCEL")+` +`),o.limit=a,A+=` +`,e==null&&(e="Choose one from list: "),(e+="")&&((!r||r.guide!==!1)&&(e=e.replace(/\s*:?\s*$/,"")+" [$<limit>]: "),A+=e),n[Yr.keyIn(A,o).toLowerCase()]};Yr.getRawInput=function(){return yk};function S2(t,e){var r;return e.length&&(r={},r[t]=e[0]),Yr.setDefaultOptions(r)[t]}Yr.setPrint=function(){return S2("print",arguments)};Yr.setPrompt=function(){return S2("prompt",arguments)};Yr.setEncoding=function(){return S2("encoding",arguments)};Yr.setMask=function(){return S2("mask",arguments)};Yr.setBufferSize=function(){return S2("bufferSize",arguments)}});var mH=_((t7t,hl)=>{(function(){var t={major:0,minor:2,patch:66,status:"beta"};tau_file_system={files:{},open:function(w,P,y){var F=tau_file_system.files[w];if(!F){if(y==="read")return null;F={path:w,text:"",type:P,get:function(z,X){return X===this.text.length||X>this.text.length?"end_of_file":this.text.substring(X,X+z)},put:function(z,X){return X==="end_of_file"?(this.text+=z,!0):X==="past_end_of_file"?null:(this.text=this.text.substring(0,X)+z+this.text.substring(X+z.length),!0)},get_byte:function(z){if(z==="end_of_stream")return-1;var X=Math.floor(z/2);if(this.text.length<=X)return-1;var Z=n(this.text[Math.floor(z/2)],0);return z%2===0?Z&255:Z/256>>>0},put_byte:function(z,X){var Z=X==="end_of_stream"?this.text.length:Math.floor(X/2);if(this.text.length<Z)return null;var ie=this.text.length===Z?-1:n(this.text[Math.floor(X/2)],0);return X%2===0?(ie=ie/256>>>0,ie=(ie&255)<<8|z&255):(ie=ie&255,ie=(z&255)<<8|ie&255),this.text.length===Z?this.text+=u(ie):this.text=this.text.substring(0,Z)+u(ie)+this.text.substring(Z+1),!0},flush:function(){return!0},close:function(){var z=tau_file_system.files[this.path];return z?!0:null}},tau_file_system.files[w]=F}return y==="write"&&(F.text=""),F}},tau_user_input={buffer:"",get:function(w,P){for(var y;tau_user_input.buffer.length<w;)y=window.prompt(),y&&(tau_user_input.buffer+=y);return y=tau_user_input.buffer.substr(0,w),tau_user_input.buffer=tau_user_input.buffer.substr(w),y}},tau_user_output={put:function(w,P){return console.log(w),!0},flush:function(){return!0}},nodejs_file_system={open:function(w,P,y){var F=ve("fs"),z=F.openSync(w,y[0]);return y==="read"&&!F.existsSync(w)?null:{get:function(X,Z){var ie=new Buffer(X);return F.readSync(z,ie,0,X,Z),ie.toString()},put:function(X,Z){var ie=Buffer.from(X);if(Z==="end_of_file")F.writeSync(z,ie);else{if(Z==="past_end_of_file")return null;F.writeSync(z,ie,0,ie.length,Z)}return!0},get_byte:function(X){return null},put_byte:function(X,Z){return null},flush:function(){return!0},close:function(){return F.closeSync(z),!0}}}},nodejs_user_input={buffer:"",get:function(w,P){for(var y,F=nme();nodejs_user_input.buffer.length<w;)nodejs_user_input.buffer+=F.question();return y=nodejs_user_input.buffer.substr(0,w),nodejs_user_input.buffer=nodejs_user_input.buffer.substr(w),y}},nodejs_user_output={put:function(w,P){return process.stdout.write(w),!0},flush:function(){return!0}};var e;Array.prototype.indexOf?e=function(w,P){return w.indexOf(P)}:e=function(w,P){for(var y=w.length,F=0;F<y;F++)if(P===w[F])return F;return-1};var r=function(w,P){if(w.length!==0){for(var y=w[0],F=w.length,z=1;z<F;z++)y=P(y,w[z]);return y}},o;Array.prototype.map?o=function(w,P){return w.map(P)}:o=function(w,P){for(var y=[],F=w.length,z=0;z<F;z++)y.push(P(w[z]));return y};var a;Array.prototype.filter?a=function(w,P){return w.filter(P)}:a=function(w,P){for(var y=[],F=w.length,z=0;z<F;z++)P(w[z])&&y.push(w[z]);return y};var n;String.prototype.codePointAt?n=function(w,P){return w.codePointAt(P)}:n=function(w,P){return w.charCodeAt(P)};var u;String.fromCodePoint?u=function(){return String.fromCodePoint.apply(null,arguments)}:u=function(){return String.fromCharCode.apply(null,arguments)};var A=0,p=1,h=/(\\a)|(\\b)|(\\f)|(\\n)|(\\r)|(\\t)|(\\v)|\\x([0-9a-fA-F]+)\\|\\([0-7]+)\\|(\\\\)|(\\')|('')|(\\")|(\\`)|(\\.)|(.)/g,E={"\\a":7,"\\b":8,"\\f":12,"\\n":10,"\\r":13,"\\t":9,"\\v":11};function I(w){var P=[],y=!1;return w.replace(h,function(F,z,X,Z,ie,Pe,Ne,ot,dt,Gt,$t,bt,an,Qr,mr,br,Wr){switch(!0){case dt!==void 0:return P.push(parseInt(dt,16)),"";case Gt!==void 0:return P.push(parseInt(Gt,8)),"";case $t!==void 0:case bt!==void 0:case an!==void 0:case Qr!==void 0:case mr!==void 0:return P.push(n(F.substr(1),0)),"";case Wr!==void 0:return P.push(n(Wr,0)),"";case br!==void 0:y=!0;default:return P.push(E[F]),""}}),y?null:P}function v(w,P){var y="";if(w.length<2)return w;try{w=w.replace(/\\([0-7]+)\\/g,function(Z,ie){return u(parseInt(ie,8))}),w=w.replace(/\\x([0-9a-fA-F]+)\\/g,function(Z,ie){return u(parseInt(ie,16))})}catch{return null}for(var F=0;F<w.length;F++){var z=w.charAt(F),X=w.charAt(F+1);if(z===P&&X===P)F++,y+=P;else if(z==="\\")if(["a","b","f","n","r","t","v","'",'"',"\\","a","\b","\f",` +`,"\r"," ","\v"].indexOf(X)!==-1)switch(F+=1,X){case"a":y+="a";break;case"b":y+="\b";break;case"f":y+="\f";break;case"n":y+=` +`;break;case"r":y+="\r";break;case"t":y+=" ";break;case"v":y+="\v";break;case"'":y+="'";break;case'"':y+='"';break;case"\\":y+="\\";break}else return null;else y+=z}return y}function x(w){for(var P="",y=0;y<w.length;y++)switch(w.charAt(y)){case"'":P+="\\'";break;case"\\":P+="\\\\";break;case"\b":P+="\\b";break;case"\f":P+="\\f";break;case` +`:P+="\\n";break;case"\r":P+="\\r";break;case" ":P+="\\t";break;case"\v":P+="\\v";break;default:P+=w.charAt(y);break}return P}function C(w){var P=w.substr(2);switch(w.substr(0,2).toLowerCase()){case"0x":return parseInt(P,16);case"0b":return parseInt(P,2);case"0o":return parseInt(P,8);case"0'":return I(P)[0];default:return parseFloat(w)}}var R={whitespace:/^\s*(?:(?:%.*)|(?:\/\*(?:\n|\r|.)*?\*\/)|(?:\s+))\s*/,variable:/^(?:[A-Z_][a-zA-Z0-9_]*)/,atom:/^(\!|,|;|[a-z][0-9a-zA-Z_]*|[#\$\&\*\+\-\.\/\:\<\=\>\?\@\^\~\\]+|'(?:[^']*?(?:\\(?:x?\d+)?\\)*(?:'')*(?:\\')*)*')/,number:/^(?:0o[0-7]+|0x[0-9a-fA-F]+|0b[01]+|0'(?:''|\\[abfnrtv\\'"`]|\\x?\d+\\|[^\\])|\d+(?:\.\d+(?:[eE][+-]?\d+)?)?)/,string:/^(?:"([^"]|""|\\")*"|`([^`]|``|\\`)*`)/,l_brace:/^(?:\[)/,r_brace:/^(?:\])/,l_bracket:/^(?:\{)/,r_bracket:/^(?:\})/,bar:/^(?:\|)/,l_paren:/^(?:\()/,r_paren:/^(?:\))/};function L(w,P){return w.get_flag("char_conversion").id==="on"?P.replace(/./g,function(y){return w.get_char_conversion(y)}):P}function U(w){this.thread=w,this.text="",this.tokens=[]}U.prototype.set_last_tokens=function(w){return this.tokens=w},U.prototype.new_text=function(w){this.text=w,this.tokens=[]},U.prototype.get_tokens=function(w){var P,y=0,F=0,z=0,X=[],Z=!1;if(w){var ie=this.tokens[w-1];y=ie.len,P=L(this.thread,this.text.substr(ie.len)),F=ie.line,z=ie.start}else P=this.text;if(/^\s*$/.test(P))return null;for(;P!=="";){var Pe=[],Ne=!1;if(/^\n/.exec(P)!==null){F++,z=0,y++,P=P.replace(/\n/,""),Z=!0;continue}for(var ot in R)if(R.hasOwnProperty(ot)){var dt=R[ot].exec(P);dt&&Pe.push({value:dt[0],name:ot,matches:dt})}if(!Pe.length)return this.set_last_tokens([{value:P,matches:[],name:"lexical",line:F,start:z}]);var ie=r(Pe,function(Qr,mr){return Qr.value.length>=mr.value.length?Qr:mr});switch(ie.start=z,ie.line=F,P=P.replace(ie.value,""),z+=ie.value.length,y+=ie.value.length,ie.name){case"atom":ie.raw=ie.value,ie.value.charAt(0)==="'"&&(ie.value=v(ie.value.substr(1,ie.value.length-2),"'"),ie.value===null&&(ie.name="lexical",ie.value="unknown escape sequence"));break;case"number":ie.float=ie.value.substring(0,2)!=="0x"&&ie.value.match(/[.eE]/)!==null&&ie.value!=="0'.",ie.value=C(ie.value),ie.blank=Ne;break;case"string":var Gt=ie.value.charAt(0);ie.value=v(ie.value.substr(1,ie.value.length-2),Gt),ie.value===null&&(ie.name="lexical",ie.value="unknown escape sequence");break;case"whitespace":var $t=X[X.length-1];$t&&($t.space=!0),Ne=!0;continue;case"r_bracket":X.length>0&&X[X.length-1].name==="l_bracket"&&(ie=X.pop(),ie.name="atom",ie.value="{}",ie.raw="{}",ie.space=!1);break;case"r_brace":X.length>0&&X[X.length-1].name==="l_brace"&&(ie=X.pop(),ie.name="atom",ie.value="[]",ie.raw="[]",ie.space=!1);break}ie.len=y,X.push(ie),Ne=!1}var bt=this.set_last_tokens(X);return bt.length===0?null:bt};function J(w,P,y,F,z){if(!P[y])return{type:A,value:b.error.syntax(P[y-1],"expression expected",!0)};var X;if(F==="0"){var Z=P[y];switch(Z.name){case"number":return{type:p,len:y+1,value:new b.type.Num(Z.value,Z.float)};case"variable":return{type:p,len:y+1,value:new b.type.Var(Z.value)};case"string":var ie;switch(w.get_flag("double_quotes").id){case"atom":ie=new H(Z.value,[]);break;case"codes":ie=new H("[]",[]);for(var Pe=Z.value.length-1;Pe>=0;Pe--)ie=new H(".",[new b.type.Num(n(Z.value,Pe),!1),ie]);break;case"chars":ie=new H("[]",[]);for(var Pe=Z.value.length-1;Pe>=0;Pe--)ie=new H(".",[new b.type.Term(Z.value.charAt(Pe),[]),ie]);break}return{type:p,len:y+1,value:ie};case"l_paren":var bt=J(w,P,y+1,w.__get_max_priority(),!0);return bt.type!==p?bt:P[bt.len]&&P[bt.len].name==="r_paren"?(bt.len++,bt):{type:A,derived:!0,value:b.error.syntax(P[bt.len]?P[bt.len]:P[bt.len-1],") or operator expected",!P[bt.len])};case"l_bracket":var bt=J(w,P,y+1,w.__get_max_priority(),!0);return bt.type!==p?bt:P[bt.len]&&P[bt.len].name==="r_bracket"?(bt.len++,bt.value=new H("{}",[bt.value]),bt):{type:A,derived:!0,value:b.error.syntax(P[bt.len]?P[bt.len]:P[bt.len-1],"} or operator expected",!P[bt.len])}}var Ne=te(w,P,y,z);return Ne.type===p||Ne.derived||(Ne=ae(w,P,y),Ne.type===p||Ne.derived)?Ne:{type:A,derived:!1,value:b.error.syntax(P[y],"unexpected token")}}var ot=w.__get_max_priority(),dt=w.__get_next_priority(F),Gt=y;if(P[y].name==="atom"&&P[y+1]&&(P[y].space||P[y+1].name!=="l_paren")){var Z=P[y++],$t=w.__lookup_operator_classes(F,Z.value);if($t&&$t.indexOf("fy")>-1){var bt=J(w,P,y,F,z);if(bt.type!==A)return Z.value==="-"&&!Z.space&&b.type.is_number(bt.value)?{value:new b.type.Num(-bt.value.value,bt.value.is_float),len:bt.len,type:p}:{value:new b.type.Term(Z.value,[bt.value]),len:bt.len,type:p};X=bt}else if($t&&$t.indexOf("fx")>-1){var bt=J(w,P,y,dt,z);if(bt.type!==A)return{value:new b.type.Term(Z.value,[bt.value]),len:bt.len,type:p};X=bt}}y=Gt;var bt=J(w,P,y,dt,z);if(bt.type===p){y=bt.len;var Z=P[y];if(P[y]&&(P[y].name==="atom"&&w.__lookup_operator_classes(F,Z.value)||P[y].name==="bar"&&w.__lookup_operator_classes(F,"|"))){var an=dt,Qr=F,$t=w.__lookup_operator_classes(F,Z.value);if($t.indexOf("xf")>-1)return{value:new b.type.Term(Z.value,[bt.value]),len:++bt.len,type:p};if($t.indexOf("xfx")>-1){var mr=J(w,P,y+1,an,z);return mr.type===p?{value:new b.type.Term(Z.value,[bt.value,mr.value]),len:mr.len,type:p}:(mr.derived=!0,mr)}else if($t.indexOf("xfy")>-1){var mr=J(w,P,y+1,Qr,z);return mr.type===p?{value:new b.type.Term(Z.value,[bt.value,mr.value]),len:mr.len,type:p}:(mr.derived=!0,mr)}else if(bt.type!==A)for(;;){y=bt.len;var Z=P[y];if(Z&&Z.name==="atom"&&w.__lookup_operator_classes(F,Z.value)){var $t=w.__lookup_operator_classes(F,Z.value);if($t.indexOf("yf")>-1)bt={value:new b.type.Term(Z.value,[bt.value]),len:++y,type:p};else if($t.indexOf("yfx")>-1){var mr=J(w,P,++y,an,z);if(mr.type===A)return mr.derived=!0,mr;y=mr.len,bt={value:new b.type.Term(Z.value,[bt.value,mr.value]),len:y,type:p}}else break}else break}}else X={type:A,value:b.error.syntax(P[bt.len-1],"operator expected")};return bt}return bt}function te(w,P,y,F){if(!P[y]||P[y].name==="atom"&&P[y].raw==="."&&!F&&(P[y].space||!P[y+1]||P[y+1].name!=="l_paren"))return{type:A,derived:!1,value:b.error.syntax(P[y-1],"unfounded token")};var z=P[y],X=[];if(P[y].name==="atom"&&P[y].raw!==","){if(y++,P[y-1].space)return{type:p,len:y,value:new b.type.Term(z.value,X)};if(P[y]&&P[y].name==="l_paren"){if(P[y+1]&&P[y+1].name==="r_paren")return{type:A,derived:!0,value:b.error.syntax(P[y+1],"argument expected")};var Z=J(w,P,++y,"999",!0);if(Z.type===A)return Z.derived?Z:{type:A,derived:!0,value:b.error.syntax(P[y]?P[y]:P[y-1],"argument expected",!P[y])};for(X.push(Z.value),y=Z.len;P[y]&&P[y].name==="atom"&&P[y].value===",";){if(Z=J(w,P,y+1,"999",!0),Z.type===A)return Z.derived?Z:{type:A,derived:!0,value:b.error.syntax(P[y+1]?P[y+1]:P[y],"argument expected",!P[y+1])};X.push(Z.value),y=Z.len}if(P[y]&&P[y].name==="r_paren")y++;else return{type:A,derived:!0,value:b.error.syntax(P[y]?P[y]:P[y-1],", or ) expected",!P[y])}}return{type:p,len:y,value:new b.type.Term(z.value,X)}}return{type:A,derived:!1,value:b.error.syntax(P[y],"term expected")}}function ae(w,P,y){if(!P[y])return{type:A,derived:!1,value:b.error.syntax(P[y-1],"[ expected")};if(P[y]&&P[y].name==="l_brace"){var F=J(w,P,++y,"999",!0),z=[F.value],X=void 0;if(F.type===A)return P[y]&&P[y].name==="r_brace"?{type:p,len:y+1,value:new b.type.Term("[]",[])}:{type:A,derived:!0,value:b.error.syntax(P[y],"] expected")};for(y=F.len;P[y]&&P[y].name==="atom"&&P[y].value===",";){if(F=J(w,P,y+1,"999",!0),F.type===A)return F.derived?F:{type:A,derived:!0,value:b.error.syntax(P[y+1]?P[y+1]:P[y],"argument expected",!P[y+1])};z.push(F.value),y=F.len}var Z=!1;if(P[y]&&P[y].name==="bar"){if(Z=!0,F=J(w,P,y+1,"999",!0),F.type===A)return F.derived?F:{type:A,derived:!0,value:b.error.syntax(P[y+1]?P[y+1]:P[y],"argument expected",!P[y+1])};X=F.value,y=F.len}return P[y]&&P[y].name==="r_brace"?{type:p,len:y+1,value:g(z,X)}:{type:A,derived:!0,value:b.error.syntax(P[y]?P[y]:P[y-1],Z?"] expected":", or | or ] expected",!P[y])}}return{type:A,derived:!1,value:b.error.syntax(P[y],"list expected")}}function fe(w,P,y){var F=P[y].line,z=J(w,P,y,w.__get_max_priority(),!1),X=null,Z;if(z.type!==A)if(y=z.len,P[y]&&P[y].name==="atom"&&P[y].raw===".")if(y++,b.type.is_term(z.value)){if(z.value.indicator===":-/2"?(X=new b.type.Rule(z.value.args[0],we(z.value.args[1])),Z={value:X,len:y,type:p}):z.value.indicator==="-->/2"?(X=he(new b.type.Rule(z.value.args[0],z.value.args[1]),w),X.body=we(X.body),Z={value:X,len:y,type:b.type.is_rule(X)?p:A}):(X=new b.type.Rule(z.value,null),Z={value:X,len:y,type:p}),X){var ie=X.singleton_variables();ie.length>0&&w.throw_warning(b.warning.singleton(ie,X.head.indicator,F))}return Z}else return{type:A,value:b.error.syntax(P[y],"callable expected")};else return{type:A,value:b.error.syntax(P[y]?P[y]:P[y-1],". or operator expected")};return z}function ce(w,P,y){y=y||{},y.from=y.from?y.from:"$tau-js",y.reconsult=y.reconsult!==void 0?y.reconsult:!0;var F=new U(w),z={},X;F.new_text(P);var Z=0,ie=F.get_tokens(Z);do{if(ie===null||!ie[Z])break;var Pe=fe(w,ie,Z);if(Pe.type===A)return new H("throw",[Pe.value]);if(Pe.value.body===null&&Pe.value.head.indicator==="?-/1"){var Ne=new Je(w.session);Ne.add_goal(Pe.value.head.args[0]),Ne.answer(function(dt){b.type.is_error(dt)?w.throw_warning(dt.args[0]):(dt===!1||dt===null)&&w.throw_warning(b.warning.failed_goal(Pe.value.head.args[0],Pe.len))}),Z=Pe.len;var ot=!0}else if(Pe.value.body===null&&Pe.value.head.indicator===":-/1"){var ot=w.run_directive(Pe.value.head.args[0]);Z=Pe.len,Pe.value.head.args[0].indicator==="char_conversion/2"&&(ie=F.get_tokens(Z),Z=0)}else{X=Pe.value.head.indicator,y.reconsult!==!1&&z[X]!==!0&&!w.is_multifile_predicate(X)&&(w.session.rules[X]=a(w.session.rules[X]||[],function(Gt){return Gt.dynamic}),z[X]=!0);var ot=w.add_rule(Pe.value,y);Z=Pe.len}if(!ot)return ot}while(!0);return!0}function me(w,P){var y=new U(w);y.new_text(P);var F=0;do{var z=y.get_tokens(F);if(z===null)break;var X=J(w,z,0,w.__get_max_priority(),!1);if(X.type!==A){var Z=X.len,ie=Z;if(z[Z]&&z[Z].name==="atom"&&z[Z].raw===".")w.add_goal(we(X.value));else{var Pe=z[Z];return new H("throw",[b.error.syntax(Pe||z[Z-1],". or operator expected",!Pe)])}F=X.len+1}else return new H("throw",[X.value])}while(!0);return!0}function he(w,P){w=w.rename(P);var y=P.next_free_variable(),F=Be(w.body,y,P);return F.error?F.value:(w.body=F.value,w.head.args=w.head.args.concat([y,F.variable]),w.head=new H(w.head.id,w.head.args),w)}function Be(w,P,y){var F;if(b.type.is_term(w)&&w.indicator==="!/0")return{value:w,variable:P,error:!1};if(b.type.is_term(w)&&w.indicator===",/2"){var z=Be(w.args[0],P,y);if(z.error)return z;var X=Be(w.args[1],z.variable,y);return X.error?X:{value:new H(",",[z.value,X.value]),variable:X.variable,error:!1}}else{if(b.type.is_term(w)&&w.indicator==="{}/1")return{value:w.args[0],variable:P,error:!1};if(b.type.is_empty_list(w))return{value:new H("true",[]),variable:P,error:!1};if(b.type.is_list(w)){F=y.next_free_variable();for(var Z=w,ie;Z.indicator==="./2";)ie=Z,Z=Z.args[1];return b.type.is_variable(Z)?{value:b.error.instantiation("DCG"),variable:P,error:!0}:b.type.is_empty_list(Z)?(ie.args[1]=F,{value:new H("=",[P,w]),variable:F,error:!1}):{value:b.error.type("list",w,"DCG"),variable:P,error:!0}}else return b.type.is_callable(w)?(F=y.next_free_variable(),w.args=w.args.concat([P,F]),w=new H(w.id,w.args),{value:w,variable:F,error:!1}):{value:b.error.type("callable",w,"DCG"),variable:P,error:!0}}}function we(w){return b.type.is_variable(w)?new H("call",[w]):b.type.is_term(w)&&[",/2",";/2","->/2"].indexOf(w.indicator)!==-1?new H(w.id,[we(w.args[0]),we(w.args[1])]):w}function g(w,P){for(var y=P||new b.type.Term("[]",[]),F=w.length-1;F>=0;F--)y=new b.type.Term(".",[w[F],y]);return y}function Ee(w,P){for(var y=w.length-1;y>=0;y--)w[y]===P&&w.splice(y,1)}function Se(w){for(var P={},y=[],F=0;F<w.length;F++)w[F]in P||(y.push(w[F]),P[w[F]]=!0);return y}function le(w,P,y,F){if(w.session.rules[y]!==null){for(var z=0;z<w.session.rules[y].length;z++)if(w.session.rules[y][z]===F){w.session.rules[y].splice(z,1),w.success(P);break}}}function ne(w){return function(P,y,F){var z=F.args[0],X=F.args.slice(1,w);if(b.type.is_variable(z))P.throw_error(b.error.instantiation(P.level));else if(!b.type.is_callable(z))P.throw_error(b.error.type("callable",z,P.level));else{var Z=new H(z.id,z.args.concat(X));P.prepend([new xe(y.goal.replace(Z),y.substitution,y)])}}}function ee(w){for(var P=w.length-1;P>=0;P--)if(w.charAt(P)==="/")return new H("/",[new H(w.substring(0,P)),new Fe(parseInt(w.substring(P+1)),!1)])}function Ie(w){this.id=w}function Fe(w,P){this.is_float=P!==void 0?P:parseInt(w)!==w,this.value=this.is_float?w:parseInt(w)}var At=0;function H(w,P,y){this.ref=y||++At,this.id=w,this.args=P||[],this.indicator=w+"/"+this.args.length}var at=0;function Re(w,P,y,F,z,X){this.id=at++,this.stream=w,this.mode=P,this.alias=y,this.type=F!==void 0?F:"text",this.reposition=z!==void 0?z:!0,this.eof_action=X!==void 0?X:"eof_code",this.position=this.mode==="append"?"end_of_stream":0,this.output=this.mode==="write"||this.mode==="append",this.input=this.mode==="read"}function ke(w){w=w||{},this.links=w}function xe(w,P,y){P=P||new ke,y=y||null,this.goal=w,this.substitution=P,this.parent=y}function He(w,P,y){this.head=w,this.body=P,this.dynamic=y||!1}function Te(w){w=w===void 0||w<=0?1e3:w,this.rules={},this.src_predicates={},this.rename=0,this.modules=[],this.thread=new Je(this),this.total_threads=1,this.renamed_variables={},this.public_predicates={},this.multifile_predicates={},this.limit=w,this.streams={user_input:new Re(typeof hl<"u"&&hl.exports?nodejs_user_input:tau_user_input,"read","user_input","text",!1,"reset"),user_output:new Re(typeof hl<"u"&&hl.exports?nodejs_user_output:tau_user_output,"write","user_output","text",!1,"eof_code")},this.file_system=typeof hl<"u"&&hl.exports?nodejs_file_system:tau_file_system,this.standard_input=this.streams.user_input,this.standard_output=this.streams.user_output,this.current_input=this.streams.user_input,this.current_output=this.streams.user_output,this.format_success=function(P){return P.substitution},this.format_error=function(P){return P.goal},this.flag={bounded:b.flag.bounded.value,max_integer:b.flag.max_integer.value,min_integer:b.flag.min_integer.value,integer_rounding_function:b.flag.integer_rounding_function.value,char_conversion:b.flag.char_conversion.value,debug:b.flag.debug.value,max_arity:b.flag.max_arity.value,unknown:b.flag.unknown.value,double_quotes:b.flag.double_quotes.value,occurs_check:b.flag.occurs_check.value,dialect:b.flag.dialect.value,version_data:b.flag.version_data.value,nodejs:b.flag.nodejs.value},this.__loaded_modules=[],this.__char_conversion={},this.__operators={1200:{":-":["fx","xfx"],"-->":["xfx"],"?-":["fx"]},1100:{";":["xfy"]},1050:{"->":["xfy"]},1e3:{",":["xfy"]},900:{"\\+":["fy"]},700:{"=":["xfx"],"\\=":["xfx"],"==":["xfx"],"\\==":["xfx"],"@<":["xfx"],"@=<":["xfx"],"@>":["xfx"],"@>=":["xfx"],"=..":["xfx"],is:["xfx"],"=:=":["xfx"],"=\\=":["xfx"],"<":["xfx"],"=<":["xfx"],">":["xfx"],">=":["xfx"]},600:{":":["xfy"]},500:{"+":["yfx"],"-":["yfx"],"/\\":["yfx"],"\\/":["yfx"]},400:{"*":["yfx"],"/":["yfx"],"//":["yfx"],rem:["yfx"],mod:["yfx"],"<<":["yfx"],">>":["yfx"]},200:{"**":["xfx"],"^":["xfy"],"-":["fy"],"+":["fy"],"\\":["fy"]}}}function Je(w){this.epoch=Date.now(),this.session=w,this.session.total_threads++,this.total_steps=0,this.cpu_time=0,this.cpu_time_last=0,this.points=[],this.debugger=!1,this.debugger_states=[],this.level="top_level/0",this.__calls=[],this.current_limit=this.session.limit,this.warnings=[]}function je(w,P,y){this.id=w,this.rules=P,this.exports=y,b.module[w]=this}je.prototype.exports_predicate=function(w){return this.exports.indexOf(w)!==-1},Ie.prototype.unify=function(w,P){if(P&&e(w.variables(),this.id)!==-1&&!b.type.is_variable(w))return null;var y={};return y[this.id]=w,new ke(y)},Fe.prototype.unify=function(w,P){return b.type.is_number(w)&&this.value===w.value&&this.is_float===w.is_float?new ke:null},H.prototype.unify=function(w,P){if(b.type.is_term(w)&&this.indicator===w.indicator){for(var y=new ke,F=0;F<this.args.length;F++){var z=b.unify(this.args[F].apply(y),w.args[F].apply(y),P);if(z===null)return null;for(var X in z.links)y.links[X]=z.links[X];y=y.apply(z)}return y}return null},Re.prototype.unify=function(w,P){return b.type.is_stream(w)&&this.id===w.id?new ke:null},Ie.prototype.toString=function(w){return this.id},Fe.prototype.toString=function(w){return this.is_float&&e(this.value.toString(),".")===-1?this.value+".0":this.value.toString()},H.prototype.toString=function(w,P,y){if(w=w||{},w.quoted=w.quoted===void 0?!0:w.quoted,w.ignore_ops=w.ignore_ops===void 0?!1:w.ignore_ops,w.numbervars=w.numbervars===void 0?!1:w.numbervars,P=P===void 0?1200:P,y=y===void 0?"":y,w.numbervars&&this.indicator==="$VAR/1"&&b.type.is_integer(this.args[0])&&this.args[0].value>=0){var F=this.args[0].value,z=Math.floor(F/26),X=F%26;return"ABCDEFGHIJKLMNOPQRSTUVWXYZ"[X]+(z!==0?z:"")}switch(this.indicator){case"[]/0":case"{}/0":case"!/0":return this.id;case"{}/1":return"{"+this.args[0].toString(w)+"}";case"./2":for(var Z="["+this.args[0].toString(w),ie=this.args[1];ie.indicator==="./2";)Z+=", "+ie.args[0].toString(w),ie=ie.args[1];return ie.indicator!=="[]/0"&&(Z+="|"+ie.toString(w)),Z+="]",Z;case",/2":return"("+this.args[0].toString(w)+", "+this.args[1].toString(w)+")";default:var Pe=this.id,Ne=w.session?w.session.lookup_operator(this.id,this.args.length):null;if(w.session===void 0||w.ignore_ops||Ne===null)return w.quoted&&!/^(!|,|;|[a-z][0-9a-zA-Z_]*)$/.test(Pe)&&Pe!=="{}"&&Pe!=="[]"&&(Pe="'"+x(Pe)+"'"),Pe+(this.args.length?"("+o(this.args,function($t){return $t.toString(w)}).join(", ")+")":"");var ot=Ne.priority>P.priority||Ne.priority===P.priority&&(Ne.class==="xfy"&&this.indicator!==P.indicator||Ne.class==="yfx"&&this.indicator!==P.indicator||this.indicator===P.indicator&&Ne.class==="yfx"&&y==="right"||this.indicator===P.indicator&&Ne.class==="xfy"&&y==="left");Ne.indicator=this.indicator;var dt=ot?"(":"",Gt=ot?")":"";return this.args.length===0?"("+this.id+")":["fy","fx"].indexOf(Ne.class)!==-1?dt+Pe+" "+this.args[0].toString(w,Ne)+Gt:["yf","xf"].indexOf(Ne.class)!==-1?dt+this.args[0].toString(w,Ne)+" "+Pe+Gt:dt+this.args[0].toString(w,Ne,"left")+" "+this.id+" "+this.args[1].toString(w,Ne,"right")+Gt}},Re.prototype.toString=function(w){return"<stream>("+this.id+")"},ke.prototype.toString=function(w){var P="{";for(var y in this.links)!this.links.hasOwnProperty(y)||(P!=="{"&&(P+=", "),P+=y+"/"+this.links[y].toString(w));return P+="}",P},xe.prototype.toString=function(w){return this.goal===null?"<"+this.substitution.toString(w)+">":"<"+this.goal.toString(w)+", "+this.substitution.toString(w)+">"},He.prototype.toString=function(w){return this.body?this.head.toString(w)+" :- "+this.body.toString(w)+".":this.head.toString(w)+"."},Te.prototype.toString=function(w){for(var P="",y=0;y<this.modules.length;y++)P+=":- use_module(library("+this.modules[y]+`)). +`;P+=` +`;for(key in this.rules)for(y=0;y<this.rules[key].length;y++)P+=this.rules[key][y].toString(w),P+=` +`;return P},Ie.prototype.clone=function(){return new Ie(this.id)},Fe.prototype.clone=function(){return new Fe(this.value,this.is_float)},H.prototype.clone=function(){return new H(this.id,o(this.args,function(w){return w.clone()}))},Re.prototype.clone=function(){return new Stram(this.stream,this.mode,this.alias,this.type,this.reposition,this.eof_action)},ke.prototype.clone=function(){var w={};for(var P in this.links)!this.links.hasOwnProperty(P)||(w[P]=this.links[P].clone());return new ke(w)},xe.prototype.clone=function(){return new xe(this.goal.clone(),this.substitution.clone(),this.parent)},He.prototype.clone=function(){return new He(this.head.clone(),this.body!==null?this.body.clone():null)},Ie.prototype.equals=function(w){return b.type.is_variable(w)&&this.id===w.id},Fe.prototype.equals=function(w){return b.type.is_number(w)&&this.value===w.value&&this.is_float===w.is_float},H.prototype.equals=function(w){if(!b.type.is_term(w)||this.indicator!==w.indicator)return!1;for(var P=0;P<this.args.length;P++)if(!this.args[P].equals(w.args[P]))return!1;return!0},Re.prototype.equals=function(w){return b.type.is_stream(w)&&this.id===w.id},ke.prototype.equals=function(w){var P;if(!b.type.is_substitution(w))return!1;for(P in this.links)if(!!this.links.hasOwnProperty(P)&&(!w.links[P]||!this.links[P].equals(w.links[P])))return!1;for(P in w.links)if(!!w.links.hasOwnProperty(P)&&!this.links[P])return!1;return!0},xe.prototype.equals=function(w){return b.type.is_state(w)&&this.goal.equals(w.goal)&&this.substitution.equals(w.substitution)&&this.parent===w.parent},He.prototype.equals=function(w){return b.type.is_rule(w)&&this.head.equals(w.head)&&(this.body===null&&w.body===null||this.body!==null&&this.body.equals(w.body))},Ie.prototype.rename=function(w){return w.get_free_variable(this)},Fe.prototype.rename=function(w){return this},H.prototype.rename=function(w){return new H(this.id,o(this.args,function(P){return P.rename(w)}))},Re.prototype.rename=function(w){return this},He.prototype.rename=function(w){return new He(this.head.rename(w),this.body!==null?this.body.rename(w):null)},Ie.prototype.variables=function(){return[this.id]},Fe.prototype.variables=function(){return[]},H.prototype.variables=function(){return[].concat.apply([],o(this.args,function(w){return w.variables()}))},Re.prototype.variables=function(){return[]},He.prototype.variables=function(){return this.body===null?this.head.variables():this.head.variables().concat(this.body.variables())},Ie.prototype.apply=function(w){return w.lookup(this.id)?w.lookup(this.id):this},Fe.prototype.apply=function(w){return this},H.prototype.apply=function(w){if(this.indicator==="./2"){for(var P=[],y=this;y.indicator==="./2";)P.push(y.args[0].apply(w)),y=y.args[1];for(var F=y.apply(w),z=P.length-1;z>=0;z--)F=new H(".",[P[z],F]);return F}return new H(this.id,o(this.args,function(X){return X.apply(w)}),this.ref)},Re.prototype.apply=function(w){return this},He.prototype.apply=function(w){return new He(this.head.apply(w),this.body!==null?this.body.apply(w):null)},ke.prototype.apply=function(w){var P,y={};for(P in this.links)!this.links.hasOwnProperty(P)||(y[P]=this.links[P].apply(w));return new ke(y)},H.prototype.select=function(){for(var w=this;w.indicator===",/2";)w=w.args[0];return w},H.prototype.replace=function(w){return this.indicator===",/2"?this.args[0].indicator===",/2"?new H(",",[this.args[0].replace(w),this.args[1]]):w===null?this.args[1]:new H(",",[w,this.args[1]]):w},H.prototype.search=function(w){if(b.type.is_term(w)&&w.ref!==void 0&&this.ref===w.ref)return!0;for(var P=0;P<this.args.length;P++)if(b.type.is_term(this.args[P])&&this.args[P].search(w))return!0;return!1},Te.prototype.get_current_input=function(){return this.current_input},Je.prototype.get_current_input=function(){return this.session.get_current_input()},Te.prototype.get_current_output=function(){return this.current_output},Je.prototype.get_current_output=function(){return this.session.get_current_output()},Te.prototype.set_current_input=function(w){this.current_input=w},Je.prototype.set_current_input=function(w){return this.session.set_current_input(w)},Te.prototype.set_current_output=function(w){this.current_input=w},Je.prototype.set_current_output=function(w){return this.session.set_current_output(w)},Te.prototype.get_stream_by_alias=function(w){return this.streams[w]},Je.prototype.get_stream_by_alias=function(w){return this.session.get_stream_by_alias(w)},Te.prototype.file_system_open=function(w,P,y){return this.file_system.open(w,P,y)},Je.prototype.file_system_open=function(w,P,y){return this.session.file_system_open(w,P,y)},Te.prototype.get_char_conversion=function(w){return this.__char_conversion[w]||w},Je.prototype.get_char_conversion=function(w){return this.session.get_char_conversion(w)},Te.prototype.parse=function(w){return this.thread.parse(w)},Je.prototype.parse=function(w){var P=new U(this);P.new_text(w);var y=P.get_tokens();if(y===null)return!1;var F=J(this,y,0,this.__get_max_priority(),!1);return F.len!==y.length?!1:{value:F.value,expr:F,tokens:y}},Te.prototype.get_flag=function(w){return this.flag[w]},Je.prototype.get_flag=function(w){return this.session.get_flag(w)},Te.prototype.add_rule=function(w,P){return P=P||{},P.from=P.from?P.from:"$tau-js",this.src_predicates[w.head.indicator]=P.from,this.rules[w.head.indicator]||(this.rules[w.head.indicator]=[]),this.rules[w.head.indicator].push(w),this.public_predicates.hasOwnProperty(w.head.indicator)||(this.public_predicates[w.head.indicator]=!1),!0},Je.prototype.add_rule=function(w,P){return this.session.add_rule(w,P)},Te.prototype.run_directive=function(w){this.thread.run_directive(w)},Je.prototype.run_directive=function(w){return b.type.is_directive(w)?(b.directive[w.indicator](this,w),!0):!1},Te.prototype.__get_max_priority=function(){return"1200"},Je.prototype.__get_max_priority=function(){return this.session.__get_max_priority()},Te.prototype.__get_next_priority=function(w){var P=0;w=parseInt(w);for(var y in this.__operators)if(!!this.__operators.hasOwnProperty(y)){var F=parseInt(y);F>P&&F<w&&(P=F)}return P.toString()},Je.prototype.__get_next_priority=function(w){return this.session.__get_next_priority(w)},Te.prototype.__lookup_operator_classes=function(w,P){return this.__operators.hasOwnProperty(w)&&this.__operators[w][P]instanceof Array&&this.__operators[w][P]||!1},Je.prototype.__lookup_operator_classes=function(w,P){return this.session.__lookup_operator_classes(w,P)},Te.prototype.lookup_operator=function(w,P){for(var y in this.__operators)if(this.__operators[y][w]){for(var F=0;F<this.__operators[y][w].length;F++)if(P===0||this.__operators[y][w][F].length===P+1)return{priority:y,class:this.__operators[y][w][F]}}return null},Je.prototype.lookup_operator=function(w,P){return this.session.lookup_operator(w,P)},Te.prototype.throw_warning=function(w){this.thread.throw_warning(w)},Je.prototype.throw_warning=function(w){this.warnings.push(w)},Te.prototype.get_warnings=function(){return this.thread.get_warnings()},Je.prototype.get_warnings=function(){return this.warnings},Te.prototype.add_goal=function(w,P){this.thread.add_goal(w,P)},Je.prototype.add_goal=function(w,P,y){y=y||null,P===!0&&(this.points=[]);for(var F=w.variables(),z={},X=0;X<F.length;X++)z[F[X]]=new Ie(F[X]);this.points.push(new xe(w,new ke(z),y))},Te.prototype.consult=function(w,P){return this.thread.consult(w,P)},Je.prototype.consult=function(w,P){var y="";if(typeof w=="string"){y=w;var F=y.length;if(y.substring(F-3,F)===".pl"&&document.getElementById(y)){var z=document.getElementById(y),X=z.getAttribute("type");X!==null&&X.replace(/ /g,"").toLowerCase()==="text/prolog"&&(y=z.text)}}else if(w.nodeName)switch(w.nodeName.toLowerCase()){case"input":case"textarea":y=w.value;break;default:y=w.innerHTML;break}else return!1;return this.warnings=[],ce(this,y,P)},Te.prototype.query=function(w){return this.thread.query(w)},Je.prototype.query=function(w){return this.points=[],this.debugger_points=[],me(this,w)},Te.prototype.head_point=function(){return this.thread.head_point()},Je.prototype.head_point=function(){return this.points[this.points.length-1]},Te.prototype.get_free_variable=function(w){return this.thread.get_free_variable(w)},Je.prototype.get_free_variable=function(w){var P=[];if(w.id==="_"||this.session.renamed_variables[w.id]===void 0){for(this.session.rename++,this.points.length>0&&(P=this.head_point().substitution.domain());e(P,b.format_variable(this.session.rename))!==-1;)this.session.rename++;if(w.id==="_")return new Ie(b.format_variable(this.session.rename));this.session.renamed_variables[w.id]=b.format_variable(this.session.rename)}return new Ie(this.session.renamed_variables[w.id])},Te.prototype.next_free_variable=function(){return this.thread.next_free_variable()},Je.prototype.next_free_variable=function(){this.session.rename++;var w=[];for(this.points.length>0&&(w=this.head_point().substitution.domain());e(w,b.format_variable(this.session.rename))!==-1;)this.session.rename++;return new Ie(b.format_variable(this.session.rename))},Te.prototype.is_public_predicate=function(w){return!this.public_predicates.hasOwnProperty(w)||this.public_predicates[w]===!0},Je.prototype.is_public_predicate=function(w){return this.session.is_public_predicate(w)},Te.prototype.is_multifile_predicate=function(w){return this.multifile_predicates.hasOwnProperty(w)&&this.multifile_predicates[w]===!0},Je.prototype.is_multifile_predicate=function(w){return this.session.is_multifile_predicate(w)},Te.prototype.prepend=function(w){return this.thread.prepend(w)},Je.prototype.prepend=function(w){for(var P=w.length-1;P>=0;P--)this.points.push(w[P])},Te.prototype.success=function(w,P){return this.thread.success(w,P)},Je.prototype.success=function(w,y){var y=typeof y>"u"?w:y;this.prepend([new xe(w.goal.replace(null),w.substitution,y)])},Te.prototype.throw_error=function(w){return this.thread.throw_error(w)},Je.prototype.throw_error=function(w){this.prepend([new xe(new H("throw",[w]),new ke,null,null)])},Te.prototype.step_rule=function(w,P){return this.thread.step_rule(w,P)},Je.prototype.step_rule=function(w,P){var y=P.indicator;if(w==="user"&&(w=null),w===null&&this.session.rules.hasOwnProperty(y))return this.session.rules[y];for(var F=w===null?this.session.modules:e(this.session.modules,w)===-1?[]:[w],z=0;z<F.length;z++){var X=b.module[F[z]];if(X.rules.hasOwnProperty(y)&&(X.rules.hasOwnProperty(this.level)||X.exports_predicate(y)))return b.module[F[z]].rules[y]}return null},Te.prototype.step=function(){return this.thread.step()},Je.prototype.step=function(){if(this.points.length!==0){var w=!1,P=this.points.pop();if(this.debugger&&this.debugger_states.push(P),b.type.is_term(P.goal)){var y=P.goal.select(),F=null,z=[];if(y!==null){this.total_steps++;for(var X=P;X.parent!==null&&X.parent.goal.search(y);)X=X.parent;if(this.level=X.parent===null?"top_level/0":X.parent.goal.select().indicator,b.type.is_term(y)&&y.indicator===":/2"&&(F=y.args[0].id,y=y.args[1]),F===null&&b.type.is_builtin(y))this.__call_indicator=y.indicator,w=b.predicate[y.indicator](this,P,y);else{var Z=this.step_rule(F,y);if(Z===null)this.session.rules.hasOwnProperty(y.indicator)||(this.get_flag("unknown").id==="error"?this.throw_error(b.error.existence("procedure",y.indicator,this.level)):this.get_flag("unknown").id==="warning"&&this.throw_warning("unknown procedure "+y.indicator+" (from "+this.level+")"));else if(Z instanceof Function)w=Z(this,P,y);else{for(var ie in Z)if(!!Z.hasOwnProperty(ie)){var Pe=Z[ie];this.session.renamed_variables={},Pe=Pe.rename(this);var Ne=this.get_flag("occurs_check").indicator==="true/0",ot=new xe,dt=b.unify(y,Pe.head,Ne);dt!==null&&(ot.goal=P.goal.replace(Pe.body),ot.goal!==null&&(ot.goal=ot.goal.apply(dt)),ot.substitution=P.substitution.apply(dt),ot.parent=P,z.push(ot))}this.prepend(z)}}}}else b.type.is_variable(P.goal)?this.throw_error(b.error.instantiation(this.level)):this.throw_error(b.error.type("callable",P.goal,this.level));return w}},Te.prototype.answer=function(w){return this.thread.answer(w)},Je.prototype.answer=function(w){w=w||function(P){},this.__calls.push(w),!(this.__calls.length>1)&&this.again()},Te.prototype.answers=function(w,P,y){return this.thread.answers(w,P,y)},Je.prototype.answers=function(w,P,y){var F=P||1e3,z=this;if(P<=0){y&&y();return}this.answer(function(X){w(X),X!==!1?setTimeout(function(){z.answers(w,P-1,y)},1):y&&y()})},Te.prototype.again=function(w){return this.thread.again(w)},Je.prototype.again=function(w){for(var P,y=Date.now();this.__calls.length>0;){for(this.warnings=[],w!==!1&&(this.current_limit=this.session.limit);this.current_limit>0&&this.points.length>0&&this.head_point().goal!==null&&!b.type.is_error(this.head_point().goal);)if(this.current_limit--,this.step()===!0)return;var F=Date.now();this.cpu_time_last=F-y,this.cpu_time+=this.cpu_time_last;var z=this.__calls.shift();this.current_limit<=0?z(null):this.points.length===0?z(!1):b.type.is_error(this.head_point().goal)?(P=this.session.format_error(this.points.pop()),this.points=[],z(P)):(this.debugger&&this.debugger_states.push(this.head_point()),P=this.session.format_success(this.points.pop()),z(P))}},Te.prototype.unfold=function(w){if(w.body===null)return!1;var P=w.head,y=w.body,F=y.select(),z=new Je(this),X=[];z.add_goal(F),z.step();for(var Z=z.points.length-1;Z>=0;Z--){var ie=z.points[Z],Pe=P.apply(ie.substitution),Ne=y.replace(ie.goal);Ne!==null&&(Ne=Ne.apply(ie.substitution)),X.push(new He(Pe,Ne))}var ot=this.rules[P.indicator],dt=e(ot,w);return X.length>0&&dt!==-1?(ot.splice.apply(ot,[dt,1].concat(X)),!0):!1},Je.prototype.unfold=function(w){return this.session.unfold(w)},Ie.prototype.interpret=function(w){return b.error.instantiation(w.level)},Fe.prototype.interpret=function(w){return this},H.prototype.interpret=function(w){return b.type.is_unitary_list(this)?this.args[0].interpret(w):b.operate(w,this)},Ie.prototype.compare=function(w){return this.id<w.id?-1:this.id>w.id?1:0},Fe.prototype.compare=function(w){if(this.value===w.value&&this.is_float===w.is_float)return 0;if(this.value<w.value||this.value===w.value&&this.is_float&&!w.is_float)return-1;if(this.value>w.value)return 1},H.prototype.compare=function(w){if(this.args.length<w.args.length||this.args.length===w.args.length&&this.id<w.id)return-1;if(this.args.length>w.args.length||this.args.length===w.args.length&&this.id>w.id)return 1;for(var P=0;P<this.args.length;P++){var y=b.compare(this.args[P],w.args[P]);if(y!==0)return y}return 0},ke.prototype.lookup=function(w){return this.links[w]?this.links[w]:null},ke.prototype.filter=function(w){var P={};for(var y in this.links)if(!!this.links.hasOwnProperty(y)){var F=this.links[y];w(y,F)&&(P[y]=F)}return new ke(P)},ke.prototype.exclude=function(w){var P={};for(var y in this.links)!this.links.hasOwnProperty(y)||e(w,y)===-1&&(P[y]=this.links[y]);return new ke(P)},ke.prototype.add=function(w,P){this.links[w]=P},ke.prototype.domain=function(w){var P=w===!0?function(z){return z}:function(z){return new Ie(z)},y=[];for(var F in this.links)y.push(P(F));return y},Ie.prototype.compile=function(){return'new pl.type.Var("'+this.id.toString()+'")'},Fe.prototype.compile=function(){return"new pl.type.Num("+this.value.toString()+", "+this.is_float.toString()+")"},H.prototype.compile=function(){return'new pl.type.Term("'+this.id.replace(/"/g,'\\"')+'", ['+o(this.args,function(w){return w.compile()})+"])"},He.prototype.compile=function(){return"new pl.type.Rule("+this.head.compile()+", "+(this.body===null?"null":this.body.compile())+")"},Te.prototype.compile=function(){var w,P=[],y;for(var F in this.rules)if(!!this.rules.hasOwnProperty(F)){var z=this.rules[F];y=[],w='"'+F+'": [';for(var X=0;X<z.length;X++)y.push(z[X].compile());w+=y.join(),w+="]",P.push(w)}return"{"+P.join()+"};"},Ie.prototype.toJavaScript=function(){},Fe.prototype.toJavaScript=function(){return this.value},H.prototype.toJavaScript=function(){if(this.args.length===0&&this.indicator!=="[]/0")return this.id;if(b.type.is_list(this)){for(var w=[],P=this,y;P.indicator==="./2";){if(y=P.args[0].toJavaScript(),y===void 0)return;w.push(y),P=P.args[1]}if(P.indicator==="[]/0")return w}},He.prototype.singleton_variables=function(){var w=this.head.variables(),P={},y=[];this.body!==null&&(w=w.concat(this.body.variables()));for(var F=0;F<w.length;F++)P[w[F]]===void 0&&(P[w[F]]=0),P[w[F]]++;for(var z in P)z!=="_"&&P[z]===1&&y.push(z);return y};var b={__env:typeof hl<"u"&&hl.exports?global:window,module:{},version:t,parser:{tokenizer:U,expression:J},utils:{str_indicator:ee,codePointAt:n,fromCodePoint:u},statistics:{getCountTerms:function(){return At}},fromJavaScript:{test:{boolean:function(w){return w===!0||w===!1},number:function(w){return typeof w=="number"},string:function(w){return typeof w=="string"},list:function(w){return w instanceof Array},variable:function(w){return w===void 0},any:function(w){return!0}},conversion:{boolean:function(w){return new H(w?"true":"false",[])},number:function(w){return new Fe(w,w%1!==0)},string:function(w){return new H(w,[])},list:function(w){for(var P=[],y,F=0;F<w.length;F++){if(y=b.fromJavaScript.apply(w[F]),y===void 0)return;P.push(y)}return g(P)},variable:function(w){return new Ie("_")},any:function(w){}},apply:function(w){for(var P in b.fromJavaScript.test)if(P!=="any"&&b.fromJavaScript.test[P](w))return b.fromJavaScript.conversion[P](w);return b.fromJavaScript.conversion.any(w)}},type:{Var:Ie,Num:Fe,Term:H,Rule:He,State:xe,Stream:Re,Module:je,Thread:Je,Session:Te,Substitution:ke,order:[Ie,Fe,H,Re],compare:function(w,P){var y=e(b.type.order,w.constructor),F=e(b.type.order,P.constructor);if(y<F)return-1;if(y>F)return 1;if(w.constructor===Fe){if(w.is_float&&P.is_float)return 0;if(w.is_float)return-1;if(P.is_float)return 1}return 0},is_substitution:function(w){return w instanceof ke},is_state:function(w){return w instanceof xe},is_rule:function(w){return w instanceof He},is_variable:function(w){return w instanceof Ie},is_stream:function(w){return w instanceof Re},is_anonymous_var:function(w){return w instanceof Ie&&w.id==="_"},is_callable:function(w){return w instanceof H},is_number:function(w){return w instanceof Fe},is_integer:function(w){return w instanceof Fe&&!w.is_float},is_float:function(w){return w instanceof Fe&&w.is_float},is_term:function(w){return w instanceof H},is_atom:function(w){return w instanceof H&&w.args.length===0},is_ground:function(w){if(w instanceof Ie)return!1;if(w instanceof H){for(var P=0;P<w.args.length;P++)if(!b.type.is_ground(w.args[P]))return!1}return!0},is_atomic:function(w){return w instanceof H&&w.args.length===0||w instanceof Fe},is_compound:function(w){return w instanceof H&&w.args.length>0},is_list:function(w){return w instanceof H&&(w.indicator==="[]/0"||w.indicator==="./2")},is_empty_list:function(w){return w instanceof H&&w.indicator==="[]/0"},is_non_empty_list:function(w){return w instanceof H&&w.indicator==="./2"},is_fully_list:function(w){for(;w instanceof H&&w.indicator==="./2";)w=w.args[1];return w instanceof Ie||w instanceof H&&w.indicator==="[]/0"},is_instantiated_list:function(w){for(;w instanceof H&&w.indicator==="./2";)w=w.args[1];return w instanceof H&&w.indicator==="[]/0"},is_unitary_list:function(w){return w instanceof H&&w.indicator==="./2"&&w.args[1]instanceof H&&w.args[1].indicator==="[]/0"},is_character:function(w){return w instanceof H&&(w.id.length===1||w.id.length>0&&w.id.length<=2&&n(w.id,0)>=65536)},is_character_code:function(w){return w instanceof Fe&&!w.is_float&&w.value>=0&&w.value<=1114111},is_byte:function(w){return w instanceof Fe&&!w.is_float&&w.value>=0&&w.value<=255},is_operator:function(w){return w instanceof H&&b.arithmetic.evaluation[w.indicator]},is_directive:function(w){return w instanceof H&&b.directive[w.indicator]!==void 0},is_builtin:function(w){return w instanceof H&&b.predicate[w.indicator]!==void 0},is_error:function(w){return w instanceof H&&w.indicator==="throw/1"},is_predicate_indicator:function(w){return w instanceof H&&w.indicator==="//2"&&w.args[0]instanceof H&&w.args[0].args.length===0&&w.args[1]instanceof Fe&&w.args[1].is_float===!1},is_flag:function(w){return w instanceof H&&w.args.length===0&&b.flag[w.id]!==void 0},is_value_flag:function(w,P){if(!b.type.is_flag(w))return!1;for(var y in b.flag[w.id].allowed)if(!!b.flag[w.id].allowed.hasOwnProperty(y)&&b.flag[w.id].allowed[y].equals(P))return!0;return!1},is_io_mode:function(w){return b.type.is_atom(w)&&["read","write","append"].indexOf(w.id)!==-1},is_stream_option:function(w){return b.type.is_term(w)&&(w.indicator==="alias/1"&&b.type.is_atom(w.args[0])||w.indicator==="reposition/1"&&b.type.is_atom(w.args[0])&&(w.args[0].id==="true"||w.args[0].id==="false")||w.indicator==="type/1"&&b.type.is_atom(w.args[0])&&(w.args[0].id==="text"||w.args[0].id==="binary")||w.indicator==="eof_action/1"&&b.type.is_atom(w.args[0])&&(w.args[0].id==="error"||w.args[0].id==="eof_code"||w.args[0].id==="reset"))},is_stream_position:function(w){return b.type.is_integer(w)&&w.value>=0||b.type.is_atom(w)&&(w.id==="end_of_stream"||w.id==="past_end_of_stream")},is_stream_property:function(w){return b.type.is_term(w)&&(w.indicator==="input/0"||w.indicator==="output/0"||w.indicator==="alias/1"&&(b.type.is_variable(w.args[0])||b.type.is_atom(w.args[0]))||w.indicator==="file_name/1"&&(b.type.is_variable(w.args[0])||b.type.is_atom(w.args[0]))||w.indicator==="position/1"&&(b.type.is_variable(w.args[0])||b.type.is_stream_position(w.args[0]))||w.indicator==="reposition/1"&&(b.type.is_variable(w.args[0])||b.type.is_atom(w.args[0])&&(w.args[0].id==="true"||w.args[0].id==="false"))||w.indicator==="type/1"&&(b.type.is_variable(w.args[0])||b.type.is_atom(w.args[0])&&(w.args[0].id==="text"||w.args[0].id==="binary"))||w.indicator==="mode/1"&&(b.type.is_variable(w.args[0])||b.type.is_atom(w.args[0])&&(w.args[0].id==="read"||w.args[0].id==="write"||w.args[0].id==="append"))||w.indicator==="eof_action/1"&&(b.type.is_variable(w.args[0])||b.type.is_atom(w.args[0])&&(w.args[0].id==="error"||w.args[0].id==="eof_code"||w.args[0].id==="reset"))||w.indicator==="end_of_stream/1"&&(b.type.is_variable(w.args[0])||b.type.is_atom(w.args[0])&&(w.args[0].id==="at"||w.args[0].id==="past"||w.args[0].id==="not")))},is_streamable:function(w){return w.__proto__.stream!==void 0},is_read_option:function(w){return b.type.is_term(w)&&["variables/1","variable_names/1","singletons/1"].indexOf(w.indicator)!==-1},is_write_option:function(w){return b.type.is_term(w)&&(w.indicator==="quoted/1"&&b.type.is_atom(w.args[0])&&(w.args[0].id==="true"||w.args[0].id==="false")||w.indicator==="ignore_ops/1"&&b.type.is_atom(w.args[0])&&(w.args[0].id==="true"||w.args[0].id==="false")||w.indicator==="numbervars/1"&&b.type.is_atom(w.args[0])&&(w.args[0].id==="true"||w.args[0].id==="false"))},is_close_option:function(w){return b.type.is_term(w)&&w.indicator==="force/1"&&b.type.is_atom(w.args[0])&&(w.args[0].id==="true"||w.args[0].id==="false")},is_modifiable_flag:function(w){return b.type.is_flag(w)&&b.flag[w.id].changeable},is_module:function(w){return w instanceof H&&w.indicator==="library/1"&&w.args[0]instanceof H&&w.args[0].args.length===0&&b.module[w.args[0].id]!==void 0}},arithmetic:{evaluation:{"e/0":{type_args:null,type_result:!0,fn:function(w){return Math.E}},"pi/0":{type_args:null,type_result:!0,fn:function(w){return Math.PI}},"tau/0":{type_args:null,type_result:!0,fn:function(w){return 2*Math.PI}},"epsilon/0":{type_args:null,type_result:!0,fn:function(w){return Number.EPSILON}},"+/1":{type_args:null,type_result:null,fn:function(w,P){return w}},"-/1":{type_args:null,type_result:null,fn:function(w,P){return-w}},"\\/1":{type_args:!1,type_result:!1,fn:function(w,P){return~w}},"abs/1":{type_args:null,type_result:null,fn:function(w,P){return Math.abs(w)}},"sign/1":{type_args:null,type_result:null,fn:function(w,P){return Math.sign(w)}},"float_integer_part/1":{type_args:!0,type_result:!1,fn:function(w,P){return parseInt(w)}},"float_fractional_part/1":{type_args:!0,type_result:!0,fn:function(w,P){return w-parseInt(w)}},"float/1":{type_args:null,type_result:!0,fn:function(w,P){return parseFloat(w)}},"floor/1":{type_args:!0,type_result:!1,fn:function(w,P){return Math.floor(w)}},"truncate/1":{type_args:!0,type_result:!1,fn:function(w,P){return parseInt(w)}},"round/1":{type_args:!0,type_result:!1,fn:function(w,P){return Math.round(w)}},"ceiling/1":{type_args:!0,type_result:!1,fn:function(w,P){return Math.ceil(w)}},"sin/1":{type_args:null,type_result:!0,fn:function(w,P){return Math.sin(w)}},"cos/1":{type_args:null,type_result:!0,fn:function(w,P){return Math.cos(w)}},"tan/1":{type_args:null,type_result:!0,fn:function(w,P){return Math.tan(w)}},"asin/1":{type_args:null,type_result:!0,fn:function(w,P){return Math.asin(w)}},"acos/1":{type_args:null,type_result:!0,fn:function(w,P){return Math.acos(w)}},"atan/1":{type_args:null,type_result:!0,fn:function(w,P){return Math.atan(w)}},"atan2/2":{type_args:null,type_result:!0,fn:function(w,P,y){return Math.atan2(w,P)}},"exp/1":{type_args:null,type_result:!0,fn:function(w,P){return Math.exp(w)}},"sqrt/1":{type_args:null,type_result:!0,fn:function(w,P){return Math.sqrt(w)}},"log/1":{type_args:null,type_result:!0,fn:function(w,P){return w>0?Math.log(w):b.error.evaluation("undefined",P.__call_indicator)}},"+/2":{type_args:null,type_result:null,fn:function(w,P,y){return w+P}},"-/2":{type_args:null,type_result:null,fn:function(w,P,y){return w-P}},"*/2":{type_args:null,type_result:null,fn:function(w,P,y){return w*P}},"//2":{type_args:null,type_result:!0,fn:function(w,P,y){return P?w/P:b.error.evaluation("zero_division",y.__call_indicator)}},"///2":{type_args:!1,type_result:!1,fn:function(w,P,y){return P?parseInt(w/P):b.error.evaluation("zero_division",y.__call_indicator)}},"**/2":{type_args:null,type_result:!0,fn:function(w,P,y){return Math.pow(w,P)}},"^/2":{type_args:null,type_result:null,fn:function(w,P,y){return Math.pow(w,P)}},"<</2":{type_args:!1,type_result:!1,fn:function(w,P,y){return w<<P}},">>/2":{type_args:!1,type_result:!1,fn:function(w,P,y){return w>>P}},"/\\/2":{type_args:!1,type_result:!1,fn:function(w,P,y){return w&P}},"\\//2":{type_args:!1,type_result:!1,fn:function(w,P,y){return w|P}},"xor/2":{type_args:!1,type_result:!1,fn:function(w,P,y){return w^P}},"rem/2":{type_args:!1,type_result:!1,fn:function(w,P,y){return P?w%P:b.error.evaluation("zero_division",y.__call_indicator)}},"mod/2":{type_args:!1,type_result:!1,fn:function(w,P,y){return P?w-parseInt(w/P)*P:b.error.evaluation("zero_division",y.__call_indicator)}},"max/2":{type_args:null,type_result:null,fn:function(w,P,y){return Math.max(w,P)}},"min/2":{type_args:null,type_result:null,fn:function(w,P,y){return Math.min(w,P)}}}},directive:{"dynamic/1":function(w,P){var y=P.args[0];if(b.type.is_variable(y))w.throw_error(b.error.instantiation(P.indicator));else if(!b.type.is_compound(y)||y.indicator!=="//2")w.throw_error(b.error.type("predicate_indicator",y,P.indicator));else if(b.type.is_variable(y.args[0])||b.type.is_variable(y.args[1]))w.throw_error(b.error.instantiation(P.indicator));else if(!b.type.is_atom(y.args[0]))w.throw_error(b.error.type("atom",y.args[0],P.indicator));else if(!b.type.is_integer(y.args[1]))w.throw_error(b.error.type("integer",y.args[1],P.indicator));else{var F=P.args[0].args[0].id+"/"+P.args[0].args[1].value;w.session.public_predicates[F]=!0,w.session.rules[F]||(w.session.rules[F]=[])}},"multifile/1":function(w,P){var y=P.args[0];b.type.is_variable(y)?w.throw_error(b.error.instantiation(P.indicator)):!b.type.is_compound(y)||y.indicator!=="//2"?w.throw_error(b.error.type("predicate_indicator",y,P.indicator)):b.type.is_variable(y.args[0])||b.type.is_variable(y.args[1])?w.throw_error(b.error.instantiation(P.indicator)):b.type.is_atom(y.args[0])?b.type.is_integer(y.args[1])?w.session.multifile_predicates[P.args[0].args[0].id+"/"+P.args[0].args[1].value]=!0:w.throw_error(b.error.type("integer",y.args[1],P.indicator)):w.throw_error(b.error.type("atom",y.args[0],P.indicator))},"set_prolog_flag/2":function(w,P){var y=P.args[0],F=P.args[1];b.type.is_variable(y)||b.type.is_variable(F)?w.throw_error(b.error.instantiation(P.indicator)):b.type.is_atom(y)?b.type.is_flag(y)?b.type.is_value_flag(y,F)?b.type.is_modifiable_flag(y)?w.session.flag[y.id]=F:w.throw_error(b.error.permission("modify","flag",y)):w.throw_error(b.error.domain("flag_value",new H("+",[y,F]),P.indicator)):w.throw_error(b.error.domain("prolog_flag",y,P.indicator)):w.throw_error(b.error.type("atom",y,P.indicator))},"use_module/1":function(w,P){var y=P.args[0];if(b.type.is_variable(y))w.throw_error(b.error.instantiation(P.indicator));else if(!b.type.is_term(y))w.throw_error(b.error.type("term",y,P.indicator));else if(b.type.is_module(y)){var F=y.args[0].id;e(w.session.modules,F)===-1&&w.session.modules.push(F)}},"char_conversion/2":function(w,P){var y=P.args[0],F=P.args[1];b.type.is_variable(y)||b.type.is_variable(F)?w.throw_error(b.error.instantiation(P.indicator)):b.type.is_character(y)?b.type.is_character(F)?y.id===F.id?delete w.session.__char_conversion[y.id]:w.session.__char_conversion[y.id]=F.id:w.throw_error(b.error.type("character",F,P.indicator)):w.throw_error(b.error.type("character",y,P.indicator))},"op/3":function(w,P){var y=P.args[0],F=P.args[1],z=P.args[2];if(b.type.is_variable(y)||b.type.is_variable(F)||b.type.is_variable(z))w.throw_error(b.error.instantiation(P.indicator));else if(!b.type.is_integer(y))w.throw_error(b.error.type("integer",y,P.indicator));else if(!b.type.is_atom(F))w.throw_error(b.error.type("atom",F,P.indicator));else if(!b.type.is_atom(z))w.throw_error(b.error.type("atom",z,P.indicator));else if(y.value<0||y.value>1200)w.throw_error(b.error.domain("operator_priority",y,P.indicator));else if(z.id===",")w.throw_error(b.error.permission("modify","operator",z,P.indicator));else if(z.id==="|"&&(y.value<1001||F.id.length!==3))w.throw_error(b.error.permission("modify","operator",z,P.indicator));else if(["fy","fx","yf","xf","xfx","yfx","xfy"].indexOf(F.id)===-1)w.throw_error(b.error.domain("operator_specifier",F,P.indicator));else{var X={prefix:null,infix:null,postfix:null};for(var Z in w.session.__operators)if(!!w.session.__operators.hasOwnProperty(Z)){var ie=w.session.__operators[Z][z.id];ie&&(e(ie,"fx")!==-1&&(X.prefix={priority:Z,type:"fx"}),e(ie,"fy")!==-1&&(X.prefix={priority:Z,type:"fy"}),e(ie,"xf")!==-1&&(X.postfix={priority:Z,type:"xf"}),e(ie,"yf")!==-1&&(X.postfix={priority:Z,type:"yf"}),e(ie,"xfx")!==-1&&(X.infix={priority:Z,type:"xfx"}),e(ie,"xfy")!==-1&&(X.infix={priority:Z,type:"xfy"}),e(ie,"yfx")!==-1&&(X.infix={priority:Z,type:"yfx"}))}var Pe;switch(F.id){case"fy":case"fx":Pe="prefix";break;case"yf":case"xf":Pe="postfix";break;default:Pe="infix";break}if(((X.prefix&&Pe==="prefix"||X.postfix&&Pe==="postfix"||X.infix&&Pe==="infix")&&X[Pe].type!==F.id||X.infix&&Pe==="postfix"||X.postfix&&Pe==="infix")&&y.value!==0)w.throw_error(b.error.permission("create","operator",z,P.indicator));else return X[Pe]&&(Ee(w.session.__operators[X[Pe].priority][z.id],F.id),w.session.__operators[X[Pe].priority][z.id].length===0&&delete w.session.__operators[X[Pe].priority][z.id]),y.value>0&&(w.session.__operators[y.value]||(w.session.__operators[y.value.toString()]={}),w.session.__operators[y.value][z.id]||(w.session.__operators[y.value][z.id]=[]),w.session.__operators[y.value][z.id].push(F.id)),!0}}},predicate:{"op/3":function(w,P,y){b.directive["op/3"](w,y)&&w.success(P)},"current_op/3":function(w,P,y){var F=y.args[0],z=y.args[1],X=y.args[2],Z=[];for(var ie in w.session.__operators)for(var Pe in w.session.__operators[ie])for(var Ne=0;Ne<w.session.__operators[ie][Pe].length;Ne++)Z.push(new xe(P.goal.replace(new H(",",[new H("=",[new Fe(ie,!1),F]),new H(",",[new H("=",[new H(w.session.__operators[ie][Pe][Ne],[]),z]),new H("=",[new H(Pe,[]),X])])])),P.substitution,P));w.prepend(Z)},";/2":function(w,P,y){if(b.type.is_term(y.args[0])&&y.args[0].indicator==="->/2"){var F=w.points,z=w.session.format_success,X=w.session.format_error;w.session.format_success=function(Ne){return Ne.substitution},w.session.format_error=function(Ne){return Ne.goal},w.points=[new xe(y.args[0].args[0],P.substitution,P)];var Z=function(Ne){w.points=F,w.session.format_success=z,w.session.format_error=X,Ne===!1?w.prepend([new xe(P.goal.replace(y.args[1]),P.substitution,P)]):b.type.is_error(Ne)?w.throw_error(Ne.args[0]):Ne===null?(w.prepend([P]),w.__calls.shift()(null)):w.prepend([new xe(P.goal.replace(y.args[0].args[1]).apply(Ne),P.substitution.apply(Ne),P)])};w.__calls.unshift(Z)}else{var ie=new xe(P.goal.replace(y.args[0]),P.substitution,P),Pe=new xe(P.goal.replace(y.args[1]),P.substitution,P);w.prepend([ie,Pe])}},"!/0":function(w,P,y){var F,z,X=[];for(F=P,z=null;F.parent!==null&&F.parent.goal.search(y);)if(z=F,F=F.parent,F.goal!==null){var Z=F.goal.select();if(Z&&Z.id==="call"&&Z.search(y)){F=z;break}}for(var ie=w.points.length-1;ie>=0;ie--){for(var Pe=w.points[ie],Ne=Pe.parent;Ne!==null&&Ne!==F.parent;)Ne=Ne.parent;Ne===null&&Ne!==F.parent&&X.push(Pe)}w.points=X.reverse(),w.success(P)},"\\+/1":function(w,P,y){var F=y.args[0];b.type.is_variable(F)?w.throw_error(b.error.instantiation(w.level)):b.type.is_callable(F)?w.prepend([new xe(P.goal.replace(new H(",",[new H(",",[new H("call",[F]),new H("!",[])]),new H("fail",[])])),P.substitution,P),new xe(P.goal.replace(null),P.substitution,P)]):w.throw_error(b.error.type("callable",F,w.level))},"->/2":function(w,P,y){var F=P.goal.replace(new H(",",[y.args[0],new H(",",[new H("!"),y.args[1]])]));w.prepend([new xe(F,P.substitution,P)])},"fail/0":function(w,P,y){},"false/0":function(w,P,y){},"true/0":function(w,P,y){w.success(P)},"call/1":ne(1),"call/2":ne(2),"call/3":ne(3),"call/4":ne(4),"call/5":ne(5),"call/6":ne(6),"call/7":ne(7),"call/8":ne(8),"once/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H(",",[new H("call",[F]),new H("!",[])])),P.substitution,P)])},"forall/2":function(w,P,y){var F=y.args[0],z=y.args[1];w.prepend([new xe(P.goal.replace(new H("\\+",[new H(",",[new H("call",[F]),new H("\\+",[new H("call",[z])])])])),P.substitution,P)])},"repeat/0":function(w,P,y){w.prepend([new xe(P.goal.replace(null),P.substitution,P),P])},"throw/1":function(w,P,y){b.type.is_variable(y.args[0])?w.throw_error(b.error.instantiation(w.level)):w.throw_error(y.args[0])},"catch/3":function(w,P,y){var F=w.points;w.points=[],w.prepend([new xe(y.args[0],P.substitution,P)]);var z=w.session.format_success,X=w.session.format_error;w.session.format_success=function(ie){return ie.substitution},w.session.format_error=function(ie){return ie.goal};var Z=function(ie){var Pe=w.points;if(w.points=F,w.session.format_success=z,w.session.format_error=X,b.type.is_error(ie)){for(var Ne=[],ot=w.points.length-1;ot>=0;ot--){for(var $t=w.points[ot],dt=$t.parent;dt!==null&&dt!==P.parent;)dt=dt.parent;dt===null&&dt!==P.parent&&Ne.push($t)}w.points=Ne;var Gt=w.get_flag("occurs_check").indicator==="true/0",$t=new xe,bt=b.unify(ie.args[0],y.args[1],Gt);bt!==null?($t.substitution=P.substitution.apply(bt),$t.goal=P.goal.replace(y.args[2]).apply(bt),$t.parent=P,w.prepend([$t])):w.throw_error(ie.args[0])}else if(ie!==!1){for(var an=ie===null?[]:[new xe(P.goal.apply(ie).replace(null),P.substitution.apply(ie),P)],Qr=[],ot=Pe.length-1;ot>=0;ot--){Qr.push(Pe[ot]);var mr=Pe[ot].goal!==null?Pe[ot].goal.select():null;if(b.type.is_term(mr)&&mr.indicator==="!/0")break}var br=o(Qr,function(Wr){return Wr.goal===null&&(Wr.goal=new H("true",[])),Wr=new xe(P.goal.replace(new H("catch",[Wr.goal,y.args[1],y.args[2]])),P.substitution.apply(Wr.substitution),Wr.parent),Wr.exclude=y.args[0].variables(),Wr}).reverse();w.prepend(br),w.prepend(an),ie===null&&(this.current_limit=0,w.__calls.shift()(null))}};w.__calls.unshift(Z)},"=/2":function(w,P,y){var F=w.get_flag("occurs_check").indicator==="true/0",z=new xe,X=b.unify(y.args[0],y.args[1],F);X!==null&&(z.goal=P.goal.apply(X).replace(null),z.substitution=P.substitution.apply(X),z.parent=P,w.prepend([z]))},"unify_with_occurs_check/2":function(w,P,y){var F=new xe,z=b.unify(y.args[0],y.args[1],!0);z!==null&&(F.goal=P.goal.apply(z).replace(null),F.substitution=P.substitution.apply(z),F.parent=P,w.prepend([F]))},"\\=/2":function(w,P,y){var F=w.get_flag("occurs_check").indicator==="true/0",z=b.unify(y.args[0],y.args[1],F);z===null&&w.success(P)},"subsumes_term/2":function(w,P,y){var F=w.get_flag("occurs_check").indicator==="true/0",z=b.unify(y.args[1],y.args[0],F);z!==null&&y.args[1].apply(z).equals(y.args[1])&&w.success(P)},"findall/3":function(w,P,y){var F=y.args[0],z=y.args[1],X=y.args[2];if(b.type.is_variable(z))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_callable(z))w.throw_error(b.error.type("callable",z,y.indicator));else if(!b.type.is_variable(X)&&!b.type.is_list(X))w.throw_error(b.error.type("list",X,y.indicator));else{var Z=w.next_free_variable(),ie=new H(",",[z,new H("=",[Z,F])]),Pe=w.points,Ne=w.session.limit,ot=w.session.format_success;w.session.format_success=function($t){return $t.substitution},w.add_goal(ie,!0,P);var dt=[],Gt=function($t){if($t!==!1&&$t!==null&&!b.type.is_error($t))w.__calls.unshift(Gt),dt.push($t.links[Z.id]),w.session.limit=w.current_limit;else if(w.points=Pe,w.session.limit=Ne,w.session.format_success=ot,b.type.is_error($t))w.throw_error($t.args[0]);else if(w.current_limit>0){for(var bt=new H("[]"),an=dt.length-1;an>=0;an--)bt=new H(".",[dt[an],bt]);w.prepend([new xe(P.goal.replace(new H("=",[X,bt])),P.substitution,P)])}};w.__calls.unshift(Gt)}},"bagof/3":function(w,P,y){var F,z=y.args[0],X=y.args[1],Z=y.args[2];if(b.type.is_variable(X))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_callable(X))w.throw_error(b.error.type("callable",X,y.indicator));else if(!b.type.is_variable(Z)&&!b.type.is_list(Z))w.throw_error(b.error.type("list",Z,y.indicator));else{var ie=w.next_free_variable(),Pe;X.indicator==="^/2"?(Pe=X.args[0].variables(),X=X.args[1]):Pe=[],Pe=Pe.concat(z.variables());for(var Ne=X.variables().filter(function(br){return e(Pe,br)===-1}),ot=new H("[]"),dt=Ne.length-1;dt>=0;dt--)ot=new H(".",[new Ie(Ne[dt]),ot]);var Gt=new H(",",[X,new H("=",[ie,new H(",",[ot,z])])]),$t=w.points,bt=w.session.limit,an=w.session.format_success;w.session.format_success=function(br){return br.substitution},w.add_goal(Gt,!0,P);var Qr=[],mr=function(br){if(br!==!1&&br!==null&&!b.type.is_error(br)){w.__calls.unshift(mr);var Wr=!1,Kn=br.links[ie.id].args[0],Ns=br.links[ie.id].args[1];for(var Ti in Qr)if(!!Qr.hasOwnProperty(Ti)){var ps=Qr[Ti];if(ps.variables.equals(Kn)){ps.answers.push(Ns),Wr=!0;break}}Wr||Qr.push({variables:Kn,answers:[Ns]}),w.session.limit=w.current_limit}else if(w.points=$t,w.session.limit=bt,w.session.format_success=an,b.type.is_error(br))w.throw_error(br.args[0]);else if(w.current_limit>0){for(var io=[],Pi=0;Pi<Qr.length;Pi++){br=Qr[Pi].answers;for(var Ls=new H("[]"),so=br.length-1;so>=0;so--)Ls=new H(".",[br[so],Ls]);io.push(new xe(P.goal.replace(new H(",",[new H("=",[ot,Qr[Pi].variables]),new H("=",[Z,Ls])])),P.substitution,P))}w.prepend(io)}};w.__calls.unshift(mr)}},"setof/3":function(w,P,y){var F,z=y.args[0],X=y.args[1],Z=y.args[2];if(b.type.is_variable(X))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_callable(X))w.throw_error(b.error.type("callable",X,y.indicator));else if(!b.type.is_variable(Z)&&!b.type.is_list(Z))w.throw_error(b.error.type("list",Z,y.indicator));else{var ie=w.next_free_variable(),Pe;X.indicator==="^/2"?(Pe=X.args[0].variables(),X=X.args[1]):Pe=[],Pe=Pe.concat(z.variables());for(var Ne=X.variables().filter(function(br){return e(Pe,br)===-1}),ot=new H("[]"),dt=Ne.length-1;dt>=0;dt--)ot=new H(".",[new Ie(Ne[dt]),ot]);var Gt=new H(",",[X,new H("=",[ie,new H(",",[ot,z])])]),$t=w.points,bt=w.session.limit,an=w.session.format_success;w.session.format_success=function(br){return br.substitution},w.add_goal(Gt,!0,P);var Qr=[],mr=function(br){if(br!==!1&&br!==null&&!b.type.is_error(br)){w.__calls.unshift(mr);var Wr=!1,Kn=br.links[ie.id].args[0],Ns=br.links[ie.id].args[1];for(var Ti in Qr)if(!!Qr.hasOwnProperty(Ti)){var ps=Qr[Ti];if(ps.variables.equals(Kn)){ps.answers.push(Ns),Wr=!0;break}}Wr||Qr.push({variables:Kn,answers:[Ns]}),w.session.limit=w.current_limit}else if(w.points=$t,w.session.limit=bt,w.session.format_success=an,b.type.is_error(br))w.throw_error(br.args[0]);else if(w.current_limit>0){for(var io=[],Pi=0;Pi<Qr.length;Pi++){br=Qr[Pi].answers.sort(b.compare);for(var Ls=new H("[]"),so=br.length-1;so>=0;so--)Ls=new H(".",[br[so],Ls]);io.push(new xe(P.goal.replace(new H(",",[new H("=",[ot,Qr[Pi].variables]),new H("=",[Z,Ls])])),P.substitution,P))}w.prepend(io)}};w.__calls.unshift(mr)}},"functor/3":function(w,P,y){var F,z=y.args[0],X=y.args[1],Z=y.args[2];if(b.type.is_variable(z)&&(b.type.is_variable(X)||b.type.is_variable(Z)))w.throw_error(b.error.instantiation("functor/3"));else if(!b.type.is_variable(Z)&&!b.type.is_integer(Z))w.throw_error(b.error.type("integer",y.args[2],"functor/3"));else if(!b.type.is_variable(X)&&!b.type.is_atomic(X))w.throw_error(b.error.type("atomic",y.args[1],"functor/3"));else if(b.type.is_integer(X)&&b.type.is_integer(Z)&&Z.value!==0)w.throw_error(b.error.type("atom",y.args[1],"functor/3"));else if(b.type.is_variable(z)){if(y.args[2].value>=0){for(var ie=[],Pe=0;Pe<Z.value;Pe++)ie.push(w.next_free_variable());var Ne=b.type.is_integer(X)?X:new H(X.id,ie);w.prepend([new xe(P.goal.replace(new H("=",[z,Ne])),P.substitution,P)])}}else{var ot=b.type.is_integer(z)?z:new H(z.id,[]),dt=b.type.is_integer(z)?new Fe(0,!1):new Fe(z.args.length,!1),Gt=new H(",",[new H("=",[ot,X]),new H("=",[dt,Z])]);w.prepend([new xe(P.goal.replace(Gt),P.substitution,P)])}},"arg/3":function(w,P,y){if(b.type.is_variable(y.args[0])||b.type.is_variable(y.args[1]))w.throw_error(b.error.instantiation(y.indicator));else if(y.args[0].value<0)w.throw_error(b.error.domain("not_less_than_zero",y.args[0],y.indicator));else if(!b.type.is_compound(y.args[1]))w.throw_error(b.error.type("compound",y.args[1],y.indicator));else{var F=y.args[0].value;if(F>0&&F<=y.args[1].args.length){var z=new H("=",[y.args[1].args[F-1],y.args[2]]);w.prepend([new xe(P.goal.replace(z),P.substitution,P)])}}},"=../2":function(w,P,y){var F;if(b.type.is_variable(y.args[0])&&(b.type.is_variable(y.args[1])||b.type.is_non_empty_list(y.args[1])&&b.type.is_variable(y.args[1].args[0])))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_fully_list(y.args[1]))w.throw_error(b.error.type("list",y.args[1],y.indicator));else if(b.type.is_variable(y.args[0])){if(!b.type.is_variable(y.args[1])){var X=[];for(F=y.args[1].args[1];F.indicator==="./2";)X.push(F.args[0]),F=F.args[1];b.type.is_variable(y.args[0])&&b.type.is_variable(F)?w.throw_error(b.error.instantiation(y.indicator)):X.length===0&&b.type.is_compound(y.args[1].args[0])?w.throw_error(b.error.type("atomic",y.args[1].args[0],y.indicator)):X.length>0&&(b.type.is_compound(y.args[1].args[0])||b.type.is_number(y.args[1].args[0]))?w.throw_error(b.error.type("atom",y.args[1].args[0],y.indicator)):X.length===0?w.prepend([new xe(P.goal.replace(new H("=",[y.args[1].args[0],y.args[0]],P)),P.substitution,P)]):w.prepend([new xe(P.goal.replace(new H("=",[new H(y.args[1].args[0].id,X),y.args[0]])),P.substitution,P)])}}else{if(b.type.is_atomic(y.args[0]))F=new H(".",[y.args[0],new H("[]")]);else{F=new H("[]");for(var z=y.args[0].args.length-1;z>=0;z--)F=new H(".",[y.args[0].args[z],F]);F=new H(".",[new H(y.args[0].id),F])}w.prepend([new xe(P.goal.replace(new H("=",[F,y.args[1]])),P.substitution,P)])}},"copy_term/2":function(w,P,y){var F=y.args[0].rename(w);w.prepend([new xe(P.goal.replace(new H("=",[F,y.args[1]])),P.substitution,P.parent)])},"term_variables/2":function(w,P,y){var F=y.args[0],z=y.args[1];if(!b.type.is_fully_list(z))w.throw_error(b.error.type("list",z,y.indicator));else{var X=g(o(Se(F.variables()),function(Z){return new Ie(Z)}));w.prepend([new xe(P.goal.replace(new H("=",[z,X])),P.substitution,P)])}},"clause/2":function(w,P,y){if(b.type.is_variable(y.args[0]))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_callable(y.args[0]))w.throw_error(b.error.type("callable",y.args[0],y.indicator));else if(!b.type.is_variable(y.args[1])&&!b.type.is_callable(y.args[1]))w.throw_error(b.error.type("callable",y.args[1],y.indicator));else if(w.session.rules[y.args[0].indicator]!==void 0)if(w.is_public_predicate(y.args[0].indicator)){var F=[];for(var z in w.session.rules[y.args[0].indicator])if(!!w.session.rules[y.args[0].indicator].hasOwnProperty(z)){var X=w.session.rules[y.args[0].indicator][z];w.session.renamed_variables={},X=X.rename(w),X.body===null&&(X.body=new H("true"));var Z=new H(",",[new H("=",[X.head,y.args[0]]),new H("=",[X.body,y.args[1]])]);F.push(new xe(P.goal.replace(Z),P.substitution,P))}w.prepend(F)}else w.throw_error(b.error.permission("access","private_procedure",y.args[0].indicator,y.indicator))},"current_predicate/1":function(w,P,y){var F=y.args[0];if(!b.type.is_variable(F)&&(!b.type.is_compound(F)||F.indicator!=="//2"))w.throw_error(b.error.type("predicate_indicator",F,y.indicator));else if(!b.type.is_variable(F)&&!b.type.is_variable(F.args[0])&&!b.type.is_atom(F.args[0]))w.throw_error(b.error.type("atom",F.args[0],y.indicator));else if(!b.type.is_variable(F)&&!b.type.is_variable(F.args[1])&&!b.type.is_integer(F.args[1]))w.throw_error(b.error.type("integer",F.args[1],y.indicator));else{var z=[];for(var X in w.session.rules)if(!!w.session.rules.hasOwnProperty(X)){var Z=X.lastIndexOf("/"),ie=X.substr(0,Z),Pe=parseInt(X.substr(Z+1,X.length-(Z+1))),Ne=new H("/",[new H(ie),new Fe(Pe,!1)]),ot=new H("=",[Ne,F]);z.push(new xe(P.goal.replace(ot),P.substitution,P))}w.prepend(z)}},"asserta/1":function(w,P,y){if(b.type.is_variable(y.args[0]))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_callable(y.args[0]))w.throw_error(b.error.type("callable",y.args[0],y.indicator));else{var F,z;y.args[0].indicator===":-/2"?(F=y.args[0].args[0],z=we(y.args[0].args[1])):(F=y.args[0],z=null),b.type.is_callable(F)?z!==null&&!b.type.is_callable(z)?w.throw_error(b.error.type("callable",z,y.indicator)):w.is_public_predicate(F.indicator)?(w.session.rules[F.indicator]===void 0&&(w.session.rules[F.indicator]=[]),w.session.public_predicates[F.indicator]=!0,w.session.rules[F.indicator]=[new He(F,z,!0)].concat(w.session.rules[F.indicator]),w.success(P)):w.throw_error(b.error.permission("modify","static_procedure",F.indicator,y.indicator)):w.throw_error(b.error.type("callable",F,y.indicator))}},"assertz/1":function(w,P,y){if(b.type.is_variable(y.args[0]))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_callable(y.args[0]))w.throw_error(b.error.type("callable",y.args[0],y.indicator));else{var F,z;y.args[0].indicator===":-/2"?(F=y.args[0].args[0],z=we(y.args[0].args[1])):(F=y.args[0],z=null),b.type.is_callable(F)?z!==null&&!b.type.is_callable(z)?w.throw_error(b.error.type("callable",z,y.indicator)):w.is_public_predicate(F.indicator)?(w.session.rules[F.indicator]===void 0&&(w.session.rules[F.indicator]=[]),w.session.public_predicates[F.indicator]=!0,w.session.rules[F.indicator].push(new He(F,z,!0)),w.success(P)):w.throw_error(b.error.permission("modify","static_procedure",F.indicator,y.indicator)):w.throw_error(b.error.type("callable",F,y.indicator))}},"retract/1":function(w,P,y){if(b.type.is_variable(y.args[0]))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_callable(y.args[0]))w.throw_error(b.error.type("callable",y.args[0],y.indicator));else{var F,z;if(y.args[0].indicator===":-/2"?(F=y.args[0].args[0],z=y.args[0].args[1]):(F=y.args[0],z=new H("true")),typeof P.retract>"u")if(w.is_public_predicate(F.indicator)){if(w.session.rules[F.indicator]!==void 0){for(var X=[],Z=0;Z<w.session.rules[F.indicator].length;Z++){w.session.renamed_variables={};var ie=w.session.rules[F.indicator][Z],Pe=ie.rename(w);Pe.body===null&&(Pe.body=new H("true",[]));var Ne=w.get_flag("occurs_check").indicator==="true/0",ot=b.unify(new H(",",[F,z]),new H(",",[Pe.head,Pe.body]),Ne);if(ot!==null){var dt=new xe(P.goal.replace(new H(",",[new H("retract",[new H(":-",[F,z])]),new H(",",[new H("=",[F,Pe.head]),new H("=",[z,Pe.body])])])),P.substitution,P);dt.retract=ie,X.push(dt)}}w.prepend(X)}}else w.throw_error(b.error.permission("modify","static_procedure",F.indicator,y.indicator));else le(w,P,F.indicator,P.retract)}},"retractall/1":function(w,P,y){var F=y.args[0];b.type.is_variable(F)?w.throw_error(b.error.instantiation(y.indicator)):b.type.is_callable(F)?w.prepend([new xe(P.goal.replace(new H(",",[new H("retract",[new b.type.Term(":-",[F,new Ie("_")])]),new H("fail",[])])),P.substitution,P),new xe(P.goal.replace(null),P.substitution,P)]):w.throw_error(b.error.type("callable",F,y.indicator))},"abolish/1":function(w,P,y){if(b.type.is_variable(y.args[0])||b.type.is_term(y.args[0])&&y.args[0].indicator==="//2"&&(b.type.is_variable(y.args[0].args[0])||b.type.is_variable(y.args[0].args[1])))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_term(y.args[0])||y.args[0].indicator!=="//2")w.throw_error(b.error.type("predicate_indicator",y.args[0],y.indicator));else if(!b.type.is_atom(y.args[0].args[0]))w.throw_error(b.error.type("atom",y.args[0].args[0],y.indicator));else if(!b.type.is_integer(y.args[0].args[1]))w.throw_error(b.error.type("integer",y.args[0].args[1],y.indicator));else if(y.args[0].args[1].value<0)w.throw_error(b.error.domain("not_less_than_zero",y.args[0].args[1],y.indicator));else if(b.type.is_number(w.get_flag("max_arity"))&&y.args[0].args[1].value>w.get_flag("max_arity").value)w.throw_error(b.error.representation("max_arity",y.indicator));else{var F=y.args[0].args[0].id+"/"+y.args[0].args[1].value;w.is_public_predicate(F)?(delete w.session.rules[F],w.success(P)):w.throw_error(b.error.permission("modify","static_procedure",F,y.indicator))}},"atom_length/2":function(w,P,y){if(b.type.is_variable(y.args[0]))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_atom(y.args[0]))w.throw_error(b.error.type("atom",y.args[0],y.indicator));else if(!b.type.is_variable(y.args[1])&&!b.type.is_integer(y.args[1]))w.throw_error(b.error.type("integer",y.args[1],y.indicator));else if(b.type.is_integer(y.args[1])&&y.args[1].value<0)w.throw_error(b.error.domain("not_less_than_zero",y.args[1],y.indicator));else{var F=new Fe(y.args[0].id.length,!1);w.prepend([new xe(P.goal.replace(new H("=",[F,y.args[1]])),P.substitution,P)])}},"atom_concat/3":function(w,P,y){var F,z,X=y.args[0],Z=y.args[1],ie=y.args[2];if(b.type.is_variable(ie)&&(b.type.is_variable(X)||b.type.is_variable(Z)))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_variable(X)&&!b.type.is_atom(X))w.throw_error(b.error.type("atom",X,y.indicator));else if(!b.type.is_variable(Z)&&!b.type.is_atom(Z))w.throw_error(b.error.type("atom",Z,y.indicator));else if(!b.type.is_variable(ie)&&!b.type.is_atom(ie))w.throw_error(b.error.type("atom",ie,y.indicator));else{var Pe=b.type.is_variable(X),Ne=b.type.is_variable(Z);if(!Pe&&!Ne)z=new H("=",[ie,new H(X.id+Z.id)]),w.prepend([new xe(P.goal.replace(z),P.substitution,P)]);else if(Pe&&!Ne)F=ie.id.substr(0,ie.id.length-Z.id.length),F+Z.id===ie.id&&(z=new H("=",[X,new H(F)]),w.prepend([new xe(P.goal.replace(z),P.substitution,P)]));else if(Ne&&!Pe)F=ie.id.substr(X.id.length),X.id+F===ie.id&&(z=new H("=",[Z,new H(F)]),w.prepend([new xe(P.goal.replace(z),P.substitution,P)]));else{for(var ot=[],dt=0;dt<=ie.id.length;dt++){var Gt=new H(ie.id.substr(0,dt)),$t=new H(ie.id.substr(dt));z=new H(",",[new H("=",[Gt,X]),new H("=",[$t,Z])]),ot.push(new xe(P.goal.replace(z),P.substitution,P))}w.prepend(ot)}}},"sub_atom/5":function(w,P,y){var F,z=y.args[0],X=y.args[1],Z=y.args[2],ie=y.args[3],Pe=y.args[4];if(b.type.is_variable(z))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_variable(X)&&!b.type.is_integer(X))w.throw_error(b.error.type("integer",X,y.indicator));else if(!b.type.is_variable(Z)&&!b.type.is_integer(Z))w.throw_error(b.error.type("integer",Z,y.indicator));else if(!b.type.is_variable(ie)&&!b.type.is_integer(ie))w.throw_error(b.error.type("integer",ie,y.indicator));else if(b.type.is_integer(X)&&X.value<0)w.throw_error(b.error.domain("not_less_than_zero",X,y.indicator));else if(b.type.is_integer(Z)&&Z.value<0)w.throw_error(b.error.domain("not_less_than_zero",Z,y.indicator));else if(b.type.is_integer(ie)&&ie.value<0)w.throw_error(b.error.domain("not_less_than_zero",ie,y.indicator));else{var Ne=[],ot=[],dt=[];if(b.type.is_variable(X))for(F=0;F<=z.id.length;F++)Ne.push(F);else Ne.push(X.value);if(b.type.is_variable(Z))for(F=0;F<=z.id.length;F++)ot.push(F);else ot.push(Z.value);if(b.type.is_variable(ie))for(F=0;F<=z.id.length;F++)dt.push(F);else dt.push(ie.value);var Gt=[];for(var $t in Ne)if(!!Ne.hasOwnProperty($t)){F=Ne[$t];for(var bt in ot)if(!!ot.hasOwnProperty(bt)){var an=ot[bt],Qr=z.id.length-F-an;if(e(dt,Qr)!==-1&&F+an+Qr===z.id.length){var mr=z.id.substr(F,an);if(z.id===z.id.substr(0,F)+mr+z.id.substr(F+an,Qr)){var br=new H("=",[new H(mr),Pe]),Wr=new H("=",[X,new Fe(F)]),Kn=new H("=",[Z,new Fe(an)]),Ns=new H("=",[ie,new Fe(Qr)]),Ti=new H(",",[new H(",",[new H(",",[Wr,Kn]),Ns]),br]);Gt.push(new xe(P.goal.replace(Ti),P.substitution,P))}}}}w.prepend(Gt)}},"atom_chars/2":function(w,P,y){var F=y.args[0],z=y.args[1];if(b.type.is_variable(F)&&b.type.is_variable(z))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_variable(F)&&!b.type.is_atom(F))w.throw_error(b.error.type("atom",F,y.indicator));else if(b.type.is_variable(F)){for(var ie=z,Pe=b.type.is_variable(F),Ne="";ie.indicator==="./2";){if(b.type.is_character(ie.args[0]))Ne+=ie.args[0].id;else if(b.type.is_variable(ie.args[0])&&Pe){w.throw_error(b.error.instantiation(y.indicator));return}else if(!b.type.is_variable(ie.args[0])){w.throw_error(b.error.type("character",ie.args[0],y.indicator));return}ie=ie.args[1]}b.type.is_variable(ie)&&Pe?w.throw_error(b.error.instantiation(y.indicator)):!b.type.is_empty_list(ie)&&!b.type.is_variable(ie)?w.throw_error(b.error.type("list",z,y.indicator)):w.prepend([new xe(P.goal.replace(new H("=",[new H(Ne),F])),P.substitution,P)])}else{for(var X=new H("[]"),Z=F.id.length-1;Z>=0;Z--)X=new H(".",[new H(F.id.charAt(Z)),X]);w.prepend([new xe(P.goal.replace(new H("=",[z,X])),P.substitution,P)])}},"atom_codes/2":function(w,P,y){var F=y.args[0],z=y.args[1];if(b.type.is_variable(F)&&b.type.is_variable(z))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_variable(F)&&!b.type.is_atom(F))w.throw_error(b.error.type("atom",F,y.indicator));else if(b.type.is_variable(F)){for(var ie=z,Pe=b.type.is_variable(F),Ne="";ie.indicator==="./2";){if(b.type.is_character_code(ie.args[0]))Ne+=u(ie.args[0].value);else if(b.type.is_variable(ie.args[0])&&Pe){w.throw_error(b.error.instantiation(y.indicator));return}else if(!b.type.is_variable(ie.args[0])){w.throw_error(b.error.representation("character_code",y.indicator));return}ie=ie.args[1]}b.type.is_variable(ie)&&Pe?w.throw_error(b.error.instantiation(y.indicator)):!b.type.is_empty_list(ie)&&!b.type.is_variable(ie)?w.throw_error(b.error.type("list",z,y.indicator)):w.prepend([new xe(P.goal.replace(new H("=",[new H(Ne),F])),P.substitution,P)])}else{for(var X=new H("[]"),Z=F.id.length-1;Z>=0;Z--)X=new H(".",[new Fe(n(F.id,Z),!1),X]);w.prepend([new xe(P.goal.replace(new H("=",[z,X])),P.substitution,P)])}},"char_code/2":function(w,P,y){var F=y.args[0],z=y.args[1];if(b.type.is_variable(F)&&b.type.is_variable(z))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_variable(F)&&!b.type.is_character(F))w.throw_error(b.error.type("character",F,y.indicator));else if(!b.type.is_variable(z)&&!b.type.is_integer(z))w.throw_error(b.error.type("integer",z,y.indicator));else if(!b.type.is_variable(z)&&!b.type.is_character_code(z))w.throw_error(b.error.representation("character_code",y.indicator));else if(b.type.is_variable(z)){var X=new Fe(n(F.id,0),!1);w.prepend([new xe(P.goal.replace(new H("=",[X,z])),P.substitution,P)])}else{var Z=new H(u(z.value));w.prepend([new xe(P.goal.replace(new H("=",[Z,F])),P.substitution,P)])}},"number_chars/2":function(w,P,y){var F,z=y.args[0],X=y.args[1];if(b.type.is_variable(z)&&b.type.is_variable(X))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_variable(z)&&!b.type.is_number(z))w.throw_error(b.error.type("number",z,y.indicator));else if(!b.type.is_variable(X)&&!b.type.is_list(X))w.throw_error(b.error.type("list",X,y.indicator));else{var Z=b.type.is_variable(z);if(!b.type.is_variable(X)){var ie=X,Pe=!0;for(F="";ie.indicator==="./2";){if(b.type.is_character(ie.args[0]))F+=ie.args[0].id;else if(b.type.is_variable(ie.args[0]))Pe=!1;else if(!b.type.is_variable(ie.args[0])){w.throw_error(b.error.type("character",ie.args[0],y.indicator));return}ie=ie.args[1]}if(Pe=Pe&&b.type.is_empty_list(ie),!b.type.is_empty_list(ie)&&!b.type.is_variable(ie)){w.throw_error(b.error.type("list",X,y.indicator));return}if(!Pe&&Z){w.throw_error(b.error.instantiation(y.indicator));return}else if(Pe)if(b.type.is_variable(ie)&&Z){w.throw_error(b.error.instantiation(y.indicator));return}else{var Ne=w.parse(F),ot=Ne.value;!b.type.is_number(ot)||Ne.tokens[Ne.tokens.length-1].space?w.throw_error(b.error.syntax_by_predicate("parseable_number",y.indicator)):w.prepend([new xe(P.goal.replace(new H("=",[z,ot])),P.substitution,P)]);return}}if(!Z){F=z.toString();for(var dt=new H("[]"),Gt=F.length-1;Gt>=0;Gt--)dt=new H(".",[new H(F.charAt(Gt)),dt]);w.prepend([new xe(P.goal.replace(new H("=",[X,dt])),P.substitution,P)])}}},"number_codes/2":function(w,P,y){var F,z=y.args[0],X=y.args[1];if(b.type.is_variable(z)&&b.type.is_variable(X))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_variable(z)&&!b.type.is_number(z))w.throw_error(b.error.type("number",z,y.indicator));else if(!b.type.is_variable(X)&&!b.type.is_list(X))w.throw_error(b.error.type("list",X,y.indicator));else{var Z=b.type.is_variable(z);if(!b.type.is_variable(X)){var ie=X,Pe=!0;for(F="";ie.indicator==="./2";){if(b.type.is_character_code(ie.args[0]))F+=u(ie.args[0].value);else if(b.type.is_variable(ie.args[0]))Pe=!1;else if(!b.type.is_variable(ie.args[0])){w.throw_error(b.error.type("character_code",ie.args[0],y.indicator));return}ie=ie.args[1]}if(Pe=Pe&&b.type.is_empty_list(ie),!b.type.is_empty_list(ie)&&!b.type.is_variable(ie)){w.throw_error(b.error.type("list",X,y.indicator));return}if(!Pe&&Z){w.throw_error(b.error.instantiation(y.indicator));return}else if(Pe)if(b.type.is_variable(ie)&&Z){w.throw_error(b.error.instantiation(y.indicator));return}else{var Ne=w.parse(F),ot=Ne.value;!b.type.is_number(ot)||Ne.tokens[Ne.tokens.length-1].space?w.throw_error(b.error.syntax_by_predicate("parseable_number",y.indicator)):w.prepend([new xe(P.goal.replace(new H("=",[z,ot])),P.substitution,P)]);return}}if(!Z){F=z.toString();for(var dt=new H("[]"),Gt=F.length-1;Gt>=0;Gt--)dt=new H(".",[new Fe(n(F,Gt),!1),dt]);w.prepend([new xe(P.goal.replace(new H("=",[X,dt])),P.substitution,P)])}}},"upcase_atom/2":function(w,P,y){var F=y.args[0],z=y.args[1];b.type.is_variable(F)?w.throw_error(b.error.instantiation(y.indicator)):b.type.is_atom(F)?!b.type.is_variable(z)&&!b.type.is_atom(z)?w.throw_error(b.error.type("atom",z,y.indicator)):w.prepend([new xe(P.goal.replace(new H("=",[z,new H(F.id.toUpperCase(),[])])),P.substitution,P)]):w.throw_error(b.error.type("atom",F,y.indicator))},"downcase_atom/2":function(w,P,y){var F=y.args[0],z=y.args[1];b.type.is_variable(F)?w.throw_error(b.error.instantiation(y.indicator)):b.type.is_atom(F)?!b.type.is_variable(z)&&!b.type.is_atom(z)?w.throw_error(b.error.type("atom",z,y.indicator)):w.prepend([new xe(P.goal.replace(new H("=",[z,new H(F.id.toLowerCase(),[])])),P.substitution,P)]):w.throw_error(b.error.type("atom",F,y.indicator))},"atomic_list_concat/2":function(w,P,y){var F=y.args[0],z=y.args[1];w.prepend([new xe(P.goal.replace(new H("atomic_list_concat",[F,new H("",[]),z])),P.substitution,P)])},"atomic_list_concat/3":function(w,P,y){var F=y.args[0],z=y.args[1],X=y.args[2];if(b.type.is_variable(z)||b.type.is_variable(F)&&b.type.is_variable(X))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_variable(F)&&!b.type.is_list(F))w.throw_error(b.error.type("list",F,y.indicator));else if(!b.type.is_variable(X)&&!b.type.is_atom(X))w.throw_error(b.error.type("atom",X,y.indicator));else if(b.type.is_variable(X)){for(var ie="",Pe=F;b.type.is_term(Pe)&&Pe.indicator==="./2";){if(!b.type.is_atom(Pe.args[0])&&!b.type.is_number(Pe.args[0])){w.throw_error(b.error.type("atomic",Pe.args[0],y.indicator));return}ie!==""&&(ie+=z.id),b.type.is_atom(Pe.args[0])?ie+=Pe.args[0].id:ie+=""+Pe.args[0].value,Pe=Pe.args[1]}ie=new H(ie,[]),b.type.is_variable(Pe)?w.throw_error(b.error.instantiation(y.indicator)):!b.type.is_term(Pe)||Pe.indicator!=="[]/0"?w.throw_error(b.error.type("list",F,y.indicator)):w.prepend([new xe(P.goal.replace(new H("=",[ie,X])),P.substitution,P)])}else{var Z=g(o(X.id.split(z.id),function(Ne){return new H(Ne,[])}));w.prepend([new xe(P.goal.replace(new H("=",[Z,F])),P.substitution,P)])}},"@=</2":function(w,P,y){b.compare(y.args[0],y.args[1])<=0&&w.success(P)},"==/2":function(w,P,y){b.compare(y.args[0],y.args[1])===0&&w.success(P)},"\\==/2":function(w,P,y){b.compare(y.args[0],y.args[1])!==0&&w.success(P)},"@</2":function(w,P,y){b.compare(y.args[0],y.args[1])<0&&w.success(P)},"@>/2":function(w,P,y){b.compare(y.args[0],y.args[1])>0&&w.success(P)},"@>=/2":function(w,P,y){b.compare(y.args[0],y.args[1])>=0&&w.success(P)},"compare/3":function(w,P,y){var F=y.args[0],z=y.args[1],X=y.args[2];if(!b.type.is_variable(F)&&!b.type.is_atom(F))w.throw_error(b.error.type("atom",F,y.indicator));else if(b.type.is_atom(F)&&["<",">","="].indexOf(F.id)===-1)w.throw_error(b.type.domain("order",F,y.indicator));else{var Z=b.compare(z,X);Z=Z===0?"=":Z===-1?"<":">",w.prepend([new xe(P.goal.replace(new H("=",[F,new H(Z,[])])),P.substitution,P)])}},"is/2":function(w,P,y){var F=y.args[1].interpret(w);b.type.is_number(F)?w.prepend([new xe(P.goal.replace(new H("=",[y.args[0],F],w.level)),P.substitution,P)]):w.throw_error(F)},"between/3":function(w,P,y){var F=y.args[0],z=y.args[1],X=y.args[2];if(b.type.is_variable(F)||b.type.is_variable(z))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_integer(F))w.throw_error(b.error.type("integer",F,y.indicator));else if(!b.type.is_integer(z))w.throw_error(b.error.type("integer",z,y.indicator));else if(!b.type.is_variable(X)&&!b.type.is_integer(X))w.throw_error(b.error.type("integer",X,y.indicator));else if(b.type.is_variable(X)){var Z=[new xe(P.goal.replace(new H("=",[X,F])),P.substitution,P)];F.value<z.value&&Z.push(new xe(P.goal.replace(new H("between",[new Fe(F.value+1,!1),z,X])),P.substitution,P)),w.prepend(Z)}else F.value<=X.value&&z.value>=X.value&&w.success(P)},"succ/2":function(w,P,y){var F=y.args[0],z=y.args[1];b.type.is_variable(F)&&b.type.is_variable(z)?w.throw_error(b.error.instantiation(y.indicator)):!b.type.is_variable(F)&&!b.type.is_integer(F)?w.throw_error(b.error.type("integer",F,y.indicator)):!b.type.is_variable(z)&&!b.type.is_integer(z)?w.throw_error(b.error.type("integer",z,y.indicator)):!b.type.is_variable(F)&&F.value<0?w.throw_error(b.error.domain("not_less_than_zero",F,y.indicator)):!b.type.is_variable(z)&&z.value<0?w.throw_error(b.error.domain("not_less_than_zero",z,y.indicator)):(b.type.is_variable(z)||z.value>0)&&(b.type.is_variable(F)?w.prepend([new xe(P.goal.replace(new H("=",[F,new Fe(z.value-1,!1)])),P.substitution,P)]):w.prepend([new xe(P.goal.replace(new H("=",[z,new Fe(F.value+1,!1)])),P.substitution,P)]))},"=:=/2":function(w,P,y){var F=b.arithmetic_compare(w,y.args[0],y.args[1]);b.type.is_term(F)?w.throw_error(F):F===0&&w.success(P)},"=\\=/2":function(w,P,y){var F=b.arithmetic_compare(w,y.args[0],y.args[1]);b.type.is_term(F)?w.throw_error(F):F!==0&&w.success(P)},"</2":function(w,P,y){var F=b.arithmetic_compare(w,y.args[0],y.args[1]);b.type.is_term(F)?w.throw_error(F):F<0&&w.success(P)},"=</2":function(w,P,y){var F=b.arithmetic_compare(w,y.args[0],y.args[1]);b.type.is_term(F)?w.throw_error(F):F<=0&&w.success(P)},">/2":function(w,P,y){var F=b.arithmetic_compare(w,y.args[0],y.args[1]);b.type.is_term(F)?w.throw_error(F):F>0&&w.success(P)},">=/2":function(w,P,y){var F=b.arithmetic_compare(w,y.args[0],y.args[1]);b.type.is_term(F)?w.throw_error(F):F>=0&&w.success(P)},"var/1":function(w,P,y){b.type.is_variable(y.args[0])&&w.success(P)},"atom/1":function(w,P,y){b.type.is_atom(y.args[0])&&w.success(P)},"atomic/1":function(w,P,y){b.type.is_atomic(y.args[0])&&w.success(P)},"compound/1":function(w,P,y){b.type.is_compound(y.args[0])&&w.success(P)},"integer/1":function(w,P,y){b.type.is_integer(y.args[0])&&w.success(P)},"float/1":function(w,P,y){b.type.is_float(y.args[0])&&w.success(P)},"number/1":function(w,P,y){b.type.is_number(y.args[0])&&w.success(P)},"nonvar/1":function(w,P,y){b.type.is_variable(y.args[0])||w.success(P)},"ground/1":function(w,P,y){y.variables().length===0&&w.success(P)},"acyclic_term/1":function(w,P,y){for(var F=P.substitution.apply(P.substitution),z=y.args[0].variables(),X=0;X<z.length;X++)if(P.substitution.links[z[X]]!==void 0&&!P.substitution.links[z[X]].equals(F.links[z[X]]))return;w.success(P)},"callable/1":function(w,P,y){b.type.is_callable(y.args[0])&&w.success(P)},"is_list/1":function(w,P,y){for(var F=y.args[0];b.type.is_term(F)&&F.indicator==="./2";)F=F.args[1];b.type.is_term(F)&&F.indicator==="[]/0"&&w.success(P)},"current_input/1":function(w,P,y){var F=y.args[0];!b.type.is_variable(F)&&!b.type.is_stream(F)&&!b.type.is_atom(F)?w.throw_error(b.error.domain("stream",F,y.indicator)):(b.type.is_atom(F)&&w.get_stream_by_alias(F.id)&&(F=w.get_stream_by_alias(F.id)),w.prepend([new xe(P.goal.replace(new H("=",[F,w.get_current_input()])),P.substitution,P)]))},"current_output/1":function(w,P,y){var F=y.args[0];!b.type.is_variable(F)&&!b.type.is_stream(F)&&!b.type.is_atom(F)?w.throw_error(b.error.domain("stream_or_alias",F,y.indicator)):(b.type.is_atom(F)&&w.get_stream_by_alias(F.id)&&(F=w.get_stream_by_alias(F.id)),w.prepend([new xe(P.goal.replace(new H("=",[F,w.get_current_output()])),P.substitution,P)]))},"set_input/1":function(w,P,y){var F=y.args[0],z=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);b.type.is_variable(F)?w.throw_error(b.error.instantiation(y.indicator)):!b.type.is_variable(F)&&!b.type.is_stream(F)&&!b.type.is_atom(F)?w.throw_error(b.error.domain("stream_or_alias",F,y.indicator)):b.type.is_stream(z)?z.output===!0?w.throw_error(b.error.permission("input","stream",F,y.indicator)):(w.set_current_input(z),w.success(P)):w.throw_error(b.error.existence("stream",F,y.indicator))},"set_output/1":function(w,P,y){var F=y.args[0],z=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);b.type.is_variable(F)?w.throw_error(b.error.instantiation(y.indicator)):!b.type.is_variable(F)&&!b.type.is_stream(F)&&!b.type.is_atom(F)?w.throw_error(b.error.domain("stream_or_alias",F,y.indicator)):b.type.is_stream(z)?z.input===!0?w.throw_error(b.error.permission("output","stream",F,y.indicator)):(w.set_current_output(z),w.success(P)):w.throw_error(b.error.existence("stream",F,y.indicator))},"open/3":function(w,P,y){var F=y.args[0],z=y.args[1],X=y.args[2];w.prepend([new xe(P.goal.replace(new H("open",[F,z,X,new H("[]",[])])),P.substitution,P)])},"open/4":function(w,P,y){var F=y.args[0],z=y.args[1],X=y.args[2],Z=y.args[3];if(b.type.is_variable(F)||b.type.is_variable(z))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_variable(z)&&!b.type.is_atom(z))w.throw_error(b.error.type("atom",z,y.indicator));else if(!b.type.is_list(Z))w.throw_error(b.error.type("list",Z,y.indicator));else if(!b.type.is_variable(X))w.throw_error(b.error.type("variable",X,y.indicator));else if(!b.type.is_atom(F)&&!b.type.is_streamable(F))w.throw_error(b.error.domain("source_sink",F,y.indicator));else if(!b.type.is_io_mode(z))w.throw_error(b.error.domain("io_mode",z,y.indicator));else{for(var ie={},Pe=Z,Ne;b.type.is_term(Pe)&&Pe.indicator==="./2";){if(Ne=Pe.args[0],b.type.is_variable(Ne)){w.throw_error(b.error.instantiation(y.indicator));return}else if(!b.type.is_stream_option(Ne)){w.throw_error(b.error.domain("stream_option",Ne,y.indicator));return}ie[Ne.id]=Ne.args[0].id,Pe=Pe.args[1]}if(Pe.indicator!=="[]/0"){b.type.is_variable(Pe)?w.throw_error(b.error.instantiation(y.indicator)):w.throw_error(b.error.type("list",Z,y.indicator));return}else{var ot=ie.alias;if(ot&&w.get_stream_by_alias(ot)){w.throw_error(b.error.permission("open","source_sink",new H("alias",[new H(ot,[])]),y.indicator));return}ie.type||(ie.type="text");var dt;if(b.type.is_atom(F)?dt=w.file_system_open(F.id,ie.type,z.id):dt=F.stream(ie.type,z.id),dt===!1){w.throw_error(b.error.permission("open","source_sink",F,y.indicator));return}else if(dt===null){w.throw_error(b.error.existence("source_sink",F,y.indicator));return}var Gt=new Re(dt,z.id,ie.alias,ie.type,ie.reposition==="true",ie.eof_action);ot?w.session.streams[ot]=Gt:w.session.streams[Gt.id]=Gt,w.prepend([new xe(P.goal.replace(new H("=",[X,Gt])),P.substitution,P)])}}},"close/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H("close",[F,new H("[]",[])])),P.substitution,P)])},"close/2":function(w,P,y){var F=y.args[0],z=y.args[1],X=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);if(b.type.is_variable(F)||b.type.is_variable(z))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_list(z))w.throw_error(b.error.type("list",z,y.indicator));else if(!b.type.is_stream(F)&&!b.type.is_atom(F))w.throw_error(b.error.domain("stream_or_alias",F,y.indicator));else if(!b.type.is_stream(X)||X.stream===null)w.throw_error(b.error.existence("stream",F,y.indicator));else{for(var Z={},ie=z,Pe;b.type.is_term(ie)&&ie.indicator==="./2";){if(Pe=ie.args[0],b.type.is_variable(Pe)){w.throw_error(b.error.instantiation(y.indicator));return}else if(!b.type.is_close_option(Pe)){w.throw_error(b.error.domain("close_option",Pe,y.indicator));return}Z[Pe.id]=Pe.args[0].id==="true",ie=ie.args[1]}if(ie.indicator!=="[]/0"){b.type.is_variable(ie)?w.throw_error(b.error.instantiation(y.indicator)):w.throw_error(b.error.type("list",z,y.indicator));return}else{if(X===w.session.standard_input||X===w.session.standard_output){w.success(P);return}else X===w.session.current_input?w.session.current_input=w.session.standard_input:X===w.session.current_output&&(w.session.current_output=w.session.current_output);X.alias!==null?delete w.session.streams[X.alias]:delete w.session.streams[X.id],X.output&&X.stream.flush();var Ne=X.stream.close();X.stream=null,(Z.force===!0||Ne===!0)&&w.success(P)}}},"flush_output/0":function(w,P,y){w.prepend([new xe(P.goal.replace(new H(",",[new H("current_output",[new Ie("S")]),new H("flush_output",[new Ie("S")])])),P.substitution,P)])},"flush_output/1":function(w,P,y){var F=y.args[0],z=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);b.type.is_variable(F)?w.throw_error(b.error.instantiation(y.indicator)):!b.type.is_stream(F)&&!b.type.is_atom(F)?w.throw_error(b.error.domain("stream_or_alias",F,y.indicator)):!b.type.is_stream(z)||z.stream===null?w.throw_error(b.error.existence("stream",F,y.indicator)):F.input===!0?w.throw_error(b.error.permission("output","stream",output,y.indicator)):(z.stream.flush(),w.success(P))},"stream_property/2":function(w,P,y){var F=y.args[0],z=y.args[1],X=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);if(!b.type.is_variable(F)&&!b.type.is_stream(F)&&!b.type.is_atom(F))w.throw_error(b.error.domain("stream_or_alias",F,y.indicator));else if(!b.type.is_variable(F)&&(!b.type.is_stream(X)||X.stream===null))w.throw_error(b.error.existence("stream",F,y.indicator));else if(!b.type.is_variable(z)&&!b.type.is_stream_property(z))w.throw_error(b.error.domain("stream_property",z,y.indicator));else{var Z=[],ie=[];if(!b.type.is_variable(F))Z.push(X);else for(var Pe in w.session.streams)Z.push(w.session.streams[Pe]);for(var Ne=0;Ne<Z.length;Ne++){var ot=[];Z[Ne].filename&&ot.push(new H("file_name",[new H(Z[Ne].file_name,[])])),ot.push(new H("mode",[new H(Z[Ne].mode,[])])),ot.push(new H(Z[Ne].input?"input":"output",[])),Z[Ne].alias&&ot.push(new H("alias",[new H(Z[Ne].alias,[])])),ot.push(new H("position",[typeof Z[Ne].position=="number"?new Fe(Z[Ne].position,!1):new H(Z[Ne].position,[])])),ot.push(new H("end_of_stream",[new H(Z[Ne].position==="end_of_stream"?"at":Z[Ne].position==="past_end_of_stream"?"past":"not",[])])),ot.push(new H("eof_action",[new H(Z[Ne].eof_action,[])])),ot.push(new H("reposition",[new H(Z[Ne].reposition?"true":"false",[])])),ot.push(new H("type",[new H(Z[Ne].type,[])]));for(var dt=0;dt<ot.length;dt++)ie.push(new xe(P.goal.replace(new H(",",[new H("=",[b.type.is_variable(F)?F:X,Z[Ne]]),new H("=",[z,ot[dt]])])),P.substitution,P))}w.prepend(ie)}},"at_end_of_stream/0":function(w,P,y){w.prepend([new xe(P.goal.replace(new H(",",[new H("current_input",[new Ie("S")]),new H(",",[new H("stream_property",[new Ie("S"),new H("end_of_stream",[new Ie("E")])]),new H(",",[new H("!",[]),new H(";",[new H("=",[new Ie("E"),new H("at",[])]),new H("=",[new Ie("E"),new H("past",[])])])])])])),P.substitution,P)])},"at_end_of_stream/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H(",",[new H("stream_property",[F,new H("end_of_stream",[new Ie("E")])]),new H(",",[new H("!",[]),new H(";",[new H("=",[new Ie("E"),new H("at",[])]),new H("=",[new Ie("E"),new H("past",[])])])])])),P.substitution,P)])},"set_stream_position/2":function(w,P,y){var F=y.args[0],z=y.args[1],X=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);b.type.is_variable(F)||b.type.is_variable(z)?w.throw_error(b.error.instantiation(y.indicator)):!b.type.is_stream(F)&&!b.type.is_atom(F)?w.throw_error(b.error.domain("stream_or_alias",F,y.indicator)):!b.type.is_stream(X)||X.stream===null?w.throw_error(b.error.existence("stream",F,y.indicator)):b.type.is_stream_position(z)?X.reposition===!1?w.throw_error(b.error.permission("reposition","stream",F,y.indicator)):(b.type.is_integer(z)?X.position=z.value:X.position=z.id,w.success(P)):w.throw_error(b.error.domain("stream_position",z,y.indicator))},"get_char/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H(",",[new H("current_input",[new Ie("S")]),new H("get_char",[new Ie("S"),F])])),P.substitution,P)])},"get_char/2":function(w,P,y){var F=y.args[0],z=y.args[1],X=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);if(b.type.is_variable(F))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_variable(z)&&!b.type.is_character(z))w.throw_error(b.error.type("in_character",z,y.indicator));else if(!b.type.is_stream(F)&&!b.type.is_atom(F))w.throw_error(b.error.domain("stream_or_alias",F,y.indicator));else if(!b.type.is_stream(X)||X.stream===null)w.throw_error(b.error.existence("stream",F,y.indicator));else if(X.output)w.throw_error(b.error.permission("input","stream",F,y.indicator));else if(X.type==="binary")w.throw_error(b.error.permission("input","binary_stream",F,y.indicator));else if(X.position==="past_end_of_stream"&&X.eof_action==="error")w.throw_error(b.error.permission("input","past_end_of_stream",F,y.indicator));else{var Z;if(X.position==="end_of_stream")Z="end_of_file",X.position="past_end_of_stream";else{if(Z=X.stream.get(1,X.position),Z===null){w.throw_error(b.error.representation("character",y.indicator));return}X.position++}w.prepend([new xe(P.goal.replace(new H("=",[new H(Z,[]),z])),P.substitution,P)])}},"get_code/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H(",",[new H("current_input",[new Ie("S")]),new H("get_code",[new Ie("S"),F])])),P.substitution,P)])},"get_code/2":function(w,P,y){var F=y.args[0],z=y.args[1],X=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);if(b.type.is_variable(F))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_variable(z)&&!b.type.is_integer(z))w.throw_error(b.error.type("integer",char,y.indicator));else if(!b.type.is_variable(F)&&!b.type.is_stream(F)&&!b.type.is_atom(F))w.throw_error(b.error.domain("stream_or_alias",F,y.indicator));else if(!b.type.is_stream(X)||X.stream===null)w.throw_error(b.error.existence("stream",F,y.indicator));else if(X.output)w.throw_error(b.error.permission("input","stream",F,y.indicator));else if(X.type==="binary")w.throw_error(b.error.permission("input","binary_stream",F,y.indicator));else if(X.position==="past_end_of_stream"&&X.eof_action==="error")w.throw_error(b.error.permission("input","past_end_of_stream",F,y.indicator));else{var Z;if(X.position==="end_of_stream")Z=-1,X.position="past_end_of_stream";else{if(Z=X.stream.get(1,X.position),Z===null){w.throw_error(b.error.representation("character",y.indicator));return}Z=n(Z,0),X.position++}w.prepend([new xe(P.goal.replace(new H("=",[new Fe(Z,!1),z])),P.substitution,P)])}},"peek_char/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H(",",[new H("current_input",[new Ie("S")]),new H("peek_char",[new Ie("S"),F])])),P.substitution,P)])},"peek_char/2":function(w,P,y){var F=y.args[0],z=y.args[1],X=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);if(b.type.is_variable(F))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_variable(z)&&!b.type.is_character(z))w.throw_error(b.error.type("in_character",z,y.indicator));else if(!b.type.is_stream(F)&&!b.type.is_atom(F))w.throw_error(b.error.domain("stream_or_alias",F,y.indicator));else if(!b.type.is_stream(X)||X.stream===null)w.throw_error(b.error.existence("stream",F,y.indicator));else if(X.output)w.throw_error(b.error.permission("input","stream",F,y.indicator));else if(X.type==="binary")w.throw_error(b.error.permission("input","binary_stream",F,y.indicator));else if(X.position==="past_end_of_stream"&&X.eof_action==="error")w.throw_error(b.error.permission("input","past_end_of_stream",F,y.indicator));else{var Z;if(X.position==="end_of_stream")Z="end_of_file",X.position="past_end_of_stream";else if(Z=X.stream.get(1,X.position),Z===null){w.throw_error(b.error.representation("character",y.indicator));return}w.prepend([new xe(P.goal.replace(new H("=",[new H(Z,[]),z])),P.substitution,P)])}},"peek_code/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H(",",[new H("current_input",[new Ie("S")]),new H("peek_code",[new Ie("S"),F])])),P.substitution,P)])},"peek_code/2":function(w,P,y){var F=y.args[0],z=y.args[1],X=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);if(b.type.is_variable(F))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_variable(z)&&!b.type.is_integer(z))w.throw_error(b.error.type("integer",char,y.indicator));else if(!b.type.is_variable(F)&&!b.type.is_stream(F)&&!b.type.is_atom(F))w.throw_error(b.error.domain("stream_or_alias",F,y.indicator));else if(!b.type.is_stream(X)||X.stream===null)w.throw_error(b.error.existence("stream",F,y.indicator));else if(X.output)w.throw_error(b.error.permission("input","stream",F,y.indicator));else if(X.type==="binary")w.throw_error(b.error.permission("input","binary_stream",F,y.indicator));else if(X.position==="past_end_of_stream"&&X.eof_action==="error")w.throw_error(b.error.permission("input","past_end_of_stream",F,y.indicator));else{var Z;if(X.position==="end_of_stream")Z=-1,X.position="past_end_of_stream";else{if(Z=X.stream.get(1,X.position),Z===null){w.throw_error(b.error.representation("character",y.indicator));return}Z=n(Z,0)}w.prepend([new xe(P.goal.replace(new H("=",[new Fe(Z,!1),z])),P.substitution,P)])}},"put_char/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H(",",[new H("current_output",[new Ie("S")]),new H("put_char",[new Ie("S"),F])])),P.substitution,P)])},"put_char/2":function(w,P,y){var F=y.args[0],z=y.args[1],X=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);b.type.is_variable(F)||b.type.is_variable(z)?w.throw_error(b.error.instantiation(y.indicator)):b.type.is_character(z)?!b.type.is_variable(F)&&!b.type.is_stream(F)&&!b.type.is_atom(F)?w.throw_error(b.error.domain("stream_or_alias",F,y.indicator)):!b.type.is_stream(X)||X.stream===null?w.throw_error(b.error.existence("stream",F,y.indicator)):X.input?w.throw_error(b.error.permission("output","stream",F,y.indicator)):X.type==="binary"?w.throw_error(b.error.permission("output","binary_stream",F,y.indicator)):X.stream.put(z.id,X.position)&&(typeof X.position=="number"&&X.position++,w.success(P)):w.throw_error(b.error.type("character",z,y.indicator))},"put_code/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H(",",[new H("current_output",[new Ie("S")]),new H("put_code",[new Ie("S"),F])])),P.substitution,P)])},"put_code/2":function(w,P,y){var F=y.args[0],z=y.args[1],X=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);b.type.is_variable(F)||b.type.is_variable(z)?w.throw_error(b.error.instantiation(y.indicator)):b.type.is_integer(z)?b.type.is_character_code(z)?!b.type.is_variable(F)&&!b.type.is_stream(F)&&!b.type.is_atom(F)?w.throw_error(b.error.domain("stream_or_alias",F,y.indicator)):!b.type.is_stream(X)||X.stream===null?w.throw_error(b.error.existence("stream",F,y.indicator)):X.input?w.throw_error(b.error.permission("output","stream",F,y.indicator)):X.type==="binary"?w.throw_error(b.error.permission("output","binary_stream",F,y.indicator)):X.stream.put_char(u(z.value),X.position)&&(typeof X.position=="number"&&X.position++,w.success(P)):w.throw_error(b.error.representation("character_code",y.indicator)):w.throw_error(b.error.type("integer",z,y.indicator))},"nl/0":function(w,P,y){w.prepend([new xe(P.goal.replace(new H(",",[new H("current_output",[new Ie("S")]),new H("put_char",[new Ie("S"),new H(` +`,[])])])),P.substitution,P)])},"nl/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H("put_char",[F,new H(` +`,[])])),P.substitution,P)])},"get_byte/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H(",",[new H("current_input",[new Ie("S")]),new H("get_byte",[new Ie("S"),F])])),P.substitution,P)])},"get_byte/2":function(w,P,y){var F=y.args[0],z=y.args[1],X=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);if(b.type.is_variable(F))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_variable(z)&&!b.type.is_byte(z))w.throw_error(b.error.type("in_byte",char,y.indicator));else if(!b.type.is_stream(F)&&!b.type.is_atom(F))w.throw_error(b.error.domain("stream_or_alias",F,y.indicator));else if(!b.type.is_stream(X)||X.stream===null)w.throw_error(b.error.existence("stream",F,y.indicator));else if(X.output)w.throw_error(b.error.permission("input","stream",F,y.indicator));else if(X.type==="text")w.throw_error(b.error.permission("input","text_stream",F,y.indicator));else if(X.position==="past_end_of_stream"&&X.eof_action==="error")w.throw_error(b.error.permission("input","past_end_of_stream",F,y.indicator));else{var Z;if(X.position==="end_of_stream")Z="end_of_file",X.position="past_end_of_stream";else{if(Z=X.stream.get_byte(X.position),Z===null){w.throw_error(b.error.representation("byte",y.indicator));return}X.position++}w.prepend([new xe(P.goal.replace(new H("=",[new Fe(Z,!1),z])),P.substitution,P)])}},"peek_byte/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H(",",[new H("current_input",[new Ie("S")]),new H("peek_byte",[new Ie("S"),F])])),P.substitution,P)])},"peek_byte/2":function(w,P,y){var F=y.args[0],z=y.args[1],X=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);if(b.type.is_variable(F))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_variable(z)&&!b.type.is_byte(z))w.throw_error(b.error.type("in_byte",char,y.indicator));else if(!b.type.is_stream(F)&&!b.type.is_atom(F))w.throw_error(b.error.domain("stream_or_alias",F,y.indicator));else if(!b.type.is_stream(X)||X.stream===null)w.throw_error(b.error.existence("stream",F,y.indicator));else if(X.output)w.throw_error(b.error.permission("input","stream",F,y.indicator));else if(X.type==="text")w.throw_error(b.error.permission("input","text_stream",F,y.indicator));else if(X.position==="past_end_of_stream"&&X.eof_action==="error")w.throw_error(b.error.permission("input","past_end_of_stream",F,y.indicator));else{var Z;if(X.position==="end_of_stream")Z="end_of_file",X.position="past_end_of_stream";else if(Z=X.stream.get_byte(X.position),Z===null){w.throw_error(b.error.representation("byte",y.indicator));return}w.prepend([new xe(P.goal.replace(new H("=",[new Fe(Z,!1),z])),P.substitution,P)])}},"put_byte/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H(",",[new H("current_output",[new Ie("S")]),new H("put_byte",[new Ie("S"),F])])),P.substitution,P)])},"put_byte/2":function(w,P,y){var F=y.args[0],z=y.args[1],X=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);b.type.is_variable(F)||b.type.is_variable(z)?w.throw_error(b.error.instantiation(y.indicator)):b.type.is_byte(z)?!b.type.is_variable(F)&&!b.type.is_stream(F)&&!b.type.is_atom(F)?w.throw_error(b.error.domain("stream_or_alias",F,y.indicator)):!b.type.is_stream(X)||X.stream===null?w.throw_error(b.error.existence("stream",F,y.indicator)):X.input?w.throw_error(b.error.permission("output","stream",F,y.indicator)):X.type==="text"?w.throw_error(b.error.permission("output","text_stream",F,y.indicator)):X.stream.put_byte(z.value,X.position)&&(typeof X.position=="number"&&X.position++,w.success(P)):w.throw_error(b.error.type("byte",z,y.indicator))},"read/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H(",",[new H("current_input",[new Ie("S")]),new H("read_term",[new Ie("S"),F,new H("[]",[])])])),P.substitution,P)])},"read/2":function(w,P,y){var F=y.args[0],z=y.args[1];w.prepend([new xe(P.goal.replace(new H("read_term",[F,z,new H("[]",[])])),P.substitution,P)])},"read_term/2":function(w,P,y){var F=y.args[0],z=y.args[1];w.prepend([new xe(P.goal.replace(new H(",",[new H("current_input",[new Ie("S")]),new H("read_term",[new Ie("S"),F,z])])),P.substitution,P)])},"read_term/3":function(w,P,y){var F=y.args[0],z=y.args[1],X=y.args[2],Z=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);if(b.type.is_variable(F)||b.type.is_variable(X))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_list(X))w.throw_error(b.error.type("list",X,y.indicator));else if(!b.type.is_stream(F)&&!b.type.is_atom(F))w.throw_error(b.error.domain("stream_or_alias",F,y.indicator));else if(!b.type.is_stream(Z)||Z.stream===null)w.throw_error(b.error.existence("stream",F,y.indicator));else if(Z.output)w.throw_error(b.error.permission("input","stream",F,y.indicator));else if(Z.type==="binary")w.throw_error(b.error.permission("input","binary_stream",F,y.indicator));else if(Z.position==="past_end_of_stream"&&Z.eof_action==="error")w.throw_error(b.error.permission("input","past_end_of_stream",F,y.indicator));else{for(var ie={},Pe=X,Ne;b.type.is_term(Pe)&&Pe.indicator==="./2";){if(Ne=Pe.args[0],b.type.is_variable(Ne)){w.throw_error(b.error.instantiation(y.indicator));return}else if(!b.type.is_read_option(Ne)){w.throw_error(b.error.domain("read_option",Ne,y.indicator));return}ie[Ne.id]=Ne.args[0],Pe=Pe.args[1]}if(Pe.indicator!=="[]/0"){b.type.is_variable(Pe)?w.throw_error(b.error.instantiation(y.indicator)):w.throw_error(b.error.type("list",X,y.indicator));return}else{for(var ot,dt,Gt,$t="",bt=[],an=null;an===null||an.name!=="atom"||an.value!=="."||Gt.type===A&&b.flatten_error(new H("throw",[Gt.value])).found==="token_not_found";){if(ot=Z.stream.get(1,Z.position),ot===null){w.throw_error(b.error.representation("character",y.indicator));return}if(ot==="end_of_file"||ot==="past_end_of_file"){Gt?w.throw_error(b.error.syntax(bt[Gt.len-1],". or expression expected",!1)):w.throw_error(b.error.syntax(null,"token not found",!0));return}Z.position++,$t+=ot,dt=new U(w),dt.new_text($t),bt=dt.get_tokens(),an=bt!==null&&bt.length>0?bt[bt.length-1]:null,bt!==null&&(Gt=J(w,bt,0,w.__get_max_priority(),!1))}if(Gt.type===p&&Gt.len===bt.length-1&&an.value==="."){Gt=Gt.value.rename(w);var Qr=new H("=",[z,Gt]);if(ie.variables){var mr=g(o(Se(Gt.variables()),function(br){return new Ie(br)}));Qr=new H(",",[Qr,new H("=",[ie.variables,mr])])}if(ie.variable_names){var mr=g(o(Se(Gt.variables()),function(Wr){var Kn;for(Kn in w.session.renamed_variables)if(w.session.renamed_variables.hasOwnProperty(Kn)&&w.session.renamed_variables[Kn]===Wr)break;return new H("=",[new H(Kn,[]),new Ie(Wr)])}));Qr=new H(",",[Qr,new H("=",[ie.variable_names,mr])])}if(ie.singletons){var mr=g(o(new He(Gt,null).singleton_variables(),function(Wr){var Kn;for(Kn in w.session.renamed_variables)if(w.session.renamed_variables.hasOwnProperty(Kn)&&w.session.renamed_variables[Kn]===Wr)break;return new H("=",[new H(Kn,[]),new Ie(Wr)])}));Qr=new H(",",[Qr,new H("=",[ie.singletons,mr])])}w.prepend([new xe(P.goal.replace(Qr),P.substitution,P)])}else Gt.type===p?w.throw_error(b.error.syntax(bt[Gt.len],"unexpected token",!1)):w.throw_error(Gt.value)}}},"write/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H(",",[new H("current_output",[new Ie("S")]),new H("write",[new Ie("S"),F])])),P.substitution,P)])},"write/2":function(w,P,y){var F=y.args[0],z=y.args[1];w.prepend([new xe(P.goal.replace(new H("write_term",[F,z,new H(".",[new H("quoted",[new H("false",[])]),new H(".",[new H("ignore_ops",[new H("false")]),new H(".",[new H("numbervars",[new H("true")]),new H("[]",[])])])])])),P.substitution,P)])},"writeq/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H(",",[new H("current_output",[new Ie("S")]),new H("writeq",[new Ie("S"),F])])),P.substitution,P)])},"writeq/2":function(w,P,y){var F=y.args[0],z=y.args[1];w.prepend([new xe(P.goal.replace(new H("write_term",[F,z,new H(".",[new H("quoted",[new H("true",[])]),new H(".",[new H("ignore_ops",[new H("false")]),new H(".",[new H("numbervars",[new H("true")]),new H("[]",[])])])])])),P.substitution,P)])},"write_canonical/1":function(w,P,y){var F=y.args[0];w.prepend([new xe(P.goal.replace(new H(",",[new H("current_output",[new Ie("S")]),new H("write_canonical",[new Ie("S"),F])])),P.substitution,P)])},"write_canonical/2":function(w,P,y){var F=y.args[0],z=y.args[1];w.prepend([new xe(P.goal.replace(new H("write_term",[F,z,new H(".",[new H("quoted",[new H("true",[])]),new H(".",[new H("ignore_ops",[new H("true")]),new H(".",[new H("numbervars",[new H("false")]),new H("[]",[])])])])])),P.substitution,P)])},"write_term/2":function(w,P,y){var F=y.args[0],z=y.args[1];w.prepend([new xe(P.goal.replace(new H(",",[new H("current_output",[new Ie("S")]),new H("write_term",[new Ie("S"),F,z])])),P.substitution,P)])},"write_term/3":function(w,P,y){var F=y.args[0],z=y.args[1],X=y.args[2],Z=b.type.is_stream(F)?F:w.get_stream_by_alias(F.id);if(b.type.is_variable(F)||b.type.is_variable(X))w.throw_error(b.error.instantiation(y.indicator));else if(!b.type.is_list(X))w.throw_error(b.error.type("list",X,y.indicator));else if(!b.type.is_stream(F)&&!b.type.is_atom(F))w.throw_error(b.error.domain("stream_or_alias",F,y.indicator));else if(!b.type.is_stream(Z)||Z.stream===null)w.throw_error(b.error.existence("stream",F,y.indicator));else if(Z.input)w.throw_error(b.error.permission("output","stream",F,y.indicator));else if(Z.type==="binary")w.throw_error(b.error.permission("output","binary_stream",F,y.indicator));else if(Z.position==="past_end_of_stream"&&Z.eof_action==="error")w.throw_error(b.error.permission("output","past_end_of_stream",F,y.indicator));else{for(var ie={},Pe=X,Ne;b.type.is_term(Pe)&&Pe.indicator==="./2";){if(Ne=Pe.args[0],b.type.is_variable(Ne)){w.throw_error(b.error.instantiation(y.indicator));return}else if(!b.type.is_write_option(Ne)){w.throw_error(b.error.domain("write_option",Ne,y.indicator));return}ie[Ne.id]=Ne.args[0].id==="true",Pe=Pe.args[1]}if(Pe.indicator!=="[]/0"){b.type.is_variable(Pe)?w.throw_error(b.error.instantiation(y.indicator)):w.throw_error(b.error.type("list",X,y.indicator));return}else{ie.session=w.session;var ot=z.toString(ie);Z.stream.put(ot,Z.position),typeof Z.position=="number"&&(Z.position+=ot.length),w.success(P)}}},"halt/0":function(w,P,y){w.points=[]},"halt/1":function(w,P,y){var F=y.args[0];b.type.is_variable(F)?w.throw_error(b.error.instantiation(y.indicator)):b.type.is_integer(F)?w.points=[]:w.throw_error(b.error.type("integer",F,y.indicator))},"current_prolog_flag/2":function(w,P,y){var F=y.args[0],z=y.args[1];if(!b.type.is_variable(F)&&!b.type.is_atom(F))w.throw_error(b.error.type("atom",F,y.indicator));else if(!b.type.is_variable(F)&&!b.type.is_flag(F))w.throw_error(b.error.domain("prolog_flag",F,y.indicator));else{var X=[];for(var Z in b.flag)if(!!b.flag.hasOwnProperty(Z)){var ie=new H(",",[new H("=",[new H(Z),F]),new H("=",[w.get_flag(Z),z])]);X.push(new xe(P.goal.replace(ie),P.substitution,P))}w.prepend(X)}},"set_prolog_flag/2":function(w,P,y){var F=y.args[0],z=y.args[1];b.type.is_variable(F)||b.type.is_variable(z)?w.throw_error(b.error.instantiation(y.indicator)):b.type.is_atom(F)?b.type.is_flag(F)?b.type.is_value_flag(F,z)?b.type.is_modifiable_flag(F)?(w.session.flag[F.id]=z,w.success(P)):w.throw_error(b.error.permission("modify","flag",F)):w.throw_error(b.error.domain("flag_value",new H("+",[F,z]),y.indicator)):w.throw_error(b.error.domain("prolog_flag",F,y.indicator)):w.throw_error(b.error.type("atom",F,y.indicator))}},flag:{bounded:{allowed:[new H("true"),new H("false")],value:new H("true"),changeable:!1},max_integer:{allowed:[new Fe(Number.MAX_SAFE_INTEGER)],value:new Fe(Number.MAX_SAFE_INTEGER),changeable:!1},min_integer:{allowed:[new Fe(Number.MIN_SAFE_INTEGER)],value:new Fe(Number.MIN_SAFE_INTEGER),changeable:!1},integer_rounding_function:{allowed:[new H("down"),new H("toward_zero")],value:new H("toward_zero"),changeable:!1},char_conversion:{allowed:[new H("on"),new H("off")],value:new H("on"),changeable:!0},debug:{allowed:[new H("on"),new H("off")],value:new H("off"),changeable:!0},max_arity:{allowed:[new H("unbounded")],value:new H("unbounded"),changeable:!1},unknown:{allowed:[new H("error"),new H("fail"),new H("warning")],value:new H("error"),changeable:!0},double_quotes:{allowed:[new H("chars"),new H("codes"),new H("atom")],value:new H("codes"),changeable:!0},occurs_check:{allowed:[new H("false"),new H("true")],value:new H("false"),changeable:!0},dialect:{allowed:[new H("tau")],value:new H("tau"),changeable:!1},version_data:{allowed:[new H("tau",[new Fe(t.major,!1),new Fe(t.minor,!1),new Fe(t.patch,!1),new H(t.status)])],value:new H("tau",[new Fe(t.major,!1),new Fe(t.minor,!1),new Fe(t.patch,!1),new H(t.status)]),changeable:!1},nodejs:{allowed:[new H("yes"),new H("no")],value:new H(typeof hl<"u"&&hl.exports?"yes":"no"),changeable:!1}},unify:function(w,P,y){y=y===void 0?!1:y;for(var F=[{left:w,right:P}],z={};F.length!==0;){var X=F.pop();if(w=X.left,P=X.right,b.type.is_term(w)&&b.type.is_term(P)){if(w.indicator!==P.indicator)return null;for(var Z=0;Z<w.args.length;Z++)F.push({left:w.args[Z],right:P.args[Z]})}else if(b.type.is_number(w)&&b.type.is_number(P)){if(w.value!==P.value||w.is_float!==P.is_float)return null}else if(b.type.is_variable(w)){if(b.type.is_variable(P)&&w.id===P.id)continue;if(y===!0&&P.variables().indexOf(w.id)!==-1)return null;if(w.id!=="_"){var ie=new ke;ie.add(w.id,P);for(var Z=0;Z<F.length;Z++)F[Z].left=F[Z].left.apply(ie),F[Z].right=F[Z].right.apply(ie);for(var Z in z)z[Z]=z[Z].apply(ie);z[w.id]=P}}else if(b.type.is_variable(P))F.push({left:P,right:w});else if(w.unify!==void 0){if(!w.unify(P))return null}else return null}return new ke(z)},compare:function(w,P){var y=b.type.compare(w,P);return y!==0?y:w.compare(P)},arithmetic_compare:function(w,P,y){var F=P.interpret(w);if(b.type.is_number(F)){var z=y.interpret(w);return b.type.is_number(z)?F.value<z.value?-1:F.value>z.value?1:0:z}else return F},operate:function(w,P){if(b.type.is_operator(P)){for(var y=b.type.is_operator(P),F=[],z,X=!1,Z=0;Z<P.args.length;Z++){if(z=P.args[Z].interpret(w),b.type.is_number(z)){if(y.type_args!==null&&z.is_float!==y.type_args)return b.error.type(y.type_args?"float":"integer",z,w.__call_indicator);F.push(z.value)}else return z;X=X||z.is_float}return F.push(w),z=b.arithmetic.evaluation[P.indicator].fn.apply(this,F),X=y.type_result===null?X:y.type_result,b.type.is_term(z)?z:z===Number.POSITIVE_INFINITY||z===Number.NEGATIVE_INFINITY?b.error.evaluation("overflow",w.__call_indicator):X===!1&&w.get_flag("bounded").id==="true"&&(z>w.get_flag("max_integer").value||z<w.get_flag("min_integer").value)?b.error.evaluation("int_overflow",w.__call_indicator):new Fe(z,X)}else return b.error.type("evaluable",P.indicator,w.__call_indicator)},error:{existence:function(w,P,y){return typeof P=="string"&&(P=ee(P)),new H("error",[new H("existence_error",[new H(w),P]),ee(y)])},type:function(w,P,y){return new H("error",[new H("type_error",[new H(w),P]),ee(y)])},instantiation:function(w){return new H("error",[new H("instantiation_error"),ee(w)])},domain:function(w,P,y){return new H("error",[new H("domain_error",[new H(w),P]),ee(y)])},representation:function(w,P){return new H("error",[new H("representation_error",[new H(w)]),ee(P)])},permission:function(w,P,y,F){return new H("error",[new H("permission_error",[new H(w),new H(P),y]),ee(F)])},evaluation:function(w,P){return new H("error",[new H("evaluation_error",[new H(w)]),ee(P)])},syntax:function(w,P,y){w=w||{value:"",line:0,column:0,matches:[""],start:0};var F=y&&w.matches.length>0?w.start+w.matches[0].length:w.start,z=y?new H("token_not_found"):new H("found",[new H(w.value.toString())]),X=new H(".",[new H("line",[new Fe(w.line+1)]),new H(".",[new H("column",[new Fe(F+1)]),new H(".",[z,new H("[]",[])])])]);return new H("error",[new H("syntax_error",[new H(P)]),X])},syntax_by_predicate:function(w,P){return new H("error",[new H("syntax_error",[new H(w)]),ee(P)])}},warning:{singleton:function(w,P,y){for(var F=new H("[]"),z=w.length-1;z>=0;z--)F=new H(".",[new Ie(w[z]),F]);return new H("warning",[new H("singleton_variables",[F,ee(P)]),new H(".",[new H("line",[new Fe(y,!1)]),new H("[]")])])},failed_goal:function(w,P){return new H("warning",[new H("failed_goal",[w]),new H(".",[new H("line",[new Fe(P,!1)]),new H("[]")])])}},format_variable:function(w){return"_"+w},format_answer:function(w,P,F){P instanceof Te&&(P=P.thread);var F=F||{};if(F.session=P?P.session:void 0,b.type.is_error(w))return"uncaught exception: "+w.args[0].toString();if(w===!1)return"false.";if(w===null)return"limit exceeded ;";var z=0,X="";if(b.type.is_substitution(w)){var Z=w.domain(!0);w=w.filter(function(Ne,ot){return!b.type.is_variable(ot)||Z.indexOf(ot.id)!==-1&&Ne!==ot.id})}for(var ie in w.links)!w.links.hasOwnProperty(ie)||(z++,X!==""&&(X+=", "),X+=ie.toString(F)+" = "+w.links[ie].toString(F));var Pe=typeof P>"u"||P.points.length>0?" ;":".";return z===0?"true"+Pe:X+Pe},flatten_error:function(w){if(!b.type.is_error(w))return null;w=w.args[0];var P={};return P.type=w.args[0].id,P.thrown=P.type==="syntax_error"?null:w.args[1].id,P.expected=null,P.found=null,P.representation=null,P.existence=null,P.existence_type=null,P.line=null,P.column=null,P.permission_operation=null,P.permission_type=null,P.evaluation_type=null,P.type==="type_error"||P.type==="domain_error"?(P.expected=w.args[0].args[0].id,P.found=w.args[0].args[1].toString()):P.type==="syntax_error"?w.args[1].indicator==="./2"?(P.expected=w.args[0].args[0].id,P.found=w.args[1].args[1].args[1].args[0],P.found=P.found.id==="token_not_found"?P.found.id:P.found.args[0].id,P.line=w.args[1].args[0].args[0].value,P.column=w.args[1].args[1].args[0].args[0].value):P.thrown=w.args[1].id:P.type==="permission_error"?(P.found=w.args[0].args[2].toString(),P.permission_operation=w.args[0].args[0].id,P.permission_type=w.args[0].args[1].id):P.type==="evaluation_error"?P.evaluation_type=w.args[0].args[0].id:P.type==="representation_error"?P.representation=w.args[0].args[0].id:P.type==="existence_error"&&(P.existence=w.args[0].args[1].toString(),P.existence_type=w.args[0].args[0].id),P},create:function(w){return new b.type.Session(w)}};typeof hl<"u"?hl.exports=b:window.pl=b})()});function ime(t,e,r){t.prepend(r.map(o=>new Ta.default.type.State(e.goal.replace(o),e.substitution,e)))}function yH(t){let e=ome.get(t.session);if(e==null)throw new Error("Assertion failed: A project should have been registered for the active session");return e}function ame(t,e){ome.set(t,e),t.consult(`:- use_module(library(${zgt.id})).`)}var EH,Ta,sme,u0,Vgt,Jgt,ome,zgt,lme=Et(()=>{Ye();EH=$e(d2()),Ta=$e(mH()),sme=$e(ve("vm")),{is_atom:u0,is_variable:Vgt,is_instantiated_list:Jgt}=Ta.default.type;ome=new WeakMap;zgt=new Ta.default.type.Module("constraints",{["project_workspaces_by_descriptor/3"]:(t,e,r)=>{let[o,a,n]=r.args;if(!u0(o)||!u0(a)){t.throw_error(Ta.default.error.instantiation(r.indicator));return}let u=W.parseIdent(o.id),A=W.makeDescriptor(u,a.id),h=yH(t).tryWorkspaceByDescriptor(A);Vgt(n)&&h!==null&&ime(t,e,[new Ta.default.type.Term("=",[n,new Ta.default.type.Term(String(h.relativeCwd))])]),u0(n)&&h!==null&&h.relativeCwd===n.id&&t.success(e)},["workspace_field/3"]:(t,e,r)=>{let[o,a,n]=r.args;if(!u0(o)||!u0(a)){t.throw_error(Ta.default.error.instantiation(r.indicator));return}let A=yH(t).tryWorkspaceByCwd(o.id);if(A==null)return;let p=(0,EH.default)(A.manifest.raw,a.id);typeof p>"u"||ime(t,e,[new Ta.default.type.Term("=",[n,new Ta.default.type.Term(typeof p=="object"?JSON.stringify(p):p)])])},["workspace_field_test/3"]:(t,e,r)=>{let[o,a,n]=r.args;t.prepend([new Ta.default.type.State(e.goal.replace(new Ta.default.type.Term("workspace_field_test",[o,a,n,new Ta.default.type.Term("[]",[])])),e.substitution,e)])},["workspace_field_test/4"]:(t,e,r)=>{let[o,a,n,u]=r.args;if(!u0(o)||!u0(a)||!u0(n)||!Jgt(u)){t.throw_error(Ta.default.error.instantiation(r.indicator));return}let p=yH(t).tryWorkspaceByCwd(o.id);if(p==null)return;let h=(0,EH.default)(p.manifest.raw,a.id);if(typeof h>"u")return;let E={$$:h};for(let[v,x]of u.toJavaScript().entries())E[`$${v}`]=x;sme.default.runInNewContext(n.id,E)&&t.success(e)}},["project_workspaces_by_descriptor/3","workspace_field/3","workspace_field_test/3","workspace_field_test/4"])});var b2={};Vt(b2,{Constraints:()=>P2,DependencyType:()=>fme});function to(t){if(t instanceof DC.default.type.Num)return t.value;if(t instanceof DC.default.type.Term)switch(t.indicator){case"throw/1":return to(t.args[0]);case"error/1":return to(t.args[0]);case"error/2":if(t.args[0]instanceof DC.default.type.Term&&t.args[0].indicator==="syntax_error/1")return Object.assign(to(t.args[0]),...to(t.args[1]));{let e=to(t.args[0]);return e.message+=` (in ${to(t.args[1])})`,e}case"syntax_error/1":return new zt(43,`Syntax error: ${to(t.args[0])}`);case"existence_error/2":return new zt(44,`Existence error: ${to(t.args[0])} ${to(t.args[1])} not found`);case"instantiation_error/0":return new zt(75,"Instantiation error: an argument is variable when an instantiated argument was expected");case"line/1":return{line:to(t.args[0])};case"column/1":return{column:to(t.args[0])};case"found/1":return{found:to(t.args[0])};case"./2":return[to(t.args[0])].concat(to(t.args[1]));case"//2":return`${to(t.args[0])}/${to(t.args[1])}`;default:return t.id}throw`couldn't pretty print because of unsupported node ${t}`}function ume(t){let e;try{e=to(t)}catch(r){throw typeof r=="string"?new zt(42,`Unknown error: ${t} (note: ${r})`):r}return typeof e.line<"u"&&typeof e.column<"u"&&(e.message+=` at line ${e.line}, column ${e.column}`),e}function em(t){return t.id==="null"?null:`${t.toJavaScript()}`}function Xgt(t){if(t.id==="null")return null;{let e=t.toJavaScript();if(typeof e!="string")return JSON.stringify(e);try{return JSON.stringify(JSON.parse(e))}catch{return JSON.stringify(e)}}}function A0(t){return typeof t=="string"?`'${t}'`:"[]"}var Ame,DC,fme,cme,CH,P2,x2=Et(()=>{Ye();Ye();St();Ame=$e(jde()),DC=$e(mH());v2();lme();(0,Ame.default)(DC.default);fme=(o=>(o.Dependencies="dependencies",o.DevDependencies="devDependencies",o.PeerDependencies="peerDependencies",o))(fme||{}),cme=["dependencies","devDependencies","peerDependencies"];CH=class{constructor(e,r){let o=1e3*e.workspaces.length;this.session=DC.default.create(o),ame(this.session,e),this.session.consult(":- use_module(library(lists))."),this.session.consult(r)}fetchNextAnswer(){return new Promise(e=>{this.session.answer(r=>{e(r)})})}async*makeQuery(e){let r=this.session.query(e);if(r!==!0)throw ume(r);for(;;){let o=await this.fetchNextAnswer();if(o===null)throw new zt(79,"Resolution limit exceeded");if(!o)break;if(o.id==="throw")throw ume(o);yield o}}};P2=class{constructor(e){this.source="";this.project=e;let r=e.configuration.get("constraintsPath");oe.existsSync(r)&&(this.source=oe.readFileSync(r,"utf8"))}static async find(e){return new P2(e)}getProjectDatabase(){let e="";for(let r of cme)e+=`dependency_type(${r}). +`;for(let r of this.project.workspacesByCwd.values()){let o=r.relativeCwd;e+=`workspace(${A0(o)}). +`,e+=`workspace_ident(${A0(o)}, ${A0(W.stringifyIdent(r.anchoredLocator))}). +`,e+=`workspace_version(${A0(o)}, ${A0(r.manifest.version)}). +`;for(let a of cme)for(let n of r.manifest[a].values())e+=`workspace_has_dependency(${A0(o)}, ${A0(W.stringifyIdent(n))}, ${A0(n.range)}, ${a}). +`}return e+=`workspace(_) :- false. +`,e+=`workspace_ident(_, _) :- false. +`,e+=`workspace_version(_, _) :- false. +`,e+=`workspace_has_dependency(_, _, _, _) :- false. +`,e}getDeclarations(){let e="";return e+=`gen_enforced_dependency(_, _, _, _) :- false. +`,e+=`gen_enforced_field(_, _, _) :- false. +`,e}get fullSource(){return`${this.getProjectDatabase()} +${this.source} +${this.getDeclarations()}`}createSession(){return new CH(this.project,this.fullSource)}async processClassic(){let e=this.createSession();return{enforcedDependencies:await this.genEnforcedDependencies(e),enforcedFields:await this.genEnforcedFields(e)}}async process(){let{enforcedDependencies:e,enforcedFields:r}=await this.processClassic(),o=new Map;for(let{workspace:a,dependencyIdent:n,dependencyRange:u,dependencyType:A}of e){let p=B2([A,W.stringifyIdent(n)]),h=_e.getMapWithDefault(o,a.cwd);_e.getMapWithDefault(h,p).set(u??void 0,new Set)}for(let{workspace:a,fieldPath:n,fieldValue:u}of r){let A=B2(n),p=_e.getMapWithDefault(o,a.cwd);_e.getMapWithDefault(p,A).set(JSON.parse(u)??void 0,new Set)}return{manifestUpdates:o,reportedErrors:new Map}}async genEnforcedDependencies(e){let r=[];for await(let o of e.makeQuery("workspace(WorkspaceCwd), dependency_type(DependencyType), gen_enforced_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType).")){let a=V.resolve(this.project.cwd,em(o.links.WorkspaceCwd)),n=em(o.links.DependencyIdent),u=em(o.links.DependencyRange),A=em(o.links.DependencyType);if(a===null||n===null)throw new Error("Invalid rule");let p=this.project.getWorkspaceByCwd(a),h=W.parseIdent(n);r.push({workspace:p,dependencyIdent:h,dependencyRange:u,dependencyType:A})}return _e.sortMap(r,[({dependencyRange:o})=>o!==null?"0":"1",({workspace:o})=>W.stringifyIdent(o.anchoredLocator),({dependencyIdent:o})=>W.stringifyIdent(o)])}async genEnforcedFields(e){let r=[];for await(let o of e.makeQuery("workspace(WorkspaceCwd), gen_enforced_field(WorkspaceCwd, FieldPath, FieldValue).")){let a=V.resolve(this.project.cwd,em(o.links.WorkspaceCwd)),n=em(o.links.FieldPath),u=Xgt(o.links.FieldValue);if(a===null||n===null)throw new Error("Invalid rule");let A=this.project.getWorkspaceByCwd(a);r.push({workspace:A,fieldPath:n,fieldValue:u})}return _e.sortMap(r,[({workspace:o})=>W.stringifyIdent(o.anchoredLocator),({fieldPath:o})=>o])}async*query(e){let r=this.createSession();for await(let o of r.makeQuery(e)){let a={};for(let[n,u]of Object.entries(o.links))n!=="_"&&(a[n]=em(u));yield a}}}});var wme=_(Ik=>{"use strict";Object.defineProperty(Ik,"__esModule",{value:!0});function q2(t){let e=[...t.caches],r=e.shift();return r===void 0?Cme():{get(o,a,n={miss:()=>Promise.resolve()}){return r.get(o,a,n).catch(()=>q2({caches:e}).get(o,a,n))},set(o,a){return r.set(o,a).catch(()=>q2({caches:e}).set(o,a))},delete(o){return r.delete(o).catch(()=>q2({caches:e}).delete(o))},clear(){return r.clear().catch(()=>q2({caches:e}).clear())}}}function Cme(){return{get(t,e,r={miss:()=>Promise.resolve()}){return e().then(a=>Promise.all([a,r.miss(a)])).then(([a])=>a)},set(t,e){return Promise.resolve(e)},delete(t){return Promise.resolve()},clear(){return Promise.resolve()}}}Ik.createFallbackableCache=q2;Ik.createNullCache=Cme});var Bme=_((QWt,Ime)=>{Ime.exports=wme()});var vme=_(TH=>{"use strict";Object.defineProperty(TH,"__esModule",{value:!0});function ddt(t={serializable:!0}){let e={};return{get(r,o,a={miss:()=>Promise.resolve()}){let n=JSON.stringify(r);if(n in e)return Promise.resolve(t.serializable?JSON.parse(e[n]):e[n]);let u=o(),A=a&&a.miss||(()=>Promise.resolve());return u.then(p=>A(p)).then(()=>u)},set(r,o){return e[JSON.stringify(r)]=t.serializable?JSON.stringify(o):o,Promise.resolve(o)},delete(r){return delete e[JSON.stringify(r)],Promise.resolve()},clear(){return e={},Promise.resolve()}}}TH.createInMemoryCache=ddt});var Sme=_((RWt,Dme)=>{Dme.exports=vme()});var bme=_(Zc=>{"use strict";Object.defineProperty(Zc,"__esModule",{value:!0});function mdt(t,e,r){let o={"x-algolia-api-key":r,"x-algolia-application-id":e};return{headers(){return t===NH.WithinHeaders?o:{}},queryParameters(){return t===NH.WithinQueryParameters?o:{}}}}function ydt(t){let e=0,r=()=>(e++,new Promise(o=>{setTimeout(()=>{o(t(r))},Math.min(100*e,1e3))}));return t(r)}function Pme(t,e=(r,o)=>Promise.resolve()){return Object.assign(t,{wait(r){return Pme(t.then(o=>Promise.all([e(o,r),o])).then(o=>o[1]))}})}function Edt(t){let e=t.length-1;for(e;e>0;e--){let r=Math.floor(Math.random()*(e+1)),o=t[e];t[e]=t[r],t[r]=o}return t}function Cdt(t,e){return e&&Object.keys(e).forEach(r=>{t[r]=e[r](t)}),t}function wdt(t,...e){let r=0;return t.replace(/%s/g,()=>encodeURIComponent(e[r++]))}var Idt="4.14.2",Bdt=t=>()=>t.transporter.requester.destroy(),NH={WithinQueryParameters:0,WithinHeaders:1};Zc.AuthMode=NH;Zc.addMethods=Cdt;Zc.createAuth=mdt;Zc.createRetryablePromise=ydt;Zc.createWaitablePromise=Pme;Zc.destroy=Bdt;Zc.encode=wdt;Zc.shuffle=Edt;Zc.version=Idt});var Y2=_((NWt,xme)=>{xme.exports=bme()});var kme=_(LH=>{"use strict";Object.defineProperty(LH,"__esModule",{value:!0});var vdt={Delete:"DELETE",Get:"GET",Post:"POST",Put:"PUT"};LH.MethodEnum=vdt});var W2=_((OWt,Qme)=>{Qme.exports=kme()});var Wme=_(Fi=>{"use strict";Object.defineProperty(Fi,"__esModule",{value:!0});var Rme=W2();function OH(t,e){let r=t||{},o=r.data||{};return Object.keys(r).forEach(a=>{["timeout","headers","queryParameters","data","cacheable"].indexOf(a)===-1&&(o[a]=r[a])}),{data:Object.entries(o).length>0?o:void 0,timeout:r.timeout||e,headers:r.headers||{},queryParameters:r.queryParameters||{},cacheable:r.cacheable}}var K2={Read:1,Write:2,Any:3},xC={Up:1,Down:2,Timeouted:3},Tme=2*60*1e3;function UH(t,e=xC.Up){return{...t,status:e,lastUpdate:Date.now()}}function Nme(t){return t.status===xC.Up||Date.now()-t.lastUpdate>Tme}function Lme(t){return t.status===xC.Timeouted&&Date.now()-t.lastUpdate<=Tme}function _H(t){return typeof t=="string"?{protocol:"https",url:t,accept:K2.Any}:{protocol:t.protocol||"https",url:t.url,accept:t.accept||K2.Any}}function Ddt(t,e){return Promise.all(e.map(r=>t.get(r,()=>Promise.resolve(UH(r))))).then(r=>{let o=r.filter(A=>Nme(A)),a=r.filter(A=>Lme(A)),n=[...o,...a],u=n.length>0?n.map(A=>_H(A)):e;return{getTimeout(A,p){return(a.length===0&&A===0?1:a.length+3+A)*p},statelessHosts:u}})}var Sdt=({isTimedOut:t,status:e})=>!t&&~~e===0,Pdt=t=>{let e=t.status;return t.isTimedOut||Sdt(t)||~~(e/100)!==2&&~~(e/100)!==4},bdt=({status:t})=>~~(t/100)===2,xdt=(t,e)=>Pdt(t)?e.onRetry(t):bdt(t)?e.onSuccess(t):e.onFail(t);function Fme(t,e,r,o){let a=[],n=Hme(r,o),u=jme(t,o),A=r.method,p=r.method!==Rme.MethodEnum.Get?{}:{...r.data,...o.data},h={"x-algolia-agent":t.userAgent.value,...t.queryParameters,...p,...o.queryParameters},E=0,I=(v,x)=>{let C=v.pop();if(C===void 0)throw Yme(MH(a));let R={data:n,headers:u,method:A,url:Ume(C,r.path,h),connectTimeout:x(E,t.timeouts.connect),responseTimeout:x(E,o.timeout)},L=J=>{let te={request:R,response:J,host:C,triesLeft:v.length};return a.push(te),te},U={onSuccess:J=>Ome(J),onRetry(J){let te=L(J);return J.isTimedOut&&E++,Promise.all([t.logger.info("Retryable failure",HH(te)),t.hostsCache.set(C,UH(C,J.isTimedOut?xC.Timeouted:xC.Down))]).then(()=>I(v,x))},onFail(J){throw L(J),Mme(J,MH(a))}};return t.requester.send(R).then(J=>xdt(J,U))};return Ddt(t.hostsCache,e).then(v=>I([...v.statelessHosts].reverse(),v.getTimeout))}function kdt(t){let{hostsCache:e,logger:r,requester:o,requestsCache:a,responsesCache:n,timeouts:u,userAgent:A,hosts:p,queryParameters:h,headers:E}=t,I={hostsCache:e,logger:r,requester:o,requestsCache:a,responsesCache:n,timeouts:u,userAgent:A,headers:E,queryParameters:h,hosts:p.map(v=>_H(v)),read(v,x){let C=OH(x,I.timeouts.read),R=()=>Fme(I,I.hosts.filter(J=>(J.accept&K2.Read)!==0),v,C);if((C.cacheable!==void 0?C.cacheable:v.cacheable)!==!0)return R();let U={request:v,mappedRequestOptions:C,transporter:{queryParameters:I.queryParameters,headers:I.headers}};return I.responsesCache.get(U,()=>I.requestsCache.get(U,()=>I.requestsCache.set(U,R()).then(J=>Promise.all([I.requestsCache.delete(U),J]),J=>Promise.all([I.requestsCache.delete(U),Promise.reject(J)])).then(([J,te])=>te)),{miss:J=>I.responsesCache.set(U,J)})},write(v,x){return Fme(I,I.hosts.filter(C=>(C.accept&K2.Write)!==0),v,OH(x,I.timeouts.write))}};return I}function Qdt(t){let e={value:`Algolia for JavaScript (${t})`,add(r){let o=`; ${r.segment}${r.version!==void 0?` (${r.version})`:""}`;return e.value.indexOf(o)===-1&&(e.value=`${e.value}${o}`),e}};return e}function Ome(t){try{return JSON.parse(t.content)}catch(e){throw qme(e.message,t)}}function Mme({content:t,status:e},r){let o=t;try{o=JSON.parse(t).message}catch{}return Gme(o,e,r)}function Fdt(t,...e){let r=0;return t.replace(/%s/g,()=>encodeURIComponent(e[r++]))}function Ume(t,e,r){let o=_me(r),a=`${t.protocol}://${t.url}/${e.charAt(0)==="/"?e.substr(1):e}`;return o.length&&(a+=`?${o}`),a}function _me(t){let e=r=>Object.prototype.toString.call(r)==="[object Object]"||Object.prototype.toString.call(r)==="[object Array]";return Object.keys(t).map(r=>Fdt("%s=%s",r,e(t[r])?JSON.stringify(t[r]):t[r])).join("&")}function Hme(t,e){if(t.method===Rme.MethodEnum.Get||t.data===void 0&&e.data===void 0)return;let r=Array.isArray(t.data)?t.data:{...t.data,...e.data};return JSON.stringify(r)}function jme(t,e){let r={...t.headers,...e.headers},o={};return Object.keys(r).forEach(a=>{let n=r[a];o[a.toLowerCase()]=n}),o}function MH(t){return t.map(e=>HH(e))}function HH(t){let e=t.request.headers["x-algolia-api-key"]?{"x-algolia-api-key":"*****"}:{};return{...t,request:{...t.request,headers:{...t.request.headers,...e}}}}function Gme(t,e,r){return{name:"ApiError",message:t,status:e,transporterStackTrace:r}}function qme(t,e){return{name:"DeserializationError",message:t,response:e}}function Yme(t){return{name:"RetryError",message:"Unreachable hosts - your application id may be incorrect. If the error persists, contact support@algolia.com.",transporterStackTrace:t}}Fi.CallEnum=K2;Fi.HostStatusEnum=xC;Fi.createApiError=Gme;Fi.createDeserializationError=qme;Fi.createMappedRequestOptions=OH;Fi.createRetryError=Yme;Fi.createStatefulHost=UH;Fi.createStatelessHost=_H;Fi.createTransporter=kdt;Fi.createUserAgent=Qdt;Fi.deserializeFailure=Mme;Fi.deserializeSuccess=Ome;Fi.isStatefulHostTimeouted=Lme;Fi.isStatefulHostUp=Nme;Fi.serializeData=Hme;Fi.serializeHeaders=jme;Fi.serializeQueryParameters=_me;Fi.serializeUrl=Ume;Fi.stackFrameWithoutCredentials=HH;Fi.stackTraceWithoutCredentials=MH});var V2=_((UWt,Kme)=>{Kme.exports=Wme()});var Vme=_(m0=>{"use strict";Object.defineProperty(m0,"__esModule",{value:!0});var kC=Y2(),Rdt=V2(),J2=W2(),Tdt=t=>{let e=t.region||"us",r=kC.createAuth(kC.AuthMode.WithinHeaders,t.appId,t.apiKey),o=Rdt.createTransporter({hosts:[{url:`analytics.${e}.algolia.com`}],...t,headers:{...r.headers(),"content-type":"application/json",...t.headers},queryParameters:{...r.queryParameters(),...t.queryParameters}}),a=t.appId;return kC.addMethods({appId:a,transporter:o},t.methods)},Ndt=t=>(e,r)=>t.transporter.write({method:J2.MethodEnum.Post,path:"2/abtests",data:e},r),Ldt=t=>(e,r)=>t.transporter.write({method:J2.MethodEnum.Delete,path:kC.encode("2/abtests/%s",e)},r),Odt=t=>(e,r)=>t.transporter.read({method:J2.MethodEnum.Get,path:kC.encode("2/abtests/%s",e)},r),Mdt=t=>e=>t.transporter.read({method:J2.MethodEnum.Get,path:"2/abtests"},e),Udt=t=>(e,r)=>t.transporter.write({method:J2.MethodEnum.Post,path:kC.encode("2/abtests/%s/stop",e)},r);m0.addABTest=Ndt;m0.createAnalyticsClient=Tdt;m0.deleteABTest=Ldt;m0.getABTest=Odt;m0.getABTests=Mdt;m0.stopABTest=Udt});var zme=_((HWt,Jme)=>{Jme.exports=Vme()});var Zme=_(z2=>{"use strict";Object.defineProperty(z2,"__esModule",{value:!0});var jH=Y2(),_dt=V2(),Xme=W2(),Hdt=t=>{let e=t.region||"us",r=jH.createAuth(jH.AuthMode.WithinHeaders,t.appId,t.apiKey),o=_dt.createTransporter({hosts:[{url:`personalization.${e}.algolia.com`}],...t,headers:{...r.headers(),"content-type":"application/json",...t.headers},queryParameters:{...r.queryParameters(),...t.queryParameters}});return jH.addMethods({appId:t.appId,transporter:o},t.methods)},jdt=t=>e=>t.transporter.read({method:Xme.MethodEnum.Get,path:"1/strategies/personalization"},e),Gdt=t=>(e,r)=>t.transporter.write({method:Xme.MethodEnum.Post,path:"1/strategies/personalization",data:e},r);z2.createPersonalizationClient=Hdt;z2.getPersonalizationStrategy=jdt;z2.setPersonalizationStrategy=Gdt});var eye=_((GWt,$me)=>{$me.exports=Zme()});var hye=_(Ft=>{"use strict";Object.defineProperty(Ft,"__esModule",{value:!0});var qt=Y2(),Na=V2(),Ir=W2(),qdt=ve("crypto");function Bk(t){let e=r=>t.request(r).then(o=>{if(t.batch!==void 0&&t.batch(o.hits),!t.shouldStop(o))return o.cursor?e({cursor:o.cursor}):e({page:(r.page||0)+1})});return e({})}var Ydt=t=>{let e=t.appId,r=qt.createAuth(t.authMode!==void 0?t.authMode:qt.AuthMode.WithinHeaders,e,t.apiKey),o=Na.createTransporter({hosts:[{url:`${e}-dsn.algolia.net`,accept:Na.CallEnum.Read},{url:`${e}.algolia.net`,accept:Na.CallEnum.Write}].concat(qt.shuffle([{url:`${e}-1.algolianet.com`},{url:`${e}-2.algolianet.com`},{url:`${e}-3.algolianet.com`}])),...t,headers:{...r.headers(),"content-type":"application/x-www-form-urlencoded",...t.headers},queryParameters:{...r.queryParameters(),...t.queryParameters}}),a={transporter:o,appId:e,addAlgoliaAgent(n,u){o.userAgent.add({segment:n,version:u})},clearCache(){return Promise.all([o.requestsCache.clear(),o.responsesCache.clear()]).then(()=>{})}};return qt.addMethods(a,t.methods)};function tye(){return{name:"MissingObjectIDError",message:"All objects must have an unique objectID (like a primary key) to be valid. Algolia is also able to generate objectIDs automatically but *it's not recommended*. To do it, use the `{'autoGenerateObjectIDIfNotExist': true}` option."}}function rye(){return{name:"ObjectNotFoundError",message:"Object not found."}}function nye(){return{name:"ValidUntilNotFoundError",message:"ValidUntil not found in given secured api key."}}var Wdt=t=>(e,r)=>{let{queryParameters:o,...a}=r||{},n={acl:e,...o!==void 0?{queryParameters:o}:{}},u=(A,p)=>qt.createRetryablePromise(h=>X2(t)(A.key,p).catch(E=>{if(E.status!==404)throw E;return h()}));return qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:"1/keys",data:n},a),u)},Kdt=t=>(e,r,o)=>{let a=Na.createMappedRequestOptions(o);return a.queryParameters["X-Algolia-User-ID"]=e,t.transporter.write({method:Ir.MethodEnum.Post,path:"1/clusters/mapping",data:{cluster:r}},a)},Vdt=t=>(e,r,o)=>t.transporter.write({method:Ir.MethodEnum.Post,path:"1/clusters/mapping/batch",data:{users:e,cluster:r}},o),Jdt=t=>(e,r)=>qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:qt.encode("/1/dictionaries/%s/batch",e),data:{clearExistingDictionaryEntries:!0,requests:{action:"addEntry",body:[]}}},r),(o,a)=>QC(t)(o.taskID,a)),vk=t=>(e,r,o)=>{let a=(n,u)=>Z2(t)(e,{methods:{waitTask:Zi}}).waitTask(n.taskID,u);return qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:qt.encode("1/indexes/%s/operation",e),data:{operation:"copy",destination:r}},o),a)},zdt=t=>(e,r,o)=>vk(t)(e,r,{...o,scope:[Sk.Rules]}),Xdt=t=>(e,r,o)=>vk(t)(e,r,{...o,scope:[Sk.Settings]}),Zdt=t=>(e,r,o)=>vk(t)(e,r,{...o,scope:[Sk.Synonyms]}),$dt=t=>(e,r)=>e.method===Ir.MethodEnum.Get?t.transporter.read(e,r):t.transporter.write(e,r),emt=t=>(e,r)=>{let o=(a,n)=>qt.createRetryablePromise(u=>X2(t)(e,n).then(u).catch(A=>{if(A.status!==404)throw A}));return qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Delete,path:qt.encode("1/keys/%s",e)},r),o)},tmt=t=>(e,r,o)=>{let a=r.map(n=>({action:"deleteEntry",body:{objectID:n}}));return qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:qt.encode("/1/dictionaries/%s/batch",e),data:{clearExistingDictionaryEntries:!1,requests:a}},o),(n,u)=>QC(t)(n.taskID,u))},rmt=()=>(t,e)=>{let r=Na.serializeQueryParameters(e),o=qdt.createHmac("sha256",t).update(r).digest("hex");return Buffer.from(o+r).toString("base64")},X2=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Get,path:qt.encode("1/keys/%s",e)},r),iye=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Get,path:qt.encode("1/task/%s",e.toString())},r),nmt=t=>e=>t.transporter.read({method:Ir.MethodEnum.Get,path:"/1/dictionaries/*/settings"},e),imt=t=>e=>t.transporter.read({method:Ir.MethodEnum.Get,path:"1/logs"},e),smt=()=>t=>{let e=Buffer.from(t,"base64").toString("ascii"),r=/validUntil=(\d+)/,o=e.match(r);if(o===null)throw nye();return parseInt(o[1],10)-Math.round(new Date().getTime()/1e3)},omt=t=>e=>t.transporter.read({method:Ir.MethodEnum.Get,path:"1/clusters/mapping/top"},e),amt=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Get,path:qt.encode("1/clusters/mapping/%s",e)},r),lmt=t=>e=>{let{retrieveMappings:r,...o}=e||{};return r===!0&&(o.getClusters=!0),t.transporter.read({method:Ir.MethodEnum.Get,path:"1/clusters/mapping/pending"},o)},Z2=t=>(e,r={})=>{let o={transporter:t.transporter,appId:t.appId,indexName:e};return qt.addMethods(o,r.methods)},cmt=t=>e=>t.transporter.read({method:Ir.MethodEnum.Get,path:"1/keys"},e),umt=t=>e=>t.transporter.read({method:Ir.MethodEnum.Get,path:"1/clusters"},e),Amt=t=>e=>t.transporter.read({method:Ir.MethodEnum.Get,path:"1/indexes"},e),fmt=t=>e=>t.transporter.read({method:Ir.MethodEnum.Get,path:"1/clusters/mapping"},e),pmt=t=>(e,r,o)=>{let a=(n,u)=>Z2(t)(e,{methods:{waitTask:Zi}}).waitTask(n.taskID,u);return qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:qt.encode("1/indexes/%s/operation",e),data:{operation:"move",destination:r}},o),a)},hmt=t=>(e,r)=>{let o=(a,n)=>Promise.all(Object.keys(a.taskID).map(u=>Z2(t)(u,{methods:{waitTask:Zi}}).waitTask(a.taskID[u],n)));return qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:"1/indexes/*/batch",data:{requests:e}},r),o)},gmt=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Post,path:"1/indexes/*/objects",data:{requests:e}},r),dmt=t=>(e,r)=>{let o=e.map(a=>({...a,params:Na.serializeQueryParameters(a.params||{})}));return t.transporter.read({method:Ir.MethodEnum.Post,path:"1/indexes/*/queries",data:{requests:o},cacheable:!0},r)},mmt=t=>(e,r)=>Promise.all(e.map(o=>{let{facetName:a,facetQuery:n,...u}=o.params;return Z2(t)(o.indexName,{methods:{searchForFacetValues:Aye}}).searchForFacetValues(a,n,{...r,...u})})),ymt=t=>(e,r)=>{let o=Na.createMappedRequestOptions(r);return o.queryParameters["X-Algolia-User-ID"]=e,t.transporter.write({method:Ir.MethodEnum.Delete,path:"1/clusters/mapping"},o)},Emt=t=>(e,r,o)=>{let a=r.map(n=>({action:"addEntry",body:n}));return qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:qt.encode("/1/dictionaries/%s/batch",e),data:{clearExistingDictionaryEntries:!0,requests:a}},o),(n,u)=>QC(t)(n.taskID,u))},Cmt=t=>(e,r)=>{let o=(a,n)=>qt.createRetryablePromise(u=>X2(t)(e,n).catch(A=>{if(A.status!==404)throw A;return u()}));return qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:qt.encode("1/keys/%s/restore",e)},r),o)},wmt=t=>(e,r,o)=>{let a=r.map(n=>({action:"addEntry",body:n}));return qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:qt.encode("/1/dictionaries/%s/batch",e),data:{clearExistingDictionaryEntries:!1,requests:a}},o),(n,u)=>QC(t)(n.taskID,u))},Imt=t=>(e,r,o)=>t.transporter.read({method:Ir.MethodEnum.Post,path:qt.encode("/1/dictionaries/%s/search",e),data:{query:r},cacheable:!0},o),Bmt=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Post,path:"1/clusters/mapping/search",data:{query:e}},r),vmt=t=>(e,r)=>qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Put,path:"/1/dictionaries/*/settings",data:e},r),(o,a)=>QC(t)(o.taskID,a)),Dmt=t=>(e,r)=>{let o=Object.assign({},r),{queryParameters:a,...n}=r||{},u=a?{queryParameters:a}:{},A=["acl","indexes","referers","restrictSources","queryParameters","description","maxQueriesPerIPPerHour","maxHitsPerQuery"],p=E=>Object.keys(o).filter(I=>A.indexOf(I)!==-1).every(I=>E[I]===o[I]),h=(E,I)=>qt.createRetryablePromise(v=>X2(t)(e,I).then(x=>p(x)?Promise.resolve():v()));return qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Put,path:qt.encode("1/keys/%s",e),data:u},n),h)},QC=t=>(e,r)=>qt.createRetryablePromise(o=>iye(t)(e,r).then(a=>a.status!=="published"?o():void 0)),sye=t=>(e,r)=>{let o=(a,n)=>Zi(t)(a.taskID,n);return qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:qt.encode("1/indexes/%s/batch",t.indexName),data:{requests:e}},r),o)},Smt=t=>e=>Bk({shouldStop:r=>r.cursor===void 0,...e,request:r=>t.transporter.read({method:Ir.MethodEnum.Post,path:qt.encode("1/indexes/%s/browse",t.indexName),data:r},e)}),Pmt=t=>e=>{let r={hitsPerPage:1e3,...e};return Bk({shouldStop:o=>o.hits.length<r.hitsPerPage,...r,request(o){return fye(t)("",{...r,...o}).then(a=>({...a,hits:a.hits.map(n=>(delete n._highlightResult,n))}))}})},bmt=t=>e=>{let r={hitsPerPage:1e3,...e};return Bk({shouldStop:o=>o.hits.length<r.hitsPerPage,...r,request(o){return pye(t)("",{...r,...o}).then(a=>({...a,hits:a.hits.map(n=>(delete n._highlightResult,n))}))}})},Dk=t=>(e,r,o)=>{let{batchSize:a,...n}=o||{},u={taskIDs:[],objectIDs:[]},A=(p=0)=>{let h=[],E;for(E=p;E<e.length&&(h.push(e[E]),h.length!==(a||1e3));E++);return h.length===0?Promise.resolve(u):sye(t)(h.map(I=>({action:r,body:I})),n).then(I=>(u.objectIDs=u.objectIDs.concat(I.objectIDs),u.taskIDs.push(I.taskID),E++,A(E)))};return qt.createWaitablePromise(A(),(p,h)=>Promise.all(p.taskIDs.map(E=>Zi(t)(E,h))))},xmt=t=>e=>qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:qt.encode("1/indexes/%s/clear",t.indexName)},e),(r,o)=>Zi(t)(r.taskID,o)),kmt=t=>e=>{let{forwardToReplicas:r,...o}=e||{},a=Na.createMappedRequestOptions(o);return r&&(a.queryParameters.forwardToReplicas=1),qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:qt.encode("1/indexes/%s/rules/clear",t.indexName)},a),(n,u)=>Zi(t)(n.taskID,u))},Qmt=t=>e=>{let{forwardToReplicas:r,...o}=e||{},a=Na.createMappedRequestOptions(o);return r&&(a.queryParameters.forwardToReplicas=1),qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:qt.encode("1/indexes/%s/synonyms/clear",t.indexName)},a),(n,u)=>Zi(t)(n.taskID,u))},Fmt=t=>(e,r)=>qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:qt.encode("1/indexes/%s/deleteByQuery",t.indexName),data:e},r),(o,a)=>Zi(t)(o.taskID,a)),Rmt=t=>e=>qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Delete,path:qt.encode("1/indexes/%s",t.indexName)},e),(r,o)=>Zi(t)(r.taskID,o)),Tmt=t=>(e,r)=>qt.createWaitablePromise(oye(t)([e],r).then(o=>({taskID:o.taskIDs[0]})),(o,a)=>Zi(t)(o.taskID,a)),oye=t=>(e,r)=>{let o=e.map(a=>({objectID:a}));return Dk(t)(o,nm.DeleteObject,r)},Nmt=t=>(e,r)=>{let{forwardToReplicas:o,...a}=r||{},n=Na.createMappedRequestOptions(a);return o&&(n.queryParameters.forwardToReplicas=1),qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Delete,path:qt.encode("1/indexes/%s/rules/%s",t.indexName,e)},n),(u,A)=>Zi(t)(u.taskID,A))},Lmt=t=>(e,r)=>{let{forwardToReplicas:o,...a}=r||{},n=Na.createMappedRequestOptions(a);return o&&(n.queryParameters.forwardToReplicas=1),qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Delete,path:qt.encode("1/indexes/%s/synonyms/%s",t.indexName,e)},n),(u,A)=>Zi(t)(u.taskID,A))},Omt=t=>e=>aye(t)(e).then(()=>!0).catch(r=>{if(r.status!==404)throw r;return!1}),Mmt=t=>(e,r,o)=>t.transporter.read({method:Ir.MethodEnum.Post,path:qt.encode("1/answers/%s/prediction",t.indexName),data:{query:e,queryLanguages:r},cacheable:!0},o),Umt=t=>(e,r)=>{let{query:o,paginate:a,...n}=r||{},u=0,A=()=>uye(t)(o||"",{...n,page:u}).then(p=>{for(let[h,E]of Object.entries(p.hits))if(e(E))return{object:E,position:parseInt(h,10),page:u};if(u++,a===!1||u>=p.nbPages)throw rye();return A()});return A()},_mt=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Get,path:qt.encode("1/indexes/%s/%s",t.indexName,e)},r),Hmt=()=>(t,e)=>{for(let[r,o]of Object.entries(t.hits))if(o.objectID===e)return parseInt(r,10);return-1},jmt=t=>(e,r)=>{let{attributesToRetrieve:o,...a}=r||{},n=e.map(u=>({indexName:t.indexName,objectID:u,...o?{attributesToRetrieve:o}:{}}));return t.transporter.read({method:Ir.MethodEnum.Post,path:"1/indexes/*/objects",data:{requests:n}},a)},Gmt=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Get,path:qt.encode("1/indexes/%s/rules/%s",t.indexName,e)},r),aye=t=>e=>t.transporter.read({method:Ir.MethodEnum.Get,path:qt.encode("1/indexes/%s/settings",t.indexName),data:{getVersion:2}},e),qmt=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Get,path:qt.encode("1/indexes/%s/synonyms/%s",t.indexName,e)},r),lye=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Get,path:qt.encode("1/indexes/%s/task/%s",t.indexName,e.toString())},r),Ymt=t=>(e,r)=>qt.createWaitablePromise(cye(t)([e],r).then(o=>({objectID:o.objectIDs[0],taskID:o.taskIDs[0]})),(o,a)=>Zi(t)(o.taskID,a)),cye=t=>(e,r)=>{let{createIfNotExists:o,...a}=r||{},n=o?nm.PartialUpdateObject:nm.PartialUpdateObjectNoCreate;return Dk(t)(e,n,a)},Wmt=t=>(e,r)=>{let{safe:o,autoGenerateObjectIDIfNotExist:a,batchSize:n,...u}=r||{},A=(C,R,L,U)=>qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:qt.encode("1/indexes/%s/operation",C),data:{operation:L,destination:R}},U),(J,te)=>Zi(t)(J.taskID,te)),p=Math.random().toString(36).substring(7),h=`${t.indexName}_tmp_${p}`,E=GH({appId:t.appId,transporter:t.transporter,indexName:h}),I=[],v=A(t.indexName,h,"copy",{...u,scope:["settings","synonyms","rules"]});I.push(v);let x=(o?v.wait(u):v).then(()=>{let C=E(e,{...u,autoGenerateObjectIDIfNotExist:a,batchSize:n});return I.push(C),o?C.wait(u):C}).then(()=>{let C=A(h,t.indexName,"move",u);return I.push(C),o?C.wait(u):C}).then(()=>Promise.all(I)).then(([C,R,L])=>({objectIDs:R.objectIDs,taskIDs:[C.taskID,...R.taskIDs,L.taskID]}));return qt.createWaitablePromise(x,(C,R)=>Promise.all(I.map(L=>L.wait(R))))},Kmt=t=>(e,r)=>qH(t)(e,{...r,clearExistingRules:!0}),Vmt=t=>(e,r)=>YH(t)(e,{...r,clearExistingSynonyms:!0}),Jmt=t=>(e,r)=>qt.createWaitablePromise(GH(t)([e],r).then(o=>({objectID:o.objectIDs[0],taskID:o.taskIDs[0]})),(o,a)=>Zi(t)(o.taskID,a)),GH=t=>(e,r)=>{let{autoGenerateObjectIDIfNotExist:o,...a}=r||{},n=o?nm.AddObject:nm.UpdateObject;if(n===nm.UpdateObject){for(let u of e)if(u.objectID===void 0)return qt.createWaitablePromise(Promise.reject(tye()))}return Dk(t)(e,n,a)},zmt=t=>(e,r)=>qH(t)([e],r),qH=t=>(e,r)=>{let{forwardToReplicas:o,clearExistingRules:a,...n}=r||{},u=Na.createMappedRequestOptions(n);return o&&(u.queryParameters.forwardToReplicas=1),a&&(u.queryParameters.clearExistingRules=1),qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:qt.encode("1/indexes/%s/rules/batch",t.indexName),data:e},u),(A,p)=>Zi(t)(A.taskID,p))},Xmt=t=>(e,r)=>YH(t)([e],r),YH=t=>(e,r)=>{let{forwardToReplicas:o,clearExistingSynonyms:a,replaceExistingSynonyms:n,...u}=r||{},A=Na.createMappedRequestOptions(u);return o&&(A.queryParameters.forwardToReplicas=1),(n||a)&&(A.queryParameters.replaceExistingSynonyms=1),qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Post,path:qt.encode("1/indexes/%s/synonyms/batch",t.indexName),data:e},A),(p,h)=>Zi(t)(p.taskID,h))},uye=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Post,path:qt.encode("1/indexes/%s/query",t.indexName),data:{query:e},cacheable:!0},r),Aye=t=>(e,r,o)=>t.transporter.read({method:Ir.MethodEnum.Post,path:qt.encode("1/indexes/%s/facets/%s/query",t.indexName,e),data:{facetQuery:r},cacheable:!0},o),fye=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Post,path:qt.encode("1/indexes/%s/rules/search",t.indexName),data:{query:e}},r),pye=t=>(e,r)=>t.transporter.read({method:Ir.MethodEnum.Post,path:qt.encode("1/indexes/%s/synonyms/search",t.indexName),data:{query:e}},r),Zmt=t=>(e,r)=>{let{forwardToReplicas:o,...a}=r||{},n=Na.createMappedRequestOptions(a);return o&&(n.queryParameters.forwardToReplicas=1),qt.createWaitablePromise(t.transporter.write({method:Ir.MethodEnum.Put,path:qt.encode("1/indexes/%s/settings",t.indexName),data:e},n),(u,A)=>Zi(t)(u.taskID,A))},Zi=t=>(e,r)=>qt.createRetryablePromise(o=>lye(t)(e,r).then(a=>a.status!=="published"?o():void 0)),$mt={AddObject:"addObject",Analytics:"analytics",Browser:"browse",DeleteIndex:"deleteIndex",DeleteObject:"deleteObject",EditSettings:"editSettings",ListIndexes:"listIndexes",Logs:"logs",Personalization:"personalization",Recommendation:"recommendation",Search:"search",SeeUnretrievableAttributes:"seeUnretrievableAttributes",Settings:"settings",Usage:"usage"},nm={AddObject:"addObject",UpdateObject:"updateObject",PartialUpdateObject:"partialUpdateObject",PartialUpdateObjectNoCreate:"partialUpdateObjectNoCreate",DeleteObject:"deleteObject",DeleteIndex:"delete",ClearIndex:"clear"},Sk={Settings:"settings",Synonyms:"synonyms",Rules:"rules"},eyt={None:"none",StopIfEnoughMatches:"stopIfEnoughMatches"},tyt={Synonym:"synonym",OneWaySynonym:"oneWaySynonym",AltCorrection1:"altCorrection1",AltCorrection2:"altCorrection2",Placeholder:"placeholder"};Ft.ApiKeyACLEnum=$mt;Ft.BatchActionEnum=nm;Ft.ScopeEnum=Sk;Ft.StrategyEnum=eyt;Ft.SynonymEnum=tyt;Ft.addApiKey=Wdt;Ft.assignUserID=Kdt;Ft.assignUserIDs=Vdt;Ft.batch=sye;Ft.browseObjects=Smt;Ft.browseRules=Pmt;Ft.browseSynonyms=bmt;Ft.chunkedBatch=Dk;Ft.clearDictionaryEntries=Jdt;Ft.clearObjects=xmt;Ft.clearRules=kmt;Ft.clearSynonyms=Qmt;Ft.copyIndex=vk;Ft.copyRules=zdt;Ft.copySettings=Xdt;Ft.copySynonyms=Zdt;Ft.createBrowsablePromise=Bk;Ft.createMissingObjectIDError=tye;Ft.createObjectNotFoundError=rye;Ft.createSearchClient=Ydt;Ft.createValidUntilNotFoundError=nye;Ft.customRequest=$dt;Ft.deleteApiKey=emt;Ft.deleteBy=Fmt;Ft.deleteDictionaryEntries=tmt;Ft.deleteIndex=Rmt;Ft.deleteObject=Tmt;Ft.deleteObjects=oye;Ft.deleteRule=Nmt;Ft.deleteSynonym=Lmt;Ft.exists=Omt;Ft.findAnswers=Mmt;Ft.findObject=Umt;Ft.generateSecuredApiKey=rmt;Ft.getApiKey=X2;Ft.getAppTask=iye;Ft.getDictionarySettings=nmt;Ft.getLogs=imt;Ft.getObject=_mt;Ft.getObjectPosition=Hmt;Ft.getObjects=jmt;Ft.getRule=Gmt;Ft.getSecuredApiKeyRemainingValidity=smt;Ft.getSettings=aye;Ft.getSynonym=qmt;Ft.getTask=lye;Ft.getTopUserIDs=omt;Ft.getUserID=amt;Ft.hasPendingMappings=lmt;Ft.initIndex=Z2;Ft.listApiKeys=cmt;Ft.listClusters=umt;Ft.listIndices=Amt;Ft.listUserIDs=fmt;Ft.moveIndex=pmt;Ft.multipleBatch=hmt;Ft.multipleGetObjects=gmt;Ft.multipleQueries=dmt;Ft.multipleSearchForFacetValues=mmt;Ft.partialUpdateObject=Ymt;Ft.partialUpdateObjects=cye;Ft.removeUserID=ymt;Ft.replaceAllObjects=Wmt;Ft.replaceAllRules=Kmt;Ft.replaceAllSynonyms=Vmt;Ft.replaceDictionaryEntries=Emt;Ft.restoreApiKey=Cmt;Ft.saveDictionaryEntries=wmt;Ft.saveObject=Jmt;Ft.saveObjects=GH;Ft.saveRule=zmt;Ft.saveRules=qH;Ft.saveSynonym=Xmt;Ft.saveSynonyms=YH;Ft.search=uye;Ft.searchDictionaryEntries=Imt;Ft.searchForFacetValues=Aye;Ft.searchRules=fye;Ft.searchSynonyms=pye;Ft.searchUserIDs=Bmt;Ft.setDictionarySettings=vmt;Ft.setSettings=Zmt;Ft.updateApiKey=Dmt;Ft.waitAppTask=QC;Ft.waitTask=Zi});var dye=_((YWt,gye)=>{gye.exports=hye()});var mye=_(Pk=>{"use strict";Object.defineProperty(Pk,"__esModule",{value:!0});function ryt(){return{debug(t,e){return Promise.resolve()},info(t,e){return Promise.resolve()},error(t,e){return Promise.resolve()}}}var nyt={Debug:1,Info:2,Error:3};Pk.LogLevelEnum=nyt;Pk.createNullLogger=ryt});var Eye=_((KWt,yye)=>{yye.exports=mye()});var Bye=_(WH=>{"use strict";Object.defineProperty(WH,"__esModule",{value:!0});var Cye=ve("http"),wye=ve("https"),iyt=ve("url"),Iye={keepAlive:!0},syt=new Cye.Agent(Iye),oyt=new wye.Agent(Iye);function ayt({agent:t,httpAgent:e,httpsAgent:r,requesterOptions:o={}}={}){let a=e||t||syt,n=r||t||oyt;return{send(u){return new Promise(A=>{let p=iyt.parse(u.url),h=p.query===null?p.pathname:`${p.pathname}?${p.query}`,E={...o,agent:p.protocol==="https:"?n:a,hostname:p.hostname,path:h,method:u.method,headers:{...o&&o.headers?o.headers:{},...u.headers},...p.port!==void 0?{port:p.port||""}:{}},I=(p.protocol==="https:"?wye:Cye).request(E,R=>{let L=[];R.on("data",U=>{L=L.concat(U)}),R.on("end",()=>{clearTimeout(x),clearTimeout(C),A({status:R.statusCode||0,content:Buffer.concat(L).toString(),isTimedOut:!1})})}),v=(R,L)=>setTimeout(()=>{I.abort(),A({status:0,content:L,isTimedOut:!0})},R*1e3),x=v(u.connectTimeout,"Connection timeout"),C;I.on("error",R=>{clearTimeout(x),clearTimeout(C),A({status:0,content:R.message,isTimedOut:!1})}),I.once("response",()=>{clearTimeout(x),C=v(u.responseTimeout,"Socket timeout")}),u.data!==void 0&&I.write(u.data),I.end()})},destroy(){return a.destroy(),n.destroy(),Promise.resolve()}}}WH.createNodeHttpRequester=ayt});var Dye=_((JWt,vye)=>{vye.exports=Bye()});var xye=_((zWt,bye)=>{"use strict";var Sye=Bme(),lyt=Sme(),FC=zme(),VH=Y2(),KH=eye(),Ut=dye(),cyt=Eye(),uyt=Dye(),Ayt=V2();function Pye(t,e,r){let o={appId:t,apiKey:e,timeouts:{connect:2,read:5,write:30},requester:uyt.createNodeHttpRequester(),logger:cyt.createNullLogger(),responsesCache:Sye.createNullCache(),requestsCache:Sye.createNullCache(),hostsCache:lyt.createInMemoryCache(),userAgent:Ayt.createUserAgent(VH.version).add({segment:"Node.js",version:process.versions.node})},a={...o,...r},n=()=>u=>KH.createPersonalizationClient({...o,...u,methods:{getPersonalizationStrategy:KH.getPersonalizationStrategy,setPersonalizationStrategy:KH.setPersonalizationStrategy}});return Ut.createSearchClient({...a,methods:{search:Ut.multipleQueries,searchForFacetValues:Ut.multipleSearchForFacetValues,multipleBatch:Ut.multipleBatch,multipleGetObjects:Ut.multipleGetObjects,multipleQueries:Ut.multipleQueries,copyIndex:Ut.copyIndex,copySettings:Ut.copySettings,copyRules:Ut.copyRules,copySynonyms:Ut.copySynonyms,moveIndex:Ut.moveIndex,listIndices:Ut.listIndices,getLogs:Ut.getLogs,listClusters:Ut.listClusters,multipleSearchForFacetValues:Ut.multipleSearchForFacetValues,getApiKey:Ut.getApiKey,addApiKey:Ut.addApiKey,listApiKeys:Ut.listApiKeys,updateApiKey:Ut.updateApiKey,deleteApiKey:Ut.deleteApiKey,restoreApiKey:Ut.restoreApiKey,assignUserID:Ut.assignUserID,assignUserIDs:Ut.assignUserIDs,getUserID:Ut.getUserID,searchUserIDs:Ut.searchUserIDs,listUserIDs:Ut.listUserIDs,getTopUserIDs:Ut.getTopUserIDs,removeUserID:Ut.removeUserID,hasPendingMappings:Ut.hasPendingMappings,generateSecuredApiKey:Ut.generateSecuredApiKey,getSecuredApiKeyRemainingValidity:Ut.getSecuredApiKeyRemainingValidity,destroy:VH.destroy,clearDictionaryEntries:Ut.clearDictionaryEntries,deleteDictionaryEntries:Ut.deleteDictionaryEntries,getDictionarySettings:Ut.getDictionarySettings,getAppTask:Ut.getAppTask,replaceDictionaryEntries:Ut.replaceDictionaryEntries,saveDictionaryEntries:Ut.saveDictionaryEntries,searchDictionaryEntries:Ut.searchDictionaryEntries,setDictionarySettings:Ut.setDictionarySettings,waitAppTask:Ut.waitAppTask,customRequest:Ut.customRequest,initIndex:u=>A=>Ut.initIndex(u)(A,{methods:{batch:Ut.batch,delete:Ut.deleteIndex,findAnswers:Ut.findAnswers,getObject:Ut.getObject,getObjects:Ut.getObjects,saveObject:Ut.saveObject,saveObjects:Ut.saveObjects,search:Ut.search,searchForFacetValues:Ut.searchForFacetValues,waitTask:Ut.waitTask,setSettings:Ut.setSettings,getSettings:Ut.getSettings,partialUpdateObject:Ut.partialUpdateObject,partialUpdateObjects:Ut.partialUpdateObjects,deleteObject:Ut.deleteObject,deleteObjects:Ut.deleteObjects,deleteBy:Ut.deleteBy,clearObjects:Ut.clearObjects,browseObjects:Ut.browseObjects,getObjectPosition:Ut.getObjectPosition,findObject:Ut.findObject,exists:Ut.exists,saveSynonym:Ut.saveSynonym,saveSynonyms:Ut.saveSynonyms,getSynonym:Ut.getSynonym,searchSynonyms:Ut.searchSynonyms,browseSynonyms:Ut.browseSynonyms,deleteSynonym:Ut.deleteSynonym,clearSynonyms:Ut.clearSynonyms,replaceAllObjects:Ut.replaceAllObjects,replaceAllSynonyms:Ut.replaceAllSynonyms,searchRules:Ut.searchRules,getRule:Ut.getRule,deleteRule:Ut.deleteRule,saveRule:Ut.saveRule,saveRules:Ut.saveRules,replaceAllRules:Ut.replaceAllRules,browseRules:Ut.browseRules,clearRules:Ut.clearRules}}),initAnalytics:()=>u=>FC.createAnalyticsClient({...o,...u,methods:{addABTest:FC.addABTest,getABTest:FC.getABTest,getABTests:FC.getABTests,stopABTest:FC.stopABTest,deleteABTest:FC.deleteABTest}}),initPersonalization:n,initRecommendation:()=>u=>(a.logger.info("The `initRecommendation` method is deprecated. Use `initPersonalization` instead."),n()(u))}})}Pye.version=VH.version;bye.exports=Pye});var zH=_((XWt,JH)=>{var kye=xye();JH.exports=kye;JH.exports.default=kye});var $H=_(($Wt,Rye)=>{"use strict";var Fye=Object.getOwnPropertySymbols,pyt=Object.prototype.hasOwnProperty,hyt=Object.prototype.propertyIsEnumerable;function gyt(t){if(t==null)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(t)}function dyt(){try{if(!Object.assign)return!1;var t=new String("abc");if(t[5]="de",Object.getOwnPropertyNames(t)[0]==="5")return!1;for(var e={},r=0;r<10;r++)e["_"+String.fromCharCode(r)]=r;var o=Object.getOwnPropertyNames(e).map(function(n){return e[n]});if(o.join("")!=="0123456789")return!1;var a={};return"abcdefghijklmnopqrst".split("").forEach(function(n){a[n]=n}),Object.keys(Object.assign({},a)).join("")==="abcdefghijklmnopqrst"}catch{return!1}}Rye.exports=dyt()?Object.assign:function(t,e){for(var r,o=gyt(t),a,n=1;n<arguments.length;n++){r=Object(arguments[n]);for(var u in r)pyt.call(r,u)&&(o[u]=r[u]);if(Fye){a=Fye(r);for(var A=0;A<a.length;A++)hyt.call(r,a[A])&&(o[a[A]]=r[a[A]])}}return o}});var Yye=_(Nn=>{"use strict";var i6=$H(),$c=typeof Symbol=="function"&&Symbol.for,$2=$c?Symbol.for("react.element"):60103,myt=$c?Symbol.for("react.portal"):60106,yyt=$c?Symbol.for("react.fragment"):60107,Eyt=$c?Symbol.for("react.strict_mode"):60108,Cyt=$c?Symbol.for("react.profiler"):60114,wyt=$c?Symbol.for("react.provider"):60109,Iyt=$c?Symbol.for("react.context"):60110,Byt=$c?Symbol.for("react.forward_ref"):60112,vyt=$c?Symbol.for("react.suspense"):60113,Dyt=$c?Symbol.for("react.memo"):60115,Syt=$c?Symbol.for("react.lazy"):60116,Tye=typeof Symbol=="function"&&Symbol.iterator;function eB(t){for(var e="https://reactjs.org/docs/error-decoder.html?invariant="+t,r=1;r<arguments.length;r++)e+="&args[]="+encodeURIComponent(arguments[r]);return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}var Nye={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Lye={};function RC(t,e,r){this.props=t,this.context=e,this.refs=Lye,this.updater=r||Nye}RC.prototype.isReactComponent={};RC.prototype.setState=function(t,e){if(typeof t!="object"&&typeof t!="function"&&t!=null)throw Error(eB(85));this.updater.enqueueSetState(this,t,e,"setState")};RC.prototype.forceUpdate=function(t){this.updater.enqueueForceUpdate(this,t,"forceUpdate")};function Oye(){}Oye.prototype=RC.prototype;function s6(t,e,r){this.props=t,this.context=e,this.refs=Lye,this.updater=r||Nye}var o6=s6.prototype=new Oye;o6.constructor=s6;i6(o6,RC.prototype);o6.isPureReactComponent=!0;var a6={current:null},Mye=Object.prototype.hasOwnProperty,Uye={key:!0,ref:!0,__self:!0,__source:!0};function _ye(t,e,r){var o,a={},n=null,u=null;if(e!=null)for(o in e.ref!==void 0&&(u=e.ref),e.key!==void 0&&(n=""+e.key),e)Mye.call(e,o)&&!Uye.hasOwnProperty(o)&&(a[o]=e[o]);var A=arguments.length-2;if(A===1)a.children=r;else if(1<A){for(var p=Array(A),h=0;h<A;h++)p[h]=arguments[h+2];a.children=p}if(t&&t.defaultProps)for(o in A=t.defaultProps,A)a[o]===void 0&&(a[o]=A[o]);return{$$typeof:$2,type:t,key:n,ref:u,props:a,_owner:a6.current}}function Pyt(t,e){return{$$typeof:$2,type:t.type,key:e,ref:t.ref,props:t.props,_owner:t._owner}}function l6(t){return typeof t=="object"&&t!==null&&t.$$typeof===$2}function byt(t){var e={"=":"=0",":":"=2"};return"$"+(""+t).replace(/[=:]/g,function(r){return e[r]})}var Hye=/\/+/g,bk=[];function jye(t,e,r,o){if(bk.length){var a=bk.pop();return a.result=t,a.keyPrefix=e,a.func=r,a.context=o,a.count=0,a}return{result:t,keyPrefix:e,func:r,context:o,count:0}}function Gye(t){t.result=null,t.keyPrefix=null,t.func=null,t.context=null,t.count=0,10>bk.length&&bk.push(t)}function t6(t,e,r,o){var a=typeof t;(a==="undefined"||a==="boolean")&&(t=null);var n=!1;if(t===null)n=!0;else switch(a){case"string":case"number":n=!0;break;case"object":switch(t.$$typeof){case $2:case myt:n=!0}}if(n)return r(o,t,e===""?"."+e6(t,0):e),1;if(n=0,e=e===""?".":e+":",Array.isArray(t))for(var u=0;u<t.length;u++){a=t[u];var A=e+e6(a,u);n+=t6(a,A,r,o)}else if(t===null||typeof t!="object"?A=null:(A=Tye&&t[Tye]||t["@@iterator"],A=typeof A=="function"?A:null),typeof A=="function")for(t=A.call(t),u=0;!(a=t.next()).done;)a=a.value,A=e+e6(a,u++),n+=t6(a,A,r,o);else if(a==="object")throw r=""+t,Error(eB(31,r==="[object Object]"?"object with keys {"+Object.keys(t).join(", ")+"}":r,""));return n}function r6(t,e,r){return t==null?0:t6(t,"",e,r)}function e6(t,e){return typeof t=="object"&&t!==null&&t.key!=null?byt(t.key):e.toString(36)}function xyt(t,e){t.func.call(t.context,e,t.count++)}function kyt(t,e,r){var o=t.result,a=t.keyPrefix;t=t.func.call(t.context,e,t.count++),Array.isArray(t)?n6(t,o,r,function(n){return n}):t!=null&&(l6(t)&&(t=Pyt(t,a+(!t.key||e&&e.key===t.key?"":(""+t.key).replace(Hye,"$&/")+"/")+r)),o.push(t))}function n6(t,e,r,o,a){var n="";r!=null&&(n=(""+r).replace(Hye,"$&/")+"/"),e=jye(e,n,o,a),r6(t,kyt,e),Gye(e)}var qye={current:null};function Xf(){var t=qye.current;if(t===null)throw Error(eB(321));return t}var Qyt={ReactCurrentDispatcher:qye,ReactCurrentBatchConfig:{suspense:null},ReactCurrentOwner:a6,IsSomeRendererActing:{current:!1},assign:i6};Nn.Children={map:function(t,e,r){if(t==null)return t;var o=[];return n6(t,o,null,e,r),o},forEach:function(t,e,r){if(t==null)return t;e=jye(null,null,e,r),r6(t,xyt,e),Gye(e)},count:function(t){return r6(t,function(){return null},null)},toArray:function(t){var e=[];return n6(t,e,null,function(r){return r}),e},only:function(t){if(!l6(t))throw Error(eB(143));return t}};Nn.Component=RC;Nn.Fragment=yyt;Nn.Profiler=Cyt;Nn.PureComponent=s6;Nn.StrictMode=Eyt;Nn.Suspense=vyt;Nn.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=Qyt;Nn.cloneElement=function(t,e,r){if(t==null)throw Error(eB(267,t));var o=i6({},t.props),a=t.key,n=t.ref,u=t._owner;if(e!=null){if(e.ref!==void 0&&(n=e.ref,u=a6.current),e.key!==void 0&&(a=""+e.key),t.type&&t.type.defaultProps)var A=t.type.defaultProps;for(p in e)Mye.call(e,p)&&!Uye.hasOwnProperty(p)&&(o[p]=e[p]===void 0&&A!==void 0?A[p]:e[p])}var p=arguments.length-2;if(p===1)o.children=r;else if(1<p){A=Array(p);for(var h=0;h<p;h++)A[h]=arguments[h+2];o.children=A}return{$$typeof:$2,type:t.type,key:a,ref:n,props:o,_owner:u}};Nn.createContext=function(t,e){return e===void 0&&(e=null),t={$$typeof:Iyt,_calculateChangedBits:e,_currentValue:t,_currentValue2:t,_threadCount:0,Provider:null,Consumer:null},t.Provider={$$typeof:wyt,_context:t},t.Consumer=t};Nn.createElement=_ye;Nn.createFactory=function(t){var e=_ye.bind(null,t);return e.type=t,e};Nn.createRef=function(){return{current:null}};Nn.forwardRef=function(t){return{$$typeof:Byt,render:t}};Nn.isValidElement=l6;Nn.lazy=function(t){return{$$typeof:Syt,_ctor:t,_status:-1,_result:null}};Nn.memo=function(t,e){return{$$typeof:Dyt,type:t,compare:e===void 0?null:e}};Nn.useCallback=function(t,e){return Xf().useCallback(t,e)};Nn.useContext=function(t,e){return Xf().useContext(t,e)};Nn.useDebugValue=function(){};Nn.useEffect=function(t,e){return Xf().useEffect(t,e)};Nn.useImperativeHandle=function(t,e,r){return Xf().useImperativeHandle(t,e,r)};Nn.useLayoutEffect=function(t,e){return Xf().useLayoutEffect(t,e)};Nn.useMemo=function(t,e){return Xf().useMemo(t,e)};Nn.useReducer=function(t,e,r){return Xf().useReducer(t,e,r)};Nn.useRef=function(t){return Xf().useRef(t)};Nn.useState=function(t){return Xf().useState(t)};Nn.version="16.13.1"});var on=_((tKt,Wye)=>{"use strict";Wye.exports=Yye()});var u6=_((rKt,c6)=>{"use strict";var An=c6.exports;c6.exports.default=An;var Ln="\x1B[",tB="\x1B]",TC="\x07",xk=";",Kye=process.env.TERM_PROGRAM==="Apple_Terminal";An.cursorTo=(t,e)=>{if(typeof t!="number")throw new TypeError("The `x` argument is required");return typeof e!="number"?Ln+(t+1)+"G":Ln+(e+1)+";"+(t+1)+"H"};An.cursorMove=(t,e)=>{if(typeof t!="number")throw new TypeError("The `x` argument is required");let r="";return t<0?r+=Ln+-t+"D":t>0&&(r+=Ln+t+"C"),e<0?r+=Ln+-e+"A":e>0&&(r+=Ln+e+"B"),r};An.cursorUp=(t=1)=>Ln+t+"A";An.cursorDown=(t=1)=>Ln+t+"B";An.cursorForward=(t=1)=>Ln+t+"C";An.cursorBackward=(t=1)=>Ln+t+"D";An.cursorLeft=Ln+"G";An.cursorSavePosition=Kye?"\x1B7":Ln+"s";An.cursorRestorePosition=Kye?"\x1B8":Ln+"u";An.cursorGetPosition=Ln+"6n";An.cursorNextLine=Ln+"E";An.cursorPrevLine=Ln+"F";An.cursorHide=Ln+"?25l";An.cursorShow=Ln+"?25h";An.eraseLines=t=>{let e="";for(let r=0;r<t;r++)e+=An.eraseLine+(r<t-1?An.cursorUp():"");return t&&(e+=An.cursorLeft),e};An.eraseEndLine=Ln+"K";An.eraseStartLine=Ln+"1K";An.eraseLine=Ln+"2K";An.eraseDown=Ln+"J";An.eraseUp=Ln+"1J";An.eraseScreen=Ln+"2J";An.scrollUp=Ln+"S";An.scrollDown=Ln+"T";An.clearScreen="\x1Bc";An.clearTerminal=process.platform==="win32"?`${An.eraseScreen}${Ln}0f`:`${An.eraseScreen}${Ln}3J${Ln}H`;An.beep=TC;An.link=(t,e)=>[tB,"8",xk,xk,e,TC,t,tB,"8",xk,xk,TC].join("");An.image=(t,e={})=>{let r=`${tB}1337;File=inline=1`;return e.width&&(r+=`;width=${e.width}`),e.height&&(r+=`;height=${e.height}`),e.preserveAspectRatio===!1&&(r+=";preserveAspectRatio=0"),r+":"+t.toString("base64")+TC};An.iTerm={setCwd:(t=process.cwd())=>`${tB}50;CurrentDir=${t}${TC}`,annotation:(t,e={})=>{let r=`${tB}1337;`,o=typeof e.x<"u",a=typeof e.y<"u";if((o||a)&&!(o&&a&&typeof e.length<"u"))throw new Error("`x`, `y` and `length` must be defined when `x` or `y` is defined");return t=t.replace(/\|/g,""),r+=e.isHidden?"AddHiddenAnnotation=":"AddAnnotation=",e.length>0?r+=(o?[t,e.length,e.x,e.y]:[e.length,t]).join("|"):r+=t,r+TC}}});var Jye=_((nKt,A6)=>{"use strict";var Vye=(t,e)=>{for(let r of Reflect.ownKeys(e))Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(e,r));return t};A6.exports=Vye;A6.exports.default=Vye});var Xye=_((iKt,Qk)=>{"use strict";var Fyt=Jye(),kk=new WeakMap,zye=(t,e={})=>{if(typeof t!="function")throw new TypeError("Expected a function");let r,o=0,a=t.displayName||t.name||"<anonymous>",n=function(...u){if(kk.set(n,++o),o===1)r=t.apply(this,u),t=null;else if(e.throw===!0)throw new Error(`Function \`${a}\` can only be called once`);return r};return Fyt(n,t),kk.set(n,o),n};Qk.exports=zye;Qk.exports.default=zye;Qk.exports.callCount=t=>{if(!kk.has(t))throw new Error(`The given function \`${t.name}\` is not wrapped by the \`onetime\` package`);return kk.get(t)}});var Zye=_((sKt,Fk)=>{Fk.exports=["SIGABRT","SIGALRM","SIGHUP","SIGINT","SIGTERM"];process.platform!=="win32"&&Fk.exports.push("SIGVTALRM","SIGXCPU","SIGXFSZ","SIGUSR2","SIGTRAP","SIGSYS","SIGQUIT","SIGIOT");process.platform==="linux"&&Fk.exports.push("SIGIO","SIGPOLL","SIGPWR","SIGSTKFLT","SIGUNUSED")});var h6=_((oKt,OC)=>{var Ei=global.process,im=function(t){return t&&typeof t=="object"&&typeof t.removeListener=="function"&&typeof t.emit=="function"&&typeof t.reallyExit=="function"&&typeof t.listeners=="function"&&typeof t.kill=="function"&&typeof t.pid=="number"&&typeof t.on=="function"};im(Ei)?($ye=ve("assert"),NC=Zye(),eEe=/^win/i.test(Ei.platform),rB=ve("events"),typeof rB!="function"&&(rB=rB.EventEmitter),Ei.__signal_exit_emitter__?Ts=Ei.__signal_exit_emitter__:(Ts=Ei.__signal_exit_emitter__=new rB,Ts.count=0,Ts.emitted={}),Ts.infinite||(Ts.setMaxListeners(1/0),Ts.infinite=!0),OC.exports=function(t,e){if(!im(global.process))return function(){};$ye.equal(typeof t,"function","a callback must be provided for exit handler"),LC===!1&&f6();var r="exit";e&&e.alwaysLast&&(r="afterexit");var o=function(){Ts.removeListener(r,t),Ts.listeners("exit").length===0&&Ts.listeners("afterexit").length===0&&Rk()};return Ts.on(r,t),o},Rk=function(){!LC||!im(global.process)||(LC=!1,NC.forEach(function(e){try{Ei.removeListener(e,Tk[e])}catch{}}),Ei.emit=Nk,Ei.reallyExit=p6,Ts.count-=1)},OC.exports.unload=Rk,sm=function(e,r,o){Ts.emitted[e]||(Ts.emitted[e]=!0,Ts.emit(e,r,o))},Tk={},NC.forEach(function(t){Tk[t]=function(){if(!!im(global.process)){var r=Ei.listeners(t);r.length===Ts.count&&(Rk(),sm("exit",null,t),sm("afterexit",null,t),eEe&&t==="SIGHUP"&&(t="SIGINT"),Ei.kill(Ei.pid,t))}}}),OC.exports.signals=function(){return NC},LC=!1,f6=function(){LC||!im(global.process)||(LC=!0,Ts.count+=1,NC=NC.filter(function(e){try{return Ei.on(e,Tk[e]),!0}catch{return!1}}),Ei.emit=rEe,Ei.reallyExit=tEe)},OC.exports.load=f6,p6=Ei.reallyExit,tEe=function(e){!im(global.process)||(Ei.exitCode=e||0,sm("exit",Ei.exitCode,null),sm("afterexit",Ei.exitCode,null),p6.call(Ei,Ei.exitCode))},Nk=Ei.emit,rEe=function(e,r){if(e==="exit"&&im(global.process)){r!==void 0&&(Ei.exitCode=r);var o=Nk.apply(this,arguments);return sm("exit",Ei.exitCode,null),sm("afterexit",Ei.exitCode,null),o}else return Nk.apply(this,arguments)}):OC.exports=function(){return function(){}};var $ye,NC,eEe,rB,Ts,Rk,sm,Tk,LC,f6,p6,tEe,Nk,rEe});var iEe=_((aKt,nEe)=>{"use strict";var Ryt=Xye(),Tyt=h6();nEe.exports=Ryt(()=>{Tyt(()=>{process.stderr.write("\x1B[?25h")},{alwaysLast:!0})})});var g6=_(MC=>{"use strict";var Nyt=iEe(),Lk=!1;MC.show=(t=process.stderr)=>{!t.isTTY||(Lk=!1,t.write("\x1B[?25h"))};MC.hide=(t=process.stderr)=>{!t.isTTY||(Nyt(),Lk=!0,t.write("\x1B[?25l"))};MC.toggle=(t,e)=>{t!==void 0&&(Lk=t),Lk?MC.show(e):MC.hide(e)}});var lEe=_(nB=>{"use strict";var aEe=nB&&nB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(nB,"__esModule",{value:!0});var sEe=aEe(u6()),oEe=aEe(g6()),Lyt=(t,{showCursor:e=!1}={})=>{let r=0,o="",a=!1,n=u=>{!e&&!a&&(oEe.default.hide(),a=!0);let A=u+` +`;A!==o&&(o=A,t.write(sEe.default.eraseLines(r)+A),r=A.split(` +`).length)};return n.clear=()=>{t.write(sEe.default.eraseLines(r)),o="",r=0},n.done=()=>{o="",r=0,e||(oEe.default.show(),a=!1)},n};nB.default={create:Lyt}});var cEe=_((uKt,Oyt)=>{Oyt.exports=[{name:"AppVeyor",constant:"APPVEYOR",env:"APPVEYOR",pr:"APPVEYOR_PULL_REQUEST_NUMBER"},{name:"Azure Pipelines",constant:"AZURE_PIPELINES",env:"SYSTEM_TEAMFOUNDATIONCOLLECTIONURI",pr:"SYSTEM_PULLREQUEST_PULLREQUESTID"},{name:"Bamboo",constant:"BAMBOO",env:"bamboo_planKey"},{name:"Bitbucket Pipelines",constant:"BITBUCKET",env:"BITBUCKET_COMMIT",pr:"BITBUCKET_PR_ID"},{name:"Bitrise",constant:"BITRISE",env:"BITRISE_IO",pr:"BITRISE_PULL_REQUEST"},{name:"Buddy",constant:"BUDDY",env:"BUDDY_WORKSPACE_ID",pr:"BUDDY_EXECUTION_PULL_REQUEST_ID"},{name:"Buildkite",constant:"BUILDKITE",env:"BUILDKITE",pr:{env:"BUILDKITE_PULL_REQUEST",ne:"false"}},{name:"CircleCI",constant:"CIRCLE",env:"CIRCLECI",pr:"CIRCLE_PULL_REQUEST"},{name:"Cirrus CI",constant:"CIRRUS",env:"CIRRUS_CI",pr:"CIRRUS_PR"},{name:"AWS CodeBuild",constant:"CODEBUILD",env:"CODEBUILD_BUILD_ARN"},{name:"Codeship",constant:"CODESHIP",env:{CI_NAME:"codeship"}},{name:"Drone",constant:"DRONE",env:"DRONE",pr:{DRONE_BUILD_EVENT:"pull_request"}},{name:"dsari",constant:"DSARI",env:"DSARI"},{name:"GitLab CI",constant:"GITLAB",env:"GITLAB_CI"},{name:"GoCD",constant:"GOCD",env:"GO_PIPELINE_LABEL"},{name:"Hudson",constant:"HUDSON",env:"HUDSON_URL"},{name:"Jenkins",constant:"JENKINS",env:["JENKINS_URL","BUILD_ID"],pr:{any:["ghprbPullId","CHANGE_ID"]}},{name:"Magnum CI",constant:"MAGNUM",env:"MAGNUM"},{name:"Netlify CI",constant:"NETLIFY",env:"NETLIFY_BUILD_BASE",pr:{env:"PULL_REQUEST",ne:"false"}},{name:"Sail CI",constant:"SAIL",env:"SAILCI",pr:"SAIL_PULL_REQUEST_NUMBER"},{name:"Semaphore",constant:"SEMAPHORE",env:"SEMAPHORE",pr:"PULL_REQUEST_NUMBER"},{name:"Shippable",constant:"SHIPPABLE",env:"SHIPPABLE",pr:{IS_PULL_REQUEST:"true"}},{name:"Solano CI",constant:"SOLANO",env:"TDDIUM",pr:"TDDIUM_PR_ID"},{name:"Strider CD",constant:"STRIDER",env:"STRIDER"},{name:"TaskCluster",constant:"TASKCLUSTER",env:["TASK_ID","RUN_ID"]},{name:"TeamCity",constant:"TEAMCITY",env:"TEAMCITY_VERSION"},{name:"Travis CI",constant:"TRAVIS",env:"TRAVIS",pr:{env:"TRAVIS_PULL_REQUEST",ne:"false"}}]});var fEe=_(gl=>{"use strict";var AEe=cEe(),pA=process.env;Object.defineProperty(gl,"_vendors",{value:AEe.map(function(t){return t.constant})});gl.name=null;gl.isPR=null;AEe.forEach(function(t){var e=Array.isArray(t.env)?t.env:[t.env],r=e.every(function(o){return uEe(o)});if(gl[t.constant]=r,r)switch(gl.name=t.name,typeof t.pr){case"string":gl.isPR=!!pA[t.pr];break;case"object":"env"in t.pr?gl.isPR=t.pr.env in pA&&pA[t.pr.env]!==t.pr.ne:"any"in t.pr?gl.isPR=t.pr.any.some(function(o){return!!pA[o]}):gl.isPR=uEe(t.pr);break;default:gl.isPR=null}});gl.isCI=!!(pA.CI||pA.CONTINUOUS_INTEGRATION||pA.BUILD_NUMBER||pA.RUN_ID||gl.name);function uEe(t){return typeof t=="string"?!!pA[t]:Object.keys(t).every(function(e){return pA[e]===t[e]})}});var hEe=_((fKt,pEe)=>{"use strict";pEe.exports=fEe().isCI});var dEe=_((pKt,gEe)=>{"use strict";var Myt=t=>{let e=new Set;do for(let r of Reflect.ownKeys(t))e.add([t,r]);while((t=Reflect.getPrototypeOf(t))&&t!==Object.prototype);return e};gEe.exports=(t,{include:e,exclude:r}={})=>{let o=a=>{let n=u=>typeof u=="string"?a===u:u.test(a);return e?e.some(n):r?!r.some(n):!0};for(let[a,n]of Myt(t.constructor.prototype)){if(n==="constructor"||!o(n))continue;let u=Reflect.getOwnPropertyDescriptor(a,n);u&&typeof u.value=="function"&&(t[n]=t[n].bind(t))}return t}});var BEe=_(kn=>{"use strict";Object.defineProperty(kn,"__esModule",{value:!0});var _C,oB,Hk,jk,I6;typeof window>"u"||typeof MessageChannel!="function"?(UC=null,d6=null,m6=function(){if(UC!==null)try{var t=kn.unstable_now();UC(!0,t),UC=null}catch(e){throw setTimeout(m6,0),e}},mEe=Date.now(),kn.unstable_now=function(){return Date.now()-mEe},_C=function(t){UC!==null?setTimeout(_C,0,t):(UC=t,setTimeout(m6,0))},oB=function(t,e){d6=setTimeout(t,e)},Hk=function(){clearTimeout(d6)},jk=function(){return!1},I6=kn.unstable_forceFrameRate=function(){}):(Ok=window.performance,y6=window.Date,yEe=window.setTimeout,EEe=window.clearTimeout,typeof console<"u"&&(CEe=window.cancelAnimationFrame,typeof window.requestAnimationFrame!="function"&&console.error("This browser doesn't support requestAnimationFrame. Make sure that you load a polyfill in older browsers. https://fb.me/react-polyfills"),typeof CEe!="function"&&console.error("This browser doesn't support cancelAnimationFrame. Make sure that you load a polyfill in older browsers. https://fb.me/react-polyfills")),typeof Ok=="object"&&typeof Ok.now=="function"?kn.unstable_now=function(){return Ok.now()}:(wEe=y6.now(),kn.unstable_now=function(){return y6.now()-wEe}),iB=!1,sB=null,Mk=-1,E6=5,C6=0,jk=function(){return kn.unstable_now()>=C6},I6=function(){},kn.unstable_forceFrameRate=function(t){0>t||125<t?console.error("forceFrameRate takes a positive int between 0 and 125, forcing framerates higher than 125 fps is not unsupported"):E6=0<t?Math.floor(1e3/t):5},w6=new MessageChannel,Uk=w6.port2,w6.port1.onmessage=function(){if(sB!==null){var t=kn.unstable_now();C6=t+E6;try{sB(!0,t)?Uk.postMessage(null):(iB=!1,sB=null)}catch(e){throw Uk.postMessage(null),e}}else iB=!1},_C=function(t){sB=t,iB||(iB=!0,Uk.postMessage(null))},oB=function(t,e){Mk=yEe(function(){t(kn.unstable_now())},e)},Hk=function(){EEe(Mk),Mk=-1});var UC,d6,m6,mEe,Ok,y6,yEe,EEe,CEe,wEe,iB,sB,Mk,E6,C6,w6,Uk;function B6(t,e){var r=t.length;t.push(e);e:for(;;){var o=Math.floor((r-1)/2),a=t[o];if(a!==void 0&&0<_k(a,e))t[o]=e,t[r]=a,r=o;else break e}}function nc(t){return t=t[0],t===void 0?null:t}function Gk(t){var e=t[0];if(e!==void 0){var r=t.pop();if(r!==e){t[0]=r;e:for(var o=0,a=t.length;o<a;){var n=2*(o+1)-1,u=t[n],A=n+1,p=t[A];if(u!==void 0&&0>_k(u,r))p!==void 0&&0>_k(p,u)?(t[o]=p,t[A]=r,o=A):(t[o]=u,t[n]=r,o=n);else if(p!==void 0&&0>_k(p,r))t[o]=p,t[A]=r,o=A;else break e}}return e}return null}function _k(t,e){var r=t.sortIndex-e.sortIndex;return r!==0?r:t.id-e.id}var eu=[],y0=[],Uyt=1,na=null,Lo=3,qk=!1,om=!1,aB=!1;function Yk(t){for(var e=nc(y0);e!==null;){if(e.callback===null)Gk(y0);else if(e.startTime<=t)Gk(y0),e.sortIndex=e.expirationTime,B6(eu,e);else break;e=nc(y0)}}function v6(t){if(aB=!1,Yk(t),!om)if(nc(eu)!==null)om=!0,_C(D6);else{var e=nc(y0);e!==null&&oB(v6,e.startTime-t)}}function D6(t,e){om=!1,aB&&(aB=!1,Hk()),qk=!0;var r=Lo;try{for(Yk(e),na=nc(eu);na!==null&&(!(na.expirationTime>e)||t&&!jk());){var o=na.callback;if(o!==null){na.callback=null,Lo=na.priorityLevel;var a=o(na.expirationTime<=e);e=kn.unstable_now(),typeof a=="function"?na.callback=a:na===nc(eu)&&Gk(eu),Yk(e)}else Gk(eu);na=nc(eu)}if(na!==null)var n=!0;else{var u=nc(y0);u!==null&&oB(v6,u.startTime-e),n=!1}return n}finally{na=null,Lo=r,qk=!1}}function IEe(t){switch(t){case 1:return-1;case 2:return 250;case 5:return 1073741823;case 4:return 1e4;default:return 5e3}}var _yt=I6;kn.unstable_ImmediatePriority=1;kn.unstable_UserBlockingPriority=2;kn.unstable_NormalPriority=3;kn.unstable_IdlePriority=5;kn.unstable_LowPriority=4;kn.unstable_runWithPriority=function(t,e){switch(t){case 1:case 2:case 3:case 4:case 5:break;default:t=3}var r=Lo;Lo=t;try{return e()}finally{Lo=r}};kn.unstable_next=function(t){switch(Lo){case 1:case 2:case 3:var e=3;break;default:e=Lo}var r=Lo;Lo=e;try{return t()}finally{Lo=r}};kn.unstable_scheduleCallback=function(t,e,r){var o=kn.unstable_now();if(typeof r=="object"&&r!==null){var a=r.delay;a=typeof a=="number"&&0<a?o+a:o,r=typeof r.timeout=="number"?r.timeout:IEe(t)}else r=IEe(t),a=o;return r=a+r,t={id:Uyt++,callback:e,priorityLevel:t,startTime:a,expirationTime:r,sortIndex:-1},a>o?(t.sortIndex=a,B6(y0,t),nc(eu)===null&&t===nc(y0)&&(aB?Hk():aB=!0,oB(v6,a-o))):(t.sortIndex=r,B6(eu,t),om||qk||(om=!0,_C(D6))),t};kn.unstable_cancelCallback=function(t){t.callback=null};kn.unstable_wrapCallback=function(t){var e=Lo;return function(){var r=Lo;Lo=e;try{return t.apply(this,arguments)}finally{Lo=r}}};kn.unstable_getCurrentPriorityLevel=function(){return Lo};kn.unstable_shouldYield=function(){var t=kn.unstable_now();Yk(t);var e=nc(eu);return e!==na&&na!==null&&e!==null&&e.callback!==null&&e.startTime<=t&&e.expirationTime<na.expirationTime||jk()};kn.unstable_requestPaint=_yt;kn.unstable_continueExecution=function(){om||qk||(om=!0,_C(D6))};kn.unstable_pauseExecution=function(){};kn.unstable_getFirstCallbackNode=function(){return nc(eu)};kn.unstable_Profiling=null});var S6=_((gKt,vEe)=>{"use strict";vEe.exports=BEe()});var DEe=_((dKt,lB)=>{lB.exports=function t(e){"use strict";var r=$H(),o=on(),a=S6();function n(S){for(var D="https://reactjs.org/docs/error-decoder.html?invariant="+S,T=1;T<arguments.length;T++)D+="&args[]="+encodeURIComponent(arguments[T]);return"Minified React error #"+S+"; visit "+D+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}var u=o.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;u.hasOwnProperty("ReactCurrentDispatcher")||(u.ReactCurrentDispatcher={current:null}),u.hasOwnProperty("ReactCurrentBatchConfig")||(u.ReactCurrentBatchConfig={suspense:null});var A=typeof Symbol=="function"&&Symbol.for,p=A?Symbol.for("react.element"):60103,h=A?Symbol.for("react.portal"):60106,E=A?Symbol.for("react.fragment"):60107,I=A?Symbol.for("react.strict_mode"):60108,v=A?Symbol.for("react.profiler"):60114,x=A?Symbol.for("react.provider"):60109,C=A?Symbol.for("react.context"):60110,R=A?Symbol.for("react.concurrent_mode"):60111,L=A?Symbol.for("react.forward_ref"):60112,U=A?Symbol.for("react.suspense"):60113,J=A?Symbol.for("react.suspense_list"):60120,te=A?Symbol.for("react.memo"):60115,ae=A?Symbol.for("react.lazy"):60116;A&&Symbol.for("react.fundamental"),A&&Symbol.for("react.responder"),A&&Symbol.for("react.scope");var fe=typeof Symbol=="function"&&Symbol.iterator;function ce(S){return S===null||typeof S!="object"?null:(S=fe&&S[fe]||S["@@iterator"],typeof S=="function"?S:null)}function me(S){if(S._status===-1){S._status=0;var D=S._ctor;D=D(),S._result=D,D.then(function(T){S._status===0&&(T=T.default,S._status=1,S._result=T)},function(T){S._status===0&&(S._status=2,S._result=T)})}}function he(S){if(S==null)return null;if(typeof S=="function")return S.displayName||S.name||null;if(typeof S=="string")return S;switch(S){case E:return"Fragment";case h:return"Portal";case v:return"Profiler";case I:return"StrictMode";case U:return"Suspense";case J:return"SuspenseList"}if(typeof S=="object")switch(S.$$typeof){case C:return"Context.Consumer";case x:return"Context.Provider";case L:var D=S.render;return D=D.displayName||D.name||"",S.displayName||(D!==""?"ForwardRef("+D+")":"ForwardRef");case te:return he(S.type);case ae:if(S=S._status===1?S._result:null)return he(S)}return null}function Be(S){var D=S,T=S;if(S.alternate)for(;D.return;)D=D.return;else{S=D;do D=S,(D.effectTag&1026)!==0&&(T=D.return),S=D.return;while(S)}return D.tag===3?T:null}function we(S){if(Be(S)!==S)throw Error(n(188))}function g(S){var D=S.alternate;if(!D){if(D=Be(S),D===null)throw Error(n(188));return D!==S?null:S}for(var T=S,j=D;;){var Y=T.return;if(Y===null)break;var Ae=Y.alternate;if(Ae===null){if(j=Y.return,j!==null){T=j;continue}break}if(Y.child===Ae.child){for(Ae=Y.child;Ae;){if(Ae===T)return we(Y),S;if(Ae===j)return we(Y),D;Ae=Ae.sibling}throw Error(n(188))}if(T.return!==j.return)T=Y,j=Ae;else{for(var De=!1,vt=Y.child;vt;){if(vt===T){De=!0,T=Y,j=Ae;break}if(vt===j){De=!0,j=Y,T=Ae;break}vt=vt.sibling}if(!De){for(vt=Ae.child;vt;){if(vt===T){De=!0,T=Ae,j=Y;break}if(vt===j){De=!0,j=Ae,T=Y;break}vt=vt.sibling}if(!De)throw Error(n(189))}}if(T.alternate!==j)throw Error(n(190))}if(T.tag!==3)throw Error(n(188));return T.stateNode.current===T?S:D}function Ee(S){if(S=g(S),!S)return null;for(var D=S;;){if(D.tag===5||D.tag===6)return D;if(D.child)D.child.return=D,D=D.child;else{if(D===S)break;for(;!D.sibling;){if(!D.return||D.return===S)return null;D=D.return}D.sibling.return=D.return,D=D.sibling}}return null}function Se(S){if(S=g(S),!S)return null;for(var D=S;;){if(D.tag===5||D.tag===6)return D;if(D.child&&D.tag!==4)D.child.return=D,D=D.child;else{if(D===S)break;for(;!D.sibling;){if(!D.return||D.return===S)return null;D=D.return}D.sibling.return=D.return,D=D.sibling}}return null}var le=e.getPublicInstance,ne=e.getRootHostContext,ee=e.getChildHostContext,Ie=e.prepareForCommit,Fe=e.resetAfterCommit,At=e.createInstance,H=e.appendInitialChild,at=e.finalizeInitialChildren,Re=e.prepareUpdate,ke=e.shouldSetTextContent,xe=e.shouldDeprioritizeSubtree,He=e.createTextInstance,Te=e.setTimeout,Je=e.clearTimeout,je=e.noTimeout,b=e.isPrimaryRenderer,w=e.supportsMutation,P=e.supportsPersistence,y=e.supportsHydration,F=e.appendChild,z=e.appendChildToContainer,X=e.commitTextUpdate,Z=e.commitMount,ie=e.commitUpdate,Pe=e.insertBefore,Ne=e.insertInContainerBefore,ot=e.removeChild,dt=e.removeChildFromContainer,Gt=e.resetTextContent,$t=e.hideInstance,bt=e.hideTextInstance,an=e.unhideInstance,Qr=e.unhideTextInstance,mr=e.cloneInstance,br=e.createContainerChildSet,Wr=e.appendChildToContainerChildSet,Kn=e.finalizeContainerChildren,Ns=e.replaceContainerChildren,Ti=e.cloneHiddenInstance,ps=e.cloneHiddenTextInstance,io=e.canHydrateInstance,Pi=e.canHydrateTextInstance,Ls=e.isSuspenseInstancePending,so=e.isSuspenseInstanceFallback,cc=e.getNextHydratableSibling,cu=e.getFirstHydratableChild,lp=e.hydrateInstance,cp=e.hydrateTextInstance,Os=e.getNextHydratableInstanceAfterSuspenseInstance,Dn=e.commitHydratedContainer,oo=e.commitHydratedSuspenseInstance,Ms=/^(.*)[\\\/]/;function ml(S){var D="";do{e:switch(S.tag){case 3:case 4:case 6:case 7:case 10:case 9:var T="";break e;default:var j=S._debugOwner,Y=S._debugSource,Ae=he(S.type);T=null,j&&(T=he(j.type)),j=Ae,Ae="",Y?Ae=" (at "+Y.fileName.replace(Ms,"")+":"+Y.lineNumber+")":T&&(Ae=" (created by "+T+")"),T=` + in `+(j||"Unknown")+Ae}D+=T,S=S.return}while(S);return D}var yl=[],ao=-1;function Vn(S){0>ao||(S.current=yl[ao],yl[ao]=null,ao--)}function On(S,D){ao++,yl[ao]=S.current,S.current=D}var Ni={},Mn={current:Ni},_i={current:!1},tr=Ni;function Oe(S,D){var T=S.type.contextTypes;if(!T)return Ni;var j=S.stateNode;if(j&&j.__reactInternalMemoizedUnmaskedChildContext===D)return j.__reactInternalMemoizedMaskedChildContext;var Y={},Ae;for(Ae in T)Y[Ae]=D[Ae];return j&&(S=S.stateNode,S.__reactInternalMemoizedUnmaskedChildContext=D,S.__reactInternalMemoizedMaskedChildContext=Y),Y}function ii(S){return S=S.childContextTypes,S!=null}function Ma(S){Vn(_i,S),Vn(Mn,S)}function hr(S){Vn(_i,S),Vn(Mn,S)}function uc(S,D,T){if(Mn.current!==Ni)throw Error(n(168));On(Mn,D,S),On(_i,T,S)}function uu(S,D,T){var j=S.stateNode;if(S=D.childContextTypes,typeof j.getChildContext!="function")return T;j=j.getChildContext();for(var Y in j)if(!(Y in S))throw Error(n(108,he(D)||"Unknown",Y));return r({},T,{},j)}function Ac(S){var D=S.stateNode;return D=D&&D.__reactInternalMemoizedMergedChildContext||Ni,tr=Mn.current,On(Mn,D,S),On(_i,_i.current,S),!0}function El(S,D,T){var j=S.stateNode;if(!j)throw Error(n(169));T?(D=uu(S,D,tr),j.__reactInternalMemoizedMergedChildContext=D,Vn(_i,S),Vn(Mn,S),On(Mn,D,S)):Vn(_i,S),On(_i,T,S)}var DA=a.unstable_runWithPriority,Au=a.unstable_scheduleCallback,Ce=a.unstable_cancelCallback,Rt=a.unstable_shouldYield,fc=a.unstable_requestPaint,Hi=a.unstable_now,fu=a.unstable_getCurrentPriorityLevel,Yt=a.unstable_ImmediatePriority,Cl=a.unstable_UserBlockingPriority,SA=a.unstable_NormalPriority,up=a.unstable_LowPriority,pc=a.unstable_IdlePriority,PA={},Qn=fc!==void 0?fc:function(){},hi=null,hc=null,bA=!1,sa=Hi(),Li=1e4>sa?Hi:function(){return Hi()-sa};function _o(){switch(fu()){case Yt:return 99;case Cl:return 98;case SA:return 97;case up:return 96;case pc:return 95;default:throw Error(n(332))}}function Ze(S){switch(S){case 99:return Yt;case 98:return Cl;case 97:return SA;case 96:return up;case 95:return pc;default:throw Error(n(332))}}function lo(S,D){return S=Ze(S),DA(S,D)}function gc(S,D,T){return S=Ze(S),Au(S,D,T)}function pu(S){return hi===null?(hi=[S],hc=Au(Yt,hu)):hi.push(S),PA}function ji(){if(hc!==null){var S=hc;hc=null,Ce(S)}hu()}function hu(){if(!bA&&hi!==null){bA=!0;var S=0;try{var D=hi;lo(99,function(){for(;S<D.length;S++){var T=D[S];do T=T(!0);while(T!==null)}}),hi=null}catch(T){throw hi!==null&&(hi=hi.slice(S+1)),Au(Yt,ji),T}finally{bA=!1}}}var xA=3;function Ua(S,D,T){return T/=10,1073741821-(((1073741821-S+D/10)/T|0)+1)*T}function dc(S,D){return S===D&&(S!==0||1/S===1/D)||S!==S&&D!==D}var hs=typeof Object.is=="function"?Object.is:dc,_t=Object.prototype.hasOwnProperty;function Fn(S,D){if(hs(S,D))return!0;if(typeof S!="object"||S===null||typeof D!="object"||D===null)return!1;var T=Object.keys(S),j=Object.keys(D);if(T.length!==j.length)return!1;for(j=0;j<T.length;j++)if(!_t.call(D,T[j])||!hs(S[T[j]],D[T[j]]))return!1;return!0}function Ci(S,D){if(S&&S.defaultProps){D=r({},D),S=S.defaultProps;for(var T in S)D[T]===void 0&&(D[T]=S[T])}return D}var oa={current:null},co=null,Us=null,aa=null;function la(){aa=Us=co=null}function Ho(S,D){var T=S.type._context;b?(On(oa,T._currentValue,S),T._currentValue=D):(On(oa,T._currentValue2,S),T._currentValue2=D)}function wi(S){var D=oa.current;Vn(oa,S),S=S.type._context,b?S._currentValue=D:S._currentValue2=D}function gs(S,D){for(;S!==null;){var T=S.alternate;if(S.childExpirationTime<D)S.childExpirationTime=D,T!==null&&T.childExpirationTime<D&&(T.childExpirationTime=D);else if(T!==null&&T.childExpirationTime<D)T.childExpirationTime=D;else break;S=S.return}}function ds(S,D){co=S,aa=Us=null,S=S.dependencies,S!==null&&S.firstContext!==null&&(S.expirationTime>=D&&(Go=!0),S.firstContext=null)}function ms(S,D){if(aa!==S&&D!==!1&&D!==0)if((typeof D!="number"||D===1073741823)&&(aa=S,D=1073741823),D={context:S,observedBits:D,next:null},Us===null){if(co===null)throw Error(n(308));Us=D,co.dependencies={expirationTime:0,firstContext:D,responders:null}}else Us=Us.next=D;return b?S._currentValue:S._currentValue2}var _s=!1;function Un(S){return{baseState:S,firstUpdate:null,lastUpdate:null,firstCapturedUpdate:null,lastCapturedUpdate:null,firstEffect:null,lastEffect:null,firstCapturedEffect:null,lastCapturedEffect:null}}function Sn(S){return{baseState:S.baseState,firstUpdate:S.firstUpdate,lastUpdate:S.lastUpdate,firstCapturedUpdate:null,lastCapturedUpdate:null,firstEffect:null,lastEffect:null,firstCapturedEffect:null,lastCapturedEffect:null}}function ys(S,D){return{expirationTime:S,suspenseConfig:D,tag:0,payload:null,callback:null,next:null,nextEffect:null}}function We(S,D){S.lastUpdate===null?S.firstUpdate=S.lastUpdate=D:(S.lastUpdate.next=D,S.lastUpdate=D)}function tt(S,D){var T=S.alternate;if(T===null){var j=S.updateQueue,Y=null;j===null&&(j=S.updateQueue=Un(S.memoizedState))}else j=S.updateQueue,Y=T.updateQueue,j===null?Y===null?(j=S.updateQueue=Un(S.memoizedState),Y=T.updateQueue=Un(T.memoizedState)):j=S.updateQueue=Sn(Y):Y===null&&(Y=T.updateQueue=Sn(j));Y===null||j===Y?We(j,D):j.lastUpdate===null||Y.lastUpdate===null?(We(j,D),We(Y,D)):(We(j,D),Y.lastUpdate=D)}function It(S,D){var T=S.updateQueue;T=T===null?S.updateQueue=Un(S.memoizedState):nr(S,T),T.lastCapturedUpdate===null?T.firstCapturedUpdate=T.lastCapturedUpdate=D:(T.lastCapturedUpdate.next=D,T.lastCapturedUpdate=D)}function nr(S,D){var T=S.alternate;return T!==null&&D===T.updateQueue&&(D=S.updateQueue=Sn(D)),D}function $(S,D,T,j,Y,Ae){switch(T.tag){case 1:return S=T.payload,typeof S=="function"?S.call(Ae,j,Y):S;case 3:S.effectTag=S.effectTag&-4097|64;case 0:if(S=T.payload,Y=typeof S=="function"?S.call(Ae,j,Y):S,Y==null)break;return r({},j,Y);case 2:_s=!0}return j}function ye(S,D,T,j,Y){_s=!1,D=nr(S,D);for(var Ae=D.baseState,De=null,vt=0,wt=D.firstUpdate,xt=Ae;wt!==null;){var _r=wt.expirationTime;_r<Y?(De===null&&(De=wt,Ae=xt),vt<_r&&(vt=_r)):(Pw(_r,wt.suspenseConfig),xt=$(S,D,wt,xt,T,j),wt.callback!==null&&(S.effectTag|=32,wt.nextEffect=null,D.lastEffect===null?D.firstEffect=D.lastEffect=wt:(D.lastEffect.nextEffect=wt,D.lastEffect=wt))),wt=wt.next}for(_r=null,wt=D.firstCapturedUpdate;wt!==null;){var is=wt.expirationTime;is<Y?(_r===null&&(_r=wt,De===null&&(Ae=xt)),vt<is&&(vt=is)):(xt=$(S,D,wt,xt,T,j),wt.callback!==null&&(S.effectTag|=32,wt.nextEffect=null,D.lastCapturedEffect===null?D.firstCapturedEffect=D.lastCapturedEffect=wt:(D.lastCapturedEffect.nextEffect=wt,D.lastCapturedEffect=wt))),wt=wt.next}De===null&&(D.lastUpdate=null),_r===null?D.lastCapturedUpdate=null:S.effectTag|=32,De===null&&_r===null&&(Ae=xt),D.baseState=Ae,D.firstUpdate=De,D.firstCapturedUpdate=_r,Hm(vt),S.expirationTime=vt,S.memoizedState=xt}function Le(S,D,T){D.firstCapturedUpdate!==null&&(D.lastUpdate!==null&&(D.lastUpdate.next=D.firstCapturedUpdate,D.lastUpdate=D.lastCapturedUpdate),D.firstCapturedUpdate=D.lastCapturedUpdate=null),pt(D.firstEffect,T),D.firstEffect=D.lastEffect=null,pt(D.firstCapturedEffect,T),D.firstCapturedEffect=D.lastCapturedEffect=null}function pt(S,D){for(;S!==null;){var T=S.callback;if(T!==null){S.callback=null;var j=D;if(typeof T!="function")throw Error(n(191,T));T.call(j)}S=S.nextEffect}}var ht=u.ReactCurrentBatchConfig,Tt=new o.Component().refs;function er(S,D,T,j){D=S.memoizedState,T=T(j,D),T=T==null?D:r({},D,T),S.memoizedState=T,j=S.updateQueue,j!==null&&S.expirationTime===0&&(j.baseState=T)}var $r={isMounted:function(S){return(S=S._reactInternalFiber)?Be(S)===S:!1},enqueueSetState:function(S,D,T){S=S._reactInternalFiber;var j=ga(),Y=ht.suspense;j=jA(j,S,Y),Y=ys(j,Y),Y.payload=D,T!=null&&(Y.callback=T),tt(S,Y),Pc(S,j)},enqueueReplaceState:function(S,D,T){S=S._reactInternalFiber;var j=ga(),Y=ht.suspense;j=jA(j,S,Y),Y=ys(j,Y),Y.tag=1,Y.payload=D,T!=null&&(Y.callback=T),tt(S,Y),Pc(S,j)},enqueueForceUpdate:function(S,D){S=S._reactInternalFiber;var T=ga(),j=ht.suspense;T=jA(T,S,j),j=ys(T,j),j.tag=2,D!=null&&(j.callback=D),tt(S,j),Pc(S,T)}};function Gi(S,D,T,j,Y,Ae,De){return S=S.stateNode,typeof S.shouldComponentUpdate=="function"?S.shouldComponentUpdate(j,Ae,De):D.prototype&&D.prototype.isPureReactComponent?!Fn(T,j)||!Fn(Y,Ae):!0}function es(S,D,T){var j=!1,Y=Ni,Ae=D.contextType;return typeof Ae=="object"&&Ae!==null?Ae=ms(Ae):(Y=ii(D)?tr:Mn.current,j=D.contextTypes,Ae=(j=j!=null)?Oe(S,Y):Ni),D=new D(T,Ae),S.memoizedState=D.state!==null&&D.state!==void 0?D.state:null,D.updater=$r,S.stateNode=D,D._reactInternalFiber=S,j&&(S=S.stateNode,S.__reactInternalMemoizedUnmaskedChildContext=Y,S.__reactInternalMemoizedMaskedChildContext=Ae),D}function bi(S,D,T,j){S=D.state,typeof D.componentWillReceiveProps=="function"&&D.componentWillReceiveProps(T,j),typeof D.UNSAFE_componentWillReceiveProps=="function"&&D.UNSAFE_componentWillReceiveProps(T,j),D.state!==S&&$r.enqueueReplaceState(D,D.state,null)}function jo(S,D,T,j){var Y=S.stateNode;Y.props=T,Y.state=S.memoizedState,Y.refs=Tt;var Ae=D.contextType;typeof Ae=="object"&&Ae!==null?Y.context=ms(Ae):(Ae=ii(D)?tr:Mn.current,Y.context=Oe(S,Ae)),Ae=S.updateQueue,Ae!==null&&(ye(S,Ae,T,Y,j),Y.state=S.memoizedState),Ae=D.getDerivedStateFromProps,typeof Ae=="function"&&(er(S,D,Ae,T),Y.state=S.memoizedState),typeof D.getDerivedStateFromProps=="function"||typeof Y.getSnapshotBeforeUpdate=="function"||typeof Y.UNSAFE_componentWillMount!="function"&&typeof Y.componentWillMount!="function"||(D=Y.state,typeof Y.componentWillMount=="function"&&Y.componentWillMount(),typeof Y.UNSAFE_componentWillMount=="function"&&Y.UNSAFE_componentWillMount(),D!==Y.state&&$r.enqueueReplaceState(Y,Y.state,null),Ae=S.updateQueue,Ae!==null&&(ye(S,Ae,T,Y,j),Y.state=S.memoizedState)),typeof Y.componentDidMount=="function"&&(S.effectTag|=4)}var kA=Array.isArray;function QA(S,D,T){if(S=T.ref,S!==null&&typeof S!="function"&&typeof S!="object"){if(T._owner){if(T=T._owner,T){if(T.tag!==1)throw Error(n(309));var j=T.stateNode}if(!j)throw Error(n(147,S));var Y=""+S;return D!==null&&D.ref!==null&&typeof D.ref=="function"&&D.ref._stringRef===Y?D.ref:(D=function(Ae){var De=j.refs;De===Tt&&(De=j.refs={}),Ae===null?delete De[Y]:De[Y]=Ae},D._stringRef=Y,D)}if(typeof S!="string")throw Error(n(284));if(!T._owner)throw Error(n(290,S))}return S}function Ap(S,D){if(S.type!=="textarea")throw Error(n(31,Object.prototype.toString.call(D)==="[object Object]"?"object with keys {"+Object.keys(D).join(", ")+"}":D,""))}function ig(S){function D(rt,Ve){if(S){var ft=rt.lastEffect;ft!==null?(ft.nextEffect=Ve,rt.lastEffect=Ve):rt.firstEffect=rt.lastEffect=Ve,Ve.nextEffect=null,Ve.effectTag=8}}function T(rt,Ve){if(!S)return null;for(;Ve!==null;)D(rt,Ve),Ve=Ve.sibling;return null}function j(rt,Ve){for(rt=new Map;Ve!==null;)Ve.key!==null?rt.set(Ve.key,Ve):rt.set(Ve.index,Ve),Ve=Ve.sibling;return rt}function Y(rt,Ve,ft){return rt=WA(rt,Ve,ft),rt.index=0,rt.sibling=null,rt}function Ae(rt,Ve,ft){return rt.index=ft,S?(ft=rt.alternate,ft!==null?(ft=ft.index,ft<Ve?(rt.effectTag=2,Ve):ft):(rt.effectTag=2,Ve)):Ve}function De(rt){return S&&rt.alternate===null&&(rt.effectTag=2),rt}function vt(rt,Ve,ft,Wt){return Ve===null||Ve.tag!==6?(Ve=Fw(ft,rt.mode,Wt),Ve.return=rt,Ve):(Ve=Y(Ve,ft,Wt),Ve.return=rt,Ve)}function wt(rt,Ve,ft,Wt){return Ve!==null&&Ve.elementType===ft.type?(Wt=Y(Ve,ft.props,Wt),Wt.ref=QA(rt,Ve,ft),Wt.return=rt,Wt):(Wt=jm(ft.type,ft.key,ft.props,null,rt.mode,Wt),Wt.ref=QA(rt,Ve,ft),Wt.return=rt,Wt)}function xt(rt,Ve,ft,Wt){return Ve===null||Ve.tag!==4||Ve.stateNode.containerInfo!==ft.containerInfo||Ve.stateNode.implementation!==ft.implementation?(Ve=Rw(ft,rt.mode,Wt),Ve.return=rt,Ve):(Ve=Y(Ve,ft.children||[],Wt),Ve.return=rt,Ve)}function _r(rt,Ve,ft,Wt,vr){return Ve===null||Ve.tag!==7?(Ve=bu(ft,rt.mode,Wt,vr),Ve.return=rt,Ve):(Ve=Y(Ve,ft,Wt),Ve.return=rt,Ve)}function is(rt,Ve,ft){if(typeof Ve=="string"||typeof Ve=="number")return Ve=Fw(""+Ve,rt.mode,ft),Ve.return=rt,Ve;if(typeof Ve=="object"&&Ve!==null){switch(Ve.$$typeof){case p:return ft=jm(Ve.type,Ve.key,Ve.props,null,rt.mode,ft),ft.ref=QA(rt,null,Ve),ft.return=rt,ft;case h:return Ve=Rw(Ve,rt.mode,ft),Ve.return=rt,Ve}if(kA(Ve)||ce(Ve))return Ve=bu(Ve,rt.mode,ft,null),Ve.return=rt,Ve;Ap(rt,Ve)}return null}function di(rt,Ve,ft,Wt){var vr=Ve!==null?Ve.key:null;if(typeof ft=="string"||typeof ft=="number")return vr!==null?null:vt(rt,Ve,""+ft,Wt);if(typeof ft=="object"&&ft!==null){switch(ft.$$typeof){case p:return ft.key===vr?ft.type===E?_r(rt,Ve,ft.props.children,Wt,vr):wt(rt,Ve,ft,Wt):null;case h:return ft.key===vr?xt(rt,Ve,ft,Wt):null}if(kA(ft)||ce(ft))return vr!==null?null:_r(rt,Ve,ft,Wt,null);Ap(rt,ft)}return null}function po(rt,Ve,ft,Wt,vr){if(typeof Wt=="string"||typeof Wt=="number")return rt=rt.get(ft)||null,vt(Ve,rt,""+Wt,vr);if(typeof Wt=="object"&&Wt!==null){switch(Wt.$$typeof){case p:return rt=rt.get(Wt.key===null?ft:Wt.key)||null,Wt.type===E?_r(Ve,rt,Wt.props.children,vr,Wt.key):wt(Ve,rt,Wt,vr);case h:return rt=rt.get(Wt.key===null?ft:Wt.key)||null,xt(Ve,rt,Wt,vr)}if(kA(Wt)||ce(Wt))return rt=rt.get(ft)||null,_r(Ve,rt,Wt,vr,null);Ap(Ve,Wt)}return null}function VA(rt,Ve,ft,Wt){for(var vr=null,Pn=null,Fr=Ve,bn=Ve=0,ai=null;Fr!==null&&bn<ft.length;bn++){Fr.index>bn?(ai=Fr,Fr=null):ai=Fr.sibling;var tn=di(rt,Fr,ft[bn],Wt);if(tn===null){Fr===null&&(Fr=ai);break}S&&Fr&&tn.alternate===null&&D(rt,Fr),Ve=Ae(tn,Ve,bn),Pn===null?vr=tn:Pn.sibling=tn,Pn=tn,Fr=ai}if(bn===ft.length)return T(rt,Fr),vr;if(Fr===null){for(;bn<ft.length;bn++)Fr=is(rt,ft[bn],Wt),Fr!==null&&(Ve=Ae(Fr,Ve,bn),Pn===null?vr=Fr:Pn.sibling=Fr,Pn=Fr);return vr}for(Fr=j(rt,Fr);bn<ft.length;bn++)ai=po(Fr,rt,bn,ft[bn],Wt),ai!==null&&(S&&ai.alternate!==null&&Fr.delete(ai.key===null?bn:ai.key),Ve=Ae(ai,Ve,bn),Pn===null?vr=ai:Pn.sibling=ai,Pn=ai);return S&&Fr.forEach(function(ho){return D(rt,ho)}),vr}function Yo(rt,Ve,ft,Wt){var vr=ce(ft);if(typeof vr!="function")throw Error(n(150));if(ft=vr.call(ft),ft==null)throw Error(n(151));for(var Pn=vr=null,Fr=Ve,bn=Ve=0,ai=null,tn=ft.next();Fr!==null&&!tn.done;bn++,tn=ft.next()){Fr.index>bn?(ai=Fr,Fr=null):ai=Fr.sibling;var ho=di(rt,Fr,tn.value,Wt);if(ho===null){Fr===null&&(Fr=ai);break}S&&Fr&&ho.alternate===null&&D(rt,Fr),Ve=Ae(ho,Ve,bn),Pn===null?vr=ho:Pn.sibling=ho,Pn=ho,Fr=ai}if(tn.done)return T(rt,Fr),vr;if(Fr===null){for(;!tn.done;bn++,tn=ft.next())tn=is(rt,tn.value,Wt),tn!==null&&(Ve=Ae(tn,Ve,bn),Pn===null?vr=tn:Pn.sibling=tn,Pn=tn);return vr}for(Fr=j(rt,Fr);!tn.done;bn++,tn=ft.next())tn=po(Fr,rt,bn,tn.value,Wt),tn!==null&&(S&&tn.alternate!==null&&Fr.delete(tn.key===null?bn:tn.key),Ve=Ae(tn,Ve,bn),Pn===null?vr=tn:Pn.sibling=tn,Pn=tn);return S&&Fr.forEach(function(vF){return D(rt,vF)}),vr}return function(rt,Ve,ft,Wt){var vr=typeof ft=="object"&&ft!==null&&ft.type===E&&ft.key===null;vr&&(ft=ft.props.children);var Pn=typeof ft=="object"&&ft!==null;if(Pn)switch(ft.$$typeof){case p:e:{for(Pn=ft.key,vr=Ve;vr!==null;){if(vr.key===Pn)if(vr.tag===7?ft.type===E:vr.elementType===ft.type){T(rt,vr.sibling),Ve=Y(vr,ft.type===E?ft.props.children:ft.props,Wt),Ve.ref=QA(rt,vr,ft),Ve.return=rt,rt=Ve;break e}else{T(rt,vr);break}else D(rt,vr);vr=vr.sibling}ft.type===E?(Ve=bu(ft.props.children,rt.mode,Wt,ft.key),Ve.return=rt,rt=Ve):(Wt=jm(ft.type,ft.key,ft.props,null,rt.mode,Wt),Wt.ref=QA(rt,Ve,ft),Wt.return=rt,rt=Wt)}return De(rt);case h:e:{for(vr=ft.key;Ve!==null;){if(Ve.key===vr)if(Ve.tag===4&&Ve.stateNode.containerInfo===ft.containerInfo&&Ve.stateNode.implementation===ft.implementation){T(rt,Ve.sibling),Ve=Y(Ve,ft.children||[],Wt),Ve.return=rt,rt=Ve;break e}else{T(rt,Ve);break}else D(rt,Ve);Ve=Ve.sibling}Ve=Rw(ft,rt.mode,Wt),Ve.return=rt,rt=Ve}return De(rt)}if(typeof ft=="string"||typeof ft=="number")return ft=""+ft,Ve!==null&&Ve.tag===6?(T(rt,Ve.sibling),Ve=Y(Ve,ft,Wt),Ve.return=rt,rt=Ve):(T(rt,Ve),Ve=Fw(ft,rt.mode,Wt),Ve.return=rt,rt=Ve),De(rt);if(kA(ft))return VA(rt,Ve,ft,Wt);if(ce(ft))return Yo(rt,Ve,ft,Wt);if(Pn&&Ap(rt,ft),typeof ft>"u"&&!vr)switch(rt.tag){case 1:case 0:throw rt=rt.type,Error(n(152,rt.displayName||rt.name||"Component"))}return T(rt,Ve)}}var gu=ig(!0),sg=ig(!1),du={},uo={current:du},FA={current:du},mc={current:du};function ca(S){if(S===du)throw Error(n(174));return S}function og(S,D){On(mc,D,S),On(FA,S,S),On(uo,du,S),D=ne(D),Vn(uo,S),On(uo,D,S)}function yc(S){Vn(uo,S),Vn(FA,S),Vn(mc,S)}function Pm(S){var D=ca(mc.current),T=ca(uo.current);D=ee(T,S.type,D),T!==D&&(On(FA,S,S),On(uo,D,S))}function ag(S){FA.current===S&&(Vn(uo,S),Vn(FA,S))}var $n={current:0};function fp(S){for(var D=S;D!==null;){if(D.tag===13){var T=D.memoizedState;if(T!==null&&(T=T.dehydrated,T===null||Ls(T)||so(T)))return D}else if(D.tag===19&&D.memoizedProps.revealOrder!==void 0){if((D.effectTag&64)!==0)return D}else if(D.child!==null){D.child.return=D,D=D.child;continue}if(D===S)break;for(;D.sibling===null;){if(D.return===null||D.return===S)return null;D=D.return}D.sibling.return=D.return,D=D.sibling}return null}function lg(S,D){return{responder:S,props:D}}var RA=u.ReactCurrentDispatcher,Hs=u.ReactCurrentBatchConfig,mu=0,Ha=null,qi=null,ua=null,yu=null,Es=null,Ec=null,Cc=0,q=null,Dt=0,wl=!1,xi=null,wc=0;function ct(){throw Error(n(321))}function Eu(S,D){if(D===null)return!1;for(var T=0;T<D.length&&T<S.length;T++)if(!hs(S[T],D[T]))return!1;return!0}function cg(S,D,T,j,Y,Ae){if(mu=Ae,Ha=D,ua=S!==null?S.memoizedState:null,RA.current=ua===null?Ew:km,D=T(j,Y),wl){do wl=!1,wc+=1,ua=S!==null?S.memoizedState:null,Ec=yu,q=Es=qi=null,RA.current=km,D=T(j,Y);while(wl);xi=null,wc=0}if(RA.current=wu,S=Ha,S.memoizedState=yu,S.expirationTime=Cc,S.updateQueue=q,S.effectTag|=Dt,S=qi!==null&&qi.next!==null,mu=0,Ec=Es=yu=ua=qi=Ha=null,Cc=0,q=null,Dt=0,S)throw Error(n(300));return D}function yw(){RA.current=wu,mu=0,Ec=Es=yu=ua=qi=Ha=null,Cc=0,q=null,Dt=0,wl=!1,xi=null,wc=0}function TA(){var S={memoizedState:null,baseState:null,queue:null,baseUpdate:null,next:null};return Es===null?yu=Es=S:Es=Es.next=S,Es}function pp(){if(Ec!==null)Es=Ec,Ec=Es.next,qi=ua,ua=qi!==null?qi.next:null;else{if(ua===null)throw Error(n(310));qi=ua;var S={memoizedState:qi.memoizedState,baseState:qi.baseState,queue:qi.queue,baseUpdate:qi.baseUpdate,next:null};Es=Es===null?yu=S:Es.next=S,ua=qi.next}return Es}function Br(S,D){return typeof D=="function"?D(S):D}function Cs(S){var D=pp(),T=D.queue;if(T===null)throw Error(n(311));if(T.lastRenderedReducer=S,0<wc){var j=T.dispatch;if(xi!==null){var Y=xi.get(T);if(Y!==void 0){xi.delete(T);var Ae=D.memoizedState;do Ae=S(Ae,Y.action),Y=Y.next;while(Y!==null);return hs(Ae,D.memoizedState)||(Go=!0),D.memoizedState=Ae,D.baseUpdate===T.last&&(D.baseState=Ae),T.lastRenderedState=Ae,[Ae,j]}}return[D.memoizedState,j]}j=T.last;var De=D.baseUpdate;if(Ae=D.baseState,De!==null?(j!==null&&(j.next=null),j=De.next):j=j!==null?j.next:null,j!==null){var vt=Y=null,wt=j,xt=!1;do{var _r=wt.expirationTime;_r<mu?(xt||(xt=!0,vt=De,Y=Ae),_r>Cc&&(Cc=_r,Hm(Cc))):(Pw(_r,wt.suspenseConfig),Ae=wt.eagerReducer===S?wt.eagerState:S(Ae,wt.action)),De=wt,wt=wt.next}while(wt!==null&&wt!==j);xt||(vt=De,Y=Ae),hs(Ae,D.memoizedState)||(Go=!0),D.memoizedState=Ae,D.baseUpdate=vt,D.baseState=Y,T.lastRenderedState=Ae}return[D.memoizedState,T.dispatch]}function ug(S){var D=TA();return typeof S=="function"&&(S=S()),D.memoizedState=D.baseState=S,S=D.queue={last:null,dispatch:null,lastRenderedReducer:Br,lastRenderedState:S},S=S.dispatch=gg.bind(null,Ha,S),[D.memoizedState,S]}function Ag(S){return Cs(Br,S)}function fg(S,D,T,j){return S={tag:S,create:D,destroy:T,deps:j,next:null},q===null?(q={lastEffect:null},q.lastEffect=S.next=S):(D=q.lastEffect,D===null?q.lastEffect=S.next=S:(T=D.next,D.next=S,S.next=T,q.lastEffect=S)),S}function hp(S,D,T,j){var Y=TA();Dt|=S,Y.memoizedState=fg(D,T,void 0,j===void 0?null:j)}function Ic(S,D,T,j){var Y=pp();j=j===void 0?null:j;var Ae=void 0;if(qi!==null){var De=qi.memoizedState;if(Ae=De.destroy,j!==null&&Eu(j,De.deps)){fg(0,T,Ae,j);return}}Dt|=S,Y.memoizedState=fg(D,T,Ae,j)}function Ct(S,D){return hp(516,192,S,D)}function bm(S,D){return Ic(516,192,S,D)}function pg(S,D){if(typeof D=="function")return S=S(),D(S),function(){D(null)};if(D!=null)return S=S(),D.current=S,function(){D.current=null}}function hg(){}function Cu(S,D){return TA().memoizedState=[S,D===void 0?null:D],S}function xm(S,D){var T=pp();D=D===void 0?null:D;var j=T.memoizedState;return j!==null&&D!==null&&Eu(D,j[1])?j[0]:(T.memoizedState=[S,D],S)}function gg(S,D,T){if(!(25>wc))throw Error(n(301));var j=S.alternate;if(S===Ha||j!==null&&j===Ha)if(wl=!0,S={expirationTime:mu,suspenseConfig:null,action:T,eagerReducer:null,eagerState:null,next:null},xi===null&&(xi=new Map),T=xi.get(D),T===void 0)xi.set(D,S);else{for(D=T;D.next!==null;)D=D.next;D.next=S}else{var Y=ga(),Ae=ht.suspense;Y=jA(Y,S,Ae),Ae={expirationTime:Y,suspenseConfig:Ae,action:T,eagerReducer:null,eagerState:null,next:null};var De=D.last;if(De===null)Ae.next=Ae;else{var vt=De.next;vt!==null&&(Ae.next=vt),De.next=Ae}if(D.last=Ae,S.expirationTime===0&&(j===null||j.expirationTime===0)&&(j=D.lastRenderedReducer,j!==null))try{var wt=D.lastRenderedState,xt=j(wt,T);if(Ae.eagerReducer=j,Ae.eagerState=xt,hs(xt,wt))return}catch{}finally{}Pc(S,Y)}}var wu={readContext:ms,useCallback:ct,useContext:ct,useEffect:ct,useImperativeHandle:ct,useLayoutEffect:ct,useMemo:ct,useReducer:ct,useRef:ct,useState:ct,useDebugValue:ct,useResponder:ct,useDeferredValue:ct,useTransition:ct},Ew={readContext:ms,useCallback:Cu,useContext:ms,useEffect:Ct,useImperativeHandle:function(S,D,T){return T=T!=null?T.concat([S]):null,hp(4,36,pg.bind(null,D,S),T)},useLayoutEffect:function(S,D){return hp(4,36,S,D)},useMemo:function(S,D){var T=TA();return D=D===void 0?null:D,S=S(),T.memoizedState=[S,D],S},useReducer:function(S,D,T){var j=TA();return D=T!==void 0?T(D):D,j.memoizedState=j.baseState=D,S=j.queue={last:null,dispatch:null,lastRenderedReducer:S,lastRenderedState:D},S=S.dispatch=gg.bind(null,Ha,S),[j.memoizedState,S]},useRef:function(S){var D=TA();return S={current:S},D.memoizedState=S},useState:ug,useDebugValue:hg,useResponder:lg,useDeferredValue:function(S,D){var T=ug(S),j=T[0],Y=T[1];return Ct(function(){a.unstable_next(function(){var Ae=Hs.suspense;Hs.suspense=D===void 0?null:D;try{Y(S)}finally{Hs.suspense=Ae}})},[S,D]),j},useTransition:function(S){var D=ug(!1),T=D[0],j=D[1];return[Cu(function(Y){j(!0),a.unstable_next(function(){var Ae=Hs.suspense;Hs.suspense=S===void 0?null:S;try{j(!1),Y()}finally{Hs.suspense=Ae}})},[S,T]),T]}},km={readContext:ms,useCallback:xm,useContext:ms,useEffect:bm,useImperativeHandle:function(S,D,T){return T=T!=null?T.concat([S]):null,Ic(4,36,pg.bind(null,D,S),T)},useLayoutEffect:function(S,D){return Ic(4,36,S,D)},useMemo:function(S,D){var T=pp();D=D===void 0?null:D;var j=T.memoizedState;return j!==null&&D!==null&&Eu(D,j[1])?j[0]:(S=S(),T.memoizedState=[S,D],S)},useReducer:Cs,useRef:function(){return pp().memoizedState},useState:Ag,useDebugValue:hg,useResponder:lg,useDeferredValue:function(S,D){var T=Ag(S),j=T[0],Y=T[1];return bm(function(){a.unstable_next(function(){var Ae=Hs.suspense;Hs.suspense=D===void 0?null:D;try{Y(S)}finally{Hs.suspense=Ae}})},[S,D]),j},useTransition:function(S){var D=Ag(!1),T=D[0],j=D[1];return[xm(function(Y){j(!0),a.unstable_next(function(){var Ae=Hs.suspense;Hs.suspense=S===void 0?null:S;try{j(!1),Y()}finally{Hs.suspense=Ae}})},[S,T]),T]}},Aa=null,Bc=null,Il=!1;function Iu(S,D){var T=Dl(5,null,null,0);T.elementType="DELETED",T.type="DELETED",T.stateNode=D,T.return=S,T.effectTag=8,S.lastEffect!==null?(S.lastEffect.nextEffect=T,S.lastEffect=T):S.firstEffect=S.lastEffect=T}function dg(S,D){switch(S.tag){case 5:return D=io(D,S.type,S.pendingProps),D!==null?(S.stateNode=D,!0):!1;case 6:return D=Pi(D,S.pendingProps),D!==null?(S.stateNode=D,!0):!1;case 13:return!1;default:return!1}}function NA(S){if(Il){var D=Bc;if(D){var T=D;if(!dg(S,D)){if(D=cc(T),!D||!dg(S,D)){S.effectTag=S.effectTag&-1025|2,Il=!1,Aa=S;return}Iu(Aa,T)}Aa=S,Bc=cu(D)}else S.effectTag=S.effectTag&-1025|2,Il=!1,Aa=S}}function gp(S){for(S=S.return;S!==null&&S.tag!==5&&S.tag!==3&&S.tag!==13;)S=S.return;Aa=S}function ja(S){if(!y||S!==Aa)return!1;if(!Il)return gp(S),Il=!0,!1;var D=S.type;if(S.tag!==5||D!=="head"&&D!=="body"&&!ke(D,S.memoizedProps))for(D=Bc;D;)Iu(S,D),D=cc(D);if(gp(S),S.tag===13){if(!y)throw Error(n(316));if(S=S.memoizedState,S=S!==null?S.dehydrated:null,!S)throw Error(n(317));Bc=Os(S)}else Bc=Aa?cc(S.stateNode):null;return!0}function mg(){y&&(Bc=Aa=null,Il=!1)}var dp=u.ReactCurrentOwner,Go=!1;function ws(S,D,T,j){D.child=S===null?sg(D,null,T,j):gu(D,S.child,T,j)}function Ii(S,D,T,j,Y){T=T.render;var Ae=D.ref;return ds(D,Y),j=cg(S,D,T,j,Ae,Y),S!==null&&!Go?(D.updateQueue=S.updateQueue,D.effectTag&=-517,S.expirationTime<=Y&&(S.expirationTime=0),si(S,D,Y)):(D.effectTag|=1,ws(S,D,j,Y),D.child)}function Qm(S,D,T,j,Y,Ae){if(S===null){var De=T.type;return typeof De=="function"&&!Qw(De)&&De.defaultProps===void 0&&T.compare===null&&T.defaultProps===void 0?(D.tag=15,D.type=De,Fm(S,D,De,j,Y,Ae)):(S=jm(T.type,null,j,null,D.mode,Ae),S.ref=D.ref,S.return=D,D.child=S)}return De=S.child,Y<Ae&&(Y=De.memoizedProps,T=T.compare,T=T!==null?T:Fn,T(Y,j)&&S.ref===D.ref)?si(S,D,Ae):(D.effectTag|=1,S=WA(De,j,Ae),S.ref=D.ref,S.return=D,D.child=S)}function Fm(S,D,T,j,Y,Ae){return S!==null&&Fn(S.memoizedProps,j)&&S.ref===D.ref&&(Go=!1,Y<Ae)?si(S,D,Ae):LA(S,D,T,j,Ae)}function qo(S,D){var T=D.ref;(S===null&&T!==null||S!==null&&S.ref!==T)&&(D.effectTag|=128)}function LA(S,D,T,j,Y){var Ae=ii(T)?tr:Mn.current;return Ae=Oe(D,Ae),ds(D,Y),T=cg(S,D,T,j,Ae,Y),S!==null&&!Go?(D.updateQueue=S.updateQueue,D.effectTag&=-517,S.expirationTime<=Y&&(S.expirationTime=0),si(S,D,Y)):(D.effectTag|=1,ws(S,D,T,Y),D.child)}function mp(S,D,T,j,Y){if(ii(T)){var Ae=!0;Ac(D)}else Ae=!1;if(ds(D,Y),D.stateNode===null)S!==null&&(S.alternate=null,D.alternate=null,D.effectTag|=2),es(D,T,j,Y),jo(D,T,j,Y),j=!0;else if(S===null){var De=D.stateNode,vt=D.memoizedProps;De.props=vt;var wt=De.context,xt=T.contextType;typeof xt=="object"&&xt!==null?xt=ms(xt):(xt=ii(T)?tr:Mn.current,xt=Oe(D,xt));var _r=T.getDerivedStateFromProps,is=typeof _r=="function"||typeof De.getSnapshotBeforeUpdate=="function";is||typeof De.UNSAFE_componentWillReceiveProps!="function"&&typeof De.componentWillReceiveProps!="function"||(vt!==j||wt!==xt)&&bi(D,De,j,xt),_s=!1;var di=D.memoizedState;wt=De.state=di;var po=D.updateQueue;po!==null&&(ye(D,po,j,De,Y),wt=D.memoizedState),vt!==j||di!==wt||_i.current||_s?(typeof _r=="function"&&(er(D,T,_r,j),wt=D.memoizedState),(vt=_s||Gi(D,T,vt,j,di,wt,xt))?(is||typeof De.UNSAFE_componentWillMount!="function"&&typeof De.componentWillMount!="function"||(typeof De.componentWillMount=="function"&&De.componentWillMount(),typeof De.UNSAFE_componentWillMount=="function"&&De.UNSAFE_componentWillMount()),typeof De.componentDidMount=="function"&&(D.effectTag|=4)):(typeof De.componentDidMount=="function"&&(D.effectTag|=4),D.memoizedProps=j,D.memoizedState=wt),De.props=j,De.state=wt,De.context=xt,j=vt):(typeof De.componentDidMount=="function"&&(D.effectTag|=4),j=!1)}else De=D.stateNode,vt=D.memoizedProps,De.props=D.type===D.elementType?vt:Ci(D.type,vt),wt=De.context,xt=T.contextType,typeof xt=="object"&&xt!==null?xt=ms(xt):(xt=ii(T)?tr:Mn.current,xt=Oe(D,xt)),_r=T.getDerivedStateFromProps,(is=typeof _r=="function"||typeof De.getSnapshotBeforeUpdate=="function")||typeof De.UNSAFE_componentWillReceiveProps!="function"&&typeof De.componentWillReceiveProps!="function"||(vt!==j||wt!==xt)&&bi(D,De,j,xt),_s=!1,wt=D.memoizedState,di=De.state=wt,po=D.updateQueue,po!==null&&(ye(D,po,j,De,Y),di=D.memoizedState),vt!==j||wt!==di||_i.current||_s?(typeof _r=="function"&&(er(D,T,_r,j),di=D.memoizedState),(_r=_s||Gi(D,T,vt,j,wt,di,xt))?(is||typeof De.UNSAFE_componentWillUpdate!="function"&&typeof De.componentWillUpdate!="function"||(typeof De.componentWillUpdate=="function"&&De.componentWillUpdate(j,di,xt),typeof De.UNSAFE_componentWillUpdate=="function"&&De.UNSAFE_componentWillUpdate(j,di,xt)),typeof De.componentDidUpdate=="function"&&(D.effectTag|=4),typeof De.getSnapshotBeforeUpdate=="function"&&(D.effectTag|=256)):(typeof De.componentDidUpdate!="function"||vt===S.memoizedProps&&wt===S.memoizedState||(D.effectTag|=4),typeof De.getSnapshotBeforeUpdate!="function"||vt===S.memoizedProps&&wt===S.memoizedState||(D.effectTag|=256),D.memoizedProps=j,D.memoizedState=di),De.props=j,De.state=di,De.context=xt,j=_r):(typeof De.componentDidUpdate!="function"||vt===S.memoizedProps&&wt===S.memoizedState||(D.effectTag|=4),typeof De.getSnapshotBeforeUpdate!="function"||vt===S.memoizedProps&&wt===S.memoizedState||(D.effectTag|=256),j=!1);return yp(S,D,T,j,Ae,Y)}function yp(S,D,T,j,Y,Ae){qo(S,D);var De=(D.effectTag&64)!==0;if(!j&&!De)return Y&&El(D,T,!1),si(S,D,Ae);j=D.stateNode,dp.current=D;var vt=De&&typeof T.getDerivedStateFromError!="function"?null:j.render();return D.effectTag|=1,S!==null&&De?(D.child=gu(D,S.child,null,Ae),D.child=gu(D,null,vt,Ae)):ws(S,D,vt,Ae),D.memoizedState=j.state,Y&&El(D,T,!0),D.child}function yg(S){var D=S.stateNode;D.pendingContext?uc(S,D.pendingContext,D.pendingContext!==D.context):D.context&&uc(S,D.context,!1),og(S,D.containerInfo)}var fa={dehydrated:null,retryTime:0};function ln(S,D,T){var j=D.mode,Y=D.pendingProps,Ae=$n.current,De=!1,vt;if((vt=(D.effectTag&64)!==0)||(vt=(Ae&2)!==0&&(S===null||S.memoizedState!==null)),vt?(De=!0,D.effectTag&=-65):S!==null&&S.memoizedState===null||Y.fallback===void 0||Y.unstable_avoidThisFallback===!0||(Ae|=1),On($n,Ae&1,D),S===null){if(Y.fallback!==void 0&&NA(D),De){if(De=Y.fallback,Y=bu(null,j,0,null),Y.return=D,(D.mode&2)===0)for(S=D.memoizedState!==null?D.child.child:D.child,Y.child=S;S!==null;)S.return=Y,S=S.sibling;return T=bu(De,j,T,null),T.return=D,Y.sibling=T,D.memoizedState=fa,D.child=Y,T}return j=Y.children,D.memoizedState=null,D.child=sg(D,null,j,T)}if(S.memoizedState!==null){if(S=S.child,j=S.sibling,De){if(Y=Y.fallback,T=WA(S,S.pendingProps,0),T.return=D,(D.mode&2)===0&&(De=D.memoizedState!==null?D.child.child:D.child,De!==S.child))for(T.child=De;De!==null;)De.return=T,De=De.sibling;return j=WA(j,Y,j.expirationTime),j.return=D,T.sibling=j,T.childExpirationTime=0,D.memoizedState=fa,D.child=T,j}return T=gu(D,S.child,Y.children,T),D.memoizedState=null,D.child=T}if(S=S.child,De){if(De=Y.fallback,Y=bu(null,j,0,null),Y.return=D,Y.child=S,S!==null&&(S.return=Y),(D.mode&2)===0)for(S=D.memoizedState!==null?D.child.child:D.child,Y.child=S;S!==null;)S.return=Y,S=S.sibling;return T=bu(De,j,T,null),T.return=D,Y.sibling=T,T.effectTag|=2,Y.childExpirationTime=0,D.memoizedState=fa,D.child=Y,T}return D.memoizedState=null,D.child=gu(D,S,Y.children,T)}function Ao(S,D){S.expirationTime<D&&(S.expirationTime=D);var T=S.alternate;T!==null&&T.expirationTime<D&&(T.expirationTime=D),gs(S.return,D)}function OA(S,D,T,j,Y,Ae){var De=S.memoizedState;De===null?S.memoizedState={isBackwards:D,rendering:null,last:j,tail:T,tailExpiration:0,tailMode:Y,lastEffect:Ae}:(De.isBackwards=D,De.rendering=null,De.last=j,De.tail=T,De.tailExpiration=0,De.tailMode=Y,De.lastEffect=Ae)}function Ga(S,D,T){var j=D.pendingProps,Y=j.revealOrder,Ae=j.tail;if(ws(S,D,j.children,T),j=$n.current,(j&2)!==0)j=j&1|2,D.effectTag|=64;else{if(S!==null&&(S.effectTag&64)!==0)e:for(S=D.child;S!==null;){if(S.tag===13)S.memoizedState!==null&&Ao(S,T);else if(S.tag===19)Ao(S,T);else if(S.child!==null){S.child.return=S,S=S.child;continue}if(S===D)break e;for(;S.sibling===null;){if(S.return===null||S.return===D)break e;S=S.return}S.sibling.return=S.return,S=S.sibling}j&=1}if(On($n,j,D),(D.mode&2)===0)D.memoizedState=null;else switch(Y){case"forwards":for(T=D.child,Y=null;T!==null;)S=T.alternate,S!==null&&fp(S)===null&&(Y=T),T=T.sibling;T=Y,T===null?(Y=D.child,D.child=null):(Y=T.sibling,T.sibling=null),OA(D,!1,Y,T,Ae,D.lastEffect);break;case"backwards":for(T=null,Y=D.child,D.child=null;Y!==null;){if(S=Y.alternate,S!==null&&fp(S)===null){D.child=Y;break}S=Y.sibling,Y.sibling=T,T=Y,Y=S}OA(D,!0,T,null,Ae,D.lastEffect);break;case"together":OA(D,!1,null,null,void 0,D.lastEffect);break;default:D.memoizedState=null}return D.child}function si(S,D,T){S!==null&&(D.dependencies=S.dependencies);var j=D.expirationTime;if(j!==0&&Hm(j),D.childExpirationTime<T)return null;if(S!==null&&D.child!==S.child)throw Error(n(153));if(D.child!==null){for(S=D.child,T=WA(S,S.pendingProps,S.expirationTime),D.child=T,T.return=D;S.sibling!==null;)S=S.sibling,T=T.sibling=WA(S,S.pendingProps,S.expirationTime),T.return=D;T.sibling=null}return D.child}function pa(S){S.effectTag|=4}var vc,Bl,ts,qr;if(w)vc=function(S,D){for(var T=D.child;T!==null;){if(T.tag===5||T.tag===6)H(S,T.stateNode);else if(T.tag!==4&&T.child!==null){T.child.return=T,T=T.child;continue}if(T===D)break;for(;T.sibling===null;){if(T.return===null||T.return===D)return;T=T.return}T.sibling.return=T.return,T=T.sibling}},Bl=function(){},ts=function(S,D,T,j,Y){if(S=S.memoizedProps,S!==j){var Ae=D.stateNode,De=ca(uo.current);T=Re(Ae,T,S,j,Y,De),(D.updateQueue=T)&&pa(D)}},qr=function(S,D,T,j){T!==j&&pa(D)};else if(P){vc=function(S,D,T,j){for(var Y=D.child;Y!==null;){if(Y.tag===5){var Ae=Y.stateNode;T&&j&&(Ae=Ti(Ae,Y.type,Y.memoizedProps,Y)),H(S,Ae)}else if(Y.tag===6)Ae=Y.stateNode,T&&j&&(Ae=ps(Ae,Y.memoizedProps,Y)),H(S,Ae);else if(Y.tag!==4){if(Y.tag===13&&(Y.effectTag&4)!==0&&(Ae=Y.memoizedState!==null)){var De=Y.child;if(De!==null&&(De.child!==null&&(De.child.return=De,vc(S,De,!0,Ae)),Ae=De.sibling,Ae!==null)){Ae.return=Y,Y=Ae;continue}}if(Y.child!==null){Y.child.return=Y,Y=Y.child;continue}}if(Y===D)break;for(;Y.sibling===null;){if(Y.return===null||Y.return===D)return;Y=Y.return}Y.sibling.return=Y.return,Y=Y.sibling}};var Ep=function(S,D,T,j){for(var Y=D.child;Y!==null;){if(Y.tag===5){var Ae=Y.stateNode;T&&j&&(Ae=Ti(Ae,Y.type,Y.memoizedProps,Y)),Wr(S,Ae)}else if(Y.tag===6)Ae=Y.stateNode,T&&j&&(Ae=ps(Ae,Y.memoizedProps,Y)),Wr(S,Ae);else if(Y.tag!==4){if(Y.tag===13&&(Y.effectTag&4)!==0&&(Ae=Y.memoizedState!==null)){var De=Y.child;if(De!==null&&(De.child!==null&&(De.child.return=De,Ep(S,De,!0,Ae)),Ae=De.sibling,Ae!==null)){Ae.return=Y,Y=Ae;continue}}if(Y.child!==null){Y.child.return=Y,Y=Y.child;continue}}if(Y===D)break;for(;Y.sibling===null;){if(Y.return===null||Y.return===D)return;Y=Y.return}Y.sibling.return=Y.return,Y=Y.sibling}};Bl=function(S){var D=S.stateNode;if(S.firstEffect!==null){var T=D.containerInfo,j=br(T);Ep(j,S,!1,!1),D.pendingChildren=j,pa(S),Kn(T,j)}},ts=function(S,D,T,j,Y){var Ae=S.stateNode,De=S.memoizedProps;if((S=D.firstEffect===null)&&De===j)D.stateNode=Ae;else{var vt=D.stateNode,wt=ca(uo.current),xt=null;De!==j&&(xt=Re(vt,T,De,j,Y,wt)),S&&xt===null?D.stateNode=Ae:(Ae=mr(Ae,xt,T,De,j,D,S,vt),at(Ae,T,j,Y,wt)&&pa(D),D.stateNode=Ae,S?pa(D):vc(Ae,D,!1,!1))}},qr=function(S,D,T,j){T!==j&&(S=ca(mc.current),T=ca(uo.current),D.stateNode=He(j,S,T,D),pa(D))}}else Bl=function(){},ts=function(){},qr=function(){};function Dc(S,D){switch(S.tailMode){case"hidden":D=S.tail;for(var T=null;D!==null;)D.alternate!==null&&(T=D),D=D.sibling;T===null?S.tail=null:T.sibling=null;break;case"collapsed":T=S.tail;for(var j=null;T!==null;)T.alternate!==null&&(j=T),T=T.sibling;j===null?D||S.tail===null?S.tail=null:S.tail.sibling=null:j.sibling=null}}function Cw(S){switch(S.tag){case 1:ii(S.type)&&Ma(S);var D=S.effectTag;return D&4096?(S.effectTag=D&-4097|64,S):null;case 3:if(yc(S),hr(S),D=S.effectTag,(D&64)!==0)throw Error(n(285));return S.effectTag=D&-4097|64,S;case 5:return ag(S),null;case 13:return Vn($n,S),D=S.effectTag,D&4096?(S.effectTag=D&-4097|64,S):null;case 19:return Vn($n,S),null;case 4:return yc(S),null;case 10:return wi(S),null;default:return null}}function Eg(S,D){return{value:S,source:D,stack:ml(D)}}var Cg=typeof WeakSet=="function"?WeakSet:Set;function qa(S,D){var T=D.source,j=D.stack;j===null&&T!==null&&(j=ml(T)),T!==null&&he(T.type),D=D.value,S!==null&&S.tag===1&&he(S.type);try{console.error(D)}catch(Y){setTimeout(function(){throw Y})}}function Rm(S,D){try{D.props=S.memoizedProps,D.state=S.memoizedState,D.componentWillUnmount()}catch(T){YA(S,T)}}function wg(S){var D=S.ref;if(D!==null)if(typeof D=="function")try{D(null)}catch(T){YA(S,T)}else D.current=null}function Qt(S,D){switch(D.tag){case 0:case 11:case 15:N(2,0,D);break;case 1:if(D.effectTag&256&&S!==null){var T=S.memoizedProps,j=S.memoizedState;S=D.stateNode,D=S.getSnapshotBeforeUpdate(D.elementType===D.type?T:Ci(D.type,T),j),S.__reactInternalSnapshotBeforeUpdate=D}break;case 3:case 5:case 6:case 4:case 17:break;default:throw Error(n(163))}}function N(S,D,T){if(T=T.updateQueue,T=T!==null?T.lastEffect:null,T!==null){var j=T=T.next;do{if((j.tag&S)!==0){var Y=j.destroy;j.destroy=void 0,Y!==void 0&&Y()}(j.tag&D)!==0&&(Y=j.create,j.destroy=Y()),j=j.next}while(j!==T)}}function K(S,D,T){switch(typeof kw=="function"&&kw(D),D.tag){case 0:case 11:case 14:case 15:if(S=D.updateQueue,S!==null&&(S=S.lastEffect,S!==null)){var j=S.next;lo(97<T?97:T,function(){var Y=j;do{var Ae=Y.destroy;if(Ae!==void 0){var De=D;try{Ae()}catch(vt){YA(De,vt)}}Y=Y.next}while(Y!==j)})}break;case 1:wg(D),T=D.stateNode,typeof T.componentWillUnmount=="function"&&Rm(D,T);break;case 5:wg(D);break;case 4:w?Cr(S,D,T):P&&ze(D)}}function re(S,D,T){for(var j=D;;)if(K(S,j,T),j.child===null||w&&j.tag===4){if(j===D)break;for(;j.sibling===null;){if(j.return===null||j.return===D)return;j=j.return}j.sibling.return=j.return,j=j.sibling}else j.child.return=j,j=j.child}function pe(S){var D=S.alternate;S.return=null,S.child=null,S.memoizedState=null,S.updateQueue=null,S.dependencies=null,S.alternate=null,S.firstEffect=null,S.lastEffect=null,S.pendingProps=null,S.memoizedProps=null,D!==null&&pe(D)}function ze(S){if(P){S=S.stateNode.containerInfo;var D=br(S);Ns(S,D)}}function mt(S){return S.tag===5||S.tag===3||S.tag===4}function fr(S){if(w){e:{for(var D=S.return;D!==null;){if(mt(D)){var T=D;break e}D=D.return}throw Error(n(160))}switch(D=T.stateNode,T.tag){case 5:var j=!1;break;case 3:D=D.containerInfo,j=!0;break;case 4:D=D.containerInfo,j=!0;break;default:throw Error(n(161))}T.effectTag&16&&(Gt(D),T.effectTag&=-17);e:t:for(T=S;;){for(;T.sibling===null;){if(T.return===null||mt(T.return)){T=null;break e}T=T.return}for(T.sibling.return=T.return,T=T.sibling;T.tag!==5&&T.tag!==6&&T.tag!==18;){if(T.effectTag&2||T.child===null||T.tag===4)continue t;T.child.return=T,T=T.child}if(!(T.effectTag&2)){T=T.stateNode;break e}}for(var Y=S;;){var Ae=Y.tag===5||Y.tag===6;if(Ae)Ae=Ae?Y.stateNode:Y.stateNode.instance,T?j?Ne(D,Ae,T):Pe(D,Ae,T):j?z(D,Ae):F(D,Ae);else if(Y.tag!==4&&Y.child!==null){Y.child.return=Y,Y=Y.child;continue}if(Y===S)break;for(;Y.sibling===null;){if(Y.return===null||Y.return===S)return;Y=Y.return}Y.sibling.return=Y.return,Y=Y.sibling}}}function Cr(S,D,T){for(var j=D,Y=!1,Ae,De;;){if(!Y){Y=j.return;e:for(;;){if(Y===null)throw Error(n(160));switch(Ae=Y.stateNode,Y.tag){case 5:De=!1;break e;case 3:Ae=Ae.containerInfo,De=!0;break e;case 4:Ae=Ae.containerInfo,De=!0;break e}Y=Y.return}Y=!0}if(j.tag===5||j.tag===6)re(S,j,T),De?dt(Ae,j.stateNode):ot(Ae,j.stateNode);else if(j.tag===4){if(j.child!==null){Ae=j.stateNode.containerInfo,De=!0,j.child.return=j,j=j.child;continue}}else if(K(S,j,T),j.child!==null){j.child.return=j,j=j.child;continue}if(j===D)break;for(;j.sibling===null;){if(j.return===null||j.return===D)return;j=j.return,j.tag===4&&(Y=!1)}j.sibling.return=j.return,j=j.sibling}}function yn(S,D){if(w)switch(D.tag){case 0:case 11:case 14:case 15:N(4,8,D);break;case 1:break;case 5:var T=D.stateNode;if(T!=null){var j=D.memoizedProps;S=S!==null?S.memoizedProps:j;var Y=D.type,Ae=D.updateQueue;D.updateQueue=null,Ae!==null&&ie(T,Ae,Y,S,j,D)}break;case 6:if(D.stateNode===null)throw Error(n(162));T=D.memoizedProps,X(D.stateNode,S!==null?S.memoizedProps:T,T);break;case 3:y&&(D=D.stateNode,D.hydrate&&(D.hydrate=!1,Dn(D.containerInfo)));break;case 12:break;case 13:oi(D),Oi(D);break;case 19:Oi(D);break;case 17:break;case 20:break;case 21:break;default:throw Error(n(163))}else{switch(D.tag){case 0:case 11:case 14:case 15:N(4,8,D);return;case 12:return;case 13:oi(D),Oi(D);return;case 19:Oi(D);return;case 3:y&&(T=D.stateNode,T.hydrate&&(T.hydrate=!1,Dn(T.containerInfo)))}e:if(P)switch(D.tag){case 1:case 5:case 6:case 20:break e;case 3:case 4:D=D.stateNode,Ns(D.containerInfo,D.pendingChildren);break e;default:throw Error(n(163))}}}function oi(S){var D=S;if(S.memoizedState===null)var T=!1;else T=!0,D=S.child,Bw=Li();if(w&&D!==null){e:if(S=D,w)for(D=S;;){if(D.tag===5){var j=D.stateNode;T?$t(j):an(D.stateNode,D.memoizedProps)}else if(D.tag===6)j=D.stateNode,T?bt(j):Qr(j,D.memoizedProps);else if(D.tag===13&&D.memoizedState!==null&&D.memoizedState.dehydrated===null){j=D.child.sibling,j.return=D,D=j;continue}else if(D.child!==null){D.child.return=D,D=D.child;continue}if(D===S)break e;for(;D.sibling===null;){if(D.return===null||D.return===S)break e;D=D.return}D.sibling.return=D.return,D=D.sibling}}}function Oi(S){var D=S.updateQueue;if(D!==null){S.updateQueue=null;var T=S.stateNode;T===null&&(T=S.stateNode=new Cg),D.forEach(function(j){var Y=yF.bind(null,S,j);T.has(j)||(T.add(j),j.then(Y,Y))})}}var Ig=typeof WeakMap=="function"?WeakMap:Map;function qv(S,D,T){T=ys(T,null),T.tag=3,T.payload={element:null};var j=D.value;return T.callback=function(){vu||(vu=!0,Mm=j),qa(S,D)},T}function Yv(S,D,T){T=ys(T,null),T.tag=3;var j=S.type.getDerivedStateFromError;if(typeof j=="function"){var Y=D.value;T.payload=function(){return qa(S,D),j(Y)}}var Ae=S.stateNode;return Ae!==null&&typeof Ae.componentDidCatch=="function"&&(T.callback=function(){typeof j!="function"&&(Du===null?Du=new Set([this]):Du.add(this),qa(S,D));var De=D.stack;this.componentDidCatch(D.value,{componentStack:De!==null?De:""})}),T}var ww=Math.ceil,Cp=u.ReactCurrentDispatcher,Iw=u.ReactCurrentOwner,En=0,Tm=8,rs=16,js=32,Bu=0,Nm=1,Bi=2,ha=3,vl=4,Sc=5,yr=En,gi=null,Or=null,ns=0,Yi=Bu,Lm=null,Ya=1073741823,MA=1073741823,Om=null,wp=0,UA=!1,Bw=0,vw=500,sr=null,vu=!1,Mm=null,Du=null,Ip=!1,Bg=null,_A=90,HA=null,vg=0,Dw=null,Um=0;function ga(){return(yr&(rs|js))!==En?1073741821-(Li()/10|0):Um!==0?Um:Um=1073741821-(Li()/10|0)}function jA(S,D,T){if(D=D.mode,(D&2)===0)return 1073741823;var j=_o();if((D&4)===0)return j===99?1073741823:1073741822;if((yr&rs)!==En)return ns;if(T!==null)S=Ua(S,T.timeoutMs|0||5e3,250);else switch(j){case 99:S=1073741823;break;case 98:S=Ua(S,150,100);break;case 97:case 96:S=Ua(S,5e3,250);break;case 95:S=2;break;default:throw Error(n(326))}return gi!==null&&S===ns&&--S,S}function Pc(S,D){if(50<vg)throw vg=0,Dw=null,Error(n(185));if(S=Dg(S,D),S!==null){var T=_o();D===1073741823?(yr&Tm)!==En&&(yr&(rs|js))===En?Sw(S):(fo(S),yr===En&&ji()):fo(S),(yr&4)===En||T!==98&&T!==99||(HA===null?HA=new Map([[S,D]]):(T=HA.get(S),(T===void 0||T>D)&&HA.set(S,D)))}}function Dg(S,D){S.expirationTime<D&&(S.expirationTime=D);var T=S.alternate;T!==null&&T.expirationTime<D&&(T.expirationTime=D);var j=S.return,Y=null;if(j===null&&S.tag===3)Y=S.stateNode;else for(;j!==null;){if(T=j.alternate,j.childExpirationTime<D&&(j.childExpirationTime=D),T!==null&&T.childExpirationTime<D&&(T.childExpirationTime=D),j.return===null&&j.tag===3){Y=j.stateNode;break}j=j.return}return Y!==null&&(gi===Y&&(Hm(D),Yi===vl&&KA(Y,ns)),eD(Y,D)),Y}function _m(S){var D=S.lastExpiredTime;return D!==0||(D=S.firstPendingTime,!$v(S,D))?D:(D=S.lastPingedTime,S=S.nextKnownPendingLevel,D>S?D:S)}function fo(S){if(S.lastExpiredTime!==0)S.callbackExpirationTime=1073741823,S.callbackPriority=99,S.callbackNode=pu(Sw.bind(null,S));else{var D=_m(S),T=S.callbackNode;if(D===0)T!==null&&(S.callbackNode=null,S.callbackExpirationTime=0,S.callbackPriority=90);else{var j=ga();if(D===1073741823?j=99:D===1||D===2?j=95:(j=10*(1073741821-D)-10*(1073741821-j),j=0>=j?99:250>=j?98:5250>=j?97:95),T!==null){var Y=S.callbackPriority;if(S.callbackExpirationTime===D&&Y>=j)return;T!==PA&&Ce(T)}S.callbackExpirationTime=D,S.callbackPriority=j,D=D===1073741823?pu(Sw.bind(null,S)):gc(j,Wv.bind(null,S),{timeout:10*(1073741821-D)-Li()}),S.callbackNode=D}}}function Wv(S,D){if(Um=0,D)return D=ga(),Gm(S,D),fo(S),null;var T=_m(S);if(T!==0){if(D=S.callbackNode,(yr&(rs|js))!==En)throw Error(n(327));if(Bp(),S===gi&&T===ns||Su(S,T),Or!==null){var j=yr;yr|=rs;var Y=qA(S);do try{pF();break}catch(vt){GA(S,vt)}while(1);if(la(),yr=j,Cp.current=Y,Yi===Nm)throw D=Lm,Su(S,T),KA(S,T),fo(S),D;if(Or===null)switch(Y=S.finishedWork=S.current.alternate,S.finishedExpirationTime=T,j=Yi,gi=null,j){case Bu:case Nm:throw Error(n(345));case Bi:Gm(S,2<T?2:T);break;case ha:if(KA(S,T),j=S.lastSuspendedTime,T===j&&(S.nextKnownPendingLevel=bw(Y)),Ya===1073741823&&(Y=Bw+vw-Li(),10<Y)){if(UA){var Ae=S.lastPingedTime;if(Ae===0||Ae>=T){S.lastPingedTime=T,Su(S,T);break}}if(Ae=_m(S),Ae!==0&&Ae!==T)break;if(j!==0&&j!==T){S.lastPingedTime=j;break}S.timeoutHandle=Te(Pu.bind(null,S),Y);break}Pu(S);break;case vl:if(KA(S,T),j=S.lastSuspendedTime,T===j&&(S.nextKnownPendingLevel=bw(Y)),UA&&(Y=S.lastPingedTime,Y===0||Y>=T)){S.lastPingedTime=T,Su(S,T);break}if(Y=_m(S),Y!==0&&Y!==T)break;if(j!==0&&j!==T){S.lastPingedTime=j;break}if(MA!==1073741823?j=10*(1073741821-MA)-Li():Ya===1073741823?j=0:(j=10*(1073741821-Ya)-5e3,Y=Li(),T=10*(1073741821-T)-Y,j=Y-j,0>j&&(j=0),j=(120>j?120:480>j?480:1080>j?1080:1920>j?1920:3e3>j?3e3:4320>j?4320:1960*ww(j/1960))-j,T<j&&(j=T)),10<j){S.timeoutHandle=Te(Pu.bind(null,S),j);break}Pu(S);break;case Sc:if(Ya!==1073741823&&Om!==null){Ae=Ya;var De=Om;if(j=De.busyMinDurationMs|0,0>=j?j=0:(Y=De.busyDelayMs|0,Ae=Li()-(10*(1073741821-Ae)-(De.timeoutMs|0||5e3)),j=Ae<=Y?0:Y+j-Ae),10<j){KA(S,T),S.timeoutHandle=Te(Pu.bind(null,S),j);break}}Pu(S);break;default:throw Error(n(329))}if(fo(S),S.callbackNode===D)return Wv.bind(null,S)}}return null}function Sw(S){var D=S.lastExpiredTime;if(D=D!==0?D:1073741823,S.finishedExpirationTime===D)Pu(S);else{if((yr&(rs|js))!==En)throw Error(n(327));if(Bp(),S===gi&&D===ns||Su(S,D),Or!==null){var T=yr;yr|=rs;var j=qA(S);do try{fF();break}catch(Y){GA(S,Y)}while(1);if(la(),yr=T,Cp.current=j,Yi===Nm)throw T=Lm,Su(S,D),KA(S,D),fo(S),T;if(Or!==null)throw Error(n(261));S.finishedWork=S.current.alternate,S.finishedExpirationTime=D,gi=null,Pu(S),fo(S)}}return null}function Kv(S,D){Gm(S,D),fo(S),(yr&(rs|js))===En&&ji()}function AF(){if(HA!==null){var S=HA;HA=null,S.forEach(function(D,T){Gm(T,D),fo(T)}),ji()}}function Vv(S,D){if((yr&(rs|js))!==En)throw Error(n(187));var T=yr;yr|=1;try{return lo(99,S.bind(null,D))}finally{yr=T,ji()}}function Su(S,D){S.finishedWork=null,S.finishedExpirationTime=0;var T=S.timeoutHandle;if(T!==je&&(S.timeoutHandle=je,Je(T)),Or!==null)for(T=Or.return;T!==null;){var j=T;switch(j.tag){case 1:var Y=j.type.childContextTypes;Y!=null&&Ma(j);break;case 3:yc(j),hr(j);break;case 5:ag(j);break;case 4:yc(j);break;case 13:Vn($n,j);break;case 19:Vn($n,j);break;case 10:wi(j)}T=T.return}gi=S,Or=WA(S.current,null,D),ns=D,Yi=Bu,Lm=null,MA=Ya=1073741823,Om=null,wp=0,UA=!1}function GA(S,D){do{try{if(la(),yw(),Or===null||Or.return===null)return Yi=Nm,Lm=D,null;e:{var T=S,j=Or.return,Y=Or,Ae=D;if(D=ns,Y.effectTag|=2048,Y.firstEffect=Y.lastEffect=null,Ae!==null&&typeof Ae=="object"&&typeof Ae.then=="function"){var De=Ae,vt=($n.current&1)!==0,wt=j;do{var xt;if(xt=wt.tag===13){var _r=wt.memoizedState;if(_r!==null)xt=_r.dehydrated!==null;else{var is=wt.memoizedProps;xt=is.fallback===void 0?!1:is.unstable_avoidThisFallback!==!0?!0:!vt}}if(xt){var di=wt.updateQueue;if(di===null){var po=new Set;po.add(De),wt.updateQueue=po}else di.add(De);if((wt.mode&2)===0){if(wt.effectTag|=64,Y.effectTag&=-2981,Y.tag===1)if(Y.alternate===null)Y.tag=17;else{var VA=ys(1073741823,null);VA.tag=2,tt(Y,VA)}Y.expirationTime=1073741823;break e}Ae=void 0,Y=D;var Yo=T.pingCache;if(Yo===null?(Yo=T.pingCache=new Ig,Ae=new Set,Yo.set(De,Ae)):(Ae=Yo.get(De),Ae===void 0&&(Ae=new Set,Yo.set(De,Ae))),!Ae.has(Y)){Ae.add(Y);var rt=mF.bind(null,T,De,Y);De.then(rt,rt)}wt.effectTag|=4096,wt.expirationTime=D;break e}wt=wt.return}while(wt!==null);Ae=Error((he(Y.type)||"A React component")+` suspended while rendering, but no fallback UI was specified. + +Add a <Suspense fallback=...> component higher in the tree to provide a loading indicator or placeholder to display.`+ml(Y))}Yi!==Sc&&(Yi=Bi),Ae=Eg(Ae,Y),wt=j;do{switch(wt.tag){case 3:De=Ae,wt.effectTag|=4096,wt.expirationTime=D;var Ve=qv(wt,De,D);It(wt,Ve);break e;case 1:De=Ae;var ft=wt.type,Wt=wt.stateNode;if((wt.effectTag&64)===0&&(typeof ft.getDerivedStateFromError=="function"||Wt!==null&&typeof Wt.componentDidCatch=="function"&&(Du===null||!Du.has(Wt)))){wt.effectTag|=4096,wt.expirationTime=D;var vr=Yv(wt,De,D);It(wt,vr);break e}}wt=wt.return}while(wt!==null)}Or=zv(Or)}catch(Pn){D=Pn;continue}break}while(1)}function qA(){var S=Cp.current;return Cp.current=wu,S===null?wu:S}function Pw(S,D){S<Ya&&2<S&&(Ya=S),D!==null&&S<MA&&2<S&&(MA=S,Om=D)}function Hm(S){S>wp&&(wp=S)}function fF(){for(;Or!==null;)Or=Jv(Or)}function pF(){for(;Or!==null&&!Rt();)Or=Jv(Or)}function Jv(S){var D=Zv(S.alternate,S,ns);return S.memoizedProps=S.pendingProps,D===null&&(D=zv(S)),Iw.current=null,D}function zv(S){Or=S;do{var D=Or.alternate;if(S=Or.return,(Or.effectTag&2048)===0){e:{var T=D;D=Or;var j=ns,Y=D.pendingProps;switch(D.tag){case 2:break;case 16:break;case 15:case 0:break;case 1:ii(D.type)&&Ma(D);break;case 3:yc(D),hr(D),Y=D.stateNode,Y.pendingContext&&(Y.context=Y.pendingContext,Y.pendingContext=null),(T===null||T.child===null)&&ja(D)&&pa(D),Bl(D);break;case 5:ag(D);var Ae=ca(mc.current);if(j=D.type,T!==null&&D.stateNode!=null)ts(T,D,j,Y,Ae),T.ref!==D.ref&&(D.effectTag|=128);else if(Y){if(T=ca(uo.current),ja(D)){if(Y=D,!y)throw Error(n(175));T=lp(Y.stateNode,Y.type,Y.memoizedProps,Ae,T,Y),Y.updateQueue=T,T=T!==null,T&&pa(D)}else{var De=At(j,Y,Ae,T,D);vc(De,D,!1,!1),D.stateNode=De,at(De,j,Y,Ae,T)&&pa(D)}D.ref!==null&&(D.effectTag|=128)}else if(D.stateNode===null)throw Error(n(166));break;case 6:if(T&&D.stateNode!=null)qr(T,D,T.memoizedProps,Y);else{if(typeof Y!="string"&&D.stateNode===null)throw Error(n(166));if(T=ca(mc.current),Ae=ca(uo.current),ja(D)){if(T=D,!y)throw Error(n(176));(T=cp(T.stateNode,T.memoizedProps,T))&&pa(D)}else D.stateNode=He(Y,T,Ae,D)}break;case 11:break;case 13:if(Vn($n,D),Y=D.memoizedState,(D.effectTag&64)!==0){D.expirationTime=j;break e}Y=Y!==null,Ae=!1,T===null?D.memoizedProps.fallback!==void 0&&ja(D):(j=T.memoizedState,Ae=j!==null,Y||j===null||(j=T.child.sibling,j!==null&&(De=D.firstEffect,De!==null?(D.firstEffect=j,j.nextEffect=De):(D.firstEffect=D.lastEffect=j,j.nextEffect=null),j.effectTag=8))),Y&&!Ae&&(D.mode&2)!==0&&(T===null&&D.memoizedProps.unstable_avoidThisFallback!==!0||($n.current&1)!==0?Yi===Bu&&(Yi=ha):((Yi===Bu||Yi===ha)&&(Yi=vl),wp!==0&&gi!==null&&(KA(gi,ns),eD(gi,wp)))),P&&Y&&(D.effectTag|=4),w&&(Y||Ae)&&(D.effectTag|=4);break;case 7:break;case 8:break;case 12:break;case 4:yc(D),Bl(D);break;case 10:wi(D);break;case 9:break;case 14:break;case 17:ii(D.type)&&Ma(D);break;case 19:if(Vn($n,D),Y=D.memoizedState,Y===null)break;if(Ae=(D.effectTag&64)!==0,De=Y.rendering,De===null){if(Ae)Dc(Y,!1);else if(Yi!==Bu||T!==null&&(T.effectTag&64)!==0)for(T=D.child;T!==null;){if(De=fp(T),De!==null){for(D.effectTag|=64,Dc(Y,!1),T=De.updateQueue,T!==null&&(D.updateQueue=T,D.effectTag|=4),Y.lastEffect===null&&(D.firstEffect=null),D.lastEffect=Y.lastEffect,T=j,Y=D.child;Y!==null;)Ae=Y,j=T,Ae.effectTag&=2,Ae.nextEffect=null,Ae.firstEffect=null,Ae.lastEffect=null,De=Ae.alternate,De===null?(Ae.childExpirationTime=0,Ae.expirationTime=j,Ae.child=null,Ae.memoizedProps=null,Ae.memoizedState=null,Ae.updateQueue=null,Ae.dependencies=null):(Ae.childExpirationTime=De.childExpirationTime,Ae.expirationTime=De.expirationTime,Ae.child=De.child,Ae.memoizedProps=De.memoizedProps,Ae.memoizedState=De.memoizedState,Ae.updateQueue=De.updateQueue,j=De.dependencies,Ae.dependencies=j===null?null:{expirationTime:j.expirationTime,firstContext:j.firstContext,responders:j.responders}),Y=Y.sibling;On($n,$n.current&1|2,D),D=D.child;break e}T=T.sibling}}else{if(!Ae)if(T=fp(De),T!==null){if(D.effectTag|=64,Ae=!0,T=T.updateQueue,T!==null&&(D.updateQueue=T,D.effectTag|=4),Dc(Y,!0),Y.tail===null&&Y.tailMode==="hidden"&&!De.alternate){D=D.lastEffect=Y.lastEffect,D!==null&&(D.nextEffect=null);break}}else Li()>Y.tailExpiration&&1<j&&(D.effectTag|=64,Ae=!0,Dc(Y,!1),D.expirationTime=D.childExpirationTime=j-1);Y.isBackwards?(De.sibling=D.child,D.child=De):(T=Y.last,T!==null?T.sibling=De:D.child=De,Y.last=De)}if(Y.tail!==null){Y.tailExpiration===0&&(Y.tailExpiration=Li()+500),T=Y.tail,Y.rendering=T,Y.tail=T.sibling,Y.lastEffect=D.lastEffect,T.sibling=null,Y=$n.current,Y=Ae?Y&1|2:Y&1,On($n,Y,D),D=T;break e}break;case 20:break;case 21:break;default:throw Error(n(156,D.tag))}D=null}if(T=Or,ns===1||T.childExpirationTime!==1){for(Y=0,Ae=T.child;Ae!==null;)j=Ae.expirationTime,De=Ae.childExpirationTime,j>Y&&(Y=j),De>Y&&(Y=De),Ae=Ae.sibling;T.childExpirationTime=Y}if(D!==null)return D;S!==null&&(S.effectTag&2048)===0&&(S.firstEffect===null&&(S.firstEffect=Or.firstEffect),Or.lastEffect!==null&&(S.lastEffect!==null&&(S.lastEffect.nextEffect=Or.firstEffect),S.lastEffect=Or.lastEffect),1<Or.effectTag&&(S.lastEffect!==null?S.lastEffect.nextEffect=Or:S.firstEffect=Or,S.lastEffect=Or))}else{if(D=Cw(Or,ns),D!==null)return D.effectTag&=2047,D;S!==null&&(S.firstEffect=S.lastEffect=null,S.effectTag|=2048)}if(D=Or.sibling,D!==null)return D;Or=S}while(Or!==null);return Yi===Bu&&(Yi=Sc),null}function bw(S){var D=S.expirationTime;return S=S.childExpirationTime,D>S?D:S}function Pu(S){var D=_o();return lo(99,hF.bind(null,S,D)),null}function hF(S,D){do Bp();while(Bg!==null);if((yr&(rs|js))!==En)throw Error(n(327));var T=S.finishedWork,j=S.finishedExpirationTime;if(T===null)return null;if(S.finishedWork=null,S.finishedExpirationTime=0,T===S.current)throw Error(n(177));S.callbackNode=null,S.callbackExpirationTime=0,S.callbackPriority=90,S.nextKnownPendingLevel=0;var Y=bw(T);if(S.firstPendingTime=Y,j<=S.lastSuspendedTime?S.firstSuspendedTime=S.lastSuspendedTime=S.nextKnownPendingLevel=0:j<=S.firstSuspendedTime&&(S.firstSuspendedTime=j-1),j<=S.lastPingedTime&&(S.lastPingedTime=0),j<=S.lastExpiredTime&&(S.lastExpiredTime=0),S===gi&&(Or=gi=null,ns=0),1<T.effectTag?T.lastEffect!==null?(T.lastEffect.nextEffect=T,Y=T.firstEffect):Y=T:Y=T.firstEffect,Y!==null){var Ae=yr;yr|=js,Iw.current=null,Ie(S.containerInfo),sr=Y;do try{gF()}catch(ho){if(sr===null)throw Error(n(330));YA(sr,ho),sr=sr.nextEffect}while(sr!==null);sr=Y;do try{for(var De=S,vt=D;sr!==null;){var wt=sr.effectTag;if(wt&16&&w&&Gt(sr.stateNode),wt&128){var xt=sr.alternate;if(xt!==null){var _r=xt.ref;_r!==null&&(typeof _r=="function"?_r(null):_r.current=null)}}switch(wt&1038){case 2:fr(sr),sr.effectTag&=-3;break;case 6:fr(sr),sr.effectTag&=-3,yn(sr.alternate,sr);break;case 1024:sr.effectTag&=-1025;break;case 1028:sr.effectTag&=-1025,yn(sr.alternate,sr);break;case 4:yn(sr.alternate,sr);break;case 8:var is=De,di=sr,po=vt;w?Cr(is,di,po):re(is,di,po),pe(di)}sr=sr.nextEffect}}catch(ho){if(sr===null)throw Error(n(330));YA(sr,ho),sr=sr.nextEffect}while(sr!==null);Fe(S.containerInfo),S.current=T,sr=Y;do try{for(wt=j;sr!==null;){var VA=sr.effectTag;if(VA&36){var Yo=sr.alternate;switch(xt=sr,_r=wt,xt.tag){case 0:case 11:case 15:N(16,32,xt);break;case 1:var rt=xt.stateNode;if(xt.effectTag&4)if(Yo===null)rt.componentDidMount();else{var Ve=xt.elementType===xt.type?Yo.memoizedProps:Ci(xt.type,Yo.memoizedProps);rt.componentDidUpdate(Ve,Yo.memoizedState,rt.__reactInternalSnapshotBeforeUpdate)}var ft=xt.updateQueue;ft!==null&&Le(xt,ft,rt,_r);break;case 3:var Wt=xt.updateQueue;if(Wt!==null){if(De=null,xt.child!==null)switch(xt.child.tag){case 5:De=le(xt.child.stateNode);break;case 1:De=xt.child.stateNode}Le(xt,Wt,De,_r)}break;case 5:var vr=xt.stateNode;Yo===null&&xt.effectTag&4&&Z(vr,xt.type,xt.memoizedProps,xt);break;case 6:break;case 4:break;case 12:break;case 13:if(y&&xt.memoizedState===null){var Pn=xt.alternate;if(Pn!==null){var Fr=Pn.memoizedState;if(Fr!==null){var bn=Fr.dehydrated;bn!==null&&oo(bn)}}}break;case 19:case 17:case 20:case 21:break;default:throw Error(n(163))}}if(VA&128){xt=void 0;var ai=sr.ref;if(ai!==null){var tn=sr.stateNode;switch(sr.tag){case 5:xt=le(tn);break;default:xt=tn}typeof ai=="function"?ai(xt):ai.current=xt}}sr=sr.nextEffect}}catch(ho){if(sr===null)throw Error(n(330));YA(sr,ho),sr=sr.nextEffect}while(sr!==null);sr=null,Qn(),yr=Ae}else S.current=T;if(Ip)Ip=!1,Bg=S,_A=D;else for(sr=Y;sr!==null;)D=sr.nextEffect,sr.nextEffect=null,sr=D;if(D=S.firstPendingTime,D===0&&(Du=null),D===1073741823?S===Dw?vg++:(vg=0,Dw=S):vg=0,typeof xw=="function"&&xw(T.stateNode,j),fo(S),vu)throw vu=!1,S=Mm,Mm=null,S;return(yr&Tm)!==En||ji(),null}function gF(){for(;sr!==null;){var S=sr.effectTag;(S&256)!==0&&Qt(sr.alternate,sr),(S&512)===0||Ip||(Ip=!0,gc(97,function(){return Bp(),null})),sr=sr.nextEffect}}function Bp(){if(_A!==90){var S=97<_A?97:_A;return _A=90,lo(S,dF)}}function dF(){if(Bg===null)return!1;var S=Bg;if(Bg=null,(yr&(rs|js))!==En)throw Error(n(331));var D=yr;for(yr|=js,S=S.current.firstEffect;S!==null;){try{var T=S;if((T.effectTag&512)!==0)switch(T.tag){case 0:case 11:case 15:N(128,0,T),N(0,64,T)}}catch(j){if(S===null)throw Error(n(330));YA(S,j)}T=S.nextEffect,S.nextEffect=null,S=T}return yr=D,ji(),!0}function Xv(S,D,T){D=Eg(T,D),D=qv(S,D,1073741823),tt(S,D),S=Dg(S,1073741823),S!==null&&fo(S)}function YA(S,D){if(S.tag===3)Xv(S,S,D);else for(var T=S.return;T!==null;){if(T.tag===3){Xv(T,S,D);break}else if(T.tag===1){var j=T.stateNode;if(typeof T.type.getDerivedStateFromError=="function"||typeof j.componentDidCatch=="function"&&(Du===null||!Du.has(j))){S=Eg(D,S),S=Yv(T,S,1073741823),tt(T,S),T=Dg(T,1073741823),T!==null&&fo(T);break}}T=T.return}}function mF(S,D,T){var j=S.pingCache;j!==null&&j.delete(D),gi===S&&ns===T?Yi===vl||Yi===ha&&Ya===1073741823&&Li()-Bw<vw?Su(S,ns):UA=!0:$v(S,T)&&(D=S.lastPingedTime,D!==0&&D<T||(S.lastPingedTime=T,S.finishedExpirationTime===T&&(S.finishedExpirationTime=0,S.finishedWork=null),fo(S)))}function yF(S,D){var T=S.stateNode;T!==null&&T.delete(D),D=0,D===0&&(D=ga(),D=jA(D,S,null)),S=Dg(S,D),S!==null&&fo(S)}var Zv;Zv=function(S,D,T){var j=D.expirationTime;if(S!==null){var Y=D.pendingProps;if(S.memoizedProps!==Y||_i.current)Go=!0;else{if(j<T){switch(Go=!1,D.tag){case 3:yg(D),mg();break;case 5:if(Pm(D),D.mode&4&&T!==1&&xe(D.type,Y))return D.expirationTime=D.childExpirationTime=1,null;break;case 1:ii(D.type)&&Ac(D);break;case 4:og(D,D.stateNode.containerInfo);break;case 10:Ho(D,D.memoizedProps.value);break;case 13:if(D.memoizedState!==null)return j=D.child.childExpirationTime,j!==0&&j>=T?ln(S,D,T):(On($n,$n.current&1,D),D=si(S,D,T),D!==null?D.sibling:null);On($n,$n.current&1,D);break;case 19:if(j=D.childExpirationTime>=T,(S.effectTag&64)!==0){if(j)return Ga(S,D,T);D.effectTag|=64}if(Y=D.memoizedState,Y!==null&&(Y.rendering=null,Y.tail=null),On($n,$n.current,D),!j)return null}return si(S,D,T)}Go=!1}}else Go=!1;switch(D.expirationTime=0,D.tag){case 2:if(j=D.type,S!==null&&(S.alternate=null,D.alternate=null,D.effectTag|=2),S=D.pendingProps,Y=Oe(D,Mn.current),ds(D,T),Y=cg(null,D,j,S,Y,T),D.effectTag|=1,typeof Y=="object"&&Y!==null&&typeof Y.render=="function"&&Y.$$typeof===void 0){if(D.tag=1,yw(),ii(j)){var Ae=!0;Ac(D)}else Ae=!1;D.memoizedState=Y.state!==null&&Y.state!==void 0?Y.state:null;var De=j.getDerivedStateFromProps;typeof De=="function"&&er(D,j,De,S),Y.updater=$r,D.stateNode=Y,Y._reactInternalFiber=D,jo(D,j,S,T),D=yp(null,D,j,!0,Ae,T)}else D.tag=0,ws(null,D,Y,T),D=D.child;return D;case 16:if(Y=D.elementType,S!==null&&(S.alternate=null,D.alternate=null,D.effectTag|=2),S=D.pendingProps,me(Y),Y._status!==1)throw Y._result;switch(Y=Y._result,D.type=Y,Ae=D.tag=wF(Y),S=Ci(Y,S),Ae){case 0:D=LA(null,D,Y,S,T);break;case 1:D=mp(null,D,Y,S,T);break;case 11:D=Ii(null,D,Y,S,T);break;case 14:D=Qm(null,D,Y,Ci(Y.type,S),j,T);break;default:throw Error(n(306,Y,""))}return D;case 0:return j=D.type,Y=D.pendingProps,Y=D.elementType===j?Y:Ci(j,Y),LA(S,D,j,Y,T);case 1:return j=D.type,Y=D.pendingProps,Y=D.elementType===j?Y:Ci(j,Y),mp(S,D,j,Y,T);case 3:if(yg(D),j=D.updateQueue,j===null)throw Error(n(282));if(Y=D.memoizedState,Y=Y!==null?Y.element:null,ye(D,j,D.pendingProps,null,T),j=D.memoizedState.element,j===Y)mg(),D=si(S,D,T);else{if((Y=D.stateNode.hydrate)&&(y?(Bc=cu(D.stateNode.containerInfo),Aa=D,Y=Il=!0):Y=!1),Y)for(T=sg(D,null,j,T),D.child=T;T;)T.effectTag=T.effectTag&-3|1024,T=T.sibling;else ws(S,D,j,T),mg();D=D.child}return D;case 5:return Pm(D),S===null&&NA(D),j=D.type,Y=D.pendingProps,Ae=S!==null?S.memoizedProps:null,De=Y.children,ke(j,Y)?De=null:Ae!==null&&ke(j,Ae)&&(D.effectTag|=16),qo(S,D),D.mode&4&&T!==1&&xe(j,Y)?(D.expirationTime=D.childExpirationTime=1,D=null):(ws(S,D,De,T),D=D.child),D;case 6:return S===null&&NA(D),null;case 13:return ln(S,D,T);case 4:return og(D,D.stateNode.containerInfo),j=D.pendingProps,S===null?D.child=gu(D,null,j,T):ws(S,D,j,T),D.child;case 11:return j=D.type,Y=D.pendingProps,Y=D.elementType===j?Y:Ci(j,Y),Ii(S,D,j,Y,T);case 7:return ws(S,D,D.pendingProps,T),D.child;case 8:return ws(S,D,D.pendingProps.children,T),D.child;case 12:return ws(S,D,D.pendingProps.children,T),D.child;case 10:e:{if(j=D.type._context,Y=D.pendingProps,De=D.memoizedProps,Ae=Y.value,Ho(D,Ae),De!==null){var vt=De.value;if(Ae=hs(vt,Ae)?0:(typeof j._calculateChangedBits=="function"?j._calculateChangedBits(vt,Ae):1073741823)|0,Ae===0){if(De.children===Y.children&&!_i.current){D=si(S,D,T);break e}}else for(vt=D.child,vt!==null&&(vt.return=D);vt!==null;){var wt=vt.dependencies;if(wt!==null){De=vt.child;for(var xt=wt.firstContext;xt!==null;){if(xt.context===j&&(xt.observedBits&Ae)!==0){vt.tag===1&&(xt=ys(T,null),xt.tag=2,tt(vt,xt)),vt.expirationTime<T&&(vt.expirationTime=T),xt=vt.alternate,xt!==null&&xt.expirationTime<T&&(xt.expirationTime=T),gs(vt.return,T),wt.expirationTime<T&&(wt.expirationTime=T);break}xt=xt.next}}else De=vt.tag===10&&vt.type===D.type?null:vt.child;if(De!==null)De.return=vt;else for(De=vt;De!==null;){if(De===D){De=null;break}if(vt=De.sibling,vt!==null){vt.return=De.return,De=vt;break}De=De.return}vt=De}}ws(S,D,Y.children,T),D=D.child}return D;case 9:return Y=D.type,Ae=D.pendingProps,j=Ae.children,ds(D,T),Y=ms(Y,Ae.unstable_observedBits),j=j(Y),D.effectTag|=1,ws(S,D,j,T),D.child;case 14:return Y=D.type,Ae=Ci(Y,D.pendingProps),Ae=Ci(Y.type,Ae),Qm(S,D,Y,Ae,j,T);case 15:return Fm(S,D,D.type,D.pendingProps,j,T);case 17:return j=D.type,Y=D.pendingProps,Y=D.elementType===j?Y:Ci(j,Y),S!==null&&(S.alternate=null,D.alternate=null,D.effectTag|=2),D.tag=1,ii(j)?(S=!0,Ac(D)):S=!1,ds(D,T),es(D,j,Y,T),jo(D,j,Y,T),yp(null,D,j,!0,S,T);case 19:return Ga(S,D,T)}throw Error(n(156,D.tag))};var xw=null,kw=null;function EF(S){if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u")return!1;var D=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(D.isDisabled||!D.supportsFiber)return!0;try{var T=D.inject(S);xw=function(j){try{D.onCommitFiberRoot(T,j,void 0,(j.current.effectTag&64)===64)}catch{}},kw=function(j){try{D.onCommitFiberUnmount(T,j)}catch{}}}catch{}return!0}function CF(S,D,T,j){this.tag=S,this.key=T,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=D,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=j,this.effectTag=0,this.lastEffect=this.firstEffect=this.nextEffect=null,this.childExpirationTime=this.expirationTime=0,this.alternate=null}function Dl(S,D,T,j){return new CF(S,D,T,j)}function Qw(S){return S=S.prototype,!(!S||!S.isReactComponent)}function wF(S){if(typeof S=="function")return Qw(S)?1:0;if(S!=null){if(S=S.$$typeof,S===L)return 11;if(S===te)return 14}return 2}function WA(S,D){var T=S.alternate;return T===null?(T=Dl(S.tag,D,S.key,S.mode),T.elementType=S.elementType,T.type=S.type,T.stateNode=S.stateNode,T.alternate=S,S.alternate=T):(T.pendingProps=D,T.effectTag=0,T.nextEffect=null,T.firstEffect=null,T.lastEffect=null),T.childExpirationTime=S.childExpirationTime,T.expirationTime=S.expirationTime,T.child=S.child,T.memoizedProps=S.memoizedProps,T.memoizedState=S.memoizedState,T.updateQueue=S.updateQueue,D=S.dependencies,T.dependencies=D===null?null:{expirationTime:D.expirationTime,firstContext:D.firstContext,responders:D.responders},T.sibling=S.sibling,T.index=S.index,T.ref=S.ref,T}function jm(S,D,T,j,Y,Ae){var De=2;if(j=S,typeof S=="function")Qw(S)&&(De=1);else if(typeof S=="string")De=5;else e:switch(S){case E:return bu(T.children,Y,Ae,D);case R:De=8,Y|=7;break;case I:De=8,Y|=1;break;case v:return S=Dl(12,T,D,Y|8),S.elementType=v,S.type=v,S.expirationTime=Ae,S;case U:return S=Dl(13,T,D,Y),S.type=U,S.elementType=U,S.expirationTime=Ae,S;case J:return S=Dl(19,T,D,Y),S.elementType=J,S.expirationTime=Ae,S;default:if(typeof S=="object"&&S!==null)switch(S.$$typeof){case x:De=10;break e;case C:De=9;break e;case L:De=11;break e;case te:De=14;break e;case ae:De=16,j=null;break e}throw Error(n(130,S==null?S:typeof S,""))}return D=Dl(De,T,D,Y),D.elementType=S,D.type=j,D.expirationTime=Ae,D}function bu(S,D,T,j){return S=Dl(7,S,j,D),S.expirationTime=T,S}function Fw(S,D,T){return S=Dl(6,S,null,D),S.expirationTime=T,S}function Rw(S,D,T){return D=Dl(4,S.children!==null?S.children:[],S.key,D),D.expirationTime=T,D.stateNode={containerInfo:S.containerInfo,pendingChildren:null,implementation:S.implementation},D}function IF(S,D,T){this.tag=D,this.current=null,this.containerInfo=S,this.pingCache=this.pendingChildren=null,this.finishedExpirationTime=0,this.finishedWork=null,this.timeoutHandle=je,this.pendingContext=this.context=null,this.hydrate=T,this.callbackNode=null,this.callbackPriority=90,this.lastExpiredTime=this.lastPingedTime=this.nextKnownPendingLevel=this.lastSuspendedTime=this.firstSuspendedTime=this.firstPendingTime=0}function $v(S,D){var T=S.firstSuspendedTime;return S=S.lastSuspendedTime,T!==0&&T>=D&&S<=D}function KA(S,D){var T=S.firstSuspendedTime,j=S.lastSuspendedTime;T<D&&(S.firstSuspendedTime=D),(j>D||T===0)&&(S.lastSuspendedTime=D),D<=S.lastPingedTime&&(S.lastPingedTime=0),D<=S.lastExpiredTime&&(S.lastExpiredTime=0)}function eD(S,D){D>S.firstPendingTime&&(S.firstPendingTime=D);var T=S.firstSuspendedTime;T!==0&&(D>=T?S.firstSuspendedTime=S.lastSuspendedTime=S.nextKnownPendingLevel=0:D>=S.lastSuspendedTime&&(S.lastSuspendedTime=D+1),D>S.nextKnownPendingLevel&&(S.nextKnownPendingLevel=D))}function Gm(S,D){var T=S.lastExpiredTime;(T===0||T>D)&&(S.lastExpiredTime=D)}function tD(S){var D=S._reactInternalFiber;if(D===void 0)throw typeof S.render=="function"?Error(n(188)):Error(n(268,Object.keys(S)));return S=Ee(D),S===null?null:S.stateNode}function rD(S,D){S=S.memoizedState,S!==null&&S.dehydrated!==null&&S.retryTime<D&&(S.retryTime=D)}function qm(S,D){rD(S,D),(S=S.alternate)&&rD(S,D)}var nD={createContainer:function(S,D,T){return S=new IF(S,D,T),D=Dl(3,null,null,D===2?7:D===1?3:0),S.current=D,D.stateNode=S},updateContainer:function(S,D,T,j){var Y=D.current,Ae=ga(),De=ht.suspense;Ae=jA(Ae,Y,De);e:if(T){T=T._reactInternalFiber;t:{if(Be(T)!==T||T.tag!==1)throw Error(n(170));var vt=T;do{switch(vt.tag){case 3:vt=vt.stateNode.context;break t;case 1:if(ii(vt.type)){vt=vt.stateNode.__reactInternalMemoizedMergedChildContext;break t}}vt=vt.return}while(vt!==null);throw Error(n(171))}if(T.tag===1){var wt=T.type;if(ii(wt)){T=uu(T,wt,vt);break e}}T=vt}else T=Ni;return D.context===null?D.context=T:D.pendingContext=T,D=ys(Ae,De),D.payload={element:S},j=j===void 0?null:j,j!==null&&(D.callback=j),tt(Y,D),Pc(Y,Ae),Ae},batchedEventUpdates:function(S,D){var T=yr;yr|=2;try{return S(D)}finally{yr=T,yr===En&&ji()}},batchedUpdates:function(S,D){var T=yr;yr|=1;try{return S(D)}finally{yr=T,yr===En&&ji()}},unbatchedUpdates:function(S,D){var T=yr;yr&=-2,yr|=Tm;try{return S(D)}finally{yr=T,yr===En&&ji()}},deferredUpdates:function(S){return lo(97,S)},syncUpdates:function(S,D,T,j){return lo(99,S.bind(null,D,T,j))},discreteUpdates:function(S,D,T,j){var Y=yr;yr|=4;try{return lo(98,S.bind(null,D,T,j))}finally{yr=Y,yr===En&&ji()}},flushDiscreteUpdates:function(){(yr&(1|rs|js))===En&&(AF(),Bp())},flushControlled:function(S){var D=yr;yr|=1;try{lo(99,S)}finally{yr=D,yr===En&&ji()}},flushSync:Vv,flushPassiveEffects:Bp,IsThisRendererActing:{current:!1},getPublicRootInstance:function(S){if(S=S.current,!S.child)return null;switch(S.child.tag){case 5:return le(S.child.stateNode);default:return S.child.stateNode}},attemptSynchronousHydration:function(S){switch(S.tag){case 3:var D=S.stateNode;D.hydrate&&Kv(D,D.firstPendingTime);break;case 13:Vv(function(){return Pc(S,1073741823)}),D=Ua(ga(),150,100),qm(S,D)}},attemptUserBlockingHydration:function(S){if(S.tag===13){var D=Ua(ga(),150,100);Pc(S,D),qm(S,D)}},attemptContinuousHydration:function(S){if(S.tag===13){ga();var D=xA++;Pc(S,D),qm(S,D)}},attemptHydrationAtCurrentPriority:function(S){if(S.tag===13){var D=ga();D=jA(D,S,null),Pc(S,D),qm(S,D)}},findHostInstance:tD,findHostInstanceWithWarning:function(S){return tD(S)},findHostInstanceWithNoPortals:function(S){return S=Se(S),S===null?null:S.tag===20?S.stateNode.instance:S.stateNode},shouldSuspend:function(){return!1},injectIntoDevTools:function(S){var D=S.findFiberByHostInstance;return EF(r({},S,{overrideHookState:null,overrideProps:null,setSuspenseHandler:null,scheduleUpdate:null,currentDispatcherRef:u.ReactCurrentDispatcher,findHostInstanceByFiber:function(T){return T=Ee(T),T===null?null:T.stateNode},findFiberByHostInstance:function(T){return D?D(T):null},findHostInstancesForRefresh:null,scheduleRefresh:null,scheduleRoot:null,setRefreshHandler:null,getCurrentFiber:null}))}};lB.exports=nD.default||nD;var BF=lB.exports;return lB.exports=t,BF}});var PEe=_((mKt,SEe)=>{"use strict";SEe.exports=DEe()});var xEe=_((yKt,bEe)=>{"use strict";var Hyt={ALIGN_COUNT:8,ALIGN_AUTO:0,ALIGN_FLEX_START:1,ALIGN_CENTER:2,ALIGN_FLEX_END:3,ALIGN_STRETCH:4,ALIGN_BASELINE:5,ALIGN_SPACE_BETWEEN:6,ALIGN_SPACE_AROUND:7,DIMENSION_COUNT:2,DIMENSION_WIDTH:0,DIMENSION_HEIGHT:1,DIRECTION_COUNT:3,DIRECTION_INHERIT:0,DIRECTION_LTR:1,DIRECTION_RTL:2,DISPLAY_COUNT:2,DISPLAY_FLEX:0,DISPLAY_NONE:1,EDGE_COUNT:9,EDGE_LEFT:0,EDGE_TOP:1,EDGE_RIGHT:2,EDGE_BOTTOM:3,EDGE_START:4,EDGE_END:5,EDGE_HORIZONTAL:6,EDGE_VERTICAL:7,EDGE_ALL:8,EXPERIMENTAL_FEATURE_COUNT:1,EXPERIMENTAL_FEATURE_WEB_FLEX_BASIS:0,FLEX_DIRECTION_COUNT:4,FLEX_DIRECTION_COLUMN:0,FLEX_DIRECTION_COLUMN_REVERSE:1,FLEX_DIRECTION_ROW:2,FLEX_DIRECTION_ROW_REVERSE:3,JUSTIFY_COUNT:6,JUSTIFY_FLEX_START:0,JUSTIFY_CENTER:1,JUSTIFY_FLEX_END:2,JUSTIFY_SPACE_BETWEEN:3,JUSTIFY_SPACE_AROUND:4,JUSTIFY_SPACE_EVENLY:5,LOG_LEVEL_COUNT:6,LOG_LEVEL_ERROR:0,LOG_LEVEL_WARN:1,LOG_LEVEL_INFO:2,LOG_LEVEL_DEBUG:3,LOG_LEVEL_VERBOSE:4,LOG_LEVEL_FATAL:5,MEASURE_MODE_COUNT:3,MEASURE_MODE_UNDEFINED:0,MEASURE_MODE_EXACTLY:1,MEASURE_MODE_AT_MOST:2,NODE_TYPE_COUNT:2,NODE_TYPE_DEFAULT:0,NODE_TYPE_TEXT:1,OVERFLOW_COUNT:3,OVERFLOW_VISIBLE:0,OVERFLOW_HIDDEN:1,OVERFLOW_SCROLL:2,POSITION_TYPE_COUNT:2,POSITION_TYPE_RELATIVE:0,POSITION_TYPE_ABSOLUTE:1,PRINT_OPTIONS_COUNT:3,PRINT_OPTIONS_LAYOUT:1,PRINT_OPTIONS_STYLE:2,PRINT_OPTIONS_CHILDREN:4,UNIT_COUNT:4,UNIT_UNDEFINED:0,UNIT_POINT:1,UNIT_PERCENT:2,UNIT_AUTO:3,WRAP_COUNT:3,WRAP_NO_WRAP:0,WRAP_WRAP:1,WRAP_WRAP_REVERSE:2};bEe.exports=Hyt});var REe=_((EKt,FEe)=>{"use strict";var jyt=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var r=arguments[e];for(var o in r)Object.prototype.hasOwnProperty.call(r,o)&&(t[o]=r[o])}return t},Wk=function(){function t(e,r){for(var o=0;o<r.length;o++){var a=r[o];a.enumerable=a.enumerable||!1,a.configurable=!0,"value"in a&&(a.writable=!0),Object.defineProperty(e,a.key,a)}}return function(e,r,o){return r&&t(e.prototype,r),o&&t(e,o),e}}();function P6(t,e,r){return e in t?Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[e]=r,t}function b6(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}var tu=xEe(),Gyt=function(){function t(e,r,o,a,n,u){b6(this,t),this.left=e,this.right=r,this.top=o,this.bottom=a,this.width=n,this.height=u}return Wk(t,[{key:"fromJS",value:function(r){r(this.left,this.right,this.top,this.bottom,this.width,this.height)}},{key:"toString",value:function(){return"<Layout#"+this.left+":"+this.right+";"+this.top+":"+this.bottom+";"+this.width+":"+this.height+">"}}]),t}(),kEe=function(){Wk(t,null,[{key:"fromJS",value:function(r){var o=r.width,a=r.height;return new t(o,a)}}]);function t(e,r){b6(this,t),this.width=e,this.height=r}return Wk(t,[{key:"fromJS",value:function(r){r(this.width,this.height)}},{key:"toString",value:function(){return"<Size#"+this.width+"x"+this.height+">"}}]),t}(),QEe=function(){function t(e,r){b6(this,t),this.unit=e,this.value=r}return Wk(t,[{key:"fromJS",value:function(r){r(this.unit,this.value)}},{key:"toString",value:function(){switch(this.unit){case tu.UNIT_POINT:return String(this.value);case tu.UNIT_PERCENT:return this.value+"%";case tu.UNIT_AUTO:return"auto";default:return this.value+"?"}}},{key:"valueOf",value:function(){return this.value}}]),t}();FEe.exports=function(t,e){function r(u,A,p){var h=u[A];u[A]=function(){for(var E=arguments.length,I=Array(E),v=0;v<E;v++)I[v]=arguments[v];return p.call.apply(p,[this,h].concat(I))}}for(var o=["setPosition","setMargin","setFlexBasis","setWidth","setHeight","setMinWidth","setMinHeight","setMaxWidth","setMaxHeight","setPadding"],a=function(){var A,p=o[n],h=(A={},P6(A,tu.UNIT_POINT,e.Node.prototype[p]),P6(A,tu.UNIT_PERCENT,e.Node.prototype[p+"Percent"]),P6(A,tu.UNIT_AUTO,e.Node.prototype[p+"Auto"]),A);r(e.Node.prototype,p,function(E){for(var I=arguments.length,v=Array(I>1?I-1:0),x=1;x<I;x++)v[x-1]=arguments[x];var C=v.pop(),R=void 0,L=void 0;if(C==="auto")R=tu.UNIT_AUTO,L=void 0;else if(C instanceof QEe)R=C.unit,L=C.valueOf();else if(R=typeof C=="string"&&C.endsWith("%")?tu.UNIT_PERCENT:tu.UNIT_POINT,L=parseFloat(C),!Number.isNaN(C)&&Number.isNaN(L))throw new Error("Invalid value "+C+" for "+p);if(!h[R])throw new Error('Failed to execute "'+p+`": Unsupported unit '`+C+"'");if(L!==void 0){var U;return(U=h[R]).call.apply(U,[this].concat(v,[L]))}else{var J;return(J=h[R]).call.apply(J,[this].concat(v))}})},n=0;n<o.length;n++)a();return r(e.Config.prototype,"free",function(){e.Config.destroy(this)}),r(e.Node,"create",function(u,A){return A?e.Node.createWithConfig(A):e.Node.createDefault()}),r(e.Node.prototype,"free",function(){e.Node.destroy(this)}),r(e.Node.prototype,"freeRecursive",function(){for(var u=0,A=this.getChildCount();u<A;++u)this.getChild(0).freeRecursive();this.free()}),r(e.Node.prototype,"setMeasureFunc",function(u,A){return A?u.call(this,function(){return kEe.fromJS(A.apply(void 0,arguments))}):this.unsetMeasureFunc()}),r(e.Node.prototype,"calculateLayout",function(u){var A=arguments.length>1&&arguments[1]!==void 0?arguments[1]:NaN,p=arguments.length>2&&arguments[2]!==void 0?arguments[2]:NaN,h=arguments.length>3&&arguments[3]!==void 0?arguments[3]:tu.DIRECTION_LTR;return u.call(this,A,p,h)}),jyt({Config:e.Config,Node:e.Node,Layout:t("Layout",Gyt),Size:t("Size",kEe),Value:t("Value",QEe),getInstanceCount:function(){return e.getInstanceCount.apply(e,arguments)}},tu)}});var TEe=_((exports,module)=>{(function(t,e){typeof define=="function"&&define.amd?define([],function(){return e}):typeof module=="object"&&module.exports?module.exports=e:(t.nbind=t.nbind||{}).init=e})(exports,function(Module,cb){typeof Module=="function"&&(cb=Module,Module={}),Module.onRuntimeInitialized=function(t,e){return function(){t&&t.apply(this,arguments);try{Module.ccall("nbind_init")}catch(r){e(r);return}e(null,{bind:Module._nbind_value,reflect:Module.NBind.reflect,queryType:Module.NBind.queryType,toggleLightGC:Module.toggleLightGC,lib:Module})}}(Module.onRuntimeInitialized,cb);var Module;Module||(Module=(typeof Module<"u"?Module:null)||{});var moduleOverrides={};for(var key in Module)Module.hasOwnProperty(key)&&(moduleOverrides[key]=Module[key]);var ENVIRONMENT_IS_WEB=!1,ENVIRONMENT_IS_WORKER=!1,ENVIRONMENT_IS_NODE=!1,ENVIRONMENT_IS_SHELL=!1;if(Module.ENVIRONMENT)if(Module.ENVIRONMENT==="WEB")ENVIRONMENT_IS_WEB=!0;else if(Module.ENVIRONMENT==="WORKER")ENVIRONMENT_IS_WORKER=!0;else if(Module.ENVIRONMENT==="NODE")ENVIRONMENT_IS_NODE=!0;else if(Module.ENVIRONMENT==="SHELL")ENVIRONMENT_IS_SHELL=!0;else throw new Error("The provided Module['ENVIRONMENT'] value is not valid. It must be one of: WEB|WORKER|NODE|SHELL.");else ENVIRONMENT_IS_WEB=typeof window=="object",ENVIRONMENT_IS_WORKER=typeof importScripts=="function",ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof ve=="function"&&!ENVIRONMENT_IS_WEB&&!ENVIRONMENT_IS_WORKER,ENVIRONMENT_IS_SHELL=!ENVIRONMENT_IS_WEB&&!ENVIRONMENT_IS_NODE&&!ENVIRONMENT_IS_WORKER;if(ENVIRONMENT_IS_NODE){Module.print||(Module.print=console.log),Module.printErr||(Module.printErr=console.warn);var nodeFS,nodePath;Module.read=function(e,r){nodeFS||(nodeFS={}("")),nodePath||(nodePath={}("")),e=nodePath.normalize(e);var o=nodeFS.readFileSync(e);return r?o:o.toString()},Module.readBinary=function(e){var r=Module.read(e,!0);return r.buffer||(r=new Uint8Array(r)),assert(r.buffer),r},Module.load=function(e){globalEval(read(e))},Module.thisProgram||(process.argv.length>1?Module.thisProgram=process.argv[1].replace(/\\/g,"/"):Module.thisProgram="unknown-program"),Module.arguments=process.argv.slice(2),typeof module<"u"&&(module.exports=Module),Module.inspect=function(){return"[Emscripten Module object]"}}else if(ENVIRONMENT_IS_SHELL)Module.print||(Module.print=print),typeof printErr<"u"&&(Module.printErr=printErr),typeof read<"u"?Module.read=read:Module.read=function(){throw"no read() available"},Module.readBinary=function(e){if(typeof readbuffer=="function")return new Uint8Array(readbuffer(e));var r=read(e,"binary");return assert(typeof r=="object"),r},typeof scriptArgs<"u"?Module.arguments=scriptArgs:typeof arguments<"u"&&(Module.arguments=arguments),typeof quit=="function"&&(Module.quit=function(t,e){quit(t)});else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(Module.read=function(e){var r=new XMLHttpRequest;return r.open("GET",e,!1),r.send(null),r.responseText},ENVIRONMENT_IS_WORKER&&(Module.readBinary=function(e){var r=new XMLHttpRequest;return r.open("GET",e,!1),r.responseType="arraybuffer",r.send(null),new Uint8Array(r.response)}),Module.readAsync=function(e,r,o){var a=new XMLHttpRequest;a.open("GET",e,!0),a.responseType="arraybuffer",a.onload=function(){a.status==200||a.status==0&&a.response?r(a.response):o()},a.onerror=o,a.send(null)},typeof arguments<"u"&&(Module.arguments=arguments),typeof console<"u")Module.print||(Module.print=function(e){console.log(e)}),Module.printErr||(Module.printErr=function(e){console.warn(e)});else{var TRY_USE_DUMP=!1;Module.print||(Module.print=TRY_USE_DUMP&&typeof dump<"u"?function(t){dump(t)}:function(t){})}ENVIRONMENT_IS_WORKER&&(Module.load=importScripts),typeof Module.setWindowTitle>"u"&&(Module.setWindowTitle=function(t){document.title=t})}else throw"Unknown runtime environment. Where are we?";function globalEval(t){eval.call(null,t)}!Module.load&&Module.read&&(Module.load=function(e){globalEval(Module.read(e))}),Module.print||(Module.print=function(){}),Module.printErr||(Module.printErr=Module.print),Module.arguments||(Module.arguments=[]),Module.thisProgram||(Module.thisProgram="./this.program"),Module.quit||(Module.quit=function(t,e){throw e}),Module.print=Module.print,Module.printErr=Module.printErr,Module.preRun=[],Module.postRun=[];for(var key in moduleOverrides)moduleOverrides.hasOwnProperty(key)&&(Module[key]=moduleOverrides[key]);moduleOverrides=void 0;var Runtime={setTempRet0:function(t){return tempRet0=t,t},getTempRet0:function(){return tempRet0},stackSave:function(){return STACKTOP},stackRestore:function(t){STACKTOP=t},getNativeTypeSize:function(t){switch(t){case"i1":case"i8":return 1;case"i16":return 2;case"i32":return 4;case"i64":return 8;case"float":return 4;case"double":return 8;default:{if(t[t.length-1]==="*")return Runtime.QUANTUM_SIZE;if(t[0]==="i"){var e=parseInt(t.substr(1));return assert(e%8===0),e/8}else return 0}}},getNativeFieldSize:function(t){return Math.max(Runtime.getNativeTypeSize(t),Runtime.QUANTUM_SIZE)},STACK_ALIGN:16,prepVararg:function(t,e){return e==="double"||e==="i64"?t&7&&(assert((t&7)===4),t+=4):assert((t&3)===0),t},getAlignSize:function(t,e,r){return!r&&(t=="i64"||t=="double")?8:t?Math.min(e||(t?Runtime.getNativeFieldSize(t):0),Runtime.QUANTUM_SIZE):Math.min(e,8)},dynCall:function(t,e,r){return r&&r.length?Module["dynCall_"+t].apply(null,[e].concat(r)):Module["dynCall_"+t].call(null,e)},functionPointers:[],addFunction:function(t){for(var e=0;e<Runtime.functionPointers.length;e++)if(!Runtime.functionPointers[e])return Runtime.functionPointers[e]=t,2*(1+e);throw"Finished up all reserved function pointers. Use a higher value for RESERVED_FUNCTION_POINTERS."},removeFunction:function(t){Runtime.functionPointers[(t-2)/2]=null},warnOnce:function(t){Runtime.warnOnce.shown||(Runtime.warnOnce.shown={}),Runtime.warnOnce.shown[t]||(Runtime.warnOnce.shown[t]=1,Module.printErr(t))},funcWrappers:{},getFuncWrapper:function(t,e){if(!!t){assert(e),Runtime.funcWrappers[e]||(Runtime.funcWrappers[e]={});var r=Runtime.funcWrappers[e];return r[t]||(e.length===1?r[t]=function(){return Runtime.dynCall(e,t)}:e.length===2?r[t]=function(a){return Runtime.dynCall(e,t,[a])}:r[t]=function(){return Runtime.dynCall(e,t,Array.prototype.slice.call(arguments))}),r[t]}},getCompilerSetting:function(t){throw"You must build with -s RETAIN_COMPILER_SETTINGS=1 for Runtime.getCompilerSetting or emscripten_get_compiler_setting to work"},stackAlloc:function(t){var e=STACKTOP;return STACKTOP=STACKTOP+t|0,STACKTOP=STACKTOP+15&-16,e},staticAlloc:function(t){var e=STATICTOP;return STATICTOP=STATICTOP+t|0,STATICTOP=STATICTOP+15&-16,e},dynamicAlloc:function(t){var e=HEAP32[DYNAMICTOP_PTR>>2],r=(e+t+15|0)&-16;if(HEAP32[DYNAMICTOP_PTR>>2]=r,r>=TOTAL_MEMORY){var o=enlargeMemory();if(!o)return HEAP32[DYNAMICTOP_PTR>>2]=e,0}return e},alignMemory:function(t,e){var r=t=Math.ceil(t/(e||16))*(e||16);return r},makeBigInt:function(t,e,r){var o=r?+(t>>>0)+ +(e>>>0)*4294967296:+(t>>>0)+ +(e|0)*4294967296;return o},GLOBAL_BASE:8,QUANTUM_SIZE:4,__dummy__:0};Module.Runtime=Runtime;var ABORT=0,EXITSTATUS=0;function assert(t,e){t||abort("Assertion failed: "+e)}function getCFunc(ident){var func=Module["_"+ident];if(!func)try{func=eval("_"+ident)}catch(t){}return assert(func,"Cannot call unknown function "+ident+" (perhaps LLVM optimizations or closure removed it?)"),func}var cwrap,ccall;(function(){var JSfuncs={stackSave:function(){Runtime.stackSave()},stackRestore:function(){Runtime.stackRestore()},arrayToC:function(t){var e=Runtime.stackAlloc(t.length);return writeArrayToMemory(t,e),e},stringToC:function(t){var e=0;if(t!=null&&t!==0){var r=(t.length<<2)+1;e=Runtime.stackAlloc(r),stringToUTF8(t,e,r)}return e}},toC={string:JSfuncs.stringToC,array:JSfuncs.arrayToC};ccall=function(e,r,o,a,n){var u=getCFunc(e),A=[],p=0;if(a)for(var h=0;h<a.length;h++){var E=toC[o[h]];E?(p===0&&(p=Runtime.stackSave()),A[h]=E(a[h])):A[h]=a[h]}var I=u.apply(null,A);if(r==="string"&&(I=Pointer_stringify(I)),p!==0){if(n&&n.async){EmterpreterAsync.asyncFinalizers.push(function(){Runtime.stackRestore(p)});return}Runtime.stackRestore(p)}return I};var sourceRegex=/^function\s*[a-zA-Z$_0-9]*\s*\(([^)]*)\)\s*{\s*([^*]*?)[\s;]*(?:return\s*(.*?)[;\s]*)?}$/;function parseJSFunc(t){var e=t.toString().match(sourceRegex).slice(1);return{arguments:e[0],body:e[1],returnValue:e[2]}}var JSsource=null;function ensureJSsource(){if(!JSsource){JSsource={};for(var t in JSfuncs)JSfuncs.hasOwnProperty(t)&&(JSsource[t]=parseJSFunc(JSfuncs[t]))}}cwrap=function cwrap(ident,returnType,argTypes){argTypes=argTypes||[];var cfunc=getCFunc(ident),numericArgs=argTypes.every(function(t){return t==="number"}),numericRet=returnType!=="string";if(numericRet&&numericArgs)return cfunc;var argNames=argTypes.map(function(t,e){return"$"+e}),funcstr="(function("+argNames.join(",")+") {",nargs=argTypes.length;if(!numericArgs){ensureJSsource(),funcstr+="var stack = "+JSsource.stackSave.body+";";for(var i=0;i<nargs;i++){var arg=argNames[i],type=argTypes[i];if(type!=="number"){var convertCode=JSsource[type+"ToC"];funcstr+="var "+convertCode.arguments+" = "+arg+";",funcstr+=convertCode.body+";",funcstr+=arg+"=("+convertCode.returnValue+");"}}}var cfuncname=parseJSFunc(function(){return cfunc}).returnValue;if(funcstr+="var ret = "+cfuncname+"("+argNames.join(",")+");",!numericRet){var strgfy=parseJSFunc(function(){return Pointer_stringify}).returnValue;funcstr+="ret = "+strgfy+"(ret);"}return numericArgs||(ensureJSsource(),funcstr+=JSsource.stackRestore.body.replace("()","(stack)")+";"),funcstr+="return ret})",eval(funcstr)}})(),Module.ccall=ccall,Module.cwrap=cwrap;function setValue(t,e,r,o){switch(r=r||"i8",r.charAt(r.length-1)==="*"&&(r="i32"),r){case"i1":HEAP8[t>>0]=e;break;case"i8":HEAP8[t>>0]=e;break;case"i16":HEAP16[t>>1]=e;break;case"i32":HEAP32[t>>2]=e;break;case"i64":tempI64=[e>>>0,(tempDouble=e,+Math_abs(tempDouble)>=1?tempDouble>0?(Math_min(+Math_floor(tempDouble/4294967296),4294967295)|0)>>>0:~~+Math_ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[t>>2]=tempI64[0],HEAP32[t+4>>2]=tempI64[1];break;case"float":HEAPF32[t>>2]=e;break;case"double":HEAPF64[t>>3]=e;break;default:abort("invalid type for setValue: "+r)}}Module.setValue=setValue;function getValue(t,e,r){switch(e=e||"i8",e.charAt(e.length-1)==="*"&&(e="i32"),e){case"i1":return HEAP8[t>>0];case"i8":return HEAP8[t>>0];case"i16":return HEAP16[t>>1];case"i32":return HEAP32[t>>2];case"i64":return HEAP32[t>>2];case"float":return HEAPF32[t>>2];case"double":return HEAPF64[t>>3];default:abort("invalid type for setValue: "+e)}return null}Module.getValue=getValue;var ALLOC_NORMAL=0,ALLOC_STACK=1,ALLOC_STATIC=2,ALLOC_DYNAMIC=3,ALLOC_NONE=4;Module.ALLOC_NORMAL=ALLOC_NORMAL,Module.ALLOC_STACK=ALLOC_STACK,Module.ALLOC_STATIC=ALLOC_STATIC,Module.ALLOC_DYNAMIC=ALLOC_DYNAMIC,Module.ALLOC_NONE=ALLOC_NONE;function allocate(t,e,r,o){var a,n;typeof t=="number"?(a=!0,n=t):(a=!1,n=t.length);var u=typeof e=="string"?e:null,A;if(r==ALLOC_NONE?A=o:A=[typeof _malloc=="function"?_malloc:Runtime.staticAlloc,Runtime.stackAlloc,Runtime.staticAlloc,Runtime.dynamicAlloc][r===void 0?ALLOC_STATIC:r](Math.max(n,u?1:e.length)),a){var o=A,p;for(assert((A&3)==0),p=A+(n&-4);o<p;o+=4)HEAP32[o>>2]=0;for(p=A+n;o<p;)HEAP8[o++>>0]=0;return A}if(u==="i8")return t.subarray||t.slice?HEAPU8.set(t,A):HEAPU8.set(new Uint8Array(t),A),A;for(var h=0,E,I,v;h<n;){var x=t[h];if(typeof x=="function"&&(x=Runtime.getFunctionIndex(x)),E=u||e[h],E===0){h++;continue}E=="i64"&&(E="i32"),setValue(A+h,x,E),v!==E&&(I=Runtime.getNativeTypeSize(E),v=E),h+=I}return A}Module.allocate=allocate;function getMemory(t){return staticSealed?runtimeInitialized?_malloc(t):Runtime.dynamicAlloc(t):Runtime.staticAlloc(t)}Module.getMemory=getMemory;function Pointer_stringify(t,e){if(e===0||!t)return"";for(var r=0,o,a=0;o=HEAPU8[t+a>>0],r|=o,!(o==0&&!e||(a++,e&&a==e)););e||(e=a);var n="";if(r<128){for(var u=1024,A;e>0;)A=String.fromCharCode.apply(String,HEAPU8.subarray(t,t+Math.min(e,u))),n=n?n+A:A,t+=u,e-=u;return n}return Module.UTF8ToString(t)}Module.Pointer_stringify=Pointer_stringify;function AsciiToString(t){for(var e="";;){var r=HEAP8[t++>>0];if(!r)return e;e+=String.fromCharCode(r)}}Module.AsciiToString=AsciiToString;function stringToAscii(t,e){return writeAsciiToMemory(t,e,!1)}Module.stringToAscii=stringToAscii;var UTF8Decoder=typeof TextDecoder<"u"?new TextDecoder("utf8"):void 0;function UTF8ArrayToString(t,e){for(var r=e;t[r];)++r;if(r-e>16&&t.subarray&&UTF8Decoder)return UTF8Decoder.decode(t.subarray(e,r));for(var o,a,n,u,A,p,h="";;){if(o=t[e++],!o)return h;if(!(o&128)){h+=String.fromCharCode(o);continue}if(a=t[e++]&63,(o&224)==192){h+=String.fromCharCode((o&31)<<6|a);continue}if(n=t[e++]&63,(o&240)==224?o=(o&15)<<12|a<<6|n:(u=t[e++]&63,(o&248)==240?o=(o&7)<<18|a<<12|n<<6|u:(A=t[e++]&63,(o&252)==248?o=(o&3)<<24|a<<18|n<<12|u<<6|A:(p=t[e++]&63,o=(o&1)<<30|a<<24|n<<18|u<<12|A<<6|p))),o<65536)h+=String.fromCharCode(o);else{var E=o-65536;h+=String.fromCharCode(55296|E>>10,56320|E&1023)}}}Module.UTF8ArrayToString=UTF8ArrayToString;function UTF8ToString(t){return UTF8ArrayToString(HEAPU8,t)}Module.UTF8ToString=UTF8ToString;function stringToUTF8Array(t,e,r,o){if(!(o>0))return 0;for(var a=r,n=r+o-1,u=0;u<t.length;++u){var A=t.charCodeAt(u);if(A>=55296&&A<=57343&&(A=65536+((A&1023)<<10)|t.charCodeAt(++u)&1023),A<=127){if(r>=n)break;e[r++]=A}else if(A<=2047){if(r+1>=n)break;e[r++]=192|A>>6,e[r++]=128|A&63}else if(A<=65535){if(r+2>=n)break;e[r++]=224|A>>12,e[r++]=128|A>>6&63,e[r++]=128|A&63}else if(A<=2097151){if(r+3>=n)break;e[r++]=240|A>>18,e[r++]=128|A>>12&63,e[r++]=128|A>>6&63,e[r++]=128|A&63}else if(A<=67108863){if(r+4>=n)break;e[r++]=248|A>>24,e[r++]=128|A>>18&63,e[r++]=128|A>>12&63,e[r++]=128|A>>6&63,e[r++]=128|A&63}else{if(r+5>=n)break;e[r++]=252|A>>30,e[r++]=128|A>>24&63,e[r++]=128|A>>18&63,e[r++]=128|A>>12&63,e[r++]=128|A>>6&63,e[r++]=128|A&63}}return e[r]=0,r-a}Module.stringToUTF8Array=stringToUTF8Array;function stringToUTF8(t,e,r){return stringToUTF8Array(t,HEAPU8,e,r)}Module.stringToUTF8=stringToUTF8;function lengthBytesUTF8(t){for(var e=0,r=0;r<t.length;++r){var o=t.charCodeAt(r);o>=55296&&o<=57343&&(o=65536+((o&1023)<<10)|t.charCodeAt(++r)&1023),o<=127?++e:o<=2047?e+=2:o<=65535?e+=3:o<=2097151?e+=4:o<=67108863?e+=5:e+=6}return e}Module.lengthBytesUTF8=lengthBytesUTF8;var UTF16Decoder=typeof TextDecoder<"u"?new TextDecoder("utf-16le"):void 0;function demangle(t){var e=Module.___cxa_demangle||Module.__cxa_demangle;if(e){try{var r=t.substr(1),o=lengthBytesUTF8(r)+1,a=_malloc(o);stringToUTF8(r,a,o);var n=_malloc(4),u=e(a,0,0,n);if(getValue(n,"i32")===0&&u)return Pointer_stringify(u)}catch{}finally{a&&_free(a),n&&_free(n),u&&_free(u)}return t}return Runtime.warnOnce("warning: build with -s DEMANGLE_SUPPORT=1 to link in libcxxabi demangling"),t}function demangleAll(t){var e=/__Z[\w\d_]+/g;return t.replace(e,function(r){var o=demangle(r);return r===o?r:r+" ["+o+"]"})}function jsStackTrace(){var t=new Error;if(!t.stack){try{throw new Error(0)}catch(e){t=e}if(!t.stack)return"(no stack trace available)"}return t.stack.toString()}function stackTrace(){var t=jsStackTrace();return Module.extraStackTrace&&(t+=` +`+Module.extraStackTrace()),demangleAll(t)}Module.stackTrace=stackTrace;var HEAP,buffer,HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateGlobalBufferViews(){Module.HEAP8=HEAP8=new Int8Array(buffer),Module.HEAP16=HEAP16=new Int16Array(buffer),Module.HEAP32=HEAP32=new Int32Array(buffer),Module.HEAPU8=HEAPU8=new Uint8Array(buffer),Module.HEAPU16=HEAPU16=new Uint16Array(buffer),Module.HEAPU32=HEAPU32=new Uint32Array(buffer),Module.HEAPF32=HEAPF32=new Float32Array(buffer),Module.HEAPF64=HEAPF64=new Float64Array(buffer)}var STATIC_BASE,STATICTOP,staticSealed,STACK_BASE,STACKTOP,STACK_MAX,DYNAMIC_BASE,DYNAMICTOP_PTR;STATIC_BASE=STATICTOP=STACK_BASE=STACKTOP=STACK_MAX=DYNAMIC_BASE=DYNAMICTOP_PTR=0,staticSealed=!1;function abortOnCannotGrowMemory(){abort("Cannot enlarge memory arrays. Either (1) compile with -s TOTAL_MEMORY=X with X higher than the current value "+TOTAL_MEMORY+", (2) compile with -s ALLOW_MEMORY_GROWTH=1 which allows increasing the size at runtime but prevents some optimizations, (3) set Module.TOTAL_MEMORY to a higher value before the program runs, or (4) if you want malloc to return NULL (0) instead of this abort, compile with -s ABORTING_MALLOC=0 ")}function enlargeMemory(){abortOnCannotGrowMemory()}var TOTAL_STACK=Module.TOTAL_STACK||5242880,TOTAL_MEMORY=Module.TOTAL_MEMORY||134217728;TOTAL_MEMORY<TOTAL_STACK&&Module.printErr("TOTAL_MEMORY should be larger than TOTAL_STACK, was "+TOTAL_MEMORY+"! (TOTAL_STACK="+TOTAL_STACK+")"),Module.buffer?buffer=Module.buffer:buffer=new ArrayBuffer(TOTAL_MEMORY),updateGlobalBufferViews();function getTotalMemory(){return TOTAL_MEMORY}if(HEAP32[0]=1668509029,HEAP16[1]=25459,HEAPU8[2]!==115||HEAPU8[3]!==99)throw"Runtime error: expected the system to be little-endian!";Module.HEAP=HEAP,Module.buffer=buffer,Module.HEAP8=HEAP8,Module.HEAP16=HEAP16,Module.HEAP32=HEAP32,Module.HEAPU8=HEAPU8,Module.HEAPU16=HEAPU16,Module.HEAPU32=HEAPU32,Module.HEAPF32=HEAPF32,Module.HEAPF64=HEAPF64;function callRuntimeCallbacks(t){for(;t.length>0;){var e=t.shift();if(typeof e=="function"){e();continue}var r=e.func;typeof r=="number"?e.arg===void 0?Module.dynCall_v(r):Module.dynCall_vi(r,e.arg):r(e.arg===void 0?null:e.arg)}}var __ATPRERUN__=[],__ATINIT__=[],__ATMAIN__=[],__ATEXIT__=[],__ATPOSTRUN__=[],runtimeInitialized=!1,runtimeExited=!1;function preRun(){if(Module.preRun)for(typeof Module.preRun=="function"&&(Module.preRun=[Module.preRun]);Module.preRun.length;)addOnPreRun(Module.preRun.shift());callRuntimeCallbacks(__ATPRERUN__)}function ensureInitRuntime(){runtimeInitialized||(runtimeInitialized=!0,callRuntimeCallbacks(__ATINIT__))}function preMain(){callRuntimeCallbacks(__ATMAIN__)}function exitRuntime(){callRuntimeCallbacks(__ATEXIT__),runtimeExited=!0}function postRun(){if(Module.postRun)for(typeof Module.postRun=="function"&&(Module.postRun=[Module.postRun]);Module.postRun.length;)addOnPostRun(Module.postRun.shift());callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(t){__ATPRERUN__.unshift(t)}Module.addOnPreRun=addOnPreRun;function addOnInit(t){__ATINIT__.unshift(t)}Module.addOnInit=addOnInit;function addOnPreMain(t){__ATMAIN__.unshift(t)}Module.addOnPreMain=addOnPreMain;function addOnExit(t){__ATEXIT__.unshift(t)}Module.addOnExit=addOnExit;function addOnPostRun(t){__ATPOSTRUN__.unshift(t)}Module.addOnPostRun=addOnPostRun;function intArrayFromString(t,e,r){var o=r>0?r:lengthBytesUTF8(t)+1,a=new Array(o),n=stringToUTF8Array(t,a,0,a.length);return e&&(a.length=n),a}Module.intArrayFromString=intArrayFromString;function intArrayToString(t){for(var e=[],r=0;r<t.length;r++){var o=t[r];o>255&&(o&=255),e.push(String.fromCharCode(o))}return e.join("")}Module.intArrayToString=intArrayToString;function writeStringToMemory(t,e,r){Runtime.warnOnce("writeStringToMemory is deprecated and should not be called! Use stringToUTF8() instead!");var o,a;r&&(a=e+lengthBytesUTF8(t),o=HEAP8[a]),stringToUTF8(t,e,1/0),r&&(HEAP8[a]=o)}Module.writeStringToMemory=writeStringToMemory;function writeArrayToMemory(t,e){HEAP8.set(t,e)}Module.writeArrayToMemory=writeArrayToMemory;function writeAsciiToMemory(t,e,r){for(var o=0;o<t.length;++o)HEAP8[e++>>0]=t.charCodeAt(o);r||(HEAP8[e>>0]=0)}if(Module.writeAsciiToMemory=writeAsciiToMemory,(!Math.imul||Math.imul(4294967295,5)!==-5)&&(Math.imul=function t(e,r){var o=e>>>16,a=e&65535,n=r>>>16,u=r&65535;return a*u+(o*u+a*n<<16)|0}),Math.imul=Math.imul,!Math.fround){var froundBuffer=new Float32Array(1);Math.fround=function(t){return froundBuffer[0]=t,froundBuffer[0]}}Math.fround=Math.fround,Math.clz32||(Math.clz32=function(t){t=t>>>0;for(var e=0;e<32;e++)if(t&1<<31-e)return e;return 32}),Math.clz32=Math.clz32,Math.trunc||(Math.trunc=function(t){return t<0?Math.ceil(t):Math.floor(t)}),Math.trunc=Math.trunc;var Math_abs=Math.abs,Math_cos=Math.cos,Math_sin=Math.sin,Math_tan=Math.tan,Math_acos=Math.acos,Math_asin=Math.asin,Math_atan=Math.atan,Math_atan2=Math.atan2,Math_exp=Math.exp,Math_log=Math.log,Math_sqrt=Math.sqrt,Math_ceil=Math.ceil,Math_floor=Math.floor,Math_pow=Math.pow,Math_imul=Math.imul,Math_fround=Math.fround,Math_round=Math.round,Math_min=Math.min,Math_clz32=Math.clz32,Math_trunc=Math.trunc,runDependencies=0,runDependencyWatcher=null,dependenciesFulfilled=null;function getUniqueRunDependency(t){return t}function addRunDependency(t){runDependencies++,Module.monitorRunDependencies&&Module.monitorRunDependencies(runDependencies)}Module.addRunDependency=addRunDependency;function removeRunDependency(t){if(runDependencies--,Module.monitorRunDependencies&&Module.monitorRunDependencies(runDependencies),runDependencies==0&&(runDependencyWatcher!==null&&(clearInterval(runDependencyWatcher),runDependencyWatcher=null),dependenciesFulfilled)){var e=dependenciesFulfilled;dependenciesFulfilled=null,e()}}Module.removeRunDependency=removeRunDependency,Module.preloadedImages={},Module.preloadedAudios={};var ASM_CONSTS=[function(t,e,r,o,a,n,u,A){return _nbind.callbackSignatureList[t].apply(this,arguments)}];function _emscripten_asm_const_iiiiiiii(t,e,r,o,a,n,u,A){return ASM_CONSTS[t](e,r,o,a,n,u,A)}function _emscripten_asm_const_iiiii(t,e,r,o,a){return ASM_CONSTS[t](e,r,o,a)}function _emscripten_asm_const_iiidddddd(t,e,r,o,a,n,u,A,p){return ASM_CONSTS[t](e,r,o,a,n,u,A,p)}function _emscripten_asm_const_iiididi(t,e,r,o,a,n,u){return ASM_CONSTS[t](e,r,o,a,n,u)}function _emscripten_asm_const_iiii(t,e,r,o){return ASM_CONSTS[t](e,r,o)}function _emscripten_asm_const_iiiid(t,e,r,o,a){return ASM_CONSTS[t](e,r,o,a)}function _emscripten_asm_const_iiiiii(t,e,r,o,a,n){return ASM_CONSTS[t](e,r,o,a,n)}STATIC_BASE=Runtime.GLOBAL_BASE,STATICTOP=STATIC_BASE+12800,__ATINIT__.push({func:function(){__GLOBAL__sub_I_Yoga_cpp()}},{func:function(){__GLOBAL__sub_I_nbind_cc()}},{func:function(){__GLOBAL__sub_I_common_cc()}},{func:function(){__GLOBAL__sub_I_Binding_cc()}}),allocate([0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192,127,0,0,192,127,0,0,192,127,0,0,192,127,3,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,3,0,0,0,0,0,192,127,3,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192,127,0,0,192,127,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192,127,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192,127,0,0,192,127,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,0,0,128,191,0,0,128,191,0,0,192,127,0,0,0,0,0,0,0,0,0,0,128,63,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,3,0,0,0,1,0,0,0,2,0,0,0,0,0,0,0,190,12,0,0,200,12,0,0,208,12,0,0,216,12,0,0,230,12,0,0,242,12,0,0,1,0,0,0,3,0,0,0,0,0,0,0,2,0,0,0,0,0,192,127,3,0,0,0,180,45,0,0,181,45,0,0,182,45,0,0,181,45,0,0,182,45,0,0,0,0,0,0,0,0,0,0,1,0,0,0,2,0,0,0,3,0,0,0,1,0,0,0,4,0,0,0,183,45,0,0,181,45,0,0,181,45,0,0,181,45,0,0,181,45,0,0,181,45,0,0,181,45,0,0,184,45,0,0,185,45,0,0,181,45,0,0,181,45,0,0,182,45,0,0,186,45,0,0,185,45,0,0,148,4,0,0,3,0,0,0,187,45,0,0,164,4,0,0,188,45,0,0,2,0,0,0,189,45,0,0,164,4,0,0,188,45,0,0,185,45,0,0,164,4,0,0,185,45,0,0,164,4,0,0,188,45,0,0,181,45,0,0,182,45,0,0,181,45,0,0,0,0,0,0,0,0,0,0,1,0,0,0,5,0,0,0,6,0,0,0,1,0,0,0,7,0,0,0,183,45,0,0,182,45,0,0,181,45,0,0,190,45,0,0,190,45,0,0,182,45,0,0,182,45,0,0,185,45,0,0,181,45,0,0,185,45,0,0,182,45,0,0,181,45,0,0,185,45,0,0,182,45,0,0,185,45,0,0,48,5,0,0,3,0,0,0,56,5,0,0,1,0,0,0,189,45,0,0,185,45,0,0,164,4,0,0,76,5,0,0,2,0,0,0,191,45,0,0,186,45,0,0,182,45,0,0,185,45,0,0,192,45,0,0,185,45,0,0,182,45,0,0,186,45,0,0,185,45,0,0,76,5,0,0,76,5,0,0,136,5,0,0,182,45,0,0,181,45,0,0,2,0,0,0,190,45,0,0,136,5,0,0,56,19,0,0,156,5,0,0,2,0,0,0,184,45,0,0,0,0,0,0,0,0,0,0,1,0,0,0,8,0,0,0,9,0,0,0,1,0,0,0,10,0,0,0,204,5,0,0,181,45,0,0,181,45,0,0,2,0,0,0,180,45,0,0,204,5,0,0,2,0,0,0,195,45,0,0,236,5,0,0,97,19,0,0,198,45,0,0,211,45,0,0,212,45,0,0,213,45,0,0,214,45,0,0,215,45,0,0,188,45,0,0,182,45,0,0,216,45,0,0,217,45,0,0,218,45,0,0,219,45,0,0,192,45,0,0,181,45,0,0,0,0,0,0,185,45,0,0,110,19,0,0,186,45,0,0,115,19,0,0,221,45,0,0,120,19,0,0,148,4,0,0,132,19,0,0,96,6,0,0,145,19,0,0,222,45,0,0,164,19,0,0,223,45,0,0,173,19,0,0,0,0,0,0,3,0,0,0,104,6,0,0,1,0,0,0,187,45,0,0,0,0,0,0,0,0,0,0,1,0,0,0,11,0,0,0,12,0,0,0,1,0,0,0,13,0,0,0,185,45,0,0,224,45,0,0,164,6,0,0,188,45,0,0,172,6,0,0,180,6,0,0,2,0,0,0,188,6,0,0,7,0,0,0,224,45,0,0,7,0,0,0,164,6,0,0,1,0,0,0,213,45,0,0,185,45,0,0,224,45,0,0,172,6,0,0,185,45,0,0,224,45,0,0,164,6,0,0,185,45,0,0,224,45,0,0,211,45,0,0,211,45,0,0,222,45,0,0,211,45,0,0,224,45,0,0,222,45,0,0,211,45,0,0,224,45,0,0,172,6,0,0,222,45,0,0,211,45,0,0,224,45,0,0,188,45,0,0,222,45,0,0,211,45,0,0,40,7,0,0,188,45,0,0,2,0,0,0,224,45,0,0,185,45,0,0,188,45,0,0,188,45,0,0,188,45,0,0,188,45,0,0,222,45,0,0,224,45,0,0,148,4,0,0,185,45,0,0,148,4,0,0,148,4,0,0,148,4,0,0,148,4,0,0,148,4,0,0,185,45,0,0,164,6,0,0,148,4,0,0,0,0,0,0,0,0,0,0,1,0,0,0,14,0,0,0,15,0,0,0,1,0,0,0,16,0,0,0,148,7,0,0,2,0,0,0,225,45,0,0,183,45,0,0,188,45,0,0,168,7,0,0,5,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,2,0,0,0,234,45,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,148,45,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,28,9,0,0,5,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,2,0,0,0,242,45,0,0,0,4,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,67,111,117,108,100,32,110,111,116,32,97,108,108,111,99,97,116,101,32,109,101,109,111,114,121,32,102,111,114,32,110,111,100,101,0,67,97,110,110,111,116,32,114,101,115,101,116,32,97,32,110,111,100,101,32,119,104,105,99,104,32,115,116,105,108,108,32,104,97,115,32,99,104,105,108,100,114,101,110,32,97,116,116,97,99,104,101,100,0,67,97,110,110,111,116,32,114,101,115,101,116,32,97,32,110,111,100,101,32,115,116,105,108,108,32,97,116,116,97,99,104,101,100,32,116,111,32,97,32,112,97,114,101,110,116,0,67,111,117,108,100,32,110,111,116,32,97,108,108,111,99,97,116,101,32,109,101,109,111,114,121,32,102,111,114,32,99,111,110,102,105,103,0,67,97,110,110,111,116,32,115,101,116,32,109,101,97,115,117,114,101,32,102,117,110,99,116,105,111,110,58,32,78,111,100,101,115,32,119,105,116,104,32,109,101,97,115,117,114,101,32,102,117,110,99,116,105,111,110,115,32,99,97,110,110,111,116,32,104,97,118,101,32,99,104,105,108,100,114,101,110,46,0,67,104,105,108,100,32,97,108,114,101,97,100,121,32,104,97,115,32,97,32,112,97,114,101,110,116,44,32,105,116,32,109,117,115,116,32,98,101,32,114,101,109,111,118,101,100,32,102,105,114,115,116,46,0,67,97,110,110,111,116,32,97,100,100,32,99,104,105,108,100,58,32,78,111,100,101,115,32,119,105,116,104,32,109,101,97,115,117,114,101,32,102,117,110,99,116,105,111,110,115,32,99,97,110,110,111,116,32,104,97,118,101,32,99,104,105,108,100,114,101,110,46,0,79,110,108,121,32,108,101,97,102,32,110,111,100,101,115,32,119,105,116,104,32,99,117,115,116,111,109,32,109,101,97,115,117,114,101,32,102,117,110,99,116,105,111,110,115,115,104,111,117,108,100,32,109,97,110,117,97,108,108,121,32,109,97,114,107,32,116,104,101,109,115,101,108,118,101,115,32,97,115,32,100,105,114,116,121,0,67,97,110,110,111,116,32,103,101,116,32,108,97,121,111,117,116,32,112,114,111,112,101,114,116,105,101,115,32,111,102,32,109,117,108,116,105,45,101,100,103,101,32,115,104,111,114,116,104,97,110,100,115,0,37,115,37,100,46,123,91,115,107,105,112,112,101,100,93,32,0,119,109,58,32,37,115,44,32,104,109,58,32,37,115,44,32,97,119,58,32,37,102,32,97,104,58,32,37,102,32,61,62,32,100,58,32,40,37,102,44,32,37,102,41,32,37,115,10,0,37,115,37,100,46,123,37,115,0,42,0,119,109,58,32,37,115,44,32,104,109,58,32,37,115,44,32,97,119,58,32,37,102,32,97,104,58,32,37,102,32,37,115,10,0,37,115,37,100,46,125,37,115,0,119,109,58,32,37,115,44,32,104,109,58,32,37,115,44,32,100,58,32,40,37,102,44,32,37,102,41,32,37,115,10,0,79,117,116,32,111,102,32,99,97,99,104,101,32,101,110,116,114,105,101,115,33,10,0,83,99,97,108,101,32,102,97,99,116,111,114,32,115,104,111,117,108,100,32,110,111,116,32,98,101,32,108,101,115,115,32,116,104,97,110,32,122,101,114,111,0,105,110,105,116,105,97,108,0,37,115,10,0,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,0,85,78,68,69,70,73,78,69,68,0,69,88,65,67,84,76,89,0,65,84,95,77,79,83,84,0,76,65,89,95,85,78,68,69,70,73,78,69,68,0,76,65,89,95,69,88,65,67,84,76,89,0,76,65,89,95,65,84,95,77,79,83,84,0,97,118,97,105,108,97,98,108,101,87,105,100,116,104,32,105,115,32,105,110,100,101,102,105,110,105,116,101,32,115,111,32,119,105,100,116,104,77,101,97,115,117,114,101,77,111,100,101,32,109,117,115,116,32,98,101,32,89,71,77,101,97,115,117,114,101,77,111,100,101,85,110,100,101,102,105,110,101,100,0,97,118,97,105,108,97,98,108,101,72,101,105,103,104,116,32,105,115,32,105,110,100,101,102,105,110,105,116,101,32,115,111,32,104,101,105,103,104,116,77,101,97,115,117,114,101,77,111,100,101,32,109,117,115,116,32,98,101,32,89,71,77,101,97,115,117,114,101,77,111,100,101,85,110,100,101,102,105,110,101,100,0,102,108,101,120,0,115,116,114,101,116,99,104,0,109,117,108,116,105,108,105,110,101,45,115,116,114,101,116,99,104,0,69,120,112,101,99,116,101,100,32,110,111,100,101,32,116,111,32,104,97,118,101,32,99,117,115,116,111,109,32,109,101,97,115,117,114,101,32,102,117,110,99,116,105,111,110,0,109,101,97,115,117,114,101,0,69,120,112,101,99,116,32,99,117,115,116,111,109,32,98,97,115,101,108,105,110,101,32,102,117,110,99,116,105,111,110,32,116,111,32,110,111,116,32,114,101,116,117,114,110,32,78,97,78,0,97,98,115,45,109,101,97,115,117,114,101,0,97,98,115,45,108,97,121,111,117,116,0,78,111,100,101,0,99,114,101,97,116,101,68,101,102,97,117,108,116,0,99,114,101,97,116,101,87,105,116,104,67,111,110,102,105,103,0,100,101,115,116,114,111,121,0,114,101,115,101,116,0,99,111,112,121,83,116,121,108,101,0,115,101,116,80,111,115,105,116,105,111,110,84,121,112,101,0,115,101,116,80,111,115,105,116,105,111,110,0,115,101,116,80,111,115,105,116,105,111,110,80,101,114,99,101,110,116,0,115,101,116,65,108,105,103,110,67,111,110,116,101,110,116,0,115,101,116,65,108,105,103,110,73,116,101,109,115,0,115,101,116,65,108,105,103,110,83,101,108,102,0,115,101,116,70,108,101,120,68,105,114,101,99,116,105,111,110,0,115,101,116,70,108,101,120,87,114,97,112,0,115,101,116,74,117,115,116,105,102,121,67,111,110,116,101,110,116,0,115,101,116,77,97,114,103,105,110,0,115,101,116,77,97,114,103,105,110,80,101,114,99,101,110,116,0,115,101,116,77,97,114,103,105,110,65,117,116,111,0,115,101,116,79,118,101,114,102,108,111,119,0,115,101,116,68,105,115,112,108,97,121,0,115,101,116,70,108,101,120,0,115,101,116,70,108,101,120,66,97,115,105,115,0,115,101,116,70,108,101,120,66,97,115,105,115,80,101,114,99,101,110,116,0,115,101,116,70,108,101,120,71,114,111,119,0,115,101,116,70,108,101,120,83,104,114,105,110,107,0,115,101,116,87,105,100,116,104,0,115,101,116,87,105,100,116,104,80,101,114,99,101,110,116,0,115,101,116,87,105,100,116,104,65,117,116,111,0,115,101,116,72,101,105,103,104,116,0,115,101,116,72,101,105,103,104,116,80,101,114,99,101,110,116,0,115,101,116,72,101,105,103,104,116,65,117,116,111,0,115,101,116,77,105,110,87,105,100,116,104,0,115,101,116,77,105,110,87,105,100,116,104,80,101,114,99,101,110,116,0,115,101,116,77,105,110,72,101,105,103,104,116,0,115,101,116,77,105,110,72,101,105,103,104,116,80,101,114,99,101,110,116,0,115,101,116,77,97,120,87,105,100,116,104,0,115,101,116,77,97,120,87,105,100,116,104,80,101,114,99,101,110,116,0,115,101,116,77,97,120,72,101,105,103,104,116,0,115,101,116,77,97,120,72,101,105,103,104,116,80,101,114,99,101,110,116,0,115,101,116,65,115,112,101,99,116,82,97,116,105,111,0,115,101,116,66,111,114,100,101,114,0,115,101,116,80,97,100,100,105,110,103,0,115,101,116,80,97,100,100,105,110,103,80,101,114,99,101,110,116,0,103,101,116,80,111,115,105,116,105,111,110,84,121,112,101,0,103,101,116,80,111,115,105,116,105,111,110,0,103,101,116,65,108,105,103,110,67,111,110,116,101,110,116,0,103,101,116,65,108,105,103,110,73,116,101,109,115,0,103,101,116,65,108,105,103,110,83,101,108,102,0,103,101,116,70,108,101,120,68,105,114,101,99,116,105,111,110,0,103,101,116,70,108,101,120,87,114,97,112,0,103,101,116,74,117,115,116,105,102,121,67,111,110,116,101,110,116,0,103,101,116,77,97,114,103,105,110,0,103,101,116,70,108,101,120,66,97,115,105,115,0,103,101,116,70,108,101,120,71,114,111,119,0,103,101,116,70,108,101,120,83,104,114,105,110,107,0,103,101,116,87,105,100,116,104,0,103,101,116,72,101,105,103,104,116,0,103,101,116,77,105,110,87,105,100,116,104,0,103,101,116,77,105,110,72,101,105,103,104,116,0,103,101,116,77,97,120,87,105,100,116,104,0,103,101,116,77,97,120,72,101,105,103,104,116,0,103,101,116,65,115,112,101,99,116,82,97,116,105,111,0,103,101,116,66,111,114,100,101,114,0,103,101,116,79,118,101,114,102,108,111,119,0,103,101,116,68,105,115,112,108,97,121,0,103,101,116,80,97,100,100,105,110,103,0,105,110,115,101,114,116,67,104,105,108,100,0,114,101,109,111,118,101,67,104,105,108,100,0,103,101,116,67,104,105,108,100,67,111,117,110,116,0,103,101,116,80,97,114,101,110,116,0,103,101,116,67,104,105,108,100,0,115,101,116,77,101,97,115,117,114,101,70,117,110,99,0,117,110,115,101,116,77,101,97,115,117,114,101,70,117,110,99,0,109,97,114,107,68,105,114,116,121,0,105,115,68,105,114,116,121,0,99,97,108,99,117,108,97,116,101,76,97,121,111,117,116,0,103,101,116,67,111,109,112,117,116,101,100,76,101,102,116,0,103,101,116,67,111,109,112,117,116,101,100,82,105,103,104,116,0,103,101,116,67,111,109,112,117,116,101,100,84,111,112,0,103,101,116,67,111,109,112,117,116,101,100,66,111,116,116,111,109,0,103,101,116,67,111,109,112,117,116,101,100,87,105,100,116,104,0,103,101,116,67,111,109,112,117,116,101,100,72,101,105,103,104,116,0,103,101,116,67,111,109,112,117,116,101,100,76,97,121,111,117,116,0,103,101,116,67,111,109,112,117,116,101,100,77,97,114,103,105,110,0,103,101,116,67,111,109,112,117,116,101,100,66,111,114,100,101,114,0,103,101,116,67,111,109,112,117,116,101,100,80,97,100,100,105,110,103,0,67,111,110,102,105,103,0,99,114,101,97,116,101,0,115,101,116,69,120,112,101,114,105,109,101,110,116,97,108,70,101,97,116,117,114,101,69,110,97,98,108,101,100,0,115,101,116,80,111,105,110,116,83,99,97,108,101,70,97,99,116,111,114,0,105,115,69,120,112,101,114,105,109,101,110,116,97,108,70,101,97,116,117,114,101,69,110,97,98,108,101,100,0,86,97,108,117,101,0,76,97,121,111,117,116,0,83,105,122,101,0,103,101,116,73,110,115,116,97,110,99,101,67,111,117,110,116,0,73,110,116,54,52,0,1,1,1,2,2,4,4,4,4,8,8,4,8,118,111,105,100,0,98,111,111,108,0,115,116,100,58,58,115,116,114,105,110,103,0,99,98,70,117,110,99,116,105,111,110,32,38,0,99,111,110,115,116,32,99,98,70,117,110,99,116,105,111,110,32,38,0,69,120,116,101,114,110,97,108,0,66,117,102,102,101,114,0,78,66,105,110,100,73,68,0,78,66,105,110,100,0,98,105,110,100,95,118,97,108,117,101,0,114,101,102,108,101,99,116,0,113,117,101,114,121,84,121,112,101,0,108,97,108,108,111,99,0,108,114,101,115,101,116,0,123,114,101,116,117,114,110,40,95,110,98,105,110,100,46,99,97,108,108,98,97,99,107,83,105,103,110,97,116,117,114,101,76,105,115,116,91,36,48,93,46,97,112,112,108,121,40,116,104,105,115,44,97,114,103,117,109,101,110,116,115,41,41,59,125,0,95,110,98,105,110,100,95,110,101,119,0,17,0,10,0,17,17,17,0,0,0,0,5,0,0,0,0,0,0,9,0,0,0,0,11,0,0,0,0,0,0,0,0,17,0,15,10,17,17,17,3,10,7,0,1,19,9,11,11,0,0,9,6,11,0,0,11,0,6,17,0,0,0,17,17,17,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,11,0,0,0,0,0,0,0,0,17,0,10,10,17,17,17,0,10,0,0,2,0,9,11,0,0,0,9,0,11,0,0,11,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,12,0,0,0,0,0,0,0,0,0,0,0,12,0,0,0,0,12,0,0,0,0,9,12,0,0,0,0,0,12,0,0,12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,14,0,0,0,0,0,0,0,0,0,0,0,13,0,0,0,4,13,0,0,0,0,9,14,0,0,0,0,0,14,0,0,14,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,16,0,0,0,0,0,0,0,0,0,0,0,15,0,0,0,0,15,0,0,0,0,9,16,0,0,0,0,0,16,0,0,16,0,0,18,0,0,0,18,18,18,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,18,0,0,0,18,18,18,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,11,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,10,0,0,0,0,9,11,0,0,0,0,0,11,0,0,11,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,12,0,0,0,0,0,0,0,0,0,0,0,12,0,0,0,0,12,0,0,0,0,9,12,0,0,0,0,0,12,0,0,12,0,0,45,43,32,32,32,48,88,48,120,0,40,110,117,108,108,41,0,45,48,88,43,48,88,32,48,88,45,48,120,43,48,120,32,48,120,0,105,110,102,0,73,78,70,0,110,97,110,0,78,65,78,0,48,49,50,51,52,53,54,55,56,57,65,66,67,68,69,70,46,0,84,33,34,25,13,1,2,3,17,75,28,12,16,4,11,29,18,30,39,104,110,111,112,113,98,32,5,6,15,19,20,21,26,8,22,7,40,36,23,24,9,10,14,27,31,37,35,131,130,125,38,42,43,60,61,62,63,67,71,74,77,88,89,90,91,92,93,94,95,96,97,99,100,101,102,103,105,106,107,108,114,115,116,121,122,123,124,0,73,108,108,101,103,97,108,32,98,121,116,101,32,115,101,113,117,101,110,99,101,0,68,111,109,97,105,110,32,101,114,114,111,114,0,82,101,115,117,108,116,32,110,111,116,32,114,101,112,114,101,115,101,110,116,97,98,108,101,0,78,111,116,32,97,32,116,116,121,0,80,101,114,109,105,115,115,105,111,110,32,100,101,110,105,101,100,0,79,112,101,114,97,116,105,111,110,32,110,111,116,32,112,101,114,109,105,116,116,101,100,0,78,111,32,115,117,99,104,32,102,105,108,101,32,111,114,32,100,105,114,101,99,116,111,114,121,0,78,111,32,115,117,99,104,32,112,114,111,99,101,115,115,0,70,105,108,101,32,101,120,105,115,116,115,0,86,97,108,117,101,32,116,111,111,32,108,97,114,103,101,32,102,111,114,32,100,97,116,97,32,116,121,112,101,0,78,111,32,115,112,97,99,101,32,108,101,102,116,32,111,110,32,100,101,118,105,99,101,0,79,117,116,32,111,102,32,109,101,109,111,114,121,0,82,101,115,111,117,114,99,101,32,98,117,115,121,0,73,110,116,101,114,114,117,112,116,101,100,32,115,121,115,116,101,109,32,99,97,108,108,0,82,101,115,111,117,114,99,101,32,116,101,109,112,111,114,97,114,105,108,121,32,117,110,97,118,97,105,108,97,98,108,101,0,73,110,118,97,108,105,100,32,115,101,101,107,0,67,114,111,115,115,45,100,101,118,105,99,101,32,108,105,110,107,0,82,101,97,100,45,111,110,108,121,32,102,105,108,101,32,115,121,115,116,101,109,0,68,105,114,101,99,116,111,114,121,32,110,111,116,32,101,109,112,116,121,0,67,111,110,110,101,99,116,105,111,110,32,114,101,115,101,116,32,98,121,32,112,101,101,114,0,79,112,101,114,97,116,105,111,110,32,116,105,109,101,100,32,111,117,116,0,67,111,110,110,101,99,116,105,111,110,32,114,101,102,117,115,101,100,0,72,111,115,116,32,105,115,32,100,111,119,110,0,72,111,115,116,32,105,115,32,117,110,114,101,97,99,104,97,98,108,101,0,65,100,100,114,101,115,115,32,105,110,32,117,115,101,0,66,114,111,107,101,110,32,112,105,112,101,0,73,47,79,32,101,114,114,111,114,0,78,111,32,115,117,99,104,32,100,101,118,105,99,101,32,111,114,32,97,100,100,114,101,115,115,0,66,108,111,99,107,32,100,101,118,105,99,101,32,114,101,113,117,105,114,101,100,0,78,111,32,115,117,99,104,32,100,101,118,105,99,101,0,78,111,116,32,97,32,100,105,114,101,99,116,111,114,121,0,73,115,32,97,32,100,105,114,101,99,116,111,114,121,0,84,101,120,116,32,102,105,108,101,32,98,117,115,121,0,69,120,101,99,32,102,111,114,109,97,116,32,101,114,114,111,114,0,73,110,118,97,108,105,100,32,97,114,103,117,109,101,110,116,0,65,114,103,117,109,101,110,116,32,108,105,115,116,32,116,111,111,32,108,111,110,103,0,83,121,109,98,111,108,105,99,32,108,105,110,107,32,108,111,111,112,0,70,105,108,101,110,97,109,101,32,116,111,111,32,108,111,110,103,0,84,111,111,32,109,97,110,121,32,111,112,101,110,32,102,105,108,101,115,32,105,110,32,115,121,115,116,101,109,0,78,111,32,102,105,108,101,32,100,101,115,99,114,105,112,116,111,114,115,32,97,118,97,105,108,97,98,108,101,0,66,97,100,32,102,105,108,101,32,100,101,115,99,114,105,112,116,111,114,0,78,111,32,99,104,105,108,100,32,112,114,111,99,101,115,115,0,66,97,100,32,97,100,100,114,101,115,115,0,70,105,108,101,32,116,111,111,32,108,97,114,103,101,0,84,111,111,32,109,97,110,121,32,108,105,110,107,115,0,78,111,32,108,111,99,107,115,32,97,118,97,105,108,97,98,108,101,0,82,101,115,111,117,114,99,101,32,100,101,97,100,108,111,99,107,32,119,111,117,108,100,32,111,99,99,117,114,0,83,116,97,116,101,32,110,111,116,32,114,101,99,111,118,101,114,97,98,108,101,0,80,114,101,118,105,111,117,115,32,111,119,110,101,114,32,100,105,101,100,0,79,112,101,114,97,116,105,111,110,32,99,97,110,99,101,108,101,100,0,70,117,110,99,116,105,111,110,32,110,111,116,32,105,109,112,108,101,109,101,110,116,101,100,0,78,111,32,109,101,115,115,97,103,101,32,111,102,32,100,101,115,105,114,101,100,32,116,121,112,101,0,73,100,101,110,116,105,102,105,101,114,32,114,101,109,111,118,101,100,0,68,101,118,105,99,101,32,110,111,116,32,97,32,115,116,114,101,97,109,0,78,111,32,100,97,116,97,32,97,118,97,105,108,97,98,108,101,0,68,101,118,105,99,101,32,116,105,109,101,111,117,116,0,79,117,116,32,111,102,32,115,116,114,101,97,109,115,32,114,101,115,111,117,114,99,101,115,0,76,105,110,107,32,104,97,115,32,98,101,101,110,32,115,101,118,101,114,101,100,0,80,114,111,116,111,99,111,108,32,101,114,114,111,114,0,66,97,100,32,109,101,115,115,97,103,101,0,70,105,108,101,32,100,101,115,99,114,105,112,116,111,114,32,105,110,32,98,97,100,32,115,116,97,116,101,0,78,111,116,32,97,32,115,111,99,107,101,116,0,68,101,115,116,105,110,97,116,105,111,110,32,97,100,100,114,101,115,115,32,114,101,113,117,105,114,101,100,0,77,101,115,115,97,103,101,32,116,111,111,32,108,97,114,103,101,0,80,114,111,116,111,99,111,108,32,119,114,111,110,103,32,116,121,112,101,32,102,111,114,32,115,111,99,107,101,116,0,80,114,111,116,111,99,111,108,32,110,111,116,32,97,118,97,105,108,97,98,108,101,0,80,114,111,116,111,99,111,108,32,110,111,116,32,115,117,112,112,111,114,116,101,100,0,83,111,99,107,101,116,32,116,121,112,101,32,110,111,116,32,115,117,112,112,111,114,116,101,100,0,78,111,116,32,115,117,112,112,111,114,116,101,100,0,80,114,111,116,111,99,111,108,32,102,97,109,105,108,121,32,110,111,116,32,115,117,112,112,111,114,116,101,100,0,65,100,100,114,101,115,115,32,102,97,109,105,108,121,32,110,111,116,32,115,117,112,112,111,114,116,101,100,32,98,121,32,112,114,111,116,111,99,111,108,0,65,100,100,114,101,115,115,32,110,111,116,32,97,118,97,105,108,97,98,108,101,0,78,101,116,119,111,114,107,32,105,115,32,100,111,119,110,0,78,101,116,119,111,114,107,32,117,110,114,101,97,99,104,97,98,108,101,0,67,111,110,110,101,99,116,105,111,110,32,114,101,115,101,116,32,98,121,32,110,101,116,119,111,114,107,0,67,111,110,110,101,99,116,105,111,110,32,97,98,111,114,116,101,100,0,78,111,32,98,117,102,102,101,114,32,115,112,97,99,101,32,97,118,97,105,108,97,98,108,101,0,83,111,99,107,101,116,32,105,115,32,99,111,110,110,101,99,116,101,100,0,83,111,99,107,101,116,32,110,111,116,32,99,111,110,110,101,99,116,101,100,0,67,97,110,110,111,116,32,115,101,110,100,32,97,102,116,101,114,32,115,111,99,107,101,116,32,115,104,117,116,100,111,119,110,0,79,112,101,114,97,116,105,111,110,32,97,108,114,101,97,100,121,32,105,110,32,112,114,111,103,114,101,115,115,0,79,112,101,114,97,116,105,111,110,32,105,110,32,112,114,111,103,114,101,115,115,0,83,116,97,108,101,32,102,105,108,101,32,104,97,110,100,108,101,0,82,101,109,111,116,101,32,73,47,79,32,101,114,114,111,114,0,81,117,111,116,97,32,101,120,99,101,101,100,101,100,0,78,111,32,109,101,100,105,117,109,32,102,111,117,110,100,0,87,114,111,110,103,32,109,101,100,105,117,109,32,116,121,112,101,0,78,111,32,101,114,114,111,114,32,105,110,102,111,114,109,97,116,105,111,110,0,0],"i8",ALLOC_NONE,Runtime.GLOBAL_BASE);var tempDoublePtr=STATICTOP;STATICTOP+=16;function _atexit(t,e){__ATEXIT__.unshift({func:t,arg:e})}function ___cxa_atexit(){return _atexit.apply(null,arguments)}function _abort(){Module.abort()}function __ZN8facebook4yoga14YGNodeToStringEPNSt3__212basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEEP6YGNode14YGPrintOptionsj(){Module.printErr("missing function: _ZN8facebook4yoga14YGNodeToStringEPNSt3__212basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEEP6YGNode14YGPrintOptionsj"),abort(-1)}function __decorate(t,e,r,o){var a=arguments.length,n=a<3?e:o===null?o=Object.getOwnPropertyDescriptor(e,r):o,u;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")n=Reflect.decorate(t,e,r,o);else for(var A=t.length-1;A>=0;A--)(u=t[A])&&(n=(a<3?u(n):a>3?u(e,r,n):u(e,r))||n);return a>3&&n&&Object.defineProperty(e,r,n),n}function _defineHidden(t){return function(e,r){Object.defineProperty(e,r,{configurable:!1,enumerable:!1,value:t,writable:!0})}}var _nbind={};function __nbind_free_external(t){_nbind.externalList[t].dereference(t)}function __nbind_reference_external(t){_nbind.externalList[t].reference()}function _llvm_stackrestore(t){var e=_llvm_stacksave,r=e.LLVM_SAVEDSTACKS[t];e.LLVM_SAVEDSTACKS.splice(t,1),Runtime.stackRestore(r)}function __nbind_register_pool(t,e,r,o){_nbind.Pool.pageSize=t,_nbind.Pool.usedPtr=e/4,_nbind.Pool.rootPtr=r,_nbind.Pool.pagePtr=o/4,HEAP32[e/4]=16909060,HEAP8[e]==1&&(_nbind.bigEndian=!0),HEAP32[e/4]=0,_nbind.makeTypeKindTbl=(n={},n[1024]=_nbind.PrimitiveType,n[64]=_nbind.Int64Type,n[2048]=_nbind.BindClass,n[3072]=_nbind.BindClassPtr,n[4096]=_nbind.SharedClassPtr,n[5120]=_nbind.ArrayType,n[6144]=_nbind.ArrayType,n[7168]=_nbind.CStringType,n[9216]=_nbind.CallbackType,n[10240]=_nbind.BindType,n),_nbind.makeTypeNameTbl={Buffer:_nbind.BufferType,External:_nbind.ExternalType,Int64:_nbind.Int64Type,_nbind_new:_nbind.CreateValueType,bool:_nbind.BooleanType,"cbFunction &":_nbind.CallbackType,"const cbFunction &":_nbind.CallbackType,"const std::string &":_nbind.StringType,"std::string":_nbind.StringType},Module.toggleLightGC=_nbind.toggleLightGC,_nbind.callUpcast=Module.dynCall_ii;var a=_nbind.makeType(_nbind.constructType,{flags:2048,id:0,name:""});a.proto=Module,_nbind.BindClass.list.push(a);var n}function _emscripten_set_main_loop_timing(t,e){if(Browser.mainLoop.timingMode=t,Browser.mainLoop.timingValue=e,!Browser.mainLoop.func)return 1;if(t==0)Browser.mainLoop.scheduler=function(){var u=Math.max(0,Browser.mainLoop.tickStartTime+e-_emscripten_get_now())|0;setTimeout(Browser.mainLoop.runner,u)},Browser.mainLoop.method="timeout";else if(t==1)Browser.mainLoop.scheduler=function(){Browser.requestAnimationFrame(Browser.mainLoop.runner)},Browser.mainLoop.method="rAF";else if(t==2){if(!window.setImmediate){let n=function(u){u.source===window&&u.data===o&&(u.stopPropagation(),r.shift()())};var a=n,r=[],o="setimmediate";window.addEventListener("message",n,!0),window.setImmediate=function(A){r.push(A),ENVIRONMENT_IS_WORKER?(Module.setImmediates===void 0&&(Module.setImmediates=[]),Module.setImmediates.push(A),window.postMessage({target:o})):window.postMessage(o,"*")}}Browser.mainLoop.scheduler=function(){window.setImmediate(Browser.mainLoop.runner)},Browser.mainLoop.method="immediate"}return 0}function _emscripten_get_now(){abort()}function _emscripten_set_main_loop(t,e,r,o,a){Module.noExitRuntime=!0,assert(!Browser.mainLoop.func,"emscripten_set_main_loop: there can only be one main loop function at once: call emscripten_cancel_main_loop to cancel the previous one before setting a new one with different parameters."),Browser.mainLoop.func=t,Browser.mainLoop.arg=o;var n;typeof o<"u"?n=function(){Module.dynCall_vi(t,o)}:n=function(){Module.dynCall_v(t)};var u=Browser.mainLoop.currentlyRunningMainloop;if(Browser.mainLoop.runner=function(){if(!ABORT){if(Browser.mainLoop.queue.length>0){var p=Date.now(),h=Browser.mainLoop.queue.shift();if(h.func(h.arg),Browser.mainLoop.remainingBlockers){var E=Browser.mainLoop.remainingBlockers,I=E%1==0?E-1:Math.floor(E);h.counted?Browser.mainLoop.remainingBlockers=I:(I=I+.5,Browser.mainLoop.remainingBlockers=(8*E+I)/9)}if(console.log('main loop blocker "'+h.name+'" took '+(Date.now()-p)+" ms"),Browser.mainLoop.updateStatus(),u<Browser.mainLoop.currentlyRunningMainloop)return;setTimeout(Browser.mainLoop.runner,0);return}if(!(u<Browser.mainLoop.currentlyRunningMainloop)){if(Browser.mainLoop.currentFrameNumber=Browser.mainLoop.currentFrameNumber+1|0,Browser.mainLoop.timingMode==1&&Browser.mainLoop.timingValue>1&&Browser.mainLoop.currentFrameNumber%Browser.mainLoop.timingValue!=0){Browser.mainLoop.scheduler();return}else Browser.mainLoop.timingMode==0&&(Browser.mainLoop.tickStartTime=_emscripten_get_now());Browser.mainLoop.method==="timeout"&&Module.ctx&&(Module.printErr("Looks like you are rendering without using requestAnimationFrame for the main loop. You should use 0 for the frame rate in emscripten_set_main_loop in order to use requestAnimationFrame, as that can greatly improve your frame rates!"),Browser.mainLoop.method=""),Browser.mainLoop.runIter(n),!(u<Browser.mainLoop.currentlyRunningMainloop)&&(typeof SDL=="object"&&SDL.audio&&SDL.audio.queueNewAudioData&&SDL.audio.queueNewAudioData(),Browser.mainLoop.scheduler())}}},a||(e&&e>0?_emscripten_set_main_loop_timing(0,1e3/e):_emscripten_set_main_loop_timing(1,1),Browser.mainLoop.scheduler()),r)throw"SimulateInfiniteLoop"}var Browser={mainLoop:{scheduler:null,method:"",currentlyRunningMainloop:0,func:null,arg:0,timingMode:0,timingValue:0,currentFrameNumber:0,queue:[],pause:function(){Browser.mainLoop.scheduler=null,Browser.mainLoop.currentlyRunningMainloop++},resume:function(){Browser.mainLoop.currentlyRunningMainloop++;var t=Browser.mainLoop.timingMode,e=Browser.mainLoop.timingValue,r=Browser.mainLoop.func;Browser.mainLoop.func=null,_emscripten_set_main_loop(r,0,!1,Browser.mainLoop.arg,!0),_emscripten_set_main_loop_timing(t,e),Browser.mainLoop.scheduler()},updateStatus:function(){if(Module.setStatus){var t=Module.statusMessage||"Please wait...",e=Browser.mainLoop.remainingBlockers,r=Browser.mainLoop.expectedBlockers;e?e<r?Module.setStatus(t+" ("+(r-e)+"/"+r+")"):Module.setStatus(t):Module.setStatus("")}},runIter:function(t){if(!ABORT){if(Module.preMainLoop){var e=Module.preMainLoop();if(e===!1)return}try{t()}catch(r){if(r instanceof ExitStatus)return;throw r&&typeof r=="object"&&r.stack&&Module.printErr("exception thrown: "+[r,r.stack]),r}Module.postMainLoop&&Module.postMainLoop()}}},isFullscreen:!1,pointerLock:!1,moduleContextCreatedCallbacks:[],workers:[],init:function(){if(Module.preloadPlugins||(Module.preloadPlugins=[]),Browser.initted)return;Browser.initted=!0;try{new Blob,Browser.hasBlobConstructor=!0}catch{Browser.hasBlobConstructor=!1,console.log("warning: no blob constructor, cannot create blobs with mimetypes")}Browser.BlobBuilder=typeof MozBlobBuilder<"u"?MozBlobBuilder:typeof WebKitBlobBuilder<"u"?WebKitBlobBuilder:Browser.hasBlobConstructor?null:console.log("warning: no BlobBuilder"),Browser.URLObject=typeof window<"u"?window.URL?window.URL:window.webkitURL:void 0,!Module.noImageDecoding&&typeof Browser.URLObject>"u"&&(console.log("warning: Browser does not support creating object URLs. Built-in browser image decoding will not be available."),Module.noImageDecoding=!0);var t={};t.canHandle=function(n){return!Module.noImageDecoding&&/\.(jpg|jpeg|png|bmp)$/i.test(n)},t.handle=function(n,u,A,p){var h=null;if(Browser.hasBlobConstructor)try{h=new Blob([n],{type:Browser.getMimetype(u)}),h.size!==n.length&&(h=new Blob([new Uint8Array(n).buffer],{type:Browser.getMimetype(u)}))}catch(x){Runtime.warnOnce("Blob constructor present but fails: "+x+"; falling back to blob builder")}if(!h){var E=new Browser.BlobBuilder;E.append(new Uint8Array(n).buffer),h=E.getBlob()}var I=Browser.URLObject.createObjectURL(h),v=new Image;v.onload=function(){assert(v.complete,"Image "+u+" could not be decoded");var C=document.createElement("canvas");C.width=v.width,C.height=v.height;var R=C.getContext("2d");R.drawImage(v,0,0),Module.preloadedImages[u]=C,Browser.URLObject.revokeObjectURL(I),A&&A(n)},v.onerror=function(C){console.log("Image "+I+" could not be decoded"),p&&p()},v.src=I},Module.preloadPlugins.push(t);var e={};e.canHandle=function(n){return!Module.noAudioDecoding&&n.substr(-4)in{".ogg":1,".wav":1,".mp3":1}},e.handle=function(n,u,A,p){var h=!1;function E(R){h||(h=!0,Module.preloadedAudios[u]=R,A&&A(n))}function I(){h||(h=!0,Module.preloadedAudios[u]=new Audio,p&&p())}if(Browser.hasBlobConstructor){try{var v=new Blob([n],{type:Browser.getMimetype(u)})}catch{return I()}var x=Browser.URLObject.createObjectURL(v),C=new Audio;C.addEventListener("canplaythrough",function(){E(C)},!1),C.onerror=function(L){if(h)return;console.log("warning: browser could not fully decode audio "+u+", trying slower base64 approach");function U(J){for(var te="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",ae="=",fe="",ce=0,me=0,he=0;he<J.length;he++)for(ce=ce<<8|J[he],me+=8;me>=6;){var Be=ce>>me-6&63;me-=6,fe+=te[Be]}return me==2?(fe+=te[(ce&3)<<4],fe+=ae+ae):me==4&&(fe+=te[(ce&15)<<2],fe+=ae),fe}C.src="data:audio/x-"+u.substr(-3)+";base64,"+U(n),E(C)},C.src=x,Browser.safeSetTimeout(function(){E(C)},1e4)}else return I()},Module.preloadPlugins.push(e);function r(){Browser.pointerLock=document.pointerLockElement===Module.canvas||document.mozPointerLockElement===Module.canvas||document.webkitPointerLockElement===Module.canvas||document.msPointerLockElement===Module.canvas}var o=Module.canvas;o&&(o.requestPointerLock=o.requestPointerLock||o.mozRequestPointerLock||o.webkitRequestPointerLock||o.msRequestPointerLock||function(){},o.exitPointerLock=document.exitPointerLock||document.mozExitPointerLock||document.webkitExitPointerLock||document.msExitPointerLock||function(){},o.exitPointerLock=o.exitPointerLock.bind(document),document.addEventListener("pointerlockchange",r,!1),document.addEventListener("mozpointerlockchange",r,!1),document.addEventListener("webkitpointerlockchange",r,!1),document.addEventListener("mspointerlockchange",r,!1),Module.elementPointerLock&&o.addEventListener("click",function(a){!Browser.pointerLock&&Module.canvas.requestPointerLock&&(Module.canvas.requestPointerLock(),a.preventDefault())},!1))},createContext:function(t,e,r,o){if(e&&Module.ctx&&t==Module.canvas)return Module.ctx;var a,n;if(e){var u={antialias:!1,alpha:!1};if(o)for(var A in o)u[A]=o[A];n=GL.createContext(t,u),n&&(a=GL.getContext(n).GLctx)}else a=t.getContext("2d");return a?(r&&(e||assert(typeof GLctx>"u","cannot set in module if GLctx is used, but we are a non-GL context that would replace it"),Module.ctx=a,e&&GL.makeContextCurrent(n),Module.useWebGL=e,Browser.moduleContextCreatedCallbacks.forEach(function(p){p()}),Browser.init()),a):null},destroyContext:function(t,e,r){},fullscreenHandlersInstalled:!1,lockPointer:void 0,resizeCanvas:void 0,requestFullscreen:function(t,e,r){Browser.lockPointer=t,Browser.resizeCanvas=e,Browser.vrDevice=r,typeof Browser.lockPointer>"u"&&(Browser.lockPointer=!0),typeof Browser.resizeCanvas>"u"&&(Browser.resizeCanvas=!1),typeof Browser.vrDevice>"u"&&(Browser.vrDevice=null);var o=Module.canvas;function a(){Browser.isFullscreen=!1;var u=o.parentNode;(document.fullscreenElement||document.mozFullScreenElement||document.msFullscreenElement||document.webkitFullscreenElement||document.webkitCurrentFullScreenElement)===u?(o.exitFullscreen=document.exitFullscreen||document.cancelFullScreen||document.mozCancelFullScreen||document.msExitFullscreen||document.webkitCancelFullScreen||function(){},o.exitFullscreen=o.exitFullscreen.bind(document),Browser.lockPointer&&o.requestPointerLock(),Browser.isFullscreen=!0,Browser.resizeCanvas&&Browser.setFullscreenCanvasSize()):(u.parentNode.insertBefore(o,u),u.parentNode.removeChild(u),Browser.resizeCanvas&&Browser.setWindowedCanvasSize()),Module.onFullScreen&&Module.onFullScreen(Browser.isFullscreen),Module.onFullscreen&&Module.onFullscreen(Browser.isFullscreen),Browser.updateCanvasDimensions(o)}Browser.fullscreenHandlersInstalled||(Browser.fullscreenHandlersInstalled=!0,document.addEventListener("fullscreenchange",a,!1),document.addEventListener("mozfullscreenchange",a,!1),document.addEventListener("webkitfullscreenchange",a,!1),document.addEventListener("MSFullscreenChange",a,!1));var n=document.createElement("div");o.parentNode.insertBefore(n,o),n.appendChild(o),n.requestFullscreen=n.requestFullscreen||n.mozRequestFullScreen||n.msRequestFullscreen||(n.webkitRequestFullscreen?function(){n.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT)}:null)||(n.webkitRequestFullScreen?function(){n.webkitRequestFullScreen(Element.ALLOW_KEYBOARD_INPUT)}:null),r?n.requestFullscreen({vrDisplay:r}):n.requestFullscreen()},requestFullScreen:function(t,e,r){return Module.printErr("Browser.requestFullScreen() is deprecated. Please call Browser.requestFullscreen instead."),Browser.requestFullScreen=function(o,a,n){return Browser.requestFullscreen(o,a,n)},Browser.requestFullscreen(t,e,r)},nextRAF:0,fakeRequestAnimationFrame:function(t){var e=Date.now();if(Browser.nextRAF===0)Browser.nextRAF=e+1e3/60;else for(;e+2>=Browser.nextRAF;)Browser.nextRAF+=1e3/60;var r=Math.max(Browser.nextRAF-e,0);setTimeout(t,r)},requestAnimationFrame:function t(e){typeof window>"u"?Browser.fakeRequestAnimationFrame(e):(window.requestAnimationFrame||(window.requestAnimationFrame=window.requestAnimationFrame||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame||window.msRequestAnimationFrame||window.oRequestAnimationFrame||Browser.fakeRequestAnimationFrame),window.requestAnimationFrame(e))},safeCallback:function(t){return function(){if(!ABORT)return t.apply(null,arguments)}},allowAsyncCallbacks:!0,queuedAsyncCallbacks:[],pauseAsyncCallbacks:function(){Browser.allowAsyncCallbacks=!1},resumeAsyncCallbacks:function(){if(Browser.allowAsyncCallbacks=!0,Browser.queuedAsyncCallbacks.length>0){var t=Browser.queuedAsyncCallbacks;Browser.queuedAsyncCallbacks=[],t.forEach(function(e){e()})}},safeRequestAnimationFrame:function(t){return Browser.requestAnimationFrame(function(){ABORT||(Browser.allowAsyncCallbacks?t():Browser.queuedAsyncCallbacks.push(t))})},safeSetTimeout:function(t,e){return Module.noExitRuntime=!0,setTimeout(function(){ABORT||(Browser.allowAsyncCallbacks?t():Browser.queuedAsyncCallbacks.push(t))},e)},safeSetInterval:function(t,e){return Module.noExitRuntime=!0,setInterval(function(){ABORT||Browser.allowAsyncCallbacks&&t()},e)},getMimetype:function(t){return{jpg:"image/jpeg",jpeg:"image/jpeg",png:"image/png",bmp:"image/bmp",ogg:"audio/ogg",wav:"audio/wav",mp3:"audio/mpeg"}[t.substr(t.lastIndexOf(".")+1)]},getUserMedia:function(t){window.getUserMedia||(window.getUserMedia=navigator.getUserMedia||navigator.mozGetUserMedia),window.getUserMedia(t)},getMovementX:function(t){return t.movementX||t.mozMovementX||t.webkitMovementX||0},getMovementY:function(t){return t.movementY||t.mozMovementY||t.webkitMovementY||0},getMouseWheelDelta:function(t){var e=0;switch(t.type){case"DOMMouseScroll":e=t.detail;break;case"mousewheel":e=t.wheelDelta;break;case"wheel":e=t.deltaY;break;default:throw"unrecognized mouse wheel event: "+t.type}return e},mouseX:0,mouseY:0,mouseMovementX:0,mouseMovementY:0,touches:{},lastTouches:{},calculateMouseEvent:function(t){if(Browser.pointerLock)t.type!="mousemove"&&"mozMovementX"in t?Browser.mouseMovementX=Browser.mouseMovementY=0:(Browser.mouseMovementX=Browser.getMovementX(t),Browser.mouseMovementY=Browser.getMovementY(t)),typeof SDL<"u"?(Browser.mouseX=SDL.mouseX+Browser.mouseMovementX,Browser.mouseY=SDL.mouseY+Browser.mouseMovementY):(Browser.mouseX+=Browser.mouseMovementX,Browser.mouseY+=Browser.mouseMovementY);else{var e=Module.canvas.getBoundingClientRect(),r=Module.canvas.width,o=Module.canvas.height,a=typeof window.scrollX<"u"?window.scrollX:window.pageXOffset,n=typeof window.scrollY<"u"?window.scrollY:window.pageYOffset;if(t.type==="touchstart"||t.type==="touchend"||t.type==="touchmove"){var u=t.touch;if(u===void 0)return;var A=u.pageX-(a+e.left),p=u.pageY-(n+e.top);A=A*(r/e.width),p=p*(o/e.height);var h={x:A,y:p};if(t.type==="touchstart")Browser.lastTouches[u.identifier]=h,Browser.touches[u.identifier]=h;else if(t.type==="touchend"||t.type==="touchmove"){var E=Browser.touches[u.identifier];E||(E=h),Browser.lastTouches[u.identifier]=E,Browser.touches[u.identifier]=h}return}var I=t.pageX-(a+e.left),v=t.pageY-(n+e.top);I=I*(r/e.width),v=v*(o/e.height),Browser.mouseMovementX=I-Browser.mouseX,Browser.mouseMovementY=v-Browser.mouseY,Browser.mouseX=I,Browser.mouseY=v}},asyncLoad:function(t,e,r,o){var a=o?"":"al "+t;Module.readAsync(t,function(n){assert(n,'Loading data file "'+t+'" failed (no arrayBuffer).'),e(new Uint8Array(n)),a&&removeRunDependency(a)},function(n){if(r)r();else throw'Loading data file "'+t+'" failed.'}),a&&addRunDependency(a)},resizeListeners:[],updateResizeListeners:function(){var t=Module.canvas;Browser.resizeListeners.forEach(function(e){e(t.width,t.height)})},setCanvasSize:function(t,e,r){var o=Module.canvas;Browser.updateCanvasDimensions(o,t,e),r||Browser.updateResizeListeners()},windowedWidth:0,windowedHeight:0,setFullscreenCanvasSize:function(){if(typeof SDL<"u"){var t=HEAPU32[SDL.screen+Runtime.QUANTUM_SIZE*0>>2];t=t|8388608,HEAP32[SDL.screen+Runtime.QUANTUM_SIZE*0>>2]=t}Browser.updateResizeListeners()},setWindowedCanvasSize:function(){if(typeof SDL<"u"){var t=HEAPU32[SDL.screen+Runtime.QUANTUM_SIZE*0>>2];t=t&-8388609,HEAP32[SDL.screen+Runtime.QUANTUM_SIZE*0>>2]=t}Browser.updateResizeListeners()},updateCanvasDimensions:function(t,e,r){e&&r?(t.widthNative=e,t.heightNative=r):(e=t.widthNative,r=t.heightNative);var o=e,a=r;if(Module.forcedAspectRatio&&Module.forcedAspectRatio>0&&(o/a<Module.forcedAspectRatio?o=Math.round(a*Module.forcedAspectRatio):a=Math.round(o/Module.forcedAspectRatio)),(document.fullscreenElement||document.mozFullScreenElement||document.msFullscreenElement||document.webkitFullscreenElement||document.webkitCurrentFullScreenElement)===t.parentNode&&typeof screen<"u"){var n=Math.min(screen.width/o,screen.height/a);o=Math.round(o*n),a=Math.round(a*n)}Browser.resizeCanvas?(t.width!=o&&(t.width=o),t.height!=a&&(t.height=a),typeof t.style<"u"&&(t.style.removeProperty("width"),t.style.removeProperty("height"))):(t.width!=e&&(t.width=e),t.height!=r&&(t.height=r),typeof t.style<"u"&&(o!=e||a!=r?(t.style.setProperty("width",o+"px","important"),t.style.setProperty("height",a+"px","important")):(t.style.removeProperty("width"),t.style.removeProperty("height"))))},wgetRequests:{},nextWgetRequestHandle:0,getNextWgetRequestHandle:function(){var t=Browser.nextWgetRequestHandle;return Browser.nextWgetRequestHandle++,t}},SYSCALLS={varargs:0,get:function(t){SYSCALLS.varargs+=4;var e=HEAP32[SYSCALLS.varargs-4>>2];return e},getStr:function(){var t=Pointer_stringify(SYSCALLS.get());return t},get64:function(){var t=SYSCALLS.get(),e=SYSCALLS.get();return t>=0?assert(e===0):assert(e===-1),t},getZero:function(){assert(SYSCALLS.get()===0)}};function ___syscall6(t,e){SYSCALLS.varargs=e;try{var r=SYSCALLS.getStreamFromFD();return FS.close(r),0}catch(o){return(typeof FS>"u"||!(o instanceof FS.ErrnoError))&&abort(o),-o.errno}}function ___syscall54(t,e){SYSCALLS.varargs=e;try{return 0}catch(r){return(typeof FS>"u"||!(r instanceof FS.ErrnoError))&&abort(r),-r.errno}}function _typeModule(t){var e=[[0,1,"X"],[1,1,"const X"],[128,1,"X *"],[256,1,"X &"],[384,1,"X &&"],[512,1,"std::shared_ptr<X>"],[640,1,"std::unique_ptr<X>"],[5120,1,"std::vector<X>"],[6144,2,"std::array<X, Y>"],[9216,-1,"std::function<X (Y)>"]];function r(p,h,E,I,v,x){if(h==1){var C=I&896;(C==128||C==256||C==384)&&(p="X const")}var R;return x?R=E.replace("X",p).replace("Y",v):R=p.replace("X",E).replace("Y",v),R.replace(/([*&]) (?=[*&])/g,"$1")}function o(p,h,E,I,v){throw new Error(p+" type "+E.replace("X",h+"?")+(I?" with flag "+I:"")+" in "+v)}function a(p,h,E,I,v,x,C,R){x===void 0&&(x="X"),R===void 0&&(R=1);var L=E(p);if(L)return L;var U=I(p),J=U.placeholderFlag,te=e[J];C&&te&&(x=r(C[2],C[0],x,te[0],"?",!0));var ae;J==0&&(ae="Unbound"),J>=10&&(ae="Corrupt"),R>20&&(ae="Deeply nested"),ae&&o(ae,p,x,J,v||"?");var fe=U.paramList[0],ce=a(fe,h,E,I,v,x,te,R+1),me,he={flags:te[0],id:p,name:"",paramList:[ce]},Be=[],we="?";switch(U.placeholderFlag){case 1:me=ce.spec;break;case 2:if((ce.flags&15360)==1024&&ce.spec.ptrSize==1){he.flags=7168;break}case 3:case 6:case 5:me=ce.spec,ce.flags&15360;break;case 8:we=""+U.paramList[1],he.paramList.push(U.paramList[1]);break;case 9:for(var g=0,Ee=U.paramList[1];g<Ee.length;g++){var Se=Ee[g],le=a(Se,h,E,I,v,x,te,R+1);Be.push(le.name),he.paramList.push(le)}we=Be.join(", ");break;default:break}if(he.name=r(te[2],te[0],ce.name,ce.flags,we),me){for(var ne=0,ee=Object.keys(me);ne<ee.length;ne++){var Ie=ee[ne];he[Ie]=he[Ie]||me[Ie]}he.flags|=me.flags}return n(h,he)}function n(p,h){var E=h.flags,I=E&896,v=E&15360;return!h.name&&v==1024&&(h.ptrSize==1?h.name=(E&16?"":(E&8?"un":"")+"signed ")+"char":h.name=(E&8?"u":"")+(E&32?"float":"int")+(h.ptrSize*8+"_t")),h.ptrSize==8&&!(E&32)&&(v=64),v==2048&&(I==512||I==640?v=4096:I&&(v=3072)),p(v,h)}var u=function(){function p(h){this.id=h.id,this.name=h.name,this.flags=h.flags,this.spec=h}return p.prototype.toString=function(){return this.name},p}(),A={Type:u,getComplexType:a,makeType:n,structureList:e};return t.output=A,t.output||A}function __nbind_register_type(t,e){var r=_nbind.readAsciiString(e),o={flags:10240,id:t,name:r};_nbind.makeType(_nbind.constructType,o)}function __nbind_register_callback_signature(t,e){var r=_nbind.readTypeIdList(t,e),o=_nbind.callbackSignatureList.length;return _nbind.callbackSignatureList[o]=_nbind.makeJSCaller(r),o}function __extends(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);function o(){this.constructor=t}o.prototype=e.prototype,t.prototype=new o}function __nbind_register_class(t,e,r,o,a,n,u){var A=_nbind.readAsciiString(u),p=_nbind.readPolicyList(e),h=HEAPU32.subarray(t/4,t/4+2),E={flags:2048|(p.Value?2:0),id:h[0],name:A},I=_nbind.makeType(_nbind.constructType,E);I.ptrType=_nbind.getComplexType(h[1],_nbind.constructType,_nbind.getType,_nbind.queryType),I.destroy=_nbind.makeMethodCaller(I.ptrType,{boundID:E.id,flags:0,name:"destroy",num:0,ptr:n,title:I.name+".free",typeList:["void","uint32_t","uint32_t"]}),a&&(I.superIdList=Array.prototype.slice.call(HEAPU32.subarray(r/4,r/4+a)),I.upcastList=Array.prototype.slice.call(HEAPU32.subarray(o/4,o/4+a))),Module[I.name]=I.makeBound(p),_nbind.BindClass.list.push(I)}function _removeAccessorPrefix(t){var e=/^[Gg]et_?([A-Z]?([A-Z]?))/;return t.replace(e,function(r,o,a){return a?o:o.toLowerCase()})}function __nbind_register_function(t,e,r,o,a,n,u,A,p,h){var E=_nbind.getType(t),I=_nbind.readPolicyList(e),v=_nbind.readTypeIdList(r,o),x;if(u==5)x=[{direct:a,name:"__nbindConstructor",ptr:0,title:E.name+" constructor",typeList:["uint32_t"].concat(v.slice(1))},{direct:n,name:"__nbindValueConstructor",ptr:0,title:E.name+" value constructor",typeList:["void","uint32_t"].concat(v.slice(1))}];else{var C=_nbind.readAsciiString(A),R=(E.name&&E.name+".")+C;(u==3||u==4)&&(C=_removeAccessorPrefix(C)),x=[{boundID:t,direct:n,name:C,ptr:a,title:R,typeList:v}]}for(var L=0,U=x;L<U.length;L++){var J=U[L];J.signatureType=u,J.policyTbl=I,J.num=p,J.flags=h,E.addMethod(J)}}function _nbind_value(t,e){_nbind.typeNameTbl[t]||_nbind.throwError("Unknown value type "+t),Module.NBind.bind_value(t,e),_defineHidden(_nbind.typeNameTbl[t].proto.prototype.__nbindValueConstructor)(e.prototype,"__nbindValueConstructor")}Module._nbind_value=_nbind_value;function __nbind_get_value_object(t,e){var r=_nbind.popValue(t);if(!r.fromJS)throw new Error("Object "+r+" has no fromJS function");r.fromJS(function(){r.__nbindValueConstructor.apply(this,Array.prototype.concat.apply([e],arguments))})}function _emscripten_memcpy_big(t,e,r){return HEAPU8.set(HEAPU8.subarray(e,e+r),t),t}function __nbind_register_primitive(t,e,r){var o={flags:1024|r,id:t,ptrSize:e};_nbind.makeType(_nbind.constructType,o)}var cttz_i8=allocate([8,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,4,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,5,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,4,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,6,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,4,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,5,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,4,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,7,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,4,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,5,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,4,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,6,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,4,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,5,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0,4,0,1,0,2,0,1,0,3,0,1,0,2,0,1,0],"i8",ALLOC_STATIC);function ___setErrNo(t){return Module.___errno_location&&(HEAP32[Module.___errno_location()>>2]=t),t}function _llvm_stacksave(){var t=_llvm_stacksave;return t.LLVM_SAVEDSTACKS||(t.LLVM_SAVEDSTACKS=[]),t.LLVM_SAVEDSTACKS.push(Runtime.stackSave()),t.LLVM_SAVEDSTACKS.length-1}function ___syscall140(t,e){SYSCALLS.varargs=e;try{var r=SYSCALLS.getStreamFromFD(),o=SYSCALLS.get(),a=SYSCALLS.get(),n=SYSCALLS.get(),u=SYSCALLS.get(),A=a;return FS.llseek(r,A,u),HEAP32[n>>2]=r.position,r.getdents&&A===0&&u===0&&(r.getdents=null),0}catch(p){return(typeof FS>"u"||!(p instanceof FS.ErrnoError))&&abort(p),-p.errno}}function ___syscall146(t,e){SYSCALLS.varargs=e;try{var r=SYSCALLS.get(),o=SYSCALLS.get(),a=SYSCALLS.get(),n=0;___syscall146.buffer||(___syscall146.buffers=[null,[],[]],___syscall146.printChar=function(E,I){var v=___syscall146.buffers[E];assert(v),I===0||I===10?((E===1?Module.print:Module.printErr)(UTF8ArrayToString(v,0)),v.length=0):v.push(I)});for(var u=0;u<a;u++){for(var A=HEAP32[o+u*8>>2],p=HEAP32[o+(u*8+4)>>2],h=0;h<p;h++)___syscall146.printChar(r,HEAPU8[A+h]);n+=p}return n}catch(E){return(typeof FS>"u"||!(E instanceof FS.ErrnoError))&&abort(E),-E.errno}}function __nbind_finish(){for(var t=0,e=_nbind.BindClass.list;t<e.length;t++){var r=e[t];r.finish()}}var ___dso_handle=STATICTOP;STATICTOP+=16,function(_nbind){var typeIdTbl={};_nbind.typeNameTbl={};var Pool=function(){function t(){}return t.lalloc=function(e){e=e+7&-8;var r=HEAPU32[t.usedPtr];if(e>t.pageSize/2||e>t.pageSize-r){var o=_nbind.typeNameTbl.NBind.proto;return o.lalloc(e)}else return HEAPU32[t.usedPtr]=r+e,t.rootPtr+r},t.lreset=function(e,r){var o=HEAPU32[t.pagePtr];if(o){var a=_nbind.typeNameTbl.NBind.proto;a.lreset(e,r)}else HEAPU32[t.usedPtr]=e},t}();_nbind.Pool=Pool;function constructType(t,e){var r=t==10240?_nbind.makeTypeNameTbl[e.name]||_nbind.BindType:_nbind.makeTypeKindTbl[t],o=new r(e);return typeIdTbl[e.id]=o,_nbind.typeNameTbl[e.name]=o,o}_nbind.constructType=constructType;function getType(t){return typeIdTbl[t]}_nbind.getType=getType;function queryType(t){var e=HEAPU8[t],r=_nbind.structureList[e][1];t/=4,r<0&&(++t,r=HEAPU32[t]+1);var o=Array.prototype.slice.call(HEAPU32.subarray(t+1,t+1+r));return e==9&&(o=[o[0],o.slice(1)]),{paramList:o,placeholderFlag:e}}_nbind.queryType=queryType;function getTypes(t,e){return t.map(function(r){return typeof r=="number"?_nbind.getComplexType(r,constructType,getType,queryType,e):_nbind.typeNameTbl[r]})}_nbind.getTypes=getTypes;function readTypeIdList(t,e){return Array.prototype.slice.call(HEAPU32,t/4,t/4+e)}_nbind.readTypeIdList=readTypeIdList;function readAsciiString(t){for(var e=t;HEAPU8[e++];);return String.fromCharCode.apply("",HEAPU8.subarray(t,e-1))}_nbind.readAsciiString=readAsciiString;function readPolicyList(t){var e={};if(t)for(;;){var r=HEAPU32[t/4];if(!r)break;e[readAsciiString(r)]=!0,t+=4}return e}_nbind.readPolicyList=readPolicyList;function getDynCall(t,e){var r={float32_t:"d",float64_t:"d",int64_t:"d",uint64_t:"d",void:"v"},o=t.map(function(n){return r[n.name]||"i"}).join(""),a=Module["dynCall_"+o];if(!a)throw new Error("dynCall_"+o+" not found for "+e+"("+t.map(function(n){return n.name}).join(", ")+")");return a}_nbind.getDynCall=getDynCall;function addMethod(t,e,r,o){var a=t[e];t.hasOwnProperty(e)&&a?((a.arity||a.arity===0)&&(a=_nbind.makeOverloader(a,a.arity),t[e]=a),a.addMethod(r,o)):(r.arity=o,t[e]=r)}_nbind.addMethod=addMethod;function throwError(t){throw new Error(t)}_nbind.throwError=throwError,_nbind.bigEndian=!1,_a=_typeModule(_typeModule),_nbind.Type=_a.Type,_nbind.makeType=_a.makeType,_nbind.getComplexType=_a.getComplexType,_nbind.structureList=_a.structureList;var BindType=function(t){__extends(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.heap=HEAPU32,r.ptrSize=4,r}return e.prototype.needsWireRead=function(r){return!!this.wireRead||!!this.makeWireRead},e.prototype.needsWireWrite=function(r){return!!this.wireWrite||!!this.makeWireWrite},e}(_nbind.Type);_nbind.BindType=BindType;var PrimitiveType=function(t){__extends(e,t);function e(r){var o=t.call(this,r)||this,a=r.flags&32?{32:HEAPF32,64:HEAPF64}:r.flags&8?{8:HEAPU8,16:HEAPU16,32:HEAPU32}:{8:HEAP8,16:HEAP16,32:HEAP32};return o.heap=a[r.ptrSize*8],o.ptrSize=r.ptrSize,o}return e.prototype.needsWireWrite=function(r){return!!r&&!!r.Strict},e.prototype.makeWireWrite=function(r,o){return o&&o.Strict&&function(a){if(typeof a=="number")return a;throw new Error("Type mismatch")}},e}(BindType);_nbind.PrimitiveType=PrimitiveType;function pushCString(t,e){if(t==null){if(e&&e.Nullable)return 0;throw new Error("Type mismatch")}if(e&&e.Strict){if(typeof t!="string")throw new Error("Type mismatch")}else t=t.toString();var r=Module.lengthBytesUTF8(t)+1,o=_nbind.Pool.lalloc(r);return Module.stringToUTF8Array(t,HEAPU8,o,r),o}_nbind.pushCString=pushCString;function popCString(t){return t===0?null:Module.Pointer_stringify(t)}_nbind.popCString=popCString;var CStringType=function(t){__extends(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.wireRead=popCString,r.wireWrite=pushCString,r.readResources=[_nbind.resources.pool],r.writeResources=[_nbind.resources.pool],r}return e.prototype.makeWireWrite=function(r,o){return function(a){return pushCString(a,o)}},e}(BindType);_nbind.CStringType=CStringType;var BooleanType=function(t){__extends(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.wireRead=function(o){return!!o},r}return e.prototype.needsWireWrite=function(r){return!!r&&!!r.Strict},e.prototype.makeWireRead=function(r){return"!!("+r+")"},e.prototype.makeWireWrite=function(r,o){return o&&o.Strict&&function(a){if(typeof a=="boolean")return a;throw new Error("Type mismatch")}||r},e}(BindType);_nbind.BooleanType=BooleanType;var Wrapper=function(){function t(){}return t.prototype.persist=function(){this.__nbindState|=1},t}();_nbind.Wrapper=Wrapper;function makeBound(t,e){var r=function(o){__extends(a,o);function a(n,u,A,p){var h=o.call(this)||this;if(!(h instanceof a))return new(Function.prototype.bind.apply(a,Array.prototype.concat.apply([null],arguments)));var E=u,I=A,v=p;if(n!==_nbind.ptrMarker){var x=h.__nbindConstructor.apply(h,arguments);E=4608,v=HEAPU32[x/4],I=HEAPU32[x/4+1]}var C={configurable:!0,enumerable:!1,value:null,writable:!1},R={__nbindFlags:E,__nbindPtr:I};v&&(R.__nbindShared=v,_nbind.mark(h));for(var L=0,U=Object.keys(R);L<U.length;L++){var J=U[L];C.value=R[J],Object.defineProperty(h,J,C)}return _defineHidden(0)(h,"__nbindState"),h}return a.prototype.free=function(){e.destroy.call(this,this.__nbindShared,this.__nbindFlags),this.__nbindState|=2,disableMember(this,"__nbindShared"),disableMember(this,"__nbindPtr")},a}(Wrapper);return __decorate([_defineHidden()],r.prototype,"__nbindConstructor",void 0),__decorate([_defineHidden()],r.prototype,"__nbindValueConstructor",void 0),__decorate([_defineHidden(t)],r.prototype,"__nbindPolicies",void 0),r}_nbind.makeBound=makeBound;function disableMember(t,e){function r(){throw new Error("Accessing deleted object")}Object.defineProperty(t,e,{configurable:!1,enumerable:!1,get:r,set:r})}_nbind.ptrMarker={};var BindClass=function(t){__extends(e,t);function e(r){var o=t.call(this,r)||this;return o.wireRead=function(a){return _nbind.popValue(a,o.ptrType)},o.wireWrite=function(a){return pushPointer(a,o.ptrType,!0)},o.pendingSuperCount=0,o.ready=!1,o.methodTbl={},r.paramList?(o.classType=r.paramList[0].classType,o.proto=o.classType.proto):o.classType=o,o}return e.prototype.makeBound=function(r){var o=_nbind.makeBound(r,this);return this.proto=o,this.ptrType.proto=o,o},e.prototype.addMethod=function(r){var o=this.methodTbl[r.name]||[];o.push(r),this.methodTbl[r.name]=o},e.prototype.registerMethods=function(r,o){for(var a,n=0,u=Object.keys(r.methodTbl);n<u.length;n++)for(var A=u[n],p=r.methodTbl[A],h=0,E=p;h<E.length;h++){var I=E[h],v=void 0,x=void 0;if(v=this.proto.prototype,!(o&&I.signatureType!=1))switch(I.signatureType){case 1:v=this.proto;case 5:x=_nbind.makeCaller(I),_nbind.addMethod(v,I.name,x,I.typeList.length-1);break;case 4:a=_nbind.makeMethodCaller(r.ptrType,I);break;case 3:Object.defineProperty(v,I.name,{configurable:!0,enumerable:!1,get:_nbind.makeMethodCaller(r.ptrType,I),set:a});break;case 2:x=_nbind.makeMethodCaller(r.ptrType,I),_nbind.addMethod(v,I.name,x,I.typeList.length-1);break;default:break}}},e.prototype.registerSuperMethods=function(r,o,a){if(!a[r.name]){a[r.name]=!0;for(var n=0,u,A=0,p=r.superIdList||[];A<p.length;A++){var h=p[A],E=_nbind.getType(h);n++<o||o<0?u=-1:u=0,this.registerSuperMethods(E,u,a)}this.registerMethods(r,o<0)}},e.prototype.finish=function(){if(this.ready)return this;this.ready=!0,this.superList=(this.superIdList||[]).map(function(a){return _nbind.getType(a).finish()});var r=this.proto;if(this.superList.length){var o=function(){this.constructor=r};o.prototype=this.superList[0].proto.prototype,r.prototype=new o}return r!=Module&&(r.prototype.__nbindType=this),this.registerSuperMethods(this,1,{}),this},e.prototype.upcastStep=function(r,o){if(r==this)return o;for(var a=0;a<this.superList.length;++a){var n=this.superList[a].upcastStep(r,_nbind.callUpcast(this.upcastList[a],o));if(n)return n}return 0},e}(_nbind.BindType);BindClass.list=[],_nbind.BindClass=BindClass;function popPointer(t,e){return t?new e.proto(_nbind.ptrMarker,e.flags,t):null}_nbind.popPointer=popPointer;function pushPointer(t,e,r){if(!(t instanceof _nbind.Wrapper)){if(r)return _nbind.pushValue(t);throw new Error("Type mismatch")}var o=t.__nbindPtr,a=t.__nbindType.classType,n=e.classType;if(t instanceof e.proto)for(;a!=n;)o=_nbind.callUpcast(a.upcastList[0],o),a=a.superList[0];else if(o=a.upcastStep(n,o),!o)throw new Error("Type mismatch");return o}_nbind.pushPointer=pushPointer;function pushMutablePointer(t,e){var r=pushPointer(t,e);if(t.__nbindFlags&1)throw new Error("Passing a const value as a non-const argument");return r}var BindClassPtr=function(t){__extends(e,t);function e(r){var o=t.call(this,r)||this;o.classType=r.paramList[0].classType,o.proto=o.classType.proto;var a=r.flags&1,n=(o.flags&896)==256&&r.flags&2,u=a?pushPointer:pushMutablePointer,A=n?_nbind.popValue:popPointer;return o.makeWireWrite=function(p,h){return h.Nullable?function(E){return E?u(E,o):0}:function(E){return u(E,o)}},o.wireRead=function(p){return A(p,o)},o.wireWrite=function(p){return u(p,o)},o}return e}(_nbind.BindType);_nbind.BindClassPtr=BindClassPtr;function popShared(t,e){var r=HEAPU32[t/4],o=HEAPU32[t/4+1];return o?new e.proto(_nbind.ptrMarker,e.flags,o,r):null}_nbind.popShared=popShared;function pushShared(t,e){if(!(t instanceof e.proto))throw new Error("Type mismatch");return t.__nbindShared}function pushMutableShared(t,e){if(!(t instanceof e.proto))throw new Error("Type mismatch");if(t.__nbindFlags&1)throw new Error("Passing a const value as a non-const argument");return t.__nbindShared}var SharedClassPtr=function(t){__extends(e,t);function e(r){var o=t.call(this,r)||this;o.readResources=[_nbind.resources.pool],o.classType=r.paramList[0].classType,o.proto=o.classType.proto;var a=r.flags&1,n=a?pushShared:pushMutableShared;return o.wireRead=function(u){return popShared(u,o)},o.wireWrite=function(u){return n(u,o)},o}return e}(_nbind.BindType);_nbind.SharedClassPtr=SharedClassPtr,_nbind.externalList=[0];var firstFreeExternal=0,External=function(){function t(e){this.refCount=1,this.data=e}return t.prototype.register=function(){var e=firstFreeExternal;return e?firstFreeExternal=_nbind.externalList[e]:e=_nbind.externalList.length,_nbind.externalList[e]=this,e},t.prototype.reference=function(){++this.refCount},t.prototype.dereference=function(e){--this.refCount==0&&(this.free&&this.free(),_nbind.externalList[e]=firstFreeExternal,firstFreeExternal=e)},t}();_nbind.External=External;function popExternal(t){var e=_nbind.externalList[t];return e.dereference(t),e.data}function pushExternal(t){var e=new External(t);return e.reference(),e.register()}var ExternalType=function(t){__extends(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.wireRead=popExternal,r.wireWrite=pushExternal,r}return e}(_nbind.BindType);_nbind.ExternalType=ExternalType,_nbind.callbackSignatureList=[];var CallbackType=function(t){__extends(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.wireWrite=function(o){return typeof o!="function"&&_nbind.throwError("Type mismatch"),new _nbind.External(o).register()},r}return e}(_nbind.BindType);_nbind.CallbackType=CallbackType,_nbind.valueList=[0];var firstFreeValue=0;function pushValue(t){var e=firstFreeValue;return e?firstFreeValue=_nbind.valueList[e]:e=_nbind.valueList.length,_nbind.valueList[e]=t,e*2+1}_nbind.pushValue=pushValue;function popValue(t,e){if(t||_nbind.throwError("Value type JavaScript class is missing or not registered"),t&1){t>>=1;var r=_nbind.valueList[t];return _nbind.valueList[t]=firstFreeValue,firstFreeValue=t,r}else{if(e)return _nbind.popShared(t,e);throw new Error("Invalid value slot "+t)}}_nbind.popValue=popValue;var valueBase=18446744073709552e3;function push64(t){return typeof t=="number"?t:pushValue(t)*4096+valueBase}function pop64(t){return t<valueBase?t:popValue((t-valueBase)/4096)}var CreateValueType=function(t){__extends(e,t);function e(){return t!==null&&t.apply(this,arguments)||this}return e.prototype.makeWireWrite=function(r){return"(_nbind.pushValue(new "+r+"))"},e}(_nbind.BindType);_nbind.CreateValueType=CreateValueType;var Int64Type=function(t){__extends(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.wireWrite=push64,r.wireRead=pop64,r}return e}(_nbind.BindType);_nbind.Int64Type=Int64Type;function pushArray(t,e){if(!t)return 0;var r=t.length;if((e.size||e.size===0)&&r<e.size)throw new Error("Type mismatch");var o=e.memberType.ptrSize,a=_nbind.Pool.lalloc(4+r*o);HEAPU32[a/4]=r;var n=e.memberType.heap,u=(a+4)/o,A=e.memberType.wireWrite,p=0;if(A)for(;p<r;)n[u++]=A(t[p++]);else for(;p<r;)n[u++]=t[p++];return a}_nbind.pushArray=pushArray;function popArray(t,e){if(t===0)return null;var r=HEAPU32[t/4],o=new Array(r),a=e.memberType.heap;t=(t+4)/e.memberType.ptrSize;var n=e.memberType.wireRead,u=0;if(n)for(;u<r;)o[u++]=n(a[t++]);else for(;u<r;)o[u++]=a[t++];return o}_nbind.popArray=popArray;var ArrayType=function(t){__extends(e,t);function e(r){var o=t.call(this,r)||this;return o.wireRead=function(a){return popArray(a,o)},o.wireWrite=function(a){return pushArray(a,o)},o.readResources=[_nbind.resources.pool],o.writeResources=[_nbind.resources.pool],o.memberType=r.paramList[0],r.paramList[1]&&(o.size=r.paramList[1]),o}return e}(_nbind.BindType);_nbind.ArrayType=ArrayType;function pushString(t,e){if(t==null)if(e&&e.Nullable)t="";else throw new Error("Type mismatch");if(e&&e.Strict){if(typeof t!="string")throw new Error("Type mismatch")}else t=t.toString();var r=Module.lengthBytesUTF8(t),o=_nbind.Pool.lalloc(4+r+1);return HEAPU32[o/4]=r,Module.stringToUTF8Array(t,HEAPU8,o+4,r+1),o}_nbind.pushString=pushString;function popString(t){if(t===0)return null;var e=HEAPU32[t/4];return Module.Pointer_stringify(t+4,e)}_nbind.popString=popString;var StringType=function(t){__extends(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.wireRead=popString,r.wireWrite=pushString,r.readResources=[_nbind.resources.pool],r.writeResources=[_nbind.resources.pool],r}return e.prototype.makeWireWrite=function(r,o){return function(a){return pushString(a,o)}},e}(_nbind.BindType);_nbind.StringType=StringType;function makeArgList(t){return Array.apply(null,Array(t)).map(function(e,r){return"a"+(r+1)})}function anyNeedsWireWrite(t,e){return t.reduce(function(r,o){return r||o.needsWireWrite(e)},!1)}function anyNeedsWireRead(t,e){return t.reduce(function(r,o){return r||!!o.needsWireRead(e)},!1)}function makeWireRead(t,e,r,o){var a=t.length;return r.makeWireRead?r.makeWireRead(o,t,a):r.wireRead?(t[a]=r.wireRead,"(convertParamList["+a+"]("+o+"))"):o}function makeWireWrite(t,e,r,o){var a,n=t.length;return r.makeWireWrite?a=r.makeWireWrite(o,e,t,n):a=r.wireWrite,a?typeof a=="string"?a:(t[n]=a,"(convertParamList["+n+"]("+o+"))"):o}function buildCallerFunction(dynCall,ptrType,ptr,num,policyTbl,needsWireWrite,prefix,returnType,argTypeList,mask,err){var argList=makeArgList(argTypeList.length),convertParamList=[],callExpression=makeWireRead(convertParamList,policyTbl,returnType,"dynCall("+[prefix].concat(argList.map(function(t,e){return makeWireWrite(convertParamList,policyTbl,argTypeList[e],t)})).join(",")+")"),resourceSet=_nbind.listResources([returnType],argTypeList),sourceCode="function("+argList.join(",")+"){"+(mask?"this.__nbindFlags&mask&&err();":"")+resourceSet.makeOpen()+"var r="+callExpression+";"+resourceSet.makeClose()+"return r;}";return eval("("+sourceCode+")")}function buildJSCallerFunction(returnType,argTypeList){var argList=makeArgList(argTypeList.length),convertParamList=[],callExpression=makeWireWrite(convertParamList,null,returnType,"_nbind.externalList[num].data("+argList.map(function(t,e){return makeWireRead(convertParamList,null,argTypeList[e],t)}).join(",")+")"),resourceSet=_nbind.listResources(argTypeList,[returnType]);resourceSet.remove(_nbind.resources.pool);var sourceCode="function("+["dummy","num"].concat(argList).join(",")+"){"+resourceSet.makeOpen()+"var r="+callExpression+";"+resourceSet.makeClose()+"return r;}";return eval("("+sourceCode+")")}_nbind.buildJSCallerFunction=buildJSCallerFunction;function makeJSCaller(t){var e=t.length-1,r=_nbind.getTypes(t,"callback"),o=r[0],a=r.slice(1),n=anyNeedsWireRead(a,null),u=o.needsWireWrite(null);if(!u&&!n)switch(e){case 0:return function(A,p){return _nbind.externalList[p].data()};case 1:return function(A,p,h){return _nbind.externalList[p].data(h)};case 2:return function(A,p,h,E){return _nbind.externalList[p].data(h,E)};case 3:return function(A,p,h,E,I){return _nbind.externalList[p].data(h,E,I)};default:break}return buildJSCallerFunction(o,a)}_nbind.makeJSCaller=makeJSCaller;function makeMethodCaller(t,e){var r=e.typeList.length-1,o=e.typeList.slice(0);o.splice(1,0,"uint32_t",e.boundID);var a=_nbind.getTypes(o,e.title),n=a[0],u=a.slice(3),A=n.needsWireRead(e.policyTbl),p=anyNeedsWireWrite(u,e.policyTbl),h=e.ptr,E=e.num,I=_nbind.getDynCall(a,e.title),v=~e.flags&1;function x(){throw new Error("Calling a non-const method on a const object")}if(!A&&!p)switch(r){case 0:return function(){return this.__nbindFlags&v?x():I(h,E,_nbind.pushPointer(this,t))};case 1:return function(C){return this.__nbindFlags&v?x():I(h,E,_nbind.pushPointer(this,t),C)};case 2:return function(C,R){return this.__nbindFlags&v?x():I(h,E,_nbind.pushPointer(this,t),C,R)};case 3:return function(C,R,L){return this.__nbindFlags&v?x():I(h,E,_nbind.pushPointer(this,t),C,R,L)};default:break}return buildCallerFunction(I,t,h,E,e.policyTbl,p,"ptr,num,pushPointer(this,ptrType)",n,u,v,x)}_nbind.makeMethodCaller=makeMethodCaller;function makeCaller(t){var e=t.typeList.length-1,r=_nbind.getTypes(t.typeList,t.title),o=r[0],a=r.slice(1),n=o.needsWireRead(t.policyTbl),u=anyNeedsWireWrite(a,t.policyTbl),A=t.direct,p=t.ptr;if(t.direct&&!n&&!u){var h=_nbind.getDynCall(r,t.title);switch(e){case 0:return function(){return h(A)};case 1:return function(x){return h(A,x)};case 2:return function(x,C){return h(A,x,C)};case 3:return function(x,C,R){return h(A,x,C,R)};default:break}p=0}var E;if(p){var I=t.typeList.slice(0);I.splice(1,0,"uint32_t"),r=_nbind.getTypes(I,t.title),E="ptr,num"}else p=A,E="ptr";var v=_nbind.getDynCall(r,t.title);return buildCallerFunction(v,null,p,t.num,t.policyTbl,u,E,o,a)}_nbind.makeCaller=makeCaller;function makeOverloader(t,e){var r=[];function o(){return r[arguments.length].apply(this,arguments)}return o.addMethod=function(a,n){r[n]=a},o.addMethod(t,e),o}_nbind.makeOverloader=makeOverloader;var Resource=function(){function t(e,r){var o=this;this.makeOpen=function(){return Object.keys(o.openTbl).join("")},this.makeClose=function(){return Object.keys(o.closeTbl).join("")},this.openTbl={},this.closeTbl={},e&&(this.openTbl[e]=!0),r&&(this.closeTbl[r]=!0)}return t.prototype.add=function(e){for(var r=0,o=Object.keys(e.openTbl);r<o.length;r++){var a=o[r];this.openTbl[a]=!0}for(var n=0,u=Object.keys(e.closeTbl);n<u.length;n++){var a=u[n];this.closeTbl[a]=!0}},t.prototype.remove=function(e){for(var r=0,o=Object.keys(e.openTbl);r<o.length;r++){var a=o[r];delete this.openTbl[a]}for(var n=0,u=Object.keys(e.closeTbl);n<u.length;n++){var a=u[n];delete this.closeTbl[a]}},t}();_nbind.Resource=Resource;function listResources(t,e){for(var r=new Resource,o=0,a=t;o<a.length;o++)for(var n=a[o],u=0,A=n.readResources||[];u<A.length;u++){var p=A[u];r.add(p)}for(var h=0,E=e;h<E.length;h++)for(var n=E[h],I=0,v=n.writeResources||[];I<v.length;I++){var p=v[I];r.add(p)}return r}_nbind.listResources=listResources,_nbind.resources={pool:new Resource("var used=HEAPU32[_nbind.Pool.usedPtr],page=HEAPU32[_nbind.Pool.pagePtr];","_nbind.Pool.lreset(used,page);")};var ExternalBuffer=function(t){__extends(e,t);function e(r,o){var a=t.call(this,r)||this;return a.ptr=o,a}return e.prototype.free=function(){_free(this.ptr)},e}(_nbind.External);function getBuffer(t){return t instanceof ArrayBuffer?new Uint8Array(t):t instanceof DataView?new Uint8Array(t.buffer,t.byteOffset,t.byteLength):t}function pushBuffer(t,e){if(t==null&&e&&e.Nullable&&(t=[]),typeof t!="object")throw new Error("Type mismatch");var r=t,o=r.byteLength||r.length;if(!o&&o!==0&&r.byteLength!==0)throw new Error("Type mismatch");var a=_nbind.Pool.lalloc(8),n=_malloc(o),u=a/4;return HEAPU32[u++]=o,HEAPU32[u++]=n,HEAPU32[u++]=new ExternalBuffer(t,n).register(),HEAPU8.set(getBuffer(t),n),a}var BufferType=function(t){__extends(e,t);function e(){var r=t!==null&&t.apply(this,arguments)||this;return r.wireWrite=pushBuffer,r.readResources=[_nbind.resources.pool],r.writeResources=[_nbind.resources.pool],r}return e.prototype.makeWireWrite=function(r,o){return function(a){return pushBuffer(a,o)}},e}(_nbind.BindType);_nbind.BufferType=BufferType;function commitBuffer(t,e,r){var o=_nbind.externalList[t].data,a=Buffer;if(typeof Buffer!="function"&&(a=function(){}),!(o instanceof Array)){var n=HEAPU8.subarray(e,e+r);if(o instanceof a){var u=void 0;typeof Buffer.from=="function"&&Buffer.from.length>=3?u=Buffer.from(n):u=new Buffer(n),u.copy(o)}else getBuffer(o).set(n)}}_nbind.commitBuffer=commitBuffer;var dirtyList=[],gcTimer=0;function sweep(){for(var t=0,e=dirtyList;t<e.length;t++){var r=e[t];r.__nbindState&3||r.free()}dirtyList=[],gcTimer=0}_nbind.mark=function(t){};function toggleLightGC(t){t?_nbind.mark=function(e){dirtyList.push(e),gcTimer||(gcTimer=setTimeout(sweep,0))}:_nbind.mark=function(e){}}_nbind.toggleLightGC=toggleLightGC}(_nbind),Module.requestFullScreen=function t(e,r,o){Module.printErr("Module.requestFullScreen is deprecated. Please call Module.requestFullscreen instead."),Module.requestFullScreen=Module.requestFullscreen,Browser.requestFullScreen(e,r,o)},Module.requestFullscreen=function t(e,r,o){Browser.requestFullscreen(e,r,o)},Module.requestAnimationFrame=function t(e){Browser.requestAnimationFrame(e)},Module.setCanvasSize=function t(e,r,o){Browser.setCanvasSize(e,r,o)},Module.pauseMainLoop=function t(){Browser.mainLoop.pause()},Module.resumeMainLoop=function t(){Browser.mainLoop.resume()},Module.getUserMedia=function t(){Browser.getUserMedia()},Module.createContext=function t(e,r,o,a){return Browser.createContext(e,r,o,a)},ENVIRONMENT_IS_NODE?_emscripten_get_now=function(){var e=process.hrtime();return e[0]*1e3+e[1]/1e6}:typeof dateNow<"u"?_emscripten_get_now=dateNow:typeof self=="object"&&self.performance&&typeof self.performance.now=="function"?_emscripten_get_now=function(){return self.performance.now()}:typeof performance=="object"&&typeof performance.now=="function"?_emscripten_get_now=function(){return performance.now()}:_emscripten_get_now=Date.now,__ATEXIT__.push(function(){var t=Module._fflush;t&&t(0);var e=___syscall146.printChar;if(!!e){var r=___syscall146.buffers;r[1].length&&e(1,10),r[2].length&&e(2,10)}}),DYNAMICTOP_PTR=allocate(1,"i32",ALLOC_STATIC),STACK_BASE=STACKTOP=Runtime.alignMemory(STATICTOP),STACK_MAX=STACK_BASE+TOTAL_STACK,DYNAMIC_BASE=Runtime.alignMemory(STACK_MAX),HEAP32[DYNAMICTOP_PTR>>2]=DYNAMIC_BASE,staticSealed=!0;function invoke_viiiii(t,e,r,o,a,n){try{Module.dynCall_viiiii(t,e,r,o,a,n)}catch(u){if(typeof u!="number"&&u!=="longjmp")throw u;Module.setThrew(1,0)}}function invoke_vif(t,e,r){try{Module.dynCall_vif(t,e,r)}catch(o){if(typeof o!="number"&&o!=="longjmp")throw o;Module.setThrew(1,0)}}function invoke_vid(t,e,r){try{Module.dynCall_vid(t,e,r)}catch(o){if(typeof o!="number"&&o!=="longjmp")throw o;Module.setThrew(1,0)}}function invoke_fiff(t,e,r,o){try{return Module.dynCall_fiff(t,e,r,o)}catch(a){if(typeof a!="number"&&a!=="longjmp")throw a;Module.setThrew(1,0)}}function invoke_vi(t,e){try{Module.dynCall_vi(t,e)}catch(r){if(typeof r!="number"&&r!=="longjmp")throw r;Module.setThrew(1,0)}}function invoke_vii(t,e,r){try{Module.dynCall_vii(t,e,r)}catch(o){if(typeof o!="number"&&o!=="longjmp")throw o;Module.setThrew(1,0)}}function invoke_ii(t,e){try{return Module.dynCall_ii(t,e)}catch(r){if(typeof r!="number"&&r!=="longjmp")throw r;Module.setThrew(1,0)}}function invoke_viddi(t,e,r,o,a){try{Module.dynCall_viddi(t,e,r,o,a)}catch(n){if(typeof n!="number"&&n!=="longjmp")throw n;Module.setThrew(1,0)}}function invoke_vidd(t,e,r,o){try{Module.dynCall_vidd(t,e,r,o)}catch(a){if(typeof a!="number"&&a!=="longjmp")throw a;Module.setThrew(1,0)}}function invoke_iiii(t,e,r,o){try{return Module.dynCall_iiii(t,e,r,o)}catch(a){if(typeof a!="number"&&a!=="longjmp")throw a;Module.setThrew(1,0)}}function invoke_diii(t,e,r,o){try{return Module.dynCall_diii(t,e,r,o)}catch(a){if(typeof a!="number"&&a!=="longjmp")throw a;Module.setThrew(1,0)}}function invoke_di(t,e){try{return Module.dynCall_di(t,e)}catch(r){if(typeof r!="number"&&r!=="longjmp")throw r;Module.setThrew(1,0)}}function invoke_iid(t,e,r){try{return Module.dynCall_iid(t,e,r)}catch(o){if(typeof o!="number"&&o!=="longjmp")throw o;Module.setThrew(1,0)}}function invoke_iii(t,e,r){try{return Module.dynCall_iii(t,e,r)}catch(o){if(typeof o!="number"&&o!=="longjmp")throw o;Module.setThrew(1,0)}}function invoke_viiddi(t,e,r,o,a,n){try{Module.dynCall_viiddi(t,e,r,o,a,n)}catch(u){if(typeof u!="number"&&u!=="longjmp")throw u;Module.setThrew(1,0)}}function invoke_viiiiii(t,e,r,o,a,n,u){try{Module.dynCall_viiiiii(t,e,r,o,a,n,u)}catch(A){if(typeof A!="number"&&A!=="longjmp")throw A;Module.setThrew(1,0)}}function invoke_dii(t,e,r){try{return Module.dynCall_dii(t,e,r)}catch(o){if(typeof o!="number"&&o!=="longjmp")throw o;Module.setThrew(1,0)}}function invoke_i(t){try{return Module.dynCall_i(t)}catch(e){if(typeof e!="number"&&e!=="longjmp")throw e;Module.setThrew(1,0)}}function invoke_iiiiii(t,e,r,o,a,n){try{return Module.dynCall_iiiiii(t,e,r,o,a,n)}catch(u){if(typeof u!="number"&&u!=="longjmp")throw u;Module.setThrew(1,0)}}function invoke_viiid(t,e,r,o,a){try{Module.dynCall_viiid(t,e,r,o,a)}catch(n){if(typeof n!="number"&&n!=="longjmp")throw n;Module.setThrew(1,0)}}function invoke_viififi(t,e,r,o,a,n,u){try{Module.dynCall_viififi(t,e,r,o,a,n,u)}catch(A){if(typeof A!="number"&&A!=="longjmp")throw A;Module.setThrew(1,0)}}function invoke_viii(t,e,r,o){try{Module.dynCall_viii(t,e,r,o)}catch(a){if(typeof a!="number"&&a!=="longjmp")throw a;Module.setThrew(1,0)}}function invoke_v(t){try{Module.dynCall_v(t)}catch(e){if(typeof e!="number"&&e!=="longjmp")throw e;Module.setThrew(1,0)}}function invoke_viid(t,e,r,o){try{Module.dynCall_viid(t,e,r,o)}catch(a){if(typeof a!="number"&&a!=="longjmp")throw a;Module.setThrew(1,0)}}function invoke_idd(t,e,r){try{return Module.dynCall_idd(t,e,r)}catch(o){if(typeof o!="number"&&o!=="longjmp")throw o;Module.setThrew(1,0)}}function invoke_viiii(t,e,r,o,a){try{Module.dynCall_viiii(t,e,r,o,a)}catch(n){if(typeof n!="number"&&n!=="longjmp")throw n;Module.setThrew(1,0)}}Module.asmGlobalArg={Math,Int8Array,Int16Array,Int32Array,Uint8Array,Uint16Array,Uint32Array,Float32Array,Float64Array,NaN:NaN,Infinity:1/0},Module.asmLibraryArg={abort,assert,enlargeMemory,getTotalMemory,abortOnCannotGrowMemory,invoke_viiiii,invoke_vif,invoke_vid,invoke_fiff,invoke_vi,invoke_vii,invoke_ii,invoke_viddi,invoke_vidd,invoke_iiii,invoke_diii,invoke_di,invoke_iid,invoke_iii,invoke_viiddi,invoke_viiiiii,invoke_dii,invoke_i,invoke_iiiiii,invoke_viiid,invoke_viififi,invoke_viii,invoke_v,invoke_viid,invoke_idd,invoke_viiii,_emscripten_asm_const_iiiii,_emscripten_asm_const_iiidddddd,_emscripten_asm_const_iiiid,__nbind_reference_external,_emscripten_asm_const_iiiiiiii,_removeAccessorPrefix,_typeModule,__nbind_register_pool,__decorate,_llvm_stackrestore,___cxa_atexit,__extends,__nbind_get_value_object,__ZN8facebook4yoga14YGNodeToStringEPNSt3__212basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEEP6YGNode14YGPrintOptionsj,_emscripten_set_main_loop_timing,__nbind_register_primitive,__nbind_register_type,_emscripten_memcpy_big,__nbind_register_function,___setErrNo,__nbind_register_class,__nbind_finish,_abort,_nbind_value,_llvm_stacksave,___syscall54,_defineHidden,_emscripten_set_main_loop,_emscripten_get_now,__nbind_register_callback_signature,_emscripten_asm_const_iiiiii,__nbind_free_external,_emscripten_asm_const_iiii,_emscripten_asm_const_iiididi,___syscall6,_atexit,___syscall140,___syscall146,DYNAMICTOP_PTR,tempDoublePtr,ABORT,STACKTOP,STACK_MAX,cttz_i8,___dso_handle};var asm=function(t,e,r){var o=new t.Int8Array(r),a=new t.Int16Array(r),n=new t.Int32Array(r),u=new t.Uint8Array(r),A=new t.Uint16Array(r),p=new t.Uint32Array(r),h=new t.Float32Array(r),E=new t.Float64Array(r),I=e.DYNAMICTOP_PTR|0,v=e.tempDoublePtr|0,x=e.ABORT|0,C=e.STACKTOP|0,R=e.STACK_MAX|0,L=e.cttz_i8|0,U=e.___dso_handle|0,J=0,te=0,ae=0,fe=0,ce=t.NaN,me=t.Infinity,he=0,Be=0,we=0,g=0,Ee=0,Se=0,le=t.Math.floor,ne=t.Math.abs,ee=t.Math.sqrt,Ie=t.Math.pow,Fe=t.Math.cos,At=t.Math.sin,H=t.Math.tan,at=t.Math.acos,Re=t.Math.asin,ke=t.Math.atan,xe=t.Math.atan2,He=t.Math.exp,Te=t.Math.log,Je=t.Math.ceil,je=t.Math.imul,b=t.Math.min,w=t.Math.max,P=t.Math.clz32,y=t.Math.fround,F=e.abort,z=e.assert,X=e.enlargeMemory,Z=e.getTotalMemory,ie=e.abortOnCannotGrowMemory,Pe=e.invoke_viiiii,Ne=e.invoke_vif,ot=e.invoke_vid,dt=e.invoke_fiff,Gt=e.invoke_vi,$t=e.invoke_vii,bt=e.invoke_ii,an=e.invoke_viddi,Qr=e.invoke_vidd,mr=e.invoke_iiii,br=e.invoke_diii,Wr=e.invoke_di,Kn=e.invoke_iid,Ns=e.invoke_iii,Ti=e.invoke_viiddi,ps=e.invoke_viiiiii,io=e.invoke_dii,Pi=e.invoke_i,Ls=e.invoke_iiiiii,so=e.invoke_viiid,cc=e.invoke_viififi,cu=e.invoke_viii,lp=e.invoke_v,cp=e.invoke_viid,Os=e.invoke_idd,Dn=e.invoke_viiii,oo=e._emscripten_asm_const_iiiii,Ms=e._emscripten_asm_const_iiidddddd,ml=e._emscripten_asm_const_iiiid,yl=e.__nbind_reference_external,ao=e._emscripten_asm_const_iiiiiiii,Vn=e._removeAccessorPrefix,On=e._typeModule,Ni=e.__nbind_register_pool,Mn=e.__decorate,_i=e._llvm_stackrestore,tr=e.___cxa_atexit,Oe=e.__extends,ii=e.__nbind_get_value_object,Ma=e.__ZN8facebook4yoga14YGNodeToStringEPNSt3__212basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEEP6YGNode14YGPrintOptionsj,hr=e._emscripten_set_main_loop_timing,uc=e.__nbind_register_primitive,uu=e.__nbind_register_type,Ac=e._emscripten_memcpy_big,El=e.__nbind_register_function,DA=e.___setErrNo,Au=e.__nbind_register_class,Ce=e.__nbind_finish,Rt=e._abort,fc=e._nbind_value,Hi=e._llvm_stacksave,fu=e.___syscall54,Yt=e._defineHidden,Cl=e._emscripten_set_main_loop,SA=e._emscripten_get_now,up=e.__nbind_register_callback_signature,pc=e._emscripten_asm_const_iiiiii,PA=e.__nbind_free_external,Qn=e._emscripten_asm_const_iiii,hi=e._emscripten_asm_const_iiididi,hc=e.___syscall6,bA=e._atexit,sa=e.___syscall140,Li=e.___syscall146,_o=y(0);let Ze=y(0);function lo(s){s=s|0;var l=0;return l=C,C=C+s|0,C=C+15&-16,l|0}function gc(){return C|0}function pu(s){s=s|0,C=s}function ji(s,l){s=s|0,l=l|0,C=s,R=l}function hu(s,l){s=s|0,l=l|0,J||(J=s,te=l)}function xA(s){s=s|0,Se=s}function Ua(){return Se|0}function dc(){var s=0,l=0;Dr(8104,8,400)|0,Dr(8504,408,540)|0,s=9044,l=s+44|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));o[9088]=0,o[9089]=1,n[2273]=0,n[2274]=948,n[2275]=948,tr(17,8104,U|0)|0}function hs(s){s=s|0,pt(s+948|0)}function _t(s){return s=y(s),((Du(s)|0)&2147483647)>>>0>2139095040|0}function Fn(s,l,c){s=s|0,l=l|0,c=c|0;e:do if(n[s+(l<<3)+4>>2]|0)s=s+(l<<3)|0;else{if((l|2|0)==3&&n[s+60>>2]|0){s=s+56|0;break}switch(l|0){case 0:case 2:case 4:case 5:{if(n[s+52>>2]|0){s=s+48|0;break e}break}default:}if(n[s+68>>2]|0){s=s+64|0;break}else{s=(l|1|0)==5?948:c;break}}while(0);return s|0}function Ci(s){s=s|0;var l=0;return l=pD(1e3)|0,oa(s,(l|0)!=0,2456),n[2276]=(n[2276]|0)+1,Dr(l|0,8104,1e3)|0,o[s+2>>0]|0&&(n[l+4>>2]=2,n[l+12>>2]=4),n[l+976>>2]=s,l|0}function oa(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;d=C,C=C+16|0,f=d,l||(n[f>>2]=c,Eg(s,5,3197,f)),C=d}function co(){return Ci(956)|0}function Us(s){s=s|0;var l=0;return l=Kt(1e3)|0,aa(l,s),oa(n[s+976>>2]|0,1,2456),n[2276]=(n[2276]|0)+1,n[l+944>>2]=0,l|0}function aa(s,l){s=s|0,l=l|0;var c=0;Dr(s|0,l|0,948)|0,Rm(s+948|0,l+948|0),c=s+960|0,s=l+960|0,l=c+40|0;do n[c>>2]=n[s>>2],c=c+4|0,s=s+4|0;while((c|0)<(l|0))}function la(s){s=s|0;var l=0,c=0,f=0,d=0;if(l=s+944|0,c=n[l>>2]|0,c|0&&(Ho(c+948|0,s)|0,n[l>>2]=0),c=wi(s)|0,c|0){l=0;do n[(gs(s,l)|0)+944>>2]=0,l=l+1|0;while((l|0)!=(c|0))}c=s+948|0,f=n[c>>2]|0,d=s+952|0,l=n[d>>2]|0,(l|0)!=(f|0)&&(n[d>>2]=l+(~((l+-4-f|0)>>>2)<<2)),ds(c),hD(s),n[2276]=(n[2276]|0)+-1}function Ho(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0;f=n[s>>2]|0,k=s+4|0,c=n[k>>2]|0,m=c;e:do if((f|0)==(c|0))d=f,B=4;else for(s=f;;){if((n[s>>2]|0)==(l|0)){d=s,B=4;break e}if(s=s+4|0,(s|0)==(c|0)){s=0;break}}while(0);return(B|0)==4&&((d|0)!=(c|0)?(f=d+4|0,s=m-f|0,l=s>>2,l&&(Mw(d|0,f|0,s|0)|0,c=n[k>>2]|0),s=d+(l<<2)|0,(c|0)==(s|0)||(n[k>>2]=c+(~((c+-4-s|0)>>>2)<<2)),s=1):s=0),s|0}function wi(s){return s=s|0,(n[s+952>>2]|0)-(n[s+948>>2]|0)>>2|0}function gs(s,l){s=s|0,l=l|0;var c=0;return c=n[s+948>>2]|0,(n[s+952>>2]|0)-c>>2>>>0>l>>>0?s=n[c+(l<<2)>>2]|0:s=0,s|0}function ds(s){s=s|0;var l=0,c=0,f=0,d=0;f=C,C=C+32|0,l=f,d=n[s>>2]|0,c=(n[s+4>>2]|0)-d|0,((n[s+8>>2]|0)-d|0)>>>0>c>>>0&&(d=c>>2,Ip(l,d,d,s+8|0),Bg(s,l),_A(l)),C=f}function ms(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0;M=wi(s)|0;do if(M|0){if((n[(gs(s,0)|0)+944>>2]|0)==(s|0)){if(!(Ho(s+948|0,l)|0))break;Dr(l+400|0,8504,540)|0,n[l+944>>2]=0,Le(s);break}B=n[(n[s+976>>2]|0)+12>>2]|0,k=s+948|0,Q=(B|0)==0,c=0,m=0;do f=n[(n[k>>2]|0)+(m<<2)>>2]|0,(f|0)==(l|0)?Le(s):(d=Us(f)|0,n[(n[k>>2]|0)+(c<<2)>>2]=d,n[d+944>>2]=s,Q||TR[B&15](f,d,s,c),c=c+1|0),m=m+1|0;while((m|0)!=(M|0));if(c>>>0<M>>>0){Q=s+948|0,k=s+952|0,B=c,c=n[k>>2]|0;do m=(n[Q>>2]|0)+(B<<2)|0,f=m+4|0,d=c-f|0,l=d>>2,l&&(Mw(m|0,f|0,d|0)|0,c=n[k>>2]|0),d=c,f=m+(l<<2)|0,(d|0)!=(f|0)&&(c=d+(~((d+-4-f|0)>>>2)<<2)|0,n[k>>2]=c),B=B+1|0;while((B|0)!=(M|0))}}while(0)}function _s(s){s=s|0;var l=0,c=0,f=0,d=0;Un(s,(wi(s)|0)==0,2491),Un(s,(n[s+944>>2]|0)==0,2545),l=s+948|0,c=n[l>>2]|0,f=s+952|0,d=n[f>>2]|0,(d|0)!=(c|0)&&(n[f>>2]=d+(~((d+-4-c|0)>>>2)<<2)),ds(l),l=s+976|0,c=n[l>>2]|0,Dr(s|0,8104,1e3)|0,o[c+2>>0]|0&&(n[s+4>>2]=2,n[s+12>>2]=4),n[l>>2]=c}function Un(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;d=C,C=C+16|0,f=d,l||(n[f>>2]=c,Ao(s,5,3197,f)),C=d}function Sn(){return n[2276]|0}function ys(){var s=0;return s=pD(20)|0,We((s|0)!=0,2592),n[2277]=(n[2277]|0)+1,n[s>>2]=n[239],n[s+4>>2]=n[240],n[s+8>>2]=n[241],n[s+12>>2]=n[242],n[s+16>>2]=n[243],s|0}function We(s,l){s=s|0,l=l|0;var c=0,f=0;f=C,C=C+16|0,c=f,s||(n[c>>2]=l,Ao(0,5,3197,c)),C=f}function tt(s){s=s|0,hD(s),n[2277]=(n[2277]|0)+-1}function It(s,l){s=s|0,l=l|0;var c=0;l?(Un(s,(wi(s)|0)==0,2629),c=1):(c=0,l=0),n[s+964>>2]=l,n[s+988>>2]=c}function nr(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;f=C,C=C+16|0,m=f+8|0,d=f+4|0,B=f,n[d>>2]=l,Un(s,(n[l+944>>2]|0)==0,2709),Un(s,(n[s+964>>2]|0)==0,2763),$(s),l=s+948|0,n[B>>2]=(n[l>>2]|0)+(c<<2),n[m>>2]=n[B>>2],ye(l,m,d)|0,n[(n[d>>2]|0)+944>>2]=s,Le(s),C=f}function $(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0;if(c=wi(s)|0,c|0&&(n[(gs(s,0)|0)+944>>2]|0)!=(s|0)){f=n[(n[s+976>>2]|0)+12>>2]|0,d=s+948|0,m=(f|0)==0,l=0;do B=n[(n[d>>2]|0)+(l<<2)>>2]|0,k=Us(B)|0,n[(n[d>>2]|0)+(l<<2)>>2]=k,n[k+944>>2]=s,m||TR[f&15](B,k,s,l),l=l+1|0;while((l|0)!=(c|0))}}function ye(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0,qe=0,Me=0,Qe=0,et=0,Xe=0;et=C,C=C+64|0,G=et+52|0,k=et+48|0,se=et+28|0,qe=et+24|0,Me=et+20|0,Qe=et,f=n[s>>2]|0,m=f,l=f+((n[l>>2]|0)-m>>2<<2)|0,f=s+4|0,d=n[f>>2]|0,B=s+8|0;do if(d>>>0<(n[B>>2]|0)>>>0){if((l|0)==(d|0)){n[l>>2]=n[c>>2],n[f>>2]=(n[f>>2]|0)+4;break}HA(s,l,d,l+4|0),l>>>0<=c>>>0&&(c=(n[f>>2]|0)>>>0>c>>>0?c+4|0:c),n[l>>2]=n[c>>2]}else{f=(d-m>>2)+1|0,d=N(s)|0,d>>>0<f>>>0&&zr(s),O=n[s>>2]|0,M=(n[B>>2]|0)-O|0,m=M>>1,Ip(Qe,M>>2>>>0<d>>>1>>>0?m>>>0<f>>>0?f:m:d,l-O>>2,s+8|0),O=Qe+8|0,f=n[O>>2]|0,m=Qe+12|0,M=n[m>>2]|0,B=M,Q=f;do if((f|0)==(M|0)){if(M=Qe+4|0,f=n[M>>2]|0,Xe=n[Qe>>2]|0,d=Xe,f>>>0<=Xe>>>0){f=B-d>>1,f=(f|0)==0?1:f,Ip(se,f,f>>>2,n[Qe+16>>2]|0),n[qe>>2]=n[M>>2],n[Me>>2]=n[O>>2],n[k>>2]=n[qe>>2],n[G>>2]=n[Me>>2],Dw(se,k,G),f=n[Qe>>2]|0,n[Qe>>2]=n[se>>2],n[se>>2]=f,f=se+4|0,Xe=n[M>>2]|0,n[M>>2]=n[f>>2],n[f>>2]=Xe,f=se+8|0,Xe=n[O>>2]|0,n[O>>2]=n[f>>2],n[f>>2]=Xe,f=se+12|0,Xe=n[m>>2]|0,n[m>>2]=n[f>>2],n[f>>2]=Xe,_A(se),f=n[O>>2]|0;break}m=f,B=((m-d>>2)+1|0)/-2|0,k=f+(B<<2)|0,d=Q-m|0,m=d>>2,m&&(Mw(k|0,f|0,d|0)|0,f=n[M>>2]|0),Xe=k+(m<<2)|0,n[O>>2]=Xe,n[M>>2]=f+(B<<2),f=Xe}while(0);n[f>>2]=n[c>>2],n[O>>2]=(n[O>>2]|0)+4,l=vg(s,Qe,l)|0,_A(Qe)}while(0);return C=et,l|0}function Le(s){s=s|0;var l=0;do{if(l=s+984|0,o[l>>0]|0)break;o[l>>0]=1,h[s+504>>2]=y(ce),s=n[s+944>>2]|0}while((s|0)!=0)}function pt(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-4-f|0)>>>2)<<2)),gt(c))}function ht(s){return s=s|0,n[s+944>>2]|0}function Tt(s){s=s|0,Un(s,(n[s+964>>2]|0)!=0,2832),Le(s)}function er(s){return s=s|0,(o[s+984>>0]|0)!=0|0}function $r(s,l){s=s|0,l=l|0,QUe(s,l,400)|0&&(Dr(s|0,l|0,400)|0,Le(s))}function Gi(s){s=s|0;var l=Ze;return l=y(h[s+44>>2]),s=_t(l)|0,y(s?y(0):l)}function es(s){s=s|0;var l=Ze;return l=y(h[s+48>>2]),_t(l)|0&&(l=o[(n[s+976>>2]|0)+2>>0]|0?y(1):y(0)),y(l)}function bi(s,l){s=s|0,l=l|0,n[s+980>>2]=l}function jo(s){return s=s|0,n[s+980>>2]|0}function kA(s,l){s=s|0,l=l|0;var c=0;c=s+4|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Le(s))}function QA(s){return s=s|0,n[s+4>>2]|0}function Ap(s,l){s=s|0,l=l|0;var c=0;c=s+8|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Le(s))}function ig(s){return s=s|0,n[s+8>>2]|0}function gu(s,l){s=s|0,l=l|0;var c=0;c=s+12|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Le(s))}function sg(s){return s=s|0,n[s+12>>2]|0}function du(s,l){s=s|0,l=l|0;var c=0;c=s+16|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Le(s))}function uo(s){return s=s|0,n[s+16>>2]|0}function FA(s,l){s=s|0,l=l|0;var c=0;c=s+20|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Le(s))}function mc(s){return s=s|0,n[s+20>>2]|0}function ca(s,l){s=s|0,l=l|0;var c=0;c=s+24|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Le(s))}function og(s){return s=s|0,n[s+24>>2]|0}function yc(s,l){s=s|0,l=l|0;var c=0;c=s+28|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Le(s))}function Pm(s){return s=s|0,n[s+28>>2]|0}function ag(s,l){s=s|0,l=l|0;var c=0;c=s+32|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Le(s))}function $n(s){return s=s|0,n[s+32>>2]|0}function fp(s,l){s=s|0,l=l|0;var c=0;c=s+36|0,(n[c>>2]|0)!=(l|0)&&(n[c>>2]=l,Le(s))}function lg(s){return s=s|0,n[s+36>>2]|0}function RA(s,l){s=s|0,l=y(l);var c=0;c=s+40|0,y(h[c>>2])!=l&&(h[c>>2]=l,Le(s))}function Hs(s,l){s=s|0,l=y(l);var c=0;c=s+44|0,y(h[c>>2])!=l&&(h[c>>2]=l,Le(s))}function mu(s,l){s=s|0,l=y(l);var c=0;c=s+48|0,y(h[c>>2])!=l&&(h[c>>2]=l,Le(s))}function Ha(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=_t(l)|0,c=(m^1)&1,f=s+52|0,d=s+56|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Le(s))}function qi(s,l){s=s|0,l=y(l);var c=0,f=0;f=s+52|0,c=s+56|0,y(h[f>>2])==l&&(n[c>>2]|0)==2||(h[f>>2]=l,f=_t(l)|0,n[c>>2]=f?3:2,Le(s))}function ua(s,l){s=s|0,l=l|0;var c=0,f=0;f=l+52|0,c=n[f+4>>2]|0,l=s,n[l>>2]=n[f>>2],n[l+4>>2]=c}function yu(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0,m=0;m=_t(c)|0,f=(m^1)&1,d=s+132+(l<<3)|0,l=s+132+(l<<3)+4|0,m|y(h[d>>2])==c&&(n[l>>2]|0)==(f|0)||(h[d>>2]=c,n[l>>2]=f,Le(s))}function Es(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0,m=0;m=_t(c)|0,f=m?0:2,d=s+132+(l<<3)|0,l=s+132+(l<<3)+4|0,m|y(h[d>>2])==c&&(n[l>>2]|0)==(f|0)||(h[d>>2]=c,n[l>>2]=f,Le(s))}function Ec(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=l+132+(c<<3)|0,l=n[f+4>>2]|0,c=s,n[c>>2]=n[f>>2],n[c+4>>2]=l}function Cc(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0,m=0;m=_t(c)|0,f=(m^1)&1,d=s+60+(l<<3)|0,l=s+60+(l<<3)+4|0,m|y(h[d>>2])==c&&(n[l>>2]|0)==(f|0)||(h[d>>2]=c,n[l>>2]=f,Le(s))}function q(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0,m=0;m=_t(c)|0,f=m?0:2,d=s+60+(l<<3)|0,l=s+60+(l<<3)+4|0,m|y(h[d>>2])==c&&(n[l>>2]|0)==(f|0)||(h[d>>2]=c,n[l>>2]=f,Le(s))}function Dt(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=l+60+(c<<3)|0,l=n[f+4>>2]|0,c=s,n[c>>2]=n[f>>2],n[c+4>>2]=l}function wl(s,l){s=s|0,l=l|0;var c=0;c=s+60+(l<<3)+4|0,(n[c>>2]|0)!=3&&(h[s+60+(l<<3)>>2]=y(ce),n[c>>2]=3,Le(s))}function xi(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0,m=0;m=_t(c)|0,f=(m^1)&1,d=s+204+(l<<3)|0,l=s+204+(l<<3)+4|0,m|y(h[d>>2])==c&&(n[l>>2]|0)==(f|0)||(h[d>>2]=c,n[l>>2]=f,Le(s))}function wc(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0,m=0;m=_t(c)|0,f=m?0:2,d=s+204+(l<<3)|0,l=s+204+(l<<3)+4|0,m|y(h[d>>2])==c&&(n[l>>2]|0)==(f|0)||(h[d>>2]=c,n[l>>2]=f,Le(s))}function ct(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=l+204+(c<<3)|0,l=n[f+4>>2]|0,c=s,n[c>>2]=n[f>>2],n[c+4>>2]=l}function Eu(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0,m=0;m=_t(c)|0,f=(m^1)&1,d=s+276+(l<<3)|0,l=s+276+(l<<3)+4|0,m|y(h[d>>2])==c&&(n[l>>2]|0)==(f|0)||(h[d>>2]=c,n[l>>2]=f,Le(s))}function cg(s,l){return s=s|0,l=l|0,y(h[s+276+(l<<3)>>2])}function yw(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=_t(l)|0,c=(m^1)&1,f=s+348|0,d=s+352|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Le(s))}function TA(s,l){s=s|0,l=y(l);var c=0,f=0;f=s+348|0,c=s+352|0,y(h[f>>2])==l&&(n[c>>2]|0)==2||(h[f>>2]=l,f=_t(l)|0,n[c>>2]=f?3:2,Le(s))}function pp(s){s=s|0;var l=0;l=s+352|0,(n[l>>2]|0)!=3&&(h[s+348>>2]=y(ce),n[l>>2]=3,Le(s))}function Br(s,l){s=s|0,l=l|0;var c=0,f=0;f=l+348|0,c=n[f+4>>2]|0,l=s,n[l>>2]=n[f>>2],n[l+4>>2]=c}function Cs(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=_t(l)|0,c=(m^1)&1,f=s+356|0,d=s+360|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Le(s))}function ug(s,l){s=s|0,l=y(l);var c=0,f=0;f=s+356|0,c=s+360|0,y(h[f>>2])==l&&(n[c>>2]|0)==2||(h[f>>2]=l,f=_t(l)|0,n[c>>2]=f?3:2,Le(s))}function Ag(s){s=s|0;var l=0;l=s+360|0,(n[l>>2]|0)!=3&&(h[s+356>>2]=y(ce),n[l>>2]=3,Le(s))}function fg(s,l){s=s|0,l=l|0;var c=0,f=0;f=l+356|0,c=n[f+4>>2]|0,l=s,n[l>>2]=n[f>>2],n[l+4>>2]=c}function hp(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=_t(l)|0,c=(m^1)&1,f=s+364|0,d=s+368|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Le(s))}function Ic(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=_t(l)|0,c=m?0:2,f=s+364|0,d=s+368|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Le(s))}function Ct(s,l){s=s|0,l=l|0;var c=0,f=0;f=l+364|0,c=n[f+4>>2]|0,l=s,n[l>>2]=n[f>>2],n[l+4>>2]=c}function bm(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=_t(l)|0,c=(m^1)&1,f=s+372|0,d=s+376|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Le(s))}function pg(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=_t(l)|0,c=m?0:2,f=s+372|0,d=s+376|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Le(s))}function hg(s,l){s=s|0,l=l|0;var c=0,f=0;f=l+372|0,c=n[f+4>>2]|0,l=s,n[l>>2]=n[f>>2],n[l+4>>2]=c}function Cu(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=_t(l)|0,c=(m^1)&1,f=s+380|0,d=s+384|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Le(s))}function xm(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=_t(l)|0,c=m?0:2,f=s+380|0,d=s+384|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Le(s))}function gg(s,l){s=s|0,l=l|0;var c=0,f=0;f=l+380|0,c=n[f+4>>2]|0,l=s,n[l>>2]=n[f>>2],n[l+4>>2]=c}function wu(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=_t(l)|0,c=(m^1)&1,f=s+388|0,d=s+392|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Le(s))}function Ew(s,l){s=s|0,l=y(l);var c=0,f=0,d=0,m=0;m=_t(l)|0,c=m?0:2,f=s+388|0,d=s+392|0,m|y(h[f>>2])==l&&(n[d>>2]|0)==(c|0)||(h[f>>2]=l,n[d>>2]=c,Le(s))}function km(s,l){s=s|0,l=l|0;var c=0,f=0;f=l+388|0,c=n[f+4>>2]|0,l=s,n[l>>2]=n[f>>2],n[l+4>>2]=c}function Aa(s,l){s=s|0,l=y(l);var c=0;c=s+396|0,y(h[c>>2])!=l&&(h[c>>2]=l,Le(s))}function Bc(s){return s=s|0,y(h[s+396>>2])}function Il(s){return s=s|0,y(h[s+400>>2])}function Iu(s){return s=s|0,y(h[s+404>>2])}function dg(s){return s=s|0,y(h[s+408>>2])}function NA(s){return s=s|0,y(h[s+412>>2])}function gp(s){return s=s|0,y(h[s+416>>2])}function ja(s){return s=s|0,y(h[s+420>>2])}function mg(s,l){switch(s=s|0,l=l|0,Un(s,(l|0)<6,2918),l|0){case 0:{l=(n[s+496>>2]|0)==2?5:4;break}case 2:{l=(n[s+496>>2]|0)==2?4:5;break}default:}return y(h[s+424+(l<<2)>>2])}function dp(s,l){switch(s=s|0,l=l|0,Un(s,(l|0)<6,2918),l|0){case 0:{l=(n[s+496>>2]|0)==2?5:4;break}case 2:{l=(n[s+496>>2]|0)==2?4:5;break}default:}return y(h[s+448+(l<<2)>>2])}function Go(s,l){switch(s=s|0,l=l|0,Un(s,(l|0)<6,2918),l|0){case 0:{l=(n[s+496>>2]|0)==2?5:4;break}case 2:{l=(n[s+496>>2]|0)==2?4:5;break}default:}return y(h[s+472+(l<<2)>>2])}function ws(s,l){s=s|0,l=l|0;var c=0,f=Ze;return c=n[s+4>>2]|0,(c|0)==(n[l+4>>2]|0)?c?(f=y(h[s>>2]),s=y(ne(y(f-y(h[l>>2]))))<y(999999974e-13)):s=1:s=0,s|0}function Ii(s,l){s=y(s),l=y(l);var c=0;return _t(s)|0?c=_t(l)|0:c=y(ne(y(s-l)))<y(999999974e-13),c|0}function Qm(s,l){s=s|0,l=l|0,Fm(s,l)}function Fm(s,l){s=s|0,l=l|0;var c=0,f=0;c=C,C=C+16|0,f=c+4|0,n[f>>2]=0,n[f+4>>2]=0,n[f+8>>2]=0,Ma(f|0,s|0,l|0,0),Ao(s,3,(o[f+11>>0]|0)<0?n[f>>2]|0:f,c),e3e(f),C=c}function qo(s,l,c,f){s=y(s),l=y(l),c=c|0,f=f|0;var d=Ze;s=y(s*l),d=y(bR(s,y(1)));do if(Ii(d,y(0))|0)s=y(s-d);else{if(s=y(s-d),Ii(d,y(1))|0){s=y(s+y(1));break}if(c){s=y(s+y(1));break}f||(d>y(.5)?d=y(1):(f=Ii(d,y(.5))|0,d=y(f?1:0)),s=y(s+d))}while(0);return y(s/l)}function LA(s,l,c,f,d,m,B,k,Q,M,O,G,se){s=s|0,l=y(l),c=c|0,f=y(f),d=d|0,m=y(m),B=B|0,k=y(k),Q=y(Q),M=y(M),O=y(O),G=y(G),se=se|0;var qe=0,Me=Ze,Qe=Ze,et=Ze,Xe=Ze,lt=Ze,Ue=Ze;return Q<y(0)|M<y(0)?se=0:((se|0)!=0&&(Me=y(h[se+4>>2]),Me!=y(0))?(et=y(qo(l,Me,0,0)),Xe=y(qo(f,Me,0,0)),Qe=y(qo(m,Me,0,0)),Me=y(qo(k,Me,0,0))):(Qe=m,et=l,Me=k,Xe=f),(d|0)==(s|0)?qe=Ii(Qe,et)|0:qe=0,(B|0)==(c|0)?se=Ii(Me,Xe)|0:se=0,!qe&&(lt=y(l-O),!(mp(s,lt,Q)|0))&&!(yp(s,lt,d,Q)|0)?qe=yg(s,lt,d,m,Q)|0:qe=1,!se&&(Ue=y(f-G),!(mp(c,Ue,M)|0))&&!(yp(c,Ue,B,M)|0)?se=yg(c,Ue,B,k,M)|0:se=1,se=qe&se),se|0}function mp(s,l,c){return s=s|0,l=y(l),c=y(c),(s|0)==1?s=Ii(l,c)|0:s=0,s|0}function yp(s,l,c,f){return s=s|0,l=y(l),c=c|0,f=y(f),(s|0)==2&(c|0)==0?l>=f?s=1:s=Ii(l,f)|0:s=0,s|0}function yg(s,l,c,f,d){return s=s|0,l=y(l),c=c|0,f=y(f),d=y(d),(s|0)==2&(c|0)==2&f>l?d<=l?s=1:s=Ii(l,d)|0:s=0,s|0}function fa(s,l,c,f,d,m,B,k,Q,M,O){s=s|0,l=y(l),c=y(c),f=f|0,d=d|0,m=m|0,B=y(B),k=y(k),Q=Q|0,M=M|0,O=O|0;var G=0,se=0,qe=0,Me=0,Qe=Ze,et=Ze,Xe=0,lt=0,Ue=0,Ge=0,Lt=0,Mr=0,or=0,Xt=0,Sr=0,Nr=0,ir=0,xn=Ze,go=Ze,mo=Ze,yo=0,ya=0;ir=C,C=C+160|0,Xt=ir+152|0,or=ir+120|0,Mr=ir+104|0,Ue=ir+72|0,Me=ir+56|0,Lt=ir+8|0,lt=ir,Ge=(n[2279]|0)+1|0,n[2279]=Ge,Sr=s+984|0,(o[Sr>>0]|0)!=0&&(n[s+512>>2]|0)!=(n[2278]|0)?Xe=4:(n[s+516>>2]|0)==(f|0)?Nr=0:Xe=4,(Xe|0)==4&&(n[s+520>>2]=0,n[s+924>>2]=-1,n[s+928>>2]=-1,h[s+932>>2]=y(-1),h[s+936>>2]=y(-1),Nr=1);e:do if(n[s+964>>2]|0)if(Qe=y(ln(s,2,B)),et=y(ln(s,0,B)),G=s+916|0,mo=y(h[G>>2]),go=y(h[s+920>>2]),xn=y(h[s+932>>2]),LA(d,l,m,c,n[s+924>>2]|0,mo,n[s+928>>2]|0,go,xn,y(h[s+936>>2]),Qe,et,O)|0)Xe=22;else if(qe=n[s+520>>2]|0,!qe)Xe=21;else for(se=0;;){if(G=s+524+(se*24|0)|0,xn=y(h[G>>2]),go=y(h[s+524+(se*24|0)+4>>2]),mo=y(h[s+524+(se*24|0)+16>>2]),LA(d,l,m,c,n[s+524+(se*24|0)+8>>2]|0,xn,n[s+524+(se*24|0)+12>>2]|0,go,mo,y(h[s+524+(se*24|0)+20>>2]),Qe,et,O)|0){Xe=22;break e}if(se=se+1|0,se>>>0>=qe>>>0){Xe=21;break}}else{if(Q){if(G=s+916|0,!(Ii(y(h[G>>2]),l)|0)){Xe=21;break}if(!(Ii(y(h[s+920>>2]),c)|0)){Xe=21;break}if((n[s+924>>2]|0)!=(d|0)){Xe=21;break}G=(n[s+928>>2]|0)==(m|0)?G:0,Xe=22;break}if(qe=n[s+520>>2]|0,!qe)Xe=21;else for(se=0;;){if(G=s+524+(se*24|0)|0,Ii(y(h[G>>2]),l)|0&&Ii(y(h[s+524+(se*24|0)+4>>2]),c)|0&&(n[s+524+(se*24|0)+8>>2]|0)==(d|0)&&(n[s+524+(se*24|0)+12>>2]|0)==(m|0)){Xe=22;break e}if(se=se+1|0,se>>>0>=qe>>>0){Xe=21;break}}}while(0);do if((Xe|0)==21)o[11697]|0?(G=0,Xe=28):(G=0,Xe=31);else if((Xe|0)==22){if(se=(o[11697]|0)!=0,!((G|0)!=0&(Nr^1)))if(se){Xe=28;break}else{Xe=31;break}Me=G+16|0,n[s+908>>2]=n[Me>>2],qe=G+20|0,n[s+912>>2]=n[qe>>2],(o[11698]|0)==0|se^1||(n[lt>>2]=OA(Ge)|0,n[lt+4>>2]=Ge,Ao(s,4,2972,lt),se=n[s+972>>2]|0,se|0&&tf[se&127](s),d=Ga(d,Q)|0,m=Ga(m,Q)|0,ya=+y(h[Me>>2]),yo=+y(h[qe>>2]),n[Lt>>2]=d,n[Lt+4>>2]=m,E[Lt+8>>3]=+l,E[Lt+16>>3]=+c,E[Lt+24>>3]=ya,E[Lt+32>>3]=yo,n[Lt+40>>2]=M,Ao(s,4,2989,Lt))}while(0);return(Xe|0)==28&&(se=OA(Ge)|0,n[Me>>2]=se,n[Me+4>>2]=Ge,n[Me+8>>2]=Nr?3047:11699,Ao(s,4,3038,Me),se=n[s+972>>2]|0,se|0&&tf[se&127](s),Lt=Ga(d,Q)|0,Xe=Ga(m,Q)|0,n[Ue>>2]=Lt,n[Ue+4>>2]=Xe,E[Ue+8>>3]=+l,E[Ue+16>>3]=+c,n[Ue+24>>2]=M,Ao(s,4,3049,Ue),Xe=31),(Xe|0)==31&&(si(s,l,c,f,d,m,B,k,Q,O),o[11697]|0&&(se=n[2279]|0,Lt=OA(se)|0,n[Mr>>2]=Lt,n[Mr+4>>2]=se,n[Mr+8>>2]=Nr?3047:11699,Ao(s,4,3083,Mr),se=n[s+972>>2]|0,se|0&&tf[se&127](s),Lt=Ga(d,Q)|0,Mr=Ga(m,Q)|0,yo=+y(h[s+908>>2]),ya=+y(h[s+912>>2]),n[or>>2]=Lt,n[or+4>>2]=Mr,E[or+8>>3]=yo,E[or+16>>3]=ya,n[or+24>>2]=M,Ao(s,4,3092,or)),n[s+516>>2]=f,G||(se=s+520|0,G=n[se>>2]|0,(G|0)==16&&(o[11697]|0&&Ao(s,4,3124,Xt),n[se>>2]=0,G=0),Q?G=s+916|0:(n[se>>2]=G+1,G=s+524+(G*24|0)|0),h[G>>2]=l,h[G+4>>2]=c,n[G+8>>2]=d,n[G+12>>2]=m,n[G+16>>2]=n[s+908>>2],n[G+20>>2]=n[s+912>>2],G=0)),Q&&(n[s+416>>2]=n[s+908>>2],n[s+420>>2]=n[s+912>>2],o[s+985>>0]=1,o[Sr>>0]=0),n[2279]=(n[2279]|0)+-1,n[s+512>>2]=n[2278],C=ir,Nr|(G|0)==0|0}function ln(s,l,c){s=s|0,l=l|0,c=y(c);var f=Ze;return f=y(K(s,l,c)),y(f+y(re(s,l,c)))}function Ao(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=C,C=C+16|0,d=m,n[d>>2]=f,s?f=n[s+976>>2]|0:f=0,Cg(f,s,l,c,d),C=m}function OA(s){return s=s|0,(s>>>0>60?3201:3201+(60-s)|0)|0}function Ga(s,l){s=s|0,l=l|0;var c=0,f=0,d=0;return d=C,C=C+32|0,c=d+12|0,f=d,n[c>>2]=n[254],n[c+4>>2]=n[255],n[c+8>>2]=n[256],n[f>>2]=n[257],n[f+4>>2]=n[258],n[f+8>>2]=n[259],(s|0)>2?s=11699:s=n[(l?f:c)+(s<<2)>>2]|0,C=d,s|0}function si(s,l,c,f,d,m,B,k,Q,M){s=s|0,l=y(l),c=y(c),f=f|0,d=d|0,m=m|0,B=y(B),k=y(k),Q=Q|0,M=M|0;var O=0,G=0,se=0,qe=0,Me=Ze,Qe=Ze,et=Ze,Xe=Ze,lt=Ze,Ue=Ze,Ge=Ze,Lt=0,Mr=0,or=0,Xt=Ze,Sr=Ze,Nr=0,ir=Ze,xn=0,go=0,mo=0,yo=0,ya=0,Fp=0,Rp=0,bl=0,Tp=0,Fu=0,Ru=0,Np=0,Lp=0,Op=0,Xr=0,xl=0,Mp=0,xc=0,Up=Ze,_p=Ze,Tu=Ze,Nu=Ze,kc=Ze,Gs=0,za=0,Wo=0,kl=0,nf=0,sf=Ze,Lu=Ze,of=Ze,af=Ze,qs=Ze,vs=Ze,Ql=0,Rn=Ze,lf=Ze,Eo=Ze,Qc=Ze,Co=Ze,Fc=Ze,cf=0,uf=0,Rc=Ze,Ys=Ze,Fl=0,Af=0,ff=0,pf=0,xr=Ze,Jn=0,Ds=0,wo=0,Ws=0,Rr=0,ur=0,Rl=0,Jt=Ze,hf=0,li=0;Rl=C,C=C+16|0,Gs=Rl+12|0,za=Rl+8|0,Wo=Rl+4|0,kl=Rl,Un(s,(d|0)==0|(_t(l)|0)^1,3326),Un(s,(m|0)==0|(_t(c)|0)^1,3406),Ds=mt(s,f)|0,n[s+496>>2]=Ds,Rr=fr(2,Ds)|0,ur=fr(0,Ds)|0,h[s+440>>2]=y(K(s,Rr,B)),h[s+444>>2]=y(re(s,Rr,B)),h[s+428>>2]=y(K(s,ur,B)),h[s+436>>2]=y(re(s,ur,B)),h[s+464>>2]=y(Cr(s,Rr)),h[s+468>>2]=y(yn(s,Rr)),h[s+452>>2]=y(Cr(s,ur)),h[s+460>>2]=y(yn(s,ur)),h[s+488>>2]=y(oi(s,Rr,B)),h[s+492>>2]=y(Oi(s,Rr,B)),h[s+476>>2]=y(oi(s,ur,B)),h[s+484>>2]=y(Oi(s,ur,B));do if(n[s+964>>2]|0)Ig(s,l,c,d,m,B,k);else{if(wo=s+948|0,Ws=(n[s+952>>2]|0)-(n[wo>>2]|0)>>2,!Ws){qv(s,l,c,d,m,B,k);break}if(!Q&&Yv(s,l,c,d,m,B,k)|0)break;$(s),xl=s+508|0,o[xl>>0]=0,Rr=fr(n[s+4>>2]|0,Ds)|0,ur=ww(Rr,Ds)|0,Jn=pe(Rr)|0,Mp=n[s+8>>2]|0,Af=s+28|0,xc=(n[Af>>2]|0)!=0,Co=Jn?B:k,Rc=Jn?k:B,Up=y(Cp(s,Rr,B)),_p=y(Iw(s,Rr,B)),Me=y(Cp(s,ur,B)),Fc=y(En(s,Rr,B)),Ys=y(En(s,ur,B)),or=Jn?d:m,Fl=Jn?m:d,xr=Jn?Fc:Ys,lt=Jn?Ys:Fc,Qc=y(ln(s,2,B)),Xe=y(ln(s,0,B)),Qe=y(y(qr(s+364|0,B))-xr),et=y(y(qr(s+380|0,B))-xr),Ue=y(y(qr(s+372|0,k))-lt),Ge=y(y(qr(s+388|0,k))-lt),Tu=Jn?Qe:Ue,Nu=Jn?et:Ge,Qc=y(l-Qc),l=y(Qc-xr),_t(l)|0?xr=l:xr=y(_n(y(Tg(l,et)),Qe)),lf=y(c-Xe),l=y(lf-lt),_t(l)|0?Eo=l:Eo=y(_n(y(Tg(l,Ge)),Ue)),Qe=Jn?xr:Eo,Rn=Jn?Eo:xr;e:do if((or|0)==1)for(f=0,G=0;;){if(O=gs(s,G)|0,!f)y(rs(O))>y(0)&&y(js(O))>y(0)?f=O:f=0;else if(Tm(O)|0){qe=0;break e}if(G=G+1|0,G>>>0>=Ws>>>0){qe=f;break}}else qe=0;while(0);Lt=qe+500|0,Mr=qe+504|0,f=0,O=0,l=y(0),se=0;do{if(G=n[(n[wo>>2]|0)+(se<<2)>>2]|0,(n[G+36>>2]|0)==1)Bu(G),o[G+985>>0]=1,o[G+984>>0]=0;else{Bl(G),Q&&Ep(G,mt(G,Ds)|0,Qe,Rn,xr);do if((n[G+24>>2]|0)!=1)if((G|0)==(qe|0)){n[Lt>>2]=n[2278],h[Mr>>2]=y(0);break}else{Nm(s,G,xr,d,Eo,xr,Eo,m,Ds,M);break}else O|0&&(n[O+960>>2]=G),n[G+960>>2]=0,O=G,f=(f|0)==0?G:f;while(0);vs=y(h[G+504>>2]),l=y(l+y(vs+y(ln(G,Rr,xr))))}se=se+1|0}while((se|0)!=(Ws|0));for(mo=l>Qe,Ql=xc&((or|0)==2&mo)?1:or,xn=(Fl|0)==1,ya=xn&(Q^1),Fp=(Ql|0)==1,Rp=(Ql|0)==2,bl=976+(Rr<<2)|0,Tp=(Fl|2|0)==2,Op=xn&(xc^1),Fu=1040+(ur<<2)|0,Ru=1040+(Rr<<2)|0,Np=976+(ur<<2)|0,Lp=(Fl|0)!=1,mo=xc&((or|0)!=0&mo),go=s+976|0,xn=xn^1,l=Qe,Nr=0,yo=0,vs=y(0),kc=y(0);;){e:do if(Nr>>>0<Ws>>>0)for(Mr=n[wo>>2]|0,se=0,Ge=y(0),Ue=y(0),et=y(0),Qe=y(0),G=0,O=0,qe=Nr;;){if(Lt=n[Mr+(qe<<2)>>2]|0,(n[Lt+36>>2]|0)!=1&&(n[Lt+940>>2]=yo,(n[Lt+24>>2]|0)!=1)){if(Xe=y(ln(Lt,Rr,xr)),Xr=n[bl>>2]|0,c=y(qr(Lt+380+(Xr<<3)|0,Co)),lt=y(h[Lt+504>>2]),c=y(Tg(c,lt)),c=y(_n(y(qr(Lt+364+(Xr<<3)|0,Co)),c)),xc&(se|0)!=0&y(Xe+y(Ue+c))>l){m=se,Xe=Ge,or=qe;break e}Xe=y(Xe+c),c=y(Ue+Xe),Xe=y(Ge+Xe),Tm(Lt)|0&&(et=y(et+y(rs(Lt))),Qe=y(Qe-y(lt*y(js(Lt))))),O|0&&(n[O+960>>2]=Lt),n[Lt+960>>2]=0,se=se+1|0,O=Lt,G=(G|0)==0?Lt:G}else Xe=Ge,c=Ue;if(qe=qe+1|0,qe>>>0<Ws>>>0)Ge=Xe,Ue=c;else{m=se,or=qe;break}}else m=0,Xe=y(0),et=y(0),Qe=y(0),G=0,or=Nr;while(0);Xr=et>y(0)&et<y(1),Xt=Xr?y(1):et,Xr=Qe>y(0)&Qe<y(1),Ge=Xr?y(1):Qe;do if(Fp)Xr=51;else if(Xe<Tu&((_t(Tu)|0)^1))l=Tu,Xr=51;else if(Xe>Nu&((_t(Nu)|0)^1))l=Nu,Xr=51;else if(o[(n[go>>2]|0)+3>>0]|0)Xr=51;else{if(Xt!=y(0)&&y(rs(s))!=y(0)){Xr=53;break}l=Xe,Xr=53}while(0);if((Xr|0)==51&&(Xr=0,_t(l)|0?Xr=53:(Sr=y(l-Xe),ir=l)),(Xr|0)==53&&(Xr=0,Xe<y(0)?(Sr=y(-Xe),ir=l):(Sr=y(0),ir=l)),!ya&&(nf=(G|0)==0,!nf)){se=n[bl>>2]|0,qe=Sr<y(0),lt=y(Sr/Ge),Lt=Sr>y(0),Ue=y(Sr/Xt),et=y(0),Xe=y(0),l=y(0),O=G;do c=y(qr(O+380+(se<<3)|0,Co)),Qe=y(qr(O+364+(se<<3)|0,Co)),Qe=y(Tg(c,y(_n(Qe,y(h[O+504>>2]))))),qe?(c=y(Qe*y(js(O))),c!=y(-0)&&(Jt=y(Qe-y(lt*c)),sf=y(Bi(O,Rr,Jt,ir,xr)),Jt!=sf)&&(et=y(et-y(sf-Qe)),l=y(l+c))):Lt&&(Lu=y(rs(O)),Lu!=y(0))&&(Jt=y(Qe+y(Ue*Lu)),of=y(Bi(O,Rr,Jt,ir,xr)),Jt!=of)&&(et=y(et-y(of-Qe)),Xe=y(Xe-Lu)),O=n[O+960>>2]|0;while((O|0)!=0);if(l=y(Ge+l),Qe=y(Sr+et),nf)l=y(0);else{lt=y(Xt+Xe),qe=n[bl>>2]|0,Lt=Qe<y(0),Mr=l==y(0),Ue=y(Qe/l),se=Qe>y(0),lt=y(Qe/lt),l=y(0);do{Jt=y(qr(G+380+(qe<<3)|0,Co)),et=y(qr(G+364+(qe<<3)|0,Co)),et=y(Tg(Jt,y(_n(et,y(h[G+504>>2]))))),Lt?(Jt=y(et*y(js(G))),Qe=y(-Jt),Jt!=y(-0)?(Jt=y(Ue*Qe),Qe=y(Bi(G,Rr,y(et+(Mr?Qe:Jt)),ir,xr))):Qe=et):se&&(af=y(rs(G)),af!=y(0))?Qe=y(Bi(G,Rr,y(et+y(lt*af)),ir,xr)):Qe=et,l=y(l-y(Qe-et)),Xe=y(ln(G,Rr,xr)),c=y(ln(G,ur,xr)),Qe=y(Qe+Xe),h[za>>2]=Qe,n[kl>>2]=1,et=y(h[G+396>>2]);e:do if(_t(et)|0){O=_t(Rn)|0;do if(!O){if(mo|(ts(G,ur,Rn)|0|xn)||(ha(s,G)|0)!=4||(n[(vl(G,ur)|0)+4>>2]|0)==3||(n[(Sc(G,ur)|0)+4>>2]|0)==3)break;h[Gs>>2]=Rn,n[Wo>>2]=1;break e}while(0);if(ts(G,ur,Rn)|0){O=n[G+992+(n[Np>>2]<<2)>>2]|0,Jt=y(c+y(qr(O,Rn))),h[Gs>>2]=Jt,O=Lp&(n[O+4>>2]|0)==2,n[Wo>>2]=((_t(Jt)|0|O)^1)&1;break}else{h[Gs>>2]=Rn,n[Wo>>2]=O?0:2;break}}else Jt=y(Qe-Xe),Xt=y(Jt/et),Jt=y(et*Jt),n[Wo>>2]=1,h[Gs>>2]=y(c+(Jn?Xt:Jt));while(0);yr(G,Rr,ir,xr,kl,za),yr(G,ur,Rn,xr,Wo,Gs);do if(!(ts(G,ur,Rn)|0)&&(ha(s,G)|0)==4){if((n[(vl(G,ur)|0)+4>>2]|0)==3){O=0;break}O=(n[(Sc(G,ur)|0)+4>>2]|0)!=3}else O=0;while(0);Jt=y(h[za>>2]),Xt=y(h[Gs>>2]),hf=n[kl>>2]|0,li=n[Wo>>2]|0,fa(G,Jn?Jt:Xt,Jn?Xt:Jt,Ds,Jn?hf:li,Jn?li:hf,xr,Eo,Q&(O^1),3488,M)|0,o[xl>>0]=o[xl>>0]|o[G+508>>0],G=n[G+960>>2]|0}while((G|0)!=0)}}else l=y(0);if(l=y(Sr+l),li=l<y(0)&1,o[xl>>0]=li|u[xl>>0],Rp&l>y(0)?(O=n[bl>>2]|0,(n[s+364+(O<<3)+4>>2]|0)!=0&&(qs=y(qr(s+364+(O<<3)|0,Co)),qs>=y(0))?Qe=y(_n(y(0),y(qs-y(ir-l)))):Qe=y(0)):Qe=l,Lt=Nr>>>0<or>>>0,Lt){qe=n[wo>>2]|0,se=Nr,O=0;do G=n[qe+(se<<2)>>2]|0,n[G+24>>2]|0||(O=((n[(vl(G,Rr)|0)+4>>2]|0)==3&1)+O|0,O=O+((n[(Sc(G,Rr)|0)+4>>2]|0)==3&1)|0),se=se+1|0;while((se|0)!=(or|0));O?(Xe=y(0),c=y(0)):Xr=101}else Xr=101;e:do if((Xr|0)==101)switch(Xr=0,Mp|0){case 1:{O=0,Xe=y(Qe*y(.5)),c=y(0);break e}case 2:{O=0,Xe=Qe,c=y(0);break e}case 3:{if(m>>>0<=1){O=0,Xe=y(0),c=y(0);break e}c=y((m+-1|0)>>>0),O=0,Xe=y(0),c=y(y(_n(Qe,y(0)))/c);break e}case 5:{c=y(Qe/y((m+1|0)>>>0)),O=0,Xe=c;break e}case 4:{c=y(Qe/y(m>>>0)),O=0,Xe=y(c*y(.5));break e}default:{O=0,Xe=y(0),c=y(0);break e}}while(0);if(l=y(Up+Xe),Lt){et=y(Qe/y(O|0)),se=n[wo>>2]|0,G=Nr,Qe=y(0);do{O=n[se+(G<<2)>>2]|0;e:do if((n[O+36>>2]|0)!=1){switch(n[O+24>>2]|0){case 1:{if(gi(O,Rr)|0){if(!Q)break e;Jt=y(Or(O,Rr,ir)),Jt=y(Jt+y(Cr(s,Rr))),Jt=y(Jt+y(K(O,Rr,xr))),h[O+400+(n[Ru>>2]<<2)>>2]=Jt;break e}break}case 0:if(li=(n[(vl(O,Rr)|0)+4>>2]|0)==3,Jt=y(et+l),l=li?Jt:l,Q&&(li=O+400+(n[Ru>>2]<<2)|0,h[li>>2]=y(l+y(h[li>>2]))),li=(n[(Sc(O,Rr)|0)+4>>2]|0)==3,Jt=y(et+l),l=li?Jt:l,ya){Jt=y(c+y(ln(O,Rr,xr))),Qe=Rn,l=y(l+y(Jt+y(h[O+504>>2])));break e}else{l=y(l+y(c+y(ns(O,Rr,xr)))),Qe=y(_n(Qe,y(ns(O,ur,xr))));break e}default:}Q&&(Jt=y(Xe+y(Cr(s,Rr))),li=O+400+(n[Ru>>2]<<2)|0,h[li>>2]=y(Jt+y(h[li>>2])))}while(0);G=G+1|0}while((G|0)!=(or|0))}else Qe=y(0);if(c=y(_p+l),Tp?Xe=y(y(Bi(s,ur,y(Ys+Qe),Rc,B))-Ys):Xe=Rn,et=y(y(Bi(s,ur,y(Ys+(Op?Rn:Qe)),Rc,B))-Ys),Lt&Q){G=Nr;do{se=n[(n[wo>>2]|0)+(G<<2)>>2]|0;do if((n[se+36>>2]|0)!=1){if((n[se+24>>2]|0)==1){if(gi(se,ur)|0){if(Jt=y(Or(se,ur,Rn)),Jt=y(Jt+y(Cr(s,ur))),Jt=y(Jt+y(K(se,ur,xr))),O=n[Fu>>2]|0,h[se+400+(O<<2)>>2]=Jt,!(_t(Jt)|0))break}else O=n[Fu>>2]|0;Jt=y(Cr(s,ur)),h[se+400+(O<<2)>>2]=y(Jt+y(K(se,ur,xr)));break}O=ha(s,se)|0;do if((O|0)==4){if((n[(vl(se,ur)|0)+4>>2]|0)==3){Xr=139;break}if((n[(Sc(se,ur)|0)+4>>2]|0)==3){Xr=139;break}if(ts(se,ur,Rn)|0){l=Me;break}hf=n[se+908+(n[bl>>2]<<2)>>2]|0,n[Gs>>2]=hf,l=y(h[se+396>>2]),li=_t(l)|0,Qe=(n[v>>2]=hf,y(h[v>>2])),li?l=et:(Sr=y(ln(se,ur,xr)),Jt=y(Qe/l),l=y(l*Qe),l=y(Sr+(Jn?Jt:l))),h[za>>2]=l,h[Gs>>2]=y(y(ln(se,Rr,xr))+Qe),n[Wo>>2]=1,n[kl>>2]=1,yr(se,Rr,ir,xr,Wo,Gs),yr(se,ur,Rn,xr,kl,za),l=y(h[Gs>>2]),Sr=y(h[za>>2]),Jt=Jn?l:Sr,l=Jn?Sr:l,li=((_t(Jt)|0)^1)&1,fa(se,Jt,l,Ds,li,((_t(l)|0)^1)&1,xr,Eo,1,3493,M)|0,l=Me}else Xr=139;while(0);e:do if((Xr|0)==139){Xr=0,l=y(Xe-y(ns(se,ur,xr)));do if((n[(vl(se,ur)|0)+4>>2]|0)==3){if((n[(Sc(se,ur)|0)+4>>2]|0)!=3)break;l=y(Me+y(_n(y(0),y(l*y(.5)))));break e}while(0);if((n[(Sc(se,ur)|0)+4>>2]|0)==3){l=Me;break}if((n[(vl(se,ur)|0)+4>>2]|0)==3){l=y(Me+y(_n(y(0),l)));break}switch(O|0){case 1:{l=Me;break e}case 2:{l=y(Me+y(l*y(.5)));break e}default:{l=y(Me+l);break e}}}while(0);Jt=y(vs+l),li=se+400+(n[Fu>>2]<<2)|0,h[li>>2]=y(Jt+y(h[li>>2]))}while(0);G=G+1|0}while((G|0)!=(or|0))}if(vs=y(vs+et),kc=y(_n(kc,c)),m=yo+1|0,or>>>0>=Ws>>>0)break;l=ir,Nr=or,yo=m}do if(Q){if(O=m>>>0>1,!O&&!(Yi(s)|0))break;if(!(_t(Rn)|0)){l=y(Rn-vs);e:do switch(n[s+12>>2]|0){case 3:{Me=y(Me+l),Ue=y(0);break}case 2:{Me=y(Me+y(l*y(.5))),Ue=y(0);break}case 4:{Rn>vs?Ue=y(l/y(m>>>0)):Ue=y(0);break}case 7:if(Rn>vs){Me=y(Me+y(l/y(m<<1>>>0))),Ue=y(l/y(m>>>0)),Ue=O?Ue:y(0);break e}else{Me=y(Me+y(l*y(.5))),Ue=y(0);break e}case 6:{Ue=y(l/y(yo>>>0)),Ue=Rn>vs&O?Ue:y(0);break}default:Ue=y(0)}while(0);if(m|0)for(Lt=1040+(ur<<2)|0,Mr=976+(ur<<2)|0,qe=0,G=0;;){e:do if(G>>>0<Ws>>>0)for(Qe=y(0),et=y(0),l=y(0),se=G;;){O=n[(n[wo>>2]|0)+(se<<2)>>2]|0;do if((n[O+36>>2]|0)!=1&&(n[O+24>>2]|0)==0){if((n[O+940>>2]|0)!=(qe|0))break e;if(Lm(O,ur)|0&&(Jt=y(h[O+908+(n[Mr>>2]<<2)>>2]),l=y(_n(l,y(Jt+y(ln(O,ur,xr)))))),(ha(s,O)|0)!=5)break;qs=y(Ya(O)),qs=y(qs+y(K(O,0,xr))),Jt=y(h[O+912>>2]),Jt=y(y(Jt+y(ln(O,0,xr)))-qs),qs=y(_n(et,qs)),Jt=y(_n(Qe,Jt)),Qe=Jt,et=qs,l=y(_n(l,y(qs+Jt)))}while(0);if(O=se+1|0,O>>>0<Ws>>>0)se=O;else{se=O;break}}else et=y(0),l=y(0),se=G;while(0);if(lt=y(Ue+l),c=Me,Me=y(Me+lt),G>>>0<se>>>0){Xe=y(c+et),O=G;do{G=n[(n[wo>>2]|0)+(O<<2)>>2]|0;e:do if((n[G+36>>2]|0)!=1&&(n[G+24>>2]|0)==0)switch(ha(s,G)|0){case 1:{Jt=y(c+y(K(G,ur,xr))),h[G+400+(n[Lt>>2]<<2)>>2]=Jt;break e}case 3:{Jt=y(y(Me-y(re(G,ur,xr)))-y(h[G+908+(n[Mr>>2]<<2)>>2])),h[G+400+(n[Lt>>2]<<2)>>2]=Jt;break e}case 2:{Jt=y(c+y(y(lt-y(h[G+908+(n[Mr>>2]<<2)>>2]))*y(.5))),h[G+400+(n[Lt>>2]<<2)>>2]=Jt;break e}case 4:{if(Jt=y(c+y(K(G,ur,xr))),h[G+400+(n[Lt>>2]<<2)>>2]=Jt,ts(G,ur,Rn)|0||(Jn?(Qe=y(h[G+908>>2]),l=y(Qe+y(ln(G,Rr,xr))),et=lt):(et=y(h[G+912>>2]),et=y(et+y(ln(G,ur,xr))),l=lt,Qe=y(h[G+908>>2])),Ii(l,Qe)|0&&Ii(et,y(h[G+912>>2]))|0))break e;fa(G,l,et,Ds,1,1,xr,Eo,1,3501,M)|0;break e}case 5:{h[G+404>>2]=y(y(Xe-y(Ya(G)))+y(Or(G,0,Rn)));break e}default:break e}while(0);O=O+1|0}while((O|0)!=(se|0))}if(qe=qe+1|0,(qe|0)==(m|0))break;G=se}}}while(0);if(h[s+908>>2]=y(Bi(s,2,Qc,B,B)),h[s+912>>2]=y(Bi(s,0,lf,k,B)),(Ql|0)!=0&&(cf=n[s+32>>2]|0,uf=(Ql|0)==2,!(uf&(cf|0)!=2))?uf&(cf|0)==2&&(l=y(Fc+ir),l=y(_n(y(Tg(l,y(MA(s,Rr,kc,Co)))),Fc)),Xr=198):(l=y(Bi(s,Rr,kc,Co,B)),Xr=198),(Xr|0)==198&&(h[s+908+(n[976+(Rr<<2)>>2]<<2)>>2]=l),(Fl|0)!=0&&(ff=n[s+32>>2]|0,pf=(Fl|0)==2,!(pf&(ff|0)!=2))?pf&(ff|0)==2&&(l=y(Ys+Rn),l=y(_n(y(Tg(l,y(MA(s,ur,y(Ys+vs),Rc)))),Ys)),Xr=204):(l=y(Bi(s,ur,y(Ys+vs),Rc,B)),Xr=204),(Xr|0)==204&&(h[s+908+(n[976+(ur<<2)>>2]<<2)>>2]=l),Q){if((n[Af>>2]|0)==2){G=976+(ur<<2)|0,se=1040+(ur<<2)|0,O=0;do qe=gs(s,O)|0,n[qe+24>>2]|0||(hf=n[G>>2]|0,Jt=y(h[s+908+(hf<<2)>>2]),li=qe+400+(n[se>>2]<<2)|0,Jt=y(Jt-y(h[li>>2])),h[li>>2]=y(Jt-y(h[qe+908+(hf<<2)>>2]))),O=O+1|0;while((O|0)!=(Ws|0))}if(f|0){O=Jn?Ql:d;do Om(s,f,xr,O,Eo,Ds,M),f=n[f+960>>2]|0;while((f|0)!=0)}if(O=(Rr|2|0)==3,G=(ur|2|0)==3,O|G){f=0;do se=n[(n[wo>>2]|0)+(f<<2)>>2]|0,(n[se+36>>2]|0)!=1&&(O&&wp(s,se,Rr),G&&wp(s,se,ur)),f=f+1|0;while((f|0)!=(Ws|0))}}}while(0);C=Rl}function pa(s,l){s=s|0,l=y(l);var c=0;oa(s,l>=y(0),3147),c=l==y(0),h[s+4>>2]=c?y(0):l}function vc(s,l,c,f){s=s|0,l=y(l),c=y(c),f=f|0;var d=Ze,m=Ze,B=0,k=0,Q=0;n[2278]=(n[2278]|0)+1,Bl(s),ts(s,2,l)|0?(d=y(qr(n[s+992>>2]|0,l)),Q=1,d=y(d+y(ln(s,2,l)))):(d=y(qr(s+380|0,l)),d>=y(0)?Q=2:(Q=((_t(l)|0)^1)&1,d=l)),ts(s,0,c)|0?(m=y(qr(n[s+996>>2]|0,c)),k=1,m=y(m+y(ln(s,0,l)))):(m=y(qr(s+388|0,c)),m>=y(0)?k=2:(k=((_t(c)|0)^1)&1,m=c)),B=s+976|0,fa(s,d,m,f,Q,k,l,c,1,3189,n[B>>2]|0)|0&&(Ep(s,n[s+496>>2]|0,l,c,l),Dc(s,y(h[(n[B>>2]|0)+4>>2]),y(0),y(0)),o[11696]|0)&&Qm(s,7)}function Bl(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;k=C,C=C+32|0,B=k+24|0,m=k+16|0,f=k+8|0,d=k,c=0;do l=s+380+(c<<3)|0,(n[s+380+(c<<3)+4>>2]|0)!=0&&(Q=l,M=n[Q+4>>2]|0,O=f,n[O>>2]=n[Q>>2],n[O+4>>2]=M,O=s+364+(c<<3)|0,M=n[O+4>>2]|0,Q=d,n[Q>>2]=n[O>>2],n[Q+4>>2]=M,n[m>>2]=n[f>>2],n[m+4>>2]=n[f+4>>2],n[B>>2]=n[d>>2],n[B+4>>2]=n[d+4>>2],ws(m,B)|0)||(l=s+348+(c<<3)|0),n[s+992+(c<<2)>>2]=l,c=c+1|0;while((c|0)!=2);C=k}function ts(s,l,c){s=s|0,l=l|0,c=y(c);var f=0;switch(s=n[s+992+(n[976+(l<<2)>>2]<<2)>>2]|0,n[s+4>>2]|0){case 0:case 3:{s=0;break}case 1:{y(h[s>>2])<y(0)?s=0:f=5;break}case 2:{y(h[s>>2])<y(0)?s=0:s=(_t(c)|0)^1;break}default:f=5}return(f|0)==5&&(s=1),s|0}function qr(s,l){switch(s=s|0,l=y(l),n[s+4>>2]|0){case 2:{l=y(y(y(h[s>>2])*l)/y(100));break}case 1:{l=y(h[s>>2]);break}default:l=y(ce)}return y(l)}function Ep(s,l,c,f,d){s=s|0,l=l|0,c=y(c),f=y(f),d=y(d);var m=0,B=Ze;l=n[s+944>>2]|0?l:1,m=fr(n[s+4>>2]|0,l)|0,l=ww(m,l)|0,c=y(Mm(s,m,c)),f=y(Mm(s,l,f)),B=y(c+y(K(s,m,d))),h[s+400+(n[1040+(m<<2)>>2]<<2)>>2]=B,c=y(c+y(re(s,m,d))),h[s+400+(n[1e3+(m<<2)>>2]<<2)>>2]=c,c=y(f+y(K(s,l,d))),h[s+400+(n[1040+(l<<2)>>2]<<2)>>2]=c,d=y(f+y(re(s,l,d))),h[s+400+(n[1e3+(l<<2)>>2]<<2)>>2]=d}function Dc(s,l,c,f){s=s|0,l=y(l),c=y(c),f=y(f);var d=0,m=0,B=Ze,k=Ze,Q=0,M=0,O=Ze,G=0,se=Ze,qe=Ze,Me=Ze,Qe=Ze;if(l!=y(0)&&(d=s+400|0,Qe=y(h[d>>2]),m=s+404|0,Me=y(h[m>>2]),G=s+416|0,qe=y(h[G>>2]),M=s+420|0,B=y(h[M>>2]),se=y(Qe+c),O=y(Me+f),f=y(se+qe),k=y(O+B),Q=(n[s+988>>2]|0)==1,h[d>>2]=y(qo(Qe,l,0,Q)),h[m>>2]=y(qo(Me,l,0,Q)),c=y(bR(y(qe*l),y(1))),Ii(c,y(0))|0?m=0:m=(Ii(c,y(1))|0)^1,c=y(bR(y(B*l),y(1))),Ii(c,y(0))|0?d=0:d=(Ii(c,y(1))|0)^1,Qe=y(qo(f,l,Q&m,Q&(m^1))),h[G>>2]=y(Qe-y(qo(se,l,0,Q))),Qe=y(qo(k,l,Q&d,Q&(d^1))),h[M>>2]=y(Qe-y(qo(O,l,0,Q))),m=(n[s+952>>2]|0)-(n[s+948>>2]|0)>>2,m|0)){d=0;do Dc(gs(s,d)|0,l,se,O),d=d+1|0;while((d|0)!=(m|0))}}function Cw(s,l,c,f,d){switch(s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,c|0){case 5:case 0:{s=i7(n[489]|0,f,d)|0;break}default:s=zUe(f,d)|0}return s|0}function Eg(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;d=C,C=C+16|0,m=d,n[m>>2]=f,Cg(s,0,l,c,m),C=d}function Cg(s,l,c,f,d){if(s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,s=s|0?s:956,D7[n[s+8>>2]&1](s,l,c,f,d)|0,(c|0)==5)Rt();else return}function qa(s,l,c){s=s|0,l=l|0,c=c|0,o[s+l>>0]=c&1}function Rm(s,l){s=s|0,l=l|0;var c=0,f=0;n[s>>2]=0,n[s+4>>2]=0,n[s+8>>2]=0,c=l+4|0,f=(n[c>>2]|0)-(n[l>>2]|0)>>2,f|0&&(wg(s,f),Qt(s,n[l>>2]|0,n[c>>2]|0,f))}function wg(s,l){s=s|0,l=l|0;var c=0;if((N(s)|0)>>>0<l>>>0&&zr(s),l>>>0>1073741823)Rt();else{c=Kt(l<<2)|0,n[s+4>>2]=c,n[s>>2]=c,n[s+8>>2]=c+(l<<2);return}}function Qt(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,f=s+4|0,s=c-l|0,(s|0)>0&&(Dr(n[f>>2]|0,l|0,s|0)|0,n[f>>2]=(n[f>>2]|0)+(s>>>2<<2))}function N(s){return s=s|0,1073741823}function K(s,l,c){return s=s|0,l=l|0,c=y(c),pe(l)|0&&(n[s+96>>2]|0)!=0?s=s+92|0:s=Fn(s+60|0,n[1040+(l<<2)>>2]|0,992)|0,y(ze(s,c))}function re(s,l,c){return s=s|0,l=l|0,c=y(c),pe(l)|0&&(n[s+104>>2]|0)!=0?s=s+100|0:s=Fn(s+60|0,n[1e3+(l<<2)>>2]|0,992)|0,y(ze(s,c))}function pe(s){return s=s|0,(s|1|0)==3|0}function ze(s,l){return s=s|0,l=y(l),(n[s+4>>2]|0)==3?l=y(0):l=y(qr(s,l)),y(l)}function mt(s,l){return s=s|0,l=l|0,s=n[s>>2]|0,((s|0)==0?(l|0)>1?l:1:s)|0}function fr(s,l){s=s|0,l=l|0;var c=0;e:do if((l|0)==2){switch(s|0){case 2:{s=3;break e}case 3:break;default:{c=4;break e}}s=2}else c=4;while(0);return s|0}function Cr(s,l){s=s|0,l=l|0;var c=Ze;return pe(l)|0&&(n[s+312>>2]|0)!=0&&(c=y(h[s+308>>2]),c>=y(0))||(c=y(_n(y(h[(Fn(s+276|0,n[1040+(l<<2)>>2]|0,992)|0)>>2]),y(0)))),y(c)}function yn(s,l){s=s|0,l=l|0;var c=Ze;return pe(l)|0&&(n[s+320>>2]|0)!=0&&(c=y(h[s+316>>2]),c>=y(0))||(c=y(_n(y(h[(Fn(s+276|0,n[1e3+(l<<2)>>2]|0,992)|0)>>2]),y(0)))),y(c)}function oi(s,l,c){s=s|0,l=l|0,c=y(c);var f=Ze;return pe(l)|0&&(n[s+240>>2]|0)!=0&&(f=y(qr(s+236|0,c)),f>=y(0))||(f=y(_n(y(qr(Fn(s+204|0,n[1040+(l<<2)>>2]|0,992)|0,c)),y(0)))),y(f)}function Oi(s,l,c){s=s|0,l=l|0,c=y(c);var f=Ze;return pe(l)|0&&(n[s+248>>2]|0)!=0&&(f=y(qr(s+244|0,c)),f>=y(0))||(f=y(_n(y(qr(Fn(s+204|0,n[1e3+(l<<2)>>2]|0,992)|0,c)),y(0)))),y(f)}function Ig(s,l,c,f,d,m,B){s=s|0,l=y(l),c=y(c),f=f|0,d=d|0,m=y(m),B=y(B);var k=Ze,Q=Ze,M=Ze,O=Ze,G=Ze,se=Ze,qe=0,Me=0,Qe=0;Qe=C,C=C+16|0,qe=Qe,Me=s+964|0,Un(s,(n[Me>>2]|0)!=0,3519),k=y(En(s,2,l)),Q=y(En(s,0,l)),M=y(ln(s,2,l)),O=y(ln(s,0,l)),_t(l)|0?G=l:G=y(_n(y(0),y(y(l-M)-k))),_t(c)|0?se=c:se=y(_n(y(0),y(y(c-O)-Q))),(f|0)==1&(d|0)==1?(h[s+908>>2]=y(Bi(s,2,y(l-M),m,m)),l=y(Bi(s,0,y(c-O),B,m))):(S7[n[Me>>2]&1](qe,s,G,f,se,d),G=y(k+y(h[qe>>2])),se=y(l-M),h[s+908>>2]=y(Bi(s,2,(f|2|0)==2?G:se,m,m)),se=y(Q+y(h[qe+4>>2])),l=y(c-O),l=y(Bi(s,0,(d|2|0)==2?se:l,B,m))),h[s+912>>2]=l,C=Qe}function qv(s,l,c,f,d,m,B){s=s|0,l=y(l),c=y(c),f=f|0,d=d|0,m=y(m),B=y(B);var k=Ze,Q=Ze,M=Ze,O=Ze;M=y(En(s,2,m)),k=y(En(s,0,m)),O=y(ln(s,2,m)),Q=y(ln(s,0,m)),l=y(l-O),h[s+908>>2]=y(Bi(s,2,(f|2|0)==2?M:l,m,m)),c=y(c-Q),h[s+912>>2]=y(Bi(s,0,(d|2|0)==2?k:c,B,m))}function Yv(s,l,c,f,d,m,B){s=s|0,l=y(l),c=y(c),f=f|0,d=d|0,m=y(m),B=y(B);var k=0,Q=Ze,M=Ze;return k=(f|0)==2,!(l<=y(0)&k)&&!(c<=y(0)&(d|0)==2)&&!((f|0)==1&(d|0)==1)?s=0:(Q=y(ln(s,0,m)),M=y(ln(s,2,m)),k=l<y(0)&k|(_t(l)|0),l=y(l-M),h[s+908>>2]=y(Bi(s,2,k?y(0):l,m,m)),l=y(c-Q),k=c<y(0)&(d|0)==2|(_t(c)|0),h[s+912>>2]=y(Bi(s,0,k?y(0):l,B,m)),s=1),s|0}function ww(s,l){return s=s|0,l=l|0,UA(s)|0?s=fr(2,l)|0:s=0,s|0}function Cp(s,l,c){return s=s|0,l=l|0,c=y(c),c=y(oi(s,l,c)),y(c+y(Cr(s,l)))}function Iw(s,l,c){return s=s|0,l=l|0,c=y(c),c=y(Oi(s,l,c)),y(c+y(yn(s,l)))}function En(s,l,c){s=s|0,l=l|0,c=y(c);var f=Ze;return f=y(Cp(s,l,c)),y(f+y(Iw(s,l,c)))}function Tm(s){return s=s|0,n[s+24>>2]|0?s=0:y(rs(s))!=y(0)?s=1:s=y(js(s))!=y(0),s|0}function rs(s){s=s|0;var l=Ze;if(n[s+944>>2]|0){if(l=y(h[s+44>>2]),_t(l)|0)return l=y(h[s+40>>2]),s=l>y(0)&((_t(l)|0)^1),y(s?l:y(0))}else l=y(0);return y(l)}function js(s){s=s|0;var l=Ze,c=0,f=Ze;do if(n[s+944>>2]|0){if(l=y(h[s+48>>2]),_t(l)|0){if(c=o[(n[s+976>>2]|0)+2>>0]|0,c<<24>>24==0&&(f=y(h[s+40>>2]),f<y(0)&((_t(f)|0)^1))){l=y(-f);break}l=c<<24>>24?y(1):y(0)}}else l=y(0);while(0);return y(l)}function Bu(s){s=s|0;var l=0,c=0;if(Xm(s+400|0,0,540)|0,o[s+985>>0]=1,$(s),c=wi(s)|0,c|0){l=s+948|0,s=0;do Bu(n[(n[l>>2]|0)+(s<<2)>>2]|0),s=s+1|0;while((s|0)!=(c|0))}}function Nm(s,l,c,f,d,m,B,k,Q,M){s=s|0,l=l|0,c=y(c),f=f|0,d=y(d),m=y(m),B=y(B),k=k|0,Q=Q|0,M=M|0;var O=0,G=Ze,se=0,qe=0,Me=Ze,Qe=Ze,et=0,Xe=Ze,lt=0,Ue=Ze,Ge=0,Lt=0,Mr=0,or=0,Xt=0,Sr=0,Nr=0,ir=0,xn=0,go=0;xn=C,C=C+16|0,Mr=xn+12|0,or=xn+8|0,Xt=xn+4|0,Sr=xn,ir=fr(n[s+4>>2]|0,Q)|0,Ge=pe(ir)|0,G=y(qr(Bw(l)|0,Ge?m:B)),Lt=ts(l,2,m)|0,Nr=ts(l,0,B)|0;do if(!(_t(G)|0)&&!(_t(Ge?c:d)|0)){if(O=l+504|0,!(_t(y(h[O>>2]))|0)&&(!(vw(n[l+976>>2]|0,0)|0)||(n[l+500>>2]|0)==(n[2278]|0)))break;h[O>>2]=y(_n(G,y(En(l,ir,m))))}else se=7;while(0);do if((se|0)==7){if(lt=Ge^1,!(lt|Lt^1)){B=y(qr(n[l+992>>2]|0,m)),h[l+504>>2]=y(_n(B,y(En(l,2,m))));break}if(!(Ge|Nr^1)){B=y(qr(n[l+996>>2]|0,B)),h[l+504>>2]=y(_n(B,y(En(l,0,m))));break}h[Mr>>2]=y(ce),h[or>>2]=y(ce),n[Xt>>2]=0,n[Sr>>2]=0,Xe=y(ln(l,2,m)),Ue=y(ln(l,0,m)),Lt?(Me=y(Xe+y(qr(n[l+992>>2]|0,m))),h[Mr>>2]=Me,n[Xt>>2]=1,qe=1):(qe=0,Me=y(ce)),Nr?(G=y(Ue+y(qr(n[l+996>>2]|0,B))),h[or>>2]=G,n[Sr>>2]=1,O=1):(O=0,G=y(ce)),se=n[s+32>>2]|0,Ge&(se|0)==2?se=2:_t(Me)|0&&!(_t(c)|0)&&(h[Mr>>2]=c,n[Xt>>2]=2,qe=2,Me=c),!((se|0)==2<)&&_t(G)|0&&!(_t(d)|0)&&(h[or>>2]=d,n[Sr>>2]=2,O=2,G=d),Qe=y(h[l+396>>2]),et=_t(Qe)|0;do if(et)se=qe;else{if((qe|0)==1<){h[or>>2]=y(y(Me-Xe)/Qe),n[Sr>>2]=1,O=1,se=1;break}Ge&(O|0)==1?(h[Mr>>2]=y(Qe*y(G-Ue)),n[Xt>>2]=1,O=1,se=1):se=qe}while(0);go=_t(c)|0,qe=(ha(s,l)|0)!=4,!(Ge|Lt|((f|0)!=1|go)|(qe|(se|0)==1))&&(h[Mr>>2]=c,n[Xt>>2]=1,!et)&&(h[or>>2]=y(y(c-Xe)/Qe),n[Sr>>2]=1,O=1),!(Nr|lt|((k|0)!=1|(_t(d)|0))|(qe|(O|0)==1))&&(h[or>>2]=d,n[Sr>>2]=1,!et)&&(h[Mr>>2]=y(Qe*y(d-Ue)),n[Xt>>2]=1),yr(l,2,m,m,Xt,Mr),yr(l,0,B,m,Sr,or),c=y(h[Mr>>2]),d=y(h[or>>2]),fa(l,c,d,Q,n[Xt>>2]|0,n[Sr>>2]|0,m,B,0,3565,M)|0,B=y(h[l+908+(n[976+(ir<<2)>>2]<<2)>>2]),h[l+504>>2]=y(_n(B,y(En(l,ir,m))))}while(0);n[l+500>>2]=n[2278],C=xn}function Bi(s,l,c,f,d){return s=s|0,l=l|0,c=y(c),f=y(f),d=y(d),f=y(MA(s,l,c,f)),y(_n(f,y(En(s,l,d))))}function ha(s,l){return s=s|0,l=l|0,l=l+20|0,l=n[((n[l>>2]|0)==0?s+16|0:l)>>2]|0,(l|0)==5&&UA(n[s+4>>2]|0)|0&&(l=1),l|0}function vl(s,l){return s=s|0,l=l|0,pe(l)|0&&(n[s+96>>2]|0)!=0?l=4:l=n[1040+(l<<2)>>2]|0,s+60+(l<<3)|0}function Sc(s,l){return s=s|0,l=l|0,pe(l)|0&&(n[s+104>>2]|0)!=0?l=5:l=n[1e3+(l<<2)>>2]|0,s+60+(l<<3)|0}function yr(s,l,c,f,d,m){switch(s=s|0,l=l|0,c=y(c),f=y(f),d=d|0,m=m|0,c=y(qr(s+380+(n[976+(l<<2)>>2]<<3)|0,c)),c=y(c+y(ln(s,l,f))),n[d>>2]|0){case 2:case 1:{d=_t(c)|0,f=y(h[m>>2]),h[m>>2]=d|f<c?f:c;break}case 0:{_t(c)|0||(n[d>>2]=2,h[m>>2]=c);break}default:}}function gi(s,l){return s=s|0,l=l|0,s=s+132|0,pe(l)|0&&(n[(Fn(s,4,948)|0)+4>>2]|0)!=0?s=1:s=(n[(Fn(s,n[1040+(l<<2)>>2]|0,948)|0)+4>>2]|0)!=0,s|0}function Or(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0;return s=s+132|0,pe(l)|0&&(f=Fn(s,4,948)|0,(n[f+4>>2]|0)!=0)?d=4:(f=Fn(s,n[1040+(l<<2)>>2]|0,948)|0,n[f+4>>2]|0?d=4:c=y(0)),(d|0)==4&&(c=y(qr(f,c))),y(c)}function ns(s,l,c){s=s|0,l=l|0,c=y(c);var f=Ze;return f=y(h[s+908+(n[976+(l<<2)>>2]<<2)>>2]),f=y(f+y(K(s,l,c))),y(f+y(re(s,l,c)))}function Yi(s){s=s|0;var l=0,c=0,f=0;e:do if(UA(n[s+4>>2]|0)|0)l=0;else if((n[s+16>>2]|0)!=5)if(c=wi(s)|0,!c)l=0;else for(l=0;;){if(f=gs(s,l)|0,(n[f+24>>2]|0)==0&&(n[f+20>>2]|0)==5){l=1;break e}if(l=l+1|0,l>>>0>=c>>>0){l=0;break}}else l=1;while(0);return l|0}function Lm(s,l){s=s|0,l=l|0;var c=Ze;return c=y(h[s+908+(n[976+(l<<2)>>2]<<2)>>2]),c>=y(0)&((_t(c)|0)^1)|0}function Ya(s){s=s|0;var l=Ze,c=0,f=0,d=0,m=0,B=0,k=0,Q=Ze;if(c=n[s+968>>2]|0,c)Q=y(h[s+908>>2]),l=y(h[s+912>>2]),l=y(w7[c&0](s,Q,l)),Un(s,(_t(l)|0)^1,3573);else{m=wi(s)|0;do if(m|0){for(c=0,d=0;;){if(f=gs(s,d)|0,n[f+940>>2]|0){B=8;break}if((n[f+24>>2]|0)!=1)if(k=(ha(s,f)|0)==5,k){c=f;break}else c=(c|0)==0?f:c;if(d=d+1|0,d>>>0>=m>>>0){B=8;break}}if((B|0)==8&&!c)break;return l=y(Ya(c)),y(l+y(h[c+404>>2]))}while(0);l=y(h[s+912>>2])}return y(l)}function MA(s,l,c,f){s=s|0,l=l|0,c=y(c),f=y(f);var d=Ze,m=0;return UA(l)|0?(l=1,m=3):pe(l)|0?(l=0,m=3):(f=y(ce),d=y(ce)),(m|0)==3&&(d=y(qr(s+364+(l<<3)|0,f)),f=y(qr(s+380+(l<<3)|0,f))),m=f<c&(f>=y(0)&((_t(f)|0)^1)),c=m?f:c,m=d>=y(0)&((_t(d)|0)^1)&c<d,y(m?d:c)}function Om(s,l,c,f,d,m,B){s=s|0,l=l|0,c=y(c),f=f|0,d=y(d),m=m|0,B=B|0;var k=Ze,Q=Ze,M=0,O=0,G=Ze,se=Ze,qe=Ze,Me=0,Qe=0,et=0,Xe=0,lt=Ze,Ue=0;et=fr(n[s+4>>2]|0,m)|0,Me=ww(et,m)|0,Qe=pe(et)|0,G=y(ln(l,2,c)),se=y(ln(l,0,c)),ts(l,2,c)|0?k=y(G+y(qr(n[l+992>>2]|0,c))):gi(l,2)|0&&sr(l,2)|0?(k=y(h[s+908>>2]),Q=y(Cr(s,2)),Q=y(k-y(Q+y(yn(s,2)))),k=y(Or(l,2,c)),k=y(Bi(l,2,y(Q-y(k+y(vu(l,2,c)))),c,c))):k=y(ce),ts(l,0,d)|0?Q=y(se+y(qr(n[l+996>>2]|0,d))):gi(l,0)|0&&sr(l,0)|0?(Q=y(h[s+912>>2]),lt=y(Cr(s,0)),lt=y(Q-y(lt+y(yn(s,0)))),Q=y(Or(l,0,d)),Q=y(Bi(l,0,y(lt-y(Q+y(vu(l,0,d)))),d,c))):Q=y(ce),M=_t(k)|0,O=_t(Q)|0;do if(M^O&&(qe=y(h[l+396>>2]),!(_t(qe)|0)))if(M){k=y(G+y(y(Q-se)*qe));break}else{lt=y(se+y(y(k-G)/qe)),Q=O?lt:Q;break}while(0);O=_t(k)|0,M=_t(Q)|0,O|M&&(Ue=(O^1)&1,f=c>y(0)&((f|0)!=0&O),k=Qe?k:f?c:k,fa(l,k,Q,m,Qe?Ue:f?2:Ue,O&(M^1)&1,k,Q,0,3623,B)|0,k=y(h[l+908>>2]),k=y(k+y(ln(l,2,c))),Q=y(h[l+912>>2]),Q=y(Q+y(ln(l,0,c)))),fa(l,k,Q,m,1,1,k,Q,1,3635,B)|0,sr(l,et)|0&&!(gi(l,et)|0)?(Ue=n[976+(et<<2)>>2]|0,lt=y(h[s+908+(Ue<<2)>>2]),lt=y(lt-y(h[l+908+(Ue<<2)>>2])),lt=y(lt-y(yn(s,et))),lt=y(lt-y(re(l,et,c))),lt=y(lt-y(vu(l,et,Qe?c:d))),h[l+400+(n[1040+(et<<2)>>2]<<2)>>2]=lt):Xe=21;do if((Xe|0)==21){if(!(gi(l,et)|0)&&(n[s+8>>2]|0)==1){Ue=n[976+(et<<2)>>2]|0,lt=y(h[s+908+(Ue<<2)>>2]),lt=y(y(lt-y(h[l+908+(Ue<<2)>>2]))*y(.5)),h[l+400+(n[1040+(et<<2)>>2]<<2)>>2]=lt;break}!(gi(l,et)|0)&&(n[s+8>>2]|0)==2&&(Ue=n[976+(et<<2)>>2]|0,lt=y(h[s+908+(Ue<<2)>>2]),lt=y(lt-y(h[l+908+(Ue<<2)>>2])),h[l+400+(n[1040+(et<<2)>>2]<<2)>>2]=lt)}while(0);sr(l,Me)|0&&!(gi(l,Me)|0)?(Ue=n[976+(Me<<2)>>2]|0,lt=y(h[s+908+(Ue<<2)>>2]),lt=y(lt-y(h[l+908+(Ue<<2)>>2])),lt=y(lt-y(yn(s,Me))),lt=y(lt-y(re(l,Me,c))),lt=y(lt-y(vu(l,Me,Qe?d:c))),h[l+400+(n[1040+(Me<<2)>>2]<<2)>>2]=lt):Xe=30;do if((Xe|0)==30&&!(gi(l,Me)|0)){if((ha(s,l)|0)==2){Ue=n[976+(Me<<2)>>2]|0,lt=y(h[s+908+(Ue<<2)>>2]),lt=y(y(lt-y(h[l+908+(Ue<<2)>>2]))*y(.5)),h[l+400+(n[1040+(Me<<2)>>2]<<2)>>2]=lt;break}Ue=(ha(s,l)|0)==3,Ue^(n[s+28>>2]|0)==2&&(Ue=n[976+(Me<<2)>>2]|0,lt=y(h[s+908+(Ue<<2)>>2]),lt=y(lt-y(h[l+908+(Ue<<2)>>2])),h[l+400+(n[1040+(Me<<2)>>2]<<2)>>2]=lt)}while(0)}function wp(s,l,c){s=s|0,l=l|0,c=c|0;var f=Ze,d=0;d=n[976+(c<<2)>>2]|0,f=y(h[l+908+(d<<2)>>2]),f=y(y(h[s+908+(d<<2)>>2])-f),f=y(f-y(h[l+400+(n[1040+(c<<2)>>2]<<2)>>2])),h[l+400+(n[1e3+(c<<2)>>2]<<2)>>2]=f}function UA(s){return s=s|0,(s|1|0)==1|0}function Bw(s){s=s|0;var l=Ze;switch(n[s+56>>2]|0){case 0:case 3:{l=y(h[s+40>>2]),l>y(0)&((_t(l)|0)^1)?s=o[(n[s+976>>2]|0)+2>>0]|0?1056:992:s=1056;break}default:s=s+52|0}return s|0}function vw(s,l){return s=s|0,l=l|0,(o[s+l>>0]|0)!=0|0}function sr(s,l){return s=s|0,l=l|0,s=s+132|0,pe(l)|0&&(n[(Fn(s,5,948)|0)+4>>2]|0)!=0?s=1:s=(n[(Fn(s,n[1e3+(l<<2)>>2]|0,948)|0)+4>>2]|0)!=0,s|0}function vu(s,l,c){s=s|0,l=l|0,c=y(c);var f=0,d=0;return s=s+132|0,pe(l)|0&&(f=Fn(s,5,948)|0,(n[f+4>>2]|0)!=0)?d=4:(f=Fn(s,n[1e3+(l<<2)>>2]|0,948)|0,n[f+4>>2]|0?d=4:c=y(0)),(d|0)==4&&(c=y(qr(f,c))),y(c)}function Mm(s,l,c){return s=s|0,l=l|0,c=y(c),gi(s,l)|0?c=y(Or(s,l,c)):c=y(-y(vu(s,l,c))),y(c)}function Du(s){return s=y(s),h[v>>2]=s,n[v>>2]|0|0}function Ip(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>1073741823)Rt();else{d=Kt(l<<2)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<2)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<2)}function Bg(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>2)<<2)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function _A(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-4-l|0)>>>2)<<2)),s=n[s>>2]|0,s|0&>(s)}function HA(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;if(B=s+4|0,k=n[B>>2]|0,d=k-f|0,m=d>>2,s=l+(m<<2)|0,s>>>0<c>>>0){f=k;do n[f>>2]=n[s>>2],s=s+4|0,f=(n[B>>2]|0)+4|0,n[B>>2]=f;while(s>>>0<c>>>0)}m|0&&Mw(k+(0-m<<2)|0,l|0,d|0)|0}function vg(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0;return k=l+4|0,Q=n[k>>2]|0,d=n[s>>2]|0,B=c,m=B-d|0,f=Q+(0-(m>>2)<<2)|0,n[k>>2]=f,(m|0)>0&&Dr(f|0,d|0,m|0)|0,d=s+4|0,m=l+8|0,f=(n[d>>2]|0)-B|0,(f|0)>0&&(Dr(n[m>>2]|0,c|0,f|0)|0,n[m>>2]=(n[m>>2]|0)+(f>>>2<<2)),B=n[s>>2]|0,n[s>>2]=n[k>>2],n[k>>2]=B,B=n[d>>2]|0,n[d>>2]=n[m>>2],n[m>>2]=B,B=s+8|0,c=l+12|0,s=n[B>>2]|0,n[B>>2]=n[c>>2],n[c>>2]=s,n[l>>2]=n[k>>2],Q|0}function Dw(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;if(B=n[l>>2]|0,m=n[c>>2]|0,(B|0)!=(m|0)){d=s+8|0,c=((m+-4-B|0)>>>2)+1|0,s=B,f=n[d>>2]|0;do n[f>>2]=n[s>>2],f=(n[d>>2]|0)+4|0,n[d>>2]=f,s=s+4|0;while((s|0)!=(m|0));n[l>>2]=B+(c<<2)}}function Um(){dc()}function ga(){var s=0;return s=Kt(4)|0,jA(s),s|0}function jA(s){s=s|0,n[s>>2]=ys()|0}function Pc(s){s=s|0,s|0&&(Dg(s),gt(s))}function Dg(s){s=s|0,tt(n[s>>2]|0)}function _m(s,l,c){s=s|0,l=l|0,c=c|0,qa(n[s>>2]|0,l,c)}function fo(s,l){s=s|0,l=y(l),pa(n[s>>2]|0,l)}function Wv(s,l){return s=s|0,l=l|0,vw(n[s>>2]|0,l)|0}function Sw(){var s=0;return s=Kt(8)|0,Kv(s,0),s|0}function Kv(s,l){s=s|0,l=l|0,l?l=Ci(n[l>>2]|0)|0:l=co()|0,n[s>>2]=l,n[s+4>>2]=0,bi(l,s)}function AF(s){s=s|0;var l=0;return l=Kt(8)|0,Kv(l,s),l|0}function Vv(s){s=s|0,s|0&&(Su(s),gt(s))}function Su(s){s=s|0;var l=0;la(n[s>>2]|0),l=s+4|0,s=n[l>>2]|0,n[l>>2]=0,s|0&&(GA(s),gt(s))}function GA(s){s=s|0,qA(s)}function qA(s){s=s|0,s=n[s>>2]|0,s|0&&PA(s|0)}function Pw(s){return s=s|0,jo(s)|0}function Hm(s){s=s|0;var l=0,c=0;c=s+4|0,l=n[c>>2]|0,n[c>>2]=0,l|0&&(GA(l),gt(l)),_s(n[s>>2]|0)}function fF(s,l){s=s|0,l=l|0,$r(n[s>>2]|0,n[l>>2]|0)}function pF(s,l){s=s|0,l=l|0,ca(n[s>>2]|0,l)}function Jv(s,l,c){s=s|0,l=l|0,c=+c,yu(n[s>>2]|0,l,y(c))}function zv(s,l,c){s=s|0,l=l|0,c=+c,Es(n[s>>2]|0,l,y(c))}function bw(s,l){s=s|0,l=l|0,gu(n[s>>2]|0,l)}function Pu(s,l){s=s|0,l=l|0,du(n[s>>2]|0,l)}function hF(s,l){s=s|0,l=l|0,FA(n[s>>2]|0,l)}function gF(s,l){s=s|0,l=l|0,kA(n[s>>2]|0,l)}function Bp(s,l){s=s|0,l=l|0,yc(n[s>>2]|0,l)}function dF(s,l){s=s|0,l=l|0,Ap(n[s>>2]|0,l)}function Xv(s,l,c){s=s|0,l=l|0,c=+c,Cc(n[s>>2]|0,l,y(c))}function YA(s,l,c){s=s|0,l=l|0,c=+c,q(n[s>>2]|0,l,y(c))}function mF(s,l){s=s|0,l=l|0,wl(n[s>>2]|0,l)}function yF(s,l){s=s|0,l=l|0,ag(n[s>>2]|0,l)}function Zv(s,l){s=s|0,l=l|0,fp(n[s>>2]|0,l)}function xw(s,l){s=s|0,l=+l,RA(n[s>>2]|0,y(l))}function kw(s,l){s=s|0,l=+l,Ha(n[s>>2]|0,y(l))}function EF(s,l){s=s|0,l=+l,qi(n[s>>2]|0,y(l))}function CF(s,l){s=s|0,l=+l,Hs(n[s>>2]|0,y(l))}function Dl(s,l){s=s|0,l=+l,mu(n[s>>2]|0,y(l))}function Qw(s,l){s=s|0,l=+l,yw(n[s>>2]|0,y(l))}function wF(s,l){s=s|0,l=+l,TA(n[s>>2]|0,y(l))}function WA(s){s=s|0,pp(n[s>>2]|0)}function jm(s,l){s=s|0,l=+l,Cs(n[s>>2]|0,y(l))}function bu(s,l){s=s|0,l=+l,ug(n[s>>2]|0,y(l))}function Fw(s){s=s|0,Ag(n[s>>2]|0)}function Rw(s,l){s=s|0,l=+l,hp(n[s>>2]|0,y(l))}function IF(s,l){s=s|0,l=+l,Ic(n[s>>2]|0,y(l))}function $v(s,l){s=s|0,l=+l,bm(n[s>>2]|0,y(l))}function KA(s,l){s=s|0,l=+l,pg(n[s>>2]|0,y(l))}function eD(s,l){s=s|0,l=+l,Cu(n[s>>2]|0,y(l))}function Gm(s,l){s=s|0,l=+l,xm(n[s>>2]|0,y(l))}function tD(s,l){s=s|0,l=+l,wu(n[s>>2]|0,y(l))}function rD(s,l){s=s|0,l=+l,Ew(n[s>>2]|0,y(l))}function qm(s,l){s=s|0,l=+l,Aa(n[s>>2]|0,y(l))}function nD(s,l,c){s=s|0,l=l|0,c=+c,Eu(n[s>>2]|0,l,y(c))}function BF(s,l,c){s=s|0,l=l|0,c=+c,xi(n[s>>2]|0,l,y(c))}function S(s,l,c){s=s|0,l=l|0,c=+c,wc(n[s>>2]|0,l,y(c))}function D(s){return s=s|0,og(n[s>>2]|0)|0}function T(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;f=C,C=C+16|0,d=f,Ec(d,n[l>>2]|0,c),j(s,d),C=f}function j(s,l){s=s|0,l=l|0,Y(s,n[l+4>>2]|0,+y(h[l>>2]))}function Y(s,l,c){s=s|0,l=l|0,c=+c,n[s>>2]=l,E[s+8>>3]=c}function Ae(s){return s=s|0,sg(n[s>>2]|0)|0}function De(s){return s=s|0,uo(n[s>>2]|0)|0}function vt(s){return s=s|0,mc(n[s>>2]|0)|0}function wt(s){return s=s|0,QA(n[s>>2]|0)|0}function xt(s){return s=s|0,Pm(n[s>>2]|0)|0}function _r(s){return s=s|0,ig(n[s>>2]|0)|0}function is(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;f=C,C=C+16|0,d=f,Dt(d,n[l>>2]|0,c),j(s,d),C=f}function di(s){return s=s|0,$n(n[s>>2]|0)|0}function po(s){return s=s|0,lg(n[s>>2]|0)|0}function VA(s,l){s=s|0,l=l|0;var c=0,f=0;c=C,C=C+16|0,f=c,ua(f,n[l>>2]|0),j(s,f),C=c}function Yo(s){return s=s|0,+ +y(Gi(n[s>>2]|0))}function rt(s){return s=s|0,+ +y(es(n[s>>2]|0))}function Ve(s,l){s=s|0,l=l|0;var c=0,f=0;c=C,C=C+16|0,f=c,Br(f,n[l>>2]|0),j(s,f),C=c}function ft(s,l){s=s|0,l=l|0;var c=0,f=0;c=C,C=C+16|0,f=c,fg(f,n[l>>2]|0),j(s,f),C=c}function Wt(s,l){s=s|0,l=l|0;var c=0,f=0;c=C,C=C+16|0,f=c,Ct(f,n[l>>2]|0),j(s,f),C=c}function vr(s,l){s=s|0,l=l|0;var c=0,f=0;c=C,C=C+16|0,f=c,hg(f,n[l>>2]|0),j(s,f),C=c}function Pn(s,l){s=s|0,l=l|0;var c=0,f=0;c=C,C=C+16|0,f=c,gg(f,n[l>>2]|0),j(s,f),C=c}function Fr(s,l){s=s|0,l=l|0;var c=0,f=0;c=C,C=C+16|0,f=c,km(f,n[l>>2]|0),j(s,f),C=c}function bn(s){return s=s|0,+ +y(Bc(n[s>>2]|0))}function ai(s,l){return s=s|0,l=l|0,+ +y(cg(n[s>>2]|0,l))}function tn(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;f=C,C=C+16|0,d=f,ct(d,n[l>>2]|0,c),j(s,d),C=f}function ho(s,l,c){s=s|0,l=l|0,c=c|0,nr(n[s>>2]|0,n[l>>2]|0,c)}function vF(s,l){s=s|0,l=l|0,ms(n[s>>2]|0,n[l>>2]|0)}function eve(s){return s=s|0,wi(n[s>>2]|0)|0}function tve(s){return s=s|0,s=ht(n[s>>2]|0)|0,s?s=Pw(s)|0:s=0,s|0}function rve(s,l){return s=s|0,l=l|0,s=gs(n[s>>2]|0,l)|0,s?s=Pw(s)|0:s=0,s|0}function nve(s,l){s=s|0,l=l|0;var c=0,f=0;f=Kt(4)|0,zq(f,l),c=s+4|0,l=n[c>>2]|0,n[c>>2]=f,l|0&&(GA(l),gt(l)),It(n[s>>2]|0,1)}function zq(s,l){s=s|0,l=l|0,gve(s,l)}function ive(s,l,c,f,d,m){s=s|0,l=l|0,c=y(c),f=f|0,d=y(d),m=m|0;var B=0,k=0;B=C,C=C+16|0,k=B,sve(k,jo(l)|0,+c,f,+d,m),h[s>>2]=y(+E[k>>3]),h[s+4>>2]=y(+E[k+8>>3]),C=B}function sve(s,l,c,f,d,m){s=s|0,l=l|0,c=+c,f=f|0,d=+d,m=m|0;var B=0,k=0,Q=0,M=0,O=0;B=C,C=C+32|0,O=B+8|0,M=B+20|0,Q=B,k=B+16|0,E[O>>3]=c,n[M>>2]=f,E[Q>>3]=d,n[k>>2]=m,ove(s,n[l+4>>2]|0,O,M,Q,k),C=B}function ove(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0;var B=0,k=0;B=C,C=C+16|0,k=B,Ka(k),l=da(l)|0,ave(s,l,+E[c>>3],n[f>>2]|0,+E[d>>3],n[m>>2]|0),Va(k),C=B}function da(s){return s=s|0,n[s>>2]|0}function ave(s,l,c,f,d,m){s=s|0,l=l|0,c=+c,f=f|0,d=+d,m=m|0;var B=0;B=Sl(lve()|0)|0,c=+JA(c),f=DF(f)|0,d=+JA(d),cve(s,hi(0,B|0,l|0,+c,f|0,+d,DF(m)|0)|0)}function lve(){var s=0;return o[7608]|0||(pve(9120),s=7608,n[s>>2]=1,n[s+4>>2]=0),9120}function Sl(s){return s=s|0,n[s+8>>2]|0}function JA(s){return s=+s,+ +SF(s)}function DF(s){return s=s|0,Zq(s)|0}function cve(s,l){s=s|0,l=l|0;var c=0,f=0,d=0;d=C,C=C+32|0,c=d,f=l,f&1?(uve(c,0),ii(f|0,c|0)|0,Ave(s,c),fve(c)):(n[s>>2]=n[l>>2],n[s+4>>2]=n[l+4>>2],n[s+8>>2]=n[l+8>>2],n[s+12>>2]=n[l+12>>2]),C=d}function uve(s,l){s=s|0,l=l|0,Xq(s,l),n[s+8>>2]=0,o[s+24>>0]=0}function Ave(s,l){s=s|0,l=l|0,l=l+8|0,n[s>>2]=n[l>>2],n[s+4>>2]=n[l+4>>2],n[s+8>>2]=n[l+8>>2],n[s+12>>2]=n[l+12>>2]}function fve(s){s=s|0,o[s+24>>0]=0}function Xq(s,l){s=s|0,l=l|0,n[s>>2]=l}function Zq(s){return s=s|0,s|0}function SF(s){return s=+s,+s}function pve(s){s=s|0,Pl(s,hve()|0,4)}function hve(){return 1064}function Pl(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c,n[s+8>>2]=up(l|0,c+1|0)|0}function gve(s,l){s=s|0,l=l|0,l=n[l>>2]|0,n[s>>2]=l,yl(l|0)}function dve(s){s=s|0;var l=0,c=0;c=s+4|0,l=n[c>>2]|0,n[c>>2]=0,l|0&&(GA(l),gt(l)),It(n[s>>2]|0,0)}function mve(s){s=s|0,Tt(n[s>>2]|0)}function yve(s){return s=s|0,er(n[s>>2]|0)|0}function Eve(s,l,c,f){s=s|0,l=+l,c=+c,f=f|0,vc(n[s>>2]|0,y(l),y(c),f)}function Cve(s){return s=s|0,+ +y(Il(n[s>>2]|0))}function wve(s){return s=s|0,+ +y(dg(n[s>>2]|0))}function Ive(s){return s=s|0,+ +y(Iu(n[s>>2]|0))}function Bve(s){return s=s|0,+ +y(NA(n[s>>2]|0))}function vve(s){return s=s|0,+ +y(gp(n[s>>2]|0))}function Dve(s){return s=s|0,+ +y(ja(n[s>>2]|0))}function Sve(s,l){s=s|0,l=l|0,E[s>>3]=+y(Il(n[l>>2]|0)),E[s+8>>3]=+y(dg(n[l>>2]|0)),E[s+16>>3]=+y(Iu(n[l>>2]|0)),E[s+24>>3]=+y(NA(n[l>>2]|0)),E[s+32>>3]=+y(gp(n[l>>2]|0)),E[s+40>>3]=+y(ja(n[l>>2]|0))}function Pve(s,l){return s=s|0,l=l|0,+ +y(mg(n[s>>2]|0,l))}function bve(s,l){return s=s|0,l=l|0,+ +y(dp(n[s>>2]|0,l))}function xve(s,l){return s=s|0,l=l|0,+ +y(Go(n[s>>2]|0,l))}function kve(){return Sn()|0}function Qve(){Fve(),Rve(),Tve(),Nve(),Lve(),Ove()}function Fve(){LNe(11713,4938,1)}function Rve(){tNe(10448)}function Tve(){LTe(10408)}function Nve(){sTe(10324)}function Lve(){pFe(10096)}function Ove(){Mve(9132)}function Mve(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0,qe=0,Me=0,Qe=0,et=0,Xe=0,lt=0,Ue=0,Ge=0,Lt=0,Mr=0,or=0,Xt=0,Sr=0,Nr=0,ir=0,xn=0,go=0,mo=0,yo=0,ya=0,Fp=0,Rp=0,bl=0,Tp=0,Fu=0,Ru=0,Np=0,Lp=0,Op=0,Xr=0,xl=0,Mp=0,xc=0,Up=0,_p=0,Tu=0,Nu=0,kc=0,Gs=0,za=0,Wo=0,kl=0,nf=0,sf=0,Lu=0,of=0,af=0,qs=0,vs=0,Ql=0,Rn=0,lf=0,Eo=0,Qc=0,Co=0,Fc=0,cf=0,uf=0,Rc=0,Ys=0,Fl=0,Af=0,ff=0,pf=0,xr=0,Jn=0,Ds=0,wo=0,Ws=0,Rr=0,ur=0,Rl=0;l=C,C=C+672|0,c=l+656|0,Rl=l+648|0,ur=l+640|0,Rr=l+632|0,Ws=l+624|0,wo=l+616|0,Ds=l+608|0,Jn=l+600|0,xr=l+592|0,pf=l+584|0,ff=l+576|0,Af=l+568|0,Fl=l+560|0,Ys=l+552|0,Rc=l+544|0,uf=l+536|0,cf=l+528|0,Fc=l+520|0,Co=l+512|0,Qc=l+504|0,Eo=l+496|0,lf=l+488|0,Rn=l+480|0,Ql=l+472|0,vs=l+464|0,qs=l+456|0,af=l+448|0,of=l+440|0,Lu=l+432|0,sf=l+424|0,nf=l+416|0,kl=l+408|0,Wo=l+400|0,za=l+392|0,Gs=l+384|0,kc=l+376|0,Nu=l+368|0,Tu=l+360|0,_p=l+352|0,Up=l+344|0,xc=l+336|0,Mp=l+328|0,xl=l+320|0,Xr=l+312|0,Op=l+304|0,Lp=l+296|0,Np=l+288|0,Ru=l+280|0,Fu=l+272|0,Tp=l+264|0,bl=l+256|0,Rp=l+248|0,Fp=l+240|0,ya=l+232|0,yo=l+224|0,mo=l+216|0,go=l+208|0,xn=l+200|0,ir=l+192|0,Nr=l+184|0,Sr=l+176|0,Xt=l+168|0,or=l+160|0,Mr=l+152|0,Lt=l+144|0,Ge=l+136|0,Ue=l+128|0,lt=l+120|0,Xe=l+112|0,et=l+104|0,Qe=l+96|0,Me=l+88|0,qe=l+80|0,se=l+72|0,G=l+64|0,O=l+56|0,M=l+48|0,Q=l+40|0,k=l+32|0,B=l+24|0,m=l+16|0,d=l+8|0,f=l,Uve(s,3646),_ve(s,3651,2)|0,Hve(s,3665,2)|0,jve(s,3682,18)|0,n[Rl>>2]=19,n[Rl+4>>2]=0,n[c>>2]=n[Rl>>2],n[c+4>>2]=n[Rl+4>>2],Tw(s,3690,c)|0,n[ur>>2]=1,n[ur+4>>2]=0,n[c>>2]=n[ur>>2],n[c+4>>2]=n[ur+4>>2],Gve(s,3696,c)|0,n[Rr>>2]=2,n[Rr+4>>2]=0,n[c>>2]=n[Rr>>2],n[c+4>>2]=n[Rr+4>>2],xu(s,3706,c)|0,n[Ws>>2]=1,n[Ws+4>>2]=0,n[c>>2]=n[Ws>>2],n[c+4>>2]=n[Ws+4>>2],Sg(s,3722,c)|0,n[wo>>2]=2,n[wo+4>>2]=0,n[c>>2]=n[wo>>2],n[c+4>>2]=n[wo+4>>2],Sg(s,3734,c)|0,n[Ds>>2]=3,n[Ds+4>>2]=0,n[c>>2]=n[Ds>>2],n[c+4>>2]=n[Ds+4>>2],xu(s,3753,c)|0,n[Jn>>2]=4,n[Jn+4>>2]=0,n[c>>2]=n[Jn>>2],n[c+4>>2]=n[Jn+4>>2],xu(s,3769,c)|0,n[xr>>2]=5,n[xr+4>>2]=0,n[c>>2]=n[xr>>2],n[c+4>>2]=n[xr+4>>2],xu(s,3783,c)|0,n[pf>>2]=6,n[pf+4>>2]=0,n[c>>2]=n[pf>>2],n[c+4>>2]=n[pf+4>>2],xu(s,3796,c)|0,n[ff>>2]=7,n[ff+4>>2]=0,n[c>>2]=n[ff>>2],n[c+4>>2]=n[ff+4>>2],xu(s,3813,c)|0,n[Af>>2]=8,n[Af+4>>2]=0,n[c>>2]=n[Af>>2],n[c+4>>2]=n[Af+4>>2],xu(s,3825,c)|0,n[Fl>>2]=3,n[Fl+4>>2]=0,n[c>>2]=n[Fl>>2],n[c+4>>2]=n[Fl+4>>2],Sg(s,3843,c)|0,n[Ys>>2]=4,n[Ys+4>>2]=0,n[c>>2]=n[Ys>>2],n[c+4>>2]=n[Ys+4>>2],Sg(s,3853,c)|0,n[Rc>>2]=9,n[Rc+4>>2]=0,n[c>>2]=n[Rc>>2],n[c+4>>2]=n[Rc+4>>2],xu(s,3870,c)|0,n[uf>>2]=10,n[uf+4>>2]=0,n[c>>2]=n[uf>>2],n[c+4>>2]=n[uf+4>>2],xu(s,3884,c)|0,n[cf>>2]=11,n[cf+4>>2]=0,n[c>>2]=n[cf>>2],n[c+4>>2]=n[cf+4>>2],xu(s,3896,c)|0,n[Fc>>2]=1,n[Fc+4>>2]=0,n[c>>2]=n[Fc>>2],n[c+4>>2]=n[Fc+4>>2],Is(s,3907,c)|0,n[Co>>2]=2,n[Co+4>>2]=0,n[c>>2]=n[Co>>2],n[c+4>>2]=n[Co+4>>2],Is(s,3915,c)|0,n[Qc>>2]=3,n[Qc+4>>2]=0,n[c>>2]=n[Qc>>2],n[c+4>>2]=n[Qc+4>>2],Is(s,3928,c)|0,n[Eo>>2]=4,n[Eo+4>>2]=0,n[c>>2]=n[Eo>>2],n[c+4>>2]=n[Eo+4>>2],Is(s,3948,c)|0,n[lf>>2]=5,n[lf+4>>2]=0,n[c>>2]=n[lf>>2],n[c+4>>2]=n[lf+4>>2],Is(s,3960,c)|0,n[Rn>>2]=6,n[Rn+4>>2]=0,n[c>>2]=n[Rn>>2],n[c+4>>2]=n[Rn+4>>2],Is(s,3974,c)|0,n[Ql>>2]=7,n[Ql+4>>2]=0,n[c>>2]=n[Ql>>2],n[c+4>>2]=n[Ql+4>>2],Is(s,3983,c)|0,n[vs>>2]=20,n[vs+4>>2]=0,n[c>>2]=n[vs>>2],n[c+4>>2]=n[vs+4>>2],Tw(s,3999,c)|0,n[qs>>2]=8,n[qs+4>>2]=0,n[c>>2]=n[qs>>2],n[c+4>>2]=n[qs+4>>2],Is(s,4012,c)|0,n[af>>2]=9,n[af+4>>2]=0,n[c>>2]=n[af>>2],n[c+4>>2]=n[af+4>>2],Is(s,4022,c)|0,n[of>>2]=21,n[of+4>>2]=0,n[c>>2]=n[of>>2],n[c+4>>2]=n[of+4>>2],Tw(s,4039,c)|0,n[Lu>>2]=10,n[Lu+4>>2]=0,n[c>>2]=n[Lu>>2],n[c+4>>2]=n[Lu+4>>2],Is(s,4053,c)|0,n[sf>>2]=11,n[sf+4>>2]=0,n[c>>2]=n[sf>>2],n[c+4>>2]=n[sf+4>>2],Is(s,4065,c)|0,n[nf>>2]=12,n[nf+4>>2]=0,n[c>>2]=n[nf>>2],n[c+4>>2]=n[nf+4>>2],Is(s,4084,c)|0,n[kl>>2]=13,n[kl+4>>2]=0,n[c>>2]=n[kl>>2],n[c+4>>2]=n[kl+4>>2],Is(s,4097,c)|0,n[Wo>>2]=14,n[Wo+4>>2]=0,n[c>>2]=n[Wo>>2],n[c+4>>2]=n[Wo+4>>2],Is(s,4117,c)|0,n[za>>2]=15,n[za+4>>2]=0,n[c>>2]=n[za>>2],n[c+4>>2]=n[za+4>>2],Is(s,4129,c)|0,n[Gs>>2]=16,n[Gs+4>>2]=0,n[c>>2]=n[Gs>>2],n[c+4>>2]=n[Gs+4>>2],Is(s,4148,c)|0,n[kc>>2]=17,n[kc+4>>2]=0,n[c>>2]=n[kc>>2],n[c+4>>2]=n[kc+4>>2],Is(s,4161,c)|0,n[Nu>>2]=18,n[Nu+4>>2]=0,n[c>>2]=n[Nu>>2],n[c+4>>2]=n[Nu+4>>2],Is(s,4181,c)|0,n[Tu>>2]=5,n[Tu+4>>2]=0,n[c>>2]=n[Tu>>2],n[c+4>>2]=n[Tu+4>>2],Sg(s,4196,c)|0,n[_p>>2]=6,n[_p+4>>2]=0,n[c>>2]=n[_p>>2],n[c+4>>2]=n[_p+4>>2],Sg(s,4206,c)|0,n[Up>>2]=7,n[Up+4>>2]=0,n[c>>2]=n[Up>>2],n[c+4>>2]=n[Up+4>>2],Sg(s,4217,c)|0,n[xc>>2]=3,n[xc+4>>2]=0,n[c>>2]=n[xc>>2],n[c+4>>2]=n[xc+4>>2],zA(s,4235,c)|0,n[Mp>>2]=1,n[Mp+4>>2]=0,n[c>>2]=n[Mp>>2],n[c+4>>2]=n[Mp+4>>2],PF(s,4251,c)|0,n[xl>>2]=4,n[xl+4>>2]=0,n[c>>2]=n[xl>>2],n[c+4>>2]=n[xl+4>>2],zA(s,4263,c)|0,n[Xr>>2]=5,n[Xr+4>>2]=0,n[c>>2]=n[Xr>>2],n[c+4>>2]=n[Xr+4>>2],zA(s,4279,c)|0,n[Op>>2]=6,n[Op+4>>2]=0,n[c>>2]=n[Op>>2],n[c+4>>2]=n[Op+4>>2],zA(s,4293,c)|0,n[Lp>>2]=7,n[Lp+4>>2]=0,n[c>>2]=n[Lp>>2],n[c+4>>2]=n[Lp+4>>2],zA(s,4306,c)|0,n[Np>>2]=8,n[Np+4>>2]=0,n[c>>2]=n[Np>>2],n[c+4>>2]=n[Np+4>>2],zA(s,4323,c)|0,n[Ru>>2]=9,n[Ru+4>>2]=0,n[c>>2]=n[Ru>>2],n[c+4>>2]=n[Ru+4>>2],zA(s,4335,c)|0,n[Fu>>2]=2,n[Fu+4>>2]=0,n[c>>2]=n[Fu>>2],n[c+4>>2]=n[Fu+4>>2],PF(s,4353,c)|0,n[Tp>>2]=12,n[Tp+4>>2]=0,n[c>>2]=n[Tp>>2],n[c+4>>2]=n[Tp+4>>2],Pg(s,4363,c)|0,n[bl>>2]=1,n[bl+4>>2]=0,n[c>>2]=n[bl>>2],n[c+4>>2]=n[bl+4>>2],XA(s,4376,c)|0,n[Rp>>2]=2,n[Rp+4>>2]=0,n[c>>2]=n[Rp>>2],n[c+4>>2]=n[Rp+4>>2],XA(s,4388,c)|0,n[Fp>>2]=13,n[Fp+4>>2]=0,n[c>>2]=n[Fp>>2],n[c+4>>2]=n[Fp+4>>2],Pg(s,4402,c)|0,n[ya>>2]=14,n[ya+4>>2]=0,n[c>>2]=n[ya>>2],n[c+4>>2]=n[ya+4>>2],Pg(s,4411,c)|0,n[yo>>2]=15,n[yo+4>>2]=0,n[c>>2]=n[yo>>2],n[c+4>>2]=n[yo+4>>2],Pg(s,4421,c)|0,n[mo>>2]=16,n[mo+4>>2]=0,n[c>>2]=n[mo>>2],n[c+4>>2]=n[mo+4>>2],Pg(s,4433,c)|0,n[go>>2]=17,n[go+4>>2]=0,n[c>>2]=n[go>>2],n[c+4>>2]=n[go+4>>2],Pg(s,4446,c)|0,n[xn>>2]=18,n[xn+4>>2]=0,n[c>>2]=n[xn>>2],n[c+4>>2]=n[xn+4>>2],Pg(s,4458,c)|0,n[ir>>2]=3,n[ir+4>>2]=0,n[c>>2]=n[ir>>2],n[c+4>>2]=n[ir+4>>2],XA(s,4471,c)|0,n[Nr>>2]=1,n[Nr+4>>2]=0,n[c>>2]=n[Nr>>2],n[c+4>>2]=n[Nr+4>>2],iD(s,4486,c)|0,n[Sr>>2]=10,n[Sr+4>>2]=0,n[c>>2]=n[Sr>>2],n[c+4>>2]=n[Sr+4>>2],zA(s,4496,c)|0,n[Xt>>2]=11,n[Xt+4>>2]=0,n[c>>2]=n[Xt>>2],n[c+4>>2]=n[Xt+4>>2],zA(s,4508,c)|0,n[or>>2]=3,n[or+4>>2]=0,n[c>>2]=n[or>>2],n[c+4>>2]=n[or+4>>2],PF(s,4519,c)|0,n[Mr>>2]=4,n[Mr+4>>2]=0,n[c>>2]=n[Mr>>2],n[c+4>>2]=n[Mr+4>>2],qve(s,4530,c)|0,n[Lt>>2]=19,n[Lt+4>>2]=0,n[c>>2]=n[Lt>>2],n[c+4>>2]=n[Lt+4>>2],Yve(s,4542,c)|0,n[Ge>>2]=12,n[Ge+4>>2]=0,n[c>>2]=n[Ge>>2],n[c+4>>2]=n[Ge+4>>2],Wve(s,4554,c)|0,n[Ue>>2]=13,n[Ue+4>>2]=0,n[c>>2]=n[Ue>>2],n[c+4>>2]=n[Ue+4>>2],Kve(s,4568,c)|0,n[lt>>2]=2,n[lt+4>>2]=0,n[c>>2]=n[lt>>2],n[c+4>>2]=n[lt+4>>2],Vve(s,4578,c)|0,n[Xe>>2]=20,n[Xe+4>>2]=0,n[c>>2]=n[Xe>>2],n[c+4>>2]=n[Xe+4>>2],Jve(s,4587,c)|0,n[et>>2]=22,n[et+4>>2]=0,n[c>>2]=n[et>>2],n[c+4>>2]=n[et+4>>2],Tw(s,4602,c)|0,n[Qe>>2]=23,n[Qe+4>>2]=0,n[c>>2]=n[Qe>>2],n[c+4>>2]=n[Qe+4>>2],Tw(s,4619,c)|0,n[Me>>2]=14,n[Me+4>>2]=0,n[c>>2]=n[Me>>2],n[c+4>>2]=n[Me+4>>2],zve(s,4629,c)|0,n[qe>>2]=1,n[qe+4>>2]=0,n[c>>2]=n[qe>>2],n[c+4>>2]=n[qe+4>>2],Xve(s,4637,c)|0,n[se>>2]=4,n[se+4>>2]=0,n[c>>2]=n[se>>2],n[c+4>>2]=n[se+4>>2],XA(s,4653,c)|0,n[G>>2]=5,n[G+4>>2]=0,n[c>>2]=n[G>>2],n[c+4>>2]=n[G+4>>2],XA(s,4669,c)|0,n[O>>2]=6,n[O+4>>2]=0,n[c>>2]=n[O>>2],n[c+4>>2]=n[O+4>>2],XA(s,4686,c)|0,n[M>>2]=7,n[M+4>>2]=0,n[c>>2]=n[M>>2],n[c+4>>2]=n[M+4>>2],XA(s,4701,c)|0,n[Q>>2]=8,n[Q+4>>2]=0,n[c>>2]=n[Q>>2],n[c+4>>2]=n[Q+4>>2],XA(s,4719,c)|0,n[k>>2]=9,n[k+4>>2]=0,n[c>>2]=n[k>>2],n[c+4>>2]=n[k+4>>2],XA(s,4736,c)|0,n[B>>2]=21,n[B+4>>2]=0,n[c>>2]=n[B>>2],n[c+4>>2]=n[B+4>>2],Zve(s,4754,c)|0,n[m>>2]=2,n[m+4>>2]=0,n[c>>2]=n[m>>2],n[c+4>>2]=n[m+4>>2],iD(s,4772,c)|0,n[d>>2]=3,n[d+4>>2]=0,n[c>>2]=n[d>>2],n[c+4>>2]=n[d+4>>2],iD(s,4790,c)|0,n[f>>2]=4,n[f+4>>2]=0,n[c>>2]=n[f>>2],n[c+4>>2]=n[f+4>>2],iD(s,4808,c)|0,C=l}function Uve(s,l){s=s|0,l=l|0;var c=0;c=iFe()|0,n[s>>2]=c,sFe(c,l),xp(n[s>>2]|0)}function _ve(s,l,c){return s=s|0,l=l|0,c=c|0,qQe(s,pn(l)|0,c,0),s|0}function Hve(s,l,c){return s=s|0,l=l|0,c=c|0,bQe(s,pn(l)|0,c,0),s|0}function jve(s,l,c){return s=s|0,l=l|0,c=c|0,hQe(s,pn(l)|0,c,0),s|0}function Tw(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Zke(s,l,d),C=f,s|0}function Gve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Rke(s,l,d),C=f,s|0}function xu(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],mke(s,l,d),C=f,s|0}function Sg(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],tke(s,l,d),C=f,s|0}function Is(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Uxe(s,l,d),C=f,s|0}function zA(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Bxe(s,l,d),C=f,s|0}function PF(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],axe(s,l,d),C=f,s|0}function Pg(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Rbe(s,l,d),C=f,s|0}function XA(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],mbe(s,l,d),C=f,s|0}function iD(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],tbe(s,l,d),C=f,s|0}function qve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],UPe(s,l,d),C=f,s|0}function Yve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],BPe(s,l,d),C=f,s|0}function Wve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],lPe(s,l,d),C=f,s|0}function Kve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],KSe(s,l,d),C=f,s|0}function Vve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],kSe(s,l,d),C=f,s|0}function Jve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],pSe(s,l,d),C=f,s|0}function zve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],XDe(s,l,d),C=f,s|0}function Xve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],RDe(s,l,d),C=f,s|0}function Zve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],$ve(s,l,d),C=f,s|0}function $ve(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],eDe(s,c,d,1),C=f}function pn(s){return s=s|0,s|0}function eDe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=bF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=tDe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,rDe(m,f)|0,f),C=d}function bF(){var s=0,l=0;if(o[7616]|0||(t9(9136),tr(24,9136,U|0)|0,l=7616,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9136)|0)){s=9136,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));t9(9136)}return 9136}function tDe(s){return s=s|0,0}function rDe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=bF()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],e9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(sDe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function hn(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0;var B=0,k=0,Q=0,M=0,O=0,G=0,se=0,qe=0;B=C,C=C+32|0,se=B+24|0,G=B+20|0,Q=B+16|0,O=B+12|0,M=B+8|0,k=B+4|0,qe=B,n[G>>2]=l,n[Q>>2]=c,n[O>>2]=f,n[M>>2]=d,n[k>>2]=m,m=s+28|0,n[qe>>2]=n[m>>2],n[se>>2]=n[qe>>2],nDe(s+24|0,se,G,O,M,Q,k)|0,n[m>>2]=n[n[m>>2]>>2],C=B}function nDe(s,l,c,f,d,m,B){return s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,B=B|0,s=iDe(l)|0,l=Kt(24)|0,$q(l+4|0,n[c>>2]|0,n[f>>2]|0,n[d>>2]|0,n[m>>2]|0,n[B>>2]|0),n[l>>2]=n[s>>2],n[s>>2]=l,l|0}function iDe(s){return s=s|0,n[s>>2]|0}function $q(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,n[s>>2]=l,n[s+4>>2]=c,n[s+8>>2]=f,n[s+12>>2]=d,n[s+16>>2]=m}function gr(s,l){return s=s|0,l=l|0,l|s|0}function e9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function sDe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=oDe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,aDe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],e9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,lDe(s,k),cDe(k),C=M;return}}function oDe(s){return s=s|0,357913941}function aDe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function lDe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function cDe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function t9(s){s=s|0,fDe(s)}function uDe(s){s=s|0,ADe(s+24|0)}function Tr(s){return s=s|0,n[s>>2]|0}function ADe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function fDe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,3,l,pDe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function Kr(){return 9228}function pDe(){return 1140}function hDe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0;return c=C,C=C+16|0,f=c+8|0,d=c,m=gDe(s)|0,s=n[m+4>>2]|0,n[d>>2]=n[m>>2],n[d+4>>2]=s,n[f>>2]=n[d>>2],n[f+4>>2]=n[d+4>>2],l=dDe(l,f)|0,C=c,l|0}function Vr(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,n[s>>2]=l,n[s+4>>2]=c,n[s+8>>2]=f,n[s+12>>2]=d,n[s+16>>2]=m}function gDe(s){return s=s|0,(n[(bF()|0)+24>>2]|0)+(s*12|0)|0}function dDe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0;return d=C,C=C+48|0,f=d,c=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(c=n[(n[s>>2]|0)+c>>2]|0),rf[c&31](f,s),f=mDe(f)|0,C=d,f|0}function mDe(s){s=s|0;var l=0,c=0,f=0,d=0;return d=C,C=C+32|0,l=d+12|0,c=d,f=xF(r9()|0)|0,f?(kF(l,f),QF(c,l),yDe(s,c),s=FF(l)|0):s=EDe(s)|0,C=d,s|0}function r9(){var s=0;return o[7632]|0||(xDe(9184),tr(25,9184,U|0)|0,s=7632,n[s>>2]=1,n[s+4>>2]=0),9184}function xF(s){return s=s|0,n[s+36>>2]|0}function kF(s,l){s=s|0,l=l|0,n[s>>2]=l,n[s+4>>2]=s,n[s+8>>2]=0}function QF(s,l){s=s|0,l=l|0,n[s>>2]=n[l>>2],n[s+4>>2]=n[l+4>>2],n[s+8>>2]=0}function yDe(s,l){s=s|0,l=l|0,BDe(l,s,s+8|0,s+16|0,s+24|0,s+32|0,s+40|0)|0}function FF(s){return s=s|0,n[(n[s+4>>2]|0)+8>>2]|0}function EDe(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0,Q=0;Q=C,C=C+16|0,c=Q+4|0,f=Q,d=Wa(8)|0,m=d,B=Kt(48)|0,k=B,l=k+48|0;do n[k>>2]=n[s>>2],k=k+4|0,s=s+4|0;while((k|0)<(l|0));return l=m+4|0,n[l>>2]=B,k=Kt(8)|0,B=n[l>>2]|0,n[f>>2]=0,n[c>>2]=n[f>>2],n9(k,B,c),n[d>>2]=k,C=Q,m|0}function n9(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,c=Kt(16)|0,n[c+4>>2]=0,n[c+8>>2]=0,n[c>>2]=1092,n[c+12>>2]=l,n[s+4>>2]=c}function CDe(s){s=s|0,zm(s),gt(s)}function wDe(s){s=s|0,s=n[s+12>>2]|0,s|0&>(s)}function IDe(s){s=s|0,gt(s)}function BDe(s,l,c,f,d,m,B){return s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,B=B|0,m=vDe(n[s>>2]|0,l,c,f,d,m,B)|0,B=s+4|0,n[(n[B>>2]|0)+8>>2]=m,n[(n[B>>2]|0)+8>>2]|0}function vDe(s,l,c,f,d,m,B){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,B=B|0;var k=0,Q=0;return k=C,C=C+16|0,Q=k,Ka(Q),s=da(s)|0,B=DDe(s,+E[l>>3],+E[c>>3],+E[f>>3],+E[d>>3],+E[m>>3],+E[B>>3])|0,Va(Q),C=k,B|0}function DDe(s,l,c,f,d,m,B){s=s|0,l=+l,c=+c,f=+f,d=+d,m=+m,B=+B;var k=0;return k=Sl(SDe()|0)|0,l=+JA(l),c=+JA(c),f=+JA(f),d=+JA(d),m=+JA(m),Ms(0,k|0,s|0,+l,+c,+f,+d,+m,+ +JA(B))|0}function SDe(){var s=0;return o[7624]|0||(PDe(9172),s=7624,n[s>>2]=1,n[s+4>>2]=0),9172}function PDe(s){s=s|0,Pl(s,bDe()|0,6)}function bDe(){return 1112}function xDe(s){s=s|0,vp(s)}function kDe(s){s=s|0,i9(s+24|0),s9(s+16|0)}function i9(s){s=s|0,FDe(s)}function s9(s){s=s|0,QDe(s)}function QDe(s){s=s|0;var l=0,c=0;if(l=n[s>>2]|0,l|0)do c=l,l=n[l>>2]|0,gt(c);while((l|0)!=0);n[s>>2]=0}function FDe(s){s=s|0;var l=0,c=0;if(l=n[s>>2]|0,l|0)do c=l,l=n[l>>2]|0,gt(c);while((l|0)!=0);n[s>>2]=0}function vp(s){s=s|0;var l=0;n[s+16>>2]=0,n[s+20>>2]=0,l=s+24|0,n[l>>2]=0,n[s+28>>2]=l,n[s+36>>2]=0,o[s+40>>0]=0,o[s+41>>0]=0}function RDe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],TDe(s,c,d,0),C=f}function TDe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=RF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=NDe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,LDe(m,f)|0,f),C=d}function RF(){var s=0,l=0;if(o[7640]|0||(a9(9232),tr(26,9232,U|0)|0,l=7640,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9232)|0)){s=9232,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));a9(9232)}return 9232}function NDe(s){return s=s|0,0}function LDe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=RF()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],o9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(ODe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function o9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function ODe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=MDe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,UDe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],o9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,_De(s,k),HDe(k),C=M;return}}function MDe(s){return s=s|0,357913941}function UDe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function _De(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function HDe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function a9(s){s=s|0,qDe(s)}function jDe(s){s=s|0,GDe(s+24|0)}function GDe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function qDe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,1,l,YDe()|0,3),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function YDe(){return 1144}function WDe(s,l,c,f,d){s=s|0,l=l|0,c=+c,f=+f,d=d|0;var m=0,B=0,k=0,Q=0;m=C,C=C+16|0,B=m+8|0,k=m,Q=KDe(s)|0,s=n[Q+4>>2]|0,n[k>>2]=n[Q>>2],n[k+4>>2]=s,n[B>>2]=n[k>>2],n[B+4>>2]=n[k+4>>2],VDe(l,B,c,f,d),C=m}function KDe(s){return s=s|0,(n[(RF()|0)+24>>2]|0)+(s*12|0)|0}function VDe(s,l,c,f,d){s=s|0,l=l|0,c=+c,f=+f,d=d|0;var m=0,B=0,k=0,Q=0,M=0;M=C,C=C+16|0,B=M+2|0,k=M+1|0,Q=M,m=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(m=n[(n[s>>2]|0)+m>>2]|0),ku(B,c),c=+Qu(B,c),ku(k,f),f=+Qu(k,f),ZA(Q,d),Q=$A(Q,d)|0,I7[m&1](s,c,f,Q),C=M}function ku(s,l){s=s|0,l=+l}function Qu(s,l){return s=s|0,l=+l,+ +zDe(l)}function ZA(s,l){s=s|0,l=l|0}function $A(s,l){return s=s|0,l=l|0,JDe(l)|0}function JDe(s){return s=s|0,s|0}function zDe(s){return s=+s,+s}function XDe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],ZDe(s,c,d,1),C=f}function ZDe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=TF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=$De(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,eSe(m,f)|0,f),C=d}function TF(){var s=0,l=0;if(o[7648]|0||(c9(9268),tr(27,9268,U|0)|0,l=7648,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9268)|0)){s=9268,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));c9(9268)}return 9268}function $De(s){return s=s|0,0}function eSe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=TF()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],l9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(tSe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function l9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function tSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=rSe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,nSe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],l9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,iSe(s,k),sSe(k),C=M;return}}function rSe(s){return s=s|0,357913941}function nSe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function iSe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function sSe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function c9(s){s=s|0,lSe(s)}function oSe(s){s=s|0,aSe(s+24|0)}function aSe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function lSe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,4,l,cSe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function cSe(){return 1160}function uSe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0;return c=C,C=C+16|0,f=c+8|0,d=c,m=ASe(s)|0,s=n[m+4>>2]|0,n[d>>2]=n[m>>2],n[d+4>>2]=s,n[f>>2]=n[d>>2],n[f+4>>2]=n[d+4>>2],l=fSe(l,f)|0,C=c,l|0}function ASe(s){return s=s|0,(n[(TF()|0)+24>>2]|0)+(s*12|0)|0}function fSe(s,l){s=s|0,l=l|0;var c=0;return c=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(c=n[(n[s>>2]|0)+c>>2]|0),u9(Lg[c&31](s)|0)|0}function u9(s){return s=s|0,s&1|0}function pSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],hSe(s,c,d,0),C=f}function hSe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=NF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=gSe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,dSe(m,f)|0,f),C=d}function NF(){var s=0,l=0;if(o[7656]|0||(f9(9304),tr(28,9304,U|0)|0,l=7656,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9304)|0)){s=9304,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));f9(9304)}return 9304}function gSe(s){return s=s|0,0}function dSe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=NF()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],A9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(mSe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function A9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function mSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=ySe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,ESe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],A9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,CSe(s,k),wSe(k),C=M;return}}function ySe(s){return s=s|0,357913941}function ESe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function CSe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function wSe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function f9(s){s=s|0,vSe(s)}function ISe(s){s=s|0,BSe(s+24|0)}function BSe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function vSe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,5,l,DSe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function DSe(){return 1164}function SSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;f=C,C=C+16|0,d=f+8|0,m=f,B=PSe(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],bSe(l,d,c),C=f}function PSe(s){return s=s|0,(n[(NF()|0)+24>>2]|0)+(s*12|0)|0}function bSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;m=C,C=C+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),Dp(d,c),c=Sp(d,c)|0,rf[f&31](s,c),Pp(d),C=m}function Dp(s,l){s=s|0,l=l|0,xSe(s,l)}function Sp(s,l){return s=s|0,l=l|0,s|0}function Pp(s){s=s|0,GA(s)}function xSe(s,l){s=s|0,l=l|0,LF(s,l)}function LF(s,l){s=s|0,l=l|0,n[s>>2]=l}function kSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],QSe(s,c,d,0),C=f}function QSe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=OF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=FSe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,RSe(m,f)|0,f),C=d}function OF(){var s=0,l=0;if(o[7664]|0||(h9(9340),tr(29,9340,U|0)|0,l=7664,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9340)|0)){s=9340,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));h9(9340)}return 9340}function FSe(s){return s=s|0,0}function RSe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=OF()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],p9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(TSe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function p9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function TSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=NSe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,LSe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],p9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,OSe(s,k),MSe(k),C=M;return}}function NSe(s){return s=s|0,357913941}function LSe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function OSe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function MSe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function h9(s){s=s|0,HSe(s)}function USe(s){s=s|0,_Se(s+24|0)}function _Se(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function HSe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,4,l,jSe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function jSe(){return 1180}function GSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=qSe(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],c=YSe(l,d,c)|0,C=f,c|0}function qSe(s){return s=s|0,(n[(OF()|0)+24>>2]|0)+(s*12|0)|0}function YSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;return m=C,C=C+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),bg(d,c),d=xg(d,c)|0,d=sD(RR[f&15](s,d)|0)|0,C=m,d|0}function bg(s,l){s=s|0,l=l|0}function xg(s,l){return s=s|0,l=l|0,WSe(l)|0}function sD(s){return s=s|0,s|0}function WSe(s){return s=s|0,s|0}function KSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],VSe(s,c,d,0),C=f}function VSe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=MF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=JSe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,zSe(m,f)|0,f),C=d}function MF(){var s=0,l=0;if(o[7672]|0||(d9(9376),tr(30,9376,U|0)|0,l=7672,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9376)|0)){s=9376,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));d9(9376)}return 9376}function JSe(s){return s=s|0,0}function zSe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=MF()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],g9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(XSe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function g9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function XSe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=ZSe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,$Se(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],g9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,ePe(s,k),tPe(k),C=M;return}}function ZSe(s){return s=s|0,357913941}function $Se(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function ePe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function tPe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function d9(s){s=s|0,iPe(s)}function rPe(s){s=s|0,nPe(s+24|0)}function nPe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function iPe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,5,l,m9()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function m9(){return 1196}function sPe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0;return c=C,C=C+16|0,f=c+8|0,d=c,m=oPe(s)|0,s=n[m+4>>2]|0,n[d>>2]=n[m>>2],n[d+4>>2]=s,n[f>>2]=n[d>>2],n[f+4>>2]=n[d+4>>2],l=aPe(l,f)|0,C=c,l|0}function oPe(s){return s=s|0,(n[(MF()|0)+24>>2]|0)+(s*12|0)|0}function aPe(s,l){s=s|0,l=l|0;var c=0;return c=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(c=n[(n[s>>2]|0)+c>>2]|0),sD(Lg[c&31](s)|0)|0}function lPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],cPe(s,c,d,1),C=f}function cPe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=UF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=uPe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,APe(m,f)|0,f),C=d}function UF(){var s=0,l=0;if(o[7680]|0||(E9(9412),tr(31,9412,U|0)|0,l=7680,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9412)|0)){s=9412,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));E9(9412)}return 9412}function uPe(s){return s=s|0,0}function APe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=UF()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],y9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(fPe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function y9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function fPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=pPe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,hPe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],y9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,gPe(s,k),dPe(k),C=M;return}}function pPe(s){return s=s|0,357913941}function hPe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function gPe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function dPe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function E9(s){s=s|0,EPe(s)}function mPe(s){s=s|0,yPe(s+24|0)}function yPe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function EPe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,6,l,C9()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function C9(){return 1200}function CPe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0;return c=C,C=C+16|0,f=c+8|0,d=c,m=wPe(s)|0,s=n[m+4>>2]|0,n[d>>2]=n[m>>2],n[d+4>>2]=s,n[f>>2]=n[d>>2],n[f+4>>2]=n[d+4>>2],l=IPe(l,f)|0,C=c,l|0}function wPe(s){return s=s|0,(n[(UF()|0)+24>>2]|0)+(s*12|0)|0}function IPe(s,l){s=s|0,l=l|0;var c=0;return c=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(c=n[(n[s>>2]|0)+c>>2]|0),oD(Lg[c&31](s)|0)|0}function oD(s){return s=s|0,s|0}function BPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],vPe(s,c,d,0),C=f}function vPe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=_F()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=DPe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,SPe(m,f)|0,f),C=d}function _F(){var s=0,l=0;if(o[7688]|0||(I9(9448),tr(32,9448,U|0)|0,l=7688,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9448)|0)){s=9448,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));I9(9448)}return 9448}function DPe(s){return s=s|0,0}function SPe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=_F()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],w9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(PPe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function w9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function PPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=bPe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,xPe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],w9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,kPe(s,k),QPe(k),C=M;return}}function bPe(s){return s=s|0,357913941}function xPe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function kPe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function QPe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function I9(s){s=s|0,TPe(s)}function FPe(s){s=s|0,RPe(s+24|0)}function RPe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function TPe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,6,l,B9()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function B9(){return 1204}function NPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;f=C,C=C+16|0,d=f+8|0,m=f,B=LPe(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],OPe(l,d,c),C=f}function LPe(s){return s=s|0,(n[(_F()|0)+24>>2]|0)+(s*12|0)|0}function OPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;m=C,C=C+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),HF(d,c),d=jF(d,c)|0,rf[f&31](s,d),C=m}function HF(s,l){s=s|0,l=l|0}function jF(s,l){return s=s|0,l=l|0,MPe(l)|0}function MPe(s){return s=s|0,s|0}function UPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],_Pe(s,c,d,0),C=f}function _Pe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=GF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=HPe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,jPe(m,f)|0,f),C=d}function GF(){var s=0,l=0;if(o[7696]|0||(D9(9484),tr(33,9484,U|0)|0,l=7696,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9484)|0)){s=9484,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));D9(9484)}return 9484}function HPe(s){return s=s|0,0}function jPe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=GF()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],v9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(GPe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function v9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function GPe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=qPe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,YPe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],v9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,WPe(s,k),KPe(k),C=M;return}}function qPe(s){return s=s|0,357913941}function YPe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function WPe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function KPe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function D9(s){s=s|0,zPe(s)}function VPe(s){s=s|0,JPe(s+24|0)}function JPe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function zPe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,1,l,XPe()|0,2),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function XPe(){return 1212}function ZPe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;d=C,C=C+16|0,m=d+8|0,B=d,k=$Pe(s)|0,s=n[k+4>>2]|0,n[B>>2]=n[k>>2],n[B+4>>2]=s,n[m>>2]=n[B>>2],n[m+4>>2]=n[B+4>>2],ebe(l,m,c,f),C=d}function $Pe(s){return s=s|0,(n[(GF()|0)+24>>2]|0)+(s*12|0)|0}function ebe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;k=C,C=C+16|0,m=k+1|0,B=k,d=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(d=n[(n[s>>2]|0)+d>>2]|0),HF(m,c),m=jF(m,c)|0,bg(B,f),B=xg(B,f)|0,Hw[d&15](s,m,B),C=k}function tbe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],rbe(s,c,d,1),C=f}function rbe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=qF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=nbe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,ibe(m,f)|0,f),C=d}function qF(){var s=0,l=0;if(o[7704]|0||(P9(9520),tr(34,9520,U|0)|0,l=7704,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9520)|0)){s=9520,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));P9(9520)}return 9520}function nbe(s){return s=s|0,0}function ibe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=qF()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],S9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(sbe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function S9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function sbe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=obe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,abe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],S9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,lbe(s,k),cbe(k),C=M;return}}function obe(s){return s=s|0,357913941}function abe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function lbe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function cbe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function P9(s){s=s|0,fbe(s)}function ube(s){s=s|0,Abe(s+24|0)}function Abe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function fbe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,1,l,pbe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function pbe(){return 1224}function hbe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;return d=C,C=C+16|0,m=d+8|0,B=d,k=gbe(s)|0,s=n[k+4>>2]|0,n[B>>2]=n[k>>2],n[B+4>>2]=s,n[m>>2]=n[B>>2],n[m+4>>2]=n[B+4>>2],f=+dbe(l,m,c),C=d,+f}function gbe(s){return s=s|0,(n[(qF()|0)+24>>2]|0)+(s*12|0)|0}function dbe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return m=C,C=C+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),ZA(d,c),d=$A(d,c)|0,B=+SF(+v7[f&7](s,d)),C=m,+B}function mbe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],ybe(s,c,d,1),C=f}function ybe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=YF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=Ebe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,Cbe(m,f)|0,f),C=d}function YF(){var s=0,l=0;if(o[7712]|0||(x9(9556),tr(35,9556,U|0)|0,l=7712,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9556)|0)){s=9556,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));x9(9556)}return 9556}function Ebe(s){return s=s|0,0}function Cbe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=YF()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],b9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(wbe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function b9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function wbe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=Ibe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,Bbe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],b9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,vbe(s,k),Dbe(k),C=M;return}}function Ibe(s){return s=s|0,357913941}function Bbe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function vbe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function Dbe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function x9(s){s=s|0,bbe(s)}function Sbe(s){s=s|0,Pbe(s+24|0)}function Pbe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function bbe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,5,l,xbe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function xbe(){return 1232}function kbe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=Qbe(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],c=+Fbe(l,d),C=f,+c}function Qbe(s){return s=s|0,(n[(YF()|0)+24>>2]|0)+(s*12|0)|0}function Fbe(s,l){s=s|0,l=l|0;var c=0;return c=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(c=n[(n[s>>2]|0)+c>>2]|0),+ +SF(+B7[c&15](s))}function Rbe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Tbe(s,c,d,1),C=f}function Tbe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=WF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=Nbe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,Lbe(m,f)|0,f),C=d}function WF(){var s=0,l=0;if(o[7720]|0||(Q9(9592),tr(36,9592,U|0)|0,l=7720,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9592)|0)){s=9592,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));Q9(9592)}return 9592}function Nbe(s){return s=s|0,0}function Lbe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=WF()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],k9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(Obe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function k9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function Obe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=Mbe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,Ube(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],k9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,_be(s,k),Hbe(k),C=M;return}}function Mbe(s){return s=s|0,357913941}function Ube(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function _be(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function Hbe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function Q9(s){s=s|0,qbe(s)}function jbe(s){s=s|0,Gbe(s+24|0)}function Gbe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function qbe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,7,l,Ybe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function Ybe(){return 1276}function Wbe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0;return c=C,C=C+16|0,f=c+8|0,d=c,m=Kbe(s)|0,s=n[m+4>>2]|0,n[d>>2]=n[m>>2],n[d+4>>2]=s,n[f>>2]=n[d>>2],n[f+4>>2]=n[d+4>>2],l=Vbe(l,f)|0,C=c,l|0}function Kbe(s){return s=s|0,(n[(WF()|0)+24>>2]|0)+(s*12|0)|0}function Vbe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0;return d=C,C=C+16|0,f=d,c=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(c=n[(n[s>>2]|0)+c>>2]|0),rf[c&31](f,s),f=F9(f)|0,C=d,f|0}function F9(s){s=s|0;var l=0,c=0,f=0,d=0;return d=C,C=C+32|0,l=d+12|0,c=d,f=xF(R9()|0)|0,f?(kF(l,f),QF(c,l),Jbe(s,c),s=FF(l)|0):s=zbe(s)|0,C=d,s|0}function R9(){var s=0;return o[7736]|0||(oxe(9640),tr(25,9640,U|0)|0,s=7736,n[s>>2]=1,n[s+4>>2]=0),9640}function Jbe(s,l){s=s|0,l=l|0,exe(l,s,s+8|0)|0}function zbe(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0;return c=C,C=C+16|0,d=c+4|0,B=c,f=Wa(8)|0,l=f,k=Kt(16)|0,n[k>>2]=n[s>>2],n[k+4>>2]=n[s+4>>2],n[k+8>>2]=n[s+8>>2],n[k+12>>2]=n[s+12>>2],m=l+4|0,n[m>>2]=k,s=Kt(8)|0,m=n[m>>2]|0,n[B>>2]=0,n[d>>2]=n[B>>2],KF(s,m,d),n[f>>2]=s,C=c,l|0}function KF(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,c=Kt(16)|0,n[c+4>>2]=0,n[c+8>>2]=0,n[c>>2]=1244,n[c+12>>2]=l,n[s+4>>2]=c}function Xbe(s){s=s|0,zm(s),gt(s)}function Zbe(s){s=s|0,s=n[s+12>>2]|0,s|0&>(s)}function $be(s){s=s|0,gt(s)}function exe(s,l,c){return s=s|0,l=l|0,c=c|0,l=txe(n[s>>2]|0,l,c)|0,c=s+4|0,n[(n[c>>2]|0)+8>>2]=l,n[(n[c>>2]|0)+8>>2]|0}function txe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;return f=C,C=C+16|0,d=f,Ka(d),s=da(s)|0,c=rxe(s,n[l>>2]|0,+E[c>>3])|0,Va(d),C=f,c|0}function rxe(s,l,c){s=s|0,l=l|0,c=+c;var f=0;return f=Sl(nxe()|0)|0,l=DF(l)|0,ml(0,f|0,s|0,l|0,+ +JA(c))|0}function nxe(){var s=0;return o[7728]|0||(ixe(9628),s=7728,n[s>>2]=1,n[s+4>>2]=0),9628}function ixe(s){s=s|0,Pl(s,sxe()|0,2)}function sxe(){return 1264}function oxe(s){s=s|0,vp(s)}function axe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],lxe(s,c,d,1),C=f}function lxe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=VF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=cxe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,uxe(m,f)|0,f),C=d}function VF(){var s=0,l=0;if(o[7744]|0||(N9(9684),tr(37,9684,U|0)|0,l=7744,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9684)|0)){s=9684,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));N9(9684)}return 9684}function cxe(s){return s=s|0,0}function uxe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=VF()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],T9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(Axe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function T9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function Axe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=fxe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,pxe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],T9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,hxe(s,k),gxe(k),C=M;return}}function fxe(s){return s=s|0,357913941}function pxe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function hxe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function gxe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function N9(s){s=s|0,yxe(s)}function dxe(s){s=s|0,mxe(s+24|0)}function mxe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function yxe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,5,l,Exe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function Exe(){return 1280}function Cxe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=wxe(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],c=Ixe(l,d,c)|0,C=f,c|0}function wxe(s){return s=s|0,(n[(VF()|0)+24>>2]|0)+(s*12|0)|0}function Ixe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return B=C,C=C+32|0,d=B,m=B+16|0,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),ZA(m,c),m=$A(m,c)|0,Hw[f&15](d,s,m),m=F9(d)|0,C=B,m|0}function Bxe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],vxe(s,c,d,1),C=f}function vxe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=JF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=Dxe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,Sxe(m,f)|0,f),C=d}function JF(){var s=0,l=0;if(o[7752]|0||(O9(9720),tr(38,9720,U|0)|0,l=7752,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9720)|0)){s=9720,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));O9(9720)}return 9720}function Dxe(s){return s=s|0,0}function Sxe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=JF()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],L9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(Pxe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function L9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function Pxe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=bxe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,xxe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],L9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,kxe(s,k),Qxe(k),C=M;return}}function bxe(s){return s=s|0,357913941}function xxe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function kxe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function Qxe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function O9(s){s=s|0,Txe(s)}function Fxe(s){s=s|0,Rxe(s+24|0)}function Rxe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function Txe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,8,l,Nxe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function Nxe(){return 1288}function Lxe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0;return c=C,C=C+16|0,f=c+8|0,d=c,m=Oxe(s)|0,s=n[m+4>>2]|0,n[d>>2]=n[m>>2],n[d+4>>2]=s,n[f>>2]=n[d>>2],n[f+4>>2]=n[d+4>>2],l=Mxe(l,f)|0,C=c,l|0}function Oxe(s){return s=s|0,(n[(JF()|0)+24>>2]|0)+(s*12|0)|0}function Mxe(s,l){s=s|0,l=l|0;var c=0;return c=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(c=n[(n[s>>2]|0)+c>>2]|0),Zq(Lg[c&31](s)|0)|0}function Uxe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],_xe(s,c,d,0),C=f}function _xe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=zF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=Hxe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,jxe(m,f)|0,f),C=d}function zF(){var s=0,l=0;if(o[7760]|0||(U9(9756),tr(39,9756,U|0)|0,l=7760,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9756)|0)){s=9756,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));U9(9756)}return 9756}function Hxe(s){return s=s|0,0}function jxe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=zF()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],M9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(Gxe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function M9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function Gxe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=qxe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,Yxe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],M9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,Wxe(s,k),Kxe(k),C=M;return}}function qxe(s){return s=s|0,357913941}function Yxe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function Wxe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function Kxe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function U9(s){s=s|0,zxe(s)}function Vxe(s){s=s|0,Jxe(s+24|0)}function Jxe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function zxe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,8,l,Xxe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function Xxe(){return 1292}function Zxe(s,l,c){s=s|0,l=l|0,c=+c;var f=0,d=0,m=0,B=0;f=C,C=C+16|0,d=f+8|0,m=f,B=$xe(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],eke(l,d,c),C=f}function $xe(s){return s=s|0,(n[(zF()|0)+24>>2]|0)+(s*12|0)|0}function eke(s,l,c){s=s|0,l=l|0,c=+c;var f=0,d=0,m=0;m=C,C=C+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),ku(d,c),c=+Qu(d,c),C7[f&31](s,c),C=m}function tke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],rke(s,c,d,0),C=f}function rke(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=XF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=nke(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,ike(m,f)|0,f),C=d}function XF(){var s=0,l=0;if(o[7768]|0||(H9(9792),tr(40,9792,U|0)|0,l=7768,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9792)|0)){s=9792,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));H9(9792)}return 9792}function nke(s){return s=s|0,0}function ike(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=XF()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],_9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(ske(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function _9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function ske(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=oke(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,ake(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],_9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,lke(s,k),cke(k),C=M;return}}function oke(s){return s=s|0,357913941}function ake(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function lke(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function cke(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function H9(s){s=s|0,fke(s)}function uke(s){s=s|0,Ake(s+24|0)}function Ake(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function fke(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,1,l,pke()|0,2),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function pke(){return 1300}function hke(s,l,c,f){s=s|0,l=l|0,c=c|0,f=+f;var d=0,m=0,B=0,k=0;d=C,C=C+16|0,m=d+8|0,B=d,k=gke(s)|0,s=n[k+4>>2]|0,n[B>>2]=n[k>>2],n[B+4>>2]=s,n[m>>2]=n[B>>2],n[m+4>>2]=n[B+4>>2],dke(l,m,c,f),C=d}function gke(s){return s=s|0,(n[(XF()|0)+24>>2]|0)+(s*12|0)|0}function dke(s,l,c,f){s=s|0,l=l|0,c=c|0,f=+f;var d=0,m=0,B=0,k=0;k=C,C=C+16|0,m=k+1|0,B=k,d=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(d=n[(n[s>>2]|0)+d>>2]|0),ZA(m,c),m=$A(m,c)|0,ku(B,f),f=+Qu(B,f),b7[d&15](s,m,f),C=k}function mke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],yke(s,c,d,0),C=f}function yke(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=ZF()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=Eke(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,Cke(m,f)|0,f),C=d}function ZF(){var s=0,l=0;if(o[7776]|0||(G9(9828),tr(41,9828,U|0)|0,l=7776,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9828)|0)){s=9828,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));G9(9828)}return 9828}function Eke(s){return s=s|0,0}function Cke(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=ZF()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],j9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(wke(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function j9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function wke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=Ike(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,Bke(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],j9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,vke(s,k),Dke(k),C=M;return}}function Ike(s){return s=s|0,357913941}function Bke(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function vke(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function Dke(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function G9(s){s=s|0,bke(s)}function Ske(s){s=s|0,Pke(s+24|0)}function Pke(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function bke(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,7,l,xke()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function xke(){return 1312}function kke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;f=C,C=C+16|0,d=f+8|0,m=f,B=Qke(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Fke(l,d,c),C=f}function Qke(s){return s=s|0,(n[(ZF()|0)+24>>2]|0)+(s*12|0)|0}function Fke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;m=C,C=C+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),ZA(d,c),d=$A(d,c)|0,rf[f&31](s,d),C=m}function Rke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Tke(s,c,d,0),C=f}function Tke(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=$F()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=Nke(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,Lke(m,f)|0,f),C=d}function $F(){var s=0,l=0;if(o[7784]|0||(Y9(9864),tr(42,9864,U|0)|0,l=7784,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9864)|0)){s=9864,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));Y9(9864)}return 9864}function Nke(s){return s=s|0,0}function Lke(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=$F()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],q9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(Oke(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function q9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function Oke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=Mke(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,Uke(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],q9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,_ke(s,k),Hke(k),C=M;return}}function Mke(s){return s=s|0,357913941}function Uke(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function _ke(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function Hke(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function Y9(s){s=s|0,qke(s)}function jke(s){s=s|0,Gke(s+24|0)}function Gke(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function qke(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,8,l,Yke()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function Yke(){return 1320}function Wke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;f=C,C=C+16|0,d=f+8|0,m=f,B=Kke(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],Vke(l,d,c),C=f}function Kke(s){return s=s|0,(n[($F()|0)+24>>2]|0)+(s*12|0)|0}function Vke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;m=C,C=C+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),Jke(d,c),d=zke(d,c)|0,rf[f&31](s,d),C=m}function Jke(s,l){s=s|0,l=l|0}function zke(s,l){return s=s|0,l=l|0,Xke(l)|0}function Xke(s){return s=s|0,s|0}function Zke(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],$ke(s,c,d,0),C=f}function $ke(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=eR()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=eQe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,tQe(m,f)|0,f),C=d}function eR(){var s=0,l=0;if(o[7792]|0||(K9(9900),tr(43,9900,U|0)|0,l=7792,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9900)|0)){s=9900,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));K9(9900)}return 9900}function eQe(s){return s=s|0,0}function tQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=eR()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],W9(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(rQe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function W9(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function rQe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=nQe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,iQe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],W9(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,sQe(s,k),oQe(k),C=M;return}}function nQe(s){return s=s|0,357913941}function iQe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function sQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function oQe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function K9(s){s=s|0,cQe(s)}function aQe(s){s=s|0,lQe(s+24|0)}function lQe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function cQe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,22,l,uQe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function uQe(){return 1344}function AQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0;c=C,C=C+16|0,f=c+8|0,d=c,m=fQe(s)|0,s=n[m+4>>2]|0,n[d>>2]=n[m>>2],n[d+4>>2]=s,n[f>>2]=n[d>>2],n[f+4>>2]=n[d+4>>2],pQe(l,f),C=c}function fQe(s){return s=s|0,(n[(eR()|0)+24>>2]|0)+(s*12|0)|0}function pQe(s,l){s=s|0,l=l|0;var c=0;c=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(c=n[(n[s>>2]|0)+c>>2]|0),tf[c&127](s)}function hQe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=tR()|0,s=gQe(c)|0,hn(m,l,d,s,dQe(c,f)|0,f)}function tR(){var s=0,l=0;if(o[7800]|0||(J9(9936),tr(44,9936,U|0)|0,l=7800,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9936)|0)){s=9936,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));J9(9936)}return 9936}function gQe(s){return s=s|0,s|0}function dQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=C,C=C+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=tR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(V9(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(mQe(B,d,m),l=n[c>>2]|0),C=k,(l-(n[B>>2]|0)>>3)+-1|0}function V9(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function mQe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=C,C=C+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=yQe(s)|0,f>>>0<B>>>0)zr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,EQe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,V9(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,CQe(s,d),wQe(d),C=k;return}}function yQe(s){return s=s|0,536870911}function EQe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function CQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function wQe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function J9(s){s=s|0,vQe(s)}function IQe(s){s=s|0,BQe(s+24|0)}function BQe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function vQe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,23,l,B9()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function DQe(s,l){s=s|0,l=l|0,PQe(n[(SQe(s)|0)>>2]|0,l)}function SQe(s){return s=s|0,(n[(tR()|0)+24>>2]|0)+(s<<3)|0}function PQe(s,l){s=s|0,l=l|0;var c=0,f=0;c=C,C=C+16|0,f=c,HF(f,l),l=jF(f,l)|0,tf[s&127](l),C=c}function bQe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=rR()|0,s=xQe(c)|0,hn(m,l,d,s,kQe(c,f)|0,f)}function rR(){var s=0,l=0;if(o[7808]|0||(X9(9972),tr(45,9972,U|0)|0,l=7808,n[l>>2]=1,n[l+4>>2]=0),!(Tr(9972)|0)){s=9972,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));X9(9972)}return 9972}function xQe(s){return s=s|0,s|0}function kQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=C,C=C+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=rR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(z9(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(QQe(B,d,m),l=n[c>>2]|0),C=k,(l-(n[B>>2]|0)>>3)+-1|0}function z9(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function QQe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=C,C=C+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=FQe(s)|0,f>>>0<B>>>0)zr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,RQe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,z9(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,TQe(s,d),NQe(d),C=k;return}}function FQe(s){return s=s|0,536870911}function RQe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function TQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function NQe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function X9(s){s=s|0,MQe(s)}function LQe(s){s=s|0,OQe(s+24|0)}function OQe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function MQe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,9,l,UQe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function UQe(){return 1348}function _Qe(s,l){return s=s|0,l=l|0,jQe(n[(HQe(s)|0)>>2]|0,l)|0}function HQe(s){return s=s|0,(n[(rR()|0)+24>>2]|0)+(s<<3)|0}function jQe(s,l){s=s|0,l=l|0;var c=0,f=0;return c=C,C=C+16|0,f=c,Z9(f,l),l=$9(f,l)|0,l=sD(Lg[s&31](l)|0)|0,C=c,l|0}function Z9(s,l){s=s|0,l=l|0}function $9(s,l){return s=s|0,l=l|0,GQe(l)|0}function GQe(s){return s=s|0,s|0}function qQe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=nR()|0,s=YQe(c)|0,hn(m,l,d,s,WQe(c,f)|0,f)}function nR(){var s=0,l=0;if(o[7816]|0||(t5(10008),tr(46,10008,U|0)|0,l=7816,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10008)|0)){s=10008,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));t5(10008)}return 10008}function YQe(s){return s=s|0,s|0}function WQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=C,C=C+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=nR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(e5(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(KQe(B,d,m),l=n[c>>2]|0),C=k,(l-(n[B>>2]|0)>>3)+-1|0}function e5(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function KQe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=C,C=C+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=VQe(s)|0,f>>>0<B>>>0)zr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,JQe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,e5(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,zQe(s,d),XQe(d),C=k;return}}function VQe(s){return s=s|0,536870911}function JQe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function zQe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function XQe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function t5(s){s=s|0,eFe(s)}function ZQe(s){s=s|0,$Qe(s+24|0)}function $Qe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function eFe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,15,l,m9()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function tFe(s){return s=s|0,nFe(n[(rFe(s)|0)>>2]|0)|0}function rFe(s){return s=s|0,(n[(nR()|0)+24>>2]|0)+(s<<3)|0}function nFe(s){return s=s|0,sD(CD[s&7]()|0)|0}function iFe(){var s=0;return o[7832]|0||(fFe(10052),tr(25,10052,U|0)|0,s=7832,n[s>>2]=1,n[s+4>>2]=0),10052}function sFe(s,l){s=s|0,l=l|0,n[s>>2]=oFe()|0,n[s+4>>2]=aFe()|0,n[s+12>>2]=l,n[s+8>>2]=lFe()|0,n[s+32>>2]=2}function oFe(){return 11709}function aFe(){return 1188}function lFe(){return aD()|0}function cFe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,(bp(f,896)|0)==512?c|0&&(uFe(c),gt(c)):l|0&&(Su(l),gt(l))}function bp(s,l){return s=s|0,l=l|0,l&s|0}function uFe(s){s=s|0,s=n[s+4>>2]|0,s|0&&kp(s)}function aD(){var s=0;return o[7824]|0||(n[2511]=AFe()|0,n[2512]=0,s=7824,n[s>>2]=1,n[s+4>>2]=0),10044}function AFe(){return 0}function fFe(s){s=s|0,vp(s)}function pFe(s){s=s|0;var l=0,c=0,f=0,d=0,m=0;l=C,C=C+32|0,c=l+24|0,m=l+16|0,d=l+8|0,f=l,hFe(s,4827),gFe(s,4834,3)|0,dFe(s,3682,47)|0,n[m>>2]=9,n[m+4>>2]=0,n[c>>2]=n[m>>2],n[c+4>>2]=n[m+4>>2],mFe(s,4841,c)|0,n[d>>2]=1,n[d+4>>2]=0,n[c>>2]=n[d>>2],n[c+4>>2]=n[d+4>>2],yFe(s,4871,c)|0,n[f>>2]=10,n[f+4>>2]=0,n[c>>2]=n[f>>2],n[c+4>>2]=n[f+4>>2],EFe(s,4891,c)|0,C=l}function hFe(s,l){s=s|0,l=l|0;var c=0;c=XRe()|0,n[s>>2]=c,ZRe(c,l),xp(n[s>>2]|0)}function gFe(s,l,c){return s=s|0,l=l|0,c=c|0,NRe(s,pn(l)|0,c,0),s|0}function dFe(s,l,c){return s=s|0,l=l|0,c=c|0,CRe(s,pn(l)|0,c,0),s|0}function mFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],tRe(s,l,d),C=f,s|0}function yFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],LFe(s,l,d),C=f,s|0}function EFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=n[c+4>>2]|0,n[m>>2]=n[c>>2],n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],CFe(s,l,d),C=f,s|0}function CFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],wFe(s,c,d,1),C=f}function wFe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=iR()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=IFe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,BFe(m,f)|0,f),C=d}function iR(){var s=0,l=0;if(o[7840]|0||(n5(10100),tr(48,10100,U|0)|0,l=7840,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10100)|0)){s=10100,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));n5(10100)}return 10100}function IFe(s){return s=s|0,0}function BFe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=iR()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],r5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(vFe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function r5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function vFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=DFe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,SFe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],r5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,PFe(s,k),bFe(k),C=M;return}}function DFe(s){return s=s|0,357913941}function SFe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function PFe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function bFe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function n5(s){s=s|0,QFe(s)}function xFe(s){s=s|0,kFe(s+24|0)}function kFe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function QFe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,6,l,FFe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function FFe(){return 1364}function RFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;return f=C,C=C+16|0,d=f+8|0,m=f,B=TFe(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],c=NFe(l,d,c)|0,C=f,c|0}function TFe(s){return s=s|0,(n[(iR()|0)+24>>2]|0)+(s*12|0)|0}function NFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;return m=C,C=C+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),ZA(d,c),d=$A(d,c)|0,d=u9(RR[f&15](s,d)|0)|0,C=m,d|0}function LFe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],OFe(s,c,d,0),C=f}function OFe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=sR()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=MFe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,UFe(m,f)|0,f),C=d}function sR(){var s=0,l=0;if(o[7848]|0||(s5(10136),tr(49,10136,U|0)|0,l=7848,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10136)|0)){s=10136,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));s5(10136)}return 10136}function MFe(s){return s=s|0,0}function UFe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=sR()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],i5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(_Fe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function i5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function _Fe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=HFe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,jFe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],i5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,GFe(s,k),qFe(k),C=M;return}}function HFe(s){return s=s|0,357913941}function jFe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function GFe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function qFe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function s5(s){s=s|0,KFe(s)}function YFe(s){s=s|0,WFe(s+24|0)}function WFe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function KFe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,9,l,VFe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function VFe(){return 1372}function JFe(s,l,c){s=s|0,l=l|0,c=+c;var f=0,d=0,m=0,B=0;f=C,C=C+16|0,d=f+8|0,m=f,B=zFe(s)|0,s=n[B+4>>2]|0,n[m>>2]=n[B>>2],n[m+4>>2]=s,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],XFe(l,d,c),C=f}function zFe(s){return s=s|0,(n[(sR()|0)+24>>2]|0)+(s*12|0)|0}function XFe(s,l,c){s=s|0,l=l|0,c=+c;var f=0,d=0,m=0,B=Ze;m=C,C=C+16|0,d=m,f=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(f=n[(n[s>>2]|0)+f>>2]|0),ZFe(d,c),B=y($Fe(d,c)),E7[f&1](s,B),C=m}function ZFe(s,l){s=s|0,l=+l}function $Fe(s,l){return s=s|0,l=+l,y(eRe(l))}function eRe(s){return s=+s,y(s)}function tRe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,d=f+8|0,m=f,k=n[c>>2]|0,B=n[c+4>>2]|0,c=pn(l)|0,n[m>>2]=k,n[m+4>>2]=B,n[d>>2]=n[m>>2],n[d+4>>2]=n[m+4>>2],rRe(s,c,d,0),C=f}function rRe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0,Q=0,M=0,O=0;d=C,C=C+32|0,m=d+16|0,O=d+8|0,k=d,M=n[c>>2]|0,Q=n[c+4>>2]|0,B=n[s>>2]|0,s=oR()|0,n[O>>2]=M,n[O+4>>2]=Q,n[m>>2]=n[O>>2],n[m+4>>2]=n[O+4>>2],c=nRe(m)|0,n[k>>2]=M,n[k+4>>2]=Q,n[m>>2]=n[k>>2],n[m+4>>2]=n[k+4>>2],hn(B,l,s,c,iRe(m,f)|0,f),C=d}function oR(){var s=0,l=0;if(o[7856]|0||(a5(10172),tr(50,10172,U|0)|0,l=7856,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10172)|0)){s=10172,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));a5(10172)}return 10172}function nRe(s){return s=s|0,0}function iRe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0;return O=C,C=C+32|0,d=O+24|0,B=O+16|0,k=O,Q=O+8|0,m=n[s>>2]|0,f=n[s+4>>2]|0,n[k>>2]=m,n[k+4>>2]=f,G=oR()|0,M=G+24|0,s=gr(l,4)|0,n[Q>>2]=s,l=G+28|0,c=n[l>>2]|0,c>>>0<(n[G+32>>2]|0)>>>0?(n[B>>2]=m,n[B+4>>2]=f,n[d>>2]=n[B>>2],n[d+4>>2]=n[B+4>>2],o5(c,d,s),s=(n[l>>2]|0)+12|0,n[l>>2]=s):(sRe(M,k,Q),s=n[l>>2]|0),C=O,((s-(n[M>>2]|0)|0)/12|0)+-1|0}function o5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=n[l+4>>2]|0,n[s>>2]=n[l>>2],n[s+4>>2]=f,n[s+8>>2]=c}function sRe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;if(M=C,C=C+48|0,f=M+32|0,B=M+24|0,k=M,Q=s+4|0,d=(((n[Q>>2]|0)-(n[s>>2]|0)|0)/12|0)+1|0,m=oRe(s)|0,m>>>0<d>>>0)zr(s);else{O=n[s>>2]|0,se=((n[s+8>>2]|0)-O|0)/12|0,G=se<<1,aRe(k,se>>>0<m>>>1>>>0?G>>>0<d>>>0?d:G:m,((n[Q>>2]|0)-O|0)/12|0,s+8|0),Q=k+8|0,m=n[Q>>2]|0,d=n[l+4>>2]|0,c=n[c>>2]|0,n[B>>2]=n[l>>2],n[B+4>>2]=d,n[f>>2]=n[B>>2],n[f+4>>2]=n[B+4>>2],o5(m,f,c),n[Q>>2]=(n[Q>>2]|0)+12,lRe(s,k),cRe(k),C=M;return}}function oRe(s){return s=s|0,357913941}function aRe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>357913941)Rt();else{d=Kt(l*12|0)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c*12|0)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l*12|0)}function lRe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(((d|0)/-12|0)*12|0)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function cRe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~(((f+-12-l|0)>>>0)/12|0)*12|0)),s=n[s>>2]|0,s|0&>(s)}function a5(s){s=s|0,fRe(s)}function uRe(s){s=s|0,ARe(s+24|0)}function ARe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~(((l+-12-f|0)>>>0)/12|0)*12|0)),gt(c))}function fRe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,2,3,l,pRe()|0,2),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function pRe(){return 1380}function hRe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;d=C,C=C+16|0,m=d+8|0,B=d,k=gRe(s)|0,s=n[k+4>>2]|0,n[B>>2]=n[k>>2],n[B+4>>2]=s,n[m>>2]=n[B>>2],n[m+4>>2]=n[B+4>>2],dRe(l,m,c,f),C=d}function gRe(s){return s=s|0,(n[(oR()|0)+24>>2]|0)+(s*12|0)|0}function dRe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;k=C,C=C+16|0,m=k+1|0,B=k,d=n[l>>2]|0,l=n[l+4>>2]|0,s=s+(l>>1)|0,l&1&&(d=n[(n[s>>2]|0)+d>>2]|0),ZA(m,c),m=$A(m,c)|0,mRe(B,f),B=yRe(B,f)|0,Hw[d&15](s,m,B),C=k}function mRe(s,l){s=s|0,l=l|0}function yRe(s,l){return s=s|0,l=l|0,ERe(l)|0}function ERe(s){return s=s|0,(s|0)!=0|0}function CRe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=aR()|0,s=wRe(c)|0,hn(m,l,d,s,IRe(c,f)|0,f)}function aR(){var s=0,l=0;if(o[7864]|0||(c5(10208),tr(51,10208,U|0)|0,l=7864,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10208)|0)){s=10208,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));c5(10208)}return 10208}function wRe(s){return s=s|0,s|0}function IRe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=C,C=C+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=aR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(l5(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(BRe(B,d,m),l=n[c>>2]|0),C=k,(l-(n[B>>2]|0)>>3)+-1|0}function l5(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function BRe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=C,C=C+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=vRe(s)|0,f>>>0<B>>>0)zr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,DRe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,l5(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,SRe(s,d),PRe(d),C=k;return}}function vRe(s){return s=s|0,536870911}function DRe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function SRe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function PRe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function c5(s){s=s|0,kRe(s)}function bRe(s){s=s|0,xRe(s+24|0)}function xRe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function kRe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,24,l,QRe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function QRe(){return 1392}function FRe(s,l){s=s|0,l=l|0,TRe(n[(RRe(s)|0)>>2]|0,l)}function RRe(s){return s=s|0,(n[(aR()|0)+24>>2]|0)+(s<<3)|0}function TRe(s,l){s=s|0,l=l|0;var c=0,f=0;c=C,C=C+16|0,f=c,Z9(f,l),l=$9(f,l)|0,tf[s&127](l),C=c}function NRe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=lR()|0,s=LRe(c)|0,hn(m,l,d,s,ORe(c,f)|0,f)}function lR(){var s=0,l=0;if(o[7872]|0||(A5(10244),tr(52,10244,U|0)|0,l=7872,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10244)|0)){s=10244,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));A5(10244)}return 10244}function LRe(s){return s=s|0,s|0}function ORe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=C,C=C+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=lR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(u5(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(MRe(B,d,m),l=n[c>>2]|0),C=k,(l-(n[B>>2]|0)>>3)+-1|0}function u5(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function MRe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=C,C=C+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=URe(s)|0,f>>>0<B>>>0)zr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,_Re(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,u5(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,HRe(s,d),jRe(d),C=k;return}}function URe(s){return s=s|0,536870911}function _Re(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function HRe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function jRe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function A5(s){s=s|0,YRe(s)}function GRe(s){s=s|0,qRe(s+24|0)}function qRe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function YRe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,16,l,WRe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function WRe(){return 1400}function KRe(s){return s=s|0,JRe(n[(VRe(s)|0)>>2]|0)|0}function VRe(s){return s=s|0,(n[(lR()|0)+24>>2]|0)+(s<<3)|0}function JRe(s){return s=s|0,zRe(CD[s&7]()|0)|0}function zRe(s){return s=s|0,s|0}function XRe(){var s=0;return o[7880]|0||(iTe(10280),tr(25,10280,U|0)|0,s=7880,n[s>>2]=1,n[s+4>>2]=0),10280}function ZRe(s,l){s=s|0,l=l|0,n[s>>2]=$Re()|0,n[s+4>>2]=eTe()|0,n[s+12>>2]=l,n[s+8>>2]=tTe()|0,n[s+32>>2]=4}function $Re(){return 11711}function eTe(){return 1356}function tTe(){return aD()|0}function rTe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,(bp(f,896)|0)==512?c|0&&(nTe(c),gt(c)):l|0&&(Dg(l),gt(l))}function nTe(s){s=s|0,s=n[s+4>>2]|0,s|0&&kp(s)}function iTe(s){s=s|0,vp(s)}function sTe(s){s=s|0,oTe(s,4920),aTe(s)|0,lTe(s)|0}function oTe(s,l){s=s|0,l=l|0;var c=0;c=R9()|0,n[s>>2]=c,xTe(c,l),xp(n[s>>2]|0)}function aTe(s){s=s|0;var l=0;return l=n[s>>2]|0,kg(l,ETe()|0),s|0}function lTe(s){s=s|0;var l=0;return l=n[s>>2]|0,kg(l,cTe()|0),s|0}function cTe(){var s=0;return o[7888]|0||(f5(10328),tr(53,10328,U|0)|0,s=7888,n[s>>2]=1,n[s+4>>2]=0),Tr(10328)|0||f5(10328),10328}function kg(s,l){s=s|0,l=l|0,hn(s,0,l,0,0,0)}function f5(s){s=s|0,fTe(s),Qg(s,10)}function uTe(s){s=s|0,ATe(s+24|0)}function ATe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function fTe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,5,1,l,dTe()|0,2),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function pTe(s,l,c){s=s|0,l=l|0,c=+c,hTe(s,l,c)}function Qg(s,l){s=s|0,l=l|0,n[s+20>>2]=l}function hTe(s,l,c){s=s|0,l=l|0,c=+c;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+16|0,m=f+8|0,k=f+13|0,d=f,B=f+12|0,ZA(k,l),n[m>>2]=$A(k,l)|0,ku(B,c),E[d>>3]=+Qu(B,c),gTe(s,m,d),C=f}function gTe(s,l,c){s=s|0,l=l|0,c=c|0,Y(s+8|0,n[l>>2]|0,+E[c>>3]),o[s+24>>0]=1}function dTe(){return 1404}function mTe(s,l){return s=s|0,l=+l,yTe(s,l)|0}function yTe(s,l){s=s|0,l=+l;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return f=C,C=C+16|0,m=f+4|0,B=f+8|0,k=f,d=Wa(8)|0,c=d,Q=Kt(16)|0,ZA(m,s),s=$A(m,s)|0,ku(B,l),Y(Q,s,+Qu(B,l)),B=c+4|0,n[B>>2]=Q,s=Kt(8)|0,B=n[B>>2]|0,n[k>>2]=0,n[m>>2]=n[k>>2],KF(s,B,m),n[d>>2]=s,C=f,c|0}function ETe(){var s=0;return o[7896]|0||(p5(10364),tr(54,10364,U|0)|0,s=7896,n[s>>2]=1,n[s+4>>2]=0),Tr(10364)|0||p5(10364),10364}function p5(s){s=s|0,ITe(s),Qg(s,55)}function CTe(s){s=s|0,wTe(s+24|0)}function wTe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function ITe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,5,4,l,STe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function BTe(s){s=s|0,vTe(s)}function vTe(s){s=s|0,DTe(s)}function DTe(s){s=s|0,h5(s+8|0),o[s+24>>0]=1}function h5(s){s=s|0,n[s>>2]=0,E[s+8>>3]=0}function STe(){return 1424}function PTe(){return bTe()|0}function bTe(){var s=0,l=0,c=0,f=0,d=0,m=0,B=0;return l=C,C=C+16|0,d=l+4|0,B=l,c=Wa(8)|0,s=c,f=Kt(16)|0,h5(f),m=s+4|0,n[m>>2]=f,f=Kt(8)|0,m=n[m>>2]|0,n[B>>2]=0,n[d>>2]=n[B>>2],KF(f,m,d),n[c>>2]=f,C=l,s|0}function xTe(s,l){s=s|0,l=l|0,n[s>>2]=kTe()|0,n[s+4>>2]=QTe()|0,n[s+12>>2]=l,n[s+8>>2]=FTe()|0,n[s+32>>2]=5}function kTe(){return 11710}function QTe(){return 1416}function FTe(){return lD()|0}function RTe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,(bp(f,896)|0)==512?c|0&&(TTe(c),gt(c)):l|0&>(l)}function TTe(s){s=s|0,s=n[s+4>>2]|0,s|0&&kp(s)}function lD(){var s=0;return o[7904]|0||(n[2600]=NTe()|0,n[2601]=0,s=7904,n[s>>2]=1,n[s+4>>2]=0),10400}function NTe(){return n[357]|0}function LTe(s){s=s|0,OTe(s,4926),MTe(s)|0}function OTe(s,l){s=s|0,l=l|0;var c=0;c=r9()|0,n[s>>2]=c,JTe(c,l),xp(n[s>>2]|0)}function MTe(s){s=s|0;var l=0;return l=n[s>>2]|0,kg(l,UTe()|0),s|0}function UTe(){var s=0;return o[7912]|0||(g5(10412),tr(56,10412,U|0)|0,s=7912,n[s>>2]=1,n[s+4>>2]=0),Tr(10412)|0||g5(10412),10412}function g5(s){s=s|0,jTe(s),Qg(s,57)}function _Te(s){s=s|0,HTe(s+24|0)}function HTe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function jTe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,5,5,l,WTe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function GTe(s){s=s|0,qTe(s)}function qTe(s){s=s|0,YTe(s)}function YTe(s){s=s|0;var l=0,c=0;l=s+8|0,c=l+48|0;do n[l>>2]=0,l=l+4|0;while((l|0)<(c|0));o[s+56>>0]=1}function WTe(){return 1432}function KTe(){return VTe()|0}function VTe(){var s=0,l=0,c=0,f=0,d=0,m=0,B=0,k=0;B=C,C=C+16|0,s=B+4|0,l=B,c=Wa(8)|0,f=c,d=Kt(48)|0,m=d,k=m+48|0;do n[m>>2]=0,m=m+4|0;while((m|0)<(k|0));return m=f+4|0,n[m>>2]=d,k=Kt(8)|0,m=n[m>>2]|0,n[l>>2]=0,n[s>>2]=n[l>>2],n9(k,m,s),n[c>>2]=k,C=B,f|0}function JTe(s,l){s=s|0,l=l|0,n[s>>2]=zTe()|0,n[s+4>>2]=XTe()|0,n[s+12>>2]=l,n[s+8>>2]=ZTe()|0,n[s+32>>2]=6}function zTe(){return 11704}function XTe(){return 1436}function ZTe(){return lD()|0}function $Te(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,(bp(f,896)|0)==512?c|0&&(eNe(c),gt(c)):l|0&>(l)}function eNe(s){s=s|0,s=n[s+4>>2]|0,s|0&&kp(s)}function tNe(s){s=s|0,rNe(s,4933),nNe(s)|0,iNe(s)|0}function rNe(s,l){s=s|0,l=l|0;var c=0;c=bNe()|0,n[s>>2]=c,xNe(c,l),xp(n[s>>2]|0)}function nNe(s){s=s|0;var l=0;return l=n[s>>2]|0,kg(l,yNe()|0),s|0}function iNe(s){s=s|0;var l=0;return l=n[s>>2]|0,kg(l,sNe()|0),s|0}function sNe(){var s=0;return o[7920]|0||(d5(10452),tr(58,10452,U|0)|0,s=7920,n[s>>2]=1,n[s+4>>2]=0),Tr(10452)|0||d5(10452),10452}function d5(s){s=s|0,lNe(s),Qg(s,1)}function oNe(s){s=s|0,aNe(s+24|0)}function aNe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function lNe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,5,1,l,fNe()|0,2),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function cNe(s,l,c){s=s|0,l=+l,c=+c,uNe(s,l,c)}function uNe(s,l,c){s=s|0,l=+l,c=+c;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+32|0,m=f+8|0,k=f+17|0,d=f,B=f+16|0,ku(k,l),E[m>>3]=+Qu(k,l),ku(B,c),E[d>>3]=+Qu(B,c),ANe(s,m,d),C=f}function ANe(s,l,c){s=s|0,l=l|0,c=c|0,m5(s+8|0,+E[l>>3],+E[c>>3]),o[s+24>>0]=1}function m5(s,l,c){s=s|0,l=+l,c=+c,E[s>>3]=l,E[s+8>>3]=c}function fNe(){return 1472}function pNe(s,l){return s=+s,l=+l,hNe(s,l)|0}function hNe(s,l){s=+s,l=+l;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return f=C,C=C+16|0,B=f+4|0,k=f+8|0,Q=f,d=Wa(8)|0,c=d,m=Kt(16)|0,ku(B,s),s=+Qu(B,s),ku(k,l),m5(m,s,+Qu(k,l)),k=c+4|0,n[k>>2]=m,m=Kt(8)|0,k=n[k>>2]|0,n[Q>>2]=0,n[B>>2]=n[Q>>2],y5(m,k,B),n[d>>2]=m,C=f,c|0}function y5(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,c=Kt(16)|0,n[c+4>>2]=0,n[c+8>>2]=0,n[c>>2]=1452,n[c+12>>2]=l,n[s+4>>2]=c}function gNe(s){s=s|0,zm(s),gt(s)}function dNe(s){s=s|0,s=n[s+12>>2]|0,s|0&>(s)}function mNe(s){s=s|0,gt(s)}function yNe(){var s=0;return o[7928]|0||(E5(10488),tr(59,10488,U|0)|0,s=7928,n[s>>2]=1,n[s+4>>2]=0),Tr(10488)|0||E5(10488),10488}function E5(s){s=s|0,wNe(s),Qg(s,60)}function ENe(s){s=s|0,CNe(s+24|0)}function CNe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function wNe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,5,6,l,DNe()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function INe(s){s=s|0,BNe(s)}function BNe(s){s=s|0,vNe(s)}function vNe(s){s=s|0,C5(s+8|0),o[s+24>>0]=1}function C5(s){s=s|0,n[s>>2]=0,n[s+4>>2]=0,n[s+8>>2]=0,n[s+12>>2]=0}function DNe(){return 1492}function SNe(){return PNe()|0}function PNe(){var s=0,l=0,c=0,f=0,d=0,m=0,B=0;return l=C,C=C+16|0,d=l+4|0,B=l,c=Wa(8)|0,s=c,f=Kt(16)|0,C5(f),m=s+4|0,n[m>>2]=f,f=Kt(8)|0,m=n[m>>2]|0,n[B>>2]=0,n[d>>2]=n[B>>2],y5(f,m,d),n[c>>2]=f,C=l,s|0}function bNe(){var s=0;return o[7936]|0||(NNe(10524),tr(25,10524,U|0)|0,s=7936,n[s>>2]=1,n[s+4>>2]=0),10524}function xNe(s,l){s=s|0,l=l|0,n[s>>2]=kNe()|0,n[s+4>>2]=QNe()|0,n[s+12>>2]=l,n[s+8>>2]=FNe()|0,n[s+32>>2]=7}function kNe(){return 11700}function QNe(){return 1484}function FNe(){return lD()|0}function RNe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,(bp(f,896)|0)==512?c|0&&(TNe(c),gt(c)):l|0&>(l)}function TNe(s){s=s|0,s=n[s+4>>2]|0,s|0&&kp(s)}function NNe(s){s=s|0,vp(s)}function LNe(s,l,c){s=s|0,l=l|0,c=c|0,s=pn(l)|0,l=ONe(c)|0,c=MNe(c,0)|0,hLe(s,l,c,cR()|0,0)}function ONe(s){return s=s|0,s|0}function MNe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=C,C=C+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=cR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(I5(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(YNe(B,d,m),l=n[c>>2]|0),C=k,(l-(n[B>>2]|0)>>3)+-1|0}function cR(){var s=0,l=0;if(o[7944]|0||(w5(10568),tr(61,10568,U|0)|0,l=7944,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10568)|0)){s=10568,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));w5(10568)}return 10568}function w5(s){s=s|0,HNe(s)}function UNe(s){s=s|0,_Ne(s+24|0)}function _Ne(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function HNe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,17,l,C9()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function jNe(s){return s=s|0,qNe(n[(GNe(s)|0)>>2]|0)|0}function GNe(s){return s=s|0,(n[(cR()|0)+24>>2]|0)+(s<<3)|0}function qNe(s){return s=s|0,oD(CD[s&7]()|0)|0}function I5(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function YNe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=C,C=C+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=WNe(s)|0,f>>>0<B>>>0)zr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,KNe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,I5(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,VNe(s,d),JNe(d),C=k;return}}function WNe(s){return s=s|0,536870911}function KNe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function VNe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function JNe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function zNe(){XNe()}function XNe(){ZNe(10604)}function ZNe(s){s=s|0,$Ne(s,4955)}function $Ne(s,l){s=s|0,l=l|0;var c=0;c=eLe()|0,n[s>>2]=c,tLe(c,l),xp(n[s>>2]|0)}function eLe(){var s=0;return o[7952]|0||(uLe(10612),tr(25,10612,U|0)|0,s=7952,n[s>>2]=1,n[s+4>>2]=0),10612}function tLe(s,l){s=s|0,l=l|0,n[s>>2]=sLe()|0,n[s+4>>2]=oLe()|0,n[s+12>>2]=l,n[s+8>>2]=aLe()|0,n[s+32>>2]=8}function xp(s){s=s|0;var l=0,c=0;l=C,C=C+16|0,c=l,Ym()|0,n[c>>2]=s,rLe(10608,c),C=l}function Ym(){return o[11714]|0||(n[2652]=0,tr(62,10608,U|0)|0,o[11714]=1),10608}function rLe(s,l){s=s|0,l=l|0;var c=0;c=Kt(8)|0,n[c+4>>2]=n[l>>2],n[c>>2]=n[s>>2],n[s>>2]=c}function nLe(s){s=s|0,iLe(s)}function iLe(s){s=s|0;var l=0,c=0;if(l=n[s>>2]|0,l|0)do c=l,l=n[l>>2]|0,gt(c);while((l|0)!=0);n[s>>2]=0}function sLe(){return 11715}function oLe(){return 1496}function aLe(){return aD()|0}function lLe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,(bp(f,896)|0)==512?c|0&&(cLe(c),gt(c)):l|0&>(l)}function cLe(s){s=s|0,s=n[s+4>>2]|0,s|0&&kp(s)}function uLe(s){s=s|0,vp(s)}function ALe(s,l){s=s|0,l=l|0;var c=0,f=0;Ym()|0,c=n[2652]|0;e:do if(c|0){for(;f=n[c+4>>2]|0,!(f|0&&(n7(uR(f)|0,s)|0)==0);)if(c=n[c>>2]|0,!c)break e;fLe(f,l)}while(0)}function uR(s){return s=s|0,n[s+12>>2]|0}function fLe(s,l){s=s|0,l=l|0;var c=0;s=s+36|0,c=n[s>>2]|0,c|0&&(GA(c),gt(c)),c=Kt(4)|0,zq(c,l),n[s>>2]=c}function AR(){return o[11716]|0||(n[2664]=0,tr(63,10656,U|0)|0,o[11716]=1),10656}function B5(){var s=0;return o[11717]|0?s=n[2665]|0:(pLe(),n[2665]=1504,o[11717]=1,s=1504),s|0}function pLe(){o[11740]|0||(o[11718]=gr(gr(8,0)|0,0)|0,o[11719]=gr(gr(0,0)|0,0)|0,o[11720]=gr(gr(0,16)|0,0)|0,o[11721]=gr(gr(8,0)|0,0)|0,o[11722]=gr(gr(0,0)|0,0)|0,o[11723]=gr(gr(8,0)|0,0)|0,o[11724]=gr(gr(0,0)|0,0)|0,o[11725]=gr(gr(8,0)|0,0)|0,o[11726]=gr(gr(0,0)|0,0)|0,o[11727]=gr(gr(8,0)|0,0)|0,o[11728]=gr(gr(0,0)|0,0)|0,o[11729]=gr(gr(0,0)|0,32)|0,o[11730]=gr(gr(0,0)|0,32)|0,o[11740]=1)}function v5(){return 1572}function hLe(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0,k=0,Q=0,M=0,O=0;m=C,C=C+32|0,O=m+16|0,M=m+12|0,Q=m+8|0,k=m+4|0,B=m,n[O>>2]=s,n[M>>2]=l,n[Q>>2]=c,n[k>>2]=f,n[B>>2]=d,AR()|0,gLe(10656,O,M,Q,k,B),C=m}function gLe(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0;var B=0;B=Kt(24)|0,$q(B+4|0,n[l>>2]|0,n[c>>2]|0,n[f>>2]|0,n[d>>2]|0,n[m>>2]|0),n[B>>2]=n[s>>2],n[s>>2]=B}function D5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0,qe=0,Me=0,Qe=0,et=0,Xe=0,lt=0;if(lt=C,C=C+32|0,Me=lt+20|0,Qe=lt+8|0,et=lt+4|0,Xe=lt,l=n[l>>2]|0,l|0){qe=Me+4|0,Q=Me+8|0,M=Qe+4|0,O=Qe+8|0,G=Qe+8|0,se=Me+8|0;do{if(B=l+4|0,k=fR(B)|0,k|0){if(d=Nw(k)|0,n[Me>>2]=0,n[qe>>2]=0,n[Q>>2]=0,f=(Lw(k)|0)+1|0,dLe(Me,f),f|0)for(;f=f+-1|0,bc(Qe,n[d>>2]|0),m=n[qe>>2]|0,m>>>0<(n[se>>2]|0)>>>0?(n[m>>2]=n[Qe>>2],n[qe>>2]=(n[qe>>2]|0)+4):pR(Me,Qe),f;)d=d+4|0;f=Ow(k)|0,n[Qe>>2]=0,n[M>>2]=0,n[O>>2]=0;e:do if(n[f>>2]|0)for(d=0,m=0;;){if((d|0)==(m|0)?mLe(Qe,f):(n[d>>2]=n[f>>2],n[M>>2]=(n[M>>2]|0)+4),f=f+4|0,!(n[f>>2]|0))break e;d=n[M>>2]|0,m=n[G>>2]|0}while(0);n[et>>2]=cD(B)|0,n[Xe>>2]=Tr(k)|0,yLe(c,s,et,Xe,Me,Qe),hR(Qe),ef(Me)}l=n[l>>2]|0}while((l|0)!=0)}C=lt}function fR(s){return s=s|0,n[s+12>>2]|0}function Nw(s){return s=s|0,n[s+12>>2]|0}function Lw(s){return s=s|0,n[s+16>>2]|0}function dLe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0;d=C,C=C+32|0,c=d,f=n[s>>2]|0,(n[s+8>>2]|0)-f>>2>>>0<l>>>0&&(R5(c,l,(n[s+4>>2]|0)-f>>2,s+8|0),T5(s,c),N5(c)),C=d}function pR(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0;if(B=C,C=C+32|0,c=B,f=s+4|0,d=((n[f>>2]|0)-(n[s>>2]|0)>>2)+1|0,m=F5(s)|0,m>>>0<d>>>0)zr(s);else{k=n[s>>2]|0,M=(n[s+8>>2]|0)-k|0,Q=M>>1,R5(c,M>>2>>>0<m>>>1>>>0?Q>>>0<d>>>0?d:Q:m,(n[f>>2]|0)-k>>2,s+8|0),m=c+8|0,n[n[m>>2]>>2]=n[l>>2],n[m>>2]=(n[m>>2]|0)+4,T5(s,c),N5(c),C=B;return}}function Ow(s){return s=s|0,n[s+8>>2]|0}function mLe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0;if(B=C,C=C+32|0,c=B,f=s+4|0,d=((n[f>>2]|0)-(n[s>>2]|0)>>2)+1|0,m=Q5(s)|0,m>>>0<d>>>0)zr(s);else{k=n[s>>2]|0,M=(n[s+8>>2]|0)-k|0,Q=M>>1,OLe(c,M>>2>>>0<m>>>1>>>0?Q>>>0<d>>>0?d:Q:m,(n[f>>2]|0)-k>>2,s+8|0),m=c+8|0,n[n[m>>2]>>2]=n[l>>2],n[m>>2]=(n[m>>2]|0)+4,MLe(s,c),ULe(c),C=B;return}}function cD(s){return s=s|0,n[s>>2]|0}function yLe(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,ELe(s,l,c,f,d,m)}function hR(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-4-f|0)>>>2)<<2)),gt(c))}function ef(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-4-f|0)>>>2)<<2)),gt(c))}function ELe(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0;var B=0,k=0,Q=0,M=0,O=0,G=0;B=C,C=C+48|0,O=B+40|0,k=B+32|0,G=B+24|0,Q=B+12|0,M=B,Ka(k),s=da(s)|0,n[G>>2]=n[l>>2],c=n[c>>2]|0,f=n[f>>2]|0,gR(Q,d),CLe(M,m),n[O>>2]=n[G>>2],wLe(s,O,c,f,Q,M),hR(M),ef(Q),Va(k),C=B}function gR(s,l){s=s|0,l=l|0;var c=0,f=0;n[s>>2]=0,n[s+4>>2]=0,n[s+8>>2]=0,c=l+4|0,f=(n[c>>2]|0)-(n[l>>2]|0)>>2,f|0&&(NLe(s,f),LLe(s,n[l>>2]|0,n[c>>2]|0,f))}function CLe(s,l){s=s|0,l=l|0;var c=0,f=0;n[s>>2]=0,n[s+4>>2]=0,n[s+8>>2]=0,c=l+4|0,f=(n[c>>2]|0)-(n[l>>2]|0)>>2,f|0&&(RLe(s,f),TLe(s,n[l>>2]|0,n[c>>2]|0,f))}function wLe(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0;var B=0,k=0,Q=0,M=0,O=0,G=0;B=C,C=C+32|0,O=B+28|0,G=B+24|0,k=B+12|0,Q=B,M=Sl(ILe()|0)|0,n[G>>2]=n[l>>2],n[O>>2]=n[G>>2],l=Fg(O)|0,c=S5(c)|0,f=dR(f)|0,n[k>>2]=n[d>>2],O=d+4|0,n[k+4>>2]=n[O>>2],G=d+8|0,n[k+8>>2]=n[G>>2],n[G>>2]=0,n[O>>2]=0,n[d>>2]=0,d=mR(k)|0,n[Q>>2]=n[m>>2],O=m+4|0,n[Q+4>>2]=n[O>>2],G=m+8|0,n[Q+8>>2]=n[G>>2],n[G>>2]=0,n[O>>2]=0,n[m>>2]=0,ao(0,M|0,s|0,l|0,c|0,f|0,d|0,BLe(Q)|0)|0,hR(Q),ef(k),C=B}function ILe(){var s=0;return o[7968]|0||(QLe(10708),s=7968,n[s>>2]=1,n[s+4>>2]=0),10708}function Fg(s){return s=s|0,b5(s)|0}function S5(s){return s=s|0,P5(s)|0}function dR(s){return s=s|0,oD(s)|0}function mR(s){return s=s|0,DLe(s)|0}function BLe(s){return s=s|0,vLe(s)|0}function vLe(s){s=s|0;var l=0,c=0,f=0;if(f=(n[s+4>>2]|0)-(n[s>>2]|0)|0,c=f>>2,f=Wa(f+4|0)|0,n[f>>2]=c,c|0){l=0;do n[f+4+(l<<2)>>2]=P5(n[(n[s>>2]|0)+(l<<2)>>2]|0)|0,l=l+1|0;while((l|0)!=(c|0))}return f|0}function P5(s){return s=s|0,s|0}function DLe(s){s=s|0;var l=0,c=0,f=0;if(f=(n[s+4>>2]|0)-(n[s>>2]|0)|0,c=f>>2,f=Wa(f+4|0)|0,n[f>>2]=c,c|0){l=0;do n[f+4+(l<<2)>>2]=b5((n[s>>2]|0)+(l<<2)|0)|0,l=l+1|0;while((l|0)!=(c|0))}return f|0}function b5(s){s=s|0;var l=0,c=0,f=0,d=0;return d=C,C=C+32|0,l=d+12|0,c=d,f=xF(x5()|0)|0,f?(kF(l,f),QF(c,l),aUe(s,c),s=FF(l)|0):s=SLe(s)|0,C=d,s|0}function x5(){var s=0;return o[7960]|0||(kLe(10664),tr(25,10664,U|0)|0,s=7960,n[s>>2]=1,n[s+4>>2]=0),10664}function SLe(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0;return c=C,C=C+16|0,d=c+4|0,B=c,f=Wa(8)|0,l=f,k=Kt(4)|0,n[k>>2]=n[s>>2],m=l+4|0,n[m>>2]=k,s=Kt(8)|0,m=n[m>>2]|0,n[B>>2]=0,n[d>>2]=n[B>>2],k5(s,m,d),n[f>>2]=s,C=c,l|0}function k5(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,c=Kt(16)|0,n[c+4>>2]=0,n[c+8>>2]=0,n[c>>2]=1656,n[c+12>>2]=l,n[s+4>>2]=c}function PLe(s){s=s|0,zm(s),gt(s)}function bLe(s){s=s|0,s=n[s+12>>2]|0,s|0&>(s)}function xLe(s){s=s|0,gt(s)}function kLe(s){s=s|0,vp(s)}function QLe(s){s=s|0,Pl(s,FLe()|0,5)}function FLe(){return 1676}function RLe(s,l){s=s|0,l=l|0;var c=0;if((Q5(s)|0)>>>0<l>>>0&&zr(s),l>>>0>1073741823)Rt();else{c=Kt(l<<2)|0,n[s+4>>2]=c,n[s>>2]=c,n[s+8>>2]=c+(l<<2);return}}function TLe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,f=s+4|0,s=c-l|0,(s|0)>0&&(Dr(n[f>>2]|0,l|0,s|0)|0,n[f>>2]=(n[f>>2]|0)+(s>>>2<<2))}function Q5(s){return s=s|0,1073741823}function NLe(s,l){s=s|0,l=l|0;var c=0;if((F5(s)|0)>>>0<l>>>0&&zr(s),l>>>0>1073741823)Rt();else{c=Kt(l<<2)|0,n[s+4>>2]=c,n[s>>2]=c,n[s+8>>2]=c+(l<<2);return}}function LLe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,f=s+4|0,s=c-l|0,(s|0)>0&&(Dr(n[f>>2]|0,l|0,s|0)|0,n[f>>2]=(n[f>>2]|0)+(s>>>2<<2))}function F5(s){return s=s|0,1073741823}function OLe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>1073741823)Rt();else{d=Kt(l<<2)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<2)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<2)}function MLe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>2)<<2)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function ULe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-4-l|0)>>>2)<<2)),s=n[s>>2]|0,s|0&>(s)}function R5(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>1073741823)Rt();else{d=Kt(l<<2)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<2)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<2)}function T5(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>2)<<2)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function N5(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-4-l|0)>>>2)<<2)),s=n[s>>2]|0,s|0&>(s)}function _Le(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0,qe=0,Me=0,Qe=0;if(Qe=C,C=C+32|0,O=Qe+20|0,G=Qe+12|0,M=Qe+16|0,se=Qe+4|0,qe=Qe,Me=Qe+8|0,k=B5()|0,m=n[k>>2]|0,B=n[m>>2]|0,B|0)for(Q=n[k+8>>2]|0,k=n[k+4>>2]|0;bc(O,B),HLe(s,O,k,Q),m=m+4|0,B=n[m>>2]|0,B;)Q=Q+1|0,k=k+1|0;if(m=v5()|0,B=n[m>>2]|0,B|0)do bc(O,B),n[G>>2]=n[m+4>>2],jLe(l,O,G),m=m+8|0,B=n[m>>2]|0;while((B|0)!=0);if(m=n[(Ym()|0)>>2]|0,m|0)do l=n[m+4>>2]|0,bc(O,n[(Wm(l)|0)>>2]|0),n[G>>2]=uR(l)|0,GLe(c,O,G),m=n[m>>2]|0;while((m|0)!=0);if(bc(M,0),m=AR()|0,n[O>>2]=n[M>>2],D5(O,m,d),m=n[(Ym()|0)>>2]|0,m|0){s=O+4|0,l=O+8|0,c=O+8|0;do{if(Q=n[m+4>>2]|0,bc(G,n[(Wm(Q)|0)>>2]|0),qLe(se,L5(Q)|0),B=n[se>>2]|0,B|0){n[O>>2]=0,n[s>>2]=0,n[l>>2]=0;do bc(qe,n[(Wm(n[B+4>>2]|0)|0)>>2]|0),k=n[s>>2]|0,k>>>0<(n[c>>2]|0)>>>0?(n[k>>2]=n[qe>>2],n[s>>2]=(n[s>>2]|0)+4):pR(O,qe),B=n[B>>2]|0;while((B|0)!=0);YLe(f,G,O),ef(O)}n[Me>>2]=n[G>>2],M=O5(Q)|0,n[O>>2]=n[Me>>2],D5(O,M,d),s9(se),m=n[m>>2]|0}while((m|0)!=0)}C=Qe}function HLe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,nOe(s,l,c,f)}function jLe(s,l,c){s=s|0,l=l|0,c=c|0,rOe(s,l,c)}function Wm(s){return s=s|0,s|0}function GLe(s,l,c){s=s|0,l=l|0,c=c|0,ZLe(s,l,c)}function L5(s){return s=s|0,s+16|0}function qLe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;if(m=C,C=C+16|0,d=m+8|0,c=m,n[s>>2]=0,f=n[l>>2]|0,n[d>>2]=f,n[c>>2]=s,c=XLe(c)|0,f|0){if(f=Kt(12)|0,B=(M5(d)|0)+4|0,s=n[B+4>>2]|0,l=f+4|0,n[l>>2]=n[B>>2],n[l+4>>2]=s,l=n[n[d>>2]>>2]|0,n[d>>2]=l,!l)s=f;else for(l=f;s=Kt(12)|0,Q=(M5(d)|0)+4|0,k=n[Q+4>>2]|0,B=s+4|0,n[B>>2]=n[Q>>2],n[B+4>>2]=k,n[l>>2]=s,B=n[n[d>>2]>>2]|0,n[d>>2]=B,B;)l=s;n[s>>2]=n[c>>2],n[c>>2]=f}C=m}function YLe(s,l,c){s=s|0,l=l|0,c=c|0,WLe(s,l,c)}function O5(s){return s=s|0,s+24|0}function WLe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+32|0,B=f+24|0,d=f+16|0,k=f+12|0,m=f,Ka(d),s=da(s)|0,n[k>>2]=n[l>>2],gR(m,c),n[B>>2]=n[k>>2],KLe(s,B,m),ef(m),Va(d),C=f}function KLe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=C,C=C+32|0,B=f+16|0,k=f+12|0,d=f,m=Sl(VLe()|0)|0,n[k>>2]=n[l>>2],n[B>>2]=n[k>>2],l=Fg(B)|0,n[d>>2]=n[c>>2],B=c+4|0,n[d+4>>2]=n[B>>2],k=c+8|0,n[d+8>>2]=n[k>>2],n[k>>2]=0,n[B>>2]=0,n[c>>2]=0,oo(0,m|0,s|0,l|0,mR(d)|0)|0,ef(d),C=f}function VLe(){var s=0;return o[7976]|0||(JLe(10720),s=7976,n[s>>2]=1,n[s+4>>2]=0),10720}function JLe(s){s=s|0,Pl(s,zLe()|0,2)}function zLe(){return 1732}function XLe(s){return s=s|0,n[s>>2]|0}function M5(s){return s=s|0,n[s>>2]|0}function ZLe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;f=C,C=C+32|0,m=f+16|0,d=f+8|0,B=f,Ka(d),s=da(s)|0,n[B>>2]=n[l>>2],c=n[c>>2]|0,n[m>>2]=n[B>>2],U5(s,m,c),Va(d),C=f}function U5(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;f=C,C=C+16|0,m=f+4|0,B=f,d=Sl($Le()|0)|0,n[B>>2]=n[l>>2],n[m>>2]=n[B>>2],l=Fg(m)|0,oo(0,d|0,s|0,l|0,S5(c)|0)|0,C=f}function $Le(){var s=0;return o[7984]|0||(eOe(10732),s=7984,n[s>>2]=1,n[s+4>>2]=0),10732}function eOe(s){s=s|0,Pl(s,tOe()|0,2)}function tOe(){return 1744}function rOe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;f=C,C=C+32|0,m=f+16|0,d=f+8|0,B=f,Ka(d),s=da(s)|0,n[B>>2]=n[l>>2],c=n[c>>2]|0,n[m>>2]=n[B>>2],U5(s,m,c),Va(d),C=f}function nOe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;d=C,C=C+32|0,B=d+16|0,m=d+8|0,k=d,Ka(m),s=da(s)|0,n[k>>2]=n[l>>2],c=o[c>>0]|0,f=o[f>>0]|0,n[B>>2]=n[k>>2],iOe(s,B,c,f),Va(m),C=d}function iOe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;d=C,C=C+16|0,B=d+4|0,k=d,m=Sl(sOe()|0)|0,n[k>>2]=n[l>>2],n[B>>2]=n[k>>2],l=Fg(B)|0,c=Km(c)|0,pc(0,m|0,s|0,l|0,c|0,Km(f)|0)|0,C=d}function sOe(){var s=0;return o[7992]|0||(aOe(10744),s=7992,n[s>>2]=1,n[s+4>>2]=0),10744}function Km(s){return s=s|0,oOe(s)|0}function oOe(s){return s=s|0,s&255|0}function aOe(s){s=s|0,Pl(s,lOe()|0,3)}function lOe(){return 1756}function cOe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;switch(se=C,C=C+32|0,k=se+8|0,Q=se+4|0,M=se+20|0,O=se,LF(s,0),f=oUe(l)|0,n[k>>2]=0,G=k+4|0,n[G>>2]=0,n[k+8>>2]=0,f<<24>>24){case 0:{o[M>>0]=0,uOe(Q,c,M),uD(s,Q)|0,qA(Q);break}case 8:{G=BR(l)|0,o[M>>0]=8,bc(O,n[G+4>>2]|0),AOe(Q,c,M,O,G+8|0),uD(s,Q)|0,qA(Q);break}case 9:{if(m=BR(l)|0,l=n[m+4>>2]|0,l|0)for(B=k+8|0,d=m+12|0;l=l+-1|0,bc(Q,n[d>>2]|0),f=n[G>>2]|0,f>>>0<(n[B>>2]|0)>>>0?(n[f>>2]=n[Q>>2],n[G>>2]=(n[G>>2]|0)+4):pR(k,Q),l;)d=d+4|0;o[M>>0]=9,bc(O,n[m+8>>2]|0),fOe(Q,c,M,O,k),uD(s,Q)|0,qA(Q);break}default:G=BR(l)|0,o[M>>0]=f,bc(O,n[G+4>>2]|0),pOe(Q,c,M,O),uD(s,Q)|0,qA(Q)}ef(k),C=se}function uOe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;f=C,C=C+16|0,d=f,Ka(d),l=da(l)|0,SOe(s,l,o[c>>0]|0),Va(d),C=f}function uD(s,l){s=s|0,l=l|0;var c=0;return c=n[s>>2]|0,c|0&&PA(c|0),n[s>>2]=n[l>>2],n[l>>2]=0,s|0}function AOe(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0,k=0,Q=0;m=C,C=C+32|0,k=m+16|0,B=m+8|0,Q=m,Ka(B),l=da(l)|0,c=o[c>>0]|0,n[Q>>2]=n[f>>2],d=n[d>>2]|0,n[k>>2]=n[Q>>2],IOe(s,l,c,k,d),Va(B),C=m}function fOe(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0,k=0,Q=0,M=0;m=C,C=C+32|0,Q=m+24|0,B=m+16|0,M=m+12|0,k=m,Ka(B),l=da(l)|0,c=o[c>>0]|0,n[M>>2]=n[f>>2],gR(k,d),n[Q>>2]=n[M>>2],yOe(s,l,c,Q,k),ef(k),Va(B),C=m}function pOe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;d=C,C=C+32|0,B=d+16|0,m=d+8|0,k=d,Ka(m),l=da(l)|0,c=o[c>>0]|0,n[k>>2]=n[f>>2],n[B>>2]=n[k>>2],hOe(s,l,c,B),Va(m),C=d}function hOe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0,B=0,k=0;d=C,C=C+16|0,m=d+4|0,k=d,B=Sl(gOe()|0)|0,c=Km(c)|0,n[k>>2]=n[f>>2],n[m>>2]=n[k>>2],AD(s,oo(0,B|0,l|0,c|0,Fg(m)|0)|0),C=d}function gOe(){var s=0;return o[8e3]|0||(dOe(10756),s=8e3,n[s>>2]=1,n[s+4>>2]=0),10756}function AD(s,l){s=s|0,l=l|0,LF(s,l)}function dOe(s){s=s|0,Pl(s,mOe()|0,2)}function mOe(){return 1772}function yOe(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0,k=0,Q=0,M=0;m=C,C=C+32|0,Q=m+16|0,M=m+12|0,B=m,k=Sl(EOe()|0)|0,c=Km(c)|0,n[M>>2]=n[f>>2],n[Q>>2]=n[M>>2],f=Fg(Q)|0,n[B>>2]=n[d>>2],Q=d+4|0,n[B+4>>2]=n[Q>>2],M=d+8|0,n[B+8>>2]=n[M>>2],n[M>>2]=0,n[Q>>2]=0,n[d>>2]=0,AD(s,pc(0,k|0,l|0,c|0,f|0,mR(B)|0)|0),ef(B),C=m}function EOe(){var s=0;return o[8008]|0||(COe(10768),s=8008,n[s>>2]=1,n[s+4>>2]=0),10768}function COe(s){s=s|0,Pl(s,wOe()|0,3)}function wOe(){return 1784}function IOe(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0,k=0,Q=0;m=C,C=C+16|0,k=m+4|0,Q=m,B=Sl(BOe()|0)|0,c=Km(c)|0,n[Q>>2]=n[f>>2],n[k>>2]=n[Q>>2],f=Fg(k)|0,AD(s,pc(0,B|0,l|0,c|0,f|0,dR(d)|0)|0),C=m}function BOe(){var s=0;return o[8016]|0||(vOe(10780),s=8016,n[s>>2]=1,n[s+4>>2]=0),10780}function vOe(s){s=s|0,Pl(s,DOe()|0,3)}function DOe(){return 1800}function SOe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;f=Sl(POe()|0)|0,AD(s,Qn(0,f|0,l|0,Km(c)|0)|0)}function POe(){var s=0;return o[8024]|0||(bOe(10792),s=8024,n[s>>2]=1,n[s+4>>2]=0),10792}function bOe(s){s=s|0,Pl(s,xOe()|0,1)}function xOe(){return 1816}function kOe(){QOe(),FOe(),ROe()}function QOe(){n[2702]=p7(65536)|0}function FOe(){$Oe(10856)}function ROe(){TOe(10816)}function TOe(s){s=s|0,NOe(s,5044),LOe(s)|0}function NOe(s,l){s=s|0,l=l|0;var c=0;c=x5()|0,n[s>>2]=c,KOe(c,l),xp(n[s>>2]|0)}function LOe(s){s=s|0;var l=0;return l=n[s>>2]|0,kg(l,OOe()|0),s|0}function OOe(){var s=0;return o[8032]|0||(_5(10820),tr(64,10820,U|0)|0,s=8032,n[s>>2]=1,n[s+4>>2]=0),Tr(10820)|0||_5(10820),10820}function _5(s){s=s|0,_Oe(s),Qg(s,25)}function MOe(s){s=s|0,UOe(s+24|0)}function UOe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function _Oe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,5,18,l,qOe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function HOe(s,l){s=s|0,l=l|0,jOe(s,l)}function jOe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0;c=C,C=C+16|0,f=c,d=c+4|0,bg(d,l),n[f>>2]=xg(d,l)|0,GOe(s,f),C=c}function GOe(s,l){s=s|0,l=l|0,H5(s+4|0,n[l>>2]|0),o[s+8>>0]=1}function H5(s,l){s=s|0,l=l|0,n[s>>2]=l}function qOe(){return 1824}function YOe(s){return s=s|0,WOe(s)|0}function WOe(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0;return c=C,C=C+16|0,d=c+4|0,B=c,f=Wa(8)|0,l=f,k=Kt(4)|0,bg(d,s),H5(k,xg(d,s)|0),m=l+4|0,n[m>>2]=k,s=Kt(8)|0,m=n[m>>2]|0,n[B>>2]=0,n[d>>2]=n[B>>2],k5(s,m,d),n[f>>2]=s,C=c,l|0}function Wa(s){s=s|0;var l=0,c=0;return s=s+7&-8,s>>>0<=32768&&(l=n[2701]|0,s>>>0<=(65536-l|0)>>>0)?(c=(n[2702]|0)+l|0,n[2701]=l+s,s=c):(s=p7(s+8|0)|0,n[s>>2]=n[2703],n[2703]=s,s=s+8|0),s|0}function KOe(s,l){s=s|0,l=l|0,n[s>>2]=VOe()|0,n[s+4>>2]=JOe()|0,n[s+12>>2]=l,n[s+8>>2]=zOe()|0,n[s+32>>2]=9}function VOe(){return 11744}function JOe(){return 1832}function zOe(){return lD()|0}function XOe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,(bp(f,896)|0)==512?c|0&&(ZOe(c),gt(c)):l|0&>(l)}function ZOe(s){s=s|0,s=n[s+4>>2]|0,s|0&&kp(s)}function $Oe(s){s=s|0,eMe(s,5052),tMe(s)|0,rMe(s,5058,26)|0,nMe(s,5069,1)|0,iMe(s,5077,10)|0,sMe(s,5087,19)|0,oMe(s,5094,27)|0}function eMe(s,l){s=s|0,l=l|0;var c=0;c=Z4e()|0,n[s>>2]=c,$4e(c,l),xp(n[s>>2]|0)}function tMe(s){s=s|0;var l=0;return l=n[s>>2]|0,kg(l,M4e()|0),s|0}function rMe(s,l,c){return s=s|0,l=l|0,c=c|0,C4e(s,pn(l)|0,c,0),s|0}function nMe(s,l,c){return s=s|0,l=l|0,c=c|0,s4e(s,pn(l)|0,c,0),s|0}function iMe(s,l,c){return s=s|0,l=l|0,c=c|0,OMe(s,pn(l)|0,c,0),s|0}function sMe(s,l,c){return s=s|0,l=l|0,c=c|0,IMe(s,pn(l)|0,c,0),s|0}function j5(s,l){s=s|0,l=l|0;var c=0,f=0;e:for(;;){for(c=n[2703]|0;;){if((c|0)==(l|0))break e;if(f=n[c>>2]|0,n[2703]=f,!c)c=f;else break}gt(c)}n[2701]=s}function oMe(s,l,c){return s=s|0,l=l|0,c=c|0,aMe(s,pn(l)|0,c,0),s|0}function aMe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=yR()|0,s=lMe(c)|0,hn(m,l,d,s,cMe(c,f)|0,f)}function yR(){var s=0,l=0;if(o[8040]|0||(q5(10860),tr(65,10860,U|0)|0,l=8040,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10860)|0)){s=10860,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));q5(10860)}return 10860}function lMe(s){return s=s|0,s|0}function cMe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=C,C=C+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=yR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(G5(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(uMe(B,d,m),l=n[c>>2]|0),C=k,(l-(n[B>>2]|0)>>3)+-1|0}function G5(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function uMe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=C,C=C+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=AMe(s)|0,f>>>0<B>>>0)zr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,fMe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,G5(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,pMe(s,d),hMe(d),C=k;return}}function AMe(s){return s=s|0,536870911}function fMe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function pMe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function hMe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function q5(s){s=s|0,mMe(s)}function gMe(s){s=s|0,dMe(s+24|0)}function dMe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function mMe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,11,l,yMe()|0,2),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function yMe(){return 1840}function EMe(s,l,c){s=s|0,l=l|0,c=c|0,wMe(n[(CMe(s)|0)>>2]|0,l,c)}function CMe(s){return s=s|0,(n[(yR()|0)+24>>2]|0)+(s<<3)|0}function wMe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;f=C,C=C+16|0,m=f+1|0,d=f,bg(m,l),l=xg(m,l)|0,bg(d,c),c=xg(d,c)|0,rf[s&31](l,c),C=f}function IMe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=ER()|0,s=BMe(c)|0,hn(m,l,d,s,vMe(c,f)|0,f)}function ER(){var s=0,l=0;if(o[8048]|0||(W5(10896),tr(66,10896,U|0)|0,l=8048,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10896)|0)){s=10896,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));W5(10896)}return 10896}function BMe(s){return s=s|0,s|0}function vMe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=C,C=C+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=ER()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(Y5(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(DMe(B,d,m),l=n[c>>2]|0),C=k,(l-(n[B>>2]|0)>>3)+-1|0}function Y5(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function DMe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=C,C=C+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=SMe(s)|0,f>>>0<B>>>0)zr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,PMe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,Y5(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,bMe(s,d),xMe(d),C=k;return}}function SMe(s){return s=s|0,536870911}function PMe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function bMe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function xMe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function W5(s){s=s|0,FMe(s)}function kMe(s){s=s|0,QMe(s+24|0)}function QMe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function FMe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,11,l,RMe()|0,1),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function RMe(){return 1852}function TMe(s,l){return s=s|0,l=l|0,LMe(n[(NMe(s)|0)>>2]|0,l)|0}function NMe(s){return s=s|0,(n[(ER()|0)+24>>2]|0)+(s<<3)|0}function LMe(s,l){s=s|0,l=l|0;var c=0,f=0;return c=C,C=C+16|0,f=c,bg(f,l),l=xg(f,l)|0,l=oD(Lg[s&31](l)|0)|0,C=c,l|0}function OMe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=CR()|0,s=MMe(c)|0,hn(m,l,d,s,UMe(c,f)|0,f)}function CR(){var s=0,l=0;if(o[8056]|0||(V5(10932),tr(67,10932,U|0)|0,l=8056,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10932)|0)){s=10932,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));V5(10932)}return 10932}function MMe(s){return s=s|0,s|0}function UMe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=C,C=C+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=CR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(K5(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(_Me(B,d,m),l=n[c>>2]|0),C=k,(l-(n[B>>2]|0)>>3)+-1|0}function K5(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function _Me(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=C,C=C+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=HMe(s)|0,f>>>0<B>>>0)zr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,jMe(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,K5(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,GMe(s,d),qMe(d),C=k;return}}function HMe(s){return s=s|0,536870911}function jMe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function GMe(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function qMe(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function V5(s){s=s|0,KMe(s)}function YMe(s){s=s|0,WMe(s+24|0)}function WMe(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function KMe(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,7,l,VMe()|0,2),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function VMe(){return 1860}function JMe(s,l,c){return s=s|0,l=l|0,c=c|0,XMe(n[(zMe(s)|0)>>2]|0,l,c)|0}function zMe(s){return s=s|0,(n[(CR()|0)+24>>2]|0)+(s<<3)|0}function XMe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0;return f=C,C=C+32|0,B=f+12|0,m=f+8|0,k=f,Q=f+16|0,d=f+4|0,ZMe(Q,l),$Me(k,Q,l),Dp(d,c),c=Sp(d,c)|0,n[B>>2]=n[k>>2],Hw[s&15](m,B,c),c=e4e(m)|0,qA(m),Pp(d),C=f,c|0}function ZMe(s,l){s=s|0,l=l|0}function $Me(s,l,c){s=s|0,l=l|0,c=c|0,t4e(s,c)}function e4e(s){return s=s|0,da(s)|0}function t4e(s,l){s=s|0,l=l|0;var c=0,f=0,d=0;d=C,C=C+16|0,c=d,f=l,f&1?(r4e(c,0),ii(f|0,c|0)|0,n4e(s,c),i4e(c)):n[s>>2]=n[l>>2],C=d}function r4e(s,l){s=s|0,l=l|0,Xq(s,l),n[s+4>>2]=0,o[s+8>>0]=0}function n4e(s,l){s=s|0,l=l|0,n[s>>2]=n[l+4>>2]}function i4e(s){s=s|0,o[s+8>>0]=0}function s4e(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=wR()|0,s=o4e(c)|0,hn(m,l,d,s,a4e(c,f)|0,f)}function wR(){var s=0,l=0;if(o[8064]|0||(z5(10968),tr(68,10968,U|0)|0,l=8064,n[l>>2]=1,n[l+4>>2]=0),!(Tr(10968)|0)){s=10968,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));z5(10968)}return 10968}function o4e(s){return s=s|0,s|0}function a4e(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=C,C=C+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=wR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(J5(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(l4e(B,d,m),l=n[c>>2]|0),C=k,(l-(n[B>>2]|0)>>3)+-1|0}function J5(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function l4e(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=C,C=C+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=c4e(s)|0,f>>>0<B>>>0)zr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,u4e(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,J5(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,A4e(s,d),f4e(d),C=k;return}}function c4e(s){return s=s|0,536870911}function u4e(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function A4e(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function f4e(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function z5(s){s=s|0,g4e(s)}function p4e(s){s=s|0,h4e(s+24|0)}function h4e(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function g4e(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,1,l,d4e()|0,5),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function d4e(){return 1872}function m4e(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,E4e(n[(y4e(s)|0)>>2]|0,l,c,f,d,m)}function y4e(s){return s=s|0,(n[(wR()|0)+24>>2]|0)+(s<<3)|0}function E4e(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0;var B=0,k=0,Q=0,M=0,O=0,G=0;B=C,C=C+32|0,k=B+16|0,Q=B+12|0,M=B+8|0,O=B+4|0,G=B,Dp(k,l),l=Sp(k,l)|0,Dp(Q,c),c=Sp(Q,c)|0,Dp(M,f),f=Sp(M,f)|0,Dp(O,d),d=Sp(O,d)|0,Dp(G,m),m=Sp(G,m)|0,y7[s&1](l,c,f,d,m),Pp(G),Pp(O),Pp(M),Pp(Q),Pp(k),C=B}function C4e(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;m=n[s>>2]|0,d=IR()|0,s=w4e(c)|0,hn(m,l,d,s,I4e(c,f)|0,f)}function IR(){var s=0,l=0;if(o[8072]|0||(Z5(11004),tr(69,11004,U|0)|0,l=8072,n[l>>2]=1,n[l+4>>2]=0),!(Tr(11004)|0)){s=11004,l=s+36|0;do n[s>>2]=0,s=s+4|0;while((s|0)<(l|0));Z5(11004)}return 11004}function w4e(s){return s=s|0,s|0}function I4e(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0,k=0,Q=0;return k=C,C=C+16|0,d=k,m=k+4|0,n[d>>2]=s,Q=IR()|0,B=Q+24|0,l=gr(l,4)|0,n[m>>2]=l,c=Q+28|0,f=n[c>>2]|0,f>>>0<(n[Q+32>>2]|0)>>>0?(X5(f,s,l),l=(n[c>>2]|0)+8|0,n[c>>2]=l):(B4e(B,d,m),l=n[c>>2]|0),C=k,(l-(n[B>>2]|0)>>3)+-1|0}function X5(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,n[s+4>>2]=c}function B4e(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0;if(k=C,C=C+32|0,d=k,m=s+4|0,B=((n[m>>2]|0)-(n[s>>2]|0)>>3)+1|0,f=v4e(s)|0,f>>>0<B>>>0)zr(s);else{Q=n[s>>2]|0,O=(n[s+8>>2]|0)-Q|0,M=O>>2,D4e(d,O>>3>>>0<f>>>1>>>0?M>>>0<B>>>0?B:M:f,(n[m>>2]|0)-Q>>3,s+8|0),B=d+8|0,X5(n[B>>2]|0,n[l>>2]|0,n[c>>2]|0),n[B>>2]=(n[B>>2]|0)+8,S4e(s,d),P4e(d),C=k;return}}function v4e(s){return s=s|0,536870911}function D4e(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0;n[s+12>>2]=0,n[s+16>>2]=f;do if(l)if(l>>>0>536870911)Rt();else{d=Kt(l<<3)|0;break}else d=0;while(0);n[s>>2]=d,f=d+(c<<3)|0,n[s+8>>2]=f,n[s+4>>2]=f,n[s+12>>2]=d+(l<<3)}function S4e(s,l){s=s|0,l=l|0;var c=0,f=0,d=0,m=0,B=0;f=n[s>>2]|0,B=s+4|0,m=l+4|0,d=(n[B>>2]|0)-f|0,c=(n[m>>2]|0)+(0-(d>>3)<<3)|0,n[m>>2]=c,(d|0)>0?(Dr(c|0,f|0,d|0)|0,f=m,c=n[m>>2]|0):f=m,m=n[s>>2]|0,n[s>>2]=c,n[f>>2]=m,m=l+8|0,d=n[B>>2]|0,n[B>>2]=n[m>>2],n[m>>2]=d,m=s+8|0,B=l+12|0,s=n[m>>2]|0,n[m>>2]=n[B>>2],n[B>>2]=s,n[l>>2]=n[f>>2]}function P4e(s){s=s|0;var l=0,c=0,f=0;l=n[s+4>>2]|0,c=s+8|0,f=n[c>>2]|0,(f|0)!=(l|0)&&(n[c>>2]=f+(~((f+-8-l|0)>>>3)<<3)),s=n[s>>2]|0,s|0&>(s)}function Z5(s){s=s|0,k4e(s)}function b4e(s){s=s|0,x4e(s+24|0)}function x4e(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function k4e(s){s=s|0;var l=0;l=Kr()|0,Vr(s,1,12,l,Q4e()|0,2),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function Q4e(){return 1896}function F4e(s,l,c){s=s|0,l=l|0,c=c|0,T4e(n[(R4e(s)|0)>>2]|0,l,c)}function R4e(s){return s=s|0,(n[(IR()|0)+24>>2]|0)+(s<<3)|0}function T4e(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;f=C,C=C+16|0,m=f+4|0,d=f,N4e(m,l),l=L4e(m,l)|0,Dp(d,c),c=Sp(d,c)|0,rf[s&31](l,c),Pp(d),C=f}function N4e(s,l){s=s|0,l=l|0}function L4e(s,l){return s=s|0,l=l|0,O4e(l)|0}function O4e(s){return s=s|0,s|0}function M4e(){var s=0;return o[8080]|0||($5(11040),tr(70,11040,U|0)|0,s=8080,n[s>>2]=1,n[s+4>>2]=0),Tr(11040)|0||$5(11040),11040}function $5(s){s=s|0,H4e(s),Qg(s,71)}function U4e(s){s=s|0,_4e(s+24|0)}function _4e(s){s=s|0;var l=0,c=0,f=0;c=n[s>>2]|0,f=c,c|0&&(s=s+4|0,l=n[s>>2]|0,(l|0)!=(c|0)&&(n[s>>2]=l+(~((l+-8-f|0)>>>3)<<3)),gt(c))}function H4e(s){s=s|0;var l=0;l=Kr()|0,Vr(s,5,7,l,Y4e()|0,0),n[s+24>>2]=0,n[s+28>>2]=0,n[s+32>>2]=0}function j4e(s){s=s|0,G4e(s)}function G4e(s){s=s|0,q4e(s)}function q4e(s){s=s|0,o[s+8>>0]=1}function Y4e(){return 1936}function W4e(){return K4e()|0}function K4e(){var s=0,l=0,c=0,f=0,d=0,m=0,B=0;return l=C,C=C+16|0,d=l+4|0,B=l,c=Wa(8)|0,s=c,m=s+4|0,n[m>>2]=Kt(1)|0,f=Kt(8)|0,m=n[m>>2]|0,n[B>>2]=0,n[d>>2]=n[B>>2],V4e(f,m,d),n[c>>2]=f,C=l,s|0}function V4e(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]=l,c=Kt(16)|0,n[c+4>>2]=0,n[c+8>>2]=0,n[c>>2]=1916,n[c+12>>2]=l,n[s+4>>2]=c}function J4e(s){s=s|0,zm(s),gt(s)}function z4e(s){s=s|0,s=n[s+12>>2]|0,s|0&>(s)}function X4e(s){s=s|0,gt(s)}function Z4e(){var s=0;return o[8088]|0||(sUe(11076),tr(25,11076,U|0)|0,s=8088,n[s>>2]=1,n[s+4>>2]=0),11076}function $4e(s,l){s=s|0,l=l|0,n[s>>2]=eUe()|0,n[s+4>>2]=tUe()|0,n[s+12>>2]=l,n[s+8>>2]=rUe()|0,n[s+32>>2]=10}function eUe(){return 11745}function tUe(){return 1940}function rUe(){return aD()|0}function nUe(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,(bp(f,896)|0)==512?c|0&&(iUe(c),gt(c)):l|0&>(l)}function iUe(s){s=s|0,s=n[s+4>>2]|0,s|0&&kp(s)}function sUe(s){s=s|0,vp(s)}function bc(s,l){s=s|0,l=l|0,n[s>>2]=l}function BR(s){return s=s|0,n[s>>2]|0}function oUe(s){return s=s|0,o[n[s>>2]>>0]|0}function aUe(s,l){s=s|0,l=l|0;var c=0,f=0;c=C,C=C+16|0,f=c,n[f>>2]=n[s>>2],lUe(l,f)|0,C=c}function lUe(s,l){s=s|0,l=l|0;var c=0;return c=cUe(n[s>>2]|0,l)|0,l=s+4|0,n[(n[l>>2]|0)+8>>2]=c,n[(n[l>>2]|0)+8>>2]|0}function cUe(s,l){s=s|0,l=l|0;var c=0,f=0;return c=C,C=C+16|0,f=c,Ka(f),s=da(s)|0,l=uUe(s,n[l>>2]|0)|0,Va(f),C=c,l|0}function Ka(s){s=s|0,n[s>>2]=n[2701],n[s+4>>2]=n[2703]}function uUe(s,l){s=s|0,l=l|0;var c=0;return c=Sl(AUe()|0)|0,Qn(0,c|0,s|0,dR(l)|0)|0}function Va(s){s=s|0,j5(n[s>>2]|0,n[s+4>>2]|0)}function AUe(){var s=0;return o[8096]|0||(fUe(11120),s=8096,n[s>>2]=1,n[s+4>>2]=0),11120}function fUe(s){s=s|0,Pl(s,pUe()|0,1)}function pUe(){return 1948}function hUe(){gUe()}function gUe(){var s=0,l=0,c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0,qe=0,Me=0,Qe=0;if(Me=C,C=C+16|0,O=Me+4|0,G=Me,Ni(65536,10804,n[2702]|0,10812),c=B5()|0,l=n[c>>2]|0,s=n[l>>2]|0,s|0)for(f=n[c+8>>2]|0,c=n[c+4>>2]|0;uc(s|0,u[c>>0]|0|0,o[f>>0]|0),l=l+4|0,s=n[l>>2]|0,s;)f=f+1|0,c=c+1|0;if(s=v5()|0,l=n[s>>2]|0,l|0)do uu(l|0,n[s+4>>2]|0),s=s+8|0,l=n[s>>2]|0;while((l|0)!=0);uu(dUe()|0,5167),M=Ym()|0,s=n[M>>2]|0;e:do if(s|0){do mUe(n[s+4>>2]|0),s=n[s>>2]|0;while((s|0)!=0);if(s=n[M>>2]|0,s|0){Q=M;do{for(;d=s,s=n[s>>2]|0,d=n[d+4>>2]|0,!!(yUe(d)|0);)if(n[G>>2]=Q,n[O>>2]=n[G>>2],EUe(M,O)|0,!s)break e;if(CUe(d),Q=n[Q>>2]|0,l=e7(d)|0,m=Hi()|0,B=C,C=C+((1*(l<<2)|0)+15&-16)|0,k=C,C=C+((1*(l<<2)|0)+15&-16)|0,l=n[(L5(d)|0)>>2]|0,l|0)for(c=B,f=k;n[c>>2]=n[(Wm(n[l+4>>2]|0)|0)>>2],n[f>>2]=n[l+8>>2],l=n[l>>2]|0,l;)c=c+4|0,f=f+4|0;Qe=Wm(d)|0,l=wUe(d)|0,c=e7(d)|0,f=IUe(d)|0,Au(Qe|0,l|0,B|0,k|0,c|0,f|0,uR(d)|0),_i(m|0)}while((s|0)!=0)}}while(0);if(s=n[(AR()|0)>>2]|0,s|0)do Qe=s+4|0,M=fR(Qe)|0,d=Ow(M)|0,m=Nw(M)|0,B=(Lw(M)|0)+1|0,k=fD(M)|0,Q=t7(Qe)|0,M=Tr(M)|0,O=cD(Qe)|0,G=vR(Qe)|0,El(0,d|0,m|0,B|0,k|0,Q|0,M|0,O|0,G|0,DR(Qe)|0),s=n[s>>2]|0;while((s|0)!=0);s=n[(Ym()|0)>>2]|0;e:do if(s|0){t:for(;;){if(l=n[s+4>>2]|0,l|0&&(se=n[(Wm(l)|0)>>2]|0,qe=n[(O5(l)|0)>>2]|0,qe|0)){c=qe;do{l=c+4|0,f=fR(l)|0;r:do if(f|0)switch(Tr(f)|0){case 0:break t;case 4:case 3:case 2:{k=Ow(f)|0,Q=Nw(f)|0,M=(Lw(f)|0)+1|0,O=fD(f)|0,G=Tr(f)|0,Qe=cD(l)|0,El(se|0,k|0,Q|0,M|0,O|0,0,G|0,Qe|0,vR(l)|0,DR(l)|0);break r}case 1:{B=Ow(f)|0,k=Nw(f)|0,Q=(Lw(f)|0)+1|0,M=fD(f)|0,O=t7(l)|0,G=Tr(f)|0,Qe=cD(l)|0,El(se|0,B|0,k|0,Q|0,M|0,O|0,G|0,Qe|0,vR(l)|0,DR(l)|0);break r}case 5:{M=Ow(f)|0,O=Nw(f)|0,G=(Lw(f)|0)+1|0,Qe=fD(f)|0,El(se|0,M|0,O|0,G|0,Qe|0,BUe(f)|0,Tr(f)|0,0,0,0);break r}default:break r}while(0);c=n[c>>2]|0}while((c|0)!=0)}if(s=n[s>>2]|0,!s)break e}Rt()}while(0);Ce(),C=Me}function dUe(){return 11703}function mUe(s){s=s|0,o[s+40>>0]=0}function yUe(s){return s=s|0,(o[s+40>>0]|0)!=0|0}function EUe(s,l){return s=s|0,l=l|0,l=vUe(l)|0,s=n[l>>2]|0,n[l>>2]=n[s>>2],gt(s),n[l>>2]|0}function CUe(s){s=s|0,o[s+40>>0]=1}function e7(s){return s=s|0,n[s+20>>2]|0}function wUe(s){return s=s|0,n[s+8>>2]|0}function IUe(s){return s=s|0,n[s+32>>2]|0}function fD(s){return s=s|0,n[s+4>>2]|0}function t7(s){return s=s|0,n[s+4>>2]|0}function vR(s){return s=s|0,n[s+8>>2]|0}function DR(s){return s=s|0,n[s+16>>2]|0}function BUe(s){return s=s|0,n[s+20>>2]|0}function vUe(s){return s=s|0,n[s>>2]|0}function pD(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0,qe=0,Me=0,Qe=0,et=0,Xe=0,lt=0,Ue=0,Ge=0,Lt=0;Lt=C,C=C+16|0,se=Lt;do if(s>>>0<245){if(M=s>>>0<11?16:s+11&-8,s=M>>>3,G=n[2783]|0,c=G>>>s,c&3|0)return l=(c&1^1)+s|0,s=11172+(l<<1<<2)|0,c=s+8|0,f=n[c>>2]|0,d=f+8|0,m=n[d>>2]|0,(s|0)==(m|0)?n[2783]=G&~(1<<l):(n[m+12>>2]=s,n[c>>2]=m),Ge=l<<3,n[f+4>>2]=Ge|3,Ge=f+Ge+4|0,n[Ge>>2]=n[Ge>>2]|1,Ge=d,C=Lt,Ge|0;if(O=n[2785]|0,M>>>0>O>>>0){if(c|0)return l=2<<s,l=c<<s&(l|0-l),l=(l&0-l)+-1|0,B=l>>>12&16,l=l>>>B,c=l>>>5&8,l=l>>>c,d=l>>>2&4,l=l>>>d,s=l>>>1&2,l=l>>>s,f=l>>>1&1,f=(c|B|d|s|f)+(l>>>f)|0,l=11172+(f<<1<<2)|0,s=l+8|0,d=n[s>>2]|0,B=d+8|0,c=n[B>>2]|0,(l|0)==(c|0)?(s=G&~(1<<f),n[2783]=s):(n[c+12>>2]=l,n[s>>2]=c,s=G),m=(f<<3)-M|0,n[d+4>>2]=M|3,f=d+M|0,n[f+4>>2]=m|1,n[f+m>>2]=m,O|0&&(d=n[2788]|0,l=O>>>3,c=11172+(l<<1<<2)|0,l=1<<l,s&l?(s=c+8|0,l=n[s>>2]|0):(n[2783]=s|l,l=c,s=c+8|0),n[s>>2]=d,n[l+12>>2]=d,n[d+8>>2]=l,n[d+12>>2]=c),n[2785]=m,n[2788]=f,Ge=B,C=Lt,Ge|0;if(k=n[2784]|0,k){if(c=(k&0-k)+-1|0,B=c>>>12&16,c=c>>>B,m=c>>>5&8,c=c>>>m,Q=c>>>2&4,c=c>>>Q,f=c>>>1&2,c=c>>>f,s=c>>>1&1,s=n[11436+((m|B|Q|f|s)+(c>>>s)<<2)>>2]|0,c=(n[s+4>>2]&-8)-M|0,f=n[s+16+(((n[s+16>>2]|0)==0&1)<<2)>>2]|0,!f)Q=s,m=c;else{do B=(n[f+4>>2]&-8)-M|0,Q=B>>>0<c>>>0,c=Q?B:c,s=Q?f:s,f=n[f+16+(((n[f+16>>2]|0)==0&1)<<2)>>2]|0;while((f|0)!=0);Q=s,m=c}if(B=Q+M|0,Q>>>0<B>>>0){d=n[Q+24>>2]|0,l=n[Q+12>>2]|0;do if((l|0)==(Q|0)){if(s=Q+20|0,l=n[s>>2]|0,!l&&(s=Q+16|0,l=n[s>>2]|0,!l)){c=0;break}for(;;){if(c=l+20|0,f=n[c>>2]|0,f|0){l=f,s=c;continue}if(c=l+16|0,f=n[c>>2]|0,f)l=f,s=c;else break}n[s>>2]=0,c=l}else c=n[Q+8>>2]|0,n[c+12>>2]=l,n[l+8>>2]=c,c=l;while(0);do if(d|0){if(l=n[Q+28>>2]|0,s=11436+(l<<2)|0,(Q|0)==(n[s>>2]|0)){if(n[s>>2]=c,!c){n[2784]=k&~(1<<l);break}}else if(n[d+16+(((n[d+16>>2]|0)!=(Q|0)&1)<<2)>>2]=c,!c)break;n[c+24>>2]=d,l=n[Q+16>>2]|0,l|0&&(n[c+16>>2]=l,n[l+24>>2]=c),l=n[Q+20>>2]|0,l|0&&(n[c+20>>2]=l,n[l+24>>2]=c)}while(0);return m>>>0<16?(Ge=m+M|0,n[Q+4>>2]=Ge|3,Ge=Q+Ge+4|0,n[Ge>>2]=n[Ge>>2]|1):(n[Q+4>>2]=M|3,n[B+4>>2]=m|1,n[B+m>>2]=m,O|0&&(f=n[2788]|0,l=O>>>3,c=11172+(l<<1<<2)|0,l=1<<l,G&l?(s=c+8|0,l=n[s>>2]|0):(n[2783]=G|l,l=c,s=c+8|0),n[s>>2]=f,n[l+12>>2]=f,n[f+8>>2]=l,n[f+12>>2]=c),n[2785]=m,n[2788]=B),Ge=Q+8|0,C=Lt,Ge|0}else G=M}else G=M}else G=M}else if(s>>>0<=4294967231)if(s=s+11|0,M=s&-8,Q=n[2784]|0,Q){f=0-M|0,s=s>>>8,s?M>>>0>16777215?k=31:(G=(s+1048320|0)>>>16&8,Ue=s<<G,O=(Ue+520192|0)>>>16&4,Ue=Ue<<O,k=(Ue+245760|0)>>>16&2,k=14-(O|G|k)+(Ue<<k>>>15)|0,k=M>>>(k+7|0)&1|k<<1):k=0,c=n[11436+(k<<2)>>2]|0;e:do if(!c)c=0,s=0,Ue=57;else for(s=0,B=M<<((k|0)==31?0:25-(k>>>1)|0),m=0;;){if(d=(n[c+4>>2]&-8)-M|0,d>>>0<f>>>0)if(d)s=c,f=d;else{s=c,f=0,d=c,Ue=61;break e}if(d=n[c+20>>2]|0,c=n[c+16+(B>>>31<<2)>>2]|0,m=(d|0)==0|(d|0)==(c|0)?m:d,d=(c|0)==0,d){c=m,Ue=57;break}else B=B<<((d^1)&1)}while(0);if((Ue|0)==57){if((c|0)==0&(s|0)==0){if(s=2<<k,s=Q&(s|0-s),!s){G=M;break}G=(s&0-s)+-1|0,B=G>>>12&16,G=G>>>B,m=G>>>5&8,G=G>>>m,k=G>>>2&4,G=G>>>k,O=G>>>1&2,G=G>>>O,c=G>>>1&1,s=0,c=n[11436+((m|B|k|O|c)+(G>>>c)<<2)>>2]|0}c?(d=c,Ue=61):(k=s,B=f)}if((Ue|0)==61)for(;;)if(Ue=0,c=(n[d+4>>2]&-8)-M|0,G=c>>>0<f>>>0,c=G?c:f,s=G?d:s,d=n[d+16+(((n[d+16>>2]|0)==0&1)<<2)>>2]|0,d)f=c,Ue=61;else{k=s,B=c;break}if((k|0)!=0&&B>>>0<((n[2785]|0)-M|0)>>>0){if(m=k+M|0,k>>>0>=m>>>0)return Ge=0,C=Lt,Ge|0;d=n[k+24>>2]|0,l=n[k+12>>2]|0;do if((l|0)==(k|0)){if(s=k+20|0,l=n[s>>2]|0,!l&&(s=k+16|0,l=n[s>>2]|0,!l)){l=0;break}for(;;){if(c=l+20|0,f=n[c>>2]|0,f|0){l=f,s=c;continue}if(c=l+16|0,f=n[c>>2]|0,f)l=f,s=c;else break}n[s>>2]=0}else Ge=n[k+8>>2]|0,n[Ge+12>>2]=l,n[l+8>>2]=Ge;while(0);do if(d){if(s=n[k+28>>2]|0,c=11436+(s<<2)|0,(k|0)==(n[c>>2]|0)){if(n[c>>2]=l,!l){f=Q&~(1<<s),n[2784]=f;break}}else if(n[d+16+(((n[d+16>>2]|0)!=(k|0)&1)<<2)>>2]=l,!l){f=Q;break}n[l+24>>2]=d,s=n[k+16>>2]|0,s|0&&(n[l+16>>2]=s,n[s+24>>2]=l),s=n[k+20>>2]|0,s&&(n[l+20>>2]=s,n[s+24>>2]=l),f=Q}else f=Q;while(0);do if(B>>>0>=16){if(n[k+4>>2]=M|3,n[m+4>>2]=B|1,n[m+B>>2]=B,l=B>>>3,B>>>0<256){c=11172+(l<<1<<2)|0,s=n[2783]|0,l=1<<l,s&l?(s=c+8|0,l=n[s>>2]|0):(n[2783]=s|l,l=c,s=c+8|0),n[s>>2]=m,n[l+12>>2]=m,n[m+8>>2]=l,n[m+12>>2]=c;break}if(l=B>>>8,l?B>>>0>16777215?l=31:(Ue=(l+1048320|0)>>>16&8,Ge=l<<Ue,lt=(Ge+520192|0)>>>16&4,Ge=Ge<<lt,l=(Ge+245760|0)>>>16&2,l=14-(lt|Ue|l)+(Ge<<l>>>15)|0,l=B>>>(l+7|0)&1|l<<1):l=0,c=11436+(l<<2)|0,n[m+28>>2]=l,s=m+16|0,n[s+4>>2]=0,n[s>>2]=0,s=1<<l,!(f&s)){n[2784]=f|s,n[c>>2]=m,n[m+24>>2]=c,n[m+12>>2]=m,n[m+8>>2]=m;break}for(s=B<<((l|0)==31?0:25-(l>>>1)|0),c=n[c>>2]|0;;){if((n[c+4>>2]&-8|0)==(B|0)){Ue=97;break}if(f=c+16+(s>>>31<<2)|0,l=n[f>>2]|0,l)s=s<<1,c=l;else{Ue=96;break}}if((Ue|0)==96){n[f>>2]=m,n[m+24>>2]=c,n[m+12>>2]=m,n[m+8>>2]=m;break}else if((Ue|0)==97){Ue=c+8|0,Ge=n[Ue>>2]|0,n[Ge+12>>2]=m,n[Ue>>2]=m,n[m+8>>2]=Ge,n[m+12>>2]=c,n[m+24>>2]=0;break}}else Ge=B+M|0,n[k+4>>2]=Ge|3,Ge=k+Ge+4|0,n[Ge>>2]=n[Ge>>2]|1;while(0);return Ge=k+8|0,C=Lt,Ge|0}else G=M}else G=M;else G=-1;while(0);if(c=n[2785]|0,c>>>0>=G>>>0)return l=c-G|0,s=n[2788]|0,l>>>0>15?(Ge=s+G|0,n[2788]=Ge,n[2785]=l,n[Ge+4>>2]=l|1,n[Ge+l>>2]=l,n[s+4>>2]=G|3):(n[2785]=0,n[2788]=0,n[s+4>>2]=c|3,Ge=s+c+4|0,n[Ge>>2]=n[Ge>>2]|1),Ge=s+8|0,C=Lt,Ge|0;if(B=n[2786]|0,B>>>0>G>>>0)return lt=B-G|0,n[2786]=lt,Ge=n[2789]|0,Ue=Ge+G|0,n[2789]=Ue,n[Ue+4>>2]=lt|1,n[Ge+4>>2]=G|3,Ge=Ge+8|0,C=Lt,Ge|0;if(n[2901]|0?s=n[2903]|0:(n[2903]=4096,n[2902]=4096,n[2904]=-1,n[2905]=-1,n[2906]=0,n[2894]=0,s=se&-16^1431655768,n[se>>2]=s,n[2901]=s,s=4096),k=G+48|0,Q=G+47|0,m=s+Q|0,d=0-s|0,M=m&d,M>>>0<=G>>>0||(s=n[2893]|0,s|0&&(O=n[2891]|0,se=O+M|0,se>>>0<=O>>>0|se>>>0>s>>>0)))return Ge=0,C=Lt,Ge|0;e:do if(n[2894]&4)l=0,Ue=133;else{c=n[2789]|0;t:do if(c){for(f=11580;s=n[f>>2]|0,!(s>>>0<=c>>>0&&(Qe=f+4|0,(s+(n[Qe>>2]|0)|0)>>>0>c>>>0));)if(s=n[f+8>>2]|0,s)f=s;else{Ue=118;break t}if(l=m-B&d,l>>>0<2147483647)if(s=Qp(l|0)|0,(s|0)==((n[f>>2]|0)+(n[Qe>>2]|0)|0)){if((s|0)!=-1){B=l,m=s,Ue=135;break e}}else f=s,Ue=126;else l=0}else Ue=118;while(0);do if((Ue|0)==118)if(c=Qp(0)|0,(c|0)!=-1&&(l=c,qe=n[2902]|0,Me=qe+-1|0,l=((Me&l|0)==0?0:(Me+l&0-qe)-l|0)+M|0,qe=n[2891]|0,Me=l+qe|0,l>>>0>G>>>0&l>>>0<2147483647)){if(Qe=n[2893]|0,Qe|0&&Me>>>0<=qe>>>0|Me>>>0>Qe>>>0){l=0;break}if(s=Qp(l|0)|0,(s|0)==(c|0)){B=l,m=c,Ue=135;break e}else f=s,Ue=126}else l=0;while(0);do if((Ue|0)==126){if(c=0-l|0,!(k>>>0>l>>>0&(l>>>0<2147483647&(f|0)!=-1)))if((f|0)==-1){l=0;break}else{B=l,m=f,Ue=135;break e}if(s=n[2903]|0,s=Q-l+s&0-s,s>>>0>=2147483647){B=l,m=f,Ue=135;break e}if((Qp(s|0)|0)==-1){Qp(c|0)|0,l=0;break}else{B=s+l|0,m=f,Ue=135;break e}}while(0);n[2894]=n[2894]|4,Ue=133}while(0);if((Ue|0)==133&&M>>>0<2147483647&&(lt=Qp(M|0)|0,Qe=Qp(0)|0,et=Qe-lt|0,Xe=et>>>0>(G+40|0)>>>0,!((lt|0)==-1|Xe^1|lt>>>0<Qe>>>0&((lt|0)!=-1&(Qe|0)!=-1)^1))&&(B=Xe?et:l,m=lt,Ue=135),(Ue|0)==135){l=(n[2891]|0)+B|0,n[2891]=l,l>>>0>(n[2892]|0)>>>0&&(n[2892]=l),Q=n[2789]|0;do if(Q){for(l=11580;;){if(s=n[l>>2]|0,c=l+4|0,f=n[c>>2]|0,(m|0)==(s+f|0)){Ue=145;break}if(d=n[l+8>>2]|0,d)l=d;else break}if((Ue|0)==145&&(n[l+12>>2]&8|0)==0&&Q>>>0<m>>>0&Q>>>0>=s>>>0){n[c>>2]=f+B,Ge=Q+8|0,Ge=(Ge&7|0)==0?0:0-Ge&7,Ue=Q+Ge|0,Ge=(n[2786]|0)+(B-Ge)|0,n[2789]=Ue,n[2786]=Ge,n[Ue+4>>2]=Ge|1,n[Ue+Ge+4>>2]=40,n[2790]=n[2905];break}for(m>>>0<(n[2787]|0)>>>0&&(n[2787]=m),c=m+B|0,l=11580;;){if((n[l>>2]|0)==(c|0)){Ue=153;break}if(s=n[l+8>>2]|0,s)l=s;else break}if((Ue|0)==153&&(n[l+12>>2]&8|0)==0){n[l>>2]=m,O=l+4|0,n[O>>2]=(n[O>>2]|0)+B,O=m+8|0,O=m+((O&7|0)==0?0:0-O&7)|0,l=c+8|0,l=c+((l&7|0)==0?0:0-l&7)|0,M=O+G|0,k=l-O-G|0,n[O+4>>2]=G|3;do if((l|0)!=(Q|0)){if((l|0)==(n[2788]|0)){Ge=(n[2785]|0)+k|0,n[2785]=Ge,n[2788]=M,n[M+4>>2]=Ge|1,n[M+Ge>>2]=Ge;break}if(s=n[l+4>>2]|0,(s&3|0)==1){B=s&-8,f=s>>>3;e:do if(s>>>0<256)if(s=n[l+8>>2]|0,c=n[l+12>>2]|0,(c|0)==(s|0)){n[2783]=n[2783]&~(1<<f);break}else{n[s+12>>2]=c,n[c+8>>2]=s;break}else{m=n[l+24>>2]|0,s=n[l+12>>2]|0;do if((s|0)==(l|0)){if(f=l+16|0,c=f+4|0,s=n[c>>2]|0,!s)if(s=n[f>>2]|0,s)c=f;else{s=0;break}for(;;){if(f=s+20|0,d=n[f>>2]|0,d|0){s=d,c=f;continue}if(f=s+16|0,d=n[f>>2]|0,d)s=d,c=f;else break}n[c>>2]=0}else Ge=n[l+8>>2]|0,n[Ge+12>>2]=s,n[s+8>>2]=Ge;while(0);if(!m)break;c=n[l+28>>2]|0,f=11436+(c<<2)|0;do if((l|0)!=(n[f>>2]|0)){if(n[m+16+(((n[m+16>>2]|0)!=(l|0)&1)<<2)>>2]=s,!s)break e}else{if(n[f>>2]=s,s|0)break;n[2784]=n[2784]&~(1<<c);break e}while(0);if(n[s+24>>2]=m,c=l+16|0,f=n[c>>2]|0,f|0&&(n[s+16>>2]=f,n[f+24>>2]=s),c=n[c+4>>2]|0,!c)break;n[s+20>>2]=c,n[c+24>>2]=s}while(0);l=l+B|0,d=B+k|0}else d=k;if(l=l+4|0,n[l>>2]=n[l>>2]&-2,n[M+4>>2]=d|1,n[M+d>>2]=d,l=d>>>3,d>>>0<256){c=11172+(l<<1<<2)|0,s=n[2783]|0,l=1<<l,s&l?(s=c+8|0,l=n[s>>2]|0):(n[2783]=s|l,l=c,s=c+8|0),n[s>>2]=M,n[l+12>>2]=M,n[M+8>>2]=l,n[M+12>>2]=c;break}l=d>>>8;do if(!l)l=0;else{if(d>>>0>16777215){l=31;break}Ue=(l+1048320|0)>>>16&8,Ge=l<<Ue,lt=(Ge+520192|0)>>>16&4,Ge=Ge<<lt,l=(Ge+245760|0)>>>16&2,l=14-(lt|Ue|l)+(Ge<<l>>>15)|0,l=d>>>(l+7|0)&1|l<<1}while(0);if(f=11436+(l<<2)|0,n[M+28>>2]=l,s=M+16|0,n[s+4>>2]=0,n[s>>2]=0,s=n[2784]|0,c=1<<l,!(s&c)){n[2784]=s|c,n[f>>2]=M,n[M+24>>2]=f,n[M+12>>2]=M,n[M+8>>2]=M;break}for(s=d<<((l|0)==31?0:25-(l>>>1)|0),c=n[f>>2]|0;;){if((n[c+4>>2]&-8|0)==(d|0)){Ue=194;break}if(f=c+16+(s>>>31<<2)|0,l=n[f>>2]|0,l)s=s<<1,c=l;else{Ue=193;break}}if((Ue|0)==193){n[f>>2]=M,n[M+24>>2]=c,n[M+12>>2]=M,n[M+8>>2]=M;break}else if((Ue|0)==194){Ue=c+8|0,Ge=n[Ue>>2]|0,n[Ge+12>>2]=M,n[Ue>>2]=M,n[M+8>>2]=Ge,n[M+12>>2]=c,n[M+24>>2]=0;break}}else Ge=(n[2786]|0)+k|0,n[2786]=Ge,n[2789]=M,n[M+4>>2]=Ge|1;while(0);return Ge=O+8|0,C=Lt,Ge|0}for(l=11580;s=n[l>>2]|0,!(s>>>0<=Q>>>0&&(Ge=s+(n[l+4>>2]|0)|0,Ge>>>0>Q>>>0));)l=n[l+8>>2]|0;d=Ge+-47|0,s=d+8|0,s=d+((s&7|0)==0?0:0-s&7)|0,d=Q+16|0,s=s>>>0<d>>>0?Q:s,l=s+8|0,c=m+8|0,c=(c&7|0)==0?0:0-c&7,Ue=m+c|0,c=B+-40-c|0,n[2789]=Ue,n[2786]=c,n[Ue+4>>2]=c|1,n[Ue+c+4>>2]=40,n[2790]=n[2905],c=s+4|0,n[c>>2]=27,n[l>>2]=n[2895],n[l+4>>2]=n[2896],n[l+8>>2]=n[2897],n[l+12>>2]=n[2898],n[2895]=m,n[2896]=B,n[2898]=0,n[2897]=l,l=s+24|0;do Ue=l,l=l+4|0,n[l>>2]=7;while((Ue+8|0)>>>0<Ge>>>0);if((s|0)!=(Q|0)){if(m=s-Q|0,n[c>>2]=n[c>>2]&-2,n[Q+4>>2]=m|1,n[s>>2]=m,l=m>>>3,m>>>0<256){c=11172+(l<<1<<2)|0,s=n[2783]|0,l=1<<l,s&l?(s=c+8|0,l=n[s>>2]|0):(n[2783]=s|l,l=c,s=c+8|0),n[s>>2]=Q,n[l+12>>2]=Q,n[Q+8>>2]=l,n[Q+12>>2]=c;break}if(l=m>>>8,l?m>>>0>16777215?c=31:(Ue=(l+1048320|0)>>>16&8,Ge=l<<Ue,lt=(Ge+520192|0)>>>16&4,Ge=Ge<<lt,c=(Ge+245760|0)>>>16&2,c=14-(lt|Ue|c)+(Ge<<c>>>15)|0,c=m>>>(c+7|0)&1|c<<1):c=0,f=11436+(c<<2)|0,n[Q+28>>2]=c,n[Q+20>>2]=0,n[d>>2]=0,l=n[2784]|0,s=1<<c,!(l&s)){n[2784]=l|s,n[f>>2]=Q,n[Q+24>>2]=f,n[Q+12>>2]=Q,n[Q+8>>2]=Q;break}for(s=m<<((c|0)==31?0:25-(c>>>1)|0),c=n[f>>2]|0;;){if((n[c+4>>2]&-8|0)==(m|0)){Ue=216;break}if(f=c+16+(s>>>31<<2)|0,l=n[f>>2]|0,l)s=s<<1,c=l;else{Ue=215;break}}if((Ue|0)==215){n[f>>2]=Q,n[Q+24>>2]=c,n[Q+12>>2]=Q,n[Q+8>>2]=Q;break}else if((Ue|0)==216){Ue=c+8|0,Ge=n[Ue>>2]|0,n[Ge+12>>2]=Q,n[Ue>>2]=Q,n[Q+8>>2]=Ge,n[Q+12>>2]=c,n[Q+24>>2]=0;break}}}else{Ge=n[2787]|0,(Ge|0)==0|m>>>0<Ge>>>0&&(n[2787]=m),n[2895]=m,n[2896]=B,n[2898]=0,n[2792]=n[2901],n[2791]=-1,l=0;do Ge=11172+(l<<1<<2)|0,n[Ge+12>>2]=Ge,n[Ge+8>>2]=Ge,l=l+1|0;while((l|0)!=32);Ge=m+8|0,Ge=(Ge&7|0)==0?0:0-Ge&7,Ue=m+Ge|0,Ge=B+-40-Ge|0,n[2789]=Ue,n[2786]=Ge,n[Ue+4>>2]=Ge|1,n[Ue+Ge+4>>2]=40,n[2790]=n[2905]}while(0);if(l=n[2786]|0,l>>>0>G>>>0)return lt=l-G|0,n[2786]=lt,Ge=n[2789]|0,Ue=Ge+G|0,n[2789]=Ue,n[Ue+4>>2]=lt|1,n[Ge+4>>2]=G|3,Ge=Ge+8|0,C=Lt,Ge|0}return n[(Vm()|0)>>2]=12,Ge=0,C=Lt,Ge|0}function hD(s){s=s|0;var l=0,c=0,f=0,d=0,m=0,B=0,k=0,Q=0;if(!!s){c=s+-8|0,d=n[2787]|0,s=n[s+-4>>2]|0,l=s&-8,Q=c+l|0;do if(s&1)k=c,B=c;else{if(f=n[c>>2]|0,!(s&3)||(B=c+(0-f)|0,m=f+l|0,B>>>0<d>>>0))return;if((B|0)==(n[2788]|0)){if(s=Q+4|0,l=n[s>>2]|0,(l&3|0)!=3){k=B,l=m;break}n[2785]=m,n[s>>2]=l&-2,n[B+4>>2]=m|1,n[B+m>>2]=m;return}if(c=f>>>3,f>>>0<256)if(s=n[B+8>>2]|0,l=n[B+12>>2]|0,(l|0)==(s|0)){n[2783]=n[2783]&~(1<<c),k=B,l=m;break}else{n[s+12>>2]=l,n[l+8>>2]=s,k=B,l=m;break}d=n[B+24>>2]|0,s=n[B+12>>2]|0;do if((s|0)==(B|0)){if(c=B+16|0,l=c+4|0,s=n[l>>2]|0,!s)if(s=n[c>>2]|0,s)l=c;else{s=0;break}for(;;){if(c=s+20|0,f=n[c>>2]|0,f|0){s=f,l=c;continue}if(c=s+16|0,f=n[c>>2]|0,f)s=f,l=c;else break}n[l>>2]=0}else k=n[B+8>>2]|0,n[k+12>>2]=s,n[s+8>>2]=k;while(0);if(d){if(l=n[B+28>>2]|0,c=11436+(l<<2)|0,(B|0)==(n[c>>2]|0)){if(n[c>>2]=s,!s){n[2784]=n[2784]&~(1<<l),k=B,l=m;break}}else if(n[d+16+(((n[d+16>>2]|0)!=(B|0)&1)<<2)>>2]=s,!s){k=B,l=m;break}n[s+24>>2]=d,l=B+16|0,c=n[l>>2]|0,c|0&&(n[s+16>>2]=c,n[c+24>>2]=s),l=n[l+4>>2]|0,l?(n[s+20>>2]=l,n[l+24>>2]=s,k=B,l=m):(k=B,l=m)}else k=B,l=m}while(0);if(!(B>>>0>=Q>>>0)&&(s=Q+4|0,f=n[s>>2]|0,!!(f&1))){if(f&2)n[s>>2]=f&-2,n[k+4>>2]=l|1,n[B+l>>2]=l,d=l;else{if(s=n[2788]|0,(Q|0)==(n[2789]|0)){if(Q=(n[2786]|0)+l|0,n[2786]=Q,n[2789]=k,n[k+4>>2]=Q|1,(k|0)!=(s|0))return;n[2788]=0,n[2785]=0;return}if((Q|0)==(s|0)){Q=(n[2785]|0)+l|0,n[2785]=Q,n[2788]=B,n[k+4>>2]=Q|1,n[B+Q>>2]=Q;return}d=(f&-8)+l|0,c=f>>>3;do if(f>>>0<256)if(l=n[Q+8>>2]|0,s=n[Q+12>>2]|0,(s|0)==(l|0)){n[2783]=n[2783]&~(1<<c);break}else{n[l+12>>2]=s,n[s+8>>2]=l;break}else{m=n[Q+24>>2]|0,s=n[Q+12>>2]|0;do if((s|0)==(Q|0)){if(c=Q+16|0,l=c+4|0,s=n[l>>2]|0,!s)if(s=n[c>>2]|0,s)l=c;else{c=0;break}for(;;){if(c=s+20|0,f=n[c>>2]|0,f|0){s=f,l=c;continue}if(c=s+16|0,f=n[c>>2]|0,f)s=f,l=c;else break}n[l>>2]=0,c=s}else c=n[Q+8>>2]|0,n[c+12>>2]=s,n[s+8>>2]=c,c=s;while(0);if(m|0){if(s=n[Q+28>>2]|0,l=11436+(s<<2)|0,(Q|0)==(n[l>>2]|0)){if(n[l>>2]=c,!c){n[2784]=n[2784]&~(1<<s);break}}else if(n[m+16+(((n[m+16>>2]|0)!=(Q|0)&1)<<2)>>2]=c,!c)break;n[c+24>>2]=m,s=Q+16|0,l=n[s>>2]|0,l|0&&(n[c+16>>2]=l,n[l+24>>2]=c),s=n[s+4>>2]|0,s|0&&(n[c+20>>2]=s,n[s+24>>2]=c)}}while(0);if(n[k+4>>2]=d|1,n[B+d>>2]=d,(k|0)==(n[2788]|0)){n[2785]=d;return}}if(s=d>>>3,d>>>0<256){c=11172+(s<<1<<2)|0,l=n[2783]|0,s=1<<s,l&s?(l=c+8|0,s=n[l>>2]|0):(n[2783]=l|s,s=c,l=c+8|0),n[l>>2]=k,n[s+12>>2]=k,n[k+8>>2]=s,n[k+12>>2]=c;return}s=d>>>8,s?d>>>0>16777215?s=31:(B=(s+1048320|0)>>>16&8,Q=s<<B,m=(Q+520192|0)>>>16&4,Q=Q<<m,s=(Q+245760|0)>>>16&2,s=14-(m|B|s)+(Q<<s>>>15)|0,s=d>>>(s+7|0)&1|s<<1):s=0,f=11436+(s<<2)|0,n[k+28>>2]=s,n[k+20>>2]=0,n[k+16>>2]=0,l=n[2784]|0,c=1<<s;do if(l&c){for(l=d<<((s|0)==31?0:25-(s>>>1)|0),c=n[f>>2]|0;;){if((n[c+4>>2]&-8|0)==(d|0)){s=73;break}if(f=c+16+(l>>>31<<2)|0,s=n[f>>2]|0,s)l=l<<1,c=s;else{s=72;break}}if((s|0)==72){n[f>>2]=k,n[k+24>>2]=c,n[k+12>>2]=k,n[k+8>>2]=k;break}else if((s|0)==73){B=c+8|0,Q=n[B>>2]|0,n[Q+12>>2]=k,n[B>>2]=k,n[k+8>>2]=Q,n[k+12>>2]=c,n[k+24>>2]=0;break}}else n[2784]=l|c,n[f>>2]=k,n[k+24>>2]=f,n[k+12>>2]=k,n[k+8>>2]=k;while(0);if(Q=(n[2791]|0)+-1|0,n[2791]=Q,!Q)s=11588;else return;for(;s=n[s>>2]|0,s;)s=s+8|0;n[2791]=-1}}}function DUe(){return 11628}function SUe(s){s=s|0;var l=0,c=0;return l=C,C=C+16|0,c=l,n[c>>2]=xUe(n[s+60>>2]|0)|0,s=gD(hc(6,c|0)|0)|0,C=l,s|0}function r7(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0,qe=0;G=C,C=C+48|0,M=G+16|0,m=G,d=G+32|0,k=s+28|0,f=n[k>>2]|0,n[d>>2]=f,Q=s+20|0,f=(n[Q>>2]|0)-f|0,n[d+4>>2]=f,n[d+8>>2]=l,n[d+12>>2]=c,f=f+c|0,B=s+60|0,n[m>>2]=n[B>>2],n[m+4>>2]=d,n[m+8>>2]=2,m=gD(Li(146,m|0)|0)|0;e:do if((f|0)!=(m|0)){for(l=2;!((m|0)<0);)if(f=f-m|0,qe=n[d+4>>2]|0,se=m>>>0>qe>>>0,d=se?d+8|0:d,l=(se<<31>>31)+l|0,qe=m-(se?qe:0)|0,n[d>>2]=(n[d>>2]|0)+qe,se=d+4|0,n[se>>2]=(n[se>>2]|0)-qe,n[M>>2]=n[B>>2],n[M+4>>2]=d,n[M+8>>2]=l,m=gD(Li(146,M|0)|0)|0,(f|0)==(m|0)){O=3;break e}n[s+16>>2]=0,n[k>>2]=0,n[Q>>2]=0,n[s>>2]=n[s>>2]|32,(l|0)==2?c=0:c=c-(n[d+4>>2]|0)|0}else O=3;while(0);return(O|0)==3&&(qe=n[s+44>>2]|0,n[s+16>>2]=qe+(n[s+48>>2]|0),n[k>>2]=qe,n[Q>>2]=qe),C=G,c|0}function PUe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;return d=C,C=C+32|0,m=d,f=d+20|0,n[m>>2]=n[s+60>>2],n[m+4>>2]=0,n[m+8>>2]=l,n[m+12>>2]=f,n[m+16>>2]=c,(gD(sa(140,m|0)|0)|0)<0?(n[f>>2]=-1,s=-1):s=n[f>>2]|0,C=d,s|0}function gD(s){return s=s|0,s>>>0>4294963200&&(n[(Vm()|0)>>2]=0-s,s=-1),s|0}function Vm(){return(bUe()|0)+64|0}function bUe(){return SR()|0}function SR(){return 2084}function xUe(s){return s=s|0,s|0}function kUe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;return d=C,C=C+32|0,f=d,n[s+36>>2]=1,(n[s>>2]&64|0)==0&&(n[f>>2]=n[s+60>>2],n[f+4>>2]=21523,n[f+8>>2]=d+16,fu(54,f|0)|0)&&(o[s+75>>0]=-1),f=r7(s,l,c)|0,C=d,f|0}function n7(s,l){s=s|0,l=l|0;var c=0,f=0;if(c=o[s>>0]|0,f=o[l>>0]|0,c<<24>>24==0||c<<24>>24!=f<<24>>24)s=f;else{do s=s+1|0,l=l+1|0,c=o[s>>0]|0,f=o[l>>0]|0;while(!(c<<24>>24==0||c<<24>>24!=f<<24>>24));s=f}return(c&255)-(s&255)|0}function QUe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0;e:do if(!c)s=0;else{for(;f=o[s>>0]|0,d=o[l>>0]|0,f<<24>>24==d<<24>>24;)if(c=c+-1|0,c)s=s+1|0,l=l+1|0;else{s=0;break e}s=(f&255)-(d&255)|0}while(0);return s|0}function i7(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0,qe=0,Me=0,Qe=0;Qe=C,C=C+224|0,O=Qe+120|0,G=Qe+80|0,qe=Qe,Me=Qe+136|0,f=G,d=f+40|0;do n[f>>2]=0,f=f+4|0;while((f|0)<(d|0));return n[O>>2]=n[c>>2],(PR(0,l,O,qe,G)|0)<0?c=-1:((n[s+76>>2]|0)>-1?se=FUe(s)|0:se=0,c=n[s>>2]|0,M=c&32,(o[s+74>>0]|0)<1&&(n[s>>2]=c&-33),f=s+48|0,n[f>>2]|0?c=PR(s,l,O,qe,G)|0:(d=s+44|0,m=n[d>>2]|0,n[d>>2]=Me,B=s+28|0,n[B>>2]=Me,k=s+20|0,n[k>>2]=Me,n[f>>2]=80,Q=s+16|0,n[Q>>2]=Me+80,c=PR(s,l,O,qe,G)|0,m&&(ED[n[s+36>>2]&7](s,0,0)|0,c=(n[k>>2]|0)==0?-1:c,n[d>>2]=m,n[f>>2]=0,n[Q>>2]=0,n[B>>2]=0,n[k>>2]=0)),f=n[s>>2]|0,n[s>>2]=f|M,se|0&&RUe(s),c=(f&32|0)==0?c:-1),C=Qe,c|0}function PR(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0,qe=0,Me=0,Qe=0,et=0,Xe=0,lt=0,Ue=0,Ge=0,Lt=0,Mr=0,or=0,Xt=0,Sr=0,Nr=0,ir=0;ir=C,C=C+64|0,or=ir+16|0,Xt=ir,Lt=ir+24|0,Sr=ir+8|0,Nr=ir+20|0,n[or>>2]=l,lt=(s|0)!=0,Ue=Lt+40|0,Ge=Ue,Lt=Lt+39|0,Mr=Sr+4|0,B=0,m=0,O=0;e:for(;;){do if((m|0)>-1)if((B|0)>(2147483647-m|0)){n[(Vm()|0)>>2]=75,m=-1;break}else{m=B+m|0;break}while(0);if(B=o[l>>0]|0,B<<24>>24)k=l;else{Xe=87;break}t:for(;;){switch(B<<24>>24){case 37:{B=k,Xe=9;break t}case 0:{B=k;break t}default:}et=k+1|0,n[or>>2]=et,B=o[et>>0]|0,k=et}t:do if((Xe|0)==9)for(;;){if(Xe=0,(o[k+1>>0]|0)!=37)break t;if(B=B+1|0,k=k+2|0,n[or>>2]=k,(o[k>>0]|0)==37)Xe=9;else break}while(0);if(B=B-l|0,lt&&ss(s,l,B),B|0){l=k;continue}Q=k+1|0,B=(o[Q>>0]|0)+-48|0,B>>>0<10?(et=(o[k+2>>0]|0)==36,Qe=et?B:-1,O=et?1:O,Q=et?k+3|0:Q):Qe=-1,n[or>>2]=Q,B=o[Q>>0]|0,k=(B<<24>>24)+-32|0;t:do if(k>>>0<32)for(M=0,G=B;;){if(B=1<<k,!(B&75913)){B=G;break t}if(M=B|M,Q=Q+1|0,n[or>>2]=Q,B=o[Q>>0]|0,k=(B<<24>>24)+-32|0,k>>>0>=32)break;G=B}else M=0;while(0);if(B<<24>>24==42){if(k=Q+1|0,B=(o[k>>0]|0)+-48|0,B>>>0<10&&(o[Q+2>>0]|0)==36)n[d+(B<<2)>>2]=10,B=n[f+((o[k>>0]|0)+-48<<3)>>2]|0,O=1,Q=Q+3|0;else{if(O|0){m=-1;break}lt?(O=(n[c>>2]|0)+(4-1)&~(4-1),B=n[O>>2]|0,n[c>>2]=O+4,O=0,Q=k):(B=0,O=0,Q=k)}n[or>>2]=Q,et=(B|0)<0,B=et?0-B|0:B,M=et?M|8192:M}else{if(B=s7(or)|0,(B|0)<0){m=-1;break}Q=n[or>>2]|0}do if((o[Q>>0]|0)==46){if((o[Q+1>>0]|0)!=42){n[or>>2]=Q+1,k=s7(or)|0,Q=n[or>>2]|0;break}if(G=Q+2|0,k=(o[G>>0]|0)+-48|0,k>>>0<10&&(o[Q+3>>0]|0)==36){n[d+(k<<2)>>2]=10,k=n[f+((o[G>>0]|0)+-48<<3)>>2]|0,Q=Q+4|0,n[or>>2]=Q;break}if(O|0){m=-1;break e}lt?(et=(n[c>>2]|0)+(4-1)&~(4-1),k=n[et>>2]|0,n[c>>2]=et+4):k=0,n[or>>2]=G,Q=G}else k=-1;while(0);for(Me=0;;){if(((o[Q>>0]|0)+-65|0)>>>0>57){m=-1;break e}if(et=Q+1|0,n[or>>2]=et,G=o[(o[Q>>0]|0)+-65+(5178+(Me*58|0))>>0]|0,se=G&255,(se+-1|0)>>>0<8)Me=se,Q=et;else break}if(!(G<<24>>24)){m=-1;break}qe=(Qe|0)>-1;do if(G<<24>>24==19)if(qe){m=-1;break e}else Xe=49;else{if(qe){n[d+(Qe<<2)>>2]=se,qe=f+(Qe<<3)|0,Qe=n[qe+4>>2]|0,Xe=Xt,n[Xe>>2]=n[qe>>2],n[Xe+4>>2]=Qe,Xe=49;break}if(!lt){m=0;break e}o7(Xt,se,c)}while(0);if((Xe|0)==49&&(Xe=0,!lt)){B=0,l=et;continue}Q=o[Q>>0]|0,Q=(Me|0)!=0&(Q&15|0)==3?Q&-33:Q,qe=M&-65537,Qe=(M&8192|0)==0?M:qe;t:do switch(Q|0){case 110:switch((Me&255)<<24>>24){case 0:{n[n[Xt>>2]>>2]=m,B=0,l=et;continue e}case 1:{n[n[Xt>>2]>>2]=m,B=0,l=et;continue e}case 2:{B=n[Xt>>2]|0,n[B>>2]=m,n[B+4>>2]=((m|0)<0)<<31>>31,B=0,l=et;continue e}case 3:{a[n[Xt>>2]>>1]=m,B=0,l=et;continue e}case 4:{o[n[Xt>>2]>>0]=m,B=0,l=et;continue e}case 6:{n[n[Xt>>2]>>2]=m,B=0,l=et;continue e}case 7:{B=n[Xt>>2]|0,n[B>>2]=m,n[B+4>>2]=((m|0)<0)<<31>>31,B=0,l=et;continue e}default:{B=0,l=et;continue e}}case 112:{Q=120,k=k>>>0>8?k:8,l=Qe|8,Xe=61;break}case 88:case 120:{l=Qe,Xe=61;break}case 111:{Q=Xt,l=n[Q>>2]|0,Q=n[Q+4>>2]|0,se=NUe(l,Q,Ue)|0,qe=Ge-se|0,M=0,G=5642,k=(Qe&8|0)==0|(k|0)>(qe|0)?k:qe+1|0,qe=Qe,Xe=67;break}case 105:case 100:if(Q=Xt,l=n[Q>>2]|0,Q=n[Q+4>>2]|0,(Q|0)<0){l=dD(0,0,l|0,Q|0)|0,Q=Se,M=Xt,n[M>>2]=l,n[M+4>>2]=Q,M=1,G=5642,Xe=66;break t}else{M=(Qe&2049|0)!=0&1,G=(Qe&2048|0)==0?(Qe&1|0)==0?5642:5644:5643,Xe=66;break t}case 117:{Q=Xt,M=0,G=5642,l=n[Q>>2]|0,Q=n[Q+4>>2]|0,Xe=66;break}case 99:{o[Lt>>0]=n[Xt>>2],l=Lt,M=0,G=5642,se=Ue,Q=1,k=qe;break}case 109:{Q=LUe(n[(Vm()|0)>>2]|0)|0,Xe=71;break}case 115:{Q=n[Xt>>2]|0,Q=Q|0?Q:5652,Xe=71;break}case 67:{n[Sr>>2]=n[Xt>>2],n[Mr>>2]=0,n[Xt>>2]=Sr,se=-1,Q=Sr,Xe=75;break}case 83:{l=n[Xt>>2]|0,k?(se=k,Q=l,Xe=75):(Bs(s,32,B,0,Qe),l=0,Xe=84);break}case 65:case 71:case 70:case 69:case 97:case 103:case 102:case 101:{B=MUe(s,+E[Xt>>3],B,k,Qe,Q)|0,l=et;continue e}default:M=0,G=5642,se=Ue,Q=k,k=Qe}while(0);t:do if((Xe|0)==61)Qe=Xt,Me=n[Qe>>2]|0,Qe=n[Qe+4>>2]|0,se=TUe(Me,Qe,Ue,Q&32)|0,G=(l&8|0)==0|(Me|0)==0&(Qe|0)==0,M=G?0:2,G=G?5642:5642+(Q>>4)|0,qe=l,l=Me,Q=Qe,Xe=67;else if((Xe|0)==66)se=Jm(l,Q,Ue)|0,qe=Qe,Xe=67;else if((Xe|0)==71)Xe=0,Qe=OUe(Q,0,k)|0,Me=(Qe|0)==0,l=Q,M=0,G=5642,se=Me?Q+k|0:Qe,Q=Me?k:Qe-Q|0,k=qe;else if((Xe|0)==75){for(Xe=0,G=Q,l=0,k=0;M=n[G>>2]|0,!(!M||(k=a7(Nr,M)|0,(k|0)<0|k>>>0>(se-l|0)>>>0));)if(l=k+l|0,se>>>0>l>>>0)G=G+4|0;else break;if((k|0)<0){m=-1;break e}if(Bs(s,32,B,l,Qe),!l)l=0,Xe=84;else for(M=0;;){if(k=n[Q>>2]|0,!k){Xe=84;break t}if(k=a7(Nr,k)|0,M=k+M|0,(M|0)>(l|0)){Xe=84;break t}if(ss(s,Nr,k),M>>>0>=l>>>0){Xe=84;break}else Q=Q+4|0}}while(0);if((Xe|0)==67)Xe=0,Q=(l|0)!=0|(Q|0)!=0,Qe=(k|0)!=0|Q,Q=((Q^1)&1)+(Ge-se)|0,l=Qe?se:Ue,se=Ue,Q=Qe?(k|0)>(Q|0)?k:Q:k,k=(k|0)>-1?qe&-65537:qe;else if((Xe|0)==84){Xe=0,Bs(s,32,B,l,Qe^8192),B=(B|0)>(l|0)?B:l,l=et;continue}Me=se-l|0,qe=(Q|0)<(Me|0)?Me:Q,Qe=qe+M|0,B=(B|0)<(Qe|0)?Qe:B,Bs(s,32,B,Qe,k),ss(s,G,M),Bs(s,48,B,Qe,k^65536),Bs(s,48,qe,Me,0),ss(s,l,Me),Bs(s,32,B,Qe,k^8192),l=et}e:do if((Xe|0)==87&&!s)if(!O)m=0;else{for(m=1;l=n[d+(m<<2)>>2]|0,!!l;)if(o7(f+(m<<3)|0,l,c),m=m+1|0,(m|0)>=10){m=1;break e}for(;;){if(n[d+(m<<2)>>2]|0){m=-1;break e}if(m=m+1|0,(m|0)>=10){m=1;break}}}while(0);return C=ir,m|0}function FUe(s){return s=s|0,0}function RUe(s){s=s|0}function ss(s,l,c){s=s|0,l=l|0,c=c|0,n[s>>2]&32||KUe(l,c,s)|0}function s7(s){s=s|0;var l=0,c=0,f=0;if(c=n[s>>2]|0,f=(o[c>>0]|0)+-48|0,f>>>0<10){l=0;do l=f+(l*10|0)|0,c=c+1|0,n[s>>2]=c,f=(o[c>>0]|0)+-48|0;while(f>>>0<10)}else l=0;return l|0}function o7(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;e:do if(l>>>0<=20)do switch(l|0){case 9:{f=(n[c>>2]|0)+(4-1)&~(4-1),l=n[f>>2]|0,n[c>>2]=f+4,n[s>>2]=l;break e}case 10:{f=(n[c>>2]|0)+(4-1)&~(4-1),l=n[f>>2]|0,n[c>>2]=f+4,f=s,n[f>>2]=l,n[f+4>>2]=((l|0)<0)<<31>>31;break e}case 11:{f=(n[c>>2]|0)+(4-1)&~(4-1),l=n[f>>2]|0,n[c>>2]=f+4,f=s,n[f>>2]=l,n[f+4>>2]=0;break e}case 12:{f=(n[c>>2]|0)+(8-1)&~(8-1),l=f,d=n[l>>2]|0,l=n[l+4>>2]|0,n[c>>2]=f+8,f=s,n[f>>2]=d,n[f+4>>2]=l;break e}case 13:{d=(n[c>>2]|0)+(4-1)&~(4-1),f=n[d>>2]|0,n[c>>2]=d+4,f=(f&65535)<<16>>16,d=s,n[d>>2]=f,n[d+4>>2]=((f|0)<0)<<31>>31;break e}case 14:{d=(n[c>>2]|0)+(4-1)&~(4-1),f=n[d>>2]|0,n[c>>2]=d+4,d=s,n[d>>2]=f&65535,n[d+4>>2]=0;break e}case 15:{d=(n[c>>2]|0)+(4-1)&~(4-1),f=n[d>>2]|0,n[c>>2]=d+4,f=(f&255)<<24>>24,d=s,n[d>>2]=f,n[d+4>>2]=((f|0)<0)<<31>>31;break e}case 16:{d=(n[c>>2]|0)+(4-1)&~(4-1),f=n[d>>2]|0,n[c>>2]=d+4,d=s,n[d>>2]=f&255,n[d+4>>2]=0;break e}case 17:{d=(n[c>>2]|0)+(8-1)&~(8-1),m=+E[d>>3],n[c>>2]=d+8,E[s>>3]=m;break e}case 18:{d=(n[c>>2]|0)+(8-1)&~(8-1),m=+E[d>>3],n[c>>2]=d+8,E[s>>3]=m;break e}default:break e}while(0);while(0)}function TUe(s,l,c,f){if(s=s|0,l=l|0,c=c|0,f=f|0,!((s|0)==0&(l|0)==0))do c=c+-1|0,o[c>>0]=u[5694+(s&15)>>0]|0|f,s=mD(s|0,l|0,4)|0,l=Se;while(!((s|0)==0&(l|0)==0));return c|0}function NUe(s,l,c){if(s=s|0,l=l|0,c=c|0,!((s|0)==0&(l|0)==0))do c=c+-1|0,o[c>>0]=s&7|48,s=mD(s|0,l|0,3)|0,l=Se;while(!((s|0)==0&(l|0)==0));return c|0}function Jm(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;if(l>>>0>0|(l|0)==0&s>>>0>4294967295){for(;f=QR(s|0,l|0,10,0)|0,c=c+-1|0,o[c>>0]=f&255|48,f=s,s=kR(s|0,l|0,10,0)|0,l>>>0>9|(l|0)==9&f>>>0>4294967295;)l=Se;l=s}else l=s;if(l)for(;c=c+-1|0,o[c>>0]=(l>>>0)%10|0|48,!(l>>>0<10);)l=(l>>>0)/10|0;return c|0}function LUe(s){return s=s|0,GUe(s,n[(jUe()|0)+188>>2]|0)|0}function OUe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;m=l&255,f=(c|0)!=0;e:do if(f&(s&3|0)!=0)for(d=l&255;;){if((o[s>>0]|0)==d<<24>>24){B=6;break e}if(s=s+1|0,c=c+-1|0,f=(c|0)!=0,!(f&(s&3|0)!=0)){B=5;break}}else B=5;while(0);(B|0)==5&&(f?B=6:c=0);e:do if((B|0)==6&&(d=l&255,(o[s>>0]|0)!=d<<24>>24)){f=je(m,16843009)|0;t:do if(c>>>0>3){for(;m=n[s>>2]^f,!((m&-2139062144^-2139062144)&m+-16843009|0);)if(s=s+4|0,c=c+-4|0,c>>>0<=3){B=11;break t}}else B=11;while(0);if((B|0)==11&&!c){c=0;break}for(;;){if((o[s>>0]|0)==d<<24>>24)break e;if(s=s+1|0,c=c+-1|0,!c){c=0;break}}}while(0);return(c|0?s:0)|0}function Bs(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0;if(B=C,C=C+256|0,m=B,(c|0)>(f|0)&(d&73728|0)==0){if(d=c-f|0,Xm(m|0,l|0,(d>>>0<256?d:256)|0)|0,d>>>0>255){l=c-f|0;do ss(s,m,256),d=d+-256|0;while(d>>>0>255);d=l&255}ss(s,m,d)}C=B}function a7(s,l){return s=s|0,l=l|0,s?s=_Ue(s,l,0)|0:s=0,s|0}function MUe(s,l,c,f,d,m){s=s|0,l=+l,c=c|0,f=f|0,d=d|0,m=m|0;var B=0,k=0,Q=0,M=0,O=0,G=0,se=0,qe=0,Me=0,Qe=0,et=0,Xe=0,lt=0,Ue=0,Ge=0,Lt=0,Mr=0,or=0,Xt=0,Sr=0,Nr=0,ir=0,xn=0;xn=C,C=C+560|0,Q=xn+8|0,et=xn,ir=xn+524|0,Nr=ir,M=xn+512|0,n[et>>2]=0,Sr=M+12|0,l7(l)|0,(Se|0)<0?(l=-l,or=1,Mr=5659):(or=(d&2049|0)!=0&1,Mr=(d&2048|0)==0?(d&1|0)==0?5660:5665:5662),l7(l)|0,Xt=Se&2146435072;do if(Xt>>>0<2146435072|(Xt|0)==2146435072&0<0){if(qe=+UUe(l,et)*2,B=qe!=0,B&&(n[et>>2]=(n[et>>2]|0)+-1),lt=m|32,(lt|0)==97){Me=m&32,se=(Me|0)==0?Mr:Mr+9|0,G=or|2,B=12-f|0;do if(f>>>0>11|(B|0)==0)l=qe;else{l=8;do B=B+-1|0,l=l*16;while((B|0)!=0);if((o[se>>0]|0)==45){l=-(l+(-qe-l));break}else{l=qe+l-l;break}}while(0);k=n[et>>2]|0,B=(k|0)<0?0-k|0:k,B=Jm(B,((B|0)<0)<<31>>31,Sr)|0,(B|0)==(Sr|0)&&(B=M+11|0,o[B>>0]=48),o[B+-1>>0]=(k>>31&2)+43,O=B+-2|0,o[O>>0]=m+15,M=(f|0)<1,Q=(d&8|0)==0,B=ir;do Xt=~~l,k=B+1|0,o[B>>0]=u[5694+Xt>>0]|Me,l=(l-+(Xt|0))*16,(k-Nr|0)==1&&!(Q&(M&l==0))?(o[k>>0]=46,B=B+2|0):B=k;while(l!=0);Xt=B-Nr|0,Nr=Sr-O|0,Sr=(f|0)!=0&(Xt+-2|0)<(f|0)?f+2|0:Xt,B=Nr+G+Sr|0,Bs(s,32,c,B,d),ss(s,se,G),Bs(s,48,c,B,d^65536),ss(s,ir,Xt),Bs(s,48,Sr-Xt|0,0,0),ss(s,O,Nr),Bs(s,32,c,B,d^8192);break}k=(f|0)<0?6:f,B?(B=(n[et>>2]|0)+-28|0,n[et>>2]=B,l=qe*268435456):(l=qe,B=n[et>>2]|0),Xt=(B|0)<0?Q:Q+288|0,Q=Xt;do Ge=~~l>>>0,n[Q>>2]=Ge,Q=Q+4|0,l=(l-+(Ge>>>0))*1e9;while(l!=0);if((B|0)>0)for(M=Xt,G=Q;;){if(O=(B|0)<29?B:29,B=G+-4|0,B>>>0>=M>>>0){Q=0;do Ue=h7(n[B>>2]|0,0,O|0)|0,Ue=xR(Ue|0,Se|0,Q|0,0)|0,Ge=Se,Xe=QR(Ue|0,Ge|0,1e9,0)|0,n[B>>2]=Xe,Q=kR(Ue|0,Ge|0,1e9,0)|0,B=B+-4|0;while(B>>>0>=M>>>0);Q&&(M=M+-4|0,n[M>>2]=Q)}for(Q=G;!(Q>>>0<=M>>>0);)if(B=Q+-4|0,!(n[B>>2]|0))Q=B;else break;if(B=(n[et>>2]|0)-O|0,n[et>>2]=B,(B|0)>0)G=Q;else break}else M=Xt;if((B|0)<0){f=((k+25|0)/9|0)+1|0,Qe=(lt|0)==102;do{if(Me=0-B|0,Me=(Me|0)<9?Me:9,M>>>0<Q>>>0){O=(1<<Me)+-1|0,G=1e9>>>Me,se=0,B=M;do Ge=n[B>>2]|0,n[B>>2]=(Ge>>>Me)+se,se=je(Ge&O,G)|0,B=B+4|0;while(B>>>0<Q>>>0);B=(n[M>>2]|0)==0?M+4|0:M,se?(n[Q>>2]=se,M=B,B=Q+4|0):(M=B,B=Q)}else M=(n[M>>2]|0)==0?M+4|0:M,B=Q;Q=Qe?Xt:M,Q=(B-Q>>2|0)>(f|0)?Q+(f<<2)|0:B,B=(n[et>>2]|0)+Me|0,n[et>>2]=B}while((B|0)<0);B=M,f=Q}else B=M,f=Q;if(Ge=Xt,B>>>0<f>>>0){if(Q=(Ge-B>>2)*9|0,O=n[B>>2]|0,O>>>0>=10){M=10;do M=M*10|0,Q=Q+1|0;while(O>>>0>=M>>>0)}}else Q=0;if(Qe=(lt|0)==103,Xe=(k|0)!=0,M=k-((lt|0)!=102?Q:0)+((Xe&Qe)<<31>>31)|0,(M|0)<(((f-Ge>>2)*9|0)+-9|0)){if(M=M+9216|0,Me=Xt+4+(((M|0)/9|0)+-1024<<2)|0,M=((M|0)%9|0)+1|0,(M|0)<9){O=10;do O=O*10|0,M=M+1|0;while((M|0)!=9)}else O=10;if(G=n[Me>>2]|0,se=(G>>>0)%(O>>>0)|0,M=(Me+4|0)==(f|0),M&(se|0)==0)M=Me;else if(qe=(((G>>>0)/(O>>>0)|0)&1|0)==0?9007199254740992:9007199254740994,Ue=(O|0)/2|0,l=se>>>0<Ue>>>0?.5:M&(se|0)==(Ue|0)?1:1.5,or&&(Ue=(o[Mr>>0]|0)==45,l=Ue?-l:l,qe=Ue?-qe:qe),M=G-se|0,n[Me>>2]=M,qe+l!=qe){if(Ue=M+O|0,n[Me>>2]=Ue,Ue>>>0>999999999)for(Q=Me;M=Q+-4|0,n[Q>>2]=0,M>>>0<B>>>0&&(B=B+-4|0,n[B>>2]=0),Ue=(n[M>>2]|0)+1|0,n[M>>2]=Ue,Ue>>>0>999999999;)Q=M;else M=Me;if(Q=(Ge-B>>2)*9|0,G=n[B>>2]|0,G>>>0>=10){O=10;do O=O*10|0,Q=Q+1|0;while(G>>>0>=O>>>0)}}else M=Me;M=M+4|0,M=f>>>0>M>>>0?M:f,Ue=B}else M=f,Ue=B;for(lt=M;;){if(lt>>>0<=Ue>>>0){et=0;break}if(B=lt+-4|0,!(n[B>>2]|0))lt=B;else{et=1;break}}f=0-Q|0;do if(Qe)if(B=((Xe^1)&1)+k|0,(B|0)>(Q|0)&(Q|0)>-5?(O=m+-1|0,k=B+-1-Q|0):(O=m+-2|0,k=B+-1|0),B=d&8,B)Me=B;else{if(et&&(Lt=n[lt+-4>>2]|0,(Lt|0)!=0))if((Lt>>>0)%10|0)M=0;else{M=0,B=10;do B=B*10|0,M=M+1|0;while(!((Lt>>>0)%(B>>>0)|0|0))}else M=9;if(B=((lt-Ge>>2)*9|0)+-9|0,(O|32|0)==102){Me=B-M|0,Me=(Me|0)>0?Me:0,k=(k|0)<(Me|0)?k:Me,Me=0;break}else{Me=B+Q-M|0,Me=(Me|0)>0?Me:0,k=(k|0)<(Me|0)?k:Me,Me=0;break}}else O=m,Me=d&8;while(0);if(Qe=k|Me,G=(Qe|0)!=0&1,se=(O|32|0)==102,se)Xe=0,B=(Q|0)>0?Q:0;else{if(B=(Q|0)<0?f:Q,B=Jm(B,((B|0)<0)<<31>>31,Sr)|0,M=Sr,(M-B|0)<2)do B=B+-1|0,o[B>>0]=48;while((M-B|0)<2);o[B+-1>>0]=(Q>>31&2)+43,B=B+-2|0,o[B>>0]=O,Xe=B,B=M-B|0}if(B=or+1+k+G+B|0,Bs(s,32,c,B,d),ss(s,Mr,or),Bs(s,48,c,B,d^65536),se){O=Ue>>>0>Xt>>>0?Xt:Ue,Me=ir+9|0,G=Me,se=ir+8|0,M=O;do{if(Q=Jm(n[M>>2]|0,0,Me)|0,(M|0)==(O|0))(Q|0)==(Me|0)&&(o[se>>0]=48,Q=se);else if(Q>>>0>ir>>>0){Xm(ir|0,48,Q-Nr|0)|0;do Q=Q+-1|0;while(Q>>>0>ir>>>0)}ss(s,Q,G-Q|0),M=M+4|0}while(M>>>0<=Xt>>>0);if(Qe|0&&ss(s,5710,1),M>>>0<lt>>>0&(k|0)>0)for(;;){if(Q=Jm(n[M>>2]|0,0,Me)|0,Q>>>0>ir>>>0){Xm(ir|0,48,Q-Nr|0)|0;do Q=Q+-1|0;while(Q>>>0>ir>>>0)}if(ss(s,Q,(k|0)<9?k:9),M=M+4|0,Q=k+-9|0,M>>>0<lt>>>0&(k|0)>9)k=Q;else{k=Q;break}}Bs(s,48,k+9|0,9,0)}else{if(Qe=et?lt:Ue+4|0,(k|0)>-1){et=ir+9|0,Me=(Me|0)==0,f=et,G=0-Nr|0,se=ir+8|0,O=Ue;do{Q=Jm(n[O>>2]|0,0,et)|0,(Q|0)==(et|0)&&(o[se>>0]=48,Q=se);do if((O|0)==(Ue|0)){if(M=Q+1|0,ss(s,Q,1),Me&(k|0)<1){Q=M;break}ss(s,5710,1),Q=M}else{if(Q>>>0<=ir>>>0)break;Xm(ir|0,48,Q+G|0)|0;do Q=Q+-1|0;while(Q>>>0>ir>>>0)}while(0);Nr=f-Q|0,ss(s,Q,(k|0)>(Nr|0)?Nr:k),k=k-Nr|0,O=O+4|0}while(O>>>0<Qe>>>0&(k|0)>-1)}Bs(s,48,k+18|0,18,0),ss(s,Xe,Sr-Xe|0)}Bs(s,32,c,B,d^8192)}else ir=(m&32|0)!=0,B=or+3|0,Bs(s,32,c,B,d&-65537),ss(s,Mr,or),ss(s,l!=l|!1?ir?5686:5690:ir?5678:5682,3),Bs(s,32,c,B,d^8192);while(0);return C=xn,((B|0)<(c|0)?c:B)|0}function l7(s){s=+s;var l=0;return E[v>>3]=s,l=n[v>>2]|0,Se=n[v+4>>2]|0,l|0}function UUe(s,l){return s=+s,l=l|0,+ +c7(s,l)}function c7(s,l){s=+s,l=l|0;var c=0,f=0,d=0;switch(E[v>>3]=s,c=n[v>>2]|0,f=n[v+4>>2]|0,d=mD(c|0,f|0,52)|0,d&2047){case 0:{s!=0?(s=+c7(s*18446744073709552e3,l),c=(n[l>>2]|0)+-64|0):c=0,n[l>>2]=c;break}case 2047:break;default:n[l>>2]=(d&2047)+-1022,n[v>>2]=c,n[v+4>>2]=f&-2146435073|1071644672,s=+E[v>>3]}return+s}function _Ue(s,l,c){s=s|0,l=l|0,c=c|0;do if(s){if(l>>>0<128){o[s>>0]=l,s=1;break}if(!(n[n[(HUe()|0)+188>>2]>>2]|0))if((l&-128|0)==57216){o[s>>0]=l,s=1;break}else{n[(Vm()|0)>>2]=84,s=-1;break}if(l>>>0<2048){o[s>>0]=l>>>6|192,o[s+1>>0]=l&63|128,s=2;break}if(l>>>0<55296|(l&-8192|0)==57344){o[s>>0]=l>>>12|224,o[s+1>>0]=l>>>6&63|128,o[s+2>>0]=l&63|128,s=3;break}if((l+-65536|0)>>>0<1048576){o[s>>0]=l>>>18|240,o[s+1>>0]=l>>>12&63|128,o[s+2>>0]=l>>>6&63|128,o[s+3>>0]=l&63|128,s=4;break}else{n[(Vm()|0)>>2]=84,s=-1;break}}else s=1;while(0);return s|0}function HUe(){return SR()|0}function jUe(){return SR()|0}function GUe(s,l){s=s|0,l=l|0;var c=0,f=0;for(f=0;;){if((u[5712+f>>0]|0)==(s|0)){s=2;break}if(c=f+1|0,(c|0)==87){c=5800,f=87,s=5;break}else f=c}if((s|0)==2&&(f?(c=5800,s=5):c=5800),(s|0)==5)for(;;){do s=c,c=c+1|0;while((o[s>>0]|0)!=0);if(f=f+-1|0,f)s=5;else break}return qUe(c,n[l+20>>2]|0)|0}function qUe(s,l){return s=s|0,l=l|0,YUe(s,l)|0}function YUe(s,l){return s=s|0,l=l|0,l?l=WUe(n[l>>2]|0,n[l+4>>2]|0,s)|0:l=0,(l|0?l:s)|0}function WUe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0;se=(n[s>>2]|0)+1794895138|0,m=Rg(n[s+8>>2]|0,se)|0,f=Rg(n[s+12>>2]|0,se)|0,d=Rg(n[s+16>>2]|0,se)|0;e:do if(m>>>0<l>>>2>>>0&&(G=l-(m<<2)|0,f>>>0<G>>>0&d>>>0<G>>>0)&&((d|f)&3|0)==0){for(G=f>>>2,O=d>>>2,M=0;;){if(k=m>>>1,Q=M+k|0,B=Q<<1,d=B+G|0,f=Rg(n[s+(d<<2)>>2]|0,se)|0,d=Rg(n[s+(d+1<<2)>>2]|0,se)|0,!(d>>>0<l>>>0&f>>>0<(l-d|0)>>>0)){f=0;break e}if(o[s+(d+f)>>0]|0){f=0;break e}if(f=n7(c,s+d|0)|0,!f)break;if(f=(f|0)<0,(m|0)==1){f=0;break e}else M=f?M:Q,m=f?k:m-k|0}f=B+O|0,d=Rg(n[s+(f<<2)>>2]|0,se)|0,f=Rg(n[s+(f+1<<2)>>2]|0,se)|0,f>>>0<l>>>0&d>>>0<(l-f|0)>>>0?f=(o[s+(f+d)>>0]|0)==0?s+f|0:0:f=0}else f=0;while(0);return f|0}function Rg(s,l){s=s|0,l=l|0;var c=0;return c=m7(s|0)|0,((l|0)==0?s:c)|0}function KUe(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0,k=0;f=c+16|0,d=n[f>>2]|0,d?m=5:VUe(c)|0?f=0:(d=n[f>>2]|0,m=5);e:do if((m|0)==5){if(k=c+20|0,B=n[k>>2]|0,f=B,(d-B|0)>>>0<l>>>0){f=ED[n[c+36>>2]&7](c,s,l)|0;break}t:do if((o[c+75>>0]|0)>-1){for(B=l;;){if(!B){m=0,d=s;break t}if(d=B+-1|0,(o[s+d>>0]|0)==10)break;B=d}if(f=ED[n[c+36>>2]&7](c,s,B)|0,f>>>0<B>>>0)break e;m=B,d=s+B|0,l=l-B|0,f=n[k>>2]|0}else m=0,d=s;while(0);Dr(f|0,d|0,l|0)|0,n[k>>2]=(n[k>>2]|0)+l,f=m+l|0}while(0);return f|0}function VUe(s){s=s|0;var l=0,c=0;return l=s+74|0,c=o[l>>0]|0,o[l>>0]=c+255|c,l=n[s>>2]|0,l&8?(n[s>>2]=l|32,s=-1):(n[s+8>>2]=0,n[s+4>>2]=0,c=n[s+44>>2]|0,n[s+28>>2]=c,n[s+20>>2]=c,n[s+16>>2]=c+(n[s+48>>2]|0),s=0),s|0}function _n(s,l){s=y(s),l=y(l);var c=0,f=0;c=u7(s)|0;do if((c&2147483647)>>>0<=2139095040){if(f=u7(l)|0,(f&2147483647)>>>0<=2139095040)if((f^c|0)<0){s=(c|0)<0?l:s;break}else{s=s<l?l:s;break}}else s=l;while(0);return y(s)}function u7(s){return s=y(s),h[v>>2]=s,n[v>>2]|0|0}function Tg(s,l){s=y(s),l=y(l);var c=0,f=0;c=A7(s)|0;do if((c&2147483647)>>>0<=2139095040){if(f=A7(l)|0,(f&2147483647)>>>0<=2139095040)if((f^c|0)<0){s=(c|0)<0?s:l;break}else{s=s<l?s:l;break}}else s=l;while(0);return y(s)}function A7(s){return s=y(s),h[v>>2]=s,n[v>>2]|0|0}function bR(s,l){s=y(s),l=y(l);var c=0,f=0,d=0,m=0,B=0,k=0,Q=0,M=0;m=(h[v>>2]=s,n[v>>2]|0),k=(h[v>>2]=l,n[v>>2]|0),c=m>>>23&255,B=k>>>23&255,Q=m&-2147483648,d=k<<1;e:do if((d|0)!=0&&!((c|0)==255|((JUe(l)|0)&2147483647)>>>0>2139095040)){if(f=m<<1,f>>>0<=d>>>0)return l=y(s*y(0)),y((f|0)==(d|0)?l:s);if(c)f=m&8388607|8388608;else{if(c=m<<9,(c|0)>-1){f=c,c=0;do c=c+-1|0,f=f<<1;while((f|0)>-1)}else c=0;f=m<<1-c}if(B)k=k&8388607|8388608;else{if(m=k<<9,(m|0)>-1){d=0;do d=d+-1|0,m=m<<1;while((m|0)>-1)}else d=0;B=d,k=k<<1-d}d=f-k|0,m=(d|0)>-1;t:do if((c|0)>(B|0)){for(;;){if(m)if(d)f=d;else break;if(f=f<<1,c=c+-1|0,d=f-k|0,m=(d|0)>-1,(c|0)<=(B|0))break t}l=y(s*y(0));break e}while(0);if(m)if(d)f=d;else{l=y(s*y(0));break}if(f>>>0<8388608)do f=f<<1,c=c+-1|0;while(f>>>0<8388608);(c|0)>0?c=f+-8388608|c<<23:c=f>>>(1-c|0),l=(n[v>>2]=c|Q,y(h[v>>2]))}else M=3;while(0);return(M|0)==3&&(l=y(s*l),l=y(l/l)),y(l)}function JUe(s){return s=y(s),h[v>>2]=s,n[v>>2]|0|0}function zUe(s,l){return s=s|0,l=l|0,i7(n[582]|0,s,l)|0}function zr(s){s=s|0,Rt()}function zm(s){s=s|0}function XUe(s,l){return s=s|0,l=l|0,0}function ZUe(s){return s=s|0,(f7(s+4|0)|0)==-1?(tf[n[(n[s>>2]|0)+8>>2]&127](s),s=1):s=0,s|0}function f7(s){s=s|0;var l=0;return l=n[s>>2]|0,n[s>>2]=l+-1,l+-1|0}function kp(s){s=s|0,ZUe(s)|0&&$Ue(s)}function $Ue(s){s=s|0;var l=0;l=s+8|0,(n[l>>2]|0)!=0&&(f7(l)|0)!=-1||tf[n[(n[s>>2]|0)+16>>2]&127](s)}function Kt(s){s=s|0;var l=0;for(l=(s|0)==0?1:s;s=pD(l)|0,!(s|0);){if(s=t3e()|0,!s){s=0;break}P7[s&0]()}return s|0}function p7(s){return s=s|0,Kt(s)|0}function gt(s){s=s|0,hD(s)}function e3e(s){s=s|0,(o[s+11>>0]|0)<0&>(n[s>>2]|0)}function t3e(){var s=0;return s=n[2923]|0,n[2923]=s+0,s|0}function r3e(){}function dD(s,l,c,f){return s=s|0,l=l|0,c=c|0,f=f|0,f=l-f-(c>>>0>s>>>0|0)>>>0,Se=f,s-c>>>0|0|0}function xR(s,l,c,f){return s=s|0,l=l|0,c=c|0,f=f|0,c=s+c>>>0,Se=l+f+(c>>>0<s>>>0|0)>>>0,c|0|0}function Xm(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0,B=0;if(m=s+c|0,l=l&255,(c|0)>=67){for(;s&3;)o[s>>0]=l,s=s+1|0;for(f=m&-4|0,d=f-64|0,B=l|l<<8|l<<16|l<<24;(s|0)<=(d|0);)n[s>>2]=B,n[s+4>>2]=B,n[s+8>>2]=B,n[s+12>>2]=B,n[s+16>>2]=B,n[s+20>>2]=B,n[s+24>>2]=B,n[s+28>>2]=B,n[s+32>>2]=B,n[s+36>>2]=B,n[s+40>>2]=B,n[s+44>>2]=B,n[s+48>>2]=B,n[s+52>>2]=B,n[s+56>>2]=B,n[s+60>>2]=B,s=s+64|0;for(;(s|0)<(f|0);)n[s>>2]=B,s=s+4|0}for(;(s|0)<(m|0);)o[s>>0]=l,s=s+1|0;return m-c|0}function h7(s,l,c){return s=s|0,l=l|0,c=c|0,(c|0)<32?(Se=l<<c|(s&(1<<c)-1<<32-c)>>>32-c,s<<c):(Se=s<<c-32,0)}function mD(s,l,c){return s=s|0,l=l|0,c=c|0,(c|0)<32?(Se=l>>>c,s>>>c|(l&(1<<c)-1)<<32-c):(Se=0,l>>>c-32|0)}function Dr(s,l,c){s=s|0,l=l|0,c=c|0;var f=0,d=0,m=0;if((c|0)>=8192)return Ac(s|0,l|0,c|0)|0;if(m=s|0,d=s+c|0,(s&3)==(l&3)){for(;s&3;){if(!c)return m|0;o[s>>0]=o[l>>0]|0,s=s+1|0,l=l+1|0,c=c-1|0}for(c=d&-4|0,f=c-64|0;(s|0)<=(f|0);)n[s>>2]=n[l>>2],n[s+4>>2]=n[l+4>>2],n[s+8>>2]=n[l+8>>2],n[s+12>>2]=n[l+12>>2],n[s+16>>2]=n[l+16>>2],n[s+20>>2]=n[l+20>>2],n[s+24>>2]=n[l+24>>2],n[s+28>>2]=n[l+28>>2],n[s+32>>2]=n[l+32>>2],n[s+36>>2]=n[l+36>>2],n[s+40>>2]=n[l+40>>2],n[s+44>>2]=n[l+44>>2],n[s+48>>2]=n[l+48>>2],n[s+52>>2]=n[l+52>>2],n[s+56>>2]=n[l+56>>2],n[s+60>>2]=n[l+60>>2],s=s+64|0,l=l+64|0;for(;(s|0)<(c|0);)n[s>>2]=n[l>>2],s=s+4|0,l=l+4|0}else for(c=d-4|0;(s|0)<(c|0);)o[s>>0]=o[l>>0]|0,o[s+1>>0]=o[l+1>>0]|0,o[s+2>>0]=o[l+2>>0]|0,o[s+3>>0]=o[l+3>>0]|0,s=s+4|0,l=l+4|0;for(;(s|0)<(d|0);)o[s>>0]=o[l>>0]|0,s=s+1|0,l=l+1|0;return m|0}function g7(s){s=s|0;var l=0;return l=o[L+(s&255)>>0]|0,(l|0)<8?l|0:(l=o[L+(s>>8&255)>>0]|0,(l|0)<8?l+8|0:(l=o[L+(s>>16&255)>>0]|0,(l|0)<8?l+16|0:(o[L+(s>>>24)>>0]|0)+24|0))}function d7(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0;var m=0,B=0,k=0,Q=0,M=0,O=0,G=0,se=0,qe=0,Me=0;if(O=s,Q=l,M=Q,B=c,se=f,k=se,!M)return m=(d|0)!=0,k?m?(n[d>>2]=s|0,n[d+4>>2]=l&0,se=0,d=0,Se=se,d|0):(se=0,d=0,Se=se,d|0):(m&&(n[d>>2]=(O>>>0)%(B>>>0),n[d+4>>2]=0),se=0,d=(O>>>0)/(B>>>0)>>>0,Se=se,d|0);m=(k|0)==0;do if(B){if(!m){if(m=(P(k|0)|0)-(P(M|0)|0)|0,m>>>0<=31){G=m+1|0,k=31-m|0,l=m-31>>31,B=G,s=O>>>(G>>>0)&l|M<<k,l=M>>>(G>>>0)&l,m=0,k=O<<k;break}return d?(n[d>>2]=s|0,n[d+4>>2]=Q|l&0,se=0,d=0,Se=se,d|0):(se=0,d=0,Se=se,d|0)}if(m=B-1|0,m&B|0){k=(P(B|0)|0)+33-(P(M|0)|0)|0,Me=64-k|0,G=32-k|0,Q=G>>31,qe=k-32|0,l=qe>>31,B=k,s=G-1>>31&M>>>(qe>>>0)|(M<<G|O>>>(k>>>0))&l,l=l&M>>>(k>>>0),m=O<<Me&Q,k=(M<<Me|O>>>(qe>>>0))&Q|O<<G&k-33>>31;break}return d|0&&(n[d>>2]=m&O,n[d+4>>2]=0),(B|0)==1?(qe=Q|l&0,Me=s|0|0,Se=qe,Me|0):(Me=g7(B|0)|0,qe=M>>>(Me>>>0)|0,Me=M<<32-Me|O>>>(Me>>>0)|0,Se=qe,Me|0)}else{if(m)return d|0&&(n[d>>2]=(M>>>0)%(B>>>0),n[d+4>>2]=0),qe=0,Me=(M>>>0)/(B>>>0)>>>0,Se=qe,Me|0;if(!O)return d|0&&(n[d>>2]=0,n[d+4>>2]=(M>>>0)%(k>>>0)),qe=0,Me=(M>>>0)/(k>>>0)>>>0,Se=qe,Me|0;if(m=k-1|0,!(m&k))return d|0&&(n[d>>2]=s|0,n[d+4>>2]=m&M|l&0),qe=0,Me=M>>>((g7(k|0)|0)>>>0),Se=qe,Me|0;if(m=(P(k|0)|0)-(P(M|0)|0)|0,m>>>0<=30){l=m+1|0,k=31-m|0,B=l,s=M<<k|O>>>(l>>>0),l=M>>>(l>>>0),m=0,k=O<<k;break}return d?(n[d>>2]=s|0,n[d+4>>2]=Q|l&0,qe=0,Me=0,Se=qe,Me|0):(qe=0,Me=0,Se=qe,Me|0)}while(0);if(!B)M=k,Q=0,k=0;else{G=c|0|0,O=se|f&0,M=xR(G|0,O|0,-1,-1)|0,c=Se,Q=k,k=0;do f=Q,Q=m>>>31|Q<<1,m=k|m<<1,f=s<<1|f>>>31|0,se=s>>>31|l<<1|0,dD(M|0,c|0,f|0,se|0)|0,Me=Se,qe=Me>>31|((Me|0)<0?-1:0)<<1,k=qe&1,s=dD(f|0,se|0,qe&G|0,(((Me|0)<0?-1:0)>>31|((Me|0)<0?-1:0)<<1)&O|0)|0,l=Se,B=B-1|0;while((B|0)!=0);M=Q,Q=0}return B=0,d|0&&(n[d>>2]=s,n[d+4>>2]=l),qe=(m|0)>>>31|(M|B)<<1|(B<<1|m>>>31)&0|Q,Me=(m<<1|0>>>31)&-2|k,Se=qe,Me|0}function kR(s,l,c,f){return s=s|0,l=l|0,c=c|0,f=f|0,d7(s,l,c,f,0)|0}function Qp(s){s=s|0;var l=0,c=0;return c=s+15&-16|0,l=n[I>>2]|0,s=l+c|0,(c|0)>0&(s|0)<(l|0)|(s|0)<0?(ie()|0,DA(12),-1):(n[I>>2]=s,(s|0)>(Z()|0)&&(X()|0)==0?(n[I>>2]=l,DA(12),-1):l|0)}function Mw(s,l,c){s=s|0,l=l|0,c=c|0;var f=0;if((l|0)<(s|0)&(s|0)<(l+c|0)){for(f=s,l=l+c|0,s=s+c|0;(c|0)>0;)s=s-1|0,l=l-1|0,c=c-1|0,o[s>>0]=o[l>>0]|0;s=f}else Dr(s,l,c)|0;return s|0}function QR(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0;var d=0,m=0;return m=C,C=C+16|0,d=m|0,d7(s,l,c,f,d)|0,C=m,Se=n[d+4>>2]|0,n[d>>2]|0|0}function m7(s){return s=s|0,(s&255)<<24|(s>>8&255)<<16|(s>>16&255)<<8|s>>>24|0}function n3e(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,y7[s&1](l|0,c|0,f|0,d|0,m|0)}function i3e(s,l,c){s=s|0,l=l|0,c=y(c),E7[s&1](l|0,y(c))}function s3e(s,l,c){s=s|0,l=l|0,c=+c,C7[s&31](l|0,+c)}function o3e(s,l,c,f){return s=s|0,l=l|0,c=y(c),f=y(f),y(w7[s&0](l|0,y(c),y(f)))}function a3e(s,l){s=s|0,l=l|0,tf[s&127](l|0)}function l3e(s,l,c){s=s|0,l=l|0,c=c|0,rf[s&31](l|0,c|0)}function c3e(s,l){return s=s|0,l=l|0,Lg[s&31](l|0)|0}function u3e(s,l,c,f,d){s=s|0,l=l|0,c=+c,f=+f,d=d|0,I7[s&1](l|0,+c,+f,d|0)}function A3e(s,l,c,f){s=s|0,l=l|0,c=+c,f=+f,Y3e[s&1](l|0,+c,+f)}function f3e(s,l,c,f){return s=s|0,l=l|0,c=c|0,f=f|0,ED[s&7](l|0,c|0,f|0)|0}function p3e(s,l,c,f){return s=s|0,l=l|0,c=c|0,f=f|0,+W3e[s&1](l|0,c|0,f|0)}function h3e(s,l){return s=s|0,l=l|0,+B7[s&15](l|0)}function g3e(s,l,c){return s=s|0,l=l|0,c=+c,K3e[s&1](l|0,+c)|0}function d3e(s,l,c){return s=s|0,l=l|0,c=c|0,RR[s&15](l|0,c|0)|0}function m3e(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=+f,d=+d,m=m|0,V3e[s&1](l|0,c|0,+f,+d,m|0)}function y3e(s,l,c,f,d,m,B){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,B=B|0,J3e[s&1](l|0,c|0,f|0,d|0,m|0,B|0)}function E3e(s,l,c){return s=s|0,l=l|0,c=c|0,+v7[s&7](l|0,c|0)}function C3e(s){return s=s|0,CD[s&7]()|0}function w3e(s,l,c,f,d,m){return s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,D7[s&1](l|0,c|0,f|0,d|0,m|0)|0}function I3e(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=+d,z3e[s&1](l|0,c|0,f|0,+d)}function B3e(s,l,c,f,d,m,B){s=s|0,l=l|0,c=c|0,f=y(f),d=d|0,m=y(m),B=B|0,S7[s&1](l|0,c|0,y(f),d|0,y(m),B|0)}function v3e(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,Hw[s&15](l|0,c|0,f|0)}function D3e(s){s=s|0,P7[s&0]()}function S3e(s,l,c,f){s=s|0,l=l|0,c=c|0,f=+f,b7[s&15](l|0,c|0,+f)}function P3e(s,l,c){return s=s|0,l=+l,c=+c,X3e[s&1](+l,+c)|0}function b3e(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,TR[s&15](l|0,c|0,f|0,d|0)}function x3e(s,l,c,f,d){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,F(0)}function k3e(s,l){s=s|0,l=y(l),F(1)}function ma(s,l){s=s|0,l=+l,F(2)}function Q3e(s,l,c){return s=s|0,l=y(l),c=y(c),F(3),Ze}function Er(s){s=s|0,F(4)}function Uw(s,l){s=s|0,l=l|0,F(5)}function Ja(s){return s=s|0,F(6),0}function F3e(s,l,c,f){s=s|0,l=+l,c=+c,f=f|0,F(7)}function R3e(s,l,c){s=s|0,l=+l,c=+c,F(8)}function T3e(s,l,c){return s=s|0,l=l|0,c=c|0,F(9),0}function N3e(s,l,c){return s=s|0,l=l|0,c=c|0,F(10),0}function Ng(s){return s=s|0,F(11),0}function L3e(s,l){return s=s|0,l=+l,F(12),0}function _w(s,l){return s=s|0,l=l|0,F(13),0}function O3e(s,l,c,f,d){s=s|0,l=l|0,c=+c,f=+f,d=d|0,F(14)}function M3e(s,l,c,f,d,m){s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,m=m|0,F(15)}function FR(s,l){return s=s|0,l=l|0,F(16),0}function U3e(){return F(17),0}function _3e(s,l,c,f,d){return s=s|0,l=l|0,c=c|0,f=f|0,d=d|0,F(18),0}function H3e(s,l,c,f){s=s|0,l=l|0,c=c|0,f=+f,F(19)}function j3e(s,l,c,f,d,m){s=s|0,l=l|0,c=y(c),f=f|0,d=y(d),m=m|0,F(20)}function yD(s,l,c){s=s|0,l=l|0,c=c|0,F(21)}function G3e(){F(22)}function Zm(s,l,c){s=s|0,l=l|0,c=+c,F(23)}function q3e(s,l){return s=+s,l=+l,F(24),0}function $m(s,l,c,f){s=s|0,l=l|0,c=c|0,f=f|0,F(25)}var y7=[x3e,_Le],E7=[k3e,fo],C7=[ma,xw,kw,EF,CF,Dl,Qw,wF,jm,bu,Rw,IF,$v,KA,eD,Gm,tD,rD,qm,ma,ma,ma,ma,ma,ma,ma,ma,ma,ma,ma,ma,ma],w7=[Q3e],tf=[Er,zm,CDe,wDe,IDe,Xbe,Zbe,$be,gNe,dNe,mNe,PLe,bLe,xLe,J4e,z4e,X4e,hs,Vv,Hm,WA,Fw,dve,mve,uDe,kDe,jDe,oSe,ISe,USe,rPe,mPe,FPe,VPe,ube,Sbe,jbe,dxe,Fxe,Vxe,uke,Ske,jke,aQe,IQe,LQe,ZQe,Pc,xFe,YFe,uRe,bRe,GRe,uTe,CTe,BTe,_Te,GTe,oNe,ENe,INe,UNe,nLe,i9,MOe,gMe,kMe,YMe,p4e,b4e,U4e,j4e,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er,Er],rf=[Uw,fF,pF,bw,Pu,hF,gF,Bp,dF,mF,yF,Zv,VA,Ve,ft,Wt,vr,Pn,Fr,vF,nve,Sve,AQe,DQe,FRe,HOe,ALe,j5,Uw,Uw,Uw,Uw],Lg=[Ja,SUe,AF,D,Ae,De,vt,wt,xt,_r,di,po,eve,tve,yve,tFe,KRe,jNe,YOe,Wa,Ja,Ja,Ja,Ja,Ja,Ja,Ja,Ja,Ja,Ja,Ja,Ja],I7=[F3e,Eve],Y3e=[R3e,cNe],ED=[T3e,r7,PUe,kUe,GSe,Cxe,RFe,JMe],W3e=[N3e,hbe],B7=[Ng,Yo,rt,bn,Cve,wve,Ive,Bve,vve,Dve,Ng,Ng,Ng,Ng,Ng,Ng],K3e=[L3e,mTe],RR=[_w,XUe,rve,hDe,uSe,sPe,CPe,Wbe,Lxe,_Qe,Wv,TMe,_w,_w,_w,_w],V3e=[O3e,WDe],J3e=[M3e,m4e],v7=[FR,ai,Pve,bve,xve,kbe,FR,FR],CD=[U3e,kve,Sw,ga,PTe,KTe,SNe,W4e],D7=[_3e,Cw],z3e=[H3e,hke],S7=[j3e,ive],Hw=[yD,T,is,tn,ho,SSe,NPe,kke,Wke,_m,cOe,EMe,F4e,yD,yD,yD],P7=[G3e],b7=[Zm,Jv,zv,Xv,YA,nD,BF,S,Zxe,JFe,pTe,Zm,Zm,Zm,Zm,Zm],X3e=[q3e,pNe],TR=[$m,ZPe,cFe,hRe,rTe,RTe,$Te,RNe,lLe,XOe,nUe,$m,$m,$m,$m,$m];return{_llvm_bswap_i32:m7,dynCall_idd:P3e,dynCall_i:C3e,_i64Subtract:dD,___udivdi3:kR,dynCall_vif:i3e,setThrew:hu,dynCall_viii:v3e,_bitshift64Lshr:mD,_bitshift64Shl:h7,dynCall_vi:a3e,dynCall_viiddi:m3e,dynCall_diii:p3e,dynCall_iii:d3e,_memset:Xm,_sbrk:Qp,_memcpy:Dr,__GLOBAL__sub_I_Yoga_cpp:Um,dynCall_vii:l3e,___uremdi3:QR,dynCall_vid:s3e,stackAlloc:lo,_nbind_init:hUe,getTempRet0:Ua,dynCall_di:h3e,dynCall_iid:g3e,setTempRet0:xA,_i64Add:xR,dynCall_fiff:o3e,dynCall_iiii:f3e,_emscripten_get_global_libc:DUe,dynCall_viid:S3e,dynCall_viiid:I3e,dynCall_viififi:B3e,dynCall_ii:c3e,__GLOBAL__sub_I_Binding_cc:kOe,dynCall_viiii:b3e,dynCall_iiiiii:w3e,stackSave:gc,dynCall_viiiii:n3e,__GLOBAL__sub_I_nbind_cc:Qve,dynCall_vidd:A3e,_free:hD,runPostSets:r3e,dynCall_viiiiii:y3e,establishStackSpace:ji,_memmove:Mw,stackRestore:pu,_malloc:pD,__GLOBAL__sub_I_common_cc:zNe,dynCall_viddi:u3e,dynCall_dii:E3e,dynCall_v:D3e}}(Module.asmGlobalArg,Module.asmLibraryArg,buffer),_llvm_bswap_i32=Module._llvm_bswap_i32=asm._llvm_bswap_i32,getTempRet0=Module.getTempRet0=asm.getTempRet0,___udivdi3=Module.___udivdi3=asm.___udivdi3,setThrew=Module.setThrew=asm.setThrew,_bitshift64Lshr=Module._bitshift64Lshr=asm._bitshift64Lshr,_bitshift64Shl=Module._bitshift64Shl=asm._bitshift64Shl,_memset=Module._memset=asm._memset,_sbrk=Module._sbrk=asm._sbrk,_memcpy=Module._memcpy=asm._memcpy,stackAlloc=Module.stackAlloc=asm.stackAlloc,___uremdi3=Module.___uremdi3=asm.___uremdi3,_nbind_init=Module._nbind_init=asm._nbind_init,_i64Subtract=Module._i64Subtract=asm._i64Subtract,setTempRet0=Module.setTempRet0=asm.setTempRet0,_i64Add=Module._i64Add=asm._i64Add,_emscripten_get_global_libc=Module._emscripten_get_global_libc=asm._emscripten_get_global_libc,__GLOBAL__sub_I_Yoga_cpp=Module.__GLOBAL__sub_I_Yoga_cpp=asm.__GLOBAL__sub_I_Yoga_cpp,__GLOBAL__sub_I_Binding_cc=Module.__GLOBAL__sub_I_Binding_cc=asm.__GLOBAL__sub_I_Binding_cc,stackSave=Module.stackSave=asm.stackSave,__GLOBAL__sub_I_nbind_cc=Module.__GLOBAL__sub_I_nbind_cc=asm.__GLOBAL__sub_I_nbind_cc,_free=Module._free=asm._free,runPostSets=Module.runPostSets=asm.runPostSets,establishStackSpace=Module.establishStackSpace=asm.establishStackSpace,_memmove=Module._memmove=asm._memmove,stackRestore=Module.stackRestore=asm.stackRestore,_malloc=Module._malloc=asm._malloc,__GLOBAL__sub_I_common_cc=Module.__GLOBAL__sub_I_common_cc=asm.__GLOBAL__sub_I_common_cc,dynCall_viiiii=Module.dynCall_viiiii=asm.dynCall_viiiii,dynCall_vif=Module.dynCall_vif=asm.dynCall_vif,dynCall_vid=Module.dynCall_vid=asm.dynCall_vid,dynCall_fiff=Module.dynCall_fiff=asm.dynCall_fiff,dynCall_vi=Module.dynCall_vi=asm.dynCall_vi,dynCall_vii=Module.dynCall_vii=asm.dynCall_vii,dynCall_ii=Module.dynCall_ii=asm.dynCall_ii,dynCall_viddi=Module.dynCall_viddi=asm.dynCall_viddi,dynCall_vidd=Module.dynCall_vidd=asm.dynCall_vidd,dynCall_iiii=Module.dynCall_iiii=asm.dynCall_iiii,dynCall_diii=Module.dynCall_diii=asm.dynCall_diii,dynCall_di=Module.dynCall_di=asm.dynCall_di,dynCall_iid=Module.dynCall_iid=asm.dynCall_iid,dynCall_iii=Module.dynCall_iii=asm.dynCall_iii,dynCall_viiddi=Module.dynCall_viiddi=asm.dynCall_viiddi,dynCall_viiiiii=Module.dynCall_viiiiii=asm.dynCall_viiiiii,dynCall_dii=Module.dynCall_dii=asm.dynCall_dii,dynCall_i=Module.dynCall_i=asm.dynCall_i,dynCall_iiiiii=Module.dynCall_iiiiii=asm.dynCall_iiiiii,dynCall_viiid=Module.dynCall_viiid=asm.dynCall_viiid,dynCall_viififi=Module.dynCall_viififi=asm.dynCall_viififi,dynCall_viii=Module.dynCall_viii=asm.dynCall_viii,dynCall_v=Module.dynCall_v=asm.dynCall_v,dynCall_viid=Module.dynCall_viid=asm.dynCall_viid,dynCall_idd=Module.dynCall_idd=asm.dynCall_idd,dynCall_viiii=Module.dynCall_viiii=asm.dynCall_viiii;Runtime.stackAlloc=Module.stackAlloc,Runtime.stackSave=Module.stackSave,Runtime.stackRestore=Module.stackRestore,Runtime.establishStackSpace=Module.establishStackSpace,Runtime.setTempRet0=Module.setTempRet0,Runtime.getTempRet0=Module.getTempRet0,Module.asm=asm;function ExitStatus(t){this.name="ExitStatus",this.message="Program terminated with exit("+t+")",this.status=t}ExitStatus.prototype=new Error,ExitStatus.prototype.constructor=ExitStatus;var initialStackTop,preloadStartTime=null,calledMain=!1;dependenciesFulfilled=function t(){Module.calledRun||run(),Module.calledRun||(dependenciesFulfilled=t)},Module.callMain=Module.callMain=function t(e){e=e||[],ensureInitRuntime();var r=e.length+1;function o(){for(var p=0;p<4-1;p++)a.push(0)}var a=[allocate(intArrayFromString(Module.thisProgram),"i8",ALLOC_NORMAL)];o();for(var n=0;n<r-1;n=n+1)a.push(allocate(intArrayFromString(e[n]),"i8",ALLOC_NORMAL)),o();a.push(0),a=allocate(a,"i32",ALLOC_NORMAL);try{var u=Module._main(r,a,0);exit(u,!0)}catch(p){if(p instanceof ExitStatus)return;if(p=="SimulateInfiniteLoop"){Module.noExitRuntime=!0;return}else{var A=p;p&&typeof p=="object"&&p.stack&&(A=[p,p.stack]),Module.printErr("exception thrown: "+A),Module.quit(1,p)}}finally{calledMain=!0}};function run(t){if(t=t||Module.arguments,preloadStartTime===null&&(preloadStartTime=Date.now()),runDependencies>0||(preRun(),runDependencies>0)||Module.calledRun)return;function e(){Module.calledRun||(Module.calledRun=!0,!ABORT&&(ensureInitRuntime(),preMain(),Module.onRuntimeInitialized&&Module.onRuntimeInitialized(),Module._main&&shouldRunNow&&Module.callMain(t),postRun()))}Module.setStatus?(Module.setStatus("Running..."),setTimeout(function(){setTimeout(function(){Module.setStatus("")},1),e()},1)):e()}Module.run=Module.run=run;function exit(t,e){e&&Module.noExitRuntime||(Module.noExitRuntime||(ABORT=!0,EXITSTATUS=t,STACKTOP=initialStackTop,exitRuntime(),Module.onExit&&Module.onExit(t)),ENVIRONMENT_IS_NODE&&process.exit(t),Module.quit(t,new ExitStatus(t)))}Module.exit=Module.exit=exit;var abortDecorators=[];function abort(t){Module.onAbort&&Module.onAbort(t),t!==void 0?(Module.print(t),Module.printErr(t),t=JSON.stringify(t)):t="",ABORT=!0,EXITSTATUS=1;var e=` +If this abort() is unexpected, build with -s ASSERTIONS=1 which can give more information.`,r="abort("+t+") at "+stackTrace()+e;throw abortDecorators&&abortDecorators.forEach(function(o){r=o(r,t)}),r}if(Module.abort=Module.abort=abort,Module.preInit)for(typeof Module.preInit=="function"&&(Module.preInit=[Module.preInit]);Module.preInit.length>0;)Module.preInit.pop()();var shouldRunNow=!0;Module.noInitialRun&&(shouldRunNow=!1),run()})});var am=_((wKt,NEe)=>{"use strict";var qyt=REe(),Yyt=TEe(),x6=!1,k6=null;Yyt({},function(t,e){if(!x6){if(x6=!0,t)throw t;k6=e}});if(!x6)throw new Error("Failed to load the yoga module - it needed to be loaded synchronously, but didn't");NEe.exports=qyt(k6.bind,k6.lib)});var F6=_((IKt,Q6)=>{"use strict";var LEe=t=>Number.isNaN(t)?!1:t>=4352&&(t<=4447||t===9001||t===9002||11904<=t&&t<=12871&&t!==12351||12880<=t&&t<=19903||19968<=t&&t<=42182||43360<=t&&t<=43388||44032<=t&&t<=55203||63744<=t&&t<=64255||65040<=t&&t<=65049||65072<=t&&t<=65131||65281<=t&&t<=65376||65504<=t&&t<=65510||110592<=t&&t<=110593||127488<=t&&t<=127569||131072<=t&&t<=262141);Q6.exports=LEe;Q6.exports.default=LEe});var MEe=_((BKt,OEe)=>{"use strict";OEe.exports=function(){return/\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62(?:\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDB40\uDC73\uDB40\uDC63\uDB40\uDC74|\uDB40\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F|\uD83D\uDC68(?:\uD83C\uDFFC\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68\uD83C\uDFFB|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFE])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFE\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFD])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFC])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83D\uDC68|(?:\uD83D[\uDC68\uDC69])\u200D(?:\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67]))|\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67])|(?:\uD83D[\uDC68\uDC69])\u200D(?:\uD83D[\uDC66\uDC67])|[\u2695\u2696\u2708]\uFE0F|\uD83D[\uDC66\uDC67]|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|(?:\uD83C\uDFFB\u200D[\u2695\u2696\u2708]|\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708])\uFE0F|\uD83C\uDFFB\u200D(?:\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C[\uDFFB-\uDFFF])|(?:\uD83E\uDDD1\uD83C\uDFFB\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDD1D\u200D\uD83D\uDC69)\uD83C\uDFFB|\uD83E\uDDD1(?:\uD83C\uDFFF\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1(?:\uD83C[\uDFFB-\uDFFF])|\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1)|(?:\uD83E\uDDD1\uD83C\uDFFE\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB-\uDFFE])|(?:\uD83E\uDDD1\uD83C\uDFFC\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDD1D\u200D\uD83D\uDC69)(?:\uD83C[\uDFFB\uDFFC])|\uD83D\uDC69(?:\uD83C\uDFFE\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFD\uDFFF])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFC\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFD-\uDFFF])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFB\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFC-\uDFFF])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D(?:\uD83D[\uDC68\uDC69])|\uD83D[\uDC68\uDC69])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD]))|\uD83D\uDC69\u200D\uD83D\uDC69\u200D(?:\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67]))|(?:\uD83E\uDDD1\uD83C\uDFFD\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDD1D\u200D\uD83D\uDC69)(?:\uD83C[\uDFFB-\uDFFD])|\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC69\u200D\uD83D\uDC69\u200D(?:\uD83D[\uDC66\uDC67])|(?:\uD83D\uDC41\uFE0F\u200D\uD83D\uDDE8|\uD83D\uDC69(?:\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708]|\uD83C\uDFFB\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\u200D[\u2695\u2696\u2708])|(?:(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)\uFE0F|\uD83D\uDC6F|\uD83E[\uDD3C\uDDDE\uDDDF])\u200D[\u2640\u2642]|(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)(?:\uD83C[\uDFFB-\uDFFF])\u200D[\u2640\u2642]|(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD6-\uDDDD])(?:(?:\uD83C[\uDFFB-\uDFFF])\u200D[\u2640\u2642]|\u200D[\u2640\u2642])|\uD83C\uDFF4\u200D\u2620)\uFE0F|\uD83D\uDC69\u200D\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67])|\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08|\uD83D\uDC15\u200D\uD83E\uDDBA|\uD83D\uDC69\u200D\uD83D\uDC66|\uD83D\uDC69\u200D\uD83D\uDC67|\uD83C\uDDFD\uD83C\uDDF0|\uD83C\uDDF4\uD83C\uDDF2|\uD83C\uDDF6\uD83C\uDDE6|[#\*0-9]\uFE0F\u20E3|\uD83C\uDDE7(?:\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF])|\uD83C\uDDF9(?:\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF])|\uD83C\uDDEA(?:\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA])|\uD83E\uDDD1(?:\uD83C[\uDFFB-\uDFFF])|\uD83C\uDDF7(?:\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC])|\uD83D\uDC69(?:\uD83C[\uDFFB-\uDFFF])|\uD83C\uDDF2(?:\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF])|\uD83C\uDDE6(?:\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF])|\uD83C\uDDF0(?:\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF])|\uD83C\uDDED(?:\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA])|\uD83C\uDDE9(?:\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF])|\uD83C\uDDFE(?:\uD83C[\uDDEA\uDDF9])|\uD83C\uDDEC(?:\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE])|\uD83C\uDDF8(?:\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF])|\uD83C\uDDEB(?:\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7])|\uD83C\uDDF5(?:\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE])|\uD83C\uDDFB(?:\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA])|\uD83C\uDDF3(?:\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF])|\uD83C\uDDE8(?:\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF])|\uD83C\uDDF1(?:\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE])|\uD83C\uDDFF(?:\uD83C[\uDDE6\uDDF2\uDDFC])|\uD83C\uDDFC(?:\uD83C[\uDDEB\uDDF8])|\uD83C\uDDFA(?:\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF])|\uD83C\uDDEE(?:\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9])|\uD83C\uDDEF(?:\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5])|(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD6-\uDDDD])(?:\uD83C[\uDFFB-\uDFFF])|(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)(?:\uD83C[\uDFFB-\uDFFF])|(?:[\u261D\u270A-\u270D]|\uD83C[\uDF85\uDFC2\uDFC7]|\uD83D[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC70\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDCAA\uDD74\uDD7A\uDD90\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC]|\uD83E[\uDD0F\uDD18-\uDD1C\uDD1E\uDD1F\uDD30-\uDD36\uDDB5\uDDB6\uDDBB\uDDD2-\uDDD5])(?:\uD83C[\uDFFB-\uDFFF])|(?:[\u231A\u231B\u23E9-\u23EC\u23F0\u23F3\u25FD\u25FE\u2614\u2615\u2648-\u2653\u267F\u2693\u26A1\u26AA\u26AB\u26BD\u26BE\u26C4\u26C5\u26CE\u26D4\u26EA\u26F2\u26F3\u26F5\u26FA\u26FD\u2705\u270A\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B\u2B1C\u2B50\u2B55]|\uD83C[\uDC04\uDCCF\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF93\uDFA0-\uDFCA\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF4\uDFF8-\uDFFF]|\uD83D[\uDC00-\uDC3E\uDC40\uDC42-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDD7A\uDD95\uDD96\uDDA4\uDDFB-\uDE4F\uDE80-\uDEC5\uDECC\uDED0-\uDED2\uDED5\uDEEB\uDEEC\uDEF4-\uDEFA\uDFE0-\uDFEB]|\uD83E[\uDD0D-\uDD3A\uDD3C-\uDD45\uDD47-\uDD71\uDD73-\uDD76\uDD7A-\uDDA2\uDDA5-\uDDAA\uDDAE-\uDDCA\uDDCD-\uDDFF\uDE70-\uDE73\uDE78-\uDE7A\uDE80-\uDE82\uDE90-\uDE95])|(?:[#\*0-9\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23E9-\u23F3\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u261D\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692-\u2697\u2699\u269B\u269C\u26A0\u26A1\u26AA\u26AB\u26B0\u26B1\u26BD\u26BE\u26C4\u26C5\u26C8\u26CE\u26CF\u26D1\u26D3\u26D4\u26E9\u26EA\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2728\u2733\u2734\u2744\u2747\u274C\u274E\u2753-\u2755\u2757\u2763\u2764\u2795-\u2797\u27A1\u27B0\u27BF\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299]|\uD83C[\uDC04\uDCCF\uDD70\uDD71\uDD7E\uDD7F\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE02\uDE1A\uDE2F\uDE32-\uDE3A\uDE50\uDE51\uDF00-\uDF21\uDF24-\uDF93\uDF96\uDF97\uDF99-\uDF9B\uDF9E-\uDFF0\uDFF3-\uDFF5\uDFF7-\uDFFF]|\uD83D[\uDC00-\uDCFD\uDCFF-\uDD3D\uDD49-\uDD4E\uDD50-\uDD67\uDD6F\uDD70\uDD73-\uDD7A\uDD87\uDD8A-\uDD8D\uDD90\uDD95\uDD96\uDDA4\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA-\uDE4F\uDE80-\uDEC5\uDECB-\uDED2\uDED5\uDEE0-\uDEE5\uDEE9\uDEEB\uDEEC\uDEF0\uDEF3-\uDEFA\uDFE0-\uDFEB]|\uD83E[\uDD0D-\uDD3A\uDD3C-\uDD45\uDD47-\uDD71\uDD73-\uDD76\uDD7A-\uDDA2\uDDA5-\uDDAA\uDDAE-\uDDCA\uDDCD-\uDDFF\uDE70-\uDE73\uDE78-\uDE7A\uDE80-\uDE82\uDE90-\uDE95])\uFE0F|(?:[\u261D\u26F9\u270A-\u270D]|\uD83C[\uDF85\uDFC2-\uDFC4\uDFC7\uDFCA-\uDFCC]|\uD83D[\uDC42\uDC43\uDC46-\uDC50\uDC66-\uDC78\uDC7C\uDC81-\uDC83\uDC85-\uDC87\uDC8F\uDC91\uDCAA\uDD74\uDD75\uDD7A\uDD90\uDD95\uDD96\uDE45-\uDE47\uDE4B-\uDE4F\uDEA3\uDEB4-\uDEB6\uDEC0\uDECC]|\uD83E[\uDD0F\uDD18-\uDD1F\uDD26\uDD30-\uDD39\uDD3C-\uDD3E\uDDB5\uDDB6\uDDB8\uDDB9\uDDBB\uDDCD-\uDDCF\uDDD1-\uDDDD])/g}});var Kk=_((vKt,R6)=>{"use strict";var Wyt=OS(),Kyt=F6(),Vyt=MEe(),UEe=t=>{if(typeof t!="string"||t.length===0||(t=Wyt(t),t.length===0))return 0;t=t.replace(Vyt()," ");let e=0;for(let r=0;r<t.length;r++){let o=t.codePointAt(r);o<=31||o>=127&&o<=159||o>=768&&o<=879||(o>65535&&r++,e+=Kyt(o)?2:1)}return e};R6.exports=UEe;R6.exports.default=UEe});var N6=_((DKt,T6)=>{"use strict";var Jyt=Kk(),_Ee=t=>{let e=0;for(let r of t.split(` +`))e=Math.max(e,Jyt(r));return e};T6.exports=_Ee;T6.exports.default=_Ee});var HEe=_(cB=>{"use strict";var zyt=cB&&cB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(cB,"__esModule",{value:!0});var Xyt=zyt(N6()),L6={};cB.default=t=>{if(t.length===0)return{width:0,height:0};if(L6[t])return L6[t];let e=Xyt.default(t),r=t.split(` +`).length;return L6[t]={width:e,height:r},{width:e,height:r}}});var jEe=_(uB=>{"use strict";var Zyt=uB&&uB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(uB,"__esModule",{value:!0});var dn=Zyt(am()),$yt=(t,e)=>{"position"in e&&t.setPositionType(e.position==="absolute"?dn.default.POSITION_TYPE_ABSOLUTE:dn.default.POSITION_TYPE_RELATIVE)},eEt=(t,e)=>{"marginLeft"in e&&t.setMargin(dn.default.EDGE_START,e.marginLeft||0),"marginRight"in e&&t.setMargin(dn.default.EDGE_END,e.marginRight||0),"marginTop"in e&&t.setMargin(dn.default.EDGE_TOP,e.marginTop||0),"marginBottom"in e&&t.setMargin(dn.default.EDGE_BOTTOM,e.marginBottom||0)},tEt=(t,e)=>{"paddingLeft"in e&&t.setPadding(dn.default.EDGE_LEFT,e.paddingLeft||0),"paddingRight"in e&&t.setPadding(dn.default.EDGE_RIGHT,e.paddingRight||0),"paddingTop"in e&&t.setPadding(dn.default.EDGE_TOP,e.paddingTop||0),"paddingBottom"in e&&t.setPadding(dn.default.EDGE_BOTTOM,e.paddingBottom||0)},rEt=(t,e)=>{var r;"flexGrow"in e&&t.setFlexGrow((r=e.flexGrow)!==null&&r!==void 0?r:0),"flexShrink"in e&&t.setFlexShrink(typeof e.flexShrink=="number"?e.flexShrink:1),"flexDirection"in e&&(e.flexDirection==="row"&&t.setFlexDirection(dn.default.FLEX_DIRECTION_ROW),e.flexDirection==="row-reverse"&&t.setFlexDirection(dn.default.FLEX_DIRECTION_ROW_REVERSE),e.flexDirection==="column"&&t.setFlexDirection(dn.default.FLEX_DIRECTION_COLUMN),e.flexDirection==="column-reverse"&&t.setFlexDirection(dn.default.FLEX_DIRECTION_COLUMN_REVERSE)),"flexBasis"in e&&(typeof e.flexBasis=="number"?t.setFlexBasis(e.flexBasis):typeof e.flexBasis=="string"?t.setFlexBasisPercent(Number.parseInt(e.flexBasis,10)):t.setFlexBasis(NaN)),"alignItems"in e&&((e.alignItems==="stretch"||!e.alignItems)&&t.setAlignItems(dn.default.ALIGN_STRETCH),e.alignItems==="flex-start"&&t.setAlignItems(dn.default.ALIGN_FLEX_START),e.alignItems==="center"&&t.setAlignItems(dn.default.ALIGN_CENTER),e.alignItems==="flex-end"&&t.setAlignItems(dn.default.ALIGN_FLEX_END)),"alignSelf"in e&&((e.alignSelf==="auto"||!e.alignSelf)&&t.setAlignSelf(dn.default.ALIGN_AUTO),e.alignSelf==="flex-start"&&t.setAlignSelf(dn.default.ALIGN_FLEX_START),e.alignSelf==="center"&&t.setAlignSelf(dn.default.ALIGN_CENTER),e.alignSelf==="flex-end"&&t.setAlignSelf(dn.default.ALIGN_FLEX_END)),"justifyContent"in e&&((e.justifyContent==="flex-start"||!e.justifyContent)&&t.setJustifyContent(dn.default.JUSTIFY_FLEX_START),e.justifyContent==="center"&&t.setJustifyContent(dn.default.JUSTIFY_CENTER),e.justifyContent==="flex-end"&&t.setJustifyContent(dn.default.JUSTIFY_FLEX_END),e.justifyContent==="space-between"&&t.setJustifyContent(dn.default.JUSTIFY_SPACE_BETWEEN),e.justifyContent==="space-around"&&t.setJustifyContent(dn.default.JUSTIFY_SPACE_AROUND))},nEt=(t,e)=>{var r,o;"width"in e&&(typeof e.width=="number"?t.setWidth(e.width):typeof e.width=="string"?t.setWidthPercent(Number.parseInt(e.width,10)):t.setWidthAuto()),"height"in e&&(typeof e.height=="number"?t.setHeight(e.height):typeof e.height=="string"?t.setHeightPercent(Number.parseInt(e.height,10)):t.setHeightAuto()),"minWidth"in e&&(typeof e.minWidth=="string"?t.setMinWidthPercent(Number.parseInt(e.minWidth,10)):t.setMinWidth((r=e.minWidth)!==null&&r!==void 0?r:0)),"minHeight"in e&&(typeof e.minHeight=="string"?t.setMinHeightPercent(Number.parseInt(e.minHeight,10)):t.setMinHeight((o=e.minHeight)!==null&&o!==void 0?o:0))},iEt=(t,e)=>{"display"in e&&t.setDisplay(e.display==="flex"?dn.default.DISPLAY_FLEX:dn.default.DISPLAY_NONE)},sEt=(t,e)=>{if("borderStyle"in e){let r=typeof e.borderStyle=="string"?1:0;t.setBorder(dn.default.EDGE_TOP,r),t.setBorder(dn.default.EDGE_BOTTOM,r),t.setBorder(dn.default.EDGE_LEFT,r),t.setBorder(dn.default.EDGE_RIGHT,r)}};uB.default=(t,e={})=>{$yt(t,e),eEt(t,e),tEt(t,e),rEt(t,e),nEt(t,e),iEt(t,e),sEt(t,e)}});var YEe=_((bKt,qEe)=>{"use strict";var AB=Kk(),oEt=OS(),aEt=DI(),M6=new Set(["\x1B","\x9B"]),lEt=39,GEe=t=>`${M6.values().next().value}[${t}m`,cEt=t=>t.split(" ").map(e=>AB(e)),O6=(t,e,r)=>{let o=[...e],a=!1,n=AB(oEt(t[t.length-1]));for(let[u,A]of o.entries()){let p=AB(A);if(n+p<=r?t[t.length-1]+=A:(t.push(A),n=0),M6.has(A))a=!0;else if(a&&A==="m"){a=!1;continue}a||(n+=p,n===r&&u<o.length-1&&(t.push(""),n=0))}!n&&t[t.length-1].length>0&&t.length>1&&(t[t.length-2]+=t.pop())},uEt=t=>{let e=t.split(" "),r=e.length;for(;r>0&&!(AB(e[r-1])>0);)r--;return r===e.length?t:e.slice(0,r).join(" ")+e.slice(r).join("")},AEt=(t,e,r={})=>{if(r.trim!==!1&&t.trim()==="")return"";let o="",a="",n,u=cEt(t),A=[""];for(let[p,h]of t.split(" ").entries()){r.trim!==!1&&(A[A.length-1]=A[A.length-1].trimLeft());let E=AB(A[A.length-1]);if(p!==0&&(E>=e&&(r.wordWrap===!1||r.trim===!1)&&(A.push(""),E=0),(E>0||r.trim===!1)&&(A[A.length-1]+=" ",E++)),r.hard&&u[p]>e){let I=e-E,v=1+Math.floor((u[p]-I-1)/e);Math.floor((u[p]-1)/e)<v&&A.push(""),O6(A,h,e);continue}if(E+u[p]>e&&E>0&&u[p]>0){if(r.wordWrap===!1&&E<e){O6(A,h,e);continue}A.push("")}if(E+u[p]>e&&r.wordWrap===!1){O6(A,h,e);continue}A[A.length-1]+=h}r.trim!==!1&&(A=A.map(uEt)),o=A.join(` +`);for(let[p,h]of[...o].entries()){if(a+=h,M6.has(h)){let I=parseFloat(/\d[^m]*/.exec(o.slice(p,p+4)));n=I===lEt?null:I}let E=aEt.codes.get(Number(n));n&&E&&(o[p+1]===` +`?a+=GEe(E):h===` +`&&(a+=GEe(n)))}return a};qEe.exports=(t,e,r)=>String(t).normalize().replace(/\r\n/g,` +`).split(` +`).map(o=>AEt(o,e,r)).join(` +`)});var VEe=_((xKt,KEe)=>{"use strict";var WEe="[\uD800-\uDBFF][\uDC00-\uDFFF]",fEt=t=>t&&t.exact?new RegExp(`^${WEe}$`):new RegExp(WEe,"g");KEe.exports=fEt});var U6=_((kKt,ZEe)=>{"use strict";var pEt=F6(),hEt=VEe(),JEe=DI(),XEe=["\x1B","\x9B"],Vk=t=>`${XEe[0]}[${t}m`,zEe=(t,e,r)=>{let o=[];t=[...t];for(let a of t){let n=a;a.match(";")&&(a=a.split(";")[0][0]+"0");let u=JEe.codes.get(parseInt(a,10));if(u){let A=t.indexOf(u.toString());A>=0?t.splice(A,1):o.push(Vk(e?u:n))}else if(e){o.push(Vk(0));break}else o.push(Vk(n))}if(e&&(o=o.filter((a,n)=>o.indexOf(a)===n),r!==void 0)){let a=Vk(JEe.codes.get(parseInt(r,10)));o=o.reduce((n,u)=>u===a?[u,...n]:[...n,u],[])}return o.join("")};ZEe.exports=(t,e,r)=>{let o=[...t.normalize()],a=[];r=typeof r=="number"?r:o.length;let n=!1,u,A=0,p="";for(let[h,E]of o.entries()){let I=!1;if(XEe.includes(E)){let v=/\d[^m]*/.exec(t.slice(h,h+18));u=v&&v.length>0?v[0]:void 0,A<r&&(n=!0,u!==void 0&&a.push(u))}else n&&E==="m"&&(n=!1,I=!0);if(!n&&!I&&++A,!hEt({exact:!0}).test(E)&&pEt(E.codePointAt())&&++A,A>e&&A<=r)p+=E;else if(A===e&&!n&&u!==void 0)p=zEe(a);else if(A>=r){p+=zEe(a,!0,u);break}}return p}});var eCe=_((QKt,$Ee)=>{"use strict";var E0=U6(),gEt=Kk();function Jk(t,e,r){if(t.charAt(e)===" ")return e;for(let o=1;o<=3;o++)if(r){if(t.charAt(e+o)===" ")return e+o}else if(t.charAt(e-o)===" ")return e-o;return e}$Ee.exports=(t,e,r)=>{r={position:"end",preferTruncationOnSpace:!1,...r};let{position:o,space:a,preferTruncationOnSpace:n}=r,u="\u2026",A=1;if(typeof t!="string")throw new TypeError(`Expected \`input\` to be a string, got ${typeof t}`);if(typeof e!="number")throw new TypeError(`Expected \`columns\` to be a number, got ${typeof e}`);if(e<1)return"";if(e===1)return u;let p=gEt(t);if(p<=e)return t;if(o==="start"){if(n){let h=Jk(t,p-e+1,!0);return u+E0(t,h,p).trim()}return a===!0&&(u+=" ",A=2),u+E0(t,p-e+A,p)}if(o==="middle"){a===!0&&(u=" "+u+" ",A=3);let h=Math.floor(e/2);if(n){let E=Jk(t,h),I=Jk(t,p-(e-h)+1,!0);return E0(t,0,E)+u+E0(t,I,p).trim()}return E0(t,0,h)+u+E0(t,p-(e-h)+A,p)}if(o==="end"){if(n){let h=Jk(t,e-1);return E0(t,0,h)+u}return a===!0&&(u=" "+u,A=2),E0(t,0,e-A)+u}throw new Error(`Expected \`options.position\` to be either \`start\`, \`middle\` or \`end\`, got ${o}`)}});var H6=_(fB=>{"use strict";var tCe=fB&&fB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(fB,"__esModule",{value:!0});var dEt=tCe(YEe()),mEt=tCe(eCe()),_6={};fB.default=(t,e,r)=>{let o=t+String(e)+String(r);if(_6[o])return _6[o];let a=t;if(r==="wrap"&&(a=dEt.default(t,e,{trim:!1,hard:!0})),r.startsWith("truncate")){let n="end";r==="truncate-middle"&&(n="middle"),r==="truncate-start"&&(n="start"),a=mEt.default(t,e,{position:n})}return _6[o]=a,a}});var G6=_(j6=>{"use strict";Object.defineProperty(j6,"__esModule",{value:!0});var rCe=t=>{let e="";if(t.childNodes.length>0)for(let r of t.childNodes){let o="";r.nodeName==="#text"?o=r.nodeValue:((r.nodeName==="ink-text"||r.nodeName==="ink-virtual-text")&&(o=rCe(r)),o.length>0&&typeof r.internal_transform=="function"&&(o=r.internal_transform(o))),e+=o}return e};j6.default=rCe});var q6=_(pi=>{"use strict";var pB=pi&&pi.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(pi,"__esModule",{value:!0});pi.setTextNodeValue=pi.createTextNode=pi.setStyle=pi.setAttribute=pi.removeChildNode=pi.insertBeforeNode=pi.appendChildNode=pi.createNode=pi.TEXT_NAME=void 0;var yEt=pB(am()),nCe=pB(HEe()),EEt=pB(jEe()),CEt=pB(H6()),wEt=pB(G6());pi.TEXT_NAME="#text";pi.createNode=t=>{var e;let r={nodeName:t,style:{},attributes:{},childNodes:[],parentNode:null,yogaNode:t==="ink-virtual-text"?void 0:yEt.default.Node.create()};return t==="ink-text"&&((e=r.yogaNode)===null||e===void 0||e.setMeasureFunc(IEt.bind(null,r))),r};pi.appendChildNode=(t,e)=>{var r;e.parentNode&&pi.removeChildNode(e.parentNode,e),e.parentNode=t,t.childNodes.push(e),e.yogaNode&&((r=t.yogaNode)===null||r===void 0||r.insertChild(e.yogaNode,t.yogaNode.getChildCount())),(t.nodeName==="ink-text"||t.nodeName==="ink-virtual-text")&&zk(t)};pi.insertBeforeNode=(t,e,r)=>{var o,a;e.parentNode&&pi.removeChildNode(e.parentNode,e),e.parentNode=t;let n=t.childNodes.indexOf(r);if(n>=0){t.childNodes.splice(n,0,e),e.yogaNode&&((o=t.yogaNode)===null||o===void 0||o.insertChild(e.yogaNode,n));return}t.childNodes.push(e),e.yogaNode&&((a=t.yogaNode)===null||a===void 0||a.insertChild(e.yogaNode,t.yogaNode.getChildCount())),(t.nodeName==="ink-text"||t.nodeName==="ink-virtual-text")&&zk(t)};pi.removeChildNode=(t,e)=>{var r,o;e.yogaNode&&((o=(r=e.parentNode)===null||r===void 0?void 0:r.yogaNode)===null||o===void 0||o.removeChild(e.yogaNode)),e.parentNode=null;let a=t.childNodes.indexOf(e);a>=0&&t.childNodes.splice(a,1),(t.nodeName==="ink-text"||t.nodeName==="ink-virtual-text")&&zk(t)};pi.setAttribute=(t,e,r)=>{t.attributes[e]=r};pi.setStyle=(t,e)=>{t.style=e,t.yogaNode&&EEt.default(t.yogaNode,e)};pi.createTextNode=t=>{let e={nodeName:"#text",nodeValue:t,yogaNode:void 0,parentNode:null,style:{}};return pi.setTextNodeValue(e,t),e};var IEt=function(t,e){var r,o;let a=t.nodeName==="#text"?t.nodeValue:wEt.default(t),n=nCe.default(a);if(n.width<=e||n.width>=1&&e>0&&e<1)return n;let u=(o=(r=t.style)===null||r===void 0?void 0:r.textWrap)!==null&&o!==void 0?o:"wrap",A=CEt.default(a,e,u);return nCe.default(A)},iCe=t=>{var e;if(!(!t||!t.parentNode))return(e=t.yogaNode)!==null&&e!==void 0?e:iCe(t.parentNode)},zk=t=>{let e=iCe(t);e?.markDirty()};pi.setTextNodeValue=(t,e)=>{typeof e!="string"&&(e=String(e)),t.nodeValue=e,zk(t)}});var cCe=_(hB=>{"use strict";var lCe=hB&&hB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(hB,"__esModule",{value:!0});var sCe=S6(),BEt=lCe(PEe()),oCe=lCe(am()),Oo=q6(),aCe=t=>{t?.unsetMeasureFunc(),t?.freeRecursive()};hB.default=BEt.default({schedulePassiveEffects:sCe.unstable_scheduleCallback,cancelPassiveEffects:sCe.unstable_cancelCallback,now:Date.now,getRootHostContext:()=>({isInsideText:!1}),prepareForCommit:()=>{},resetAfterCommit:t=>{if(t.isStaticDirty){t.isStaticDirty=!1,typeof t.onImmediateRender=="function"&&t.onImmediateRender();return}typeof t.onRender=="function"&&t.onRender()},getChildHostContext:(t,e)=>{let r=t.isInsideText,o=e==="ink-text"||e==="ink-virtual-text";return r===o?t:{isInsideText:o}},shouldSetTextContent:()=>!1,createInstance:(t,e,r,o)=>{if(o.isInsideText&&t==="ink-box")throw new Error("<Box> can\u2019t be nested inside <Text> component");let a=t==="ink-text"&&o.isInsideText?"ink-virtual-text":t,n=Oo.createNode(a);for(let[u,A]of Object.entries(e))u!=="children"&&(u==="style"?Oo.setStyle(n,A):u==="internal_transform"?n.internal_transform=A:u==="internal_static"?n.internal_static=!0:Oo.setAttribute(n,u,A));return n},createTextInstance:(t,e,r)=>{if(!r.isInsideText)throw new Error(`Text string "${t}" must be rendered inside <Text> component`);return Oo.createTextNode(t)},resetTextContent:()=>{},hideTextInstance:t=>{Oo.setTextNodeValue(t,"")},unhideTextInstance:(t,e)=>{Oo.setTextNodeValue(t,e)},getPublicInstance:t=>t,hideInstance:t=>{var e;(e=t.yogaNode)===null||e===void 0||e.setDisplay(oCe.default.DISPLAY_NONE)},unhideInstance:t=>{var e;(e=t.yogaNode)===null||e===void 0||e.setDisplay(oCe.default.DISPLAY_FLEX)},appendInitialChild:Oo.appendChildNode,appendChild:Oo.appendChildNode,insertBefore:Oo.insertBeforeNode,finalizeInitialChildren:(t,e,r,o)=>(t.internal_static&&(o.isStaticDirty=!0,o.staticNode=t),!1),supportsMutation:!0,appendChildToContainer:Oo.appendChildNode,insertInContainerBefore:Oo.insertBeforeNode,removeChildFromContainer:(t,e)=>{Oo.removeChildNode(t,e),aCe(e.yogaNode)},prepareUpdate:(t,e,r,o,a)=>{t.internal_static&&(a.isStaticDirty=!0);let n={},u=Object.keys(o);for(let A of u)if(o[A]!==r[A]){if(A==="style"&&typeof o.style=="object"&&typeof r.style=="object"){let h=o.style,E=r.style,I=Object.keys(h);for(let v of I){if(v==="borderStyle"||v==="borderColor"){if(typeof n.style!="object"){let x={};n.style=x}n.style.borderStyle=h.borderStyle,n.style.borderColor=h.borderColor}if(h[v]!==E[v]){if(typeof n.style!="object"){let x={};n.style=x}n.style[v]=h[v]}}continue}n[A]=o[A]}return n},commitUpdate:(t,e)=>{for(let[r,o]of Object.entries(e))r!=="children"&&(r==="style"?Oo.setStyle(t,o):r==="internal_transform"?t.internal_transform=o:r==="internal_static"?t.internal_static=!0:Oo.setAttribute(t,r,o))},commitTextUpdate:(t,e,r)=>{Oo.setTextNodeValue(t,r)},removeChild:(t,e)=>{Oo.removeChildNode(t,e),aCe(e.yogaNode)}})});var ACe=_((LKt,uCe)=>{"use strict";uCe.exports=(t,e=1,r)=>{if(r={indent:" ",includeEmptyLines:!1,...r},typeof t!="string")throw new TypeError(`Expected \`input\` to be a \`string\`, got \`${typeof t}\``);if(typeof e!="number")throw new TypeError(`Expected \`count\` to be a \`number\`, got \`${typeof e}\``);if(typeof r.indent!="string")throw new TypeError(`Expected \`options.indent\` to be a \`string\`, got \`${typeof r.indent}\``);if(e===0)return t;let o=r.includeEmptyLines?/^/gm:/^(?!\s*$)/gm;return t.replace(o,r.indent.repeat(e))}});var fCe=_(gB=>{"use strict";var vEt=gB&&gB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(gB,"__esModule",{value:!0});var Xk=vEt(am());gB.default=t=>t.getComputedWidth()-t.getComputedPadding(Xk.default.EDGE_LEFT)-t.getComputedPadding(Xk.default.EDGE_RIGHT)-t.getComputedBorder(Xk.default.EDGE_LEFT)-t.getComputedBorder(Xk.default.EDGE_RIGHT)});var pCe=_((MKt,DEt)=>{DEt.exports={single:{topLeft:"\u250C",topRight:"\u2510",bottomRight:"\u2518",bottomLeft:"\u2514",vertical:"\u2502",horizontal:"\u2500"},double:{topLeft:"\u2554",topRight:"\u2557",bottomRight:"\u255D",bottomLeft:"\u255A",vertical:"\u2551",horizontal:"\u2550"},round:{topLeft:"\u256D",topRight:"\u256E",bottomRight:"\u256F",bottomLeft:"\u2570",vertical:"\u2502",horizontal:"\u2500"},bold:{topLeft:"\u250F",topRight:"\u2513",bottomRight:"\u251B",bottomLeft:"\u2517",vertical:"\u2503",horizontal:"\u2501"},singleDouble:{topLeft:"\u2553",topRight:"\u2556",bottomRight:"\u255C",bottomLeft:"\u2559",vertical:"\u2551",horizontal:"\u2500"},doubleSingle:{topLeft:"\u2552",topRight:"\u2555",bottomRight:"\u255B",bottomLeft:"\u2558",vertical:"\u2502",horizontal:"\u2550"},classic:{topLeft:"+",topRight:"+",bottomRight:"+",bottomLeft:"+",vertical:"|",horizontal:"-"}}});var gCe=_((UKt,Y6)=>{"use strict";var hCe=pCe();Y6.exports=hCe;Y6.exports.default=hCe});var mCe=_((_Kt,dCe)=>{"use strict";var SEt=(t,e,r)=>{let o=t.indexOf(e);if(o===-1)return t;let a=e.length,n=0,u="";do u+=t.substr(n,o-n)+e+r,n=o+a,o=t.indexOf(e,n);while(o!==-1);return u+=t.substr(n),u},PEt=(t,e,r,o)=>{let a=0,n="";do{let u=t[o-1]==="\r";n+=t.substr(a,(u?o-1:o)-a)+e+(u?`\r +`:` +`)+r,a=o+1,o=t.indexOf(` +`,a)}while(o!==-1);return n+=t.substr(a),n};dCe.exports={stringReplaceAll:SEt,stringEncaseCRLFWithFirstIndex:PEt}});var ICe=_((HKt,wCe)=>{"use strict";var bEt=/(?:\\(u(?:[a-f\d]{4}|\{[a-f\d]{1,6}\})|x[a-f\d]{2}|.))|(?:\{(~)?(\w+(?:\([^)]*\))?(?:\.\w+(?:\([^)]*\))?)*)(?:[ \t]|(?=\r?\n)))|(\})|((?:.|[\r\n\f])+?)/gi,yCe=/(?:^|\.)(\w+)(?:\(([^)]*)\))?/g,xEt=/^(['"])((?:\\.|(?!\1)[^\\])*)\1$/,kEt=/\\(u(?:[a-f\d]{4}|{[a-f\d]{1,6}})|x[a-f\d]{2}|.)|([^\\])/gi,QEt=new Map([["n",` +`],["r","\r"],["t"," "],["b","\b"],["f","\f"],["v","\v"],["0","\0"],["\\","\\"],["e","\x1B"],["a","\x07"]]);function CCe(t){let e=t[0]==="u",r=t[1]==="{";return e&&!r&&t.length===5||t[0]==="x"&&t.length===3?String.fromCharCode(parseInt(t.slice(1),16)):e&&r?String.fromCodePoint(parseInt(t.slice(2,-1),16)):QEt.get(t)||t}function FEt(t,e){let r=[],o=e.trim().split(/\s*,\s*/g),a;for(let n of o){let u=Number(n);if(!Number.isNaN(u))r.push(u);else if(a=n.match(xEt))r.push(a[2].replace(kEt,(A,p,h)=>p?CCe(p):h));else throw new Error(`Invalid Chalk template style argument: ${n} (in style '${t}')`)}return r}function REt(t){yCe.lastIndex=0;let e=[],r;for(;(r=yCe.exec(t))!==null;){let o=r[1];if(r[2]){let a=FEt(o,r[2]);e.push([o].concat(a))}else e.push([o])}return e}function ECe(t,e){let r={};for(let a of e)for(let n of a.styles)r[n[0]]=a.inverse?null:n.slice(1);let o=t;for(let[a,n]of Object.entries(r))if(!!Array.isArray(n)){if(!(a in o))throw new Error(`Unknown Chalk style: ${a}`);o=n.length>0?o[a](...n):o[a]}return o}wCe.exports=(t,e)=>{let r=[],o=[],a=[];if(e.replace(bEt,(n,u,A,p,h,E)=>{if(u)a.push(CCe(u));else if(p){let I=a.join("");a=[],o.push(r.length===0?I:ECe(t,r)(I)),r.push({inverse:A,styles:REt(p)})}else if(h){if(r.length===0)throw new Error("Found extraneous } in Chalk template literal");o.push(ECe(t,r)(a.join(""))),a=[],r.pop()}else a.push(E)}),o.push(a.join("")),r.length>0){let n=`Chalk template literal is missing ${r.length} closing bracket${r.length===1?"":"s"} (\`}\`)`;throw new Error(n)}return o.join("")}});var rQ=_((jKt,bCe)=>{"use strict";var dB=DI(),{stdout:K6,stderr:V6}=dN(),{stringReplaceAll:TEt,stringEncaseCRLFWithFirstIndex:NEt}=mCe(),{isArray:Zk}=Array,vCe=["ansi","ansi","ansi256","ansi16m"],HC=Object.create(null),LEt=(t,e={})=>{if(e.level&&!(Number.isInteger(e.level)&&e.level>=0&&e.level<=3))throw new Error("The `level` option should be an integer from 0 to 3");let r=K6?K6.level:0;t.level=e.level===void 0?r:e.level},J6=class{constructor(e){return DCe(e)}},DCe=t=>{let e={};return LEt(e,t),e.template=(...r)=>PCe(e.template,...r),Object.setPrototypeOf(e,$k.prototype),Object.setPrototypeOf(e.template,e),e.template.constructor=()=>{throw new Error("`chalk.constructor()` is deprecated. Use `new chalk.Instance()` instead.")},e.template.Instance=J6,e.template};function $k(t){return DCe(t)}for(let[t,e]of Object.entries(dB))HC[t]={get(){let r=eQ(this,z6(e.open,e.close,this._styler),this._isEmpty);return Object.defineProperty(this,t,{value:r}),r}};HC.visible={get(){let t=eQ(this,this._styler,!0);return Object.defineProperty(this,"visible",{value:t}),t}};var SCe=["rgb","hex","keyword","hsl","hsv","hwb","ansi","ansi256"];for(let t of SCe)HC[t]={get(){let{level:e}=this;return function(...r){let o=z6(dB.color[vCe[e]][t](...r),dB.color.close,this._styler);return eQ(this,o,this._isEmpty)}}};for(let t of SCe){let e="bg"+t[0].toUpperCase()+t.slice(1);HC[e]={get(){let{level:r}=this;return function(...o){let a=z6(dB.bgColor[vCe[r]][t](...o),dB.bgColor.close,this._styler);return eQ(this,a,this._isEmpty)}}}}var OEt=Object.defineProperties(()=>{},{...HC,level:{enumerable:!0,get(){return this._generator.level},set(t){this._generator.level=t}}}),z6=(t,e,r)=>{let o,a;return r===void 0?(o=t,a=e):(o=r.openAll+t,a=e+r.closeAll),{open:t,close:e,openAll:o,closeAll:a,parent:r}},eQ=(t,e,r)=>{let o=(...a)=>Zk(a[0])&&Zk(a[0].raw)?BCe(o,PCe(o,...a)):BCe(o,a.length===1?""+a[0]:a.join(" "));return Object.setPrototypeOf(o,OEt),o._generator=t,o._styler=e,o._isEmpty=r,o},BCe=(t,e)=>{if(t.level<=0||!e)return t._isEmpty?"":e;let r=t._styler;if(r===void 0)return e;let{openAll:o,closeAll:a}=r;if(e.indexOf("\x1B")!==-1)for(;r!==void 0;)e=TEt(e,r.close,r.open),r=r.parent;let n=e.indexOf(` +`);return n!==-1&&(e=NEt(e,a,o,n)),o+e+a},W6,PCe=(t,...e)=>{let[r]=e;if(!Zk(r)||!Zk(r.raw))return e.join(" ");let o=e.slice(1),a=[r.raw[0]];for(let n=1;n<r.length;n++)a.push(String(o[n-1]).replace(/[{}\\]/g,"\\$&"),String(r.raw[n]));return W6===void 0&&(W6=ICe()),W6(t,a.join(""))};Object.defineProperties($k.prototype,HC);var tQ=$k();tQ.supportsColor=K6;tQ.stderr=$k({level:V6?V6.level:0});tQ.stderr.supportsColor=V6;bCe.exports=tQ});var X6=_(yB=>{"use strict";var MEt=yB&&yB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(yB,"__esModule",{value:!0});var mB=MEt(rQ()),UEt=/^(rgb|hsl|hsv|hwb)\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/,_Et=/^(ansi|ansi256)\(\s?(\d+)\s?\)$/,nQ=(t,e)=>e==="foreground"?t:"bg"+t[0].toUpperCase()+t.slice(1);yB.default=(t,e,r)=>{if(!e)return t;if(e in mB.default){let a=nQ(e,r);return mB.default[a](t)}if(e.startsWith("#")){let a=nQ("hex",r);return mB.default[a](e)(t)}if(e.startsWith("ansi")){let a=_Et.exec(e);if(!a)return t;let n=nQ(a[1],r),u=Number(a[2]);return mB.default[n](u)(t)}if(e.startsWith("rgb")||e.startsWith("hsl")||e.startsWith("hsv")||e.startsWith("hwb")){let a=UEt.exec(e);if(!a)return t;let n=nQ(a[1],r),u=Number(a[2]),A=Number(a[3]),p=Number(a[4]);return mB.default[n](u,A,p)(t)}return t}});var kCe=_(EB=>{"use strict";var xCe=EB&&EB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(EB,"__esModule",{value:!0});var HEt=xCe(gCe()),Z6=xCe(X6());EB.default=(t,e,r,o)=>{if(typeof r.style.borderStyle=="string"){let a=r.yogaNode.getComputedWidth(),n=r.yogaNode.getComputedHeight(),u=r.style.borderColor,A=HEt.default[r.style.borderStyle],p=Z6.default(A.topLeft+A.horizontal.repeat(a-2)+A.topRight,u,"foreground"),h=(Z6.default(A.vertical,u,"foreground")+` +`).repeat(n-2),E=Z6.default(A.bottomLeft+A.horizontal.repeat(a-2)+A.bottomRight,u,"foreground");o.write(t,e,p,{transformers:[]}),o.write(t,e+1,h,{transformers:[]}),o.write(t+a-1,e+1,h,{transformers:[]}),o.write(t,e+n-1,E,{transformers:[]})}}});var FCe=_(CB=>{"use strict";var lm=CB&&CB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(CB,"__esModule",{value:!0});var jEt=lm(am()),GEt=lm(N6()),qEt=lm(ACe()),YEt=lm(H6()),WEt=lm(fCe()),KEt=lm(G6()),VEt=lm(kCe()),JEt=(t,e)=>{var r;let o=(r=t.childNodes[0])===null||r===void 0?void 0:r.yogaNode;if(o){let a=o.getComputedLeft(),n=o.getComputedTop();e=` +`.repeat(n)+qEt.default(e,a)}return e},QCe=(t,e,r)=>{var o;let{offsetX:a=0,offsetY:n=0,transformers:u=[],skipStaticElements:A}=r;if(A&&t.internal_static)return;let{yogaNode:p}=t;if(p){if(p.getDisplay()===jEt.default.DISPLAY_NONE)return;let h=a+p.getComputedLeft(),E=n+p.getComputedTop(),I=u;if(typeof t.internal_transform=="function"&&(I=[t.internal_transform,...u]),t.nodeName==="ink-text"){let v=KEt.default(t);if(v.length>0){let x=GEt.default(v),C=WEt.default(p);if(x>C){let R=(o=t.style.textWrap)!==null&&o!==void 0?o:"wrap";v=YEt.default(v,C,R)}v=JEt(t,v),e.write(h,E,v,{transformers:I})}return}if(t.nodeName==="ink-box"&&VEt.default(h,E,t,e),t.nodeName==="ink-root"||t.nodeName==="ink-box")for(let v of t.childNodes)QCe(v,e,{offsetX:h,offsetY:E,transformers:I,skipStaticElements:A})}};CB.default=QCe});var TCe=_((WKt,RCe)=>{"use strict";RCe.exports=t=>{t=Object.assign({onlyFirst:!1},t);let e=["[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)","(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"].join("|");return new RegExp(e,t.onlyFirst?void 0:"g")}});var LCe=_((KKt,$6)=>{"use strict";var zEt=TCe(),NCe=t=>typeof t=="string"?t.replace(zEt(),""):t;$6.exports=NCe;$6.exports.default=NCe});var UCe=_((VKt,MCe)=>{"use strict";var OCe="[\uD800-\uDBFF][\uDC00-\uDFFF]";MCe.exports=t=>t&&t.exact?new RegExp(`^${OCe}$`):new RegExp(OCe,"g")});var HCe=_((JKt,ej)=>{"use strict";var XEt=LCe(),ZEt=UCe(),_Ce=t=>XEt(t).replace(ZEt()," ").length;ej.exports=_Ce;ej.exports.default=_Ce});var qCe=_(wB=>{"use strict";var GCe=wB&&wB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(wB,"__esModule",{value:!0});var jCe=GCe(U6()),$Et=GCe(HCe()),tj=class{constructor(e){this.writes=[];let{width:r,height:o}=e;this.width=r,this.height=o}write(e,r,o,a){let{transformers:n}=a;!o||this.writes.push({x:e,y:r,text:o,transformers:n})}get(){let e=[];for(let o=0;o<this.height;o++)e.push(" ".repeat(this.width));for(let o of this.writes){let{x:a,y:n,text:u,transformers:A}=o,p=u.split(` +`),h=0;for(let E of p){let I=e[n+h];if(!I)continue;let v=$Et.default(E);for(let x of A)E=x(E);e[n+h]=jCe.default(I,0,a)+E+jCe.default(I,a+v),h++}}return{output:e.map(o=>o.trimRight()).join(` +`),height:e.length}}};wB.default=tj});var KCe=_(IB=>{"use strict";var rj=IB&&IB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(IB,"__esModule",{value:!0});var eCt=rj(am()),YCe=rj(FCe()),WCe=rj(qCe());IB.default=(t,e)=>{var r;if(t.yogaNode.setWidth(e),t.yogaNode){t.yogaNode.calculateLayout(void 0,void 0,eCt.default.DIRECTION_LTR);let o=new WCe.default({width:t.yogaNode.getComputedWidth(),height:t.yogaNode.getComputedHeight()});YCe.default(t,o,{skipStaticElements:!0});let a;!((r=t.staticNode)===null||r===void 0)&&r.yogaNode&&(a=new WCe.default({width:t.staticNode.yogaNode.getComputedWidth(),height:t.staticNode.yogaNode.getComputedHeight()}),YCe.default(t.staticNode,a,{skipStaticElements:!1}));let{output:n,height:u}=o.get();return{output:n,outputHeight:u,staticOutput:a?`${a.get().output} +`:""}}return{output:"",outputHeight:0,staticOutput:""}}});var XCe=_((ZKt,zCe)=>{"use strict";var VCe=ve("stream"),JCe=["assert","count","countReset","debug","dir","dirxml","error","group","groupCollapsed","groupEnd","info","log","table","time","timeEnd","timeLog","trace","warn"],nj={},tCt=t=>{let e=new VCe.PassThrough,r=new VCe.PassThrough;e.write=a=>t("stdout",a),r.write=a=>t("stderr",a);let o=new console.Console(e,r);for(let a of JCe)nj[a]=console[a],console[a]=o[a];return()=>{for(let a of JCe)console[a]=nj[a];nj={}}};zCe.exports=tCt});var sj=_(ij=>{"use strict";Object.defineProperty(ij,"__esModule",{value:!0});ij.default=new WeakMap});var aj=_(oj=>{"use strict";Object.defineProperty(oj,"__esModule",{value:!0});var rCt=on(),ZCe=rCt.createContext({exit:()=>{}});ZCe.displayName="InternalAppContext";oj.default=ZCe});var cj=_(lj=>{"use strict";Object.defineProperty(lj,"__esModule",{value:!0});var nCt=on(),$Ce=nCt.createContext({stdin:void 0,setRawMode:()=>{},isRawModeSupported:!1,internal_exitOnCtrlC:!0});$Ce.displayName="InternalStdinContext";lj.default=$Ce});var Aj=_(uj=>{"use strict";Object.defineProperty(uj,"__esModule",{value:!0});var iCt=on(),ewe=iCt.createContext({stdout:void 0,write:()=>{}});ewe.displayName="InternalStdoutContext";uj.default=ewe});var pj=_(fj=>{"use strict";Object.defineProperty(fj,"__esModule",{value:!0});var sCt=on(),twe=sCt.createContext({stderr:void 0,write:()=>{}});twe.displayName="InternalStderrContext";fj.default=twe});var iQ=_(hj=>{"use strict";Object.defineProperty(hj,"__esModule",{value:!0});var oCt=on(),rwe=oCt.createContext({activeId:void 0,add:()=>{},remove:()=>{},activate:()=>{},deactivate:()=>{},enableFocus:()=>{},disableFocus:()=>{},focusNext:()=>{},focusPrevious:()=>{}});rwe.displayName="InternalFocusContext";hj.default=rwe});var iwe=_((sVt,nwe)=>{"use strict";var aCt=/[|\\{}()[\]^$+*?.-]/g;nwe.exports=t=>{if(typeof t!="string")throw new TypeError("Expected a string");return t.replace(aCt,"\\$&")}});var lwe=_((oVt,awe)=>{"use strict";var lCt=iwe(),cCt=typeof process=="object"&&process&&typeof process.cwd=="function"?process.cwd():".",owe=[].concat(ve("module").builtinModules,"bootstrap_node","node").map(t=>new RegExp(`(?:\\((?:node:)?${t}(?:\\.js)?:\\d+:\\d+\\)$|^\\s*at (?:node:)?${t}(?:\\.js)?:\\d+:\\d+$)`));owe.push(/\((?:node:)?internal\/[^:]+:\d+:\d+\)$/,/\s*at (?:node:)?internal\/[^:]+:\d+:\d+$/,/\/\.node-spawn-wrap-\w+-\w+\/node:\d+:\d+\)?$/);var BB=class{constructor(e){e={ignoredPackages:[],...e},"internals"in e||(e.internals=BB.nodeInternals()),"cwd"in e||(e.cwd=cCt),this._cwd=e.cwd.replace(/\\/g,"/"),this._internals=[].concat(e.internals,uCt(e.ignoredPackages)),this._wrapCallSite=e.wrapCallSite||!1}static nodeInternals(){return[...owe]}clean(e,r=0){r=" ".repeat(r),Array.isArray(e)||(e=e.split(` +`)),!/^\s*at /.test(e[0])&&/^\s*at /.test(e[1])&&(e=e.slice(1));let o=!1,a=null,n=[];return e.forEach(u=>{if(u=u.replace(/\\/g,"/"),this._internals.some(p=>p.test(u)))return;let A=/^\s*at /.test(u);o?u=u.trimEnd().replace(/^(\s+)at /,"$1"):(u=u.trim(),A&&(u=u.slice(3))),u=u.replace(`${this._cwd}/`,""),u&&(A?(a&&(n.push(a),a=null),n.push(u)):(o=!0,a=u))}),n.map(u=>`${r}${u} +`).join("")}captureString(e,r=this.captureString){typeof e=="function"&&(r=e,e=1/0);let{stackTraceLimit:o}=Error;e&&(Error.stackTraceLimit=e);let a={};Error.captureStackTrace(a,r);let{stack:n}=a;return Error.stackTraceLimit=o,this.clean(n)}capture(e,r=this.capture){typeof e=="function"&&(r=e,e=1/0);let{prepareStackTrace:o,stackTraceLimit:a}=Error;Error.prepareStackTrace=(A,p)=>this._wrapCallSite?p.map(this._wrapCallSite):p,e&&(Error.stackTraceLimit=e);let n={};Error.captureStackTrace(n,r);let{stack:u}=n;return Object.assign(Error,{prepareStackTrace:o,stackTraceLimit:a}),u}at(e=this.at){let[r]=this.capture(1,e);if(!r)return{};let o={line:r.getLineNumber(),column:r.getColumnNumber()};swe(o,r.getFileName(),this._cwd),r.isConstructor()&&(o.constructor=!0),r.isEval()&&(o.evalOrigin=r.getEvalOrigin()),r.isNative()&&(o.native=!0);let a;try{a=r.getTypeName()}catch{}a&&a!=="Object"&&a!=="[object Object]"&&(o.type=a);let n=r.getFunctionName();n&&(o.function=n);let u=r.getMethodName();return u&&n!==u&&(o.method=u),o}parseLine(e){let r=e&&e.match(ACt);if(!r)return null;let o=r[1]==="new",a=r[2],n=r[3],u=r[4],A=Number(r[5]),p=Number(r[6]),h=r[7],E=r[8],I=r[9],v=r[10]==="native",x=r[11]===")",C,R={};if(E&&(R.line=Number(E)),I&&(R.column=Number(I)),x&&h){let L=0;for(let U=h.length-1;U>0;U--)if(h.charAt(U)===")")L++;else if(h.charAt(U)==="("&&h.charAt(U-1)===" "&&(L--,L===-1&&h.charAt(U-1)===" ")){let J=h.slice(0,U-1);h=h.slice(U+1),a+=` (${J}`;break}}if(a){let L=a.match(fCt);L&&(a=L[1],C=L[2])}return swe(R,h,this._cwd),o&&(R.constructor=!0),n&&(R.evalOrigin=n,R.evalLine=A,R.evalColumn=p,R.evalFile=u&&u.replace(/\\/g,"/")),v&&(R.native=!0),a&&(R.function=a),C&&a!==C&&(R.method=C),R}};function swe(t,e,r){e&&(e=e.replace(/\\/g,"/"),e.startsWith(`${r}/`)&&(e=e.slice(r.length+1)),t.file=e)}function uCt(t){if(t.length===0)return[];let e=t.map(r=>lCt(r));return new RegExp(`[/\\\\]node_modules[/\\\\](?:${e.join("|")})[/\\\\][^:]+:\\d+:\\d+`)}var ACt=new RegExp("^(?:\\s*at )?(?:(new) )?(?:(.*?) \\()?(?:eval at ([^ ]+) \\((.+?):(\\d+):(\\d+)\\), )?(?:(.+?):(\\d+):(\\d+)|(native))(\\)?)$"),fCt=/^(.*?) \[as (.*?)\]$/;awe.exports=BB});var uwe=_((aVt,cwe)=>{"use strict";cwe.exports=(t,e)=>t.replace(/^\t+/gm,r=>" ".repeat(r.length*(e||2)))});var fwe=_((lVt,Awe)=>{"use strict";var pCt=uwe(),hCt=(t,e)=>{let r=[],o=t-e,a=t+e;for(let n=o;n<=a;n++)r.push(n);return r};Awe.exports=(t,e,r)=>{if(typeof t!="string")throw new TypeError("Source code is missing.");if(!e||e<1)throw new TypeError("Line number must start from `1`.");if(t=pCt(t).split(/\r?\n/),!(e>t.length))return r={around:3,...r},hCt(e,r.around).filter(o=>t[o-1]!==void 0).map(o=>({line:o,value:t[o-1]}))}});var sQ=_(ru=>{"use strict";var gCt=ru&&ru.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),dCt=ru&&ru.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),mCt=ru&&ru.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(t!=null)for(var r in t)r!=="default"&&Object.hasOwnProperty.call(t,r)&&gCt(e,t,r);return dCt(e,t),e},yCt=ru&&ru.__rest||function(t,e){var r={};for(var o in t)Object.prototype.hasOwnProperty.call(t,o)&&e.indexOf(o)<0&&(r[o]=t[o]);if(t!=null&&typeof Object.getOwnPropertySymbols=="function")for(var a=0,o=Object.getOwnPropertySymbols(t);a<o.length;a++)e.indexOf(o[a])<0&&Object.prototype.propertyIsEnumerable.call(t,o[a])&&(r[o[a]]=t[o[a]]);return r};Object.defineProperty(ru,"__esModule",{value:!0});var pwe=mCt(on()),gj=pwe.forwardRef((t,e)=>{var{children:r}=t,o=yCt(t,["children"]);let a=Object.assign(Object.assign({},o),{marginLeft:o.marginLeft||o.marginX||o.margin||0,marginRight:o.marginRight||o.marginX||o.margin||0,marginTop:o.marginTop||o.marginY||o.margin||0,marginBottom:o.marginBottom||o.marginY||o.margin||0,paddingLeft:o.paddingLeft||o.paddingX||o.padding||0,paddingRight:o.paddingRight||o.paddingX||o.padding||0,paddingTop:o.paddingTop||o.paddingY||o.padding||0,paddingBottom:o.paddingBottom||o.paddingY||o.padding||0});return pwe.default.createElement("ink-box",{ref:e,style:a},r)});gj.displayName="Box";gj.defaultProps={flexDirection:"row",flexGrow:0,flexShrink:1};ru.default=gj});var yj=_(vB=>{"use strict";var dj=vB&&vB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(vB,"__esModule",{value:!0});var ECt=dj(on()),jC=dj(rQ()),hwe=dj(X6()),mj=({color:t,backgroundColor:e,dimColor:r,bold:o,italic:a,underline:n,strikethrough:u,inverse:A,wrap:p,children:h})=>{if(h==null)return null;let E=I=>(r&&(I=jC.default.dim(I)),t&&(I=hwe.default(I,t,"foreground")),e&&(I=hwe.default(I,e,"background")),o&&(I=jC.default.bold(I)),a&&(I=jC.default.italic(I)),n&&(I=jC.default.underline(I)),u&&(I=jC.default.strikethrough(I)),A&&(I=jC.default.inverse(I)),I);return ECt.default.createElement("ink-text",{style:{flexGrow:0,flexShrink:1,flexDirection:"row",textWrap:p},internal_transform:E},h)};mj.displayName="Text";mj.defaultProps={dimColor:!1,bold:!1,italic:!1,underline:!1,strikethrough:!1,wrap:"wrap"};vB.default=mj});var ywe=_(nu=>{"use strict";var CCt=nu&&nu.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),wCt=nu&&nu.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),ICt=nu&&nu.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(t!=null)for(var r in t)r!=="default"&&Object.hasOwnProperty.call(t,r)&&CCt(e,t,r);return wCt(e,t),e},DB=nu&&nu.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(nu,"__esModule",{value:!0});var gwe=ICt(ve("fs")),fs=DB(on()),dwe=DB(lwe()),BCt=DB(fwe()),Zf=DB(sQ()),hA=DB(yj()),mwe=new dwe.default({cwd:process.cwd(),internals:dwe.default.nodeInternals()}),vCt=({error:t})=>{let e=t.stack?t.stack.split(` +`).slice(1):void 0,r=e?mwe.parseLine(e[0]):void 0,o,a=0;if(r?.file&&r?.line&&gwe.existsSync(r.file)){let n=gwe.readFileSync(r.file,"utf8");if(o=BCt.default(n,r.line),o)for(let{line:u}of o)a=Math.max(a,String(u).length)}return fs.default.createElement(Zf.default,{flexDirection:"column",padding:1},fs.default.createElement(Zf.default,null,fs.default.createElement(hA.default,{backgroundColor:"red",color:"white"}," ","ERROR"," "),fs.default.createElement(hA.default,null," ",t.message)),r&&fs.default.createElement(Zf.default,{marginTop:1},fs.default.createElement(hA.default,{dimColor:!0},r.file,":",r.line,":",r.column)),r&&o&&fs.default.createElement(Zf.default,{marginTop:1,flexDirection:"column"},o.map(({line:n,value:u})=>fs.default.createElement(Zf.default,{key:n},fs.default.createElement(Zf.default,{width:a+1},fs.default.createElement(hA.default,{dimColor:n!==r.line,backgroundColor:n===r.line?"red":void 0,color:n===r.line?"white":void 0},String(n).padStart(a," "),":")),fs.default.createElement(hA.default,{key:n,backgroundColor:n===r.line?"red":void 0,color:n===r.line?"white":void 0}," "+u)))),t.stack&&fs.default.createElement(Zf.default,{marginTop:1,flexDirection:"column"},t.stack.split(` +`).slice(1).map(n=>{let u=mwe.parseLine(n);return u?fs.default.createElement(Zf.default,{key:n},fs.default.createElement(hA.default,{dimColor:!0},"- "),fs.default.createElement(hA.default,{dimColor:!0,bold:!0},u.function),fs.default.createElement(hA.default,{dimColor:!0,color:"gray"}," ","(",u.file,":",u.line,":",u.column,")")):fs.default.createElement(Zf.default,{key:n},fs.default.createElement(hA.default,{dimColor:!0},"- "),fs.default.createElement(hA.default,{dimColor:!0,bold:!0},n))})))};nu.default=vCt});var Cwe=_(iu=>{"use strict";var DCt=iu&&iu.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),SCt=iu&&iu.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),PCt=iu&&iu.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(t!=null)for(var r in t)r!=="default"&&Object.hasOwnProperty.call(t,r)&&DCt(e,t,r);return SCt(e,t),e},um=iu&&iu.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(iu,"__esModule",{value:!0});var cm=PCt(on()),Ewe=um(g6()),bCt=um(aj()),xCt=um(cj()),kCt=um(Aj()),QCt=um(pj()),FCt=um(iQ()),RCt=um(ywe()),TCt=" ",NCt="\x1B[Z",LCt="\x1B",oQ=class extends cm.PureComponent{constructor(){super(...arguments),this.state={isFocusEnabled:!0,activeFocusId:void 0,focusables:[],error:void 0},this.rawModeEnabledCount=0,this.handleSetRawMode=e=>{let{stdin:r}=this.props;if(!this.isRawModeSupported())throw r===process.stdin?new Error(`Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default. +Read about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported`):new Error(`Raw mode is not supported on the stdin provided to Ink. +Read about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported`);if(r.setEncoding("utf8"),e){this.rawModeEnabledCount===0&&(r.addListener("data",this.handleInput),r.resume(),r.setRawMode(!0)),this.rawModeEnabledCount++;return}--this.rawModeEnabledCount===0&&(r.setRawMode(!1),r.removeListener("data",this.handleInput),r.pause())},this.handleInput=e=>{e===""&&this.props.exitOnCtrlC&&this.handleExit(),e===LCt&&this.state.activeFocusId&&this.setState({activeFocusId:void 0}),this.state.isFocusEnabled&&this.state.focusables.length>0&&(e===TCt&&this.focusNext(),e===NCt&&this.focusPrevious())},this.handleExit=e=>{this.isRawModeSupported()&&this.handleSetRawMode(!1),this.props.onExit(e)},this.enableFocus=()=>{this.setState({isFocusEnabled:!0})},this.disableFocus=()=>{this.setState({isFocusEnabled:!1})},this.focusNext=()=>{this.setState(e=>{let r=e.focusables[0].id;return{activeFocusId:this.findNextFocusable(e)||r}})},this.focusPrevious=()=>{this.setState(e=>{let r=e.focusables[e.focusables.length-1].id;return{activeFocusId:this.findPreviousFocusable(e)||r}})},this.addFocusable=(e,{autoFocus:r})=>{this.setState(o=>{let a=o.activeFocusId;return!a&&r&&(a=e),{activeFocusId:a,focusables:[...o.focusables,{id:e,isActive:!0}]}})},this.removeFocusable=e=>{this.setState(r=>({activeFocusId:r.activeFocusId===e?void 0:r.activeFocusId,focusables:r.focusables.filter(o=>o.id!==e)}))},this.activateFocusable=e=>{this.setState(r=>({focusables:r.focusables.map(o=>o.id!==e?o:{id:e,isActive:!0})}))},this.deactivateFocusable=e=>{this.setState(r=>({activeFocusId:r.activeFocusId===e?void 0:r.activeFocusId,focusables:r.focusables.map(o=>o.id!==e?o:{id:e,isActive:!1})}))},this.findNextFocusable=e=>{let r=e.focusables.findIndex(o=>o.id===e.activeFocusId);for(let o=r+1;o<e.focusables.length;o++)if(e.focusables[o].isActive)return e.focusables[o].id},this.findPreviousFocusable=e=>{let r=e.focusables.findIndex(o=>o.id===e.activeFocusId);for(let o=r-1;o>=0;o--)if(e.focusables[o].isActive)return e.focusables[o].id}}static getDerivedStateFromError(e){return{error:e}}isRawModeSupported(){return this.props.stdin.isTTY}render(){return cm.default.createElement(bCt.default.Provider,{value:{exit:this.handleExit}},cm.default.createElement(xCt.default.Provider,{value:{stdin:this.props.stdin,setRawMode:this.handleSetRawMode,isRawModeSupported:this.isRawModeSupported(),internal_exitOnCtrlC:this.props.exitOnCtrlC}},cm.default.createElement(kCt.default.Provider,{value:{stdout:this.props.stdout,write:this.props.writeToStdout}},cm.default.createElement(QCt.default.Provider,{value:{stderr:this.props.stderr,write:this.props.writeToStderr}},cm.default.createElement(FCt.default.Provider,{value:{activeId:this.state.activeFocusId,add:this.addFocusable,remove:this.removeFocusable,activate:this.activateFocusable,deactivate:this.deactivateFocusable,enableFocus:this.enableFocus,disableFocus:this.disableFocus,focusNext:this.focusNext,focusPrevious:this.focusPrevious}},this.state.error?cm.default.createElement(RCt.default,{error:this.state.error}):this.props.children)))))}componentDidMount(){Ewe.default.hide(this.props.stdout)}componentWillUnmount(){Ewe.default.show(this.props.stdout),this.isRawModeSupported()&&this.handleSetRawMode(!1)}componentDidCatch(e){this.handleExit(e)}};iu.default=oQ;oQ.displayName="InternalApp"});var Bwe=_(su=>{"use strict";var OCt=su&&su.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),MCt=su&&su.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),UCt=su&&su.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(t!=null)for(var r in t)r!=="default"&&Object.hasOwnProperty.call(t,r)&&OCt(e,t,r);return MCt(e,t),e},ou=su&&su.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(su,"__esModule",{value:!0});var _Ct=ou(on()),wwe=lM(),HCt=ou(lEe()),jCt=ou(u6()),GCt=ou(hEe()),qCt=ou(dEe()),Ej=ou(cCe()),YCt=ou(KCe()),WCt=ou(h6()),KCt=ou(XCe()),VCt=UCt(q6()),JCt=ou(sj()),zCt=ou(Cwe()),GC=process.env.CI==="false"?!1:GCt.default,Iwe=()=>{},Cj=class{constructor(e){this.resolveExitPromise=()=>{},this.rejectExitPromise=()=>{},this.unsubscribeExit=()=>{},this.onRender=()=>{if(this.isUnmounted)return;let{output:r,outputHeight:o,staticOutput:a}=YCt.default(this.rootNode,this.options.stdout.columns||80),n=a&&a!==` +`;if(this.options.debug){n&&(this.fullStaticOutput+=a),this.options.stdout.write(this.fullStaticOutput+r);return}if(GC){n&&this.options.stdout.write(a),this.lastOutput=r;return}if(n&&(this.fullStaticOutput+=a),o>=this.options.stdout.rows){this.options.stdout.write(jCt.default.clearTerminal+this.fullStaticOutput+r),this.lastOutput=r;return}n&&(this.log.clear(),this.options.stdout.write(a),this.log(r)),!n&&r!==this.lastOutput&&this.throttledLog(r),this.lastOutput=r},qCt.default(this),this.options=e,this.rootNode=VCt.createNode("ink-root"),this.rootNode.onRender=e.debug?this.onRender:wwe(this.onRender,32,{leading:!0,trailing:!0}),this.rootNode.onImmediateRender=this.onRender,this.log=HCt.default.create(e.stdout),this.throttledLog=e.debug?this.log:wwe(this.log,void 0,{leading:!0,trailing:!0}),this.isUnmounted=!1,this.lastOutput="",this.fullStaticOutput="",this.container=Ej.default.createContainer(this.rootNode,!1,!1),this.unsubscribeExit=WCt.default(this.unmount,{alwaysLast:!1}),e.patchConsole&&this.patchConsole(),GC||(e.stdout.on("resize",this.onRender),this.unsubscribeResize=()=>{e.stdout.off("resize",this.onRender)})}render(e){let r=_Ct.default.createElement(zCt.default,{stdin:this.options.stdin,stdout:this.options.stdout,stderr:this.options.stderr,writeToStdout:this.writeToStdout,writeToStderr:this.writeToStderr,exitOnCtrlC:this.options.exitOnCtrlC,onExit:this.unmount},e);Ej.default.updateContainer(r,this.container,null,Iwe)}writeToStdout(e){if(!this.isUnmounted){if(this.options.debug){this.options.stdout.write(e+this.fullStaticOutput+this.lastOutput);return}if(GC){this.options.stdout.write(e);return}this.log.clear(),this.options.stdout.write(e),this.log(this.lastOutput)}}writeToStderr(e){if(!this.isUnmounted){if(this.options.debug){this.options.stderr.write(e),this.options.stdout.write(this.fullStaticOutput+this.lastOutput);return}if(GC){this.options.stderr.write(e);return}this.log.clear(),this.options.stderr.write(e),this.log(this.lastOutput)}}unmount(e){this.isUnmounted||(this.onRender(),this.unsubscribeExit(),typeof this.restoreConsole=="function"&&this.restoreConsole(),typeof this.unsubscribeResize=="function"&&this.unsubscribeResize(),GC?this.options.stdout.write(this.lastOutput+` +`):this.options.debug||this.log.done(),this.isUnmounted=!0,Ej.default.updateContainer(null,this.container,null,Iwe),JCt.default.delete(this.options.stdout),e instanceof Error?this.rejectExitPromise(e):this.resolveExitPromise())}waitUntilExit(){return this.exitPromise||(this.exitPromise=new Promise((e,r)=>{this.resolveExitPromise=e,this.rejectExitPromise=r})),this.exitPromise}clear(){!GC&&!this.options.debug&&this.log.clear()}patchConsole(){this.options.debug||(this.restoreConsole=KCt.default((e,r)=>{e==="stdout"&&this.writeToStdout(r),e==="stderr"&&(r.startsWith("The above error occurred")||this.writeToStderr(r))}))}};su.default=Cj});var Dwe=_(SB=>{"use strict";var vwe=SB&&SB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(SB,"__esModule",{value:!0});var XCt=vwe(Bwe()),aQ=vwe(sj()),ZCt=ve("stream"),$Ct=(t,e)=>{let r=Object.assign({stdout:process.stdout,stdin:process.stdin,stderr:process.stderr,debug:!1,exitOnCtrlC:!0,patchConsole:!0},ewt(e)),o=twt(r.stdout,()=>new XCt.default(r));return o.render(t),{rerender:o.render,unmount:()=>o.unmount(),waitUntilExit:o.waitUntilExit,cleanup:()=>aQ.default.delete(r.stdout),clear:o.clear}};SB.default=$Ct;var ewt=(t={})=>t instanceof ZCt.Stream?{stdout:t,stdin:process.stdin}:t,twt=(t,e)=>{let r;return aQ.default.has(t)?r=aQ.default.get(t):(r=e(),aQ.default.set(t,r)),r}});var Pwe=_($f=>{"use strict";var rwt=$f&&$f.__createBinding||(Object.create?function(t,e,r,o){o===void 0&&(o=r),Object.defineProperty(t,o,{enumerable:!0,get:function(){return e[r]}})}:function(t,e,r,o){o===void 0&&(o=r),t[o]=e[r]}),nwt=$f&&$f.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),iwt=$f&&$f.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(t!=null)for(var r in t)r!=="default"&&Object.hasOwnProperty.call(t,r)&&rwt(e,t,r);return nwt(e,t),e};Object.defineProperty($f,"__esModule",{value:!0});var PB=iwt(on()),Swe=t=>{let{items:e,children:r,style:o}=t,[a,n]=PB.useState(0),u=PB.useMemo(()=>e.slice(a),[e,a]);PB.useLayoutEffect(()=>{n(e.length)},[e.length]);let A=u.map((h,E)=>r(h,a+E)),p=PB.useMemo(()=>Object.assign({position:"absolute",flexDirection:"column"},o),[o]);return PB.default.createElement("ink-box",{internal_static:!0,style:p},A)};Swe.displayName="Static";$f.default=Swe});var xwe=_(bB=>{"use strict";var swt=bB&&bB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(bB,"__esModule",{value:!0});var owt=swt(on()),bwe=({children:t,transform:e})=>t==null?null:owt.default.createElement("ink-text",{style:{flexGrow:0,flexShrink:1,flexDirection:"row"},internal_transform:e},t);bwe.displayName="Transform";bB.default=bwe});var Qwe=_(xB=>{"use strict";var awt=xB&&xB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(xB,"__esModule",{value:!0});var lwt=awt(on()),kwe=({count:t=1})=>lwt.default.createElement("ink-text",null,` +`.repeat(t));kwe.displayName="Newline";xB.default=kwe});var Twe=_(kB=>{"use strict";var Fwe=kB&&kB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(kB,"__esModule",{value:!0});var cwt=Fwe(on()),uwt=Fwe(sQ()),Rwe=()=>cwt.default.createElement(uwt.default,{flexGrow:1});Rwe.displayName="Spacer";kB.default=Rwe});var lQ=_(QB=>{"use strict";var Awt=QB&&QB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(QB,"__esModule",{value:!0});var fwt=on(),pwt=Awt(cj()),hwt=()=>fwt.useContext(pwt.default);QB.default=hwt});var Lwe=_(FB=>{"use strict";var gwt=FB&&FB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(FB,"__esModule",{value:!0});var Nwe=on(),dwt=gwt(lQ()),mwt=(t,e={})=>{let{stdin:r,setRawMode:o,internal_exitOnCtrlC:a}=dwt.default();Nwe.useEffect(()=>{if(e.isActive!==!1)return o(!0),()=>{o(!1)}},[e.isActive,o]),Nwe.useEffect(()=>{if(e.isActive===!1)return;let n=u=>{let A=String(u),p={upArrow:A==="\x1B[A",downArrow:A==="\x1B[B",leftArrow:A==="\x1B[D",rightArrow:A==="\x1B[C",pageDown:A==="\x1B[6~",pageUp:A==="\x1B[5~",return:A==="\r",escape:A==="\x1B",ctrl:!1,shift:!1,tab:A===" "||A==="\x1B[Z",backspace:A==="\b",delete:A==="\x7F"||A==="\x1B[3~",meta:!1};A<=""&&!p.return&&(A=String.fromCharCode(A.charCodeAt(0)+"a".charCodeAt(0)-1),p.ctrl=!0),A.startsWith("\x1B")&&(A=A.slice(1),p.meta=!0);let h=A>="A"&&A<="Z",E=A>="\u0410"&&A<="\u042F";A.length===1&&(h||E)&&(p.shift=!0),p.tab&&A==="[Z"&&(p.shift=!0),(p.tab||p.backspace||p.delete)&&(A=""),(!(A==="c"&&p.ctrl)||!a)&&t(A,p)};return r?.on("data",n),()=>{r?.off("data",n)}},[e.isActive,r,a,t])};FB.default=mwt});var Owe=_(RB=>{"use strict";var ywt=RB&&RB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(RB,"__esModule",{value:!0});var Ewt=on(),Cwt=ywt(aj()),wwt=()=>Ewt.useContext(Cwt.default);RB.default=wwt});var Mwe=_(TB=>{"use strict";var Iwt=TB&&TB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(TB,"__esModule",{value:!0});var Bwt=on(),vwt=Iwt(Aj()),Dwt=()=>Bwt.useContext(vwt.default);TB.default=Dwt});var Uwe=_(NB=>{"use strict";var Swt=NB&&NB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(NB,"__esModule",{value:!0});var Pwt=on(),bwt=Swt(pj()),xwt=()=>Pwt.useContext(bwt.default);NB.default=xwt});var Hwe=_(OB=>{"use strict";var _we=OB&&OB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(OB,"__esModule",{value:!0});var LB=on(),kwt=_we(iQ()),Qwt=_we(lQ()),Fwt=({isActive:t=!0,autoFocus:e=!1}={})=>{let{isRawModeSupported:r,setRawMode:o}=Qwt.default(),{activeId:a,add:n,remove:u,activate:A,deactivate:p}=LB.useContext(kwt.default),h=LB.useMemo(()=>Math.random().toString().slice(2,7),[]);return LB.useEffect(()=>(n(h,{autoFocus:e}),()=>{u(h)}),[h,e]),LB.useEffect(()=>{t?A(h):p(h)},[t,h]),LB.useEffect(()=>{if(!(!r||!t))return o(!0),()=>{o(!1)}},[t]),{isFocused:Boolean(h)&&a===h}};OB.default=Fwt});var jwe=_(MB=>{"use strict";var Rwt=MB&&MB.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(MB,"__esModule",{value:!0});var Twt=on(),Nwt=Rwt(iQ()),Lwt=()=>{let t=Twt.useContext(Nwt.default);return{enableFocus:t.enableFocus,disableFocus:t.disableFocus,focusNext:t.focusNext,focusPrevious:t.focusPrevious}};MB.default=Lwt});var Gwe=_(wj=>{"use strict";Object.defineProperty(wj,"__esModule",{value:!0});wj.default=t=>{var e,r,o,a;return{width:(r=(e=t.yogaNode)===null||e===void 0?void 0:e.getComputedWidth())!==null&&r!==void 0?r:0,height:(a=(o=t.yogaNode)===null||o===void 0?void 0:o.getComputedHeight())!==null&&a!==void 0?a:0}}});var ic=_(ro=>{"use strict";Object.defineProperty(ro,"__esModule",{value:!0});var Owt=Dwe();Object.defineProperty(ro,"render",{enumerable:!0,get:function(){return Owt.default}});var Mwt=sQ();Object.defineProperty(ro,"Box",{enumerable:!0,get:function(){return Mwt.default}});var Uwt=yj();Object.defineProperty(ro,"Text",{enumerable:!0,get:function(){return Uwt.default}});var _wt=Pwe();Object.defineProperty(ro,"Static",{enumerable:!0,get:function(){return _wt.default}});var Hwt=xwe();Object.defineProperty(ro,"Transform",{enumerable:!0,get:function(){return Hwt.default}});var jwt=Qwe();Object.defineProperty(ro,"Newline",{enumerable:!0,get:function(){return jwt.default}});var Gwt=Twe();Object.defineProperty(ro,"Spacer",{enumerable:!0,get:function(){return Gwt.default}});var qwt=Lwe();Object.defineProperty(ro,"useInput",{enumerable:!0,get:function(){return qwt.default}});var Ywt=Owe();Object.defineProperty(ro,"useApp",{enumerable:!0,get:function(){return Ywt.default}});var Wwt=lQ();Object.defineProperty(ro,"useStdin",{enumerable:!0,get:function(){return Wwt.default}});var Kwt=Mwe();Object.defineProperty(ro,"useStdout",{enumerable:!0,get:function(){return Kwt.default}});var Vwt=Uwe();Object.defineProperty(ro,"useStderr",{enumerable:!0,get:function(){return Vwt.default}});var Jwt=Hwe();Object.defineProperty(ro,"useFocus",{enumerable:!0,get:function(){return Jwt.default}});var zwt=jwe();Object.defineProperty(ro,"useFocusManager",{enumerable:!0,get:function(){return zwt.default}});var Xwt=Gwe();Object.defineProperty(ro,"measureElement",{enumerable:!0,get:function(){return Xwt.default}})});var Bj={};Vt(Bj,{Gem:()=>Ij});var qwe,Am,Ij,cQ=Et(()=>{qwe=$e(ic()),Am=$e(on()),Ij=(0,Am.memo)(({active:t})=>{let e=(0,Am.useMemo)(()=>t?"\u25C9":"\u25EF",[t]),r=(0,Am.useMemo)(()=>t?"green":"yellow",[t]);return Am.default.createElement(qwe.Text,{color:r},e)})});var Wwe={};Vt(Wwe,{useKeypress:()=>fm});function fm({active:t},e,r){let{stdin:o}=(0,Ywe.useStdin)(),a=(0,uQ.useCallback)((n,u)=>e(n,u),r);(0,uQ.useEffect)(()=>{if(!(!t||!o))return o.on("keypress",a),()=>{o.off("keypress",a)}},[t,a,o])}var Ywe,uQ,UB=Et(()=>{Ywe=$e(ic()),uQ=$e(on())});var Vwe={};Vt(Vwe,{FocusRequest:()=>Kwe,useFocusRequest:()=>vj});var Kwe,vj,Dj=Et(()=>{UB();Kwe=(r=>(r.BEFORE="before",r.AFTER="after",r))(Kwe||{}),vj=function({active:t},e,r){fm({active:t},(o,a)=>{a.name==="tab"&&(a.shift?e("before"):e("after"))},r)}});var Jwe={};Vt(Jwe,{useListInput:()=>_B});var _B,AQ=Et(()=>{UB();_B=function(t,e,{active:r,minus:o,plus:a,set:n,loop:u=!0}){fm({active:r},(A,p)=>{let h=e.indexOf(t);switch(p.name){case o:{let E=h-1;if(u){n(e[(e.length+E)%e.length]);return}if(E<0)return;n(e[E])}break;case a:{let E=h+1;if(u){n(e[E%e.length]);return}if(E>=e.length)return;n(e[E])}break}},[e,t,a,n,u])}});var fQ={};Vt(fQ,{ScrollableItems:()=>Zwt});var C0,La,Zwt,pQ=Et(()=>{C0=$e(ic()),La=$e(on());Dj();AQ();Zwt=({active:t=!0,children:e=[],radius:r=10,size:o=1,loop:a=!0,onFocusRequest:n,willReachEnd:u})=>{let A=L=>{if(L.key===null)throw new Error("Expected all children to have a key");return L.key},p=La.default.Children.map(e,L=>A(L)),h=p[0],[E,I]=(0,La.useState)(h),v=p.indexOf(E);(0,La.useEffect)(()=>{p.includes(E)||I(h)},[e]),(0,La.useEffect)(()=>{u&&v>=p.length-2&&u()},[v]),vj({active:t&&!!n},L=>{n?.(L)},[n]),_B(E,p,{active:t,minus:"up",plus:"down",set:I,loop:a});let x=v-r,C=v+r;C>p.length&&(x-=C-p.length,C=p.length),x<0&&(C+=-x,x=0),C>=p.length&&(C=p.length-1);let R=[];for(let L=x;L<=C;++L){let U=p[L],J=t&&U===E;R.push(La.default.createElement(C0.Box,{key:U,height:o},La.default.createElement(C0.Box,{marginLeft:1,marginRight:1},La.default.createElement(C0.Text,null,J?La.default.createElement(C0.Text,{color:"cyan",bold:!0},">"):" ")),La.default.createElement(C0.Box,null,La.default.cloneElement(e[L],{active:J}))))}return La.default.createElement(C0.Box,{flexDirection:"column",width:"100%"},R)}});var zwe,ep,Xwe,Sj,Zwe,Pj=Et(()=>{zwe=$e(ic()),ep=$e(on()),Xwe=ve("readline"),Sj=ep.default.createContext(null),Zwe=({children:t})=>{let{stdin:e,setRawMode:r}=(0,zwe.useStdin)();(0,ep.useEffect)(()=>{r&&r(!0),e&&(0,Xwe.emitKeypressEvents)(e)},[e,r]);let[o,a]=(0,ep.useState)(new Map),n=(0,ep.useMemo)(()=>({getAll:()=>o,get:u=>o.get(u),set:(u,A)=>a(new Map([...o,[u,A]]))}),[o,a]);return ep.default.createElement(Sj.Provider,{value:n,children:t})}});var bj={};Vt(bj,{useMinistore:()=>$wt});function $wt(t,e){let r=(0,hQ.useContext)(Sj);if(r===null)throw new Error("Expected this hook to run with a ministore context attached");if(typeof t>"u")return r.getAll();let o=(0,hQ.useCallback)(n=>{r.set(t,n)},[t,r.set]),a=r.get(t);return typeof a>"u"&&(a=e),[a,o]}var hQ,xj=Et(()=>{hQ=$e(on());Pj()});var dQ={};Vt(dQ,{renderForm:()=>eIt});async function eIt(t,e,{stdin:r,stdout:o,stderr:a}){let n,u=p=>{let{exit:h}=(0,gQ.useApp)();fm({active:!0},(E,I)=>{I.name==="return"&&(n=p,h())},[h,p])},{waitUntilExit:A}=(0,gQ.render)(kj.default.createElement(Zwe,null,kj.default.createElement(t,{...e,useSubmit:u})),{stdin:r,stdout:o,stderr:a});return await A(),n}var gQ,kj,mQ=Et(()=>{gQ=$e(ic()),kj=$e(on());Pj();UB()});var rIe=_(HB=>{"use strict";Object.defineProperty(HB,"__esModule",{value:!0});HB.UncontrolledTextInput=void 0;var eIe=on(),Qj=on(),$we=ic(),pm=rQ(),tIe=({value:t,placeholder:e="",focus:r=!0,mask:o,highlightPastedText:a=!1,showCursor:n=!0,onChange:u,onSubmit:A})=>{let[{cursorOffset:p,cursorWidth:h},E]=Qj.useState({cursorOffset:(t||"").length,cursorWidth:0});Qj.useEffect(()=>{E(R=>{if(!r||!n)return R;let L=t||"";return R.cursorOffset>L.length-1?{cursorOffset:L.length,cursorWidth:0}:R})},[t,r,n]);let I=a?h:0,v=o?o.repeat(t.length):t,x=v,C=e?pm.grey(e):void 0;if(n&&r){C=e.length>0?pm.inverse(e[0])+pm.grey(e.slice(1)):pm.inverse(" "),x=v.length>0?"":pm.inverse(" ");let R=0;for(let L of v)R>=p-I&&R<=p?x+=pm.inverse(L):x+=L,R++;v.length>0&&p===v.length&&(x+=pm.inverse(" "))}return $we.useInput((R,L)=>{if(L.upArrow||L.downArrow||L.ctrl&&R==="c"||L.tab||L.shift&&L.tab)return;if(L.return){A&&A(t);return}let U=p,J=t,te=0;L.leftArrow?n&&U--:L.rightArrow?n&&U++:L.backspace||L.delete?p>0&&(J=t.slice(0,p-1)+t.slice(p,t.length),U--):(J=t.slice(0,p)+R+t.slice(p,t.length),U+=R.length,R.length>1&&(te=R.length)),p<0&&(U=0),p>t.length&&(U=t.length),E({cursorOffset:U,cursorWidth:te}),J!==t&&u(J)},{isActive:r}),eIe.createElement($we.Text,null,e?v.length>0?x:C:x)};HB.default=tIe;HB.UncontrolledTextInput=t=>{let[e,r]=Qj.useState("");return eIe.createElement(tIe,Object.assign({},t,{value:e,onChange:r}))}});var sIe={};Vt(sIe,{Pad:()=>Fj});var nIe,iIe,Fj,Rj=Et(()=>{nIe=$e(ic()),iIe=$e(on()),Fj=({length:t,active:e})=>{if(t===0)return null;let r=t>1?` ${"-".repeat(t-1)}`:" ";return iIe.default.createElement(nIe.Text,{dimColor:!e},r)}});var oIe={};Vt(oIe,{ItemOptions:()=>tIt});var GB,I0,tIt,aIe=Et(()=>{GB=$e(ic()),I0=$e(on());AQ();cQ();Rj();tIt=function({active:t,skewer:e,options:r,value:o,onChange:a,sizes:n=[]}){let u=r.filter(({label:p})=>!!p).map(({value:p})=>p),A=r.findIndex(p=>p.value===o&&p.label!="");return _B(o,u,{active:t,minus:"left",plus:"right",set:a}),I0.default.createElement(I0.default.Fragment,null,r.map(({label:p},h)=>{let E=h===A,I=n[h]-1||0,v=p.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,""),x=Math.max(0,I-v.length-2);return p?I0.default.createElement(GB.Box,{key:p,width:I,marginLeft:1},I0.default.createElement(GB.Text,{wrap:"truncate"},I0.default.createElement(Ij,{active:E})," ",p),e?I0.default.createElement(Fj,{active:t,length:x}):null):I0.default.createElement(GB.Box,{key:`spacer-${h}`,width:I,marginLeft:1})}))}});var BIe=_((zJt,IIe)=>{var jj;IIe.exports=()=>(typeof jj>"u"&&(jj=ve("zlib").brotliDecompressSync(Buffer.from("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","base64")).toString()),jj)});var qIe=_((wzt,GIe)=>{var Xj=Symbol("arg flag"),Oa=class extends Error{constructor(e,r){super(e),this.name="ArgError",this.code=r,Object.setPrototypeOf(this,Oa.prototype)}};function sv(t,{argv:e=process.argv.slice(2),permissive:r=!1,stopAtPositional:o=!1}={}){if(!t)throw new Oa("argument specification object is required","ARG_CONFIG_NO_SPEC");let a={_:[]},n={},u={};for(let A of Object.keys(t)){if(!A)throw new Oa("argument key cannot be an empty string","ARG_CONFIG_EMPTY_KEY");if(A[0]!=="-")throw new Oa(`argument key must start with '-' but found: '${A}'`,"ARG_CONFIG_NONOPT_KEY");if(A.length===1)throw new Oa(`argument key must have a name; singular '-' keys are not allowed: ${A}`,"ARG_CONFIG_NONAME_KEY");if(typeof t[A]=="string"){n[A]=t[A];continue}let p=t[A],h=!1;if(Array.isArray(p)&&p.length===1&&typeof p[0]=="function"){let[E]=p;p=(I,v,x=[])=>(x.push(E(I,v,x[x.length-1])),x),h=E===Boolean||E[Xj]===!0}else if(typeof p=="function")h=p===Boolean||p[Xj]===!0;else throw new Oa(`type missing or not a function or valid array type: ${A}`,"ARG_CONFIG_VAD_TYPE");if(A[1]!=="-"&&A.length>2)throw new Oa(`short argument keys (with a single hyphen) must have only one character: ${A}`,"ARG_CONFIG_SHORTOPT_TOOLONG");u[A]=[p,h]}for(let A=0,p=e.length;A<p;A++){let h=e[A];if(o&&a._.length>0){a._=a._.concat(e.slice(A));break}if(h==="--"){a._=a._.concat(e.slice(A+1));break}if(h.length>1&&h[0]==="-"){let E=h[1]==="-"||h.length===2?[h]:h.slice(1).split("").map(I=>`-${I}`);for(let I=0;I<E.length;I++){let v=E[I],[x,C]=v[1]==="-"?v.split(/=(.*)/,2):[v,void 0],R=x;for(;R in n;)R=n[R];if(!(R in u))if(r){a._.push(v);continue}else throw new Oa(`unknown or unexpected option: ${x}`,"ARG_UNKNOWN_OPTION");let[L,U]=u[R];if(!U&&I+1<E.length)throw new Oa(`option requires argument (but was followed by another short argument): ${x}`,"ARG_MISSING_REQUIRED_SHORTARG");if(U)a[R]=L(!0,R,a[R]);else if(C===void 0){if(e.length<A+2||e[A+1].length>1&&e[A+1][0]==="-"&&!(e[A+1].match(/^-?\d*(\.(?=\d))?\d*$/)&&(L===Number||typeof BigInt<"u"&&L===BigInt))){let J=x===R?"":` (alias for ${R})`;throw new Oa(`option requires argument: ${x}${J}`,"ARG_MISSING_REQUIRED_LONGARG")}a[R]=L(e[A+1],R,a[R]),++A}else a[R]=L(C,R,a[R])}}else a._.push(h)}return a}sv.flag=t=>(t[Xj]=!0,t);sv.COUNT=sv.flag((t,e,r)=>(r||0)+1);sv.ArgError=Oa;GIe.exports=sv});var ZIe=_((Jzt,XIe)=>{var tG;XIe.exports=()=>(typeof tG>"u"&&(tG=ve("zlib").brotliDecompressSync(Buffer.from("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","base64")).toString()),tG)});var n1e=_((aG,lG)=>{(function(t){aG&&typeof aG=="object"&&typeof lG<"u"?lG.exports=t():typeof define=="function"&&define.amd?define([],t):typeof window<"u"?window.isWindows=t():typeof global<"u"?global.isWindows=t():typeof self<"u"?self.isWindows=t():this.isWindows=t()})(function(){"use strict";return function(){return process&&(process.platform==="win32"||/^(msys|cygwin)$/.test(process.env.OSTYPE))}})});var a1e=_((KXt,o1e)=>{"use strict";cG.ifExists=ZIt;var YC=ve("util"),sc=ve("path"),i1e=n1e(),JIt=/^#!\s*(?:\/usr\/bin\/env)?\s*([^ \t]+)(.*)$/,zIt={createPwshFile:!0,createCmdFile:i1e(),fs:ve("fs")},XIt=new Map([[".js","node"],[".cjs","node"],[".mjs","node"],[".cmd","cmd"],[".bat","cmd"],[".ps1","pwsh"],[".sh","sh"]]);function s1e(t){let e={...zIt,...t},r=e.fs;return e.fs_={chmod:r.chmod?YC.promisify(r.chmod):async()=>{},mkdir:YC.promisify(r.mkdir),readFile:YC.promisify(r.readFile),stat:YC.promisify(r.stat),unlink:YC.promisify(r.unlink),writeFile:YC.promisify(r.writeFile)},e}async function cG(t,e,r){let o=s1e(r);await o.fs_.stat(t),await e1t(t,e,o)}function ZIt(t,e,r){return cG(t,e,r).catch(()=>{})}function $It(t,e){return e.fs_.unlink(t).catch(()=>{})}async function e1t(t,e,r){let o=await s1t(t,r);return await t1t(e,r),r1t(t,e,o,r)}function t1t(t,e){return e.fs_.mkdir(sc.dirname(t),{recursive:!0})}function r1t(t,e,r,o){let a=s1e(o),n=[{generator:l1t,extension:""}];return a.createCmdFile&&n.push({generator:a1t,extension:".cmd"}),a.createPwshFile&&n.push({generator:c1t,extension:".ps1"}),Promise.all(n.map(u=>o1t(t,e+u.extension,r,u.generator,a)))}function n1t(t,e){return $It(t,e)}function i1t(t,e){return u1t(t,e)}async function s1t(t,e){let a=(await e.fs_.readFile(t,"utf8")).trim().split(/\r*\n/)[0].match(JIt);if(!a){let n=sc.extname(t).toLowerCase();return{program:XIt.get(n)||null,additionalArgs:""}}return{program:a[1],additionalArgs:a[2]}}async function o1t(t,e,r,o,a){let n=a.preserveSymlinks?"--preserve-symlinks":"",u=[r.additionalArgs,n].filter(A=>A).join(" ");return a=Object.assign({},a,{prog:r.program,args:u}),await n1t(e,a),await a.fs_.writeFile(e,o(t,e,a),"utf8"),i1t(e,a)}function a1t(t,e,r){let a=sc.relative(sc.dirname(e),t).split("/").join("\\"),n=sc.isAbsolute(a)?`"${a}"`:`"%~dp0\\${a}"`,u,A=r.prog,p=r.args||"",h=uG(r.nodePath).win32;A?(u=`"%~dp0\\${A}.exe"`,a=n):(A=n,p="",a="");let E=r.progArgs?`${r.progArgs.join(" ")} `:"",I=h?`@SET NODE_PATH=${h}\r +`:"";return u?I+=`@IF EXIST ${u} (\r + ${u} ${p} ${a} ${E}%*\r +) ELSE (\r + @SETLOCAL\r + @SET PATHEXT=%PATHEXT:;.JS;=;%\r + ${A} ${p} ${a} ${E}%*\r +)\r +`:I+=`@${A} ${p} ${a} ${E}%*\r +`,I}function l1t(t,e,r){let o=sc.relative(sc.dirname(e),t),a=r.prog&&r.prog.split("\\").join("/"),n;o=o.split("\\").join("/");let u=sc.isAbsolute(o)?`"${o}"`:`"$basedir/${o}"`,A=r.args||"",p=uG(r.nodePath).posix;a?(n=`"$basedir/${r.prog}"`,o=u):(a=u,A="",o="");let h=r.progArgs?`${r.progArgs.join(" ")} `:"",E=`#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')") + +case \`uname\` in + *CYGWIN*) basedir=\`cygpath -w "$basedir"\`;; +esac + +`,I=r.nodePath?`export NODE_PATH="${p}" +`:"";return n?E+=`${I}if [ -x ${n} ]; then + exec ${n} ${A} ${o} ${h}"$@" +else + exec ${a} ${A} ${o} ${h}"$@" +fi +`:E+=`${I}${a} ${A} ${o} ${h}"$@" +exit $? +`,E}function c1t(t,e,r){let o=sc.relative(sc.dirname(e),t),a=r.prog&&r.prog.split("\\").join("/"),n=a&&`"${a}$exe"`,u;o=o.split("\\").join("/");let A=sc.isAbsolute(o)?`"${o}"`:`"$basedir/${o}"`,p=r.args||"",h=uG(r.nodePath),E=h.win32,I=h.posix;n?(u=`"$basedir/${r.prog}$exe"`,o=A):(n=A,p="",o="");let v=r.progArgs?`${r.progArgs.join(" ")} `:"",x=`#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +${r.nodePath?`$env_node_path=$env:NODE_PATH +$env:NODE_PATH="${E}" +`:""}if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +}`;return r.nodePath&&(x+=` else { + $env:NODE_PATH="${I}" +}`),u?x+=` +$ret=0 +if (Test-Path ${u}) { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & ${u} ${p} ${o} ${v}$args + } else { + & ${u} ${p} ${o} ${v}$args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & ${n} ${p} ${o} ${v}$args + } else { + & ${n} ${p} ${o} ${v}$args + } + $ret=$LASTEXITCODE +} +${r.nodePath?`$env:NODE_PATH=$env_node_path +`:""}exit $ret +`:x+=` +# Support pipeline input +if ($MyInvocation.ExpectingInput) { + $input | & ${n} ${p} ${o} ${v}$args +} else { + & ${n} ${p} ${o} ${v}$args +} +${r.nodePath?`$env:NODE_PATH=$env_node_path +`:""}exit $LASTEXITCODE +`,x}function u1t(t,e){return e.fs_.chmod(t,493)}function uG(t){if(!t)return{win32:"",posix:""};let e=typeof t=="string"?t.split(sc.delimiter):Array.from(t),r={};for(let o=0;o<e.length;o++){let a=e[o].split("/").join("\\"),n=i1e()?e[o].split("\\").join("/").replace(/^([^:\\/]*):/,(u,A)=>`/mnt/${A.toLowerCase()}`):e[o];r.win32=r.win32?`${r.win32};${a}`:a,r.posix=r.posix?`${r.posix}:${n}`:n,r[o]={win32:a,posix:n}}return r}o1e.exports=cG});var vG=_((h$t,b1e)=>{b1e.exports=ve("stream")});var F1e=_((g$t,Q1e)=>{"use strict";function x1e(t,e){var r=Object.keys(t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(t);e&&(o=o.filter(function(a){return Object.getOwnPropertyDescriptor(t,a).enumerable})),r.push.apply(r,o)}return r}function O1t(t){for(var e=1;e<arguments.length;e++){var r=arguments[e]!=null?arguments[e]:{};e%2?x1e(Object(r),!0).forEach(function(o){M1t(t,o,r[o])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(r)):x1e(Object(r)).forEach(function(o){Object.defineProperty(t,o,Object.getOwnPropertyDescriptor(r,o))})}return t}function M1t(t,e,r){return e in t?Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[e]=r,t}function U1t(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function k1e(t,e){for(var r=0;r<e.length;r++){var o=e[r];o.enumerable=o.enumerable||!1,o.configurable=!0,"value"in o&&(o.writable=!0),Object.defineProperty(t,o.key,o)}}function _1t(t,e,r){return e&&k1e(t.prototype,e),r&&k1e(t,r),t}var H1t=ve("buffer"),xQ=H1t.Buffer,j1t=ve("util"),DG=j1t.inspect,G1t=DG&&DG.custom||"inspect";function q1t(t,e,r){xQ.prototype.copy.call(t,e,r)}Q1e.exports=function(){function t(){U1t(this,t),this.head=null,this.tail=null,this.length=0}return _1t(t,[{key:"push",value:function(r){var o={data:r,next:null};this.length>0?this.tail.next=o:this.head=o,this.tail=o,++this.length}},{key:"unshift",value:function(r){var o={data:r,next:this.head};this.length===0&&(this.tail=o),this.head=o,++this.length}},{key:"shift",value:function(){if(this.length!==0){var r=this.head.data;return this.length===1?this.head=this.tail=null:this.head=this.head.next,--this.length,r}}},{key:"clear",value:function(){this.head=this.tail=null,this.length=0}},{key:"join",value:function(r){if(this.length===0)return"";for(var o=this.head,a=""+o.data;o=o.next;)a+=r+o.data;return a}},{key:"concat",value:function(r){if(this.length===0)return xQ.alloc(0);for(var o=xQ.allocUnsafe(r>>>0),a=this.head,n=0;a;)q1t(a.data,o,n),n+=a.data.length,a=a.next;return o}},{key:"consume",value:function(r,o){var a;return r<this.head.data.length?(a=this.head.data.slice(0,r),this.head.data=this.head.data.slice(r)):r===this.head.data.length?a=this.shift():a=o?this._getString(r):this._getBuffer(r),a}},{key:"first",value:function(){return this.head.data}},{key:"_getString",value:function(r){var o=this.head,a=1,n=o.data;for(r-=n.length;o=o.next;){var u=o.data,A=r>u.length?u.length:r;if(A===u.length?n+=u:n+=u.slice(0,r),r-=A,r===0){A===u.length?(++a,o.next?this.head=o.next:this.head=this.tail=null):(this.head=o,o.data=u.slice(A));break}++a}return this.length-=a,n}},{key:"_getBuffer",value:function(r){var o=xQ.allocUnsafe(r),a=this.head,n=1;for(a.data.copy(o),r-=a.data.length;a=a.next;){var u=a.data,A=r>u.length?u.length:r;if(u.copy(o,o.length-r,0,A),r-=A,r===0){A===u.length?(++n,a.next?this.head=a.next:this.head=this.tail=null):(this.head=a,a.data=u.slice(A));break}++n}return this.length-=n,o}},{key:G1t,value:function(r,o){return DG(this,O1t({},o,{depth:0,customInspect:!1}))}}]),t}()});var PG=_((d$t,T1e)=>{"use strict";function Y1t(t,e){var r=this,o=this._readableState&&this._readableState.destroyed,a=this._writableState&&this._writableState.destroyed;return o||a?(e?e(t):t&&(this._writableState?this._writableState.errorEmitted||(this._writableState.errorEmitted=!0,process.nextTick(SG,this,t)):process.nextTick(SG,this,t)),this):(this._readableState&&(this._readableState.destroyed=!0),this._writableState&&(this._writableState.destroyed=!0),this._destroy(t||null,function(n){!e&&n?r._writableState?r._writableState.errorEmitted?process.nextTick(kQ,r):(r._writableState.errorEmitted=!0,process.nextTick(R1e,r,n)):process.nextTick(R1e,r,n):e?(process.nextTick(kQ,r),e(n)):process.nextTick(kQ,r)}),this)}function R1e(t,e){SG(t,e),kQ(t)}function kQ(t){t._writableState&&!t._writableState.emitClose||t._readableState&&!t._readableState.emitClose||t.emit("close")}function W1t(){this._readableState&&(this._readableState.destroyed=!1,this._readableState.reading=!1,this._readableState.ended=!1,this._readableState.endEmitted=!1),this._writableState&&(this._writableState.destroyed=!1,this._writableState.ended=!1,this._writableState.ending=!1,this._writableState.finalCalled=!1,this._writableState.prefinished=!1,this._writableState.finished=!1,this._writableState.errorEmitted=!1)}function SG(t,e){t.emit("error",e)}function K1t(t,e){var r=t._readableState,o=t._writableState;r&&r.autoDestroy||o&&o.autoDestroy?t.destroy(e):t.emit("error",e)}T1e.exports={destroy:Y1t,undestroy:W1t,errorOrDestroy:K1t}});var Q0=_((m$t,O1e)=>{"use strict";var L1e={};function ac(t,e,r){r||(r=Error);function o(n,u,A){return typeof e=="string"?e:e(n,u,A)}class a extends r{constructor(u,A,p){super(o(u,A,p))}}a.prototype.name=r.name,a.prototype.code=t,L1e[t]=a}function N1e(t,e){if(Array.isArray(t)){let r=t.length;return t=t.map(o=>String(o)),r>2?`one of ${e} ${t.slice(0,r-1).join(", ")}, or `+t[r-1]:r===2?`one of ${e} ${t[0]} or ${t[1]}`:`of ${e} ${t[0]}`}else return`of ${e} ${String(t)}`}function V1t(t,e,r){return t.substr(!r||r<0?0:+r,e.length)===e}function J1t(t,e,r){return(r===void 0||r>t.length)&&(r=t.length),t.substring(r-e.length,r)===e}function z1t(t,e,r){return typeof r!="number"&&(r=0),r+e.length>t.length?!1:t.indexOf(e,r)!==-1}ac("ERR_INVALID_OPT_VALUE",function(t,e){return'The value "'+e+'" is invalid for option "'+t+'"'},TypeError);ac("ERR_INVALID_ARG_TYPE",function(t,e,r){let o;typeof e=="string"&&V1t(e,"not ")?(o="must not be",e=e.replace(/^not /,"")):o="must be";let a;if(J1t(t," argument"))a=`The ${t} ${o} ${N1e(e,"type")}`;else{let n=z1t(t,".")?"property":"argument";a=`The "${t}" ${n} ${o} ${N1e(e,"type")}`}return a+=`. Received type ${typeof r}`,a},TypeError);ac("ERR_STREAM_PUSH_AFTER_EOF","stream.push() after EOF");ac("ERR_METHOD_NOT_IMPLEMENTED",function(t){return"The "+t+" method is not implemented"});ac("ERR_STREAM_PREMATURE_CLOSE","Premature close");ac("ERR_STREAM_DESTROYED",function(t){return"Cannot call "+t+" after a stream was destroyed"});ac("ERR_MULTIPLE_CALLBACK","Callback called multiple times");ac("ERR_STREAM_CANNOT_PIPE","Cannot pipe, not readable");ac("ERR_STREAM_WRITE_AFTER_END","write after end");ac("ERR_STREAM_NULL_VALUES","May not write null values to stream",TypeError);ac("ERR_UNKNOWN_ENCODING",function(t){return"Unknown encoding: "+t},TypeError);ac("ERR_STREAM_UNSHIFT_AFTER_END_EVENT","stream.unshift() after end event");O1e.exports.codes=L1e});var bG=_((y$t,M1e)=>{"use strict";var X1t=Q0().codes.ERR_INVALID_OPT_VALUE;function Z1t(t,e,r){return t.highWaterMark!=null?t.highWaterMark:e?t[r]:null}function $1t(t,e,r,o){var a=Z1t(e,o,r);if(a!=null){if(!(isFinite(a)&&Math.floor(a)===a)||a<0){var n=o?r:"highWaterMark";throw new X1t(n,a)}return Math.floor(a)}return t.objectMode?16:16*1024}M1e.exports={getHighWaterMark:$1t}});var U1e=_((E$t,xG)=>{typeof Object.create=="function"?xG.exports=function(e,r){r&&(e.super_=r,e.prototype=Object.create(r.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}))}:xG.exports=function(e,r){if(r){e.super_=r;var o=function(){};o.prototype=r.prototype,e.prototype=new o,e.prototype.constructor=e}}});var F0=_((C$t,QG)=>{try{if(kG=ve("util"),typeof kG.inherits!="function")throw"";QG.exports=kG.inherits}catch{QG.exports=U1e()}var kG});var H1e=_((w$t,_1e)=>{_1e.exports=ve("util").deprecate});var TG=_((I$t,K1e)=>{"use strict";K1e.exports=Ri;function G1e(t){var e=this;this.next=null,this.entry=null,this.finish=function(){S2t(e,t)}}var zC;Ri.WritableState=mv;var e2t={deprecate:H1e()},q1e=vG(),FQ=ve("buffer").Buffer,t2t=global.Uint8Array||function(){};function r2t(t){return FQ.from(t)}function n2t(t){return FQ.isBuffer(t)||t instanceof t2t}var RG=PG(),i2t=bG(),s2t=i2t.getHighWaterMark,R0=Q0().codes,o2t=R0.ERR_INVALID_ARG_TYPE,a2t=R0.ERR_METHOD_NOT_IMPLEMENTED,l2t=R0.ERR_MULTIPLE_CALLBACK,c2t=R0.ERR_STREAM_CANNOT_PIPE,u2t=R0.ERR_STREAM_DESTROYED,A2t=R0.ERR_STREAM_NULL_VALUES,f2t=R0.ERR_STREAM_WRITE_AFTER_END,p2t=R0.ERR_UNKNOWN_ENCODING,XC=RG.errorOrDestroy;F0()(Ri,q1e);function h2t(){}function mv(t,e,r){zC=zC||Cm(),t=t||{},typeof r!="boolean"&&(r=e instanceof zC),this.objectMode=!!t.objectMode,r&&(this.objectMode=this.objectMode||!!t.writableObjectMode),this.highWaterMark=s2t(this,t,"writableHighWaterMark",r),this.finalCalled=!1,this.needDrain=!1,this.ending=!1,this.ended=!1,this.finished=!1,this.destroyed=!1;var o=t.decodeStrings===!1;this.decodeStrings=!o,this.defaultEncoding=t.defaultEncoding||"utf8",this.length=0,this.writing=!1,this.corked=0,this.sync=!0,this.bufferProcessing=!1,this.onwrite=function(a){w2t(e,a)},this.writecb=null,this.writelen=0,this.bufferedRequest=null,this.lastBufferedRequest=null,this.pendingcb=0,this.prefinished=!1,this.errorEmitted=!1,this.emitClose=t.emitClose!==!1,this.autoDestroy=!!t.autoDestroy,this.bufferedRequestCount=0,this.corkedRequestsFree=new G1e(this)}mv.prototype.getBuffer=function(){for(var e=this.bufferedRequest,r=[];e;)r.push(e),e=e.next;return r};(function(){try{Object.defineProperty(mv.prototype,"buffer",{get:e2t.deprecate(function(){return this.getBuffer()},"_writableState.buffer is deprecated. Use _writableState.getBuffer instead.","DEP0003")})}catch{}})();var QQ;typeof Symbol=="function"&&Symbol.hasInstance&&typeof Function.prototype[Symbol.hasInstance]=="function"?(QQ=Function.prototype[Symbol.hasInstance],Object.defineProperty(Ri,Symbol.hasInstance,{value:function(e){return QQ.call(this,e)?!0:this!==Ri?!1:e&&e._writableState instanceof mv}})):QQ=function(e){return e instanceof this};function Ri(t){zC=zC||Cm();var e=this instanceof zC;if(!e&&!QQ.call(Ri,this))return new Ri(t);this._writableState=new mv(t,this,e),this.writable=!0,t&&(typeof t.write=="function"&&(this._write=t.write),typeof t.writev=="function"&&(this._writev=t.writev),typeof t.destroy=="function"&&(this._destroy=t.destroy),typeof t.final=="function"&&(this._final=t.final)),q1e.call(this)}Ri.prototype.pipe=function(){XC(this,new c2t)};function g2t(t,e){var r=new f2t;XC(t,r),process.nextTick(e,r)}function d2t(t,e,r,o){var a;return r===null?a=new A2t:typeof r!="string"&&!e.objectMode&&(a=new o2t("chunk",["string","Buffer"],r)),a?(XC(t,a),process.nextTick(o,a),!1):!0}Ri.prototype.write=function(t,e,r){var o=this._writableState,a=!1,n=!o.objectMode&&n2t(t);return n&&!FQ.isBuffer(t)&&(t=r2t(t)),typeof e=="function"&&(r=e,e=null),n?e="buffer":e||(e=o.defaultEncoding),typeof r!="function"&&(r=h2t),o.ending?g2t(this,r):(n||d2t(this,o,t,r))&&(o.pendingcb++,a=y2t(this,o,n,t,e,r)),a};Ri.prototype.cork=function(){this._writableState.corked++};Ri.prototype.uncork=function(){var t=this._writableState;t.corked&&(t.corked--,!t.writing&&!t.corked&&!t.bufferProcessing&&t.bufferedRequest&&Y1e(this,t))};Ri.prototype.setDefaultEncoding=function(e){if(typeof e=="string"&&(e=e.toLowerCase()),!(["hex","utf8","utf-8","ascii","binary","base64","ucs2","ucs-2","utf16le","utf-16le","raw"].indexOf((e+"").toLowerCase())>-1))throw new p2t(e);return this._writableState.defaultEncoding=e,this};Object.defineProperty(Ri.prototype,"writableBuffer",{enumerable:!1,get:function(){return this._writableState&&this._writableState.getBuffer()}});function m2t(t,e,r){return!t.objectMode&&t.decodeStrings!==!1&&typeof e=="string"&&(e=FQ.from(e,r)),e}Object.defineProperty(Ri.prototype,"writableHighWaterMark",{enumerable:!1,get:function(){return this._writableState.highWaterMark}});function y2t(t,e,r,o,a,n){if(!r){var u=m2t(e,o,a);o!==u&&(r=!0,a="buffer",o=u)}var A=e.objectMode?1:o.length;e.length+=A;var p=e.length<e.highWaterMark;if(p||(e.needDrain=!0),e.writing||e.corked){var h=e.lastBufferedRequest;e.lastBufferedRequest={chunk:o,encoding:a,isBuf:r,callback:n,next:null},h?h.next=e.lastBufferedRequest:e.bufferedRequest=e.lastBufferedRequest,e.bufferedRequestCount+=1}else FG(t,e,!1,A,o,a,n);return p}function FG(t,e,r,o,a,n,u){e.writelen=o,e.writecb=u,e.writing=!0,e.sync=!0,e.destroyed?e.onwrite(new u2t("write")):r?t._writev(a,e.onwrite):t._write(a,n,e.onwrite),e.sync=!1}function E2t(t,e,r,o,a){--e.pendingcb,r?(process.nextTick(a,o),process.nextTick(dv,t,e),t._writableState.errorEmitted=!0,XC(t,o)):(a(o),t._writableState.errorEmitted=!0,XC(t,o),dv(t,e))}function C2t(t){t.writing=!1,t.writecb=null,t.length-=t.writelen,t.writelen=0}function w2t(t,e){var r=t._writableState,o=r.sync,a=r.writecb;if(typeof a!="function")throw new l2t;if(C2t(r),e)E2t(t,r,o,e,a);else{var n=W1e(r)||t.destroyed;!n&&!r.corked&&!r.bufferProcessing&&r.bufferedRequest&&Y1e(t,r),o?process.nextTick(j1e,t,r,n,a):j1e(t,r,n,a)}}function j1e(t,e,r,o){r||I2t(t,e),e.pendingcb--,o(),dv(t,e)}function I2t(t,e){e.length===0&&e.needDrain&&(e.needDrain=!1,t.emit("drain"))}function Y1e(t,e){e.bufferProcessing=!0;var r=e.bufferedRequest;if(t._writev&&r&&r.next){var o=e.bufferedRequestCount,a=new Array(o),n=e.corkedRequestsFree;n.entry=r;for(var u=0,A=!0;r;)a[u]=r,r.isBuf||(A=!1),r=r.next,u+=1;a.allBuffers=A,FG(t,e,!0,e.length,a,"",n.finish),e.pendingcb++,e.lastBufferedRequest=null,n.next?(e.corkedRequestsFree=n.next,n.next=null):e.corkedRequestsFree=new G1e(e),e.bufferedRequestCount=0}else{for(;r;){var p=r.chunk,h=r.encoding,E=r.callback,I=e.objectMode?1:p.length;if(FG(t,e,!1,I,p,h,E),r=r.next,e.bufferedRequestCount--,e.writing)break}r===null&&(e.lastBufferedRequest=null)}e.bufferedRequest=r,e.bufferProcessing=!1}Ri.prototype._write=function(t,e,r){r(new a2t("_write()"))};Ri.prototype._writev=null;Ri.prototype.end=function(t,e,r){var o=this._writableState;return typeof t=="function"?(r=t,t=null,e=null):typeof e=="function"&&(r=e,e=null),t!=null&&this.write(t,e),o.corked&&(o.corked=1,this.uncork()),o.ending||D2t(this,o,r),this};Object.defineProperty(Ri.prototype,"writableLength",{enumerable:!1,get:function(){return this._writableState.length}});function W1e(t){return t.ending&&t.length===0&&t.bufferedRequest===null&&!t.finished&&!t.writing}function B2t(t,e){t._final(function(r){e.pendingcb--,r&&XC(t,r),e.prefinished=!0,t.emit("prefinish"),dv(t,e)})}function v2t(t,e){!e.prefinished&&!e.finalCalled&&(typeof t._final=="function"&&!e.destroyed?(e.pendingcb++,e.finalCalled=!0,process.nextTick(B2t,t,e)):(e.prefinished=!0,t.emit("prefinish")))}function dv(t,e){var r=W1e(e);if(r&&(v2t(t,e),e.pendingcb===0&&(e.finished=!0,t.emit("finish"),e.autoDestroy))){var o=t._readableState;(!o||o.autoDestroy&&o.endEmitted)&&t.destroy()}return r}function D2t(t,e,r){e.ending=!0,dv(t,e),r&&(e.finished?process.nextTick(r):t.once("finish",r)),e.ended=!0,t.writable=!1}function S2t(t,e,r){var o=t.entry;for(t.entry=null;o;){var a=o.callback;e.pendingcb--,a(r),o=o.next}e.corkedRequestsFree.next=t}Object.defineProperty(Ri.prototype,"destroyed",{enumerable:!1,get:function(){return this._writableState===void 0?!1:this._writableState.destroyed},set:function(e){!this._writableState||(this._writableState.destroyed=e)}});Ri.prototype.destroy=RG.destroy;Ri.prototype._undestroy=RG.undestroy;Ri.prototype._destroy=function(t,e){e(t)}});var Cm=_((B$t,J1e)=>{"use strict";var P2t=Object.keys||function(t){var e=[];for(var r in t)e.push(r);return e};J1e.exports=EA;var V1e=OG(),LG=TG();F0()(EA,V1e);for(NG=P2t(LG.prototype),RQ=0;RQ<NG.length;RQ++)TQ=NG[RQ],EA.prototype[TQ]||(EA.prototype[TQ]=LG.prototype[TQ]);var NG,TQ,RQ;function EA(t){if(!(this instanceof EA))return new EA(t);V1e.call(this,t),LG.call(this,t),this.allowHalfOpen=!0,t&&(t.readable===!1&&(this.readable=!1),t.writable===!1&&(this.writable=!1),t.allowHalfOpen===!1&&(this.allowHalfOpen=!1,this.once("end",b2t)))}Object.defineProperty(EA.prototype,"writableHighWaterMark",{enumerable:!1,get:function(){return this._writableState.highWaterMark}});Object.defineProperty(EA.prototype,"writableBuffer",{enumerable:!1,get:function(){return this._writableState&&this._writableState.getBuffer()}});Object.defineProperty(EA.prototype,"writableLength",{enumerable:!1,get:function(){return this._writableState.length}});function b2t(){this._writableState.ended||process.nextTick(x2t,this)}function x2t(t){t.end()}Object.defineProperty(EA.prototype,"destroyed",{enumerable:!1,get:function(){return this._readableState===void 0||this._writableState===void 0?!1:this._readableState.destroyed&&this._writableState.destroyed},set:function(e){this._readableState===void 0||this._writableState===void 0||(this._readableState.destroyed=e,this._writableState.destroyed=e)}})});var Z1e=_((MG,X1e)=>{var NQ=ve("buffer"),ip=NQ.Buffer;function z1e(t,e){for(var r in t)e[r]=t[r]}ip.from&&ip.alloc&&ip.allocUnsafe&&ip.allocUnsafeSlow?X1e.exports=NQ:(z1e(NQ,MG),MG.Buffer=ZC);function ZC(t,e,r){return ip(t,e,r)}z1e(ip,ZC);ZC.from=function(t,e,r){if(typeof t=="number")throw new TypeError("Argument must not be a number");return ip(t,e,r)};ZC.alloc=function(t,e,r){if(typeof t!="number")throw new TypeError("Argument must be a number");var o=ip(t);return e!==void 0?typeof r=="string"?o.fill(e,r):o.fill(e):o.fill(0),o};ZC.allocUnsafe=function(t){if(typeof t!="number")throw new TypeError("Argument must be a number");return ip(t)};ZC.allocUnsafeSlow=function(t){if(typeof t!="number")throw new TypeError("Argument must be a number");return NQ.SlowBuffer(t)}});var HG=_(e2e=>{"use strict";var _G=Z1e().Buffer,$1e=_G.isEncoding||function(t){switch(t=""+t,t&&t.toLowerCase()){case"hex":case"utf8":case"utf-8":case"ascii":case"binary":case"base64":case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":case"raw":return!0;default:return!1}};function k2t(t){if(!t)return"utf8";for(var e;;)switch(t){case"utf8":case"utf-8":return"utf8";case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return"utf16le";case"latin1":case"binary":return"latin1";case"base64":case"ascii":case"hex":return t;default:if(e)return;t=(""+t).toLowerCase(),e=!0}}function Q2t(t){var e=k2t(t);if(typeof e!="string"&&(_G.isEncoding===$1e||!$1e(t)))throw new Error("Unknown encoding: "+t);return e||t}e2e.StringDecoder=yv;function yv(t){this.encoding=Q2t(t);var e;switch(this.encoding){case"utf16le":this.text=O2t,this.end=M2t,e=4;break;case"utf8":this.fillLast=T2t,e=4;break;case"base64":this.text=U2t,this.end=_2t,e=3;break;default:this.write=H2t,this.end=j2t;return}this.lastNeed=0,this.lastTotal=0,this.lastChar=_G.allocUnsafe(e)}yv.prototype.write=function(t){if(t.length===0)return"";var e,r;if(this.lastNeed){if(e=this.fillLast(t),e===void 0)return"";r=this.lastNeed,this.lastNeed=0}else r=0;return r<t.length?e?e+this.text(t,r):this.text(t,r):e||""};yv.prototype.end=L2t;yv.prototype.text=N2t;yv.prototype.fillLast=function(t){if(this.lastNeed<=t.length)return t.copy(this.lastChar,this.lastTotal-this.lastNeed,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal);t.copy(this.lastChar,this.lastTotal-this.lastNeed,0,t.length),this.lastNeed-=t.length};function UG(t){return t<=127?0:t>>5===6?2:t>>4===14?3:t>>3===30?4:t>>6===2?-1:-2}function F2t(t,e,r){var o=e.length-1;if(o<r)return 0;var a=UG(e[o]);return a>=0?(a>0&&(t.lastNeed=a-1),a):--o<r||a===-2?0:(a=UG(e[o]),a>=0?(a>0&&(t.lastNeed=a-2),a):--o<r||a===-2?0:(a=UG(e[o]),a>=0?(a>0&&(a===2?a=0:t.lastNeed=a-3),a):0))}function R2t(t,e,r){if((e[0]&192)!==128)return t.lastNeed=0,"\uFFFD";if(t.lastNeed>1&&e.length>1){if((e[1]&192)!==128)return t.lastNeed=1,"\uFFFD";if(t.lastNeed>2&&e.length>2&&(e[2]&192)!==128)return t.lastNeed=2,"\uFFFD"}}function T2t(t){var e=this.lastTotal-this.lastNeed,r=R2t(this,t,e);if(r!==void 0)return r;if(this.lastNeed<=t.length)return t.copy(this.lastChar,e,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal);t.copy(this.lastChar,e,0,t.length),this.lastNeed-=t.length}function N2t(t,e){var r=F2t(this,t,e);if(!this.lastNeed)return t.toString("utf8",e);this.lastTotal=r;var o=t.length-(r-this.lastNeed);return t.copy(this.lastChar,0,o),t.toString("utf8",e,o)}function L2t(t){var e=t&&t.length?this.write(t):"";return this.lastNeed?e+"\uFFFD":e}function O2t(t,e){if((t.length-e)%2===0){var r=t.toString("utf16le",e);if(r){var o=r.charCodeAt(r.length-1);if(o>=55296&&o<=56319)return this.lastNeed=2,this.lastTotal=4,this.lastChar[0]=t[t.length-2],this.lastChar[1]=t[t.length-1],r.slice(0,-1)}return r}return this.lastNeed=1,this.lastTotal=2,this.lastChar[0]=t[t.length-1],t.toString("utf16le",e,t.length-1)}function M2t(t){var e=t&&t.length?this.write(t):"";if(this.lastNeed){var r=this.lastTotal-this.lastNeed;return e+this.lastChar.toString("utf16le",0,r)}return e}function U2t(t,e){var r=(t.length-e)%3;return r===0?t.toString("base64",e):(this.lastNeed=3-r,this.lastTotal=3,r===1?this.lastChar[0]=t[t.length-1]:(this.lastChar[0]=t[t.length-2],this.lastChar[1]=t[t.length-1]),t.toString("base64",e,t.length-r))}function _2t(t){var e=t&&t.length?this.write(t):"";return this.lastNeed?e+this.lastChar.toString("base64",0,3-this.lastNeed):e}function H2t(t){return t.toString(this.encoding)}function j2t(t){return t&&t.length?this.write(t):""}});var LQ=_((D$t,n2e)=>{"use strict";var t2e=Q0().codes.ERR_STREAM_PREMATURE_CLOSE;function G2t(t){var e=!1;return function(){if(!e){e=!0;for(var r=arguments.length,o=new Array(r),a=0;a<r;a++)o[a]=arguments[a];t.apply(this,o)}}}function q2t(){}function Y2t(t){return t.setHeader&&typeof t.abort=="function"}function r2e(t,e,r){if(typeof e=="function")return r2e(t,null,e);e||(e={}),r=G2t(r||q2t);var o=e.readable||e.readable!==!1&&t.readable,a=e.writable||e.writable!==!1&&t.writable,n=function(){t.writable||A()},u=t._writableState&&t._writableState.finished,A=function(){a=!1,u=!0,o||r.call(t)},p=t._readableState&&t._readableState.endEmitted,h=function(){o=!1,p=!0,a||r.call(t)},E=function(C){r.call(t,C)},I=function(){var C;if(o&&!p)return(!t._readableState||!t._readableState.ended)&&(C=new t2e),r.call(t,C);if(a&&!u)return(!t._writableState||!t._writableState.ended)&&(C=new t2e),r.call(t,C)},v=function(){t.req.on("finish",A)};return Y2t(t)?(t.on("complete",A),t.on("abort",I),t.req?v():t.on("request",v)):a&&!t._writableState&&(t.on("end",n),t.on("close",n)),t.on("end",h),t.on("finish",A),e.error!==!1&&t.on("error",E),t.on("close",I),function(){t.removeListener("complete",A),t.removeListener("abort",I),t.removeListener("request",v),t.req&&t.req.removeListener("finish",A),t.removeListener("end",n),t.removeListener("close",n),t.removeListener("finish",A),t.removeListener("end",h),t.removeListener("error",E),t.removeListener("close",I)}}n2e.exports=r2e});var s2e=_((S$t,i2e)=>{"use strict";var OQ;function T0(t,e,r){return e in t?Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[e]=r,t}var W2t=LQ(),N0=Symbol("lastResolve"),wm=Symbol("lastReject"),Ev=Symbol("error"),MQ=Symbol("ended"),Im=Symbol("lastPromise"),jG=Symbol("handlePromise"),Bm=Symbol("stream");function L0(t,e){return{value:t,done:e}}function K2t(t){var e=t[N0];if(e!==null){var r=t[Bm].read();r!==null&&(t[Im]=null,t[N0]=null,t[wm]=null,e(L0(r,!1)))}}function V2t(t){process.nextTick(K2t,t)}function J2t(t,e){return function(r,o){t.then(function(){if(e[MQ]){r(L0(void 0,!0));return}e[jG](r,o)},o)}}var z2t=Object.getPrototypeOf(function(){}),X2t=Object.setPrototypeOf((OQ={get stream(){return this[Bm]},next:function(){var e=this,r=this[Ev];if(r!==null)return Promise.reject(r);if(this[MQ])return Promise.resolve(L0(void 0,!0));if(this[Bm].destroyed)return new Promise(function(u,A){process.nextTick(function(){e[Ev]?A(e[Ev]):u(L0(void 0,!0))})});var o=this[Im],a;if(o)a=new Promise(J2t(o,this));else{var n=this[Bm].read();if(n!==null)return Promise.resolve(L0(n,!1));a=new Promise(this[jG])}return this[Im]=a,a}},T0(OQ,Symbol.asyncIterator,function(){return this}),T0(OQ,"return",function(){var e=this;return new Promise(function(r,o){e[Bm].destroy(null,function(a){if(a){o(a);return}r(L0(void 0,!0))})})}),OQ),z2t),Z2t=function(e){var r,o=Object.create(X2t,(r={},T0(r,Bm,{value:e,writable:!0}),T0(r,N0,{value:null,writable:!0}),T0(r,wm,{value:null,writable:!0}),T0(r,Ev,{value:null,writable:!0}),T0(r,MQ,{value:e._readableState.endEmitted,writable:!0}),T0(r,jG,{value:function(n,u){var A=o[Bm].read();A?(o[Im]=null,o[N0]=null,o[wm]=null,n(L0(A,!1))):(o[N0]=n,o[wm]=u)},writable:!0}),r));return o[Im]=null,W2t(e,function(a){if(a&&a.code!=="ERR_STREAM_PREMATURE_CLOSE"){var n=o[wm];n!==null&&(o[Im]=null,o[N0]=null,o[wm]=null,n(a)),o[Ev]=a;return}var u=o[N0];u!==null&&(o[Im]=null,o[N0]=null,o[wm]=null,u(L0(void 0,!0))),o[MQ]=!0}),e.on("readable",V2t.bind(null,o)),o};i2e.exports=Z2t});var c2e=_((P$t,l2e)=>{"use strict";function o2e(t,e,r,o,a,n,u){try{var A=t[n](u),p=A.value}catch(h){r(h);return}A.done?e(p):Promise.resolve(p).then(o,a)}function $2t(t){return function(){var e=this,r=arguments;return new Promise(function(o,a){var n=t.apply(e,r);function u(p){o2e(n,o,a,u,A,"next",p)}function A(p){o2e(n,o,a,u,A,"throw",p)}u(void 0)})}}function a2e(t,e){var r=Object.keys(t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(t);e&&(o=o.filter(function(a){return Object.getOwnPropertyDescriptor(t,a).enumerable})),r.push.apply(r,o)}return r}function eBt(t){for(var e=1;e<arguments.length;e++){var r=arguments[e]!=null?arguments[e]:{};e%2?a2e(Object(r),!0).forEach(function(o){tBt(t,o,r[o])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(r)):a2e(Object(r)).forEach(function(o){Object.defineProperty(t,o,Object.getOwnPropertyDescriptor(r,o))})}return t}function tBt(t,e,r){return e in t?Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[e]=r,t}var rBt=Q0().codes.ERR_INVALID_ARG_TYPE;function nBt(t,e,r){var o;if(e&&typeof e.next=="function")o=e;else if(e&&e[Symbol.asyncIterator])o=e[Symbol.asyncIterator]();else if(e&&e[Symbol.iterator])o=e[Symbol.iterator]();else throw new rBt("iterable",["Iterable"],e);var a=new t(eBt({objectMode:!0},r)),n=!1;a._read=function(){n||(n=!0,u())};function u(){return A.apply(this,arguments)}function A(){return A=$2t(function*(){try{var p=yield o.next(),h=p.value,E=p.done;E?a.push(null):a.push(yield h)?u():n=!1}catch(I){a.destroy(I)}}),A.apply(this,arguments)}return a}l2e.exports=nBt});var OG=_((x$t,E2e)=>{"use strict";E2e.exports=mn;var $C;mn.ReadableState=p2e;var b$t=ve("events").EventEmitter,f2e=function(e,r){return e.listeners(r).length},wv=vG(),UQ=ve("buffer").Buffer,iBt=global.Uint8Array||function(){};function sBt(t){return UQ.from(t)}function oBt(t){return UQ.isBuffer(t)||t instanceof iBt}var GG=ve("util"),en;GG&&GG.debuglog?en=GG.debuglog("stream"):en=function(){};var aBt=F1e(),zG=PG(),lBt=bG(),cBt=lBt.getHighWaterMark,_Q=Q0().codes,uBt=_Q.ERR_INVALID_ARG_TYPE,ABt=_Q.ERR_STREAM_PUSH_AFTER_EOF,fBt=_Q.ERR_METHOD_NOT_IMPLEMENTED,pBt=_Q.ERR_STREAM_UNSHIFT_AFTER_END_EVENT,ew,qG,YG;F0()(mn,wv);var Cv=zG.errorOrDestroy,WG=["error","close","destroy","pause","resume"];function hBt(t,e,r){if(typeof t.prependListener=="function")return t.prependListener(e,r);!t._events||!t._events[e]?t.on(e,r):Array.isArray(t._events[e])?t._events[e].unshift(r):t._events[e]=[r,t._events[e]]}function p2e(t,e,r){$C=$C||Cm(),t=t||{},typeof r!="boolean"&&(r=e instanceof $C),this.objectMode=!!t.objectMode,r&&(this.objectMode=this.objectMode||!!t.readableObjectMode),this.highWaterMark=cBt(this,t,"readableHighWaterMark",r),this.buffer=new aBt,this.length=0,this.pipes=null,this.pipesCount=0,this.flowing=null,this.ended=!1,this.endEmitted=!1,this.reading=!1,this.sync=!0,this.needReadable=!1,this.emittedReadable=!1,this.readableListening=!1,this.resumeScheduled=!1,this.paused=!0,this.emitClose=t.emitClose!==!1,this.autoDestroy=!!t.autoDestroy,this.destroyed=!1,this.defaultEncoding=t.defaultEncoding||"utf8",this.awaitDrain=0,this.readingMore=!1,this.decoder=null,this.encoding=null,t.encoding&&(ew||(ew=HG().StringDecoder),this.decoder=new ew(t.encoding),this.encoding=t.encoding)}function mn(t){if($C=$C||Cm(),!(this instanceof mn))return new mn(t);var e=this instanceof $C;this._readableState=new p2e(t,this,e),this.readable=!0,t&&(typeof t.read=="function"&&(this._read=t.read),typeof t.destroy=="function"&&(this._destroy=t.destroy)),wv.call(this)}Object.defineProperty(mn.prototype,"destroyed",{enumerable:!1,get:function(){return this._readableState===void 0?!1:this._readableState.destroyed},set:function(e){!this._readableState||(this._readableState.destroyed=e)}});mn.prototype.destroy=zG.destroy;mn.prototype._undestroy=zG.undestroy;mn.prototype._destroy=function(t,e){e(t)};mn.prototype.push=function(t,e){var r=this._readableState,o;return r.objectMode?o=!0:typeof t=="string"&&(e=e||r.defaultEncoding,e!==r.encoding&&(t=UQ.from(t,e),e=""),o=!0),h2e(this,t,e,!1,o)};mn.prototype.unshift=function(t){return h2e(this,t,null,!0,!1)};function h2e(t,e,r,o,a){en("readableAddChunk",e);var n=t._readableState;if(e===null)n.reading=!1,mBt(t,n);else{var u;if(a||(u=gBt(n,e)),u)Cv(t,u);else if(n.objectMode||e&&e.length>0)if(typeof e!="string"&&!n.objectMode&&Object.getPrototypeOf(e)!==UQ.prototype&&(e=sBt(e)),o)n.endEmitted?Cv(t,new pBt):KG(t,n,e,!0);else if(n.ended)Cv(t,new ABt);else{if(n.destroyed)return!1;n.reading=!1,n.decoder&&!r?(e=n.decoder.write(e),n.objectMode||e.length!==0?KG(t,n,e,!1):JG(t,n)):KG(t,n,e,!1)}else o||(n.reading=!1,JG(t,n))}return!n.ended&&(n.length<n.highWaterMark||n.length===0)}function KG(t,e,r,o){e.flowing&&e.length===0&&!e.sync?(e.awaitDrain=0,t.emit("data",r)):(e.length+=e.objectMode?1:r.length,o?e.buffer.unshift(r):e.buffer.push(r),e.needReadable&&HQ(t)),JG(t,e)}function gBt(t,e){var r;return!oBt(e)&&typeof e!="string"&&e!==void 0&&!t.objectMode&&(r=new uBt("chunk",["string","Buffer","Uint8Array"],e)),r}mn.prototype.isPaused=function(){return this._readableState.flowing===!1};mn.prototype.setEncoding=function(t){ew||(ew=HG().StringDecoder);var e=new ew(t);this._readableState.decoder=e,this._readableState.encoding=this._readableState.decoder.encoding;for(var r=this._readableState.buffer.head,o="";r!==null;)o+=e.write(r.data),r=r.next;return this._readableState.buffer.clear(),o!==""&&this._readableState.buffer.push(o),this._readableState.length=o.length,this};var u2e=1073741824;function dBt(t){return t>=u2e?t=u2e:(t--,t|=t>>>1,t|=t>>>2,t|=t>>>4,t|=t>>>8,t|=t>>>16,t++),t}function A2e(t,e){return t<=0||e.length===0&&e.ended?0:e.objectMode?1:t!==t?e.flowing&&e.length?e.buffer.head.data.length:e.length:(t>e.highWaterMark&&(e.highWaterMark=dBt(t)),t<=e.length?t:e.ended?e.length:(e.needReadable=!0,0))}mn.prototype.read=function(t){en("read",t),t=parseInt(t,10);var e=this._readableState,r=t;if(t!==0&&(e.emittedReadable=!1),t===0&&e.needReadable&&((e.highWaterMark!==0?e.length>=e.highWaterMark:e.length>0)||e.ended))return en("read: emitReadable",e.length,e.ended),e.length===0&&e.ended?VG(this):HQ(this),null;if(t=A2e(t,e),t===0&&e.ended)return e.length===0&&VG(this),null;var o=e.needReadable;en("need readable",o),(e.length===0||e.length-t<e.highWaterMark)&&(o=!0,en("length less than watermark",o)),e.ended||e.reading?(o=!1,en("reading or ended",o)):o&&(en("do read"),e.reading=!0,e.sync=!0,e.length===0&&(e.needReadable=!0),this._read(e.highWaterMark),e.sync=!1,e.reading||(t=A2e(r,e)));var a;return t>0?a=m2e(t,e):a=null,a===null?(e.needReadable=e.length<=e.highWaterMark,t=0):(e.length-=t,e.awaitDrain=0),e.length===0&&(e.ended||(e.needReadable=!0),r!==t&&e.ended&&VG(this)),a!==null&&this.emit("data",a),a};function mBt(t,e){if(en("onEofChunk"),!e.ended){if(e.decoder){var r=e.decoder.end();r&&r.length&&(e.buffer.push(r),e.length+=e.objectMode?1:r.length)}e.ended=!0,e.sync?HQ(t):(e.needReadable=!1,e.emittedReadable||(e.emittedReadable=!0,g2e(t)))}}function HQ(t){var e=t._readableState;en("emitReadable",e.needReadable,e.emittedReadable),e.needReadable=!1,e.emittedReadable||(en("emitReadable",e.flowing),e.emittedReadable=!0,process.nextTick(g2e,t))}function g2e(t){var e=t._readableState;en("emitReadable_",e.destroyed,e.length,e.ended),!e.destroyed&&(e.length||e.ended)&&(t.emit("readable"),e.emittedReadable=!1),e.needReadable=!e.flowing&&!e.ended&&e.length<=e.highWaterMark,XG(t)}function JG(t,e){e.readingMore||(e.readingMore=!0,process.nextTick(yBt,t,e))}function yBt(t,e){for(;!e.reading&&!e.ended&&(e.length<e.highWaterMark||e.flowing&&e.length===0);){var r=e.length;if(en("maybeReadMore read 0"),t.read(0),r===e.length)break}e.readingMore=!1}mn.prototype._read=function(t){Cv(this,new fBt("_read()"))};mn.prototype.pipe=function(t,e){var r=this,o=this._readableState;switch(o.pipesCount){case 0:o.pipes=t;break;case 1:o.pipes=[o.pipes,t];break;default:o.pipes.push(t);break}o.pipesCount+=1,en("pipe count=%d opts=%j",o.pipesCount,e);var a=(!e||e.end!==!1)&&t!==process.stdout&&t!==process.stderr,n=a?A:R;o.endEmitted?process.nextTick(n):r.once("end",n),t.on("unpipe",u);function u(L,U){en("onunpipe"),L===r&&U&&U.hasUnpiped===!1&&(U.hasUnpiped=!0,E())}function A(){en("onend"),t.end()}var p=EBt(r);t.on("drain",p);var h=!1;function E(){en("cleanup"),t.removeListener("close",x),t.removeListener("finish",C),t.removeListener("drain",p),t.removeListener("error",v),t.removeListener("unpipe",u),r.removeListener("end",A),r.removeListener("end",R),r.removeListener("data",I),h=!0,o.awaitDrain&&(!t._writableState||t._writableState.needDrain)&&p()}r.on("data",I);function I(L){en("ondata");var U=t.write(L);en("dest.write",U),U===!1&&((o.pipesCount===1&&o.pipes===t||o.pipesCount>1&&y2e(o.pipes,t)!==-1)&&!h&&(en("false write response, pause",o.awaitDrain),o.awaitDrain++),r.pause())}function v(L){en("onerror",L),R(),t.removeListener("error",v),f2e(t,"error")===0&&Cv(t,L)}hBt(t,"error",v);function x(){t.removeListener("finish",C),R()}t.once("close",x);function C(){en("onfinish"),t.removeListener("close",x),R()}t.once("finish",C);function R(){en("unpipe"),r.unpipe(t)}return t.emit("pipe",r),o.flowing||(en("pipe resume"),r.resume()),t};function EBt(t){return function(){var r=t._readableState;en("pipeOnDrain",r.awaitDrain),r.awaitDrain&&r.awaitDrain--,r.awaitDrain===0&&f2e(t,"data")&&(r.flowing=!0,XG(t))}}mn.prototype.unpipe=function(t){var e=this._readableState,r={hasUnpiped:!1};if(e.pipesCount===0)return this;if(e.pipesCount===1)return t&&t!==e.pipes?this:(t||(t=e.pipes),e.pipes=null,e.pipesCount=0,e.flowing=!1,t&&t.emit("unpipe",this,r),this);if(!t){var o=e.pipes,a=e.pipesCount;e.pipes=null,e.pipesCount=0,e.flowing=!1;for(var n=0;n<a;n++)o[n].emit("unpipe",this,{hasUnpiped:!1});return this}var u=y2e(e.pipes,t);return u===-1?this:(e.pipes.splice(u,1),e.pipesCount-=1,e.pipesCount===1&&(e.pipes=e.pipes[0]),t.emit("unpipe",this,r),this)};mn.prototype.on=function(t,e){var r=wv.prototype.on.call(this,t,e),o=this._readableState;return t==="data"?(o.readableListening=this.listenerCount("readable")>0,o.flowing!==!1&&this.resume()):t==="readable"&&!o.endEmitted&&!o.readableListening&&(o.readableListening=o.needReadable=!0,o.flowing=!1,o.emittedReadable=!1,en("on readable",o.length,o.reading),o.length?HQ(this):o.reading||process.nextTick(CBt,this)),r};mn.prototype.addListener=mn.prototype.on;mn.prototype.removeListener=function(t,e){var r=wv.prototype.removeListener.call(this,t,e);return t==="readable"&&process.nextTick(d2e,this),r};mn.prototype.removeAllListeners=function(t){var e=wv.prototype.removeAllListeners.apply(this,arguments);return(t==="readable"||t===void 0)&&process.nextTick(d2e,this),e};function d2e(t){var e=t._readableState;e.readableListening=t.listenerCount("readable")>0,e.resumeScheduled&&!e.paused?e.flowing=!0:t.listenerCount("data")>0&&t.resume()}function CBt(t){en("readable nexttick read 0"),t.read(0)}mn.prototype.resume=function(){var t=this._readableState;return t.flowing||(en("resume"),t.flowing=!t.readableListening,wBt(this,t)),t.paused=!1,this};function wBt(t,e){e.resumeScheduled||(e.resumeScheduled=!0,process.nextTick(IBt,t,e))}function IBt(t,e){en("resume",e.reading),e.reading||t.read(0),e.resumeScheduled=!1,t.emit("resume"),XG(t),e.flowing&&!e.reading&&t.read(0)}mn.prototype.pause=function(){return en("call pause flowing=%j",this._readableState.flowing),this._readableState.flowing!==!1&&(en("pause"),this._readableState.flowing=!1,this.emit("pause")),this._readableState.paused=!0,this};function XG(t){var e=t._readableState;for(en("flow",e.flowing);e.flowing&&t.read()!==null;);}mn.prototype.wrap=function(t){var e=this,r=this._readableState,o=!1;t.on("end",function(){if(en("wrapped end"),r.decoder&&!r.ended){var u=r.decoder.end();u&&u.length&&e.push(u)}e.push(null)}),t.on("data",function(u){if(en("wrapped data"),r.decoder&&(u=r.decoder.write(u)),!(r.objectMode&&u==null)&&!(!r.objectMode&&(!u||!u.length))){var A=e.push(u);A||(o=!0,t.pause())}});for(var a in t)this[a]===void 0&&typeof t[a]=="function"&&(this[a]=function(A){return function(){return t[A].apply(t,arguments)}}(a));for(var n=0;n<WG.length;n++)t.on(WG[n],this.emit.bind(this,WG[n]));return this._read=function(u){en("wrapped _read",u),o&&(o=!1,t.resume())},this};typeof Symbol=="function"&&(mn.prototype[Symbol.asyncIterator]=function(){return qG===void 0&&(qG=s2e()),qG(this)});Object.defineProperty(mn.prototype,"readableHighWaterMark",{enumerable:!1,get:function(){return this._readableState.highWaterMark}});Object.defineProperty(mn.prototype,"readableBuffer",{enumerable:!1,get:function(){return this._readableState&&this._readableState.buffer}});Object.defineProperty(mn.prototype,"readableFlowing",{enumerable:!1,get:function(){return this._readableState.flowing},set:function(e){this._readableState&&(this._readableState.flowing=e)}});mn._fromList=m2e;Object.defineProperty(mn.prototype,"readableLength",{enumerable:!1,get:function(){return this._readableState.length}});function m2e(t,e){if(e.length===0)return null;var r;return e.objectMode?r=e.buffer.shift():!t||t>=e.length?(e.decoder?r=e.buffer.join(""):e.buffer.length===1?r=e.buffer.first():r=e.buffer.concat(e.length),e.buffer.clear()):r=e.buffer.consume(t,e.decoder),r}function VG(t){var e=t._readableState;en("endReadable",e.endEmitted),e.endEmitted||(e.ended=!0,process.nextTick(BBt,e,t))}function BBt(t,e){if(en("endReadableNT",t.endEmitted,t.length),!t.endEmitted&&t.length===0&&(t.endEmitted=!0,e.readable=!1,e.emit("end"),t.autoDestroy)){var r=e._writableState;(!r||r.autoDestroy&&r.finished)&&e.destroy()}}typeof Symbol=="function"&&(mn.from=function(t,e){return YG===void 0&&(YG=c2e()),YG(mn,t,e)});function y2e(t,e){for(var r=0,o=t.length;r<o;r++)if(t[r]===e)return r;return-1}});var ZG=_((k$t,w2e)=>{"use strict";w2e.exports=sp;var jQ=Q0().codes,vBt=jQ.ERR_METHOD_NOT_IMPLEMENTED,DBt=jQ.ERR_MULTIPLE_CALLBACK,SBt=jQ.ERR_TRANSFORM_ALREADY_TRANSFORMING,PBt=jQ.ERR_TRANSFORM_WITH_LENGTH_0,GQ=Cm();F0()(sp,GQ);function bBt(t,e){var r=this._transformState;r.transforming=!1;var o=r.writecb;if(o===null)return this.emit("error",new DBt);r.writechunk=null,r.writecb=null,e!=null&&this.push(e),o(t);var a=this._readableState;a.reading=!1,(a.needReadable||a.length<a.highWaterMark)&&this._read(a.highWaterMark)}function sp(t){if(!(this instanceof sp))return new sp(t);GQ.call(this,t),this._transformState={afterTransform:bBt.bind(this),needTransform:!1,transforming:!1,writecb:null,writechunk:null,writeencoding:null},this._readableState.needReadable=!0,this._readableState.sync=!1,t&&(typeof t.transform=="function"&&(this._transform=t.transform),typeof t.flush=="function"&&(this._flush=t.flush)),this.on("prefinish",xBt)}function xBt(){var t=this;typeof this._flush=="function"&&!this._readableState.destroyed?this._flush(function(e,r){C2e(t,e,r)}):C2e(this,null,null)}sp.prototype.push=function(t,e){return this._transformState.needTransform=!1,GQ.prototype.push.call(this,t,e)};sp.prototype._transform=function(t,e,r){r(new vBt("_transform()"))};sp.prototype._write=function(t,e,r){var o=this._transformState;if(o.writecb=r,o.writechunk=t,o.writeencoding=e,!o.transforming){var a=this._readableState;(o.needTransform||a.needReadable||a.length<a.highWaterMark)&&this._read(a.highWaterMark)}};sp.prototype._read=function(t){var e=this._transformState;e.writechunk!==null&&!e.transforming?(e.transforming=!0,this._transform(e.writechunk,e.writeencoding,e.afterTransform)):e.needTransform=!0};sp.prototype._destroy=function(t,e){GQ.prototype._destroy.call(this,t,function(r){e(r)})};function C2e(t,e,r){if(e)return t.emit("error",e);if(r!=null&&t.push(r),t._writableState.length)throw new PBt;if(t._transformState.transforming)throw new SBt;return t.push(null)}});var v2e=_((Q$t,B2e)=>{"use strict";B2e.exports=Iv;var I2e=ZG();F0()(Iv,I2e);function Iv(t){if(!(this instanceof Iv))return new Iv(t);I2e.call(this,t)}Iv.prototype._transform=function(t,e,r){r(null,t)}});var x2e=_((F$t,b2e)=>{"use strict";var $G;function kBt(t){var e=!1;return function(){e||(e=!0,t.apply(void 0,arguments))}}var P2e=Q0().codes,QBt=P2e.ERR_MISSING_ARGS,FBt=P2e.ERR_STREAM_DESTROYED;function D2e(t){if(t)throw t}function RBt(t){return t.setHeader&&typeof t.abort=="function"}function TBt(t,e,r,o){o=kBt(o);var a=!1;t.on("close",function(){a=!0}),$G===void 0&&($G=LQ()),$G(t,{readable:e,writable:r},function(u){if(u)return o(u);a=!0,o()});var n=!1;return function(u){if(!a&&!n){if(n=!0,RBt(t))return t.abort();if(typeof t.destroy=="function")return t.destroy();o(u||new FBt("pipe"))}}}function S2e(t){t()}function NBt(t,e){return t.pipe(e)}function LBt(t){return!t.length||typeof t[t.length-1]!="function"?D2e:t.pop()}function OBt(){for(var t=arguments.length,e=new Array(t),r=0;r<t;r++)e[r]=arguments[r];var o=LBt(e);if(Array.isArray(e[0])&&(e=e[0]),e.length<2)throw new QBt("streams");var a,n=e.map(function(u,A){var p=A<e.length-1,h=A>0;return TBt(u,p,h,function(E){a||(a=E),E&&n.forEach(S2e),!p&&(n.forEach(S2e),o(a))})});return e.reduce(NBt)}b2e.exports=OBt});var tw=_((lc,vv)=>{var Bv=ve("stream");process.env.READABLE_STREAM==="disable"&&Bv?(vv.exports=Bv.Readable,Object.assign(vv.exports,Bv),vv.exports.Stream=Bv):(lc=vv.exports=OG(),lc.Stream=Bv||lc,lc.Readable=lc,lc.Writable=TG(),lc.Duplex=Cm(),lc.Transform=ZG(),lc.PassThrough=v2e(),lc.finished=LQ(),lc.pipeline=x2e())});var F2e=_((R$t,Q2e)=>{"use strict";var{Buffer:lu}=ve("buffer"),k2e=Symbol.for("BufferList");function ni(t){if(!(this instanceof ni))return new ni(t);ni._init.call(this,t)}ni._init=function(e){Object.defineProperty(this,k2e,{value:!0}),this._bufs=[],this.length=0,e&&this.append(e)};ni.prototype._new=function(e){return new ni(e)};ni.prototype._offset=function(e){if(e===0)return[0,0];let r=0;for(let o=0;o<this._bufs.length;o++){let a=r+this._bufs[o].length;if(e<a||o===this._bufs.length-1)return[o,e-r];r=a}};ni.prototype._reverseOffset=function(t){let e=t[0],r=t[1];for(let o=0;o<e;o++)r+=this._bufs[o].length;return r};ni.prototype.get=function(e){if(e>this.length||e<0)return;let r=this._offset(e);return this._bufs[r[0]][r[1]]};ni.prototype.slice=function(e,r){return typeof e=="number"&&e<0&&(e+=this.length),typeof r=="number"&&r<0&&(r+=this.length),this.copy(null,0,e,r)};ni.prototype.copy=function(e,r,o,a){if((typeof o!="number"||o<0)&&(o=0),(typeof a!="number"||a>this.length)&&(a=this.length),o>=this.length||a<=0)return e||lu.alloc(0);let n=!!e,u=this._offset(o),A=a-o,p=A,h=n&&r||0,E=u[1];if(o===0&&a===this.length){if(!n)return this._bufs.length===1?this._bufs[0]:lu.concat(this._bufs,this.length);for(let I=0;I<this._bufs.length;I++)this._bufs[I].copy(e,h),h+=this._bufs[I].length;return e}if(p<=this._bufs[u[0]].length-E)return n?this._bufs[u[0]].copy(e,r,E,E+p):this._bufs[u[0]].slice(E,E+p);n||(e=lu.allocUnsafe(A));for(let I=u[0];I<this._bufs.length;I++){let v=this._bufs[I].length-E;if(p>v)this._bufs[I].copy(e,h,E),h+=v;else{this._bufs[I].copy(e,h,E,E+p),h+=v;break}p-=v,E&&(E=0)}return e.length>h?e.slice(0,h):e};ni.prototype.shallowSlice=function(e,r){if(e=e||0,r=typeof r!="number"?this.length:r,e<0&&(e+=this.length),r<0&&(r+=this.length),e===r)return this._new();let o=this._offset(e),a=this._offset(r),n=this._bufs.slice(o[0],a[0]+1);return a[1]===0?n.pop():n[n.length-1]=n[n.length-1].slice(0,a[1]),o[1]!==0&&(n[0]=n[0].slice(o[1])),this._new(n)};ni.prototype.toString=function(e,r,o){return this.slice(r,o).toString(e)};ni.prototype.consume=function(e){if(e=Math.trunc(e),Number.isNaN(e)||e<=0)return this;for(;this._bufs.length;)if(e>=this._bufs[0].length)e-=this._bufs[0].length,this.length-=this._bufs[0].length,this._bufs.shift();else{this._bufs[0]=this._bufs[0].slice(e),this.length-=e;break}return this};ni.prototype.duplicate=function(){let e=this._new();for(let r=0;r<this._bufs.length;r++)e.append(this._bufs[r]);return e};ni.prototype.append=function(e){if(e==null)return this;if(e.buffer)this._appendBuffer(lu.from(e.buffer,e.byteOffset,e.byteLength));else if(Array.isArray(e))for(let r=0;r<e.length;r++)this.append(e[r]);else if(this._isBufferList(e))for(let r=0;r<e._bufs.length;r++)this.append(e._bufs[r]);else typeof e=="number"&&(e=e.toString()),this._appendBuffer(lu.from(e));return this};ni.prototype._appendBuffer=function(e){this._bufs.push(e),this.length+=e.length};ni.prototype.indexOf=function(t,e,r){if(r===void 0&&typeof e=="string"&&(r=e,e=void 0),typeof t=="function"||Array.isArray(t))throw new TypeError('The "value" argument must be one of type string, Buffer, BufferList, or Uint8Array.');if(typeof t=="number"?t=lu.from([t]):typeof t=="string"?t=lu.from(t,r):this._isBufferList(t)?t=t.slice():Array.isArray(t.buffer)?t=lu.from(t.buffer,t.byteOffset,t.byteLength):lu.isBuffer(t)||(t=lu.from(t)),e=Number(e||0),isNaN(e)&&(e=0),e<0&&(e=this.length+e),e<0&&(e=0),t.length===0)return e>this.length?this.length:e;let o=this._offset(e),a=o[0],n=o[1];for(;a<this._bufs.length;a++){let u=this._bufs[a];for(;n<u.length;)if(u.length-n>=t.length){let p=u.indexOf(t,n);if(p!==-1)return this._reverseOffset([a,p]);n=u.length-t.length+1}else{let p=this._reverseOffset([a,n]);if(this._match(p,t))return p;n++}n=0}return-1};ni.prototype._match=function(t,e){if(this.length-t<e.length)return!1;for(let r=0;r<e.length;r++)if(this.get(t+r)!==e[r])return!1;return!0};(function(){let t={readDoubleBE:8,readDoubleLE:8,readFloatBE:4,readFloatLE:4,readInt32BE:4,readInt32LE:4,readUInt32BE:4,readUInt32LE:4,readInt16BE:2,readInt16LE:2,readUInt16BE:2,readUInt16LE:2,readInt8:1,readUInt8:1,readIntBE:null,readIntLE:null,readUIntBE:null,readUIntLE:null};for(let e in t)(function(r){t[r]===null?ni.prototype[r]=function(o,a){return this.slice(o,o+a)[r](0,a)}:ni.prototype[r]=function(o=0){return this.slice(o,o+t[r])[r](0)}})(e)})();ni.prototype._isBufferList=function(e){return e instanceof ni||ni.isBufferList(e)};ni.isBufferList=function(e){return e!=null&&e[k2e]};Q2e.exports=ni});var R2e=_((T$t,qQ)=>{"use strict";var eq=tw().Duplex,MBt=F0(),Dv=F2e();function Uo(t){if(!(this instanceof Uo))return new Uo(t);if(typeof t=="function"){this._callback=t;let e=function(o){this._callback&&(this._callback(o),this._callback=null)}.bind(this);this.on("pipe",function(o){o.on("error",e)}),this.on("unpipe",function(o){o.removeListener("error",e)}),t=null}Dv._init.call(this,t),eq.call(this)}MBt(Uo,eq);Object.assign(Uo.prototype,Dv.prototype);Uo.prototype._new=function(e){return new Uo(e)};Uo.prototype._write=function(e,r,o){this._appendBuffer(e),typeof o=="function"&&o()};Uo.prototype._read=function(e){if(!this.length)return this.push(null);e=Math.min(e,this.length),this.push(this.slice(0,e)),this.consume(e)};Uo.prototype.end=function(e){eq.prototype.end.call(this,e),this._callback&&(this._callback(null,this.slice()),this._callback=null)};Uo.prototype._destroy=function(e,r){this._bufs.length=0,this.length=0,r(e)};Uo.prototype._isBufferList=function(e){return e instanceof Uo||e instanceof Dv||Uo.isBufferList(e)};Uo.isBufferList=Dv.isBufferList;qQ.exports=Uo;qQ.exports.BufferListStream=Uo;qQ.exports.BufferList=Dv});var nq=_(nw=>{var UBt=Buffer.alloc,_Bt="0000000000000000000",HBt="7777777777777777777",T2e="0".charCodeAt(0),N2e=Buffer.from("ustar\0","binary"),jBt=Buffer.from("00","binary"),GBt=Buffer.from("ustar ","binary"),qBt=Buffer.from(" \0","binary"),YBt=parseInt("7777",8),Sv=257,rq=263,WBt=function(t,e,r){return typeof t!="number"?r:(t=~~t,t>=e?e:t>=0||(t+=e,t>=0)?t:0)},KBt=function(t){switch(t){case 0:return"file";case 1:return"link";case 2:return"symlink";case 3:return"character-device";case 4:return"block-device";case 5:return"directory";case 6:return"fifo";case 7:return"contiguous-file";case 72:return"pax-header";case 55:return"pax-global-header";case 27:return"gnu-long-link-path";case 28:case 30:return"gnu-long-path"}return null},VBt=function(t){switch(t){case"file":return 0;case"link":return 1;case"symlink":return 2;case"character-device":return 3;case"block-device":return 4;case"directory":return 5;case"fifo":return 6;case"contiguous-file":return 7;case"pax-header":return 72}return 0},L2e=function(t,e,r,o){for(;r<o;r++)if(t[r]===e)return r;return o},O2e=function(t){for(var e=256,r=0;r<148;r++)e+=t[r];for(var o=156;o<512;o++)e+=t[o];return e},O0=function(t,e){return t=t.toString(8),t.length>e?HBt.slice(0,e)+" ":_Bt.slice(0,e-t.length)+t+" "};function JBt(t){var e;if(t[0]===128)e=!0;else if(t[0]===255)e=!1;else return null;for(var r=[],o=t.length-1;o>0;o--){var a=t[o];e?r.push(a):r.push(255-a)}var n=0,u=r.length;for(o=0;o<u;o++)n+=r[o]*Math.pow(256,o);return e?n:-1*n}var M0=function(t,e,r){if(t=t.slice(e,e+r),e=0,t[e]&128)return JBt(t);for(;e<t.length&&t[e]===32;)e++;for(var o=WBt(L2e(t,32,e,t.length),t.length,t.length);e<o&&t[e]===0;)e++;return o===e?0:parseInt(t.slice(e,o).toString(),8)},rw=function(t,e,r,o){return t.slice(e,L2e(t,0,e,e+r)).toString(o)},tq=function(t){var e=Buffer.byteLength(t),r=Math.floor(Math.log(e)/Math.log(10))+1;return e+r>=Math.pow(10,r)&&r++,e+r+t};nw.decodeLongPath=function(t,e){return rw(t,0,t.length,e)};nw.encodePax=function(t){var e="";t.name&&(e+=tq(" path="+t.name+` +`)),t.linkname&&(e+=tq(" linkpath="+t.linkname+` +`));var r=t.pax;if(r)for(var o in r)e+=tq(" "+o+"="+r[o]+` +`);return Buffer.from(e)};nw.decodePax=function(t){for(var e={};t.length;){for(var r=0;r<t.length&&t[r]!==32;)r++;var o=parseInt(t.slice(0,r).toString(),10);if(!o)return e;var a=t.slice(r+1,o-1).toString(),n=a.indexOf("=");if(n===-1)return e;e[a.slice(0,n)]=a.slice(n+1),t=t.slice(o)}return e};nw.encode=function(t){var e=UBt(512),r=t.name,o="";if(t.typeflag===5&&r[r.length-1]!=="/"&&(r+="/"),Buffer.byteLength(r)!==r.length)return null;for(;Buffer.byteLength(r)>100;){var a=r.indexOf("/");if(a===-1)return null;o+=o?"/"+r.slice(0,a):r.slice(0,a),r=r.slice(a+1)}return Buffer.byteLength(r)>100||Buffer.byteLength(o)>155||t.linkname&&Buffer.byteLength(t.linkname)>100?null:(e.write(r),e.write(O0(t.mode&YBt,6),100),e.write(O0(t.uid,6),108),e.write(O0(t.gid,6),116),e.write(O0(t.size,11),124),e.write(O0(t.mtime.getTime()/1e3|0,11),136),e[156]=T2e+VBt(t.type),t.linkname&&e.write(t.linkname,157),N2e.copy(e,Sv),jBt.copy(e,rq),t.uname&&e.write(t.uname,265),t.gname&&e.write(t.gname,297),e.write(O0(t.devmajor||0,6),329),e.write(O0(t.devminor||0,6),337),o&&e.write(o,345),e.write(O0(O2e(e),6),148),e)};nw.decode=function(t,e,r){var o=t[156]===0?0:t[156]-T2e,a=rw(t,0,100,e),n=M0(t,100,8),u=M0(t,108,8),A=M0(t,116,8),p=M0(t,124,12),h=M0(t,136,12),E=KBt(o),I=t[157]===0?null:rw(t,157,100,e),v=rw(t,265,32),x=rw(t,297,32),C=M0(t,329,8),R=M0(t,337,8),L=O2e(t);if(L===8*32)return null;if(L!==M0(t,148,8))throw new Error("Invalid tar header. Maybe the tar is corrupted or it needs to be gunzipped?");if(N2e.compare(t,Sv,Sv+6)===0)t[345]&&(a=rw(t,345,155,e)+"/"+a);else if(!(GBt.compare(t,Sv,Sv+6)===0&&qBt.compare(t,rq,rq+2)===0)){if(!r)throw new Error("Invalid tar header: unknown format.")}return o===0&&a&&a[a.length-1]==="/"&&(o=5),{name:a,mode:n,uid:u,gid:A,size:p,mtime:new Date(1e3*h),type:E,linkname:I,uname:v,gname:x,devmajor:C,devminor:R}}});var q2e=_((L$t,G2e)=>{var U2e=ve("util"),zBt=R2e(),Pv=nq(),_2e=tw().Writable,H2e=tw().PassThrough,j2e=function(){},M2e=function(t){return t&=511,t&&512-t},XBt=function(t,e){var r=new YQ(t,e);return r.end(),r},ZBt=function(t,e){return e.path&&(t.name=e.path),e.linkpath&&(t.linkname=e.linkpath),e.size&&(t.size=parseInt(e.size,10)),t.pax=e,t},YQ=function(t,e){this._parent=t,this.offset=e,H2e.call(this,{autoDestroy:!1})};U2e.inherits(YQ,H2e);YQ.prototype.destroy=function(t){this._parent.destroy(t)};var op=function(t){if(!(this instanceof op))return new op(t);_2e.call(this,t),t=t||{},this._offset=0,this._buffer=zBt(),this._missing=0,this._partial=!1,this._onparse=j2e,this._header=null,this._stream=null,this._overflow=null,this._cb=null,this._locked=!1,this._destroyed=!1,this._pax=null,this._paxGlobal=null,this._gnuLongPath=null,this._gnuLongLinkPath=null;var e=this,r=e._buffer,o=function(){e._continue()},a=function(v){if(e._locked=!1,v)return e.destroy(v);e._stream||o()},n=function(){e._stream=null;var v=M2e(e._header.size);v?e._parse(v,u):e._parse(512,I),e._locked||o()},u=function(){e._buffer.consume(M2e(e._header.size)),e._parse(512,I),o()},A=function(){var v=e._header.size;e._paxGlobal=Pv.decodePax(r.slice(0,v)),r.consume(v),n()},p=function(){var v=e._header.size;e._pax=Pv.decodePax(r.slice(0,v)),e._paxGlobal&&(e._pax=Object.assign({},e._paxGlobal,e._pax)),r.consume(v),n()},h=function(){var v=e._header.size;this._gnuLongPath=Pv.decodeLongPath(r.slice(0,v),t.filenameEncoding),r.consume(v),n()},E=function(){var v=e._header.size;this._gnuLongLinkPath=Pv.decodeLongPath(r.slice(0,v),t.filenameEncoding),r.consume(v),n()},I=function(){var v=e._offset,x;try{x=e._header=Pv.decode(r.slice(0,512),t.filenameEncoding,t.allowUnknownFormat)}catch(C){e.emit("error",C)}if(r.consume(512),!x){e._parse(512,I),o();return}if(x.type==="gnu-long-path"){e._parse(x.size,h),o();return}if(x.type==="gnu-long-link-path"){e._parse(x.size,E),o();return}if(x.type==="pax-global-header"){e._parse(x.size,A),o();return}if(x.type==="pax-header"){e._parse(x.size,p),o();return}if(e._gnuLongPath&&(x.name=e._gnuLongPath,e._gnuLongPath=null),e._gnuLongLinkPath&&(x.linkname=e._gnuLongLinkPath,e._gnuLongLinkPath=null),e._pax&&(e._header=x=ZBt(x,e._pax),e._pax=null),e._locked=!0,!x.size||x.type==="directory"){e._parse(512,I),e.emit("entry",x,XBt(e,v),a);return}e._stream=new YQ(e,v),e.emit("entry",x,e._stream,a),e._parse(x.size,n),o()};this._onheader=I,this._parse(512,I)};U2e.inherits(op,_2e);op.prototype.destroy=function(t){this._destroyed||(this._destroyed=!0,t&&this.emit("error",t),this.emit("close"),this._stream&&this._stream.emit("close"))};op.prototype._parse=function(t,e){this._destroyed||(this._offset+=t,this._missing=t,e===this._onheader&&(this._partial=!1),this._onparse=e)};op.prototype._continue=function(){if(!this._destroyed){var t=this._cb;this._cb=j2e,this._overflow?this._write(this._overflow,void 0,t):t()}};op.prototype._write=function(t,e,r){if(!this._destroyed){var o=this._stream,a=this._buffer,n=this._missing;if(t.length&&(this._partial=!0),t.length<n)return this._missing-=t.length,this._overflow=null,o?o.write(t,r):(a.append(t),r());this._cb=r,this._missing=0;var u=null;t.length>n&&(u=t.slice(n),t=t.slice(0,n)),o?o.end(t):a.append(t),this._overflow=u,this._onparse()}};op.prototype._final=function(t){if(this._partial)return this.destroy(new Error("Unexpected end of data"));t()};G2e.exports=op});var W2e=_((O$t,Y2e)=>{Y2e.exports=ve("fs").constants||ve("constants")});var X2e=_((M$t,z2e)=>{var iw=W2e(),K2e=LM(),KQ=F0(),$Bt=Buffer.alloc,V2e=tw().Readable,sw=tw().Writable,evt=ve("string_decoder").StringDecoder,WQ=nq(),tvt=parseInt("755",8),rvt=parseInt("644",8),J2e=$Bt(1024),sq=function(){},iq=function(t,e){e&=511,e&&t.push(J2e.slice(0,512-e))};function nvt(t){switch(t&iw.S_IFMT){case iw.S_IFBLK:return"block-device";case iw.S_IFCHR:return"character-device";case iw.S_IFDIR:return"directory";case iw.S_IFIFO:return"fifo";case iw.S_IFLNK:return"symlink"}return"file"}var VQ=function(t){sw.call(this),this.written=0,this._to=t,this._destroyed=!1};KQ(VQ,sw);VQ.prototype._write=function(t,e,r){if(this.written+=t.length,this._to.push(t))return r();this._to._drain=r};VQ.prototype.destroy=function(){this._destroyed||(this._destroyed=!0,this.emit("close"))};var JQ=function(){sw.call(this),this.linkname="",this._decoder=new evt("utf-8"),this._destroyed=!1};KQ(JQ,sw);JQ.prototype._write=function(t,e,r){this.linkname+=this._decoder.write(t),r()};JQ.prototype.destroy=function(){this._destroyed||(this._destroyed=!0,this.emit("close"))};var bv=function(){sw.call(this),this._destroyed=!1};KQ(bv,sw);bv.prototype._write=function(t,e,r){r(new Error("No body allowed for this entry"))};bv.prototype.destroy=function(){this._destroyed||(this._destroyed=!0,this.emit("close"))};var CA=function(t){if(!(this instanceof CA))return new CA(t);V2e.call(this,t),this._drain=sq,this._finalized=!1,this._finalizing=!1,this._destroyed=!1,this._stream=null};KQ(CA,V2e);CA.prototype.entry=function(t,e,r){if(this._stream)throw new Error("already piping an entry");if(!(this._finalized||this._destroyed)){typeof e=="function"&&(r=e,e=null),r||(r=sq);var o=this;if((!t.size||t.type==="symlink")&&(t.size=0),t.type||(t.type=nvt(t.mode)),t.mode||(t.mode=t.type==="directory"?tvt:rvt),t.uid||(t.uid=0),t.gid||(t.gid=0),t.mtime||(t.mtime=new Date),typeof e=="string"&&(e=Buffer.from(e)),Buffer.isBuffer(e)){t.size=e.length,this._encode(t);var a=this.push(e);return iq(o,t.size),a?process.nextTick(r):this._drain=r,new bv}if(t.type==="symlink"&&!t.linkname){var n=new JQ;return K2e(n,function(A){if(A)return o.destroy(),r(A);t.linkname=n.linkname,o._encode(t),r()}),n}if(this._encode(t),t.type!=="file"&&t.type!=="contiguous-file")return process.nextTick(r),new bv;var u=new VQ(this);return this._stream=u,K2e(u,function(A){if(o._stream=null,A)return o.destroy(),r(A);if(u.written!==t.size)return o.destroy(),r(new Error("size mismatch"));iq(o,t.size),o._finalizing&&o.finalize(),r()}),u}};CA.prototype.finalize=function(){if(this._stream){this._finalizing=!0;return}this._finalized||(this._finalized=!0,this.push(J2e),this.push(null))};CA.prototype.destroy=function(t){this._destroyed||(this._destroyed=!0,t&&this.emit("error",t),this.emit("close"),this._stream&&this._stream.destroy&&this._stream.destroy())};CA.prototype._encode=function(t){if(!t.pax){var e=WQ.encode(t);if(e){this.push(e);return}}this._encodePax(t)};CA.prototype._encodePax=function(t){var e=WQ.encodePax({name:t.name,linkname:t.linkname,pax:t.pax}),r={name:"PaxHeader",mode:t.mode,uid:t.uid,gid:t.gid,size:e.length,mtime:t.mtime,type:"pax-header",linkname:t.linkname&&"PaxHeader",uname:t.uname,gname:t.gname,devmajor:t.devmajor,devminor:t.devminor};this.push(WQ.encode(r)),this.push(e),iq(this,e.length),r.size=t.size,r.type=t.type,this.push(WQ.encode(r))};CA.prototype._read=function(t){var e=this._drain;this._drain=sq,e()};z2e.exports=CA});var Z2e=_(oq=>{oq.extract=q2e();oq.pack=X2e()});var uBe=_((ier,cBe)=>{"use strict";var vm=class{constructor(e,r,o){this.__specs=e||{},Object.keys(this.__specs).forEach(a=>{if(typeof this.__specs[a]=="string"){let n=this.__specs[a],u=this.__specs[n];if(u){let A=u.aliases||[];A.push(a,n),u.aliases=[...new Set(A)],this.__specs[a]=u}else throw new Error(`Alias refers to invalid key: ${n} -> ${a}`)}}),this.__opts=r||{},this.__providers=aBe(o.filter(a=>a!=null&&typeof a=="object")),this.__isFiggyPudding=!0}get(e){return fq(this,e,!0)}get[Symbol.toStringTag](){return"FiggyPudding"}forEach(e,r=this){for(let[o,a]of this.entries())e.call(r,a,o,this)}toJSON(){let e={};return this.forEach((r,o)=>{e[o]=r}),e}*entries(e){for(let o of Object.keys(this.__specs))yield[o,this.get(o)];let r=e||this.__opts.other;if(r){let o=new Set;for(let a of this.__providers){let n=a.entries?a.entries(r):yvt(a);for(let[u,A]of n)r(u)&&!o.has(u)&&(o.add(u),yield[u,A])}}}*[Symbol.iterator](){for(let[e,r]of this.entries())yield[e,r]}*keys(){for(let[e]of this.entries())yield e}*values(){for(let[,e]of this.entries())yield e}concat(...e){return new Proxy(new vm(this.__specs,this.__opts,aBe(this.__providers).concat(e)),lBe)}};try{let t=ve("util");vm.prototype[t.inspect.custom]=function(e,r){return this[Symbol.toStringTag]+" "+t.inspect(this.toJSON(),r)}}catch{}function dvt(t){throw Object.assign(new Error(`invalid config key requested: ${t}`),{code:"EBADKEY"})}function fq(t,e,r){let o=t.__specs[e];if(r&&!o&&(!t.__opts.other||!t.__opts.other(e)))dvt(e);else{o||(o={});let a;for(let n of t.__providers){if(a=oBe(e,n),a===void 0&&o.aliases&&o.aliases.length){for(let u of o.aliases)if(u!==e&&(a=oBe(u,n),a!==void 0))break}if(a!==void 0)break}return a===void 0&&o.default!==void 0?typeof o.default=="function"?o.default(t):o.default:a}}function oBe(t,e){let r;return e.__isFiggyPudding?r=fq(e,t,!1):typeof e.get=="function"?r=e.get(t):r=e[t],r}var lBe={has(t,e){return e in t.__specs&&fq(t,e,!1)!==void 0},ownKeys(t){return Object.keys(t.__specs)},get(t,e){return typeof e=="symbol"||e.slice(0,2)==="__"||e in vm.prototype?t[e]:t.get(e)},set(t,e,r){if(typeof e=="symbol"||e.slice(0,2)==="__")return t[e]=r,!0;throw new Error("figgyPudding options cannot be modified. Use .concat() instead.")},deleteProperty(){throw new Error("figgyPudding options cannot be deleted. Use .concat() and shadow them instead.")}};cBe.exports=mvt;function mvt(t,e){function r(...o){return new Proxy(new vm(t,e,o),lBe)}return r}function aBe(t){let e=[];return t.forEach(r=>e.unshift(r)),e}function yvt(t){return Object.keys(t).map(e=>[e,t[e]])}});var pBe=_((ser,BA)=>{"use strict";var kv=ve("crypto"),Evt=uBe(),Cvt=ve("stream").Transform,ABe=["sha256","sha384","sha512"],wvt=/^[a-z0-9+/]+(?:=?=?)$/i,Ivt=/^([^-]+)-([^?]+)([?\S*]*)$/,Bvt=/^([^-]+)-([A-Za-z0-9+/=]{44,88})(\?[\x21-\x7E]*)*$/,vvt=/^[\x21-\x7E]+$/,ia=Evt({algorithms:{default:["sha512"]},error:{default:!1},integrity:{},options:{default:[]},pickAlgorithm:{default:()=>Fvt},Promise:{default:()=>Promise},sep:{default:" "},single:{default:!1},size:{},strict:{default:!1}}),_0=class{get isHash(){return!0}constructor(e,r){r=ia(r);let o=!!r.strict;this.source=e.trim();let a=this.source.match(o?Bvt:Ivt);if(!a||o&&!ABe.some(u=>u===a[1]))return;this.algorithm=a[1],this.digest=a[2];let n=a[3];this.options=n?n.slice(1).split("?"):[]}hexDigest(){return this.digest&&Buffer.from(this.digest,"base64").toString("hex")}toJSON(){return this.toString()}toString(e){if(e=ia(e),e.strict&&!(ABe.some(o=>o===this.algorithm)&&this.digest.match(wvt)&&(this.options||[]).every(o=>o.match(vvt))))return"";let r=this.options&&this.options.length?`?${this.options.join("?")}`:"";return`${this.algorithm}-${this.digest}${r}`}},Dm=class{get isIntegrity(){return!0}toJSON(){return this.toString()}toString(e){e=ia(e);let r=e.sep||" ";return e.strict&&(r=r.replace(/\S+/g," ")),Object.keys(this).map(o=>this[o].map(a=>_0.prototype.toString.call(a,e)).filter(a=>a.length).join(r)).filter(o=>o.length).join(r)}concat(e,r){r=ia(r);let o=typeof e=="string"?e:xv(e,r);return IA(`${this.toString(r)} ${o}`,r)}hexDigest(){return IA(this,{single:!0}).hexDigest()}match(e,r){r=ia(r);let o=IA(e,r),a=o.pickAlgorithm(r);return this[a]&&o[a]&&this[a].find(n=>o[a].find(u=>n.digest===u.digest))||!1}pickAlgorithm(e){e=ia(e);let r=e.pickAlgorithm,o=Object.keys(this);if(!o.length)throw new Error(`No algorithms available for ${JSON.stringify(this.toString())}`);return o.reduce((a,n)=>r(a,n)||a)}};BA.exports.parse=IA;function IA(t,e){if(e=ia(e),typeof t=="string")return pq(t,e);if(t.algorithm&&t.digest){let r=new Dm;return r[t.algorithm]=[t],pq(xv(r,e),e)}else return pq(xv(t,e),e)}function pq(t,e){return e.single?new _0(t,e):t.trim().split(/\s+/).reduce((r,o)=>{let a=new _0(o,e);if(a.algorithm&&a.digest){let n=a.algorithm;r[n]||(r[n]=[]),r[n].push(a)}return r},new Dm)}BA.exports.stringify=xv;function xv(t,e){return e=ia(e),t.algorithm&&t.digest?_0.prototype.toString.call(t,e):typeof t=="string"?xv(IA(t,e),e):Dm.prototype.toString.call(t,e)}BA.exports.fromHex=Dvt;function Dvt(t,e,r){r=ia(r);let o=r.options&&r.options.length?`?${r.options.join("?")}`:"";return IA(`${e}-${Buffer.from(t,"hex").toString("base64")}${o}`,r)}BA.exports.fromData=Svt;function Svt(t,e){e=ia(e);let r=e.algorithms,o=e.options&&e.options.length?`?${e.options.join("?")}`:"";return r.reduce((a,n)=>{let u=kv.createHash(n).update(t).digest("base64"),A=new _0(`${n}-${u}${o}`,e);if(A.algorithm&&A.digest){let p=A.algorithm;a[p]||(a[p]=[]),a[p].push(A)}return a},new Dm)}BA.exports.fromStream=Pvt;function Pvt(t,e){e=ia(e);let r=e.Promise||Promise,o=hq(e);return new r((a,n)=>{t.pipe(o),t.on("error",n),o.on("error",n);let u;o.on("integrity",A=>{u=A}),o.on("end",()=>a(u)),o.on("data",()=>{})})}BA.exports.checkData=bvt;function bvt(t,e,r){if(r=ia(r),e=IA(e,r),!Object.keys(e).length){if(r.error)throw Object.assign(new Error("No valid integrity hashes to check against"),{code:"EINTEGRITY"});return!1}let o=e.pickAlgorithm(r),a=kv.createHash(o).update(t).digest("base64"),n=IA({algorithm:o,digest:a}),u=n.match(e,r);if(u||!r.error)return u;if(typeof r.size=="number"&&t.length!==r.size){let A=new Error(`data size mismatch when checking ${e}. + Wanted: ${r.size} + Found: ${t.length}`);throw A.code="EBADSIZE",A.found=t.length,A.expected=r.size,A.sri=e,A}else{let A=new Error(`Integrity checksum failed when using ${o}: Wanted ${e}, but got ${n}. (${t.length} bytes)`);throw A.code="EINTEGRITY",A.found=n,A.expected=e,A.algorithm=o,A.sri=e,A}}BA.exports.checkStream=xvt;function xvt(t,e,r){r=ia(r);let o=r.Promise||Promise,a=hq(r.concat({integrity:e}));return new o((n,u)=>{t.pipe(a),t.on("error",u),a.on("error",u);let A;a.on("verified",p=>{A=p}),a.on("end",()=>n(A)),a.on("data",()=>{})})}BA.exports.integrityStream=hq;function hq(t){t=ia(t);let e=t.integrity&&IA(t.integrity,t),r=e&&Object.keys(e).length,o=r&&e.pickAlgorithm(t),a=r&&e[o],n=Array.from(new Set(t.algorithms.concat(o?[o]:[]))),u=n.map(kv.createHash),A=0,p=new Cvt({transform(h,E,I){A+=h.length,u.forEach(v=>v.update(h,E)),I(null,h,E)}}).on("end",()=>{let h=t.options&&t.options.length?`?${t.options.join("?")}`:"",E=IA(u.map((v,x)=>`${n[x]}-${v.digest("base64")}${h}`).join(" "),t),I=r&&E.match(e,t);if(typeof t.size=="number"&&A!==t.size){let v=new Error(`stream size mismatch when checking ${e}. + Wanted: ${t.size} + Found: ${A}`);v.code="EBADSIZE",v.found=A,v.expected=t.size,v.sri=e,p.emit("error",v)}else if(t.integrity&&!I){let v=new Error(`${e} integrity checksum failed when using ${o}: wanted ${a} but got ${E}. (${A} bytes)`);v.code="EINTEGRITY",v.found=E,v.expected=a,v.algorithm=o,v.sri=e,p.emit("error",v)}else p.emit("size",A),p.emit("integrity",E),I&&p.emit("verified",I)});return p}BA.exports.create=kvt;function kvt(t){t=ia(t);let e=t.algorithms,r=t.options.length?`?${t.options.join("?")}`:"",o=e.map(kv.createHash);return{update:function(a,n){return o.forEach(u=>u.update(a,n)),this},digest:function(a){return e.reduce((u,A)=>{let p=o.shift().digest("base64"),h=new _0(`${A}-${p}${r}`,t);if(h.algorithm&&h.digest){let E=h.algorithm;u[E]||(u[E]=[]),u[E].push(h)}return u},new Dm)}}}var Qvt=new Set(kv.getHashes()),fBe=["md5","whirlpool","sha1","sha224","sha256","sha384","sha512","sha3","sha3-256","sha3-384","sha3-512","sha3_256","sha3_384","sha3_512"].filter(t=>Qvt.has(t));function Fvt(t,e){return fBe.indexOf(t.toLowerCase())>=fBe.indexOf(e.toLowerCase())?t:e}});var jBe=_((lir,HBe)=>{var FDt=lL();function RDt(t){return FDt(t)?void 0:t}HBe.exports=RDt});var qBe=_((cir,GBe)=>{var TDt=Hb(),NDt=x8(),LDt=R8(),ODt=Gd(),MDt=dd(),UDt=jBe(),_Dt=v_(),HDt=b8(),jDt=1,GDt=2,qDt=4,YDt=_Dt(function(t,e){var r={};if(t==null)return r;var o=!1;e=TDt(e,function(n){return n=ODt(n,t),o||(o=n.length>1),n}),MDt(t,HDt(t),r),o&&(r=NDt(r,jDt|GDt|qDt,UDt));for(var a=e.length;a--;)LDt(r,e[a]);return r});GBe.exports=YDt});St();Ye();St();var JBe=ve("child_process"),zBe=$e(td());jt();var AC=new Map([]);var a2={};Vt(a2,{BaseCommand:()=>ut,WorkspaceRequiredError:()=>rr,getCli:()=>$pe,getDynamicLibs:()=>Zpe,getPluginConfiguration:()=>pC,openWorkspace:()=>fC,pluginCommands:()=>AC,runExit:()=>nk});jt();var ut=class extends nt{constructor(){super(...arguments);this.cwd=ge.String("--cwd",{hidden:!0})}validateAndExecute(){if(typeof this.cwd<"u")throw new it("The --cwd option is ambiguous when used anywhere else than the very first parameter provided in the command line, before even the command path");return super.validateAndExecute()}};Ye();St();jt();var rr=class extends it{constructor(e,r){let o=V.relative(e,r),a=V.join(e,Ot.fileName);super(`This command can only be run from within a workspace of your project (${o} isn't a workspace of ${a}).`)}};Ye();St();nA();Nl();k1();jt();var RAt=$e(zn());Za();var Zpe=()=>new Map([["@yarnpkg/cli",a2],["@yarnpkg/core",o2],["@yarnpkg/fslib",Vw],["@yarnpkg/libzip",x1],["@yarnpkg/parsers",rI],["@yarnpkg/shell",T1],["clipanion",hI],["semver",RAt],["typanion",Vo]]);Ye();async function fC(t,e){let{project:r,workspace:o}=await Pt.find(t,e);if(!o)throw new rr(r.cwd,e);return o}Ye();St();nA();Nl();k1();jt();var eSt=$e(zn());Za();var $8={};Vt($8,{AddCommand:()=>kh,BinCommand:()=>Qh,CacheCleanCommand:()=>Fh,ClipanionCommand:()=>Kd,ConfigCommand:()=>Lh,ConfigGetCommand:()=>Rh,ConfigSetCommand:()=>Th,ConfigUnsetCommand:()=>Nh,DedupeCommand:()=>Oh,EntryCommand:()=>mC,ExecCommand:()=>Mh,ExplainCommand:()=>Hh,ExplainPeerRequirementsCommand:()=>Uh,HelpCommand:()=>Vd,InfoCommand:()=>jh,LinkCommand:()=>qh,NodeCommand:()=>Yh,PluginCheckCommand:()=>Wh,PluginImportCommand:()=>Jh,PluginImportSourcesCommand:()=>zh,PluginListCommand:()=>Kh,PluginRemoveCommand:()=>Xh,PluginRuntimeCommand:()=>Zh,RebuildCommand:()=>$h,RemoveCommand:()=>e0,RunCommand:()=>t0,RunIndexCommand:()=>Xd,SetResolutionCommand:()=>r0,SetVersionCommand:()=>_h,SetVersionSourcesCommand:()=>Vh,UnlinkCommand:()=>n0,UpCommand:()=>Jf,VersionCommand:()=>Jd,WhyCommand:()=>i0,WorkspaceCommand:()=>a0,WorkspacesListCommand:()=>o0,YarnCommand:()=>Gh,dedupeUtils:()=>pk,default:()=>Sgt,suggestUtils:()=>zc});var kde=$e(td());Ye();Ye();Ye();jt();var _0e=$e(f2());Za();var zc={};Vt(zc,{Modifier:()=>B8,Strategy:()=>uk,Target:()=>p2,WorkspaceModifier:()=>N0e,applyModifier:()=>$ft,extractDescriptorFromPath:()=>v8,extractRangeModifier:()=>L0e,fetchDescriptorFrom:()=>D8,findProjectDescriptors:()=>U0e,getModifier:()=>h2,getSuggestedDescriptors:()=>g2,makeWorkspaceDescriptor:()=>M0e,toWorkspaceModifier:()=>O0e});Ye();Ye();St();var I8=$e(zn()),Xft="workspace:",p2=(o=>(o.REGULAR="dependencies",o.DEVELOPMENT="devDependencies",o.PEER="peerDependencies",o))(p2||{}),B8=(o=>(o.CARET="^",o.TILDE="~",o.EXACT="",o))(B8||{}),N0e=(o=>(o.CARET="^",o.TILDE="~",o.EXACT="*",o))(N0e||{}),uk=(n=>(n.KEEP="keep",n.REUSE="reuse",n.PROJECT="project",n.LATEST="latest",n.CACHE="cache",n))(uk||{});function h2(t,e){return t.exact?"":t.caret?"^":t.tilde?"~":e.configuration.get("defaultSemverRangePrefix")}var Zft=/^([\^~]?)[0-9]+(?:\.[0-9]+){0,2}(?:-\S+)?$/;function L0e(t,{project:e}){let r=t.match(Zft);return r?r[1]:e.configuration.get("defaultSemverRangePrefix")}function $ft(t,e){let{protocol:r,source:o,params:a,selector:n}=W.parseRange(t.range);return I8.default.valid(n)&&(n=`${e}${t.range}`),W.makeDescriptor(t,W.makeRange({protocol:r,source:o,params:a,selector:n}))}function O0e(t){switch(t){case"^":return"^";case"~":return"~";case"":return"*";default:throw new Error(`Assertion failed: Unknown modifier: "${t}"`)}}function M0e(t,e){return W.makeDescriptor(t.anchoredDescriptor,`${Xft}${O0e(e)}`)}async function U0e(t,{project:e,target:r}){let o=new Map,a=n=>{let u=o.get(n.descriptorHash);return u||o.set(n.descriptorHash,u={descriptor:n,locators:[]}),u};for(let n of e.workspaces)if(r==="peerDependencies"){let u=n.manifest.peerDependencies.get(t.identHash);u!==void 0&&a(u).locators.push(n.anchoredLocator)}else{let u=n.manifest.dependencies.get(t.identHash),A=n.manifest.devDependencies.get(t.identHash);r==="devDependencies"?A!==void 0?a(A).locators.push(n.anchoredLocator):u!==void 0&&a(u).locators.push(n.anchoredLocator):u!==void 0?a(u).locators.push(n.anchoredLocator):A!==void 0&&a(A).locators.push(n.anchoredLocator)}return o}async function v8(t,{cwd:e,workspace:r}){return await ept(async o=>{V.isAbsolute(t)||(t=V.relative(r.cwd,V.resolve(e,t)),t.match(/^\.{0,2}\//)||(t=`./${t}`));let{project:a}=r,n=await D8(W.makeIdent(null,"archive"),t,{project:r.project,cache:o,workspace:r});if(!n)throw new Error("Assertion failed: The descriptor should have been found");let u=new Qi,A=a.configuration.makeResolver(),p=a.configuration.makeFetcher(),h={checksums:a.storedChecksums,project:a,cache:o,fetcher:p,report:u,resolver:A},E=A.bindDescriptor(n,r.anchoredLocator,h),I=W.convertDescriptorToLocator(E),v=await p.fetch(I,h),x=await Ot.find(v.prefixPath,{baseFs:v.packageFs});if(!x.name)throw new Error("Target path doesn't have a name");return W.makeDescriptor(x.name,t)})}async function g2(t,{project:e,workspace:r,cache:o,target:a,fixed:n,modifier:u,strategies:A,maxResults:p=1/0}){if(!(p>=0))throw new Error(`Invalid maxResults (${p})`);let[h,E]=t.range!=="unknown"?n||kr.validRange(t.range)||!t.range.match(/^[a-z0-9._-]+$/i)?[t.range,"latest"]:["unknown",t.range]:["unknown","latest"];if(h!=="unknown")return{suggestions:[{descriptor:t,name:`Use ${W.prettyDescriptor(e.configuration,t)}`,reason:"(unambiguous explicit request)"}],rejections:[]};let I=typeof r<"u"&&r!==null&&r.manifest[a].get(t.identHash)||null,v=[],x=[],C=async R=>{try{await R()}catch(L){x.push(L)}};for(let R of A){if(v.length>=p)break;switch(R){case"keep":await C(async()=>{I&&v.push({descriptor:I,name:`Keep ${W.prettyDescriptor(e.configuration,I)}`,reason:"(no changes)"})});break;case"reuse":await C(async()=>{for(let{descriptor:L,locators:U}of(await U0e(t,{project:e,target:a})).values()){if(U.length===1&&U[0].locatorHash===r.anchoredLocator.locatorHash&&A.includes("keep"))continue;let J=`(originally used by ${W.prettyLocator(e.configuration,U[0])}`;J+=U.length>1?` and ${U.length-1} other${U.length>2?"s":""})`:")",v.push({descriptor:L,name:`Reuse ${W.prettyDescriptor(e.configuration,L)}`,reason:J})}});break;case"cache":await C(async()=>{for(let L of e.storedDescriptors.values())L.identHash===t.identHash&&v.push({descriptor:L,name:`Reuse ${W.prettyDescriptor(e.configuration,L)}`,reason:"(already used somewhere in the lockfile)"})});break;case"project":await C(async()=>{if(r.manifest.name!==null&&t.identHash===r.manifest.name.identHash)return;let L=e.tryWorkspaceByIdent(t);if(L===null)return;let U=M0e(L,u);v.push({descriptor:U,name:`Attach ${W.prettyDescriptor(e.configuration,U)}`,reason:`(local workspace at ${de.pretty(e.configuration,L.relativeCwd,de.Type.PATH)})`})});break;case"latest":{let L=e.configuration.get("enableNetwork"),U=e.configuration.get("enableOfflineMode");await C(async()=>{if(a==="peerDependencies")v.push({descriptor:W.makeDescriptor(t,"*"),name:"Use *",reason:"(catch-all peer dependency pattern)"});else if(!L&&!U)v.push({descriptor:null,name:"Resolve from latest",reason:de.pretty(e.configuration,"(unavailable because enableNetwork is toggled off)","grey")});else{let J=await D8(t,E,{project:e,cache:o,workspace:r,modifier:u});J&&v.push({descriptor:J,name:`Use ${W.prettyDescriptor(e.configuration,J)}`,reason:`(resolved from ${U?"the cache":"latest"})`})}})}break}}return{suggestions:v.slice(0,p),rejections:x.slice(0,p)}}async function D8(t,e,{project:r,cache:o,workspace:a,preserveModifier:n=!0,modifier:u}){let A=r.configuration.normalizeDependency(W.makeDescriptor(t,e)),p=new Qi,h=r.configuration.makeFetcher(),E=r.configuration.makeResolver(),I={project:r,fetcher:h,cache:o,checksums:r.storedChecksums,report:p,cacheOptions:{skipIntegrityCheck:!0}},v={...I,resolver:E,fetchOptions:I},x=E.bindDescriptor(A,a.anchoredLocator,v),C=await E.getCandidates(x,{},v);if(C.length===0)return null;let R=C[0],{protocol:L,source:U,params:J,selector:te}=W.parseRange(W.convertToManifestRange(R.reference));if(L===r.configuration.get("defaultProtocol")&&(L=null),I8.default.valid(te)){let ae=te;if(typeof u<"u")te=u+te;else if(n!==!1){let me=typeof n=="string"?n:A.range;te=L0e(me,{project:r})+te}let fe=W.makeDescriptor(R,W.makeRange({protocol:L,source:U,params:J,selector:te}));(await E.getCandidates(r.configuration.normalizeDependency(fe),{},v)).length!==1&&(te=ae)}return W.makeDescriptor(R,W.makeRange({protocol:L,source:U,params:J,selector:te}))}async function ept(t){return await oe.mktempPromise(async e=>{let r=Ke.create(e);return r.useWithSource(e,{enableMirror:!1,compressionLevel:0},e,{overwrite:!0}),await t(new Lr(e,{configuration:r,check:!1,immutable:!1}))})}var kh=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.fixed=ge.Boolean("-F,--fixed",!1,{description:"Store dependency tags as-is instead of resolving them"});this.exact=ge.Boolean("-E,--exact",!1,{description:"Don't use any semver modifier on the resolved range"});this.tilde=ge.Boolean("-T,--tilde",!1,{description:"Use the `~` semver modifier on the resolved range"});this.caret=ge.Boolean("-C,--caret",!1,{description:"Use the `^` semver modifier on the resolved range"});this.dev=ge.Boolean("-D,--dev",!1,{description:"Add a package as a dev dependency"});this.peer=ge.Boolean("-P,--peer",!1,{description:"Add a package as a peer dependency"});this.optional=ge.Boolean("-O,--optional",!1,{description:"Add / upgrade a package to an optional regular / peer dependency"});this.preferDev=ge.Boolean("--prefer-dev",!1,{description:"Add / upgrade a package to a dev dependency"});this.interactive=ge.Boolean("-i,--interactive",{description:"Reuse the specified package from other workspaces in the project"});this.cached=ge.Boolean("--cached",!1,{description:"Reuse the highest version already used somewhere within the project"});this.mode=ge.String("--mode",{description:"Change what artifacts installs generate",validator:Ks(pl)});this.silent=ge.Boolean("--silent",{hidden:!0});this.packages=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd),n=await Lr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState({restoreResolutions:!1});let u=this.fixed,A=this.interactive??r.get("preferInteractive"),p=A||r.get("preferReuse"),h=h2(this,o),E=[p?"reuse":void 0,"project",this.cached?"cache":void 0,"latest"].filter(U=>typeof U<"u"),I=A?1/0:1,v=await Promise.all(this.packages.map(async U=>{let J=U.match(/^\.{0,2}\//)?await v8(U,{cwd:this.context.cwd,workspace:a}):W.tryParseDescriptor(U),te=U.match(/^(https?:|git@github)/);if(te)throw new it(`It seems you are trying to add a package using a ${de.pretty(r,`${te[0]}...`,de.Type.RANGE)} url; we now require package names to be explicitly specified. +Try running the command again with the package name prefixed: ${de.pretty(r,"yarn add",de.Type.CODE)} ${de.pretty(r,W.makeDescriptor(W.makeIdent(null,"my-package"),`${te[0]}...`),de.Type.DESCRIPTOR)}`);if(!J)throw new it(`The ${de.pretty(r,U,de.Type.CODE)} string didn't match the required format (package-name@range). Did you perhaps forget to explicitly reference the package name?`);let ae=tpt(a,J,{dev:this.dev,peer:this.peer,preferDev:this.preferDev,optional:this.optional});return await Promise.all(ae.map(async ce=>{let me=await g2(J,{project:o,workspace:a,cache:n,fixed:u,target:ce,modifier:h,strategies:E,maxResults:I});return{request:J,suggestedDescriptors:me,target:ce}}))})).then(U=>U.flat()),x=await AA.start({configuration:r,stdout:this.context.stdout,suggestInstall:!1},async U=>{for(let{request:J,suggestedDescriptors:{suggestions:te,rejections:ae}}of v)if(te.filter(ce=>ce.descriptor!==null).length===0){let[ce]=ae;if(typeof ce>"u")throw new Error("Assertion failed: Expected an error to have been set");o.configuration.get("enableNetwork")?U.reportError(27,`${W.prettyDescriptor(r,J)} can't be resolved to a satisfying range`):U.reportError(27,`${W.prettyDescriptor(r,J)} can't be resolved to a satisfying range (note: network resolution has been disabled)`),U.reportSeparator(),U.reportExceptionOnce(ce)}});if(x.hasErrors())return x.exitCode();let C=!1,R=[],L=[];for(let{suggestedDescriptors:{suggestions:U},target:J}of v){let te,ae=U.filter(he=>he.descriptor!==null),fe=ae[0].descriptor,ce=ae.every(he=>W.areDescriptorsEqual(he.descriptor,fe));ae.length===1||ce?te=fe:(C=!0,{answer:te}=await(0,_0e.prompt)({type:"select",name:"answer",message:"Which range do you want to use?",choices:U.map(({descriptor:he,name:Be,reason:we})=>he?{name:Be,hint:we,descriptor:he}:{name:Be,hint:we,disabled:!0}),onCancel:()=>process.exit(130),result(he){return this.find(he,"descriptor")},stdin:this.context.stdin,stdout:this.context.stdout}));let me=a.manifest[J].get(te.identHash);(typeof me>"u"||me.descriptorHash!==te.descriptorHash)&&(a.manifest[J].set(te.identHash,te),this.optional&&(J==="dependencies"?a.manifest.ensureDependencyMeta({...te,range:"unknown"}).optional=!0:J==="peerDependencies"&&(a.manifest.ensurePeerDependencyMeta({...te,range:"unknown"}).optional=!0)),typeof me>"u"?R.push([a,J,te,E]):L.push([a,J,me,te]))}return await r.triggerMultipleHooks(U=>U.afterWorkspaceDependencyAddition,R),await r.triggerMultipleHooks(U=>U.afterWorkspaceDependencyReplacement,L),C&&this.context.stdout.write(` +`),await o.installWithNewReport({json:this.json,stdout:this.context.stdout,quiet:this.context.quiet},{cache:n,mode:this.mode})}};kh.paths=[["add"]],kh.usage=nt.Usage({description:"add dependencies to the project",details:"\n This command adds a package to the package.json for the nearest workspace.\n\n - If it didn't exist before, the package will by default be added to the regular `dependencies` field, but this behavior can be overriden thanks to the `-D,--dev` flag (which will cause the dependency to be added to the `devDependencies` field instead) and the `-P,--peer` flag (which will do the same but for `peerDependencies`).\n\n - If the package was already listed in your dependencies, it will by default be upgraded whether it's part of your `dependencies` or `devDependencies` (it won't ever update `peerDependencies`, though).\n\n - If set, the `--prefer-dev` flag will operate as a more flexible `-D,--dev` in that it will add the package to your `devDependencies` if it isn't already listed in either `dependencies` or `devDependencies`, but it will also happily upgrade your `dependencies` if that's what you already use (whereas `-D,--dev` would throw an exception).\n\n - If set, the `-O,--optional` flag will add the package to the `optionalDependencies` field and, in combination with the `-P,--peer` flag, it will add the package as an optional peer dependency. If the package was already listed in your `dependencies`, it will be upgraded to `optionalDependencies`. If the package was already listed in your `peerDependencies`, in combination with the `-P,--peer` flag, it will be upgraded to an optional peer dependency: `\"peerDependenciesMeta\": { \"<package>\": { \"optional\": true } }`\n\n - If the added package doesn't specify a range at all its `latest` tag will be resolved and the returned version will be used to generate a new semver range (using the `^` modifier by default unless otherwise configured via the `defaultSemverRangePrefix` configuration, or the `~` modifier if `-T,--tilde` is specified, or no modifier at all if `-E,--exact` is specified). Two exceptions to this rule: the first one is that if the package is a workspace then its local version will be used, and the second one is that if you use `-P,--peer` the default range will be `*` and won't be resolved at all.\n\n - If the added package specifies a range (such as `^1.0.0`, `latest`, or `rc`), Yarn will add this range as-is in the resulting package.json entry (in particular, tags such as `rc` will be encoded as-is rather than being converted into a semver range).\n\n If the `--cached` option is used, Yarn will preferably reuse the highest version already used somewhere within the project, even if through a transitive dependency.\n\n If the `-i,--interactive` option is used (or if the `preferInteractive` settings is toggled on) the command will first try to check whether other workspaces in the project use the specified package and, if so, will offer to reuse them.\n\n If the `--mode=<mode>` option is set, Yarn will change which artifacts are generated. The modes currently supported are:\n\n - `skip-build` will not run the build scripts at all. Note that this is different from setting `enableScripts` to false because the latter will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run.\n\n - `update-lockfile` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost.\n\n For a compilation of all the supported protocols, please consult the dedicated page from our website: https://yarnpkg.com/protocols.\n ",examples:[["Add a regular package to the current workspace","$0 add lodash"],["Add a specific version for a package to the current workspace","$0 add lodash@1.2.3"],["Add a package from a GitHub repository (the master branch) to the current workspace using a URL","$0 add lodash@https://github.com/lodash/lodash"],["Add a package from a GitHub repository (the master branch) to the current workspace using the GitHub protocol","$0 add lodash@github:lodash/lodash"],["Add a package from a GitHub repository (the master branch) to the current workspace using the GitHub protocol (shorthand)","$0 add lodash@lodash/lodash"],["Add a package from a specific branch of a GitHub repository to the current workspace using the GitHub protocol (shorthand)","$0 add lodash-es@lodash/lodash#es"]]});function tpt(t,e,{dev:r,peer:o,preferDev:a,optional:n}){let u=t.manifest["dependencies"].has(e.identHash),A=t.manifest["devDependencies"].has(e.identHash),p=t.manifest["peerDependencies"].has(e.identHash);if((r||o)&&u)throw new it(`Package "${W.prettyIdent(t.project.configuration,e)}" is already listed as a regular dependency - remove the -D,-P flags or remove it from your dependencies first`);if(!r&&!o&&p)throw new it(`Package "${W.prettyIdent(t.project.configuration,e)}" is already listed as a peer dependency - use either of -D or -P, or remove it from your peer dependencies first`);if(n&&A)throw new it(`Package "${W.prettyIdent(t.project.configuration,e)}" is already listed as a dev dependency - remove the -O flag or remove it from your dev dependencies first`);if(n&&!o&&p)throw new it(`Package "${W.prettyIdent(t.project.configuration,e)}" is already listed as a peer dependency - remove the -O flag or add the -P flag or remove it from your peer dependencies first`);if((r||a)&&n)throw new it(`Package "${W.prettyIdent(t.project.configuration,e)}" cannot simultaneously be a dev dependency and an optional dependency`);let h=[];return o&&h.push("peerDependencies"),(r||a)&&h.push("devDependencies"),n&&h.push("dependencies"),h.length>0?h:A?["devDependencies"]:p?["peerDependencies"]:["dependencies"]}Ye();Ye();jt();var Qh=class extends ut{constructor(){super(...arguments);this.verbose=ge.Boolean("-v,--verbose",!1,{description:"Print both the binary name and the locator of the package that provides the binary"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.name=ge.String({required:!1})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,locator:a}=await Pt.find(r,this.context.cwd);if(await o.restoreInstallState(),this.name){let A=(await un.getPackageAccessibleBinaries(a,{project:o})).get(this.name);if(!A)throw new it(`Couldn't find a binary named "${this.name}" for package "${W.prettyLocator(r,a)}"`);let[,p]=A;return this.context.stdout.write(`${p} +`),0}return(await Nt.start({configuration:r,json:this.json,stdout:this.context.stdout},async u=>{let A=await un.getPackageAccessibleBinaries(a,{project:o}),h=Array.from(A.keys()).reduce((E,I)=>Math.max(E,I.length),0);for(let[E,[I,v]]of A)u.reportJson({name:E,source:W.stringifyIdent(I),path:v});if(this.verbose)for(let[E,[I]]of A)u.reportInfo(null,`${E.padEnd(h," ")} ${W.prettyLocator(r,I)}`);else for(let E of A.keys())u.reportInfo(null,E)})).exitCode()}};Qh.paths=[["bin"]],Qh.usage=nt.Usage({description:"get the path to a binary script",details:` + When used without arguments, this command will print the list of all the binaries available in the current workspace. Adding the \`-v,--verbose\` flag will cause the output to contain both the binary name and the locator of the package that provides the binary. + + When an argument is specified, this command will just print the path to the binary on the standard output and exit. Note that the reported path may be stored within a zip archive. + `,examples:[["List all the available binaries","$0 bin"],["Print the path to a specific binary","$0 bin eslint"]]});Ye();St();jt();var Fh=class extends ut{constructor(){super(...arguments);this.mirror=ge.Boolean("--mirror",!1,{description:"Remove the global cache files instead of the local cache files"});this.all=ge.Boolean("--all",!1,{description:"Remove both the global cache files and the local cache files of the current project"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=await Lr.find(r);return(await Nt.start({configuration:r,stdout:this.context.stdout},async()=>{let n=(this.all||this.mirror)&&o.mirrorCwd!==null,u=!this.mirror;n&&(await oe.removePromise(o.mirrorCwd),await r.triggerHook(A=>A.cleanGlobalArtifacts,r)),u&&await oe.removePromise(o.cwd)})).exitCode()}};Fh.paths=[["cache","clean"],["cache","clear"]],Fh.usage=nt.Usage({description:"remove the shared cache files",details:` + This command will remove all the files from the cache. + `,examples:[["Remove all the local archives","$0 cache clean"],["Remove all the archives stored in the ~/.yarn directory","$0 cache clean --mirror"]]});Ye();jt();var j0e=$e(d2()),S8=ve("util"),Rh=class extends ut{constructor(){super(...arguments);this.why=ge.Boolean("--why",!1,{description:"Print the explanation for why a setting has its value"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.unsafe=ge.Boolean("--no-redacted",!1,{description:"Don't redact secrets (such as tokens) from the output"});this.name=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=this.name.replace(/[.[].*$/,""),a=this.name.replace(/^[^.[]*/,"");if(typeof r.settings.get(o)>"u")throw new it(`Couldn't find a configuration settings named "${o}"`);let u=r.getSpecial(o,{hideSecrets:!this.unsafe,getNativePaths:!0}),A=_e.convertMapsToIndexableObjects(u),p=a?(0,j0e.default)(A,a):A,h=await Nt.start({configuration:r,includeFooter:!1,json:this.json,stdout:this.context.stdout},async E=>{E.reportJson(p)});if(!this.json){if(typeof p=="string")return this.context.stdout.write(`${p} +`),h.exitCode();S8.inspect.styles.name="cyan",this.context.stdout.write(`${(0,S8.inspect)(p,{depth:1/0,colors:r.get("enableColors"),compact:!1})} +`)}return h.exitCode()}};Rh.paths=[["config","get"]],Rh.usage=nt.Usage({description:"read a configuration settings",details:` + This command will print a configuration setting. + + Secrets (such as tokens) will be redacted from the output by default. If this behavior isn't desired, set the \`--no-redacted\` to get the untransformed value. + `,examples:[["Print a simple configuration setting","yarn config get yarnPath"],["Print a complex configuration setting","yarn config get packageExtensions"],["Print a nested field from the configuration",`yarn config get 'npmScopes["my-company"].npmRegistryServer'`],["Print a token from the configuration","yarn config get npmAuthToken --no-redacted"],["Print a configuration setting as JSON","yarn config get packageExtensions --json"]]});Ye();jt();var Fge=$e(k8()),Rge=$e(d2()),Tge=$e(Q8()),F8=ve("util"),Th=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Set complex configuration settings to JSON values"});this.home=ge.Boolean("-H,--home",!1,{description:"Update the home configuration instead of the project configuration"});this.name=ge.String();this.value=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=()=>{if(!r.projectCwd)throw new it("This command must be run from within a project folder");return r.projectCwd},a=this.name.replace(/[.[].*$/,""),n=this.name.replace(/^[^.[]*\.?/,"");if(typeof r.settings.get(a)>"u")throw new it(`Couldn't find a configuration settings named "${a}"`);if(a==="enableStrictSettings")throw new it("This setting only affects the file it's in, and thus cannot be set from the CLI");let A=this.json?JSON.parse(this.value):this.value;await(this.home?C=>Ke.updateHomeConfiguration(C):C=>Ke.updateConfiguration(o(),C))(C=>{if(n){let R=(0,Fge.default)(C);return(0,Tge.default)(R,this.name,A),R}else return{...C,[a]:A}});let E=(await Ke.find(this.context.cwd,this.context.plugins)).getSpecial(a,{hideSecrets:!0,getNativePaths:!0}),I=_e.convertMapsToIndexableObjects(E),v=n?(0,Rge.default)(I,n):I;return(await Nt.start({configuration:r,includeFooter:!1,stdout:this.context.stdout},async C=>{F8.inspect.styles.name="cyan",C.reportInfo(0,`Successfully set ${this.name} to ${(0,F8.inspect)(v,{depth:1/0,colors:r.get("enableColors"),compact:!1})}`)})).exitCode()}};Th.paths=[["config","set"]],Th.usage=nt.Usage({description:"change a configuration settings",details:` + This command will set a configuration setting. + + When used without the \`--json\` flag, it can only set a simple configuration setting (a string, a number, or a boolean). + + When used with the \`--json\` flag, it can set both simple and complex configuration settings, including Arrays and Objects. + `,examples:[["Set a simple configuration setting (a string, a number, or a boolean)","yarn config set initScope myScope"],["Set a simple configuration setting (a string, a number, or a boolean) using the `--json` flag",'yarn config set initScope --json \\"myScope\\"'],["Set a complex configuration setting (an Array) using the `--json` flag",`yarn config set unsafeHttpWhitelist --json '["*.example.com", "example.com"]'`],["Set a complex configuration setting (an Object) using the `--json` flag",`yarn config set packageExtensions --json '{ "@babel/parser@*": { "dependencies": { "@babel/types": "*" } } }'`],["Set a nested configuration setting",'yarn config set npmScopes.company.npmRegistryServer "https://npm.example.com"'],["Set a nested configuration setting using indexed access for non-simple keys",`yarn config set 'npmRegistries["//npm.example.com"].npmAuthToken' "ffffffff-ffff-ffff-ffff-ffffffffffff"`]]});Ye();jt();var Yge=$e(k8()),Wge=$e(Mge()),Kge=$e(T8()),Nh=class extends ut{constructor(){super(...arguments);this.home=ge.Boolean("-H,--home",!1,{description:"Update the home configuration instead of the project configuration"});this.name=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=()=>{if(!r.projectCwd)throw new it("This command must be run from within a project folder");return r.projectCwd},a=this.name.replace(/[.[].*$/,""),n=this.name.replace(/^[^.[]*\.?/,"");if(typeof r.settings.get(a)>"u")throw new it(`Couldn't find a configuration settings named "${a}"`);let A=this.home?h=>Ke.updateHomeConfiguration(h):h=>Ke.updateConfiguration(o(),h);return(await Nt.start({configuration:r,includeFooter:!1,stdout:this.context.stdout},async h=>{let E=!1;await A(I=>{if(!(0,Wge.default)(I,this.name))return h.reportWarning(0,`Configuration doesn't contain setting ${this.name}; there is nothing to unset`),E=!0,I;let v=n?(0,Yge.default)(I):{...I};return(0,Kge.default)(v,this.name),v}),E||h.reportInfo(0,`Successfully unset ${this.name}`)})).exitCode()}};Nh.paths=[["config","unset"]],Nh.usage=nt.Usage({description:"unset a configuration setting",details:` + This command will unset a configuration setting. + `,examples:[["Unset a simple configuration setting","yarn config unset initScope"],["Unset a complex configuration setting","yarn config unset packageExtensions"],["Unset a nested configuration setting","yarn config unset npmScopes.company.npmRegistryServer"]]});Ye();St();jt();var fk=ve("util"),Lh=class extends ut{constructor(){super(...arguments);this.noDefaults=ge.Boolean("--no-defaults",!1,{description:"Omit the default values from the display"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.verbose=ge.Boolean("-v,--verbose",{hidden:!0});this.why=ge.Boolean("--why",{hidden:!0});this.names=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins,{strict:!1}),o=await LE({configuration:r,stdout:this.context.stdout,forceError:this.json},[{option:this.verbose,message:"The --verbose option is deprecated, the settings' descriptions are now always displayed"},{option:this.why,message:"The --why option is deprecated, the settings' sources are now always displayed"}]);if(o!==null)return o;let a=this.names.length>0?[...new Set(this.names)].sort():[...r.settings.keys()].sort(),n,u=await Nt.start({configuration:r,json:this.json,stdout:this.context.stdout,includeFooter:!1},async A=>{if(r.invalid.size>0&&!this.json){for(let[p,h]of r.invalid)A.reportError(34,`Invalid configuration key "${p}" in ${h}`);A.reportSeparator()}if(this.json)for(let p of a){let h=r.settings.get(p);typeof h>"u"&&A.reportError(34,`No configuration key named "${p}"`);let E=r.getSpecial(p,{hideSecrets:!0,getNativePaths:!0}),I=r.sources.get(p)??"<default>",v=I&&I[0]!=="<"?ue.fromPortablePath(I):I;A.reportJson({key:p,effective:E,source:v,...h})}else{let p={breakLength:1/0,colors:r.get("enableColors"),maxArrayLength:2},h={},E={children:h};for(let I of a){if(this.noDefaults&&!r.sources.has(I))continue;let v=r.settings.get(I),x=r.sources.get(I)??"<default>",C=r.getSpecial(I,{hideSecrets:!0,getNativePaths:!0}),R={Description:{label:"Description",value:de.tuple(de.Type.MARKDOWN,{text:v.description,format:this.cli.format(),paragraphs:!1})},Source:{label:"Source",value:de.tuple(x[0]==="<"?de.Type.CODE:de.Type.PATH,x)}};h[I]={value:de.tuple(de.Type.CODE,I),children:R};let L=(U,J)=>{for(let[te,ae]of J)if(ae instanceof Map){let fe={};U[te]={children:fe},L(fe,ae)}else U[te]={label:te,value:de.tuple(de.Type.NO_HINT,(0,fk.inspect)(ae,p))}};C instanceof Map?L(R,C):R.Value={label:"Value",value:de.tuple(de.Type.NO_HINT,(0,fk.inspect)(C,p))}}a.length!==1&&(n=void 0),$s.emitTree(E,{configuration:r,json:this.json,stdout:this.context.stdout,separators:2})}});if(!this.json&&typeof n<"u"){let A=a[0],p=(0,fk.inspect)(r.getSpecial(A,{hideSecrets:!0,getNativePaths:!0}),{colors:r.get("enableColors")});this.context.stdout.write(` +`),this.context.stdout.write(`${p} +`)}return u.exitCode()}};Lh.paths=[["config"]],Lh.usage=nt.Usage({description:"display the current configuration",details:` + This command prints the current active configuration settings. + `,examples:[["Print the active configuration settings","$0 config"]]});Ye();jt();Za();var pk={};Vt(pk,{Strategy:()=>m2,acceptedStrategies:()=>O0t,dedupe:()=>N8});Ye();Ye();var Vge=$e(Zo()),m2=(e=>(e.HIGHEST="highest",e))(m2||{}),O0t=new Set(Object.values(m2)),M0t={highest:async(t,e,{resolver:r,fetcher:o,resolveOptions:a,fetchOptions:n})=>{let u=new Map;for(let[p,h]of t.storedResolutions){let E=t.storedDescriptors.get(p);if(typeof E>"u")throw new Error(`Assertion failed: The descriptor (${p}) should have been registered`);_e.getSetWithDefault(u,E.identHash).add(h)}let A=new Map(_e.mapAndFilter(t.storedDescriptors.values(),p=>W.isVirtualDescriptor(p)?_e.mapAndFilter.skip:[p.descriptorHash,_e.makeDeferred()]));for(let p of t.storedDescriptors.values()){let h=A.get(p.descriptorHash);if(typeof h>"u")throw new Error(`Assertion failed: The descriptor (${p.descriptorHash}) should have been registered`);let E=t.storedResolutions.get(p.descriptorHash);if(typeof E>"u")throw new Error(`Assertion failed: The resolution (${p.descriptorHash}) should have been registered`);let I=t.originalPackages.get(E);if(typeof I>"u")throw new Error(`Assertion failed: The package (${E}) should have been registered`);Promise.resolve().then(async()=>{let v=r.getResolutionDependencies(p,a),x=Object.fromEntries(await _e.allSettledSafe(Object.entries(v).map(async([te,ae])=>{let fe=A.get(ae.descriptorHash);if(typeof fe>"u")throw new Error(`Assertion failed: The descriptor (${ae.descriptorHash}) should have been registered`);let ce=await fe.promise;if(!ce)throw new Error("Assertion failed: Expected the dependency to have been through the dedupe process itself");return[te,ce.updatedPackage]})));if(e.length&&!Vge.default.isMatch(W.stringifyIdent(p),e)||!r.shouldPersistResolution(I,a))return I;let C=u.get(p.identHash);if(typeof C>"u")throw new Error(`Assertion failed: The resolutions (${p.identHash}) should have been registered`);if(C.size===1)return I;let R=[...C].map(te=>{let ae=t.originalPackages.get(te);if(typeof ae>"u")throw new Error(`Assertion failed: The package (${te}) should have been registered`);return ae}),L=await r.getSatisfying(p,x,R,a),U=L.locators?.[0];if(typeof U>"u"||!L.sorted)return I;let J=t.originalPackages.get(U.locatorHash);if(typeof J>"u")throw new Error(`Assertion failed: The package (${U.locatorHash}) should have been registered`);return J}).then(async v=>{let x=await t.preparePackage(v,{resolver:r,resolveOptions:a});h.resolve({descriptor:p,currentPackage:I,updatedPackage:v,resolvedPackage:x})}).catch(v=>{h.reject(v)})}return[...A.values()].map(p=>p.promise)}};async function N8(t,{strategy:e,patterns:r,cache:o,report:a}){let{configuration:n}=t,u=new Qi,A=n.makeResolver(),p=n.makeFetcher(),h={cache:o,checksums:t.storedChecksums,fetcher:p,project:t,report:u,cacheOptions:{skipIntegrityCheck:!0}},E={project:t,resolver:A,report:u,fetchOptions:h};return await a.startTimerPromise("Deduplication step",async()=>{let I=M0t[e],v=await I(t,r,{resolver:A,resolveOptions:E,fetcher:p,fetchOptions:h}),x=Xs.progressViaCounter(v.length);await a.reportProgress(x);let C=0;await Promise.all(v.map(U=>U.then(J=>{if(J===null||J.currentPackage.locatorHash===J.updatedPackage.locatorHash)return;C++;let{descriptor:te,currentPackage:ae,updatedPackage:fe}=J;a.reportInfo(0,`${W.prettyDescriptor(n,te)} can be deduped from ${W.prettyLocator(n,ae)} to ${W.prettyLocator(n,fe)}`),a.reportJson({descriptor:W.stringifyDescriptor(te),currentResolution:W.stringifyLocator(ae),updatedResolution:W.stringifyLocator(fe)}),t.storedResolutions.set(te.descriptorHash,fe.locatorHash)}).finally(()=>x.tick())));let R;switch(C){case 0:R="No packages";break;case 1:R="One package";break;default:R=`${C} packages`}let L=de.pretty(n,e,de.Type.CODE);return a.reportInfo(0,`${R} can be deduped using the ${L} strategy`),C})}var Oh=class extends ut{constructor(){super(...arguments);this.strategy=ge.String("-s,--strategy","highest",{description:"The strategy to use when deduping dependencies",validator:Ks(m2)});this.check=ge.Boolean("-c,--check",!1,{description:"Exit with exit code 1 when duplicates are found, without persisting the dependency tree"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.mode=ge.String("--mode",{description:"Change what artifacts installs generate",validator:Ks(pl)});this.patterns=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o}=await Pt.find(r,this.context.cwd),a=await Lr.find(r);await o.restoreInstallState({restoreResolutions:!1});let n=0,u=await Nt.start({configuration:r,includeFooter:!1,stdout:this.context.stdout,json:this.json},async A=>{n=await N8(o,{strategy:this.strategy,patterns:this.patterns,cache:a,report:A})});return u.hasErrors()?u.exitCode():this.check?n?1:0:await o.installWithNewReport({json:this.json,stdout:this.context.stdout},{cache:a,mode:this.mode})}};Oh.paths=[["dedupe"]],Oh.usage=nt.Usage({description:"deduplicate dependencies with overlapping ranges",details:"\n Duplicates are defined as descriptors with overlapping ranges being resolved and locked to different locators. They are a natural consequence of Yarn's deterministic installs, but they can sometimes pile up and unnecessarily increase the size of your project.\n\n This command dedupes dependencies in the current project using different strategies (only one is implemented at the moment):\n\n - `highest`: Reuses (where possible) the locators with the highest versions. This means that dependencies can only be upgraded, never downgraded. It's also guaranteed that it never takes more than a single pass to dedupe the entire dependency tree.\n\n **Note:** Even though it never produces a wrong dependency tree, this command should be used with caution, as it modifies the dependency tree, which can sometimes cause problems when packages don't strictly follow semver recommendations. Because of this, it is recommended to also review the changes manually.\n\n If set, the `-c,--check` flag will only report the found duplicates, without persisting the modified dependency tree. If changes are found, the command will exit with a non-zero exit code, making it suitable for CI purposes.\n\n If the `--mode=<mode>` option is set, Yarn will change which artifacts are generated. The modes currently supported are:\n\n - `skip-build` will not run the build scripts at all. Note that this is different from setting `enableScripts` to false because the latter will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run.\n\n - `update-lockfile` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost.\n\n This command accepts glob patterns as arguments (if valid Idents and supported by [micromatch](https://github.com/micromatch/micromatch)). Make sure to escape the patterns, to prevent your own shell from trying to expand them.\n\n ### In-depth explanation:\n\n Yarn doesn't deduplicate dependencies by default, otherwise installs wouldn't be deterministic and the lockfile would be useless. What it actually does is that it tries to not duplicate dependencies in the first place.\n\n **Example:** If `foo@^2.3.4` (a dependency of a dependency) has already been resolved to `foo@2.3.4`, running `yarn add foo@*`will cause Yarn to reuse `foo@2.3.4`, even if the latest `foo` is actually `foo@2.10.14`, thus preventing unnecessary duplication.\n\n Duplication happens when Yarn can't unlock dependencies that have already been locked inside the lockfile.\n\n **Example:** If `foo@^2.3.4` (a dependency of a dependency) has already been resolved to `foo@2.3.4`, running `yarn add foo@2.10.14` will cause Yarn to install `foo@2.10.14` because the existing resolution doesn't satisfy the range `2.10.14`. This behavior can lead to (sometimes) unwanted duplication, since now the lockfile contains 2 separate resolutions for the 2 `foo` descriptors, even though they have overlapping ranges, which means that the lockfile can be simplified so that both descriptors resolve to `foo@2.10.14`.\n ",examples:[["Dedupe all packages","$0 dedupe"],["Dedupe all packages using a specific strategy","$0 dedupe --strategy highest"],["Dedupe a specific package","$0 dedupe lodash"],["Dedupe all packages with the `@babel/*` scope","$0 dedupe '@babel/*'"],["Check for duplicates (can be used as a CI step)","$0 dedupe --check"]]});Ye();jt();var Kd=class extends ut{async execute(){let{plugins:e}=await Ke.find(this.context.cwd,this.context.plugins),r=[];for(let u of e){let{commands:A}=u[1];if(A){let h=as.from(A).definitions();r.push([u[0],h])}}let o=this.cli.definitions(),a=(u,A)=>u.split(" ").slice(1).join()===A.split(" ").slice(1).join(),n=Jge()["@yarnpkg/builder"].bundles.standard;for(let u of r){let A=u[1];for(let p of A)o.find(h=>a(h.path,p.path)).plugin={name:u[0],isDefault:n.includes(u[0])}}this.context.stdout.write(`${JSON.stringify(o,null,2)} +`)}};Kd.paths=[["--clipanion=definitions"]];var Vd=class extends ut{async execute(){this.context.stdout.write(this.cli.usage(null))}};Vd.paths=[["help"],["--help"],["-h"]];Ye();St();jt();var mC=class extends ut{constructor(){super(...arguments);this.leadingArgument=ge.String();this.args=ge.Proxy()}async execute(){if(this.leadingArgument.match(/[\\/]/)&&!W.tryParseIdent(this.leadingArgument)){let r=V.resolve(this.context.cwd,ue.toPortablePath(this.leadingArgument));return await this.cli.run(this.args,{cwd:r})}else return await this.cli.run(["run",this.leadingArgument,...this.args])}};Ye();var Jd=class extends ut{async execute(){this.context.stdout.write(`${rn||"<unknown>"} +`)}};Jd.paths=[["-v"],["--version"]];Ye();Ye();jt();var Mh=class extends ut{constructor(){super(...arguments);this.commandName=ge.String();this.args=ge.Proxy()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,locator:a}=await Pt.find(r,this.context.cwd);return await o.restoreInstallState(),await un.executePackageShellcode(a,this.commandName,this.args,{cwd:this.context.cwd,stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr,project:o})}};Mh.paths=[["exec"]],Mh.usage=nt.Usage({description:"execute a shell script",details:` + This command simply executes a shell script within the context of the root directory of the active workspace using the portable shell. + + It also makes sure to call it in a way that's compatible with the current project (for example, on PnP projects the environment will be setup in such a way that PnP will be correctly injected into the environment). + `,examples:[["Execute a single shell command","$0 exec echo Hello World"],["Execute a shell script",'$0 exec "tsc & babel src --out-dir lib"']]});Ye();jt();Za();var Uh=class extends ut{constructor(){super(...arguments);this.hash=ge.String({validator:oS(Cy(),[oI(/^p[0-9a-f]{5}$/)])})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o}=await Pt.find(r,this.context.cwd);return await o.restoreInstallState({restoreResolutions:!1}),await o.applyLightResolution(),await _0t(this.hash,o,{stdout:this.context.stdout})}};Uh.paths=[["explain","peer-requirements"]],Uh.usage=nt.Usage({description:"explain a set of peer requirements",details:` + A set of peer requirements represents all peer requirements that a dependent must satisfy when providing a given peer request to a requester and its descendants. + + When the hash argument is specified, this command prints a detailed explanation of all requirements of the set corresponding to the hash and whether they're satisfied or not. + + When used without arguments, this command lists all sets of peer requirements and the corresponding hash that can be used to get detailed information about a given set. + + **Note:** A hash is a six-letter p-prefixed code that can be obtained from peer dependency warnings or from the list of all peer requirements (\`yarn explain peer-requirements\`). + `,examples:[["Explain the corresponding set of peer requirements for a hash","$0 explain peer-requirements p1a4ed"],["List all sets of peer requirements","$0 explain peer-requirements"]]});async function _0t(t,e,r){let o=e.peerWarnings.find(n=>n.hash===t);if(typeof o>"u")throw new Error(`No peerDependency requirements found for hash: "${t}"`);return(await Nt.start({configuration:e.configuration,stdout:r.stdout,includeFooter:!1,includePrefix:!1},async n=>{let u=de.mark(e.configuration);switch(o.type){case 2:{n.reportInfo(0,`We have a problem with ${de.pretty(e.configuration,o.requested,de.Type.IDENT)}, which is provided with version ${W.prettyReference(e.configuration,o.version)}.`),n.reportInfo(0,"It is needed by the following direct dependencies of workspaces in your project:"),n.reportSeparator();for(let h of o.requesters.values()){let E=e.storedPackages.get(h.locatorHash);if(!E)throw new Error("Assertion failed: Expected the package to be registered");let I=E?.peerDependencies.get(o.requested.identHash);if(!I)throw new Error("Assertion failed: Expected the package to list the peer dependency");let v=kr.satisfiesWithPrereleases(o.version,I.range)?u.Check:u.Cross;n.reportInfo(null,` ${v} ${W.prettyLocator(e.configuration,h)} (via ${W.prettyRange(e.configuration,I.range)})`)}let A=[...o.links.values()].filter(h=>!o.requesters.has(h.locatorHash));if(A.length>0){n.reportSeparator(),n.reportInfo(0,`However, those packages themselves have more dependencies listing ${W.prettyIdent(e.configuration,o.requested)} as peer dependency:`),n.reportSeparator();for(let h of A){let E=e.storedPackages.get(h.locatorHash);if(!E)throw new Error("Assertion failed: Expected the package to be registered");let I=E?.peerDependencies.get(o.requested.identHash);if(!I)throw new Error("Assertion failed: Expected the package to list the peer dependency");let v=kr.satisfiesWithPrereleases(o.version,I.range)?u.Check:u.Cross;n.reportInfo(null,` ${v} ${W.prettyLocator(e.configuration,h)} (via ${W.prettyRange(e.configuration,I.range)})`)}}let p=Array.from(o.links.values(),h=>{let E=e.storedPackages.get(h.locatorHash);if(typeof E>"u")throw new Error("Assertion failed: Expected the package to be registered");let I=E.peerDependencies.get(o.requested.identHash);if(typeof I>"u")throw new Error("Assertion failed: Expected the ident to be registered");return I.range});if(p.length>1){let h=kr.simplifyRanges(p);n.reportSeparator(),h===null?(n.reportInfo(0,"Unfortunately, put together, we found no single range that can satisfy all those peer requirements."),n.reportInfo(0,`Your best option may be to try to upgrade some dependencies with ${de.pretty(e.configuration,"yarn up",de.Type.CODE)}, or silence the warning via ${de.pretty(e.configuration,"logFilters",de.Type.CODE)}.`)):n.reportInfo(0,`Put together, the final range we computed is ${de.pretty(e.configuration,h,de.Type.RANGE)}`)}}break;default:n.reportInfo(0,`The ${de.pretty(e.configuration,"yarn explain peer-requirements",de.Type.CODE)} command doesn't support this warning type yet.`);break}})).exitCode()}Ye();jt();Za();Ye();Ye();St();jt();var zge=$e(zn()),_h=class extends ut{constructor(){super(...arguments);this.useYarnPath=ge.Boolean("--yarn-path",{description:"Set the yarnPath setting even if the version can be accessed by Corepack"});this.onlyIfNeeded=ge.Boolean("--only-if-needed",!1,{description:"Only lock the Yarn version if it isn't already locked"});this.version=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins);if(this.onlyIfNeeded&&r.get("yarnPath")){let A=r.sources.get("yarnPath");if(!A)throw new Error("Assertion failed: Expected 'yarnPath' to have a source");let p=r.projectCwd??r.startingCwd;if(V.contains(p,A))return 0}let o=()=>{if(typeof rn>"u")throw new it("The --install flag can only be used without explicit version specifier from the Yarn CLI");return`file://${process.argv[1]}`},a,n=(A,p)=>({version:p,url:A.replace(/\{\}/g,p)});if(this.version==="self")a={url:o(),version:rn??"self"};else if(this.version==="latest"||this.version==="berry"||this.version==="stable")a=n("https://repo.yarnpkg.com/{}/packages/yarnpkg-cli/bin/yarn.js",await y2(r,"stable"));else if(this.version==="canary")a=n("https://repo.yarnpkg.com/{}/packages/yarnpkg-cli/bin/yarn.js",await y2(r,"canary"));else if(this.version==="classic")a={url:"https://classic.yarnpkg.com/latest.js",version:"classic"};else if(this.version.match(/^https?:/))a={url:this.version,version:"remote"};else if(this.version.match(/^\.{0,2}[\\/]/)||ue.isAbsolute(this.version))a={url:`file://${V.resolve(ue.toPortablePath(this.version))}`,version:"file"};else if(kr.satisfiesWithPrereleases(this.version,">=2.0.0"))a=n("https://repo.yarnpkg.com/{}/packages/yarnpkg-cli/bin/yarn.js",this.version);else if(kr.satisfiesWithPrereleases(this.version,"^0.x || ^1.x"))a=n("https://github.com/yarnpkg/yarn/releases/download/v{}/yarn-{}.js",this.version);else if(kr.validRange(this.version))a=n("https://repo.yarnpkg.com/{}/packages/yarnpkg-cli/bin/yarn.js",await H0t(r,this.version));else throw new it(`Invalid version descriptor "${this.version}"`);return(await Nt.start({configuration:r,stdout:this.context.stdout,includeLogs:!this.context.quiet},async A=>{let p=async()=>{let h="file://";return a.url.startsWith(h)?(A.reportInfo(0,`Retrieving ${de.pretty(r,a.url,de.Type.PATH)}`),await oe.readFilePromise(a.url.slice(h.length))):(A.reportInfo(0,`Downloading ${de.pretty(r,a.url,de.Type.URL)}`),await nn.get(a.url,{configuration:r}))};await L8(r,a.version,p,{report:A,useYarnPath:this.useYarnPath})})).exitCode()}};_h.paths=[["set","version"]],_h.usage=nt.Usage({description:"lock the Yarn version used by the project",details:"\n This command will set a specific release of Yarn to be used by Corepack: https://nodejs.org/api/corepack.html.\n\n By default it only will set the `packageManager` field at the root of your project, but if the referenced release cannot be represented this way, if you already have `yarnPath` configured, or if you set the `--yarn-path` command line flag, then the release will also be downloaded from the Yarn GitHub repository, stored inside your project, and referenced via the `yarnPath` settings from your project `.yarnrc.yml` file.\n\n A very good use case for this command is to enforce the version of Yarn used by any single member of your team inside the same project - by doing this you ensure that you have control over Yarn upgrades and downgrades (including on your deployment servers), and get rid of most of the headaches related to someone using a slightly different version and getting different behavior.\n\n The version specifier can be:\n\n - a tag:\n - `latest` / `berry` / `stable` -> the most recent stable berry (`>=2.0.0`) release\n - `canary` -> the most recent canary (release candidate) berry (`>=2.0.0`) release\n - `classic` -> the most recent classic (`^0.x || ^1.x`) release\n\n - a semver range (e.g. `2.x`) -> the most recent version satisfying the range (limited to berry releases)\n\n - a semver version (e.g. `2.4.1`, `1.22.1`)\n\n - a local file referenced through either a relative or absolute path\n\n - `self` -> the version used to invoke the command\n ",examples:[["Download the latest release from the Yarn repository","$0 set version latest"],["Download the latest canary release from the Yarn repository","$0 set version canary"],["Download the latest classic release from the Yarn repository","$0 set version classic"],["Download the most recent Yarn 3 build","$0 set version 3.x"],["Download a specific Yarn 2 build","$0 set version 2.0.0-rc.30"],["Switch back to a specific Yarn 1 release","$0 set version 1.22.1"],["Use a release from the local filesystem","$0 set version ./yarn.cjs"],["Use a release from a URL","$0 set version https://repo.yarnpkg.com/3.1.0/packages/yarnpkg-cli/bin/yarn.js"],["Download the version used to invoke the command","$0 set version self"]]});async function H0t(t,e){let o=(await nn.get("https://repo.yarnpkg.com/tags",{configuration:t,jsonResponse:!0})).tags.filter(a=>kr.satisfiesWithPrereleases(a,e));if(o.length===0)throw new it(`No matching release found for range ${de.pretty(t,e,de.Type.RANGE)}.`);return o[0]}async function y2(t,e){let r=await nn.get("https://repo.yarnpkg.com/tags",{configuration:t,jsonResponse:!0});if(!r.latest[e])throw new it(`Tag ${de.pretty(t,e,de.Type.RANGE)} not found`);return r.latest[e]}async function L8(t,e,r,{report:o,useYarnPath:a}){let n,u=async()=>(typeof n>"u"&&(n=await r()),n);if(e===null){let te=await u();await oe.mktempPromise(async ae=>{let fe=V.join(ae,"yarn.cjs");await oe.writeFilePromise(fe,te);let{stdout:ce}=await Ur.execvp(process.execPath,[ue.fromPortablePath(fe),"--version"],{cwd:ae,env:{...t.env,YARN_IGNORE_PATH:"1"}});if(e=ce.trim(),!zge.default.valid(e))throw new Error(`Invalid semver version. ${de.pretty(t,"yarn --version",de.Type.CODE)} returned: +${e}`)})}let A=t.projectCwd??t.startingCwd,p=V.resolve(A,".yarn/releases"),h=V.resolve(p,`yarn-${e}.cjs`),E=V.relative(t.startingCwd,h),I=_e.isTaggedYarnVersion(e),v=t.get("yarnPath"),x=!I,C=x||!!v||!!a;if(a===!1){if(x)throw new zt(0,"You explicitly opted out of yarnPath usage in your command line, but the version you specified cannot be represented by Corepack");C=!1}else!C&&!process.env.COREPACK_ROOT&&(o.reportWarning(0,`You don't seem to have ${de.applyHyperlink(t,"Corepack","https://nodejs.org/api/corepack.html")} enabled; we'll have to rely on ${de.applyHyperlink(t,"yarnPath","https://yarnpkg.com/configuration/yarnrc#yarnPath")} instead`),C=!0);if(C){let te=await u();o.reportInfo(0,`Saving the new release in ${de.pretty(t,E,"magenta")}`),await oe.removePromise(V.dirname(h)),await oe.mkdirPromise(V.dirname(h),{recursive:!0}),await oe.writeFilePromise(h,te,{mode:493}),await Ke.updateConfiguration(A,{yarnPath:V.relative(A,h)})}else await oe.removePromise(V.dirname(h)),await Ke.updateConfiguration(A,{yarnPath:Ke.deleteProperty});let R=await Ot.tryFind(A)||new Ot;R.packageManager=`yarn@${I?e:await y2(t,"stable")}`;let L={};R.exportTo(L);let U=V.join(A,Ot.fileName),J=`${JSON.stringify(L,null,R.indent)} +`;return await oe.changeFilePromise(U,J,{automaticNewlines:!0}),{bundleVersion:e}}function Xge(t){return wr[AS(t)]}var j0t=/## (?<code>YN[0-9]{4}) - `(?<name>[A-Z_]+)`\n\n(?<details>(?:.(?!##))+)/gs;async function G0t(t){let r=`https://repo.yarnpkg.com/${_e.isTaggedYarnVersion(rn)?rn:await y2(t,"canary")}/packages/gatsby/content/advanced/error-codes.md`,o=await nn.get(r,{configuration:t});return new Map(Array.from(o.toString().matchAll(j0t),({groups:a})=>{if(!a)throw new Error("Assertion failed: Expected the match to have been successful");let n=Xge(a.code);if(a.name!==n)throw new Error(`Assertion failed: Invalid error code data: Expected "${a.name}" to be named "${n}"`);return[a.code,a.details]}))}var Hh=class extends ut{constructor(){super(...arguments);this.code=ge.String({required:!1,validator:aI(Cy(),[oI(/^YN[0-9]{4}$/)])});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins);if(typeof this.code<"u"){let o=Xge(this.code),a=de.pretty(r,o,de.Type.CODE),n=this.cli.format().header(`${this.code} - ${a}`),A=(await G0t(r)).get(this.code),p=typeof A<"u"?de.jsonOrPretty(this.json,r,de.tuple(de.Type.MARKDOWN,{text:A,format:this.cli.format(),paragraphs:!0})):`This error code does not have a description. + +You can help us by editing this page on GitHub \u{1F642}: +${de.jsonOrPretty(this.json,r,de.tuple(de.Type.URL,"https://github.com/yarnpkg/berry/blob/master/packages/gatsby/content/advanced/error-codes.md"))} +`;this.json?this.context.stdout.write(`${JSON.stringify({code:this.code,name:o,details:p})} +`):this.context.stdout.write(`${n} + +${p} +`)}else{let o={children:_e.mapAndFilter(Object.entries(wr),([a,n])=>Number.isNaN(Number(a))?_e.mapAndFilter.skip:{label:Wu(Number(a)),value:de.tuple(de.Type.CODE,n)})};$s.emitTree(o,{configuration:r,stdout:this.context.stdout,json:this.json})}}};Hh.paths=[["explain"]],Hh.usage=nt.Usage({description:"explain an error code",details:` + When the code argument is specified, this command prints its name and its details. + + When used without arguments, this command lists all error codes and their names. + `,examples:[["Explain an error code","$0 explain YN0006"],["List all error codes","$0 explain"]]});Ye();St();jt();var Zge=$e(Zo()),jh=class extends ut{constructor(){super(...arguments);this.all=ge.Boolean("-A,--all",!1,{description:"Print versions of a package from the whole project"});this.recursive=ge.Boolean("-R,--recursive",!1,{description:"Print information for all packages, including transitive dependencies"});this.extra=ge.Array("-X,--extra",[],{description:"An array of requests of extra data provided by plugins"});this.cache=ge.Boolean("--cache",!1,{description:"Print information about the cache entry of a package (path, size, checksum)"});this.dependents=ge.Boolean("--dependents",!1,{description:"Print all dependents for each matching package"});this.manifest=ge.Boolean("--manifest",!1,{description:"Print data obtained by looking at the package archive (license, homepage, ...)"});this.nameOnly=ge.Boolean("--name-only",!1,{description:"Only print the name for the matching packages"});this.virtuals=ge.Boolean("--virtuals",!1,{description:"Print each instance of the virtual packages"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.patterns=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd),n=await Lr.find(r);if(!a&&!this.all)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState();let u=new Set(this.extra);this.cache&&u.add("cache"),this.dependents&&u.add("dependents"),this.manifest&&u.add("manifest");let A=(ae,{recursive:fe})=>{let ce=ae.anchoredLocator.locatorHash,me=new Map,he=[ce];for(;he.length>0;){let Be=he.shift();if(me.has(Be))continue;let we=o.storedPackages.get(Be);if(typeof we>"u")throw new Error("Assertion failed: Expected the package to be registered");if(me.set(Be,we),W.isVirtualLocator(we)&&he.push(W.devirtualizeLocator(we).locatorHash),!(!fe&&Be!==ce))for(let g of we.dependencies.values()){let Ee=o.storedResolutions.get(g.descriptorHash);if(typeof Ee>"u")throw new Error("Assertion failed: Expected the resolution to be registered");he.push(Ee)}}return me.values()},p=({recursive:ae})=>{let fe=new Map;for(let ce of o.workspaces)for(let me of A(ce,{recursive:ae}))fe.set(me.locatorHash,me);return fe.values()},h=({all:ae,recursive:fe})=>ae&&fe?o.storedPackages.values():ae?p({recursive:fe}):A(a,{recursive:fe}),E=({all:ae,recursive:fe})=>{let ce=h({all:ae,recursive:fe}),me=this.patterns.map(we=>{let g=W.parseLocator(we),Ee=Zge.default.makeRe(W.stringifyIdent(g)),Se=W.isVirtualLocator(g),le=Se?W.devirtualizeLocator(g):g;return ne=>{let ee=W.stringifyIdent(ne);if(!Ee.test(ee))return!1;if(g.reference==="unknown")return!0;let Ie=W.isVirtualLocator(ne),Fe=Ie?W.devirtualizeLocator(ne):ne;return!(Se&&Ie&&g.reference!==ne.reference||le.reference!==Fe.reference)}}),he=_e.sortMap([...ce],we=>W.stringifyLocator(we));return{selection:he.filter(we=>me.length===0||me.some(g=>g(we))),sortedLookup:he}},{selection:I,sortedLookup:v}=E({all:this.all,recursive:this.recursive});if(I.length===0)throw new it("No package matched your request");let x=new Map;if(this.dependents)for(let ae of v)for(let fe of ae.dependencies.values()){let ce=o.storedResolutions.get(fe.descriptorHash);if(typeof ce>"u")throw new Error("Assertion failed: Expected the resolution to be registered");_e.getArrayWithDefault(x,ce).push(ae)}let C=new Map;for(let ae of v){if(!W.isVirtualLocator(ae))continue;let fe=W.devirtualizeLocator(ae);_e.getArrayWithDefault(C,fe.locatorHash).push(ae)}let R={},L={children:R},U=r.makeFetcher(),J={project:o,fetcher:U,cache:n,checksums:o.storedChecksums,report:new Qi,cacheOptions:{skipIntegrityCheck:!0}},te=[async(ae,fe,ce)=>{if(!fe.has("manifest"))return;let me=await U.fetch(ae,J),he;try{he=await Ot.find(me.prefixPath,{baseFs:me.packageFs})}finally{me.releaseFs?.()}ce("Manifest",{License:de.tuple(de.Type.NO_HINT,he.license),Homepage:de.tuple(de.Type.URL,he.raw.homepage??null)})},async(ae,fe,ce)=>{if(!fe.has("cache"))return;let me=o.storedChecksums.get(ae.locatorHash)??null,he=n.getLocatorPath(ae,me),Be;if(he!==null)try{Be=await oe.statPromise(he)}catch{}let we=typeof Be<"u"?[Be.size,de.Type.SIZE]:void 0;ce("Cache",{Checksum:de.tuple(de.Type.NO_HINT,me),Path:de.tuple(de.Type.PATH,he),Size:we})}];for(let ae of I){let fe=W.isVirtualLocator(ae);if(!this.virtuals&&fe)continue;let ce={},me={value:[ae,de.Type.LOCATOR],children:ce};if(R[W.stringifyLocator(ae)]=me,this.nameOnly){delete me.children;continue}let he=C.get(ae.locatorHash);typeof he<"u"&&(ce.Instances={label:"Instances",value:de.tuple(de.Type.NUMBER,he.length)}),ce.Version={label:"Version",value:de.tuple(de.Type.NO_HINT,ae.version)};let Be=(g,Ee)=>{let Se={};if(ce[g]=Se,Array.isArray(Ee))Se.children=Ee.map(le=>({value:le}));else{let le={};Se.children=le;for(let[ne,ee]of Object.entries(Ee))typeof ee>"u"||(le[ne]={label:ne,value:ee})}};if(!fe){for(let g of te)await g(ae,u,Be);await r.triggerHook(g=>g.fetchPackageInfo,ae,u,Be)}ae.bin.size>0&&!fe&&Be("Exported Binaries",[...ae.bin.keys()].map(g=>de.tuple(de.Type.PATH,g)));let we=x.get(ae.locatorHash);typeof we<"u"&&we.length>0&&Be("Dependents",we.map(g=>de.tuple(de.Type.LOCATOR,g))),ae.dependencies.size>0&&!fe&&Be("Dependencies",[...ae.dependencies.values()].map(g=>{let Ee=o.storedResolutions.get(g.descriptorHash),Se=typeof Ee<"u"?o.storedPackages.get(Ee)??null:null;return de.tuple(de.Type.RESOLUTION,{descriptor:g,locator:Se})})),ae.peerDependencies.size>0&&fe&&Be("Peer dependencies",[...ae.peerDependencies.values()].map(g=>{let Ee=ae.dependencies.get(g.identHash),Se=typeof Ee<"u"?o.storedResolutions.get(Ee.descriptorHash)??null:null,le=Se!==null?o.storedPackages.get(Se)??null:null;return de.tuple(de.Type.RESOLUTION,{descriptor:g,locator:le})}))}$s.emitTree(L,{configuration:r,json:this.json,stdout:this.context.stdout,separators:this.nameOnly?0:2})}};jh.paths=[["info"]],jh.usage=nt.Usage({description:"see information related to packages",details:"\n This command prints various information related to the specified packages, accepting glob patterns.\n\n By default, if the locator reference is missing, Yarn will default to print the information about all the matching direct dependencies of the package for the active workspace. To instead print all versions of the package that are direct dependencies of any of your workspaces, use the `-A,--all` flag. Adding the `-R,--recursive` flag will also report transitive dependencies.\n\n Some fields will be hidden by default in order to keep the output readable, but can be selectively displayed by using additional options (`--dependents`, `--manifest`, `--virtuals`, ...) described in the option descriptions.\n\n Note that this command will only print the information directly related to the selected packages - if you wish to know why the package is there in the first place, use `yarn why` which will do just that (it also provides a `-R,--recursive` flag that may be of some help).\n ",examples:[["Show information about Lodash","$0 info lodash"]]});Ye();St();Nl();var hk=$e(td());jt();var O8=$e(zn());Za();var q0t=[{selector:t=>t===-1,name:"nodeLinker",value:"node-modules"},{selector:t=>t!==-1&&t<8,name:"enableGlobalCache",value:!1},{selector:t=>t!==-1&&t<8,name:"compressionLevel",value:"mixed"}],Gh=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.immutable=ge.Boolean("--immutable",{description:"Abort with an error exit code if the lockfile was to be modified"});this.immutableCache=ge.Boolean("--immutable-cache",{description:"Abort with an error exit code if the cache folder was to be modified"});this.refreshLockfile=ge.Boolean("--refresh-lockfile",{description:"Refresh the package metadata stored in the lockfile"});this.checkCache=ge.Boolean("--check-cache",{description:"Always refetch the packages and ensure that their checksums are consistent"});this.checkResolutions=ge.Boolean("--check-resolutions",{description:"Validates that the package resolutions are coherent"});this.inlineBuilds=ge.Boolean("--inline-builds",{description:"Verbosely print the output of the build steps of dependencies"});this.mode=ge.String("--mode",{description:"Change what artifacts installs generate",validator:Ks(pl)});this.cacheFolder=ge.String("--cache-folder",{hidden:!0});this.frozenLockfile=ge.Boolean("--frozen-lockfile",{hidden:!0});this.ignoreEngines=ge.Boolean("--ignore-engines",{hidden:!0});this.nonInteractive=ge.Boolean("--non-interactive",{hidden:!0});this.preferOffline=ge.Boolean("--prefer-offline",{hidden:!0});this.production=ge.Boolean("--production",{hidden:!0});this.registry=ge.String("--registry",{hidden:!0});this.silent=ge.Boolean("--silent",{hidden:!0});this.networkTimeout=ge.String("--network-timeout",{hidden:!0})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins);typeof this.inlineBuilds<"u"&&r.useWithSource("<cli>",{enableInlineBuilds:this.inlineBuilds},r.startingCwd,{overwrite:!0});let o=!!process.env.FUNCTION_TARGET||!!process.env.GOOGLE_RUNTIME,a=await LE({configuration:r,stdout:this.context.stdout},[{option:this.ignoreEngines,message:"The --ignore-engines option is deprecated; engine checking isn't a core feature anymore",error:!hk.default.VERCEL},{option:this.registry,message:"The --registry option is deprecated; prefer setting npmRegistryServer in your .yarnrc.yml file"},{option:this.preferOffline,message:"The --prefer-offline flag is deprecated; use the --cached flag with 'yarn add' instead",error:!hk.default.VERCEL},{option:this.production,message:"The --production option is deprecated on 'install'; use 'yarn workspaces focus' instead",error:!0},{option:this.nonInteractive,message:"The --non-interactive option is deprecated",error:!o},{option:this.frozenLockfile,message:"The --frozen-lockfile option is deprecated; use --immutable and/or --immutable-cache instead",callback:()=>this.immutable=this.frozenLockfile},{option:this.cacheFolder,message:"The cache-folder option has been deprecated; use rc settings instead",error:!hk.default.NETLIFY}]);if(a!==null)return a;let n=this.mode==="update-lockfile";if(n&&(this.immutable||this.immutableCache))throw new it(`${de.pretty(r,"--immutable",de.Type.CODE)} and ${de.pretty(r,"--immutable-cache",de.Type.CODE)} cannot be used with ${de.pretty(r,"--mode=update-lockfile",de.Type.CODE)}`);let u=(this.immutable??r.get("enableImmutableInstalls"))&&!n,A=this.immutableCache&&!n;if(r.projectCwd!==null){let R=await Nt.start({configuration:r,json:this.json,stdout:this.context.stdout,includeFooter:!1},async L=>{let U=!1;await K0t(r,u)&&(L.reportInfo(48,"Automatically removed core plugins that are now builtins \u{1F44D}"),U=!0),await W0t(r,u)&&(L.reportInfo(48,"Automatically fixed merge conflicts \u{1F44D}"),U=!0),U&&L.reportSeparator()});if(R.hasErrors())return R.exitCode()}if(r.projectCwd!==null){let R=await Nt.start({configuration:r,json:this.json,stdout:this.context.stdout,includeFooter:!1},async L=>{if(Ke.telemetry?.isNew)Ke.telemetry.commitTips(),L.reportInfo(65,"Yarn will periodically gather anonymous telemetry: https://yarnpkg.com/advanced/telemetry"),L.reportInfo(65,`Run ${de.pretty(r,"yarn config set --home enableTelemetry 0",de.Type.CODE)} to disable`),L.reportSeparator();else if(Ke.telemetry?.shouldShowTips){let U=await nn.get("https://repo.yarnpkg.com/tags",{configuration:r,jsonResponse:!0}).catch(()=>null);if(U!==null){let J=null;if(rn!==null){let ae=O8.default.prerelease(rn)?"canary":"stable",fe=U.latest[ae];O8.default.gt(fe,rn)&&(J=[ae,fe])}if(J)Ke.telemetry.commitTips(),L.reportInfo(88,`${de.applyStyle(r,`A new ${J[0]} version of Yarn is available:`,de.Style.BOLD)} ${W.prettyReference(r,J[1])}!`),L.reportInfo(88,`Upgrade now by running ${de.pretty(r,`yarn set version ${J[1]}`,de.Type.CODE)}`),L.reportSeparator();else{let te=Ke.telemetry.selectTip(U.tips);te&&(L.reportInfo(89,de.pretty(r,te.message,de.Type.MARKDOWN_INLINE)),te.url&&L.reportInfo(89,`Learn more at ${te.url}`),L.reportSeparator())}}}});if(R.hasErrors())return R.exitCode()}let{project:p,workspace:h}=await Pt.find(r,this.context.cwd),E=p.lockfileLastVersion;if(E!==null){let R=await Nt.start({configuration:r,json:this.json,stdout:this.context.stdout,includeFooter:!1},async L=>{let U={};for(let J of q0t)J.selector(E)&&typeof r.sources.get(J.name)>"u"&&(r.use("<compat>",{[J.name]:J.value},p.cwd,{overwrite:!0}),U[J.name]=J.value);Object.keys(U).length>0&&(await Ke.updateConfiguration(p.cwd,U),L.reportInfo(87,"Migrated your project to the latest Yarn version \u{1F680}"),L.reportSeparator())});if(R.hasErrors())return R.exitCode()}let I=await Lr.find(r,{immutable:A,check:this.checkCache});if(!h)throw new rr(p.cwd,this.context.cwd);await p.restoreInstallState({restoreResolutions:!1});let v=r.get("enableHardenedMode");v&&typeof r.sources.get("enableHardenedMode")>"u"&&await Nt.start({configuration:r,json:this.json,stdout:this.context.stdout,includeFooter:!1},async R=>{R.reportWarning(0,"Yarn detected that the current workflow is executed from a public pull request. For safety the hardened mode has been enabled."),R.reportWarning(0,`It will prevent malicious lockfile manipulations, in exchange for a slower install time. You can opt-out if necessary; check our ${de.applyHyperlink(r,"documentation","https://yarnpkg.com/features/security#hardened-mode")} for more details.`),R.reportSeparator()}),(this.refreshLockfile??v)&&(p.lockfileNeedsRefresh=!0);let x=this.checkResolutions??v;return(await Nt.start({configuration:r,json:this.json,stdout:this.context.stdout,forceSectionAlignment:!0,includeLogs:!0,includeVersion:!0},async R=>{await p.install({cache:I,report:R,immutable:u,checkResolutions:x,mode:this.mode})})).exitCode()}};Gh.paths=[["install"],nt.Default],Gh.usage=nt.Usage({description:"install the project dependencies",details:"\n This command sets up your project if needed. The installation is split into four different steps that each have their own characteristics:\n\n - **Resolution:** First the package manager will resolve your dependencies. The exact way a dependency version is privileged over another isn't standardized outside of the regular semver guarantees. If a package doesn't resolve to what you would expect, check that all dependencies are correctly declared (also check our website for more information: ).\n\n - **Fetch:** Then we download all the dependencies if needed, and make sure that they're all stored within our cache (check the value of `cacheFolder` in `yarn config` to see where the cache files are stored).\n\n - **Link:** Then we send the dependency tree information to internal plugins tasked with writing them on the disk in some form (for example by generating the .pnp.cjs file you might know).\n\n - **Build:** Once the dependency tree has been written on the disk, the package manager will now be free to run the build scripts for all packages that might need it, in a topological order compatible with the way they depend on one another. See https://yarnpkg.com/advanced/lifecycle-scripts for detail.\n\n Note that running this command is not part of the recommended workflow. Yarn supports zero-installs, which means that as long as you store your cache and your .pnp.cjs file inside your repository, everything will work without requiring any install right after cloning your repository or switching branches.\n\n If the `--immutable` option is set (defaults to true on CI), Yarn will abort with an error exit code if the lockfile was to be modified (other paths can be added using the `immutablePatterns` configuration setting). For backward compatibility we offer an alias under the name of `--frozen-lockfile`, but it will be removed in a later release.\n\n If the `--immutable-cache` option is set, Yarn will abort with an error exit code if the cache folder was to be modified (either because files would be added, or because they'd be removed).\n\n If the `--refresh-lockfile` option is set, Yarn will keep the same resolution for the packages currently in the lockfile but will refresh their metadata. If used together with `--immutable`, it can validate that the lockfile information are consistent. This flag is enabled by default when Yarn detects it runs within a pull request context.\n\n If the `--check-cache` option is set, Yarn will always refetch the packages and will ensure that their checksum matches what's 1/ described in the lockfile 2/ inside the existing cache files (if present). This is recommended as part of your CI workflow if you're both following the Zero-Installs model and accepting PRs from third-parties, as they'd otherwise have the ability to alter the checked-in packages before submitting them.\n\n If the `--inline-builds` option is set, Yarn will verbosely print the output of the build steps of your dependencies (instead of writing them into individual files). This is likely useful mostly for debug purposes only when using Docker-like environments.\n\n If the `--mode=<mode>` option is set, Yarn will change which artifacts are generated. The modes currently supported are:\n\n - `skip-build` will not run the build scripts at all. Note that this is different from setting `enableScripts` to false because the latter will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run.\n\n - `update-lockfile` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost.\n ",examples:[["Install the project","$0 install"],["Validate a project when using Zero-Installs","$0 install --immutable --immutable-cache"],["Validate a project when using Zero-Installs (slightly safer if you accept external PRs)","$0 install --immutable --immutable-cache --check-cache"]]});var Y0t="<<<<<<<";async function W0t(t,e){if(!t.projectCwd)return!1;let r=V.join(t.projectCwd,dr.lockfile);if(!await oe.existsPromise(r)||!(await oe.readFilePromise(r,"utf8")).includes(Y0t))return!1;if(e)throw new zt(47,"Cannot autofix a lockfile when running an immutable install");let a=await Ur.execvp("git",["rev-parse","MERGE_HEAD","HEAD"],{cwd:t.projectCwd});if(a.code!==0&&(a=await Ur.execvp("git",["rev-parse","REBASE_HEAD","HEAD"],{cwd:t.projectCwd})),a.code!==0&&(a=await Ur.execvp("git",["rev-parse","CHERRY_PICK_HEAD","HEAD"],{cwd:t.projectCwd})),a.code!==0)throw new zt(83,"Git returned an error when trying to find the commits pertaining to the conflict");let n=await Promise.all(a.stdout.trim().split(/\n/).map(async A=>{let p=await Ur.execvp("git",["show",`${A}:./${dr.lockfile}`],{cwd:t.projectCwd});if(p.code!==0)throw new zt(83,`Git returned an error when trying to access the lockfile content in ${A}`);try{return Ki(p.stdout)}catch{throw new zt(46,"A variant of the conflicting lockfile failed to parse")}}));n=n.filter(A=>!!A.__metadata);for(let A of n){if(A.__metadata.version<7)for(let p of Object.keys(A)){if(p==="__metadata")continue;let h=W.parseDescriptor(p,!0),E=t.normalizeDependency(h),I=W.stringifyDescriptor(E);I!==p&&(A[I]=A[p],delete A[p])}for(let p of Object.keys(A)){if(p==="__metadata")continue;let h=A[p].checksum;typeof h=="string"&&h.includes("/")||(A[p].checksum=`${A.__metadata.cacheKey}/${h}`)}}let u=Object.assign({},...n);u.__metadata.version=`${Math.min(...n.map(A=>parseInt(A.__metadata.version??0)))}`,u.__metadata.cacheKey="merged";for(let[A,p]of Object.entries(u))typeof p=="string"&&delete u[A];return await oe.changeFilePromise(r,Ba(u),{automaticNewlines:!0}),!0}async function K0t(t,e){if(!t.projectCwd)return!1;let r=[],o=V.join(t.projectCwd,".yarn/plugins/@yarnpkg");return await Ke.updateConfiguration(t.projectCwd,{plugins:n=>{if(!Array.isArray(n))return n;let u=n.filter(A=>{if(!A.path)return!0;let p=V.resolve(t.projectCwd,A.path),h=v1.has(A.spec)&&V.contains(o,p);return h&&r.push(p),!h});return u.length===0?Ke.deleteProperty:u.length===n.length?n:u}},{immutable:e})?(await Promise.all(r.map(async n=>{await oe.removePromise(n)})),!0):!1}Ye();St();jt();var qh=class extends ut{constructor(){super(...arguments);this.all=ge.Boolean("-A,--all",!1,{description:"Link all workspaces belonging to the target projects to the current one"});this.private=ge.Boolean("-p,--private",!1,{description:"Also link private workspaces belonging to the target projects to the current one"});this.relative=ge.Boolean("-r,--relative",!1,{description:"Link workspaces using relative paths instead of absolute paths"});this.destinations=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd),n=await Lr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState({restoreResolutions:!1});let u=o.topLevelWorkspace,A=[];for(let p of this.destinations){let h=V.resolve(this.context.cwd,ue.toPortablePath(p)),E=await Ke.find(h,this.context.plugins,{useRc:!1,strict:!1}),{project:I,workspace:v}=await Pt.find(E,h);if(o.cwd===I.cwd)throw new it(`Invalid destination '${p}'; Can't link the project to itself`);if(!v)throw new rr(I.cwd,h);if(this.all){let x=!1;for(let C of I.workspaces)C.manifest.name&&(!C.manifest.private||this.private)&&(A.push(C),x=!0);if(!x)throw new it(`No workspace found to be linked in the target project: ${p}`)}else{if(!v.manifest.name)throw new it(`The target workspace at '${p}' doesn't have a name and thus cannot be linked`);if(v.manifest.private&&!this.private)throw new it(`The target workspace at '${p}' is marked private - use the --private flag to link it anyway`);A.push(v)}}for(let p of A){let h=W.stringifyIdent(p.anchoredLocator),E=this.relative?V.relative(o.cwd,p.cwd):p.cwd;u.manifest.resolutions.push({pattern:{descriptor:{fullName:h}},reference:`portal:${E}`})}return await o.installWithNewReport({stdout:this.context.stdout},{cache:n})}};qh.paths=[["link"]],qh.usage=nt.Usage({description:"connect the local project to another one",details:"\n This command will set a new `resolutions` field in the project-level manifest and point it to the workspace at the specified location (even if part of another project).\n ",examples:[["Register one or more remote workspaces for use in the current project","$0 link ~/ts-loader ~/jest"],["Register all workspaces from a remote project for use in the current project","$0 link ~/jest --all"]]});jt();var Yh=class extends ut{constructor(){super(...arguments);this.args=ge.Proxy()}async execute(){return this.cli.run(["exec","node",...this.args])}};Yh.paths=[["node"]],Yh.usage=nt.Usage({description:"run node with the hook already setup",details:` + This command simply runs Node. It also makes sure to call it in a way that's compatible with the current project (for example, on PnP projects the environment will be setup in such a way that PnP will be correctly injected into the environment). + + The Node process will use the exact same version of Node as the one used to run Yarn itself, which might be a good way to ensure that your commands always use a consistent Node version. + `,examples:[["Run a Node script","$0 node ./my-script.js"]]});Ye();jt();var Wh=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=await Ke.findRcFiles(this.context.cwd);return(await Nt.start({configuration:r,json:this.json,stdout:this.context.stdout},async n=>{for(let u of o)if(!!u.data?.plugins)for(let A of u.data.plugins){if(!A.checksum||!A.spec.match(/^https?:/))continue;let p=await nn.get(A.spec,{configuration:r}),h=wn.makeHash(p);if(A.checksum===h)continue;let E=de.pretty(r,A.path,de.Type.PATH),I=de.pretty(r,A.spec,de.Type.URL),v=`${E} is different from the file provided by ${I}`;n.reportJson({...A,newChecksum:h}),n.reportError(0,v)}})).exitCode()}};Wh.paths=[["plugin","check"]],Wh.usage=nt.Usage({category:"Plugin-related commands",description:"find all third-party plugins that differ from their own spec",details:` + Check only the plugins from https. + + If this command detects any plugin differences in the CI environment, it will throw an error. + `,examples:[["find all third-party plugins that differ from their own spec","$0 plugin check"]]});Ye();Ye();St();jt();var nde=ve("os");Ye();St();jt();var $ge=ve("os");Ye();Nl();jt();var V0t="https://raw.githubusercontent.com/yarnpkg/berry/master/plugins.yml";async function zd(t,e){let r=await nn.get(V0t,{configuration:t}),o=Ki(r.toString());return Object.fromEntries(Object.entries(o).filter(([a,n])=>!e||kr.satisfiesWithPrereleases(e,n.range??"<4.0.0-rc.1")))}var Kh=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins);return(await Nt.start({configuration:r,json:this.json,stdout:this.context.stdout},async a=>{let n=await zd(r,rn);for(let[u,{experimental:A,...p}]of Object.entries(n)){let h=u;A&&(h+=" [experimental]"),a.reportJson({name:u,experimental:A,...p}),a.reportInfo(null,h)}})).exitCode()}};Kh.paths=[["plugin","list"]],Kh.usage=nt.Usage({category:"Plugin-related commands",description:"list the available official plugins",details:"\n This command prints the plugins available directly from the Yarn repository. Only those plugins can be referenced by name in `yarn plugin import`.\n ",examples:[["List the official plugins","$0 plugin list"]]});var J0t=/^[0-9]+$/,z0t=process.platform==="win32";function ede(t){return J0t.test(t)?`pull/${t}/head`:t}var X0t=({repository:t,branch:e},r)=>[["git","init",ue.fromPortablePath(r)],["git","remote","add","origin",t],["git","fetch","origin","--depth=1",ede(e)],["git","reset","--hard","FETCH_HEAD"]],Z0t=({branch:t})=>[["git","fetch","origin","--depth=1",ede(t),"--force"],["git","reset","--hard","FETCH_HEAD"],["git","clean","-dfx","-e","packages/yarnpkg-cli/bundles"]],$0t=({plugins:t,noMinify:e},r,o)=>[["yarn","build:cli",...new Array().concat(...t.map(a=>["--plugin",V.resolve(o,a)])),...e?["--no-minify"]:[],"|"],[z0t?"move":"mv","packages/yarnpkg-cli/bundles/yarn.js",ue.fromPortablePath(r),"|"]],Vh=class extends ut{constructor(){super(...arguments);this.installPath=ge.String("--path",{description:"The path where the repository should be cloned to"});this.repository=ge.String("--repository","https://github.com/yarnpkg/berry.git",{description:"The repository that should be cloned"});this.branch=ge.String("--branch","master",{description:"The branch of the repository that should be cloned"});this.plugins=ge.Array("--plugin",[],{description:"An array of additional plugins that should be included in the bundle"});this.dryRun=ge.Boolean("-n,--dry-run",!1,{description:"If set, the bundle will be built but not added to the project"});this.noMinify=ge.Boolean("--no-minify",!1,{description:"Build a bundle for development (debugging) - non-minified and non-mangled"});this.force=ge.Boolean("-f,--force",!1,{description:"Always clone the repository instead of trying to fetch the latest commits"});this.skipPlugins=ge.Boolean("--skip-plugins",!1,{description:"Skip updating the contrib plugins"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o}=await Pt.find(r,this.context.cwd),a=typeof this.installPath<"u"?V.resolve(this.context.cwd,ue.toPortablePath(this.installPath)):V.resolve(ue.toPortablePath((0,$ge.tmpdir)()),"yarnpkg-sources",wn.makeHash(this.repository).slice(0,6));return(await Nt.start({configuration:r,stdout:this.context.stdout},async u=>{await M8(this,{configuration:r,report:u,target:a}),u.reportSeparator(),u.reportInfo(0,"Building a fresh bundle"),u.reportSeparator();let A=await Ur.execvp("git",["rev-parse","--short","HEAD"],{cwd:a,strict:!0}),p=V.join(a,`packages/yarnpkg-cli/bundles/yarn-${A.stdout.trim()}.js`);oe.existsSync(p)||(await E2($0t(this,p,a),{configuration:r,context:this.context,target:a}),u.reportSeparator());let h=await oe.readFilePromise(p);if(!this.dryRun){let{bundleVersion:E}=await L8(r,null,async()=>h,{report:u});this.skipPlugins||await egt(this,E,{project:o,report:u,target:a})}})).exitCode()}};Vh.paths=[["set","version","from","sources"]],Vh.usage=nt.Usage({description:"build Yarn from master",details:` + This command will clone the Yarn repository into a temporary folder, then build it. The resulting bundle will then be copied into the local project. + + By default, it also updates all contrib plugins to the same commit the bundle is built from. This behavior can be disabled by using the \`--skip-plugins\` flag. + `,examples:[["Build Yarn from master","$0 set version from sources"]]});async function E2(t,{configuration:e,context:r,target:o}){for(let[a,...n]of t){let u=n[n.length-1]==="|";if(u&&n.pop(),u)await Ur.pipevp(a,n,{cwd:o,stdin:r.stdin,stdout:r.stdout,stderr:r.stderr,strict:!0});else{r.stdout.write(`${de.pretty(e,` $ ${[a,...n].join(" ")}`,"grey")} +`);try{await Ur.execvp(a,n,{cwd:o,strict:!0})}catch(A){throw r.stdout.write(A.stdout||A.stack),A}}}}async function M8(t,{configuration:e,report:r,target:o}){let a=!1;if(!t.force&&oe.existsSync(V.join(o,".git"))){r.reportInfo(0,"Fetching the latest commits"),r.reportSeparator();try{await E2(Z0t(t),{configuration:e,context:t.context,target:o}),a=!0}catch{r.reportSeparator(),r.reportWarning(0,"Repository update failed; we'll try to regenerate it")}}a||(r.reportInfo(0,"Cloning the remote repository"),r.reportSeparator(),await oe.removePromise(o),await oe.mkdirPromise(o,{recursive:!0}),await E2(X0t(t,o),{configuration:e,context:t.context,target:o}))}async function egt(t,e,{project:r,report:o,target:a}){let n=await zd(r.configuration,e),u=new Set(Object.keys(n));for(let A of r.configuration.plugins.keys())!u.has(A)||await U8(A,t,{project:r,report:o,target:a})}Ye();Ye();St();jt();var tde=$e(zn()),rde=ve("vm");var Jh=class extends ut{constructor(){super(...arguments);this.name=ge.String();this.checksum=ge.Boolean("--checksum",!0,{description:"Whether to care if this plugin is modified"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins);return(await Nt.start({configuration:r,stdout:this.context.stdout},async a=>{let{project:n}=await Pt.find(r,this.context.cwd),u,A;if(this.name.match(/^\.{0,2}[\\/]/)||ue.isAbsolute(this.name)){let p=V.resolve(this.context.cwd,ue.toPortablePath(this.name));a.reportInfo(0,`Reading ${de.pretty(r,p,de.Type.PATH)}`),u=V.relative(n.cwd,p),A=await oe.readFilePromise(p)}else{let p;if(this.name.match(/^https?:/)){try{new URL(this.name)}catch{throw new zt(52,`Plugin specifier "${this.name}" is neither a plugin name nor a valid url`)}u=this.name,p=this.name}else{let h=W.parseLocator(this.name.replace(/^((@yarnpkg\/)?plugin-)?/,"@yarnpkg/plugin-"));if(h.reference!=="unknown"&&!tde.default.valid(h.reference))throw new zt(0,"Official plugins only accept strict version references. Use an explicit URL if you wish to download them from another location.");let E=W.stringifyIdent(h),I=await zd(r,rn);if(!Object.hasOwn(I,E)){let v=`Couldn't find a plugin named ${W.prettyIdent(r,h)} on the remote registry. +`;throw r.plugins.has(E)?v+=`A plugin named ${W.prettyIdent(r,h)} is already installed; possibly attempting to import a built-in plugin.`:v+=`Note that only the plugins referenced on our website (${de.pretty(r,"https://github.com/yarnpkg/berry/blob/master/plugins.yml",de.Type.URL)}) can be referenced by their name; any other plugin will have to be referenced through its public url (for example ${de.pretty(r,"https://github.com/yarnpkg/berry/raw/master/packages/plugin-typescript/bin/%40yarnpkg/plugin-typescript.js",de.Type.URL)}).`,new zt(51,v)}u=E,p=I[E].url,h.reference!=="unknown"?p=p.replace(/\/master\//,`/${E}/${h.reference}/`):rn!==null&&(p=p.replace(/\/master\//,`/@yarnpkg/cli/${rn}/`))}a.reportInfo(0,`Downloading ${de.pretty(r,p,"green")}`),A=await nn.get(p,{configuration:r})}await _8(u,A,{checksum:this.checksum,project:n,report:a})})).exitCode()}};Jh.paths=[["plugin","import"]],Jh.usage=nt.Usage({category:"Plugin-related commands",description:"download a plugin",details:` + This command downloads the specified plugin from its remote location and updates the configuration to reference it in further CLI invocations. + + Three types of plugin references are accepted: + + - If the plugin is stored within the Yarn repository, it can be referenced by name. + - Third-party plugins can be referenced directly through their public urls. + - Local plugins can be referenced by their path on the disk. + + If the \`--no-checksum\` option is set, Yarn will no longer care if the plugin is modified. + + Plugins cannot be downloaded from the npm registry, and aren't allowed to have dependencies (they need to be bundled into a single file, possibly thanks to the \`@yarnpkg/builder\` package). + `,examples:[['Download and activate the "@yarnpkg/plugin-exec" plugin',"$0 plugin import @yarnpkg/plugin-exec"],['Download and activate the "@yarnpkg/plugin-exec" plugin (shorthand)',"$0 plugin import exec"],["Download and activate a community plugin","$0 plugin import https://example.org/path/to/plugin.js"],["Activate a local plugin","$0 plugin import ./path/to/plugin.js"]]});async function _8(t,e,{checksum:r=!0,project:o,report:a}){let{configuration:n}=o,u={},A={exports:u};(0,rde.runInNewContext)(e.toString(),{module:A,exports:u});let h=`.yarn/plugins/${A.exports.name}.cjs`,E=V.resolve(o.cwd,h);a.reportInfo(0,`Saving the new plugin in ${de.pretty(n,h,"magenta")}`),await oe.mkdirPromise(V.dirname(E),{recursive:!0}),await oe.writeFilePromise(E,e);let I={path:h,spec:t};r&&(I.checksum=wn.makeHash(e)),await Ke.addPlugin(o.cwd,[I])}var tgt=({pluginName:t,noMinify:e},r)=>[["yarn",`build:${t}`,...e?["--no-minify"]:[],"|"]],zh=class extends ut{constructor(){super(...arguments);this.installPath=ge.String("--path",{description:"The path where the repository should be cloned to"});this.repository=ge.String("--repository","https://github.com/yarnpkg/berry.git",{description:"The repository that should be cloned"});this.branch=ge.String("--branch","master",{description:"The branch of the repository that should be cloned"});this.noMinify=ge.Boolean("--no-minify",!1,{description:"Build a plugin for development (debugging) - non-minified and non-mangled"});this.force=ge.Boolean("-f,--force",!1,{description:"Always clone the repository instead of trying to fetch the latest commits"});this.name=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=typeof this.installPath<"u"?V.resolve(this.context.cwd,ue.toPortablePath(this.installPath)):V.resolve(ue.toPortablePath((0,nde.tmpdir)()),"yarnpkg-sources",wn.makeHash(this.repository).slice(0,6));return(await Nt.start({configuration:r,stdout:this.context.stdout},async n=>{let{project:u}=await Pt.find(r,this.context.cwd),A=W.parseIdent(this.name.replace(/^((@yarnpkg\/)?plugin-)?/,"@yarnpkg/plugin-")),p=W.stringifyIdent(A),h=await zd(r,rn);if(!Object.hasOwn(h,p))throw new zt(51,`Couldn't find a plugin named "${p}" on the remote registry. Note that only the plugins referenced on our website (https://github.com/yarnpkg/berry/blob/master/plugins.yml) can be built and imported from sources.`);let E=p;await M8(this,{configuration:r,report:n,target:o}),await U8(E,this,{project:u,report:n,target:o})})).exitCode()}};zh.paths=[["plugin","import","from","sources"]],zh.usage=nt.Usage({category:"Plugin-related commands",description:"build a plugin from sources",details:` + This command clones the Yarn repository into a temporary folder, builds the specified contrib plugin and updates the configuration to reference it in further CLI invocations. + + The plugins can be referenced by their short name if sourced from the official Yarn repository. + `,examples:[['Build and activate the "@yarnpkg/plugin-exec" plugin',"$0 plugin import from sources @yarnpkg/plugin-exec"],['Build and activate the "@yarnpkg/plugin-exec" plugin (shorthand)',"$0 plugin import from sources exec"]]});async function U8(t,{context:e,noMinify:r},{project:o,report:a,target:n}){let u=t.replace(/@yarnpkg\//,""),{configuration:A}=o;a.reportSeparator(),a.reportInfo(0,`Building a fresh ${u}`),a.reportSeparator(),await E2(tgt({pluginName:u,noMinify:r},n),{configuration:A,context:e,target:n}),a.reportSeparator();let p=V.resolve(n,`packages/${u}/bundles/${t}.js`),h=await oe.readFilePromise(p);await _8(t,h,{project:o,report:a})}Ye();St();jt();var Xh=class extends ut{constructor(){super(...arguments);this.name=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o}=await Pt.find(r,this.context.cwd);return(await Nt.start({configuration:r,stdout:this.context.stdout},async n=>{let u=this.name,A=W.parseIdent(u);if(!r.plugins.has(u))throw new it(`${W.prettyIdent(r,A)} isn't referenced by the current configuration`);let p=`.yarn/plugins/${u}.cjs`,h=V.resolve(o.cwd,p);oe.existsSync(h)&&(n.reportInfo(0,`Removing ${de.pretty(r,p,de.Type.PATH)}...`),await oe.removePromise(h)),n.reportInfo(0,"Updating the configuration..."),await Ke.updateConfiguration(o.cwd,{plugins:E=>{if(!Array.isArray(E))return E;let I=E.filter(v=>v.path!==p);return I.length===0?Ke.deleteProperty:I.length===E.length?E:I}})})).exitCode()}};Xh.paths=[["plugin","remove"]],Xh.usage=nt.Usage({category:"Plugin-related commands",description:"remove a plugin",details:` + This command deletes the specified plugin from the .yarn/plugins folder and removes it from the configuration. + + **Note:** The plugins have to be referenced by their name property, which can be obtained using the \`yarn plugin runtime\` command. Shorthands are not allowed. + `,examples:[["Remove a plugin imported from the Yarn repository","$0 plugin remove @yarnpkg/plugin-typescript"],["Remove a plugin imported from a local file","$0 plugin remove my-local-plugin"]]});Ye();jt();var Zh=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins);return(await Nt.start({configuration:r,json:this.json,stdout:this.context.stdout},async a=>{for(let n of r.plugins.keys()){let u=this.context.plugins.plugins.has(n),A=n;u&&(A+=" [builtin]"),a.reportJson({name:n,builtin:u}),a.reportInfo(null,`${A}`)}})).exitCode()}};Zh.paths=[["plugin","runtime"]],Zh.usage=nt.Usage({category:"Plugin-related commands",description:"list the active plugins",details:` + This command prints the currently active plugins. Will be displayed both builtin plugins and external plugins. + `,examples:[["List the currently active plugins","$0 plugin runtime"]]});Ye();Ye();jt();var $h=class extends ut{constructor(){super(...arguments);this.idents=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd),n=await Lr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);let u=new Set;for(let A of this.idents)u.add(W.parseIdent(A).identHash);if(await o.restoreInstallState({restoreResolutions:!1}),await o.resolveEverything({cache:n,report:new Qi}),u.size>0)for(let A of o.storedPackages.values())u.has(A.identHash)&&(o.storedBuildState.delete(A.locatorHash),o.skippedBuilds.delete(A.locatorHash));else o.storedBuildState.clear(),o.skippedBuilds.clear();return await o.installWithNewReport({stdout:this.context.stdout,quiet:this.context.quiet},{cache:n})}};$h.paths=[["rebuild"]],$h.usage=nt.Usage({description:"rebuild the project's native packages",details:` + This command will automatically cause Yarn to forget about previous compilations of the given packages and to run them again. + + Note that while Yarn forgets the compilation, the previous artifacts aren't erased from the filesystem and may affect the next builds (in good or bad). To avoid this, you may remove the .yarn/unplugged folder, or any other relevant location where packages might have been stored (Yarn may offer a way to do that automatically in the future). + + By default all packages will be rebuilt, but you can filter the list by specifying the names of the packages you want to clear from memory. + `,examples:[["Rebuild all packages","$0 rebuild"],["Rebuild fsevents only","$0 rebuild fsevents"]]});Ye();Ye();Ye();jt();var H8=$e(Zo());Za();var e0=class extends ut{constructor(){super(...arguments);this.all=ge.Boolean("-A,--all",!1,{description:"Apply the operation to all workspaces from the current project"});this.mode=ge.String("--mode",{description:"Change what artifacts installs generate",validator:Ks(pl)});this.patterns=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd),n=await Lr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState({restoreResolutions:!1});let u=this.all?o.workspaces:[a],A=["dependencies","devDependencies","peerDependencies"],p=[],h=!1,E=[];for(let C of this.patterns){let R=!1,L=W.parseIdent(C);for(let U of u){let J=[...U.manifest.peerDependenciesMeta.keys()];for(let te of(0,H8.default)(J,C))U.manifest.peerDependenciesMeta.delete(te),h=!0,R=!0;for(let te of A){let ae=U.manifest.getForScope(te),fe=[...ae.values()].map(ce=>W.stringifyIdent(ce));for(let ce of(0,H8.default)(fe,W.stringifyIdent(L))){let{identHash:me}=W.parseIdent(ce),he=ae.get(me);if(typeof he>"u")throw new Error("Assertion failed: Expected the descriptor to be registered");U.manifest[te].delete(me),E.push([U,te,he]),h=!0,R=!0}}}R||p.push(C)}let I=p.length>1?"Patterns":"Pattern",v=p.length>1?"don't":"doesn't",x=this.all?"any":"this";if(p.length>0)throw new it(`${I} ${de.prettyList(r,p,de.Type.CODE)} ${v} match any packages referenced by ${x} workspace`);return h?(await r.triggerMultipleHooks(C=>C.afterWorkspaceDependencyRemoval,E),await o.installWithNewReport({stdout:this.context.stdout},{cache:n,mode:this.mode})):0}};e0.paths=[["remove"]],e0.usage=nt.Usage({description:"remove dependencies from the project",details:` + This command will remove the packages matching the specified patterns from the current workspace. + + If the \`--mode=<mode>\` option is set, Yarn will change which artifacts are generated. The modes currently supported are: + + - \`skip-build\` will not run the build scripts at all. Note that this is different from setting \`enableScripts\` to false because the latter will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run. + + - \`update-lockfile\` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost. + + This command accepts glob patterns as arguments (if valid Idents and supported by [micromatch](https://github.com/micromatch/micromatch)). Make sure to escape the patterns, to prevent your own shell from trying to expand them. + `,examples:[["Remove a dependency from the current project","$0 remove lodash"],["Remove a dependency from all workspaces at once","$0 remove lodash --all"],["Remove all dependencies starting with `eslint-`","$0 remove 'eslint-*'"],["Remove all dependencies with the `@babel` scope","$0 remove '@babel/*'"],["Remove all dependencies matching `react-dom` or `react-helmet`","$0 remove 'react-{dom,helmet}'"]]});Ye();Ye();jt();var ide=ve("util"),Xd=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);return(await Nt.start({configuration:r,stdout:this.context.stdout,json:this.json},async u=>{let A=a.manifest.scripts,p=_e.sortMap(A.keys(),I=>I),h={breakLength:1/0,colors:r.get("enableColors"),maxArrayLength:2},E=p.reduce((I,v)=>Math.max(I,v.length),0);for(let[I,v]of A.entries())u.reportInfo(null,`${I.padEnd(E," ")} ${(0,ide.inspect)(v,h)}`),u.reportJson({name:I,script:v})})).exitCode()}};Xd.paths=[["run"]];Ye();Ye();jt();var t0=class extends ut{constructor(){super(...arguments);this.inspect=ge.String("--inspect",!1,{tolerateBoolean:!0,description:"Forwarded to the underlying Node process when executing a binary"});this.inspectBrk=ge.String("--inspect-brk",!1,{tolerateBoolean:!0,description:"Forwarded to the underlying Node process when executing a binary"});this.topLevel=ge.Boolean("-T,--top-level",!1,{description:"Check the root workspace for scripts and/or binaries instead of the current one"});this.binariesOnly=ge.Boolean("-B,--binaries-only",!1,{description:"Ignore any user defined scripts and only check for binaries"});this.require=ge.String("--require",{description:"Forwarded to the underlying Node process when executing a binary"});this.silent=ge.Boolean("--silent",{hidden:!0});this.scriptName=ge.String();this.args=ge.Proxy()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a,locator:n}=await Pt.find(r,this.context.cwd);await o.restoreInstallState();let u=this.topLevel?o.topLevelWorkspace.anchoredLocator:n;if(!this.binariesOnly&&await un.hasPackageScript(u,this.scriptName,{project:o}))return await un.executePackageScript(u,this.scriptName,this.args,{project:o,stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr});let A=await un.getPackageAccessibleBinaries(u,{project:o});if(A.get(this.scriptName)){let h=[];return this.inspect&&(typeof this.inspect=="string"?h.push(`--inspect=${this.inspect}`):h.push("--inspect")),this.inspectBrk&&(typeof this.inspectBrk=="string"?h.push(`--inspect-brk=${this.inspectBrk}`):h.push("--inspect-brk")),this.require&&h.push(`--require=${this.require}`),await un.executePackageAccessibleBinary(u,this.scriptName,this.args,{cwd:this.context.cwd,project:o,stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr,nodeArgs:h,packageAccessibleBinaries:A})}if(!this.topLevel&&!this.binariesOnly&&a&&this.scriptName.includes(":")){let E=(await Promise.all(o.workspaces.map(async I=>I.manifest.scripts.has(this.scriptName)?I:null))).filter(I=>I!==null);if(E.length===1)return await un.executeWorkspaceScript(E[0],this.scriptName,this.args,{stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr})}if(this.topLevel)throw this.scriptName==="node-gyp"?new it(`Couldn't find a script name "${this.scriptName}" in the top-level (used by ${W.prettyLocator(r,n)}). This typically happens because some package depends on "node-gyp" to build itself, but didn't list it in their dependencies. To fix that, please run "yarn add node-gyp" into your top-level workspace. You also can open an issue on the repository of the specified package to suggest them to use an optional peer dependency.`):new it(`Couldn't find a script name "${this.scriptName}" in the top-level (used by ${W.prettyLocator(r,n)}).`);{if(this.scriptName==="global")throw new it("The 'yarn global' commands have been removed in 2.x - consider using 'yarn dlx' or a third-party plugin instead");let h=[this.scriptName].concat(this.args);for(let[E,I]of AC)for(let v of I)if(h.length>=v.length&&JSON.stringify(h.slice(0,v.length))===JSON.stringify(v))throw new it(`Couldn't find a script named "${this.scriptName}", but a matching command can be found in the ${E} plugin. You can install it with "yarn plugin import ${E}".`);throw new it(`Couldn't find a script named "${this.scriptName}".`)}}};t0.paths=[["run"]],t0.usage=nt.Usage({description:"run a script defined in the package.json",details:` + This command will run a tool. The exact tool that will be executed will depend on the current state of your workspace: + + - If the \`scripts\` field from your local package.json contains a matching script name, its definition will get executed. + + - Otherwise, if one of the local workspace's dependencies exposes a binary with a matching name, this binary will get executed. + + - Otherwise, if the specified name contains a colon character and if one of the workspaces in the project contains exactly one script with a matching name, then this script will get executed. + + Whatever happens, the cwd of the spawned process will be the workspace that declares the script (which makes it possible to call commands cross-workspaces using the third syntax). + `,examples:[["Run the tests from the local workspace","$0 run test"],['Same thing, but without the "run" keyword',"$0 test"],["Inspect Webpack while running","$0 run --inspect-brk webpack"]]});Ye();Ye();jt();var r0=class extends ut{constructor(){super(...arguments);this.descriptor=ge.String();this.resolution=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd),n=await Lr.find(r);if(await o.restoreInstallState({restoreResolutions:!1}),!a)throw new rr(o.cwd,this.context.cwd);let u=W.parseDescriptor(this.descriptor,!0),A=W.makeDescriptor(u,this.resolution);return o.storedDescriptors.set(u.descriptorHash,u),o.storedDescriptors.set(A.descriptorHash,A),o.resolutionAliases.set(u.descriptorHash,A.descriptorHash),await o.installWithNewReport({stdout:this.context.stdout},{cache:n})}};r0.paths=[["set","resolution"]],r0.usage=nt.Usage({description:"enforce a package resolution",details:'\n This command updates the resolution table so that `descriptor` is resolved by `resolution`.\n\n Note that by default this command only affect the current resolution table - meaning that this "manual override" will disappear if you remove the lockfile, or if the package disappear from the table. If you wish to make the enforced resolution persist whatever happens, edit the `resolutions` field in your top-level manifest.\n\n Note that no attempt is made at validating that `resolution` is a valid resolution entry for `descriptor`.\n ',examples:[["Force all instances of lodash@npm:^1.2.3 to resolve to 1.5.0","$0 set resolution lodash@npm:^1.2.3 1.5.0"]]});Ye();St();jt();var sde=$e(Zo()),n0=class extends ut{constructor(){super(...arguments);this.all=ge.Boolean("-A,--all",!1,{description:"Unlink all workspaces belonging to the target project from the current one"});this.leadingArguments=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd),n=await Lr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);let u=o.topLevelWorkspace,A=new Set;if(this.leadingArguments.length===0&&this.all)for(let{pattern:p,reference:h}of u.manifest.resolutions)h.startsWith("portal:")&&A.add(p.descriptor.fullName);if(this.leadingArguments.length>0)for(let p of this.leadingArguments){let h=V.resolve(this.context.cwd,ue.toPortablePath(p));if(_e.isPathLike(p)){let E=await Ke.find(h,this.context.plugins,{useRc:!1,strict:!1}),{project:I,workspace:v}=await Pt.find(E,h);if(!v)throw new rr(I.cwd,h);if(this.all){for(let x of I.workspaces)x.manifest.name&&A.add(W.stringifyIdent(x.anchoredLocator));if(A.size===0)throw new it("No workspace found to be unlinked in the target project")}else{if(!v.manifest.name)throw new it("The target workspace doesn't have a name and thus cannot be unlinked");A.add(W.stringifyIdent(v.anchoredLocator))}}else{let E=[...u.manifest.resolutions.map(({pattern:I})=>I.descriptor.fullName)];for(let I of(0,sde.default)(E,p))A.add(I)}}return u.manifest.resolutions=u.manifest.resolutions.filter(({pattern:p})=>!A.has(p.descriptor.fullName)),await o.installWithNewReport({stdout:this.context.stdout,quiet:this.context.quiet},{cache:n})}};n0.paths=[["unlink"]],n0.usage=nt.Usage({description:"disconnect the local project from another one",details:` + This command will remove any resolutions in the project-level manifest that would have been added via a yarn link with similar arguments. + `,examples:[["Unregister a remote workspace in the current project","$0 unlink ~/ts-loader"],["Unregister all workspaces from a remote project in the current project","$0 unlink ~/jest --all"],["Unregister all previously linked workspaces","$0 unlink --all"],["Unregister all workspaces matching a glob","$0 unlink '@babel/*' 'pkg-{a,b}'"]]});Ye();Ye();Ye();jt();var ode=$e(f2()),j8=$e(Zo());Za();var Jf=class extends ut{constructor(){super(...arguments);this.interactive=ge.Boolean("-i,--interactive",{description:"Offer various choices, depending on the detected upgrade paths"});this.fixed=ge.Boolean("-F,--fixed",!1,{description:"Store dependency tags as-is instead of resolving them"});this.exact=ge.Boolean("-E,--exact",!1,{description:"Don't use any semver modifier on the resolved range"});this.tilde=ge.Boolean("-T,--tilde",!1,{description:"Use the `~` semver modifier on the resolved range"});this.caret=ge.Boolean("-C,--caret",!1,{description:"Use the `^` semver modifier on the resolved range"});this.recursive=ge.Boolean("-R,--recursive",!1,{description:"Resolve again ALL resolutions for those packages"});this.mode=ge.String("--mode",{description:"Change what artifacts installs generate",validator:Ks(pl)});this.patterns=ge.Rest()}async execute(){return this.recursive?await this.executeUpRecursive():await this.executeUpClassic()}async executeUpRecursive(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd),n=await Lr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState({restoreResolutions:!1});let u=[...o.storedDescriptors.values()],A=u.map(E=>W.stringifyIdent(E)),p=new Set;for(let E of this.patterns){if(W.parseDescriptor(E).range!=="unknown")throw new it("Ranges aren't allowed when using --recursive");for(let I of(0,j8.default)(A,E)){let v=W.parseIdent(I);p.add(v.identHash)}}let h=u.filter(E=>p.has(E.identHash));for(let E of h)o.storedDescriptors.delete(E.descriptorHash),o.storedResolutions.delete(E.descriptorHash);return await o.installWithNewReport({stdout:this.context.stdout},{cache:n,mode:this.mode})}async executeUpClassic(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd),n=await Lr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState({restoreResolutions:!1});let u=this.fixed,A=this.interactive??r.get("preferInteractive"),p=h2(this,o),h=A?["keep","reuse","project","latest"]:["project","latest"],E=[],I=[];for(let L of this.patterns){let U=!1,J=W.parseDescriptor(L),te=W.stringifyIdent(J);for(let ae of o.workspaces)for(let fe of["dependencies","devDependencies"]){let me=[...ae.manifest.getForScope(fe).values()].map(Be=>W.stringifyIdent(Be)),he=te==="*"?me:(0,j8.default)(me,te);for(let Be of he){let we=W.parseIdent(Be),g=ae.manifest[fe].get(we.identHash);if(typeof g>"u")throw new Error("Assertion failed: Expected the descriptor to be registered");let Ee=W.makeDescriptor(we,J.range);E.push(Promise.resolve().then(async()=>[ae,fe,g,await g2(Ee,{project:o,workspace:ae,cache:n,target:fe,fixed:u,modifier:p,strategies:h})])),U=!0}}U||I.push(L)}if(I.length>1)throw new it(`Patterns ${de.prettyList(r,I,de.Type.CODE)} don't match any packages referenced by any workspace`);if(I.length>0)throw new it(`Pattern ${de.prettyList(r,I,de.Type.CODE)} doesn't match any packages referenced by any workspace`);let v=await Promise.all(E),x=await AA.start({configuration:r,stdout:this.context.stdout,suggestInstall:!1},async L=>{for(let[,,U,{suggestions:J,rejections:te}]of v){let ae=J.filter(fe=>fe.descriptor!==null);if(ae.length===0){let[fe]=te;if(typeof fe>"u")throw new Error("Assertion failed: Expected an error to have been set");let ce=this.cli.error(fe);o.configuration.get("enableNetwork")?L.reportError(27,`${W.prettyDescriptor(r,U)} can't be resolved to a satisfying range + +${ce}`):L.reportError(27,`${W.prettyDescriptor(r,U)} can't be resolved to a satisfying range (note: network resolution has been disabled) + +${ce}`)}else ae.length>1&&!A&&L.reportError(27,`${W.prettyDescriptor(r,U)} has multiple possible upgrade strategies; use -i to disambiguate manually`)}});if(x.hasErrors())return x.exitCode();let C=!1,R=[];for(let[L,U,,{suggestions:J}]of v){let te,ae=J.filter(he=>he.descriptor!==null),fe=ae[0].descriptor,ce=ae.every(he=>W.areDescriptorsEqual(he.descriptor,fe));ae.length===1||ce?te=fe:(C=!0,{answer:te}=await(0,ode.prompt)({type:"select",name:"answer",message:`Which range do you want to use in ${W.prettyWorkspace(r,L)} \u276F ${U}?`,choices:J.map(({descriptor:he,name:Be,reason:we})=>he?{name:Be,hint:we,descriptor:he}:{name:Be,hint:we,disabled:!0}),onCancel:()=>process.exit(130),result(he){return this.find(he,"descriptor")},stdin:this.context.stdin,stdout:this.context.stdout}));let me=L.manifest[U].get(te.identHash);if(typeof me>"u")throw new Error("Assertion failed: This descriptor should have a matching entry");if(me.descriptorHash!==te.descriptorHash)L.manifest[U].set(te.identHash,te),R.push([L,U,me,te]);else{let he=r.makeResolver(),Be={project:o,resolver:he},we=r.normalizeDependency(me),g=he.bindDescriptor(we,L.anchoredLocator,Be);o.forgetResolution(g)}}return await r.triggerMultipleHooks(L=>L.afterWorkspaceDependencyReplacement,R),C&&this.context.stdout.write(` +`),await o.installWithNewReport({stdout:this.context.stdout},{cache:n,mode:this.mode})}};Jf.paths=[["up"]],Jf.usage=nt.Usage({description:"upgrade dependencies across the project",details:"\n This command upgrades the packages matching the list of specified patterns to their latest available version across the whole project (regardless of whether they're part of `dependencies` or `devDependencies` - `peerDependencies` won't be affected). This is a project-wide command: all workspaces will be upgraded in the process.\n\n If `-R,--recursive` is set the command will change behavior and no other switch will be allowed. When operating under this mode `yarn up` will force all ranges matching the selected packages to be resolved again (often to the highest available versions) before being stored in the lockfile. It however won't touch your manifests anymore, so depending on your needs you might want to run both `yarn up` and `yarn up -R` to cover all bases.\n\n If `-i,--interactive` is set (or if the `preferInteractive` settings is toggled on) the command will offer various choices, depending on the detected upgrade paths. Some upgrades require this flag in order to resolve ambiguities.\n\n The, `-C,--caret`, `-E,--exact` and `-T,--tilde` options have the same meaning as in the `add` command (they change the modifier used when the range is missing or a tag, and are ignored when the range is explicitly set).\n\n If the `--mode=<mode>` option is set, Yarn will change which artifacts are generated. The modes currently supported are:\n\n - `skip-build` will not run the build scripts at all. Note that this is different from setting `enableScripts` to false because the latter will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run.\n\n - `update-lockfile` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost.\n\n Generally you can see `yarn up` as a counterpart to what was `yarn upgrade --latest` in Yarn 1 (ie it ignores the ranges previously listed in your manifests), but unlike `yarn upgrade` which only upgraded dependencies in the current workspace, `yarn up` will upgrade all workspaces at the same time.\n\n This command accepts glob patterns as arguments (if valid Descriptors and supported by [micromatch](https://github.com/micromatch/micromatch)). Make sure to escape the patterns, to prevent your own shell from trying to expand them.\n\n **Note:** The ranges have to be static, only the package scopes and names can contain glob patterns.\n ",examples:[["Upgrade all instances of lodash to the latest release","$0 up lodash"],["Upgrade all instances of lodash to the latest release, but ask confirmation for each","$0 up lodash -i"],["Upgrade all instances of lodash to 1.2.3","$0 up lodash@1.2.3"],["Upgrade all instances of packages with the `@babel` scope to the latest release","$0 up '@babel/*'"],["Upgrade all instances of packages containing the word `jest` to the latest release","$0 up '*jest*'"],["Upgrade all instances of packages with the `@babel` scope to 7.0.0","$0 up '@babel/*@7.0.0'"]]}),Jf.schema=[cI("recursive",qu.Forbids,["interactive","exact","tilde","caret"],{ignore:[void 0,!1]})];Ye();Ye();Ye();jt();var i0=class extends ut{constructor(){super(...arguments);this.recursive=ge.Boolean("-R,--recursive",!1,{description:"List, for each workspace, what are all the paths that lead to the dependency"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.peers=ge.Boolean("--peers",!1,{description:"Also print the peer dependencies that match the specified name"});this.package=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState();let n=W.parseIdent(this.package).identHash,u=this.recursive?ngt(o,n,{configuration:r,peers:this.peers}):rgt(o,n,{configuration:r,peers:this.peers});$s.emitTree(u,{configuration:r,stdout:this.context.stdout,json:this.json,separators:1})}};i0.paths=[["why"]],i0.usage=nt.Usage({description:"display the reason why a package is needed",details:` + This command prints the exact reasons why a package appears in the dependency tree. + + If \`-R,--recursive\` is set, the listing will go in depth and will list, for each workspaces, what are all the paths that lead to the dependency. Note that the display is somewhat optimized in that it will not print the package listing twice for a single package, so if you see a leaf named "Foo" when looking for "Bar", it means that "Foo" already got printed higher in the tree. + `,examples:[["Explain why lodash is used in your project","$0 why lodash"]]});function rgt(t,e,{configuration:r,peers:o}){let a=_e.sortMap(t.storedPackages.values(),A=>W.stringifyLocator(A)),n={},u={children:n};for(let A of a){let p={};for(let E of A.dependencies.values()){if(!o&&A.peerDependencies.has(E.identHash))continue;let I=t.storedResolutions.get(E.descriptorHash);if(!I)throw new Error("Assertion failed: The resolution should have been registered");let v=t.storedPackages.get(I);if(!v)throw new Error("Assertion failed: The package should have been registered");if(v.identHash!==e)continue;{let C=W.stringifyLocator(A);n[C]={value:[A,de.Type.LOCATOR],children:p}}let x=W.stringifyLocator(v);p[x]={value:[{descriptor:E,locator:v},de.Type.DEPENDENT]}}}return u}function ngt(t,e,{configuration:r,peers:o}){let a=_e.sortMap(t.workspaces,v=>W.stringifyLocator(v.anchoredLocator)),n=new Set,u=new Set,A=v=>{if(n.has(v.locatorHash))return u.has(v.locatorHash);if(n.add(v.locatorHash),v.identHash===e)return u.add(v.locatorHash),!0;let x=!1;v.identHash===e&&(x=!0);for(let C of v.dependencies.values()){if(!o&&v.peerDependencies.has(C.identHash))continue;let R=t.storedResolutions.get(C.descriptorHash);if(!R)throw new Error("Assertion failed: The resolution should have been registered");let L=t.storedPackages.get(R);if(!L)throw new Error("Assertion failed: The package should have been registered");A(L)&&(x=!0)}return x&&u.add(v.locatorHash),x};for(let v of a)A(v.anchoredPackage);let p=new Set,h={},E={children:h},I=(v,x,C)=>{if(!u.has(v.locatorHash))return;let R=C!==null?de.tuple(de.Type.DEPENDENT,{locator:v,descriptor:C}):de.tuple(de.Type.LOCATOR,v),L={},U={value:R,children:L},J=W.stringifyLocator(v);if(x[J]=U,!p.has(v.locatorHash)&&(p.add(v.locatorHash),!(C!==null&&t.tryWorkspaceByLocator(v))))for(let te of v.dependencies.values()){if(!o&&v.peerDependencies.has(te.identHash))continue;let ae=t.storedResolutions.get(te.descriptorHash);if(!ae)throw new Error("Assertion failed: The resolution should have been registered");let fe=t.storedPackages.get(ae);if(!fe)throw new Error("Assertion failed: The package should have been registered");I(fe,L,te)}};for(let v of a)I(v.anchoredPackage,h,null);return E}Ye();var Z8={};Vt(Z8,{GitFetcher:()=>w2,GitResolver:()=>I2,default:()=>vgt,gitUtils:()=>ra});Ye();St();var ra={};Vt(ra,{TreeishProtocols:()=>C2,clone:()=>X8,fetchBase:()=>bde,fetchChangedFiles:()=>xde,fetchChangedWorkspaces:()=>Igt,fetchRoot:()=>Pde,isGitUrl:()=>CC,lsRemote:()=>Sde,normalizeLocator:()=>wgt,normalizeRepoUrl:()=>yC,resolveUrl:()=>z8,splitRepoUrl:()=>s0,validateRepoUrl:()=>J8});Ye();St();jt();var Bde=$e(Cde()),vde=$e(mU()),EC=$e(ve("querystring")),K8=$e(zn());function W8(t,e,r){let o=t.indexOf(r);return t.lastIndexOf(e,o>-1?o:1/0)}function wde(t){try{return new URL(t)}catch{return}}function Egt(t){let e=W8(t,"@","#"),r=W8(t,":","#");return r>e&&(t=`${t.slice(0,r)}/${t.slice(r+1)}`),W8(t,":","#")===-1&&t.indexOf("//")===-1&&(t=`ssh://${t}`),t}function Ide(t){return wde(t)||wde(Egt(t))}function yC(t,{git:e=!1}={}){if(t=t.replace(/^git\+https:/,"https:"),t=t.replace(/^(?:github:|https:\/\/github\.com\/|git:\/\/github\.com\/)?(?!\.{1,2}\/)([a-zA-Z0-9._-]+)\/(?!\.{1,2}(?:#|$))([a-zA-Z0-9._-]+?)(?:\.git)?(#.*)?$/,"https://github.com/$1/$2.git$3"),t=t.replace(/^https:\/\/github\.com\/(?!\.{1,2}\/)([a-zA-Z0-9._-]+)\/(?!\.{1,2}(?:#|$))([a-zA-Z0-9._-]+?)\/tarball\/(.+)?$/,"https://github.com/$1/$2.git#$3"),e){let r=Ide(t);r&&(t=r.href),t=t.replace(/^git\+([^:]+):/,"$1:")}return t}function Dde(){return{...process.env,GIT_SSH_COMMAND:process.env.GIT_SSH_COMMAND||`${process.env.GIT_SSH||"ssh"} -o BatchMode=yes`}}var Cgt=[/^ssh:/,/^git(?:\+[^:]+)?:/,/^(?:git\+)?https?:[^#]+\/[^#]+(?:\.git)(?:#.*)?$/,/^git@[^#]+\/[^#]+\.git(?:#.*)?$/,/^(?:github:|https:\/\/github\.com\/)?(?!\.{1,2}\/)([a-zA-Z._0-9-]+)\/(?!\.{1,2}(?:#|$))([a-zA-Z._0-9-]+?)(?:\.git)?(?:#.*)?$/,/^https:\/\/github\.com\/(?!\.{1,2}\/)([a-zA-Z0-9._-]+)\/(?!\.{1,2}(?:#|$))([a-zA-Z0-9._-]+?)\/tarball\/(.+)?$/],C2=(a=>(a.Commit="commit",a.Head="head",a.Tag="tag",a.Semver="semver",a))(C2||{});function CC(t){return t?Cgt.some(e=>!!t.match(e)):!1}function s0(t){t=yC(t);let e=t.indexOf("#");if(e===-1)return{repo:t,treeish:{protocol:"head",request:"HEAD"},extra:{}};let r=t.slice(0,e),o=t.slice(e+1);if(o.match(/^[a-z]+=/)){let a=EC.default.parse(o);for(let[p,h]of Object.entries(a))if(typeof h!="string")throw new Error(`Assertion failed: The ${p} parameter must be a literal string`);let n=Object.values(C2).find(p=>Object.hasOwn(a,p)),[u,A]=typeof n<"u"?[n,a[n]]:["head","HEAD"];for(let p of Object.values(C2))delete a[p];return{repo:r,treeish:{protocol:u,request:A},extra:a}}else{let a=o.indexOf(":"),[n,u]=a===-1?[null,o]:[o.slice(0,a),o.slice(a+1)];return{repo:r,treeish:{protocol:n,request:u},extra:{}}}}function wgt(t){return W.makeLocator(t,yC(t.reference))}function J8(t,{configuration:e}){let r=yC(t,{git:!0});if(!nn.getNetworkSettings(`https://${(0,Bde.default)(r).resource}`,{configuration:e}).enableNetwork)throw new zt(80,`Request to '${r}' has been blocked because of your configuration settings`);return r}async function Sde(t,e){let r=J8(t,{configuration:e}),o=await V8("listing refs",["ls-remote",r],{cwd:e.startingCwd,env:Dde()},{configuration:e,normalizedRepoUrl:r}),a=new Map,n=/^([a-f0-9]{40})\t([^\n]+)/gm,u;for(;(u=n.exec(o.stdout))!==null;)a.set(u[2],u[1]);return a}async function z8(t,e){let{repo:r,treeish:{protocol:o,request:a},extra:n}=s0(t),u=await Sde(r,e),A=(h,E)=>{switch(h){case"commit":{if(!E.match(/^[a-f0-9]{40}$/))throw new Error("Invalid commit hash");return EC.default.stringify({...n,commit:E})}case"head":{let I=u.get(E==="HEAD"?E:`refs/heads/${E}`);if(typeof I>"u")throw new Error(`Unknown head ("${E}")`);return EC.default.stringify({...n,commit:I})}case"tag":{let I=u.get(`refs/tags/${E}`);if(typeof I>"u")throw new Error(`Unknown tag ("${E}")`);return EC.default.stringify({...n,commit:I})}case"semver":{let I=kr.validRange(E);if(!I)throw new Error(`Invalid range ("${E}")`);let v=new Map([...u.entries()].filter(([C])=>C.startsWith("refs/tags/")).map(([C,R])=>[K8.default.parse(C.slice(10)),R]).filter(C=>C[0]!==null)),x=K8.default.maxSatisfying([...v.keys()],I);if(x===null)throw new Error(`No matching range ("${E}")`);return EC.default.stringify({...n,commit:v.get(x)})}case null:{let I;if((I=p("commit",E))!==null||(I=p("tag",E))!==null||(I=p("head",E))!==null)return I;throw E.match(/^[a-f0-9]+$/)?new Error(`Couldn't resolve "${E}" as either a commit, a tag, or a head - if a commit, use the 40-characters commit hash`):new Error(`Couldn't resolve "${E}" as either a commit, a tag, or a head`)}default:throw new Error(`Invalid Git resolution protocol ("${h}")`)}},p=(h,E)=>{try{return A(h,E)}catch{return null}};return yC(`${r}#${A(o,a)}`)}async function X8(t,e){return await e.getLimit("cloneConcurrency")(async()=>{let{repo:r,treeish:{protocol:o,request:a}}=s0(t);if(o!=="commit")throw new Error("Invalid treeish protocol when cloning");let n=J8(r,{configuration:e}),u=await oe.mktempPromise(),A={cwd:u,env:Dde()};return await V8("cloning the repository",["clone","-c core.autocrlf=false",n,ue.fromPortablePath(u)],A,{configuration:e,normalizedRepoUrl:n}),await V8("switching branch",["checkout",`${a}`],A,{configuration:e,normalizedRepoUrl:n}),u})}async function Pde(t){let e,r=t;do{if(e=r,await oe.existsPromise(V.join(e,".git")))return e;r=V.dirname(e)}while(r!==e);return null}async function bde(t,{baseRefs:e}){if(e.length===0)throw new it("Can't run this command with zero base refs specified.");let r=[];for(let A of e){let{code:p}=await Ur.execvp("git",["merge-base",A,"HEAD"],{cwd:t});p===0&&r.push(A)}if(r.length===0)throw new it(`No ancestor could be found between any of HEAD and ${e.join(", ")}`);let{stdout:o}=await Ur.execvp("git",["merge-base","HEAD",...r],{cwd:t,strict:!0}),a=o.trim(),{stdout:n}=await Ur.execvp("git",["show","--quiet","--pretty=format:%s",a],{cwd:t,strict:!0}),u=n.trim();return{hash:a,title:u}}async function xde(t,{base:e,project:r}){let o=_e.buildIgnorePattern(r.configuration.get("changesetIgnorePatterns")),{stdout:a}=await Ur.execvp("git",["diff","--name-only",`${e}`],{cwd:t,strict:!0}),n=a.split(/\r\n|\r|\n/).filter(h=>h.length>0).map(h=>V.resolve(t,ue.toPortablePath(h))),{stdout:u}=await Ur.execvp("git",["ls-files","--others","--exclude-standard"],{cwd:t,strict:!0}),A=u.split(/\r\n|\r|\n/).filter(h=>h.length>0).map(h=>V.resolve(t,ue.toPortablePath(h))),p=[...new Set([...n,...A].sort())];return o?p.filter(h=>!V.relative(r.cwd,h).match(o)):p}async function Igt({ref:t,project:e}){if(e.configuration.projectCwd===null)throw new it("This command can only be run from within a Yarn project");let r=[V.resolve(e.cwd,dr.lockfile),V.resolve(e.cwd,e.configuration.get("cacheFolder")),V.resolve(e.cwd,e.configuration.get("installStatePath")),V.resolve(e.cwd,e.configuration.get("virtualFolder"))];await e.configuration.triggerHook(u=>u.populateYarnPaths,e,u=>{u!=null&&r.push(u)});let o=await Pde(e.configuration.projectCwd);if(o==null)throw new it("This command can only be run on Git repositories");let a=await bde(o,{baseRefs:typeof t=="string"?[t]:e.configuration.get("changesetBaseRefs")}),n=await xde(o,{base:a.hash,project:e});return new Set(_e.mapAndFilter(n,u=>{let A=e.tryWorkspaceByFilePath(u);return A===null?_e.mapAndFilter.skip:r.some(p=>u.startsWith(p))?_e.mapAndFilter.skip:A}))}async function V8(t,e,r,{configuration:o,normalizedRepoUrl:a}){try{return await Ur.execvp("git",e,{...r,strict:!0})}catch(n){if(!(n instanceof Ur.ExecError))throw n;let u=n.reportExtra,A=n.stderr.toString();throw new zt(1,`Failed ${t}`,p=>{p.reportError(1,` ${de.prettyField(o,{label:"Repository URL",value:de.tuple(de.Type.URL,a)})}`);for(let h of A.matchAll(/^(.+?): (.*)$/gm)){let[,E,I]=h;E=E.toLowerCase();let v=E==="error"?"Error":`${(0,vde.default)(E)} Error`;p.reportError(1,` ${de.prettyField(o,{label:v,value:de.tuple(de.Type.NO_HINT,I)})}`)}u?.(p)})}}var w2=class{supports(e,r){return CC(e.reference)}getLocalPath(e,r){return null}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,a=new Map(r.checksums);a.set(e.locatorHash,o);let n={...r,checksums:a},u=await this.downloadHosted(e,n);if(u!==null)return u;let[A,p,h]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${W.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the remote repository`),loader:()=>this.cloneFromRemote(e,n),...r.cacheOptions});return{packageFs:A,releaseFs:p,prefixPath:W.getIdentVendorPath(e),checksum:h}}async downloadHosted(e,r){return r.project.configuration.reduceHook(o=>o.fetchHostedRepository,null,e,r)}async cloneFromRemote(e,r){let o=await X8(e.reference,r.project.configuration),a=s0(e.reference),n=V.join(o,"package.tgz");await un.prepareExternalProject(o,n,{configuration:r.project.configuration,report:r.report,workspace:a.extra.workspace,locator:e});let u=await oe.readFilePromise(n);return await _e.releaseAfterUseAsync(async()=>await Xi.convertToZip(u,{configuration:r.project.configuration,prefixPath:W.getIdentVendorPath(e),stripComponents:1}))}};Ye();Ye();var I2=class{supportsDescriptor(e,r){return CC(e.range)}supportsLocator(e,r){return CC(e.reference)}shouldPersistResolution(e,r){return!0}bindDescriptor(e,r,o){return e}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){let a=await z8(e.range,o.project.configuration);return[W.makeLocator(e,a)]}async getSatisfying(e,r,o,a){let n=s0(e.range);return{locators:o.filter(A=>{if(A.identHash!==e.identHash)return!1;let p=s0(A.reference);return!(n.repo!==p.repo||n.treeish.protocol==="commit"&&n.treeish.request!==p.treeish.request)}),sorted:!1}}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let o=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),a=await _e.releaseAfterUseAsync(async()=>await Ot.find(o.prefixPath,{baseFs:o.packageFs}),o.releaseFs);return{...e,version:a.version||"0.0.0",languageName:a.languageName||r.project.configuration.get("defaultLanguageName"),linkType:"HARD",conditions:a.getConditions(),dependencies:r.project.configuration.normalizeDependencyMap(a.dependencies),peerDependencies:a.peerDependencies,dependenciesMeta:a.dependenciesMeta,peerDependenciesMeta:a.peerDependenciesMeta,bin:a.bin}}};var Bgt={configuration:{changesetBaseRefs:{description:"The base git refs that the current HEAD is compared against when detecting changes. Supports git branches, tags, and commits.",type:"STRING",isArray:!0,isNullable:!1,default:["master","origin/master","upstream/master","main","origin/main","upstream/main"]},changesetIgnorePatterns:{description:"Array of glob patterns; files matching them will be ignored when fetching the changed files",type:"STRING",default:[],isArray:!0},cloneConcurrency:{description:"Maximal number of concurrent clones",type:"NUMBER",default:2}},fetchers:[w2],resolvers:[I2]};var vgt=Bgt;jt();var o0=class extends ut{constructor(){super(...arguments);this.since=ge.String("--since",{description:"Only include workspaces that have been changed since the specified ref.",tolerateBoolean:!0});this.recursive=ge.Boolean("-R,--recursive",!1,{description:"Find packages via dependencies/devDependencies instead of using the workspaces field"});this.noPrivate=ge.Boolean("--no-private",{description:"Exclude workspaces that have the private field set to true"});this.verbose=ge.Boolean("-v,--verbose",!1,{description:"Also return the cross-dependencies between workspaces"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o}=await Pt.find(r,this.context.cwd);return(await Nt.start({configuration:r,json:this.json,stdout:this.context.stdout},async n=>{let u=this.since?await ra.fetchChangedWorkspaces({ref:this.since,project:o}):o.workspaces,A=new Set(u);if(this.recursive)for(let p of[...u].map(h=>h.getRecursiveWorkspaceDependents()))for(let h of p)A.add(h);for(let p of A){let{manifest:h}=p;if(h.private&&this.noPrivate)continue;let E;if(this.verbose){let I=new Set,v=new Set;for(let x of Ot.hardDependencies)for(let[C,R]of h.getForScope(x)){let L=o.tryWorkspaceByDescriptor(R);L===null?o.workspacesByIdent.has(C)&&v.add(R):I.add(L)}E={workspaceDependencies:Array.from(I).map(x=>x.relativeCwd),mismatchedWorkspaceDependencies:Array.from(v).map(x=>W.stringifyDescriptor(x))}}n.reportInfo(null,`${p.relativeCwd}`),n.reportJson({location:p.relativeCwd,name:h.name?W.stringifyIdent(h.name):null,...E})}})).exitCode()}};o0.paths=[["workspaces","list"]],o0.usage=nt.Usage({category:"Workspace-related commands",description:"list all available workspaces",details:"\n This command will print the list of all workspaces in the project.\n\n - If `--since` is set, Yarn will only list workspaces that have been modified since the specified ref. By default Yarn will use the refs specified by the `changesetBaseRefs` configuration option.\n\n - If `-R,--recursive` is set, Yarn will find workspaces to run the command on by recursively evaluating `dependencies` and `devDependencies` fields, instead of looking at the `workspaces` fields.\n\n - If `--no-private` is set, Yarn will not list any workspaces that have the `private` field set to `true`.\n\n - If both the `-v,--verbose` and `--json` options are set, Yarn will also return the cross-dependencies between each workspaces (useful when you wish to automatically generate Buck / Bazel rules).\n "});Ye();Ye();jt();var a0=class extends ut{constructor(){super(...arguments);this.workspaceName=ge.String();this.commandName=ge.String();this.args=ge.Proxy()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);let n=o.workspaces,u=new Map(n.map(p=>[W.stringifyIdent(p.anchoredLocator),p])),A=u.get(this.workspaceName);if(A===void 0){let p=Array.from(u.keys()).sort();throw new it(`Workspace '${this.workspaceName}' not found. Did you mean any of the following: + - ${p.join(` + - `)}?`)}return this.cli.run([this.commandName,...this.args],{cwd:A.cwd})}};a0.paths=[["workspace"]],a0.usage=nt.Usage({category:"Workspace-related commands",description:"run a command within the specified workspace",details:` + This command will run a given sub-command on a single workspace. + `,examples:[["Add a package to a single workspace","yarn workspace components add -D react"],["Run build script on a single workspace","yarn workspace components run build"]]});var Dgt={configuration:{enableImmutableInstalls:{description:"If true (the default on CI), prevents the install command from modifying the lockfile",type:"BOOLEAN",default:kde.isCI},defaultSemverRangePrefix:{description:"The default save prefix: '^', '~' or ''",type:"STRING",values:["^","~",""],default:"^"},preferReuse:{description:"If true, `yarn add` will attempt to reuse the most common dependency range in other workspaces.",type:"BOOLEAN",default:!1}},commands:[Fh,Rh,Th,Nh,r0,Vh,_h,o0,Kd,Vd,mC,Jd,kh,Qh,Lh,Oh,Mh,Uh,Hh,jh,Gh,qh,n0,Yh,Wh,zh,Jh,Xh,Kh,Zh,$h,e0,Xd,t0,Jf,i0,a0]},Sgt=Dgt;var iH={};Vt(iH,{default:()=>bgt});Ye();var kt={optional:!0},eH=[["@tailwindcss/aspect-ratio@<0.2.1",{peerDependencies:{tailwindcss:"^2.0.2"}}],["@tailwindcss/line-clamp@<0.2.1",{peerDependencies:{tailwindcss:"^2.0.2"}}],["@fullhuman/postcss-purgecss@3.1.3 || 3.1.3-alpha.0",{peerDependencies:{postcss:"^8.0.0"}}],["@samverschueren/stream-to-observable@<0.3.1",{peerDependenciesMeta:{rxjs:kt,zenObservable:kt}}],["any-observable@<0.5.1",{peerDependenciesMeta:{rxjs:kt,zenObservable:kt}}],["@pm2/agent@<1.0.4",{dependencies:{debug:"*"}}],["debug@<4.2.0",{peerDependenciesMeta:{["supports-color"]:kt}}],["got@<11",{dependencies:{["@types/responselike"]:"^1.0.0",["@types/keyv"]:"^3.1.1"}}],["cacheable-lookup@<4.1.2",{dependencies:{["@types/keyv"]:"^3.1.1"}}],["http-link-dataloader@*",{peerDependencies:{graphql:"^0.13.1 || ^14.0.0"}}],["typescript-language-server@*",{dependencies:{["vscode-jsonrpc"]:"^5.0.1",["vscode-languageserver-protocol"]:"^3.15.0"}}],["postcss-syntax@*",{peerDependenciesMeta:{["postcss-html"]:kt,["postcss-jsx"]:kt,["postcss-less"]:kt,["postcss-markdown"]:kt,["postcss-scss"]:kt}}],["jss-plugin-rule-value-function@<=10.1.1",{dependencies:{["tiny-warning"]:"^1.0.2"}}],["ink-select-input@<4.1.0",{peerDependencies:{react:"^16.8.2"}}],["license-webpack-plugin@<2.3.18",{peerDependenciesMeta:{webpack:kt}}],["snowpack@>=3.3.0",{dependencies:{["node-gyp"]:"^7.1.0"}}],["promise-inflight@*",{peerDependenciesMeta:{bluebird:kt}}],["reactcss@*",{peerDependencies:{react:"*"}}],["react-color@<=2.19.0",{peerDependencies:{react:"*"}}],["gatsby-plugin-i18n@*",{dependencies:{ramda:"^0.24.1"}}],["useragent@^2.0.0",{dependencies:{request:"^2.88.0",yamlparser:"0.0.x",semver:"5.5.x"}}],["@apollographql/apollo-tools@<=0.5.2",{peerDependencies:{graphql:"^14.2.1 || ^15.0.0"}}],["material-table@^2.0.0",{dependencies:{"@babel/runtime":"^7.11.2"}}],["@babel/parser@*",{dependencies:{"@babel/types":"^7.8.3"}}],["fork-ts-checker-webpack-plugin@<=6.3.4",{peerDependencies:{eslint:">= 6",typescript:">= 2.7",webpack:">= 4","vue-template-compiler":"*"},peerDependenciesMeta:{eslint:kt,"vue-template-compiler":kt}}],["rc-animate@<=3.1.1",{peerDependencies:{react:">=16.9.0","react-dom":">=16.9.0"}}],["react-bootstrap-table2-paginator@*",{dependencies:{classnames:"^2.2.6"}}],["react-draggable@<=4.4.3",{peerDependencies:{react:">= 16.3.0","react-dom":">= 16.3.0"}}],["apollo-upload-client@<14",{peerDependencies:{graphql:"14 - 15"}}],["react-instantsearch-core@<=6.7.0",{peerDependencies:{algoliasearch:">= 3.1 < 5"}}],["react-instantsearch-dom@<=6.7.0",{dependencies:{"react-fast-compare":"^3.0.0"}}],["ws@<7.2.1",{peerDependencies:{bufferutil:"^4.0.1","utf-8-validate":"^5.0.2"},peerDependenciesMeta:{bufferutil:kt,"utf-8-validate":kt}}],["react-portal@<4.2.2",{peerDependencies:{"react-dom":"^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0"}}],["react-scripts@<=4.0.1",{peerDependencies:{react:"*"}}],["testcafe@<=1.10.1",{dependencies:{"@babel/plugin-transform-for-of":"^7.12.1","@babel/runtime":"^7.12.5"}}],["testcafe-legacy-api@<=4.2.0",{dependencies:{"testcafe-hammerhead":"^17.0.1","read-file-relative":"^1.2.0"}}],["@google-cloud/firestore@<=4.9.3",{dependencies:{protobufjs:"^6.8.6"}}],["gatsby-source-apiserver@*",{dependencies:{["babel-polyfill"]:"^6.26.0"}}],["@webpack-cli/package-utils@<=1.0.1-alpha.4",{dependencies:{["cross-spawn"]:"^7.0.3"}}],["gatsby-remark-prismjs@<3.3.28",{dependencies:{lodash:"^4"}}],["gatsby-plugin-favicon@*",{peerDependencies:{webpack:"*"}}],["gatsby-plugin-sharp@<=4.6.0-next.3",{dependencies:{debug:"^4.3.1"}}],["gatsby-react-router-scroll@<=5.6.0-next.0",{dependencies:{["prop-types"]:"^15.7.2"}}],["@rebass/forms@*",{dependencies:{["@styled-system/should-forward-prop"]:"^5.0.0"},peerDependencies:{react:"^16.8.6"}}],["rebass@*",{peerDependencies:{react:"^16.8.6"}}],["@ant-design/react-slick@<=0.28.3",{peerDependencies:{react:">=16.0.0"}}],["mqtt@<4.2.7",{dependencies:{duplexify:"^4.1.1"}}],["vue-cli-plugin-vuetify@<=2.0.3",{dependencies:{semver:"^6.3.0"},peerDependenciesMeta:{"sass-loader":kt,"vuetify-loader":kt}}],["vue-cli-plugin-vuetify@<=2.0.4",{dependencies:{"null-loader":"^3.0.0"}}],["vue-cli-plugin-vuetify@>=2.4.3",{peerDependencies:{vue:"*"}}],["@vuetify/cli-plugin-utils@<=0.0.4",{dependencies:{semver:"^6.3.0"},peerDependenciesMeta:{"sass-loader":kt}}],["@vue/cli-plugin-typescript@<=5.0.0-alpha.0",{dependencies:{"babel-loader":"^8.1.0"}}],["@vue/cli-plugin-typescript@<=5.0.0-beta.0",{dependencies:{"@babel/core":"^7.12.16"},peerDependencies:{"vue-template-compiler":"^2.0.0"},peerDependenciesMeta:{"vue-template-compiler":kt}}],["cordova-ios@<=6.3.0",{dependencies:{underscore:"^1.9.2"}}],["cordova-lib@<=10.0.1",{dependencies:{underscore:"^1.9.2"}}],["git-node-fs@*",{peerDependencies:{"js-git":"^0.7.8"},peerDependenciesMeta:{"js-git":kt}}],["consolidate@<0.16.0",{peerDependencies:{mustache:"^3.0.0"},peerDependenciesMeta:{mustache:kt}}],["consolidate@<=0.16.0",{peerDependencies:{velocityjs:"^2.0.1",tinyliquid:"^0.2.34","liquid-node":"^3.0.1",jade:"^1.11.0","then-jade":"*",dust:"^0.3.0","dustjs-helpers":"^1.7.4","dustjs-linkedin":"^2.7.5",swig:"^1.4.2","swig-templates":"^2.0.3","razor-tmpl":"^1.3.1",atpl:">=0.7.6",liquor:"^0.0.5",twig:"^1.15.2",ejs:"^3.1.5",eco:"^1.1.0-rc-3",jazz:"^0.0.18",jqtpl:"~1.1.0",hamljs:"^0.6.2",hamlet:"^0.3.3",whiskers:"^0.4.0","haml-coffee":"^1.14.1","hogan.js":"^3.0.2",templayed:">=0.2.3",handlebars:"^4.7.6",underscore:"^1.11.0",lodash:"^4.17.20",pug:"^3.0.0","then-pug":"*",qejs:"^3.0.5",walrus:"^0.10.1",mustache:"^4.0.1",just:"^0.1.8",ect:"^0.5.9",mote:"^0.2.0",toffee:"^0.3.6",dot:"^1.1.3","bracket-template":"^1.1.5",ractive:"^1.3.12",nunjucks:"^3.2.2",htmling:"^0.0.8","babel-core":"^6.26.3",plates:"~0.4.11","react-dom":"^16.13.1",react:"^16.13.1","arc-templates":"^0.5.3",vash:"^0.13.0",slm:"^2.0.0",marko:"^3.14.4",teacup:"^2.0.0","coffee-script":"^1.12.7",squirrelly:"^5.1.0",twing:"^5.0.2"},peerDependenciesMeta:{velocityjs:kt,tinyliquid:kt,"liquid-node":kt,jade:kt,"then-jade":kt,dust:kt,"dustjs-helpers":kt,"dustjs-linkedin":kt,swig:kt,"swig-templates":kt,"razor-tmpl":kt,atpl:kt,liquor:kt,twig:kt,ejs:kt,eco:kt,jazz:kt,jqtpl:kt,hamljs:kt,hamlet:kt,whiskers:kt,"haml-coffee":kt,"hogan.js":kt,templayed:kt,handlebars:kt,underscore:kt,lodash:kt,pug:kt,"then-pug":kt,qejs:kt,walrus:kt,mustache:kt,just:kt,ect:kt,mote:kt,toffee:kt,dot:kt,"bracket-template":kt,ractive:kt,nunjucks:kt,htmling:kt,"babel-core":kt,plates:kt,"react-dom":kt,react:kt,"arc-templates":kt,vash:kt,slm:kt,marko:kt,teacup:kt,"coffee-script":kt,squirrelly:kt,twing:kt}}],["vue-loader@<=16.3.3",{peerDependencies:{"@vue/compiler-sfc":"^3.0.8",webpack:"^4.1.0 || ^5.0.0-0"},peerDependenciesMeta:{"@vue/compiler-sfc":kt}}],["vue-loader@^16.7.0",{peerDependencies:{"@vue/compiler-sfc":"^3.0.8",vue:"^3.2.13"},peerDependenciesMeta:{"@vue/compiler-sfc":kt,vue:kt}}],["scss-parser@<=1.0.5",{dependencies:{lodash:"^4.17.21"}}],["query-ast@<1.0.5",{dependencies:{lodash:"^4.17.21"}}],["redux-thunk@<=2.3.0",{peerDependencies:{redux:"^4.0.0"}}],["skypack@<=0.3.2",{dependencies:{tar:"^6.1.0"}}],["@npmcli/metavuln-calculator@<2.0.0",{dependencies:{"json-parse-even-better-errors":"^2.3.1"}}],["bin-links@<2.3.0",{dependencies:{"mkdirp-infer-owner":"^1.0.2"}}],["rollup-plugin-polyfill-node@<=0.8.0",{peerDependencies:{rollup:"^1.20.0 || ^2.0.0"}}],["snowpack@<3.8.6",{dependencies:{"magic-string":"^0.25.7"}}],["elm-webpack-loader@*",{dependencies:{temp:"^0.9.4"}}],["winston-transport@<=4.4.0",{dependencies:{logform:"^2.2.0"}}],["jest-vue-preprocessor@*",{dependencies:{"@babel/core":"7.8.7","@babel/template":"7.8.6"},peerDependencies:{pug:"^2.0.4"},peerDependenciesMeta:{pug:kt}}],["redux-persist@*",{peerDependencies:{react:">=16"},peerDependenciesMeta:{react:kt}}],["sodium@>=3",{dependencies:{"node-gyp":"^3.8.0"}}],["babel-plugin-graphql-tag@<=3.1.0",{peerDependencies:{graphql:"^14.0.0 || ^15.0.0"}}],["@playwright/test@<=1.14.1",{dependencies:{"jest-matcher-utils":"^26.4.2"}}],...["babel-plugin-remove-graphql-queries@<3.14.0-next.1","babel-preset-gatsby-package@<1.14.0-next.1","create-gatsby@<1.14.0-next.1","gatsby-admin@<0.24.0-next.1","gatsby-cli@<3.14.0-next.1","gatsby-core-utils@<2.14.0-next.1","gatsby-design-tokens@<3.14.0-next.1","gatsby-legacy-polyfills@<1.14.0-next.1","gatsby-plugin-benchmark-reporting@<1.14.0-next.1","gatsby-plugin-graphql-config@<0.23.0-next.1","gatsby-plugin-image@<1.14.0-next.1","gatsby-plugin-mdx@<2.14.0-next.1","gatsby-plugin-netlify-cms@<5.14.0-next.1","gatsby-plugin-no-sourcemaps@<3.14.0-next.1","gatsby-plugin-page-creator@<3.14.0-next.1","gatsby-plugin-preact@<5.14.0-next.1","gatsby-plugin-preload-fonts@<2.14.0-next.1","gatsby-plugin-schema-snapshot@<2.14.0-next.1","gatsby-plugin-styletron@<6.14.0-next.1","gatsby-plugin-subfont@<3.14.0-next.1","gatsby-plugin-utils@<1.14.0-next.1","gatsby-recipes@<0.25.0-next.1","gatsby-source-shopify@<5.6.0-next.1","gatsby-source-wikipedia@<3.14.0-next.1","gatsby-transformer-screenshot@<3.14.0-next.1","gatsby-worker@<0.5.0-next.1"].map(t=>[t,{dependencies:{"@babel/runtime":"^7.14.8"}}]),["gatsby-core-utils@<2.14.0-next.1",{dependencies:{got:"8.3.2"}}],["gatsby-plugin-gatsby-cloud@<=3.1.0-next.0",{dependencies:{"gatsby-core-utils":"^2.13.0-next.0"}}],["gatsby-plugin-gatsby-cloud@<=3.2.0-next.1",{peerDependencies:{webpack:"*"}}],["babel-plugin-remove-graphql-queries@<=3.14.0-next.1",{dependencies:{"gatsby-core-utils":"^2.8.0-next.1"}}],["gatsby-plugin-netlify@3.13.0-next.1",{dependencies:{"gatsby-core-utils":"^2.13.0-next.0"}}],["clipanion-v3-codemod@<=0.2.0",{peerDependencies:{jscodeshift:"^0.11.0"}}],["react-live@*",{peerDependencies:{"react-dom":"*",react:"*"}}],["webpack@<4.44.1",{peerDependenciesMeta:{"webpack-cli":kt,"webpack-command":kt}}],["webpack@<5.0.0-beta.23",{peerDependenciesMeta:{"webpack-cli":kt}}],["webpack-dev-server@<3.10.2",{peerDependenciesMeta:{"webpack-cli":kt}}],["@docusaurus/responsive-loader@<1.5.0",{peerDependenciesMeta:{sharp:kt,jimp:kt}}],["eslint-module-utils@*",{peerDependenciesMeta:{"eslint-import-resolver-node":kt,"eslint-import-resolver-typescript":kt,"eslint-import-resolver-webpack":kt,"@typescript-eslint/parser":kt}}],["eslint-plugin-import@*",{peerDependenciesMeta:{"@typescript-eslint/parser":kt}}],["critters-webpack-plugin@<3.0.2",{peerDependenciesMeta:{"html-webpack-plugin":kt}}],["terser@<=5.10.0",{dependencies:{acorn:"^8.5.0"}}],["babel-preset-react-app@10.0.x <10.0.2",{dependencies:{"@babel/plugin-proposal-private-property-in-object":"^7.16.7"}}],["eslint-config-react-app@*",{peerDependenciesMeta:{typescript:kt}}],["@vue/eslint-config-typescript@<11.0.0",{peerDependenciesMeta:{typescript:kt}}],["unplugin-vue2-script-setup@<0.9.1",{peerDependencies:{"@vue/composition-api":"^1.4.3","@vue/runtime-dom":"^3.2.26"}}],["@cypress/snapshot@*",{dependencies:{debug:"^3.2.7"}}],["auto-relay@<=0.14.0",{peerDependencies:{"reflect-metadata":"^0.1.13"}}],["vue-template-babel-compiler@<1.2.0",{peerDependencies:{["vue-template-compiler"]:"^2.6.0"}}],["@parcel/transformer-image@<2.5.0",{peerDependencies:{["@parcel/core"]:"*"}}],["@parcel/transformer-js@<2.5.0",{peerDependencies:{["@parcel/core"]:"*"}}],["parcel@*",{peerDependenciesMeta:{["@parcel/core"]:kt}}],["react-scripts@*",{peerDependencies:{eslint:"*"}}],["focus-trap-react@^8.0.0",{dependencies:{tabbable:"^5.3.2"}}],["react-rnd@<10.3.7",{peerDependencies:{react:">=16.3.0","react-dom":">=16.3.0"}}],["connect-mongo@<5.0.0",{peerDependencies:{"express-session":"^1.17.1"}}],["vue-i18n@<9",{peerDependencies:{vue:"^2"}}],["vue-router@<4",{peerDependencies:{vue:"^2"}}],["unified@<10",{dependencies:{"@types/unist":"^2.0.0"}}],["react-github-btn@<=1.3.0",{peerDependencies:{react:">=16.3.0"}}],["react-dev-utils@*",{peerDependencies:{typescript:">=2.7",webpack:">=4"},peerDependenciesMeta:{typescript:kt}}],["@asyncapi/react-component@<=1.0.0-next.39",{peerDependencies:{react:">=16.8.0","react-dom":">=16.8.0"}}],["xo@*",{peerDependencies:{webpack:">=1.11.0"},peerDependenciesMeta:{webpack:kt}}],["babel-plugin-remove-graphql-queries@<=4.20.0-next.0",{dependencies:{"@babel/types":"^7.15.4"}}],["gatsby-plugin-page-creator@<=4.20.0-next.1",{dependencies:{"fs-extra":"^10.1.0"}}],["gatsby-plugin-utils@<=3.14.0-next.1",{dependencies:{fastq:"^1.13.0"},peerDependencies:{graphql:"^15.0.0"}}],["gatsby-plugin-mdx@<3.1.0-next.1",{dependencies:{mkdirp:"^1.0.4"}}],["gatsby-plugin-mdx@^2",{peerDependencies:{gatsby:"^3.0.0-next"}}],["fdir@<=5.2.0",{peerDependencies:{picomatch:"2.x"},peerDependenciesMeta:{picomatch:kt}}],["babel-plugin-transform-typescript-metadata@<=0.3.2",{peerDependencies:{"@babel/core":"^7","@babel/traverse":"^7"},peerDependenciesMeta:{"@babel/traverse":kt}}],["graphql-compose@>=9.0.10",{peerDependencies:{graphql:"^14.2.0 || ^15.0.0 || ^16.0.0"}}],["vite-plugin-vuetify@<=1.0.2",{peerDependencies:{vue:"^3.0.0"}}],["webpack-plugin-vuetify@<=2.0.1",{peerDependencies:{vue:"^3.2.6"}}]];var tH;function Qde(){return typeof tH>"u"&&(tH=ve("zlib").brotliDecompressSync(Buffer.from("G7weAByFTVk3Vs7UfHhq4yykgEM7pbW7TI43SG2S5tvGrwHBAzdz+s/npQ6tgEvobvxisrPIadkXeUAJotBn5bDZ5kAhcRqsIHe3F75Walet5hNalwgFDtxb0BiDUjiUQkjG0yW2hto9HPgiCkm316d6bC0kST72YN7D7rfkhCE9x4J0XwB0yavalxpUu2t9xszHrmtwalOxT7VslsxWcB1qpqZwERUra4psWhTV8BgwWeizurec82Caf1ABL11YMfbf8FJ9JBceZOkgmvrQPbC9DUldX/yMbmX06UQluCEjSwUoyO+EZPIjofr+/oAZUck2enraRD+oWLlnlYnj8xB+gwSo9lmmks4fXv574qSqcWA6z21uYkzMu3EWj+K23RxeQlLqiE35/rC8GcS4CGkKHKKq+zAIQwD9iRDNfiAqueLLpicFFrNsAI4zeTD/eO9MHcnRa5m8UT+M2+V+AkFST4BlKneiAQRSdST8KEAIyFlULt6wa9EBd0Ds28VmpaxquJdVt+nwdEs5xUskI13OVtFyY0UrQIRAlCuvvWivvlSKQfTO+2Q8OyUR1W5RvetaPz4jD27hdtwHFFA1Ptx6Ee/t2cY2rg2G46M1pNDRf2pWhvpy8pqMnuI3++4OF3+7OFIWXGjh+o7Nr2jNvbiYcQdQS1h903/jVFgOpA0yJ78z+x759bFA0rq+6aY5qPB4FzS3oYoLupDUhD9nDz6F6H7hpnlMf18KNKDu4IKjTWwrAnY6MFQw1W6ymOALHlFyCZmQhldg1MQHaMVVQTVgDC60TfaBqG++Y8PEoFhN/PBTZT175KNP/BlHDYGOOBmnBdzqJKplZ/ljiVG0ZBzfqeBRrrUkn6rA54462SgiliKoYVnbeptMdXNfAuaupIEi0bApF10TlgHfmEJAPUVidRVFyDupSem5po5vErPqWKhKbUIp0LozpYsIKK57dM/HKr+nguF+7924IIWMICkQ8JUigs9D+W+c4LnNoRtPPKNRUiCYmP+Jfo2lfKCKw8qpraEeWU3uiNRO6zcyKQoXPR5htmzzLznke7b4YbXW3I1lIRzmgG02Udb58U+7TpwyN7XymCgH+wuPDthZVQvRZuEP+SnLtMicz9m5zASWOBiAcLmkuFlTKuHspSIhCBD0yUPKcxu81A+4YD78rA2vtwsUEday9WNyrShyrl60rWmA+SmbYZkQOwFJWArxRYYc5jGhA5ikxYw1rx3ei4NmeX/lKiwpZ9Ln1tV2Ae7sArvxuVLbJjqJRjW1vFXAyHpvLG+8MJ6T2Ubx5M2KDa2SN6vuIGxJ9WQM9Mk3Q7aCNiZONXllhqq24DmoLbQfW2rYWsOgHWjtOmIQMyMKdiHZDjoyIq5+U700nZ6odJAoYXPQBvFNiQ78d5jaXliBqLTJEqUCwi+LiH2mx92EmNKDsJL74Z613+3lf20pxkV1+erOrjj8pW00vsPaahKUM+05ssd5uwM7K482KWEf3TCwlg/o3e5ngto7qSMz7YteIgCsF1UOcsLk7F7MxWbvrPMY473ew0G+noVL8EPbkmEMftMSeL6HFub/zy+2JQ==","base64")).toString()),tH}var rH;function Fde(){return typeof rH>"u"&&(rH=ve("zlib").brotliDecompressSync(Buffer.from("G8MSIIzURnVBnObTcvb3XE6v2S9Qgc2K801Oa5otNKEtK8BINZNcaQHy+9/vf/WXBimwutXC33P2DPc64pps5rz7NGGWaOKNSPL4Y2KRE8twut2lFOIN+OXPtRmPMRhMTILib2bEQx43az2I5d3YS8Roa5UZpF/ujHb3Djd3GDvYUfvFYSUQ39vb2cmifp/rgB4J/65JK3wRBTvMBoNBmn3mbXC63/gbBkW/2IRPri0O8bcsRBsmarF328pAln04nyJFkwUAvNu934supAqLtyerZZpJ8I8suJHhf/ocMV+scKwa8NOiDKIPXw6Ex/EEZD6TEGaW8N5zvNHYF10l6Lfooj7D5W2k3dgvQSbp2Wv8TGOayS978gxlOLVjTGXs66ozewbrjwElLtyrYNnWTfzzdEutgROUFPVMhnMoy8EjJLLlWwIEoySxliim9kYW30JUHiPVyjt0iAw/ZpPmCbUCltYPnq6ZNblIKhTNhqS/oqC9iya5sGKZTOVsTEg34n92uZTf2iPpcZih8rPW8CzA+adIGmyCPcKdLMsBLShd+zuEbTrqpwuh+DLmracZcjPC5Sdf5odDAhKpFuOsQS67RT+1VgWWygSv3YwxDnylc04/PYuaMeIzhBkLrvs7e/OUzRTF56MmfY6rI63QtEjEQzq637zQqJ39nNhu3NmoRRhW/086bHGBUtx0PE0j3aEGvkdh9WJC8y8j8mqqke9/dQ5la+Q3ba4RlhvTbnfQhPDDab3tUifkjKuOsp13mXEmO00Mu88F/M67R7LXfoFDFLNtgCSWjWX+3Jn1371pJTK9xPBiMJafvDjtFyAzu8rxeQ0TKMQXNPs5xxiBOd+BRJP8KP88XPtJIbZKh/cdW8KvBUkpqKpGoiIaA32c3/JnQr4efXt85mXvidOvn/eU3Pase1typLYBalJ14mCso9h79nuMOuCa/kZAOkJHmTjP5RM2WNoPasZUAnT1TAE/NH25hUxcQv6hQWR/m1PKk4ooXMcM4SR1iYU3fUohvqk4RY2hbmTVVIXv6TvqO+0doOjgeVFAcom+RlwJQmOVH7pr1Q9LoJT6n1DeQEB+NHygsATbIwTcOKZlJsY8G4+suX1uQLjUWwLjjs0mvSvZcLTpIGAekeR7GCgl8eo3ndAqEe2XCav4huliHjdbIPBsGJuPX7lrO9HX1UbXRH5opOe1x6JsOSgHZR+EaxuXVhpLLxm6jk1LJtZfHSc6BKPun3CpYYVMJGwEUyk8MTGG0XL5MfEwaXpnc9TKnBmlGn6nHiGREc3ysn47XIBDzA+YvFdjZzVIEDcKGpS6PbUJehFRjEne8D0lVU1XuRtlgszq6pTNlQ/3MzNOEgCWPyTct22V2mEi2krizn5VDo9B19/X2DB3hCGRMM7ONbtnAcIx/OWB1u5uPbW1gsH8irXxT/IzG0PoXWYjhbMsH3KTuoOl5o17PulcgvsfTSnKFM354GWI8luqZnrswWjiXy3G+Vbyo1KMopFmmvBwNELgaS8z8dNZchx/Cl/xjddxhMcyqtzFyONb2Zdu90NkI8pAeufe7YlXrp53v8Dj/l8vWeVspRKBGXScBBPI/HinSTGmLDOGGOCIyH0JFdOZx0gWsacNlQLJMIrBhqRxXxHF/5pseWwejlAAvZ3klZSDSYY8mkToaWejXhgNomeGtx1DTLEUFMRkgF5yFB22WYdJnaWN14r1YJj81hGi45+jrADS5nYRhCiSlCJJ1nL8pYX+HDSMhdTEWyRcgHVp/IsUIZYMfT+YYncUQPgcxNGCHfZ88vDdrcUuaGIl6zhAsiaq7R5dfqrqXH/JcBhfjT8D0azayIyEz75Nxp6YkcyDxlJq3EXnJUpqDohJJOysL1t1uNiHESlvsxPb5cpbW0+ICZqJmUZus1BMW0F5IVBODLIo2zHHjA0=","base64")).toString()),rH}var nH;function Rde(){return typeof nH>"u"&&(nH=ve("zlib").brotliDecompressSync(Buffer.from("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","base64")).toString()),nH}var Tde=new Map([[W.makeIdent(null,"fsevents").identHash,Qde],[W.makeIdent(null,"resolve").identHash,Fde],[W.makeIdent(null,"typescript").identHash,Rde]]),Pgt={hooks:{registerPackageExtensions:async(t,e)=>{for(let[r,o]of eH)e(W.parseDescriptor(r,!0),o)},getBuiltinPatch:async(t,e)=>{let r="compat/";if(!e.startsWith(r))return;let o=W.parseIdent(e.slice(r.length)),a=Tde.get(o.identHash)?.();return typeof a<"u"?a:null},reduceDependency:async(t,e,r,o)=>typeof Tde.get(t.identHash)>"u"?t:W.makeDescriptor(t,W.makeRange({protocol:"patch:",source:W.stringifyDescriptor(t),selector:`optional!builtin<compat/${W.stringifyIdent(t)}>`,params:null}))}},bgt=Pgt;var wH={};Vt(wH,{ConstraintsCheckCommand:()=>h0,ConstraintsQueryCommand:()=>f0,ConstraintsSourceCommand:()=>p0,default:()=>tdt});Ye();Ye();v2();var IC=class{constructor(e){this.project=e}createEnvironment(){let e=new wC(["cwd","ident"]),r=new wC(["workspace","type","ident"]),o=new wC(["ident"]),a={manifestUpdates:new Map,reportedErrors:new Map},n=new Map,u=new Map;for(let A of this.project.storedPackages.values()){let p=Array.from(A.peerDependencies.values(),h=>[W.stringifyIdent(h),h.range]);n.set(A.locatorHash,{workspace:null,ident:W.stringifyIdent(A),version:A.version,dependencies:new Map,peerDependencies:new Map(p.filter(([h])=>A.peerDependenciesMeta.get(h)?.optional!==!0)),optionalPeerDependencies:new Map(p.filter(([h])=>A.peerDependenciesMeta.get(h)?.optional===!0))})}for(let A of this.project.storedPackages.values()){let p=n.get(A.locatorHash);p.dependencies=new Map(Array.from(A.dependencies.values(),h=>{let E=this.project.storedResolutions.get(h.descriptorHash);if(typeof E>"u")throw new Error("Assertion failed: The resolution should have been registered");let I=n.get(E);if(typeof I>"u")throw new Error("Assertion failed: The package should have been registered");return[W.stringifyIdent(h),I]})),p.dependencies.delete(p.ident)}for(let A of this.project.workspaces){let p=W.stringifyIdent(A.anchoredLocator),h=A.manifest.exportTo({}),E=n.get(A.anchoredLocator.locatorHash);if(typeof E>"u")throw new Error("Assertion failed: The package should have been registered");let I=(R,L,{caller:U=Ji.getCaller()}={})=>{let J=B2(R),te=_e.getMapWithDefault(a.manifestUpdates,A.cwd),ae=_e.getMapWithDefault(te,J),fe=_e.getSetWithDefault(ae,L);U!==null&&fe.add(U)},v=R=>I(R,void 0,{caller:Ji.getCaller()}),x=R=>{_e.getArrayWithDefault(a.reportedErrors,A.cwd).push(R)},C=e.insert({cwd:A.relativeCwd,ident:p,manifest:h,pkg:E,set:I,unset:v,error:x});u.set(A,C);for(let R of Ot.allDependencies)for(let L of A.manifest[R].values()){let U=W.stringifyIdent(L),J=()=>{I([R,U],void 0,{caller:Ji.getCaller()})},te=fe=>{I([R,U],fe,{caller:Ji.getCaller()})},ae=null;if(R!=="peerDependencies"&&(R!=="dependencies"||!A.manifest.devDependencies.has(L.identHash))){let fe=A.anchoredPackage.dependencies.get(L.identHash);if(fe){if(typeof fe>"u")throw new Error("Assertion failed: The dependency should have been registered");let ce=this.project.storedResolutions.get(fe.descriptorHash);if(typeof ce>"u")throw new Error("Assertion failed: The resolution should have been registered");let me=n.get(ce);if(typeof me>"u")throw new Error("Assertion failed: The package should have been registered");ae=me}}r.insert({workspace:C,ident:U,range:L.range,type:R,resolution:ae,update:te,delete:J,error:x})}}for(let A of this.project.storedPackages.values()){let p=this.project.tryWorkspaceByLocator(A);if(!p)continue;let h=u.get(p);if(typeof h>"u")throw new Error("Assertion failed: The workspace should have been registered");let E=n.get(A.locatorHash);if(typeof E>"u")throw new Error("Assertion failed: The package should have been registered");E.workspace=h}return{workspaces:e,dependencies:r,packages:o,result:a}}async process(){let e=this.createEnvironment(),r={Yarn:{workspace:a=>e.workspaces.find(a)[0]??null,workspaces:a=>e.workspaces.find(a),dependency:a=>e.dependencies.find(a)[0]??null,dependencies:a=>e.dependencies.find(a),package:a=>e.packages.find(a)[0]??null,packages:a=>e.packages.find(a)}},o=await this.project.loadUserConfig();return o?.constraints?(await o.constraints(r),e.result):null}};Ye();Ye();jt();var f0=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.query=ge.String()}async execute(){let{Constraints:r}=await Promise.resolve().then(()=>(x2(),b2)),o=await Ke.find(this.context.cwd,this.context.plugins),{project:a}=await Pt.find(o,this.context.cwd),n=await r.find(a),u=this.query;return u.endsWith(".")||(u=`${u}.`),(await Nt.start({configuration:o,json:this.json,stdout:this.context.stdout},async p=>{for await(let h of n.query(u)){let E=Array.from(Object.entries(h)),I=E.length,v=E.reduce((x,[C])=>Math.max(x,C.length),0);for(let x=0;x<I;x++){let[C,R]=E[x];p.reportInfo(null,`${$gt(x,I)}${C.padEnd(v," ")} = ${Zgt(R)}`)}p.reportJson(h)}})).exitCode()}};f0.paths=[["constraints","query"]],f0.usage=nt.Usage({category:"Constraints-related commands",description:"query the constraints fact database",details:` + This command will output all matches to the given prolog query. + `,examples:[["List all dependencies throughout the workspace","yarn constraints query 'workspace_has_dependency(_, DependencyName, _, _).'"]]});function Zgt(t){return typeof t!="string"?`${t}`:t.match(/^[a-zA-Z][a-zA-Z0-9_]+$/)?t:`'${t}'`}function $gt(t,e){let r=t===0,o=t===e-1;return r&&o?"":r?"\u250C ":o?"\u2514 ":"\u2502 "}Ye();jt();var p0=class extends ut{constructor(){super(...arguments);this.verbose=ge.Boolean("-v,--verbose",!1,{description:"Also print the fact database automatically compiled from the workspace manifests"})}async execute(){let{Constraints:r}=await Promise.resolve().then(()=>(x2(),b2)),o=await Ke.find(this.context.cwd,this.context.plugins),{project:a}=await Pt.find(o,this.context.cwd),n=await r.find(a);this.context.stdout.write(this.verbose?n.fullSource:n.source)}};p0.paths=[["constraints","source"]],p0.usage=nt.Usage({category:"Constraints-related commands",description:"print the source code for the constraints",details:"\n This command will print the Prolog source code used by the constraints engine. Adding the `-v,--verbose` flag will print the *full* source code, including the fact database automatically compiled from the workspace manifests.\n ",examples:[["Prints the source code","yarn constraints source"],["Print the source code and the fact database","yarn constraints source -v"]]});Ye();Ye();jt();v2();var h0=class extends ut{constructor(){super(...arguments);this.fix=ge.Boolean("--fix",!1,{description:"Attempt to automatically fix unambiguous issues, following a multi-pass process"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o}=await Pt.find(r,this.context.cwd);await o.restoreInstallState();let a=await o.loadUserConfig(),n;if(a?.constraints)n=new IC(o);else{let{Constraints:h}=await Promise.resolve().then(()=>(x2(),b2));n=await h.find(o)}let u,A=!1,p=!1;for(let h=this.fix?10:1;h>0;--h){let E=await n.process();if(!E)break;let{changedWorkspaces:I,remainingErrors:v}=gk(o,E,{fix:this.fix}),x=[];for(let[C,R]of I){let L=C.manifest.indent;C.manifest=new Ot,C.manifest.indent=L,C.manifest.load(R),x.push(C.persistManifest())}if(await Promise.all(x),!(I.size>0&&h>1)){u=Hde(v,{configuration:r}),A=!1,p=!0;for(let[,C]of v)for(let R of C)R.fixable?A=!0:p=!1}}if(u.children.length===0)return 0;if(A){let h=p?`Those errors can all be fixed by running ${de.pretty(r,"yarn constraints --fix",de.Type.CODE)}`:`Errors prefixed by '\u2699' can be fixed by running ${de.pretty(r,"yarn constraints --fix",de.Type.CODE)}`;await Nt.start({configuration:r,stdout:this.context.stdout,includeNames:!1,includeFooter:!1},async E=>{E.reportInfo(0,h),E.reportSeparator()})}return u.children=_e.sortMap(u.children,h=>h.value[1]),$s.emitTree(u,{configuration:r,stdout:this.context.stdout,json:this.json,separators:1}),1}};h0.paths=[["constraints"]],h0.usage=nt.Usage({category:"Constraints-related commands",description:"check that the project constraints are met",details:` + This command will run constraints on your project and emit errors for each one that is found but isn't met. If any error is emitted the process will exit with a non-zero exit code. + + If the \`--fix\` flag is used, Yarn will attempt to automatically fix the issues the best it can, following a multi-pass process (with a maximum of 10 iterations). Some ambiguous patterns cannot be autofixed, in which case you'll have to manually specify the right resolution. + + For more information as to how to write constraints, please consult our dedicated page on our website: https://yarnpkg.com/features/constraints. + `,examples:[["Check that all constraints are satisfied","yarn constraints"],["Autofix all unmet constraints","yarn constraints --fix"]]});v2();var edt={configuration:{enableConstraintsChecks:{description:"If true, constraints will run during installs",type:"BOOLEAN",default:!1},constraintsPath:{description:"The path of the constraints file.",type:"ABSOLUTE_PATH",default:"./constraints.pro"}},commands:[f0,p0,h0],hooks:{async validateProjectAfterInstall(t,{reportError:e}){if(!t.configuration.get("enableConstraintsChecks"))return;let r=await t.loadUserConfig(),o;if(r?.constraints)o=new IC(t);else{let{Constraints:u}=await Promise.resolve().then(()=>(x2(),b2));o=await u.find(t)}let a=await o.process();if(!a)return;let{remainingErrors:n}=gk(t,a);if(n.size!==0)if(t.configuration.isCI)for(let[u,A]of n)for(let p of A)e(84,`${de.pretty(t.configuration,u.anchoredLocator,de.Type.IDENT)}: ${p.text}`);else e(84,`Constraint check failed; run ${de.pretty(t.configuration,"yarn constraints",de.Type.CODE)} for more details`)}}},tdt=edt;var IH={};Vt(IH,{CreateCommand:()=>tm,DlxCommand:()=>g0,default:()=>ndt});Ye();jt();var tm=class extends ut{constructor(){super(...arguments);this.pkg=ge.String("-p,--package",{description:"The package to run the provided command from"});this.quiet=ge.Boolean("-q,--quiet",!1,{description:"Only report critical errors instead of printing the full install logs"});this.command=ge.String();this.args=ge.Proxy()}async execute(){let r=[];this.pkg&&r.push("--package",this.pkg),this.quiet&&r.push("--quiet");let o=this.command.replace(/^(@[^@/]+)(@|$)/,"$1/create$2"),a=W.parseDescriptor(o),n=a.name.match(/^create(-|$)/)?a:a.scope?W.makeIdent(a.scope,`create-${a.name}`):W.makeIdent(null,`create-${a.name}`),u=W.stringifyIdent(n);return a.range!=="unknown"&&(u+=`@${a.range}`),this.cli.run(["dlx",...r,u,...this.args])}};tm.paths=[["create"]];Ye();Ye();St();jt();var g0=class extends ut{constructor(){super(...arguments);this.packages=ge.Array("-p,--package",{description:"The package(s) to install before running the command"});this.quiet=ge.Boolean("-q,--quiet",!1,{description:"Only report critical errors instead of printing the full install logs"});this.command=ge.String();this.args=ge.Proxy()}async execute(){return Ke.telemetry=null,await oe.mktempPromise(async r=>{let o=V.join(r,`dlx-${process.pid}`);await oe.mkdirPromise(o),await oe.writeFilePromise(V.join(o,"package.json"),`{} +`),await oe.writeFilePromise(V.join(o,"yarn.lock"),"");let a=V.join(o,".yarnrc.yml"),n=await Ke.findProjectCwd(this.context.cwd),A={enableGlobalCache:!(await Ke.find(this.context.cwd,null,{strict:!1})).get("enableGlobalCache"),enableTelemetry:!1,logFilters:[{code:Wu(68),level:de.LogLevel.Discard}]},p=n!==null?V.join(n,".yarnrc.yml"):null;p!==null&&oe.existsSync(p)?(await oe.copyFilePromise(p,a),await Ke.updateConfiguration(o,L=>{let U=_e.toMerged(L,A);return Array.isArray(L.plugins)&&(U.plugins=L.plugins.map(J=>{let te=typeof J=="string"?J:J.path,ae=ue.isAbsolute(te)?te:ue.resolve(ue.fromPortablePath(n),te);return typeof J=="string"?ae:{path:ae,spec:J.spec}})),U})):await oe.writeJsonPromise(a,A);let h=this.packages??[this.command],E=W.parseDescriptor(this.command).name,I=await this.cli.run(["add","--fixed","--",...h],{cwd:o,quiet:this.quiet});if(I!==0)return I;this.quiet||this.context.stdout.write(` +`);let v=await Ke.find(o,this.context.plugins),{project:x,workspace:C}=await Pt.find(v,o);if(C===null)throw new rr(x.cwd,o);await x.restoreInstallState();let R=await un.getWorkspaceAccessibleBinaries(C);return R.has(E)===!1&&R.size===1&&typeof this.packages>"u"&&(E=Array.from(R)[0][0]),await un.executeWorkspaceAccessibleBinary(C,E,this.args,{packageAccessibleBinaries:R,cwd:this.context.cwd,stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr})})}};g0.paths=[["dlx"]],g0.usage=nt.Usage({description:"run a package in a temporary environment",details:"\n This command will install a package within a temporary environment, and run its binary script if it contains any. The binary will run within the current cwd.\n\n By default Yarn will download the package named `command`, but this can be changed through the use of the `-p,--package` flag which will instruct Yarn to still run the same command but from a different package.\n\n Using `yarn dlx` as a replacement of `yarn add` isn't recommended, as it makes your project non-deterministic (Yarn doesn't keep track of the packages installed through `dlx` - neither their name, nor their version).\n ",examples:[["Use create-react-app to create a new React app","yarn dlx create-react-app ./my-app"],["Install multiple packages for a single command",`yarn dlx -p typescript -p ts-node ts-node --transpile-only -e "console.log('hello!')"`]]});var rdt={commands:[tm,g0]},ndt=rdt;var DH={};Vt(DH,{ExecFetcher:()=>Q2,ExecResolver:()=>F2,default:()=>odt,execUtils:()=>Ek});Ye();Ye();St();var fA="exec:";var Ek={};Vt(Ek,{loadGeneratorFile:()=>k2,makeLocator:()=>vH,makeSpec:()=>pme,parseSpec:()=>BH});Ye();St();function BH(t){let{params:e,selector:r}=W.parseRange(t),o=ue.toPortablePath(r);return{parentLocator:e&&typeof e.locator=="string"?W.parseLocator(e.locator):null,path:o}}function pme({parentLocator:t,path:e,generatorHash:r,protocol:o}){let a=t!==null?{locator:W.stringifyLocator(t)}:{},n=typeof r<"u"?{hash:r}:{};return W.makeRange({protocol:o,source:e,selector:e,params:{...n,...a}})}function vH(t,{parentLocator:e,path:r,generatorHash:o,protocol:a}){return W.makeLocator(t,pme({parentLocator:e,path:r,generatorHash:o,protocol:a}))}async function k2(t,e,r){let{parentLocator:o,path:a}=W.parseFileStyleRange(t,{protocol:e}),n=V.isAbsolute(a)?{packageFs:new gn(Bt.root),prefixPath:Bt.dot,localPath:Bt.root}:await r.fetcher.fetch(o,r),u=n.localPath?{packageFs:new gn(Bt.root),prefixPath:V.relative(Bt.root,n.localPath)}:n;n!==u&&n.releaseFs&&n.releaseFs();let A=u.packageFs,p=V.join(u.prefixPath,a);return await A.readFilePromise(p,"utf8")}var Q2=class{supports(e,r){return!!e.reference.startsWith(fA)}getLocalPath(e,r){let{parentLocator:o,path:a}=W.parseFileStyleRange(e.reference,{protocol:fA});if(V.isAbsolute(a))return a;let n=r.fetcher.getLocalPath(o,r);return n===null?null:V.resolve(n,a)}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,[a,n,u]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e),loader:()=>this.fetchFromDisk(e,r),...r.cacheOptions});return{packageFs:a,releaseFs:n,prefixPath:W.getIdentVendorPath(e),localPath:this.getLocalPath(e,r),checksum:u}}async fetchFromDisk(e,r){let o=await k2(e.reference,fA,r);return oe.mktempPromise(async a=>{let n=V.join(a,"generator.js");return await oe.writeFilePromise(n,o),oe.mktempPromise(async u=>{if(await this.generatePackage(u,e,n,r),!oe.existsSync(V.join(u,"build")))throw new Error("The script should have generated a build directory");return await Xi.makeArchiveFromDirectory(V.join(u,"build"),{prefixPath:W.getIdentVendorPath(e),compressionLevel:r.project.configuration.get("compressionLevel")})})})}async generatePackage(e,r,o,a){return await oe.mktempPromise(async n=>{let u=await un.makeScriptEnv({project:a.project,binFolder:n}),A=V.join(e,"runtime.js");return await oe.mktempPromise(async p=>{let h=V.join(p,"buildfile.log"),E=V.join(e,"generator"),I=V.join(e,"build");await oe.mkdirPromise(E),await oe.mkdirPromise(I);let v={tempDir:ue.fromPortablePath(E),buildDir:ue.fromPortablePath(I),locator:W.stringifyLocator(r)};await oe.writeFilePromise(A,` + // Expose 'Module' as a global variable + Object.defineProperty(global, 'Module', { + get: () => require('module'), + configurable: true, + enumerable: false, + }); + + // Expose non-hidden built-in modules as global variables + for (const name of Module.builtinModules.filter((name) => name !== 'module' && !name.startsWith('_'))) { + Object.defineProperty(global, name, { + get: () => require(name), + configurable: true, + enumerable: false, + }); + } + + // Expose the 'execEnv' global variable + Object.defineProperty(global, 'execEnv', { + value: { + ...${JSON.stringify(v)}, + }, + enumerable: true, + }); + `);let x=u.NODE_OPTIONS||"",C=/\s*--require\s+\S*\.pnp\.c?js\s*/g;x=x.replace(C," ").trim(),u.NODE_OPTIONS=x;let{stdout:R,stderr:L}=a.project.configuration.getSubprocessStreams(h,{header:`# This file contains the result of Yarn generating a package (${W.stringifyLocator(r)}) +`,prefix:W.prettyLocator(a.project.configuration,r),report:a.report}),{code:U}=await Ur.pipevp(process.execPath,["--require",ue.fromPortablePath(A),ue.fromPortablePath(o),W.stringifyIdent(r)],{cwd:e,env:u,stdin:null,stdout:R,stderr:L});if(U!==0)throw oe.detachTemp(p),new Error(`Package generation failed (exit code ${U}, logs can be found here: ${de.pretty(a.project.configuration,h,de.Type.PATH)})`)})})}};Ye();Ye();var idt=2,F2=class{supportsDescriptor(e,r){return!!e.range.startsWith(fA)}supportsLocator(e,r){return!!e.reference.startsWith(fA)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,o){return W.bindDescriptor(e,{locator:W.stringifyLocator(r)})}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){if(!o.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let{path:a,parentLocator:n}=BH(e.range);if(n===null)throw new Error("Assertion failed: The descriptor should have been bound");let u=await k2(W.makeRange({protocol:fA,source:a,selector:a,params:{locator:W.stringifyLocator(n)}}),fA,o.fetchOptions),A=wn.makeHash(`${idt}`,u).slice(0,6);return[vH(e,{parentLocator:n,path:a,generatorHash:A,protocol:fA})]}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let o=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),a=await _e.releaseAfterUseAsync(async()=>await Ot.find(o.prefixPath,{baseFs:o.packageFs}),o.releaseFs);return{...e,version:a.version||"0.0.0",languageName:a.languageName||r.project.configuration.get("defaultLanguageName"),linkType:"HARD",conditions:a.getConditions(),dependencies:r.project.configuration.normalizeDependencyMap(a.dependencies),peerDependencies:a.peerDependencies,dependenciesMeta:a.dependenciesMeta,peerDependenciesMeta:a.peerDependenciesMeta,bin:a.bin}}};var sdt={fetchers:[Q2],resolvers:[F2]},odt=sdt;var PH={};Vt(PH,{FileFetcher:()=>L2,FileResolver:()=>O2,TarballFileFetcher:()=>M2,TarballFileResolver:()=>U2,default:()=>cdt,fileUtils:()=>rm});Ye();St();var SC=/^(?:[a-zA-Z]:[\\/]|\.{0,2}\/)/,R2=/^[^?]*\.(?:tar\.gz|tgz)(?:::.*)?$/,Ui="file:";var rm={};Vt(rm,{fetchArchiveFromLocator:()=>N2,makeArchiveFromLocator:()=>Ck,makeBufferFromLocator:()=>SH,makeLocator:()=>PC,makeSpec:()=>hme,parseSpec:()=>T2});Ye();St();function T2(t){let{params:e,selector:r}=W.parseRange(t),o=ue.toPortablePath(r);return{parentLocator:e&&typeof e.locator=="string"?W.parseLocator(e.locator):null,path:o}}function hme({parentLocator:t,path:e,hash:r,protocol:o}){let a=t!==null?{locator:W.stringifyLocator(t)}:{},n=typeof r<"u"?{hash:r}:{};return W.makeRange({protocol:o,source:e,selector:e,params:{...n,...a}})}function PC(t,{parentLocator:e,path:r,hash:o,protocol:a}){return W.makeLocator(t,hme({parentLocator:e,path:r,hash:o,protocol:a}))}async function N2(t,e){let{parentLocator:r,path:o}=W.parseFileStyleRange(t.reference,{protocol:Ui}),a=V.isAbsolute(o)?{packageFs:new gn(Bt.root),prefixPath:Bt.dot,localPath:Bt.root}:await e.fetcher.fetch(r,e),n=a.localPath?{packageFs:new gn(Bt.root),prefixPath:V.relative(Bt.root,a.localPath)}:a;a!==n&&a.releaseFs&&a.releaseFs();let u=n.packageFs,A=V.join(n.prefixPath,o);return await _e.releaseAfterUseAsync(async()=>await u.readFilePromise(A),n.releaseFs)}async function Ck(t,{protocol:e,fetchOptions:r,inMemory:o=!1}){let{parentLocator:a,path:n}=W.parseFileStyleRange(t.reference,{protocol:e}),u=V.isAbsolute(n)?{packageFs:new gn(Bt.root),prefixPath:Bt.dot,localPath:Bt.root}:await r.fetcher.fetch(a,r),A=u.localPath?{packageFs:new gn(Bt.root),prefixPath:V.relative(Bt.root,u.localPath)}:u;u!==A&&u.releaseFs&&u.releaseFs();let p=A.packageFs,h=V.join(A.prefixPath,n);return await _e.releaseAfterUseAsync(async()=>await Xi.makeArchiveFromDirectory(h,{baseFs:p,prefixPath:W.getIdentVendorPath(t),compressionLevel:r.project.configuration.get("compressionLevel"),inMemory:o}),A.releaseFs)}async function SH(t,{protocol:e,fetchOptions:r}){return(await Ck(t,{protocol:e,fetchOptions:r,inMemory:!0})).getBufferAndClose()}var L2=class{supports(e,r){return!!e.reference.startsWith(Ui)}getLocalPath(e,r){let{parentLocator:o,path:a}=W.parseFileStyleRange(e.reference,{protocol:Ui});if(V.isAbsolute(a))return a;let n=r.fetcher.getLocalPath(o,r);return n===null?null:V.resolve(n,a)}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,[a,n,u]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${W.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the disk`),loader:()=>this.fetchFromDisk(e,r),...r.cacheOptions});return{packageFs:a,releaseFs:n,prefixPath:W.getIdentVendorPath(e),localPath:this.getLocalPath(e,r),checksum:u}}async fetchFromDisk(e,r){return Ck(e,{protocol:Ui,fetchOptions:r})}};Ye();Ye();var adt=2,O2=class{supportsDescriptor(e,r){return e.range.match(SC)?!0:!!e.range.startsWith(Ui)}supportsLocator(e,r){return!!e.reference.startsWith(Ui)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,o){return SC.test(e.range)&&(e=W.makeDescriptor(e,`${Ui}${e.range}`)),W.bindDescriptor(e,{locator:W.stringifyLocator(r)})}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){if(!o.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let{path:a,parentLocator:n}=T2(e.range);if(n===null)throw new Error("Assertion failed: The descriptor should have been bound");let u=await SH(W.makeLocator(e,W.makeRange({protocol:Ui,source:a,selector:a,params:{locator:W.stringifyLocator(n)}})),{protocol:Ui,fetchOptions:o.fetchOptions}),A=wn.makeHash(`${adt}`,u).slice(0,6);return[PC(e,{parentLocator:n,path:a,hash:A,protocol:Ui})]}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let o=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),a=await _e.releaseAfterUseAsync(async()=>await Ot.find(o.prefixPath,{baseFs:o.packageFs}),o.releaseFs);return{...e,version:a.version||"0.0.0",languageName:a.languageName||r.project.configuration.get("defaultLanguageName"),linkType:"HARD",conditions:a.getConditions(),dependencies:r.project.configuration.normalizeDependencyMap(a.dependencies),peerDependencies:a.peerDependencies,dependenciesMeta:a.dependenciesMeta,peerDependenciesMeta:a.peerDependenciesMeta,bin:a.bin}}};Ye();var M2=class{supports(e,r){return R2.test(e.reference)?!!e.reference.startsWith(Ui):!1}getLocalPath(e,r){return null}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,[a,n,u]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${W.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the disk`),loader:()=>this.fetchFromDisk(e,r),...r.cacheOptions});return{packageFs:a,releaseFs:n,prefixPath:W.getIdentVendorPath(e),checksum:u}}async fetchFromDisk(e,r){let o=await N2(e,r);return await Xi.convertToZip(o,{configuration:r.project.configuration,prefixPath:W.getIdentVendorPath(e),stripComponents:1})}};Ye();Ye();Ye();var U2=class{supportsDescriptor(e,r){return R2.test(e.range)?!!(e.range.startsWith(Ui)||SC.test(e.range)):!1}supportsLocator(e,r){return R2.test(e.reference)?!!e.reference.startsWith(Ui):!1}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,o){return SC.test(e.range)&&(e=W.makeDescriptor(e,`${Ui}${e.range}`)),W.bindDescriptor(e,{locator:W.stringifyLocator(r)})}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){if(!o.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let{path:a,parentLocator:n}=T2(e.range);if(n===null)throw new Error("Assertion failed: The descriptor should have been bound");let u=PC(e,{parentLocator:n,path:a,hash:"",protocol:Ui}),A=await N2(u,o.fetchOptions),p=wn.makeHash(A).slice(0,6);return[PC(e,{parentLocator:n,path:a,hash:p,protocol:Ui})]}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let o=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),a=await _e.releaseAfterUseAsync(async()=>await Ot.find(o.prefixPath,{baseFs:o.packageFs}),o.releaseFs);return{...e,version:a.version||"0.0.0",languageName:a.languageName||r.project.configuration.get("defaultLanguageName"),linkType:"HARD",conditions:a.getConditions(),dependencies:r.project.configuration.normalizeDependencyMap(a.dependencies),peerDependencies:a.peerDependencies,dependenciesMeta:a.dependenciesMeta,peerDependenciesMeta:a.peerDependenciesMeta,bin:a.bin}}};var ldt={fetchers:[M2,L2],resolvers:[U2,O2]},cdt=ldt;var kH={};Vt(kH,{GithubFetcher:()=>_2,default:()=>Adt,githubUtils:()=>wk});Ye();St();var wk={};Vt(wk,{invalidGithubUrlMessage:()=>mme,isGithubUrl:()=>bH,parseGithubUrl:()=>xH});var gme=$e(ve("querystring")),dme=[/^https?:\/\/(?:([^/]+?)@)?github.com\/([^/#]+)\/([^/#]+)\/tarball\/([^/#]+)(?:#(.*))?$/,/^https?:\/\/(?:([^/]+?)@)?github.com\/([^/#]+)\/([^/#]+?)(?:\.git)?(?:#(.*))?$/];function bH(t){return t?dme.some(e=>!!t.match(e)):!1}function xH(t){let e;for(let A of dme)if(e=t.match(A),e)break;if(!e)throw new Error(mme(t));let[,r,o,a,n="master"]=e,{commit:u}=gme.default.parse(n);return n=u||n.replace(/[^:]*:/,""),{auth:r,username:o,reponame:a,treeish:n}}function mme(t){return`Input cannot be parsed as a valid GitHub URL ('${t}').`}var _2=class{supports(e,r){return!!bH(e.reference)}getLocalPath(e,r){return null}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,[a,n,u]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${W.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from GitHub`),loader:()=>this.fetchFromNetwork(e,r),...r.cacheOptions});return{packageFs:a,releaseFs:n,prefixPath:W.getIdentVendorPath(e),checksum:u}}async fetchFromNetwork(e,r){let o=await nn.get(this.getLocatorUrl(e,r),{configuration:r.project.configuration});return await oe.mktempPromise(async a=>{let n=new gn(a);await Xi.extractArchiveTo(o,n,{stripComponents:1});let u=ra.splitRepoUrl(e.reference),A=V.join(a,"package.tgz");await un.prepareExternalProject(a,A,{configuration:r.project.configuration,report:r.report,workspace:u.extra.workspace,locator:e});let p=await oe.readFilePromise(A);return await Xi.convertToZip(p,{configuration:r.project.configuration,prefixPath:W.getIdentVendorPath(e),stripComponents:1})})}getLocatorUrl(e,r){let{auth:o,username:a,reponame:n,treeish:u}=xH(e.reference);return`https://${o?`${o}@`:""}github.com/${a}/${n}/archive/${u}.tar.gz`}};var udt={hooks:{async fetchHostedRepository(t,e,r){if(t!==null)return t;let o=new _2;if(!o.supports(e,r))return null;try{return await o.fetch(e,r)}catch{return null}}}},Adt=udt;var QH={};Vt(QH,{TarballHttpFetcher:()=>j2,TarballHttpResolver:()=>G2,default:()=>pdt});Ye();function H2(t){let e;try{e=new URL(t)}catch{return!1}return!(e.protocol!=="http:"&&e.protocol!=="https:"||!e.pathname.match(/(\.tar\.gz|\.tgz|\/[^.]+)$/))}var j2=class{supports(e,r){return H2(e.reference)}getLocalPath(e,r){return null}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,[a,n,u]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${W.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the remote server`),loader:()=>this.fetchFromNetwork(e,r),...r.cacheOptions});return{packageFs:a,releaseFs:n,prefixPath:W.getIdentVendorPath(e),checksum:u}}async fetchFromNetwork(e,r){let o=await nn.get(e.reference,{configuration:r.project.configuration});return await Xi.convertToZip(o,{configuration:r.project.configuration,prefixPath:W.getIdentVendorPath(e),stripComponents:1})}};Ye();Ye();var G2=class{supportsDescriptor(e,r){return H2(e.range)}supportsLocator(e,r){return H2(e.reference)}shouldPersistResolution(e,r){return!0}bindDescriptor(e,r,o){return e}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){return[W.convertDescriptorToLocator(e)]}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let o=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),a=await _e.releaseAfterUseAsync(async()=>await Ot.find(o.prefixPath,{baseFs:o.packageFs}),o.releaseFs);return{...e,version:a.version||"0.0.0",languageName:a.languageName||r.project.configuration.get("defaultLanguageName"),linkType:"HARD",conditions:a.getConditions(),dependencies:r.project.configuration.normalizeDependencyMap(a.dependencies),peerDependencies:a.peerDependencies,dependenciesMeta:a.dependenciesMeta,peerDependenciesMeta:a.peerDependenciesMeta,bin:a.bin}}};var fdt={fetchers:[j2],resolvers:[G2]},pdt=fdt;var FH={};Vt(FH,{InitCommand:()=>d0,default:()=>gdt});Ye();Ye();St();jt();var d0=class extends ut{constructor(){super(...arguments);this.private=ge.Boolean("-p,--private",!1,{description:"Initialize a private package"});this.workspace=ge.Boolean("-w,--workspace",!1,{description:"Initialize a workspace root with a `packages/` directory"});this.install=ge.String("-i,--install",!1,{tolerateBoolean:!0,description:"Initialize a package with a specific bundle that will be locked in the project"});this.name=ge.String("-n,--name",{description:"Initialize a package with the given name"});this.usev2=ge.Boolean("-2",!1,{hidden:!0});this.yes=ge.Boolean("-y,--yes",{hidden:!0})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=typeof this.install=="string"?this.install:this.usev2||this.install===!0?"latest":null;return o!==null?await this.executeProxy(r,o):await this.executeRegular(r)}async executeProxy(r,o){if(r.projectCwd!==null&&r.projectCwd!==this.context.cwd)throw new it("Cannot use the --install flag from within a project subdirectory");oe.existsSync(this.context.cwd)||await oe.mkdirPromise(this.context.cwd,{recursive:!0});let a=V.join(this.context.cwd,dr.lockfile);oe.existsSync(a)||await oe.writeFilePromise(a,"");let n=await this.cli.run(["set","version",o],{quiet:!0});if(n!==0)return n;let u=[];return this.private&&u.push("-p"),this.workspace&&u.push("-w"),this.name&&u.push(`-n=${this.name}`),this.yes&&u.push("-y"),await oe.mktempPromise(async A=>{let{code:p}=await Ur.pipevp("yarn",["init",...u],{cwd:this.context.cwd,stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr,env:await un.makeScriptEnv({binFolder:A})});return p})}async executeRegular(r){let o=null;try{o=(await Pt.find(r,this.context.cwd)).project}catch{o=null}oe.existsSync(this.context.cwd)||await oe.mkdirPromise(this.context.cwd,{recursive:!0});let a=await Ot.tryFind(this.context.cwd),n=a??new Ot,u=Object.fromEntries(r.get("initFields").entries());n.load(u),n.name=n.name??W.makeIdent(r.get("initScope"),this.name??V.basename(this.context.cwd)),n.packageManager=rn&&_e.isTaggedYarnVersion(rn)?`yarn@${rn}`:null,(!a&&this.workspace||this.private)&&(n.private=!0),this.workspace&&n.workspaceDefinitions.length===0&&(await oe.mkdirPromise(V.join(this.context.cwd,"packages"),{recursive:!0}),n.workspaceDefinitions=[{pattern:"packages/*"}]);let A={};n.exportTo(A);let p=V.join(this.context.cwd,Ot.fileName);await oe.changeFilePromise(p,`${JSON.stringify(A,null,2)} +`,{automaticNewlines:!0});let h=[p],E=V.join(this.context.cwd,"README.md");if(oe.existsSync(E)||(await oe.writeFilePromise(E,`# ${W.stringifyIdent(n.name)} +`),h.push(E)),!o||o.cwd===this.context.cwd){let I=V.join(this.context.cwd,dr.lockfile);oe.existsSync(I)||(await oe.writeFilePromise(I,""),h.push(I));let x=[".yarn/*","!.yarn/patches","!.yarn/plugins","!.yarn/releases","!.yarn/sdks","!.yarn/versions","","# Swap the comments on the following lines if you wish to use zero-installs","# In that case, don't forget to run `yarn config set enableGlobalCache false`!","# Documentation here: https://yarnpkg.com/features/caching#zero-installs","","#!.yarn/cache",".pnp.*"].map(fe=>`${fe} +`).join(""),C=V.join(this.context.cwd,".gitignore");oe.existsSync(C)||(await oe.writeFilePromise(C,x),h.push(C));let L=["/.yarn/** linguist-vendored","/.yarn/releases/* binary","/.yarn/plugins/**/* binary","/.pnp.* binary linguist-generated"].map(fe=>`${fe} +`).join(""),U=V.join(this.context.cwd,".gitattributes");oe.existsSync(U)||(await oe.writeFilePromise(U,L),h.push(U));let J={["*"]:{endOfLine:"lf",insertFinalNewline:!0},["*.{js,json,yml}"]:{charset:"utf-8",indentStyle:"space",indentSize:2}};_e.mergeIntoTarget(J,r.get("initEditorConfig"));let te=`root = true +`;for(let[fe,ce]of Object.entries(J)){te+=` +[${fe}] +`;for(let[me,he]of Object.entries(ce)){let Be=me.replace(/[A-Z]/g,we=>`_${we.toLowerCase()}`);te+=`${Be} = ${he} +`}}let ae=V.join(this.context.cwd,".editorconfig");oe.existsSync(ae)||(await oe.writeFilePromise(ae,te),h.push(ae)),await this.cli.run(["install"],{quiet:!0}),oe.existsSync(V.join(this.context.cwd,".git"))||(await Ur.execvp("git",["init"],{cwd:this.context.cwd}),await Ur.execvp("git",["add","--",...h],{cwd:this.context.cwd}),await Ur.execvp("git",["commit","--allow-empty","-m","First commit"],{cwd:this.context.cwd}))}}};d0.paths=[["init"]],d0.usage=nt.Usage({description:"create a new package",details:"\n This command will setup a new package in your local directory.\n\n If the `-p,--private` or `-w,--workspace` options are set, the package will be private by default.\n\n If the `-w,--workspace` option is set, the package will be configured to accept a set of workspaces in the `packages/` directory.\n\n If the `-i,--install` option is given a value, Yarn will first download it using `yarn set version` and only then forward the init call to the newly downloaded bundle. Without arguments, the downloaded bundle will be `latest`.\n\n The initial settings of the manifest can be changed by using the `initScope` and `initFields` configuration values. Additionally, Yarn will generate an EditorConfig file whose rules can be altered via `initEditorConfig`, and will initialize a Git repository in the current directory.\n ",examples:[["Create a new package in the local directory","yarn init"],["Create a new private package in the local directory","yarn init -p"],["Create a new package and store the Yarn release inside","yarn init -i=latest"],["Create a new private package and defines it as a workspace root","yarn init -w"]]});var hdt={configuration:{initScope:{description:"Scope used when creating packages via the init command",type:"STRING",default:null},initFields:{description:"Additional fields to set when creating packages via the init command",type:"MAP",valueDefinition:{description:"",type:"ANY"}},initEditorConfig:{description:"Extra rules to define in the generator editorconfig",type:"MAP",valueDefinition:{description:"",type:"ANY"}}},commands:[d0]},gdt=hdt;var Tj={};Vt(Tj,{SearchCommand:()=>w0,UpgradeInteractiveCommand:()=>B0,default:()=>nIt});Ye();var Eme=$e(ve("os"));function bC({stdout:t}){if(Eme.default.endianness()==="BE")throw new Error("Interactive commands cannot be used on big-endian systems because ink depends on yoga-layout-prebuilt which only supports little-endian architectures");if(!t.isTTY)throw new Error("Interactive commands can only be used inside a TTY environment")}jt();var Qye=$e(zH()),XH={appId:"OFCNCOG2CU",apiKey:"6fe4476ee5a1832882e326b506d14126",indexName:"npm-search"},fyt=(0,Qye.default)(XH.appId,XH.apiKey).initIndex(XH.indexName),ZH=async(t,e=0)=>await fyt.search(t,{analyticsTags:["yarn-plugin-interactive-tools"],attributesToRetrieve:["name","version","owner","repository","humanDownloadsLast30Days"],page:e,hitsPerPage:10});var jB=["regular","dev","peer"],w0=class extends ut{async execute(){bC(this.context);let{Gem:e}=await Promise.resolve().then(()=>(cQ(),Bj)),{ScrollableItems:r}=await Promise.resolve().then(()=>(pQ(),fQ)),{useKeypress:o}=await Promise.resolve().then(()=>(UB(),Wwe)),{useMinistore:a}=await Promise.resolve().then(()=>(xj(),bj)),{renderForm:n}=await Promise.resolve().then(()=>(mQ(),dQ)),{default:u}=await Promise.resolve().then(()=>$e(rIe())),{Box:A,Text:p}=await Promise.resolve().then(()=>$e(ic())),{default:h,useEffect:E,useState:I}=await Promise.resolve().then(()=>$e(on())),v=await Ke.find(this.context.cwd,this.context.plugins),x=()=>h.createElement(A,{flexDirection:"row"},h.createElement(A,{flexDirection:"column",width:48},h.createElement(A,null,h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<up>"),"/",h.createElement(p,{bold:!0,color:"cyanBright"},"<down>")," to move between packages.")),h.createElement(A,null,h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<space>")," to select a package.")),h.createElement(A,null,h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<space>")," again to change the target."))),h.createElement(A,{flexDirection:"column"},h.createElement(A,{marginLeft:1},h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<enter>")," to install the selected packages.")),h.createElement(A,{marginLeft:1},h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<ctrl+c>")," to abort.")))),C=()=>h.createElement(h.Fragment,null,h.createElement(A,{width:15},h.createElement(p,{bold:!0,underline:!0,color:"gray"},"Owner")),h.createElement(A,{width:11},h.createElement(p,{bold:!0,underline:!0,color:"gray"},"Version")),h.createElement(A,{width:10},h.createElement(p,{bold:!0,underline:!0,color:"gray"},"Downloads"))),R=()=>h.createElement(A,{width:17},h.createElement(p,{bold:!0,underline:!0,color:"gray"},"Target")),L=({hit:he,active:Be})=>{let[we,g]=a(he.name,null);o({active:Be},(le,ne)=>{if(ne.name!=="space")return;if(!we){g(jB[0]);return}let ee=jB.indexOf(we)+1;ee===jB.length?g(null):g(jB[ee])},[we,g]);let Ee=W.parseIdent(he.name),Se=W.prettyIdent(v,Ee);return h.createElement(A,null,h.createElement(A,{width:45},h.createElement(p,{bold:!0,wrap:"wrap"},Se)),h.createElement(A,{width:14,marginLeft:1},h.createElement(p,{bold:!0,wrap:"truncate"},he.owner.name)),h.createElement(A,{width:10,marginLeft:1},h.createElement(p,{italic:!0,wrap:"truncate"},he.version)),h.createElement(A,{width:16,marginLeft:1},h.createElement(p,null,he.humanDownloadsLast30Days)))},U=({name:he,active:Be})=>{let[we]=a(he,null),g=W.parseIdent(he);return h.createElement(A,null,h.createElement(A,{width:47},h.createElement(p,{bold:!0}," - ",W.prettyIdent(v,g))),jB.map(Ee=>h.createElement(A,{key:Ee,width:14,marginLeft:1},h.createElement(p,null," ",h.createElement(e,{active:we===Ee})," ",h.createElement(p,{bold:!0},Ee)))))},J=()=>h.createElement(A,{marginTop:1},h.createElement(p,null,"Powered by Algolia.")),ae=await n(({useSubmit:he})=>{let Be=a();he(Be);let we=Array.from(Be.keys()).filter(H=>Be.get(H)!==null),[g,Ee]=I(""),[Se,le]=I(0),[ne,ee]=I([]),Ie=H=>{H.match(/\t| /)||Ee(H)},Fe=async()=>{le(0);let H=await ZH(g);H.query===g&&ee(H.hits)},At=async()=>{let H=await ZH(g,Se+1);H.query===g&&H.page-1===Se&&(le(H.page),ee([...ne,...H.hits]))};return E(()=>{g?Fe():ee([])},[g]),h.createElement(A,{flexDirection:"column"},h.createElement(x,null),h.createElement(A,{flexDirection:"row",marginTop:1},h.createElement(p,{bold:!0},"Search: "),h.createElement(A,{width:41},h.createElement(u,{value:g,onChange:Ie,placeholder:"i.e. babel, webpack, react...",showCursor:!1})),h.createElement(C,null)),ne.length?h.createElement(r,{radius:2,loop:!1,children:ne.map(H=>h.createElement(L,{key:H.name,hit:H,active:!1})),willReachEnd:At}):h.createElement(p,{color:"gray"},"Start typing..."),h.createElement(A,{flexDirection:"row",marginTop:1},h.createElement(A,{width:49},h.createElement(p,{bold:!0},"Selected:")),h.createElement(R,null)),we.length?we.map(H=>h.createElement(U,{key:H,name:H,active:!1})):h.createElement(p,{color:"gray"},"No selected packages..."),h.createElement(J,null))},{},{stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr});if(typeof ae>"u")return 1;let fe=Array.from(ae.keys()).filter(he=>ae.get(he)==="regular"),ce=Array.from(ae.keys()).filter(he=>ae.get(he)==="dev"),me=Array.from(ae.keys()).filter(he=>ae.get(he)==="peer");return fe.length&&await this.cli.run(["add",...fe]),ce.length&&await this.cli.run(["add","--dev",...ce]),me&&await this.cli.run(["add","--peer",...me]),0}};w0.paths=[["search"]],w0.usage=nt.Usage({category:"Interactive commands",description:"open the search interface",details:` + This command opens a fullscreen terminal interface where you can search for and install packages from the npm registry. + `,examples:[["Open the search window","yarn search"]]});Ye();jt();E_();var cIe=$e(zn()),lIe=/^((?:[\^~]|>=?)?)([0-9]+)(\.[0-9]+)(\.[0-9]+)((?:-\S+)?)$/,uIe=(t,e)=>t.length>0?[t.slice(0,e)].concat(uIe(t.slice(e),e)):[],B0=class extends ut{async execute(){bC(this.context);let{ItemOptions:e}=await Promise.resolve().then(()=>(aIe(),oIe)),{Pad:r}=await Promise.resolve().then(()=>(Rj(),sIe)),{ScrollableItems:o}=await Promise.resolve().then(()=>(pQ(),fQ)),{useMinistore:a}=await Promise.resolve().then(()=>(xj(),bj)),{renderForm:n}=await Promise.resolve().then(()=>(mQ(),dQ)),{Box:u,Text:A}=await Promise.resolve().then(()=>$e(ic())),{default:p,useEffect:h,useRef:E,useState:I}=await Promise.resolve().then(()=>$e(on())),v=await Ke.find(this.context.cwd,this.context.plugins),{project:x,workspace:C}=await Pt.find(v,this.context.cwd),R=await Lr.find(v);if(!C)throw new rr(x.cwd,this.context.cwd);await x.restoreInstallState({restoreResolutions:!1});let L=this.context.stdout.rows-7,U=(Ee,Se)=>{let le=upe(Ee,Se),ne="";for(let ee of le)ee.added?ne+=de.pretty(v,ee.value,"green"):ee.removed||(ne+=ee.value);return ne},J=(Ee,Se)=>{if(Ee===Se)return Se;let le=W.parseRange(Ee),ne=W.parseRange(Se),ee=le.selector.match(lIe),Ie=ne.selector.match(lIe);if(!ee||!Ie)return U(Ee,Se);let Fe=["gray","red","yellow","green","magenta"],At=null,H="";for(let at=1;at<Fe.length;++at)At!==null||ee[at]!==Ie[at]?(At===null&&(At=Fe[at-1]),H+=de.pretty(v,Ie[at],At)):H+=Ie[at];return H},te=async(Ee,Se,le)=>{let ne=await zc.fetchDescriptorFrom(Ee,le,{project:x,cache:R,preserveModifier:Se,workspace:C});return ne!==null?ne.range:Ee.range},ae=async Ee=>{let Se=cIe.default.valid(Ee.range)?`^${Ee.range}`:Ee.range,[le,ne]=await Promise.all([te(Ee,Ee.range,Se).catch(()=>null),te(Ee,Ee.range,"latest").catch(()=>null)]),ee=[{value:null,label:Ee.range}];return le&&le!==Ee.range?ee.push({value:le,label:J(Ee.range,le)}):ee.push({value:null,label:""}),ne&&ne!==le&&ne!==Ee.range?ee.push({value:ne,label:J(Ee.range,ne)}):ee.push({value:null,label:""}),ee},fe=()=>p.createElement(u,{flexDirection:"row"},p.createElement(u,{flexDirection:"column",width:49},p.createElement(u,{marginLeft:1},p.createElement(A,null,"Press ",p.createElement(A,{bold:!0,color:"cyanBright"},"<up>"),"/",p.createElement(A,{bold:!0,color:"cyanBright"},"<down>")," to select packages.")),p.createElement(u,{marginLeft:1},p.createElement(A,null,"Press ",p.createElement(A,{bold:!0,color:"cyanBright"},"<left>"),"/",p.createElement(A,{bold:!0,color:"cyanBright"},"<right>")," to select versions."))),p.createElement(u,{flexDirection:"column"},p.createElement(u,{marginLeft:1},p.createElement(A,null,"Press ",p.createElement(A,{bold:!0,color:"cyanBright"},"<enter>")," to install.")),p.createElement(u,{marginLeft:1},p.createElement(A,null,"Press ",p.createElement(A,{bold:!0,color:"cyanBright"},"<ctrl+c>")," to abort.")))),ce=()=>p.createElement(u,{flexDirection:"row",paddingTop:1,paddingBottom:1},p.createElement(u,{width:50},p.createElement(A,{bold:!0},p.createElement(A,{color:"greenBright"},"?")," Pick the packages you want to upgrade.")),p.createElement(u,{width:17},p.createElement(A,{bold:!0,underline:!0,color:"gray"},"Current")),p.createElement(u,{width:17},p.createElement(A,{bold:!0,underline:!0,color:"gray"},"Range")),p.createElement(u,{width:17},p.createElement(A,{bold:!0,underline:!0,color:"gray"},"Latest"))),me=({active:Ee,descriptor:Se,suggestions:le})=>{let[ne,ee]=a(Se.descriptorHash,null),Ie=W.stringifyIdent(Se),Fe=Math.max(0,45-Ie.length);return p.createElement(p.Fragment,null,p.createElement(u,null,p.createElement(u,{width:45},p.createElement(A,{bold:!0},W.prettyIdent(v,Se)),p.createElement(r,{active:Ee,length:Fe})),p.createElement(e,{active:Ee,options:le,value:ne,skewer:!0,onChange:ee,sizes:[17,17,17]})))},he=({dependencies:Ee})=>{let[Se,le]=I(Ee.map(()=>null)),ne=E(!0),ee=async Ie=>{let Fe=await ae(Ie);return Fe.filter(At=>At.label!=="").length<=1?null:{descriptor:Ie,suggestions:Fe}};return h(()=>()=>{ne.current=!1},[]),h(()=>{let Ie=Math.trunc(L*1.75),Fe=Ee.slice(0,Ie),At=Ee.slice(Ie),H=uIe(At,L),at=Fe.map(ee).reduce(async(Re,ke)=>{await Re;let xe=await ke;xe!==null&&(!ne.current||le(He=>{let Te=He.findIndex(je=>je===null),Je=[...He];return Je[Te]=xe,Je}))},Promise.resolve());H.reduce((Re,ke)=>Promise.all(ke.map(xe=>Promise.resolve().then(()=>ee(xe)))).then(async xe=>{xe=xe.filter(He=>He!==null),await Re,ne.current&&le(He=>{let Te=He.findIndex(Je=>Je===null);return He.slice(0,Te).concat(xe).concat(He.slice(Te+xe.length))})}),at).then(()=>{ne.current&&le(Re=>Re.filter(ke=>ke!==null))})},[]),Se.length?p.createElement(o,{radius:L>>1,children:Se.map((Ie,Fe)=>Ie!==null?p.createElement(me,{key:Fe,active:!1,descriptor:Ie.descriptor,suggestions:Ie.suggestions}):p.createElement(A,{key:Fe},"Loading..."))}):p.createElement(A,null,"No upgrades found")},we=await n(({useSubmit:Ee})=>{Ee(a());let Se=new Map;for(let ne of x.workspaces)for(let ee of["dependencies","devDependencies"])for(let Ie of ne.manifest[ee].values())x.tryWorkspaceByDescriptor(Ie)===null&&(Ie.range.startsWith("link:")||Se.set(Ie.descriptorHash,Ie));let le=_e.sortMap(Se.values(),ne=>W.stringifyDescriptor(ne));return p.createElement(u,{flexDirection:"column"},p.createElement(fe,null),p.createElement(ce,null),p.createElement(he,{dependencies:le}))},{},{stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr});if(typeof we>"u")return 1;let g=!1;for(let Ee of x.workspaces)for(let Se of["dependencies","devDependencies"]){let le=Ee.manifest[Se];for(let ne of le.values()){let ee=we.get(ne.descriptorHash);typeof ee<"u"&&ee!==null&&(le.set(ne.identHash,W.makeDescriptor(ne,ee)),g=!0)}}return g?await x.installWithNewReport({quiet:this.context.quiet,stdout:this.context.stdout},{cache:R}):0}};B0.paths=[["upgrade-interactive"]],B0.usage=nt.Usage({category:"Interactive commands",description:"open the upgrade interface",details:` + This command opens a fullscreen terminal interface where you can see any out of date packages used by your application, their status compared to the latest versions available on the remote registry, and select packages to upgrade. + `,examples:[["Open the upgrade window","yarn upgrade-interactive"]]});var rIt={commands:[w0,B0]},nIt=rIt;var Nj={};Vt(Nj,{LinkFetcher:()=>qB,LinkResolver:()=>YB,PortalFetcher:()=>WB,PortalResolver:()=>KB,default:()=>sIt});Ye();St();var tp="portal:",rp="link:";var qB=class{supports(e,r){return!!e.reference.startsWith(rp)}getLocalPath(e,r){let{parentLocator:o,path:a}=W.parseFileStyleRange(e.reference,{protocol:rp});if(V.isAbsolute(a))return a;let n=r.fetcher.getLocalPath(o,r);return n===null?null:V.resolve(n,a)}async fetch(e,r){let{parentLocator:o,path:a}=W.parseFileStyleRange(e.reference,{protocol:rp}),n=V.isAbsolute(a)?{packageFs:new gn(Bt.root),prefixPath:Bt.dot,localPath:Bt.root}:await r.fetcher.fetch(o,r),u=n.localPath?{packageFs:new gn(Bt.root),prefixPath:V.relative(Bt.root,n.localPath),localPath:Bt.root}:n;n!==u&&n.releaseFs&&n.releaseFs();let A=u.packageFs,p=V.resolve(u.localPath??u.packageFs.getRealPath(),u.prefixPath,a);return n.localPath?{packageFs:new gn(p,{baseFs:A}),releaseFs:u.releaseFs,prefixPath:Bt.dot,discardFromLookup:!0,localPath:p}:{packageFs:new _u(p,{baseFs:A}),releaseFs:u.releaseFs,prefixPath:Bt.dot,discardFromLookup:!0}}};Ye();St();var YB=class{supportsDescriptor(e,r){return!!e.range.startsWith(rp)}supportsLocator(e,r){return!!e.reference.startsWith(rp)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,o){return W.bindDescriptor(e,{locator:W.stringifyLocator(r)})}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){let a=e.range.slice(rp.length);return[W.makeLocator(e,`${rp}${ue.toPortablePath(a)}`)]}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){return{...e,version:"0.0.0",languageName:r.project.configuration.get("defaultLanguageName"),linkType:"SOFT",conditions:null,dependencies:new Map,peerDependencies:new Map,dependenciesMeta:new Map,peerDependenciesMeta:new Map,bin:new Map}}};Ye();St();var WB=class{supports(e,r){return!!e.reference.startsWith(tp)}getLocalPath(e,r){let{parentLocator:o,path:a}=W.parseFileStyleRange(e.reference,{protocol:tp});if(V.isAbsolute(a))return a;let n=r.fetcher.getLocalPath(o,r);return n===null?null:V.resolve(n,a)}async fetch(e,r){let{parentLocator:o,path:a}=W.parseFileStyleRange(e.reference,{protocol:tp}),n=V.isAbsolute(a)?{packageFs:new gn(Bt.root),prefixPath:Bt.dot,localPath:Bt.root}:await r.fetcher.fetch(o,r),u=n.localPath?{packageFs:new gn(Bt.root),prefixPath:V.relative(Bt.root,n.localPath),localPath:Bt.root}:n;n!==u&&n.releaseFs&&n.releaseFs();let A=u.packageFs,p=V.resolve(u.localPath??u.packageFs.getRealPath(),u.prefixPath,a);return n.localPath?{packageFs:new gn(p,{baseFs:A}),releaseFs:u.releaseFs,prefixPath:Bt.dot,localPath:p}:{packageFs:new _u(p,{baseFs:A}),releaseFs:u.releaseFs,prefixPath:Bt.dot}}};Ye();Ye();St();var KB=class{supportsDescriptor(e,r){return!!e.range.startsWith(tp)}supportsLocator(e,r){return!!e.reference.startsWith(tp)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,o){return W.bindDescriptor(e,{locator:W.stringifyLocator(r)})}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){let a=e.range.slice(tp.length);return[W.makeLocator(e,`${tp}${ue.toPortablePath(a)}`)]}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){if(!r.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let o=await r.fetchOptions.fetcher.fetch(e,r.fetchOptions),a=await _e.releaseAfterUseAsync(async()=>await Ot.find(o.prefixPath,{baseFs:o.packageFs}),o.releaseFs);return{...e,version:a.version||"0.0.0",languageName:a.languageName||r.project.configuration.get("defaultLanguageName"),linkType:"SOFT",conditions:a.getConditions(),dependencies:r.project.configuration.normalizeDependencyMap(a.dependencies),peerDependencies:a.peerDependencies,dependenciesMeta:a.dependenciesMeta,peerDependenciesMeta:a.peerDependenciesMeta,bin:a.bin}}};var iIt={fetchers:[qB,WB],resolvers:[YB,KB]},sIt=iIt;var yG={};Vt(yG,{NodeModulesLinker:()=>lv,NodeModulesMode:()=>hG,PnpLooseLinker:()=>cv,default:()=>w1t});St();Ye();St();St();var Oj=(t,e)=>`${t}@${e}`,AIe=(t,e)=>{let r=e.indexOf("#"),o=r>=0?e.substring(r+1):e;return Oj(t,o)};var hIe=(t,e={})=>{let r=e.debugLevel||Number(process.env.NM_DEBUG_LEVEL||-1),o=e.check||r>=9,a=e.hoistingLimits||new Map,n={check:o,debugLevel:r,hoistingLimits:a,fastLookupPossible:!0},u;n.debugLevel>=0&&(u=Date.now());let A=fIt(t,n),p=!1,h=0;do p=Mj(A,[A],new Set([A.locator]),new Map,n).anotherRoundNeeded,n.fastLookupPossible=!1,h++;while(p);if(n.debugLevel>=0&&console.log(`hoist time: ${Date.now()-u}ms, rounds: ${h}`),n.debugLevel>=1){let E=VB(A);if(Mj(A,[A],new Set([A.locator]),new Map,n).isGraphChanged)throw new Error(`The hoisting result is not terminal, prev tree: +${E}, next tree: +${VB(A)}`);let v=gIe(A);if(v)throw new Error(`${v}, after hoisting finished: +${VB(A)}`)}return n.debugLevel>=2&&console.log(VB(A)),pIt(A)},oIt=t=>{let e=t[t.length-1],r=new Map,o=new Set,a=n=>{if(!o.has(n)){o.add(n);for(let u of n.hoistedDependencies.values())r.set(u.name,u);for(let u of n.dependencies.values())n.peerNames.has(u.name)||a(u)}};return a(e),r},aIt=t=>{let e=t[t.length-1],r=new Map,o=new Set,a=new Set,n=(u,A)=>{if(o.has(u))return;o.add(u);for(let h of u.hoistedDependencies.values())if(!A.has(h.name)){let E;for(let I of t)E=I.dependencies.get(h.name),E&&r.set(E.name,E)}let p=new Set;for(let h of u.dependencies.values())p.add(h.name);for(let h of u.dependencies.values())u.peerNames.has(h.name)||n(h,p)};return n(e,a),r},fIe=(t,e)=>{if(e.decoupled)return e;let{name:r,references:o,ident:a,locator:n,dependencies:u,originalDependencies:A,hoistedDependencies:p,peerNames:h,reasons:E,isHoistBorder:I,hoistPriority:v,dependencyKind:x,hoistedFrom:C,hoistedTo:R}=e,L={name:r,references:new Set(o),ident:a,locator:n,dependencies:new Map(u),originalDependencies:new Map(A),hoistedDependencies:new Map(p),peerNames:new Set(h),reasons:new Map(E),decoupled:!0,isHoistBorder:I,hoistPriority:v,dependencyKind:x,hoistedFrom:new Map(C),hoistedTo:new Map(R)},U=L.dependencies.get(r);return U&&U.ident==L.ident&&L.dependencies.set(r,L),t.dependencies.set(L.name,L),L},lIt=(t,e)=>{let r=new Map([[t.name,[t.ident]]]);for(let a of t.dependencies.values())t.peerNames.has(a.name)||r.set(a.name,[a.ident]);let o=Array.from(e.keys());o.sort((a,n)=>{let u=e.get(a),A=e.get(n);return A.hoistPriority!==u.hoistPriority?A.hoistPriority-u.hoistPriority:A.peerDependents.size!==u.peerDependents.size?A.peerDependents.size-u.peerDependents.size:A.dependents.size-u.dependents.size});for(let a of o){let n=a.substring(0,a.indexOf("@",1)),u=a.substring(n.length+1);if(!t.peerNames.has(n)){let A=r.get(n);A||(A=[],r.set(n,A)),A.indexOf(u)<0&&A.push(u)}}return r},Lj=t=>{let e=new Set,r=(o,a=new Set)=>{if(!a.has(o)){a.add(o);for(let n of o.peerNames)if(!t.peerNames.has(n)){let u=t.dependencies.get(n);u&&!e.has(u)&&r(u,a)}e.add(o)}};for(let o of t.dependencies.values())t.peerNames.has(o.name)||r(o);return e},Mj=(t,e,r,o,a,n=new Set)=>{let u=e[e.length-1];if(n.has(u))return{anotherRoundNeeded:!1,isGraphChanged:!1};n.add(u);let A=hIt(u),p=lIt(u,A),h=t==u?new Map:a.fastLookupPossible?oIt(e):aIt(e),E,I=!1,v=!1,x=new Map(Array.from(p.entries()).map(([R,L])=>[R,L[0]])),C=new Map;do{let R=AIt(t,e,r,h,x,p,o,C,a);R.isGraphChanged&&(v=!0),R.anotherRoundNeeded&&(I=!0),E=!1;for(let[L,U]of p)U.length>1&&!u.dependencies.has(L)&&(x.delete(L),U.shift(),x.set(L,U[0]),E=!0)}while(E);for(let R of u.dependencies.values())if(!u.peerNames.has(R.name)&&!r.has(R.locator)){r.add(R.locator);let L=Mj(t,[...e,R],r,C,a);L.isGraphChanged&&(v=!0),L.anotherRoundNeeded&&(I=!0),r.delete(R.locator)}return{anotherRoundNeeded:I,isGraphChanged:v}},cIt=t=>{for(let[e,r]of t.dependencies)if(!t.peerNames.has(e)&&r.ident!==t.ident)return!0;return!1},uIt=(t,e,r,o,a,n,u,A,{outputReason:p,fastLookupPossible:h})=>{let E,I=null,v=new Set;p&&(E=`${Array.from(e).map(L=>no(L)).join("\u2192")}`);let x=r[r.length-1],R=!(o.ident===x.ident);if(p&&!R&&(I="- self-reference"),R&&(R=o.dependencyKind!==1,p&&!R&&(I="- workspace")),R&&o.dependencyKind===2&&(R=!cIt(o),p&&!R&&(I="- external soft link with unhoisted dependencies")),R&&(R=x.dependencyKind!==1||x.hoistedFrom.has(o.name)||e.size===1,p&&!R&&(I=x.reasons.get(o.name))),R&&(R=!t.peerNames.has(o.name),p&&!R&&(I=`- cannot shadow peer: ${no(t.originalDependencies.get(o.name).locator)} at ${E}`)),R){let L=!1,U=a.get(o.name);if(L=!U||U.ident===o.ident,p&&!L&&(I=`- filled by: ${no(U.locator)} at ${E}`),L)for(let J=r.length-1;J>=1;J--){let ae=r[J].dependencies.get(o.name);if(ae&&ae.ident!==o.ident){L=!1;let fe=A.get(x);fe||(fe=new Set,A.set(x,fe)),fe.add(o.name),p&&(I=`- filled by ${no(ae.locator)} at ${r.slice(0,J).map(ce=>no(ce.locator)).join("\u2192")}`);break}}R=L}if(R&&(R=n.get(o.name)===o.ident,p&&!R&&(I=`- filled by: ${no(u.get(o.name)[0])} at ${E}`)),R){let L=!0,U=new Set(o.peerNames);for(let J=r.length-1;J>=1;J--){let te=r[J];for(let ae of U){if(te.peerNames.has(ae)&&te.originalDependencies.has(ae))continue;let fe=te.dependencies.get(ae);fe&&t.dependencies.get(ae)!==fe&&(J===r.length-1?v.add(fe):(v=null,L=!1,p&&(I=`- peer dependency ${no(fe.locator)} from parent ${no(te.locator)} was not hoisted to ${E}`))),U.delete(ae)}if(!L)break}R=L}if(R&&!h)for(let L of o.hoistedDependencies.values()){let U=a.get(L.name)||t.dependencies.get(L.name);if(!U||L.ident!==U.ident){R=!1,p&&(I=`- previously hoisted dependency mismatch, needed: ${no(L.locator)}, available: ${no(U?.locator)}`);break}}return v!==null&&v.size>0?{isHoistable:2,dependsOn:v,reason:I}:{isHoistable:R?0:1,reason:I}},yQ=t=>`${t.name}@${t.locator}`,AIt=(t,e,r,o,a,n,u,A,p)=>{let h=e[e.length-1],E=new Set,I=!1,v=!1,x=(U,J,te,ae,fe)=>{if(E.has(ae))return;let ce=[...J,yQ(ae)],me=[...te,yQ(ae)],he=new Map,Be=new Map;for(let le of Lj(ae)){let ne=uIt(h,r,[h,...U,ae],le,o,a,n,A,{outputReason:p.debugLevel>=2,fastLookupPossible:p.fastLookupPossible});if(Be.set(le,ne),ne.isHoistable===2)for(let ee of ne.dependsOn){let Ie=he.get(ee.name)||new Set;Ie.add(le.name),he.set(ee.name,Ie)}}let we=new Set,g=(le,ne,ee)=>{if(!we.has(le)){we.add(le),Be.set(le,{isHoistable:1,reason:ee});for(let Ie of he.get(le.name)||[])g(ae.dependencies.get(Ie),ne,p.debugLevel>=2?`- peer dependency ${no(le.locator)} from parent ${no(ae.locator)} was not hoisted`:"")}};for(let[le,ne]of Be)ne.isHoistable===1&&g(le,ne,ne.reason);let Ee=!1;for(let le of Be.keys())if(!we.has(le)){v=!0;let ne=u.get(ae);ne&&ne.has(le.name)&&(I=!0),Ee=!0,ae.dependencies.delete(le.name),ae.hoistedDependencies.set(le.name,le),ae.reasons.delete(le.name);let ee=h.dependencies.get(le.name);if(p.debugLevel>=2){let Ie=Array.from(J).concat([ae.locator]).map(At=>no(At)).join("\u2192"),Fe=h.hoistedFrom.get(le.name);Fe||(Fe=[],h.hoistedFrom.set(le.name,Fe)),Fe.push(Ie),ae.hoistedTo.set(le.name,Array.from(e).map(At=>no(At.locator)).join("\u2192"))}if(!ee)h.ident!==le.ident&&(h.dependencies.set(le.name,le),fe.add(le));else for(let Ie of le.references)ee.references.add(Ie)}if(ae.dependencyKind===2&&Ee&&(I=!0),p.check){let le=gIe(t);if(le)throw new Error(`${le}, after hoisting dependencies of ${[h,...U,ae].map(ne=>no(ne.locator)).join("\u2192")}: +${VB(t)}`)}let Se=Lj(ae);for(let le of Se)if(we.has(le)){let ne=Be.get(le);if((a.get(le.name)===le.ident||!ae.reasons.has(le.name))&&ne.isHoistable!==0&&ae.reasons.set(le.name,ne.reason),!le.isHoistBorder&&me.indexOf(yQ(le))<0){E.add(ae);let Ie=fIe(ae,le);x([...U,ae],ce,me,Ie,R),E.delete(ae)}}},C,R=new Set(Lj(h)),L=Array.from(e).map(U=>yQ(U));do{C=R,R=new Set;for(let U of C){if(U.locator===h.locator||U.isHoistBorder)continue;let J=fIe(h,U);x([],Array.from(r),L,J,R)}}while(R.size>0);return{anotherRoundNeeded:I,isGraphChanged:v}},gIe=t=>{let e=[],r=new Set,o=new Set,a=(n,u,A)=>{if(r.has(n)||(r.add(n),o.has(n)))return;let p=new Map(u);for(let h of n.dependencies.values())n.peerNames.has(h.name)||p.set(h.name,h);for(let h of n.originalDependencies.values()){let E=p.get(h.name),I=()=>`${Array.from(o).concat([n]).map(v=>no(v.locator)).join("\u2192")}`;if(n.peerNames.has(h.name)){let v=u.get(h.name);(v!==E||!v||v.ident!==h.ident)&&e.push(`${I()} - broken peer promise: expected ${h.ident} but found ${v&&v.ident}`)}else{let v=A.hoistedFrom.get(n.name),x=n.hoistedTo.get(h.name),C=`${v?` hoisted from ${v.join(", ")}`:""}`,R=`${x?` hoisted to ${x}`:""}`,L=`${I()}${C}`;E?E.ident!==h.ident&&e.push(`${L} - broken require promise for ${h.name}${R}: expected ${h.ident}, but found: ${E.ident}`):e.push(`${L} - broken require promise: no required dependency ${h.name}${R} found`)}}o.add(n);for(let h of n.dependencies.values())n.peerNames.has(h.name)||a(h,p,n);o.delete(n)};return a(t,t.dependencies,t),e.join(` +`)},fIt=(t,e)=>{let{identName:r,name:o,reference:a,peerNames:n}=t,u={name:o,references:new Set([a]),locator:Oj(r,a),ident:AIe(r,a),dependencies:new Map,originalDependencies:new Map,hoistedDependencies:new Map,peerNames:new Set(n),reasons:new Map,decoupled:!0,isHoistBorder:!0,hoistPriority:0,dependencyKind:1,hoistedFrom:new Map,hoistedTo:new Map},A=new Map([[t,u]]),p=(h,E)=>{let I=A.get(h),v=!!I;if(!I){let{name:x,identName:C,reference:R,peerNames:L,hoistPriority:U,dependencyKind:J}=h,te=e.hoistingLimits.get(E.locator);I={name:x,references:new Set([R]),locator:Oj(C,R),ident:AIe(C,R),dependencies:new Map,originalDependencies:new Map,hoistedDependencies:new Map,peerNames:new Set(L),reasons:new Map,decoupled:!0,isHoistBorder:te?te.has(x):!1,hoistPriority:U||0,dependencyKind:J||0,hoistedFrom:new Map,hoistedTo:new Map},A.set(h,I)}if(E.dependencies.set(h.name,I),E.originalDependencies.set(h.name,I),v){let x=new Set,C=R=>{if(!x.has(R)){x.add(R),R.decoupled=!1;for(let L of R.dependencies.values())R.peerNames.has(L.name)||C(L)}};C(I)}else for(let x of h.dependencies)p(x,I)};for(let h of t.dependencies)p(h,u);return u},Uj=t=>t.substring(0,t.indexOf("@",1)),pIt=t=>{let e={name:t.name,identName:Uj(t.locator),references:new Set(t.references),dependencies:new Set},r=new Set([t]),o=(a,n,u)=>{let A=r.has(a),p;if(n===a)p=u;else{let{name:h,references:E,locator:I}=a;p={name:h,identName:Uj(I),references:E,dependencies:new Set}}if(u.dependencies.add(p),!A){r.add(a);for(let h of a.dependencies.values())a.peerNames.has(h.name)||o(h,a,p);r.delete(a)}};for(let a of t.dependencies.values())o(a,t,e);return e},hIt=t=>{let e=new Map,r=new Set([t]),o=u=>`${u.name}@${u.ident}`,a=u=>{let A=o(u),p=e.get(A);return p||(p={dependents:new Set,peerDependents:new Set,hoistPriority:0},e.set(A,p)),p},n=(u,A)=>{let p=!!r.has(A);if(a(A).dependents.add(u.ident),!p){r.add(A);for(let E of A.dependencies.values()){let I=a(E);I.hoistPriority=Math.max(I.hoistPriority,E.hoistPriority),A.peerNames.has(E.name)?I.peerDependents.add(A.ident):n(A,E)}}};for(let u of t.dependencies.values())t.peerNames.has(u.name)||n(t,u);return e},no=t=>{if(!t)return"none";let e=t.indexOf("@",1),r=t.substring(0,e);r.endsWith("$wsroot$")&&(r=`wh:${r.replace("$wsroot$","")}`);let o=t.substring(e+1);if(o==="workspace:.")return".";if(o){let a=(o.indexOf("#")>0?o.split("#")[1]:o).replace("npm:","");return o.startsWith("virtual")&&(r=`v:${r}`),a.startsWith("workspace")&&(r=`w:${r}`,a=""),`${r}${a?`@${a}`:""}`}else return`${r}`},pIe=5e4,VB=t=>{let e=0,r=(a,n,u="")=>{if(e>pIe||n.has(a))return"";e++;let A=Array.from(a.dependencies.values()).sort((h,E)=>h.name===E.name?0:h.name>E.name?1:-1),p="";n.add(a);for(let h=0;h<A.length;h++){let E=A[h];if(!a.peerNames.has(E.name)&&E!==a){let I=a.reasons.get(E.name),v=Uj(E.locator);p+=`${u}${h<A.length-1?"\u251C\u2500":"\u2514\u2500"}${(n.has(E)?">":"")+(v!==E.name?`a:${E.name}:`:"")+no(E.locator)+(I?` ${I}`:"")} +`,p+=r(E,n,`${u}${h<A.length-1?"\u2502 ":" "}`)}}return n.delete(a),p};return r(t,new Set)+(e>pIe?` +Tree is too large, part of the tree has been dunped +`:"")};var JB=(o=>(o.WORKSPACES="workspaces",o.DEPENDENCIES="dependencies",o.NONE="none",o))(JB||{}),dIe="node_modules",v0="$wsroot$";var zB=(t,e)=>{let{packageTree:r,hoistingLimits:o,errors:a,preserveSymlinksRequired:n}=dIt(t,e),u=null;if(a.length===0){let A=hIe(r,{hoistingLimits:o});u=yIt(t,A,e)}return{tree:u,errors:a,preserveSymlinksRequired:n}},gA=t=>`${t.name}@${t.reference}`,Hj=t=>{let e=new Map;for(let[r,o]of t.entries())if(!o.dirList){let a=e.get(o.locator);a||(a={target:o.target,linkType:o.linkType,locations:[],aliases:o.aliases},e.set(o.locator,a)),a.locations.push(r)}for(let r of e.values())r.locations=r.locations.sort((o,a)=>{let n=o.split(V.delimiter).length,u=a.split(V.delimiter).length;return a===o?0:n!==u?u-n:a>o?1:-1});return e},mIe=(t,e)=>{let r=W.isVirtualLocator(t)?W.devirtualizeLocator(t):t,o=W.isVirtualLocator(e)?W.devirtualizeLocator(e):e;return W.areLocatorsEqual(r,o)},_j=(t,e,r,o)=>{if(t.linkType!=="SOFT")return!1;let a=ue.toPortablePath(r.resolveVirtual&&e.reference&&e.reference.startsWith("virtual:")?r.resolveVirtual(t.packageLocation):t.packageLocation);return V.contains(o,a)===null},gIt=t=>{let e=t.getPackageInformation(t.topLevel);if(e===null)throw new Error("Assertion failed: Expected the top-level package to have been registered");if(t.findPackageLocator(e.packageLocation)===null)throw new Error("Assertion failed: Expected the top-level package to have a physical locator");let o=ue.toPortablePath(e.packageLocation.slice(0,-1)),a=new Map,n={children:new Map},u=t.getDependencyTreeRoots(),A=new Map,p=new Set,h=(v,x)=>{let C=gA(v);if(p.has(C))return;p.add(C);let R=t.getPackageInformation(v);if(R){let L=x?gA(x):"";if(gA(v)!==L&&R.linkType==="SOFT"&&!v.reference.startsWith("link:")&&!_j(R,v,t,o)){let U=yIe(R,v,t);(!A.get(U)||v.reference.startsWith("workspace:"))&&A.set(U,v)}for(let[U,J]of R.packageDependencies)J!==null&&(R.packagePeers.has(U)||h(t.getLocator(U,J),v))}};for(let v of u)h(v,null);let E=o.split(V.sep);for(let v of A.values()){let x=t.getPackageInformation(v),R=ue.toPortablePath(x.packageLocation.slice(0,-1)).split(V.sep).slice(E.length),L=n;for(let U of R){let J=L.children.get(U);J||(J={children:new Map},L.children.set(U,J)),L=J}L.workspaceLocator=v}let I=(v,x)=>{if(v.workspaceLocator){let C=gA(x),R=a.get(C);R||(R=new Set,a.set(C,R)),R.add(v.workspaceLocator)}for(let C of v.children.values())I(C,v.workspaceLocator||x)};for(let v of n.children.values())I(v,n.workspaceLocator);return a},dIt=(t,e)=>{let r=[],o=!1,a=new Map,n=gIt(t),u=t.getPackageInformation(t.topLevel);if(u===null)throw new Error("Assertion failed: Expected the top-level package to have been registered");let A=t.findPackageLocator(u.packageLocation);if(A===null)throw new Error("Assertion failed: Expected the top-level package to have a physical locator");let p=ue.toPortablePath(u.packageLocation.slice(0,-1)),h={name:A.name,identName:A.name,reference:A.reference,peerNames:u.packagePeers,dependencies:new Set,dependencyKind:1},E=new Map,I=(x,C)=>`${gA(C)}:${x}`,v=(x,C,R,L,U,J,te,ae)=>{let fe=I(x,R),ce=E.get(fe),me=!!ce;!me&&R.name===A.name&&R.reference===A.reference&&(ce=h,E.set(fe,h));let he=_j(C,R,t,p);if(!ce){let le=0;he?le=2:C.linkType==="SOFT"&&R.name.endsWith(v0)&&(le=1),ce={name:x,identName:R.name,reference:R.reference,dependencies:new Set,peerNames:le===1?new Set:C.packagePeers,dependencyKind:le},E.set(fe,ce)}let Be;if(he?Be=2:U.linkType==="SOFT"?Be=1:Be=0,ce.hoistPriority=Math.max(ce.hoistPriority||0,Be),ae&&!he){let le=gA({name:L.identName,reference:L.reference}),ne=a.get(le)||new Set;a.set(le,ne),ne.add(ce.name)}let we=new Map(C.packageDependencies);if(e.project){let le=e.project.workspacesByCwd.get(ue.toPortablePath(C.packageLocation.slice(0,-1)));if(le){let ne=new Set([...Array.from(le.manifest.peerDependencies.values(),ee=>W.stringifyIdent(ee)),...Array.from(le.manifest.peerDependenciesMeta.keys())]);for(let ee of ne)we.has(ee)||(we.set(ee,J.get(ee)||null),ce.peerNames.add(ee))}}let g=gA({name:R.name.replace(v0,""),reference:R.reference}),Ee=n.get(g);if(Ee)for(let le of Ee)we.set(`${le.name}${v0}`,le.reference);(C!==U||C.linkType!=="SOFT"||!he&&(!e.selfReferencesByCwd||e.selfReferencesByCwd.get(te)))&&L.dependencies.add(ce);let Se=R!==A&&C.linkType==="SOFT"&&!R.name.endsWith(v0)&&!he;if(!me&&!Se){let le=new Map;for(let[ne,ee]of we)if(ee!==null){let Ie=t.getLocator(ne,ee),Fe=t.getLocator(ne.replace(v0,""),ee),At=t.getPackageInformation(Fe);if(At===null)throw new Error("Assertion failed: Expected the package to have been registered");let H=_j(At,Ie,t,p);if(e.validateExternalSoftLinks&&e.project&&H){At.packageDependencies.size>0&&(o=!0);for(let[He,Te]of At.packageDependencies)if(Te!==null){let Je=W.parseLocator(Array.isArray(Te)?`${Te[0]}@${Te[1]}`:`${He}@${Te}`);if(gA(Je)!==gA(Ie)){let je=we.get(He);if(je){let b=W.parseLocator(Array.isArray(je)?`${je[0]}@${je[1]}`:`${He}@${je}`);mIe(b,Je)||r.push({messageName:71,text:`Cannot link ${W.prettyIdent(e.project.configuration,W.parseIdent(Ie.name))} into ${W.prettyLocator(e.project.configuration,W.parseLocator(`${R.name}@${R.reference}`))} dependency ${W.prettyLocator(e.project.configuration,Je)} conflicts with parent dependency ${W.prettyLocator(e.project.configuration,b)}`})}else{let b=le.get(He);if(b){let w=b.target,P=W.parseLocator(Array.isArray(w)?`${w[0]}@${w[1]}`:`${He}@${w}`);mIe(P,Je)||r.push({messageName:71,text:`Cannot link ${W.prettyIdent(e.project.configuration,W.parseIdent(Ie.name))} into ${W.prettyLocator(e.project.configuration,W.parseLocator(`${R.name}@${R.reference}`))} dependency ${W.prettyLocator(e.project.configuration,Je)} conflicts with dependency ${W.prettyLocator(e.project.configuration,P)} from sibling portal ${W.prettyIdent(e.project.configuration,W.parseIdent(b.portal.name))}`})}else le.set(He,{target:Je.reference,portal:Ie})}}}}let at=e.hoistingLimitsByCwd?.get(te),Re=H?te:V.relative(p,ue.toPortablePath(At.packageLocation))||Bt.dot,ke=e.hoistingLimitsByCwd?.get(Re);v(ne,At,Ie,ce,C,we,Re,at==="dependencies"||ke==="dependencies"||ke==="workspaces")}}};return v(A.name,u,A,h,u,u.packageDependencies,Bt.dot,!1),{packageTree:h,hoistingLimits:a,errors:r,preserveSymlinksRequired:o}};function yIe(t,e,r){let o=r.resolveVirtual&&e.reference&&e.reference.startsWith("virtual:")?r.resolveVirtual(t.packageLocation):t.packageLocation;return ue.toPortablePath(o||t.packageLocation)}function mIt(t,e,r){let o=e.getLocator(t.name.replace(v0,""),t.reference),a=e.getPackageInformation(o);if(a===null)throw new Error("Assertion failed: Expected the package to be registered");return r.pnpifyFs?{linkType:"SOFT",target:ue.toPortablePath(a.packageLocation)}:{linkType:a.linkType,target:yIe(a,t,e)}}var yIt=(t,e,r)=>{let o=new Map,a=(E,I,v)=>{let{linkType:x,target:C}=mIt(E,t,r);return{locator:gA(E),nodePath:I,target:C,linkType:x,aliases:v}},n=E=>{let[I,v]=E.split("/");return v?{scope:I,name:v}:{scope:null,name:I}},u=new Set,A=(E,I,v)=>{if(u.has(E))return;u.add(E);let x=Array.from(E.references).sort().join("#");for(let C of E.dependencies){let R=Array.from(C.references).sort().join("#");if(C.identName===E.identName.replace(v0,"")&&R===x)continue;let L=Array.from(C.references).sort(),U={name:C.identName,reference:L[0]},{name:J,scope:te}=n(C.name),ae=te?[te,J]:[J],fe=V.join(I,dIe),ce=V.join(fe,...ae),me=`${v}/${U.name}`,he=a(U,v,L.slice(1)),Be=!1;if(he.linkType==="SOFT"&&r.project){let we=r.project.workspacesByCwd.get(he.target.slice(0,-1));Be=!!(we&&!we.manifest.name)}if(!C.name.endsWith(v0)&&!Be){let we=o.get(ce);if(we){if(we.dirList)throw new Error(`Assertion failed: ${ce} cannot merge dir node with leaf node`);{let Se=W.parseLocator(we.locator),le=W.parseLocator(he.locator);if(we.linkType!==he.linkType)throw new Error(`Assertion failed: ${ce} cannot merge nodes with different link types ${we.nodePath}/${W.stringifyLocator(Se)} and ${v}/${W.stringifyLocator(le)}`);if(Se.identHash!==le.identHash)throw new Error(`Assertion failed: ${ce} cannot merge nodes with different idents ${we.nodePath}/${W.stringifyLocator(Se)} and ${v}/s${W.stringifyLocator(le)}`);he.aliases=[...he.aliases,...we.aliases,W.parseLocator(we.locator).reference]}}o.set(ce,he);let g=ce.split("/"),Ee=g.indexOf(dIe);for(let Se=g.length-1;Ee>=0&&Se>Ee;Se--){let le=ue.toPortablePath(g.slice(0,Se).join(V.sep)),ne=g[Se],ee=o.get(le);if(!ee)o.set(le,{dirList:new Set([ne])});else if(ee.dirList){if(ee.dirList.has(ne))break;ee.dirList.add(ne)}}}A(C,he.linkType==="SOFT"?he.target:ce,me)}},p=a({name:e.name,reference:Array.from(e.references)[0]},"",[]),h=p.target;return o.set(h,p),A(e,h,""),o};Ye();Ye();St();St();nA();Nl();var oG={};Vt(oG,{PnpInstaller:()=>mm,PnpLinker:()=>P0,UnplugCommand:()=>x0,default:()=>VIt,getPnpPath:()=>b0,jsInstallUtils:()=>yA,pnpUtils:()=>av,quotePathIfNeeded:()=>r1e});St();var t1e=ve("url");Ye();Ye();St();St();var EIe={["DEFAULT"]:{collapsed:!1,next:{["*"]:"DEFAULT"}},["TOP_LEVEL"]:{collapsed:!1,next:{fallbackExclusionList:"FALLBACK_EXCLUSION_LIST",packageRegistryData:"PACKAGE_REGISTRY_DATA",["*"]:"DEFAULT"}},["FALLBACK_EXCLUSION_LIST"]:{collapsed:!1,next:{["*"]:"FALLBACK_EXCLUSION_ENTRIES"}},["FALLBACK_EXCLUSION_ENTRIES"]:{collapsed:!0,next:{["*"]:"FALLBACK_EXCLUSION_DATA"}},["FALLBACK_EXCLUSION_DATA"]:{collapsed:!0,next:{["*"]:"DEFAULT"}},["PACKAGE_REGISTRY_DATA"]:{collapsed:!1,next:{["*"]:"PACKAGE_REGISTRY_ENTRIES"}},["PACKAGE_REGISTRY_ENTRIES"]:{collapsed:!0,next:{["*"]:"PACKAGE_STORE_DATA"}},["PACKAGE_STORE_DATA"]:{collapsed:!1,next:{["*"]:"PACKAGE_STORE_ENTRIES"}},["PACKAGE_STORE_ENTRIES"]:{collapsed:!0,next:{["*"]:"PACKAGE_INFORMATION_DATA"}},["PACKAGE_INFORMATION_DATA"]:{collapsed:!1,next:{packageDependencies:"PACKAGE_DEPENDENCIES",["*"]:"DEFAULT"}},["PACKAGE_DEPENDENCIES"]:{collapsed:!1,next:{["*"]:"PACKAGE_DEPENDENCY"}},["PACKAGE_DEPENDENCY"]:{collapsed:!0,next:{["*"]:"DEFAULT"}}};function EIt(t,e,r){let o="";o+="[";for(let a=0,n=t.length;a<n;++a)o+=EQ(String(a),t[a],e,r).replace(/^ +/g,""),a+1<n&&(o+=", ");return o+="]",o}function CIt(t,e,r){let o=`${r} `,a="";a+=r,a+=`[ +`;for(let n=0,u=t.length;n<u;++n)a+=o+EQ(String(n),t[n],e,o).replace(/^ +/,""),n+1<u&&(a+=","),a+=` +`;return a+=r,a+="]",a}function wIt(t,e,r){let o=Object.keys(t),a="";a+="{";for(let n=0,u=o.length,A=0;n<u;++n){let p=o[n],h=t[p];typeof h>"u"||(A!==0&&(a+=", "),a+=JSON.stringify(p),a+=": ",a+=EQ(p,h,e,r).replace(/^ +/g,""),A+=1)}return a+="}",a}function IIt(t,e,r){let o=Object.keys(t),a=`${r} `,n="";n+=r,n+=`{ +`;let u=0;for(let A=0,p=o.length;A<p;++A){let h=o[A],E=t[h];typeof E>"u"||(u!==0&&(n+=",",n+=` +`),n+=a,n+=JSON.stringify(h),n+=": ",n+=EQ(h,E,e,a).replace(/^ +/g,""),u+=1)}return u!==0&&(n+=` +`),n+=r,n+="}",n}function EQ(t,e,r,o){let{next:a}=EIe[r],n=a[t]||a["*"];return CIe(e,n,o)}function CIe(t,e,r){let{collapsed:o}=EIe[e];return Array.isArray(t)?o?EIt(t,e,r):CIt(t,e,r):typeof t=="object"&&t!==null?o?wIt(t,e,r):IIt(t,e,r):JSON.stringify(t)}function wIe(t){return CIe(t,"TOP_LEVEL","")}function XB(t,e){let r=Array.from(t);Array.isArray(e)||(e=[e]);let o=[];for(let n of e)o.push(r.map(u=>n(u)));let a=r.map((n,u)=>u);return a.sort((n,u)=>{for(let A of o){let p=A[n]<A[u]?-1:A[n]>A[u]?1:0;if(p!==0)return p}return 0}),a.map(n=>r[n])}function BIt(t){let e=new Map,r=XB(t.fallbackExclusionList||[],[({name:o,reference:a})=>o,({name:o,reference:a})=>a]);for(let{name:o,reference:a}of r){let n=e.get(o);typeof n>"u"&&e.set(o,n=new Set),n.add(a)}return Array.from(e).map(([o,a])=>[o,Array.from(a)])}function vIt(t){return XB(t.fallbackPool||[],([e])=>e)}function DIt(t){let e=[];for(let[r,o]of XB(t.packageRegistry,([a])=>a===null?"0":`1${a}`)){let a=[];e.push([r,a]);for(let[n,{packageLocation:u,packageDependencies:A,packagePeers:p,linkType:h,discardFromLookup:E}]of XB(o,([I])=>I===null?"0":`1${I}`)){let I=[];r!==null&&n!==null&&!A.has(r)&&I.push([r,n]);for(let[C,R]of XB(A.entries(),([L])=>L))I.push([C,R]);let v=p&&p.size>0?Array.from(p):void 0,x=E||void 0;a.push([n,{packageLocation:u,packageDependencies:I,packagePeers:v,linkType:h,discardFromLookup:x}])}}return e}function ZB(t){return{__info:["This file is automatically generated. Do not touch it, or risk","your modifications being lost."],dependencyTreeRoots:t.dependencyTreeRoots,enableTopLevelFallback:t.enableTopLevelFallback||!1,ignorePatternData:t.ignorePattern||null,fallbackExclusionList:BIt(t),fallbackPool:vIt(t),packageRegistryData:DIt(t)}}var vIe=$e(BIe());function DIe(t,e){return[t?`${t} +`:"",`/* eslint-disable */ +`,`"use strict"; +`,` +`,e,` +`,(0,vIe.default)()].join("")}function SIt(t){return JSON.stringify(t,null,2)}function PIt(t){return`'${t.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/\n/g,`\\ +`)}'`}function bIt(t){return[`const RAW_RUNTIME_STATE = +`,`${PIt(wIe(t))}; + +`,`function $$SETUP_STATE(hydrateRuntimeState, basePath) { +`,` return hydrateRuntimeState(JSON.parse(RAW_RUNTIME_STATE), {basePath: basePath || __dirname}); +`,`} +`].join("")}function xIt(){return[`function $$SETUP_STATE(hydrateRuntimeState, basePath) { +`,` const fs = require('fs'); +`,` const path = require('path'); +`,` const pnpDataFilepath = path.resolve(__dirname, ${JSON.stringify(dr.pnpData)}); +`,` return hydrateRuntimeState(JSON.parse(fs.readFileSync(pnpDataFilepath, 'utf8')), {basePath: basePath || __dirname}); +`,`} +`].join("")}function SIe(t){let e=ZB(t),r=bIt(e);return DIe(t.shebang,r)}function PIe(t){let e=ZB(t),r=xIt(),o=DIe(t.shebang,r);return{dataFile:SIt(e),loaderFile:o}}St();function Gj(t,{basePath:e}){let r=ue.toPortablePath(e),o=V.resolve(r),a=t.ignorePatternData!==null?new RegExp(t.ignorePatternData):null,n=new Map,u=new Map(t.packageRegistryData.map(([I,v])=>[I,new Map(v.map(([x,C])=>{if(I===null!=(x===null))throw new Error("Assertion failed: The name and reference should be null, or neither should");let R=C.discardFromLookup??!1,L={name:I,reference:x},U=n.get(C.packageLocation);U?(U.discardFromLookup=U.discardFromLookup&&R,R||(U.locator=L)):n.set(C.packageLocation,{locator:L,discardFromLookup:R});let J=null;return[x,{packageDependencies:new Map(C.packageDependencies),packagePeers:new Set(C.packagePeers),linkType:C.linkType,discardFromLookup:R,get packageLocation(){return J||(J=V.join(o,C.packageLocation))}}]}))])),A=new Map(t.fallbackExclusionList.map(([I,v])=>[I,new Set(v)])),p=new Map(t.fallbackPool),h=t.dependencyTreeRoots,E=t.enableTopLevelFallback;return{basePath:r,dependencyTreeRoots:h,enableTopLevelFallback:E,fallbackExclusionList:A,fallbackPool:p,ignorePattern:a,packageLocatorsByLocations:n,packageRegistry:u}}St();St();var np=ve("module"),dm=ve("url"),$j=ve("util");var Mo=ve("url");var QIe=$e(ve("assert"));var qj=Array.isArray,$B=JSON.stringify,ev=Object.getOwnPropertyNames,hm=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),Yj=(t,e)=>RegExp.prototype.exec.call(t,e),Wj=(t,...e)=>RegExp.prototype[Symbol.replace].apply(t,e),D0=(t,...e)=>String.prototype.endsWith.apply(t,e),Kj=(t,...e)=>String.prototype.includes.apply(t,e),Vj=(t,...e)=>String.prototype.lastIndexOf.apply(t,e),tv=(t,...e)=>String.prototype.indexOf.apply(t,e),bIe=(t,...e)=>String.prototype.replace.apply(t,e),S0=(t,...e)=>String.prototype.slice.apply(t,e),dA=(t,...e)=>String.prototype.startsWith.apply(t,e),xIe=Map,kIe=JSON.parse;function rv(t,e,r){return class extends r{constructor(...o){super(e(...o)),this.code=t,this.name=`${r.name} [${t}]`}}}var FIe=rv("ERR_PACKAGE_IMPORT_NOT_DEFINED",(t,e,r)=>`Package import specifier "${t}" is not defined${e?` in package ${e}package.json`:""} imported from ${r}`,TypeError),Jj=rv("ERR_INVALID_MODULE_SPECIFIER",(t,e,r=void 0)=>`Invalid module "${t}" ${e}${r?` imported from ${r}`:""}`,TypeError),RIe=rv("ERR_INVALID_PACKAGE_TARGET",(t,e,r,o=!1,a=void 0)=>{let n=typeof r=="string"&&!o&&r.length&&!dA(r,"./");return e==="."?((0,QIe.default)(o===!1),`Invalid "exports" main target ${$B(r)} defined in the package config ${t}package.json${a?` imported from ${a}`:""}${n?'; targets must start with "./"':""}`):`Invalid "${o?"imports":"exports"}" target ${$B(r)} defined for '${e}' in the package config ${t}package.json${a?` imported from ${a}`:""}${n?'; targets must start with "./"':""}`},Error),nv=rv("ERR_INVALID_PACKAGE_CONFIG",(t,e,r)=>`Invalid package config ${t}${e?` while importing ${e}`:""}${r?`. ${r}`:""}`,Error),TIe=rv("ERR_PACKAGE_PATH_NOT_EXPORTED",(t,e,r=void 0)=>e==="."?`No "exports" main defined in ${t}package.json${r?` imported from ${r}`:""}`:`Package subpath '${e}' is not defined by "exports" in ${t}package.json${r?` imported from ${r}`:""}`,Error);var wQ=ve("url");function NIe(t,e){let r=Object.create(null);for(let o=0;o<e.length;o++){let a=e[o];hm(t,a)&&(r[a]=t[a])}return r}var CQ=new xIe;function kIt(t,e,r,o){let a=CQ.get(t);if(a!==void 0)return a;let n=o(t);if(n===void 0){let x={pjsonPath:t,exists:!1,main:void 0,name:void 0,type:"none",exports:void 0,imports:void 0};return CQ.set(t,x),x}let u;try{u=kIe(n)}catch(x){throw new nv(t,(r?`"${e}" from `:"")+(0,wQ.fileURLToPath)(r||e),x.message)}let{imports:A,main:p,name:h,type:E}=NIe(u,["imports","main","name","type"]),I=hm(u,"exports")?u.exports:void 0;(typeof A!="object"||A===null)&&(A=void 0),typeof p!="string"&&(p=void 0),typeof h!="string"&&(h=void 0),E!=="module"&&E!=="commonjs"&&(E="none");let v={pjsonPath:t,exists:!0,main:p,name:h,type:E,exports:I,imports:A};return CQ.set(t,v),v}function LIe(t,e){let r=new URL("./package.json",t);for(;;){let n=r.pathname;if(D0(n,"node_modules/package.json"))break;let u=kIt((0,wQ.fileURLToPath)(r),t,void 0,e);if(u.exists)return u;let A=r;if(r=new URL("../package.json",r),r.pathname===A.pathname)break}let o=(0,wQ.fileURLToPath)(r),a={pjsonPath:o,exists:!1,main:void 0,name:void 0,type:"none",exports:void 0,imports:void 0};return CQ.set(o,a),a}function QIt(t,e,r){throw new FIe(t,e&&(0,Mo.fileURLToPath)(new URL(".",e)),(0,Mo.fileURLToPath)(r))}function FIt(t,e,r,o){let a=`request is not a valid subpath for the "${r?"imports":"exports"}" resolution of ${(0,Mo.fileURLToPath)(e)}`;throw new Jj(t,a,o&&(0,Mo.fileURLToPath)(o))}function iv(t,e,r,o,a){throw typeof e=="object"&&e!==null?e=$B(e,null,""):e=`${e}`,new RIe((0,Mo.fileURLToPath)(new URL(".",r)),t,e,o,a&&(0,Mo.fileURLToPath)(a))}var OIe=/(^|\\|\/)((\.|%2e)(\.|%2e)?|(n|%6e|%4e)(o|%6f|%4f)(d|%64|%44)(e|%65|%45)(_|%5f)(m|%6d|%4d)(o|%6f|%4f)(d|%64|%44)(u|%75|%55)(l|%6c|%4c)(e|%65|%45)(s|%73|%53))(\\|\/|$)/i,MIe=/\*/g;function RIt(t,e,r,o,a,n,u,A){if(e!==""&&!n&&t[t.length-1]!=="/"&&iv(r,t,o,u,a),!dA(t,"./")){if(u&&!dA(t,"../")&&!dA(t,"/")){let I=!1;try{new URL(t),I=!0}catch{}if(!I)return n?Wj(MIe,t,()=>e):t+e}iv(r,t,o,u,a)}Yj(OIe,S0(t,2))!==null&&iv(r,t,o,u,a);let p=new URL(t,o),h=p.pathname,E=new URL(".",o).pathname;if(dA(h,E)||iv(r,t,o,u,a),e==="")return p;if(Yj(OIe,e)!==null){let I=n?bIe(r,"*",()=>e):r+e;FIt(I,o,u,a)}return n?new URL(Wj(MIe,p.href,()=>e)):new URL(e,p)}function TIt(t){let e=+t;return`${e}`!==t?!1:e>=0&&e<4294967295}function qC(t,e,r,o,a,n,u,A){if(typeof e=="string")return RIt(e,r,o,t,a,n,u,A);if(qj(e)){if(e.length===0)return null;let p;for(let h=0;h<e.length;h++){let E=e[h],I;try{I=qC(t,E,r,o,a,n,u,A)}catch(v){if(p=v,v.code==="ERR_INVALID_PACKAGE_TARGET")continue;throw v}if(I!==void 0){if(I===null){p=null;continue}return I}}if(p==null)return p;throw p}else if(typeof e=="object"&&e!==null){let p=ev(e);for(let h=0;h<p.length;h++){let E=p[h];if(TIt(E))throw new nv((0,Mo.fileURLToPath)(t),a,'"exports" cannot contain numeric property keys.')}for(let h=0;h<p.length;h++){let E=p[h];if(E==="default"||A.has(E)){let I=e[E],v=qC(t,I,r,o,a,n,u,A);if(v===void 0)continue;return v}}return}else if(e===null)return null;iv(o,e,t,u,a)}function _Ie(t,e){let r=tv(t,"*"),o=tv(e,"*"),a=r===-1?t.length:r+1,n=o===-1?e.length:o+1;return a>n?-1:n>a||r===-1?1:o===-1||t.length>e.length?-1:e.length>t.length?1:0}function NIt(t,e,r){if(typeof t=="string"||qj(t))return!0;if(typeof t!="object"||t===null)return!1;let o=ev(t),a=!1,n=0;for(let u=0;u<o.length;u++){let A=o[u],p=A===""||A[0]!==".";if(n++===0)a=p;else if(a!==p)throw new nv((0,Mo.fileURLToPath)(e),r,`"exports" cannot contain some keys starting with '.' and some not. The exports object must either be an object of package subpath keys or an object of main entry condition name keys only.`)}return a}function zj(t,e,r){throw new TIe((0,Mo.fileURLToPath)(new URL(".",e)),t,r&&(0,Mo.fileURLToPath)(r))}var UIe=new Set;function LIt(t,e,r){let o=(0,Mo.fileURLToPath)(e);UIe.has(o+"|"+t)||(UIe.add(o+"|"+t),process.emitWarning(`Use of deprecated trailing slash pattern mapping "${t}" in the "exports" field module resolution of the package at ${o}${r?` imported from ${(0,Mo.fileURLToPath)(r)}`:""}. Mapping specifiers ending in "/" is no longer supported.`,"DeprecationWarning","DEP0155"))}function HIe({packageJSONUrl:t,packageSubpath:e,exports:r,base:o,conditions:a}){if(NIt(r,t,o)&&(r={".":r}),hm(r,e)&&!Kj(e,"*")&&!D0(e,"/")){let p=r[e],h=qC(t,p,"",e,o,!1,!1,a);return h==null&&zj(e,t,o),h}let n="",u,A=ev(r);for(let p=0;p<A.length;p++){let h=A[p],E=tv(h,"*");if(E!==-1&&dA(e,S0(h,0,E))){D0(e,"/")&&LIt(e,t,o);let I=S0(h,E+1);e.length>=h.length&&D0(e,I)&&_Ie(n,h)===1&&Vj(h,"*")===E&&(n=h,u=S0(e,E,e.length-I.length))}}if(n){let p=r[n],h=qC(t,p,u,n,o,!0,!1,a);return h==null&&zj(e,t,o),h}zj(e,t,o)}function jIe({name:t,base:e,conditions:r,readFileSyncFn:o}){if(t==="#"||dA(t,"#/")||D0(t,"/")){let u="is not a valid internal imports specifier name";throw new Jj(t,u,(0,Mo.fileURLToPath)(e))}let a,n=LIe(e,o);if(n.exists){a=(0,Mo.pathToFileURL)(n.pjsonPath);let u=n.imports;if(u)if(hm(u,t)&&!Kj(t,"*")){let A=qC(a,u[t],"",t,e,!1,!0,r);if(A!=null)return A}else{let A="",p,h=ev(u);for(let E=0;E<h.length;E++){let I=h[E],v=tv(I,"*");if(v!==-1&&dA(t,S0(I,0,v))){let x=S0(I,v+1);t.length>=I.length&&D0(t,x)&&_Ie(A,I)===1&&Vj(I,"*")===v&&(A=I,p=S0(t,v,t.length-x.length))}}if(A){let E=u[A],I=qC(a,E,p,A,e,!0,!0,r);if(I!=null)return I}}}QIt(t,a,e)}St();var OIt=new Set(["BUILTIN_NODE_RESOLUTION_FAILED","MISSING_DEPENDENCY","MISSING_PEER_DEPENDENCY","QUALIFIED_PATH_RESOLUTION_FAILED","UNDECLARED_DEPENDENCY"]);function $i(t,e,r={},o){o??=OIt.has(t)?"MODULE_NOT_FOUND":t;let a={configurable:!0,writable:!0,enumerable:!1};return Object.defineProperties(new Error(e),{code:{...a,value:o},pnpCode:{...a,value:t},data:{...a,value:r}})}function au(t){return ue.normalize(ue.fromPortablePath(t))}var WIe=$e(qIe());function KIe(t){return MIt(),Zj[t]}var Zj;function MIt(){Zj||(Zj={"--conditions":[],...YIe(UIt()),...YIe(process.execArgv)})}function YIe(t){return(0,WIe.default)({"--conditions":[String],"-C":"--conditions"},{argv:t,permissive:!0})}function UIt(){let t=[],e=_It(process.env.NODE_OPTIONS||"",t);return t.length,e}function _It(t,e){let r=[],o=!1,a=!0;for(let n=0;n<t.length;++n){let u=t[n];if(u==="\\"&&o){if(n+1===t.length)return e.push(`invalid value for NODE_OPTIONS (invalid escape) +`),r;u=t[++n]}else if(u===" "&&!o){a=!0;continue}else if(u==='"'){o=!o;continue}a?(r.push(u),a=!1):r[r.length-1]+=u}return o&&e.push(`invalid value for NODE_OPTIONS (unterminated string) +`),r}St();var[mA,gm]=process.versions.node.split(".").map(t=>parseInt(t,10)),VIe=mA>19||mA===19&&gm>=2||mA===18&&gm>=13,Bzt=mA===20&&gm<6||mA===19&&gm>=3,vzt=mA>19||mA===19&&gm>=6,Dzt=mA>=21||mA===20&&gm>=10||mA===18&&gm>=19;function JIe(t){if(process.env.WATCH_REPORT_DEPENDENCIES&&process.send)if(t=t.map(e=>ue.fromPortablePath(mi.resolveVirtual(ue.toPortablePath(e)))),VIe)process.send({"watch:require":t});else for(let e of t)process.send({"watch:require":e})}function eG(t,e){let r=Number(process.env.PNP_ALWAYS_WARN_ON_FALLBACK)>0,o=Number(process.env.PNP_DEBUG_LEVEL),a=/^(?![a-zA-Z]:[\\/]|\\\\|\.{0,2}(?:\/|$))((?:node:)?(?:@[^/]+\/)?[^/]+)\/*(.*|)$/,n=/^(\/|\.{1,2}(\/|$))/,u=/\/$/,A=/^\.{0,2}\//,p={name:null,reference:null},h=[],E=new Set;if(t.enableTopLevelFallback===!0&&h.push(p),e.compatibilityMode!==!1)for(let Re of["react-scripts","gatsby"]){let ke=t.packageRegistry.get(Re);if(ke)for(let xe of ke.keys()){if(xe===null)throw new Error("Assertion failed: This reference shouldn't be null");h.push({name:Re,reference:xe})}}let{ignorePattern:I,packageRegistry:v,packageLocatorsByLocations:x}=t;function C(Re,ke){return{fn:Re,args:ke,error:null,result:null}}function R(Re){let ke=process.stderr?.hasColors?.()??process.stdout.isTTY,xe=(Je,je)=>`\x1B[${Je}m${je}\x1B[0m`,He=Re.error;console.error(He?xe("31;1",`\u2716 ${Re.error?.message.replace(/\n.*/s,"")}`):xe("33;1","\u203C Resolution")),Re.args.length>0&&console.error();for(let Je of Re.args)console.error(` ${xe("37;1","In \u2190")} ${(0,$j.inspect)(Je,{colors:ke,compact:!0})}`);Re.result&&(console.error(),console.error(` ${xe("37;1","Out \u2192")} ${(0,$j.inspect)(Re.result,{colors:ke,compact:!0})}`));let Te=new Error().stack.match(/(?<=^ +)at.*/gm)?.slice(2)??[];if(Te.length>0){console.error();for(let Je of Te)console.error(` ${xe("38;5;244",Je)}`)}console.error()}function L(Re,ke){if(e.allowDebug===!1)return ke;if(Number.isFinite(o)){if(o>=2)return(...xe)=>{let He=C(Re,xe);try{return He.result=ke(...xe)}catch(Te){throw He.error=Te}finally{R(He)}};if(o>=1)return(...xe)=>{try{return ke(...xe)}catch(He){let Te=C(Re,xe);throw Te.error=He,R(Te),He}}}return ke}function U(Re){let ke=g(Re);if(!ke)throw $i("INTERNAL","Couldn't find a matching entry in the dependency tree for the specified parent (this is probably an internal error)");return ke}function J(Re){if(Re.name===null)return!0;for(let ke of t.dependencyTreeRoots)if(ke.name===Re.name&&ke.reference===Re.reference)return!0;return!1}let te=new Set(["node","require",...KIe("--conditions")]);function ae(Re,ke=te,xe){let He=le(V.join(Re,"internal.js"),{resolveIgnored:!0,includeDiscardFromLookup:!0});if(He===null)throw $i("INTERNAL",`The locator that owns the "${Re}" path can't be found inside the dependency tree (this is probably an internal error)`);let{packageLocation:Te}=U(He),Je=V.join(Te,dr.manifest);if(!e.fakeFs.existsSync(Je))return null;let je=JSON.parse(e.fakeFs.readFileSync(Je,"utf8"));if(je.exports==null)return null;let b=V.contains(Te,Re);if(b===null)throw $i("INTERNAL","unqualifiedPath doesn't contain the packageLocation (this is probably an internal error)");b!=="."&&!A.test(b)&&(b=`./${b}`);try{let w=HIe({packageJSONUrl:(0,dm.pathToFileURL)(ue.fromPortablePath(Je)),packageSubpath:b,exports:je.exports,base:xe?(0,dm.pathToFileURL)(ue.fromPortablePath(xe)):null,conditions:ke});return ue.toPortablePath((0,dm.fileURLToPath)(w))}catch(w){throw $i("EXPORTS_RESOLUTION_FAILED",w.message,{unqualifiedPath:au(Re),locator:He,pkgJson:je,subpath:au(b),conditions:ke},w.code)}}function fe(Re,ke,{extensions:xe}){let He;try{ke.push(Re),He=e.fakeFs.statSync(Re)}catch{}if(He&&!He.isDirectory())return e.fakeFs.realpathSync(Re);if(He&&He.isDirectory()){let Te;try{Te=JSON.parse(e.fakeFs.readFileSync(V.join(Re,dr.manifest),"utf8"))}catch{}let Je;if(Te&&Te.main&&(Je=V.resolve(Re,Te.main)),Je&&Je!==Re){let je=fe(Je,ke,{extensions:xe});if(je!==null)return je}}for(let Te=0,Je=xe.length;Te<Je;Te++){let je=`${Re}${xe[Te]}`;if(ke.push(je),e.fakeFs.existsSync(je))return je}if(He&&He.isDirectory())for(let Te=0,Je=xe.length;Te<Je;Te++){let je=V.format({dir:Re,name:"index",ext:xe[Te]});if(ke.push(je),e.fakeFs.existsSync(je))return je}return null}function ce(Re){let ke=new np.Module(Re,null);return ke.filename=Re,ke.paths=np.Module._nodeModulePaths(Re),ke}function me(Re,ke){return ke.endsWith("/")&&(ke=V.join(ke,"internal.js")),np.Module._resolveFilename(ue.fromPortablePath(Re),ce(ue.fromPortablePath(ke)),!1,{plugnplay:!1})}function he(Re){if(I===null)return!1;let ke=V.contains(t.basePath,Re);return ke===null?!1:!!I.test(ke.replace(/\/$/,""))}let Be={std:3,resolveVirtual:1,getAllLocators:1},we=p;function g({name:Re,reference:ke}){let xe=v.get(Re);if(!xe)return null;let He=xe.get(ke);return He||null}function Ee({name:Re,reference:ke}){let xe=[];for(let[He,Te]of v)if(He!==null)for(let[Je,je]of Te)Je===null||je.packageDependencies.get(Re)!==ke||He===Re&&Je===ke||xe.push({name:He,reference:Je});return xe}function Se(Re,ke){let xe=new Map,He=new Set,Te=je=>{let b=JSON.stringify(je.name);if(He.has(b))return;He.add(b);let w=Ee(je);for(let P of w)if(U(P).packagePeers.has(Re))Te(P);else{let F=xe.get(P.name);typeof F>"u"&&xe.set(P.name,F=new Set),F.add(P.reference)}};Te(ke);let Je=[];for(let je of[...xe.keys()].sort())for(let b of[...xe.get(je)].sort())Je.push({name:je,reference:b});return Je}function le(Re,{resolveIgnored:ke=!1,includeDiscardFromLookup:xe=!1}={}){if(he(Re)&&!ke)return null;let He=V.relative(t.basePath,Re);He.match(n)||(He=`./${He}`),He.endsWith("/")||(He=`${He}/`);do{let Te=x.get(He);if(typeof Te>"u"||Te.discardFromLookup&&!xe){He=He.substring(0,He.lastIndexOf("/",He.length-2)+1);continue}return Te.locator}while(He!=="");return null}function ne(Re){try{return e.fakeFs.readFileSync(ue.toPortablePath(Re),"utf8")}catch(ke){if(ke.code==="ENOENT")return;throw ke}}function ee(Re,ke,{considerBuiltins:xe=!0}={}){if(Re.startsWith("#"))throw new Error("resolveToUnqualified can not handle private import mappings");if(Re==="pnpapi")return ue.toPortablePath(e.pnpapiResolution);if(xe&&(0,np.isBuiltin)(Re))return null;let He=au(Re),Te=ke&&au(ke);if(ke&&he(ke)&&(!V.isAbsolute(Re)||le(Re)===null)){let b=me(Re,ke);if(b===!1)throw $i("BUILTIN_NODE_RESOLUTION_FAILED",`The builtin node resolution algorithm was unable to resolve the requested module (it didn't go through the pnp resolver because the issuer was explicitely ignored by the regexp) + +Require request: "${He}" +Required by: ${Te} +`,{request:He,issuer:Te});return ue.toPortablePath(b)}let Je,je=Re.match(a);if(je){if(!ke)throw $i("API_ERROR","The resolveToUnqualified function must be called with a valid issuer when the path isn't a builtin nor absolute",{request:He,issuer:Te});let[,b,w]=je,P=le(ke);if(!P){let Ne=me(Re,ke);if(Ne===!1)throw $i("BUILTIN_NODE_RESOLUTION_FAILED",`The builtin node resolution algorithm was unable to resolve the requested module (it didn't go through the pnp resolver because the issuer doesn't seem to be part of the Yarn-managed dependency tree). + +Require path: "${He}" +Required by: ${Te} +`,{request:He,issuer:Te});return ue.toPortablePath(Ne)}let F=U(P).packageDependencies.get(b),z=null;if(F==null&&P.name!==null){let Ne=t.fallbackExclusionList.get(P.name);if(!Ne||!Ne.has(P.reference)){for(let dt=0,Gt=h.length;dt<Gt;++dt){let bt=U(h[dt]).packageDependencies.get(b);if(bt!=null){r?z=bt:F=bt;break}}if(t.enableTopLevelFallback&&F==null&&z===null){let dt=t.fallbackPool.get(b);dt!=null&&(z=dt)}}}let X=null;if(F===null)if(J(P))X=$i("MISSING_PEER_DEPENDENCY",`Your application tried to access ${b} (a peer dependency); this isn't allowed as there is no ancestor to satisfy the requirement. Use a devDependency if needed. + +Required package: ${b}${b!==He?` (via "${He}")`:""} +Required by: ${Te} +`,{request:He,issuer:Te,dependencyName:b});else{let Ne=Se(b,P);Ne.every(ot=>J(ot))?X=$i("MISSING_PEER_DEPENDENCY",`${P.name} tried to access ${b} (a peer dependency) but it isn't provided by your application; this makes the require call ambiguous and unsound. + +Required package: ${b}${b!==He?` (via "${He}")`:""} +Required by: ${P.name}@${P.reference} (via ${Te}) +${Ne.map(ot=>`Ancestor breaking the chain: ${ot.name}@${ot.reference} +`).join("")} +`,{request:He,issuer:Te,issuerLocator:Object.assign({},P),dependencyName:b,brokenAncestors:Ne}):X=$i("MISSING_PEER_DEPENDENCY",`${P.name} tried to access ${b} (a peer dependency) but it isn't provided by its ancestors; this makes the require call ambiguous and unsound. + +Required package: ${b}${b!==He?` (via "${He}")`:""} +Required by: ${P.name}@${P.reference} (via ${Te}) + +${Ne.map(ot=>`Ancestor breaking the chain: ${ot.name}@${ot.reference} +`).join("")} +`,{request:He,issuer:Te,issuerLocator:Object.assign({},P),dependencyName:b,brokenAncestors:Ne})}else F===void 0&&(!xe&&(0,np.isBuiltin)(Re)?J(P)?X=$i("UNDECLARED_DEPENDENCY",`Your application tried to access ${b}. While this module is usually interpreted as a Node builtin, your resolver is running inside a non-Node resolution context where such builtins are ignored. Since ${b} isn't otherwise declared in your dependencies, this makes the require call ambiguous and unsound. + +Required package: ${b}${b!==He?` (via "${He}")`:""} +Required by: ${Te} +`,{request:He,issuer:Te,dependencyName:b}):X=$i("UNDECLARED_DEPENDENCY",`${P.name} tried to access ${b}. While this module is usually interpreted as a Node builtin, your resolver is running inside a non-Node resolution context where such builtins are ignored. Since ${b} isn't otherwise declared in ${P.name}'s dependencies, this makes the require call ambiguous and unsound. + +Required package: ${b}${b!==He?` (via "${He}")`:""} +Required by: ${Te} +`,{request:He,issuer:Te,issuerLocator:Object.assign({},P),dependencyName:b}):J(P)?X=$i("UNDECLARED_DEPENDENCY",`Your application tried to access ${b}, but it isn't declared in your dependencies; this makes the require call ambiguous and unsound. + +Required package: ${b}${b!==He?` (via "${He}")`:""} +Required by: ${Te} +`,{request:He,issuer:Te,dependencyName:b}):X=$i("UNDECLARED_DEPENDENCY",`${P.name} tried to access ${b}, but it isn't declared in its dependencies; this makes the require call ambiguous and unsound. + +Required package: ${b}${b!==He?` (via "${He}")`:""} +Required by: ${P.name}@${P.reference} (via ${Te}) +`,{request:He,issuer:Te,issuerLocator:Object.assign({},P),dependencyName:b}));if(F==null){if(z===null||X===null)throw X||new Error("Assertion failed: Expected an error to have been set");F=z;let Ne=X.message.replace(/\n.*/g,"");X.message=Ne,!E.has(Ne)&&o!==0&&(E.add(Ne),process.emitWarning(X))}let Z=Array.isArray(F)?{name:F[0],reference:F[1]}:{name:b,reference:F},ie=U(Z);if(!ie.packageLocation)throw $i("MISSING_DEPENDENCY",`A dependency seems valid but didn't get installed for some reason. This might be caused by a partial install, such as dev vs prod. + +Required package: ${Z.name}@${Z.reference}${Z.name!==He?` (via "${He}")`:""} +Required by: ${P.name}@${P.reference} (via ${Te}) +`,{request:He,issuer:Te,dependencyLocator:Object.assign({},Z)});let Pe=ie.packageLocation;w?Je=V.join(Pe,w):Je=Pe}else if(V.isAbsolute(Re))Je=V.normalize(Re);else{if(!ke)throw $i("API_ERROR","The resolveToUnqualified function must be called with a valid issuer when the path isn't a builtin nor absolute",{request:He,issuer:Te});let b=V.resolve(ke);ke.match(u)?Je=V.normalize(V.join(b,Re)):Je=V.normalize(V.join(V.dirname(b),Re))}return V.normalize(Je)}function Ie(Re,ke,xe=te,He){if(n.test(Re))return ke;let Te=ae(ke,xe,He);return Te?V.normalize(Te):ke}function Fe(Re,{extensions:ke=Object.keys(np.Module._extensions)}={}){let xe=[],He=fe(Re,xe,{extensions:ke});if(He)return V.normalize(He);{JIe(xe.map(je=>ue.fromPortablePath(je)));let Te=au(Re),Je=le(Re);if(Je){let{packageLocation:je}=U(Je),b=!0;try{e.fakeFs.accessSync(je)}catch(w){if(w?.code==="ENOENT")b=!1;else{let P=(w?.message??w??"empty exception thrown").replace(/^[A-Z]/,y=>y.toLowerCase());throw $i("QUALIFIED_PATH_RESOLUTION_FAILED",`Required package exists but could not be accessed (${P}). + +Missing package: ${Je.name}@${Je.reference} +Expected package location: ${au(je)} +`,{unqualifiedPath:Te,extensions:ke})}}if(!b){let w=je.includes("/unplugged/")?"Required unplugged package missing from disk. This may happen when switching branches without running installs (unplugged packages must be fully materialized on disk to work).":"Required package missing from disk. If you keep your packages inside your repository then restarting the Node process may be enough. Otherwise, try to run an install first.";throw $i("QUALIFIED_PATH_RESOLUTION_FAILED",`${w} + +Missing package: ${Je.name}@${Je.reference} +Expected package location: ${au(je)} +`,{unqualifiedPath:Te,extensions:ke})}}throw $i("QUALIFIED_PATH_RESOLUTION_FAILED",`Qualified path resolution failed: we looked for the following paths, but none could be accessed. + +Source path: ${Te} +${xe.map(je=>`Not found: ${au(je)} +`).join("")}`,{unqualifiedPath:Te,extensions:ke})}}function At(Re,ke,xe){if(!ke)throw new Error("Assertion failed: An issuer is required to resolve private import mappings");let He=jIe({name:Re,base:(0,dm.pathToFileURL)(ue.fromPortablePath(ke)),conditions:xe.conditions??te,readFileSyncFn:ne});if(He instanceof URL)return Fe(ue.toPortablePath((0,dm.fileURLToPath)(He)),{extensions:xe.extensions});if(He.startsWith("#"))throw new Error("Mapping from one private import to another isn't allowed");return H(He,ke,xe)}function H(Re,ke,xe={}){try{if(Re.startsWith("#"))return At(Re,ke,xe);let{considerBuiltins:He,extensions:Te,conditions:Je}=xe,je=ee(Re,ke,{considerBuiltins:He});if(Re==="pnpapi")return je;if(je===null)return null;let b=()=>ke!==null?he(ke):!1,w=(!He||!(0,np.isBuiltin)(Re))&&!b()?Ie(Re,je,Je,ke):je;return Fe(w,{extensions:Te})}catch(He){throw Object.hasOwn(He,"pnpCode")&&Object.assign(He.data,{request:au(Re),issuer:ke&&au(ke)}),He}}function at(Re){let ke=V.normalize(Re),xe=mi.resolveVirtual(ke);return xe!==ke?xe:null}return{VERSIONS:Be,topLevel:we,getLocator:(Re,ke)=>Array.isArray(ke)?{name:ke[0],reference:ke[1]}:{name:Re,reference:ke},getDependencyTreeRoots:()=>[...t.dependencyTreeRoots],getAllLocators(){let Re=[];for(let[ke,xe]of v)for(let He of xe.keys())ke!==null&&He!==null&&Re.push({name:ke,reference:He});return Re},getPackageInformation:Re=>{let ke=g(Re);if(ke===null)return null;let xe=ue.fromPortablePath(ke.packageLocation);return{...ke,packageLocation:xe}},findPackageLocator:Re=>le(ue.toPortablePath(Re)),resolveToUnqualified:L("resolveToUnqualified",(Re,ke,xe)=>{let He=ke!==null?ue.toPortablePath(ke):null,Te=ee(ue.toPortablePath(Re),He,xe);return Te===null?null:ue.fromPortablePath(Te)}),resolveUnqualified:L("resolveUnqualified",(Re,ke)=>ue.fromPortablePath(Fe(ue.toPortablePath(Re),ke))),resolveRequest:L("resolveRequest",(Re,ke,xe)=>{let He=ke!==null?ue.toPortablePath(ke):null,Te=H(ue.toPortablePath(Re),He,xe);return Te===null?null:ue.fromPortablePath(Te)}),resolveVirtual:L("resolveVirtual",Re=>{let ke=at(ue.toPortablePath(Re));return ke!==null?ue.fromPortablePath(ke):null})}}St();var zIe=(t,e,r)=>{let o=ZB(t),a=Gj(o,{basePath:e}),n=ue.join(e,dr.pnpCjs);return eG(a,{fakeFs:r,pnpapiResolution:n})};var rG=$e(ZIe());jt();var yA={};Vt(yA,{checkManifestCompatibility:()=>$Ie,extractBuildRequest:()=>IQ,getExtractHint:()=>nG,hasBindingGyp:()=>iG});Ye();St();function $Ie(t){return W.isPackageCompatible(t,Ji.getArchitectureSet())}function IQ(t,e,r,{configuration:o}){let a=[];for(let n of["preinstall","install","postinstall"])e.manifest.scripts.has(n)&&a.push({type:0,script:n});return!e.manifest.scripts.has("install")&&e.misc.hasBindingGyp&&a.push({type:1,script:"node-gyp rebuild"}),a.length===0?null:t.linkType!=="HARD"?{skipped:!0,explain:n=>n.reportWarningOnce(6,`${W.prettyLocator(o,t)} lists build scripts, but is referenced through a soft link. Soft links don't support build scripts, so they'll be ignored.`)}:r&&r.built===!1?{skipped:!0,explain:n=>n.reportInfoOnce(5,`${W.prettyLocator(o,t)} lists build scripts, but its build has been explicitly disabled through configuration.`)}:!o.get("enableScripts")&&!r.built?{skipped:!0,explain:n=>n.reportWarningOnce(4,`${W.prettyLocator(o,t)} lists build scripts, but all build scripts have been disabled.`)}:$Ie(t)?{skipped:!1,directives:a}:{skipped:!0,explain:n=>n.reportWarningOnce(76,`${W.prettyLocator(o,t)} The ${Ji.getArchitectureName()} architecture is incompatible with this package, build skipped.`)}}var jIt=new Set([".exe",".bin",".h",".hh",".hpp",".c",".cc",".cpp",".java",".jar",".node"]);function nG(t){return t.packageFs.getExtractHint({relevantExtensions:jIt})}function iG(t){let e=V.join(t.prefixPath,"binding.gyp");return t.packageFs.existsSync(e)}var av={};Vt(av,{getUnpluggedPath:()=>ov});Ye();St();function ov(t,{configuration:e}){return V.resolve(e.get("pnpUnpluggedFolder"),W.slugifyLocator(t))}var GIt=new Set([W.makeIdent(null,"open").identHash,W.makeIdent(null,"opn").identHash]),P0=class{constructor(){this.mode="strict";this.pnpCache=new Map}getCustomDataKey(){return JSON.stringify({name:"PnpLinker",version:2})}supportsPackage(e,r){return this.isEnabled(r)}async findPackageLocation(e,r){if(!this.isEnabled(r))throw new Error("Assertion failed: Expected the PnP linker to be enabled");let o=b0(r.project).cjs;if(!oe.existsSync(o))throw new it(`The project in ${de.pretty(r.project.configuration,`${r.project.cwd}/package.json`,de.Type.PATH)} doesn't seem to have been installed - running an install there might help`);let a=_e.getFactoryWithDefault(this.pnpCache,o,()=>_e.dynamicRequire(o,{cachingStrategy:_e.CachingStrategy.FsTime})),n={name:W.stringifyIdent(e),reference:e.reference},u=a.getPackageInformation(n);if(!u)throw new it(`Couldn't find ${W.prettyLocator(r.project.configuration,e)} in the currently installed PnP map - running an install might help`);return ue.toPortablePath(u.packageLocation)}async findPackageLocator(e,r){if(!this.isEnabled(r))return null;let o=b0(r.project).cjs;if(!oe.existsSync(o))return null;let n=_e.getFactoryWithDefault(this.pnpCache,o,()=>_e.dynamicRequire(o,{cachingStrategy:_e.CachingStrategy.FsTime})).findPackageLocator(ue.fromPortablePath(e));return n?W.makeLocator(W.parseIdent(n.name),n.reference):null}makeInstaller(e){return new mm(e)}isEnabled(e){return!(e.project.configuration.get("nodeLinker")!=="pnp"||e.project.configuration.get("pnpMode")!==this.mode)}},mm=class{constructor(e){this.opts=e;this.mode="strict";this.asyncActions=new _e.AsyncActions(10);this.packageRegistry=new Map;this.virtualTemplates=new Map;this.isESMLoaderRequired=!1;this.customData={store:new Map};this.unpluggedPaths=new Set;this.opts=e}attachCustomData(e){this.customData=e}async installPackage(e,r,o){let a=W.stringifyIdent(e),n=e.reference,u=!!this.opts.project.tryWorkspaceByLocator(e),A=W.isVirtualLocator(e),p=e.peerDependencies.size>0&&!A,h=!p&&!u,E=!p&&e.linkType!=="SOFT",I,v;if(h||E){let te=A?W.devirtualizeLocator(e):e;I=this.customData.store.get(te.locatorHash),typeof I>"u"&&(I=await qIt(r),e.linkType==="HARD"&&this.customData.store.set(te.locatorHash,I)),I.manifest.type==="module"&&(this.isESMLoaderRequired=!0),v=this.opts.project.getDependencyMeta(te,e.version)}let x=h?IQ(e,I,v,{configuration:this.opts.project.configuration}):null,C=E?await this.unplugPackageIfNeeded(e,I,r,v,o):r.packageFs;if(V.isAbsolute(r.prefixPath))throw new Error(`Assertion failed: Expected the prefix path (${r.prefixPath}) to be relative to the parent`);let R=V.resolve(C.getRealPath(),r.prefixPath),L=sG(this.opts.project.cwd,R),U=new Map,J=new Set;if(A){for(let te of e.peerDependencies.values())U.set(W.stringifyIdent(te),null),J.add(W.stringifyIdent(te));if(!u){let te=W.devirtualizeLocator(e);this.virtualTemplates.set(te.locatorHash,{location:sG(this.opts.project.cwd,mi.resolveVirtual(R)),locator:te})}}return _e.getMapWithDefault(this.packageRegistry,a).set(n,{packageLocation:L,packageDependencies:U,packagePeers:J,linkType:e.linkType,discardFromLookup:r.discardFromLookup||!1}),{packageLocation:R,buildRequest:x}}async attachInternalDependencies(e,r){let o=this.getPackageInformation(e);for(let[a,n]of r){let u=W.areIdentsEqual(a,n)?n.reference:[W.stringifyIdent(n),n.reference];o.packageDependencies.set(W.stringifyIdent(a),u)}}async attachExternalDependents(e,r){for(let o of r)this.getDiskInformation(o).packageDependencies.set(W.stringifyIdent(e),e.reference)}async finalizeInstall(){if(this.opts.project.configuration.get("pnpMode")!==this.mode)return;let e=b0(this.opts.project);if(this.isEsmEnabled()||await oe.removePromise(e.esmLoader),this.opts.project.configuration.get("nodeLinker")!=="pnp"){await oe.removePromise(e.cjs),await oe.removePromise(e.data),await oe.removePromise(e.esmLoader),await oe.removePromise(this.opts.project.configuration.get("pnpUnpluggedFolder"));return}for(let{locator:E,location:I}of this.virtualTemplates.values())_e.getMapWithDefault(this.packageRegistry,W.stringifyIdent(E)).set(E.reference,{packageLocation:I,packageDependencies:new Map,packagePeers:new Set,linkType:"SOFT",discardFromLookup:!1});this.packageRegistry.set(null,new Map([[null,this.getPackageInformation(this.opts.project.topLevelWorkspace.anchoredLocator)]]));let r=this.opts.project.configuration.get("pnpFallbackMode"),o=this.opts.project.workspaces.map(({anchoredLocator:E})=>({name:W.stringifyIdent(E),reference:E.reference})),a=r!=="none",n=[],u=new Map,A=_e.buildIgnorePattern([".yarn/sdks/**",...this.opts.project.configuration.get("pnpIgnorePatterns")]),p=this.packageRegistry,h=this.opts.project.configuration.get("pnpShebang");if(r==="dependencies-only")for(let E of this.opts.project.storedPackages.values())this.opts.project.tryWorkspaceByLocator(E)&&n.push({name:W.stringifyIdent(E),reference:E.reference});return await this.asyncActions.wait(),await this.finalizeInstallWithPnp({dependencyTreeRoots:o,enableTopLevelFallback:a,fallbackExclusionList:n,fallbackPool:u,ignorePattern:A,packageRegistry:p,shebang:h}),{customData:this.customData}}async transformPnpSettings(e){}isEsmEnabled(){if(this.opts.project.configuration.sources.has("pnpEnableEsmLoader"))return this.opts.project.configuration.get("pnpEnableEsmLoader");if(this.isESMLoaderRequired)return!0;for(let e of this.opts.project.workspaces)if(e.manifest.type==="module")return!0;return!1}async finalizeInstallWithPnp(e){let r=b0(this.opts.project),o=await this.locateNodeModules(e.ignorePattern);if(o.length>0){this.opts.report.reportWarning(31,"One or more node_modules have been detected and will be removed. This operation may take some time.");for(let n of o)await oe.removePromise(n)}if(await this.transformPnpSettings(e),this.opts.project.configuration.get("pnpEnableInlining")){let n=SIe(e);await oe.changeFilePromise(r.cjs,n,{automaticNewlines:!0,mode:493}),await oe.removePromise(r.data)}else{let{dataFile:n,loaderFile:u}=PIe(e);await oe.changeFilePromise(r.cjs,u,{automaticNewlines:!0,mode:493}),await oe.changeFilePromise(r.data,n,{automaticNewlines:!0,mode:420})}this.isEsmEnabled()&&(this.opts.report.reportWarning(0,"ESM support for PnP uses the experimental loader API and is therefore experimental"),await oe.changeFilePromise(r.esmLoader,(0,rG.default)(),{automaticNewlines:!0,mode:420}));let a=this.opts.project.configuration.get("pnpUnpluggedFolder");if(this.unpluggedPaths.size===0)await oe.removePromise(a);else for(let n of await oe.readdirPromise(a)){let u=V.resolve(a,n);this.unpluggedPaths.has(u)||await oe.removePromise(u)}}async locateNodeModules(e){let r=[],o=e?new RegExp(e):null;for(let a of this.opts.project.workspaces){let n=V.join(a.cwd,"node_modules");if(o&&o.test(V.relative(this.opts.project.cwd,a.cwd))||!oe.existsSync(n))continue;let u=await oe.readdirPromise(n,{withFileTypes:!0}),A=u.filter(p=>!p.isDirectory()||p.name===".bin"||!p.name.startsWith("."));if(A.length===u.length)r.push(n);else for(let p of A)r.push(V.join(n,p.name))}return r}async unplugPackageIfNeeded(e,r,o,a,n){return this.shouldBeUnplugged(e,r,a)?this.unplugPackage(e,o,n):o.packageFs}shouldBeUnplugged(e,r,o){return typeof o.unplugged<"u"?o.unplugged:GIt.has(e.identHash)||e.conditions!=null?!0:r.manifest.preferUnplugged!==null?r.manifest.preferUnplugged:!!(IQ(e,r,o,{configuration:this.opts.project.configuration})?.skipped===!1||r.misc.extractHint)}async unplugPackage(e,r,o){let a=ov(e,{configuration:this.opts.project.configuration});return this.opts.project.disabledLocators.has(e.locatorHash)?new Uu(a,{baseFs:r.packageFs,pathUtils:V}):(this.unpluggedPaths.add(a),o.holdFetchResult(this.asyncActions.set(e.locatorHash,async()=>{let n=V.join(a,r.prefixPath,".ready");await oe.existsPromise(n)||(this.opts.project.storedBuildState.delete(e.locatorHash),await oe.mkdirPromise(a,{recursive:!0}),await oe.copyPromise(a,Bt.dot,{baseFs:r.packageFs,overwrite:!1}),await oe.writeFilePromise(n,""))})),new gn(a))}getPackageInformation(e){let r=W.stringifyIdent(e),o=e.reference,a=this.packageRegistry.get(r);if(!a)throw new Error(`Assertion failed: The package information store should have been available (for ${W.prettyIdent(this.opts.project.configuration,e)})`);let n=a.get(o);if(!n)throw new Error(`Assertion failed: The package information should have been available (for ${W.prettyLocator(this.opts.project.configuration,e)})`);return n}getDiskInformation(e){let r=_e.getMapWithDefault(this.packageRegistry,"@@disk"),o=sG(this.opts.project.cwd,e);return _e.getFactoryWithDefault(r,o,()=>({packageLocation:o,packageDependencies:new Map,packagePeers:new Set,linkType:"SOFT",discardFromLookup:!1}))}};function sG(t,e){let r=V.relative(t,e);return r.match(/^\.{0,2}\//)||(r=`./${r}`),r.replace(/\/?$/,"/")}async function qIt(t){let e=await Ot.tryFind(t.prefixPath,{baseFs:t.packageFs})??new Ot,r=new Set(["preinstall","install","postinstall"]);for(let o of e.scripts.keys())r.has(o)||e.scripts.delete(o);return{manifest:{scripts:e.scripts,preferUnplugged:e.preferUnplugged,type:e.type},misc:{extractHint:nG(t),hasBindingGyp:iG(t)}}}Ye();Ye();jt();var e1e=$e(Zo());var x0=class extends ut{constructor(){super(...arguments);this.all=ge.Boolean("-A,--all",!1,{description:"Unplug direct dependencies from the entire project"});this.recursive=ge.Boolean("-R,--recursive",!1,{description:"Unplug both direct and transitive dependencies"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.patterns=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd),n=await Lr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);if(r.get("nodeLinker")!=="pnp")throw new it("This command can only be used if the `nodeLinker` option is set to `pnp`");await o.restoreInstallState();let u=new Set(this.patterns),A=this.patterns.map(x=>{let C=W.parseDescriptor(x),R=C.range!=="unknown"?C:W.makeDescriptor(C,"*");if(!kr.validRange(R.range))throw new it(`The range of the descriptor patterns must be a valid semver range (${W.prettyDescriptor(r,R)})`);return L=>{let U=W.stringifyIdent(L);return!e1e.default.isMatch(U,W.stringifyIdent(R))||L.version&&!kr.satisfiesWithPrereleases(L.version,R.range)?!1:(u.delete(x),!0)}}),p=()=>{let x=[];for(let C of o.storedPackages.values())!o.tryWorkspaceByLocator(C)&&!W.isVirtualLocator(C)&&A.some(R=>R(C))&&x.push(C);return x},h=x=>{let C=new Set,R=[],L=(U,J)=>{if(C.has(U.locatorHash))return;let te=!!o.tryWorkspaceByLocator(U);if(!(J>0&&!this.recursive&&te)&&(C.add(U.locatorHash),!o.tryWorkspaceByLocator(U)&&A.some(ae=>ae(U))&&R.push(U),!(J>0&&!this.recursive)))for(let ae of U.dependencies.values()){let fe=o.storedResolutions.get(ae.descriptorHash);if(!fe)throw new Error("Assertion failed: The resolution should have been registered");let ce=o.storedPackages.get(fe);if(!ce)throw new Error("Assertion failed: The package should have been registered");L(ce,J+1)}};for(let U of x)L(U.anchoredPackage,0);return R},E,I;if(this.all&&this.recursive?(E=p(),I="the project"):this.all?(E=h(o.workspaces),I="any workspace"):(E=h([a]),I="this workspace"),u.size>1)throw new it(`Patterns ${de.prettyList(r,u,de.Type.CODE)} don't match any packages referenced by ${I}`);if(u.size>0)throw new it(`Pattern ${de.prettyList(r,u,de.Type.CODE)} doesn't match any packages referenced by ${I}`);E=_e.sortMap(E,x=>W.stringifyLocator(x));let v=await Nt.start({configuration:r,stdout:this.context.stdout,json:this.json},async x=>{for(let C of E){let R=C.version??"unknown",L=o.topLevelWorkspace.manifest.ensureDependencyMeta(W.makeDescriptor(C,R));L.unplugged=!0,x.reportInfo(0,`Will unpack ${W.prettyLocator(r,C)} to ${de.pretty(r,ov(C,{configuration:r}),de.Type.PATH)}`),x.reportJson({locator:W.stringifyLocator(C),version:R})}await o.topLevelWorkspace.persistManifest(),this.json||x.reportSeparator()});return v.hasErrors()?v.exitCode():await o.installWithNewReport({json:this.json,stdout:this.context.stdout},{cache:n})}};x0.paths=[["unplug"]],x0.usage=nt.Usage({description:"force the unpacking of a list of packages",details:"\n This command will add the selectors matching the specified patterns to the list of packages that must be unplugged when installed.\n\n A package being unplugged means that instead of being referenced directly through its archive, it will be unpacked at install time in the directory configured via `pnpUnpluggedFolder`. Note that unpacking packages this way is generally not recommended because it'll make it harder to store your packages within the repository. However, it's a good approach to quickly and safely debug some packages, and can even sometimes be required depending on the context (for example when the package contains shellscripts).\n\n Running the command will set a persistent flag inside your top-level `package.json`, in the `dependenciesMeta` field. As such, to undo its effects, you'll need to revert the changes made to the manifest and run `yarn install` to apply the modification.\n\n By default, only direct dependencies from the current workspace are affected. If `-A,--all` is set, direct dependencies from the entire project are affected. Using the `-R,--recursive` flag will affect transitive dependencies as well as direct ones.\n\n This command accepts glob patterns inside the scope and name components (not the range). Make sure to escape the patterns to prevent your own shell from trying to expand them.\n ",examples:[["Unplug the lodash dependency from the active workspace","yarn unplug lodash"],["Unplug all instances of lodash referenced by any workspace","yarn unplug lodash -A"],["Unplug all instances of lodash referenced by the active workspace and its dependencies","yarn unplug lodash -R"],["Unplug all instances of lodash, anywhere","yarn unplug lodash -AR"],["Unplug one specific version of lodash","yarn unplug lodash@1.2.3"],["Unplug all packages with the `@babel` scope","yarn unplug '@babel/*'"],["Unplug all packages (only for testing, not recommended)","yarn unplug -R '*'"]]});var b0=t=>({cjs:V.join(t.cwd,dr.pnpCjs),data:V.join(t.cwd,dr.pnpData),esmLoader:V.join(t.cwd,dr.pnpEsmLoader)}),r1e=t=>/\s/.test(t)?JSON.stringify(t):t;async function YIt(t,e,r){let o=/\s*--require\s+\S*\.pnp\.c?js\s*/g,a=/\s*--experimental-loader\s+\S*\.pnp\.loader\.mjs\s*/,n=(e.NODE_OPTIONS??"").replace(o," ").replace(a," ").trim();if(t.configuration.get("nodeLinker")!=="pnp"){e.NODE_OPTIONS=n;return}let u=b0(t),A=`--require ${r1e(ue.fromPortablePath(u.cjs))}`;oe.existsSync(u.esmLoader)&&(A=`${A} --experimental-loader ${(0,t1e.pathToFileURL)(ue.fromPortablePath(u.esmLoader)).href}`),oe.existsSync(u.cjs)&&(e.NODE_OPTIONS=n?`${A} ${n}`:A)}async function WIt(t,e){let r=b0(t);e(r.cjs),e(r.data),e(r.esmLoader),e(t.configuration.get("pnpUnpluggedFolder"))}var KIt={hooks:{populateYarnPaths:WIt,setupScriptEnvironment:YIt},configuration:{nodeLinker:{description:'The linker used for installing Node packages, one of: "pnp", "pnpm", or "node-modules"',type:"STRING",default:"pnp"},winLinkType:{description:"Whether Yarn should use Windows Junctions or symlinks when creating links on Windows.",type:"STRING",values:["junctions","symlinks"],default:"junctions"},pnpMode:{description:"If 'strict', generates standard PnP maps. If 'loose', merges them with the n_m resolution.",type:"STRING",default:"strict"},pnpShebang:{description:"String to prepend to the generated PnP script",type:"STRING",default:"#!/usr/bin/env node"},pnpIgnorePatterns:{description:"Array of glob patterns; files matching them will use the classic resolution",type:"STRING",default:[],isArray:!0},pnpEnableEsmLoader:{description:"If true, Yarn will generate an ESM loader (`.pnp.loader.mjs`). If this is not explicitly set Yarn tries to automatically detect whether ESM support is required.",type:"BOOLEAN",default:!1},pnpEnableInlining:{description:"If true, the PnP data will be inlined along with the generated loader",type:"BOOLEAN",default:!0},pnpFallbackMode:{description:"If true, the generated PnP loader will follow the top-level fallback rule",type:"STRING",default:"dependencies-only"},pnpUnpluggedFolder:{description:"Folder where the unplugged packages must be stored",type:"ABSOLUTE_PATH",default:"./.yarn/unplugged"}},linkers:[P0],commands:[x0]},VIt=KIt;var u1e=$e(a1e());jt();var pG=$e(ve("crypto")),A1e=$e(ve("fs")),f1e=1,Si="node_modules",BQ=".bin",p1e=".yarn-state.yml",A1t=1e3,hG=(o=>(o.CLASSIC="classic",o.HARDLINKS_LOCAL="hardlinks-local",o.HARDLINKS_GLOBAL="hardlinks-global",o))(hG||{}),lv=class{constructor(){this.installStateCache=new Map}getCustomDataKey(){return JSON.stringify({name:"NodeModulesLinker",version:3})}supportsPackage(e,r){return this.isEnabled(r)}async findPackageLocation(e,r){if(!this.isEnabled(r))throw new Error("Assertion failed: Expected the node-modules linker to be enabled");let o=r.project.tryWorkspaceByLocator(e);if(o)return o.cwd;let a=await _e.getFactoryWithDefault(this.installStateCache,r.project.cwd,async()=>await fG(r.project,{unrollAliases:!0}));if(a===null)throw new it("Couldn't find the node_modules state file - running an install might help (findPackageLocation)");let n=a.locatorMap.get(W.stringifyLocator(e));if(!n){let p=new it(`Couldn't find ${W.prettyLocator(r.project.configuration,e)} in the currently installed node_modules map - running an install might help`);throw p.code="LOCATOR_NOT_INSTALLED",p}let u=n.locations.sort((p,h)=>p.split(V.sep).length-h.split(V.sep).length),A=V.join(r.project.configuration.startingCwd,Si);return u.find(p=>V.contains(A,p))||n.locations[0]}async findPackageLocator(e,r){if(!this.isEnabled(r))return null;let o=await _e.getFactoryWithDefault(this.installStateCache,r.project.cwd,async()=>await fG(r.project,{unrollAliases:!0}));if(o===null)return null;let{locationRoot:a,segments:n}=vQ(V.resolve(e),{skipPrefix:r.project.cwd}),u=o.locationTree.get(a);if(!u)return null;let A=u.locator;for(let p of n){if(u=u.children.get(p),!u)break;A=u.locator||A}return W.parseLocator(A)}makeInstaller(e){return new AG(e)}isEnabled(e){return e.project.configuration.get("nodeLinker")==="node-modules"}},AG=class{constructor(e){this.opts=e;this.localStore=new Map;this.realLocatorChecksums=new Map;this.customData={store:new Map}}attachCustomData(e){this.customData=e}async installPackage(e,r){let o=V.resolve(r.packageFs.getRealPath(),r.prefixPath),a=this.customData.store.get(e.locatorHash);if(typeof a>"u"&&(a=await f1t(e,r),e.linkType==="HARD"&&this.customData.store.set(e.locatorHash,a)),!W.isPackageCompatible(e,this.opts.project.configuration.getSupportedArchitectures()))return{packageLocation:null,buildRequest:null};let n=new Map,u=new Set;n.has(W.stringifyIdent(e))||n.set(W.stringifyIdent(e),e.reference);let A=e;if(W.isVirtualLocator(e)){A=W.devirtualizeLocator(e);for(let E of e.peerDependencies.values())n.set(W.stringifyIdent(E),null),u.add(W.stringifyIdent(E))}let p={packageLocation:`${ue.fromPortablePath(o)}/`,packageDependencies:n,packagePeers:u,linkType:e.linkType,discardFromLookup:r.discardFromLookup??!1};this.localStore.set(e.locatorHash,{pkg:e,customPackageData:a,dependencyMeta:this.opts.project.getDependencyMeta(e,e.version),pnpNode:p});let h=r.checksum?r.checksum.substring(r.checksum.indexOf("/")+1):null;return this.realLocatorChecksums.set(A.locatorHash,h),{packageLocation:o,buildRequest:null}}async attachInternalDependencies(e,r){let o=this.localStore.get(e.locatorHash);if(typeof o>"u")throw new Error("Assertion failed: Expected information object to have been registered");for(let[a,n]of r){let u=W.areIdentsEqual(a,n)?n.reference:[W.stringifyIdent(n),n.reference];o.pnpNode.packageDependencies.set(W.stringifyIdent(a),u)}}async attachExternalDependents(e,r){throw new Error("External dependencies haven't been implemented for the node-modules linker")}async finalizeInstall(){if(this.opts.project.configuration.get("nodeLinker")!=="node-modules")return;let e=new mi({baseFs:new Jl({maxOpenFiles:80,readOnlyArchives:!0})}),r=await fG(this.opts.project),o=this.opts.project.configuration.get("nmMode");(r===null||o!==r.nmMode)&&(this.opts.project.storedBuildState.clear(),r={locatorMap:new Map,binSymlinks:new Map,locationTree:new Map,nmMode:o,mtimeMs:0});let a=new Map(this.opts.project.workspaces.map(v=>{let x=this.opts.project.configuration.get("nmHoistingLimits");try{x=_e.validateEnum(JB,v.manifest.installConfig?.hoistingLimits??x)}catch{let R=W.prettyWorkspace(this.opts.project.configuration,v);this.opts.report.reportWarning(57,`${R}: Invalid 'installConfig.hoistingLimits' value. Expected one of ${Object.values(JB).join(", ")}, using default: "${x}"`)}return[v.relativeCwd,x]})),n=new Map(this.opts.project.workspaces.map(v=>{let x=this.opts.project.configuration.get("nmSelfReferences");return x=v.manifest.installConfig?.selfReferences??x,[v.relativeCwd,x]})),u={VERSIONS:{std:1},topLevel:{name:null,reference:null},getLocator:(v,x)=>Array.isArray(x)?{name:x[0],reference:x[1]}:{name:v,reference:x},getDependencyTreeRoots:()=>this.opts.project.workspaces.map(v=>{let x=v.anchoredLocator;return{name:W.stringifyIdent(x),reference:x.reference}}),getPackageInformation:v=>{let x=v.reference===null?this.opts.project.topLevelWorkspace.anchoredLocator:W.makeLocator(W.parseIdent(v.name),v.reference),C=this.localStore.get(x.locatorHash);if(typeof C>"u")throw new Error("Assertion failed: Expected the package reference to have been registered");return C.pnpNode},findPackageLocator:v=>{let x=this.opts.project.tryWorkspaceByCwd(ue.toPortablePath(v));if(x!==null){let C=x.anchoredLocator;return{name:W.stringifyIdent(C),reference:C.reference}}throw new Error("Assertion failed: Unimplemented")},resolveToUnqualified:()=>{throw new Error("Assertion failed: Unimplemented")},resolveUnqualified:()=>{throw new Error("Assertion failed: Unimplemented")},resolveRequest:()=>{throw new Error("Assertion failed: Unimplemented")},resolveVirtual:v=>ue.fromPortablePath(mi.resolveVirtual(ue.toPortablePath(v)))},{tree:A,errors:p,preserveSymlinksRequired:h}=zB(u,{pnpifyFs:!1,validateExternalSoftLinks:!0,hoistingLimitsByCwd:a,project:this.opts.project,selfReferencesByCwd:n});if(!A){for(let{messageName:v,text:x}of p)this.opts.report.reportError(v,x);return}let E=Hj(A);await y1t(r,E,{baseFs:e,project:this.opts.project,report:this.opts.report,realLocatorChecksums:this.realLocatorChecksums,loadManifest:async v=>{let x=W.parseLocator(v),C=this.localStore.get(x.locatorHash);if(typeof C>"u")throw new Error("Assertion failed: Expected the slot to exist");return C.customPackageData.manifest}});let I=[];for(let[v,x]of E.entries()){if(m1e(v))continue;let C=W.parseLocator(v),R=this.localStore.get(C.locatorHash);if(typeof R>"u")throw new Error("Assertion failed: Expected the slot to exist");if(this.opts.project.tryWorkspaceByLocator(R.pkg))continue;let L=yA.extractBuildRequest(R.pkg,R.customPackageData,R.dependencyMeta,{configuration:this.opts.project.configuration});!L||I.push({buildLocations:x.locations,locator:C,buildRequest:L})}return h&&this.opts.report.reportWarning(72,`The application uses portals and that's why ${de.pretty(this.opts.project.configuration,"--preserve-symlinks",de.Type.CODE)} Node option is required for launching it`),{customData:this.customData,records:I}}};async function f1t(t,e){let r=await Ot.tryFind(e.prefixPath,{baseFs:e.packageFs})??new Ot,o=new Set(["preinstall","install","postinstall"]);for(let a of r.scripts.keys())o.has(a)||r.scripts.delete(a);return{manifest:{bin:r.bin,scripts:r.scripts},misc:{hasBindingGyp:yA.hasBindingGyp(e)}}}async function p1t(t,e,r,o,{installChangedByUser:a}){let n="";n+=`# Warning: This file is automatically generated. Removing it is fine, but will +`,n+=`# cause your node_modules installation to become invalidated. +`,n+=` +`,n+=`__metadata: +`,n+=` version: ${f1e} +`,n+=` nmMode: ${o.value} +`;let u=Array.from(e.keys()).sort(),A=W.stringifyLocator(t.topLevelWorkspace.anchoredLocator);for(let E of u){let I=e.get(E);n+=` +`,n+=`${JSON.stringify(E)}: +`,n+=` locations: +`;for(let v of I.locations){let x=V.contains(t.cwd,v);if(x===null)throw new Error(`Assertion failed: Expected the path to be within the project (${v})`);n+=` - ${JSON.stringify(x)} +`}if(I.aliases.length>0){n+=` aliases: +`;for(let v of I.aliases)n+=` - ${JSON.stringify(v)} +`}if(E===A&&r.size>0){n+=` bin: +`;for(let[v,x]of r){let C=V.contains(t.cwd,v);if(C===null)throw new Error(`Assertion failed: Expected the path to be within the project (${v})`);n+=` ${JSON.stringify(C)}: +`;for(let[R,L]of x){let U=V.relative(V.join(v,Si),L);n+=` ${JSON.stringify(R)}: ${JSON.stringify(U)} +`}}}}let p=t.cwd,h=V.join(p,Si,p1e);a&&await oe.removePromise(h),await oe.changeFilePromise(h,n,{automaticNewlines:!0})}async function fG(t,{unrollAliases:e=!1}={}){let r=t.cwd,o=V.join(r,Si,p1e),a;try{a=await oe.statPromise(o)}catch{}if(!a)return null;let n=Ki(await oe.readFilePromise(o,"utf8"));if(n.__metadata.version>f1e)return null;let u=n.__metadata.nmMode||"classic",A=new Map,p=new Map;delete n.__metadata;for(let[h,E]of Object.entries(n)){let I=E.locations.map(x=>V.join(r,x)),v=E.bin;if(v)for(let[x,C]of Object.entries(v)){let R=V.join(r,ue.toPortablePath(x)),L=_e.getMapWithDefault(p,R);for(let[U,J]of Object.entries(C))L.set(U,ue.toPortablePath([R,Si,J].join(V.sep)))}if(A.set(h,{target:Bt.dot,linkType:"HARD",locations:I,aliases:E.aliases||[]}),e&&E.aliases)for(let x of E.aliases){let{scope:C,name:R}=W.parseLocator(h),L=W.makeLocator(W.makeIdent(C,R),x),U=W.stringifyLocator(L);A.set(U,{target:Bt.dot,linkType:"HARD",locations:I,aliases:[]})}}return{locatorMap:A,binSymlinks:p,locationTree:h1e(A,{skipPrefix:t.cwd}),nmMode:u,mtimeMs:a.mtimeMs}}var WC=async(t,e)=>{if(t.split(V.sep).indexOf(Si)<0)throw new Error(`Assertion failed: trying to remove dir that doesn't contain node_modules: ${t}`);try{if(!e.innerLoop){let o=e.allowSymlink?await oe.statPromise(t):await oe.lstatPromise(t);if(e.allowSymlink&&!o.isDirectory()||!e.allowSymlink&&o.isSymbolicLink()){await oe.unlinkPromise(t);return}}let r=await oe.readdirPromise(t,{withFileTypes:!0});for(let o of r){let a=V.join(t,o.name);o.isDirectory()?(o.name!==Si||e&&e.innerLoop)&&await WC(a,{innerLoop:!0,contentsOnly:!1}):await oe.unlinkPromise(a)}e.contentsOnly||await oe.rmdirPromise(t)}catch(r){if(r.code!=="ENOENT"&&r.code!=="ENOTEMPTY")throw r}},l1e=4,vQ=(t,{skipPrefix:e})=>{let r=V.contains(e,t);if(r===null)throw new Error(`Assertion failed: Writing attempt prevented to ${t} which is outside project root: ${e}`);let o=r.split(V.sep).filter(p=>p!==""),a=o.indexOf(Si),n=o.slice(0,a).join(V.sep),u=V.join(e,n),A=o.slice(a);return{locationRoot:u,segments:A}},h1e=(t,{skipPrefix:e})=>{let r=new Map;if(t===null)return r;let o=()=>({children:new Map,linkType:"HARD"});for(let[a,n]of t.entries()){if(n.linkType==="SOFT"&&V.contains(e,n.target)!==null){let A=_e.getFactoryWithDefault(r,n.target,o);A.locator=a,A.linkType=n.linkType}for(let u of n.locations){let{locationRoot:A,segments:p}=vQ(u,{skipPrefix:e}),h=_e.getFactoryWithDefault(r,A,o);for(let E=0;E<p.length;++E){let I=p[E];if(I!=="."){let v=_e.getFactoryWithDefault(h.children,I,o);h.children.set(I,v),h=v}E===p.length-1&&(h.locator=a,h.linkType=n.linkType)}}}return r},gG=async(t,e,r)=>{if(process.platform==="win32"&&r==="junctions"){let o;try{o=await oe.lstatPromise(t)}catch{}if(!o||o.isDirectory()){await oe.symlinkPromise(t,e,"junction");return}}await oe.symlinkPromise(V.relative(V.dirname(e),t),e)};async function g1e(t,e,r){let o=V.join(t,`${pG.default.randomBytes(16).toString("hex")}.tmp`);try{await oe.writeFilePromise(o,r);try{await oe.linkPromise(o,e)}catch{}}finally{await oe.unlinkPromise(o)}}async function h1t({srcPath:t,dstPath:e,entry:r,globalHardlinksStore:o,baseFs:a,nmMode:n}){if(r.kind===d1e.FILE){if(n.value==="hardlinks-global"&&o&&r.digest){let A=V.join(o,r.digest.substring(0,2),`${r.digest.substring(2)}.dat`),p;try{let h=await oe.statPromise(A);if(h&&(!r.mtimeMs||h.mtimeMs>r.mtimeMs||h.mtimeMs<r.mtimeMs-A1t))if(await wn.checksumFile(A,{baseFs:oe,algorithm:"sha1"})!==r.digest){let I=V.join(o,`${pG.default.randomBytes(16).toString("hex")}.tmp`);await oe.renamePromise(A,I);let v=await a.readFilePromise(t);await oe.writeFilePromise(I,v);try{await oe.linkPromise(I,A),r.mtimeMs=new Date().getTime(),await oe.unlinkPromise(I)}catch{}}else r.mtimeMs||(r.mtimeMs=Math.ceil(h.mtimeMs));await oe.linkPromise(A,e),p=!0}catch{p=!1}if(!p){let h=await a.readFilePromise(t);await g1e(o,A,h),r.mtimeMs=new Date().getTime();try{await oe.linkPromise(A,e)}catch(E){E&&E.code&&E.code=="EXDEV"&&(n.value="hardlinks-local",await a.copyFilePromise(t,e))}}}else await a.copyFilePromise(t,e);let u=r.mode&511;u!==420&&await oe.chmodPromise(e,u)}}var d1e=(o=>(o.FILE="file",o.DIRECTORY="directory",o.SYMLINK="symlink",o))(d1e||{}),g1t=async(t,e,{baseFs:r,globalHardlinksStore:o,nmMode:a,windowsLinkType:n,packageChecksum:u})=>{await oe.mkdirPromise(t,{recursive:!0});let A=async(E=Bt.dot)=>{let I=V.join(e,E),v=await r.readdirPromise(I,{withFileTypes:!0}),x=new Map;for(let C of v){let R=V.join(E,C.name),L,U=V.join(I,C.name);if(C.isFile()){if(L={kind:"file",mode:(await r.lstatPromise(U)).mode},a.value==="hardlinks-global"){let J=await wn.checksumFile(U,{baseFs:r,algorithm:"sha1"});L.digest=J}}else if(C.isDirectory())L={kind:"directory"};else if(C.isSymbolicLink())L={kind:"symlink",symlinkTo:await r.readlinkPromise(U)};else throw new Error(`Unsupported file type (file: ${U}, mode: 0o${await r.statSync(U).mode.toString(8).padStart(6,"0")})`);if(x.set(R,L),C.isDirectory()&&R!==Si){let J=await A(R);for(let[te,ae]of J)x.set(te,ae)}}return x},p;if(a.value==="hardlinks-global"&&o&&u){let E=V.join(o,u.substring(0,2),`${u.substring(2)}.json`);try{p=new Map(Object.entries(JSON.parse(await oe.readFilePromise(E,"utf8"))))}catch{p=await A()}}else p=await A();let h=!1;for(let[E,I]of p){let v=V.join(e,E),x=V.join(t,E);if(I.kind==="directory")await oe.mkdirPromise(x,{recursive:!0});else if(I.kind==="file"){let C=I.mtimeMs;await h1t({srcPath:v,dstPath:x,entry:I,nmMode:a,baseFs:r,globalHardlinksStore:o}),I.mtimeMs!==C&&(h=!0)}else I.kind==="symlink"&&await gG(V.resolve(V.dirname(x),I.symlinkTo),x,n)}if(a.value==="hardlinks-global"&&o&&h&&u){let E=V.join(o,u.substring(0,2),`${u.substring(2)}.json`);await oe.removePromise(E),await g1e(o,E,Buffer.from(JSON.stringify(Object.fromEntries(p))))}};function d1t(t,e,r,o){let a=new Map,n=new Map,u=new Map,A=!1,p=(h,E,I,v,x)=>{let C=!0,R=V.join(h,E),L=new Set;if(E===Si||E.startsWith("@")){let J;try{J=oe.statSync(R)}catch{}C=!!J,J?J.mtimeMs>r?(A=!0,L=new Set(oe.readdirSync(R))):L=new Set(I.children.get(E).children.keys()):A=!0;let te=e.get(h);if(te){let ae=V.join(h,Si,BQ),fe;try{fe=oe.statSync(ae)}catch{}if(!fe)A=!0;else if(fe.mtimeMs>r){A=!0;let ce=new Set(oe.readdirSync(ae)),me=new Map;n.set(h,me);for(let[he,Be]of te)ce.has(he)&&me.set(he,Be)}else n.set(h,te)}}else C=x.has(E);let U=I.children.get(E);if(C){let{linkType:J,locator:te}=U,ae={children:new Map,linkType:J,locator:te};if(v.children.set(E,ae),te){let fe=_e.getSetWithDefault(u,te);fe.add(R),u.set(te,fe)}for(let fe of U.children.keys())p(R,fe,U,ae,L)}else U.locator&&o.storedBuildState.delete(W.parseLocator(U.locator).locatorHash)};for(let[h,E]of t){let{linkType:I,locator:v}=E,x={children:new Map,linkType:I,locator:v};if(a.set(h,x),v){let C=_e.getSetWithDefault(u,E.locator);C.add(h),u.set(E.locator,C)}E.children.has(Si)&&p(h,Si,E,x,new Set)}return{locationTree:a,binSymlinks:n,locatorLocations:u,installChangedByUser:A}}function m1e(t){let e=W.parseDescriptor(t);return W.isVirtualDescriptor(e)&&(e=W.devirtualizeDescriptor(e)),e.range.startsWith("link:")}async function m1t(t,e,r,{loadManifest:o}){let a=new Map;for(let[A,{locations:p}]of t){let h=m1e(A)?null:await o(A,p[0]),E=new Map;if(h)for(let[I,v]of h.bin){let x=V.join(p[0],v);v!==""&&oe.existsSync(x)&&E.set(I,v)}a.set(A,E)}let n=new Map,u=(A,p,h)=>{let E=new Map,I=V.contains(r,A);if(h.locator&&I!==null){let v=a.get(h.locator);for(let[x,C]of v){let R=V.join(A,ue.toPortablePath(C));E.set(x,R)}for(let[x,C]of h.children){let R=V.join(A,x),L=u(R,R,C);L.size>0&&n.set(A,new Map([...n.get(A)||new Map,...L]))}}else for(let[v,x]of h.children){let C=u(V.join(A,v),p,x);for(let[R,L]of C)E.set(R,L)}return E};for(let[A,p]of e){let h=u(A,A,p);h.size>0&&n.set(A,new Map([...n.get(A)||new Map,...h]))}return n}var c1e=(t,e)=>{if(!t||!e)return t===e;let r=W.parseLocator(t);W.isVirtualLocator(r)&&(r=W.devirtualizeLocator(r));let o=W.parseLocator(e);return W.isVirtualLocator(o)&&(o=W.devirtualizeLocator(o)),W.areLocatorsEqual(r,o)};function dG(t){return V.join(t.get("globalFolder"),"store")}async function y1t(t,e,{baseFs:r,project:o,report:a,loadManifest:n,realLocatorChecksums:u}){let A=V.join(o.cwd,Si),{locationTree:p,binSymlinks:h,locatorLocations:E,installChangedByUser:I}=d1t(t.locationTree,t.binSymlinks,t.mtimeMs,o),v=h1e(e,{skipPrefix:o.cwd}),x=[],C=async({srcDir:Be,dstDir:we,linkType:g,globalHardlinksStore:Ee,nmMode:Se,windowsLinkType:le,packageChecksum:ne})=>{let ee=(async()=>{try{g==="SOFT"?(await oe.mkdirPromise(V.dirname(we),{recursive:!0}),await gG(V.resolve(Be),we,le)):await g1t(we,Be,{baseFs:r,globalHardlinksStore:Ee,nmMode:Se,windowsLinkType:le,packageChecksum:ne})}catch(Ie){throw Ie.message=`While persisting ${Be} -> ${we} ${Ie.message}`,Ie}finally{ae.tick()}})().then(()=>x.splice(x.indexOf(ee),1));x.push(ee),x.length>l1e&&await Promise.race(x)},R=async(Be,we,g)=>{let Ee=(async()=>{let Se=async(le,ne,ee)=>{try{ee.innerLoop||await oe.mkdirPromise(ne,{recursive:!0});let Ie=await oe.readdirPromise(le,{withFileTypes:!0});for(let Fe of Ie){if(!ee.innerLoop&&Fe.name===BQ)continue;let At=V.join(le,Fe.name),H=V.join(ne,Fe.name);Fe.isDirectory()?(Fe.name!==Si||ee&&ee.innerLoop)&&(await oe.mkdirPromise(H,{recursive:!0}),await Se(At,H,{...ee,innerLoop:!0})):me.value==="hardlinks-local"||me.value==="hardlinks-global"?await oe.linkPromise(At,H):await oe.copyFilePromise(At,H,A1e.default.constants.COPYFILE_FICLONE)}}catch(Ie){throw ee.innerLoop||(Ie.message=`While cloning ${le} -> ${ne} ${Ie.message}`),Ie}finally{ee.innerLoop||ae.tick()}};await Se(Be,we,g)})().then(()=>x.splice(x.indexOf(Ee),1));x.push(Ee),x.length>l1e&&await Promise.race(x)},L=async(Be,we,g)=>{if(g)for(let[Ee,Se]of we.children){let le=g.children.get(Ee);await L(V.join(Be,Ee),Se,le)}else{we.children.has(Si)&&await WC(V.join(Be,Si),{contentsOnly:!1});let Ee=V.basename(Be)===Si&&v.has(V.join(V.dirname(Be),V.sep));await WC(Be,{contentsOnly:Be===A,allowSymlink:Ee})}};for(let[Be,we]of p){let g=v.get(Be);for(let[Ee,Se]of we.children){if(Ee===".")continue;let le=g&&g.children.get(Ee),ne=V.join(Be,Ee);await L(ne,Se,le)}}let U=async(Be,we,g)=>{if(g){c1e(we.locator,g.locator)||await WC(Be,{contentsOnly:we.linkType==="HARD"});for(let[Ee,Se]of we.children){let le=g.children.get(Ee);await U(V.join(Be,Ee),Se,le)}}else{we.children.has(Si)&&await WC(V.join(Be,Si),{contentsOnly:!0});let Ee=V.basename(Be)===Si&&v.has(V.join(V.dirname(Be),V.sep));await WC(Be,{contentsOnly:we.linkType==="HARD",allowSymlink:Ee})}};for(let[Be,we]of v){let g=p.get(Be);for(let[Ee,Se]of we.children){if(Ee===".")continue;let le=g&&g.children.get(Ee);await U(V.join(Be,Ee),Se,le)}}let J=new Map,te=[];for(let[Be,we]of E)for(let g of we){let{locationRoot:Ee,segments:Se}=vQ(g,{skipPrefix:o.cwd}),le=v.get(Ee),ne=Ee;if(le){for(let ee of Se)if(ne=V.join(ne,ee),le=le.children.get(ee),!le)break;if(le){let ee=c1e(le.locator,Be),Ie=e.get(le.locator),Fe=Ie.target,At=ne,H=Ie.linkType;if(ee)J.has(Fe)||J.set(Fe,At);else if(Fe!==At){let at=W.parseLocator(le.locator);W.isVirtualLocator(at)&&(at=W.devirtualizeLocator(at)),te.push({srcDir:Fe,dstDir:At,linkType:H,realLocatorHash:at.locatorHash})}}}}for(let[Be,{locations:we}]of e.entries())for(let g of we){let{locationRoot:Ee,segments:Se}=vQ(g,{skipPrefix:o.cwd}),le=p.get(Ee),ne=v.get(Ee),ee=Ee,Ie=e.get(Be),Fe=W.parseLocator(Be);W.isVirtualLocator(Fe)&&(Fe=W.devirtualizeLocator(Fe));let At=Fe.locatorHash,H=Ie.target,at=g;if(H===at)continue;let Re=Ie.linkType;for(let ke of Se)ne=ne.children.get(ke);if(!le)te.push({srcDir:H,dstDir:at,linkType:Re,realLocatorHash:At});else for(let ke of Se)if(ee=V.join(ee,ke),le=le.children.get(ke),!le){te.push({srcDir:H,dstDir:at,linkType:Re,realLocatorHash:At});break}}let ae=Xs.progressViaCounter(te.length),fe=a.reportProgress(ae),ce=o.configuration.get("nmMode"),me={value:ce},he=o.configuration.get("winLinkType");try{let Be=me.value==="hardlinks-global"?`${dG(o.configuration)}/v1`:null;if(Be&&!await oe.existsPromise(Be)){await oe.mkdirpPromise(Be);for(let g=0;g<256;g++)await oe.mkdirPromise(V.join(Be,g.toString(16).padStart(2,"0")))}for(let g of te)(g.linkType==="SOFT"||!J.has(g.srcDir))&&(J.set(g.srcDir,g.dstDir),await C({...g,globalHardlinksStore:Be,nmMode:me,windowsLinkType:he,packageChecksum:u.get(g.realLocatorHash)||null}));await Promise.all(x),x.length=0;for(let g of te){let Ee=J.get(g.srcDir);g.linkType!=="SOFT"&&g.dstDir!==Ee&&await R(Ee,g.dstDir,{nmMode:me})}await Promise.all(x),await oe.mkdirPromise(A,{recursive:!0});let we=await m1t(e,v,o.cwd,{loadManifest:n});await E1t(h,we,o.cwd,he),await p1t(o,e,we,me,{installChangedByUser:I}),ce=="hardlinks-global"&&me.value=="hardlinks-local"&&a.reportWarningOnce(74,"'nmMode' has been downgraded to 'hardlinks-local' due to global cache and install folder being on different devices")}finally{fe.stop()}}async function E1t(t,e,r,o){for(let a of t.keys()){if(V.contains(r,a)===null)throw new Error(`Assertion failed. Excepted bin symlink location to be inside project dir, instead it was at ${a}`);if(!e.has(a)){let n=V.join(a,Si,BQ);await oe.removePromise(n)}}for(let[a,n]of e){if(V.contains(r,a)===null)throw new Error(`Assertion failed. Excepted bin symlink location to be inside project dir, instead it was at ${a}`);let u=V.join(a,Si,BQ),A=t.get(a)||new Map;await oe.mkdirPromise(u,{recursive:!0});for(let p of A.keys())n.has(p)||(await oe.removePromise(V.join(u,p)),process.platform==="win32"&&await oe.removePromise(V.join(u,`${p}.cmd`)));for(let[p,h]of n){let E=A.get(p),I=V.join(u,p);E!==h&&(process.platform==="win32"?await(0,u1e.default)(ue.fromPortablePath(h),ue.fromPortablePath(I),{createPwshFile:!1}):(await oe.removePromise(I),await gG(h,I,o),V.contains(r,await oe.realpathPromise(h))!==null&&await oe.chmodPromise(h,493)))}}}Ye();St();nA();var cv=class extends P0{constructor(){super(...arguments);this.mode="loose"}makeInstaller(r){return new mG(r)}},mG=class extends mm{constructor(){super(...arguments);this.mode="loose"}async transformPnpSettings(r){let o=new mi({baseFs:new Jl({maxOpenFiles:80,readOnlyArchives:!0})}),a=zIe(r,this.opts.project.cwd,o),{tree:n,errors:u}=zB(a,{pnpifyFs:!1,project:this.opts.project});if(!n){for(let{messageName:I,text:v}of u)this.opts.report.reportError(I,v);return}let A=new Map;r.fallbackPool=A;let p=(I,v)=>{let x=W.parseLocator(v.locator),C=W.stringifyIdent(x);C===I?A.set(I,x.reference):A.set(I,[C,x.reference])},h=V.join(this.opts.project.cwd,dr.nodeModules),E=n.get(h);if(!(typeof E>"u")){if("target"in E)throw new Error("Assertion failed: Expected the root junction point to be a directory");for(let I of E.dirList){let v=V.join(h,I),x=n.get(v);if(typeof x>"u")throw new Error("Assertion failed: Expected the child to have been registered");if("target"in x)p(I,x);else for(let C of x.dirList){let R=V.join(v,C),L=n.get(R);if(typeof L>"u")throw new Error("Assertion failed: Expected the subchild to have been registered");if("target"in L)p(`${I}/${C}`,L);else throw new Error("Assertion failed: Expected the leaf junction to be a package")}}}}};var C1t={hooks:{cleanGlobalArtifacts:async t=>{let e=dG(t);await oe.removePromise(e)}},configuration:{nmHoistingLimits:{description:"Prevents packages to be hoisted past specific levels",type:"STRING",values:["workspaces","dependencies","none"],default:"none"},nmMode:{description:"Defines in which measure Yarn must use hardlinks and symlinks when generated `node_modules` directories.",type:"STRING",values:["classic","hardlinks-local","hardlinks-global"],default:"classic"},nmSelfReferences:{description:"Defines whether the linker should generate self-referencing symlinks for workspaces.",type:"BOOLEAN",default:!0}},linkers:[lv,cv]},w1t=C1t;var dq={};Vt(dq,{NpmHttpFetcher:()=>fv,NpmRemapResolver:()=>pv,NpmSemverFetcher:()=>dl,NpmSemverResolver:()=>hv,NpmTagResolver:()=>gv,default:()=>Lvt,npmConfigUtils:()=>Zn,npmHttpUtils:()=>Zr,npmPublishUtils:()=>ow});Ye();var D1e=$e(zn());var Wn="npm:";var Zr={};Vt(Zr,{AuthType:()=>I1e,customPackageError:()=>ym,del:()=>R1t,get:()=>Em,getIdentUrl:()=>DQ,getPackageMetadata:()=>JC,handleInvalidAuthenticationError:()=>k0,post:()=>Q1t,put:()=>F1t});Ye();Ye();St();var wG=$e(f2()),C1e=$e(D_()),w1e=$e(zn());var Zn={};Vt(Zn,{RegistryType:()=>y1e,getAuditRegistry:()=>I1t,getAuthConfiguration:()=>CG,getDefaultRegistry:()=>uv,getPublishRegistry:()=>B1t,getRegistryConfiguration:()=>E1e,getScopeConfiguration:()=>EG,getScopeRegistry:()=>KC,normalizeRegistry:()=>oc});var y1e=(o=>(o.AUDIT_REGISTRY="npmAuditRegistry",o.FETCH_REGISTRY="npmRegistryServer",o.PUBLISH_REGISTRY="npmPublishRegistry",o))(y1e||{});function oc(t){return t.replace(/\/$/,"")}function I1t({configuration:t}){return uv({configuration:t,type:"npmAuditRegistry"})}function B1t(t,{configuration:e}){return t.publishConfig?.registry?oc(t.publishConfig.registry):t.name?KC(t.name.scope,{configuration:e,type:"npmPublishRegistry"}):uv({configuration:e,type:"npmPublishRegistry"})}function KC(t,{configuration:e,type:r="npmRegistryServer"}){let o=EG(t,{configuration:e});if(o===null)return uv({configuration:e,type:r});let a=o.get(r);return a===null?uv({configuration:e,type:r}):oc(a)}function uv({configuration:t,type:e="npmRegistryServer"}){let r=t.get(e);return oc(r!==null?r:t.get("npmRegistryServer"))}function E1e(t,{configuration:e}){let r=e.get("npmRegistries"),o=oc(t),a=r.get(o);if(typeof a<"u")return a;let n=r.get(o.replace(/^[a-z]+:/,""));return typeof n<"u"?n:null}function EG(t,{configuration:e}){if(t===null)return null;let o=e.get("npmScopes").get(t);return o||null}function CG(t,{configuration:e,ident:r}){let o=r&&EG(r.scope,{configuration:e});return o?.get("npmAuthIdent")||o?.get("npmAuthToken")?o:E1e(t,{configuration:e})||e}var I1e=(a=>(a[a.NO_AUTH=0]="NO_AUTH",a[a.BEST_EFFORT=1]="BEST_EFFORT",a[a.CONFIGURATION=2]="CONFIGURATION",a[a.ALWAYS_AUTH=3]="ALWAYS_AUTH",a))(I1e||{});async function k0(t,{attemptedAs:e,registry:r,headers:o,configuration:a}){if(PQ(t))throw new zt(41,"Invalid OTP token");if(t.originalError?.name==="HTTPError"&&t.originalError?.response.statusCode===401)throw new zt(41,`Invalid authentication (${typeof e!="string"?`as ${await N1t(r,o,{configuration:a})}`:`attempted as ${e}`})`)}function ym(t,e){let r=t.response?.statusCode;return r?r===404?"Package not found":r>=500&&r<600?`The registry appears to be down (using a ${de.applyHyperlink(e,"local cache","https://yarnpkg.com/advanced/lexicon#local-cache")} might have protected you against such outages)`:null:null}function DQ(t){return t.scope?`/@${t.scope}%2f${t.name}`:`/${t.name}`}var B1e=new Map,v1t=new Map;async function D1t(t){return await _e.getFactoryWithDefault(B1e,t,async()=>{let e=null;try{e=await oe.readJsonPromise(t)}catch{}return e})}async function S1t(t,e,{configuration:r,cached:o,registry:a,headers:n,version:u,...A}){return await _e.getFactoryWithDefault(v1t,t,async()=>await Em(DQ(e),{...A,customErrorMessage:ym,configuration:r,registry:a,ident:e,headers:{...n,["If-None-Match"]:o?.etag,["If-Modified-Since"]:o?.lastModified},wrapNetworkRequest:async p=>async()=>{let h=await p();if(h.statusCode===304){if(o===null)throw new Error("Assertion failed: cachedMetadata should not be null");return{...h,body:o.metadata}}let E=P1t(JSON.parse(h.body.toString())),I={metadata:E,etag:h.headers.etag,lastModified:h.headers["last-modified"]};return B1e.set(t,Promise.resolve(I)),Promise.resolve().then(async()=>{let v=`${t}-${process.pid}.tmp`;await oe.mkdirPromise(V.dirname(v),{recursive:!0}),await oe.writeJsonPromise(v,I,{compact:!0}),await oe.renamePromise(v,t)}).catch(()=>{}),{...h,body:E}}}))}async function JC(t,{cache:e,project:r,registry:o,headers:a,version:n,...u}){let{configuration:A}=r;o=Av(A,{ident:t,registry:o});let p=x1t(A,o),h=V.join(p,`${W.slugifyIdent(t)}.json`),E=null;if(!r.lockfileNeedsRefresh&&(E=await D1t(h),E)){if(typeof n<"u"&&typeof E.metadata.versions[n]<"u")return E.metadata;if(A.get("enableOfflineMode")){let I=structuredClone(E.metadata),v=new Set;if(e){for(let C of Object.keys(I.versions)){let R=W.makeLocator(t,`npm:${C}`),L=e.getLocatorMirrorPath(R);(!L||!oe.existsSync(L))&&(delete I.versions[C],v.add(C))}let x=I["dist-tags"].latest;if(v.has(x)){let C=Object.keys(E.metadata.versions).sort(w1e.default.compare),R=C.indexOf(x);for(;v.has(C[R])&&R>=0;)R-=1;R>=0?I["dist-tags"].latest=C[R]:delete I["dist-tags"].latest}}return I}}return await S1t(h,t,{...u,configuration:A,cached:E,registry:o,headers:a,version:n})}var v1e=["name","dist.tarball","bin","scripts","os","cpu","libc","dependencies","dependenciesMeta","optionalDependencies","peerDependencies","peerDependenciesMeta","deprecated"];function P1t(t){return{"dist-tags":t["dist-tags"],versions:Object.fromEntries(Object.entries(t.versions).map(([e,r])=>[e,(0,C1e.default)(r,v1e)]))}}var b1t=wn.makeHash(...v1e).slice(0,6);function x1t(t,e){let r=k1t(t),o=new URL(e);return V.join(r,b1t,o.hostname)}function k1t(t){return V.join(t.get("globalFolder"),"metadata/npm")}async function Em(t,{configuration:e,headers:r,ident:o,authType:a,registry:n,...u}){n=Av(e,{ident:o,registry:n}),o&&o.scope&&typeof a>"u"&&(a=1);let A=await SQ(n,{authType:a,configuration:e,ident:o});A&&(r={...r,authorization:A});try{return await nn.get(t.charAt(0)==="/"?`${n}${t}`:t,{configuration:e,headers:r,...u})}catch(p){throw await k0(p,{registry:n,configuration:e,headers:r}),p}}async function Q1t(t,e,{attemptedAs:r,configuration:o,headers:a,ident:n,authType:u=3,registry:A,otp:p,...h}){A=Av(o,{ident:n,registry:A});let E=await SQ(A,{authType:u,configuration:o,ident:n});E&&(a={...a,authorization:E}),p&&(a={...a,...VC(p)});try{return await nn.post(A+t,e,{configuration:o,headers:a,...h})}catch(I){if(!PQ(I)||p)throw await k0(I,{attemptedAs:r,registry:A,configuration:o,headers:a}),I;p=await IG(I,{configuration:o});let v={...a,...VC(p)};try{return await nn.post(`${A}${t}`,e,{configuration:o,headers:v,...h})}catch(x){throw await k0(x,{attemptedAs:r,registry:A,configuration:o,headers:a}),x}}}async function F1t(t,e,{attemptedAs:r,configuration:o,headers:a,ident:n,authType:u=3,registry:A,otp:p,...h}){A=Av(o,{ident:n,registry:A});let E=await SQ(A,{authType:u,configuration:o,ident:n});E&&(a={...a,authorization:E}),p&&(a={...a,...VC(p)});try{return await nn.put(A+t,e,{configuration:o,headers:a,...h})}catch(I){if(!PQ(I))throw await k0(I,{attemptedAs:r,registry:A,configuration:o,headers:a}),I;p=await IG(I,{configuration:o});let v={...a,...VC(p)};try{return await nn.put(`${A}${t}`,e,{configuration:o,headers:v,...h})}catch(x){throw await k0(x,{attemptedAs:r,registry:A,configuration:o,headers:a}),x}}}async function R1t(t,{attemptedAs:e,configuration:r,headers:o,ident:a,authType:n=3,registry:u,otp:A,...p}){u=Av(r,{ident:a,registry:u});let h=await SQ(u,{authType:n,configuration:r,ident:a});h&&(o={...o,authorization:h}),A&&(o={...o,...VC(A)});try{return await nn.del(u+t,{configuration:r,headers:o,...p})}catch(E){if(!PQ(E)||A)throw await k0(E,{attemptedAs:e,registry:u,configuration:r,headers:o}),E;A=await IG(E,{configuration:r});let I={...o,...VC(A)};try{return await nn.del(`${u}${t}`,{configuration:r,headers:I,...p})}catch(v){throw await k0(v,{attemptedAs:e,registry:u,configuration:r,headers:o}),v}}}function Av(t,{ident:e,registry:r}){if(typeof r>"u"&&e)return KC(e.scope,{configuration:t});if(typeof r!="string")throw new Error("Assertion failed: The registry should be a string");return oc(r)}async function SQ(t,{authType:e=2,configuration:r,ident:o}){let a=CG(t,{configuration:r,ident:o}),n=T1t(a,e);if(!n)return null;let u=await r.reduceHook(A=>A.getNpmAuthenticationHeader,void 0,t,{configuration:r,ident:o});if(u)return u;if(a.get("npmAuthToken"))return`Bearer ${a.get("npmAuthToken")}`;if(a.get("npmAuthIdent")){let A=a.get("npmAuthIdent");return A.includes(":")?`Basic ${Buffer.from(A).toString("base64")}`:`Basic ${A}`}if(n&&e!==1)throw new zt(33,"No authentication configured for request");return null}function T1t(t,e){switch(e){case 2:return t.get("npmAlwaysAuth");case 1:case 3:return!0;case 0:return!1;default:throw new Error("Unreachable")}}async function N1t(t,e,{configuration:r}){if(typeof e>"u"||typeof e.authorization>"u")return"an anonymous user";try{return(await nn.get(new URL(`${t}/-/whoami`).href,{configuration:r,headers:e,jsonResponse:!0})).username??"an unknown user"}catch{return"an unknown user"}}async function IG(t,{configuration:e}){let r=t.originalError?.response.headers["npm-notice"];if(r&&(await Nt.start({configuration:e,stdout:process.stdout,includeFooter:!1},async a=>{if(a.reportInfo(0,r.replace(/(https?:\/\/\S+)/g,de.pretty(e,"$1",de.Type.URL))),!process.env.YARN_IS_TEST_ENV){let n=r.match(/open (https?:\/\/\S+)/i);if(n&&Ji.openUrl){let{openNow:u}=await(0,wG.prompt)({type:"confirm",name:"openNow",message:"Do you want to try to open this url now?",required:!0,initial:!0,onCancel:()=>process.exit(130)});u&&(await Ji.openUrl(n[1])||(a.reportSeparator(),a.reportWarning(0,"We failed to automatically open the url; you'll have to open it yourself in your browser of choice.")))}}}),process.stdout.write(` +`)),process.env.YARN_IS_TEST_ENV)return process.env.YARN_INJECT_NPM_2FA_TOKEN||"";let{otp:o}=await(0,wG.prompt)({type:"password",name:"otp",message:"One-time password:",required:!0,onCancel:()=>process.exit(130)});return process.stdout.write(` +`),o}function PQ(t){if(t.originalError?.name!=="HTTPError")return!1;try{return(t.originalError?.response.headers["www-authenticate"].split(/,\s*/).map(r=>r.toLowerCase())).includes("otp")}catch{return!1}}function VC(t){return{["npm-otp"]:t}}var fv=class{supports(e,r){if(!e.reference.startsWith(Wn))return!1;let{selector:o,params:a}=W.parseRange(e.reference);return!(!D1e.default.valid(o)||a===null||typeof a.__archiveUrl!="string")}getLocalPath(e,r){return null}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,[a,n,u]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${W.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the remote server`),loader:()=>this.fetchFromNetwork(e,r),...r.cacheOptions});return{packageFs:a,releaseFs:n,prefixPath:W.getIdentVendorPath(e),checksum:u}}async fetchFromNetwork(e,r){let{params:o}=W.parseRange(e.reference);if(o===null||typeof o.__archiveUrl!="string")throw new Error("Assertion failed: The archiveUrl querystring parameter should have been available");let a=await Em(o.__archiveUrl,{customErrorMessage:ym,configuration:r.project.configuration,ident:e});return await Xi.convertToZip(a,{configuration:r.project.configuration,prefixPath:W.getIdentVendorPath(e),stripComponents:1})}};Ye();var pv=class{supportsDescriptor(e,r){return!(!e.range.startsWith(Wn)||!W.tryParseDescriptor(e.range.slice(Wn.length),!0))}supportsLocator(e,r){return!1}shouldPersistResolution(e,r){throw new Error("Unreachable")}bindDescriptor(e,r,o){return e}getResolutionDependencies(e,r){let o=r.project.configuration.normalizeDependency(W.parseDescriptor(e.range.slice(Wn.length),!0));return r.resolver.getResolutionDependencies(o,r)}async getCandidates(e,r,o){let a=o.project.configuration.normalizeDependency(W.parseDescriptor(e.range.slice(Wn.length),!0));return await o.resolver.getCandidates(a,r,o)}async getSatisfying(e,r,o,a){let n=a.project.configuration.normalizeDependency(W.parseDescriptor(e.range.slice(Wn.length),!0));return a.resolver.getSatisfying(n,r,o,a)}resolve(e,r){throw new Error("Unreachable")}};Ye();Ye();var S1e=$e(zn());var dl=class{supports(e,r){if(!e.reference.startsWith(Wn))return!1;let o=new URL(e.reference);return!(!S1e.default.valid(o.pathname)||o.searchParams.has("__archiveUrl"))}getLocalPath(e,r){return null}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,[a,n,u]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${W.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the remote registry`),loader:()=>this.fetchFromNetwork(e,r),...r.cacheOptions});return{packageFs:a,releaseFs:n,prefixPath:W.getIdentVendorPath(e),checksum:u}}async fetchFromNetwork(e,r){let o;try{o=await Em(dl.getLocatorUrl(e),{customErrorMessage:ym,configuration:r.project.configuration,ident:e})}catch{o=await Em(dl.getLocatorUrl(e).replace(/%2f/g,"/"),{customErrorMessage:ym,configuration:r.project.configuration,ident:e})}return await Xi.convertToZip(o,{configuration:r.project.configuration,prefixPath:W.getIdentVendorPath(e),stripComponents:1})}static isConventionalTarballUrl(e,r,{configuration:o}){let a=KC(e.scope,{configuration:o}),n=dl.getLocatorUrl(e);return r=r.replace(/^https?:(\/\/(?:[^/]+\.)?npmjs.org(?:$|\/))/,"https:$1"),a=a.replace(/^https:\/\/registry\.npmjs\.org($|\/)/,"https://registry.yarnpkg.com$1"),r=r.replace(/^https:\/\/registry\.npmjs\.org($|\/)/,"https://registry.yarnpkg.com$1"),r===a+n||r===a+n.replace(/%2f/g,"/")}static getLocatorUrl(e){let r=kr.clean(e.reference.slice(Wn.length));if(r===null)throw new zt(10,"The npm semver resolver got selected, but the version isn't semver");return`${DQ(e)}/-/${e.name}-${r}.tgz`}};Ye();Ye();Ye();var BG=$e(zn());var bQ=W.makeIdent(null,"node-gyp"),L1t=/\b(node-gyp|prebuild-install)\b/,hv=class{supportsDescriptor(e,r){return e.range.startsWith(Wn)?!!kr.validRange(e.range.slice(Wn.length)):!1}supportsLocator(e,r){if(!e.reference.startsWith(Wn))return!1;let{selector:o}=W.parseRange(e.reference);return!!BG.default.valid(o)}shouldPersistResolution(e,r){return!0}bindDescriptor(e,r,o){return e}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){let a=kr.validRange(e.range.slice(Wn.length));if(a===null)throw new Error(`Expected a valid range, got ${e.range.slice(Wn.length)}`);let n=await JC(e,{cache:o.fetchOptions?.cache,project:o.project,version:BG.default.valid(a.raw)?a.raw:void 0}),u=_e.mapAndFilter(Object.keys(n.versions),h=>{try{let E=new kr.SemVer(h);if(a.test(E))return E}catch{}return _e.mapAndFilter.skip}),A=u.filter(h=>!n.versions[h.raw].deprecated),p=A.length>0?A:u;return p.sort((h,E)=>-h.compare(E)),p.map(h=>{let E=W.makeLocator(e,`${Wn}${h.raw}`),I=n.versions[h.raw].dist.tarball;return dl.isConventionalTarballUrl(E,I,{configuration:o.project.configuration})?E:W.bindLocator(E,{__archiveUrl:I})})}async getSatisfying(e,r,o,a){let n=kr.validRange(e.range.slice(Wn.length));if(n===null)throw new Error(`Expected a valid range, got ${e.range.slice(Wn.length)}`);return{locators:_e.mapAndFilter(o,p=>{if(p.identHash!==e.identHash)return _e.mapAndFilter.skip;let h=W.tryParseRange(p.reference,{requireProtocol:Wn});if(!h)return _e.mapAndFilter.skip;let E=new kr.SemVer(h.selector);return n.test(E)?{locator:p,version:E}:_e.mapAndFilter.skip}).sort((p,h)=>-p.version.compare(h.version)).map(({locator:p})=>p),sorted:!0}}async resolve(e,r){let{selector:o}=W.parseRange(e.reference),a=kr.clean(o);if(a===null)throw new zt(10,"The npm semver resolver got selected, but the version isn't semver");let n=await JC(e,{cache:r.fetchOptions?.cache,project:r.project,version:a});if(!Object.hasOwn(n,"versions"))throw new zt(15,'Registry returned invalid data for - missing "versions" field');if(!Object.hasOwn(n.versions,a))throw new zt(16,`Registry failed to return reference "${a}"`);let u=new Ot;if(u.load(n.versions[a]),!u.dependencies.has(bQ.identHash)&&!u.peerDependencies.has(bQ.identHash)){for(let A of u.scripts.values())if(A.match(L1t)){u.dependencies.set(bQ.identHash,W.makeDescriptor(bQ,"latest"));break}}return{...e,version:a,languageName:"node",linkType:"HARD",conditions:u.getConditions(),dependencies:r.project.configuration.normalizeDependencyMap(u.dependencies),peerDependencies:u.peerDependencies,dependenciesMeta:u.dependenciesMeta,peerDependenciesMeta:u.peerDependenciesMeta,bin:u.bin}}};Ye();Ye();var P1e=$e(zn());var gv=class{supportsDescriptor(e,r){return!(!e.range.startsWith(Wn)||!FE.test(e.range.slice(Wn.length)))}supportsLocator(e,r){return!1}shouldPersistResolution(e,r){throw new Error("Unreachable")}bindDescriptor(e,r,o){return e}getResolutionDependencies(e,r){return{}}async getCandidates(e,r,o){let a=e.range.slice(Wn.length),n=await JC(e,{cache:o.fetchOptions?.cache,project:o.project});if(!Object.hasOwn(n,"dist-tags"))throw new zt(15,'Registry returned invalid data - missing "dist-tags" field');let u=n["dist-tags"];if(!Object.hasOwn(u,a))throw new zt(16,`Registry failed to return tag "${a}"`);let A=u[a],p=W.makeLocator(e,`${Wn}${A}`),h=n.versions[A].dist.tarball;return dl.isConventionalTarballUrl(p,h,{configuration:o.project.configuration})?[p]:[W.bindLocator(p,{__archiveUrl:h})]}async getSatisfying(e,r,o,a){let n=[];for(let u of o){if(u.identHash!==e.identHash)continue;let A=W.tryParseRange(u.reference,{requireProtocol:Wn});if(!(!A||!P1e.default.valid(A.selector))){if(A.params?.__archiveUrl){let p=W.makeRange({protocol:Wn,selector:A.selector,source:null,params:null}),[h]=await a.resolver.getCandidates(W.makeDescriptor(e,p),r,a);if(u.reference!==h.reference)continue}n.push(u)}}return{locators:n,sorted:!1}}async resolve(e,r){throw new Error("Unreachable")}};var ow={};Vt(ow,{getGitHead:()=>Tvt,getPublishAccess:()=>dBe,getReadmeContent:()=>mBe,makePublishBody:()=>Rvt});Ye();Ye();St();var Aq={};Vt(Aq,{PackCommand:()=>U0,default:()=>gvt,packUtils:()=>wA});Ye();Ye();Ye();St();jt();var wA={};Vt(wA,{genPackList:()=>XQ,genPackStream:()=>uq,genPackageManifest:()=>iBe,hasPackScripts:()=>lq,prepareForPack:()=>cq});Ye();St();var aq=$e(Zo()),rBe=$e(Z2e()),nBe=ve("zlib"),ivt=["/package.json","/readme","/readme.*","/license","/license.*","/licence","/licence.*","/changelog","/changelog.*"],svt=["/package.tgz",".github",".git",".hg","node_modules",".npmignore",".gitignore",".#*",".DS_Store"];async function lq(t){return!!(un.hasWorkspaceScript(t,"prepack")||un.hasWorkspaceScript(t,"postpack"))}async function cq(t,{report:e},r){await un.maybeExecuteWorkspaceLifecycleScript(t,"prepack",{report:e});try{let o=V.join(t.cwd,Ot.fileName);await oe.existsPromise(o)&&await t.manifest.loadFile(o,{baseFs:oe}),await r()}finally{await un.maybeExecuteWorkspaceLifecycleScript(t,"postpack",{report:e})}}async function uq(t,e){typeof e>"u"&&(e=await XQ(t));let r=new Set;for(let n of t.manifest.publishConfig?.executableFiles??new Set)r.add(V.normalize(n));for(let n of t.manifest.bin.values())r.add(V.normalize(n));let o=rBe.default.pack();process.nextTick(async()=>{for(let n of e){let u=V.normalize(n),A=V.resolve(t.cwd,u),p=V.join("package",u),h=await oe.lstatPromise(A),E={name:p,mtime:new Date(vi.SAFE_TIME*1e3)},I=r.has(u)?493:420,v,x,C=new Promise((L,U)=>{v=L,x=U}),R=L=>{L?x(L):v()};if(h.isFile()){let L;u==="package.json"?L=Buffer.from(JSON.stringify(await iBe(t),null,2)):L=await oe.readFilePromise(A),o.entry({...E,mode:I,type:"file"},L,R)}else h.isSymbolicLink()?o.entry({...E,mode:I,type:"symlink",linkname:await oe.readlinkPromise(A)},R):R(new Error(`Unsupported file type ${h.mode} for ${ue.fromPortablePath(u)}`));await C}o.finalize()});let a=(0,nBe.createGzip)();return o.pipe(a),a}async function iBe(t){let e=JSON.parse(JSON.stringify(t.manifest.raw));return await t.project.configuration.triggerHook(r=>r.beforeWorkspacePacking,t,e),e}async function XQ(t){let e=t.project,r=e.configuration,o={accept:[],reject:[]};for(let I of svt)o.reject.push(I);for(let I of ivt)o.accept.push(I);o.reject.push(r.get("rcFilename"));let a=I=>{if(I===null||!I.startsWith(`${t.cwd}/`))return;let v=V.relative(t.cwd,I),x=V.resolve(Bt.root,v);o.reject.push(x)};a(V.resolve(e.cwd,dr.lockfile)),a(r.get("cacheFolder")),a(r.get("globalFolder")),a(r.get("installStatePath")),a(r.get("virtualFolder")),a(r.get("yarnPath")),await r.triggerHook(I=>I.populateYarnPaths,e,I=>{a(I)});for(let I of e.workspaces){let v=V.relative(t.cwd,I.cwd);v!==""&&!v.match(/^(\.\.)?\//)&&o.reject.push(`/${v}`)}let n={accept:[],reject:[]},u=t.manifest.publishConfig?.main??t.manifest.main,A=t.manifest.publishConfig?.module??t.manifest.module,p=t.manifest.publishConfig?.browser??t.manifest.browser,h=t.manifest.publishConfig?.bin??t.manifest.bin;u!=null&&n.accept.push(V.resolve(Bt.root,u)),A!=null&&n.accept.push(V.resolve(Bt.root,A)),typeof p=="string"&&n.accept.push(V.resolve(Bt.root,p));for(let I of h.values())n.accept.push(V.resolve(Bt.root,I));if(p instanceof Map)for(let[I,v]of p.entries())n.accept.push(V.resolve(Bt.root,I)),typeof v=="string"&&n.accept.push(V.resolve(Bt.root,v));let E=t.manifest.files!==null;if(E){n.reject.push("/*");for(let I of t.manifest.files)sBe(n.accept,I,{cwd:Bt.root})}return await ovt(t.cwd,{hasExplicitFileList:E,globalList:o,ignoreList:n})}async function ovt(t,{hasExplicitFileList:e,globalList:r,ignoreList:o}){let a=[],n=new _u(t),u=[[Bt.root,[o]]];for(;u.length>0;){let[A,p]=u.pop(),h=await n.lstatPromise(A);if(!eBe(A,{globalList:r,ignoreLists:h.isDirectory()?null:p}))if(h.isDirectory()){let E=await n.readdirPromise(A),I=!1,v=!1;if(!e||A!==Bt.root)for(let R of E)I=I||R===".gitignore",v=v||R===".npmignore";let x=v?await $2e(n,A,".npmignore"):I?await $2e(n,A,".gitignore"):null,C=x!==null?[x].concat(p):p;eBe(A,{globalList:r,ignoreLists:p})&&(C=[...p,{accept:[],reject:["**/*"]}]);for(let R of E)u.push([V.resolve(A,R),C])}else(h.isFile()||h.isSymbolicLink())&&a.push(V.relative(Bt.root,A))}return a.sort()}async function $2e(t,e,r){let o={accept:[],reject:[]},a=await t.readFilePromise(V.join(e,r),"utf8");for(let n of a.split(/\n/g))sBe(o.reject,n,{cwd:e});return o}function avt(t,{cwd:e}){let r=t[0]==="!";return r&&(t=t.slice(1)),t.match(/\.{0,1}\//)&&(t=V.resolve(e,t)),r&&(t=`!${t}`),t}function sBe(t,e,{cwd:r}){let o=e.trim();o===""||o[0]==="#"||t.push(avt(o,{cwd:r}))}function eBe(t,{globalList:e,ignoreLists:r}){let o=zQ(t,e.accept);if(o!==0)return o===2;let a=zQ(t,e.reject);if(a!==0)return a===1;if(r!==null)for(let n of r){let u=zQ(t,n.accept);if(u!==0)return u===2;let A=zQ(t,n.reject);if(A!==0)return A===1}return!1}function zQ(t,e){let r=e,o=[];for(let a=0;a<e.length;++a)e[a][0]!=="!"?r!==e&&r.push(e[a]):(r===e&&(r=e.slice(0,a)),o.push(e[a].slice(1)));return tBe(t,o)?2:tBe(t,r)?1:0}function tBe(t,e){let r=e,o=[];for(let a=0;a<e.length;++a)e[a].includes("/")?r!==e&&r.push(e[a]):(r===e&&(r=e.slice(0,a)),o.push(e[a]));return!!(aq.default.isMatch(t,r,{dot:!0,nocase:!0})||aq.default.isMatch(t,o,{dot:!0,basename:!0,nocase:!0}))}var U0=class extends ut{constructor(){super(...arguments);this.installIfNeeded=ge.Boolean("--install-if-needed",!1,{description:"Run a preliminary `yarn install` if the package contains build scripts"});this.dryRun=ge.Boolean("-n,--dry-run",!1,{description:"Print the file paths without actually generating the package archive"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.out=ge.String("-o,--out",{description:"Create the archive at the specified path"});this.filename=ge.String("--filename",{hidden:!0})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);await lq(a)&&(this.installIfNeeded?await o.install({cache:await Lr.find(r),report:new Qi}):await o.restoreInstallState());let n=this.out??this.filename,u=typeof n<"u"?V.resolve(this.context.cwd,lvt(n,{workspace:a})):V.resolve(a.cwd,"package.tgz");return(await Nt.start({configuration:r,stdout:this.context.stdout,json:this.json},async p=>{await cq(a,{report:p},async()=>{p.reportJson({base:ue.fromPortablePath(a.cwd)});let h=await XQ(a);for(let E of h)p.reportInfo(null,ue.fromPortablePath(E)),p.reportJson({location:ue.fromPortablePath(E)});if(!this.dryRun){let E=await uq(a,h),I=oe.createWriteStream(u);E.pipe(I),await new Promise(v=>{I.on("finish",v)})}}),this.dryRun||(p.reportInfo(0,`Package archive generated in ${de.pretty(r,u,de.Type.PATH)}`),p.reportJson({output:ue.fromPortablePath(u)}))})).exitCode()}};U0.paths=[["pack"]],U0.usage=nt.Usage({description:"generate a tarball from the active workspace",details:"\n This command will turn the active workspace into a compressed archive suitable for publishing. The archive will by default be stored at the root of the workspace (`package.tgz`).\n\n If the `-o,---out` is set the archive will be created at the specified path. The `%s` and `%v` variables can be used within the path and will be respectively replaced by the package name and version.\n ",examples:[["Create an archive from the active workspace","yarn pack"],["List the files that would be made part of the workspace's archive","yarn pack --dry-run"],["Name and output the archive in a dedicated folder","yarn pack --out /artifacts/%s-%v.tgz"]]});function lvt(t,{workspace:e}){let r=t.replace("%s",cvt(e)).replace("%v",uvt(e));return ue.toPortablePath(r)}function cvt(t){return t.manifest.name!==null?W.slugifyIdent(t.manifest.name):"package"}function uvt(t){return t.manifest.version!==null?t.manifest.version:"unknown"}var Avt=["dependencies","devDependencies","peerDependencies"],fvt="workspace:",pvt=(t,e)=>{e.publishConfig&&(e.publishConfig.type&&(e.type=e.publishConfig.type),e.publishConfig.main&&(e.main=e.publishConfig.main),e.publishConfig.browser&&(e.browser=e.publishConfig.browser),e.publishConfig.module&&(e.module=e.publishConfig.module),e.publishConfig.exports&&(e.exports=e.publishConfig.exports),e.publishConfig.imports&&(e.imports=e.publishConfig.imports),e.publishConfig.bin&&(e.bin=e.publishConfig.bin));let r=t.project;for(let o of Avt)for(let a of t.manifest.getForScope(o).values()){let n=r.tryWorkspaceByDescriptor(a),u=W.parseRange(a.range);if(u.protocol===fvt)if(n===null){if(r.tryWorkspaceByIdent(a)===null)throw new zt(21,`${W.prettyDescriptor(r.configuration,a)}: No local workspace found for this range`)}else{let A;W.areDescriptorsEqual(a,n.anchoredDescriptor)||u.selector==="*"?A=n.manifest.version??"0.0.0":u.selector==="~"||u.selector==="^"?A=`${u.selector}${n.manifest.version??"0.0.0"}`:A=u.selector;let p=o==="dependencies"?W.makeDescriptor(a,"unknown"):null,h=p!==null&&t.manifest.ensureDependencyMeta(p).optional?"optionalDependencies":o;e[h][W.stringifyIdent(a)]=A}}},hvt={hooks:{beforeWorkspacePacking:pvt},commands:[U0]},gvt=hvt;var hBe=ve("crypto"),gBe=$e(pBe());async function Rvt(t,e,{access:r,tag:o,registry:a,gitHead:n}){let u=t.manifest.name,A=t.manifest.version,p=W.stringifyIdent(u),h=(0,hBe.createHash)("sha1").update(e).digest("hex"),E=gBe.default.fromData(e).toString(),I=r??dBe(t,u),v=await mBe(t),x=await wA.genPackageManifest(t),C=`${p}-${A}.tgz`,R=new URL(`${oc(a)}/${p}/-/${C}`);return{_id:p,_attachments:{[C]:{content_type:"application/octet-stream",data:e.toString("base64"),length:e.length}},name:p,access:I,["dist-tags"]:{[o]:A},versions:{[A]:{...x,_id:`${p}@${A}`,name:p,version:A,gitHead:n,dist:{shasum:h,integrity:E,tarball:R.toString()}}},readme:v}}async function Tvt(t){try{let{stdout:e}=await Ur.execvp("git",["rev-parse","--revs-only","HEAD"],{cwd:t});return e.trim()===""?void 0:e.trim()}catch{return}}function dBe(t,e){let r=t.project.configuration;return t.manifest.publishConfig&&typeof t.manifest.publishConfig.access=="string"?t.manifest.publishConfig.access:r.get("npmPublishAccess")!==null?r.get("npmPublishAccess"):e.scope?"restricted":"public"}async function mBe(t){let e=ue.toPortablePath(`${t.cwd}/README.md`),r=t.manifest.name,a=`# ${W.stringifyIdent(r)} +`;try{a=await oe.readFilePromise(e,"utf8")}catch(n){if(n.code==="ENOENT")return a;throw n}return a}var gq={npmAlwaysAuth:{description:"URL of the selected npm registry (note: npm enterprise isn't supported)",type:"BOOLEAN",default:!1},npmAuthIdent:{description:"Authentication identity for the npm registry (_auth in npm and yarn v1)",type:"SECRET",default:null},npmAuthToken:{description:"Authentication token for the npm registry (_authToken in npm and yarn v1)",type:"SECRET",default:null}},yBe={npmAuditRegistry:{description:"Registry to query for audit reports",type:"STRING",default:null},npmPublishRegistry:{description:"Registry to push packages to",type:"STRING",default:null},npmRegistryServer:{description:"URL of the selected npm registry (note: npm enterprise isn't supported)",type:"STRING",default:"https://registry.yarnpkg.com"}},Nvt={configuration:{...gq,...yBe,npmScopes:{description:"Settings per package scope",type:"MAP",valueDefinition:{description:"",type:"SHAPE",properties:{...gq,...yBe}}},npmRegistries:{description:"Settings per registry",type:"MAP",normalizeKeys:oc,valueDefinition:{description:"",type:"SHAPE",properties:{...gq}}}},fetchers:[fv,dl],resolvers:[pv,hv,gv]},Lvt=Nvt;var Dq={};Vt(Dq,{NpmAuditCommand:()=>H0,NpmInfoCommand:()=>j0,NpmLoginCommand:()=>G0,NpmLogoutCommand:()=>q0,NpmPublishCommand:()=>Y0,NpmTagAddCommand:()=>K0,NpmTagListCommand:()=>W0,NpmTagRemoveCommand:()=>V0,NpmWhoamiCommand:()=>J0,default:()=>Gvt,npmAuditTypes:()=>Rv,npmAuditUtils:()=>ZQ});Ye();Ye();jt();var wq=$e(Zo());Za();var Rv={};Vt(Rv,{Environment:()=>Qv,Severity:()=>Fv});var Qv=(o=>(o.All="all",o.Production="production",o.Development="development",o))(Qv||{}),Fv=(n=>(n.Info="info",n.Low="low",n.Moderate="moderate",n.High="high",n.Critical="critical",n))(Fv||{});var ZQ={};Vt(ZQ,{allSeverities:()=>aw,getPackages:()=>Cq,getReportTree:()=>yq,getSeverityInclusions:()=>mq,getTopLevelDependencies:()=>Eq});Ye();var EBe=$e(zn());var aw=["info","low","moderate","high","critical"];function mq(t){if(typeof t>"u")return new Set(aw);let e=aw.indexOf(t),r=aw.slice(e);return new Set(r)}function yq(t){let e={},r={children:e};for(let[o,a]of _e.sortMap(Object.entries(t),n=>n[0]))for(let n of _e.sortMap(a,u=>`${u.id}`))e[`${o}/${n.id}`]={value:de.tuple(de.Type.IDENT,W.parseIdent(o)),children:{ID:typeof n.id<"u"&&{label:"ID",value:de.tuple(de.Type.ID,n.id)},Issue:{label:"Issue",value:de.tuple(de.Type.NO_HINT,n.title)},URL:typeof n.url<"u"&&{label:"URL",value:de.tuple(de.Type.URL,n.url)},Severity:{label:"Severity",value:de.tuple(de.Type.NO_HINT,n.severity)},["Vulnerable Versions"]:{label:"Vulnerable Versions",value:de.tuple(de.Type.RANGE,n.vulnerable_versions)},["Tree Versions"]:{label:"Tree Versions",children:[...n.versions].sort(EBe.default.compare).map(u=>({value:de.tuple(de.Type.REFERENCE,u)}))},Dependents:{label:"Dependents",children:_e.sortMap(n.dependents,u=>W.stringifyLocator(u)).map(u=>({value:de.tuple(de.Type.LOCATOR,u)}))}}};return r}function Eq(t,e,{all:r,environment:o}){let a=[],n=r?t.workspaces:[e],u=["all","production"].includes(o),A=["all","development"].includes(o);for(let p of n)for(let h of p.anchoredPackage.dependencies.values())(p.manifest.devDependencies.has(h.identHash)?!A:!u)||a.push({workspace:p,dependency:h});return a}function Cq(t,e,{recursive:r}){let o=new Map,a=new Set,n=[],u=(A,p)=>{let h=t.storedResolutions.get(p.descriptorHash);if(typeof h>"u")throw new Error("Assertion failed: The resolution should have been registered");if(!a.has(h))a.add(h);else return;let E=t.storedPackages.get(h);if(typeof E>"u")throw new Error("Assertion failed: The package should have been registered");if(W.ensureDevirtualizedLocator(E).reference.startsWith("npm:")&&E.version!==null){let v=W.stringifyIdent(E),x=_e.getMapWithDefault(o,v);_e.getArrayWithDefault(x,E.version).push(A)}if(r)for(let v of E.dependencies.values())n.push([E,v])};for(let{workspace:A,dependency:p}of e)n.push([A.anchoredLocator,p]);for(;n.length>0;){let[A,p]=n.shift();u(A,p)}return o}var H0=class extends ut{constructor(){super(...arguments);this.all=ge.Boolean("-A,--all",!1,{description:"Audit dependencies from all workspaces"});this.recursive=ge.Boolean("-R,--recursive",!1,{description:"Audit transitive dependencies as well"});this.environment=ge.String("--environment","all",{description:"Which environments to cover",validator:Ks(Qv)});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.noDeprecations=ge.Boolean("--no-deprecations",!1,{description:"Don't warn about deprecated packages"});this.severity=ge.String("--severity","info",{description:"Minimal severity requested for packages to be displayed",validator:Ks(Fv)});this.excludes=ge.Array("--exclude",[],{description:"Array of glob patterns of packages to exclude from audit"});this.ignores=ge.Array("--ignore",[],{description:"Array of glob patterns of advisory ID's to ignore in the audit report"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState();let n=Eq(o,a,{all:this.all,environment:this.environment}),u=Cq(o,n,{recursive:this.recursive}),A=Array.from(new Set([...r.get("npmAuditExcludePackages"),...this.excludes])),p=Object.create(null);for(let[L,U]of u)A.some(J=>wq.default.isMatch(L,J))||(p[L]=[...U.keys()]);let h=Zn.getAuditRegistry({configuration:r}),E,I=await AA.start({configuration:r,stdout:this.context.stdout},async()=>{let L=Zr.post("/-/npm/v1/security/advisories/bulk",p,{authType:Zr.AuthType.BEST_EFFORT,configuration:r,jsonResponse:!0,registry:h}),U=this.noDeprecations?[]:await Promise.all(Array.from(Object.entries(p),async([te,ae])=>{let fe=await Zr.getPackageMetadata(W.parseIdent(te),{project:o});return _e.mapAndFilter(ae,ce=>{let{deprecated:me}=fe.versions[ce];return me?[te,ce,me]:_e.mapAndFilter.skip})})),J=await L;for(let[te,ae,fe]of U.flat(1))Object.hasOwn(J,te)&&J[te].some(ce=>kr.satisfiesWithPrereleases(ae,ce.vulnerable_versions))||(J[te]??=[],J[te].push({id:`${te} (deprecation)`,title:fe.trim()||"This package has been deprecated.",severity:"moderate",vulnerable_versions:ae}));E=J});if(I.hasErrors())return I.exitCode();let v=mq(this.severity),x=Array.from(new Set([...r.get("npmAuditIgnoreAdvisories"),...this.ignores])),C=Object.create(null);for(let[L,U]of Object.entries(E)){let J=U.filter(te=>!wq.default.isMatch(`${te.id}`,x)&&v.has(te.severity));J.length>0&&(C[L]=J.map(te=>{let ae=u.get(L);if(typeof ae>"u")throw new Error("Assertion failed: Expected the registry to only return packages that were requested");let fe=[...ae.keys()].filter(me=>kr.satisfiesWithPrereleases(me,te.vulnerable_versions)),ce=new Map;for(let me of fe)for(let he of ae.get(me))ce.set(he.locatorHash,he);return{...te,versions:fe,dependents:[...ce.values()]}}))}let R=Object.keys(C).length>0;return R?($s.emitTree(yq(C),{configuration:r,json:this.json,stdout:this.context.stdout,separators:2}),1):(await Nt.start({configuration:r,includeFooter:!1,json:this.json,stdout:this.context.stdout},async L=>{L.reportInfo(1,"No audit suggestions")}),R?1:0)}};H0.paths=[["npm","audit"]],H0.usage=nt.Usage({description:"perform a vulnerability audit against the installed packages",details:` + This command checks for known security reports on the packages you use. The reports are by default extracted from the npm registry, and may or may not be relevant to your actual program (not all vulnerabilities affect all code paths). + + For consistency with our other commands the default is to only check the direct dependencies for the active workspace. To extend this search to all workspaces, use \`-A,--all\`. To extend this search to both direct and transitive dependencies, use \`-R,--recursive\`. + + Applying the \`--severity\` flag will limit the audit table to vulnerabilities of the corresponding severity and above. Valid values are ${aw.map(r=>`\`${r}\``).join(", ")}. + + If the \`--json\` flag is set, Yarn will print the output exactly as received from the registry. Regardless of this flag, the process will exit with a non-zero exit code if a report is found for the selected packages. + + If certain packages produce false positives for a particular environment, the \`--exclude\` flag can be used to exclude any number of packages from the audit. This can also be set in the configuration file with the \`npmAuditExcludePackages\` option. + + If particular advisories are needed to be ignored, the \`--ignore\` flag can be used with Advisory ID's to ignore any number of advisories in the audit report. This can also be set in the configuration file with the \`npmAuditIgnoreAdvisories\` option. + + To understand the dependency tree requiring vulnerable packages, check the raw report with the \`--json\` flag or use \`yarn why package\` to get more information as to who depends on them. + `,examples:[["Checks for known security issues with the installed packages. The output is a list of known issues.","yarn npm audit"],["Audit dependencies in all workspaces","yarn npm audit --all"],["Limit auditing to `dependencies` (excludes `devDependencies`)","yarn npm audit --environment production"],["Show audit report as valid JSON","yarn npm audit --json"],["Audit all direct and transitive dependencies","yarn npm audit --recursive"],["Output moderate (or more severe) vulnerabilities","yarn npm audit --severity moderate"],["Exclude certain packages","yarn npm audit --exclude package1 --exclude package2"],["Ignore specific advisories","yarn npm audit --ignore 1234567 --ignore 7654321"]]});Ye();Ye();St();jt();var Iq=$e(zn()),Bq=ve("util"),j0=class extends ut{constructor(){super(...arguments);this.fields=ge.String("-f,--fields",{description:"A comma-separated list of manifest fields that should be displayed"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.packages=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o}=await Pt.find(r,this.context.cwd),a=typeof this.fields<"u"?new Set(["name",...this.fields.split(/\s*,\s*/)]):null,n=[],u=!1,A=await Nt.start({configuration:r,includeFooter:!1,json:this.json,stdout:this.context.stdout},async p=>{for(let h of this.packages){let E;if(h==="."){let ae=o.topLevelWorkspace;if(!ae.manifest.name)throw new it(`Missing ${de.pretty(r,"name",de.Type.CODE)} field in ${ue.fromPortablePath(V.join(ae.cwd,dr.manifest))}`);E=W.makeDescriptor(ae.manifest.name,"unknown")}else E=W.parseDescriptor(h);let I=Zr.getIdentUrl(E),v=vq(await Zr.get(I,{configuration:r,ident:E,jsonResponse:!0,customErrorMessage:Zr.customPackageError})),x=Object.keys(v.versions).sort(Iq.default.compareLoose),R=v["dist-tags"].latest||x[x.length-1],L=kr.validRange(E.range);if(L){let ae=Iq.default.maxSatisfying(x,L);ae!==null?R=ae:(p.reportWarning(0,`Unmet range ${W.prettyRange(r,E.range)}; falling back to the latest version`),u=!0)}else Object.hasOwn(v["dist-tags"],E.range)?R=v["dist-tags"][E.range]:E.range!=="unknown"&&(p.reportWarning(0,`Unknown tag ${W.prettyRange(r,E.range)}; falling back to the latest version`),u=!0);let U=v.versions[R],J={...v,...U,version:R,versions:x},te;if(a!==null){te={};for(let ae of a){let fe=J[ae];if(typeof fe<"u")te[ae]=fe;else{p.reportWarning(1,`The ${de.pretty(r,ae,de.Type.CODE)} field doesn't exist inside ${W.prettyIdent(r,E)}'s information`),u=!0;continue}}}else this.json||(delete J.dist,delete J.readme,delete J.users),te=J;p.reportJson(te),this.json||n.push(te)}});Bq.inspect.styles.name="cyan";for(let p of n)(p!==n[0]||u)&&this.context.stdout.write(` +`),this.context.stdout.write(`${(0,Bq.inspect)(p,{depth:1/0,colors:!0,compact:!1})} +`);return A.exitCode()}};j0.paths=[["npm","info"]],j0.usage=nt.Usage({category:"Npm-related commands",description:"show information about a package",details:"\n This command fetches information about a package from the npm registry and prints it in a tree format.\n\n The package does not have to be installed locally, but needs to have been published (in particular, local changes will be ignored even for workspaces).\n\n Append `@<range>` to the package argument to provide information specific to the latest version that satisfies the range or to the corresponding tagged version. If the range is invalid or if there is no version satisfying the range, the command will print a warning and fall back to the latest version.\n\n If the `-f,--fields` option is set, it's a comma-separated list of fields which will be used to only display part of the package information.\n\n By default, this command won't return the `dist`, `readme`, and `users` fields, since they are often very long. To explicitly request those fields, explicitly list them with the `--fields` flag or request the output in JSON mode.\n ",examples:[["Show all available information about react (except the `dist`, `readme`, and `users` fields)","yarn npm info react"],["Show all available information about react as valid JSON (including the `dist`, `readme`, and `users` fields)","yarn npm info react --json"],["Show all available information about react@16.12.0","yarn npm info react@16.12.0"],["Show all available information about react@next","yarn npm info react@next"],["Show the description of react","yarn npm info react --fields description"],["Show all available versions of react","yarn npm info react --fields versions"],["Show the readme of react","yarn npm info react --fields readme"],["Show a few fields of react","yarn npm info react --fields homepage,repository"]]});function vq(t){if(Array.isArray(t)){let e=[];for(let r of t)r=vq(r),r&&e.push(r);return e}else if(typeof t=="object"&&t!==null){let e={};for(let r of Object.keys(t)){if(r.startsWith("_"))continue;let o=vq(t[r]);o&&(e[r]=o)}return e}else return t||null}Ye();Ye();jt();var CBe=$e(f2()),G0=class extends ut{constructor(){super(...arguments);this.scope=ge.String("-s,--scope",{description:"Login to the registry configured for a given scope"});this.publish=ge.Boolean("--publish",!1,{description:"Login to the publish registry"});this.alwaysAuth=ge.Boolean("--always-auth",{description:"Set the npmAlwaysAuth configuration"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=await $Q({configuration:r,cwd:this.context.cwd,publish:this.publish,scope:this.scope});return(await Nt.start({configuration:r,stdout:this.context.stdout,includeFooter:!1},async n=>{let u=await Uvt({configuration:r,registry:o,report:n,stdin:this.context.stdin,stdout:this.context.stdout}),A=await Ovt(o,u,r);return await Mvt(o,A,{alwaysAuth:this.alwaysAuth,scope:this.scope}),n.reportInfo(0,"Successfully logged in")})).exitCode()}};G0.paths=[["npm","login"]],G0.usage=nt.Usage({category:"Npm-related commands",description:"store new login info to access the npm registry",details:"\n This command will ask you for your username, password, and 2FA One-Time-Password (when it applies). It will then modify your local configuration (in your home folder, never in the project itself) to reference the new tokens thus generated.\n\n Adding the `-s,--scope` flag will cause the authentication to be done against whatever registry is configured for the associated scope (see also `npmScopes`).\n\n Adding the `--publish` flag will cause the authentication to be done against the registry used when publishing the package (see also `publishConfig.registry` and `npmPublishRegistry`).\n ",examples:[["Login to the default registry","yarn npm login"],["Login to the registry linked to the @my-scope registry","yarn npm login --scope my-scope"],["Login to the publish registry for the current package","yarn npm login --publish"]]});async function $Q({scope:t,publish:e,configuration:r,cwd:o}){return t&&e?Zn.getScopeRegistry(t,{configuration:r,type:Zn.RegistryType.PUBLISH_REGISTRY}):t?Zn.getScopeRegistry(t,{configuration:r}):e?Zn.getPublishRegistry((await fC(r,o)).manifest,{configuration:r}):Zn.getDefaultRegistry({configuration:r})}async function Ovt(t,e,r){let o=`/-/user/org.couchdb.user:${encodeURIComponent(e.name)}`,a={_id:`org.couchdb.user:${e.name}`,name:e.name,password:e.password,type:"user",roles:[],date:new Date().toISOString()},n={attemptedAs:e.name,configuration:r,registry:t,jsonResponse:!0,authType:Zr.AuthType.NO_AUTH};try{return(await Zr.put(o,a,n)).token}catch(E){if(!(E.originalError?.name==="HTTPError"&&E.originalError?.response.statusCode===409))throw E}let u={...n,authType:Zr.AuthType.NO_AUTH,headers:{authorization:`Basic ${Buffer.from(`${e.name}:${e.password}`).toString("base64")}`}},A=await Zr.get(o,u);for(let[E,I]of Object.entries(A))(!a[E]||E==="roles")&&(a[E]=I);let p=`${o}/-rev/${a._rev}`;return(await Zr.put(p,a,u)).token}async function Mvt(t,e,{alwaysAuth:r,scope:o}){let a=u=>A=>{let p=_e.isIndexableObject(A)?A:{},h=p[u],E=_e.isIndexableObject(h)?h:{};return{...p,[u]:{...E,...r!==void 0?{npmAlwaysAuth:r}:{},npmAuthToken:e}}},n=o?{npmScopes:a(o)}:{npmRegistries:a(t)};return await Ke.updateHomeConfiguration(n)}async function Uvt({configuration:t,registry:e,report:r,stdin:o,stdout:a}){r.reportInfo(0,`Logging in to ${de.pretty(t,e,de.Type.URL)}`);let n=!1;if(e.match(/^https:\/\/npm\.pkg\.github\.com(\/|$)/)&&(r.reportInfo(0,"You seem to be using the GitHub Package Registry. Tokens must be generated with the 'repo', 'write:packages', and 'read:packages' permissions."),n=!0),r.reportSeparator(),t.env.YARN_IS_TEST_ENV)return{name:t.env.YARN_INJECT_NPM_USER||"",password:t.env.YARN_INJECT_NPM_PASSWORD||""};let u=await(0,CBe.prompt)([{type:"input",name:"name",message:"Username:",required:!0,onCancel:()=>process.exit(130),stdin:o,stdout:a},{type:"password",name:"password",message:n?"Token:":"Password:",required:!0,onCancel:()=>process.exit(130),stdin:o,stdout:a}]);return r.reportSeparator(),u}Ye();Ye();jt();var lw=new Set(["npmAuthIdent","npmAuthToken"]),q0=class extends ut{constructor(){super(...arguments);this.scope=ge.String("-s,--scope",{description:"Logout of the registry configured for a given scope"});this.publish=ge.Boolean("--publish",!1,{description:"Logout of the publish registry"});this.all=ge.Boolean("-A,--all",!1,{description:"Logout of all registries"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o=async()=>{let n=await $Q({configuration:r,cwd:this.context.cwd,publish:this.publish,scope:this.scope}),u=await Ke.find(this.context.cwd,this.context.plugins),A=W.makeIdent(this.scope??null,"pkg");return!Zn.getAuthConfiguration(n,{configuration:u,ident:A}).get("npmAuthToken")};return(await Nt.start({configuration:r,stdout:this.context.stdout},async n=>{if(this.all&&(await Hvt(),n.reportInfo(0,"Successfully logged out from everything")),this.scope){await wBe("npmScopes",this.scope),await o()?n.reportInfo(0,`Successfully logged out from ${this.scope}`):n.reportWarning(0,"Scope authentication settings removed, but some other ones settings still apply to it");return}let u=await $Q({configuration:r,cwd:this.context.cwd,publish:this.publish});await wBe("npmRegistries",u),await o()?n.reportInfo(0,`Successfully logged out from ${u}`):n.reportWarning(0,"Registry authentication settings removed, but some other ones settings still apply to it")})).exitCode()}};q0.paths=[["npm","logout"]],q0.usage=nt.Usage({category:"Npm-related commands",description:"logout of the npm registry",details:"\n This command will log you out by modifying your local configuration (in your home folder, never in the project itself) to delete all credentials linked to a registry.\n\n Adding the `-s,--scope` flag will cause the deletion to be done against whatever registry is configured for the associated scope (see also `npmScopes`).\n\n Adding the `--publish` flag will cause the deletion to be done against the registry used when publishing the package (see also `publishConfig.registry` and `npmPublishRegistry`).\n\n Adding the `-A,--all` flag will cause the deletion to be done against all registries and scopes.\n ",examples:[["Logout of the default registry","yarn npm logout"],["Logout of the @my-scope scope","yarn npm logout --scope my-scope"],["Logout of the publish registry for the current package","yarn npm logout --publish"],["Logout of all registries","yarn npm logout --all"]]});function _vt(t,e){let r=t[e];if(!_e.isIndexableObject(r))return!1;let o=new Set(Object.keys(r));if([...lw].every(n=>!o.has(n)))return!1;for(let n of lw)o.delete(n);if(o.size===0)return t[e]=void 0,!0;let a={...r};for(let n of lw)delete a[n];return t[e]=a,!0}async function Hvt(){let t=e=>{let r=!1,o=_e.isIndexableObject(e)?{...e}:{};o.npmAuthToken&&(delete o.npmAuthToken,r=!0);for(let a of Object.keys(o))_vt(o,a)&&(r=!0);if(Object.keys(o).length!==0)return r?o:e};return await Ke.updateHomeConfiguration({npmRegistries:t,npmScopes:t})}async function wBe(t,e){return await Ke.updateHomeConfiguration({[t]:r=>{let o=_e.isIndexableObject(r)?r:{};if(!Object.hasOwn(o,e))return r;let a=o[e],n=_e.isIndexableObject(a)?a:{},u=new Set(Object.keys(n));if([...lw].every(p=>!u.has(p)))return r;for(let p of lw)u.delete(p);if(u.size===0)return Object.keys(o).length===1?void 0:{...o,[e]:void 0};let A={};for(let p of lw)A[p]=void 0;return{...o,[e]:{...n,...A}}}})}Ye();jt();var Y0=class extends ut{constructor(){super(...arguments);this.access=ge.String("--access",{description:"The access for the published package (public or restricted)"});this.tag=ge.String("--tag","latest",{description:"The tag on the registry that the package should be attached to"});this.tolerateRepublish=ge.Boolean("--tolerate-republish",!1,{description:"Warn and exit when republishing an already existing version of a package"});this.otp=ge.String("--otp",{description:"The OTP token to use with the command"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);if(a.manifest.private)throw new it("Private workspaces cannot be published");if(a.manifest.name===null||a.manifest.version===null)throw new it("Workspaces must have valid names and versions to be published on an external registry");await o.restoreInstallState();let n=a.manifest.name,u=a.manifest.version,A=Zn.getPublishRegistry(a.manifest,{configuration:r});return(await Nt.start({configuration:r,stdout:this.context.stdout},async h=>{if(this.tolerateRepublish)try{let E=await Zr.get(Zr.getIdentUrl(n),{configuration:r,registry:A,ident:n,jsonResponse:!0});if(!Object.hasOwn(E,"versions"))throw new zt(15,'Registry returned invalid data for - missing "versions" field');if(Object.hasOwn(E.versions,u)){h.reportWarning(0,`Registry already knows about version ${u}; skipping.`);return}}catch(E){if(E.originalError?.response?.statusCode!==404)throw E}await un.maybeExecuteWorkspaceLifecycleScript(a,"prepublish",{report:h}),await wA.prepareForPack(a,{report:h},async()=>{let E=await wA.genPackList(a);for(let R of E)h.reportInfo(null,R);let I=await wA.genPackStream(a,E),v=await _e.bufferStream(I),x=await ow.getGitHead(a.cwd),C=await ow.makePublishBody(a,v,{access:this.access,tag:this.tag,registry:A,gitHead:x});await Zr.put(Zr.getIdentUrl(n),C,{configuration:r,registry:A,ident:n,otp:this.otp,jsonResponse:!0})}),h.reportInfo(0,"Package archive published")})).exitCode()}};Y0.paths=[["npm","publish"]],Y0.usage=nt.Usage({category:"Npm-related commands",description:"publish the active workspace to the npm registry",details:'\n This command will pack the active workspace into a fresh archive and upload it to the npm registry.\n\n The package will by default be attached to the `latest` tag on the registry, but this behavior can be overriden by using the `--tag` option.\n\n Note that for legacy reasons scoped packages are by default published with an access set to `restricted` (aka "private packages"). This requires you to register for a paid npm plan. In case you simply wish to publish a public scoped package to the registry (for free), just add the `--access public` flag. This behavior can be enabled by default through the `npmPublishAccess` settings.\n ',examples:[["Publish the active workspace","yarn npm publish"]]});Ye();jt();var IBe=$e(zn());Ye();St();jt();var W0=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.package=ge.String({required:!1})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd),n;if(typeof this.package<"u")n=W.parseIdent(this.package);else{if(!a)throw new rr(o.cwd,this.context.cwd);if(!a.manifest.name)throw new it(`Missing 'name' field in ${ue.fromPortablePath(V.join(a.cwd,dr.manifest))}`);n=a.manifest.name}let u=await Tv(n,r),p={children:_e.sortMap(Object.entries(u),([h])=>h).map(([h,E])=>({value:de.tuple(de.Type.RESOLUTION,{descriptor:W.makeDescriptor(n,h),locator:W.makeLocator(n,E)})}))};return $s.emitTree(p,{configuration:r,json:this.json,stdout:this.context.stdout})}};W0.paths=[["npm","tag","list"]],W0.usage=nt.Usage({category:"Npm-related commands",description:"list all dist-tags of a package",details:` + This command will list all tags of a package from the npm registry. + + If the package is not specified, Yarn will default to the current workspace. + `,examples:[["List all tags of package `my-pkg`","yarn npm tag list my-pkg"]]});async function Tv(t,e){let r=`/-/package${Zr.getIdentUrl(t)}/dist-tags`;return Zr.get(r,{configuration:e,ident:t,jsonResponse:!0,customErrorMessage:Zr.customPackageError})}var K0=class extends ut{constructor(){super(...arguments);this.package=ge.String();this.tag=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);let n=W.parseDescriptor(this.package,!0),u=n.range;if(!IBe.default.valid(u))throw new it(`The range ${de.pretty(r,n.range,de.Type.RANGE)} must be a valid semver version`);let A=Zn.getPublishRegistry(a.manifest,{configuration:r}),p=de.pretty(r,n,de.Type.IDENT),h=de.pretty(r,u,de.Type.RANGE),E=de.pretty(r,this.tag,de.Type.CODE);return(await Nt.start({configuration:r,stdout:this.context.stdout},async v=>{let x=await Tv(n,r);Object.hasOwn(x,this.tag)&&x[this.tag]===u&&v.reportWarning(0,`Tag ${E} is already set to version ${h}`);let C=`/-/package${Zr.getIdentUrl(n)}/dist-tags/${encodeURIComponent(this.tag)}`;await Zr.put(C,u,{configuration:r,registry:A,ident:n,jsonRequest:!0,jsonResponse:!0}),v.reportInfo(0,`Tag ${E} added to version ${h} of package ${p}`)})).exitCode()}};K0.paths=[["npm","tag","add"]],K0.usage=nt.Usage({category:"Npm-related commands",description:"add a tag for a specific version of a package",details:` + This command will add a tag to the npm registry for a specific version of a package. If the tag already exists, it will be overwritten. + `,examples:[["Add a `beta` tag for version `2.3.4-beta.4` of package `my-pkg`","yarn npm tag add my-pkg@2.3.4-beta.4 beta"]]});Ye();jt();var V0=class extends ut{constructor(){super(...arguments);this.package=ge.String();this.tag=ge.String()}async execute(){if(this.tag==="latest")throw new it("The 'latest' tag cannot be removed.");let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);let n=W.parseIdent(this.package),u=Zn.getPublishRegistry(a.manifest,{configuration:r}),A=de.pretty(r,this.tag,de.Type.CODE),p=de.pretty(r,n,de.Type.IDENT),h=await Tv(n,r);if(!Object.hasOwn(h,this.tag))throw new it(`${A} is not a tag of package ${p}`);return(await Nt.start({configuration:r,stdout:this.context.stdout},async I=>{let v=`/-/package${Zr.getIdentUrl(n)}/dist-tags/${encodeURIComponent(this.tag)}`;await Zr.del(v,{configuration:r,registry:u,ident:n,jsonResponse:!0}),I.reportInfo(0,`Tag ${A} removed from package ${p}`)})).exitCode()}};V0.paths=[["npm","tag","remove"]],V0.usage=nt.Usage({category:"Npm-related commands",description:"remove a tag from a package",details:` + This command will remove a tag from a package from the npm registry. + `,examples:[["Remove the `beta` tag from package `my-pkg`","yarn npm tag remove my-pkg beta"]]});Ye();Ye();jt();var J0=class extends ut{constructor(){super(...arguments);this.scope=ge.String("-s,--scope",{description:"Print username for the registry configured for a given scope"});this.publish=ge.Boolean("--publish",!1,{description:"Print username for the publish registry"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),o;return this.scope&&this.publish?o=Zn.getScopeRegistry(this.scope,{configuration:r,type:Zn.RegistryType.PUBLISH_REGISTRY}):this.scope?o=Zn.getScopeRegistry(this.scope,{configuration:r}):this.publish?o=Zn.getPublishRegistry((await fC(r,this.context.cwd)).manifest,{configuration:r}):o=Zn.getDefaultRegistry({configuration:r}),(await Nt.start({configuration:r,stdout:this.context.stdout},async n=>{let u;try{u=await Zr.get("/-/whoami",{configuration:r,registry:o,authType:Zr.AuthType.ALWAYS_AUTH,jsonResponse:!0,ident:this.scope?W.makeIdent(this.scope,""):void 0})}catch(A){if(A.response?.statusCode===401||A.response?.statusCode===403){n.reportError(41,"Authentication failed - your credentials may have expired");return}else throw A}n.reportInfo(0,u.username)})).exitCode()}};J0.paths=[["npm","whoami"]],J0.usage=nt.Usage({category:"Npm-related commands",description:"display the name of the authenticated user",details:"\n Print the username associated with the current authentication settings to the standard output.\n\n When using `-s,--scope`, the username printed will be the one that matches the authentication settings of the registry associated with the given scope (those settings can be overriden using the `npmRegistries` map, and the registry associated with the scope is configured via the `npmScopes` map).\n\n When using `--publish`, the registry we'll select will by default be the one used when publishing packages (`publishConfig.registry` or `npmPublishRegistry` if available, otherwise we'll fallback to the regular `npmRegistryServer`).\n ",examples:[["Print username for the default registry","yarn npm whoami"],["Print username for the registry on a given scope","yarn npm whoami --scope company"]]});var jvt={configuration:{npmPublishAccess:{description:"Default access of the published packages",type:"STRING",default:null},npmAuditExcludePackages:{description:"Array of glob patterns of packages to exclude from npm audit",type:"STRING",default:[],isArray:!0},npmAuditIgnoreAdvisories:{description:"Array of glob patterns of advisory IDs to exclude from npm audit",type:"STRING",default:[],isArray:!0}},commands:[H0,j0,G0,q0,Y0,K0,W0,V0,J0]},Gvt=jvt;var Fq={};Vt(Fq,{PatchCommand:()=>Z0,PatchCommitCommand:()=>X0,PatchFetcher:()=>Uv,PatchResolver:()=>_v,default:()=>aDt,patchUtils:()=>Sm});Ye();Ye();St();nA();var Sm={};Vt(Sm,{applyPatchFile:()=>tF,diffFolders:()=>kq,ensureUnpatchedDescriptor:()=>Sq,ensureUnpatchedLocator:()=>nF,extractPackageToDisk:()=>xq,extractPatchFlags:()=>xBe,isParentRequired:()=>bq,isPatchDescriptor:()=>rF,isPatchLocator:()=>z0,loadPatchFiles:()=>Mv,makeDescriptor:()=>iF,makeLocator:()=>Pq,makePatchHash:()=>Qq,parseDescriptor:()=>Lv,parseLocator:()=>Ov,parsePatchFile:()=>Nv,unpatchDescriptor:()=>iDt,unpatchLocator:()=>sDt});Ye();St();Ye();St();var qvt=/^@@ -(\d+)(,(\d+))? \+(\d+)(,(\d+))? @@.*/;function cw(t){return V.relative(Bt.root,V.resolve(Bt.root,ue.toPortablePath(t)))}function Yvt(t){let e=t.trim().match(qvt);if(!e)throw new Error(`Bad header line: '${t}'`);return{original:{start:Math.max(Number(e[1]),1),length:Number(e[3]||1)},patched:{start:Math.max(Number(e[4]),1),length:Number(e[6]||1)}}}var Wvt=420,Kvt=493;var BBe=()=>({semverExclusivity:null,diffLineFromPath:null,diffLineToPath:null,oldMode:null,newMode:null,deletedFileMode:null,newFileMode:null,renameFrom:null,renameTo:null,beforeHash:null,afterHash:null,fromPath:null,toPath:null,hunks:null}),Vvt=t=>({header:Yvt(t),parts:[]}),Jvt={["@"]:"header",["-"]:"deletion",["+"]:"insertion",[" "]:"context",["\\"]:"pragma",undefined:"context"};function zvt(t){let e=[],r=BBe(),o="parsing header",a=null,n=null;function u(){a&&(n&&(a.parts.push(n),n=null),r.hunks.push(a),a=null)}function A(){u(),e.push(r),r=BBe()}for(let p=0;p<t.length;p++){let h=t[p];if(o==="parsing header")if(h.startsWith("@@"))o="parsing hunks",r.hunks=[],p-=1;else if(h.startsWith("diff --git ")){r&&r.diffLineFromPath&&A();let E=h.match(/^diff --git a\/(.*?) b\/(.*?)\s*$/);if(!E)throw new Error(`Bad diff line: ${h}`);r.diffLineFromPath=E[1],r.diffLineToPath=E[2]}else if(h.startsWith("old mode "))r.oldMode=h.slice(9).trim();else if(h.startsWith("new mode "))r.newMode=h.slice(9).trim();else if(h.startsWith("deleted file mode "))r.deletedFileMode=h.slice(18).trim();else if(h.startsWith("new file mode "))r.newFileMode=h.slice(14).trim();else if(h.startsWith("rename from "))r.renameFrom=h.slice(12).trim();else if(h.startsWith("rename to "))r.renameTo=h.slice(10).trim();else if(h.startsWith("index ")){let E=h.match(/(\w+)\.\.(\w+)/);if(!E)continue;r.beforeHash=E[1],r.afterHash=E[2]}else h.startsWith("semver exclusivity ")?r.semverExclusivity=h.slice(19).trim():h.startsWith("--- ")?r.fromPath=h.slice(6).trim():h.startsWith("+++ ")&&(r.toPath=h.slice(6).trim());else{let E=Jvt[h[0]]||null;switch(E){case"header":u(),a=Vvt(h);break;case null:o="parsing header",A(),p-=1;break;case"pragma":{if(!h.startsWith("\\ No newline at end of file"))throw new Error(`Unrecognized pragma in patch file: ${h}`);if(!n)throw new Error("Bad parser state: No newline at EOF pragma encountered without context");n.noNewlineAtEndOfFile=!0}break;case"context":case"deletion":case"insertion":{if(!a)throw new Error("Bad parser state: Hunk lines encountered before hunk header");n&&n.type!==E&&(a.parts.push(n),n=null),n||(n={type:E,lines:[],noNewlineAtEndOfFile:!1}),n.lines.push(h.slice(1))}break;default:_e.assertNever(E);break}}}A();for(let{hunks:p}of e)if(p)for(let h of p)Zvt(h);return e}function Xvt(t){let e=[];for(let r of t){let{semverExclusivity:o,diffLineFromPath:a,diffLineToPath:n,oldMode:u,newMode:A,deletedFileMode:p,newFileMode:h,renameFrom:E,renameTo:I,beforeHash:v,afterHash:x,fromPath:C,toPath:R,hunks:L}=r,U=E?"rename":p?"file deletion":h?"file creation":L&&L.length>0?"patch":"mode change",J=null;switch(U){case"rename":{if(!E||!I)throw new Error("Bad parser state: rename from & to not given");e.push({type:"rename",semverExclusivity:o,fromPath:cw(E),toPath:cw(I)}),J=I}break;case"file deletion":{let te=a||C;if(!te)throw new Error("Bad parse state: no path given for file deletion");e.push({type:"file deletion",semverExclusivity:o,hunk:L&&L[0]||null,path:cw(te),mode:eF(p),hash:v})}break;case"file creation":{let te=n||R;if(!te)throw new Error("Bad parse state: no path given for file creation");e.push({type:"file creation",semverExclusivity:o,hunk:L&&L[0]||null,path:cw(te),mode:eF(h),hash:x})}break;case"patch":case"mode change":J=R||n;break;default:_e.assertNever(U);break}J&&u&&A&&u!==A&&e.push({type:"mode change",semverExclusivity:o,path:cw(J),oldMode:eF(u),newMode:eF(A)}),J&&L&&L.length&&e.push({type:"patch",semverExclusivity:o,path:cw(J),hunks:L,beforeHash:v,afterHash:x})}if(e.length===0)throw new Error("Unable to parse patch file: No changes found. Make sure the patch is a valid UTF8 encoded string");return e}function eF(t){let e=parseInt(t,8)&511;if(e!==Wvt&&e!==Kvt)throw new Error(`Unexpected file mode string: ${t}`);return e}function Nv(t){let e=t.split(/\n/g);return e[e.length-1]===""&&e.pop(),Xvt(zvt(e))}function Zvt(t){let e=0,r=0;for(let{type:o,lines:a}of t.parts)switch(o){case"context":r+=a.length,e+=a.length;break;case"deletion":e+=a.length;break;case"insertion":r+=a.length;break;default:_e.assertNever(o);break}if(e!==t.header.original.length||r!==t.header.patched.length){let o=a=>a<0?a:`+${a}`;throw new Error(`hunk header integrity check failed (expected @@ ${o(t.header.original.length)} ${o(t.header.patched.length)} @@, got @@ ${o(e)} ${o(r)} @@)`)}}Ye();St();var uw=class extends Error{constructor(r,o){super(`Cannot apply hunk #${r+1}`);this.hunk=o}};async function Aw(t,e,r){let o=await t.lstatPromise(e),a=await r();typeof a<"u"&&(e=a),await t.lutimesPromise(e,o.atime,o.mtime)}async function tF(t,{baseFs:e=new Tn,dryRun:r=!1,version:o=null}={}){for(let a of t)if(!(a.semverExclusivity!==null&&o!==null&&!kr.satisfiesWithPrereleases(o,a.semverExclusivity)))switch(a.type){case"file deletion":if(r){if(!e.existsSync(a.path))throw new Error(`Trying to delete a file that doesn't exist: ${a.path}`)}else await Aw(e,V.dirname(a.path),async()=>{await e.unlinkPromise(a.path)});break;case"rename":if(r){if(!e.existsSync(a.fromPath))throw new Error(`Trying to move a file that doesn't exist: ${a.fromPath}`)}else await Aw(e,V.dirname(a.fromPath),async()=>{await Aw(e,V.dirname(a.toPath),async()=>{await Aw(e,a.fromPath,async()=>(await e.movePromise(a.fromPath,a.toPath),a.toPath))})});break;case"file creation":if(r){if(e.existsSync(a.path))throw new Error(`Trying to create a file that already exists: ${a.path}`)}else{let n=a.hunk?a.hunk.parts[0].lines.join(` +`)+(a.hunk.parts[0].noNewlineAtEndOfFile?"":` +`):"";await e.mkdirpPromise(V.dirname(a.path),{chmod:493,utimes:[vi.SAFE_TIME,vi.SAFE_TIME]}),await e.writeFilePromise(a.path,n,{mode:a.mode}),await e.utimesPromise(a.path,vi.SAFE_TIME,vi.SAFE_TIME)}break;case"patch":await Aw(e,a.path,async()=>{await tDt(a,{baseFs:e,dryRun:r})});break;case"mode change":{let u=(await e.statPromise(a.path)).mode;if(vBe(a.newMode)!==vBe(u))continue;await Aw(e,a.path,async()=>{await e.chmodPromise(a.path,a.newMode)})}break;default:_e.assertNever(a);break}}function vBe(t){return(t&64)>0}function DBe(t){return t.replace(/\s+$/,"")}function eDt(t,e){return DBe(t)===DBe(e)}async function tDt({hunks:t,path:e},{baseFs:r,dryRun:o=!1}){let a=await r.statSync(e).mode,u=(await r.readFileSync(e,"utf8")).split(/\n/),A=[],p=0,h=0;for(let I of t){let v=Math.max(h,I.header.patched.start+p),x=Math.max(0,v-h),C=Math.max(0,u.length-v-I.header.original.length),R=Math.max(x,C),L=0,U=0,J=null;for(;L<=R;){if(L<=x&&(U=v-L,J=SBe(I,u,U),J!==null)){L=-L;break}if(L<=C&&(U=v+L,J=SBe(I,u,U),J!==null))break;L+=1}if(J===null)throw new uw(t.indexOf(I),I);A.push(J),p+=L,h=U+I.header.original.length}if(o)return;let E=0;for(let I of A)for(let v of I)switch(v.type){case"splice":{let x=v.index+E;u.splice(x,v.numToDelete,...v.linesToInsert),E+=v.linesToInsert.length-v.numToDelete}break;case"pop":u.pop();break;case"push":u.push(v.line);break;default:_e.assertNever(v);break}await r.writeFilePromise(e,u.join(` +`),{mode:a})}function SBe(t,e,r){let o=[];for(let a of t.parts)switch(a.type){case"context":case"deletion":{for(let n of a.lines){let u=e[r];if(u==null||!eDt(u,n))return null;r+=1}a.type==="deletion"&&(o.push({type:"splice",index:r-a.lines.length,numToDelete:a.lines.length,linesToInsert:[]}),a.noNewlineAtEndOfFile&&o.push({type:"push",line:""}))}break;case"insertion":o.push({type:"splice",index:r,numToDelete:0,linesToInsert:a.lines}),a.noNewlineAtEndOfFile&&o.push({type:"pop"});break;default:_e.assertNever(a.type);break}return o}var nDt=/^builtin<([^>]+)>$/;function fw(t,e){let{protocol:r,source:o,selector:a,params:n}=W.parseRange(t);if(r!=="patch:")throw new Error("Invalid patch range");if(o===null)throw new Error("Patch locators must explicitly define their source");let u=a?a.split(/&/).map(E=>ue.toPortablePath(E)):[],A=n&&typeof n.locator=="string"?W.parseLocator(n.locator):null,p=n&&typeof n.version=="string"?n.version:null,h=e(o);return{parentLocator:A,sourceItem:h,patchPaths:u,sourceVersion:p}}function rF(t){return t.range.startsWith("patch:")}function z0(t){return t.reference.startsWith("patch:")}function Lv(t){let{sourceItem:e,...r}=fw(t.range,W.parseDescriptor);return{...r,sourceDescriptor:e}}function Ov(t){let{sourceItem:e,...r}=fw(t.reference,W.parseLocator);return{...r,sourceLocator:e}}function iDt(t){let{sourceItem:e}=fw(t.range,W.parseDescriptor);return e}function sDt(t){let{sourceItem:e}=fw(t.reference,W.parseLocator);return e}function Sq(t){if(!rF(t))return t;let{sourceItem:e}=fw(t.range,W.parseDescriptor);return e}function nF(t){if(!z0(t))return t;let{sourceItem:e}=fw(t.reference,W.parseLocator);return e}function PBe({parentLocator:t,sourceItem:e,patchPaths:r,sourceVersion:o,patchHash:a},n){let u=t!==null?{locator:W.stringifyLocator(t)}:{},A=typeof o<"u"?{version:o}:{},p=typeof a<"u"?{hash:a}:{};return W.makeRange({protocol:"patch:",source:n(e),selector:r.join("&"),params:{...A,...p,...u}})}function iF(t,{parentLocator:e,sourceDescriptor:r,patchPaths:o}){return W.makeDescriptor(t,PBe({parentLocator:e,sourceItem:r,patchPaths:o},W.stringifyDescriptor))}function Pq(t,{parentLocator:e,sourcePackage:r,patchPaths:o,patchHash:a}){return W.makeLocator(t,PBe({parentLocator:e,sourceItem:r,sourceVersion:r.version,patchPaths:o,patchHash:a},W.stringifyLocator))}function bBe({onAbsolute:t,onRelative:e,onProject:r,onBuiltin:o},a){let n=a.lastIndexOf("!");n!==-1&&(a=a.slice(n+1));let u=a.match(nDt);return u!==null?o(u[1]):a.startsWith("~/")?r(a.slice(2)):V.isAbsolute(a)?t(a):e(a)}function xBe(t){let e=t.lastIndexOf("!");return{optional:(e!==-1?new Set(t.slice(0,e).split(/!/)):new Set).has("optional")}}function bq(t){return bBe({onAbsolute:()=>!1,onRelative:()=>!0,onProject:()=>!1,onBuiltin:()=>!1},t)}async function Mv(t,e,r){let o=t!==null?await r.fetcher.fetch(t,r):null,a=o&&o.localPath?{packageFs:new gn(Bt.root),prefixPath:V.relative(Bt.root,o.localPath)}:o;o&&o!==a&&o.releaseFs&&o.releaseFs();let n=await _e.releaseAfterUseAsync(async()=>await Promise.all(e.map(async u=>{let A=xBe(u),p=await bBe({onAbsolute:async h=>await oe.readFilePromise(h,"utf8"),onRelative:async h=>{if(a===null)throw new Error("Assertion failed: The parent locator should have been fetched");return await a.packageFs.readFilePromise(V.join(a.prefixPath,h),"utf8")},onProject:async h=>await oe.readFilePromise(V.join(r.project.cwd,h),"utf8"),onBuiltin:async h=>await r.project.configuration.firstHook(E=>E.getBuiltinPatch,r.project,h)},u);return{...A,source:p}})));for(let u of n)typeof u.source=="string"&&(u.source=u.source.replace(/\r\n?/g,` +`));return n}async function xq(t,{cache:e,project:r}){let o=r.storedPackages.get(t.locatorHash);if(typeof o>"u")throw new Error("Assertion failed: Expected the package to be registered");let a=nF(t),n=r.storedChecksums,u=new Qi,A=await oe.mktempPromise(),p=V.join(A,"source"),h=V.join(A,"user"),E=V.join(A,".yarn-patch.json"),I=r.configuration.makeFetcher(),v=[];try{let x,C;if(t.locatorHash===a.locatorHash){let R=await I.fetch(t,{cache:e,project:r,fetcher:I,checksums:n,report:u});v.push(()=>R.releaseFs?.()),x=R,C=R}else x=await I.fetch(t,{cache:e,project:r,fetcher:I,checksums:n,report:u}),v.push(()=>x.releaseFs?.()),C=await I.fetch(t,{cache:e,project:r,fetcher:I,checksums:n,report:u}),v.push(()=>C.releaseFs?.());await Promise.all([oe.copyPromise(p,x.prefixPath,{baseFs:x.packageFs}),oe.copyPromise(h,C.prefixPath,{baseFs:C.packageFs}),oe.writeJsonPromise(E,{locator:W.stringifyLocator(t),version:o.version})])}finally{for(let x of v)x()}return oe.detachTemp(A),h}async function kq(t,e){let r=ue.fromPortablePath(t).replace(/\\/g,"/"),o=ue.fromPortablePath(e).replace(/\\/g,"/"),{stdout:a,stderr:n}=await Ur.execvp("git",["-c","core.safecrlf=false","diff","--src-prefix=a/","--dst-prefix=b/","--ignore-cr-at-eol","--full-index","--no-index","--no-renames","--text",r,o],{cwd:ue.toPortablePath(process.cwd()),env:{...process.env,GIT_CONFIG_NOSYSTEM:"1",HOME:"",XDG_CONFIG_HOME:"",USERPROFILE:""}});if(n.length>0)throw new Error(`Unable to diff directories. Make sure you have a recent version of 'git' available in PATH. +The following error was reported by 'git': +${n}`);let u=r.startsWith("/")?A=>A.slice(1):A=>A;return a.replace(new RegExp(`(a|b)(${_e.escapeRegExp(`/${u(r)}/`)})`,"g"),"$1/").replace(new RegExp(`(a|b)${_e.escapeRegExp(`/${u(o)}/`)}`,"g"),"$1/").replace(new RegExp(_e.escapeRegExp(`${r}/`),"g"),"").replace(new RegExp(_e.escapeRegExp(`${o}/`),"g"),"")}function Qq(t,e){let r=[];for(let{source:o}of t){if(o===null)continue;let a=Nv(o);for(let n of a){let{semverExclusivity:u,...A}=n;u!==null&&e!==null&&!kr.satisfiesWithPrereleases(e,u)||r.push(JSON.stringify(A))}}return wn.makeHash(`${3}`,...r).slice(0,6)}Ye();function kBe(t,{configuration:e,report:r}){for(let o of t.parts)for(let a of o.lines)switch(o.type){case"context":r.reportInfo(null,` ${de.pretty(e,a,"grey")}`);break;case"deletion":r.reportError(28,`- ${de.pretty(e,a,de.Type.REMOVED)}`);break;case"insertion":r.reportError(28,`+ ${de.pretty(e,a,de.Type.ADDED)}`);break;default:_e.assertNever(o.type)}}var Uv=class{supports(e,r){return!!z0(e)}getLocalPath(e,r){return null}async fetch(e,r){let o=r.checksums.get(e.locatorHash)||null,[a,n,u]=await r.cache.fetchPackageFromCache(e,o,{onHit:()=>r.report.reportCacheHit(e),onMiss:()=>r.report.reportCacheMiss(e,`${W.prettyLocator(r.project.configuration,e)} can't be found in the cache and will be fetched from the disk`),loader:()=>this.patchPackage(e,r),...r.cacheOptions});return{packageFs:a,releaseFs:n,prefixPath:W.getIdentVendorPath(e),localPath:this.getLocalPath(e,r),checksum:u}}async patchPackage(e,r){let{parentLocator:o,sourceLocator:a,sourceVersion:n,patchPaths:u}=Ov(e),A=await Mv(o,u,r),p=await oe.mktempPromise(),h=V.join(p,"current.zip"),E=await r.fetcher.fetch(a,r),I=W.getIdentVendorPath(e),v=new zi(h,{create:!0,level:r.project.configuration.get("compressionLevel")});await _e.releaseAfterUseAsync(async()=>{await v.copyPromise(I,E.prefixPath,{baseFs:E.packageFs,stableSort:!0})},E.releaseFs),v.saveAndClose();for(let{source:x,optional:C}of A){if(x===null)continue;let R=new zi(h,{level:r.project.configuration.get("compressionLevel")}),L=new gn(V.resolve(Bt.root,I),{baseFs:R});try{await tF(Nv(x),{baseFs:L,version:n})}catch(U){if(!(U instanceof uw))throw U;let J=r.project.configuration.get("enableInlineHunks"),te=!J&&!C?" (set enableInlineHunks for details)":"",ae=`${W.prettyLocator(r.project.configuration,e)}: ${U.message}${te}`,fe=ce=>{!J||kBe(U.hunk,{configuration:r.project.configuration,report:ce})};if(R.discardAndClose(),C){r.report.reportWarningOnce(66,ae,{reportExtra:fe});continue}else throw new zt(66,ae,fe)}R.saveAndClose()}return new zi(h,{level:r.project.configuration.get("compressionLevel")})}};Ye();var _v=class{supportsDescriptor(e,r){return!!rF(e)}supportsLocator(e,r){return!!z0(e)}shouldPersistResolution(e,r){return!1}bindDescriptor(e,r,o){let{patchPaths:a}=Lv(e);return a.every(n=>!bq(n))?e:W.bindDescriptor(e,{locator:W.stringifyLocator(r)})}getResolutionDependencies(e,r){let{sourceDescriptor:o}=Lv(e);return{sourceDescriptor:r.project.configuration.normalizeDependency(o)}}async getCandidates(e,r,o){if(!o.fetchOptions)throw new Error("Assertion failed: This resolver cannot be used unless a fetcher is configured");let{parentLocator:a,patchPaths:n}=Lv(e),u=await Mv(a,n,o.fetchOptions),A=r.sourceDescriptor;if(typeof A>"u")throw new Error("Assertion failed: The dependency should have been resolved");let p=Qq(u,A.version);return[Pq(e,{parentLocator:a,sourcePackage:A,patchPaths:n,patchHash:p})]}async getSatisfying(e,r,o,a){let[n]=await this.getCandidates(e,r,a);return{locators:o.filter(u=>u.locatorHash===n.locatorHash),sorted:!1}}async resolve(e,r){let{sourceLocator:o}=Ov(e);return{...await r.resolver.resolve(o,r),...e}}};Ye();St();jt();var X0=class extends ut{constructor(){super(...arguments);this.save=ge.Boolean("-s,--save",!1,{description:"Add the patch to your resolution entries"});this.patchFolder=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState();let n=V.resolve(this.context.cwd,ue.toPortablePath(this.patchFolder)),u=V.join(n,"../source"),A=V.join(n,"../.yarn-patch.json");if(!oe.existsSync(u))throw new it("The argument folder didn't get created by 'yarn patch'");let p=await kq(u,n),h=await oe.readJsonPromise(A),E=W.parseLocator(h.locator,!0);if(!o.storedPackages.has(E.locatorHash))throw new it("No package found in the project for the given locator");if(!this.save){this.context.stdout.write(p);return}let I=r.get("patchFolder"),v=V.join(I,`${W.slugifyLocator(E)}.patch`);await oe.mkdirPromise(I,{recursive:!0}),await oe.writeFilePromise(v,p);let x=[],C=new Map;for(let R of o.storedPackages.values()){if(W.isVirtualLocator(R))continue;let L=R.dependencies.get(E.identHash);if(!L)continue;let U=W.ensureDevirtualizedDescriptor(L),J=Sq(U),te=o.storedResolutions.get(J.descriptorHash);if(!te)throw new Error("Assertion failed: Expected the resolution to have been registered");if(!o.storedPackages.get(te))throw new Error("Assertion failed: Expected the package to have been registered");let fe=o.tryWorkspaceByLocator(R);if(fe)x.push(fe);else{let ce=o.originalPackages.get(R.locatorHash);if(!ce)throw new Error("Assertion failed: Expected the original package to have been registered");let me=ce.dependencies.get(L.identHash);if(!me)throw new Error("Assertion failed: Expected the original dependency to have been registered");C.set(me.descriptorHash,me)}}for(let R of x)for(let L of Ot.hardDependencies){let U=R.manifest[L].get(E.identHash);if(!U)continue;let J=iF(U,{parentLocator:null,sourceDescriptor:W.convertLocatorToDescriptor(E),patchPaths:[V.join(dr.home,V.relative(o.cwd,v))]});R.manifest[L].set(U.identHash,J)}for(let R of C.values()){let L=iF(R,{parentLocator:null,sourceDescriptor:W.convertLocatorToDescriptor(E),patchPaths:[V.join(dr.home,V.relative(o.cwd,v))]});o.topLevelWorkspace.manifest.resolutions.push({pattern:{descriptor:{fullName:W.stringifyIdent(L),description:R.range}},reference:L.range})}await o.persist()}};X0.paths=[["patch-commit"]],X0.usage=nt.Usage({description:"generate a patch out of a directory",details:"\n By default, this will print a patchfile on stdout based on the diff between the folder passed in and the original version of the package. Such file is suitable for consumption with the `patch:` protocol.\n\n With the `-s,--save` option set, the patchfile won't be printed on stdout anymore and will instead be stored within a local file (by default kept within `.yarn/patches`, but configurable via the `patchFolder` setting). A `resolutions` entry will also be added to your top-level manifest, referencing the patched package via the `patch:` protocol.\n\n Note that only folders generated by `yarn patch` are accepted as valid input for `yarn patch-commit`.\n "});Ye();St();jt();var Z0=class extends ut{constructor(){super(...arguments);this.update=ge.Boolean("-u,--update",!1,{description:"Reapply local patches that already apply to this packages"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.package=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd),n=await Lr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState();let u=W.parseLocator(this.package);if(u.reference==="unknown"){let A=_e.mapAndFilter([...o.storedPackages.values()],p=>p.identHash!==u.identHash?_e.mapAndFilter.skip:W.isVirtualLocator(p)?_e.mapAndFilter.skip:z0(p)!==this.update?_e.mapAndFilter.skip:p);if(A.length===0)throw new it("No package found in the project for the given locator");if(A.length>1)throw new it(`Multiple candidate packages found; explicitly choose one of them (use \`yarn why <package>\` to get more information as to who depends on them): +${A.map(p=>` +- ${W.prettyLocator(r,p)}`).join("")}`);u=A[0]}if(!o.storedPackages.has(u.locatorHash))throw new it("No package found in the project for the given locator");await Nt.start({configuration:r,json:this.json,stdout:this.context.stdout},async A=>{let p=nF(u),h=await xq(u,{cache:n,project:o});A.reportJson({locator:W.stringifyLocator(p),path:ue.fromPortablePath(h)});let E=this.update?" along with its current modifications":"";A.reportInfo(0,`Package ${W.prettyLocator(r,p)} got extracted with success${E}!`),A.reportInfo(0,`You can now edit the following folder: ${de.pretty(r,ue.fromPortablePath(h),"magenta")}`),A.reportInfo(0,`Once you are done run ${de.pretty(r,`yarn patch-commit -s ${process.platform==="win32"?'"':""}${ue.fromPortablePath(h)}${process.platform==="win32"?'"':""}`,"cyan")} and Yarn will store a patchfile based on your changes.`)})}};Z0.paths=[["patch"]],Z0.usage=nt.Usage({description:"prepare a package for patching",details:"\n This command will cause a package to be extracted in a temporary directory intended to be editable at will.\n\n Once you're done with your changes, run `yarn patch-commit -s path` (with `path` being the temporary directory you received) to generate a patchfile and register it into your top-level manifest via the `patch:` protocol. Run `yarn patch-commit -h` for more details.\n\n Calling the command when you already have a patch won't import it by default (in other words, the default behavior is to reset existing patches). However, adding the `-u,--update` flag will import any current patch.\n "});var oDt={configuration:{enableInlineHunks:{description:"If true, the installs will print unmatched patch hunks",type:"BOOLEAN",default:!1},patchFolder:{description:"Folder where the patch files must be written",type:"ABSOLUTE_PATH",default:"./.yarn/patches"}},commands:[X0,Z0],fetchers:[Uv],resolvers:[_v]},aDt=oDt;var Nq={};Vt(Nq,{PnpmLinker:()=>Hv,default:()=>fDt});Ye();St();jt();var Hv=class{getCustomDataKey(){return JSON.stringify({name:"PnpmLinker",version:3})}supportsPackage(e,r){return this.isEnabled(r)}async findPackageLocation(e,r){if(!this.isEnabled(r))throw new Error("Assertion failed: Expected the pnpm linker to be enabled");let o=this.getCustomDataKey(),a=r.project.linkersCustomData.get(o);if(!a)throw new it(`The project in ${de.pretty(r.project.configuration,`${r.project.cwd}/package.json`,de.Type.PATH)} doesn't seem to have been installed - running an install there might help`);let n=a.pathsByLocator.get(e.locatorHash);if(typeof n>"u")throw new it(`Couldn't find ${W.prettyLocator(r.project.configuration,e)} in the currently installed pnpm map - running an install might help`);return n.packageLocation}async findPackageLocator(e,r){if(!this.isEnabled(r))return null;let o=this.getCustomDataKey(),a=r.project.linkersCustomData.get(o);if(!a)throw new it(`The project in ${de.pretty(r.project.configuration,`${r.project.cwd}/package.json`,de.Type.PATH)} doesn't seem to have been installed - running an install there might help`);let n=e.match(/(^.*\/node_modules\/(@[^/]*\/)?[^/]+)(\/.*$)/);if(n){let p=a.locatorByPath.get(n[1]);if(p)return p}let u=e,A=e;do{A=u,u=V.dirname(A);let p=a.locatorByPath.get(A);if(p)return p}while(u!==A);return null}makeInstaller(e){return new Rq(e)}isEnabled(e){return e.project.configuration.get("nodeLinker")==="pnpm"}},Rq=class{constructor(e){this.opts=e;this.asyncActions=new _e.AsyncActions(10);this.customData={pathsByLocator:new Map,locatorByPath:new Map};this.indexFolderPromise=SD(oe,{indexPath:V.join(e.project.configuration.get("globalFolder"),"index")})}attachCustomData(e){}async installPackage(e,r,o){switch(e.linkType){case"SOFT":return this.installPackageSoft(e,r,o);case"HARD":return this.installPackageHard(e,r,o)}throw new Error("Assertion failed: Unsupported package link type")}async installPackageSoft(e,r,o){let a=V.resolve(r.packageFs.getRealPath(),r.prefixPath),n=this.opts.project.tryWorkspaceByLocator(e)?V.join(a,dr.nodeModules):null;return this.customData.pathsByLocator.set(e.locatorHash,{packageLocation:a,dependenciesLocation:n}),{packageLocation:a,buildRequest:null}}async installPackageHard(e,r,o){let a=lDt(e,{project:this.opts.project}),n=a.packageLocation;this.customData.locatorByPath.set(n,W.stringifyLocator(e)),this.customData.pathsByLocator.set(e.locatorHash,a),o.holdFetchResult(this.asyncActions.set(e.locatorHash,async()=>{await oe.mkdirPromise(n,{recursive:!0}),await oe.copyPromise(n,r.prefixPath,{baseFs:r.packageFs,overwrite:!1,linkStrategy:{type:"HardlinkFromIndex",indexPath:await this.indexFolderPromise,autoRepair:!0}})}));let A=W.isVirtualLocator(e)?W.devirtualizeLocator(e):e,p={manifest:await Ot.tryFind(r.prefixPath,{baseFs:r.packageFs})??new Ot,misc:{hasBindingGyp:yA.hasBindingGyp(r)}},h=this.opts.project.getDependencyMeta(A,e.version),E=yA.extractBuildRequest(e,p,h,{configuration:this.opts.project.configuration});return{packageLocation:n,buildRequest:E}}async attachInternalDependencies(e,r){if(this.opts.project.configuration.get("nodeLinker")!=="pnpm"||!QBe(e,{project:this.opts.project}))return;let o=this.customData.pathsByLocator.get(e.locatorHash);if(typeof o>"u")throw new Error(`Assertion failed: Expected the package to have been registered (${W.stringifyLocator(e)})`);let{dependenciesLocation:a}=o;!a||this.asyncActions.reduce(e.locatorHash,async n=>{await oe.mkdirPromise(a,{recursive:!0});let u=await cDt(a),A=new Map(u),p=[n],h=(I,v)=>{let x=v;QBe(v,{project:this.opts.project})||(this.opts.report.reportWarningOnce(0,"The pnpm linker doesn't support providing different versions to workspaces' peer dependencies"),x=W.devirtualizeLocator(v));let C=this.customData.pathsByLocator.get(x.locatorHash);if(typeof C>"u")throw new Error(`Assertion failed: Expected the package to have been registered (${W.stringifyLocator(v)})`);let R=W.stringifyIdent(I),L=V.join(a,R),U=V.relative(V.dirname(L),C.packageLocation),J=A.get(R);A.delete(R),p.push(Promise.resolve().then(async()=>{if(J){if(J.isSymbolicLink()&&await oe.readlinkPromise(L)===U)return;await oe.removePromise(L)}await oe.mkdirpPromise(V.dirname(L)),process.platform=="win32"&&this.opts.project.configuration.get("winLinkType")==="junctions"?await oe.symlinkPromise(C.packageLocation,L,"junction"):await oe.symlinkPromise(U,L)}))},E=!1;for(let[I,v]of r)I.identHash===e.identHash&&(E=!0),h(I,v);!E&&!this.opts.project.tryWorkspaceByLocator(e)&&h(W.convertLocatorToDescriptor(e),e),p.push(uDt(a,A)),await Promise.all(p)})}async attachExternalDependents(e,r){throw new Error("External dependencies haven't been implemented for the pnpm linker")}async finalizeInstall(){let e=RBe(this.opts.project);if(this.opts.project.configuration.get("nodeLinker")!=="pnpm")await oe.removePromise(e);else{let r;try{r=new Set(await oe.readdirPromise(e))}catch{r=new Set}for(let{dependenciesLocation:o}of this.customData.pathsByLocator.values()){if(!o)continue;let a=V.contains(e,o);if(a===null)continue;let[n]=a.split(V.sep);r.delete(n)}await Promise.all([...r].map(async o=>{await oe.removePromise(V.join(e,o))}))}return await this.asyncActions.wait(),await Tq(e),this.opts.project.configuration.get("nodeLinker")!=="node-modules"&&await Tq(FBe(this.opts.project)),{customData:this.customData}}};function FBe(t){return V.join(t.cwd,dr.nodeModules)}function RBe(t){return V.join(FBe(t),".store")}function lDt(t,{project:e}){let r=W.slugifyLocator(t),o=RBe(e),a=V.join(o,r,"package"),n=V.join(o,r,dr.nodeModules);return{packageLocation:a,dependenciesLocation:n}}function QBe(t,{project:e}){return!W.isVirtualLocator(t)||!e.tryWorkspaceByLocator(t)}async function cDt(t){let e=new Map,r=[];try{r=await oe.readdirPromise(t,{withFileTypes:!0})}catch(o){if(o.code!=="ENOENT")throw o}try{for(let o of r)if(!o.name.startsWith("."))if(o.name.startsWith("@")){let a=await oe.readdirPromise(V.join(t,o.name),{withFileTypes:!0});if(a.length===0)e.set(o.name,o);else for(let n of a)e.set(`${o.name}/${n.name}`,n)}else e.set(o.name,o)}catch(o){if(o.code!=="ENOENT")throw o}return e}async function uDt(t,e){let r=[],o=new Set;for(let a of e.keys()){r.push(oe.removePromise(V.join(t,a)));let n=W.tryParseIdent(a)?.scope;n&&o.add(`@${n}`)}return Promise.all(r).then(()=>Promise.all([...o].map(a=>Tq(V.join(t,a)))))}async function Tq(t){try{await oe.rmdirPromise(t)}catch(e){if(e.code!=="ENOENT"&&e.code!=="ENOTEMPTY")throw e}}var ADt={linkers:[Hv]},fDt=ADt;var jq={};Vt(jq,{StageCommand:()=>$0,default:()=>BDt,stageUtils:()=>oF});Ye();St();jt();Ye();St();var oF={};Vt(oF,{ActionType:()=>Lq,checkConsensus:()=>sF,expandDirectory:()=>Uq,findConsensus:()=>_q,findVcsRoot:()=>Oq,genCommitMessage:()=>Hq,getCommitPrefix:()=>TBe,isYarnFile:()=>Mq});St();var Lq=(n=>(n[n.CREATE=0]="CREATE",n[n.DELETE=1]="DELETE",n[n.ADD=2]="ADD",n[n.REMOVE=3]="REMOVE",n[n.MODIFY=4]="MODIFY",n))(Lq||{});async function Oq(t,{marker:e}){do if(!oe.existsSync(V.join(t,e)))t=V.dirname(t);else return t;while(t!=="/");return null}function Mq(t,{roots:e,names:r}){if(r.has(V.basename(t)))return!0;do if(!e.has(t))t=V.dirname(t);else return!0;while(t!=="/");return!1}function Uq(t){let e=[],r=[t];for(;r.length>0;){let o=r.pop(),a=oe.readdirSync(o);for(let n of a){let u=V.resolve(o,n);oe.lstatSync(u).isDirectory()?r.push(u):e.push(u)}}return e}function sF(t,e){let r=0,o=0;for(let a of t)a!=="wip"&&(e.test(a)?r+=1:o+=1);return r>=o}function _q(t){let e=sF(t,/^(\w\(\w+\):\s*)?\w+s/),r=sF(t,/^(\w\(\w+\):\s*)?[A-Z]/),o=sF(t,/^\w\(\w+\):/);return{useThirdPerson:e,useUpperCase:r,useComponent:o}}function TBe(t){return t.useComponent?"chore(yarn): ":""}var pDt=new Map([[0,"create"],[1,"delete"],[2,"add"],[3,"remove"],[4,"update"]]);function Hq(t,e){let r=TBe(t),o=[],a=e.slice().sort((n,u)=>n[0]-u[0]);for(;a.length>0;){let[n,u]=a.shift(),A=pDt.get(n);t.useUpperCase&&o.length===0&&(A=`${A[0].toUpperCase()}${A.slice(1)}`),t.useThirdPerson&&(A+="s");let p=[u];for(;a.length>0&&a[0][0]===n;){let[,E]=a.shift();p.push(E)}p.sort();let h=p.shift();p.length===1?h+=" (and one other)":p.length>1&&(h+=` (and ${p.length} others)`),o.push(`${A} ${h}`)}return`${r}${o.join(", ")}`}var hDt="Commit generated via `yarn stage`",gDt=11;async function NBe(t){let{code:e,stdout:r}=await Ur.execvp("git",["log","-1","--pretty=format:%H"],{cwd:t});return e===0?r.trim():null}async function dDt(t,e){let r=[],o=e.filter(h=>V.basename(h.path)==="package.json");for(let{action:h,path:E}of o){let I=V.relative(t,E);if(h===4){let v=await NBe(t),{stdout:x}=await Ur.execvp("git",["show",`${v}:${I}`],{cwd:t,strict:!0}),C=await Ot.fromText(x),R=await Ot.fromFile(E),L=new Map([...R.dependencies,...R.devDependencies]),U=new Map([...C.dependencies,...C.devDependencies]);for(let[J,te]of U){let ae=W.stringifyIdent(te),fe=L.get(J);fe?fe.range!==te.range&&r.push([4,`${ae} to ${fe.range}`]):r.push([3,ae])}for(let[J,te]of L)U.has(J)||r.push([2,W.stringifyIdent(te)])}else if(h===0){let v=await Ot.fromFile(E);v.name?r.push([0,W.stringifyIdent(v.name)]):r.push([0,"a package"])}else if(h===1){let v=await NBe(t),{stdout:x}=await Ur.execvp("git",["show",`${v}:${I}`],{cwd:t,strict:!0}),C=await Ot.fromText(x);C.name?r.push([1,W.stringifyIdent(C.name)]):r.push([1,"a package"])}else throw new Error("Assertion failed: Unsupported action type")}let{code:a,stdout:n}=await Ur.execvp("git",["log",`-${gDt}`,"--pretty=format:%s"],{cwd:t}),u=a===0?n.split(/\n/g).filter(h=>h!==""):[],A=_q(u);return Hq(A,r)}var mDt={[0]:[" A ","?? "],[4]:[" M "],[1]:[" D "]},yDt={[0]:["A "],[4]:["M "],[1]:["D "]},LBe={async findRoot(t){return await Oq(t,{marker:".git"})},async filterChanges(t,e,r,o){let{stdout:a}=await Ur.execvp("git",["status","-s"],{cwd:t,strict:!0}),n=a.toString().split(/\n/g),u=o?.staged?yDt:mDt;return[].concat(...n.map(p=>{if(p==="")return[];let h=p.slice(0,3),E=V.resolve(t,p.slice(3));if(!o?.staged&&h==="?? "&&p.endsWith("/"))return Uq(E).map(I=>({action:0,path:I}));{let v=[0,4,1].find(x=>u[x].includes(h));return v!==void 0?[{action:v,path:E}]:[]}})).filter(p=>Mq(p.path,{roots:e,names:r}))},async genCommitMessage(t,e){return await dDt(t,e)},async makeStage(t,e){let r=e.map(o=>ue.fromPortablePath(o.path));await Ur.execvp("git",["add","--",...r],{cwd:t,strict:!0})},async makeCommit(t,e,r){let o=e.map(a=>ue.fromPortablePath(a.path));await Ur.execvp("git",["add","-N","--",...o],{cwd:t,strict:!0}),await Ur.execvp("git",["commit","-m",`${r} + +${hDt} +`,"--",...o],{cwd:t,strict:!0})},async makeReset(t,e){let r=e.map(o=>ue.fromPortablePath(o.path));await Ur.execvp("git",["reset","HEAD","--",...r],{cwd:t,strict:!0})}};var EDt=[LBe],$0=class extends ut{constructor(){super(...arguments);this.commit=ge.Boolean("-c,--commit",!1,{description:"Commit the staged files"});this.reset=ge.Boolean("-r,--reset",!1,{description:"Remove all files from the staging area"});this.dryRun=ge.Boolean("-n,--dry-run",!1,{description:"Print the commit message and the list of modified files without staging / committing"});this.update=ge.Boolean("-u,--update",!1,{hidden:!0})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o}=await Pt.find(r,this.context.cwd),{driver:a,root:n}=await CDt(o.cwd),u=[r.get("cacheFolder"),r.get("globalFolder"),r.get("virtualFolder"),r.get("yarnPath")];await r.triggerHook(I=>I.populateYarnPaths,o,I=>{u.push(I)});let A=new Set;for(let I of u)for(let v of wDt(n,I))A.add(v);let p=new Set([r.get("rcFilename"),dr.lockfile,dr.manifest]),h=await a.filterChanges(n,A,p),E=await a.genCommitMessage(n,h);if(this.dryRun)if(this.commit)this.context.stdout.write(`${E} +`);else for(let I of h)this.context.stdout.write(`${ue.fromPortablePath(I.path)} +`);else if(this.reset){let I=await a.filterChanges(n,A,p,{staged:!0});I.length===0?this.context.stdout.write("No staged changes found!"):await a.makeReset(n,I)}else h.length===0?this.context.stdout.write("No changes found!"):this.commit?await a.makeCommit(n,h,E):(await a.makeStage(n,h),this.context.stdout.write(E))}};$0.paths=[["stage"]],$0.usage=nt.Usage({description:"add all yarn files to your vcs",details:"\n This command will add to your staging area the files belonging to Yarn (typically any modified `package.json` and `.yarnrc.yml` files, but also linker-generated files, cache data, etc). It will take your ignore list into account, so the cache files won't be added if the cache is ignored in a `.gitignore` file (assuming you use Git).\n\n Running `--reset` will instead remove them from the staging area (the changes will still be there, but won't be committed until you stage them back).\n\n Since the staging area is a non-existent concept in Mercurial, Yarn will always create a new commit when running this command on Mercurial repositories. You can get this behavior when using Git by using the `--commit` flag which will directly create a commit.\n ",examples:[["Adds all modified project files to the staging area","yarn stage"],["Creates a new commit containing all modified project files","yarn stage --commit"]]});async function CDt(t){let e=null,r=null;for(let o of EDt)if((r=await o.findRoot(t))!==null){e=o;break}if(e===null||r===null)throw new it("No stage driver has been found for your current project");return{driver:e,root:r}}function wDt(t,e){let r=[];if(e===null)return r;for(;;){(e===t||e.startsWith(`${t}/`))&&r.push(e);let o;try{o=oe.statSync(e)}catch{break}if(o.isSymbolicLink())e=V.resolve(V.dirname(e),oe.readlinkSync(e));else break}return r}var IDt={commands:[$0]},BDt=IDt;var Gq={};Vt(Gq,{default:()=>QDt});Ye();Ye();St();var UBe=$e(zn());Ye();var OBe=$e(zH()),vDt="e8e1bd300d860104bb8c58453ffa1eb4",DDt="OFCNCOG2CU",MBe=async(t,e)=>{let r=W.stringifyIdent(t),a=SDt(e).initIndex("npm-search");try{return(await a.getObject(r,{attributesToRetrieve:["types"]})).types?.ts==="definitely-typed"}catch{return!1}},SDt=t=>(0,OBe.default)(DDt,vDt,{requester:{async send(r){try{let o=await nn.request(r.url,r.data||null,{configuration:t,headers:r.headers});return{content:o.body,isTimedOut:!1,status:o.statusCode}}catch(o){return{content:o.response.body,isTimedOut:!1,status:o.response.statusCode}}}}});var _Be=t=>t.scope?`${t.scope}__${t.name}`:`${t.name}`,PDt=async(t,e,r,o)=>{if(r.scope==="types")return;let{project:a}=t,{configuration:n}=a;if(!(n.get("tsEnableAutoTypes")??oe.existsSync(V.join(a.cwd,"tsconfig.json"))))return;let A=n.makeResolver(),p={project:a,resolver:A,report:new Qi};if(!await MBe(r,n))return;let E=_Be(r),I=W.parseRange(r.range).selector;if(!kr.validRange(I)){let L=n.normalizeDependency(r),U=await A.getCandidates(L,{},p);I=W.parseRange(U[0].reference).selector}let v=UBe.default.coerce(I);if(v===null)return;let x=`${zc.Modifier.CARET}${v.major}`,C=W.makeDescriptor(W.makeIdent("types",E),x),R=_e.mapAndFind(a.workspaces,L=>{let U=L.manifest.dependencies.get(r.identHash)?.descriptorHash,J=L.manifest.devDependencies.get(r.identHash)?.descriptorHash;if(U!==r.descriptorHash&&J!==r.descriptorHash)return _e.mapAndFind.skip;let te=[];for(let ae of Ot.allDependencies){let fe=L.manifest[ae].get(C.identHash);typeof fe>"u"||te.push([ae,fe])}return te.length===0?_e.mapAndFind.skip:te});if(typeof R<"u")for(let[L,U]of R)t.manifest[L].set(U.identHash,U);else{try{let L=n.normalizeDependency(C);if((await A.getCandidates(L,{},p)).length===0)return}catch{return}t.manifest[zc.Target.DEVELOPMENT].set(C.identHash,C)}},bDt=async(t,e,r)=>{if(r.scope==="types")return;let{project:o}=t,{configuration:a}=o;if(!(a.get("tsEnableAutoTypes")??oe.existsSync(V.join(o.cwd,"tsconfig.json"))))return;let u=_Be(r),A=W.makeIdent("types",u);for(let p of Ot.allDependencies)typeof t.manifest[p].get(A.identHash)>"u"||t.manifest[p].delete(A.identHash)},xDt=(t,e)=>{e.publishConfig&&e.publishConfig.typings&&(e.typings=e.publishConfig.typings),e.publishConfig&&e.publishConfig.types&&(e.types=e.publishConfig.types)},kDt={configuration:{tsEnableAutoTypes:{description:"Whether Yarn should auto-install @types/ dependencies on 'yarn add'",type:"BOOLEAN",isNullable:!0,default:null}},hooks:{afterWorkspaceDependencyAddition:PDt,afterWorkspaceDependencyRemoval:bDt,beforeWorkspacePacking:xDt}},QDt=kDt;var Vq={};Vt(Vq,{VersionApplyCommand:()=>eg,VersionCheckCommand:()=>tg,VersionCommand:()=>rg,default:()=>zDt,versionUtils:()=>dw});Ye();Ye();jt();var dw={};Vt(dw,{Decision:()=>hw,applyPrerelease:()=>WBe,applyReleases:()=>Kq,applyStrategy:()=>lF,clearVersionFiles:()=>qq,getUndecidedDependentWorkspaces:()=>Gv,getUndecidedWorkspaces:()=>aF,openVersionFile:()=>gw,requireMoreDecisions:()=>KDt,resolveVersionFiles:()=>jv,suggestStrategy:()=>Wq,updateVersionFiles:()=>Yq,validateReleaseDecision:()=>pw});Ye();St();Nl();jt();var YBe=$e(qBe()),vA=$e(zn()),WDt=/^(>=|[~^]|)(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$/,hw=(u=>(u.UNDECIDED="undecided",u.DECLINE="decline",u.MAJOR="major",u.MINOR="minor",u.PATCH="patch",u.PRERELEASE="prerelease",u))(hw||{});function pw(t){let e=vA.default.valid(t);return e||_e.validateEnum((0,YBe.default)(hw,"UNDECIDED"),t)}async function jv(t,{prerelease:e=null}={}){let r=new Map,o=t.configuration.get("deferredVersionFolder");if(!oe.existsSync(o))return r;let a=await oe.readdirPromise(o);for(let n of a){if(!n.endsWith(".yml"))continue;let u=V.join(o,n),A=await oe.readFilePromise(u,"utf8"),p=Ki(A);for(let[h,E]of Object.entries(p.releases||{})){if(E==="decline")continue;let I=W.parseIdent(h),v=t.tryWorkspaceByIdent(I);if(v===null)throw new Error(`Assertion failed: Expected a release definition file to only reference existing workspaces (${V.basename(u)} references ${h})`);if(v.manifest.version===null)throw new Error(`Assertion failed: Expected the workspace to have a version (${W.prettyLocator(t.configuration,v.anchoredLocator)})`);let x=v.manifest.raw.stableVersion??v.manifest.version,C=r.get(v),R=lF(x,pw(E));if(R===null)throw new Error(`Assertion failed: Expected ${x} to support being bumped via strategy ${E}`);let L=typeof C<"u"?vA.default.gt(R,C)?R:C:R;r.set(v,L)}}return e&&(r=new Map([...r].map(([n,u])=>[n,WBe(u,{current:n.manifest.version,prerelease:e})]))),r}async function qq(t){let e=t.configuration.get("deferredVersionFolder");!oe.existsSync(e)||await oe.removePromise(e)}async function Yq(t,e){let r=new Set(e),o=t.configuration.get("deferredVersionFolder");if(!oe.existsSync(o))return;let a=await oe.readdirPromise(o);for(let n of a){if(!n.endsWith(".yml"))continue;let u=V.join(o,n),A=await oe.readFilePromise(u,"utf8"),p=Ki(A),h=p?.releases;if(!!h){for(let E of Object.keys(h)){let I=W.parseIdent(E),v=t.tryWorkspaceByIdent(I);(v===null||r.has(v))&&delete p.releases[E]}Object.keys(p.releases).length>0?await oe.changeFilePromise(u,Ba(new Ba.PreserveOrdering(p))):await oe.unlinkPromise(u)}}}async function gw(t,{allowEmpty:e=!1}={}){let r=t.configuration;if(r.projectCwd===null)throw new it("This command can only be run from within a Yarn project");let o=await ra.fetchRoot(r.projectCwd),a=o!==null?await ra.fetchBase(o,{baseRefs:r.get("changesetBaseRefs")}):null,n=o!==null?await ra.fetchChangedFiles(o,{base:a.hash,project:t}):[],u=r.get("deferredVersionFolder"),A=n.filter(x=>V.contains(u,x)!==null);if(A.length>1)throw new it(`Your current branch contains multiple versioning files; this isn't supported: +- ${A.map(x=>ue.fromPortablePath(x)).join(` +- `)}`);let p=new Set(_e.mapAndFilter(n,x=>{let C=t.tryWorkspaceByFilePath(x);return C===null?_e.mapAndFilter.skip:C}));if(A.length===0&&p.size===0&&!e)return null;let h=A.length===1?A[0]:V.join(u,`${wn.makeHash(Math.random().toString()).slice(0,8)}.yml`),E=oe.existsSync(h)?await oe.readFilePromise(h,"utf8"):"{}",I=Ki(E),v=new Map;for(let x of I.declined||[]){let C=W.parseIdent(x),R=t.getWorkspaceByIdent(C);v.set(R,"decline")}for(let[x,C]of Object.entries(I.releases||{})){let R=W.parseIdent(x),L=t.getWorkspaceByIdent(R);v.set(L,pw(C))}return{project:t,root:o,baseHash:a!==null?a.hash:null,baseTitle:a!==null?a.title:null,changedFiles:new Set(n),changedWorkspaces:p,releaseRoots:new Set([...p].filter(x=>x.manifest.version!==null)),releases:v,async saveAll(){let x={},C=[],R=[];for(let L of t.workspaces){if(L.manifest.version===null)continue;let U=W.stringifyIdent(L.anchoredLocator),J=v.get(L);J==="decline"?C.push(U):typeof J<"u"?x[U]=pw(J):p.has(L)&&R.push(U)}await oe.mkdirPromise(V.dirname(h),{recursive:!0}),await oe.changeFilePromise(h,Ba(new Ba.PreserveOrdering({releases:Object.keys(x).length>0?x:void 0,declined:C.length>0?C:void 0,undecided:R.length>0?R:void 0})))}}}function KDt(t){return aF(t).size>0||Gv(t).length>0}function aF(t){let e=new Set;for(let r of t.changedWorkspaces)r.manifest.version!==null&&(t.releases.has(r)||e.add(r));return e}function Gv(t,{include:e=new Set}={}){let r=[],o=new Map(_e.mapAndFilter([...t.releases],([n,u])=>u==="decline"?_e.mapAndFilter.skip:[n.anchoredLocator.locatorHash,n])),a=new Map(_e.mapAndFilter([...t.releases],([n,u])=>u!=="decline"?_e.mapAndFilter.skip:[n.anchoredLocator.locatorHash,n]));for(let n of t.project.workspaces)if(!(!e.has(n)&&(a.has(n.anchoredLocator.locatorHash)||o.has(n.anchoredLocator.locatorHash)))&&n.manifest.version!==null)for(let u of Ot.hardDependencies)for(let A of n.manifest.getForScope(u).values()){let p=t.project.tryWorkspaceByDescriptor(A);p!==null&&o.has(p.anchoredLocator.locatorHash)&&r.push([n,p])}return r}function Wq(t,e){let r=vA.default.clean(e);for(let o of Object.values(hw))if(o!=="undecided"&&o!=="decline"&&vA.default.inc(t,o)===r)return o;return null}function lF(t,e){if(vA.default.valid(e))return e;if(t===null)throw new it(`Cannot apply the release strategy "${e}" unless the workspace already has a valid version`);if(!vA.default.valid(t))throw new it(`Cannot apply the release strategy "${e}" on a non-semver version (${t})`);let r=vA.default.inc(t,e);if(r===null)throw new it(`Cannot apply the release strategy "${e}" on the specified version (${t})`);return r}function Kq(t,e,{report:r}){let o=new Map;for(let a of t.workspaces)for(let n of Ot.allDependencies)for(let u of a.manifest[n].values()){let A=t.tryWorkspaceByDescriptor(u);if(A===null||!e.has(A))continue;_e.getArrayWithDefault(o,A).push([a,n,u.identHash])}for(let[a,n]of e){let u=a.manifest.version;a.manifest.version=n,vA.default.prerelease(n)===null?delete a.manifest.raw.stableVersion:a.manifest.raw.stableVersion||(a.manifest.raw.stableVersion=u);let A=a.manifest.name!==null?W.stringifyIdent(a.manifest.name):null;r.reportInfo(0,`${W.prettyLocator(t.configuration,a.anchoredLocator)}: Bumped to ${n}`),r.reportJson({cwd:ue.fromPortablePath(a.cwd),ident:A,oldVersion:u,newVersion:n});let p=o.get(a);if(!(typeof p>"u"))for(let[h,E,I]of p){let v=h.manifest[E].get(I);if(typeof v>"u")throw new Error("Assertion failed: The dependency should have existed");let x=v.range,C=!1;if(x.startsWith(Xn.protocol)&&(x=x.slice(Xn.protocol.length),C=!0,x===a.relativeCwd))continue;let R=x.match(WDt);if(!R){r.reportWarning(0,`Couldn't auto-upgrade range ${x} (in ${W.prettyLocator(t.configuration,h.anchoredLocator)})`);continue}let L=`${R[1]}${n}`;C&&(L=`${Xn.protocol}${L}`);let U=W.makeDescriptor(v,L);h.manifest[E].set(I,U)}}}var VDt=new Map([["%n",{extract:t=>t.length>=1?[t[0],t.slice(1)]:null,generate:(t=0)=>`${t+1}`}]]);function WBe(t,{current:e,prerelease:r}){let o=new vA.default.SemVer(e),a=o.prerelease.slice(),n=[];o.prerelease=[],o.format()!==t&&(a.length=0);let u=!0,A=r.split(/\./g);for(let p of A){let h=VDt.get(p);if(typeof h>"u")n.push(p),a[0]===p?a.shift():u=!1;else{let E=u?h.extract(a):null;E!==null&&typeof E[0]=="number"?(n.push(h.generate(E[0])),a=E[1]):(n.push(h.generate()),u=!1)}}return o.prerelease&&(o.prerelease=[]),`${t}-${n.join(".")}`}var eg=class extends ut{constructor(){super(...arguments);this.all=ge.Boolean("--all",!1,{description:"Apply the deferred version changes on all workspaces"});this.dryRun=ge.Boolean("--dry-run",!1,{description:"Print the versions without actually generating the package archive"});this.prerelease=ge.String("--prerelease",{description:"Add a prerelease identifier to new versions",tolerateBoolean:!0});this.recursive=ge.Boolean("-R,--recursive",{description:"Release the transitive workspaces as well"});this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd),n=await Lr.find(r);if(!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState({restoreResolutions:!1});let u=await Nt.start({configuration:r,json:this.json,stdout:this.context.stdout},async A=>{let p=this.prerelease?typeof this.prerelease!="boolean"?this.prerelease:"rc.%n":null,h=await jv(o,{prerelease:p}),E=new Map;if(this.all)E=h;else{let I=this.recursive?a.getRecursiveWorkspaceDependencies():[a];for(let v of I){let x=h.get(v);typeof x<"u"&&E.set(v,x)}}if(E.size===0){let I=h.size>0?" Did you want to add --all?":"";A.reportWarning(0,`The current workspace doesn't seem to require a version bump.${I}`);return}Kq(o,E,{report:A}),this.dryRun||(p||(this.all?await qq(o):await Yq(o,[...E.keys()])),A.reportSeparator())});return this.dryRun||u.hasErrors()?u.exitCode():await o.installWithNewReport({json:this.json,stdout:this.context.stdout},{cache:n})}};eg.paths=[["version","apply"]],eg.usage=nt.Usage({category:"Release-related commands",description:"apply all the deferred version bumps at once",details:` + This command will apply the deferred version changes and remove their definitions from the repository. + + Note that if \`--prerelease\` is set, the given prerelease identifier (by default \`rc.%d\`) will be used on all new versions and the version definitions will be kept as-is. + + By default only the current workspace will be bumped, but you can configure this behavior by using one of: + + - \`--recursive\` to also apply the version bump on its dependencies + - \`--all\` to apply the version bump on all packages in the repository + + Note that this command will also update the \`workspace:\` references across all your local workspaces, thus ensuring that they keep referring to the same workspaces even after the version bump. + `,examples:[["Apply the version change to the local workspace","yarn version apply"],["Apply the version change to all the workspaces in the local workspace","yarn version apply --all"]]});Ye();St();jt();var cF=$e(zn());var tg=class extends ut{constructor(){super(...arguments);this.interactive=ge.Boolean("-i,--interactive",{description:"Open an interactive interface used to set version bumps"})}async execute(){return this.interactive?await this.executeInteractive():await this.executeStandard()}async executeInteractive(){bC(this.context);let{Gem:r}=await Promise.resolve().then(()=>(cQ(),Bj)),{ScrollableItems:o}=await Promise.resolve().then(()=>(pQ(),fQ)),{FocusRequest:a}=await Promise.resolve().then(()=>(Dj(),Vwe)),{useListInput:n}=await Promise.resolve().then(()=>(AQ(),Jwe)),{renderForm:u}=await Promise.resolve().then(()=>(mQ(),dQ)),{Box:A,Text:p}=await Promise.resolve().then(()=>$e(ic())),{default:h,useCallback:E,useState:I}=await Promise.resolve().then(()=>$e(on())),v=await Ke.find(this.context.cwd,this.context.plugins),{project:x,workspace:C}=await Pt.find(v,this.context.cwd);if(!C)throw new rr(x.cwd,this.context.cwd);await x.restoreInstallState();let R=await gw(x);if(R===null||R.releaseRoots.size===0)return 0;if(R.root===null)throw new it("This command can only be run on Git repositories");let L=()=>h.createElement(A,{flexDirection:"row",paddingBottom:1},h.createElement(A,{flexDirection:"column",width:60},h.createElement(A,null,h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<up>"),"/",h.createElement(p,{bold:!0,color:"cyanBright"},"<down>")," to select workspaces.")),h.createElement(A,null,h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<left>"),"/",h.createElement(p,{bold:!0,color:"cyanBright"},"<right>")," to select release strategies."))),h.createElement(A,{flexDirection:"column"},h.createElement(A,{marginLeft:1},h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<enter>")," to save.")),h.createElement(A,{marginLeft:1},h.createElement(p,null,"Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<ctrl+c>")," to abort.")))),U=({workspace:me,active:he,decision:Be,setDecision:we})=>{let g=me.manifest.raw.stableVersion??me.manifest.version;if(g===null)throw new Error(`Assertion failed: The version should have been set (${W.prettyLocator(v,me.anchoredLocator)})`);if(cF.default.prerelease(g)!==null)throw new Error(`Assertion failed: Prerelease identifiers shouldn't be found (${g})`);let Ee=["undecided","decline","patch","minor","major"];n(Be,Ee,{active:he,minus:"left",plus:"right",set:we});let Se=Be==="undecided"?h.createElement(p,{color:"yellow"},g):Be==="decline"?h.createElement(p,{color:"green"},g):h.createElement(p,null,h.createElement(p,{color:"magenta"},g)," \u2192 ",h.createElement(p,{color:"green"},cF.default.valid(Be)?Be:cF.default.inc(g,Be)));return h.createElement(A,{flexDirection:"column"},h.createElement(A,null,h.createElement(p,null,W.prettyLocator(v,me.anchoredLocator)," - ",Se)),h.createElement(A,null,Ee.map(le=>h.createElement(A,{key:le,paddingLeft:2},h.createElement(p,null,h.createElement(r,{active:le===Be})," ",le)))))},J=me=>{let he=new Set(R.releaseRoots),Be=new Map([...me].filter(([we])=>he.has(we)));for(;;){let we=Gv({project:R.project,releases:Be}),g=!1;if(we.length>0){for(let[Ee]of we)if(!he.has(Ee)){he.add(Ee),g=!0;let Se=me.get(Ee);typeof Se<"u"&&Be.set(Ee,Se)}}if(!g)break}return{relevantWorkspaces:he,relevantReleases:Be}},te=()=>{let[me,he]=I(()=>new Map(R.releases)),Be=E((we,g)=>{let Ee=new Map(me);g!=="undecided"?Ee.set(we,g):Ee.delete(we);let{relevantReleases:Se}=J(Ee);he(Se)},[me,he]);return[me,Be]},ae=({workspaces:me,releases:he})=>{let Be=[];Be.push(`${me.size} total`);let we=0,g=0;for(let Ee of me){let Se=he.get(Ee);typeof Se>"u"?g+=1:Se!=="decline"&&(we+=1)}return Be.push(`${we} release${we===1?"":"s"}`),Be.push(`${g} remaining`),h.createElement(p,{color:"yellow"},Be.join(", "))},ce=await u(({useSubmit:me})=>{let[he,Be]=te();me(he);let{relevantWorkspaces:we}=J(he),g=new Set([...we].filter(ne=>!R.releaseRoots.has(ne))),[Ee,Se]=I(0),le=E(ne=>{switch(ne){case a.BEFORE:Se(Ee-1);break;case a.AFTER:Se(Ee+1);break}},[Ee,Se]);return h.createElement(A,{flexDirection:"column"},h.createElement(L,null),h.createElement(A,null,h.createElement(p,{wrap:"wrap"},"The following files have been modified in your local checkout.")),h.createElement(A,{flexDirection:"column",marginTop:1,paddingLeft:2},[...R.changedFiles].map(ne=>h.createElement(A,{key:ne},h.createElement(p,null,h.createElement(p,{color:"grey"},ue.fromPortablePath(R.root)),ue.sep,ue.relative(ue.fromPortablePath(R.root),ue.fromPortablePath(ne)))))),R.releaseRoots.size>0&&h.createElement(h.Fragment,null,h.createElement(A,{marginTop:1},h.createElement(p,{wrap:"wrap"},"Because of those files having been modified, the following workspaces may need to be released again (note that private workspaces are also shown here, because even though they won't be published, releasing them will allow us to flag their dependents for potential re-release):")),g.size>3?h.createElement(A,{marginTop:1},h.createElement(ae,{workspaces:R.releaseRoots,releases:he})):null,h.createElement(A,{marginTop:1,flexDirection:"column"},h.createElement(o,{active:Ee%2===0,radius:1,size:2,onFocusRequest:le},[...R.releaseRoots].map(ne=>h.createElement(U,{key:ne.cwd,workspace:ne,decision:he.get(ne)||"undecided",setDecision:ee=>Be(ne,ee)}))))),g.size>0?h.createElement(h.Fragment,null,h.createElement(A,{marginTop:1},h.createElement(p,{wrap:"wrap"},"The following workspaces depend on other workspaces that have been marked for release, and thus may need to be released as well:")),h.createElement(A,null,h.createElement(p,null,"(Press ",h.createElement(p,{bold:!0,color:"cyanBright"},"<tab>")," to move the focus between the workspace groups.)")),g.size>5?h.createElement(A,{marginTop:1},h.createElement(ae,{workspaces:g,releases:he})):null,h.createElement(A,{marginTop:1,flexDirection:"column"},h.createElement(o,{active:Ee%2===1,radius:2,size:2,onFocusRequest:le},[...g].map(ne=>h.createElement(U,{key:ne.cwd,workspace:ne,decision:he.get(ne)||"undecided",setDecision:ee=>Be(ne,ee)}))))):null)},{versionFile:R},{stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr});if(typeof ce>"u")return 1;R.releases.clear();for(let[me,he]of ce)R.releases.set(me,he);await R.saveAll()}async executeStandard(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);return await o.restoreInstallState(),(await Nt.start({configuration:r,stdout:this.context.stdout},async u=>{let A=await gw(o);if(A===null||A.releaseRoots.size===0)return;if(A.root===null)throw new it("This command can only be run on Git repositories");if(u.reportInfo(0,`Your PR was started right after ${de.pretty(r,A.baseHash.slice(0,7),"yellow")} ${de.pretty(r,A.baseTitle,"magenta")}`),A.changedFiles.size>0){u.reportInfo(0,"You have changed the following files since then:"),u.reportSeparator();for(let v of A.changedFiles)u.reportInfo(null,`${de.pretty(r,ue.fromPortablePath(A.root),"gray")}${ue.sep}${ue.relative(ue.fromPortablePath(A.root),ue.fromPortablePath(v))}`)}let p=!1,h=!1,E=aF(A);if(E.size>0){p||u.reportSeparator();for(let v of E)u.reportError(0,`${W.prettyLocator(r,v.anchoredLocator)} has been modified but doesn't have a release strategy attached`);p=!0}let I=Gv(A);for(let[v,x]of I)h||u.reportSeparator(),u.reportError(0,`${W.prettyLocator(r,v.anchoredLocator)} doesn't have a release strategy attached, but depends on ${W.prettyWorkspace(r,x)} which is planned for release.`),h=!0;(p||h)&&(u.reportSeparator(),u.reportInfo(0,"This command detected that at least some workspaces have received modifications without explicit instructions as to how they had to be released (if needed)."),u.reportInfo(0,"To correct these errors, run `yarn version check --interactive` then follow the instructions."))})).exitCode()}};tg.paths=[["version","check"]],tg.usage=nt.Usage({category:"Release-related commands",description:"check that all the relevant packages have been bumped",details:"\n **Warning:** This command currently requires Git.\n\n This command will check that all the packages covered by the files listed in argument have been properly bumped or declined to bump.\n\n In the case of a bump, the check will also cover transitive packages - meaning that should `Foo` be bumped, a package `Bar` depending on `Foo` will require a decision as to whether `Bar` will need to be bumped. This check doesn't cross packages that have declined to bump.\n\n In case no arguments are passed to the function, the list of modified files will be generated by comparing the HEAD against `master`.\n ",examples:[["Check whether the modified packages need a bump","yarn version check"]]});Ye();jt();var uF=$e(zn());var rg=class extends ut{constructor(){super(...arguments);this.deferred=ge.Boolean("-d,--deferred",{description:"Prepare the version to be bumped during the next release cycle"});this.immediate=ge.Boolean("-i,--immediate",{description:"Bump the version immediately"});this.strategy=ge.String()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd);if(!a)throw new rr(o.cwd,this.context.cwd);let n=r.get("preferDeferredVersions");this.deferred&&(n=!0),this.immediate&&(n=!1);let u=uF.default.valid(this.strategy),A=this.strategy==="decline",p;if(u)if(a.manifest.version!==null){let E=Wq(a.manifest.version,this.strategy);E!==null?p=E:p=this.strategy}else p=this.strategy;else{let E=a.manifest.version;if(!A){if(E===null)throw new it("Can't bump the version if there wasn't a version to begin with - use 0.0.0 as initial version then run the command again.");if(typeof E!="string"||!uF.default.valid(E))throw new it(`Can't bump the version (${E}) if it's not valid semver`)}p=pw(this.strategy)}if(!n){let I=(await jv(o)).get(a);if(typeof I<"u"&&p!=="decline"){let v=lF(a.manifest.version,p);if(uF.default.lt(v,I))throw new it(`Can't bump the version to one that would be lower than the current deferred one (${I})`)}}let h=await gw(o,{allowEmpty:!0});return h.releases.set(a,p),await h.saveAll(),n?0:await this.cli.run(["version","apply"])}};rg.paths=[["version"]],rg.usage=nt.Usage({category:"Release-related commands",description:"apply a new version to the current package",details:"\n This command will bump the version number for the given package, following the specified strategy:\n\n - If `major`, the first number from the semver range will be increased (`X.0.0`).\n - If `minor`, the second number from the semver range will be increased (`0.X.0`).\n - If `patch`, the third number from the semver range will be increased (`0.0.X`).\n - If prefixed by `pre` (`premajor`, ...), a `-0` suffix will be set (`0.0.0-0`).\n - If `prerelease`, the suffix will be increased (`0.0.0-X`); the third number from the semver range will also be increased if there was no suffix in the previous version.\n - If `decline`, the nonce will be increased for `yarn version check` to pass without version bump.\n - If a valid semver range, it will be used as new version.\n - If unspecified, Yarn will ask you for guidance.\n\n For more information about the `--deferred` flag, consult our documentation (https://yarnpkg.com/features/release-workflow#deferred-versioning).\n ",examples:[["Immediately bump the version to the next major","yarn version major"],["Prepare the version to be bumped to the next major","yarn version major --deferred"]]});var JDt={configuration:{deferredVersionFolder:{description:"Folder where are stored the versioning files",type:"ABSOLUTE_PATH",default:"./.yarn/versions"},preferDeferredVersions:{description:"If true, running `yarn version` will assume the `--deferred` flag unless `--immediate` is set",type:"BOOLEAN",default:!1}},commands:[eg,tg,rg]},zDt=JDt;var Jq={};Vt(Jq,{WorkspacesFocusCommand:()=>ng,WorkspacesForeachCommand:()=>ap,default:()=>$Dt});Ye();Ye();jt();var ng=class extends ut{constructor(){super(...arguments);this.json=ge.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.production=ge.Boolean("--production",!1,{description:"Only install regular dependencies by omitting dev dependencies"});this.all=ge.Boolean("-A,--all",!1,{description:"Install the entire project"});this.workspaces=ge.Rest()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd),n=await Lr.find(r);await o.restoreInstallState({restoreResolutions:!1});let u;if(this.all)u=new Set(o.workspaces);else if(this.workspaces.length===0){if(!a)throw new rr(o.cwd,this.context.cwd);u=new Set([a])}else u=new Set(this.workspaces.map(A=>o.getWorkspaceByIdent(W.parseIdent(A))));for(let A of u)for(let p of this.production?["dependencies"]:Ot.hardDependencies)for(let h of A.manifest.getForScope(p).values()){let E=o.tryWorkspaceByDescriptor(h);E!==null&&u.add(E)}for(let A of o.workspaces)u.has(A)?this.production&&A.manifest.devDependencies.clear():(A.manifest.installConfig=A.manifest.installConfig||{},A.manifest.installConfig.selfReferences=!1,A.manifest.dependencies.clear(),A.manifest.devDependencies.clear(),A.manifest.peerDependencies.clear(),A.manifest.scripts.clear());return await o.installWithNewReport({json:this.json,stdout:this.context.stdout},{cache:n,persistProject:!1})}};ng.paths=[["workspaces","focus"]],ng.usage=nt.Usage({category:"Workspace-related commands",description:"install a single workspace and its dependencies",details:"\n This command will run an install as if the specified workspaces (and all other workspaces they depend on) were the only ones in the project. If no workspaces are explicitly listed, the active one will be assumed.\n\n Note that this command is only very moderately useful when using zero-installs, since the cache will contain all the packages anyway - meaning that the only difference between a full install and a focused install would just be a few extra lines in the `.pnp.cjs` file, at the cost of introducing an extra complexity.\n\n If the `-A,--all` flag is set, the entire project will be installed. Combine with `--production` to replicate the old `yarn install --production`.\n "});Ye();Ye();Ye();jt();var mw=$e(Zo()),VBe=$e(id());Za();var ap=class extends ut{constructor(){super(...arguments);this.from=ge.Array("--from",{description:"An array of glob pattern idents or paths from which to base any recursion"});this.all=ge.Boolean("-A,--all",{description:"Run the command on all workspaces of a project"});this.recursive=ge.Boolean("-R,--recursive",{description:"Run the command on the current workspace and all of its recursive dependencies"});this.worktree=ge.Boolean("-W,--worktree",{description:"Run the command on all workspaces of the current worktree"});this.verbose=ge.Counter("-v,--verbose",{description:"Increase level of logging verbosity up to 2 times"});this.parallel=ge.Boolean("-p,--parallel",!1,{description:"Run the commands in parallel"});this.interlaced=ge.Boolean("-i,--interlaced",!1,{description:"Print the output of commands in real-time instead of buffering it"});this.jobs=ge.String("-j,--jobs",{description:"The maximum number of parallel tasks that the execution will be limited to; or `unlimited`",validator:TT([Ks(["unlimited"]),aI(RT(),[LT(),NT(1)])])});this.topological=ge.Boolean("-t,--topological",!1,{description:"Run the command after all workspaces it depends on (regular) have finished"});this.topologicalDev=ge.Boolean("--topological-dev",!1,{description:"Run the command after all workspaces it depends on (regular + dev) have finished"});this.include=ge.Array("--include",[],{description:"An array of glob pattern idents or paths; only matching workspaces will be traversed"});this.exclude=ge.Array("--exclude",[],{description:"An array of glob pattern idents or paths; matching workspaces won't be traversed"});this.publicOnly=ge.Boolean("--no-private",{description:"Avoid running the command on private workspaces"});this.since=ge.String("--since",{description:"Only include workspaces that have been changed since the specified ref.",tolerateBoolean:!0});this.dryRun=ge.Boolean("-n,--dry-run",{description:"Print the commands that would be run, without actually running them"});this.commandName=ge.String();this.args=ge.Proxy()}async execute(){let r=await Ke.find(this.context.cwd,this.context.plugins),{project:o,workspace:a}=await Pt.find(r,this.context.cwd);if(!this.all&&!a)throw new rr(o.cwd,this.context.cwd);await o.restoreInstallState();let n=this.cli.process([this.commandName,...this.args]),u=n.path.length===1&&n.path[0]==="run"&&typeof n.scriptName<"u"?n.scriptName:null;if(n.path.length===0)throw new it("Invalid subcommand name for iteration - use the 'run' keyword if you wish to execute a script");let A=we=>{!this.dryRun||this.context.stdout.write(`${we} +`)},p=()=>{let we=this.from.map(g=>mw.default.matcher(g));return o.workspaces.filter(g=>{let Ee=W.stringifyIdent(g.anchoredLocator),Se=g.relativeCwd;return we.some(le=>le(Ee)||le(Se))})},h=[];if(this.since?(A("Option --since is set; selecting the changed workspaces as root for workspace selection"),h=Array.from(await ra.fetchChangedWorkspaces({ref:this.since,project:o}))):this.from?(A("Option --from is set; selecting the specified workspaces"),h=[...p()]):this.worktree?(A("Option --worktree is set; selecting the current workspace"),h=[a]):this.recursive?(A("Option --recursive is set; selecting the current workspace"),h=[a]):this.all&&(A("Option --all is set; selecting all workspaces"),h=[...o.workspaces]),this.dryRun&&!this.all){for(let we of h)A(` +- ${we.relativeCwd} + ${W.prettyLocator(r,we.anchoredLocator)}`);h.length>0&&A("")}let E;if(this.recursive?this.since?(A("Option --recursive --since is set; recursively selecting all dependent workspaces"),E=new Set(h.map(we=>[...we.getRecursiveWorkspaceDependents()]).flat())):(A("Option --recursive is set; recursively selecting all transitive dependencies"),E=new Set(h.map(we=>[...we.getRecursiveWorkspaceDependencies()]).flat())):this.worktree?(A("Option --worktree is set; recursively selecting all nested workspaces"),E=new Set(h.map(we=>[...we.getRecursiveWorkspaceChildren()]).flat())):E=null,E!==null&&(h=[...new Set([...h,...E])],this.dryRun))for(let we of E)A(` +- ${we.relativeCwd} + ${W.prettyLocator(r,we.anchoredLocator)}`);let I=[],v=!1;if(u?.includes(":")){for(let we of o.workspaces)if(we.manifest.scripts.has(u)&&(v=!v,v===!1))break}for(let we of h){if(u&&!we.manifest.scripts.has(u)&&!v&&!(await un.getWorkspaceAccessibleBinaries(we)).has(u)){A(`Excluding ${we.relativeCwd} because it doesn't have a "${u}" script`);continue}if(!(u===r.env.npm_lifecycle_event&&we.cwd===a.cwd)){if(this.include.length>0&&!mw.default.isMatch(W.stringifyIdent(we.anchoredLocator),this.include)&&!mw.default.isMatch(we.relativeCwd,this.include)){A(`Excluding ${we.relativeCwd} because it doesn't match the --include filter`);continue}if(this.exclude.length>0&&(mw.default.isMatch(W.stringifyIdent(we.anchoredLocator),this.exclude)||mw.default.isMatch(we.relativeCwd,this.exclude))){A(`Excluding ${we.relativeCwd} because it matches the --include filter`);continue}if(this.publicOnly&&we.manifest.private===!0){A(`Excluding ${we.relativeCwd} because it's a private workspace and --no-private was set`);continue}I.push(we)}}if(this.dryRun)return 0;let x=this.verbose??(this.context.stdout.isTTY?1/0:0),C=x>0,R=x>1,L=this.parallel?this.jobs==="unlimited"?1/0:Number(this.jobs)||Math.ceil(Ji.availableParallelism()/2):1,U=L===1?!1:this.parallel,J=U?this.interlaced:!0,te=(0,VBe.default)(L),ae=new Map,fe=new Set,ce=0,me=null,he=!1,Be=await Nt.start({configuration:r,stdout:this.context.stdout,includePrefix:!1},async we=>{let g=async(Ee,{commandIndex:Se})=>{if(he)return-1;!U&&R&&Se>1&&we.reportSeparator();let le=XDt(Ee,{configuration:r,label:C,commandIndex:Se}),[ne,ee]=KBe(we,{prefix:le,interlaced:J}),[Ie,Fe]=KBe(we,{prefix:le,interlaced:J});try{R&&we.reportInfo(null,`${le?`${le} `:""}Process started`);let At=Date.now(),H=await this.cli.run([this.commandName,...this.args],{cwd:Ee.cwd,stdout:ne,stderr:Ie})||0;ne.end(),Ie.end(),await ee,await Fe;let at=Date.now();if(R){let Re=r.get("enableTimers")?`, completed in ${de.pretty(r,at-At,de.Type.DURATION)}`:"";we.reportInfo(null,`${le?`${le} `:""}Process exited (exit code ${H})${Re}`)}return H===130&&(he=!0,me=H),H}catch(At){throw ne.end(),Ie.end(),await ee,await Fe,At}};for(let Ee of I)ae.set(Ee.anchoredLocator.locatorHash,Ee);for(;ae.size>0&&!we.hasErrors();){let Ee=[];for(let[ne,ee]of ae){if(fe.has(ee.anchoredDescriptor.descriptorHash))continue;let Ie=!0;if(this.topological||this.topologicalDev){let Fe=this.topologicalDev?new Map([...ee.manifest.dependencies,...ee.manifest.devDependencies]):ee.manifest.dependencies;for(let At of Fe.values()){let H=o.tryWorkspaceByDescriptor(At);if(Ie=H===null||!ae.has(H.anchoredLocator.locatorHash),!Ie)break}}if(!!Ie&&(fe.add(ee.anchoredDescriptor.descriptorHash),Ee.push(te(async()=>{let Fe=await g(ee,{commandIndex:++ce});return ae.delete(ne),fe.delete(ee.anchoredDescriptor.descriptorHash),Fe})),!U))break}if(Ee.length===0){let ne=Array.from(ae.values()).map(ee=>W.prettyLocator(r,ee.anchoredLocator)).join(", ");we.reportError(3,`Dependency cycle detected (${ne})`);return}let le=(await Promise.all(Ee)).find(ne=>ne!==0);me===null&&(me=typeof le<"u"?1:me),(this.topological||this.topologicalDev)&&typeof le<"u"&&we.reportError(0,"The command failed for workspaces that are depended upon by other workspaces; can't satisfy the dependency graph")}});return me!==null?me:Be.exitCode()}};ap.paths=[["workspaces","foreach"]],ap.usage=nt.Usage({category:"Workspace-related commands",description:"run a command on all workspaces",details:"\n This command will run a given sub-command on current and all its descendant workspaces. Various flags can alter the exact behavior of the command:\n\n - If `-p,--parallel` is set, the commands will be ran in parallel; they'll by default be limited to a number of parallel tasks roughly equal to half your core number, but that can be overridden via `-j,--jobs`, or disabled by setting `-j unlimited`.\n\n - If `-p,--parallel` and `-i,--interlaced` are both set, Yarn will print the lines from the output as it receives them. If `-i,--interlaced` wasn't set, it would instead buffer the output from each process and print the resulting buffers only after their source processes have exited.\n\n - If `-t,--topological` is set, Yarn will only run the command after all workspaces that it depends on through the `dependencies` field have successfully finished executing. If `--topological-dev` is set, both the `dependencies` and `devDependencies` fields will be considered when figuring out the wait points.\n\n - If `-A,--all` is set, Yarn will run the command on all the workspaces of a project.\n\n - If `-R,--recursive` is set, Yarn will find workspaces to run the command on by recursively evaluating `dependencies` and `devDependencies` fields, instead of looking at the `workspaces` fields.\n\n - If `-W,--worktree` is set, Yarn will find workspaces to run the command on by looking at the current worktree.\n\n - If `--from` is set, Yarn will use the packages matching the 'from' glob as the starting point for any recursive search.\n\n - If `--since` is set, Yarn will only run the command on workspaces that have been modified since the specified ref. By default Yarn will use the refs specified by the `changesetBaseRefs` configuration option.\n\n - If `--dry-run` is set, Yarn will explain what it would do without actually doing anything.\n\n - The command may apply to only some workspaces through the use of `--include` which acts as a whitelist. The `--exclude` flag will do the opposite and will be a list of packages that mustn't execute the script. Both flags accept glob patterns (if valid Idents and supported by [micromatch](https://github.com/micromatch/micromatch)). Make sure to escape the patterns, to prevent your own shell from trying to expand them.\n\n The `-v,--verbose` flag can be passed up to twice: once to prefix output lines with the originating workspace's name, and again to include start/finish/timing log lines. Maximum verbosity is enabled by default in terminal environments.\n\n If the command is `run` and the script being run does not exist the child workspace will be skipped without error.\n ",examples:[["Publish all packages","yarn workspaces foreach -A npm publish --tolerate-republish"],["Run the build script on all descendant packages","yarn workspaces foreach -A run build"],["Run the build script on current and all descendant packages in parallel, building package dependencies first","yarn workspaces foreach -Apt run build"],["Run the build script on several packages and all their dependencies, building dependencies first","yarn workspaces foreach -Rpt --from '{workspace-a,workspace-b}' run build"]]}),ap.schema=[cI("all",qu.Forbids,["from","recursive","since","worktree"],{missingIf:"undefined"}),OT(["all","recursive","since","worktree"],{missingIf:"undefined"})];function KBe(t,{prefix:e,interlaced:r}){let o=t.createStreamReporter(e),a=new _e.DefaultStream;a.pipe(o,{end:!1}),a.on("finish",()=>{o.end()});let n=new Promise(A=>{o.on("finish",()=>{A(a.active)})});if(r)return[a,n];let u=new _e.BufferStream;return u.pipe(a,{end:!1}),u.on("finish",()=>{a.end()}),[u,n]}function XDt(t,{configuration:e,commandIndex:r,label:o}){if(!o)return null;let n=`[${W.stringifyIdent(t.anchoredLocator)}]:`,u=["#2E86AB","#A23B72","#F18F01","#C73E1D","#CCE2A3"],A=u[r%u.length];return de.pretty(e,n,A)}var ZDt={commands:[ng,ap]},$Dt=ZDt;var pC=()=>({modules:new Map([["@yarnpkg/cli",a2],["@yarnpkg/core",o2],["@yarnpkg/fslib",Vw],["@yarnpkg/libzip",x1],["@yarnpkg/parsers",rI],["@yarnpkg/shell",T1],["clipanion",hI],["semver",eSt],["typanion",Vo],["@yarnpkg/plugin-essentials",$8],["@yarnpkg/plugin-compat",iH],["@yarnpkg/plugin-constraints",wH],["@yarnpkg/plugin-dlx",IH],["@yarnpkg/plugin-exec",DH],["@yarnpkg/plugin-file",PH],["@yarnpkg/plugin-git",Z8],["@yarnpkg/plugin-github",kH],["@yarnpkg/plugin-http",QH],["@yarnpkg/plugin-init",FH],["@yarnpkg/plugin-interactive-tools",Tj],["@yarnpkg/plugin-link",Nj],["@yarnpkg/plugin-nm",yG],["@yarnpkg/plugin-npm",dq],["@yarnpkg/plugin-npm-cli",Dq],["@yarnpkg/plugin-pack",Aq],["@yarnpkg/plugin-patch",Fq],["@yarnpkg/plugin-pnp",oG],["@yarnpkg/plugin-pnpm",Nq],["@yarnpkg/plugin-stage",jq],["@yarnpkg/plugin-typescript",Gq],["@yarnpkg/plugin-version",Vq],["@yarnpkg/plugin-workspace-tools",Jq]]),plugins:new Set(["@yarnpkg/plugin-essentials","@yarnpkg/plugin-compat","@yarnpkg/plugin-constraints","@yarnpkg/plugin-dlx","@yarnpkg/plugin-exec","@yarnpkg/plugin-file","@yarnpkg/plugin-git","@yarnpkg/plugin-github","@yarnpkg/plugin-http","@yarnpkg/plugin-init","@yarnpkg/plugin-interactive-tools","@yarnpkg/plugin-link","@yarnpkg/plugin-nm","@yarnpkg/plugin-npm","@yarnpkg/plugin-npm-cli","@yarnpkg/plugin-pack","@yarnpkg/plugin-patch","@yarnpkg/plugin-pnp","@yarnpkg/plugin-pnpm","@yarnpkg/plugin-stage","@yarnpkg/plugin-typescript","@yarnpkg/plugin-version","@yarnpkg/plugin-workspace-tools"])});function XBe({cwd:t,pluginConfiguration:e}){let r=new as({binaryLabel:"Yarn Package Manager",binaryName:"yarn",binaryVersion:rn??"<unknown>"});return Object.assign(r,{defaultContext:{...as.defaultContext,cwd:t,plugins:e,quiet:!1,stdin:process.stdin,stdout:process.stdout,stderr:process.stderr}})}function tSt(t){if(_e.parseOptionalBoolean(process.env.YARN_IGNORE_NODE))return!0;let r=process.versions.node,o=">=18.12.0";if(kr.satisfiesWithPrereleases(r,o))return!0;let a=new it(`This tool requires a Node version compatible with ${o} (got ${r}). Upgrade Node, or set \`YARN_IGNORE_NODE=1\` in your environment.`);return as.defaultContext.stdout.write(t.error(a)),!1}async function ZBe({selfPath:t,pluginConfiguration:e}){return await Ke.find(ue.toPortablePath(process.cwd()),e,{strict:!1,usePathCheck:t})}function rSt(t,e,{yarnPath:r}){if(!oe.existsSync(r))return t.error(new Error(`The "yarn-path" option has been set, but the specified location doesn't exist (${r}).`)),1;process.on("SIGINT",()=>{});let o={stdio:"inherit",env:{...process.env,YARN_IGNORE_PATH:"1"}};try{(0,JBe.execFileSync)(process.execPath,[ue.fromPortablePath(r),...e],o)}catch(a){return a.status??1}return 0}function nSt(t,e){let r=null,o=e;return e.length>=2&&e[0]==="--cwd"?(r=ue.toPortablePath(e[1]),o=e.slice(2)):e.length>=1&&e[0].startsWith("--cwd=")?(r=ue.toPortablePath(e[0].slice(6)),o=e.slice(1)):e[0]==="add"&&e[e.length-2]==="--cwd"&&(r=ue.toPortablePath(e[e.length-1]),o=e.slice(0,e.length-2)),t.defaultContext.cwd=r!==null?V.resolve(r):V.cwd(),o}function iSt(t,{configuration:e}){if(!e.get("enableTelemetry")||zBe.isCI||!process.stdout.isTTY)return;Ke.telemetry=new uC(e,"puba9cdc10ec5790a2cf4969dd413a47270");let o=/^@yarnpkg\/plugin-(.*)$/;for(let a of e.plugins.keys())AC.has(a.match(o)?.[1]??"")&&Ke.telemetry?.reportPluginName(a);t.binaryVersion&&Ke.telemetry.reportVersion(t.binaryVersion)}function $Be(t,{configuration:e}){for(let r of e.plugins.values())for(let o of r.commands||[])t.register(o)}async function sSt(t,e,{selfPath:r,pluginConfiguration:o}){if(!tSt(t))return 1;let a=await ZBe({selfPath:r,pluginConfiguration:o}),n=a.get("yarnPath"),u=a.get("ignorePath");if(n&&!u)return rSt(t,e,{yarnPath:n});delete process.env.YARN_IGNORE_PATH;let A=nSt(t,e);iSt(t,{configuration:a}),$Be(t,{configuration:a});let p=t.process(A,t.defaultContext);return p.help||Ke.telemetry?.reportCommandName(p.path.join(" ")),await t.run(p,t.defaultContext)}async function $pe({cwd:t=V.cwd(),pluginConfiguration:e=pC()}={}){let r=XBe({cwd:t,pluginConfiguration:e}),o=await ZBe({pluginConfiguration:e,selfPath:null});return $Be(r,{configuration:o}),r}async function nk(t,{cwd:e=V.cwd(),selfPath:r,pluginConfiguration:o}){let a=XBe({cwd:e,pluginConfiguration:o});try{process.exitCode=await sSt(a,t,{selfPath:r,pluginConfiguration:o})}catch(n){as.defaultContext.stdout.write(a.error(n)),process.exitCode=1}finally{await oe.rmtempPromise()}}nk(process.argv.slice(2),{cwd:V.cwd(),selfPath:ue.toPortablePath(ue.resolve(process.argv[1])),pluginConfiguration:pC()});})(); +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ +/*! + * buildToken + * Builds OAuth token prefix (helper function) + * + * @name buildToken + * @function + * @param {GitUrl} obj The parsed Git url object. + * @return {String} token prefix + */ +/*! + * fill-range <https://github.com/jonschlinkert/fill-range> + * + * Copyright (c) 2014-present, Jon Schlinkert. + * Licensed under the MIT License. + */ +/*! + * is-extglob <https://github.com/jonschlinkert/is-extglob> + * + * Copyright (c) 2014-2016, Jon Schlinkert. + * Licensed under the MIT License. + */ +/*! + * is-glob <https://github.com/jonschlinkert/is-glob> + * + * Copyright (c) 2014-2017, Jon Schlinkert. + * Released under the MIT License. + */ +/*! + * is-number <https://github.com/jonschlinkert/is-number> + * + * Copyright (c) 2014-present, Jon Schlinkert. + * Released under the MIT License. + */ +/*! + * is-windows <https://github.com/jonschlinkert/is-windows> + * + * Copyright © 2015-2018, Jon Schlinkert. + * Released under the MIT License. + */ +/*! + * to-regex-range <https://github.com/micromatch/to-regex-range> + * + * Copyright (c) 2015-present, Jon Schlinkert. + * Released under the MIT License. + */ +/** + @license + Copyright (c) 2015, Rebecca Turner + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + PERFORMANCE OF THIS SOFTWARE. + */ +/** + @license + Copyright Joyent, Inc. and other Node contributors. + + 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. +*/ +/** + @license + Copyright Node.js contributors. All rights reserved. + + 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. +*/ +/** + @license + The MIT License (MIT) + + Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) + + 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. +*/ +/** @license React v0.18.0 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +/** @license React v0.24.0 + * react-reconciler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ diff --git a/.yarn/sdks/eslint/bin/eslint.js b/.yarn/sdks/eslint/bin/eslint.js deleted file mode 100755 index 9ef98e400b47b..0000000000000 --- a/.yarn/sdks/eslint/bin/eslint.js +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node - -const {existsSync} = require(`fs`); -const {createRequire} = require(`module`); -const {resolve} = require(`path`); - -const relPnpApiPath = "../../../../.pnp.cjs"; - -const absPnpApiPath = resolve(__dirname, relPnpApiPath); -const absRequire = createRequire(absPnpApiPath); - -if (existsSync(absPnpApiPath)) { - if (!process.versions.pnp) { - // Setup the environment to be able to require eslint/bin/eslint.js - require(absPnpApiPath).setup(); - } -} - -// Defer to the real eslint/bin/eslint.js your application uses -module.exports = absRequire(`eslint/bin/eslint.js`); diff --git a/.yarn/sdks/eslint/lib/api.js b/.yarn/sdks/eslint/lib/api.js deleted file mode 100644 index 653b22bae06f4..0000000000000 --- a/.yarn/sdks/eslint/lib/api.js +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node - -const {existsSync} = require(`fs`); -const {createRequire} = require(`module`); -const {resolve} = require(`path`); - -const relPnpApiPath = "../../../../.pnp.cjs"; - -const absPnpApiPath = resolve(__dirname, relPnpApiPath); -const absRequire = createRequire(absPnpApiPath); - -if (existsSync(absPnpApiPath)) { - if (!process.versions.pnp) { - // Setup the environment to be able to require eslint - require(absPnpApiPath).setup(); - } -} - -// Defer to the real eslint your application uses -module.exports = absRequire(`eslint`); diff --git a/.yarn/sdks/eslint/package.json b/.yarn/sdks/eslint/package.json deleted file mode 100644 index ef371cb8e726c..0000000000000 --- a/.yarn/sdks/eslint/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "eslint", - "version": "8.52.0-sdk", - "main": "./lib/api.js", - "type": "commonjs", - "bin": { - "eslint": "./bin/eslint.js" - }, - "exports": { - "./package.json": "./package.json", - ".": "./lib/api.js", - "./use-at-your-own-risk": "./lib/unsupported-api.js" - } -} diff --git a/.yarn/sdks/integrations.yml b/.yarn/sdks/integrations.yml deleted file mode 100644 index 7e42d1884d84d..0000000000000 --- a/.yarn/sdks/integrations.yml +++ /dev/null @@ -1,6 +0,0 @@ -# This file is automatically generated by @yarnpkg/sdks. -# Manual changes might be lost! - -integrations: - - vscode - - vim diff --git a/.yarn/sdks/prettier/package.json b/.yarn/sdks/prettier/package.json deleted file mode 100644 index 4301e0948f114..0000000000000 --- a/.yarn/sdks/prettier/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "prettier", - "version": "3.0.0-sdk", - "main": "./index.cjs", - "type": "commonjs", - "bin": "./bin/prettier.cjs" -} diff --git a/.yarn/sdks/typescript/bin/tsc b/.yarn/sdks/typescript/bin/tsc deleted file mode 100755 index 454b950b7e8f1..0000000000000 --- a/.yarn/sdks/typescript/bin/tsc +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node - -const {existsSync} = require(`fs`); -const {createRequire} = require(`module`); -const {resolve} = require(`path`); - -const relPnpApiPath = "../../../../.pnp.cjs"; - -const absPnpApiPath = resolve(__dirname, relPnpApiPath); -const absRequire = createRequire(absPnpApiPath); - -if (existsSync(absPnpApiPath)) { - if (!process.versions.pnp) { - // Setup the environment to be able to require typescript/bin/tsc - require(absPnpApiPath).setup(); - } -} - -// Defer to the real typescript/bin/tsc your application uses -module.exports = absRequire(`typescript/bin/tsc`); diff --git a/.yarn/sdks/typescript/bin/tsserver b/.yarn/sdks/typescript/bin/tsserver deleted file mode 100755 index d7a605684df95..0000000000000 --- a/.yarn/sdks/typescript/bin/tsserver +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node - -const {existsSync} = require(`fs`); -const {createRequire} = require(`module`); -const {resolve} = require(`path`); - -const relPnpApiPath = "../../../../.pnp.cjs"; - -const absPnpApiPath = resolve(__dirname, relPnpApiPath); -const absRequire = createRequire(absPnpApiPath); - -if (existsSync(absPnpApiPath)) { - if (!process.versions.pnp) { - // Setup the environment to be able to require typescript/bin/tsserver - require(absPnpApiPath).setup(); - } -} - -// Defer to the real typescript/bin/tsserver your application uses -module.exports = absRequire(`typescript/bin/tsserver`); diff --git a/.yarn/sdks/typescript/lib/tsc.js b/.yarn/sdks/typescript/lib/tsc.js deleted file mode 100644 index 2f62fc96c0a08..0000000000000 --- a/.yarn/sdks/typescript/lib/tsc.js +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node - -const {existsSync} = require(`fs`); -const {createRequire} = require(`module`); -const {resolve} = require(`path`); - -const relPnpApiPath = "../../../../.pnp.cjs"; - -const absPnpApiPath = resolve(__dirname, relPnpApiPath); -const absRequire = createRequire(absPnpApiPath); - -if (existsSync(absPnpApiPath)) { - if (!process.versions.pnp) { - // Setup the environment to be able to require typescript/lib/tsc.js - require(absPnpApiPath).setup(); - } -} - -// Defer to the real typescript/lib/tsc.js your application uses -module.exports = absRequire(`typescript/lib/tsc.js`); diff --git a/.yarn/sdks/typescript/lib/tsserver.js b/.yarn/sdks/typescript/lib/tsserver.js deleted file mode 100644 index bbb1e46501b52..0000000000000 --- a/.yarn/sdks/typescript/lib/tsserver.js +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/env node - -const {existsSync} = require(`fs`); -const {createRequire} = require(`module`); -const {resolve} = require(`path`); - -const relPnpApiPath = "../../../../.pnp.cjs"; - -const absPnpApiPath = resolve(__dirname, relPnpApiPath); -const absRequire = createRequire(absPnpApiPath); - -const moduleWrapper = tsserver => { - if (!process.versions.pnp) { - return tsserver; - } - - const {isAbsolute} = require(`path`); - const pnpApi = require(`pnpapi`); - - const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//); - const isPortal = str => str.startsWith("portal:/"); - const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); - - const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => { - return `${locator.name}@${locator.reference}`; - })); - - // VSCode sends the zip paths to TS using the "zip://" prefix, that TS - // doesn't understand. This layer makes sure to remove the protocol - // before forwarding it to TS, and to add it back on all returned paths. - - function toEditorPath(str) { - // We add the `zip:` prefix to both `.zip/` paths and virtual paths - if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) { - // We also take the opportunity to turn virtual paths into physical ones; - // this makes it much easier to work with workspaces that list peer - // dependencies, since otherwise Ctrl+Click would bring us to the virtual - // file instances instead of the real ones. - // - // We only do this to modules owned by the the dependency tree roots. - // This avoids breaking the resolution when jumping inside a vendor - // with peer dep (otherwise jumping into react-dom would show resolution - // errors on react). - // - const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str; - if (resolved) { - const locator = pnpApi.findPackageLocator(resolved); - if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) { - str = resolved; - } - } - - str = normalize(str); - - if (str.match(/\.zip\//)) { - switch (hostInfo) { - // Absolute VSCode `Uri.fsPath`s need to start with a slash. - // VSCode only adds it automatically for supported schemes, - // so we have to do it manually for the `zip` scheme. - // The path needs to start with a caret otherwise VSCode doesn't handle the protocol - // - // Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910 - // - // 2021-10-08: VSCode changed the format in 1.61. - // Before | ^zip:/c:/foo/bar.zip/package.json - // After | ^/zip//c:/foo/bar.zip/package.json - // - // 2022-04-06: VSCode changed the format in 1.66. - // Before | ^/zip//c:/foo/bar.zip/package.json - // After | ^/zip/c:/foo/bar.zip/package.json - // - // 2022-05-06: VSCode changed the format in 1.68 - // Before | ^/zip/c:/foo/bar.zip/package.json - // After | ^/zip//c:/foo/bar.zip/package.json - // - case `vscode <1.61`: { - str = `^zip:${str}`; - } break; - - case `vscode <1.66`: { - str = `^/zip/${str}`; - } break; - - case `vscode <1.68`: { - str = `^/zip${str}`; - } break; - - case `vscode`: { - str = `^/zip/${str}`; - } break; - - // To make "go to definition" work, - // We have to resolve the actual file system path from virtual path - // and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip) - case `coc-nvim`: { - str = normalize(resolved).replace(/\.zip\//, `.zip::`); - str = resolve(`zipfile:${str}`); - } break; - - // Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server) - // We have to resolve the actual file system path from virtual path, - // everything else is up to neovim - case `neovim`: { - str = normalize(resolved).replace(/\.zip\//, `.zip::`); - str = `zipfile://${str}`; - } break; - - default: { - str = `zip:${str}`; - } break; - } - } else { - str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`); - } - } - - return str; - } - - function fromEditorPath(str) { - switch (hostInfo) { - case `coc-nvim`: { - str = str.replace(/\.zip::/, `.zip/`); - // The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/... - // So in order to convert it back, we use .* to match all the thing - // before `zipfile:` - return process.platform === `win32` - ? str.replace(/^.*zipfile:\//, ``) - : str.replace(/^.*zipfile:/, ``); - } break; - - case `neovim`: { - str = str.replace(/\.zip::/, `.zip/`); - // The path for neovim is in format of zipfile:///<pwd>/.yarn/... - return str.replace(/^zipfile:\/\//, ``); - } break; - - case `vscode`: - default: { - return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`) - } break; - } - } - - // Force enable 'allowLocalPluginLoads' - // TypeScript tries to resolve plugins using a path relative to itself - // which doesn't work when using the global cache - // https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238 - // VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but - // TypeScript already does local loads and if this code is running the user trusts the workspace - // https://github.com/microsoft/vscode/issues/45856 - const ConfiguredProject = tsserver.server.ConfiguredProject; - const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype; - ConfiguredProject.prototype.enablePluginsWithOptions = function() { - this.projectService.allowLocalPluginLoads = true; - return originalEnablePluginsWithOptions.apply(this, arguments); - }; - - // And here is the point where we hijack the VSCode <-> TS communications - // by adding ourselves in the middle. We locate everything that looks - // like an absolute path of ours and normalize it. - - const Session = tsserver.server.Session; - const {onMessage: originalOnMessage, send: originalSend} = Session.prototype; - let hostInfo = `unknown`; - - Object.assign(Session.prototype, { - onMessage(/** @type {string | object} */ message) { - const isStringMessage = typeof message === 'string'; - const parsedMessage = isStringMessage ? JSON.parse(message) : message; - - if ( - parsedMessage != null && - typeof parsedMessage === `object` && - parsedMessage.arguments && - typeof parsedMessage.arguments.hostInfo === `string` - ) { - hostInfo = parsedMessage.arguments.hostInfo; - if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) { - const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match( - // The RegExp from https://semver.org/ but without the caret at the start - /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ - ) ?? []).map(Number) - - if (major === 1) { - if (minor < 61) { - hostInfo += ` <1.61`; - } else if (minor < 66) { - hostInfo += ` <1.66`; - } else if (minor < 68) { - hostInfo += ` <1.68`; - } - } - } - } - - const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { - return typeof value === 'string' ? fromEditorPath(value) : value; - }); - - return originalOnMessage.call( - this, - isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) - ); - }, - - send(/** @type {any} */ msg) { - return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => { - return typeof value === `string` ? toEditorPath(value) : value; - }))); - } - }); - - return tsserver; -}; - -if (existsSync(absPnpApiPath)) { - if (!process.versions.pnp) { - // Setup the environment to be able to require typescript/lib/tsserver.js - require(absPnpApiPath).setup(); - } -} - -// Defer to the real typescript/lib/tsserver.js your application uses -module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`)); diff --git a/.yarn/sdks/typescript/lib/tsserverlibrary.js b/.yarn/sdks/typescript/lib/tsserverlibrary.js deleted file mode 100644 index a68f028fe1971..0000000000000 --- a/.yarn/sdks/typescript/lib/tsserverlibrary.js +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/env node - -const {existsSync} = require(`fs`); -const {createRequire} = require(`module`); -const {resolve} = require(`path`); - -const relPnpApiPath = "../../../../.pnp.cjs"; - -const absPnpApiPath = resolve(__dirname, relPnpApiPath); -const absRequire = createRequire(absPnpApiPath); - -const moduleWrapper = tsserver => { - if (!process.versions.pnp) { - return tsserver; - } - - const {isAbsolute} = require(`path`); - const pnpApi = require(`pnpapi`); - - const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//); - const isPortal = str => str.startsWith("portal:/"); - const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); - - const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => { - return `${locator.name}@${locator.reference}`; - })); - - // VSCode sends the zip paths to TS using the "zip://" prefix, that TS - // doesn't understand. This layer makes sure to remove the protocol - // before forwarding it to TS, and to add it back on all returned paths. - - function toEditorPath(str) { - // We add the `zip:` prefix to both `.zip/` paths and virtual paths - if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) { - // We also take the opportunity to turn virtual paths into physical ones; - // this makes it much easier to work with workspaces that list peer - // dependencies, since otherwise Ctrl+Click would bring us to the virtual - // file instances instead of the real ones. - // - // We only do this to modules owned by the the dependency tree roots. - // This avoids breaking the resolution when jumping inside a vendor - // with peer dep (otherwise jumping into react-dom would show resolution - // errors on react). - // - const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str; - if (resolved) { - const locator = pnpApi.findPackageLocator(resolved); - if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) { - str = resolved; - } - } - - str = normalize(str); - - if (str.match(/\.zip\//)) { - switch (hostInfo) { - // Absolute VSCode `Uri.fsPath`s need to start with a slash. - // VSCode only adds it automatically for supported schemes, - // so we have to do it manually for the `zip` scheme. - // The path needs to start with a caret otherwise VSCode doesn't handle the protocol - // - // Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910 - // - // 2021-10-08: VSCode changed the format in 1.61. - // Before | ^zip:/c:/foo/bar.zip/package.json - // After | ^/zip//c:/foo/bar.zip/package.json - // - // 2022-04-06: VSCode changed the format in 1.66. - // Before | ^/zip//c:/foo/bar.zip/package.json - // After | ^/zip/c:/foo/bar.zip/package.json - // - // 2022-05-06: VSCode changed the format in 1.68 - // Before | ^/zip/c:/foo/bar.zip/package.json - // After | ^/zip//c:/foo/bar.zip/package.json - // - case `vscode <1.61`: { - str = `^zip:${str}`; - } break; - - case `vscode <1.66`: { - str = `^/zip/${str}`; - } break; - - case `vscode <1.68`: { - str = `^/zip${str}`; - } break; - - case `vscode`: { - str = `^/zip/${str}`; - } break; - - // To make "go to definition" work, - // We have to resolve the actual file system path from virtual path - // and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip) - case `coc-nvim`: { - str = normalize(resolved).replace(/\.zip\//, `.zip::`); - str = resolve(`zipfile:${str}`); - } break; - - // Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server) - // We have to resolve the actual file system path from virtual path, - // everything else is up to neovim - case `neovim`: { - str = normalize(resolved).replace(/\.zip\//, `.zip::`); - str = `zipfile://${str}`; - } break; - - default: { - str = `zip:${str}`; - } break; - } - } else { - str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`); - } - } - - return str; - } - - function fromEditorPath(str) { - switch (hostInfo) { - case `coc-nvim`: { - str = str.replace(/\.zip::/, `.zip/`); - // The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/... - // So in order to convert it back, we use .* to match all the thing - // before `zipfile:` - return process.platform === `win32` - ? str.replace(/^.*zipfile:\//, ``) - : str.replace(/^.*zipfile:/, ``); - } break; - - case `neovim`: { - str = str.replace(/\.zip::/, `.zip/`); - // The path for neovim is in format of zipfile:///<pwd>/.yarn/... - return str.replace(/^zipfile:\/\//, ``); - } break; - - case `vscode`: - default: { - return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`) - } break; - } - } - - // Force enable 'allowLocalPluginLoads' - // TypeScript tries to resolve plugins using a path relative to itself - // which doesn't work when using the global cache - // https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238 - // VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but - // TypeScript already does local loads and if this code is running the user trusts the workspace - // https://github.com/microsoft/vscode/issues/45856 - const ConfiguredProject = tsserver.server.ConfiguredProject; - const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype; - ConfiguredProject.prototype.enablePluginsWithOptions = function() { - this.projectService.allowLocalPluginLoads = true; - return originalEnablePluginsWithOptions.apply(this, arguments); - }; - - // And here is the point where we hijack the VSCode <-> TS communications - // by adding ourselves in the middle. We locate everything that looks - // like an absolute path of ours and normalize it. - - const Session = tsserver.server.Session; - const {onMessage: originalOnMessage, send: originalSend} = Session.prototype; - let hostInfo = `unknown`; - - Object.assign(Session.prototype, { - onMessage(/** @type {string | object} */ message) { - const isStringMessage = typeof message === 'string'; - const parsedMessage = isStringMessage ? JSON.parse(message) : message; - - if ( - parsedMessage != null && - typeof parsedMessage === `object` && - parsedMessage.arguments && - typeof parsedMessage.arguments.hostInfo === `string` - ) { - hostInfo = parsedMessage.arguments.hostInfo; - if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) { - const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match( - // The RegExp from https://semver.org/ but without the caret at the start - /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ - ) ?? []).map(Number) - - if (major === 1) { - if (minor < 61) { - hostInfo += ` <1.61`; - } else if (minor < 66) { - hostInfo += ` <1.66`; - } else if (minor < 68) { - hostInfo += ` <1.68`; - } - } - } - } - - const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { - return typeof value === 'string' ? fromEditorPath(value) : value; - }); - - return originalOnMessage.call( - this, - isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) - ); - }, - - send(/** @type {any} */ msg) { - return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => { - return typeof value === `string` ? toEditorPath(value) : value; - }))); - } - }); - - return tsserver; -}; - -if (existsSync(absPnpApiPath)) { - if (!process.versions.pnp) { - // Setup the environment to be able to require typescript/lib/tsserverlibrary.js - require(absPnpApiPath).setup(); - } -} - -// Defer to the real typescript/lib/tsserverlibrary.js your application uses -module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`)); diff --git a/.yarn/sdks/typescript/lib/typescript.js b/.yarn/sdks/typescript/lib/typescript.js deleted file mode 100644 index b5f4db25bee67..0000000000000 --- a/.yarn/sdks/typescript/lib/typescript.js +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node - -const {existsSync} = require(`fs`); -const {createRequire} = require(`module`); -const {resolve} = require(`path`); - -const relPnpApiPath = "../../../../.pnp.cjs"; - -const absPnpApiPath = resolve(__dirname, relPnpApiPath); -const absRequire = createRequire(absPnpApiPath); - -if (existsSync(absPnpApiPath)) { - if (!process.versions.pnp) { - // Setup the environment to be able to require typescript - require(absPnpApiPath).setup(); - } -} - -// Defer to the real typescript your application uses -module.exports = absRequire(`typescript`); diff --git a/.yarn/sdks/typescript/package.json b/.yarn/sdks/typescript/package.json deleted file mode 100644 index d32f3913d7958..0000000000000 --- a/.yarn/sdks/typescript/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "typescript", - "version": "5.2.2-sdk", - "main": "./lib/typescript.js", - "type": "commonjs", - "bin": { - "tsc": "./bin/tsc", - "tsserver": "./bin/tsserver" - } -} diff --git a/.yarnrc.yml b/.yarnrc.yml index affb1c6d2fe9c..4f0f49b98d0a8 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -4,12 +4,15 @@ enableGlobalCache: false enableTelemetry: false -nodeLinker: pnp +nodeLinker: node-modules packageExtensions: - "@storybook/core-common@7.4.5": + '@storybook/core-common@7.4.5': dependencies: '@storybook/react-webpack5': 7.4.5 + 'croact-css-styled@1.1.9': + dependencies: + croact: 1.0.4 doctrine@3.0.0: dependencies: assert: 2.0.0 @@ -26,16 +29,20 @@ packageExtensions: react-simple-compat: 1.2.2 react-icons@2.2.7: peerDependencies: - prop-types: "*" + prop-types: '*' react-resizable@3.0.4: peerDependencies: react-dom: 17.0.1 + '@msagl/drawing@*': + dependencies: + queue-typescript: "^1.0.1" + "@esfx/collections-sortedmap": "^1.0.0" plugins: - path: .yarn/plugins/@yarnpkg/plugin-outdated.cjs - spec: "https://mskelton.dev/yarn-outdated/v2" + spec: 'https://mskelton.dev/yarn-outdated/v2' -yarnPath: .yarn/releases/yarn-4.0.0.cjs +yarnPath: .yarn/releases/yarn-4.1.0.cjs # Uncomment the following lines if you want to use Verdaccio local npm registry. Read more at packages/README.md # npmScopes: # grafana: diff --git a/CHANGELOG.md b/CHANGELOG.md index 58412f83ff04b..9482a70d17aaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,403 @@ +<!-- 10.4.0 START --> + +# 10.4.0 (2024-03-06) + +### Features and enhancements + +- **AuthToken:** Remove client token rotation feature toggle. [#82886](https://github.com/grafana/grafana/issues/82886), [@kalleep](https://github.com/kalleep) +- **Plugins:** Enable feature toggle angularDeprecationUI by default. [#82880](https://github.com/grafana/grafana/issues/82880), [@xnyo](https://github.com/xnyo) +- **Table Component:** Improve text-wrapping behavior of cells. [#82872](https://github.com/grafana/grafana/issues/82872), [@ahuarte47](https://github.com/ahuarte47) +- **Alerting:** Dry-run legacy upgrade on startup. [#82835](https://github.com/grafana/grafana/issues/82835), [@JacobsonMT](https://github.com/JacobsonMT) +- **Tempo:** Upgrade @grafana/lezer-traceql patch version to use trace metrics syntax. [#82532](https://github.com/grafana/grafana/issues/82532), [@joey-grafana](https://github.com/joey-grafana) +- **Logs Panel:** Add CSV to download options. [#82480](https://github.com/grafana/grafana/issues/82480), [@gtk-grafana](https://github.com/gtk-grafana) +- **Folders:** Switch order of the columns in folder table indexes so that org_id becomes first. [#82454](https://github.com/grafana/grafana/issues/82454), [@papagian](https://github.com/papagian) +- **Logs panel:** Table UI - Guess string field types. [#82397](https://github.com/grafana/grafana/issues/82397), [@gtk-grafana](https://github.com/gtk-grafana) +- **Alerting:** Send alerts to APIv2 when using the Alertmanager contact point. [#82373](https://github.com/grafana/grafana/issues/82373), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Alerting:** Emit warning when creating or updating unusually large groups. [#82279](https://github.com/grafana/grafana/issues/82279), [@alexweav](https://github.com/alexweav) +- **Keybindings:** Change 'h' to 'mod+h' to open help modal. [#82253](https://github.com/grafana/grafana/issues/82253), [@tskarhed](https://github.com/tskarhed) +- **Chore:** Update arrow and prometheus dependencies. [#82215](https://github.com/grafana/grafana/issues/82215), [@ryantxu](https://github.com/ryantxu) +- **Alerting:** Enable group-level rule evaluation jittering by default, remove feature toggle. [#82212](https://github.com/grafana/grafana/issues/82212), [@alexweav](https://github.com/alexweav) +- **Loki Log Context:** Always show label filters with at least one parsed label. [#82211](https://github.com/grafana/grafana/issues/82211), [@svennergr](https://github.com/svennergr) +- **Logs Panel:** Table UI - better default column spacing. [#82205](https://github.com/grafana/grafana/issues/82205), [@gtk-grafana](https://github.com/gtk-grafana) +- **RBAC:** Migration to remove the scope from permissions where action is alert.instances:read. [#82202](https://github.com/grafana/grafana/issues/82202), [@IevaVasiljeva](https://github.com/IevaVasiljeva) +- **JWT Authentication:** Add support for specifying groups in auth.jwt for teamsync. [#82175](https://github.com/grafana/grafana/issues/82175), [@Jguer](https://github.com/Jguer) +- **Alerting:** GA alertingPreviewUpgrade and enable by default. [#82038](https://github.com/grafana/grafana/issues/82038), [@JacobsonMT](https://github.com/JacobsonMT) +- **Elasticsearch:** Apply ad-hoc filters to annotation queries. [#82032](https://github.com/grafana/grafana/issues/82032), [@mikelv92](https://github.com/mikelv92) +- **Alerting:** Show legacy provisioned alert rules warning. [#81902](https://github.com/grafana/grafana/issues/81902), [@gillesdemey](https://github.com/gillesdemey) +- **Tempo:** Support TraceQL metrics queries. [#81886](https://github.com/grafana/grafana/issues/81886), [@adrapereira](https://github.com/adrapereira) +- **Tempo:** Support backtick strings. [#81802](https://github.com/grafana/grafana/issues/81802), [@fabrizio-grafana](https://github.com/fabrizio-grafana) +- **Dashboards:** Remove `advancedDataSourcePicker` feature toggle. [#81790](https://github.com/grafana/grafana/issues/81790), [@Sergej-Vlasov](https://github.com/Sergej-Vlasov) +- **CloudWatch:** Remove references to pkg/infra/metrics. [#81744](https://github.com/grafana/grafana/issues/81744), [@iwysiu](https://github.com/iwysiu) +- **Licensing:** Redact license when overriden by env variable. [#81726](https://github.com/grafana/grafana/issues/81726), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **Explore:** Disable cursor sync. [#81698](https://github.com/grafana/grafana/issues/81698), [@ifrost](https://github.com/ifrost) +- **Tempo:** Add custom headers middleware for grpc client. [#81693](https://github.com/grafana/grafana/issues/81693), [@aocenas](https://github.com/aocenas) +- **Chore:** Update test database initialization. [#81673](https://github.com/grafana/grafana/issues/81673), [@DanCech](https://github.com/DanCech) +- **Elasticsearch:** Implement CheckHealth method in the backend. [#81671](https://github.com/grafana/grafana/issues/81671), [@mikelv92](https://github.com/mikelv92) +- **Tooltips:** Hide dimension configuration when tooltip mode is hidden. [#81627](https://github.com/grafana/grafana/issues/81627), [@codeincarnate](https://github.com/codeincarnate) +- **Alerting:** Show warning when cp does not exist and invalidate the form. [#81621](https://github.com/grafana/grafana/issues/81621), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **User:** Add uid colum to user table. [#81615](https://github.com/grafana/grafana/issues/81615), [@ryantxu](https://github.com/ryantxu) +- **Cloudwatch:** Remove core imports from infra/log. [#81543](https://github.com/grafana/grafana/issues/81543), [@njvrzm](https://github.com/njvrzm) +- **Alerting:** Add pagination and improved search for notification policies. [#81535](https://github.com/grafana/grafana/issues/81535), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Alerting:** Move action buttons in the alert list view. [#81341](https://github.com/grafana/grafana/issues/81341), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Grafana/ui:** Add deprecation notices to the legacy layout components. [#81328](https://github.com/grafana/grafana/issues/81328), [@Clarity-89](https://github.com/Clarity-89) +- **Cloudwatch:** Deprecate cloudwatchNewRegionsHandler feature toggle and remove core imports from featuremgmt. [#81310](https://github.com/grafana/grafana/issues/81310), [@njvrzm](https://github.com/njvrzm) +- **Candlestick:** Add tooltip options. [#81307](https://github.com/grafana/grafana/issues/81307), [@adela-almasan](https://github.com/adela-almasan) +- **Folders:** Forbid performing operations on folders via dashboards HTTP API. [#81264](https://github.com/grafana/grafana/issues/81264), [@undef1nd](https://github.com/undef1nd) +- **Feature Management:** Move awsDatasourcesNewFormStyling to Public Preview. [#81257](https://github.com/grafana/grafana/issues/81257), [@idastambuk](https://github.com/idastambuk) +- **Alerting:** Update API to use folders' full paths. [#81214](https://github.com/grafana/grafana/issues/81214), [@yuri-tceretian](https://github.com/yuri-tceretian) +- **Datasources:** Add concurrency number to the settings. [#81212](https://github.com/grafana/grafana/issues/81212), [@itsmylife](https://github.com/itsmylife) +- **CloudWatch:** Remove dependencies on grafana/pkg/setting. [#81208](https://github.com/grafana/grafana/issues/81208), [@iwysiu](https://github.com/iwysiu) +- **Logs:** Table UI - Allow users to resize field selection section. [#81201](https://github.com/grafana/grafana/issues/81201), [@gtk-grafana](https://github.com/gtk-grafana) +- **Dashboards:** Remove emptyDashboardPage feature flag. [#81188](https://github.com/grafana/grafana/issues/81188), [@Sergej-Vlasov](https://github.com/Sergej-Vlasov) +- **Cloudwatch:** Import httpClient from grafana-plugin-sdk-go instead of grafana/infra. [#81187](https://github.com/grafana/grafana/issues/81187), [@idastambuk](https://github.com/idastambuk) +- **Logs:** Table UI - Enable feature flag by default (GA). [#81185](https://github.com/grafana/grafana/issues/81185), [@gtk-grafana](https://github.com/gtk-grafana) +- **Tempo:** Improve tags UX. [#81166](https://github.com/grafana/grafana/issues/81166), [@joey-grafana](https://github.com/joey-grafana) +- **Table:** Cell inspector auto-detecting JSON. [#81152](https://github.com/grafana/grafana/issues/81152), [@gtk-grafana](https://github.com/gtk-grafana) +- **Grafana/ui:** Add Space component. [#81145](https://github.com/grafana/grafana/issues/81145), [@Clarity-89](https://github.com/Clarity-89) +- **Grafana/ui:** Add deprecation notice to the Form component. [#81068](https://github.com/grafana/grafana/issues/81068), [@Clarity-89](https://github.com/Clarity-89) +- **Alerting:** Swap order between Annotations and Labels step in the alert rule form. [#81060](https://github.com/grafana/grafana/issues/81060), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Plugins:** Change managedPluginsInstall to public preview. [#81053](https://github.com/grafana/grafana/issues/81053), [@oshirohugo](https://github.com/oshirohugo) +- **Tempo:** Add span, trace vars to trace to metrics interpolation. [#81046](https://github.com/grafana/grafana/issues/81046), [@joey-grafana](https://github.com/joey-grafana) +- **Tempo:** Support multiple filter expressions for service graph queries. [#81037](https://github.com/grafana/grafana/issues/81037), [@domasx2](https://github.com/domasx2) +- **Alerting:** Support for simplified notification settings in rule API. [#81011](https://github.com/grafana/grafana/issues/81011), [@yuri-tceretian](https://github.com/yuri-tceretian) +- **Plugins:** Add fuzzy search to plugins catalogue. [#81001](https://github.com/grafana/grafana/issues/81001), [@Ukochka](https://github.com/Ukochka) +- **CloudWatch:** Only override contextDialer when using PDC. [#80992](https://github.com/grafana/grafana/issues/80992), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **Alerting:** Add a feature flag to periodically save states. [#80987](https://github.com/grafana/grafana/issues/80987), [@JohnnyQQQQ](https://github.com/JohnnyQQQQ) +- **RBAC:** Return the underlying error instead of internal server or bad request for managed permission endpoints. [#80974](https://github.com/grafana/grafana/issues/80974), [@IevaVasiljeva](https://github.com/IevaVasiljeva) +- **Correlations:** Enable correlations feature toggle by default. [#80881](https://github.com/grafana/grafana/issues/80881), [@ifrost](https://github.com/ifrost) +- **Transformations:** Focus search input on drawer open. [#80859](https://github.com/grafana/grafana/issues/80859), [@codeincarnate](https://github.com/codeincarnate) +- **Packaging:** Use the GRAFANA_HOME variable in postinst script on Debian. [#80853](https://github.com/grafana/grafana/issues/80853), [@denisse-dev](https://github.com/denisse-dev) +- **Visualizations:** Hue gradient mode now applies to the line color . [#80805](https://github.com/grafana/grafana/issues/80805), [@torkelo](https://github.com/torkelo) +- **Drawer:** Resizable via draggable edge . [#80796](https://github.com/grafana/grafana/issues/80796), [@torkelo](https://github.com/torkelo) +- **Alerting:** Add setting to distribute rule group evaluations over time. [#80766](https://github.com/grafana/grafana/issues/80766), [@alexweav](https://github.com/alexweav) +- **Logs Panel:** Permalink (copy shortlink). [#80764](https://github.com/grafana/grafana/issues/80764), [@gtk-grafana](https://github.com/gtk-grafana) +- **VizTooltips:** Copy to clipboard functionality. [#80761](https://github.com/grafana/grafana/issues/80761), [@adela-almasan](https://github.com/adela-almasan) +- **AuthN:** Support reloading SSO config after the sso settings have changed. [#80734](https://github.com/grafana/grafana/issues/80734), [@mgyongyosi](https://github.com/mgyongyosi) +- **Logs Panel:** Add total count to logs volume panel in explore. [#80730](https://github.com/grafana/grafana/issues/80730), [@gtk-grafana](https://github.com/gtk-grafana) +- **Caching:** Remove useCachingService feature toggle. [#80695](https://github.com/grafana/grafana/issues/80695), [@mmandrus](https://github.com/mmandrus) +- **Table:** Support showing data links inline. . [#80691](https://github.com/grafana/grafana/issues/80691), [@ryantxu](https://github.com/ryantxu) +- **Storage:** Add support for sortBy selector. [#80680](https://github.com/grafana/grafana/issues/80680), [@DanCech](https://github.com/DanCech) +- **Alerting:** Add metric counting rule groups per org. [#80669](https://github.com/grafana/grafana/issues/80669), [@alexweav](https://github.com/alexweav) +- **RBAC:** Cover plugin routes. [#80578](https://github.com/grafana/grafana/issues/80578), [@gamab](https://github.com/gamab) +- **Profiling:** Import godeltaprof/http/pprof. [#80509](https://github.com/grafana/grafana/issues/80509), [@korniltsev](https://github.com/korniltsev) +- **Tempo:** Add warning message when scope missing in TraceQL. [#80472](https://github.com/grafana/grafana/issues/80472), [@joey-grafana](https://github.com/joey-grafana) +- **Cloudwatch:** Move getNextRefIdChar util from app/core/utils to @grafana/data. [#80471](https://github.com/grafana/grafana/issues/80471), [@idastambuk](https://github.com/idastambuk) +- **DataFrame:** Add optional unique id definition. [#80428](https://github.com/grafana/grafana/issues/80428), [@aocenas](https://github.com/aocenas) +- **Canvas:** Add element snapping and alignment. [#80407](https://github.com/grafana/grafana/issues/80407), [@nmarrs](https://github.com/nmarrs) +- **Logs:** Add show context to dashboard panel. [#80403](https://github.com/grafana/grafana/issues/80403), [@svennergr](https://github.com/svennergr) +- **Canvas:** Support context menu in panel edit mode. [#80335](https://github.com/grafana/grafana/issues/80335), [@nmarrs](https://github.com/nmarrs) +- **VizTooltip:** Add sizing options. [#80306](https://github.com/grafana/grafana/issues/80306), [@Develer](https://github.com/Develer) +- **Plugins:** Parse defaultValues correctly for nested options. [#80302](https://github.com/grafana/grafana/issues/80302), [@oshirohugo](https://github.com/oshirohugo) +- **Geomap:** Support geojson styling properties. [#80272](https://github.com/grafana/grafana/issues/80272), [@drew08t](https://github.com/drew08t) +- **Runtime:** Add property for disabling caching. [#80245](https://github.com/grafana/grafana/issues/80245), [@aangelisc](https://github.com/aangelisc) +- **Alerting:** Log scheduler maxAttempts, guard against invalid retry counts, log retry errors. [#80234](https://github.com/grafana/grafana/issues/80234), [@alexweav](https://github.com/alexweav) +- **Alerting:** Improve integration with dashboards. [#80201](https://github.com/grafana/grafana/issues/80201), [@konrad147](https://github.com/konrad147) +- **Transformations:** Use an explicit join seperator when converting from an array to string field. [#80169](https://github.com/grafana/grafana/issues/80169), [@ryantxu](https://github.com/ryantxu) +- **Build:** Update plugin IDs list in build and release process. [#80160](https://github.com/grafana/grafana/issues/80160), [@fabrizio-grafana](https://github.com/fabrizio-grafana) +- **NestedFolders:** Support Shared with me folder for showing items you've been granted access to. [#80141](https://github.com/grafana/grafana/issues/80141), [@joshhunt](https://github.com/joshhunt) +- **Log Context:** Add highlighted words to log rows. [#80119](https://github.com/grafana/grafana/issues/80119), [@svennergr](https://github.com/svennergr) +- **Tempo:** Add `}` when `{` is inserted automatically. [#80113](https://github.com/grafana/grafana/issues/80113), [@harrymaurya05](https://github.com/harrymaurya05) +- **Time Range:** Copy-paste Time Range. [#80107](https://github.com/grafana/grafana/issues/80107), [@harisrozajac](https://github.com/harisrozajac) +- **PanelContext:** Remove deprecated onSplitOpen. [#80087](https://github.com/grafana/grafana/issues/80087), [@harisrozajac](https://github.com/harisrozajac) +- **Docs:** Add HAProxy rewrite information considering `serve_from_sub_path` setting. [#80062](https://github.com/grafana/grafana/issues/80062), [@simPod](https://github.com/simPod) +- **Table:** Keep expanded rows persistent when data changes if it has unique ID. [#80031](https://github.com/grafana/grafana/issues/80031), [@aocenas](https://github.com/aocenas) +- **SSO Config:** Add generic OAuth. [#79972](https://github.com/grafana/grafana/issues/79972), [@Clarity-89](https://github.com/Clarity-89) +- **FeatureFlags:** Remove the unsupported/undocumented option to read flags from a file. [#79959](https://github.com/grafana/grafana/issues/79959), [@ryantxu](https://github.com/ryantxu) +- **Transformations:** Add Group to Nested Tables Transformation. [#79952](https://github.com/grafana/grafana/issues/79952), [@codeincarnate](https://github.com/codeincarnate) +- **Cloudwatch Metrics:** Adjust error handling. [#79911](https://github.com/grafana/grafana/issues/79911), [@idastambuk](https://github.com/idastambuk) +- **Tempo:** Decouple Tempo from Grafana core. [#79888](https://github.com/grafana/grafana/issues/79888), [@fabrizio-grafana](https://github.com/fabrizio-grafana) +- **Table Panel:** Filter column values with operators or expressions. [#79853](https://github.com/grafana/grafana/issues/79853), [@ahuarte47](https://github.com/ahuarte47) +- **Chore:** Generate shorter UIDs. [#79843](https://github.com/grafana/grafana/issues/79843), [@ryantxu](https://github.com/ryantxu) +- **Alerting:** MuteTiming service return errutil + GetTiming by name. [#79772](https://github.com/grafana/grafana/issues/79772), [@yuri-tceretian](https://github.com/yuri-tceretian) +- **Azure Monitor:** Add select all subscription option for ARG queries. [#79582](https://github.com/grafana/grafana/issues/79582), [@alyssabull](https://github.com/alyssabull) +- **Alerting:** Enable sending notifications to a specific topic on Telegram. [#79546](https://github.com/grafana/grafana/issues/79546), [@th0th](https://github.com/th0th) +- **Logs Panel:** Table UI - Reordering table columns via drag-and-drop. [#79536](https://github.com/grafana/grafana/issues/79536), [@gtk-grafana](https://github.com/gtk-grafana) +- **Cloudwatch:** Add AWS/EMRServerless and AWS/KafkaConnect Metrics . [#79532](https://github.com/grafana/grafana/issues/79532), [@DugeraProve](https://github.com/DugeraProve) +- **Transformations:** Move transformation help to drawer component. [#79247](https://github.com/grafana/grafana/issues/79247), [@codeincarnate](https://github.com/codeincarnate) +- **Stat:** Support no value in spark line. [#78986](https://github.com/grafana/grafana/issues/78986), [@FOWind](https://github.com/FOWind) +- **NodeGraph:** Use layered layout instead of force based layout. [#78957](https://github.com/grafana/grafana/issues/78957), [@aocenas](https://github.com/aocenas) +- **Alerting:** Create alertingQueryOptimization feature flag for alert query optimization. [#78932](https://github.com/grafana/grafana/issues/78932), [@JacobsonMT](https://github.com/JacobsonMT) +- **Dashboard:** New EmbeddedDashboard runtime component . [#78916](https://github.com/grafana/grafana/issues/78916), [@torkelo](https://github.com/torkelo) +- **Alerting:** Show warning when query optimized. [#78751](https://github.com/grafana/grafana/issues/78751), [@JacobsonMT](https://github.com/JacobsonMT) +- **Alerting:** Add support for TTL for pushover for Mimir Alertmanager. [#78687](https://github.com/grafana/grafana/issues/78687), [@gillesdemey](https://github.com/gillesdemey) +- **Grafana/ui:** Enable removing values in multiselect opened state. [#78662](https://github.com/grafana/grafana/issues/78662), [@FOWind](https://github.com/FOWind) +- **SQL datasources:** Consistent interval handling. [#78517](https://github.com/grafana/grafana/issues/78517), [@gabor](https://github.com/gabor) +- **Alerting:** During legacy migration reduce the number of created silences. [#78505](https://github.com/grafana/grafana/issues/78505), [@JacobsonMT](https://github.com/JacobsonMT) +- **UI:** New share button and toolbar reorganize. [#77563](https://github.com/grafana/grafana/issues/77563), [@evictorero](https://github.com/evictorero) +- **Alerting:** Update rule API to address folders by UID. [#74600](https://github.com/grafana/grafana/issues/74600), [@papagian](https://github.com/papagian) +- **Reports:** Add uid column to the database. (Enterprise) +- **Plugins:** Add metrics for cloud plugin install. (Enterprise) +- **RBAC:** Make seeding resilient to failed plugin loading. (Enterprise) +- **Plugins:** Support disabling caching at a plugin instance level. (Enterprise) + +### Bug fixes + +- **Auth:** Fix email verification bypass when using basic authentication. [#82914](https://github.com/grafana/grafana/issues/82914), [@volcanonoodle](https://github.com/volcanonoodle) +- **LibraryPanels/RBAC:** Fix issue where folder scopes weren't being correctly inherited. [#82700](https://github.com/grafana/grafana/issues/82700), [@kaydelaney](https://github.com/kaydelaney) +- **Table Panel:** Fix display of ad-hoc filter actions. [#82442](https://github.com/grafana/grafana/issues/82442), [@codeincarnate](https://github.com/codeincarnate) +- **Loki:** Update `@grafana/lezer-logql` to `0.2.3` containing fix for ip label name. [#82378](https://github.com/grafana/grafana/issues/82378), [@ivanahuckova](https://github.com/ivanahuckova) +- **Alerting:** Fix slack double pound and email summary. [#82333](https://github.com/grafana/grafana/issues/82333), [@gillesdemey](https://github.com/gillesdemey) +- **Elasticsearch:** Fix resource calls for paths that include `:`. [#82327](https://github.com/grafana/grafana/issues/82327), [@ivanahuckova](https://github.com/ivanahuckova) +- **Alerting:** Return provenance of notification templates. [#82274](https://github.com/grafana/grafana/issues/82274), [@julienduchesne](https://github.com/julienduchesne) +- **LibraryPanels:** Fix issue with repeated library panels. [#82255](https://github.com/grafana/grafana/issues/82255), [@kaydelaney](https://github.com/kaydelaney) +- **Loki:** Fix fetching of values for label if no previous equality operator. [#82251](https://github.com/grafana/grafana/issues/82251), [@ivanahuckova](https://github.com/ivanahuckova) +- **Alerting:** Fix data races and improve testing. [#81994](https://github.com/grafana/grafana/issues/81994), [@diegommm](https://github.com/diegommm) +- **chore:** Fix typo in GraphTresholdsStyleMode enum. [#81960](https://github.com/grafana/grafana/issues/81960), [@paulJonesCalian](https://github.com/paulJonesCalian) +- **CloudWatch:** Fix code editor not resizing on mount when content height is > 200px. [#81911](https://github.com/grafana/grafana/issues/81911), [@kevinwcyu](https://github.com/kevinwcyu) +- **FieldOptions:** Revert scalable unit option as we already support this via custom prefix/suffixes . [#81893](https://github.com/grafana/grafana/issues/81893), [@torkelo](https://github.com/torkelo) +- **Browse Dashboards:** Imported dashboards now display immediately in the dashboard list. [#81819](https://github.com/grafana/grafana/issues/81819), [@ashharrison90](https://github.com/ashharrison90) +- **Elasticsearch:** Set middlewares from Grafana's `httpClientProvider`. [#81814](https://github.com/grafana/grafana/issues/81814), [@svennergr](https://github.com/svennergr) +- **Folders:** Fix failure to update folder in SQLite. [#81795](https://github.com/grafana/grafana/issues/81795), [@papagian](https://github.com/papagian) +- **Plugins:** Never disable add new data source for core plugins. [#81774](https://github.com/grafana/grafana/issues/81774), [@oshirohugo](https://github.com/oshirohugo) +- **Alerting:** Fixes for pending period. [#81718](https://github.com/grafana/grafana/issues/81718), [@gillesdemey](https://github.com/gillesdemey) +- **Alerting:** Fix editing group of nested folder. [#81665](https://github.com/grafana/grafana/issues/81665), [@gillesdemey](https://github.com/gillesdemey) +- **Plugins:** Don't auto prepend app sub url to plugin asset paths. [#81658](https://github.com/grafana/grafana/issues/81658), [@wbrowne](https://github.com/wbrowne) +- **Alerting:** Fix inconsistent AM raw config when applied via sync vs API. [#81655](https://github.com/grafana/grafana/issues/81655), [@JacobsonMT](https://github.com/JacobsonMT) +- **Alerting:** Fix support check for export with modifications. [#81602](https://github.com/grafana/grafana/issues/81602), [@gillesdemey](https://github.com/gillesdemey) +- **Alerting:** Fix selecting empty contact point value for notification policy inheritance. [#81482](https://github.com/grafana/grafana/issues/81482), [@gillesdemey](https://github.com/gillesdemey) +- **Alerting:** Fix child provisioned polices not being rendered as provisioned. [#81449](https://github.com/grafana/grafana/issues/81449), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Tempo:** Fix durations in TraceQL. [#81418](https://github.com/grafana/grafana/issues/81418), [@fabrizio-grafana](https://github.com/fabrizio-grafana) +- **Logs:** Fix toggleable filters to be applied for specified query. [#81368](https://github.com/grafana/grafana/issues/81368), [@ivanahuckova](https://github.com/ivanahuckova) +- **Loki:** Fix label not being added to all subexpressions. [#81360](https://github.com/grafana/grafana/issues/81360), [@svennergr](https://github.com/svennergr) +- **Loki/Elastic:** Assert queryfix value to always be string. [#81349](https://github.com/grafana/grafana/issues/81349), [@svennergr](https://github.com/svennergr) +- **Tempo:** Add query ref in the query editor. [#81343](https://github.com/grafana/grafana/issues/81343), [@joey-grafana](https://github.com/joey-grafana) +- **Transformations:** Use the display name of the original y field for the predicted field of the regression analysis transformation. [#81332](https://github.com/grafana/grafana/issues/81332), [@oscarkilhed](https://github.com/oscarkilhed) +- **Field:** Fix perf regression in getUniqueFieldName(). [#81323](https://github.com/grafana/grafana/issues/81323), [@leeoniya](https://github.com/leeoniya) +- **Alerting:** Fix scheduler to group folders by the unique key (orgID and UID). [#81303](https://github.com/grafana/grafana/issues/81303), [@yuri-tceretian](https://github.com/yuri-tceretian) +- **Alerting:** Fix migration edge-case race condition for silences. [#81206](https://github.com/grafana/grafana/issues/81206), [@JacobsonMT](https://github.com/JacobsonMT) +- **Explore:** Set default time range to now-1h. [#81135](https://github.com/grafana/grafana/issues/81135), [@ifrost](https://github.com/ifrost) +- **Elasticsearch:** Fix URL creation and allowlist for `/_mapping` requests. [#80970](https://github.com/grafana/grafana/issues/80970), [@svennergr](https://github.com/svennergr) +- **Postgres:** Handle single quotes in table names in the query editor. [#80951](https://github.com/grafana/grafana/issues/80951), [@gabor](https://github.com/gabor) +- **Folders:** Fix creating/updating a folder whose title has leading and trailing spaces. [#80909](https://github.com/grafana/grafana/issues/80909), [@papagian](https://github.com/papagian) +- **Loki:** Fix missing timerange in query builder values request. [#80829](https://github.com/grafana/grafana/issues/80829), [@svennergr](https://github.com/svennergr) +- **Elasticsearch:** Fix showing of logs when `__source` is log message field. [#80804](https://github.com/grafana/grafana/issues/80804), [@ivanahuckova](https://github.com/ivanahuckova) +- **Pyroscope:** Fix stale value for query in query editor. [#80753](https://github.com/grafana/grafana/issues/80753), [@joey-grafana](https://github.com/joey-grafana) +- **Stat:** Fix data links that refer to fields. [#80693](https://github.com/grafana/grafana/issues/80693), [@ajwerner](https://github.com/ajwerner) +- **RBAC:** Clean up data source permissions after data source deletion. [#80654](https://github.com/grafana/grafana/issues/80654), [@IevaVasiljeva](https://github.com/IevaVasiljeva) +- **Alerting:** Fix MuteTiming Get API to return provenance status. [#80494](https://github.com/grafana/grafana/issues/80494), [@yuri-tceretian](https://github.com/yuri-tceretian) +- **Tempo:** Fix regression caused by #79938. [#80465](https://github.com/grafana/grafana/issues/80465), [@fabrizio-grafana](https://github.com/fabrizio-grafana) +- **Alerting:** Fix preview getting the correct queries from the form. [#80458](https://github.com/grafana/grafana/issues/80458), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Alerting:** Fix firing alerts title when showing active in Insights panel. [#80414](https://github.com/grafana/grafana/issues/80414), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Postgres:** Fix enabling the socks proxy. [#80361](https://github.com/grafana/grafana/issues/80361), [@gabor](https://github.com/gabor) +- **Alerting:** Fix group filter. [#80358](https://github.com/grafana/grafana/issues/80358), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Alerting:** Increase size of kvstore value type for MySQL to LONGTEXT. [#80331](https://github.com/grafana/grafana/issues/80331), [@JacobsonMT](https://github.com/JacobsonMT) +- **Annotations:** Split cleanup into separate queries and deletes to avoid deadlocks on MySQL. [#80329](https://github.com/grafana/grafana/issues/80329), [@alexweav](https://github.com/alexweav) +- **Loki:** Fix bug duplicating parsed labels across multiple log lines. [#80292](https://github.com/grafana/grafana/issues/80292), [@svennergr](https://github.com/svennergr) +- **Alerting:** Fix NoData & Error alerts not resolving when rule is reset. [#80184](https://github.com/grafana/grafana/issues/80184), [@JacobsonMT](https://github.com/JacobsonMT) +- **Loki:** Fix metric time splitting to split starting with the start time. [#80085](https://github.com/grafana/grafana/issues/80085), [@svennergr](https://github.com/svennergr) +- **Rendering:** Fix streaming panels always reaching timeout. [#80022](https://github.com/grafana/grafana/issues/80022), [@AgnesToulet](https://github.com/AgnesToulet) +- **Plugins:** Fix colon in CallResource URL returning an error when creating plugin resource request. [#79746](https://github.com/grafana/grafana/issues/79746), [@GiedriusS](https://github.com/GiedriusS) +- **PDF:** Fix initialization when SMTP is disabled. (Enterprise) +- **PDF:** Fix repeated panels placement issue. (Enterprise) +- **Report CSV:** Fix timeout with streaming panels. (Enterprise) +- **RBAC:** Avoid repopulating removed basic role permissions if the permission scope has changed. (Enterprise) + +### Breaking changes + +We're adding a between the response of the ID token HD parameter and the list of allowed domains. This feature can be disabled through the configuration toggle `validate_hd `. Anyone using the legacy Google OAuth configuration should disable this validation if the ID Token response doesn't have the HD parameter. Issue [#83726](https://github.com/grafana/grafana/issues/83726) + +If you use an automated provisioning (eg, Terraform) for custom roles, and have provisioned a role that includes permission with action `alert.instances:read` and some scope, you will need to update the permission in your provisioning files by removing the scope. Issue [#82202](https://github.com/grafana/grafana/issues/82202) + +**The following breaking change occurs only when feature flag `nestedFolders` is enabled.** +If the folder title contains the symbol `/` (forward-slash) the notifications created from the rules that are placed in that folder will contain an escape sequence for that symbol in the label `grafana_folder`. +For example, the folder title is `Grafana / Folder`. Currently the label `grafana_folder` will contain the title as it is. If PR is merged - the label value will be `Grafana \/ Folder`. +This can break notifications if notification policies have matches that match that label and folder. Issue [#81214](https://github.com/grafana/grafana/issues/81214) + +`PanelContext.onSplitOpen` is removed. In the context of Explore, plugins should use `field.getLinks` to get a list of data link models. Issue [#80087](https://github.com/grafana/grafana/issues/80087) + +The unstable alert rule API has been changed and now expects a folder UID instead of the folder title as namespace path parameter. +I addition to this, the responses that used to return the folder title now return `<folder parent UID>/<folder title>` to uniquely identify them. +Any consumers of the specific API should be appropriately adapted. Issue [#74600](https://github.com/grafana/grafana/issues/74600) + +### Plugin development fixes & changes + +- **Grafana/UI:** Add new Splitter component . [#82357](https://github.com/grafana/grafana/issues/82357), [@torkelo](https://github.com/torkelo) + +<!-- 10.4.0 END --> +<!-- 10.3.4 START --> + +# 10.3.4 (2024-03-06) + +### Features and enhancements + +- **Chore:** Improve domain validation for Google OAuth - Backport 83229 to v10.3.x. [#83725](https://github.com/grafana/grafana/issues/83725), [@linoman](https://github.com/linoman) + +### Bug fixes + +- **LDAP:** Fix LDAP users authenticated via auth proxy not being able to use LDAP active sync. [#83750](https://github.com/grafana/grafana/issues/83750), [@Jguer](https://github.com/Jguer) +- **Tempo:** Add template variable interpolation for filters (#83213). [#83706](https://github.com/grafana/grafana/issues/83706), [@joey-grafana](https://github.com/joey-grafana) +- **Elasticsearch:** Fix adhoc filters not applied in frontend mode. [#83596](https://github.com/grafana/grafana/issues/83596), [@svennergr](https://github.com/svennergr) +- **Dashboards:** Fixes issue where panels would not refresh if time range updated while in panel view mode. [#83525](https://github.com/grafana/grafana/issues/83525), [@kaydelaney](https://github.com/kaydelaney) +- **Auth:** Fix email verification bypass when using basic authentication. [#83484](https://github.com/grafana/grafana/issues/83484) +- **AuthProxy:** Invalidate previous cached item for user when changes are made to any header. [#83203](https://github.com/grafana/grafana/issues/83203), [@klesh](https://github.com/klesh) +- **LibraryPanels/RBAC:** Fix issue where folder scopes weren't being correctly inherited. [#82902](https://github.com/grafana/grafana/issues/82902), [@kaydelaney](https://github.com/kaydelaney) +- **LibraryPanels:** Fix issue with repeated library panels. [#82259](https://github.com/grafana/grafana/issues/82259), [@kaydelaney](https://github.com/kaydelaney) +- **Plugins:** Don't auto prepend app sub url to plugin asset paths. [#82147](https://github.com/grafana/grafana/issues/82147), [@wbrowne](https://github.com/wbrowne) +- **Elasticsearch:** Set middlewares from Grafana's `httpClientProvider`. [#81929](https://github.com/grafana/grafana/issues/81929), [@svennergr](https://github.com/svennergr) +- **Folders:** Fix failure to update folder in SQLite. [#81862](https://github.com/grafana/grafana/issues/81862), [@papagian](https://github.com/papagian) +- **Loki/Elastic:** Assert queryfix value to always be string. [#81463](https://github.com/grafana/grafana/issues/81463), [@svennergr](https://github.com/svennergr) + +### Breaking changes + +We're adding a between the response of the ID token HD parameter and the list of allowed domains. This feature can be disabled through the configuration toggle `validate_hd `. Anyone using the legacy Google OAuth configuration should disable this validation if the ID Token response doesn't have the HD parameter. Issue [#83725](https://github.com/grafana/grafana/issues/83725) + +<!-- 10.3.4 END --> +<!-- 10.3.3 START --> + +# 10.3.3 (2024-02-02) + +### Bug fixes + +- **Elasticsearch:** Fix creating of legend so it is backward compatible with frontend produced frames. [#81786](https://github.com/grafana/grafana/issues/81786), [@ivanahuckova](https://github.com/ivanahuckova) +- **ShareModal:** Fixes url sync issue that caused issue with save drawer. [#81721](https://github.com/grafana/grafana/issues/81721), [@ivanortegaalba](https://github.com/ivanortegaalba) + +<!-- 10.3.3 END --> +<!-- 10.3.1 START --> + +# 10.3.1 (2024-01-22) + +To resolve a technical issue within the Grafana release package management process, we are releasing both Grafana 10.3.0 and Grafana 10.3.1 simultaneously. The 10.3.1 release contains no breaking or functional changes from 10.3.0. Please refer to the [What’s New](https://grafana.com/docs/grafana/latest/whatsnew/whats-new-in-v10-3/) post for Grafana 10.3.0 for details on new features and changes in this release. + +<!-- 10.3.1 END --> +<!-- 10.3.0 START --> + +# 10.3.0 (2024-01-22) + +To resolve a technical issue within the Grafana release package management process, we are releasing both Grafana 10.3.0 and Grafana 10.3.1 simultaneously. The 10.3.1 release contains no breaking or functional changes from 10.3.0. Please refer to the [What’s New](https://grafana.com/docs/grafana/latest/whatsnew/whats-new-in-v10-3/) post for Grafana 10.3.0 for details on new features and changes in this release. + +### Features and enhancements + +- **Alerting:** Guided legacy alerting upgrade dry-run. [#80071](https://github.com/grafana/grafana/issues/80071), [@JacobsonMT](https://github.com/JacobsonMT) +- **Explore:** Preserve time range when creating a dashboard panel from Explore. [#80070](https://github.com/grafana/grafana/issues/80070), [@Elfo404](https://github.com/Elfo404) +- **Explore:** Init with mixed DS if there's no root DS in the URL and queries have multiple datasources. [#80068](https://github.com/grafana/grafana/issues/80068), [@Elfo404](https://github.com/Elfo404) +- **QueryEditor:** Display error even if error field is empty. [#79943](https://github.com/grafana/grafana/issues/79943), [@idastambuk](https://github.com/idastambuk) +- **K8s:** Enable api-server by default. [#79942](https://github.com/grafana/grafana/issues/79942), [@ryantxu](https://github.com/ryantxu) +- **Parca:** Add standalone building configuration. [#79896](https://github.com/grafana/grafana/issues/79896), [@fabrizio-grafana](https://github.com/fabrizio-grafana) +- **Auth:** Hide forgot password if grafana auth is disabled. [#79895](https://github.com/grafana/grafana/issues/79895), [@Jguer](https://github.com/Jguer) +- **Plugins:** Add uninstall requested message for cloud plugins. [#79748](https://github.com/grafana/grafana/issues/79748), [@oshirohugo](https://github.com/oshirohugo) +- **Loki:** Open log context in new tab. [#79723](https://github.com/grafana/grafana/issues/79723), [@svennergr](https://github.com/svennergr) +- **Alerting:** Allow linking to library panels. [#79693](https://github.com/grafana/grafana/issues/79693), [@gillesdemey](https://github.com/gillesdemey) +- **Loki:** Drop all errors in volume requests. [#79686](https://github.com/grafana/grafana/issues/79686), [@svennergr](https://github.com/svennergr) +- **Loki Logs volume:** Added a query splitting loading indicator to the Logs Volume graph. [#79681](https://github.com/grafana/grafana/issues/79681), [@matyax](https://github.com/matyax) +- **Plugins:** Disable add new data source for incomplete install. [#79658](https://github.com/grafana/grafana/issues/79658), [@oshirohugo](https://github.com/oshirohugo) +- **RBAC:** Render team, service account and user list when a user can see entities but not roles attached to them. [#79642](https://github.com/grafana/grafana/issues/79642), [@kalleep](https://github.com/kalleep) +- **InfluxDB:** Use database input for SQL configuration instead of metadata. [#79579](https://github.com/grafana/grafana/issues/79579), [@itsmylife](https://github.com/itsmylife) +- **Tempo:** Support special characters in identifiers. [#79565](https://github.com/grafana/grafana/issues/79565), [@fabrizio-grafana](https://github.com/fabrizio-grafana) +- **Alerting:** Display "Show all" button for cloud rules. [#79512](https://github.com/grafana/grafana/issues/79512), [@VikaCep](https://github.com/VikaCep) +- **React Hook Form:** Update to v 7.49.2. [#79493](https://github.com/grafana/grafana/issues/79493), [@Clarity-89](https://github.com/Clarity-89) +- **Loki:** Add timeRange to labels requests in LogContext to reduce loading times. [#79478](https://github.com/grafana/grafana/issues/79478), [@svennergr](https://github.com/svennergr) +- **InfluxDB:** Enable SQL support by default. [#79474](https://github.com/grafana/grafana/issues/79474), [@itsmylife](https://github.com/itsmylife) +- **OAuth:** Remove accessTokenExpirationCheck feature toggle. [#79455](https://github.com/grafana/grafana/issues/79455), [@mgyongyosi](https://github.com/mgyongyosi) +- **Units:** Add scalable unit option. [#79411](https://github.com/grafana/grafana/issues/79411), [@Develer](https://github.com/Develer) +- **Alerting:** Add export mute timings feature to the UI. [#79395](https://github.com/grafana/grafana/issues/79395), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Config:** Can add static headers to email messages. [#79365](https://github.com/grafana/grafana/issues/79365), [@owensmallwood](https://github.com/owensmallwood) +- **Alerting:** Drop NamespaceID from responses on unstable ngalert API endpoints in favor of NamespaceUID. [#79359](https://github.com/grafana/grafana/issues/79359), [@alexweav](https://github.com/alexweav) +- **Cloudwatch:** Update cloudwatchNewRegionsHandler to General Availability. [#79348](https://github.com/grafana/grafana/issues/79348), [@sarahzinger](https://github.com/sarahzinger) +- **Plugins:** Include Azure settings as a part of Grafana config sent in plugin requests. [#79342](https://github.com/grafana/grafana/issues/79342), [@aangelisc](https://github.com/aangelisc) +- **Plugins:** Add hide_angular_deprecation setting. [#79296](https://github.com/grafana/grafana/issues/79296), [@xnyo](https://github.com/xnyo) +- **Table:** Add select/unselect all column values to table filter. [#79290](https://github.com/grafana/grafana/issues/79290), [@ahuarte47](https://github.com/ahuarte47) +- **Anonymous:** Add configurable device limit. [#79265](https://github.com/grafana/grafana/issues/79265), [@Jguer](https://github.com/Jguer) +- **Frontend:** Detect new assets / versions / config changes. [#79258](https://github.com/grafana/grafana/issues/79258), [@ryantxu](https://github.com/ryantxu) +- **Plugins:** Add option to disable TLS in the socks proxy. [#79246](https://github.com/grafana/grafana/issues/79246), [@PoorlyDefinedBehaviour](https://github.com/PoorlyDefinedBehaviour) +- **Frontend:** Reload the browser when backend configuration/assets change. [#79057](https://github.com/grafana/grafana/issues/79057), [@torkelo](https://github.com/torkelo) +- **Chore:** Refactor dataviz aria-label e2e selectors to data-testid. [#78938](https://github.com/grafana/grafana/issues/78938), [@khushijain21](https://github.com/khushijain21) +- **SSO:** Add GitHub auth configuration page. [#78933](https://github.com/grafana/grafana/issues/78933), [@Clarity-89](https://github.com/Clarity-89) +- **PublicDashboards:** Add setting to disable the feature. [#78894](https://github.com/grafana/grafana/issues/78894), [@AgnesToulet](https://github.com/AgnesToulet) +- **Variables:** Interpolate variables used in custom variable definition. [#78800](https://github.com/grafana/grafana/issues/78800), [@torkelo](https://github.com/torkelo) +- **Table:** Highlight row on shared crosshair. [#78392](https://github.com/grafana/grafana/issues/78392), [@mdvictor](https://github.com/mdvictor) +- **Stat:** Add Percent Change Option. [#78250](https://github.com/grafana/grafana/issues/78250), [@drew08t](https://github.com/drew08t) +- **Plugins:** Add Command Palette extension point. [#78098](https://github.com/grafana/grafana/issues/78098), [@sd2k](https://github.com/sd2k) +- **Transformations:** Add frame source picker to allow transforming annotations. [#77842](https://github.com/grafana/grafana/issues/77842), [@leeoniya](https://github.com/leeoniya) +- **Pyroscope:** Send start/end with profile types query. [#77523](https://github.com/grafana/grafana/issues/77523), [@bryanhuhta](https://github.com/bryanhuhta) +- **Explore:** Create menu for short link button. [#77336](https://github.com/grafana/grafana/issues/77336), [@gelicia](https://github.com/gelicia) +- **Alerting:** Don't record annotations for mapped NoData transitions, when NoData is mapped to OK. [#77164](https://github.com/grafana/grafana/issues/77164), [@alexweav](https://github.com/alexweav) +- **Canvas:** Add Pan and Zoom. [#76705](https://github.com/grafana/grafana/issues/76705), [@drew08t](https://github.com/drew08t) +- **Alerting:** In migration, create one label per channel. [#76527](https://github.com/grafana/grafana/issues/76527), [@JacobsonMT](https://github.com/JacobsonMT) +- **Alerting:** Separate overlapping legacy and UA alerting routes. [#76517](https://github.com/grafana/grafana/issues/76517), [@JacobsonMT](https://github.com/JacobsonMT) +- **Tooltip:** Improved Timeseries and Candlestick tooltips. [#75841](https://github.com/grafana/grafana/issues/75841), [@adela-almasan](https://github.com/adela-almasan) +- **Alerting:** Support hysteresis command expression. [#75189](https://github.com/grafana/grafana/issues/75189), [@yuri-tceretian](https://github.com/yuri-tceretian) +- **Plugins:** Add update for instance plugins. (Enterprise) +- **React Hook Form:** Update to v 7.49.2. (Enterprise) +- **Plugins:** Improve cloud plugins install error treatment. (Enterprise) + +### Bug fixes + +- **Transformations:** Fix bug where having NaN in the input to regression analysis transformation causes all predictions to be NaN. [#80079](https://github.com/grafana/grafana/issues/80079), [@oscarkilhed](https://github.com/oscarkilhed) +- **Alerting:** Fix URL timestamp conversion in historian API in annotation mode. [#80026](https://github.com/grafana/grafana/issues/80026), [@alexweav](https://github.com/alexweav) +- **Fix:** Switch component not being styled as disabled when is checked. [#80012](https://github.com/grafana/grafana/issues/80012), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Tempo:** Fix Spans table format. [#79938](https://github.com/grafana/grafana/issues/79938), [@fabrizio-grafana](https://github.com/fabrizio-grafana) +- **Gauges:** Fixing broken auto sizing. [#79926](https://github.com/grafana/grafana/issues/79926), [@torkelo](https://github.com/torkelo) +- **Barchart:** Fix percent stacking regression. [#79903](https://github.com/grafana/grafana/issues/79903), [@nmarrs](https://github.com/nmarrs) +- **Alerting:** Fix reusing last url in tab when reopening a new tab in rule detail a…. [#79801](https://github.com/grafana/grafana/issues/79801), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Azure Monitor:** Fix multi-resource bug "Missing required region params, requested QueryParams: api-version:2017-12-01-preview...". [#79669](https://github.com/grafana/grafana/issues/79669), [@bossinc](https://github.com/bossinc) +- **Explore:** Fix URL sync with async queries import . [#79584](https://github.com/grafana/grafana/issues/79584), [@Elfo404](https://github.com/Elfo404) +- **Dashboards:** Skip inherited object variable names. [#79567](https://github.com/grafana/grafana/issues/79567), [@jarben](https://github.com/jarben) +- **Alerting:** Fix queries and expressions in rule view details. [#79497](https://github.com/grafana/grafana/issues/79497), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Tempo:** Fix cache in TraceQL editor. [#79468](https://github.com/grafana/grafana/issues/79468), [@adrapereira](https://github.com/adrapereira) +- **Nested Folders:** Fix /api/folders pagination. [#79447](https://github.com/grafana/grafana/issues/79447), [@papagian](https://github.com/papagian) +- **Elasticsearch:** Fix modify query with backslashes. [#79430](https://github.com/grafana/grafana/issues/79430), [@svennergr](https://github.com/svennergr) +- **Cloudwatch:** Fix errors while loading queries/datasource on Safari. [#79417](https://github.com/grafana/grafana/issues/79417), [@kevinwcyu](https://github.com/kevinwcyu) +- **Stat:** Fix inconsistent center padding. [#79389](https://github.com/grafana/grafana/issues/79389), [@torkelo](https://github.com/torkelo) +- **Tempo:** Fix autocompletion with strings. [#79370](https://github.com/grafana/grafana/issues/79370), [@fabrizio-grafana](https://github.com/fabrizio-grafana) +- **Alerting:** Fix for data source filter on cloud rules. [#79327](https://github.com/grafana/grafana/issues/79327), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Alerting:** Fix UI inheriting mute timings from parent when calculating the polic…. [#79295](https://github.com/grafana/grafana/issues/79295), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Auth:** Fix a panic during logout when OAuth provider is not set. [#79271](https://github.com/grafana/grafana/issues/79271), [@dmihai](https://github.com/dmihai) +- **Tempo:** Fix read-only assignment. [#79183](https://github.com/grafana/grafana/issues/79183), [@fabrizio-grafana](https://github.com/fabrizio-grafana) +- **Templating:** Json interpolation of single-value default selection does not create valid json. [#79137](https://github.com/grafana/grafana/issues/79137), [@kaydelaney](https://github.com/kaydelaney) +- **Heatmap:** Fix null options migration. [#79083](https://github.com/grafana/grafana/issues/79083), [@overvenus](https://github.com/overvenus) +- **Dashboards:** Run shared queries even when source panel is in collapsed row. [#77792](https://github.com/grafana/grafana/issues/77792), [@kaydelaney](https://github.com/kaydelaney) +- **PDF:** Fix support for large panels. (Enterprise) +- **Reporting:** Fix daylight saving time support for custom schedules. (Enterprise) +- **RBAC:** Fix role assignment removal . (Enterprise) + +### Breaking changes + +Users who have InfluxDB datasource configured with SQL querying language must update their database information. They have to enter their `bucket name` into the database field. Issue [#79579](https://github.com/grafana/grafana/issues/79579) + +Removes `NamespaceID` from responses of all GET routes underneath the path `/api/ruler/grafana/api/v1/rules` - 3 affected endpoints. All affected routes are not in the publicly documented or `stable` marked portion of the ngalert API. This only breaks clients who are directly using the unstable portion of the API. Such clients should use `NamespaceUID` rather than `NamespaceID` to identify namespaces. Issue [#79359](https://github.com/grafana/grafana/issues/79359) + +<!-- 10.3.0 END --> +<!-- 10.2.5 START --> + +# 10.2.5 (2024-03-06) + +### Features and enhancements + +- **Alerting:** Add setting to distribute rule group evaluations over time. [#81404](https://github.com/grafana/grafana/issues/81404), [@alexweav](https://github.com/alexweav) + +### Bug fixes + +- **Cloudwatch:** Fix errors while loading queries/datasource on Safari. [#83842](https://github.com/grafana/grafana/issues/83842), [@kevinwcyu](https://github.com/kevinwcyu) +- **Elasticsearch:** Fix adhoc filters not applied in frontend mode. [#83595](https://github.com/grafana/grafana/issues/83595), [@svennergr](https://github.com/svennergr) +- **Auth:** Fix email verification bypass when using basic authentication. [#83489](https://github.com/grafana/grafana/issues/83489) +- **Alerting:** Fix queries and expressions in rule view details. [#82875](https://github.com/grafana/grafana/issues/82875), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Plugins:** Don't auto prepend app sub url to plugin asset paths. [#82146](https://github.com/grafana/grafana/issues/82146), [@wbrowne](https://github.com/wbrowne) +- **Folders:** Fix failure to update folder in SQLite. [#81861](https://github.com/grafana/grafana/issues/81861), [@papagian](https://github.com/papagian) + +<!-- 10.2.5 END --> +<!-- 10.2.4 START --> + +# 10.2.4 (2024-01-29) + +### Features and enhancements + +- **Chore:** Upgrade Go to 1.21.5. [#79560](https://github.com/grafana/grafana/issues/79560), [@tolzhabayev](https://github.com/tolzhabayev) + +### Bug fixes + +- **Field:** Fix perf regression in getUniqueFieldName(). [#81417](https://github.com/grafana/grafana/issues/81417), [@leeoniya](https://github.com/leeoniya) +- **Alerting:** Fix Graphite subqueries. [#80816](https://github.com/grafana/grafana/issues/80816), [@gillesdemey](https://github.com/gillesdemey) +- **Alerting:** Fix Graphite subqueries. [#80744](https://github.com/grafana/grafana/issues/80744), [@gillesdemey](https://github.com/gillesdemey) +- **Annotations:** Split cleanup into separate queries and deletes to avoid deadlocks on MySQL. [#80485](https://github.com/grafana/grafana/issues/80485), [@alexweav](https://github.com/alexweav) +- **Loki:** Fix bug duplicating parsed labels across multiple log lines. [#80368](https://github.com/grafana/grafana/issues/80368), [@svennergr](https://github.com/svennergr) +- **Alerting:** Fix NoData & Error alerts not resolving when rule is reset. [#80241](https://github.com/grafana/grafana/issues/80241), [@JacobsonMT](https://github.com/JacobsonMT) +- **Auth:** Fix a panic during logout when OAuth provider is not set. [#80221](https://github.com/grafana/grafana/issues/80221), [@dmihai](https://github.com/dmihai) +- **Gauges:** Fixing broken auto sizing. [#79940](https://github.com/grafana/grafana/issues/79940), [@torkelo](https://github.com/torkelo) +- **Templating:** Json interpolation of single-value default selection does not create valid json. [#79503](https://github.com/grafana/grafana/issues/79503), [@kaydelaney](https://github.com/kaydelaney) +- **Tempo:** Fix cache in TraceQL editor. [#79471](https://github.com/grafana/grafana/issues/79471), [@adrapereira](https://github.com/adrapereira) +- **Alerting:** Fix for data source filter on cloud rules. (#79327). [#79350](https://github.com/grafana/grafana/issues/79350), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) + +<!-- 10.2.4 END --> <!-- 10.2.3 START --> # 10.2.3 (2023-12-18) @@ -100,7 +500,7 @@ - **Alerting:** Show receiver in groups view to avoid duplication in the list. [#77109](https://github.com/grafana/grafana/issues/77109), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) - **Alerting:** Allow more time before Alertmanager expire-resolves alerts. [#77094](https://github.com/grafana/grafana/issues/77094), [@alexweav](https://github.com/alexweav) - **Tempo:** Add new structural operators. [#77056](https://github.com/grafana/grafana/issues/77056), [@fabrizio-grafana](https://github.com/fabrizio-grafana) -- **ServiceAccount:** Add pagination to service accout table. [#77044](https://github.com/grafana/grafana/issues/77044), [@kalleep](https://github.com/kalleep) +- **ServiceAccount:** Add pagination to service account table. [#77044](https://github.com/grafana/grafana/issues/77044), [@kalleep](https://github.com/kalleep) - **Transformations:** Cumulative and window modes for `Add field from calculation`. [#77029](https://github.com/grafana/grafana/issues/77029), [@mdvictor](https://github.com/mdvictor) - **Plugins:** Allow disabling angular deprecation UI for specific plugins. [#77026](https://github.com/grafana/grafana/issues/77026), [@xnyo](https://github.com/xnyo) - **Stat:** Add panel option to control wide layout. [#77018](https://github.com/grafana/grafana/issues/77018), [@nmarrs](https://github.com/nmarrs) @@ -208,7 +608,7 @@ - **Organize fields transformation:** Fix re-ordering of fields using drag and drop. [#77172](https://github.com/grafana/grafana/issues/77172), [@adela-almasan](https://github.com/adela-almasan) - **Bug fix:** Correctly set permissions on provisioned dashboards. [#77155](https://github.com/grafana/grafana/issues/77155), [@IevaVasiljeva](https://github.com/IevaVasiljeva) - **InfluxDB:** Fix adhoc filter calls by properly checking optional parameter in metricFindQuery. [#77113](https://github.com/grafana/grafana/issues/77113), [@itsmylife](https://github.com/itsmylife) -- **Alerting:** Fix NoRulesSplash being rendered for some seconds, fater creating a rule. [#77048](https://github.com/grafana/grafana/issues/77048), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Alerting:** Fix NoRulesSplash being rendered for some seconds, faster creating a rule. [#77048](https://github.com/grafana/grafana/issues/77048), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) - **RBAC:** Allow scoping access to root level dashboards. [#76987](https://github.com/grafana/grafana/issues/76987), [@IevaVasiljeva](https://github.com/IevaVasiljeva) - **Alerting:** Dont show 1 firing series when no data in Expressions PreviewSummary. [#76981](https://github.com/grafana/grafana/issues/76981), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) - **InfluxDB:** Fix aliasing with $measurement or $m on backend mode. [#76917](https://github.com/grafana/grafana/issues/76917), [@itsmylife](https://github.com/itsmylife) @@ -260,7 +660,7 @@ Please use the following endpoints instead: - `GET /api/access-control/datasources/:uid` for listing data source permissions - `POST /api/access-control/datasources/:uid/users/:id`, `POST /api/access-control/datasources/:uid/teams/:id` and `POST /api/access-control/datasources/:uid/buildInRoles/:id` for adding or removing data source permissions -If you are using Terraform Grafana provider to manage data source permissions, you will need to upgrade your provider to [version 2.6.0](https://registry.terraform.io/providers/grafana/grafana/2.6.0/docs) or newer to ensure that data source permission provisioning keeps working. Issue [#5880](https://github.com/grafana/grafana/issues/5880) +If you are using Terraform Grafana provider to manage data source permissions, you will need to upgrade your provider to [version 2.6.0](https://registry.terraform.io/providers/grafana/grafana/2.6.0/docs) or newer to ensure that data source permission provisioning keeps working. ### Deprecations @@ -581,7 +981,7 @@ For the existing backend mode users who have table visualization might see some - **Pyroscope:** Fix error when no profile types are returned. [#75143](https://github.com/grafana/grafana/issues/75143), [@aocenas](https://github.com/aocenas) - **BarChart:** Axes centered zero, borders, and colors. [#75136](https://github.com/grafana/grafana/issues/75136), [@leeoniya](https://github.com/leeoniya) - **Plugins:** Refresh plugin info after installation. [#75074](https://github.com/grafana/grafana/issues/75074), [@oshirohugo](https://github.com/oshirohugo) -- **LDAP:** FIX Enable users on successfull login . [#75073](https://github.com/grafana/grafana/issues/75073), [@gamab](https://github.com/gamab) +- **LDAP:** FIX Enable users on successful login . [#75073](https://github.com/grafana/grafana/issues/75073), [@gamab](https://github.com/gamab) - **XYChart:** Fix numerous axis options. [#75044](https://github.com/grafana/grafana/issues/75044), [@leeoniya](https://github.com/leeoniya) - **Trace View:** Remove "deployment.environment" default traces 2 logs tag. [#74986](https://github.com/grafana/grafana/issues/74986), [@domasx2](https://github.com/domasx2) - **Snapshots:** Use appUrl on snapshot list page. [#74944](https://github.com/grafana/grafana/issues/74944), [@evictorero](https://github.com/evictorero) @@ -651,6 +1051,24 @@ Starting with 10.2, `parentRowIndex` is deprecated. It will be removed in a futu - **Drawer:** Make content scroll by default. [#75287](https://github.com/grafana/grafana/issues/75287), [@ashharrison90](https://github.com/ashharrison90) <!-- 10.2.0 END --> +<!-- 10.1.8 START --> + +# 10.1.8 (2024-03-06) + +### Bug fixes + +- **Auth:** Fix email verification bypass when using basic authentication. [#83492](https://github.com/grafana/grafana/issues/83492) + +<!-- 10.1.8 END --> +<!-- 10.1.7 START --> + +# 10.1.7 (2024-01-29) + +### Bug fixes + +- **Annotations:** Split cleanup into separate queries and deletes to avoid deadlocks on MySQL. [#80678](https://github.com/grafana/grafana/issues/80678), [@alexweav](https://github.com/alexweav) + +<!-- 10.1.7 END --> <!-- 10.1.6 START --> # 10.1.6 (2023-12-18) @@ -669,7 +1087,7 @@ Starting with 10.2, `parentRowIndex` is deprecated. It will be removed in a futu - **Loki:** Cache extracted labels. [#75905](https://github.com/grafana/grafana/issues/75905), [@gtk-grafana](https://github.com/gtk-grafana) - **DataSourcePicker:** Disable autocomplete for the search input . [#75900](https://github.com/grafana/grafana/issues/75900), [@ivanortegaalba](https://github.com/ivanortegaalba) - **Plugins:** Refresh plugin info after installation. [#75225](https://github.com/grafana/grafana/issues/75225), [@oshirohugo](https://github.com/oshirohugo) -- **LDAP:** FIX Enable users on successfull login . [#75176](https://github.com/grafana/grafana/issues/75176), [@gamab](https://github.com/gamab) +- **LDAP:** FIX Enable users on successful login . [#75176](https://github.com/grafana/grafana/issues/75176), [@gamab](https://github.com/gamab) - **Loki:** Fix filters not being added with multiple expressions and parsers. [#75172](https://github.com/grafana/grafana/issues/75172), [@svennergr](https://github.com/svennergr) - **Recorded Queries:** Add org isolation (remote write target per org), and fix cross org Delete/List. (Enterprise) - **Auditing and UsageInsights:** FIX Loki configuration to use proxy env variables. (Enterprise) @@ -777,7 +1195,7 @@ Starting with 10.2, `parentRowIndex` is deprecated. It will be removed in a futu - **Geomap:** Promote route + photos layer to beta, promote geojson layer to stable. [#72233](https://github.com/grafana/grafana/issues/72233), [@nmarrs](https://github.com/nmarrs) - **Dashboards:** Add Angular deprecation alert in data source query editor. [#72211](https://github.com/grafana/grafana/issues/72211), [@xnyo](https://github.com/xnyo) - **Auth:** Lock organization roles for users who are managed through an external auth provider. [#72204](https://github.com/grafana/grafana/issues/72204), [@IevaVasiljeva](https://github.com/IevaVasiljeva) -- **Tranformations:** True OUTER JOIN in the join by field transformation used for tabular data . [#72176](https://github.com/grafana/grafana/issues/72176), [@bohandley](https://github.com/bohandley) +- **Transformations:** True OUTER JOIN in the join by field transformation used for tabular data . [#72176](https://github.com/grafana/grafana/issues/72176), [@bohandley](https://github.com/bohandley) - **NestedFolders:** Enable new nested folder picker by default for nested folders. [#72129](https://github.com/grafana/grafana/issues/72129), [@joshhunt](https://github.com/joshhunt) - **Alerting:** Add dashboardUID and panelID query parameters for loki state history. [#72119](https://github.com/grafana/grafana/issues/72119), [@alexweav](https://github.com/alexweav) - **Feature toggles management:** Define get feature toggles api. [#72106](https://github.com/grafana/grafana/issues/72106), [@jcalisto](https://github.com/jcalisto) @@ -844,7 +1262,7 @@ Starting with 10.2, `parentRowIndex` is deprecated. It will be removed in a futu - **Login:** Adjust error message when user exceed login attempts. [#70736](https://github.com/grafana/grafana/issues/70736), [@RoxanaAnamariaTurc](https://github.com/RoxanaAnamariaTurc) - **Nested folders:** Paginate child folder items. [#70730](https://github.com/grafana/grafana/issues/70730), [@ashharrison90](https://github.com/ashharrison90) - **Units:** Add events/messages/records/rows throughput units. [#70726](https://github.com/grafana/grafana/issues/70726), [@hhromic](https://github.com/hhromic) -- **Plugins:** Enable feature toggles for long running queries by deafult. [#70678](https://github.com/grafana/grafana/issues/70678), [@idastambuk](https://github.com/idastambuk) +- **Plugins:** Enable feature toggles for long running queries by default. [#70678](https://github.com/grafana/grafana/issues/70678), [@idastambuk](https://github.com/idastambuk) - **I18n:** Translate phrases for new Browse Dashboards. [#70654](https://github.com/grafana/grafana/issues/70654), [@Bohdanator](https://github.com/Bohdanator) - **Flamegraph:** Prevent cropping of tooltip by bottom of the viewport. [#70633](https://github.com/grafana/grafana/issues/70633), [@aocenas](https://github.com/aocenas) - **Pyroscope:** Preselect default profile type or app in the query editor dropdown. [#70624](https://github.com/grafana/grafana/issues/70624), [@aocenas](https://github.com/aocenas) @@ -870,7 +1288,7 @@ Starting with 10.2, `parentRowIndex` is deprecated. It will be removed in a futu - **Geomap:** Add network layer. [#70192](https://github.com/grafana/grafana/issues/70192), [@drew08t](https://github.com/drew08t) - **Alerting:** Bump grafana/alerting and refactor the ImageStore/Provider to provide image URL/bytes. [#70182](https://github.com/grafana/grafana/issues/70182), [@santihernandezc](https://github.com/santihernandezc) - **Auth:** Support google OIDC and group fetching. [#70140](https://github.com/grafana/grafana/issues/70140), [@Jguer](https://github.com/Jguer) -- **Alerting:** Make QueryEditor not collapsable. [#70112](https://github.com/grafana/grafana/issues/70112), [@VikaCep](https://github.com/VikaCep) +- **Alerting:** Make QueryEditor not collapsible. [#70112](https://github.com/grafana/grafana/issues/70112), [@VikaCep](https://github.com/VikaCep) - **TimeSeries:** Add option to disconnect values. [#70097](https://github.com/grafana/grafana/issues/70097), [@drew08t](https://github.com/drew08t) - **Logs:** Add toggle behavior support for "filter for" and "filter out" label within Logs Details. [#70091](https://github.com/grafana/grafana/issues/70091), [@matyax](https://github.com/matyax) - **Plugins:** Periodically update public signing key. [#70080](https://github.com/grafana/grafana/issues/70080), [@andresmgot](https://github.com/andresmgot) @@ -1152,6 +1570,24 @@ Starting with 10.0, changing the folder UID is deprecated. It will be removed in - **Grafana/ui:** Fix margin in RadioButtonGroup option when only icon is present. [#68899](https://github.com/grafana/grafana/issues/68899), [@aocenas](https://github.com/aocenas) <!-- 10.1.0 END --> +<!-- 10.0.12 START --> + +# 10.0.12 (2024-03-06) + +### Bug fixes + +- **Auth:** Fix email verification bypass when using basic authentication. [#83493](https://github.com/grafana/grafana/issues/83493) + +<!-- 10.0.12 END --> +<!-- 10.0.11 START --> + +# 10.0.11 (2024-01-29) + +### Bug fixes + +- **Annotations:** Split cleanup into separate queries and deletes to avoid deadlocks on MySQL. [#80681](https://github.com/grafana/grafana/issues/80681), [@alexweav](https://github.com/alexweav) + +<!-- 10.0.11 END --> <!-- 10.0.10 START --> # 10.0.10 (2023-12-18) @@ -1684,6 +2120,29 @@ The `database` field has been deprecated in the Elasticsearch datasource provisi - **InteractiveTable:** Updated design and minor tweak to Correlactions page. [#66443](https://github.com/grafana/grafana/issues/66443), [@torkelo](https://github.com/torkelo) <!-- 10.0.0-preview END --> +<!-- 9.5.17 START --> + +# 9.5.17 (2024-03-05) + +### Features and enhancements + +- Bump go-git to v5.11.0. [#83711](https://github.com/grafana/grafana/issues/83711), [@papagian](https://github.com/papagian) +- **Plugins:** Bump otelgrpc instrumentation to 0.47.0. [#83674](https://github.com/grafana/grafana/issues/83674), [@wbrowne](https://github.com/wbrowne) + +### Bug fixes + +- **Auth:** Fix email verification bypass when using basic authentication. [#83494](https://github.com/grafana/grafana/issues/83494) + +<!-- 9.5.17 END --> +<!-- 9.5.16 START --> + +# 9.5.16 (2024-01-29) + +### Bug fixes + +- **Annotations:** Split cleanup into separate queries and deletes to avoid deadlocks on MySQL. [#80682](https://github.com/grafana/grafana/issues/80682), [@alexweav](https://github.com/alexweav) + +<!-- 9.5.16 END --> <!-- 9.5.15 START --> # 9.5.15 (2023-12-18) @@ -1706,7 +2165,7 @@ The `database` field has been deprecated in the Elasticsearch datasource provisi - **Alerting:** Fix state manager to not keep datasource_uid and ref_id labels in state after Error. [#77391](https://github.com/grafana/grafana/issues/77391), [@yuri-tceretian](https://github.com/yuri-tceretian) - **Transformations:** Config overrides being lost when config from query transform is applied. [#75347](https://github.com/grafana/grafana/issues/75347), [@IbrahimCSAE](https://github.com/IbrahimCSAE) -- **LDAP:** FIX Enable users on successfull login . [#75192](https://github.com/grafana/grafana/issues/75192), [@gamab](https://github.com/gamab) +- **LDAP:** FIX Enable users on successful login . [#75192](https://github.com/grafana/grafana/issues/75192), [@gamab](https://github.com/gamab) - **Auditing and UsageInsights:** FIX Loki configuration to use proxy env variables. (Enterprise) <!-- 9.5.14 END --> diff --git a/Dockerfile b/Dockerfile index 59355acfc8f50..d17a384ff3327 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ # syntax=docker/dockerfile:1 -ARG BASE_IMAGE=alpine:3.18.3 -ARG JS_IMAGE=node:20-alpine3.18 +ARG BASE_IMAGE=alpine:3.19.1 +ARG JS_IMAGE=node:20-alpine ARG JS_PLATFORM=linux/amd64 -ARG GO_IMAGE=golang:1.21.5-alpine3.18 +ARG GO_IMAGE=golang:1.21.8-alpine ARG GO_SRC=go-builder ARG JS_SRC=js-builder @@ -20,9 +20,11 @@ COPY packages packages COPY plugins-bundled plugins-bundled COPY public public +RUN apk add --no-cache make build-base python3 + RUN yarn install --immutable -COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js babel.config.json ./ +COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js ./ COPY public public COPY scripts scripts COPY emails emails @@ -38,6 +40,9 @@ ARG GO_BUILD_TAGS="oss" ARG WIRE_TAGS="oss" ARG BINGO="true" +# This is required to allow building on arm64 due to https://github.com/golang/go/issues/22040 +RUN apk add --no-cache binutils-gold + # Install build dependencies RUN if grep -i -q alpine /etc/issue; then \ apk add --no-cache gcc g++ make git; \ @@ -50,6 +55,8 @@ COPY .bingo .bingo # Include vendored dependencies COPY pkg/util/xorm/go.* pkg/util/xorm/ +COPY pkg/apiserver/go.* pkg/apiserver/ +COPY pkg/apimachinery/go.* pkg/apimachinery/ RUN go mod download RUN if [[ "$BINGO" = "true" ]]; then \ diff --git a/GOVERNANCE.md b/GOVERNANCE.md index def47f1a1c15f..ff129a8755770 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -173,7 +173,7 @@ Supermajority votes must be called explicitly in a separate thread on the approp Votes may take the form of a single proposal, with the option to vote yes or no, or the form of multiple alternatives. -A vote on a single proposal is considered successful if at least two thirds of those eligible to vote vote in favor. +A vote on a single proposal is considered successful if at least two thirds of those eligible to vote in favor. If there are multiple alternatives, members may vote for one or more alternatives, or vote “no” to object to all alternatives. A vote on multiple alternatives is considered decided in favor of one alternative if it has received the most votes in favor, and a vote from at least two thirds of those eligible to vote. Should no alternative reach this quorum, another vote on a reduced number of options may be called separately. diff --git a/HALL_OF_FAME.md b/HALL_OF_FAME.md index 18fd83f6c2103..f895045493067 100644 --- a/HALL_OF_FAME.md +++ b/HALL_OF_FAME.md @@ -2,4 +2,4 @@ List of previous team members that have had a big impact on the company or the product and contributed during a long period of time. -- Hugo Häggmark ([School of applied technology](https://salt.study)) +- Hugo Häggmark ([Björn Lundén](https://www.bjornlunden.se/)) diff --git a/LICENSING.md b/LICENSING.md index f8bfef7f94585..97f2c53d8e759 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -18,7 +18,7 @@ packaging/ kinds/ pkg/kinds/ pkg/kindsys/ -pkg/registry/corekind/ +pkg/registry/schemas/ grafana-mixin/ public/app/plugins/datasource/tempo public/app/features/explore/TraceView/components diff --git a/Makefile b/Makefile index 4c6a646c8822e..3d53ca4dcfc7c 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ include .bingo/Variables.mk .PHONY: all deps-go deps-js deps build-go build-backend build-server build-cli build-js build build-docker-full build-docker-full-ubuntu lint-go golangci-lint test-go test-js gen-ts test run run-frontend clean devenv devenv-down protobuf drone help gen-go gen-cue fix-cue GO = go -GO_FILES ?= ./pkg/... +GO_FILES ?= ./pkg/... ./pkg/apiserver/... ./pkg/apimachinery/... ./pkg/promlib/... SH_FILES ?= $(shell find ./scripts -name *.sh) GO_BUILD_FLAGS += $(if $(GO_BUILD_DEV),-dev) GO_BUILD_FLAGS += $(if $(GO_BUILD_TAGS),-build-tags=$(GO_BUILD_TAGS)) @@ -45,12 +45,12 @@ $(NGALERT_SPEC_TARGET): $(MERGED_SPEC_TARGET): swagger-oss-gen swagger-enterprise-gen $(NGALERT_SPEC_TARGET) $(SWAGGER) ## Merge generated and ngalert API specs # known conflicts DsPermissionType, AddApiKeyCommand, Json, Duration (identical models referenced by both specs) - $(SWAGGER) mixin $(SPEC_TARGET) $(ENTERPRISE_SPEC_TARGET) $(NGALERT_SPEC_TARGET) --ignore-conflicts -o $(MERGED_SPEC_TARGET) + $(SWAGGER) mixin -q $(SPEC_TARGET) $(ENTERPRISE_SPEC_TARGET) $(NGALERT_SPEC_TARGET) --ignore-conflicts -o $(MERGED_SPEC_TARGET) swagger-oss-gen: $(SWAGGER) ## Generate API Swagger specification @echo "re-generating swagger for OSS" rm -f $(SPEC_TARGET) - SWAGGER_GENERATE_EXTENSION=false $(SWAGGER) generate spec -m -w pkg/server -o $(SPEC_TARGET) \ + SWAGGER_GENERATE_EXTENSION=false $(SWAGGER) generate spec -q -m -w pkg/server -o $(SPEC_TARGET) \ -x "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" \ -x "github.com/prometheus/alertmanager" \ -i pkg/api/swagger_tags.json \ @@ -66,7 +66,7 @@ else swagger-enterprise-gen: $(SWAGGER) ## Generate API Swagger specification @echo "re-generating swagger for enterprise" rm -f $(ENTERPRISE_SPEC_TARGET) - SWAGGER_GENERATE_EXTENSION=false $(SWAGGER) generate spec -m -w pkg/server -o $(ENTERPRISE_SPEC_TARGET) \ + SWAGGER_GENERATE_EXTENSION=false $(SWAGGER) generate spec -q -m -w pkg/server -o $(ENTERPRISE_SPEC_TARGET) \ -x "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" \ -x "github.com/prometheus/alertmanager" \ -i pkg/api/swagger_tags.json \ @@ -77,7 +77,7 @@ endif swagger-gen: gen-go $(MERGED_SPEC_TARGET) swagger-validate swagger-validate: $(MERGED_SPEC_TARGET) $(SWAGGER) ## Validate API spec - $(SWAGGER) validate $(<) + $(SWAGGER) validate --skip-warnings $(<) swagger-clean: rm -f $(SPEC_TARGET) $(MERGED_SPEC_TARGET) $(OAPI_SPEC_TARGET) @@ -103,10 +103,8 @@ openapi3-gen: swagger-gen ## Generates OpenApi 3 specs from the Swagger 2 alread ##@ Building gen-cue: ## Do all CUE/Thema code generation @echo "generate code from .cue files" - go generate ./pkg/plugins/plugindef go generate ./kinds/gen.go go generate ./public/app/plugins/gen.go - go generate ./pkg/kindsysreport/codegen/report.go gen-go: $(WIRE) @echo "generate go files" @@ -141,11 +139,6 @@ build-js: ## Build frontend assets. yarn run build yarn run plugins:build-bundled -build-plugins-go: ## Build decoupled plugins - @echo "build plugins" - @cd pkg/tsdb; \ - mage -v - PLUGIN_ID ?= build-plugin-go: ## Build decoupled plugins @@ -173,7 +166,8 @@ test-go: test-go-unit test-go-integration .PHONY: test-go-unit test-go-unit: ## Run unit tests for backend with flags. @echo "test backend unit tests" - $(GO) test -short -covermode=atomic -timeout=30m ./pkg/... + go list -f '{{.Dir}}/...' -m | xargs \ + $(GO) test -short -covermode=atomic -timeout=30m .PHONY: test-go-integration test-go-integration: ## Run integration tests for backend with flags. @@ -261,7 +255,7 @@ build-docker-full-ubuntu: ## Build Docker image based on Ubuntu for development. --build-arg COMMIT_SHA=$$(git rev-parse HEAD) \ --build-arg BUILD_BRANCH=$$(git rev-parse --abbrev-ref HEAD) \ --build-arg BASE_IMAGE=ubuntu:22.04 \ - --build-arg GO_IMAGE=golang:1.21.5 \ + --build-arg GO_IMAGE=golang:1.21.8 \ --tag grafana/grafana$(TAG_SUFFIX):dev-ubuntu \ $(DOCKER_BUILD_ARGS) @@ -324,6 +318,7 @@ gen-ts: # Use this make target to regenerate the configuration YAML files when # you modify starlark files. drone: $(DRONE) + bash scripts/drone/env-var-check.sh $(DRONE) starlark --format $(DRONE) lint .drone.yml --trusted $(DRONE) --server https://drone.grafana.net sign --save grafana/grafana diff --git a/WORKFLOW.md b/WORKFLOW.md index fca782e178d21..ab1821b468ffe 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -44,7 +44,7 @@ Once a PR is approved as per above, any team member MAY merge the PR. ## Backporting a PR Critical bug fixes needed for a previous minor release should be backported to the respective release branches after coordinating with the delivery team. -Please see the [contibution guide](./contribute/merge-pull-request.md#should-the-pull-request-be-backported) for further details. +Please see the [contribution guide](./contribute/merge-pull-request.md#should-the-pull-request-be-backported) for further details. # Release workflow diff --git a/babel.config.json b/babel.config.json deleted file mode 100644 index b7e1723c6268d..0000000000000 --- a/babel.config.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "babelrc": false, - // Note: order is bottom-to-top and/or right-to-left - "presets": [ - [ - "@babel/preset-env", - { - "bugfixes": true, - "browserslistEnv": "dev" - } - ], - [ - "@babel/preset-typescript", - { - "allowNamespaces": true, - "allowDeclareFields": true - } - ], - [ - "@babel/preset-react", - { - "runtime": "automatic" - } - ] - ], - "plugins": [ - [ - "@babel/plugin-transform-typescript", - { - "allowNamespaces": true, - "allowDeclareFields": true - } - ], - // added to mitigate https://github.com/babel/babel/issues/14289 - // package (and following line) can be removed once the issue is fixed and released - "@babel/plugin-proposal-class-properties", - ["@babel/plugin-proposal-object-rest-spread", { "loose": true }], - "@babel/plugin-transform-react-constant-elements", - "@babel/plugin-proposal-nullish-coalescing-operator", - "@babel/plugin-proposal-optional-chaining", - "@babel/plugin-syntax-dynamic-import", // needed for `() => import()` in routes.ts - "angularjs-annotate", - "macros" - ], - "env": { - "production": { - "presets": [ - [ - "@babel/preset-env", - { - "browserslistEnv": "production" - } - ] - ] - }, - "hot": { - "plugins": ["react-refresh/babel"] - } - } -} diff --git a/conf/defaults.ini b/conf/defaults.ini index 87e2ec3c8b21a..1db14386a2cfe 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -9,10 +9,6 @@ app_mode = production # instance name, defaults to HOSTNAME environment variable value or hostname if HOSTNAME var is empty instance_name = ${HOSTNAME} -# force migration will run migrations that might cause dataloss -# Deprecated, use clean_upgrade option in [unified_alerting.upgrade] instead. -force_migration = false - #################################### Paths ############################### [paths] # Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is used) @@ -94,6 +90,10 @@ read_timeout = 0 #exampleHeader1 = exampleValue1 #exampleHeader2 = exampleValue2 +[environment] +# Sets whether the local file system is available for Grafana to use. Default is true for backward compatibility. +local_file_system_available = true + #################################### GRPC Server ######################### [grpc_server] network = "tcp" @@ -134,6 +134,9 @@ log_queries = # For "mysql", use either "true", "false", or "skip-verify". ssl_mode = disable +# For "postregs", use either "1" to enable or "0" to disable SNI +ssl_sni = + # Database drivers may support different transaction isolation levels. # Currently, only "mysql" driver supports isolation levels. # If the value is empty - driver's default isolation level is applied. @@ -246,7 +249,7 @@ reporting_distributor = grafana-labs # for new versions of grafana. The check is used # in some UI views to notify that a grafana update exists. # This option does not cause any auto updates, nor send any information -# only a GET request to https://raw.githubusercontent.com/grafana/grafana/main/latest.json to get the latest version. +# only a GET request to https://grafana.com/api/grafana/versions/stable to get the latest version. check_for_updates = true # Set to false to disable all checks to https://grafana.com @@ -429,6 +432,11 @@ default_home_dashboard_path = # Upper limit of data sources that Grafana will return. This limit is a temporary configuration and it will be deprecated when pagination will be introduced on the list data sources API. datasource_limit = 5000 +# Number of queries to be executed concurrently. Only for the datasource supports concurrency. +# For now only Loki and InfluxDB (with influxql) are supporting concurrency behind the feature flags. +# Check datasource documentations for enabling concurrency. +concurrent_query_count = 10 + ################################### SQL Data Sources ##################### [sql_datasources] @@ -464,6 +472,9 @@ auto_assign_org_role = Viewer # Require email validation before sign up completes verify_email_enabled = false +# Redirect to default OrgId after login +login_default_org_id = + # Background text for the user field on the login page login_hint = email or username password_hint = password @@ -491,6 +502,9 @@ editors_can_admin = false # The duration in time a user invitation remains valid before expiring. This setting should be expressed as a duration. Examples: 6h (hours), 2d (days), 1w (week). Default is 24h (24 hours). The minimum supported duration is 15m (15 minutes). user_invite_max_lifetime_duration = 24h +# The duration in time a verification email, used to update the email address of a user, remains valid before expiring. This setting should be expressed as a duration. Examples: 6h (hours), 2d (days), 1w (week). Default is 1h (1 hour). +verification_email_max_lifetime_duration = 1h + # Enter a comma-separated list of usernames to hide them in the Grafana UI. These users are shown to Grafana admins and to themselves. hidden_users = @@ -577,6 +591,16 @@ id_response_header_prefix = X-Grafana # The header value will encode the namespace ("user:<id>", "api-key:<id>", "service-account:<id>") id_response_header_namespaces = user api-key service-account +#################################### SSO Settings ########################### +[sso_settings] +# interval for reloading the SSO Settings from the database +# useful in high availability setups running multiple Grafana instances +# set to 0 to disable this feature +reload_interval = 1m + +# List of providers that can be configured through the SSO Settings API and UI. +configurable_providers = github gitlab google generic_oauth azuread okta + #################################### Anonymous Auth ###################### [auth.anonymous] # enable anonymous access @@ -592,7 +616,7 @@ org_role = Viewer hide_version = false # number of devices in total -device_limit = +device_limit = #################################### GitHub Auth ######################### [auth.github] @@ -664,6 +688,7 @@ token_url = https://oauth2.googleapis.com/token api_url = https://openidconnect.googleapis.com/v1/userinfo signout_redirect_url = allowed_domains = +validate_hd = true hosted_domain = allowed_groups = role_attribute_path = @@ -797,6 +822,14 @@ use_refresh_token = false #################################### Basic Auth ########################## [auth.basic] enabled = true +# This setting will enable a stronger password policy for user's password under basic auth. +# The password will need to comply with the following password policy +# 1. Have a minimum of 12 characters +# 2. Composed by at least 1 uppercase character +# 3. Composed by at least 1 lowercase character +# 4. Composed by at least 1 digit character +# 5. Composed by at least 1 symbol character +password_policy = false #################################### Auth Proxy ########################## [auth.proxy] @@ -804,7 +837,7 @@ enabled = false header_name = X-WEBAUTH-USER header_property = username auto_sign_up = true -sync_ttl = 60 +sync_ttl = 15 whitelist = headers = headers_encoded = false @@ -825,6 +858,7 @@ key_file = key_id = role_attribute_path = role_attribute_strict = false +groups_attribute_path = auto_sign_up = false url_login = false allow_assign_grafana_admin = false @@ -843,7 +877,7 @@ skip_org_role_sync = false sync_cron = "0 1 * * *" active_sync_enabled = true -#################################### AWS ########################### +#################################### AWS ##################################### [aws] # Enter a comma-separated list of allowed AWS authentication providers. # Options are: default (AWS SDK Default), keys (Access && secret key), credentials (Credentials field), ec2_iam_role (EC2 IAM Role) @@ -859,6 +893,14 @@ list_metrics_page_limit = 500 # Experimental, for use in Grafana Cloud only. Please do not set. external_id = +# Sets the expiry duration of an assumed role. +# This setting should be expressed as a duration. Examples: 6h (hours), 10d (days), 2w (weeks), 1M (month). +session_duration = "15m" + +# Set the plugins that will receive AWS settings for each request (via plugin context) +# By default this will include all Grafana Labs owned AWS plugins, or those that make use of AWS settings (ElasticSearch, Prometheus). +forward_settings_to_plugins = cloudwatch, grafana-athena-datasource, grafana-redshift-datasource, grafana-x-ray-datasource, grafana-timestream-datasource, grafana-iot-sitewise-datasource, grafana-iot-twinmaker-app, grafana-opensearch-datasource, aws-datasource-provisioner, elasticsearch, prometheus + #################################### Azure ############################### [azure] # Azure cloud environment where Grafana is hosted @@ -940,6 +982,7 @@ from_address = admin@grafana.localhost from_name = Grafana ehlo_identity = startTLS_policy = +enable_tracing = false [smtp.static_headers] # Include custom static headers in all outgoing emails @@ -1031,6 +1074,11 @@ instrumentations_console_enabled = false # Should webvitals instrumentation be enabled, only affects Grafana Javascript Agent instrumentations_webvitals_enabled = false +# level of internal logging for debugging Grafana Javascript Agent. +# possible values are: 0 = OFF, 1 = ERROR, 2 = WARN, 3 = INFO, 4 = VERBOSE +# more details: https://github.com/grafana/faro-web-sdk/blob/v1.3.7/docs/sources/tutorials/quick-start-browser.md#how-to-activate-debugging +internal_logger_level = 0 + # Api Key, only applies to Grafana Javascript Agent provider api_key = @@ -1081,10 +1129,14 @@ global_file = 1000 # global limit of correlations global_correlations = -1 +# Limit of the number of alert rules per rule group. +# This is not strictly enforced yet, but will be enforced over time. +alerting_rule_group_rules = 100 + #################################### Unified Alerting #################### [unified_alerting] -# Enable the Unified Alerting sub-system and interface. When enabled we'll migrate all of your alert rules and notification channels to the new system. New alert rules will be created and your notification channels will be converted into an Alertmanager configuration. Previous data is preserved to enable backwards compatibility but new data is removed when switching. When this configuration section and flag are not defined, the state is defined at runtime. See the documentation for more details. -enabled = false +# Enable the Alerting sub-system and interface. +enabled = # Comma-separated list of organization IDs for which to disable unified alerting. Only supported if unified alerting is enabled. disabled_orgs = @@ -1150,17 +1202,17 @@ ha_gossip_interval = 200ms # The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m. ha_push_pull_interval = 60s -# Enable or disable alerting rule execution. The alerting UI remains visible. This option has a legacy version in the `[alerting]` section that takes precedence. +# Enable or disable alerting rule execution. The alerting UI remains visible. execute_alerts = true -# Alert evaluation timeout when fetching data from the datasource. This option has a legacy version in the `[alerting]` section that takes precedence. +# Alert evaluation timeout when fetching data from the datasource. # The timeout string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m. evaluation_timeout = 30s # Number of times we'll attempt to evaluate an alert rule before giving up on that evaluation. The default value is 1. max_attempts = 1 -# Minimum interval to enforce between rule evaluations. Rules will be adjusted if they are less than this value or if they are not multiple of the scheduler interval (10s). Higher values can help with resource management as we'll schedule fewer evaluations over time. This option has a legacy version in the `[alerting]` section that takes precedence. +# Minimum interval to enforce between rule evaluations. Rules will be adjusted if they are less than this value or if they are not multiple of the scheduler interval (10s). Higher values can help with resource management as we'll schedule fewer evaluations over time. # The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m. min_interval = 10s @@ -1169,6 +1221,14 @@ min_interval = 10s # (concurrent queries per rule disabled). max_state_save_concurrency = 1 +# If the feature flag 'alertingSaveStatePeriodic' is enabled, this is the interval that is used to persist the alerting instances to the database. +# The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m. +state_periodic_save_interval = 5m + +# Disables the smoothing of alert evaluations across their evaluation window. +# Rules will evaluate in sync. +disable_jitter = false + [unified_alerting.screenshots] # Enable screenshots in notifications. You must have either installed the Grafana image rendering # plugin, or set up Grafana to use a remote rendering service. @@ -1242,6 +1302,10 @@ loki_basic_auth_username = # Optional password for basic authentication on requests sent to Loki. Can be left blank. loki_basic_auth_password = +# For "loki" only. +# Optional max query length for queries sent to Loki. Default is 721h which matches the default Loki value. +loki_max_query_length = 721h + [unified_alerting.state_history.external_labels] # Optional extra labels to attach to outbound state history records or log streams. # Any number of label key-value-pairs can be provided. @@ -1249,12 +1313,16 @@ loki_basic_auth_password = # ex. # mylabelkey = mylabelvalue -[unified_alerting.upgrade] -# If set to true when upgrading from legacy alerting to Unified Alerting, grafana will first delete all existing -# Unified Alerting resources, thus re-upgrading all organizations from scratch. If false or unset, organizations that -# have previously upgraded will not lose their existing Unified Alerting data when switching between legacy and -# Unified Alerting. Should be kept false when not needed as it may cause unintended data-loss if left enabled. -clean_upgrade = false +[unified_alerting.state_history.annotations] +# Controls retention of annotations automatically created while evaluating alert rules. +# Alert state history backend must be configured to be annotations (see setting [unified_alerting.state_history].backend). + +# Configures how long alert annotations are stored for. Default is 0, which keeps them forever. +# This setting should be expressed as a duration. Ex 6h (hours), 10d (days), 2w (weeks), 1M (month). +max_age = + +# Configures max number of alert annotations that Grafana stores. Default value is 0, which keeps all alert annotations. +max_annotations_to_keep = # NOTE: this configuration options are not used yet. [remote.alertmanager] @@ -1278,43 +1346,6 @@ password = sync_interval = 5m -#################################### Alerting ############################ -[alerting] -# Enable the legacy alerting sub-system and interface. If Unified Alerting is already enabled and you try to go back to legacy alerting, all data that is part of Unified Alerting will be deleted. When this configuration section and flag are not defined, the state is defined at runtime. See the documentation for more details. -enabled = false - -# Makes it possible to turn off alert execution but alerting UI is visible -execute_alerts = true - -# Default setting for new alert rules. Defaults to categorize error and timeouts as alerting. (alerting, keep_state) -error_or_timeout = alerting - -# Default setting for how Grafana handles nodata or null values in alerting. (alerting, no_data, keep_state, ok) -nodata_or_nullvalues = no_data - -# Alert notifications can include images, but rendering many images at the same time can overload the server -# This limit will protect the server from render overloading and make sure notifications are sent out quickly -concurrent_render_limit = 5 - -# Default setting for alert calculation timeout. Default value is 30 -evaluation_timeout_seconds = 30 - -# Default setting for alert notification timeout. Default value is 30 -notification_timeout_seconds = 30 - -# Default setting for max attempts to sending alert notifications. Default value is 3 -max_attempts = 3 - -# Makes it possible to enforce a minimal interval between evaluations, to reduce load on the backend -min_interval_seconds = 1 - -# Configures for how long alert annotations are stored. Default is 0, which keeps them forever. -# This setting should be expressed as an duration. Ex 6h (hours), 10d (days), 2w (weeks), 1M (month). -max_annotation_age = - -# Configures max number of alert annotations that Grafana stores. Default value is 0, which keeps all alert annotations. -max_annotations_to_keep = - #################################### Annotations ######################### [annotations] # Configures the batch size for the annotation clean-up job. This setting is used for dashboard, API, and alert annotations. @@ -1518,6 +1549,12 @@ concurrent_render_request_limit = 30 # Default is 5m. This should be more than enough for most deployments. # Change the value only if image rendering is failing and you see `Failed to get the render key from cache` in Grafana logs. render_key_lifetime = 5m +# Default width for panel screenshot +default_image_width = 1000 +# Default height for panel screenshot +default_image_height = 500 +# Default scale for panel screenshot +default_image_scale = 1 [panels] # here for to support old env variables, can remove after a few months @@ -1770,3 +1807,8 @@ read_only_toggles = [public_dashboards] # Set to false to disable public dashboards enabled = true + +###################################### Cloud Migration ###################################### +[cloud_migration] +# Set to true to enable target-side migration UI +is_target = false diff --git a/conf/provisioning/alerting/sample.yaml b/conf/provisioning/alerting/sample.yaml index ec41552e5310b..51836f25b3f58 100644 --- a/conf/provisioning/alerting/sample.yaml +++ b/conf/provisioning/alerting/sample.yaml @@ -67,6 +67,45 @@ apiVersion: 1 # labels: # team: sre_team_1 # isPaused: false +# # optional settings that let configure notification settings applied to alerts created by this rule +# notification_settings: +# # <string> name of the receiver (contact-point) that should be used for this route +# receiver: grafana-default-email +# # <list<string>> The labels by which incoming alerts are grouped together. For example, +# # multiple alerts coming in for cluster=A and alertname=LatencyHigh would +# # be batched into a single group. +# # +# # To aggregate by all possible labels, use the special value '...' as +# # the sole label name, for example: +# # group_by: ['...'] +# # This effectively disables aggregation entirely, passing through all +# # alerts as-is. This is unlikely to be what you want, unless you have +# # a very low alert volume or your upstream notification system performs +# # its own grouping. +# # If defined, must contain the labels 'alertname' and 'grafana_folder', except when contains '...' +# group_by: ["alertname", "grafana_folder", "region"] +# # <list> Times when the route should be muted. These must match the name of a +# # mute time interval. +# # Additionally, the root node cannot have any mute times. +# # When a route is muted it will not send any notifications, but +# # otherwise acts normally (including ending the route-matching process +# # if the `continue` option is not set) +# mute_time_intervals: +# - abc +# # <duration> How long to initially wait to send a notification for a group +# # of alerts. Allows to collect more initial alerts for the same group. +# # (Usually ~0s to few minutes). +# # If not specified, the corresponding setting of the default policy is used. +# group_wait: 30s +# # <duration> How long to wait before sending a notification about new alerts that +# # are added to a group of alerts for which an initial notification has +# # already been sent. (Usually ~5m or more). +# # If not specified, the corresponding setting of the default policy is used. +# group_interval: 5m +# # <duration> How long to wait before sending a notification again if it has already +# # been sent successfully for an alert. (Usually ~3h or more) +# # If not specified, the corresponding setting of the default policy is used. +# repeat_interval: 4h # # List of alert rule UIDs that should be deleted # deleteRules: diff --git a/conf/provisioning/datasources/sample.yaml b/conf/provisioning/datasources/sample.yaml index 2d9bae8ee5622..98895709d5989 100644 --- a/conf/provisioning/datasources/sample.yaml +++ b/conf/provisioning/datasources/sample.yaml @@ -1,50 +1,71 @@ -# # config file version +# Configuration file version apiVersion: 1 -# # list of datasources that should be deleted from the database -#deleteDatasources: +# # List of data sources to delete from the database. +# deleteDatasources: # - name: Graphite # orgId: 1 -# # list of datasources to insert/update depending -# # on what's available in the database -#datasources: -# # <string, required> name of the datasource. Required -# - name: Graphite -# # <string, required> datasource type. Required -# type: graphite -# # <string, required> access mode. direct or proxy. Required -# access: proxy -# # <int> org id. will default to orgId 1 if not specified -# orgId: 1 -# # <string> url -# url: http://localhost:8080 -# # <string> database user, if used -# user: -# # <string> database name, if used -# database: -# # <bool> enable/disable basic auth -# basicAuth: -# # <string> basic auth username -# basicAuthUser: -# # <bool> enable/disable with credentials headers -# withCredentials: -# # <bool> mark as default datasource. Max one per org -# isDefault: -# # <map> fields that will be converted to json and stored in json_data -# jsonData: -# graphiteVersion: "1.1" -# tlsAuth: true -# tlsAuthWithCACert: true -# httpHeaderName1: "Authorization" -# # <string> json object of data that will be encrypted. -# secureJsonData: -# tlsCACert: "..." -# tlsClientCert: "..." -# tlsClientKey: "..." -# # <openshift\kubernetes token example> -# httpHeaderValue1: "Bearer xf5yhfkpsnmgo" -# version: 1 -# # <bool> allow users to edit datasources from the UI. -# editable: false - +# # List of data sources to insert/update depending on what's +# # available in the database. +# datasources: +# # <string, required> Sets the name you use to refer to +# # the data source in panels and queries. +# - name: Graphite +# # <string, required> Sets the data source type. +# type: graphite +# # <string, required> Sets the access mode, either +# # proxy or direct (Server or Browser in the UI). +# # Some data sources are incompatible with any setting +# # but proxy (Server). +# access: proxy +# # <int> Sets the organization id. Defaults to orgId 1. +# orgId: 1 +# # <string> Sets a custom UID to reference this +# # data source in other parts of the configuration. +# # If not specified, Grafana generates one. +# uid: my_unique_uid +# # <string> Sets the data source's URL, including the +# # port. +# url: http://localhost:8080 +# # <string> Sets the database user, if necessary. +# user: +# # <string> Sets the database name, if necessary. +# database: +# # <bool> Enables basic authorization. +# basicAuth: +# # <string> Sets the basic authorization username. +# basicAuthUser: +# # <bool> Enables credential headers. +# withCredentials: +# # <bool> Toggles whether the data source is pre-selected +# # for new panels. You can set only one default +# # data source per organization. +# isDefault: +# # <map> Fields to convert to JSON and store in jsonData. +# jsonData: +# # <string> Defines the Graphite service's version. +# graphiteVersion: '1.1' +# # <bool> Enables TLS authentication using a client +# # certificate configured in secureJsonData. +# tlsAuth: true +# # <bool> Enables TLS authentication using a CA +# # certificate. +# tlsAuthWithCACert: true +# # <map> Fields to encrypt before storing in jsonData. +# secureJsonData: +# # <string> Defines the CA cert, client cert, and +# # client key for encrypted authentication. +# tlsCACert: '...' +# tlsClientCert: '...' +# tlsClientKey: '...' +# # <string> Sets the database password, if necessary. +# password: +# # <string> Sets the basic authorization password. +# basicAuthPassword: +# # <int> Sets the version. Used to compare versions when +# # updating. Ignored when creating a new data source. +# version: 1 +# # <bool> Allows users to edit data sources from the +# # Grafana UI. +# editable: false diff --git a/conf/provisioning/notifiers/sample.yaml b/conf/provisioning/notifiers/sample.yaml deleted file mode 100644 index 7d90983941252..0000000000000 --- a/conf/provisioning/notifiers/sample.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# # config file version -apiVersion: 1 - -# notifiers: -# - name: default-slack-temp -# type: slack -# org_name: Main Org. -# is_default: true -# uid: notifier1 -# settings: -# recipient: "XXX" -# token: "xoxb" -# uploadImage: true -# url: https://slack.com -# - name: default-email -# type: email -# org_id: 1 -# uid: notifier2 -# is_default: false -# settings: -# addresses: example11111@example.com -# delete_notifiers: -# - name: default-slack-temp -# org_name: Main Org. -# uid: notifier1 \ No newline at end of file diff --git a/conf/sample.ini b/conf/sample.ini index ad9a28410fdf0..1c52da72f8d93 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -9,10 +9,6 @@ # instance name, defaults to HOSTNAME environment variable value or hostname if HOSTNAME var is empty ;instance_name = ${HOSTNAME} -# force migration will run migrations that might cause dataloss -# Deprecated, use clean_upgrade option in [unified_alerting.upgrade] instead. -;force_migration = false - #################################### Paths #################################### [paths] # Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is used) @@ -124,6 +120,9 @@ # For "mysql", use either "true", "false", or "skip-verify". ;ssl_mode = disable +# For "postregs", use either "1" to enable or "0" to disable SNI +;ssl_sni = + # Database drivers may support different transaction isolation levels. # Currently, only "mysql" driver supports isolation levels. # If the value is empty - driver's default isolation level is applied. @@ -253,7 +252,7 @@ # for new versions of grafana. The check is used # in some UI views to notify that a grafana update exists. # This option does not cause any auto updates, nor send any information -# only a GET request to https://raw.githubusercontent.com/grafana/grafana/main/latest.json to get the latest version. +# only a GET request to https://grafana.com/api/grafana/versions/stable to get the latest version. ;check_for_updates = true # Set to false to disable all checks to https://grafana.com @@ -449,6 +448,9 @@ # Require email validation before sign up completes ;verify_email_enabled = false +# Redirect to default OrgId after login +;login_default_org_id = + # Background text for the user field on the login page ;login_hint = email or username ;password_hint = password @@ -476,6 +478,9 @@ # The duration in time a user invitation remains valid before expiring. This setting should be expressed as a duration. Examples: 6h (hours), 2d (days), 1w (week). Default is 24h (24 hours). The minimum supported duration is 15m (15 minutes). ;user_invite_max_lifetime_duration = 24h +# The duration in time a verification email, used to update the email address of a user, remains valid before expiring. This setting should be expressed as a duration. Examples: 6h (hours), 2d (days), 1w (week). Default is 1h (1 hour). +;verification_email_max_lifetime_duration = 1h + # Enter a comma-separated list of users login to hide them in the Grafana UI. These users are shown to Grafana admins and themselves. ; hidden_users = @@ -643,6 +648,7 @@ ;api_url = https://openidconnect.googleapis.com/v1/userinfo ;signout_redirect_url = ;allowed_domains = +;validate_hd = ;hosted_domain = ;allowed_groups = ;role_attribute_path = @@ -745,6 +751,7 @@ #################################### Basic Auth ########################## [auth.basic] ;enabled = true +;password_policy = false #################################### Auth Proxy ########################## [auth.proxy] @@ -774,6 +781,7 @@ # Use in conjunction with key_file in case the JWT token's header specifies a key ID in "kid" field ;key_id = some-key-id ;role_attribute_path = +;groups_attribute_path = ;role_attribute_strict = false ;auto_sign_up = false ;url_login = false @@ -808,6 +816,14 @@ # Experimental, for use in Grafana Cloud only. Please do not set. ; external_id = +# Sets the expiry duration of an assumed role. +# This setting should be expressed as a duration. Examples: 6h (hours), 10d (days), 2w (weeks), 1M (month). +; session_duration = "15m" + +# Set the plugins that will receive AWS settings for each request (via plugin context) +# By default this will include all Grafana Labs owned AWS plugins, or those that make use of AWS settings (ElasticSearch, Prometheus). +; forward_settings_to_plugins = cloudwatch, grafana-athena-datasource, grafana-redshift-datasource, grafana-x-ray-datasource, grafana-timestream-datasource, grafana-iot-sitewise-datasource, grafana-iot-twinmaker-app, grafana-opensearch-datasource, aws-datasource-provisioner, elasticsearch, prometheus + #################################### Azure ############################### [azure] # Azure cloud environment where Grafana is hosted @@ -890,6 +906,8 @@ ;ehlo_identity = dashboard.example.com # SMTP startTLS policy (defaults to 'OpportunisticStartTLS') ;startTLS_policy = NoStartTLS +# Enable trace propagation in e-mail headers, using the 'traceparent', 'tracestate' and (optionally) 'baggage' fields (defaults to false) +;enable_tracing = false [smtp.static_headers] # Include custom static headers in all outgoing emails @@ -1030,6 +1048,10 @@ # global limit of correlations ; global_correlations = -1 +# Limit of the number of alert rules per rule group. +# This is not strictly enforced yet, but will be enforced over time. +;alerting_rule_group_rules = 100 + #################################### Unified Alerting #################### [unified_alerting] #Enable the Unified Alerting sub-system and interface. When enabled we'll migrate all of your alert rules and notification channels to the new system. New alert rules will be created and your notification channels will be converted into an Alertmanager configuration. Previous data is preserved to enable backwards compatibility but new data is removed.``` @@ -1096,20 +1118,33 @@ # The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m. ;ha_push_pull_interval = "60s" -# Enable or disable alerting rule execution. The alerting UI remains visible. This option has a legacy version in the `[alerting]` section that takes precedence. +# Enable or disable alerting rule execution. The alerting UI remains visible. ;execute_alerts = true -# Alert evaluation timeout when fetching data from the datasource. This option has a legacy version in the `[alerting]` section that takes precedence. +# Alert evaluation timeout when fetching data from the datasource. # The timeout string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m. ;evaluation_timeout = 30s # Number of times we'll attempt to evaluate an alert rule before giving up on that evaluation. The default value is 1. ;max_attempts = 1 -# Minimum interval to enforce between rule evaluations. Rules will be adjusted if they are less than this value or if they are not multiple of the scheduler interval (10s). Higher values can help with resource management as we'll schedule fewer evaluations over time. This option has a legacy version in the `[alerting]` section that takes precedence. +# Minimum interval to enforce between rule evaluations. Rules will be adjusted if they are less than this value or if they are not multiple of the scheduler interval (10s). Higher values can help with resource management as we'll schedule fewer evaluations over time. # The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m. ;min_interval = 10s +# This is an experimental option to add parallelization to saving alert states in the database. +# It configures the maximum number of concurrent queries per rule evaluated. The default value is 1 +# (concurrent queries per rule disabled). +;max_state_save_concurrency = 1 + +# If the feature flag 'alertingSaveStatePeriodic' is enabled, this is the interval that is used to persist the alerting instances to the database. +# The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m. +;state_periodic_save_interval = 5m + +# Disables the smoothing of alert evaluations across their evaluation window. +# Rules will evaluate in sync. +;disable_jitter = false + [unified_alerting.reserved_labels] # Comma-separated list of reserved labels added by the Grafana Alerting engine that should be disabled. # For example: `disabled_labels=grafana_folder` @@ -1160,54 +1195,25 @@ # Optional password for basic authentication on requests sent to Loki. Can be left blank. ; loki_basic_auth_password = "mypass" +# For "loki" only. +# Optional max query length for queries sent to Loki. Default is 721h which matches the default Loki value. +; loki_max_query_length = 360h + [unified_alerting.state_history.external_labels] # Optional extra labels to attach to outbound state history records or log streams. # Any number of label key-value-pairs can be provided. ; mylabelkey = mylabelvalue -[unified_alerting.upgrade] -# If set to true when upgrading from legacy alerting to Unified Alerting, grafana will first delete all existing -# Unified Alerting resources, thus re-upgrading all organizations from scratch. If false or unset, organizations that -# have previously upgraded will not lose their existing Unified Alerting data when switching between legacy and -# Unified Alerting. Should be kept false when not needed as it may cause unintended data-loss if left enabled. -;clean_upgrade = false - -#################################### Alerting ############################ -[alerting] -# Disable legacy alerting engine & UI features -;enabled = false - -# Makes it possible to turn off alert execution but alerting UI is visible -;execute_alerts = true - -# Default setting for new alert rules. Defaults to categorize error and timeouts as alerting. (alerting, keep_state) -;error_or_timeout = alerting - -# Default setting for how Grafana handles nodata or null values in alerting. (alerting, no_data, keep_state, ok) -;nodata_or_nullvalues = no_data - -# Alert notifications can include images, but rendering many images at the same time can overload the server -# This limit will protect the server from render overloading and make sure notifications are sent out quickly -;concurrent_render_limit = 5 - -# Default setting for alert calculation timeout. Default value is 30 -;evaluation_timeout_seconds = 30 - -# Default setting for alert notification timeout. Default value is 30 -;notification_timeout_seconds = 30 - -# Default setting for max attempts to sending alert notifications. Default value is 3 -;max_attempts = 3 - -# Makes it possible to enforce a minimal interval between evaluations, to reduce load on the backend -;min_interval_seconds = 1 +[unified_alerting.state_history.annotations] +# This section controls retention of annotations automatically created while evaluating alert rules +# when alerting state history backend is configured to be annotations (a setting [unified_alerting.state_history].backend # Configures for how long alert annotations are stored. Default is 0, which keeps them forever. -# This setting should be expressed as a duration. Examples: 6h (hours), 10d (days), 2w (weeks), 1M (month). -;max_annotation_age = +# This setting should be expressed as an duration. Ex 6h (hours), 10d (days), 2w (weeks), 1M (month). +max_age = # Configures max number of alert annotations that Grafana stores. Default value is 0, which keeps all alert annotations. -;max_annotations_to_keep = +max_annotations_to_keep = #################################### Annotations ######################### [annotations] @@ -1620,4 +1626,3 @@ [public_dashboards] # Set to false to disable public dashboards ;enabled = true - diff --git a/ISSUE_TRIAGE.md b/contribute/ISSUE_TRIAGE.md similarity index 97% rename from ISSUE_TRIAGE.md rename to contribute/ISSUE_TRIAGE.md index 336b6497a25ac..70ee5c5e7bc83 100644 --- a/ISSUE_TRIAGE.md +++ b/contribute/ISSUE_TRIAGE.md @@ -4,7 +4,7 @@ The main goal of issue triage is to categorize all incoming Grafana issues and m > **Note:** This information is for Grafana project Maintainers, Owners, and Admins. If you are a Contributor, then you will not be able to perform most of the tasks in this topic. -The core maintainers of the Grafana project are responsible for categorizing all incoming issues and delegating any critical or important issue to other maintainers. Currently one maintainer each week is responsible. Besides that part, triage provides an important way to contribute to an open source project. +The core maintainers of the Grafana project are responsible for categorizing all incoming issues and delegating any critical or important issue to other maintainers. Currently, one maintainer each week is responsible. Besides that part, triage provides an important way to contribute to an open source project. Triage helps ensure issues resolve quickly by: @@ -136,13 +136,13 @@ To make it easier for everyone to understand and find issues they're searching f Depending on the issue, you might not feel all this information is needed. Use your best judgement. If you cannot triage an issue using what its author provided, explain kindly to the author that they must provide the above information to clarify the problem. Label issue with `needs more info` and add any related `area/*` or `datasource/*` labels. Alternatively, use `bot/needs more info` label and the Grafana bot will request it for you. -If the author provides the standard information but you are still unable to triage the issue, request additional information. Do this kindly and politely because you are asking for more of the author's time. +If the author provides the standard information, but you are still unable to triage the issue, request additional information. Do this kindly and politely because you are asking for more of the author's time. If the author does not respond to the requested information within the timespan of a week, close the issue with a kind note stating that the author can request for the issue to be reopened when the necessary information is provided. When you feel you have all the information needed you're ready to [categorizing the issue](#3-categorizing-an-issue). -If you receive a notification with additional information provided but you are not anymore on issue triage and you feel you do not have time to handle it, you should delegate it to the current person on issue triage. +If you receive a notification with additional information provided, but you are not anymore on issue triage and you feel you do not have time to handle it, you should delegate it to the current person on issue triage. ## 3. Categorizing an issue @@ -312,7 +312,7 @@ When an issue has all basic information provided, but the triage responsible hav Investigating issues can be a very time consuming task, especially for the maintainers, given the huge number of combinations of plugins, data sources, platforms, databases, browsers, tools, hardware, integrations, versions and cloud services, etc that are being used with Grafana. There is a certain number of combinations that are more common than others, and these are in general easier for maintainers to investigate. -For some other combinations it may not be possible at all for a maintainer to setup a proper test environment to investigate the issue. In these cases we really appreciate any help we can get from the community. Otherwise the issue is highly likely to be closed. +For some other combinations it may not be possible at all for a maintainer to setup a proper test environment to investigate the issue. In these cases we really appreciate any help we can get from the community. Otherwise, the issue is highly likely to be closed. Even if you don't have the time or knowledge to investigate an issue we highly recommend that you [upvote](https://github.blog/2016-03-10-add-reactions-to-pull-requests-issues-and-comments) the issue if you happen to have the same problem. If you have further details that may help investigating the issue please provide as much information as possible. diff --git a/UPGRADING_DEPENDENCIES.md b/contribute/UPGRADING_DEPENDENCIES.md similarity index 100% rename from UPGRADING_DEPENDENCIES.md rename to contribute/UPGRADING_DEPENDENCIES.md diff --git a/contribute/backend/errors.md b/contribute/backend/errors.md index 1d3ba3c9228f4..2d79cb8a6e141 100644 --- a/contribute/backend/errors.md +++ b/contribute/backend/errors.md @@ -109,7 +109,7 @@ fully Go modules compatible, but can be viewed using ### Error source You can optionally specify an error source that describes from where an -error originates. By default it's _server_ and means the error originates +error originates. By default, it's _server_ and means the error originates from within the application, e.g. Grafana. The `errutil.WithDownstream()` option may be appended to the NewBase function call to denote an error originates from a _downstream_ server/service. The error source information diff --git a/contribute/backend/instrumentation.md b/contribute/backend/instrumentation.md index a845825828a7b..d6d50c19c810e 100644 --- a/contribute/backend/instrumentation.md +++ b/contribute/backend/instrumentation.md @@ -80,7 +80,7 @@ func doSomething(ctx context.Context) { ### Enable certain log levels for certain loggers -During development it's convenient to enable certain log level, e.g. debug, for certain loggers to minimize the generated log output and make it easier to find things. See [[log.filters]](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#filters) for information how to configure this. +During development, it's convenient to enable certain log level, e.g. debug, for certain loggers to minimize the generated log output and make it easier to find things. See [[log.filters]](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#filters) for information how to configure this. It's also possible to configure multiple loggers: diff --git a/contribute/backend/style-guide.md b/contribute/backend/style-guide.md index 17a063d3dc521..a0877c3b44b63 100644 --- a/contribute/backend/style-guide.md +++ b/contribute/backend/style-guide.md @@ -29,13 +29,40 @@ We value clean and readable code, that is loosely coupled and covered by unit te Tests must use the standard library, `testing`. For assertions, prefer using [testify](https://github.com/stretchr/testify). +### Test Suite and Database Tests + +We have a [testsuite](https://github.com/grafana/grafana/tree/main/pkg/tests/testsuite) package which provides utilities for package-level setup and teardown. + +Currently, this is just used to ensure that test databases are correctly set up and torn down, but it also provides a place we can attach future tasks. + +Each package SHOULD include a [TestMain](https://pkg.go.dev/testing#hdr-Main) function that calls `testsuite.Run(m)`: + +```go +package mypkg + +import ( + "testing" + + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} +``` + +You only need to define `TestMain` in one `_test.go` file within each package. + +> Warning +> For tests that use the database, you MUST define `TestMain` so that the test databases can be cleaned up properly. + ### Integration Tests We run unit and integration tests separately, to help keep our CI pipeline running smoothly and provide a better developer experience. To properly mark a test as being an integration test, you must format your test function definition as follows, with the function name starting with `TestIntegration` and the check for `testing.Short()`: -``` +```go func TestIntegrationFoo(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -51,7 +78,7 @@ func TestIntegrationFoo(t *testing.T) { Use respectively [`assert.*`](https://github.com/stretchr/testify#assert-package) functions to make assertions that should _not_ halt the test ("soft checks") and [`require.*`](https://github.com/stretchr/testify#require-package) -functions to make assertions that _should_ halt the test ("hard checks"). Typically you want to use the latter type of +functions to make assertions that _should_ halt the test ("hard checks"). Typically, you want to use the latter type of check to assert that errors have or have not happened, since continuing the test after such an assertion fails is chaotic (the system under test will be in an undefined state) and you'll often have segfaults in practice. @@ -66,8 +93,8 @@ Use [`t.Cleanup`](https://golang.org/pkg/testing/#T.Cleanup) to clean up resourc ### Mock -Optionally, we use [`mock.Mock`](https://github.com/stretchr/testify#mock-package) package to write mocks. This is -useful when you expect different behaviours of the same function. +Optionally, we use [`mock.Mock`](https://github.com/stretchr/testify#mock-package) package to write mocks. +This is useful when you expect different behaviors of the same function. #### Tips @@ -126,7 +153,7 @@ assert.Equal(t, resp.Message, objectToReturn.Message) #### Mockery -When an interface to test is too big, it's annoying to mock each function manually. To avoid this, you can +When an interface to test is too big, it may be toilsome to mock each function manually. To avoid this, you can use [`mockery`](https://github.com/vektra/mockery) library to generate the mocks. The command is like the following (there are more options documented if you need to use another one): diff --git a/contribute/create-pull-request.md b/contribute/create-pull-request.md index 7fe1023a8979e..0e41754ea486f 100644 --- a/contribute/create-pull-request.md +++ b/contribute/create-pull-request.md @@ -124,7 +124,7 @@ If you're unsure, see the existing [changelog](https://github.com/grafana/grafan The pull request title should be formatted according to `<Area>: <Summary>` (Both "Area" and "Summary" should start with a capital letter). -The Grafana team _squashes_ all commits into one when we accept a pull request. The title of the pull request becomes the subject line of the squashed commit message. We still encourage contributors to write informative commit messages, as they becomes a part of the Git commit body. +The Grafana team _squashes_ all commits into one when we accept a pull request. The title of the pull request becomes the subject line of the squashed commit message. We still encourage contributors to write informative commit messages, as they become a part of the Git commit body. We use the pull request title when we generate change logs for releases. As such, we strive to make the title as informative as possible. @@ -133,7 +133,7 @@ We use the pull request title when we generate change logs for releases. As such ## Configuration changes -If your PR includes configuration changes, all of the following files must be changed correspondingly: +If your PR includes configuration changes, all the following files must be changed correspondingly: - conf/defaults.ini - conf/sample.ini diff --git a/contribute/deprecation-policy.md b/contribute/deprecation-policy.md index a49016588a147..3c305833ab1a3 100644 --- a/contribute/deprecation-policy.md +++ b/contribute/deprecation-policy.md @@ -26,6 +26,6 @@ Grafana employees can find more details in our internal docs. ## Announced deprecations. -| Name | Annoucement Date | Disabling date | Removal Date | Description | Status | -| ------------------------------------------------------------------------ | ---------------- | -------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------- | ------- | -| [Support for Mysql 5.7](https://github.com/grafana/grafana/issues/68446) | 2023-05-15 | October 2023 | | MySQL 5.7 is being deprecated in October 2023 and Grafana's policy is to test against the officially supported version. | Planned | +| Name | Announcement Date | Disabling date | Removal Date | Description | Status | +| ------------------------------------------------------------------------ | ----------------- | -------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------- | ------- | +| [Support for Mysql 5.7](https://github.com/grafana/grafana/issues/68446) | 2023-05-15 | October 2023 | | MySQL 5.7 is being deprecated in October 2023 and Grafana's policy is to test against the officially supported version. | Planned | diff --git a/contribute/developer-guide.md b/contribute/developer-guide.md index 20163d8dbfca0..650b966626304 100644 --- a/contribute/developer-guide.md +++ b/contribute/developer-guide.md @@ -8,8 +8,8 @@ Make sure you have the following dependencies installed before setting up your d - [Git](https://git-scm.com/) - [Go](https://golang.org/dl/) (see [go.mod](../go.mod#L3) for minimum required version) -- [Node.js (Long Term Support)](https://nodejs.org) -- [Yarn](https://yarnpkg.com) +- [Node.js (Long Term Support)](https://nodejs.org), with [corepack enabled](https://nodejs.org/api/corepack.html#enabling-the-feature). See [.nvmrc](../.nvmrc) for supported version. It's recommend you use a version manager such as [nvm](https://github.com/nvm-sh/nvm), [fnm](https://github.com/Schniz/fnm), or similar. +- GCC (required for Cgo dependencies) ### macOS @@ -19,7 +19,7 @@ We recommend using [Homebrew](https://brew.sh/) for installing any missing depen brew install git brew install go brew install node@20 -npm install -g yarn +corepack enable ``` ### Windows @@ -37,14 +37,6 @@ For alternative ways of cloning the Grafana repository, please refer to [GitHub' **Warning:** Do not use `go get` to download Grafana. Recent versions of Go have added behavior which isn't compatible with the way the Grafana repository is structured. -### Configure Editors - -For some IDEs, additional configuration may be needed for Typescript to work with [Yarn plug'n'play](https://yarnpkg.com/features/pnp). -For [VSCode](https://yarnpkg.com/getting-started/editor-sdks#vscode) and [Vim](https://yarnpkg.com/getting-started/editor-sdks#vim), -it's as easy as running `yarn dlx @yarnpkg/sdks vscode` or `yarn dlx @yarnpkg/sdks vim`, respectively. - -More information can be found [here](https://yarnpkg.com/getting-started/editor-sdks). - ### Configure precommit hooks We use pre-commit hooks (via [lefthook](https://github.com/evilmartians/lefthook)) to lint, fix, and format code as you commit your changes. Previously the Grafana repository automatically installed these hook when you did `yarn install`, but they are now opt in for all contributors @@ -84,6 +76,7 @@ After the command has finished, we can start building our source code: yarn start ``` +This command will generate sass theme files, build all external plugins, then build the frontend assets. Once `yarn start` has built the assets, it will continue to do so whenever any of the files change. This means you don't have to manually build the assets every time you change the code. > Troubleshooting: if your first build works, but after pulling updates you see unexpected errors in the "Type-checking in progress..." stage, these can be caused by the [tsbuildinfo cache supporting incremental builds](https://www.typescriptlang.org/tsconfig#incremental). You can `rm tsconfig.tsbuildinfo` and re-try. @@ -159,7 +152,7 @@ go run build.go test ### Run SQLLite, PostgreSQL and MySQL integration tests -By default grafana runs SQLite, to run test with SQLite +By default, grafana runs SQLite, to run test with SQLite ```bash go test -covermode=atomic -tags=integration ./pkg/... @@ -179,9 +172,11 @@ make test-go-integration-postgres ### Run end-to-end tests -The end to end tests in Grafana use [Cypress](https://www.cypress.io/) to run automated scripts in a headless Chromium browser. Read more about our [e2e framework](/contribute/style-guides/e2e.md). +The end to end tests in Grafana use [Cypress](https://www.cypress.io/) and [Playwright](https://playwright.dev/) to run automated scripts in a browser. Read more about our Cypress [e2e framework](/contribute/style-guides/e2e.md). + +#### Running Cypress tests -To run the tests: +To run all tests in a headless Chromium browser. ``` yarn e2e @@ -205,6 +200,34 @@ To choose a single test to follow in the browser as it runs, use `yarn e2e:dev` yarn e2e:dev ``` +#### To run the Playwright tests: + +**Note:** If you're using VS Code as your development editor, it's recommended to install the [Playwright test extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright). It allows you to run, debug and generate Playwright tests from within the editor. For more information about the extension and how to install it, refer to the [Playwright documentation](https://playwright.dev/docs/getting-started-vscode). + +Each version of Playwright needs specific versions of browser binaries to operate. You will need to use the Playwright CLI to install these browsers. + +``` +yarn playwright install chromium +``` + +To run all tests in a headless Chromium browser and display results in the terminal. + +``` +yarn e2e:playwright +``` + +For a better developer experience, open the Playwright UI where you can easily walk through each step of the test and visually see what was happening before, during and after each step. + +``` +yarn e2e:playwright:ui +``` + +To open the HTML reporter for the last test run session. + +``` +yarn e2e:playwright:report +``` + ## Configure Grafana for development The default configuration, `defaults.ini`, is located in the `conf` directory. @@ -259,6 +282,12 @@ The resulting image will be tagged as grafana/grafana:dev. Are you having issues with setting up your environment? Here are some tips that might help. +### IDE configuration + +Configure your IDE to use the Typescript version from the Grafana repository. The version should match the Typescript version in the package.json file, and is typically at the path `node_modules/.bin/tsc`. + +Previously Grafana used Yarn PnP to install frontend dependencies, which required additional special IDE configuration. This is no longer the case. If you have custom paths in your IDE for ESLint, Prettier, or Typescript, you can now remove them and use the defaults from node_modules. + ### Too many open files when running `make run` Depending on your environment, you may have to increase the maximum number of open files allowed. For the rest of this section, we will assume you are on a Unix like OS (e.g. Linux/macOS), where you can control the maximum number of open files through the [ulimit](https://ss64.com/bash/ulimit.html) shell command. @@ -302,7 +331,7 @@ For some people, typically using the bash shell, ulimit fails with an error simi ulimit: open files: cannot modify limit: Operation not permitted ``` -If that happens to you, chances are you've already set a lower limit and your shell won't let you set a higher one. Try looking in your shell initialization files (~/.bashrc typically), if there's already a ulimit command that you can tweak. +If that happens to you, chances are you've already set a lower limit and your shell won't let you set a higher one. Try looking in your shell initialization files (~/.bashrc typically), if there's already an ulimit command that you can tweak. ## Next steps diff --git a/contribute/drone-pipeline.md b/contribute/drone-pipeline.md index 2d0b74db04698..b75ab2649c7d1 100644 --- a/contribute/drone-pipeline.md +++ b/contribute/drone-pipeline.md @@ -14,4 +14,4 @@ The Drone pipelines are built with [Starlark](https://github.com/bazelbuild/star - Open a PR where you can do test runs for your changes. If you need to experiment with secrets, create a PR in the [grafana-ci-sandbox repo](https://github.com/grafana/grafana-ci-sandbox), before opening a PR in the main repo. - Run `make drone` after making changes to the Starlark files. This builds the `.drone.yml` file. -For further questions, reach out to the grafana-delivery squad. +For further questions, reach out to the grafana-release-guild squad. diff --git a/contribute/engineering/terminology.md b/contribute/engineering/terminology.md index 4a833dd7fb23f..5b28cb7f4b151 100644 --- a/contribute/engineering/terminology.md +++ b/contribute/engineering/terminology.md @@ -6,7 +6,7 @@ This document defines technical terms used in Grafana. ## TLS/SSL -The acronyms [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security) (Transport Layer Security and +The acronyms [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security) (Transport Layer Security) and [SSL](https://en.wikipedia.org/wiki/SSL) (Secure Socket Layer) are both used to describe the HTTPS security layer, and are in practice synonymous. However, TLS is considered the current name for the technology, and SSL is considered [deprecated](https://tools.ietf.org/html/rfc7568). diff --git a/contribute/internationalization.md b/contribute/internationalization.md index 243db35efe21a..8d32a65e3edca 100644 --- a/contribute/internationalization.md +++ b/contribute/internationalization.md @@ -9,7 +9,8 @@ Grafana uses the [i18next](https://www.i18next.com/) framework for managing tran - Use `<Trans i18nKey="search-results.panel-link">Go to {{ pageTitle }}</Trans>` in code to add a translatable phrase - Translations are stored in JSON files in `public/locales/{locale}/grafana.json` - If a particular phrase is not available in the a language then it will fall back to English -- To update phrases in English, edit the default phrase in both the component's source and the [English grafana.json message catalogue](../public/locales/en-US/grafana.json), then run `yarn i18n:extract`. +- To update phrases in English, edit the default phrase in the component's source and then run `yarn i18n:extract`. +- The single source of truth for en-US (fallback language) is in grafana/grafana, the single source of truth for any translated language is Crowdin - To update phrases in any translated language, edit the phrase in Crowdin. Do not edit the `{locale}/grafana.json` ## How to add a new translation phrase @@ -40,7 +41,8 @@ const ErrorMessage = ({ id, message }) => <Trans i18nKey={`errors.${id}`}>There 2. Upon reload, the default English phrase will appear on the page. -3. Before submitting your PR, run the `yarn i18n:extract` command to extract the messages you added into the `grafana.json` file and make them available for translation. +3. Before submitting your PR, run the `yarn i18n:extract` command to extract the messages you added into the `public/locales/en-US/grafana.json` file and make them available for translation. + **Note:** All other languages will receive their translations when they are ready to be downloaded from Crowdin. ### Plain JS usage @@ -64,20 +66,20 @@ While the `t` function can technically be used outside of React functions (e.g, ## How to add a new language -1. Add new locale in Crowdin and sync files to repo +1. Add new locale in Crowdin and download files to repo 1. Grafana OSS Crowdin project -> "dot dot dot" menu in top right -> Target languages - 2. Grafana OSS Crowdin project -> Integrations -> Github -> Sync Now - 3. If Crowdin's locale code is different from our IETF language tag, add a custom mapping in Project Settings -> Language mapping -2. Update `public/app/core/internationalization/constants.ts` (add new constant, and add to `LOCALES`) -3. Update `public/locales/i18next-parser.config.js` to add the new locale to `locales` -4. Run `yarn i18n:extract` and commit the result + 2. If Crowdin's locale code is different from our IETF language tag, add a custom mapping in Project Settings -> Language mapping + 3. GH repo grafana/grafana -> Actions -> Choose `Crowdin Download Action` -> Run workflow -> Creates a PR automatically +2. Review the PR `I18n: Download translations from Crowdin` +3. Update `public/app/core/internationalization/constants.ts` (add new constant, and add to `LOCALES`) and add changes to the open PR +4. Approve and merge the PR ## How translations work in Grafana Grafana uses the [i18next](https://www.i18next.com/) framework for managing translating phrases in the Grafana frontend. It: - Marks up phrases within our code for extraction -- Extracts phrases into messages catalogues for translating in external systems +- Extracts phrases into the default messages catalogue for translating in external systems - Manages the user's locale and putting the translated phrases in the UI Grafana will load the message catalogue JSON before the initial render. diff --git a/contribute/style-guides/frontend.md b/contribute/style-guides/frontend.md index c25b671a8924f..07055362fffc3 100644 --- a/contribute/style-guides/frontend.md +++ b/contribute/style-guides/frontend.md @@ -180,7 +180,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ }); ``` -Use hook useStyles2(getStyles) to memoize the styles generation and try to avoid passing props to the the getStyles function and instead compose classes using emotion cx function. +Use hook useStyles2(getStyles) to memoize the styles generation and try to avoid passing props to the getStyles function and instead compose classes using emotion cx function. #### Use `ALL_CAPS` for constants. diff --git a/crowdin.yml b/crowdin.yml index 1cdf69254dfa2..0d1bab25f7c40 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -1,8 +1,5 @@ files: - - source: /public/locales/en-US/grafana.json - translation: /public/locales/%locale%/%original_file_name% - type: i18next_json -pull_request_title: 'I18n: Crowdin sync' -pull_request_labels: - - area/internationalization - - no-changelog + - type: i18next_json +# The following are pulled from env variables +project_id_env: CROWDIN_PROJECT_ID +api_token_env: CROWDIN_PERSONAL_TOKEN diff --git a/devenv/bulk_alerting_dashboards/bulk_alerting_dashboards.yaml b/devenv/bulk_alerting_dashboards/bulk_alerting_dashboards.yaml deleted file mode 100644 index 1ede5dcd30a12..0000000000000 --- a/devenv/bulk_alerting_dashboards/bulk_alerting_dashboards.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: 1 - -providers: - - name: 'Bulk alerting dashboards' - folder: 'Bulk alerting dashboards' - type: file - options: - path: devenv/bulk_alerting_dashboards - diff --git a/devenv/bulk_alerting_dashboards/dashboard.libsonnet b/devenv/bulk_alerting_dashboards/dashboard.libsonnet deleted file mode 100644 index d19e1e1e45d6b..0000000000000 --- a/devenv/bulk_alerting_dashboards/dashboard.libsonnet +++ /dev/null @@ -1,169 +0,0 @@ -{ - alertingDashboard(dashboardCounter, datasourceCounter):: { - title: "alerting-title-" + dashboardCounter, - editable: true, - gnetId: null, - graphTooltip: 0, - id: null, - links: [], - panels: [ - { - alert: { - conditions: [ - { - evaluator: { - params: [ - 65 - ], - type: "gt" - }, - operator: { - type: "and" - }, - query: { - params: [ - "A", - "5m", - "now" - ] - }, - reducer: { - params: [], - type: "avg" - }, - type: "query" - } - ], - executionErrorState: "alerting", - frequency: "24h", - handler: 1, - name: "bulk alerting " + dashboardCounter, - noDataState: "no_data", - notifications: [] - }, - aliasColors: {}, - bars: false, - dashLength: 10, - dashes: false, - datasource: "gfdev-bulkalerting-" + datasourceCounter, - fill: 1, - gridPos: { - h: 9, - w: 12, - x: 0, - y: 0 - }, - id: 1, - legend: { - avg: false, - current: false, - max: false, - min: false, - show: true, - total: false, - values: false - }, - lines: true, - linewidth: 1, - nullPointMode: "null", - percentage: false, - pointradius: 5, - points: false, - renderer: "flot", - seriesOverrides: [], - spaceLength: 10, - stack: false, - steppedLine: false, - targets: [ - { - expr: "go_goroutines", - format: "time_series", - intervalFactor: 1, - refId: "A" - } - ], - thresholds: [ - { - colorMode: "critical", - fill: true, - line: true, - op: "gt", - value: 50 - } - ], - timeFrom: null, - timeShift: null, - title: "Panel Title", - tooltip: { - shared: true, - sort: 0, - value_type: "individual" - }, - type: "graph", - xaxis: { - buckets: null, - mode: "time", - name: null, - show: true, - values: [] - }, - yaxes: [ - { - format: "short", - label: null, - logBase: 1, - max: null, - min: null, - show: true - }, - { - format: "short", - label: null, - logBase: 1, - max: null, - min: null, - show: true - } - ] - } - ], - schemaVersion: 16, - tags: [], - templating: { - list: [] - }, - time: { - from: "now-6h", - to: "now" - }, - timepicker: { - refresh_intervals: [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ], - time_options: [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ] - }, - timezone: "", - uid: null, - version: 0 - }, -} - diff --git a/devenv/bulk_alerting_dashboards/datasources.jsonnet b/devenv/bulk_alerting_dashboards/datasources.jsonnet deleted file mode 100644 index 453e1a812bfe6..0000000000000 --- a/devenv/bulk_alerting_dashboards/datasources.jsonnet +++ /dev/null @@ -1,14 +0,0 @@ -local arr = std.range(1, 100); - -{ - "apiVersion": 1, - "datasources": [ - { - "name": 'gfdev-bulkalerting-' + counter, - "type": "prometheus", - "access": "proxy", - "url": "http://localhost:9090" - } - for counter in arr - ], -} diff --git a/devenv/dev-dashboards/alerting/testdata_alerts.json b/devenv/dev-dashboards/alerting/testdata_alerts.json deleted file mode 100644 index b76ad5ff9ed10..0000000000000 --- a/devenv/dev-dashboards/alerting/testdata_alerts.json +++ /dev/null @@ -1,806 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "datasource", - "uid": "grafana" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": 147, - "links": [], - "liveNow": false, - "panels": [ - { - "alert": { - "alertRuleTags": {}, - "conditions": [ - { - "evaluator": { - "params": [ - 177 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "executionErrorState": "alerting", - "for": "0m", - "frequency": "60s", - "handler": 1, - "name": "TestData - Always Alerting", - "noDataState": "no_data", - "notifications": [] - }, - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": { - "type": "testdata", - "uid": "PD8C576611E62080A" - }, - "editable": true, - "error": false, - "fieldConfig": { - "defaults": { - "links": [] - }, - "overrides": [] - }, - "fill": 1, - "fillGradient": 0, - "gridPos": { - "h": 7, - "w": 10, - "x": 0, - "y": 0 - }, - "hiddenSeries": false, - "id": 4, - "isNew": true, - "legend": { - "show": true - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "options": { - "alertThreshold": true - }, - "percentage": false, - "pluginVersion": "9.4.0-pre", - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "datasource": { - "type": "testdata", - "uid": "PD8C576611E62080A" - }, - "refId": "A", - "scenario": "random_walk", - "scenarioId": "csv_metric_values", - "stringInput": "200,445,100,150,200,220,190", - "target": "" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 177 - } - ], - "timeRegions": [], - "title": "Always Alerting", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "mode": "time", - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": "", - "logBase": 1, - "min": "0", - "show": true - }, - { - "format": "short", - "label": "", - "logBase": 1, - "show": true - } - ], - "yaxis": { - "align": false - } - }, - { - "alert": { - "alertRuleTags": {}, - "conditions": [ - { - "evaluator": { - "params": [ - 100 - ], - "type": "gt" - }, - "operator": { - "type": "and" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "executionErrorState": "alerting", - "for": "900000h", - "frequency": "1m", - "handler": 1, - "name": "TestData - Always Pending", - "noDataState": "no_data", - "notifications": [] - }, - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": { - "type": "testdata", - "uid": "PD8C576611E62080A" - }, - "editable": true, - "error": false, - "fieldConfig": { - "defaults": { - "links": [] - }, - "overrides": [] - }, - "fill": 1, - "fillGradient": 0, - "gridPos": { - "h": 7, - "w": 10, - "x": 10, - "y": 0 - }, - "hiddenSeries": false, - "id": 7, - "isNew": true, - "legend": { - "show": true - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "options": { - "alertThreshold": true - }, - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "datasource": { - "type": "testdata", - "uid": "PD8C576611E62080A" - }, - "refId": "A", - "scenario": "random_walk", - "scenarioId": "csv_metric_values", - "stringInput": "200,445,100,150,200,220,190", - "target": "" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 100 - } - ], - "timeRegions": [], - "title": "Always Pending with For", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "mode": "time", - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": "", - "logBase": 1, - "min": "0", - "show": true - }, - { - "format": "short", - "label": "", - "logBase": 1, - "show": true - } - ], - "yaxis": { - "align": false - } - }, - { - "datasource": { - "type": "testdata", - "uid": "PD8C576611E62080A" - }, - "gridPos": { - "h": 20, - "w": 4, - "x": 20, - "y": 0 - }, - "id": 9, - "targets": [ - { - "datasource": { - "type": "testdata", - "uid": "PD8C576611E62080A" - }, - "refId": "A" - } - ], - "title": "Alert list", - "type": "alertlist" - }, - { - "alert": { - "alertRuleTags": {}, - "conditions": [ - { - "evaluator": { - "params": [ - 177 - ], - "type": "gt" - }, - "operator": { - "type": "and" - }, - "query": { - "params": [ - "A", - "15m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "executionErrorState": "alerting", - "for": "1m", - "frequency": "1m", - "handler": 1, - "name": "TestData - Always Alerting For", - "noDataState": "no_data", - "notifications": [] - }, - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": { - "type": "testdata", - "uid": "PD8C576611E62080A" - }, - "editable": true, - "error": false, - "fieldConfig": { - "defaults": { - "links": [] - }, - "overrides": [] - }, - "fill": 1, - "fillGradient": 0, - "gridPos": { - "h": 6, - "w": 10, - "x": 0, - "y": 7 - }, - "hiddenSeries": false, - "id": 6, - "isNew": true, - "legend": { - "show": true - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "options": { - "alertThreshold": true - }, - "percentage": false, - "pluginVersion": "9.4.0-pre", - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "datasource": { - "type": "testdata", - "uid": "PD8C576611E62080A" - }, - "refId": "A", - "scenario": "random_walk", - "scenarioId": "csv_metric_values", - "stringInput": "200,445,100,150,200,220,190", - "target": "" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 177 - } - ], - "timeRegions": [], - "title": "Always Alerting with For", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "mode": "time", - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": "", - "logBase": 1, - "min": "0", - "show": true - }, - { - "format": "short", - "label": "", - "logBase": 1, - "show": true - } - ], - "yaxis": { - "align": false - } - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 60 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "TestData - Always OK", - "noDataState": "no_data", - "notifications": [] - }, - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": { - "type": "testdata", - "uid": "PD8C576611E62080A" - }, - "editable": true, - "error": false, - "fieldConfig": { - "defaults": { - "links": [] - }, - "overrides": [] - }, - "fill": 1, - "fillGradient": 0, - "gridPos": { - "h": 6, - "w": 10, - "x": 10, - "y": 7 - }, - "hiddenSeries": false, - "id": 3, - "isNew": true, - "legend": { - "show": true - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "options": { - "alertThreshold": true - }, - "percentage": false, - "pluginVersion": "9.4.0-pre", - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "datasource": { - "type": "testdata", - "uid": "PD8C576611E62080A" - }, - "refId": "A", - "scenario": "random_walk", - "scenarioId": "csv_metric_values", - "stringInput": "1,20,90,30,5,0", - "target": "" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 60 - } - ], - "timeRegions": [], - "title": "Always OK", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "mode": "time", - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": "", - "logBase": 1, - "max": "125", - "min": "0", - "show": true - }, - { - "format": "short", - "logBase": 1, - "show": true - } - ], - "yaxis": { - "align": false - } - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 1 - ], - "type": "gt" - }, - "operator": { - "type": "and" - }, - "query": { - "params": [ - "A", - "15m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "executionErrorState": "alerting", - "for": "5m", - "frequency": "1m", - "handler": 1, - "name": "TestData - No data", - "noDataState": "no_data", - "notifications": [] - }, - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": { - "type": "testdata", - "uid": "PD8C576611E62080A" - }, - "editable": true, - "error": false, - "fieldConfig": { - "defaults": { - "links": [] - }, - "overrides": [] - }, - "fill": 1, - "fillGradient": 0, - "gridPos": { - "h": 7, - "w": 10, - "x": 0, - "y": 13 - }, - "hiddenSeries": false, - "id": 5, - "isNew": true, - "legend": { - "show": true - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "options": { - "alertThreshold": true - }, - "percentage": false, - "pluginVersion": "9.4.0-pre", - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "datasource": { - "type": "testdata", - "uid": "PD8C576611E62080A" - }, - "refId": "A", - "scenario": "random_walk", - "scenarioId": "no_data_points", - "stringInput": "", - "target": "" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 1 - } - ], - "timeRegions": [], - "title": "No data", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "mode": "time", - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": "", - "logBase": 1, - "min": "0", - "show": true - }, - { - "format": "short", - "label": "", - "logBase": 1, - "show": true - } - ], - "yaxis": { - "align": false - } - } - ], - "schemaVersion": 37, - "tags": [ - "gdev", - "alerting" - ], - "templating": { - "list": [ - { - "current": { - "text": "TestData", - "value": "TestData" - }, - "hide": 0, - "includeAll": false, - "label": "alert name filter", - "multi": false, - "name": "namefilter", - "options": [ - { - "selected": true, - "text": "TestData", - "value": "TestData" - }, - { - "selected": false, - "text": "Prometheus", - "value": "Prometheus" - }, - { - "selected": false, - "text": "Graphite", - "value": "Graphite" - } - ], - "query": "TestData,Prometheus,Graphite", - "skipUrlSync": false, - "type": "custom" - } - ] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": { - "refresh_intervals": [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ] - }, - "timezone": "browser", - "title": "Alerting with TestData", - "uid": "7MeksYbmk", - "version": 1, - "weekStart": "" -} diff --git a/devenv/dev-dashboards/datasource-loki/loki_query_splitting.json b/devenv/dev-dashboards/datasource-loki/loki_query_splitting.json index 35285857acc69..804b354ea9da0 100644 --- a/devenv/dev-dashboards/datasource-loki/loki_query_splitting.json +++ b/devenv/dev-dashboards/datasource-loki/loki_query_splitting.json @@ -1,900 +1,1139 @@ { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": 7004, - "links": [], - "liveNow": false, - "panels": [ + "annotations": { + "list": [ { + "builtIn": 1, "datasource": { - "type": "loki", - "uid": "gdev-loki" + "type": "grafana", + "uid": "-- Grafana --" }, - "description": "Transformations:\n- Count\n- Sort by\n- Limit 10", - "gridPos": { - "h": 7, - "w": 12, - "x": 0, - "y": 0 + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" + }, + "description": "Transformations:\n- Count\n- Sort by\n- Limit 10", + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": false, + "showTime": false, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" + }, + "editorMode": "code", + "expr": "{place=\"luna\"} | logfmt | label=\"val2\" | float > 60", + "maxLines": 5000, + "queryType": "range", + "refId": "A" + } + ], + "title": "Split logs", + "transformations": [ + { + "id": "calculateField", + "options": { + "alias": "id_count", + "mode": "reduceRow", + "reduce": { + "include": [ + "id" + ], + "reducer": "count" + }, + "replaceFields": false + } }, - "id": 1, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "prettifyLogMessage": false, - "showCommonLabels": false, - "showLabels": false, - "showTime": false, - "sortOrder": "Descending", - "wrapLogMessage": false + { + "id": "sortBy", + "options": { + "fields": {}, + "sort": [ + { + "desc": true, + "field": "tsNs" + } + ] + } }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" - }, - "editorMode": "code", - "expr": "{place=\"luna\"} | logfmt | label=\"val2\" | float > 60", - "maxLines": 5000, - "queryType": "range", - "refId": "A" + { + "id": "limit", + "options": { + "limitField": 10 } - ], - "title": "Split logs", - "transformations": [ - { - "id": "calculateField", - "options": { - "alias": "id_count", - "mode": "reduceRow", - "reduce": { - "include": [ - "id" - ], - "reducer": "count" - }, - "replaceFields": false - } - }, - { - "id": "sortBy", - "options": { - "fields": {}, - "sort": [ - { - "desc": true, - "field": "tsNs" - } - ] - } + } + ], + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" + }, + "description": "Transformations:\n- Count\n- Sort by\n- Limit 10", + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": false, + "showTime": false, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" }, - { - "id": "limit", - "options": { - "limitField": 10 - } + "editorMode": "code", + "expr": "{place=\"luna\"} | logfmt | label=\"val2\" | float > 60", + "maxLines": 5000, + "queryType": "range", + "refId": "do-not-chunk" + } + ], + "title": "Logs without splitting", + "transformations": [ + { + "id": "calculateField", + "options": { + "alias": "id_count", + "mode": "reduceRow", + "reduce": { + "include": [ + "id" + ], + "reducer": "count" + }, + "replaceFields": false } - ], - "type": "logs" - }, - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" }, - "description": "Transformations:\n- Count\n- Sort by\n- Limit 10", - "gridPos": { - "h": 7, - "w": 12, - "x": 12, - "y": 0 - }, - "id": 2, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "prettifyLogMessage": false, - "showCommonLabels": false, - "showLabels": false, - "showTime": false, - "sortOrder": "Descending", - "wrapLogMessage": false + { + "id": "sortBy", + "options": { + "fields": {}, + "sort": [ + { + "desc": true, + "field": "tsNs" + } + ] + } }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" + { + "id": "limit", + "options": {} + } + ], + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false }, - "editorMode": "code", - "expr": "{place=\"luna\"} | logfmt | label=\"val2\" | float > 60", - "maxLines": 5000, - "queryType": "range", - "refId": "do-not-chunk" - } - ], - "title": "Logs without splitting", - "transformations": [ - { - "id": "calculateField", - "options": { - "alias": "id_count", - "mode": "reduceRow", - "reduce": { - "include": [ - "id" - ], - "reducer": "count" - }, - "replaceFields": false + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" } }, - { - "id": "sortBy", - "options": { - "fields": {}, - "sort": [ - { - "desc": true, - "field": "tsNs" - } - ] - } + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] }, - { - "id": "limit", - "options": {} - } - ], - "type": "logs" + "unitScale": true + }, + "overrides": [] }, - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 7 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" + }, + "editorMode": "code", + "expr": "count_over_time({place=\"luna\"} | logfmt | label=\"val2\" | float > 60 | drop wave, _entry, level, float, counter [$__auto])", + "queryType": "range", + "refId": "A" + } + ], + "title": "Split TS", + "transformations": [], + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" } }, - "overrides": [] + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unitScale": true }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 7 + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 7 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true }, - "id": 3, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" + }, + "editorMode": "code", + "expr": "count_over_time({place=\"luna\"} | logfmt | label=\"val2\" | float > 60 | drop wave, _entry, level, float, counter [$__auto])", + "queryType": "range", + "refId": "do-not-chunk" + } + ], + "title": "TS without splitting", + "transformations": [], + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" + }, + "gridPos": { + "h": 5, + "w": 12, + "x": 0, + "y": 15 + }, + "id": 5, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": false, + "showTime": false, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" + }, + "editorMode": "code", + "expr": "{place=\"luna\"} | logfmt", + "maxLines": 5000, + "queryType": "range", + "refId": "A" + } + ], + "title": "Logs with filter transformation", + "transformations": [ + { + "id": "filterByValue", + "options": { + "filters": [ + { + "config": { + "id": "isNull", + "options": {} + }, + "fieldName": "TraceID" + } + ], + "match": "any", + "type": "exclude" } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" - }, - "editorMode": "code", - "expr": "count_over_time({place=\"luna\"} | logfmt | label=\"val2\" | float > 60 | drop wave, _entry, level, float, counter [$__auto])", - "queryType": "range", - "refId": "A" + } + ], + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" + }, + "gridPos": { + "h": 5, + "w": 12, + "x": 12, + "y": 15 + }, + "id": 6, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": false, + "showTime": false, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" + }, + "editorMode": "code", + "expr": "{place=\"luna\"} | logfmt", + "maxLines": 5000, + "queryType": "range", + "refId": "do-not-chunk" + } + ], + "title": "Logs with filter transformation, no splitting", + "transformations": [ + { + "id": "filterByValue", + "options": { + "filters": [ + { + "config": { + "id": "isNull", + "options": {} + }, + "fieldName": "TraceID" + } + ], + "match": "any", + "type": "exclude" } - ], - "title": "Split TS", - "transformations": [], - "type": "timeseries" + } + ], + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" }, - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null }, - "thresholdsStyle": { - "mode": "off" + { + "color": "red", + "value": 80 } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } + ] }, - "overrides": [] + "unitScale": true }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 7 + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 20 + }, + "id": 7, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false }, - "id": 4, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" + "showHeader": true + }, + "pluginVersion": "10.3.0-pre", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" + }, + "editorMode": "code", + "expr": "{place=\"luna\", age=\"new\"}", + "maxLines": 5000, + "queryType": "range", + "refId": "A" + } + ], + "title": "Logs with extract fields transformation", + "transformations": [ + { + "id": "extractFields", + "options": { + "format": "json", + "keepTime": true, + "replace": true, + "source": "labels" } }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" + { + "id": "calculateField", + "options": { + "alias": "Row", + "mode": "index", + "reduce": { + "reducer": "sum" }, - "editorMode": "code", - "expr": "count_over_time({place=\"luna\"} | logfmt | label=\"val2\" | float > 60 | drop wave, _entry, level, float, counter [$__auto])", - "queryType": "range", - "refId": "do-not-chunk" + "replaceFields": false } - ], - "title": "TS without splitting", - "transformations": [], - "type": "timeseries" + } + ], + "type": "table" + }, + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" }, - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" - }, - "gridPos": { - "h": 5, - "w": 12, - "x": 0, - "y": 15 - }, - "id": 5, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "prettifyLogMessage": false, - "showCommonLabels": false, - "showLabels": false, - "showTime": false, - "sortOrder": "Descending", - "wrapLogMessage": false - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" }, - "editorMode": "code", - "expr": "{place=\"luna\"} | logfmt", - "maxLines": 5000, - "queryType": "range", - "refId": "A" - } - ], - "title": "Logs with filter transformation", - "transformations": [ - { - "id": "filterByValue", - "options": { - "filters": [ - { - "config": { - "id": "isNull", - "options": {} - }, - "fieldName": "TraceID" - } - ], - "match": "any", - "type": "exclude" - } - } - ], - "type": "logs" - }, - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unitScale": true }, - "gridPos": { - "h": 5, - "w": 12, - "x": 12, - "y": 15 + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 20 + }, + "id": 8, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false }, - "id": 6, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "prettifyLogMessage": false, - "showCommonLabels": false, - "showLabels": false, - "showTime": false, - "sortOrder": "Descending", - "wrapLogMessage": false + "showHeader": true + }, + "pluginVersion": "10.3.0-pre", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" + }, + "editorMode": "code", + "expr": "{place=\"luna\", age=\"new\"}", + "maxLines": 5000, + "queryType": "range", + "refId": "do-not-chunk" + } + ], + "title": "Logs with extract fields transformation, no splitting", + "transformations": [ + { + "id": "extractFields", + "options": { + "format": "json", + "keepTime": true, + "replace": true, + "source": "labels" + } }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" + { + "id": "calculateField", + "options": { + "alias": "Row", + "mode": "index", + "reduce": { + "reducer": "sum" }, - "editorMode": "code", - "expr": "{place=\"luna\"} | logfmt", - "maxLines": 5000, - "queryType": "range", - "refId": "do-not-chunk" - } - ], - "title": "Logs with filter transformation, no splitting", - "transformations": [ - { - "id": "filterByValue", - "options": { - "filters": [ - { - "config": { - "id": "isNull", - "options": {} - }, - "fieldName": "TraceID" - } - ], - "match": "any", - "type": "exclude" - } + "replaceFields": false } - ], - "type": "logs" + } + ], + "type": "table" + }, + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" }, - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } + { + "color": "red", + "value": 80 + } + ] }, - "overrides": [] + "unitScale": true }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 20 + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 28 + }, + "id": 9, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false }, - "id": 7, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false + "showHeader": true + }, + "pluginVersion": "10.3.0-pre", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" }, - "showHeader": true + "editorMode": "code", + "expr": "{place=\"moon\"}", + "maxLines": 5, + "queryType": "range", + "refId": "A" + } + ], + "title": "Logs with extract key=value and organize", + "transformations": [ + { + "id": "extractFields", + "options": { + "format": "auto", + "keepTime": true, + "replace": true, + "source": "Line" + } }, - "pluginVersion": "10.2.0-61469", - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" + { + "id": "organize", + "options": { + "excludeByName": { + "31": true, + "32": true, + "33": true, + "34": true, + "35": true, + "36": true, + "37": true, + "38": true, + "39": true, + "40": true, + "41": true, + "42": true, + "43": true, + "44": true, + "45": true, + "46": true, + "2023-09-20T14": true, + "33+00": true, + "caller": true, + "main.go": true, + "t": true, + "ts": true }, - "editorMode": "code", - "expr": "{place=\"luna\", age=\"new\"}", - "maxLines": 5000, - "queryType": "range", - "refId": "A" - } - ], - "title": "Logs with extract fields transformation", - "transformations": [ - { - "id": "extractFields", - "options": { - "format": "json", - "keepTime": true, - "replace": true, - "source": "labels" - } - }, - { - "id": "calculateField", - "options": { - "alias": "Row", - "mode": "index", - "reduce": { - "reducer": "sum" - }, - "replaceFields": false + "indexByName": {}, + "renameByName": { + "level": "nivel", + "ts": "" } } - ], - "type": "table" + } + ], + "type": "table" + }, + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" }, - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } + { + "color": "red", + "value": 80 + } + ] }, - "overrides": [] + "unitScale": true }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 20 + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 28 + }, + "id": 10, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false }, - "id": 8, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false + "showHeader": true + }, + "pluginVersion": "10.3.0-pre", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" }, - "showHeader": true + "editorMode": "code", + "expr": "{place=\"moon\"}", + "maxLines": 5, + "queryType": "range", + "refId": "do-not-chunk" + } + ], + "title": "Logs with extract key=value and organize, no splitting", + "transformations": [ + { + "id": "extractFields", + "options": { + "format": "auto", + "keepTime": true, + "replace": true, + "source": "Line" + } }, - "pluginVersion": "10.2.0-61469", - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" + { + "id": "organize", + "options": { + "excludeByName": { + "31": true, + "32": true, + "33": true, + "34": true, + "35": true, + "36": true, + "37": true, + "38": true, + "39": true, + "40": true, + "41": true, + "42": true, + "43": true, + "44": true, + "45": true, + "46": true, + "2023-09-20T14": true, + "33+00": true, + "caller": true, + "main.go": true, + "t": true, + "ts": true }, - "editorMode": "code", - "expr": "{place=\"luna\", age=\"new\"}", - "maxLines": 5000, - "queryType": "range", - "refId": "do-not-chunk" - } - ], - "title": "Logs with extract fields transformation, no splitting", - "transformations": [ - { - "id": "extractFields", - "options": { - "format": "json", - "keepTime": true, - "replace": true, - "source": "labels" - } - }, - { - "id": "calculateField", - "options": { - "alias": "Row", - "mode": "index", - "reduce": { - "reducer": "sum" - }, - "replaceFields": false + "indexByName": {}, + "renameByName": { + "level": "nivel", + "ts": "" } } - ], - "type": "table" + } + ], + "type": "table" + }, + { + "datasource": { + "type": "loki", + "uid": "PDDA8E780A17E7EF1" }, - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } + { + "color": "red", + "value": 80 + } + ] }, - "overrides": [] + "unitScale": true }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 28 + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 36 + }, + "id": 11, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false }, - "id": 9, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false + "showHeader": true + }, + "pluginVersion": "10.3.0-pre", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" }, - "showHeader": true + "editorMode": "code", + "expr": "sum(count_over_time({place=\"moon\"}[$__auto]))", + "maxLines": 5, + "queryType": "range", + "refId": "A", + "step": "1d" + } + ], + "title": "Metric 1d step", + "transformations": [ + { + "id": "extractFields", + "options": { + "format": "auto", + "keepTime": true, + "replace": true, + "source": "Line" + } }, - "pluginVersion": "10.2.0-61469", - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" + { + "id": "organize", + "options": { + "excludeByName": { + "31": true, + "32": true, + "33": true, + "34": true, + "35": true, + "36": true, + "37": true, + "38": true, + "39": true, + "40": true, + "41": true, + "42": true, + "43": true, + "44": true, + "45": true, + "46": true, + "2023-09-20T14": true, + "33+00": true, + "caller": true, + "main.go": true, + "t": true, + "ts": true }, - "editorMode": "code", - "expr": "{place=\"moon\"}", - "maxLines": 5, - "queryType": "range", - "refId": "A" - } - ], - "title": "Logs with extract key=value and organize", - "transformations": [ - { - "id": "extractFields", - "options": { - "format": "auto", - "keepTime": true, - "replace": true, - "source": "Line" - } - }, - { - "id": "organize", - "options": { - "excludeByName": { - "31": true, - "32": true, - "33": true, - "34": true, - "35": true, - "36": true, - "37": true, - "38": true, - "39": true, - "40": true, - "41": true, - "42": true, - "43": true, - "44": true, - "45": true, - "46": true, - "2023-09-20T14": true, - "33+00": true, - "caller": true, - "main.go": true, - "t": true, - "ts": true - }, - "indexByName": {}, - "renameByName": { - "level": "nivel", - "ts": "" - } + "indexByName": {}, + "renameByName": { + "level": "nivel", + "ts": "" } } - ], - "type": "table" + } + ], + "type": "table" + }, + { + "datasource": { + "type": "loki", + "uid": "PDDA8E780A17E7EF1" }, - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } + { + "color": "red", + "value": 80 + } + ] }, - "overrides": [] + "unitScale": true }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 28 + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 36 + }, + "id": 12, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false }, - "id": 10, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false + "showHeader": true + }, + "pluginVersion": "10.3.0-pre", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "gdev-loki" }, - "showHeader": true + "editorMode": "code", + "expr": "sum(count_over_time({place=\"moon\"}[$__auto]))", + "maxLines": 5, + "queryType": "range", + "refId": "do-not-chunk", + "step": "1d" + } + ], + "title": "Metric 1d step, no splitting", + "transformations": [ + { + "id": "extractFields", + "options": { + "format": "auto", + "keepTime": true, + "replace": true, + "source": "Line" + } }, - "pluginVersion": "10.2.0-61469", - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "gdev-loki" + { + "id": "organize", + "options": { + "excludeByName": { + "31": true, + "32": true, + "33": true, + "34": true, + "35": true, + "36": true, + "37": true, + "38": true, + "39": true, + "40": true, + "41": true, + "42": true, + "43": true, + "44": true, + "45": true, + "46": true, + "2023-09-20T14": true, + "33+00": true, + "caller": true, + "main.go": true, + "t": true, + "ts": true }, - "editorMode": "code", - "expr": "{place=\"moon\"}", - "maxLines": 5, - "queryType": "range", - "refId": "do-not-chunk" - } - ], - "title": "Logs with extract key=value and organize, no splitting", - "transformations": [ - { - "id": "extractFields", - "options": { - "format": "auto", - "keepTime": true, - "replace": true, - "source": "Line" - } - }, - { - "id": "organize", - "options": { - "excludeByName": { - "31": true, - "32": true, - "33": true, - "34": true, - "35": true, - "36": true, - "37": true, - "38": true, - "39": true, - "40": true, - "41": true, - "42": true, - "43": true, - "44": true, - "45": true, - "46": true, - "2023-09-20T14": true, - "33+00": true, - "caller": true, - "main.go": true, - "t": true, - "ts": true - }, - "indexByName": {}, - "renameByName": { - "level": "nivel", - "ts": "" - } + "indexByName": {}, + "renameByName": { + "level": "nivel", + "ts": "" } } - ], - "type": "table" - } - ], - "refresh": "", - "schemaVersion": 38, - "tags": [], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Datasource tests - Loki query splitting", - "uid": "dc4ec947-7e6b-4c3b-be8f-0abec7b0652a", - "version": 13, - "weekStart": "" - } \ No newline at end of file + } + ], + "type": "table" + } + ], + "refresh": "", + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Datasource tests - Loki query splitting", + "uid": "dc4ec947-7e6b-4c3b-be8f-0abec7b0652a", + "version": 2, + "weekStart": "" +} \ No newline at end of file diff --git a/devenv/dev-dashboards/live/live-publish.json b/devenv/dev-dashboards/live/live-publish.json new file mode 100644 index 0000000000000..1e7e26b8dfb1e --- /dev/null +++ b/devenv/dev-dashboards/live/live-publish.json @@ -0,0 +1,447 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 209, + "links": [], + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 2, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 9, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "## This dashboard requires alpha panels to be enabled!", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "type": "text" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 4, + "w": 15, + "x": 0, + "y": 2 + }, + "id": 2, + "options": { + "channel": { + "namespace": "devenv", + "path": "weather", + "scope": "stream" + }, + "display": "none", + "json": { + "hello": "world" + }, + "message": "weather,location=west,sensor=A temperature=82\nweather,location=east,sensor=A temperature=76", + "publish": "influx" + }, + "title": "Panel Title", + "type": "live" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 9, + "x": 15, + "y": 2 + }, + "id": 4, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "channel": "stream/devenv/weather", + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "measurements", + "refId": "A" + } + ], + "title": "Weather (values)", + "transformations": [ + { + "id": "sortBy", + "options": { + "fields": {}, + "sort": [ + { + "desc": true, + "field": "time" + } + ] + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 4, + "w": 15, + "x": 0, + "y": 6 + }, + "id": 5, + "options": { + "channel": { + "namespace": "devenv", + "path": "weather", + "scope": "stream" + }, + "display": "none", + "json": { + "hello": "world" + }, + "message": "weather,location=west,sensor=A temperature=90\nweather,location=east,sensor=A temperature=80", + "publish": "influx" + }, + "title": "Panel Title", + "type": "live" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 10 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "channel": "stream/devenv/weather", + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "measurements", + "refId": "A" + }, + { + "channel": "stream/devenv/weatherX", + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "hide": false, + "queryType": "measurements", + "refId": "B" + } + ], + "title": "Panel Title", + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 4, + "w": 15, + "x": 0, + "y": 17 + }, + "id": 6, + "options": { + "channel": { + "namespace": "devenv", + "path": "weather", + "scope": "stream" + }, + "display": "none", + "json": { + "hello": "world" + }, + "message": "weatherX,location=west,sensor=X temperature=82\nweatherX,location=east,sensor=X temperature=76", + "publish": "influx" + }, + "title": "Panel Title", + "type": "live" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 9, + "x": 15, + "y": 17 + }, + "id": 7, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "channel": "stream/devenv/weatherX", + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "measurements", + "refId": "A" + } + ], + "title": "WeatherX (values)", + "transformations": [ + { + "id": "sortBy", + "options": { + "fields": {}, + "sort": [ + { + "desc": true, + "field": "time" + } + ] + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 4, + "w": 15, + "x": 0, + "y": 21 + }, + "id": 8, + "options": { + "channel": { + "namespace": "devenv", + "path": "weather", + "scope": "stream" + }, + "display": "none", + "json": { + "hello": "world" + }, + "message": "weatherX,location=west,sensor=X temperature=90\nweatherX,location=east,sensor=X temperature=22", + "publish": "influx" + }, + "title": "Panel Title", + "type": "live" + } + ], + "schemaVersion": 39, + "tags": [ + "gdev", + "live-tests" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "live test", + "uid": "addoomtlivedev", + "version": 17, + "weekStart": "" + } \ No newline at end of file diff --git a/devenv/dev-dashboards/migrations/migrations.json b/devenv/dev-dashboards/migrations/migrations.json index e28a753409d2b..3d13fe327f696 100644 --- a/devenv/dev-dashboards/migrations/migrations.json +++ b/devenv/dev-dashboards/migrations/migrations.json @@ -21,7 +21,127 @@ "links": [ { "asDropdown": false, - "icon": "dashboard", + "icon": "external link", + "includeVars": false, + "keepTime": false, + "tags": [], + "targetBlank": true, + "title": "Auto migrate graph panel (TRUE)", + "tooltip": "", + "type": "link", + "url": " /d/cdd412c4/?__feature.autoMigrateGraphPanel=true" + }, + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": false, + "tags": [], + "targetBlank": true, + "title": "Auto migrate graph panel (FALSE)", + "tooltip": "", + "type": "link", + "url": " /d/cdd412c4/?__feature.autoMigrateGraphPanel=false" + }, + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": false, + "tags": [], + "targetBlank": true, + "title": "Auto migrate table (old) panel (TRUE)", + "tooltip": "", + "type": "link", + "url": " /d/cdd412c4/?__feature.autoMigrateTablePanel=true" + }, + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": false, + "tags": [], + "targetBlank": true, + "title": "Auto migrate table (old) panel (FALSE)", + "tooltip": "", + "type": "link", + "url": " /d/cdd412c4/?__feature.autoMigrateTablePanel=false" + }, + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": false, + "tags": [], + "targetBlank": true, + "title": "Auto migrate piechart panel (TRUE)", + "tooltip": "", + "type": "link", + "url": " /d/cdd412c4/?__feature.autoMigratePiechartPanel=true" + }, + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": false, + "tags": [], + "targetBlank": true, + "title": "Auto migrate piechart panel (FALSE)", + "tooltip": "", + "type": "link", + "url": " /d/cdd412c4/?__feature.autoMigratePiechartPanel=false" + }, + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": false, + "tags": [], + "targetBlank": true, + "title": "Auto migrate worldmap panel (TRUE)", + "tooltip": "", + "type": "link", + "url": " /d/cdd412c4/?__feature.autoMigrateWorldmapPanel=true" + }, + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": false, + "tags": [], + "targetBlank": true, + "title": "Auto migrate worldmap panel (FALSE)", + "tooltip": "", + "type": "link", + "url": " /d/cdd412c4/?__feature.autoMigrateWorldmapPanel=false" + }, + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": false, + "tags": [], + "targetBlank": true, + "title": "Auto migrate stat panel (TRUE)", + "tooltip": "", + "type": "link", + "url": " /d/cdd412c4/?__feature.autoMigrateStatPanel=true" + }, + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": false, + "tags": [], + "targetBlank": true, + "title": "Auto migrate stat panel (FALSE)", + "tooltip": "", + "type": "link", + "url": " /d/cdd412c4/?__feature.autoMigrateStatPanel=false" + }, + { + "asDropdown": false, + "icon": "external link", "includeVars": false, "keepTime": false, "tags": [], @@ -45,7 +165,7 @@ }, { "asDropdown": false, - "icon": "dashboard", + "icon": "external link", "includeVars": false, "keepTime": false, "tags": [], @@ -72,7 +192,279 @@ "panels": [ { "aliasColors": {}, - "bars": false, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 11, + "w": 16, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 4, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "percentage": false, + "pluginVersion": "11.0.0-pre", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "random_walk", + "seriesCount": 3 + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Flot graph", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "gridPos": { + "h": 11, + "w": 8, + "x": 16, + "y": 0 + }, + "id": 6, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "# Graph panel >> Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A" + } + ], + "title": "Status + Notes", + "type": "text" + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 11, + "w": 16, + "x": 0, + "y": 11 + }, + "hiddenSeries": false, + "id": 28, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "percentage": false, + "pluginVersion": "11.0.0-pre", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "random_walk", + "seriesCount": 3 + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Flot graph - x axis series mode", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "series", + "show": true, + "values": [ + "total" + ] + }, + "yaxes": [ + { + "$$hashKey": "object:88", + "format": "short", + "logBase": 1, + "show": true + }, + { + "$$hashKey": "object:89", + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "gridPos": { + "h": 11, + "w": 8, + "x": 16, + "y": 11 + }, + "id": 29, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "# Graph panel >> Bar chart panel\n", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A" + } + ], + "title": "Status + Notes", + "type": "text" + }, + { + "aliasColors": {}, + "bars": true, "dashLength": 10, "dashes": false, "datasource": { @@ -81,52 +473,6 @@ }, "fieldConfig": { "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, "unit": "short" }, "overrides": [] @@ -137,20 +483,20 @@ "h": 11, "w": 16, "x": 0, - "y": 0 + "y": 22 }, "hiddenSeries": false, - "id": 4, + "id": 30, "legend": { "avg": false, "current": false, "max": false, "min": false, - "show": true, + "show": false, "total": false, "values": false }, - "lines": true, + "lines": false, "linewidth": 1, "nullPointMode": "null", "options": { @@ -167,7 +513,7 @@ } }, "percentage": false, - "pluginVersion": "9.5.0-pre", + "pluginVersion": "11.0.0-pre", "pointradius": 2, "points": false, "renderer": "flot", @@ -188,25 +534,27 @@ ], "thresholds": [], "timeRegions": [], - "title": "Flot graph", + "title": "Flot graph - x axis histogram mode", "tooltip": { - "shared": true, + "shared": false, "sort": 0, "value_type": "individual" }, - "type": "timeseries", + "type": "graph", "xaxis": { - "mode": "time", + "mode": "histogram", "show": true, "values": [] }, "yaxes": [ { + "$$hashKey": "object:193", "format": "short", "logBase": 1, "show": true }, { + "$$hashKey": "object:194", "format": "short", "logBase": 1, "show": true @@ -225,19 +573,19 @@ "h": 11, "w": 8, "x": 16, - "y": 0 + "y": 22 }, - "id": 6, + "id": 31, "options": { "code": { "language": "plaintext", "showLineNumbers": false, "showMiniMap": false }, - "content": "# Graph panel >> Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions", + "content": "# Graph panel >> Histogram panel\n", "mode": "markdown" }, - "pluginVersion": "9.5.0-pre", + "pluginVersion": "11.0.0-pre", "targets": [ { "datasource": { @@ -261,7 +609,7 @@ "h": 10, "w": 16, "x": 0, - "y": 11 + "y": 33 }, "id": 2, "options": { @@ -337,7 +685,7 @@ "h": 10, "w": 8, "x": 16, - "y": 11 + "y": 33 }, "id": 7, "options": { @@ -349,7 +697,7 @@ "content": "# Table (old) >> Table\n\nKnown issues:\n* wrapping text\n* style changes", "mode": "markdown" }, - "pluginVersion": "9.5.0-pre", + "pluginVersion": "11.0.0-pre", "targets": [ { "datasource": { @@ -386,10 +734,9 @@ "h": 8, "w": 8, "x": 0, - "y": 21 + "y": 43 }, "id": 9, - "links": [], "mappingType": 1, "mappingTypes": [ { @@ -444,65 +791,186 @@ "valueName": "avg" }, { - "colorBackground": false, - "colorValue": true, - "colors": [ - "#299c46", - "#73BF69", - "#d44a3a" - ], "datasource": { "type": "testdata", "uid": "PD8C576611E62080A" }, - "format": "ms", - "gauge": { - "maxValue": 100, - "minValue": 0, - "show": false, - "thresholdLabels": false, - "thresholdMarkers": true + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ms" + }, + "overrides": [] }, "gridPos": { "h": 8, "w": 8, "x": 8, - "y": 21 + "y": 43 }, "id": 23, - "links": [], - "mappingType": 1, - "mappingTypes": [ + "maxDataPoints": 100, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "mean" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0-pre", + "targets": [ { - "name": "value to text", - "value": 1 + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A" + } + ], + "title": "singlestat (old, internal. Migrated if schema < 28)", + "type": "stat" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 43 + }, + "id": 10, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false }, + "content": "# Singlestat >> Stat\n\nKnown issues:\n* limited options", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ { - "name": "range to text", - "value": 2 + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A" } ], - "maxDataPoints": 100, - "nullPointMode": "connected", - "pluginVersion": "6.2.0-pre", - "postfix": "", - "postfixFontSize": "50%", - "prefix": "p95", - "prefixFontSize": "80%", - "rangeMaps": [ + "title": "Status + Notes", + "type": "text" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "gridPos": { + "h": 10, + "w": 16, + "x": 0, + "y": 51 + }, + "id": 24, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "9.5.0-pre", + "targets": [ { - "from": "null", - "text": "N/A", - "to": "null" + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "random_walk_table" } ], - "sparkline": { - "fillColor": "rgba(31, 118, 189, 0.18)", - "full": false, - "lineColor": "rgb(31, 120, 193)", - "show": true + "title": "grafana-piechart-panel", + "transformations": [ + { + "id": "merge", + "options": { + "reducers": [] + } + } + ], + "type": "grafana-piechart-panel" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" }, - "tableColumn": "", + "gridPos": { + "h": 10, + "w": 8, + "x": 16, + "y": 51 + }, + "id": 25, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "# grafana-piechart-panel >> piechart\n\nKnown issues:\n* TBD", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", "targets": [ { "datasource": { @@ -512,18 +980,152 @@ "refId": "A" } ], - "thresholds": "", - "title": "singlestat (old, internal. Migrated if schema < 28)", - "type": "singlestat", - "valueFontSize": "120%", - "valueMaps": [ + "title": "Status + Notes", + "type": "text" + }, + { + "circleMaxSize": 30, + "circleMinSize": 2, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "decimals": 0, + "esMetric": "Count", + "gridPos": { + "h": 10, + "w": 16, + "x": 0, + "y": 61 + }, + "hideEmpty": false, + "hideZero": false, + "id": 26, + "initialZoom": 1, + "locationData": "countries", + "mapCenter": "(0°, 0°)", + "mapCenterLatitude": 0, + "mapCenterLongitude": 0, + "maxDataPoints": 1, + "mouseWheelZoom": false, + "options": { + "basemap": { + "name": "Basemap", + "type": "default" + }, + "controls": { + "mouseWheelZoom": false, + "showAttribution": true, + "showDebug": false, + "showMeasure": false, + "showScale": false, + "showZoom": true + }, + "layers": [ + { + "config": { + "showLegend": true, + "style": { + "color": { + "fixed": "dark-green" + }, + "opacity": 0.4, + "rotation": { + "fixed": 0, + "max": 360, + "min": -360, + "mode": "mod" + }, + "size": { + "fixed": 5, + "max": 30, + "min": 2 + }, + "symbol": { + "fixed": "img/icons/marker/circle.svg", + "mode": "fixed" + }, + "symbolAlign": { + "horizontal": "center", + "vertical": "center" + }, + "textConfig": { + "fontSize": 12, + "offsetX": 0, + "offsetY": 0, + "textAlign": "center", + "textBaseline": "middle" + } + } + }, + "location": { + "gazetteer": "public/gazetteer/countries.json", + "mode": "lookup" + }, + "name": "Layer 0", + "tooltip": true, + "type": "markers" + } + ], + "tooltip": { + "mode": "details" + }, + "view": { + "allLayers": true, + "id": "zero", + "lat": 0, + "lon": 0, + "zoom": 1 + } + }, + "pluginVersion": "10.4.0-pre", + "showLegend": true, + "stickyLabels": false, + "tableQueryOptions": { + "geohashField": "geohash", + "latitudeField": "latitude", + "longitudeField": "longitude", + "metricField": "metric", + "queryType": "geohash" + }, + "targets": [ { - "op": "=", - "text": "N/A", - "value": "null" + "csvFileName": "flight_info_by_state.csv", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_file" } ], - "valueName": "avg" + "thresholds": "0,10", + "title": "grafana-worldmap-panel", + "transformations": [ + { + "id": "merge", + "options": { + "reducers": [] + } + }, + { + "id": "reduce", + "options": { + "reducers": [ + "sum" + ] + } + } + ], + "type": "grafana-worldmap-panel", + "unitPlural": "", + "unitSingle": "", + "valueName": "total" }, { "datasource": { @@ -531,22 +1133,22 @@ "uid": "PD8C576611E62080A" }, "gridPos": { - "h": 8, + "h": 10, "w": 8, "x": 16, - "y": 21 + "y": 61 }, - "id": 10, + "id": 27, "options": { "code": { "language": "plaintext", "showLineNumbers": false, "showMiniMap": false }, - "content": "# Singlestat >> Stat\n\nKnown issues:\n* limited options", + "content": "# grafana-worldmap-panel >> geomap\n\nKnown issues:\n* TBD", "mode": "markdown" }, - "pluginVersion": "9.5.0-pre", + "pluginVersion": "11.0.0-pre", "targets": [ { "datasource": { @@ -561,7 +1163,7 @@ } ], "refresh": "", - "schemaVersion": 34, + "schemaVersion": 39, "tags": [ "gdev", "migrations", @@ -574,10 +1176,11 @@ "from": "now-6h", "to": "now" }, + "timeRangeUpdatedDuringEditOrView": false, "timepicker": {}, "timezone": "", "title": "Devenv - Panel migrations", "uid": "cdd412c4", - "version": 6, + "version": 68, "weekStart": "" -} +} \ No newline at end of file diff --git a/devenv/dev-dashboards/panel-canvas/canvas-datalinks.json b/devenv/dev-dashboards/panel-canvas/canvas-datalinks.json new file mode 100644 index 0000000000000..8cbe54eb83e55 --- /dev/null +++ b/devenv/dev-dashboards/panel-canvas/canvas-datalinks.json @@ -0,0 +1,2940 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 740, + "links": [], + "panels": [ + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A-hehwzd" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "A-series data link", + "url": "http://localhost:3000/d/dddouk2ygsb9cc/6114e28a-4041-5fbd-878f-b98718a90c4d?${__url_time_range}" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Count (transformation)" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Count data link", + "url": "http://localhost:3000/d/dddouk2ygsb9cc/6114e28a-4041-5fbd-878f-b98718a90c4d${__data.fields.A-series}" + } + ] + } + ] + } + ] + }, + "gridPos": { + "h": 30, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "inlineEditing": false, + "panZoom": false, + "root": { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + }, + "width": 0 + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "elements": [ + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "color": { + "fixed": "rgb(204, 204, 220)" + }, + "size": 24, + "text": { + "fixed": "Metric Value" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 1", + "placement": { + "height": 50, + "left": 38, + "top": 23, + "width": 144 + }, + "type": "text" + }, + { + "background": { + "color": { + "fixed": "#D9D9D9" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "color": { + "fixed": "#000000" + }, + "size": 20, + "text": { + "field": "A-hehwzd", + "fixed": "", + "mode": "field" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 2", + "placement": { + "height": 50, + "left": 16, + "top": 91, + "width": 188 + }, + "type": "metric-value" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "color": { + "field": "A-hehwzd", + "fixed": "#000000" + }, + "size": 20, + "text": { + "fixed": "Text color", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 3", + "placement": { + "height": 50, + "left": 16, + "top": 170, + "width": 188 + }, + "type": "metric-value" + }, + { + "background": { + "color": { + "fixed": "transparent" + }, + "image": { + "field": "Count (transformation)", + "mode": "field" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "color": { + "fixed": "text" + }, + "size": 20, + "text": { + "fixed": "Background image", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 4", + "placement": { + "height": 50, + "left": 16, + "top": 249, + "width": 188 + }, + "type": "metric-value" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "field": "A-hehwzd", + "fixed": "dark-green" + }, + "width": 2 + }, + "config": { + "align": "center", + "color": { + "fixed": "text" + }, + "size": 20, + "text": { + "fixed": "Border", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 5", + "placement": { + "height": 50, + "left": 16, + "top": 328, + "width": 188 + }, + "type": "metric-value" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "color": { + "fixed": "text" + }, + "size": 16, + "text": { + "field": "A-hehwzd", + "mode": "field" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 6", + "placement": { + "height": 50, + "left": 262, + "top": 91, + "width": 188 + }, + "type": "text" + }, + { + "background": { + "color": { + "fixed": "transparent" + }, + "image": { + "fixed": "" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "color": { + "field": "A-hehwzd", + "fixed": "rgb(204, 204, 220)" + }, + "size": 16, + "text": { + "fixed": "Text color" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 7", + "placement": { + "height": 50, + "left": 262, + "top": 170, + "width": 188 + }, + "type": "text" + }, + { + "background": { + "color": { + "fixed": "transparent" + }, + "image": { + "field": "Count (transformation)", + "mode": "field" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "color": { + "fixed": "rgb(204, 204, 220)" + }, + "size": 16, + "text": { + "fixed": "Background image" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 8", + "placement": { + "height": 50, + "left": 262, + "top": 249, + "width": 188 + }, + "type": "text" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "field": "A-hehwzd", + "fixed": "dark-green" + }, + "width": 2 + }, + "config": { + "align": "center", + "color": { + "fixed": "rgb(204, 204, 220)" + }, + "size": 16, + "text": { + "fixed": "Border" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 9", + "placement": { + "height": 50, + "left": 262, + "top": 328, + "width": 188 + }, + "type": "text" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "color": { + "fixed": "rgb(204, 204, 220)" + }, + "size": 24, + "text": { + "fixed": "Text" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 10", + "placement": { + "height": 50, + "left": 284, + "top": 23, + "width": 144 + }, + "type": "text" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "backgroundColor": { + "fixed": "#D9D9D9" + }, + "borderColor": { + "fixed": "transparent" + }, + "color": { + "fixed": "#000000" + }, + "text": { + "field": "A-hehwzd", + "mode": "field" + }, + "valign": "middle", + "width": 1 + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 11", + "placement": { + "height": 50, + "left": 530, + "top": 91, + "width": 188 + }, + "type": "ellipse" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "backgroundColor": { + "fixed": "#D9D9D9" + }, + "borderColor": { + "fixed": "transparent" + }, + "color": { + "field": "A-hehwzd", + "fixed": "#000000" + }, + "text": { + "fixed": "Text color" + }, + "valign": "middle", + "width": 1 + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 12", + "placement": { + "height": 50, + "left": 530, + "top": 170, + "width": 188 + }, + "type": "ellipse" + }, + { + "background": { + "color": { + "fixed": "transparent" + }, + "image": { + "field": "Count (transformation)", + "mode": "field" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "backgroundColor": { + "fixed": "#D9D9D9" + }, + "borderColor": { + "fixed": "transparent" + }, + "color": { + "fixed": "#000000" + }, + "text": { + "fixed": "Background image" + }, + "valign": "middle", + "width": 1 + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 13", + "placement": { + "height": 50, + "left": 530, + "top": 249, + "width": 188 + }, + "type": "ellipse" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "field": "A-hehwzd", + "fixed": "dark-green" + }, + "width": 2 + }, + "config": { + "align": "center", + "backgroundColor": { + "fixed": "#D9D9D9" + }, + "borderColor": { + "fixed": "transparent" + }, + "color": { + "fixed": "#000000" + }, + "text": { + "fixed": "Border" + }, + "valign": "middle", + "width": 1 + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 14", + "placement": { + "height": 50, + "left": 530, + "top": 328, + "width": 188 + }, + "type": "ellipse" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "color": { + "fixed": "rgb(204, 204, 220)" + }, + "size": 24, + "text": { + "fixed": "Ellipse" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 15", + "placement": { + "height": 50, + "left": 552, + "top": 23, + "width": 144 + }, + "type": "text" + }, + { + "background": { + "color": { + "fixed": "#D9D9D9" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "color": { + "fixed": "#000000" + }, + "text": { + "field": "A-hehwzd", + "mode": "field" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 16", + "placement": { + "height": 50, + "left": 798, + "top": 91, + "width": 188 + }, + "type": "rectangle" + }, + { + "background": { + "color": { + "fixed": "#D9D9D9" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "color": { + "field": "A-hehwzd", + "fixed": "#000000" + }, + "text": { + "fixed": "Text color" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 17", + "placement": { + "height": 50, + "left": 798, + "top": 170, + "width": 188 + }, + "type": "rectangle" + }, + { + "background": { + "color": { + "fixed": "#D9D9D9" + }, + "image": { + "field": "Count (transformation)", + "mode": "field" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "color": { + "fixed": "#000000" + }, + "text": { + "fixed": "Background image" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 18", + "placement": { + "height": 50, + "left": 798, + "top": 249, + "width": 188 + }, + "type": "rectangle" + }, + { + "background": { + "color": { + "fixed": "#D9D9D9" + } + }, + "border": { + "color": { + "field": "A-hehwzd", + "fixed": "dark-green" + }, + "width": 2 + }, + "config": { + "align": "center", + "color": { + "fixed": "#000000" + }, + "text": { + "fixed": "Border" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 19", + "placement": { + "height": 50, + "left": 798, + "top": 328, + "width": 188 + }, + "type": "rectangle" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "color": { + "fixed": "rgb(204, 204, 220)" + }, + "size": 24, + "text": { + "fixed": "Rectangle" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 20", + "placement": { + "height": 50, + "left": 820, + "top": 23, + "width": 144 + }, + "type": "text" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "fill": { + "fixed": "#D9D9D9" + }, + "path": { + "field": "A-hehwzd", + "fixed": "img/icons/unicons/question-circle.svg", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 21", + "placement": { + "height": 50, + "left": 1066, + "top": 91, + "width": 188 + }, + "type": "icon" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "fill": { + "field": "A-hehwzd", + "fixed": "#D9D9D9" + }, + "path": { + "fixed": "img/icons/unicons/question-circle.svg", + "mode": "fixed" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 22", + "placement": { + "height": 50, + "left": 1066, + "top": 170, + "width": 188 + }, + "type": "icon" + }, + { + "background": { + "color": { + "fixed": "transparent" + }, + "image": { + "field": "Count (transformation)", + "mode": "field" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "fill": { + "fixed": "#D9D9D9" + }, + "path": { + "fixed": "img/icons/unicons/question-circle.svg", + "mode": "fixed" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 23", + "placement": { + "height": 50, + "left": 1066, + "top": 249, + "width": 188 + }, + "type": "icon" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "field": "A-hehwzd", + "fixed": "dark-green" + }, + "width": 2 + }, + "config": { + "fill": { + "fixed": "#D9D9D9" + }, + "path": { + "fixed": "img/icons/unicons/question-circle.svg", + "mode": "fixed" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 24", + "placement": { + "height": 50, + "left": 1066, + "top": 328, + "width": 188 + }, + "type": "icon" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "color": { + "fixed": "rgb(204, 204, 220)" + }, + "size": 24, + "text": { + "fixed": "Icon" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 25", + "placement": { + "height": 50, + "left": 1088, + "top": 23, + "width": 144 + }, + "type": "text" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "statusColor": { + "field": "A-hehwzd" + }, + "type": "Single" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 26", + "placement": { + "height": 90, + "left": 65, + "top": 516, + "width": 90 + }, + "type": "server" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "field": "A-hehwzd", + "fixed": "dark-green" + }, + "width": 2 + }, + "config": { + "type": "Single" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 29", + "placement": { + "height": 90, + "left": 65, + "top": 870, + "width": 90 + }, + "type": "server" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "color": { + "fixed": "rgb(204, 204, 220)" + }, + "size": 24, + "text": { + "fixed": "Server" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 30", + "placement": { + "height": 50, + "left": 38, + "top": 438, + "width": 144 + }, + "type": "text" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "bulbColor": { + "field": "A-hehwzd" + }, + "statusColor": { + "fixed": "transparent" + }, + "type": "Single" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 31", + "placement": { + "height": 90, + "left": 65, + "top": 634, + "width": 90 + }, + "type": "server" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "blinkRate": { + "field": "A-hehwzd" + }, + "bulbColor": { + "fixed": "#000000" + }, + "statusColor": { + "fixed": "transparent" + }, + "type": "Single" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 32", + "placement": { + "height": 90, + "left": 65, + "top": 752, + "width": 90 + }, + "type": "server" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "rpm": { + "field": "A-hehwzd" + }, + "statusColor": { + "field": "A-hehwzd" + }, + "type": "Single" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 33", + "placement": { + "height": 55, + "left": 329, + "top": 516, + "width": 55 + }, + "type": "windTurbine" + }, + { + "background": { + "color": { + "fixed": "transparent" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "align": "center", + "color": { + "fixed": "rgb(204, 204, 220)" + }, + "size": 24, + "text": { + "fixed": "Wind Turbine" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 35", + "placement": { + "height": 50, + "left": 270, + "top": 438, + "width": 172 + }, + "type": "text" + }, + { + "background": { + "color": { + "field": "A-hehwzd", + "fixed": "transparent" + }, + "image": { + "field": "Count (transformation)", + "mode": "field" + } + }, + "border": { + "color": { + "fixed": "dark-green" + } + }, + "config": { + "bulbColor": { + "field": "A-hehwzd" + }, + "statusColor": { + "fixed": "transparent" + }, + "type": "Single" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 36", + "placement": { + "height": 55, + "left": 329, + "top": 633, + "width": 55 + }, + "type": "windTurbine" + }, + { + "background": { + "color": { + "field": "Count (transformation)", + "fixed": "transparent" + }, + "image": { + "field": "A-hehwzd", + "mode": "field" + } + }, + "border": { + "color": { + "fixed": "dark-green" + }, + "width": 0 + }, + "config": { + "type": "Single" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 38", + "placement": { + "height": 90, + "left": 65, + "top": 988, + "width": 90 + }, + "type": "server" + }, + { + "background": { + "color": { + "fixed": "transparent" + }, + "image": { + "field": "", + "mode": "field" + } + }, + "border": { + "color": { + "field": "A-hehwzd", + "fixed": "dark-green" + }, + "width": 1 + }, + "config": { + "bulbColor": { + "field": "A-hehwzd" + }, + "statusColor": { + "fixed": "transparent" + }, + "type": "Single" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Element 37", + "placement": { + "height": 55, + "left": 329, + "top": 752, + "width": 55 + }, + "type": "windTurbine" + } + ], + "name": "Element 1708700648848", + "placement": { + "height": 100, + "left": 0, + "top": 0, + "width": 100 + }, + "type": "frame" + }, + "showAdvancedTypes": true + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "queryType": "snapshot", + "refId": "A", + "snapshot": [ + { + "data": { + "values": [ + [ + 1708679336805, + 1708679366805, + 1708679396805, + 1708679426805, + 1708679456805, + 1708679486805, + 1708679516805, + 1708679546805, + 1708679576805, + 1708679606805, + 1708679636805, + 1708679666805, + 1708679696805, + 1708679726805, + 1708679756805, + 1708679786805, + 1708679816805, + 1708679846805, + 1708679876805, + 1708679906805, + 1708679936805, + 1708679966805, + 1708679996805, + 1708680026805, + 1708680056805, + 1708680086805, + 1708680116805, + 1708680146805, + 1708680176805, + 1708680206805, + 1708680236805, + 1708680266805, + 1708680296805, + 1708680326805, + 1708680356805, + 1708680386805, + 1708680416805, + 1708680446805, + 1708680476805, + 1708680506805, + 1708680536805, + 1708680566805, + 1708680596805, + 1708680626805, + 1708680656805, + 1708680686805, + 1708680716805, + 1708680746805, + 1708680776805, + 1708680806805, + 1708680836805, + 1708680866805, + 1708680896805, + 1708680926805, + 1708680956805, + 1708680986805, + 1708681016805, + 1708681046805, + 1708681076805, + 1708681106805, + 1708681136805, + 1708681166805, + 1708681196805, + 1708681226805, + 1708681256805, + 1708681286805, + 1708681316805, + 1708681346805, + 1708681376805, + 1708681406805, + 1708681436805, + 1708681466805, + 1708681496805, + 1708681526805, + 1708681556805, + 1708681586805, + 1708681616805, + 1708681646805, + 1708681676805, + 1708681706805, + 1708681736805, + 1708681766805, + 1708681796805, + 1708681826805, + 1708681856805, + 1708681886805, + 1708681916805, + 1708681946805, + 1708681976805, + 1708682006805, + 1708682036805, + 1708682066805, + 1708682096805, + 1708682126805, + 1708682156805, + 1708682186805, + 1708682216805, + 1708682246805, + 1708682276805, + 1708682306805, + 1708682336805, + 1708682366805, + 1708682396805, + 1708682426805, + 1708682456805, + 1708682486805, + 1708682516805, + 1708682546805, + 1708682576805, + 1708682606805, + 1708682636805, + 1708682666805, + 1708682696805, + 1708682726805, + 1708682756805, + 1708682786805, + 1708682816805, + 1708682846805, + 1708682876805, + 1708682906805, + 1708682936805, + 1708682966805, + 1708682996805, + 1708683026805, + 1708683056805, + 1708683086805, + 1708683116805, + 1708683146805, + 1708683176805, + 1708683206805, + 1708683236805, + 1708683266805, + 1708683296805, + 1708683326805, + 1708683356805, + 1708683386805, + 1708683416805, + 1708683446805, + 1708683476805, + 1708683506805, + 1708683536805, + 1708683566805, + 1708683596805, + 1708683626805, + 1708683656805, + 1708683686805, + 1708683716805, + 1708683746805, + 1708683776805, + 1708683806805, + 1708683836805, + 1708683866805, + 1708683896805, + 1708683926805, + 1708683956805, + 1708683986805, + 1708684016805, + 1708684046805, + 1708684076805, + 1708684106805, + 1708684136805, + 1708684166805, + 1708684196805, + 1708684226805, + 1708684256805, + 1708684286805, + 1708684316805, + 1708684346805, + 1708684376805, + 1708684406805, + 1708684436805, + 1708684466805, + 1708684496805, + 1708684526805, + 1708684556805, + 1708684586805, + 1708684616805, + 1708684646805, + 1708684676805, + 1708684706805, + 1708684736805, + 1708684766805, + 1708684796805, + 1708684826805, + 1708684856805, + 1708684886805, + 1708684916805, + 1708684946805, + 1708684976805, + 1708685006805, + 1708685036805, + 1708685066805, + 1708685096805, + 1708685126805, + 1708685156805, + 1708685186805, + 1708685216805, + 1708685246805, + 1708685276805, + 1708685306805, + 1708685336805, + 1708685366805, + 1708685396805, + 1708685426805, + 1708685456805, + 1708685486805, + 1708685516805, + 1708685546805, + 1708685576805, + 1708685606805, + 1708685636805, + 1708685666805, + 1708685696805, + 1708685726805, + 1708685756805, + 1708685786805, + 1708685816805, + 1708685846805, + 1708685876805, + 1708685906805, + 1708685936805, + 1708685966805, + 1708685996805, + 1708686026805, + 1708686056805, + 1708686086805, + 1708686116805, + 1708686146805, + 1708686176805, + 1708686206805, + 1708686236805, + 1708686266805, + 1708686296805, + 1708686326805, + 1708686356805, + 1708686386805, + 1708686416805, + 1708686446805, + 1708686476805, + 1708686506805, + 1708686536805, + 1708686566805, + 1708686596805, + 1708686626805, + 1708686656805, + 1708686686805, + 1708686716805, + 1708686746805, + 1708686776805, + 1708686806805, + 1708686836805, + 1708686866805, + 1708686896805, + 1708686926805, + 1708686956805, + 1708686986805, + 1708687016805, + 1708687046805, + 1708687076805, + 1708687106805, + 1708687136805, + 1708687166805, + 1708687196805, + 1708687226805, + 1708687256805, + 1708687286805, + 1708687316805, + 1708687346805, + 1708687376805, + 1708687406805, + 1708687436805, + 1708687466805, + 1708687496805, + 1708687526805, + 1708687556805, + 1708687586805, + 1708687616805, + 1708687646805, + 1708687676805, + 1708687706805, + 1708687736805, + 1708687766805, + 1708687796805, + 1708687826805, + 1708687856805, + 1708687886805, + 1708687916805, + 1708687946805, + 1708687976805, + 1708688006805, + 1708688036805, + 1708688066805, + 1708688096805, + 1708688126805, + 1708688156805, + 1708688186805, + 1708688216805, + 1708688246805, + 1708688276805, + 1708688306805, + 1708688336805, + 1708688366805, + 1708688396805, + 1708688426805, + 1708688456805, + 1708688486805, + 1708688516805, + 1708688546805, + 1708688576805, + 1708688606805, + 1708688636805, + 1708688666805, + 1708688696805, + 1708688726805, + 1708688756805, + 1708688786805, + 1708688816805, + 1708688846805, + 1708688876805, + 1708688906805, + 1708688936805, + 1708688966805, + 1708688996805, + 1708689026805, + 1708689056805, + 1708689086805, + 1708689116805, + 1708689146805, + 1708689176805, + 1708689206805, + 1708689236805, + 1708689266805, + 1708689296805, + 1708689326805, + 1708689356805, + 1708689386805, + 1708689416805, + 1708689446805, + 1708689476805, + 1708689506805, + 1708689536805, + 1708689566805, + 1708689596805, + 1708689626805, + 1708689656805, + 1708689686805, + 1708689716805, + 1708689746805, + 1708689776805, + 1708689806805, + 1708689836805, + 1708689866805, + 1708689896805, + 1708689926805, + 1708689956805, + 1708689986805, + 1708690016805, + 1708690046805, + 1708690076805, + 1708690106805, + 1708690136805, + 1708690166805, + 1708690196805, + 1708690226805, + 1708690256805, + 1708690286805, + 1708690316805, + 1708690346805, + 1708690376805, + 1708690406805, + 1708690436805, + 1708690466805, + 1708690496805, + 1708690526805, + 1708690556805, + 1708690586805, + 1708690616805, + 1708690646805, + 1708690676805, + 1708690706805, + 1708690736805, + 1708690766805, + 1708690796805, + 1708690826805, + 1708690856805, + 1708690886805, + 1708690916805, + 1708690946805, + 1708690976805, + 1708691006805, + 1708691036805, + 1708691066805, + 1708691096805, + 1708691126805, + 1708691156805, + 1708691186805, + 1708691216805, + 1708691246805, + 1708691276805, + 1708691306805, + 1708691336805, + 1708691366805, + 1708691396805, + 1708691426805, + 1708691456805, + 1708691486805, + 1708691516805, + 1708691546805, + 1708691576805, + 1708691606805, + 1708691636805, + 1708691666805, + 1708691696805, + 1708691726805, + 1708691756805, + 1708691786805, + 1708691816805, + 1708691846805, + 1708691876805, + 1708691906805, + 1708691936805, + 1708691966805, + 1708691996805, + 1708692026805, + 1708692056805, + 1708692086805, + 1708692116805, + 1708692146805, + 1708692176805, + 1708692206805, + 1708692236805, + 1708692266805, + 1708692296805, + 1708692326805, + 1708692356805, + 1708692386805, + 1708692416805, + 1708692446805, + 1708692476805, + 1708692506805, + 1708692536805, + 1708692566805, + 1708692596805, + 1708692626805, + 1708692656805, + 1708692686805, + 1708692716805, + 1708692746805, + 1708692776805, + 1708692806805, + 1708692836805, + 1708692866805, + 1708692896805, + 1708692926805, + 1708692956805, + 1708692986805, + 1708693016805, + 1708693046805, + 1708693076805, + 1708693106805, + 1708693136805, + 1708693166805, + 1708693196805, + 1708693226805, + 1708693256805, + 1708693286805, + 1708693316805, + 1708693346805, + 1708693376805, + 1708693406805, + 1708693436805, + 1708693466805, + 1708693496805, + 1708693526805, + 1708693556805, + 1708693586805, + 1708693616805, + 1708693646805, + 1708693676805, + 1708693706805, + 1708693736805, + 1708693766805, + 1708693796805, + 1708693826805, + 1708693856805, + 1708693886805, + 1708693916805, + 1708693946805, + 1708693976805, + 1708694006805, + 1708694036805, + 1708694066805, + 1708694096805, + 1708694126805, + 1708694156805, + 1708694186805, + 1708694216805, + 1708694246805, + 1708694276805, + 1708694306805, + 1708694336805, + 1708694366805, + 1708694396805, + 1708694426805, + 1708694456805, + 1708694486805, + 1708694516805, + 1708694546805, + 1708694576805, + 1708694606805, + 1708694636805, + 1708694666805, + 1708694696805, + 1708694726805, + 1708694756805, + 1708694786805, + 1708694816805, + 1708694846805, + 1708694876805, + 1708694906805, + 1708694936805, + 1708694966805, + 1708694996805, + 1708695026805, + 1708695056805, + 1708695086805, + 1708695116805, + 1708695146805, + 1708695176805, + 1708695206805, + 1708695236805, + 1708695266805, + 1708695296805, + 1708695326805, + 1708695356805, + 1708695386805, + 1708695416805, + 1708695446805, + 1708695476805, + 1708695506805, + 1708695536805, + 1708695566805, + 1708695596805, + 1708695626805, + 1708695656805, + 1708695686805, + 1708695716805, + 1708695746805, + 1708695776805, + 1708695806805, + 1708695836805, + 1708695866805, + 1708695896805, + 1708695926805, + 1708695956805, + 1708695986805, + 1708696016805, + 1708696046805, + 1708696076805, + 1708696106805, + 1708696136805, + 1708696166805, + 1708696196805, + 1708696226805, + 1708696256805, + 1708696286805, + 1708696316805, + 1708696346805, + 1708696376805, + 1708696406805, + 1708696436805, + 1708696466805, + 1708696496805, + 1708696526805, + 1708696556805, + 1708696586805, + 1708696616805, + 1708696646805, + 1708696676805, + 1708696706805, + 1708696736805, + 1708696766805, + 1708696796805, + 1708696826805, + 1708696856805, + 1708696886805, + 1708696916805, + 1708696946805, + 1708696976805, + 1708697006805, + 1708697036805, + 1708697066805, + 1708697096805, + 1708697126805, + 1708697156805, + 1708697186805, + 1708697216805, + 1708697246805, + 1708697276805, + 1708697306805, + 1708697336805, + 1708697366805, + 1708697396805, + 1708697426805, + 1708697456805, + 1708697486805, + 1708697516805, + 1708697546805, + 1708697576805, + 1708697606805, + 1708697636805, + 1708697666805, + 1708697696805, + 1708697726805, + 1708697756805, + 1708697786805, + 1708697816805, + 1708697846805, + 1708697876805, + 1708697906805, + 1708697936805, + 1708697966805, + 1708697996805, + 1708698026805, + 1708698056805, + 1708698086805, + 1708698116805, + 1708698146805, + 1708698176805, + 1708698206805, + 1708698236805, + 1708698266805, + 1708698296805, + 1708698326805, + 1708698356805, + 1708698386805, + 1708698416805, + 1708698446805, + 1708698476805, + 1708698506805, + 1708698536805, + 1708698566805, + 1708698596805, + 1708698626805, + 1708698656805, + 1708698686805, + 1708698716805, + 1708698746805, + 1708698776805, + 1708698806805, + 1708698836805, + 1708698866805, + 1708698896805, + 1708698926805, + 1708698956805, + 1708698986805, + 1708699016805, + 1708699046805, + 1708699076805, + 1708699106805, + 1708699136805, + 1708699166805, + 1708699196805, + 1708699226805, + 1708699256805, + 1708699286805, + 1708699316805, + 1708699346805, + 1708699376805, + 1708699406805, + 1708699436805, + 1708699466805, + 1708699496805, + 1708699526805, + 1708699556805, + 1708699586805, + 1708699616805, + 1708699646805, + 1708699676805, + 1708699706805, + 1708699736805, + 1708699766805, + 1708699796805, + 1708699826805, + 1708699856805, + 1708699886805, + 1708699916805, + 1708699946805, + 1708699976805, + 1708700006805, + 1708700036805, + 1708700066805, + 1708700096805, + 1708700126805, + 1708700156805, + 1708700186805, + 1708700216805, + 1708700246805, + 1708700276805, + 1708700306805, + 1708700336805, + 1708700366805, + 1708700396805, + 1708700426805, + 1708700456805, + 1708700486805, + 1708700516805, + 1708700546805, + 1708700576805, + 1708700606805, + 1708700636805, + 1708700666805, + 1708700696805, + 1708700726805, + 1708700756805, + 1708700786805, + 1708700816805, + 1708700846805, + 1708700876805, + 1708700906805 + ], + [ + 61.7880220958275, + 61.81270791445456, + 61.829208768453746, + 61.3952804521061, + 61.54378204663586, + 61.793292376739025, + 61.89508181687579, + 62.032346031223305, + 62.090954811114734, + 61.788446359641156, + 61.34681406228381, + 61.37147422434034, + 61.40948125782848, + 61.69144945060843, + 61.790001610516114, + 61.9726511399688, + 62.17186395369245, + 61.841593279449114, + 61.81351577982158, + 61.774548608665306, + 61.45071912098117, + 61.837381696515884, + 61.65059114296433, + 61.1939165193637, + 61.251406089255795, + 60.90456801094042, + 60.56801269725712, + 60.79736902333104, + 60.9367183948152, + 61.40785835680261, + 61.621326272755965, + 61.53478423246078, + 61.616180375313114, + 61.56950269911591, + 61.419724817297585, + 61.76631225002593, + 62.12897242834343, + 62.123394668856335, + 61.84384351878861, + 61.784342739352965, + 61.946359707479985, + 62.00854561243485, + 61.949378148238175, + 61.83251536870273, + 62.10046102360543, + 62.51955528279164, + 62.43194543555232, + 61.93563724102479, + 61.61222776112464, + 61.706572968014584, + 61.25653135192282, + 61.356339636867965, + 61.699918128409436, + 61.700832273918536, + 61.48680261436916, + 61.23885493750151, + 60.88064816100224, + 60.8061115971784, + 60.48689168723215, + 60.55420316390225, + 60.78326551379002, + 60.71763616746389, + 61.0384027799684, + 61.361681117105356, + 61.819694140841825, + 61.60303629180282, + 61.13531779096821, + 61.59247554276454, + 62.073700396192514, + 62.257393021269124, + 62.433793965222456, + 62.250955190991135, + 61.85027922321473, + 61.991414140632, + 62.103475488499456, + 62.1675085033716, + 61.81759701584563, + 61.44204189561965, + 61.39004247589699, + 61.84841370788845, + 62.01982884908544, + 62.29261477385842, + 62.1881407732118, + 61.77974858108549, + 61.30269030921595, + 61.72885233653658, + 62.160061928899516, + 62.13127060574461, + 61.99860014382346, + 62.2713524353439, + 62.45360209624644, + 62.45033536766769, + 62.74579940152534, + 62.652379007396135, + 62.50461375528075, + 62.4923739782267, + 62.87608071230181, + 62.979684959589086, + 63.376138214369306, + 63.66354651661235, + 63.77873383844567, + 64.02365055931571, + 63.57333739418103, + 63.569251641994995, + 63.51940188659398, + 63.81887126104792, + 64.10808860762592, + 64.26214829221084, + 63.81353904099011, + 63.792123977174654, + 64.09459044217124, + 64.49651926575704, + 64.87380353415439, + 65.22102686208456, + 65.49078940759729, + 65.83529933530703, + 66.06313506256244, + 66.36902912666964, + 66.78048161957578, + 66.85326316970041, + 67.14736719324404, + 67.55927886414139, + 67.497025942169, + 67.84593513490577, + 67.47441764273138, + 67.94871645670011, + 68.32729017349827, + 68.0857872436968, + 67.93500201308075, + 67.75781760651351, + 67.62321694227796, + 67.87687655718156, + 68.03974600191707, + 67.82627187210018, + 68.29863099170737, + 67.95842239244082, + 67.56173819267319, + 67.11089985276264, + 67.58721546400895, + 67.43817707268052, + 67.63734474285276, + 67.8608900819446, + 68.2491098921388, + 68.64222598287019, + 68.93436090180123, + 68.58516897681044, + 68.88556663186037, + 68.82509258379442, + 68.43122435581691, + 68.01151775860062, + 68.31539501569645, + 68.01224489043588, + 68.21383479637454, + 67.85864255481124, + 67.85285437130011, + 67.68770174333852, + 67.78834746198736, + 68.07096889676643, + 68.06340992932907, + 68.37116739265392, + 68.25606363201948, + 68.0308569092854, + 68.0279418148109, + 67.68837311892736, + 67.98015788941186, + 68.39001231797856, + 68.55810426314746, + 68.67055538403375, + 68.24669275435873, + 68.3542775530211, + 67.99792233705742, + 67.93848132742042, + 67.54327434886798, + 67.86541640645122, + 67.63715332453567, + 67.44863373545599, + 67.54029786855584, + 67.3799227903252, + 67.272800723832, + 67.65752571426705, + 67.27217330871126, + 67.36259068699385, + 67.6920166495926, + 67.99237921892899, + 68.00526928940994, + 67.67665255949264, + 67.82106088034847, + 68.22225474161398, + 68.17103508007338, + 68.1016199190692, + 67.9715626706774, + 68.32706440829391, + 67.87634330533211, + 67.97207720874553, + 67.87493382264455, + 67.38357737752105, + 66.94768029712087, + 66.67493510394992, + 66.48367657533245, + 66.77035937087237, + 66.87685661575208, + 66.68590889792685, + 67.11545823298368, + 67.39896920212514, + 67.34979904565203, + 67.19769898467558, + 67.45512207826779, + 67.07906680752531, + 67.41541723819202, + 67.15472798048341, + 67.15672773264464, + 67.6290223056695, + 68.11240707910342, + 68.40594352628086, + 67.92798893549167, + 67.46919856375305, + 67.4835115803936, + 67.63236807133978, + 67.17328415938867, + 67.24458272746313, + 66.84593455247185, + 67.01430262963748, + 67.05291762336604, + 67.23817875420809, + 66.98941509949245, + 66.68717338595964, + 67.12826784930103, + 66.74586515933792, + 67.12397362624425, + 66.91465424208462, + 66.68655917540693, + 66.19881496899525, + 66.68817057925634, + 66.37134623059349, + 66.596971028669, + 67.08641642499875, + 67.12758650919066, + 67.38518121414808, + 67.46997153732326, + 67.33145553844577, + 67.65363406004056, + 67.45595043797628, + 67.24978988019829, + 67.39677514651268, + 67.5978323405599, + 67.96140308646021, + 68.00477049936622, + 67.75104436464927, + 67.99988077010511, + 68.19366044789288, + 68.49848829432483, + 68.25947149341026, + 68.6317723773926, + 68.91668608614779, + 68.8159923502771, + 69.17513974647629, + 69.23356802652235, + 69.25309375298245, + 69.21125949874141, + 69.20726323550102, + 68.78019883289588, + 69.24667107728621, + 69.1153426259508, + 69.56206231608063, + 69.72141422867662, + 69.541238907092, + 69.81884141217625, + 69.53432369593423, + 69.43315031151812, + 69.01688148574168, + 68.90923934889648, + 69.13664462290991, + 69.31984528071725, + 69.14875943039056, + 69.48885652654923, + 69.88297168977773, + 69.42462273878934, + 69.12679413148601, + 68.98123233159377, + 68.7762758442149, + 68.48846507574687, + 68.02689832504595, + 68.02852643078998, + 67.555096330903, + 67.4639788904284, + 67.61277291577984, + 67.70121565906206, + 67.66817331556194, + 67.84767253849441, + 68.18538578924706, + 68.29860737971661, + 68.53693780239855, + 68.59722904625475, + 68.72061222893264, + 68.6709989466837, + 69.09400533816238, + 69.20295511428635, + 69.04258759148102, + 68.77139467473914, + 68.57756213054044, + 68.67449080482191, + 68.60860533532343, + 69.01676017621124, + 69.3052161196497, + 68.89017381442457, + 68.87423335356772, + 68.94718162265079, + 69.07199142609447, + 68.79769510366016, + 68.65106890757201, + 68.77703094552813, + 68.28258034480257, + 68.39479364216955, + 68.34220719427985, + 68.64253416645396, + 68.87725555362057, + 68.5131252571485, + 68.89127035251008, + 68.67886068136877, + 68.4447624537287, + 68.00181105736998, + 68.16998112143412, + 68.43591872875314, + 68.02705208136817, + 68.45211203302853, + 68.53289570629535, + 68.58200283313724, + 68.25718933019785, + 68.36370956465593, + 68.05020228727913, + 68.50658655636543, + 68.66449948753682, + 68.2241732330651, + 68.09791466418139, + 67.88940531760399, + 68.36978817923638, + 67.96433264274425, + 68.36349840881765, + 68.48435193949715, + 68.79844380614031, + 69.02017361165895, + 68.52624119834393, + 68.74005315812674, + 68.76067653657759, + 68.27918960695276, + 68.65714329915559, + 68.42872809873309, + 67.98192094490678, + 68.02723949195821, + 68.36423058076824, + 68.71132274325691, + 68.6730162618873, + 68.2039400216969, + 67.86554691576616, + 67.49988053949725, + 67.16307215146469, + 67.0353669125758, + 67.25486895820083, + 66.95629780399439, + 67.11477592840751, + 66.64323746050167, + 66.20269172026232, + 66.48026858749327, + 66.4824015740555, + 66.09931551662274, + 66.52915019033718, + 66.45576884782787, + 66.24667785942938, + 65.81418399593981, + 65.5611498094969, + 65.3398959293035, + 65.77646026507297, + 65.39346792956597, + 65.41692492067247, + 65.80888038895401, + 66.01134945900266, + 65.80681809031556, + 65.32295296805074, + 65.37373854104783, + 64.93533382859144, + 64.7705026812803, + 65.17813748381224, + 65.361732144628, + 64.9021692008158, + 64.7544901225187, + 64.89471070898715, + 64.74868721773261, + 65.00087272748908, + 65.13124433830268, + 64.86532668113782, + 64.51108629312537, + 64.48208116580882, + 64.86340417937541, + 64.5927354669956, + 64.1183990008181, + 63.66603997870095, + 63.670535114339415, + 63.179312479687646, + 62.71414451358941, + 62.349677869382184, + 62.73321111245177, + 62.63668992149067, + 62.246656820357686, + 62.12151749467506, + 62.09114940207692, + 62.13652644719604, + 62.59170540130276, + 62.28435878005047, + 62.62555470862962, + 62.73559453227453, + 62.63492310358647, + 62.99493118502586, + 63.2621260239913, + 62.99279716454684, + 62.8629723908099, + 63.01324605242161, + 63.27456767760901, + 63.11585082867133, + 63.04279509050621, + 63.10999097814922, + 63.544265738869036, + 63.11822935137409, + 63.01523903936018, + 63.20403674621687, + 62.83896086980938, + 62.41843196460615, + 62.11985404869429, + 62.07986319614809, + 61.99277704632526, + 61.668764434861785, + 61.24484474074755, + 61.461440830656386, + 61.276838934561326, + 61.190724244624434, + 61.55613660124493, + 61.12078454699415, + 61.3276958600818, + 61.6042219184118, + 61.25513892816126, + 61.31664892177688, + 61.688070650858535, + 62.01190639528168, + 62.08869564629636, + 61.97211569824653, + 62.06419901170042, + 62.32586483758532, + 62.23902878995992, + 62.10813808603461, + 61.85578175807045, + 62.23028871528028, + 62.632050281179744, + 62.94816419819628, + 62.73043921369783, + 62.23627146727455, + 62.584832680453275, + 62.62993353336442, + 62.8749075179418, + 62.48040775408503, + 62.21373335340891, + 61.79955494265226, + 61.833015907179636, + 61.39273179502193, + 61.269508387088344, + 61.72530078527864, + 62.09495330426252, + 62.151597131318645, + 62.03375578347783, + 62.417839543433374, + 62.857582366978384, + 62.57434410206618, + 63.025461247334185, + 62.891637883802545, + 62.74129737438404, + 62.686344200077194, + 62.74668907124072, + 62.8988624768576, + 62.60573732388666, + 63.025949401766745, + 62.67167444177004, + 62.90064380635685, + 62.864966632927896, + 62.75573606469665, + 62.64819512833622, + 62.568258206957815, + 62.60577268655998, + 62.15762551315059, + 62.567239861872935, + 62.2060758300364, + 62.31900525191699, + 62.28916506290531, + 62.44905922260444, + 62.461296429876256, + 62.500385909925505, + 62.650801268022704, + 62.56666212391569, + 62.40046878730333, + 62.094243157050244, + 62.53663695990193, + 62.42300679544488, + 62.36921559899188, + 62.24670139180489, + 62.569517596988945, + 62.66647211282815, + 62.665771463959715, + 62.876380887882625, + 62.45928337880216, + 62.909344895815984, + 62.792949925824054, + 62.83068709163355, + 62.69318202524612, + 62.87621607310353, + 63.31283919698538, + 63.25629003985129, + 63.705604438529726, + 63.273909526061956, + 63.5892070912608, + 63.25172069842234, + 63.29295380191922, + 63.37827406047424, + 63.40692971848919, + 62.91617622008834, + 62.78539371994352, + 62.67023463643756, + 62.778172553545886, + 62.47010093156481, + 62.73229570857713, + 62.59545419552364, + 63.021765325598885, + 63.12625341655425, + 62.78539970673421, + 62.48947067386955, + 62.50019409861797, + 62.27827076256643, + 62.32133231940964, + 62.28201148707485, + 62.75074499118775, + 62.2850034814543, + 62.11551149639342, + 62.19275216451308, + 61.99131816833137, + 61.53708598957499, + 61.935909883441674, + 61.982544659526965, + 62.47356745991423, + 62.54325753022373, + 62.146857201138765, + 62.189218538018835, + 62.06268691181029, + 62.33603183556959, + 62.82869197777662, + 63.18597742858942, + 63.032993740715966, + 63.5325845753775, + 63.562424373715885, + 63.50147245681481, + 63.9167851513779, + 64.41348447594058, + 64.47641931732102, + 64.00422787602398, + 63.84372934402268, + 63.57536630107787, + 64.06767133087304, + 63.857109831163605, + 63.594856285335126, + 64.01811161431772, + 64.07634398750827, + 64.53914449236566, + 64.0503925198368, + 63.65098131727389, + 63.68701372362301, + 63.69177696813634, + 63.37477814620089, + 62.9056011078514, + 62.51297543133976, + 62.59250225336332, + 62.80712544689635, + 62.841367040210486, + 63.01245999144349, + 63.05987075936997, + 62.7064932753452, + 62.53562962324097, + 62.31063278405174, + 62.266535846784436, + 62.302218124559445, + 62.342098988884466, + 61.85031656038063, + 62.236466943962625, + 62.38666354433638, + 62.50448443527158, + 62.2386149786552, + 62.43706094747305, + 62.48167927367151, + 62.53256313135768, + 62.78282604117919, + 62.610692060658806, + 62.60559562844521, + 62.522478905756024, + 62.11039441006909, + 62.455676051055406, + 62.12208142671207, + 62.08989132214651, + 61.60556028504288, + 61.72080384449528, + 61.67203415718332, + 61.300028102691506, + 61.2882234681282, + 61.67414171824772, + 61.663919195939215, + 61.82126261684866, + 62.14747753168045, + 61.90000454947883, + 61.550477655414085, + 61.823665895055534, + 62.2914692558774, + 61.97827489307881, + 62.373656454593686, + 62.77509964777066, + 63.03123026432718, + 62.71959998560016, + 62.798641799535915, + 62.36309802737797, + 62.187803490902276, + 61.86629603436246, + 61.51332614924758, + 61.670529134596514, + 62.01614193494398, + 62.42759829956865, + 62.04049015814663, + 62.4879050225676, + 62.534578908118796, + 62.53729183335963, + 62.507213724278294, + 62.91741156445245, + 62.80078558969012, + 62.89530940332275, + 62.82952275179085, + 62.931001539540134, + 62.715893011718826, + 63.176668587903144, + 63.25804897932602, + 63.715274804489724, + 63.25110762353138, + 63.703759539930466, + 63.9121877763375, + 63.74483403967367, + 63.278090432146044, + 63.25689283100131, + 63.5587547259249, + 63.99611056226184, + 63.711033213960846, + 63.61430481648302, + 63.37762519043254, + 63.825084284030204, + 63.65971618383464, + 64.11513361026066, + 64.55773804769903, + 64.72114634405787, + 64.92113863156139, + 64.74299967907277, + 64.58315181550863, + 64.53895512079164, + 64.29203322638713, + 64.35539787981175, + 64.51347297698413, + 64.41240397641245, + 64.37002365296716, + 64.54885534394022, + 65.0387758477243, + 64.77700441309366, + 65.18362739291737, + 65.05949296204716, + 64.57684960686204, + 64.47443633413799, + 64.25660237904727, + 64.56848853603353, + 64.13857108098799, + 63.779464548765795, + 63.940686810035814, + 63.47280572727399, + 63.75027671584932, + 63.72842205520389, + 63.28631208752196, + 63.54806381048302, + 63.89145142689777, + 63.77801877620565, + 64.22035568724904, + 63.99613288560804, + 64.03027098363421, + 64.45100338093056, + 64.4954103610469, + 64.0379570128468, + 64.509809400983, + 64.24776908391132, + 64.18308085450447, + 64.02686567211323, + 64.00949969057655, + 64.08790624904579, + 64.44413148646105, + 64.436667344197, + 64.925560935662, + 64.4380208837763, + 64.63611397061874, + 64.53720467943039, + 64.89043109965093, + 65.02933526203901, + 65.38083597755491, + 65.63733460002958, + 66.04889884599933, + 66.15611674378638, + 66.58017724018646, + 66.13733327264003, + 65.69321107501534, + 65.92059175869956, + 65.73905913370584, + 65.39714875503125, + 65.31355908128221, + 65.75886616744013, + 66.05775873128395, + 65.93022978097021, + 66.3107650269844 + ] + ] + }, + "schema": { + "fields": [ + { + "config": { + "interval": 30000 + }, + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "config": {}, + "labels": {}, + "name": "A-hehwzd", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + } + } + ], + "meta": { + "custom": { + "customStat": 10 + }, + "typeVersion": [ + 0, + 0 + ] + }, + "refId": "A" + } + } + ] + } + ], + "title": "Canvas Enhanced Data Links", + "transformations": [ + { + "id": "calculateField", + "options": { + "alias": "Count (transformation)", + "mode": "reduceRow", + "reduce": { + "include": [ + "A-series" + ], + "reducer": "count" + } + } + } + ], + "type": "canvas" + } + ], + "schemaVersion": 39, + "tags": [ + "gdev", + "panel-tests", + "canvas" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timeRangeUpdatedDuringEditOrView": false, + "timepicker": {}, + "timezone": "browser", + "title": "Panel Tests - Canvas Datalinks", + "uid": "adf95uwu7w1s0e", + "version": 26, + "weekStart": "" +} \ No newline at end of file diff --git a/devenv/dev-dashboards/panel-histogram/histogram_tests.json b/devenv/dev-dashboards/panel-histogram/histogram_tests.json index 4399f6b67961e..60ca7a6302b7d 100644 --- a/devenv/dev-dashboards/panel-histogram/histogram_tests.json +++ b/devenv/dev-dashboards/panel-histogram/histogram_tests.json @@ -59,7 +59,8 @@ "value": 80 } ] - } + }, + "unitScale": true }, "overrides": [] }, @@ -128,7 +129,8 @@ "value": 80 } ] - } + }, + "unitScale": true }, "overrides": [] }, @@ -197,7 +199,8 @@ "value": 80 } ] - } + }, + "unitScale": true }, "overrides": [] }, @@ -279,7 +282,8 @@ "value": 80 } ] - } + }, + "unitScale": true }, "overrides": [] }, @@ -341,7 +345,9 @@ }, "custom": { "align": "auto", - "displayMode": "auto", + "cellOptions": { + "type": "auto" + }, "inspect": false }, "mappings": [], @@ -356,7 +362,8 @@ "value": 80 } ] - } + }, + "unitScale": true }, "overrides": [] }, @@ -368,6 +375,7 @@ }, "id": 8, "options": { + "cellHeight": "sm", "footer": { "countRows": false, "fields": [], @@ -376,9 +384,15 @@ ], "show": false }, - "showHeader": true + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Height" + } + ] }, - "pluginVersion": "9.4.0-pre", + "pluginVersion": "10.4.0-pre", "targets": [ { "csvFileName": "weight_height.csv", @@ -393,6 +407,7 @@ "title": "Standalone transform - Height", "transformations": [ { + "disabled": true, "id": "filterFieldsByName", "options": { "include": { @@ -403,6 +418,7 @@ } }, { + "disabled": true, "id": "histogram", "options": { "combine": true, @@ -424,7 +440,9 @@ }, "custom": { "align": "auto", - "displayMode": "auto", + "cellOptions": { + "type": "auto" + }, "inspect": false }, "mappings": [], @@ -439,7 +457,8 @@ "value": 80 } ] - } + }, + "unitScale": true }, "overrides": [] }, @@ -451,6 +470,7 @@ }, "id": 9, "options": { + "cellHeight": "sm", "footer": { "countRows": false, "fields": [], @@ -461,7 +481,7 @@ }, "showHeader": true }, - "pluginVersion": "9.4.0-pre", + "pluginVersion": "10.4.0-pre", "targets": [ { "csvFileName": "weight_height.csv", @@ -527,7 +547,8 @@ "value": 80 } ] - } + }, + "unitScale": true }, "overrides": [ { @@ -611,7 +632,8 @@ "value": 80 } ] - } + }, + "unitScale": true }, "overrides": [ { @@ -695,7 +717,8 @@ "value": 80 } ] - } + }, + "unitScale": true }, "overrides": [ { @@ -746,9 +769,157 @@ ], "title": "Implict xMin", "type": "histogram" + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unitScale": true + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 33 + }, + "id": 14, + "options": { + "bucketOffset": 0, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + } + }, + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"meta\": {\n \"custom\": {\n \"resultType\": \"matrix\"\n },\n \"type\": \"heatmap-rows\",\n \"typeVersion\": [\n 0,\n 1\n ]\n },\n \"name\": \"0.005\",\n \"fields\": [\n {\n \"config\": {\n \"interval\": 1200000\n },\n \"name\": \"Time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\"\n }\n },\n {\n \"config\": {\n \"displayNameFromDS\": \"0.005\"\n },\n \"labels\": {\n \"le\": \"0.005\"\n },\n \"name\": \"0.005\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n }\n },\n {\n \"config\": {\n \"displayNameFromDS\": \"0.01\"\n },\n \"labels\": {\n \"le\": \"0.01\"\n },\n \"name\": \"0.01\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n }\n },\n {\n \"config\": {\n \"displayNameFromDS\": \"0.025\"\n },\n \"labels\": {\n \"le\": \"0.025\"\n },\n \"name\": \"0.025\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n }\n },\n {\n \"config\": {\n \"displayNameFromDS\": \"0.05\"\n },\n \"labels\": {\n \"le\": \"0.05\"\n },\n \"name\": \"0.05\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n }\n },\n {\n \"config\": {\n \"displayNameFromDS\": \"0.1\"\n },\n \"labels\": {\n \"le\": \"0.1\"\n },\n \"name\": \"0.1\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n }\n },\n {\n \"config\": {\n \"displayNameFromDS\": \"0.25\"\n },\n \"labels\": {\n \"le\": \"0.25\"\n },\n \"name\": \"0.25\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n }\n },\n {\n \"config\": {\n \"displayNameFromDS\": \"0.5\"\n },\n \"labels\": {\n \"le\": \"0.5\"\n },\n \"name\": \"0.5\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n }\n },\n {\n \"config\": {\n \"displayNameFromDS\": \"1.0\"\n },\n \"labels\": {\n \"le\": \"1.0\"\n },\n \"name\": \"1.0\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n }\n },\n {\n \"config\": {\n \"displayNameFromDS\": \"2.5\"\n },\n \"labels\": {\n \"le\": \"2.5\"\n },\n \"name\": \"2.5\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n }\n },\n {\n \"config\": {\n \"displayNameFromDS\": \"5.0\"\n },\n \"labels\": {\n \"le\": \"5.0\"\n },\n \"name\": \"5.0\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n }\n },\n {\n \"config\": {\n \"displayNameFromDS\": \"10.0\"\n },\n \"labels\": {\n \"le\": \"10.0\"\n },\n \"name\": \"10.0\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n }\n },\n {\n \"config\": {\n \"displayNameFromDS\": \"25.0\"\n },\n \"labels\": {\n \"le\": \"25.0\"\n },\n \"name\": \"25.0\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n }\n },\n {\n \"config\": {\n \"displayNameFromDS\": \"50.0\"\n },\n \"labels\": {\n \"le\": \"50.0\"\n },\n \"name\": \"50.0\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n }\n },\n {\n \"config\": {\n \"displayNameFromDS\": \"100.0\"\n },\n \"labels\": {\n \"le\": \"100.0\"\n },\n \"name\": \"100.0\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n }\n },\n {\n \"config\": {\n \"displayNameFromDS\": \"+Inf\"\n },\n \"labels\": {\n \"le\": \"+Inf\"\n },\n \"name\": \"+Inf\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1706456400000,\n 1706457600000,\n 1706458800000,\n 1706460000000,\n 1706461200000,\n 1706462400000,\n 1706463600000,\n 1706464800000,\n 1706466000000,\n 1706467200000,\n 1706468400000,\n 1706469600000,\n 1706470800000,\n 1706472000000,\n 1706473200000,\n 1706474400000,\n 1706475600000,\n 1706476800000,\n 1706478000000\n ],\n [\n 0.19357429718875502,\n 0.18072289156626506,\n 0.18313253012048192,\n 0.18955823293172688,\n 0.18634538152610441,\n 0.19518072289156624,\n 0.20080321285140562,\n 0.18313253012048192,\n 0.19678714859437751,\n 0.18795180722891563,\n 0.18473895582329317,\n 0.19357429718875502,\n 0.19116465863453813,\n 0.19196787148594374,\n 0.19437751004016063,\n 0.19759036144578312,\n 0.19839357429718874,\n 0.19357429718875502,\n 0.18634538152610441\n ],\n [\n 0.22248995983935738,\n 0.229718875502008,\n 0.22248995983935738,\n 0.22168674698795174,\n 0.2305220883534136,\n 0.21285140562248994,\n 0.2128514056224899,\n 0.22891566265060237,\n 0.22570281124497987,\n 0.22088353413654616,\n 0.22088353413654618,\n 0.21927710843373488,\n 0.21686746987951805,\n 0.22248995983935738,\n 0.21847389558232927,\n 0.21124497991967867,\n 0.216867469879518,\n 0.2200803212851405,\n 0.22329317269076304\n ],\n [\n 0.017670682730923704,\n 0.02329317269076303,\n 0.027309236947791138,\n 0.02168674698795181,\n 0.016867469879518093,\n 0.025702811244979917,\n 0.02008032128514059,\n 0.018473895582329314,\n 0.01124497991967871,\n 0.024899598393574307,\n 0.025702811244979862,\n 0.0208835341365462,\n 0.02409638554216864,\n 0.01927710843373498,\n 0.0208835341365462,\n 0.02248995983935742,\n 0.016867469879518093,\n 0.02008032128514059,\n 0.02329317269076303\n ],\n [\n 0,\n 0,\n 0.0008032128514056658,\n 0.0008032128514056658,\n 0,\n 0,\n 0,\n 0.0032128514056224966,\n 0,\n 0,\n 0.0024096385542168863,\n 0,\n 0.001606425702811276,\n 0,\n 0,\n 0.0024096385542168863,\n 0.001606425702811276,\n 0,\n 0.0008032128514056103\n ],\n [\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0\n ],\n [\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0\n ],\n [\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0\n ],\n [\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0\n ],\n [\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0\n ],\n [\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0\n ],\n [\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0\n ],\n [\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665,\n 0.06666666666666665\n ],\n [\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0\n ],\n [\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0\n ],\n [\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "heatmap-rows frame", + "type": "histogram" + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unitScale": true + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "sum(rate(cortex_request_duration_seconds{container=~\"compactor\", route=~\"(debug_pprof|metrics|ready)\"}[4m0s]))" + }, + "properties": [ + { + "id": "unit", + "value": "s" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 33 + }, + "id": 15, + "options": { + "bucketOffset": 0, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + } + }, + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"meta\": {\n \"type\": \"heatmap-cells\",\n \"typeVersion\": [\n 0,\n 0\n ],\n \"executedQueryString\": \"Expr: sum(rate(cortex_request_duration_seconds{container=~\\\"compactor\\\", route=~\\\"(debug_pprof|metrics|ready)\\\"}[4m0s]))\\nStep: 1m0s\",\n \"preferredVisualisationType\": \"graph\"\n },\n \"fields\": [\n {\n \"name\": \"xMax\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\"\n },\n \"config\": {\n \"interval\": 60000\n }\n },\n {\n \"name\": \"yMin\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n },\n \"labels\": {},\n \"config\": {\n \"displayNameFromDS\": \"sum(rate(cortex_request_duration_seconds{container=~\\\"compactor\\\", route=~\\\"(debug_pprof|metrics|ready)\\\"}[4m0s]))\"\n }\n },\n {\n \"name\": \"yMax\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n },\n \"config\": {}\n },\n {\n \"name\": \"count\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\"\n },\n \"config\": {}\n },\n {\n \"name\": \"yLayout\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"int8\"\n },\n \"config\": {}\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483460000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483520000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483580000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483640000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483700000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000,\n 1706483760000\n ],\n [\n 0.000012831061023768835,\n 0.000013992371264719713,\n 0.000016639827463764308,\n 0.0000181458605194507,\n 0.000019788201212326197,\n 0.000021579186437577742,\n 0.00002566212204753767,\n 0.000039576402424652394,\n 0.00020529697638030136,\n 0.0002238779402355154,\n 0.00026623723942022893,\n 0.0002903337683112112,\n 0.00031661121939721915,\n 0.00048828125,\n 0.002532889755177753,\n 0.002762135864009951,\n 0.00390625,\n 0.004259795830723663,\n 0.004645340292979379,\n 0.005065779510355506,\n 0.005524271728019902,\n 0.0060242610367497685,\n 0.006569503244169644,\n 0.007164094087536493,\n 0.0078125,\n 0.009290680585958758,\n 0.010131559020711013,\n 0.012048522073499537,\n 0.014328188175072986,\n 0.01703918332289465,\n 0.018581361171917516,\n 0.020263118041422026,\n 13.45434264405943,\n 0.000012831061023768835,\n 0.000013992371264719713,\n 0.000016639827463764308,\n 0.0000181458605194507,\n 0.000019788201212326197,\n 0.000021579186437577742,\n 0.00002566212204753767,\n 0.000039576402424652394,\n 0.0002238779402355154,\n 0.000244140625,\n 0.00026623723942022893,\n 0.0002903337683112112,\n 0.00048828125,\n 0.0021298979153618314,\n 0.002532889755177753,\n 0.002762135864009951,\n 0.00390625,\n 0.004259795830723663,\n 0.004645340292979379,\n 0.005065779510355506,\n 0.005524271728019902,\n 0.0060242610367497685,\n 0.006569503244169644,\n 0.007164094087536493,\n 0.0078125,\n 0.009290680585958758,\n 0.010131559020711013,\n 0.011048543456039804,\n 0.012048522073499537,\n 0.014328188175072986,\n 0.01703918332289465,\n 13.45434264405943,\n 0.000012831061023768835,\n 0.000013992371264719713,\n 0.000016639827463764308,\n 0.0000181458605194507,\n 0.000019788201212326197,\n 0.000021579186437577742,\n 0.00002566212204753767,\n 0.000244140625,\n 0.00026623723942022893,\n 0.0002903337683112112,\n 0.0021298979153618314,\n 0.002532889755177753,\n 0.002762135864009951,\n 0.00390625,\n 0.004259795830723663,\n 0.004645340292979379,\n 0.005065779510355506,\n 0.005524271728019902,\n 0.0060242610367497685,\n 0.006569503244169644,\n 0.007164094087536493,\n 0.0078125,\n 0.009290680585958758,\n 0.010131559020711013,\n 0.011048543456039804,\n 0.012048522073499537,\n 0.014328188175072986,\n 0.01703918332289465,\n 13.45434264405943,\n 0.000012831061023768835,\n 0.000013992371264719713,\n 0.0000152587890625,\n 0.000016639827463764308,\n 0.0000181458605194507,\n 0.000019788201212326197,\n 0.000021579186437577742,\n 0.00002566212204753767,\n 0.000244140625,\n 0.00026623723942022893,\n 0.0002903337683112112,\n 0.0021298979153618314,\n 0.002762135864009951,\n 0.00390625,\n 0.004259795830723663,\n 0.004645340292979379,\n 0.005065779510355506,\n 0.005524271728019902,\n 0.0060242610367497685,\n 0.006569503244169644,\n 0.007164094087536493,\n 0.0078125,\n 0.008519591661447326,\n 0.009290680585958758,\n 0.010131559020711013,\n 0.011048543456039804,\n 0.012048522073499537,\n 0.014328188175072986,\n 0.01703918332289465,\n 13.45434264405943,\n 0.000012831061023768835,\n 0.000013992371264719713,\n 0.0000152587890625,\n 0.000016639827463764308,\n 0.0000181458605194507,\n 0.000019788201212326197,\n 0.000021579186437577742,\n 0.00002566212204753767,\n 0.000244140625,\n 0.00026623723942022893,\n 0.0002903337683112112,\n 0.0005806675366224224,\n 0.0021298979153618314,\n 0.002762135864009951,\n 0.00390625,\n 0.004259795830723663,\n 0.004645340292979379,\n 0.005065779510355506,\n 0.005524271728019902,\n 0.0060242610367497685,\n 0.006569503244169644,\n 0.007164094087536493,\n 0.0078125,\n 0.008519591661447326,\n 0.009290680585958758,\n 0.010131559020711013,\n 0.011048543456039804,\n 0.015625,\n 13.45434264405943,\n 0.000012831061023768835,\n 0.000013992371264719713,\n 0.0000152587890625,\n 0.000016639827463764308,\n 0.0000181458605194507,\n 0.000019788201212326197,\n 0.000021579186437577742,\n 0.00002566212204753767,\n 0.000244140625,\n 0.00026623723942022893,\n 0.0002903337683112112,\n 0.00031661121939721915,\n 0.0003452669830012439,\n 0.0005806675366224224,\n 0.0021298979153618314,\n 0.002762135864009951,\n 0.00390625,\n 0.004259795830723663,\n 0.004645340292979379,\n 0.005065779510355506,\n 0.005524271728019902,\n 0.0060242610367497685,\n 0.006569503244169644,\n 0.007164094087536493,\n 0.0078125,\n 0.008519591661447326,\n 0.009290680585958758,\n 0.010131559020711013,\n 0.011048543456039804,\n 0.015625,\n 13.45434264405943\n ],\n [\n 0.000013992371264719713,\n 0.0000152587890625,\n 0.0000181458605194507,\n 0.000019788201212326197,\n 0.000021579186437577742,\n 0.000023532269674803783,\n 0.000027984742529439426,\n 0.000043158372875155485,\n 0.0002238779402355154,\n 0.000244140625,\n 0.0002903337683112112,\n 0.00031661121939721915,\n 0.0003452669830012439,\n 0.0005324744788404579,\n 0.002762135864009951,\n 0.0030121305183748843,\n 0.004259795830723663,\n 0.004645340292979379,\n 0.005065779510355506,\n 0.005524271728019902,\n 0.0060242610367497685,\n 0.006569503244169644,\n 0.007164094087536493,\n 0.0078125,\n 0.008519591661447326,\n 0.010131559020711013,\n 0.011048543456039804,\n 0.013139006488339287,\n 0.015625,\n 0.018581361171917516,\n 0.020263118041422026,\n 0.022097086912079608,\n 14.672064691274738,\n 0.000013992371264719713,\n 0.0000152587890625,\n 0.0000181458605194507,\n 0.000019788201212326197,\n 0.000021579186437577742,\n 0.000023532269674803783,\n 0.000027984742529439426,\n 0.000043158372875155485,\n 0.000244140625,\n 0.00026623723942022893,\n 0.0002903337683112112,\n 0.00031661121939721915,\n 0.0005324744788404579,\n 0.0023226701464896895,\n 0.002762135864009951,\n 0.0030121305183748843,\n 0.004259795830723663,\n 0.004645340292979379,\n 0.005065779510355506,\n 0.005524271728019902,\n 0.0060242610367497685,\n 0.006569503244169644,\n 0.007164094087536493,\n 0.0078125,\n 0.008519591661447326,\n 0.010131559020711013,\n 0.011048543456039804,\n 0.012048522073499537,\n 0.013139006488339287,\n 0.015625,\n 0.018581361171917516,\n 14.672064691274738,\n 0.000013992371264719713,\n 0.0000152587890625,\n 0.0000181458605194507,\n 0.000019788201212326197,\n 0.000021579186437577742,\n 0.000023532269674803783,\n 0.000027984742529439426,\n 0.00026623723942022893,\n 0.0002903337683112112,\n 0.00031661121939721915,\n 0.0023226701464896895,\n 0.002762135864009951,\n 0.0030121305183748843,\n 0.004259795830723663,\n 0.004645340292979379,\n 0.005065779510355506,\n 0.005524271728019902,\n 0.0060242610367497685,\n 0.006569503244169644,\n 0.007164094087536493,\n 0.0078125,\n 0.008519591661447326,\n 0.010131559020711013,\n 0.011048543456039804,\n 0.012048522073499537,\n 0.013139006488339287,\n 0.015625,\n 0.018581361171917516,\n 14.672064691274738,\n 0.000013992371264719713,\n 0.0000152587890625,\n 0.000016639827463764308,\n 0.0000181458605194507,\n 0.000019788201212326197,\n 0.000021579186437577742,\n 0.000023532269674803783,\n 0.000027984742529439426,\n 0.00026623723942022893,\n 0.0002903337683112112,\n 0.00031661121939721915,\n 0.0023226701464896895,\n 0.0030121305183748843,\n 0.004259795830723663,\n 0.004645340292979379,\n 0.005065779510355506,\n 0.005524271728019902,\n 0.0060242610367497685,\n 0.006569503244169644,\n 0.007164094087536493,\n 0.0078125,\n 0.008519591661447326,\n 0.009290680585958758,\n 0.010131559020711013,\n 0.011048543456039804,\n 0.012048522073499537,\n 0.013139006488339287,\n 0.015625,\n 0.018581361171917516,\n 14.672064691274738,\n 0.000013992371264719713,\n 0.0000152587890625,\n 0.000016639827463764308,\n 0.0000181458605194507,\n 0.000019788201212326197,\n 0.000021579186437577742,\n 0.000023532269674803783,\n 0.000027984742529439426,\n 0.00026623723942022893,\n 0.0002903337683112112,\n 0.00031661121939721915,\n 0.0006332224387944383,\n 0.0023226701464896895,\n 0.0030121305183748843,\n 0.004259795830723663,\n 0.004645340292979379,\n 0.005065779510355506,\n 0.005524271728019902,\n 0.0060242610367497685,\n 0.006569503244169644,\n 0.007164094087536493,\n 0.0078125,\n 0.008519591661447326,\n 0.009290680585958758,\n 0.010131559020711013,\n 0.011048543456039804,\n 0.012048522073499537,\n 0.01703918332289465,\n 14.672064691274738,\n 0.000013992371264719713,\n 0.0000152587890625,\n 0.000016639827463764308,\n 0.0000181458605194507,\n 0.000019788201212326197,\n 0.000021579186437577742,\n 0.000023532269674803783,\n 0.000027984742529439426,\n 0.00026623723942022893,\n 0.0002903337683112112,\n 0.00031661121939721915,\n 0.0003452669830012439,\n 0.00037651631479686053,\n 0.0006332224387944383,\n 0.0023226701464896895,\n 0.0030121305183748843,\n 0.004259795830723663,\n 0.004645340292979379,\n 0.005065779510355506,\n 0.005524271728019902,\n 0.0060242610367497685,\n 0.006569503244169644,\n 0.007164094087536493,\n 0.0078125,\n 0.008519591661447326,\n 0.009290680585958758,\n 0.010131559020711013,\n 0.011048543456039804,\n 0.012048522073499537,\n 0.01703918332289465,\n 14.672064691274738\n ],\n [\n 0.0044444444444444444,\n 0.017777777777777778,\n 0.0044444444444444444,\n 0.035555555555555556,\n 0.017777777777777778,\n 0.013333333333333332,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.008888888888888889,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.008888888888888889,\n 0.0044444444444444444,\n 0.017777777777777778,\n 0.03111111111111111,\n 0.013333333333333332,\n 0.05333333333333334,\n 0.035555555555555556,\n 0.022222222222222223,\n 0.022222222222222223,\n 0.022222222222222223,\n 0.017777777777777778,\n 0.013333333333333332,\n 0.013333333333333332,\n 0.008888888888888889,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.06666666666666667,\n 0.008888888888888889,\n 0.017777777777777778,\n 0.008888888888888889,\n 0.04,\n 0.0044444444444444444,\n 0.013333333333333332,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.013333333333333332,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.008888888888888889,\n 0.0044444444444444444,\n 0.026666666666666665,\n 0.035555555555555556,\n 0.008888888888888889,\n 0.044444444444444446,\n 0.035555555555555556,\n 0.026666666666666665,\n 0.022222222222222223,\n 0.022222222222222223,\n 0.013333333333333332,\n 0.008888888888888889,\n 0.017777777777777778,\n 0.0044444444444444444,\n 0.008888888888888889,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.06666666666666667,\n 0.008888888888888889,\n 0.017777777777777778,\n 0.017777777777777778,\n 0.04,\n 0.0044444444444444444,\n 0.008888888888888889,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.008888888888888889,\n 0.017777777777777778,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.008888888888888889,\n 0.022222222222222223,\n 0.03111111111111111,\n 0.008888888888888889,\n 0.044444444444444446,\n 0.035555555555555556,\n 0.026666666666666665,\n 0.026666666666666665,\n 0.022222222222222223,\n 0.017777777777777778,\n 0.008888888888888889,\n 0.017777777777777778,\n 0.0044444444444444444,\n 0.008888888888888889,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.06666666666666667,\n 0.008888888888888889,\n 0.017777777777777778,\n 0.0044444444444444444,\n 0.017777777777777778,\n 0.03111111111111111,\n 0.008888888888888889,\n 0.008888888888888889,\n 0.0044444444444444444,\n 0.008888888888888889,\n 0.013333333333333332,\n 0.008888888888888889,\n 0.008888888888888889,\n 0.008888888888888889,\n 0.022222222222222223,\n 0.013333333333333332,\n 0.008888888888888889,\n 0.044444444444444446,\n 0.04,\n 0.022222222222222223,\n 0.022222222222222223,\n 0.022222222222222223,\n 0.03111111111111111,\n 0.0044444444444444444,\n 0.013333333333333332,\n 0.017777777777777778,\n 0.008888888888888889,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.06666666666666667,\n 0.008888888888888889,\n 0.026666666666666665,\n 0.0044444444444444444,\n 0.013333333333333332,\n 0.022222222222222223,\n 0.008888888888888889,\n 0.013333333333333332,\n 0.0044444444444444444,\n 0.008888888888888889,\n 0.008888888888888889,\n 0.008888888888888889,\n 0.0044444444444444444,\n 0.008888888888888889,\n 0.008888888888888889,\n 0.026666666666666665,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.035555555555555556,\n 0.057777777777777775,\n 0.026666666666666665,\n 0.03111111111111111,\n 0.03111111111111111,\n 0.022222222222222223,\n 0.0044444444444444444,\n 0.013333333333333332,\n 0.013333333333333332,\n 0.008888888888888889,\n 0.0044444444444444444,\n 0.06666666666666667,\n 0.0044444444444444444,\n 0.026666666666666665,\n 0.0044444444444444444,\n 0.022222222222222223,\n 0.022222222222222223,\n 0.0044444444444444444,\n 0.013333333333333332,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.008888888888888889,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.013333333333333332,\n 0.017777777777777778,\n 0.013333333333333332,\n 0.008888888888888889,\n 0.035555555555555556,\n 0.05333333333333333,\n 0.022222222222222223,\n 0.026666666666666665,\n 0.035555555555555556,\n 0.022222222222222223,\n 0.008888888888888889,\n 0.02222222222222222,\n 0.008888888888888889,\n 0.0044444444444444444,\n 0.0044444444444444444,\n 0.06666666666666667\n ],\n [\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "heatmap-cells frame", + "type": "histogram" } ], - "schemaVersion": 37, + "refresh": "", + "schemaVersion": 39, "tags": [ "gdev", "panel-tests", @@ -765,6 +936,6 @@ "timezone": "", "title": "Panel Tests - Histogram", "uid": "UTv--wqMk", - "version": 7, + "version": 29, "weekStart": "" } diff --git a/devenv/dev-dashboards/panel-timeline/timeline-align-endtime.json b/devenv/dev-dashboards/panel-timeline/timeline-align-endtime.json new file mode 100644 index 0000000000000..8542c7349b6f8 --- /dev/null +++ b/devenv/dev-dashboards/panel-timeline/timeline-align-endtime.json @@ -0,0 +1,271 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 988, + "links": [], + "panels": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 13, + "w": 15, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"meta\": {\n \"typeVersion\": [\n 0,\n 0\n ]\n },\n \"name\": \"A\",\n \"fields\": [\n {\n \"name\": \"channel\",\n \"config\": {\n \"selector\": \"channel\"\n },\n \"type\": \"string\"\n },\n {\n \"name\": \"name\",\n \"config\": {\n \"selector\": \"name\"\n },\n \"type\": \"string\"\n },\n {\n \"name\": \"starttime\",\n \"config\": {\n \"selector\": \"starttime\"\n },\n \"type\": \"string\"\n },\n {\n \"name\": \"endtime\",\n \"config\": {\n \"selector\": \"endtime\"\n },\n \"type\": \"string\"\n },\n {\n \"name\": \"duration_minutes\",\n \"config\": {\n \"selector\": \"duration_minutes\"\n },\n \"type\": \"number\"\n },\n {\n \"name\": \"state\",\n \"config\": {\n \"selector\": \"state\"\n },\n \"type\": \"string\"\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n \"Channel 1\",\n \"Channel 2\",\n \"Channel 1\",\n \"Channel 2\"\n ],\n [\n \"Event 1\",\n \"Event 2\",\n \"Event 3\",\n \"Event 4\"\n ],\n [\n \"2024-02-28T08:00:00Z\",\n \"2024-02-28T09:00:00Z\",\n \"2024-02-28T11:00:00Z\",\n \"2024-02-28T12:30:00Z\"\n ],\n [\n \"2024-02-28T10:00:00Z\",\n \"2024-02-28T10:30:00Z\",\n \"2024-02-28T14:00:00Z\",\n \"2024-02-28T13:30:00Z\"\n ],\n [\n 120,\n 90,\n 180,\n 60\n ],\n [\n \"OK\",\n \"ERROR\",\n \"NO_DATA\",\n \"WARNING\"\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "Raw frames w/enums", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "channel": false, + "duration_minutes": true, + "name": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": {} + } + }, + { + "id": "convertFieldType", + "options": { + "conversions": [ + { + "destinationType": "time", + "enumConfig": { + "text": [ + "2024-02-28T08:00:00Z", + "2024-02-28T09:00:00Z", + "2024-02-28T11:00:00Z", + "2024-02-28T12:30:00Z" + ] + }, + "targetField": "starttime" + }, + { + "destinationType": "time", + "targetField": "endtime" + }, + { + "destinationType": "enum", + "enumConfig": { + "text": [ + "OK", + "ERROR", + "NO_DATA", + "WARNING" + ] + }, + "targetField": "state" + } + ], + "fields": {} + } + }, + { + "id": "partitionByValues", + "options": { + "fields": [ + "channel" + ], + "keepFields": false, + "naming": { + "asLabels": false + } + } + } + ], + "type": "state-timeline" + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 15, + "x": 0, + "y": 13 + }, + "id": 2, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "alias": "Channel 1", + "csvContent": "starttime,endtime,state\n1709107200000,1709114400000,OK\n1709118000000,1709128800000,NO_DATA", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_content" + }, + { + "alias": "Channel 2", + "csvContent": "starttime,endtime,state\n1709110800000,1709116200000,ERROR\n1709123400000,1709127000000,WARNING", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "B", + "scenarioId": "csv_content" + } + ], + "title": "CSV content", + "type": "state-timeline" + } + ], + "refresh": "", + "schemaVersion": 39, + "tags": [ + "gdev", + "panel-tests", + "graph-ng", + "demo" + ], + "templating": { + "list": [] + }, + "time": { + "from": "2024-02-28T07:47:21.428Z", + "to": "2024-02-28T14:12:43.391Z" + }, + "timeRangeUpdatedDuringEditOrView": false, + "timepicker": {}, + "timezone": "browser", + "title": "Panel Tests - StateTimeline - multiple frames with endTime", + "uid": "cdf3gkge5reo0f", + "version": 4, + "weekStart": "" +} \ No newline at end of file diff --git a/devenv/dev-dashboards/panel-timeline/timeline-align-nulls-retain.json b/devenv/dev-dashboards/panel-timeline/timeline-align-nulls-retain.json new file mode 100644 index 0000000000000..49907cca7714c --- /dev/null +++ b/devenv/dev-dashboards/panel-timeline/timeline-align-nulls-retain.json @@ -0,0 +1,213 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 993, + "links": [], + "panels": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "light-blue", + "mode": "fixed" + }, + "custom": { + "fillOpacity": 20, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 1, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Dose" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#289fb0", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Mix" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#d4b10b", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Cook" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#c900c3", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Int. Shear" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#a49225", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Ext. Shear" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#148dd7", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Transfer" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#01b70c", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 19, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "alignValue": "center", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "mergeValues": false, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "repeat": "CHANNEL", + "repeatDirection": "v", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"Dose\",\n \"meta\": {\n \"executedQueryString\": \"from(bucket: \\\"data\\\")\\r\\n |> range(start: 2023-10-20T05:04:00Z, stop: 2023-10-20T07:22:00Z)\\r\\n |> filter(fn: (r) => r[\\\"_field\\\"] == \\\"FactoryManager_Analogs_DB.A01C02U09.PHS.Dose\\\")\\r\\n |> keep(columns: [\\\"_time\\\", \\\"_value\\\"])\\r\\n |> map(fn: (r) => ({ \\r\\n \\\"Dose\\\": r._value,\\r\\n time: r._time,\\r\\n }))\\r\\n\",\n \"typeVersion\": [\n 0,\n 0\n ]\n },\n \"fields\": [\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n }\n },\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"Dose\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1697781872300,\n 1697781963303,\n 1697784138453,\n 1697784160451\n ],\n [\n \"Cold Water Dosing Active (150 ltrs)\",\n null,\n \"Hot Water Dosing Active (50 ltrs)\",\n null\n ]\n ]\n }\n },\n {\n \"schema\": {\n \"refId\": \"Mix\",\n \"meta\": {\n \"executedQueryString\": \"from(bucket: \\\"data\\\")\\r\\n |> range(start: 2023-10-20T05:04:00Z, stop: 2023-10-20T07:22:00Z)\\r\\n |> filter(fn: (r) => r[\\\"_field\\\"] == \\\"FactoryManager_Analogs_DB.A01C02U09.PHS.Mix\\\")\\r\\n |> keep(columns: [\\\"_time\\\", \\\"_value\\\"])\\r\\n |> map(fn: (r) => ({ \\r\\n \\\"Mix\\\": r._value,\\r\\n time: r._time,\\r\\n }))\\r\\n\",\n \"typeVersion\": [\n 0,\n 0\n ]\n },\n \"fields\": [\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n }\n },\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"Mix\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1697778291972,\n 1697778393992,\n 1697778986994,\n 1697786485890\n ],\n [\n \"Running Constant Forward\",\n null,\n \"Running Constant Forward\",\n null\n ]\n ]\n }\n },\n {\n \"schema\": {\n \"refId\": \"Cook\",\n \"meta\": {\n \"executedQueryString\": \"from(bucket: \\\"data\\\")\\r\\n |> range(start: 2023-10-20T05:04:00Z, stop: 2023-10-20T07:22:00Z)\\r\\n |> filter(fn: (r) => r[\\\"_field\\\"] == \\\"FactoryManager_Analogs_DB.A01C02U09.PHS.Cook\\\")\\r\\n |> keep(columns: [\\\"_time\\\", \\\"_value\\\"])\\r\\n |> map(fn: (r) => ({ \\r\\n \\\"Cook\\\": r._value,\\r\\n time: r._time,\\r\\n }))\\r\\n\",\n \"typeVersion\": [\n 0,\n 0\n ]\n },\n \"fields\": [\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n }\n },\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"Cook\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1697779163986,\n 1697779921045,\n 1697780221094,\n 1697780521111,\n 1697781186192,\n 1697781786291,\n 1697783332361,\n 1697783784395,\n 1697783790397,\n 1697784146478,\n 1697784517471,\n 1697784523487,\n 1697784949480,\n 1697785369505\n ],\n [\n \"Heating to Setpoint (92c)\",\n \"Stage Time Running (5 mins)\",\n null,\n \"Heating to Setpoint (96c)\",\n \"Stage Time Running (10 mins)\",\n null,\n \"Heating to Setpoint (92c)\",\n \"Stage Time Running (0 mins)\",\n null,\n \"Heating to Setpoint (92c)\",\n \"Stage Time Running (0 mins)\",\n null,\n \"CCP in Progress (7 mins)\",\n null\n ]\n ]\n }\n },\n {\n \"schema\": {\n \"refId\": \"Shear\",\n \"meta\": {\n \"executedQueryString\": \"from(bucket: \\\"data\\\")\\r\\n |> range(start: 2023-10-20T05:04:00Z, stop: 2023-10-20T07:22:00Z)\\r\\n |> filter(fn: (r) => r[\\\"_field\\\"] == \\\"FactoryManager_Analogs_DB.A01C02U09.PHS.Shear\\\")\\r\\n |> keep(columns: [\\\"_time\\\", \\\"_value\\\"])\\r\\n |> map(fn: (r) => ({ \\r\\n \\\"Int. Shear\\\": r._value,\\r\\n time: r._time,\\r\\n }))\\r\\n\",\n \"typeVersion\": [\n 0,\n 0\n ]\n },\n \"fields\": [\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n }\n },\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"Int. Shear\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1697782100330,\n 1697782832342\n ],\n [\n \"Shearing Active (12 mins)\",\n null\n ]\n ]\n }\n },\n {\n \"schema\": {\n \"refId\": \"Recirc\",\n \"meta\": {\n \"executedQueryString\": \"from(bucket: \\\"data\\\")\\r\\n |> range(start: 2023-10-20T05:04:00Z, stop: 2023-10-20T07:22:00Z)\\r\\n |> filter(fn: (r) => r[\\\"_field\\\"] == \\\"FactoryManager_Analogs_DB.A01C02U09.PHS.Recirc\\\")\\r\\n |> keep(columns: [\\\"_time\\\", \\\"_value\\\"])\\r\\n |> map(fn: (r) => ({ \\r\\n \\\"Ext. Shear\\\": r._value,\\r\\n time: r._time,\\r\\n }))\\r\\n\",\n \"typeVersion\": [\n 0,\n 0\n ]\n },\n \"fields\": []\n },\n \"data\": {\n \"values\": []\n }\n },\n {\n \"schema\": {\n \"refId\": \"Transfer\",\n \"meta\": {\n \"executedQueryString\": \"from(bucket: \\\"data\\\")\\r\\n |> range(start: 2023-10-20T05:04:00Z, stop: 2023-10-20T07:22:00Z)\\r\\n |> filter(fn: (r) => r[\\\"_field\\\"] == \\\"FactoryManager_Analogs_DB.A01C02U09.PHS.Transfer\\\")\\r\\n |> keep(columns: [\\\"_time\\\", \\\"_value\\\"])\\r\\n |> map(fn: (r) => ({ \\r\\n \\\"Transfer\\\": r._value,\\r\\n time: r._time,\\r\\n }))\\r\\n\",\n \"typeVersion\": [\n 0,\n 0\n ]\n },\n \"fields\": [\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n }\n },\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"Transfer\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1697785713869,\n 1697785753879,\n 1697785764887,\n 1697785875872,\n 1697786481929\n ],\n [\n \"Pre-Start Drain\",\n null,\n \"Build Pressure (0.6 Barg)\",\n \"Transfer in progress (0.7 Barg)\",\n \"Wait for pressure dissipation (0.2 Barg)\"\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "Reproduced with embedded data", + "type": "state-timeline" + } + ], + "refresh": "", + "schemaVersion": 39, + "tags": [ + "gdev", + "panel-tests", + "graph-ng", + "demo" + ], + "templating": { + "list": [] + }, + "time": { + "from": "2023-10-20T05:04:00.000Z", + "to": "2023-10-20T07:22:00.000Z" + }, + "timeRangeUpdatedDuringEditOrView": false, + "timepicker": {}, + "timezone": "utc", + "title": "Panel Tests - StateTimeline - multiple frames with nulls", + "uid": "edf55caay3w8wa", + "version": 4, + "weekStart": "" +} \ No newline at end of file diff --git a/devenv/dev-dashboards/panel-timeseries/timeseries-by-value-color-schemes.json b/devenv/dev-dashboards/panel-timeseries/timeseries-by-value-color-schemes.json index d785a1fcaffec..1167172539746 100644 --- a/devenv/dev-dashboards/panel-timeseries/timeseries-by-value-color-schemes.json +++ b/devenv/dev-dashboards/panel-timeseries/timeseries-by-value-color-schemes.json @@ -516,7 +516,7 @@ "refId": "A" } ], - "title": "Color line by discrete tresholds", + "title": "Color line by discrete thresholds", "type": "timeseries" }, { diff --git a/devenv/dev-dashboards/panel-xychart/xychart-tooltip-color-test.json b/devenv/dev-dashboards/panel-xychart/xychart-tooltip-color-test.json new file mode 100644 index 0000000000000..5ee05b7b7f9b8 --- /dev/null +++ b/devenv/dev-dashboards/panel-xychart/xychart-tooltip-color-test.json @@ -0,0 +1,687 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": {}, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "blue", + "mode": "fixed" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "pointSize": { + "fixed": 32 + }, + "scaleDistribution": { + "type": "linear" + }, + "show": "points" + }, + "fieldMinMax": false, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unitScale": true + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 7, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "series": [ + { + "pointColor": { + "fixed": "orange" + }, + "x": "Miles_per_Gallon", + "y": "Horsepower" + } + ], + "seriesMapping": "auto", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "csvContent": "x,y,z\n1,2,3\n3,4,5\n5,6,7", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "${DS_GDEV-TESTDATA}" + }, + "refId": "A", + "scenarioId": "csv_content" + } + ], + "title": "Standard options: Single color", + "type": "xychart" + }, + { + "datasource": {}, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "shades" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "pointSize": { + "fixed": 32 + }, + "scaleDistribution": { + "type": "linear" + }, + "show": "points" + }, + "fieldMinMax": false, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unitScale": true + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 7, + "x": 7, + "y": 0 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "series": [ + { + "pointColor": { + "fixed": "orange" + }, + "x": "Miles_per_Gallon", + "y": "Horsepower" + } + ], + "seriesMapping": "auto", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "csvContent": "x,y,z\n1,2,3\n3,4,5\n5,6,7", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "${DS_GDEV-TESTDATA}" + }, + "refId": "A", + "scenarioId": "csv_content" + } + ], + "title": "Standard options: Shades of a color", + "type": "xychart" + }, + { + "datasource": {}, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "yellow", + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "pointSize": { + "fixed": 32 + }, + "scaleDistribution": { + "type": "linear" + }, + "show": "points" + }, + "fieldMinMax": false, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unitScale": true + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 7, + "x": 14, + "y": 0 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "series": [ + { + "pointColor": { + "fixed": "orange" + }, + "x": "Miles_per_Gallon", + "y": "Horsepower" + } + ], + "seriesMapping": "auto", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "csvContent": "x,y,z\n1,2,3\n3,4,5\n5,6,7", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "${DS_GDEV-TESTDATA}" + }, + "refId": "A", + "scenarioId": "csv_content" + } + ], + "title": "Standard options: Classic palette", + "type": "xychart" + }, + { + "datasource": {}, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "blue", + "mode": "fixed" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "pointSize": { + "fixed": 32 + }, + "scaleDistribution": { + "type": "linear" + }, + "show": "points" + }, + "fieldMinMax": false, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unitScale": true + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 7, + "x": 0, + "y": 11 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "series": [ + { + "pointColor": { + "fixed": "orange" + }, + "x": "x", + "y": "y" + }, + { + "pointColor": { + "fixed": "green" + }, + "x": "x", + "y": "z" + } + ], + "seriesMapping": "manual", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "csvContent": "x,y,z\n1,2,3\n3,4,5\n5,6,7", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "${DS_GDEV-TESTDATA}" + }, + "refId": "A", + "scenarioId": "csv_content" + } + ], + "title": "Standard options: Single color + Mapping: Fixed color", + "type": "xychart" + }, + { + "datasource": {}, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "blue", + "mode": "shades" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "pointSize": { + "fixed": 32 + }, + "scaleDistribution": { + "type": "linear" + }, + "show": "points" + }, + "fieldMinMax": false, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unitScale": true + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 7, + "x": 7, + "y": 11 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "series": [ + { + "pointColor": { + "fixed": "orange" + }, + "x": "x", + "y": "y" + }, + { + "pointColor": { + "fixed": "green" + }, + "x": "x", + "y": "z" + } + ], + "seriesMapping": "manual", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "csvContent": "x,y,z\n1,2,3\n3,4,5\n5,6,7", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "${DS_GDEV-TESTDATA}" + }, + "refId": "A", + "scenarioId": "csv_content" + } + ], + "title": "Standard options: Shades of color + Mapping: Fixed color", + "type": "xychart" + }, + { + "datasource": {}, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "blue", + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "pointSize": { + "fixed": 32 + }, + "scaleDistribution": { + "type": "linear" + }, + "show": "points" + }, + "fieldMinMax": false, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unitScale": true + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 7, + "x": 14, + "y": 11 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "series": [ + { + "pointColor": { + "fixed": "orange" + }, + "x": "x", + "y": "y" + }, + { + "pointColor": { + "fixed": "green" + }, + "x": "x", + "y": "z" + } + ], + "seriesMapping": "manual", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "csvContent": "x,y,z\n1,2,3\n3,4,5\n5,6,7", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "${DS_GDEV-TESTDATA}" + }, + "refId": "A", + "scenarioId": "csv_content" + } + ], + "title": "Standard options: Classic palette + Mapping: Fixed color", + "type": "xychart" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-BlYlRd" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "pointSize": { + "fixed": 50 + }, + "scaleDistribution": { + "type": "linear" + }, + "show": "points" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unitScale": true + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 7, + "x": 0, + "y": 23 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "series": [ + { + "pointColor": { + "field": "c", + "fixed": "dark-green" + }, + "x": "x", + "y": "y" + } + ], + "seriesMapping": "manual", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "csvContent": "x,y,c\n0,0,0\n50,50,25\n100,100,50", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_content" + } + ], + "title": "Panel Title", + "type": "xychart" + } + ], + "refresh": "", + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "XYChart tooltip color test", + "uid": "cb67db43-dd72-4ada-a313-53f46c20adcc", + "version": 1, + "weekStart": "" +} \ No newline at end of file diff --git a/devenv/dev-dashboards/scenarios/tall_dashboard.json b/devenv/dev-dashboards/scenarios/tall_dashboard.json new file mode 100644 index 0000000000000..ca87850c2b0c5 --- /dev/null +++ b/devenv/dev-dashboards/scenarios/tall_dashboard.json @@ -0,0 +1,1791 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 3, + "links": [], + "panels": [ + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 1", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #1", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 2, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 2", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #2", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 3, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 3", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #3", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 4, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 4", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #4", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 5, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 5", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #5", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 40 + }, + "id": 6, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 6", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #6", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 48 + }, + "id": 7, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 7", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #7", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 56 + }, + "id": 8, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 8", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #8", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 64 + }, + "id": 9, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 9", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #9", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 72 + }, + "id": 10, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 10", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #10", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 80 + }, + "id": 11, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 11", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #11", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 88 + }, + "id": 12, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 12", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #12", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 96 + }, + "id": 13, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 13", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #13", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 104 + }, + "id": 14, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 14", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #14", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 112 + }, + "id": 15, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 15", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #15", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 120 + }, + "id": 16, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 16", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #16", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 128 + }, + "id": 17, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 17", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #17", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 136 + }, + "id": 18, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 18", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #18", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 144 + }, + "id": 19, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 19", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #19", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 152 + }, + "id": 20, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 20", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #20", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 160 + }, + "id": 21, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 21", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #21", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 168 + }, + "id": 22, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 22", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #22", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 176 + }, + "id": 23, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 23", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #23", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 184 + }, + "id": 24, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 24", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #24", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 192 + }, + "id": 25, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 25", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #25", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 200 + }, + "id": 26, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 26", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #26", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 208 + }, + "id": 27, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 27", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #27", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 216 + }, + "id": 28, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 28", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #28", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 224 + }, + "id": 29, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 29", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #29", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 232 + }, + "id": 30, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 30", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #30", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 240 + }, + "id": 31, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 31", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #31", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 248 + }, + "id": 32, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 32", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #32", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 256 + }, + "id": 33, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 33", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #33", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 264 + }, + "id": 34, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 34", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #34", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 272 + }, + "id": 35, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 35", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #35", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 280 + }, + "id": 36, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 36", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #36", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 288 + }, + "id": 37, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 37", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #37", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 296 + }, + "id": 38, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 38", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #38", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 304 + }, + "id": 39, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 39", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #39", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 312 + }, + "id": 40, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 40", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #40", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 320 + }, + "id": 41, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 41", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #41", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 328 + }, + "id": 42, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 42", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #42", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 336 + }, + "id": 43, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 43", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #43", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 344 + }, + "id": 44, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 44", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #44", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 352 + }, + "id": 45, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 45", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #45", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 360 + }, + "id": 46, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 46", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #46", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 368 + }, + "id": 47, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 47", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #47", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 376 + }, + "id": 48, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 48", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #48", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 384 + }, + "id": 49, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 49", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #49", + "type": "text" + }, + { + "datasource": { + "type": "grafana", + "uid": "grafana" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 392 + }, + "id": 50, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Dashboard panel 50", + "mode": "markdown" + }, + "pluginVersion": "11.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel #50", + "type": "text" + } + ], + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timeRangeUpdatedDuringEditOrView": false, + "timepicker": {}, + "timezone": "browser", + "title": "A tall dashboard", + "uid": "edediimbjhdz4b", + "version": 1, + "weekStart": "" +} diff --git a/devenv/docker/blocks/auth/README.md b/devenv/docker/blocks/auth/README.md index ed2872704b732..2046804fb9ab1 100644 --- a/devenv/docker/blocks/auth/README.md +++ b/devenv/docker/blocks/auth/README.md @@ -9,7 +9,7 @@ Spin up a service with the following command from the base directory of this repository. ```bash -make devenv=oauth +make devenv=auth/oauth ``` This will add the `oauth/docker-compose` block to the `docker-compose` file used diff --git a/devenv/docker/blocks/auth/authentik/cloak.sql b/devenv/docker/blocks/auth/authentik/cloak.sql index 6669bf2bf6f8a..e861be4726763 100644 --- a/devenv/docker/blocks/auth/authentik/cloak.sql +++ b/devenv/docker/blocks/auth/authentik/cloak.sql @@ -2,8 +2,8 @@ -- PostgreSQL database dump -- --- Dumped from database version 12.13 --- Dumped by pg_dump version 12.13 +-- Dumped from database version 16.1 (Debian 16.1-1.pgdg120+1) +-- Dumped by pg_dump version 16.1 (Debian 16.1-1.pgdg120+1) SET statement_timeout = 0; SET lock_timeout = 0; @@ -178,6 +178,33 @@ CREATE TABLE public.authentik_core_group ( ALTER TABLE public.authentik_core_group OWNER TO authentik; +-- +-- Name: authentik_core_group_roles; Type: TABLE; Schema: public; Owner: authentik +-- + +CREATE TABLE public.authentik_core_group_roles ( + id integer NOT NULL, + group_id uuid NOT NULL, + role_id uuid NOT NULL +); + + +ALTER TABLE public.authentik_core_group_roles OWNER TO authentik; + +-- +-- Name: authentik_core_group_roles_id_seq; Type: SEQUENCE; Schema: public; Owner: authentik +-- + +ALTER TABLE public.authentik_core_group_roles ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.authentik_core_group_roles_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + -- -- Name: authentik_core_propertymapping; Type: TABLE; Schema: public; Owner: authentik -- @@ -198,8 +225,11 @@ ALTER TABLE public.authentik_core_propertymapping OWNER TO authentik; CREATE TABLE public.authentik_core_provider ( id integer NOT NULL, - authorization_flow_id uuid NOT NULL, - name text NOT NULL + authorization_flow_id uuid, + name text NOT NULL, + authentication_flow_id uuid, + backchannel_application_id uuid, + is_backchannel boolean NOT NULL ); @@ -330,7 +360,8 @@ CREATE TABLE public.authentik_core_user ( name text NOT NULL, password_change_date timestamp with time zone NOT NULL, attributes jsonb NOT NULL, - path text NOT NULL + path text NOT NULL, + type text NOT NULL ); @@ -477,6 +508,39 @@ CREATE TABLE public.authentik_crypto_certificatekeypair ( ALTER TABLE public.authentik_crypto_certificatekeypair OWNER TO authentik; +-- +-- Name: authentik_enterprise_license; Type: TABLE; Schema: public; Owner: authentik +-- + +CREATE TABLE public.authentik_enterprise_license ( + license_uuid uuid NOT NULL, + key text NOT NULL, + name text NOT NULL, + expiry timestamp with time zone NOT NULL, + internal_users bigint NOT NULL, + external_users bigint NOT NULL +); + + +ALTER TABLE public.authentik_enterprise_license OWNER TO authentik; + +-- +-- Name: authentik_enterprise_licenseusage; Type: TABLE; Schema: public; Owner: authentik +-- + +CREATE TABLE public.authentik_enterprise_licenseusage ( + expiring boolean NOT NULL, + expires timestamp with time zone NOT NULL, + usage_uuid uuid NOT NULL, + user_count bigint NOT NULL, + external_user_count bigint NOT NULL, + within_limits boolean NOT NULL, + record_date timestamp with time zone NOT NULL +); + + +ALTER TABLE public.authentik_enterprise_licenseusage OWNER TO authentik; + -- -- Name: authentik_events_event; Type: TABLE; Schema: public; Owner: authentik -- @@ -646,6 +710,17 @@ CREATE TABLE public.authentik_flows_stage ( ALTER TABLE public.authentik_flows_stage OWNER TO authentik; +-- +-- Name: authentik_install_id; Type: TABLE; Schema: public; Owner: authentik +-- + +CREATE TABLE public.authentik_install_id ( + id text NOT NULL +); + + +ALTER TABLE public.authentik_install_id OWNER TO authentik; + -- -- Name: authentik_outposts_dockerserviceconnection; Type: TABLE; Schema: public; Owner: authentik -- @@ -749,9 +824,10 @@ ALTER TABLE public.authentik_policies_dummy_dummypolicy OWNER TO authentik; CREATE TABLE public.authentik_policies_event_matcher_eventmatcherpolicy ( policy_ptr_id uuid NOT NULL, - action text NOT NULL, - client_ip text NOT NULL, - app text NOT NULL + action text, + client_ip text, + app text, + model text ); @@ -841,7 +917,9 @@ CREATE TABLE public.authentik_policies_policybinding ( negate boolean NOT NULL, timeout integer NOT NULL, group_id uuid, - user_id integer + user_id integer, + failure_result boolean NOT NULL, + CONSTRAINT authentik_policies_policybinding_timeout_b41b0d57_check CHECK ((timeout >= 0)) ); @@ -869,7 +947,9 @@ CREATE TABLE public.authentik_policies_reputation_reputation ( ip inet NOT NULL, ip_geo_data jsonb NOT NULL, score bigint NOT NULL, - updated timestamp with time zone NOT NULL + updated timestamp with time zone NOT NULL, + expires timestamp with time zone NOT NULL, + expiring boolean NOT NULL ); @@ -902,7 +982,8 @@ CREATE TABLE public.authentik_providers_ldap_ldapprovider ( gid_start_number integer NOT NULL, uid_start_number integer NOT NULL, search_mode text NOT NULL, - bind_mode text NOT NULL + bind_mode text NOT NULL, + mfa_support boolean NOT NULL ); @@ -921,7 +1002,9 @@ CREATE TABLE public.authentik_providers_oauth2_accesstoken ( token text NOT NULL, _id_token text NOT NULL, provider_id integer NOT NULL, - user_id integer NOT NULL + user_id integer NOT NULL, + auth_time timestamp with time zone NOT NULL, + session_id character varying NOT NULL ); @@ -956,7 +1039,9 @@ CREATE TABLE public.authentik_providers_oauth2_authorizationcode ( code_challenge_method character varying(255), provider_id integer NOT NULL, user_id integer NOT NULL, - revoked boolean NOT NULL + revoked boolean NOT NULL, + auth_time timestamp with time zone NOT NULL, + session_id character varying NOT NULL ); @@ -1070,7 +1155,9 @@ CREATE TABLE public.authentik_providers_oauth2_refreshtoken ( provider_id integer NOT NULL, user_id integer NOT NULL, revoked boolean NOT NULL, - token text NOT NULL + token text NOT NULL, + auth_time timestamp with time zone NOT NULL, + session_id character varying NOT NULL ); @@ -1126,6 +1213,20 @@ CREATE TABLE public.authentik_providers_proxy_proxyprovider ( ALTER TABLE public.authentik_providers_proxy_proxyprovider OWNER TO authentik; +-- +-- Name: authentik_providers_radius_radiusprovider; Type: TABLE; Schema: public; Owner: authentik +-- + +CREATE TABLE public.authentik_providers_radius_radiusprovider ( + provider_ptr_id integer NOT NULL, + shared_secret text NOT NULL, + client_networks text NOT NULL, + mfa_support boolean NOT NULL +); + + +ALTER TABLE public.authentik_providers_radius_radiusprovider OWNER TO authentik; + -- -- Name: authentik_providers_saml_samlpropertymapping; Type: TABLE; Schema: public; Owner: authentik -- @@ -1156,12 +1257,105 @@ CREATE TABLE public.authentik_providers_saml_samlprovider ( signing_kp_id uuid, sp_binding text NOT NULL, verification_kp_id uuid, - name_id_mapping_id uuid + name_id_mapping_id uuid, + default_relay_state text NOT NULL ); ALTER TABLE public.authentik_providers_saml_samlprovider OWNER TO authentik; +-- +-- Name: authentik_providers_scim_scimgroup; Type: TABLE; Schema: public; Owner: authentik +-- + +CREATE TABLE public.authentik_providers_scim_scimgroup ( + id text NOT NULL, + group_id uuid NOT NULL, + provider_id integer NOT NULL +); + + +ALTER TABLE public.authentik_providers_scim_scimgroup OWNER TO authentik; + +-- +-- Name: authentik_providers_scim_scimmapping; Type: TABLE; Schema: public; Owner: authentik +-- + +CREATE TABLE public.authentik_providers_scim_scimmapping ( + propertymapping_ptr_id uuid NOT NULL +); + + +ALTER TABLE public.authentik_providers_scim_scimmapping OWNER TO authentik; + +-- +-- Name: authentik_providers_scim_scimprovider; Type: TABLE; Schema: public; Owner: authentik +-- + +CREATE TABLE public.authentik_providers_scim_scimprovider ( + provider_ptr_id integer NOT NULL, + url text NOT NULL, + token text NOT NULL, + exclude_users_service_account boolean NOT NULL, + filter_group_id uuid +); + + +ALTER TABLE public.authentik_providers_scim_scimprovider OWNER TO authentik; + +-- +-- Name: authentik_providers_scim_scimprovider_property_mappings_group; Type: TABLE; Schema: public; Owner: authentik +-- + +CREATE TABLE public.authentik_providers_scim_scimprovider_property_mappings_group ( + id integer NOT NULL, + scimprovider_id integer NOT NULL, + propertymapping_id uuid NOT NULL +); + + +ALTER TABLE public.authentik_providers_scim_scimprovider_property_mappings_group OWNER TO authentik; + +-- +-- Name: authentik_providers_scim_scimprovider_property_mappings__id_seq; Type: SEQUENCE; Schema: public; Owner: authentik +-- + +ALTER TABLE public.authentik_providers_scim_scimprovider_property_mappings_group ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.authentik_providers_scim_scimprovider_property_mappings__id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + +-- +-- Name: authentik_providers_scim_scimuser; Type: TABLE; Schema: public; Owner: authentik +-- + +CREATE TABLE public.authentik_providers_scim_scimuser ( + id text NOT NULL, + provider_id integer NOT NULL, + user_id integer NOT NULL +); + + +ALTER TABLE public.authentik_providers_scim_scimuser OWNER TO authentik; + +-- +-- Name: authentik_rbac_role; Type: TABLE; Schema: public; Owner: authentik +-- + +CREATE TABLE public.authentik_rbac_role ( + uuid uuid NOT NULL, + name text NOT NULL, + group_id integer NOT NULL +); + + +ALTER TABLE public.authentik_rbac_role OWNER TO authentik; + -- -- Name: authentik_sources_ldap_ldappropertymapping; Type: TABLE; Schema: public; Owner: authentik -- @@ -1195,7 +1389,9 @@ CREATE TABLE public.authentik_sources_ldap_ldapsource ( sync_users boolean NOT NULL, sync_users_password boolean NOT NULL, group_membership_field text NOT NULL, - peer_certificate_id uuid + peer_certificate_id uuid, + client_certificate_id uuid, + sni boolean NOT NULL ); @@ -1307,7 +1503,8 @@ CREATE TABLE public.authentik_sources_saml_samlsource ( allow_idp_initiated boolean NOT NULL, digest_algorithm character varying(50) NOT NULL, signature_algorithm character varying(50) NOT NULL, - pre_authentication_flow_id uuid NOT NULL + pre_authentication_flow_id uuid NOT NULL, + verification_kp_id uuid ); @@ -1336,7 +1533,8 @@ CREATE TABLE public.authentik_stages_authenticator_duo_authenticatorduostage ( api_hostname text NOT NULL, configure_flow_id uuid, admin_integration_key text NOT NULL, - admin_secret_key text NOT NULL + admin_secret_key text NOT NULL, + friendly_name text ); @@ -1387,7 +1585,8 @@ CREATE TABLE public.authentik_stages_authenticator_sms_authenticatorsmsstage ( auth_password text NOT NULL, auth_type text NOT NULL, verify_only boolean NOT NULL, - mapping_id uuid + mapping_id uuid, + friendly_name text ); @@ -1433,12 +1632,74 @@ ALTER TABLE public.authentik_stages_authenticator_sms_smsdevice ALTER COLUMN id CREATE TABLE public.authentik_stages_authenticator_static_authenticatorstaticstage ( stage_ptr_id uuid NOT NULL, token_count integer NOT NULL, - configure_flow_id uuid + configure_flow_id uuid, + friendly_name text, + token_length integer NOT NULL, + CONSTRAINT authentik_stages_authen_token_count_72138a40_check CHECK ((token_count >= 0)), + CONSTRAINT authentik_stages_authenticator_static_authen_token_length_check CHECK ((token_length >= 0)) ); ALTER TABLE public.authentik_stages_authenticator_static_authenticatorstaticstage OWNER TO authentik; +-- +-- Name: authentik_stages_authenticator_static_staticdevice; Type: TABLE; Schema: public; Owner: authentik +-- + +CREATE TABLE public.authentik_stages_authenticator_static_staticdevice ( + id integer NOT NULL, + name character varying(64) NOT NULL, + confirmed boolean NOT NULL, + user_id integer NOT NULL, + throttling_failure_count integer NOT NULL, + throttling_failure_timestamp timestamp with time zone, + CONSTRAINT otp_static_staticdevice_throttling_failure_count_check CHECK ((throttling_failure_count >= 0)) +); + + +ALTER TABLE public.authentik_stages_authenticator_static_staticdevice OWNER TO authentik; + +-- +-- Name: authentik_stages_authenticator_static_staticdevice_id_seq; Type: SEQUENCE; Schema: public; Owner: authentik +-- + +ALTER TABLE public.authentik_stages_authenticator_static_staticdevice ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.authentik_stages_authenticator_static_staticdevice_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + +-- +-- Name: authentik_stages_authenticator_static_statictoken; Type: TABLE; Schema: public; Owner: authentik +-- + +CREATE TABLE public.authentik_stages_authenticator_static_statictoken ( + id integer NOT NULL, + token character varying(16) NOT NULL, + device_id integer NOT NULL +); + + +ALTER TABLE public.authentik_stages_authenticator_static_statictoken OWNER TO authentik; + +-- +-- Name: authentik_stages_authenticator_static_statictoken_id_seq; Type: SEQUENCE; Schema: public; Owner: authentik +-- + +ALTER TABLE public.authentik_stages_authenticator_static_statictoken ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.authentik_stages_authenticator_static_statictoken_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + -- -- Name: authentik_stages_authenticator_totp_authenticatortotpstage; Type: TABLE; Schema: public; Owner: authentik -- @@ -1446,12 +1707,54 @@ ALTER TABLE public.authentik_stages_authenticator_static_authenticatorstaticstag CREATE TABLE public.authentik_stages_authenticator_totp_authenticatortotpstage ( stage_ptr_id uuid NOT NULL, digits integer NOT NULL, - configure_flow_id uuid + configure_flow_id uuid, + friendly_name text ); ALTER TABLE public.authentik_stages_authenticator_totp_authenticatortotpstage OWNER TO authentik; +-- +-- Name: authentik_stages_authenticator_totp_totpdevice; Type: TABLE; Schema: public; Owner: authentik +-- + +CREATE TABLE public.authentik_stages_authenticator_totp_totpdevice ( + id integer NOT NULL, + name character varying(64) NOT NULL, + confirmed boolean NOT NULL, + key character varying(80) NOT NULL, + step smallint NOT NULL, + t0 bigint NOT NULL, + digits smallint NOT NULL, + tolerance smallint NOT NULL, + drift smallint NOT NULL, + last_t bigint NOT NULL, + user_id integer NOT NULL, + throttling_failure_count integer NOT NULL, + throttling_failure_timestamp timestamp with time zone, + CONSTRAINT otp_totp_totpdevice_digits_check CHECK ((digits >= 0)), + CONSTRAINT otp_totp_totpdevice_step_check CHECK ((step >= 0)), + CONSTRAINT otp_totp_totpdevice_throttling_failure_count_check CHECK ((throttling_failure_count >= 0)), + CONSTRAINT otp_totp_totpdevice_tolerance_check CHECK ((tolerance >= 0)) +); + + +ALTER TABLE public.authentik_stages_authenticator_totp_totpdevice OWNER TO authentik; + +-- +-- Name: authentik_stages_authenticator_totp_totpdevice_id_seq; Type: SEQUENCE; Schema: public; Owner: authentik +-- + +ALTER TABLE public.authentik_stages_authenticator_totp_totpdevice ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.authentik_stages_authenticator_totp_totpdevice_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + -- -- Name: authentik_stages_authenticator_validate_authenticatorvalida3e25; Type: TABLE; Schema: public; Owner: authentik -- @@ -1503,7 +1806,8 @@ CREATE TABLE public.authentik_stages_authenticator_webauthn_authenticatewebauth4 configure_flow_id uuid, user_verification text NOT NULL, authenticator_attachment text, - resident_key_requirement text NOT NULL + resident_key_requirement text NOT NULL, + friendly_name text ); @@ -1516,7 +1820,7 @@ ALTER TABLE public.authentik_stages_authenticator_webauthn_authenticatewebauth4b CREATE TABLE public.authentik_stages_authenticator_webauthn_webauthndevice ( id integer NOT NULL, name text NOT NULL, - credential_id character varying(300) NOT NULL, + credential_id text NOT NULL, public_key text NOT NULL, sign_count integer NOT NULL, rp_id character varying(253) NOT NULL, @@ -1606,7 +1910,8 @@ ALTER TABLE public.authentik_stages_consent_userconsent ALTER COLUMN id ADD GENE -- CREATE TABLE public.authentik_stages_deny_denystage ( - stage_ptr_id uuid NOT NULL + stage_ptr_id uuid NOT NULL, + deny_message text NOT NULL ); @@ -1752,7 +2057,9 @@ CREATE TABLE public.authentik_stages_prompt_prompt ( "order" integer NOT NULL, sub_text text NOT NULL, placeholder_expression boolean NOT NULL, - name text NOT NULL + name text NOT NULL, + initial_value text NOT NULL, + initial_value_expression boolean NOT NULL ); @@ -1840,7 +2147,9 @@ ALTER TABLE public.authentik_stages_user_delete_userdeletestage OWNER TO authent CREATE TABLE public.authentik_stages_user_login_userloginstage ( stage_ptr_id uuid NOT NULL, - session_duration text NOT NULL + session_duration text NOT NULL, + terminate_other_sessions boolean NOT NULL, + remember_me_offset text NOT NULL ); @@ -1866,7 +2175,8 @@ CREATE TABLE public.authentik_stages_user_write_userwritestage ( create_users_as_inactive boolean NOT NULL, create_users_group_id uuid, user_path_template text NOT NULL, - user_creation_mode text NOT NULL + user_creation_mode text NOT NULL, + user_type text NOT NULL ); @@ -2024,122 +2334,23 @@ ALTER TABLE public.guardian_userobjectpermission ALTER COLUMN id ADD GENERATED B -- --- Name: otp_static_staticdevice; Type: TABLE; Schema: public; Owner: authentik --- - -CREATE TABLE public.otp_static_staticdevice ( - id integer NOT NULL, - name character varying(64) NOT NULL, - confirmed boolean NOT NULL, - user_id integer NOT NULL, - throttling_failure_count integer NOT NULL, - throttling_failure_timestamp timestamp with time zone, - CONSTRAINT otp_static_staticdevice_throttling_failure_count_check CHECK ((throttling_failure_count >= 0)) -); - - -ALTER TABLE public.otp_static_staticdevice OWNER TO authentik; - --- --- Name: otp_static_staticdevice_id_seq; Type: SEQUENCE; Schema: public; Owner: authentik +-- Data for Name: auth_group; Type: TABLE DATA; Schema: public; Owner: authentik -- -ALTER TABLE public.otp_static_staticdevice ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( - SEQUENCE NAME public.otp_static_staticdevice_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1 -); +COPY public.auth_group (id, name) FROM stdin; +\. -- --- Name: otp_static_statictoken; Type: TABLE; Schema: public; Owner: authentik +-- Data for Name: auth_group_permissions; Type: TABLE DATA; Schema: public; Owner: authentik -- -CREATE TABLE public.otp_static_statictoken ( - id integer NOT NULL, - token character varying(16) NOT NULL, - device_id integer NOT NULL -); - +COPY public.auth_group_permissions (id, group_id, permission_id) FROM stdin; +\. -ALTER TABLE public.otp_static_statictoken OWNER TO authentik; -- --- Name: otp_static_statictoken_id_seq; Type: SEQUENCE; Schema: public; Owner: authentik --- - -ALTER TABLE public.otp_static_statictoken ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( - SEQUENCE NAME public.otp_static_statictoken_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1 -); - - --- --- Name: otp_totp_totpdevice; Type: TABLE; Schema: public; Owner: authentik --- - -CREATE TABLE public.otp_totp_totpdevice ( - id integer NOT NULL, - name character varying(64) NOT NULL, - confirmed boolean NOT NULL, - key character varying(80) NOT NULL, - step smallint NOT NULL, - t0 bigint NOT NULL, - digits smallint NOT NULL, - tolerance smallint NOT NULL, - drift smallint NOT NULL, - last_t bigint NOT NULL, - user_id integer NOT NULL, - throttling_failure_count integer NOT NULL, - throttling_failure_timestamp timestamp with time zone, - CONSTRAINT otp_totp_totpdevice_digits_check CHECK ((digits >= 0)), - CONSTRAINT otp_totp_totpdevice_step_check CHECK ((step >= 0)), - CONSTRAINT otp_totp_totpdevice_throttling_failure_count_check CHECK ((throttling_failure_count >= 0)), - CONSTRAINT otp_totp_totpdevice_tolerance_check CHECK ((tolerance >= 0)) -); - - -ALTER TABLE public.otp_totp_totpdevice OWNER TO authentik; - --- --- Name: otp_totp_totpdevice_id_seq; Type: SEQUENCE; Schema: public; Owner: authentik --- - -ALTER TABLE public.otp_totp_totpdevice ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( - SEQUENCE NAME public.otp_totp_totpdevice_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1 -); - - --- --- Data for Name: auth_group; Type: TABLE DATA; Schema: public; Owner: authentik --- - -COPY public.auth_group (id, name) FROM stdin; -\. - - --- --- Data for Name: auth_group_permissions; Type: TABLE DATA; Schema: public; Owner: authentik --- - -COPY public.auth_group_permissions (id, group_id, permission_id) FROM stdin; -\. - - --- --- Data for Name: auth_permission; Type: TABLE DATA; Schema: public; Owner: authentik +-- Data for Name: auth_permission; Type: TABLE DATA; Schema: public; Owner: authentik -- COPY public.auth_permission (id, name, content_type_id, codename) FROM stdin; @@ -2503,6 +2714,59 @@ COPY public.auth_permission (id, name, content_type_id, codename) FROM stdin; 358 Can change OAuth2 Access Token 88 change_accesstoken 359 Can delete OAuth2 Access Token 88 delete_accesstoken 360 Can view OAuth2 Access Token 88 view_accesstoken +361 Can add Radius Provider 89 add_radiusprovider +362 Can change Radius Provider 89 change_radiusprovider +363 Can delete Radius Provider 89 delete_radiusprovider +364 Can view Radius Provider 89 view_radiusprovider +365 Can add SCIM Mapping 90 add_scimmapping +366 Can change SCIM Mapping 90 change_scimmapping +367 Can delete SCIM Mapping 90 delete_scimmapping +368 Can view SCIM Mapping 90 view_scimmapping +369 Can add SCIM Provider 91 add_scimprovider +370 Can change SCIM Provider 91 change_scimprovider +371 Can delete SCIM Provider 91 delete_scimprovider +372 Can view SCIM Provider 91 view_scimprovider +373 Can add scim user 92 add_scimuser +374 Can change scim user 92 change_scimuser +375 Can delete scim user 92 delete_scimuser +376 Can view scim user 92 view_scimuser +377 Can add scim group 93 add_scimgroup +378 Can change scim group 93 change_scimgroup +379 Can delete scim group 93 delete_scimgroup +380 Can view scim group 93 view_scimgroup +381 Can add license 94 add_license +382 Can change license 94 change_license +383 Can delete license 94 delete_license +384 Can view license 94 view_license +385 Can add license usage 95 add_licenseusage +386 Can change license usage 95 change_licenseusage +387 Can delete license usage 95 delete_licenseusage +388 Can view license usage 95 view_licenseusage +389 Can inspect a Flow's execution 11 inspect_flow +390 Can add Role 96 add_role +391 Can change Role 96 change_role +392 Can delete Role 96 delete_role +393 Can view Role 96 view_role +394 Can assign permissions to users 96 assign_role_permissions +395 Can unassign permissions from users 96 unassign_role_permissions +396 Can view system info 97 view_system_info +397 Can view system tasks 97 view_system_tasks +398 Can run system tasks 97 run_system_tasks +399 Can access admin interface 97 access_admin_interface +400 Can add Static Device 98 add_staticdevice +401 Can change Static Device 98 change_staticdevice +402 Can delete Static Device 98 delete_staticdevice +403 Can view Static Device 98 view_staticdevice +404 Can add Static Token 99 add_statictoken +405 Can change Static Token 99 change_statictoken +406 Can delete Static Token 99 delete_statictoken +407 Can view Static Token 99 view_statictoken +408 Can add TOTP Device 100 add_totpdevice +409 Can change TOTP Device 100 change_totpdevice +410 Can delete TOTP Device 100 delete_totpdevice +411 Can view TOTP Device 100 view_totpdevice +412 Can assign permissions to users 79 assign_user_permissions +413 Can unassign permissions from users 79 unassign_user_permissions \. @@ -2511,25 +2775,28 @@ COPY public.auth_permission (id, name, content_type_id, codename) FROM stdin; -- COPY public.authentik_blueprints_blueprintinstance (created, last_updated, managed, instance_uuid, name, metadata, path, context, last_applied, last_applied_hash, status, enabled, managed_models, content) FROM stdin; -2022-10-25 09:00:53.397894+00 2022-10-25 09:10:14.895369+00 \N 213482b6-347a-4595-ad7d-0840332a7cce System - OAuth2 Provider - Scopes {"name": "System - OAuth2 Provider - Scopes", "labels": {"blueprints.goauthentik.io/system": "true"}} system/providers-oauth2.yaml {} 2022-10-25 09:10:14.895374+00 02f2a78e41e3dfd1961f58670180c4cbc363a817ad2374703c6c00cb13ac7bd6384b6f43068fdec3234bc0670c2fc648d99d3a6e6605a5dcbf40e518533abb41 successful t {} -2022-10-25 09:00:53.310947+00 2022-10-25 09:10:09.618136+00 \N 1b7a73a0-43a3-4f2a-a7ee-12b0d13f4f24 Default - Source authentication flow {"name": "Default - Source authentication flow", "labels": {}} default/20-flow-default-source-authentication.yaml {} 2022-10-25 09:10:09.618142+00 ca118ec7730cc3d4bc92ba1ae24f8a48d907334eec49ad42c4879fcb4be280ad71b4d382f695eb23dc58914fda64fe99b4fa2429859730495cd3bdaa9d9bb2e7 successful t {} -2022-10-25 09:00:53.350088+00 2022-10-25 09:10:16.645135+00 \N 92453927-91a0-4b15-8333-b2da469353d7 Default - Out-of-box-experience flow {"name": "Default - Out-of-box-experience flow", "labels": {}} default/91-flow-oobe.yaml {} 2022-10-25 09:10:16.64514+00 438cbcda03cf8ede7626243ae323c52122188a11cb36a4aa558aa75b28b74994577099c8884b7527c4ef97417e62e17e13edd6f7a1984c8f7306f9a4fe81f020 successful t {} -2022-10-25 09:00:53.340402+00 2022-10-25 09:10:17.999704+00 \N f08f67f4-fbd6-418d-bf24-e228ca2ae34e Default - Tenant {"name": "Default - Tenant", "labels": {}} default/90-default-tenant.yaml {} 2022-10-25 09:10:17.999712+00 e344cbc00fb28d259089011b56d7973851d04234d74b4c5d4dd2b84fcb9bb26c70142dafa927a768631810e2c08a65823fde450cc1e28c177be08e43f89ecf9f successful t {} 2022-10-25 09:00:53.406863+00 2022-10-25 09:10:18.979063+00 \N c447f75b-a332-422c-a7dd-74bae02281ef System - SAML Provider - Mappings {"name": "System - SAML Provider - Mappings", "labels": {"blueprints.goauthentik.io/system": "true"}} system/providers-saml.yaml {} 2022-10-25 09:10:18.979067+00 15301d80ffa95fa0c37bc14fe3b5aaa74224a5998f82cdbd1a701ab96f05290b05dcee25df7053282c516383a6ba9890cbaac11aeca0fe1f9717f24e3d819ae3 successful t {} -2022-10-25 09:00:53.335974+00 2022-10-25 09:10:14.729439+00 \N 1217d36b-4f47-4ae1-9a5b-d65fdd507895 Default - Events Transport & Rules {"name": "Default - Events Transport & Rules", "labels": {}} default/40-events-default.yaml {} 2022-10-25 09:10:14.729446+00 2064655bcf32b92e6ca7cc8bc5c872c76937b708a25607f25586c55f2bbe1fe410ea5287f75d90dc6defaeaf0e3f9e2a933a34b8ef5a49b2abdadf1a8c92c9d7 successful t {} -2022-10-25 09:00:53.400474+00 2022-10-25 09:10:17.189551+00 \N de836c31-6248-4155-b6d2-94f8e81ba828 System - Proxy Provider - Scopes {"name": "System - Proxy Provider - Scopes", "labels": {"blueprints.goauthentik.io/system": "true"}} system/providers-proxy.yaml {} 2022-10-25 09:10:17.189557+00 c92fa491977a1a528005dd11953a6c524ae91cbb8f390938a8083211df02428168c883e75ce3b4a76484cad89310633ef34f2a8a6a272b27e1a53914ea53a8d5 successful t {} -2022-10-25 09:00:53.328036+00 2022-10-25 09:10:17.987802+00 \N 2cf975a1-1f35-4976-aaaa-b3a62d0b0c8d Default - User settings flow {"name": "Default - User settings flow", "labels": {}} default/30-flow-default-user-settings-flow.yaml {} 2022-10-25 09:10:17.987806+00 a5288e994e544ec8a37722ffd3484b893fc6c5ec689ef62450da8bab1c67af0d2a9c0b137387b43f2053d6b3f2c01c4e9f33d9f680416e1940f25323451b664a successful t {} -2022-10-25 09:00:53.413147+00 2022-10-25 09:10:19.535643+00 \N 6c91b3d1-2c5f-435e-b678-cb1b061cb9d8 System - LDAP Source - Mappings {"name": "System - LDAP Source - Mappings", "labels": {"blueprints.goauthentik.io/system": "true"}} system/sources-ldap.yaml {} 2022-10-25 09:10:19.535647+00 a764c21c655e7c65bc93ae1ddd839cea9b4726a41ad2b7ca70323d48b0676beda6a67a3b8274cfaa65ed0f4cc5c089a44aeead66ef4579c78c5761861577bbe2 successful t {} -2022-10-25 09:00:53.317415+00 2022-10-25 09:10:12.218147+00 \N 157ef54a-3fc7-4529-81c1-17fb0e83c451 Default - Source enrollment flow {"name": "Default - Source enrollment flow", "labels": {}} default/20-flow-default-source-enrollment.yaml {} 2022-10-25 09:10:12.218151+00 cb2f4f1bdc25e16c15cc4ec8d6eee0322867dd6cffa8ea3b92de716c737e4131e669692c47d86c7a4a8ec6b0e30eadf1b49d607863f49dcc2718815f20935ea3 successful t {} -2022-10-25 09:00:53.281978+00 2022-10-25 09:10:17.748151+00 \N 69074314-6974-4913-ba5d-48c1496c0f64 Default - Password change flow {"name": "Default - Password change flow", "labels": {}} default/0-flow-password-change.yaml {} 2022-10-25 09:10:17.748155+00 0be8cfcc5e9f619eab59f3eaf13c1ec94d1a73b2357cb7d62acb35d23cb4fc2dcbecc055c8df048a2f8d44a537e672936463d3dc02cc15e9a2f31de62ddc9a3c successful t {} -2022-10-25 09:00:53.288147+00 2022-10-25 09:10:17.817575+00 \N 714dc451-7831-4016-b8e8-4e2e050233e1 Default - Authentication flow {"name": "Default - Authentication flow", "labels": {}} default/10-flow-default-authentication-flow.yaml {} 2022-10-25 09:10:17.817579+00 eb9af60243f762b4288943af5d25f4be6633444b54be1e84a9d850ee0cf84c05c45abd0321b71587619593508b49dde279788517231bd114f10350cfcaefa272 successful t {} -2022-10-25 09:00:53.297924+00 2023-03-13 09:19:05.28134+00 \N e47fd8ed-c357-4220-bb82-16931f998fe0 Default - TOTP MFA setup flow {"name": "Default - TOTP MFA setup flow", "labels": {}} default/20-flow-default-authenticator-totp-setup.yaml {} 2023-03-13 09:19:05.281345+00 43d2dcce568cbbe0f5409f52e2250ce4aa28ca34049cfc6f284070a2c28f81e85bb11fcf797f171beb1cb7aa15d70f26175ba9010b615563cac6057fc2563104 successful t {} -2022-10-25 09:00:53.300852+00 2023-03-13 09:19:06.955701+00 \N 658a7307-9f70-4d89-93ea-b6160a092741 Default - WebAuthn MFA setup flow {"name": "Default - WebAuthn MFA setup flow", "labels": {}} default/20-flow-default-authenticator-webauthn-setup.yaml {} 2023-03-13 09:19:06.955709+00 67ef724536f41c451b9a88edd01100bd9ba4460f41b88f86a1385f9df30617371d0ddb610526b0a93aa32b9918475a63db03dfcf57bf7d0b02e7b509f9d35870 successful t {} -2022-10-25 09:00:53.306829+00 2023-03-13 09:19:07.746788+00 \N a463e186-ff86-4d2e-8c04-700e05a741af Default - Provider authorization flow (implicit consent) {"name": "Default - Provider authorization flow (implicit consent)", "labels": {}} default/20-flow-default-provider-authorization-implicit-consent.yaml {} 2023-03-13 09:19:07.746797+00 2321088d922a57fdb741f6857eb48d3090210b552a6ecadf9b08de4832f574c5231f37943da3dfda3a4fa07c900d236ace3d237710e2e986a207c758859c1ecb successful t {} -2022-10-25 09:00:53.319721+00 2023-03-13 09:19:08.143754+00 \N 29fc34e4-d6e5-47ed-97f0-2923728a9f81 Default - Source pre-authentication flow {"name": "Default - Source pre-authentication flow", "labels": {}} default/20-flow-default-source-pre-authentication.yaml {} 2023-03-13 09:19:08.143761+00 51b6b65fb60f2f925d80b38cf173e7aa1bc00a074990e8d0b0dfaae088007ceb9051d0722f41500da309af5d0c78417dfa08a40c706203c3d6ee1eb613ad564c successful t {} -2022-10-25 09:00:53.295041+00 2023-03-13 09:19:05.301487+00 \N 2cf52321-80c1-4c45-a598-ccdd61af26aa Default - Static MFA setup flow {"name": "Default - Static MFA setup flow", "labels": {}} default/20-flow-default-authenticator-static-setup.yaml {} 2023-03-13 09:19:05.301492+00 552f08718b573593e1b643f47734a1c185d7bc9efbea9c8d3dc49e6fd5060cd979098637fa20d958f4c3c9ff375e0fde3b871c956b87ebfbb1fb9319ba073bcb successful t {} -2022-10-25 09:00:53.303808+00 2023-03-13 09:19:07.800567+00 \N 8f64e59f-b41e-4c84-891b-492df2873c7b Default - Provider authorization flow (explicit consent) {"name": "Default - Provider authorization flow (explicit consent)", "labels": {}} default/20-flow-default-provider-authorization-explicit-consent.yaml {} 2023-03-13 09:19:07.800573+00 598eee861d8814c0dc200542f59e5d8085c140f7131e87a9fc6baf688a3e9e7b0a32e865c439084f76b11992ce056be574e5aba2664375cee287206d66d345c9 successful t {} -2022-10-25 09:00:53.291824+00 2023-03-13 09:19:04.593543+00 \N f999866c-2729-44cd-aee3-53e3df227bd6 Default - Invalidation flow {"name": "Default - Invalidation flow", "labels": {}} default/10-flow-default-invalidation-flow.yaml {} 2023-03-13 09:19:04.593549+00 a143bf2b9a1d20e2078389f7c37d213e51c52ea6a8df8f8ae7a7e6a1bb129ec4b862803aa44b8b1dbfe727e98e45cedac7a1a03632065040c87f045dcabab86a successful t {} +2024-01-10 13:34:48.625092+00 2024-01-10 13:34:54.474912+00 \N 7adb4b0a-c9bf-4872-9220-bbe6245e3162 Default - Tenant {"name": "Default - Tenant", "labels": {}} default/default-tenant.yaml {} 2024-01-10 13:34:54.474916+00 65a18916f00bcac6bb8a2fb5d05f891eb1473ef262d4d9c78afeba012efbaf1330c2141923f7ffade17472be0c19b9f0b53b40f46e66f07a35bb6d2c00922aee successful t {} +2024-01-10 13:34:48.638166+00 2024-01-10 13:34:52.69052+00 \N 55bfa7bc-b223-41f2-b105-1f95da2685e6 Default - Events Transport & Rules {"name": "Default - Events Transport & Rules", "labels": {}} default/events-default.yaml {} 2024-01-10 13:34:52.690524+00 bbee0ba3e8e51ea5290dbb2081c7c0a18f633766ff8f43c8897d68d0ea51b648cdffb345239b55cbab72d041972e80aea793d8a7a379f25aca02882f9fbf94ff successful t {} +2024-01-10 13:34:48.645446+00 2024-01-10 13:34:53.155446+00 \N e990d111-e8a0-4ad8-9d7e-6a5d2437a146 Default - Static MFA setup flow {"name": "Default - Static MFA setup flow", "labels": {}} default/flow-default-authenticator-static-setup.yaml {} 2024-01-10 13:34:53.155449+00 552f08718b573593e1b643f47734a1c185d7bc9efbea9c8d3dc49e6fd5060cd979098637fa20d958f4c3c9ff375e0fde3b871c956b87ebfbb1fb9319ba073bcb successful t {} +2024-01-10 13:34:48.649232+00 2024-01-10 13:34:53.730219+00 \N e23700e3-9a34-4c4e-875d-91cfb5c4f256 Default - TOTP MFA setup flow {"name": "Default - TOTP MFA setup flow", "labels": {}} default/flow-default-authenticator-totp-setup.yaml {} 2024-01-10 13:34:53.730224+00 43d2dcce568cbbe0f5409f52e2250ce4aa28ca34049cfc6f284070a2c28f81e85bb11fcf797f171beb1cb7aa15d70f26175ba9010b615563cac6057fc2563104 successful t {} +2024-01-10 13:34:48.653021+00 2024-01-10 13:34:54.090272+00 \N d359504f-978c-45a0-b6aa-b966c6e03cd7 Default - WebAuthn MFA setup flow {"name": "Default - WebAuthn MFA setup flow", "labels": {}} default/flow-default-authenticator-webauthn-setup.yaml {} 2024-01-10 13:34:54.090276+00 67ef724536f41c451b9a88edd01100bd9ba4460f41b88f86a1385f9df30617371d0ddb610526b0a93aa32b9918475a63db03dfcf57bf7d0b02e7b509f9d35870 successful t {} +2024-01-10 13:34:48.686469+00 2024-01-10 13:34:54.232304+00 \N 8a3417bb-039c-4bfe-b6ff-5d5a8911f01f Default - Password change flow {"name": "Default - Password change flow", "labels": {}} default/flow-password-change.yaml {} 2024-01-10 13:34:54.232307+00 6d7f1794e5a024d80e373fa9c9c791fa4837c690ad70d707fdf16208dec7c4b7feb490ab83f1db3e7d37f3d3ea41dfc4b06d87a221ffb208c076ff6672c27cc1 successful t {} +2024-01-10 13:34:48.641729+00 2024-01-10 13:34:54.29831+00 \N f5735a05-808c-4889-bba5-988fcb3063b6 Default - Authentication flow {"name": "Default - Authentication flow", "labels": {}} default/flow-default-authentication-flow.yaml {} 2024-01-10 13:34:54.298314+00 4a184d3cf46bcc05e3e6e2428b1100877d3b3a6ddf934a936d65f26ef81bc5cfb5481cf3128217ee3bbf4d1a83060f4b0c7b43a88546827b9e65fdaa361e8259 successful t {} +2024-01-10 13:34:48.656613+00 2024-01-10 13:34:54.329178+00 \N a1c83a68-6d68-47ae-af9a-62c9762cf788 Default - Invalidation flow {"name": "Default - Invalidation flow", "labels": {}} default/flow-default-invalidation-flow.yaml {} 2024-01-10 13:34:54.329181+00 a143bf2b9a1d20e2078389f7c37d213e51c52ea6a8df8f8ae7a7e6a1bb129ec4b862803aa44b8b1dbfe727e98e45cedac7a1a03632065040c87f045dcabab86a successful t {} +2024-01-10 13:34:48.679748+00 2024-01-10 13:34:54.462557+00 \N f4774725-5832-4732-a797-c06f0893fc63 Default - User settings flow {"name": "Default - User settings flow", "labels": {}} default/flow-default-user-settings-flow.yaml {} 2024-01-10 13:34:54.462561+00 192e32332465563ca0ec85be2ef4057a6bb8aeb3a291503d184f66c45377caa313225dc367780ef5ea4f1c86fbddc0b7706dd4cf0a3b9f5528abc98c790a883b successful t {} +2022-10-25 09:00:53.413147+00 2024-01-10 13:36:28.987297+00 \N 6c91b3d1-2c5f-435e-b678-cb1b061cb9d8 System - LDAP Source - Mappings {"name": "System - LDAP Source - Mappings", "labels": {"blueprints.goauthentik.io/system": "true"}} system/sources-ldap.yaml {} 2024-01-10 13:36:28.987301+00 cd457d2cef04cfc24d7bbadfac35d8ee1e91b04a0a1662a18a0428e97043bdbfbe7a23b21b065d2e201c291c99f22a38b32436d76edfa86dbae3a5dc4d0ae4fc successful t {} +2024-01-10 13:34:48.704062+00 2024-01-10 13:36:28.914859+00 \N d2e1c529-0d50-466b-8e49-99be615d70ae System - SCIM Provider - Mappings {"name": "System - SCIM Provider - Mappings", "labels": {"blueprints.goauthentik.io/system": "true"}} system/providers-scim.yaml {} 2024-01-10 13:36:28.914863+00 02355b6b64a68ce55025b5ddfe82f1153e1cc96bb3d2dad1176eef58a2285090944db5d583dd3a196bb5b7366f023412e043e72738613dc00f943f836cfc5b77 successful t {} +2024-01-10 13:34:48.659762+00 2024-01-10 13:36:28.345301+00 \N 24622aa4-74dc-46ba-8dba-f49da993c93e Default - Provider authorization flow (explicit consent) {"name": "Default - Provider authorization flow (explicit consent)", "labels": {}} default/flow-default-provider-authorization-explicit-consent.yaml {} 2024-01-10 13:36:28.345305+00 598eee861d8814c0dc200542f59e5d8085c140f7131e87a9fc6baf688a3e9e7b0a32e865c439084f76b11992ce056be574e5aba2664375cee287206d66d345c9 successful t {} +2024-01-10 13:34:48.663845+00 2024-01-10 13:36:28.370938+00 \N ec217fcf-60d9-4503-9e9c-345ff752a84d Default - Provider authorization flow (implicit consent) {"name": "Default - Provider authorization flow (implicit consent)", "labels": {}} default/flow-default-provider-authorization-implicit-consent.yaml {} 2024-01-10 13:36:28.370942+00 2321088d922a57fdb741f6857eb48d3090210b552a6ecadf9b08de4832f574c5231f37943da3dfda3a4fa07c900d236ace3d237710e2e986a207c758859c1ecb successful t {} +2024-01-10 13:34:48.672318+00 2024-01-10 13:36:28.547263+00 \N 2e2d0f4b-634d-4b56-a0ea-f06b33a789be Default - Source enrollment flow {"name": "Default - Source enrollment flow", "labels": {}} default/flow-default-source-enrollment.yaml {} 2024-01-10 13:36:28.547269+00 09d441466d7e215601551bcd7c147616bf3794909f706c99c8fe527120d6fc612c1e4885c09bca433b97273e6a3fb10840d957bd7c898b14495a7bb245060792 successful t {} +2024-01-10 13:34:48.683193+00 2024-01-10 13:36:28.724385+00 \N c0185639-17d7-4acb-bba0-f2044a8639a2 Default - Out-of-box-experience flow {"name": "Default - Out-of-box-experience flow", "labels": {}} default/flow-oobe.yaml {} 2024-01-10 13:36:28.724389+00 92a35a46db8ccbcd707c6065bf07797ab7a45406e24660d921224fb534875a2e204acf9bc65bcfc3a7b6f876414f0ee6d43669fa51244022afdf1b51aac11940 successful t {} +2022-10-25 09:00:53.400474+00 2024-01-10 13:36:28.879985+00 \N de836c31-6248-4155-b6d2-94f8e81ba828 System - Proxy Provider - Scopes {"name": "System - Proxy Provider - Scopes", "labels": {"blueprints.goauthentik.io/system": "true"}} system/providers-proxy.yaml {} 2024-01-10 13:36:28.879989+00 a17fede4a19025273be4e8556cdef4b62c6a4ebc8051f6dde98657cf571f16cf7ce45388539b68e891f96184b6cde3bb2273de43643e1b6d788548cfbf0393dc successful t {} +2022-10-25 09:00:53.397894+00 2024-01-10 13:36:28.896306+00 \N 213482b6-347a-4595-ad7d-0840332a7cce System - OAuth2 Provider - Scopes {"name": "System - OAuth2 Provider - Scopes", "labels": {"blueprints.goauthentik.io/system": "true"}} system/providers-oauth2.yaml {} 2024-01-10 13:36:28.89631+00 bef8877737dd0345a5e88ef83518cbcdd45b2345037f925dc51eb7097bdbd0c4e1565aacdf08ec3ce2c0b90f8bcba013fa8618ea12881062e4e59d87a2abf992 successful t {} +2024-01-10 13:34:48.668685+00 2024-01-10 13:36:28.445116+00 \N 4ec2089d-f691-4167-bd7c-5e569f26e126 Default - Source authentication flow {"name": "Default - Source authentication flow", "labels": {}} default/flow-default-source-authentication.yaml {} 2024-01-10 13:36:28.445119+00 d86613f21394af35d554692e9868e7e714bb7cbd99a362ef3b64476edc1ffcb832443610f193d3d116685a360a1734482a7152b6b6bc8913f45be9aac3934233 successful t {} +2024-01-10 13:34:48.676012+00 2024-01-10 13:36:28.470437+00 \N 60726d29-8b4f-461a-af0e-9f53a47016fa Default - Source pre-authentication flow {"name": "Default - Source pre-authentication flow", "labels": {}} default/flow-default-source-pre-authentication.yaml {} 2024-01-10 13:36:28.470448+00 51b6b65fb60f2f925d80b38cf173e7aa1bc00a074990e8d0b0dfaae088007ceb9051d0722f41500da309af5d0c78417dfa08a40c706203c3d6ee1eb613ad564c successful t {} +2024-01-10 13:34:48.694474+00 2024-01-10 13:36:28.845959+00 \N 018eec69-91fa-45fd-9157-153b590aadf8 Migration - Remove old prompt fields {"name": "Migration - Remove old prompt fields", "labels": {"blueprints.goauthentik.io/description": "Migrate to 2023.2, remove unused prompt fields"}} migrations/migrate-prompt-name.yaml {} 2024-01-10 13:36:28.845963+00 75617432af2bb8d9cbf74db0ba9d6ec1b123811fdb40975685386c85d8fa4046cc4749a8e6f1f3e0c1068e45bf2af73914c6ede5b3d31fa2ac3195a53b94cf43 successful t {} +2024-01-10 13:34:48.697665+00 2024-01-10 13:36:28.845678+00 \N 104c603e-f3e5-4e67-b3ad-ec31a9b2a9de authentik Bootstrap {"name": "authentik Bootstrap", "labels": {"blueprints.goauthentik.io/system": "true", "blueprints.goauthentik.io/description": "This blueprint configures the default admin user and group, and configures them for the [Automated install](https://goauthentik.io/docs/installation/automated-install).\\n", "blueprints.goauthentik.io/system-bootstrap": "true"}} system/bootstrap.yaml {} 2024-01-10 13:36:28.845683+00 c572042434ffa2218c5a30a656cbf10c80c434f19c596cdd9bc6aa37897af3be3f0ab0a73e332ebafb72bdf8a1a15937fb62c9077b484602edf4a196b632aa45 successful t {} \. @@ -2549,29 +2816,7 @@ d7dfa1d6-58cb-4bfc-94c5-38a2e507e290 Grafana OIDC grafana-oidc 3 f -- COPY public.authentik_core_authenticatedsession (expires, expiring, uuid, session_key, last_ip, last_user_agent, last_used, user_id) FROM stdin; -2023-03-27 09:19:36.714022+00 t 33fe8ba2-6652-46bc-992f-97edf89c9024 pnrgpcmuzjc6jd2ae4z3242v4le8s4dq 172.21.0.1 Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/111.0 2023-03-13 09:19:36.714338+00 1 -2023-03-27 09:20:56.674782+00 t db2c32ff-209a-4d71-a3c0-0dee0e024878 xps84azhqco49sstac6uj8jsjm9djtky 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 09:20:56.675277+00 6 -2023-03-27 09:21:58.815582+00 t fb2c4db2-c31e-4381-ba85-67828f436acc mwee9onydg2kpu2bg3pth8al2zv6jlve 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 09:21:58.815791+00 6 -2023-03-27 09:22:06.761376+00 t b183a313-ee3f-4e6f-b40a-d895aecbba25 1hhujbpdfb5muhahlo90s8523scor7dy 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 09:22:06.761575+00 6 -2023-03-27 09:22:07.83058+00 t 23261a68-1274-45cf-84c8-57591f5f13d0 2usmu6zxqajue9y7gl40brm34zh8jyhu 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 09:22:07.830772+00 8 -2023-03-27 09:26:00.688+00 t a1ea049c-5a2a-444f-bb0f-46f0cc1b83c2 t7k0uvsutwv5j7belns84z00di5bax5b 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 09:26:00.688213+00 6 -2023-03-27 09:27:12.748587+00 t c91473b6-670e-471f-8fdd-342a912c26a3 twvhclnfbrpdro8g787rhchxsdw99vpy 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 09:27:12.748761+00 6 -2023-03-27 09:33:04.047499+00 t b7d2c069-b68c-4674-b542-49ee8bcab49c 7anpa8pum56ecu84q910ey1tdgqt0l7a 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 09:33:04.048038+00 6 -2023-03-27 09:36:00.677614+00 t 1b7369c6-423c-4619-a95a-554fea065bb9 asz3sdzh4l8jap3v5fwobm0p3ryb3m6z 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 09:36:00.677783+00 6 -2023-03-27 09:38:09.280265+00 t 1d06b319-18fe-40a8-88b9-09a0fad5e9e7 wgivmdoyjh4j3lk4o35lxe1rgr4mtiiz 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 09:38:09.280422+00 6 -2023-03-27 09:46:00.676548+00 t 21e81f59-9584-489a-ae72-1c9be3788b5f 4ssznhrdtmibayofismpmq9pi4ji1z8y 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 09:46:00.676691+00 6 -2023-03-27 09:56:00.696737+00 t 7fefc723-7eb0-49c2-91dd-1b1c761f6863 vservymrqbrg4iqtr3tq2u3770fi9m5w 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 09:56:00.696955+00 6 -2023-03-27 12:15:42.983641+00 t 20e56a62-42d9-4a85-9354-8ecd546adc82 4u7n2w39blisa36ljoltvi1mnvn6k7rt 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 12:15:42.983795+00 6 -2023-03-27 12:16:00.756786+00 t 543a5423-0540-4a97-bea8-dd1a2fc1ef20 tlhzjzc8dus5akchnm7e3ivh2t8a7z5o 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 12:16:00.756966+00 6 -2023-03-27 12:16:25.282349+00 t 79648ae0-368e-4b54-ab74-bf8508f99c3b izckm6qrlxf200ikhmp245gw1ud1jni2 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 12:16:25.282588+00 6 -2023-03-27 12:17:03.669358+00 t f743c44b-7c71-488c-a065-d5e217fa5307 fuywipgbmgx1wzyf8654z1dniyy6yl9y 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 12:17:03.669511+00 6 -2023-03-27 12:17:27.480927+00 t 0da81503-cba6-44f5-a1ea-b81a43c8cdac 7icfpl80xx04jmth4untnhgznjw1jjcp 172.21.0.1 Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/111.0 2023-03-13 12:17:27.481193+00 8 -2023-03-27 12:22:20.180659+00 t 5df911c2-797f-4b77-82d2-fa66bdb251d0 ledovmptzdsj60tfv4irprijdaycu19d 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 12:22:20.180803+00 6 -2023-03-27 12:22:37.470262+00 t 21fcd520-e1d7-45bc-9691-6c14e51b4a1b gznzvsfyrfjr6i8qt5oyggutd89mzi19 172.21.0.1 Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/111.0 2023-03-13 12:22:37.470585+00 8 -2023-03-27 12:35:42.672958+00 t 6674325a-4a57-4ae3-995f-c0769c7cb5c2 ookwxmfok1k9id4jediu5030pvrdnzoj 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 12:35:42.673195+00 6 -2023-03-27 12:36:00.775286+00 t bb8317b6-8aab-44bd-9762-bb0a5cdd6854 qg1f4o9q2yihtvuejx8rz0exgcoxgxcq 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 12:36:00.775486+00 6 -2023-03-27 12:36:31.723397+00 t 42645d7c-a570-44e0-a030-d3078b54d978 m7skhobmxdhmrk4c3ne1yq6xbvfzrp29 172.21.0.1 Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/111.0 2023-03-13 12:36:31.723588+00 8 -2023-03-27 12:40:53.868898+00 t 4ba1f1c1-b066-4c1f-b483-b6cd3484f634 h2zyqqyoe9kxch9blcb8e42xzagr3r02 172.21.0.1 goauthentik.io/outpost/2023.1.2 2023-03-13 12:40:53.869061+00 6 +2024-01-25 22:11:15.829855+00 t a1b000d1-d98e-4e84-8f3a-39476361245a 3w9uc4x8na04umrd1glnat6bpldocjkg 172.20.0.8 goauthentik.io/outpost/2023.10.6 2024-01-11 22:11:15.830189+00 6 \. @@ -2589,21 +2834,19 @@ ca14281c-e025-4691-b5a2-3ee415920e80 extra-group {} \N f \. +-- +-- Data for Name: authentik_core_group_roles; Type: TABLE DATA; Schema: public; Owner: authentik +-- + +COPY public.authentik_core_group_roles (id, group_id, role_id) FROM stdin; +\. + + -- -- Data for Name: authentik_core_propertymapping; Type: TABLE DATA; Schema: public; Owner: authentik -- COPY public.authentik_core_propertymapping (pm_uuid, name, expression, managed) FROM stdin; -d3406e7e-2c6d-4d72-acdd-dda5513b1396 authentik default OAuth Mapping: Proxy outpost # This mapping is used by the authentik proxy. It passes extra user attributes,\n# which are used for example for the HTTP-Basic Authentication mapping.\nreturn {\n "ak_proxy": {\n "user_attributes": request.user.group_attributes(request),\n "is_superuser": request.user.is_superuser,\n }\n} goauthentik.io/providers/proxy/scope-proxy -353b9d65-66ce-4fea-814b-e12c6e45a277 authentik default LDAP Mapping: Name return ldap.get('name') goauthentik.io/sources/ldap/default-name -a58eb3a5-c68d-406d-a5ff-688a6a047575 authentik default LDAP Mapping: mail return ldap.get('mail') goauthentik.io/sources/ldap/default-mail -a75310af-c0cf-4709-a66c-963d8c27e662 authentik default Active Directory Mapping: sAMAccountName return ldap.get('sAMAccountName') goauthentik.io/sources/ldap/ms-samaccountname -b5ae1f98-a70a-4ea3-a8c5-15285a8755bc authentik default Active Directory Mapping: userPrincipalName return list_flatten(ldap.get('userPrincipalName')) goauthentik.io/sources/ldap/ms-userprincipalname -e8244e12-ed86-4f61-a0df-cfac793c267e authentik default Active Directory Mapping: givenName return list_flatten(ldap.get('givenName')) goauthentik.io/sources/ldap/ms-givenName -81c79ce4-042d-470d-9aa4-48baaa08591b authentik default Active Directory Mapping: sn return list_flatten(ldap.get('sn')) goauthentik.io/sources/ldap/ms-sn -9c13a34a-6007-4846-8c28-11d6dbd235c7 authentik default OAuth Mapping: OpenID 'openid' # This scope is required by the OpenID-spec, and must as such exist in authentik.\n# The scope by itself does not grant any information\nreturn {} goauthentik.io/providers/oauth2/scope-openid -a4825145-a8d1-4fbf-b88c-70d8cc801a4a authentik default OAuth Mapping: OpenID 'email' return {\n "email": request.user.email,\n "email_verified": True\n} goauthentik.io/providers/oauth2/scope-email -7446364c-faec-4cab-9191-beb2889d47b7 authentik default OAuth Mapping: OpenID 'profile' return {\n # Because authentik only saves the user's full name, and has no concept of first and last names,\n # the full name is used as given name.\n # You can override this behaviour in custom mappings, i.e. `request.user.name.split(" ")`\n "name": request.user.name,\n "given_name": request.user.name,\n "family_name": "",\n "preferred_username": request.user.username,\n "nickname": request.user.username,\n # groups is not part of the official userinfo schema, but is a quasi-standard\n "groups": [group.name for group in request.user.ak_groups.all()],\n} goauthentik.io/providers/oauth2/scope-profile 765b25ee-0e8b-4394-a56c-e27abf68f5ce authentik default SAML Mapping: UPN return request.user.attributes.get('upn', request.user.email) goauthentik.io/providers/saml/upn ef027c1a-728f-44e5-9072-6942d3e8d15e authentik default SAML Mapping: Name return request.user.name goauthentik.io/providers/saml/name 05712cf6-0557-4ae7-95e7-3a0538962b67 authentik default SAML Mapping: Email return request.user.email goauthentik.io/providers/saml/email @@ -2611,8 +2854,21 @@ b3fa1ee7-9507-4869-a29a-3c17c2482839 authentik default SAML Mapping: Username re 38ac59c3-e754-44e7-9790-5108a2cd027c authentik default SAML Mapping: User ID return request.user.pk goauthentik.io/providers/saml/uid c5d93ce6-8cda-42fa-9c19-3837ba67788a authentik default SAML Mapping: Groups for group in request.user.ak_groups.all():\n yield group.name goauthentik.io/providers/saml/groups 29fab284-b6db-433a-9c3c-7575aba47b2b authentik default SAML Mapping: WindowsAccountname (Username) return request.user.username goauthentik.io/providers/saml/ms-windowsaccountname +9c13a34a-6007-4846-8c28-11d6dbd235c7 authentik default OAuth Mapping: OpenID 'openid' # This scope is required by the OpenID-spec, and must as such exist in authentik.\n# The scope by itself does not grant any information\nreturn {} goauthentik.io/providers/oauth2/scope-openid +a4825145-a8d1-4fbf-b88c-70d8cc801a4a authentik default OAuth Mapping: OpenID 'email' return {\n "email": request.user.email,\n "email_verified": True\n} goauthentik.io/providers/oauth2/scope-email +252edb40-34ee-4f55-8ca4-4bf0a7fb445f authentik default SCIM Mapping: Group return {\n "displayName": group.name,\n} goauthentik.io/providers/scim/group +353b9d65-66ce-4fea-814b-e12c6e45a277 authentik default LDAP Mapping: Name return ldap.get('name') goauthentik.io/sources/ldap/default-name +a58eb3a5-c68d-406d-a5ff-688a6a047575 authentik default LDAP Mapping: mail return ldap.get('mail') goauthentik.io/sources/ldap/default-mail +a75310af-c0cf-4709-a66c-963d8c27e662 authentik default Active Directory Mapping: sAMAccountName return ldap.get('sAMAccountName') goauthentik.io/sources/ldap/ms-samaccountname +b5ae1f98-a70a-4ea3-a8c5-15285a8755bc authentik default Active Directory Mapping: userPrincipalName return list_flatten(ldap.get('userPrincipalName')) goauthentik.io/sources/ldap/ms-userprincipalname +e8244e12-ed86-4f61-a0df-cfac793c267e authentik default Active Directory Mapping: givenName return list_flatten(ldap.get('givenName')) goauthentik.io/sources/ldap/ms-givenName +81c79ce4-042d-470d-9aa4-48baaa08591b authentik default Active Directory Mapping: sn return list_flatten(ldap.get('sn')) goauthentik.io/sources/ldap/ms-sn 0094050f-e24a-4eff-8346-712851851af9 authentik default OpenLDAP Mapping: uid return ldap.get('uid') goauthentik.io/sources/ldap/openldap-uid e8809202-4ff0-4086-b078-196e6152e826 authentik default OpenLDAP Mapping: cn return ldap.get('cn') goauthentik.io/sources/ldap/openldap-cn +7446364c-faec-4cab-9191-beb2889d47b7 authentik default OAuth Mapping: OpenID 'profile' return {\n # Because authentik only saves the user's full name, and has no concept of first and last names,\n # the full name is used as given name.\n # You can override this behaviour in custom mappings, i.e. `request.user.name.split(" ")`\n "name": request.user.name,\n "given_name": request.user.name,\n "preferred_username": request.user.username,\n "nickname": request.user.username,\n # groups is not part of the official userinfo schema, but is a quasi-standard\n "groups": [group.name for group in request.user.ak_groups.all()],\n} goauthentik.io/providers/oauth2/scope-profile +200af9d0-7476-43e1-a0fb-b7a7a68dd277 authentik default LDAP Mapping: DN to User Path dn = ldap.get("distinguishedName")\npath_elements = []\nfor pair in dn.split(","):\n attr, _, value = pair.partition("=")\n # Ignore elements from the Root DSE and the canonical name of the object\n if attr.lower() in ["cn", "dc"]:\n continue\n path_elements.append(value)\npath_elements.reverse()\n\npath = source.get_user_path()\nif len(path_elements) > 0:\n path = f"{path}/{'/'.join(path_elements)}"\nreturn path goauthentik.io/sources/ldap/default-dn-path +d3406e7e-2c6d-4d72-acdd-dda5513b1396 authentik default OAuth Mapping: Proxy outpost # This mapping is used by the authentik proxy. It passes extra user attributes,\n# which are used for example for the HTTP-Basic Authentication mapping.\nsession_id = None\nif "token" in request.context:\n session_id = request.context.get("token").session_id\nreturn {\n "sid": session_id,\n "ak_proxy": {\n "user_attributes": request.user.group_attributes(request),\n "is_superuser": request.user.is_superuser,\n }\n} goauthentik.io/providers/proxy/scope-proxy +83a19cb8-acab-44e1-884f-a0a279ae4d2d authentik default SCIM Mapping: User # Some implementations require givenName and familyName to be set\ngivenName, familyName = request.user.name, " "\nformatted = request.user.name + " "\n# This default sets givenName to the name before the first space\n# and the remainder as family name\n# if the user's name has no space the givenName is the entire name\n# (this might cause issues with some SCIM implementations)\nif " " in request.user.name:\n givenName, _, familyName = request.user.name.partition(" ")\n formatted = request.user.name\n\n# photos supports URLs to images, however authentik might return data URIs\navatar = request.user.avatar\nphotos = None\nif "://" in avatar:\n photos = [{"value": avatar, "type": "photo"}]\n\nlocale = request.user.locale()\nif locale == "":\n locale = None\n\nemails = []\nif request.user.email != "":\n emails = [{\n "value": request.user.email,\n "type": "other",\n "primary": True,\n }]\nreturn {\n "userName": request.user.username,\n "name": {\n "formatted": formatted,\n "givenName": givenName,\n "familyName": familyName,\n },\n "displayName": request.user.name,\n "photos": photos,\n "locale": locale,\n "active": request.user.is_active,\n "emails": emails,\n} goauthentik.io/providers/scim/user \. @@ -2620,10 +2876,10 @@ e8809202-4ff0-4086-b078-196e6152e826 authentik default OpenLDAP Mapping: cn retu -- Data for Name: authentik_core_provider; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_core_provider (id, authorization_flow_id, name) FROM stdin; -1 87ca9599-5323-451c-8603-2f5bee7a0c12 grafana-ldap -2 a7ef54bb-7959-4559-979d-9345c022e086 grafana-saml -3 a7ef54bb-7959-4559-979d-9345c022e086 grafana-oidc +COPY public.authentik_core_provider (id, authorization_flow_id, name, authentication_flow_id, backchannel_application_id, is_backchannel) FROM stdin; +1 87ca9599-5323-451c-8603-2f5bee7a0c12 grafana-ldap \N \N t +3 a7ef54bb-7959-4559-979d-9345c022e086 grafana-oidc \N \N f +2 a7ef54bb-7959-4559-979d-9345c022e086 grafana-saml \N \N f \. @@ -2676,15 +2932,15 @@ COPY public.authentik_core_token (token_uuid, expires, expiring, description, us -- Data for Name: authentik_core_user; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_core_user (id, password, last_login, username, first_name, last_name, email, is_active, date_joined, uuid, name, password_change_date, attributes, path) FROM stdin; -2 !n7aLj0jKk3WIaSvfbaqM1nyy6fWejHsM54lRFljX \N AnonymousUser t 2022-10-25 09:01:03.467789+00 fe23113c-e658-40b0-872b-d522b6d84e95 2022-10-25 09:01:03.468042+00 {} users -7 pbkdf2_sha256$390000$s67BmqSPGYWQPDnZ01phY4$ky7qvH0SBsnsqeEM19AB0B4Ki8zIcmfshf7/r7aiodE= \N authentik-viewer authentik-viewer@localhost t 2022-10-25 09:56:58.979268+00 ee7aa163-4303-4c2d-99c7-293ffd9d08dd authentik-viewer 2022-10-25 09:57:13.55852+00 {} users -5 pbkdf2_sha256$390000$GmvsVEytoLZGHoKB8LdomP$Xi06ESYoLZ/ijL0FZDT964KM9C9WoPhQtv6Rfl3b6Nc= 2023-01-02 16:38:22.084529+00 authentik-admin authentik-admin@localhost t 2022-10-25 09:37:30.689793+00 a7d3e4ed-8b75-456f-b3aa-f53f45afd3a4 authentik-admin 2022-10-25 09:37:49.480346+00 {} users -4 !yrI1uXKCdF0svOmz83ldwXJnWMOUY8oY52dzYNT1 \N ak-outpost-efe635b92ce74de4977f9f25b9f36d97 t 2022-10-25 09:13:37.843347+00 a7dc29a9-11b7-4d35-aec1-260ae1ea62f5 Outpost ldap-outpost Service-Account 2022-10-25 09:13:37.843557+00 {"goauthentik.io/user/override-ips": true, "goauthentik.io/user/service-account": true} goauthentik.io/outposts -6 pbkdf2_sha256$390000$k0Ykknoutfj56D2AaJFxWJ$bmAmMoNISoYnUn+DQnepRd39Mp1pnY74yfAoiHwRYRc= 2023-03-13 12:40:53.895342+00 ldapservice ldapservice@localhost t 2022-10-25 09:49:18.218809+00 94fafa12-9abd-4567-9627-3c6998f38cca ldapservice 2022-10-25 09:49:50.76013+00 {} users -1 pbkdf2_sha256$390000$c4McOkDtg2Hps9uyXGdezW$8g7sh3praXa6Jo1AHfrng1dsf8dktric2hQYlUyAGyY= 2023-03-13 09:19:36.730646+00 akadmin admin@localhost t 2022-10-25 09:00:53.707692+00 d719cb26-008d-4bff-8f5c-13d1bf53de25 authentik Default Admin 2022-10-25 09:09:42.841946+00 {} users -3 !8j4wiqLzHmjS9fUEU0e0DsXx4z8Cj8W6lXnOCbo3 \N ak-outpost-0c24aadcf97e4720a70f72a190b0cafc t 2022-10-25 09:01:07.984838+00 b9835bf9-2711-41ef-86d2-b07124b9fef9 Outpost authentik Embedded Outpost Service-Account 2022-10-25 09:01:07.98507+00 {"goauthentik.io/user/override-ips": true, "goauthentik.io/user/service-account": true} goauthentik.io/outposts -8 pbkdf2_sha256$390000$g9w4rydmb2SLrzWYyInNZZ$W5G+Y2iayJp1KIp9wHuiozJ79Mdibo3zFx+Zxvofm5s= 2023-03-13 12:36:31.74172+00 authentik-editor authentik-editor@localhost t 2022-10-25 09:57:43.64112+00 3678963f-023e-48b3-97a1-0910b4fbbfea authentik-editor 2022-10-25 09:57:54.40015+00 {} users +COPY public.authentik_core_user (id, password, last_login, username, first_name, last_name, email, is_active, date_joined, uuid, name, password_change_date, attributes, path, type) FROM stdin; +2 !n7aLj0jKk3WIaSvfbaqM1nyy6fWejHsM54lRFljX \N AnonymousUser t 2022-10-25 09:01:03.467789+00 fe23113c-e658-40b0-872b-d522b6d84e95 2022-10-25 09:01:03.468042+00 {} users internal +3 !8j4wiqLzHmjS9fUEU0e0DsXx4z8Cj8W6lXnOCbo3 \N ak-outpost-0c24aadcf97e4720a70f72a190b0cafc t 2022-10-25 09:01:07.984838+00 b9835bf9-2711-41ef-86d2-b07124b9fef9 Outpost authentik Embedded Outpost Service-Account 2022-10-25 09:01:07.98507+00 {"goauthentik.io/user/override-ips": true, "goauthentik.io/user/service-account": true} goauthentik.io/outposts internal_service_account +4 !yrI1uXKCdF0svOmz83ldwXJnWMOUY8oY52dzYNT1 \N ak-outpost-efe635b92ce74de4977f9f25b9f36d97 t 2022-10-25 09:13:37.843347+00 a7dc29a9-11b7-4d35-aec1-260ae1ea62f5 Outpost ldap-outpost Service-Account 2022-10-25 09:13:37.843557+00 {"goauthentik.io/user/override-ips": true, "goauthentik.io/user/service-account": true} goauthentik.io/outposts internal_service_account +1 pbkdf2_sha256$600000$Z9YK1xODbvcOk3u3p0KonJ$N/S+WQZ1f9iXB734Xcc30AmE5hmV7e8EtT9cihvtc0g= 2024-01-11 22:06:44.87578+00 akadmin admin@localhost t 2022-10-25 09:00:53.707692+00 d719cb26-008d-4bff-8f5c-13d1bf53de25 authentik Default Admin 2022-10-25 09:09:42.841946+00 {} users internal +7 pbkdf2_sha256$600000$KzvAt9vwjfcZZ2j2vUxBeo$z3TLTqCB6IGqNiuqMhOp89GqCOA8NztlPc2CuSBlz2E= 2024-01-11 22:10:28.187334+00 authentik-viewer authentik-viewer@localhost t 2022-10-25 09:56:58.979268+00 ee7aa163-4303-4c2d-99c7-293ffd9d08dd authentik-viewer 2022-10-25 09:57:13.55852+00 {} users internal +8 pbkdf2_sha256$600000$mvb1xpRzqjDKlTV77sIulv$7dItv/PVoaamglojWjnh/AJrm9kzs/ZFPT6hPoSqk6Y= 2024-01-11 22:10:54.084985+00 authentik-editor authentik-editor@localhost t 2022-10-25 09:57:43.64112+00 3678963f-023e-48b3-97a1-0910b4fbbfea authentik-editor 2022-10-25 09:57:54.40015+00 {} users internal +6 pbkdf2_sha256$600000$5yumaes8rmMgoYHBoZJccx$ZwJQzQihlRZFZU8XwPj1WbJW0ZZhdHNo4SnoSEx+xV4= 2024-01-11 22:11:15.849616+00 ldapservice ldapservice@localhost t 2022-10-25 09:49:18.218809+00 94fafa12-9abd-4567-9627-3c6998f38cca ldapservice 2022-10-25 09:49:50.76013+00 {} users internal +5 pbkdf2_sha256$600000$SAxnow0EApm0aOYiQX5ZsB$iAxPX8OJVoKC+Cwf3mZyK9lOf1Q+i1BVJR+ECWd7jik= 2024-01-11 22:11:16.862134+00 authentik-admin authentik-admin@localhost t 2022-10-25 09:37:30.689793+00 a7d3e4ed-8b75-456f-b3aa-f53f45afd3a4 authentik-admin 2022-10-25 09:37:49.480346+00 {} users internal \. @@ -2716,10 +2972,10 @@ COPY public.authentik_core_user_groups (id, user_id, group_id) FROM stdin; -- COPY public.authentik_core_user_user_permissions (id, user_id, permission_id) FROM stdin; -31 4 21 -32 4 321 -33 4 348 -34 3 21 +38 4 21 +39 4 321 +40 4 348 +41 3 21 \. @@ -2736,10 +2992,24 @@ COPY public.authentik_core_usersourceconnection (id, created, last_updated, sour -- COPY public.authentik_crypto_certificatekeypair (created, last_updated, kp_uuid, name, certificate_data, key_data, managed) FROM stdin; -2022-10-25 09:01:06.131741+00 2022-10-25 09:01:06.131757+00 44404c15-dd39-48d7-8962-fcb807cd5e58 authentik Internal JWT Certificate -----BEGIN CERTIFICATE-----\nMIIFDDCCAvSgAwIBAgIRALJo/63v/kEBhLmb280DNEgwDQYJKoZIhvcNAQELBQAw\nHjEcMBoGA1UEAwwTYXV0aGVudGlrIDIwMjIuMTAuMDAeFw0yMjEwMjQwOTAxMDZa\nFw0yMzEwMjAwOTAxMDZaMEMxFzAVBgNVBAMMDmdvYXV0aGVudGlrLmlvMRIwEAYD\nVQQKDAlhdXRoZW50aWsxFDASBgNVBAsMC1NlbGYtc2lnbmVkMIICIjANBgkqhkiG\n9w0BAQEFAAOCAg8AMIICCgKCAgEAr4h9pDBcYkN+9+USHXFUj/j9UNQop+FLBSMz\nZ7sXLViRDxzIeWJ7ejB5Qshp+4pqOQiCLrOdxYuhupkl5w8O54DigKRCFLSzXtc8\nma3mDjUssrTwd8KLKzifpjUn5A/UGq4+AL7thJekgSqwo5nkQgnW8usXWnxViFxs\nMEqIFn1+XdD9s02lhuTkdq9k6c46duUFu/3YZmEOYmJguS3cV+DkQdqIr5DUT6+l\nx8iQNx2TsOxecpyBI17Jhh03TCiaL9btcj5hQTShIA/ssMh7xsTpqloLL0WG9hVG\n4txCIelIwEtSj7EGDTYDm6w2LUgxaEaPPPYWgnEEV5VEuAcWDVTpFyhy6xAlRrMR\nmN7HocHtXjcrAftv9rDRwi0dfpAr3SQgPy9q3vYimvl6srNNyuBEOYPXdIwAiDmQ\nE7NIouRi8jpFavd8L04WWaD05TghJ4WGPIR6MSEPBFQwCKmYdWUe/TSf/OlR57h/\n4KXuci9XB6xv69MSY6Ne5wprxouEz9FQTjjTipdkOw9iOQ9G+9FAW12BmpefOR+W\nazVdXwgmMjb0o7KckKOGMBJ4KqsXajn2sw77HKazHdtIMMCdNY3/Mykmi66iqkOF\nzG+HdJBMS6YMoQ2wKmRFOcZx0VH1M4muG903CryJND9Lt4befnkN7iKH4cY3PFI5\nOXRs/asCAwEAAaMgMB4wHAYDVR0RAQH/BBIwEIIOZ29hdXRoZW50aWsuaW8wDQYJ\nKoZIhvcNAQELBQADggIBAAllqD94m0olUDvOMenW80QVS/Im4S/Vk6E06SlsDcf6\nzaZ34SVBUftcYw1KhlDIifT2JwVLBEIsnW/nInmeFsLqGGlTuYTm+CDBT/+P4lsM\nKgy7cK0OrURcG1XxQlzYZqrHQinGVR6KaT7DQlonV8a3VUV40fEnn5urfbV/wOlW\nk6mmVJ2WvwUJ81dyGOxRLX9dwoMBzhuCdnpOk7rxfFZbpg/Rn8w6rng76U98R6ta\nbrS34vpqX1bARHVssi+2syXCRgBSvHFi6Y3Ix9XeUIhLCl/0R9eRxtEn0ROIcFyA\n0HtGtXZkHAJe9+Qyy6riImmFkLNIy7AwPkE7LgES8hYChjTPAzRg2uwlqW06esxW\nOWqnZCD387Dd5ji2vRLjvuTBDzkfK2mx8nYxJmFhGBe1gV6IscNoFcyxo2S2YS05\nWzjn9zADlE7e7+BhTtA1aOc6M2NqgtACGoKUFs76spYU4RNyGwsL2UDozvYeCr8r\nbbFBwclouhekWA/B6/4+uRqA3eUxYS3tmwHKiNM/xJUt0C2AZRxbW1FyWK/ZKtrI\n1Uv73BTLyAI8YmKbZn5IITJmWb2PAbGSb90FRXhk2qZan0x/uqypx7dm0/IKKWql\nu1Z9UP6KGNUBFzVmCc8ZoRfd6qZjHCS67LlTNVMTKf5eJ7wcNVKJ3WhRuLm/99Lc\n-----END CERTIFICATE-----\n -----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----\n goauthentik.io/crypto/jwt-managed -2022-10-25 09:01:07.326561+00 2022-10-25 09:01:07.326585+00 6304c995-9003-4fc4-9ba2-3d32016fb04e authentik Self-signed Certificate -----BEGIN CERTIFICATE-----\nMIIFVDCCAzygAwIBAgIRAIquKwHdJ0cuqRSnZy/BhO0wDQYJKoZIhvcNAQELBQAw\nHjEcMBoGA1UEAwwTYXV0aGVudGlrIDIwMjIuMTAuMDAeFw0yMjEwMjQwOTAxMDda\nFw0yMzEwMjUwOTAxMDdaMFYxKjAoBgNVBAMMIWF1dGhlbnRpayBTZWxmLXNpZ25l\nZCBDZXJ0aWZpY2F0ZTESMBAGA1UECgwJYXV0aGVudGlrMRQwEgYDVQQLDAtTZWxm\nLXNpZ25lZDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAIvVUHe4feDV\n8kMLGbF25EuCnugVQpUhuwjcXrbQ9IiKvNCdw51Iagm3dpFftw+mkJ63WPTjuk+X\nyqSHvseBkStOaqsG1puq+TJ5apVix2/qVWAyQ63ERZs6kv3AJu+9hkHDNF8Vv2b5\ndXS5vvSiURZPIHh1mgZo+GepggdC2ob2wzs521b1o+2DfyNQeq5dnVM7JDV5qyRj\nXqcHd5GiPC4pUJbIw4srLAjW9oL0N7LdxcgtB+MLDxSc2KxZ64i4It3umpRx/zId\nzu3hJWuNKKr51SX3n7zUTJ0AM1MHSv6q/mbYOaiclZVzSOmhqVvEYiXFMeNCQJX0\nZuX9ZCeoS+j0NT3icmtK2enIC7S4a78nIEIwI91W5Co6oCzQHmNwffrKpYOAG4bE\n4BiY1afs2g6uam7XKYz+yixZ/fgSiGtCj7V1eeAiNy+3+hJJlvS5IS1FVTDK4BUL\nZ2h/6ZgkecjCNX0TxIsqo0F+M+7n00aRtLIDdc5w9VvAW9C5jMc0vBwWdrYHG3ZQ\nZAHvE5fRdIEVpEk8SKm9K0COuo0KX52VSRdYWTh3StDeaN9aHaF9tfoB/1rr+PEZ\n9v2BhR0bOUANaMVGUg4dZxonRGL3lYzDbsYEN+0/l2U15xZGfk6gpllFUpQi/fyc\ng/I2XI30YFBuoBhresBYTvErwqM45CQVAgMBAAGjVTBTMFEGA1UdEQEB/wRHMEWC\nQ2xabzJqU1V3WE9vcHJodUxDUjhGNXFYOXA3SVlNWmppRXpNTVh6TUwuc2VsZi1z\naWduZWQuZ29hdXRoZW50aWsuaW8wDQYJKoZIhvcNAQELBQADggIBAGNLFsJVrIDV\n170FzQ+vTu6Rcstcgefn+h7kSkALpsZeEjtZwM23Cew12iEbjUl6KUK4WXrgScE4\n7SPHOi0oInehNAVQGpiuE0J2tB8yDWkt56sPmDNYCoBR7HKLnEFHr8FRGMF/D3wD\ni0JmBH3kizpeUnEqSeEYDQpjETyla7xpYbPrUCJSgDbvB/lgYPuT682ZnfNfPGNo\nM5XU8QmkxzX9A2BtaBx04rP1rNtxS8iYNmVow0VNYhzrSMS85wvjg8cZPZkOQYC7\nbsiBIIFMRjgbWEd2GFMcJpM57pd6/uFvfIL+2V5eaH2Rbv8S1g1klrP8iN6kEV81\nr4QqKTG+lLZcxtnylHLO8EdquiSXGBp4hHxA1wC6r7DY82nVnL/yQ8VA8vh/wcH9\nhOkkjIvzwBkgV+SHgwE3c3w12sn+gRxOmWjEwbmr9mTNloADyMICftOiVU5VFF+9\nAyARrJg1n+t+IZdm6zGrE2/y3147YZtzxyoySp+RcqIzQM2BKEjk1GSSvdbBbNHZ\nu2fVFpl3/A+lZIgCYaSLwyw7dhfXnrTdHGofz2506tA7xIsjYK6etFepzPJgQmrP\n8v0MUb6tEtz201JISWAJumVDHqHxKH41RzZ0CCKmwHDtpNYCla22cGuvwPojWVJ9\nNYcW4HEgx5ppagtgwCDAVorV9HeJhfa+\n-----END CERTIFICATE-----\n -----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----\n \N -2022-10-25 09:01:08.328413+00 2022-10-25 09:01:08.328425+00 f0a78f88-adc2-47e6-82c1-d49e9e159329 authentik Self-signed Certificate -----BEGIN CERTIFICATE-----\nMIIFUzCCAzugAwIBAgIQanwCoettS3O8glhNFB65gTANBgkqhkiG9w0BAQsFADAe\nMRwwGgYDVQQDDBNhdXRoZW50aWsgMjAyMi4xMC4wMB4XDTIyMTAyNDA5MDEwOFoX\nDTIzMTAyNTA5MDEwOFowVjEqMCgGA1UEAwwhYXV0aGVudGlrIFNlbGYtc2lnbmVk\nIENlcnRpZmljYXRlMRIwEAYDVQQKDAlhdXRoZW50aWsxFDASBgNVBAsMC1NlbGYt\nc2lnbmVkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvBnbYXnQZBho\nj9DxExY04f9ELCT48oqdLHitJPxOy46iyfKyePWgNBvGUtqquCOy65ViNqFiXz/S\nat+5GLtXelXipqmOlBp7GfP9yTpeo0ClVfQ8vdccFGstrEaDv5nZkkKwPMfCGqVY\nwhJcxJCPUaXDDsK91YLpuLYNGJnmCHJUlHof7KSYjhn4Ie0/UtKjw5aqIoffMNup\nxs2tsdhUKz9nnKJzGm6lJeo18bOenhNmj1sYoF+xkdFENP768fAb8I39sIitlpL0\nAmPUW7PQpEQ835EwRHVoaUoQpjphuNsRGPcXOpIxkMgI2jJXDz/BGl70UZta/R8R\nGT9Oid8qm2UsOswbDUnHGMDbf1LogchOB0Wi3WgPVG8Z1sdBp5KStqDuYh+cxXwS\nD+xdfRfTzuFwsauTd0R27avWQ5e9qVjA7gQNohkVLQofy71ajrB2s/U+CoPnNIk5\nG50Ijd0K/WuE4en6e2vNxB9vTxZ0D7psc8/9wn+vtlcYn5o/gWBerBF5ptg4qjgp\nQ0f8vmifHuUztrrvI8q/wcdxjXQNfGlGZPRVArGecYEu3pxarRXhb7Bd0bzQu5Rp\nv3/IOFVL50Y1Y78TgCB/tSi5GKwkVT5WruntBPgE7IDCr3R9KqtMrE7ngon5wH2m\nhjKpkCRSa1Z4+spfw00UUENNwVcWFoUCAwEAAaNVMFMwUQYDVR0RAQH/BEcwRYJD\nOVJiZmRCRUU3c093QWp0bzN1RW9kbmNJZzFVQXBiREtlM3BJSkF0Zi5zZWxmLXNp\nZ25lZC5nb2F1dGhlbnRpay5pbzANBgkqhkiG9w0BAQsFAAOCAgEAkLqIXBRW7ffh\nrdwPkRaw/yuUN36gOnxs77j496mZMTHr2U+d/9k3gtvrZk8wB8tuL3kzsnRQp3PE\nRgJDJzlIOwFBwTkHb7Oi5Gb7ngVZKogufhgQvfCIJH2V3tgjF9Jj6/CaBfy9efy2\n4fQieSer0XB53fWL46eg5ZsHJhy4uo4Rwf3hggNtuHk3AO4SSg9iDHSthPaeMoXs\nyt5TnA2YkxHKQJRW5KjgdtqK+7D/nOzEr01chKWu94xKNSWAEh7FNRHuZhX9ppkc\nUiMtiRMY0AZXrWbLMjrbnNr7lNcrw6oze05ZNkws9NI04pytEhjbuREYvSHOUFub\n2SJg/jWPAjoh8LSAT5A97yEMtZf9x8prkbOq2fVyaNYBHhBciPFxqGMPdmDcLcvt\n5AxiTzHmgWL6B9eKdfT5wFbgbacdwtc4fNvb4g0nBipU6pKf2Rnyp2E99P7J7wx/\n/WVIPhEjovCj+2zakAxJna2Jn4AUDJBlO7xQrrPM9DCT3DWZzSX7oIr7u2mNwysw\nr0D5XhzWbzuhaNmS0gG6YUYk5xBTxjyaU0BJmcu1s9A5+a8DDGk7txJKPgAEdOtp\nQCDMNEk4usqLIEn21b9HaY0hlyGD+ae5JumwoIxoUHQtDIhJ55AM4GCn/wIitf4X\nLwH3MAd1SC3l7OGnME9gradc9SZYDmg=\n-----END CERTIFICATE-----\n -----BEGIN RSA PRIVATE KEY-----\nMIIJKAIBAAKCAgEAvBnbYXnQZBhoj9DxExY04f9ELCT48oqdLHitJPxOy46iyfKy\nePWgNBvGUtqquCOy65ViNqFiXz/Sat+5GLtXelXipqmOlBp7GfP9yTpeo0ClVfQ8\nvdccFGstrEaDv5nZkkKwPMfCGqVYwhJcxJCPUaXDDsK91YLpuLYNGJnmCHJUlHof\n7KSYjhn4Ie0/UtKjw5aqIoffMNupxs2tsdhUKz9nnKJzGm6lJeo18bOenhNmj1sY\noF+xkdFENP768fAb8I39sIitlpL0AmPUW7PQpEQ835EwRHVoaUoQpjphuNsRGPcX\nOpIxkMgI2jJXDz/BGl70UZta/R8RGT9Oid8qm2UsOswbDUnHGMDbf1LogchOB0Wi\n3WgPVG8Z1sdBp5KStqDuYh+cxXwSD+xdfRfTzuFwsauTd0R27avWQ5e9qVjA7gQN\nohkVLQofy71ajrB2s/U+CoPnNIk5G50Ijd0K/WuE4en6e2vNxB9vTxZ0D7psc8/9\nwn+vtlcYn5o/gWBerBF5ptg4qjgpQ0f8vmifHuUztrrvI8q/wcdxjXQNfGlGZPRV\nArGecYEu3pxarRXhb7Bd0bzQu5Rpv3/IOFVL50Y1Y78TgCB/tSi5GKwkVT5Wrunt\nBPgE7IDCr3R9KqtMrE7ngon5wH2mhjKpkCRSa1Z4+spfw00UUENNwVcWFoUCAwEA\nAQKCAgAOCywq6epaHsxnuGTXVPgby3Auj5Aao6i6ckQTF9dWMU8MHteeWlBcs5oq\nA3NtjhTErAGKLKBolh5CpnNuWkZctt7WLKCdhCCevm27QWVhcOknMrX6Qn4xNBNT\n4mvhuT3aQqpfz1Y5oRCwZKiScGyS3vpiNr3vm/eRN95gcNUQ2mBgOYH6rAtLyLqS\n5eCtcVaSusP3Egfi4PObCS4nsgwOsows0KAhYBNqckMWyZ9VFAO7PkTnbF2YknQy\nNLtNpuyfGCrRrFKxhEU9jHCFbDZunT2iP3fIFK/39HSrxH4ZTBnJsGEGmXWj5ptq\nlwmS9OKY8m/xS9UOQuyG4wgdGRFYKdf/NdjQSVs19GkkWqrE1TnUBqsxnWB/7aNP\nbohXDyunDdxBgEOCnVmdXq8DiqEDRC7sVfqd4TpRTikj6W6IFdLEwlEUfOkmhI5n\ncggt/HkdtR4INNPz/tbJFKjxceH7mAJiuCyaBWz/3kD4jZeSzOnpLC71kbDWTf7v\nBEL8vjeT8EJVcsOgbNHwaFkfFmrSMxBp8IYqqaCOC8c15GoQeZ5e5fgbXvi81lzf\n9qaRH740eyF5FLOK0cFYC6FTm5NE78rMsrEaxd/Ooqid1HMNelGJmvGIpfIeuQZ+\n2qHcQIX649p8QbxzyuWXWxnjKwFHWtSoC0ibNyGO3o9bC8cEwQKCAQEAxv6APp1F\n1Ah0zcSTpv4nLRCGAcb8OnV5rw8isixVt2SBeHuxE1mTSjP6SJNiNu0iKSzN7Qmk\nyRcNp3jQG3wpUwIGVXZ4ct4Gdf42sR4XyoZ619eYGM+dR1P7GezU1vZBFxqhofti\nVgJjouAvvYjl06wPz3a0az6UuLdtbpTBxF7sebIW4Xq2ZlxufnfAo0haq9KmHOqR\nut0KO7AuAxswCWGvu/PzxdFvB1/1/UXhiGtLzuwhXePbomlHQLki6DbFAreL8TlJ\n0RxexQHiCERXUNlWyMzYQvyyB+c0IYP7cLFuspAQezsEOWgr3TtTOhWA4ln//x75\nlHAKSgFW76TQMQKCAQEA8fx+PH/QCA6EKLZMa6kVzm01D8oTetGce4r80Jva2sbq\nu437yRVe1Kya1Hqeo1gb8Ord4714FOwduqz5IdMGuhljNEaMYH4FecQjpfiNd1RN\nFreGhDJ+DNq4Nz5aVZWgVtKkmdwjZALfZpfetAAfkaR/75GwV2PKQOygjgcXpxLx\nCyFOTC0RxnrjAMsuc8cDHkw90x3iLJkEBRI5Ud7yI8lD7jCKSWUc5FiYznR/4dX6\nX6Q3+WSAp9s6grBSBHMgHYV0vMETDDtJSQT8uAYwZY/5WnXyqNqSGZJlwwEyCLvQ\nbacfYYeq4NDwdHGFHyl8hjkX/c/ojR+knSbe6bcKlQKCAQARrww2hENwVFAM+Ssl\n6/APUSiXf9dqWoY1yGKM8uOrKINY8aUK0ysDrRrQ2EgiGXCvuAuUxQaf2CyedV4J\nEz+Y4NUvQxfxHUn1smGp28LBfLHt/HrHuauAazYlV6aSc8/U/cjfXDeg7RVto/6a\nGVBTPzcoeKQP47/TqnlPGmkdylG5ftoJr54F+rDXDE1SNVvZaR7Z8v6AXKRMKZvt\nKr1aGBRF36gKLFJxlVWG7G7ecKqL+O/+KAGcrT5nwgwYFsXrN+R3y1+D9cF2QHSV\nx3z68lWEArA1Q7+OdD1tvQzbNulfdO4CBXyanWdIgb0jrEWH1/en9Fi5mDtR2Eyh\nf0QBAoIBAQDd6iTGTJw110Ihp9R7YuupjAL+QN4OYw3zSinBETzqJ0N6zqGZ7awy\nszumIfE7cQDmtizYvpfR8BrvZ9Nszn67ya5tcUko8EkLOvFKMp9hUIEFlTaaI0fv\nd+E6YEctd3M8TB5BL12RSQUgq6PDRN2ujcH5rIygB9aiJZ6zwRNS148QGvIX77j+\nYRGuV6Z47IgSj+6enigdqBHEqMrCJwe+A8P1OrSGzGBrlEhGBRaFv9rFPO9MGXvW\n87g7w4DjrcRE7m947kMOk4wIl7c++AYIDbmp2MK5UQBszRHffEgrNDnXZUzk/yqY\n02PAFZ70TfJxkQhMbg8g5GTs7Ym9oWvlAoIBAEi7Pu9KKEueC7p7ZfDBoHA5WXfV\nwAdRmJrowf5fAR2w7y56Gzglv5jaUzsdVlsUnittJ4Kh+Gy3S0lB/962N/yb/mle\nl/GSo337OI2Naq1mC/ZhT1ESHc+9bktDL979oSdj6QxOE5bk0XB/9gzq3XDFCVpy\n524xRfTUn2QTZUOeEZz8KdZRB+mjlm4n5xhAl94nwF6YXZH7FgDcP0FTfuOKoASM\n/vHQPFaL71fBnNwudmCBmF2yHjQ3qQ8w9NIZRoydqmmfXQg1rXaJ4Pfsiaz66S2M\nzZ/qxMa75ZM5U1s3qmuNZgf2KRKzy4Tn0+iBDK1mwBe5oDIUWxnEyyaIa5k=\n-----END RSA PRIVATE KEY-----\n \N -2022-10-25 09:01:08.654156+00 2022-10-25 09:01:08.654169+00 fd8c40c1-e491-48d1-a265-02ef85ea9a12 authentik Self-signed Certificate -----BEGIN CERTIFICATE-----\nMIIFVDCCAzygAwIBAgIRAIwEhD3SuEWNm7TZwrVbRmAwDQYJKoZIhvcNAQELBQAw\nHjEcMBoGA1UEAwwTYXV0aGVudGlrIDIwMjIuMTAuMDAeFw0yMjEwMjQwOTAxMDha\nFw0yMzEwMjUwOTAxMDhaMFYxKjAoBgNVBAMMIWF1dGhlbnRpayBTZWxmLXNpZ25l\nZCBDZXJ0aWZpY2F0ZTESMBAGA1UECgwJYXV0aGVudGlrMRQwEgYDVQQLDAtTZWxm\nLXNpZ25lZDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKv4Thu+i4GD\ngNN6Id2fFg879TGr6WQZZW2DCsn4JgEiShtG1E4jXfzR0MaANrJU1Nn1RDd8Vp5O\n0RVso3+A21t44HVGJDiuPRYDrBaGxoKkvwb5TIq0rw8ID2XBqs+PzTXsd+fp1T94\nhRiSWFgsMuQgGXt4xH94VgHadv7oi4btZ40BuvF9oevw9S5ogbMfRnQuQe7inWlg\n6QkeyVnSAgZqT+IzuxL8y/XXIGzetQGsD/fvHz+BgWNFHwrEQd7gpOmrZSW7/SHU\nNBmP0PDONg2NQz8luFAAR1goebeZf+CqaNUWnmC/zmnNd+1wRIcuyMtmaVlBOrw1\nKgoLv6mg5rnIQB5W2BD2wBvh10+ed49Q4sKOndIej7I8lqkLm55g9VJxL7NXKk+v\n9H6bkvj4xWeXkyAONJKkzc/ggSgwOF/VVKXFR5qKuulAKYmZ+8OloB3dETKPcP7E\n+bcSX/8Z+1F3goFiUzgZms7Tlgzkp9Sy408SMssqexqqf3x4BV+eETDMKIfUosZ1\nt93hMa7nhP7VyfCf2SDV5+HP7BULbZhoepojwdtnH62zB/tp+3L/PAIfznJmJTaj\nOyLbk4fCRr5fR5F/FOO0rlnE73PIy8vEMDHvgoRbvuJM2CrCquYOEqBCX5NSsKJa\nzjp9n+LbuCJV8RSit4aX5KVNMl9XeS+DAgMBAAGjVTBTMFEGA1UdEQEB/wRHMEWC\nQ2JNMDJoVjZaWGI3ODR6bUU3ZlRoYzZRTk9nWG1ZWVY1VWpITjgwNTAuc2VsZi1z\naWduZWQuZ29hdXRoZW50aWsuaW8wDQYJKoZIhvcNAQELBQADggIBAKiDpxrkM8Nr\ngYfRPOvzOfviYdEVrcu8LJawEESKqBphFZjj30AOFSH79GdBz3ckeMdmyEAmfDh4\nijSueXJ65sgzYFjLRaG3YdjoTWMHH8tK3RK9NeOCQSrDaaIXRPdb5QGjUOmR3O6K\n+Z5vCy2mGnpeUnTit/s90NqylUn+CbVDxRqfdFushl6FPIfCGMXkLH0nTBhEDTyG\nrmgh9I6NtXI5HBAy+ZKdahcy4NE6rVqlzdvyc1jUjlP4Bwl670nzg9HFUDIGIbBf\n7OkBOt8AgZnziLP0vxmTcIa8KCtdbobdx5tIXoKsHkHsP2LrR24TbpRrfj40Ikzl\ni4ooqkQQ25SUGYjeKgX+ZQv7rWWs/3JyV3YGWSBTJI879v2+58WYXArXoGIzT1yo\nLAuPXzHMCYdHTCk7SzOtWa7g9HnqKx43jSWPc8sQg4HzubxoMzmBEthH/PXAgN8C\nGKgsATd9rpiml1FIeoJpJtCfE3F6oTdyAB73upgvQKUhTGShzwV8vCD9WERX1aXc\nu+0W4o8Zt7Oqtdj2rnqk8PQlNyn0ZsfsJYtiFPgntHLhIOh0HCY2irkQyUgFr4RU\nfjrf3PPnXzMfoa/bRuyNprMPl+R8WKU+6o0paZ3oSTkHzn/IUdNy+06JSdZDMjRM\nBdiRGnL05ZtSyoaf4dFZno2Qd0XAjYQJ\n-----END CERTIFICATE-----\n -----BEGIN RSA PRIVATE KEY-----\nMIIJJwIBAAKCAgEAq/hOG76LgYOA03oh3Z8WDzv1MavpZBllbYMKyfgmASJKG0bU\nTiNd/NHQxoA2slTU2fVEN3xWnk7RFWyjf4DbW3jgdUYkOK49FgOsFobGgqS/BvlM\nirSvDwgPZcGqz4/NNex35+nVP3iFGJJYWCwy5CAZe3jEf3hWAdp2/uiLhu1njQG6\n8X2h6/D1LmiBsx9GdC5B7uKdaWDpCR7JWdICBmpP4jO7EvzL9dcgbN61AawP9+8f\nP4GBY0UfCsRB3uCk6atlJbv9IdQ0GY/Q8M42DY1DPyW4UABHWCh5t5l/4Kpo1Rae\nYL/Oac137XBEhy7Iy2ZpWUE6vDUqCgu/qaDmuchAHlbYEPbAG+HXT553j1Diwo6d\n0h6PsjyWqQubnmD1UnEvs1cqT6/0fpuS+PjFZ5eTIA40kqTNz+CBKDA4X9VUpcVH\nmoq66UApiZn7w6WgHd0RMo9w/sT5txJf/xn7UXeCgWJTOBmaztOWDOSn1LLjTxIy\nyyp7Gqp/fHgFX54RMMwoh9SixnW33eExrueE/tXJ8J/ZINXn4c/sFQttmGh6miPB\n22cfrbMH+2n7cv88Ah/OcmYlNqM7ItuTh8JGvl9HkX8U47SuWcTvc8jLy8QwMe+C\nhFu+4kzYKsKq5g4SoEJfk1KwolrOOn2f4tu4IlXxFKK3hpfkpU0yX1d5L4MCAwEA\nAQKCAgAkvyZRn2HIBwWcKtjZtojMMI+wUX/Jt/OIdxvzFGdqjp0vPu9W0w/eWic2\ng2csrBvfhx1Qje0kXssmvNQjBrHY1feAl8BdrD25WoeHOL1qZTG8l8DXUsyldZ90\nqvGI8L107Fai48CBk4s9OOhPzwIA5SsMyz2Rz3DxbHFI/v/xkQEzjE8aEzJqbE63\n3/T3BZPUd533Ic1pGyAwprd3zfxIyqbPOyaaZBJhMdrn6J7dAJtY62vN8ipnv/lm\nH5HDwlT66Xjvmz/33pRTcfu/uRCrzKe18qVz0ttr5xo7NT2yEDfecLrfCZ1d1l3u\nCrNZ/5FAhV21iM64MNQO2XvXD169sjEtgnH4nmeLjaDOgPq7Heu/+ZaVW23O800D\nd56z9V5B0T62cqdi1GfHfjHt2mAnNaIuJsay5hPeC/B8fL+4V/0Y/PNoszm4tMiK\nwkhvtJusyCKQ6LRu9dsKRCAVimX4HqRLPweotR22sSqbc/HcJ4j3ge7g49Cet+Hp\n+huCeQ49p0aRJdSbgiGTOqnRCYxK9kljaSkrJ36e9+5fzTH9BNsWPXwZfmq53NCH\nFoTdxBe7TojCnUiQ23P4SX6+IgVCWmbRx5MIi+w84IimQarGNbCtAqbkKZI2/ClA\n7o+04Sr9pDZb6wVrLtR/XYVLXirXA2iJvWucqLslP12irFsSoQKCAQEAwktGBVMc\nKmIqCf8woojW2vIOgaHUdGH0SzwaxcU90XWxI7fcxsSqVPs1N0jgQVqP27EiEcti\n9Y1abNHXqYiMbl1wE3yXUHr3wkb7XHfD8KSbeysX3BuYPTRHmjRLs8GFUeA6NWUu\npL0KtX4ZRwa8Ri0OOdAXiDMgmyJ+Mq9FG0oCwpW/xu3Ftw6MHfn5o6qZoCN9O4Cl\nKoCHpQ13MMZjaqawuTKw7NDaJA1HTlyRNnq1D2Hj5hkS4CRXIFgjKRrs58jxJlnt\noxHaBBBR/xawYvlZcZGZduOjxXq3vYkEpAKwtgwvIOXMlKKLY1drDKMs3xjNbeJP\ndn6pU3jSXCNQnwKCAQEA4pYC4d8h+xbC4Jy+W3r3etwxy8kwvX+FnlxoBLgcxnIe\nDsr7whYinfqa0EO760waPgO0jdW95IHT2xspYh8+Yo1PsEAM+kwCe73bVRNazD8f\nKM1xb76OVwkSLlkBhXUzF3DQJTLsMYzAHcb+iZHiXK1/BN+K2xG/QsGl8Gog3LR7\n9SsWNrJF3jr0Fsbi5586+JZCf/mjXNaZTFW8uC4TmI1K4JvC9tSibc+1qS+DEt+V\nogI3X+OEk0CNn8GBZPkjS+R7YFH0stS3F7Zs14I3YIDebVgdRDy0poK4tWrwiNlV\n3D9/9KNPFDKXJ6b/mzKyqaw83EHo4jl9bbd3TSCCnQKCAQBJulCu0pPcjXWQZ5Lo\nCm8llDbnACbjpwwRxJQQOkG0CyoV+L5Bev170/ukp/XZNuliH3xjLj+2GFaY9qQU\nxCkt9C0EaAHvW0pLIa5er1/eIEiT241pS9tgVkdZf8C+TJAvupu8CVJC1y2KI3iq\nGCc3pf9A9vMHDwevds5Wo2Xg7hvQBQ1KCo49YbrP4TzW23UCbJUoEDAcybsvacWx\n74ZoQSrMjCzRIIu2pIdLeBruhm/Qj3/wn9Em1wNs1aU6AuqlJf5EZFmlyK9nXV4Q\nI2b5l89WJj8K+2T5GhFBTg5BwneWDVeQ70LLoEaugsyvdfggDIRfz0ICIBxW/YEt\nz4TXAoIBAECWVCIAUZZaCZiu9U/sQEkEJSmaossRY4wdeQUHWiRy+QBsedD8MEfp\n3GFLPXC3068jrT7U03kkTLhjCKvQjh7XewSZbVmXewm8t52Lk9EumPovfYtFvRsw\nGb+eXWGmY7bl0F8aIduJ7GVQiNO8AtM6HT/NXK5PY75qppteFRn0VHUxH/bdYC8V\njOhFLv6pE80yP6htB1T6Ut5Afjfd4nYUqGhX3f4v5FD66SRdx7YEh4mTqicqGmtC\n9lu4c+LNZXNHoKd3FRI4siwPMv1HP/PBKCXw/P6rcy8XigEkh4XqtZ+/dgDM4Ei4\nlv+Viby6edUFpJ+AJhr+GjPBHT+d6b0CggEAceyPbVs3gHOJtGGJcIw2mr14hc1d\n08BDvIUbe0pYscZfrYf0zY2uhsHyETBcC20rSkoeDcmhY6/1XrWpDH/Anhh3Wbo/\n4Yu1/KXOd4NkmWL81ZmE6lY07Sydxk6bcLmlXcJMwkfNFzuFj67Pe2NgWdBXc1Fd\nwLZdciN5LMDRupIf9E8X3TlMGh67Vdf01tKJC1BwmJkiinBuMmlhpqJmrG+CesOK\nU9AzzGDijQ/mO1te9wTgHuAlWNwZaOzU8I/vCHT0NmIdIK0q6BUhIk+zEC0ETwCY\nSCzAby4dGEazC0l8iZKgG6xWQ8BO2B5IgGZPbMGHyowSMAPrHpjnO98/ew==\n-----END RSA PRIVATE KEY-----\n \N +2022-10-25 09:01:06.131741+00 2024-01-10 13:34:38.497975+00 44404c15-dd39-48d7-8962-fcb807cd5e58 authentik Internal JWT Certificate -----BEGIN CERTIFICATE-----\nMIIFHjCCAwagAwIBAgIQF5FQmU7oSciGLNkWVbLSpzANBgkqhkiG9w0BAQsFADAd\nMRswGQYDVQQDDBJhdXRoZW50aWsgMjAyMy44LjEwHhcNMjQwMTA5MTMzNDM4WhcN\nMjUwMTA0MTMzNDM4WjBXMSswKQYDVQQDDCJhdXRoZW50aWsgSW50ZXJuYWwgSldU\nIENlcnRpZmljYXRlMRIwEAYDVQQKDAlhdXRoZW50aWsxFDASBgNVBAsMC1NlbGYt\nc2lnbmVkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAz7YMxtUcRT+A\ni8oOZpnBHF/5NFQ15dexytyfB/ni2B0w7SjN0aoOXAY0/AHd8ykloSz9XLgDrUx+\n8qHYMX4V941DVPrFLe26UYnXNUDaLW4N6uMhJjxFzQQQ3bjMTzizJ8sbT7qAJ5yh\n2cs+IM3vHnivc/pYuM7VgLlbnPfbjYtnvpLNf94xkOaMuOX8HdbQmtlt/ARMe9mS\nhZYDkPcAkFjnxOE+dZkkKE13sZyoeghcrjaB56GZqhQ8e6Jkih6cbJ5vto5UOP8+\n9Hwe2FD//wEvRYYl2d32GREDqqrJX5ChODyL2u99z/6Zfe2UGbIYn0vF7zTYICTq\nAZgUuPzk5X2caUAVwDG1DRzu6xaU9AmNarg+MgdhKdO20F9qjJ/DJ3srSgaSLHyd\ne1XW94Pagt1mCGfPuxpuX/b0HCUhR4hQGmm76FbthkZn8VlucZc+qXuLzWyNWbI1\nNFXIpewSn5vGOyMRifCEZAXkIU0mBLp/ah8OK63Q4YoH+LnM7y1haIScVlY77AGa\nZq0wDnr5bpq5Cruylu90RsgkXqokqHmVJtKW0IQIxT5ilMpGXNnPkuwZxsFkAz2U\nlwBfKPhyrmYIIHvvLQYHohZOu/JSESqQ+KzkiRlzmdXmWciuC/G58Khz+Zoq+xas\nrR/vATcNjfkldbH6hJoV6o6gD1fO9TcCAwEAAaMgMB4wHAYDVR0RAQH/BBIwEIIO\nZ29hdXRoZW50aWsuaW8wDQYJKoZIhvcNAQELBQADggIBAG+xj/e41S2wPMmkSXx6\no88x7FhS+pCMUQqhdlCSSjweIWuIK+iTW+MHOhk9xVqwBKkO6fu1ImiDcZHv88JA\ne/dEMyqv+hfqx8MpAr6pPYfVloEY8UsafTXwGoE5bgr5SIeO2yqZ5IBEys3pC1I4\necGLEgKFJ+nNyCeC2COE7NTDQOSfYnnQQLDhBSehJU7gHJ05FJJCGwjSNp25TKEl\nT/9r7tnfgPkygu38bUHg9wRrR69gLsfgG4gkVrkgg/ac9XfKFOMstsXH/njm2CpI\nk6Kl1XrKxeJX65fFMSzHZ05NyWVRNyFH6EvLuFNehwF9fYiYFBGrLGjOD+LtnIPo\nOD+8Mx415zRAWGQzePrgK2oWuVqGAz/Rdw18vAFKHJdt0zwd1D+RzUKXNJIha40n\nyDvHIUjAGIcE5iOozoYk0VqCDzYaQDib+aYiy8UXmK7ac+AfYhH/+HmpcibQGOXx\n1XpDjkbYXosKfi9E77yOFL7Iuavgu8u4/F1OMF/yG1UxDEo/WS1mriF6e3Ec8snq\nV2taRnKmjj0vI1cRCPeLf8uoD/MoLjjyuTwmDhnIjbt24b6+bul87ZKpQtk3Ecp6\nyB7SWjTuIph3pxpwmHjRopFP7Upz7YLzU0/TViIF++pN2X65yDxiZc0zSoqJ/53I\nNfx5/oC06EbgbJieCxnHeYuB\n-----END CERTIFICATE-----\n -----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----\n goauthentik.io/crypto/jwt-managed +2024-01-11 22:07:32.546353+00 2024-01-11 22:07:32.546366+00 3fcd5d6c-3e40-43bc-b916-c7cd3675d0f1 authentik Self-signed Certificate -----BEGIN CERTIFICATE-----\nMIIE/TCCAuWgAwIBAgIRAK1LRDkgWUYlrpBkN5TBxu8wDQYJKoZIhvcNAQELBQAw\nHjEcMBoGA1UEAwwTYXV0aGVudGlrIDIwMjMuMTAuNjAeFw0yNDAxMTAyMjA3MzJa\nFw0zNDAxMDgyMjA3MzJaMFYxKjAoBgNVBAMMIWF1dGhlbnRpayBTZWxmLXNpZ25l\nZCBDZXJ0aWZpY2F0ZTESMBAGA1UECgwJYXV0aGVudGlrMRQwEgYDVQQLDAtTZWxm\nLXNpZ25lZDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALY9CVd1AZoE\nidS5z5x0UiXhOlvrxDeJ2gZOzpKdfQ/V6piMruOnz247vsqTWTziy3dTejuTiBA4\nvxOolWzuTu6jWs20zmbpynqfszEB9X83ag0ijpkzIgj2nvtgIkXn+ti2v4f4tdmC\n8OlY6G4tlD0NUc95VBo6qFdrsKJHmDfNsNqcsYzsxvi4QXoIXx+WILcvMFAP0wU5\ndKIg3mrTc2EO08KHqh2rXj2W6DzhBCbQUTDRnuJwH0I4wtr8QgUGbjY2l5qig2Fm\nonH0L229NUiQmo7ptp9xvj3L1Kzt8q/aEoUW3hSTRujP0Wx72U3Q86xaD3dnC1tK\nF5PXG/JBb72oRGF978kfgAhERUvkmuyAV4kvFCTWw2oZTsszOZMIbQ10lxJ5+yGg\n0AhYojNnqWiM+D03FCnXpYdMUfTmEHSxFTmYivi7RKE/uA7VjvKL/mXLqR6alQBC\nHg08bhXcZCBvJEn+LKsysAvFuwb+yauV6O8BOKL1f1PMxK0VEhzISQ9p7FB84v7d\nZLH7pCzBSb8Or3URgls0wuNVsg6WedOgdCW3KOr1JcFNEn9daEKF3uoGxJ+58k5L\ngFV1+JOO1rgAFpUlIy8+UXHEkKUIA2HnUStedNfta5Rduw0sVkrIOOxG5GF3/wlu\n7ZKoogrKWVsmBIaDHD1FRTdtkz5BbJrtAgMBAAEwDQYJKoZIhvcNAQELBQADggIB\nAAHdrZStP3QAgiQclZHmW9rgjMIFstWGj3+MbR+MxJPqTJLAjXWYiFsTWE1WrjLv\n3eoE2pszEKPV+ZLSTmSFC3N61F9u7coXXLmGq8Z01ubyzZIiyih/pJOWax+0xYkD\n+gNZ0syjgyBQ9JxaOHol0ydoQSKYEhI+BsD5GVrh1q9FBtmJhB+E3Z+qb4axd+n1\n9u9G77ZMcoAjknXZsULx9jkAQehC5+qO6+7zOdKZ5LfvQGY7K6lsKpsCuC9nC9nl\n33BeKdshrIvJwFQ60U9f8CrbAEMVKaEB6UCWlmHAVk/i3/vYW49L31UKl0d8nyxC\neg+xE9lK8xfZ18P2G5xPOXkj0qqk/byMrTYTeVCzYQstc4Vpkcr6H0ltuYF41Fcg\nHJSV4mtxv3EvkZdpPx2Iqw8td4theHjt+j7Xb3sufgBfjEcX/ZYgPOYlFCs2ScDR\nwBvpXytwd58s7pWfIYhi7mnn4LDqhLgYMOue5KpuqMTBbINGywyoiA+tr5paJOy1\n7+KFqUuymClfKtvsBWEci+a71Toy/5O0OvvqbfBkiwpjSNwvTWLiVjuTCQx9BcBP\n3ZXeKE4s3SQ9WHTi4PLpItYNpkjDDQgy7avEBGSofh9k6RNunLkOmQJDTWofPXbI\nfVB1RPV/Pi69jLSI4y4nNPSXIN6r7R6mAHv4nng+Q6Tj\n-----END CERTIFICATE-----\n -----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----\n \N +\. + + +-- +-- Data for Name: authentik_enterprise_license; Type: TABLE DATA; Schema: public; Owner: authentik +-- + +COPY public.authentik_enterprise_license (license_uuid, key, name, expiry, internal_users, external_users) FROM stdin; +\. + + +-- +-- Data for Name: authentik_enterprise_licenseusage; Type: TABLE DATA; Schema: public; Owner: authentik +-- + +COPY public.authentik_enterprise_licenseusage (expiring, expires, usage_uuid, user_count, external_user_count, within_limits, record_date) FROM stdin; \. @@ -2748,107 +3018,7 @@ COPY public.authentik_crypto_certificatekeypair (created, last_updated, kp_uuid, -- COPY public.authentik_events_event (event_uuid, action, app, context, client_ip, created, "user", expires, expiring, tenant) FROM stdin; -ca71d863-2b13-42fd-a836-134bdb8e13f2 system_exception authentik.root.celery {"message": "Traceback (most recent call last):\\ndjango.db.utils.InterfaceError: connection already closed"} \N 2022-10-25 09:09:13.17925+00 {} 2023-10-25 09:09:13.175376+00 t {"pk": "0b2cdade01e549bc94874f4e23b9feb9", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -fe793d8d-5f6b-40ef-af46-b8cf221432ca password_set authentik.events.signals {} 255.255.255.255 2022-10-25 09:09:42.825971+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:09:42.825304+00 t {"pk": "981ac441d0bb4249bdd540582695859f", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -af542ed4-b917-417a-8ca5-5f206298b936 user_write authentik.events.signals {"email": "admin@localhost", "created": false, "password": "********************", "component": "ak-stage-prompt", "http_request": {"args": {"query": ""}, "path": "/api/v3/flows/executor/initial-setup/", "method": "GET"}, "password_repeat": "********************", "oobe-header-text": "Welcome to authentik! Please set a password for the default admin user, akadmin."} 192.168.32.1 2022-10-25 09:09:42.969783+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:09:42.968372+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -b2f7c0a7-f389-4e88-b336-4490e21f83de login authentik.events.signals {"http_request": {"args": {"query": ""}, "path": "/api/v3/flows/executor/initial-setup/", "method": "GET"}} 192.168.32.1 2022-10-25 09:09:43.014062+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:09:43.013647+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -32a35640-815b-4f6e-a259-5a535259cd66 model_updated authentik.events.signals {"model": {"pk": "0c24aadcf97e4720a70f72a190b0cafc", "app": "authentik_outposts", "name": "authentik Embedded Outpost", "model_name": "outpost"}, "http_request": {"args": {}, "path": "/api/v3/outposts/instances/0c24aadc-f97e-4720-a70f-72a190b0cafc/", "method": "PUT"}} 192.168.32.1 2022-10-25 09:10:11.96456+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:10:11.963869+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -e1118e2c-91c8-48ed-a5ca-d7c6e60a7378 model_created authentik.events.signals {"model": {"pk": 1, "app": "authentik_providers_ldap", "name": "grafana-ldap", "model_name": "ldapprovider"}, "http_request": {"args": {}, "path": "/api/v3/providers/ldap/", "method": "POST"}} 192.168.32.1 2022-10-25 09:12:08.367626+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:12:08.36694+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -ef09da1d-1893-4528-bede-2aadb7f0c77e model_created authentik.events.signals {"model": {"pk": "77d294b58a504d6d8edd5b15e5871826", "app": "authentik_core", "name": "Grafana", "model_name": "application"}, "http_request": {"args": {}, "path": "/api/v3/core/applications/", "method": "POST"}} 192.168.32.1 2022-10-25 09:12:55.329043+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:12:55.328294+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -e60a1d29-8678-4e11-bfcc-c0fd2f2c464f model_created authentik.events.signals {"model": {"pk": 4, "app": "authentik_core", "name": "", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/outposts/instances/", "method": "POST"}} 192.168.32.1 2022-10-25 09:13:37.846856+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:13:37.846108+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -2047c684-54a8-44af-8f95-46112d8543a7 model_updated authentik.events.signals {"model": {"pk": 4, "app": "authentik_core", "name": "Outpost ldap-outpost Service-Account", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/outposts/instances/", "method": "POST"}} 192.168.32.1 2022-10-25 09:13:37.856944+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:13:37.856452+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -2dc275ec-e9a6-4dce-b259-bb685e7e429f model_created authentik.events.signals {"model": {"pk": "44299d02c0864d35b54e2e56bb3346f2", "app": "authentik_core", "name": "ak-outpost-efe635b9-2ce7-4de4-977f-9f25b9f36d97-api", "model_name": "token"}, "http_request": {"args": {}, "path": "/api/v3/outposts/instances/", "method": "POST"}} 192.168.32.1 2022-10-25 09:13:37.88489+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:13:37.88312+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -c7b6fea0-c89d-4125-8a23-859bf55911d9 model_created authentik.events.signals {"model": {"pk": "efe635b92ce74de4977f9f25b9f36d97", "app": "authentik_outposts", "name": "ldap-outpost", "model_name": "outpost"}, "http_request": {"args": {}, "path": "/api/v3/outposts/instances/", "method": "POST"}} 192.168.32.1 2022-10-25 09:13:37.890188+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:13:37.889512+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -f6ff23d7-9b7b-4424-afef-ec5ac2a574d0 secret_view authentik.core.api.tokens {"secret": {"pk": "44299d02c0864d35b54e2e56bb3346f2", "app": "authentik_core", "name": "ak-outpost-efe635b9-2ce7-4de4-977f-9f25b9f36d97-api", "model_name": "token"}, "http_request": {"args": {}, "path": "/api/v3/core/tokens/ak-outpost-efe635b9-2ce7-4de4-977f-9f25b9f36d97-api/view_key/", "method": "GET"}} 192.168.32.1 2022-10-25 09:15:44.834592+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:15:44.834124+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -da39c55c-e585-4d8b-b19e-7e18606b58aa login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "next=%2Fif%2Fadmin%2F%23%2Foutpost%2Foutposts"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 192.168.48.1 2022-10-25 09:17:04.22777+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:17:04.226384+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -3fc6a6d8-eb31-40a7-965d-c2ed92af04a6 model_updated authentik.events.signals {"model": {"pk": 1, "app": "authentik_providers_ldap", "name": "grafana-ldap", "model_name": "ldapprovider"}, "http_request": {"args": {}, "path": "/api/v3/providers/ldap/1/", "method": "PUT"}} 192.168.48.1 2022-10-25 09:29:26.860418+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:29:26.859915+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -ccee3640-def8-4062-a307-710b2033d31c model_updated authentik.events.signals {"model": {"pk": 1, "app": "authentik_providers_ldap", "name": "grafana-ldap", "model_name": "ldapprovider"}, "http_request": {"args": {}, "path": "/api/v3/providers/ldap/1/", "method": "PUT"}} 192.168.48.1 2022-10-25 09:32:43.836453+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:32:43.835938+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -7c898c81-9586-4aae-b239-ae659ed974c1 model_created authentik.events.signals {"model": {"pk": 5, "app": "authentik_core", "name": "authentik-admin", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/core/users/", "method": "POST"}} 192.168.48.1 2022-10-25 09:37:30.692906+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:37:30.692315+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -67a1a1a8-a924-4777-a8f4-d29aef82691c password_set authentik.events.signals {} 255.255.255.255 2022-10-25 09:37:47.793935+00 {"pk": 5, "email": "authentik-admin@localhost", "username": "authentik-admin"} 2023-10-25 09:37:47.793475+00 t {"pk": "124e241c825640d9b416596b2daab8b3", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -b895bd1d-03a2-44f1-8753-5232220d37a0 model_updated authentik.events.signals {"model": {"pk": 5, "app": "authentik_core", "name": "authentik-admin", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/core/users/5/set_password/", "method": "POST"}} 192.168.48.1 2022-10-25 09:37:47.924884+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:37:47.924462+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -54fefcce-6f93-40c6-93d1-007e77298c91 password_set authentik.events.signals {} 255.255.255.255 2022-10-25 09:37:49.464784+00 {"pk": 5, "email": "authentik-admin@localhost", "username": "authentik-admin"} 2023-10-25 09:37:49.464216+00 t {"pk": "124e241c825640d9b416596b2daab8b3", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -5ef43d7a-eac0-46c8-8681-c48dff52c46c model_updated authentik.events.signals {"model": {"pk": 5, "app": "authentik_core", "name": "authentik-admin", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/core/users/5/set_password/", "method": "POST"}} 192.168.48.1 2022-10-25 09:37:49.602412+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:37:49.601954+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -6689eeb4-5bef-4efa-a323-dbdcb7c4c7cd login_failed authentik.events.signals {"stage": {"pk": "02b4a829f9ab45d0adcc11e3c165f1c4", "app": "authentik_stages_password", "name": "default-authentication-password", "model_name": "passwordstage"}, "password": "********************", "username": "akadmin", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "POST"}} 192.168.48.1 2022-10-25 09:38:11.496946+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2023-10-25 09:38:11.49223+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -16ec88aa-d907-4bf6-bfc7-e81632994434 login_failed authentik.events.signals {"stage": {"pk": "02b4a829f9ab45d0adcc11e3c165f1c4", "app": "authentik_stages_password", "name": "default-authentication-password", "model_name": "passwordstage"}, "password": "********************", "username": "akadmin", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "POST"}} 192.168.48.1 2022-10-25 09:39:03.51403+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2023-10-25 09:39:03.509268+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -69829427-ccb3-49de-a311-f32de325b431 model_updated authentik.events.signals {"model": {"pk": 1, "app": "authentik_providers_ldap", "name": "grafana-ldap", "model_name": "ldapprovider"}, "http_request": {"args": {}, "path": "/api/v3/providers/ldap/1/", "method": "PUT"}} 192.168.48.1 2022-10-25 09:42:05.479394+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:42:05.478987+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -5a10fbbc-7ce4-4e1e-b5fc-201edf4fd1c7 login_failed authentik.events.signals {"stage": {"pk": "02b4a829f9ab45d0adcc11e3c165f1c4", "app": "authentik_stages_password", "name": "default-authentication-password", "model_name": "passwordstage"}, "password": "********************", "username": "akadmin", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "POST"}} 192.168.48.1 2022-10-25 09:42:08.483874+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2023-10-25 09:42:08.477967+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -a4594d02-c239-4607-b95b-3043d3d3b565 login_failed authentik.events.signals {"stage": {"pk": "02b4a829f9ab45d0adcc11e3c165f1c4", "app": "authentik_stages_password", "name": "default-authentication-password", "model_name": "passwordstage"}, "password": "********************", "username": "akadmin", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "POST"}} 192.168.48.1 2022-10-25 09:42:17.786095+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2023-10-25 09:42:17.780264+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -ede37431-b8b6-4b88-baaf-78a58802305c login_failed authentik.events.signals {"stage": {"pk": "02b4a829f9ab45d0adcc11e3c165f1c4", "app": "authentik_stages_password", "name": "default-authentication-password", "model_name": "passwordstage"}, "password": "********************", "username": "akadmin", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "POST"}} 192.168.48.1 2022-10-25 09:42:31.137414+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2023-10-25 09:42:31.132685+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -8d8dab83-750e-44f3-b5f9-e33b85e2f8b2 model_updated authentik.events.signals {"model": {"pk": 1, "app": "authentik_providers_ldap", "name": "grafana-ldap", "model_name": "ldapprovider"}, "http_request": {"args": {}, "path": "/api/v3/providers/ldap/1/", "method": "PUT"}} 192.168.48.1 2022-10-25 09:43:27.594287+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:43:27.59388+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -dacab0a4-45f4-43be-8471-44d99f3b8f4f login_failed authentik.events.signals {"stage": {"pk": "02b4a829f9ab45d0adcc11e3c165f1c4", "app": "authentik_stages_password", "name": "default-authentication-password", "model_name": "passwordstage"}, "password": "********************", "username": "akadmin", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "POST"}} 192.168.48.1 2022-10-25 09:43:30.63453+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2023-10-25 09:43:30.629991+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -12d75a37-20ee-44f4-9f1b-9f99d6dfdeea update_available authentik.admin.tasks {"message": "Changelog: https://goauthentik.io/docs/releases/2022.12#fixed-in-2022121", "new_version": "2022.12.1"} \N 2023-01-02 14:17:08.672486+00 {} 2024-01-02 14:17:08.671057+00 t {"pk": "3fe5e3c1df6f42e1a4a934178957c3b6", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -63133b06-3f6d-4b91-8dab-3de201e71ab5 model_updated authentik.events.signals {"model": {"pk": 1, "app": "authentik_providers_ldap", "name": "grafana-ldap", "model_name": "ldapprovider"}, "http_request": {"args": {}, "path": "/api/v3/providers/ldap/1/", "method": "PUT"}} 192.168.48.1 2022-10-25 09:44:12.026999+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:44:12.026611+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -8b1c946c-4713-47ff-a104-9053e97e4a70 model_created authentik.events.signals {"model": {"pk": "f4abcb7d5f9248908760afb9effda665", "app": "authentik_stages_identification", "name": "ldap-identification-stage", "model_name": "identificationstage"}, "http_request": {"args": {}, "path": "/api/v3/stages/identification/", "method": "POST"}} 192.168.48.1 2022-10-25 09:45:15.819069+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:45:15.81859+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -bcd2fae1-ef0b-4f9c-807d-67a66817d063 model_created authentik.events.signals {"model": {"pk": "95715d75025b4e5e9d2396826479906d", "app": "authentik_stages_password", "name": "ldap-authentication-password", "model_name": "passwordstage"}, "http_request": {"args": {}, "path": "/api/v3/stages/password/", "method": "POST"}} 192.168.48.1 2022-10-25 09:45:43.056738+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:45:43.056256+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -d1de4dc6-df68-493e-b13c-4379f96b5ff7 model_created authentik.events.signals {"model": {"pk": "8a432e545bb44199add953e198eca1dc", "app": "authentik_stages_user_login", "name": "ldap-authentication-login", "model_name": "userloginstage"}, "http_request": {"args": {}, "path": "/api/v3/stages/user_login/", "method": "POST"}} 192.168.48.1 2022-10-25 09:46:01.893085+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:46:01.892272+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -b3dfb7cf-4fd3-473c-b6ad-739962e40702 model_created authentik.events.signals {"model": {"pk": "87ca95995323451c86032f5bee7a0c12", "app": "authentik_flows", "name": "ldap-authentication-flow", "model_name": "flow"}, "http_request": {"args": {}, "path": "/api/v3/flows/instances/", "method": "POST"}} 192.168.48.1 2022-10-25 09:46:38.942684+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:46:38.942223+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -0aa9b970-1d95-44fa-9f79-d99b7e3ff03e model_created authentik.events.signals {"model": {"pk": "585626b672d84f0f9b4e085d6a3c2ee0", "app": "authentik_flows", "name": "Flow-stage binding #10 to 87ca9599-5323-451c-8603-2f5bee7a0c12", "model_name": "flowstagebinding"}, "http_request": {"args": {}, "path": "/api/v3/flows/bindings/", "method": "POST"}} 192.168.48.1 2022-10-25 09:47:33.7108+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:47:33.710142+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -79eee3e4-65e8-4080-87e3-9e8c636a9d17 model_created authentik.events.signals {"model": {"pk": "1e25b2d4d52b4f198b9e65960d3a64a8", "app": "authentik_flows", "name": "Flow-stage binding #30 to 87ca9599-5323-451c-8603-2f5bee7a0c12", "model_name": "flowstagebinding"}, "http_request": {"args": {}, "path": "/api/v3/flows/bindings/", "method": "POST"}} 192.168.48.1 2022-10-25 09:47:53.392562+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:47:53.392118+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -5afcc689-ec3d-4c8b-a40b-0d5dc94b0a96 model_updated authentik.events.signals {"model": {"pk": "f4abcb7d5f9248908760afb9effda665", "app": "authentik_stages_identification", "name": "ldap-identification-stage", "model_name": "identificationstage"}, "http_request": {"args": {}, "path": "/api/v3/stages/identification/f4abcb7d-5f92-4890-8760-afb9effda665/", "method": "PUT"}} 192.168.48.1 2022-10-25 09:48:07.123134+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:48:07.122699+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -02a707b3-cd63-48b1-8731-734d73af8c6a model_updated authentik.events.signals {"model": {"pk": 1, "app": "authentik_providers_ldap", "name": "grafana-ldap", "model_name": "ldapprovider"}, "http_request": {"args": {}, "path": "/api/v3/providers/ldap/1/", "method": "PUT"}} 192.168.48.1 2022-10-25 09:48:41.639902+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:48:41.639409+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -d4595a1a-8c5b-450f-a4de-9907facd1e94 model_created authentik.events.signals {"model": {"pk": 6, "app": "authentik_core", "name": "ldapservice", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/core/users/", "method": "POST"}} 192.168.48.1 2022-10-25 09:49:18.229623+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:49:18.228008+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -dfab21a9-ad29-4980-a762-80a1f0c42ab5 model_created authentik.events.signals {"model": {"pk": "a9c6327ccfca4d5ba0af421645e43c31", "app": "authentik_core", "name": "ldapsearch", "model_name": "group"}, "http_request": {"args": {}, "path": "/api/v3/core/groups/", "method": "POST"}} 192.168.48.1 2022-10-25 09:49:32.570493+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:49:32.570074+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -d273c74c-ec69-445b-87aa-0a7869c359b6 password_set authentik.events.signals {} 255.255.255.255 2022-10-25 09:49:44.127166+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2023-10-25 09:49:44.12686+00 t {"pk": "124e241c825640d9b416596b2daab8b3", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -9969bf93-4d3f-4325-97db-4037c6e9ed59 model_updated authentik.events.signals {"model": {"pk": 6, "app": "authentik_core", "name": "ldapservice", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/core/users/6/set_password/", "method": "POST"}} 192.168.48.1 2022-10-25 09:49:44.253791+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:49:44.253274+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -f8e8270f-871a-41bd-b02e-f3c29e9897f8 password_set authentik.events.signals {} 255.255.255.255 2022-10-25 09:49:45.195476+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2023-10-25 09:49:45.195113+00 t {"pk": "669e359eaf174055957065109edc72a8", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -a36d1d0c-a444-4dcd-b27c-297ecfce47fc authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": "SAMLRequest=nJJBj9MwEIX%2FiuV7aicNSWttIpWtEJUWqLaFA7epM9lacuzgmQD771GzRVok1MMebc97n%2BfN3BEMfjSbic%2FhEX9MSCx%2BDz6QuTw0ckrBRCBHJsCAZNiaw%2BbTgykW2gARJnYxyFeS8bZmTJGjjV6K3baRrstWuu7zWndlX%2BaAuF6eynVV9brP39WnWq9sUeVVibUU3zCRi6GRxUJLsSOacBeIIXAjC10sM51nujjmlVnmZrlerMr6uxRbJHYBeFaemUejlI8W%2FDkSm7XWWsE4emfnEnVpQT0l6CFANh%2BIojq50LnwpBJ2LqFlJcXmb%2FP3MdA0YDpg%2Buksfn18%2BA9neeHMdmBJiv01hfcvvrcju8LJfDwe99n%2By%2BEo23lsZs4giQ8xDcC3TS43rsv6udRgYMfPsr3xzwEZOmC4U69Q7XVdPsOAu%2B0%2Bemef34DnBIEcBpZi4338dZ8QGBvJaUKp2hfkv0vZ%2FgkAAP%2F%2F&RelayState=TUVNeGt5N3FINDA5UGVEM1JuSHA1dU82TFBWUUZPSzVEcFR2U2RqQ1hrb2lOdmdKMEp8U2RLaHBycFZ6&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&Signature=w3j4ku9na9TCOBB0lF%2FHA5VTsVQb%2Fa0kuSgILzVcbLTq%2BcAcPRNmEcpVqZPHcWtIfJPw%2F%2FP0HsQhKiPm70JbrVBbZPsZO3ysTe%2Fa9bhpot1yf9HHbkFWShcT%2BA5hgap1DEvJMSZD8N5%2Fkq%2BO47s9%2F0ngZeN%2FErO41mXDrvEGXFEJfHCgU18BOAwbKN9eGj09idnldNiUJ4mm1HEbAHI935KS6buBDnLgM0dYIYZzEIBKewt7j8poD98hRyV9GnCBrN%2FH2D7yOf3IGwKBc4hdMhYbeqBKcj4MTywBEQIc1uY9nUdtRI4N1Nrfmwd2KOpribVB%2FE6musXjb%2Fxo5%2BRmvyMASQGb%2Foo1QIfDZ87lRbRNKffw8KTI7dTusJTl%2BH6mYEtWU5rd%2B6bsiSLFiLl%2F7wpFOHlynTaqte2J1Cg7RAaJt8o0V7vb3%2BpS9CxaFY%2BF8J7MLc3drOTLy9EReBgV2vmdkwS2lE92EYkOzm987nusuUGP2f8ET5oUr1udHw1w2UPdyoJHdTYiHwLUufCM1%2BgaJsoZALfzgW0vKe8ghMUrHl03M9mddynTF%2B%2F9y%2FtIp3INVoWeS6WRHhX0bwlkwBXpL675KkW7q%2F0rVYJ3m6BOt9JkzXPv3l42yQPYs8KY3TXvbwIf2m0L%2FTORX5Jnhs09rWhQrNv%2BrONgD1mUrsA%3D"}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.24.0.1 2023-01-02 16:31:40.277118+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 16:31:40.276431+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -c07b9726-c9a6-41d9-8750-298dc6c02bb9 model_updated authentik.events.signals {"model": {"pk": 6, "app": "authentik_core", "name": "ldapservice", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/core/users/6/set_password/", "method": "POST"}} 192.168.48.1 2022-10-25 09:49:45.334164+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:49:45.333246+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -0e5513c2-7946-4809-b7ad-e8ee39eb06f7 password_set authentik.events.signals {} 255.255.255.255 2022-10-25 09:49:50.747043+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2023-10-25 09:49:50.746711+00 t {"pk": "669e359eaf174055957065109edc72a8", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -3c0fb3f6-a496-4f75-8748-065b52ecef2d model_updated authentik.events.signals {"model": {"pk": 6, "app": "authentik_core", "name": "ldapservice", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/core/users/6/set_password/", "method": "POST"}} 192.168.48.1 2022-10-25 09:49:50.876408+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:49:50.87598+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -ad444c96-d144-4242-912d-df660a82dfa8 model_updated authentik.events.signals {"model": {"pk": 1, "app": "authentik_providers_ldap", "name": "grafana-ldap", "model_name": "ldapprovider"}, "http_request": {"args": {}, "path": "/api/v3/providers/ldap/1/", "method": "PUT"}} 192.168.48.1 2022-10-25 09:51:10.6754+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:51:10.674831+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -6c99cc35-0fd4-42ce-866f-4c733c979208 model_updated authentik.events.signals {"model": {"pk": 3, "app": "authentik_core", "name": "Outpost authentik Embedded Outpost Service-Account", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/core/users/6/metrics/", "method": "GET"}} 192.168.48.1 2022-10-25 09:52:37.060707+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:52:37.059951+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -5a428128-25a6-4b0b-8228-1c965aca57f3 login_failed authentik.events.signals {"stage": {"pk": "f4abcb7d5f9248908760afb9effda665", "app": "authentik_stages_identification", "name": "ldap-identification-stage", "model_name": "identificationstage"}, "password": "********************", "username": "akadmin", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "POST"}} 192.168.48.1 2022-10-25 09:52:46.450704+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2023-10-25 09:52:46.444595+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -2fef73b5-98e6-4a7a-9b0d-ccd326d1dab6 login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 192.168.48.1 2022-10-25 09:53:30.278414+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2023-10-25 09:53:30.271341+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -2a320fa4-0e4b-4350-88a9-0f3e2dafc249 login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 192.168.48.1 2022-10-25 09:53:37.391263+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2023-10-25 09:53:37.387326+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -a8612adc-a554-41f0-8b4e-c097121e2e16 login_failed authentik.events.signals {"stage": {"pk": "f4abcb7d5f9248908760afb9effda665", "app": "authentik_stages_identification", "name": "ldap-identification-stage", "model_name": "identificationstage"}, "password": "********************", "username": "akadmin", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "POST"}} 192.168.48.1 2022-10-25 09:53:38.124841+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2023-10-25 09:53:38.120289+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -df28e1d0-db28-4303-9c1b-a1e2bd437de2 login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 192.168.48.1 2022-10-25 09:53:47.045402+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2023-10-25 09:53:47.042514+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -16e97a17-7e98-4e87-849a-3db39705f612 login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 192.168.48.1 2022-10-25 09:53:47.802321+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2023-10-25 09:53:47.798122+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -39515339-d523-4c5f-8209-97efccdd7e11 login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 192.168.48.1 2022-10-25 09:54:00.215328+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2023-10-25 09:54:00.210145+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -1333efce-d2f6-4ef0-bd3a-3a0276df711b login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 192.168.48.1 2022-10-25 09:54:00.975468+00 {"pk": 5, "email": "authentik-admin@localhost", "username": "authentik-admin"} 2023-10-25 09:54:00.971761+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -7864022f-6f92-4812-8a3e-80da868b792f model_created authentik.events.signals {"model": {"pk": 7, "app": "authentik_core", "name": "authentik-viewer", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/core/users/", "method": "POST"}} 192.168.48.1 2022-10-25 09:56:58.989276+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:56:58.988641+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -b961fe98-2057-43e6-bba0-610c0bd2aba1 password_set authentik.events.signals {} 255.255.255.255 2022-10-25 09:57:07.656784+00 {"pk": 7, "email": "authentik-viewer@localhost", "username": "authentik-viewer"} 2023-10-25 09:57:07.656418+00 t {"pk": "669e359eaf174055957065109edc72a8", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -ecdd7e58-c10f-44dd-abe6-473b698b167e model_updated authentik.events.signals {"model": {"pk": 7, "app": "authentik_core", "name": "authentik-viewer", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/core/users/7/set_password/", "method": "POST"}} 192.168.48.1 2022-10-25 09:57:07.785245+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:57:07.784815+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -9d996d5c-3949-4a7c-a229-7572086943bb password_set authentik.events.signals {} 255.255.255.255 2022-10-25 09:57:13.548415+00 {"pk": 7, "email": "authentik-viewer@localhost", "username": "authentik-viewer"} 2023-10-25 09:57:13.548084+00 t {"pk": "124e241c825640d9b416596b2daab8b3", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -62ba45d9-5037-4b6d-ab3f-06a76e759f9e model_updated authentik.events.signals {"model": {"pk": 7, "app": "authentik_core", "name": "authentik-viewer", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/core/users/7/set_password/", "method": "POST"}} 192.168.48.1 2022-10-25 09:57:13.678156+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:57:13.677728+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -064c5c29-9b44-4887-88a6-7a921ba10c42 model_created authentik.events.signals {"model": {"pk": 8, "app": "authentik_core", "name": "authentik-editor", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/core/users/", "method": "POST"}} 192.168.48.1 2022-10-25 09:57:43.651383+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:57:43.650616+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -f9ea2089-5b8a-4701-95ab-eb29fc832fee password_set authentik.events.signals {} 255.255.255.255 2022-10-25 09:57:52.094392+00 {"pk": 8, "email": "authentik-editor@localhost", "username": "authentik-editor"} 2023-10-25 09:57:52.094045+00 t {"pk": "669e359eaf174055957065109edc72a8", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -4403cff8-482b-4447-8548-37aeedbe027d model_updated authentik.events.signals {"model": {"pk": 8, "app": "authentik_core", "name": "authentik-editor", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/core/users/8/set_password/", "method": "POST"}} 192.168.48.1 2022-10-25 09:57:52.230146+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:57:52.229464+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -2094c1e2-a954-4cbf-b58f-e7221698b1a7 password_set authentik.events.signals {} 255.255.255.255 2022-10-25 09:57:54.385835+00 {"pk": 8, "email": "authentik-editor@localhost", "username": "authentik-editor"} 2023-10-25 09:57:54.385364+00 t {"pk": "124e241c825640d9b416596b2daab8b3", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -ef60de42-c27f-46f3-9773-1c540f072ae0 model_updated authentik.events.signals {"model": {"pk": 8, "app": "authentik_core", "name": "authentik-editor", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/core/users/8/set_password/", "method": "POST"}} 192.168.48.1 2022-10-25 09:57:54.524086+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:57:54.523676+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -a8885c27-5608-4eb5-a0d1-1f19dea20bda model_created authentik.events.signals {"model": {"pk": "17bb354ecea248dbb3229f9c8b53b8bb", "app": "authentik_core", "name": "admin", "model_name": "group"}, "http_request": {"args": {}, "path": "/api/v3/core/groups/", "method": "POST"}} 192.168.48.1 2022-10-25 09:58:22.075895+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:58:22.075408+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -86d0a1ed-6d6b-4422-a4b5-6f60f9918b96 model_created authentik.events.signals {"model": {"pk": "7e216c1b2a0f4772ac3d7bdaf7511a2e", "app": "authentik_core", "name": "editor", "model_name": "group"}, "http_request": {"args": {}, "path": "/api/v3/core/groups/", "method": "POST"}} 192.168.48.1 2022-10-25 09:58:27.448906+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:58:27.44842+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -5f4a3eb8-6118-46f7-a066-26277ac65e15 model_created authentik.events.signals {"model": {"pk": "10d9a55e195b41a09fe663382d5b8208", "app": "authentik_core", "name": "viewer", "model_name": "group"}, "http_request": {"args": {}, "path": "/api/v3/core/groups/", "method": "POST"}} 192.168.48.1 2022-10-25 09:58:32.144204+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:58:32.143773+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -3454a52b-238c-4cb6-8db5-64c8303e339c model_updated authentik.events.signals {"model": {"pk": "17bb354ecea248dbb3229f9c8b53b8bb", "app": "authentik_core", "name": "admin", "model_name": "group"}, "http_request": {"args": {}, "path": "/api/v3/core/groups/17bb354e-cea2-48db-b322-9f9c8b53b8bb/", "method": "PUT"}} 192.168.48.1 2022-10-25 09:59:21.181345+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:59:21.180942+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -fdf81360-a8a8-4c8f-988d-2ed85c613a49 model_updated authentik.events.signals {"model": {"pk": "7e216c1b2a0f4772ac3d7bdaf7511a2e", "app": "authentik_core", "name": "editor", "model_name": "group"}, "http_request": {"args": {}, "path": "/api/v3/core/groups/7e216c1b-2a0f-4772-ac3d-7bdaf7511a2e/", "method": "PUT"}} 192.168.48.1 2022-10-25 09:59:30.4796+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:59:30.478673+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -a3998ed2-5beb-4a26-b395-f5b579d632ca model_updated authentik.events.signals {"model": {"pk": "10d9a55e195b41a09fe663382d5b8208", "app": "authentik_core", "name": "viewer", "model_name": "group"}, "http_request": {"args": {}, "path": "/api/v3/core/groups/10d9a55e-195b-41a0-9fe6-63382d5b8208/", "method": "PUT"}} 192.168.48.1 2022-10-25 09:59:42.963339+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2023-10-25 09:59:42.962914+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -2b3c7de7-f3cb-4e4d-bbcd-36ff4d1add8e login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "next=%2F"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 172.24.0.1 2023-01-02 14:16:55.819839+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 14:16:55.819267+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -eeb1be95-4ade-4812-93b0-2c87db29716c update_available authentik.admin.tasks {"message": "Changelog: https://goauthentik.io/docs/releases/2022.12#fixed-in-2022121", "new_version": "2022.12.1"} \N 2023-01-02 14:17:08.645256+00 {} 2024-01-02 14:17:08.643976+00 t {"pk": "3fe5e3c1df6f42e1a4a934178957c3b6", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -fa558ccb-705b-43b8-9a2e-86d4f1d8d0a0 system_task_exception authentik.events.monitored_tasks {"message": "Task notification_transport encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.10/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n File \\"/usr/local/lib/python3.10/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.10/site-packages/sentry_sdk/_compat.py\\", line 56, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.10/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n File \\"/usr/local/lib/python3.10/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n File \\"/usr/local/lib/python3.10/site-packages/celery/app/autoretry.py\\", line 54, in run\\n ret = task.retry(exc=exc, **retry_kwargs)\\n File \\"/usr/local/lib/python3.10/site-packages/celery/app/task.py\\", line 717, in retry\\n raise_with_context(exc)\\n File \\"/usr/local/lib/python3.10/site-packages/celery/app/autoretry.py\\", line 34, in run\\n return task._orig_run(*args, **kwargs)\\n File \\"/authentik/events/tasks.py\\", line 130, in notification_transport\\n raise exc\\n File \\"/authentik/events/tasks.py\\", line 126, in notification_transport\\n transport.send(notification)\\n File \\"/authentik/events/models.py\\", line 341, in send\\n return self.send_email(notification)\\n File \\"/authentik/events/models.py\\", line 463, in send_email\\n raise NotificationTransportError from exc\\nauthentik.events.models.NotificationTransportError: "} \N 2023-01-02 14:17:13.924879+00 {} 2024-01-02 14:17:13.924102+00 t {"pk": "3fe5e3c1df6f42e1a4a934178957c3b6", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -c1d32b85-3711-44c1-bd8b-918eba2599e2 system_task_exception authentik.events.monitored_tasks {"message": "Task notification_transport encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.10/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n File \\"/usr/local/lib/python3.10/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.10/site-packages/sentry_sdk/_compat.py\\", line 56, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.10/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n File \\"/usr/local/lib/python3.10/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n File \\"/usr/local/lib/python3.10/site-packages/celery/app/autoretry.py\\", line 54, in run\\n ret = task.retry(exc=exc, **retry_kwargs)\\n File \\"/usr/local/lib/python3.10/site-packages/celery/app/task.py\\", line 717, in retry\\n raise_with_context(exc)\\n File \\"/usr/local/lib/python3.10/site-packages/celery/app/autoretry.py\\", line 34, in run\\n return task._orig_run(*args, **kwargs)\\n File \\"/authentik/events/tasks.py\\", line 130, in notification_transport\\n raise exc\\n File \\"/authentik/events/tasks.py\\", line 126, in notification_transport\\n transport.send(notification)\\n File \\"/authentik/events/models.py\\", line 341, in send\\n return self.send_email(notification)\\n File \\"/authentik/events/models.py\\", line 463, in send_email\\n raise NotificationTransportError from exc\\nauthentik.events.models.NotificationTransportError: "} \N 2023-01-02 14:17:14.593626+00 {} 2024-01-02 14:17:14.592229+00 t {"pk": "3fe5e3c1df6f42e1a4a934178957c3b6", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -521e9674-7fa5-4d70-b266-9b8048b640b0 model_created authentik.events.signals {"model": {"pk": 2, "app": "authentik_providers_saml", "name": "grafana-saml", "model_name": "samlprovider"}, "http_request": {"args": {}, "path": "/api/v3/providers/saml/", "method": "POST"}} 172.24.0.1 2023-01-02 14:20:39.41267+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 14:20:39.41213+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -8a5a4f65-cbd8-4617-a52a-515f7e008164 model_created authentik.events.signals {"model": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}, "http_request": {"args": {}, "path": "/api/v3/core/applications/", "method": "POST"}} 172.24.0.1 2023-01-02 14:21:07.723593+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 14:21:07.722493+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -7ee95403-f95f-4a94-be59-0eb1210a3b55 authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": ""}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.24.0.1 2023-01-02 14:21:13.310418+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 14:21:13.30943+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -2e67f976-24c8-4f39-9fb8-160c8df5fc48 authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": ""}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.24.0.1 2023-01-02 14:21:43.965265+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 14:21:43.964033+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -a2a95f65-9f92-496e-b577-55c844b90891 model_updated authentik.events.signals {"model": {"pk": 2, "app": "authentik_providers_saml", "name": "grafana-saml", "model_name": "samlprovider"}, "http_request": {"args": {}, "path": "/api/v3/providers/saml/2/", "method": "PUT"}} 172.24.0.1 2023-01-02 14:22:28.185263+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 14:22:28.184795+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -95797e9f-e818-4f5d-9fdb-2d499833ae4c model_updated authentik.events.signals {"model": {"pk": 2, "app": "authentik_providers_saml", "name": "grafana-saml", "model_name": "samlprovider"}, "http_request": {"args": {}, "path": "/api/v3/providers/saml/2/", "method": "PUT"}} 172.24.0.1 2023-01-02 14:23:24.663957+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 14:23:24.663405+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -aa49c5e8-b508-4046-907e-cfdca67edf75 configuration_error authentik.providers.saml.views.sso {"message": "Failed to verify signature", "provider": {"pk": 2, "app": "authentik_providers_saml", "name": "grafana-saml", "model_name": "samlprovider"}} \N 2023-01-02 16:20:27.103437+00 {} 2024-01-02 16:20:27.102874+00 t {"pk": "69cc7a86348f4144973b0cbaaea44e3f", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} d0c607f5-b34e-4c87-bfe3-86b6af93bff8 system_exception authentik.root.celery {"message": "Traceback (most recent call last):\\ndjango.db.utils.InterfaceError: connection already closed"} \N 2023-03-13 09:19:32.449521+00 {} 2024-03-12 09:19:32.448974+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -9aac6d1c-02e9-4e51-b705-85a86f1919df system_task_exception authentik.events.monitored_tasks {"message": "Task notification_transport encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.10/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n File \\"/usr/local/lib/python3.10/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.10/site-packages/sentry_sdk/_compat.py\\", line 56, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.10/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n File \\"/usr/local/lib/python3.10/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n File \\"/usr/local/lib/python3.10/site-packages/celery/app/autoretry.py\\", line 54, in run\\n ret = task.retry(exc=exc, **retry_kwargs)\\n File \\"/usr/local/lib/python3.10/site-packages/celery/app/task.py\\", line 717, in retry\\n raise_with_context(exc)\\n File \\"/usr/local/lib/python3.10/site-packages/celery/app/autoretry.py\\", line 34, in run\\n return task._orig_run(*args, **kwargs)\\n File \\"/authentik/events/tasks.py\\", line 130, in notification_transport\\n raise exc\\n File \\"/authentik/events/tasks.py\\", line 126, in notification_transport\\n transport.send(notification)\\n File \\"/authentik/events/models.py\\", line 341, in send\\n return self.send_email(notification)\\n File \\"/authentik/events/models.py\\", line 463, in send_email\\n raise NotificationTransportError from exc\\nauthentik.events.models.NotificationTransportError: "} \N 2023-01-02 16:20:32.525925+00 {} 2024-01-02 16:20:32.524994+00 t {"pk": "3fe5e3c1df6f42e1a4a934178957c3b6", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -d93d9f43-d318-4f57-8fc5-58859aafd0a6 authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": ""}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.24.0.1 2023-01-02 16:20:39.35962+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 16:20:39.358773+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -9a1859e1-089a-46c9-a2a9-5ca91e1a14da model_updated authentik.events.signals {"model": {"pk": 2, "app": "authentik_providers_saml", "name": "grafana-saml", "model_name": "samlprovider"}, "http_request": {"args": {}, "path": "/api/v3/providers/saml/2/", "method": "PUT"}} 172.24.0.1 2023-01-02 16:21:00.718418+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 16:21:00.717578+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -58277880-d969-4e13-ba98-dfca29dc2815 model_updated authentik.events.signals {"model": {"pk": 2, "app": "authentik_providers_saml", "name": "grafana-saml", "model_name": "samlprovider"}, "http_request": {"args": {}, "path": "/api/v3/providers/saml/2/", "method": "PUT"}} 172.24.0.1 2023-01-02 16:21:20.595211+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 16:21:20.594713+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -9c9fc310-2115-4aee-a0f0-d91abc0dae03 authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": ""}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.24.0.1 2023-01-02 16:21:26.679637+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 16:21:26.67829+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -2f1ac464-0e79-47ea-b3df-bf70f90d22c7 authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": ""}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.24.0.1 2023-01-02 16:21:47.199639+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 16:21:47.198789+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -d4549f57-3afb-4e6f-8726-be968bbc902f model_updated authentik.events.signals {"model": {"pk": 2, "app": "authentik_providers_saml", "name": "grafana-saml", "model_name": "samlprovider"}, "http_request": {"args": {}, "path": "/api/v3/providers/saml/2/", "method": "PUT"}} 172.24.0.1 2023-01-02 16:22:24.896791+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 16:22:24.895837+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -8d6e7456-f3f6-4806-a1ae-286cd50c6e54 authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": ""}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.24.0.1 2023-01-02 16:22:37.062381+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 16:22:37.061613+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -7203b3e8-4475-498f-b837-5775a0501e86 authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": ""}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.24.0.1 2023-01-02 16:26:08.685203+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 16:26:08.684412+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -7384e1a4-157e-47dd-ad30-f0c4030a1b54 authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": "SAMLRequest=nJJBj9MwEIX%2FiuV76sRRmtbaRCpbISotUG0LB25TZ7q15NjBMwH236Nmi1QklANH2%2FPe53kzDwS9H8xm5Et4xu8jEotfvQ9krg%2BNHFMwEciRCdAjGbbmsPn4ZPQiN0CEiV0M8k4yzGuGFDna6KXYbRvpumxZl%2BeyrivbrWoN5akDrOtTpYuyWq3XS1utKnvSxVmKr5jIxdBIvcil2BGNuAvEELiROtdllhdZro%2FF0uiVKcpFWa%2B%2FSbFFYheAJ%2BWFeTBK%2BWjBXyKxWed5rmAYvLNTibq2oF4SnCFANh2Iojq50LnwohJ2LqFlJcXmT%2FOPMdDYYzpg%2BuEsfnl%2B%2BgenvHImO7Akxf6Wwrs33%2FnIbnAyH47Hfbb%2FfDjKdhqbmTJI4n1MPfC8yfXGddl5KjUY2PGrbGf%2B2SNDBwwP6g7V3tblE%2FS42%2B6jd%2Fb1P%2FCcIJDDwFJsvI8%2FHxMCYyM5jShV%2B4b8eynb3wEAAP%2F%2F&RelayState=MVFtczE2eEVENFo0amhyUEtoUFkySDJ5Wm5CRkRQWXJYaW42eTBMNTIyYkZNRENhS0Z8WkV6SHRydFZ6&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&Signature=Uds21tE5ZR3SmvxFqy9VrYyL8ujqShawaGcz0V77dayLPpeykS6JN36ZoTD4eHSPUqmHqvTziWpLC8O2IlbfANmm8%2F58AOWsxSaKjdqwlKgWeLSQUrqhDXLOdOfabzMXPFkcBg9jS7P%2FXRl4rGSyjS%2BCYTJdazdrM8w5JOBvO%2B%2B2NGfxbo1hcHbGprQTt7ScDsxt7WPcEE0GRVhEbOZAym%2Fp6ZwCGB%2FkxC%2BGN68hdbXKD%2B51X0SIdF8Apy%2BYw4nshrIqteXb6jcoTwfNQdNY25wnuJ3J4LXaE5ZqajjjIIfPFYjBsnRFuzFUed3372rahiX5ax0SWp7Fj3uxyG6%2FQil9MYSvCX1Kw0rQf5zKP%2FFohivJ3vtbgHsVa6mqgWPR%2BFyiX5xZbDi4DaHGof%2F7NM5f9g3CgcaQr5v8%2BK8eHn%2Fl1EjDlJSDr2BSJ3NX68581527VAcWZ2OjXRIeodh5DKBrQ9F9%2BTW4AEuTlRBPUWdL15209HG6MDcN5p6M0OgYh%2B6STW5FY0aqgSFBjxjXAGk%2BlKQTq%2BrpJnLQXxgJlby3%2BYkyO92Ick34NsxoaDr5ySYYtM6M%2FuZP9443t8B9xun407wdaja3CNWP8iXuM2%2FhS1JCDud%2Fb8oCq14l0sITUqUK4R%2BCEd7odJDUuSNdDjHiM%2BEW1mf3MyuB68M1dhE%3D"}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.24.0.1 2023-01-02 16:28:13.791097+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 16:28:13.789745+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -b4315108-6c82-4ff3-aeea-56a31d071961 model_updated authentik.events.signals {"model": {"pk": 2, "app": "authentik_providers_saml", "name": "grafana-saml", "model_name": "samlprovider"}, "http_request": {"args": {}, "path": "/api/v3/providers/saml/2/", "method": "PUT"}} 172.24.0.1 2023-01-02 16:30:58.798901+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 16:30:58.797248+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -439439e2-e8e7-4af6-b3ef-e563b36f674a model_created authentik.events.signals {"model": {"pk": "ca14281ce0254691b5a23ee415920e80", "app": "authentik_core", "name": "extra-group", "model_name": "group"}, "http_request": {"args": {}, "path": "/api/v3/core/groups/", "method": "POST"}} 172.24.0.1 2023-01-02 16:33:50.270344+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 16:33:50.269707+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -c5efd86a-6999-4a7b-8fd9-305b26d96bc6 login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "next=%2Fapplication%2Fsaml%2Fgrafana-saml%2Fsso%2Fbinding%2Fredirect%2F%3FSAMLRequest%3DnJJBb9swDIX%252FiqC7I1t2U0%252BoDWQNhgXotqDJdtiNlelGgCx5Ir2t%252F36ImwEZMPjQoyS%252B94mPvCMY%252FGg2E5%252FCI%252F6YkFj8Hnwgc35o5JSCiUCOTIABybA1h82nB6NXuQEiTOxikFeScVkzpsjRRi%252FFbttI12VY6luLsF5bjbaobxHqQvc3T1VVV7qsir4rboq%252BRim%252BYSIXQyP1KpdiRzThLhBD4EbqXJdZXmS5PhZrU9am0Kt19V2KLRK7ADwLT8yjUcpHC%252F4Uic27PM8VjKN3di5R5w7Uc4IeAmTzgSiqJxc6F55Vws4ltKyk2Pzt%252FT4GmgZMB0w%252FncWvjw%252F%252F4ZRnzmwHlqTYX0J4%252F%252Bq7nNgFTubj8bjP9l8OR9nOUzNzBEl8iGkAXjY537gu6%252BdSg4Edv8h24Z8DMnTAcKeuUO1lWz7DgLvtPnpnX96A5wSBHAaWYuN9%252FHWfEBgbyWlCqdpX5L872f4JAAD%252F%252Fw%253D%253D%26RelayState%3DR3cwMXZJMU9xZUVaY1RZVmxVMDkwVk1DSEVkQzYzR2hBU3NNUHNkVlZxSEt1SDdrUDJ8Z2pPbHA5cFZ6%26SigAlg%3Dhttp%253A%252F%252Fwww.w3.org%252F2001%252F04%252Fxmldsig-more%2523rsa-sha256%26Signature%3DBX5F2GCFaeadEWb17AxG2mVisyVfBGrNRczpBwqe0TwTQZKySlVpaED8Vqak2hSyOC9HdKSkRmYGBJqrKPImbGzdeptgxXqlr8usnoBmxy1UF8GKrOM2ptPOqMsXargtigpS7mLatQN1spTkxyDUnfQfCknvH%252BgjQymkGDjtMtOQLEP2y3webPtuBiwv43Wl4YzojxSXALALITtANMgCqdsE8omJ%252FtuZZG2iu31j%252BJk3OTDWhZn6QBNNrV%252FMEPZOVD5CpnxagNhPC4J74rmWP2JA6Yu%252BcqFaCBTlty0do7D%252FQVvdzjHgrnAaosxltAtUkDWS47%252Fo1Ed9Jp2jTzsLzOh2%252F2EXZB7UrPIiQL28THjChuMv9ohoVrwHi6Z77SSJzJJYQmUKPC6IUg4PeoUY%252FpghlrtqxTDeghePUcLuMTusLu4wf%252BdMEvhwi20Eaz6ABr7F0TYAxnKfdEwsG%252FEIkBHa109%252Fv3YqDKKbKUuJKyUeu%252F%252Bo9YwbZG5eRDqKoBoNRbAyDOBVqXMgkTNUEhs5qtLDbOlgcim%252F5Tum5usf921zEn0Z01vGTO2bjrjdRbdJiAfAPhOj%252F69n7rJn%252FklU8NtXdiNHYZn0MLAdOKpBChjnfjehCp08n%252BG4ZcCXMe62gnD2Art0HJf30z2N76vJsoyrXMypc%252BN6WpUQSpsgb6w%253D"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 172.24.0.1 2023-01-02 16:38:22.087813+00 {"pk": 5, "email": "authentik-admin@localhost", "username": "authentik-admin"} 2024-01-02 16:38:22.087203+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -0ffd3d0e-31f3-4b55-bdf7-d00160afcfdc authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": "SAMLRequest=nJJBb9swDIX%2FiqC7I1t2U0%2BoDWQNhgXotqDJdtiNlelGgCx5Ir2t%2F36ImwEZMPjQoyS%2B94mPvCMY%2FGg2E5%2FCI%2F6YkFj8Hnwgc35o5JSCiUCOTIABybA1h82nB6NXuQEiTOxikFeScVkzpsjRRi%2FFbttI12VY6luLsF5bjbaobxHqQvc3T1VVV7qsir4rboq%2BRim%2BYSIXQyP1KpdiRzThLhBD4EbqXJdZXmS5PhZrU9am0Kt19V2KLRK7ADwLT8yjUcpHC%2F4Uic27PM8VjKN3di5R5w7Uc4IeAmTzgSiqJxc6F55Vws4ltKyk2Pzt%2FT4GmgZMB0w%2FncWvjw%2F%2F4ZRnzmwHlqTYX0J4%2F%2Bq7nNgFTubj8bjP9l8OR9nOUzNzBEl8iGkAXjY537gu6%2BdSg4Edv8h24Z8DMnTAcKeuUO1lWz7DgLvtPnpnX96A5wSBHAaWYuN9%2FHWfEBgbyWlCqdpX5L872f4JAAD%2F%2Fw%3D%3D&RelayState=R3cwMXZJMU9xZUVaY1RZVmxVMDkwVk1DSEVkQzYzR2hBU3NNUHNkVlZxSEt1SDdrUDJ8Z2pPbHA5cFZ6&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&Signature=BX5F2GCFaeadEWb17AxG2mVisyVfBGrNRczpBwqe0TwTQZKySlVpaED8Vqak2hSyOC9HdKSkRmYGBJqrKPImbGzdeptgxXqlr8usnoBmxy1UF8GKrOM2ptPOqMsXargtigpS7mLatQN1spTkxyDUnfQfCknvH%2BgjQymkGDjtMtOQLEP2y3webPtuBiwv43Wl4YzojxSXALALITtANMgCqdsE8omJ%2FtuZZG2iu31j%2BJk3OTDWhZn6QBNNrV%2FMEPZOVD5CpnxagNhPC4J74rmWP2JA6Yu%2BcqFaCBTlty0do7D%2FQVvdzjHgrnAaosxltAtUkDWS47%2Fo1Ed9Jp2jTzsLzOh2%2F2EXZB7UrPIiQL28THjChuMv9ohoVrwHi6Z77SSJzJJYQmUKPC6IUg4PeoUY%2FpghlrtqxTDeghePUcLuMTusLu4wf%2BdMEvhwi20Eaz6ABr7F0TYAxnKfdEwsG%2FEIkBHa109%2Fv3YqDKKbKUuJKyUeu%2F%2Bo9YwbZG5eRDqKoBoNRbAyDOBVqXMgkTNUEhs5qtLDbOlgcim%2F5Tum5usf921zEn0Z01vGTO2bjrjdRbdJiAfAPhOj%2F69n7rJn%2FklU8NtXdiNHYZn0MLAdOKpBChjnfjehCp08n%2BG4ZcCXMe62gnD2Art0HJf30z2N76vJsoyrXMypc%2BN6WpUQSpsgb6w%3D"}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.24.0.1 2023-01-02 16:38:22.508665+00 {"pk": 5, "email": "authentik-admin@localhost", "username": "authentik-admin"} 2024-01-02 16:38:22.508022+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -025e97e0-ee2c-4178-a410-2b832340a35a authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": ""}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.24.0.1 2023-01-02 16:40:38.094961+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 16:40:38.094424+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -8cdc9b41-47bb-440b-9ce8-3fa9d7c81800 authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": "SAMLRequest=nJJBj9MwEIX%2FSuR7ajvpJsTaRCpbISotUG0LB25TZ7q15NjBMwH236Nmi1Qk1MMebc97n%2BfN3BMMfjSriU%2FhCX9MSJz9Hnwgc35oxZSCiUCOTIABybA1u9WnR1MslAEiTOxiEFeS8bZmTJGjjV5km3UrXJ%2Frpa5RN1Dp4nBodF%2FZEnVT9XWpbH1X3tW2Pmo8vBPZN0zkYmhFsVAi2xBNuAnEELgVhSrKXOlcFXtdmWVl1HJRNvq7yNZI7ALwrDwxj0ZKHy34UyQ2jVJKwjh6Z%2BcSeW5BPic4QoB8PhBFeXChd%2BFZJuxdQstSZKu%2FzT%2FEQNOAaYfpp7P49enxP5zyzJntwJLItpcU3r%2F63o7sAifzcb%2Ff5tsvu73o5rGZOYOUfYhpAL5tcr5xfX6cSw0Gdvwiuhv%2FHJChB4Z7eYXqLuvyGQbcrLfRO%2FvyBjwnCOQwsMhW3sdfDwmBsRWcJhSye0X%2Bu5TdnwAAAP%2F%2F&RelayState=VVk1bmU0UDk4clBnUnNOVk5PNFBzV3hQamFHUVgxdEszS094WUN3SEZtWnlLczg0VXV8U2F5SGhydFZ6&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&Signature=mW4CQva3Vem85Dq5yo%2BBclqHaloTreonGr4VCel4SvbiRfF5jgQDGEcxn7kcxDw8JZym8iquo7Ivx%2BM%2FuciSTWOMp2n%2FTzbkkR7QxSfYjBLUXTsoh0JBZvh2fVsJMr10rYaW%2Frig8mGmP697eEhRyma9t5HJnu6mkvE%2Biszr3qQmv85nX%2B9Ac6IBOQOsiSheeKqdZm3Yj3gb3IGZjJ5Y8xmGZSmQ%2BLbiB7AerHM8KNUqysRBoyDBcG5YTtTNao4ImNBkCxh0a45Geur3y0LwEmt0lQkY2YJzW4Uh%2B3dKCK2yRqnTZfEK7nvH37Ue0uc1Fx9zH6hjgijSqWfYAYEJWf%2B4sM%2BhMZ8tHV93bLzzaPXH%2FfhAYyo%2FMREW2AKlIYElK6dQA68wblp%2FxYXm%2BXgJoYs%2FHQqPrQLU%2BE2Bd5Me5xvUIfD2KBR3Lx9GsY8%2F0n21HTajAgxga3NuscTGlP9Is%2FLdOxfu9IY3d77ecSTTUWgkcR%2BRyEo1ApUGLWZrAp5WK1IWMbfBf0hhPmQCccjgjN%2B8kDgAE9ETst36804uotZH6VY4iuTJhdnX4EPcFl848E6BwnrnlP%2FyJ16Qb7nQGWn2zGVGFTncEtwH%2FaUb1LKp6yTuMSuSQRcqzNyFuvJDBvmWiGTbGzlt9VCbwtIVTInSI3NLin%2BMErSktt5AFVg%3D"}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.24.0.1 2023-01-02 16:46:04.805431+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-01-02 16:46:04.80479+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} efb6497b-cd83-4e42-a2da-9ea634020d29 system_exception authentik.root.celery {"message": "Traceback (most recent call last):\\ndjango.db.utils.InterfaceError: connection already closed"} \N 2023-03-13 09:18:52.857773+00 {} 2024-03-12 09:18:52.85257+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} f2345f3c-50f4-445f-b1f1-fd3cdfc1e548 system_exception authentik.root.celery {"message": "Traceback (most recent call last):\\ndjango.db.utils.InterfaceError: connection already closed"} \N 2023-03-13 09:18:55.67402+00 {} 2024-03-12 09:18:55.673476+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} 1da692a0-5369-49b1-918d-397a8fb4f6c7 system_exception authentik.root.celery {"message": "Traceback (most recent call last):\\ndjango.db.utils.InterfaceError: connection already closed"} \N 2023-03-13 09:18:59.93416+00 {} 2024-03-12 09:18:59.933726+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} @@ -2906,6 +3076,7 @@ c70a59e5-58b6-4a28-b1e8-ed6d4496c16c login authentik.events.signals {"auth_metho 0811b173-1a28-4ffc-8805-f25b7b32566e system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 10:02:00.03614+00 {} 2024-03-12 10:02:00.035596+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} 2c333a98-104b-4069-99f8-3995f8e276fe system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 10:07:00.057378+00 {} 2024-03-12 10:07:00.056377+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} 95d26a20-b12f-45c3-9ea6-937eefbf4c8a system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 10:12:00.043399+00 {} 2024-03-12 10:12:00.042488+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} +c9a70db7-55e2-4bec-99c7-fad51b4cbefb login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"next": "/if/admin/#/crypto/certificates"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "GET", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}, "auth_method_args": {}} 172.20.0.1 2024-01-11 22:06:44.865953+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2025-01-10 22:06:44.865331+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} 509eaab6-93c5-402d-bcac-2236f3abed19 system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 10:17:00.031189+00 {} 2024-03-12 10:17:00.030686+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} c00c8441-beb6-4486-94fb-1e908df60050 system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 10:22:00.032774+00 {} 2024-03-12 10:22:00.032273+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} c3738939-74b3-46a0-9599-e5675a21fddf system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 10:27:00.033048+00 {} 2024-03-12 10:27:00.032527+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} @@ -2921,12 +3092,15 @@ f581e71a-734f-4d1c-9632-e7de00452063 system_exception authentik.root.celery {"me b9f9a80a-54c0-4e99-b1b3-dbdb61a8a8e1 system_exception authentik.root.celery {"message": "Traceback (most recent call last):\\ndjango.db.utils.InterfaceError: connection already closed"} \N 2023-03-13 10:55:02.921301+00 {} 2024-03-12 10:55:02.920999+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} 4306c39d-89fc-439d-afa7-92e599477417 system_exception authentik.root.celery {"message": "Traceback (most recent call last):\\ndjango.db.utils.InterfaceError: connection already closed"} \N 2023-03-13 10:55:10.307515+00 {} 2024-03-12 10:55:10.30681+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} 843f8709-4e62-41b8-a98b-213645931ca1 system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 10:57:00.032694+00 {} 2024-03-12 10:57:00.031958+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} +cf434072-8f63-4112-acae-f8bb01a43709 login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 172.21.0.1 2023-03-13 12:15:42.997603+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2024-03-12 12:15:42.993087+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} 62899e67-5886-49ee-88a7-8c664e2f746d system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 11:02:00.034669+00 {} 2024-03-12 11:02:00.034074+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} 387c9985-3baf-4cd2-ad89-a64642510aaf system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 11:07:00.025306+00 {} 2024-03-12 11:07:00.024832+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} 4f3a4faf-c73b-40ab-9d4c-27a91a60fd69 system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 11:12:00.037904+00 {} 2024-03-12 11:12:00.037441+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} d1014f1d-f907-4ed8-bb64-50fece7cc94b authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": "SAMLRequest=nJJBb9swDIX%2FiqC7I9lu40aoDWQNhgXotqDJdtiNkZhGgCx5Ir2t%2F36ImwEdMPiwoyS%2B94mPvCfow2DWI5%2FjE34fkVj86kMkc3lo5ZijSUCeTIQeybA1%2B%2FXHR1MttAEizOxTlG8kw7xmyImTTUGK7aaV3hUrfTy6m2bpdLVcVWWDDdjbu5uycqfG4hJq3cDtCWspvmImn2Irq4WWYks04jYSQ%2BRWVrqqC10XZX0oK1PeGV0uVk39TYoNEvsIPCnPzINRKiQL4ZyIzUprrWAYgrdTibq0oJ4znCBCMR2Ikjr66Hx8Vhmdz2hZSbH%2B0%2FxDijT2mPeYf3iLX54e%2F8GpL5zJDixJsbum8O7Vdz6yK5zMh8NhV%2Bw%2B7w%2Bym8ZmpgyyeJ9yDzxvcrnxrjhNpQYje36R3cw%2Fe2RwwHCv3qC667p8gh63m10K3r78B54zRPIYWYp1COnnQ0ZgbCXnEaXqXpF%2FL2X3OwAA%2F%2F8%3D&RelayState=YWRYcTJyV01iNlVkY2R2VWZnVFpHRkVJNlNlM0lIYzM3bmVqWjJwTVd6dWtab2QwYzd8Y2VlMzM1M2Mt&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&Signature=lUlHgH7IwMv%2F04xulllUtloFIwLWu2r%2FuyoXy4FHZ68ofaEEWzof2L7vMBv8aI3QQ91jI6eeQUjIBLO0HqaSUpadpp8ap%2Bm4cOsilBImRjoOIqpLsukzdBm4S7u1tjO5OqmeQaJmdZuBfwAWemiBEKp0DW1VCHsEDV9th%2BK%2BrptQAhVb8gfVmqRsYY13NZav0ZQOEHu7thDoDfVwQ1l6e6htxcpUFMfQvIB%2BQfB1aA5AAocVylIW1f%2B%2BtMNArDGW4VFAUpBga6lafl6cdSNhxRjPvVEdwAkLK%2BlKvhirGCM44R7ayQBTTIlrdfDaFKl3BEY8XuAnV2eQT6xSERBDyVpunPrZObQqFIp93%2Bm8vovmq8A6xNBHzkmwG%2BWPR4SoVoxIygRogkLuSxi%2B3RtXvRna7vs89sLyZgcIzDLAe6xMt%2FYHphlCvpM3yv83VqmQtPVhFKqQmqaeeJEGtB7JE5P4HK0fPReRXY65zFUeDDYpPrRbUjerI6YJogJqasG9UdZC%2BJXTW%2BAx3eNIpteqehGRXJP9FAbXUVgACS2%2FEUUJy%2FmgxkVHpK%2FUotX0DL50oHCmt0XCFMpWCUZMCXwMFXHEuO%2F%2BpSUqPOkVhX%2Bz3iWKCnUpHgF73UAbjUtgfXpA%2FGo4N3MyoDX1sNbQqPLai5iCZTRwdObBgsgQn%2FXPegw%3D"}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.21.0.1 2023-03-13 12:18:02.192895+00 {"pk": 8, "email": "authentik-editor@localhost", "username": "authentik-editor"} 2024-03-12 12:18:02.192123+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} 2bb35f8d-322c-4193-b09d-32aafac220b7 system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 11:17:00.043594+00 {} 2024-03-12 11:17:00.043125+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} 076a18d6-6d9b-4524-b619-cc4a0a6b5f94 system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 11:22:00.029942+00 {} 2024-03-12 11:22:00.029491+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} +05424407-2396-4a74-86e4-6b6833846bcf login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 172.21.0.1 2023-03-13 12:16:00.769208+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2024-03-12 12:16:00.765898+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +51e69755-68bd-42de-817c-bd744ea221df login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 172.21.0.1 2023-03-13 12:16:25.296145+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2024-03-12 12:16:25.291452+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} 93da91ee-16b7-4bd8-b05f-ed07b6f9e8b6 system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 11:27:00.027139+00 {} 2024-03-12 11:27:00.026677+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} ad4eb94d-1d1b-4e70-b8dc-9f60371a3076 system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 11:32:00.028541+00 {} 2024-03-12 11:32:00.028065+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} eb523ba5-1b16-45bb-8ae8-7a40eb463c30 system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 11:37:00.048995+00 {} 2024-03-12 11:37:00.048385+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} @@ -2944,11 +3118,9 @@ cef79b5a-c90c-4d64-b140-c9a302f31a8e system_task_exception authentik.events.moni 39052e69-2537-41c7-9709-8170094bb2f0 authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": "SAMLRequest=nJJPj9MwEMW%2FiuV7asdR%2F1mbSGUrRKUFqm3hwG3WmWwtOXbwTID99ijZIi0S6oGj7Xnv53kzdwR9GOxu5Et8xO8jEotffYhkp4dajjnaBOTJRuiRLDt72n18sGahLRBhZp%2BifCMZbmuGnDi5FKQ47Gvp26JbbzfVGio0K72BrTGlgW6jn1bGadDLzpW47tp2JcVXzORTrKVZaCkORCMeIjFErqXRpip0VZTVuTS23NjSLJbb5Tcp9kjsI%2FCsvDAPVqmQHIRLIrZbrbWCYQjezSVqakE9Z%2BggQjEfiJJ68rH18VllbH1Gx0qK3Z%2Fm71Okscd8wvzDO%2Fzy%2BPAPTjVxZjtwJMXxmsK7V9%2FbkV3hZD%2Bcz8fi%2BPl0ls08NjtnkMX7lHvg2ybTzZT2XGoxsucX2dz4Z48MLTDcqTeo5roun6DHw%2F6Ygncv%2F4HnDJE8RpZiF0L6eZ8RGGvJeUSpmlfk30vZ%2FA4AAP%2F%2F&RelayState=NzhJeXNnVnNZZDZKRDd3Q3BLVmFJa3hSaXRtOTlFdjJ2ZE8xaDQ0VlE4OVhlMGVjVE98YThlZWJkMDgt&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&Signature=puFkhzy3Z5ac%2F1r3XdkaI0iEal2x1%2F0VLcr70BnL2IzjKUZifOxWx5laZoeWQnLdh1kKv1Nwnn32R53gOQkY4ofvAN81RaOB6vuJLTupOqA8XVkv2B%2Fs%2BwpfZrYgpUbxMVVJCzasr15e5%2FLd8uvDbdL6EBKJuVqhKfEP1T%2FH%2Bgnjc8WnW6yPJpAzQa3VuciZCBxkyq7SWfZabfADGwrF7PYzUniCOzXco%2FCHPuqgoPi5DNg32g4UxkgZmgCum%2B16Hf0hcT3eHUFuCkn5CA4PnnmWeDJeWiy%2B8yiYthZcfZAsZ1d0ngr58nmn9%2B3pbq5ICAC6ebpQci5NuY1nH4h%2FabMCJQsS1RDlkVpQdfWImyY3KcrgXavDKmqlWZ66dPGOP94LiFrB6hvGCrgPujvxnMUF3q54I9m2DGI9n5NA9Tx6rvClljP35bjMHV16H%2BzKsmPdInvmXtSJCf1OQC4GHAnGXsHTKLOCXqEQGFcb%2BwXVMGCCpXcguc4pWxd4wgMhc18bTANKN5%2FzlXGFWQyHLFMwC5iIuJFll4DKGC0gZTlixuGPjyiBEkCWtIIq8CZkSvKp6Dcx1CdC8gsZhJUxWxVws8IgfrL2TpL9AHlWOjZEWPYA8GiZF%2B9Cuyt%2F%2BZZCxoHl2ISx1CMR46pXirmSKWU3sAB3qDXHuv%2FaXxIt5tc%3D"}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.21.0.1 2023-03-13 12:18:12.851482+00 {"pk": 8, "email": "authentik-editor@localhost", "username": "authentik-editor"} 2024-03-12 12:18:12.85073+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} 0f288bb0-3131-4b6b-bd13-781b1fc8f7f1 system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 12:07:00.029026+00 {} 2024-03-12 12:07:00.028562+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} cea6cb21-9afe-4f32-9daa-5f5e65bc6d8c system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 12:12:00.033447+00 {} 2024-03-12 12:12:00.032956+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} -cf434072-8f63-4112-acae-f8bb01a43709 login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 172.21.0.1 2023-03-13 12:15:42.997603+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2024-03-12 12:15:42.993087+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -05424407-2396-4a74-86e4-6b6833846bcf login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 172.21.0.1 2023-03-13 12:16:00.769208+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2024-03-12 12:16:00.765898+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} -51e69755-68bd-42de-817c-bd744ea221df login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 172.21.0.1 2023-03-13 12:16:25.296145+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2024-03-12 12:16:25.291452+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} 5c20ff33-9685-41f9-a1a2-db46d9e4f5d9 authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": "SAMLRequest=nJJBb9swDIX%2FiqC7K0V201ioDWQNhgXotqDJdtiNkZlGgCx5Ir2t%2F36wmwEdMOSwoyS%2B94mPvCfow2DXI5%2FjE34fkVj86kMkOz00cszRJiBPNkKPZNnZ%2FfrjozU32gIRZvYpyjeS4bpmyImTS0GK7aaRvitOZVXWxxpcZW67FTiHtTtWGmpcLQ3cGaPvXF2fllJ8xUw%2BxUaaGy3FlmjEbSSGyI002pSFLotFeVgYu1ja6vZmVa2%2BSbFBYh%2BBZ%2BWZebBKheQgnBOxrbXWCoYheDeXqKkF9ZzhBBGK%2BUCU1NHHzsdnlbHzGR0rKdZ%2Fmn9IkcYe8x7zD%2B%2Fwy9PjPzjlxJntwJEUu0sK7159r0d2gZP9cDjsit3n%2FUG289jsnEEW71Puga%2BbTDdT2nOpxcieX2R75Z89MnTAcK%2FeoNrLunyCHrebXQrevfwHnjNE8hhZinUI6edDRmBsJOcRpWpfkX8vZfs7AAD%2F%2Fw%3D%3D&RelayState=VElyVjNaN2pnbDRVd1RWM29FM2x5MVZuVlBkY2N0WXBoZ01ET2Z4d2RKU2dOUE10ZFF8YmFjMjQzNDIt&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&Signature=b5zxgS%2FAfwENvPrKFQIwHF04kGrruQL8VG3I15mtw7IOTkKWg%2BBZ6MuhG7C%2Bc4XNQfqTNtYwHNCr8FFd3STN1%2BsLv7NaBdcHjA%2BPAOZWrZc%2BsqB%2FFBpH6rWmB667UNcC9sTtlX1XS9O5vAJ%2Fln3Hb3pgV5r%2FEXcyrC1KSTiLSz6Xt6fqqopH6P7b%2FBOcAiWqvpt4J10UxXsC3huufmdg0MSGJO4ROun5dyL2NjxgASre4q9B2ka4pN3QaFdv5LOV9ptCd6EU5WJkc8rGVkvpGm2OdlrdxJi0jJy951%2FomKNGzfuR%2Bv6gWONM7aUoYUJbTKBibdC65EFVoNX%2BFnZIli9%2FK2zr00IoLqsyWkVpqZi4ziSevs%2F7XK4Hx4bFQ39E94Cs79uc6Mv1xDYa3Vf4grFkK99UUGoPBPgVrHOCRDt7SGw66ugSMktBFr7XPzJiTz%2B9Ue%2Fv4spp9J%2BuyQc1bdVFcgXIfBXwfomYvr%2BtPvc8gsIZJq2DFABStFX7OuaJOe5nKqhQuvfGMUlpFEePSOlTv2FhkH2ANMX0W%2B30pZjiOA1B7wthS6FrGJ9S6DVxqzvmfCzHFtMYPzOkLnE2hTNTjVIv%2FF5%2Bcsh8a4KT9o7KRAhXHbnrbyKA%2F1CKKJS%2FJfml7dG8eUOnGLg5BlQfTr4OS8DxxyHtFLykHPvfSQI%3D"}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.21.0.1 2023-03-13 12:16:46.200868+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-03-12 12:16:46.200309+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} bc3357f2-1df7-47bd-8fa2-e853e44df893 authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": "SAMLRequest=nJJBb9swDIX%2FiqC7I1v2mkaoDWQNhgXotqDJdtiNkZhGgCx5Ir2t%2F36ImwEdMPjQoyS%2B94mPvCPow2DWI5%2FjI%2F4YkVj87kMkc3lo5ZijSUCeTIQeybA1%2B%2FWnB6MXpQEizOxTlK8kw7xmyImTTUGK7aaV3hUNNEtcHt%2FBaalRQ31cVo11VVNXK32ra8TjyTl7C1J8w0w%2BxVbqRSnFlmjEbSSGyK3Upa6Lsi6q%2BlBpU92YZrW4KevvUmyQ2EfgSXlmHoxSIVkI50RsVmVZKhiG4O1Uoi4tqKcMJ4hQTAeipI4%2BOh%2BfVEbnM1pWUqz%2FNn%2BfIo095j3mn97i18eH%2F3DqC2eyA0tS7K4pvH%2FxnY%2FsCifz8XDYFbsv%2B4PsprGZKYMsPqTcA8%2BbXG68K05TqcHInp9lN%2FPPHhkcMNypV6juui6focftZpeCt89vwHOGSB4jS7EOIf26zwiMreQ8olTdC%2FLfpez%2BBAAA%2F%2F8%3D&RelayState=VDBZcDVuczVpbVhzTlFMaXg0Y2k5UUk1b1N0M05LQ1YwaHVhcXlrZVJHdDBITTluOUR8ZDMxZDgxNzIt&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&Signature=wUqtpzGasm1xm0%2B21lZLUOer33uK6YPH6MphpRPlGp8veCavKzKyr%2Fk%2BC9%2Fy6YqUScqYLmFd8joiRTWq1l0rZ1o6olA8Hq1%2FTyTx6OzuzbVV9qPd6hCIqfNFgNhfSjFLXolj079hkVqzs43nILWLyUb%2FH7bfLDu6yGPqndwpuvnZiRR4R3gYtkkTKsDvA%2B2BqskaLiW%2B%2FbMVaunroA0feuPWYlS6sO9gEwN%2BmNd2y8Ny9YmTBh8D8kwUrjasqsj7VwUQvA%2FPBxHrVY1qE%2B%2Bc2a0X0JOm4eBOvrAbuUGC58vZykC%2Fo7ub%2B2iaEsuPZow9vvJdYKzXUZSrYbRIdDBNOmkBpmA4QllPjV1vEdgDCNrGWmUPU7fB4xtLyoc2X0%2Bx010L1t9Z2QuLm3lQm8pK11oBzsiMcfzXBEbdZg%2BkPTQFL2szutYG3f2V4QmSUjQhDuphAKs99%2FUunI57Qn5rrck5cBmM0J97xWzNPzceIO0uamESgaBiteqw2ywBYgxpD0vTfc7VonPXpymR6z%2BjA0UPgslrQgqZxjAdHdPACdYKdHLfFIsRLAtyzHY%2FFkh5z6lmEqT9%2BGENv%2FGph%2FRxshU3Fhj4VQaT%2FHbkz6BOug2HUAbt8WiCgp4bwd8qSYAQLKSxXButM%2BLMofSt7GHhgdW2xMGurtNGqUhjX2jDFUo%3D"}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.21.0.1 2023-03-13 12:16:49.912109+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-03-12 12:16:49.911609+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +2fe33328-90f8-4505-917b-5a6ba069ef79 model_updated authentik.events.middleware {"model": {"pk": 1, "app": "authentik_core", "name": "authentik Default Admin", "model_name": "user"}, "http_request": {"args": {"next": "/if/admin/#/crypto/certificates"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "GET", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}} 172.20.0.1 2024-01-11 22:06:44.879979+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2025-01-10 22:06:44.879476+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} a6bcd075-2241-45f4-93c3-ba4b69af2918 system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 12:17:00.03235+00 {} 2024-03-12 12:17:00.031758+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} 85b9d791-85e5-4c24-8c4d-36583538b4fa login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 172.21.0.1 2023-03-13 12:17:03.682721+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2024-03-12 12:17:03.67834+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} 2597fa7f-1be7-4636-9165-04f0b2dd813b login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "next=%2Fapplication%2Fsaml%2Fgrafana-saml%2Fsso%2Fbinding%2Fredirect%2F%3FSAMLRequest%3DnJJBj9MwEIX%252FiuV7aidhCbE2kcpWiEoLVNvCgds0mWwtOePgmQD771GzRVok1MMebc97n%252BfN3DKMYXLrWU70gD9mZFG%252Fx0Dszg%252BNnhO5COzZEYzITjq3X3%252B6d8XKOmDGJD6SfiGZrmumFCV2MWi13TTa95mtclu%252BHfI%252BH4ayACxsVeRV%252FabEfKhtfQMW62N%252FBK2%252BYWIfqdHFymq1ZZ5xSyxA0ujCFmVmyywvD3nh8srZavWuvPmu1QZZPIEsypPI5IwJsYNwiiyuttYamKbgu6XEnFswjwkGIMiWA3M0R0%252B9p0eTsPcJOzFarf82fxeJ5xHTHtNP3%252BHXh%252Fv%252FcMozZ7GDjrXaXVJ4%252F%252Bx7PbILnN3Hw2GX7b7sD7pdxuaWDJL6ENMIct3kfOP7bFhKHZJ4edLtlX%252BOKNCDwK15gWov6%252FIZRtxudjH47ukVeElA7JFEq3UI8dddQhBstKQZtWmfkf8uZfsnAAD%252F%252Fw%253D%253D%26RelayState%3DM01VdDk4TzhnSWtVMjBDTnlWb2FZZ05GOWZjUmxSNU41Sk1UbXdVcUxHcmM1dmJac2t8YWI3YjFjYTYt%26SigAlg%3Dhttp%253A%252F%252Fwww.w3.org%252F2001%252F04%252Fxmldsig-more%2523rsa-sha256%26Signature%3DW1uiWN6vvpGfU5nA49tvVbhWp71UjMbe%252Bsao3uLOeCYRCdJAxe22GIdpdViY8iuKSWISmNRMIDp3oWlTmbg5leMgWtO1wJxdk4AxnH8dLcMBhpNhhr8VD3QvQsJZfEMPBeWDa4IeFhfn%252FCL7gBKFHi5%252BNfzZuFUESyGQ0ZhRezPyff9pSPaZacYe%252FlAB%252FFQcejoGxBBohsbeVd5OMwYy0UVPmcgIWvp72%252FYYdMY5NEpre5tbQ9KQY1TlzMHKZAbVqfdPODKxobEXYuZBtEjrRzTJJP9iEpj4XXNEezg8vYOsAeex8Iagke2pSOa%252Bg741XW6HmEWDYNF3lksMd7f7lOP8b6MbcVeVUgZcF7s5XcSPYLuTelEruz%252BJgBGbtquqmUMidbeWAfvF4oiYcPnjncwToTSRa0EE1c%252BLxJaVwBAfDOYTRkYXb7BAg19tHQ8eMpG6JaQqdWY0OSabb2%252FGObiY9SMCjqgHFVYesRm9hn5Sv5nofAjjSxnQf69WiPCNTENznRZ%252BCYezWfEkXeYvh2Qk830%252BA24FHHRUZ4mvjlWwd9fFGuxcIy88LVkFfo5MmR%252FzrXZuPOLY5hRsBo9bHTbxxmYIW9HV8Q3UxO%252BuWnvYzhF3luBdePoxi%252B%252FDy%252B%252BrVaG1VfVUx89qVwgNcksp8C8FtkXO88t%252F1sIp8Hu0I3w%253D"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 172.21.0.1 2023-03-13 12:17:27.490802+00 {"pk": 8, "email": "authentik-editor@localhost", "username": "authentik-editor"} 2024-03-12 12:17:27.490352+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} @@ -2963,6 +3135,7 @@ ec35d60f-97e1-4eed-b11c-12e5388aabb9 system_task_exception authentik.events.moni 53020bf2-b2f1-4a00-8106-f068bfd43302 authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": "SAMLRequest=nJJBbxMxEIX%2FiuX7xo69AWp1VwqNEJEKRE3gwG3inTSWvPbimQX671G2QSoS2kOPtue9z%2FNmbgn6OLj1yOf0gD9GJBa%2F%2B5jIXR4aOZbkMlAgl6BHcuzdfv3p3pmFdkCEhUNO8oVkmNcMJXP2OUqx3TQydJXuTvZtZ9HbI5qVwdU7U4O2Gur6ja6N8X5p9MkfpfiGhUJOjTQLLcWWaMRtIobEjTTa2ErbamkPS%2BOMcfVysTI336XYIHFIwJPyzDw4pWL2EM%2BZ2N1orRUMQwx%2BKlGXFtRjgRMkqKYDUVbHkLqQHlXBLhT0rKRY%2F23%2BLicaeyx7LD%2BDx68P9%2F%2Fh2AtnsgNPUuyuKbx%2F9p2P7Aon9%2FFw2FW7L%2FuDbKexuSmDIj7k0gPPm1xuQledplKHiQM%2FyXbmnz0ydMBwq16g2uu6fIYet5tdjsE%2FvQLPBRIFTCzFOsb8664gMDaSy4hStc%2FIf5ey%2FRMAAP%2F%2F&RelayState=MUZpWUxjVUNYRUttcTdLaXRqVWhadTBkVzFmRUJhUWUwaWdkNHRRcmhsQWk2NXhzNnN8YjY3OGJlODMt&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&Signature=iEae0yzkwmmEGk7goszwfJQvGB887jfnfszCokTmcnDBxT2FvWwYM%2FQ3chK3nvYHi%2BpmVVQmlFOFeq%2But0c8zbvSHz8oDkLy%2F1aIvQejRm%2B6HOJXxKfscKwYMOczyKD%2BBFTz6qEwiqBXeCmK8ab9jRwgt4NCz77aN%2Fx320cFZFOeK%2F8MQzUj%2FgAW1l1kzFe0eK4fQ2WQqRydvYtUcUMk%2FMfFQZZL%2BysvS8AnGVt15V13ZE6b%2BaM37F%2B726vOBqjyse99cLpZsY08P6PIKr7duElXND25x1jMyqC1hEEOBCzSj9Rd9be4elzUWMDRLPzEpPgLVD05cdvt64Dp%2FIP05HFW036Xt8A37azAfr5gulR%2B1ZDlsNXbnCHAHjVSfuAiS31%2B%2B%2BwPMwCk5hWInP5b8D7uCR989om153iD61cJgUrUHdBknhikTn6hhGahKNLVf6uDNcDiqdfcAEzgnaMB2Hy1MxTpz3Ge4nmXj6wK2%2F%2BgDhPTu6JayscchroBZPt7U4oN6ryuVcuSuOToqVJvhMilGUzMP4a9yeHqv6qk7T9egBBfwIrC%2FQ%2FsfNblceIDUTLomxw3gahf0Qb1JxmsuTqCYYHSJiddmBhMUzQ4iEMjz8tqEFWo6pJZFE1SrQATH7mXEB3Ki6%2FwkyBYPq6KHV36nDYhKJT34kWcrsaoRiA%3D"}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.21.0.1 2023-03-13 12:22:41.756407+00 {"pk": 8, "email": "authentik-editor@localhost", "username": "authentik-editor"} 2024-03-12 12:22:41.755874+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} 33883ab6-55eb-40af-bd68-6f1b00a360c4 authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": "SAMLRequest=nJJBb9s%2BDMW%2FiqG7I1VK%2F4mF2kD%2BDYYF6LagyXbYjZXoRoAseSK9rd9%2BiJsBHTDksKMkvvcTH3lHMMTRbiY%2BpUf8NiFx9XOIiez5oRVTSTYDBbIJBiTLzh42Hx6sXigLRFg45CTeSMbrmrFkzi5HUe22rQi%2BXprGm1vjV6s1%2BnWz7lWv%2FNo40zw12rhbpVbw37JfiuoLFgo5tUIvlKh2RBPuEjEkboVW2tTK1DfmeKOt1na5Whijv4pqi8QhAc%2FKE%2FNopYzZQTxlYtsopSSMYwxuLpHnFuRzgR4S1POBKMunkHxIz7KgDwUdS1Ftfjd%2FnxNNA5YDlu%2FB4efHh79wzJkz24EjUe0vKfz%2F6ns9sguc7PvjcV%2FvPx2OopvHZucMSvUulwH4usn5Jvi6n0stJg78Iror%2FxyQwQPDnXyD6i7r8hEG3G33OQb38g94LpAoYGJRbWLMP%2B4LAmMruEwoZPeK%2FHMpu18BAAD%2F%2Fw%3D%3D&RelayState=anIybTVpRXpNb1M1cTY1STZkbTg5MnFEQ2Q5eUhyS3RmMnlhWDZrMWhtRVNCV3dieTJ8ZTU1N2E0YzIt&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&Signature=Hg4HrLcQEHmjmUoBFkreDfipeHYL03BpwjjG0oErIkjegN5cvJeK6vOKgMuecGSt07zBYnOZ7w4%2BFbztzlUA9Ck1Z4939R8cWdIHwwXWq4EBCIANuRo83ae7igPfWK%2BAa%2FRRcVRsoHwpJC0Mj5Icz4obi6JYrQDy3YdY0jwRa7d5NDiYdmi3zmF2O4gBZqZmrIdJPeDGmF%2F6Ut3%2Bbpkw8Hkc%2FVSXohyNRsedQXLT30Uc7KsNSrdfIqlhJlYFhtDLh3ZKGWXxeI3qsKDwwj%2BCmbuzk8ElQfokH1ZBievQCrZ%2FpJZnxDYWZ2ipmAZAm%2BojsE0gJCVLZT6PMMF%2FJeP98Q8Z35rVZ8fNgJ0F14OPaU%2FRad12rMTJdlnoAVAbxvIhO%2BezuftLg81VAeJmxmhqU0wPpxZjFv3c5jsC2w04QufYW6rGQ9Eqk9Aul%2BBM398CWXo2fdWqSdTK3R9k9Hu4OPMD%2BOE%2FY4TxF4aDDfoDnO2AjWz0aW0SgU4R8%2B1Mq03kZhz5WGJ%2BVV4rbvIZC3rmTU2Uu9PXSUXrFEKe%2Fh3hifwHiCfLiQd7%2FttY1syGI%2ByOjBTCpfEYc8Idbk%2BVZDuWwLHFKk6sJPZ3S65DWFY83uGSmYmhmci8NG9tpaFu47ZrTJ0XIf8uVrp7NbLuJpJQ428XqyWeyskr1QchFoA5gK0%3D"}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.21.0.1 2023-03-13 12:22:47.615052+00 {"pk": 8, "email": "authentik-editor@localhost", "username": "authentik-editor"} 2024-03-12 12:22:47.614547+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} a3a94a54-ad14-44e1-b3fa-82daeded38cc authorize_application authentik.providers.saml.views.flows {"flow": "a7ef54bb79594559979d9345c022e086", "http_request": {"args": {"query": "SAMLRequest=nJJBb9swDIX%2FiqC7I1lu6lqoDWQNhgXotqDJdtiNkZlGgCx5Ir2t%2F36ImwEdMOSwoyS%2B94mPvCcYwmhXE5%2FiE36fkFj8GkIke35o5ZSjTUCebIQBybKzu9XHR2sW2gIRZvYpyjeS8bpmzImTS0GKzbqVvi%2BwPkBVHg8llDfQGF0v4Q7uqmZZNb3Dm6o2eHtsbkGKr5jJp9hKs9BSbIgm3ERiiNxKo01V6Kooq31prDF2qRd1WX%2BTYo3EPgLPyhPzaJUKyUE4JWLbaK0VjGPwbi5R5xbUc4YjRCjmA1FSBx97H59Vxt5ndKykWP1p%2FiFFmgbMO8w%2FvMMvT4%2F%2F4FRnzmwHjqTYXlJ49%2Bp7PbILnOyH%2FX5bbD%2Fv9rKbx2bnDLJ4n%2FIAfN3kfOP74jiXWozs%2BUV2V%2F45IEMPDPfqDaq7rMsnGHCz3qbg3ct%2F4DlDJI%2BRpViFkH4%2BZATGVnKeUKruFfn3Una%2FAwAA%2F%2F8%3D&RelayState=M3pwWm9kRldFeml2eThQVHBQcGI1NGp6Y3VCdGE2QU4yY0Eyc05OVlZzS1hlZEpsVEx8ZmFkNjlmYzEt&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&Signature=AMKBlDIOTlPlZhLhBMQnT6S1%2FPH0luAJKcoJDqnljva%2Fc8y8ttw8hyj9%2FsZ66ghIVdelzLHGYf0zHhnQpjLTnOlss1XKigzxLfzcW4VpNzb87x%2Bo4XgtKVQg41XYUiidoaY7WhaMViAlXit93lAoSVxLSRf2bdVMKAUdze1W2PHZLzNFWb8le%2FkIc1RL9am%2FqKHPVkV7dgaZYzsGjID1buVA8nES5AbeFEa13ncP4BgqlUOhBeYnPO7uY7g6UDXOrGFpsVbg7NCy61ERbU1YZgUUcVB%2F%2Fzuyl%2Bx9oGwAnE%2FcGQ62NWS4MgcUSyC9fUhm%2Bdh1FvCPpLbmh3aAFHwJgfW1zrLZqosu2vgDWdPl%2FFy8GhrPQtxHpUsmminQqVWuB03J5RL5faXuV8KbMSyHEKBYrPL8TosM5AUC8Myc3ofAWmoSnBhHHPDjo%2FGuvjziRCyknuIgMwXASS0fOJonaYJyYGwHNOFYdRhMvP0h40y5sbZZEsDQOZI0x6PkGX41rHfsB2K6nUshcKwr7vxf8DpBblmCgby7ikD11ZQaM32MKv9Nuy91jQ2ukPBlXTOZy2fUKokK0EHs%2Fu7W1%2FB6ZlWSCyC2B0Vk7CsG2WILsRo6U%2Fhpzn9Stka1y0L59k4%2Bj6yErQsSWny06kEaCiaS%2B1Os6Epib00K8q8Mhkf8swk%3D"}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "506b118040694dda9405e185f355c34d", "app": "authentik_core", "name": "Grafana SAML", "model_name": "application"}} 172.21.0.1 2023-03-13 12:22:50.948601+00 {"pk": 8, "email": "authentik-editor@localhost", "username": "authentik-editor"} 2024-03-12 12:22:50.94811+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +4bd7b207-ae32-4c25-9122-1aa0c86f429a login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"goauthentik.io/outpost/ldap": "true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET", "user_agent": "goauthentik.io/outpost/2023.10.6"}, "auth_method_args": {}} 172.20.0.8 2024-01-11 22:11:15.836471+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2025-01-10 22:11:15.832207+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} 6e9edd91-9db5-4c23-9bcf-66a796709859 system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 12:27:00.034068+00 {} 2024-03-12 12:27:00.033459+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} 9cebce0e-a584-4b23-b496-b45994aa0097 system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 12:32:00.030109+00 {} 2024-03-12 12:32:00.02958+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} 6d9f7727-b1eb-4886-8bd1-8813397af8f2 model_created authentik.events.middleware {"model": {"pk": 3, "app": "authentik_providers_oauth2", "name": "grafana-oidc", "model_name": "oauth2provider"}, "http_request": {"args": {}, "path": "/api/v3/providers/oauth2/", "method": "POST"}} 172.21.0.1 2023-03-13 12:32:49.967325+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2024-03-12 12:32:49.966976+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} @@ -2972,6 +3145,7 @@ c319f061-18ad-4abe-a39d-8d952a64c49f model_created authentik.events.middleware { 34fe3cf8-47fe-4a75-bf99-74885f47b69e login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 172.21.0.1 2023-03-13 12:36:00.789499+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2024-03-12 12:36:00.784286+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} 637bc09b-af2c-4e11-9e01-e131d553a69b login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "next=%2Fapplication%2Fo%2Fauthorize%2F%3Fclient_id%3D43e8d2746fe2e508325a23cdf816d6ddd12e94f1%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A3000%252Flogin%252Fgeneric_oauth%26response_type%3Dcode%26scope%3Dopenid%2Bemail%2Bprofile%26state%3DKhwpxm9LZ-bo7yjuyBvGuTCM2Gi4pSXwJk8hCRWup-s%253D"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 172.21.0.1 2023-03-13 12:36:31.733073+00 {"pk": 8, "email": "authentik-editor@localhost", "username": "authentik-editor"} 2024-03-12 12:36:31.732702+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} 530a91a4-929a-4d8e-b2ed-8f26d4e36b0e authorize_application authentik.providers.oauth2.views.authorize {"flow": "a7ef54bb79594559979d9345c022e086", "scopes": "openid email profile", "http_request": {"args": {"query": "client_id=43e8d2746fe2e508325a23cdf816d6ddd12e94f1&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Flogin%2Fgeneric_oauth&response_type=code&scope=openid+email+profile&state=Khwpxm9LZ-bo7yjuyBvGuTCM2Gi4pSXwJk8hCRWup-s%3D"}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET"}, "authorized_application": {"pk": "d7dfa1d658cb4bfc94c538a2e507e290", "app": "authentik_core", "name": "Grafana OIDC", "model_name": "application"}} 172.21.0.1 2023-03-13 12:36:31.983378+00 {"pk": 8, "email": "authentik-editor@localhost", "username": "authentik-editor"} 2024-03-12 12:36:31.982723+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +cad2b7aa-cc81-4767-bba1-caa28a096461 model_updated authentik.events.middleware {"model": {"pk": 1, "app": "authentik_core", "name": "authentik Default Admin", "model_name": "user"}, "http_request": {"args": {"next": "/if/admin/#/administration/overview"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "POST", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}} 10.89.0.4 2024-01-10 13:37:34.033723+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2025-01-09 13:37:34.032188+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} 993af708-fc75-4a06-864f-54fe56c6a753 system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 12:37:00.030818+00 {} 2024-03-12 12:37:00.030297+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} 61565f70-33eb-4563-a7a7-a31a963087ee login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"query": "goauthentik.io%2Foutpost%2Fldap=true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET"}, "auth_method_args": {}} 172.21.0.1 2023-03-13 12:40:53.885265+00 {"pk": 6, "email": "ldapservice@localhost", "username": "ldapservice"} 2024-03-12 12:40:53.878318+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} fd92f349-4b74-4387-8f10-56a833ef3c44 system_task_exception authentik.events.monitored_tasks {"message": "Task clean_expired_models encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 451, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 207, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 57, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 202, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 734, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/core/tasks.py\\", line 38, in clean_expired_models\\n for obj in objects:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 394, in __iter__\\n self._fetch_all()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1867, in _fetch_all\\n self._result_cache = list(self._iterable_class(self))\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 87, in __iter__\\n results = compiler.execute_sql(\\n ^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1398, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 563, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 71, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\ndjango.db.utils.ProgrammingError: column authentik_providers_oauth2_authorizationcode.is_open_id does not exist\\nLINE 1: ...ntik_providers_oauth2_authorizationcode\\".\\"nonce\\", \\"authentik...\\n ^\\n"} \N 2023-03-13 12:42:00.041363+00 {} 2024-03-12 12:42:00.040909+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} @@ -3000,6 +3174,32 @@ f05f35f9-fbe7-4838-9728-284298dd4776 system_exception authentik.root.celery {"me 04362a89-dae5-4023-a99a-541668da1e9e system_exception authentik.root.celery {"message": "Traceback (most recent call last):\\ndjango.db.utils.InterfaceError: connection already closed"} \N 2023-03-13 13:55:02.882399+00 {} 2024-03-12 13:55:02.882148+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} a934eccb-bc35-45d4-8e2a-87eaca050a30 system_exception authentik.root.celery {"message": "Traceback (most recent call last):\\ndjango.db.utils.InterfaceError: connection already closed"} \N 2023-03-13 13:55:02.900363+00 {} 2024-03-12 13:55:02.900026+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} 7dc1bd11-f563-45a3-aa35-71e7699619cc system_exception authentik.root.celery {"message": "Traceback (most recent call last):\\ndjango.db.utils.InterfaceError: connection already closed"} \N 2023-03-13 13:55:05.125416+00 {} 2024-03-12 13:55:05.124542+00 t {"pk": "8ec6ecbff1624757a9c8360797195029", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} +18adc05a-2cc7-45eb-9771-8fdbb2e3f329 system_task_exception authentik.events.monitored_tasks {"message": "Task blueprints_discovery encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 477, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 275, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 60, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 270, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 760, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/blueprints/v1/tasks.py\\", line 145, in blueprints_discovery\\n check_blueprint_v1_file(blueprint)\\n File \\"/authentik/blueprints/v1/tasks.py\\", line 173, in check_blueprint_v1_file\\n instance.save()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/base.py\\", line 814, in save\\n self.save_base(\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/base.py\\", line 877, in save_base\\n updated = self._save_table(\\n ^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/base.py\\", line 1020, in _save_table\\n results = self._do_insert(\\n ^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/base.py\\", line 1061, in _do_insert\\n return manager._insert(\\n ^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/manager.py\\", line 87, in manager_method\\n return getattr(self.get_queryset(), name)(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1805, in _insert\\n return query.get_compiler(using=using).execute_sql(returning_fields)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1822, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 616, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 69, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/psycopg/cursor.py\\", line 737, in execute\\n raise ex.with_traceback(None)\\ndjango.db.utils.IntegrityError: duplicate key value violates unique constraint \\"authentik_blueprints_blueprintinstance_name_404be626_uniq\\"\\nDETAIL: Key (name)=(Default - Tenant) already exists."} \N 2024-01-10 13:34:47.947721+00 {} 2025-01-09 13:34:47.947069+00 t {"pk": "9a0465281f674272822bef895fae1b75", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} +359e7883-75a9-4840-a51a-37e75a921b8b system_task_exception authentik.events.monitored_tasks {"message": "Task blueprints_discovery encountered an error: Traceback (most recent call last):\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 477, in trace_task\\n R = retval = fun(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 275, in _inner\\n reraise(*exc_info)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py\\", line 60, in reraise\\n raise value\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py\\", line 270, in _inner\\n return f(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/celery/app/trace.py\\", line 760, in __protected_call__\\n return self.run(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/authentik/blueprints/v1/tasks.py\\", line 145, in blueprints_discovery\\n check_blueprint_v1_file(blueprint)\\n File \\"/authentik/blueprints/v1/tasks.py\\", line 173, in check_blueprint_v1_file\\n instance.save()\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/base.py\\", line 814, in save\\n self.save_base(\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/base.py\\", line 877, in save_base\\n updated = self._save_table(\\n ^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/base.py\\", line 1020, in _save_table\\n results = self._do_insert(\\n ^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/base.py\\", line 1061, in _do_insert\\n return manager._insert(\\n ^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/manager.py\\", line 87, in manager_method\\n return getattr(self.get_queryset(), name)(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/query.py\\", line 1805, in _insert\\n return query.get_compiler(using=using).execute_sql(returning_fields)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py\\", line 1822, in execute_sql\\n cursor.execute(sql, params)\\n File \\"/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/django/__init__.py\\", line 616, in execute\\n return real_execute(self, sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 67, in execute\\n return self._execute_with_wrappers(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 80, in _execute_with_wrappers\\n return executor(sql, params, many, context)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 84, in _execute\\n with self.db.wrap_database_errors:\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/utils.py\\", line 91, in __exit__\\n raise dj_exc_value.with_traceback(traceback) from exc_value\\n File \\"/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py\\", line 89, in _execute\\n return self.cursor.execute(sql, params)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py\\", line 69, in execute\\n return super().execute(*args, **kwargs)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n File \\"/usr/local/lib/python3.11/site-packages/psycopg/cursor.py\\", line 737, in execute\\n raise ex.with_traceback(None)\\ndjango.db.utils.IntegrityError: duplicate key value violates unique constraint \\"authentik_blueprints_blueprintinstance_name_path_982f03b6_uniq\\"\\nDETAIL: Key (name, path)=(Default - Source pre-authentication flow, default/flow-default-source-pre-authentication.yaml) already exists."} \N 2024-01-10 13:34:48.686741+00 {} 2025-01-09 13:34:48.686124+00 t {"pk": "9a0465281f674272822bef895fae1b75", "app": "authentik_tenants", "name": "Tenant fallback", "model_name": "tenant"} +d1f42ba6-00e0-446b-b170-9b822c4df6d8 login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"next": "/if/admin/#/administration/overview"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "GET", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}, "auth_method_args": {}} 10.89.0.4 2024-01-10 13:37:34.122453+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2025-01-09 13:37:34.121905+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +f3afa096-3cea-422d-8bc7-0b2117edde32 model_updated authentik.events.middleware {"model": {"pk": 1, "app": "authentik_core", "name": "authentik Default Admin", "model_name": "user"}, "http_request": {"args": {"next": "/if/admin/#/administration/overview"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "GET", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}} 10.89.0.4 2024-01-10 13:37:34.136251+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2025-01-09 13:37:34.135644+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +b5999b08-6c2a-4c7b-9356-263fd74f7b5c model_deleted authentik.events.middleware {"model": {"pk": "f0a78f88adc247e682c1d49e9e159329", "app": "authentik_crypto", "name": "authentik Self-signed Certificate_2", "model_name": "certificatekeypair"}, "http_request": {"args": {}, "path": "/api/v3/crypto/certificatekeypairs/f0a78f88-adc2-47e6-82c1-d49e9e159329/", "method": "DELETE", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}} 172.20.0.1 2024-01-11 22:06:53.231803+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2025-01-10 22:06:53.231151+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +8d921b9a-8cfe-4402-8949-7e73d53a931c model_updated authentik.events.middleware {"model": {"pk": 6, "app": "authentik_core", "name": "ldapservice", "model_name": "user"}, "http_request": {"args": {"goauthentik.io/outpost/ldap": "true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET", "user_agent": "goauthentik.io/outpost/2023.10.6"}} 172.20.0.8 2024-01-11 22:11:15.858579+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2025-01-10 22:11:15.85419+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +e6b62e94-cac4-4ab7-8cb1-d010fb1a1fd5 model_deleted authentik.events.middleware {"model": {"pk": "fd8c40c1e49148d1a26502ef85ea9a12", "app": "authentik_crypto", "name": "authentik Self-signed Certificate_3", "model_name": "certificatekeypair"}, "http_request": {"args": {}, "path": "/api/v3/crypto/certificatekeypairs/fd8c40c1-e491-48d1-a265-02ef85ea9a12/", "method": "DELETE", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}} 172.20.0.1 2024-01-11 22:06:59.939328+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2025-01-10 22:06:59.936003+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +a1d710c2-239e-400e-bad7-c53d6b3eb7aa model_deleted authentik.events.middleware {"model": {"pk": "6304c99590034fc49ba23d32016fb04e", "app": "authentik_crypto", "name": "authentik Self-signed Certificate-old", "model_name": "certificatekeypair"}, "http_request": {"args": {}, "path": "/api/v3/crypto/certificatekeypairs/6304c995-9003-4fc4-9ba2-3d32016fb04e/", "method": "DELETE", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}} 172.20.0.1 2024-01-11 22:10:04.13061+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2025-01-10 22:10:04.130083+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +5504bc47-2819-442f-b589-fbcba17e325c model_updated authentik.events.middleware {"model": {"pk": 8, "app": "authentik_core", "name": "authentik-editor", "model_name": "user"}, "http_request": {"args": {"next": "/application/o/authorize/?client_id=43e8d2746fe2e508325a23cdf816d6ddd12e94f1&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Flogin%2Fgeneric_oauth&response_type=code&scope=openid+email+profile&state=BT8hDLepiB6WQKgDYSIbOMG4bN3mam2JhqoQCA08LxU%3D"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "POST", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}} 172.20.0.1 2024-01-11 22:10:53.973197+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2025-01-10 22:10:53.971822+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +4305fdab-10dd-4d90-8f00-6ef86b4b2e2a model_updated authentik.events.middleware {"model": {"pk": 5, "app": "authentik_core", "name": "authentik-admin", "model_name": "user"}, "http_request": {"args": {"goauthentik.io/outpost/ldap": "true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "POST", "user_agent": "goauthentik.io/outpost/2023.10.6"}} 172.20.0.8 2024-01-11 22:11:16.787611+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2025-01-10 22:11:16.78303+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +47643d1d-1747-47c8-b588-8afb16426f56 model_updated authentik.events.middleware {"model": {"pk": "6304c99590034fc49ba23d32016fb04e", "app": "authentik_crypto", "name": "authentik Self-signed Certificate-old", "model_name": "certificatekeypair"}, "http_request": {"args": {}, "path": "/api/v3/crypto/certificatekeypairs/6304c995-9003-4fc4-9ba2-3d32016fb04e/", "method": "PATCH", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}} 172.20.0.1 2024-01-11 22:07:21.528806+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2025-01-10 22:07:21.528218+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +114e2a99-98b7-45fd-9625-96d8805161f4 model_updated authentik.events.middleware {"model": {"pk": 7, "app": "authentik_core", "name": "authentik-viewer", "model_name": "user"}, "http_request": {"args": {"next": "/application/o/authorize/?client_id=43e8d2746fe2e508325a23cdf816d6ddd12e94f1&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Flogin%2Fgeneric_oauth&response_type=code&scope=openid+email+profile&state=CgTPaX9JYavq4yJ5EWmgXQ9UEPhAp6TCumANg1b1jmw%3D"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "POST", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}} 172.20.0.1 2024-01-11 22:10:27.787705+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2025-01-10 22:10:27.7848+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +edd940c9-a07b-402b-9a70-87e5c4c31f74 login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"next": "/application/o/authorize/?client_id=43e8d2746fe2e508325a23cdf816d6ddd12e94f1&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Flogin%2Fgeneric_oauth&response_type=code&scope=openid+email+profile&state=BT8hDLepiB6WQKgDYSIbOMG4bN3mam2JhqoQCA08LxU%3D"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "GET", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}, "auth_method_args": {}} 172.20.0.1 2024-01-11 22:10:54.076153+00 {"pk": 8, "email": "authentik-editor@localhost", "username": "authentik-editor"} 2025-01-10 22:10:54.075325+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +95d31a52-acd7-48d6-afb9-ff48c48aef43 model_updated authentik.events.middleware {"model": {"pk": 8, "app": "authentik_core", "name": "authentik-editor", "model_name": "user"}, "http_request": {"args": {"next": "/application/o/authorize/?client_id=43e8d2746fe2e508325a23cdf816d6ddd12e94f1&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Flogin%2Fgeneric_oauth&response_type=code&scope=openid+email+profile&state=BT8hDLepiB6WQKgDYSIbOMG4bN3mam2JhqoQCA08LxU%3D"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "GET", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}} 172.20.0.1 2024-01-11 22:10:54.08931+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2025-01-10 22:10:54.088632+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +6315b883-7b38-46cf-a91e-14ef04761a1b login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"goauthentik.io/outpost/ldap": "true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET", "user_agent": "goauthentik.io/outpost/2023.10.6"}, "auth_method_args": {}} 172.20.0.8 2024-01-11 22:11:16.851466+00 {"pk": 5, "email": "authentik-admin@localhost", "username": "authentik-admin"} 2025-01-10 22:11:16.847575+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +5884597f-5ece-4a83-8171-d14b38f27227 model_updated authentik.events.middleware {"model": {"pk": 5, "app": "authentik_core", "name": "authentik-admin", "model_name": "user"}, "http_request": {"args": {"goauthentik.io/outpost/ldap": "true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "GET", "user_agent": "goauthentik.io/outpost/2023.10.6"}} 172.20.0.8 2024-01-11 22:11:16.869188+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2025-01-10 22:11:16.865921+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +c3258783-f555-4fd6-acb7-6a0a5768b37c model_created authentik.events.middleware {"model": {"pk": "3fcd5d6c3e4043bcb916c7cd3675d0f1", "app": "authentik_crypto", "name": "authentik Self-signed Certificate", "model_name": "certificatekeypair"}, "http_request": {"args": {}, "path": "/api/v3/crypto/certificatekeypairs/generate/", "method": "POST", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}} 172.20.0.1 2024-01-11 22:07:32.558046+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2025-01-10 22:07:32.557502+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +e5080aed-28b1-4c28-ac1d-e663d7de0062 login authentik.events.signals {"auth_method": "password", "http_request": {"args": {"next": "/application/o/authorize/?client_id=43e8d2746fe2e508325a23cdf816d6ddd12e94f1&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Flogin%2Fgeneric_oauth&response_type=code&scope=openid+email+profile&state=CgTPaX9JYavq4yJ5EWmgXQ9UEPhAp6TCumANg1b1jmw%3D"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "GET", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}, "auth_method_args": {}} 172.20.0.1 2024-01-11 22:10:28.148148+00 {"pk": 7, "email": "authentik-viewer@localhost", "username": "authentik-viewer"} 2025-01-10 22:10:28.145662+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +46c5aafd-ad92-45ed-ba31-d8de54fbdf04 model_updated authentik.events.middleware {"model": {"pk": 7, "app": "authentik_core", "name": "authentik-viewer", "model_name": "user"}, "http_request": {"args": {"next": "/application/o/authorize/?client_id=43e8d2746fe2e508325a23cdf816d6ddd12e94f1&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Flogin%2Fgeneric_oauth&response_type=code&scope=openid+email+profile&state=CgTPaX9JYavq4yJ5EWmgXQ9UEPhAp6TCumANg1b1jmw%3D"}, "path": "/api/v3/flows/executor/default-authentication-flow/", "method": "GET", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}} 172.20.0.1 2024-01-11 22:10:28.20553+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2025-01-10 22:10:28.202421+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +cbbc715b-9c98-472c-be6f-5859f99f4156 authorize_application authentik.providers.oauth2.views.authorize {"flow": "a7ef54bb79594559979d9345c022e086", "scopes": "openid email profile", "http_request": {"args": {"scope": "openid email profile", "state": "BT8hDLepiB6WQKgDYSIbOMG4bN3mam2JhqoQCA08LxU=", "client_id": "43e8d2746fe2e508325a23cdf816d6ddd12e94f1", "redirect_uri": "http://localhost:3000/login/generic_oauth", "response_type": "code"}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}, "authorized_application": {"pk": "d7dfa1d658cb4bfc94c538a2e507e290", "app": "authentik_core", "name": "Grafana OIDC", "model_name": "application"}} 172.20.0.1 2024-01-11 22:10:54.310719+00 {"pk": 8, "email": "authentik-editor@localhost", "username": "authentik-editor"} 2025-01-10 22:10:54.310134+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +ebc788aa-027b-4d78-94bc-d87d6fc1cfe6 model_updated authentik.events.middleware {"model": {"pk": 3, "app": "authentik_providers_oauth2", "name": "grafana-oidc", "model_name": "oauth2provider"}, "http_request": {"args": {}, "path": "/api/v3/providers/oauth2/3/", "method": "PUT", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}} 172.20.0.1 2024-01-11 22:08:00.123074+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2025-01-10 22:08:00.122462+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +161c7b5f-88fe-4930-9f0b-be5e4184a184 authorize_application authentik.providers.oauth2.views.authorize {"flow": "a7ef54bb79594559979d9345c022e086", "scopes": "openid email profile", "http_request": {"args": {"scope": "openid email profile", "state": "CgTPaX9JYavq4yJ5EWmgXQ9UEPhAp6TCumANg1b1jmw=", "client_id": "43e8d2746fe2e508325a23cdf816d6ddd12e94f1", "redirect_uri": "http://localhost:3000/login/generic_oauth", "response_type": "code"}, "path": "/api/v3/flows/executor/default-provider-authorization-implicit-consent/", "method": "GET", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}, "authorized_application": {"pk": "d7dfa1d658cb4bfc94c538a2e507e290", "app": "authentik_core", "name": "Grafana OIDC", "model_name": "application"}} 172.20.0.1 2024-01-11 22:10:30.227099+00 {"pk": 7, "email": "authentik-viewer@localhost", "username": "authentik-viewer"} 2025-01-10 22:10:30.220684+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +f3cdd7de-c03d-49f4-be44-2e5d0d982faa logout authentik.events.signals {"http_request": {"args": {}, "path": "/api/v3/flows/executor/default-invalidation-flow/", "method": "GET", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}} 172.20.0.1 2024-01-11 22:11:00.005003+00 {"pk": 8, "email": "authentik-editor@localhost", "username": "authentik-editor"} 2025-01-10 22:11:00.004272+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +1b2835c2-f1ae-4c30-8b34-252a286b3ac9 model_updated authentik.events.middleware {"model": {"pk": 2, "app": "authentik_providers_saml", "name": "grafana-saml", "model_name": "samlprovider"}, "http_request": {"args": {}, "path": "/api/v3/providers/saml/2/", "method": "PUT", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}} 172.20.0.1 2024-01-11 22:08:27.768122+00 {"pk": 1, "email": "admin@localhost", "username": "akadmin"} 2025-01-10 22:08:27.767604+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +302baacc-6574-4fc7-b476-a77456c33492 logout authentik.events.signals {"http_request": {"args": {}, "path": "/api/v3/flows/executor/default-invalidation-flow/", "method": "GET", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}} 172.20.0.1 2024-01-11 22:10:36.768574+00 {"pk": 7, "email": "authentik-viewer@localhost", "username": "authentik-viewer"} 2025-01-10 22:10:36.768045+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} +2d580ab2-8337-4cbe-9700-dda14b3ea05f model_updated authentik.events.middleware {"model": {"pk": 6, "app": "authentik_core", "name": "ldapservice", "model_name": "user"}, "http_request": {"args": {"goauthentik.io/outpost/ldap": "true"}, "path": "/api/v3/flows/executor/ldap-authentication-flow/", "method": "POST", "user_agent": "goauthentik.io/outpost/2023.10.6"}} 172.20.0.8 2024-01-11 22:11:15.749495+00 {"pk": 2, "email": "", "username": "AnonymousUser"} 2025-01-10 22:11:15.739789+00 t {"pk": "1b45e4ae6c1b4ea89691df11c97baeef", "app": "authentik_tenants", "name": "Default tenant", "model_name": "tenant"} \. @@ -3008,7 +3208,6 @@ a934eccb-bc35-45d4-8e2a-87eaca050a30 system_exception authentik.root.celery {"me -- COPY public.authentik_events_notification (uuid, severity, body, created, seen, event_id, user_id) FROM stdin; -b124e245-e4fc-457d-9171-329220ca358f alert Failed to verify signature 2023-01-02 16:20:28.230329+00 f aa49c5e8-b508-4046-907e-cfdca67edf75 1 \. @@ -3060,20 +3259,20 @@ COPY public.authentik_events_notificationwebhookmapping (propertymapping_ptr_id) -- COPY public.authentik_flows_flow (flow_uuid, name, slug, designation, policybindingmodel_ptr_id, title, background, compatibility_mode, layout, denied_action, authentication) FROM stdin; -c6e83269-3b5e-4f2a-8014-f198578240c1 Welcome to authentik! Please select a username. default-source-enrollment enrollment 63e07f88-3fd7-4180-807f-470bb3209bac Welcome to authentik! Please select a username. f stacked message_continue none -7c801f5d-8952-4b95-9f6c-385af22e798d default-oobe-setup initial-setup stage_configuration db71b00d-8d55-4ce6-a1a4-8fa8be93e810 Welcome to authentik! f stacked message_continue none -4ca3e92c-7f33-4d2f-bb5e-1efa4c6c902a Welcome to authentik! default-source-authentication authentication 181928f0-b530-4858-bee9-376597ac63ed Welcome to authentik! f stacked message_continue none -4b14409d-da92-4b6c-91e8-bad06d0966c2 Change Password default-password-change stage_configuration 65c208d9-e145-4259-bb87-deecf97c3643 Change password f stacked message_continue none -c4257191-cb7a-4120-9566-a38ba8a50248 Welcome to authentik! default-authentication-flow authentication 5be1e1aa-8051-417e-b03a-1a0e87cd8101 Welcome to authentik! f stacked message_continue none -1d2dd494-c62d-438a-8d0b-f864084f883c User settings default-user-settings-flow stage_configuration 71205979-10a2-41db-9e08-c05f1644f732 Update your info f stacked message_continue none 87ca9599-5323-451c-8603-2f5bee7a0c12 ldap-authentication-flow ldap-authentication-flow authentication 105415a2-7753-4a7f-bdf5-ed9841420289 ldap-authentication-flow f stacked message_continue none +4b14409d-da92-4b6c-91e8-bad06d0966c2 Change Password default-password-change stage_configuration 65c208d9-e145-4259-bb87-deecf97c3643 Change password f stacked message_continue require_authenticated +c4257191-cb7a-4120-9566-a38ba8a50248 Welcome to authentik! default-authentication-flow authentication 5be1e1aa-8051-417e-b03a-1a0e87cd8101 Welcome to authentik! f stacked message_continue none +2287d235-72aa-4d23-80a2-44278d7c7839 Logout default-invalidation-flow invalidation 7c8e66c0-7a12-42bd-901f-a88fcd742a5e Default Invalidation Flow f stacked message_continue none 35537410-5840-48ee-ba54-76f2a7d42617 default-authenticator-totp-setup default-authenticator-totp-setup stage_configuration 62e82cc0-d670-4f23-8d7b-14675757dd8c Setup Two-Factor authentication f stacked message_continue require_authenticated +1d2dd494-c62d-438a-8d0b-f864084f883c User settings default-user-settings-flow stage_configuration 71205979-10a2-41db-9e08-c05f1644f732 Update your info f stacked message_continue require_authenticated 0bcbad7c-01fc-4ed0-b3f1-a1671683d6b3 default-authenticator-static-setup default-authenticator-static-setup stage_configuration c4f758ad-0e74-4b1a-a1bc-32ae3a370b69 Setup Static OTP Tokens f stacked message_continue require_authenticated 611b3458-aadd-47c1-b37e-1940bc72ede7 default-authenticator-webauthn-setup default-authenticator-webauthn-setup stage_configuration 3d7a1eb4-72c2-44dd-bdf7-741750024ce2 Setup WebAuthn f stacked message_continue require_authenticated a7ef54bb-7959-4559-979d-9345c022e086 Authorize Application default-provider-authorization-implicit-consent authorization cba52268-0d9a-40bd-ae7f-b723f5e05671 Redirecting to %(app)s f stacked message_continue require_authenticated -3b01d5c4-0d46-4bfe-b37f-553a7b4d051c Authorize Application default-provider-authorization-explicit-consent authorization ab6b7aa3-82da-4972-9158-7b7fc70ab973 Redirecting to %(app)s f stacked message_continue require_authenticated +4ca3e92c-7f33-4d2f-bb5e-1efa4c6c902a Welcome to authentik! default-source-authentication authentication 181928f0-b530-4858-bee9-376597ac63ed Welcome to authentik! f stacked message_continue require_unauthenticated 7bd42149-dc52-445a-bd1e-4513c50d01dd Pre-Authentication default-source-pre-authentication stage_configuration 081ce9e5-201f-48e9-af60-7c10076f0857 Pre-authentication f stacked message_continue none -2287d235-72aa-4d23-80a2-44278d7c7839 Logout default-invalidation-flow invalidation 7c8e66c0-7a12-42bd-901f-a88fcd742a5e Default Invalidation Flow f stacked message_continue none +c6e83269-3b5e-4f2a-8014-f198578240c1 Welcome to authentik! Please select a username. default-source-enrollment enrollment 63e07f88-3fd7-4180-807f-470bb3209bac Welcome to authentik! Please select a username. f stacked message_continue none +7c801f5d-8952-4b95-9f6c-385af22e798d default-oobe-setup initial-setup stage_configuration db71b00d-8d55-4ce6-a1a4-8fa8be93e810 Welcome to authentik! f stacked message_continue require_superuser +3b01d5c4-0d46-4bfe-b37f-553a7b4d051c Authorize Application default-provider-authorization-explicit-consent authorization ab6b7aa3-82da-4972-9158-7b7fc70ab973 Redirecting to %(app)s f stacked message_continue require_authenticated \. @@ -3082,28 +3281,28 @@ a7ef54bb-7959-4559-979d-9345c022e086 Authorize Application default-provider-auth -- COPY public.authentik_flows_flowstagebinding (policybindingmodel_ptr_id, fsb_uuid, re_evaluate_policies, "order", target_id, stage_id, evaluate_on_plan, invalid_response_action) FROM stdin; -88ddedff-15ef-4b45-86cf-0aebf0e55b25 bae6a178-c8fc-4041-b2f0-a1e44f83259f f 0 4ca3e92c-7f33-4d2f-bb5e-1efa4c6c902a 2e18f37d-528d-4c12-b9ea-d7dbf67b56a7 t retry -6f6c5b88-ae7e-44ae-bdae-432893a9e4b6 534d4ed8-ca6e-4140-8a2b-77c546c67d07 t 0 c6e83269-3b5e-4f2a-8014-f198578240c1 89176b0f-7a9c-41eb-8897-6c532db04b96 t retry -417e795d-6832-40b6-b083-4462357471cb 9735981f-6ff9-4207-8093-47b15ba0b678 f 1 c6e83269-3b5e-4f2a-8014-f198578240c1 dfc93c1c-c398-4e8f-aa34-fdc21b6accfb t retry +e4c83e8e-69f4-4993-a0c2-f99552fd76d0 585626b6-72d8-4f0f-9b4e-085d6a3c2ee0 f 10 87ca9599-5323-451c-8603-2f5bee7a0c12 f4abcb7d-5f92-4890-8760-afb9effda665 t retry +9fdfd009-537e-4b36-8cc8-98f62620f410 1e25b2d4-d52b-4f19-8b9e-65960d3a64a8 f 30 87ca9599-5323-451c-8603-2f5bee7a0c12 8a432e54-5bb4-4199-add9-53e198eca1dc t retry +3cb69a8b-fd96-415c-95e6-f094934905d0 f7804829-283b-4e98-adc2-186ef7f2fc09 f 0 611b3458-aadd-47c1-b37e-1940bc72ede7 7dac33d8-ebb1-421c-b9d4-13aba2c28a3c t retry c0792518-ed7b-4d0f-a373-5f20c253de11 aa44b8e7-908e-466f-9e5b-467c8b380ed6 f 0 4b14409d-da92-4b6c-91e8-bad06d0966c2 366481d2-b22d-4993-9502-d367fb8ae7f1 t retry dfabc06a-e2f8-401d-92c4-01e3185f7dc1 b695f5b6-73e4-4f4e-a25f-6eba4715ec1d f 1 4b14409d-da92-4b6c-91e8-bad06d0966c2 e87982c6-2f31-4dc5-bdce-2f680601c023 t retry 7321fbe5-2c34-41b6-8682-6ab794e00096 dbcef3a3-9fd3-4392-87e6-7722de38c458 f 10 c4257191-cb7a-4120-9566-a38ba8a50248 44b8d668-3efe-4e44-aa7f-74849414f977 t retry -0a3c97a9-ac46-45cb-8718-a2984fff154a cffd43dd-d238-4eda-8b40-31562f94b2b0 f 2 c6e83269-3b5e-4f2a-8014-f198578240c1 03fc4a51-f2e0-4844-b58b-b421ea235f34 t retry -b696e261-9362-4cbe-b864-eee1f3c04afe b4a7322f-e68d-4512-a10d-69b95bceefec f 20 c4257191-cb7a-4120-9566-a38ba8a50248 02b4a829-f9ab-45d0-adcc-11e3c165f1c4 t retry +b696e261-9362-4cbe-b864-eee1f3c04afe b4a7322f-e68d-4512-a10d-69b95bceefec t 20 c4257191-cb7a-4120-9566-a38ba8a50248 02b4a829-f9ab-45d0-adcc-11e3c165f1c4 t retry +4f206f06-1d5e-4725-8206-c58d48f5be6c 21bf872d-1b1b-44ee-a737-727e072d5680 f 0 35537410-5840-48ee-ba54-76f2a7d42617 0d0f2722-298b-4212-9307-640a205253bd t retry 07e871ad-dc36-48ea-abf8-397cd2a612b7 60731f11-952f-4c2f-bd4c-f35310d51e84 f 30 c4257191-cb7a-4120-9566-a38ba8a50248 b461e46b-7c2f-4188-b4a7-7a4df1c2c142 t retry cd0bdadb-3a40-458a-bced-d62f12dcb5aa 73c108f5-720c-46b6-922f-2aa1c9d6bf89 f 100 c4257191-cb7a-4120-9566-a38ba8a50248 58a4b17a-a21a-4c29-a3fd-6797bd7260cc t retry +a67caf05-5165-441b-8e62-6f37fa9c2a0d d8ab26bb-8bd3-4bc8-8e58-9179a5805d29 f 0 2287d235-72aa-4d23-80a2-44278d7c7839 6fce99da-2643-4784-928c-94c1b2a3ab16 t retry 1f1353d5-b69c-438e-822c-c13f75fdb18a 737c6465-10a1-4e8d-8fce-ab209f9b6d61 f 20 1d2dd494-c62d-438a-8d0b-f864084f883c fa87ed01-29a6-457d-936d-4667077bba44 t retry cd98a539-1b50-4d59-b4b9-4a66b98b20fe 5672aba5-f29a-412b-b0fe-15a7ab162ec8 f 100 1d2dd494-c62d-438a-8d0b-f864084f883c 9934f0fc-b4b4-44e0-a90d-7587844ac68b t retry +303b1296-cc7f-4887-92fd-0ea0550fc6d4 6dbcdc89-8ac7-4159-8cda-529cabea5f58 f 0 0bcbad7c-01fc-4ed0-b3f1-a1671683d6b3 4c0a51c3-ed6a-40d6-8889-3a3e8ac13546 t retry +65398796-09e1-494b-af85-8bed8b491c62 2bd6d69e-1b08-4c42-bf8c-adaa4ca284ce f 0 3b01d5c4-0d46-4bfe-b37f-553a7b4d051c d9149797-1c7d-4b62-b193-2e745f9dbb9c t retry +88ddedff-15ef-4b45-86cf-0aebf0e55b25 bae6a178-c8fc-4041-b2f0-a1e44f83259f f 0 4ca3e92c-7f33-4d2f-bb5e-1efa4c6c902a 2e18f37d-528d-4c12-b9ea-d7dbf67b56a7 t retry +6f6c5b88-ae7e-44ae-bdae-432893a9e4b6 534d4ed8-ca6e-4140-8a2b-77c546c67d07 t 0 c6e83269-3b5e-4f2a-8014-f198578240c1 89176b0f-7a9c-41eb-8897-6c532db04b96 t retry +417e795d-6832-40b6-b083-4462357471cb 9735981f-6ff9-4207-8093-47b15ba0b678 f 1 c6e83269-3b5e-4f2a-8014-f198578240c1 dfc93c1c-c398-4e8f-aa34-fdc21b6accfb t retry +0a3c97a9-ac46-45cb-8718-a2984fff154a cffd43dd-d238-4eda-8b40-31562f94b2b0 f 2 c6e83269-3b5e-4f2a-8014-f198578240c1 03fc4a51-f2e0-4844-b58b-b421ea235f34 t retry bc36ea75-31be-4dd6-b2cf-11f2e7ca7a33 744e08bc-bb8e-42f6-875a-bdf583d6a5e8 f 10 7c801f5d-8952-4b95-9f6c-385af22e798d 4b3bfdf2-31ed-4b1f-8de4-1752ef0ece92 t retry 563837f0-8633-46c5-8cd2-f744cb5f3591 aaa3e596-6bd8-4326-9ece-81f586cac391 t 20 7c801f5d-8952-4b95-9f6c-385af22e798d e87982c6-2f31-4dc5-bdce-2f680601c023 f retry 49f5f048-9c0b-4d92-a103-e5eec6ab1ed2 dbd6f009-ee18-4461-8757-8bd99925792c f 100 7c801f5d-8952-4b95-9f6c-385af22e798d 58a4b17a-a21a-4c29-a3fd-6797bd7260cc t retry -e4c83e8e-69f4-4993-a0c2-f99552fd76d0 585626b6-72d8-4f0f-9b4e-085d6a3c2ee0 f 10 87ca9599-5323-451c-8603-2f5bee7a0c12 f4abcb7d-5f92-4890-8760-afb9effda665 t retry -9fdfd009-537e-4b36-8cc8-98f62620f410 1e25b2d4-d52b-4f19-8b9e-65960d3a64a8 f 30 87ca9599-5323-451c-8603-2f5bee7a0c12 8a432e54-5bb4-4199-add9-53e198eca1dc t retry -a67caf05-5165-441b-8e62-6f37fa9c2a0d d8ab26bb-8bd3-4bc8-8e58-9179a5805d29 f 0 2287d235-72aa-4d23-80a2-44278d7c7839 6fce99da-2643-4784-928c-94c1b2a3ab16 t retry -4f206f06-1d5e-4725-8206-c58d48f5be6c 21bf872d-1b1b-44ee-a737-727e072d5680 f 0 35537410-5840-48ee-ba54-76f2a7d42617 0d0f2722-298b-4212-9307-640a205253bd t retry -303b1296-cc7f-4887-92fd-0ea0550fc6d4 6dbcdc89-8ac7-4159-8cda-529cabea5f58 f 0 0bcbad7c-01fc-4ed0-b3f1-a1671683d6b3 4c0a51c3-ed6a-40d6-8889-3a3e8ac13546 t retry -3cb69a8b-fd96-415c-95e6-f094934905d0 f7804829-283b-4e98-adc2-186ef7f2fc09 f 0 611b3458-aadd-47c1-b37e-1940bc72ede7 7dac33d8-ebb1-421c-b9d4-13aba2c28a3c t retry -65398796-09e1-494b-af85-8bed8b491c62 2bd6d69e-1b08-4c42-bf8c-adaa4ca284ce f 0 3b01d5c4-0d46-4bfe-b37f-553a7b4d051c d9149797-1c7d-4b62-b193-2e745f9dbb9c t retry \. @@ -3120,27 +3319,36 @@ COPY public.authentik_flows_flowtoken (token_ptr_id, _plan, flow_id) FROM stdin; -- COPY public.authentik_flows_stage (stage_uuid, name) FROM stdin; +95715d75-025b-4e5e-9d23-96826479906d ldap-authentication-password +8a432e54-5bb4-4199-add9-53e198eca1dc ldap-authentication-login +f4abcb7d-5f92-4890-8760-afb9effda665 ldap-identification-stage +7dac33d8-ebb1-421c-b9d4-13aba2c28a3c default-authenticator-webauthn-setup 366481d2-b22d-4993-9502-d367fb8ae7f1 default-password-change-prompt 02b4a829-f9ab-45d0-adcc-11e3c165f1c4 default-authentication-password b461e46b-7c2f-4188-b4a7-7a4df1c2c142 default-authentication-mfa-validation 44b8d668-3efe-4e44-aa7f-74849414f977 default-authentication-identification -58a4b17a-a21a-4c29-a3fd-6797bd7260cc default-authentication-login +6fce99da-2643-4784-928c-94c1b2a3ab16 default-invalidation-logout +4c0a51c3-ed6a-40d6-8889-3a3e8ac13546 default-authenticator-static-setup +9934f0fc-b4b4-44e0-a90d-7587844ac68b default-user-settings-write fa87ed01-29a6-457d-936d-4667077bba44 default-user-settings +0d0f2722-298b-4212-9307-640a205253bd default-authenticator-totp-setup +d9149797-1c7d-4b62-b193-2e745f9dbb9c default-provider-authorization-consent 2e18f37d-528d-4c12-b9ea-d7dbf67b56a7 default-source-authentication-login 03fc4a51-f2e0-4844-b58b-b421ea235f34 default-source-enrollment-login 89176b0f-7a9c-41eb-8897-6c532db04b96 default-source-enrollment-prompt +dfc93c1c-c398-4e8f-aa34-fdc21b6accfb default-source-enrollment-write 4b3bfdf2-31ed-4b1f-8de4-1752ef0ece92 stage-default-oobe-password -95715d75-025b-4e5e-9d23-96826479906d ldap-authentication-password -8a432e54-5bb4-4199-add9-53e198eca1dc ldap-authentication-login -f4abcb7d-5f92-4890-8760-afb9effda665 ldap-identification-stage +58a4b17a-a21a-4c29-a3fd-6797bd7260cc default-authentication-login e87982c6-2f31-4dc5-bdce-2f680601c023 default-password-change-write -9934f0fc-b4b4-44e0-a90d-7587844ac68b default-user-settings-write -dfc93c1c-c398-4e8f-aa34-fdc21b6accfb default-source-enrollment-write -6fce99da-2643-4784-928c-94c1b2a3ab16 default-invalidation-logout -0d0f2722-298b-4212-9307-640a205253bd default-authenticator-totp-setup -4c0a51c3-ed6a-40d6-8889-3a3e8ac13546 default-authenticator-static-setup -7dac33d8-ebb1-421c-b9d4-13aba2c28a3c default-authenticator-webauthn-setup -d9149797-1c7d-4b62-b193-2e745f9dbb9c default-provider-authorization-consent +\. + + +-- +-- Data for Name: authentik_install_id; Type: TABLE DATA; Schema: public; Owner: authentik +-- + +COPY public.authentik_install_id (id) FROM stdin; +FA8GANUqMJwFg0drDlurF+ZQK2A6ohSjc4MGksUqN+A36yIA \. @@ -3199,11 +3407,11 @@ COPY public.authentik_policies_dummy_dummypolicy (policy_ptr_id, result, wait_mi -- Data for Name: authentik_policies_event_matcher_eventmatcherpolicy; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_policies_event_matcher_eventmatcherpolicy (policy_ptr_id, action, client_ip, app) FROM stdin; -70803949-828d-4c91-b717-71ad944e8e48 configuration_error -590a74ec-ff58-42b2-b9ba-1bc560c6724f update_available -db70f174-c06b-4716-a063-67fc36823307 policy_exception -7168d9b3-2c2c-48d3-9c6f-7f5ea85e4408 property_mapping_exception +COPY public.authentik_policies_event_matcher_eventmatcherpolicy (policy_ptr_id, action, client_ip, app, model) FROM stdin; +70803949-828d-4c91-b717-71ad944e8e48 configuration_error \N \N \N +590a74ec-ff58-42b2-b9ba-1bc560c6724f update_available \N \N \N +db70f174-c06b-4716-a063-67fc36823307 policy_exception \N \N \N +7168d9b3-2c2c-48d3-9c6f-7f5ea85e4408 property_mapping_exception \N \N \N \. @@ -3220,12 +3428,14 @@ COPY public.authentik_policies_expiry_passwordexpirypolicy (policy_ptr_id, deny_ -- COPY public.authentik_policies_expression_expressionpolicy (policy_ptr_id, expression) FROM stdin; -ac669155-b876-42a0-b998-7bfae5c3d11c # Check if we''ve not been given a username by the external IdP\n# and trigger the enrollment flow\nreturn 'username' not in context.get('prompt_data', {}) -8721edd2-c6a9-42d8-bd81-c85d528ba9d8 # This policy ensures that this flow can only be used when the user\n# is in a SSO Flow (meaning they come from an external IdP)\nreturn ak_is_sso_flow -515a8421-2f61-40ad-ab94-c9997612788b from authentik.lib.config import CONFIG\nfrom authentik.core.models import (\n USER_ATTRIBUTE_CHANGE_EMAIL,\n USER_ATTRIBUTE_CHANGE_NAME,\n USER_ATTRIBUTE_CHANGE_USERNAME\n)\nprompt_data = request.context.get("prompt_data")\n\nif not request.user.group_attributes(request.http_request).get(\n USER_ATTRIBUTE_CHANGE_EMAIL, CONFIG.y_bool("default_user_change_email", True)\n):\n if prompt_data.get("email") != request.user.email:\n ak_message("Not allowed to change email address.")\n return False\n\nif not request.user.group_attributes(request.http_request).get(\n USER_ATTRIBUTE_CHANGE_NAME, CONFIG.y_bool("default_user_change_name", True)\n):\n if prompt_data.get("name") != request.user.name:\n ak_message("Not allowed to change name.")\n return False\n\nif not request.user.group_attributes(request.http_request).get(\n USER_ATTRIBUTE_CHANGE_USERNAME, CONFIG.y_bool("default_user_change_username", True)\n):\n if prompt_data.get("username") != request.user.username:\n ak_message("Not allowed to change username.")\n return False\n\nreturn True -d892bf8e-8fde-4851-95db-c8bf8b277fe8 # This policy ensures that this flow can only be used when the user\n# is in a SSO Flow (meaning they come from an external IdP)\nreturn ak_is_sso_flow +9588cdbe-226b-4c5a-9547-3fe684ee8260 flow_plan = request.context.get("flow_plan")\nif not flow_plan:\n return True\n# If the user does not have a backend attached to it, they haven't\n# been authenticated yet and we need the password stage\nreturn not hasattr(flow_plan.context.get("pending_user"), "backend") +515a8421-2f61-40ad-ab94-c9997612788b from authentik.lib.config import CONFIG\nfrom authentik.core.models import (\n USER_ATTRIBUTE_CHANGE_EMAIL,\n USER_ATTRIBUTE_CHANGE_NAME,\n USER_ATTRIBUTE_CHANGE_USERNAME\n)\nprompt_data = request.context.get("prompt_data")\n\nif not request.user.group_attributes(request.http_request).get(\n USER_ATTRIBUTE_CHANGE_EMAIL, CONFIG.get_bool("default_user_change_email", True)\n):\n if prompt_data.get("email") != request.user.email:\n ak_message("Not allowed to change email address.")\n return False\n\nif not request.user.group_attributes(request.http_request).get(\n USER_ATTRIBUTE_CHANGE_NAME, CONFIG.get_bool("default_user_change_name", True)\n):\n if prompt_data.get("name") != request.user.name:\n ak_message("Not allowed to change name.")\n return False\n\nif not request.user.group_attributes(request.http_request).get(\n USER_ATTRIBUTE_CHANGE_USERNAME, CONFIG.get_bool("default_user_change_username", True)\n):\n if prompt_data.get("username") != request.user.username:\n ak_message("Not allowed to change username.")\n return False\n\nreturn True 83952542-88c4-404d-b62f-ff22889a5047 # This policy sets the user for the currently running flow\n# by injecting "pending_user"\nakadmin = ak_user_by(username="akadmin")\ncontext["flow_plan"].context["pending_user"] = akadmin\nreturn True fd5bfceb-8270-41f7-abef-84a47841d0e8 # This policy ensures that the setup flow can only be\n# executed when the admin user doesn''t have a password set\nakadmin = ak_user_by(username="akadmin")\nreturn not akadmin.has_usable_password() +fd3f6a6c-6623-489d-9d45-868df08c3b75 # This policy ensures that the setup flow can only be\n# used one time\nfrom authentik.flows.models import Flow, FlowAuthenticationRequirement\nFlow.objects.filter(slug="initial-setup").update(\n authentication=FlowAuthenticationRequirement.REQUIRE_SUPERUSER,\n)\nreturn True +d892bf8e-8fde-4851-95db-c8bf8b277fe8 # This policy ensures that this flow can only be used when the user\n# is in a SSO Flow (meaning they come from an external IdP)\nreturn ak_is_sso_flow +ac669155-b876-42a0-b998-7bfae5c3d11c # Check if we''ve not been given a username by the external IdP\n# and trigger the enrollment flow\nreturn 'username' not in context.get('prompt_data', {}) +8721edd2-c6a9-42d8-bd81-c85d528ba9d8 # This policy ensures that this flow can only be used when the user\n# is in a SSO Flow (meaning they come from an external IdP)\nreturn ak_is_sso_flow \. @@ -3242,16 +3452,18 @@ COPY public.authentik_policies_password_passwordpolicy (policy_ptr_id, amount_up -- COPY public.authentik_policies_policy (created, last_updated, policy_uuid, name, execution_logging) FROM stdin; -2022-10-25 09:09:08.800329+00 2022-10-25 09:10:09.573205+00 d892bf8e-8fde-4851-95db-c8bf8b277fe8 default-source-authentication-if-sso f -2022-10-25 09:09:10.070001+00 2022-10-25 09:10:12.104261+00 ac669155-b876-42a0-b998-7bfae5c3d11c default-source-enrollment-if-username f -2022-10-25 09:09:10.076908+00 2022-10-25 09:10:12.118457+00 8721edd2-c6a9-42d8-bd81-c85d528ba9d8 default-source-enrollment-if-sso f -2022-10-25 09:09:12.352255+00 2022-10-25 09:10:14.622156+00 70803949-828d-4c91-b717-71ad944e8e48 default-match-configuration-error f -2022-10-25 09:09:13.006949+00 2022-10-25 09:10:14.6496+00 590a74ec-ff58-42b2-b9ba-1bc560c6724f default-match-update f -2022-10-25 09:09:13.724566+00 2022-10-25 09:10:14.67473+00 db70f174-c06b-4716-a063-67fc36823307 default-match-policy-exception f -2022-10-25 09:09:13.728881+00 2022-10-25 09:10:14.683346+00 7168d9b3-2c2c-48d3-9c6f-7f5ea85e4408 default-match-property-mapping-exception f -2022-10-25 09:09:28.773441+00 2022-10-25 09:10:16.553121+00 83952542-88c4-404d-b62f-ff22889a5047 default-oobe-prefill-user f -2022-10-25 09:09:28.779052+00 2022-10-25 09:10:16.560446+00 fd5bfceb-8270-41f7-abef-84a47841d0e8 default-oobe-password-usable f -2022-10-25 09:09:08.5634+00 2022-10-25 09:10:17.954438+00 515a8421-2f61-40ad-ab94-c9997612788b default-user-settings-authorization f +2022-10-25 09:09:12.352255+00 2024-01-10 13:34:52.611461+00 70803949-828d-4c91-b717-71ad944e8e48 default-match-configuration-error f +2022-10-25 09:09:13.006949+00 2024-01-10 13:34:52.633986+00 590a74ec-ff58-42b2-b9ba-1bc560c6724f default-match-update f +2022-10-25 09:09:13.724566+00 2024-01-10 13:34:52.656067+00 db70f174-c06b-4716-a063-67fc36823307 default-match-policy-exception f +2022-10-25 09:09:13.728881+00 2024-01-10 13:34:52.659921+00 7168d9b3-2c2c-48d3-9c6f-7f5ea85e4408 default-match-property-mapping-exception f +2024-01-10 13:34:53.197968+00 2024-01-10 13:34:54.279244+00 9588cdbe-226b-4c5a-9547-3fe684ee8260 default-authentication-flow-password-stage f +2022-10-25 09:09:08.5634+00 2024-01-10 13:34:54.427456+00 515a8421-2f61-40ad-ab94-c9997612788b default-user-settings-authorization f +2022-10-25 09:09:10.076908+00 2024-01-10 13:36:28.483735+00 8721edd2-c6a9-42d8-bd81-c85d528ba9d8 default-source-enrollment-if-sso f +2022-10-25 09:09:28.773441+00 2024-01-10 13:36:28.635412+00 83952542-88c4-404d-b62f-ff22889a5047 default-oobe-prefill-user f +2022-10-25 09:09:28.779052+00 2024-01-10 13:36:28.638387+00 fd5bfceb-8270-41f7-abef-84a47841d0e8 default-oobe-password-usable f +2024-01-10 13:36:26.752962+00 2024-01-10 13:36:28.641341+00 fd3f6a6c-6623-489d-9d45-868df08c3b75 default-oobe-flow-set-authentication f +2022-10-25 09:09:08.800329+00 2024-01-10 13:36:28.419593+00 d892bf8e-8fde-4851-95db-c8bf8b277fe8 default-source-authentication-if-sso f +2022-10-25 09:09:10.070001+00 2024-01-10 13:36:28.480406+00 ac669155-b876-42a0-b998-7bfae5c3d11c default-source-enrollment-if-username f \. @@ -3259,16 +3471,18 @@ COPY public.authentik_policies_policy (created, last_updated, policy_uuid, name, -- Data for Name: authentik_policies_policybinding; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_policies_policybinding (policy_binding_uuid, enabled, "order", policy_id, target_id, negate, timeout, group_id, user_id) FROM stdin; -8bfc6195-0957-4be5-abb6-dcd9816a3c24 t 0 70803949-828d-4c91-b717-71ad944e8e48 1ed3e589-5391-4e46-a947-c764191091cd f 30 \N \N -486d3ed0-3097-434c-8f22-da01f1938775 t 0 590a74ec-ff58-42b2-b9ba-1bc560c6724f 5d0ae86a-0f92-4329-a141-4df745aa532d f 30 \N \N -c4525029-87a5-448a-bdf9-583b7c122fa5 t 0 db70f174-c06b-4716-a063-67fc36823307 2edcbba8-4015-494a-b14e-f86dfffcd52c f 30 \N \N -36b5a264-eea9-4d7e-b11c-36be3efaf92e t 1 7168d9b3-2c2c-48d3-9c6f-7f5ea85e4408 2edcbba8-4015-494a-b14e-f86dfffcd52c f 30 \N \N -b69908c9-bcba-4cc8-a2e6-e7d2b8e71584 t 0 fd5bfceb-8270-41f7-abef-84a47841d0e8 db71b00d-8d55-4ce6-a1a4-8fa8be93e810 f 30 \N \N -0bf4412d-93f2-4e15-b747-f6b6a5c578bc t 0 83952542-88c4-404d-b62f-ff22889a5047 563837f0-8633-46c5-8cd2-f744cb5f3591 f 30 \N \N -329669b7-205e-4ee0-a548-cd3376bd09ad t 0 d892bf8e-8fde-4851-95db-c8bf8b277fe8 181928f0-b530-4858-bee9-376597ac63ed f 30 \N \N -ffc7c71f-eba8-4535-bc55-1ab18fa165ef t 0 8721edd2-c6a9-42d8-bd81-c85d528ba9d8 63e07f88-3fd7-4180-807f-470bb3209bac f 30 \N \N -492fe6be-936e-4dc4-bca8-72a845fe3b08 t 0 ac669155-b876-42a0-b998-7bfae5c3d11c 6f6c5b88-ae7e-44ae-bdae-432893a9e4b6 f 30 \N \N +COPY public.authentik_policies_policybinding (policy_binding_uuid, enabled, "order", policy_id, target_id, negate, timeout, group_id, user_id, failure_result) FROM stdin; +8bfc6195-0957-4be5-abb6-dcd9816a3c24 t 0 70803949-828d-4c91-b717-71ad944e8e48 1ed3e589-5391-4e46-a947-c764191091cd f 30 \N \N f +486d3ed0-3097-434c-8f22-da01f1938775 t 0 590a74ec-ff58-42b2-b9ba-1bc560c6724f 5d0ae86a-0f92-4329-a141-4df745aa532d f 30 \N \N f +c4525029-87a5-448a-bdf9-583b7c122fa5 t 0 db70f174-c06b-4716-a063-67fc36823307 2edcbba8-4015-494a-b14e-f86dfffcd52c f 30 \N \N f +36b5a264-eea9-4d7e-b11c-36be3efaf92e t 1 7168d9b3-2c2c-48d3-9c6f-7f5ea85e4408 2edcbba8-4015-494a-b14e-f86dfffcd52c f 30 \N \N f +b5e045e7-4c90-4feb-8e4b-7604085119ad t 10 9588cdbe-226b-4c5a-9547-3fe684ee8260 b696e261-9362-4cbe-b864-eee1f3c04afe f 30 \N \N f +ffc7c71f-eba8-4535-bc55-1ab18fa165ef t 0 8721edd2-c6a9-42d8-bd81-c85d528ba9d8 63e07f88-3fd7-4180-807f-470bb3209bac f 30 \N \N f +492fe6be-936e-4dc4-bca8-72a845fe3b08 t 0 ac669155-b876-42a0-b998-7bfae5c3d11c 6f6c5b88-ae7e-44ae-bdae-432893a9e4b6 f 30 \N \N f +b69908c9-bcba-4cc8-a2e6-e7d2b8e71584 t 0 fd5bfceb-8270-41f7-abef-84a47841d0e8 db71b00d-8d55-4ce6-a1a4-8fa8be93e810 f 30 \N \N f +0bf4412d-93f2-4e15-b747-f6b6a5c578bc t 0 83952542-88c4-404d-b62f-ff22889a5047 563837f0-8633-46c5-8cd2-f744cb5f3591 f 30 \N \N f +8e567620-a250-4e98-b095-ffb0bef7af40 t 0 fd3f6a6c-6623-489d-9d45-868df08c3b75 49f5f048-9c0b-4d92-a103-e5eec6ab1ed2 f 30 \N \N f +329669b7-205e-4ee0-a548-cd3376bd09ad t 0 d892bf8e-8fde-4851-95db-c8bf8b277fe8 181928f0-b530-4858-bee9-376597ac63ed f 30 \N \N f \. @@ -3278,14 +3492,17 @@ ffc7c71f-eba8-4535-bc55-1ab18fa165ef t 0 8721edd2-c6a9-42d8-bd81-c85d528ba9d8 63 COPY public.authentik_policies_policybindingmodel (pbm_uuid, policy_engine_mode) FROM stdin; 77d294b5-8a50-4d6d-8edd-5b15e5871826 any +105415a2-7753-4a7f-bdf5-ed9841420289 any +e4c83e8e-69f4-4993-a0c2-f99552fd76d0 any +9fdfd009-537e-4b36-8cc8-98f62620f410 any +506b1180-4069-4dda-9405-e185f355c34d any +0e32dc99-655c-42f4-aca4-f3f271e010fd any +d7dfa1d6-58cb-4bfc-94c5-38a2e507e290 any +3d7a1eb4-72c2-44dd-bdf7-741750024ce2 any +3cb69a8b-fd96-415c-95e6-f094934905d0 any 1ed3e589-5391-4e46-a947-c764191091cd any 5d0ae86a-0f92-4329-a141-4df745aa532d any 2edcbba8-4015-494a-b14e-f86dfffcd52c any -db71b00d-8d55-4ce6-a1a4-8fa8be93e810 any -bc36ea75-31be-4dd6-b2cf-11f2e7ca7a33 any -563837f0-8633-46c5-8cd2-f744cb5f3591 any -49f5f048-9c0b-4d92-a103-e5eec6ab1ed2 any -65c208d9-e145-4259-bb87-deecf97c3643 any c0792518-ed7b-4d0f-a373-5f20c253de11 any dfabc06a-e2f8-401d-92c4-01e3185f7dc1 any 5be1e1aa-8051-417e-b03a-1a0e87cd8101 any @@ -3293,33 +3510,30 @@ dfabc06a-e2f8-401d-92c4-01e3185f7dc1 any b696e261-9362-4cbe-b864-eee1f3c04afe any 07e871ad-dc36-48ea-abf8-397cd2a612b7 any cd0bdadb-3a40-458a-bced-d62f12dcb5aa any +7c8e66c0-7a12-42bd-901f-a88fcd742a5e any +a67caf05-5165-441b-8e62-6f37fa9c2a0d any 71205979-10a2-41db-9e08-c05f1644f732 any 1f1353d5-b69c-438e-822c-c13f75fdb18a any cd98a539-1b50-4d59-b4b9-4a66b98b20fe any +c4f758ad-0e74-4b1a-a1bc-32ae3a370b69 any +303b1296-cc7f-4887-92fd-0ea0550fc6d4 any +62e82cc0-d670-4f23-8d7b-14675757dd8c any +4f206f06-1d5e-4725-8206-c58d48f5be6c any +65c208d9-e145-4259-bb87-deecf97c3643 any +ab6b7aa3-82da-4972-9158-7b7fc70ab973 any +65398796-09e1-494b-af85-8bed8b491c62 any +cba52268-0d9a-40bd-ae7f-b723f5e05671 any 181928f0-b530-4858-bee9-376597ac63ed any 88ddedff-15ef-4b45-86cf-0aebf0e55b25 any +081ce9e5-201f-48e9-af60-7c10076f0857 any 63e07f88-3fd7-4180-807f-470bb3209bac any 6f6c5b88-ae7e-44ae-bdae-432893a9e4b6 any 417e795d-6832-40b6-b083-4462357471cb any 0a3c97a9-ac46-45cb-8718-a2984fff154a any -105415a2-7753-4a7f-bdf5-ed9841420289 any -e4c83e8e-69f4-4993-a0c2-f99552fd76d0 any -9fdfd009-537e-4b36-8cc8-98f62620f410 any -506b1180-4069-4dda-9405-e185f355c34d any -0e32dc99-655c-42f4-aca4-f3f271e010fd any -7c8e66c0-7a12-42bd-901f-a88fcd742a5e any -a67caf05-5165-441b-8e62-6f37fa9c2a0d any -d7dfa1d6-58cb-4bfc-94c5-38a2e507e290 any -62e82cc0-d670-4f23-8d7b-14675757dd8c any -4f206f06-1d5e-4725-8206-c58d48f5be6c any -c4f758ad-0e74-4b1a-a1bc-32ae3a370b69 any -303b1296-cc7f-4887-92fd-0ea0550fc6d4 any -3d7a1eb4-72c2-44dd-bdf7-741750024ce2 any -3cb69a8b-fd96-415c-95e6-f094934905d0 any -cba52268-0d9a-40bd-ae7f-b723f5e05671 any -ab6b7aa3-82da-4972-9158-7b7fc70ab973 any -65398796-09e1-494b-af85-8bed8b491c62 any -081ce9e5-201f-48e9-af60-7c10076f0857 any +db71b00d-8d55-4ce6-a1a4-8fa8be93e810 any +bc36ea75-31be-4dd6-b2cf-11f2e7ca7a33 any +563837f0-8633-46c5-8cd2-f744cb5f3591 any +49f5f048-9c0b-4d92-a103-e5eec6ab1ed2 any \. @@ -3327,18 +3541,12 @@ ab6b7aa3-82da-4972-9158-7b7fc70ab973 any -- Data for Name: authentik_policies_reputation_reputation; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_policies_reputation_reputation (reputation_uuid, identifier, ip, ip_geo_data, score, updated) FROM stdin; -6fad70f3-701f-4811-a177-08a0f2f8706f admin 192.168.32.1 {} -1 2022-10-25 09:10:21.819657+00 -869cd538-3c10-477a-a152-e412430288c9 akadmin 192.168.32.1 {} 1 2022-10-25 09:10:21.829882+00 -d36ccca8-c986-4517-8110-761eac3d8be0 akadmin 192.168.48.1 {} -2 2022-10-25 09:17:04.253267+00 -f433dfcb-3aa4-4d93-8abc-6fe2c4a77f77 ldapservice 192.168.48.1 {} 5 2022-10-25 09:53:31.155505+00 -2f00817d-e5f1-4e9d-bc4e-8210b2ca2258 authentik-admin 192.168.48.1 {} 1 2022-10-25 09:54:01.492491+00 -85999073-85b3-4f12-93b8-a98b770daa41 akadmin 172.24.0.1 {} 1 2023-01-02 14:16:56.149781+00 -0eb5ae80-8ca7-4431-bac6-2032e2e5ef97 authentik-admin 172.24.0.1 {} 1 2023-01-02 16:38:23.763498+00 -774c5225-53f8-4c7e-81f5-0c604fc2a68b authentik-editor 172.21.0.1 {} 1 2023-03-13 09:22:01.579092+00 -11631826-9f70-42c7-91d1-dc7b9fee5522 ldapservice 172.21.0.1 {} 3 2023-03-13 09:21:07.539614+00 -706276b1-28cc-44db-8d47-e36acca22b2c admin 172.21.0.1 {} -1 2023-03-13 09:19:23.126928+00 -16d6956d-4d9c-429d-a135-91d5dca763e0 akadmin 172.21.0.1 {} 1 2023-03-13 09:20:07.469678+00 +COPY public.authentik_policies_reputation_reputation (reputation_uuid, identifier, ip, ip_geo_data, score, updated, expires, expiring) FROM stdin; +12dd4686-a03a-4094-984a-7df6e32666e3 akadmin 172.20.0.1 {} 1 2024-01-11 22:06:44.922067+00 2024-01-12 22:06:44.921204+00 t +c0783b41-a22c-4093-ab63-9038ad174bbb authentik-viewer 172.20.0.1 {} 1 2024-01-11 22:10:28.507575+00 2024-01-12 22:10:28.498415+00 t +318b3a5e-5b2f-4082-8b67-e1481d731d32 authentik-editor 172.20.0.1 {} 1 2024-01-11 22:10:54.126894+00 2024-01-12 22:10:54.126667+00 t +72c0af40-7d86-4724-ac14-94e405580960 ldapservice 172.20.0.8 {} 1 2024-01-11 22:11:16.091562+00 2024-01-12 22:11:16.09075+00 t +3d6a52ef-80aa-4774-a88f-67db06dd6933 authentik-admin 172.20.0.8 {} 1 2024-01-11 22:11:16.910702+00 2024-01-12 22:11:16.910258+00 t \. @@ -3354,8 +3562,8 @@ COPY public.authentik_policies_reputation_reputationpolicy (policy_ptr_id, check -- Data for Name: authentik_providers_ldap_ldapprovider; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_providers_ldap_ldapprovider (provider_ptr_id, base_dn, search_group_id, certificate_id, tls_server_name, gid_start_number, uid_start_number, search_mode, bind_mode) FROM stdin; -1 DC=ldap,DC=goauthentik,DC=io a9c6327c-cfca-4d5b-a0af-421645e43c31 \N 4000 2000 direct direct +COPY public.authentik_providers_ldap_ldapprovider (provider_ptr_id, base_dn, search_group_id, certificate_id, tls_server_name, gid_start_number, uid_start_number, search_mode, bind_mode, mfa_support) FROM stdin; +1 DC=ldap,DC=goauthentik,DC=io a9c6327c-cfca-4d5b-a0af-421645e43c31 \N 4000 2000 direct direct t \. @@ -3363,8 +3571,9 @@ COPY public.authentik_providers_ldap_ldapprovider (provider_ptr_id, base_dn, sea -- Data for Name: authentik_providers_oauth2_accesstoken; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_providers_oauth2_accesstoken (id, expires, expiring, revoked, _scope, token, _id_token, provider_id, user_id) FROM stdin; -1 2023-03-13 12:41:32.048979+00 t f openid email profile eyJhbGciOiJSUzI1NiIsImtpZCI6ImE1NmE2NjVjMGJhMTQ1MDdmY2Q0ZGU3NmZhZDdlYmNlIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAvYXBwbGljYXRpb24vby9ncmFmYW5hLW9pZGMvIiwic3ViIjoiN2YzODgwM2NmZDM4ODVkZDU3OGUxMDZiYTU4OTg1OWVlOTk3YjQyNjhlNjcyM2VlNWYwZDA1NWQxMTU2MzAwNSIsImF1ZCI6IjQzZThkMjc0NmZlMmU1MDgzMjVhMjNjZGY4MTZkNmRkZDEyZTk0ZjEiLCJleHAiOjE2Nzg3MTEyOTIsImlhdCI6MTY3ODcxMDk5MiwiYWNyIjoiZ29hdXRoZW50aWsuaW8vcHJvdmlkZXJzL29hdXRoMi9kZWZhdWx0IiwiZW1haWwiOiJhdXRoZW50aWstZWRpdG9yQGxvY2FsaG9zdCIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoiYXV0aGVudGlrLWVkaXRvciIsImdpdmVuX25hbWUiOiJhdXRoZW50aWstZWRpdG9yIiwiZmFtaWx5X25hbWUiOiIiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhdXRoZW50aWstZWRpdG9yIiwibmlja25hbWUiOiJhdXRoZW50aWstZWRpdG9yIiwiZ3JvdXBzIjpbImVkaXRvciIsImV4dHJhLWdyb3VwIl0sImF6cCI6IjQzZThkMjc0NmZlMmU1MDgzMjVhMjNjZGY4MTZkNmRkZDEyZTk0ZjEiLCJ1aWQiOiJXRkFwc1FpZ00wbGt1blZYazNCbVJTR3VhZXlPT0N5MjB6Q25ER3FBIn0.XMvIFaDw_xTJZYWiH76hZn-v9ClX9OU1zjRy7hCP6a3aPWcGkK7bVoLUvtG4EwZl5BlY7h2Ajut3mfWXbzGUJEwA0pXBb-cOgIEa9Mq_y9nQUH2kYs4Gfi9kyiU2CiCXQKtquIuoH5RjevPjaupICiZQ0PvLlT9mGR6QD6gAnVhK-PAObFOvy4_X6B0i8PM_nKTx8cr5KW4YwnORTc_VDcGia8iZICBb8ZzyBeHWUmXpDy5G4MoPDgyWWGQaURDEXZN_v-FH0sR3Iu2Mx_by4SkAIIYO-lFtlW51tbjPBItQdZQdXwzcsB_dKtrZXMFKwcyFSlvHStn5Hd3kyP7oR0EPlHCY1G3NA48i38IitpgokW8om8LLqtuYuv7HqbR0VsQOYNOSFw2y3SuUD4FYj966bYwiCF5HXWr6DDhpxYq1liRszwlM3DVfb1Mi5Cnopsqjn2vF2tCeEWJ3cTmiDQvV_MEovsBFerj89GxbnVbbWhNQ9qgr91tQNEFaSW61E7eXOKQfoU79JlCe6eMmNEEQ5YRK4gWfraeHwi_Fhmbtok_Lrhy-hv5hEq_mMN--3axi6_NVuL3JuRELuKBIpyU9y8LVSlcnvZnBhf_UfL1EQmv_u4-mROw06o96FxNNWaIBgUG3Qr9fVl01eI10Dgc8zVAlu-xLnvVwdVBuCpQ {"iss": "http://localhost:9000/application/o/grafana-oidc/", "sub": "7f38803cfd3885dd578e106ba589859ee997b4268e6723ee5f0d055d11563005", "aud": "43e8d2746fe2e508325a23cdf816d6ddd12e94f1", "exp": 1678711292, "iat": 1678710992, "acr": "goauthentik.io/providers/oauth2/default", "email": "authentik-editor@localhost", "email_verified": true, "name": "authentik-editor", "given_name": "authentik-editor", "family_name": "", "preferred_username": "authentik-editor", "nickname": "authentik-editor", "groups": ["editor", "extra-group"]} 3 8 +COPY public.authentik_providers_oauth2_accesstoken (id, expires, expiring, revoked, _scope, token, _id_token, provider_id, user_id, auth_time, session_id) FROM stdin; +2 2024-01-11 22:15:30.794629+00 t f openid email profile eyJhbGciOiJSUzI1NiIsImtpZCI6IjRlM2MzZDFhYjJkMmU4Y2IyMTQyZGNmNDBhOTE4OTIyIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwOi8vc3VwcG9ydC5sYWI6OTAwMC9hcHBsaWNhdGlvbi9vL2dyYWZhbmEtb2lkYy8iLCJzdWIiOiI5ZmMxOTgyODQ0MmMzODg3MzE5YjY4ZDI0OTZjYWNiMjhlOTUwY2I2MTE3ODY3MjFkMDdkMWZiNTc5ZjM5MTk5IiwiYXVkIjoiNDNlOGQyNzQ2ZmUyZTUwODMyNWEyM2NkZjgxNmQ2ZGRkMTJlOTRmMSIsImV4cCI6MTcwNTAxMTMzMCwiaWF0IjoxNzA1MDExMDMwLCJhdXRoX3RpbWUiOjE3MDUwMTEwMjgsImFjciI6ImdvYXV0aGVudGlrLmlvL3Byb3ZpZGVycy9vYXV0aDIvZGVmYXVsdCIsImVtYWlsIjoiYXV0aGVudGlrLXZpZXdlckBsb2NhbGhvc3QiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6ImF1dGhlbnRpay12aWV3ZXIiLCJnaXZlbl9uYW1lIjoiYXV0aGVudGlrLXZpZXdlciIsInByZWZlcnJlZF91c2VybmFtZSI6ImF1dGhlbnRpay12aWV3ZXIiLCJuaWNrbmFtZSI6ImF1dGhlbnRpay12aWV3ZXIiLCJncm91cHMiOlsidmlld2VyIl0sImF6cCI6IjQzZThkMjc0NmZlMmU1MDgzMjVhMjNjZGY4MTZkNmRkZDEyZTk0ZjEiLCJ1aWQiOiI3cGpvSVQwamxTZ1VXcHBKRkFXZ0pZem5ibDNjUWIyYU9VOWIwUGhBIn0.UtMubrWVhHvjwn6eRnIfTw08t_ay7HnKnilSKmmh9J7NE2mya7CWA8tQvwn2f70B6AvkAKOVbgxIcCmVfW7skixNQsK_aBONwanepBgPHqPXA7vS_9G18ITa5vLvgHKzWptGrMqa1PeJQzVbB4N_xXjYMF0iQPNgwZP9_Ikrod62QVuDylHFmQhr6wCiGEbcZ6WkkwZg2_j-O7ocVpOlcLlXOZkKV1eYkSMY21imk7PQE95AhEb1Zo4xdFCm7wckYpd_KtqkIcEvBwFotSQKkLfDECkdgMVCJbaP6HJUW3tIQ_vIAm0-B591SZcVCm4F6Jbe-edjfT-RIpHIlY9yAraei1k-HE3QwXKp7LzuOZSQq8TT1_x2F_U7ZePdk93gQ3BtomZ622eL24s2OfQKPYVFc80hNwAuk3LqFhMT4BeMtge33Hx46IXOJX-kOFU9I8jdIMPjfIdUJ53Ma-g8aSUdZqOP2SP6GJajWBMrMh-yyHy8D9nsUqZhWGNffwhcwa_ZDiMjrl9KBIMtdvW3IFwhVh9Ga2QQRGJsw_IN-s5tBz8neOcpQfs4y-bL2Ipb3GHMcr6vIhQvNE_Juf1lwOOL03K7Ofy4JVENIixF-hp7jGpSng-YV74eMtj3FqjafpCt-BkP7sus8bezVGDNomrQzDrp_NaKc1gqaqd2Ogk {"iss": "http://support.lab:9000/application/o/grafana-oidc/", "sub": "9fc19828442c3887319b68d2496cacb28e950cb611786721d07d1fb579f39199", "aud": "43e8d2746fe2e508325a23cdf816d6ddd12e94f1", "exp": 1705011330, "iat": 1705011030, "auth_time": 1705011028, "acr": "goauthentik.io/providers/oauth2/default", "amr": null, "c_hash": null, "nonce": null, "at_hash": null, "claims": {"email": "authentik-viewer@localhost", "email_verified": true, "name": "authentik-viewer", "given_name": "authentik-viewer", "preferred_username": "authentik-viewer", "nickname": "authentik-viewer", "groups": ["viewer"]}} 3 7 2024-01-11 22:10:28.148148+00 0164b59fd260313483e48a466d2a652797f7e2683b98e357c4ae595e9249414e +3 2024-01-11 22:15:54.393285+00 t f openid email profile eyJhbGciOiJSUzI1NiIsImtpZCI6IjRlM2MzZDFhYjJkMmU4Y2IyMTQyZGNmNDBhOTE4OTIyIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwOi8vc3VwcG9ydC5sYWI6OTAwMC9hcHBsaWNhdGlvbi9vL2dyYWZhbmEtb2lkYy8iLCJzdWIiOiI3ZjM4ODAzY2ZkMzg4NWRkNTc4ZTEwNmJhNTg5ODU5ZWU5OTdiNDI2OGU2NzIzZWU1ZjBkMDU1ZDExNTYzMDA1IiwiYXVkIjoiNDNlOGQyNzQ2ZmUyZTUwODMyNWEyM2NkZjgxNmQ2ZGRkMTJlOTRmMSIsImV4cCI6MTcwNTAxMTM1NCwiaWF0IjoxNzA1MDExMDU0LCJhdXRoX3RpbWUiOjE3MDUwMTEwNTQsImFjciI6ImdvYXV0aGVudGlrLmlvL3Byb3ZpZGVycy9vYXV0aDIvZGVmYXVsdCIsImVtYWlsIjoiYXV0aGVudGlrLWVkaXRvckBsb2NhbGhvc3QiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6ImF1dGhlbnRpay1lZGl0b3IiLCJnaXZlbl9uYW1lIjoiYXV0aGVudGlrLWVkaXRvciIsInByZWZlcnJlZF91c2VybmFtZSI6ImF1dGhlbnRpay1lZGl0b3IiLCJuaWNrbmFtZSI6ImF1dGhlbnRpay1lZGl0b3IiLCJncm91cHMiOlsiZWRpdG9yIiwiZXh0cmEtZ3JvdXAiXSwiYXpwIjoiNDNlOGQyNzQ2ZmUyZTUwODMyNWEyM2NkZjgxNmQ2ZGRkMTJlOTRmMSIsInVpZCI6Im12aE9YYTFISEk0TlVGdTlhcm9CVE9KSmh6RzQzUU1QS0JGa20xbTMifQ.azZ4QLiKWU05Gg4pGIVqKuPQE-cZwvLvM2kOlHqOGiIlPyro4h4R9WROd42plv9Onn7IGGsb8KNmfBehwX0tGR_0HTh046UBcv3AYU8HAJwGwLG2Bdz6L2Y7UloLhe-oRRvuAA_hd_CLV2YVeU_5539UAuJUIMd7WxFjBSE4jrEx0_KD8Ht8IjAlkuVpEprenLagmFz9XAsGCu-23xA7CoJeCMR9TXdLo_vP8uxQlntEoVPLomwLCdCkQxC3xo3evbiB-rjBj4pmjPXOMtrOsB5JdATKQarVNPcQE88IZ6K2kr7GuN102UOBequPEWwO_DtEC1nbXpFtTOwCrCmLkMDhz4bOxTsF0885piQxM7QkqozjhoSv1IxlHH6EPPCiwNdXmERrejNJ6yTjxXRr0zuJm2HYfEx3-2pue5b2uTjjWyh804S8Rh6Pxk1y7DbbifRV01QLMja2K8HX5mTKzRhDfHj5_i_3G1r57R2ybIzHwoVoKTL2gzSly5UJx3LhllxTByaBb_Edow7WXFy4qrTL6MeSYWZjDnajbQ2fbBH8gH1GmbABrr92SBNNhR_sj6lkytvVOIhLfcsukjjXx9-gOgNB4cpPuUXOvhCQhxEK5vnTKes8OhyBv2PBjPQgIPN_ke_QK3bZIMDqcAU8UFg8eVdfixNtrHtsqV9zy28 {"iss": "http://support.lab:9000/application/o/grafana-oidc/", "sub": "7f38803cfd3885dd578e106ba589859ee997b4268e6723ee5f0d055d11563005", "aud": "43e8d2746fe2e508325a23cdf816d6ddd12e94f1", "exp": 1705011354, "iat": 1705011054, "auth_time": 1705011054, "acr": "goauthentik.io/providers/oauth2/default", "amr": null, "c_hash": null, "nonce": null, "at_hash": null, "claims": {"email": "authentik-editor@localhost", "email_verified": true, "name": "authentik-editor", "given_name": "authentik-editor", "preferred_username": "authentik-editor", "nickname": "authentik-editor", "groups": ["editor", "extra-group"]}} 3 8 2024-01-11 22:10:54.076153+00 dcc4d376a787547e44300d834a6ca2a6c734d5beec7a995b55acc555207461dd \. @@ -3372,7 +3581,7 @@ COPY public.authentik_providers_oauth2_accesstoken (id, expires, expiring, revok -- Data for Name: authentik_providers_oauth2_authorizationcode; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_providers_oauth2_authorizationcode (id, expires, expiring, _scope, code, nonce, code_challenge, code_challenge_method, provider_id, user_id, revoked) FROM stdin; +COPY public.authentik_providers_oauth2_authorizationcode (id, expires, expiring, _scope, code, nonce, code_challenge, code_challenge_method, provider_id, user_id, revoked, auth_time, session_id) FROM stdin; \. @@ -3389,7 +3598,7 @@ COPY public.authentik_providers_oauth2_devicetoken (id, expires, expiring, devic -- COPY public.authentik_providers_oauth2_oauth2provider (provider_ptr_id, client_type, client_id, client_secret, redirect_uris, include_claims_in_id_token, refresh_token_validity, signing_key_id, sub_mode, issuer_mode, access_code_validity, access_token_validity) FROM stdin; -3 confidential 43e8d2746fe2e508325a23cdf816d6ddd12e94f1 e50440f14a010fd69dfed85bc6c071653f22c73e2c6c8d7ba96a936937d92040936b7e5a4bcc1bf40d5cf1dc019b1db327a1a00e2183c53471fb7530d4a09d7e .* t days=30 6304c995-9003-4fc4-9ba2-3d32016fb04e hashed_user_id per_provider minutes=1 minutes=5 +3 confidential 43e8d2746fe2e508325a23cdf816d6ddd12e94f1 e50440f14a010fd69dfed85bc6c071653f22c73e2c6c8d7ba96a936937d92040936b7e5a4bcc1bf40d5cf1dc019b1db327a1a00e2183c53471fb7530d4a09d7e .* t days=30 3fcd5d6c-3e40-43bc-b916-c7cd3675d0f1 hashed_user_id per_provider minutes=1 minutes=5 \. @@ -3405,8 +3614,9 @@ COPY public.authentik_providers_oauth2_oauth2provider_jwks_sources (id, oauth2pr -- Data for Name: authentik_providers_oauth2_refreshtoken; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_providers_oauth2_refreshtoken (id, expires, expiring, _scope, _id_token, provider_id, user_id, revoked, token) FROM stdin; -1 2023-04-12 12:36:32.048979+00 t openid email profile {"iss": "http://localhost:9000/application/o/grafana-oidc/", "sub": "7f38803cfd3885dd578e106ba589859ee997b4268e6723ee5f0d055d11563005", "aud": "43e8d2746fe2e508325a23cdf816d6ddd12e94f1", "exp": 1681302992, "iat": 1678710992, "acr": "goauthentik.io/providers/oauth2/default", "at_hash": "sbPtd6pSOzPd1Tryh_o9_w", "email": "authentik-editor@localhost", "email_verified": true, "name": "authentik-editor", "given_name": "authentik-editor", "family_name": "", "preferred_username": "authentik-editor", "nickname": "authentik-editor", "groups": ["editor", "extra-group"]} 3 8 f PyGk`BA`6ZVUI_]I/\\I`w&j@NyY~cP%>&a'TV1nz_^>2g[H1.d{f0+En=bVZ5I.y)YO9q{)^Fnf6KT09;5_s$oq="@Af/qi,/<gs9U):|5Z(|g;5XY"LF,1&@=R;L>r0 +COPY public.authentik_providers_oauth2_refreshtoken (id, expires, expiring, _scope, _id_token, provider_id, user_id, revoked, token, auth_time, session_id) FROM stdin; +2 2024-02-10 22:10:30.794629+00 t openid email profile {"iss": "http://support.lab:9000/application/o/grafana-oidc/", "sub": "9fc19828442c3887319b68d2496cacb28e950cb611786721d07d1fb579f39199", "aud": "43e8d2746fe2e508325a23cdf816d6ddd12e94f1", "exp": 1707603030, "iat": 1705011031, "auth_time": 1705011028, "acr": "goauthentik.io/providers/oauth2/default", "amr": null, "c_hash": null, "nonce": null, "at_hash": "mGFSh_jKOLwb-sZBngObVw", "claims": {"email": "authentik-viewer@localhost", "email_verified": true, "name": "authentik-viewer", "given_name": "authentik-viewer", "preferred_username": "authentik-viewer", "nickname": "authentik-viewer", "groups": ["viewer"]}} 3 7 f qEy5NMin86Yonih6sBtXgK4mFdzu4owN5DHXllqESCEGxaT96jsc2utPJrpvdbf5hlSf00Ffc3Uve6SAQtLh06P9Ycv3yBIzpTFvDi6bGcLXZPO8W9cYcfI7fsqHIXq3 2024-01-11 22:10:28.148148+00 0164b59fd260313483e48a466d2a652797f7e2683b98e357c4ae595e9249414e +3 2024-02-10 22:10:54.393285+00 t openid email profile {"iss": "http://support.lab:9000/application/o/grafana-oidc/", "sub": "7f38803cfd3885dd578e106ba589859ee997b4268e6723ee5f0d055d11563005", "aud": "43e8d2746fe2e508325a23cdf816d6ddd12e94f1", "exp": 1707603054, "iat": 1705011054, "auth_time": 1705011054, "acr": "goauthentik.io/providers/oauth2/default", "amr": null, "c_hash": null, "nonce": null, "at_hash": "YdMhObdt58ReC4t7A_3USg", "claims": {"email": "authentik-editor@localhost", "email_verified": true, "name": "authentik-editor", "given_name": "authentik-editor", "preferred_username": "authentik-editor", "nickname": "authentik-editor", "groups": ["editor", "extra-group"]}} 3 8 f QaLmuHsYcDVmmoJdfZlw2ZsI1iVypVy8sZOVt8V9RErRYWGKgAGSD56yYfrTQrLv2oseM0S2pmdR8w2fK4CIUwWKYZVimfyZpsMOteeQda7XgNLQXI9zEKooou4kRttM 2024-01-11 22:10:54.076153+00 dcc4d376a787547e44300d834a6ca2a6c734d5beec7a995b55acc555207461dd \. @@ -3415,10 +3625,10 @@ COPY public.authentik_providers_oauth2_refreshtoken (id, expires, expiring, _sco -- COPY public.authentik_providers_oauth2_scopemapping (propertymapping_ptr_id, scope_name, description) FROM stdin; +d3406e7e-2c6d-4d72-acdd-dda5513b1396 ak_proxy authentik Proxy - User information 9c13a34a-6007-4846-8c28-11d6dbd235c7 openid a4825145-a8d1-4fbf-b88c-70d8cc801a4a email Email address 7446364c-faec-4cab-9191-beb2889d47b7 profile General Profile Information -d3406e7e-2c6d-4d72-acdd-dda5513b1396 ak_proxy authentik Proxy - User information \. @@ -3430,6 +3640,14 @@ COPY public.authentik_providers_proxy_proxyprovider (oauth2provider_ptr_id, inte \. +-- +-- Data for Name: authentik_providers_radius_radiusprovider; Type: TABLE DATA; Schema: public; Owner: authentik +-- + +COPY public.authentik_providers_radius_radiusprovider (provider_ptr_id, shared_secret, client_networks, mfa_support) FROM stdin; +\. + + -- -- Data for Name: authentik_providers_saml_samlpropertymapping; Type: TABLE DATA; Schema: public; Owner: authentik -- @@ -3449,8 +3667,58 @@ c5d93ce6-8cda-42fa-9c19-3837ba67788a http://schemas.xmlsoap.org/claims/Group \N -- Data for Name: authentik_providers_saml_samlprovider; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_providers_saml_samlprovider (provider_ptr_id, acs_url, audience, issuer, assertion_valid_not_before, assertion_valid_not_on_or_after, session_valid_not_on_or_after, digest_algorithm, signature_algorithm, signing_kp_id, sp_binding, verification_kp_id, name_id_mapping_id) FROM stdin; -2 http://localhost:3000/saml/acs http://localhost:3000/saml/metadata minutes=-5 minutes=5 minutes=86400 http://www.w3.org/2001/04/xmlenc#sha256 http://www.w3.org/2001/04/xmldsig-more#rsa-sha256 6304c995-9003-4fc4-9ba2-3d32016fb04e post \N 38ac59c3-e754-44e7-9790-5108a2cd027c +COPY public.authentik_providers_saml_samlprovider (provider_ptr_id, acs_url, audience, issuer, assertion_valid_not_before, assertion_valid_not_on_or_after, session_valid_not_on_or_after, digest_algorithm, signature_algorithm, signing_kp_id, sp_binding, verification_kp_id, name_id_mapping_id, default_relay_state) FROM stdin; +2 http://localhost:3000/saml/acs http://localhost:3000/saml/metadata minutes=-5 minutes=5 minutes=86400 http://www.w3.org/2001/04/xmlenc#sha256 http://www.w3.org/2001/04/xmldsig-more#rsa-sha256 3fcd5d6c-3e40-43bc-b916-c7cd3675d0f1 post \N 38ac59c3-e754-44e7-9790-5108a2cd027c +\. + + +-- +-- Data for Name: authentik_providers_scim_scimgroup; Type: TABLE DATA; Schema: public; Owner: authentik +-- + +COPY public.authentik_providers_scim_scimgroup (id, group_id, provider_id) FROM stdin; +\. + + +-- +-- Data for Name: authentik_providers_scim_scimmapping; Type: TABLE DATA; Schema: public; Owner: authentik +-- + +COPY public.authentik_providers_scim_scimmapping (propertymapping_ptr_id) FROM stdin; +83a19cb8-acab-44e1-884f-a0a279ae4d2d +252edb40-34ee-4f55-8ca4-4bf0a7fb445f +\. + + +-- +-- Data for Name: authentik_providers_scim_scimprovider; Type: TABLE DATA; Schema: public; Owner: authentik +-- + +COPY public.authentik_providers_scim_scimprovider (provider_ptr_id, url, token, exclude_users_service_account, filter_group_id) FROM stdin; +\. + + +-- +-- Data for Name: authentik_providers_scim_scimprovider_property_mappings_group; Type: TABLE DATA; Schema: public; Owner: authentik +-- + +COPY public.authentik_providers_scim_scimprovider_property_mappings_group (id, scimprovider_id, propertymapping_id) FROM stdin; +\. + + +-- +-- Data for Name: authentik_providers_scim_scimuser; Type: TABLE DATA; Schema: public; Owner: authentik +-- + +COPY public.authentik_providers_scim_scimuser (id, provider_id, user_id) FROM stdin; +\. + + +-- +-- Data for Name: authentik_rbac_role; Type: TABLE DATA; Schema: public; Owner: authentik +-- + +COPY public.authentik_rbac_role (uuid, name, group_id) FROM stdin; \. @@ -3459,6 +3727,7 @@ COPY public.authentik_providers_saml_samlprovider (provider_ptr_id, acs_url, aud -- COPY public.authentik_sources_ldap_ldappropertymapping (propertymapping_ptr_id, object_field) FROM stdin; +200af9d0-7476-43e1-a0fb-b7a7a68dd277 path 353b9d65-66ce-4fea-814b-e12c6e45a277 name a58eb3a5-c68d-406d-a5ff-688a6a047575 email a75310af-c0cf-4709-a66c-963d8c27e662 username @@ -3474,7 +3743,7 @@ e8809202-4ff0-4086-b078-196e6152e826 name -- Data for Name: authentik_sources_ldap_ldapsource; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_sources_ldap_ldapsource (source_ptr_id, server_uri, bind_cn, bind_password, start_tls, base_dn, additional_user_dn, additional_group_dn, user_object_filter, group_object_filter, object_uniqueness_field, sync_groups, sync_parent_group_id, sync_users, sync_users_password, group_membership_field, peer_certificate_id) FROM stdin; +COPY public.authentik_sources_ldap_ldapsource (source_ptr_id, server_uri, bind_cn, bind_password, start_tls, base_dn, additional_user_dn, additional_group_dn, user_object_filter, group_object_filter, object_uniqueness_field, sync_groups, sync_parent_group_id, sync_users, sync_users_password, group_membership_field, peer_certificate_id, client_certificate_id, sni) FROM stdin; \. @@ -3522,7 +3791,7 @@ COPY public.authentik_sources_plex_plexsourceconnection (usersourceconnection_pt -- Data for Name: authentik_sources_saml_samlsource; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_sources_saml_samlsource (source_ptr_id, issuer, sso_url, slo_url, signing_kp_id, binding_type, temporary_user_delete_after, name_id_policy, allow_idp_initiated, digest_algorithm, signature_algorithm, pre_authentication_flow_id) FROM stdin; +COPY public.authentik_sources_saml_samlsource (source_ptr_id, issuer, sso_url, slo_url, signing_kp_id, binding_type, temporary_user_delete_after, name_id_policy, allow_idp_initiated, digest_algorithm, signature_algorithm, pre_authentication_flow_id, verification_kp_id) FROM stdin; \. @@ -3538,7 +3807,7 @@ COPY public.authentik_sources_saml_usersamlsourceconnection (usersourceconnectio -- Data for Name: authentik_stages_authenticator_duo_authenticatorduostage; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_stages_authenticator_duo_authenticatorduostage (stage_ptr_id, client_id, client_secret, api_hostname, configure_flow_id, admin_integration_key, admin_secret_key) FROM stdin; +COPY public.authentik_stages_authenticator_duo_authenticatorduostage (stage_ptr_id, client_id, client_secret, api_hostname, configure_flow_id, admin_integration_key, admin_secret_key, friendly_name) FROM stdin; \. @@ -3554,7 +3823,7 @@ COPY public.authentik_stages_authenticator_duo_duodevice (id, name, confirmed, d -- Data for Name: authentik_stages_authenticator_sms_authenticatorsmsstage; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_stages_authenticator_sms_authenticatorsmsstage (stage_ptr_id, provider, account_sid, auth, configure_flow_id, from_number, auth_password, auth_type, verify_only, mapping_id) FROM stdin; +COPY public.authentik_stages_authenticator_sms_authenticatorsmsstage (stage_ptr_id, provider, account_sid, auth, configure_flow_id, from_number, auth_password, auth_type, verify_only, mapping_id, friendly_name) FROM stdin; \. @@ -3570,8 +3839,24 @@ COPY public.authentik_stages_authenticator_sms_smsdevice (id, name, confirmed, t -- Data for Name: authentik_stages_authenticator_static_authenticatorstaticstage; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_stages_authenticator_static_authenticatorstaticstage (stage_ptr_id, token_count, configure_flow_id) FROM stdin; -4c0a51c3-ed6a-40d6-8889-3a3e8ac13546 6 0bcbad7c-01fc-4ed0-b3f1-a1671683d6b3 +COPY public.authentik_stages_authenticator_static_authenticatorstaticstage (stage_ptr_id, token_count, configure_flow_id, friendly_name, token_length) FROM stdin; +4c0a51c3-ed6a-40d6-8889-3a3e8ac13546 6 0bcbad7c-01fc-4ed0-b3f1-a1671683d6b3 \N 12 +\. + + +-- +-- Data for Name: authentik_stages_authenticator_static_staticdevice; Type: TABLE DATA; Schema: public; Owner: authentik +-- + +COPY public.authentik_stages_authenticator_static_staticdevice (id, name, confirmed, user_id, throttling_failure_count, throttling_failure_timestamp) FROM stdin; +\. + + +-- +-- Data for Name: authentik_stages_authenticator_static_statictoken; Type: TABLE DATA; Schema: public; Owner: authentik +-- + +COPY public.authentik_stages_authenticator_static_statictoken (id, token, device_id) FROM stdin; \. @@ -3579,8 +3864,16 @@ COPY public.authentik_stages_authenticator_static_authenticatorstaticstage (stag -- Data for Name: authentik_stages_authenticator_totp_authenticatortotpstage; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_stages_authenticator_totp_authenticatortotpstage (stage_ptr_id, digits, configure_flow_id) FROM stdin; -0d0f2722-298b-4212-9307-640a205253bd 6 35537410-5840-48ee-ba54-76f2a7d42617 +COPY public.authentik_stages_authenticator_totp_authenticatortotpstage (stage_ptr_id, digits, configure_flow_id, friendly_name) FROM stdin; +0d0f2722-298b-4212-9307-640a205253bd 6 35537410-5840-48ee-ba54-76f2a7d42617 \N +\. + + +-- +-- Data for Name: authentik_stages_authenticator_totp_totpdevice; Type: TABLE DATA; Schema: public; Owner: authentik +-- + +COPY public.authentik_stages_authenticator_totp_totpdevice (id, name, confirmed, key, step, t0, digits, tolerance, drift, last_t, user_id, throttling_failure_count, throttling_failure_timestamp) FROM stdin; \. @@ -3605,8 +3898,8 @@ b461e46b-7c2f-4188-b4a7-7a4df1c2c142 skip {static,totp,webauthn,duo,sms} seconds -- Data for Name: authentik_stages_authenticator_webauthn_authenticatewebauth4bbe; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_stages_authenticator_webauthn_authenticatewebauth4bbe (stage_ptr_id, configure_flow_id, user_verification, authenticator_attachment, resident_key_requirement) FROM stdin; -7dac33d8-ebb1-421c-b9d4-13aba2c28a3c 611b3458-aadd-47c1-b37e-1940bc72ede7 preferred \N preferred +COPY public.authentik_stages_authenticator_webauthn_authenticatewebauth4bbe (stage_ptr_id, configure_flow_id, user_verification, authenticator_attachment, resident_key_requirement, friendly_name) FROM stdin; +7dac33d8-ebb1-421c-b9d4-13aba2c28a3c 611b3458-aadd-47c1-b37e-1940bc72ede7 preferred \N preferred \N \. @@ -3647,7 +3940,7 @@ COPY public.authentik_stages_consent_userconsent (id, expires, expiring, applica -- Data for Name: authentik_stages_deny_denystage; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_stages_deny_denystage (stage_ptr_id) FROM stdin; +COPY public.authentik_stages_deny_denystage (stage_ptr_id, deny_message) FROM stdin; \. @@ -3672,8 +3965,8 @@ COPY public.authentik_stages_email_emailstage (stage_ptr_id, host, port, usernam -- COPY public.authentik_stages_identification_identificationstage (stage_ptr_id, user_fields, enrollment_flow_id, recovery_flow_id, case_insensitive_matching, show_matched_user, password_stage_id, show_source_labels, passwordless_flow_id) FROM stdin; -44b8d668-3efe-4e44-aa7f-74849414f977 {email,username} \N \N t t \N f \N f4abcb7d-5f92-4890-8760-afb9effda665 {username,email} \N \N t t 95715d75-025b-4e5e-9d23-96826479906d f \N +44b8d668-3efe-4e44-aa7f-74849414f977 {email,username} \N \N t t \N f \N \. @@ -3707,8 +4000,8 @@ COPY public.authentik_stages_invitation_invitationstage (stage_ptr_id, continue_ -- COPY public.authentik_stages_password_passwordstage (stage_ptr_id, backends, configure_flow_id, failed_attempts_before_cancel) FROM stdin; -02b4a829-f9ab-45d0-adcc-11e3c165f1c4 {authentik.core.auth.InbuiltBackend,authentik.sources.ldap.auth.LDAPBackend,authentik.core.auth.TokenBackend} 4b14409d-da92-4b6c-91e8-bad06d0966c2 5 95715d75-025b-4e5e-9d23-96826479906d {authentik.core.auth.InbuiltBackend,authentik.core.auth.TokenBackend,authentik.sources.ldap.auth.LDAPBackend} 4b14409d-da92-4b6c-91e8-bad06d0966c2 5 +02b4a829-f9ab-45d0-adcc-11e3c165f1c4 {authentik.core.auth.InbuiltBackend,authentik.sources.ldap.auth.LDAPBackend,authentik.core.auth.TokenBackend} 4b14409d-da92-4b6c-91e8-bad06d0966c2 5 \. @@ -3716,14 +4009,18 @@ COPY public.authentik_stages_password_passwordstage (stage_ptr_id, backends, con -- Data for Name: authentik_stages_prompt_prompt; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_stages_prompt_prompt (prompt_uuid, field_key, label, type, required, placeholder, "order", sub_text, placeholder_expression, name) FROM stdin; -8cbc609d-e3f5-43e6-9134-078ac015b0e6 oobe-header-text oobe-header-text static t Welcome to authentik! Please set a password for the default admin user, akadmin. 100 f oobe-header-text_stage-default-oobe-password -75430f27-c76f-4a41-863a-ca2603ed855d password Password password t Password 300 f password_default-password-change-prompt -c75f2e96-7892-43c0-94b8-815962250d00 password_repeat Password (repeat) password t Password (repeat) 301 f password_repeat_default-password-change-prompt -bb10dc15-c00c-4d51-9605-9f72be503eb3 username Username text t try:\n return user.username\nexcept:\n return '' 200 t username_default-source-enrollment-prompt -daf4b9e8-80df-4d6e-95be-bae1e4c6e9b9 name Name text t try:\n return user.name\nexcept:\n return '' 201 t name_default-user-settings -640c00a0-c1cf-437f-b3a0-4eaa689b11be email Email email t try:\n return user.email\nexcept:\n return '' 202 t email_default-user-settings -a576e417-3082-451a-8311-4149d8362889 attributes.settings.locale Locale ak-locale t try:\n return user.attributes.get("settings", {}).get("locale", "")\nexcept:\n return '' 203 t attributes.settings.locale_default-user-settings +COPY public.authentik_stages_prompt_prompt (prompt_uuid, field_key, label, type, required, placeholder, "order", sub_text, placeholder_expression, name, initial_value, initial_value_expression) FROM stdin; +53bf385b-f5b5-46b1-bae2-52fa7febccbd username Username text t Username 200 f default-user-settings-field-username try:\n return user.username\nexcept:\n return '' t +67a03d10-9859-4252-a92b-6a95210cedf5 name Name text t Name 201 f default-user-settings-field-name try:\n return user.name\nexcept:\n return '' t +e28c6f05-63c5-490e-9270-6d3eaef00007 email Email email t Email 202 f default-user-settings-field-email try:\n return user.email\nexcept:\n return '' t +d5c18367-0f00-480a-9678-c5142ed2c11f attributes.settings.locale Locale ak-locale t Locale 203 f default-user-settings-field-locale try:\n return user.attributes.get("settings", {}).get("locale", "")\nexcept:\n return '' t +5d802ff7-2a30-453f-a624-95f0fbc7bb5b password Password password t Password 300 f default-password-change-field-password f +456af4e0-d665-4cfd-9d26-95b003aa7b0c password_repeat Password (repeat) password t Password (repeat) 301 f default-password-change-field-password-repeat f +9342773a-b3f5-4f5a-9b76-91fff8fbbc21 username Username text t Username 100 f default-source-enrollment-field-username f +84314c23-a6e1-42a1-80f4-064e029ca290 oobe-header-text oobe-header-text static t Welcome to authentik! Please set a password for the default admin user, akadmin. 100 f initial-setup-field-header f +9f75ae41-6a0b-496b-8b0d-316b024317ea email Email email t Admin email 101 f initial-setup-field-email f +a59dd42c-bf08-4c14-9951-91dcc68cdd80 password Password password t Password 300 f initial-setup-field-password f +7fbf7e70-a561-42fe-8b17-12603c8971b0 password_repeat Password (repeat) password t Password (repeat) 301 f initial-setup-field-password-repeat f \. @@ -3744,17 +4041,17 @@ fa87ed01-29a6-457d-936d-4667077bba44 -- COPY public.authentik_stages_prompt_promptstage_fields (id, promptstage_id, prompt_id) FROM stdin; -3 366481d2-b22d-4993-9502-d367fb8ae7f1 c75f2e96-7892-43c0-94b8-815962250d00 -4 366481d2-b22d-4993-9502-d367fb8ae7f1 75430f27-c76f-4a41-863a-ca2603ed855d -10 fa87ed01-29a6-457d-936d-4667077bba44 640c00a0-c1cf-437f-b3a0-4eaa689b11be -11 fa87ed01-29a6-457d-936d-4667077bba44 daf4b9e8-80df-4d6e-95be-bae1e4c6e9b9 -12 fa87ed01-29a6-457d-936d-4667077bba44 a576e417-3082-451a-8311-4149d8362889 -13 fa87ed01-29a6-457d-936d-4667077bba44 bb10dc15-c00c-4d51-9605-9f72be503eb3 -14 89176b0f-7a9c-41eb-8897-6c532db04b96 bb10dc15-c00c-4d51-9605-9f72be503eb3 -19 4b3bfdf2-31ed-4b1f-8de4-1752ef0ece92 c75f2e96-7892-43c0-94b8-815962250d00 -20 4b3bfdf2-31ed-4b1f-8de4-1752ef0ece92 640c00a0-c1cf-437f-b3a0-4eaa689b11be -21 4b3bfdf2-31ed-4b1f-8de4-1752ef0ece92 8cbc609d-e3f5-43e6-9134-078ac015b0e6 -22 4b3bfdf2-31ed-4b1f-8de4-1752ef0ece92 75430f27-c76f-4a41-863a-ca2603ed855d +45 366481d2-b22d-4993-9502-d367fb8ae7f1 456af4e0-d665-4cfd-9d26-95b003aa7b0c +46 366481d2-b22d-4993-9502-d367fb8ae7f1 5d802ff7-2a30-453f-a624-95f0fbc7bb5b +51 fa87ed01-29a6-457d-936d-4667077bba44 d5c18367-0f00-480a-9678-c5142ed2c11f +52 fa87ed01-29a6-457d-936d-4667077bba44 e28c6f05-63c5-490e-9270-6d3eaef00007 +53 fa87ed01-29a6-457d-936d-4667077bba44 67a03d10-9859-4252-a92b-6a95210cedf5 +54 fa87ed01-29a6-457d-936d-4667077bba44 53bf385b-f5b5-46b1-bae2-52fa7febccbd +56 89176b0f-7a9c-41eb-8897-6c532db04b96 9342773a-b3f5-4f5a-9b76-91fff8fbbc21 +65 4b3bfdf2-31ed-4b1f-8de4-1752ef0ece92 a59dd42c-bf08-4c14-9951-91dcc68cdd80 +66 4b3bfdf2-31ed-4b1f-8de4-1752ef0ece92 7fbf7e70-a561-42fe-8b17-12603c8971b0 +67 4b3bfdf2-31ed-4b1f-8de4-1752ef0ece92 84314c23-a6e1-42a1-80f4-064e029ca290 +68 4b3bfdf2-31ed-4b1f-8de4-1752ef0ece92 9f75ae41-6a0b-496b-8b0d-316b024317ea \. @@ -3779,11 +4076,11 @@ COPY public.authentik_stages_user_delete_userdeletestage (stage_ptr_id) FROM std -- Data for Name: authentik_stages_user_login_userloginstage; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_stages_user_login_userloginstage (stage_ptr_id, session_duration) FROM stdin; -2e18f37d-528d-4c12-b9ea-d7dbf67b56a7 seconds=0 -03fc4a51-f2e0-4844-b58b-b421ea235f34 seconds=0 -58a4b17a-a21a-4c29-a3fd-6797bd7260cc seconds=0 -8a432e54-5bb4-4199-add9-53e198eca1dc seconds=0 +COPY public.authentik_stages_user_login_userloginstage (stage_ptr_id, session_duration, terminate_other_sessions, remember_me_offset) FROM stdin; +8a432e54-5bb4-4199-add9-53e198eca1dc seconds=0 f seconds=0 +2e18f37d-528d-4c12-b9ea-d7dbf67b56a7 seconds=0 f seconds=0 +03fc4a51-f2e0-4844-b58b-b421ea235f34 seconds=0 f seconds=0 +58a4b17a-a21a-4c29-a3fd-6797bd7260cc seconds=0 f seconds=0 \. @@ -3800,10 +4097,10 @@ COPY public.authentik_stages_user_logout_userlogoutstage (stage_ptr_id) FROM std -- Data for Name: authentik_stages_user_write_userwritestage; Type: TABLE DATA; Schema: public; Owner: authentik -- -COPY public.authentik_stages_user_write_userwritestage (stage_ptr_id, create_users_as_inactive, create_users_group_id, user_path_template, user_creation_mode) FROM stdin; -e87982c6-2f31-4dc5-bdce-2f680601c023 f \N create_when_required -9934f0fc-b4b4-44e0-a90d-7587844ac68b f \N create_when_required -dfc93c1c-c398-4e8f-aa34-fdc21b6accfb f \N create_when_required +COPY public.authentik_stages_user_write_userwritestage (stage_ptr_id, create_users_as_inactive, create_users_group_id, user_path_template, user_creation_mode, user_type) FROM stdin; +9934f0fc-b4b4-44e0-a90d-7587844ac68b f \N never_create external +dfc93c1c-c398-4e8f-aa34-fdc21b6accfb f \N always_create external +e87982c6-2f31-4dc5-bdce-2f680601c023 f \N never_create external \. @@ -3909,6 +4206,18 @@ COPY public.django_content_type (id, app_label, model) FROM stdin; 86 authentik_core application 87 authentik_core authenticatedsession 88 authentik_providers_oauth2 accesstoken +89 authentik_providers_radius radiusprovider +90 authentik_providers_scim scimmapping +91 authentik_providers_scim scimprovider +92 authentik_providers_scim scimuser +93 authentik_providers_scim scimgroup +94 authentik_enterprise license +95 authentik_enterprise licenseusage +96 authentik_rbac role +97 authentik_rbac systempermission +98 authentik_stages_authenticator_static staticdevice +99 authentik_stages_authenticator_static statictoken +100 authentik_stages_authenticator_totp totpdevice \. @@ -4272,10 +4581,6 @@ COPY public.django_migrations (id, app, name, applied) FROM stdin; 356 authentik_tenants 0004_tenant_flow_device_code 2022-10-25 09:01:02.570998+00 357 guardian 0001_initial 2022-10-25 09:01:02.818376+00 358 guardian 0002_generic_permissions_index 2022-10-25 09:01:02.845895+00 -359 otp_static 0001_initial 2022-10-25 09:01:02.937653+00 -360 otp_static 0002_throttling 2022-10-25 09:01:02.96972+00 -361 otp_totp 0001_initial 2022-10-25 09:01:03.019116+00 -362 otp_totp 0002_auto_20190420_0723 2022-10-25 09:01:03.049858+00 363 sessions 0001_initial 2022-10-25 09:01:03.062934+00 364 authentik_events 0001_squashed_0019_alter_notificationtransport_webhook_url 2022-10-25 09:01:03.068999+00 365 authentik_flows 0001_squashed_0007_auto_20200703_2059 2022-10-25 09:01:03.072173+00 @@ -4312,6 +4617,64 @@ COPY public.django_migrations (id, app, name, applied) FROM stdin; 396 authentik_stages_prompt 0009_prompt_name 2023-03-13 09:18:49.706828+00 397 authentik_stages_user_write 0006_userwritestage_can_create_users 2023-03-13 09:18:49.730647+00 398 authentik_stages_user_write 0007_remove_userwritestage_can_create_users_and_more 2023-03-13 09:18:49.820103+00 +399 authentik_blueprints 0003_alter_blueprintinstance_name 2024-01-10 13:34:39.219213+00 +400 authentik_flows 0025_alter_flowstagebinding_evaluate_on_plan_and_more 2024-01-10 13:34:39.299234+00 +401 authentik_core 0025_alter_provider_authorization_flow 2024-01-10 13:34:39.346809+00 +402 authentik_providers_scim 0001_initial 2024-01-10 13:34:39.633321+00 +403 authentik_providers_scim 0002_scimuser 2024-01-10 13:34:39.643609+00 +404 authentik_providers_scim 0003_scimgroup 2024-01-10 13:34:39.651202+00 +405 authentik_providers_scim 0004_scimprovider_property_mappings_group 2024-01-10 13:34:39.657027+00 +406 authentik_providers_scim 0005_scimprovider_exclude_users_service_account_and_more 2024-01-10 13:34:39.661818+00 +407 authentik_providers_scim 0006_rename_parent_group_scimprovider_filter_group 2024-01-10 13:34:39.666202+00 +408 authentik_core 0026_alter_propertymapping_name_alter_provider_name 2024-01-10 13:34:39.853588+00 +409 authentik_core 0027_alter_user_uuid 2024-01-10 13:34:39.887946+00 +410 authentik_core 0028_provider_authentication_flow 2024-01-10 13:34:39.936054+00 +411 authentik_core 0029_provider_backchannel_applications_and_more 2024-01-10 13:34:40.231447+00 +412 authentik_core 0030_user_type 2024-01-10 13:34:40.310035+00 +413 authentik_core 0031_alter_user_type 2024-01-10 13:34:40.38953+00 +414 authentik_crypto 0004_alter_certificatekeypair_name 2024-01-10 13:34:40.462454+00 +415 authentik_enterprise 0001_initial 2024-01-10 13:34:40.481606+00 +416 authentik_enterprise 0002_rename_users_license_internal_users_and_more 2024-01-10 13:34:40.498701+00 +417 authentik_outposts 0019_alter_outpost_name_and_more 2024-01-10 13:34:40.59884+00 +418 authentik_outposts 0020_alter_outpost_type 2024-01-10 13:34:40.620184+00 +419 authentik_policies 0010_alter_policy_name 2024-01-10 13:34:40.802184+00 +420 authentik_policies_event_matcher 0022_eventmatcherpolicy_model 2024-01-10 13:34:40.814569+00 +421 authentik_policies_event_matcher 0023_alter_eventmatcherpolicy_action_and_more 2024-01-10 13:34:40.88434+00 +422 authentik_providers_ldap 0003_ldapprovider_mfa_support_and_more 2024-01-10 13:34:40.929103+00 +423 authentik_providers_oauth2 0015_accesstoken_auth_time_authorizationcode_auth_time_and_more 2024-01-10 13:34:41.000612+00 +424 authentik_providers_oauth2 0016_alter_refreshtoken_token 2024-01-10 13:34:41.036692+00 +425 authentik_providers_radius 0001_initial 2024-01-10 13:34:41.082262+00 +426 authentik_sources_ldap 0003_ldapsource_client_certificate_ldapsource_sni_and_more 2024-01-10 13:34:41.287946+00 +427 authentik_sources_saml 0013_samlsource_verification_kp_and_more 2024-01-10 13:34:41.407493+00 +428 authentik_stages_authenticator_duo 0005_authenticatorduostage_friendly_name 2024-01-10 13:34:41.430002+00 +429 authentik_stages_authenticator_sms 0006_authenticatorsmsstage_friendly_name 2024-01-10 13:34:41.453801+00 +430 authentik_stages_authenticator_static 0006_authenticatorstaticstage_friendly_name 2024-01-10 13:34:41.476862+00 +431 authentik_stages_authenticator_static 0007_authenticatorstaticstage_token_length_and_more 2024-01-10 13:34:41.511476+00 +432 authentik_stages_authenticator_totp 0007_authenticatortotpstage_friendly_name 2024-01-10 13:34:41.532451+00 +433 authentik_stages_authenticator_webauthn 0008_alter_webauthndevice_credential_id 2024-01-10 13:34:41.551282+00 +434 authentik_stages_authenticator_webauthn 0009_authenticatewebauthnstage_friendly_name 2024-01-10 13:34:41.570976+00 +435 authentik_stages_prompt 0010_alter_prompt_placeholder_alter_prompt_type 2024-01-10 13:34:41.646021+00 +436 authentik_stages_prompt 0011_prompt_initial_value_prompt_initial_value_expression_and_more 2024-01-10 13:34:41.8418+00 +437 authentik_stages_user_login 0004_userloginstage_terminate_other_sessions 2024-01-10 13:34:41.858913+00 +438 authentik_stages_user_login 0005_userloginstage_remember_me_offset 2024-01-10 13:34:41.875539+00 +439 authentik_providers_scim 0001_squashed_0006_rename_parent_group_scimprovider_filter_group 2024-01-10 13:34:41.885818+00 +359 authentik_stages_authenticator_static 0008_initial 2022-10-25 09:01:02.937653+00 +360 authentik_stages_authenticator_static 0009_throttling 2022-10-25 09:01:02.96972+00 +361 authentik_stages_authenticator_totp 0008_initial 2022-10-25 09:01:03.019116+00 +362 authentik_stages_authenticator_totp 0009_auto_20190420_0723 2022-10-25 09:01:03.049858+00 +440 authentik_rbac 0001_initial 2024-01-10 13:36:11.884953+00 +441 authentik_core 0032_group_roles 2024-01-10 13:36:12.058845+00 +442 authentik_flows 0026_alter_flow_options 2024-01-10 13:36:12.080204+00 +443 authentik_flows 0027_auto_20231028_1424 2024-01-10 13:36:12.12669+00 +444 authentik_policies 0011_policybinding_failure_result_and_more 2024-01-10 13:36:12.194152+00 +445 authentik_policies_reputation 0005_reputation_expires_reputation_expiring 2024-01-10 13:36:12.207094+00 +446 authentik_providers_oauth2 0017_accesstoken_session_id_authorizationcode_session_id_and_more 2024-01-10 13:36:12.26718+00 +447 authentik_providers_radius 0002_radiusprovider_mfa_support 2024-01-10 13:36:12.281068+00 +448 authentik_providers_saml 0013_samlprovider_default_relay_state 2024-01-10 13:36:12.302128+00 +449 authentik_rbac 0002_systempermission 2024-01-10 13:36:12.308847+00 +450 authentik_stages_authenticator_totp 0010_alter_totpdevice_key 2024-01-10 13:36:12.353573+00 +451 authentik_stages_deny 0002_denystage_deny_message 2024-01-10 13:36:12.375379+00 +452 authentik_stages_user_write 0008_userwritestage_user_type 2024-01-10 13:36:12.400547+00 \. @@ -4336,33 +4699,9 @@ COPY public.guardian_groupobjectpermission (id, object_pk, content_type_id, grou -- COPY public.guardian_userobjectpermission (id, object_pk, content_type_id, permission_id, user_id) FROM stdin; -22 efe635b9-2ce7-4de4-977f-9f25b9f36d97 15 63 4 -23 1 30 125 4 -24 0c24aadc-f97e-4720-a70f-72a190b0cafc 15 63 3 -\. - - --- --- Data for Name: otp_static_staticdevice; Type: TABLE DATA; Schema: public; Owner: authentik --- - -COPY public.otp_static_staticdevice (id, name, confirmed, user_id, throttling_failure_count, throttling_failure_timestamp) FROM stdin; -\. - - --- --- Data for Name: otp_static_statictoken; Type: TABLE DATA; Schema: public; Owner: authentik --- - -COPY public.otp_static_statictoken (id, token, device_id) FROM stdin; -\. - - --- --- Data for Name: otp_totp_totpdevice; Type: TABLE DATA; Schema: public; Owner: authentik --- - -COPY public.otp_totp_totpdevice (id, name, confirmed, key, step, t0, digits, tolerance, drift, last_t, user_id, throttling_failure_count, throttling_failure_timestamp) FROM stdin; +27 efe635b9-2ce7-4de4-977f-9f25b9f36d97 15 63 4 +28 1 30 125 4 +29 0c24aadc-f97e-4720-a70f-72a190b0cafc 15 63 3 \. @@ -4384,7 +4723,14 @@ SELECT pg_catalog.setval('public.auth_group_permissions_id_seq', 1, false); -- Name: auth_permission_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik -- -SELECT pg_catalog.setval('public.auth_permission_id_seq', 360, true); +SELECT pg_catalog.setval('public.auth_permission_id_seq', 413, true); + + +-- +-- Name: authentik_core_group_roles_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik +-- + +SELECT pg_catalog.setval('public.authentik_core_group_roles_id_seq', 1, false); -- @@ -4433,7 +4779,7 @@ SELECT pg_catalog.setval('public.authentik_core_user_pb_groups_id_seq', 7, true) -- Name: authentik_core_user_user_permissions_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik -- -SELECT pg_catalog.setval('public.authentik_core_user_user_permissions_id_seq', 34, true); +SELECT pg_catalog.setval('public.authentik_core_user_user_permissions_id_seq', 41, true); -- @@ -4461,14 +4807,14 @@ SELECT pg_catalog.setval('public.authentik_outposts_outpost_providers_id_seq', 1 -- Name: authentik_providers_oauth2_accesstoken_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik -- -SELECT pg_catalog.setval('public.authentik_providers_oauth2_accesstoken_id_seq', 1, true); +SELECT pg_catalog.setval('public.authentik_providers_oauth2_accesstoken_id_seq', 3, true); -- -- Name: authentik_providers_oauth2_authorizationcode_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik -- -SELECT pg_catalog.setval('public.authentik_providers_oauth2_authorizationcode_id_seq', 1, true); +SELECT pg_catalog.setval('public.authentik_providers_oauth2_authorizationcode_id_seq', 3, true); -- @@ -4489,7 +4835,14 @@ SELECT pg_catalog.setval('public.authentik_providers_oauth2_oauth2provider_jwks_ -- Name: authentik_providers_oauth2_refreshtoken_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik -- -SELECT pg_catalog.setval('public.authentik_providers_oauth2_refreshtoken_id_seq', 1, true); +SELECT pg_catalog.setval('public.authentik_providers_oauth2_refreshtoken_id_seq', 3, true); + + +-- +-- Name: authentik_providers_scim_scimprovider_property_mappings__id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik +-- + +SELECT pg_catalog.setval('public.authentik_providers_scim_scimprovider_property_mappings__id_seq', 1, false); -- @@ -4513,6 +4866,27 @@ SELECT pg_catalog.setval('public.authentik_stages_authenticator_duo_duodevice_id SELECT pg_catalog.setval('public.authentik_stages_authenticator_sms_smsdevice_id_seq', 1, false); +-- +-- Name: authentik_stages_authenticator_static_staticdevice_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik +-- + +SELECT pg_catalog.setval('public.authentik_stages_authenticator_static_staticdevice_id_seq', 1, false); + + +-- +-- Name: authentik_stages_authenticator_static_statictoken_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik +-- + +SELECT pg_catalog.setval('public.authentik_stages_authenticator_static_statictoken_id_seq', 1, false); + + +-- +-- Name: authentik_stages_authenticator_totp_totpdevice_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik +-- + +SELECT pg_catalog.setval('public.authentik_stages_authenticator_totp_totpdevice_id_seq', 1, false); + + -- -- Name: authentik_stages_authenticator_validate_authenticatorval_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik -- @@ -4545,7 +4919,7 @@ SELECT pg_catalog.setval('public.authentik_stages_identification_identifications -- Name: authentik_stages_prompt_promptstage_fields_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik -- -SELECT pg_catalog.setval('public.authentik_stages_prompt_promptstage_fields_id_seq', 22, true); +SELECT pg_catalog.setval('public.authentik_stages_prompt_promptstage_fields_id_seq', 68, true); -- @@ -4559,14 +4933,14 @@ SELECT pg_catalog.setval('public.authentik_stages_prompt_promptstage_validation_ -- Name: django_content_type_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik -- -SELECT pg_catalog.setval('public.django_content_type_id_seq', 88, true); +SELECT pg_catalog.setval('public.django_content_type_id_seq', 100, true); -- -- Name: django_migrations_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik -- -SELECT pg_catalog.setval('public.django_migrations_id_seq', 398, true); +SELECT pg_catalog.setval('public.django_migrations_id_seq', 452, true); -- @@ -4580,28 +4954,7 @@ SELECT pg_catalog.setval('public.guardian_groupobjectpermission_id_seq', 1, fals -- Name: guardian_userobjectpermission_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik -- -SELECT pg_catalog.setval('public.guardian_userobjectpermission_id_seq', 24, true); - - --- --- Name: otp_static_staticdevice_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik --- - -SELECT pg_catalog.setval('public.otp_static_staticdevice_id_seq', 1, false); - - --- --- Name: otp_static_statictoken_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik --- - -SELECT pg_catalog.setval('public.otp_static_statictoken_id_seq', 1, false); - - --- --- Name: otp_totp_totpdevice_id_seq; Type: SEQUENCE SET; Schema: public; Owner: authentik --- - -SELECT pg_catalog.setval('public.otp_totp_totpdevice_id_seq', 1, false); +SELECT pg_catalog.setval('public.guardian_userobjectpermission_id_seq', 29, true); -- @@ -4660,6 +5013,14 @@ ALTER TABLE ONLY public.authentik_blueprints_blueprintinstance ADD CONSTRAINT authentik_blueprints_blueprintinstance_managed_key UNIQUE (managed); +-- +-- Name: authentik_blueprints_blueprintinstance authentik_blueprints_blueprintinstance_name_404be626_uniq; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_blueprints_blueprintinstance + ADD CONSTRAINT authentik_blueprints_blueprintinstance_name_404be626_uniq UNIQUE (name); + + -- -- Name: authentik_blueprints_blueprintinstance authentik_blueprints_blueprintinstance_name_path_982f03b6_uniq; Type: CONSTRAINT; Schema: public; Owner: authentik -- @@ -4724,6 +5085,22 @@ ALTER TABLE ONLY public.authentik_core_group ADD CONSTRAINT authentik_core_group_pkey PRIMARY KEY (group_uuid); +-- +-- Name: authentik_core_group_roles authentik_core_group_roles_group_id_role_id_f2df9be5_uniq; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_core_group_roles + ADD CONSTRAINT authentik_core_group_roles_group_id_role_id_f2df9be5_uniq UNIQUE (group_id, role_id); + + +-- +-- Name: authentik_core_group_roles authentik_core_group_roles_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_core_group_roles + ADD CONSTRAINT authentik_core_group_roles_pkey PRIMARY KEY (id); + + -- -- Name: authentik_core_propertymapping authentik_core_propertymapping_managed_key; Type: CONSTRAINT; Schema: public; Owner: authentik -- @@ -4732,6 +5109,14 @@ ALTER TABLE ONLY public.authentik_core_propertymapping ADD CONSTRAINT authentik_core_propertymapping_managed_key UNIQUE (managed); +-- +-- Name: authentik_core_propertymapping authentik_core_propertymapping_name_a457d137_uniq; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_core_propertymapping + ADD CONSTRAINT authentik_core_propertymapping_name_a457d137_uniq UNIQUE (name); + + -- -- Name: authentik_core_propertymapping authentik_core_propertymapping_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- @@ -4748,6 +5133,14 @@ ALTER TABLE ONLY public.authentik_core_provider_property_mappings ADD CONSTRAINT authentik_core_provider__provider_id_propertymapp_cf242fa9_uniq UNIQUE (provider_id, propertymapping_id); +-- +-- Name: authentik_core_provider authentik_core_provider_name_66fc4ef4_uniq; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_core_provider + ADD CONSTRAINT authentik_core_provider_name_66fc4ef4_uniq UNIQUE (name); + + -- -- Name: authentik_core_provider authentik_core_provider_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- @@ -4892,6 +5285,14 @@ ALTER TABLE ONLY public.authentik_core_user ADD CONSTRAINT authentik_core_user_username_key UNIQUE (username); +-- +-- Name: authentik_core_user authentik_core_user_uuid_c7a047d9_uniq; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_core_user + ADD CONSTRAINT authentik_core_user_uuid_c7a047d9_uniq UNIQUE (uuid); + + -- -- Name: authentik_core_usersourceconnection authentik_core_usersourc_user_id_source_id_ad1f5aa7_uniq; Type: CONSTRAINT; Schema: public; Owner: authentik -- @@ -4916,6 +5317,14 @@ ALTER TABLE ONLY public.authentik_crypto_certificatekeypair ADD CONSTRAINT authentik_crypto_certificatekeypair_managed_key UNIQUE (managed); +-- +-- Name: authentik_crypto_certificatekeypair authentik_crypto_certificatekeypair_name_719603a4_uniq; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_crypto_certificatekeypair + ADD CONSTRAINT authentik_crypto_certificatekeypair_name_719603a4_uniq UNIQUE (name); + + -- -- Name: authentik_crypto_certificatekeypair authentik_crypto_certificatekeypair_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- @@ -4924,6 +5333,22 @@ ALTER TABLE ONLY public.authentik_crypto_certificatekeypair ADD CONSTRAINT authentik_crypto_certificatekeypair_pkey PRIMARY KEY (kp_uuid); +-- +-- Name: authentik_enterprise_license authentik_enterprise_license_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_enterprise_license + ADD CONSTRAINT authentik_enterprise_license_pkey PRIMARY KEY (license_uuid); + + +-- +-- Name: authentik_enterprise_licenseusage authentik_enterprise_licenseusage_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_enterprise_licenseusage + ADD CONSTRAINT authentik_enterprise_licenseusage_pkey PRIMARY KEY (usage_uuid); + + -- -- Name: authentik_events_event authentik_events_event_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- @@ -5100,6 +5525,14 @@ ALTER TABLE ONLY public.authentik_outposts_outpost ADD CONSTRAINT authentik_outposts_outpost_managed_key UNIQUE (managed); +-- +-- Name: authentik_outposts_outpost authentik_outposts_outpost_name_b07e0428_uniq; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_outposts_outpost + ADD CONSTRAINT authentik_outposts_outpost_name_b07e0428_uniq UNIQUE (name); + + -- -- Name: authentik_outposts_outpost authentik_outposts_outpost_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- @@ -5116,6 +5549,14 @@ ALTER TABLE ONLY public.authentik_outposts_outpost_providers ADD CONSTRAINT authentik_outposts_outpost_providers_pkey PRIMARY KEY (id); +-- +-- Name: authentik_outposts_outpostserviceconnection authentik_outposts_outpostserviceconnection_name_ec30b5ea_uniq; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_outposts_outpostserviceconnection + ADD CONSTRAINT authentik_outposts_outpostserviceconnection_name_ec30b5ea_uniq UNIQUE (name); + + -- -- Name: authentik_outposts_outpostserviceconnection authentik_outposts_outpostserviceconnection_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- @@ -5172,6 +5613,14 @@ ALTER TABLE ONLY public.authentik_policies_policybinding ADD CONSTRAINT authentik_policies_polic_policy_id_target_id_orde_0a6ac3bd_uniq UNIQUE (policy_id, target_id, "order"); +-- +-- Name: authentik_policies_policy authentik_policies_policy_name_48caa747_uniq; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_policies_policy + ADD CONSTRAINT authentik_policies_policy_name_48caa747_uniq UNIQUE (name); + + -- -- Name: authentik_policies_policy authentik_policies_policy_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- @@ -5216,120 +5665,216 @@ ALTER TABLE ONLY public.authentik_policies_reputation_reputation -- Name: authentik_policies_reputation_reputationpolicy authentik_policies_reputation_reputationpolicy_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.authentik_policies_reputation_reputationpolicy - ADD CONSTRAINT authentik_policies_reputation_reputationpolicy_pkey PRIMARY KEY (policy_ptr_id); +ALTER TABLE ONLY public.authentik_policies_reputation_reputationpolicy + ADD CONSTRAINT authentik_policies_reputation_reputationpolicy_pkey PRIMARY KEY (policy_ptr_id); + + +-- +-- Name: authentik_providers_ldap_ldapprovider authentik_providers_ldap_ldapprovider_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_ldap_ldapprovider + ADD CONSTRAINT authentik_providers_ldap_ldapprovider_pkey PRIMARY KEY (provider_ptr_id); + + +-- +-- Name: authentik_providers_oauth2_oauth2provider_jwks_sources authentik_providers_oaut_oauth2provider_id_oauths_5151c4b7_uniq; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_oauth2_oauth2provider_jwks_sources + ADD CONSTRAINT authentik_providers_oaut_oauth2provider_id_oauths_5151c4b7_uniq UNIQUE (oauth2provider_id, oauthsource_id); + + +-- +-- Name: authentik_providers_oauth2_accesstoken authentik_providers_oauth2_accesstoken_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_oauth2_accesstoken + ADD CONSTRAINT authentik_providers_oauth2_accesstoken_pkey PRIMARY KEY (id); + + +-- +-- Name: authentik_providers_oauth2_authorizationcode authentik_providers_oauth2_authorizationcode_code_key; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_oauth2_authorizationcode + ADD CONSTRAINT authentik_providers_oauth2_authorizationcode_code_key UNIQUE (code); + + +-- +-- Name: authentik_providers_oauth2_authorizationcode authentik_providers_oauth2_authorizationcode_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_oauth2_authorizationcode + ADD CONSTRAINT authentik_providers_oauth2_authorizationcode_pkey PRIMARY KEY (id); + + +-- +-- Name: authentik_providers_oauth2_devicetoken authentik_providers_oauth2_devicetoken_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_oauth2_devicetoken + ADD CONSTRAINT authentik_providers_oauth2_devicetoken_pkey PRIMARY KEY (id); + + +-- +-- Name: authentik_providers_oauth2_oauth2provider authentik_providers_oauth2_oauth2provider_client_id_key; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_oauth2_oauth2provider + ADD CONSTRAINT authentik_providers_oauth2_oauth2provider_client_id_key UNIQUE (client_id); + + +-- +-- Name: authentik_providers_oauth2_oauth2provider_jwks_sources authentik_providers_oauth2_oauth2provider_jwks_sources_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_oauth2_oauth2provider_jwks_sources + ADD CONSTRAINT authentik_providers_oauth2_oauth2provider_jwks_sources_pkey PRIMARY KEY (id); + + +-- +-- Name: authentik_providers_oauth2_oauth2provider authentik_providers_oauth2_oauth2provider_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_oauth2_oauth2provider + ADD CONSTRAINT authentik_providers_oauth2_oauth2provider_pkey PRIMARY KEY (provider_ptr_id); + + +-- +-- Name: authentik_providers_oauth2_refreshtoken authentik_providers_oauth2_refreshtoken_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_oauth2_refreshtoken + ADD CONSTRAINT authentik_providers_oauth2_refreshtoken_pkey PRIMARY KEY (id); + + +-- +-- Name: authentik_providers_oauth2_scopemapping authentik_providers_oauth2_scopemapping_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_oauth2_scopemapping + ADD CONSTRAINT authentik_providers_oauth2_scopemapping_pkey PRIMARY KEY (propertymapping_ptr_id); + + +-- +-- Name: authentik_providers_proxy_proxyprovider authentik_providers_proxy_proxyprovider_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_proxy_proxyprovider + ADD CONSTRAINT authentik_providers_proxy_proxyprovider_pkey PRIMARY KEY (oauth2provider_ptr_id); -- --- Name: authentik_providers_ldap_ldapprovider authentik_providers_ldap_ldapprovider_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_providers_radius_radiusprovider authentik_providers_radius_radiusprovider_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.authentik_providers_ldap_ldapprovider - ADD CONSTRAINT authentik_providers_ldap_ldapprovider_pkey PRIMARY KEY (provider_ptr_id); +ALTER TABLE ONLY public.authentik_providers_radius_radiusprovider + ADD CONSTRAINT authentik_providers_radius_radiusprovider_pkey PRIMARY KEY (provider_ptr_id); -- --- Name: authentik_providers_oauth2_oauth2provider_jwks_sources authentik_providers_oaut_oauth2provider_id_oauths_5151c4b7_uniq; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_providers_saml_samlpropertymapping authentik_providers_saml_samlpropertymapping_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.authentik_providers_oauth2_oauth2provider_jwks_sources - ADD CONSTRAINT authentik_providers_oaut_oauth2provider_id_oauths_5151c4b7_uniq UNIQUE (oauth2provider_id, oauthsource_id); +ALTER TABLE ONLY public.authentik_providers_saml_samlpropertymapping + ADD CONSTRAINT authentik_providers_saml_samlpropertymapping_pkey PRIMARY KEY (propertymapping_ptr_id); -- --- Name: authentik_providers_oauth2_accesstoken authentik_providers_oauth2_accesstoken_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_providers_saml_samlprovider authentik_providers_saml_samlprovider_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.authentik_providers_oauth2_accesstoken - ADD CONSTRAINT authentik_providers_oauth2_accesstoken_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public.authentik_providers_saml_samlprovider + ADD CONSTRAINT authentik_providers_saml_samlprovider_pkey PRIMARY KEY (provider_ptr_id); -- --- Name: authentik_providers_oauth2_authorizationcode authentik_providers_oauth2_authorizationcode_code_key; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_providers_scim_scimgroup authentik_providers_scim_id_group_id_provider_id_9d50d292_uniq; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.authentik_providers_oauth2_authorizationcode - ADD CONSTRAINT authentik_providers_oauth2_authorizationcode_code_key UNIQUE (code); +ALTER TABLE ONLY public.authentik_providers_scim_scimgroup + ADD CONSTRAINT authentik_providers_scim_id_group_id_provider_id_9d50d292_uniq UNIQUE (id, group_id, provider_id); -- --- Name: authentik_providers_oauth2_authorizationcode authentik_providers_oauth2_authorizationcode_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_providers_scim_scimuser authentik_providers_scim_id_user_id_provider_id_d664a1b5_uniq; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.authentik_providers_oauth2_authorizationcode - ADD CONSTRAINT authentik_providers_oauth2_authorizationcode_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public.authentik_providers_scim_scimuser + ADD CONSTRAINT authentik_providers_scim_id_user_id_provider_id_d664a1b5_uniq UNIQUE (id, user_id, provider_id); -- --- Name: authentik_providers_oauth2_devicetoken authentik_providers_oauth2_devicetoken_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_providers_scim_scimgroup authentik_providers_scim_scimgroup_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.authentik_providers_oauth2_devicetoken - ADD CONSTRAINT authentik_providers_oauth2_devicetoken_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public.authentik_providers_scim_scimgroup + ADD CONSTRAINT authentik_providers_scim_scimgroup_pkey PRIMARY KEY (id); -- --- Name: authentik_providers_oauth2_oauth2provider authentik_providers_oauth2_oauth2provider_client_id_key; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_providers_scim_scimmapping authentik_providers_scim_scimmapping_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.authentik_providers_oauth2_oauth2provider - ADD CONSTRAINT authentik_providers_oauth2_oauth2provider_client_id_key UNIQUE (client_id); +ALTER TABLE ONLY public.authentik_providers_scim_scimmapping + ADD CONSTRAINT authentik_providers_scim_scimmapping_pkey PRIMARY KEY (propertymapping_ptr_id); -- --- Name: authentik_providers_oauth2_oauth2provider_jwks_sources authentik_providers_oauth2_oauth2provider_jwks_sources_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_providers_scim_scimprovider_property_mappings_group authentik_providers_scim_scimprovider_id_property_32317bb5_uniq; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.authentik_providers_oauth2_oauth2provider_jwks_sources - ADD CONSTRAINT authentik_providers_oauth2_oauth2provider_jwks_sources_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public.authentik_providers_scim_scimprovider_property_mappings_group + ADD CONSTRAINT authentik_providers_scim_scimprovider_id_property_32317bb5_uniq UNIQUE (scimprovider_id, propertymapping_id); -- --- Name: authentik_providers_oauth2_oauth2provider authentik_providers_oauth2_oauth2provider_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_providers_scim_scimprovider authentik_providers_scim_scimprovider_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.authentik_providers_oauth2_oauth2provider - ADD CONSTRAINT authentik_providers_oauth2_oauth2provider_pkey PRIMARY KEY (provider_ptr_id); +ALTER TABLE ONLY public.authentik_providers_scim_scimprovider + ADD CONSTRAINT authentik_providers_scim_scimprovider_pkey PRIMARY KEY (provider_ptr_id); -- --- Name: authentik_providers_oauth2_refreshtoken authentik_providers_oauth2_refreshtoken_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_providers_scim_scimprovider_property_mappings_group authentik_providers_scim_scimprovider_property_mappings_gr_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.authentik_providers_oauth2_refreshtoken - ADD CONSTRAINT authentik_providers_oauth2_refreshtoken_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public.authentik_providers_scim_scimprovider_property_mappings_group + ADD CONSTRAINT authentik_providers_scim_scimprovider_property_mappings_gr_pkey PRIMARY KEY (id); -- --- Name: authentik_providers_oauth2_scopemapping authentik_providers_oauth2_scopemapping_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_providers_scim_scimuser authentik_providers_scim_scimuser_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.authentik_providers_oauth2_scopemapping - ADD CONSTRAINT authentik_providers_oauth2_scopemapping_pkey PRIMARY KEY (propertymapping_ptr_id); +ALTER TABLE ONLY public.authentik_providers_scim_scimuser + ADD CONSTRAINT authentik_providers_scim_scimuser_pkey PRIMARY KEY (id); -- --- Name: authentik_providers_proxy_proxyprovider authentik_providers_proxy_proxyprovider_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_rbac_role authentik_rbac_role_group_id_key; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.authentik_providers_proxy_proxyprovider - ADD CONSTRAINT authentik_providers_proxy_proxyprovider_pkey PRIMARY KEY (oauth2provider_ptr_id); +ALTER TABLE ONLY public.authentik_rbac_role + ADD CONSTRAINT authentik_rbac_role_group_id_key UNIQUE (group_id); -- --- Name: authentik_providers_saml_samlpropertymapping authentik_providers_saml_samlpropertymapping_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_rbac_role authentik_rbac_role_name_key; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.authentik_providers_saml_samlpropertymapping - ADD CONSTRAINT authentik_providers_saml_samlpropertymapping_pkey PRIMARY KEY (propertymapping_ptr_id); +ALTER TABLE ONLY public.authentik_rbac_role + ADD CONSTRAINT authentik_rbac_role_name_key UNIQUE (name); -- --- Name: authentik_providers_saml_samlprovider authentik_providers_saml_samlprovider_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_rbac_role authentik_rbac_role_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.authentik_providers_saml_samlprovider - ADD CONSTRAINT authentik_providers_saml_samlprovider_pkey PRIMARY KEY (provider_ptr_id); +ALTER TABLE ONLY public.authentik_rbac_role + ADD CONSTRAINT authentik_rbac_role_pkey PRIMARY KEY (uuid); -- @@ -5781,26 +6326,26 @@ ALTER TABLE ONLY public.guardian_userobjectpermission -- --- Name: otp_static_staticdevice otp_static_staticdevice_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_stages_authenticator_static_staticdevice otp_static_staticdevice_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.otp_static_staticdevice +ALTER TABLE ONLY public.authentik_stages_authenticator_static_staticdevice ADD CONSTRAINT otp_static_staticdevice_pkey PRIMARY KEY (id); -- --- Name: otp_static_statictoken otp_static_statictoken_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_stages_authenticator_static_statictoken otp_static_statictoken_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.otp_static_statictoken +ALTER TABLE ONLY public.authentik_stages_authenticator_static_statictoken ADD CONSTRAINT otp_static_statictoken_pkey PRIMARY KEY (id); -- --- Name: otp_totp_totpdevice otp_totp_totpdevice_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_stages_authenticator_totp_totpdevice otp_totp_totpdevice_pkey; Type: CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.otp_totp_totpdevice +ALTER TABLE ONLY public.authentik_stages_authenticator_totp_totpdevice ADD CONSTRAINT otp_totp_totpdevice_pkey PRIMARY KEY (id); @@ -5839,6 +6384,13 @@ CREATE INDEX auth_permission_content_type_id_2f476e4b ON public.auth_permission CREATE INDEX authentik_blueprints_blueprintinstance_managed_a2959093_like ON public.authentik_blueprints_blueprintinstance USING btree (managed text_pattern_ops); +-- +-- Name: authentik_blueprints_blueprintinstance_name_404be626_like; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_blueprints_blueprintinstance_name_404be626_like ON public.authentik_blueprints_blueprintinstance USING btree (name text_pattern_ops); + + -- -- Name: authentik_c_identif_d9d032_idx; Type: INDEX; Schema: public; Owner: authentik -- @@ -5888,6 +6440,20 @@ CREATE INDEX authentik_core_authenticatedsession_user_id_5055b6cf ON public.auth CREATE INDEX authentik_core_group_parent_id_c2cd3508 ON public.authentik_core_group USING btree (parent_id); +-- +-- Name: authentik_core_group_roles_group_id_43a283e0; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_core_group_roles_group_id_43a283e0 ON public.authentik_core_group_roles USING btree (group_id); + + +-- +-- Name: authentik_core_group_roles_role_id_5b9a84fe; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_core_group_roles_role_id_5b9a84fe ON public.authentik_core_group_roles USING btree (role_id); + + -- -- Name: authentik_core_propertymapping_managed_e1718652_like; Type: INDEX; Schema: public; Owner: authentik -- @@ -5895,6 +6461,20 @@ CREATE INDEX authentik_core_group_parent_id_c2cd3508 ON public.authentik_core_gr CREATE INDEX authentik_core_propertymapping_managed_e1718652_like ON public.authentik_core_propertymapping USING btree (managed text_pattern_ops); +-- +-- Name: authentik_core_propertymapping_name_a457d137_like; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_core_propertymapping_name_a457d137_like ON public.authentik_core_propertymapping USING btree (name text_pattern_ops); + + +-- +-- Name: authentik_core_provider_authentication_flow_id_49dea03c; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_core_provider_authentication_flow_id_49dea03c ON public.authentik_core_provider USING btree (authentication_flow_id); + + -- -- Name: authentik_core_provider_authorization_flow_id_1482437b; Type: INDEX; Schema: public; Owner: authentik -- @@ -5902,6 +6482,20 @@ CREATE INDEX authentik_core_propertymapping_managed_e1718652_like ON public.auth CREATE INDEX authentik_core_provider_authorization_flow_id_1482437b ON public.authentik_core_provider USING btree (authorization_flow_id); +-- +-- Name: authentik_core_provider_backchannel_application_id_a972ebd6; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_core_provider_backchannel_application_id_a972ebd6 ON public.authentik_core_provider USING btree (backchannel_application_id); + + +-- +-- Name: authentik_core_provider_name_66fc4ef4_like; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_core_provider_name_66fc4ef4_like ON public.authentik_core_provider USING btree (name text_pattern_ops); + + -- -- Name: authentik_core_provider_pr_propertymapping_id_7d1de2b7; Type: INDEX; Schema: public; Owner: authentik -- @@ -6049,6 +6643,20 @@ CREATE INDEX authentik_core_usersourceconnection_user_id_7f305d6f ON public.auth CREATE INDEX authentik_crypto_certificatekeypair_managed_a997712a_like ON public.authentik_crypto_certificatekeypair USING btree (managed text_pattern_ops); +-- +-- Name: authentik_crypto_certificatekeypair_name_719603a4_like; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_crypto_certificatekeypair_name_719603a4_like ON public.authentik_crypto_certificatekeypair USING btree (name text_pattern_ops); + + +-- +-- Name: authentik_e_key_523e13_hash; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_e_key_523e13_hash ON public.authentik_enterprise_license USING hash (key); + + -- -- Name: authentik_events_notificat_notificationrule_id_eccbe1d6; Type: INDEX; Schema: public; Owner: authentik -- @@ -6161,6 +6769,13 @@ CREATE INDEX authentik_outposts_dockers_tls_verification_id_769fac22 ON public.a CREATE INDEX authentik_outposts_outpost_managed_e03db044_like ON public.authentik_outposts_outpost USING btree (managed text_pattern_ops); +-- +-- Name: authentik_outposts_outpost_name_b07e0428_like; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_outposts_outpost_name_b07e0428_like ON public.authentik_outposts_outpost USING btree (name text_pattern_ops); + + -- -- Name: authentik_outposts_outpost_providers_outpost_id_eaaf025b; Type: INDEX; Schema: public; Owner: authentik -- @@ -6182,6 +6797,13 @@ CREATE INDEX authentik_outposts_outpost_providers_provider_id_7c5cb603 ON public CREATE INDEX authentik_outposts_outpost_service_connection_id_3eafd830 ON public.authentik_outposts_outpost USING btree (service_connection_id); +-- +-- Name: authentik_outposts_outpostserviceconnection_name_ec30b5ea_like; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_outposts_outpostserviceconnection_name_ec30b5ea_like ON public.authentik_outposts_outpostserviceconnection USING btree (name text_pattern_ops); + + -- -- Name: authentik_p_group_i_5d2d24_idx; Type: INDEX; Schema: public; Owner: authentik -- @@ -6252,6 +6874,13 @@ CREATE INDEX authentik_p_target__2e4d50_idx ON public.authentik_policies_policyb CREATE INDEX authentik_p_user_id_603323_idx ON public.authentik_policies_policybinding USING btree (user_id); +-- +-- Name: authentik_policies_policy_name_48caa747_like; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_policies_policy_name_48caa747_like ON public.authentik_policies_policy USING btree (name text_pattern_ops); + + -- -- Name: authentik_policies_policybinding_group_id_2afb13ee; Type: INDEX; Schema: public; Owner: authentik -- @@ -6413,6 +7042,83 @@ CREATE INDEX authentik_providers_saml_s_verification_kp_id_7b0fbd80 ON public.au CREATE INDEX authentik_providers_saml_samlprovider_signing_kp_id_f1bce700 ON public.authentik_providers_saml_samlprovider USING btree (signing_kp_id); +-- +-- Name: authentik_providers_scim_s_propertymapping_id_7ea3950a; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_providers_scim_s_propertymapping_id_7ea3950a ON public.authentik_providers_scim_scimprovider_property_mappings_group USING btree (propertymapping_id); + + +-- +-- Name: authentik_providers_scim_s_scimprovider_id_c3c41ae0; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_providers_scim_s_scimprovider_id_c3c41ae0 ON public.authentik_providers_scim_scimprovider_property_mappings_group USING btree (scimprovider_id); + + +-- +-- Name: authentik_providers_scim_scimgroup_group_id_071f6834; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_providers_scim_scimgroup_group_id_071f6834 ON public.authentik_providers_scim_scimgroup USING btree (group_id); + + +-- +-- Name: authentik_providers_scim_scimgroup_id_96ae7587_like; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_providers_scim_scimgroup_id_96ae7587_like ON public.authentik_providers_scim_scimgroup USING btree (id text_pattern_ops); + + +-- +-- Name: authentik_providers_scim_scimgroup_provider_id_29f58417; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_providers_scim_scimgroup_provider_id_29f58417 ON public.authentik_providers_scim_scimgroup USING btree (provider_id); + + +-- +-- Name: authentik_providers_scim_scimprovider_filter_group_id_1aca1276; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_providers_scim_scimprovider_filter_group_id_1aca1276 ON public.authentik_providers_scim_scimprovider USING btree (filter_group_id); + + +-- +-- Name: authentik_providers_scim_scimuser_id_64507c6f_like; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_providers_scim_scimuser_id_64507c6f_like ON public.authentik_providers_scim_scimuser USING btree (id text_pattern_ops); + + +-- +-- Name: authentik_providers_scim_scimuser_provider_id_08127ac7; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_providers_scim_scimuser_provider_id_08127ac7 ON public.authentik_providers_scim_scimuser USING btree (provider_id); + + +-- +-- Name: authentik_providers_scim_scimuser_user_id_63e0ca83; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_providers_scim_scimuser_user_id_63e0ca83 ON public.authentik_providers_scim_scimuser USING btree (user_id); + + +-- +-- Name: authentik_rbac_role_name_94e9e999_like; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_rbac_role_name_94e9e999_like ON public.authentik_rbac_role USING btree (name text_pattern_ops); + + +-- +-- Name: authentik_sources_ldap_lda_client_certificate_id_937bc1c9; Type: INDEX; Schema: public; Owner: authentik +-- + +CREATE INDEX authentik_sources_ldap_lda_client_certificate_id_937bc1c9 ON public.authentik_sources_ldap_ldapsource USING btree (client_certificate_id); + + -- -- Name: authentik_sources_ldap_lda_ldapsource_id_4eff95ce; Type: INDEX; Schema: public; Owner: authentik -- @@ -6456,10 +7162,10 @@ CREATE INDEX authentik_sources_saml_samlsource_signing_kp_id_faae605c ON public. -- --- Name: authentik_stages_authent_credential_id_4260aaee_like; Type: INDEX; Schema: public; Owner: authentik +-- Name: authentik_sources_saml_samlsource_verification_kp_id_f76620d9; Type: INDEX; Schema: public; Owner: authentik -- -CREATE INDEX authentik_stages_authent_credential_id_4260aaee_like ON public.authentik_stages_authenticator_webauthn_webauthndevice USING btree (credential_id varchar_pattern_ops); +CREATE INDEX authentik_sources_saml_samlsource_verification_kp_id_f76620d9 ON public.authentik_sources_saml_samlsource USING btree (verification_kp_id); -- @@ -6809,35 +7515,35 @@ CREATE INDEX guardian_userobjectpermission_user_id_d5c1e964 ON public.guardian_u -- Name: otp_static_staticdevice_user_id_7f9cff2b; Type: INDEX; Schema: public; Owner: authentik -- -CREATE INDEX otp_static_staticdevice_user_id_7f9cff2b ON public.otp_static_staticdevice USING btree (user_id); +CREATE INDEX otp_static_staticdevice_user_id_7f9cff2b ON public.authentik_stages_authenticator_static_staticdevice USING btree (user_id); -- -- Name: otp_static_statictoken_device_id_74b7c7d1; Type: INDEX; Schema: public; Owner: authentik -- -CREATE INDEX otp_static_statictoken_device_id_74b7c7d1 ON public.otp_static_statictoken USING btree (device_id); +CREATE INDEX otp_static_statictoken_device_id_74b7c7d1 ON public.authentik_stages_authenticator_static_statictoken USING btree (device_id); -- -- Name: otp_static_statictoken_token_d0a51866; Type: INDEX; Schema: public; Owner: authentik -- -CREATE INDEX otp_static_statictoken_token_d0a51866 ON public.otp_static_statictoken USING btree (token); +CREATE INDEX otp_static_statictoken_token_d0a51866 ON public.authentik_stages_authenticator_static_statictoken USING btree (token); -- -- Name: otp_static_statictoken_token_d0a51866_like; Type: INDEX; Schema: public; Owner: authentik -- -CREATE INDEX otp_static_statictoken_token_d0a51866_like ON public.otp_static_statictoken USING btree (token varchar_pattern_ops); +CREATE INDEX otp_static_statictoken_token_d0a51866_like ON public.authentik_stages_authenticator_static_statictoken USING btree (token varchar_pattern_ops); -- -- Name: otp_totp_totpdevice_user_id_0fb18292; Type: INDEX; Schema: public; Owner: authentik -- -CREATE INDEX otp_totp_totpdevice_user_id_0fb18292 ON public.otp_totp_totpdevice USING btree (user_id); +CREATE INDEX otp_totp_totpdevice_user_id_0fb18292 ON public.authentik_stages_authenticator_totp_totpdevice USING btree (user_id); -- @@ -6888,6 +7594,14 @@ ALTER TABLE ONLY public.authentik_core_authenticatedsession ADD CONSTRAINT authentik_core_authe_user_id_5055b6cf_fk_authentik FOREIGN KEY (user_id) REFERENCES public.authentik_core_user(id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: authentik_core_group_roles authentik_core_group_group_id_43a283e0_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_core_group_roles + ADD CONSTRAINT authentik_core_group_group_id_43a283e0_fk_authentik FOREIGN KEY (group_id) REFERENCES public.authentik_core_group(group_uuid) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: authentik_core_group authentik_core_group_parent_id_c2cd3508_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik -- @@ -6896,6 +7610,22 @@ ALTER TABLE ONLY public.authentik_core_group ADD CONSTRAINT authentik_core_group_parent_id_c2cd3508_fk_authentik FOREIGN KEY (parent_id) REFERENCES public.authentik_core_group(group_uuid) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: authentik_core_group_roles authentik_core_group_role_id_5b9a84fe_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_core_group_roles + ADD CONSTRAINT authentik_core_group_role_id_5b9a84fe_fk_authentik FOREIGN KEY (role_id) REFERENCES public.authentik_rbac_role(uuid) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: authentik_core_provider authentik_core_provi_authentication_flow__49dea03c_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_core_provider + ADD CONSTRAINT authentik_core_provi_authentication_flow__49dea03c_fk_authentik FOREIGN KEY (authentication_flow_id) REFERENCES public.authentik_flows_flow(flow_uuid) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: authentik_core_provider authentik_core_provi_authorization_flow_i_1482437b_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik -- @@ -6904,6 +7634,14 @@ ALTER TABLE ONLY public.authentik_core_provider ADD CONSTRAINT authentik_core_provi_authorization_flow_i_1482437b_fk_authentik FOREIGN KEY (authorization_flow_id) REFERENCES public.authentik_flows_flow(flow_uuid) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: authentik_core_provider authentik_core_provi_backchannel_applicat_a972ebd6_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_core_provider + ADD CONSTRAINT authentik_core_provi_backchannel_applicat_a972ebd6_fk_authentik FOREIGN KEY (backchannel_application_id) REFERENCES public.authentik_core_application(policybindingmodel_ptr_id) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: authentik_core_provider_property_mappings authentik_core_provi_propertymapping_id_7d1de2b7_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik -- @@ -7296,6 +8034,22 @@ ALTER TABLE ONLY public.authentik_providers_proxy_proxyprovider ADD CONSTRAINT authentik_providers__certificate_id_b1e0c422_fk_authentik FOREIGN KEY (certificate_id) REFERENCES public.authentik_crypto_certificatekeypair(kp_uuid) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: authentik_providers_scim_scimprovider authentik_providers__filter_group_id_1aca1276_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_scim_scimprovider + ADD CONSTRAINT authentik_providers__filter_group_id_1aca1276_fk_authentik FOREIGN KEY (filter_group_id) REFERENCES public.authentik_core_group(group_uuid) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: authentik_providers_scim_scimgroup authentik_providers__group_id_071f6834_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_scim_scimgroup + ADD CONSTRAINT authentik_providers__group_id_071f6834_fk_authentik FOREIGN KEY (group_id) REFERENCES public.authentik_core_group(group_uuid) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: authentik_providers_saml_samlprovider authentik_providers__name_id_mapping_id_5d45f77f_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik -- @@ -7328,6 +8082,14 @@ ALTER TABLE ONLY public.authentik_providers_oauth2_oauth2provider_jwks_sources ADD CONSTRAINT authentik_providers__oauthsource_id_41457c33_fk_authentik FOREIGN KEY (oauthsource_id) REFERENCES public.authentik_sources_oauth_oauthsource(source_ptr_id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: authentik_providers_scim_scimprovider_property_mappings_group authentik_providers__propertymapping_id_7ea3950a_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_scim_scimprovider_property_mappings_group + ADD CONSTRAINT authentik_providers__propertymapping_id_7ea3950a_fk_authentik FOREIGN KEY (propertymapping_id) REFERENCES public.authentik_core_propertymapping(pm_uuid) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: authentik_providers_oauth2_scopemapping authentik_providers__propertymapping_ptr__37f1d926_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik -- @@ -7344,6 +8106,30 @@ ALTER TABLE ONLY public.authentik_providers_saml_samlpropertymapping ADD CONSTRAINT authentik_providers__propertymapping_ptr__812e8cfb_fk_authentik FOREIGN KEY (propertymapping_ptr_id) REFERENCES public.authentik_core_propertymapping(pm_uuid) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: authentik_providers_scim_scimmapping authentik_providers__propertymapping_ptr__bd79ae32_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_scim_scimmapping + ADD CONSTRAINT authentik_providers__propertymapping_ptr__bd79ae32_fk_authentik FOREIGN KEY (propertymapping_ptr_id) REFERENCES public.authentik_core_propertymapping(pm_uuid) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: authentik_providers_scim_scimuser authentik_providers__provider_id_08127ac7_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_scim_scimuser + ADD CONSTRAINT authentik_providers__provider_id_08127ac7_fk_authentik FOREIGN KEY (provider_id) REFERENCES public.authentik_providers_scim_scimprovider(provider_ptr_id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: authentik_providers_scim_scimgroup authentik_providers__provider_id_29f58417_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_scim_scimgroup + ADD CONSTRAINT authentik_providers__provider_id_29f58417_fk_authentik FOREIGN KEY (provider_id) REFERENCES public.authentik_providers_scim_scimprovider(provider_ptr_id) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: authentik_providers_oauth2_accesstoken authentik_providers__provider_id_40ef5d24_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik -- @@ -7392,6 +8178,22 @@ ALTER TABLE ONLY public.authentik_providers_oauth2_oauth2provider ADD CONSTRAINT authentik_providers__provider_ptr_id_76903270_fk_authentik FOREIGN KEY (provider_ptr_id) REFERENCES public.authentik_core_provider(id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: authentik_providers_scim_scimprovider authentik_providers__provider_ptr_id_77bcf9bd_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_scim_scimprovider + ADD CONSTRAINT authentik_providers__provider_ptr_id_77bcf9bd_fk_authentik FOREIGN KEY (provider_ptr_id) REFERENCES public.authentik_core_provider(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: authentik_providers_radius_radiusprovider authentik_providers__provider_ptr_id_908e5d8a_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_radius_radiusprovider + ADD CONSTRAINT authentik_providers__provider_ptr_id_908e5d8a_fk_authentik FOREIGN KEY (provider_ptr_id) REFERENCES public.authentik_core_provider(id) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: authentik_providers_ldap_ldapprovider authentik_providers__provider_ptr_id_deae38fd_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik -- @@ -7400,6 +8202,14 @@ ALTER TABLE ONLY public.authentik_providers_ldap_ldapprovider ADD CONSTRAINT authentik_providers__provider_ptr_id_deae38fd_fk_authentik FOREIGN KEY (provider_ptr_id) REFERENCES public.authentik_core_provider(id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: authentik_providers_scim_scimprovider_property_mappings_group authentik_providers__scimprovider_id_c3c41ae0_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_scim_scimprovider_property_mappings_group + ADD CONSTRAINT authentik_providers__scimprovider_id_c3c41ae0_fk_authentik FOREIGN KEY (scimprovider_id) REFERENCES public.authentik_providers_scim_scimprovider(provider_ptr_id) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: authentik_providers_ldap_ldapprovider authentik_providers__search_group_id_64713cfa_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik -- @@ -7432,6 +8242,14 @@ ALTER TABLE ONLY public.authentik_providers_oauth2_devicetoken ADD CONSTRAINT authentik_providers__user_id_17317b6d_fk_authentik FOREIGN KEY (user_id) REFERENCES public.authentik_core_user(id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: authentik_providers_scim_scimuser authentik_providers__user_id_63e0ca83_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_providers_scim_scimuser + ADD CONSTRAINT authentik_providers__user_id_63e0ca83_fk_authentik FOREIGN KEY (user_id) REFERENCES public.authentik_core_user(id) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: authentik_providers_oauth2_accesstoken authentik_providers__user_id_7261588f_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik -- @@ -7464,6 +8282,22 @@ ALTER TABLE ONLY public.authentik_providers_saml_samlprovider ADD CONSTRAINT authentik_providers__verification_kp_id_7b0fbd80_fk_authentik FOREIGN KEY (verification_kp_id) REFERENCES public.authentik_crypto_certificatekeypair(kp_uuid) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: authentik_rbac_role authentik_rbac_role_group_id_d8509027_fk_auth_group_id; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_rbac_role + ADD CONSTRAINT authentik_rbac_role_group_id_d8509027_fk_auth_group_id FOREIGN KEY (group_id) REFERENCES public.auth_group(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: authentik_sources_ldap_ldapsource authentik_sources_ld_client_certificate_i_937bc1c9_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_sources_ldap_ldapsource + ADD CONSTRAINT authentik_sources_ld_client_certificate_i_937bc1c9_fk_authentik FOREIGN KEY (client_certificate_id) REFERENCES public.authentik_crypto_certificatekeypair(kp_uuid) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: authentik_sources_ldap_ldapsource_property_mappings_group authentik_sources_ld_ldapsource_id_4eff95ce_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik -- @@ -7576,6 +8410,14 @@ ALTER TABLE ONLY public.authentik_sources_saml_usersamlsourceconnection ADD CONSTRAINT authentik_sources_sa_usersourceconnection_b6d9dcfe_fk_authentik FOREIGN KEY (usersourceconnection_ptr_id) REFERENCES public.authentik_core_usersourceconnection(id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: authentik_sources_saml_samlsource authentik_sources_sa_verification_kp_id_f76620d9_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- + +ALTER TABLE ONLY public.authentik_sources_saml_samlsource + ADD CONSTRAINT authentik_sources_sa_verification_kp_id_f76620d9_fk_authentik FOREIGN KEY (verification_kp_id) REFERENCES public.authentik_crypto_certificatekeypair(kp_uuid) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: authentik_stages_authenticator_validate_authenticatorvalida3e25 authentik_stages_aut_authenticatorvalidat_aff63c61_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik -- @@ -8065,26 +8907,26 @@ ALTER TABLE ONLY public.guardian_userobjectpermission -- --- Name: otp_static_staticdevice otp_static_staticdev_user_id_7f9cff2b_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_stages_authenticator_static_staticdevice otp_static_staticdev_user_id_7f9cff2b_fk_authentik; Type: FK CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.otp_static_staticdevice +ALTER TABLE ONLY public.authentik_stages_authenticator_static_staticdevice ADD CONSTRAINT otp_static_staticdev_user_id_7f9cff2b_fk_authentik FOREIGN KEY (user_id) REFERENCES public.authentik_core_user(id) DEFERRABLE INITIALLY DEFERRED; -- --- Name: otp_static_statictoken otp_static_statictok_device_id_74b7c7d1_fk_otp_stati; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_stages_authenticator_static_statictoken otp_static_statictok_device_id_74b7c7d1_fk_otp_stati; Type: FK CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.otp_static_statictoken - ADD CONSTRAINT otp_static_statictok_device_id_74b7c7d1_fk_otp_stati FOREIGN KEY (device_id) REFERENCES public.otp_static_staticdevice(id) DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE ONLY public.authentik_stages_authenticator_static_statictoken + ADD CONSTRAINT otp_static_statictok_device_id_74b7c7d1_fk_otp_stati FOREIGN KEY (device_id) REFERENCES public.authentik_stages_authenticator_static_staticdevice(id) DEFERRABLE INITIALLY DEFERRED; -- --- Name: otp_totp_totpdevice otp_totp_totpdevice_user_id_0fb18292_fk_authentik_core_user_id; Type: FK CONSTRAINT; Schema: public; Owner: authentik +-- Name: authentik_stages_authenticator_totp_totpdevice otp_totp_totpdevice_user_id_0fb18292_fk_authentik_core_user_id; Type: FK CONSTRAINT; Schema: public; Owner: authentik -- -ALTER TABLE ONLY public.otp_totp_totpdevice +ALTER TABLE ONLY public.authentik_stages_authenticator_totp_totpdevice ADD CONSTRAINT otp_totp_totpdevice_user_id_0fb18292_fk_authentik_core_user_id FOREIGN KEY (user_id) REFERENCES public.authentik_core_user(id) DEFERRABLE INITIALLY DEFERRED; diff --git a/devenv/docker/blocks/auth/authentik/docker-compose.yaml b/devenv/docker/blocks/auth/authentik/docker-compose.yaml index 997652afd9325..5457ed51460dd 100644 --- a/devenv/docker/blocks/auth/authentik/docker-compose.yaml +++ b/devenv/docker/blocks/auth/authentik/docker-compose.yaml @@ -39,7 +39,7 @@ - "authentik:authentik" authentik: - image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2023.10.4} + image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2023.10.6} restart: unless-stopped container_name: authentik command: server @@ -66,7 +66,7 @@ - "authentikredis:authentikredis" authentik-worker: - image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2023.10.4} + image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2023.10.6} restart: unless-stopped container_name: authentik-worker command: worker diff --git a/devenv/docker/blocks/auth/jwt_proxy/cloak.sql b/devenv/docker/blocks/auth/jwt_proxy/cloak.sql index 653caaa1e2223..ec1c6f005a5b7 100644 --- a/devenv/docker/blocks/auth/jwt_proxy/cloak.sql +++ b/devenv/docker/blocks/auth/jwt_proxy/cloak.sql @@ -2,8 +2,8 @@ -- PostgreSQL database dump -- --- Dumped from database version 12.2 (Debian 12.2-2.pgdg100+1) --- Dumped by pg_dump version 12.2 (Debian 12.2-2.pgdg100+1) +-- Dumped from database version 16.1 +-- Dumped by pg_dump version 16.1 SET statement_timeout = 0; SET lock_timeout = 0; @@ -386,7 +386,7 @@ CREATE TABLE public.component_config ( id character varying(36) NOT NULL, component_id character varying(36) NOT NULL, name character varying(255) NOT NULL, - value character varying(4000) + value text ); @@ -488,7 +488,8 @@ CREATE TABLE public.event_entity ( session_id character varying(255), event_time bigint, type character varying(255), - user_id character varying(255) + user_id character varying(255), + details_json_long_value text ); @@ -1097,7 +1098,7 @@ ALTER TABLE public.resource_scope OWNER TO keycloak; CREATE TABLE public.resource_server ( id character varying(36) NOT NULL, allow_rs_remote_mgmt boolean DEFAULT false NOT NULL, - policy_enforce_mode character varying(15) NOT NULL, + policy_enforce_mode smallint NOT NULL, decision_strategy smallint DEFAULT 1 NOT NULL ); @@ -1132,8 +1133,8 @@ CREATE TABLE public.resource_server_policy ( name character varying(255) NOT NULL, description character varying(255), type character varying(255) NOT NULL, - decision_strategy character varying(20), - logic character varying(20), + decision_strategy smallint, + logic smallint, resource_server_id character varying(36) NOT NULL, owner character varying(255) ); @@ -1484,7 +1485,6 @@ bfbcc1e9-f129-4336-a0cd-b6960a811bd9 \N direct-grant-validate-password master 5f 5a2470c4-3136-4cf0-8383-e74a413ccd48 \N direct-grant-validate-otp master 99865746-4232-46f0-84b5-20952fe9eb51 0 20 f \N \N 47c96943-ad68-4d93-afff-ff54fc86eb0b \N registration-page-form master 1695e7d2-ad80-4502-8479-8121a6e2a2f0 0 10 t 8fb96669-d28d-4173-a8f4-dc24d41c7d27 \N a6678624-1bd4-4793-bfac-68551cf0ac7c \N registration-user-creation master 8fb96669-d28d-4173-a8f4-dc24d41c7d27 0 20 f \N \N -5b45827d-5dfd-4152-a99e-373cb975ef87 \N registration-profile-action master 8fb96669-d28d-4173-a8f4-dc24d41c7d27 0 40 f \N \N 5a2fb70d-63ae-4604-b37c-ae043d6a900d \N registration-password-action master 8fb96669-d28d-4173-a8f4-dc24d41c7d27 0 50 f \N \N 0b06a30e-daa7-498a-9fdb-899abbf36450 \N registration-recaptcha-action master 8fb96669-d28d-4173-a8f4-dc24d41c7d27 3 60 f \N \N b6ab0b5d-8184-4609-bb81-da8413dfb858 \N reset-credentials-choose-user master 954b046d-2b24-405e-84ee-c44ffe603df2 0 10 f \N \N @@ -1511,11 +1511,6 @@ d600bb67-e258-44be-8f69-f1bae9c35a0f \N idp-username-password-form master ca3a36 90cc39a9-cddb-49bd-b9f5-d64d03341333 \N auth-otp-form master a7d23655-efbb-4950-8ab6-50dbc85681a0 0 20 f \N \N b0634301-594e-42db-9736-6c90ebbeb8b2 \N http-basic-authenticator master 57c56583-d91c-4399-bd15-05a1a17d48c1 0 10 f \N \N 34fa4d44-716b-4b2a-b98e-aa9748154292 \N docker-http-basic-authenticator master 032b05cf-0007-44da-a370-b42039f6b762 0 10 f \N \N -4838277a-46ea-4d95-bd86-d8dc6fdce352 \N no-cookie-redirect master 1c7af06b-3085-46c3-849c-34c67f581b9e 0 10 f \N \N -59a349ee-20ce-42d8-b20b-8f902c09742d \N \N master 1c7af06b-3085-46c3-849c-34c67f581b9e 0 20 t 85c00992-77dd-4262-8744-a9dd8521e98e \N -d9b5fa46-6595-4406-9841-2c0720dbf644 \N basic-auth master 85c00992-77dd-4262-8744-a9dd8521e98e 0 10 f \N \N -3a4ee6f1-1528-47c7-aeda-f317248b3b93 \N basic-auth-otp master 85c00992-77dd-4262-8744-a9dd8521e98e 3 20 f \N \N -014847fc-06df-4ddf-a8f2-deeb0f1eb59a \N auth-spnego master 85c00992-77dd-4262-8744-a9dd8521e98e 3 30 f \N \N b46bc4f6-2fe5-44d5-b47f-36880742cf50 \N auth-cookie grafana a38aeb47-f27e-4e68-82ff-7cc7371a47a7 2 10 f \N \N 6cec48cc-066a-4e3e-8158-85351bfa4c27 \N auth-spnego grafana a38aeb47-f27e-4e68-82ff-7cc7371a47a7 3 20 f \N \N 63c55c5a-ad11-4f83-9d6e-d8ca2efcaf66 \N identity-provider-redirector grafana a38aeb47-f27e-4e68-82ff-7cc7371a47a7 2 25 f \N \N @@ -1530,7 +1525,6 @@ b46bc4f6-2fe5-44d5-b47f-36880742cf50 \N auth-cookie grafana a38aeb47-f27e-4e68-8 d87abeef-9f1d-46f5-9f36-acd7eaf21a72 \N direct-grant-validate-otp grafana b3491338-0630-4232-97e7-a518c254b248 0 20 f \N \N 4f204bab-0311-44b4-80b6-37d23fd0fd5a \N registration-page-form grafana 9d02badd-cb1c-4655-bf5e-f888861433ff 0 10 t c3ed2ad1-cfb4-49fa-8c75-cf5047527c68 \N 2d4ee446-623c-42a0-8d4a-9f6c4f7f28ec \N registration-user-creation grafana c3ed2ad1-cfb4-49fa-8c75-cf5047527c68 0 20 f \N \N -d806effc-dd17-4468-9a98-4e1c2f9e799d \N registration-profile-action grafana c3ed2ad1-cfb4-49fa-8c75-cf5047527c68 0 40 f \N \N 306fa749-c191-43c6-bf04-0eb6d3d02732 \N registration-password-action grafana c3ed2ad1-cfb4-49fa-8c75-cf5047527c68 0 50 f \N \N 7de9bbee-eb3d-4f3e-a134-e7e8d4a6df25 \N registration-recaptcha-action grafana c3ed2ad1-cfb4-49fa-8c75-cf5047527c68 3 60 f \N \N 8a31d18e-1622-4eac-8eff-9434fa9cade3 \N reset-credentials-choose-user grafana 3085fb68-fc1f-4e1c-a8be-33fb45194b04 0 10 f \N \N @@ -1557,11 +1551,6 @@ b8ce6905-73eb-493b-9ce1-408ec55e3c46 \N conditional-user-configured grafana 21fb 54d7692d-c0e3-40ef-9ef9-d9e8227d618d \N auth-otp-form grafana 21fbd70a-286f-431a-abc4-fbf6590fcdc3 0 20 f \N \N 3722f24d-6ffb-4b20-a481-1fd8a17afdf6 \N http-basic-authenticator grafana ba53abf5-9a64-4371-810b-67378eb3d781 0 10 f \N \N a700b05f-a61d-4eeb-ad75-1a3df05ed429 \N docker-http-basic-authenticator grafana 95e02703-f5bc-4e04-8bef-f6adc2d8173f 0 10 f \N \N -035c4f94-03a6-4101-a729-f3c01ee4c490 \N no-cookie-redirect grafana f397495e-d073-4ef1-babf-569a338db596 0 10 f \N \N -29f310db-b302-44b2-9182-4b91648cbabf \N \N grafana f397495e-d073-4ef1-babf-569a338db596 0 20 t 56c40f89-4d69-46fd-bb18-d6c01808d2af \N -4e7d257c-e013-4597-a44d-b186a85606af \N basic-auth grafana 56c40f89-4d69-46fd-bb18-d6c01808d2af 0 10 f \N \N -2ba05817-a59f-4e72-a565-f3b4591390dc \N basic-auth-otp grafana 56c40f89-4d69-46fd-bb18-d6c01808d2af 3 20 f \N \N -5db9c781-6718-4674-a833-9a4ac3e8212e \N auth-spnego grafana 56c40f89-4d69-46fd-bb18-d6c01808d2af 3 30 f \N \N 5f032dbb-bd37-425b-af1e-ba555c7a8245 \N \N grafana b478ecfb-db7e-4797-a245-8fc3b4dec884 1 30 t b3491338-0630-4232-97e7-a518c254b248 \N \. @@ -1589,8 +1578,6 @@ ca3a3600-552c-4849-9a9d-826c8aa3e646 Verify Existing Account by Re-authenticatio a7d23655-efbb-4950-8ab6-50dbc85681a0 First broker login - Conditional OTP Flow to determine if the OTP is required for the authentication master basic-flow f t 57c56583-d91c-4399-bd15-05a1a17d48c1 saml ecp SAML ECP Profile Authentication Flow master basic-flow t t 032b05cf-0007-44da-a370-b42039f6b762 docker auth Used by Docker clients to authenticate against the IDP master basic-flow t t -1c7af06b-3085-46c3-849c-34c67f581b9e http challenge An authentication flow based on challenge-response HTTP Authentication Schemes master basic-flow t t -85c00992-77dd-4262-8744-a9dd8521e98e Authentication Options Authentication options. master basic-flow f t a38aeb47-f27e-4e68-82ff-7cc7371a47a7 browser browser based authentication grafana basic-flow t t c53e357f-e276-43aa-b36c-46366a7ffd35 forms Username, password, otp and other auth forms. grafana basic-flow f t cf4831e9-3e1d-452e-984e-e6d4d9eeafb5 Browser - Conditional OTP Flow to determine if the OTP is required for the authentication grafana basic-flow f t @@ -1609,8 +1596,6 @@ df86516c-dcb1-41a8-877e-eb8805bcac8c User creation or linking Flow for the exist 21fbd70a-286f-431a-abc4-fbf6590fcdc3 First broker login - Conditional OTP Flow to determine if the OTP is required for the authentication grafana basic-flow f t ba53abf5-9a64-4371-810b-67378eb3d781 saml ecp SAML ECP Profile Authentication Flow grafana basic-flow t t 95e02703-f5bc-4e04-8bef-f6adc2d8173f docker auth Used by Docker clients to authenticate against the IDP grafana basic-flow t t -f397495e-d073-4ef1-babf-569a338db596 http challenge An authentication flow based on challenge-response HTTP Authentication Schemes grafana basic-flow t t -56c40f89-4d69-46fd-bb18-d6c01808d2af Authentication Options Authentication options. grafana basic-flow f t \. @@ -1840,6 +1825,7 @@ d30340a8-630b-416e-8c93-3ccf932d34f3 false display.on.consent.screen d30340a8-630b-416e-8c93-3ccf932d34f3 false include.in.token.scope a96603aa-e6db-4b3e-8baa-679c75ccfc8f false display.on.consent.screen a96603aa-e6db-4b3e-8baa-679c75ccfc8f false include.in.token.scope +c61f5b19-c17e-49a1-91b8-a0296411b928 gui.order \. @@ -1979,6 +1965,7 @@ a8698f4f-5fa1-4baa-be05-87d03052af49 c61f5b19-c17e-49a1-91b8-a0296411b928 f 169f1dea-80f0-4a99-8509-9abb70ab0a5c a5bb3a5f-fd26-4be6-9557-26e20a03d33d f 169f1dea-80f0-4a99-8509-9abb70ab0a5c d6ffe9fc-a03c-4496-85dc-dbb5e7754587 f 169f1dea-80f0-4a99-8509-9abb70ab0a5c c61f5b19-c17e-49a1-91b8-a0296411b928 f +09b79548-8426-4c0e-8e0b-7488467532c7 c61f5b19-c17e-49a1-91b8-a0296411b928 t \. @@ -2086,6 +2073,7 @@ d47557df-07ff-4cae-bedd-b584c0697852 f95566ed-b955-4668-88fc-e7413fd98615 allowe 2ae4acc4-c6d6-4d2f-92c6-3a222a7d078a 28d2466c-5af6-4786-a8a2-c25d6cb4833f allowed-protocol-mapper-types oidc-sha256-pairwise-sub-mapper 880a35e4-65a1-4697-836d-fbc46641d676 28d2466c-5af6-4786-a8a2-c25d6cb4833f allowed-protocol-mapper-types oidc-full-name-mapper b087e631-754f-4c03-8cfc-354c7e7456fe 28d2466c-5af6-4786-a8a2-c25d6cb4833f allowed-protocol-mapper-types oidc-usermodel-property-mapper +258b8704-0587-4208-996f-96627992e370 80af2f23-4a51-498a-a011-732cf9cfa8f8 priority 100 079e63d4-0862-4e63-a62f-1a168cbbc25c 28d2466c-5af6-4786-a8a2-c25d6cb4833f allowed-protocol-mapper-types saml-user-property-mapper da61fbc2-7533-4bc1-b0c4-357db9f108e4 28d2466c-5af6-4786-a8a2-c25d6cb4833f allowed-protocol-mapper-types oidc-address-mapper 37a4be58-26b0-4d4a-9c41-a89b27fc25f6 28d2466c-5af6-4786-a8a2-c25d6cb4833f allowed-protocol-mapper-types saml-user-attribute-mapper @@ -2112,7 +2100,6 @@ bb8c28ca-bb74-4a07-82c3-8293354517be 9877acf2-e1cc-4038-a3c2-75db29b432e0 secret 64ea89c8-a2ce-4d2d-896d-aa49e7ca9fcc 9877acf2-e1cc-4038-a3c2-75db29b432e0 kid 7d80efc5-222b-4b6d-9b99-c3b516a59733 bec1483f-75e7-46e8-916c-102db4cbefb5 80af2f23-4a51-498a-a011-732cf9cfa8f8 certificate MIICnTCCAYUCBgF+u1ir8jANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdncmFmYW5hMB4XDTIyMDIwMjE2NDkxN1oXDTMyMDIwMjE2NTA1N1owEjEQMA4GA1UEAwwHZ3JhZmFuYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKg5kB303DkDs5jSW1b7b7kvKfxIJorHD+7wPz2TcisfTu7rchrqAJiR/HtsPICyAw1h5ef8fGgCJf/k0z00osl/COvK8iHUdvGUnubuKUXaVwlbyaTnnyjSMUAkx+67OCrkY9B2drtZrtVc+fwnggqCsCkpoXg97tcUyfPlcUJnanxsYbirZ5KH+/e+x1jlsuBiwxascmB4IoT/zJknk5l1IVXmSOiDgqhzKRfHhVlRijOlfKyCn/EDtiv7wyQTP9wvd97zGPJqkkF2yNxueMftJsgGkF6+CZMY71BioOWAt2V8OwI32b/1v30DhtBmKdoUNGpEeCjSk91zzZqTFZUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEABlW64QxuREB81VMGsyhj4Q5RykFaVuD5O8YlwUpmVfAVLzb0Drf54Kn4bnpnckKyYV+T+HsN4QXt81UE41xH0Aai2H3vrGH+PJf6aLPCDE+jpMqtN3n6IgImJXJPL8upMfhhWDv4nkM4uynEwWupzmrKi4oJuTETSMktJby4o6//XWnCzCVMoAGFJU4gtjBUzOMLW26zD+yc+BuUtfR3HzItVHSZKQSNSFO0kVS68RgrER8qJw07z3BOJ2bPpPM0PYyEngGMaowz/T6lI32ymGMWYMAnslthS1KAW9xcTBwnrW1nMhe5a0LPxIktys/wJtxIHZLc5sOddGT4xYklLg== 48e8b904-1393-43a4-aaa1-30e2d9634b36 80af2f23-4a51-498a-a011-732cf9cfa8f8 privateKey MIIEowIBAAKCAQEAqDmQHfTcOQOzmNJbVvtvuS8p/EgmiscP7vA/PZNyKx9O7utyGuoAmJH8e2w8gLIDDWHl5/x8aAIl/+TTPTSiyX8I68ryIdR28ZSe5u4pRdpXCVvJpOefKNIxQCTH7rs4KuRj0HZ2u1mu1Vz5/CeCCoKwKSmheD3u1xTJ8+VxQmdqfGxhuKtnkof7977HWOWy4GLDFqxyYHgihP/MmSeTmXUhVeZI6IOCqHMpF8eFWVGKM6V8rIKf8QO2K/vDJBM/3C933vMY8mqSQXbI3G54x+0myAaQXr4JkxjvUGKg5YC3ZXw7AjfZv/W/fQOG0GYp2hQ0akR4KNKT3XPNmpMVlQIDAQABAoIBAF3kEt3FZoyj1j97WOOJXmf7PPHDy081n1z61jEl9FjBFqse2gbPiBmfkU3JsVMbB70WYN1D/KOIX3EdZBELKbhQoMgJ826SSPi4vJ+jWYHVRTLB+h+B70E3X6mvXa+O6uB1rIgTNl2Gxp/rTtM/scLwAiZXR/n2hzGgNr9b1gT7D7kyCUIKDiJsQed2pA6ZbSDNTQQDE2qeN/Rr/+VV4XRuIIdBXn+Brap8ihjx4Gnn/SDBKM3MZoacwgS+9CZNhLjs8Ou4xD+KyitbbGGY4ZK/ZW2eSAH4ra8vVGTDK6bJrMTAE/73A23Evp/sKtRkFqcNumJ3rCykcAJorDUK48ECgYEA0TpkTwK6f2a72ncmw7Xzy4zdu+FdkI9PzWmS4IrbYOYRwsRMQLowsUPIdtU0EMrs7PRl1dSQa3/kAnw5J8wogkqVf4dJ3/lb/N5qtsjHP87N35QMXnMdE/BC6o7t5e+iRoHMloAuSBonGC9uBn1UeHWrNeLCB8+upJoSEBKbEQ0CgYEAzdSoKL4CJo31gVEqoJFPzPemsYGmV6Zl1Vj8mqMbQd8wcJoSJWbNV/3sfpIqdvOJOXQrzm+LYIbhhUnS3ZlGBmFsqUtC7dw3AqLg53msCyJLxmCIib0m/ONCY9qczV4OkUM6HVAdgzmMqqhk5ZB70IdUyStn2meqXKXLGFlLJKkCgYEAuizHTTcUVIFJ7x/PMp8ZjKqQM7pZ02Rykkm7FGr6wsJ2U2TwpTgIU/QI0RTt+3NWV5MxepBm4gEvFrcK9MrJ0QYk+RGdPttYay5Ors8B3Vlb//Jw/ypXWYKVSLpeHhiZwTuGnPT6OdZrqy2pLcUgAQBTlONt3B2FPZqLMBoeOZECgYBuCwm0bpF7x13AO4LMwaOmc6jdMfGa3s2G2MKEcjt6ZjbhnJ2i/Wk/Z/RuXvrxCZcN7nwVLDGZ88LSnftsmiuD8cZEZIZt4NRQRoBzgOtoMHfOoYGeElCr11yBQjme2nBzXTvOvCxrIfOAsfLvgOWRQSklPF2TuOSuD72bUPIJsQKBgBbeb8rIXKRRFC3A0IkKm8C51gG8mMgPdxZMKKuVhpb6D8B5aPh+WW44yNOpn7LInYI4jzf3Kv+kcj0PNH3nFaplnyCGFfmUIg27SAsRcrJcTlXtIKooZw6oPU+dQfAo11suArVAVD6OQc/7z99VoIpLN0cUHfS/g0H1dx/r/32o -258b8704-0587-4208-996f-96627992e370 80af2f23-4a51-498a-a011-732cf9cfa8f8 priority 100 12cd94c5-bd7e-4a5c-95e2-256b0bcf14bd 261e38de-3e1f-40a3-9200-f5aac1975701 allowed-protocol-mapper-types oidc-address-mapper f33b34f1-3793-4786-a281-b286fce52f45 261e38de-3e1f-40a3-9200-f5aac1975701 allowed-protocol-mapper-types saml-role-list-mapper d056c1de-9aa1-46a0-a644-fafb33088967 261e38de-3e1f-40a3-9200-f5aac1975701 allowed-protocol-mapper-types saml-user-property-mapper @@ -2226,11 +2213,11 @@ b4c89bc8-3945-4963-80b5-3da62e8c54ad 18a7066b-fe71-410e-9581-69f78347ec29 -- COPY public.credential (id, salt, type, user_id, created_date, user_label, secret_data, credential_data, priority) FROM stdin; -d4b2c483-1dd3-47f6-86bf-42548009918d \N password 74e29604-ff35-42bb-a26d-4d0b81ef0917 1643820449817 \N {"value":"Hou7HlbGvohOx6II0VSCP4BIGI4Cyzy+BcXbPUQe/kaMQzNU77kH2pOKZ236UPfkiCyOLe7A3oS0afExA+ymAQ==","salt":"urXvCw0KdWf9s74km4G+lA==","additionalParameters":{}} {"hashIterations":27500,"algorithm":"pbkdf2-sha256","additionalParameters":{}} 10 cb2bd4ed-94b8-4259-bcaa-9250c3fb28d3 \N password 6db3c5e5-b84b-4f9d-a7a8-8d05b03c929d 1657026827644 \N {"value":"q3Z59Nh/5bdezDEpCwEbMPu8d+VgJ5WetafXkR8l0FlsTTkSDQgW+j6GaM3seJR93p3/jCxyfsvZl062d1pq7w==","salt":"ohuHnjLnwF9dBZ38DRJJWg==","additionalParameters":{}} {"hashIterations":27500,"algorithm":"pbkdf2-sha256","additionalParameters":{}} 10 -b58e1964-6466-40b2-879c-982b724d7f9c \N password 88692d07-bb9a-46cf-844c-7ff5c529cd04 1657026904515 \N {"value":"+/0zWjiJyE3+dCOEf0SO6G3n1/LsFAVoDAZREKTfN4vQ5xJH8srJoCjxcgb+bI1crMr8gknDlFyGRy7CpYn2VQ==","salt":"v/2okNt3wGOZz+x4DjOCDQ==","additionalParameters":{}} {"hashIterations":27500,"algorithm":"pbkdf2-sha256","additionalParameters":{}} 10 -3ff7dd8f-a299-4b51-bf5d-99665ccfd313 \N password 8f58cbec-6e40-4bab-bff0-1c5ff899fe2e 1657026943075 \N {"value":"nMYodMJMiq/J8g9vRPktGc7WSWnOKr6leMDZX4p9K9KgAUYeXFDSu+d29PWWn0rFn93dL0PNdIdHWNQhfkIDMg==","salt":"rmi9WLHgarmIXGukecSIig==","additionalParameters":{}} {"hashIterations":27500,"algorithm":"pbkdf2-sha256","additionalParameters":{}} 10 c9582964-cfdd-49a1-99fb-847604b0c78c \N password 1a85b7e0-4baa-420b-89f8-1cea43a540dd 1662480997923 \N {"value":"ViNTHbpBUNdtH1qGSlip7WFI8Z9lvcGQdbL8Yw48zUgB46jVFbD1eNrOw68p3ovDwfDCIJKm34EFNbw9/uzHSg==","salt":"Z9P8RfnrQwCn0xUTpWC2DQ==","additionalParameters":{}} {"hashIterations":27500,"algorithm":"pbkdf2-sha256","additionalParameters":{}} 10 +b58e1964-6466-40b2-879c-982b724d7f9c \N password 88692d07-bb9a-46cf-844c-7ff5c529cd04 1657026904515 \N {"value":"L/GNO2HAU90A/ajXqyZyujncf+GfJiLWzNVTIDAVj1k=","salt":"L6WsIapZ+hKo0809G6c6SA==","additionalParameters":{}} {"hashIterations":27500,"algorithm":"pbkdf2-sha256","additionalParameters":{}} 10 +d4b2c483-1dd3-47f6-86bf-42548009918d \N password 74e29604-ff35-42bb-a26d-4d0b81ef0917 1643820449817 \N {"value":"GweNaTEq3+hCqr5bBhnfqUd0oAiv8G4eUBkQUqtqiTI=","salt":"z64RYF8zRJz9ko0KDrIPxw==","additionalParameters":{}} {"hashIterations":27500,"algorithm":"pbkdf2-sha256","additionalParameters":{}} 10 +3ff7dd8f-a299-4b51-bf5d-99665ccfd313 \N password 8f58cbec-6e40-4bab-bff0-1c5ff899fe2e 1657026943075 \N {"value":"81KWe14rtsbn3SS6YbWzBy2SvBQhmH+0MDHkK7aXCbo=","salt":"CnzQUkkAMD1tPk1KFDDm1w==","additionalParameters":{}} {"hashIterations":27500,"algorithm":"pbkdf2-sha256","additionalParameters":{}} 10 \. @@ -2239,118 +2226,125 @@ c9582964-cfdd-49a1-99fb-847604b0c78c \N password 1a85b7e0-4baa-420b-89f8-1cea43a -- COPY public.databasechangelog (id, author, filename, dateexecuted, orderexecuted, exectype, md5sum, description, comments, tag, liquibase, contexts, labels, deployment_id) FROM stdin; -authn-3.4.0.CR1-refresh-token-max-reuse glavoie@gmail.com META-INF/jpa-changelog-authz-3.4.0.CR1.xml 2022-02-02 16:47:26.706593 49 EXECUTED \N addColumn tableName=REALM \N 3.5.4 \N \N 3820445829 -1.0.0.Final-KEYCLOAK-5461 sthorger@redhat.com META-INF/jpa-changelog-1.0.0.Final.xml 2022-02-02 16:47:26.017844 1 EXECUTED \N createTable tableName=APPLICATION_DEFAULT_ROLES; createTable tableName=CLIENT; createTable tableName=CLIENT_SESSION; createTable tableName=CLIENT_SESSION_ROLE; createTable tableName=COMPOSITE_ROLE; createTable tableName=CREDENTIAL; createTable tab... \N 3.5.4 \N \N 3820445829 -1.0.0.Final-KEYCLOAK-5461 sthorger@redhat.com META-INF/db2-jpa-changelog-1.0.0.Final.xml 2022-02-02 16:47:26.03122 2 MARK_RAN \N createTable tableName=APPLICATION_DEFAULT_ROLES; createTable tableName=CLIENT; createTable tableName=CLIENT_SESSION; createTable tableName=CLIENT_SESSION_ROLE; createTable tableName=COMPOSITE_ROLE; createTable tableName=CREDENTIAL; createTable tab... \N 3.5.4 \N \N 3820445829 -1.1.0.Beta1 sthorger@redhat.com META-INF/jpa-changelog-1.1.0.Beta1.xml 2022-02-02 16:47:26.06085 3 EXECUTED \N delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION; createTable tableName=CLIENT_ATTRIBUTES; createTable tableName=CLIENT_SESSION_NOTE; createTable tableName=APP_NODE_REGISTRATIONS; addColumn table... \N 3.5.4 \N \N 3820445829 -1.1.0.Final sthorger@redhat.com META-INF/jpa-changelog-1.1.0.Final.xml 2022-02-02 16:47:26.065284 4 EXECUTED \N renameColumn newColumnName=EVENT_TIME, oldColumnName=TIME, tableName=EVENT_ENTITY \N 3.5.4 \N \N 3820445829 -4.8.0-KEYCLOAK-8835 sguilhen@redhat.com META-INF/jpa-changelog-4.8.0.xml 2022-02-02 16:47:26.928034 70 EXECUTED \N addNotNullConstraint columnName=SSO_MAX_LIFESPAN_REMEMBER_ME, tableName=REALM; addNotNullConstraint columnName=SSO_IDLE_TIMEOUT_REMEMBER_ME, tableName=REALM \N 3.5.4 \N \N 3820445829 -1.2.0.Beta1 psilva@redhat.com META-INF/jpa-changelog-1.2.0.Beta1.xml 2022-02-02 16:47:26.130908 5 EXECUTED \N delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION; createTable tableName=PROTOCOL_MAPPER; createTable tableName=PROTOCOL_MAPPER_CONFIG; createTable tableName=... \N 3.5.4 \N \N 3820445829 -1.2.0.Beta1 psilva@redhat.com META-INF/db2-jpa-changelog-1.2.0.Beta1.xml 2022-02-02 16:47:26.133863 6 MARK_RAN \N delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION; createTable tableName=PROTOCOL_MAPPER; createTable tableName=PROTOCOL_MAPPER_CONFIG; createTable tableName=... \N 3.5.4 \N \N 3820445829 -1.2.0.RC1 bburke@redhat.com META-INF/jpa-changelog-1.2.0.CR1.xml 2022-02-02 16:47:26.183318 7 EXECUTED \N delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete tableName=USER_SESSION; createTable tableName=MIGRATION_MODEL; createTable tableName=IDENTITY_P... \N 3.5.4 \N \N 3820445829 -1.2.0.RC1 bburke@redhat.com META-INF/db2-jpa-changelog-1.2.0.CR1.xml 2022-02-02 16:47:26.186858 8 MARK_RAN \N delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete tableName=USER_SESSION; createTable tableName=MIGRATION_MODEL; createTable tableName=IDENTITY_P... \N 3.5.4 \N \N 3820445829 -1.2.0.Final keycloak META-INF/jpa-changelog-1.2.0.Final.xml 2022-02-02 16:47:26.19172 9 EXECUTED \N update tableName=CLIENT; update tableName=CLIENT; update tableName=CLIENT \N 3.5.4 \N \N 3820445829 -1.3.0 bburke@redhat.com META-INF/jpa-changelog-1.3.0.xml 2022-02-02 16:47:26.242162 10 EXECUTED \N delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_PROT_MAPPER; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete tableName=USER_SESSION; createTable tableName=ADMI... \N 3.5.4 \N \N 3820445829 -1.4.0 bburke@redhat.com META-INF/jpa-changelog-1.4.0.xml 2022-02-02 16:47:26.275929 11 EXECUTED \N delete tableName=CLIENT_SESSION_AUTH_STATUS; delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_PROT_MAPPER; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete table... \N 3.5.4 \N \N 3820445829 -1.4.0 bburke@redhat.com META-INF/db2-jpa-changelog-1.4.0.xml 2022-02-02 16:47:26.278548 12 MARK_RAN \N delete tableName=CLIENT_SESSION_AUTH_STATUS; delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_PROT_MAPPER; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete table... \N 3.5.4 \N \N 3820445829 -1.5.0 bburke@redhat.com META-INF/jpa-changelog-1.5.0.xml 2022-02-02 16:47:26.287616 13 EXECUTED \N delete tableName=CLIENT_SESSION_AUTH_STATUS; delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_PROT_MAPPER; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete table... \N 3.5.4 \N \N 3820445829 -1.6.1_from15 mposolda@redhat.com META-INF/jpa-changelog-1.6.1.xml 2022-02-02 16:47:26.299798 14 EXECUTED \N addColumn tableName=REALM; addColumn tableName=KEYCLOAK_ROLE; addColumn tableName=CLIENT; createTable tableName=OFFLINE_USER_SESSION; createTable tableName=OFFLINE_CLIENT_SESSION; addPrimaryKey constraintName=CONSTRAINT_OFFL_US_SES_PK2, tableName=... \N 3.5.4 \N \N 3820445829 -1.6.1_from16-pre mposolda@redhat.com META-INF/jpa-changelog-1.6.1.xml 2022-02-02 16:47:26.302088 15 MARK_RAN \N delete tableName=OFFLINE_CLIENT_SESSION; delete tableName=OFFLINE_USER_SESSION \N 3.5.4 \N \N 3820445829 -1.6.1_from16 mposolda@redhat.com META-INF/jpa-changelog-1.6.1.xml 2022-02-02 16:47:26.303889 16 MARK_RAN \N dropPrimaryKey constraintName=CONSTRAINT_OFFLINE_US_SES_PK, tableName=OFFLINE_USER_SESSION; dropPrimaryKey constraintName=CONSTRAINT_OFFLINE_CL_SES_PK, tableName=OFFLINE_CLIENT_SESSION; addColumn tableName=OFFLINE_USER_SESSION; update tableName=OF... \N 3.5.4 \N \N 3820445829 -1.6.1 mposolda@redhat.com META-INF/jpa-changelog-1.6.1.xml 2022-02-02 16:47:26.306641 17 EXECUTED \N empty \N 3.5.4 \N \N 3820445829 -1.7.0 bburke@redhat.com META-INF/jpa-changelog-1.7.0.xml 2022-02-02 16:47:26.338791 18 EXECUTED \N createTable tableName=KEYCLOAK_GROUP; createTable tableName=GROUP_ROLE_MAPPING; createTable tableName=GROUP_ATTRIBUTE; createTable tableName=USER_GROUP_MEMBERSHIP; createTable tableName=REALM_DEFAULT_GROUPS; addColumn tableName=IDENTITY_PROVIDER; ... \N 3.5.4 \N \N 3820445829 -1.8.0 mposolda@redhat.com META-INF/jpa-changelog-1.8.0.xml 2022-02-02 16:47:26.381463 19 EXECUTED \N addColumn tableName=IDENTITY_PROVIDER; createTable tableName=CLIENT_TEMPLATE; createTable tableName=CLIENT_TEMPLATE_ATTRIBUTES; createTable tableName=TEMPLATE_SCOPE_MAPPING; dropNotNullConstraint columnName=CLIENT_ID, tableName=PROTOCOL_MAPPER; ad... \N 3.5.4 \N \N 3820445829 -1.8.0-2 keycloak META-INF/jpa-changelog-1.8.0.xml 2022-02-02 16:47:26.390165 20 EXECUTED \N dropDefaultValue columnName=ALGORITHM, tableName=CREDENTIAL; update tableName=CREDENTIAL \N 3.5.4 \N \N 3820445829 -authz-3.4.0.CR1-resource-server-pk-change-part1 glavoie@gmail.com META-INF/jpa-changelog-authz-3.4.0.CR1.xml 2022-02-02 16:47:26.679075 45 EXECUTED \N addColumn tableName=RESOURCE_SERVER_POLICY; addColumn tableName=RESOURCE_SERVER_RESOURCE; addColumn tableName=RESOURCE_SERVER_SCOPE \N 3.5.4 \N \N 3820445829 -1.8.0 mposolda@redhat.com META-INF/db2-jpa-changelog-1.8.0.xml 2022-02-02 16:47:26.392862 21 MARK_RAN \N addColumn tableName=IDENTITY_PROVIDER; createTable tableName=CLIENT_TEMPLATE; createTable tableName=CLIENT_TEMPLATE_ATTRIBUTES; createTable tableName=TEMPLATE_SCOPE_MAPPING; dropNotNullConstraint columnName=CLIENT_ID, tableName=PROTOCOL_MAPPER; ad... \N 3.5.4 \N \N 3820445829 -1.8.0-2 keycloak META-INF/db2-jpa-changelog-1.8.0.xml 2022-02-02 16:47:26.395652 22 MARK_RAN \N dropDefaultValue columnName=ALGORITHM, tableName=CREDENTIAL; update tableName=CREDENTIAL \N 3.5.4 \N \N 3820445829 -1.9.0 mposolda@redhat.com META-INF/jpa-changelog-1.9.0.xml 2022-02-02 16:47:26.40969 23 EXECUTED \N update tableName=REALM; update tableName=REALM; update tableName=REALM; update tableName=REALM; update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=REALM; update tableName=REALM; customChange; dr... \N 3.5.4 \N \N 3820445829 -1.9.1 keycloak META-INF/jpa-changelog-1.9.1.xml 2022-02-02 16:47:26.414344 24 EXECUTED \N modifyDataType columnName=PRIVATE_KEY, tableName=REALM; modifyDataType columnName=PUBLIC_KEY, tableName=REALM; modifyDataType columnName=CERTIFICATE, tableName=REALM \N 3.5.4 \N \N 3820445829 -1.9.1 keycloak META-INF/db2-jpa-changelog-1.9.1.xml 2022-02-02 16:47:26.416193 25 MARK_RAN \N modifyDataType columnName=PRIVATE_KEY, tableName=REALM; modifyDataType columnName=CERTIFICATE, tableName=REALM \N 3.5.4 \N \N 3820445829 -1.9.2 keycloak META-INF/jpa-changelog-1.9.2.xml 2022-02-02 16:47:26.437367 26 EXECUTED \N createIndex indexName=IDX_USER_EMAIL, tableName=USER_ENTITY; createIndex indexName=IDX_USER_ROLE_MAPPING, tableName=USER_ROLE_MAPPING; createIndex indexName=IDX_USER_GROUP_MAPPING, tableName=USER_GROUP_MEMBERSHIP; createIndex indexName=IDX_USER_CO... \N 3.5.4 \N \N 3820445829 -9.0.1-KEYCLOAK-12579-recreate-constraints keycloak META-INF/jpa-changelog-9.0.1.xml 2022-02-02 16:47:26.981645 84 MARK_RAN \N addUniqueConstraint constraintName=SIBLING_NAMES, tableName=KEYCLOAK_GROUP \N 3.5.4 \N \N 3820445829 -authz-2.0.0 psilva@redhat.com META-INF/jpa-changelog-authz-2.0.0.xml 2022-02-02 16:47:26.481647 27 EXECUTED \N createTable tableName=RESOURCE_SERVER; addPrimaryKey constraintName=CONSTRAINT_FARS, tableName=RESOURCE_SERVER; addUniqueConstraint constraintName=UK_AU8TT6T700S9V50BU18WS5HA6, tableName=RESOURCE_SERVER; createTable tableName=RESOURCE_SERVER_RESOU... \N 3.5.4 \N \N 3820445829 -authz-2.5.1 psilva@redhat.com META-INF/jpa-changelog-authz-2.5.1.xml 2022-02-02 16:47:26.484459 28 EXECUTED \N update tableName=RESOURCE_SERVER_POLICY \N 3.5.4 \N \N 3820445829 -2.1.0-KEYCLOAK-5461 bburke@redhat.com META-INF/jpa-changelog-2.1.0.xml 2022-02-02 16:47:26.523006 29 EXECUTED \N createTable tableName=BROKER_LINK; createTable tableName=FED_USER_ATTRIBUTE; createTable tableName=FED_USER_CONSENT; createTable tableName=FED_USER_CONSENT_ROLE; createTable tableName=FED_USER_CONSENT_PROT_MAPPER; createTable tableName=FED_USER_CR... \N 3.5.4 \N \N 3820445829 -2.2.0 bburke@redhat.com META-INF/jpa-changelog-2.2.0.xml 2022-02-02 16:47:26.532066 30 EXECUTED \N addColumn tableName=ADMIN_EVENT_ENTITY; createTable tableName=CREDENTIAL_ATTRIBUTE; createTable tableName=FED_CREDENTIAL_ATTRIBUTE; modifyDataType columnName=VALUE, tableName=CREDENTIAL; addForeignKeyConstraint baseTableName=FED_CREDENTIAL_ATTRIBU... \N 3.5.4 \N \N 3820445829 -2.3.0 bburke@redhat.com META-INF/jpa-changelog-2.3.0.xml 2022-02-02 16:47:26.541837 31 EXECUTED \N createTable tableName=FEDERATED_USER; addPrimaryKey constraintName=CONSTR_FEDERATED_USER, tableName=FEDERATED_USER; dropDefaultValue columnName=TOTP, tableName=USER_ENTITY; dropColumn columnName=TOTP, tableName=USER_ENTITY; addColumn tableName=IDE... \N 3.5.4 \N \N 3820445829 -2.4.0 bburke@redhat.com META-INF/jpa-changelog-2.4.0.xml 2022-02-02 16:47:26.545809 32 EXECUTED \N customChange \N 3.5.4 \N \N 3820445829 -2.5.0 bburke@redhat.com META-INF/jpa-changelog-2.5.0.xml 2022-02-02 16:47:26.549823 33 EXECUTED \N customChange; modifyDataType columnName=USER_ID, tableName=OFFLINE_USER_SESSION \N 3.5.4 \N \N 3820445829 -2.5.0-unicode-oracle hmlnarik@redhat.com META-INF/jpa-changelog-2.5.0.xml 2022-02-02 16:47:26.55176 34 MARK_RAN \N modifyDataType columnName=DESCRIPTION, tableName=AUTHENTICATION_FLOW; modifyDataType columnName=DESCRIPTION, tableName=CLIENT_TEMPLATE; modifyDataType columnName=DESCRIPTION, tableName=RESOURCE_SERVER_POLICY; modifyDataType columnName=DESCRIPTION,... \N 3.5.4 \N \N 3820445829 -2.5.0-unicode-other-dbs hmlnarik@redhat.com META-INF/jpa-changelog-2.5.0.xml 2022-02-02 16:47:26.567305 35 EXECUTED \N modifyDataType columnName=DESCRIPTION, tableName=AUTHENTICATION_FLOW; modifyDataType columnName=DESCRIPTION, tableName=CLIENT_TEMPLATE; modifyDataType columnName=DESCRIPTION, tableName=RESOURCE_SERVER_POLICY; modifyDataType columnName=DESCRIPTION,... \N 3.5.4 \N \N 3820445829 -2.5.0-duplicate-email-support slawomir@dabek.name META-INF/jpa-changelog-2.5.0.xml 2022-02-02 16:47:26.570727 36 EXECUTED \N addColumn tableName=REALM \N 3.5.4 \N \N 3820445829 -2.5.0-unique-group-names hmlnarik@redhat.com META-INF/jpa-changelog-2.5.0.xml 2022-02-02 16:47:26.578396 37 EXECUTED \N addUniqueConstraint constraintName=SIBLING_NAMES, tableName=KEYCLOAK_GROUP \N 3.5.4 \N \N 3820445829 -2.5.1 bburke@redhat.com META-INF/jpa-changelog-2.5.1.xml 2022-02-02 16:47:26.581391 38 EXECUTED \N addColumn tableName=FED_USER_CONSENT \N 3.5.4 \N \N 3820445829 -3.0.0 bburke@redhat.com META-INF/jpa-changelog-3.0.0.xml 2022-02-02 16:47:26.584204 39 EXECUTED \N addColumn tableName=IDENTITY_PROVIDER \N 3.5.4 \N \N 3820445829 -3.2.0-fix keycloak META-INF/jpa-changelog-3.2.0.xml 2022-02-02 16:47:26.585877 40 MARK_RAN \N addNotNullConstraint columnName=REALM_ID, tableName=CLIENT_INITIAL_ACCESS \N 3.5.4 \N \N 3820445829 -3.2.0-fix-with-keycloak-5416 keycloak META-INF/jpa-changelog-3.2.0.xml 2022-02-02 16:47:26.587657 41 MARK_RAN \N dropIndex indexName=IDX_CLIENT_INIT_ACC_REALM, tableName=CLIENT_INITIAL_ACCESS; addNotNullConstraint columnName=REALM_ID, tableName=CLIENT_INITIAL_ACCESS; createIndex indexName=IDX_CLIENT_INIT_ACC_REALM, tableName=CLIENT_INITIAL_ACCESS \N 3.5.4 \N \N 3820445829 -3.2.0-fix-offline-sessions hmlnarik META-INF/jpa-changelog-3.2.0.xml 2022-02-02 16:47:26.591561 42 EXECUTED \N customChange \N 3.5.4 \N \N 3820445829 -3.2.0-fixed keycloak META-INF/jpa-changelog-3.2.0.xml 2022-02-02 16:47:26.669981 43 EXECUTED \N addColumn tableName=REALM; dropPrimaryKey constraintName=CONSTRAINT_OFFL_CL_SES_PK2, tableName=OFFLINE_CLIENT_SESSION; dropColumn columnName=CLIENT_SESSION_ID, tableName=OFFLINE_CLIENT_SESSION; addPrimaryKey constraintName=CONSTRAINT_OFFL_CL_SES_P... \N 3.5.4 \N \N 3820445829 -3.3.0 keycloak META-INF/jpa-changelog-3.3.0.xml 2022-02-02 16:47:26.673701 44 EXECUTED \N addColumn tableName=USER_ENTITY \N 3.5.4 \N \N 3820445829 -authz-3.4.0.CR1-resource-server-pk-change-part2-KEYCLOAK-6095 hmlnarik@redhat.com META-INF/jpa-changelog-authz-3.4.0.CR1.xml 2022-02-02 16:47:26.681987 46 EXECUTED \N customChange \N 3.5.4 \N \N 3820445829 -authz-3.4.0.CR1-resource-server-pk-change-part3-fixed glavoie@gmail.com META-INF/jpa-changelog-authz-3.4.0.CR1.xml 2022-02-02 16:47:26.683661 47 MARK_RAN \N dropIndex indexName=IDX_RES_SERV_POL_RES_SERV, tableName=RESOURCE_SERVER_POLICY; dropIndex indexName=IDX_RES_SRV_RES_RES_SRV, tableName=RESOURCE_SERVER_RESOURCE; dropIndex indexName=IDX_RES_SRV_SCOPE_RES_SRV, tableName=RESOURCE_SERVER_SCOPE \N 3.5.4 \N \N 3820445829 -authz-3.4.0.CR1-resource-server-pk-change-part3-fixed-nodropindex glavoie@gmail.com META-INF/jpa-changelog-authz-3.4.0.CR1.xml 2022-02-02 16:47:26.702743 48 EXECUTED \N addNotNullConstraint columnName=RESOURCE_SERVER_CLIENT_ID, tableName=RESOURCE_SERVER_POLICY; addNotNullConstraint columnName=RESOURCE_SERVER_CLIENT_ID, tableName=RESOURCE_SERVER_RESOURCE; addNotNullConstraint columnName=RESOURCE_SERVER_CLIENT_ID, ... \N 3.5.4 \N \N 3820445829 -3.4.0 keycloak META-INF/jpa-changelog-3.4.0.xml 2022-02-02 16:47:26.734467 50 EXECUTED \N addPrimaryKey constraintName=CONSTRAINT_REALM_DEFAULT_ROLES, tableName=REALM_DEFAULT_ROLES; addPrimaryKey constraintName=CONSTRAINT_COMPOSITE_ROLE, tableName=COMPOSITE_ROLE; addPrimaryKey constraintName=CONSTR_REALM_DEFAULT_GROUPS, tableName=REALM... \N 3.5.4 \N \N 3820445829 -3.4.0-KEYCLOAK-5230 hmlnarik@redhat.com META-INF/jpa-changelog-3.4.0.xml 2022-02-02 16:47:26.78037 51 EXECUTED \N createIndex indexName=IDX_FU_ATTRIBUTE, tableName=FED_USER_ATTRIBUTE; createIndex indexName=IDX_FU_CONSENT, tableName=FED_USER_CONSENT; createIndex indexName=IDX_FU_CONSENT_RU, tableName=FED_USER_CONSENT; createIndex indexName=IDX_FU_CREDENTIAL, t... \N 3.5.4 \N \N 3820445829 -3.4.1 psilva@redhat.com META-INF/jpa-changelog-3.4.1.xml 2022-02-02 16:47:26.783989 52 EXECUTED \N modifyDataType columnName=VALUE, tableName=CLIENT_ATTRIBUTES \N 3.5.4 \N \N 3820445829 -3.4.2 keycloak META-INF/jpa-changelog-3.4.2.xml 2022-02-02 16:47:26.786619 53 EXECUTED \N update tableName=REALM \N 3.5.4 \N \N 3820445829 -3.4.2-KEYCLOAK-5172 mkanis@redhat.com META-INF/jpa-changelog-3.4.2.xml 2022-02-02 16:47:26.788788 54 EXECUTED \N update tableName=CLIENT \N 3.5.4 \N \N 3820445829 -4.0.0-KEYCLOAK-6335 bburke@redhat.com META-INF/jpa-changelog-4.0.0.xml 2022-02-02 16:47:26.794881 55 EXECUTED \N createTable tableName=CLIENT_AUTH_FLOW_BINDINGS; addPrimaryKey constraintName=C_CLI_FLOW_BIND, tableName=CLIENT_AUTH_FLOW_BINDINGS \N 3.5.4 \N \N 3820445829 -4.0.0-CLEANUP-UNUSED-TABLE bburke@redhat.com META-INF/jpa-changelog-4.0.0.xml 2022-02-02 16:47:26.799493 56 EXECUTED \N dropTable tableName=CLIENT_IDENTITY_PROV_MAPPING \N 3.5.4 \N \N 3820445829 -4.0.0-KEYCLOAK-6228 bburke@redhat.com META-INF/jpa-changelog-4.0.0.xml 2022-02-02 16:47:26.810686 57 EXECUTED \N dropUniqueConstraint constraintName=UK_JKUWUVD56ONTGSUHOGM8UEWRT, tableName=USER_CONSENT; dropNotNullConstraint columnName=CLIENT_ID, tableName=USER_CONSENT; addColumn tableName=USER_CONSENT; addUniqueConstraint constraintName=UK_JKUWUVD56ONTGSUHO... \N 3.5.4 \N \N 3820445829 -4.0.0-KEYCLOAK-5579-fixed mposolda@redhat.com META-INF/jpa-changelog-4.0.0.xml 2022-02-02 16:47:26.861332 58 EXECUTED \N dropForeignKeyConstraint baseTableName=CLIENT_TEMPLATE_ATTRIBUTES, constraintName=FK_CL_TEMPL_ATTR_TEMPL; renameTable newTableName=CLIENT_SCOPE_ATTRIBUTES, oldTableName=CLIENT_TEMPLATE_ATTRIBUTES; renameColumn newColumnName=SCOPE_ID, oldColumnName... \N 3.5.4 \N \N 3820445829 -authz-4.0.0.CR1 psilva@redhat.com META-INF/jpa-changelog-authz-4.0.0.CR1.xml 2022-02-02 16:47:26.877018 59 EXECUTED \N createTable tableName=RESOURCE_SERVER_PERM_TICKET; addPrimaryKey constraintName=CONSTRAINT_FAPMT, tableName=RESOURCE_SERVER_PERM_TICKET; addForeignKeyConstraint baseTableName=RESOURCE_SERVER_PERM_TICKET, constraintName=FK_FRSRHO213XCX4WNKOG82SSPMT... \N 3.5.4 \N \N 3820445829 -authz-4.0.0.Beta3 psilva@redhat.com META-INF/jpa-changelog-authz-4.0.0.Beta3.xml 2022-02-02 16:47:26.881203 60 EXECUTED \N addColumn tableName=RESOURCE_SERVER_POLICY; addColumn tableName=RESOURCE_SERVER_PERM_TICKET; addForeignKeyConstraint baseTableName=RESOURCE_SERVER_PERM_TICKET, constraintName=FK_FRSRPO2128CX4WNKOG82SSRFY, referencedTableName=RESOURCE_SERVER_POLICY \N 3.5.4 \N \N 3820445829 -authz-4.2.0.Final mhajas@redhat.com META-INF/jpa-changelog-authz-4.2.0.Final.xml 2022-02-02 16:47:26.886177 61 EXECUTED \N createTable tableName=RESOURCE_URIS; addForeignKeyConstraint baseTableName=RESOURCE_URIS, constraintName=FK_RESOURCE_SERVER_URIS, referencedTableName=RESOURCE_SERVER_RESOURCE; customChange; dropColumn columnName=URI, tableName=RESOURCE_SERVER_RESO... \N 3.5.4 \N \N 3820445829 -authz-4.2.0.Final-KEYCLOAK-9944 hmlnarik@redhat.com META-INF/jpa-changelog-authz-4.2.0.Final.xml 2022-02-02 16:47:26.890482 62 EXECUTED \N addPrimaryKey constraintName=CONSTRAINT_RESOUR_URIS_PK, tableName=RESOURCE_URIS \N 3.5.4 \N \N 3820445829 -4.2.0-KEYCLOAK-6313 wadahiro@gmail.com META-INF/jpa-changelog-4.2.0.xml 2022-02-02 16:47:26.893518 63 EXECUTED \N addColumn tableName=REQUIRED_ACTION_PROVIDER \N 3.5.4 \N \N 3820445829 -4.3.0-KEYCLOAK-7984 wadahiro@gmail.com META-INF/jpa-changelog-4.3.0.xml 2022-02-02 16:47:26.895621 64 EXECUTED \N update tableName=REQUIRED_ACTION_PROVIDER \N 3.5.4 \N \N 3820445829 -4.6.0-KEYCLOAK-7950 psilva@redhat.com META-INF/jpa-changelog-4.6.0.xml 2022-02-02 16:47:26.89756 65 EXECUTED \N update tableName=RESOURCE_SERVER_RESOURCE \N 3.5.4 \N \N 3820445829 -4.6.0-KEYCLOAK-8377 keycloak META-INF/jpa-changelog-4.6.0.xml 2022-02-02 16:47:26.908059 66 EXECUTED \N createTable tableName=ROLE_ATTRIBUTE; addPrimaryKey constraintName=CONSTRAINT_ROLE_ATTRIBUTE_PK, tableName=ROLE_ATTRIBUTE; addForeignKeyConstraint baseTableName=ROLE_ATTRIBUTE, constraintName=FK_ROLE_ATTRIBUTE_ID, referencedTableName=KEYCLOAK_ROLE... \N 3.5.4 \N \N 3820445829 -4.6.0-KEYCLOAK-8555 gideonray@gmail.com META-INF/jpa-changelog-4.6.0.xml 2022-02-02 16:47:26.912693 67 EXECUTED \N createIndex indexName=IDX_COMPONENT_PROVIDER_TYPE, tableName=COMPONENT \N 3.5.4 \N \N 3820445829 -4.7.0-KEYCLOAK-1267 sguilhen@redhat.com META-INF/jpa-changelog-4.7.0.xml 2022-02-02 16:47:26.915771 68 EXECUTED \N addColumn tableName=REALM \N 3.5.4 \N \N 3820445829 -4.7.0-KEYCLOAK-7275 keycloak META-INF/jpa-changelog-4.7.0.xml 2022-02-02 16:47:26.924465 69 EXECUTED \N renameColumn newColumnName=CREATED_ON, oldColumnName=LAST_SESSION_REFRESH, tableName=OFFLINE_USER_SESSION; addNotNullConstraint columnName=CREATED_ON, tableName=OFFLINE_USER_SESSION; addColumn tableName=OFFLINE_USER_SESSION; customChange; createIn... \N 3.5.4 \N \N 3820445829 -authz-7.0.0-KEYCLOAK-10443 psilva@redhat.com META-INF/jpa-changelog-authz-7.0.0.xml 2022-02-02 16:47:26.93061 71 EXECUTED \N addColumn tableName=RESOURCE_SERVER \N 3.5.4 \N \N 3820445829 -8.0.0-adding-credential-columns keycloak META-INF/jpa-changelog-8.0.0.xml 2022-02-02 16:47:26.933771 72 EXECUTED \N addColumn tableName=CREDENTIAL; addColumn tableName=FED_USER_CREDENTIAL \N 3.5.4 \N \N 3820445829 +1.6.1 mposolda@redhat.com META-INF/jpa-changelog-1.6.1.xml 2022-02-02 16:47:26.306641 17 EXECUTED 9:d41d8cd98f00b204e9800998ecf8427e empty \N 3.5.4 \N \N 3820445829 +authz-2.5.1 psilva@redhat.com META-INF/jpa-changelog-authz-2.5.1.xml 2022-02-02 16:47:26.484459 28 EXECUTED 9:44bae577f551b3738740281eceb4ea70 update tableName=RESOURCE_SERVER_POLICY \N 3.5.4 \N \N 3820445829 8.0.0-updating-credential-data-not-oracle keycloak META-INF/jpa-changelog-8.0.0.xml 2022-02-02 16:47:26.937673 73 EXECUTED \N update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=FED_USER_CREDENTIAL; update tableName=FED_USER_CREDENTIAL; update tableName=FED_USER_CREDENTIAL \N 3.5.4 \N \N 3820445829 8.0.0-updating-credential-data-oracle keycloak META-INF/jpa-changelog-8.0.0.xml 2022-02-02 16:47:26.939218 74 MARK_RAN \N update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=FED_USER_CREDENTIAL; update tableName=FED_USER_CREDENTIAL; update tableName=FED_USER_CREDENTIAL \N 3.5.4 \N \N 3820445829 -8.0.0-credential-cleanup-fixed keycloak META-INF/jpa-changelog-8.0.0.xml 2022-02-02 16:47:26.945819 75 EXECUTED \N dropDefaultValue columnName=COUNTER, tableName=CREDENTIAL; dropDefaultValue columnName=DIGITS, tableName=CREDENTIAL; dropDefaultValue columnName=PERIOD, tableName=CREDENTIAL; dropDefaultValue columnName=ALGORITHM, tableName=CREDENTIAL; dropColumn ... \N 3.5.4 \N \N 3820445829 -8.0.0-resource-tag-support keycloak META-INF/jpa-changelog-8.0.0.xml 2022-02-02 16:47:26.950255 76 EXECUTED \N addColumn tableName=MIGRATION_MODEL; createIndex indexName=IDX_UPDATE_TIME, tableName=MIGRATION_MODEL \N 3.5.4 \N \N 3820445829 -9.0.0-always-display-client keycloak META-INF/jpa-changelog-9.0.0.xml 2022-02-02 16:47:26.955505 77 EXECUTED \N addColumn tableName=CLIENT \N 3.5.4 \N \N 3820445829 -9.0.0-drop-constraints-for-column-increase keycloak META-INF/jpa-changelog-9.0.0.xml 2022-02-02 16:47:26.957216 78 MARK_RAN \N dropUniqueConstraint constraintName=UK_FRSR6T700S9V50BU18WS5PMT, tableName=RESOURCE_SERVER_PERM_TICKET; dropUniqueConstraint constraintName=UK_FRSR6T700S9V50BU18WS5HA6, tableName=RESOURCE_SERVER_RESOURCE; dropPrimaryKey constraintName=CONSTRAINT_O... \N 3.5.4 \N \N 3820445829 -9.0.0-increase-column-size-federated-fk keycloak META-INF/jpa-changelog-9.0.0.xml 2022-02-02 16:47:26.966746 79 EXECUTED \N modifyDataType columnName=CLIENT_ID, tableName=FED_USER_CONSENT; modifyDataType columnName=CLIENT_REALM_CONSTRAINT, tableName=KEYCLOAK_ROLE; modifyDataType columnName=OWNER, tableName=RESOURCE_SERVER_POLICY; modifyDataType columnName=CLIENT_ID, ta... \N 3.5.4 \N \N 3820445829 -9.0.0-recreate-constraints-after-column-increase keycloak META-INF/jpa-changelog-9.0.0.xml 2022-02-02 16:47:26.969643 80 MARK_RAN \N addNotNullConstraint columnName=CLIENT_ID, tableName=OFFLINE_CLIENT_SESSION; addNotNullConstraint columnName=OWNER, tableName=RESOURCE_SERVER_PERM_TICKET; addNotNullConstraint columnName=REQUESTER, tableName=RESOURCE_SERVER_PERM_TICKET; addNotNull... \N 3.5.4 \N \N 3820445829 -9.0.1-add-index-to-client.client_id keycloak META-INF/jpa-changelog-9.0.1.xml 2022-02-02 16:47:26.975764 81 EXECUTED \N createIndex indexName=IDX_CLIENT_ID, tableName=CLIENT \N 3.5.4 \N \N 3820445829 -9.0.1-KEYCLOAK-12579-drop-constraints keycloak META-INF/jpa-changelog-9.0.1.xml 2022-02-02 16:47:26.977227 82 MARK_RAN \N dropUniqueConstraint constraintName=SIBLING_NAMES, tableName=KEYCLOAK_GROUP \N 3.5.4 \N \N 3820445829 -9.0.1-KEYCLOAK-12579-add-not-null-constraint keycloak META-INF/jpa-changelog-9.0.1.xml 2022-02-02 16:47:26.980058 83 EXECUTED \N addNotNullConstraint columnName=PARENT_GROUP, tableName=KEYCLOAK_GROUP \N 3.5.4 \N \N 3820445829 -9.0.1-add-index-to-events keycloak META-INF/jpa-changelog-9.0.1.xml 2022-02-02 16:47:26.985465 85 EXECUTED \N createIndex indexName=IDX_EVENT_TIME, tableName=EVENT_ENTITY \N 3.5.4 \N \N 3820445829 -map-remove-ri keycloak META-INF/jpa-changelog-11.0.0.xml 2022-02-02 16:47:26.98869 86 EXECUTED \N dropForeignKeyConstraint baseTableName=REALM, constraintName=FK_TRAF444KK6QRKMS7N56AIWQ5Y; dropForeignKeyConstraint baseTableName=KEYCLOAK_ROLE, constraintName=FK_KJHO5LE2C0RAL09FL8CM9WFW9 \N 3.5.4 \N \N 3820445829 -map-remove-ri keycloak META-INF/jpa-changelog-12.0.0.xml 2022-02-02 16:47:26.992854 87 EXECUTED \N dropForeignKeyConstraint baseTableName=REALM_DEFAULT_GROUPS, constraintName=FK_DEF_GROUPS_GROUP; dropForeignKeyConstraint baseTableName=REALM_DEFAULT_ROLES, constraintName=FK_H4WPD7W4HSOOLNI3H0SW7BTJE; dropForeignKeyConstraint baseTableName=CLIENT... \N 3.5.4 \N \N 3820445829 -12.1.0-add-realm-localization-table keycloak META-INF/jpa-changelog-12.0.0.xml 2022-02-02 16:47:26.999694 88 EXECUTED \N createTable tableName=REALM_LOCALIZATIONS; addPrimaryKey tableName=REALM_LOCALIZATIONS \N 3.5.4 \N \N 3820445829 -8.0.0-updating-credential-data-not-oracle-fixed keycloak META-INF/jpa-changelog-8.0.0.xml 2023-01-09 14:34:23.080082 89 MARK_RAN 8:83f7a671792ca98b3cbd3a1a34862d3d update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=FED_USER_CREDENTIAL; update tableName=FED_USER_CREDENTIAL; update tableName=FED_USER_CREDENTIAL \N 4.8.0 \N \N 3274862955 -8.0.0-updating-credential-data-oracle-fixed keycloak META-INF/jpa-changelog-8.0.0.xml 2023-01-09 14:34:23.099857 90 MARK_RAN 8:f58ad148698cf30707a6efbdf8061aa7 update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=FED_USER_CREDENTIAL; update tableName=FED_USER_CREDENTIAL; update tableName=FED_USER_CREDENTIAL \N 4.8.0 \N \N 3274862955 -default-roles keycloak META-INF/jpa-changelog-13.0.0.xml 2023-01-09 14:34:23.144776 91 EXECUTED 8:72d03345fda8e2f17093d08801947773 addColumn tableName=REALM; customChange \N 4.8.0 \N \N 3274862955 -default-roles-cleanup keycloak META-INF/jpa-changelog-13.0.0.xml 2023-01-09 14:34:23.155298 92 EXECUTED 8:61c9233951bd96ffecd9ba75f7d978a4 dropTable tableName=REALM_DEFAULT_ROLES; dropTable tableName=CLIENT_DEFAULT_ROLES \N 4.8.0 \N \N 3274862955 -13.0.0-KEYCLOAK-16844 keycloak META-INF/jpa-changelog-13.0.0.xml 2023-01-09 14:34:23.162911 93 EXECUTED 8:ea82e6ad945cec250af6372767b25525 createIndex indexName=IDX_OFFLINE_USS_PRELOAD, tableName=OFFLINE_USER_SESSION \N 4.8.0 \N \N 3274862955 -map-remove-ri-13.0.0 keycloak META-INF/jpa-changelog-13.0.0.xml 2023-01-09 14:34:23.17617 94 EXECUTED 8:d3f4a33f41d960ddacd7e2ef30d126b3 dropForeignKeyConstraint baseTableName=DEFAULT_CLIENT_SCOPE, constraintName=FK_R_DEF_CLI_SCOPE_SCOPE; dropForeignKeyConstraint baseTableName=CLIENT_SCOPE_CLIENT, constraintName=FK_C_CLI_SCOPE_SCOPE; dropForeignKeyConstraint baseTableName=CLIENT_SC... \N 4.8.0 \N \N 3274862955 -13.0.0-KEYCLOAK-17992-drop-constraints keycloak META-INF/jpa-changelog-13.0.0.xml 2023-01-09 14:34:23.1803 95 MARK_RAN 8:1284a27fbd049d65831cb6fc07c8a783 dropPrimaryKey constraintName=C_CLI_SCOPE_BIND, tableName=CLIENT_SCOPE_CLIENT; dropIndex indexName=IDX_CLSCOPE_CL, tableName=CLIENT_SCOPE_CLIENT; dropIndex indexName=IDX_CL_CLSCOPE, tableName=CLIENT_SCOPE_CLIENT \N 4.8.0 \N \N 3274862955 -13.0.0-increase-column-size-federated keycloak META-INF/jpa-changelog-13.0.0.xml 2023-01-09 14:34:23.190613 96 EXECUTED 8:9d11b619db2ae27c25853b8a37cd0dea modifyDataType columnName=CLIENT_ID, tableName=CLIENT_SCOPE_CLIENT; modifyDataType columnName=SCOPE_ID, tableName=CLIENT_SCOPE_CLIENT \N 4.8.0 \N \N 3274862955 -13.0.0-KEYCLOAK-17992-recreate-constraints keycloak META-INF/jpa-changelog-13.0.0.xml 2023-01-09 14:34:23.193934 97 MARK_RAN 8:3002bb3997451bb9e8bac5c5cd8d6327 addNotNullConstraint columnName=CLIENT_ID, tableName=CLIENT_SCOPE_CLIENT; addNotNullConstraint columnName=SCOPE_ID, tableName=CLIENT_SCOPE_CLIENT; addPrimaryKey constraintName=C_CLI_SCOPE_BIND, tableName=CLIENT_SCOPE_CLIENT; createIndex indexName=... \N 4.8.0 \N \N 3274862955 -json-string-accomodation-fixed keycloak META-INF/jpa-changelog-13.0.0.xml 2023-01-09 14:34:23.202965 98 EXECUTED 8:dfbee0d6237a23ef4ccbb7a4e063c163 addColumn tableName=REALM_ATTRIBUTE; update tableName=REALM_ATTRIBUTE; dropColumn columnName=VALUE, tableName=REALM_ATTRIBUTE; renameColumn newColumnName=VALUE, oldColumnName=VALUE_NEW, tableName=REALM_ATTRIBUTE \N 4.8.0 \N \N 3274862955 -14.0.0-KEYCLOAK-11019 keycloak META-INF/jpa-changelog-14.0.0.xml 2023-01-09 14:34:23.212244 99 EXECUTED 8:75f3e372df18d38c62734eebb986b960 createIndex indexName=IDX_OFFLINE_CSS_PRELOAD, tableName=OFFLINE_CLIENT_SESSION; createIndex indexName=IDX_OFFLINE_USS_BY_USER, tableName=OFFLINE_USER_SESSION; createIndex indexName=IDX_OFFLINE_USS_BY_USERSESS, tableName=OFFLINE_USER_SESSION \N 4.8.0 \N \N 3274862955 -14.0.0-KEYCLOAK-18286 keycloak META-INF/jpa-changelog-14.0.0.xml 2023-01-09 14:34:23.2164 100 MARK_RAN 8:7fee73eddf84a6035691512c85637eef createIndex indexName=IDX_CLIENT_ATT_BY_NAME_VALUE, tableName=CLIENT_ATTRIBUTES \N 4.8.0 \N \N 3274862955 -14.0.0-KEYCLOAK-18286-revert keycloak META-INF/jpa-changelog-14.0.0.xml 2023-01-09 14:34:23.227895 101 MARK_RAN 8:7a11134ab12820f999fbf3bb13c3adc8 dropIndex indexName=IDX_CLIENT_ATT_BY_NAME_VALUE, tableName=CLIENT_ATTRIBUTES \N 4.8.0 \N \N 3274862955 -14.0.0-KEYCLOAK-18286-supported-dbs keycloak META-INF/jpa-changelog-14.0.0.xml 2023-01-09 14:34:23.235491 102 EXECUTED 8:c0f6eaac1f3be773ffe54cb5b8482b70 createIndex indexName=IDX_CLIENT_ATT_BY_NAME_VALUE, tableName=CLIENT_ATTRIBUTES \N 4.8.0 \N \N 3274862955 -14.0.0-KEYCLOAK-18286-unsupported-dbs keycloak META-INF/jpa-changelog-14.0.0.xml 2023-01-09 14:34:23.238498 103 MARK_RAN 8:18186f0008b86e0f0f49b0c4d0e842ac createIndex indexName=IDX_CLIENT_ATT_BY_NAME_VALUE, tableName=CLIENT_ATTRIBUTES \N 4.8.0 \N \N 3274862955 -KEYCLOAK-17267-add-index-to-user-attributes keycloak META-INF/jpa-changelog-14.0.0.xml 2023-01-09 14:34:23.24435 104 EXECUTED 8:09c2780bcb23b310a7019d217dc7b433 createIndex indexName=IDX_USER_ATTRIBUTE_NAME, tableName=USER_ATTRIBUTE \N 4.8.0 \N \N 3274862955 -KEYCLOAK-18146-add-saml-art-binding-identifier keycloak META-INF/jpa-changelog-14.0.0.xml 2023-01-09 14:34:23.250444 105 EXECUTED 8:276a44955eab693c970a42880197fff2 customChange \N 4.8.0 \N \N 3274862955 -15.0.0-KEYCLOAK-18467 keycloak META-INF/jpa-changelog-15.0.0.xml 2023-01-09 14:34:23.261701 106 EXECUTED 8:ba8ee3b694d043f2bfc1a1079d0760d7 addColumn tableName=REALM_LOCALIZATIONS; update tableName=REALM_LOCALIZATIONS; dropColumn columnName=TEXTS, tableName=REALM_LOCALIZATIONS; renameColumn newColumnName=TEXTS, oldColumnName=TEXTS_NEW, tableName=REALM_LOCALIZATIONS; addNotNullConstrai... \N 4.8.0 \N \N 3274862955 -17.0.0-9562 keycloak META-INF/jpa-changelog-17.0.0.xml 2023-01-09 14:34:23.269577 107 EXECUTED 8:5e06b1d75f5d17685485e610c2851b17 createIndex indexName=IDX_USER_SERVICE_ACCOUNT, tableName=USER_ENTITY \N 4.8.0 \N \N 3274862955 -18.0.0-10625-IDX_ADMIN_EVENT_TIME keycloak META-INF/jpa-changelog-18.0.0.xml 2023-01-09 14:34:23.276738 108 EXECUTED 8:4b80546c1dc550ac552ee7b24a4ab7c0 createIndex indexName=IDX_ADMIN_EVENT_TIME, tableName=ADMIN_EVENT_ENTITY \N 4.8.0 \N \N 3274862955 -19.0.0-10135 keycloak META-INF/jpa-changelog-19.0.0.xml 2023-01-09 14:34:23.297485 109 EXECUTED 8:af510cd1bb2ab6339c45372f3e491696 customChange \N 4.8.0 \N \N 3274862955 -20.0.0-12964-supported-dbs keycloak META-INF/jpa-changelog-20.0.0.xml 2023-01-09 14:34:23.305463 110 EXECUTED 8:05c99fc610845ef66ee812b7921af0ef createIndex indexName=IDX_GROUP_ATT_BY_NAME_VALUE, tableName=GROUP_ATTRIBUTE \N 4.8.0 \N \N 3274862955 -20.0.0-12964-unsupported-dbs keycloak META-INF/jpa-changelog-20.0.0.xml 2023-01-09 14:34:23.309005 111 MARK_RAN 8:314e803baf2f1ec315b3464e398b8247 createIndex indexName=IDX_GROUP_ATT_BY_NAME_VALUE, tableName=GROUP_ATTRIBUTE \N 4.8.0 \N \N 3274862955 -client-attributes-string-accomodation-fixed keycloak META-INF/jpa-changelog-20.0.0.xml 2023-01-09 14:34:23.318788 112 EXECUTED 8:56e4677e7e12556f70b604c573840100 addColumn tableName=CLIENT_ATTRIBUTES; update tableName=CLIENT_ATTRIBUTES; dropColumn columnName=VALUE, tableName=CLIENT_ATTRIBUTES; renameColumn newColumnName=VALUE, oldColumnName=VALUE_NEW, tableName=CLIENT_ATTRIBUTES \N 4.8.0 \N \N 3274862955 +3.4.2 keycloak META-INF/jpa-changelog-3.4.2.xml 2022-02-02 16:47:26.786619 53 EXECUTED 9:8f23e334dbc59f82e0a328373ca6ced0 update tableName=REALM \N 3.5.4 \N \N 3820445829 +1.0.0.Final-KEYCLOAK-5461 sthorger@redhat.com META-INF/jpa-changelog-1.0.0.Final.xml 2022-02-02 16:47:26.017844 1 EXECUTED 9:6f1016664e21e16d26517a4418f5e3df createTable tableName=APPLICATION_DEFAULT_ROLES; createTable tableName=CLIENT; createTable tableName=CLIENT_SESSION; createTable tableName=CLIENT_SESSION_ROLE; createTable tableName=COMPOSITE_ROLE; createTable tableName=CREDENTIAL; createTable tab... \N 3.5.4 \N \N 3820445829 +1.0.0.Final-KEYCLOAK-5461 sthorger@redhat.com META-INF/db2-jpa-changelog-1.0.0.Final.xml 2022-02-02 16:47:26.03122 2 MARK_RAN 9:828775b1596a07d1200ba1d49e5e3941 createTable tableName=APPLICATION_DEFAULT_ROLES; createTable tableName=CLIENT; createTable tableName=CLIENT_SESSION; createTable tableName=CLIENT_SESSION_ROLE; createTable tableName=COMPOSITE_ROLE; createTable tableName=CREDENTIAL; createTable tab... \N 3.5.4 \N \N 3820445829 +1.1.0.Beta1 sthorger@redhat.com META-INF/jpa-changelog-1.1.0.Beta1.xml 2022-02-02 16:47:26.06085 3 EXECUTED 9:5f090e44a7d595883c1fb61f4b41fd38 delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION; createTable tableName=CLIENT_ATTRIBUTES; createTable tableName=CLIENT_SESSION_NOTE; createTable tableName=APP_NODE_REGISTRATIONS; addColumn table... \N 3.5.4 \N \N 3820445829 +1.1.0.Final sthorger@redhat.com META-INF/jpa-changelog-1.1.0.Final.xml 2022-02-02 16:47:26.065284 4 EXECUTED 9:c07e577387a3d2c04d1adc9aaad8730e renameColumn newColumnName=EVENT_TIME, oldColumnName=TIME, tableName=EVENT_ENTITY \N 3.5.4 \N \N 3820445829 +1.2.0.Beta1 psilva@redhat.com META-INF/jpa-changelog-1.2.0.Beta1.xml 2022-02-02 16:47:26.130908 5 EXECUTED 9:b68ce996c655922dbcd2fe6b6ae72686 delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION; createTable tableName=PROTOCOL_MAPPER; createTable tableName=PROTOCOL_MAPPER_CONFIG; createTable tableName=... \N 3.5.4 \N \N 3820445829 +1.2.0.Beta1 psilva@redhat.com META-INF/db2-jpa-changelog-1.2.0.Beta1.xml 2022-02-02 16:47:26.133863 6 MARK_RAN 9:543b5c9989f024fe35c6f6c5a97de88e delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION; createTable tableName=PROTOCOL_MAPPER; createTable tableName=PROTOCOL_MAPPER_CONFIG; createTable tableName=... \N 3.5.4 \N \N 3820445829 +1.2.0.RC1 bburke@redhat.com META-INF/jpa-changelog-1.2.0.CR1.xml 2022-02-02 16:47:26.183318 7 EXECUTED 9:765afebbe21cf5bbca048e632df38336 delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete tableName=USER_SESSION; createTable tableName=MIGRATION_MODEL; createTable tableName=IDENTITY_P... \N 3.5.4 \N \N 3820445829 +1.2.0.RC1 bburke@redhat.com META-INF/db2-jpa-changelog-1.2.0.CR1.xml 2022-02-02 16:47:26.186858 8 MARK_RAN 9:db4a145ba11a6fdaefb397f6dbf829a1 delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete tableName=USER_SESSION; createTable tableName=MIGRATION_MODEL; createTable tableName=IDENTITY_P... \N 3.5.4 \N \N 3820445829 +1.2.0.Final keycloak META-INF/jpa-changelog-1.2.0.Final.xml 2022-02-02 16:47:26.19172 9 EXECUTED 9:9d05c7be10cdb873f8bcb41bc3a8ab23 update tableName=CLIENT; update tableName=CLIENT; update tableName=CLIENT \N 3.5.4 \N \N 3820445829 +1.3.0 bburke@redhat.com META-INF/jpa-changelog-1.3.0.xml 2022-02-02 16:47:26.242162 10 EXECUTED 9:18593702353128d53111f9b1ff0b82b8 delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_PROT_MAPPER; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete tableName=USER_SESSION; createTable tableName=ADMI... \N 3.5.4 \N \N 3820445829 +1.4.0 bburke@redhat.com META-INF/jpa-changelog-1.4.0.xml 2022-02-02 16:47:26.275929 11 EXECUTED 9:6122efe5f090e41a85c0f1c9e52cbb62 delete tableName=CLIENT_SESSION_AUTH_STATUS; delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_PROT_MAPPER; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete table... \N 3.5.4 \N \N 3820445829 +1.4.0 bburke@redhat.com META-INF/db2-jpa-changelog-1.4.0.xml 2022-02-02 16:47:26.278548 12 MARK_RAN 9:e1ff28bf7568451453f844c5d54bb0b5 delete tableName=CLIENT_SESSION_AUTH_STATUS; delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_PROT_MAPPER; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete table... \N 3.5.4 \N \N 3820445829 +1.5.0 bburke@redhat.com META-INF/jpa-changelog-1.5.0.xml 2022-02-02 16:47:26.287616 13 EXECUTED 9:7af32cd8957fbc069f796b61217483fd delete tableName=CLIENT_SESSION_AUTH_STATUS; delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_PROT_MAPPER; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete table... \N 3.5.4 \N \N 3820445829 +1.6.1_from15 mposolda@redhat.com META-INF/jpa-changelog-1.6.1.xml 2022-02-02 16:47:26.299798 14 EXECUTED 9:6005e15e84714cd83226bf7879f54190 addColumn tableName=REALM; addColumn tableName=KEYCLOAK_ROLE; addColumn tableName=CLIENT; createTable tableName=OFFLINE_USER_SESSION; createTable tableName=OFFLINE_CLIENT_SESSION; addPrimaryKey constraintName=CONSTRAINT_OFFL_US_SES_PK2, tableName=... \N 3.5.4 \N \N 3820445829 +1.6.1_from16-pre mposolda@redhat.com META-INF/jpa-changelog-1.6.1.xml 2022-02-02 16:47:26.302088 15 MARK_RAN 9:bf656f5a2b055d07f314431cae76f06c delete tableName=OFFLINE_CLIENT_SESSION; delete tableName=OFFLINE_USER_SESSION \N 3.5.4 \N \N 3820445829 +1.6.1_from16 mposolda@redhat.com META-INF/jpa-changelog-1.6.1.xml 2022-02-02 16:47:26.303889 16 MARK_RAN 9:f8dadc9284440469dcf71e25ca6ab99b dropPrimaryKey constraintName=CONSTRAINT_OFFLINE_US_SES_PK, tableName=OFFLINE_USER_SESSION; dropPrimaryKey constraintName=CONSTRAINT_OFFLINE_CL_SES_PK, tableName=OFFLINE_CLIENT_SESSION; addColumn tableName=OFFLINE_USER_SESSION; update tableName=OF... \N 3.5.4 \N \N 3820445829 +1.7.0 bburke@redhat.com META-INF/jpa-changelog-1.7.0.xml 2022-02-02 16:47:26.338791 18 EXECUTED 9:3368ff0be4c2855ee2dd9ca813b38d8e createTable tableName=KEYCLOAK_GROUP; createTable tableName=GROUP_ROLE_MAPPING; createTable tableName=GROUP_ATTRIBUTE; createTable tableName=USER_GROUP_MEMBERSHIP; createTable tableName=REALM_DEFAULT_GROUPS; addColumn tableName=IDENTITY_PROVIDER; ... \N 3.5.4 \N \N 3820445829 +1.8.0 mposolda@redhat.com META-INF/jpa-changelog-1.8.0.xml 2022-02-02 16:47:26.381463 19 EXECUTED 9:8ac2fb5dd030b24c0570a763ed75ed20 addColumn tableName=IDENTITY_PROVIDER; createTable tableName=CLIENT_TEMPLATE; createTable tableName=CLIENT_TEMPLATE_ATTRIBUTES; createTable tableName=TEMPLATE_SCOPE_MAPPING; dropNotNullConstraint columnName=CLIENT_ID, tableName=PROTOCOL_MAPPER; ad... \N 3.5.4 \N \N 3820445829 +1.8.0-2 keycloak META-INF/jpa-changelog-1.8.0.xml 2022-02-02 16:47:26.390165 20 EXECUTED 9:f91ddca9b19743db60e3057679810e6c dropDefaultValue columnName=ALGORITHM, tableName=CREDENTIAL; update tableName=CREDENTIAL \N 3.5.4 \N \N 3820445829 +1.8.0 mposolda@redhat.com META-INF/db2-jpa-changelog-1.8.0.xml 2022-02-02 16:47:26.392862 21 MARK_RAN 9:831e82914316dc8a57dc09d755f23c51 addColumn tableName=IDENTITY_PROVIDER; createTable tableName=CLIENT_TEMPLATE; createTable tableName=CLIENT_TEMPLATE_ATTRIBUTES; createTable tableName=TEMPLATE_SCOPE_MAPPING; dropNotNullConstraint columnName=CLIENT_ID, tableName=PROTOCOL_MAPPER; ad... \N 3.5.4 \N \N 3820445829 +1.8.0-2 keycloak META-INF/db2-jpa-changelog-1.8.0.xml 2022-02-02 16:47:26.395652 22 MARK_RAN 9:f91ddca9b19743db60e3057679810e6c dropDefaultValue columnName=ALGORITHM, tableName=CREDENTIAL; update tableName=CREDENTIAL \N 3.5.4 \N \N 3820445829 +1.9.0 mposolda@redhat.com META-INF/jpa-changelog-1.9.0.xml 2022-02-02 16:47:26.40969 23 EXECUTED 9:bc3d0f9e823a69dc21e23e94c7a94bb1 update tableName=REALM; update tableName=REALM; update tableName=REALM; update tableName=REALM; update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=REALM; update tableName=REALM; customChange; dr... \N 3.5.4 \N \N 3820445829 +1.9.1 keycloak META-INF/jpa-changelog-1.9.1.xml 2022-02-02 16:47:26.414344 24 EXECUTED 9:c9999da42f543575ab790e76439a2679 modifyDataType columnName=PRIVATE_KEY, tableName=REALM; modifyDataType columnName=PUBLIC_KEY, tableName=REALM; modifyDataType columnName=CERTIFICATE, tableName=REALM \N 3.5.4 \N \N 3820445829 +1.9.1 keycloak META-INF/db2-jpa-changelog-1.9.1.xml 2022-02-02 16:47:26.416193 25 MARK_RAN 9:0d6c65c6f58732d81569e77b10ba301d modifyDataType columnName=PRIVATE_KEY, tableName=REALM; modifyDataType columnName=CERTIFICATE, tableName=REALM \N 3.5.4 \N \N 3820445829 +1.9.2 keycloak META-INF/jpa-changelog-1.9.2.xml 2022-02-02 16:47:26.437367 26 EXECUTED 9:fc576660fc016ae53d2d4778d84d86d0 createIndex indexName=IDX_USER_EMAIL, tableName=USER_ENTITY; createIndex indexName=IDX_USER_ROLE_MAPPING, tableName=USER_ROLE_MAPPING; createIndex indexName=IDX_USER_GROUP_MAPPING, tableName=USER_GROUP_MEMBERSHIP; createIndex indexName=IDX_USER_CO... \N 3.5.4 \N \N 3820445829 +authz-2.0.0 psilva@redhat.com META-INF/jpa-changelog-authz-2.0.0.xml 2022-02-02 16:47:26.481647 27 EXECUTED 9:43ed6b0da89ff77206289e87eaa9c024 createTable tableName=RESOURCE_SERVER; addPrimaryKey constraintName=CONSTRAINT_FARS, tableName=RESOURCE_SERVER; addUniqueConstraint constraintName=UK_AU8TT6T700S9V50BU18WS5HA6, tableName=RESOURCE_SERVER; createTable tableName=RESOURCE_SERVER_RESOU... \N 3.5.4 \N \N 3820445829 +2.1.0-KEYCLOAK-5461 bburke@redhat.com META-INF/jpa-changelog-2.1.0.xml 2022-02-02 16:47:26.523006 29 EXECUTED 9:bd88e1f833df0420b01e114533aee5e8 createTable tableName=BROKER_LINK; createTable tableName=FED_USER_ATTRIBUTE; createTable tableName=FED_USER_CONSENT; createTable tableName=FED_USER_CONSENT_ROLE; createTable tableName=FED_USER_CONSENT_PROT_MAPPER; createTable tableName=FED_USER_CR... \N 3.5.4 \N \N 3820445829 +2.2.0 bburke@redhat.com META-INF/jpa-changelog-2.2.0.xml 2022-02-02 16:47:26.532066 30 EXECUTED 9:a7022af5267f019d020edfe316ef4371 addColumn tableName=ADMIN_EVENT_ENTITY; createTable tableName=CREDENTIAL_ATTRIBUTE; createTable tableName=FED_CREDENTIAL_ATTRIBUTE; modifyDataType columnName=VALUE, tableName=CREDENTIAL; addForeignKeyConstraint baseTableName=FED_CREDENTIAL_ATTRIBU... \N 3.5.4 \N \N 3820445829 +2.3.0 bburke@redhat.com META-INF/jpa-changelog-2.3.0.xml 2022-02-02 16:47:26.541837 31 EXECUTED 9:fc155c394040654d6a79227e56f5e25a createTable tableName=FEDERATED_USER; addPrimaryKey constraintName=CONSTR_FEDERATED_USER, tableName=FEDERATED_USER; dropDefaultValue columnName=TOTP, tableName=USER_ENTITY; dropColumn columnName=TOTP, tableName=USER_ENTITY; addColumn tableName=IDE... \N 3.5.4 \N \N 3820445829 +2.4.0 bburke@redhat.com META-INF/jpa-changelog-2.4.0.xml 2022-02-02 16:47:26.545809 32 EXECUTED 9:eac4ffb2a14795e5dc7b426063e54d88 customChange \N 3.5.4 \N \N 3820445829 +2.5.0 bburke@redhat.com META-INF/jpa-changelog-2.5.0.xml 2022-02-02 16:47:26.549823 33 EXECUTED 9:54937c05672568c4c64fc9524c1e9462 customChange; modifyDataType columnName=USER_ID, tableName=OFFLINE_USER_SESSION \N 3.5.4 \N \N 3820445829 +2.5.0-unicode-oracle hmlnarik@redhat.com META-INF/jpa-changelog-2.5.0.xml 2022-02-02 16:47:26.55176 34 MARK_RAN 9:3a32bace77c84d7678d035a7f5a8084e modifyDataType columnName=DESCRIPTION, tableName=AUTHENTICATION_FLOW; modifyDataType columnName=DESCRIPTION, tableName=CLIENT_TEMPLATE; modifyDataType columnName=DESCRIPTION, tableName=RESOURCE_SERVER_POLICY; modifyDataType columnName=DESCRIPTION,... \N 3.5.4 \N \N 3820445829 +2.5.0-unicode-other-dbs hmlnarik@redhat.com META-INF/jpa-changelog-2.5.0.xml 2022-02-02 16:47:26.567305 35 EXECUTED 9:33d72168746f81f98ae3a1e8e0ca3554 modifyDataType columnName=DESCRIPTION, tableName=AUTHENTICATION_FLOW; modifyDataType columnName=DESCRIPTION, tableName=CLIENT_TEMPLATE; modifyDataType columnName=DESCRIPTION, tableName=RESOURCE_SERVER_POLICY; modifyDataType columnName=DESCRIPTION,... \N 3.5.4 \N \N 3820445829 +2.5.0-duplicate-email-support slawomir@dabek.name META-INF/jpa-changelog-2.5.0.xml 2022-02-02 16:47:26.570727 36 EXECUTED 9:61b6d3d7a4c0e0024b0c839da283da0c addColumn tableName=REALM \N 3.5.4 \N \N 3820445829 +2.5.0-unique-group-names hmlnarik@redhat.com META-INF/jpa-changelog-2.5.0.xml 2022-02-02 16:47:26.578396 37 EXECUTED 9:8dcac7bdf7378e7d823cdfddebf72fda addUniqueConstraint constraintName=SIBLING_NAMES, tableName=KEYCLOAK_GROUP \N 3.5.4 \N \N 3820445829 +2.5.1 bburke@redhat.com META-INF/jpa-changelog-2.5.1.xml 2022-02-02 16:47:26.581391 38 EXECUTED 9:a2b870802540cb3faa72098db5388af3 addColumn tableName=FED_USER_CONSENT \N 3.5.4 \N \N 3820445829 +3.0.0 bburke@redhat.com META-INF/jpa-changelog-3.0.0.xml 2022-02-02 16:47:26.584204 39 EXECUTED 9:132a67499ba24bcc54fb5cbdcfe7e4c0 addColumn tableName=IDENTITY_PROVIDER \N 3.5.4 \N \N 3820445829 +3.2.0-fix keycloak META-INF/jpa-changelog-3.2.0.xml 2022-02-02 16:47:26.585877 40 MARK_RAN 9:938f894c032f5430f2b0fafb1a243462 addNotNullConstraint columnName=REALM_ID, tableName=CLIENT_INITIAL_ACCESS \N 3.5.4 \N \N 3820445829 +3.2.0-fix-with-keycloak-5416 keycloak META-INF/jpa-changelog-3.2.0.xml 2022-02-02 16:47:26.587657 41 MARK_RAN 9:845c332ff1874dc5d35974b0babf3006 dropIndex indexName=IDX_CLIENT_INIT_ACC_REALM, tableName=CLIENT_INITIAL_ACCESS; addNotNullConstraint columnName=REALM_ID, tableName=CLIENT_INITIAL_ACCESS; createIndex indexName=IDX_CLIENT_INIT_ACC_REALM, tableName=CLIENT_INITIAL_ACCESS \N 3.5.4 \N \N 3820445829 +3.2.0-fix-offline-sessions hmlnarik META-INF/jpa-changelog-3.2.0.xml 2022-02-02 16:47:26.591561 42 EXECUTED 9:fc86359c079781adc577c5a217e4d04c customChange \N 3.5.4 \N \N 3820445829 +3.2.0-fixed keycloak META-INF/jpa-changelog-3.2.0.xml 2022-02-02 16:47:26.669981 43 EXECUTED 9:59a64800e3c0d09b825f8a3b444fa8f4 addColumn tableName=REALM; dropPrimaryKey constraintName=CONSTRAINT_OFFL_CL_SES_PK2, tableName=OFFLINE_CLIENT_SESSION; dropColumn columnName=CLIENT_SESSION_ID, tableName=OFFLINE_CLIENT_SESSION; addPrimaryKey constraintName=CONSTRAINT_OFFL_CL_SES_P... \N 3.5.4 \N \N 3820445829 +3.3.0 keycloak META-INF/jpa-changelog-3.3.0.xml 2022-02-02 16:47:26.673701 44 EXECUTED 9:d48d6da5c6ccf667807f633fe489ce88 addColumn tableName=USER_ENTITY \N 3.5.4 \N \N 3820445829 +authz-3.4.0.CR1-resource-server-pk-change-part1 glavoie@gmail.com META-INF/jpa-changelog-authz-3.4.0.CR1.xml 2022-02-02 16:47:26.679075 45 EXECUTED 9:dde36f7973e80d71fceee683bc5d2951 addColumn tableName=RESOURCE_SERVER_POLICY; addColumn tableName=RESOURCE_SERVER_RESOURCE; addColumn tableName=RESOURCE_SERVER_SCOPE \N 3.5.4 \N \N 3820445829 +authz-3.4.0.CR1-resource-server-pk-change-part2-KEYCLOAK-6095 hmlnarik@redhat.com META-INF/jpa-changelog-authz-3.4.0.CR1.xml 2022-02-02 16:47:26.681987 46 EXECUTED 9:b855e9b0a406b34fa323235a0cf4f640 customChange \N 3.5.4 \N \N 3820445829 +authz-3.4.0.CR1-resource-server-pk-change-part3-fixed glavoie@gmail.com META-INF/jpa-changelog-authz-3.4.0.CR1.xml 2022-02-02 16:47:26.683661 47 MARK_RAN 9:51abbacd7b416c50c4421a8cabf7927e dropIndex indexName=IDX_RES_SERV_POL_RES_SERV, tableName=RESOURCE_SERVER_POLICY; dropIndex indexName=IDX_RES_SRV_RES_RES_SRV, tableName=RESOURCE_SERVER_RESOURCE; dropIndex indexName=IDX_RES_SRV_SCOPE_RES_SRV, tableName=RESOURCE_SERVER_SCOPE \N 3.5.4 \N \N 3820445829 +authz-3.4.0.CR1-resource-server-pk-change-part3-fixed-nodropindex glavoie@gmail.com META-INF/jpa-changelog-authz-3.4.0.CR1.xml 2022-02-02 16:47:26.702743 48 EXECUTED 9:bdc99e567b3398bac83263d375aad143 addNotNullConstraint columnName=RESOURCE_SERVER_CLIENT_ID, tableName=RESOURCE_SERVER_POLICY; addNotNullConstraint columnName=RESOURCE_SERVER_CLIENT_ID, tableName=RESOURCE_SERVER_RESOURCE; addNotNullConstraint columnName=RESOURCE_SERVER_CLIENT_ID, ... \N 3.5.4 \N \N 3820445829 +authn-3.4.0.CR1-refresh-token-max-reuse glavoie@gmail.com META-INF/jpa-changelog-authz-3.4.0.CR1.xml 2022-02-02 16:47:26.706593 49 EXECUTED 9:d198654156881c46bfba39abd7769e69 addColumn tableName=REALM \N 3.5.4 \N \N 3820445829 +3.4.0 keycloak META-INF/jpa-changelog-3.4.0.xml 2022-02-02 16:47:26.734467 50 EXECUTED 9:cfdd8736332ccdd72c5256ccb42335db addPrimaryKey constraintName=CONSTRAINT_REALM_DEFAULT_ROLES, tableName=REALM_DEFAULT_ROLES; addPrimaryKey constraintName=CONSTRAINT_COMPOSITE_ROLE, tableName=COMPOSITE_ROLE; addPrimaryKey constraintName=CONSTR_REALM_DEFAULT_GROUPS, tableName=REALM... \N 3.5.4 \N \N 3820445829 +3.4.0-KEYCLOAK-5230 hmlnarik@redhat.com META-INF/jpa-changelog-3.4.0.xml 2022-02-02 16:47:26.78037 51 EXECUTED 9:7c84de3d9bd84d7f077607c1a4dcb714 createIndex indexName=IDX_FU_ATTRIBUTE, tableName=FED_USER_ATTRIBUTE; createIndex indexName=IDX_FU_CONSENT, tableName=FED_USER_CONSENT; createIndex indexName=IDX_FU_CONSENT_RU, tableName=FED_USER_CONSENT; createIndex indexName=IDX_FU_CREDENTIAL, t... \N 3.5.4 \N \N 3820445829 +3.4.1 psilva@redhat.com META-INF/jpa-changelog-3.4.1.xml 2022-02-02 16:47:26.783989 52 EXECUTED 9:5a6bb36cbefb6a9d6928452c0852af2d modifyDataType columnName=VALUE, tableName=CLIENT_ATTRIBUTES \N 3.5.4 \N \N 3820445829 +3.4.2-KEYCLOAK-5172 mkanis@redhat.com META-INF/jpa-changelog-3.4.2.xml 2022-02-02 16:47:26.788788 54 EXECUTED 9:9156214268f09d970cdf0e1564d866af update tableName=CLIENT \N 3.5.4 \N \N 3820445829 +4.0.0-KEYCLOAK-6335 bburke@redhat.com META-INF/jpa-changelog-4.0.0.xml 2022-02-02 16:47:26.794881 55 EXECUTED 9:db806613b1ed154826c02610b7dbdf74 createTable tableName=CLIENT_AUTH_FLOW_BINDINGS; addPrimaryKey constraintName=C_CLI_FLOW_BIND, tableName=CLIENT_AUTH_FLOW_BINDINGS \N 3.5.4 \N \N 3820445829 +4.0.0-CLEANUP-UNUSED-TABLE bburke@redhat.com META-INF/jpa-changelog-4.0.0.xml 2022-02-02 16:47:26.799493 56 EXECUTED 9:229a041fb72d5beac76bb94a5fa709de dropTable tableName=CLIENT_IDENTITY_PROV_MAPPING \N 3.5.4 \N \N 3820445829 +4.0.0-KEYCLOAK-6228 bburke@redhat.com META-INF/jpa-changelog-4.0.0.xml 2022-02-02 16:47:26.810686 57 EXECUTED 9:079899dade9c1e683f26b2aa9ca6ff04 dropUniqueConstraint constraintName=UK_JKUWUVD56ONTGSUHOGM8UEWRT, tableName=USER_CONSENT; dropNotNullConstraint columnName=CLIENT_ID, tableName=USER_CONSENT; addColumn tableName=USER_CONSENT; addUniqueConstraint constraintName=UK_JKUWUVD56ONTGSUHO... \N 3.5.4 \N \N 3820445829 +4.0.0-KEYCLOAK-5579-fixed mposolda@redhat.com META-INF/jpa-changelog-4.0.0.xml 2022-02-02 16:47:26.861332 58 EXECUTED 9:139b79bcbbfe903bb1c2d2a4dbf001d9 dropForeignKeyConstraint baseTableName=CLIENT_TEMPLATE_ATTRIBUTES, constraintName=FK_CL_TEMPL_ATTR_TEMPL; renameTable newTableName=CLIENT_SCOPE_ATTRIBUTES, oldTableName=CLIENT_TEMPLATE_ATTRIBUTES; renameColumn newColumnName=SCOPE_ID, oldColumnName... \N 3.5.4 \N \N 3820445829 +authz-4.0.0.CR1 psilva@redhat.com META-INF/jpa-changelog-authz-4.0.0.CR1.xml 2022-02-02 16:47:26.877018 59 EXECUTED 9:b55738ad889860c625ba2bf483495a04 createTable tableName=RESOURCE_SERVER_PERM_TICKET; addPrimaryKey constraintName=CONSTRAINT_FAPMT, tableName=RESOURCE_SERVER_PERM_TICKET; addForeignKeyConstraint baseTableName=RESOURCE_SERVER_PERM_TICKET, constraintName=FK_FRSRHO213XCX4WNKOG82SSPMT... \N 3.5.4 \N \N 3820445829 +authz-4.0.0.Beta3 psilva@redhat.com META-INF/jpa-changelog-authz-4.0.0.Beta3.xml 2022-02-02 16:47:26.881203 60 EXECUTED 9:e0057eac39aa8fc8e09ac6cfa4ae15fe addColumn tableName=RESOURCE_SERVER_POLICY; addColumn tableName=RESOURCE_SERVER_PERM_TICKET; addForeignKeyConstraint baseTableName=RESOURCE_SERVER_PERM_TICKET, constraintName=FK_FRSRPO2128CX4WNKOG82SSRFY, referencedTableName=RESOURCE_SERVER_POLICY \N 3.5.4 \N \N 3820445829 +authz-4.2.0.Final mhajas@redhat.com META-INF/jpa-changelog-authz-4.2.0.Final.xml 2022-02-02 16:47:26.886177 61 EXECUTED 9:42a33806f3a0443fe0e7feeec821326c createTable tableName=RESOURCE_URIS; addForeignKeyConstraint baseTableName=RESOURCE_URIS, constraintName=FK_RESOURCE_SERVER_URIS, referencedTableName=RESOURCE_SERVER_RESOURCE; customChange; dropColumn columnName=URI, tableName=RESOURCE_SERVER_RESO... \N 3.5.4 \N \N 3820445829 +authz-4.2.0.Final-KEYCLOAK-9944 hmlnarik@redhat.com META-INF/jpa-changelog-authz-4.2.0.Final.xml 2022-02-02 16:47:26.890482 62 EXECUTED 9:9968206fca46eecc1f51db9c024bfe56 addPrimaryKey constraintName=CONSTRAINT_RESOUR_URIS_PK, tableName=RESOURCE_URIS \N 3.5.4 \N \N 3820445829 +4.2.0-KEYCLOAK-6313 wadahiro@gmail.com META-INF/jpa-changelog-4.2.0.xml 2022-02-02 16:47:26.893518 63 EXECUTED 9:92143a6daea0a3f3b8f598c97ce55c3d addColumn tableName=REQUIRED_ACTION_PROVIDER \N 3.5.4 \N \N 3820445829 +4.3.0-KEYCLOAK-7984 wadahiro@gmail.com META-INF/jpa-changelog-4.3.0.xml 2022-02-02 16:47:26.895621 64 EXECUTED 9:82bab26a27195d889fb0429003b18f40 update tableName=REQUIRED_ACTION_PROVIDER \N 3.5.4 \N \N 3820445829 +4.6.0-KEYCLOAK-7950 psilva@redhat.com META-INF/jpa-changelog-4.6.0.xml 2022-02-02 16:47:26.89756 65 EXECUTED 9:e590c88ddc0b38b0ae4249bbfcb5abc3 update tableName=RESOURCE_SERVER_RESOURCE \N 3.5.4 \N \N 3820445829 +4.6.0-KEYCLOAK-8377 keycloak META-INF/jpa-changelog-4.6.0.xml 2022-02-02 16:47:26.908059 66 EXECUTED 9:5c1f475536118dbdc38d5d7977950cc0 createTable tableName=ROLE_ATTRIBUTE; addPrimaryKey constraintName=CONSTRAINT_ROLE_ATTRIBUTE_PK, tableName=ROLE_ATTRIBUTE; addForeignKeyConstraint baseTableName=ROLE_ATTRIBUTE, constraintName=FK_ROLE_ATTRIBUTE_ID, referencedTableName=KEYCLOAK_ROLE... \N 3.5.4 \N \N 3820445829 +4.6.0-KEYCLOAK-8555 gideonray@gmail.com META-INF/jpa-changelog-4.6.0.xml 2022-02-02 16:47:26.912693 67 EXECUTED 9:e7c9f5f9c4d67ccbbcc215440c718a17 createIndex indexName=IDX_COMPONENT_PROVIDER_TYPE, tableName=COMPONENT \N 3.5.4 \N \N 3820445829 +4.7.0-KEYCLOAK-1267 sguilhen@redhat.com META-INF/jpa-changelog-4.7.0.xml 2022-02-02 16:47:26.915771 68 EXECUTED 9:88e0bfdda924690d6f4e430c53447dd5 addColumn tableName=REALM \N 3.5.4 \N \N 3820445829 +4.7.0-KEYCLOAK-7275 keycloak META-INF/jpa-changelog-4.7.0.xml 2022-02-02 16:47:26.924465 69 EXECUTED 9:f53177f137e1c46b6a88c59ec1cb5218 renameColumn newColumnName=CREATED_ON, oldColumnName=LAST_SESSION_REFRESH, tableName=OFFLINE_USER_SESSION; addNotNullConstraint columnName=CREATED_ON, tableName=OFFLINE_USER_SESSION; addColumn tableName=OFFLINE_USER_SESSION; customChange; createIn... \N 3.5.4 \N \N 3820445829 +4.8.0-KEYCLOAK-8835 sguilhen@redhat.com META-INF/jpa-changelog-4.8.0.xml 2022-02-02 16:47:26.928034 70 EXECUTED 9:a74d33da4dc42a37ec27121580d1459f addNotNullConstraint columnName=SSO_MAX_LIFESPAN_REMEMBER_ME, tableName=REALM; addNotNullConstraint columnName=SSO_IDLE_TIMEOUT_REMEMBER_ME, tableName=REALM \N 3.5.4 \N \N 3820445829 +authz-7.0.0-KEYCLOAK-10443 psilva@redhat.com META-INF/jpa-changelog-authz-7.0.0.xml 2022-02-02 16:47:26.93061 71 EXECUTED 9:fd4ade7b90c3b67fae0bfcfcb42dfb5f addColumn tableName=RESOURCE_SERVER \N 3.5.4 \N \N 3820445829 +8.0.0-adding-credential-columns keycloak META-INF/jpa-changelog-8.0.0.xml 2022-02-02 16:47:26.933771 72 EXECUTED 9:aa072ad090bbba210d8f18781b8cebf4 addColumn tableName=CREDENTIAL; addColumn tableName=FED_USER_CREDENTIAL \N 3.5.4 \N \N 3820445829 +8.0.0-credential-cleanup-fixed keycloak META-INF/jpa-changelog-8.0.0.xml 2022-02-02 16:47:26.945819 75 EXECUTED 9:2b9cc12779be32c5b40e2e67711a218b dropDefaultValue columnName=COUNTER, tableName=CREDENTIAL; dropDefaultValue columnName=DIGITS, tableName=CREDENTIAL; dropDefaultValue columnName=PERIOD, tableName=CREDENTIAL; dropDefaultValue columnName=ALGORITHM, tableName=CREDENTIAL; dropColumn ... \N 3.5.4 \N \N 3820445829 +8.0.0-resource-tag-support keycloak META-INF/jpa-changelog-8.0.0.xml 2022-02-02 16:47:26.950255 76 EXECUTED 9:91fa186ce7a5af127a2d7a91ee083cc5 addColumn tableName=MIGRATION_MODEL; createIndex indexName=IDX_UPDATE_TIME, tableName=MIGRATION_MODEL \N 3.5.4 \N \N 3820445829 +9.0.0-always-display-client keycloak META-INF/jpa-changelog-9.0.0.xml 2022-02-02 16:47:26.955505 77 EXECUTED 9:6335e5c94e83a2639ccd68dd24e2e5ad addColumn tableName=CLIENT \N 3.5.4 \N \N 3820445829 +9.0.0-drop-constraints-for-column-increase keycloak META-INF/jpa-changelog-9.0.0.xml 2022-02-02 16:47:26.957216 78 MARK_RAN 9:6bdb5658951e028bfe16fa0a8228b530 dropUniqueConstraint constraintName=UK_FRSR6T700S9V50BU18WS5PMT, tableName=RESOURCE_SERVER_PERM_TICKET; dropUniqueConstraint constraintName=UK_FRSR6T700S9V50BU18WS5HA6, tableName=RESOURCE_SERVER_RESOURCE; dropPrimaryKey constraintName=CONSTRAINT_O... \N 3.5.4 \N \N 3820445829 +9.0.0-increase-column-size-federated-fk keycloak META-INF/jpa-changelog-9.0.0.xml 2022-02-02 16:47:26.966746 79 EXECUTED 9:d5bc15a64117ccad481ce8792d4c608f modifyDataType columnName=CLIENT_ID, tableName=FED_USER_CONSENT; modifyDataType columnName=CLIENT_REALM_CONSTRAINT, tableName=KEYCLOAK_ROLE; modifyDataType columnName=OWNER, tableName=RESOURCE_SERVER_POLICY; modifyDataType columnName=CLIENT_ID, ta... \N 3.5.4 \N \N 3820445829 +9.0.0-recreate-constraints-after-column-increase keycloak META-INF/jpa-changelog-9.0.0.xml 2022-02-02 16:47:26.969643 80 MARK_RAN 9:077cba51999515f4d3e7ad5619ab592c addNotNullConstraint columnName=CLIENT_ID, tableName=OFFLINE_CLIENT_SESSION; addNotNullConstraint columnName=OWNER, tableName=RESOURCE_SERVER_PERM_TICKET; addNotNullConstraint columnName=REQUESTER, tableName=RESOURCE_SERVER_PERM_TICKET; addNotNull... \N 3.5.4 \N \N 3820445829 +9.0.1-add-index-to-client.client_id keycloak META-INF/jpa-changelog-9.0.1.xml 2022-02-02 16:47:26.975764 81 EXECUTED 9:be969f08a163bf47c6b9e9ead8ac2afb createIndex indexName=IDX_CLIENT_ID, tableName=CLIENT \N 3.5.4 \N \N 3820445829 +9.0.1-KEYCLOAK-12579-drop-constraints keycloak META-INF/jpa-changelog-9.0.1.xml 2022-02-02 16:47:26.977227 82 MARK_RAN 9:6d3bb4408ba5a72f39bd8a0b301ec6e3 dropUniqueConstraint constraintName=SIBLING_NAMES, tableName=KEYCLOAK_GROUP \N 3.5.4 \N \N 3820445829 +9.0.1-KEYCLOAK-12579-add-not-null-constraint keycloak META-INF/jpa-changelog-9.0.1.xml 2022-02-02 16:47:26.980058 83 EXECUTED 9:966bda61e46bebf3cc39518fbed52fa7 addNotNullConstraint columnName=PARENT_GROUP, tableName=KEYCLOAK_GROUP \N 3.5.4 \N \N 3820445829 +9.0.1-KEYCLOAK-12579-recreate-constraints keycloak META-INF/jpa-changelog-9.0.1.xml 2022-02-02 16:47:26.981645 84 MARK_RAN 9:8dcac7bdf7378e7d823cdfddebf72fda addUniqueConstraint constraintName=SIBLING_NAMES, tableName=KEYCLOAK_GROUP \N 3.5.4 \N \N 3820445829 +9.0.1-add-index-to-events keycloak META-INF/jpa-changelog-9.0.1.xml 2022-02-02 16:47:26.985465 85 EXECUTED 9:7d93d602352a30c0c317e6a609b56599 createIndex indexName=IDX_EVENT_TIME, tableName=EVENT_ENTITY \N 3.5.4 \N \N 3820445829 +map-remove-ri keycloak META-INF/jpa-changelog-11.0.0.xml 2022-02-02 16:47:26.98869 86 EXECUTED 9:71c5969e6cdd8d7b6f47cebc86d37627 dropForeignKeyConstraint baseTableName=REALM, constraintName=FK_TRAF444KK6QRKMS7N56AIWQ5Y; dropForeignKeyConstraint baseTableName=KEYCLOAK_ROLE, constraintName=FK_KJHO5LE2C0RAL09FL8CM9WFW9 \N 3.5.4 \N \N 3820445829 +map-remove-ri keycloak META-INF/jpa-changelog-12.0.0.xml 2022-02-02 16:47:26.992854 87 EXECUTED 9:a9ba7d47f065f041b7da856a81762021 dropForeignKeyConstraint baseTableName=REALM_DEFAULT_GROUPS, constraintName=FK_DEF_GROUPS_GROUP; dropForeignKeyConstraint baseTableName=REALM_DEFAULT_ROLES, constraintName=FK_H4WPD7W4HSOOLNI3H0SW7BTJE; dropForeignKeyConstraint baseTableName=CLIENT... \N 3.5.4 \N \N 3820445829 +12.1.0-add-realm-localization-table keycloak META-INF/jpa-changelog-12.0.0.xml 2022-02-02 16:47:26.999694 88 EXECUTED 9:fffabce2bc01e1a8f5110d5278500065 createTable tableName=REALM_LOCALIZATIONS; addPrimaryKey tableName=REALM_LOCALIZATIONS \N 3.5.4 \N \N 3820445829 +8.0.0-updating-credential-data-not-oracle-fixed keycloak META-INF/jpa-changelog-8.0.0.xml 2023-01-09 14:34:23.080082 89 MARK_RAN 9:1ae6be29bab7c2aa376f6983b932be37 update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=FED_USER_CREDENTIAL; update tableName=FED_USER_CREDENTIAL; update tableName=FED_USER_CREDENTIAL \N 4.8.0 \N \N 3274862955 +8.0.0-updating-credential-data-oracle-fixed keycloak META-INF/jpa-changelog-8.0.0.xml 2023-01-09 14:34:23.099857 90 MARK_RAN 9:14706f286953fc9a25286dbd8fb30d97 update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=FED_USER_CREDENTIAL; update tableName=FED_USER_CREDENTIAL; update tableName=FED_USER_CREDENTIAL \N 4.8.0 \N \N 3274862955 +default-roles keycloak META-INF/jpa-changelog-13.0.0.xml 2023-01-09 14:34:23.144776 91 EXECUTED 9:fa8a5b5445e3857f4b010bafb5009957 addColumn tableName=REALM; customChange \N 4.8.0 \N \N 3274862955 +default-roles-cleanup keycloak META-INF/jpa-changelog-13.0.0.xml 2023-01-09 14:34:23.155298 92 EXECUTED 9:67ac3241df9a8582d591c5ed87125f39 dropTable tableName=REALM_DEFAULT_ROLES; dropTable tableName=CLIENT_DEFAULT_ROLES \N 4.8.0 \N \N 3274862955 +13.0.0-KEYCLOAK-16844 keycloak META-INF/jpa-changelog-13.0.0.xml 2023-01-09 14:34:23.162911 93 EXECUTED 9:ad1194d66c937e3ffc82386c050ba089 createIndex indexName=IDX_OFFLINE_USS_PRELOAD, tableName=OFFLINE_USER_SESSION \N 4.8.0 \N \N 3274862955 +map-remove-ri-13.0.0 keycloak META-INF/jpa-changelog-13.0.0.xml 2023-01-09 14:34:23.17617 94 EXECUTED 9:d9be619d94af5a2f5d07b9f003543b91 dropForeignKeyConstraint baseTableName=DEFAULT_CLIENT_SCOPE, constraintName=FK_R_DEF_CLI_SCOPE_SCOPE; dropForeignKeyConstraint baseTableName=CLIENT_SCOPE_CLIENT, constraintName=FK_C_CLI_SCOPE_SCOPE; dropForeignKeyConstraint baseTableName=CLIENT_SC... \N 4.8.0 \N \N 3274862955 +13.0.0-KEYCLOAK-17992-drop-constraints keycloak META-INF/jpa-changelog-13.0.0.xml 2023-01-09 14:34:23.1803 95 MARK_RAN 9:544d201116a0fcc5a5da0925fbbc3bde dropPrimaryKey constraintName=C_CLI_SCOPE_BIND, tableName=CLIENT_SCOPE_CLIENT; dropIndex indexName=IDX_CLSCOPE_CL, tableName=CLIENT_SCOPE_CLIENT; dropIndex indexName=IDX_CL_CLSCOPE, tableName=CLIENT_SCOPE_CLIENT \N 4.8.0 \N \N 3274862955 +13.0.0-increase-column-size-federated keycloak META-INF/jpa-changelog-13.0.0.xml 2023-01-09 14:34:23.190613 96 EXECUTED 9:43c0c1055b6761b4b3e89de76d612ccf modifyDataType columnName=CLIENT_ID, tableName=CLIENT_SCOPE_CLIENT; modifyDataType columnName=SCOPE_ID, tableName=CLIENT_SCOPE_CLIENT \N 4.8.0 \N \N 3274862955 +13.0.0-KEYCLOAK-17992-recreate-constraints keycloak META-INF/jpa-changelog-13.0.0.xml 2023-01-09 14:34:23.193934 97 MARK_RAN 9:8bd711fd0330f4fe980494ca43ab1139 addNotNullConstraint columnName=CLIENT_ID, tableName=CLIENT_SCOPE_CLIENT; addNotNullConstraint columnName=SCOPE_ID, tableName=CLIENT_SCOPE_CLIENT; addPrimaryKey constraintName=C_CLI_SCOPE_BIND, tableName=CLIENT_SCOPE_CLIENT; createIndex indexName=... \N 4.8.0 \N \N 3274862955 +json-string-accomodation-fixed keycloak META-INF/jpa-changelog-13.0.0.xml 2023-01-09 14:34:23.202965 98 EXECUTED 9:e07d2bc0970c348bb06fb63b1f82ddbf addColumn tableName=REALM_ATTRIBUTE; update tableName=REALM_ATTRIBUTE; dropColumn columnName=VALUE, tableName=REALM_ATTRIBUTE; renameColumn newColumnName=VALUE, oldColumnName=VALUE_NEW, tableName=REALM_ATTRIBUTE \N 4.8.0 \N \N 3274862955 +14.0.0-KEYCLOAK-11019 keycloak META-INF/jpa-changelog-14.0.0.xml 2023-01-09 14:34:23.212244 99 EXECUTED 9:24fb8611e97f29989bea412aa38d12b7 createIndex indexName=IDX_OFFLINE_CSS_PRELOAD, tableName=OFFLINE_CLIENT_SESSION; createIndex indexName=IDX_OFFLINE_USS_BY_USER, tableName=OFFLINE_USER_SESSION; createIndex indexName=IDX_OFFLINE_USS_BY_USERSESS, tableName=OFFLINE_USER_SESSION \N 4.8.0 \N \N 3274862955 +14.0.0-KEYCLOAK-18286 keycloak META-INF/jpa-changelog-14.0.0.xml 2023-01-09 14:34:23.2164 100 MARK_RAN 9:259f89014ce2506ee84740cbf7163aa7 createIndex indexName=IDX_CLIENT_ATT_BY_NAME_VALUE, tableName=CLIENT_ATTRIBUTES \N 4.8.0 \N \N 3274862955 +14.0.0-KEYCLOAK-18286-revert keycloak META-INF/jpa-changelog-14.0.0.xml 2023-01-09 14:34:23.227895 101 MARK_RAN 9:04baaf56c116ed19951cbc2cca584022 dropIndex indexName=IDX_CLIENT_ATT_BY_NAME_VALUE, tableName=CLIENT_ATTRIBUTES \N 4.8.0 \N \N 3274862955 +14.0.0-KEYCLOAK-18286-supported-dbs keycloak META-INF/jpa-changelog-14.0.0.xml 2023-01-09 14:34:23.235491 102 EXECUTED 9:60ca84a0f8c94ec8c3504a5a3bc88ee8 createIndex indexName=IDX_CLIENT_ATT_BY_NAME_VALUE, tableName=CLIENT_ATTRIBUTES \N 4.8.0 \N \N 3274862955 +14.0.0-KEYCLOAK-18286-unsupported-dbs keycloak META-INF/jpa-changelog-14.0.0.xml 2023-01-09 14:34:23.238498 103 MARK_RAN 9:d3d977031d431db16e2c181ce49d73e9 createIndex indexName=IDX_CLIENT_ATT_BY_NAME_VALUE, tableName=CLIENT_ATTRIBUTES \N 4.8.0 \N \N 3274862955 +KEYCLOAK-17267-add-index-to-user-attributes keycloak META-INF/jpa-changelog-14.0.0.xml 2023-01-09 14:34:23.24435 104 EXECUTED 9:0b305d8d1277f3a89a0a53a659ad274c createIndex indexName=IDX_USER_ATTRIBUTE_NAME, tableName=USER_ATTRIBUTE \N 4.8.0 \N \N 3274862955 +KEYCLOAK-18146-add-saml-art-binding-identifier keycloak META-INF/jpa-changelog-14.0.0.xml 2023-01-09 14:34:23.250444 105 EXECUTED 9:2c374ad2cdfe20e2905a84c8fac48460 customChange \N 4.8.0 \N \N 3274862955 +15.0.0-KEYCLOAK-18467 keycloak META-INF/jpa-changelog-15.0.0.xml 2023-01-09 14:34:23.261701 106 EXECUTED 9:47a760639ac597360a8219f5b768b4de addColumn tableName=REALM_LOCALIZATIONS; update tableName=REALM_LOCALIZATIONS; dropColumn columnName=TEXTS, tableName=REALM_LOCALIZATIONS; renameColumn newColumnName=TEXTS, oldColumnName=TEXTS_NEW, tableName=REALM_LOCALIZATIONS; addNotNullConstrai... \N 4.8.0 \N \N 3274862955 +17.0.0-9562 keycloak META-INF/jpa-changelog-17.0.0.xml 2023-01-09 14:34:23.269577 107 EXECUTED 9:a6272f0576727dd8cad2522335f5d99e createIndex indexName=IDX_USER_SERVICE_ACCOUNT, tableName=USER_ENTITY \N 4.8.0 \N \N 3274862955 +18.0.0-10625-IDX_ADMIN_EVENT_TIME keycloak META-INF/jpa-changelog-18.0.0.xml 2023-01-09 14:34:23.276738 108 EXECUTED 9:015479dbd691d9cc8669282f4828c41d createIndex indexName=IDX_ADMIN_EVENT_TIME, tableName=ADMIN_EVENT_ENTITY \N 4.8.0 \N \N 3274862955 +19.0.0-10135 keycloak META-INF/jpa-changelog-19.0.0.xml 2023-01-09 14:34:23.297485 109 EXECUTED 9:9518e495fdd22f78ad6425cc30630221 customChange \N 4.8.0 \N \N 3274862955 +20.0.0-12964-supported-dbs keycloak META-INF/jpa-changelog-20.0.0.xml 2023-01-09 14:34:23.305463 110 EXECUTED 9:e5f243877199fd96bcc842f27a1656ac createIndex indexName=IDX_GROUP_ATT_BY_NAME_VALUE, tableName=GROUP_ATTRIBUTE \N 4.8.0 \N \N 3274862955 +20.0.0-12964-unsupported-dbs keycloak META-INF/jpa-changelog-20.0.0.xml 2023-01-09 14:34:23.309005 111 MARK_RAN 9:1a6fcaa85e20bdeae0a9ce49b41946a5 createIndex indexName=IDX_GROUP_ATT_BY_NAME_VALUE, tableName=GROUP_ATTRIBUTE \N 4.8.0 \N \N 3274862955 +client-attributes-string-accomodation-fixed keycloak META-INF/jpa-changelog-20.0.0.xml 2023-01-09 14:34:23.318788 112 EXECUTED 9:3f332e13e90739ed0c35b0b25b7822ca addColumn tableName=CLIENT_ATTRIBUTES; update tableName=CLIENT_ATTRIBUTES; dropColumn columnName=VALUE, tableName=CLIENT_ATTRIBUTES; renameColumn newColumnName=VALUE, oldColumnName=VALUE_NEW, tableName=CLIENT_ATTRIBUTES \N 4.8.0 \N \N 3274862955 +21.0.2-17277 keycloak META-INF/jpa-changelog-21.0.2.xml 2024-02-14 09:01:09.307684 113 EXECUTED 9:7ee1f7a3fb8f5588f171fb9a6ab623c0 customChange \N 4.23.2 \N \N 7901269232 +21.1.0-19404 keycloak META-INF/jpa-changelog-21.1.0.xml 2024-02-14 09:01:09.324282 114 EXECUTED 9:3d7e830b52f33676b9d64f7f2b2ea634 modifyDataType columnName=DECISION_STRATEGY, tableName=RESOURCE_SERVER_POLICY; modifyDataType columnName=LOGIC, tableName=RESOURCE_SERVER_POLICY; modifyDataType columnName=POLICY_ENFORCE_MODE, tableName=RESOURCE_SERVER \N 4.23.2 \N \N 7901269232 +21.1.0-19404-2 keycloak META-INF/jpa-changelog-21.1.0.xml 2024-02-14 09:01:09.327044 115 MARK_RAN 9:627d032e3ef2c06c0e1f73d2ae25c26c addColumn tableName=RESOURCE_SERVER_POLICY; update tableName=RESOURCE_SERVER_POLICY; dropColumn columnName=DECISION_STRATEGY, tableName=RESOURCE_SERVER_POLICY; renameColumn newColumnName=DECISION_STRATEGY, oldColumnName=DECISION_STRATEGY_NEW, tabl... \N 4.23.2 \N \N 7901269232 +22.0.0-17484-updated keycloak META-INF/jpa-changelog-22.0.0.xml 2024-02-14 09:01:09.332189 116 EXECUTED 9:90af0bfd30cafc17b9f4d6eccd92b8b3 customChange \N 4.23.2 \N \N 7901269232 +22.0.5-24031 keycloak META-INF/jpa-changelog-22.0.0.xml 2024-02-14 09:01:09.333603 117 MARK_RAN 9:a60d2d7b315ec2d3eba9e2f145f9df28 customChange \N 4.23.2 \N \N 7901269232 +23.0.0-12062 keycloak META-INF/jpa-changelog-23.0.0.xml 2024-02-14 09:01:09.340927 118 EXECUTED 9:2168fbe728fec46ae9baf15bf80927b8 addColumn tableName=COMPONENT_CONFIG; update tableName=COMPONENT_CONFIG; dropColumn columnName=VALUE, tableName=COMPONENT_CONFIG; renameColumn newColumnName=VALUE, oldColumnName=VALUE_NEW, tableName=COMPONENT_CONFIG \N 4.23.2 \N \N 7901269232 +23.0.0-17258 keycloak META-INF/jpa-changelog-23.0.0.xml 2024-02-14 09:01:09.343484 119 EXECUTED 9:36506d679a83bbfda85a27ea1864dca8 addColumn tableName=EVENT_ENTITY \N 4.23.2 \N \N 7901269232 \. @@ -2385,9 +2379,9 @@ grafana a5bb3a5f-fd26-4be6-9557-26e20a03d33d f grafana d6ffe9fc-a03c-4496-85dc-dbb5e7754587 f grafana d6077ed7-b265-4f82-9336-24614967bd5d t grafana 699671ab-e7c1-4fcf-beb8-ea54f1471fc1 t -grafana c61f5b19-c17e-49a1-91b8-a0296411b928 f grafana d30340a8-630b-416e-8c93-3ccf932d34f3 t master a96603aa-e6db-4b3e-8baa-679c75ccfc8f t +grafana c61f5b19-c17e-49a1-91b8-a0296411b928 t \. @@ -2395,7 +2389,7 @@ master a96603aa-e6db-4b3e-8baa-679c75ccfc8f t -- Data for Name: event_entity; Type: TABLE DATA; Schema: public; Owner: keycloak -- -COPY public.event_entity (id, client_id, details_json, error, ip_address, realm_id, session_id, event_time, type, user_id) FROM stdin; +COPY public.event_entity (id, client_id, details_json, error, ip_address, realm_id, session_id, event_time, type, user_id, details_json_long_value) FROM stdin; \. @@ -2524,6 +2518,9 @@ COPY public.idp_mapper_config (idp_mapper_id, value, name) FROM stdin; -- COPY public.keycloak_group (id, name, parent_group, realm_id) FROM stdin; +f1975bf4-6e92-479a-923c-d7d5b65d684c First grafana +fe974984-8b18-4925-beef-37b6cfb6af7b Second grafana +4cc0bd0f-9071-42a9-b282-be22515156ba Third grafana \. @@ -2628,6 +2625,7 @@ c9a895d1-c901-46b0-91cf-2c67902b4a93 a5a8fed6-0bca-4646-9946-2fe84175353b t ${ro COPY public.migration_model (id, version, update_time) FROM stdin; g5slr 12.0.1 1643820448 wupxt 20.0.2 1673274863 +y2nke 23.0.1 1707901269 \. @@ -2710,13 +2708,13 @@ b4854867-3bfb-409b-92a8-6ec37db17f99 phone number verified openid-connect oidc-u 1fc8999a-04d9-421b-8557-e417a3750358 realm roles openid-connect oidc-usermodel-realm-role-mapper \N d6077ed7-b265-4f82-9336-24614967bd5d 384e97dd-36ad-4b0e-af63-d0cb3a2153d4 allowed web origins openid-connect oidc-allowed-origins-mapper \N 699671ab-e7c1-4fcf-beb8-ea54f1471fc1 f03cac68-3f0e-4068-9adf-ee64567689a7 upn openid-connect oidc-usermodel-property-mapper \N c61f5b19-c17e-49a1-91b8-a0296411b928 -04183ee1-b558-4f63-839f-922d30b34a9e groups openid-connect oidc-usermodel-realm-role-mapper \N c61f5b19-c17e-49a1-91b8-a0296411b928 df78645e-c32b-4160-b79f-42e622d71982 locale openid-connect oidc-usermodel-attribute-mapper 805aebc8-9d01-42b6-bcce-6ce48ca63ef0 \N 0108b99f-2f31-4e73-9597-cb29e0e8c486 username openid-connect oidc-usermodel-property-mapper \N f619a55a-d565-4cc0-8bf4-4dbaab5382fe 70b0a264-a7c3-43ff-b24f-14ca4f5f118e login openid-connect oidc-usermodel-property-mapper \N 0a7c7dde-23d7-4a93-bdee-4a8963aee9a4 2f8ee9af-b6dd-4790-9e7b-cce83a603566 name openid-connect oidc-full-name-mapper \N d4723cd4-f717-44b7-a9b0-6c32c5ecd23f 854a81a5-8e06-4257-98ec-0e3087356223 acr loa level openid-connect oidc-acr-mapper \N d30340a8-630b-416e-8c93-3ccf932d34f3 53832401-d27c-489e-801f-9807cdaa4b08 acr loa level openid-connect oidc-acr-mapper \N a96603aa-e6db-4b3e-8baa-679c75ccfc8f +6814ca6a-2d7d-495d-87ad-53e12b561808 groups openid-connect oidc-group-membership-mapper \N c61f5b19-c17e-49a1-91b8-a0296411b928 \. @@ -2991,12 +2989,6 @@ f03cac68-3f0e-4068-9adf-ee64567689a7 true id.token.claim f03cac68-3f0e-4068-9adf-ee64567689a7 true access.token.claim f03cac68-3f0e-4068-9adf-ee64567689a7 upn claim.name f03cac68-3f0e-4068-9adf-ee64567689a7 String jsonType.label -04183ee1-b558-4f63-839f-922d30b34a9e true multivalued -04183ee1-b558-4f63-839f-922d30b34a9e foo user.attribute -04183ee1-b558-4f63-839f-922d30b34a9e true id.token.claim -04183ee1-b558-4f63-839f-922d30b34a9e true access.token.claim -04183ee1-b558-4f63-839f-922d30b34a9e groups claim.name -04183ee1-b558-4f63-839f-922d30b34a9e String jsonType.label df78645e-c32b-4160-b79f-42e622d71982 true userinfo.token.claim df78645e-c32b-4160-b79f-42e622d71982 locale user.attribute df78645e-c32b-4160-b79f-42e622d71982 true id.token.claim @@ -3025,6 +3017,13 @@ df78645e-c32b-4160-b79f-42e622d71982 String jsonType.label 854a81a5-8e06-4257-98ec-0e3087356223 true access.token.claim 53832401-d27c-489e-801f-9807cdaa4b08 true id.token.claim 53832401-d27c-489e-801f-9807cdaa4b08 true access.token.claim +6814ca6a-2d7d-495d-87ad-53e12b561808 true introspection.token.claim +6814ca6a-2d7d-495d-87ad-53e12b561808 true userinfo.token.claim +6814ca6a-2d7d-495d-87ad-53e12b561808 true id.token.claim +6814ca6a-2d7d-495d-87ad-53e12b561808 true access.token.claim +6814ca6a-2d7d-495d-87ad-53e12b561808 groups claim.name +6814ca6a-2d7d-495d-87ad-53e12b561808 false full.path +6814ca6a-2d7d-495d-87ad-53e12b561808 true multivalued \. @@ -3199,16 +3198,16 @@ ad4dfd2c-307a-4563-b93a-0bb726b4ccaa VERIFY_EMAIL Verify Email master t f VERIFY 2c7fffa4-ff20-4015-9a97-cc6a19e698ba UPDATE_PROFILE Update Profile master t f UPDATE_PROFILE 40 c76d17f4-eacf-497a-ab5a-f78936bbc50e CONFIGURE_TOTP Configure OTP master t f CONFIGURE_TOTP 10 83de9f97-43df-4265-982c-5414a2b19985 UPDATE_PASSWORD Update Password master t f UPDATE_PASSWORD 30 -9f538737-770e-4731-abd9-e98172a85d2f terms_and_conditions Terms and Conditions master f f terms_and_conditions 20 306fc47e-d8ae-4bb1-b2bc-53608a44536c update_user_locale Update User Locale master t f update_user_locale 1000 f158f7d8-8b7f-414c-b1bd-0dde83c91133 delete_account Delete Account master f f delete_account 60 969a57d1-c906-4f49-87d6-3cbba2f3898a VERIFY_EMAIL Verify Email grafana t f VERIFY_EMAIL 50 233d5b8e-6f36-450f-bffd-43b82e27295c UPDATE_PROFILE Update Profile grafana t f UPDATE_PROFILE 40 ab3a9aa7-3d1b-4fb1-93ad-9412142deed3 CONFIGURE_TOTP Configure OTP grafana t f CONFIGURE_TOTP 10 988d8e0d-35ef-4e6a-8b48-821cca56acf2 UPDATE_PASSWORD Update Password grafana t f UPDATE_PASSWORD 30 -0e2b6144-5c2c-4dcb-92d8-00529b19a7a5 terms_and_conditions Terms and Conditions grafana f f terms_and_conditions 20 94993a02-f883-4f8a-a549-d48f95aabed2 update_user_locale Update User Locale grafana t f update_user_locale 1000 72d09b7f-acde-4b90-af9a-ea3c642a2f6d delete_account Delete Account grafana f f delete_account 60 +9f538737-770e-4731-abd9-e98172a85d2f TERMS_AND_CONDITIONS Terms and Conditions master f f TERMS_AND_CONDITIONS 20 +0e2b6144-5c2c-4dcb-92d8-00529b19a7a5 TERMS_AND_CONDITIONS Terms and Conditions grafana f f TERMS_AND_CONDITIONS 20 \. @@ -3386,6 +3385,11 @@ COPY public.user_federation_provider (id, changed_sync_period, display_name, ful -- COPY public.user_group_membership (group_id, user_id) FROM stdin; +f1975bf4-6e92-479a-923c-d7d5b65d684c 88692d07-bb9a-46cf-844c-7ff5c529cd04 +f1975bf4-6e92-479a-923c-d7d5b65d684c 8f58cbec-6e40-4bab-bff0-1c5ff899fe2e +fe974984-8b18-4925-beef-37b6cfb6af7b 88692d07-bb9a-46cf-844c-7ff5c529cd04 +4cc0bd0f-9071-42a9-b282-be22515156ba 6db3c5e5-b84b-4f9d-a7a8-8d05b03c929d +4cc0bd0f-9071-42a9-b282-be22515156ba 1a85b7e0-4baa-420b-89f8-1cea43a540dd \. diff --git a/devenv/docker/blocks/auth/jwt_proxy/readme.md b/devenv/docker/blocks/auth/jwt_proxy/readme.md index f3e32d147c49c..823287f54e35d 100644 --- a/devenv/docker/blocks/auth/jwt_proxy/readme.md +++ b/devenv/docker/blocks/auth/jwt_proxy/readme.md @@ -24,6 +24,7 @@ expect_claims = {"iss": "http://env.grafana.local:8087/realms/grafana", "azp": " auto_sign_up = true role_attribute_path = contains(roles[*], 'grafanaadmin') && 'GrafanaAdmin' || contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer' role_attribute_strict = false +groups_attribute_path = groups[] allow_assign_grafana_admin = true ``` diff --git a/devenv/docker/blocks/mimir_backend/docker-compose.yaml b/devenv/docker/blocks/mimir_backend/docker-compose.yaml index 8666ab093cce5..058a0d8608599 100644 --- a/devenv/docker/blocks/mimir_backend/docker-compose.yaml +++ b/devenv/docker/blocks/mimir_backend/docker-compose.yaml @@ -1,8 +1,9 @@ mimir_backend: - image: grafana/mimir + image: grafana/mimir:r274-1780c50 container_name: mimir_backend command: - -target=backend + - -alertmanager.grafana-alertmanager-compatibility-enabled nginx: environment: - NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx diff --git a/devenv/docker/blocks/tempo/tempo.yaml b/devenv/docker/blocks/tempo/tempo.yaml index e8378e9b9a68c..f94d4ca96e715 100644 --- a/devenv/docker/blocks/tempo/tempo.yaml +++ b/devenv/docker/blocks/tempo/tempo.yaml @@ -43,7 +43,6 @@ storage: bloom_filter_false_positive: .05 # bloom filter false positive rate. lower values create larger filters but fewer false positives v2_index_downsample_bytes: 1000 # number of bytes per index record v2_encoding: zstd # block encoding/compression. options: none, gzip, lz4-64k, lz4-256k, lz4-1M, lz4, snappy, zstd, s2 - version: vParquet wal: path: /tmp/tempo/wal # where to store the the wal locally v2_encoding: snappy # wal encoding/compression. options: none, gzip, lz4-64k, lz4-256k, lz4-1M, lz4, snappy, zstd, s2 @@ -51,6 +50,8 @@ storage: path: /tmp/tempo/blocks overrides: - metrics_generator_processors: [local-blocks, service-graphs, span-metrics] + defaults: + metrics_generator: + processors: [local-blocks, service-graphs, span-metrics] stream_over_http_enabled: true diff --git a/devenv/docker/ha-test-unified-alerting/grafana/provisioning/alerts.jsonnet b/devenv/docker/ha-test-unified-alerting/grafana/provisioning/alerts.jsonnet deleted file mode 100644 index f3db6a51a569f..0000000000000 --- a/devenv/docker/ha-test-unified-alerting/grafana/provisioning/alerts.jsonnet +++ /dev/null @@ -1,202 +0,0 @@ -local numAlerts = std.extVar('alerts'); -local condition = std.extVar('condition'); -local arr = std.range(1, numAlerts); - -local alertDashboardTemplate = { - "editable": true, - "gnetId": null, - "graphTooltip": 0, - "id": null, - "links": [], - "panels": [ - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 65 - ], - "type": "gt" - }, - "operator": { - "type": "and" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "executionErrorState": "alerting", - "frequency": "10s", - "handler": 1, - "for": "1m", - "name": "bulk alerting", - "noDataState": "no_data", - "notifications": [ - { - "id": 2 - } - ] - }, - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 9, - "w": 12, - "x": 0, - "y": 0 - }, - "id": 2, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "nullPointMode": "null", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "$$hashKey": "object:117", - "expr": "go_goroutines", - "format": "time_series", - "intervalFactor": 1, - "refId": "A" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 50 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Panel Title", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ] - } - ], - "schemaVersion": 16, - "tags": [], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": { - "refresh_intervals": [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ] - }, - "timezone": "", - "title": "New dashboard", - "uid": null, - "version": 0 -}; - - -{ - ['alert-' + std.toString(x) + '.json']: - alertDashboardTemplate + { - panels: [ - alertDashboardTemplate.panels[0] + - { - alert+: { - name: 'Alert rule ' + x, - conditions: [ - alertDashboardTemplate.panels[0].alert.conditions[0] + - { - evaluator+: { - params: [condition] - } - }, - ], - }, - }, - ], - uid: 'alert-' + x, - title: 'Alert ' + x - }, - for x in arr -} \ No newline at end of file diff --git a/devenv/docker/ha_test/alerts.sh b/devenv/docker/ha_test/alerts.sh index f2e7b4aec6cad..0b35f0d811870 100755 --- a/devenv/docker/ha_test/alerts.sh +++ b/devenv/docker/ha_test/alerts.sh @@ -84,27 +84,6 @@ slack() { http://admin:admin@grafana.loc/api/alert-notifications/2 } -provision() { - alerts=1 - condition=65 - while getopts ":a:c:" o; do - case "${o}" in - a) - alerts=${OPTARG} - ;; - c) - condition=${OPTARG} - ;; - esac - done - shift $((OPTIND-1)) - - requiresJsonnet - - find grafana/provisioning/dashboards/alerts -maxdepth 1 -name 'alert*.json' -delete - jsonnet -m grafana/provisioning/dashboards/alerts grafana/provisioning/alerts.jsonnet --ext-code alerts=$alerts --ext-code condition=$condition -} - pause() { curl -H "Content-Type: application/json" \ -d '{"paused":true}' \ @@ -126,9 +105,6 @@ usage() { echo -e " [-u]\t\t\t url" echo -e " [-r]\t\t\t send reminders" echo -e " [-e <remind every>]\t\t default 10m\n" - echo -e " provision\t provision alerts" - echo -e " [-a <alert rule count>]\t default 1" - echo -e " [-c <condition value>]\t default 65\n" echo -e " pause\t\t pause all alerts" echo -e " unpause\t unpause all alerts" } @@ -140,8 +116,6 @@ main() { setup elif [[ $cmd == "slack" ]]; then slack "${@:2}" - elif [[ $cmd == "provision" ]]; then - provision "${@:2}" elif [[ $cmd == "pause" ]]; then pause elif [[ $cmd == "unpause" ]]; then diff --git a/devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet b/devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet deleted file mode 100644 index f3db6a51a569f..0000000000000 --- a/devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet +++ /dev/null @@ -1,202 +0,0 @@ -local numAlerts = std.extVar('alerts'); -local condition = std.extVar('condition'); -local arr = std.range(1, numAlerts); - -local alertDashboardTemplate = { - "editable": true, - "gnetId": null, - "graphTooltip": 0, - "id": null, - "links": [], - "panels": [ - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 65 - ], - "type": "gt" - }, - "operator": { - "type": "and" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "executionErrorState": "alerting", - "frequency": "10s", - "handler": 1, - "for": "1m", - "name": "bulk alerting", - "noDataState": "no_data", - "notifications": [ - { - "id": 2 - } - ] - }, - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 9, - "w": 12, - "x": 0, - "y": 0 - }, - "id": 2, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "nullPointMode": "null", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "$$hashKey": "object:117", - "expr": "go_goroutines", - "format": "time_series", - "intervalFactor": 1, - "refId": "A" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 50 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Panel Title", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ] - } - ], - "schemaVersion": 16, - "tags": [], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": { - "refresh_intervals": [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ] - }, - "timezone": "", - "title": "New dashboard", - "uid": null, - "version": 0 -}; - - -{ - ['alert-' + std.toString(x) + '.json']: - alertDashboardTemplate + { - panels: [ - alertDashboardTemplate.panels[0] + - { - alert+: { - name: 'Alert rule ' + x, - conditions: [ - alertDashboardTemplate.panels[0].alert.conditions[0] + - { - evaluator+: { - params: [condition] - } - }, - ], - }, - }, - ], - uid: 'alert-' + x, - title: 'Alert ' + x - }, - for x in arr -} \ No newline at end of file diff --git a/devenv/jsonnet/dev-dashboards.libsonnet b/devenv/jsonnet/dev-dashboards.libsonnet index 07726f41723f0..391e684fca41e 100644 --- a/devenv/jsonnet/dev-dashboards.libsonnet +++ b/devenv/jsonnet/dev-dashboards.libsonnet @@ -21,6 +21,7 @@ "barchart-tooltips-legends": (import '../dev-dashboards/panel-barchart/barchart-tooltips-legends.json'), "candlestick": (import '../dev-dashboards/panel-candlestick/candlestick.json'), "canvas-connection-examples": (import '../dev-dashboards/panel-canvas/canvas-connection-examples.json'), + "canvas-datalinks": (import '../dev-dashboards/panel-canvas/canvas-datalinks.json'), "canvas-examples": (import '../dev-dashboards/panel-canvas/canvas-examples.json'), "color_modes": (import '../dev-dashboards/panel-common/color_modes.json'), "config-from-query": (import '../dev-dashboards/transforms/config-from-query.json'), @@ -58,6 +59,7 @@ "join-by-labels": (import '../dev-dashboards/transforms/join-by-labels.json'), "lazy_loading": (import '../dev-dashboards/panel-common/lazy_loading.json'), "linked-viz": (import '../dev-dashboards/panel-common/linked-viz.json'), + "live-publish": (import '../dev-dashboards/live/live-publish.json'), "loki_fakedata": (import '../dev-dashboards/datasource-loki/loki_fakedata.json'), "loki_query_splitting": (import '../dev-dashboards/datasource-loki/loki_query_splitting.json'), "migrations": (import '../dev-dashboards/migrations/migrations.json'), @@ -90,6 +92,7 @@ "table_sparkline_cell": (import '../dev-dashboards/panel-table/table_sparkline_cell.json'), "table_tests": (import '../dev-dashboards/panel-table/table_tests.json'), "table_tests_new": (import '../dev-dashboards/panel-table/table_tests_new.json'), + "tall_dashboard": (import '../dev-dashboards/scenarios/tall_dashboard.json'), "templating-dashboard-links-and-variables": (import '../dev-dashboards/feature-templating/templating-dashboard-links-and-variables.json'), "templating-repeating-panels": (import '../dev-dashboards/feature-templating/templating-repeating-panels.json'), "templating-repeating-rows": (import '../dev-dashboards/feature-templating/templating-repeating-rows.json'), @@ -100,9 +103,10 @@ "testdata-test-variable-output": (import '../dev-dashboards/feature-templating/testdata-test-variable-output.json'), "testdata-variables-textbox": (import '../dev-dashboards/feature-templating/testdata-variables-textbox.json'), "testdata-variables-that-update-on-time-c": (import '../dev-dashboards/feature-templating/testdata-variables-that-update-on-time-change.json'), - "testdata_alerts": (import '../dev-dashboards/alerting/testdata_alerts.json'), "text-options": (import '../dev-dashboards/panel-text/text-options.json'), "time_zone_support": (import '../dev-dashboards/scenarios/time_zone_support.json'), + "timeline-align-endtime": (import '../dev-dashboards/panel-timeline/timeline-align-endtime.json'), + "timeline-align-nulls-retain": (import '../dev-dashboards/panel-timeline/timeline-align-nulls-retain.json'), "timeline-demo": (import '../dev-dashboards/panel-timeline/timeline-demo.json'), "timeline-modes": (import '../dev-dashboards/panel-timeline/timeline-modes.json'), "timeline-thresholds-mappings": (import '../dev-dashboards/panel-timeline/timeline-thresholds-mappings.json'), @@ -124,5 +128,6 @@ "timeseries-yaxis-ticks": (import '../dev-dashboards/panel-timeseries/timeseries-yaxis-ticks.json'), "trend_example": (import '../dev-dashboards/panel-trend/trend_example.json'), "xychart-example": (import '../dev-dashboards/panel-xychart/xychart-example.json'), + "xychart-tooltip-color-test": (import '../dev-dashboards/panel-xychart/xychart-tooltip-color-test.json'), }, } diff --git a/devenv/setup.sh b/devenv/setup.sh index 906f160182788..ec1b8ea65a672 100755 --- a/devenv/setup.sh +++ b/devenv/setup.sh @@ -14,27 +14,6 @@ bulkDashboard() { ln -s -f ../../../devenv/bulk-dashboards/bulk-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml } -bulkAlertingDashboard() { - - requiresJsonnet - - jsonnet -o "bulk_alerting_dashboards/bulk_alerting_datasources.yaml" ./bulk_alerting_dashboards/datasources.jsonnet - - COUNTER=1 - DS=1 - MAX=1000 - while [ $COUNTER -lt $MAX ]; do - jsonnet -o "bulk_alerting_dashboards/alerting_dashboard${COUNTER}.json" \ - -e "local bulkDash = import 'bulk_alerting_dashboards/dashboard.libsonnet'; bulkDash.alertingDashboard(${COUNTER}, ${DS})" - let COUNTER=COUNTER+1 - let DS=COUNTER/10 - let DS=DS+1 - done - - ln -s -f ../../../devenv/bulk_alerting_dashboards/bulk_alerting_dashboards.yaml ../conf/provisioning/dashboards/custom.yaml - ln -s -f ../../../devenv/bulk_alerting_dashboards/bulk_alerting_datasources.yaml ../conf/provisioning/datasources/custom.yaml -} - bulkFolders() { ./bulk-folders/bulk-folders.sh "$1" ln -s -f ../../../devenv/bulk-folders/bulk-folders.yaml ../conf/provisioning/dashboards/bulk-folders.yaml @@ -70,12 +49,6 @@ undev() { rm -rf bulk-folders/Bulk\ Folder* echo -e " \xE2\x9C\x94 Reverting bulk-folders provisioning" - - # Removing generated dashboard and datasource files from bulk-alerting-dashboards - rm -f bulk_alerting_dashboards/alerting_dashboard*.json - rm -f "bulk_alerting_dashboards/bulk_alerting_datasources.yaml" - echo -e " \xE2\x9C\x94 Reverting bulk-alerting-dashboards provisioning" - # Removing the symlinks rm -f ../conf/provisioning/dashboards/custom.yaml rm -f ../conf/provisioning/dashboards/bulk-folders.yaml @@ -88,7 +61,6 @@ usage() { echo -e "\n" echo "Usage:" echo " bulk-dashboards - provision 400 dashboards" - echo " bulk-alerting-dashboards - provision 400 dashboards with alerts" echo " bulk-folders [folders] [dashboards] - provision many folders with dashboards" echo " bulk-folders - provision 200 folders with 3 dashboards in each" echo " no args - provision core datasources and dev dashboards" @@ -104,9 +76,7 @@ main() { local cmd=$1 local arg1=$2 - if [[ $cmd == "bulk-alerting-dashboards" ]]; then - bulkAlertingDashboard - elif [[ $cmd == "bulk-dashboards" ]]; then + if [[ $cmd == "bulk-dashboards" ]]; then bulkDashboard elif [[ $cmd == "bulk-folders" ]]; then bulkFolders "$arg1" diff --git a/.codespellignore b/docs/.codespellignore similarity index 100% rename from .codespellignore rename to docs/.codespellignore diff --git a/docs/Makefile b/docs/Makefile index 38997200b0de9..36b7e4f41be64 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -9,7 +9,6 @@ include docs.mk .PHONY: sources/panels-visualizations/query-transform-data/transform-data/index.md sources/panels-visualizations/query-transform-data/transform-data/index.md: ## Generate the Transform Data page source. -sources/panels-visualizations/query-transform-data/transform-data/index.md: cd $(CURDIR)/.. && npx tsc ./scripts/docs/generate-transformations.ts && \ - node ./scripts/docs/generate-transformations.js > $(CURDIR)/$@ && \ + node -e "require('./scripts/docs/generate-transformations').buildMarkdownContent()" && \ npx prettier -w $(CURDIR)/$@ diff --git a/docs/make-docs b/docs/make-docs index 25176a37f0519..43efdb5faad3a 100755 --- a/docs/make-docs +++ b/docs/make-docs @@ -6,7 +6,44 @@ # [Semantic versioning](https://semver.org/) is used to help the reader identify the significance of changes. # Changes are relevant to this script and the support docs.mk GNU Make interface. # - +# ## 6.0.1 (2024-02-28) +# +# ### Added +# +# - Suppress new errors relating to absent content introduced in https://github.com/grafana/website/pull/17561. +# +# ## 6.0.0 (2024-02-16) +# +# ### Changed +# +# - Require `jq` for human readable `make doc-validator` output. +# +# ## 5.4.0 (2024-02-12) +# +# ### Changed +# +# - Set `WEBSITE_MOUNTS=true` when a user includes the `website` project. +# +# Ensures consistent behavior across repositories. +# To disable website mounts, add `export WEBSITE_MOUNTS := false` to your `variables.mk` or `variables.mk.local` file. +# - Use website mounts and container volumes also when a user includes the `grafana-cloud` project. +# +# ## 5.3.0 (2024-02-08) +# +# ### Changed +# +# - Updated support for plugins monorepo now that multiple projects have been moved into it. +# - Use `printf` instead of `echo` for better portability of output. +# +# https://www.in-ulm.de/~mascheck/various/echo+printf/ +# +# ## 5.2.0 (2024-01-18) +# +# ### Changed +# +# - Updated `make vale` to use latest Vale style and configuration. +# - Updated `make vale` to use platform appropriate image. +# # ## 5.1.2 (2023-11-08) # # ### Added @@ -191,8 +228,6 @@ readonly DOC_VALIDATOR_SKIP_CHECKS="${DOC_VALIDATOR_SKIP_CHECKS:-^image-}" readonly HUGO_REFLINKSERRORLEVEL="${HUGO_REFLINKSERRORLEVEL:-WARNING}" readonly VALE_MINALERTLEVEL="${VALE_MINALERTLEVEL:-error}" readonly WEBSITE_EXEC="${WEBSITE_EXEC:-make server-docs}" -# If set, the docs-base image will run a prebuild script that sets up Hugo mounts. -readonly WEBSITE_MOUNTS="${WEBSITE_MOUNTS:-}" PODMAN="$(if command -v podman >/dev/null 2>&1; then echo podman; else echo docker; fi)" @@ -264,10 +299,7 @@ SOURCES_helm_charts_mimir_distributed='mimir' SOURCES_helm_charts_tempo_distributed='tempo' SOURCES_opentelemetry='opentelemetry-docs' SOURCES_plugins_grafana_datadog_datasource='datadog-datasource' -SOURCES_plugins_grafana_jira_datasource='jira-datasource' -SOURCES_plugins_grafana_mongodb_datasource='mongodb-datasource' SOURCES_plugins_grafana_oracle_datasource='oracle-datasource' -SOURCES_plugins_grafana_splunk_datasource='splunk-datasource' VERSIONS_as_code='UNVERSIONED' VERSIONS_grafana_cloud='UNVERSIONED' @@ -278,10 +310,7 @@ VERSIONS_grafana_cloud_data_configuration_integrations='UNVERSIONED' VERSIONS_grafana_cloud_frontend_observability_faro_web_sdk='UNVERSIONED' VERSIONS_opentelemetry='UNVERSIONED' VERSIONS_plugins_grafana_datadog_datasource='latest' -VERSIONS_plugins_grafana_jira_datasource='latest' -VERSIONS_plugins_grafana_mongodb_datasource='latest' VERSIONS_plugins_grafana_oracle_datasource='latest' -VERSIONS_plugins_grafana_splunk_datasource='latest' VERSIONS_technical_documentation='UNVERSIONED' VERSIONS_website='UNVERSIONED' VERSIONS_writers_toolkit='UNVERSIONED' @@ -291,10 +320,7 @@ PATHS_helm_charts_mimir_distributed='docs/sources/helm-charts/mimir-distributed' PATHS_helm_charts_tempo_distributed='docs/sources/helm-charts/tempo-distributed' PATHS_mimir='docs/sources/mimir' PATHS_plugins_grafana_datadog_datasource='docs/sources' -PATHS_plugins_grafana_jira_datasource='docs/sources' -PATHS_plugins_grafana_mongodb_datasource='docs/sources' PATHS_plugins_grafana_oracle_datasource='docs/sources' -PATHS_plugins_grafana_splunk_datasource='docs/sources' PATHS_tempo='docs/sources/tempo' PATHS_website='content' @@ -418,7 +444,7 @@ proj_url() { $1 POSIX_HERESTRING - if [ "${_project}" = 'website' ]; then + if [ "${_project}" = website ]; then echo "http://localhost:${DOCS_HOST_PORT}/docs/" unset _project _version @@ -452,7 +478,7 @@ proj_dst() { $1 POSIX_HERESTRING - if [ "${_project}" = 'website' ]; then + if [ "${_project}" = website ]; then echo '/hugo/content' unset _project _version @@ -511,7 +537,7 @@ proj_canonical() { $1 POSIX_HERESTRING - if [ "${_project}" = 'website' ]; then + if [ "${_project}" = website ]; then echo '/docs' unset _project _version @@ -580,12 +606,11 @@ await_build() { while [ "${i}" -ne "${max}" ] do sleep 1 - debg "Retrying request to webserver assuming the process is still starting up." + debg "Retrying request to web server assuming the process is still starting up." i=$((i + 1)) if ${req} "${url}"; then - echo - echo "View documentation locally:" + printf '\r\nView documentation locally:\r\n' for x in ${url_src_dst_vers}; do IFS='^' read -r url _ _ <<POSIX_HERESTRING $x @@ -593,19 +618,18 @@ POSIX_HERESTRING if [ -n "${url}" ]; then if [ "${_url}" != "arbitrary" ]; then - echo " ${url}" + printf '\r %s\r\n' "${url}" fi fi done - echo - echo 'Press Ctrl+C to stop the server' + printf '\r\nPress Ctrl+C to stop the server\r\n' unset i max req url return fi done - echo + printf '\r\n' errr 'The build was interrupted or a build error occurred, check the previous logs for possible causes.' note 'You might need to use Ctrl+C to end the process.' @@ -614,16 +638,16 @@ POSIX_HERESTRING debg() { if [ -n "${DEBUG}" ]; then - echo "DEBG: $1" >&2 + printf 'DEBG: %s\r\n' "$1" >&2 fi } errr() { - echo "ERRR: $1" >&2 + printf 'ERRR: %s\r\n' "$1" >&2 } note() { - echo "NOTE: $1" >&2 + printf 'NOTE: %s\r\n' "$1" >&2 } url_src_dst_vers="$(url_src_dst_vers "$@")" @@ -635,9 +659,16 @@ for arg in "$@"; do IFS=: read -r _project _ _repo _ <<POSIX_HERESTRING ${arg} POSIX_HERESTRING - if [ "${_project}" = website ]; then + if [ "${_project}" = website ] || [ "${_project}" = grafana-cloud ]; then note "Please be patient, building the website can take some time." + # If set, the docs-base image will run a prebuild script that sets up Hugo mounts. + if [ "${WEBSITE_MOUNTS}" = false ]; then + unset WEBSITE_MOUNTS + else + readonly WEBSITE_MOUNTS=true + fi + _repo="$(repo_path website)" volumes="--volume=${_repo}/config:/hugo/config" volumes="${volumes} --volume=${_repo}/layouts:/hugo/layouts" @@ -683,8 +714,15 @@ POSIX_HERESTRING case "${image}" in 'grafana/doc-validator') + if ! command -v jq >/dev/null 2>&1; then + errr '`jq` must be installed for the `doc-validator` target to work.' + note 'To install `jq`, refer to https://jqlang.github.io/jq/download/,' + + exit 1 + fi + proj="$(new_proj "$1")" - echo + printf '\r\n' "${PODMAN}" run \ --init \ --interactive \ @@ -695,23 +733,25 @@ case "${image}" in "${DOCS_IMAGE}" \ "--include=${DOC_VALIDATOR_INCLUDE}" \ "--skip-checks=${DOC_VALIDATOR_SKIP_CHECKS}" \ - /hugo/content/docs \ - "$(proj_canonical "${proj}")" | sed "s#$(proj_dst "${proj}")#sources#" + "/hugo/content$(proj_canonical "${proj}")" \ + "$(proj_canonical "${proj}")" \ + | sed "s#$(proj_dst "${proj}")#sources#" \ + | jq -r '"ERROR: \(.location.path):\(.location.range.start.line // 1):\(.location.range.start.column // 1): \(.message)" + if .suggestions[0].text then "\nSuggestion: \(.suggestions[0].text)" else "" end' ;; 'grafana/vale') proj="$(new_proj "$1")" - echo + printf '\r\n' "${PODMAN}" run \ --init \ --interactive \ - --platform linux/amd64 \ --rm \ + --workdir /etc/vale \ --tty \ ${volumes} \ "${DOCS_IMAGE}" \ "--minAlertLevel=${VALE_MINALERTLEVEL}" \ - --config=/etc/vale/.vale.ini \ - --output=line \ + '--glob=*.md' \ + --output=/etc/vale/rdjsonl.tmpl \ /hugo/content/docs | sed "s#$(proj_dst "${proj}")#sources#" ;; *) @@ -788,7 +828,8 @@ EOF -e '/rm -rf dist*/ d' \ -e '/Press Ctrl+C to stop/ d' \ -e '/make/ d' \ - -e '/WARNING: The manual_mount source directory/ d' + -e '/WARNING: The manual_mount source directory/ d' \ + -e '/docs\/_index.md .* not found/ d' fi ;; esac diff --git a/docs/sources/_index.md b/docs/sources/_index.md index 46dcc4fa1380d..2ef3f960b46b0 100644 --- a/docs/sources/_index.md +++ b/docs/sources/_index.md @@ -15,6 +15,7 @@ labels: - oss cascade: TEMPO_VERSION: latest + PYROSCOPE_VERSION: latest title: Grafana open source documentation --- @@ -42,6 +43,10 @@ title: Grafana open source documentation <img src="/static/img/logos/logo-docker.svg"> <h5>Run Docker image</h5> </a> + <a href="{{< relref "setup-grafana/installation/kubernetes/" >}}" class="nav-cards__item nav-cards__item--install"> + <img src="/static/img/logos/logo-kubernetes.svg"> + <h5>Run on Kubernetes</h5> + </a> <a href="https://grafana.com/docs/grafana-cloud/" class="nav-cards__item nav-cards__item--install"> <div class="nav-cards__icon fa fa-cloud"> </div> @@ -77,8 +82,8 @@ title: Grafana open source documentation <h4>Provisioning</h4> <p>Learn how to automate your Grafana configuration.</p> </a> - <a href="{{< relref "whatsnew/whats-new-in-v10-2/" >}}" class="nav-cards__item nav-cards__item--guide"> - <h4>What's new in v10.2</h4> + <a href="{{< relref "whatsnew/whats-new-in-v10-4/" >}}" class="nav-cards__item nav-cards__item--guide"> + <h4>What's new in v10.4</h4> <p>Explore the features and enhancements in the latest release.</p> </a> diff --git a/docs/sources/administration/api-keys/index.md b/docs/sources/administration/api-keys/index.md index 6ea118897244c..bc4a5b6cd0db7 100644 --- a/docs/sources/administration/api-keys/index.md +++ b/docs/sources/administration/api-keys/index.md @@ -17,6 +17,10 @@ weight: 700 # API keys +{{% admonition type="note" %}} +Deprecated: [Service accounts]({{< relref "../service-accounts/" >}}) have replaced API keys as the primary way to authenticate applications that interact with Grafana. +{{% /admonition %}} + An API key is a randomly generated string that external systems use to interact with Grafana HTTP APIs. When you create an API key, you specify a **Role** that determines the permissions associated with the API key. Role permissions control that actions the API key can perform on Grafana resources. diff --git a/docs/sources/administration/correlations/_index.md b/docs/sources/administration/correlations/_index.md index 6a8b332c5a8a3..977849d294860 100644 --- a/docs/sources/administration/correlations/_index.md +++ b/docs/sources/administration/correlations/_index.md @@ -30,11 +30,6 @@ Explore visualizations that currently support showing links based on correlation You can configure correlations using [provisioning]({{< relref "../provisioning" >}}), the **Administration > Plugins and data > Correlations** page in Grafana or directly in [Explore]({{< relref "../../explore/correlations-editor-in-explore" >}}). -{{% admonition type="note" %}} -Correlations are available in Grafana 10.0+ as an opt-in beta feature. -Modify the Grafana [configuration file]({{< relref "../../setup-grafana/configure-grafana#configuration-file-location" >}}) to enable the `correlations` [feature toggle]({{< relref "../../setup-grafana/configure-grafana#feature_toggles" >}}) to use it. -{{% /admonition %}} - ## Example of how links work in Explore once set up {{< figure src="/static/img/docs/correlations/correlations-in-explore-10-0.gif" alt="Demonstration of following a correlation link in Grafana Explore" caption="Correlations links in Explore" >}} diff --git a/docs/sources/administration/data-source-management/index.md b/docs/sources/administration/data-source-management/_index.md similarity index 81% rename from docs/sources/administration/data-source-management/index.md rename to docs/sources/administration/data-source-management/_index.md index f4a9be6f23b45..b57424f245ede 100644 --- a/docs/sources/administration/data-source-management/index.md +++ b/docs/sources/administration/data-source-management/_index.md @@ -10,7 +10,7 @@ description: Data source management information for Grafana administrators labels: products: - enterprise - - oss + - cloud title: Data source management weight: 100 --- @@ -21,27 +21,15 @@ Grafana supports many different storage backends for your time series data (data Refer to [data sources]({{< relref "../../datasources" >}}) for more information about using data sources in Grafana. Only users with the organization admin role can add data sources. -## Add a data source - -Before you can create your first dashboard, you need to add your data source. - -{{% admonition type="note" %}} -Only users with the organization admin role can add data sources. -{{% /admonition %}} - -**To add a data source:** - -1. Click **Connections** in the left-side menu. -1. Enter the name of a specific data source in the search dialog. You can filter by **Data source** to only see data sources. -1. Click the data source you want to add. -1. Configure the data source following instructions specific to that data source. - For links to data source-specific documentation, see [Data sources]({{< relref "../../datasources" >}}). ## Data source permissions You can configure data source permissions to allow or deny certain users the ability to query, edit, or administrate a data source. Each data source’s configuration includes a Permissions tab where you can restrict data source permissions to specific users, service accounts, teams, or roles. -Query permission allows users to query the data source. Edit permission allows users to query the data source, edit the data source’s configuration and delete the data source. Admin permission allows users to query and edit the data source, change permissions on the data source and enable or disable query caching for the data source. + +- The `query` permission allows users to query the data source. +- The `edit` permission allows users to query the data source, edit the data source’s configuration and delete the data source. +- The `admin` permission allows users to query and edit the data source, change permissions on the data source and enable or disable query caching for the data source. {{% admonition type="note" %}} Available in [Grafana Enterprise]({{< relref "../../introduction/grafana-enterprise/" >}}) and [Grafana Cloud](/docs/grafana-cloud). @@ -71,7 +59,7 @@ You can assign data source permissions to users, service accounts, teams, and ro 1. Click **Connections** in the left-side menu. 1. Under Your connections, click **Data sources**. 1. Select the data source for which you want to edit permissions. -1. On the Permissions tab, find the user, service account, team, or role permission you want to update. +1. On the Permissions tab, find the **User**, **Service Account**, **Team**, or **Role** permission you want to update. 1. Select a different option in the **Permission** dropdown. <div class="clearfix"></div> @@ -81,7 +69,7 @@ You can assign data source permissions to users, service accounts, teams, and ro 1. Click **Connections** in the left-side menu. 1. Under Your connections, click **Data sources**. 1. Select the data source from which you want to remove permissions. -1. On the Permissions tab, find the user, service account, team, or role permission you want to remove. +1. On the Permissions tab, find the **User**, **Service Account**, **Team**, or **Role** permission you want to remove. 1. Click the **X** next to the permission. <div class="clearfix"></div> @@ -178,22 +166,3 @@ This action impacts all cache-enabled data sources. If you are using Memcached, ### Sending a request without cache If a data source query request contains an `X-Cache-Skip` header, then Grafana skips the caching middleware, and does not search the cache for a response. This can be particularly useful when debugging data source queries using cURL. - -## Add data source plugins - -Grafana ships with several [built-in data sources]({{< relref "../../datasources#built-in-core-data-sources" >}}). -You can add additional data sources as plugins, which you can install or create yourself. - -### Find data source plugins in the plugin catalog - -To view available data source plugins, go to the [plugin catalog](/grafana/plugins/?type=datasource) and select the "Data sources" filter. -For details about the plugin catalog, refer to [Plugin management]({{< relref "../../administration/plugin-management/" >}}). - -You can further filter the plugin catalog's results for data sources provided by the Grafana community, Grafana Labs, and partners. -If you use [Grafana Enterprise]({{< relref "../../introduction/grafana-enterprise/" >}}), you can also filter by Enterprise-supported plugins. - -For more documentation on a specific data source plugin's features, including its query language and editor, refer to its plugin catalog page. - -### Create a data source plugin - -To build your own data source plugin, refer to the ["Build a data source plugin"](/developers/plugin-tools/tutorials/build-a-data-source-plugin) tutorial and our documentation about [building a plugin](/developers/plugin-tools). diff --git a/docs/sources/administration/data-source-management/teamlbac/_index.md b/docs/sources/administration/data-source-management/teamlbac/_index.md new file mode 100644 index 0000000000000..1d87bdbb06d3d --- /dev/null +++ b/docs/sources/administration/data-source-management/teamlbac/_index.md @@ -0,0 +1,68 @@ +--- +description: Label based data access for Loki given Teams +keywords: + - grafana + - loki + - lbac +labels: + products: + - enterprise + - cloud +title: Team LBAC +weight: 100 +--- + +# Team LBAC + +Team Label Based Access Control (LBAC) simplifies and streamlines data source access management based on team memberships. + +{{< admonition type="note" >}} +Creating Team LBAC rules is available for preview for logs with Loki in Grafana Cloud. +Report any unexpected behavior to the Grafana Support team. +{{< /admonition >}} + +You can configure user access based upon team memberships using LogQL. +Team LBAC controls access to logs depending on the rules set for each team. + +This feature addresses two common challenges faced by Grafana users: + +1. Having a high number of Grafana Cloud data sources. + Team LBAC lets Grafana administrators reduce the total number of data sources per instance from hundreds, to one. +1. Using the same dashboard across multiple teams. + Team LBAC lets Grafana Teams use the same dashboard with different access control rules. + +To set up Team LBAC for a Loki data source, refer to [Configure Team LBAC](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/teamlbac/configure-teamlbac-for-loki/). + +## Limitations + +- If there are no Team LBAC rules for a user's team, that user can query all logs. +- If an administrator is part of a team with Team LBAC rules, those rules are applied to the administrator requests. +- Cloud Access Policies (CAP) LBAC rules override Team LBAC rules. + Cloud Access Policies are the access controls from Grafana Cloud. + If there are any CAP LBAC rules configured for the same data source, then only the CAP LBAC rules are applied. + + You must remove any label selectors from your Cloud Access Policies to use Team LBAC. + For more information about CAP label selectors, refer to [Use label-based access control (LBAC) with access policies](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/label-access-policies/). + +## Data source permissions + +Data source permissions allow the users access to query the data source. +Administrators set the permissions at the data source level. +All the teams and users that are part of the data source inherit those permissions. + +## Recommended setup + +It's recommended that you create a single Loki data source for using Team LBAC rules so you have a clear separation of data sources using Team LBAC and those that aren't. +All teams should have with only teams having `query` permission. +You should create another Loki data source configured without Team LBAC for full access to the logs. + +## Team LBAC rules + +Grafana adds Team LBAC rules to the HTTP request via the Loki data source. + +If you configure multiple rules for a team, each rule is evaluated separately. +Query results include lines that match any of the rules. + +Only users with data source `Admin` permissions can edit Team LBAC rules in the **Data source permissions** tab because changing LBAC rules requires the same access level as editing data source permissions. + +To set up Team LBAC for a Loki data source, refer to [Configure Team LBAC](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/teamlbac/configure-teamlbac-for-loki/). diff --git a/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-loki/index.md b/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-loki/index.md new file mode 100644 index 0000000000000..bb1ab192d74fb --- /dev/null +++ b/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-loki/index.md @@ -0,0 +1,45 @@ +--- +description: Configure Team LBAC for Loki data source on Grafana Cloud +keywords: + - loki + - datasource + - team +labels: + products: + - cloud +title: Configure Team LBAC for Loki +weight: 250 +--- + +# Configure Team LBAC for Loki data source on Grafana Cloud + +Team LBAC is available in private preview on Grafana Cloud for Loki created with basic authentication. Loki datasources for Team LBAC can only be created, provisioning is currently not available. + +## Before you begin + +To be able to use Team LBAC rules, you need to enable the feature toggle `teamHTTPHeaders` on your Grafana instance. Contact support to enable the feature toggle for you. + +- Be sure that you have the permission setup to create a loki tenant in Grafana Cloud +- Be sure that you have admin data source permissions for Grafana. + +### Permissions + +We recommend that you remove all permissions for roles and teams that are not required to access the data source. This will help to ensure that only the required teams have access to the data source. The recommended permissions are `Admin` permission and only add the teams `Query` permissions that you want to add Team LBAC rules for. + +## Task 1: Configure Team LBAC for a new Loki data source + +1. Access Loki data sources details for your stack through grafana.com +1. Copy Loki Details and Create a CAP + - Copy the details of your Loki setup. + - Create a Cloud Access Policy (CAP) for the Loki data source in grafana.com. + - Ensure the CAP includes `logs:read` permissions. + - Ensure the CAP does not include `labels` rules. +1. Create a New Loki Data Source + - In Grafana, proceed to add a new data source and select Loki as the type. +1. Navigate back to the Loki data source + - Set up the Loki data source using basic authentication. Use the userID as the username. Use the generated CAP token as the password. + - Save and connect. +1. Navigate to Data Source Permissions + - Go to the permissions tab of the newly created Loki data source. Here, you'll find the Team LBAC rules section. + +For more information on how to setup Team LBAC rules for a Loki data source, [Add Team LBAC rules]({{< relref "./../create-teamlbac-rules/" >}}). diff --git a/docs/sources/administration/data-source-management/teamlbac/create-teamlbac-rules/index.md b/docs/sources/administration/data-source-management/teamlbac/create-teamlbac-rules/index.md new file mode 100644 index 0000000000000..f19fa06ec0f5c --- /dev/null +++ b/docs/sources/administration/data-source-management/teamlbac/create-teamlbac-rules/index.md @@ -0,0 +1,125 @@ +--- +description: Learn how to create Team LBAC rules for the Loki data source. +keywords: + - loki + - lbac + - team +labels: + products: + - enterprise + - cloud +title: Create Team LBAC rules for the Loki data source +weight: 250 +--- + +# Create Team LBAC rules for the Loki data source + +Team LBAC is available on Cloud for data sources created with basic authentication. Any managed Loki data source can **NOT** be configured with Team LBAC rules. + +## Before you begin + +- Be sure that you have admin data source permissions for Grafana. +- Be sure that you have a team setup in Grafana. + +### Create a Team LBAC Rule for a team + +1. Navigate to your Loki datasource +1. Navigate to the permissions tab + - Here, you'll find the Team LBAC rules section. +1. Add a Team LBAC Rule + - Add a new rule for the team in the Team LBAC rules section. +1. Define Label Selector for the Rule + - Add a label selector to the rule. Refer to Loki query documentation for guidance on the types of log selections you can specify. + +### LBAC rule + +A LBAC rule is a `logql` query that runs as a query to the loki instance for your logs. Each rule is it's own filtering operating independently from the other rules within a team. For example, you can create a label policy that includes all log lines with the label. + +One rule `{namespace="dev", cluster="us-west-0"}` created with multiple namespaces will be seen as `namespace="dev"` **AND** `cluster="us-west-0"`. +Two rules `{namespace="dev"}`, `{cluster="us-west-0"}` created for a team will be seen as `namespace="dev"` **OR** `cluster="us-west-0"`. + +#### Best practices + +We recommend you only add `query` permissions for teams that should use the data source and only `Admin` have `Admin` permissions. + +We recommend for a first setup, setting up as few rules as possible for each team and make them additive for simplicity. + +For validating the rules, we recommend testing the rules in the Loki Explore view. This will allow you to see the logs that would be returned for the rule. + +#### Tasks + +### Task 1: One rule setup for each team + +One common use case for creating an LBAC policy is to have specific access to logs that have a specific label. For example, you can create a label policy that includes all log lines with the label. + +We have two teams, Team A and Team B with `Query` permissions. Loki access is setup with `Admin` roles to have `Admin` permission only. + +- Team A has a rule `namespace="dev"`. + +- Team B has a rule `namespace="prod"`. + +A user that is part of Team A will have access to logs that match `namespace="dev"`. + +A user that is part of Team B will have access to logs that match `namespace="prod"`. + +A user that is part of Team A and Team B will have access to logs that match `namespace="dev"` OR `namespace="prod"`. + +### Task 2: One rule setup for a team Exclude a label + +One common use case for creating an LBAC policy is to exclude logs that have a specific label. For example, you can create a label policy that excludes all log lines with the label secret=true by adding a selector with `secret!="true"` when you create an access policy: + +We have one team, Team A `Query` permissions. Loki access is setup with `Admin` roles to have `Admin` permission only. + +- Team A has a rule `secret!="true"`. + +A user that is part of Team A will **NOT** have access to logs that match `secret!="true"`. + +### Task 3: Multiple rules setup for one team + +We have two teams, Team A and Team B with `Query` permissions. Loki access is setup with `Admin` roles having `Admin` permission. + +- Team A has rule `cluster="us-west-0", namespace=~"dev|prod"` configured. + +- Team B has rule `cluster="us-west-0", namespace="staging"` configured. + +A user that is only part of Team A will have access to logs that match `cluster="us-west-0" AND (namespace="dev" OR namespace="prod")`. + +A user that is only part of Team B will have access to logs that match `cluster="us-west-0" AND namespace="staging"`. + +A user in Team A has access to logs in cluster us-west-0 with namespaces `dev` and `prod`. A user in Team B has access to to everything in cluster us-west-0, except namespace prod. So basically, user who is member of both team A and team B has access to everything in cluster us-west-0. + +A user that is **not** part of any Team with `Editor/Viewer` role will not have access to query any logs. + +**Important** + +A `Admin` user that is part of a Team with will only have access to that teams logs + +A `Admin` user that is not part of any Team with `Admin` role will have access to all logs + +### Task 4: Rules that overlap + +We have two teams, Team A and Team B. + +- Team A has a rule `namespace="dev"`. + +- Team B has a rule `namespace!="dev"`. + +A user in Team A will have access to logs that match `namespace="dev"`. + +A user in Team B will have access to logs that match `namespace!="dev"`. + +> _NOTE:_ A user that is part of Team A and Team B will have access to all logs that match `namespace="dev"` `OR` `namespace!="dev"`. + +### Task 5: One rule setup for a Team + +We have two teams, Team A and Team B. Loki access is setup with `Editor`, `Viewer` roles to have `Query` permission. + +- Team A has a rule `namespace="dev"` configured. + +- Team B does not have a rule configured for it. + +A user that is part of Team A will have access to logs that match `namespace="dev"`. + +A user that is part of Team A and part of Team B will have access to logs that match `namespace="dev"`. + +A user that is not part of Team A and part of Team B, that is `Editor` or `Viewer` will have access to all logs (due to the query permission for the user). diff --git a/docs/sources/administration/enterprise-licensing/_index.md b/docs/sources/administration/enterprise-licensing/_index.md index 6dd8c9e2f70cc..5d7930976844f 100644 --- a/docs/sources/administration/enterprise-licensing/_index.md +++ b/docs/sources/administration/enterprise-licensing/_index.md @@ -196,6 +196,13 @@ The active users limit is turned off immediately. Settings updates at runtime are not affected by an expired license. +#### Email sharing + +External users can't access dashboards shared via email anymore. +These dashboards are now private but you can make them public and accessible to everyone if you want to. + +Grafana keeps your sharing configurations and restores them after you update your license. + ## Grafana Enterprise license restrictions When you become a Grafana Enterprise customer, you receive a license that governs your use of Grafana Enterprise. diff --git a/docs/sources/administration/organization-preferences/index.md b/docs/sources/administration/organization-preferences/index.md index 913f6ca8345f4..590370bf27f17 100644 --- a/docs/sources/administration/organization-preferences/index.md +++ b/docs/sources/administration/organization-preferences/index.md @@ -188,7 +188,7 @@ Users with the Grafana Server Admin flag on their account or access to the confi #### [Optional] Convert an existing dashboard into a JSON file 1. Navigate to the page of the dashboard you want to use as the home dashboard. -1. Click the **Share dashboard** icon next to the dashboard title. +1. Click the **Share** button at the top right of the screen. 1. In the Export tab, click **Save to file**. Grafana converts the dashboard to a JSON file and saves it locally. #### Use a JSON file as the home dashboard diff --git a/docs/sources/administration/provisioning/index.md b/docs/sources/administration/provisioning/index.md index 3a96f6e31117b..7c98942d581f2 100644 --- a/docs/sources/administration/provisioning/index.md +++ b/docs/sources/administration/provisioning/index.md @@ -419,82 +419,14 @@ providers: To provision dashboards to the root level, store them in the root of your `path`. {{% /admonition %}} +{{< admonition type="note" >}} +This feature doesn't currently allow you to create nested folder structures, that is, where you have folders within folders. +{{< /admonition >}} + ## Alerting For information on provisioning Grafana Alerting, refer to [Provision Grafana Alerting resources]({{< relref "../../alerting/set-up/provision-alerting-resources/" >}}). -## Alert Notification Channels - -{{% admonition type="note" %}} -Alert Notification Channels are part of legacy alerting, which is deprecated and will be removed in Grafana 10. Use the Provision contact points section in [Create and manage alerting resources using file provisioning]({{< relref "../../alerting/set-up/provision-alerting-resources/file-provisioning" >}}). -{{% /admonition %}} - -Alert Notification Channels can be provisioned by adding one or more YAML config files in the [`provisioning/notifiers`](/administration/configuration/#provisioning) directory. - -Each config file can contain the following top-level fields: - -- `notifiers`, a list of alert notifications that will be added or updated during start up. If the notification channel already exists, Grafana will update it to match the configuration file. -- `delete_notifiers`, a list of alert notifications to be deleted before inserting/updating those in the `notifiers` list. - -Provisioning looks up alert notifications by uid, and will update any existing notification with the provided uid. - -By default, exporting a dashboard as JSON will use a sequential identifier to refer to alert notifications. The field `uid` can be optionally specified to specify a string identifier for the alert name. - -```json -{ - ... - "alert": { - ..., - "conditions": [...], - "frequency": "24h", - "noDataState": "ok", - "notifications": [ - {"uid": "notifier1"}, - {"uid": "notifier2"}, - ] - } - ... -} -``` - -### Example Alert Notification Channels Config File - -```yaml -notifiers: - - name: notification-channel-1 - type: slack - uid: notifier1 - # either - org_id: 2 - # or - org_name: Main Org. - is_default: true - send_reminder: true - frequency: 1h - disable_resolve_message: false - # See `Supported Settings` section for settings supported for each - # alert notification type. - settings: - recipient: 'XXX' - uploadImage: true - token: 'xoxb' # legacy setting since Grafana v7.2 (stored non-encrypted) - url: https://slack.com # legacy setting since Grafana v7.2 (stored non-encrypted) - # Secure settings that will be encrypted in the database (supported since Grafana v7.2). See `Supported Settings` section for secure settings supported for each notifier. - secure_settings: - token: 'xoxb' - url: https://slack.com - -delete_notifiers: - - name: notification-channel-1 - uid: notifier1 - # either - org_id: 2 - # or - org_name: Main Org. - - name: notification-channel-2 - # default org_id: 1 -``` - ### Supported Settings The following sections detail the supported settings and secure settings for each alert notification type. Secure settings are stored encrypted in the database and you add them to `secure_settings` in the YAML file instead of `settings`. diff --git a/docs/sources/administration/roles-and-permissions/access-control/_index.md b/docs/sources/administration/roles-and-permissions/access-control/_index.md index 75d41e2fa89e1..10c8bad4054c3 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/_index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/_index.md @@ -93,6 +93,7 @@ Assign fixed roles when the basic roles do not meet your permission requirements - [Feature Toggles]({{< relref "../../feature-toggles/" >}}) - [Folders]({{< relref "../../../dashboards/manage-dashboards/#create-a-dashboard-folder" >}}) - [LDAP]({{< relref "../../../setup-grafana/configure-security/configure-authentication/ldap/" >}}) +- [Library panels]({{< relref "../../../dashboards/build-dashboards/manage-library-panels" >}}) - [Licenses]({{< relref "../../stats-and-license/" >}}) - [Organizations]({{< relref "../../organization-management/" >}}) - [Provisioning]({{< relref "../../provisioning/" >}}) diff --git a/docs/sources/administration/roles-and-permissions/access-control/configure-rbac/index.md b/docs/sources/administration/roles-and-permissions/access-control/configure-rbac/index.md index ab8a5eaf47bf3..c5e22b1f79e5a 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/configure-rbac/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/configure-rbac/index.md @@ -22,7 +22,7 @@ The table below describes all RBAC configuration options. Like any other Grafana | Setting | Required | Description | Default | | ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | `permission_cache` | No | Enable to use in memory cache for loading and evaluating users' permissions. | `true` | -| `permission_validation_enabled` | No | Grafana enforces validation for permissions when a user creates or updates a role. The system checks the internal list of scopes and actions for each permission to determine they are valid. By default, if a scope or action is not recognized, Grafana logs a warning message. When set to `true`, Grafana returns an error. | `false` | +| `permission_validation_enabled` | No | Grafana enforces validation for permissions when a user creates or updates a role. The system checks the internal list of scopes and actions for each permission to determine they are valid. By default, if a scope or action is not recognized, Grafana logs a warning message. When set to `true`, Grafana returns an error. | `true` | | `reset_basic_roles` | No | Reset Grafana's basic roles' (Viewer, Editor, Admin, Grafana Admin) permissions to their default. Warning, if this configuration option is left to `true` this will be done on every reboot. | `true` | ## Example RBAC configuration diff --git a/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md b/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md index 9efe67b5fa5ba..3d2751e4009cd 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md @@ -29,136 +29,140 @@ To learn more about the Grafana resources to which you can apply RBAC, refer to The following list contains role-based access control actions. -| Action | Applicable scope | Description | -| ------------------------------------ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `alert.instances.external:read` | `datasources:*`<br>`datasources:uid:*` | Read alerts and silences in data sources that support alerting. | -| `alert.instances.external:write` | `datasources:*`<br>`datasources:uid:*` | Manage alerts and silences in data sources that support alerting. | -| `alert.instances:create` | n/a | Create silences in the current organization. | -| `alert.instances:read` | n/a | Read alerts and silences in the current organization. | -| `alert.instances:write` | n/a | Update and expire silences in the current organization. | -| `alert.notifications.external:read` | `datasources:*`<br>`datasources:uid:*` | Read templates, contact points, notification policies, and mute timings in data sources that support alerting. | -| `alert.notifications.external:write` | `datasources:*`<br>`datasources:uid:*` | Manage templates, contact points, notification policies, and mute timings in data sources that support alerting. | -| `alert.notifications:write` | n/a | Manage templates, contact points, notification policies, and mute timings in the current organization. | -| `alert.notifications:read` | n/a | Read all templates, contact points, notification policies, and mute timings in the current organization. | -| `alert.rules.external:read` | `datasources:*`<br>`datasources:uid:*` | Read alert rules in data sources that support alerting (Prometheus, Mimir, and Loki) | -| `alert.rules.external:write` | `datasources:*`<br>`datasources:uid:*` | Create, update, and delete alert rules in data sources that support alerting (Mimir and Loki). | -| `alert.rules:create` | `folders:*`<br>`folders:uid:*` | Create Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | -| `alert.rules:delete` | `folders:*`<br>`folders:uid:*` | Delete Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | -| `alert.rules:read` | `folders:*`<br>`folders:uid:*` | Read Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | -| `alert.rules:write` | `folders:*`<br>`folders:uid:*` | Update Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | -| `alert.provisioning:read` | n/a | Read all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and datasource are not required. | -| `alert.provisioning.secrets:read` | n/a | Same as `alert.provisioning:read` plus ability to export resources with decrypted secrets. | -| `alert.provisioning:write` | n/a | Update all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and datasource are not required. | -| `annotations:create` | `annotations:*`<br>`annotations:type:*` | Create annotations. | -| `annotations:delete` | `annotations:*`<br>`annotations:type:*` | Delete annotations. | -| `annotations:read` | `annotations:*`<br>`annotations:type:*` | Read annotations and annotation tags. | -| `annotations:write` | `annotations:*`<br>`annotations:type:*` | Update annotations. | -| `apikeys:create` | n/a | Create API keys. | -| `apikeys:read` | `apikeys:*`<br>`apikeys:id:*` | Read API keys. | -| `apikeys:delete` | `apikeys:*`<br>`apikeys:id:*` | Delete API keys. | -| `dashboards:create` | `folders:*`<br>`folders:uid:*` | Create dashboards in one or more folders and their subfolders. | -| `dashboards:delete` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Delete one or more dashboards. | -| `dashboards.insights:read` | n/a | Read dashboard insights data and see presence indicators. | -| `dashboards.permissions:read` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Read permissions for one or more dashboards. | -| `dashboards.permissions:write` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Update permissions for one or more dashboards. | -| `dashboards:read` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Read one or more dashboards. | -| `dashboards:write` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Update one or more dashboards. | -| `dashboards.public:write` | `dashboards:*`<br>`dashboards:uid:*` | Write public dashboard configuration. | -| `datasources.caching:read` | `datasources:*`<br>`datasources:uid:*` | Read data source query caching settings. | -| `datasources.caching:write` | `datasources:*`<br>`datasources:uid:*` | Update data source query caching settings. | -| `datasources:create` | n/a | Create data sources. | -| `datasources:delete` | `datasources:*`<br>`datasources:uid:*` | Delete data sources. | -| `datasources:explore` | n/a | Enable access to the **Explore** tab. | -| `datasources.id:read` | `datasources:*`<br>`datasources:uid:*` | Read data source IDs. | -| `datasources.insights:read` | n/a | Read data sources insights data. | -| `datasources.permissions:read` | `datasources:*`<br>`datasources:uid:*` | List data source permissions. | -| `datasources.permissions:write` | `datasources:*`<br>`datasources:uid:*` | Update data source permissions. | -| `datasources:query` | `datasources:*`<br>`datasources:uid:*` | Query data sources. | -| `datasources:read` | `datasources:*`<br>`datasources:uid:*` | List data sources. | -| `datasources:write` | `datasources:*`<br>`datasources:uid:*` | Update data sources. | -| `featuremgmt.read` | n/a | Read feature toggles. | -| `featuremgmt.write` | n/a | Write feature toggles. | -| `folders.permissions:read` | `folders:*`<br>`folders:uid:*` | Read permissions for one or more folders and their subfolders. | -| `folders.permissions:write` | `folders:*`<br>`folders:uid:*` | Update permissions for one or more folders and their subfolders. | -| `folders:create` | n/a | Create folders in the root level. If granted together with `folders:write`, also allows creating subfolders under all folders that the user can update. | -| `folders:delete` | `folders:*`<br>`folders:uid:*` | Delete one or more folders and their subfolders. | -| `folders:read` | `folders:*`<br>`folders:uid:*` | Read one or more folders and their subfolders. | -| `folders:write` | `folders:*`<br>`folders:uid:*` | Update one or more folders and their subfolders. If granted together with `folders:create` permission, also allows creating subfolders under these folders. | -| `ldap.config:reload` | n/a | Reload the LDAP configuration. | -| `ldap.status:read` | n/a | Verify the availability of the LDAP server or servers. | -| `ldap.user:read` | n/a | Read users via LDAP. | -| `ldap.user:sync` | n/a | Sync users via LDAP. | -| `licensing.reports:read` | n/a | Get custom permission reports. | -| `licensing:delete` | n/a | Delete the license token. | -| `licensing:read` | n/a | Read licensing information. | -| `licensing:write` | n/a | Update the license token. | -| `org.users:write` | `users:*` <br> `users:id:*` | Update the organization role (`Viewer`, `Editor`, or `Admin`) of a user. | -| `org.users:add` | `users:*` <br> `users:id:*` | Add a user to an organization or invite a new user to an organization. | -| `org.users:read` | `users:*` <br> `users:id:*` | Get user profiles within an organization. | -| `org.users:remove` | `users:*` <br> `users:id:*` | Remove a user from an organization. | -| `orgs.preferences:read` | n/a | Read organization preferences. | -| `orgs.preferences:write` | n/a | Update organization preferences. | -| `orgs.quotas:read` | n/a | Read organization quotas. | -| `orgs.quotas:write` | n/a | Update organization quotas. | -| `orgs:create` | n/a | Create an organization. | -| `orgs:delete` | n/a | Delete one or more organizations. | -| `orgs:read` | n/a | Read one or more organizations. | -| `orgs:write` | n/a | Update one or more organizations. | -| `plugins.app:access` | `plugins:*` <br> `plugins:id:*` | Access one or more application plugins (still enforcing the organization role) | -| `plugins:install` | n/a | Install and uninstall plugins. | -| `plugins:write` | `plugins:*` <br> `plugins:id:*` | Edit settings for one or more plugins. | -| `provisioning:reload` | `provisioners:*` | Reload provisioning files. To find the exact scope for specific provisioner, see [Scope definitions]({{< relref "#scope-definitions" >}}). | -| `reports:create` | n/a | Create reports. | -| `reports:write` | `reports:*` <br> `reports:id:*` | Update reports. | -| `reports.settings:read` | n/a | Read report settings. | -| `reports.settings:write` | n/a | Update report settings. | -| `reports:delete` | `reports:*` <br> `reports:id:*` | Delete reports. | -| `reports:read` | `reports:*` <br> `reports:id:*` | List all available reports or get a specific report. | -| `reports:send` | `reports:*` <br> `reports:id:*` | Send a report email. | -| `roles:delete` | `permissions:type:delegate` | Delete a custom role. | -| `roles:read` | `roles:*` <br> `roles:uid:*` | List roles and read a specific with its permissions. | -| `roles:write` | `permissions:type:delegate` | Create or update a custom role. | -| `roles:write` | `permissions:type:escalate` | Reset basic roles to their default permissions. | -| `server.stats:read` | n/a | Read Grafana instance statistics. | -| `server.usagestats.report:read` | n/a | View usage statistics report. | -| `serviceaccounts:write` | `serviceaccounts:*` | Create Grafana service accounts. | -| `serviceaccounts:create` | n/a | Update Grafana service accounts. | -| `serviceaccounts:delete` | `serviceaccounts:*` <br> `serviceaccounts:id:*` | Delete Grafana service accounts. | -| `serviceaccounts:read` | `serviceaccounts:*` <br> `serviceaccounts:id:*` | Read Grafana service accounts. | -| `serviceaccounts.permissions:write` | `serviceaccounts:*` <br> `serviceaccounts:id:*` | Update Grafana service account permissions to control who can do what with the service account. | -| `serviceaccounts.permissions:read` | `serviceaccounts:*` <br> `serviceaccounts:id:*` | Read Grafana service account permissions to see who can do what with the service account. | -| `settings:read` | `settings:*`<br>`settings:auth.saml:*`<br>`settings:auth.saml:enabled` (property level) | Read the [Grafana configuration settings]({{< relref "../../../../setup-grafana/configure-grafana/" >}}) | -| `settings:write` | `settings:*`<br>`settings:auth.saml:*`<br>`settings:auth.saml:enabled` (property level) | Update any Grafana configuration settings that can be [updated at runtime]({{< relref "../../../../setup-grafana/configure-grafana/settings-updates-at-runtime" >}}). | -| `support.bundles:create` | n/a | Create support bundles. | -| `support.bundles:delete` | n/a | Delete support bundles. | -| `support.bundles:read` | n/a | List and download support bundles. | -| `status:accesscontrol` | `services:accesscontrol` | Get access-control enabled status. | -| `teams.permissions:read` | `teams:*`<br>`teams:id:*` | Read members and Team Sync setup for teams. | -| `teams.permissions:write` | `teams:*`<br>`teams:id:*` | Add, remove and update members and manage Team Sync setup for teams. | -| `teams.roles:add` | `permissions:type:delegate` | Assign a role to a team. | -| `teams.roles:read` | `teams:*`<br>`teams:id:*` | List roles assigned directly to a team. | -| `teams.roles:remove` | `permissions:type:delegate` | Unassign a role from a team. | -| `teams:create` | n/a | Create teams. | -| `teams:delete` | `teams:*`<br>`teams:id:*` | Delete one or more teams. | -| `teams:read` | `teams:*`<br>`teams:id:*` | Read one or more teams and team preferences. | -| `teams:write` | `teams:*`<br>`teams:id:*` | Update one or more teams and team preferences. | -| `users.authtoken:read` | `global.users:*` <br> `global.users:id:*` | List authentication tokens that are assigned to a user. | -| `users.authtoken:write` | `global.users:*` <br> `global.users:id:*` | Update authentication tokens that are assigned to a user. | -| `users.password:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s password. | -| `users.permissions:read` | `users:*` | List permissions of a user. | -| `users.permissions:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s organization-level permissions. | -| `users.quotas:read` | `global.users:*` <br> `global.users:id:*` | List a user’s quotas. | -| `users.quotas:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s quotas. | -| `users.roles:add` | `permissions:type:delegate` | Assign a role to a user or a service account. | -| `users.roles:read` | `users:*` | List roles assigned directly to a user or a service account. | -| `users.roles:remove` | `permissions:type:delegate` | Unassign a role from a user or a service account. | -| `users:create` | n/a | Create a user. | -| `users:delete` | `global.users:*` <br> `global.users:id:*` | Delete a user. | -| `users:disable` | `global.users:*` <br> `global.users:id:*` | Disable a user. | -| `users:enable` | `global.users:*` <br> `global.users:id:*` | Enable a user. | -| `users:logout` | `global.users:*` <br> `global.users:id:*` | Sign out a user. | -| `users:read` | `global.users:*` | Read or search user profiles. | -| `users:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s profile. | +| Action | Applicable scope | Description | +| ------------------------------------ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `alert.instances.external:read` | `datasources:*`<br>`datasources:uid:*` | Read alerts and silences in data sources that support alerting. | +| `alert.instances.external:write` | `datasources:*`<br>`datasources:uid:*` | Manage alerts and silences in data sources that support alerting. | +| `alert.instances:create` | n/a | Create silences in the current organization. | +| `alert.instances:read` | n/a | Read alerts and silences in the current organization. | +| `alert.instances:write` | n/a | Update and expire silences in the current organization. | +| `alert.notifications.external:read` | `datasources:*`<br>`datasources:uid:*` | Read templates, contact points, notification policies, and mute timings in data sources that support alerting. | +| `alert.notifications.external:write` | `datasources:*`<br>`datasources:uid:*` | Manage templates, contact points, notification policies, and mute timings in data sources that support alerting. | +| `alert.notifications:write` | n/a | Manage templates, contact points, notification policies, and mute timings in the current organization. | +| `alert.notifications:read` | n/a | Read all templates, contact points, notification policies, and mute timings in the current organization. | +| `alert.rules.external:read` | `datasources:*`<br>`datasources:uid:*` | Read alert rules in data sources that support alerting (Prometheus, Mimir, and Loki) | +| `alert.rules.external:write` | `datasources:*`<br>`datasources:uid:*` | Create, update, and delete alert rules in data sources that support alerting (Mimir and Loki). | +| `alert.rules:create` | `folders:*`<br>`folders:uid:*` | Create Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | +| `alert.rules:delete` | `folders:*`<br>`folders:uid:*` | Delete Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | +| `alert.rules:read` | `folders:*`<br>`folders:uid:*` | Read Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | +| `alert.rules:write` | `folders:*`<br>`folders:uid:*` | Update Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | +| `alert.provisioning:read` | n/a | Read all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and datasource are not required. | +| `alert.provisioning.secrets:read` | n/a | Same as `alert.provisioning:read` plus ability to export resources with decrypted secrets. | +| `alert.provisioning:write` | n/a | Update all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and datasource are not required. | +| `annotations:create` | `annotations:*`<br>`annotations:type:*` | Create annotations. | +| `annotations:delete` | `annotations:*`<br>`annotations:type:*` | Delete annotations. | +| `annotations:read` | `annotations:*`<br>`annotations:type:*` | Read annotations and annotation tags. | +| `annotations:write` | `annotations:*`<br>`annotations:type:*` | Update annotations. | +| `apikeys:create` | n/a | Create API keys. | +| `apikeys:read` | `apikeys:*`<br>`apikeys:id:*` | Read API keys. | +| `apikeys:delete` | `apikeys:*`<br>`apikeys:id:*` | Delete API keys. | +| `dashboards:create` | `folders:*`<br>`folders:uid:*` | Create dashboards in one or more folders and their subfolders. | +| `dashboards:delete` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Delete one or more dashboards. | +| `dashboards.insights:read` | n/a | Read dashboard insights data and see presence indicators. | +| `dashboards.permissions:read` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Read permissions for one or more dashboards. | +| `dashboards.permissions:write` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Update permissions for one or more dashboards. | +| `dashboards:read` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Read one or more dashboards. | +| `dashboards:write` | `dashboards:*`<br>`dashboards:uid:*`<br>`folders:*`<br>`folders:uid:*` | Update one or more dashboards. | +| `dashboards.public:write` | `dashboards:*`<br>`dashboards:uid:*` | Write public dashboard configuration. | +| `datasources.caching:read` | `datasources:*`<br>`datasources:uid:*` | Read data source query caching settings. | +| `datasources.caching:write` | `datasources:*`<br>`datasources:uid:*` | Update data source query caching settings. | +| `datasources:create` | n/a | Create data sources. | +| `datasources:delete` | `datasources:*`<br>`datasources:uid:*` | Delete data sources. | +| `datasources:explore` | n/a | Enable access to the **Explore** tab. | +| `datasources.id:read` | `datasources:*`<br>`datasources:uid:*` | Read data source IDs. | +| `datasources.insights:read` | n/a | Read data sources insights data. | +| `datasources.permissions:read` | `datasources:*`<br>`datasources:uid:*` | List data source permissions. | +| `datasources.permissions:write` | `datasources:*`<br>`datasources:uid:*` | Update data source permissions. | +| `datasources:query` | `datasources:*`<br>`datasources:uid:*` | Query data sources. | +| `datasources:read` | `datasources:*`<br>`datasources:uid:*` | List data sources. | +| `datasources:write` | `datasources:*`<br>`datasources:uid:*` | Update data sources. | +| `featuremgmt.read` | n/a | Read feature toggles. | +| `featuremgmt.write` | n/a | Write feature toggles. | +| `folders.permissions:read` | `folders:*`<br>`folders:uid:*` | Read permissions for one or more folders and their subfolders. | +| `folders.permissions:write` | `folders:*`<br>`folders:uid:*` | Update permissions for one or more folders and their subfolders. | +| `folders:create` | n/a | Create folders in the root level. If granted together with `folders:write`, also allows creating subfolders under all folders that the user can update. | +| `folders:delete` | `folders:*`<br>`folders:uid:*` | Delete one or more folders and their subfolders. | +| `folders:read` | `folders:*`<br>`folders:uid:*` | Read one or more folders and their subfolders. | +| `folders:write` | `folders:*`<br>`folders:uid:*` | Update one or more folders and their subfolders. If granted together with `folders:create` permission, also allows creating subfolders under these folders. | +| `ldap.config:reload` | n/a | Reload the LDAP configuration. | +| `ldap.status:read` | n/a | Verify the availability of the LDAP server or servers. | +| `ldap.user:read` | n/a | Read users via LDAP. | +| `ldap.user:sync` | n/a | Sync users via LDAP. | +| `library.panels:create` | `folders:*` <br> `folders:uid:*` | Create a library panel in one or more folders and their subfolders. | +| `library.panels:read` | `folders:*` <br> `folders:uid:*` <br> `library.panels:*` <br> `library.panels:uid:*` | Read one or more library panels. | +| `library.panels:write` | `folders:*` <br> `folders:uid:*` <br> `library.panels:*` <br> `library.panels:uid:*` | Update one or more library panels. | +| `library.panels:delete` | `folders:*` <br> `folders:uid:*` <br> `library.panels:*` <br> `library.panels:uid:*` | Delete one or more library panels. | +| `licensing.reports:read` | n/a | Get custom permission reports. | +| `licensing:delete` | n/a | Delete the license token. | +| `licensing:read` | n/a | Read licensing information. | +| `licensing:write` | n/a | Update the license token. | +| `org.users:write` | `users:*` <br> `users:id:*` | Update the organization role (`Viewer`, `Editor`, or `Admin`) of a user. | +| `org.users:add` | `users:*` <br> `users:id:*` | Add a user to an organization or invite a new user to an organization. | +| `org.users:read` | `users:*` <br> `users:id:*` | Get user profiles within an organization. | +| `org.users:remove` | `users:*` <br> `users:id:*` | Remove a user from an organization. | +| `orgs.preferences:read` | n/a | Read organization preferences. | +| `orgs.preferences:write` | n/a | Update organization preferences. | +| `orgs.quotas:read` | n/a | Read organization quotas. | +| `orgs.quotas:write` | n/a | Update organization quotas. | +| `orgs:create` | n/a | Create an organization. | +| `orgs:delete` | n/a | Delete one or more organizations. | +| `orgs:read` | n/a | Read one or more organizations. | +| `orgs:write` | n/a | Update one or more organizations. | +| `plugins.app:access` | `plugins:*` <br> `plugins:id:*` | Access one or more application plugins (still enforcing the organization role) | +| `plugins:install` | n/a | Install and uninstall plugins. | +| `plugins:write` | `plugins:*` <br> `plugins:id:*` | Edit settings for one or more plugins. | +| `provisioning:reload` | `provisioners:*` | Reload provisioning files. To find the exact scope for specific provisioner, see [Scope definitions]({{< relref "#scope-definitions" >}}). | +| `reports:create` | n/a | Create reports. | +| `reports:write` | `reports:*` <br> `reports:id:*` | Update reports. | +| `reports.settings:read` | n/a | Read report settings. | +| `reports.settings:write` | n/a | Update report settings. | +| `reports:delete` | `reports:*` <br> `reports:id:*` | Delete reports. | +| `reports:read` | `reports:*` <br> `reports:id:*` | List all available reports or get a specific report. | +| `reports:send` | `reports:*` <br> `reports:id:*` | Send a report email. | +| `roles:delete` | `permissions:type:delegate` | Delete a custom role. | +| `roles:read` | `roles:*` <br> `roles:uid:*` | List roles and read a specific role with its permissions. | +| `roles:write` | `permissions:type:delegate` | Create or update a custom role. | +| `roles:write` | `permissions:type:escalate` | Reset basic roles to their default permissions. | +| `server.stats:read` | n/a | Read Grafana instance statistics. | +| `server.usagestats.report:read` | n/a | View usage statistics report. | +| `serviceaccounts:write` | `serviceaccounts:*` | Create Grafana service accounts. | +| `serviceaccounts:create` | n/a | Update Grafana service accounts. | +| `serviceaccounts:delete` | `serviceaccounts:*` <br> `serviceaccounts:id:*` | Delete Grafana service accounts. | +| `serviceaccounts:read` | `serviceaccounts:*` <br> `serviceaccounts:id:*` | Read Grafana service accounts. | +| `serviceaccounts.permissions:write` | `serviceaccounts:*` <br> `serviceaccounts:id:*` | Update Grafana service account permissions to control who can do what with the service account. | +| `serviceaccounts.permissions:read` | `serviceaccounts:*` <br> `serviceaccounts:id:*` | Read Grafana service account permissions to see who can do what with the service account. | +| `settings:read` | `settings:*`<br>`settings:auth.saml:*`<br>`settings:auth.saml:enabled` (property level) | Read the [Grafana configuration settings]({{< relref "../../../../setup-grafana/configure-grafana/" >}}) | +| `settings:write` | `settings:*`<br>`settings:auth.saml:*`<br>`settings:auth.saml:enabled` (property level) | Update any Grafana configuration settings that can be [updated at runtime]({{< relref "../../../../setup-grafana/configure-grafana/settings-updates-at-runtime" >}}). | +| `support.bundles:create` | n/a | Create support bundles. | +| `support.bundles:delete` | n/a | Delete support bundles. | +| `support.bundles:read` | n/a | List and download support bundles. | +| `status:accesscontrol` | `services:accesscontrol` | Get access-control enabled status. | +| `teams.permissions:read` | `teams:*`<br>`teams:id:*` | Read members and Team Sync setup for teams. | +| `teams.permissions:write` | `teams:*`<br>`teams:id:*` | Add, remove and update members and manage Team Sync setup for teams. | +| `teams.roles:add` | `permissions:type:delegate` | Assign a role to a team. | +| `teams.roles:read` | `teams:*`<br>`teams:id:*` | List roles assigned directly to a team. | +| `teams.roles:remove` | `permissions:type:delegate` | Unassign a role from a team. | +| `teams:create` | n/a | Create teams. | +| `teams:delete` | `teams:*`<br>`teams:id:*` | Delete one or more teams. | +| `teams:read` | `teams:*`<br>`teams:id:*` | Read one or more teams and team preferences. To list teams through the UI one of the following permissions is required in addition to `teams:read`: `teams:write`, `teams.permissions:read` or `teams.permissions:write`. | +| `teams:write` | `teams:*`<br>`teams:id:*` | Update one or more teams and team preferences. | +| `users.authtoken:read` | `global.users:*` <br> `global.users:id:*` | List authentication tokens that are assigned to a user. | +| `users.authtoken:write` | `global.users:*` <br> `global.users:id:*` | Update authentication tokens that are assigned to a user. | +| `users.password:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s password. | +| `users.permissions:read` | `users:*` | List permissions of a user. | +| `users.permissions:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s organization-level permissions. | +| `users.quotas:read` | `global.users:*` <br> `global.users:id:*` | List a user’s quotas. | +| `users.quotas:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s quotas. | +| `users.roles:add` | `permissions:type:delegate` | Assign a role to a user or a service account. | +| `users.roles:read` | `users:*` | List roles assigned directly to a user or a service account. | +| `users.roles:remove` | `permissions:type:delegate` | Unassign a role from a user or a service account. | +| `users:create` | n/a | Create a user. | +| `users:delete` | `global.users:*` <br> `global.users:id:*` | Delete a user. | +| `users:disable` | `global.users:*` <br> `global.users:id:*` | Disable a user. | +| `users:enable` | `global.users:*` <br> `global.users:id:*` | Enable a user. | +| `users:logout` | `global.users:*` <br> `global.users:id:*` | Sign out a user. | +| `users:read` | `global.users:*` | Read or search user profiles. | +| `users:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s profile. | ### Grafana OnCall action definitions (beta) @@ -209,6 +213,7 @@ The following list contains role-based access control scopes. | `datasources:*`<br>`datasources:uid:*` | Restrict an action to a set of data sources. For example, `datasources:*` matches any data source, and `datasources:uid:1` matches the data source whose UID is `1`. | | `folders:*`<br>`folders:uid:*` | Restrict an action to a set of folders. For example, `folders:*` matches any folder, and `folders:uid:1` matches the folder whose UID is `1`. Note that permissions granted to a folder cascade down to subfolders located under it | | `global.users:*` <br> `global.users:id:*` | Restrict an action to a set of global users. For example, `global.users:*` matches any user and `global.users:id:1` matches the user whose ID is `1`. | +| `library.panels:*` <br> `library.panels:uid:*` | Restrict an action to a set of library panels. For example, `library.panels:*` matches any library panel, and `library.panel:uid:1` matches the library panel whose UID is `1`. | | `orgs:*` <br> `orgs:id:*` | Restrict an action to a set of organizations. For example, `orgs:*` matches any organization and `orgs:id:1` matches the organization whose ID is `1`. | | `permissions:type:delegate` | The scope is only applicable for roles associated with the Access Control itself and indicates that you can delegate your permissions only, or a subset of it, by creating a new role or making an assignment. | | `permissions:type:escalate` | The scope is required to trigger the reset of basic roles permissions. It indicates that users might acquire additional permissions they did not previously have. | diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md index ed3aa9cd33c37..05d1235169ea5 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md @@ -23,13 +23,13 @@ The following tables list permissions associated with basic and fixed roles. ## Basic role assignments -| Basic role | Associated fixed roles | Description | -| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| Grafana Admin | `fixed:roles:reader`<br>`fixed:roles:writer`<br>`fixed:users:reader`<br>`fixed:users:writer`<br>`fixed:org.users:reader`<br>`fixed:org.users:writer`<br>`fixed:ldap:reader`<br>`fixed:ldap:writer`<br>`fixed:stats:reader`<br>`fixed:settings:reader`<br>`fixed:settings:writer`<br>`fixed:provisioning:writer`<br>`fixed:organization:reader`<br>`fixed:organization:maintainer`<br>`fixed:licensing:reader`<br>`fixed:licensing:writer`<br>`fixed:datasources.caching:reader`<br>`fixed:datasources.caching:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:plugins:maintainer`<br>`fixed:authentication.config:writer` | Default [Grafana server administrator]({{< relref "../../#grafana-server-administrators" >}}) assignments. | -| Admin | `fixed:reports:reader`<br>`fixed:reports:writer`<br>`fixed:datasources:reader`<br>`fixed:datasources:writer`<br>`fixed:organization:writer`<br>`fixed:datasources.permissions:reader`<br>`fixed:datasources.permissions:writer`<br>`fixed:teams:writer`<br>`fixed:dashboards:reader`<br>`fixed:dashboards:writer`<br>`fixed:dashboards.permissions:reader`<br>`fixed:dashboards.permissions:writer`<br>`fixed:dashboards.public:writer`<br>`fixed:folders:reader`<br>`fixed:folders:writer`<br>`fixed:folders.permissions:reader`<br>`fixed:folders.permissions:writer`<br>`fixed:alerting:writer`<br>`fixed:apikeys:reader`<br>`fixed:apikeys:writer`<br>`fixed:alerting.provisioning.secrets:reader`<br>`fixed:alerting.provisioning:writer`<br>`fixed:datasources.caching:reader`<br>`fixed:datasources.caching:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:plugins:writer` | Default [Grafana organization administrator]({{< relref "../#basic-roles" >}}) assignments. | -| Editor | `fixed:datasources:explorer`<br>`fixed:dashboards:creator`<br>`fixed:folders:creator`<br>`fixed:annotations:writer`<br>`fixed:teams:creator` if the `editors_can_admin` configuration flag is enabled<br>`fixed:alerting:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader` | Default [Editor]({{< relref "../#basic-roles" >}}) assignments. | -| Viewer | `fixed:datasources:id:reader`<br>`fixed:organization:reader`<br>`fixed:annotations:reader`<br>`fixed:annotations.dashboard:writer`<br>`fixed:alerting:reader`<br>`fixed:plugins.app:reader`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader` | Default [Viewer]({{< relref "../#basic-roles" >}}) assignments. | -| No Basic Role | | Default [No Basic Role]({{< relref "../#basic-roles" >}}) | +| Basic role | Associated fixed roles | Description | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| Grafana Admin | `fixed:roles:reader`<br>`fixed:roles:writer`<br>`fixed:users:reader`<br>`fixed:users:writer`<br>`fixed:org.users:reader`<br>`fixed:org.users:writer`<br>`fixed:ldap:reader`<br>`fixed:ldap:writer`<br>`fixed:stats:reader`<br>`fixed:settings:reader`<br>`fixed:settings:writer`<br>`fixed:provisioning:writer`<br>`fixed:organization:reader`<br>`fixed:organization:maintainer`<br>`fixed:licensing:reader`<br>`fixed:licensing:writer`<br>`fixed:datasources.caching:reader`<br>`fixed:datasources.caching:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:plugins:maintainer`<br>`fixed:authentication.config:writer`<br>`fixed:library.panels:creator`<br>`fixed:library.panels:reader`<br>`fixed:library.panels:general.reader`<br>`fixed:library.panels:writer`<br>`fixed:library.panels:general.writer` | Default [Grafana server administrator]({{< relref "../../#grafana-server-administrators" >}}) assignments. | +| Admin | `fixed:reports:reader`<br>`fixed:reports:writer`<br>`fixed:datasources:reader`<br>`fixed:datasources:writer`<br>`fixed:organization:writer`<br>`fixed:datasources.permissions:reader`<br>`fixed:datasources.permissions:writer`<br>`fixed:teams:writer`<br>`fixed:dashboards:reader`<br>`fixed:dashboards:writer`<br>`fixed:dashboards.permissions:reader`<br>`fixed:dashboards.permissions:writer`<br>`fixed:dashboards.public:writer`<br>`fixed:folders:reader`<br>`fixed:folders:writer`<br>`fixed:folders.permissions:reader`<br>`fixed:folders.permissions:writer`<br>`fixed:alerting:writer`<br>`fixed:apikeys:reader`<br>`fixed:apikeys:writer`<br>`fixed:alerting.provisioning.secrets:reader`<br>`fixed:alerting.provisioning:writer`<br>`fixed:datasources.caching:reader`<br>`fixed:datasources.caching:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:plugins:writer`<br>`fixed:library.panels:creator`<br>`fixed:library.panels:reader`<br>`fixed:library.panels:general.reader`<br>`fixed:library.panels:writer`<br>`fixed:library.panels:general.writer` | Default [Grafana organization administrator]({{< relref "../#basic-roles" >}}) assignments. | +| Editor | `fixed:datasources:explorer`<br>`fixed:dashboards:creator`<br>`fixed:folders:creator`<br>`fixed:annotations:writer`<br>`fixed:teams:creator` if the `editors_can_admin` configuration flag is enabled<br>`fixed:alerting:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:library.panels:creator`<br>`fixed:library.panels:general.reader`<br>`fixed:library.panels:general.writer` | Default [Editor]({{< relref "../#basic-roles" >}}) assignments. | +| Viewer | `fixed:datasources.id:reader`<br>`fixed:organization:reader`<br>`fixed:annotations:reader`<br>`fixed:annotations.dashboard:writer`<br>`fixed:alerting:reader`<br>`fixed:plugins.app:reader`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:library.panels:general.reader` | Default [Viewer]({{< relref "../#basic-roles" >}}) assignments. | +| No Basic Role | | Default [No Basic Role]({{< relref "../#basic-roles" >}}) | ## Fixed role definitions @@ -61,7 +61,7 @@ The following tables list permissions associated with basic and fixed roles. | `fixed:datasources.caching:reader` | `datasources.caching:read` | Read data source query caching settings. | | `fixed:datasources.caching:writer` | `datasources.caching:read`<br>`datasources.caching:write` | Enable, disable, or update query caching settings. | | `fixed:datasources:explorer` | `datasources:explore` | Enable the Explore feature. Data source permissions still apply, you can only query data sources for which you have query permissions. | -| `fixed:datasources:id:reader` | `datasources.id:read` | Read the ID of a data source based on its name. | +| `fixed:datasources.id:reader` | `datasources.id:read` | Read the ID of a data source based on its name. | | `fixed:datasources.insights:reader` | `datasources.insights:read` | Read data source insights data. | | `fixed:datasources.permissions:reader` | `datasources.permissions:read` | Read data source permissions. | | `fixed:datasources.permissions:writer` | All permissions from `fixed:datasources.permissions:reader` and <br>`datasources.permissions:write` | Create, read, or delete permissions of a data source. | @@ -75,6 +75,11 @@ The following tables list permissions associated with basic and fixed roles. | `fixed:folders:writer` | All permissions from `fixed:dashboards:writer` and <br>`folders:read`<br>`folders:write`<br>`folders:create`<br>`folders:delete`<br>`folders.permissions:read`<br>`folders.permissions:write` | Read, create, update, and delete all folders and dashboards. If granted together with `fixed:folders:creator`, allows creating subfolders under all folders. | | `fixed:ldap:reader` | `ldap.user:read`<br>`ldap.status:read` | Read the LDAP configuration and LDAP status information. | | `fixed:ldap:writer` | All permissions from `fixed:ldap:reader` and <br>`ldap.user:sync`<br>`ldap.config:reload` | Read and update the LDAP configuration, and read LDAP status information. | +| `fixed:library.panels:creator` | `library.panels:create`<br>`folders:read` | Create library panel at the root level. | +| `fixed:library.panels:reader` | `library.panels:read` | Read all library panels. | +| `fixed:library.panels:general.reader` | `library.panels:read` | Read all library panels at the root level. | +| `fixed:library.panels:writer` | All permissions from `fixed:library.panels:reader` plus<br>`library.panels:create`<br>`library.panels:delete`<br>`library.panels:write` | Create, read, write or delete all library panels and their permissions. | +| `fixed:library.panels:general.writer` | All permissions from `fixed:library.panels:general.reader` plus<br>`library.panels:create`<br>`library.panels:delete`<br>`library.panels:write` | Create, read, write or delete all library panels and their permissions at the root level. | | `fixed:licensing:reader` | `licensing:read`<br>`licensing.reports:read` | Read licensing information and licensing reports. | | `fixed:licensing:writer` | All permissions from `fixed:licensing:viewer` and <br>`licensing:write`<br>`licensing:delete` | Read licensing information and licensing reports, update and delete the license token. | | `fixed:org.users:reader` | `org.users:read` | Read users within a single organization. | @@ -105,7 +110,7 @@ The following tables list permissions associated with basic and fixed roles. ### Alerting roles -If alerting is [enabled]({{< relref "../../../../alerting/set-up/migrating-alerts" >}}), you can use predefined roles to manage user access to alert rules, alert instances, and alert notification settings and create custom roles to limit user access to alert rules in a folder. +You can use predefined roles to manage user access to alert rules, alert instances, and alert notification settings and create custom roles to limit user access to alert rules in a folder. Access to Grafana alert rules is an intersection of many permissions: @@ -128,37 +133,7 @@ You can enable feature toggles through configuration file or environment variabl {{% /admonition %}} If you are using [Grafana OnCall](https://grafana.com/docs/oncall/latest/get-started/), you can try out the integration between Grafana OnCall and RBAC. -This will allow you to control access to different OnCall features using the following RBAC roles: - -| Fixed role | Permissions | Description | -| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `plugins:grafana-oncall-app:reader` | `plugins.app:access`<br>`grafana-oncall-app.alert-groups:read`<br>`grafana-oncall-app.integrations:read`<br>`grafana-oncall-app.escalation-chains:read`<br>`grafana-oncall-app.schedules:read`<br>`grafana-oncall-app.chatops:read`<br>`grafana-oncall-app.outgoing-webhooks:read`<br>`grafana-oncall-app.maintenance:read`<br>`grafana-oncall-app.notification-settings:read`<br>`grafana-oncall-app.user-settings:read`<br>`grafana-oncall-app.other-settings:read` | Read everything in OnCall. | -| `plugins:grafana-oncall-app:oncaller` | All permissions from `plugins:grafana-oncall-app:reader` and `grafana-oncall-app.alert-groups:write`<br>`grafana-oncall-app.schedules:write` | Read everything in OnCall and edit alert groups and schedules. | -| `plugins:grafana-oncall-app:editor` | All permissions from `plugins:grafana-oncall-app:oncaller` and `grafana-oncall-app.notifications:read`<br>`grafana-oncall-app.integrations:test`<br>`grafana-oncall-app.schedules:export`<br>`grafana-oncall-app.chatops:write`<br>`grafana-oncall-app.maintenance:write`<br>`grafana-oncall-app.notification-settings:write`<br>`grafana-oncall-app.user-settings:write` | Read everything in OnCall and edit alert groups, schedules, ChatOps, maintenance, notification settings, and user's own settings. | -| `plugins:grafana-oncall-app:admin` | All permissions from `plugins:grafana-oncall-app:editor` and `grafana-oncall-app.integrations:write`<br>`grafana-oncall-app.escalation-chains:write`<br>`grafana-oncall-app.chatops:update-settings:write`<br>`grafana-oncall-app.outgoing-webhooks:write`<br>`grafana-oncall-app.api-keys:write`<br>`grafana-oncall-app.user-settings:admin`<br>`grafana-oncall-app.other-settings:write` | Read and edit everything in OnCall. | -| `plugins:grafana-oncall-app:alert-groups-reader` | `plugins.app:access`<br>`grafana-oncall-app.alert-groups:read` | Read OnCall alert groups. | -| `plugins:grafana-oncall-app:alert-groups-editor` | `plugins.app:access`<br>`grafana-oncall-app.alert-groups:read`<br>`grafana-oncall-app.alert-groups:write` | Create, read, update and delete OnCall alert groups. | -| `plugins:grafana-oncall-app:integrations-reader` | `plugins.app:access`<br>`grafana-oncall-app.integrations:read` | Read OnCall integrations. | -| `plugins:grafana-oncall-app:integrations-editor` | `plugins.app:access`<br>`grafana-oncall-app.integrations:read`<br>`grafana-oncall-app.integrations:write`<br>`grafana-oncall-app.integrations:test` | Create, read, update and delete OnCall integrations. | -| `plugins:grafana-oncall-app:escalation-chains-reader` | `plugins.app:access`<br>`grafana-oncall-app.escalation-chains:read` | Read OnCall escalation chains. | -| `plugins:grafana-oncall-app:escalation-chains-editor` | `plugins.app:access`<br>`grafana-oncall-app.escalation-chains:read`<br>`grafana-oncall-app.escalation-chains:write` | Create, read, update and delete OnCall escalation chains. | -| `plugins:grafana-oncall-app:schedules-reader` | `plugins.app:access`<br>`grafana-oncall-app.schedules:read` | Read OnCall schedules. | -| `plugins:grafana-oncall-app:schedules-editor` | `plugins.app:access`<br>`grafana-oncall-app.schedules:read`<br>`grafana-oncall-app.schedules:write`<br>`grafana-oncall-app.schedules:export` | Create, read, update and delete OnCall schedules. | -| `plugins:grafana-oncall-app:chatops-reader` | `plugins.app:access`<br>`grafana-oncall-app.chatops:read` | Read OnCall ChatOps. | -| `plugins:grafana-oncall-app:chatops-editor` | `plugins.app:access`<br>`grafana-oncall-app.chatops:read`<br>`grafana-oncall-app.chatops:write`<br>`grafana-oncall-app.chatops:update-settings` | Read and update OnCall ChatOps. | -| `plugins:grafana-oncall-app:outgoing-webhooks-reader` | `plugins.app:access`<br>`grafana-oncall-app.outgoing-webhooks:read` | Read OnCall outgoing webhooks. | -| `plugins:grafana-oncall-app:outgoing-webhooks-editor` | `plugins.app:access`<br>`grafana-oncall-app.outgoing-webhooks:read`<br>`grafana-oncall-app.outgoing-webhooks:write` | Create, read, update and delete OnCall outgoing webhooks. | -| `plugins:grafana-oncall-app:maintenance-reader` | `plugins.app:access`<br>`grafana-oncall-app.maintenance:read` | Read OnCall maintenance. | -| `plugins:grafana-oncall-app:maintenance-editor` | `plugins.app:access`<br>`grafana-oncall-app.maintenance:read`<br>`grafana-oncall-app.maintenance:write` | Read and update OnCall maintenance. | -| `plugins:grafana-oncall-app:api-keys-reader` | `plugins.app:access`<br>`grafana-oncall-app.api-keys:read` | Read OnCall API keys. | -| `plugins:grafana-oncall-app:api-keys-editor` | `plugins.app:access`<br>`grafana-oncall-app.api-keys:read`<br>`grafana-oncall-app.api-keys:write` | Create, read, update and delete OnCall API keys. Also grants access to be able to consume the OnCall API. | -| `plugins:grafana-oncall-app:notification-settings-reader` | `plugins.app:access`<br>`grafana-oncall-app.notification-settings:read` | Read OnCall notification settings. | -| `plugins:grafana-oncall-app:notification-settings-editor` | `plugins.app:access`<br>`grafana-oncall-app.notification-settings:read`<br>`grafana-oncall-app.notification-settings:write` | Read and update OnCall notification settings. | -| `plugins:grafana-oncall-app:user-settings-reader` | `plugins.app:access`<br>`grafana-oncall-app.user-settings:read` | Read user's own OnCall user settings. | -| `plugins:grafana-oncall-app:user-settings-editor` | `plugins.app:access`<br>`grafana-oncall-app.user-settings:read`<br>`grafana-oncall-app.user-settings:write` | Read and update user's own OnCall user settings. | -| `plugins:grafana-oncall-app:user-settings-admin` | `plugins.app:access`<br>`grafana-oncall-app.user-settings:read`<br>`grafana-oncall-app.user-settings:write`<br>`grafana-oncall-app.user-settings:admin` | Read and update OnCall user settings for all users. | -| `plugins:grafana-oncall-app:settings-reader` | `plugins.app:access`<br>`grafana-oncall-app.other-settings:read` | Read OnCall settings. | -| `plugins:grafana-oncall-app:settings-editor` | `plugins.app:access`<br>`grafana-oncall-app.other-settings:read`<br>`grafana-oncall-app.other-settings:write` | Read and update OnCall settings. | +For a detailed list of the available OnCall RBAC roles, refer to the table in [Available Grafana OnCall RBAC roles and granted actions](https://grafana.com/docs/oncall/latest/user-and-team-management/#available-grafana-oncall-rbac-roles--granted-actions). The following table lists the default RBAC OnCall role assignments to the basic roles: diff --git a/docs/sources/administration/service-accounts/index.md b/docs/sources/administration/service-accounts/index.md index b22b4b440d259..4c3e71f63f3cf 100644 --- a/docs/sources/administration/service-accounts/index.md +++ b/docs/sources/administration/service-accounts/index.md @@ -22,7 +22,7 @@ weight: 800 You can use a service account to run automated workloads in Grafana, such as dashboard provisioning, configuration, or report generation. Create service accounts and tokens to authenticate applications, such as Terraform, with the Grafana API. {{% admonition type="note" %}} -Service accounts will eventually replace [API keys]({{< relref "../api-keys/" >}}) as the primary way to authenticate applications that interact with Grafana. +Service accounts replace [API keys]({{< relref "../api-keys/" >}}) as the primary way to authenticate applications that interact with Grafana. {{% /admonition %}} A common use case for creating a service account is to perform operations on automated or triggered tasks. You can use service accounts to: diff --git a/docs/sources/alerting/_index.md b/docs/sources/alerting/_index.md index b51a15f8ddb0f..0d341c0afe53d 100644 --- a/docs/sources/alerting/_index.md +++ b/docs/sources/alerting/_index.md @@ -1,8 +1,8 @@ --- aliases: - - about-alerting/ - - ./unified-alerting/alerting/ - - ./alerting/unified-alerting/ + - about-alerting/ # /docs/grafana/<GRAFANA_VERSION>/about-alerting + - ./unified-alerting/alerting/ # /docs/grafana/<GRAFANA_VERSION>/unified-alerting/alerting/ + - ./alerting/unified-alerting/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/ canonical: https://grafana.com/docs/grafana/latest/alerting/ description: Learn about the key benefits and features of Grafana Alerting labels: @@ -10,91 +10,57 @@ labels: - cloud - enterprise - oss -title: Alerting +menuTitle: Alerting +title: Grafana Alerting weight: 114 +hero: + title: Grafana Alerting + level: 1 + image: /media/docs/grafana-cloud/alerting-and-irm/grafana-icon-alerting.svg + width: 100 + height: 100 + description: Grafana Alerting allows you to learn about problems in your systems moments after they occur. +cards: + title_class: pt-0 lh-1 + items: + - title: Introduction + href: ./fundamentals/ + description: Learn more about the fundamentals and available features that help you create, manage, and respond to alerts; and improve your team’s ability to resolve issues quickly. + height: 24 + - title: Set up + href: ./set-up/ + description: Set up your implementation of Grafana Alerting. + height: 24 + - title: Configure alert rules + href: ./alerting-rules/ + description: Create, manage, view, and adjust alert rules to alert on your metrics data or log entries from multiple data sources — no matter where your data is stored. + height: 24 + - title: Configure notifications + href: ./configure-notifications/ + description: Choose how, when, and where to send your alert notifications. + height: 24 + - title: Detect and respond + href: ./manage-notifications/ + description: Monitor, respond to, and triage issues within your services. + height: 24 + - title: Monitor + href: ./monitor/ + description: Monitor your alerting metrics to ensure you identify potential issues before they become critical. + height: 24 --- -# Alerting +{{< docs/hero-simple key="hero" >}} -Grafana Alerting allows you to learn about problems in your systems moments after they occur. +--- + +## Overview -Monitor your incoming metrics data or log entries and set up your Alerting system to watch for specific events or circumstances and then send notifications when those things are found. +Monitor your incoming metrics data or log entries and set up your Grafana Alerting system to watch for specific events or circumstances. In this way, you eliminate the need for manual monitoring and provide a first line of defense against system outages or changes that could turn into major incidents. Using Grafana Alerting, you create queries and expressions from multiple data sources — no matter where your data is stored — giving you the flexibility to combine your data and alert on your metrics and logs in new and unique ways. You can then create, manage, and take action on your alerts from a single, consolidated view, and improve your team’s ability to identify and resolve issues quickly. -Grafana Alerting is available for Grafana OSS, Grafana Enterprise, or Grafana Cloud. With Mimir and Loki alert rules you can run alert expressions closer to your data and at massive scale, all managed by the Grafana UI you are already familiar with. - -Watch this video to learn more about Grafana Alerting: {{< vimeo 720001629 >}} - -_Refer to [Manage your alert rules][alerting-rules] for current instructions._ - -## Key features and benefits - -**One page for all alerts** - -A single Grafana Alerting page consolidates both Grafana-managed alerts and alerts that reside in your Prometheus-compatible data source in one single place. - -**Multi-dimensional alerts** - -Alert rules can create multiple individual alert instances per alert rule, known as multi-dimensional alerts, giving you the power and flexibility to gain visibility into your entire system with just a single alert rule. You do this by adding labels to your query to specify which component is being monitored and generate multiple alert instances for a single alert rule. For example, if you want to monitor each server in a cluster, a multi-dimensional alert will alert on each CPU, whereas a standard alert will alert on the overall server. - -**Route alerts** - -Route each alert instance to a specific contact point based on labels you define. Notification policies are the set of rules for where, when, and how the alerts are routed to contact points. - -**Silence alerts** - -Silences stop notifications from getting created and last for only a specified window of time. -Silences allow you to stop receiving persistent notifications from one or more alert rules. You can also partially pause an alert based on certain criteria. Silences have their own dedicated section for better organization and visibility, so that you can scan your paused alert rules without cluttering the main alerting view. - -**Mute timings** - -A mute timing is a recurring interval of time when no new notifications for a policy are generated or sent. Use them to prevent alerts from firing a specific and reoccurring period, for example, a regular maintenance period. - -Similar to silences, mute timings do not prevent alert rules from being evaluated, nor do they stop alert instances from being shown in the user interface. They only prevent notifications from being created. - -## Design your Alerting system - -Monitoring complex IT systems and understanding whether everything is up and running correctly is a difficult task. Setting up an effective alert management system is therefore essential to inform you when things are going wrong before they start to impact your business outcomes. - -Designing and configuring an alert management set up that works takes time. - -Here are some tips on how to create an effective alert management set up for your business: - -**Which are the key metrics for your business that you want to monitor and alert on?** - -- Find events that are important to know about and not so trivial or frequent that recipients ignore them. - -- Alerts should only be created for big events that require immediate attention or intervention. - -- Consider quality over quantity. - -**Which type of Alerting do you want to use?** - -- Choose between Grafana-managed Alerting or Grafana Mimir or Loki-managed Alerting; or both. - -**How do you want to organize your alerts and notifications?** - -- Be selective about who you set to receive alerts. Consider sending them to whoever is on call or a specific Slack channel. -- Automate as far as possible using the Alerting API or alerts as code (Terraform). - -**How can you reduce alert fatigue?** - -- Avoid noisy, unnecessary alerts by using silences, mute timings, or pausing alert rule evaluation. -- Continually tune your alert rules to review effectiveness. Remove alert rules to avoid duplication or ineffective alerts. -- Think carefully about priority and severity levels. -- Continually review your thresholds and evaluation rules. - -## Useful links - -- [Introduction to Alerting][fundamentals] - -{{% docs/reference %}} -[alerting-rules]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/alerting-rules" -[alerting-rules]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules" +## Explore -[fundamentals]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals" -[fundamentals]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals" -{{% /docs/reference %}} +{{< card-grid key="cards" type="simple" >}} diff --git a/docs/sources/alerting/alerting-rules/_index.md b/docs/sources/alerting/alerting-rules/_index.md index ac9a03150f5a7..d4ab108d9e1e6 100644 --- a/docs/sources/alerting/alerting-rules/_index.md +++ b/docs/sources/alerting/alerting-rules/_index.md @@ -1,58 +1,48 @@ --- aliases: - - old-alerting/create-alerts/ - - rules/ - - unified-alerting/alerting-rules/ - - ./create-alerts/ + - rules/ # /docs/grafana/<GRAFANA_VERSION>/alerting/rules/ + - unified-alerting/alerting-rules/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/alerting-rules/ + - ./create-alerts/ # /docs/grafana/<GRAFANA_VERSION>/alerting/create-alerts/ canonical: https://grafana.com/docs/grafana/latest/alerting/alerting-rules/ -description: Configure the features and integrations you need to create and manage your alerts +description: Configure alert rules labels: products: - cloud - enterprise - oss -menuTitle: Configure -title: Configure Alerting +title: Configure alert rules weight: 120 --- -# Configure Alerting +# Configure alert rules -Configure the features and integrations that you need to create and manage your alerts. +An alert rule consists of one or more queries and expressions that select the data you want to measure. It also contains a condition, which is the threshold that an alert rule must meet or exceed in order to fire. -**Configure alert rules** +Create, manage, view, and adjust alert rules to alert on your metrics data or log entries from multiple data sources — no matter where your data is stored. -[Configure Grafana-managed alert rules][create-grafana-managed-rule]. +The main parts of alert rule creation are: -[Configure data source-managed alert rules][create-mimir-loki-managed-rule] +1. Select your data source +1. Query your data +1. Normalize your data +1. Set your threshold -**Configure recording rules** +**Query, expressions, and alert condition** -_Recording rules are only available for compatible Prometheus or Loki data sources._ +What are you monitoring? How are you measuring it? -For more information, see [Configure recording rules][create-mimir-loki-managed-recording-rule]. +{{< admonition type="note" >}} +Expressions can only be used for Grafana-managed alert rules. +{{< /admonition >}} -**Configure contact points** +**Evaluation** -For information on how to configure contact points, see [Configure contact points][manage-contact-points]. +How do you want your alert to be evaluated? -**Configure notification policies** +**Labels and notifications** -For information on how to configure notification policies, see [Configure notification policies][create-notification-policy]. +How do you want to route your alert? What kind of additional labels could you add to annotate your alert rules and ease searching? -{{% docs/reference %}} -[create-mimir-loki-managed-rule]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/alerting-rules/create-mimir-loki-managed-rule" -[create-mimir-loki-managed-rule]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/create-mimir-loki-managed-rule" +**Annotations** -[create-mimir-loki-managed-recording-rule]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/alerting-rules/create-mimir-loki-managed-recording-rule" -[create-mimir-loki-managed-recording-rule]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/create-mimir-loki-managed-recording-rule" - -[create-grafana-managed-rule]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/alerting-rules/create-grafana-managed-rule" -[create-grafana-managed-rule]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/create-grafana-managed-rule" - -[manage-contact-points]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/alerting-rules/manage-contact-points" -[manage-contact-points]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/manage-contact-points" - -[create-notification-policy]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/alerting-rules/create-notification-policy" -[create-notification-policy]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/create-notification-policy" -{{% /docs/reference %}} +Do you want to add more context on the alert in your notification messages, for example, what caused the alert to fire? Which server did it happen on? diff --git a/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md b/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md index c320b76bb37b1..d5712ba87e41b 100644 --- a/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md +++ b/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md @@ -1,6 +1,6 @@ --- aliases: - - ../unified-alerting/alerting-rules/create-grafana-managed-rule/ + - ../unified-alerting/alerting-rules/create-grafana-managed-rule/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/alerting-rules/create-grafana-managed-rule/ canonical: https://grafana.com/docs/grafana/latest/alerting/alerting-rules/create-grafana-managed-rule/ description: Configure Grafana-managed alert rules to create alerts that can act on data from any of our supported data sources keywords: @@ -38,8 +38,6 @@ Grafana managed alert rules can only be edited or deleted by users with Edit per If you delete an alerting resource created in the UI, you can no longer retrieve it. To make a backup of your configuration and to be able to restore deleted alerting resources, create your alerting resources using file provisioning, Terraform, or the Alerting API. -Watch this video to learn more about creating alert rules: {{< vimeo 720001934 >}} - In the following sections, we’ll guide you through the process of creating your Grafana-managed alert rules. To create a Grafana-managed alert rule, use the in-product alert creation flow and follow these steps to help you. @@ -71,6 +69,7 @@ Define a query to get the data you want to measure and a condition that needs to All alert rules are managed by Grafana by default. If you want to switch to a data source-managed alert rule, click **Switch to data source-managed alert rule**. 1. Add one or more [expressions][expression-queries]. + a. For each expression, select either **Classic condition** to create a single alert rule, or choose from the **Math**, **Reduce**, and **Resample** options to generate separate alert for each series. {{% admonition type="note" %}} @@ -79,6 +78,10 @@ Define a query to get the data you want to measure and a condition that needs to b. Click **Preview** to verify that the expression is successful. +1. To add a recovery threshold, turn the **Custom recovery threshold** toggle on and fill in a value for when your alert rule should stop firing. + + You can only add one recovery threshold in a query and it must be the alert condition. + 1. Click **Set as alert condition** on the query or expression you want to set as your alert condition. ## Set alert evaluation behavior @@ -102,56 +105,109 @@ To do this, you need to make sure that your alert rule is in the right evaluatio 1. Turn on pause alert notifications, if required. - **Note**: - - Pause alert rule evaluation to prevent noisy alerting while tuning your alerts. Pausing stops alert rule evaluation and does not create any alert instances. This is different to mute timings, which stop notifications from being delivered, but still allow for alert rule evaluation and the creation of alert instances. - - You can pause alert rule evaluation to prevent noisy alerting while tuning your alerts. Pausing stops alert rule evaluation and does not create any alert instances. This is different to mute timings, which stop notifications from being delivered, but still allow for alert rule evaluation and the creation of alert instances. + {{< admonition type="note" >}} + You can pause alert rule evaluation to prevent noisy alerting while tuning your alerts. + Pausing stops alert rule evaluation and doesn't create any alert instances. + This is different to mute timings, which stop notifications from being delivered, but still allows for alert rule evaluation and the creation of alert instances. + {{< /admonition >}} 1. In **Configure no data and error handling**, configure alerting behavior in the absence of data. Use the guidelines in [No data and error handling](#configure-no-data-and-error-handling). -## Add annotations +## Configure notifications -Add [annotations][annotation-label]. to provide more context on the alert in your alert notifications. +{{< admonition type="note" >}} +To try out a simplified version of routing your alerts, enable the alertingSimplifiedRouting feature toggle and refer to the following section Configure notifications (simplified). +{{< /admonition >}} -Annotations add metadata to provide more information on the alert in your alert notifications. For example, add a **Summary** annotation to tell you which value caused the alert to fire or which server it happened on. +1. Add labels to your alert rules to set which notification policy should handle your firing alert instances. -1. [Optional] Add a summary. + All alert rules and instances, irrespective of their labels, match the default notification policy. If there are no nested policies, or no nested policies match the labels in the alert rule or alert instance, then the default notification policy is the matching policy. - Short summary of what happened and why. + Add labels if you want to change the way your notifications are routed. -2. [Optional] Add a description. + Add custom labels by selecting existing key-value pairs from the drop down, or add new labels by entering the new key or value. - Description of what the alert rule does. +1. Preview your alert instance routing set up. -3. [Optional] Add a Runbook URL. + Based on the labels added, alert instances are routed to the following notification policies displayed. - Webpage where you keep your runbook for the alert + Expand each notification policy below to view more details. -4. [Optional] Add a custom annotation -5. [Optional] Add a dashboard and panel link. +1. Click See details to view alert routing details and an email preview. - Links alerts to panels in a dashboard. +1. Click **Save rule**. -## Configure notifications +## Configure notifications (simplified) + +{{< admonition type="note" >}} +To try this out, enable the alertingSimplifiedRouting feature toggle. -Add labels to your alert rules to set which notification policy should handle your firing alert instances. +This feature is currently not available for Grafana Cloud. +{{< /admonition >}} -All alert rules and instances, irrespective of their labels, match the default notification policy. If there are no nested policies, or no nested policies match the labels in the alert rule or alert instance, then the default notification policy is the matching policy. +In the **Labels** section, you can optionally choose whether to add labels to organize your alert rules, make searching easier, as well as set which notification policy should handle your firing alert instance. -1. Add labels if you want to change the way your notifications are routed. +In the **Configure notifications** section, you can choose to select a contact point directly from the alert rule form or choose to use notification policy routing as well as set up mute timings and groupings. + +Complete the following steps to set up labels and notifications. + +1. Add labels, if required. Add custom labels by selecting existing key-value pairs from the drop down, or add new labels by entering the new key or value. -1. Preview your alert instance routing set up. +2. Configure who receives a notification when an alert rule fires by either choosing **Select contact point** or **Use notification policy**. - Based on the labels added, alert instances are routed to the following notification policies displayed. + **Select contact point** - Expand each notification policy below to view more details. + 1. Choose this option to select an existing contact point. + + All notifications for this alert rule are sent to this contact point automatically and notification policies are not used. -1. Click **See details** to view alert routing details and an email preview. + 2. You can also optionally select a mute timing as well as groupings and timings to define when not to send notifications. + + {{< admonition type="note" >}} + An auto-generated notification policy is generated. Only admins can view these auto-generated policies from the **Notification policies** list view. Any changes have to be made in the alert rules form. {{< /admonition >}} + + **Use notification policy** + + 3. Choose this option to use the notification policy tree to direct your notifications. + + {{< admonition type="note" >}} + All alert rules and instances, irrespective of their labels, match the default notification policy. If there are no nested policies, or no nested policies match the labels in the alert rule or alert instance, then the default notification policy is the matching policy. + {{< /admonition >}} + + 4. Preview your alert instance routing set up. + + Based on the labels added, alert instances are routed to the following notification policies displayed. + + 5. Expand each notification policy below to view more details. + + 6. Click **See details** to view alert routing details and an email preview. + +## Add annotations + +Add [annotations][annotation-label]. to provide more context on the alert in your alert notification message. + +Annotations add metadata to provide more information on the alert in your alert notification message. For example, add a **Summary** annotation to tell you which value caused the alert to fire or which server it happened on. + +1. [Optional] Add a summary. + + Short summary of what happened and why. + +1. [Optional] Add a description. + + Description of what the alert rule does. + +1. [Optional] Add a Runbook URL. + + Webpage where you keep your runbook for the alert + +1. [Optional] Add a custom annotation +1. [Optional] Add a dashboard and panel link. + + Links alerts to panels in a dashboard. 1. Click **Save rule**. @@ -169,7 +225,7 @@ For more information, see [expressions documentation][expression-queries]. To generate a separate alert for each series, create a multi-dimensional rule. Use `Math`, `Reduce`, or `Resample` expressions to create a multi-dimensional rule. For example: -- Add a `Reduce` expression for each query to aggregate values in the selected time range into a single value. (Not needed for [rules using numeric data][alerting-on-numeric-data]. +- Add a `Reduce` expression for each query to aggregate values in the selected time range into a single value. (Not needed for [rules using numeric data][alerting-on-numeric-data]). - Add a `Math` expression with the condition for the rule. Not needed in case a query or a reduce expression already returns 0 if rule should not fire, or a positive number if it should fire. Some examples: `$B > 70` if it should fire in case value of B query/expression is more than 70. `$B < $C * 100` in case it should fire if value of B is less than value of C multiplied by 100. If queries being compared have multiple series in their results, series from different queries are matched if they have the same labels or one is a subset of the other. ![Query section multi dimensional](/static/img/docs/alerting/unified/rule-edit-multi-8-0.png 'Query section multi dimensional screenshot') @@ -215,21 +271,21 @@ Create alerts from any panel type. This means you can reuse the queries in the p This will open the alert rule form, allowing you to configure and create your alert based on the current panel's query. {{% docs/reference %}} -[add-a-query]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/query-transform-data#add-a-query" -[add-a-query]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/query-transform-data#add-a-query" +[add-a-query]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data#add-a-query" +[add-a-query]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data#add-a-query" -[alerting-on-numeric-data]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals/evaluate-grafana-alerts#alerting-on-numeric-data-1" -[alerting-on-numeric-data]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/evaluate-grafana-alerts#alerting-on-numeric-data-1" +[alerting-on-numeric-data]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/queries-conditions#alert-on-numeric-data" +[alerting-on-numeric-data]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/queries-conditions#alert-on-numeric-data" -[annotation-label]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals/annotation-label" -[annotation-label]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/annotation-label" +[annotation-label]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/annotation-label" +[annotation-label]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label" -[expression-queries]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/query-transform-data/expression-queries" -[expression-queries]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/query-transform-data/expression-queries" +[expression-queries]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/expression-queries" +[expression-queries]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/expression-queries" -[fundamentals]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals" +[fundamentals]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals" [fundamentals]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals" -[time-units-and-relative-ranges]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/use-dashboards#time-units-and-relative-ranges" -[time-units-and-relative-ranges]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/use-dashboards#time-units-and-relative-ranges" +[time-units-and-relative-ranges]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/dashboards/use-dashboards#time-units-and-relative-ranges" +[time-units-and-relative-ranges]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/use-dashboards#time-units-and-relative-ranges" {{% /docs/reference %}} diff --git a/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md b/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md index 77d206f8e61ea..494f92edda015 100644 --- a/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md +++ b/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md @@ -1,9 +1,9 @@ --- aliases: - - ../unified-alerting/alerting-rules/create-cortex-loki-managed-recording-rule/ - - ../unified-alerting/alerting-rules/create-mimir-loki-managed-recording-rule/ + - ../unified-alerting/alerting-rules/create-cortex-loki-managed-recording-rule/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/alerting-rules/create-cortex-loki-managed-recording-rule/ + - ../unified-alerting/alerting-rules/create-mimir-loki-managed-recording-rule/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/alerting-rules/create-mimir-loki-managed-recording-rule/ canonical: https://grafana.com/docs/grafana/latest/alerting/alerting-rules/create-mimir-loki-managed-recording-rule/ -description: Configure recording rules for an external Grafana Mimir or Loki instance +description: Create recording rules for an external Grafana Mimir or Loki instance keywords: - grafana - alerting @@ -16,13 +16,16 @@ labels: - cloud - enterprise - oss -title: Configure recording rules +title: Create recording rules weight: 300 --- -# Configure recording rules +# Create recording rules -You can create and manage recording rules for an external Grafana Mimir or Loki instance. Recording rules calculate frequently needed expressions or computationally expensive expressions in advance and save the result as a new set of time series. Querying this new time series is faster, especially for dashboards since they query the same expression every time the dashboards refresh. +You can create and manage recording rules for an external Grafana Mimir or Loki instance. +Recording rules calculate frequently needed expressions or computationally expensive expressions in advance and save the result as a new set of time series. Querying this new time series is faster, especially for dashboards since they query the same expression every time the dashboards refresh. + +For more information on recording rules in Prometheus, refer to [Defining recording rules in Prometheus](https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/). **Note:** @@ -48,13 +51,14 @@ To create recording rules, follow these steps. 1. Click **Alerts & IRM** -> **Alerting** -> **Alert rules**. -1. Click the **More** dropdown and then **New recording rule**. +1. Select **Rule type** -> **Recording**. +1. Click **+New recording rule**. -1. Set rule name. +1. Enter recording rule name. The recording rule name must be a Prometheus metric name and contain no whitespace. -1. Define query. +1. Define recording rule. - Select your Loki or Prometheus data source. - Enter a query. 1. Add namespace and group. @@ -65,9 +69,8 @@ To create recording rules, follow these steps. 1. Click **Save rule** to save the rule or **Save rule and exit** to save the rule and go back to the Alerting page. {{% docs/reference %}} -[annotation-label]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals/annotation-label" -[annotation-label]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/annotation-label" +[annotation-label]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/annotation-label" +[annotation-label]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label" -[configure-grafana]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana" -[configure-grafana]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana" +[configure-grafana]: "/docs/ -> /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana" {{% /docs/reference %}} diff --git a/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-rule.md b/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-rule.md index 496f96728cadb..0abd6c5550bab 100644 --- a/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-rule.md +++ b/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-rule.md @@ -1,8 +1,8 @@ --- aliases: - - ../unified-alerting/alerting-rules/create-cortex-loki-managed-recording-rule/ - - ../unified-alerting/alerting-rules/create-mimir-loki-managed-recording-rule/ - - ../unified-alerting/alerting-rules/create-mimir-loki-managed-rule/ + - ../unified-alerting/alerting-rules/create-cortex-loki-managed-recording-rule/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/alerting-rules/create-cortex-loki-managed-recording-rule/ + - ../unified-alerting/alerting-rules/create-mimir-loki-managed-recording-rule/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/alerting-rules/create-mimir-loki-managed-recording-rule/ + - ../unified-alerting/alerting-rules/create-mimir-loki-managed-rule/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/alerting-rules/create-mimir-loki-managed-rule/ canonical: https://grafana.com/docs/grafana/latest/alerting/alerting-rules/create-mimir-loki-managed-rule/ description: Configure data source-managed alert rules alert for an external Grafana Mimir or Loki instance keywords: @@ -90,6 +90,16 @@ Use alert rule evaluation to determine how frequently an alert rule should be ev Once a condition is met, the alert goes into the **Pending** state. If the condition remains active for the duration specified, the alert transitions to the **Firing** state, else it reverts to the **Normal** state. +## Configure notifications + +Add labels to your alert rules to set which notification policy should handle your firing alert instances. + +All alert rules and instances, irrespective of their labels, match the default notification policy. If there are no nested policies, or no nested policies match the labels in the alert rule or alert instance, then the default notification policy is the matching policy. + +1. Add labels if you want to change the way your notifications are routed. + + Add custom labels by selecting existing key-value pairs from the drop down, or add new labels by entering the new key or value. + ## Add annotations Add [annotations][annotation-label]. to provide more context on the alert in your alert notifications. @@ -100,35 +110,25 @@ Annotations add metadata to provide more information on the alert in your alert Short summary of what happened and why. -2. [Optional] Add a description. +1. [Optional] Add a description. Description of what the alert rule does. -3. [Optional] Add a Runbook URL. +1. [Optional] Add a Runbook URL. Webpage where you keep your runbook for the alert -4. [Optional] Add a custom annotation -5. [Optional] Add a dashboard and panel link. +1. [Optional] Add a custom annotation +1. [Optional] Add a dashboard and panel link. Links alerts to panels in a dashboard. -## Configure notifications - -Add labels to your alert rules to set which notification policy should handle your firing alert instances. - -All alert rules and instances, irrespective of their labels, match the default notification policy. If there are no nested policies, or no nested policies match the labels in the alert rule or alert instance, then the default notification policy is the matching policy. - -1. Add labels if you want to change the way your notifications are routed. - - Add custom labels by selecting existing key-value pairs from the drop down, or add new labels by entering the new key or value. - 1. Click **Save rule**. {{% docs/reference %}} -[alerting]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting" +[alerting]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting" [alerting]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting" -[annotation-label]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals/annotation-label" -[annotation-label]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/annotation-label" +[annotation-label]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/annotation-label" +[annotation-label]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label" {{% /docs/reference %}} diff --git a/docs/sources/alerting/alerting-rules/manage-contact-points/_index.md b/docs/sources/alerting/alerting-rules/manage-contact-points/_index.md deleted file mode 100644 index 186c487914b5a..0000000000000 --- a/docs/sources/alerting/alerting-rules/manage-contact-points/_index.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -aliases: - - ../contact-points/ # /docs/grafana/<GRAFANA_VERSION>/alerting/contact-points/ - - ../contact-points/create-contact-point/ # /docs/grafana/<GRAFANA_VERSION>/alerting/contact-points/create-contact-point/ - - ../contact-points/delete-contact-point/ # /docs/grafana/<GRAFANA_VERSION>/alerting/contact-points/delete-contact-point/ - - ../contact-points/edit-contact-point/ # /docs/grafana/<GRAFANA_VERSION>/alerting/contact-points/edit-contact-point/ - - ../contact-points/test-contact-point/ # /docs/grafana/<GRAFANA_VERSION>/alerting/contact-points/test-contact-point/ - - ../manage-notifications/manage-contact-points/ # /docs/grafana/<GRAFANA_VERSION>/alerting/manage-notifications/manage-contact-points/ - - create-contact-point/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/create-contact-point/ -canonical: https://grafana.com/docs/grafana/latest/alerting/alerting-rules/manage-contact-points/ -description: Configure contact points to define how your contacts are notified when an alert rule fires -keywords: - - grafana - - alerting - - guide - - contact point - - templating -labels: - products: - - cloud - - enterprise - - oss -title: Configure contact points -weight: 410 ---- - -# Configure contact points - -Use contact points to define how your contacts are notified when an alert rule fires. You can add, edit, delete, and test a contact point. - -## Add a contact point - -Complete the following steps to add a contact point. - -1. In the left-side menu, click **Alerts & IRM** and then **Alerting**. -1. Click **Contact points**. -1. From the **Choose Alertmanager** dropdown, select an Alertmanager. By default, **Grafana Alertmanager** is selected. -1. On the **Contact Points** tab, click **+ Add contact point**. -1. Enter a descriptive name for the contact point. -1. From **Integration**, select a type and fill out mandatory fields. For example, if you choose email, enter the email addresses. Or if you choose Slack, enter the Slack channel(s) and users who should be contacted. -1. Some contact point integrations, like email or webhook, have optional settings. In **Optional settings**, specify additional settings for the selected contact point integration. -1. In Notification settings, optionally select **Disable resolved message** if you do not want to be notified when an alert resolves. -1. To add another contact point integration, click **Add contact point integration** and repeat steps 6 through 8. -1. Save your changes. - -## Edit a contact point - -Complete the following steps to edit a contact point. - -1. In the left-side menu, click **Alerts & IRM** and then **Alerting**. -1. Click **Contact points** to view a list of existing contact points. -1. On the **Contact Points** tab, find the contact point you want to edit, and then click **Edit**. -1. Update the contact point and save your changes. - -## Delete a contact point - -Complete the following steps to delete a contact point. - -1. In the left-side menu, click **Alerts & IRM** and then **Alerting**. -1. Click **Contact points** to view a list of existing contact points. -1. On the **Contact Points** tab, find the contact point you want to delete, and then click **More** -> **Delete**. -1. In the confirmation dialog, click **Yes, delete**. - -{{% admonition type="note" %}} -You cannot delete contact points that are in use by a notification policy. Either delete the notification policy or update it to use another contact point. -{{% /admonition %}} - -## Test a contact point - -Complete the following steps to test a contact point. - -1. In the left-side menu, click **Alerts & IRM** and then **Alerting**. -1. Click **Contact points** to view a list of existing contact points. -1. On the **Contact Points** tab, find the contact point you want to test, then click **Edit**. You can also create a new contact point if needed. -1. Click **Test** to open the contact point testing modal. -1. Choose whether to send a predefined test notification or choose custom to add your own custom annotations and labels to include in the notification. -1. Click **Send test notification** to fire the alert. diff --git a/docs/sources/alerting/alerting-rules/manage-contact-points/integrations/_index.md b/docs/sources/alerting/alerting-rules/manage-contact-points/integrations/_index.md deleted file mode 100644 index dfd1efd3c6dd0..0000000000000 --- a/docs/sources/alerting/alerting-rules/manage-contact-points/integrations/_index.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -aliases: - - alerting/manage-notifications/manage-contact-points/configure-integrations/ -canonical: https://grafana.com/docs/grafana/latest/alerting/alerting-rules/manage-contact-points/integrations/ -description: Configure contact point integrations to select your preferred communication channels for receiving notifications of firing alerts. -keywords: - - Grafana - - alerting - - guide - - notifications - - integrations - - contact points -labels: - products: - - cloud - - enterprise - - oss -title: Configure contact point integrations -weight: 100 ---- - -# Configure contact point integrations - -Configure contact point integrations in Grafana to select your preferred communication channel for receiving notifications when your alert rules are firing. Each integration has its own configuration options and setup process. In most cases, this involves providing an API key or a Webhook URL. - -Once configured, you can use integrations as part of your contact points to receive notifications whenever your alert changes its state. In this section, we'll cover the basic steps to configure your integrations, so you can start receiving real-time alerts and stay on top of your monitoring data. - -## List of supported integrations - -| Name | Type | -| ----------------------- | ------------------------- | -| DingDing | `dingding` | -| Discord | `discord` | -| Email | `email` | -| Google Chat | `googlechat` | -| Hipchat | `hipchat` | -| Kafka | `kafka` | -| Line | `line` | -| Microsoft Teams | `teams` | -| Opsgenie | `opsgenie` | -| Pagerduty | `pagerduty` | -| Prometheus Alertmanager | `prometheus-alertmanager` | -| Pushover | `pushover` | -| Sensu | `sensu` | -| Sensu Go | `sensugo` | -| Slack | `slack` | -| Telegram | `telegram` | -| Threema | `threema` | -| VictorOps | `victorops` | -| Webhook | `webhook` | diff --git a/docs/sources/alerting/fundamentals/annotation-label/variables-label-annotation.md b/docs/sources/alerting/alerting-rules/templating-labels-annotations.md similarity index 93% rename from docs/sources/alerting/fundamentals/annotation-label/variables-label-annotation.md rename to docs/sources/alerting/alerting-rules/templating-labels-annotations.md index c51bf4f4cd7a3..008cefecd8659 100644 --- a/docs/sources/alerting/fundamentals/annotation-label/variables-label-annotation.md +++ b/docs/sources/alerting/alerting-rules/templating-labels-annotations.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/annotation-label/variables-label-annotation/ +aliases: + - ../fundamentals/annotation-label/variables-label-annotation/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/annotation-label/variables-label-annotation/ +canonical: https://grafana.com/docs/grafana/latest/alerting/alerting-rules/templating-labels-annotations/ description: Learn about how to template labels and annotations keywords: - grafana @@ -13,13 +15,15 @@ labels: - enterprise - oss title: Templating labels and annotations -weight: 117 +weight: 500 --- # Templating labels and annotations You can use templates to include data from queries and expressions in labels and annotations. For example, you might want to set the severity label for an alert based on the value of the query, or use the instance label from the query in a summary annotation so you know which server is experiencing high CPU usage. +When using custom labels with templates it is important to make sure that the label value does not change between consecutive evaluations of the alert rule as this will end up creating large numbers of distinct alerts. However, it is OK for the template to produce different label values for different alerts. For example, do not put the value of the query in a custom label as this will end up creating a new set of alerts each time the value changes. Instead use annotations. + All templates should be written in [text/template](https://pkg.go.dev/text/template). Regardless of whether you are templating a label or an annotation, you should write each template inline inside the label or annotation that you are templating. This means you cannot share templates between labels and annotations, and instead you will need to copy templates wherever you want to use them. Each template is evaluated whenever the alert rule is evaluated, and is evaluated for every alert separately. For example, if your alert rule has a templated summary annotation, and the alert rule has 10 firing alerts, then the template will be executed 10 times, once for each alert. You should try to avoid doing expensive computations in your templates as much as possible. @@ -225,7 +229,7 @@ The `$value` variable is a string containing the labels and values of all instan To print the `$value` variable in the summary you would write something like this: ``` -CPU usage for {{ index $labels "instance" }} has exceeded 80% for the last 5 minutes: {{ $value }}) +CPU usage for {{ index $labels "instance" }} has exceeded 80% for the last 5 minutes: {{ $value }} ``` And would look something like this: @@ -245,7 +249,7 @@ The `$values` variable is a table containing the labels and floating point value To print the value of the instant query with Ref ID A: ``` -CPU usage for {{ index $labels "instance" }} has exceeded 80% for the last 5 minutes: {{ index $values "A" }}) +CPU usage for {{ index $labels "instance" }} has exceeded 80% for the last 5 minutes: {{ index $values "A" }} ``` For example, given an alert with the labels `instance=server1` and an instant query with the value `81.2345`, this would print: @@ -257,7 +261,7 @@ CPU usage for instance1 has exceeded 80% for the last 5 minutes: 81.2345 If the query in Ref ID A is a range query rather than an instant query then add a reduce expression with Ref ID B and replace `(index $values "A")` with `(index $values "B")`: ``` -CPU usage for {{ index $labels "instance" }} has exceeded 80% for the last 5 minutes: {{ index $values "B" }}) +CPU usage for {{ index $labels "instance" }} has exceeded 80% for the last 5 minutes: {{ index $values "B" }} ``` ## Functions @@ -445,6 +449,5 @@ example.com:8080 ``` {{% docs/reference %}} -[explore]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/explore" -[explore]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/explore" +[explore]: "/docs/ -> /docs/grafana/<GRAFANA_VERSION>/explore" {{% /docs/reference %}} diff --git a/docs/sources/alerting/configure-notifications/_index.md b/docs/sources/alerting/configure-notifications/_index.md new file mode 100644 index 0000000000000..f709efed8fa25 --- /dev/null +++ b/docs/sources/alerting/configure-notifications/_index.md @@ -0,0 +1,26 @@ +--- +canonical: https://grafana.com/docs/grafana/latest/alerting/configure-notifications +description: Configure how, when, and where to send your alert notifications +keywords: + - grafana + - alert + - notifications +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Configure notifications +title: Configure notifications +weight: 125 +--- + +# Configure notifications + +Choose how, when, and where to send your alert notifications. + +As a first step, define your contact points; where to send your alert notifications to. A contact point is a set of one or more integrations that are used to deliver notifications. + +Next, create a notification policy which is a set of rules for where, when and how your alerts are routed to contact points. In a notification policy, you define where to send your alert notifications by choosing one of the contact points you created. + +Optionally, you can add notification templates to contact points for reuse and consistent messaging in your notifications. diff --git a/docs/sources/alerting/alerting-rules/create-notification-policy.md b/docs/sources/alerting/configure-notifications/create-notification-policy.md similarity index 89% rename from docs/sources/alerting/alerting-rules/create-notification-policy.md rename to docs/sources/alerting/configure-notifications/create-notification-policy.md index 8e0d09b1e0123..bcd9d8a80642e 100644 --- a/docs/sources/alerting/alerting-rules/create-notification-policy.md +++ b/docs/sources/alerting/configure-notifications/create-notification-policy.md @@ -1,9 +1,10 @@ --- aliases: - - ../notifications/ - - ../old-alerting/notifications/ - - ../unified-alerting/notifications/ -canonical: https://grafana.com/docs/grafana/latest/alerting/alerting-rules/create-notification-policy/ + - ../notifications/ # /docs/grafana/<GRAFANA_VERSION>/alerting/notifications/ + - ../unified-alerting/notifications/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/notifications/ + - ../alerting-rules/create-notification-policy/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/create-notification-policy/ + - ../manage-notifications/create-notification-policy/ # /docs/grafana/<GRAFANA_VERSION>/alerting/manage-notifications/create-notification-policy/ +canonical: https://grafana.com/docs/grafana/latest/alerting/configure-notifications/create-notification-policy/ description: Configure notification policies to determine how alerts are routed to contact points keywords: - grafana @@ -36,10 +37,6 @@ For more information on notification policies, see [fundamentals of Notification ## Edit default notification policy -{{% admonition type="note" %}} -Before Grafana v8.2, the configuration of the embedded Alertmanager was shared across organizations. Users of Grafana 8.0 and 8.1 are advised to use the new Grafana 8 Alerts only if they have one organization. Otherwise, silences for the Grafana managed alerts will be visible by all organizations. -{{% /admonition %}} - 1. In the left-side menu, click **Alerts & IRM** and then **Alerting**. 1. Click **Notification policies**. 1. From the **Choose Alertmanager** dropdown, select an external Alertmanager. By default, the **Grafana Alertmanager** is selected. @@ -111,6 +108,6 @@ An example of an alert configuration. - Create specific routes for particular teams that handle their own on-call rotations. {{% docs/reference %}} -[notification-policies]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals/notification-policies" -[notification-policies]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notification-policies" +[notification-policies]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/notifications/notification-policies" +[notification-policies]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notifications/notification-policies" {{% /docs/reference %}} diff --git a/docs/sources/alerting/manage-notifications/create-silence.md b/docs/sources/alerting/configure-notifications/create-silence.md similarity index 71% rename from docs/sources/alerting/manage-notifications/create-silence.md rename to docs/sources/alerting/configure-notifications/create-silence.md index 3b0d544360ead..01d9062ea221a 100644 --- a/docs/sources/alerting/manage-notifications/create-silence.md +++ b/docs/sources/alerting/configure-notifications/create-silence.md @@ -1,12 +1,13 @@ --- aliases: - - ../silences/create-silence/ - - ../silences/edit-silence/ - - ../silences/linking-to-silence-form/ - - ../silences/remove-silence/ - - ../unified-alerting/silences/ - - ../silences/ -canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/create-silence/ + - ../silences/create-silence/ # /docs/grafana/<GRAFANA_VERSION>/alerting/silences/create-silence/ + - ../silences/edit-silence/ # /docs/grafana/<GRAFANA_VERSION>/alerting/silences/edit-silence/ + - ../silences/linking-to-silence-form/ # /docs/grafana/<GRAFANA_VERSION>/alerting/silences/linking-to-silence-form/ + - ../silences/remove-silence/ # /docs/grafana/<GRAFANA_VERSION>/alerting/silences/remove-silence/ + - ../unified-alerting/silences/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/silences/ + - ../silences/ # /docs/grafana/<GRAFANA_VERSION>/alerting/silences/ + - ../manage-notifications/create-silence/ # /docs/grafana/<GRAFANA_VERSION>/alerting/manage-notifications/create-silence/ +canonical: https://grafana.com/docs/grafana/latest/alerting/configure-notifications/create-silence/ description: Create silences to stop notifications from getting created for a specified window of time keywords: - grafana @@ -18,15 +19,19 @@ labels: - cloud - enterprise - oss -title: Manage silences -weight: 410 +title: Configure silences +weight: 440 --- -# Manage silences +# Configure silences Silences stop notifications from getting created and last for only a specified window of time. -**Note that inhibition rules are not supported in the Grafana Alertmanager.** +{{< admonition type="note" >}} + +- Inhibition rules are not supported in the Grafana Alertmanager. +- The preview of silenced alerts only applies to alerts in firing state. + {{< /admonition >}} ## Add silences @@ -38,7 +43,7 @@ To add a silence, complete the following steps. 1. Click **Create silence** to open the Create silence page. 1. In **Silence start and end**, select the start and end date to indicate when the silence should go into effect and expire. 1. Optionally, in **Duration**, specify how long the silence is enforced. This automatically updates the end time in the **Silence start and end** field. -1. In the **Label** and **Value** fields, enter one or more _Matching Labels_. Matchers determine which rules the silence will apply to. +1. In the **Label** and **Value** fields, enter one or more _Matching Labels_. Matchers determine which rules the silence will apply to. Any matching alerts (in firing state) will show in the **Affected alert instances** field 1. In **Comment**, add details about the silence. 1. Click **Submit**. diff --git a/docs/sources/alerting/configure-notifications/manage-contact-points/_index.md b/docs/sources/alerting/configure-notifications/manage-contact-points/_index.md new file mode 100644 index 0000000000000..0bb2064e0a829 --- /dev/null +++ b/docs/sources/alerting/configure-notifications/manage-contact-points/_index.md @@ -0,0 +1,144 @@ +--- +aliases: + - ../contact-points/create-contact-point/ # /docs/grafana/<GRAFANA_VERSION>/alerting/contact-points/create-contact-point/ + - ../contact-points/delete-contact-point/ # /docs/grafana/<GRAFANA_VERSION>/alerting/contact-points/delete-contact-point/ + - ../contact-points/edit-contact-point/ # /docs/grafana/<GRAFANA_VERSION>/alerting/contact-points/edit-contact-point/ + - ../contact-points/test-contact-point/ # /docs/grafana/<GRAFANA_VERSION>/alerting/contact-points/test-contact-point/ + - ../manage-notifications/manage-contact-points/ # /docs/grafana/<GRAFANA_VERSION>/alerting/manage-notifications/manage-contact-points/ + - ../alerting-rules/create-contact-point/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/create-contact-point/ + - ../alerting-rules/manage-contact-points/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/manage-contact-points/ + - ../alerting-rules/create-notification-policy/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/create-notification-policy/ + - ../alerting-rules/manage-contact-points/integrations/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/manage-contact-points/integrations/ + - ../manage-notifications/manage-contact-points/ # /docs/grafana/<GRAFANA_VERSION>/alerting/manage-notifications/manage-contact-points/ +canonical: https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/ +description: Configure contact points to define how your contacts are notified when an alert rule fires +keywords: + - grafana + - alerting + - guide + - contact point + - templating +labels: + products: + - cloud + - enterprise + - oss +title: Configure contact points +weight: 410 +--- + +# Configure contact points + +Use contact points to define how your contacts are notified when an alert rule fires. You can add, edit, delete, and test a contact point. + +Configure contact point integrations in Grafana to select your preferred communication channel for receiving notifications when your alert rules are firing. + +## Add a contact point + +Complete the following steps to add a contact point. + +1. In the left-side menu, click **Alerts & IRM** and then **Alerting**. +1. Click **Contact points**. +1. From the **Choose Alertmanager** dropdown, select an Alertmanager. By default, **Grafana Alertmanager** is selected. +1. On the **Contact Points** tab, click **+ Add contact point**. +1. Enter a descriptive name for the contact point. +1. From **Integration**, select a type and fill out mandatory fields. For example, if you choose email, enter the email addresses. Or if you choose Slack, enter the Slack channel(s) and users who should be contacted. +1. Some contact point integrations, like email or webhook, have optional settings. In **Optional settings**, specify additional settings for the selected contact point integration. +1. In Notification settings, optionally select **Disable resolved message** if you do not want to be notified when an alert resolves. +1. To add another contact point integration, click **Add contact point integration** and repeat steps 6 through 8. +1. Save your changes. + +## Edit a contact point + +Complete the following steps to edit a contact point. + +1. In the left-side menu, click **Alerts & IRM** and then **Alerting**. +1. Click **Contact points** to view a list of existing contact points. +1. On the **Contact Points** tab, find the contact point you want to edit, and then click **Edit**. +1. Update the contact point and save your changes. + +## Delete a contact point + +Complete the following steps to delete a contact point. + +1. In the left-side menu, click **Alerts & IRM** and then **Alerting**. +1. Click **Contact points** to view a list of existing contact points. +1. On the **Contact Points** tab, find the contact point you want to delete, and then click **More** -> **Delete**. +1. In the confirmation dialog, click **Yes, delete**. + +{{% admonition type="note" %}} +You cannot delete contact points that are in use by a notification policy. Either delete the notification policy or update it to use another contact point. +{{% /admonition %}} + +## Test a contact point + +Complete the following steps to test a contact point. + +1. In the left-side menu, click **Alerts & IRM** and then **Alerting**. +1. Click **Contact points** to view a list of existing contact points. +1. On the **Contact Points** tab, find the contact point you want to test, then click **Edit**. You can also create a new contact point if needed. +1. Click **Test** to open the contact point testing modal. +1. Choose whether to send a predefined test notification or choose custom to add your own custom annotations and labels to include in the notification. +1. Click **Send test notification** to fire the alert. + +## Manage contact points + +The Contact points list view lists all existing contact points and notification templates. + +On the **Contact Points** tab, you can: + +- Search for name and type of contact points and integrations +- View all existing contact points and integrations +- View how many notification policies each contact point is being used for and navigate directly to the linked notification policies +- View the status of notification deliveries +- Export individual contact points or all contact points in JSON, YAML, or Terraform format +- Delete contact points that are not in use by a notification policy + +On the **Notification templates** tab, you can: + +- View, edit, copy or delete existing notification templates + +## Configure contact point integrations + +Each contact point integration has its own configuration options and setup process. In most cases, this involves providing an API key or a Webhook URL. + +Once configured, you can use integrations as part of your contact points to receive notifications whenever your alert changes its state. In this section, we'll cover the basic steps to configure your integrations, so you can start receiving real-time alerts and stay on top of your monitoring data. + +## List of supported integrations + +| Name | Type | +| ------------------------ | ------------------------- | +| DingDing | `dingding` | +| Discord | `discord` | +| Email | `email` | +| Google Chat | `googlechat` | +| [Grafana Oncall][oncall] | `oncall` | +| Hipchat | `hipchat` | +| Kafka | `kafka` | +| Line | `line` | +| Microsoft Teams | `teams` | +| Opsgenie | `opsgenie` | +| [Pagerduty][pagerduty] | `pagerduty` | +| Prometheus Alertmanager | `prometheus-alertmanager` | +| Pushover | `pushover` | +| Sensu | `sensu` | +| Sensu Go | `sensugo` | +| [Slack][slack] | `slack` | +| Telegram | `telegram` | +| Threema | `threema` | +| VictorOps | `victorops` | +| [Webhook][webhook] | `webhook` | + +{{% docs/reference %}} +[pagerduty]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/manage-contact-points/integrations/pager-duty" +[pagerduty]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/manage-contact-points/integrations/pager-duty" + +[oncall]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/manage-contact-points/integrations/configure-oncall" +[oncall]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/manage-contact-points/integrations/configure-oncall" + +[slack]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/manage-contact-points/integrations/configure-slack" +[slack]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/manage-contact-points/integrations/configure-slack" + +[webhook]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier" +[webhook]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier" +{{% /docs/reference %}} diff --git a/docs/sources/alerting/alerting-rules/manage-contact-points/integrations/configure-oncall.md b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-oncall.md similarity index 77% rename from docs/sources/alerting/alerting-rules/manage-contact-points/integrations/configure-oncall.md rename to docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-oncall.md index 093e78b5cad2d..e55761ad318fc 100644 --- a/docs/sources/alerting/alerting-rules/manage-contact-points/integrations/configure-oncall.md +++ b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-oncall.md @@ -1,5 +1,8 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/alerting-rules/manage-contact-points/integrations/configure-oncall/ +aliases: + - ../../../alerting-rules/manage-contact-points/configure-oncall/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/manage-contact-points/configure-oncall/ + - ../../../alerting-rules/manage-contact-points/integrations/configure-oncall/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/manage-contact-points/integrations/configure-oncall/ +canonical: https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/configure-oncall/ description: Configure the Alerting - Grafana OnCall integration to connect alerts generated by Grafana Alerting with Grafana OnCall keywords: - grafana @@ -7,7 +10,7 @@ keywords: - oncall - integration aliases: - - ../configure-oncall/ # /docs/grafana/latest/alerting/alerting-rules/manage-contact-points/configure-oncall/ + - ../configure-oncall/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/manage-contact-points/configure-oncall/ labels: products: - cloud @@ -63,12 +66,12 @@ To set up the Grafana OnCall integration using the Grafana Alerting application, This redirects you to the Grafana OnCall integration page in the Grafana OnCall application. From there, you can add [routes and escalation chains][escalation-chain]. {{% docs/reference %}} -[create-notification-policy]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/alerting-rules/create-notification-policy" -[create-notification-policy]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/create-notification-policy" +[create-notification-policy]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/create-notification-policy" +[create-notification-policy]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/create-notification-policy" [oncall-integration]: "/docs/grafana/ -> /docs/oncall/latest/integrations/grafana-alerting" [oncall-integration]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/oncall/integrations/grafana-alerting" -[escalation-chain]: "/docs/grafana/ -> /docs/oncall/latest/escalation-chains-and-routes" -[escalation-chain]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/oncall/escalation-chains-and-routes" +[escalation-chain]: "/docs/grafana/ -> /docs/oncall/latest/configure/escalation-chains-and-routes" +[escalation-chain]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/oncall/configure/escalation-chains-and-routes" {{% /docs/reference %}} diff --git a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md new file mode 100644 index 0000000000000..f08ab60fdaac7 --- /dev/null +++ b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md @@ -0,0 +1,93 @@ +--- +canonical: https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/configure-slack/ +description: Configure the Slack integration to connect alerts generated by Grafana Alerting +keywords: + - grafana + - alerting + - slack + - integration +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Slack +title: Configure Slack for Alerting +weight: 300 +--- + +## Configure Slack for Alerting + +Use the Grafana Alerting - Slack integration to send Slack notifications when your alerts are firing. + +There are two ways of integrating Slack into Grafana Alerting. + +1. Use a [Slack API token](https://api.slack.com/authentication/token-types) + + Enable your app to access the Slack API. If, for example, you are interested in more granular control over permissions, or your project is expected to regularly scale, resulting in new channels being created, this is the best option. + +1. Use a [Webhook URL](https://api.slack.com/messaging/webhooks) + + Webhooks is the simpler way to post messages into Slack. Slack automatically creates a bot user with all the necessary permissions to post messages to one particular channel of your choice. + +{{< admonition type="note" >}} +Grafana Alerting only allows one Slack channel per contact point. +{{< /admonition >}} + +## Before you begin + +### Slack API Token + +If you are using a Slack API Token, complete the following steps. + +1. Follow steps 1 and 2 of the [Slack API Quickstart](https://api.slack.com/start/quickstart). +1. Add the [chat:write.public](https://api.slack.com/scopes/chat:write.public) scope to give your app the ability to post in all public channels without joining. +1. In OAuth Tokens for Your Workspace, copy the Bot User OAuth Token. +1. Open your Slack workplace. +1. Right click the channel you want to receive notifications in. +1. Click View channel details. +1. Scroll down and copy the Channel ID. + {{< admonition type="note" >}} + While going through these steps, Slack may prompt you to Reinstall your app in order for the changes to take effect. + {{< /admonition >}} + +### Webhook URL + +If you are using a Webhook URL, follow steps 1 and 5 in the [Slack API Quickstart](https://api.slack.com/start/quickstart). + +{{< admonition type="note" >}} +Make sure you copy the Slack app Webhook URL. You will need this when setting up your contact point integration in Grafana Alerting. +{{< /admonition >}} + +## Procedure + +To create your Slack integration in Grafana Alerting, complete the following steps. + +1. Navigate to **Alerts & IRM** -> **Alerting** -> **Contact points**. +1. Click **+ Add contact point**. +1. Enter a contact point name. +1. From the Integration list, select Slack. +1. If you are using a Slack API token: + - In the **Recipient** field, copy in the channel ID. + - In the **Token** field, copy in the Bot User OAuth Token that starts with “xoxb-”. +1. If you are using a Webhook URL, in the **Webhook** field, copy in your Slack app Webhook URL. +1. Click **Test** to check that your integration works. +1. Click **Save contact point**. + +## Next steps + +To add the contact point and integration you created to your default notification policy, complete the following steps. + +1. Navigate to **Alerts & IRM** -> **Alerting** -> **Notification policies**. +1. In the **Default policy**, click the ellipsis icon (…) and then **Edit**, +1. Change the default policy to the contact point you created. +1. Click **Update default policy**. + +**Note:** +If you have more than one contact point, add a new notification policy rather than edit the default one, so you can route specific alerts to Slack. For more information, refer to [Notification policies][nested-policy]. + +{{% docs/reference %}} +[nested-policy]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/create-notification-policy#add-new-nested-policy" + +[nested-policy]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/create-notification-policy#add-new-nested-policy" +{{% /docs/reference %}} diff --git a/docs/sources/alerting/alerting-rules/manage-contact-points/integrations/pager-duty.md b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/pager-duty.md similarity index 79% rename from docs/sources/alerting/alerting-rules/manage-contact-points/integrations/pager-duty.md rename to docs/sources/alerting/configure-notifications/manage-contact-points/integrations/pager-duty.md index da79c613ebbe2..7a55e0734247a 100644 --- a/docs/sources/alerting/alerting-rules/manage-contact-points/integrations/pager-duty.md +++ b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/pager-duty.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/alerting-rules/manage-contact-points/integrations/pager-duty/ +aliases: + - ../../../alerting-rules/manage-contact-points/integrations/pager-duty/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/manage-contact-points/integrations/pager-duty/ +canonical: https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/pager-duty/ description: Configure the PagerDuty integration for Alerting keywords: - grafana diff --git a/docs/sources/alerting/alerting-rules/manage-contact-points/integrations/webhook-notifier.md b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md similarity index 91% rename from docs/sources/alerting/alerting-rules/manage-contact-points/integrations/webhook-notifier.md rename to docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md index 77004bc92056f..769297b6d3e17 100644 --- a/docs/sources/alerting/alerting-rules/manage-contact-points/integrations/webhook-notifier.md +++ b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md @@ -1,9 +1,12 @@ --- aliases: - - ../contact-points/notifiers/webhook-notifier/ - - ../fundamentals/contact-points/webhook-notifier/ + - ../../../fundamentals/contact-points/notifiers/webhook-notifier/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/contact-points/notifiers/webhook-notifier/ + - ../../../fundamentals/contact-points/webhook-notifier/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/contact-points/webhook-notifier/ + - ../../../manage-notifications/manage-contact-points/webhook-notifier/ # /docs/grafana/<GRAFANA_VERSION>/alerting/manage-notifications/manage-contact-points/webhook-notifier/ - alerting/manage-notifications/manage-contact-points/webhook-notifier/ -canonical: https://grafana.com/docs/grafana/latest/alerting/alerting-rules/manage-contact-points/integrations/webhook-notifier/ + - ../../../alerting-rules/manage-contact-points/integrations/webhook-notifier/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/manage-contact-points/integrations/webhook-notifier/ + +canonical: https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/ description: Configure the webhook notifier integration for Alerting keywords: - grafana diff --git a/docs/sources/alerting/manage-notifications/mute-timings.md b/docs/sources/alerting/configure-notifications/mute-timings.md similarity index 82% rename from docs/sources/alerting/manage-notifications/mute-timings.md rename to docs/sources/alerting/configure-notifications/mute-timings.md index 678ea5b7f2a60..9e2efeef2b840 100644 --- a/docs/sources/alerting/manage-notifications/mute-timings.md +++ b/docs/sources/alerting/configure-notifications/mute-timings.md @@ -1,8 +1,9 @@ --- aliases: - - ../notifications/mute-timings/ - - ../unified-alerting/notifications/mute-timings/ -canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/mute-timings/ + - ../notifications/mute-timings/ # /docs/grafana/<GRAFANA_VERSION>/alerting/notifications/mute-timings/ + - ../unified-alerting/notifications/mute-timings/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/notifications/mute-timings/ + - ../manage-notifications/mute-timings/ # /docs/grafana/<GRAFANA_VERSION>/alerting/manage-notifications/mute-timings/ +canonical: /docs/grafana/latest/alerting/configure-notifications/mute-timings/ description: Create mute timings to prevent alerts from firing during a specific and reoccurring period of time keywords: - grafana @@ -16,17 +17,17 @@ labels: - cloud - enterprise - oss -title: Create mute timings -weight: 420 +title: Configure mute timings +weight: 450 --- -# Create mute timings +# Configure mute timings A mute timing is a recurring interval of time when no new notifications for a policy are generated or sent. Use them to prevent alerts from firing a specific and reoccurring period, for example, a regular maintenance period. Similar to silences, mute timings do not prevent alert rules from being evaluated, nor do they stop alert instances from being shown in the user interface. They only prevent notifications from being created. -You can configure Grafana managed mute timings as well as mute timings for an [external Alertmanager data source][datasources/alertmanager]. For more information, refer to [Alertmanager documentation][fundamentals/alertmanager]. +You can configure Grafana managed mute timings as well as mute timings for an [external Alertmanager data source][datasources/alertmanager]. For more information, refer to [Alertmanager documentation][intro-alertmanager]. ## Mute timings vs silences @@ -81,9 +82,9 @@ If you want to specify an exact duration, specify all the options. For example, - Days of the month: `1:7` {{% docs/reference %}} -[datasources/alertmanager]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/alertmanager" -[datasources/alertmanager]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/alertmanager" +[datasources/alertmanager]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/datasources/alertmanager" +[datasources/alertmanager]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/connect-externally-hosted/data-sources/alertmanager" -[fundamentals/alertmanager]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals/alertmanager" -[fundamentals/alertmanager]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alertmanager" +[intro-alertmanager]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/notifications/alertmanager" +[intro-alertmanager]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notifications/alertmanager" {{% /docs/reference %}} diff --git a/docs/sources/alerting/manage-notifications/template-notifications/_index.md b/docs/sources/alerting/configure-notifications/template-notifications/_index.md similarity index 68% rename from docs/sources/alerting/manage-notifications/template-notifications/_index.md rename to docs/sources/alerting/configure-notifications/template-notifications/_index.md index 735f1b0c874f7..745f3fc86006e 100644 --- a/docs/sources/alerting/manage-notifications/template-notifications/_index.md +++ b/docs/sources/alerting/configure-notifications/template-notifications/_index.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/template-notifications/ +aliases: + - ../manage-notifications/template-notifications/ # /docs/grafana/<GRAFANA_VERSION>/alerting/manage-notifications/template-notifications/ +canonical: https://grafana.com/docs/grafana/latest/alerting/configure-notifications/template-notifications/ description: Customize your notifications using notification templates keywords: - grafana @@ -11,13 +13,13 @@ labels: - cloud - enterprise - oss -title: Customize notifications -weight: 400 +title: Configure notification messages +weight: 430 --- -# Customize notifications +# Configure notification messages -Customize your notifications with notifications templates. +Customize the content of your notifications with notifications templates. You can use notification templates to change the title, message, and format of the message in your notifications. @@ -52,12 +54,12 @@ Use notification templates to send notifications to your contact points. Data that is available when writing templates. {{% docs/reference %}} -[reference]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/manage-notifications/template-notifications/reference" -[reference]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/manage-notifications/template-notifications/reference" +[reference]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/template-notifications/reference" +[reference]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/template-notifications/reference" -[use-notification-templates]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/manage-notifications/template-notifications/use-notification-templates" -[use-notification-templates]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/manage-notifications/template-notifications/use-notification-templates" +[use-notification-templates]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/template-notifications/use-notification-templates" +[use-notification-templates]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/template-notifications/use-notification-templates" -[using-go-templating-language]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/manage-notifications/template-notifications/using-go-templating-language" -[using-go-templating-language]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/manage-notifications/template-notifications/using-go-templating-language" +[using-go-templating-language]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/template-notifications/using-go-templating-language" +[using-go-templating-language]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/template-notifications/using-go-templating-language" {{% /docs/reference %}} diff --git a/docs/sources/alerting/manage-notifications/template-notifications/create-notification-templates.md b/docs/sources/alerting/configure-notifications/template-notifications/create-notification-templates.md similarity index 96% rename from docs/sources/alerting/manage-notifications/template-notifications/create-notification-templates.md rename to docs/sources/alerting/configure-notifications/template-notifications/create-notification-templates.md index 947556e4e2324..19f7f3a465990 100644 --- a/docs/sources/alerting/manage-notifications/template-notifications/create-notification-templates.md +++ b/docs/sources/alerting/configure-notifications/template-notifications/create-notification-templates.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/template-notifications/create-notification-templates/ +aliases: + - ../../manage-notifications/template-notifications/create-notification-templates/ # /docs/grafana/<GRAFANA_VERSION>/alerting/manage-notifications/template-notifications/create-notification-templates/ +canonical: https://grafana.com/docs/grafana/latest/alerting/configure-notifications/template-notifications/create-notification-templates/ description: Create notification templates to sent to your contact points keywords: - grafana diff --git a/docs/sources/alerting/manage-notifications/images-in-notifications.md b/docs/sources/alerting/configure-notifications/template-notifications/images-in-notifications.md similarity index 91% rename from docs/sources/alerting/manage-notifications/images-in-notifications.md rename to docs/sources/alerting/configure-notifications/template-notifications/images-in-notifications.md index 7155814c59c5d..6bd1260213625 100644 --- a/docs/sources/alerting/manage-notifications/images-in-notifications.md +++ b/docs/sources/alerting/configure-notifications/template-notifications/images-in-notifications.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/images-in-notifications/ +aliases: + - ../manage-notifications/images-in-notifications/ # /docs/grafana/<GRAFANA_VERSION>/alerting/manage-notifications/images-in-notifications/ +canonical: https://grafana.com/docs/grafana/latest/alerting/configure-notifications/template-notifications/images-in-notifications/ description: Use images in notifications to help users better understand why alerts are firing or have been resolved keywords: - grafana @@ -12,7 +14,7 @@ labels: - enterprise - oss title: Use images in notifications -weight: 405 +weight: 500 --- # Use images in notifications @@ -33,19 +35,19 @@ Refer to the table at the end of this page for a list of contact points and thei ## Requirements -1. To use images in notifications, Grafana must be set up to use [image rendering][image-rendering]. You can either install the image rendering plugin or run it as a remote rendering service. +1. To use images in notifications, Grafana must be set up to use image rendering. You can either install the image rendering plugin or run it as a remote rendering service. 2. When a screenshot is taken it is saved to the [data][paths] folder, even if Grafana is configured to upload screenshots to a cloud storage service. Grafana must have write-access to this folder otherwise screenshots cannot be saved to disk and an error will be logged for each failed screenshot attempt. 3. You should use a cloud storage service unless sending alerts to Discord, Email, Pushover, Slack or Telegram. These integrations support either embedding screenshots in the email or attaching screenshots to the notification, while other integrations must link screenshots uploaded to a cloud storage bucket. If a cloud storage service has been configured then integrations that support both will link screenshots from the cloud storage bucket instead of embedding or attaching screenshots to the notification. -4. If uploading screenshots to a cloud storage service such as Amazon S3, Azure Blob Storage or Google Cloud Storage; and accessing screenshots in the bucket requires authentication, logging into a VPN or corporate network; then image previews might not work in all instant messaging and communication platforms as some services rewrite URLs to use their CDN. If this happens we recommend using [integrations which support uploading images]({{< relref "#supported-contact-points" >}}) or [disabling images in notifications]({{< relref "#configuration" >}}) altogether. +4. If uploading screenshots to a cloud storage service such as Amazon S3, Azure Blob Storage or Google Cloud Storage; and accessing screenshots in the bucket requires authentication, logging into a VPN or corporate network; then image previews might not work in all instant messaging and communication platforms as some services rewrite URLs to use their CDN. If this happens we recommend using [integrations which support uploading images](#supported-contact-points) or [disabling images in notifications](#configuration) altogether. 5. When uploading screenshots to a cloud storage service Grafana uses a random 20 character (30 characters for Azure Blob Storage) filename for each image. This makes URLs hard to guess but not impossible. 6. Grafana does not delete screenshots from cloud storage. We recommend configuring a retention policy with your cloud storage service to delete screenshots older than 1 month. -7. If Grafana is configured to upload screenshots to its internal web server, and accessing Grafana requires logging into a VPN or corporate network; image previews might not work in all instant messaging and communication platforms as some services rewrite URLs to use their CDN. If this happens we recommend using [integrations which support uploading images]({{< relref "#supported-contact-points" >}}) or [disabling images in notifications]({{< relref "#configuration" >}}) altogether. +7. If Grafana is configured to upload screenshots to its internal web server, and accessing Grafana requires logging into a VPN or corporate network; image previews might not work in all instant messaging and communication platforms as some services rewrite URLs to use their CDN. If this happens we recommend using [integrations which support uploading images](#supported-contact-points) or [disabling images in notifications](#configuration) altogether. 8. Grafana does not delete screenshots uploaded to its internal web server. To delete screenshots from `static_root_path/images/attachments` after a certain amount of time we recommend setting up a CRON job. @@ -69,8 +71,6 @@ If screenshots should be uploaded to cloud storage then `upload_external_image_s # will be persisted to disk for up to temp_data_lifetime. upload_external_image_storage = false -For more information on image rendering, refer to [image rendering][image-rendering]. - Restart Grafana for the changes to take effect. ## Advanced configuration @@ -137,11 +137,3 @@ For example, if a screenshot could not be taken within the expected time (10 sec - `grafana_screenshot_successes_total` - `grafana_screenshot_upload_failures_total` - `grafana_screenshot_upload_successes_total` - -{{% docs/reference %}} -[image-rendering]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/image-rendering" -[image-rendering]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/image-rendering" - -[paths]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana#paths" -[paths]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana#paths" -{{% /docs/reference %}} diff --git a/docs/sources/alerting/manage-notifications/template-notifications/reference.md b/docs/sources/alerting/configure-notifications/template-notifications/reference.md similarity index 95% rename from docs/sources/alerting/manage-notifications/template-notifications/reference.md rename to docs/sources/alerting/configure-notifications/template-notifications/reference.md index 16c26acc6d517..d38e517a4d2c9 100644 --- a/docs/sources/alerting/manage-notifications/template-notifications/reference.md +++ b/docs/sources/alerting/configure-notifications/template-notifications/reference.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/template-notifications/reference/ +aliases: + - ../../manage-notifications/template-notifications/reference/ # /docs/grafana/<GRAFANA_VERSION>/alerting/manage-notifications/template-notifications/reference/ +canonical: https://grafana.com/docs/grafana/latest/alerting/configure-notifications/template-notifications/reference/ description: Learn about templating notifications options keywords: - grafana diff --git a/docs/sources/alerting/manage-notifications/template-notifications/use-notification-templates.md b/docs/sources/alerting/configure-notifications/template-notifications/use-notification-templates.md similarity index 58% rename from docs/sources/alerting/manage-notifications/template-notifications/use-notification-templates.md rename to docs/sources/alerting/configure-notifications/template-notifications/use-notification-templates.md index 78f2293b43358..6dcc4ac35ff9e 100644 --- a/docs/sources/alerting/manage-notifications/template-notifications/use-notification-templates.md +++ b/docs/sources/alerting/configure-notifications/template-notifications/use-notification-templates.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/template-notifications/use-notification-templates/ +aliases: + - ../../manage-notifications/template-notifications/use-notification-templates/ # /docs/grafana/<GRAFANA_VERSION>/alerting/manage-notifications/template-notifications/use-notification-templates/ +canonical: https://grafana.com/docs/grafana/latest/alerting/configure-notifications/template-notifications/use-notification-templates/ description: Use notification templates in contact points to customize your notifications keywords: - grafana @@ -26,18 +28,18 @@ In the Contact points tab, you can see a list of your contact points. **Note:** You can edit an existing contact by clicking the Edit icon. -2. Execute a template from one or more fields such as Message and Subject: +1. Execute a template from one or more fields such as Message and Subject: {{< figure max-width="940px" src="/static/img/docs/alerting/unified/use-notification-template-9-4.png" caption="Use notification template" >}} For more information on how to write and execute templates, refer to [Using Go's templating language][using-go-templating-language] and [Create notification templates][create-notification-templates]. -3. Click Save template. +1. Click **Save contact point**. {{% docs/reference %}} -[create-notification-templates]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/manage-notifications/template-notifications/create-notification-templates" -[create-notification-templates]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/manage-notifications/template-notifications/create-notification-templates" +[create-notification-templates]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/template-notifications/create-notification-templates" +[create-notification-templates]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/template-notifications/create-notification-templates" -[using-go-templating-language]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/manage-notifications/template-notifications/using-go-templating-language" -[using-go-templating-language]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/manage-notifications/template-notifications/using-go-templating-language" +[using-go-templating-language]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/template-notifications/using-go-templating-language" +[using-go-templating-language]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/template-notifications/using-go-templating-language" {{% /docs/reference %}} diff --git a/docs/sources/alerting/manage-notifications/template-notifications/using-go-templating-language.md b/docs/sources/alerting/configure-notifications/template-notifications/using-go-templating-language.md similarity index 88% rename from docs/sources/alerting/manage-notifications/template-notifications/using-go-templating-language.md rename to docs/sources/alerting/configure-notifications/template-notifications/using-go-templating-language.md index 7919b32170e83..8bb0bad2c3e86 100644 --- a/docs/sources/alerting/manage-notifications/template-notifications/using-go-templating-language.md +++ b/docs/sources/alerting/configure-notifications/template-notifications/using-go-templating-language.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/template-notifications/using-go-templating-language/ +aliases: + - ../../manage-notifications/template-notifications/using-go-templating-language/ # /docs/grafana/<GRAFANA_VERSION>/alerting/manage-notifications/template-notifications/using-go-templating-language/ +canonical: https://grafana.com/docs/grafana/latest/alerting/configure-notifications/template-notifications/using-go-templating-language/ description: Use Go's templating language to create your own notification templates keywords: - grafana @@ -32,7 +34,7 @@ In text/template, templates start with `{{` and end with `}}` irrespective of wh ## Print -To print the value of something use `{{` and `}}`. You can print the value of dot, a field of dot, the result of a function, and the value of a [variable]({{< relref "#variables" >}}). For example, to print the `Alerts` field where dot refers to `ExtendedData` you would write the following: +To print the value of something use `{{` and `}}`. You can print the value of dot, a field of dot, the result of a function, and the value of a [variable](#variables). For example, to print the `Alerts` field where dot refers to `ExtendedData` you would write the following: ``` {{ .Alerts }} @@ -280,12 +282,12 @@ grafana_folder = "Test alerts" ``` {{% docs/reference %}} -[create-notification-templates]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/manage-notifications/template-notifications/create-notification-templates" -[create-notification-templates]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/manage-notifications/template-notifications/create-notification-templates" +[create-notification-templates]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/template-notifications/create-notification-templates" +[create-notification-templates]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/template-notifications/create-notification-templates" -[extendeddata]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/manage-notifications/template-notifications/reference#extendeddata" -[extendeddata]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/manage-notifications/template-notifications/reference#extendeddata" +[extendeddata]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/template-notifications/reference#extendeddata" +[extendeddata]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/template-notifications/reference#extendeddata" -[reference]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/manage-notifications/template-notifications/reference" -[reference]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/manage-notifications/template-notifications/reference" +[reference]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/template-notifications/reference" +[reference]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/template-notifications/reference" {{% /docs/reference %}} diff --git a/docs/sources/alerting/difference-old-new.md b/docs/sources/alerting/difference-old-new.md deleted file mode 100644 index 779a6566c2f5a..0000000000000 --- a/docs/sources/alerting/difference-old-new.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -_build: - list: false -aliases: - - ./unified-alerting/difference-old-new/ # /docs/grafana/<GRAFANA VERSION>/alerting/unified-alerting/difference-old-new/ -canonical: https://grafana.com/docs/grafana/latest/alerting/difference-old-new/ -description: Learn about how Grafana Alerting compares to legacy alerting -keywords: - - grafana - - alerting - - guide -labels: - products: - - cloud - - enterprise - - oss -title: Grafana Alerting vs Legacy dashboard alerting -weight: 108 ---- - -# Grafana Alerting vs Legacy dashboard alerting - -Introduced in Grafana 8.0, and the only system since Grafana 10.0, Grafana Alerting has several enhancements over legacy dashboard alerting. - -## Multi-dimensional alerting - -You can now create alerts that give you system-wide visibility with a single alerting rule. Generate multiple alert instances from a single alert rule. For example, you can create a rule to monitor the disk usage of multiple mount points on a single host. The evaluation engine returns multiple time series from a single query, with each time series identified by its label set. - -## Create alerts outside of Dashboards - -Unlike legacy dashboard alerts, Grafana alerts allow you to create queries and expressions that combine data from multiple sources in unique ways. You can still link dashboards and panels to alerting rules using their ID and quickly troubleshoot the system under observation. - -Since unified alerts are no longer directly tied to panel queries, they do not include images or query values in the notification email. You can use customized notification templates to view query values. - -## Create Loki and Grafana Mimir alerting rules - -In Grafana Alerting, you can manage Loki and Grafana Mimir alerting rules using the same UI and API as your Grafana managed alerts. - -## View and search for alerts from Prometheus compatible data sources - -Alerts for Prometheus compatible data sources are now listed under the Grafana alerts section. You can search for labels across multiple data sources to quickly find relevant alerts. - -## Special alerts for alert state NoData and Error - -Grafana Alerting introduced a new concept of the alert states. When evaluation of an alerting rule produces state NoData or Error, Grafana Alerting will generate special alerts that will have the following labels: - -- `alertname` with value DatasourceNoData or DatasourceError depending on the state. -- `rulename` name of the alert rule the special alert belongs to. -- `datasource_uid` will have the UID of the data source that caused the state. -- all labels and annotations of the original alert rule - -You can handle these alerts the same way as regular alerts by adding a silence, route to a contact point, and so on. - -> **Note:** If the rule uses many data sources and one or many returns no data, the special alert will be created for each data source that caused the alert state. diff --git a/docs/sources/alerting/fundamentals/_index.md b/docs/sources/alerting/fundamentals/_index.md index eb228673e6dee..32c1c7cdc8397 100644 --- a/docs/sources/alerting/fundamentals/_index.md +++ b/docs/sources/alerting/fundamentals/_index.md @@ -1,7 +1,7 @@ --- aliases: - - metrics/ - - unified-alerting/fundamentals/ + - ./metrics/ # /docs/grafana/<GRAFANA_VERSION>/alerting/metrics/ + - ./unified-alerting/fundamentals/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/fundamentals/ canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/ description: Learn about the fundamentals of Grafana Alerting as well as the key features it offers labels: @@ -18,33 +18,42 @@ weight: 100 Whether you’re just starting out or you're a more experienced user of Grafana Alerting, learn more about the fundamentals and available features that help you create, manage, and respond to alerts; and improve your team’s ability to resolve issues quickly. -## Principles - -In Prometheus-based alerting systems, you have an alert generator that creates alerts and an alert receiver that receives alerts. For example, Prometheus is an alert generator and is responsible for evaluating alert rules, while Alertmanager is an alert receiver and is responsible for grouping, inhibiting, silencing, and sending notifications about firing and resolved alerts. - -Grafana Alerting is built on the Prometheus model of designing alerting systems. It has an internal alert generator responsible for scheduling and evaluating alert rules, as well as an internal alert receiver responsible for grouping, inhibiting, silencing, and sending notifications. Grafana doesn’t use Prometheus as its alert generator because Grafana Alerting needs to work with many other data sources in addition to Prometheus. However, it does use Alertmanager as its alert receiver. - -Alerts are sent to the alert receiver where they are routed, grouped, inhibited, silenced and notified. In Grafana Alerting, the default alert receiver is the Alertmanager embedded inside Grafana, and is referred to as the Grafana Alertmanager. However, you can use other Alertmanagers too, and these are referred to as [External Alertmanagers][external-alertmanagers]. - The following diagram gives you an overview of Grafana Alerting and introduces you to some of the fundamental features that are the principles of how Grafana Alerting works. {{< figure src="/media/docs/alerting/how-alerting-works.png" max-width="750px" caption="How Alerting works" >}} +## How it works at a glance + +- Grafana alerting periodically queries data sources and evaluates the condition defined in the alert rule +- If the condition is breached, an alert instance fires +- Firing instances are routed to notification policies based on matching labels +- Notifications are sent out to the contact points specified in the notification policy + ## Fundamentals +The following concepts are key to your understanding of how Grafana Alerting works. + ### Alert rules -An alert rule is a set of criteria that determine when an alert should fire. It consists of one or more queries and expressions, a condition which needs to be met, an interval which determines how often the alert rule is evaluated, and a duration over which the condition must be met for an alert to fire. +An alert rule consists of one or more queries and expressions that select the data you want to measure. It also contains a condition, which is the threshold that an alert rule must meet or exceed in order to fire. + +Add annotations to your alert rule to provide additional information about the alert rule and add labels to uniquely identify your alert rule and configure alert routing. Labels link alert rules to notification policies, so you can easily manage which policy should handle which alerts and who gets notified. + +Once alert rules are created, they go through various states and transitions. An alert rule can produce multiple alert instances - one alert instance for each time series. -Alert rules are evaluated over their interval, and each alert rule can have zero, one, or any number of alerts firing at a time. The state of the alert rule is determined by its most "severe" alert, which can be one of Normal, Pending, or Firing. For example, if at least one of an alert rule's alerts are firing then the alert rule is also firing. The health of an alert rule is determined by the status of its most recent evaluation. These can be OK, Error, and NoData. +The alert rule state is determined by the “worst case” state of the alert instances produced and the states can be Normal, Pending, or Firing. For example, if one alert instance is firing, the alert rule state will also be firing. -A very important feature of alert rules is that they support custom annotations and labels. These allow you to instrument alerts with additional metadata such as summaries and descriptions, and add additional labels to route alerts to specific notification policies. +The alert rule health is determined by the status of the evaluation of the alert rule, which can be Ok, Error, and NoData. -### Alerts +### Labels and states -Alerts are uniquely identified by sets of key/value pairs called Labels. Each key is a label name and each value is a label value. For example, one alert might have the labels `foo=bar` and another alert might have the labels `foo=baz`. An alert can have many labels such as `foo=bar,bar=baz` but it cannot have the same label twice such as `foo=bar,foo=baz`. Two alerts cannot have the same labels either, and if two alerts have the same labels such as `foo=bar,bar=baz` and `foo=bar,bar=baz` then one of the alerts will be discarded. Alerts are resolved when the condition in the alert rule is no longer met, or the alert rule is deleted. +Alert rules are uniquely identified by sets of key/value pairs called labels. Each key is a label name and each value is a label value. For example, one alert might have the labels `foo=bar` and another alert rule might have the labels `foo=baz`. An alert rule can have many labels such as `foo=bar,bar=baz`, but it cannot have the same label twice such as `foo=bar,foo=baz`. Two alert rules cannot have the same labels either, and if two alert rules have the same labels such as `foo=bar,bar=baz` and `foo=bar,bar=baz` then one of the alerts will be discarded. Firing alerts are resolved when the condition in the alert rule is no longer met, or the alert rule is deleted. -In Grafana Managed Alerts, alerts can be in Normal, Pending, Alerting, No Data or Error states. In Datasource Managed Alerts, such as Mimir and Loki, alerts can be in Normal, Pending and Alerting, but not NoData or Error. +In Grafana-managed alert rules, alert rules can be in Normal, Pending, Alerting, No Data or Error states. In datasource-managed alert rules, such as Mimir and Loki, alert rules can be in Normal, Pending and Alerting, but not NoData or Error. + +### Alert instances + +For Grafana-managed alert rules, multiple alert instances can be created as a result of one alert rule (also known as a multi-dimensional alerting) and they can be in Normal, Pending, Alerting, No Data, Error states. For Mimir or Loki-managed alert rules, alert instances are only created when the threshold condition defined in an alert rule is breached. ### Contact points @@ -68,9 +77,74 @@ Silences and mute timings allow you to pause notifications for specific alerts o You can create your alerting resources (alert rules, notification policies, and so on) in the Grafana UI; configmaps, files and configuration management systems using file-based provisioning; and in Terraform using API-based provisioning. +## Key features and benefits + +**One page for all alerts** + +A single Grafana Alerting page consolidates both Grafana-managed alerts and alerts that reside in your Prometheus-compatible data source in one single place. + +**Multi-dimensional alerts** + +Alert rules can create multiple individual alert instances per alert rule, known as multi-dimensional alerts, giving you the power and flexibility to gain visibility into your entire system with just a single alert rule. You do this by adding labels to your query to specify which component is being monitored and generate multiple alert instances for a single alert rule. For example, if you want to monitor each server in a cluster, a multi-dimensional alert will alert on each CPU, whereas a standard alert will alert on the overall server. + +**Route alerts** + +Route each alert instance to a specific contact point based on labels you define. Notification policies are the set of rules for where, when, and how the alerts are routed to contact points. + +**Silence alerts** + +Silences stop notifications from getting created and last for only a specified window of time. +Silences allow you to stop receiving persistent notifications from one or more alert rules. You can also partially pause an alert based on certain criteria. Silences have their own dedicated section for better organization and visibility, so that you can scan your paused alert rules without cluttering the main alerting view. + +**Mute timings** + +A mute timing is a recurring interval of time when no new notifications for a policy are generated or sent. Use them to prevent alerts from firing a specific and reoccurring period, for example, a regular maintenance period. + +Similar to silences, mute timings do not prevent alert rules from being evaluated, nor do they stop alert instances from being shown in the user interface. They only prevent notifications from being created. + +## Design your Alerting system + +Monitoring complex IT systems and understanding whether everything is up and running correctly is a difficult task. Setting up an effective alert management system is therefore essential to inform you when things are going wrong before they start to impact your business outcomes. + +Designing and configuring an alert management set up that works takes time. + +Here are some tips on how to create an effective alert management set up for your business: + +**Which are the key metrics for your business that you want to monitor and alert on?** + +- Find events that are important to know about and not so trivial or frequent that recipients ignore them. + +- Alerts should only be created for big events that require immediate attention or intervention. + +- Consider quality over quantity. + +**Which type of Alerting do you want to use?** + +- Choose between Grafana-managed Alerting or Grafana Mimir or Loki-managed Alerting; or both. + +**How do you want to organize your alerts and notifications?** + +- Be selective about who you set to receive alerts. Consider sending them to whoever is on call or a specific Slack channel. +- Automate as far as possible using the Alerting API or alerts as code (Terraform). + +**How can you reduce alert fatigue?** + +- Avoid noisy, unnecessary alerts by using silences, mute timings, or pausing alert rule evaluation. +- Continually tune your alert rules to review effectiveness. Remove alert rules to avoid duplication or ineffective alerts. +- Think carefully about priority and severity levels. +- Continually review your thresholds and evaluation rules. + +## Principles + +In Prometheus-based alerting systems, you have an alert generator that creates alerts and an alert receiver that receives alerts. For example, Prometheus is an alert generator and is responsible for evaluating alert rules, while Alertmanager is an alert receiver and is responsible for grouping, inhibiting, silencing, and sending notifications about firing and resolved alerts. + +Grafana Alerting is built on the Prometheus model of designing alerting systems. It has an internal alert generator responsible for scheduling and evaluating alert rules, as well as an internal alert receiver responsible for grouping, inhibiting, silencing, and sending notifications. Grafana doesn’t use Prometheus as its alert generator because Grafana Alerting needs to work with many other data sources in addition to Prometheus. However, it does use Alertmanager as its alert receiver. + +Alerts are sent to the alert receiver where they are routed, grouped, inhibited, silenced and notified. In Grafana Alerting, the default alert receiver is the Alertmanager embedded inside Grafana, and is referred to as the Grafana Alertmanager. However, you can use other Alertmanagers too, and these are referred to as [External Alertmanagers][external-alertmanagers]. + {{% docs/reference %}} -[external-alertmanagers]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/configure-alertmanager" +[external-alertmanagers]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/configure-alertmanager" [external-alertmanagers]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/configure-alertmanager" -[notification-policies]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals/notification-policies" -[notification-policies]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notification-policies" +[notification-policies]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/notifications/notification-policies" +[notification-policies]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notifications/notification-policies" {{% /docs/reference %}} diff --git a/docs/sources/alerting/fundamentals/alert-rules/_index.md b/docs/sources/alerting/fundamentals/alert-rules/_index.md index ed350a9e98c41..aefd84dd05357 100644 --- a/docs/sources/alerting/fundamentals/alert-rules/_index.md +++ b/docs/sources/alerting/fundamentals/alert-rules/_index.md @@ -1,4 +1,9 @@ --- +aliases: + - ../fundamentals/data-source-alerting/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/data-source-alerting/ + - ../fundamentals/alert-rules/alert-instances/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/alert-instances/ + - ../fundamentals/alert-rules/recording-rules/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/recording-rules/ + - ../fundamentals/alert-rules/alert-rule-types/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/alert-rule-types/ canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/ description: Learn about alert rules keywords: @@ -11,32 +16,115 @@ labels: - enterprise - oss title: Alert rules -weight: 130 +weight: 100 --- # Alert rules -An alert rule is a set of evaluation criteria for when an alert rule should fire. An alert rule consists of one or more queries and expressions, a condition, and the duration over which the condition needs to be met to start firing. +An alert rule is a set of evaluation criteria for when an alert rule should fire. An alert rule consists of one or more [queries and expressions, a condition][queries-and-conditions], and the duration over which the condition needs to be met to start firing. While queries and expressions select the data set to evaluate, a condition sets the threshold that an alert must meet or exceed to create an alert. -An interval specifies how frequently an alerting rule is evaluated. Duration, when configured, indicates how long a condition must be met. The alert rules can also define alerting behavior in the absence of data. +An interval specifies how frequently an [alert rule is evaluated][alert-rule-evaluation]. Duration, when configured, indicates how long a condition must be met. The alert rules can also define alerting behavior in the absence of data. -- [Alert rule types][alert-rule-types] -- [Alert instances][alert-instances] -- [Organising alert rules][organising-alerts] -- [Annotation and labels][annotation-label] +Grafana supports two different alert rule types: [Grafana-managed alert rules](#grafana-managed-alert-rules) and [Data source-managed alert rules](#data-source-managed-alert-rules). + +## Grafana-managed alert rules + +Grafana-managed alert rules are the most flexible alert rule type. They allow you to create alerts that can act on data from any of our supported data sources. + +In addition to supporting multiple data sources, you can also add expressions to transform your data and set alert conditions. Using images in alert notifications is also supported. This is the only type of rule that allows alerting from multiple data sources in a single rule definition. + +The following diagram shows how Grafana-managed alerting works. + +{{< figure src="/media/docs/alerting/grafana-managed-rule.png" max-width="750px" caption="Grafana-managed alerting" >}} + +1. Alert rules are created within Grafana based on one or more data sources. + +1. Alert rules are evaluated by the Alert Rule Evaluation Engine from within Grafana. + +1. Alerts are delivered using the internal Grafana Alertmanager. + + Note that you can also configure alerts to be delivered using an external Alertmanager; or use both internal and external alertmanagers. + +### Supported data sources + +Grafana-managed alert rules can query backend data sources if Grafana Alerting enabled by specifying `{"backend": true, "alerting": true}` in the [plugin.json](/developers/plugin-tools/reference-plugin-json). + +The following data sources are supported: + +- [Enterprise data source plugins](/grafana/plugins/data-source-plugins/?enterprise=1) and others maintained by Grafana such as [AWS Athena](/grafana/plugins/grafana-athena-datasource/), [AWS X-Ray](/grafana/plugins/grafana-x-ray-datasource/), [AWS Redshift](/grafana/plugins/grafana-redshift-datasource/), [AWS Timestream](/grafana/plugins/grafana-timestream-datasource/), [AWS IoT SiteWise](/grafana/plugins/grafana-iot-sitewise-datasource/), [Azure Data Explorer](/grafana/plugins/grafana-azure-data-explorer-datasource/), [Azure Monitor](/grafana/plugins/grafana-azure-monitor-datasource/), [ClickHouse](/grafana/plugins/grafana-clickhouse-datasource/), [Cloudwatch](/grafana/plugins/cloudwatch/), [CSV](/grafana/plugins/marcusolsson-csv-datasource/), [Elasticsearch](/grafana/plugins/elasticsearch/), [Falcon LogScale](/grafana/plugins/grafana-falconlogscale-datasource/), [GitHub](/grafana/plugins/grafana-github-datasource/), [Google BigQuery](/grafana/plugins/grafana-bigquery-datasource/), [Google Cloud Monitoring](/grafana/plugins/stackdriver/), [Graphite](/grafana/plugins/graphite/), [Loki](/grafana/plugins/loki/), [InfluxDB](/grafana/plugins/influxdb/), [Infinity](/grafana/plugins/yesoreyeram-infinity-datasource/), [MSSQL](/grafana/plugins/mssql/), [MySQL](/grafana/plugins/mysql/), [OpenSearch](/grafana/plugins/grafana-opensearch-datasource/), [OpenTSDB](/grafana/plugins/opentsdb/), [Oracle](/grafana/plugins/grafana-oracle-datasource/), [Orbit](/grafana/plugins/grafana-orbit-datasource/), [PostgreSQL](/grafana/plugins/postgres/), [Prometheus](/grafana/plugins/prometheus/), [Sentry](/grafana/plugins/grafana-sentry-datasource/), [SurrealDB](/grafana/plugins/grafana-surrealdb-datasource/), and [TestData](/grafana/plugins/grafana-testdata-datasource/). +- Backend data sources maintained by the [community](/grafana/plugins/data-source-plugins/?signature=community) and [partners](/grafana/plugins/data-source-plugins/?signature=commercial) that enable alerting. + +### Multi-dimensional alerts + +Grafana-managed alerting supports multi-dimensional alerting. Each alert rule can create multiple alert instances. This is exceptionally powerful if you are observing multiple series in a single expression. + +Consider the following PromQL expression: + +```promql +sum by(cpu) ( + rate(node_cpu_seconds_total{mode!="idle"}[1m]) +) +``` + +A rule using this expression will create as many alert instances as the amount of CPUs we are observing after the first evaluation, allowing a single rule to report the status of each CPU. + +{{< figure src="/static/img/docs/alerting/unified/multi-dimensional-alert.png" caption="A multi-dimensional Grafana managed alert rule" >}} + +## Data source-managed alert rules + +To create data source-managed alert rules, you must have a compatible Prometheus or Loki data source. + +You can check if your data source supports rule creation via Grafana by testing the data source and observing if the Ruler API is supported. + +For more information on the Ruler API, refer to [Ruler API](/docs/loki/latest/api/#ruler). + +The following diagram shows how data source-managed alerting works. + +{{< figure src="/media/docs/alerting/loki-mimir-rule.png" max-width="750px" caption="Grafana Mimir/Loki-managed alerting" >}} + +1. Alert rules are created and stored within the data source itself. +1. Alert rules can only be created based on Prometheus data. +1. Alert rule evaluation and delivery is distributed across multiple nodes for high availability and fault tolerance. + +### Recording rules + +A recording rule allows you to pre-compute frequently needed or computationally expensive expressions and save their result as a new set of time series. This is useful if you want to run alerts on aggregated data or if you have dashboards that query computationally expensive expressions repeatedly. + +Querying this new time series is faster, especially for dashboards since they query the same expression every time the dashboards refresh. + +Grafana Enterprise offers an alternative to recorded rules in the form of recorded queries that can be executed against any data source. + +For more information on recording rules, refer to [Create recording rules][create-recording-rules]. + +## Comparison between alert rule types + +When choosing which alert rule type to use, consider the following comparison between Grafana-managed and data source-managed alert rules. + +| <div style="width:200px">Feature</div> | <div style="width:200px">Grafana-managed alert rule</div> | <div style="width:200px">Data source-managed alert rule | +| ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Create alert rules<wbr /> based on data from any of our supported data sources | Yes | No: You can only create alert rules that are based on Prometheus data. The data source must have the Ruler API enabled. | +| Mix and match data sources | Yes | No | +| Includes support for recording rules | No | Yes | +| Add expressions to transform<wbr /> your data and set alert conditions | Yes | No | +| Use images in alert notifications | Yes | No | +| Scaling | More resource intensive, depend on the database, and are likely to suffer from transient errors. They only scale vertically. | Store alert rules within the data source itself and allow for “infinite” scaling. Generate and send alert notifications from the location of your data. | +| Alert rule evaluation and delivery | Alert rule evaluation and delivery is done from within Grafana, using an external Alertmanager; or both. | Alert rule evaluation and alert delivery is distributed, meaning there is no single point of failure. | + +**Note:** + +If you are using non-Prometheus data, we recommend choosing Grafana-managed alert rules. Otherwise, choose Grafana Mimir or Grafana Loki alert rules where possible. {{% docs/reference %}} -[alert-instances]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals/alert-rules/alert-instances" -[alert-instances]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/alert-instances" -[alert-rule-types]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals/alert-rules/alert-rule-types" -[alert-rule-types]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/alert-rule-types" +[create-recording-rules]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/create-mimir-loki-managed-recording-rule" +[create-recording-rules]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/create-mimir-loki-managed-recording-rule" + +[alert-rule-evaluation]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/rule-evaluation" +[alert-rule-evaluation]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/rule-evaluation" -[annotation-label]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals/annotation-label" -[annotation-label]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/annotation-label" +[queries-and-conditions]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/queries-conditions" +[queries-and-conditions]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/queries-conditions" -[organising-alerts]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals/alert-rules/organising-alerts" -[organising-alerts]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/organising-alerts" {{% /docs/reference %}} diff --git a/docs/sources/alerting/fundamentals/alert-rules/alert-instances.md b/docs/sources/alerting/fundamentals/alert-rules/alert-instances.md deleted file mode 100644 index f7a3793c1d476..0000000000000 --- a/docs/sources/alerting/fundamentals/alert-rules/alert-instances.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/alert-instances/ -description: Learn about alert instances -keywords: - - grafana - - alerting - - instances -labels: - products: - - cloud - - enterprise - - oss -title: Alert instances -weight: 105 ---- - -# Alert instances - -Grafana managed alerts support multi-dimensional alerting. Each alert rule can create multiple alert instances. This is exceptionally powerful if you are observing multiple series in a single expression. - -Consider the following PromQL expression: - -```promql -sum by(cpu) ( - rate(node_cpu_seconds_total{mode!="idle"}[1m]) -) -``` - -A rule using this expression will create as many alert instances as the amount of CPUs we are observing after the first evaluation, allowing a single rule to report the status of each CPU. - -{{< figure src="/static/img/docs/alerting/unified/multi-dimensional-alert.png" caption="A multi-dimensional Grafana managed alert rule" >}} diff --git a/docs/sources/alerting/fundamentals/alert-rules/alert-rule-types.md b/docs/sources/alerting/fundamentals/alert-rules/alert-rule-types.md deleted file mode 100644 index dd25109c20d8c..0000000000000 --- a/docs/sources/alerting/fundamentals/alert-rules/alert-rule-types.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/alert-rule-types/ -description: Learn about the different alert rule types that Grafana Alerting supports -keywords: - - grafana - - alerting - - rule types -labels: - products: - - cloud - - enterprise - - oss -title: Alert rule types -weight: 102 ---- - -# Alert rule types - -Grafana supports two different alert rule types. Learn more about each of the alert rule types, how they work, and decide which one is best for your use case. - -## Grafana-managed alert rules - -Grafana-managed alert rules are the most flexible alert rule type. They allow you to create alerts that can act on data from any of our supported data sources. - -In addition to supporting multiple data sources, you can also add expressions to transform your data and set alert conditions. Using images in alert notifications is also supported. This is the only type of rule that allows alerting from multiple data sources in a single rule definition. - -The following diagram shows how Grafana-managed alerting works. - -{{< figure src="/media/docs/alerting/grafana-managed-rule.png" max-width="750px" caption="Grafana-managed alerting" >}} - -1. Alert rules are created within Grafana based on one or more data sources. - -1. Alert rules are evaluated by the Alert Rule Evaluation Engine from within Grafana. - -1. Alerts are delivered using the internal Grafana Alertmanager. - -**Note:** - -You can also configure alerts to be delivered using an external Alertmanager; or use both internal and external alertmanagers. -For more information, see Add an external Alertmanager. - -## Data source-managed alert rules - -To create data source-managed alert rules, you must have a compatible Prometheus or Loki data source. - -You can check if your data source supports rule creation via Grafana by testing the data source and observing if the Ruler API is supported. - -For more information on the Ruler API, refer to [Ruler API](/docs/loki/latest/api/#ruler). - -The following diagram shows how data source-managed alerting works. - -{{< figure src="/media/docs/alerting/loki-mimir-rule.png" max-width="750px" caption="Grafana Mimir/Loki-managed alerting" >}} - -1. Alert rules are created and stored within the data source itself. -1. Alert rules can only be created based on Prometheus data. -1. Alert rule evaluation and delivery is distributed across multiple nodes for high availability and fault tolerance. - -## Choose an alert rule type - -When choosing which alert rule type to use, consider the following comparison between Grafana-managed alert rules and Grafana Mimir or Loki alert rules. - -{{< responsive-table >}} -| <div style="width:200px">Feature</div> | <div style="width:200px">Grafana-managed alert rule</div> | <div style="width:200px">Loki/Mimir-managed alert rule | -| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Create alert rules<wbr /> based on data from any of our supported data sources | Yes | No: You can only create alert rules that are based on Prometheus data. The data source must have the Ruler API enabled. | -| Mix and match data sources | Yes | No | -| Includes support for recording rules | No | Yes | -| Add expressions to transform<wbr /> your data and set alert conditions | Yes | No | -| Use images in alert notifications | Yes | No | -| Scaling | More resource intensive, depend on the database, and are likely to suffer from transient errors. They only scale vertically. | Store alert rules within the data source itself and allow for “infinite” scaling. Generate and send alert notifications from the location of your data. | -| Alert rule evaluation and delivery | Alert rule evaluation and delivery is done from within Grafana, using an external Alertmanager; or both. | Alert rule evaluation and alert delivery is distributed, meaning there is no single point of failure. | - -{{< /responsive-table >}} - -**Note:** - -If you are using non-Prometheus data, we recommend choosing Grafana-managed alert rules. Otherwise, choose Grafana Mimir or Grafana Loki alert rules where possible. diff --git a/docs/sources/alerting/fundamentals/alert-rules/annotation-label.md b/docs/sources/alerting/fundamentals/alert-rules/annotation-label.md new file mode 100644 index 0000000000000..05eeffcdf5cdf --- /dev/null +++ b/docs/sources/alerting/fundamentals/alert-rules/annotation-label.md @@ -0,0 +1,141 @@ +--- +aliases: + - ../../fundamentals/annotation-label/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/annotation-label/ + - ../../fundamentals/annotation-label/labels-and-label-matchers/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/annotation-label/labels-and-label-matchers/ + - ../../fundamentals/annotation-label/how-to-use-labels/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/annotation-label/how-to-use-labels/ + - ../../alerting-rules/alert-annotation-label/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/alert-annotation-label/ + - ../../unified-alerting/alerting-rules/alert-annotation-label/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/alerting-rules/alert-annotation-label/ +canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/annotation-label/ +description: Learn how to use annotations and labels to store key information about alerts +keywords: + - grafana + - alerting + - guide + - rules + - create +labels: + products: + - cloud + - enterprise + - oss +title: Labels and annotations +weight: 105 +--- + +# Labels and annotations + +Labels and annotations contain information about an alert. Labels are used to differentiate an alert from all other alerts, while annotations are used to add additional information to an existing alert. + +## Labels + +Labels contain information that identifies an alert. An example of a label might be `server=server1` or `team=backend`. Each alert can have more than one label, and the complete set of labels for an alert is called its label set. It is this label set that identifies the alert. + +For example, an alert might have the label set `{alertname="High CPU usage",server="server1"}` while another alert might have the label set `{alertname="High CPU usage",server="server2"}`. These are two separate alerts because although their `alertname` labels are the same, their `server` labels are different. + +Labels are a fundamental component of alerting: + +- The complete set of labels for an alert is what uniquely identifies an alert within Grafana alerts. +- The alerting UI shows labels for every alert instance generated during evaluation of that rule. +- Contact points can access labels to send notification messages that contain specific alert information. +- The Alertmanager uses labels to match alerts for silences and alert groups in notification policies. + +### How label matching works + +Use labels and label matchers to link alert rules to notification policies and silences. This allows for a flexible way to manage your alert instances, specify which policy should handle them, and which alerts to silence. + +A label matchers consists of 3 distinct parts, the **label**, the **value** and the **operator**. + +- The **Label** field is the name of the label to match. It must exactly match the label name. + +- The **Value** field matches against the corresponding value for the specified **Label** name. How it matches depends on the **Operator** value. + +- The **Operator** field is the operator to match against the label value. The available operators are: + + | Operator | Description | + | -------- | -------------------------------------------------- | + | `=` | Select labels that are exactly equal to the value. | + | `!=` | Select labels that are not equal to the value. | + | `=~` | Select labels that regex-match the value. | + | `!~` | Select labels that do not regex-match the value. | + +If you are using multiple label matchers, they are combined using the AND logical operator. This means that all matchers must match in order to link a rule to a policy. + +{{< collapse title="Label matching example" >}} + +If you define the following set of labels for your alert: + +`{ foo=bar, baz=qux, id=12 }` + +then: + +- A label matcher defined as `foo=bar` matches this alert rule. +- A label matcher defined as `foo!=bar` does _not_ match this alert rule. +- A label matcher defined as `id=~[0-9]+` matches this alert rule. +- A label matcher defined as `baz!~[0-9]+` matches this alert rule. +- Two label matchers defined as `foo=bar` and `id=~[0-9]+` match this alert rule. + +**Exclude labels** + +You can also write label matchers to exclude labels. + +Here is an example that shows how to exclude the label `Team`. You can choose between any of the values below to exclude labels. + +| Label | Operator | Value | +| ------ | -------- | ----- | +| `team` | `=` | `""` | +| `team` | `!~` | `.+` | +| `team` | `=~` | `^$` | + +{{< /collapse >}} + +## Label types + +An alert's label set can contain three types of labels: + +- Labels from the datasource, +- Custom labels specified in the alert rule, +- A series of reserved labels, such as `alertname` or `grafana_folder`. + +### Custom Labels + +Custom labels are additional labels configured manually in the alert rule. + +Ensure the label set for an alert does not have two or more labels with the same name. If a custom label has the same name as a label from the datasource then it will replace that label. However, should a custom label have the same name as a reserved label then the custom label will be omitted from the alert. + +{{< collapse title="Key format" >}} + +Grafana's built-in Alertmanager supports both Unicode label keys and values. If you are using an external Prometheus Alertmanager, label keys must be compatible with their [data model](https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels). +This means that label keys must only contain **ASCII letters**, **numbers**, as well as **underscores** and match the regex `[a-zA-Z_][a-zA-Z0-9_]*`. +Any invalid characters will be removed or replaced by the Grafana alerting engine before being sent to the external Alertmanager according to the following rules: + +- `Whitespace` will be removed. +- `ASCII characters` will be replaced with `_`. +- `All other characters` will be replaced with their lower-case hex representation. If this is the first character it will be prefixed with `_`. + +Example: A label key/value pair `Alert! 🔔="🔥"` will become `Alert_0x1f514="🔥"`. + +If multiple label keys are sanitized to the same value, the duplicates will have a short hash of the original label appended as a suffix. + +{{< /collapse >}} + +### Reserved labels + +Reserved labels can be used in the same way as manually configured custom labels. The current list of available reserved labels are: + +| Label | Description | +| -------------- | ----------------------------------------- | +| alert_name | The name of the alert rule. | +| grafana_folder | Title of the folder containing the alert. | + +Labels prefixed with `grafana_` are reserved by Grafana for special use. To stop Grafana Alerting from adding a reserved label, you can disable it via the `disabled_labels` option in [unified_alerting.reserved_labels](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana#unified_alertingreserved_labels) configuration. + +## Annotations + +Both labels and annotations have the same structure: a set of named values; however their intended uses are different. The purpose of annotations is to add additional information to existing alerts. + +There are a number of suggested annotations in Grafana such as `description`, `summary`, `runbook_url`, `dashboardUId` and `panelId`. Like custom labels, annotations must have a name, and their value can contain a combination of text and template code that is evaluated when an alert is fired. + +{{% docs/reference %}} +[variables-label-annotation]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/templating-labels-annotations" +[variables-label-annotation]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/templating-labels-annotations" +{{% /docs/reference %}} diff --git a/docs/sources/alerting/fundamentals/alert-rules/organising-alerts.md b/docs/sources/alerting/fundamentals/alert-rules/organising-alerts.md index 7952929c8526c..09113a1622e2c 100644 --- a/docs/sources/alerting/fundamentals/alert-rules/organising-alerts.md +++ b/docs/sources/alerting/fundamentals/alert-rules/organising-alerts.md @@ -1,7 +1,7 @@ --- aliases: - - ../unified-alerting/alerting-rules/edit-cortex-loki-namespace-group/ - - ../unified-alerting/alerting-rules/edit-mimir-loki-namespace-group/ + - ../../unified-alerting/alerting-rules/edit-cortex-loki-namespace-group/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/alerting-rules/edit-cortex-loki-namespace-group/ + - ../../unified-alerting/alerting-rules/edit-mimir-loki-namespace-group/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/alerting-rules/edit-mimir-loki-namespace-group/ canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/organising-alerts/ description: Learn about organizing alerts using namespaces, folders, and groups keywords: @@ -14,7 +14,7 @@ labels: - enterprise - oss title: Namespaces, folders, and groups -weight: 105 +weight: 107 --- ## Namespaces, folders, and groups diff --git a/docs/sources/alerting/fundamentals/alert-rules/queries-conditions/_index.md b/docs/sources/alerting/fundamentals/alert-rules/queries-conditions.md similarity index 62% rename from docs/sources/alerting/fundamentals/alert-rules/queries-conditions/_index.md rename to docs/sources/alerting/fundamentals/alert-rules/queries-conditions.md index 4dc1385bf4858..41e0f2d2101b4 100644 --- a/docs/sources/alerting/fundamentals/alert-rules/queries-conditions/_index.md +++ b/docs/sources/alerting/fundamentals/alert-rules/queries-conditions.md @@ -1,4 +1,7 @@ --- +aliases: + - ../../fundamentals/evaluate-grafana-alerts/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/evaluate-grafana-alerts/ + - ../../unified-alerting/fundamentals/evaluate-grafana-alerts/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/fundamentals/evaluate-grafana-alerts/ canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/queries-conditions/ description: Define queries to get the data you want to measure and conditions that need to be met before an alert rule fires keywords: @@ -128,10 +131,91 @@ When the queried data satisfies the defined condition, Grafana triggers the asso By default, the last expression added is used as the alert condition. +## Recovery threshold + +{{% admonition type="note" %}} +The recovery threshold feature is currently only available in OSS. +{{% /admonition %}} + +To reduce the noise of flapping alerts, you can set a recovery threshold different to the alert threshold. + +Flapping alerts occur when a metric hovers around the alert threshold condition and may lead to frequent state changes, resulting in too many notifications being generated. + +Grafana-managed alert rules are evaluated for a specific interval of time. During each evaluation, the result of the query is checked against the threshold set in the alert rule. If the value of a metric is above the threshold, an alert rule fires and a notification is sent. When the value goes below the threshold and there is an active alert for this metric, the alert is resolved, and another notification is sent. + +It can be tricky to create an alert rule for a noisy metric. That is, when the value of a metric continually goes above and below a threshold. This is called flapping and results in a series of firing - resolved - firing notifications and a noisy alert state history. + +For example, if you have an alert for latency with a threshold of 1000ms and the number fluctuates around 1000 (say 980 ->1010 -> 990 -> 1020, and so on) then each of those will trigger a notification. + +To solve this problem, you can set a (custom) recovery threshold, which basically means having two thresholds instead of one. An alert is triggered when the first threshold is crossed and is resolved only when the second threshold is crossed. + +For example, you could set a threshold of 1000ms and a recovery threshold of 900ms. This way, an alert rule will only stop firing when it goes under 900ms and flapping is reduced. + +## Alert on numeric data + +Among certain data sources numeric data that is not time series can be directly alerted on, or passed into Server Side Expressions (SSE). This allows for more processing and resulting efficiency within the data source, and it can also simplify alert rules. +When alerting on numeric data instead of time series data, there is no need to reduce each labeled time series into a single number. Instead labeled numbers are returned to Grafana instead. + +### Tabular Data + +This feature is supported with backend data sources that query tabular data: + +- SQL data sources such as MySQL, Postgres, MSSQL, and Oracle. +- The Azure Kusto based services: Azure Monitor (Logs), Azure Monitor (Azure Resource Graph), and Azure Data Explorer. + +A query with Grafana managed alerts or SSE is considered numeric with these data sources, if: + +- The "Format AS" option is set to "Table" in the data source query. +- The table response returned to Grafana from the query includes only one numeric (e.g. int, double, float) column, and optionally additional string columns. + +If there are string columns then those columns become labels. The name of column becomes the label name, and the value for each row becomes the value of the corresponding label. If multiple rows are returned, then each row should be uniquely identified their labels. + +**Example** + +For a MySQL table called "DiskSpace": + +| Time | Host | Disk | PercentFree | +| ----------- | ---- | ---- | ----------- | +| 2021-June-7 | web1 | /etc | 3 | +| 2021-June-7 | web2 | /var | 4 | +| 2021-June-7 | web3 | /var | 8 | +| ... | ... | ... | ... | + +You can query the data filtering on time, but without returning the time series to Grafana. For example, an alert that would trigger per Host, Disk when there is less than 5% free space: + +```sql +SELECT Host, Disk, CASE WHEN PercentFree < 5.0 THEN PercentFree ELSE 0 END FROM ( + SELECT + Host, + Disk, + Avg(PercentFree) + FROM DiskSpace + Group By + Host, + Disk + Where __timeFilter(Time) +``` + +This query returns the following Table response to Grafana: + +| Host | Disk | PercentFree | +| ---- | ---- | ----------- | +| web1 | /etc | 3 | +| web2 | /var | 4 | +| web3 | /var | 0 | + +When this query is used as the **condition** in an alert rule, then the non-zero will be alerting. As a result, three alert instances are produced: + +| Labels | Status | +| --------------------- | -------- | +| {Host=web1,disk=/etc} | Alerting | +| {Host=web2,disk=/var} | Alerting | +| {Host=web3,disk=/var} | Normal | + {{% docs/reference %}} -[data-source-alerting]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals/data-source-alerting" -[data-source-alerting]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/data-source-alerting" +[data-source-alerting]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules#supported-data-sources" +[data-source-alerting]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules#supported-data-sources" -[query-transform-data]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/query-transform-data" -[query-transform-data]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/query-transform-data" +[query-transform-data]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data" +[query-transform-data]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data" {{% /docs/reference %}} diff --git a/docs/sources/alerting/fundamentals/alert-rules/recording-rules/_index.md b/docs/sources/alerting/fundamentals/alert-rules/recording-rules/_index.md deleted file mode 100644 index 3065ca9408664..0000000000000 --- a/docs/sources/alerting/fundamentals/alert-rules/recording-rules/_index.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/recording-rules/ -description: Create recording rules to pre-compute frequently needed or computationally expensive expressions and save the result as a new set of time series -keywords: - - grafana - - alerting - - recording rules -labels: - products: - - cloud - - enterprise - - oss -title: Recording rules -weight: 103 ---- - -# Recording rules - -_Recording rules are only available for compatible Prometheus or Loki data sources._ - -A recording rule allows you to pre-compute frequently needed or computationally expensive expressions and save their result as a new set of time series. This is useful if you want to run alerts on aggregated data or if you have dashboards that query computationally expensive expressions repeatedly. - -Querying this new time series is faster, especially for dashboards since they query the same expression every time the dashboards refresh. - -Grafana Enterprise offers an alternative to recorded rules in the form of recorded queries that can be executed against any data source. - -For more information on recording rules in Prometheus, refer to [recording rules](https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/). diff --git a/docs/sources/alerting/fundamentals/alert-rules/rule-evaluation/_index.md b/docs/sources/alerting/fundamentals/alert-rules/rule-evaluation.md similarity index 79% rename from docs/sources/alerting/fundamentals/alert-rules/rule-evaluation/_index.md rename to docs/sources/alerting/fundamentals/alert-rules/rule-evaluation.md index e27bdbbf79d8f..a34a508601c7f 100644 --- a/docs/sources/alerting/fundamentals/alert-rules/rule-evaluation/_index.md +++ b/docs/sources/alerting/fundamentals/alert-rules/rule-evaluation.md @@ -11,7 +11,7 @@ labels: - enterprise - oss title: Alert rule evaluation -weight: 106 +weight: 108 --- # Alert rule evaluation @@ -22,9 +22,11 @@ To do this, you need to make sure that your alert rule is in the right evaluatio ## Evaluation group -Every alert rule is part of an evaluation group. Each evaluation group contains an evaluation interval that determines how frequently the alert rule is checked. Alert rules within the same group are evaluated one after the other, while alert rules in different groups can be evaluated simultaneously. +Every alert rule is part of an evaluation group. Each evaluation group contains an evaluation interval that determines how frequently the alert rule is checked. -This feature is especially useful for Prometheus/Mimir rules when you want to ensure that recording rules are evaluated before any alert rules. +**Data-source managed** alert rules within the same group are evaluated one after the other, while alert rules in different groups can be evaluated simultaneously. This feature is especially useful when you want to ensure that recording rules are evaluated before any alert rules. + +**Grafana-managed** alert rules are evaluated at the same time, regardless of alert rule group. The default evaluation interval is set at 10 seconds, which means that Grafana-managed alert rules are evaluated every 10 seconds to the closest 10-second window on the clock, for example, 10:00:00, 10:00:10, 10:00:20, and so on. You can also configure your own evaluation interval, if required. **Note:** diff --git a/docs/sources/alerting/fundamentals/alert-rules/state-and-health.md b/docs/sources/alerting/fundamentals/alert-rules/state-and-health.md index 1c58bcb91665f..3e067eadf0fde 100644 --- a/docs/sources/alerting/fundamentals/alert-rules/state-and-health.md +++ b/docs/sources/alerting/fundamentals/alert-rules/state-and-health.md @@ -1,6 +1,7 @@ --- aliases: - - ../unified-alerting/alerting-rules/state-and-health/ + - ../../fundamentals/state-and-health/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/state-and-health/ + - ../../unified-alerting/alerting-rules/state-and-health/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/alerting-rules/state-and-health canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/state-and-health/ description: Learn about the state and health of alert rules to understand several key status indicators about your alerts keywords: @@ -13,13 +14,13 @@ labels: - cloud - enterprise - oss -title: State and health of alerting rules -weight: 405 +title: State and health of alert rules +weight: 109 --- -# State and health of alerting rules +# State and health of alert rules -The state and health of alerting rules help you understand several key status indicators about your alerts. +The state and health of alert rules help you understand several key status indicators about your alerts. There are three key components: [alert rule state](#alert-rule-state), [alert instance state](#alert-instance-state), and [alert rule health](#alert-rule-health). Although related, each component conveys subtly different information. @@ -47,7 +48,7 @@ An alert instance can be in either of the following states: | **Pending** | The state of an alert that has been active for less than the configured threshold duration. | | **Alerting** | The state of an alert that has been active for longer than the configured threshold duration. | | **NoData** | No data has been received for the configured time window. | -| **Error** | The error that occurred when attempting to evaluate an alerting rule. | +| **Error** | The error that occurred when attempting to evaluate an alert rule. | ## Alert rule health @@ -55,13 +56,13 @@ An alert rule can have one the following health statuses: | State | Description | | ---------- | ---------------------------------------------------------------------------------- | -| **Ok** | No error when evaluating an alerting rule. | -| **Error** | An error occurred when evaluating an alerting rule. | +| **Ok** | No error when evaluating an alert rule. | +| **Error** | An error occurred when evaluating an alert rule. | | **NoData** | The absence of data in at least one time series returned during a rule evaluation. | ## Special alerts for `NoData` and `Error` -When evaluation of an alerting rule produces state `NoData` or `Error`, Grafana Alerting will generate alert instances that have the following additional labels: +When evaluation of an alert rule produces state `NoData` or `Error`, Grafana Alerting will generate alert instances that have the following additional labels: | Label | Description | | ------------------ | ---------------------------------------------------------------------- | diff --git a/docs/sources/alerting/fundamentals/alertmanager.md b/docs/sources/alerting/fundamentals/alertmanager.md deleted file mode 100644 index 7d559f0ddd993..0000000000000 --- a/docs/sources/alerting/fundamentals/alertmanager.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -aliases: - - ../fundamentals/alertmanager/ - - ../metrics/ - - ../unified-alerting/fundamentals/alertmanager/ - - alerting/manage-notifications/alertmanager/ -canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/alertmanager/ -description: Learn about Alertmanagers and the Alertmanager options for Grafana Alerting -labels: - products: - - cloud - - enterprise - - oss -title: Alertmanager -weight: 140 ---- - -# Alertmanager - -Alertmanager enables you to quickly and efficiently manage and respond to alerts. It receives alerts, handles silencing, inhibition, grouping, and routing by sending notifications out via your channel of choice, for example, email or Slack. - -In Grafana, you can use the Cloud Alertmanager, Grafana Alertmanager, or an external Alertmanager. You can also run multiple Alertmanagers; your decision depends on your set up and where your alerts are being generated. - -**Cloud Alertmanager** - -Cloud Alertmanager runs in Grafana Cloud and it can receive alerts from Grafana, Mimir, and Loki. - -**Grafana Alertmanager** - -Grafana Alertmanager is an internal Alertmanager that is pre-configured and available for selection by default if you run Grafana on-premises or open-source. - -The Grafana Alertmanager can receive alerts from Grafana, but it cannot receive alerts from outside Grafana, for example, from Mimir or Loki. - -**Note that inhibition rules are not supported in the Grafana Alertmanager.** - -**External Alertmanager** - -If you want to use a single Alertmanager to receive all your Grafana, Loki, Mimir, and Prometheus alerts, you can set up Grafana to use an external Alertmanager. This external Alertmanager can be configured and administered from within Grafana itself. - -Here are two examples of when you may want to configure your own external alertmanager and send your alerts there instead of the Grafana Alertmanager: - -1. You may already have Alertmanagers on-premises in your own Cloud infrastructure that you have set up and still want to use, because you have other alert generators, such as Prometheus. - -2. You want to use both Prometheus on-premises and hosted Grafana to send alerts to the same Alertmanager that runs in your Cloud infrastructure. - -Alertmanagers are visible from the drop-down menu on the Alerting Contact Points, Notification Policies, and Silences pages. - -If you are provisioning your data source, set the flag `handleGrafanaManagedAlerts` in the `jsonData` field to `true` to send Grafana-managed alerts to this Alertmanager. - -**Useful links** - -[Prometheus Alertmanager documentation](https://prometheus.io/docs/alerting/latest/alertmanager/) - -[Add an external Alertmanager][configure-alertmanager] - -{{% docs/reference %}} -[configure-alertmanager]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/configure-alertmanager" -[configure-alertmanager]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/configure-alertmanager" -{{% /docs/reference %}} diff --git a/docs/sources/alerting/fundamentals/annotation-label/_index.md b/docs/sources/alerting/fundamentals/annotation-label/_index.md deleted file mode 100644 index 2dab64ebce3ef..0000000000000 --- a/docs/sources/alerting/fundamentals/annotation-label/_index.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -aliases: - - ../alerting-rules/alert-annotation-label/ - - ../unified-alerting/alerting-rules/alert-annotation-label/ -canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/annotation-label/ -description: Learn how to use annotations and labels to store key information about alerts -keywords: - - grafana - - alerting - - guide - - rules - - create -labels: - products: - - cloud - - enterprise - - oss -title: Labels and annotations -weight: 120 ---- - -# Labels and annotations - -Labels and annotations contain information about an alert. Both labels and annotations have the same structure: a set of named values; however their intended uses are different. An example of label, or the equivalent annotation, might be `alertname="test"`. - -The main difference between a label and an annotation is that labels are used to differentiate an alert from all other alerts, while annotations are used to add additional information to an existing alert. - -For example, consider two high CPU alerts: one for `server1` and another for `server2`. In such an example we might have a label called `server` where the first alert has the label `server="server1"` and the second alert has the label `server="server2"`. However, we might also want to add a description to each alert such as `"The CPU usage for server1 is above 75%."`, where `server1` and `75%` are replaced with the name and CPU usage of the server (please refer to the documentation on [templating labels and annotations][variables-label-annotation] for how to do this). This kind of description would be more suitable as an annotation. - -## Labels - -Labels contain information that identifies an alert. An example of a label might be `server=server1`. Each alert can have more than one label, and the complete set of labels for an alert is called its label set. It is this label set that identifies the alert. - -For example, an alert might have the label set `{alertname="High CPU usage",server="server1"}` while another alert might have the label set `{alertname="High CPU usage",server="server2"}`. These are two separate alerts because although their `alertname` labels are the same, their `server` labels are different. - -The label set for an alert is a combination of the labels from the datasource, custom labels from the alert rule, and a number of reserved labels such as `alertname`. - -### Custom Labels - -Custom labels are additional labels from the alert rule. Like annotations, custom labels must have a name, and their value can contain a combination of text and template code that is evaluated when an alert is fired. Documentation on how to template custom labels can be found [here][variables-label-annotation]. - -When using custom labels with templates it is important to make sure that the label value does not change between consecutive evaluations of the alert rule as this will end up creating large numbers of distinct alerts. However, it is OK for the template to produce different label values for different alerts. For example, do not put the value of the query in a custom label as this will end up creating a new set of alerts each time the value changes. Instead use annotations. - -It is also important to make sure that the label set for an alert does not have two or more labels with the same name. If a custom label has the same name as a label from the datasource then it will replace that label. However, should a custom label have the same name as a reserved label then the custom label will be omitted from the alert. - -## Annotations - -Annotations are named pairs that add additional information to existing alerts. There are a number of suggested annotations in Grafana such as `description`, `summary`, `runbook_url`, `dashboardUId` and `panelId`. Like custom labels, annotations must have a name, and their value can contain a combination of text and template code that is evaluated when an alert is fired. If an annotation contains template code, the template is evaluated once when the alert is fired. It is not re-evaluated, even when the alert is resolved. Documentation on how to template annotations can be found [here][variables-label-annotation]. - -{{% docs/reference %}} -[variables-label-annotation]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals/annotation-label/variables-label-annotation" -[variables-label-annotation]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/annotation-label/variables-label-annotation" -{{% /docs/reference %}} diff --git a/docs/sources/alerting/fundamentals/annotation-label/how-to-use-labels.md b/docs/sources/alerting/fundamentals/annotation-label/how-to-use-labels.md deleted file mode 100644 index 241b047cbd214..0000000000000 --- a/docs/sources/alerting/fundamentals/annotation-label/how-to-use-labels.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/annotation-label/how-to-use-labels/ -description: Learn how to use labels to link alert rules to notification policies and silences -keywords: - - grafana - - alerting - - guide - - fundamentals -labels: - products: - - cloud - - enterprise - - oss -title: Labels in Grafana Alerting -weight: 117 ---- - -# Labels in Grafana Alerting - -This topic explains why labels are a fundamental component of alerting. - -- The complete set of labels for an alert is what uniquely identifies an alert within Grafana alerts. -- The Alertmanager uses labels to match alerts for silences and alert groups in notification policies. -- The alerting UI shows labels for every alert instance generated during evaluation of that rule. -- Contact points can access labels to dynamically generate notifications that contain information specific to the alert that is resulting in a notification. -- You can add labels to an [alerting rule][alerting-rules]. Labels are manually configurable, use template functions, and can reference other labels. Labels added to an alerting rule take precedence in the event of a collision between labels (except in the case of [Grafana reserved labels](#grafana-reserved-labels)). - -{{< figure src="/static/img/docs/alerting/unified/rule-edit-details-8-0.png" max-width="550px" caption="Alert details" >}} - -## External Alertmanager Compatibility - -Grafana's built-in Alertmanager supports both Unicode label keys and values. If you are using an external Prometheus Alertmanager, label keys must be compatible with their [data model](https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels). -This means that label keys must only contain **ASCII letters**, **numbers**, as well as **underscores** and match the regex `[a-zA-Z_][a-zA-Z0-9_]*`. -Any invalid characters will be removed or replaced by the Grafana alerting engine before being sent to the external Alertmanager according to the following rules: - -- `Whitespace` will be removed. -- `ASCII characters` will be replaced with `_`. -- `All other characters` will be replaced with their lower-case hex representation. If this is the first character it will be prefixed with `_`. - -Example: A label key/value pair `Alert! 🔔="🔥"` will become `Alert_0x1f514="🔥"`. - -**Note** If multiple label keys are sanitized to the same value, the duplicates will have a short hash of the original label appended as a suffix. - -## Grafana reserved labels - -{{% admonition type="note" %}} -Labels prefixed with `grafana_` are reserved by Grafana for special use. If a manually configured label is added beginning with `grafana_` it may be overwritten in case of collision. -To stop the Grafana Alerting engine from adding a reserved label, you can disable it via the `disabled_labels` option in [unified_alerting.reserved_labels][unified-alerting-reserved-labels] configuration. -{{% /admonition %}} - -Grafana reserved labels can be used in the same way as manually configured labels. The current list of available reserved labels are: - -| Label | Description | -| -------------- | ----------------------------------------- | -| grafana_folder | Title of the folder containing the alert. | - -{{% docs/reference %}} -[alerting-rules]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/alerting-rules" -[alerting-rules]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules" - -[unified-alerting-reserved-labels]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana#unified_alertingreserved_labels" -[unified-alerting-reserved-labels]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana#unified_alertingreserved_labels" -{{% /docs/reference %}} diff --git a/docs/sources/alerting/fundamentals/annotation-label/labels-and-label-matchers.md b/docs/sources/alerting/fundamentals/annotation-label/labels-and-label-matchers.md deleted file mode 100644 index 44f780fb17edb..0000000000000 --- a/docs/sources/alerting/fundamentals/annotation-label/labels-and-label-matchers.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/annotation-label/labels-and-label-matchers/ -description: Learn how to use label matchers to link alert rules to notification policies and silences -keywords: - - grafana - - alerting - - guide - - fundamentals -labels: - products: - - cloud - - enterprise - - oss -menuTitle: Label matchers -title: How label matching works -weight: 117 ---- - -# How label matching works - -Use labels and label matchers to link alert rules to notification policies and silences. This allows for a very flexible way to manage your alert instances, specify which policy should handle them, and which alerts to silence. - -A label matchers consists of 3 distinct parts, the **label**, the **value** and the **operator**. - -- The **Label** field is the name of the label to match. It must exactly match the label name. - -- The **Value** field matches against the corresponding value for the specified **Label** name. How it matches depends on the **Operator** value. - -- The **Operator** field is the operator to match against the label value. The available operators are: - -| Operator | Description | -| -------- | -------------------------------------------------- | -| `=` | Select labels that are exactly equal to the value. | -| `!=` | Select labels that are not equal to the value. | -| `=~` | Select labels that regex-match the value. | -| `!~` | Select labels that do not regex-match the value. | - -If you are using multiple label matchers, they are combined using the AND logical operator. This means that all matchers must match in order to link a rule to a policy. - -## Example scenario - -If you define the following set of labels for your alert: - -`{ foo=bar, baz=qux, id=12 }` - -then: - -- A label matcher defined as `foo=bar` matches this alert rule. -- A label matcher defined as `foo!=bar` does _not_ match this alert rule. -- A label matcher defined as `id=~[0-9]+` matches this alert rule. -- A label matcher defined as `baz!~[0-9]+` matches this alert rule. -- Two label matchers defined as `foo=bar` and `id=~[0-9]+` match this alert rule. - -## Exclude labels - -You can also write label matchers to exclude labels. - -Here is an example that shows how to exclude the label `Team`. You can choose between any of the values below to exclude labels. - -| Label | Operator | Value | -| ------ | -------- | ----- | -| `team` | `=` | `""` | -| `team` | `!~` | `.+` | -| `team` | `=~` | `^$` | diff --git a/docs/sources/alerting/fundamentals/data-source-alerting.md b/docs/sources/alerting/fundamentals/data-source-alerting.md deleted file mode 100644 index e2c09e1cd4658..0000000000000 --- a/docs/sources/alerting/fundamentals/data-source-alerting.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/data-source-alerting/ -description: Learn about the data sources supported by Grafana Alerting -labels: - products: - - cloud - - enterprise - - oss -title: Data sources and Grafana Alerting -weight: 100 ---- - -# Data sources and Grafana Alerting - -There are a number of data sources that are compatible with Grafana Alerting. Each data source is supported by a plugin. You can use one of the built-in data sources listed below, use [external data source plugins](/grafana/plugins/?type=datasource), or create your own data source plugin. - -If you are creating your own data source plugin, make sure it is a backend plugin as Grafana Alerting requires this in order to be able to evaluate rules using the data source. Frontend data sources are not supported, because the evaluation engine runs on the backend. - -Specifying `{ "alerting": true, “backend”: true }` in the plugin.json file indicates that the data source plugin is compatible with Grafana Alerting and includes the backend data-fetching code. For more information, refer to [Build a data source backend plugin](/tutorials/build-a-data-source-backend-plugin/). - -These are the data sources that are compatible with and supported by Grafana Alerting. - -- [AWS CloudWatch][] -- [Azure Monitor][] -- [Elasticsearch][] -- [Google Cloud Monitoring][] -- [Graphite][] -- [InfluxDB][] -- [Loki][] -- [Microsoft SQL Server (MSSQL)][] -- [MySQL][] -- [Open TSDB][] -- [PostgreSQL][] -- [Prometheus][] -- [Jaeger][] -- [Zipkin][] -- [Tempo][] -- [Testdata][] - -## Useful links - -- [Grafana data sources][] - -{{% docs/reference %}} -[Grafana data sources]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources" -[Grafana data sources]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources" - -[AWS CloudWatch]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/aws-cloudwatch" -[AWS CloudWatch]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/aws-cloudwatch" - -[Azure Monitor]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/azure-monitor" -[Azure Monitor]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/azure-monitor" - -[Elasticsearch]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/elasticsearch" -[Elasticsearch]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/elasticsearch" - -[Google Cloud Monitoring]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/google-cloud-monitoring" -[Google Cloud Monitoring]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/google-cloud-monitoring" - -[Graphite]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/graphite" -[Graphite]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/graphite" - -[InfluxDB]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/influxdb" -[InfluxDB]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/influxdb" - -[Loki]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/loki" -[Loki]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/loki" - -[Microsoft SQL Server (MSSQL)]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/mssql" -[Microsoft SQL Server (MSSQL)]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/mssql" - -[MySQL]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/mysql" -[MySQL]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/mysql" - -[Open TSDB]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/opentsdb" -[Open TSDB]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/opentsdb" - -[PostgreSQL]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/postgres" -[PostgreSQL]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/postgres" - -[Prometheus]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/prometheus" -[Prometheus]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/prometheus" - -[Jaeger]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/jaeger" -[Jaeger]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/jaeger" - -[Zipkin]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/zipkin" -[Zipkin]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/zipkin" - -[Tempo]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/tempo" -[Tempo]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/tempo" - -[Testdata]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/testdata" -[Testdata]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/testdata" -{{% /docs/reference %}} diff --git a/docs/sources/alerting/fundamentals/evaluate-grafana-alerts.md b/docs/sources/alerting/fundamentals/evaluate-grafana-alerts.md deleted file mode 100644 index aeacd684b3c8c..0000000000000 --- a/docs/sources/alerting/fundamentals/evaluate-grafana-alerts.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -aliases: - - ../metrics/ - - ../unified-alerting/fundamentals/evaluate-grafana-alerts/ -canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/evaluate-grafana-alerts/ -description: Learn how how Grafana-managed alerts are evaluated by the backend engine as well as how Grafana handles alerting on numeric rather than time series data -labels: - products: - - cloud - - enterprise - - oss -title: Alerting on numeric data -weight: 110 ---- - -# Alerting on numeric data - -This topic describes how Grafana managed alerts are evaluated by the backend engine as well as how Grafana handles alerting on numeric rather than time series data. - -- [Alerting on numeric data](#alerting-on-numeric-data) - - [Alert evaluation](#alert-evaluation) - - [Metrics from the alerting engine](#metrics-from-the-alerting-engine) - - [Alerting on numeric data](#alerting-on-numeric-data-1) - - [Tabular Data](#tabular-data) - - [Example](#example) - -## Alert evaluation - -Grafana managed alerts query the following backend data sources that have alerting enabled: - -- built-in data sources or those developed and maintained by Grafana: `Graphite`, `Prometheus`, `Loki`, `InfluxDB`, `Elasticsearch`, - `Google Cloud Monitoring`, `Cloudwatch`, `Azure Monitor`, `MySQL`, `PostgreSQL`, `MSSQL`, `OpenTSDB`, `Oracle`, and `Azure Monitor` -- community developed backend data sources with alerting enabled (`backend` and `alerting` properties are set in the [plugin.json](/developers/plugin-tools/reference-plugin-json) - -### Metrics from the alerting engine - -The alerting engine publishes some internal metrics about itself. You can read more about how Grafana publishes [internal metrics][set-up-grafana-monitoring]. - -| Metric Name | Type | Description | -| ------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------- | -| `grafana_alerting_alerts` | gauge | How many alerts by state | -| `grafana_alerting_request_duration` | histogram | Histogram of requests to the Alerting API | -| `grafana_alerting_active_configurations` | gauge | The number of active, non default Alertmanager configurations for grafana managed alerts | -| `grafana_alerting_rule_evaluations_total` | counter | The total number of rule evaluations | -| `grafana_alerting_rule_evaluation_failures_total` | counter | The total number of rule evaluation failures | -| `grafana_alerting_rule_evaluation_duration` | summary | The duration for a rule to execute | -| `grafana_alerting_rule_group_rules` | gauge | The number of rules | - -## Alerting on numeric data - -Among certain data sources numeric data that is not time series can be directly alerted on, or passed into Server Side Expressions (SSE). This allows for more processing and resulting efficiency within the data source, and it can also simplify alert rules. -When alerting on numeric data instead of time series data, there is no need to reduce each labeled time series into a single number. Instead labeled numbers are returned to Grafana instead. - -### Tabular Data - -This feature is supported with backend data sources that query tabular data: - -- SQL data sources such as MySQL, Postgres, MSSQL, and Oracle. -- The Azure Kusto based services: Azure Monitor (Logs), Azure Monitor (Azure Resource Graph), and Azure Data Explorer. - -A query with Grafana managed alerts or SSE is considered numeric with these data sources, if: - -- The "Format AS" option is set to "Table" in the data source query. -- The table response returned to Grafana from the query includes only one numeric (e.g. int, double, float) column, and optionally additional string columns. - -If there are string columns then those columns become labels. The name of column becomes the label name, and the value for each row becomes the value of the corresponding label. If multiple rows are returned, then each row should be uniquely identified their labels. - -### Example - -For a MySQL table called "DiskSpace": - -| Time | Host | Disk | PercentFree | -| ----------- | ---- | ---- | ----------- | -| 2021-June-7 | web1 | /etc | 3 | -| 2021-June-7 | web2 | /var | 4 | -| 2021-June-7 | web3 | /var | 8 | -| ... | ... | ... | ... | - -You can query the data filtering on time, but without returning the time series to Grafana. For example, an alert that would trigger per Host, Disk when there is less than 5% free space: - -```sql -SELECT Host, Disk, CASE WHEN PercentFree < 5.0 THEN PercentFree ELSE 0 END FROM ( - SELECT - Host, - Disk, - Avg(PercentFree) - FROM DiskSpace - Group By - Host, - Disk - Where __timeFilter(Time) -``` - -This query returns the following Table response to Grafana: - -| Host | Disk | PercentFree | -| ---- | ---- | ----------- | -| web1 | /etc | 3 | -| web2 | /var | 4 | -| web3 | /var | 0 | - -When this query is used as the **condition** in an alert rule, then the non-zero will be alerting. As a result, three alert instances are produced: - -| Labels | Status | -| --------------------- | -------- | -| {Host=web1,disk=/etc} | Alerting | -| {Host=web2,disk=/var} | Alerting | -| {Host=web3,disk=/var} | Normal | - -{{% docs/reference %}} - -[set-up-grafana-monitoring]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/set-up-grafana-monitoring" -[set-up-grafana-monitoring]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/set-up-grafana-monitoring" -{{% /docs/reference %}} diff --git a/docs/sources/alerting/fundamentals/high-availability/_index.md b/docs/sources/alerting/fundamentals/high-availability/_index.md deleted file mode 100644 index 4e43fa4b21b31..0000000000000 --- a/docs/sources/alerting/fundamentals/high-availability/_index.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -aliases: - - ../high-availability/ - - ../unified-alerting/high-availability/ -canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/high-availability/ -description: Learn about high availability in Grafana Alerting -keywords: - - grafana - - alerting - - tutorials - - ha - - high availability -labels: - products: - - cloud - - enterprise - - oss -title: Alerting high availability -weight: 170 ---- - -# Alerting high availability - -Grafana Alerting uses the Prometheus model of separating the evaluation of alert rules from the delivering of notifications. In this model the evaluation of alert rules is done in the alert generator and the delivering of notifications is done in the alert receiver. In Grafana Alerting, the alert generator is the Scheduler and the receiver is the Alertmanager. - -{{< figure src="/static/img/docs/alerting/unified/high-availability-ua.png" class="docs-image--no-shadow" max-width= "750px" caption="High availability" >}} - -When running multiple instances of Grafana, all alert rules are evaluated on all instances. You can think of the evaluation of alert rules as being duplicated. This is how Grafana Alerting makes sure that as long as at least one Grafana instance is working, alert rules will still be evaluated and notifications for alerts will still be sent. You will see this duplication in state history, and is a good way to tell if you are using high availability. - -While the alert generator evaluates all alert rules on all instances, the alert receiver makes a best-effort attempt to avoid sending duplicate notifications. Alertmanager chooses availability over consistency, which may result in occasional duplicated or out-of-order notifications. It takes the opinion that duplicate or out-of-order notifications are better than no notifications. - -The Alertmanager uses a gossip protocol to share information about notifications between Grafana instances. It also gossips silences, which means a silence created on one Grafana instance is replicated to all other Grafana instances. Both notifications and silences are persisted to the database periodically, and during graceful shut down. - -It is important to make sure that gossiping is configured and tested. You can find the documentation on how to do that [here][configure-high-availability]. - -## Useful links - -[Configure alerting high availability][configure-high-availability] - -{{% docs/reference %}} -[configure-high-availability]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/configure-high-availability" -[configure-high-availability]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/configure-high-availability" -{{% /docs/reference %}} diff --git a/docs/sources/alerting/fundamentals/notification-policies/_index.md b/docs/sources/alerting/fundamentals/notifications/_index.md similarity index 87% rename from docs/sources/alerting/fundamentals/notification-policies/_index.md rename to docs/sources/alerting/fundamentals/notifications/_index.md index 56dc8e06bd304..693c673beda4d 100644 --- a/docs/sources/alerting/fundamentals/notification-policies/_index.md +++ b/docs/sources/alerting/fundamentals/notifications/_index.md @@ -1,6 +1,8 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/notification-policies/ -description: Learn about how notification policies work +aliases: + - ./notification-policies/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/notification-policies/ +canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/notifications/ +description: Learn about how notifications work keywords: - grafana - alerting @@ -11,7 +13,7 @@ labels: - enterprise - oss title: Notifications -weight: 160 +weight: 110 --- # Notifications @@ -26,6 +28,12 @@ Next, create a notification policy which is a set of rules for where, when and h Grafana uses Alertmanagers to send notifications for firing and resolved alerts. Grafana has its own Alertmanager, referred to as "Grafana" in the user interface, but also supports sending notifications from other Alertmanagers too, such as the [Prometheus Alertmanager](https://prometheus.io/docs/alerting/latest/alertmanager/). The Grafana Alertmanager uses notification policies and contact points to configure how and where a notification is sent; how often a notification should be sent; and whether alerts should all be sent in the same notification, sent in grouped notifications based on a set of labels, or as separate notifications. +## Contact points + +Contact points contain the configuration for sending alert notifications, specifying destinations like email, Slack, OnCall, webhooks, and their notification messages. They allow the customization of notification messages and the use of notification templates. + +A contact point is a list of integrations, each sending a message to a specific destination. You can configure them via notification policies or alert rules. + ## Notification policies Notification policies control when and where notifications are sent. A notification policy can choose to send all alerts together in the same notification, send alerts in grouped notifications based on a set of labels, or send alerts as separate notifications. You can configure each notification policy to control how often notifications should be sent as well as having one or more mute timings to inhibit notifications at certain times of the day and on certain days of the week. diff --git a/docs/sources/alerting/fundamentals/notifications/alertmanager.md b/docs/sources/alerting/fundamentals/notifications/alertmanager.md new file mode 100644 index 0000000000000..8a744659fb09b --- /dev/null +++ b/docs/sources/alerting/fundamentals/notifications/alertmanager.md @@ -0,0 +1,46 @@ +--- +aliases: + - ../../fundamentals/alertmanager/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alertmanager/ + - ../../unified-alerting/fundamentals/alertmanager/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/fundamentals/alertmanager/ + - ../../manage-notifications/alertmanager/ # /docs/grafana/<GRAFANA_VERSION>/alerting/manage-notifications/alertmanager/ +canonical: https://grafana.com/docs/grafana/latest/alerting/notifications/alertmanager/ +description: Learn about Alertmanagers and the Alertmanager options for Grafana Alerting +labels: + products: + - cloud + - enterprise + - oss +title: Alertmanager +weight: 111 +--- + +# Alertmanager + +Grafana sends firing and resolved alerts to Alertmanagers. The Alertmanager receives alerts, handles silencing, inhibition, grouping, and routing by sending notifications out via your channel of choice, for example, email or Slack. + +Grafana has its own Alertmanager, referred to as "Grafana" in the user interface, but also supports sending alerts to other Alertmanagers too, such as the [Prometheus Alertmanager](https://prometheus.io/docs/alerting/latest/alertmanager/). + +The Grafana Alertmanager uses notification policies and contact points to configure how and where a notification is sent; how often a notification should be sent; and whether alerts should all be sent in the same notification, sent in grouped notifications based on a set of labels, or as separate notifications. + +Alertmanagers are visible from the drop-down menu on the Alerting Contact Points, Notification Policies, and Silences pages. + +In Grafana, you can use the Cloud Alertmanager, Grafana Alertmanager, or an external Alertmanager. You can also run multiple Alertmanagers; your decision depends on your set up and where your alerts are being generated. + +- **Grafana Alertmanager** is an internal Alertmanager that is pre-configured and available for selection by default if you run Grafana on-premises or open-source. + + The Grafana Alertmanager can receive alerts from Grafana, but it cannot receive alerts from outside Grafana, for example, from Mimir or Loki. Note that inhibition rules are not supported. + +- **Cloud Alertmanager** runs in Grafana Cloud and it can receive alerts from Grafana, Mimir, and Loki. + +- **External Alertmanager** can receive all your Grafana, Loki, Mimir, and Prometheus alerts. External Alertmanagers can be configured and administered from within Grafana itself. + +Here are two examples of when you may want to [add your own external alertmanager][configure-alertmanager] and send your alerts there instead of the Grafana Alertmanager: + +1. You may already have Alertmanagers on-premises in your own Cloud infrastructure that you have set up and still want to use, because you have other alert generators, such as Prometheus. + +2. You want to use both Prometheus on-premises and hosted Grafana to send alerts to the same Alertmanager that runs in your Cloud infrastructure. + +{{% docs/reference %}} +[configure-alertmanager]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/configure-alertmanager" +[configure-alertmanager]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/configure-alertmanager" +{{% /docs/reference %}} diff --git a/docs/sources/alerting/fundamentals/contact-points/index.md b/docs/sources/alerting/fundamentals/notifications/contact-points.md similarity index 92% rename from docs/sources/alerting/fundamentals/contact-points/index.md rename to docs/sources/alerting/fundamentals/notifications/contact-points.md index 5494a9b6c0a73..98aa75a0f8c9a 100644 --- a/docs/sources/alerting/fundamentals/contact-points/index.md +++ b/docs/sources/alerting/fundamentals/notifications/contact-points.md @@ -1,9 +1,10 @@ --- aliases: - - /docs/grafana/latest/alerting/contact-points/ - - /docs/grafana/latest/alerting/unified-alerting/contact-points/ - - /docs/grafana/latest/alerting/fundamentals/contact-points/contact-point-types/ -canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/contact-points/ + - ../../fundamentals/contact-points/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/contact-points/ + - ../../fundamentals/contact-points/contact-point-types/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/contact-points/contact-point-types/ + - ../../contact-points/ # /docs/grafana/<GRAFANA_VERSION>/alerting/contact-points/ + - ../../unified-alerting/contact-points/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/contact-points/ +canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/notifications/contact-points/ description: Learn about contact points and the supported contact point integrations keywords: - grafana @@ -18,7 +19,7 @@ labels: - enterprise - oss title: Contact points -weight: 150 +weight: 112 --- # Contact points diff --git a/docs/sources/alerting/fundamentals/alert-rules/message-templating.md b/docs/sources/alerting/fundamentals/notifications/message-templating.md similarity index 86% rename from docs/sources/alerting/fundamentals/alert-rules/message-templating.md rename to docs/sources/alerting/fundamentals/notifications/message-templating.md index b9aa80e72edd2..9e6d33eae91b2 100644 --- a/docs/sources/alerting/fundamentals/alert-rules/message-templating.md +++ b/docs/sources/alerting/fundamentals/notifications/message-templating.md @@ -1,9 +1,9 @@ --- aliases: - - ../../contact-points/message-templating/ - - ../../message-templating/ - - ../../unified-alerting/message-templating/ -canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/message-templating/ + - ../../contact-points/message-templating/ # /docs/grafana/<GRAFANA_VERSION>/alerting/contact-points/message-templating/ + - ../../alert-rules/message-templating/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alert-rules/message-templating/ + - ../../unified-alerting/message-templating/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/message-templating/ +canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/notifications/message-templating/ description: Learn about notification templating keywords: - grafana @@ -16,11 +16,11 @@ labels: - cloud - enterprise - oss -title: Notification templating -weight: 415 +title: Notification templates +weight: 114 --- -# Notification templating +# Notification templates Notifications sent via contact points are built using notification templates. Grafana's default templates are based on the [Go templating system](https://golang.org/pkg/text/template) where some fields are evaluated as text, while others are evaluated as HTML (which can affect escaping). diff --git a/docs/sources/alerting/fundamentals/notification-policies/notifications.md b/docs/sources/alerting/fundamentals/notifications/notification-policies.md similarity index 94% rename from docs/sources/alerting/fundamentals/notification-policies/notifications.md rename to docs/sources/alerting/fundamentals/notifications/notification-policies.md index fd98ef3961b38..1e4d1d2dcf68f 100644 --- a/docs/sources/alerting/fundamentals/notification-policies/notifications.md +++ b/docs/sources/alerting/fundamentals/notifications/notification-policies.md @@ -1,8 +1,7 @@ --- aliases: - - ../notifications/ - - alerting/manage-notifications/create-notification-policy/ -canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/notification-policies/notifications/ + - ../notification-policies/notifications/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/notification-policies/notifications/ +canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/notifications/notification-policies/ description: Learn about how notification policies work and are structured keywords: - grafana @@ -17,7 +16,7 @@ labels: - enterprise - oss title: Notification policies -weight: 410 +weight: 113 --- # Notification policies @@ -44,7 +43,7 @@ To determine which notification policy will handle which alert instances, you ha If no policies other than the default policy are configured, the default policy will handle the alert instance. -If policies other than the default policy are defined, it will inspect those notification policies in descending order. +If policies other than the default policy are defined, it will evaluate those notification policies in the order they are displayed. If a notification policy has label matchers that match the labels of the alert instance, it will descend in to its child policies and, if there are any, will continue to look for any child policies that might have label matchers that further narrow down the set of labels, and so forth until no more child policies have been found. @@ -132,6 +131,6 @@ Repeat interval decides how often notifications are repeated if the group has no **Default** 4 hours {{% docs/reference %}} -[labels-and-label-matchers]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals/annotation-label/labels-and-label-matchers" -[labels-and-label-matchers]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/annotation-label/labels-and-label-matchers" +[labels-and-label-matchers]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/annotation-label#how-label-matching-works" +[labels-and-label-matchers]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label#how-label-matching-works" {{% /docs/reference %}} diff --git a/docs/sources/alerting/manage-notifications/_index.md b/docs/sources/alerting/manage-notifications/_index.md index 31f9683375f08..9adb839afcce8 100644 --- a/docs/sources/alerting/manage-notifications/_index.md +++ b/docs/sources/alerting/manage-notifications/_index.md @@ -1,47 +1,22 @@ --- canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/ -description: Manage your alerts by creating silences, mute timings, and more +description: Detect and respond for day-to-day triage and analysis of what’s going on and action you need to take keywords: - grafana - - alert - - notifications + - detect + - respond labels: products: - cloud - enterprise - oss -menuTitle: Manage -title: Manage your alerts +menuTitle: Detect and respond +title: Detect and respond weight: 130 --- -# Manage your alerts +# Detect and respond -Once you have set up your alert rules, contact points, and notification policies, you can use Grafana Alerting to: +Use Grafana Alerting to track and generate alerts and send notifications, providing an efficient way for engineers to monitor, respond, and triage issues within their services. -[Create silences][create-silence] - -[Create mute timings][mute-timings] - -[Declare incidents from firing alerts][declare-incident-from-firing-alert] - -[View the state and health of alert rules][view-state-health] - -[View and filter alert rules][view-alert-rules] - -{{% docs/reference %}} -[create-silence]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/manage-notifications/create-silence" -[create-silence]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/manage-notifications/create-silence" - -[declare-incident-from-firing-alert]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/manage-notifications/declare-incident-from-alert" -[declare-incident-from-firing-alert]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/manage-notifications/declare-incident-from-alert" - -[mute-timings]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/manage-notifications/mute-timings" -[mute-timings]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/manage-notifications/mute-timings" - -[view-alert-rules]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/manage-notifications/view-alert-rules" -[view-alert-rules]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/manage-notifications/view-alert-rules" - -[view-state-health]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/manage-notifications/view-state-health" -[view-state-health]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/manage-notifications/view-state-health" -{{% /docs/reference %}} +Alerts and alert notifications provide a lot of value as key indicators to issues during the triage process, providing engineers with the information they need to understand what is going on in their system or service. diff --git a/docs/sources/alerting/manage-notifications/declare-incident-from-alert.md b/docs/sources/alerting/manage-notifications/declare-incident-from-alert.md index 10459fa355e68..019efae8699f8 100644 --- a/docs/sources/alerting/manage-notifications/declare-incident-from-alert.md +++ b/docs/sources/alerting/manage-notifications/declare-incident-from-alert.md @@ -1,6 +1,6 @@ --- aliases: - - alerting/alerting-rules/declare-incident-from-alert/ + - ../../alerting/alerting-rules/declare-incident-from-alert/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/declare-incident-from-alert/ canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/declare-incident-from-alert/ description: Declare an incident from a firing alert keywords: diff --git a/docs/sources/alerting/manage-notifications/manage-contact-points.md b/docs/sources/alerting/manage-notifications/manage-contact-points.md deleted file mode 100644 index bbfd98e4b1e29..0000000000000 --- a/docs/sources/alerting/manage-notifications/manage-contact-points.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/manage-contact-points/ -description: View, edit, copy, or delete your contact points and notification templates -keywords: - - grafana - - alerting - - contact points - - search - - export -labels: - products: - - cloud - - enterprise - - oss -title: Manage contact points -weight: 410 ---- - -# Manage contact points - -The Contact points list view lists all existing contact points and notification templates. - -On the **Contact Points** tab, you can: - -- Search for name and type of contact points and integrations -- View all existing contact points and integrations -- View how many notification policies each contact point is being used for and navigate directly to the linked notification policies -- View the status of notification deliveries -- Export individual contact points or all contact points in JSON, YAML, or Terraform format -- Delete contact points that are not in use by a notification policy - -On the **Notification templates** tab, you can: - -- View, edit, copy or delete existing notification templates diff --git a/docs/sources/alerting/manage-notifications/view-alert-groups.md b/docs/sources/alerting/manage-notifications/view-alert-groups.md index 105806767dd03..f050af7373fa0 100644 --- a/docs/sources/alerting/manage-notifications/view-alert-groups.md +++ b/docs/sources/alerting/manage-notifications/view-alert-groups.md @@ -1,10 +1,9 @@ --- aliases: - - -docs/grafana/latest/alerting/manage-notifications/view-alert-groups/ - - ../alert-groups/ - - ../alert-groups/filter-alerts/ - - ../alert-groups/view-alert-grouping/ - - ../unified-alerting/alert-groups/ + - ../../alerting/alert-groups/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alert-groups/ + - ../../alerting/alert-groups/filter-alerts/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alert-groups/filter-alerts/ + - ../../alerting/alert-groups/view-alert-grouping/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alert-groups/view-alert-grouping/ + - ../../alerting/unified-alerting/alert-groups/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/alert-groups/ canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/view-alert-groups/ description: Alert groups keywords: diff --git a/docs/sources/alerting/manage-notifications/view-alert-rules.md b/docs/sources/alerting/manage-notifications/view-alert-rules.md index 0306c707d9130..824c4a69ce7a4 100644 --- a/docs/sources/alerting/manage-notifications/view-alert-rules.md +++ b/docs/sources/alerting/manage-notifications/view-alert-rules.md @@ -1,8 +1,8 @@ --- aliases: - - ../unified-alerting/alerting-rules/rule-list/ - - ../view-alert-rules/ - - rule-list/ + - ../../alerting/unified-alerting/alerting-rules/rule-list/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/alerting-rules/rule-list + - ../../alerting/alerting-rules/view-alert-rules/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/view-alert-rules + - ../../alerting/alerting-rules/rule-list/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/rule-list canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/view-alert-rules/ description: View and filter by alert rules keywords: @@ -52,7 +52,7 @@ From the Alert list page, you can also make copies of alert rules to help you re Click the **Export rule group** icon next to each alert rule group to export to YAML, JSON, or Terraform. -Click **More** -> **Export all Grafana-managed rules** to export all Grafana-managed alert rules to YAML, JSON, or Terraform. +Click **Export rules** to export all Grafana-managed alert rules to YAML, JSON, or Terraform. Click **More** -> **Modify export** next to each individual alert rule within a group to edit provisioned alert rules and export a modified version. diff --git a/docs/sources/alerting/manage-notifications/view-state-health.md b/docs/sources/alerting/manage-notifications/view-state-health.md index 0ebed5afd1182..96f4b3275699a 100644 --- a/docs/sources/alerting/manage-notifications/view-state-health.md +++ b/docs/sources/alerting/manage-notifications/view-state-health.md @@ -1,8 +1,6 @@ --- aliases: - - ../fundamentals/state-and-health/ - - ../unified-alerting/alerting-rules/state-and-health/ - - ../view-state-health/ + - ../../alerting/alerting-rules/view-state-health/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/view-state-health canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/view-state-health/ description: View the state and health of alert rules keywords: diff --git a/docs/sources/alerting/set-up/_index.md b/docs/sources/alerting/set-up/_index.md index 761a2be32228b..e07d33b08be7d 100644 --- a/docs/sources/alerting/set-up/_index.md +++ b/docs/sources/alerting/set-up/_index.md @@ -1,6 +1,6 @@ --- aliases: - - unified-alerting/set-up/ + - unified-alerting/set-up/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/set-up/ canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/ description: Set up or upgrade your implementation of Grafana Alerting labels: @@ -60,21 +60,19 @@ The following topics provide you with advanced configuration options for Grafana - [Configure high availability][configure-high-availability] {{% docs/reference %}} -[configure-alertmanager]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/configure-alertmanager" +[configure-alertmanager]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/configure-alertmanager" [configure-alertmanager]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/configure-alertmanager" -[configure-high-availability]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/configure-high-availability" +[configure-high-availability]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/configure-high-availability" [configure-high-availability]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/configure-high-availability" -[data-source-alerting]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/fundamentals/data-source-alerting" -[data-source-alerting]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/data-source-alerting" +[data-source-alerting]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules#supported-data-sources" +[data-source-alerting]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules#supported-data-sources" -[data-source-management]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/data-source-management" -[data-source-management]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/data-source-management" +[data-source-management]: "/docs/ -> /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management" -[file-provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/file-provisioning" -[file-provisioning]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/file-provisioning" +[file-provisioning]: "/docs/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/file-provisioning" -[terraform-provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/terraform-provisioning" +[terraform-provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/terraform-provisioning" [terraform-provisioning]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/terraform-provisioning" {{% /docs/reference %}} diff --git a/docs/sources/alerting/set-up/configure-alert-state-history/index.md b/docs/sources/alerting/set-up/configure-alert-state-history/index.md index 1f82d29778799..a9892dc716b3b 100644 --- a/docs/sources/alerting/set-up/configure-alert-state-history/index.md +++ b/docs/sources/alerting/set-up/configure-alert-state-history/index.md @@ -9,12 +9,12 @@ keywords: - alert state history labels: products: - - cloud -title: Configure Alert State History -weight: 600 + - oss +title: Configure alert state history +weight: 250 --- -# Configure Alert State History +# Configure alert state history Starting with Grafana 10, Alerting can record all alert rule state changes for your Grafana managed alert rules in a Loki instance. diff --git a/docs/sources/alerting/set-up/configure-alertmanager/index.md b/docs/sources/alerting/set-up/configure-alertmanager/index.md index 3b6d24a6d8a32..d2d9c3148c819 100644 --- a/docs/sources/alerting/set-up/configure-alertmanager/index.md +++ b/docs/sources/alerting/set-up/configure-alertmanager/index.md @@ -1,6 +1,6 @@ --- aliases: - - ../configure-alertmanager/ + - ../configure-alertmanager/ # /docs/grafana/<GRAFANA_VERSION>/configure-alertmanager/ canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/configure-alertmanager/ description: Configure an Alertmanager to receive all of your alerts keywords: @@ -27,11 +27,7 @@ Grafana Alerting does not support sending alerts to the AWS Managed Service for Once you have added the Alertmanager, you can use the Grafana Alerting UI to manage silences, contact points, and notification policies. A drop-down option in these pages allows you to switch between alertmanagers. -{{% admonition type="note" %}} -Starting with Grafana 9.2, the URL configuration of external alertmanagers from the Admin tab on the Alerting page is deprecated. It will be removed in a future release. -{{% /admonition %}} - -External alertmanagers should now be configured as data sources using Grafana Configuration from the main Grafana navigation menu. This enables you to manage the contact points and notification policies of external alertmanagers from within Grafana and also encrypts HTTP basic authentication credentials that were previously visible when configuring external alertmanagers by URL. +External alertmanagers should now be configured as data sources using Grafana Configuration from the main Grafana navigation menu. This enables you to manage the contact points and notification policies of external alertmanagers from within Grafana and also encrypts HTTP basic authentication credentials. To add an external Alertmanager, complete the following steps. diff --git a/docs/sources/alerting/set-up/configure-high-availability/_index.md b/docs/sources/alerting/set-up/configure-high-availability/_index.md index d71926d9d9ae0..4992fbbe9ebc1 100644 --- a/docs/sources/alerting/set-up/configure-high-availability/_index.md +++ b/docs/sources/alerting/set-up/configure-high-availability/_index.md @@ -1,9 +1,11 @@ --- aliases: - - ../high-availability/enable-alerting-ha/ - - ../unified-alerting/high-availability/ + - ../unified-alerting/high-availability/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/high-availability + - ../high-availability/enable-alerting-ha/ # /docs/grafana/<GRAFANA_VERSION>/alerting/high-availability/enable-alerting-ha/ + - ../high-availability/ # /docs/grafana/<GRAFANA_VERSION>/alerting/high-availability + - ../fundamentals/high-availability/ # /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/high-availability canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/configure-high-availability/ -description: Enable alerting high availability +description: Configure High Availability keywords: - grafana - alerting @@ -14,14 +16,23 @@ labels: products: - enterprise - oss -title: Enable alerting high availability -weight: 400 +title: Configure high availability +weight: 600 --- -# Enable alerting high availability +# Configure high availability -You can enable alerting high availability support by updating the Grafana configuration file. If you run Grafana in a Kubernetes cluster, additional steps are required. Both options are described below. -Please note that the deduplication is done for the notification, but the alert will still be evaluated on every Grafana instance. This means that events in alerting state history will be duplicated by the number of Grafana instances running. +Grafana Alerting uses the Prometheus model of separating the evaluation of alert rules from the delivering of notifications. In this model, the evaluation of alert rules is done in the alert generator and the delivering of notifications is done in the alert receiver. In Grafana Alerting, the alert generator is the Scheduler and the receiver is the Alertmanager. + +{{< figure src="/static/img/docs/alerting/unified/high-availability-ua.png" class="docs-image--no-shadow" max-width= "750px" caption="High availability" >}} + +When running multiple instances of Grafana, all alert rules are evaluated on all instances. You can think of the evaluation of alert rules as being duplicated by the number of running Grafana instances. This is how Grafana Alerting makes sure that as long as at least one Grafana instance is working, alert rules will still be evaluated and notifications for alerts will still be sent. + +You can find this duplication in state history and it is a good way to confirm if you are using high availability. + +While the alert generator evaluates all alert rules on all instances, the alert receiver makes a best-effort attempt to avoid sending duplicate notifications. Alertmanager chooses availability over consistency, which may result in occasional duplicated or out-of-order notifications. It takes the opinion that duplicate or out-of-order notifications are better than no notifications. + +The Alertmanager uses a gossip protocol to share information about notifications between Grafana instances. It also gossips silences, which means a silence created on one Grafana instance is replicated to all other Grafana instances. Both notifications and silences are persisted to the database periodically, and during graceful shut down. {{% admonition type="note" %}} @@ -30,31 +41,31 @@ This is because the HA settings (`ha_peers`, etc), only apply to the alert notif {{% /admonition %}} -## Enable alerting high availability in Grafana using Memberlist +## Enable alerting high availability using Memberlist -### Before you begin +**Before you begin** Since gossiping of notifications and silences uses both TCP and UDP port `9094`, ensure that each Grafana instance is able to accept incoming connections on these ports. **To enable high availability support:** 1. In your custom configuration file ($WORKING_DIR/conf/custom.ini), go to the `[unified_alerting]` section. -2. Set `[ha_peers]` to the number of hosts for each Grafana instance in the cluster (using a format of host:port), for example, `ha_peers=10.0.0.5:9094,10.0.0.6:9094,10.0.0.7:9094`. +1. Set `[ha_peers]` to the number of hosts for each Grafana instance in the cluster (using a format of host:port), for example, `ha_peers=10.0.0.5:9094,10.0.0.6:9094,10.0.0.7:9094`. You must have at least one (1) Grafana instance added to the `ha_peers` section. -3. Set `[ha_listen_address]` to the instance IP address using a format of `host:port` (or the [Pod's](https://kubernetes.io/docs/concepts/workloads/pods/) IP in the case of using Kubernetes). +1. Set `[ha_listen_address]` to the instance IP address using a format of `host:port` (or the [Pod's](https://kubernetes.io/docs/concepts/workloads/pods/) IP in the case of using Kubernetes). By default, it is set to listen to all interfaces (`0.0.0.0`). -4. Set `[ha_peer_timeout]` in the `[unified_alerting]` section of the custom.ini to specify the time to wait for an instance to send a notification via the Alertmanager. The default value is 15s, but it may increase if Grafana servers are located in different geographic regions or if the network latency between them is high. +1. Set `[ha_peer_timeout]` in the `[unified_alerting]` section of the custom.ini to specify the time to wait for an instance to send a notification via the Alertmanager. The default value is 15s, but it may increase if Grafana servers are located in different geographic regions or if the network latency between them is high. -## Enable alerting high availability in Grafana using Redis +## Enable alerting high availability using Redis As an alternative to Memberlist, you can use Redis for high availability. This is useful if you want to have a central database for HA and cannot support the meshing of all Grafana servers. 1. Make sure you have a redis server that supports pub/sub. If you use a proxy in front of your redis cluster, make sure the proxy supports pub/sub. -2. In your custom configuration file ($WORKING_DIR/conf/custom.ini), go to the [unified_alerting] section. -3. Set `ha_redis_address` to the redis server address Grafana should connect to. -4. [Optional] Set the username and password if authentication is enabled on the redis server using `ha_redis_username` and `ha_redis_password`. -5. [Optional] Set `ha_redis_prefix` to something unique if you plan to share the redis server with multiple Grafana instances. +1. In your custom configuration file ($WORKING_DIR/conf/custom.ini), go to the [unified_alerting] section. +1. Set `ha_redis_address` to the redis server address Grafana should connect to. +1. [Optional] Set the username and password if authentication is enabled on the redis server using `ha_redis_username` and `ha_redis_password`. +1. [Optional] Set `ha_redis_prefix` to something unique if you plan to share the redis server with multiple Grafana instances. The following metrics can be used for meta monitoring, exposed by Grafana's `/metrics` endpoint: @@ -72,67 +83,67 @@ The following metrics can be used for meta monitoring, exposed by Grafana's `/me ## Enable alerting high availability using Kubernetes -If you are using Kubernetes, you can expose the pod IP [through an environment variable](https://kubernetes.io/docs/tasks/inject-data-application/environment-variable-expose-pod-information/) via the container definition. +1. You can expose the pod IP [through an environment variable](https://kubernetes.io/docs/tasks/inject-data-application/environment-variable-expose-pod-information/) via the container definition. -```yaml -env: - - name: POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP -``` + ```yaml + env: + - name: POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + ``` 1. Add the port 9094 to the Grafana deployment: -```yaml -ports: - - containerPort: 3000 - name: http-grafana - protocol: TCP - - containerPort: 9094 - name: grafana-alert - protocol: TCP -``` - -2. Add the environment variables to the Grafana deployment: - -```yaml -env: - - name: POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP -``` - -3. Create a headless service that returns the pod IP instead of the service IP, which is what the `ha_peers` need: - -```yaml -apiVersion: v1 -kind: Service -metadata: - name: grafana-alerting - namespace: grafana - labels: - app.kubernetes.io/name: grafana-alerting - app.kubernetes.io/part-of: grafana -spec: - type: ClusterIP - clusterIP: 'None' - ports: - - port: 9094 - selector: - app: grafana -``` - -4. Make sure your grafana deployment has the label matching the selector, e.g. `app:grafana`: - -5. Add in the grafana.ini: - -```bash -[unified_alerting] -enabled = true -ha_listen_address = "${POD_IP}:9094" -ha_peers = "grafana-alerting.grafana:9094" -ha_advertise_address = "${POD_IP}:9094" -ha_peer_timeout = 15s -``` + ```yaml + ports: + - containerPort: 3000 + name: http-grafana + protocol: TCP + - containerPort: 9094 + name: grafana-alert + protocol: TCP + ``` + +1. Add the environment variables to the Grafana deployment: + + ```yaml + env: + - name: POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + ``` + +1. Create a headless service that returns the pod IP instead of the service IP, which is what the `ha_peers` need: + + ```yaml + apiVersion: v1 + kind: Service + metadata: + name: grafana-alerting + namespace: grafana + labels: + app.kubernetes.io/name: grafana-alerting + app.kubernetes.io/part-of: grafana + spec: + type: ClusterIP + clusterIP: 'None' + ports: + - port: 9094 + selector: + app: grafana + ``` + +1. Make sure your grafana deployment has the label matching the selector, e.g. `app:grafana`: + +1. Add in the grafana.ini: + + ```bash + [unified_alerting] + enabled = true + ha_listen_address = "${POD_IP}:9094" + ha_peers = "grafana-alerting.grafana:9094" + ha_advertise_address = "${POD_IP}:9094" + ha_peer_timeout = 15s + ``` diff --git a/docs/sources/alerting/set-up/migrating-alerts/_index.md b/docs/sources/alerting/set-up/migrating-alerts/_index.md deleted file mode 100644 index 078f172dd0dca..0000000000000 --- a/docs/sources/alerting/set-up/migrating-alerts/_index.md +++ /dev/null @@ -1,161 +0,0 @@ ---- -aliases: - - ../migrating-alerts/ # /docs/grafana/<GRAFANA VERSION>/alerting/migrating-alerts/ -canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/migrating-alerts/ -description: Upgrade to Grafana Alerting -labels: - products: - - enterprise - - oss -title: Upgrade Alerting -weight: 150 ---- - -# Upgrade Alerting - -Grafana Alerting is enabled by default for new installations or existing installations whether or not legacy alerting is configured. - -{{% admonition type="note" %}} -When upgrading, your dashboard alerts are migrated to a new format. This migration can be rolled back easily by opting out. If you have any questions regarding this migration, please contact us. -{{% /admonition %}} - -Existing installations that do not use legacy alerting will have Grafana Alerting enabled by default unless alerting is disabled in the configuration. - -Likewise, existing installations that use legacy alerting will be automatically upgraded to Grafana Alerting unless you have opted out of Grafana Alerting before migration takes place. During the upgrade, legacy alerts are migrated to the new alerts type and no alerts or alerting data are lost. - -Once the upgrade has taken place, you still have the option to roll back to legacy alerting. However, we do not recommend choosing this option. If you do choose to roll back, Grafana will restore your alerts to the alerts you had at the point in time when the upgrade took place. - -{{% admonition type="note" %}} -Cloud customers, who do not want to upgrade to Grafana Alerting, should contact customer support. -{{% /admonition %}} - -If you have opted out or rolled back, you can always choose to opt in to Grafana Alerting at a later point in time. - -The following table provides details on the upgrade for Cloud, Enterprise, and OSS installations and the new Grafana Alerting UI. - -| Grafana instance upgraded to 9.0 | | -| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Cloud | Existing Cloud installations with legacy dashboard alerting will have two alerting icons in the left navigation panel - the old alerting plugin icon and the new Grafana Alerting icon. During upgrade, existing alerts from the Cloud alerting plugin are migrated to Grafana Alerting. Once migration is complete, you can access and manage the older alerts from the new alerting Grafana Alerting icon in the navigation panel. The (older) Cloud alerting plugin is uninstalled from your cloud instance. Contact customer support if you **do not wish** to migrate to Grafana Alerting for your Cloud stack. If you choose to use legacy alerting, use the You will see the new Grafana Alerting icon as well as the old Cloud alerting plugin in the left navigation panel. | -| Enterprise | Existing Enterprise instances using legacy alerting will have both the old (marked as legacy) and the new alerting icons in the navigation panel. During upgrade, existing legacy alerts are migrated to Grafana Alerting. If you wish, you can opt out of Grafana Alerting and roll back to legacy alerting. In that case, you can manage your legacy alerts from the alerting icon marked as legacy. | -| OSS | Existing OSS installations with legacy dashboard alerting will have two alerting icons in the left navigation panel - the old alerting icon (marked as legacy) and the new Grafana Alerting icon. During upgrade, existing legacy alerts are migrated to Grafana Alerting. If you wish, you can opt out of Grafana Alerting and roll back to legacy alerting. In that case, you can manage your legacy alerts from the alerting icon marked as legacy. | - -> **Note:** Starting with v9.0, legacy alerting is deprecated and will be removed in a future release. - -## Opt out - -You can opt out of Grafana Alerting at any time and switch to using legacy alerting. Alternatively, you can opt out of using alerting in its entirety. - -## Stay on legacy alerting - -When upgrading to Grafana > 9.0, existing installations that use legacy alerting are automatically upgraded to Grafana Alerting unless you have opted-out of Grafana Alerting before migration takes place. During the upgrade, legacy alerts are migrated to the new alerts type and no alerts or alerting data are lost. To keep using legacy alerting and deactivate Grafana Alerting: - -1. Go to your custom configuration file ($WORKING_DIR/conf/custom.ini). -2. Enter the following in your configuration: - -```toml -[alerting] -enabled = true - -[unified_alerting] -enabled = false -``` - -Installations that have been migrated to Grafana Alerting can roll back to legacy alerting at any time. - -{{% admonition type="note" %}} -This topic is only relevant for OSS and Enterprise customers. Contact customer support to enable or disable Grafana Alerting for your Grafana Cloud stack. -{{% /admonition %}} - -The `ngalert` toggle previously used to enable or disable Grafana Alerting is no longer available. - -## Deactivate alerting - -You can deactivate both Grafana Alerting and legacy alerting in Grafana. - -1. Go to your custom configuration file ($WORKING_DIR/conf/custom.ini). -1. Enter the following in your configuration: - -```toml -[alerting] -enabled = false - -[unified_alerting] -enabled = false -``` - -3. Restart Grafana for the configuration changes to take effect. - -If you want to turn alerting back on, you can remove both flags to enable Grafana Alerting. - -## Roll back - -Once the upgrade has taken place, you still have the option to roll back to legacy alerting. If you choose to roll back, Grafana will restore your alerts to the alerts you had at the point in time when the upgrade took place. - -To roll back to legacy alerting, enter the following in your configuration: - -```toml -[alerting] -enabled = true - -[unified_alerting] -enabled = false -``` - -> **Note**: The next time you upgrade to Grafana Alerting, Grafana will restore your Grafana Alerting alerts and configuration to those you had before rolling back. - -If, after rolling back, you wish to delete any existing Grafana Alerting configuration and upgrade your legacy alerting configuration again from scratch, you can enable the `clean_upgrade` option: - -```toml -[unified_alerting.upgrade] -clean_upgrade = true -``` - -## Opt in - -If you have previously disabled alerting in Grafana, or opted out of Grafana Alerting and have decided that you would now like to use Grafana Alerting, you can choose to opt in at any time. - -If you have been using legacy alerting up until now your existing alerts will be migrated to the new alerts type and no alerts or alerting data are lost. Even if you choose to opt in to Grafana Alerting, you can roll back to legacy alerting at any time. - -To opt in to Grafana Alerting, enter the following in your configuration: - -```toml -[alerting] -enabled = false - -[unified_alerting] -enabled = true -``` - -## Differences and limitations - -There are some differences between Grafana Alerting and legacy dashboard alerts, and a number of features that are no -longer supported. - -**Differences** - -1. When Grafana Alerting is enabled or upgraded to Grafana 9.0 or later, existing legacy dashboard alerts migrate in a format compatible with the Grafana Alerting. In the Alerting page of your Grafana instance, you can view the migrated alerts alongside any new alerts. - This topic explains how legacy dashboard alerts are migrated and some limitations of the migration. - -1. Read and write access to legacy dashboard alerts and Grafana alerts are governed by the permissions of the folders storing them. During migration, legacy dashboard alert permissions are matched to the new rules permissions as follows: - - - If there are dashboard permissions, a folder named `Migrated {"dashboardUid": "UID", "panelId": 1, "alertId": 1}` is created to match the permissions of the dashboard (including the inherited permissions from the folder). - - If there are no dashboard permissions and the dashboard is in a folder, then the rule is linked to this folder and inherits its permissions. - - If there are no dashboard permissions and the dashboard is in the General folder, then the rule is linked to the `General Alerting` folder and the rule inherits the default permissions. - -1. `NoData` and `Error` settings are migrated as is to the corresponding settings in Grafana Alerting, except in two situations: - - 3.1. As there is no `Keep Last State` option in Grafana Alerting, this option becomes either [`NoData` or `Error`](/docs/sources/alerting/alerting-rules/create-grafana-managed-rule/#configure-no-data-and-error-handling). To match the behavior of the `Keep Last State` as closely as possible during the migration Grafana automatically creates a silence for each alert rule with a duration of 1 year. If the alert evaluation returns no data or fails (error or timeout), then it creates a [special alert](/docs/sources/alerting/fundamentals/alert-rules/state-and-health/#special-alerts-for-nodata-and-error), which will be silenced by the silence created during the migration. - - 3.2. Due to lack of validation, legacy alert rules imported via JSON or provisioned along with dashboards can contain arbitrary values for [`NoData` or `Error`](/docs/sources/alerting/alerting-rules/create-grafana-managed-rule/#configure-no-data-and-error-handling). In this situation, Grafana will use the default setting: `NoData` for No data, and `Error` for Error. - -1. Notification channels are migrated to an Alertmanager configuration with the appropriate routes and receivers. Default notification channels are added as contact points to the default route. Notification channels not associated with any Dashboard alert go to the `autogen-unlinked-channel-recv` route. - -1. Unlike legacy dashboard alerts where images in notifications are enabled per contact point, images in notifications for Grafana Alerting must be enabled in the Grafana configuration, either in the configuration file or environment variables, and are enabled for either all or no contact points. - -1. The JSON format for webhook notifications has changed in Grafana Alerting and uses the format from [Prometheus Alertmanager](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config). - -1. Alerting on Prometheus `Both` type queries is not supported in Grafana Alerting. Existing legacy alerts with `Both` type queries are migrated to Grafana Alerting as alerts with `Range` type queries. - -**Limitations** - -1. Since `Hipchat` and `Sensu` notification channels are no longer supported, legacy alerts associated with these channels are not automatically migrated to Grafana Alerting. Assign the legacy alerts to a supported notification channel so that you continue to receive notifications for those alerts. diff --git a/docs/sources/alerting/set-up/migrating-alerts/legacy-alerting-deprecation.md b/docs/sources/alerting/set-up/migrating-alerts/legacy-alerting-deprecation.md deleted file mode 100644 index 8c165e4b6b60d..0000000000000 --- a/docs/sources/alerting/set-up/migrating-alerts/legacy-alerting-deprecation.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -aliases: - - alerting/legacy-alerting-deprecation/ -canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/migrating-alerts/legacy-alerting-deprecation/ -description: Learn about legacy alerting deprecation -keywords: - - grafana - - alerting -labels: - products: - - enterprise - - oss -title: Legacy alerting deprecation -weight: 109 ---- - -# Legacy alerting deprecation - -Starting with Grafana v9.0.0, legacy alerting is deprecated, meaning that it is no longer actively maintained or supported by Grafana. As of Grafana v10.0.0, we do not contribute or accept external contributions to the codebase apart from CVE fixes. - -Legacy alerting refers to the old alerting system that was used prior to the introduction of Grafana Alerting; the new alerting system in Grafana. - -The decision to deprecate legacy alerting was made to encourage users to migrate to the new alerting system, which offers a more powerful and flexible alerting experience based on Prometheus Alertmanager. - -Users who are still using legacy alerting are encouraged to migrate their alerts to the new system as soon as possible to ensure that they continue to receive new features, bug fixes, and support. - -However, we will still patch CVEs until legacy alerting is completely removed in Grafana 11; honoring our commitment to building and distributing secure software. - -We have provided [instructions][migrating-alerts] on how to migrate to the new alerting system, making the process as easy as possible for users. - -## Why are we deprecating legacy alerting? - -The new Grafana alerting system is more powerful and flexible than the legacy alerting feature. - -The new system is based on Prometheus Alertmanager, which offers a more comprehensive set of features for defining and managing alerts. With the new alerting system, users can create alerts based on complex queries, configure alert notifications via various integrations, and set up sophisticated alerting rules with support for conditional expressions, aggregation, and grouping. - -Overall, the new alerting system in Grafana is a major improvement over the legacy alerting feature, providing users with a more powerful and flexible alerting experience. - -Additionally, legacy alerting still requires Angular to function and we are [planning to remove support for it][angular_deprecation] in Grafana 11. - -## When will we remove legacy alerting completely? - -Legacy alerting will be removed from the code-base in Grafana 11, following the same timeline as the [Angular deprecation][angular_deprecation]. - -## How do I migrate to the new Grafana alerting? - -Refer to our [upgrade instructions][migrating-alerts]. - -### Useful links - -- [Upgrade Alerting][migrating-alerts] -- [Angular support deprecation][angular_deprecation] - -{{% docs/reference %}} -[angular_deprecation]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/developers/angular_deprecation" -[angular_deprecation]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/developers/angular_deprecation" - -[migrating-alerts]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/migrating-alerts" -[migrating-alerts]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/migrating-alerts" -{{% /docs/reference %}} diff --git a/docs/sources/alerting/set-up/performance-limitations/index.md b/docs/sources/alerting/set-up/performance-limitations/index.md index 882174d4e6743..5cd9a89446210 100644 --- a/docs/sources/alerting/set-up/performance-limitations/index.md +++ b/docs/sources/alerting/set-up/performance-limitations/index.md @@ -1,7 +1,7 @@ --- aliases: - - alerting-limitations/ - - alerting/performance-limitations/ + - ./alerting-limitations/ # /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/alerting-limitations/ + - ../../alerting/performance-limitations/ # /docs/grafana/<GRAFANA_VERSION>/alerting/performance-limitations/ canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/performance-limitations/ description: Learn about performance considerations and limitations keywords: @@ -54,4 +54,16 @@ Grafana cannot be used to receive external alerts. You can only send alerts to t You have the option to send Grafana managed alerts to an external Alertmanager, you can find this option in the admin tab on the Alerting page. -For more information, refer to [this GitHub discussion](https://github.com/grafana/grafana/discussions/45773). +For more information, refer to [this GitHub issue](https://github.com/grafana/grafana/issues/73447). + +## High load on database caused by a high number of alert instances + +If you have a high number of alert instances, it can happen that the load on the database gets very high, as each state +transition of an alert instance will be saved in the database. + +This can be prevented by writing to the database periodically. For this the feature flag `alertingSaveStatePeriodic` needs +to be enabled. By default it will save the states every 5 minutes to the database and on each shutdown. The periodic interval +can also be configured using the `state_periodic_save_interval` configuration flag. + +The time it takes to write to the database periodically can be monitored using the `state_full_sync_duration_seconds` metric +that is exposed by Grafana. diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/_index.md b/docs/sources/alerting/set-up/provision-alerting-resources/_index.md index b64985632fdad..518450be1a509 100644 --- a/docs/sources/alerting/set-up/provision-alerting-resources/_index.md +++ b/docs/sources/alerting/set-up/provision-alerting-resources/_index.md @@ -1,6 +1,4 @@ --- -aliases: - - ../provision-alerting-resources/ canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/ description: Provision alerting resources keywords: @@ -14,44 +12,68 @@ labels: - cloud - enterprise - oss -title: Provision Grafana Alerting resources +title: Provision Alerting resources weight: 300 --- -# Provision Grafana Alerting resources +# Provision Alerting resources -Alerting infrastructure is often complex, with many pieces of the pipeline that often live in different places. Scaling this across multiple teams and organizations is an especially challenging task. Grafana Alerting provisioning makes this process easier by enabling you to create, manage, and maintain your alerting data in a way that best suits your organization. +Alerting infrastructure is often complex, with many pieces of the pipeline that often live in different places. Scaling this across multiple teams and organizations is an especially challenging task. Importing and exporting (or provisioning) your alerting resources in Grafana Alerting makes this process easier by enabling you to create, manage, and maintain your alerting data in a way that best suits your organization. -Provisioning for Grafana Alerting supports alert rules, contact points, notification policies, mute timings, and templates. +You can import alert rules, contact points, notification policies, mute timings, and templates. -You cannot edit provisioned alerting resources in the Grafana UI in the same way as unprovisioned alerting resources. You can only edit provisioned contact points, notification policies, templates, and mute timings in the source where they were created. For example, if you provision your alerting resources using files from disk, you cannot edit the data in Terraform or from within Grafana. +You cannot edit imported alerting resources in the Grafana UI in the same way as alerting resources that were not imported. You can only edit imported contact points, notification policies, templates, and mute timings in the source where they were created. For example, if you manage your alerting resources using files from disk, you cannot edit the data in Terraform or from within Grafana. -To modify provisioned alert rules, you can use the **Modify export** feature to edit and then export. +## Import alerting resources -Choose from the options below to provision your Grafana Alerting resources. +Choose from the options below to import (or provision) your Grafana Alerting resources. -1. Use file provisioning to provision your Grafana Alerting resources, such as alert rules and contact points, through files on disk. +1. [Use configuration files to provision your alerting resources][alerting_file_provisioning], such as alert rules and contact points, through files on disk. - {{% admonition type="note" %}} - File provisioning is not available in Grafana Cloud instances. - {{% /admonition %}} + {{< admonition type="note" >}} -2. Use the Alerting Provisioning HTTP API. + - You cannot edit provisioned resources from files in the Grafana UI. + - Provisioning with configuration files is not available in Grafana Cloud. + {{< /admonition >}} - For more information on the Alerting Provisioning HTTP API, refer to [Alerting provisioning HTTP API][alerting_provisioning]. +1. Use [Terraform to provision alerting resources][alerting_tf_provisioning]. -3. Use [Terraform](https://www.terraform.io/). +1. Use the [Alerting provisioning HTTP API][alerting_http_provisioning] to manage alerting resources. -**Useful Links:** + {{< admonition type="note" >}} + The JSON output from the majority of Alerting HTTP endpoints isn't compatible for provisioning via configuration files. -[Grafana provisioning][provisioning] + If you need the alerting resources for file provisioning, use [Export Alerting endpoints](/docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-api-endpoints) to return or download them in provisioning format. + {{< /admonition >}} -[Grafana Alerting provisioning API][alerting_provisioning] +## Export alerting resources + +You can export both manually created and provisioned alerting resources. You can also edit and export an alert rule without applying the changes. + +For detailed instructions on the various export options, refer to [Export alerting resources][alerting_export]. + +## View provisioned alerting resources + +To view your provisioned resources in Grafana, complete the following steps. + +1. Open your Grafana instance. +1. Navigate to Alerting. +1. Click an alerting resource folder, for example, Alert rules. + +Provisioned resources are labeled **Provisioned**, so that it is clear that they were not created manually. {{% docs/reference %}} -[alerting_provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/developers/http_api/alerting_provisioning" -[alerting_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/developers/http_api/alerting_provisioning" +[alerting_tf_provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/terraform-provisioning" +[alerting_tf_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/terraform-provisioning" +[alerting_http_provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning" +[alerting_http_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/http-api-provisioning" +[alerting_export]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources" +[alerting_export]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/export-alerting-resources" + +[alerting_export_http]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-api-endpoints" +[alerting_export_http]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-api-endpoints" + +[alerting_file_provisioning]: "/docs/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/file-provisioning" -[provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning" -[provisioning]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning" +[provisioning]: "/docs/ -> /docs/grafana/<GRAFANA_VERSION>/administration/provisioning" {{% /docs/reference %}} diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/export-alerting-resources/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/export-alerting-resources/index.md new file mode 100644 index 0000000000000..1663d4474b54c --- /dev/null +++ b/docs/sources/alerting/set-up/provision-alerting-resources/export-alerting-resources/index.md @@ -0,0 +1,213 @@ +--- +aliases: + - ../../provision-alerting-resources/view-provisioned-resources/ # /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/view-provisioned-resources/ +canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/export-alerting-resources/ +description: Export alerting resources in Grafana +keywords: + - grafana + - alerting + - alerting resources + - provisioning +labels: + products: + - cloud + - enterprise + - oss +title: Export alerting resources +weight: 300 +--- + +# Export alerting resources + +Export your alerting resources, such as alert rules, contact points, and notification policies for provisioning, automatically importing single folders and single groups. + +There are distinct methods to export your alerting resources: + +- [Grafana UI](#export-from-the-grafana-ui) exports in Terraform format and YAML or JSON formats for file provisioning. +- [HTTP Alerting API](#http-alerting-api) exports in JSON API format used by the HTTP Alerting API. +- [HTTP Alerting API - Export endpoints](#export-api-endpoints) exports in YAML or JSON formats for file provisioning. + +{{< admonition type="note" >}} +Alerting resources imported through [file provisioning](/docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/file-provisioning) cannot be edited in the Grafana UI. This prevents changes made in the UI from being overridden by file provisioning during Grafana restarts. + +If you need to modify provisioned alerting resources in Grafana, refer to [edit HTTP API alerting resources in the Grafana UI](/docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#edit-resources-in-the-grafana-ui) or to [edit Terraform alerting resources in the Grafana UI](/docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/terraform-provisioning#enable-editing-resources-in-the-grafana-ui). +{{< /admonition >}} + +## Export from the Grafana UI + +The export options listed below enable you to download resources in YAML, JSON, or Terraform format, facilitating their provisioning through [configuration files][alerting_file_provisioning] or [Terraform][alerting_tf_provisioning]. + +### Export alert rules + +To export alert rules from the Grafana UI, complete the following steps. + +1. Click **Alerts & IRM** -> **Alert rules**. +1. To export all Grafana-managed rules, click **Export rules**. +1. To export a folder, change the **View as** to **List**. +1. Select the folder you want to export and click the **Export rules folder** icon. +1. To export a group, change the **View as** to **Grouped**. +1. Find the group you want to export and click the **Export rule group** icon. +1. Choose the format to export in. + + The exported alert rule data appears in different formats - YAML, JSON, Terraform. + +1. Click **Copy Code** or **Download**. + +### Modify alert rule and export rule group without saving changes + +{{% admonition type="note" %}} This feature is for Grafana-managed alert rules only. It is available to Admin, Viewer, and Editor roles. {{% /admonition %}} + +Use the **Modify export** mode to edit and export an alert rule without updating it. The exported data includes all alert rules within the same alert group. + +To export a modified alert rule without saving the modifications, complete the following steps from the Grafana UI. + +1. Click **Alerts & IRM** -> **Alert rules**. +1. Locate the alert rule you want to edit and click **More** -> **Modify Export** to open the Alert Rule form. +1. From the Alert Rule form, edit the fields you want to change. Changes made are not applied to the alert rule. +1. Click **Export**. +1. Choose the format to export in. + + The exported alert rule group appears in different formats - YAML, JSON, Terraform. + +1. Click **Copy Code** or **Download**. + +### Export contact points + +To export contact points from the Grafana UI, complete the following steps. + +1. Click **Alerts & IRM** -> **Contact points**. +1. Find the contact point you want to export and click **More** -> **Export**. +1. Choose the format to export in. + + The exported contact point appears in different formats - YAML, JSON, Terraform. + +1. Click **Copy Code** or **Download**. + +### Export templates + +Grafana currently doesn't offer an Export UI or [Export endpoint](#export-api-endpoints) for notification templates, unlike other Alerting resources presented in this documentation. + +However, you can export it by manually copying the content template and title directly from the Grafana UI. + +1. Click **Alerts & IRM** -> **Contact points** -> **Notification templates** tab. +1. Find the template you want to export. +1. Copy the content and title. +1. Adjust it for the [file provisioning format][alerting_file_provisioning_template] or [Terraform resource][alerting_tf_provisioning_template]. + +### Export the notification policy tree + +All notification policies are provisioned through a single resource: the root of the notification policy tree. + +{{% admonition type="warning" %}} + +Since the policy tree is a single resource, provisioning it will overwrite a policy tree created through any other means. + +{{< /admonition >}} + +To export the notification policy tree from the Grafana UI, complete the following steps. + +1. Click **Alerts & IRM** -> **Notification policies**. +1. In the **Default notification policy** section, click **...** -> **Export**. +1. Choose the format to export in. + + The exported contact point appears in different formats - YAML, JSON, Terraform. + +1. Click **Copy Code** or **Download**. + +### Export mute timings + +To export mute timings from the Grafana UI, complete the following steps. + +1. Click **Alerts & IRM** -> **Notification policies**, and then the **Mute timings** tab. +1. Find the mute timing you want to export and click **Export**. +1. Choose the format to export in. + + The exported contact point appears in different formats - YAML, JSON, Terraform. + +1. Click **Copy Code** or **Download**. + +## HTTP Alerting API + +You can use the [Alerting HTTP API][alerting_http_provisioning] to return existing alerting resources in JSON and import them to another Grafana instance using the same endpoint. + +| Resource | URI | +| -------------------------------------------------------------- | ----------------------------------- | +| [Alert rules][alerting_http_alertrules] | /api/v1/provisioning/alert-rules | +| [Contact points][alerting_http_contactpoints] | /api/v1/provisioning/contact-points | +| [Notification policy tree][alerting_http_notificationpolicies] | /api/v1/provisioning/policies | +| [Mute timings][alerting_http_mutetimings] | /api/v1/provisioning/mute-timings | +| [Templates][alerting_http_templates] | /api/v1/provisioning/templates | + +However, note the standard endpoints return a JSON format that is not compatible for provisioning through configuration files or Terraform, except the `/export` endpoints listed below. + +### Export API endpoints + +The **Alerting HTTP API** provides specific endpoints for exporting alerting resources in YAML or JSON formats, facilitating [provisioning via configuration files][alerting_file_provisioning]. Currently, Terraform format is not supported. + +| Resource | Method / URI | Summary | +| ------------------------ | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Alert rules | GET /api/v1/provisioning/alert-rules/export | [Export all alert rules in provisioning file format.][export_rules] | +| Alert rules | GET /api/v1/provisioning/folder/:folderUid/rule-groups/:group/export | [Export an alert rule group in provisioning file format.][export_rule_group] | +| Alert rules | GET /api/v1/provisioning/alert-rules/:uid/export | [Export an alert rule in provisioning file format.][export_rule] | +| Contact points | GET /api/v1/provisioning/contact-points/export | [Export all contact points in provisioning file format.][export_contacts] | +| Notification policy tree | GET /api/v1/provisioning/policies/export | [Export the notification policy tree in provisioning file format.][export_notifications] | +| Mute timings | GET /api/v1/provisioning/mute-timings/export | [Export all mute timings in provisioning file format.][export_mute_timings] | +| Mute timings | GET /api/v1/provisioning/mute-timings/:name/export | [Export a mute timing in provisioning file format.][export_mute_timing] | + +These endpoints accept a `download` parameter to download a file containing the exported resources. + +<!-- prettier-ignore-start --> + +{{% docs/reference %}} + +[alerting_http_alertrules]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#alert-rules" +[alerting_http_alertrules]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/http-api-provisioning#alert-rules" + +[alerting_http_contactpoints]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#contact-points" +[alerting_http_contactpoints]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/http-api-provisioning#contact-points" + +[alerting_http_notificationpolicies]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#notification-policies" +[alerting_http_notificationpolicies]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/http-api-provisioning#notification-policies" + +[alerting_http_mutetimings]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#mute-timings" +[alerting_http_mutetimings]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/http-api-provisioning#mute-timings" + +[alerting_http_templates]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#templates" +[alerting_http_templates]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/http-api-provisioning#templates" + +[alerting_tf_provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/terraform-provisioning" +[alerting_tf_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/terraform-provisioning" + +[alerting_tf_provisioning_template]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/terraform-provisioning#import-contact-points-and-templates" +[alerting_tf_provisioning_template]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/terraform-provisioning#import-contact-points-and-templates" + +[alerting_http_provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning" +[alerting_http_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/http-api-provisioning" + +[alerting_file_provisioning]: "/docs/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/file-provisioning" + +[alerting_file_provisioning_template]: "/docs/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/file-provisioning#import-templates" + +[export_rule]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#span-idroute-get-alert-rule-exportspan-export-an-alert-rule-in-provisioning-file-format-_routegetalertruleexport_" +[export_rule]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#span-idroute-get-alert-rule-exportspan-export-an-alert-rule-in-provisioning-file-format-_routegetalertruleexport_" + +[export_rule_group]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#span-idroute-get-alert-rule-group-exportspan-export-an-alert-rule-group-in-provisioning-file-format-_routegetalertrulegroupexport_" +[export_rule_group]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#span-idroute-get-alert-rule-group-exportspan-export-an-alert-rule-group-in-provisioning-file-format-_routegetalertrulegroupexport_" + +[export_rules]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#span-idroute-get-alert-rules-exportspan-export-all-alert-rules-in-provisioning-file-format-_routegetalertrulesexport_" +[export_rules]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#span-idroute-get-alert-rules-exportspan-export-all-alert-rules-in-provisioning-file-format-_routegetalertrulesexport_" + +[export_contacts]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#span-idroute-get-contactpoints-exportspan-export-all-contact-points-in-provisioning-file-format-_routegetcontactpointsexport_" +[export_contacts]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#span-idroute-get-contactpoints-exportspan-export-all-contact-points-in-provisioning-file-format-_routegetcontactpointsexport_" + +[export_mute_timing]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#span-idroute-get-mute-timing-exportspan-export-a-mute-timing-in-provisioning-file-format-_routegetmutetimingexport_" +[export_mute_timing]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#span-idroute-get-mute-timing-exportspan-export-a-mute-timing-in-provisioning-file-format-_routegetmutetimingexport_" + +[export_mute_timings]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#span-idroute-get-mute-timings-exportspan-export-all-mute-timings-in-provisioning-file-format-_routegetmutetimingsexport_" +[export_mute_timings]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#span-idroute-get-mute-timings-exportspan-export-all-mute-timings-in-provisioning-file-format-_routegetmutetimingsexport_" + +[export_notifications]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#span-idroute-get-policy-tree-exportspan-export-the-notification-policy-tree-in-provisioning-file-format-_routegetpolicytreeexport_" +[export_notifications]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning#span-idroute-get-policy-tree-exportspan-export-the-notification-policy-tree-in-provisioning-file-format-_routegetpolicytreeexport_" +{{% /docs/reference %}} + +<!-- prettier-ignore-end --> diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md index 1ac14176febbb..245376f6efb8a 100644 --- a/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md +++ b/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md @@ -1,6 +1,4 @@ --- -aliases: - - ../../provision-alerting-resources/file-provisioning/ canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/file-provisioning/ description: Create and manage resources using file provisioning keywords: @@ -11,41 +9,43 @@ keywords: - provisioning labels: products: - - cloud - enterprise - oss -title: Create and manage alerting resources using file provisioning +menuTitle: Use configuration files to provision +title: Use configuration files to provision alerting resources weight: 100 --- -## Create and manage alerting resources using file provisioning +# Use configuration files to provision alerting resources -Provision your alerting resources using files from disk. When you start Grafana, the data from these files is created in your Grafana system. Grafana adds any new resources you created, updates any that you changed, and deletes old ones. +Manage your alerting resources using configuration files that can be version controlled. When Grafana starts, it provisions the resources defined in your configuration files. [Provisioning][provisioning] can create, update, or delete existing resources in your Grafana instance. -Arrange your files in a directory in a way that best suits your use case. For example, you can choose a team-based layout where every team has its own file, you can have one big file for all your teams; or you can have one file per resource type. +This guide outlines the steps and references to provision alerting resources using YAML files. For a practical demo, you can clone and try [this example using Grafana OSS and Docker Compose](https://github.com/grafana/provisioning-alerting-examples/tree/main/config-files). -Details on how to set up the files and which fields are required for each object are listed below depending on which resource you are provisioning. +{{< admonition type="note" >}} + +- [Provisioning Grafana](/docs/grafana/<GRAFANA_VERSION>/administration/provisioning) with configuration files is not available in Grafana Cloud. -**Note:** +- You cannot edit provisioned resources from files in Grafana. You can only change the resource properties by changing the provisioning file and restarting Grafana or carrying out a hot reload. This prevents changes being made to the resource that would be overwritten if a file is provisioned again or a hot reload is carried out. -Provisioning takes place during the initial set up of your Grafana system, but you can re-run it at any time using the [Grafana Admin API][reload-provisioning-configurations]. +- Provisioning using configuration files takes place during the initial set up of your Grafana system, but you can re-run it at any time using the [Grafana Admin API](/docs/grafana/<GRAFANA_VERSION>/developers/http_api/admin#reload-provisioning-configurations). -### Provision alert rules +- Importing an existing alerting resource results in a conflict. First, when present, remove the resources you plan to import. + {{< /admonition >}} -Create or delete alert rules in your Grafana instance(s). +Details on how to set up the files and which fields are required for each object are listed below depending on which resource you are provisioning. -1. Create alert rules in Grafana. -1. Use the [Alerting provisioning API][alerting_provisioning] export endpoints to download a provisioning file for your alert rules. -1. Copy the contents into a YAML or JSON configuration file in the default provisioning directory or in your configured directory. +## Import alert rules - Example configuration files can be found below. +Create or delete alert rules using provisioning files in your Grafana instance(s). -1. Ensure that your files are in the right directory on the node running the Grafana server, so that they deploy alongside your Grafana instance(s). -1. Delete the alert rules in Grafana that will be provisioned. +1. Find the alert rule group in Grafana. +1. [Export][export_alert_rules] and download a provisioning file for your alert rules. +1. Copy the contents into a YAML or JSON configuration file and add it to the `provisioning/alerting` directory of the Grafana instance you want to import the alerting resources to. - **Note:** + Example configuration files can be found below. - If you do not delete the alert rule, it will clash with the provisioned alert rule once uploaded. +1. Restart your Grafana instance (or reload the provisioned files using the Admin API). Here is an example of a configuration file for creating alert rules. @@ -108,6 +108,7 @@ groups: # <string> the state the alert rule will have when the query execution # failed - possible values: "Error", "Alerting", "OK" # default = Alerting + execErrState: Alerting # <duration, required> for how long should the alert fire before alerting for: 60s # <map<string, string>> a map of strings to pass around any data @@ -133,17 +134,17 @@ deleteRules: uid: my_id_1 ``` -### Provision contact points +## Import contact points -Create or delete contact points in your Grafana instance(s). +Create or delete contact points using provisioning files in your Grafana instance(s). -1. Create a contact point in Grafana. -1. Use the [Alerting provisioning API][alerting_provisioning] export endpoints to download a provisioning file for your contact point. -1. Copy the contents into a YAML or JSON configuration file in the default provisioning directory or in your configured directory. +1. Find the contact point in Grafana. +1. [Export][export_contact_points] and download a provisioning file for your contact point. +1. Copy the contents into a YAML or JSON configuration file and add it to the `provisioning/alerting` directory of the Grafana instance you want to import the alerting resources to. Example configuration files can be found below. -1. Ensure that your files are in the right directory on the node running the Grafana server, so that they deploy alongside your Grafana instance(s). +1. Restart your Grafana instance (or reload the provisioned files using the Admin API). Here is an example of a configuration file for creating contact points. @@ -183,12 +184,14 @@ deleteContactPoints: uid: first_uid ``` -#### Settings +### Settings Here are some examples of settings you can use for the different contact point integrations. -##### Alertmanager +{{< collapse title="Alertmanager" >}} + +#### Alertmanager ```yaml type: prometheus-alertmanager @@ -201,7 +204,11 @@ settings: basicAuthPassword: abc123 ``` -##### DingDing +{{< /collapse >}} + +{{< collapse title="DingDing" >}} + +#### DingDing ```yaml type: dingding @@ -215,7 +222,11 @@ settings: {{ template "default.message" . }} ``` -##### Discord +{{< /collapse >}} + +{{< collapse title="Discord" >}} + +#### Discord ```yaml type: discord @@ -231,7 +242,11 @@ settings: {{ template "default.message" . }} ``` -##### E-Mail +{{< /collapse >}} + +{{< collapse title="E-Mail" >}} + +#### E-Mail ```yaml type: email @@ -247,7 +262,11 @@ settings: {{ template "default.title" . }} ``` -##### Google Chat +{{< /collapse >}} + +{{< collapse title="Google Chat" >}} + +#### Google Chat ```yaml type: googlechat @@ -259,7 +278,11 @@ settings: {{ template "default.message" . }} ``` -##### Kafka +{{< /collapse >}} + +{{< collapse title="Kafka" >}} + +#### Kafka ```yaml type: kafka @@ -270,7 +293,11 @@ settings: kafkaTopic: topic1 ``` -##### LINE +{{< /collapse >}} + +{{< collapse title="LINE" >}} + +#### LINE ```yaml type: line @@ -279,7 +306,11 @@ settings: token: xxx ``` -##### Microsoft Teams +{{< /collapse >}} + +{{< collapse title="Microsoft Teams" >}} + +#### Microsoft Teams ```yaml type: teams @@ -296,7 +327,11 @@ settings: {{ template "default.message" . }} ``` -##### OpsGenie +{{< /collapse >}} + +{{< collapse title="OpsGenie" >}} + +#### OpsGenie ```yaml type: opsgenie @@ -318,7 +353,11 @@ settings: sendTagsAs: both ``` -##### PagerDuty +{{< /collapse >}} + +{{< collapse title="PagerDuty" >}} + +#### PagerDuty ```yaml type: pagerduty @@ -338,7 +377,11 @@ settings: {{ template "default.message" . }} ``` -##### Pushover +{{< /collapse >}} + +{{< collapse title="Pushover" >}} + +#### Pushover ```yaml type: pushover @@ -355,6 +398,8 @@ settings: retry: '30' # <string> expire: '120' + # <string> the number of seconds before a message expires and is deleted automatically. Examples: 10s, 5m30s, 8h. + ttl: # <string> sound: siren # <string> @@ -364,7 +409,11 @@ settings: {{ template "default.message" . }} ``` -##### Slack +{{< /collapse >}} + +{{< collapse title="Slack" >}} + +#### Slack ```yaml type: slack @@ -396,7 +445,11 @@ settings: {{ template "slack.default.text" . }} ``` -##### Sensu Go +{{< /collapse >}} + +{{< collapse title="Sensu Go" >}} + +#### Sensu Go ```yaml type: sensugo @@ -418,7 +471,11 @@ settings: {{ template "default.message" . }} ``` -##### Telegram +{{< /collapse >}} + +{{< collapse title="Telegram" >}} + +#### Telegram ```yaml type: telegram @@ -432,7 +489,11 @@ settings: {{ template "default.message" . }} ``` -##### Threema Gateway +{{< /collapse >}} + +{{< collapse title="Threema Gateway" >}} + +#### Threema Gateway ```yaml type: threema @@ -445,7 +506,11 @@ settings: recipient_id: A9R4KL4S ``` -##### VictorOps +{{< /collapse >}} + +{{< collapse title="VictorOps" >}} + +#### VictorOps ```yaml type: victorops @@ -456,7 +521,11 @@ settings: messageType: CRITICAL ``` -##### Webhook +{{< /collapse >}} + +{{< collapse title="Webhook" >}} + +#### Webhook ```yaml type: webhook @@ -477,7 +546,11 @@ settings: maxAlerts: '10' ``` -##### WeCom +{{< /collapse >}} + +{{< collapse title="WeCom" >}} + +#### WeCom ```yaml type: wecom @@ -492,17 +565,72 @@ settings: {{ template "default.title" . }} ``` -### Provision notification policies +{{< /collapse >}} + +## Import templates + +Create or delete templates using provisioning files in your Grafana instance(s). + +1. Find the notification template in Grafana. +1. [Export][export_templates] a template by copying the template content and title. +1. Copy the contents into a YAML or JSON configuration file and add it to the `provisioning/alerting` directory of the Grafana instance you want to import the alerting resources to. + + Example configuration files can be found below. + +1. Restart your Grafana instance (or reload the provisioned files using the Admin API). + +Here is an example of a configuration file for creating templates. + +```yaml +# config file version +apiVersion: 1 + +# List of templates to import or update +templates: + # <int> organization ID, default = 1 + - orgId: 1 + # <string, required> name of the template, must be unique + name: my_first_template + # <string, required> content of the template + template: | + {{ define "my_first_template" }} + Custom notification message + {{ end }} +``` + +Here is an example of a configuration file for deleting templates. + +```yaml +# config file version +apiVersion: 1 + +# List of alert rule UIDs that should be deleted +deleteTemplates: + # <int> organization ID, default = 1 + - orgId: 1 + # <string, required> name of the template, must be unique + name: my_first_template +``` + +## Import notification policies + +Create or reset the notification policy tree using provisioning files in your Grafana instance(s). + +In Grafana, the entire notification policy tree is considered a single, large resource. Add new specific policies as sub-policies under the root policy. Since specific policies may depend on each other, you cannot provision subsets of the policy tree; the entire tree must be defined in a single place. + +{{% admonition type="warning" %}} -Create or reset the notification policy tree in your Grafana instance(s). +Since the policy tree is a single resource, provisioning it will overwrite a policy tree created through any other means. -1. Create a notification policy in Grafana. -1. Use the [Alerting provisioning API][alerting_provisioning] export endpoints to download a provisioning file for your notification policy. -1. Copy the contents into a YAML or JSON configuration file in the default provisioning directory or in your configured directory. +{{< /admonition >}} + +1. Find the notification policy tree in Grafana. +1. [Export][export_policies] and download a provisioning file for your notification policy tree. +1. Copy the contents into a YAML or JSON configuration file and add it to the `provisioning/alerting` directory of the Grafana instance you want to import the alerting resources to. Example configuration files can be found below. -1. Ensure that your files are in the right directory on the node running the Grafana server, so that they deploy alongside your Grafana instance(s). +1. Restart your Grafana instance (or reload the provisioned files using the Admin API). Here is an example of a configuration file for creating notification policies. @@ -578,61 +706,17 @@ resetPolicies: - 1 ``` -**Note:** - -In Grafana, the entire notification policy tree is considered a single, large resource. Add new specific policies as sub-policies under the root policy. Since specific policies may depend on each other, you cannot provision subsets of the policy tree; the entire tree must be defined in a single place. - -Since the policy tree is a single resource, applying it will overwrite a policy tree created through any other means. +## Import mute timings -### Provision templates +Create or delete mute timings via provisioning files using provisioning files in your Grafana instance(s). -Create or delete templates in your Grafana instance(s). - -1. Create a YAML or JSON configuration file. +1. Find the mute timing in Grafana. +1. [Export][export_mute_timings] and download a provisioning file for your mute timing. +1. Copy the contents into a YAML or JSON configuration file and add it to the `provisioning/alerting` directory of the Grafana instance you want to import the alerting resources to. Example configuration files can be found below. -2. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s). - -Here is an example of a configuration file for creating templates. - -```yaml -# config file version -apiVersion: 1 - -# List of templates to import or update -templates: - # <int> organization ID, default = 1 - - orgID: 1 - # <string, required> name of the template, must be unique - name: my_first_template - # <string, required> content of the the template - template: Alerting with a custom text template -``` - -Here is an example of a configuration file for deleting templates. - -```yaml -# config file version -apiVersion: 1 - -# List of alert rule UIDs that should be deleted -deleteTemplates: - # <int> organization ID, default = 1 - - orgId: 1 - # <string, required> name of the template, must be unique - name: my_first_template -``` - -### Provision mute timings - -Create or delete mute timings in your Grafana instance(s). - -1. Create a YAML or JSON configuration file. - - Example configuration files can be found below. - -1. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s). +1. Restart your Grafana instance (or reload the provisioned files using the Admin API). Here is an example of a configuration file for creating mute timings. @@ -673,65 +757,33 @@ deleteMuteTimes: name: mti_1 ``` -### File provisioning using Kubernetes +## More examples -If you are a Kubernetes user, you can leverage file provisioning using Kubernetes configuration maps. +For more examples on the concept of this guide: -1. Create one or more configuration maps as follows. +- Try provisioning alerting resources in Grafana OSS with YAML files through a demo project using [Docker Compose](https://github.com/grafana/provisioning-alerting-examples/tree/main/config-files) or [Kubernetes deployments](https://github.com/grafana/provisioning-alerting-examples/tree/main/kubernetes). +- Review the distinct options about how Grafana provisions resources in the [Provision Grafana documentation][provisioning]. +- For Helm support, review the examples provisioning alerting resources in the [Grafana Helm Chart documentation](https://github.com/grafana/helm-charts/blob/main/charts/grafana/README.md). -```yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-alerting -data: - provisioning.yaml: | - templates: - - name: my_first_template - template: the content for my template -``` +{{% docs/reference %}} -2. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s). +[export_alert_rules]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-alert-rules" +[export_alert_rules]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-alert-rules" -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: grafana -spec: - replicas: 1 - selector: - matchLabels: - app: grafana - template: - metadata: - name: grafana - labels: - app: grafana - spec: - containers: - - name: grafana - image: grafana/grafana:latest - ports: - - name: grafana - containerPort: 3000 - volumeMounts: - - mountPath: /etc/grafana/provisioning/alerting - name: grafana-alerting - readOnly: false - volumes: - - name: grafana-alerting - configMap: - defaultMode: 420 - name: grafana-alerting -``` +[export_contact_points]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-contact-points" +[export_contact_points]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-contact-points" -This eliminates the need for a persistent database to use Grafana Alerting in Kubernetes; all your provisioned resources appear after each restart or re-deployment. Grafana still requires a database for normal operation, you do not need to persist the contents of the database between restarts if all objects are provisioned using files. +[export_templates]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-templates" +[export_templates]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-templates" -{{% docs/reference %}} -[alerting_provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/developers/http_api/alerting_provisioning" -[alerting_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/developers/http_api/alerting_provisioning" +[export_policies]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-the-notification-policy-tree" +[export_policies]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-the-notification-policy-tree" + +[export_mute_timings]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-mute-timings" +[export_mute_timings]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-mute-timings" + +[provisioning]: "/docs/ -> /docs/grafana/<GRAFANA_VERSION>/administration/provisioning" + +[reload-provisioning-configurations]: "/docs/ -> /docs/grafana/<GRAFANA_VERSION>/developers/http_api/admin#reload-provisioning-configurations" -[reload-provisioning-configurations]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/developers/http_api/admin#reload-provisioning-configurations" -[reload-provisioning-configurations]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/developers/http_api/admin#reload-provisioning-configurations" {{% /docs/reference %}} diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/http-api-provisioning/_index.md b/docs/sources/alerting/set-up/provision-alerting-resources/http-api-provisioning/_index.md new file mode 100644 index 0000000000000..4c1f460657110 --- /dev/null +++ b/docs/sources/alerting/set-up/provision-alerting-resources/http-api-provisioning/_index.md @@ -0,0 +1,20 @@ +--- +canonical: https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/ +description: Create and manage alerting resources using the HTTP API +keywords: + - grafana + - alerting + - alerting resources + - provisioning +labels: + products: + - cloud + - enterprise + - oss +title: Use the HTTP API to manage alerting resources +weight: 400 +--- + +# Use the HTTP API to manage alerting resources + +{{< docs/shared lookup="alerts/alerting_provisioning.md" source="grafana" version="latest" >}} diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md index 00da792044447..b3bef7ffc7a39 100644 --- a/docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md +++ b/docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md @@ -1,6 +1,4 @@ --- -aliases: - - ../../provision-alerting-resources/terraform-provisioning/ canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/ description: Create and manage alerting resources using Terraform keywords: @@ -11,357 +9,415 @@ keywords: - Terraform labels: products: + - cloud - enterprise - oss -title: Create and manage alerting resources using Terraform +menuTitle: Use Terraform to provision +title: Use Terraform to provision alerting resources weight: 200 --- -# Create and manage alerting resources using Terraform +# Use Terraform to provision alerting resources Use Terraform’s Grafana Provider to manage your alerting resources and provision them into your Grafana system. Terraform provider support for Grafana Alerting makes it easy to create, manage, and maintain your entire Grafana Alerting stack as code. -For more information on managing your alerting resources using Terraform, refer to the [Grafana Provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) documentation. +This guide outlines the steps and references to provision alerting resources with Terraform. For a practical demo, you can clone and try this [example using Grafana OSS and Docker Compose](https://github.com/grafana/provisioning-alerting-examples/tree/main/terraform). -Complete the following tasks to create and manage your alerting resources using Terraform. +To create and manage your alerting resources using Terraform, you have to complete the following tasks. -1. Create an API key for provisioning. -1. Configure the Terraform provider. -1. Define your alerting resources in Terraform. +1. Create an API key to configure the Terraform provider. +1. Create your alerting resources in Terraform format by + - [exporting configured alerting resources][alerting_export] + - or writing the [Terraform Alerting schemas](https://registry.terraform.io/providers/grafana/grafana/latest/docs). + > By default, you cannot edit provisioned resources. Enable [`disable_provenance` in the Terraform resource](#enable-editing-resources-in-the-grafana-ui) to allow changes in the Grafana UI. 1. Run `terraform apply` to provision your alerting resources. -## Before you begin +Before you begin, you should have available a Grafana instance and [Terraform installed](https://www.terraform.io/downloads) on your machine. -- Ensure you have the grafana/grafana [Terraform provider](https://registry.terraform.io/providers/grafana/grafana/1.28.0) 1.27.0 or higher. +## Create an API key and configure the Terraform provider -- Ensure you are using Grafana 9.1 or higher. +You can create a [service account token][service-accounts] to authenticate Terraform with Grafana. To create an API key for provisioning alerting resources, complete the following steps. -## Create an API key for provisioning - -You can [create a normal Grafana API key][api-keys] to authenticate Terraform with Grafana. Most existing tooling using API keys should automatically work with the new Grafana Alerting support. - -There are also dedicated RBAC roles for alerting provisioning. This lets you easily authenticate as a [service account][service-accounts] with the minimum permissions needed to provision your Alerting infrastructure. - -To create an API key for provisioning, complete the following steps. - -1. Create a new service account for your CI pipeline. -1. Assign the role “Access the alert rules Provisioning API.” +1. Create a new service account. +1. Assign the role or permission to access the [Alerting provisioning API][alerting_http_provisioning]. 1. Create a new service account token. 1. Name and save the token for use in Terraform. -Alternatively, you can use basic authentication. To view all the supported authentication formats, see [here](https://registry.terraform.io/providers/grafana/grafana/latest/docs#authentication). +You can now move to the working directory for your Terraform configurations, and create a file named `main.tf` like: -## Configure the Terraform provider - -Grafana Alerting support is included as part of the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs). - -The following is an example you can use to configure the Terraform provider. - -```HCL +```main.tf terraform { required_providers { grafana = { source = "grafana/grafana" - version = ">= 1.28.2" + version = ">= 2.9.0" } } } provider "grafana" { - url = <YOUR_GRAFANA_URL> - auth = <YOUR_GRAFANA_API_KEY> + url = <grafana-url> + auth = <api-key> } ``` -## Provision contact points and templates +Replace the following values: + +- `<grafana-url>` with the URL of the Grafana instance. +- `<api-key>` with the API token previously created. + +This Terraform configuration installs the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) and authenticates against your Grafana instance using an API token. For other authentication alternatives including basic authentication, refer to the [`auth` option documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs#authentication). + +For Grafana Cloud, refer to the [instructions to manage a Grafana Cloud stack with Terraform][provision-cloud-with-terraform]. For role-based access control, refer to [Provisioning RBAC with Terraform][rbac-terraform-provisioning] and the [alerting provisioning roles (`fixed:alerting.provisioning.*`)][rbac-role-definitions]. + +## Create Terraform configurations for alerting resources + +[Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) enables you to manage the following alerting resources. + +| Alerting resource | Terraform resource | +| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| [Alert rules][alerting-rules] | [grafana_rule_group](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/rule_group) | +| [Contact points][contact-points] | [grafana_contact_point](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/contact_point) | +| [Notification templates][notification-template] | [grafana_message_template](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/message_template) | +| [Notification policy tree][notification-policy] | [grafana_notification_policy](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/notification_policy) | +| [Mute timings][mute-timings] | [grafana_mute_timing](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/mute_timing) | + +In this section, we'll create Terraform configurations for each alerting resource and demonstrate how to link them together. + +### Add alert rules + +[Alert rules][alerting-rules] enable you to receive alerts by querying any backend Grafana data sources. + +1. First, create a data source to query and a folder to store your rules in. + + In this example, the [TestData][testdata] data source is used. + + ```terraform + resource "grafana_data_source" "<terraform_data_source_name>" { + name = "TestData" + type = "testdata" + } -Contact points connect an alerting stack to the outside world. They tell Grafana how to connect to your external systems and where to deliver notifications. There are over fifteen different [integrations](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/contact_point#optional) to choose from. + resource "grafana_folder" "<terraform_folder_name>" { + title = "My Rule Folder" + } + ``` -To provision contact points and templates, complete the following steps. + Replace the following field values: -1. Copy this code block into a .tf file on your local machine. + - `<terraform_data_source_name>` with the terraform name of the data source. + - `<terraform_folder_name>` with the terraform name of the folder. -This example creates a contact point that sends alert notifications to Slack. +1. Create or find an alert rule you want to import in Grafana. -```HCL -resource "grafana_contact_point" "my_slack_contact_point" { - name = "Send to My Slack Channel" +1. [Export][alerting_export] the alert rule group in Terraform format. This exports the alert rule group as [`grafana_rule_group` Terraform resource](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/rule_group). - slack { - url = <YOUR_SLACK_WEBHOOK_URL> - text = <<EOT -{{ len .Alerts.Firing }} alerts are firing! + You can edit the exported resource, or alternatively, consider creating the resource from scratch. -Alert summaries: -{{ range .Alerts.Firing }} -{{ template "Alert Instance Template" . }} -{{ end }} -EOT - } -} -``` + ```terraform + resource "grafana_rule_group" "<terraform_rule_group_name>" { + name = "My Alert Rules" + folder_uid = grafana_folder.<terraform_folder_name>.uid + interval_seconds = 60 + org_id = 1 -You can create multiple external integrations in a single contact point. Notifications routed to this contact point will be sent to all integrations. This example shows multiple integrations in the same Terraform resource. + rule { + name = "My Random Walk Alert" + condition = "C" + for = "0s" -``` -resource "grafana_contact_point" "my_multi_contact_point" { - name = "Send to Many Places" + // Query the datasource. + data { + ref_id = "A" + relative_time_range { + from = 600 + to = 0 + } + datasource_uid = grafana_data_source.<terraform_data_source_name>.uid + // `model` is a JSON blob that sends datasource-specific data. + // It's different for every datasource. The alert's query is defined here. + model = jsonencode({ + intervalMs = 1000 + maxDataPoints = 43200 + refId = "A" + }) + } - slack { - url = "webhook1" - ... - } - slack { - url = "webhook2" - ... - } - teams { - ... - } - email { - ... + // The query was configured to obtain data from the last 60 seconds. Let's alert on the average value of that series using a Reduce stage. + data { + datasource_uid = "__expr__" + // You can also create a rule in the UI, then GET that rule to obtain the JSON. + // This can be helpful when using more complex reduce expressions. + model = <<EOT + {"conditions":[{"evaluator":{"params":[0,0],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"params":[],"type":"last"},"type":"avg"}],"datasource":{"name":"Expression","type":"__expr__","uid":"__expr__"},"expression":"A","hide":false,"intervalMs":1000,"maxDataPoints":43200,"reducer":"last","refId":"B","type":"reduce"} + EOT + ref_id = "B" + relative_time_range { + from = 0 + to = 0 + } + } + + // Now, let's use a math expression as our threshold. + // We want to alert when the value of stage "B" above exceeds 70. + data { + datasource_uid = "__expr__" + ref_id = "C" + relative_time_range { + from = 0 + to = 0 + } + model = jsonencode({ + expression = "$B > 70" + type = "math" + refId = "C" + }) + } + } + } + ``` + + Replace the following field values: + + - `<terraform_rule_group_name>` with the name of the alert rule group. + + Note that the distinct Grafana resources are connected through `uid` values in their Terraform configurations. The `uid` value will be randomly generated when provisioning. + + To link the alert rule group with its respective data source and folder in this example, replace the following field values: + + - `<terraform_data_source_name>` with the terraform name of the previously defined data source. + - `<terraform_folder_name>` with the terraform name of the the previously defined folder. + +1. Continue to add more Grafana resources or [use the Terraform CLI for provisioning](#provision-grafana-resources-with-terraform). + +### Add contact points + +[Contact points][contact-points] are the receivers of alert notifications. + +1. Create or find the contact points you want to import in Grafana. Alternatively, consider writing the resource in code as demonstrated in the example below. + +1. [Export][alerting_export] the contact point in Terraform format. This exports the contact point as [`grafana_contact_point` Terraform resource](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/contact_point)—edit it if necessary. + +1. In this example, notifications are muted on weekends. + + ```terraform + resource "grafana_contact_point" "<terraform_contact_point_name>" { + name = "My contact point email" + + email { + addresses = ["<email_address>"] + } } -} -``` + ``` -2. Enter text for your notification in the text field. + Replace the following field values: -The `text` field supports [Go-style templating](https://pkg.go.dev/text/template). This enables you to manage your Grafana Alerting notification templates directly in Terraform. + - `<terraform_contact_point_name>` with the terraform name of the contact point. It will be used to reference the contact point in other Terraform resources. + - `<email_address>` with the email to receive alert notifications. -3. Run the command ‘terraform apply’. +1. Continue to add more Grafana resources or [use the Terraform CLI for provisioning](#provision-grafana-resources-with-terraform). -4. Go to the Grafana UI and check the details of your contact point. +### Add and enable templates -By default, you cannot edit resources provisioned via Terraform from the UI. This ensures that your alerting stack always stays in sync with your code. +[Notification templates][notification-template] allow customization of alert notifications across multiple contact points. -5. Click **Test** to verify that the contact point works correctly. +1. Create or find the notification template you want to import in Grafana. Alternatively, consider writing the resource in code as demonstrated in the example below. -**Note:** +1. [Export][alerting_export] the template as [`grafana_message_template` Terraform resource](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/message_template). -You can reuse the same templates across many contact points. In the example above, a shared template ie embedded using the statement `{{ template “Alert Instance Template” . }}` + This example is a simple demo template defined as `custom_email.message`. -This fragment can then be managed separately in Terraform: + ```terraform + resource "grafana_message_template" "<terraform_message_template_name>" { + name = "custom_email.message" -```HCL -resource "grafana_message_template" "my_alert_template" { - name = "Alert Instance Template" + template = <<EOT + {{ define "custom_email.message" }} + Lorem ipsum - Custom alert! + {{ end }} + EOT + } + ``` - template = <<EOT -{{ define "Alert Instance Template" }} -Firing: {{ .Labels.alertname }} -Silence: {{ .SilenceURL }} -{{ end }} -EOT -} -``` +1. In the previous contact point, enable the template by setting the `email.message` property as follows. -## Provision notification policies and routing + ```terraform + resource "grafana_contact_point" "<terraform_contact_point_name>" { + name = "My contact point email" -Notification policies tell Grafana how to route alert instances to your contact points. They connect firing alerts to your previously defined contact points using a system of labels and matchers. + email { + addresses = ["<email_address>"] + message = "{{ template \"custom_email.message\" .}}" + } + } + ``` -To provision notification policies and routing, complete the following steps. +1. Continue to add more Grafana resources or [use the Terraform CLI for provisioning](#provision-grafana-resources-with-terraform). -1. Copy this code block into a .tf file on your local machine. +### Add mute timings -In this example, the alerts are grouped by `alertname`, which means that any notifications coming from alerts which share the same name, are grouped into the same Slack message. You can provide any set of label keys here, or you can use the special label `"..."` to route by all label keys, sending each alert in a separate notification. +[Mute timings][mute-timings] pause alert notifications during predetermined intervals. -If you want to route specific notifications differently, you can add sub-policies. Sub-policies allow you to apply routing to different alerts based on label matching. In this example, we apply a mute timing to all alerts with the label a=b. +1. Create or find the mute timings you want to import in Grafana. Alternatively, consider writing the resource in code as demonstrated in the example below. -```HCL -resource "grafana_notification_policy" "my_policy" { - group_by = ["alertname"] - contact_point = grafana_contact_point.my_slack_contact_point.name +1. [Export][alerting_export] the mute timing in Terraform format. This exports the mute timing as [`grafana_mute_timing` Terraform resource](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/mute_timing)—edit it if necessary. - group_wait = "45s" - group_interval = "6m" - repeat_interval = "3h" +1. This example turns off notifications on weekends. - policy { - matcher { - label = "a" - match = "=" - value = "b" - } - group_by = ["..."] - contact_point = grafana_contact_point.a_different_contact_point.name - mute_timings = [grafana_mute_timing.my_mute_timing.name] - - policy { - matcher { - label = "sublabel" - match = "=" - value = "subvalue" - } - contact_point = grafana_contact_point.a_third_contact_point.name - group_by = ["..."] + ```terraform + resource "grafana_mute_timing" "<terraform_mute_timing_name>" { + name = "No weekends" + + intervals { + weekdays = ["saturday", "sunday"] } } -} -``` + ``` -2. In the mute_timings field, link a mute timing to your notification policy. + Replace the following field values: -3. Run the command ‘terraform apply’. + - `<terraform_mute_timing_name>` with the name of the Terraform resource. It will be used to reference the mute timing in the Terraform notification policy tree. -4. Go to the Grafana UI and check the details of your notification policy. +1. Continue to add more Grafana resources or [use the Terraform CLI for provisioning](#provision-grafana-resources-with-terraform). -**Note:** +### Add the notification policy tree -Since the policy tree is a single resource, applying it will overwrite a policy tree created through any other means. +[Notification policies][notification-policy] defines how to route alert instances to your contact points. -By default, you cannot edit resources provisioned from Terraform from the UI. This ensures that your alerting stack always stays in sync with your code. +{{% admonition type="warning" %}} -5. Click **Test** to verify that the notification point is working correctly. +Since the policy tree is a single resource, provisioning the `grafana_notification_policy` resource will overwrite a policy tree created through any other means. -## Provision mute timings +{{< /admonition >}} -Mute timings provide the ability to mute alert notifications for defined time periods. +1. Find the default notification policy tree. Alternatively, consider writing the resource in code as demonstrated in the example below. -To provision mute timings, complete the following steps. +1. [Export][alerting_export] the notification policy tree in Terraform format. This exports it as [`grafana_notification_policy` Terraform resource](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/notification_policy)—edit it if necessary. -1. Copy this code block into a .tf file on your local machine. + ```terraform + resource "grafana_notification_policy" "my_policy_tree" { + contact_point = grafana_contact_point.<terraform_contact_point_name>.name + ... -In this example, alert notifications are muted on weekends. + policy { + contact_point = grafana_contact_point.<terraform_contact_point_name>.name -```HCL -resource "grafana_mute_timing" "my_mute_timing" { - name = "My Mute Timing" + matcher {...} - intervals { - times { - start = "04:56" - end = "14:17" - } - weekdays = ["saturday", "sunday", "tuesday:thursday"] - months = ["january:march", "12"] - years = ["2025:2027"] - } + mute_timings = [grafana_mute_timing.<terraform_mute_timing_name>.name] + } + } + ``` + + To configure the mute timing and contact point previously created in the notification policy tree, replace the following field values: + + - `<terraform_data_source_name>` with the terraform name of the previously defined contact point. + - `<terraform_folder_name>` with the terraform name of the the previously defined mute timing. + +1. Continue to add more Grafana resources or [use the Terraform CLI for provisioning](#provision-grafana-resources-with-terraform). + +### Enable editing resources in the Grafana UI + +By default, you cannot edit resources provisioned via Terraform in Grafana. This ensures that your alerting stack always stays in sync with your Terraform code. + +To make provisioned resources editable in the Grafana UI, enable the `disable_provenance` attribute on alerting resources. + +```terraform +resource "grafana_contact_point" "my_contact_point" { + name = "My Contact Point" + + disable_provenance = true } -``` -2. Run the command ‘terraform apply’. -3. Go to the Grafana UI and check the details of your mute timing. -4. Reference your newly created mute timing in a notification policy using the `mute_timings` field. - This will apply your mute timing to some or all of your notifications. +resource "grafana_message_template" "my_template" { + name = "My Reusable Template" + template = "{{define \"My Reusable Template\" }}\n template content\n{{ end }}" -**Note:** + disable_provenance = true +} +... +``` -By default, you cannot edit resources provisioned from Terraform from the UI. This ensures that your alerting stack always stays in sync with your code. +Note that `disable_provenance` is not supported for [grafana_mute_timing](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/mute_timing). -5. Click **Test** to verify that the mute timing is working correctly. +## Provision Grafana resources with Terraform -## Provision alert rules +To create the previous alerting resources in Grafana with the Terraform CLI, complete the following steps. -[Alert rules][alerting-rules] enable you to alert against any Grafana data source. This can be a data source that you already have configured, or you can [define your data sources in Terraform](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/data_source) alongside your alert rules. +1. Initialize the working directory containing the Terraform configuration files. -To provision alert rules, complete the following steps. + ```shell + terraform init + ``` -1. Create a data source to query and a folder to store your rules in. + This command initializes the Terraform directory, installing the Grafana Terraform provider configured in the `main.tf` file. -In this example, the [TestData][testdata] data source is used. +1. Apply the Terraform configuration files to provision the resources. -Alerts can be defined against any backend datasource in Grafana. + ```shell + terraform apply + ``` -```HCL -resource "grafana_data_source" "testdata_datasource" { - name = "TestData" - type = "testdata" -} + Before applying any changes to Grafana, Terraform displays the execution plan and requests your approval. -resource "grafana_folder" "rule_folder" { - title = "My Rule Folder" -} -``` + ```shell + Plan: 4 to add, 0 to change, 0 to destroy. -2. Define an alert rule. - -For more information on alert rules, refer to [how to create Grafana-managed alerts](/blog/2022/08/01/grafana-alerting-video-how-to-create-alerts-in-grafana-9/). - -3. Create a rule group containing one or more rules. - -In this example, the `grafana_rule_group` resource group is used. - -```HCL -resource "grafana_rule_group" "my_rule_group" { - name = "My Alert Rules" - folder_uid = grafana_folder.rule_folder.uid - interval_seconds = 60 - org_id = 1 - - rule { - name = "My Random Walk Alert" - condition = "C" - for = "0s" - - // Query the datasource. - data { - ref_id = "A" - relative_time_range { - from = 600 - to = 0 - } - datasource_uid = grafana_data_source.testdata_datasource.uid - // `model` is a JSON blob that sends datasource-specific data. - // It's different for every datasource. The alert's query is defined here. - model = jsonencode({ - intervalMs = 1000 - maxDataPoints = 43200 - refId = "A" - }) - } + Do you want to perform these actions? + Terraform will perform the actions described above. + Only 'yes' will be accepted to approve. - // The query was configured to obtain data from the last 60 seconds. Let's alert on the average value of that series using a Reduce stage. - data { - datasource_uid = "__expr__" - // You can also create a rule in the UI, then GET that rule to obtain the JSON. - // This can be helpful when using more complex reduce expressions. - model = <<EOT -{"conditions":[{"evaluator":{"params":[0,0],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"params":[],"type":"last"},"type":"avg"}],"datasource":{"name":"Expression","type":"__expr__","uid":"__expr__"},"expression":"A","hide":false,"intervalMs":1000,"maxDataPoints":43200,"reducer":"last","refId":"B","type":"reduce"} -EOT - ref_id = "B" - relative_time_range { - from = 0 - to = 0 - } - } + Enter a value: + ``` - // Now, let's use a math expression as our threshold. - // We want to alert when the value of stage "B" above exceeds 70. - data { - datasource_uid = "__expr__" - ref_id = "C" - relative_time_range { - from = 0 - to = 0 - } - model = jsonencode({ - expression = "$B > 70" - type = "math" - refId = "C" - }) - } - } -} -``` + Once you have confirmed to proceed with the changes, Terraform will create the provisioned resources in Grafana! -4. Go to the Grafana UI and check your alert rule. + ```shell + Apply complete! Resources: 4 added, 0 changed, 0 destroyed. + ``` -You can see whether or not the alert rule is firing. You can also see a visualization of each of the alert rule’s query stages +You can now access Grafana to verify the creation of the distinct resources. -When the alert fires, Grafana routes a notification through the policy you defined. +## More examples -For example, if you chose Slack as a contact point, Grafana’s embedded [Alertmanager](https://github.com/prometheus/alertmanager) automatically posts a message to Slack. +For more examples on the concept of this guide: + +- Try the demo [provisioning alerting resources in Grafana OSS using Terraform and Docker Compose](https://github.com/grafana/provisioning-alerting-examples/tree/main/terraform). +- Review all the available options and examples of the Terraform Alerting schemas in the [Grafana Terraform Provider documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs). +- Review the [tutorial to manage a Grafana Cloud stack using Terraform][provision-cloud-with-terraform]. {{% docs/reference %}} -[alerting-rules]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/alerting-rules" + +[alerting-rules]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules" [alerting-rules]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules" -[api-keys]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/api-keys" -[api-keys]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/api-keys" +[contact-points]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/manage-contact-points" +[contact-points]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/manage-contact-points" + +[mute-timings]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/mute-timings" +[mute-timings]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/mute-timings" + +[notification-policy]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/create-notification-policy" +[notification-policy]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/create-notification-policy" + +[notification-template]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/template-notifications" +[notification-template]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/template-notifications" + +[alerting_export]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources" +[alerting_export]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/export-alerting-resources" + +[alerting_http_provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning" +[alerting_http_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/http-api-provisioning" + +[service-accounts]: "/docs/ -> /docs/grafana/<GRAFANA_VERSION>/administration/service-accounts" + +[testdata]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/datasources/testdata" +[testdata]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/connect-externally-hosted/data-sources/testdata" + +[provision-cloud-with-terraform]: "/docs/ -> /docs/grafana-cloud/developer-resources/infrastructure-as-code/terraform/terraform-cloud-stack" + +[rbac-role-definitions]: "/docs/ -> /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions" -[service-accounts]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/service-accounts" -[service-accounts]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/service-accounts" +[rbac-terraform-provisioning]: "/docs/ -> /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/rbac-terraform-provisioning" -[testdata]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/testdata" -[testdata]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources/testdata" {{% /docs/reference %}} diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/view-provisioned-resources/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/view-provisioned-resources/index.md deleted file mode 100644 index 5edb960bffce4..0000000000000 --- a/docs/sources/alerting/set-up/provision-alerting-resources/view-provisioned-resources/index.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -aliases: - - ../../provision-alerting-resources/view-provisioned-resources/ -canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/view-provisioned-resources/ -description: Manage provisioned alerting resources in Grafana -keywords: - - grafana - - alerting - - alerting resources - - provisioning -labels: - products: - - cloud - - enterprise - - oss -menuTitle: Manage provisioned resources in Grafana -title: Manage provisioned alerting resources in Grafana -weight: 300 ---- - -# Manage provisioned alerting resources in Grafana - -Verify that your alerting resources were created in Grafana, as well as edit or export your provisioned alerting resources. - -## View provisioned alerting resoureces - -To view your provisioned resources in Grafana, complete the following steps. - -1. Open your Grafana instance. -1. Navigate to Alerting. -1. Click an alerting resource folder, for example, Alert rules. - -Provisioned resources are labeled **Provisioned**, so that it is clear that they were not created manually. - -## Export provisioned alerting resources - -Export your alerting resources, such as alert rules, contact points, and notification policies in JSON, YAML, or Terraform format. You can export all Grafana-managed alert rules, single folders, and single groups. - -To export provisioned alerting resources from the Grafana UI, complete the following steps. - -1. Click **Alerts & IRM** -> **Alert rules**. -1. To export all Grafana-managed rules, click **More v** -> **Export all Grafana-managed rules**. -1. To export a folder, change the **View as** to **List**. -1. Select the folder you want to export and click the **Export rules folder** icon. -1. To export a group, change the **View as** to **Grouped**. -1. Find the group you want to export and click the **Export rule group** icon. -1. Choose the format to export in. - - Note that formats JSON and YAML are suitable only for file provisioning. To get rule definition in provisioning API format, use the provisioning GET API. - -1. Click **Copy Code** or **Download**. -1. Choose **Copy Code** to go to an existing file and paste in the code. -1. Choose **Download** to download a file with the exported data. - -## Edit provisioned alert rules - -Use the **Modify export** mode for alert rules to edit provisioned alert rules and export a modified version. - -{{% admonition type="note" %}} This feature is for Grafana-managed alert rules only. It is available to Admin, Viewer, and Editor roles. {{% /admonition %}} - -To edit provisioned alerting alert rules from the Grafana UI, complete the following steps. - -1. Click **Alerts & IRM** -> **Alert rules**. -1. Locate the alert rule you want to edit and click **More** -> **Modify Export** to open the Alert Rule form. -1. From the Alert Rule form, edit the fields you want to change. -1. Click **Export** to export all alert rules within the group. - - You can only export groups of rules; not single rules. - The exported rule data appears in different formats - HTML, JSON, Terraform. - -1. Choose the format to export in. -1. Click **Copy Code** or **Download**. - - a. Choose **Copy Code** to go to an existing file and paste in the code. - - b. Choose **Download** to download a file with the exported data. - -## Edit API-provisioned alerting resources - -To enable editing of API-provisioned resources in the Grafana UI, add the `X-Disable-Provenance` header to the following requests in the API: - -- `POST /api/v1/provisioning/alert-rules` -- `PUT /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}` (calling this endpoint will change provenance for all alert rules within the alert group) -- `POST /api/v1/provisioning/contact-points` -- `POST /api/v1/provisioning/mute-timings` -- `PUT /api/v1/provisioning/policies` -- `PUT /api/v1/provisioning/templates/{name}` - -To reset the notification policy tree to the default and unlock it for editing in the Grafana UI, use the `DELETE /api/v1/provisioning/policies` endpoint. - -To pass the `X-Disable-Provenance` header from Terraform, add it to the `http_headers` field on the provider object: - -``` -provider "grafana" { - url = "http://grafana.example.com/" - auth = var.grafana_auth - http_headers = { - "X-Disable-Provenance" = "true" - } -} -``` - -**Note:** - -You cannot edit provisioned resources from files in Grafana. You can only change the resource properties by changing the provisioning file and restarting Grafana or carrying out a hot reload. This prevents changes being made to the resource that would be overwritten if a file is provisioned again or a hot reload is carried out. diff --git a/docs/sources/breaking-changes/breaking-changes-v10-0.md b/docs/sources/breaking-changes/breaking-changes-v10-0.md index 1135ccad11bbb..8c0dd3f29e22b 100644 --- a/docs/sources/breaking-changes/breaking-changes-v10-0.md +++ b/docs/sources/breaking-changes/breaking-changes-v10-0.md @@ -81,7 +81,7 @@ Grafana legacy alerting (dashboard alerts) has been deprecated since Grafana v9. #### Migration path -The new Grafana Alerting was introduced in Grafana 8 and is a superset of legacy alerting. Learn how to migrate your alerts in the [Upgrade Alerting documentation]({{< relref "../alerting/set-up/migrating-alerts/" >}}). +The new Grafana Alerting was introduced in Grafana 8 and is a superset of legacy alerting. Learn how to migrate your alerts in the [Upgrade Alerting documentation]({{< relref "./v10.4/alerting/set-up/migrating-alerts/" >}}). ### API keys are migrating to service accounts diff --git a/docs/sources/breaking-changes/breaking-changes-v10-3.md b/docs/sources/breaking-changes/breaking-changes-v10-3.md new file mode 100644 index 0000000000000..cbb0a37a77caa --- /dev/null +++ b/docs/sources/breaking-changes/breaking-changes-v10-3.md @@ -0,0 +1,77 @@ +--- +description: Breaking changes for Grafana v10.3 +keywords: + - grafana + - breaking changes + - documentation + - '10.3' + - '10.2.3' + - release notes +labels: + products: + - cloud + - enterprise + - oss +title: Breaking changes in Grafana v10.3 +weight: -2 +--- + +# Breaking changes in Grafana v10.3 + +Following are breaking changes that you should be aware of when upgrading to Grafana v10.3. Breaking changes that were introduced in release 10.2.3 are also included here and are marked with an asterisk. + +For our purposes, a breaking change is any change that requires users or operators to do something. This includes: + +- Changes in one part of the system that could cause other components to fail +- Deprecations or removal of a feature +- Changes to an API that could break automation +- Changes that affect some plugins or functions of Grafana +- Migrations that can’t be rolled back + +For each change, the provided information: + +- Helps you determine if you’re affected +- Describes the change or relevant background information +- Guides you in how to mitigate for the change or migrate +- Provides more learning resources + +For release highlights and deprecations, refer to our [v10.3 What’s new](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/whatsnew/whats-new-in-v10-3/). For the specific steps we recommend when you upgrade to v10.3, check out our [Upgrade guide](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/upgrade-guide/upgrade-v10.3/). + +<!-- +## Feature + +You must use relative references when linking to docs within the Grafana repo. Please do not use absolute URLs. For more information about relrefs, refer to [Links and references](/docs/writers-toolkit/writing-guide/references/).--> + +## General breaking changes + +### Transformations\* + +In panels using the extract fields transformation, where one of the extracted names collides with one of the already existing fields, the extracted field will be renamed. Issue [#77569](https://github.com/grafana/grafana/issues/77569). + +If you use the Table visualization, you might see some inconsistencies in your panels. We have updated the table column naming. This will potentially affect field transformations and/or field overrides. To resolve this, either: + +- Update the transformation you are using +- Update field override. Issue [#76899](https://github.com/grafana/grafana/issues/76899). + +Users who have transformations with the Time field might see their transformations are not working. Those panels that have broken transformations will fail to render. This is because we changed the field key. See related PR: [#69865](https://github.com/grafana/grafana/pull/69865). To resolve this, either: + +- Remove the affected panel and re-create it +- Select the Time field again +- Edit the time field as Time for transformation in panel.json or dashboard.json. Issue [#76641](https://github.com/grafana/grafana/issues/76641). + +### Data source permissions\* + +The following data source permission endpoints have been removed: + +- `GET /datasources/:datasourceId/permissions` +- `POST /api/datasources/:datasourceId/permissions` +- `DELETE /datasources/:datasourceId/permissions` +- `POST /datasources/:datasourceId/enable-permissions` +- `POST /datasources/:datasourceId/disable-permissions` + +Please use the following endpoints instead: + +- `GET /api/access-control/datasources/:uid` for listing data source permissions +- `POST /api/access-control/datasources/:uid/users/:id`, `POST /api/access-control/datasources/:uid/teams/:id`, and `POST /api/access-control/datasources/:uid/buildInRoles/:id` for adding or removing data source permissions + +If you are using the Grafana provider for Terraform to manage data source permissions, you will need to upgrade your provider to [version 2.6.0](https://registry.terraform.io/providers/grafana/grafana/2.6.0/docs) or newer to ensure that data source permission provisioning keeps working. Issue [#5880](https://github.com/grafana/grafana-enterprise/pull/5880). diff --git a/docs/sources/dashboards/_index.md b/docs/sources/dashboards/_index.md index 297c26cd7a801..0207994cd7360 100644 --- a/docs/sources/dashboards/_index.md +++ b/docs/sources/dashboards/_index.md @@ -28,46 +28,50 @@ Before you begin, ensure that you have configured a data source. See also: - [Playlist][] - [Reporting][] - [Version history][] -- [Export and import][] +- [Import][] +- [Export and share][] - [JSON model][] {{% docs/reference %}} [data source]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources" -[data source]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/datasources" +[data source]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/connect-externally-hosted/data-sources" [Reporting]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/create-reports" -[Reporting]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/create-reports" +[Reporting]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/create-reports" [Public dashboards]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/dashboard-public" -[Public dashboards]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/dashboard-public" +[Public dashboards]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/dashboard-public" [Version history]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/build-dashboards/manage-version-history" -[Version history]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/build-dashboards/manage-version-history" +[Version history]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/build-dashboards/manage-version-history" [panels]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations" -[panels]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations" +[panels]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations" [Annotations]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/build-dashboards/annotate-visualizations" -[Annotations]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/build-dashboards/annotate-visualizations" +[Annotations]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/build-dashboards/annotate-visualizations" [Create dashboard folders]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/manage-dashboards#create-a-dashboard-folder" -[Create dashboard folders]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/manage-dashboards#create-a-dashboard-folder" +[Create dashboard folders]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/manage-dashboards#create-a-dashboard-folder" [JSON model]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/build-dashboards/view-dashboard-json-model" -[JSON model]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/build-dashboards/view-dashboard-json-model" +[JSON model]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/build-dashboards/view-dashboard-json-model" -[Export and import]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/manage-dashboards#export-and-import-dashboards" -[Export and import]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/manage-dashboards#export-and-import-dashboards" +[Import]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/build-dashboards/import-dashboards" +[Import]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/build-dashboards/import-dashboards" + +[Export and share]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/share-dashboards-panels" +[Export and share]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/share-dashboards-panels" [Manage dashboards]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/manage-dashboards" -[Manage dashboards]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/manage-dashboards" +[Manage dashboards]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/manage-dashboards" [Build dashboards]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/build-dashboards" -[Build dashboards]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/build-dashboards" +[Build dashboards]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/build-dashboards" [Use dashboards]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/use-dashboards" -[Use dashboards]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/use-dashboards" +[Use dashboards]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/use-dashboards" [Playlist]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/create-manage-playlists" -[Playlist]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/create-manage-playlists" +[Playlist]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/create-manage-playlists" {{% /docs/reference %}} diff --git a/docs/sources/dashboards/assess-dashboard-usage/index.md b/docs/sources/dashboards/assess-dashboard-usage/index.md index 4c28d254240a2..4f451a66d4818 100644 --- a/docs/sources/dashboards/assess-dashboard-usage/index.md +++ b/docs/sources/dashboards/assess-dashboard-usage/index.md @@ -60,11 +60,7 @@ Dashboard insights show the following information: {{< figure src="/static/img/docs/enterprise/dashboard_insights_stats.png" max-width="400px" class="docs-image--no-shadow" alt="Stats tab" >}}{{< figure src="/static/img/docs/enterprise/dashboard_insights_users.png" max-width="400px" class="docs-image--no-shadow" alt="Users and activity tab" >}} -{{% admonition type="note" %}} - -If public dashboards are [enabled]({{< relref "../../setup-grafana/configure-grafana/#public_dashboards" >}}), you'll also see a **Public dashboards** tab in your analytics. - -{{% /admonition %}} +If public dashboards are [enabled][], you'll also see a **Public dashboards** tab in your analytics. ### Data source insights @@ -146,4 +142,7 @@ You can click the previous links to download the respective dashboard JSON, then [Grafana Enterprise]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/introduction/grafana-enterprise" [Grafana Enterprise]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/introduction/grafana-enterprise" + +[enabled]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana#public_dashboards" +[enabled]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana#public_dashboards" {{% /docs/reference %}} diff --git a/docs/sources/dashboards/build-dashboards/annotate-visualizations/index.md b/docs/sources/dashboards/build-dashboards/annotate-visualizations/index.md index ab3ec39d6d7a9..d9711ffffab5e 100644 --- a/docs/sources/dashboards/build-dashboards/annotate-visualizations/index.md +++ b/docs/sources/dashboards/build-dashboards/annotate-visualizations/index.md @@ -44,7 +44,10 @@ Annotations are supported for the following visualization types: Grafana comes with the ability to add annotation events directly from a panel using the [built-in annotation query](#built-in-query) that exists on all dashboards. Annotations that you create this way are stored in Grafana. -To add annotations directly in the panel, the built-in query must be enabled. Learn more in [Built-in query](#built-in-query) +To add annotations directly in the panel: + +- The dashboard must already be saved. +- The built-in query must be enabled. Learn more in [Built-in query](#built-in-query). ### Add an annotation @@ -83,6 +86,10 @@ Alternatively, to add an annotation, press Ctrl/Cmd and click the panel, and the In the dashboard settings, under **Annotations**, you can add new queries to fetch annotations using any data source, including the built-in data annotation data source. Annotation queries return events that can be visualized as event markers in graphs across the dashboard. +Check out the video below for a quick tutorial. + +{{< youtube id="2istdJpPj2Y" >}} + ### Add new annotation queries To add a new annotation query to a dashboard, take the following steps: diff --git a/docs/sources/dashboards/build-dashboards/best-practices/index.md b/docs/sources/dashboards/build-dashboards/best-practices/index.md index d0beecb789258..b0677c24b132a 100644 --- a/docs/sources/dashboards/build-dashboards/best-practices/index.md +++ b/docs/sources/dashboards/build-dashboards/best-practices/index.md @@ -14,7 +14,7 @@ labels: - oss menuTitle: Best practices title: Grafana dashboard best practices -weight: 100 +weight: 800 --- # Grafana dashboard best practices diff --git a/docs/sources/dashboards/build-dashboards/create-dashboard/index.md b/docs/sources/dashboards/build-dashboards/create-dashboard/index.md index 8509fe8ff0362..03e89a27c447c 100644 --- a/docs/sources/dashboards/build-dashboards/create-dashboard/index.md +++ b/docs/sources/dashboards/build-dashboards/create-dashboard/index.md @@ -95,6 +95,20 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee When you add additional panels to the dashboard, you're taken straight to the **Edit panel** view. +## Copy an existing dashboard + +To copy an existing dashboard, follow these steps: + +1. Click **Dashboards** in the primary menu. +1. Open the dashboard to be copied. +1. Click **Settings** (gear icon) in the top right of the dashboard. +1. Click **Save as** in the top-right corner of the dashboard. +1. (Optional) Specify the name, folder, description, and whether or not to copy the original dashboard tags for the copied dashboard. + + By default, the copied dashboard has the same name as the original dashboard with the word "Copy" appended and is located in the same folder. + +1. Click **Save**. + ## Configure repeating rows You can configure Grafana to dynamically add panels or rows to a dashboard based on the value of a variable. Variables dynamically change your queries across all rows in a dashboard. For more information about repeating panels, refer to [Configure repeating panels][]. diff --git a/docs/sources/dashboards/build-dashboards/import-dashboards/index.md b/docs/sources/dashboards/build-dashboards/import-dashboards/index.md new file mode 100644 index 0000000000000..4f4003ad70c0a --- /dev/null +++ b/docs/sources/dashboards/build-dashboards/import-dashboards/index.md @@ -0,0 +1,60 @@ +--- +aliases: + - ../../reference/export_import/ # /docs/grafana/<GRAFANA_VERSION>/reference/export_import/ + - ../export-import/ # /docs/grafana/<GRAFANA_VERSION>/dashboards/export-import/ +canonical: https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/import-dashboards/ +keywords: + - grafana + - dashboard + - import +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Import dashboards +title: Import dashboards +description: Learn how to import dashboards and about Grafana's preconfigured dashboards +weight: 5 +--- + +# Import dashboards + +You can import preconfigured dashboards into your Grafana instance or Cloud stack using the UI or the [HTTP API][]. + +## Import a dashboard + +To import a dashboard, follow these steps: + +1. Click **Dashboards** in the primary menu. +1. Click **New** and select **Import** in the drop-down menu. +1. Perform one of the following steps: + + - Upload a dashboard JSON file. + - Paste a [Grafana.com dashboard](#discover-dashboards-on-grafanacom) URL or ID into the field provided. + - Paste dashboard JSON text directly into the text area. + +1. (Optional) Change the dashboard name, folder, or UID, and specify metric prefixes, if the dashboard uses any. +1. Select a data source, if required. +1. Click **Import**. +1. Save the dashboard. + +## Discover dashboards on grafana.com + +The [Dashboards page](https://grafana.com/grafana/dashboards/) on grafana.com provides you with dashboards for common server applications. Browse our library of official and community-built dashboards and import them to quickly get up and running. + +{{< figure src="/media/docs/grafana/dashboards/screenshot-gcom-dashboards.png" alt="Preconfigured dashboards on grafana.com">}} + +You can also add to this library by exporting one of your own dashboards. For more information, refer to [Share dashboards and panels][]. + +## More examples + +Your Grafana Cloud stack comes with several default dashboards in the **Grafana Cloud** folder in **Dashboards**. If you're running your own installation of Grafana, you can find more example dashboards in the `public/dashboards/` directory. + +{{% docs/reference %}} +[HTTP API]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/developers/http_api" +[HTTP API]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/developer-resources/api-reference/http-api" + +[Share dashboards and panels]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/share-dashboards-panels" +[Share dashboards and panels]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/share-dashboards-panels" +{{% /docs/reference %}} diff --git a/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md b/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md index 52e4605bff931..ba83b0e073c31 100644 --- a/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md +++ b/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md @@ -94,7 +94,7 @@ Add a link to a URL at the top of your current dashboard. You can link to any av - `from` - Defines the lower limit of the time range, specified in ms epoch. - `to` - Defines the upper limit of the time range, specified in ms epoch. - `time` and `time.window` - Define a time range from `time-time.window/2` to `time+time.window/2`. Both params should be specified in ms. For example `?time=1500000000000&time.window=10000` will result in 10s time range from 1499999995000 to 1500000005000. - - **Variable values** – Select this option to include template variables currently used as query parameters in the link. When the user clicks the link, any matching templates in the linked dashboard are set to the values from the link. Here is the variable format: `https://${you-domain}/path/to/your/dashboard?var-${template-varable1}=value1&var-{template-variable2}=value2` **Example:** https://play.grafana.org/d/000000074/alerting?var-app=backend&var-server=backend_01&var-server=backend_03&var-interval=1h + - **Variable values** – Select this option to include template variables currently used as query parameters in the link. When the user clicks the link, any matching templates in the linked dashboard are set to the values from the link. Here is the variable format: `https://${you-domain}/path/to/your/dashboard?var-${template-variable1}=value1&var-{template-variable2}=value2` **Example:** https://play.grafana.org/d/000000074/alerting?var-app=backend&var-server=backend_01&var-server=backend_03&var-interval=1h - **Open in new tab** – Select this option if you want the dashboard link to open in a new tab or window. 1. Click **Add**. diff --git a/docs/sources/dashboards/build-dashboards/manage-library-panels/index.md b/docs/sources/dashboards/build-dashboards/manage-library-panels/index.md index 0e230570bed2c..e948a019c76fb 100644 --- a/docs/sources/dashboards/build-dashboards/manage-library-panels/index.md +++ b/docs/sources/dashboards/build-dashboards/manage-library-panels/index.md @@ -25,6 +25,10 @@ A library panel is a reusable panel that you can use in any dashboard. When you You can save a library panel in a folder alongside saved dashboards. +## Role-based access control + +You can control permissions for library panels using [role-based access control (RBAC)][rbac]. RBAC provides a standardized way of granting, changing, and revoking access when it comes to viewing and modifying Grafana resources, such as dashboards, reports, and administrative settings. + ## Create a library panel When you create a library panel, the panel on the source dashboard is converted to a library panel as well. You need to save the original dashboard once a panel is converted. @@ -91,3 +95,8 @@ Delete a library panel when you no longer need it. 1. Click **Dashboards** in the left-side menu. 1. Click **Library panels**. 1. Click the delete icon next to the library panel name. + +{{% docs/reference %}} +[rbac]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control" +[rbac]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control" +{{% /docs/reference %}} diff --git a/docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md b/docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md index 7572f895af31e..affd4f6d850ec 100644 --- a/docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md +++ b/docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md @@ -128,6 +128,19 @@ The grid has a negative gravity that moves panels up if there is empty space abo "enable": true, "notice": false, "now": true, + "hidden": false, + "nowDelay": "", + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ], "refresh_intervals": [ "5s", "10s", @@ -147,15 +160,18 @@ The grid has a negative gravity that moves panels up if there is empty space abo Usage of the fields is explained below: -| Name | Usage | -| --------------------- | -------------------------------------- | -| **collapse** | whether timepicker is collapsed or not | -| **enable** | whether timepicker is enabled or not | -| **notice** | | -| **now** | | -| **refresh_intervals** | | -| **status** | | -| **type** | | +| Name | Usage | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| **collapse** | whether timepicker is collapsed or not | +| **enable** | whether timepicker is enabled or not | +| **notice** | | +| **now** | | +| **hidden** | whether timepicker is hidden or not | +| **nowDelay** | override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. | +| **time_options** | options available in the time picker dropdown | +| **refresh_intervals** | interval options available in the refresh picker dropdown | +| **status** | | +| **type** | | ### templating diff --git a/docs/sources/dashboards/create-reports/index.md b/docs/sources/dashboards/create-reports/index.md index 73e8aad43de98..aebdae63d01dc 100644 --- a/docs/sources/dashboards/create-reports/index.md +++ b/docs/sources/dashboards/create-reports/index.md @@ -107,17 +107,21 @@ You can include dynamic dashboards with panels or rows, set to repeat by a varia By default, reports use the saved time range of the dashboard. You can change the time range of the report by: -- Saving a modified time range to the dashboard. +- Saving a modified time range to the dashboard. Changing the dashboard time range without saving it doesn't change the time zone of the report. - Setting a time range via the **Time range** field in the report form. If specified, the custom time range overrides the time range from the report's dashboard. -The page header of the report displays the time range for the dashboard's data queries. Dashboards set to use the browser's time zone use the time zone on the Grafana server. +The page header of the report displays the time range for the dashboard's data queries. + +#### Report time zones + +Reports use the time zone of the dashboard from which they’re generated. You can control the time zone for your reports by setting the dashboard to a specific time zone. Note that this affects the display of the dashboard for all users. + +If a dashboard has the **Browser Time** setting, the reports generated from that dashboard use the time zone of the Grafana server. As a result, this time zone might not match the time zone of users creating or receiving the report. If the time zone is set differently between your Grafana server and its remote image renderer, then the time ranges in the report might be different between the page header and the time axes in the panels. To avoid this, set the time zone to UTC for dashboards when using a remote renderer. Each dashboard's time zone setting is visible in the [time range controls][]. ### Layout and orientation -> We're actively developing new report layout options. [Contact us](https://grafana.com/contact?about=grafana-enterprise&topic=design-process&value=reporting) to get involved in the design process. - | Layout | Orientation | Support | Description | Preview | | ------ | ----------- | ------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Simple | Portrait | v6.4+ | Generates an A4 page in portrait mode with three panels per page. | {{< figure src="/static/img/docs/enterprise/reports_portrait_preview.png" max-width="500px" max-height="500px" class="docs-image--no-shadow" alt="Simple layout in portrait" >}} | @@ -137,6 +141,19 @@ When the CSV file is generated, it is temporarily written to the `csv` folder in A background job runs every 10 minutes and removes temporary CSV files. You can configure how long a CSV file should be stored before being removed by configuring the [temp-data-lifetime][] setting. This setting also affects how long a renderer PNG file should be stored. +### Table data in PDF + +{{% admonition type="note" %}} +Available in public preview (`pdfTables` feature toggle) in [Grafana Enterprise][] v10.3+ with the [Grafana image renderer plugin](/grafana/plugins/grafana-image-renderer) v3.0+, and [Grafana Cloud](/docs/grafana-cloud/). +{{% /admonition %}} + +When there's more data in your table visualizations than can be shown in the dashboard PDF, you can select one of these two options to access all table visualization data as PDF in your reports: + +- **Include table data as PDF appendix** - Adds an appendix to the main dashboard PDF. +- **Attach a separate PDF of table data** - Generates a separate PDF file. + +This feature relies on the same plugin that supports the [image rendering][] features. + ### Scheduling > **Note:** Available in [Grafana Enterprise][] version 8.0 and later, and [Grafana Cloud](/docs/grafana-cloud/). @@ -193,7 +210,7 @@ You can generate and save PDF files of any dashboard. > **Note:** Available in [Grafana Enterprise][] version 6.7 and later, and [Grafana Cloud](/docs/grafana-cloud/). -1. In the dashboard that you want to export as PDF, click the **Share dashboard** icon. +1. In the dashboard that you want to export as PDF, click the **Share** button. 1. On the PDF tab, select a layout option for the exported dashboard: **Portrait** or **Landscape**. 1. Click **Save as PDF** to render the dashboard as a PDF file. @@ -267,8 +284,8 @@ filters = report:debug ``` {{% docs/reference %}} -[time range controls]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/manage-dashboards" -[time range controls]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/manage-dashboards" +[time range controls]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/use-dashboards#set-dashboard-time-range" +[time range controls]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/use-dashboards#set-dashboard-time-range" [image rendering]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/image-rendering" [image rendering]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/image-rendering" @@ -289,10 +306,10 @@ filters = report:debug [SMTP]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana#smtp" [Repeat panels or rows]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/configure-panel-options#configure-repeating-rows-or-panels" -[Repeat panels or rows]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/configure-panel-options#configure-repeating-rows-or-panels" +[Repeat panels or rows]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/configure-panel-options#configure-repeating-rows-or-panels" [Templates and variables]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/variables" -[Templates and variables]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/variables" +[Templates and variables]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/variables" [temp-data-lifetime]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana#temp-data-lifetime" [temp-data-lifetime]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana#temp-data-lifetime" diff --git a/docs/sources/dashboards/manage-dashboards/index.md b/docs/sources/dashboards/manage-dashboards/index.md index 2d764588284fa..40929b1640887 100644 --- a/docs/sources/dashboards/manage-dashboards/index.md +++ b/docs/sources/dashboards/manage-dashboards/index.md @@ -3,23 +3,15 @@ aliases: - ../features/dashboard/dashboards/ - ../panels/working-with-panels/organize-dashboard/ - ../reference/dashboard_folders/ - - ../reference/export_import/ - - ../reference/timerange/ - - ../troubleshooting/troubleshoot-dashboards/ - dashboard-folders/ - dashboard-manage/ - - export-import/ +canonical: https://grafana.com/docs/grafana/latest/dashboards/manage-dashboards/ keywords: - grafana - dashboard - dashboard folders - folder - folders - - import - - export - - troubleshoot - - time range - - scripting labels: products: - cloud @@ -27,34 +19,37 @@ labels: - oss menuTitle: Manage dashboards title: Manage dashboards -description: Learn about dashboard folders, generative AI features for dashboards, and troubleshooting +description: Learn about dashboard management and generative AI features for dashboards weight: 8 --- # Manage dashboards -This topic includes techniques you can use to manage your Grafana dashboards, including: +On the **Dashboards** page, you can perform dashboard management tasks such as: -- [Creating and managing dashboard folders](#create-and-manage-dashboard-folders) -- [Exporting and importing dashboards](#export-and-import-dashboards) -- [Organizing dashboards](#organize-a-dashboard) -- [Troubleshooting dashboards](#troubleshoot-dashboards) +- [Browsing](#browse-dashboards) and [creating](#create-a-dashboard-folder) dashboard folders +- [Managing folder permissions](#folder-permissions) +- [Adding generative AI features to dashboards](#set-up-generative-ai-features-for-dashboards) -For more information about creating dashboards, refer to [Add and organize panels](../add-organize-panels). +For more information about creating dashboards, refer to [Build dashboards][]. ## Browse dashboards On the **Dashboards** page, you can browse and manage folders and dashboards. This includes the options to: -- Create folders and dashboards +- Create folders and dashboards. - Move dashboards between folders. - Delete multiple dashboards and folders. - Navigate to a folder. - Manage folder permissions. For more information, refer to [Dashboard permissions][]. -{{% admonition type="note" %}} -As of Grafana 10.2, there is no longer a special **General** folder. Dashboards without a folder are now shown at the top level alongside folders. -{{% /admonition %}} +The page lists all the dashboards to which you have access, grouped into folders. Dashboards without a folder are displayed at the top level alongside folders. + +### Shared with me + +The **Shared with me** section displays folders and dashboards that are directly shared with you. These folders and dashboards aren't shown in the main list because you don't have access to one or more of their parent folders. + +If you have permission to view all folders, you won't see a **Shared with me**. ## Create a dashboard folder @@ -64,11 +59,17 @@ Folders help you organize and group dashboards, which is useful when you have ma **To create a dashboard folder:** -1. Click **Dashboards** in the main menu. -1. On the **Dashboards** page, click **New** and select **New folder** in the drop-down. +1. Click **Dashboards** in the primary menu. +1. Do one of the following: + + - On the **Dashboards** page, click **New** and select **New folder** in the drop-down. + - Click an existing folder and on the folder’s page, click **New** and select **New folder** in the drop-down. + 1. Enter a unique name and click **Create**. -When you save a dashboard, you can either select a folder for the dashboard to be saved in or create a new folder. +When you nest folders, you can do so up to four levels deep. + +When you save a dashboard, you can optionally select a folder to save the dashboard in. {{% admonition type="note" %}} Alerts can't be placed in folders with slashes (\ /) in the name. If you wish to place alerts in the folder, don't use slashes in the folder name. @@ -76,9 +77,9 @@ Alerts can't be placed in folders with slashes (\ /) in the name. If you wish to **To edit the name of a folder:** -1. Click **Dashboards** in the main menu. +1. Click **Dashboards** in the primary menu. 1. Navigate to the folder by selecting it in the list, or searching for it. -1. Click the pencil icon labelled **Edit title** in the header and update the name of the folder. +1. Click the **Edit title** icon (pencil) in the header and update the name of the folder. The new folder name is automatically saved. @@ -88,7 +89,7 @@ You can assign permissions to a folder. Dashboards in the folder inherit any per **To modify permissions for a folder:** -1. Click **Dashboards** in the main menu. +1. Click **Dashboards** in the primary menu. 1. Navigate to the folder by selecting it in the list, or searching for it. 1. On the folder's page, click **Folder actions** and select **Manage permissions** in the drop-down. 1. Update the permissions as desired. @@ -97,49 +98,6 @@ Changes are saved automatically. For more information about dashboard permissions, refer to [Dashboard permissions][]. -## Export and import dashboards - -You can use the Grafana UI or the [HTTP API][] to export and import dashboards. - -### Export a dashboard - -The dashboard export action creates a Grafana JSON file that contains everything you need, including layout, variables, styles, data sources, queries, and so on, so that you can later import the dashboard. - -1. Click **Dashboards** in the main menu. -1. Open the dashboard you want to export. -1. Click the **Share** icon in the top navigation bar. -1. Click **Export**. - - If you're exporting the dashboard to use in another instance, with different data source UIDs, enable the **Export for sharing externally** switch. - -1. Click **Save to file**. - -Grafana downloads a JSON file to your local machine. - -#### Make a dashboard portable - -If you want to export a dashboard for others to use, you can add template variables for things like a metric prefix (use a constant variable) and server name. - -A template variable of the type `Constant` is automatically hidden in the dashboard, and is also added as a required input when the dashboard is imported. - -### Import a dashboard - -1. Click **Dashboards** in the left-side menu. -1. Click **New** and select **Import** in the dropdown menu. -1. Perform one of the following steps: - - - Upload a dashboard JSON file - - Paste a [Grafana.com](https://grafana.com) dashboard URL - - Paste dashboard JSON text directly into the text area - -The import process enables you to change the name of the dashboard, pick the data source you want the dashboard to use, and specify any metric prefixes (if the dashboard uses any). - -### Discover dashboards on grafana.com - -Find dashboards for common server applications at [Grafana.com/dashboards](https://grafana.com/dashboards). - -{{< figure src="/media/docs/grafana/dashboards/screenshot-gcom-dashboards.png" alt="Preconfigured dashboards on grafana.com">}} - ## Set up generative AI features for dashboards {{< docs/public-preview product="Generative AI in dashboards" featureFlag="`dashgpt`" >}} @@ -153,56 +111,13 @@ To access these features, enable the `dashgpt` feature toggle. Then install and When enabled, the **✨ Auto generate** option displays next to the **Title** and **Description** fields in your panels and dashboards, or when you press the **Save** button. -## Troubleshoot dashboards - -This section provides information to help you solve common dashboard problems. - -### Dashboard is slow - -- Are you trying to render dozens (or hundreds or thousands) of time-series on a graph? This can cause the browser to lag. Try using functions like `highestMax` (in Graphite) to reduce the returned series. -- Sometimes the series names can be very large. This causes larger response sizes. Try using `alias` to reduce the size of the returned series names. -- Are you querying many time-series or for a long range of time? Both of these conditions can cause Grafana or your data source to pull in a lot of data, which may slow it down. -- It could be high load on your network infrastructure. If the slowness isn't consistent, this may be the problem. - -### Dashboard refresh rate issues - -By default, Grafana queries your data source every 30 seconds. Setting a low refresh rate on your dashboards puts unnecessary stress on the backend. In many cases, querying this frequently isn't necessary because the data isn't being sent to the system such that changes would be seen. - -We recommend the following: - -- Only enable auto-refreshing on dashboards, panels, or variables unless if necessary. Users can refresh their browser manually, or you can set the refresh rate for a time period that makes sense (every ten minutes, every hour, and so on). -- If it's required, then set the refresh rate to once a minute. Users can always refresh the dashboard manually. -- If your dashboard has a longer time period (such as a week), then you really don't need automated refreshing. - -#### Handling or rendering null data is wrong or confusing - -Some applications publish data intermittently; for example, they only post a metric when an event occurs. By default, Grafana graphs connect lines between the data points. This can be very deceiving. - -In the picture below we've enabled: - -- Points and 3-point radius to highlight where data points are actually present. -- **Connect null values\* is set to **Always\*\*. - -{{< figure src="/static/img/docs/troubleshooting/grafana_null_connected.png" max-width="1200px" alt="Graph with null values connected" >}} - -In this graph, we set graph to show bars instead of lines and set the **No value** under **Standard options** to **0**. There is a very big difference in the visuals. - -{{< figure src="/static/img/docs/troubleshooting/grafana_null_zero.png" max-width="1200px" alt="Graph with null values not connected" >}} - -### More examples - -You can find more examples in `public/dashboards/` directory of your Grafana installation. - {{% docs/reference %}} [Dashboard permissions]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/roles-and-permissions#dashboard-permissions" [Dashboard permissions]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/roles-and-permissions#dashboard-permissions" -[panels]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations" -[panels]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations" - -[HTTP API]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/developers/http_api" -[HTTP API]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/developer-resources/api-reference/http-api" +[Grafana LLM plugin documentation]: "/docs/grafana/ -> /docs/grafana-cloud/alerting-and-irm/machine-learning/configure/llm-plugin" +[Grafana LLM plugin documentation]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/machine-learning/configure/llm-plugin" -[Grafana LLM plugin documentation]: "/docs/grafana/ -> /docs/grafana-cloud/alerting-and-irm/machine-learning/llm-plugin" -[Grafana LLM plugin documentation]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/machine-learning/llm-plugin" +[Build dashboards]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/build-dashboards" +[Build dashboards]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/build-dashboards" {{% /docs/reference %}} diff --git a/docs/sources/dashboards/share-dashboards-panels/index.md b/docs/sources/dashboards/share-dashboards-panels/index.md index aca0c34b03cf5..b5a90ba97f50c 100644 --- a/docs/sources/dashboards/share-dashboards-panels/index.md +++ b/docs/sources/dashboards/share-dashboards-panels/index.md @@ -64,7 +64,7 @@ If you change a dashboard, ensure that you save the changes before sharing. 1. Click **Dashboards** in the left-side menu. 1. Click the dashboard you want to share. -1. Click the share icon at the top of the screen. +1. Click the **Share** button at the top right of the screen. The share dialog opens and shows the Link tab. @@ -85,7 +85,7 @@ A dashboard snapshot shares an interactive dashboard publicly. Grafana strips se You can publish snapshots to your local instance or to [snapshots.raintank.io](http://snapshots.raintank.io). The latter is a free service provided by Grafana Labs that enables you to publish dashboard snapshots to an external Grafana instance. Anyone with the link can view it. You can set an expiration time if you want the snapshot removed after a certain time period. 1. Click the **Snapshot** tab. -1. Click **Publish to snapshots.raintank.io** or **Local Snapshot**. +1. Click **Publish to snapshots.raintank.io** or **Publish Snapshot**. Grafana generates a link of the snapshot. @@ -93,9 +93,26 @@ You can publish snapshots to your local instance or to [snapshots.raintank.io](h If you created a snapshot by mistake, click **Delete snapshot** to remove the snapshot from your Grafana instance. -### Dashboard export +### Export a dashboard as JSON -Grafana dashboards can easily be exported and imported. For more information, refer to [Export and import dashboards][]. +The dashboard export action creates a Grafana JSON file that contains everything you need, including layout, variables, styles, data sources, queries, and so on, so that you can later import the dashboard. + +1. Click **Dashboards** in the main menu. +1. Open the dashboard you want to export. +1. Click the **Share** icon in the top navigation bar. +1. Click **Export**. + + If you're exporting the dashboard to use in another instance, with different data source UIDs, enable the **Export for sharing externally** switch. + +1. Click **Save to file**. + +Grafana downloads a JSON file to your local machine. + +#### Make a dashboard portable + +If you want to export a dashboard for others to use, you can add template variables for things like a metric prefix (use a constant variable) and server name. + +A template variable of the type `Constant` is automatically hidden in the dashboard, and is also added as a required input when the dashboard is imported. ## Export dashboard as PDF @@ -105,7 +122,7 @@ You can generate and save PDF files of any dashboard. 1. Click **Dashboards** in the left-side menu. 1. Click the dashboard you want to share. -1. Click the share icon at the top of the screen. +1. Click the **Share** button at the top right of the screen. 1. On the PDF tab, select a layout option for the exported dashboard: **Portrait** or **Landscape**. 1. Click **Save as PDF** to render the dashboard as a PDF file. @@ -151,10 +168,16 @@ https://play.grafana.org/d/000000012/grafana-play-home?orgId=1&from=156871968017 A panel snapshot shares an interactive panel publicly. Grafana strips sensitive data leaving only the visible metric data and series names embedded in the dashboard. Panel snapshots can be accessed by anyone with the link. -You can publish snapshots to your local instance or to [snapshots.raintank.io](http://snapshots.raintank.io). The latter is a free service provided by [Grafana Labs](https://grafana.com), that enables you to publish dashboard snapshots to an external Grafana instance. You can optionally set an expiration time if you want the snapshot to be removed after a certain time period. +You can publish snapshots to your local instance or to [snapshots.raintank.io](http://snapshots.raintank.io). The latter is a free service provided by [Grafana Labs](https://grafana.com), that enables you to publish dashboard snapshots to an external Grafana instance. + +{{< admonition type="note" >}} +As of Grafana 11, the option to publish to [snapshots.raintank.io](http://snapshots.raintank.io) is no longer available for Grafana Cloud. +{{< /admonition >}} + +You can optionally set an expiration time if you want the snapshot to be removed after a certain time period. 1. In the **Share Panel** dialog, click **Snapshot** to go to the tab. -1. Click **Publish to snapshots.raintank.io** or **Local Snapshot**. +1. Click **Publish to snapshots.raintank.io** or **Publish Snapshot**. Grafana generates the link of the snapshot. @@ -192,9 +215,6 @@ To create a library panel from the **Share Panel** dialog: 1. Save the dashboard. {{% docs/reference %}} -[Export and import dashboards]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/manage-dashboards#export-and-import-dashboards" -[Export and import dashboards]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/manage-dashboards#export-and-import-dashboards" - [Grafana Enterprise]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/introduction/grafana-enterprise" [Grafana Enterprise]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/introduction/grafana-enterprise" diff --git a/docs/sources/dashboards/troubleshoot-dashboards/index.md b/docs/sources/dashboards/troubleshoot-dashboards/index.md new file mode 100644 index 0000000000000..817f035fb2d7d --- /dev/null +++ b/docs/sources/dashboards/troubleshoot-dashboards/index.md @@ -0,0 +1,58 @@ +--- +aliases: + - ../troubleshooting/troubleshoot-dashboards/ + - ../reference/timerange/ +canonical: https://grafana.com/docs/grafana/latest/dashboards/troubleshoot-dashboards/ +keywords: + - grafana + - dashboard + - troubleshoot + - time range +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Troubleshoot dashboards +title: Troubleshoot dashboards +description: Learn how to troubleshoot common dashboard issues +weight: 300 +--- + +# Troubleshoot dashboards + +Use the following strategies to help you troubleshoot common dashboard problems. + +## Dashboard is slow + +- Are you trying to render dozens (or hundreds or thousands) of time series on a graph? This can cause the browser to lag. Try using functions like `highestMax` (in Graphite) to reduce the number of returned series. +- Sometimes series names can be very large. This causes larger response sizes. Try using `alias` to reduce the size of the returned series names. +- Are you querying many time series or a long time range? Both of these conditions can cause Grafana or your data source to pull in a lot of data, which may slow the dashboard down. Try reducing one or both of these. +- There could be high load on your network infrastructure. If the slowness isn't consistent, this may be the problem. + +## Dashboard refresh rate issues + +By default, Grafana queries your data source every 30 seconds. However, setting a low refresh rate on your dashboards puts unnecessary stress on the backend. In many cases, querying this frequently isn't necessary because the data source isn't sending data often enough for there to be changes every 30 seconds. + +We recommend the following: + +- Only enable auto-refreshing on dashboards, panels, or variables if necessary. Users can refresh their browser manually. +- If you require auto-refreshing, then set the refresh rate to a longer time period that makes sense, such as once a minute, every 10 minutes, or every hour. +- Check the time range of your dashboard. If your dashboard has a longer time range, such as a week, then you really don't need automated refreshing and you should disable it. + +## Handling or rendering null data is wrong or confusing + +Some applications publish data intermittently; for example, they only post a metric when an event occurs. By default, Grafana graphs connect lines between the data points, but this can be deceptive. + +The graph in the following image has: + +- Points and 3-point radius enabled to highlight where data points are actually present. +- **Connect null values** set to **Always**. + +{{< figure src="/static/img/docs/troubleshooting/grafana_null_connected.png" max-width="1200px" alt="Graph with null values connected" >}} + +The graph in this next image shows bars instead of lines and has the **No value** option under **Standard options** set to **0**. + +{{< figure src="/static/img/docs/troubleshooting/grafana_null_zero.png" max-width="1200px" alt="Graph with null values not connected" >}} + +As you can see, there's a significant difference in the visualizations. diff --git a/docs/sources/dashboards/use-dashboards/index.md b/docs/sources/dashboards/use-dashboards/index.md index 0d47cce7a5e1b..4f1339db9365e 100644 --- a/docs/sources/dashboards/use-dashboards/index.md +++ b/docs/sources/dashboards/use-dashboards/index.md @@ -61,7 +61,7 @@ The following image and descriptions highlight all dashboard features. ## Keyboard shortcuts -Grafana has a number of keyboard shortcuts available. Press `?` or `h` on your keyboard to display all keyboard shortcuts available in your version of Grafana. +Grafana has a number of keyboard shortcuts available. Press `?` or `Ctrl+h` on your keyboard to display all keyboard shortcuts available in your version of Grafana. - `Ctrl+S`: Saves the current dashboard. - `f`: Opens the dashboard finder / search. @@ -138,7 +138,7 @@ Hover your cursor over the field to see the exact time stamps in the range and t Click the current time range to change it. You can change the current time using a _relative time range_, such as the last 15 minutes, or an _absolute time range_, such as `2020-05-14 00:00:00 to 2020-05-15 23:59:59`. -<img class="no-shadow" src="/media/docs/grafana/dashboards/screenshot-change-current-time-range.png" max-width="900px"> +<img class="no-shadow" src="/media/docs/grafana/dashboards/screenshot-change-current-time-range-10.3.png" max-width="900px"> #### Relative time range @@ -190,6 +190,15 @@ This is equivalent to the **Today so far** time range preset, but it starts at 8 Using a semi-relative time range, as time progresses, your dashboard will automatically and progressively zoom out to show more history and fewer details. At the same rate, as high data resolution decreases, historical trends over the entire time period will become more clear. +#### Copy and paste time range + +You can copy and paste the time range from a dashboard to **Explore** and vice versa, or from one dashboard to another. +Click the **Copy time range to clipboard** icon to copy the current time range to the clipboard. Then paste the time range into **Explore** or another dashboard. + +<img class="no-shadow" src="/media/docs/grafana/dashboards/screenshot-copy-paste-time-range.png" max-width="900"> + +You can also copy and paste a time range using the keyboard shortcuts `t+c` and `t+v` respectively. + #### Zoom out (Cmd+Z or Ctrl+Z) Click the **Zoom out** icon to view a larger time range in the dashboard or panel visualization. diff --git a/docs/sources/dashboards/variables/_index.md b/docs/sources/dashboards/variables/_index.md index f6390264d5eca..8b6c4a397f639 100644 --- a/docs/sources/dashboards/variables/_index.md +++ b/docs/sources/dashboards/variables/_index.md @@ -15,6 +15,8 @@ weight: 130 # Variables +{{< youtube id="mMUJ3iwIYwc" >}} + The following topics describe how to add and manage variables in your dashboards: {{< section >}} diff --git a/docs/sources/datasources/_index.md b/docs/sources/datasources/_index.md index d5332117bea69..b738aa80a0c09 100644 --- a/docs/sources/datasources/_index.md +++ b/docs/sources/datasources/_index.md @@ -18,6 +18,8 @@ Grafana comes with built-in support for many _data sources_. If you need other data sources, you can also install one of the many data source plugins. If the plugin you need doesn't exist, you can develop a custom plugin. +{{< youtube id="cqHO0oYW6Ic" >}} + Each data source comes with a _query editor_, which formulates custom queries according to the source's structure. After you add and configure a data source, you can use it as an input for many operations, including: @@ -30,14 +32,29 @@ This documentation describes how to manage data sources in general, and how to configure or query the built-in data sources. For other data sources, refer to the list of [datasource plugins](/grafana/plugins/). -To develop a custom plugin, refer to [Build a plugin](/developers/plugin-tools). +To develop a custom plugin, refer to [Create a data source plugin](#create-a-data-source-plugin). ## Manage data sources Only users with the [organization administrator role][organization-roles] can add or remove data sources. To access data source management tools in Grafana as an administrator, navigate to **Configuration > Data Sources** in the Grafana sidebar. -For details on data source management, including instructions on how to add data sources and configure user permissions for queries, refer to the [administration documentation][data-source-management]. +For details on data source management, including instructions on how configure user permissions for queries, refer to the [administration documentation][data-source-management]. + +## Add a data source + +Before you can create your first dashboard, you need to add your data source. + +{{% admonition type="note" %}} +Only users with the organization admin role can add data sources. +{{% /admonition %}} + +**To add a data source:** + +1. Click **Connections** in the left-side menu. +1. Enter the name of a specific data source in the search dialog. You can filter by **Data source** to only see data sources. +1. Click the data source you want to add. +1. Configure the data source following instructions specific to that data source. ## Use query editors @@ -53,7 +70,7 @@ For example, this video demonstrates the visual Prometheus query builder: {{< vimeo 720004179 >}} -For general information about querying in Grafana, and common options and user interface elements across all query editors, refer to [Query and transform data][query-transform-data] . +For general information about querying in Grafana, and common options and user interface elements across all query editors, refer to [Query and transform data][query-transform-data]. ## Special data sources @@ -66,6 +83,7 @@ Grafana includes three special data sources: - You can't change an existing query to use the **Mixed** data source. - Grafana Play example: [Mixed data sources](https://play.grafana.org/d/000000100/mixed-datasources?orgId=1) - **Dashboard:** A data source that uses the result set from another panel in the same dashboard. The dashboard data source can use data either directly from the selected panel or from annotations attached to the selected panel. + - Grafana Play example: [Panel as Data source](https://play.grafana.org/d/ede8zps8ndb0gc/panel-as-data-source?orgId=1) ## Built-in core data sources @@ -89,6 +107,24 @@ These built-in core data sources are also included in the Grafana documentation: - [Testdata]({{< relref "./testdata" >}}) - [Zipkin]({{< relref "./zipkin" >}}) +## Add additional data source plugins + +You can add additional data sources as plugins (that are not available in core Grafana), which you can install or create yourself. + +### Find data source plugins in the plugin catalog + +To view available data source plugins, go to the [plugin catalog](/grafana/plugins/?type=datasource) and select the "Data sources" filter. +For details about the plugin catalog, refer to [Plugin management][Plugin-management]. + +You can further filter the plugin catalog's results for data sources provided by the Grafana community, Grafana Labs, and partners. +If you use [Grafana Enterprise][Grafana-Enterprise], you can also filter by Enterprise-supported plugins. + +For more documentation on a specific data source plugin's features, including its query language and editor, refer to its plugin catalog page. + +### Create a data source plugin + +To build your own data source plugin, refer to the [Build a data source plugin](/developers/plugin-tools/tutorials/build-a-data-source-plugin) tutorial and [Plugin tools](/developers/plugin-tools). + {{% docs/reference %}} [alerts]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting" [alerts]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/alerting" @@ -107,4 +143,10 @@ These built-in core data sources are also included in the Grafana documentation: [query-transform-data]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/query-transform-data" [query-transform-data]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/query-transform-data" + +[Plugin-management]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/plugin-management" +[Plugin-management]: "/docs/grafana-cloud -> /docs/grafana/<GRAFANA VERSION>/administration/plugin-management" + +[Grafana-Enterprise]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/introduction/grafana-enterprise" + {{% /docs/reference %}} diff --git a/docs/sources/datasources/aws-cloudwatch/aws-authentication/index.md b/docs/sources/datasources/aws-cloudwatch/aws-authentication/index.md index a347e92240e86..78d0021b2c67c 100644 --- a/docs/sources/datasources/aws-cloudwatch/aws-authentication/index.md +++ b/docs/sources/datasources/aws-cloudwatch/aws-authentication/index.md @@ -47,8 +47,8 @@ Open source Grafana enables the `AWS SDK Default`, `Credentials file`, and `Acce While `AWS SDK Default` will also find the shared credentials file, this option allows you to specify which profile to use without using environment variables. This option doesn't have any implicit fallbacks to other credential providers, and it fails if the credentials provided from the file aren't correct. - `Access and secret key` corresponds to the [StaticProvider](https://docs.aws.amazon.com/sdk-for-go/api/aws/credentials/#StaticProvider) and uses the given access key ID and secret key to authenticate. - This method doesn't have any fallbacks, and will fail if the provided key pair doesn't work. -- `Grafana Assume Role` - With this auth provider option, Grafana Cloud users create an AWS IAM role that has a trust relationship with Grafana's AWS account. Grafana then uses [STS](https://docs.aws.amazon.com/STS/latest/APIReference/welcome.html) to generate temporary credentials on its behalf. Users with this option enabled no longer need to generate secret and access keys for users. + This method doesn't have any fallbacks, and will fail if the provided key pair doesn't work. This is the primary authentication method for Grafana Cloud. +- `Grafana Assume Role` - With this auth provider option, Grafana Cloud users create an AWS IAM role that has a trust relationship with Grafana's AWS account. Grafana then uses [STS](https://docs.aws.amazon.com/STS/latest/APIReference/welcome.html) to generate temporary credentials on its behalf. Users with this option enabled no longer need to generate secret and access keys for users. Refer to [Use Grafana Assume Role](/docs/grafana/latest/datasources/aws-cloudwatch/aws-authentication/#use-grafana-assume-role) for further detail. - `Workspace IAM role` corresponds to the [EC2RoleProvider](https://docs.aws.amazon.com/sdk-for-go/api/aws/credentials/ec2rolecreds/#EC2RoleProvider). The EC2RoleProvider pulls credentials for a role attached to the EC2 instance that Grafana runs on. You can also achieve this by using the authentication method AWS SDK Default, but this option is different as it doesn't have any fallbacks. @@ -159,7 +159,7 @@ To use the Grafana Assume Role: 1. Put in a request to Customer Support to enable`awsDatasourcesTempCredentials`. 2. Once the feature is enabled, create a new CloudWatch data source (or update an existing one) and select **Grafana Assume Role** as an authentication provider. 3. In the AWS Console, create a new IAM role, and under **Trusted entity type**, select **Another AWS account** as the trusted Entity. -4. Enter Grafana's account id (displayed in the instructions box on the the **Settings** tab of the CloudWatch data source configuration) and check the **Require external ID** box. +4. Enter Grafana's account id (displayed in the instructions box on the **Settings** tab of the CloudWatch data source configuration) and check the **Require external ID** box. 5. Enter the external ID specified in the instructions box on the **Settings** tab of the CloudWatch data source configuration in Grafana. This external ID will be unique to your Grafana instance. 6. Attach any required permissions you would like Grafana to be able to access on your behalf (for example, CloudWatch Logs and CloudWatch Metrics policies). 7. Give the role a name and description, and click **Create role**. diff --git a/docs/sources/datasources/azure-monitor/_index.md b/docs/sources/datasources/azure-monitor/_index.md index 18e9c6d91cb3e..4579af5e1da3f 100644 --- a/docs/sources/datasources/azure-monitor/_index.md +++ b/docs/sources/datasources/azure-monitor/_index.md @@ -35,7 +35,7 @@ Only users with the organization administrator role can add data sources. Once you've added the Azure Monitor data source, you can [configure it](#configure-the-data-source) so that your Grafana instance's users can create queries in its [query editor]({{< relref "./query-editor" >}}) when they [build dashboards][build-dashboards] and use [Explore][explore]. -The Azure Monitor data source supports visualizing data from three Azure services: +The Azure Monitor data source supports visualizing data from four Azure services: - **Azure Monitor Metrics:** Collect numeric data from resources in your Azure account. - **Azure Monitor Logs:** Collect log and performance data from your Azure account, and query using the Kusto Query Language (KQL). @@ -215,7 +215,7 @@ Grafana refers to such variables as template variables. For details, see the [template variables documentation]({{< relref "./template-variables" >}}). -## Application Insights and Insights Analytics (removed)) +## Application Insights and Insights Analytics (removed) Until Grafana v8.0, you could query the same Azure Application Insights data using Application Insights and Insights Analytics. diff --git a/docs/sources/datasources/azure-monitor/query-editor/index.md b/docs/sources/datasources/azure-monitor/query-editor/index.md index b7be89e709c5e..7a27d2a2c6f12 100644 --- a/docs/sources/datasources/azure-monitor/query-editor/index.md +++ b/docs/sources/datasources/azure-monitor/query-editor/index.md @@ -86,17 +86,17 @@ For example: - `Blob Type: {{ blobtype }}` becomes `Blob Type: PageBlob`, `Blob Type: BlockBlob` - `{{ resourcegroup }} - {{ resourcename }}` becomes `production - web_server` -| Alias pattern | Description | -| ----------------------------- | ------------------------------------------------------------------------------------------------------ | -| `{{ subscriptionid }}` | Replaced with the subscription ID. | -| `{{ subscription }}` | Replaced with the subscription name. | -| `{{ resourcegroup }}` | Replaced with the the resource group. | -| `{{ namespace }}` | Replaced with the resource type or namespace, such as `Microsoft.Compute/virtualMachines`. | -| `{{ resourcename }}` | Replaced with the resource name. | -| `{{ metric }}` | Replaced with the metric name, such as "Percentage CPU". | -| _`{{ arbitaryDimensionID }}`_ | Replaced with the value of the specified dimension. For example, `{{ blobtype }}` becomes `BlockBlob`. | -| `{{ dimensionname }}` | _(Legacy for backward compatibility)_ Replaced with the name of the first dimension. | -| `{{ dimensionvalue }}` | _(Legacy for backward compatibility)_ Replaced with the value of the first dimension. | +| Alias pattern | Description | +| ------------------------------ | ------------------------------------------------------------------------------------------------------ | +| `{{ subscriptionid }}` | Replaced with the subscription ID. | +| `{{ subscription }}` | Replaced with the subscription name. | +| `{{ resourcegroup }}` | Replaced with the resource group. | +| `{{ namespace }}` | Replaced with the resource type or namespace, such as `Microsoft.Compute/virtualMachines`. | +| `{{ resourcename }}` | Replaced with the resource name. | +| `{{ metric }}` | Replaced with the metric name, such as "Percentage CPU". | +| _`{{ arbitraryDimensionID }}`_ | Replaced with the value of the specified dimension. For example, `{{ blobtype }}` becomes `BlockBlob`. | +| `{{ dimensionname }}` | _(Legacy for backward compatibility)_ Replaced with the name of the first dimension. | +| `{{ dimensionvalue }}` | _(Legacy for backward compatibility)_ Replaced with the value of the first dimension. | ### Filter using dimensions @@ -106,7 +106,7 @@ Grafana can display and filter metrics based on dimension values. The data source supports the `equals`, `not equals`, and `starts with` operators as detailed in the [Monitor Metrics API documentation](https://docs.microsoft.com/en-us/rest/api/monitor/metrics/list). -For more information onmulti-dimensional metrics, refer to the [Azure Monitor data platform metrics documentation](https://docs.microsoft.com/en-us/azure/azure-monitor/essentials/data-platform-metrics#multi-dimensional-metrics) and [Azure Monitor filtering documentation](https://docs.microsoft.com/en-us/azure/azure-monitor/essentials/metrics-charts#filters). +For more information on multi-dimensional metrics, refer to the [Azure Monitor data platform metrics documentation](https://docs.microsoft.com/en-us/azure/azure-monitor/essentials/data-platform-metrics#multi-dimensional-metrics) and [Azure Monitor filtering documentation](https://docs.microsoft.com/en-us/azure/azure-monitor/essentials/metrics-charts#filters). ## Query Azure Monitor Logs diff --git a/docs/sources/datasources/elasticsearch/_index.md b/docs/sources/datasources/elasticsearch/_index.md index 9388062f64f02..d70d0e7339241 100644 --- a/docs/sources/datasources/elasticsearch/_index.md +++ b/docs/sources/datasources/elasticsearch/_index.md @@ -34,7 +34,7 @@ The following will help you get started working with Elasticsearch and Grafana: This data source supports these versions of Elasticsearch: -- v7.16+ +- v7.17+ - v8.x Our maintenance policy for Elasticsearch data source is aligned with the [Elastic Product End of Life Dates](https://www.elastic.co/support/eol) and we ensure proper functionality for supported versions. If you are using an Elasticsearch with version that is past its end-of-life (EOL), you can still execute queries, but you will receive a notification in the query builder indicating that the version of Elasticsearch you are using is no longer supported. It's important to note that in such cases, we do not guarantee the correctness of the functionality, and we will not be addressing any related issues. diff --git a/docs/sources/datasources/elasticsearch/query-editor/index.md b/docs/sources/datasources/elasticsearch/query-editor/index.md index 8d15a47e36deb..bb801bcabacce 100644 --- a/docs/sources/datasources/elasticsearch/query-editor/index.md +++ b/docs/sources/datasources/elasticsearch/query-editor/index.md @@ -24,7 +24,11 @@ weight: 300 # Elasticsearch query editor Grafana provides a query editor for Elasticsearch. Elasticsearch queries are in Lucene format. -See [Lucene query syntax](https://www.elastic.co/guide/en/kibana/current/lucene-query.html) and and [Query string syntax](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/query-dsl-query-string-query.html#query-string-syntax) if you are new to working with Lucene queries in Elasticsearch. +See [Lucene query syntax](https://www.elastic.co/guide/en/kibana/current/lucene-query.html) and [Query string syntax](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/query-dsl-query-string-query.html#query-string-syntax) if you are new to working with Lucene queries in Elasticsearch. + +{{% admonition type="note" %}} +When composing Lucene queries, ensure that you use uppercase boolean operators: `AND`, `OR`, and `NOT`. Lowercase versions of these operators are not supported by the Lucene query syntax. +{{% /admonition %}} {{< figure src="/static/img/docs/elasticsearch/elastic-query-editor-10.1.png" max-width="800px" class="docs-image--no-shadow" caption="Elasticsearch query editor" >}} diff --git a/docs/sources/datasources/google-cloud-monitoring/google-authentication/index.md b/docs/sources/datasources/google-cloud-monitoring/google-authentication/index.md index 9243693543132..5d96965f1b13b 100644 --- a/docs/sources/datasources/google-cloud-monitoring/google-authentication/index.md +++ b/docs/sources/datasources/google-cloud-monitoring/google-authentication/index.md @@ -51,7 +51,7 @@ To visualize data from multiple GCP Projects, create one data source per GCP Pro You can create a service account and key file that can be used to access multiple projects. Follow steps 1-5 above, then: 1. Note the email address of the service account, it will look a little strange like `foobar-478@main-boardwalk-90210.iam.gserviceaccount.com`. -1. Navigtate to the other project(s) you want to access. +1. Navigate to the other project(s) you want to access. 1. Add the service account email address to the IAM page of each project, and grant it the required roles. 1. Navigate back to the original project's service account and create a [service account key](https://cloud.google.com/iam/docs/creating-managing-service-account-keys#iam-service-account-keys-create-console). A JSON key file is created and downloaded to your computer 1. Store the key file in a secure place, because it grants access to your Google data. diff --git a/docs/sources/datasources/grafana-pyroscope.md b/docs/sources/datasources/grafana-pyroscope.md deleted file mode 100644 index f0b32c1c0aaad..0000000000000 --- a/docs/sources/datasources/grafana-pyroscope.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -aliases: - - ../features/datasources/phlare/ - - ../features/datasources/grafana-pyroscope/ -description: Horizontally-scalable, highly-available, multi-tenant continuous profiling - aggregation system. OSS profiling solution from Grafana Labs. -keywords: - - grafana - - phlare - - guide - - profiling - - pyroscope -labels: - products: - - cloud - - enterprise - - oss -title: Grafana Pyroscope -weight: 1150 ---- - -# Grafana Pyroscope data source - -Formerly Phlare data source, now Grafana Pyroscope, a horizontally scalable, highly-available, multi-tenant, OSS, continuous profiling aggregation system. Add it as a data source, and you are ready to query your profiles in [Explore][explore]. - -## Configure the Grafana Pyroscope data source - -To configure basic settings for the data source, complete the following steps: - -1. Click **Connections** in the left-side menu. -1. Under Your connections, click **Data sources**. -1. Enter `Grafana Pyroscope` in the search bar. -1. Click **Grafana Pyroscope**. - - The **Settings** tab of the data source is displayed. - -1. Set the data source's basic configuration options: - - | Name | Description | - | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | - | `Name` | A name to specify the data source in panels, queries, and Explore. | - | `Default` | The default data source will be pre-selected for new panels. | - | `URL` | The URL of the Grafana Pyroscope or Phlare instance, e.g., `http://localhost:4100` | - | `Basic Auth` | Enable basic authentication to the data source. | - | `User` | User name for basic authentication. | - | `Password` | Password for basic authentication. | - | `Minimal step` | Used for queries returning timeseries data. Phlare backend, similar to Prometheus, scrapes profiles at certain intervals. To prevent querying at smaller interval use Minimal step same or higher than your Phlare scrape interval. For Pyroscope backend this prevents returning too many data points to the front end. | - -## Querying - -### Query Editor - -![Query editor](/static/img/docs/phlare/query-editor.png 'Query editor') - -Query editor gives you access to a profile type selector, a label selector, and collapsible options. - -![Profile selector](/static/img/docs/phlare/select-profile.png 'Profile selector') - -Select a profile type from the drop-down menu. While the label selector can be left empty to query all profiles without filtering by labels, the profile type or app must be selected for the query to be valid. Grafana does not show any data if the profile type or app isn’t selected when a query is run. - -![Labels selector](/static/img/docs/phlare/labels-selector.png 'Labels selector') - -Use the labels selector input to filter by labels. Pyroscope uses similar syntax to Prometheus to filter labels. Refer to [Pyroscope documentation](https://grafana.com/docs/pyroscope/latest/) for available operators and syntax. - -![Options section](/static/img/docs/phlare/options-section.png 'Options section') - -Options section contains a switch for Query Type and Group by. - -Select a query type to return the profile data which can be shown in the [Flame Graph][flame-graph], metric data visualized in a graph, or both. You can only select both options in a dashboard, because panels allow only one visualization. - -Group by allows you to group metric data by a specified label. Without any Group by label, metric data is aggregated over all the labels into single time series. You can use multiple labels to group by. Group by has only an effect on the metric data and does not change the profile data results. - -### Profiles query results - -Profiles can be visualized in a flame graph. See the [Flame Graph documentation][flame-graph] to learn about the visualization and its features. - -![Flame graph](/static/img/docs/phlare/flame-graph.png 'Flame graph') - -Pyroscope returns profiles aggregated over a selected time range, and the absolute values in the flame graph grow as the time range gets bigger while keeping the relative values meaningful. You can zoom in on the time range to get a higher granularity profile up to the point of a single scrape interval. - -### Metrics query results - -Metrics results represent the aggregated sum value over time of the selected profile type. - -![Metrics graph](/static/img/docs/phlare/metric-graph.png 'Metrics graph') - -This allows you to quickly see any spikes in the value of the scraped profiles and zoom in to a particular time range. - -## Provision the Grafana Pyroscope data source - -You can modify the Grafana configuration files to provision the Grafana Pyroscope data source. To learn more, and to view the available provisioning settings, see [provisioning documentation][provisioning-data-sources]. - -Here is an example config: - -```yaml -apiVersion: 1 - -datasources: - - name: Grafana Pyroscope - type: grafana-pyroscope-datasource - url: http://localhost:4040 - jsonData: - minStep: '15s' -``` - -{{% docs/reference %}} -[explore]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/explore" -[explore]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/explore" - -[flame-graph]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/flame-graph" -[flame-graph]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/flame-graph" - -[provisioning-data-sources]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning#datasources" -[provisioning-data-sources]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning#datasources" -{{% /docs/reference %}} diff --git a/docs/sources/datasources/influxdb/_index.md b/docs/sources/datasources/influxdb/_index.md index d6421c228717d..295cf0cb6e155 100644 --- a/docs/sources/datasources/influxdb/_index.md +++ b/docs/sources/datasources/influxdb/_index.md @@ -126,10 +126,11 @@ Configure these options if you select the InfluxQL (classic InfluxDB) query lang Configure these options if you select the SQL query language: -| Name | Description | -| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Database** | Sets the ID of the bucket to query. Copy this from the Buckets page of the InfluxDB UI. | -| **Token** | API token used for SQL queries. It can be generated on InfluxDB Cloud dashboard under [Load Data > API Tokens](https://docs.influxdata.com/influxdb/cloud-serverless/get-started/setup/#create-an-all-access-api-token) menu. | +| Name | Description | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Database** | Sets the ID of the bucket to query. Copy this from the Buckets page of the InfluxDB UI. | +| **Token** | API token used for SQL queries. It can be generated on InfluxDB Cloud dashboard under [Load Data > API Tokens](https://docs.influxdata.com/influxdb/cloud-serverless/get-started/setup/#create-an-all-access-api-token) menu. | +| **Insecure Connection** | Disable gRPC TLS security. | ### Configure Flux @@ -138,7 +139,7 @@ Configure these options if you select the Flux query language: | Name | Description | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Organization** | The [Influx organization](https://v2.docs.influxdata.com/v2.0/organizations/) that will be used for Flux queries. This is also used to for the `v.organization` query macro. | -| **Token** | The authentication token used for Flux queries. With Influx 2.0, use the [influx authentication token to function](https://v2.docs.influxdata.com/v2.0/security/tokens/create-token/). Token must be set as `Authorization` header with the value `Token <geenrated-token>`. For influx 1.8, the token is `username:password`. | +| **Token** | The authentication token used for Flux queries. With Influx 2.0, use the [influx authentication token to function](https://v2.docs.influxdata.com/v2.0/security/tokens/create-token/). Token must be set as `Authorization` header with the value `Token <generated-token>`. For influx 1.8, the token is `username:password`. | | **Default bucket** | _(Optional)_ The [Influx bucket](https://v2.docs.influxdata.com/v2.0/organizations/buckets/) that will be used for the `v.defaultBucket` macro in Flux queries. | ### Provision the data source @@ -203,9 +204,8 @@ datasources: access: proxy url: http://localhost:8086 jsonData: - version: SQL - metadata: - - database: <bucket-name> + dbName: site + httpHeaderName1: 'Authorization' secureJsonData: httpHeaderValue1: 'Token <token>' ``` @@ -216,11 +216,12 @@ datasources: apiVersion: 1 datasources: - - name: InfluxDB_v2_InfluxQL + - name: InfluxDB_v3_InfluxQL type: influxdb access: proxy url: http://localhost:8086 jsonData: + version: SQL dbName: site httpMode: POST secureJsonData: diff --git a/docs/sources/datasources/jaeger/_index.md b/docs/sources/datasources/jaeger/_index.md index 91143aed8b570..36dc7d0131ee9 100644 --- a/docs/sources/datasources/jaeger/_index.md +++ b/docs/sources/datasources/jaeger/_index.md @@ -123,11 +123,6 @@ The following table describes the ways in which you can configure your trace to ### Trace to metrics -{{% admonition type="note" %}} -This feature is behind the `traceToMetrics` [feature toggle][configure-grafana-feature-toggles]. -If you use Grafana Cloud, open a [support ticket in the Cloud Portal](/profile/org#support) to access this feature. -{{% /admonition %}} - The **Trace to metrics** setting configures the [trace to metrics feature](/blog/2022/08/18/new-in-grafana-9.1-trace-to-metrics-allows-users-to-navigate-from-a-trace-span-to-a-selected-data-source/) available when integrating Grafana with Jaeger. To configure trace to metrics: @@ -369,3 +364,8 @@ To configure this feature, see the [introduction to exemplars][exemplars] docume [variable-syntax]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/variables/variable-syntax" [variable-syntax]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/variables/variable-syntax" {{% /docs/reference %}} + +## Visualizing the dependency graph + +If service dependency information is available in Jaeger, it can be visualized in Grafana. +Use the Jaeger data source with the "Dependency Graph" query type on a Node Graph panel for this. diff --git a/docs/sources/datasources/loki/_index.md b/docs/sources/datasources/loki/_index.md index f81b2c9007258..b048a9f4c43e1 100644 --- a/docs/sources/datasources/loki/_index.md +++ b/docs/sources/datasources/loki/_index.md @@ -32,6 +32,12 @@ The following guides will help you get started with Loki: - [LogQL](/docs/loki/latest/logql/) - [Loki query editor]({{< relref "./query-editor" >}}) +## Supported Loki versions + +This data source supports these versions of Loki: + +- v2.8+ + ## Adding a data source For instructions on how to add a data source to Grafana, refer to the [administration documentation][data-source-management] @@ -60,6 +66,7 @@ datasources: access: proxy url: http://localhost:3100 jsonData: + timeout: 60 maxLines: 1000 ``` diff --git a/docs/sources/datasources/loki/configure-loki-data-source.md b/docs/sources/datasources/loki/configure-loki-data-source.md index 81ec007efbe55..bae45a99498e2 100644 --- a/docs/sources/datasources/loki/configure-loki-data-source.md +++ b/docs/sources/datasources/loki/configure-loki-data-source.md @@ -91,9 +91,9 @@ To troubleshoot configuration and other issues, check the log file located at `/ ### Derived fields -Derived Fields are used to extract new fields from a log message and create a link from the value of the field. +Derived Fields are used to extract new fields from your logs and create a link from the value of the field. -For example, you can link to your tracing backend directly from your logs, or link to a user profile page if the log line contains a corresponding userId. +For example, you can link to your tracing backend directly from your logs, or link to a user profile page if the log line contains a corresponding `userId`. These links appear in the [log details][]. You can add multiple derived fields. @@ -106,7 +106,11 @@ Each derived field consists of the following: - **Name** - Sets the field name. Displayed as a label in the log details. -- **Regex** - Defines a regular expression to parse a part of the log message and capture it as the value of the new field. Can contain only one capture group. +- **Type** - Defines the type of the derived field. It can be either: + + - **Regex**: A regular expression to parse a part of the log message and capture it as the value of the new field. Can contain only one capture group. + + - **Label**: A label from the selected log line. This can be any type of label - indexed, parsed or structured metadata. The label's value will be used as the value of the derived field. - **URL/query** Sets the full link URL if the link is external, or a query for the target data source if the link is internal. You can interpolate the value from the field with the `${__value.raw}` macro. diff --git a/docs/sources/datasources/mysql/_index.md b/docs/sources/datasources/mysql/_index.md index ae6e6eefdba55..9319d93b68d6a 100644 --- a/docs/sources/datasources/mysql/_index.md +++ b/docs/sources/datasources/mysql/_index.md @@ -48,7 +48,7 @@ Administrators can also [configure the data source via YAML](#provision-the-data | **Database** | Name of your MySQL database. | | **User** | Database user's login/username | | **Password** | Database user's password | -| **Session Timezone** | Specifies the time zone used in the database session, such as `Europe/Berlin` or `+02:00`. Required if the timezone of the database (or the host of the database) is set to something other than UTC. Set the value used in the session with `SET time_zone='...'`. If you leave this field empty, then the time zone is not updated. For more information, refer to the [MySQL documentation](https://dev.mysql.com/doc/en/time-zone-support.html). | +| **Session Timezone** | Specifies the timezone used in the database session, such as `Europe/Berlin` or `+02:00`. Required if the timezone of the database (or the host of the database) is set to something other than UTC. Set this to `+00:00` so Grafana can handle times properly. Set the value used in the session with `SET time_zone='...'`. If you leave this field empty, the timezone will not be updated. For more information, refer to [MySQL Server Time Zone Support](https://dev.mysql.com/doc/en/time-zone-support.html). | | **Max open** | The maximum number of open connections to the database, default `100` (Grafana v5.4+). | | **Max idle** | The maximum number of connections in the idle connection pool, default `100` (Grafana v5.4+). | | **Auto (max idle)** | Toggle to set the maximum number of idle connections to the number of maximum open connections (available in Grafana v9.5.1+). Default is `true`. | diff --git a/docs/sources/datasources/prometheus/_index.md b/docs/sources/datasources/prometheus/_index.md index 298320dd8aa94..bd8e9ee45a1b7 100644 --- a/docs/sources/datasources/prometheus/_index.md +++ b/docs/sources/datasources/prometheus/_index.md @@ -19,7 +19,7 @@ weight: 1300 # Prometheus data source -Prometheus is an open-source database that uses an telemetry collector agent to scrape and store metrics used for monitoring and alerting. If you are just getting started with Prometheus, see [What is Prometheus?][intro-to-prometheus]. +Prometheus is an open-source database that uses a telemetry collector agent to scrape and store metrics used for monitoring and alerting. If you are just getting started with Prometheus, see [What is Prometheus?][intro-to-prometheus]. Grafana provides native support for Prometheus. For instructions on downloading Prometheus see [Get started with Grafana and Prometheus][get-started-prometheus]. diff --git a/docs/sources/datasources/prometheus/query-editor/index.md b/docs/sources/datasources/prometheus/query-editor/index.md index e07e4d818d6ac..f8d24817a011e 100644 --- a/docs/sources/datasources/prometheus/query-editor/index.md +++ b/docs/sources/datasources/prometheus/query-editor/index.md @@ -74,7 +74,7 @@ The **Legend** setting defines the time series's name. You can use a predefined The **Min step** setting defines the lower bounds on the interval between data points. For example, set this to `1h` to hint that measurements are taken hourly. -This setting supports the `$__interval` and `$__rate_interval` macros. +This setting supports the `$__interval` and `$__rate_interval` macros. Be aware that the query range dates are aligned to the step and this can change the start and end of the range. ### Format @@ -96,7 +96,7 @@ For more information, refer to the [Time Series Transform option documentation][ {{% admonition type="note" %}} Grafana modifies the request dates for queries to align them with the dynamically calculated step. -This ensures a consistent display of metrics data, but it can result in a small gap of data at the right edge of a graph. +This ensures a consistent display of metrics data and Prometheus requires this for caching results. But, aligning the range with the step can result in a small gap of data at the right edge of a graph or change the start date of the range. For example, a 15s step aligns the range to Unix time divisible by 15s and a 1w minstep aligns the range to the start of the week on a Thursday. {{% /admonition %}} ### Exemplars diff --git a/docs/sources/datasources/pyroscope/_index.md b/docs/sources/datasources/pyroscope/_index.md new file mode 100644 index 0000000000000..8c7d7bc37c961 --- /dev/null +++ b/docs/sources/datasources/pyroscope/_index.md @@ -0,0 +1,85 @@ +--- +aliases: + - ../features/datasources/phlare/ # /docs/grafana/<GRAFANA_VERSION>/features/datasources/phlare/ + - ../features/datasources/grafana-pyroscope/ # /docs/grafana/<GRAFANA_VERSION>/features/datasources/grafana-pyroscope/ + - ../datasources/grafana-pyroscope/ # /docs/grafana/<GRAFANA_VERSION>/datasources/grafana-pyroscope/ +description: Horizontally-scalable, highly-available, multi-tenant continuous profiling + aggregation system. OSS profiling solution from Grafana Labs. +keywords: + - grafana + - phlare + - guide + - profiling + - pyroscope +labels: + products: + - cloud + - enterprise + - oss +title: Grafana Pyroscope +weight: 1150 +--- + +# Grafana Pyroscope data source + +Grafana Pyroscope is a horizontally scalable, highly available, multi-tenant, OSS, continuous profiling aggregation system. Add it as a data source, and you are ready to query your profiles in [Explore][explore]. + +Refer to [Introduction to Pyroscope](https://grafana.com/docs/pyroscope/<PYROSCOPE_VERSION>/introduction/) to understand profiling and Pyroscope. + +To use profiling data, you should: + +- [Configure your application to send profiles](/docs/pyroscope/<PYROSCOPE_VERSION>/configure-client/) +- [Configure the Grafana Pyroscope data source](./configure-pyroscope-data-source/). +- [View and query profiling data in Explore](./query-profile-data/) + +## Integrate profiles into dashboards + +Using the Pyroscope data source, you can integrate profiles into your dashboards. +In this case, the screenshot shows memory profiles alongside panels for logs and metrics to be able to debug out of memory (OOM) errors alongside the associated logs and metrics. + +![dashboard](https://grafana.com/static/img/pyroscope/grafana-pyroscope-dashboard-2023-11-30.png) + +## Visualize traces and profiles data using Traces to profiles + +You can link profile and tracing data using your Pyroscope data source with the Tempo data source. +To learn more about how profiles and tracing can work together, refer to [Profiling and tracing synergies](./profiling-and-tracing/). + +Combined traces and profiles let you see granular line-level detail when available for a trace span. This allows you pinpoint the exact function that's causing a bottleneck in your application as well as a specific request. + +![trace-profiler-view](https://grafana.com/static/img/pyroscope/pyroscope-trace-profiler-view-2023-11-30.png) + +For more information, refer to the [Traces to profile section][configure-tempo-data-source] and [Link tracing and profiling with span profiles](https://grafana.com/docs/pyroscope/<PYROSCOPE_VERSION>/configure-client/trace-span-profiles/). + +{{< youtube id="AG8VzfFMLxo" >}} + +## Provision the Grafana Pyroscope data source + +You can modify the Grafana configuration files to provision the Grafana Pyroscope data source. +To learn more, and to view the available provisioning settings, refer to [provisioning documentation][provisioning-data-sources]. + +Here is an example configuration: + +```yaml +apiVersion: 1 + +datasources: + - name: Grafana Pyroscope + type: grafana-pyroscope-datasource + url: http://localhost:4040 + jsonData: + minStep: '15s' +``` + +{{% docs/reference %}} +[explore]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/explore" +[explore]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/explore" + +[flame-graph]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/flame-graph" +[flame-graph]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/flame-graph" + +[provisioning-data-sources]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning#datasources" +[provisioning-data-sources]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning#datasources" + +[configure-tempo-data-source]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/tempo/configure-tempo-data-source" +[configure-tempo-data-source]: "/docs/grafana-cloud/ -> docs/grafana-cloud/connect-externally-hosted/data-sources/tempo/configure-tempo-data-source" +{{% /docs/reference %}} diff --git a/docs/sources/datasources/pyroscope/configure-pyroscope-data-source.md b/docs/sources/datasources/pyroscope/configure-pyroscope-data-source.md new file mode 100644 index 0000000000000..8503f2dfa4580 --- /dev/null +++ b/docs/sources/datasources/pyroscope/configure-pyroscope-data-source.md @@ -0,0 +1,50 @@ +--- +description: Configure your Pyroscope data source for Grafana. +keywords: + - configure + - profiling + - pyroscope +labels: + products: + - cloud + - enterprise + - oss +title: Configure the Grafana Pyroscope data source +menuTitle: Configure Pyroscope +weight: 200 +--- + +# Configure the Grafana Pyroscope data source + +To configure basic settings for the data source, complete the following steps: + +1. Click **Connections** in the left-side menu. +1. Under Your connections, click **Data sources**. +1. Enter `Grafana Pyroscope` in the search bar. +1. Click **Grafana Pyroscope** to display the **Settings** tab of the data source. + +1. Set the data source's basic configuration options: + + | Name | Description | + | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | + | `Name` | A name to specify the data source in panels, queries, and Explore. | + | `Default` | The default data source will be pre-selected for new panels. | + | `URL` | The URL of the Grafana Pyroscope instance, for example, `http://localhost:4100`. | + | `Basic Auth` | Enable basic authentication to the data source. | + | `User` | User name for basic authentication. | + | `Password` | Password for basic authentication. | + | `Minimal step` | Used for queries returning timeseries data. The Pyroscope backend, similar to Prometheus, scrapes profiles at certain intervals. To prevent querying at smaller interval, use Minimal step same or higher than your Pyroscope scrape interval. This prevents returning too many data points to the frontend. | + +{{% docs/reference %}} +[explore]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/explore" +[explore]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/explore" + +[flame-graph]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/flame-graph" +[flame-graph]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/flame-graph" + +[provisioning-data-sources]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning#datasources" +[provisioning-data-sources]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning#datasources" + +[configure-tempo-data-source]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/tempo/configure-tempo-data-source" +[configure-tempo-data-source]: "/docs/grafana-cloud/ -> docs/grafana-cloud/connect-externally-hosted/data-sources/tempo/configure-tempo-data-source" +{{% /docs/reference %}} diff --git a/docs/sources/datasources/pyroscope/profiling-and-tracing.md b/docs/sources/datasources/pyroscope/profiling-and-tracing.md new file mode 100644 index 0000000000000..98315f7c9963c --- /dev/null +++ b/docs/sources/datasources/pyroscope/profiling-and-tracing.md @@ -0,0 +1,16 @@ +--- +title: How profiling and tracing work together +menuTitle: How profiling and tracing work together +description: Learn about how profiling and tracing work together. +weight: 250 +keywords: + - pyroscope data source + - continuous profiling + - tracing +--- + +# How profiling and tracing work together + +[//]: # 'Shared content for Trace to profiles in the Pyroscope data source' + +{{< docs/shared source="grafana" lookup="datasources/pyroscope-profile-tracing-intro.md" version="<GRAFANA_VERSION>" >}} diff --git a/docs/sources/datasources/pyroscope/query-profile-data.md b/docs/sources/datasources/pyroscope/query-profile-data.md new file mode 100644 index 0000000000000..8c68fb4275bc9 --- /dev/null +++ b/docs/sources/datasources/pyroscope/query-profile-data.md @@ -0,0 +1,82 @@ +--- +description: Use the query editor to explore your Pyroscope data. +keywords: + - query + - profiling + - pyroscope +labels: + products: + - cloud + - enterprise + - oss +title: Query profile data +menuTitle: Query profile data +weight: 300 +--- + +# Query profile data + +The Pyroscope data source query editor gives you access to a profile type selector, a label selector, and collapsible options. + +![Query editor](/media/docs/pyroscope/query-editor/query-editor.png 'Query editor') + +To access the query editor: + +1. Sign into Grafana or Grafana Cloud. +1. Select your Pyroscope data source. +1. From the menu, choose **Explore**. + +1. Select a profile type from the drop-down menu. + + {{< figure src="/media/docs/pyroscope/query-editor/select-profile.png" class="docs-image--no-shadow" max-width="450px" caption="Profile selector" >}} + +1. Use the labels selector input to filter by labels. Pyroscope uses similar syntax to Prometheus to filter labels. + Refer to [Pyroscope documentation](https://grafana.com/docs/pyroscope/latest/) for available operators and syntax. + + While the label selector can be left empty to query all profiles without filtering by labels, the profile type or app must be selected for the query to be valid. + + Grafana doesn't show any data if the profile type or app isn’t selected when a query runs. + + ![Labels selector](/media/docs/pyroscope/query-editor/labels-selector.png 'Labels selector') + +1. Expand the **Options** section to view **Query Type** and **Group by**. + ![Options section](/media/docs/pyroscope/query-editor/options-section.png 'Options section') + +1. Select a query type to return the profile data. Data is shown in the [Flame Graph][flame-graph], metric data visualized in a graph, or both. You can only select both options in Explore. The panels used on dashboards allow only one visualization. + +Using **Group by**, you can group metric data by a specified label. +Without any **Group by** label, metric data aggregates over all the labels into single time series. +You can use multiple labels to group by. Group by only effects the metric data and doesn't change the profile data results. + +## Profiles query results + +Profiles can be visualized in a flame graph. +Refer to the [Flame Graph documentation][flame-graph] to learn about the visualization and its features. + +![Flame graph](/media/docs/pyroscope/query-editor/flame-graph.png 'Flame graph') + +Pyroscope returns profiles aggregated over a selected time range. +The absolute values in the flame graph grow as the time range gets bigger while keeping the relative values meaningful. +You can zoom in on the time range to get a higher granularity profile up to the point of a single scrape interval. + +## Metrics query results + +Metrics results represent the aggregated sum value over time of the selected profile type. + +![Metrics graph](/media/docs/pyroscope/query-editor/metric-graph.png 'Metrics graph') + +This allows you to quickly see any spikes in the value of the scraped profiles and zoom in to a particular time range. + +{{% docs/reference %}} +[explore]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/explore" +[explore]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/explore" + +[flame-graph]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/flame-graph" +[flame-graph]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/flame-graph" + +[provisioning-data-sources]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning#datasources" +[provisioning-data-sources]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning#datasources" + +[configure-tempo-data-source]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/tempo/configure-tempo-data-source" +[configure-tempo-data-source]: "/docs/grafana-cloud/ -> docs/grafana-cloud/connect-externally-hosted/data-sources/tempo/configure-tempo-data-source" +{{% /docs/reference %}} diff --git a/docs/sources/datasources/tempo/_index.md b/docs/sources/datasources/tempo/_index.md index 70ba9c4f682b7..445bda32e7236 100644 --- a/docs/sources/datasources/tempo/_index.md +++ b/docs/sources/datasources/tempo/_index.md @@ -20,11 +20,15 @@ weight: 1400 # Tempo data source -Grafana ships with built-in support for [Tempo](https://grafana.com/docs/tempo/latest/), a high-volume, minimal-dependency trace storage, open-source tracing solution from Grafana Labs. This topic explains configuration and queries specific to the Tempo data source. +Grafana ships with built-in support for [Tempo](https://grafana.com/docs/tempo/latest/), a high-volume, minimal-dependency trace storage, open source tracing solution from Grafana Labs. This topic explains configuration and queries specific to the Tempo data source. For instructions on how to add a data source to Grafana, refer to the [administration documentation][data-source-management]. Only users with the organization administrator role can add data sources. -Administrators can also [configure the data source via YAML](#provision-the-data-source) with Grafana's provisioning system. +Administrators can also [configure the data source via YAML][configure-tempo-data-source] with Grafana's provisioning system. + +This video explains how to add data sources, including Loki, Tempo, and Mimir, to Grafana and Grafana Cloud. Tempo data source set up starts at 4:58 in the video. + +{{< youtube id="cqHO0oYW6Ic" start="298" >}} Once you've added the data source, you can [configure it]({{< relref "./configure-tempo-data-source/" >}}) so that your Grafana instance's users can create queries in its [query editor]({{< relref "./query-editor/" >}}) when they [build dashboards][build-dashboards] and use [Explore][explore]. @@ -37,6 +41,9 @@ Once you've added the data source, you can [configure it]({{< relref "./configur [configure-grafana-feature-toggles]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana#feature_toggles" [configure-grafana-feature-toggles]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana#feature_toggles" +[configure-tempo-data-source]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/tempo/configure-tempo-data-source#provision-the-data-source" +[configure-tempo-data-source]: "/docs/grafana-cloud/ -> docs/grafana-cloud/connect-externally-hosted/data-sources/tempo/configure-tempo-data-source#provision-the-data-source" + [data-source-management]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/data-source-management" [data-source-management]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/data-source-management" diff --git a/docs/sources/datasources/tempo/configure-tempo-data-source.md b/docs/sources/datasources/tempo/configure-tempo-data-source.md index caaa3f76aa77b..a37b83bedaa7b 100644 --- a/docs/sources/datasources/tempo/configure-tempo-data-source.md +++ b/docs/sources/datasources/tempo/configure-tempo-data-source.md @@ -35,7 +35,11 @@ To configure basic settings for the Tempo data source, complete the following st | **User** | Sets the user name for basic authentication. | | **Password** | Sets the password for basic authentication. | -You can also configure settings specific to the Tempo data source. These options are described in the sections below. +You can also configure settings specific to the Tempo data source. + +This video explains how to add data sources, including Loki, Tempo, and Mimir, to Grafana and Grafana Cloud. Tempo data source set up starts at 4:58 in the video. + +{{< youtube id="cqHO0oYW6Ic" start="298" >}} ## Trace to logs @@ -55,7 +59,7 @@ There are two ways to configure the trace to logs feature: You can also click **Open advanced data source picker** to see more options, including adding a data source. 1. Set start and end time shift. As the logs timestamps may not exactly match the timestamps of the spans in trace it may be necessary to search in larger or shifted time range to find the desired logs. -1. Select which tags to use in the logs query. The tags you configure must be present in the spans attributes or resources for a trace to logs span link to appear. You can optionally configure a new name for the tag. This is useful for example if the tag has dots in the name and the target data source does not allow using dots in labels. In that case you can for example remap `http.status` to `http_status`. +1. Select which tags to use in the logs query. The tags you configure must be present in the span's attributes or resources for a trace to logs span link to appear. You can optionally configure a new name for the tag. This is useful, for example, if the tag has dots in the name and the target data source does not allow using dots in labels. In that case, you can for example remap `http.status` (the span attribute) to `http_status` (the data source field). "Data source" in this context can refer to Loki, or another log data source. 1. Optionally switch on the **Filter by trace ID** and/or **Filter by span ID** setting to further filter the logs if your logs consistently contain trace or span IDs. ### Configure a custom query @@ -65,26 +69,12 @@ There are two ways to configure the trace to logs feature: You can also click **Open advanced data source picker** to see more options, including adding a data source. 1. Set start and end time shift. As the logs timestamps may not exactly match the timestamps of the spans in the trace it may be necessary to widen or shift the time range to find the desired logs. -1. Optionally select tags to map. These tags can be used in the custom query with `${__tags}` variable. This variable will interpolate the mapped tags as list in an appropriate syntax for the data source and will only include the tags that were present in the span omitting those that weren't present. You can optionally configure a new name for the tag. This is useful in cases where the tag has dots in the name and the target data source does not allow using dots in labels. For example, you can remap `http.status` to `http_status` in such a case. If you don't map any tags here, you can still use any tag in the query like this `method="${__span.tags.method}"`. +1. Optional: Select tags to map. These tags can be used in the custom query with `${__tags}` variable. This variable interpolates the mapped tags as list in an appropriate syntax for the data source. Only the tags that were present in the span are included; tags that aren't present are omitted You can also configure a new name for the tag. This is useful in cases where the tag has dots in the name and the target data source doesn't allow dots in labels. For example, you can remap `http.status` to `http_status`. If you don't map any tags here, you can still use any tag in the query, for example, `method="${__span.tags.method}"`. You can learn more about custom query variables [here](/docs/grafana/latest/datasources/tempo/configure-tempo-data-source/#custom-query-variables). 1. Skip **Filter by trace ID** and **Filter by span ID** settings as these cannot be used with a custom query. 1. Switch on **Use custom query**. 1. Specify a custom query to be used to query the logs. You can use various variables to make that query relevant for current span. The link will only be shown only if all the variables are interpolated with non-empty values to prevent creating an invalid query. -### Variables that can be used in a custom query - -To use a variable you need to wrap it in `${}`. For example `${__span.name}`. - -| Variable name | Description | -| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **\_\_tags** | This variable uses the tag mapping from the UI to create a label matcher string in the specific data source syntax. The variable only uses tags that are present in the span. The link is still created even if only one of those tags is present in the span. You can use this if all tags are not required for the query to be useful. | -| **\_\_span.spanId** | The ID of the span. | -| **\_\_span.traceId** | The ID of the trace. | -| **\_\_span.duration** | The duration of the span. | -| **\_\_span.name** | Name of the span. | -| **\_\_span.tags** | Namespace for the tags in the span. To access a specific tag named `version`, you would use `${__span.tags.version}`. In case the tag contains dot, you have to access it as `${__span.tags["http.status"]}`. | -| **\_\_trace.traceId** | The ID of the trace. | -| **\_\_trace.duration** | The duration of the trace. | -| **\_\_trace.name** | The name of the trace. | +### Configure trace to logs The following table describes the ways in which you can configure your trace to logs settings: @@ -101,34 +91,58 @@ The following table describes the ways in which you can configure your trace to ## Trace to metrics -{{% admonition type="note" %}} -This feature is behind the `traceToMetrics` [feature toggle][configure-grafana-feature-toggles]. -If you use Grafana Cloud, open a [support ticket in the Cloud Portal](/profile/org#support) to access this feature. -{{% /admonition %}} - The **Trace to metrics** setting configures the [trace to metrics feature](/blog/2022/08/18/new-in-grafana-9.1-trace-to-metrics-allows-users-to-navigate-from-a-trace-span-to-a-selected-data-source/) available when integrating Grafana with Tempo. {{< youtube id="TkapvLeMMpc" >}} -To configure trace to metrics: +There are two ways to configure the trace to metrics feature: -1. Select the target data source from the drop-down list. +- Use a basic configuration with a default query, or +- Configure one or more custom queries where you can use a [template language][variable-syntax] to interpolate variables from the trace or span. - You can also click **Open advanced data source picker** to see more options, including adding a data source. +### Simple config + +To use a simple configuration, follow these steps: + +1. Select a metrics data source from the **Data source** drop-down. +1. Optional: Choose any tags to use in the query. If left blank, the default values of `cluster`, `hostname`, `namespace`, `pod`, `service.name` and `service.namespace` are used. + + The tags you configure must be present in the spans attributes or resources for a trace to metrics span link to appear. You can optionally configure a new name for the tag. This is useful for example if the tag has dots in the name and the target data source doesn't allow using dots in labels. In that case you can for example remap `service.name` to `service_name`. + +1. Do not select **Add query**. +1. Select **Save and Test**. + +### Custom queries + +To use custom queries with the configuration, follow these steps: + +1. Select a metrics data source from the **Data source** drop-down. +1. Optional: Choose any tags to use in the query. If left blank, the default values of `cluster`, `hostname`, `namespace`, `pod`, `service.name` and `service.namespace` are used. -1. Create any desired linked queries. + These tags can be used in the custom query with `${__tags}` variable. This variable interpolates the mapped tags as list in an appropriate syntax for the data source and will only include the tags that were present in the span omitting those that weren’t present. You can optionally configure a new name for the tag. This is useful in cases where the tag has dots in the name and the target data source doesn't allow using dots in labels. For example, you can remap `service.name` to `service_name` in such a case. If you don’t map any tags here, you can still use any tag in the query like this `method="${__span.tags.method}"`. You can learn more about custom query variables [here](/docs/grafana/latest/datasources/tempo/configure-tempo-data-source/#custom-query-variables). -| Setting name | Description | -| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Data source** | Defines the target data source. | -| **Tags** | Defines the tags used in linked queries. The key sets the span attribute name, and the optional value sets the corresponding metric label name. For example, you can map `k8s.pod` to `pod`. To interpolate these tags into queries, use the `$__tags` keyword. | +1. Click **Add query** to add a custom query. +1. Specify a custom query to be used to query metrics data. -Each linked query consists of: + Each linked query consists of: -- **Link Label:** _(Optional)_ Descriptive label for the linked query. -- **Query:** The query ran when navigating from a trace to the metrics data source. - Interpolate tags using the `$__tags` keyword. - For example, when you configure the query `requests_total{$__tags}`with the tags `k8s.pod=pod` and `cluster`, the result looks like `requests_total{pod="nginx-554b9", cluster="us-east-1"}`. + - **Link Label:** _(Optional)_ Descriptive label for the linked query. + - **Query:** The query ran when navigating from a trace to the metrics data source. + Interpolate tags using the `$__tags` keyword. + For example, when you configure the query `requests_total{$__tags}`with the tags `k8s.pod=pod` and `cluster`, the result looks like `requests_total{pod="nginx-554b9", cluster="us-east-1"}`. + +1. Select **Save and Test**. + +### Configure trace to metrics + +| Setting name | Description | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Data source** | Defines the target data source. | +| **Span start time shift** | Shifts the start time for the metrics query, based on the span's start time. You can use time units, such as `5s`, `1m`, `3h`. To extend the time to the past, use a negative value. Default: `0`. | +| **Span end time shift** | Shifts the end time for the metrics query, based on the span's end time. You can use time units. Default: `0`. | +| **Tags** | Defines the tags used in linked queries. The key sets the span attribute name, and the optional value sets the corresponding metric label name. For example, you can map `k8s.pod` to `pod`. To interpolate these tags into queries, use the `$__tags` keyword. | +| **Link Label** | _(Optional)_ Descriptive label for the linked query. | +| **Query** | Input to write a custom query. Use variable interpolation to customize it with variables from span. | ## Trace to profiles @@ -136,6 +150,22 @@ Each linked query consists of: {{< docs/shared source="grafana" lookup="datasources/tempo-traces-to-profiles.md" leveloffset="+1" version="<GRAFANA VERSION>" >}} +## Custom query variables + +To use a variable in your trace to logs, metrics or profiles you need to wrap it in `${}`. For example, `${__span.name}`. + +| Variable name | Description | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **\_\_tags** | This variable uses the tag mapping from the UI to create a label matcher string in the specific data source syntax. The variable only uses tags that are present in the span. The link is still created even if only one of those tags is present in the span. You can use this if all tags are not required for the query to be useful. | +| **\_\_span.spanId** | The ID of the span. | +| **\_\_span.traceId** | The ID of the trace. | +| **\_\_span.duration** | The duration of the span. | +| **\_\_span.name** | Name of the span. | +| **\_\_span.tags** | Namespace for the tags in the span. To access a specific tag named `version`, you would use `${__span.tags.version}`. In case the tag contains dot, you have to access it as `${__span.tags["http.status"]}`. | +| **\_\_trace.traceId** | The ID of the trace. | +| **\_\_trace.duration** | The duration of the trace. | +| **\_\_trace.name** | The name of the trace. | + ## Service Graph The **Service Graph** setting configures the [Service Graph](/docs/tempo/latest/metrics-generator/service_graphs/enable-service-graphs/) feature. @@ -156,13 +186,6 @@ The **Search** setting configures [Tempo search](/docs/tempo/latest/configuratio You can configure the **Hide search** setting to hide the search query option in **Explore** if search is not configured in the Tempo instance. -## Loki search - -The **Loki search** setting configures the Loki search query type. - -Configure the **Data source** setting to define which Loki instance you want to use to search traces. -You must configure [derived fields]({{< relref "../loki#configure-derived-fields" >}}) in the Loki instance. - ## TraceID query The **TraceID query** setting modifies how TraceID queries are run. The time range can be used when there are performance issues or timeouts since it will narrow down the search to the defined range. This setting is disabled by default. @@ -224,14 +247,18 @@ datasources: queries: - name: 'Sample query' query: 'sum(rate(traces_spanmetrics_latency_bucket{$$__tags}[5m]))' + traceToProfiles: + datasourceUid: 'grafana-pyroscope-datasource' + tags: ['job', 'instance', 'pod', 'namespace'] + profileTypeId: 'process_cpu:cpu:nanoseconds:cpu:nanoseconds' + customQuery: true + query: 'method="${__span.tags.method}"' serviceMap: datasourceUid: 'prometheus' nodeGraph: enabled: true search: hide: false - lokiSearch: - datasourceUid: 'loki' traceQuery: timeShiftEnabled: true spanStartTimeShift: '1h' @@ -245,9 +272,6 @@ datasources: [build-dashboards]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/build-dashboards" [build-dashboards]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/build-dashboards" -[configure-grafana-feature-toggles]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana#feature_toggles" -[configure-grafana-feature-toggles]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana#feature_toggles" - [data-source-management]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/data-source-management" [data-source-management]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/data-source-management" diff --git a/docs/sources/datasources/tempo/query-editor/_index.md b/docs/sources/datasources/tempo/query-editor/_index.md index 14c9f364a80e6..f80a938d22bdd 100644 --- a/docs/sources/datasources/tempo/query-editor/_index.md +++ b/docs/sources/datasources/tempo/query-editor/_index.md @@ -12,12 +12,12 @@ labels: - cloud - enterprise - oss -menuTitle: Query editor -title: Tempo query editor +menuTitle: Query tracing data +title: Query tracing data weight: 300 --- -# Tempo query editor +# Query tracing data The Tempo data source's query editor helps you query and display traces from Tempo in [Explore][explore]. @@ -99,13 +99,6 @@ To query a particular trace: {{< figure src="/static/img/docs/tempo/query-editor-traceid.png" class="docs-image--no-shadow" max-width="750px" caption="Screenshot of the Tempo TraceID query type" >}} -## Query Loki for traces - -To find traces to visualize, you can use the [Loki query editor]({{< relref "../../loki#loki-query-editor" >}}). -For results, you must configure [derived fields]({{< relref "../../loki#configure-derived-fields" >}}) in the Loki data source that point to this data source. - -{{< figure src="/static/img/docs/tempo/query-editor-search.png" class="docs-image--no-shadow" max-width="750px" caption="Screenshot of the Tempo query editor showing the Loki Search tab" >}} - {{% docs/reference %}} [explore]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/explore" [explore]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/explore" diff --git a/docs/sources/datasources/tempo/tracing-best-practices.md b/docs/sources/datasources/tempo/tracing-best-practices.md new file mode 100644 index 0000000000000..bab5083848344 --- /dev/null +++ b/docs/sources/datasources/tempo/tracing-best-practices.md @@ -0,0 +1,25 @@ +--- +description: Use best practices to plan how you implement tracing. +keywords: + - grafana + - tempo + - best practices + - tracing +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Best practices +title: Best practices for tracing +weight: 250 +--- + +# Best practices for tracing + +This page provides some general best practices for tracing. + +[//]: # 'Shared content for best practices for traces' +[//]: # 'This content is located in /tempo/docs/sources/shared/best-practices-traces.md' + +{{< docs/shared source="tempo" lookup="best-practices-traces.md" version="<TEMPO_VERSION>" >}} diff --git a/docs/sources/datasources/zipkin/_index.md b/docs/sources/datasources/zipkin/_index.md index c1d96a862567a..b12037fbe2c03 100644 --- a/docs/sources/datasources/zipkin/_index.md +++ b/docs/sources/datasources/zipkin/_index.md @@ -121,11 +121,6 @@ The following table describes the ways in which you can configure your trace to ### Trace to metrics -{{% admonition type="note" %}} -This feature is behind the `traceToMetrics` [feature toggle][configure-grafana-feature-toggles]. -If you use Grafana Cloud, open a [support ticket in the Cloud Portal](/profile/org/#support) to access this feature. -{{% /admonition %}} - The **Trace to metrics** setting configures the [trace to metrics feature](/blog/2022/08/18/new-in-grafana-9.1-trace-to-metrics-allows-users-to-navigate-from-a-trace-span-to-a-selected-data-source/) available when integrating Grafana with Zipkin. To configure trace to metrics: diff --git a/docs/sources/developers/angular_deprecation/_index.md b/docs/sources/developers/angular_deprecation/_index.md index 89b3037c1ae51..060e3931dfea7 100644 --- a/docs/sources/developers/angular_deprecation/_index.md +++ b/docs/sources/developers/angular_deprecation/_index.md @@ -14,7 +14,20 @@ weight: 500 # Angular support deprecation -Angular plugin support is deprecated and will be removed in a future release. There are still many community plugins that rely on Grafana's Angular plugin support to work. The same is true for many internal (private) plugins that have been developed by Grafana users over the years. Grafana version 9 has a server configuration option that is global to the entire instance and controls whether Angular plugin support is available or not. By default, Angular support is still enabled, but that will change soon once we complete the migration of all Angular code in the core product. +Angular plugin support is deprecated and will be removed in a future release. +There are legacy core Grafana visualizations and external plugins that rely on Grafana's Angular plugin support to work. The same is likely true for [private plugins](https://grafana.com/legal/plugins/) that have been developed by Grafana users for use on their own instances over the years. +From Grafana v9 and onwards, there is a [server configuration option](https://github.com/grafana/grafana/blob/d61bcdf4ca5e69489e0067c56fbe7f0bfdf84ee4/conf/defaults.ini#L362) that's global to the entire instance and controls whether Angular plugin support is available or not. +In Grafana 11, we will change the default value for the configuration to remove support. + +Warning messages are displayed if a dashboard depends on an a panel visualization or data source which requires AngularJS as shown in the following video: + +{{< youtube id="XlEVs6g8dC8" >}} + +To avoid disruption: + +- Ensure that you are running the latest version of plugins by following this guide on [updating]({{< relref "../../administration/plugin-management/#update-a-plugin" >}}). Many panels and data sources have migrated from AngularJS. +- If you are using legacy Core Grafana visualizations such as Graph or Table-old, migrate to their replacements using the provided [automatic migrations]({{< relref "./angular-plugins/#automatic-migration-of-plugins" >}}). +- Review the [list of current Angular plugins]({{< relref "./angular-plugins/" >}}) to discover which Core and external plugins are impacted, and whether an update or alternative is required. ## Why are we deprecating Angular support? @@ -22,17 +35,24 @@ AngularJS is an old frontend framework whose active development stopped many yea ## When will Angular plugins stop working? -Our goal is to transfer all the remaining Angular code to the core of Grafana before Grafana 10 is released in Summer 2023. Once this is done, the option "[angular_support_enabled](https://github.com/grafana/grafana/blob/d61bcdf4ca5e69489e0067c56fbe7f0bfdf84ee4/conf/defaults.ini#L362)" will be disabled by default for new Grafana Cloud users, resulting in the inability to use Angular plugins. In case you still rely on AngularJS-based plugins developed internally or by the community, you will need to enable this option to continue using them. Following the release of Grafana 10 we will be migrating Grafana Cloud users where possible and disabling Angular support when appropriate, we will also be introducing new features to help all users identify how they are impacted and to warn of the use of deprecated plugins within the Grafana UI. +In Grafana 11, which will be released in preview in April 2024 and generally available in May, we will change the default behavior of the [angular_support_enabled](https://github.com/grafana/grafana/blob/d61bcdf4ca5e69489e0067c56fbe7f0bfdf84ee4/conf/defaults.ini#L362) configuration parameter to turn off support for AngularJS based plugins. In case you still rely on [AngularJS-based plugins]({{< relref "./angular-plugins/" >}}) developed internally or by the community, you will need to enable this option to continue using them. + +New Grafana Cloud users will be unable to request for support to be added to their instance. ## When will we remove Angular support completely? -Our plan is to completely remove support for Angular plugins in version 11, which will be released in 2024. This means that all plugins that depend on Angular will stop working and the temporary option introduced in version 10 to enable Angular will be removed. +Our current plan is to completely remove any remaining support for Angular plugins in version 12. Including the removal of the [angular_support_enabled](https://github.com/grafana/grafana/blob/d61bcdf4ca5e69489e0067c56fbe7f0bfdf84ee4/conf/defaults.ini#L362) configuration parameter. + +## A dashboard I use is displaying a warning, what do I need to do? + +A dashboard displays warnings when one or more panel visualizations or data sources in the dashboard have a dependency on Angular. +Contact your system administrator to advise them of the issue or follow the preceding guidance on avoiding disruption. ## How do I migrate an Angular plugin to React? Depending on if it’s a data source plugin, panel plugin, or app plugin the process will differ. -For panels, the rendering logic could in some cases be easily preserved but all options need to be redone to use the declarative options framework. For data source plugins the query editor and config options will likely need a total rewrite. +For panels, the rendering logic could in some cases be easily preserved, but all options need to be redone to use the declarative options framework. For data source plugins the query editor and config options will likely need a total rewrite. ## How do I encourage a community plugin to migrate? diff --git a/docs/sources/developers/angular_deprecation/angular-plugins.md b/docs/sources/developers/angular_deprecation/angular-plugins.md index be32d807fd807..d565cb5a2e197 100644 --- a/docs/sources/developers/angular_deprecation/angular-plugins.md +++ b/docs/sources/developers/angular_deprecation/angular-plugins.md @@ -16,708 +16,192 @@ description: An annotated list of Grafana plugins using AngularJS. # Plugins using AngularJS -The use of AngularJS in Grafana has been [deprecated]({{< relref "../angular_deprecation" >}}) and support for it will be removed in a future release. +The use of AngularJS in Grafana has been [deprecated]({{< relref "../angular_deprecation" >}}) in favor of React. Support for AngularJS will be turned off by default in Grafana 11. -This page is to help users of Grafana understand how they might be impacted by the removal of Angular support, and whether a migration option exists. - -It lists the latest versions of plugins _currently available_ in the Grafana [plugin catalog](https://grafana.com/plugins) which depend on Angular, and will stop working when Angular support is removed from Grafana. The list will be updated as more plugins migrate to React or offer migration advice. - -Plugins which have been [deprecated](/legal/plugin-deprecation/) will _not_ be listed. Generally, we advise users to migrate away from deprecated plugins as they will not be updated and may not function in current or future versions of Grafana. - -{{% admonition type="note" %}} -We advise you to ensure you are running the latest version of plugins, as previous releases of plugins not listed here may still require AngularJS. -{{% /admonition %}} - -We also list the year in which the plugin was last updated in the catalog and where appropriate, highlight warnings for plugins where the source repository has not been updated in a number of years and appears inactive. This may help indicate the likelihood of a migration being undertaken, but is informational rather than definitive. - -{{% admonition type="note" %}} -Plugins were updated to include signatures in 2021, so whilst a plugin may show as having been updated at that point - the last update to its functionality or dependencies may have been longer ago. -{{% /admonition %}} - -## What should I do with the information below? - -- Consider the available migration steps. -- Check your Grafana instances for usage of these plugins - see information here on [browsing installed plugins]({{< relref "../../administration/plugin-management/#browse-plugins" >}}). -- Review the project repositories to add your support to any migration issues. - -## I'm a plugin author - -We are greatly appreciative of the developers who have contributed plugins to the Grafana ecosystem, your work has helped support millions of users to gain insights into their data. A plugin being listed below is no reflection on its quality, and is purely to help users understand the impact of the removal of Angular support in Grafana. - -Guidance on migrating a plugin to React can be found in our [migration guide](/developers/plugin-tools/migration-guides/migrate-angularjs-to-react). If you would like to add any specific migration guidance for your plugin here or update our assessment, please open a PR by clicking the `Suggest an edit` button at the bottom of this page. - -# Current AngularJS based plugins - -## Apps - -### [Bosun](https://grafana.com/grafana/plugins/bosun-app) - -Latest Version: 0.0.29 | Signature: Community | Last Updated: 2023 - -> [Migration issue](https://github.com/bosun-monitor/bosun-grafana-app/issues/63) has been raised. - -### [GLPI](https://grafana.com/grafana/plugins/ddurieux-glpi-app) - -Latest Version: 1.3.1 | Signature: Community | Last Updated: 2021 - -> [Migration issue](https://github.com/ddurieux/glpi_app_grafana/issues/96) has been raised. - -### [DevOpsProdigy KubeGraf](https://grafana.com/grafana/plugins/devopsprodigy-kubegraf-app/) - -Latest Version: 1.5.2 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -[Issues](https://github.com/devopsprodigy/kubegraf/issues/71) in the project repository suggest that the project _may_ be unsupported. -{{% /admonition %}} - -> **Migration available - potential alternative:** Grafana Cloud includes a [Kubernetes integration](https://grafana.com/solutions/kubernetes/). - -### [AWS IoT TwinMaker App](https://grafana.com/grafana/plugins/grafana-iot-twinmaker-app) - -Latest Version: 1.6.2 | Signature: Grafana | Last Updated: 2023 - -{{% admonition type="note" %}} -Plugin should continue to work even if Angular is disabled, and a full removal of Angular related code is planned. -{{% /admonition %}} - -### [Stagemonitor Elasticsearch](https://grafana.com/grafana/plugins/stagemonitor-elasticsearch-app) - -Latest Version: 0.83.3 | Signature: Community | Last Updated: 2021 - -> [Migration issue](https://github.com/stagemonitor/stagemonitor-grafana-elasticsearch/issues/1) has been raised. - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/stagemonitor/stagemonitor-grafana-elasticsearch) in the past 4 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Voxter VoIP Platform Metrics](https://grafana.com/grafana/plugins/voxter-app) - -Latest Version: 0.0.2 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/raintank/voxter-app) in the past 3 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -## Datasources - -### [Akumuli](https://grafana.com/grafana/plugins/akumuli-datasource/) - -Latest Version: 1.3.12 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -[Issues](https://github.com/akumuli/Akumuli/issues/379) in the project repository suggest that the project _may_ be unsupported. -{{% /admonition %}} - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/akumuli/Akumuli/) in the past 3 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Finance](https://grafana.com/grafana/plugins/ayoungprogrammer-finance-datasource/) - -Latest Version: 1.0.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -[Issues](https://github.com/ayoungprogrammer/grafana-finance/issues/7) in the project repository suggest that the project _may_ be unsupported. -{{% /admonition %}} - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/ayoungprogrammer/grafana-finance) in the past 6 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Chaos Mesh](https://grafana.com/grafana/plugins/chaosmeshorg-datasource/) - -Latest Version: 2.2.3 | Signature: Community | Last Updated: 2022 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/chaos-mesh/datasource) in the past year suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [DeviceHive](https://grafana.com/grafana/plugins/devicehive-devicehive-datasource/) - -Latest Version: 2.0.2 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/devicehive/devicehive-grafana-datasource) in the past 5 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Google BigQuery](https://grafana.com/grafana/plugins/doitintl-bigquery-datasource/) - -Latest Version: 2.0.3 | Signature: Community | Last Updated: 2022 - -> **Migration available - plugin superseded:** Grafana provides its own [Google BigQuery Plugin](https://grafana.com/grafana/plugins/grafana-bigquery-datasource/). The previous [Project repository](https://github.com/doitintl/bigquery-grafana) was archived on December 11, 2022 with a recommendation to migrate to the aforementioned Grafana provided plugin. - -### [Open-Falcon](https://grafana.com/grafana/plugins/fastweb-openfalcon-datasource/) - -Latest Version: 1.0.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -[Project repository](https://github.com/open-falcon/grafana-openfalcon-datasource) suggests support for Grafana v4.2 - Grafana v5.4. -{{% /admonition %}} - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/open-falcon/grafana-openfalcon-datasource) in the past year suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [GraphQL Data Source](https://grafana.com/grafana/plugins/fifemon-graphql-datasource/) - -Latest Version: 1.3.0 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Project support is unclear after a request for new maintainers - [source](https://github.com/fifemon/graphql-datasource/issues/77). -{{% /admonition %}} - -> **Migration available - potential alternative:** The [Infinity](https://grafana.com/grafana/plugins/yesoreyeram-infinity-datasource/) data source supports GraphQL. - -### [Cloudera Manager](https://grafana.com/grafana/plugins/foursquare-clouderamanager-datasource/) - -Latest Version: 0.9.3 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/foursquare/datasource-plugin-clouderamanager) in the past 7 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Simple Annotations](https://grafana.com/grafana/plugins/fzakaria-simple-annotations-datasource/) - -Latest Version: 1.0.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Plugin only claims support for Grafana v4.x.x. -{{% /admonition %}} - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/fzakaria/simple-annotations-plugin/) in the past 6 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -{{% admonition type="warning" %}} -Developer no longer maintains the project, but is open to contributions: https://github.com/fzakaria/simple-annotations-plugin/issues/2 -{{% /admonition %}} - -### [Gnocchi](https://grafana.com/grafana/plugins/gnocchixyz-gnocchi-datasource/) - -Latest Version: 1.7.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Plugin only claims support for Grafana v4.x.x -{{% /admonition %}} - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/gnocchixyz/grafana-gnocchi-datasource) in the past 3 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [MetaQueries](https://grafana.com/grafana/plugins/goshposh-metaqueries-datasource/) - -Latest Version: 0.0.9 | Signature: Community | Last Updated: 2022 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/GoshPosh/grafana-meta-queries) in the past year suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Open Distro for Elasticsearch](https://grafana.com/grafana/plugins/grafana-es-open-distro-datasource/) - -Latest Version: 1.0.6 | Signature: Grafana | Last Updated: 2021 - -> **Migration available - plugin superseded:** Plugin was deprecated in favour of the [OpenSearch Plugin](https://grafana.com/grafana/plugins/grafana-opensearch-datasource/). - -### [KairosDB](https://grafana.com/grafana/plugins/grafana-kairosdb-datasource/) - -Latest Version: 3.0.2 | Signature: Grafana | Last Updated: 2021 - -{{% admonition type="warning" %}} -[Project repository](https://github.com/grafana/kairosdb-datasource) was archived on August 30th, 2021, and is no longer maintained. -{{% /admonition %}} - -### [SimpleJson](https://grafana.com/grafana/plugins/grafana-simple-json-datasource/) - -Latest Version: 1.4.2 | Signature: Grafana | Last Updated: 2021 - -> **Migration available - potential alternative:** [Project repository](https://github.com/grafana/simple-json-datasource) is no longer maintained, but a number of alternatives exist, including - [Infinity](https://grafana.com/grafana/plugins/yesoreyeram-infinity-datasource/), [JSON](https://grafana.com/grafana/plugins/simpod-json-datasource) and [JSON API](https://grafana.com/grafana/plugins/marcusolsson-json-datasource). - -{{% admonition type="note" %}} -If you're looking for an example of a data source plugin to start from, refer to [grafana-starter-datasource-backend](https://github.com/grafana/grafana-starter-datasource-backend). -{{% /admonition %}} - -### [openHistorian](https://grafana.com/grafana/plugins/gridprotectionalliance-openhistorian-datasource/) - -Latest Version: 1.0.3 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/GridProtectionAlliance/openHistorian-grafana/) in the past 2 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Hawkular](https://grafana.com/grafana/plugins/hawkular-datasource/) - -Latest Version: 1.1.2 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/hawkular/hawkular-grafana-datasource) in the past 5 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [PRTG](https://grafana.com/grafana/plugins/jasonlashua-prtg-datasource/) - -Latest Version: 4.0.4 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/neuralfraud/grafana-prtg) in the past 4 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -{{% admonition type="warning" %}} -Unmaintained since 2017 - [source](https://github.com/neuralfraud/grafana-prtg/wiki). -{{% /admonition %}} - -### [Monasca](https://grafana.com/grafana/plugins/monasca-datasource/) - -Latest Version: 1.0.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/openstack/monasca-grafana-datasource) in the past 2 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -{{% admonition type="warning" %}} -Last updated to support Grafana v7. -{{% /admonition %}} - -### [Monitoring Art](https://grafana.com/grafana/plugins/monitoringartist-monitoringart-datasource/) - -Latest Version: 1.0.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/monitoringartist/grafana-monitoring-art) in the past 6 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [GoogleCalendar](https://grafana.com/grafana/plugins/mtanda-google-calendar-datasource/) - -Latest Version: 1.0.5 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/mtanda/grafana-google-calendar-datasource) in the past 2 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [USGS Water Services](https://grafana.com/grafana/plugins/natel-usgs-datasource/) - -Latest Version: 0.0.3 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/NatelEnergy/natel-usgs-datasource) in the past 3 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [KapacitorSimpleJson](https://grafana.com/grafana/plugins/paytm-kapacitor-datasource/) - -Latest Version: 0.1.3 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/paytm/kapacitor-grafana-datasource-plugin) in the past 4 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Ambari Metrics](https://grafana.com/grafana/plugins/praj-ams-datasource/) - -Latest Version: 1.2.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/prajwalrao/ambari-metrics-grafana) in the past 5 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Solr](https://grafana.com/grafana/plugins/pue-solr-datasource/) - -Latest Version: 1.0.3 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Unclear progress on migration to React - [issue](https://github.com/pueteam/datasource-plugin-solr/issues/12). -{{% /admonition %}} - -> **Migration available - potential alternative:** Users could configure the solr-exporter for Prometheus as described [here](https://solr.apache.org/guide/solr/latest/deployment-guide/monitoring-with-prometheus-and-grafana.html). - -### [QuasarDB](https://grafana.com/grafana/plugins/quasardb-datasource/) - -Latest Version: 3.8.3 | Signature: Community | Last Updated: 2021 - -### [Blueflood](https://grafana.com/grafana/plugins/rackerlabs-blueflood-datasource/) - -Latest Version: 0.0.3 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/rax-maas/blueflood-grafana) in the past 7 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [NetXMS](https://grafana.com/grafana/plugins/radensolutions-netxms-datasource/) - -Latest Version: 1.2.3 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/netxms/grafana) in the past 2 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Sidewinder](https://grafana.com/grafana/plugins/sidewinder-datasource/) - -Latest Version: 0.2.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/srotya/sidewinder-grafana) in the past 5 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Skydive](https://grafana.com/grafana/plugins/skydive-datasource/) - -Latest Version: 1.2.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/skydive-project/skydive-grafana-datasource) in the past 4 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -{{% admonition type="warning" %}} -Issues suggest the entire project, not just the plugin, may be abandoned - [source](https://github.com/skydive-project/skydive/issues/2417). -{{% /admonition %}} - -### [Altinity plugin for ClickHouse](https://grafana.com/grafana/plugins/vertamedia-clickhouse-datasource/) - -Latest Version: 2.5.3 | Signature: Community | Last Updated: 2022 +This page explains how Grafana users might be impacted by the removal of Angular support based on plugins dependent on this legacy framework. You will also see if there is a migration option available for a given plugin. {{% admonition type="note" %}} -The [migration issue](https://github.com/Altinity/clickhouse-grafana/issues/475) has been assigned to a new major version milestone. -{{% /admonition %}} - -### [Pagerduty](https://grafana.com/grafana/plugins/xginn8-pagerduty-datasource/) - -Latest Version: 0.2.2 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/xginn8/grafana-pagerduty) in the past year suggests project _may_ not be actively maintained. -{{% /admonition %}} - -{{% admonition type="warning" %}} -Plugin only claims support for Grafana v5. -{{% /admonition %}} - -### [Chaos Mesh](https://grafana.com/grafana/plugins/yeya24-chaosmesh-datasource/) - -Latest Version: 0.2.3 | Signature: Community | Last Updated: 2022 - -{{% admonition type="warning" %}} -Plugin declares itself deprecated in favour of [chaosmeshorg-datasource](https://grafana.com/grafana/plugins/chaosmeshorg-datasource/) which also appears above in this list with warnings around its future. -{{% /admonition %}} - -## Panels - -### [FlowCharting](https://grafana.com/grafana/plugins/agenty-flowcharting-panel/) - -Latest Version: 0.9.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/algenty/grafana-flowcharting) in the past year suggests project _may_ not be actively maintained. -{{% /admonition %}} - -> **Migration available - potential alternative:** Grafana provides the native [Canvas]({{< relref "../../panels-visualizations/visualizations/canvas/" >}}) panel. - -### [HTML](https://grafana.com/grafana/plugins/aidanmountford-html-panel/) - -Latest Version: 0.0.2 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/aidanmountford/aidanmountford-html-panel) in the past 4 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -> **Migration available - potential alternative:** The [Text]({{< relref "../../panels-visualizations/visualizations/text/#html" >}}) panel included with Grafana supports rendering HTML content. Other plugins also exist which provide similar capabilities - [HTML](https://grafana.com/grafana/plugins/gapit-htmlgraphics-panel/) and [Dynamic Text](https://grafana.com/grafana/plugins/marcusolsson-dynamictext-panel/). - -### [Track Map](https://grafana.com/grafana/plugins/alexandra-trackmap-panel/) - -Latest Version: 1.2.6 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -[Issue](https://github.com/alexandrainst/alexandra-trackmap-panel/issues/72#issuecomment-1332179974) suggests problems with ongoing maintenance unless new contributors are found. -{{% /admonition %}} - -{{% admonition type="warning" %}} -[Migration issue](https://github.com/alexandrainst/alexandra-trackmap-panel/issues/105) has been marked as needing help. -{{% /admonition %}} - -### [PictureIt](https://grafana.com/grafana/plugins/bessler-pictureit-panel/) - -Latest Version: 1.0.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/vbessler/grafana-pictureit) in the past 6 years suggests project _may_ not be actively maintained. +We are greatly appreciative of the developers who have contributed plugins to the Grafana ecosystem. Guidance on migrating a plugin to React can be found in our [migration guide](/developers/plugin-tools/migration-guides/migrate-angularjs-to-react). {{% /admonition %}} -> **Migration available - potential alternative:** another plugin exists which provides similar capabilities - [ePict](https://grafana.com/grafana/plugins/larona-epict-panel/). - -### [Singlestat Math](https://grafana.com/grafana/plugins/blackmirror1-singlestat-math-panel/) - -Latest Version: 1.1.8 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/black-mirror-1/singlestat-math) in the past 5 years suggests project _may_ not be actively maintained. -{{% /admonition %}} +## What should I do with the list of AngularJS plugins? -### [Status By Group Panel](https://grafana.com/grafana/plugins/blackmirror1-statusbygroup-panel/) +Refer to the [table below](#angularjs-based-plugins) and take the appropriate action for you. -Latest Version: 1.1.2 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/black-mirror-1/Grafana_Status_panel) in the past 5 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Datatable Panel](https://grafana.com/grafana/plugins/briangann-datatable-panel/) - -Latest Version: 1.0.3 | Signature: Community | Last Updated: 2021 +- Consider the advice on whether to update, migrate to a listed alternative, or explore the Grafana plugins [catalog](/grafana/plugins) to find the most suitable option for your use case. +- Use our [detect-angular-dashboards](https://github.com/grafana/detect-angular-dashboards) open source tooling to list dashboards which have a dependency on Angular plugins. +- Check your Grafana instances for usage of these plugins. Refer to the documentation on [browsing installed plugins]({{< relref "../../administration/plugin-management/#browse-plugins" >}}). +- Customers of Grafana Enterprise and users of Grafana Cloud can also leverage [usage insights]({{< relref "../../dashboards/assess-dashboard-usage/" >}}) to prioritize any migration efforts. +- Review the plugin source repositories to add your support to any migration issues or consider forking the repo. {{% admonition type="note" %}} -Migration to React is planned - [issue](https://github.com/briangann/grafana-datatable-panel/issues/174). -{{% /admonition %}} - -### [GeoLoop](https://grafana.com/grafana/plugins/citilogics-geoloop-panel/) - -Latest Version: 1.1.2 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/CitiLogics/citilogics-geoloop-panel) in the past 2 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Progress List](https://grafana.com/grafana/plugins/corpglory-progresslist-panel/) - -Latest Version: 1.0.6 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/CorpGlory/grafana-progress-list) in the past 2 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Bubble Chart](https://grafana.com/grafana/plugins/digrich-bubblechart-panel/) - -Latest Version: 1.2.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/digrich/bubblechart-panel) in the past 3 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Blendstat](https://grafana.com/grafana/plugins/farski-blendstat-panel/) - -Latest Version: 1.0.3 | Signature: Community | Last Updated: 2021 - -> **Migration available - potential alternative:** plugin author recommends use of single stat panel and transformations functionality - [source](https://github.com/farski/blendstat-grafana/issues/11#issuecomment-1112158909). - -### [WindRose](https://grafana.com/grafana/plugins/fatcloud-windrose-panel/) - -Latest Version: 0.7.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/fatcloud/windrose-panel) in the past 4 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -> **Migration available - potential alternative:** another plugin exists which provides similar capabilities - [Operator Windrose](https://grafana.com/grafana/plugins/operato-windrose-panel/) - -### [Statusmap](https://grafana.com/grafana/plugins/flant-statusmap-panel/) - -Latest Version: 0.5.1 | Signature: Community | Last Updated: 2022 - -{{% admonition type="warning" %}} -Unknown whether migration to React will be undertaken - [migration issue](https://github.com/flant/grafana-statusmap/issues/302). -{{% /admonition %}} - -### [Singlestat](https://grafana.com/grafana/plugins/grafana-singlestat-panel/) - -Latest Version: 2.0.0 | Signature: Grafana | Last Updated: 2022 - -> **Migration available - plugin superseded:** Singlestat plugin was replaced by the [Stat]({{< relref "../../panels-visualizations/visualizations/stat/" >}})panel included in Grafana. - -### [Worldmap Panel](https://grafana.com/grafana/plugins/grafana-worldmap-panel/) - -Latest Version: 1.0.3 | Signature: Grafana | Last Updated: 2023 - -> **Migration available - plugin superseded:** Worldmap plugin was replaced by [Geomap]({{< relref "../../panels-visualizations/visualizations/geomap/" >}}) panel included in Grafana. - -### [Topology Panel](https://grafana.com/grafana/plugins/gretamosa-topology-panel/) - -Latest Version: 1.0.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/gretamosa/gretamosa-topology-panel) in the past 4 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [SVG](https://grafana.com/grafana/plugins/marcuscalidus-svg-panel/) - -Latest Version: 0.3.4 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/MarcusCalidus/marcuscalidus-svg-panel) in the past year suggests project _may_ not be actively maintained. -{{% /admonition %}} - -> **Migration available - potential alternative:** Grafana provides the native [Canvas]({{< relref "../../panels-visualizations/visualizations/canvas/" >}}) panel. - -> **Migration available - potential alternative:** other plugins exist which provide similar capabilities - [HTML](https://grafana.com/grafana/plugins/gapit-htmlgraphics-panel/), [Dynamic Text](https://grafana.com/grafana/plugins/marcusolsson-dynamictext-panel/) and [ACE.SVG](https://grafana.com/grafana/plugins/aceiot-svg-panel/). Note that the ACE.SVG panel has compatibility issues with Grafana versions 10.0.0-10.1.0. - -### [Annunciator](https://grafana.com/grafana/plugins/michaeldmoore-annunciator-panel/) - -Latest Version: 1.1.0 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Plugin developer has indicated they will retire the plugin once Angular support is discontinued - [source](https://github.com/michaeldmoore/michaeldmoore-annunciator-panel/issues/24#issuecomment-1479372673). -{{% /admonition %}} - -### [Multistat](https://grafana.com/grafana/plugins/michaeldmoore-multistat-panel/) - -Latest Version: 1.7.2 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Plugin developer has indicated they will retire the plugin once Angular support is discontinued - [source](https://github.com/michaeldmoore/michaeldmoore-multistat-panel/issues/71#issuecomment-1479372977). -{{% /admonition %}} - -### [HeatmapEpoch](https://grafana.com/grafana/plugins/mtanda-heatmap-epoch-panel/) - -Latest Version: 0.1.8 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Plugin advises caution as not stable; [project repository](https://github.com/mtanda/grafana-heatmap-epoch-panel) has not been updated in 7 years. -{{% /admonition %}} - -> **Migration available - potential alternative:** Other Heatmap panels exist including natively in Grafana - [learn more]({{< relref "../../panels-visualizations/visualizations/heatmap/" >}}). - -### [Histogram](https://grafana.com/grafana/plugins/mtanda-histogram-panel/) - -Latest Version: 0.1.7 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/mtanda/grafana-histogram-panel) in the past 7 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -> **Migration available - potential alternative:** other Histogram panels exist including natively in Grafana - [learn more]({{< relref "../../panels-visualizations/visualizations/histogram/" >}}). - -### [Separator](https://grafana.com/grafana/plugins/mxswat-separator-panel/) - -Latest Version: 1.0.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/mxswat/grafana-separator-panel) in the past 5 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -> **Migration available - potential alternative:** the [Text]({{< relref "../../panels-visualizations/visualizations/text/#html" >}}) panel can be used with no data to provide space within dashboards. - -### [Discrete](https://grafana.com/grafana/plugins/natel-discrete-panel/) - -Latest Version: 0.1.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/NatelEnergy/grafana-discrete-panel) in the past 3 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Influx Admin](https://grafana.com/grafana/plugins/natel-influx-admin-panel/) - -Latest Version: 0.0.6 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/NatelEnergy/grafana-influx-admin) in the past 5 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Plotly](https://grafana.com/grafana/plugins/natel-plotly-panel/) - -Latest Version: 0.0.7 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/NatelEnergy/grafana-plotly-panel) in the past 2 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -> **Migration available - potential alternative:** other plugins exist which provide similar capabilities - [nline-plotlyjs-panel](https://grafana.com/grafana/plugins/nline-plotlyjs-panel/) and [ae3e-plotly-panel](https://grafana.com/grafana/plugins/ae3e-plotly-panel/). - -### [Cal-HeatMap](https://grafana.com/grafana/plugins/neocat-cal-heatmap-panel/) - -Latest Version: 0.0.4 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Plugin advises caution as not stable; [project repository](https://github.com/NeoCat/grafana-cal-heatmap-panel) has not been updated in 7 years. -{{% /admonition %}} - -> **Migration available - potential alternative:** other Heatmap panels exist including natively in Grafana - [learn more]({{< relref "../../panels-visualizations/visualizations/heatmap/" >}}). - -### [Annotation Panel](https://grafana.com/grafana/plugins/novalabs-annotations-panel/) - -Latest Version: 0.0.2 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/novalabs/grafana-annotations-panel) in the past 6 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Carpet plot](https://grafana.com/grafana/plugins/petrslavotinek-carpetplot-panel/) - -Latest Version: 0.1.2 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/petrslavotinek/grafana-carpetplot) in the past 6 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [TrackMap](https://grafana.com/grafana/plugins/pr0ps-trackmap-panel/) - -Latest Version: 2.1.4 | Signature: Community | Last Updated: 2023 - -{{% admonition type="warning" %}} -Unknown whether migration to React will be undertaken - [migration issue](https://github.com/pR0Ps/grafana-trackmap-panel/issues/84). -{{% /admonition %}} - -### [AJAX](https://grafana.com/grafana/plugins/ryantxu-ajax-panel/) - -Latest Version: 0.1.0 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/ryantxu/ajax-panel) in the past 2 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [3D Globe Panel](https://grafana.com/grafana/plugins/satellogic-3d-globe-panel/) - -Latest Version: 0.1.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/satellogic/grafana-3d-globe-panel) in the past 5 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Heatmap](https://grafana.com/grafana/plugins/savantly-heatmap-panel/) - -Latest Version: 0.2.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/savantly-net/grafana-heatmap) in the past 6 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -> **Migration available - potential alternative:** other Heatmap panels exist including natively in Grafana - [learn more]({{< relref "../../panels-visualizations/visualizations/heatmap/" >}}). - -### [TrafficLight](https://grafana.com/grafana/plugins/smartmakers-trafficlight-panel/) - -Latest Version: 1.0.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/smartmakers/grafana-trafficlight) in the past 5 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Radar Graph](https://grafana.com/grafana/plugins/snuids-radar-panel/) - -Latest Version: 1.5.1 | Signature: Community | Last Updated: 2022 - -{{% admonition type="warning" %}} -Unknown whether migration to React will be undertaken - [migration issue](https://github.com/snuids/grafana-radar-panel/issues/29). -{{% /admonition %}} - -### [Traffic Lights](https://grafana.com/grafana/plugins/snuids-trafficlights-panel/) - -Latest Version: 1.6.0 | Signature: Community | Last Updated: 2023 - -{{% admonition type="warning" %}} -Unknown whether migration to React will be undertaken - [migration issue](https://github.com/snuids/trafficlights-panel/issues/44). -{{% /admonition %}} - -### [Status Panel](https://grafana.com/grafana/plugins/vonage-status-panel/) - -Latest Version: 1.0.11 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/Vonage/Grafana_Status_panel) in the past 3 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Boom Table](https://grafana.com/grafana/plugins/yesoreyeram-boomtable-panel/) - -Latest Version: 1.4.1 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Lack of recent activity in the [project repository](https://github.com/yesoreyeram/yesoreyeram-boomtable-panel) in the past 3 years suggests project _may_ not be actively maintained. -{{% /admonition %}} - -### [Parity Report](https://grafana.com/grafana/plugins/zuburqan-parity-report-panel/) - -Latest Version: 1.2.2 | Signature: Community | Last Updated: 2021 - -{{% admonition type="warning" %}} -Unknown whether migration to React will be undertaken - [migration issue](https://github.com/zuburqan/grafana-parity-report/issues/17). -{{% /admonition %}} +If you want to add any specific migration guidance for your plugin here or update our assessment, please open a PR by clicking **Suggest an edit** at the bottom of this page. +{{% /admonition %}} + +## Private plugins + +Grafana OSS and Grafana Enterprise support the creation of private plugins for use on local instances. These plugins may have a dependency on AngularJS and require an update. + +The `detect-angular-dashboards` tool listed above will include private plugins in its report **if the Grafana version is v10.1.0 or later**. + +Additionally, warning icons and messages will be displayed when browsing the catalog via **Administration** > **Plugins and Data** > **Plugins** in your local instance. + +## Automatic migration of plugins + +Certain legacy Grafana panel plugins automatically update to their React-based replacements when Angular support is disabled. This migration is usually available within the panel options, as shown in the screenshot below for World Map. Automatic migration can be triggered by setting the feature toggle `autoMigrateOldPanels` to `true`. + +Automatic migration is supported for the plugins shown in the following table. Each of the target plugins are included in Grafana as Core plugins which don't require installation. + +| Plugin | Migration target | +| ----------- | ---------------- | +| Graph (old) | Time Series | +| Singlestat | Stat | +| Stat (old) | Stat | +| Table (old) | Table | +| Worldmap | Geomap | + +A dashboard must still be saved with the new plugin ID to persist the change. + +# AngularJS-based plugins + +This table lists plugins which we have detected as having a dependency on AngularJS. For alternatives, consider included [Visualizations]({{< relref "../../panels-visualizations/visualizations" >}}) and [Data sources]({{< relref "../../datasources" >}}), as well as external plugins from the [catalog](/grafana/plugins). + +| Plugin ID | Name | Action | +| ----------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| grafana-worldmap-panel | Worldmap Panel | Migrate - [Geomap]({{< relref "../../panels-visualizations/visualizations/geomap" >}}) (core) replaced Worldmap - Note this should migrate when Angular is disabled. | +| natel-discrete-panel | Discrete | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| vonage-status-panel | Status Panel | Migrate - Consider [Stat]({{< relref "../../panels-visualizations/visualizations/stat" >}}) (core) or [Polystat](https://grafana.com/grafana/plugins/grafana-polystat-panel/) as potential alternatives. | +| grafana-simple-json-datasource | SimpleJson | Migrate - Consider [Infinity](https://grafana.com/grafana/plugins/yesoreyeram-infinity-datasource/) as a potential alternative. | +| natel-plotly-panel | Plotly | Migrate - Consider alternative [nline-plotlyjs-panel](https://grafana.com/grafana/plugins/nline-plotlyjs-panel/) plugin. | +| agenty-flowcharting-panel | FlowCharting | Migrate - Consider [Canvas]({{< relref "../../panels-visualizations/visualizations/canvas" >}}) (core) or [Diagram](https://grafana.com/grafana/plugins/jdbranham-diagram-panel/) as potential alternatives. | +| camptocamp-prometheus-alertmanager-datasource | Prometheus AlertManager | Update - Note the minimum version for React is 2.0.0. | +| briangann-gauge-panel | D3 Gauge | Update - Note the minimum version for React is 2.0.0. We recommend the latest. | +| yesoreyeram-boomtable-panel | Boom Table | Migrate - Consider [Table]({{< relref "../../panels-visualizations/visualizations/table" >}}) (core) and [transformations]({{< relref "../../panels-visualizations/query-transform-data/transform-data/" >}}) as appropriate. | +| briangann-datatable-panel | Datatable Panel | Wait - New version with React migration is planned. | +| flant-statusmap-panel | Statusmap | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| michaeldmoore-multistat-panel | Multistat | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| pr0ps-trackmap-panel | TrackMap | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| snuids-trafficlights-panel | Traffic Lights | Migrate - Consider [Traffic Light](https://grafana.com/grafana/plugins/heywesty-trafficlight-panel/) as a potential alternative. | +| vertamedia-clickhouse-datasource | Altinity plugin for ClickHouse | Update - Note the minimum version for React is 3.0.0. | +| petrslavotinek-carpetplot-panel | Carpet plot | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| ryantxu-ajax-panel | AJAX | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| michaeldmoore-annunciator-panel | Annunciator | Migrate - Consider [Stat]({{< relref "../../panels-visualizations/visualizations/stat" >}}) (core). | +| marcuscalidus-svg-panel | SVG | Migrate - Consider alternatives such as [Canvas]({{< relref "../../panels-visualizations/visualizations/canvas" >}}) (core), [Colored SVG](https://grafana.com/grafana/plugins/snuids-svg-panel/), or others. | +| neocat-cal-heatmap-panel | Cal-HeatMap | Migrate - Consider [Heatmap]({{< relref "../../panels-visualizations/visualizations/heatmap" >}}) (core) visualization. | +| blackmirror1-singlestat-math-panel | Singlestat Math | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| scadavis-synoptic-panel | SCADAvis Synoptic Panel | Update - Note the minimum version for React is 2.0. | +| farski-blendstat-panel | Blendstat | Migrate - Consider [Stat]({{< relref "../../panels-visualizations/visualizations/stat" >}}) (core) and [transformations]({{< relref "../../panels-visualizations/query-transform-data/transform-data/" >}}) as appropriate. | +| savantly-heatmap-panel | Heatmap | Migrate - Consider [Heatmap]({{< relref "../../panels-visualizations/visualizations/heatmap" >}}) (core) visualization. | +| mtanda-histogram-panel | Histogram | Migrate - Consider included [Histogram]({{< relref "../../panels-visualizations/visualizations/histogram" >}}) visualization. | +| snuids-radar-panel | Radar Graph | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| fatcloud-windrose-panel | WindRose | Migrate - Consider alternative [Operator Windrose](https://grafana.com/grafana/plugins/operato-windrose-panel/) plugin. | +| bessler-pictureit-panel | PictureIt | Migrate - Consider alternative [ePict](https://grafana.com/grafana/plugins/larona-epict-panel/) plugin. | +| digrich-bubblechart-panel | Bubble Chart | Update - Note the minimum version for React is 2.0.1. We recommend the latest. | +| corpglory-progresslist-panel | Progress List | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| aidanmountford-html-panel | HTML | Migrate - Consider alternatives such as [Text]({{< relref "../../panels-visualizations/visualizations/text" >}}) (core), [HTML](https://grafana.com/grafana/plugins/gapit-htmlgraphics-panel), or others. | +| fifemon-graphql-datasource | GraphQL Data Source | Wait - Removal of AngularJS is planned. Consider [Infinity](https://grafana.com/grafana/plugins/yesoreyeram-infinity-datasource/) plugin as alternative. | +| goshposh-metaqueries-datasource | MetaQueries | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| mxswat-separator-panel | Separator | Migrate - Consider alternative [Text]({{< relref "../../panels-visualizations/visualizations/text" >}}) panel (core) which can be empty and used as a separator. | +| natel-influx-admin-panel | Influx Admin | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| doitintl-bigquery-datasource | Google BigQuery | Migrate - Consider [Grafana Big Query](https://grafana.com/grafana/plugins/grafana-bigquery-datasource/) plugin. | +| satellogic-3d-globe-panel | 3D Globe Panel | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| foursquare-clouderamanager-datasource | Cloudera Manager | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| grafana-splunk-datasource | Splunk | Update - Note the minimum version for React is 4.1.0. We recommend the latest. | +| grafana-singlestat-panel | Singlestat | Migrate - Consider [Stat]({{< relref "../../panels-visualizations/visualizations/stat" >}}) panel (core). | +| blackmirror1-statusbygroup-panel | Status By Group Panel | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| novalabs-annotations-panel | Annotation Panel | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| jasonlashua-prtg-datasource | PRTG | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| ryantxu-annolist-panel | Annotation List | Migrate - Consider [annotations list]({{< relref "../../panels-visualizations/visualizations/annotations" >}}) (core). | +| cloudflare-app | Cloudflare Grafana App | Migrate - Consider using the [Cloudflare Dashboard](https://dash.cloudflare.com/?to=/:account/:zone/analytics/dns) or [DNS Analytics API](https://developers.cloudflare.com/api/operations/dns-analytics-table). | +| smartmakers-trafficlight-panel | TrafficLight | Migrate - Consider [Traffic Light](https://grafana.com/grafana/plugins/heywesty-trafficlight-panel/) as a potential alternative. | +| zuburqan-parity-report-panel | Parity Report | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| citilogics-geoloop-panel | GeoLoop | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| xginn8-pagerduty-datasource | Pagerduty | Wait - We are developing an Enterprise plugin for Pagerduty targeted for availability in Q1 2024. Note that all roadmap items are subject to change. | +| gretamosa-topology-panel | Topology Panel | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| fzakaria-simple-annotations-datasource | Simple Annotations | Migrate - Check for annotations support within your data sources to remove dependency on this plugin. | +| oci-metrics-datasource | Oracle Cloud Infrastructure Metrics | Update - Note the minimum version for React is 5.0.0. | +| abhisant-druid-datasource | Druid | Migrate - Migrate to replacement [Druid](https://grafana.com/grafana/plugins/grafadruid-druid-datasource/) plugin. | +| devopsprodigy-kubegraf-app | DevOpsProdigy KubeGraf | Migrate - Consider [Grafana Kubernetes Monitoring](https://grafana.com/solutions/kubernetes/) (Grafana Cloud only). | +| mtanda-heatmap-epoch-panel | HeatmapEpoch | Migrate - Consider [Heatmap]({{< relref "../../panels-visualizations/visualizations/heatmap" >}}) (core) visualization. | +| alexandra-trackmap-panel | Track Map | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| gnocchixyz-gnocchi-datasource | Gnocchi | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| tencentcloud-monitor-app | Tencent Cloud Monitor | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| andig-darksky-datasource | DarkSky | Remove - Note that support for the DarkSky API was ended by Apple in March 2023. | +| mtanda-google-calendar-datasource | GoogleCalendar | Wait - Migration to React is planned. | +| ntop-ntopng-datasource | ntopng | Migrate - Consider [InfluxDB]({{< relref "../../datasources/influxdb/" >}}) (core), with additional guidance available [here](https://www.ntop.org/guides/ntopng/basic_concepts/timeseries.html#influxdb-driver). | +| ayoungprogrammer-finance-datasource | Finance | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| grafana-kairosdb-datasource | KairosDB | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| fastweb-openfalcon-datasource | Open-Falcon | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| praj-ams-datasource | Ambari Metrics | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| monasca-datasource | Monasca | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| grafana-strava-datasource | Strava | Update - Note the minimum version for React is 1.6.0. We recommend the latest. | +| gridprotectionalliance-osisoftpi-datasource | OSIsoft-PI | Update - Note the minimum version for React is 4.0.0. We recommend the latest. | +| monitoringartist-monitoringart-datasource | Monitoring Art | Migrate - Browse included visualizations and plugins catalog for potential alternatives. | +| hawkular-datasource | Hawkular | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| ovh-warp10-datasource | Warp 10 | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| natel-usgs-datasource | USGS Water Services | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| radensolutions-netxms-datasource | NetXMS | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| ibm-apm-datasource | IBM APM | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| cognitedata-datasource | Cognite Data Fusion | Update - Note the minimum version for React is 4.0.0. We recommend the latest. | +| linksmart-sensorthings-datasource | LinkSmart SensorThings | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| pue-solr-datasource | Solr | Migrate - Consider this [guidance](https://solr.apache.org/guide/solr/latest/deployment-guide/monitoring-with-prometheus-and-grafana.html) on using solr-exporter for prometheus. | +| paytm-kapacitor-datasource | KapacitorSimpleJson | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| oci-logs-datasource | Oracle Cloud Infrastructure Logs | Update - Note the minimum version for React is 4.0.0. | +| gridprotectionalliance-openhistorian-datasource | openHistorian | Wait - Note that new version with React migration is planned. | +| devicehive-devicehive-datasource | DeviceHive | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| rackerlabs-blueflood-datasource | Blueflood | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| udoprog-heroic-datasource | Heroic | Migrate - Note that Heroic DB has been discontinued. | +| akumuli-datasource | Akumuli | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| bmchelix-ade-datasource | BMC Helix | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| sidewinder-datasource | Sidewinder | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| linksmart-hds-datasource | LinkSmart HDS Datasource | Migrate - browse included data sources and plugins catalog for potential alternatives. | +| skydive-datasource | Skydive | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| quasardb-datasource | QuasarDB | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| spotify-heroic-datasource | Heroic | Migrate - Note that Heroic DB has been discontinued. | +| grafana-es-open-distro-datasource | Open Distro for Elasticsearch | Migrate - Note that [OpenSearch](https://grafana.com/grafana/plugins/grafana-opensearch-datasource/) replaced Open Distro for Elasticseach. | +| humio-datasource | Humio | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| yeya24-chaosmesh-datasource | Chaos Mesh | Migrate - Note that plugin was replaced by [chaosmeshorg-datasource](https://grafana.com/grafana/plugins/chaosmeshorg-datasource/). | +| kentik-connect-app | Kentik Connect Pro | Update - Note the minimum version for React is 1.7.0. | +| chaosmeshorg-datasource | Chaos Mesh | Update - Note the minimum version for React is 3.0.0. | +| aquaqanalytics-kdbadaptor-datasource | kdb+ | Migrate - Note that [kdb+ backend](https://grafana.com/grafana/plugins/aquaqanalytics-kdbbackend-datasource) replaced kdb.+. | +| alexanderzobnin-zabbix-app | Zabbix | Update - Note the minimum version for React is 4.3.0. We recommend the latest. Recently brought under Grafana signature. | +| bosun-app | Bosun | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| belugacdn-app | BelugaCDN | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| grafana-azure-data-explorer-datasource | Azure Data Explorer Datasource | Update - The minimum supported version is 3.5.1. We recommend the latest. | +| ddurieux-glpi-app | glpi | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| fetzerch-sunandmoon-datasource | Sun and Moon | Update - Note the minimum version for React is 0.3.0. | +| grafana-clock-panel | Clock | Update - Note the minimum version for React is 1.1.0. We recommend the latest. | +| grafana-github-datasource | GitHub | Update - Note the minimum version for React is 1.3.3. We recommend the latest. | +| grafana-datadog-datasource | Datadog | Update - Note the minimum version for React is 3.0.0. We recommend the latest. | +| grafana-gitlab-datasource | Gitlab | Update - Note the minimum version for React is 1.1.0. We recommend the latest. | +| grafana-iot-twinmaker-app | AWS IoT TwinMaker App | Update - Note the minimum version for React is 1.6.3. We recommend the latest. | +| grafana-newrelic-datasource | New Relic | Update - Note the minimum version for React is 3.0.0. We recommend the latest. | +| grafana-opensearch-datasource | Opensearch | Update - Note the minimum version for React is 2.0.0. We recommend the latest. | +| grafana-oracle-datasource | Oracle | Update - Note the minimum version for React is 2.0.6. We recommend the latest. | +| grafana-piechart-panel | Pie Chart | Migrate - Note that [Pie Chart]({{< relref "../../panels-visualizations/visualizations/pie-chart" >}}) (core) replaced Pie Chart. | +| grafana-polystat-panel | Polystat | Update - Note the minimum version for React is 2.0.0. We recommend the latest. | +| grafana-servicenow-datasource | ServiceNow | Update - Note the minimum version for React is 2.0.2. We recommend the latest. | +| grafana-synthetic-monitoring-app | Synthetic Monitoring | Update - Note the minimum version for React is 0.7.3. We recommend the latest. | +| grafana-wavefront-datasource | Wavefront | Update - Note the minimum version for React is 2.0.0. We recommend the latest. | +| hadesarchitect-cassandra-datasource | Apache Cassandra | Update - Note the minimum version for React is 2.1.1. We recommend the latest. | +| instana-datasource | Instana | Update - Note the minimum version for React is 3.0.0. We recommend the latest. | +| jdbranham-diagram-panel | Diagram | Update - Note the minimum version for React is 1.7.1. We recommend the latest. | +| larona-epict-panel | ePict | Update - Note the minimum version for React is 2.0.0. We recommend the latest. | +| moogsoft-aiops-app | Moogsoft AIOps | Update - Note the minimum version for React is 9.0.0. | +| opennms-helm-app | OpenNMS Helm | Migrate - Note that [OpenNMS Plugin for Grafana](https://grafana.com/grafana/plugins/opennms-opennms-app/) replaced OpenNMS Helm. | +| percona-percona-app | Percona | Migrate - Consider use of [Percona dashboards](https://github.com/percona/grafana-dashboards/). | +| novatec-sdg-panel | Service Dependency Graph | Update - Note the minimum version for React is 4.0.3. | +| pierosavi-imageit-panel | ImageIt | Migrate - Consider [ePict](https://grafana.com/grafana/plugins/larona-epict-panel/) or browse plugins catalog for potential alternatives. | +| redis-app | Redis Application | Update - Note the minimum version for React is 1.2.0. We recommend the latest. | +| sbueringer-consul-datasource | Consul | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| simpod-json-datasource | JSON | Update - Note the minimum version for React is 0.3.0. We recommend the latest. | +| singlestat | Singlestat | Migrate - Note that [Stat]({{< relref "../../panels-visualizations/visualizations/stat" >}}) (core) replaced Singlestat. | +| sni-pnp-datasource | PNP | Update - Note the minimum version for React is 2.0.0. We recommend the latest. | +| sni-thruk-datasource | Thruk | Update - Note the minimum version for React is 2.0.0. We recommend the latest. | +| stagemonitor-elasticsearch-app | stagemonitor Elasticsearch | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| tdengine-datasource | TDengine Datasource | Update - Note the minimum version for React is 3.3.0. We recommend the latest. | +| vertica-grafana-datasource | Vertica | Update - Note the minimum version for React is 2.0.0. We recommend the latest. | +| voxter-app | Voxter VoIP Platform Metrics | Migrate - Browse included data sources and plugins catalog for potential alternatives. | +| graph | Graph (old) | Migrate - Note that this is replaced by [Time Series]({{< relref "../../panels-visualizations/visualizations/time-series" >}}) (core) - This plugin should migrate when Angular is disabled. Also consider Bar Chart or Histogram if appropriate. | +| table-old | Table (old) | Migrate - Note that this is replaced by [Table]({{< relref "../../panels-visualizations/visualizations/table" >}}) (core) - This plugin should migrate when AngularJS is disabled. | +| shorelinesoftware-shoreline-datasource | Shoreline Data Source | Update - Note the minimum version for React is 1.2.1. We recommend the latest. | diff --git a/docs/sources/developers/http_api/_index.md b/docs/sources/developers/http_api/_index.md index e986b3f1a56f4..806ab1f865051 100644 --- a/docs/sources/developers/http_api/_index.md +++ b/docs/sources/developers/http_api/_index.md @@ -27,7 +27,7 @@ Since version 8.4, HTTP API details are [specified](https://editor.swagger.io/?u Starting from version 9.1, there is also a [OpenAPI v3 specification](https://editor.swagger.io/?url=https://raw.githubusercontent.com/grafana/grafana/main/public/openapi3.json) (generated by the v2 one). -Users can browser and try out both via the Swagger UI editor (served by the grafana server) by navigating to `/swagger`. +Users can browser and try out both via the Swagger UI editor (served by the grafana server) by navigating to `/swagger-ui`. ## Authenticating API requests diff --git a/docs/sources/developers/http_api/access_control.md b/docs/sources/developers/http_api/access_control.md index 695f8d129e664..72c5aec8a7674 100644 --- a/docs/sources/developers/http_api/access_control.md +++ b/docs/sources/developers/http_api/access_control.md @@ -383,12 +383,12 @@ Content-Type: application/json #### Status codes -| Code | Description | -| ---- | ------------------------------------------------------------------------------------- | -| 200 | Role is updated. | -| 400 | Bad request (invalid json, missing content-type, missing or invalid fields, etc.). | -| 403 | Access denied (one of the specified permissions is not assigned to the the requester) | -| 500 | Unexpected error. Refer to body and/or server logs for more details. | +| Code | Description | +| ---- | ---------------------------------------------------------------------------------- | +| 200 | Role is updated. | +| 400 | Bad request (invalid json, missing content-type, missing or invalid fields, etc.). | +| 403 | Access denied (one of the specified permissions is not assigned to the requester) | +| 500 | Unexpected error. Refer to body and/or server logs for more details. | ### Update a role @@ -498,13 +498,13 @@ For more information, refer to [Create role validation errors]({{< ref "#create- #### Status codes -| Code | Description | -| ---- | ------------------------------------------------------------------------------------- | -| 200 | Role is updated. | -| 400 | Bad request (invalid json, missing content-type, missing or invalid fields, etc.). | -| 403 | Access denied (one of the specified permissions is not assigned to the the requester) | -| 404 | Role was not found to update. | -| 500 | Unexpected error. Refer to body and/or server logs for more details. | +| Code | Description | +| ---- | ---------------------------------------------------------------------------------- | +| 200 | Role is updated. | +| 400 | Bad request (invalid json, missing content-type, missing or invalid fields, etc.). | +| 403 | Access denied (one of the specified permissions is not assigned to the requester) | +| 404 | Role was not found to update. | +| 500 | Unexpected error. Refer to body and/or server logs for more details. | ### Delete a custom role @@ -532,7 +532,7 @@ Accept: application/json | Param | Type | Required | Description | | ------ | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| force | boolean | No | When set to `true`, the role will be deleted with all it's assignments. | +| force | boolean | No | When set to `true`, the role will be deleted with all its assignments. | | global | boolean | No | A flag indicating if the role is global or not. If set to false, the default org ID of the authenticated user will be used from the request. Refer to the [About RBAC]({{< relref "/docs/grafana/latest/administration/roles-and-permissions/access-control" >}}) for more information. | #### Example response diff --git a/docs/sources/developers/http_api/admin.md b/docs/sources/developers/http_api/admin.md index b7a40e1d7418c..cddfced04828e 100644 --- a/docs/sources/developers/http_api/admin.md +++ b/docs/sources/developers/http_api/admin.md @@ -473,45 +473,6 @@ Content-Type: application/json {"message": "User deleted"} ``` -## Pause all alerts - -`POST /api/admin/pause-all-alerts` - -{{% admonition type="note" %}} -This API is relevant for the [legacy dashboard alerts](https://grafana.com/docs/grafana/v8.5/alerting/old-alerting/) only. For default alerting, use silences to stop alerts from being delivered. -{{% /admonition %}} - -Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. - -**Example Request**: - -```http -POST /api/admin/pause-all-alerts HTTP/1.1 -Accept: application/json -Content-Type: application/json - -{ - "paused": true -} -``` - -JSON Body schema: - -- **paused** – If true then all alerts are to be paused, false unpauses all alerts. - -**Example Response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -{ - "state": "Paused", - "message": "alert paused", - "alertsAffected": 1 -} -``` - ## Auth tokens for User `GET /api/admin/users/:id/auth-tokens` @@ -654,8 +615,6 @@ Content-Type: application/json `POST /api/admin/provisioning/plugins/reload` -`POST /api/admin/provisioning/notifications/reload` - `POST /api/admin/provisioning/access-control/reload` `POST /api/admin/provisioning/alerting/reload` @@ -676,7 +635,6 @@ See note in the [introduction]({{< ref "#admin-api" >}}) for an explanation. | provisioning:reload | provisioners:dashboards | dashboards | | provisioning:reload | provisioners:datasources | datasources | | provisioning:reload | provisioners:plugins | plugins | -| provisioning:reload | provisioners:notifications | notifications | | provisioning:reload | provisioners:alerting | alerting | **Example Request**: diff --git a/docs/sources/developers/http_api/alerting.md b/docs/sources/developers/http_api/alerting.md deleted file mode 100644 index ceece602a88b7..0000000000000 --- a/docs/sources/developers/http_api/alerting.md +++ /dev/null @@ -1,184 +0,0 @@ ---- -aliases: - - ../../http_api/alerting/ -canonical: /docs/grafana/latest/developers/http_api/alerting/ -description: Grafana Alerts HTTP API -keywords: - - grafana - - http - - documentation - - api - - alerting - - alerts -labels: - products: - - enterprise - - oss -title: Legacy Alerting API ---- - -# Legacy Alerting API - -{{% admonition type="note" %}} -Starting with v9.0, the Legacy Alerting HTTP API is deprecated. It will be removed in a future release. -{{% /admonition %}} - -This topic is relevant for the [legacy dashboard alerts](/docs/grafana/v8.5/alerting/old-alerting/) only. - -If you are using Grafana Alerting, refer to [Alerting provisioning API]({{< relref "./alerting_provisioning" >}}) - -You can find Grafana Alerting API specification details [here](https://editor.swagger.io/?url=https://raw.githubusercontent.com/grafana/grafana/main/pkg/services/ngalert/api/tooling/post.json). Also, refer to [Grafana Alerting alerts documentation][] for details on how to create and manage new alerts. - -You can use the Alerting API to get information about legacy dashboard alerts and their states but this API cannot be used to modify the alert. -To create new alerts or modify them you need to update the dashboard JSON that contains the alerts. - -## Get alerts - -`GET /api/alerts/` - -**Example Request**: - -```http -GET /api/alerts HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk -``` - -Querystring Parameters: - -These parameters are used as querystring parameters. For example: - -`/api/alerts?dashboardId=1` - -- **dashboardId** – Limit response to alerts in specified dashboard(s). You can specify multiple dashboards, e.g. dashboardId=23&dashboardId=35. -- **panelId** – Limit response to alert for a specified panel on a dashboard. -- **query** - Limit response to alerts having a name like this value. -- **state** - Return alerts with one or more of the following alert states: `ALL`,`no_data`, `paused`, `alerting`, `ok`, `pending`. To specify multiple states use the following format: `?state=paused&state=alerting` -- **limit** - Limit response to _X_ number of alerts. -- **folderId** – Limit response to alerts of dashboards in specified folder(s). You can specify multiple folders, e.g. folderId=23&folderId=35. -- **dashboardQuery** - Limit response to alerts having a dashboard name like this value. -- **dashboardTag** - Limit response to alerts of dashboards with specified tags. To do an "AND" filtering with multiple tags, specify the tags parameter multiple times e.g. dashboardTag=tag1&dashboardTag=tag2. - -**Example Response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -[ - { - "id": 1, - "dashboardId": 1, - "dashboardUId": "ABcdEFghij" - "dashboardSlug": "sensors", - "panelId": 1, - "name": "fire place sensor", - "state": "alerting", - "newStateDate": "2018-05-14T05:55:20+02:00", - "evalDate": "0001-01-01T00:00:00Z", - "evalData": null, - "executionError": "", - "url": "http://grafana.com/dashboard/db/sensors" - } -] -``` - -## Get alert by id - -`GET /api/alerts/:id` - -**Example Request**: - -```http -GET /api/alerts/1 HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk -``` - -**Example Response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -{ - "id": 1, - "dashboardId": 1, - "dashboardUId": "ABcdEFghij" - "dashboardSlug": "sensors", - "panelId": 1, - "name": "fire place sensor", - "state": "alerting", - "message": "Someone is trying to break in through the fire place", - "newStateDate": "2018-05-14T05:55:20+02:00", - "evalDate": "0001-01-01T00:00:00Z", - "evalData": "evalMatches": [ - { - "metric": "movement", - "tags": { - "name": "fireplace_chimney" - }, - "value": 98.765 - } - ], - "executionError": "", - "url": "http://grafana.com/dashboard/db/sensors" -} -``` - -**Important Note**: -"evalMatches" data is cached in the db when and only when the state of the alert changes -(e.g. transitioning from "ok" to "alerting" state). - -If data from one server triggers the alert first and, before that server is seen leaving alerting state, -a second server also enters a state that would trigger the alert, the second server will not be visible in "evalMatches" data. - -## Pause alert by id - -`POST /api/alerts/:id/pause` - -**Example Request**: - -```http -POST /api/alerts/1/pause HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - -{ - "paused": true -} -``` - -The :id query parameter is the id of the alert to be paused or unpaused. - -JSON Body Schema: - -- **paused** – Can be `true` or `false`. True to pause an alert. False to unpause an alert. - -**Example Response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -{ - "alertId": 1, - "state": "Paused", - "message": "alert paused" -} -``` - -## Pause all alerts - -See [Admin API][]. - -{{% docs/reference %}} -[Admin API]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/developers/http_api/admin#pause-all-alerts" -[Admin API]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/developers/http_api/admin#pause-all-alerts" - -[Grafana Alerting alerts documentation]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting" -[Grafana Alerting alerts documentation]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/alerting" -{{% /docs/reference %}} diff --git a/docs/sources/developers/http_api/alerting_notification_channels.md b/docs/sources/developers/http_api/alerting_notification_channels.md deleted file mode 100644 index 440237b3287e1..0000000000000 --- a/docs/sources/developers/http_api/alerting_notification_channels.md +++ /dev/null @@ -1,424 +0,0 @@ ---- -aliases: - - ../../http_api/alerting_notification_channels/ -canonical: /docs/grafana/latest/developers/http_api/alerting_notification_channels/ -description: Grafana Alerting Notification Channel HTTP API -keywords: - - grafana - - http - - documentation - - api - - alerting - - alerts - - notifications -labels: - products: - - enterprise - - oss -title: Legacy Alerting Notification Channels API ---- - -# Legacy Alerting Notification Channels API - -{{% admonition type="note" %}} -Starting with v9.0, the Legacy Alerting Notification Channels API is deprecated. It will be removed in a future release. -{{% /admonition %}} - -This page documents the Alerting Notification Channels API. - -## Identifier (id) vs unique identifier (uid) - -The identifier (id) of a notification channel is an auto-incrementing numeric value and is only unique per Grafana install. - -The unique identifier (uid) of a notification channel can be used for uniquely identify a notification channel between -multiple Grafana installs. It's automatically generated if not provided when creating a notification channel. The uid -allows having consistent URLs for accessing notification channels and when syncing notification channels between multiple -Grafana installations, refer to [alert notification channel provisioning]({{< relref "/docs/grafana/latest/administration/provisioning#alert-notification-channels" >}}). - -The uid can have a maximum length of 40 characters. - -## Get all notification channels - -Returns all notification channels that the authenticated user has permission to view. - -`GET /api/alert-notifications` - -**Example request**: - -```http -GET /api/alert-notifications HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk -``` - -**Example response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -[ - { - "id": 1, - "uid": "team-a-email-notifier", - "name": "Team A", - "type": "email", - "isDefault": false, - "sendReminder": false, - "disableResolveMessage": false, - "settings": { - "addresses": "dev@grafana.com" - }, - "created": "2018-04-23T14:44:09+02:00", - "updated": "2018-08-20T15:47:49+02:00" - } -] - -``` - -## Get all notification channels (lookup) - -Returns all notification channels, but with less detailed information. Accessible by any authenticated user and is mainly used by providing alert notification channels in Grafana UI when configuring alert rule. - -`GET /api/alert-notifications/lookup` - -**Example request**: - -```http -GET /api/alert-notifications/lookup HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk -``` - -**Example response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -[ - { - "id": 1, - "uid": "000000001", - "name": "Test", - "type": "email", - "isDefault": false - }, - { - "id": 2, - "uid": "000000002", - "name": "Slack", - "type": "slack", - "isDefault": false - } -] - -``` - -## Get notification channel by uid - -`GET /api/alert-notifications/uid/:uid` - -Returns the notification channel given the notification channel uid. - -**Example request**: - -```http -GET /api/alert-notifications/uid/team-a-email-notifier HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk -``` - -**Example response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -{ - "id": 1, - "uid": "team-a-email-notifier", - "name": "Team A", - "type": "email", - "isDefault": false, - "sendReminder": false, - "disableResolveMessage": false, - "settings": { - "addresses": "dev@grafana.com" - }, - "created": "2018-04-23T14:44:09+02:00", - "updated": "2018-08-20T15:47:49+02:00" -} -``` - -## Get notification channel by id - -`GET /api/alert-notifications/:id` - -Returns the notification channel given the notification channel id. - -**Example request**: - -```http -GET /api/alert-notifications/1 HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk -``` - -**Example response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -{ - "id": 1, - "uid": "team-a-email-notifier", - "name": "Team A", - "type": "email", - "isDefault": false, - "sendReminder": false, - "disableResolveMessage": false, - "settings": { - "addresses": "dev@grafana.com" - }, - "created": "2018-04-23T14:44:09+02:00", - "updated": "2018-08-20T15:47:49+02:00" -} -``` - -## Create notification channel - -You can find the full list of [supported notifiers](/docs/grafana/v8.5/alerting/old-alerting/notifications/) on the alert notifiers page. - -`POST /api/alert-notifications` - -**Example request**: - -```http -POST /api/alert-notifications HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - -{ - "uid": "new-alert-notification", // optional - "name": "new alert notification", //Required - "type": "email", //Required - "isDefault": false, - "sendReminder": false, - "settings": { - "addresses": "dev@grafana.com" - } -} -``` - -**Example response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -{ - "id": 1, - "uid": "new-alert-notification", - "name": "new alert notification", - "type": "email", - "isDefault": false, - "sendReminder": false, - "settings": { - "addresses": "dev@grafana.com" - }, - "created": "2018-04-23T14:44:09+02:00", - "updated": "2018-08-20T15:47:49+02:00" -} -``` - -## Update notification channel by uid - -`PUT /api/alert-notifications/uid/:uid` - -Updates an existing notification channel identified by uid. - -**Example request**: - -```http -PUT /api/alert-notifications/uid/cIBgcSjkk HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - -{ - "uid": "new-alert-notification", // optional - "name": "new alert notification", //Required - "type": "email", //Required - "isDefault": false, - "sendReminder": true, - "frequency": "15m", - "settings": { - "addresses": "dev@grafana.com" - } -} -``` - -**Example response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -{ - "id": 1, - "uid": "new-alert-notification", - "name": "new alert notification", - "type": "email", - "isDefault": false, - "sendReminder": true, - "frequency": "15m", - "settings": { - "addresses": "dev@grafana.com" - }, - "created": "2017-01-01 12:34", - "updated": "2017-01-01 12:34" -} -``` - -## Update notification channel by id - -`PUT /api/alert-notifications/:id` - -Updates an existing notification channel identified by id. - -**Example request**: - -```http -PUT /api/alert-notifications/1 HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - -{ - "id": 1, - "uid": "new-alert-notification", // optional - "name": "new alert notification", //Required - "type": "email", //Required - "isDefault": false, - "sendReminder": true, - "frequency": "15m", - "settings": { - "addresses": "dev@grafana.com" - } -} -``` - -**Example response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -{ - "id": 1, - "uid": "new-alert-notification", - "name": "new alert notification", - "type": "email", - "isDefault": false, - "sendReminder": true, - "frequency": "15m", - "settings": { - "addresses": "dev@grafana.com" - }, - "created": "2017-01-01 12:34", - "updated": "2017-01-01 12:34" -} -``` - -## Delete alert notification by uid - -`DELETE /api/alert-notifications/uid/:uid` - -Deletes an existing notification channel identified by uid. - -**Example request**: - -```http -DELETE /api/alert-notifications/uid/team-a-email-notifier HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk -``` - -**Example response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -{ - "message": "Notification deleted" -} -``` - -## Delete alert notification by id - -`DELETE /api/alert-notifications/:id` - -Deletes an existing notification channel identified by id. - -**Example request**: - -```http -DELETE /api/alert-notifications/1 HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk -``` - -**Example response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -{ - "message": "Notification deleted" -} -``` - -## Test notification channel - -Sends a test notification message for the given notification channel type and settings. -You can find the full list of [supported notifiers](/alerting/notifications/#all-supported-notifier) at the alert notifiers page. - -`POST /api/alert-notifications/test` - -**Example request**: - -```http -POST /api/alert-notifications/test HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - -{ - "type": "email", - "settings": { - "addresses": "dev@grafana.com" - } -} -``` - -**Example response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -{ - "message": "Test notification sent" -} -``` diff --git a/docs/sources/developers/http_api/alerting_provisioning.md b/docs/sources/developers/http_api/alerting_provisioning.md index c02a4bb02a440..0f9cfe3225a62 100644 --- a/docs/sources/developers/http_api/alerting_provisioning.md +++ b/docs/sources/developers/http_api/alerting_provisioning.md @@ -12,6 +12,7 @@ keywords: - alerts labels: products: + - cloud - enterprise - oss title: 'Alerting Provisioning HTTP API ' @@ -19,1525 +20,4 @@ title: 'Alerting Provisioning HTTP API ' # Alerting provisioning HTTP API -## Information - -### Version - -1.1.0 - -## Content negotiation - -### Consumes - -- application/json - -### Produces - -- application/json -- text/yaml -- application/yaml - -## All endpoints - -### Alert rules - -| Method | URI | Name | Summary | -| ------ | ------------------------------------------------------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------- | -| DELETE | /api/v1/provisioning/alert-rules/{UID} | [route delete alert rule](#route-delete-alert-rule) | Delete a specific alert rule by UID. | -| GET | /api/v1/provisioning/alert-rules/{UID} | [route get alert rule](#route-get-alert-rule) | Get a specific alert rule by UID. | -| GET | /api/v1/provisioning/alert-rules/{UID}/export | [route get alert rule export](#route-get-alert-rule-export) | Export an alert rule in provisioning file format. | -| GET | /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group} | [route get alert rule group](#route-get-alert-rule-group) | Get a rule group. | -| GET | /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export | [route get alert rule group export](#route-get-alert-rule-group-export) | Export an alert rule group in provisioning file format. | -| GET | /api/v1/provisioning/alert-rules | [route get alert rules](#route-get-alert-rules) | Get all the alert rules. | -| GET | /api/v1/provisioning/alert-rules/export | [route get alert rules export](#route-get-alert-rules-export) | Export all alert rules in provisioning file format. | -| POST | /api/v1/provisioning/alert-rules | [route post alert rule](#route-post-alert-rule) | Create a new alert rule. | -| PUT | /api/v1/provisioning/alert-rules/{UID} | [route put alert rule](#route-put-alert-rule) | Update an existing alert rule. | -| PUT | /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group} | [route put alert rule group](#route-put-alert-rule-group) | Update the interval of a rule group. | - -### Contact points - -**Note:** - -Contact point provisioning is for Grafana-managed alerts only. - -| Method | URI | Name | Summary | -| ------ | ------------------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------ | -| DELETE | /api/v1/provisioning/contact-points/{UID} | [route delete contactpoints](#route-delete-contactpoints) | Delete a contact point. | -| GET | /api/v1/provisioning/contact-points | [route get contactpoints](#route-get-contactpoints) | Get all the contact points. | -| GET | /api/v1/provisioning/contact-points/export | [route get contactpoints export](#route-get-contactpoints-export) | Export all contact points in provisioning file format. | -| POST | /api/v1/provisioning/contact-points | [route post contactpoints](#route-post-contactpoints) | Create a contact point. | -| PUT | /api/v1/provisioning/contact-points/{UID} | [route put contactpoint](#route-put-contactpoint) | Update an existing contact point. | - -### Notification policies - -| Method | URI | Name | Summary | -| ------ | ------------------------------------ | ------------------------------------------------------------- | ---------------------------------------------------------------- | -| DELETE | /api/v1/provisioning/policies | [route reset policy tree](#route-reset-policy-tree) | Clears the notification policy tree. | -| GET | /api/v1/provisioning/policies | [route get policy tree](#route-get-policy-tree) | Get the notification policy tree. | -| GET | /api/v1/provisioning/policies/export | [route get policy tree export](#route-get-policy-tree-export) | Export the notification policy tree in provisioning file format. | -| PUT | /api/v1/provisioning/policies | [route put policy tree](#route-put-policy-tree) | Sets the notification policy tree. | - -### Mute timings - -| Method | URI | Name | Summary | -| ------ | ---------------------------------------- | ----------------------------------------------------- | -------------------------------- | -| DELETE | /api/v1/provisioning/mute-timings/{name} | [route delete mute timing](#route-delete-mute-timing) | Delete a mute timing. | -| GET | /api/v1/provisioning/mute-timings/{name} | [route get mute timing](#route-get-mute-timing) | Get a mute timing. | -| GET | /api/v1/provisioning/mute-timings | [route get mute timings](#route-get-mute-timings) | Get all the mute timings. | -| POST | /api/v1/provisioning/mute-timings | [route post mute timing](#route-post-mute-timing) | Create a new mute timing. | -| PUT | /api/v1/provisioning/mute-timings/{name} | [route put mute timing](#route-put-mute-timing) | Replace an existing mute timing. | - -### Templates - -| Method | URI | Name | Summary | -| ------ | ------------------------------------- | ----------------------------------------------- | ------------------------------------------ | -| DELETE | /api/v1/provisioning/templates/{name} | [route delete template](#route-delete-template) | Delete a template. | -| GET | /api/v1/provisioning/templates/{name} | [route get template](#route-get-template) | Get a notification template. | -| GET | /api/v1/provisioning/templates | [route get templates](#route-get-templates) | Get all notification templates. | -| PUT | /api/v1/provisioning/templates/{name} | [route put template](#route-put-template) | Updates an existing notification template. | - -## Paths - -### <span id="route-delete-alert-rule"></span> Delete a specific alert rule by UID. (_RouteDeleteAlertRule_) - -``` -DELETE /api/v1/provisioning/alert-rules/{UID} -``` - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------- | -------- | ------ | -------- | --------- | :------: | ------- | --------------------------------------------------------- | -| UID | `path` | string | `string` | | ✓ | | Alert rule UID | -| X-Disable-Provenance | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ----------------------------------- | ---------- | ---------------------------------------- | :---------: | --------------------------------------------- | -| [204](#route-delete-alert-rule-204) | No Content | The alert rule was deleted successfully. | | [schema](#route-delete-alert-rule-204-schema) | - -#### Responses - -##### <span id="route-delete-alert-rule-204"></span> 204 - The alert rule was deleted successfully. - -Status: No Content - -###### <span id="route-delete-alert-rule-204-schema"></span> Schema - -### <span id="route-delete-contactpoints"></span> Delete a contact point. (_RouteDeleteContactpoints_) - -``` -DELETE /api/v1/provisioning/contact-points/{UID} -``` - -#### Consumes - -- application/json - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ------------------------------------------ | -| UID | `path` | string | `string` | | ✓ | | UID is the contact point unique identifier | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| -------------------------------------- | ---------- | ------------------------------------------- | :---------: | ------------------------------------------------ | -| [204](#route-delete-contactpoints-204) | No Content | The contact point was deleted successfully. | | [schema](#route-delete-contactpoints-204-schema) | - -#### Responses - -##### <span id="route-delete-contactpoints-204"></span> 204 - The contact point was deleted successfully. - -Status: No Content - -###### <span id="route-delete-contactpoints-204-schema"></span> Schema - -### <span id="route-delete-mute-timing"></span> Delete a mute timing. (_RouteDeleteMuteTiming_) - -``` -DELETE /api/v1/provisioning/mute-timings/{name} -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ---------------- | -| name | `path` | string | `string` | | ✓ | | Mute timing name | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ------------------------------------ | ---------- | ----------------------------------------- | :---------: | ---------------------------------------------- | -| [204](#route-delete-mute-timing-204) | No Content | The mute timing was deleted successfully. | | [schema](#route-delete-mute-timing-204-schema) | - -#### Responses - -##### <span id="route-delete-mute-timing-204"></span> 204 - The mute timing was deleted successfully. - -Status: No Content - -###### <span id="route-delete-mute-timing-204-schema"></span> Schema - -### <span id="route-delete-template"></span> Delete a template. (_RouteDeleteTemplate_) - -``` -DELETE /api/v1/provisioning/templates/{name} -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ------------- | -| name | `path` | string | `string` | | ✓ | | Template Name | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | ---------- | -------------------------------------- | :---------: | ------------------------------------------- | -| [204](#route-delete-template-204) | No Content | The template was deleted successfully. | | [schema](#route-delete-template-204-schema) | - -#### Responses - -##### <span id="route-delete-template-204"></span> 204 - The template was deleted successfully. - -Status: No Content - -###### <span id="route-delete-template-204-schema"></span> Schema - -### <span id="route-get-alert-rule"></span> Get a specific alert rule by UID. (_RouteGetAlertRule_) - -``` -GET /api/v1/provisioning/alert-rules/{UID} -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | -------------- | -| UID | `path` | string | `string` | | ✓ | | Alert rule UID | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| -------------------------------- | --------- | -------------------- | :---------: | ------------------------------------------ | -| [200](#route-get-alert-rule-200) | OK | ProvisionedAlertRule | | [schema](#route-get-alert-rule-200-schema) | -| [404](#route-get-alert-rule-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-404-schema) | - -#### Responses - -##### <span id="route-get-alert-rule-200"></span> 200 - ProvisionedAlertRule - -Status: OK - -###### <span id="route-get-alert-rule-200-schema"></span> Schema - -[ProvisionedAlertRule](#provisioned-alert-rule) - -##### <span id="route-get-alert-rule-404"></span> 404 - Not found. - -Status: Not Found - -###### <span id="route-get-alert-rule-404-schema"></span> Schema - -### <span id="route-get-alert-rule-export"></span> Export an alert rule in provisioning file format. (_RouteGetAlertRuleExport_) - -``` -GET /api/v1/provisioning/alert-rules/{UID}/export -``` - -#### Produces - -- application/json -- application/yaml -- text/yaml - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | -| UID | `path` | string | `string` | | ✓ | | Alert rule UID | -| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | -| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------------- | --------- | ------------------ | :---------: | ------------------------------------------------- | -| [200](#route-get-alert-rule-export-200) | OK | AlertingFileExport | | [schema](#route-get-alert-rule-export-200-schema) | -| [404](#route-get-alert-rule-export-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-export-404-schema) | - -#### Responses - -##### <span id="route-get-alert-rule-export-200"></span> 200 - AlertingFileExport - -Status: OK - -###### <span id="route-get-alert-rule-export-200-schema"></span> Schema - -[AlertingFileExport](#alerting-file-export) - -##### <span id="route-get-alert-rule-export-404"></span> 404 - Not found. - -Status: Not Found - -###### <span id="route-get-alert-rule-export-404-schema"></span> Schema - -### <span id="route-get-alert-rule-group"></span> Get a rule group. (_RouteGetAlertRuleGroup_) - -``` -GET /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group} -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| --------- | ------ | ------ | -------- | --------- | :------: | ------- | ----------- | -| FolderUID | `path` | string | `string` | | ✓ | | | -| Group | `path` | string | `string` | | ✓ | | | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| -------------------------------------- | --------- | -------------- | :---------: | ------------------------------------------------ | -| [200](#route-get-alert-rule-group-200) | OK | AlertRuleGroup | | [schema](#route-get-alert-rule-group-200-schema) | -| [404](#route-get-alert-rule-group-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-group-404-schema) | - -#### Responses - -##### <span id="route-get-alert-rule-group-200"></span> 200 - AlertRuleGroup - -Status: OK - -###### <span id="route-get-alert-rule-group-200-schema"></span> Schema - -[AlertRuleGroup](#alert-rule-group) - -##### <span id="route-get-alert-rule-group-404"></span> 404 - Not found. - -Status: Not Found - -###### <span id="route-get-alert-rule-group-404-schema"></span> Schema - -### <span id="route-get-alert-rule-group-export"></span> Export an alert rule group in provisioning file format. (_RouteGetAlertRuleGroupExport_) - -``` -GET /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export -``` - -#### Produces - -- application/json -- application/yaml -- text/yaml - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| --------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | -| FolderUID | `path` | string | `string` | | ✓ | | | -| Group | `path` | string | `string` | | ✓ | | | -| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | -| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------------------- | --------- | ------------------ | :---------: | ------------------------------------------------------- | -| [200](#route-get-alert-rule-group-export-200) | OK | AlertingFileExport | | [schema](#route-get-alert-rule-group-export-200-schema) | -| [404](#route-get-alert-rule-group-export-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-group-export-404-schema) | - -#### Responses - -##### <span id="route-get-alert-rule-group-export-200"></span> 200 - AlertingFileExport - -Status: OK - -###### <span id="route-get-alert-rule-group-export-200-schema"></span> Schema - -[AlertingFileExport](#alerting-file-export) - -##### <span id="route-get-alert-rule-group-export-404"></span> 404 - Not found. - -Status: Not Found - -###### <span id="route-get-alert-rule-group-export-404-schema"></span> Schema - -### <span id="route-get-alert-rules"></span> Get all the alert rules. (_RouteGetAlertRules_) - -``` -GET /api/v1/provisioning/alert-rules -``` - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | ------ | --------------------- | :---------: | ------------------------------------------- | -| [200](#route-get-alert-rules-200) | OK | ProvisionedAlertRules | | [schema](#route-get-alert-rules-200-schema) | - -#### Responses - -##### <span id="route-get-alert-rules-200"></span> 200 - ProvisionedAlertRules - -Status: OK - -###### <span id="route-get-alert-rules-200-schema"></span> Schema - -[ProvisionedAlertRules](#provisioned-alert-rules) - -### <span id="route-get-alert-rules-export"></span> Export all alert rules in provisioning file format. (_RouteGetAlertRulesExport_) - -``` -GET /api/v1/provisioning/alert-rules/export -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | -| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | -| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ---------------------------------------- | --------- | ------------------ | :---------: | -------------------------------------------------- | -| [200](#route-get-alert-rules-export-200) | OK | AlertingFileExport | | [schema](#route-get-alert-rules-export-200-schema) | -| [404](#route-get-alert-rules-export-404) | Not Found | Not found. | | [schema](#route-get-alert-rules-export-404-schema) | - -#### Responses - -##### <span id="route-get-alert-rules-export-200"></span> 200 - AlertingFileExport - -Status: OK - -###### <span id="route-get-alert-rules-export-200-schema"></span> Schema - -[AlertingFileExport](#alerting-file-export) - -##### <span id="route-get-alert-rules-export-404"></span> 404 - Not found. - -Status: Not Found - -###### <span id="route-get-alert-rules-export-404-schema"></span> Schema - -### <span id="route-get-contactpoints"></span> Get all the contact points. (_RouteGetContactpoints_) - -``` -GET /api/v1/provisioning/contact-points -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------- | ------ | -------- | --------- | :------: | ------- | -------------- | -| name | `query` | string | `string` | | | | Filter by name | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ----------------------------------- | ------ | ------------- | :---------: | --------------------------------------------- | -| [200](#route-get-contactpoints-200) | OK | ContactPoints | | [schema](#route-get-contactpoints-200-schema) | - -#### Responses - -##### <span id="route-get-contactpoints-200"></span> 200 - ContactPoints - -Status: OK - -###### <span id="route-get-contactpoints-200-schema"></span> Schema - -[ContactPoints](#contact-points) - -### <span id="route-get-contactpoints-export"></span> Export all contact points in provisioning file format. (_RouteGetContactpointsExport_) - -``` -GET /api/v1/provisioning/contact-points/export -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------- | ------- | ------- | -------- | --------- | :------: | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| decrypt | `query` | boolean | `bool` | | | | Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings. | -| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | -| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | -| name | `query` | string | `string` | | | | Filter by name | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ------------------------------------------ | --------- | ------------------ | :---------: | ---------------------------------------------------- | -| [200](#route-get-contactpoints-export-200) | OK | AlertingFileExport | | [schema](#route-get-contactpoints-export-200-schema) | -| [403](#route-get-contactpoints-export-403) | Forbidden | PermissionDenied | | [schema](#route-get-contactpoints-export-403-schema) | - -#### Responses - -##### <span id="route-get-contactpoints-export-200"></span> 200 - AlertingFileExport - -Status: OK - -###### <span id="route-get-contactpoints-export-200-schema"></span> Schema - -[AlertingFileExport](#alerting-file-export) - -##### <span id="route-get-contactpoints-export-403"></span> 403 - PermissionDenied - -Status: Forbidden - -###### <span id="route-get-contactpoints-export-403-schema"></span> Schema - -[PermissionDenied](#permission-denied) - -### <span id="route-get-mute-timing"></span> Get a mute timing. (_RouteGetMuteTiming_) - -``` -GET /api/v1/provisioning/mute-timings/{name} -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ---------------- | -| name | `path` | string | `string` | | ✓ | | Mute timing name | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | --------- | ---------------- | :---------: | ------------------------------------------- | -| [200](#route-get-mute-timing-200) | OK | MuteTimeInterval | | [schema](#route-get-mute-timing-200-schema) | -| [404](#route-get-mute-timing-404) | Not Found | Not found. | | [schema](#route-get-mute-timing-404-schema) | - -#### Responses - -##### <span id="route-get-mute-timing-200"></span> 200 - MuteTimeInterval - -Status: OK - -###### <span id="route-get-mute-timing-200-schema"></span> Schema - -[MuteTimeInterval](#mute-time-interval) - -##### <span id="route-get-mute-timing-404"></span> 404 - Not found. - -Status: Not Found - -###### <span id="route-get-mute-timing-404-schema"></span> Schema - -### <span id="route-get-mute-timings"></span> Get all the mute timings. (_RouteGetMuteTimings_) - -``` -GET /api/v1/provisioning/mute-timings -``` - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ---------------------------------- | ------ | ----------- | :---------: | -------------------------------------------- | -| [200](#route-get-mute-timings-200) | OK | MuteTimings | | [schema](#route-get-mute-timings-200-schema) | - -#### Responses - -##### <span id="route-get-mute-timings-200"></span> 200 - MuteTimings - -Status: OK - -###### <span id="route-get-mute-timings-200-schema"></span> Schema - -[MuteTimings](#mute-timings) - -### <span id="route-get-policy-tree"></span> Get the notification policy tree. (_RouteGetPolicyTree_) - -``` -GET /api/v1/provisioning/policies -``` - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | ------ | ----------- | :---------: | ------------------------------------------- | -| [200](#route-get-policy-tree-200) | OK | Route | | [schema](#route-get-policy-tree-200-schema) | - -#### Responses - -##### <span id="route-get-policy-tree-200"></span> 200 - Route - -Status: OK - -###### <span id="route-get-policy-tree-200-schema"></span> Schema - -[Route](#route) - -### <span id="route-get-policy-tree-export"></span> Export the notification policy tree in provisioning file format. (_RouteGetPolicyTreeExport_) - -``` -GET /api/v1/provisioning/policies/export -``` - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ---------------------------------------- | --------- | ------------------ | :---------: | -------------------------------------------------- | -| [200](#route-get-policy-tree-export-200) | OK | AlertingFileExport | | [schema](#route-get-policy-tree-export-200-schema) | -| [404](#route-get-policy-tree-export-404) | Not Found | NotFound | | [schema](#route-get-policy-tree-export-404-schema) | - -#### Responses - -##### <span id="route-get-policy-tree-export-200"></span> 200 - AlertingFileExport - -Status: OK - -###### <span id="route-get-policy-tree-export-200-schema"></span> Schema - -[AlertingFileExport](#alerting-file-export) - -##### <span id="route-get-policy-tree-export-404"></span> 404 - NotFound - -Status: Not Found - -###### <span id="route-get-policy-tree-export-404-schema"></span> Schema - -[NotFound](#not-found) - -### <span id="route-get-template"></span> Get a notification template. (_RouteGetTemplate_) - -``` -GET /api/v1/provisioning/templates/{name} -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ------------- | -| name | `path` | string | `string` | | ✓ | | Template Name | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ------------------------------ | --------- | -------------------- | :---------: | ---------------------------------------- | -| [200](#route-get-template-200) | OK | NotificationTemplate | | [schema](#route-get-template-200-schema) | -| [404](#route-get-template-404) | Not Found | Not found. | | [schema](#route-get-template-404-schema) | - -#### Responses - -##### <span id="route-get-template-200"></span> 200 - NotificationTemplate - -Status: OK - -###### <span id="route-get-template-200-schema"></span> Schema - -[NotificationTemplate](#notification-template) - -##### <span id="route-get-template-404"></span> 404 - Not found. - -Status: Not Found - -###### <span id="route-get-template-404-schema"></span> Schema - -### <span id="route-get-templates"></span> Get all notification templates. (_RouteGetTemplates_) - -``` -GET /api/v1/provisioning/templates -``` - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ------------------------------- | --------- | --------------------- | :---------: | ----------------------------------------- | -| [200](#route-get-templates-200) | OK | NotificationTemplates | | [schema](#route-get-templates-200-schema) | -| [404](#route-get-templates-404) | Not Found | Not found. | | [schema](#route-get-templates-404-schema) | - -#### Responses - -##### <span id="route-get-templates-200"></span> 200 - NotificationTemplates - -Status: OK - -###### <span id="route-get-templates-200-schema"></span> Schema - -[NotificationTemplates](#notification-templates) - -##### <span id="route-get-templates-404"></span> 404 - Not found. - -Status: Not Found - -###### <span id="route-get-templates-404-schema"></span> Schema - -### <span id="route-post-alert-rule"></span> Create a new alert rule. (_RoutePostAlertRule_) - -``` -POST /api/v1/provisioning/alert-rules -``` - -#### Consumes - -- application/json - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | -| X-Disable-Provenance | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [ProvisionedAlertRule](#provisioned-alert-rule) | `models.ProvisionedAlertRule` | | | | | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | ----------- | -------------------- | :---------: | ------------------------------------------- | -| [201](#route-post-alert-rule-201) | Created | ProvisionedAlertRule | | [schema](#route-post-alert-rule-201-schema) | -| [400](#route-post-alert-rule-400) | Bad Request | ValidationError | | [schema](#route-post-alert-rule-400-schema) | - -#### Responses - -##### <span id="route-post-alert-rule-201"></span> 201 - ProvisionedAlertRule - -Status: Created - -###### <span id="route-post-alert-rule-201-schema"></span> Schema - -[ProvisionedAlertRule](#provisioned-alert-rule) - -##### <span id="route-post-alert-rule-400"></span> 400 - ValidationError - -Status: Bad Request - -###### <span id="route-post-alert-rule-400-schema"></span> Schema - -[ValidationError](#validation-error) - -### <span id="route-post-contactpoints"></span> Create a contact point. (_RoutePostContactpoints_) - -``` -POST /api/v1/provisioning/contact-points -``` - -#### Consumes - -- application/json - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | -| X-Disable-Provenance | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [EmbeddedContactPoint](#embedded-contact-point) | `models.EmbeddedContactPoint` | | | | | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ------------------------------------ | ----------- | -------------------- | :---------: | ---------------------------------------------- | -| [202](#route-post-contactpoints-202) | Accepted | EmbeddedContactPoint | | [schema](#route-post-contactpoints-202-schema) | -| [400](#route-post-contactpoints-400) | Bad Request | ValidationError | | [schema](#route-post-contactpoints-400-schema) | - -#### Responses - -##### <span id="route-post-contactpoints-202"></span> 202 - EmbeddedContactPoint - -Status: Accepted - -###### <span id="route-post-contactpoints-202-schema"></span> Schema - -[EmbeddedContactPoint](#embedded-contact-point) - -##### <span id="route-post-contactpoints-400"></span> 400 - ValidationError - -Status: Bad Request - -###### <span id="route-post-contactpoints-400-schema"></span> Schema - -[ValidationError](#validation-error) - -### <span id="route-post-mute-timing"></span> Create a new mute timing. (_RoutePostMuteTiming_) - -``` -POST /api/v1/provisioning/mute-timings -``` - -#### Consumes - -- application/json - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------- | -------- | --------------------------------------- | ------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | -| X-Disable-Provenance | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [MuteTimeInterval](#mute-time-interval) | `models.MuteTimeInterval` | | | | | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ---------------------------------- | ----------- | ---------------- | :---------: | -------------------------------------------- | -| [201](#route-post-mute-timing-201) | Created | MuteTimeInterval | | [schema](#route-post-mute-timing-201-schema) | -| [400](#route-post-mute-timing-400) | Bad Request | ValidationError | | [schema](#route-post-mute-timing-400-schema) | - -#### Responses - -##### <span id="route-post-mute-timing-201"></span> 201 - MuteTimeInterval - -Status: Created - -###### <span id="route-post-mute-timing-201-schema"></span> Schema - -[MuteTimeInterval](#mute-time-interval) - -##### <span id="route-post-mute-timing-400"></span> 400 - ValidationError - -Status: Bad Request - -###### <span id="route-post-mute-timing-400-schema"></span> Schema - -[ValidationError](#validation-error) - -### <span id="route-put-alert-rule"></span> Update an existing alert rule. (_RoutePutAlertRule_) - -``` -PUT /api/v1/provisioning/alert-rules/{UID} -``` - -#### Consumes - -- application/json - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | -| UID | `path` | string | `string` | | ✓ | | Alert rule UID | -| X-Disable-Provenance | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [ProvisionedAlertRule](#provisioned-alert-rule) | `models.ProvisionedAlertRule` | | | | | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| -------------------------------- | ----------- | -------------------- | :---------: | ------------------------------------------ | -| [200](#route-put-alert-rule-200) | OK | ProvisionedAlertRule | | [schema](#route-put-alert-rule-200-schema) | -| [400](#route-put-alert-rule-400) | Bad Request | ValidationError | | [schema](#route-put-alert-rule-400-schema) | - -#### Responses - -##### <span id="route-put-alert-rule-200"></span> 200 - ProvisionedAlertRule - -Status: OK - -###### <span id="route-put-alert-rule-200-schema"></span> Schema - -[ProvisionedAlertRule](#provisioned-alert-rule) - -##### <span id="route-put-alert-rule-400"></span> 400 - ValidationError - -Status: Bad Request - -###### <span id="route-put-alert-rule-400-schema"></span> Schema - -[ValidationError](#validation-error) - -### <span id="route-put-alert-rule-group"></span> Update the interval of a rule group. (_RoutePutAlertRuleGroup_) - -``` -PUT /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group} -``` - -#### Consumes - -- application/json - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------- | -------- | ----------------------------------- | ----------------------- | --------- | :------: | ------- | --------------------------------------------------------- | -| FolderUID | `path` | string | `string` | | ✓ | | | -| Group | `path` | string | `string` | | ✓ | | | -| X-Disable-Provenance | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [AlertRuleGroup](#alert-rule-group) | `models.AlertRuleGroup` | | | | | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| -------------------------------------- | ----------- | --------------- | :---------: | ------------------------------------------------ | -| [200](#route-put-alert-rule-group-200) | OK | AlertRuleGroup | | [schema](#route-put-alert-rule-group-200-schema) | -| [400](#route-put-alert-rule-group-400) | Bad Request | ValidationError | | [schema](#route-put-alert-rule-group-400-schema) | - -#### Responses - -##### <span id="route-put-alert-rule-group-200"></span> 200 - AlertRuleGroup - -Status: OK - -###### <span id="route-put-alert-rule-group-200-schema"></span> Schema - -[AlertRuleGroup](#alert-rule-group) - -##### <span id="route-put-alert-rule-group-400"></span> 400 - ValidationError - -Status: Bad Request - -###### <span id="route-put-alert-rule-group-400-schema"></span> Schema - -[ValidationError](#validation-error) - -### <span id="route-put-contactpoint"></span> Update an existing contact point. (_RoutePutContactpoint_) - -``` -PUT /api/v1/provisioning/contact-points/{UID} -``` - -#### Consumes - -- application/json - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | -| UID | `path` | string | `string` | | ✓ | | UID is the contact point unique identifier | -| X-Disable-Provenance | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [EmbeddedContactPoint](#embedded-contact-point) | `models.EmbeddedContactPoint` | | | | | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ---------------------------------- | ----------- | --------------- | :---------: | -------------------------------------------- | -| [202](#route-put-contactpoint-202) | Accepted | Ack | | [schema](#route-put-contactpoint-202-schema) | -| [400](#route-put-contactpoint-400) | Bad Request | ValidationError | | [schema](#route-put-contactpoint-400-schema) | - -#### Responses - -##### <span id="route-put-contactpoint-202"></span> 202 - Ack - -Status: Accepted - -###### <span id="route-put-contactpoint-202-schema"></span> Schema - -[Ack](#ack) - -##### <span id="route-put-contactpoint-400"></span> 400 - ValidationError - -Status: Bad Request - -###### <span id="route-put-contactpoint-400-schema"></span> Schema - -[ValidationError](#validation-error) - -### <span id="route-put-mute-timing"></span> Replace an existing mute timing. (_RoutePutMuteTiming_) - -``` -PUT /api/v1/provisioning/mute-timings/{name} -``` - -#### Consumes - -- application/json - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------- | -------- | --------------------------------------- | ------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | -| name | `path` | string | `string` | | ✓ | | Mute timing name | -| X-Disable-Provenance | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [MuteTimeInterval](#mute-time-interval) | `models.MuteTimeInterval` | | | | | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | ----------- | ---------------- | :---------: | ------------------------------------------- | -| [200](#route-put-mute-timing-200) | OK | MuteTimeInterval | | [schema](#route-put-mute-timing-200-schema) | -| [400](#route-put-mute-timing-400) | Bad Request | ValidationError | | [schema](#route-put-mute-timing-400-schema) | - -#### Responses - -##### <span id="route-put-mute-timing-200"></span> 200 - MuteTimeInterval - -Status: OK - -###### <span id="route-put-mute-timing-200-schema"></span> Schema - -[MuteTimeInterval](#mute-time-interval) - -##### <span id="route-put-mute-timing-400"></span> 400 - ValidationError - -Status: Bad Request - -###### <span id="route-put-mute-timing-400-schema"></span> Schema - -[ValidationError](#validation-error) - -### <span id="route-put-policy-tree"></span> Sets the notification policy tree. (_RoutePutPolicyTree_) - -``` -PUT /api/v1/provisioning/policies -``` - -#### Consumes - -- application/json - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------- | -------- | --------------- | -------------- | --------- | :------: | ------- | --------------------------------------------------------- | -| X-Disable-Provenance | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [Route](#route) | `models.Route` | | | | The new notification routing tree to use | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | ----------- | --------------- | :---------: | ------------------------------------------- | -| [202](#route-put-policy-tree-202) | Accepted | Ack | | [schema](#route-put-policy-tree-202-schema) | -| [400](#route-put-policy-tree-400) | Bad Request | ValidationError | | [schema](#route-put-policy-tree-400-schema) | - -#### Responses - -##### <span id="route-put-policy-tree-202"></span> 202 - Ack - -Status: Accepted - -###### <span id="route-put-policy-tree-202-schema"></span> Schema - -[Ack](#ack) - -##### <span id="route-put-policy-tree-400"></span> 400 - ValidationError - -Status: Bad Request - -###### <span id="route-put-policy-tree-400-schema"></span> Schema - -[ValidationError](#validation-error) - -### <span id="route-put-template"></span> Updates an existing notification template. (_RoutePutTemplate_) - -``` -PUT /api/v1/provisioning/templates/{name} -``` - -#### Consumes - -- application/json - -{{% responsive-table %}} - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------- | -------- | ------------------------------------------------------------- | ------------------------------------ | --------- | :------: | ------- | --------------------------------------------------------- | -| name | `path` | string | `string` | | ✓ | | Template Name | -| X-Disable-Provenance | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [NotificationTemplateContent](#notification-template-content) | `models.NotificationTemplateContent` | | | | | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ------------------------------ | ----------- | -------------------- | :---------: | ---------------------------------------- | -| [202](#route-put-template-202) | Accepted | NotificationTemplate | | [schema](#route-put-template-202-schema) | -| [400](#route-put-template-400) | Bad Request | ValidationError | | [schema](#route-put-template-400-schema) | - -#### Responses - -##### <span id="route-put-template-202"></span> 202 - NotificationTemplate - -Status: Accepted - -###### <span id="route-put-template-202-schema"></span> Schema - -[NotificationTemplate](#notification-template) - -##### <span id="route-put-template-400"></span> 400 - ValidationError - -Status: Bad Request - -###### <span id="route-put-template-400-schema"></span> Schema - -[ValidationError](#validation-error) - -### <span id="route-reset-policy-tree"></span> Clears the notification policy tree. (_RouteResetPolicyTree_) - -``` -DELETE /api/v1/provisioning/policies -``` - -#### Consumes - -- application/json - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ----------------------------------- | -------- | ----------- | :---------: | --------------------------------------------- | -| [202](#route-reset-policy-tree-202) | Accepted | Ack | | [schema](#route-reset-policy-tree-202-schema) | - -#### Responses - -##### <span id="route-reset-policy-tree-202"></span> 202 - Ack - -Status: Accepted - -###### <span id="route-reset-policy-tree-202-schema"></span> Schema - -[Ack](#ack) - -## Models - -### <span id="ack"></span> Ack - -[interface{}](#interface) - -### <span id="alert-query"></span> AlertQuery - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| --------------------------------------------------------- | ----------------------------------------- | ------------------- | :------: | ------- | ------------------------------------------------------------------------------------------------------ | ------- | -| datasourceUid | string | `string` | | | Grafana data source unique identifier; it should be '**expr**' for a Server Side Expression operation. | | -| model | [interface{}](#interface) | `interface{}` | | | JSON is the raw JSON query and includes the above properties as well as custom properties. | | -| queryType | string | `string` | | | QueryType is an optional identifier for the type of query. | -| It can be used to distinguish different types of queries. | | -| refId | string | `string` | | | RefID is the unique identifier of the query, set by the frontend call. | | -| relativeTimeRange | [RelativeTimeRange](#relative-time-range) | `RelativeTimeRange` | | | | | - -{{% /responsive-table %}} - -### <span id="alert-query-export"></span> AlertQueryExport - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ----------------- | ----------------------------------------- | ------------------- | :------: | ------- | ----------- | ------- | -| datasourceUid | string | `string` | | | | | -| model | [interface{}](#interface) | `interface{}` | | | | | -| queryType | string | `string` | | | | | -| refId | string | `string` | | | | | -| relativeTimeRange | [RelativeTimeRange](#relative-time-range) | `RelativeTimeRange` | | | | | - -{{% /responsive-table %}} - -### <span id="alert-rule-export"></span> AlertRuleExport - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ------------ | ----------------------------------------- | --------------------- | :------: | ------- | ----------- | ------- | -| annotations | map of string | `map[string]string` | | | | | -| condition | string | `string` | | | | | -| dasboardUid | string | `string` | | | | | -| data | [][AlertQueryExport](#alert-query-export) | `[]*AlertQueryExport` | | | | | -| execErrState | string | `string` | | | | | -| for | [Duration](#duration) | `Duration` | | | | | -| isPaused | boolean | `bool` | | | | | -| labels | map of string | `map[string]string` | | | | | -| noDataState | string | `string` | | | | | -| panelId | int64 (formatted integer) | `int64` | | | | | -| title | string | `string` | | | | | -| uid | string | `string` | | | | | - -{{% /responsive-table %}} - -### <span id="alert-rule-group"></span> AlertRuleGroup - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| --------- | ------------------------------------------------- | ------------------------- | :------: | ------- | ----------- | ------- | -| folderUid | string | `string` | | | | | -| interval | int64 (formatted integer) | `int64` | | | | | -| rules | [][ProvisionedAlertRule](#provisioned-alert-rule) | `[]*ProvisionedAlertRule` | | | | | -| title | string | `string` | | | | | - -{{% /responsive-table %}} - -### <span id="alert-rule-group-export"></span> AlertRuleGroupExport - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| -------- | --------------------------------------- | -------------------- | :------: | ------- | ----------- | ------- | -| folder | string | `string` | | | | | -| interval | [Duration](#duration) | `Duration` | | | | | -| name | string | `string` | | | | | -| orgId | int64 (formatted integer) | `int64` | | | | | -| rules | [][AlertRuleExport](#alert-rule-export) | `[]*AlertRuleExport` | | | | | - -{{% /responsive-table %}} - -### <span id="alerting-file-export"></span> AlertingFileExport - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ------------- | --------------------------------------------------------- | ----------------------------- | :------: | ------- | ----------- | ------- | -| apiVersion | int64 (formatted integer) | `int64` | | | | | -| contactPoints | [][ContactPointExport](#contact-point-export) | `[]*ContactPointExport` | | | | | -| groups | [][AlertRuleGroupExport](#alert-rule-group-export) | `[]*AlertRuleGroupExport` | | | | | -| policies | [][NotificationPolicyExport](#notification-policy-export) | `[]*NotificationPolicyExport` | | | | | - -{{% /responsive-table %}} - -### <span id="contact-point-export"></span> ContactPointExport - -**Properties** - -| Name | Type | Go type | Required | Default | Description | Example | -| --------- | ------------------------------------ | ------------------- | :------: | ------- | ----------- | ------- | -| name | string | `string` | | | | | -| orgId | int64 (formatted integer) | `int64` | | | | | -| receivers | [][ReceiverExport](#receiver-export) | `[]*ReceiverExport` | | | | | - -### <span id="contact-points"></span> ContactPoints - -[][EmbeddedContactPoint](#embedded-contact-point) - -### <span id="duration"></span> Duration - -| Name | Type | Go type | Default | Description | Example | -| -------- | ------------------------- | ------- | ------- | ----------- | ------- | -| Duration | int64 (formatted integer) | int64 | | | | - -### <span id="embedded-contact-point"></span> EmbeddedContactPoint - -> EmbeddedContactPoint is the contact point type that is used -> by grafanas embedded alertmanager implementation. - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ------------------------------------ | ----------------------- | -------- | :------: | ------- | ----------------------------------------------------------------- | --------- | -| disableResolveMessage | boolean | `bool` | | | | `false` | -| name | string | `string` | | | Name is used as grouping key in the UI. Contact points with the | -| same name will be grouped in the UI. | `webhook_1` | -| provenance | string | `string` | | | | | -| settings | [JSON](#json) | `JSON` | ✓ | | | | -| type | string | `string` | ✓ | | | `webhook` | -| uid | string | `string` | | | UID is the unique identifier of the contact point. The UID can be | -| set by the user. | `my_external_reference` | - -{{% /responsive-table %}} - -### <span id="json"></span> Json - -[interface{}](#interface) - -### <span id="match-regexps"></span> MatchRegexps - -[MatchRegexps](#match-regexps) - -### <span id="match-type"></span> MatchType - -| Name | Type | Go type | Default | Description | Example | -| --------- | ------------------------- | ------- | ------- | ----------- | ------- | -| MatchType | int64 (formatted integer) | int64 | | | | - -### <span id="matcher"></span> Matcher - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ----- | ------------------------ | ----------- | :------: | ------- | ----------- | ------- | -| Name | string | `string` | | | | | -| Type | [MatchType](#match-type) | `MatchType` | | | | | -| Value | string | `string` | | | | | - -{{% /responsive-table %}} - -### <span id="matchers"></span> Matchers - -> Matchers is a slice of Matchers that is sortable, implements Stringer, and -> provides a Matches method to match a LabelSet against all Matchers in the -> slice. Note that some users of Matchers might require it to be sorted. - -[][Matcher](#matcher) - -### <span id="mute-time-interval"></span> MuteTimeInterval - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| -------------- | -------------------------------- | ----------------- | :------: | ------- | ----------- | ------- | -| name | string | `string` | | | | | -| time_intervals | [][TimeInterval](#time-interval) | `[]*TimeInterval` | | | | | - -{{% /responsive-table %}} - -### <span id="mute-timings"></span> MuteTimings - -[][MuteTimeInterval](#mute-time-interval) - -### <span id="not-found"></span> NotFound - -[interface{}](#interface) - -### <span id="notification-policy-export"></span> NotificationPolicyExport - -**Properties** - -| Name | Type | Go type | Required | Default | Description | Example | -| ------ | ---------------------------- | ------------- | :------: | ------- | ----------- | ------- | -| Policy | [RouteExport](#route-export) | `RouteExport` | | | inline | | -| orgId | int64 (formatted integer) | `int64` | | | | | - -### <span id="notification-template"></span> NotificationTemplate - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ---------- | ------------------------- | ------------ | :------: | ------- | ----------- | ------- | -| name | string | `string` | | | | | -| provenance | [Provenance](#provenance) | `Provenance` | | | | | -| template | string | `string` | | | | | - -{{% /responsive-table %}} - -### <span id="notification-template-content"></span> NotificationTemplateContent - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| -------- | ------ | -------- | :------: | ------- | ----------- | ------- | -| template | string | `string` | | | | | - -{{% /responsive-table %}} - -### <span id="notification-templates"></span> NotificationTemplates - -[][NotificationTemplate](#notification-template) - -### <span id="object-matchers"></span> ObjectMatchers - -[Matchers](#matchers) - -#### Inlined models - -### <span id="permission-denied"></span> PermissionDenied - -[interface{}](#interface) - -### <span id="provenance"></span> Provenance - -| Name | Type | Go type | Default | Description | Example | -| ---------- | ------ | ------- | ------- | ----------- | ------- | -| Provenance | string | string | | | | - -### <span id="provisioned-alert-rule"></span> ProvisionedAlertRule - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ------------ | ---------------------------- | ------------------- | :------: | ------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| annotations | map of string | `map[string]string` | | | | `{"runbook_url":"https://supercoolrunbook.com/page/13"}` | -| condition | string | `string` | ✓ | | | `A` | -| data | [][AlertQuery](#alert-query) | `[]*AlertQuery` | ✓ | | | `[{"datasourceUid":"__expr__","model":{"conditions":[{"evaluator":{"params":[0,0],"type":"gt"},"operator":{"type":"and"},"query":{"params":[]},"reducer":{"params":[],"type":"avg"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1 == 1","hide":false,"intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"},"queryType":"","refId":"A","relativeTimeRange":{"from":0,"to":0}}]` | -| execErrState | string | `string` | ✓ | | | | -| folderUID | string | `string` | ✓ | | | `project_x` | -| for | [Duration](#duration) | `Duration` | ✓ | | | | -| id | int64 (formatted integer) | `int64` | | | | | -| isPaused | boolean | `bool` | | | | `false` | -| labels | map of string | `map[string]string` | | | | `{"team":"sre-team-1"}` | -| noDataState | string | `string` | ✓ | | | | -| orgID | int64 (formatted integer) | `int64` | ✓ | | | | -| provenance | [Provenance](#provenance) | `Provenance` | | | | | -| ruleGroup | string | `string` | ✓ | | | `eval_group_1` | -| title | string | `string` | ✓ | | | `Always firing` | -| uid | string | `string` | | | | | -| updated | date-time (formatted string) | `strfmt.DateTime` | | | | | - -{{% /responsive-table %}} - -### <span id="provisioned-alert-rules"></span> ProvisionedAlertRules - -[][ProvisionedAlertRule](#provisioned-alert-rule) - -### <span id="raw-message"></span> RawMessage - -[interface{}](#interface) - -### <span id="receiver-export"></span> ReceiverExport - -**Properties** - -| Name | Type | Go type | Required | Default | Description | Example | -| --------------------- | -------------------------- | ------------ | :------: | ------- | ----------- | ------- | -| disableResolveMessage | boolean | `bool` | | | | | -| settings | [RawMessage](#raw-message) | `RawMessage` | | | | | -| type | string | `string` | | | | | -| uid | string | `string` | | | | | - -### <span id="regexp"></span> Regexp - -> A Regexp is safe for concurrent use by multiple goroutines, -> except for configuration methods, such as Longest. - -[interface{}](#interface) - -### <span id="relative-time-range"></span> RelativeTimeRange - -> RelativeTimeRange is the per query start and end time -> for requests. - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ---- | --------------------- | ---------- | :------: | ------- | ----------- | ------- | -| from | [Duration](#duration) | `Duration` | | | | | -| to | [Duration](#duration) | `Duration` | | | | | - -{{% /responsive-table %}} - -### <span id="route"></span> Route - -> A Route is a node that contains definitions of how to handle alerts. This is modified -> from the upstream alertmanager in that it adds the ObjectMatchers property. - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ------------------- | ---------------------------------- | ------------------- | :------: | ------- | --------------------------------------- | ------- | -| continue | boolean | `bool` | | | | | -| group_by | []string | `[]string` | | | | | -| group_interval | string | `string` | | | | | -| group_wait | string | `string` | | | | | -| match | map of string | `map[string]string` | | | Deprecated. Remove before v1.0 release. | | -| match_re | [MatchRegexps](#match-regexps) | `MatchRegexps` | | | | | -| matchers | [Matchers](#matchers) | `Matchers` | | | | | -| mute_time_intervals | []string | `[]string` | | | | | -| object_matchers | [ObjectMatchers](#object-matchers) | `ObjectMatchers` | | | | | -| provenance | [Provenance](#provenance) | `Provenance` | | | | | -| receiver | string | `string` | | | | | -| repeat_interval | string | `string` | | | | | -| routes | [][Route](#route) | `[]*Route` | | | | | - -{{% /responsive-table %}} - -### <span id="route-export"></span> RouteExport - -> RouteExport is the provisioned file export of definitions.Route. This is needed to hide fields that aren't usable in -> provisioning file format. An alternative would be to define a custom MarshalJSON and MarshalYAML that excludes them. - -**Properties** - -| Name | Type | Go type | Required | Default | Description | Example | -| ------------------- | ---------------------------------- | ------------------- | :------: | ------- | --------------------------------------- | ------- | -| continue | boolean | `bool` | | | | | -| group_by | []string | `[]string` | | | | | -| group_interval | string | `string` | | | | | -| group_wait | string | `string` | | | | | -| match | map of string | `map[string]string` | | | Deprecated. Remove before v1.0 release. | | -| match_re | [MatchRegexps](#match-regexps) | `MatchRegexps` | | | | | -| matchers | [Matchers](#matchers) | `Matchers` | | | | | -| mute_time_intervals | []string | `[]string` | | | | | -| object_matchers | [ObjectMatchers](#object-matchers) | `ObjectMatchers` | | | | | -| receiver | string | `string` | | | | | -| repeat_interval | string | `string` | | | | | -| routes | [][RouteExport](#route-export) | `[]*RouteExport` | | | | | - -### <span id="time-interval"></span> TimeInterval - -> TimeInterval describes intervals of time. ContainsTime will tell you if a golang time is contained -> within the interval. - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ------------- | -------------------------- | -------------- | :------: | ------- | ----------- | ------- | -| days_of_month | []string | `[]string` | | | | | -| location | string | `string` | | | | | -| months | []string | `[]string` | | | | | -| times | [][TimeRange](#time-range) | `[]*TimeRange` | | | | | -| weekdays | []string | `[]string` | | | | | -| years | []string | `[]string` | | | | | - -{{% /responsive-table %}} - -### <span id="time-range"></span> TimeRange - -> For example, 4:00PM to End of the day would Begin at 1020 and End at 1440. - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ----------- | ------------------------- | ------- | :------: | ------- | ----------- | ------- | -| EndMinute | int64 (formatted integer) | `int64` | | | | | -| StartMinute | int64 (formatted integer) | `int64` | | | | | - -{{% /responsive-table %}} - -### <span id="validation-error"></span> ValidationError - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ---- | ------ | -------- | :------: | ------- | ----------- | --------------- | -| msg | string | `string` | | | | `error message` | - -{{% /responsive-table %}} +{{< docs/shared lookup="alerts/alerting_provisioning.md" source="grafana" version="latest" >}} diff --git a/docs/sources/developers/http_api/annotations.md b/docs/sources/developers/http_api/annotations.md index 337bb230886a0..4d58b32a8298d 100644 --- a/docs/sources/developers/http_api/annotations.md +++ b/docs/sources/developers/http_api/annotations.md @@ -189,7 +189,7 @@ Content-Type: application/json "what": "Event - deploy", "tags": ["deploy", "production"], "when": 1467844481, - "data": "deploy of master branch happened at Wed Jul 6 22:34:41 UTC 2016" + "data": "deploy of main branch happened at Wed Jul 6 22:34:41 UTC 2016" } ``` diff --git a/docs/sources/developers/http_api/dashboard.md b/docs/sources/developers/http_api/dashboard.md index 05dcb8294c4c2..c818e470929cd 100644 --- a/docs/sources/developers/http_api/dashboard.md +++ b/docs/sources/developers/http_api/dashboard.md @@ -37,6 +37,8 @@ The uid can have a maximum length of 40 characters. Creates a new dashboard or updates an existing dashboard. When updating existing dashboards, if you do not define the `folderId` or the `folderUid` property, then the dashboard(s) are moved to the root level. (You need to define only one property, not both). +> **Note:** This endpoint is not intended for creating folders, use `POST /api/folders` for that. + **Required permissions** See note in the [introduction]({{< ref "#dashboard-api" >}}) for an explanation. diff --git a/docs/sources/developers/http_api/dashboard_public.md b/docs/sources/developers/http_api/dashboard_public.md index 5815672230273..d0f1980c6b04e 100644 --- a/docs/sources/developers/http_api/dashboard_public.md +++ b/docs/sources/developers/http_api/dashboard_public.md @@ -111,7 +111,9 @@ Content-Length: 107 ## Update a public dashboard -`PATCH /api/dashboards/uid/:uid/public-dashboards/` +`PATCH /api/dashboards/uid/:uid/public-dashboards/:publicDashboardUid` + +Will update the public dashboard given the specified unique identifier (uid). **Required permissions** @@ -124,7 +126,7 @@ See note in the [introduction](#public-dashboard-api) for an explanation. **Example Request for updating a public dashboard**: ```http -PATCH /api/dashboards/uid/xCpsVuc4z/public-dashboards/ HTTP/1.1 +PATCH /api/dashboards/uid/xCpsVuc4z/public-dashboards/cd56d9fd-f3d4-486d-afba-a21760e2acbe HTTP/1.1 Accept: application/json Content-Type: application/json Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk @@ -205,7 +207,7 @@ See note in the [introduction](#public-dashboard-api) for an explanation. **Example Request**: ```http -GET /api/dashboards/uid/xCpsVuc4z HTTP/1.1 +GET /api/dashboards/uid/xCpsVuc4z/public-dashboards/ HTTP/1.1 Accept: application/json Content-Type: application/json Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk diff --git a/docs/sources/developers/http_api/folder.md b/docs/sources/developers/http_api/folder.md index 12087d1acc8c7..aaae48dc48685 100644 --- a/docs/sources/developers/http_api/folder.md +++ b/docs/sources/developers/http_api/folder.md @@ -160,7 +160,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk { "uid": "nErXDvCkzz", - "title": "Department ABC" + "title": "Department ABC", + "parentUid": "fgnj5e52gel76g" } ``` @@ -168,6 +169,7 @@ JSON Body schema: - **uid** – Optional [unique identifier]({{< ref "#identifier-id-vs-unique-identifier-uid" >}}). - **title** – The title of the folder. +- **parentUid** - Optional field, the unique identifier of the parent folder under which the folder should be created. Requires nested folders to be enabled. **Example Response**: diff --git a/docs/sources/developers/http_api/folder_dashboard_search.md b/docs/sources/developers/http_api/folder_dashboard_search.md index 13becff4d0ef6..fc97317bbaf51 100644 --- a/docs/sources/developers/http_api/folder_dashboard_search.md +++ b/docs/sources/developers/http_api/folder_dashboard_search.md @@ -34,7 +34,7 @@ Query parameters: - **dashboardIds** – List of dashboard id's to search for - **dashboardUID** - List of dashboard uid's to search for, It is deprecated since Grafana v9.1, please use dashboardUIDs instead - **dashboardUIDs** – List of dashboard uid's to search for -- **folderIds** – List of folder id's to search in for dashboards +- **folderUIDs** – List of folder UIDs to search in - **starred** – Flag indicating if only starred Dashboards should be returned - **limit** – Limit the number of returned results (max is 5000; default is 1000) - **page** – Use this parameter to access hits beyond limit. Numbering starts at 1. limit param acts as page size. Only available in Grafana v6.2+. @@ -42,7 +42,7 @@ Query parameters: **Example request for retrieving folders and dashboards at the root level**: ```http -GET /api/search?folderIds=0&query=&starred=false HTTP/1.1 +GET /api/search?query=&starred=false HTTP/1.1 Accept: application/json Content-Type: application/json Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk diff --git a/docs/sources/developers/http_api/reporting.md b/docs/sources/developers/http_api/reporting.md index d1f1d794716dd..871775fe15a05 100644 --- a/docs/sources/developers/http_api/reporting.md +++ b/docs/sources/developers/http_api/reporting.md @@ -241,7 +241,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk "to": "2022-09-02T17:00:00+02:00" }, "reportVariables": { - "varibale1": "Value1" + "variable1": "Value1" } } ], @@ -354,7 +354,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk "to": "2022-09-02T17:00:00+02:00" }, "reportVariables": { - "varibale1": "Value1" + "variable1": "Value1" } } ], @@ -647,7 +647,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk "to": "2022-09-02T17:00:00+02:00" }, "reportVariables": { - "varibale1": "Value1" + "variable1": "Value1" } } ], diff --git a/docs/sources/developers/http_api/serviceaccount.md b/docs/sources/developers/http_api/serviceaccount.md index 8dff8a38d9052..15e424cf151f5 100644 --- a/docs/sources/developers/http_api/serviceaccount.md +++ b/docs/sources/developers/http_api/serviceaccount.md @@ -19,6 +19,7 @@ title: Service account HTTP API # Service account API > If you are running Grafana Enterprise, for some endpoints you'll need to have specific permissions. Refer to [Role-based access control permissions]({{< relref "/docs/grafana/latest/administration/roles-and-permissions/access-control/custom-role-actions-scopes" >}}) for more information. +> For Grafana Cloud instances, please use a Bearer token to authenticate. The examples within this section reference Basic authentication which is for On-Prem Grafana instances. ## Search service accounts with Paging diff --git a/docs/sources/developers/http_api/sso-settings.md b/docs/sources/developers/http_api/sso-settings.md index 25d337104a842..c7c8a343a3a3d 100644 --- a/docs/sources/developers/http_api/sso-settings.md +++ b/docs/sources/developers/http_api/sso-settings.md @@ -22,8 +22,176 @@ title: SSO Settings API > If you are running Grafana Enterprise, for some endpoints you'll need to have specific permissions. Refer to [Role-based access control permissions]({{< relref "/docs/grafana/latest/administration/roles-and-permissions/access-control/custom-role-actions-scopes" >}}) for more information. +{{% admonition type="note" %}} +Available in Public Preview in Grafana 10.4 and on Grafana Cloud behind the `ssoSettingsApi` feature toggle. +{{% /admonition %}} + The API can be used to create, update, delete, get, and list SSO Settings. +## List SSO Settings + +`GET /api/v1/sso-settings` + +Lists the SSO Settings for all providers. + +**Required permissions** + +See note in the [introduction]({{< ref "#sso-settings" >}}) for an explanation. + +| Action | Scope | +| --------------- | ---------------------------- | +| `settings:read` | `settings:auth.{provider}:*` | + +**Example Request**: + +```http +GET /api/v1/sso-settings HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json +[ + { + "id": "1", + "provider": "github", + "settings": { + "apiUrl": "https://api.github.com/user", + "clientId": "my_github_client", + "clientSecret": "*********", + "enabled": true, + "scopes": "user:email,read:org" + // rest of the settings + }, + "source": "system", + }, + { + "id": "2", + "provider": "azuread", + "settings": { + "authUrl": "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/v2.0/authorize", + "clientId": "my_azuread_client", + "clientSecret": "*********", + "enabled": true, + "scopes": "openid,email,profile" + // rest of the settings + }, + "source": "system", + } +] +``` + +Status Codes: + +- **200** – SSO Settings found +- **400** – Bad Request +- **401** – Unauthorized +- **403** – Access Denied + +## Get SSO Settings + +`GET /api/v1/sso-settings/:provider` + +Gets the SSO Settings for a provider. + +**Required permissions** + +See note in the [introduction]({{< ref "#sso-settings" >}}) for an explanation. + +| Action | Scope | +| --------------- | ---------------------------- | +| `settings:read` | `settings:auth.{provider}:*` | + +**Example Request**: + +```http +GET /api/v1/sso-settings/github HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json +ETag: db87f729761898ee +{ + "id": "1", + "provider": "github", + "settings": { + "apiUrl": "https://api.github.com/user", + "clientId": "my_github_client", + "clientSecret": "*********", + "enabled": true, + "scopes": "user:email,read:org" + // rest of the settings + }, + "source": "system", +} +``` + +Status Codes: + +- **200** – SSO Settings found +- **400** – Bad Request +- **401** – Unauthorized +- **403** – Access Denied +- **404** – SSO Settings not found + +## Update SSO Settings + +`PUT /api/v1/sso-settings/:provider` + +Updates the SSO Settings for a provider. + +**Required permissions** + +See note in the [introduction]({{< ref "#sso-settings" >}}) for an explanation. + +| Action | Scope | +| ---------------- | ---------------------------- | +| `settings:write` | `settings:auth.{provider}:*` | + +**Example Request**: + +```http +PUT /api/v1/sso-settings/github HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +{ + "settings": { + "apiUrl": "https://api.github.com/user", + "clientId": "my_github_client", + "clientSecret": "my_github_secret", + "enabled": true, + "scopes": "user:email,read:org" + } +} +``` + +**Example Response**: + +```http +HTTP/1.1 204 +Content-Type: application/json +``` + +Status Codes: + +- **204** – SSO Settings updated +- **400** – Bad Request +- **401** – Unauthorized +- **403** – Access Denied + ## Delete SSO Settings `DELETE /api/v1/sso-settings/:provider` diff --git a/docs/sources/developers/kinds/_index.md b/docs/sources/developers/kinds/_index.md deleted file mode 100644 index de9fa0e3fb1cc..0000000000000 --- a/docs/sources/developers/kinds/_index.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -_build: - list: false -labels: - products: - - enterprise - - oss -title: Grafana schema -weight: 200 ---- - -# Grafana schema - -> Grafana’s schemas, kind system and related code generation are in active development. - -Grafana is moving to a schema-centric model of development, where schemas are the single source of truth that specify -the shape of objects - for example, dashboards, datasources, users - in the frontend, backend, and plugin code. -Eventually, all of Grafana’s object types will be schematized within the “Kind System.” Kinds, their schemas, the Kind -system rules, and associated code generators will collectively provide a clear, consistent foundation for Grafana’s -APIs, documentation, persistent storage, clients, as-code tooling, and so forth. - -It’s exciting to imagine the possibilities that a crisp, consistent development workflow will enable - this is why -companies build [developer platforms](https://internaldeveloperplatform.org/)! At the same time, it’s also -overwhelming - any schema system that can meet Grafana’s complex requirements will necessarily have a lot of moving -parts. Additionally, we must account for having Grafana continue to work as we make the transition - a prerequisite -for every large-scale refactoring. - -In the Grafana ecosystem, there are three basic Kind categories and associated schema categories: - -- [Core Kinds]({{< relref "core/" >}}) -- Custom Kinds -- [Composable Kinds]({{< relref "composable/" >}}) - -The schema authoring workflow for each varies, as does the path to maturity. -[Grafana Kinds - From Zero to Maturity]({{< relref "maturity/" >}}) contains general reference material applicable to -all Kind-writing, and links to the guides for each category of Kind. diff --git a/docs/sources/developers/kinds/composable/_index.md b/docs/sources/developers/kinds/composable/_index.md deleted file mode 100644 index 14e40cd6a3f01..0000000000000 --- a/docs/sources/developers/kinds/composable/_index.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -labels: - products: - - enterprise - - oss -title: Composable kinds -weight: 200 ---- - -# Grafana composable kinds - -{{< section >}} diff --git a/docs/sources/developers/kinds/composable/alertgroups/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/alertgroups/panelcfg/schema-reference.md deleted file mode 100644 index 8d69846cf2678..0000000000000 --- a/docs/sources/developers/kinds/composable/alertgroups/panelcfg/schema-reference.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: AlertGroupsPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## AlertGroupsPanelCfg - -#### Maturity: [merged](../../../maturity/#merged) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-----------|--------------------|----------|---------|-------------| -| `Options` | [object](#options) | **Yes** | | | - -### Options - -| Property | Type | Required | Default | Description | -|----------------|---------|----------|---------|-------------------------------------------------------------| -| `alertmanager` | string | **Yes** | | Name of the alertmanager used as a source for alerts | -| `expandAll` | boolean | **Yes** | | Expand all alert groups by default | -| `labels` | string | **Yes** | | Comma-separated list of values used to filter alert results | - - diff --git a/docs/sources/developers/kinds/composable/annotationslist/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/annotationslist/panelcfg/schema-reference.md deleted file mode 100644 index eb739f4509d5b..0000000000000 --- a/docs/sources/developers/kinds/composable/annotationslist/panelcfg/schema-reference.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: AnnotationsListPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## AnnotationsListPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-----------|--------------------|----------|---------|-------------| -| `Options` | [object](#options) | **Yes** | | | - -### Options - -| Property | Type | Required | Default | Description | -|-------------------------|----------|----------|---------|-------------| -| `limit` | uint32 | **Yes** | `10` | | -| `navigateAfter` | string | **Yes** | `10m` | | -| `navigateBefore` | string | **Yes** | `10m` | | -| `navigateToPanel` | boolean | **Yes** | `true` | | -| `onlyFromThisDashboard` | boolean | **Yes** | `false` | | -| `onlyInTimeRange` | boolean | **Yes** | `false` | | -| `showTags` | boolean | **Yes** | `true` | | -| `showTime` | boolean | **Yes** | `true` | | -| `showUser` | boolean | **Yes** | `true` | | -| `tags` | string[] | **Yes** | | | - - diff --git a/docs/sources/developers/kinds/composable/azuremonitor/dataquery/schema-reference.md b/docs/sources/developers/kinds/composable/azuremonitor/dataquery/schema-reference.md deleted file mode 100644 index 7c8f824177899..0000000000000 --- a/docs/sources/developers/kinds/composable/azuremonitor/dataquery/schema-reference.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: AzureMonitorDataQuery kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## AzureMonitorDataQuery - -#### Maturity: [merged](../../../maturity/#merged) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - - diff --git a/docs/sources/developers/kinds/composable/barchart/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/barchart/panelcfg/schema-reference.md deleted file mode 100644 index e07747b77e674..0000000000000 --- a/docs/sources/developers/kinds/composable/barchart/panelcfg/schema-reference.md +++ /dev/null @@ -1,181 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: BarChartPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## BarChartPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|---------------|------------------------|----------|---------|-------------| -| `FieldConfig` | [object](#fieldconfig) | **Yes** | | | -| `Options` | [object](#options) | **Yes** | | | - -### FieldConfig - -It extends [AxisConfig](#axisconfig) and [HideableFieldConfig](#hideablefieldconfig). - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------------|----------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisCenteredZero` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisColorMode` | string | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisLabel` | string | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisPlacement` | string | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisSoftMin` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisWidth` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `fillOpacity` | integer | No | `80` | Controls the fill opacity of the bars.<br/>Constraint: `>=0 & <=100`. | -| `gradientMode` | string | No | | Set the mode of the gradient fill. Fill gradient is based on the line color. To change the color, use the standard color scheme field option.<br/>Gradient appearance is influenced by the Fill opacity setting. | -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | *(Inherited from [HideableFieldConfig](#hideablefieldconfig))*<br/>TODO docs | -| `lineWidth` | integer | No | `1` | Controls line width of the bars.<br/>Constraint: `>=0 & <=10`. | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs | -| `thresholdsStyle` | [GraphThresholdsStyleConfig](#graphthresholdsstyleconfig) | No | | TODO docs | - -### AxisConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | | -| `axisCenteredZero` | boolean | No | | | -| `axisColorMode` | string | No | | TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | | -| `axisLabel` | string | No | | | -| `axisPlacement` | string | No | | TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | | -| `axisSoftMin` | number | No | | | -| `axisWidth` | number | No | | | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | TODO docs | - -### ScaleDistributionConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-------------------|--------|----------|---------|--------------------------------------------------------------------------| -| `type` | string | **Yes** | | TODO docs<br/>Possible values are: `linear`, `log`, `ordinal`, `symlog`. | -| `linearThreshold` | number | No | | | -| `log` | number | No | | | - -### GraphThresholdsStyleConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-----------------------------------------------------------------------------------------------------------| -| `mode` | string | **Yes** | | TODO docs<br/>Possible values are: `off`, `line`, `dashed`, `area`, `line+area`, `dashed+area`, `series`. | - -### HideSeriesConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|---------|----------|---------|-------------| -| `legend` | boolean | **Yes** | | | -| `tooltip` | boolean | **Yes** | | | -| `viz` | boolean | **Yes** | | | - -### HideableFieldConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|---------------------------------------|----------|---------|-------------| -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | TODO docs | - -### Options - -It extends [OptionsWithLegend](#optionswithlegend) and [OptionsWithTooltip](#optionswithtooltip) and [OptionsWithTextFormatting](#optionswithtextformatting). - -| Property | Type | Required | Default | Description | -|-----------------------|-------------------------------------------------|----------|---------|------------------------------------------------------------------------------------------------------------------| -| `barWidth` | number | **Yes** | `0.97` | Controls the width of bars. 1 = Max width, 0 = Min width.<br/>Constraint: `>=0 & <=1`. | -| `fullHighlight` | boolean | **Yes** | `false` | Enables mode which highlights the entire bar area and shows tooltip when cursor<br/>hovers over highlighted area | -| `groupWidth` | number | **Yes** | `0.7` | Controls the width of groups. 1 = max with, 0 = min width.<br/>Constraint: `>=0 & <=1`. | -| `legend` | [VizLegendOptions](#vizlegendoptions) | **Yes** | | *(Inherited from [OptionsWithLegend](#optionswithlegend))*<br/>TODO docs | -| `orientation` | string | **Yes** | | Controls the orientation of the bar chart, either vertical or horizontal. | -| `showValue` | string | **Yes** | | This controls whether values are shown on top or to the left of bars. | -| `stacking` | string | **Yes** | | Controls whether bars are stacked or not, either normally or in percent mode. | -| `tooltip` | [VizTooltipOptions](#viztooltipoptions) | **Yes** | | *(Inherited from [OptionsWithTooltip](#optionswithtooltip))*<br/>TODO docs | -| `xTickLabelMaxLength` | integer | **Yes** | | Sets the max length that a label can have before it is truncated.<br/>Constraint: `>=0 & <=2147483647`. | -| `xTickLabelRotation` | integer | **Yes** | `0` | Controls the rotation of the x axis labels.<br/>Constraint: `>=-90 & <=90`. | -| `barRadius` | number | No | `0` | Controls the radius of each bar.<br/>Constraint: `>=0 & <=0.5`. | -| `colorByField` | string | No | | Use the color value for a sibling field to color each bar value. | -| `text` | [VizTextDisplayOptions](#viztextdisplayoptions) | No | | *(Inherited from [OptionsWithTextFormatting](#optionswithtextformatting))*<br/>TODO docs | -| `xField` | string | No | | Manually select which field from the dataset to represent the x field. | -| `xTickLabelSpacing` | int32 | No | `0` | Controls the spacing between x axis labels.<br/>negative values indicate backwards skipping behavior | - -### OptionsWithLegend - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|---------------------------------------|----------|---------|-------------| -| `legend` | [VizLegendOptions](#vizlegendoptions) | **Yes** | | TODO docs | - -### VizLegendOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|----------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `calcs` | string[] | **Yes** | | | -| `displayMode` | string | **Yes** | | TODO docs<br/>Note: "hidden" needs to remain as an option for plugins compatibility<br/>Possible values are: `list`, `table`, `hidden`. | -| `placement` | string | **Yes** | | TODO docs<br/>Possible values are: `bottom`, `right`. | -| `showLegend` | boolean | **Yes** | | | -| `asTable` | boolean | No | | | -| `isVisible` | boolean | No | | | -| `sortBy` | string | No | | | -| `sortDesc` | boolean | No | | | -| `width` | number | No | | | - -### OptionsWithTextFormatting - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|-------------------------------------------------|----------|---------|-------------| -| `text` | [VizTextDisplayOptions](#viztextdisplayoptions) | No | | TODO docs | - -### VizTextDisplayOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|-------------|--------|----------|---------|--------------------------| -| `titleSize` | number | No | | Explicit title text size | -| `valueSize` | number | No | | Explicit value text size | - -### OptionsWithTooltip - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|-----------------------------------------|----------|---------|-------------| -| `tooltip` | [VizTooltipOptions](#viztooltipoptions) | **Yes** | | TODO docs | - -### VizTooltipOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|---------------------------------------------------------------| -| `mode` | string | **Yes** | | TODO docs<br/>Possible values are: `single`, `multi`, `none`. | -| `sort` | string | **Yes** | | TODO docs<br/>Possible values are: `asc`, `desc`, `none`. | - - diff --git a/docs/sources/developers/kinds/composable/bargauge/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/bargauge/panelcfg/schema-reference.md deleted file mode 100644 index db7bc0e5c09df..0000000000000 --- a/docs/sources/developers/kinds/composable/bargauge/panelcfg/schema-reference.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: BarGaugePanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## BarGaugePanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-----------|--------------------|----------|---------|-------------| -| `Options` | [object](#options) | **Yes** | | | - -### Options - -It extends [SingleStatBaseOptions](#singlestatbaseoptions). - -| Property | Type | Required | Default | Description | -|-----------------|-------------------------------------------------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------| -| `displayMode` | string | **Yes** | | Enum expressing the possible display modes<br/>for the bar gauge component of Grafana UI<br/>Possible values are: `basic`, `lcd`, `gradient`. | -| `maxVizHeight` | uint32 | **Yes** | `300` | | -| `minVizHeight` | uint32 | **Yes** | `16` | | -| `minVizWidth` | uint32 | **Yes** | `8` | | -| `namePlacement` | string | **Yes** | | Allows for the bar gauge name to be placed explicitly<br/>Possible values are: `auto`, `top`, `left`. | -| `showUnfilled` | boolean | **Yes** | `true` | | -| `sizing` | string | **Yes** | | Allows for the bar gauge size to be set explicitly<br/>Possible values are: `auto`, `manual`. | -| `valueMode` | string | **Yes** | | Allows for the table cell gauge display type to set the gauge mode.<br/>Possible values are: `color`, `text`, `hidden`. | -| `orientation` | string | No | | *(Inherited from [SingleStatBaseOptions](#singlestatbaseoptions))*<br/>TODO docs<br/>Possible values are: `auto`, `vertical`, `horizontal`. | -| `reduceOptions` | [ReduceDataOptions](#reducedataoptions) | No | | *(Inherited from [SingleStatBaseOptions](#singlestatbaseoptions))*<br/>TODO docs | -| `text` | [VizTextDisplayOptions](#viztextdisplayoptions) | No | | *(Inherited from [SingleStatBaseOptions](#singlestatbaseoptions))*<br/>TODO docs | - -### ReduceDataOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|----------|----------|---------|---------------------------------------------------------------| -| `calcs` | string[] | **Yes** | | When !values, pick one value for the whole field | -| `fields` | string | No | | Which fields to show. By default this is only numeric fields | -| `limit` | number | No | | if showing all values limit | -| `values` | boolean | No | | If true show each row value | - -### SingleStatBaseOptions - -TODO docs - -It extends [OptionsWithTextFormatting](#optionswithtextformatting). - -| Property | Type | Required | Default | Description | -|-----------------|-------------------------------------------------|----------|---------|------------------------------------------------------------------------------------------| -| `orientation` | string | **Yes** | | TODO docs<br/>Possible values are: `auto`, `vertical`, `horizontal`. | -| `reduceOptions` | [ReduceDataOptions](#reducedataoptions) | **Yes** | | TODO docs | -| `text` | [VizTextDisplayOptions](#viztextdisplayoptions) | No | | *(Inherited from [OptionsWithTextFormatting](#optionswithtextformatting))*<br/>TODO docs | - -### OptionsWithTextFormatting - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|-------------------------------------------------|----------|---------|-------------| -| `text` | [VizTextDisplayOptions](#viztextdisplayoptions) | No | | TODO docs | - -### VizTextDisplayOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|-------------|--------|----------|---------|--------------------------| -| `titleSize` | number | No | | Explicit title text size | -| `valueSize` | number | No | | Explicit value text size | - - diff --git a/docs/sources/developers/kinds/composable/candlestick/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/candlestick/panelcfg/schema-reference.md deleted file mode 100644 index 75fe334da4397..0000000000000 --- a/docs/sources/developers/kinds/composable/candlestick/panelcfg/schema-reference.md +++ /dev/null @@ -1,257 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: CandlestickPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## CandlestickPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-----------------------|---------------------------------------|----------|---------|-------------------------------------------------------------| -| `CandleStyle` | string | **Yes** | | Possible values are: `candles`, `ohlcbars`. | -| `CandlestickColors` | [object](#candlestickcolors) | **Yes** | | | -| `CandlestickFieldMap` | [object](#candlestickfieldmap) | **Yes** | | | -| `ColorStrategy` | string | **Yes** | | Possible values are: `open-close`, `close-close`. | -| `FieldConfig` | [GraphFieldConfig](#graphfieldconfig) | **Yes** | | TODO docs | -| `Options` | [object](#options) | **Yes** | | | -| `VizDisplayMode` | string | **Yes** | | Possible values are: `candles+volume`, `candles`, `volume`. | - -### CandlestickColors - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-------------| -| `down` | string | **Yes** | `red` | | -| `flat` | string | **Yes** | `gray` | | -| `up` | string | **Yes** | `green` | | - -### CandlestickFieldMap - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|------------------------------------------------------------------------------| -| `close` | string | No | | Corresponds to the final (end) value of the given period | -| `high` | string | No | | Corresponds to the highest value of the given period | -| `low` | string | No | | Corresponds to the lowest value of the given period | -| `open` | string | No | | Corresponds to the starting value of the given period | -| `volume` | string | No | | Corresponds to the sample count in the given period. (e.g. number of trades) | - -### GraphFieldConfig - -TODO docs - -It extends [LineConfig](#lineconfig) and [FillConfig](#fillconfig) and [PointsConfig](#pointsconfig) and [AxisConfig](#axisconfig) and [BarConfig](#barconfig) and [StackableFieldConfig](#stackablefieldconfig) and [HideableFieldConfig](#hideablefieldconfig). - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------------|----------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisCenteredZero` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisColorMode` | string | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisLabel` | string | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisPlacement` | string | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisSoftMin` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisWidth` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `barAlignment` | integer | No | | *(Inherited from [BarConfig](#barconfig))*<br/>TODO docs<br/>Possible values are: `-1`, `0`, `1`. | -| `barMaxWidth` | number | No | | *(Inherited from [BarConfig](#barconfig))* | -| `barWidthFactor` | number | No | | *(Inherited from [BarConfig](#barconfig))* | -| `drawStyle` | string | No | | TODO docs<br/>Possible values are: `line`, `bars`, `points`. | -| `fillBelowTo` | string | No | | *(Inherited from [FillConfig](#fillconfig))* | -| `fillColor` | string | No | | *(Inherited from [FillConfig](#fillconfig))* | -| `fillOpacity` | number | No | | *(Inherited from [FillConfig](#fillconfig))* | -| `gradientMode` | string | No | | TODO docs<br/>Possible values are: `none`, `opacity`, `hue`, `scheme`. | -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | *(Inherited from [HideableFieldConfig](#hideablefieldconfig))*<br/>TODO docs | -| `lineColor` | string | No | | *(Inherited from [LineConfig](#lineconfig))* | -| `lineInterpolation` | string | No | | *(Inherited from [LineConfig](#lineconfig))*<br/>TODO docs<br/>Possible values are: `linear`, `smooth`, `stepBefore`, `stepAfter`. | -| `lineStyle` | [LineStyle](#linestyle) | No | | *(Inherited from [LineConfig](#lineconfig))*<br/>TODO docs | -| `lineWidth` | number | No | | *(Inherited from [LineConfig](#lineconfig))* | -| `pointColor` | string | No | | *(Inherited from [PointsConfig](#pointsconfig))* | -| `pointSize` | number | No | | *(Inherited from [PointsConfig](#pointsconfig))* | -| `pointSymbol` | string | No | | *(Inherited from [PointsConfig](#pointsconfig))* | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs | -| `showPoints` | string | No | | *(Inherited from [PointsConfig](#pointsconfig))*<br/>TODO docs<br/>Possible values are: `auto`, `never`, `always`. | -| `spanNulls` | | No | | *(Inherited from [LineConfig](#lineconfig))*<br/>Indicate if null values should be treated as gaps or connected.<br/>When the value is a number, it represents the maximum delta in the<br/>X axis that should be considered connected. For timeseries, this is milliseconds | -| `stacking` | [StackingConfig](#stackingconfig) | No | | *(Inherited from [StackableFieldConfig](#stackablefieldconfig))*<br/>TODO docs | -| `thresholdsStyle` | [GraphThresholdsStyleConfig](#graphthresholdsstyleconfig) | No | | TODO docs | -| `transform` | string | No | | TODO docs<br/>Possible values are: `constant`, `negative-Y`. | - -### AxisConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | | -| `axisCenteredZero` | boolean | No | | | -| `axisColorMode` | string | No | | TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | | -| `axisLabel` | string | No | | | -| `axisPlacement` | string | No | | TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | | -| `axisSoftMin` | number | No | | | -| `axisWidth` | number | No | | | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | TODO docs | - -### ScaleDistributionConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-------------------|--------|----------|---------|--------------------------------------------------------------------------| -| `type` | string | **Yes** | | TODO docs<br/>Possible values are: `linear`, `log`, `ordinal`, `symlog`. | -| `linearThreshold` | number | No | | | -| `log` | number | No | | | - -### BarConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------------|---------|----------|---------|----------------------------------------------------| -| `barAlignment` | integer | No | | TODO docs<br/>Possible values are: `-1`, `0`, `1`. | -| `barMaxWidth` | number | No | | | -| `barWidthFactor` | number | No | | | - -### FillConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|--------|----------|---------|-------------| -| `fillBelowTo` | string | No | | | -| `fillColor` | string | No | | | -| `fillOpacity` | number | No | | | - -### GraphThresholdsStyleConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-----------------------------------------------------------------------------------------------------------| -| `mode` | string | **Yes** | | TODO docs<br/>Possible values are: `off`, `line`, `dashed`, `area`, `line+area`, `dashed+area`, `series`. | - -### HideSeriesConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|---------|----------|---------|-------------| -| `legend` | boolean | **Yes** | | | -| `tooltip` | boolean | **Yes** | | | -| `viz` | boolean | **Yes** | | | - -### HideableFieldConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|---------------------------------------|----------|---------|-------------| -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | TODO docs | - -### LineConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------------|-------------------------|----------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `lineColor` | string | No | | | -| `lineInterpolation` | string | No | | TODO docs<br/>Possible values are: `linear`, `smooth`, `stepBefore`, `stepAfter`. | -| `lineStyle` | [LineStyle](#linestyle) | No | | TODO docs | -| `lineWidth` | number | No | | | -| `spanNulls` | | No | | Indicate if null values should be treated as gaps or connected.<br/>When the value is a number, it represents the maximum delta in the<br/>X axis that should be considered connected. For timeseries, this is milliseconds | - -### LineStyle - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|----------|----------|---------|--------------------------------------------------------| -| `dash` | number[] | No | | | -| `fill` | string | No | | Possible values are: `solid`, `dash`, `dot`, `square`. | - -### PointsConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|--------|----------|---------|---------------------------------------------------------------| -| `pointColor` | string | No | | | -| `pointSize` | number | No | | | -| `pointSymbol` | string | No | | | -| `showPoints` | string | No | | TODO docs<br/>Possible values are: `auto`, `never`, `always`. | - -### StackableFieldConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|-----------------------------------|----------|---------|-------------| -| `stacking` | [StackingConfig](#stackingconfig) | No | | TODO docs | - -### StackingConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-----------------------------------------------------------------| -| `group` | string | No | | | -| `mode` | string | No | | TODO docs<br/>Possible values are: `none`, `normal`, `percent`. | - -### Options - -It extends [OptionsWithLegend](#optionswithlegend). - -| Property | Type | Required | Default | Description | -|--------------------|-----------------------------------------|----------|---------|--------------------------------------------------------------------------| -| `candleStyle` | string | **Yes** | | Sets the style of the candlesticks | -| `colorStrategy` | string | **Yes** | | Sets the color strategy for the candlesticks | -| `colors` | [CandlestickColors](#candlestickcolors) | **Yes** | | | -| `fields` | [object](#fields) | **Yes** | `map[]` | Map fields to appropriate dimension | -| `legend` | [VizLegendOptions](#vizlegendoptions) | **Yes** | | *(Inherited from [OptionsWithLegend](#optionswithlegend))*<br/>TODO docs | -| `mode` | string | **Yes** | | Sets which dimensions are used for the visualization | -| `includeAllFields` | boolean | No | `false` | When enabled, all fields will be sent to the graph | - -### OptionsWithLegend - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|---------------------------------------|----------|---------|-------------| -| `legend` | [VizLegendOptions](#vizlegendoptions) | **Yes** | | TODO docs | - -### VizLegendOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|----------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `calcs` | string[] | **Yes** | | | -| `displayMode` | string | **Yes** | | TODO docs<br/>Note: "hidden" needs to remain as an option for plugins compatibility<br/>Possible values are: `list`, `table`, `hidden`. | -| `placement` | string | **Yes** | | TODO docs<br/>Possible values are: `bottom`, `right`. | -| `showLegend` | boolean | **Yes** | | | -| `asTable` | boolean | No | | | -| `isVisible` | boolean | No | | | -| `sortBy` | string | No | | | -| `sortDesc` | boolean | No | | | -| `width` | number | No | | | - -### Fields - -Map fields to appropriate dimension - -| Property | Type | Required | Default | Description | -|----------|-----------------------------------|----------|---------|-------------| -| `object` | Possible types are: [](#), [](#). | | | - - diff --git a/docs/sources/developers/kinds/composable/canvas/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/canvas/panelcfg/schema-reference.md deleted file mode 100644 index ca4647b16ff5e..0000000000000 --- a/docs/sources/developers/kinds/composable/canvas/panelcfg/schema-reference.md +++ /dev/null @@ -1,160 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: CanvasPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## CanvasPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-------------------------|----------------------------------|----------|---------|-----------------------------------------------------------------------| -| `BackgroundConfig` | [object](#backgroundconfig) | **Yes** | | | -| `BackgroundImageSize` | string | **Yes** | | Possible values are: `original`, `contain`, `cover`, `fill`, `tile`. | -| `CanvasConnection` | [object](#canvasconnection) | **Yes** | | | -| `CanvasElementOptions` | [object](#canvaselementoptions) | **Yes** | | | -| `ConnectionCoordinates` | [object](#connectioncoordinates) | **Yes** | | | -| `ConnectionPath` | string | **Yes** | | Possible values are: `straight`. | -| `Constraint` | [object](#constraint) | **Yes** | | | -| `HorizontalConstraint` | string | **Yes** | | Possible values are: `left`, `right`, `leftright`, `center`, `scale`. | -| `HttpRequestMethod` | string | **Yes** | | Possible values are: `GET`, `POST`, `PUT`. | -| `LineConfig` | [object](#lineconfig) | **Yes** | | | -| `Options` | [object](#options) | **Yes** | | | -| `Placement` | [object](#placement) | **Yes** | | | -| `VerticalConstraint` | string | **Yes** | | Possible values are: `top`, `bottom`, `topbottom`, `center`, `scale`. | - -### BackgroundConfig - -| Property | Type | Required | Default | Description | -|----------|-----------------------------------------------------|----------|---------|----------------------------------------------------------------------| -| `color` | [ColorDimensionConfig](#colordimensionconfig) | No | | | -| `image` | [ResourceDimensionConfig](#resourcedimensionconfig) | No | | Links to a resource (image/svg path) | -| `size` | string | No | | Possible values are: `original`, `contain`, `cover`, `fill`, `tile`. | - -### ColorDimensionConfig - -It extends [BaseDimensionConfig](#basedimensionconfig). - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|--------------------------------------------------------------------------------------------------------------| -| `field` | string | No | | *(Inherited from [BaseDimensionConfig](#basedimensionconfig))*<br/>fixed: T -- will be added by each element | -| `fixed` | string | No | | | - -### BaseDimensionConfig - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-------------------------------------------| -| `field` | string | No | | fixed: T -- will be added by each element | - -### ResourceDimensionConfig - -Links to a resource (image/svg path) - -It extends [BaseDimensionConfig](#basedimensionconfig). - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|--------------------------------------------------------------------------------------------------------------| -| `mode` | string | **Yes** | | Possible values are: `fixed`, `field`, `mapping`. | -| `field` | string | No | | *(Inherited from [BaseDimensionConfig](#basedimensionconfig))*<br/>fixed: T -- will be added by each element | -| `fixed` | string | No | | | - -### CanvasConnection - -| Property | Type | Required | Default | Description | -|--------------|-------------------------------------------------|----------|---------|----------------------------------| -| `path` | string | **Yes** | | Possible values are: `straight`. | -| `source` | [ConnectionCoordinates](#connectioncoordinates) | **Yes** | | | -| `target` | [ConnectionCoordinates](#connectioncoordinates) | **Yes** | | | -| `color` | [ColorDimensionConfig](#colordimensionconfig) | No | | | -| `size` | [ScaleDimensionConfig](#scaledimensionconfig) | No | | | -| `targetName` | string | No | | | - -### ConnectionCoordinates - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-------------| -| `x` | number | **Yes** | | | -| `y` | number | **Yes** | | | - -### ScaleDimensionConfig - -It extends [BaseDimensionConfig](#basedimensionconfig). - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|--------------------------------------------------------------------------------------------------------------| -| `max` | number | **Yes** | | | -| `min` | number | **Yes** | | | -| `field` | string | No | | *(Inherited from [BaseDimensionConfig](#basedimensionconfig))*<br/>fixed: T -- will be added by each element | -| `fixed` | number | No | | | -| `mode` | string | No | | Possible values are: `linear`, `quad`. | - -### CanvasElementOptions - -| Property | Type | Required | Default | Description | -|---------------|-----------------------------------------|----------|---------|---------------------------------------------------------| -| `name` | string | **Yes** | | | -| `type` | string | **Yes** | | | -| `background` | [BackgroundConfig](#backgroundconfig) | No | | | -| `border` | [LineConfig](#lineconfig) | No | | | -| `config` | | No | | TODO: figure out how to define this (element config(s)) | -| `connections` | [CanvasConnection](#canvasconnection)[] | No | | | -| `constraint` | [Constraint](#constraint) | No | | | -| `placement` | [Placement](#placement) | No | | | - -### Constraint - -| Property | Type | Required | Default | Description | -|--------------|--------|----------|---------|-----------------------------------------------------------------------| -| `horizontal` | string | No | | Possible values are: `left`, `right`, `leftright`, `center`, `scale`. | -| `vertical` | string | No | | Possible values are: `top`, `bottom`, `topbottom`, `center`, `scale`. | - -### LineConfig - -| Property | Type | Required | Default | Description | -|----------|-----------------------------------------------|----------|---------|-------------| -| `color` | [ColorDimensionConfig](#colordimensionconfig) | No | | | -| `width` | number | No | | | - -### Placement - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-------------| -| `bottom` | number | No | | | -| `height` | number | No | | | -| `left` | number | No | | | -| `right` | number | No | | | -| `top` | number | No | | | -| `width` | number | No | | | - -### Options - -| Property | Type | Required | Default | Description | -|---------------------|-----------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------| -| `inlineEditing` | boolean | **Yes** | `true` | Enable inline editing | -| `panZoom` | boolean | **Yes** | `true` | Enable pan and zoom | -| `root` | [object](#root) | **Yes** | | The root element of canvas (frame), where all canvas elements are nested<br/>TODO: Figure out how to define a default value for this | -| `showAdvancedTypes` | boolean | **Yes** | `true` | Show all available element types | - -### Root - -The root element of canvas (frame), where all canvas elements are nested -TODO: Figure out how to define a default value for this - -| Property | Type | Required | Default | Description | -|------------|-------------------------------------------------|----------|---------|----------------------------------------------------------------| -| `elements` | [CanvasElementOptions](#canvaselementoptions)[] | **Yes** | | The list of canvas elements attached to the root element | -| `name` | string | **Yes** | | Name of the root element | -| `type` | string | **Yes** | | Type of root element (frame)<br/>Possible values are: `frame`. | - - diff --git a/docs/sources/developers/kinds/composable/cloudwatch/dataquery/schema-reference.md b/docs/sources/developers/kinds/composable/cloudwatch/dataquery/schema-reference.md deleted file mode 100644 index 78cb0d0f470ab..0000000000000 --- a/docs/sources/developers/kinds/composable/cloudwatch/dataquery/schema-reference.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: CloudWatchDataQuery kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## CloudWatchDataQuery - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - - diff --git a/docs/sources/developers/kinds/composable/dashboardlist/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/dashboardlist/panelcfg/schema-reference.md deleted file mode 100644 index 732b4ea58377b..0000000000000 --- a/docs/sources/developers/kinds/composable/dashboardlist/panelcfg/schema-reference.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: DashboardListPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## DashboardListPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-----------|--------------------|----------|---------|-------------| -| `Options` | [object](#options) | **Yes** | | | - -### Options - -| Property | Type | Required | Default | Description | -|----------------------|----------|----------|---------|-----------------------------------------------------------------| -| `includeVars` | boolean | **Yes** | `false` | | -| `keepTime` | boolean | **Yes** | `false` | | -| `maxItems` | integer | **Yes** | `10` | | -| `query` | string | **Yes** | `` | | -| `showHeadings` | boolean | **Yes** | `true` | | -| `showRecentlyViewed` | boolean | **Yes** | `false` | | -| `showSearch` | boolean | **Yes** | `false` | | -| `showStarred` | boolean | **Yes** | `true` | | -| `tags` | string[] | **Yes** | | | -| `folderId` | integer | No | | folderId is deprecated, and migrated to folderUid on panel init | -| `folderUID` | string | No | | | - - diff --git a/docs/sources/developers/kinds/composable/datagrid/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/datagrid/panelcfg/schema-reference.md deleted file mode 100644 index adc4c54aec41e..0000000000000 --- a/docs/sources/developers/kinds/composable/datagrid/panelcfg/schema-reference.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: DatagridPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## DatagridPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-----------|--------------------|----------|---------|-------------| -| `Options` | [object](#options) | **Yes** | | | - -### Options - -| Property | Type | Required | Default | Description | -|------------------|---------|----------|---------|-----------------------------------| -| `selectedSeries` | integer | **Yes** | `0` | Constraint: `>=0 & <=2147483647`. | - - diff --git a/docs/sources/developers/kinds/composable/debug/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/debug/panelcfg/schema-reference.md deleted file mode 100644 index 90832442886fe..0000000000000 --- a/docs/sources/developers/kinds/composable/debug/panelcfg/schema-reference.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: DebugPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## DebugPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|----------------|-------------------------|----------|---------|---------------------------------------------------------------------------| -| `DebugMode` | string | **Yes** | | Possible values are: `render`, `events`, `cursor`, `State`, `ThrowError`. | -| `Options` | [object](#options) | **Yes** | | | -| `UpdateConfig` | [object](#updateconfig) | **Yes** | | | - -### Options - -| Property | Type | Required | Default | Description | -|------------|-------------------------------|----------|---------|---------------------------------------------------------------------------| -| `mode` | string | **Yes** | | Possible values are: `render`, `events`, `cursor`, `State`, `ThrowError`. | -| `counters` | [UpdateConfig](#updateconfig) | No | | | - -### UpdateConfig - -| Property | Type | Required | Default | Description | -|-----------------|---------|----------|---------|-------------| -| `dataChanged` | boolean | **Yes** | | | -| `render` | boolean | **Yes** | | | -| `schemaChanged` | boolean | **Yes** | | | - - diff --git a/docs/sources/developers/kinds/composable/elasticsearch/dataquery/schema-reference.md b/docs/sources/developers/kinds/composable/elasticsearch/dataquery/schema-reference.md deleted file mode 100644 index 384fe82ee0ab5..0000000000000 --- a/docs/sources/developers/kinds/composable/elasticsearch/dataquery/schema-reference.md +++ /dev/null @@ -1,283 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: ElasticsearchDataQuery kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## ElasticsearchDataQuery - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|--------------|-------------------------------------------|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `refId` | string | **Yes** | | A unique identifier for the query within the list of targets.<br/>In server side expressions, the refId is used as a variable name to identify results.<br/>By default, the UI will assign A->Z; however setting meaningful names may be useful. | -| `alias` | string | No | | Alias pattern | -| `bucketAggs` | [BucketAggregation](#bucketaggregation)[] | No | | List of bucket aggregations | -| `datasource` | | No | | For mixed data sources the selected datasource is on the query level.<br/>For non mixed scenarios this is undefined.<br/>TODO find a better way to do this ^ that's friendly to schema<br/>TODO this shouldn't be unknown but DataSourceRef | null | -| `hide` | boolean | No | | true if query is disabled (ie should not be returned to the dashboard)<br/>Note this does not always imply that the query should not be executed since<br/>the results from a hidden query may be used as the input to other queries (SSE etc) | -| `metrics` | [MetricAggregation](#metricaggregation)[] | No | | List of metric aggregations | -| `queryType` | string | No | | Specify the query flavor<br/>TODO make this required and give it a default | -| `query` | string | No | | Lucene query | -| `timeField` | string | No | | Name of time field | - -### BucketAggregation - -| Property | Type | Required | Default | Description | -|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|---------|-------------| -| `object` | Possible types are: [DateHistogram](#datehistogram), [Histogram](#histogram), [Terms](#terms), [Filters](#filters), [GeoHashGrid](#geohashgrid), [Nested](#nested). | | | - -### DateHistogram - -It extends [BucketAggregationWithField](#bucketaggregationwithfield). - -| Property | Type | Required | Default | Description | -|------------|--------|----------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | string | **Yes** | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))*<br/>Possible values are: `terms`, `filters`, `geohash_grid`, `date_histogram`, `histogram`, `nested`. | -| `field` | string | No | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))* | -| `id` | string | No | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))* | -| `settings` | | No | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))* | - -### BucketAggregationWithField - -It extends [BaseBucketAggregation](#basebucketaggregation). - -| Property | Type | Required | Default | Description | -|------------|--------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `id` | string | **Yes** | | *(Inherited from [BaseBucketAggregation](#basebucketaggregation))* | -| `type` | string | **Yes** | | *(Inherited from [BaseBucketAggregation](#basebucketaggregation))*<br/>Possible values are: `terms`, `filters`, `geohash_grid`, `date_histogram`, `histogram`, `nested`. | -| `field` | string | No | | | -| `settings` | | No | | *(Inherited from [BaseBucketAggregation](#basebucketaggregation))* | - -### BaseBucketAggregation - -| Property | Type | Required | Default | Description | -|------------|--------|----------|---------|---------------------------------------------------------------------------------------------------| -| `id` | string | **Yes** | | | -| `type` | string | **Yes** | | Possible values are: `terms`, `filters`, `geohash_grid`, `date_histogram`, `histogram`, `nested`. | -| `settings` | | No | | | - -### Filters - -It extends [BaseBucketAggregation](#basebucketaggregation). - -| Property | Type | Required | Default | Description | -|------------|--------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `id` | string | **Yes** | | *(Inherited from [BaseBucketAggregation](#basebucketaggregation))* | -| `type` | string | **Yes** | | *(Inherited from [BaseBucketAggregation](#basebucketaggregation))*<br/>Possible values are: `terms`, `filters`, `geohash_grid`, `date_histogram`, `histogram`, `nested`. | -| `settings` | | No | | *(Inherited from [BaseBucketAggregation](#basebucketaggregation))* | - -### GeoHashGrid - -It extends [BucketAggregationWithField](#bucketaggregationwithfield). - -| Property | Type | Required | Default | Description | -|------------|--------|----------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | string | **Yes** | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))*<br/>Possible values are: `terms`, `filters`, `geohash_grid`, `date_histogram`, `histogram`, `nested`. | -| `field` | string | No | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))* | -| `id` | string | No | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))* | -| `settings` | | No | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))* | - -### Histogram - -It extends [BucketAggregationWithField](#bucketaggregationwithfield). - -| Property | Type | Required | Default | Description | -|------------|--------|----------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | string | **Yes** | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))*<br/>Possible values are: `terms`, `filters`, `geohash_grid`, `date_histogram`, `histogram`, `nested`. | -| `field` | string | No | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))* | -| `id` | string | No | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))* | -| `settings` | | No | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))* | - -### Nested - -It extends [BucketAggregationWithField](#bucketaggregationwithfield). - -| Property | Type | Required | Default | Description | -|------------|--------|----------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | string | **Yes** | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))*<br/>Possible values are: `terms`, `filters`, `geohash_grid`, `date_histogram`, `histogram`, `nested`. | -| `field` | string | No | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))* | -| `id` | string | No | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))* | -| `settings` | | No | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))* | - -### Terms - -It extends [BucketAggregationWithField](#bucketaggregationwithfield). - -| Property | Type | Required | Default | Description | -|------------|--------|----------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | string | **Yes** | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))*<br/>Possible values are: `terms`, `filters`, `geohash_grid`, `date_histogram`, `histogram`, `nested`. | -| `field` | string | No | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))* | -| `id` | string | No | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))* | -| `settings` | | No | | *(Inherited from [BucketAggregationWithField](#bucketaggregationwithfield))* | - -### MetricAggregation - -| Property | Type | Required | Default | Description | -|----------|------------------------------------------------------------------------------------------------------|----------|---------|-------------| -| `object` | Possible types are: [Count](#count), [PipelineMetricAggregation](#pipelinemetricaggregation), [](#). | | | - -### Count - -It extends [BaseMetricAggregation](#basemetricaggregation). - -| Property | Type | Required | Default | Description | -|----------|---------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `id` | string | **Yes** | | *(Inherited from [BaseMetricAggregation](#basemetricaggregation))* | -| `type` | string | **Yes** | | *(Inherited from [BaseMetricAggregation](#basemetricaggregation))*<br/>Possible values are: `count`, `avg`, `sum`, `min`, `max`, `extended_stats`, `percentiles`, `cardinality`, `raw_document`, `raw_data`, `logs`, `rate`, `top_metrics`, `moving_avg`, `moving_fn`, `derivative`, `serial_diff`, `cumulative_sum`, `bucket_script`. | -| `hide` | boolean | No | | *(Inherited from [BaseMetricAggregation](#basemetricaggregation))* | - -### BaseMetricAggregation - -| Property | Type | Required | Default | Description | -|----------|---------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `id` | string | **Yes** | | | -| `type` | string | **Yes** | | Possible values are: `count`, `avg`, `sum`, `min`, `max`, `extended_stats`, `percentiles`, `cardinality`, `raw_document`, `raw_data`, `logs`, `rate`, `top_metrics`, `moving_avg`, `moving_fn`, `derivative`, `serial_diff`, `cumulative_sum`, `bucket_script`. | -| `hide` | boolean | No | | | - -### PipelineMetricAggregation - -| Property | Type | Required | Default | Description | -|----------|-------------------------------------------------------------------------------------------------------------------------------------------------|----------|---------|-------------| -| `object` | Possible types are: [MovingAverage](#movingaverage), [Derivative](#derivative), [CumulativeSum](#cumulativesum), [BucketScript](#bucketscript). | | | - -### BucketScript - -It extends [PipelineMetricAggregationWithMultipleBucketPaths](#pipelinemetricaggregationwithmultiplebucketpaths). - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | string | **Yes** | | *(Inherited from [PipelineMetricAggregationWithMultipleBucketPaths](#pipelinemetricaggregationwithmultiplebucketpaths))*<br/>Possible values are: `count`, `avg`, `sum`, `min`, `max`, `extended_stats`, `percentiles`, `cardinality`, `raw_document`, `raw_data`, `logs`, `rate`, `top_metrics`, `moving_avg`, `moving_fn`, `derivative`, `serial_diff`, `cumulative_sum`, `bucket_script`. | -| `hide` | boolean | No | | *(Inherited from [PipelineMetricAggregationWithMultipleBucketPaths](#pipelinemetricaggregationwithmultiplebucketpaths))* | -| `id` | string | No | | *(Inherited from [PipelineMetricAggregationWithMultipleBucketPaths](#pipelinemetricaggregationwithmultiplebucketpaths))* | -| `pipelineVariables` | [PipelineVariable](#pipelinevariable)[] | No | | *(Inherited from [PipelineMetricAggregationWithMultipleBucketPaths](#pipelinemetricaggregationwithmultiplebucketpaths))* | -| `settings` | [object](#settings) | No | | | - -### PipelineMetricAggregationWithMultipleBucketPaths - -It extends [BaseMetricAggregation](#basemetricaggregation). - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `id` | string | **Yes** | | *(Inherited from [BaseMetricAggregation](#basemetricaggregation))* | -| `type` | string | **Yes** | | *(Inherited from [BaseMetricAggregation](#basemetricaggregation))*<br/>Possible values are: `count`, `avg`, `sum`, `min`, `max`, `extended_stats`, `percentiles`, `cardinality`, `raw_document`, `raw_data`, `logs`, `rate`, `top_metrics`, `moving_avg`, `moving_fn`, `derivative`, `serial_diff`, `cumulative_sum`, `bucket_script`. | -| `hide` | boolean | No | | *(Inherited from [BaseMetricAggregation](#basemetricaggregation))* | -| `pipelineVariables` | [PipelineVariable](#pipelinevariable)[] | No | | | - -### PipelineVariable - -| Property | Type | Required | Default | Description | -|---------------|--------|----------|---------|-------------| -| `name` | string | **Yes** | | | -| `pipelineAgg` | string | **Yes** | | | - -### Settings - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| -| `script` | | No | | | - -### CumulativeSum - -It extends [BasePipelineMetricAggregation](#basepipelinemetricaggregation). - -| Property | Type | Required | Default | Description | -|---------------|---------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | string | **Yes** | | *(Inherited from [BasePipelineMetricAggregation](#basepipelinemetricaggregation))*<br/>Possible values are: `count`, `avg`, `sum`, `min`, `max`, `extended_stats`, `percentiles`, `cardinality`, `raw_document`, `raw_data`, `logs`, `rate`, `top_metrics`, `moving_avg`, `moving_fn`, `derivative`, `serial_diff`, `cumulative_sum`, `bucket_script`. | -| `field` | string | No | | *(Inherited from [BasePipelineMetricAggregation](#basepipelinemetricaggregation))* | -| `hide` | boolean | No | | *(Inherited from [BasePipelineMetricAggregation](#basepipelinemetricaggregation))* | -| `id` | string | No | | *(Inherited from [BasePipelineMetricAggregation](#basepipelinemetricaggregation))* | -| `pipelineAgg` | string | No | | *(Inherited from [BasePipelineMetricAggregation](#basepipelinemetricaggregation))* | -| `settings` | [object](#settings) | No | | | - -### BasePipelineMetricAggregation - -It extends [MetricAggregationWithField](#metricaggregationwithfield). - -| Property | Type | Required | Default | Description | -|---------------|---------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | string | **Yes** | | *(Inherited from [MetricAggregationWithField](#metricaggregationwithfield))*<br/>Possible values are: `count`, `avg`, `sum`, `min`, `max`, `extended_stats`, `percentiles`, `cardinality`, `raw_document`, `raw_data`, `logs`, `rate`, `top_metrics`, `moving_avg`, `moving_fn`, `derivative`, `serial_diff`, `cumulative_sum`, `bucket_script`. | -| `field` | string | No | | *(Inherited from [MetricAggregationWithField](#metricaggregationwithfield))* | -| `hide` | boolean | No | | *(Inherited from [MetricAggregationWithField](#metricaggregationwithfield))* | -| `id` | string | No | | *(Inherited from [MetricAggregationWithField](#metricaggregationwithfield))* | -| `pipelineAgg` | string | No | | | - -### MetricAggregationWithField - -It extends [BaseMetricAggregation](#basemetricaggregation). - -| Property | Type | Required | Default | Description | -|----------|---------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `id` | string | **Yes** | | *(Inherited from [BaseMetricAggregation](#basemetricaggregation))* | -| `type` | string | **Yes** | | *(Inherited from [BaseMetricAggregation](#basemetricaggregation))*<br/>Possible values are: `count`, `avg`, `sum`, `min`, `max`, `extended_stats`, `percentiles`, `cardinality`, `raw_document`, `raw_data`, `logs`, `rate`, `top_metrics`, `moving_avg`, `moving_fn`, `derivative`, `serial_diff`, `cumulative_sum`, `bucket_script`. | -| `field` | string | No | | | -| `hide` | boolean | No | | *(Inherited from [BaseMetricAggregation](#basemetricaggregation))* | - -### Settings - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-------------| -| `format` | string | No | | | - -### Derivative - -It extends [BasePipelineMetricAggregation](#basepipelinemetricaggregation). - -| Property | Type | Required | Default | Description | -|---------------|---------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | string | **Yes** | | *(Inherited from [BasePipelineMetricAggregation](#basepipelinemetricaggregation))*<br/>Possible values are: `count`, `avg`, `sum`, `min`, `max`, `extended_stats`, `percentiles`, `cardinality`, `raw_document`, `raw_data`, `logs`, `rate`, `top_metrics`, `moving_avg`, `moving_fn`, `derivative`, `serial_diff`, `cumulative_sum`, `bucket_script`. | -| `field` | string | No | | *(Inherited from [BasePipelineMetricAggregation](#basepipelinemetricaggregation))* | -| `hide` | boolean | No | | *(Inherited from [BasePipelineMetricAggregation](#basepipelinemetricaggregation))* | -| `id` | string | No | | *(Inherited from [BasePipelineMetricAggregation](#basepipelinemetricaggregation))* | -| `pipelineAgg` | string | No | | *(Inherited from [BasePipelineMetricAggregation](#basepipelinemetricaggregation))* | -| `settings` | [object](#settings) | No | | | - -### Settings - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-------------| -| `unit` | string | No | | | - -### MovingAverage - -#MovingAverage's settings are overridden in types.ts - -It extends [BasePipelineMetricAggregation](#basepipelinemetricaggregation). - -| Property | Type | Required | Default | Description | -|---------------|---------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | string | **Yes** | | *(Inherited from [BasePipelineMetricAggregation](#basepipelinemetricaggregation))*<br/>Possible values are: `count`, `avg`, `sum`, `min`, `max`, `extended_stats`, `percentiles`, `cardinality`, `raw_document`, `raw_data`, `logs`, `rate`, `top_metrics`, `moving_avg`, `moving_fn`, `derivative`, `serial_diff`, `cumulative_sum`, `bucket_script`. | -| `field` | string | No | | *(Inherited from [BasePipelineMetricAggregation](#basepipelinemetricaggregation))* | -| `hide` | boolean | No | | *(Inherited from [BasePipelineMetricAggregation](#basepipelinemetricaggregation))* | -| `id` | string | No | | *(Inherited from [BasePipelineMetricAggregation](#basepipelinemetricaggregation))* | -| `pipelineAgg` | string | No | | *(Inherited from [BasePipelineMetricAggregation](#basepipelinemetricaggregation))* | -| `settings` | [object](#settings) | No | | | - -### Settings - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Meta - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Settings - -| Property | Type | Required | Default | Description | -|-----------|----------|----------|---------|-------------| -| `metrics` | string[] | No | | | -| `orderBy` | string | No | | | -| `order` | string | No | | | - - diff --git a/docs/sources/developers/kinds/composable/gauge/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/gauge/panelcfg/schema-reference.md deleted file mode 100644 index 5cc9eaa99c66e..0000000000000 --- a/docs/sources/developers/kinds/composable/gauge/panelcfg/schema-reference.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: GaugePanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## GaugePanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-----------|--------------------|----------|---------|-------------| -| `Options` | [object](#options) | **Yes** | | | - -### Options - -It extends [SingleStatBaseOptions](#singlestatbaseoptions). - -| Property | Type | Required | Default | Description | -|------------------------|-------------------------------------------------|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------| -| `minVizHeight` | uint32 | **Yes** | `75` | | -| `minVizWidth` | uint32 | **Yes** | `75` | | -| `showThresholdLabels` | boolean | **Yes** | `false` | | -| `showThresholdMarkers` | boolean | **Yes** | `true` | | -| `sizing` | string | **Yes** | | Allows for the bar gauge size to be set explicitly<br/>Possible values are: `auto`, `manual`. | -| `orientation` | string | No | | *(Inherited from [SingleStatBaseOptions](#singlestatbaseoptions))*<br/>TODO docs<br/>Possible values are: `auto`, `vertical`, `horizontal`. | -| `reduceOptions` | [ReduceDataOptions](#reducedataoptions) | No | | *(Inherited from [SingleStatBaseOptions](#singlestatbaseoptions))*<br/>TODO docs | -| `text` | [VizTextDisplayOptions](#viztextdisplayoptions) | No | | *(Inherited from [SingleStatBaseOptions](#singlestatbaseoptions))*<br/>TODO docs | - -### ReduceDataOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|----------|----------|---------|---------------------------------------------------------------| -| `calcs` | string[] | **Yes** | | When !values, pick one value for the whole field | -| `fields` | string | No | | Which fields to show. By default this is only numeric fields | -| `limit` | number | No | | if showing all values limit | -| `values` | boolean | No | | If true show each row value | - -### SingleStatBaseOptions - -TODO docs - -It extends [OptionsWithTextFormatting](#optionswithtextformatting). - -| Property | Type | Required | Default | Description | -|-----------------|-------------------------------------------------|----------|---------|------------------------------------------------------------------------------------------| -| `orientation` | string | **Yes** | | TODO docs<br/>Possible values are: `auto`, `vertical`, `horizontal`. | -| `reduceOptions` | [ReduceDataOptions](#reducedataoptions) | **Yes** | | TODO docs | -| `text` | [VizTextDisplayOptions](#viztextdisplayoptions) | No | | *(Inherited from [OptionsWithTextFormatting](#optionswithtextformatting))*<br/>TODO docs | - -### OptionsWithTextFormatting - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|-------------------------------------------------|----------|---------|-------------| -| `text` | [VizTextDisplayOptions](#viztextdisplayoptions) | No | | TODO docs | - -### VizTextDisplayOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|-------------|--------|----------|---------|--------------------------| -| `titleSize` | number | No | | Explicit title text size | -| `valueSize` | number | No | | Explicit value text size | - - diff --git a/docs/sources/developers/kinds/composable/geomap/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/geomap/panelcfg/schema-reference.md deleted file mode 100644 index cfa0b3c913860..0000000000000 --- a/docs/sources/developers/kinds/composable/geomap/panelcfg/schema-reference.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: GeomapPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## GeomapPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-------------------|----------------------------|----------|---------|-----------------------------------------------| -| `ControlsOptions` | [object](#controlsoptions) | **Yes** | | | -| `MapCenterID` | string | **Yes** | | Possible values are: `zero`, `coords`, `fit`. | -| `MapViewConfig` | [object](#mapviewconfig) | **Yes** | | | -| `Options` | [object](#options) | **Yes** | | | -| `TooltipMode` | string | **Yes** | | Possible values are: `none`, `details`. | -| `TooltipOptions` | [object](#tooltipoptions) | **Yes** | | | - -### ControlsOptions - -| Property | Type | Required | Default | Description | -|-------------------|---------|----------|---------|--------------------------| -| `mouseWheelZoom` | boolean | No | | let the mouse wheel zoom | -| `showAttribution` | boolean | No | | Lower right | -| `showDebug` | boolean | No | | Show debug | -| `showMeasure` | boolean | No | | Show measure | -| `showScale` | boolean | No | | Scale options | -| `showZoom` | boolean | No | | Zoom (upper left) | - -### MapViewConfig - -| Property | Type | Required | Default | Description | -|-------------|---------|----------|---------|-------------| -| `id` | string | **Yes** | `zero` | | -| `allLayers` | boolean | No | `true` | | -| `lastOnly` | boolean | No | | | -| `lat` | int64 | No | `0` | | -| `layer` | string | No | | | -| `lon` | int64 | No | `0` | | -| `maxZoom` | integer | No | | | -| `minZoom` | integer | No | | | -| `padding` | integer | No | | | -| `shared` | boolean | No | | | -| `zoom` | int64 | No | `1` | | - -### Options - -| Property | Type | Required | Default | Description | -|------------|---------------------------------------|----------|---------|-------------| -| `basemap` | [MapLayerOptions](#maplayeroptions) | **Yes** | | | -| `controls` | [ControlsOptions](#controlsoptions) | **Yes** | | | -| `layers` | [MapLayerOptions](#maplayeroptions)[] | **Yes** | | | -| `tooltip` | [TooltipOptions](#tooltipoptions) | **Yes** | | | -| `view` | [MapViewConfig](#mapviewconfig) | **Yes** | | | - -### MapLayerOptions - -| Property | Type | Required | Default | Description | -|--------------|---------------------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------| -| `name` | string | **Yes** | | configured unique display name | -| `type` | string | **Yes** | | | -| `config` | | No | | Custom options depending on the type | -| `filterData` | | No | | Defines a frame MatcherConfig that may filter data for the given layer | -| `location` | [FrameGeometrySource](#framegeometrysource) | No | | | -| `opacity` | integer | No | | Common properties:<br/>https://openlayers.org/en/latest/apidoc/module-ol_layer_Base-BaseLayer.html<br/>Layer opacity (0-1) | -| `tooltip` | boolean | No | | Check tooltip (defaults to true) | - -### FrameGeometrySource - -| Property | Type | Required | Default | Description | -|-------------|--------|----------|---------|-------------------------------------------------------------| -| `mode` | string | **Yes** | | Possible values are: `auto`, `geohash`, `coords`, `lookup`. | -| `gazetteer` | string | No | | Path to Gazetteer | -| `geohash` | string | No | | Field mappings | -| `latitude` | string | No | | | -| `longitude` | string | No | | | -| `lookup` | string | No | | | -| `wkt` | string | No | | | - -### TooltipOptions - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-----------------------------------------| -| `mode` | string | **Yes** | | Possible values are: `none`, `details`. | - - diff --git a/docs/sources/developers/kinds/composable/googlecloudmonitoring/dataquery/schema-reference.md b/docs/sources/developers/kinds/composable/googlecloudmonitoring/dataquery/schema-reference.md deleted file mode 100644 index d8f28ff51d6ee..0000000000000 --- a/docs/sources/developers/kinds/composable/googlecloudmonitoring/dataquery/schema-reference.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: GoogleCloudMonitoringDataQuery kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## GoogleCloudMonitoringDataQuery - -#### Maturity: [merged](../../../maturity/#merged) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - - diff --git a/docs/sources/developers/kinds/composable/grafanapyroscope/dataquery/schema-reference.md b/docs/sources/developers/kinds/composable/grafanapyroscope/dataquery/schema-reference.md deleted file mode 100644 index 3b7a95d56c6c4..0000000000000 --- a/docs/sources/developers/kinds/composable/grafanapyroscope/dataquery/schema-reference.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: GrafanaPyroscopeDataQuery kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## GrafanaPyroscopeDataQuery - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-----------------|----------|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `groupBy` | string[] | **Yes** | | Allows to group the results. | -| `labelSelector` | string | **Yes** | `{}` | Specifies the query label selectors. | -| `profileTypeId` | string | **Yes** | | Specifies the type of profile to query. | -| `refId` | string | **Yes** | | A unique identifier for the query within the list of targets.<br/>In server side expressions, the refId is used as a variable name to identify results.<br/>By default, the UI will assign A->Z; however setting meaningful names may be useful. | -| `datasource` | | No | | For mixed data sources the selected datasource is on the query level.<br/>For non mixed scenarios this is undefined.<br/>TODO find a better way to do this ^ that's friendly to schema<br/>TODO this shouldn't be unknown but DataSourceRef | null | -| `hide` | boolean | No | | true if query is disabled (ie should not be returned to the dashboard)<br/>Note this does not always imply that the query should not be executed since<br/>the results from a hidden query may be used as the input to other queries (SSE etc) | -| `maxNodes` | integer | No | | Sets the maximum number of nodes in the flamegraph. | -| `queryType` | string | No | | Specify the query flavor<br/>TODO make this required and give it a default | -| `spanSelector` | string[] | No | | Specifies the query span selectors. | - - diff --git a/docs/sources/developers/kinds/composable/heatmap/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/heatmap/panelcfg/schema-reference.md deleted file mode 100644 index 4427b6c99e9c4..0000000000000 --- a/docs/sources/developers/kinds/composable/heatmap/panelcfg/schema-reference.md +++ /dev/null @@ -1,240 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: HeatmapPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## HeatmapPanelCfg - -#### Maturity: [merged](../../../maturity/#merged) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-----------------------|--------------------------------|----------|---------|-------------------------------------------------------------------------------------------| -| `CellValues` | [object](#cellvalues) | **Yes** | | Controls cell value options | -| `ExemplarConfig` | [object](#exemplarconfig) | **Yes** | | Controls exemplar options | -| `FieldConfig` | [object](#fieldconfig) | **Yes** | | | -| `FilterValueRange` | [object](#filtervaluerange) | **Yes** | | Controls the value filter range | -| `HeatmapColorMode` | string | **Yes** | | Controls the color mode of the heatmap<br/>Possible values are: `opacity`, `scheme`. | -| `HeatmapColorOptions` | [object](#heatmapcoloroptions) | **Yes** | | Controls various color options | -| `HeatmapColorScale` | string | **Yes** | | Controls the color scale of the heatmap<br/>Possible values are: `linear`, `exponential`. | -| `HeatmapLegend` | [object](#heatmaplegend) | **Yes** | | Controls legend options | -| `HeatmapTooltip` | [object](#heatmaptooltip) | **Yes** | | Controls tooltip options | -| `Options` | [object](#options) | **Yes** | | | -| `RowsHeatmapOptions` | [object](#rowsheatmapoptions) | **Yes** | | Controls frame rows options | -| `YAxisConfig` | [object](#yaxisconfig) | **Yes** | | Configuration options for the yAxis | - -### CellValues - -Controls cell value options - -| Property | Type | Required | Default | Description | -|------------|--------|----------|---------|-------------------------------------------------| -| `decimals` | number | No | | Controls the number of decimals for cell values | -| `unit` | string | No | | Controls the cell value unit | - -### ExemplarConfig - -Controls exemplar options - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|----------------------------------------| -| `color` | string | **Yes** | | Sets the color of the exemplar markers | - -### FieldConfig - -It extends [HideableFieldConfig](#hideablefieldconfig). - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------|----------|---------|------------------------------------------------------------------------------| -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | *(Inherited from [HideableFieldConfig](#hideablefieldconfig))*<br/>TODO docs | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | TODO docs | - -### HideSeriesConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|---------|----------|---------|-------------| -| `legend` | boolean | **Yes** | | | -| `tooltip` | boolean | **Yes** | | | -| `viz` | boolean | **Yes** | | | - -### HideableFieldConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|---------------------------------------|----------|---------|-------------| -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | TODO docs | - -### ScaleDistributionConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-------------------|--------|----------|---------|--------------------------------------------------------------------------| -| `type` | string | **Yes** | | TODO docs<br/>Possible values are: `linear`, `log`, `ordinal`, `symlog`. | -| `linearThreshold` | number | No | | | -| `log` | number | No | | | - -### FilterValueRange - -Controls the value filter range - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|--------------------------------------------------------------------------| -| `ge` | number | No | | Sets the filter range to values greater than or equal to the given value | -| `le` | number | No | | Sets the filter range to values less than or equal to the given value | - -### HeatmapColorOptions - -Controls various color options - -| Property | Type | Required | Default | Description | -|------------|---------|----------|---------|-------------------------------------------------------------------------------------------| -| `exponent` | number | **Yes** | | Controls the exponent when scale is set to exponential | -| `fill` | string | **Yes** | | Controls the color fill when in opacity mode | -| `reverse` | boolean | **Yes** | | Reverses the color scheme | -| `scheme` | string | **Yes** | | Controls the color scheme used | -| `steps` | integer | **Yes** | | Controls the number of color steps<br/>Constraint: `>=2 & <=128`. | -| `max` | number | No | | Sets the maximum value for the color scale | -| `min` | number | No | | Sets the minimum value for the color scale | -| `mode` | string | No | | Controls the color mode of the heatmap<br/>Possible values are: `opacity`, `scheme`. | -| `scale` | string | No | | Controls the color scale of the heatmap<br/>Possible values are: `linear`, `exponential`. | - -### HeatmapLegend - -Controls legend options - -| Property | Type | Required | Default | Description | -|----------|---------|----------|---------|---------------------------------| -| `show` | boolean | **Yes** | | Controls if the legend is shown | - -### HeatmapTooltip - -Controls tooltip options - -| Property | Type | Required | Default | Description | -|------------------|---------|----------|---------|----------------------------------------------------------------| -| `show` | boolean | **Yes** | | Controls if the tooltip is shown | -| `showColorScale` | boolean | No | | Controls if the tooltip shows a color scale in header | -| `yHistogram` | boolean | No | | Controls if the tooltip shows a histogram of the y-axis values | - -### Options - -| Property | Type | Required | Default | Description | -|----------------|---------------------------------------------------------|----------|----------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `color` | [object](#color) | **Yes** | `map[exponent:0.5 fill:dark-orange reverse:false scheme:Oranges steps:64]` | Controls the color options | -| `exemplars` | [ExemplarConfig](#exemplarconfig) | **Yes** | | Controls exemplar options | -| `legend` | [HeatmapLegend](#heatmaplegend) | **Yes** | | Controls legend options | -| `showValue` | string | **Yes** | | | *{<br/> layout: ui.HeatmapCellLayout & "auto" // TODO: fix after remove when https://github.com/grafana/cuetsy/issues/74 is fixed<br/>}<br/>Controls the display of the value in the cell | -| `tooltip` | [HeatmapTooltip](#heatmaptooltip) | **Yes** | | Controls tooltip options | -| `yAxis` | [YAxisConfig](#yaxisconfig) | **Yes** | | Configuration options for the yAxis | -| `calculate` | boolean | No | `false` | Controls if the heatmap should be calculated from data | -| `calculation` | [HeatmapCalculationOptions](#heatmapcalculationoptions) | No | | | -| `cellGap` | integer | No | `1` | Controls gap between cells<br/>Constraint: `>=0 & <=25`. | -| `cellRadius` | number | No | | Controls cell radius | -| `cellValues` | [object](#cellvalues) | No | `map[]` | Controls cell value unit | -| `filterValues` | [object](#filtervalues) | No | `map[le:1e-09]` | Filters values between a given range | -| `rowsFrame` | [RowsHeatmapOptions](#rowsheatmapoptions) | No | | Controls frame rows options | - -### HeatmapCalculationOptions - -| Property | Type | Required | Default | Description | -|------------|-------------------------------------------------------------------|----------|---------|-------------| -| `xBuckets` | [HeatmapCalculationBucketConfig](#heatmapcalculationbucketconfig) | No | | | -| `yBuckets` | [HeatmapCalculationBucketConfig](#heatmapcalculationbucketconfig) | No | | | - -### HeatmapCalculationBucketConfig - -| Property | Type | Required | Default | Description | -|----------|-----------------------------------------------------|----------|---------|----------------------------------------------------------| -| `mode` | string | No | | Possible values are: `size`, `count`. | -| `scale` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | TODO docs | -| `value` | string | No | | The number of buckets to use for the axis in the heatmap | - -### RowsHeatmapOptions - -Controls frame rows options - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|----------------------------------------------------------| -| `layout` | string | No | | Possible values are: `le`, `ge`, `unknown`, `auto`. | -| `value` | string | No | | Sets the name of the cell when not calculating from data | - -### YAxisConfig - -Configuration options for the yAxis - -It extends [AxisConfig](#axisconfig). - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisCenteredZero` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisColorMode` | string | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisLabel` | string | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisPlacement` | string | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisSoftMin` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisWidth` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `decimals` | number | No | | Controls the number of decimals for yAxis values | -| `max` | number | No | | Sets the maximum value for the yAxis | -| `min` | number | No | | Sets the minimum value for the yAxis | -| `reverse` | boolean | No | | Reverses the yAxis | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs | -| `unit` | string | No | | Sets the yAxis unit | - -### AxisConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | | -| `axisCenteredZero` | boolean | No | | | -| `axisColorMode` | string | No | | TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | | -| `axisLabel` | string | No | | | -| `axisPlacement` | string | No | | TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | | -| `axisSoftMin` | number | No | | | -| `axisWidth` | number | No | | | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | TODO docs | - -### CellValues - -Controls cell value unit - -| Property | Type | Required | Default | Description | -|----------|-----------------------------------|----------|---------|-------------| -| `object` | Possible types are: [](#), [](#). | | | - -### Color - -Controls the color options - -| Property | Type | Required | Default | Description | -|----------|-----------------------------------|----------|---------|-------------| -| `object` | Possible types are: [](#), [](#). | | | - -### FilterValues - -Filters values between a given range - -| Property | Type | Required | Default | Description | -|----------|-----------------------------------|----------|---------|-------------| -| `object` | Possible types are: [](#), [](#). | | | - - diff --git a/docs/sources/developers/kinds/composable/histogram/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/histogram/panelcfg/schema-reference.md deleted file mode 100644 index f3f0efe92345a..0000000000000 --- a/docs/sources/developers/kinds/composable/histogram/panelcfg/schema-reference.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: HistogramPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## HistogramPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|---------------|------------------------|----------|---------|-------------| -| `FieldConfig` | [object](#fieldconfig) | **Yes** | | | -| `Options` | [object](#options) | **Yes** | | | - -### FieldConfig - -It extends [AxisConfig](#axisconfig) and [HideableFieldConfig](#hideablefieldconfig). - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------|----------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisCenteredZero` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisColorMode` | string | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisLabel` | string | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisPlacement` | string | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisSoftMin` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisWidth` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `fillOpacity` | integer | No | `80` | Controls the fill opacity of the bars.<br/>Constraint: `>=0 & <=100`. | -| `gradientMode` | string | No | | Set the mode of the gradient fill. Fill gradient is based on the line color. To change the color, use the standard color scheme field option.<br/>Gradient appearance is influenced by the Fill opacity setting. | -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | *(Inherited from [HideableFieldConfig](#hideablefieldconfig))*<br/>TODO docs | -| `lineWidth` | integer | No | `1` | Controls line width of the bars.<br/>Constraint: `>=0 & <=10`. | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs | - -### AxisConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | | -| `axisCenteredZero` | boolean | No | | | -| `axisColorMode` | string | No | | TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | | -| `axisLabel` | string | No | | | -| `axisPlacement` | string | No | | TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | | -| `axisSoftMin` | number | No | | | -| `axisWidth` | number | No | | | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | TODO docs | - -### ScaleDistributionConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-------------------|--------|----------|---------|--------------------------------------------------------------------------| -| `type` | string | **Yes** | | TODO docs<br/>Possible values are: `linear`, `log`, `ordinal`, `symlog`. | -| `linearThreshold` | number | No | | | -| `log` | number | No | | | - -### HideSeriesConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|---------|----------|---------|-------------| -| `legend` | boolean | **Yes** | | | -| `tooltip` | boolean | **Yes** | | | -| `viz` | boolean | **Yes** | | | - -### HideableFieldConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|---------------------------------------|----------|---------|-------------| -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | TODO docs | - -### Options - -It extends [OptionsWithLegend](#optionswithlegend) and [OptionsWithTooltip](#optionswithtooltip). - -| Property | Type | Required | Default | Description | -|----------------|-----------------------------------------|----------|---------|----------------------------------------------------------------------------| -| `legend` | [VizLegendOptions](#vizlegendoptions) | **Yes** | | *(Inherited from [OptionsWithLegend](#optionswithlegend))*<br/>TODO docs | -| `tooltip` | [VizTooltipOptions](#viztooltipoptions) | **Yes** | | *(Inherited from [OptionsWithTooltip](#optionswithtooltip))*<br/>TODO docs | -| `bucketOffset` | int32 | No | `0` | Offset buckets by this amount | -| `bucketSize` | integer | No | | Size of each bucket | -| `combine` | boolean | No | | Combines multiple series into a single histogram | - -### OptionsWithLegend - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|---------------------------------------|----------|---------|-------------| -| `legend` | [VizLegendOptions](#vizlegendoptions) | **Yes** | | TODO docs | - -### VizLegendOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|----------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `calcs` | string[] | **Yes** | | | -| `displayMode` | string | **Yes** | | TODO docs<br/>Note: "hidden" needs to remain as an option for plugins compatibility<br/>Possible values are: `list`, `table`, `hidden`. | -| `placement` | string | **Yes** | | TODO docs<br/>Possible values are: `bottom`, `right`. | -| `showLegend` | boolean | **Yes** | | | -| `asTable` | boolean | No | | | -| `isVisible` | boolean | No | | | -| `sortBy` | string | No | | | -| `sortDesc` | boolean | No | | | -| `width` | number | No | | | - -### OptionsWithTooltip - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|-----------------------------------------|----------|---------|-------------| -| `tooltip` | [VizTooltipOptions](#viztooltipoptions) | **Yes** | | TODO docs | - -### VizTooltipOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|---------------------------------------------------------------| -| `mode` | string | **Yes** | | TODO docs<br/>Possible values are: `single`, `multi`, `none`. | -| `sort` | string | **Yes** | | TODO docs<br/>Possible values are: `asc`, `desc`, `none`. | - - diff --git a/docs/sources/developers/kinds/composable/logs/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/logs/panelcfg/schema-reference.md deleted file mode 100644 index 3a883dcad8ef2..0000000000000 --- a/docs/sources/developers/kinds/composable/logs/panelcfg/schema-reference.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: LogsPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## LogsPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-----------|--------------------|----------|---------|-------------| -| `Options` | [object](#options) | **Yes** | | | - -### Options - -| Property | Type | Required | Default | Description | -|----------------------|---------|----------|---------|---------------------------------------------------------------| -| `dedupStrategy` | string | **Yes** | | Possible values are: `none`, `exact`, `numbers`, `signature`. | -| `enableLogDetails` | boolean | **Yes** | | | -| `prettifyLogMessage` | boolean | **Yes** | | | -| `showCommonLabels` | boolean | **Yes** | | | -| `showLabels` | boolean | **Yes** | | | -| `showTime` | boolean | **Yes** | | | -| `sortOrder` | string | **Yes** | | Possible values are: `Descending`, `Ascending`. | -| `wrapLogMessage` | boolean | **Yes** | | | - - diff --git a/docs/sources/developers/kinds/composable/loki/dataquery/schema-reference.md b/docs/sources/developers/kinds/composable/loki/dataquery/schema-reference.md deleted file mode 100644 index d7cfba8b09af7..0000000000000 --- a/docs/sources/developers/kinds/composable/loki/dataquery/schema-reference.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: LokiDataQuery kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## LokiDataQuery - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|----------------|---------|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `expr` | string | **Yes** | | The LogQL query. | -| `refId` | string | **Yes** | | A unique identifier for the query within the list of targets.<br/>In server side expressions, the refId is used as a variable name to identify results.<br/>By default, the UI will assign A->Z; however setting meaningful names may be useful. | -| `datasource` | | No | | For mixed data sources the selected datasource is on the query level.<br/>For non mixed scenarios this is undefined.<br/>TODO find a better way to do this ^ that's friendly to schema<br/>TODO this shouldn't be unknown but DataSourceRef | null | -| `editorMode` | string | No | | Possible values are: `code`, `builder`. | -| `hide` | boolean | No | | true if query is disabled (ie should not be returned to the dashboard)<br/>Note this does not always imply that the query should not be executed since<br/>the results from a hidden query may be used as the input to other queries (SSE etc) | -| `instant` | boolean | No | | @deprecated, now use queryType. | -| `legendFormat` | string | No | | Used to override the name of the series. | -| `maxLines` | integer | No | | Used to limit the number of log rows returned. | -| `queryType` | string | No | | Specify the query flavor<br/>TODO make this required and give it a default | -| `range` | boolean | No | | @deprecated, now use queryType. | -| `resolution` | integer | No | | @deprecated, now use step. | -| `step` | string | No | | Used to set step value for range queries. | - - diff --git a/docs/sources/developers/kinds/composable/news/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/news/panelcfg/schema-reference.md deleted file mode 100644 index f4290acc9a8e8..0000000000000 --- a/docs/sources/developers/kinds/composable/news/panelcfg/schema-reference.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: NewsPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## NewsPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-----------|--------------------|----------|---------|-------------| -| `Options` | [object](#options) | **Yes** | | | - -### Options - -| Property | Type | Required | Default | Description | -|-------------|---------|----------|---------|--------------------------------------------| -| `feedUrl` | string | No | | empty/missing will default to grafana blog | -| `showImage` | boolean | No | `true` | | - - diff --git a/docs/sources/developers/kinds/composable/nodegraph/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/nodegraph/panelcfg/schema-reference.md deleted file mode 100644 index 1ac4a3f7a3461..0000000000000 --- a/docs/sources/developers/kinds/composable/nodegraph/panelcfg/schema-reference.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: NodeGraphPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## NodeGraphPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|---------------|------------------------|----------|---------|-------------| -| `ArcOption` | [object](#arcoption) | **Yes** | | | -| `EdgeOptions` | [object](#edgeoptions) | **Yes** | | | -| `NodeOptions` | [object](#nodeoptions) | **Yes** | | | -| `Options` | [object](#options) | **Yes** | | | - -### ArcOption - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-----------------------------------------------------------------------------------------------------| -| `color` | string | No | | The color of the arc. | -| `field` | string | No | | Field from which to get the value. Values should be less than 1, representing fraction of a circle. | - -### EdgeOptions - -| Property | Type | Required | Default | Description | -|---------------------|--------|----------|---------|-----------------------------------------------------------------------------| -| `mainStatUnit` | string | No | | Unit for the main stat to override what ever is set in the data frame. | -| `secondaryStatUnit` | string | No | | Unit for the secondary stat to override what ever is set in the data frame. | - -### NodeOptions - -| Property | Type | Required | Default | Description | -|---------------------|---------------------------|----------|---------|-----------------------------------------------------------------------------------------| -| `arcs` | [ArcOption](#arcoption)[] | No | | Define which fields are shown as part of the node arc (colored circle around the node). | -| `mainStatUnit` | string | No | | Unit for the main stat to override what ever is set in the data frame. | -| `secondaryStatUnit` | string | No | | Unit for the secondary stat to override what ever is set in the data frame. | - -### Options - -| Property | Type | Required | Default | Description | -|----------|-----------------------------|----------|---------|-------------| -| `edges` | [EdgeOptions](#edgeoptions) | No | | | -| `nodes` | [NodeOptions](#nodeoptions) | No | | | - - diff --git a/docs/sources/developers/kinds/composable/parca/dataquery/schema-reference.md b/docs/sources/developers/kinds/composable/parca/dataquery/schema-reference.md deleted file mode 100644 index baf0cc6be0010..0000000000000 --- a/docs/sources/developers/kinds/composable/parca/dataquery/schema-reference.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: ParcaDataQuery kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## ParcaDataQuery - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-----------------|---------|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `labelSelector` | string | **Yes** | `{}` | Specifies the query label selectors. | -| `profileTypeId` | string | **Yes** | | Specifies the type of profile to query. | -| `refId` | string | **Yes** | | A unique identifier for the query within the list of targets.<br/>In server side expressions, the refId is used as a variable name to identify results.<br/>By default, the UI will assign A->Z; however setting meaningful names may be useful. | -| `datasource` | | No | | For mixed data sources the selected datasource is on the query level.<br/>For non mixed scenarios this is undefined.<br/>TODO find a better way to do this ^ that's friendly to schema<br/>TODO this shouldn't be unknown but DataSourceRef | null | -| `hide` | boolean | No | | true if query is disabled (ie should not be returned to the dashboard)<br/>Note this does not always imply that the query should not be executed since<br/>the results from a hidden query may be used as the input to other queries (SSE etc) | -| `queryType` | string | No | | Specify the query flavor<br/>TODO make this required and give it a default | - - diff --git a/docs/sources/developers/kinds/composable/piechart/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/piechart/panelcfg/schema-reference.md deleted file mode 100644 index 9c2a8060c23f9..0000000000000 --- a/docs/sources/developers/kinds/composable/piechart/panelcfg/schema-reference.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: PieChartPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## PieChartPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-------------------------|---------------------------------------------|----------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `FieldConfig` | [HideableFieldConfig](#hideablefieldconfig) | **Yes** | | TODO docs | -| `Options` | [object](#options) | **Yes** | | | -| `PieChartLabels` | string | **Yes** | | Select labels to display on the pie chart.<br/> - Name - The series or field name.<br/> - Percent - The percentage of the whole.<br/> - Value - The raw numerical value.<br/>Possible values are: `name`, `value`, `percent`. | -| `PieChartLegendOptions` | [object](#piechartlegendoptions) | **Yes** | | | -| `PieChartLegendValues` | string | **Yes** | | Select values to display in the legend.<br/> - Percent: The percentage of the whole.<br/> - Value: The raw numerical value.<br/>Possible values are: `value`, `percent`. | -| `PieChartType` | string | **Yes** | | Select the pie chart display style.<br/>Possible values are: `pie`, `donut`. | - -### HideableFieldConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|---------------------------------------|----------|---------|-------------| -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | TODO docs | - -### HideSeriesConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|---------|----------|---------|-------------| -| `legend` | boolean | **Yes** | | | -| `tooltip` | boolean | **Yes** | | | -| `viz` | boolean | **Yes** | | | - -### Options - -It extends [OptionsWithTooltip](#optionswithtooltip) and [SingleStatBaseOptions](#singlestatbaseoptions). - -| Property | Type | Required | Default | Description | -|-----------------|-------------------------------------------------|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------| -| `displayLabels` | string[] | **Yes** | | | -| `legend` | [PieChartLegendOptions](#piechartlegendoptions) | **Yes** | | | -| `pieType` | string | **Yes** | | Select the pie chart display style.<br/>Possible values are: `pie`, `donut`. | -| `tooltip` | [VizTooltipOptions](#viztooltipoptions) | **Yes** | | *(Inherited from [OptionsWithTooltip](#optionswithtooltip))*<br/>TODO docs | -| `orientation` | string | No | | *(Inherited from [SingleStatBaseOptions](#singlestatbaseoptions))*<br/>TODO docs<br/>Possible values are: `auto`, `vertical`, `horizontal`. | -| `reduceOptions` | [ReduceDataOptions](#reducedataoptions) | No | | *(Inherited from [SingleStatBaseOptions](#singlestatbaseoptions))*<br/>TODO docs | -| `text` | [VizTextDisplayOptions](#viztextdisplayoptions) | No | | *(Inherited from [SingleStatBaseOptions](#singlestatbaseoptions))*<br/>TODO docs | - -### OptionsWithTooltip - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|-----------------------------------------|----------|---------|-------------| -| `tooltip` | [VizTooltipOptions](#viztooltipoptions) | **Yes** | | TODO docs | - -### VizTooltipOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|---------------------------------------------------------------| -| `mode` | string | **Yes** | | TODO docs<br/>Possible values are: `single`, `multi`, `none`. | -| `sort` | string | **Yes** | | TODO docs<br/>Possible values are: `asc`, `desc`, `none`. | - -### PieChartLegendOptions - -It extends [VizLegendOptions](#vizlegendoptions). - -| Property | Type | Required | Default | Description | -|---------------|----------|----------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `calcs` | string[] | **Yes** | | *(Inherited from [VizLegendOptions](#vizlegendoptions))* | -| `displayMode` | string | **Yes** | | *(Inherited from [VizLegendOptions](#vizlegendoptions))*<br/>TODO docs<br/>Note: "hidden" needs to remain as an option for plugins compatibility<br/>Possible values are: `list`, `table`, `hidden`. | -| `placement` | string | **Yes** | | *(Inherited from [VizLegendOptions](#vizlegendoptions))*<br/>TODO docs<br/>Possible values are: `bottom`, `right`. | -| `showLegend` | boolean | **Yes** | | *(Inherited from [VizLegendOptions](#vizlegendoptions))* | -| `values` | string[] | **Yes** | | | -| `asTable` | boolean | No | | *(Inherited from [VizLegendOptions](#vizlegendoptions))* | -| `isVisible` | boolean | No | | *(Inherited from [VizLegendOptions](#vizlegendoptions))* | -| `sortBy` | string | No | | *(Inherited from [VizLegendOptions](#vizlegendoptions))* | -| `sortDesc` | boolean | No | | *(Inherited from [VizLegendOptions](#vizlegendoptions))* | -| `width` | number | No | | *(Inherited from [VizLegendOptions](#vizlegendoptions))* | - -### VizLegendOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|----------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `calcs` | string[] | **Yes** | | | -| `displayMode` | string | **Yes** | | TODO docs<br/>Note: "hidden" needs to remain as an option for plugins compatibility<br/>Possible values are: `list`, `table`, `hidden`. | -| `placement` | string | **Yes** | | TODO docs<br/>Possible values are: `bottom`, `right`. | -| `showLegend` | boolean | **Yes** | | | -| `asTable` | boolean | No | | | -| `isVisible` | boolean | No | | | -| `sortBy` | string | No | | | -| `sortDesc` | boolean | No | | | -| `width` | number | No | | | - -### ReduceDataOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|----------|----------|---------|---------------------------------------------------------------| -| `calcs` | string[] | **Yes** | | When !values, pick one value for the whole field | -| `fields` | string | No | | Which fields to show. By default this is only numeric fields | -| `limit` | number | No | | if showing all values limit | -| `values` | boolean | No | | If true show each row value | - -### SingleStatBaseOptions - -TODO docs - -It extends [OptionsWithTextFormatting](#optionswithtextformatting). - -| Property | Type | Required | Default | Description | -|-----------------|-------------------------------------------------|----------|---------|------------------------------------------------------------------------------------------| -| `orientation` | string | **Yes** | | TODO docs<br/>Possible values are: `auto`, `vertical`, `horizontal`. | -| `reduceOptions` | [ReduceDataOptions](#reducedataoptions) | **Yes** | | TODO docs | -| `text` | [VizTextDisplayOptions](#viztextdisplayoptions) | No | | *(Inherited from [OptionsWithTextFormatting](#optionswithtextformatting))*<br/>TODO docs | - -### OptionsWithTextFormatting - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|-------------------------------------------------|----------|---------|-------------| -| `text` | [VizTextDisplayOptions](#viztextdisplayoptions) | No | | TODO docs | - -### VizTextDisplayOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|-------------|--------|----------|---------|--------------------------| -| `titleSize` | number | No | | Explicit title text size | -| `valueSize` | number | No | | Explicit value text size | - - diff --git a/docs/sources/developers/kinds/composable/prometheus/dataquery/schema-reference.md b/docs/sources/developers/kinds/composable/prometheus/dataquery/schema-reference.md deleted file mode 100644 index 07bc7b865de8f..0000000000000 --- a/docs/sources/developers/kinds/composable/prometheus/dataquery/schema-reference.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: PrometheusDataQuery kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## PrometheusDataQuery - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|------------------|---------|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `expr` | string | **Yes** | | The actual expression/query that will be evaluated by Prometheus | -| `refId` | string | **Yes** | | A unique identifier for the query within the list of targets.<br/>In server side expressions, the refId is used as a variable name to identify results.<br/>By default, the UI will assign A->Z; however setting meaningful names may be useful. | -| `datasource` | | No | | For mixed data sources the selected datasource is on the query level.<br/>For non mixed scenarios this is undefined.<br/>TODO find a better way to do this ^ that's friendly to schema<br/>TODO this shouldn't be unknown but DataSourceRef | null | -| `editorMode` | string | No | | Possible values are: `code`, `builder`. | -| `exemplar` | boolean | No | | Execute an additional query to identify interesting raw samples relevant for the given expr | -| `format` | string | No | | Possible values are: `time_series`, `table`, `heatmap`. | -| `hide` | boolean | No | | true if query is disabled (ie should not be returned to the dashboard)<br/>Note this does not always imply that the query should not be executed since<br/>the results from a hidden query may be used as the input to other queries (SSE etc) | -| `instant` | boolean | No | | Returns only the latest value that Prometheus has scraped for the requested time series | -| `intervalFactor` | number | No | | @deprecated Used to specify how many times to divide max data points by. We use max data points under query options<br/>See https://github.com/grafana/grafana/issues/48081 | -| `legendFormat` | string | No | | Series name override or template. Ex. {{hostname}} will be replaced with label value for hostname | -| `queryType` | string | No | | Specify the query flavor<br/>TODO make this required and give it a default | -| `range` | boolean | No | | Returns a Range vector, comprised of a set of time series containing a range of data points over time for each time series | - - diff --git a/docs/sources/developers/kinds/composable/stat/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/stat/panelcfg/schema-reference.md deleted file mode 100644 index 51fac3c3120ff..0000000000000 --- a/docs/sources/developers/kinds/composable/stat/panelcfg/schema-reference.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: StatPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## StatPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-----------|--------------------|----------|---------|-------------| -| `Options` | [object](#options) | **Yes** | | | - -### Options - -It extends [SingleStatBaseOptions](#singlestatbaseoptions). - -| Property | Type | Required | Default | Description | -|---------------------|-------------------------------------------------|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------| -| `colorMode` | string | **Yes** | | TODO docs<br/>Possible values are: `value`, `background`, `background_solid`, `none`. | -| `graphMode` | string | **Yes** | | TODO docs<br/>Possible values are: `none`, `line`, `area`. | -| `justifyMode` | string | **Yes** | | TODO docs<br/>Possible values are: `auto`, `center`. | -| `showPercentChange` | boolean | **Yes** | `false` | | -| `textMode` | string | **Yes** | | TODO docs<br/>Possible values are: `auto`, `value`, `value_and_name`, `name`, `none`. | -| `wideLayout` | boolean | **Yes** | `true` | | -| `orientation` | string | No | | *(Inherited from [SingleStatBaseOptions](#singlestatbaseoptions))*<br/>TODO docs<br/>Possible values are: `auto`, `vertical`, `horizontal`. | -| `reduceOptions` | [ReduceDataOptions](#reducedataoptions) | No | | *(Inherited from [SingleStatBaseOptions](#singlestatbaseoptions))*<br/>TODO docs | -| `text` | [VizTextDisplayOptions](#viztextdisplayoptions) | No | | *(Inherited from [SingleStatBaseOptions](#singlestatbaseoptions))*<br/>TODO docs | - -### ReduceDataOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|----------|----------|---------|---------------------------------------------------------------| -| `calcs` | string[] | **Yes** | | When !values, pick one value for the whole field | -| `fields` | string | No | | Which fields to show. By default this is only numeric fields | -| `limit` | number | No | | if showing all values limit | -| `values` | boolean | No | | If true show each row value | - -### SingleStatBaseOptions - -TODO docs - -It extends [OptionsWithTextFormatting](#optionswithtextformatting). - -| Property | Type | Required | Default | Description | -|-----------------|-------------------------------------------------|----------|---------|------------------------------------------------------------------------------------------| -| `orientation` | string | **Yes** | | TODO docs<br/>Possible values are: `auto`, `vertical`, `horizontal`. | -| `reduceOptions` | [ReduceDataOptions](#reducedataoptions) | **Yes** | | TODO docs | -| `text` | [VizTextDisplayOptions](#viztextdisplayoptions) | No | | *(Inherited from [OptionsWithTextFormatting](#optionswithtextformatting))*<br/>TODO docs | - -### OptionsWithTextFormatting - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|-------------------------------------------------|----------|---------|-------------| -| `text` | [VizTextDisplayOptions](#viztextdisplayoptions) | No | | TODO docs | - -### VizTextDisplayOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|-------------|--------|----------|---------|--------------------------| -| `titleSize` | number | No | | Explicit title text size | -| `valueSize` | number | No | | Explicit value text size | - - diff --git a/docs/sources/developers/kinds/composable/statetimeline/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/statetimeline/panelcfg/schema-reference.md deleted file mode 100644 index be3e14fdc3598..0000000000000 --- a/docs/sources/developers/kinds/composable/statetimeline/panelcfg/schema-reference.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: StateTimelinePanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## StateTimelinePanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|---------------|------------------------|----------|---------|-------------| -| `FieldConfig` | [object](#fieldconfig) | **Yes** | | | -| `Options` | [object](#options) | **Yes** | | | - -### FieldConfig - -It extends [HideableFieldConfig](#hideablefieldconfig). - -| Property | Type | Required | Default | Description | -|---------------|---------------------------------------|----------|---------|------------------------------------------------------------------------------| -| `fillOpacity` | integer | No | `70` | Constraint: `>=0 & <=100`. | -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | *(Inherited from [HideableFieldConfig](#hideablefieldconfig))*<br/>TODO docs | -| `lineWidth` | integer | No | `0` | Constraint: `>=0 & <=10`. | - -### HideSeriesConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|---------|----------|---------|-------------| -| `legend` | boolean | **Yes** | | | -| `tooltip` | boolean | **Yes** | | | -| `viz` | boolean | **Yes** | | | - -### HideableFieldConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|---------------------------------------|----------|---------|-------------| -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | TODO docs | - -### Options - -It extends [OptionsWithLegend](#optionswithlegend) and [OptionsWithTooltip](#optionswithtooltip) and [OptionsWithTimezones](#optionswithtimezones). - -| Property | Type | Required | Default | Description | -|---------------|-----------------------------------------|----------|---------|----------------------------------------------------------------------------| -| `legend` | [VizLegendOptions](#vizlegendoptions) | **Yes** | | *(Inherited from [OptionsWithLegend](#optionswithlegend))*<br/>TODO docs | -| `rowHeight` | number | **Yes** | `0.9` | Controls the row height | -| `showValue` | string | **Yes** | | Show timeline values on chart | -| `tooltip` | [VizTooltipOptions](#viztooltipoptions) | **Yes** | | *(Inherited from [OptionsWithTooltip](#optionswithtooltip))*<br/>TODO docs | -| `alignValue` | string | No | | Controls value alignment on the timelines | -| `mergeValues` | boolean | No | `true` | Merge equal consecutive values | -| `timezone` | string[] | No | | *(Inherited from [OptionsWithTimezones](#optionswithtimezones))* | - -### OptionsWithLegend - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|---------------------------------------|----------|---------|-------------| -| `legend` | [VizLegendOptions](#vizlegendoptions) | **Yes** | | TODO docs | - -### VizLegendOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|----------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `calcs` | string[] | **Yes** | | | -| `displayMode` | string | **Yes** | | TODO docs<br/>Note: "hidden" needs to remain as an option for plugins compatibility<br/>Possible values are: `list`, `table`, `hidden`. | -| `placement` | string | **Yes** | | TODO docs<br/>Possible values are: `bottom`, `right`. | -| `showLegend` | boolean | **Yes** | | | -| `asTable` | boolean | No | | | -| `isVisible` | boolean | No | | | -| `sortBy` | string | No | | | -| `sortDesc` | boolean | No | | | -| `width` | number | No | | | - -### OptionsWithTimezones - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|----------|----------|---------|-------------| -| `timezone` | string[] | No | | | - -### OptionsWithTooltip - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|-----------------------------------------|----------|---------|-------------| -| `tooltip` | [VizTooltipOptions](#viztooltipoptions) | **Yes** | | TODO docs | - -### VizTooltipOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|---------------------------------------------------------------| -| `mode` | string | **Yes** | | TODO docs<br/>Possible values are: `single`, `multi`, `none`. | -| `sort` | string | **Yes** | | TODO docs<br/>Possible values are: `asc`, `desc`, `none`. | - - diff --git a/docs/sources/developers/kinds/composable/statushistory/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/statushistory/panelcfg/schema-reference.md deleted file mode 100644 index 1220c13da15e7..0000000000000 --- a/docs/sources/developers/kinds/composable/statushistory/panelcfg/schema-reference.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: StatusHistoryPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## StatusHistoryPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|---------------|------------------------|----------|---------|-------------| -| `FieldConfig` | [object](#fieldconfig) | **Yes** | | | -| `Options` | [object](#options) | **Yes** | | | - -### FieldConfig - -It extends [HideableFieldConfig](#hideablefieldconfig). - -| Property | Type | Required | Default | Description | -|---------------|---------------------------------------|----------|---------|------------------------------------------------------------------------------| -| `fillOpacity` | integer | No | `70` | Constraint: `>=0 & <=100`. | -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | *(Inherited from [HideableFieldConfig](#hideablefieldconfig))*<br/>TODO docs | -| `lineWidth` | integer | No | `1` | Constraint: `>=0 & <=10`. | - -### HideSeriesConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|---------|----------|---------|-------------| -| `legend` | boolean | **Yes** | | | -| `tooltip` | boolean | **Yes** | | | -| `viz` | boolean | **Yes** | | | - -### HideableFieldConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|---------------------------------------|----------|---------|-------------| -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | TODO docs | - -### Options - -It extends [OptionsWithLegend](#optionswithlegend) and [OptionsWithTooltip](#optionswithtooltip) and [OptionsWithTimezones](#optionswithtimezones). - -| Property | Type | Required | Default | Description | -|-------------|-----------------------------------------|----------|---------|----------------------------------------------------------------------------| -| `legend` | [VizLegendOptions](#vizlegendoptions) | **Yes** | | *(Inherited from [OptionsWithLegend](#optionswithlegend))*<br/>TODO docs | -| `rowHeight` | number | **Yes** | `0.9` | Set the height of the rows<br/>Constraint: `>=0 & <=1`. | -| `showValue` | string | **Yes** | | Show values on the columns | -| `tooltip` | [VizTooltipOptions](#viztooltipoptions) | **Yes** | | *(Inherited from [OptionsWithTooltip](#optionswithtooltip))*<br/>TODO docs | -| `colWidth` | number | No | `0.9` | Controls the column width | -| `timezone` | string[] | No | | *(Inherited from [OptionsWithTimezones](#optionswithtimezones))* | - -### OptionsWithLegend - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|---------------------------------------|----------|---------|-------------| -| `legend` | [VizLegendOptions](#vizlegendoptions) | **Yes** | | TODO docs | - -### VizLegendOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|----------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `calcs` | string[] | **Yes** | | | -| `displayMode` | string | **Yes** | | TODO docs<br/>Note: "hidden" needs to remain as an option for plugins compatibility<br/>Possible values are: `list`, `table`, `hidden`. | -| `placement` | string | **Yes** | | TODO docs<br/>Possible values are: `bottom`, `right`. | -| `showLegend` | boolean | **Yes** | | | -| `asTable` | boolean | No | | | -| `isVisible` | boolean | No | | | -| `sortBy` | string | No | | | -| `sortDesc` | boolean | No | | | -| `width` | number | No | | | - -### OptionsWithTimezones - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|----------|----------|---------|-------------| -| `timezone` | string[] | No | | | - -### OptionsWithTooltip - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|-----------------------------------------|----------|---------|-------------| -| `tooltip` | [VizTooltipOptions](#viztooltipoptions) | **Yes** | | TODO docs | - -### VizTooltipOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|---------------------------------------------------------------| -| `mode` | string | **Yes** | | TODO docs<br/>Possible values are: `single`, `multi`, `none`. | -| `sort` | string | **Yes** | | TODO docs<br/>Possible values are: `asc`, `desc`, `none`. | - - diff --git a/docs/sources/developers/kinds/composable/table/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/table/panelcfg/schema-reference.md deleted file mode 100644 index 0fbc39fba0104..0000000000000 --- a/docs/sources/developers/kinds/composable/table/panelcfg/schema-reference.md +++ /dev/null @@ -1,332 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: TablePanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## TablePanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|---------------|------------------------|----------|---------|-------------| -| `FieldConfig` | [object](#fieldconfig) | **Yes** | | | -| `Options` | [object](#options) | **Yes** | | | - -### FieldConfig - -| Property | Type | Required | Default | Description | -|---------------|---------------------------------------|----------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `align` | string | **Yes** | | TODO -- should not be table specific!<br/>TODO docs<br/>Possible values are: `auto`, `left`, `right`, `center`. | -| `cellOptions` | [TableCellOptions](#tablecelloptions) | **Yes** | | Table cell options. Each cell has a display mode<br/>and other potential options for that display. | -| `inspect` | boolean | **Yes** | `false` | | -| `displayMode` | string | No | | Internally, this is the "type" of cell that's being displayed<br/>in the table such as colored text, JSON, gauge, etc.<br/>The color-background-solid, gradient-gauge, and lcd-gauge<br/>modes are deprecated in favor of new cell subOptions<br/>Possible values are: `auto`, `color-text`, `color-background`, `color-background-solid`, `gradient-gauge`, `lcd-gauge`, `json-view`, `basic`, `image`, `gauge`, `sparkline`, `custom`. | -| `filterable` | boolean | No | | | -| `hidden` | boolean | No | | | -| `hideHeader` | boolean | No | | Hides any header for a column, useful for columns that show some static content or buttons. | -| `minWidth` | number | No | | | -| `width` | number | No | | | - -### TableCellOptions - -Table cell options. Each cell has a display mode -and other potential options for that display. - -| Property | Type | Required | Default | Description | -|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|---------|-------------| -| `object` | Possible types are: [TableAutoCellOptions](#tableautocelloptions), [TableSparklineCellOptions](#tablesparklinecelloptions), [TableBarGaugeCellOptions](#tablebargaugecelloptions), [TableColoredBackgroundCellOptions](#tablecoloredbackgroundcelloptions), [TableColorTextCellOptions](#tablecolortextcelloptions), [TableImageCellOptions](#tableimagecelloptions), [TableJsonViewCellOptions](#tablejsonviewcelloptions). | | | - -### GraphThresholdsStyleConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-----------------------------------------------------------------------------------------------------------| -| `mode` | string | **Yes** | | TODO docs<br/>Possible values are: `off`, `line`, `dashed`, `area`, `line+area`, `dashed+area`, `series`. | - -### HideSeriesConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|---------|----------|---------|-------------| -| `legend` | boolean | **Yes** | | | -| `tooltip` | boolean | **Yes** | | | -| `viz` | boolean | **Yes** | | | - -### LineStyle - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|----------|----------|---------|--------------------------------------------------------| -| `dash` | number[] | No | | | -| `fill` | string | No | | Possible values are: `solid`, `dash`, `dot`, `square`. | - -### ScaleDistributionConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-------------------|--------|----------|---------|--------------------------------------------------------------------------| -| `type` | string | **Yes** | | TODO docs<br/>Possible values are: `linear`, `log`, `ordinal`, `symlog`. | -| `linearThreshold` | number | No | | | -| `log` | number | No | | | - -### StackingConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-----------------------------------------------------------------| -| `group` | string | No | | | -| `mode` | string | No | | TODO docs<br/>Possible values are: `none`, `normal`, `percent`. | - -### TableAutoCellOptions - -Auto mode table cell options - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-------------| -| `type` | string | **Yes** | | | - -### TableBarGaugeCellOptions - -Gauge cell options - -| Property | Type | Required | Default | Description | -|--------------------|--------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | string | **Yes** | | | -| `mode` | string | No | | Enum expressing the possible display modes<br/>for the bar gauge component of Grafana UI<br/>Possible values are: `basic`, `lcd`, `gradient`. | -| `valueDisplayMode` | string | No | | Allows for the table cell gauge display type to set the gauge mode.<br/>Possible values are: `color`, `text`, `hidden`. | - -### TableColorTextCellOptions - -Colored text cell options - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-------------| -| `type` | string | **Yes** | | | - -### TableColoredBackgroundCellOptions - -Colored background cell options - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | string | **Yes** | | | -| `mode` | string | No | | Display mode to the "Colored Background" display<br/>mode for table cells. Either displays a solid color (basic mode)<br/>or a gradient.<br/>Possible values are: `basic`, `gradient`. | - -### TableImageCellOptions - -Json view cell options - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-------------| -| `type` | string | **Yes** | | | - -### TableJsonViewCellOptions - -Json view cell options - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-------------| -| `type` | string | **Yes** | | | - -### TableSparklineCellOptions - -Sparkline cell options - -It extends [GraphFieldConfig](#graphfieldconfig). - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------------|----------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | string | **Yes** | | | -| `axisBorderShow` | boolean | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `axisCenteredZero` | boolean | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `axisColorMode` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `axisLabel` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `axisPlacement` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `axisSoftMin` | number | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `axisWidth` | number | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `barAlignment` | integer | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs<br/>Possible values are: `-1`, `0`, `1`. | -| `barMaxWidth` | number | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `barWidthFactor` | number | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `drawStyle` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs<br/>Possible values are: `line`, `bars`, `points`. | -| `fillBelowTo` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `fillColor` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `fillOpacity` | number | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `gradientMode` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs<br/>Possible values are: `none`, `opacity`, `hue`, `scheme`. | -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs | -| `hideValue` | boolean | No | | | -| `lineColor` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `lineInterpolation` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs<br/>Possible values are: `linear`, `smooth`, `stepBefore`, `stepAfter`. | -| `lineStyle` | [LineStyle](#linestyle) | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs | -| `lineWidth` | number | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `pointColor` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `pointSize` | number | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `pointSymbol` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs | -| `showPoints` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs<br/>Possible values are: `auto`, `never`, `always`. | -| `spanNulls` | | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>Indicate if null values should be treated as gaps or connected.<br/>When the value is a number, it represents the maximum delta in the<br/>X axis that should be considered connected. For timeseries, this is milliseconds | -| `stacking` | [StackingConfig](#stackingconfig) | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs | -| `thresholdsStyle` | [GraphThresholdsStyleConfig](#graphthresholdsstyleconfig) | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs | -| `transform` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs<br/>Possible values are: `constant`, `negative-Y`. | - -### GraphFieldConfig - -TODO docs - -It extends [LineConfig](#lineconfig) and [FillConfig](#fillconfig) and [PointsConfig](#pointsconfig) and [AxisConfig](#axisconfig) and [BarConfig](#barconfig) and [StackableFieldConfig](#stackablefieldconfig) and [HideableFieldConfig](#hideablefieldconfig). - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------------|----------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisCenteredZero` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisColorMode` | string | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisLabel` | string | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisPlacement` | string | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisSoftMin` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisWidth` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `barAlignment` | integer | No | | *(Inherited from [BarConfig](#barconfig))*<br/>TODO docs<br/>Possible values are: `-1`, `0`, `1`. | -| `barMaxWidth` | number | No | | *(Inherited from [BarConfig](#barconfig))* | -| `barWidthFactor` | number | No | | *(Inherited from [BarConfig](#barconfig))* | -| `drawStyle` | string | No | | TODO docs<br/>Possible values are: `line`, `bars`, `points`. | -| `fillBelowTo` | string | No | | *(Inherited from [FillConfig](#fillconfig))* | -| `fillColor` | string | No | | *(Inherited from [FillConfig](#fillconfig))* | -| `fillOpacity` | number | No | | *(Inherited from [FillConfig](#fillconfig))* | -| `gradientMode` | string | No | | TODO docs<br/>Possible values are: `none`, `opacity`, `hue`, `scheme`. | -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | *(Inherited from [HideableFieldConfig](#hideablefieldconfig))*<br/>TODO docs | -| `lineColor` | string | No | | *(Inherited from [LineConfig](#lineconfig))* | -| `lineInterpolation` | string | No | | *(Inherited from [LineConfig](#lineconfig))*<br/>TODO docs<br/>Possible values are: `linear`, `smooth`, `stepBefore`, `stepAfter`. | -| `lineStyle` | [LineStyle](#linestyle) | No | | *(Inherited from [LineConfig](#lineconfig))*<br/>TODO docs | -| `lineWidth` | number | No | | *(Inherited from [LineConfig](#lineconfig))* | -| `pointColor` | string | No | | *(Inherited from [PointsConfig](#pointsconfig))* | -| `pointSize` | number | No | | *(Inherited from [PointsConfig](#pointsconfig))* | -| `pointSymbol` | string | No | | *(Inherited from [PointsConfig](#pointsconfig))* | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs | -| `showPoints` | string | No | | *(Inherited from [PointsConfig](#pointsconfig))*<br/>TODO docs<br/>Possible values are: `auto`, `never`, `always`. | -| `spanNulls` | | No | | *(Inherited from [LineConfig](#lineconfig))*<br/>Indicate if null values should be treated as gaps or connected.<br/>When the value is a number, it represents the maximum delta in the<br/>X axis that should be considered connected. For timeseries, this is milliseconds | -| `stacking` | [StackingConfig](#stackingconfig) | No | | *(Inherited from [StackableFieldConfig](#stackablefieldconfig))*<br/>TODO docs | -| `thresholdsStyle` | [GraphThresholdsStyleConfig](#graphthresholdsstyleconfig) | No | | TODO docs | -| `transform` | string | No | | TODO docs<br/>Possible values are: `constant`, `negative-Y`. | - -### AxisConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | | -| `axisCenteredZero` | boolean | No | | | -| `axisColorMode` | string | No | | TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | | -| `axisLabel` | string | No | | | -| `axisPlacement` | string | No | | TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | | -| `axisSoftMin` | number | No | | | -| `axisWidth` | number | No | | | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | TODO docs | - -### BarConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------------|---------|----------|---------|----------------------------------------------------| -| `barAlignment` | integer | No | | TODO docs<br/>Possible values are: `-1`, `0`, `1`. | -| `barMaxWidth` | number | No | | | -| `barWidthFactor` | number | No | | | - -### FillConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|--------|----------|---------|-------------| -| `fillBelowTo` | string | No | | | -| `fillColor` | string | No | | | -| `fillOpacity` | number | No | | | - -### HideableFieldConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|---------------------------------------|----------|---------|-------------| -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | TODO docs | - -### LineConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------------|-------------------------|----------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `lineColor` | string | No | | | -| `lineInterpolation` | string | No | | TODO docs<br/>Possible values are: `linear`, `smooth`, `stepBefore`, `stepAfter`. | -| `lineStyle` | [LineStyle](#linestyle) | No | | TODO docs | -| `lineWidth` | number | No | | | -| `spanNulls` | | No | | Indicate if null values should be treated as gaps or connected.<br/>When the value is a number, it represents the maximum delta in the<br/>X axis that should be considered connected. For timeseries, this is milliseconds | - -### PointsConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|--------|----------|---------|---------------------------------------------------------------| -| `pointColor` | string | No | | | -| `pointSize` | number | No | | | -| `pointSymbol` | string | No | | | -| `showPoints` | string | No | | TODO docs<br/>Possible values are: `auto`, `never`, `always`. | - -### StackableFieldConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|-----------------------------------|----------|---------|-------------| -| `stacking` | [StackingConfig](#stackingconfig) | No | | TODO docs | - -### Options - -| Property | Type | Required | Default | Description | -|-----------------|---------------------------------------------------|----------|----------------------------------------------|--------------------------------------------------------------------| -| `frameIndex` | number | **Yes** | `0` | Represents the index of the selected frame | -| `showHeader` | boolean | **Yes** | `true` | Controls whether the panel should show the header | -| `cellHeight` | string | No | | Controls the height of the rows | -| `footer` | [object](#footer) | No | `map[countRows:false reducer:[] show:false]` | Controls footer options | -| `showTypeIcons` | boolean | No | `false` | Controls whether the header should show icons for the column types | -| `sortBy` | [TableSortByFieldState](#tablesortbyfieldstate)[] | No | | Used to control row sorting | - -### TableSortByFieldState - -Sort by field state - -| Property | Type | Required | Default | Description | -|---------------|---------|----------|---------|-----------------------------------------------| -| `displayName` | string | **Yes** | | Sets the display name of the field to sort by | -| `desc` | boolean | No | | Flag used to indicate descending sort order | - -### Footer - -Controls footer options - -| Property | Type | Required | Default | Description | -|----------|-----------------------------------|----------|---------|-------------| -| `object` | Possible types are: [](#), [](#). | | | - - diff --git a/docs/sources/developers/kinds/composable/tempo/dataquery/schema-reference.md b/docs/sources/developers/kinds/composable/tempo/dataquery/schema-reference.md deleted file mode 100644 index 1b44c7cc98fb6..0000000000000 --- a/docs/sources/developers/kinds/composable/tempo/dataquery/schema-reference.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: TempoDataQuery kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## TempoDataQuery - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - - diff --git a/docs/sources/developers/kinds/composable/testdata/dataquery/schema-reference.md b/docs/sources/developers/kinds/composable/testdata/dataquery/schema-reference.md deleted file mode 100644 index 9cb911bd5c27f..0000000000000 --- a/docs/sources/developers/kinds/composable/testdata/dataquery/schema-reference.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: TestDataDataQuery kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## TestDataDataQuery - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-------------------|-------------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `refId` | string | **Yes** | | A unique identifier for the query within the list of targets.<br/>In server side expressions, the refId is used as a variable name to identify results.<br/>By default, the UI will assign A->Z; however setting meaningful names may be useful. | -| `alias` | string | No | | | -| `channel` | string | No | | | -| `csvContent` | string | No | | | -| `csvFileName` | string | No | | | -| `csvWave` | [CSVWave](#csvwave)[] | No | | | -| `datasource` | | No | | For mixed data sources the selected datasource is on the query level.<br/>For non mixed scenarios this is undefined.<br/>TODO find a better way to do this ^ that's friendly to schema<br/>TODO this shouldn't be unknown but DataSourceRef | null | -| `dropPercent` | number | No | | Drop percentage (the chance we will lose a point 0-100) | -| `errorType` | string | No | | Possible values are: `server_panic`, `frontend_exception`, `frontend_observable`. | -| `flamegraphDiff` | boolean | No | | | -| `hide` | boolean | No | | true if query is disabled (ie should not be returned to the dashboard)<br/>Note this does not always imply that the query should not be executed since<br/>the results from a hidden query may be used as the input to other queries (SSE etc) | -| `labels` | string | No | | | -| `levelColumn` | boolean | No | | | -| `lines` | integer | No | | | -| `nodes` | [NodesQuery](#nodesquery) | No | | | -| `points` | array[] | No | | | -| `pulseWave` | [PulseWaveQuery](#pulsewavequery) | No | | | -| `queryType` | string | No | | Specify the query flavor<br/>TODO make this required and give it a default | -| `rawFrameContent` | string | No | | | -| `scenarioId` | string | No | | Possible values are: `random_walk`, `slow_query`, `random_walk_with_error`, `random_walk_table`, `exponential_heatmap_bucket_data`, `linear_heatmap_bucket_data`, `no_data_points`, `datapoints_outside_range`, `csv_metric_values`, `predictable_pulse`, `predictable_csv_wave`, `streaming_client`, `simulation`, `usa`, `live`, `grafana_api`, `arrow`, `annotations`, `table_static`, `server_error_500`, `logs`, `node_graph`, `flame_graph`, `raw_frame`, `csv_file`, `csv_content`, `trace`, `manual_entry`, `variables-query`. | -| `seriesCount` | integer | No | | | -| `sim` | [SimulationQuery](#simulationquery) | No | | | -| `spanCount` | integer | No | | | -| `stream` | [StreamingQuery](#streamingquery) | No | | | -| `stringInput` | string | No | | | -| `usa` | [USAQuery](#usaquery) | No | | | - -### CSVWave - -| Property | Type | Required | Default | Description | -|-------------|---------|----------|---------|-------------| -| `labels` | string | No | | | -| `name` | string | No | | | -| `timeStep` | integer | No | | | -| `valuesCSV` | string | No | | | - -### NodesQuery - -| Property | Type | Required | Default | Description | -|----------|---------|----------|---------|------------------------------------------------------------| -| `count` | integer | No | | | -| `type` | string | No | | Possible values are: `random`, `response`, `random edges`. | - -### PulseWaveQuery - -| Property | Type | Required | Default | Description | -|------------|---------|----------|---------|-------------| -| `offCount` | integer | No | | | -| `offValue` | number | No | | | -| `onCount` | integer | No | | | -| `onValue` | number | No | | | -| `timeStep` | integer | No | | | - -### SimulationQuery - -| Property | Type | Required | Default | Description | -|----------|-------------------|----------|---------|-------------| -| `key` | [object](#key) | **Yes** | | | -| `config` | [object](#config) | No | | | -| `last` | boolean | No | | | -| `stream` | boolean | No | | | - -### Config - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Key - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-------------| -| `tick` | number | **Yes** | | | -| `type` | string | **Yes** | | | -| `uid` | string | No | | | - -### StreamingQuery - -| Property | Type | Required | Default | Description | -|----------|---------|----------|---------|-------------------------------------------------| -| `noise` | integer | **Yes** | | | -| `speed` | integer | **Yes** | | | -| `spread` | integer | **Yes** | | | -| `type` | string | **Yes** | | Possible values are: `signal`, `logs`, `fetch`. | -| `bands` | integer | No | | | -| `url` | string | No | | | - -### USAQuery - -| Property | Type | Required | Default | Description | -|----------|----------|----------|---------|-------------| -| `fields` | string[] | No | | | -| `mode` | string | No | | | -| `period` | string | No | | | -| `states` | string[] | No | | | - - diff --git a/docs/sources/developers/kinds/composable/text/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/text/panelcfg/schema-reference.md deleted file mode 100644 index 8b566c2a2abda..0000000000000 --- a/docs/sources/developers/kinds/composable/text/panelcfg/schema-reference.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: TextPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## TextPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|----------------|------------------------|----------|-------------|---------------------------------------------------------------------------------------------------------| -| `CodeLanguage` | string | **Yes** | `plaintext` | Possible values are: `plaintext`, `yaml`, `xml`, `typescript`, `sql`, `go`, `markdown`, `html`, `json`. | -| `CodeOptions` | [object](#codeoptions) | **Yes** | | | -| `Options` | [object](#options) | **Yes** | | | -| `TextMode` | string | **Yes** | | Possible values are: `html`, `markdown`, `code`. | - -### CodeOptions - -| Property | Type | Required | Default | Description | -|-------------------|---------|----------|-------------|---------------------------------------------------------------------------------------------------------| -| `language` | string | **Yes** | `plaintext` | Possible values are: `plaintext`, `yaml`, `xml`, `typescript`, `sql`, `go`, `markdown`, `html`, `json`. | -| `showLineNumbers` | boolean | **Yes** | `false` | | -| `showMiniMap` | boolean | **Yes** | `false` | | - -### Options - -| Property | Type | Required | Default | Description | -|-----------|-----------------------------|----------|--------------------------------------------------------------------------------|--------------------------------------------------| -| `content` | string | **Yes** | `# Title | | -| | | | | | -| | | | For markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)` | | -| `mode` | string | **Yes** | | Possible values are: `html`, `markdown`, `code`. | -| `code` | [CodeOptions](#codeoptions) | No | | | - - diff --git a/docs/sources/developers/kinds/composable/timeseries/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/timeseries/panelcfg/schema-reference.md deleted file mode 100644 index 06347e8182666..0000000000000 --- a/docs/sources/developers/kinds/composable/timeseries/panelcfg/schema-reference.md +++ /dev/null @@ -1,231 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: TimeSeriesPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## TimeSeriesPanelCfg - -#### Maturity: [merged](../../../maturity/#merged) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|---------------|---------------------------------------|----------|---------|-------------| -| `FieldConfig` | [GraphFieldConfig](#graphfieldconfig) | **Yes** | | TODO docs | -| `Options` | [object](#options) | **Yes** | | | - -### GraphFieldConfig - -TODO docs - -It extends [LineConfig](#lineconfig) and [FillConfig](#fillconfig) and [PointsConfig](#pointsconfig) and [AxisConfig](#axisconfig) and [BarConfig](#barconfig) and [StackableFieldConfig](#stackablefieldconfig) and [HideableFieldConfig](#hideablefieldconfig). - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------------|----------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisCenteredZero` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisColorMode` | string | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisLabel` | string | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisPlacement` | string | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisSoftMin` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisWidth` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `barAlignment` | integer | No | | *(Inherited from [BarConfig](#barconfig))*<br/>TODO docs<br/>Possible values are: `-1`, `0`, `1`. | -| `barMaxWidth` | number | No | | *(Inherited from [BarConfig](#barconfig))* | -| `barWidthFactor` | number | No | | *(Inherited from [BarConfig](#barconfig))* | -| `drawStyle` | string | No | | TODO docs<br/>Possible values are: `line`, `bars`, `points`. | -| `fillBelowTo` | string | No | | *(Inherited from [FillConfig](#fillconfig))* | -| `fillColor` | string | No | | *(Inherited from [FillConfig](#fillconfig))* | -| `fillOpacity` | number | No | | *(Inherited from [FillConfig](#fillconfig))* | -| `gradientMode` | string | No | | TODO docs<br/>Possible values are: `none`, `opacity`, `hue`, `scheme`. | -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | *(Inherited from [HideableFieldConfig](#hideablefieldconfig))*<br/>TODO docs | -| `lineColor` | string | No | | *(Inherited from [LineConfig](#lineconfig))* | -| `lineInterpolation` | string | No | | *(Inherited from [LineConfig](#lineconfig))*<br/>TODO docs<br/>Possible values are: `linear`, `smooth`, `stepBefore`, `stepAfter`. | -| `lineStyle` | [LineStyle](#linestyle) | No | | *(Inherited from [LineConfig](#lineconfig))*<br/>TODO docs | -| `lineWidth` | number | No | | *(Inherited from [LineConfig](#lineconfig))* | -| `pointColor` | string | No | | *(Inherited from [PointsConfig](#pointsconfig))* | -| `pointSize` | number | No | | *(Inherited from [PointsConfig](#pointsconfig))* | -| `pointSymbol` | string | No | | *(Inherited from [PointsConfig](#pointsconfig))* | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs | -| `showPoints` | string | No | | *(Inherited from [PointsConfig](#pointsconfig))*<br/>TODO docs<br/>Possible values are: `auto`, `never`, `always`. | -| `spanNulls` | | No | | *(Inherited from [LineConfig](#lineconfig))*<br/>Indicate if null values should be treated as gaps or connected.<br/>When the value is a number, it represents the maximum delta in the<br/>X axis that should be considered connected. For timeseries, this is milliseconds | -| `stacking` | [StackingConfig](#stackingconfig) | No | | *(Inherited from [StackableFieldConfig](#stackablefieldconfig))*<br/>TODO docs | -| `thresholdsStyle` | [GraphThresholdsStyleConfig](#graphthresholdsstyleconfig) | No | | TODO docs | -| `transform` | string | No | | TODO docs<br/>Possible values are: `constant`, `negative-Y`. | - -### AxisConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | | -| `axisCenteredZero` | boolean | No | | | -| `axisColorMode` | string | No | | TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | | -| `axisLabel` | string | No | | | -| `axisPlacement` | string | No | | TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | | -| `axisSoftMin` | number | No | | | -| `axisWidth` | number | No | | | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | TODO docs | - -### ScaleDistributionConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-------------------|--------|----------|---------|--------------------------------------------------------------------------| -| `type` | string | **Yes** | | TODO docs<br/>Possible values are: `linear`, `log`, `ordinal`, `symlog`. | -| `linearThreshold` | number | No | | | -| `log` | number | No | | | - -### BarConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------------|---------|----------|---------|----------------------------------------------------| -| `barAlignment` | integer | No | | TODO docs<br/>Possible values are: `-1`, `0`, `1`. | -| `barMaxWidth` | number | No | | | -| `barWidthFactor` | number | No | | | - -### FillConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|--------|----------|---------|-------------| -| `fillBelowTo` | string | No | | | -| `fillColor` | string | No | | | -| `fillOpacity` | number | No | | | - -### GraphThresholdsStyleConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-----------------------------------------------------------------------------------------------------------| -| `mode` | string | **Yes** | | TODO docs<br/>Possible values are: `off`, `line`, `dashed`, `area`, `line+area`, `dashed+area`, `series`. | - -### HideSeriesConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|---------|----------|---------|-------------| -| `legend` | boolean | **Yes** | | | -| `tooltip` | boolean | **Yes** | | | -| `viz` | boolean | **Yes** | | | - -### HideableFieldConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|---------------------------------------|----------|---------|-------------| -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | TODO docs | - -### LineConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------------|-------------------------|----------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `lineColor` | string | No | | | -| `lineInterpolation` | string | No | | TODO docs<br/>Possible values are: `linear`, `smooth`, `stepBefore`, `stepAfter`. | -| `lineStyle` | [LineStyle](#linestyle) | No | | TODO docs | -| `lineWidth` | number | No | | | -| `spanNulls` | | No | | Indicate if null values should be treated as gaps or connected.<br/>When the value is a number, it represents the maximum delta in the<br/>X axis that should be considered connected. For timeseries, this is milliseconds | - -### LineStyle - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|----------|----------|---------|--------------------------------------------------------| -| `dash` | number[] | No | | | -| `fill` | string | No | | Possible values are: `solid`, `dash`, `dot`, `square`. | - -### PointsConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|--------|----------|---------|---------------------------------------------------------------| -| `pointColor` | string | No | | | -| `pointSize` | number | No | | | -| `pointSymbol` | string | No | | | -| `showPoints` | string | No | | TODO docs<br/>Possible values are: `auto`, `never`, `always`. | - -### StackableFieldConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|-----------------------------------|----------|---------|-------------| -| `stacking` | [StackingConfig](#stackingconfig) | No | | TODO docs | - -### StackingConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-----------------------------------------------------------------| -| `group` | string | No | | | -| `mode` | string | No | | TODO docs<br/>Possible values are: `none`, `normal`, `percent`. | - -### Options - -It extends [OptionsWithTimezones](#optionswithtimezones). - -| Property | Type | Required | Default | Description | -|------------|-----------------------------------------|----------|---------|------------------------------------------------------------------| -| `legend` | [VizLegendOptions](#vizlegendoptions) | **Yes** | | TODO docs | -| `tooltip` | [VizTooltipOptions](#viztooltipoptions) | **Yes** | | TODO docs | -| `timezone` | string[] | No | | *(Inherited from [OptionsWithTimezones](#optionswithtimezones))* | - -### OptionsWithTimezones - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|----------|----------|---------|-------------| -| `timezone` | string[] | No | | | - -### VizLegendOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|----------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `calcs` | string[] | **Yes** | | | -| `displayMode` | string | **Yes** | | TODO docs<br/>Note: "hidden" needs to remain as an option for plugins compatibility<br/>Possible values are: `list`, `table`, `hidden`. | -| `placement` | string | **Yes** | | TODO docs<br/>Possible values are: `bottom`, `right`. | -| `showLegend` | boolean | **Yes** | | | -| `asTable` | boolean | No | | | -| `isVisible` | boolean | No | | | -| `sortBy` | string | No | | | -| `sortDesc` | boolean | No | | | -| `width` | number | No | | | - -### VizTooltipOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|---------------------------------------------------------------| -| `mode` | string | **Yes** | | TODO docs<br/>Possible values are: `single`, `multi`, `none`. | -| `sort` | string | **Yes** | | TODO docs<br/>Possible values are: `asc`, `desc`, `none`. | - - diff --git a/docs/sources/developers/kinds/composable/trend/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/trend/panelcfg/schema-reference.md deleted file mode 100644 index 73ec1cfc49c74..0000000000000 --- a/docs/sources/developers/kinds/composable/trend/panelcfg/schema-reference.md +++ /dev/null @@ -1,223 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: TrendPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## TrendPanelCfg - -#### Maturity: [merged](../../../maturity/#merged) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|---------------|---------------------------------------|----------|---------|----------------------------------------------------------------------| -| `FieldConfig` | [GraphFieldConfig](#graphfieldconfig) | **Yes** | | TODO docs | -| `Options` | [object](#options) | **Yes** | | Identical to timeseries... except it does not have timezone settings | - -### GraphFieldConfig - -TODO docs - -It extends [LineConfig](#lineconfig) and [FillConfig](#fillconfig) and [PointsConfig](#pointsconfig) and [AxisConfig](#axisconfig) and [BarConfig](#barconfig) and [StackableFieldConfig](#stackablefieldconfig) and [HideableFieldConfig](#hideablefieldconfig). - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------------|----------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisCenteredZero` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisColorMode` | string | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisLabel` | string | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisPlacement` | string | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisSoftMin` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisWidth` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `barAlignment` | integer | No | | *(Inherited from [BarConfig](#barconfig))*<br/>TODO docs<br/>Possible values are: `-1`, `0`, `1`. | -| `barMaxWidth` | number | No | | *(Inherited from [BarConfig](#barconfig))* | -| `barWidthFactor` | number | No | | *(Inherited from [BarConfig](#barconfig))* | -| `drawStyle` | string | No | | TODO docs<br/>Possible values are: `line`, `bars`, `points`. | -| `fillBelowTo` | string | No | | *(Inherited from [FillConfig](#fillconfig))* | -| `fillColor` | string | No | | *(Inherited from [FillConfig](#fillconfig))* | -| `fillOpacity` | number | No | | *(Inherited from [FillConfig](#fillconfig))* | -| `gradientMode` | string | No | | TODO docs<br/>Possible values are: `none`, `opacity`, `hue`, `scheme`. | -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | *(Inherited from [HideableFieldConfig](#hideablefieldconfig))*<br/>TODO docs | -| `lineColor` | string | No | | *(Inherited from [LineConfig](#lineconfig))* | -| `lineInterpolation` | string | No | | *(Inherited from [LineConfig](#lineconfig))*<br/>TODO docs<br/>Possible values are: `linear`, `smooth`, `stepBefore`, `stepAfter`. | -| `lineStyle` | [LineStyle](#linestyle) | No | | *(Inherited from [LineConfig](#lineconfig))*<br/>TODO docs | -| `lineWidth` | number | No | | *(Inherited from [LineConfig](#lineconfig))* | -| `pointColor` | string | No | | *(Inherited from [PointsConfig](#pointsconfig))* | -| `pointSize` | number | No | | *(Inherited from [PointsConfig](#pointsconfig))* | -| `pointSymbol` | string | No | | *(Inherited from [PointsConfig](#pointsconfig))* | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs | -| `showPoints` | string | No | | *(Inherited from [PointsConfig](#pointsconfig))*<br/>TODO docs<br/>Possible values are: `auto`, `never`, `always`. | -| `spanNulls` | | No | | *(Inherited from [LineConfig](#lineconfig))*<br/>Indicate if null values should be treated as gaps or connected.<br/>When the value is a number, it represents the maximum delta in the<br/>X axis that should be considered connected. For timeseries, this is milliseconds | -| `stacking` | [StackingConfig](#stackingconfig) | No | | *(Inherited from [StackableFieldConfig](#stackablefieldconfig))*<br/>TODO docs | -| `thresholdsStyle` | [GraphThresholdsStyleConfig](#graphthresholdsstyleconfig) | No | | TODO docs | -| `transform` | string | No | | TODO docs<br/>Possible values are: `constant`, `negative-Y`. | - -### AxisConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | | -| `axisCenteredZero` | boolean | No | | | -| `axisColorMode` | string | No | | TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | | -| `axisLabel` | string | No | | | -| `axisPlacement` | string | No | | TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | | -| `axisSoftMin` | number | No | | | -| `axisWidth` | number | No | | | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | TODO docs | - -### ScaleDistributionConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-------------------|--------|----------|---------|--------------------------------------------------------------------------| -| `type` | string | **Yes** | | TODO docs<br/>Possible values are: `linear`, `log`, `ordinal`, `symlog`. | -| `linearThreshold` | number | No | | | -| `log` | number | No | | | - -### BarConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------------|---------|----------|---------|----------------------------------------------------| -| `barAlignment` | integer | No | | TODO docs<br/>Possible values are: `-1`, `0`, `1`. | -| `barMaxWidth` | number | No | | | -| `barWidthFactor` | number | No | | | - -### FillConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|--------|----------|---------|-------------| -| `fillBelowTo` | string | No | | | -| `fillColor` | string | No | | | -| `fillOpacity` | number | No | | | - -### GraphThresholdsStyleConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-----------------------------------------------------------------------------------------------------------| -| `mode` | string | **Yes** | | TODO docs<br/>Possible values are: `off`, `line`, `dashed`, `area`, `line+area`, `dashed+area`, `series`. | - -### HideSeriesConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|---------|----------|---------|-------------| -| `legend` | boolean | **Yes** | | | -| `tooltip` | boolean | **Yes** | | | -| `viz` | boolean | **Yes** | | | - -### HideableFieldConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|---------------------------------------|----------|---------|-------------| -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | TODO docs | - -### LineConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------------|-------------------------|----------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `lineColor` | string | No | | | -| `lineInterpolation` | string | No | | TODO docs<br/>Possible values are: `linear`, `smooth`, `stepBefore`, `stepAfter`. | -| `lineStyle` | [LineStyle](#linestyle) | No | | TODO docs | -| `lineWidth` | number | No | | | -| `spanNulls` | | No | | Indicate if null values should be treated as gaps or connected.<br/>When the value is a number, it represents the maximum delta in the<br/>X axis that should be considered connected. For timeseries, this is milliseconds | - -### LineStyle - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|----------|----------|---------|--------------------------------------------------------| -| `dash` | number[] | No | | | -| `fill` | string | No | | Possible values are: `solid`, `dash`, `dot`, `square`. | - -### PointsConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|--------|----------|---------|---------------------------------------------------------------| -| `pointColor` | string | No | | | -| `pointSize` | number | No | | | -| `pointSymbol` | string | No | | | -| `showPoints` | string | No | | TODO docs<br/>Possible values are: `auto`, `never`, `always`. | - -### StackableFieldConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|-----------------------------------|----------|---------|-------------| -| `stacking` | [StackingConfig](#stackingconfig) | No | | TODO docs | - -### StackingConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-----------------------------------------------------------------| -| `group` | string | No | | | -| `mode` | string | No | | TODO docs<br/>Possible values are: `none`, `normal`, `percent`. | - -### Options - -Identical to timeseries... except it does not have timezone settings - -| Property | Type | Required | Default | Description | -|-----------|-----------------------------------------|----------|---------|-------------------------------------------------------| -| `legend` | [VizLegendOptions](#vizlegendoptions) | **Yes** | | TODO docs | -| `tooltip` | [VizTooltipOptions](#viztooltipoptions) | **Yes** | | TODO docs | -| `xField` | string | No | | Name of the x field to use (defaults to first number) | - -### VizLegendOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|----------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `calcs` | string[] | **Yes** | | | -| `displayMode` | string | **Yes** | | TODO docs<br/>Note: "hidden" needs to remain as an option for plugins compatibility<br/>Possible values are: `list`, `table`, `hidden`. | -| `placement` | string | **Yes** | | TODO docs<br/>Possible values are: `bottom`, `right`. | -| `showLegend` | boolean | **Yes** | | | -| `asTable` | boolean | No | | | -| `isVisible` | boolean | No | | | -| `sortBy` | string | No | | | -| `sortDesc` | boolean | No | | | -| `width` | number | No | | | - -### VizTooltipOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|---------------------------------------------------------------| -| `mode` | string | **Yes** | | TODO docs<br/>Possible values are: `single`, `multi`, `none`. | -| `sort` | string | **Yes** | | TODO docs<br/>Possible values are: `asc`, `desc`, `none`. | - - diff --git a/docs/sources/developers/kinds/composable/xychart/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/xychart/panelcfg/schema-reference.md deleted file mode 100644 index 64886c535c870..0000000000000 --- a/docs/sources/developers/kinds/composable/xychart/panelcfg/schema-reference.md +++ /dev/null @@ -1,239 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: XYChartPanelCfg kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## XYChartPanelCfg - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - - - -| Property | Type | Required | Default | Description | -|-----------------------|--------------------------------|----------|---------|----------------------------------------------------------------------| -| `FieldConfig` | [object](#fieldconfig) | **Yes** | | | -| `Options` | [object](#options) | **Yes** | | | -| `ScatterSeriesConfig` | [object](#scatterseriesconfig) | **Yes** | | | -| `ScatterShow` | string | **Yes** | | Possible values are: `points`, `lines`, `points+lines`. | -| `SeriesMapping` | string | **Yes** | | Auto is "table" in the UI<br/>Possible values are: `auto`, `manual`. | -| `XYDimensionConfig` | [object](#xydimensionconfig) | **Yes** | | Configuration for the Table/Auto mode | - -### FieldConfig - -It extends [HideableFieldConfig](#hideablefieldconfig) and [AxisConfig](#axisconfig). - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisCenteredZero` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisColorMode` | string | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisLabel` | string | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisPlacement` | string | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisSoftMin` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `axisWidth` | number | No | | *(Inherited from [AxisConfig](#axisconfig))* | -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | *(Inherited from [HideableFieldConfig](#hideablefieldconfig))*<br/>TODO docs | -| `labelValue` | [TextDimensionConfig](#textdimensionconfig) | No | | | -| `label` | string | No | | TODO docs<br/>Possible values are: `auto`, `never`, `always`. | -| `lineColor` | [ColorDimensionConfig](#colordimensionconfig) | No | | | -| `lineStyle` | [LineStyle](#linestyle) | No | | TODO docs | -| `lineWidth` | integer | No | | Constraint: `>=0 & <=2147483647`. | -| `pointColor` | [ColorDimensionConfig](#colordimensionconfig) | No | | | -| `pointSize` | [ScaleDimensionConfig](#scaledimensionconfig) | No | | | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | *(Inherited from [AxisConfig](#axisconfig))*<br/>TODO docs | -| `show` | string | No | | Possible values are: `points`, `lines`, `points+lines`. | - -### AxisConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | | -| `axisCenteredZero` | boolean | No | | | -| `axisColorMode` | string | No | | TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | | -| `axisLabel` | string | No | | | -| `axisPlacement` | string | No | | TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | | -| `axisSoftMin` | number | No | | | -| `axisWidth` | number | No | | | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | TODO docs | - -### ScaleDistributionConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-------------------|--------|----------|---------|--------------------------------------------------------------------------| -| `type` | string | **Yes** | | TODO docs<br/>Possible values are: `linear`, `log`, `ordinal`, `symlog`. | -| `linearThreshold` | number | No | | | -| `log` | number | No | | | - -### ColorDimensionConfig - -It extends [BaseDimensionConfig](#basedimensionconfig). - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|--------------------------------------------------------------------------------------------------------------| -| `field` | string | No | | *(Inherited from [BaseDimensionConfig](#basedimensionconfig))*<br/>fixed: T -- will be added by each element | -| `fixed` | string | No | | | - -### BaseDimensionConfig - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-------------------------------------------| -| `field` | string | No | | fixed: T -- will be added by each element | - -### HideSeriesConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|---------|----------|---------|-------------| -| `legend` | boolean | **Yes** | | | -| `tooltip` | boolean | **Yes** | | | -| `viz` | boolean | **Yes** | | | - -### HideableFieldConfig - -TODO docs - -| Property | Type | Required | Default | Description | -|------------|---------------------------------------|----------|---------|-------------| -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | TODO docs | - -### LineStyle - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|----------|----------|---------|--------------------------------------------------------| -| `dash` | number[] | No | | | -| `fill` | string | No | | Possible values are: `solid`, `dash`, `dot`, `square`. | - -### ScaleDimensionConfig - -It extends [BaseDimensionConfig](#basedimensionconfig). - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|--------------------------------------------------------------------------------------------------------------| -| `max` | number | **Yes** | | | -| `min` | number | **Yes** | | | -| `field` | string | No | | *(Inherited from [BaseDimensionConfig](#basedimensionconfig))*<br/>fixed: T -- will be added by each element | -| `fixed` | number | No | | | -| `mode` | string | No | | Possible values are: `linear`, `quad`. | - -### TextDimensionConfig - -It extends [BaseDimensionConfig](#basedimensionconfig). - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|--------------------------------------------------------------------------------------------------------------| -| `mode` | string | **Yes** | | Possible values are: `fixed`, `field`, `template`. | -| `field` | string | No | | *(Inherited from [BaseDimensionConfig](#basedimensionconfig))*<br/>fixed: T -- will be added by each element | -| `fixed` | string | No | | | - -### Options - -It extends [OptionsWithLegend](#optionswithlegend) and [OptionsWithTooltip](#optionswithtooltip). - -| Property | Type | Required | Default | Description | -|-----------------|-----------------------------------------------|----------|---------|----------------------------------------------------------------------------| -| `dims` | [XYDimensionConfig](#xydimensionconfig) | **Yes** | | Configuration for the Table/Auto mode | -| `legend` | [VizLegendOptions](#vizlegendoptions) | **Yes** | | *(Inherited from [OptionsWithLegend](#optionswithlegend))*<br/>TODO docs | -| `series` | [ScatterSeriesConfig](#scatterseriesconfig)[] | **Yes** | | Manual Mode | -| `tooltip` | [VizTooltipOptions](#viztooltipoptions) | **Yes** | | *(Inherited from [OptionsWithTooltip](#optionswithtooltip))*<br/>TODO docs | -| `seriesMapping` | string | No | | Auto is "table" in the UI<br/>Possible values are: `auto`, `manual`. | - -### OptionsWithLegend - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|---------------------------------------|----------|---------|-------------| -| `legend` | [VizLegendOptions](#vizlegendoptions) | **Yes** | | TODO docs | - -### VizLegendOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|---------------|----------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `calcs` | string[] | **Yes** | | | -| `displayMode` | string | **Yes** | | TODO docs<br/>Note: "hidden" needs to remain as an option for plugins compatibility<br/>Possible values are: `list`, `table`, `hidden`. | -| `placement` | string | **Yes** | | TODO docs<br/>Possible values are: `bottom`, `right`. | -| `showLegend` | boolean | **Yes** | | | -| `asTable` | boolean | No | | | -| `isVisible` | boolean | No | | | -| `sortBy` | string | No | | | -| `sortDesc` | boolean | No | | | -| `width` | number | No | | | - -### OptionsWithTooltip - -TODO docs - -| Property | Type | Required | Default | Description | -|-----------|-----------------------------------------|----------|---------|-------------| -| `tooltip` | [VizTooltipOptions](#viztooltipoptions) | **Yes** | | TODO docs | - -### VizTooltipOptions - -TODO docs - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|---------------------------------------------------------------| -| `mode` | string | **Yes** | | TODO docs<br/>Possible values are: `single`, `multi`, `none`. | -| `sort` | string | **Yes** | | TODO docs<br/>Possible values are: `asc`, `desc`, `none`. | - -### ScatterSeriesConfig - -It extends [FieldConfig](#fieldconfig). - -| Property | Type | Required | Default | Description | -|---------------------|-----------------------------------------------------|----------|---------|-------------------------------------------------------------------------------------------------------------------------------------------| -| `axisBorderShow` | boolean | No | | *(Inherited from [FieldConfig](#fieldconfig))* | -| `axisCenteredZero` | boolean | No | | *(Inherited from [FieldConfig](#fieldconfig))* | -| `axisColorMode` | string | No | | *(Inherited from [FieldConfig](#fieldconfig))*<br/>TODO docs<br/>Possible values are: `text`, `series`. | -| `axisGridShow` | boolean | No | | *(Inherited from [FieldConfig](#fieldconfig))* | -| `axisLabel` | string | No | | *(Inherited from [FieldConfig](#fieldconfig))* | -| `axisPlacement` | string | No | | *(Inherited from [FieldConfig](#fieldconfig))*<br/>TODO docs<br/>Possible values are: `auto`, `top`, `right`, `bottom`, `left`, `hidden`. | -| `axisSoftMax` | number | No | | *(Inherited from [FieldConfig](#fieldconfig))* | -| `axisSoftMin` | number | No | | *(Inherited from [FieldConfig](#fieldconfig))* | -| `axisWidth` | number | No | | *(Inherited from [FieldConfig](#fieldconfig))* | -| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | *(Inherited from [FieldConfig](#fieldconfig))*<br/>TODO docs | -| `labelValue` | [TextDimensionConfig](#textdimensionconfig) | No | | *(Inherited from [FieldConfig](#fieldconfig))* | -| `label` | string | No | | *(Inherited from [FieldConfig](#fieldconfig))*<br/>TODO docs<br/>Possible values are: `auto`, `never`, `always`. | -| `lineColor` | [ColorDimensionConfig](#colordimensionconfig) | No | | *(Inherited from [FieldConfig](#fieldconfig))* | -| `lineStyle` | [LineStyle](#linestyle) | No | | *(Inherited from [FieldConfig](#fieldconfig))*<br/>TODO docs | -| `lineWidth` | integer | No | | *(Inherited from [FieldConfig](#fieldconfig))*<br/>Constraint: `>=0 & <=2147483647`. | -| `name` | string | No | | | -| `pointColor` | [ColorDimensionConfig](#colordimensionconfig) | No | | *(Inherited from [FieldConfig](#fieldconfig))* | -| `pointSize` | [ScaleDimensionConfig](#scaledimensionconfig) | No | | *(Inherited from [FieldConfig](#fieldconfig))* | -| `scaleDistribution` | [ScaleDistributionConfig](#scaledistributionconfig) | No | | *(Inherited from [FieldConfig](#fieldconfig))*<br/>TODO docs | -| `show` | string | No | | *(Inherited from [FieldConfig](#fieldconfig))*<br/>Possible values are: `points`, `lines`, `points+lines`. | -| `x` | string | No | | | -| `y` | string | No | | | - -### XYDimensionConfig - -Configuration for the Table/Auto mode - -| Property | Type | Required | Default | Description | -|-----------|----------|----------|---------|-----------------------------------| -| `frame` | integer | **Yes** | | Constraint: `>=0 & <=2147483647`. | -| `exclude` | string[] | No | | | -| `x` | string | No | | | - - diff --git a/docs/sources/developers/kinds/core/_index.md b/docs/sources/developers/kinds/core/_index.md deleted file mode 100644 index f791015d5b4ca..0000000000000 --- a/docs/sources/developers/kinds/core/_index.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -labels: - products: - - enterprise - - oss -title: Core kinds -weight: 200 ---- - -# Grafana core kinds - -Kinds that define Grafana’s core schematized object types - dashboards, datasources, users, etc. - -{{< section >}} diff --git a/docs/sources/developers/kinds/core/accesspolicy/schema-reference.md b/docs/sources/developers/kinds/core/accesspolicy/schema-reference.md deleted file mode 100644 index 0df9b57ef00b8..0000000000000 --- a/docs/sources/developers/kinds/core/accesspolicy/schema-reference.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: AccessPolicy kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## AccessPolicy - -#### Maturity: [merged](../../../maturity/#merged) -#### Version: 0.0 - -Access rules for a scope+role. NOTE there is a unique constraint on role+scope - -| Property | Type | Required | Default | Description | -|------------|---------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `metadata` | [object](#metadata) | **Yes** | | metadata contains embedded CommonMetadata and can be extended with custom string fields<br/>TODO: use CommonMetadata instead of redefining here; currently needs to be defined here<br/>without external reference as using the CommonMetadata reference breaks thema codegen. | -| `spec` | [object](#spec) | **Yes** | | | -| `status` | [object](#status) | **Yes** | | | - -### Metadata - -metadata contains embedded CommonMetadata and can be extended with custom string fields -TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -without external reference as using the CommonMetadata reference breaks thema codegen. - -It extends [_kubeObjectMetadata](#_kubeobjectmetadata). - -| Property | Type | Required | Default | Description | -|---------------------|------------------------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `createdBy` | string | **Yes** | | | -| `creationTimestamp` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `extraFields` | [object](#extrafields) | **Yes** | | extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata | -| `finalizers` | string[] | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `labels` | map[string]string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `resourceVersion` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `uid` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `updateTimestamp` | string | **Yes** | | | -| `updatedBy` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | - -### _kubeObjectMetadata - -_kubeObjectMetadata is metadata found in a kubernetes object's metadata field. -It is not exhaustive and only includes fields which may be relevant to a kind's implementation, -As it is also intended to be generic enough to function with any API Server. - -| Property | Type | Required | Default | Description | -|---------------------|-------------------|----------|---------|-------------| -| `creationTimestamp` | string | **Yes** | | | -| `finalizers` | string[] | **Yes** | | | -| `labels` | map[string]string | **Yes** | | | -| `resourceVersion` | string | **Yes** | | | -| `uid` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | | - -### ExtraFields - -extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Spec - -| Property | Type | Required | Default | Description | -|----------|-----------------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------| -| `role` | [RoleRef](#roleref) | **Yes** | | | -| `rules` | [AccessRule](#accessrule)[] | **Yes** | | The set of rules to apply. Note that * is required to modify<br/>access policy rules, and that "none" will reject all actions | -| `scope` | [ResourceRef](#resourceref) | **Yes** | | | - -### AccessRule - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `kind` | string | **Yes** | | The kind this rule applies to (dashboards, alert, etc) | -| `verb` | string | **Yes** | | READ, WRITE, CREATE, DELETE, ...<br/>should move to k8s style verbs like: "get", "list", "watch", "create", "update", "patch", "delete" | -| `target` | string | No | | Specific sub-elements like "alert.rules" or "dashboard.permissions"???? | - -### ResourceRef - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-------------| -| `kind` | string | **Yes** | | | -| `name` | string | **Yes** | | | - -### RoleRef - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `kind` | string | **Yes** | | Policies can apply to roles, teams, or users<br/>Applying policies to individual users is supported, but discouraged<br/>Possible values are: `Role`, `BuiltinRole`, `Team`, `User`. | -| `name` | string | **Yes** | | | -| `xname` | string | **Yes** | | | - -### Status - -| Property | Type | Required | Default | Description | -|--------------------|------------------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `additionalFields` | [object](#additionalfields) | No | | additionalFields is reserved for future use | -| `operatorStates` | map[string][status.#OperatorState](#status.#operatorstate) | No | | operatorStates is a map of operator ID to operator state evaluations.<br/>Any operator which consumes this kind SHOULD add its state evaluation information to this field. | - -### AdditionalFields - -additionalFields is reserved for future use - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Status.#OperatorState - -| Property | Type | Required | Default | Description | -|--------------------|--------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `lastEvaluation` | string | **Yes** | | lastEvaluation is the ResourceVersion last evaluated | -| `state` | string | **Yes** | | state describes the state of the lastEvaluation.<br/>It is limited to three possible states for machine evaluation.<br/>Possible values are: `success`, `in_progress`, `failed`. | -| `descriptiveState` | string | No | | descriptiveState is an optional more descriptive state field which has no requirements on format | -| `details` | [object](#details) | No | | details contains any extra information that is operator-specific | - -### Details - -details contains any extra information that is operator-specific - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - - diff --git a/docs/sources/developers/kinds/core/dashboard/schema-reference.md b/docs/sources/developers/kinds/core/dashboard/schema-reference.md deleted file mode 100644 index e8fb56b1c183d..0000000000000 --- a/docs/sources/developers/kinds/core/dashboard/schema-reference.md +++ /dev/null @@ -1,669 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: Dashboard kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## Dashboard - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - -A Grafana dashboard. - -| Property | Type | Required | Default | Description | -|------------|---------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `metadata` | [object](#metadata) | **Yes** | | metadata contains embedded CommonMetadata and can be extended with custom string fields<br/>TODO: use CommonMetadata instead of redefining here; currently needs to be defined here<br/>without external reference as using the CommonMetadata reference breaks thema codegen. | -| `spec` | [object](#spec) | **Yes** | | | -| `status` | [object](#status) | **Yes** | | | - -### Metadata - -metadata contains embedded CommonMetadata and can be extended with custom string fields -TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -without external reference as using the CommonMetadata reference breaks thema codegen. - -It extends [_kubeObjectMetadata](#_kubeobjectmetadata). - -| Property | Type | Required | Default | Description | -|---------------------|------------------------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `createdBy` | string | **Yes** | | | -| `creationTimestamp` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `extraFields` | [object](#extrafields) | **Yes** | | extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata | -| `finalizers` | string[] | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `labels` | map[string]string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `resourceVersion` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `uid` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `updateTimestamp` | string | **Yes** | | | -| `updatedBy` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | - -### _kubeObjectMetadata - -_kubeObjectMetadata is metadata found in a kubernetes object's metadata field. -It is not exhaustive and only includes fields which may be relevant to a kind's implementation, -As it is also intended to be generic enough to function with any API Server. - -| Property | Type | Required | Default | Description | -|---------------------|-------------------|----------|---------|-------------| -| `creationTimestamp` | string | **Yes** | | | -| `finalizers` | string[] | **Yes** | | | -| `labels` | map[string]string | **Yes** | | | -| `resourceVersion` | string | **Yes** | | | -| `uid` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | | - -### ExtraFields - -extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Spec - -| Property | Type | Required | Default | Description | -|------------------------|---------------------------------------------|----------|-----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `schemaVersion` | uint16 | **Yes** | `36` | Version of the JSON schema, incremented each time a Grafana update brings<br/>changes to said schema. | -| `annotations` | [AnnotationContainer](#annotationcontainer) | No | | Contains the list of annotations that are associated with the dashboard.<br/>Annotations are used to overlay event markers and overlay event tags on graphs.<br/>Grafana comes with a native annotation store and the ability to add annotation events directly from the graph panel or via the HTTP API.<br/>See https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/annotate-visualizations/ | -| `description` | string | No | | Description of dashboard. | -| `editable` | boolean | No | `true` | Whether a dashboard is editable or not. | -| `fiscalYearStartMonth` | integer | No | `0` | The month that the fiscal year starts on. 0 = January, 11 = December<br/>Constraint: `>=0 & <12`. | -| `gnetId` | string | No | | ID of a dashboard imported from the https://grafana.com/grafana/dashboards/ portal | -| `graphTooltip` | integer | No | `0` | 0 for no shared crosshair or tooltip (default).<br/>1 for shared crosshair.<br/>2 for shared crosshair AND shared tooltip.<br/>Possible values are: `0`, `1`, `2`. | -| `id` | integer or null | No | | Unique numeric identifier for the dashboard.<br/>`id` is internal to a specific Grafana instance. `uid` should be used to identify a dashboard across Grafana instances. | -| `links` | [DashboardLink](#dashboardlink)[] | No | | Links with references to other dashboards or external websites. | -| `liveNow` | boolean | No | | When set to true, the dashboard will redraw panels at an interval matching the pixel width.<br/>This will keep data "moving left" regardless of the query refresh rate. This setting helps<br/>avoid dashboards presenting stale live data | -| `panels` | [object](#panels)[] | No | | List of dashboard panels | -| `refresh` | | No | | Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". | -| `revision` | integer | No | | This property should only be used in dashboards defined by plugins. It is a quick check<br/>to see if the version has changed since the last time. | -| `snapshot` | [Snapshot](#snapshot) | No | | A dashboard snapshot shares an interactive dashboard publicly.<br/>It is a read-only version of a dashboard, and is not editable.<br/>It is possible to create a snapshot of a snapshot.<br/>Grafana strips away all sensitive information from the dashboard.<br/>Sensitive information stripped: queries (metric, template,annotation) and panel links. | -| `tags` | string[] | No | | Tags associated with dashboard. | -| `templating` | [object](#templating) | No | | Configured template variables | -| `time` | [object](#time) | No | | Time range for dashboard.<br/>Accepted values are relative time strings like {from: 'now-6h', to: 'now'} or absolute time strings like {from: '2020-07-10T08:00:00.000Z', to: '2020-07-10T14:00:00.000Z'}. | -| `timepicker` | [TimePickerConfig](#timepickerconfig) | No | | Time picker configuration<br/>It defines the default config for the time picker and the refresh picker for the specific dashboard. | -| `timezone` | string | No | `browser` | Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc". | -| `title` | string | No | | Title of dashboard. | -| `uid` | string | No | | Unique dashboard identifier that can be generated by anyone. string (8-40) | -| `version` | uint32 | No | | Version of the dashboard, incremented each time the dashboard is updated. | -| `weekStart` | string | No | | Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday". | - -### AnnotationContainer - -Contains the list of annotations that are associated with the dashboard. -Annotations are used to overlay event markers and overlay event tags on graphs. -Grafana comes with a native annotation store and the ability to add annotation events directly from the graph panel or via the HTTP API. -See https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/annotate-visualizations/ - -| Property | Type | Required | Default | Description | -|----------|---------------------------------------|----------|---------|---------------------| -| `list` | [AnnotationQuery](#annotationquery)[] | No | | List of annotations | - -### AnnotationQuery - -TODO docs -FROM: AnnotationQuery in grafana-data/src/types/annotations.ts - -| Property | Type | Required | Default | Description | -|--------------|-------------------------------------------------|----------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `datasource` | [DataSourceRef](#datasourceref) | **Yes** | | Ref to a DataSource instance | -| `enable` | boolean | **Yes** | `true` | When enabled the annotation query is issued with every dashboard refresh | -| `iconColor` | string | **Yes** | | Color to use for the annotation event markers | -| `name` | string | **Yes** | | Name of annotation. | -| `builtIn` | number | No | `0` | Set to 1 for the standard annotation query all dashboards have by default. | -| `filter` | [AnnotationPanelFilter](#annotationpanelfilter) | No | | | -| `hide` | boolean | No | `false` | Annotation queries can be toggled on or off at the top of the dashboard.<br/>When hide is true, the toggle is not shown in the dashboard. | -| `target` | [AnnotationTarget](#annotationtarget) | No | | TODO: this should be a regular DataQuery that depends on the selected dashboard<br/>these match the properties of the "grafana" datasouce that is default in most dashboards | -| `type` | string | No | | TODO -- this should not exist here, it is based on the --grafana-- datasource | - -### AnnotationPanelFilter - -| Property | Type | Required | Default | Description | -|-----------|-----------|----------|---------|-----------------------------------------------------| -| `ids` | integer[] | **Yes** | | Panel IDs that should be included or excluded | -| `exclude` | boolean | No | `false` | Should the specified panels be included or excluded | - -### AnnotationTarget - -TODO: this should be a regular DataQuery that depends on the selected dashboard -these match the properties of the "grafana" datasouce that is default in most dashboards - -| Property | Type | Required | Default | Description | -|------------|----------|----------|---------|-------------------------------------------------------------------------------------------------------------------| -| `limit` | integer | **Yes** | | Only required/valid for the grafana datasource...<br/>but code+tests is already depending on it so hard to change | -| `matchAny` | boolean | **Yes** | | Only required/valid for the grafana datasource...<br/>but code+tests is already depending on it so hard to change | -| `tags` | string[] | **Yes** | | Only required/valid for the grafana datasource...<br/>but code+tests is already depending on it so hard to change | -| `type` | string | **Yes** | | Only required/valid for the grafana datasource...<br/>but code+tests is already depending on it so hard to change | - -### DataSourceRef - -Ref to a DataSource instance - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|------------------------------| -| `type` | string | No | | The plugin type-id | -| `uid` | string | No | | Specific datasource instance | - -### DashboardLink - -Links with references to other dashboards or external resources - -| Property | Type | Required | Default | Description | -|---------------|----------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `asDropdown` | boolean | **Yes** | `false` | If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards | -| `icon` | string | **Yes** | | Icon name to be displayed with the link | -| `includeVars` | boolean | **Yes** | `false` | If true, includes current template variables values in the link as query params | -| `keepTime` | boolean | **Yes** | `false` | If true, includes current time range in the link as query params | -| `tags` | string[] | **Yes** | | List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards | -| `targetBlank` | boolean | **Yes** | `false` | If true, the link will be opened in a new tab | -| `title` | string | **Yes** | | Title to display with the link | -| `tooltip` | string | **Yes** | | Tooltip to display when the user hovers their mouse over it | -| `type` | string | **Yes** | | Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)<br/>Possible values are: `link`, `dashboards`. | -| `url` | string | No | | Link URL. Only required/valid if the type is link | - -### Snapshot - -A dashboard snapshot shares an interactive dashboard publicly. -It is a read-only version of a dashboard, and is not editable. -It is possible to create a snapshot of a snapshot. -Grafana strips away all sensitive information from the dashboard. -Sensitive information stripped: queries (metric, template,annotation) and panel links. - -| Property | Type | Required | Default | Description | -|---------------|---------|----------|---------|--------------------------------------------------------------------------------| -| `created` | string | **Yes** | | Time when the snapshot was created | -| `expires` | string | **Yes** | | Time when the snapshot expires, default is never to expire | -| `externalUrl` | string | **Yes** | | external url, if snapshot was shared in external grafana instance | -| `external` | boolean | **Yes** | | Is the snapshot saved in an external grafana instance | -| `id` | uint32 | **Yes** | | Unique identifier of the snapshot | -| `key` | string | **Yes** | | Optional, defined the unique key of the snapshot, required if external is true | -| `name` | string | **Yes** | | Optional, name of the snapshot | -| `orgId` | uint32 | **Yes** | | org id of the snapshot | -| `updated` | string | **Yes** | | last time when the snapshot was updated | -| `userId` | uint32 | **Yes** | | user id of the snapshot creator | -| `url` | string | No | | url of the snapshot, if snapshot was shared internally | - -### TimePickerConfig - -Time picker configuration -It defines the default config for the time picker and the refresh picker for the specific dashboard. - -| Property | Type | Required | Default | Description | -|---------------------|----------|----------|---------------------------------------|---------------------------------------------------------------------------------------------------| -| `hidden` | boolean | **Yes** | `false` | Whether timepicker is visible or not. | -| `refresh_intervals` | string[] | **Yes** | `[5s 10s 30s 1m 5m 15m 30m 1h 2h 1d]` | Interval options available in the refresh picker dropdown. | -| `time_options` | string[] | **Yes** | `[5m 15m 1h 6h 12h 24h 2d 7d 30d]` | Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. | - -### Panels - -| Property | Type | Required | Default | Description | -|----------|---------------------------------------------------|----------|---------|-------------| -| `object` | Possible types are: [](#), [RowPanel](#rowpanel). | | | - -### DataTransformerConfig - -Transformations allow to manipulate data returned by a query before the system applies a visualization. -Using transformations you can: rename fields, join time series data, perform mathematical operations across queries, -use the output of one transformation as the input to another transformation, etc. - -| Property | Type | Required | Default | Description | -|------------|---------------------------------|----------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `id` | string | **Yes** | | Unique identifier of transformer | -| `options` | | **Yes** | | Options to be passed to the transformer<br/>Valid options depend on the transformer id | -| `disabled` | boolean | No | | Disabled transformations are skipped | -| `filter` | [MatcherConfig](#matcherconfig) | No | | Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.<br/>It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. | - -### MatcherConfig - -Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. -It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. - -| Property | Type | Required | Default | Description | -|-----------|--------|----------|---------|--------------------------------------------------------------------------------| -| `id` | string | **Yes** | `` | The matcher id. This is used to find the matcher implementation from registry. | -| `options` | | No | | The matcher options. This is specific to the matcher implementation. | - -### FieldConfigSource - -The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. -Each column within this structure is called a field. A field can represent a single time series or table column. -Field options allow you to change how the data is displayed in your visualizations. - -| Property | Type | Required | Default | Description | -|-------------|-----------------------------|----------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `defaults` | [FieldConfig](#fieldconfig) | **Yes** | | The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.<br/>Each column within this structure is called a field. A field can represent a single time series or table column.<br/>Field options allow you to change how the data is displayed in your visualizations. | -| `overrides` | [object](#overrides)[] | **Yes** | | Overrides are the options applied to specific fields overriding the defaults. | - -### FieldConfig - -The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. -Each column within this structure is called a field. A field can represent a single time series or table column. -Field options allow you to change how the data is displayed in your visualizations. - -| Property | Type | Required | Default | Description | -|---------------------|---------------------------------------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `color` | [FieldColor](#fieldcolor) | No | | Map a field to a color. | -| `custom` | [object](#custom) | No | | custom is specified by the FieldConfig field<br/>in panel plugin schemas. | -| `decimals` | number | No | | Specify the number of decimals Grafana includes in the rendered value.<br/>If you leave this field blank, Grafana automatically truncates the number of decimals based on the value.<br/>For example 1.1234 will display as 1.12 and 100.456 will display as 100.<br/>To display all decimals, set the unit to `String`. | -| `description` | string | No | | Human readable field metadata | -| `displayNameFromDS` | string | No | | This can be used by data sources that return and explicit naming structure for values and labels<br/>When this property is configured, this value is used rather than the default naming strategy. | -| `displayName` | string | No | | The display value for this field. This supports template variables blank is auto | -| `filterable` | boolean | No | | True if data source field supports ad-hoc filters | -| `links` | | No | | The behavior when clicking on a result | -| `mappings` | [ValueMapping](#valuemapping)[] | No | | Convert input values into a display string | -| `max` | number | No | | The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. | -| `min` | number | No | | The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. | -| `noValue` | string | No | | Alternative to empty string | -| `path` | string | No | | An explicit path to the field in the datasource. When the frame meta includes a path,<br/>This will default to `${frame.meta.path}/${field.name}<br/><br/>When defined, this value can be used as an identifier within the datasource scope, and<br/>may be used to update the results | -| `thresholds` | [ThresholdsConfig](#thresholdsconfig) | No | | Thresholds configuration for the panel | -| `unit` | string | No | | Unit a field should use. The unit you select is applied to all fields except time.<br/>You can use the units ID availables in Grafana or a custom unit.<br/>Available units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts<br/>As custom unit, you can use the following formats:<br/>`suffix:<suffix>` for custom unit that should go after value.<br/>`prefix:<prefix>` for custom unit that should go before value.<br/>`time:<format>` For custom date time formats type for example `time:YYYY-MM-DD`.<br/>`si:<base scale><unit characters>` for custom SI units. For example: `si: mF`. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.<br/>`count:<unit>` for a custom count unit.<br/>`currency:<unit>` for custom a currency unit. | -| `writeable` | boolean | No | | True if data source can write a value to the path. Auth/authz are supported separately | - -### FieldColor - -Map a field to a color. - -| Property | Type | Required | Default | Description | -|--------------|--------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `mode` | string | **Yes** | | Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value.<br/>Continuous color interpolates a color using the percentage of a value relative to min and max.<br/>Accepted values are:<br/>`thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold<br/>`palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations<br/>`palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations<br/>`continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode<br/>`continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode<br/>`continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode<br/>`continuous-YlRd`: Continuous Yellow-Red palette mode<br/>`continuous-BlPu`: Continuous Blue-Purple palette mode<br/>`continuous-YlBl`: Continuous Yellow-Blue palette mode<br/>`continuous-blues`: Continuous Blue palette mode<br/>`continuous-reds`: Continuous Red palette mode<br/>`continuous-greens`: Continuous Green palette mode<br/>`continuous-purples`: Continuous Purple palette mode<br/>`shades`: Shades of a single color. Specify a single color, useful in an override rule.<br/>`fixed`: Fixed color mode. Specify a single color, useful in an override rule.<br/>Possible values are: `thresholds`, `palette-classic`, `palette-classic-by-name`, `continuous-GrYlRd`, `continuous-RdYlGr`, `continuous-BlYlRd`, `continuous-YlRd`, `continuous-BlPu`, `continuous-YlBl`, `continuous-blues`, `continuous-reds`, `continuous-greens`, `continuous-purples`, `fixed`, `shades`. | -| `fixedColor` | string | No | | The fixed color value for fixed or shades color modes. | -| `seriesBy` | string | No | | Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.<br/>Possible values are: `min`, `max`, `last`. | - -### ThresholdsConfig - -Thresholds configuration for the panel - -| Property | Type | Required | Default | Description | -|----------|---------------------------|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `mode` | string | **Yes** | | Thresholds can either be `absolute` (specific number) or `percentage` (relative to min or max, it will be values between 0 and 1).<br/>Possible values are: `absolute`, `percentage`. | -| `steps` | [Threshold](#threshold)[] | **Yes** | | Must be sorted by 'value', first value is always -Infinity | - -### Threshold - -User-defined value for a metric that triggers visual changes in a panel when this value is met or exceeded -They are used to conditionally style and color visualizations based on query results , and can be applied to most visualizations. - -| Property | Type | Required | Default | Description | -|----------|----------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `color` | string | **Yes** | | Color represents the color of the visual change that will occur in the dashboard when the threshold value is met or exceeded. | -| `value` | number or null | **Yes** | | Value represents a specified metric for the threshold, which triggers a visual change in the dashboard when this value is met or exceeded.<br/>Nulls currently appear here when serializing -Infinity to JSON. | - -### ValueMapping - -Allow to transform the visual representation of specific data values in a visualization, irrespective of their original units - -| Property | Type | Required | Default | Description | -|----------|-------------------------------------------------------------------------------------------------------------------------------|----------|---------|-------------| -| `object` | Possible types are: [ValueMap](#valuemap), [RangeMap](#rangemap), [RegexMap](#regexmap), [SpecialValueMap](#specialvaluemap). | | | - -### RangeMap - -Maps numerical ranges to a display text and color. -For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. - -| Property | Type | Required | Default | Description | -|-----------|--------------------|----------|---------|-----------------------------------------------------------------------------------| -| `options` | [object](#options) | **Yes** | | Range to match against and the result to apply when the value is within the range | -| `type` | string | **Yes** | | | - -### Options - -Range to match against and the result to apply when the value is within the range - -| Property | Type | Required | Default | Description | -|----------|-------------------------------------------|----------|---------|-----------------------------------------------------------------------| -| `from` | number or null | **Yes** | | Min value of the range. It can be null which means -Infinity | -| `result` | [ValueMappingResult](#valuemappingresult) | **Yes** | | Result used as replacement with text and color when the value matches | -| `to` | number or null | **Yes** | | Max value of the range. It can be null which means +Infinity | - -### ValueMappingResult - -Result used as replacement with text and color when the value matches - -| Property | Type | Required | Default | Description | -|----------|---------|----------|---------|-----------------------------------------------------------------------| -| `color` | string | No | | Text to use when the value matches | -| `icon` | string | No | | Icon to display when the value matches. Only specific visualizations. | -| `index` | integer | No | | Position in the mapping array. Only used internally. | -| `text` | string | No | | Text to display when the value matches | - -### RegexMap - -Maps regular expressions to replacement text and a color. -For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. - -| Property | Type | Required | Default | Description | -|-----------|--------------------|----------|---------|----------------------------------------------------------------------------------------------| -| `options` | [object](#options) | **Yes** | | Regular expression to match against and the result to apply when the value matches the regex | -| `type` | string | **Yes** | | | - -### Options - -Regular expression to match against and the result to apply when the value matches the regex - -| Property | Type | Required | Default | Description | -|-----------|-------------------------------------------|----------|---------|-----------------------------------------------------------------------| -| `pattern` | string | **Yes** | | Regular expression to match against | -| `result` | [ValueMappingResult](#valuemappingresult) | **Yes** | | Result used as replacement with text and color when the value matches | - -### SpecialValueMap - -Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. -See SpecialValueMatch to see the list of special values. -For example, you can configure a special value mapping so that null values appear as N/A. - -| Property | Type | Required | Default | Description | -|-----------|--------------------|----------|---------|-------------| -| `options` | [object](#options) | **Yes** | | | -| `type` | string | **Yes** | | | - -### Options - -| Property | Type | Required | Default | Description | -|----------|-------------------------------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------| -| `match` | string | **Yes** | | Special value types supported by the `SpecialValueMap`<br/>Possible values are: `true`, `false`, `null`, `nan`, `null+nan`, `empty`. | -| `result` | [ValueMappingResult](#valuemappingresult) | **Yes** | | Result used as replacement with text and color when the value matches | - -### ValueMap - -Maps text values to a color or different display text and color. -For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. - -| Property | Type | Required | Default | Description | -|-----------|------------------------------------------------------|----------|---------|---------------------------------------------------------------------------------------------------------------| -| `options` | map[string][ValueMappingResult](#valuemappingresult) | **Yes** | | Map with <value_to_match>: ValueMappingResult. For example: { "10": { text: "Perfection!", color: "green" } } | -| `type` | string | **Yes** | | | - -### Custom - -custom is specified by the FieldConfig field -in panel plugin schemas. - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Overrides - -| Property | Type | Required | Default | Description | -|--------------|---------------------------------------------|----------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `matcher` | [MatcherConfig](#matcherconfig) | **Yes** | | Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.<br/>It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. | -| `properties` | [DynamicConfigValue](#dynamicconfigvalue)[] | **Yes** | | | - -### DynamicConfigValue - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|-------------| -| `id` | string | **Yes** | `` | | -| `value` | | No | | | - -### GridPos - -Position and dimensions of a panel in the grid - -| Property | Type | Required | Default | Description | -|----------|---------|----------|---------|-------------------------------------------------------------------------------------------------------------------| -| `h` | uint32 | **Yes** | `9` | Panel height. The height is the number of rows from the top edge of the panel. | -| `w` | integer | **Yes** | `12` | Panel width. The width is the number of columns from the left edge of the panel.<br/>Constraint: `>0 & <=24`. | -| `x` | integer | **Yes** | `0` | Panel x. The x coordinate is the number of columns from the left edge of the grid<br/>Constraint: `>=0 & <24`. | -| `y` | uint32 | **Yes** | `0` | Panel y. The y coordinate is the number of rows from the top edge of the grid | -| `static` | boolean | No | | Whether the panel is fixed within the grid. If true, the panel will not be affected by other panels' interactions | - -### LibraryPanelRef - -A library panel is a reusable panel that you can use in any dashboard. -When you make a change to a library panel, that change propagates to all instances of where the panel is used. -Library panels streamline reuse of panels across multiple dashboards. - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|--------------------| -| `name` | string | **Yes** | | Library panel name | -| `uid` | string | **Yes** | | Library panel uid | - -### Panel - -Dashboard panels are the basic visualization building blocks. - -| Property | Type | Required | Default | Description | -|--------------------|---------------------------------------------------|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | string | **Yes** | | The panel plugin type id. This is used to find the plugin to display the panel.<br/>Constraint: `length >=1`. | -| `datasource` | [DataSourceRef](#datasourceref) | No | | Ref to a DataSource instance | -| `description` | string | No | | Panel description. | -| `fieldConfig` | [FieldConfigSource](#fieldconfigsource) | No | | The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.<br/>Each column within this structure is called a field. A field can represent a single time series or table column.<br/>Field options allow you to change how the data is displayed in your visualizations. | -| `gridPos` | [GridPos](#gridpos) | No | | Position and dimensions of a panel in the grid | -| `hideTimeOverride` | boolean | No | | Controls if the timeFrom or timeShift overrides are shown in the panel header | -| `id` | uint32 | No | | Unique identifier of the panel. Generated by Grafana when creating a new panel. It must be unique within a dashboard, but not globally. | -| `interval` | string | No | | The min time interval setting defines a lower limit for the $__interval and $__interval_ms variables.<br/>This value must be formatted as a number followed by a valid time<br/>identifier like: "40s", "3d", etc.<br/>See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options | -| `libraryPanel` | [LibraryPanelRef](#librarypanelref) | No | | A library panel is a reusable panel that you can use in any dashboard.<br/>When you make a change to a library panel, that change propagates to all instances of where the panel is used.<br/>Library panels streamline reuse of panels across multiple dashboards. | -| `links` | [DashboardLink](#dashboardlink)[] | No | | Panel links. | -| `maxDataPoints` | number | No | | The maximum number of data points that the panel queries are retrieving. | -| `maxPerRow` | number | No | | Option for repeated panels that controls max items per row<br/>Only relevant for horizontally repeated panels | -| `options` | [object](#options) | No | | It depends on the panel plugin. They are specified by the Options field in panel plugin schemas. | -| `pluginVersion` | string | No | | The version of the plugin that is used for this panel. This is used to find the plugin to display the panel and to migrate old panel configs. | -| `repeatDirection` | string | No | `h` | Direction to repeat in if 'repeat' is set.<br/>`h` for horizontal, `v` for vertical.<br/>Possible values are: `h`, `v`. | -| `repeat` | string | No | | Name of template variable to repeat for. | -| `tags` | string[] | No | | Tags for the panel. | -| `targets` | [Target](#target)[] | No | | Depends on the panel plugin. See the plugin documentation for details. | -| `timeFrom` | string | No | | Overrides the relative time range for individual panels,<br/>which causes them to be different than what is selected in<br/>the dashboard time picker in the top-right corner of the dashboard. You can use this to show metrics from different<br/>time periods or days on the same dashboard.<br/>The value is formatted as time operation like: `now-5m` (Last 5 minutes), `now/d` (the day so far),<br/>`now-5d/d`(Last 5 days), `now/w` (This week so far), `now-2y/y` (Last 2 years).<br/>Note: Panel time overrides have no effect when the dashboard’s time range is absolute.<br/>See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options | -| `timeShift` | string | No | | Overrides the time range for individual panels by shifting its start and end relative to the time picker.<br/>For example, you can shift the time range for the panel to be two hours earlier than the dashboard time picker setting `2h`.<br/>Note: Panel time overrides have no effect when the dashboard’s time range is absolute.<br/>See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options | -| `title` | string | No | | Panel title. | -| `transformations` | [DataTransformerConfig](#datatransformerconfig)[] | No | | List of transformations that are applied to the panel data before rendering.<br/>When there are multiple transformations, Grafana applies them in the order they are listed.<br/>Each transformation creates a result set that then passes on to the next transformation in the processing pipeline. | -| `transparent` | boolean | No | `false` | Whether to display the panel without a background. | - -### Target - -Schema for panel targets is specified by datasource -plugins. We use a placeholder definition, which the Go -schema loader either left open/as-is with the Base -variant of the Dashboard and Panel families, or filled -with types derived from plugins in the Instance variant. -When working directly from CUE, importers can extend this -type directly to achieve the same effect. - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Options - -It depends on the panel plugin. They are specified by the Options field in panel plugin schemas. - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### RowPanel - -Row panel - -| Property | Type | Required | Default | Description | -|--------------|---------------------------------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `collapsed` | boolean | **Yes** | `false` | Whether this row should be collapsed or not. | -| `id` | uint32 | **Yes** | | Unique identifier of the panel. Generated by Grafana when creating a new panel. It must be unique within a dashboard, but not globally. | -| `panels` | [Panel](#panel)[] | **Yes** | | List of panels in the row | -| `type` | string | **Yes** | | The panel type<br/>Possible values are: `row`. | -| `datasource` | [DataSourceRef](#datasourceref) | No | | Ref to a DataSource instance | -| `gridPos` | [GridPos](#gridpos) | No | | Position and dimensions of a panel in the grid | -| `repeat` | string | No | | Name of template variable to repeat for. | -| `title` | string | No | | Row title | - -### Panel - -Dashboard panels are the basic visualization building blocks. - -| Property | Type | Required | Default | Description | -|--------------------|---------------------------------------------------|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | string | **Yes** | | The panel plugin type id. This is used to find the plugin to display the panel.<br/>Constraint: `length >=1`. | -| `datasource` | [DataSourceRef](#datasourceref) | No | | Ref to a DataSource instance | -| `description` | string | No | | Panel description. | -| `fieldConfig` | [FieldConfigSource](#fieldconfigsource) | No | | The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.<br/>Each column within this structure is called a field. A field can represent a single time series or table column.<br/>Field options allow you to change how the data is displayed in your visualizations. | -| `gridPos` | [GridPos](#gridpos) | No | | Position and dimensions of a panel in the grid | -| `hideTimeOverride` | boolean | No | | Controls if the timeFrom or timeShift overrides are shown in the panel header | -| `id` | uint32 | No | | Unique identifier of the panel. Generated by Grafana when creating a new panel. It must be unique within a dashboard, but not globally. | -| `interval` | string | No | | The min time interval setting defines a lower limit for the $__interval and $__interval_ms variables.<br/>This value must be formatted as a number followed by a valid time<br/>identifier like: "40s", "3d", etc.<br/>See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options | -| `libraryPanel` | [LibraryPanelRef](#librarypanelref) | No | | A library panel is a reusable panel that you can use in any dashboard.<br/>When you make a change to a library panel, that change propagates to all instances of where the panel is used.<br/>Library panels streamline reuse of panels across multiple dashboards. | -| `links` | [DashboardLink](#dashboardlink)[] | No | | Panel links. | -| `maxDataPoints` | number | No | | The maximum number of data points that the panel queries are retrieving. | -| `maxPerRow` | number | No | | Option for repeated panels that controls max items per row<br/>Only relevant for horizontally repeated panels | -| `options` | [options](#options) | No | | It depends on the panel plugin. They are specified by the Options field in panel plugin schemas. | -| `pluginVersion` | string | No | | The version of the plugin that is used for this panel. This is used to find the plugin to display the panel and to migrate old panel configs. | -| `repeatDirection` | string | No | `h` | Direction to repeat in if 'repeat' is set.<br/>`h` for horizontal, `v` for vertical.<br/>Possible values are: `h`, `v`. | -| `repeat` | string | No | | Name of template variable to repeat for. | -| `tags` | string[] | No | | Tags for the panel. | -| `targets` | [Target](#target)[] | No | | Depends on the panel plugin. See the plugin documentation for details. | -| `timeFrom` | string | No | | Overrides the relative time range for individual panels,<br/>which causes them to be different than what is selected in<br/>the dashboard time picker in the top-right corner of the dashboard. You can use this to show metrics from different<br/>time periods or days on the same dashboard.<br/>The value is formatted as time operation like: `now-5m` (Last 5 minutes), `now/d` (the day so far),<br/>`now-5d/d`(Last 5 days), `now/w` (This week so far), `now-2y/y` (Last 2 years).<br/>Note: Panel time overrides have no effect when the dashboard’s time range is absolute.<br/>See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options | -| `timeShift` | string | No | | Overrides the time range for individual panels by shifting its start and end relative to the time picker.<br/>For example, you can shift the time range for the panel to be two hours earlier than the dashboard time picker setting `2h`.<br/>Note: Panel time overrides have no effect when the dashboard’s time range is absolute.<br/>See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options | -| `title` | string | No | | Panel title. | -| `transformations` | [DataTransformerConfig](#datatransformerconfig)[] | No | | List of transformations that are applied to the panel data before rendering.<br/>When there are multiple transformations, Grafana applies them in the order they are listed.<br/>Each transformation creates a result set that then passes on to the next transformation in the processing pipeline. | -| `transparent` | boolean | No | `false` | Whether to display the panel without a background. | - -### FieldConfigSource - -The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. -Each column within this structure is called a field. A field can represent a single time series or table column. -Field options allow you to change how the data is displayed in your visualizations. - -| Property | Type | Required | Default | Description | -|-------------|-----------------------------|----------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `defaults` | [FieldConfig](#fieldconfig) | **Yes** | | The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.<br/>Each column within this structure is called a field. A field can represent a single time series or table column.<br/>Field options allow you to change how the data is displayed in your visualizations. | -| `overrides` | [overrides](#overrides)[] | **Yes** | | Overrides are the options applied to specific fields overriding the defaults. | - -### FieldConfig - -The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. -Each column within this structure is called a field. A field can represent a single time series or table column. -Field options allow you to change how the data is displayed in your visualizations. - -| Property | Type | Required | Default | Description | -|---------------------|---------------------------------------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `color` | [FieldColor](#fieldcolor) | No | | Map a field to a color. | -| `custom` | [custom](#custom) | No | | custom is specified by the FieldConfig field<br/>in panel plugin schemas. | -| `decimals` | number | No | | Specify the number of decimals Grafana includes in the rendered value.<br/>If you leave this field blank, Grafana automatically truncates the number of decimals based on the value.<br/>For example 1.1234 will display as 1.12 and 100.456 will display as 100.<br/>To display all decimals, set the unit to `String`. | -| `description` | string | No | | Human readable field metadata | -| `displayNameFromDS` | string | No | | This can be used by data sources that return and explicit naming structure for values and labels<br/>When this property is configured, this value is used rather than the default naming strategy. | -| `displayName` | string | No | | The display value for this field. This supports template variables blank is auto | -| `filterable` | boolean | No | | True if data source field supports ad-hoc filters | -| `links` | | No | | The behavior when clicking on a result | -| `mappings` | [ValueMapping](#valuemapping)[] | No | | Convert input values into a display string | -| `max` | number | No | | The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. | -| `min` | number | No | | The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. | -| `noValue` | string | No | | Alternative to empty string | -| `path` | string | No | | An explicit path to the field in the datasource. When the frame meta includes a path,<br/>This will default to `${frame.meta.path}/${field.name}<br/><br/>When defined, this value can be used as an identifier within the datasource scope, and<br/>may be used to update the results | -| `thresholds` | [ThresholdsConfig](#thresholdsconfig) | No | | Thresholds configuration for the panel | -| `unit` | string | No | | Unit a field should use. The unit you select is applied to all fields except time.<br/>You can use the units ID availables in Grafana or a custom unit.<br/>Available units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts<br/>As custom unit, you can use the following formats:<br/>`suffix:<suffix>` for custom unit that should go after value.<br/>`prefix:<prefix>` for custom unit that should go before value.<br/>`time:<format>` For custom date time formats type for example `time:YYYY-MM-DD`.<br/>`si:<base scale><unit characters>` for custom SI units. For example: `si: mF`. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.<br/>`count:<unit>` for a custom count unit.<br/>`currency:<unit>` for custom a currency unit. | -| `writeable` | boolean | No | | True if data source can write a value to the path. Auth/authz are supported separately | - -### RangeMap - -Maps numerical ranges to a display text and color. -For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. - -| Property | Type | Required | Default | Description | -|-----------|---------------------|----------|---------|-----------------------------------------------------------------------------------| -| `options` | [options](#options) | **Yes** | | Range to match against and the result to apply when the value is within the range | -| `type` | string | **Yes** | | | - -### RegexMap - -Maps regular expressions to replacement text and a color. -For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. - -| Property | Type | Required | Default | Description | -|-----------|---------------------|----------|---------|----------------------------------------------------------------------------------------------| -| `options` | [options](#options) | **Yes** | | Regular expression to match against and the result to apply when the value matches the regex | -| `type` | string | **Yes** | | | - -### SpecialValueMap - -Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. -See SpecialValueMatch to see the list of special values. -For example, you can configure a special value mapping so that null values appear as N/A. - -| Property | Type | Required | Default | Description | -|-----------|---------------------|----------|---------|-------------| -| `options` | [options](#options) | **Yes** | | | -| `type` | string | **Yes** | | | - -### Templating - -Configured template variables - -| Property | Type | Required | Default | Description | -|----------|-----------------------------------|----------|---------|----------------------------------------------------------------------------------------------| -| `list` | [VariableModel](#variablemodel)[] | No | | List of configured template variables with their saved values along with some other metadata | - -### VariableModel - -A variable is a placeholder for a value. You can use variables in metric queries and in panel titles. - -| Property | Type | Required | Default | Description | -|---------------|-------------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `name` | string | **Yes** | | Name of variable | -| `type` | string | **Yes** | | Dashboard variable type<br/>`query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on.<br/>`adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only).<br/>`constant`: Define a hidden constant.<br/>`datasource`: Quickly change the data source for an entire dashboard.<br/>`interval`: Interval variables represent time spans.<br/>`textbox`: Display a free text input field with an optional default value.<br/>`custom`: Define the variable options manually using a comma-separated list.<br/>`system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables<br/>Possible values are: `query`, `adhoc`, `constant`, `datasource`, `interval`, `textbox`, `custom`, `system`. | -| `current` | [VariableOption](#variableoption) | No | | Option to be selected in a variable. | -| `datasource` | [DataSourceRef](#datasourceref) | No | | Ref to a DataSource instance | -| `description` | string | No | | Description of variable. It can be defined but `null`. | -| `hide` | integer | No | | Determine if the variable shows on dashboard<br/>Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing).<br/>Possible values are: `0`, `1`, `2`. | -| `label` | string | No | | Optional display name | -| `multi` | boolean | No | `false` | Whether multiple values can be selected or not from variable value list | -| `options` | [VariableOption](#variableoption)[] | No | | Options that can be selected for a variable. | -| `query` | | No | | Query used to fetch values for a variable | -| `refresh` | integer | No | | Options to config when to refresh a variable<br/>`0`: Never refresh the variable<br/>`1`: Queries the data source every time the dashboard loads.<br/>`2`: Queries the data source when the dashboard time range changes.<br/>Possible values are: `0`, `1`, `2`. | -| `skipUrlSync` | boolean | No | `false` | Whether the variable value should be managed by URL query params or not | -| `sort` | integer | No | | Sort variable options<br/>Accepted values are:<br/>`0`: No sorting<br/>`1`: Alphabetical ASC<br/>`2`: Alphabetical DESC<br/>`3`: Numerical ASC<br/>`4`: Numerical DESC<br/>`5`: Alphabetical Case Insensitive ASC<br/>`6`: Alphabetical Case Insensitive DESC<br/>`7`: Natural ASC<br/>`8`: Natural DESC<br/>Possible values are: `0`, `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`. | - -### VariableOption - -Option to be selected in a variable. - -| Property | Type | Required | Default | Description | -|------------|---------|----------|---------|---------------------------------------| -| `text` | | **Yes** | | Text to be displayed for the option | -| `value` | | **Yes** | | Value of the option | -| `selected` | boolean | No | | Whether the option is selected or not | - -### Time - -Time range for dashboard. -Accepted values are relative time strings like {from: 'now-6h', to: 'now'} or absolute time strings like {from: '2020-07-10T08:00:00.000Z', to: '2020-07-10T14:00:00.000Z'}. - -| Property | Type | Required | Default | Description | -|----------|--------|----------|----------|-------------| -| `from` | string | **Yes** | `now-6h` | | -| `to` | string | **Yes** | `now` | | - -### Status - -| Property | Type | Required | Default | Description | -|--------------------|------------------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `additionalFields` | [object](#additionalfields) | No | | additionalFields is reserved for future use | -| `operatorStates` | map[string][status.#OperatorState](#status.#operatorstate) | No | | operatorStates is a map of operator ID to operator state evaluations.<br/>Any operator which consumes this kind SHOULD add its state evaluation information to this field. | - -### AdditionalFields - -additionalFields is reserved for future use - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Status.#OperatorState - -| Property | Type | Required | Default | Description | -|--------------------|--------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `lastEvaluation` | string | **Yes** | | lastEvaluation is the ResourceVersion last evaluated | -| `state` | string | **Yes** | | state describes the state of the lastEvaluation.<br/>It is limited to three possible states for machine evaluation.<br/>Possible values are: `success`, `in_progress`, `failed`. | -| `descriptiveState` | string | No | | descriptiveState is an optional more descriptive state field which has no requirements on format | -| `details` | [object](#details) | No | | details contains any extra information that is operator-specific | - -### Details - -details contains any extra information that is operator-specific - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - - diff --git a/docs/sources/developers/kinds/core/folder/schema-reference.md b/docs/sources/developers/kinds/core/folder/schema-reference.md deleted file mode 100644 index cbc8f41c417cc..0000000000000 --- a/docs/sources/developers/kinds/core/folder/schema-reference.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: Folder kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## Folder - -#### Maturity: [merged](../../../maturity/#merged) -#### Version: 0.0 - -A folder is a collection of resources that are grouped together and can share permissions. - -| Property | Type | Required | Default | Description | -|------------|---------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `metadata` | [object](#metadata) | **Yes** | | metadata contains embedded CommonMetadata and can be extended with custom string fields<br/>TODO: use CommonMetadata instead of redefining here; currently needs to be defined here<br/>without external reference as using the CommonMetadata reference breaks thema codegen. | -| `spec` | [object](#spec) | **Yes** | | TODO:<br/>common metadata will soon support setting the parent folder in the metadata | -| `status` | [object](#status) | **Yes** | | | - -### Metadata - -metadata contains embedded CommonMetadata and can be extended with custom string fields -TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -without external reference as using the CommonMetadata reference breaks thema codegen. - -It extends [_kubeObjectMetadata](#_kubeobjectmetadata). - -| Property | Type | Required | Default | Description | -|---------------------|------------------------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `createdBy` | string | **Yes** | | | -| `creationTimestamp` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `extraFields` | [object](#extrafields) | **Yes** | | extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata | -| `finalizers` | string[] | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `labels` | map[string]string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `resourceVersion` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `uid` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `updateTimestamp` | string | **Yes** | | | -| `updatedBy` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | - -### _kubeObjectMetadata - -_kubeObjectMetadata is metadata found in a kubernetes object's metadata field. -It is not exhaustive and only includes fields which may be relevant to a kind's implementation, -As it is also intended to be generic enough to function with any API Server. - -| Property | Type | Required | Default | Description | -|---------------------|-------------------|----------|---------|-------------| -| `creationTimestamp` | string | **Yes** | | | -| `finalizers` | string[] | **Yes** | | | -| `labels` | map[string]string | **Yes** | | | -| `resourceVersion` | string | **Yes** | | | -| `uid` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | | - -### ExtraFields - -extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Spec - -TODO: -common metadata will soon support setting the parent folder in the metadata - -| Property | Type | Required | Default | Description | -|---------------|--------|----------|---------|--------------------------------------| -| `title` | string | **Yes** | | Folder title | -| `uid` | string | **Yes** | | Unique folder id. (will be k8s name) | -| `description` | string | No | | Description of the folder. | - -### Status - -| Property | Type | Required | Default | Description | -|--------------------|------------------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `additionalFields` | [object](#additionalfields) | No | | additionalFields is reserved for future use | -| `operatorStates` | map[string][status.#OperatorState](#status.#operatorstate) | No | | operatorStates is a map of operator ID to operator state evaluations.<br/>Any operator which consumes this kind SHOULD add its state evaluation information to this field. | - -### AdditionalFields - -additionalFields is reserved for future use - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Status.#OperatorState - -| Property | Type | Required | Default | Description | -|--------------------|--------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `lastEvaluation` | string | **Yes** | | lastEvaluation is the ResourceVersion last evaluated | -| `state` | string | **Yes** | | state describes the state of the lastEvaluation.<br/>It is limited to three possible states for machine evaluation.<br/>Possible values are: `success`, `in_progress`, `failed`. | -| `descriptiveState` | string | No | | descriptiveState is an optional more descriptive state field which has no requirements on format | -| `details` | [object](#details) | No | | details contains any extra information that is operator-specific | - -### Details - -details contains any extra information that is operator-specific - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - - diff --git a/docs/sources/developers/kinds/core/librarypanel/schema-reference.md b/docs/sources/developers/kinds/core/librarypanel/schema-reference.md deleted file mode 100644 index eaab5f3544832..0000000000000 --- a/docs/sources/developers/kinds/core/librarypanel/schema-reference.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: LibraryPanel kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## LibraryPanel - -#### Maturity: [experimental](../../../maturity/#experimental) -#### Version: 0.0 - -A standalone panel - -| Property | Type | Required | Default | Description | -|------------|---------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `metadata` | [object](#metadata) | **Yes** | | metadata contains embedded CommonMetadata and can be extended with custom string fields<br/>TODO: use CommonMetadata instead of redefining here; currently needs to be defined here<br/>without external reference as using the CommonMetadata reference breaks thema codegen. | -| `spec` | [object](#spec) | **Yes** | | | -| `status` | [object](#status) | **Yes** | | | - -### Metadata - -metadata contains embedded CommonMetadata and can be extended with custom string fields -TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -without external reference as using the CommonMetadata reference breaks thema codegen. - -It extends [_kubeObjectMetadata](#_kubeobjectmetadata). - -| Property | Type | Required | Default | Description | -|---------------------|------------------------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `createdBy` | string | **Yes** | | | -| `creationTimestamp` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `extraFields` | [object](#extrafields) | **Yes** | | extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata | -| `finalizers` | string[] | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `labels` | map[string]string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `resourceVersion` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `uid` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `updateTimestamp` | string | **Yes** | | | -| `updatedBy` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | - -### _kubeObjectMetadata - -_kubeObjectMetadata is metadata found in a kubernetes object's metadata field. -It is not exhaustive and only includes fields which may be relevant to a kind's implementation, -As it is also intended to be generic enough to function with any API Server. - -| Property | Type | Required | Default | Description | -|---------------------|-------------------|----------|---------|-------------| -| `creationTimestamp` | string | **Yes** | | | -| `finalizers` | string[] | **Yes** | | | -| `labels` | map[string]string | **Yes** | | | -| `resourceVersion` | string | **Yes** | | | -| `uid` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | | - -### ExtraFields - -extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Spec - -| Property | Type | Required | Default | Description | -|-----------------|-------------------------------------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------| -| `model` | [object](#model) | **Yes** | | TODO: should be the same panel schema defined in dashboard<br/>Typescript: Omit<Panel, 'gridPos' | 'id' | 'libraryPanel'>; | -| `name` | string | **Yes** | | Panel name (also saved in the model)<br/>Constraint: `length >=1`. | -| `type` | string | **Yes** | | The panel type (from inside the model)<br/>Constraint: `length >=1`. | -| `uid` | string | **Yes** | | Library element UID | -| `version` | integer | **Yes** | | panel version, incremented each time the dashboard is updated. | -| `description` | string | No | | Panel description | -| `folderUid` | string | No | | Folder UID | -| `meta` | [LibraryElementDTOMeta](#libraryelementdtometa) | No | | | -| `schemaVersion` | uint16 | No | | Dashboard version when this was saved (zero if unknown) | - -### LibraryElementDTOMeta - -| Property | Type | Required | Default | Description | -|-----------------------|---------------------------------------------------------|----------|---------|-------------| -| `connectedDashboards` | integer | **Yes** | | | -| `createdBy` | [LibraryElementDTOMetaUser](#libraryelementdtometauser) | **Yes** | | | -| `created` | string | **Yes** | | | -| `folderName` | string | **Yes** | | | -| `folderUid` | string | **Yes** | | | -| `updatedBy` | [LibraryElementDTOMetaUser](#libraryelementdtometauser) | **Yes** | | | -| `updated` | string | **Yes** | | | - -### LibraryElementDTOMetaUser - -| Property | Type | Required | Default | Description | -|-------------|---------|----------|---------|-------------| -| `avatarUrl` | string | **Yes** | | | -| `id` | integer | **Yes** | | | -| `name` | string | **Yes** | | | - -### Model - -TODO: should be the same panel schema defined in dashboard -Typescript: Omit<Panel, 'gridPos' | 'id' | 'libraryPanel'>; - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Status - -| Property | Type | Required | Default | Description | -|--------------------|------------------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `additionalFields` | [object](#additionalfields) | No | | additionalFields is reserved for future use | -| `operatorStates` | map[string][status.#OperatorState](#status.#operatorstate) | No | | operatorStates is a map of operator ID to operator state evaluations.<br/>Any operator which consumes this kind SHOULD add its state evaluation information to this field. | - -### AdditionalFields - -additionalFields is reserved for future use - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Status.#OperatorState - -| Property | Type | Required | Default | Description | -|--------------------|--------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `lastEvaluation` | string | **Yes** | | lastEvaluation is the ResourceVersion last evaluated | -| `state` | string | **Yes** | | state describes the state of the lastEvaluation.<br/>It is limited to three possible states for machine evaluation.<br/>Possible values are: `success`, `in_progress`, `failed`. | -| `descriptiveState` | string | No | | descriptiveState is an optional more descriptive state field which has no requirements on format | -| `details` | [object](#details) | No | | details contains any extra information that is operator-specific | - -### Details - -details contains any extra information that is operator-specific - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - - diff --git a/docs/sources/developers/kinds/core/preferences/schema-reference.md b/docs/sources/developers/kinds/core/preferences/schema-reference.md deleted file mode 100644 index e5ac35187cdc4..0000000000000 --- a/docs/sources/developers/kinds/core/preferences/schema-reference.md +++ /dev/null @@ -1,144 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: Preferences kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## Preferences - -#### Maturity: [merged](../../../maturity/#merged) -#### Version: 0.0 - -The user or team frontend preferences - -| Property | Type | Required | Default | Description | -|------------|---------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `metadata` | [object](#metadata) | **Yes** | | metadata contains embedded CommonMetadata and can be extended with custom string fields<br/>TODO: use CommonMetadata instead of redefining here; currently needs to be defined here<br/>without external reference as using the CommonMetadata reference breaks thema codegen. | -| `spec` | [object](#spec) | **Yes** | | Spec defines user, team or org Grafana preferences<br/>swagger:model Preferences | -| `status` | [object](#status) | **Yes** | | | - -### Metadata - -metadata contains embedded CommonMetadata and can be extended with custom string fields -TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -without external reference as using the CommonMetadata reference breaks thema codegen. - -It extends [_kubeObjectMetadata](#_kubeobjectmetadata). - -| Property | Type | Required | Default | Description | -|---------------------|------------------------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `createdBy` | string | **Yes** | | | -| `creationTimestamp` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `extraFields` | [object](#extrafields) | **Yes** | | extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata | -| `finalizers` | string[] | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `labels` | map[string]string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `resourceVersion` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `uid` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `updateTimestamp` | string | **Yes** | | | -| `updatedBy` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | - -### _kubeObjectMetadata - -_kubeObjectMetadata is metadata found in a kubernetes object's metadata field. -It is not exhaustive and only includes fields which may be relevant to a kind's implementation, -As it is also intended to be generic enough to function with any API Server. - -| Property | Type | Required | Default | Description | -|---------------------|-------------------|----------|---------|-------------| -| `creationTimestamp` | string | **Yes** | | | -| `finalizers` | string[] | **Yes** | | | -| `labels` | map[string]string | **Yes** | | | -| `resourceVersion` | string | **Yes** | | | -| `uid` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | | - -### ExtraFields - -extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Spec - -Spec defines user, team or org Grafana preferences -swagger:model Preferences - -| Property | Type | Required | Default | Description | -|---------------------|---------------------------------------------------|----------|---------|---------------------------------------------------------------------------------| -| `cookiePreferences` | [CookiePreferences](#cookiepreferences) | No | | | -| `homeDashboardUID` | string | No | | UID for the home dashboard | -| `language` | string | No | | Selected language (beta) | -| `queryHistory` | [QueryHistoryPreference](#queryhistorypreference) | No | | | -| `theme` | string | No | | light, dark, empty is default | -| `timezone` | string | No | | The timezone selection<br/>TODO: this should use the timezone defined in common | -| `weekStart` | string | No | | day of the week (sunday, monday, etc) | - -### CookiePreferences - -| Property | Type | Required | Default | Description | -|---------------|------------------------|----------|---------|-------------| -| `analytics` | [object](#analytics) | No | | | -| `functional` | [object](#functional) | No | | | -| `performance` | [object](#performance) | No | | | - -### Analytics - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Functional - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Performance - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### QueryHistoryPreference - -| Property | Type | Required | Default | Description | -|-----------|--------|----------|---------|---------------------------------------------| -| `homeTab` | string | No | | one of: '' | 'query' | 'starred'; | - -### Status - -| Property | Type | Required | Default | Description | -|--------------------|------------------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `additionalFields` | [object](#additionalfields) | No | | additionalFields is reserved for future use | -| `operatorStates` | map[string][status.#OperatorState](#status.#operatorstate) | No | | operatorStates is a map of operator ID to operator state evaluations.<br/>Any operator which consumes this kind SHOULD add its state evaluation information to this field. | - -### AdditionalFields - -additionalFields is reserved for future use - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Status.#OperatorState - -| Property | Type | Required | Default | Description | -|--------------------|--------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `lastEvaluation` | string | **Yes** | | lastEvaluation is the ResourceVersion last evaluated | -| `state` | string | **Yes** | | state describes the state of the lastEvaluation.<br/>It is limited to three possible states for machine evaluation.<br/>Possible values are: `success`, `in_progress`, `failed`. | -| `descriptiveState` | string | No | | descriptiveState is an optional more descriptive state field which has no requirements on format | -| `details` | [object](#details) | No | | details contains any extra information that is operator-specific | - -### Details - -details contains any extra information that is operator-specific - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - - diff --git a/docs/sources/developers/kinds/core/publicdashboard/schema-reference.md b/docs/sources/developers/kinds/core/publicdashboard/schema-reference.md deleted file mode 100644 index 3f6f3ab223c89..0000000000000 --- a/docs/sources/developers/kinds/core/publicdashboard/schema-reference.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: PublicDashboard kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## PublicDashboard - -#### Maturity: [merged](../../../maturity/#merged) -#### Version: 0.0 - -Public dashboard configuration - -| Property | Type | Required | Default | Description | -|------------|---------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `metadata` | [object](#metadata) | **Yes** | | metadata contains embedded CommonMetadata and can be extended with custom string fields<br/>TODO: use CommonMetadata instead of redefining here; currently needs to be defined here<br/>without external reference as using the CommonMetadata reference breaks thema codegen. | -| `spec` | [object](#spec) | **Yes** | | | -| `status` | [object](#status) | **Yes** | | | - -### Metadata - -metadata contains embedded CommonMetadata and can be extended with custom string fields -TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -without external reference as using the CommonMetadata reference breaks thema codegen. - -It extends [_kubeObjectMetadata](#_kubeobjectmetadata). - -| Property | Type | Required | Default | Description | -|---------------------|------------------------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `createdBy` | string | **Yes** | | | -| `creationTimestamp` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `extraFields` | [object](#extrafields) | **Yes** | | extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata | -| `finalizers` | string[] | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `labels` | map[string]string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `resourceVersion` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `uid` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `updateTimestamp` | string | **Yes** | | | -| `updatedBy` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | - -### _kubeObjectMetadata - -_kubeObjectMetadata is metadata found in a kubernetes object's metadata field. -It is not exhaustive and only includes fields which may be relevant to a kind's implementation, -As it is also intended to be generic enough to function with any API Server. - -| Property | Type | Required | Default | Description | -|---------------------|-------------------|----------|---------|-------------| -| `creationTimestamp` | string | **Yes** | | | -| `finalizers` | string[] | **Yes** | | | -| `labels` | map[string]string | **Yes** | | | -| `resourceVersion` | string | **Yes** | | | -| `uid` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | | - -### ExtraFields - -extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Spec - -| Property | Type | Required | Default | Description | -|------------------------|---------|----------|---------|-----------------------------------------------------------------| -| `annotationsEnabled` | boolean | **Yes** | | Flag that indicates if annotations are enabled | -| `dashboardUid` | string | **Yes** | | Dashboard unique identifier referenced by this public dashboard | -| `isEnabled` | boolean | **Yes** | | Flag that indicates if the public dashboard is enabled | -| `timeSelectionEnabled` | boolean | **Yes** | | Flag that indicates if the time range picker is enabled | -| `uid` | string | **Yes** | | Unique public dashboard identifier | -| `accessToken` | string | No | | Unique public access token | - -### Status - -| Property | Type | Required | Default | Description | -|--------------------|------------------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `additionalFields` | [object](#additionalfields) | No | | additionalFields is reserved for future use | -| `operatorStates` | map[string][status.#OperatorState](#status.#operatorstate) | No | | operatorStates is a map of operator ID to operator state evaluations.<br/>Any operator which consumes this kind SHOULD add its state evaluation information to this field. | - -### AdditionalFields - -additionalFields is reserved for future use - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Status.#OperatorState - -| Property | Type | Required | Default | Description | -|--------------------|--------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `lastEvaluation` | string | **Yes** | | lastEvaluation is the ResourceVersion last evaluated | -| `state` | string | **Yes** | | state describes the state of the lastEvaluation.<br/>It is limited to three possible states for machine evaluation.<br/>Possible values are: `success`, `in_progress`, `failed`. | -| `descriptiveState` | string | No | | descriptiveState is an optional more descriptive state field which has no requirements on format | -| `details` | [object](#details) | No | | details contains any extra information that is operator-specific | - -### Details - -details contains any extra information that is operator-specific - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - - diff --git a/docs/sources/developers/kinds/core/role/schema-reference.md b/docs/sources/developers/kinds/core/role/schema-reference.md deleted file mode 100644 index b5d96f79479c7..0000000000000 --- a/docs/sources/developers/kinds/core/role/schema-reference.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: Role kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## Role - -#### Maturity: [merged](../../../maturity/#merged) -#### Version: 0.0 - -Roles represent a set of users+teams that should share similar access - -| Property | Type | Required | Default | Description | -|------------|---------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `metadata` | [object](#metadata) | **Yes** | | metadata contains embedded CommonMetadata and can be extended with custom string fields<br/>TODO: use CommonMetadata instead of redefining here; currently needs to be defined here<br/>without external reference as using the CommonMetadata reference breaks thema codegen. | -| `spec` | [object](#spec) | **Yes** | | | -| `status` | [object](#status) | **Yes** | | | - -### Metadata - -metadata contains embedded CommonMetadata and can be extended with custom string fields -TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -without external reference as using the CommonMetadata reference breaks thema codegen. - -It extends [_kubeObjectMetadata](#_kubeobjectmetadata). - -| Property | Type | Required | Default | Description | -|---------------------|------------------------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `createdBy` | string | **Yes** | | | -| `creationTimestamp` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `extraFields` | [object](#extrafields) | **Yes** | | extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata | -| `finalizers` | string[] | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `labels` | map[string]string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `resourceVersion` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `uid` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `updateTimestamp` | string | **Yes** | | | -| `updatedBy` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | - -### _kubeObjectMetadata - -_kubeObjectMetadata is metadata found in a kubernetes object's metadata field. -It is not exhaustive and only includes fields which may be relevant to a kind's implementation, -As it is also intended to be generic enough to function with any API Server. - -| Property | Type | Required | Default | Description | -|---------------------|-------------------|----------|---------|-------------| -| `creationTimestamp` | string | **Yes** | | | -| `finalizers` | string[] | **Yes** | | | -| `labels` | map[string]string | **Yes** | | | -| `resourceVersion` | string | **Yes** | | | -| `uid` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | | - -### ExtraFields - -extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Spec - -| Property | Type | Required | Default | Description | -|---------------|---------|----------|---------|-----------------------------------------------------------| -| `hidden` | boolean | **Yes** | | Do not show this role | -| `name` | string | **Yes** | | The role identifier `managed:builtins:editor:permissions` | -| `description` | string | No | | Role description | -| `displayName` | string | No | | Optional display | -| `groupName` | string | No | | Name of the team. | - -### Status - -| Property | Type | Required | Default | Description | -|--------------------|------------------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `additionalFields` | [object](#additionalfields) | No | | additionalFields is reserved for future use | -| `operatorStates` | map[string][status.#OperatorState](#status.#operatorstate) | No | | operatorStates is a map of operator ID to operator state evaluations.<br/>Any operator which consumes this kind SHOULD add its state evaluation information to this field. | - -### AdditionalFields - -additionalFields is reserved for future use - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Status.#OperatorState - -| Property | Type | Required | Default | Description | -|--------------------|--------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `lastEvaluation` | string | **Yes** | | lastEvaluation is the ResourceVersion last evaluated | -| `state` | string | **Yes** | | state describes the state of the lastEvaluation.<br/>It is limited to three possible states for machine evaluation.<br/>Possible values are: `success`, `in_progress`, `failed`. | -| `descriptiveState` | string | No | | descriptiveState is an optional more descriptive state field which has no requirements on format | -| `details` | [object](#details) | No | | details contains any extra information that is operator-specific | - -### Details - -details contains any extra information that is operator-specific - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - - diff --git a/docs/sources/developers/kinds/core/rolebinding/schema-reference.md b/docs/sources/developers/kinds/core/rolebinding/schema-reference.md deleted file mode 100644 index 370a2a82ccfb0..0000000000000 --- a/docs/sources/developers/kinds/core/rolebinding/schema-reference.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: RoleBinding kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## RoleBinding - -#### Maturity: [merged](../../../maturity/#merged) -#### Version: 0.0 - -Role bindings links a user|team to a configured role - -| Property | Type | Required | Default | Description | -|------------|---------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `metadata` | [object](#metadata) | **Yes** | | metadata contains embedded CommonMetadata and can be extended with custom string fields<br/>TODO: use CommonMetadata instead of redefining here; currently needs to be defined here<br/>without external reference as using the CommonMetadata reference breaks thema codegen. | -| `spec` | [object](#spec) | **Yes** | | | -| `status` | [object](#status) | **Yes** | | | - -### Metadata - -metadata contains embedded CommonMetadata and can be extended with custom string fields -TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -without external reference as using the CommonMetadata reference breaks thema codegen. - -It extends [_kubeObjectMetadata](#_kubeobjectmetadata). - -| Property | Type | Required | Default | Description | -|---------------------|------------------------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `createdBy` | string | **Yes** | | | -| `creationTimestamp` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `extraFields` | [object](#extrafields) | **Yes** | | extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata | -| `finalizers` | string[] | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `labels` | map[string]string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `resourceVersion` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `uid` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `updateTimestamp` | string | **Yes** | | | -| `updatedBy` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | - -### _kubeObjectMetadata - -_kubeObjectMetadata is metadata found in a kubernetes object's metadata field. -It is not exhaustive and only includes fields which may be relevant to a kind's implementation, -As it is also intended to be generic enough to function with any API Server. - -| Property | Type | Required | Default | Description | -|---------------------|-------------------|----------|---------|-------------| -| `creationTimestamp` | string | **Yes** | | | -| `finalizers` | string[] | **Yes** | | | -| `labels` | map[string]string | **Yes** | | | -| `resourceVersion` | string | **Yes** | | | -| `uid` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | | - -### ExtraFields - -extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Spec - -| Property | Type | Required | Default | Description | -|-----------|-------------------------------------------|----------|---------|----------------------------| -| `role` | [object](#role) | **Yes** | | The role we are discussing | -| `subject` | [RoleBindingSubject](#rolebindingsubject) | **Yes** | | | - -### RoleBindingSubject - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|--------------------------------------| -| `kind` | string | **Yes** | | Possible values are: `Team`, `User`. | -| `name` | string | **Yes** | | The team/user identifier name | - -### Role - -The role we are discussing - -| Property | Type | Required | Default | Description | -|----------|-----------------------------------------------------------------------------------------|----------|---------|-------------| -| `object` | Possible types are: [BuiltinRoleRef](#builtinroleref), [CustomRoleRef](#customroleref). | | | - -### BuiltinRoleRef - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|---------------------------------------------------| -| `kind` | string | **Yes** | | Possible values are: `BuiltinRole`. | -| `name` | string | **Yes** | | Possible values are: `viewer`, `editor`, `admin`. | - -### CustomRoleRef - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|------------------------------| -| `kind` | string | **Yes** | | Possible values are: `Role`. | -| `name` | string | **Yes** | | | - -### Status - -| Property | Type | Required | Default | Description | -|--------------------|------------------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `additionalFields` | [object](#additionalfields) | No | | additionalFields is reserved for future use | -| `operatorStates` | map[string][status.#OperatorState](#status.#operatorstate) | No | | operatorStates is a map of operator ID to operator state evaluations.<br/>Any operator which consumes this kind SHOULD add its state evaluation information to this field. | - -### AdditionalFields - -additionalFields is reserved for future use - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Status.#OperatorState - -| Property | Type | Required | Default | Description | -|--------------------|--------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `lastEvaluation` | string | **Yes** | | lastEvaluation is the ResourceVersion last evaluated | -| `state` | string | **Yes** | | state describes the state of the lastEvaluation.<br/>It is limited to three possible states for machine evaluation.<br/>Possible values are: `success`, `in_progress`, `failed`. | -| `descriptiveState` | string | No | | descriptiveState is an optional more descriptive state field which has no requirements on format | -| `details` | [object](#details) | No | | details contains any extra information that is operator-specific | - -### Details - -details contains any extra information that is operator-specific - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - - diff --git a/docs/sources/developers/kinds/core/serviceaccount/schema-reference.md b/docs/sources/developers/kinds/core/serviceaccount/schema-reference.md deleted file mode 100644 index 4c609ec6bba98..0000000000000 --- a/docs/sources/developers/kinds/core/serviceaccount/schema-reference.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -keywords: -- grafana -- schema -labels: - products: - - enterprise - - oss -title: ServiceAccount kind ---- - -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## ServiceAccount - -#### Maturity: [merged](../../../maturity/#merged) - -#### Version: 0.0 - -system account - -| Property | Type | Required | Default | Description | -| ---------- | ------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `metadata` | [object](#metadata) | **Yes** | | metadata contains embedded CommonMetadata and can be extended with custom string fields<br/>TODO: use CommonMetadata instead of redefining here; currently needs to be defined here<br/>without external reference as using the CommonMetadata reference breaks thema codegen. | -| `spec` | [object](#spec) | **Yes** | | | -| `status` | [object](#status) | **Yes** | | | - -### Metadata - -metadata contains embedded CommonMetadata and can be extended with custom string fields -TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -without external reference as using the CommonMetadata reference breaks thema codegen. - -It extends [\_kubeObjectMetadata](#_kubeobjectmetadata). - -| Property | Type | Required | Default | Description | -| ------------------- | ---------------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `createdBy` | string | **Yes** | | | -| `creationTimestamp` | string | **Yes** | | _(Inherited from [\_kubeObjectMetadata](#_kubeobjectmetadata))_ | -| `extraFields` | [object](#extrafields) | **Yes** | | extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata | -| `finalizers` | string[] | **Yes** | | _(Inherited from [\_kubeObjectMetadata](#_kubeobjectmetadata))_ | -| `labels` | map[string]string | **Yes** | | _(Inherited from [\_kubeObjectMetadata](#_kubeobjectmetadata))_ | -| `resourceVersion` | string | **Yes** | | _(Inherited from [\_kubeObjectMetadata](#_kubeobjectmetadata))_ | -| `uid` | string | **Yes** | | _(Inherited from [\_kubeObjectMetadata](#_kubeobjectmetadata))_ | -| `updateTimestamp` | string | **Yes** | | | -| `updatedBy` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | _(Inherited from [\_kubeObjectMetadata](#_kubeobjectmetadata))_ | - -### \_kubeObjectMetadata - -\_kubeObjectMetadata is metadata found in a kubernetes object's metadata field. -It is not exhaustive and only includes fields which may be relevant to a kind's implementation, -As it is also intended to be generic enough to function with any API Server. - -| Property | Type | Required | Default | Description | -| ------------------- | ----------------- | -------- | ------- | ----------- | -| `creationTimestamp` | string | **Yes** | | | -| `finalizers` | string[] | **Yes** | | | -| `labels` | map[string]string | **Yes** | | | -| `resourceVersion` | string | **Yes** | | | -| `uid` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | | - -### ExtraFields - -extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata - -| Property | Type | Required | Default | Description | -| -------- | ---- | -------- | ------- | ----------- | - -### Spec - -| Property | Type | Required | Default | Description | -| --------------- | ------------------ | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `avatarUrl` | string | **Yes** | | AvatarUrl is the service account's avatar URL. It allows the frontend to display a picture in front<br/>of the service account. | -| `id` | integer | **Yes** | | ID is the unique identifier of the service account in the database. | -| `isDisabled` | boolean | **Yes** | | IsDisabled indicates if the service account is disabled. | -| `login` | string | **Yes** | | Login of the service account. | -| `name` | string | **Yes** | | Name of the service account. | -| `orgId` | integer | **Yes** | | OrgId is the ID of an organisation the service account belongs to. | -| `role` | string | **Yes** | | OrgRole is a Grafana Organization Role which can be 'Viewer', 'Editor', 'Admin'.<br/>Possible values are: `Admin`, `Editor`, `Viewer`. | -| `tokens` | integer | **Yes** | | Tokens is the number of active tokens for the service account.<br/>Tokens are used to authenticate the service account against Grafana. | -| `accessControl` | map[string]boolean | No | | AccessControl metadata associated with a given resource. | -| `teams` | string[] | No | | Teams is a list of teams the service account belongs to. | - -### Status - -| Property | Type | Required | Default | Description | -| ------------------ | ---------------------------------------------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `additionalFields` | [object](#additionalfields) | No | | additionalFields is reserved for future use | -| `operatorStates` | map[string][status.#OperatorState](#status.#operatorstate) | No | | operatorStates is a map of operator ID to operator state evaluations.<br/>Any operator which consumes this kind SHOULD add its state evaluation information to this field. | - -### AdditionalFields - -additionalFields is reserved for future use - -| Property | Type | Required | Default | Description | -| -------- | ---- | -------- | ------- | ----------- | - -### Status.#OperatorState - -| Property | Type | Required | Default | Description | -| ------------------ | ------------------ | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `lastEvaluation` | string | **Yes** | | lastEvaluation is the ResourceVersion last evaluated | -| `state` | string | **Yes** | | state describes the state of the lastEvaluation.<br/>It is limited to three possible states for machine evaluation.<br/>Possible values are: `success`, `in_progress`, `failed`. | -| `descriptiveState` | string | No | | descriptiveState is an optional more descriptive state field which has no requirements on format | -| `details` | [object](#details) | No | | details contains any extra information that is operator-specific | - -### Details - -details contains any extra information that is operator-specific - -| Property | Type | Required | Default | Description | -| -------- | ---- | -------- | ------- | ----------- | diff --git a/docs/sources/developers/kinds/core/team/schema-reference.md b/docs/sources/developers/kinds/core/team/schema-reference.md deleted file mode 100644 index 6f759abf319cf..0000000000000 --- a/docs/sources/developers/kinds/core/team/schema-reference.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: Team kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## Team - -#### Maturity: [merged](../../../maturity/#merged) -#### Version: 0.0 - -A team is a named grouping of Grafana users to which access control rules may be assigned. - -| Property | Type | Required | Default | Description | -|------------|---------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `metadata` | [object](#metadata) | **Yes** | | metadata contains embedded CommonMetadata and can be extended with custom string fields<br/>TODO: use CommonMetadata instead of redefining here; currently needs to be defined here<br/>without external reference as using the CommonMetadata reference breaks thema codegen. | -| `spec` | [object](#spec) | **Yes** | | | -| `status` | [object](#status) | **Yes** | | | - -### Metadata - -metadata contains embedded CommonMetadata and can be extended with custom string fields -TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -without external reference as using the CommonMetadata reference breaks thema codegen. - -It extends [_kubeObjectMetadata](#_kubeobjectmetadata). - -| Property | Type | Required | Default | Description | -|---------------------|------------------------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `createdBy` | string | **Yes** | | | -| `creationTimestamp` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `extraFields` | [object](#extrafields) | **Yes** | | extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata | -| `finalizers` | string[] | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `labels` | map[string]string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `resourceVersion` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `uid` | string | **Yes** | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | -| `updateTimestamp` | string | **Yes** | | | -| `updatedBy` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | *(Inherited from [_kubeObjectMetadata](#_kubeobjectmetadata))* | - -### _kubeObjectMetadata - -_kubeObjectMetadata is metadata found in a kubernetes object's metadata field. -It is not exhaustive and only includes fields which may be relevant to a kind's implementation, -As it is also intended to be generic enough to function with any API Server. - -| Property | Type | Required | Default | Description | -|---------------------|-------------------|----------|---------|-------------| -| `creationTimestamp` | string | **Yes** | | | -| `finalizers` | string[] | **Yes** | | | -| `labels` | map[string]string | **Yes** | | | -| `resourceVersion` | string | **Yes** | | | -| `uid` | string | **Yes** | | | -| `deletionTimestamp` | string | No | | | - -### ExtraFields - -extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Spec - -| Property | Type | Required | Default | Description | -|----------|--------|----------|---------|--------------------| -| `name` | string | **Yes** | | Name of the team. | -| `email` | string | No | | Email of the team. | - -### Status - -| Property | Type | Required | Default | Description | -|--------------------|------------------------------------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `additionalFields` | [object](#additionalfields) | No | | additionalFields is reserved for future use | -| `operatorStates` | map[string][status.#OperatorState](#status.#operatorstate) | No | | operatorStates is a map of operator ID to operator state evaluations.<br/>Any operator which consumes this kind SHOULD add its state evaluation information to this field. | - -### AdditionalFields - -additionalFields is reserved for future use - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - -### Status.#OperatorState - -| Property | Type | Required | Default | Description | -|--------------------|--------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `lastEvaluation` | string | **Yes** | | lastEvaluation is the ResourceVersion last evaluated | -| `state` | string | **Yes** | | state describes the state of the lastEvaluation.<br/>It is limited to three possible states for machine evaluation.<br/>Possible values are: `success`, `in_progress`, `failed`. | -| `descriptiveState` | string | No | | descriptiveState is an optional more descriptive state field which has no requirements on format | -| `details` | [object](#details) | No | | details contains any extra information that is operator-specific | - -### Details - -details contains any extra information that is operator-specific - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| - - diff --git a/docs/sources/developers/kinds/maturity.md b/docs/sources/developers/kinds/maturity.md deleted file mode 100644 index e9a6264f2f0d4..0000000000000 --- a/docs/sources/developers/kinds/maturity.md +++ /dev/null @@ -1,298 +0,0 @@ ---- -keywords: -- grafana -- schema -- maturity -labels: - products: - - enterprise - - oss -title: Grafana Kinds - From Zero to Maturity -weight: 300 ---- - -# Grafana Kinds - From Zero to Maturity - -> Grafana’s schema, Kind, and related codegen systems are under intense development. - -Fear of unknown impacts leads to defensive coding, slow PRs, circular arguments, and an overall hesitance to engage. -That friction alone is sufficient to sink a large-scale project. This guide seeks to counteract this friction by -defining an end goal for all schemas: “mature.” This is the word we’re using to refer to the commonsense notion of “this -software reached 1.0.” - -In general, 1.0/mature suggests: “we’ve thought about this thing, done the necessary experimenting, know what it is, and -feel confident about presenting it to the world.” In the context of schemas intended to act as a single source of truth -driving many use cases, we can intuitively phrase maturity as: - -- The schema follows general best practices (e.g. good comments, follows field type rules), and the team owning the - schema believes that the fields described in the schema are accurate. -- Automation propagates the schema as source of truth to every relevant - [domain](https://docs.google.com/document/d/13Rv395_T8WTLBgdL-2rbXKu0fx_TW-Q9yz9x6oBjm6g/edit#heading=h.67pop2k2f8fq) - (for example: types in frontend, backend, as-code; plugins SDK; docs; APIs and storage; search indexing) - -This intuitive definition gets us pointed in the right direction. But we can’t just jump straight there - we have to -approach it methodically. To that end, this doc outlines four (ok five, but really, four) basic maturity milestones that -we expect Kinds and their schemas to progress through: - -- _(Planned - Put a Kind name on the official TODO list)_ -- **Merged** - Get an initial schema written down. Not final. Not perfect. -- **Experimental** - Kind schemas are the source of truth for basic working code. -- **Stable** - Kind schemas are the source of truth for all target domains. -- **Mature** - The operational transition path for the Kind is battle-tested and reliable. - -These milestones have functional definitions, tied to code and enforced in CI. A Kind having reached a particular -milestone corresponds to properties of the code that are enforced in CI; advancing to the next milestone likely has a -direct impact on code generation and runtime behavior. - -Finally, the above definitions imply that maturity for _individual Kinds/schemas_ depends on _the Kind system_ being -mature, as well. This is by design: **Grafana Labs does not intend to publicize any single schema as mature until -[certain schema system milestones are met](https://github.com/orgs/grafana/projects/133/views/8).** - -## Schema Maturity Milestones - -Maturity milestones are a linear progression. Each milestone implies that the conditions of its predecessors continue to -be met. - -Reaching a particular milestone implies that the properties of all prior milestones are still met. - -### (Milestone 0 - Planned) {#planned} - -| **Goal** | Put a Kind name on the official TODO list: [Kind Schematization Progress Tracker](https://docs.google.com/spreadsheets/d/1DL6nZHyX42X013QraWYbKsMmHozLrtXDj8teLKvwYMY/edit#gid=0) | -| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Reached when** | The planned Kind is listed in the relevant sheet of the progress tracker with a link to track / be able to see when exactly it is planned and who is responsible for doing it | -| **Common hurdles** | Existing definitions may not correspond clearly to an object boundary - e.g. playlists are currently in denormalized SQL tables playlist and playlist_item | -| **Public-facing guarantees** | None | -| **customer-facing stage** | None | - -### Milestone 1 - Merged {#merged} - -| **Goal** | Get an initial schema written down. Not final. Not perfect. | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Reached when** | A PR introducing the initial version of a schema has been merged. | -| **Common hurdles** | Getting comfortable with Thema and CUE<br/>Figuring out where all the existing definitions of the Kind are<br/>Knowing whether it’s safe to omit possibly-crufty fields from the existing definitions when writing the schema | -| **Public-facing guarantees** | None | -| **User-facing stage** | None | - -### Milestone 2 - Experimental {#experimental} - -| **Goal** | Schemas are the source of truth for basic working code. | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Reached when** | Go and TypeScript types generated from schema are used in all relevant production code, having replaced handwritten type definitions (if any). | -| **Common hurdles** | Compromises on field definitions that seemed fine to reach “committed” start to feel unacceptable<br/>Ergonomics of generated code may start to bite<br/>Aligning with the look and feel of related schemas | -| **Public-facing guarantees** | Kinds are available for as-code usage in [grok](https://github.com/grafana/grok), and in tools downstream of grok, following all of grok’s standard patterns. | -| **Stage comms** | Internal users:- Start using the schema and give feedback internally to help move to the next stage.External users:- Align with the [experimental](https://docs.google.com/document/d/1lqp0hALax2PT7jSObsX52EbQmIDFnLFMqIbBrJ4EYCE/edit#heading=h.ehl5iy7pcjvq) stage in the release definition document.  - Experimental schemas will be discoverable, and from a customer PoV should never be used in production, but they can be explored and we are more than happy to receive feedback | - -## Schema-writing guidelines - -### Avoid anonymous nested structs - -**_Always name your sub-objects._** - -In CUE, nesting structs is like nesting objects in JSON, and just as easy: - -```json -one: { - two: { - three: { - } -} -``` - -While these can be accurately represented in other languages, they aren’t especially friendly to work with: - -```typescript -// TypeScript -export interface One { - two: { - three: string; - }; -} -``` - -```go -// Go -type One struct { - Two struct { - Three string `json:"three"` - } `json:"two"` -} -``` - -Instead, within your schema, prefer to make root-level definitions with the appropriate attributes: - -```cue -// Cue -one: { - two: #Two - #Two: { - three: string - } @cuetsy(kind="interface") -} -``` - -```Typescript -// TypeScript -export interface Two { - three: string; -} -export interface One { - two: Two; -} -``` - -```Go -// Go -type One struct { - Two Two `json:"two"` -} -type Two struct { - Three string `json:"three"` -} -``` - -### Use precise numeric types - -**_Use precise numeric types like `float64` or `uint32`. Never use `number`._** - -Never use `number` for a numeric type in a schema. - -Instead, use a specific, sized type like `int64` or `float32`. This makes your intent precisely clear. -TypeScript will still represent these fields with `number`, but other languages (e.g. Go, Protobuf) can be more precise. - -Unlike in Go, int and uint are not your friends. These correspond to `math/big` types. Use a sized type, -like `uint32` or `int32`, unless the use case specifically requires a huge numeric space. - -### No explicit `null` - -**_Do not use `null` as a type in any schema._** - -This one is tricky to think about, and requires some background. - -Historically, Grafana’s dashboard JSON has often contained fields with the explicit value `null`. -This was problematic, because explicit `null` introduces an ambiguity: is a JSON field being present -with value null meaningfully different from the field being absent? That is, should a program behave differently -if it encounters a null vs. an absent field? - -In almost all cases, the answer is “no.” Thus, the ambiguity: if both explicit null and absence are _accepted_ -by a system, it pushes responsibility onto anyone writing code in that system to decide, case-by-case, -whether the two are _intended to be meaningfully different_, and therefore whether behavior should be different. - -CUE does have a `null` type, and only accepts data containing `nulls` as valid if the schema explicitly allows a `null`. -That means, by default, using CUE for schemas removes the possibility of ambiguity in code that receives data validated -by those schemas, even if the language they’re writing in still allows for ambiguity. (Javascript does, Go doesn’t.) - -As a schema author, this means you’re being unambiguous by default - no `nulls`. That’s good! The only question is -whether it’s worth explicitly allowing a `null` for some particular case: - -```Cue -someField: int32 | null -``` - -The _only_ time this _may_ be a good idea is if your field needs to be able to represent a value -that is not otherwise acceptable within the value space - for example, if `someField` needs to be able to contain -[Infinity](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/POSITIVE_INFINITY). -When such values are serialized to null by default, it can be convenient to accept null in the schema - but even then, -explicit null is unlikely to be the best way to represent such values, because it is so subtle and falsey. - -**Above all, DO NOT accept `null` in a schema simply because current behavior sometimes unintentionally produces a `null`.** -Schematization is an opportunity to get rid of this ambiguity. Fix the accidental null-producing behavior, instead. - -### Issues - -- If a schema has a "kind" field and its set as enum, it generates a Kind alias that conflicts with the generated - Kind struct. -- Byte fields are existing in Go but not in TS, so the generator fails. -- **omitempty** is useful when we return things like json.RawMessage (alias of []byte) because Postgres saves this - information as `nil`, when MySQL and SQLite save it as `{}`. If we found it in the rest of the cases, it isn't necessary - to set `?` in the field in the schema. - -## Schema Attributes - -Grafana’s schema system relies on [CUE attributes](https://cuelang.org/docs/references/spec/#attributes)declared on -properties within schemas to control some aspects of code generation behavior. -In a schema, an attribute is the whole of `@cuetsy(kind=”type”)`: - -```Cue -field: string @cuetsy(kind="type") -``` - -CUE attributes are purely informational - they cannot influence CUE evaluation behavior, including the types being -expressed in a Thema schema. - -CUE attributes have three parts. In `@cuetsy(kind=”type”)`, those are: - -- name - `@cuetsy` -- arg - `kind` -- argval - `“type”` - -Any given attribute may consist of `{name}`, `{name,arg}`, or `{name,arg,argval}`. These three levels form a tree -(meaning of any argval is specific to its arg, which is specific to its name). The following documentation represents -this tree using a header hierarchy. - -### @cuetsy - -These attributes control the behavior of the [cuetsy code generator](https://github.com/grafana/cuetsy), which converts -CUE to TypeScript. We include only the kind arg here for brevity; cuetsy’s README has the canonical documentation on all -supported args and argvals, and their intended usage. - -Notes: - -- Only top-level fields in a Thema schema are scanned for `@cuetsy` attributes. -- Grafana’s code generators hardcode that an interface (`@cuetsy(kind=”interface”)`) is generated to represent the root - schema object, unless it is known to be a [grouped lineage](https://docs.google.com/document/d/13Rv395_T8WTLBgdL-2rbXKu0fx_TW-Q9yz9x6oBjm6g/edit#heading=h.vx7stzpxtw4t). - -#### kind - -Indicates the kind of TypeScript symbol that should be generated for that schema field. - -#### interface - -Generate the schema field as a TS interface. Field must be struct-kinded. - -#### enum - -Generate the schema field as a TS enum. Field must be either int-kinded (numeric enums) or string-kinded (string enums). - -#### type - -Generate the schema field as a TS type alias. - -### @grafana - -These attributes control code generation behaviors that are specific to Grafana core. Some may also be supported -in plugin code generators. - -#### TSVeneer - -Applying a TSVeneer arg to a field in a schema indicates that the schema author wants to enrich the generated type -(for example by adding generic type parameters), so code generation should expect a handwritten -[veneer](https://docs.google.com/document/d/13Rv395_T8WTLBgdL-2rbXKu0fx_TW-Q9yz9x6oBjm6g/edit#heading=h.bmtjq0bb1yxp). - -TSVeneer requires at least one argval, each of which impacts TypeScript code generation in its own way. -Multiple argvals may be given, separated by `|`. - -A TSVeneer arg has no effect if it is applied to a field that is not exported as a standalone TypeScript type -(which usually means a CUE field that also has an `@cuetsy(kind=)` attribute). - -#### type - -A handwritten veneer is needed to refine the raw generated TypeScript type, for example by adding generics. -See [the dashboard types veneer](https://github.com/grafana/grafana/blob/5f93e67419e9587363d1fc1e6f1f4a8044eb54d0/packages/grafana-schema/src/veneer/dashboard.types.ts) -for an example, and [some](https://github.com/grafana/grafana/blob/5f93e67419e9587363d1fc1e6f1f4a8044eb54d0/kinds/dashboard/dashboard_kind.cue#L12) -[corresponding](https://github.com/grafana/grafana/blob/5f93e67419e9587363d1fc1e6f1f4a8044eb54d0/kinds/dashboard/dashboard_kind.cue#L143) -CUE attributes. - -### @grafanamaturity - -These attributes are used to support iterative development of a schema towards maturity. - -Grafana code generators and CI enforce that schemas marked as mature MUST NOT have any `@grafanamaturity` attributes. - -#### NeedsExpertReview - -Indicates that a non-expert on that schema wrote the field, and was not fully confident in its type and/or docs. - -Primarily useful on very large schemas, like the dashboard schema, for getting _something_ written down for a given -field that at least makes validation tests pass, but making clear that the field isn’t necessarily properly correct. - -No argval is accepted. (Use a `//` comment to say more about the attention that’s needed.) diff --git a/docs/sources/developers/plugins/plugin.schema.json b/docs/sources/developers/plugins/plugin.schema.json index 41ee3c2b3f19c..4947f96e0f242 100644 --- a/docs/sources/developers/plugins/plugin.schema.json +++ b/docs/sources/developers/plugins/plugin.schema.json @@ -272,6 +272,10 @@ "description": "The minimum role a user must have to see this page in the navigation menu.", "enum": ["Admin", "Editor", "Viewer"] }, + "action": { + "type": "string", + "description": "The RBAC action a user must have to see this page in the navigation menu." + }, "path": { "type": "string", "description": "Used for app plugins." @@ -353,6 +357,10 @@ "reqRole": { "type": "string" }, + "reqAction": { + "type": "string", + "description": "The RBAC action a user must have to use this route." + }, "headers": { "type": "array", "description": "For data source plugins. Route headers adds HTTP headers to the proxied request." @@ -497,31 +505,53 @@ } } } - }, - "impersonation": { - "type": "object", - "description": "Impersonation describes the permissions that the plugin will be restricted to when acting on behalf of the user.", - "properties": { - "groups": { - "type": "boolean", - "description": "Groups allows the service to list the impersonated user's teams." - }, - "permissions": { - "type": "array", - "description": "Permissions are the permissions that the plugin needs when impersonating a user. The intersection of this set with the impersonated user's permission guarantees that the client will not gain more privileges than the impersonated user has.", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "action": { - "type": "string" - }, - "scope": { - "type": "string" + } + } + }, + "roles": { + "type": "array", + "description": "List of RBAC roles and their default assignments.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "role": { + "type": "object", + "description": "RBAC role definition to bundle related RBAC permissions on the plugin.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Display name of the role." + }, + "description": { + "type": "string", + "description": "Describe the aim of the role." + }, + "permissions": { + "type": "array", + "description": "RBAC permission on the plugin.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "scope": { + "type": "string" + } } } } } + }, + "grants": { + "type": "array", + "description": "Default assignments of the role to Grafana basic roles (Viewer, Editor, Admin, Grafana Admin)", + "items": { + "type": "string" + } } } } diff --git a/docs/sources/explore/_index.md b/docs/sources/explore/_index.md index 92257239d952b..640c9785aa2c6 100644 --- a/docs/sources/explore/_index.md +++ b/docs/sources/explore/_index.md @@ -29,6 +29,8 @@ If you just want to explore your data and do not want to create a dashboard, the ## Start exploring +{{< youtube id="1q3YzX2DDM4" >}} + > Refer to [Role-based access Control]({{< relref "../administration/roles-and-permissions/access-control/" >}}) in Grafana Enterprise to understand how you can manage Explore with role-based permissions. In order to access Explore, you must have an editor or an administrator role, unless the [viewers_can_edit option]({{< relref "../setup-grafana/configure-grafana/#viewers_can_edit" >}}) is enabled. Refer to [About users and permissions]({{< relref "../administration/roles-and-permissions/" >}}) for more information on what each role has access to. diff --git a/docs/sources/explore/logs-integration.md b/docs/sources/explore/logs-integration.md index 703c87829ed75..92ffc6eabe7aa 100644 --- a/docs/sources/explore/logs-integration.md +++ b/docs/sources/explore/logs-integration.md @@ -21,6 +21,7 @@ Explore is a powerful tool for logging and log analysis. It allows you to invest - [Cloudwatch]({{< relref "../datasources/aws-cloudwatch" >}}) - [InfluxDB]({{< relref "../datasources/influxdb" >}}) - [Azure Monitor]({{< relref "../datasources/azure-monitor" >}}) +- [ClickHouse](https://github.com/grafana/clickhouse-datasource) With Explore, you can efficiently monitor, troubleshoot, and respond to incidents by analyzing your logs and identifying the root causes. It also helps you to correlate logs with other telemetry signals such as metrics, traces or profiles, by viewing them side-by-side. diff --git a/docs/sources/explore/trace-integration.md b/docs/sources/explore/trace-integration.md index dad3dbf68f6ff..59408b5676502 100644 --- a/docs/sources/explore/trace-integration.md +++ b/docs/sources/explore/trace-integration.md @@ -23,6 +23,7 @@ Supported data sources are: - [Zipkin]({{< relref "../datasources/zipkin/" >}}) - [X-Ray](https://grafana.com/grafana/plugins/grafana-x-ray-datasource) - [Azure Monitor Application Insights]({{< relref "../datasources/azure-monitor/" >}}) +- [ClickHouse](https://github.com/grafana/clickhouse-datasource) For information on how to configure queries for the data sources listed above, refer to the documentation for specific data source. @@ -38,6 +39,7 @@ For information on querying each data source, refer to their documentation: - [Jaeger query editor]({{< relref "../datasources/jaeger/#query-the-data-source" >}}) - [Zipkin query editor]({{< relref "../datasources/zipkin/#query-the-data-source" >}}) - [Azure Monitor Application Insights query editor]({{< relref "../datasources/azure-monitor/query-editor/#query-application-insights-traces" >}}) +- [ClickHouse query editor](https://clickhouse.com/docs/en/integrations/grafana/query-builder#traces) ## Trace view @@ -117,12 +119,10 @@ You can navigate from a span in a trace view directly to metrics relevant for th ### Trace to profiles -{{< docs/experimental product="Trace to profiles" featureFlag="traceToProfiles" >}} - Using Trace to profiles, you can use Grafana’s ability to correlate different signals by adding the functionality to link between traces and profiles. Refer to the [relevant documentation](/docs/grafana/latest/datasources/tempo/configure-tempo-data-source#trace-to-profiles) for configuration instructions. -![Selecting a link in the span queries the profile data source](/static/img/docs/tempo/profiles/tempo-profiles-Span-link-profile-data-source.png) +{{< figure src="/static/img/docs/tempo/profiles/tempo-trace-to-profile.png" max-width="900px" class="docs-image--no-shadow" alt="Selecting a link in the span queries the profile data source" >}} ## Node graph diff --git a/docs/sources/fundamentals/intro-histograms/index.md b/docs/sources/fundamentals/intro-histograms/index.md index 4b78139e3910e..21b1c85e866b7 100644 --- a/docs/sources/fundamentals/intro-histograms/index.md +++ b/docs/sources/fundamentals/intro-histograms/index.md @@ -32,7 +32,7 @@ and the bar height represents the frequency (such as count) of values that fell This _histogram_ shows the value distribution of a couple of time series. You can easily see that most values land between 240-300 with a peak between 260-280. -![](/static/img/docs/v43/heatmap_histogram.png) +![Histogram example](/static/img/docs/v43/heatmap_histogram.png) Here is an example showing height distribution of people. @@ -48,7 +48,7 @@ A _heatmap_ is like a histogram, but over time, where each time slice represents In this example, you can clearly see what values are more common and how they trend over time. -![](/static/img/docs/v43/heatmap_histogram_over_time.png) +![Heatmap example](/static/img/docs/v43/heatmap_histogram_over_time.png) For more information about heatmap visualization options, refer to [Heatmap][heatmap]. diff --git a/docs/sources/fundamentals/intro-to-prometheus/index.md b/docs/sources/fundamentals/intro-to-prometheus/index.md index 3217abc965cfd..6fa0023c62f9f 100644 --- a/docs/sources/fundamentals/intro-to-prometheus/index.md +++ b/docs/sources/fundamentals/intro-to-prometheus/index.md @@ -1,6 +1,7 @@ --- aliases: - ../basics/timeseries/ + - /docs/grafana-cloud/introduction/prometheus/ description: Introduction to Prometheus keywords: - grafana diff --git a/docs/sources/fundamentals/timeseries-dimensions/index.md b/docs/sources/fundamentals/timeseries-dimensions/index.md index aeaa2182942b7..3e1e73d502273 100644 --- a/docs/sources/fundamentals/timeseries-dimensions/index.md +++ b/docs/sources/fundamentals/timeseries-dimensions/index.md @@ -3,6 +3,7 @@ aliases: - ../basics/timeseries-dimensions/ - ../getting-started/timeseries-dimensions/ - ../guides/timeseries-dimensions/ + - /docs/grafana-cloud/introduction/timeseries-dimensions/ description: time series dimensions keywords: - grafana @@ -28,7 +29,7 @@ In [Introduction to time series][time-series-databases], the concept of _labels_ With time series data, the data often contain more than a single series, and is a set of multiple time series. Many Grafana data sources support this type of data. -{{< figure src="/static/img/docs/example_graph_multi_dim.png" class="docs-image--no-shadow" max-width="850px" >}} +{{< figure src="/static/img/docs/example_graph_multi_dim.png" class="docs-image--no-shadow" max-width="850px" alt="Temperature by location" >}} The common case is issuing a single query for a measurement with one or more additional properties as dimensions. For example, querying a temperature measurement along with a location property. In this case, multiple series are returned back from that single query and each series has unique location as a dimension. diff --git a/docs/sources/fundamentals/timeseries/index.md b/docs/sources/fundamentals/timeseries/index.md index f95f6e6aa416a..aaa0df85d3c9a 100644 --- a/docs/sources/fundamentals/timeseries/index.md +++ b/docs/sources/fundamentals/timeseries/index.md @@ -1,6 +1,7 @@ --- aliases: - ../basics/timeseries/ + - /docs/grafana-cloud/introduction/timeseries/ description: Introduction to time series keywords: - grafana @@ -31,7 +32,7 @@ Temperature data like this is one example of what we call a _time series_ — a Tables are useful when you want to identify individual measurements, but they make it difficult to see the big picture. A more common visualization for time series is the _graph_, which instead places each measurement along a time axis. Visual representations like the graph make it easier to discover patterns and features of the data that otherwise would be difficult to see. -{{< figure src="/static/img/docs/example_graph.png" class="docs-image--no-shadow" max-width="850px" >}} +{{< figure src="/static/img/docs/example_graph.png" class="docs-image--no-shadow" max-width="850px" alt="Temperature data displayed on dashboard" >}} Temperature data like the one in the example, is far from the only example of a time series. Other examples of time series are: diff --git a/docs/sources/getting-started/build-first-dashboard.md b/docs/sources/getting-started/build-first-dashboard.md index 49dda7915a37a..fd76142ab5fe3 100644 --- a/docs/sources/getting-started/build-first-dashboard.md +++ b/docs/sources/getting-started/build-first-dashboard.md @@ -77,7 +77,7 @@ Congratulations, you have created your first dashboard and it's displaying resul #### Next steps -Continue to experiment with what you have built, try the [explore workflow]({{< relref "../explore" >}}) or another visualization feature. Refer to [Data sources]({{< relref "../datasources" >}}) for a list of supported data sources and instructions on how to [add a data source]({{< relref "../administration/data-source-management#add-a-data-source" >}}). The following topics will be of interest to you: +Continue to experiment with what you have built, try the [explore workflow]({{< relref "../explore" >}}) or another visualization feature. Refer to [Data sources]({{< relref "../datasources" >}}) for a list of supported data sources and instructions on how to [add a data source]({{< relref "../datasources#add-a-data-source" >}}). The following topics will be of interest to you: - [Panels and visualizations]({{< relref "../panels-visualizations" >}}) - [Dashboards]({{< relref "../dashboards" >}}) diff --git a/docs/sources/getting-started/get-started-grafana-influxdb.md b/docs/sources/getting-started/get-started-grafana-influxdb.md index c257014ae35c2..d6a163a1219a4 100644 --- a/docs/sources/getting-started/get-started-grafana-influxdb.md +++ b/docs/sources/getting-started/get-started-grafana-influxdb.md @@ -38,7 +38,7 @@ Windows users might need to make additional adjustments. Look for special instru You can have more than one InfluxDB data source defined in Grafana. -1. Follow the general instructions to [add a data source]({{< relref "../administration/data-source-management#add-a-data-source" >}}). +1. Follow the general instructions to [add a data source]({{< relref "../datasources#add-a-data-source" >}}). 1. Decide if you will use InfluxQL or Flux as your query language. - [Configure the data source]({{< relref "../datasources/influxdb#configure-the-data-source" >}}) for your chosen query language. Each query language has its own unique data source settings. diff --git a/docs/sources/introduction/grafana-enterprise.md b/docs/sources/introduction/grafana-enterprise.md index 2de825d6c5777..234cca9bcf1b1 100644 --- a/docs/sources/introduction/grafana-enterprise.md +++ b/docs/sources/introduction/grafana-enterprise.md @@ -21,6 +21,8 @@ To learn more about Grafana Enterprise, refer to [our product page](/enterprise) Many Grafana Enterprise features are also available in [Grafana Cloud](/docs/grafana-cloud) Free, Pro, and Advanced accounts. For details, refer to [the Grafana Cloud features table](/pricing/#featuresTable). +To migrate to Grafana Cloud, refer to [Migrate from Grafana Enterprise to Grafana Cloud](/docs/grafana-cloud/account-management/e2c-guide/). + ## Authentication Grafana Enterprise includes integrations with more ways to authenticate your users and enhanced authentication capabilities. @@ -85,6 +87,7 @@ With a Grafana Enterprise license, you also get access to premium data sources, - [MongoDB](/grafana/plugins/grafana-mongodb-datasource) - [New Relic](/grafana/plugins/grafana-newrelic-datasource) - [Oracle Database](/grafana/plugins/grafana-oracle-datasource) +- [PagerDuty](/grafana/plugins/grafana-pagerduty-datasource) - [Salesforce](/grafana/plugins/grafana-salesforce-datasource) - [SAP HANA®](/grafana/plugins/grafana-saphana-datasource) - [ServiceNow](/grafana/plugins/grafana-servicenow-datasource) diff --git a/docs/sources/old-alerting/_index.md b/docs/sources/old-alerting/_index.md deleted file mode 100644 index 12dd7a2687f18..0000000000000 --- a/docs/sources/old-alerting/_index.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -draft: true -labels: - products: - - enterprise - - oss -title: Legacy Grafana alerts -weight: 114 ---- - -# Legacy Grafana alerts - -Grafana Alerting is enabled by default for new OSS installations. For older installations, it is still an [opt-in]({{< relref "../alerting/migrating-alerts/opt-in" >}}) feature. - -{{% admonition type="note" %}} -Legacy dashboard alerts are deprecated and will be removed in Grafana 9. We encourage you to migrate to [Grafana Alerting]({{< relref "../alerting/migrating-alerts" >}}) for all existing installations. -{{% /admonition %}} - -Legacy dashboard alerts have two main components: - -- Alert rule - When the alert is triggered. Alert rules are defined by one or more conditions that are regularly evaluated by Grafana. -- Notification channel - How the alert is delivered. When the conditions of an alert rule are met, the Grafana notifies the channels configured for that alert. - -## Alert tasks - -You can perform the following tasks for alerts: - -- [Create an alert rule]({{< relref "./create-alerts" >}}) -- [View existing alert rules and their current state]({{< relref "./view-alerts" >}}) -- [Test alert rules and troubleshoot]({{< relref "./troubleshoot-alerts" >}}) -- [Add or edit an alert contact point]({{< relref "./notifications" >}}) - -{{< docs/shared lookup="alerts/grafana-managed-alerts.md" source="grafana" version="<GRAFANA VERSION>" >}} diff --git a/docs/sources/old-alerting/add-notification-template.md b/docs/sources/old-alerting/add-notification-template.md deleted file mode 100644 index f2cea0cac274e..0000000000000 --- a/docs/sources/old-alerting/add-notification-template.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -aliases: - - ../alerting/add-notification-template/ -draft: true -keywords: - - grafana - - documentation - - alerting - - alerts - - notification - - templating -labels: - products: - - enterprise - - oss -title: Alert notification templating -weight: 110 ---- - -# Alert notification templating - -You can provide detailed information to alert notification recipients by injecting alert query data into an alert notification. This topic explains how you can use alert query labels in alert notifications. - -You can use labels generated during an alerting query evaluation to create alert notification messages. For multiple unique values for the same label, the values are comma-separated. - -When an alert fires, the alerting data series indicates the violation. For resolved alerts, all data series are included in the resolved notification. - -This topic explains how you can use alert query labels in alert notifications. - -## Adding alert label data into your alert notification - -1. Navigate to the panel you want to add or edit an alert rule for. -1. Click on the panel title, and then click **Edit**. -1. On the Alert tab, click **Create Alert**. If an alert already exists for this panel, then you can edit the alert directly. -1. Refer to the alert query labels in the alert rule name and/or alert notification message field by using the `${Label}` syntax. -1. Click **Save** in the upper right corner to save the alert rule and the dashboard. - -![Alerting notification template](/static/img/docs/alerting/alert-notification-template-7-4.png) diff --git a/docs/sources/old-alerting/create-alerts.md b/docs/sources/old-alerting/create-alerts.md deleted file mode 100644 index f60f8afebf1fe..0000000000000 --- a/docs/sources/old-alerting/create-alerts.md +++ /dev/null @@ -1,138 +0,0 @@ ---- -aliases: - - ../alerting/create-alerts/ -description: Configure alert rules -draft: true -keywords: - - grafana - - alerting - - guide - - rules -labels: - products: - - enterprise - - oss -title: Create alerts -weight: 200 ---- - -# Create alerts - -Grafana Alerting allows you to attach rules to your dashboard panels. When you save the dashboard, Grafana extracts the alert rules into a separate alert rule storage and schedules them for evaluation. - -![Alerting overview](/static/img/docs/alerting/drag_handles_gif.gif) - -In the Alert tab of the graph panel you can configure how often the alert rule should be evaluated and the conditions that need to be met for the alert to change state and trigger its [notifications]({{< relref "./notifications" >}}). - -Currently only the graph panel supports alert rules. - -## Add or edit an alert rule - -1. Navigate to the panel you want to add or edit an alert rule for, click the title, and then click **Edit**. -1. On the Alert tab, click **Create Alert**. If an alert already exists for this panel, then you can just edit the fields on the Alert tab. -1. Fill out the fields. Descriptions are listed below in [Alert rule fields](#alert-rule-fields). -1. When you have finished writing your rule, click **Save** in the upper right corner to save alert rule and the dashboard. -1. (Optional but recommended) Click **Test rule** to make sure the rule returns the results you expect. - -## Delete an alert - -To delete an alert, scroll to the bottom of the alert and then click **Delete**. - -## Alert rule fields - -This section describes the fields you fill out to create an alert. - -### Rule - -- **Name -** Enter a descriptive name. The name will be displayed in the Alert Rules list. This field supports [templating]({{< relref "./add-notification-template" >}}). -- **Evaluate every -** Specify how often the scheduler should evaluate the alert rule. This is referred to as the _evaluation interval_. -- **For -** Specify how long the query needs to violate the configured thresholds before the alert notification triggers. - -You can set a minimum evaluation interval in the `alerting.min_interval_seconds` configuration field, to set a minimum time between evaluations. Refer to [Configuration]({{< relref "../setup-grafana/configure-grafana#min_interval_seconds" >}}) for more information. - -{{% admonition type="caution" %}} -Do not use `For` with the `If no data or all values are null` setting set to `No Data`. The triggering of `No Data` will trigger instantly and not take `For` into consideration. This may also result in that an OK notification not being sent if alert transitions from `No Data -Pending -OK`. -{{% /admonition %}} - -If an alert rule has a configured `For` and the query violates the configured threshold, then it will first go from `OK` to `Pending`. Going from `OK` to `Pending` Grafana will not send any notifications. Once the alert rule has been firing for more than `For` duration, it will change to `Alerting` and send alert notifications. - -Typically, it's always a good idea to use this setting since it's often worse to get false positive than wait a few minutes before the alert notification triggers. Looking at the `Alert list` or `Alert list panels` you will be able to see alerts in pending state. - -Below you can see an example timeline of an alert using the `For` setting. At ~16:04 the alert state changes to `Pending` and after 4 minutes it changes to `Alerting` which is when alert notifications are sent. Once the series falls back to normal the alert rule goes back to `OK`. -{{< figure class="float-right" src="/static/img/docs/v54/alerting-for-dark-theme.png" caption="Alerting For" >}} - -{{< figure class="float-right" max-width="40%" src="/static/img/docs/v4/alerting_conditions.png" caption="Alerting Conditions" >}} - -### Conditions - -Currently the only condition type that exists is a `Query` condition that allows you to -specify a query letter, time range and an aggregation function. - -#### Query condition example - -```sql -avg() OF query(A, 15m, now) IS BELOW 14 -``` - -- `avg()` Controls how the values for **each** series should be reduced to a value that can be compared against the threshold. Click on the function to change it to another aggregation function. -- `query(A, 15m, now)` The letter defines what query to execute from the **Metrics** tab. The second two parameters define the time range, `15m, now` means 15 minutes ago to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes ago to 2 minutes ago. This is useful if you want to ignore the last 2 minutes of data. -- `IS BELOW 14` Defines the type of threshold and the threshold value. You can click on `IS BELOW` to change the type of threshold. - -The query used in an alert rule cannot contain any template variables. Currently we only support `AND` and `OR` operators between conditions and they are executed serially. -For example, we have 3 conditions in the following order: -_condition:A(evaluates to: TRUE) OR condition:B(evaluates to: FALSE) AND condition:C(evaluates to: TRUE)_ -so the result will be calculated as ((TRUE OR FALSE) AND TRUE) = TRUE. - -We plan to add other condition types in the future, like `Other Alert`, where you can include the state of another alert in your conditions, and `Time Of Day`. - -#### Multiple Series - -If a query returns multiple series, then the aggregation function and threshold check will be evaluated for each series. What Grafana does not do currently is track alert rule state **per series**. This has implications that are detailed in the scenario below. - -- Alert condition with query that returns 2 series: **server1** and **server2** -- **server1** series causes the alert rule to fire and switch to state `Alerting` -- Notifications are sent out with message: _load peaking (server1)_ -- In a subsequent evaluation of the same alert rule, the **server2** series also causes the alert rule to fire -- No new notifications are sent as the alert rule is already in state `Alerting`. - -So, as you can see from the above scenario Grafana will not send out notifications when other series cause the alert to fire if the rule already is in state `Alerting`. To improve support for queries that return multiple series we plan to track state **per series** in a future release. - -> Starting with Grafana v5.3 you can configure reminders to be sent for triggered alerts. This will send additional notifications -> when an alert continues to fire. If other series (like server2 in the example above) also cause the alert rule to fire they will be included in the reminder notification. Depending on what notification channel you're using you may be able to take advantage of this feature for identifying new/existing series causing alert to fire. - -### No Data & Error Handling - -Below are conditions you can configure how the rule evaluation engine should handle queries that return no data or only null values. - -| No Data Option | Description | -| --------------- | ------------------------------------------------------------------------------------------ | -| No Data | Set alert rule state to `NoData` | -| Alerting | Set alert rule state to `Alerting` | -| Keep Last State | Keep the current alert rule state, whatever it is. | -| Ok | Not sure why you would want to send yourself an alert when things are okay, but you could. | - -### Execution errors or timeouts - -Tell Grafana how to handle execution or timeout errors. - -| Error or timeout option | Description | -| ----------------------- | -------------------------------------------------- | -| Alerting | Set alert rule state to `Alerting` | -| Keep Last State | Keep the current alert rule state, whatever it is. | - -If you have an unreliable time series store from which queries sometime timeout or fail randomly you can set this option to `Keep Last State` in order to basically ignore them. - -## Notifications - -In alert tab you can also specify alert rule notifications along with a detailed message about the alert rule. The message can contain anything, information about how you might solve the issue, link to runbook, and so on. - -The actual notifications are configured and shared between multiple alerts. Read -[Alert notifications]({{< relref "./notifications" >}}) for information on how to configure and set up notifications. - -- **Send to -** Select an alert notification channel if you have one set up. -- **Message -** Enter a text message to be sent on the notification channel. Some alert notifiers support transforming the text to HTML or other rich formats. This field supports [templating]({{< relref "./add-notification-template" >}}). -- **Tags -** Specify a list of tags (key/value) to be included in the notification. It is only supported by [some notifiers]({{< relref "./notifications#list-of-supported-notifiers" >}}). - -## Alert state history and annotations - -Alert state changes are recorded in the internal annotation table in Grafana's database. The state changes are visualized as annotations in the alert rule's graph panel. You can also go into the `State history` submenu in the alert tab to view and clear state history. diff --git a/docs/sources/old-alerting/notifications.md b/docs/sources/old-alerting/notifications.md deleted file mode 100644 index c450f8cbdb002..0000000000000 --- a/docs/sources/old-alerting/notifications.md +++ /dev/null @@ -1,302 +0,0 @@ ---- -aliases: - - ../alerting/notifications/ -description: Alerting notifications guide -draft: true -keywords: - - Grafana - - alerting - - guide - - notifications -labels: - products: - - enterprise - - oss -title: Alert notifications -weight: 100 ---- - -# Alert notifications - -When an alert changes state, it sends out notifications. Each alert rule can have -multiple notifications. In order to add a notification to an alert rule you first need -to add and configure a `notification` channel (can be email, PagerDuty, or other integration). - -This is done from the Notification channels page. - -{{% admonition type="note" %}} -Alerting is only available in Grafana v4.0 and above. -{{% /admonition %}} - -## Add a notification channel - -1. In the Grafana side bar, hover your cursor over the **Alerting** (bell) icon and then click **Notification channels**. -1. Click **Add channel**. -1. Fill out the fields or select options described below. - -## New notification channel fields - -### Default (send on all alerts) - -- **Name -** Enter a name for this channel. It will be displayed when users add notifications to alert rules. -- **Type -** Select the channel type. Refer to the [List of supported notifiers](#list-of-supported-notifiers) for details. -- **Default (send on all alerts) -** When selected, this option sends a notification on this channel for all alert rules. -- **Include Image -** See [Enable images in notifications](#enable-images-in-notifications-external-image-store) for details. -- **Disable Resolve Message -** When selected, this option disables the resolve message [OK] that is sent when the alerting state returns to false. -- **Send reminders -** When this option is checked additional notifications (reminders) will be sent for triggered alerts. You can specify how often reminders should be sent using number of seconds (s), minutes (m) or hours (h), for example `30s`, `3m`, `5m` or `1h`. - -**Important:** Alert reminders are sent after rules are evaluated. Therefore a reminder can never be sent more frequently than a configured alert rule evaluation interval. - -These examples show how often and when reminders are sent for a triggered alert. - -| Alert rule evaluation interval | Send reminders every | Reminder sent every (after last alert notification) | -| ------------------------------ | -------------------- | --------------------------------------------------- | -| `30s` | `15s` | ~30 seconds | -| `1m` | `5m` | ~5 minutes | -| `5m` | `15m` | ~15 minutes | -| `6m` | `20m` | ~24 minutes | -| `1h` | `15m` | ~1 hour | -| `1h` | `2h` | ~2 hours | - -<div class="clearfix"></div> - -## List of supported notifiers - -| Name | Type | Supports images | Supports alert rule tags | -| --------------------------------------------- | ------------------------- | ------------------ | ------------------------ | -| [DingDing](#dingdingdingtalk) | `dingding` | yes, external only | no | -| [Discord](#discord) | `discord` | yes | no | -| [Email](#email) | `email` | yes | no | -| [Google Hangouts Chat](#google-hangouts-chat) | `googlechat` | yes, external only | no | -| Hipchat | `hipchat` | yes, external only | no | -| [Kafka](#kafka) | `kafka` | yes, external only | no | -| Line | `line` | yes, external only | no | -| Microsoft Teams | `teams` | yes, external only | no | -| [Opsgenie](#opsgenie) | `opsgenie` | yes, external only | yes | -| [Pagerduty](#pagerduty) | `pagerduty` | yes, external only | yes | -| Prometheus Alertmanager | `prometheus-alertmanager` | yes, external only | yes | -| [Pushover](#pushover) | `pushover` | yes | no | -| Sensu | `sensu` | yes, external only | no | -| [Sensu Go](#sensu-go) | `sensugo` | yes, external only | no | -| [Slack](#slack) | `slack` | yes | no | -| Telegram | `telegram` | yes | no | -| Threema | `threema` | yes, external only | no | -| VictorOps | `victorops` | yes, external only | yes | -| [Webhook](#webhook) | `webhook` | yes, external only | yes | - -### Email - -To enable email notifications you have to set up [SMTP settings]({{< relref "../setup-grafana/configure-grafana#smtp" >}}) -in the Grafana config. Email notifications will upload an image of the alert graph to an -external image destination if available or fallback to attaching the image to the email. -Be aware that if you use the `local` image storage email servers and clients might not be -able to access the image. - -{{% admonition type="note" %}} -Template variables are not supported in email alerts. -{{% /admonition %}} - -| Setting | Description | -| ------------ | -------------------------------------------------------------------------------------------- | -| Single email | Send a single email to all recipients. Disabled per default. | -| Addresses | Email addresses to recipients. You can enter multiple email addresses using a ";" separator. | - -### Slack - -{{< figure class="float-right" max-width="40%" src="/static/img/docs/v4/slack_notification.png" caption="Alerting Slack Notification" >}} - -To set up Slack, you need to configure an incoming Slack webhook URL. You can follow -[Sending messages using Incoming Webhooks](https://api.slack.com/incoming-webhooks) on how to do that. If you want to include screenshots of the -firing alerts in the Slack messages you have to configure either the [external image destination](#enable-images-in-notifications-external-image-store) -in Grafana or a bot integration via Slack Apps. [Follow Slack's guide to set up a bot integration](https://api.slack.com/bot-users) and use the token -provided, which starts with "xoxb". - -| Setting | Description | -| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Url | Slack incoming webhook URL, or eventually the [chat.postMessage](https://api.slack.com/methods/chat.postMessage) Slack API endpoint. | -| Username | Set the username for the bot's message. | -| Recipient | Allows you to override the Slack recipient. You must either provide a channel Slack ID, a user Slack ID, a username reference (@<user>, all lowercase, no whitespace), or a channel reference (#<channel>, all lowercase, no whitespace). If you use the `chat.postMessage` Slack API endpoint, this is required. | -| Icon emoji | Provide an emoji to use as the icon for the bot's message. Ex :smile: | -| Icon URL | Provide a URL to an image to use as the icon for the bot's message. | -| Mention Users | Optionally mention one or more users in the Slack notification sent by Grafana. You have to refer to users, comma-separated, via their corresponding Slack IDs (which you can find by clicking the overflow button on each user's Slack profile). | -| Mention Groups | Optionally mention one or more groups in the Slack notification sent by Grafana. You have to refer to groups, comma-separated, via their corresponding Slack IDs (which you can get from each group's Slack profile URL). | -| Mention Channel | Optionally mention either all channel members or just active ones. | -| Token | If provided, Grafana will upload the generated image via Slack's file.upload API method, not the external image destination. If you use the `chat.postMessage` Slack API endpoint, this is required. | - -If you are using the token for a slack bot, then you have to invite the bot to the channel you want to send notifications and add the channel to the recipient field. - -### Opsgenie - -To setup Opsgenie you will need an API Key and the Alert API Url. These can be obtained by configuring a new [Grafana Integration](https://docs.opsgenie.com/docs/grafana-integration). - -| Setting | Description | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Alert API URL | The API URL for your Opsgenie instance. This will normally be either `https://api.opsgenie.com` or, for EU customers, `https://api.eu.opsgenie.com`. | -| API Key | The API Key as provided by Opsgenie for your configured Grafana integration. | -| Override priority | Configures the alert priority using the `og_priority` tag. The `og_priority` tag must have one of the following values: `P1`, `P2`, `P3`, `P4`, or `P5`. Default is `False`. | -| Send notification tags as | Specify how you would like [Notification Tags]({{< relref "./create-alerts#notifications" >}}) delivered to Opsgenie. They can be delivered as `Tags`, `Extra Properties` or both. Default is Tags. See note below for more information. | - -{{% admonition type="note" %}} -When notification tags are sent as `Tags` they are concatenated into a string with a `key:value` format. If you prefer to receive the notifications tags as key/values under Extra Properties in Opsgenie then change the `Send notification tags as` to either `Extra Properties` or `Tags & Extra Properties`. -{{% /admonition %}} - -### PagerDuty - -To set up PagerDuty, all you have to do is to provide an integration key. - -| Setting | Description | -| ---------------------- | ----------------------------------------------------------------------------------------------- | -| Integration Key | Integration key for PagerDuty. | -| Severity | Level for dynamic notifications, default is `critical` (1) | -| Auto resolve incidents | Resolve incidents in PagerDuty once the alert goes back to ok | -| Message in details | Removes the Alert message from the PD summary field and puts it into custom details instead (2) | - -> **Note:** The tags `Severity`, `Class`, `Group`, `dedup_key`, and `Component` have special meaning in the [Pagerduty Common Event Format - PD-CEF](https://support.pagerduty.com/docs/pd-cef). If an alert panel defines these tag keys, then they are transposed to the root of the event sent to Pagerduty. This means they will be available within the Pagerduty UI and Filtering tools. A Severity tag set on an alert overrides the global Severity set on the notification channel if it's a valid level. - -> Using Message In Details will change the structure of the `custom_details` field in the PagerDuty Event. -> This might break custom event rules in your PagerDuty rules if you rely on the fields in `payload.custom_details`. -> Move any existing rules using `custom_details.myMetric` to `custom_details.queries.myMetric`. -> This behavior will become the default in a future version of Grafana. - -> **Note:** The `dedup_key` tag overrides the Grafana-generated `dedup_key` with a custom key. - -> **Note:** The `state` tag overrides the current alert state inside the `custom_details` payload. - -> **Note:** Grafana uses the `Events API V2` integration. This can be configured for each service. - -### VictorOps - -To configure VictorOps, provide the URL from the Grafana Integration and substitute `$routing_key` with a valid key. - -> **Note:** The tag `Severity` has special meaning in the [VictorOps Incident Fields](https://help.victorops.com/knowledge-base/incident-fields-glossary/). If an alert panel defines this key, then it replaces the `message_type` in the root of the event sent to VictorOps. - -### Pushover - -To set up Pushover, you must provide a user key and an API token. Refer to [What is Pushover and how do I use it](https://support.pushover.net/i7-what-is-pushover-and-how-do-i-use-it) for instructions on how to generate them. - -| Setting | Description | -| -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| API Token | Application token | -| User key(s) | A comma-separated list of user keys | -| Device(s) | A comma-separated list of devices | -| Priority | The priority alerting nottifications are sent | -| OK priority | The priority OK notifications are sent; if not set, then OK notifications are sent with the priority set for alerting notifications | -| Retry | How often (in seconds) the Pushover servers send the same notification to the user. (minimum 30 seconds) | -| Expire | How many seconds your notification will continue to be retried for (maximum 86400 seconds) | -| Alerting sound | The sound for alerting notifications | -| OK sound | The sound for OK notifications | - -### Webhook - -The webhook notification is a simple way to send information about a state change over HTTP to a custom endpoint. -Using this notification you could integrate Grafana into a system of your choosing. - -Example json body: - -```json -{ - "dashboardId": 1, - "evalMatches": [ - { - "value": 1, - "metric": "Count", - "tags": {} - } - ], - "imageUrl": "https://grafana.com/static/assets/img/blog/mixed_styles.png", - "message": "Notification Message", - "orgId": 1, - "panelId": 2, - "ruleId": 1, - "ruleName": "Panel Title alert", - "ruleUrl": "http://localhost:3000/d/hZ7BuVbWz/test-dashboard?fullscreen\u0026edit\u0026tab=alert\u0026panelId=2\u0026orgId=1", - "state": "alerting", - "tags": { - "tag name": "tag value" - }, - "title": "[Alerting] Panel Title alert" -} -``` - -- **state** - The possible values for alert state are: `ok`, `paused`, `alerting`, `pending`, `no_data`. - -### DingDing/DingTalk - -DingTalk supports the following "message type": `text`, `link` and `markdown`. Only the `link` message type is supported. Refer to the [configuration instructions](https://developers.dingtalk.com/document/app/custom-robot-access) in Chinese language. - -In DingTalk PC Client: - -1. Click "more" icon on upper right of the panel. - -2. Click "Robot Manage" item in the pop menu, there will be a new panel call "Robot Manage". - -3. In the "Robot Manage" panel, select "customized: customized robot with Webhook". - -4. In the next new panel named "robot detail", click "Add" button. - -5. In "Add Robot" panel, input a nickname for the robot and select a "message group" which the robot will join in. click "next". - -6. There will be a Webhook URL in the panel, looks like this: https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx. Copy this URL to the Grafana DingTalk setting page and then click "finish". - -### Discord - -To set up Discord, you must create a Discord channel webhook. For instructions on how to create the channel, refer to -[Intro to Webhooks](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks). - -| Setting | Description | -| ------------------------------ | ----------------------------------------------------------------------------------------------------- | -| Webhook URL | Discord webhook URL. | -| Message Content | Mention a group using @ or a user using <@ID> when notifying in a channel. | -| Avatar URL | Optionally, provide a URL to an image to use as the avatar for the bot's message. | -| Use Discord's Webhook Username | Use the username configured in Discord's webhook settings. Otherwise, the username will be 'Grafana.' | - -Alternately, use the [Slack](#slack) notifier by appending `/slack` to a Discord webhook URL. - -### Kafka - -Notifications can be sent to a Kafka topic from Grafana using the [Kafka REST Proxy](https://docs.confluent.io/1.0/kafka-rest/docs/index.html). -There are a couple of configuration options which need to be set up in Grafana UI under Kafka Settings: - -1. Kafka REST Proxy endpoint. - -1. Kafka Topic. - -Once these two properties are set, you can send the alerts to Kafka for further processing or throttling. - -### Google Hangouts Chat - -Notifications can be sent by setting up an incoming webhook in Google Hangouts chat. For more information about configuring a webhook, refer to [webhooks](https://developers.google.com/hangouts/chat/how-tos/webhooks). - -### Prometheus Alertmanager - -Alertmanager handles alerts sent by client applications such as Prometheus server or Grafana. It takes care of deduplicating, grouping, and routing them to the correct receiver. Grafana notifications can be sent to Alertmanager via a simple incoming webhook. Refer to the official [Prometheus Alertmanager documentation](https://prometheus.io/docs/alerting/alertmanager) for configuration information. - -{{% admonition type="caution" %}} -In case of a high-availability setup, do not load balance traffic between Grafana and Alertmanagers to keep coherence between all your Alertmanager instances. Instead, point Grafana to a list of all Alertmanagers, by listing their URLs comma-separated in the notification channel configuration. -{{% /admonition %}} - -### Sensu Go - -Grafana alert notifications can be sent to [Sensu](https://sensu.io) Go as events via the API. This operation requires an API key. For information on creating this key, refer to [Sensu Go documentation](https://docs.sensu.io/sensu-go/latest/operations/control-access/use-apikeys/#api-key-authentication). - -## Enable images in notifications {#external-image-store} - -Grafana can render the panel associated with the alert rule as a PNG image and include that in the notification. Read more about the requirements and how to configure -[image rendering]({{< relref "../setup-grafana/image-rendering" >}}). - -You must configure an [external image storage provider]({{< relref "../setup-grafana/configure-grafana#external_image_storage" >}}) in order to receive images in alert notifications. If your notification channel requires that the image be publicly accessible (e.g. Slack, PagerDuty), configure a provider which uploads the image to a remote image store like Amazon S3, Webdav, Google Cloud Storage, or Azure Blob Storage. Otherwise, the local provider can be used to serve the image directly from Grafana. - -Notification services which need public image access are marked as 'external only'. - -## Configure the link back to Grafana from alert notifications - -All alert notifications contain a link back to the triggered alert in the Grafana instance. -This URL is based on the [domain]({{< relref "../setup-grafana/configure-grafana#domain" >}}) setting in Grafana. - -## Notification templating - -{{% admonition type="note" %}} -Alert notification templating is only available in Grafana v7.4 and above. -{{% /admonition %}} - -The alert notification template feature allows you to take the [label]({{< relref "../fundamentals/timeseries-dimensions#labels" >}}) value from an alert query and [inject that into alert notifications]({{< relref "./add-notification-template" >}}). diff --git a/docs/sources/old-alerting/pause-an-alert-rule.md b/docs/sources/old-alerting/pause-an-alert-rule.md deleted file mode 100644 index 8cc675f7d6689..0000000000000 --- a/docs/sources/old-alerting/pause-an-alert-rule.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -aliases: - - ../alerting/pause-an-alert-rule/ -description: Pause an existing alert rule -draft: true -keywords: - - grafana - - alerting - - guide - - rules - - view -labels: - products: - - enterprise - - oss -title: Pause an alert rule -weight: 400 ---- - -# Pause an alert rule - -Pausing the evaluation of an alert rule can sometimes be useful. For example, during a maintenance window, pausing alert rules can avoid triggering a flood of alerts. - -1. In the Grafana side bar, hover your cursor over the Alerting (bell) icon and then click **Alert Rules**. All configured alert rules are listed, along with their current state. -1. Find your alert in the list, and click the **Pause** icon on the right. The **Pause** icon turns into a **Play** icon. -1. Click the **Play** icon to resume evaluation of your alert. diff --git a/docs/sources/old-alerting/troubleshoot-alerts.md b/docs/sources/old-alerting/troubleshoot-alerts.md deleted file mode 100644 index 053bc34690ffa..0000000000000 --- a/docs/sources/old-alerting/troubleshoot-alerts.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -aliases: - - ../alerting/troubleshoot-alerts/ -description: Troubleshoot alert rules -draft: true -keywords: - - grafana - - alerting - - guide - - rules - - troubleshoot -labels: - products: - - enterprise - - oss -title: Troubleshoot alerts -weight: 500 ---- - -# Troubleshoot alerts - -If alerts are not behaving as you expect, here are some steps you can take to troubleshoot and figure out what is going wrong. - -![Test Rule](/static/img/docs/v4/alert_test_rule.png) - -The first level of troubleshooting you can do is click **Test Rule**. You will get result back that you can expand to the point where you can see the raw data that was returned from your query. - -Further troubleshooting can also be done by inspecting the grafana-server log. If it's not an error or for some reason the log does not say anything you can enable debug logging for some relevant components. This is done in Grafana's ini config file. - -Example showing loggers that could be relevant when troubleshooting alerting. - -```ini -[log] -filters = alerting.scheduler:debug \ - alerting.engine:debug \ - alerting.resultHandler:debug \ - alerting.evalHandler:debug \ - alerting.evalContext:debug \ - alerting.extractor:debug \ - alerting.notifier:debug \ - alerting.notifier.slack:debug \ - alerting.notifier.pagerduty:debug \ - alerting.notifier.email:debug \ - alerting.notifier.webhook:debug \ - tsdb.graphite:debug \ - tsdb.prometheus:debug \ - tsdb.opentsdb:debug \ - tsdb.influxdb:debug \ - tsdb.elasticsearch:debug \ - tsdb.elasticsearch.client:debug \ -``` - -If you want to log raw query sent to your TSDB and raw response in log you also have to set grafana.ini option `app_mode` to `development`. diff --git a/docs/sources/old-alerting/view-alerts.md b/docs/sources/old-alerting/view-alerts.md deleted file mode 100644 index b927715685b83..0000000000000 --- a/docs/sources/old-alerting/view-alerts.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -aliases: - - ../alerting/view-alerts/ -description: View existing alert rules -draft: true -keywords: - - grafana - - alerting - - guide - - rules - - view -labels: - products: - - enterprise - - oss -menuTitle: View alerts -title: View existing alert rules -weight: 400 ---- - -# View existing alert rules - -Grafana stores individual alert rules in the panels where they are defined, but you can also view a list of all existing alert rules and their current state. - -In the Grafana side bar, hover your cursor over the Alerting (bell) icon and then click **Alert Rules**. All configured alert rules are listed, along with their current state. - -You can do several things while viewing alerts. - -- **Filter alerts by name -** Type an alert name in the **Search alerts** field. -- **Filter alerts by state -** In **States**, select which alert states you want to see. All others will be hidden. -- **Pause or resume an alert -** Click the **Pause** or **Play** icon next to the alert to pause or resume evaluation. See [Pause an alert rule]({{< relref "./pause-an-alert-rule" >}}) for more information. -- **Access alert rule settings -** Click the alert name or the **Edit alert rule** (gear) icon. Grafana opens the Alert tab of the panel where the alert rule is defined. This is helpful when an alert is firing but you don't know which panel it is defined in. diff --git a/docs/sources/panels-visualizations/_index.md b/docs/sources/panels-visualizations/_index.md index af1f7843323ea..dbb1f5d71c732 100644 --- a/docs/sources/panels-visualizations/_index.md +++ b/docs/sources/panels-visualizations/_index.md @@ -30,8 +30,13 @@ Panels can be dragged, dropped, and resized to rearrange them on the dashboard. Before you add a panel, ensure that you have configured a data source. -- For more information about adding and managing data sources as an administrator, refer to [Data source management][]. -- For details about using specific data sources, refer to [Data sources][]. +- For details about using data sources, refer to [Data sources][]. + +- For more information about managing data sources as an administrator, refer to [Data source management][]. + + {{% admonition type="note" %}} + [Data source management](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/) is only available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and [Grafana Cloud](https://grafana.com/docs/grafana-cloud/). + {{% /admonition %}} This section includes the following sub topics: diff --git a/docs/sources/panels-visualizations/configure-data-links/index.md b/docs/sources/panels-visualizations/configure-data-links/index.md index 683e1a8a9de3d..9996e398e38d2 100644 --- a/docs/sources/panels-visualizations/configure-data-links/index.md +++ b/docs/sources/panels-visualizations/configure-data-links/index.md @@ -26,13 +26,37 @@ weight: 80 # Configure data links -You can use data link variables or data links to create links between panels. +Data links allow you to provide more granular context to your links. You can create links that include the series name or even the value under the cursor. For example, if your visualization shows four servers, you can add a data link to one or two of them. You can also link panels using data links. + +The link itself is accessible in different ways depending on the visualization. For the time series visualization you need to click a data point or line: + +![Time series visualization with a data link displayed](/media/docs/grafana/panels-visualizations/screenshot-time-series-data-link-v10.3.png) + +For visualizations like stat, gauge, or bar gauge you can click anywhere on the visualization to open the context menu: + +![Stat visualization with a data link displayed](/media/docs/grafana/panels-visualizations/screenshot-stat-data-link-v10.3.png) + +If there's only one data link in the visualization, clicking anywhere on the visualization opens the link rather than the context menu. + +## Supported visualizations + +You can configure data links for the following visualizations: + +| | | | +| -------------------------- | ---------------------- | -------------------------------- | +| [Bar chart][bar chart] | [Geomap][geomap] | [State timeline][state timeline] | +| [Bar gauge][bar gauge] | [Heatmap][heatmap] | [Status history][status history] | +| [Candlestick][candlestick] | [Histogram][histogram] | [Table][table] | +| [Canvas][canvas] | [Pie chart][pie chart] | [Time series][time series] | +| [Gauge][gauge] | [Stat][stat] | [Trend][trend] | + +<!--Also xy chart --> ## Data link variables -You can use variables in data links to refer to series fields, labels, and values. For more information about data links, refer to [Data links](#data-links). +Variables in data links let you send people to a detailed dashboard with preserved data filters. For example, you could use variables to specify a label, time range, series, or variable selection. -To see a list of available variables, type `$` in the data link **URL** field to see a list of variables that you can use. +To see a list of available variables, enter `$` in the data link **URL** field. {{% admonition type="note" %}} These variables changed in 6.4 so if you have an older version of Grafana, then use the version picker to select docs for an older version of Grafana. @@ -40,50 +64,65 @@ These variables changed in 6.4 so if you have an older version of Grafana, then Azure Monitor, [CloudWatch][], and [Google Cloud Monitoring][] have pre-configured data links called _deep links_. -You can also use template variables in your data links URLs, refer to [Templates and variables][] for more information on template variables. +You can also use template variables in your data links URLs. For more information, refer to [Templates and variables][]. -## Time range panel variables +### Time range panel variables -These variables allow you to include the current time range in the data link URL. +These variables allow you to include the current time range in the data link URL: -- `__url_time_range` - current dashboard's time range (i.e. `?from=now-6h&to=now`) -- `$__from and $__to` - For more information, refer to [Global variables][]. +| Variable | Description | +| ------------------ | ------------------------------------------------------------------- | +| `__url_time_range` | Current dashboard's time range (for example, `?from=now-6h&to=now`) | +| `__from` | For more information, refer to [Global variables][]. | +| `__to` | For more information, refer to [Global variables][]. | -## Series variables +When you create data links using time range variables like `__url_time_range` in the URL, you have to form the query parameter syntax yourself; that is, you must format the URL by appending query parameters using the question mark (`?`) and ampersand (`&`) syntax. These characters aren't automatically generated. + +### Series variables Series-specific variables are available under `__series` namespace: -- `__series.name` - series name to the URL +| Variable | Description | +| --------------- | ---------------------- | +| `__series.name` | Series name to the URL | -## Field variables +### Field variables Field-specific variables are available under `__field` namespace: -- `__field.name` - the name of the field -- `__field.labels.<LABEL>` - label's value to the URL. If your label contains dots, then use `__field.labels["<LABEL>"]` syntax. +| Variable | Description | +| ------------------------ | --------------------------------------------------------------------------------------------------- | +| `__field.name` | The name of the field | +| `__field.labels.<LABEL>` | Label's value to the URL. If your label contains dots, then use `__field.labels["<LABEL>"]` syntax. | -## Value variables +### Value variables Value-specific variables are available under `__value` namespace: -- `__value.time` - value's timestamp (Unix ms epoch) to the URL (i.e. `?time=1560268814105`) -- `__value.raw` - raw value -- `__value.numeric` - numeric representation of a value -- `__value.text` - text representation of a value -- `__value.calc` - calculation name if the value is result of calculation +| Variable | Description | +| ----------------- | --------------------------------------------------------------------------------- | +| `__value.time` | Value's timestamp (Unix ms epoch) to the URL (for example, `?time=1560268814105`) | +| `__value.raw` | Raw value | +| `__value.numeric` | Numeric representation of a value | +| `__value.text` | Text representation of a value | +| `__value.calc` | Calculation name if the value is result of calculation | Using value-specific variables in data links can show different results depending on the set option of Tooltip mode. -## Data variables +When you create data links using time range variables like `__value.time` in the URL, you have to form the query parameter syntax yourself; that is, you must add the question mark (`?`) and ampersand (`&`). These characters aren't automatically generated. + +### Data variables To access values and labels from other fields use: -- `${__data.fields[i]}` - value of field `i` (on the same row) -- `${__data.fields["NameOfField"]}` - value of field using name instead of index -- `${__data.fields["NameOfField"]}` - value of field using name instead of index -- `${__data.fields[1].labels.cluster}` - access labels of another field +| Variable | Description | +| --------------------------------- | ------------------------------------------ | +| `__data.fields[i]` | Value of field `i` (on the same row) | +| `__data.fields["NameOfField"]` | Value of field using name instead of index | +| `__data.fields["NameOfField"]` | Value of field using name instead of index | +| `__data.fields[1].labels.cluster` | Access labels of another field | -## Template variables +### Template variables When linking to another dashboard that uses template variables, select variable values for whoever clicks the link. @@ -97,54 +136,69 @@ When linking to another dashboard that uses template variables, select variable If you want to add all of the current dashboard's variables to the URL, then use `${__all_variables}`. -## Data links +## Add a data link + +1. Navigate to the panel to which you want to add the data link. +1. Hover over any part of the panel to display the menu icon in the upper-right corner. +1. Click the menu icon and select **Edit** to open the panel editor. +1. In the panel edit pane, scroll down to the **Data links** section and expand it. +1. Click **Add link**. +1. In the dialog box that opens, enter a **Title**. This is a human-readable label for the link, which will be displayed in the UI. +1. Enter the **URL** or variable to which you want to link. + + To add a data link variable, click in the **URL** field and enter `$` or press Ctrl+Space or Cmd+Space to see a list of available variables. -Data links allow you to provide more granular context to your links. You can create links that include the series name or even the value under the cursor. For example, if your visualization showed four servers, you could add a data link to one or two of them. +1. If you want the link to open in a new tab, then toggle the **Open in a new tab** switch. +1. Click **Save** to save changes and close the dialog box. +1. Click **Apply** to see your changes in the dashboard. +1. Click the **Save dashboard** icon to save your changes to the dashboard. -The link itself is accessible in different ways depending on the visualization. For the Graph you need to click on a data point or line, for a panel like -Stat, Gauge, or Bar Gauge you can click anywhere on the visualization to open the context menu. +{{% docs/reference %}} +[bar chart]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/bar-chart" +[bar chart]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/bar-chart" -You can use variables in data links to send people to a detailed dashboard with preserved data filters. For example, you could use variables to specify a time range, series, and variable selection. For more information, refer to [Data link variables](#data-link-variables). +[bar gauge]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/bar-gauge" +[bar gauge]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/bar-gauge" -### Typeahead suggestions +[candlestick]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/candlestick" +[candlestick]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/candlestick" -When creating or updating a data link, press Cmd+Space or Ctrl+Space on your keyboard to open the typeahead suggestions to more easily add variables to your URL. +[canvas]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/canvas" +[canvas]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/canvas" -{{< figure src="/static/img/docs/data_link_typeahead.png" max-width= "800px" alt="Drop-down list with variable suggestions open from the URL field" >}} +[gauge]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/gauge" +[gauge]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/gauge" -### Add a data link +[geomap]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/geomap" +[geomap]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/geomap" -1. Hover over any part of the panel you want to which you want to add the data link to display the actions menu on the top right corner. -1. Click the menu and select **Edit**. +[heatmap]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/heatmap" +[heatmap]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/heatmap" - To use a keyboard shortcut to open the panel, hover over the panel and press `e`. +[histogram]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/histogram" +[histogram]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/histogram" -1. Scroll down to the Data links section and expand it. -1. Click **Add link**. -1. Enter a **Title**. **Title** is a human-readable label for the link that will be displayed in the UI. -1. Enter the **URL** you want to link to. +[pie chart]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/pie-chart" +[pie chart]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/pie-chart" - You can even add one of the template variables defined in the dashboard. Click in the **URL** field and then type `$` or press Ctrl+Space or Cmd+Space to see a list of available variables. By adding template variables to your panel link, the link sends the user to the right context, with the relevant variables already set. For more information, refer to [Data link variables](#data-link-variables). +[stat]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/stat" +[stat]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/stat" -1. If you want the link to open in a new tab, then select **Open in a new tab**. -1. Click **Save** to save changes and close the window. -1. Click **Save** in the upper right to save your changes to the dashboard. +[state timeline]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/state-timeline" +[state timeline]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/state-timeline" -### Update a data link +[status history]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/status-history" +[status history]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/status-history" -1. Scroll down to the Data links section, expand it, and find the link that you want to make changes to. -1. Click the Edit (pencil) icon to open the Edit link window. -1. Make any necessary changes. -1. Click **Save** to save changes and close the window. -1. Click **Save** in the upper right to save your changes to the dashboard. +[table]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/table" +[table]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/table" -### Delete a data link +[time series]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/time-series" +[time series]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/time-series" -1. Scroll down to the Data links section, expand it, and find the link that you want to delete. -1. Click the **X** icon next to the link you want to delete. -1. Click **Save** in the upper right to save your changes to the dashboard. +[trend]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/trend" +[trend]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/trend" -{{% docs/reference %}} [Cloudwatch]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/aws-cloudwatch/query-editor#deep-link-grafana-panels-to-the-cloudwatch-console-1" [Cloudwatch]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/connect-externally-hosted/data-sources/aws-cloudwatch/query-editor#deep-link-grafana-panels-to-the-cloudwatch-console-1" @@ -152,8 +206,8 @@ When creating or updating a data link, press Cmd+Space or Ctrl+Space on your key [Google Cloud Monitoring]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/connect-externally-hosted/data-sources/google-cloud-monitoring/query-editor#deep-link-from-grafana-panels-to-the-google-cloud-console-metrics-explorer" [Templates and variables]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/variables" -[Templates and variables]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/variables" +[Templates and variables]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/variables" [Global variables]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/variables/add-template-variables#**from-and-**to" -[Global variables]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/variables/add-template-variables#**from-and-**to" +[Global variables]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/variables/add-template-variables#**from-and-**to" {{% /docs/reference %}} diff --git a/docs/sources/panels-visualizations/configure-legend/index.md b/docs/sources/panels-visualizations/configure-legend/index.md index 49c29959d4f80..fb26179406688 100644 --- a/docs/sources/panels-visualizations/configure-legend/index.md +++ b/docs/sources/panels-visualizations/configure-legend/index.md @@ -30,7 +30,7 @@ Legends are supported for the following visualizations: - [Trend][trend] <!-- - xy chart --> -[Geomaps][] and [heatmaps][] also have legends, but they only provide the the choice to display or not display a legend and don't support other legend options. +[Geomaps][] and [heatmaps][] also have legends, but they only provide the choice to display or not display a legend and don't support other legend options. ## Legend options diff --git a/docs/sources/panels-visualizations/configure-overrides/index.md b/docs/sources/panels-visualizations/configure-overrides/index.md index 6f875238ee595..64472b81107db 100644 --- a/docs/sources/panels-visualizations/configure-overrides/index.md +++ b/docs/sources/panels-visualizations/configure-overrides/index.md @@ -21,13 +21,61 @@ weight: 110 # Configure field overrides -Overrides allow you to customize visualization settings for specific fields or series. This is accomplished by adding an override rule that targets a particular set of fields and that can each define multiple options. +Overrides allow you to customize visualization settings for specific fields or series. When you add an override rule, it targets a particular set of fields and lets you define multiple options for how that field is displayed. -For example, you set the unit for all fields that include the text 'bytes' by adding an override using the `Fields with name matching regex` matcher and then add the Unit option to the override rule. +For example, you can override the default unit measurement for all fields that include the text "bytes" by adding an override using the **Fields with name matching regex** matcher and then the **Standard options > Unit** setting to the override rule: -## Example 1: Format temperature +![Field with unit override](/media/docs/grafana/panels-visualizations/screenshot-unit-override-v10.3.png) -Let’s assume that our result set is a data frame that consists of two fields: time and temperature. +After you've set them, your overrides appear in both the **All** and **Overrides** tabs of the panel editor pane: + +![All and Overrides tabs of panel editor pane](/media/docs/grafana/panels-visualizations/screenshot-all-overrides-tabs-v11.png) + +## Supported visualizations + +You can configure field overrides for the following visualizations: + +| | | | +| -------------------------- | ---------------------- | -------------------------------- | +| [Bar chart][bar chart] | [Geomap][geomap] | [State timeline][state timeline] | +| [Bar gauge][bar gauge] | [Heatmap][heatmap] | [Status history][status history] | +| [Candlestick][candlestick] | [Histogram][histogram] | [Table][table] | +| [Canvas][canvas] | [Pie chart][pie chart] | [Time series][time series] | +| [Gauge][gauge] | [Stat][stat] | [Trend][trend] | + +<!--Also xy chart--> + +## Override rules + +You can choose from five types of override rules, which are described in the following sections. + +### Fields with name + +Select a field from the list of all available fields. Properties you add to this type of rule are only applied to this single field. + +### Fields with name matching regex + +Specify fields to override with a regular expression. Properties you add to this type of rule are applied to all fields where the field name matches the regular expression. This override doesn't rename the field; to do this, use the [Rename by regex transformation][]. + +### Fields with type + +Select fields by type, such as string, numeric, or time. Properties you add to this type of rule are applied to all fields that match the selected type. + +### Fields returned by query + +Select all fields returned by a specific query, such as A, B, or C. Properties you add to this type of rule are applied to all fields returned by the selected query. + +### Fields with values + +Select all fields returned by your defined reducer condition, such as **Min**, **Max**, **Count**, **Total**. Properties you add to this type of rule are applied to all fields returned by the selected condition. + +## Examples + +The following examples demonstrate how you can use override rules to alter the display of fields in visualizations. + +### Example 1: Format temperature + +The following result set is a data frame that consists of two fields: time and temperature. | time | temperature | | :-----------------: | :---------: | @@ -35,7 +83,14 @@ Let’s assume that our result set is a data frame that consists of two fields: | 2020-01-02 03:05:00 | 47.0 | | 2020-01-02 03:06:00 | 48.0 | -Each field (column) of this structure can have field options applied that alter the way its values are displayed. This means that you can, for example, set the Unit to Temperature > Celsius, resulting in the following table: +You can apply field options to each field (column) of this structure to alter the way its values are displayed. For example, you can set the following override rule: + +- Rule: **Fields with type** +- Field: temperature +- Override property: **Standard options > Unit** + - Selection: **Temperature > Celsius** + +This results in the following table: | time | temperature | | :-----------------: | :---------: | @@ -43,7 +98,7 @@ Each field (column) of this structure can have field options applied that alter | 2020-01-02 03:05:00 | 47.0 °C | | 2020-01-02 03:06:00 | 48.0 °C | -In addition, the decimal place is not required, so we can remove it. You can change the Decimals from `auto` to zero (`0`), resulting in the following table: +In addition, the decimal place isn't required, so you can remove it by adding another override property that changes the **Standard options > Decimals** setting from **auto** to `0`. That results in the following table: | time | temperature | | :-----------------: | :---------: | @@ -51,9 +106,9 @@ In addition, the decimal place is not required, so we can remove it. You can cha | 2020-01-02 03:05:00 | 47 °C | | 2020-01-02 03:06:00 | 48 °C | -## Example 2: Format temperature and humidity +### Example 2: Format temperature and humidity -Let’s assume that our result set is a data frame that consists of four fields: time, high temp, low temp, and humidity. +The following result set is a data frame that consists of four fields: time, high temp, low temp, and humidity. | time | high temp | low temp | humidity | | ------------------- | --------- | -------- | -------- | @@ -61,7 +116,16 @@ Let’s assume that our result set is a data frame that consists of four fields: | 2020-01-02 03:05:00 | 47.0 | 34.0 | 68 | | 2020-01-02 03:06:00 | 48.0 | 31.0 | 68 | -Let's add the Celsius unit and get rid of the decimal place. This results in the following table: +Use the following override rule and properties to add the **Celsius** unit option and remove the decimal place: + +- Rule: **Fields with type** +- Field: temperature +- Override property: **Standard options > Unit** + - Selection: **Temperature > Celsius** +- Override property: **Standard options > Decimals** + -Change setting from **auto** to `0` + +This results in the following table: | time | high temp | low temp | humidity | | ------------------- | --------- | -------- | -------- | @@ -69,7 +133,7 @@ Let's add the Celsius unit and get rid of the decimal place. This results in the | 2020-01-02 03:05:00 | 47 °C | 34 °C | 68 °C | | 2020-01-02 03:06:00 | 48 °C | 31 °C | 68 °C | -The temperature fields look good, but the humidity must now be changed. We can fix this by applying a field option override to the humidity field and change the unit to Misc > percent (0-100). +The temperature fields are displaying correctly, but the humidity has incorrect units. You can fix this by applying a **Misc > Percent (0-100)** override to the humidity field. This results in the following table: | time | high temp | low temp | humidity | | ------------------- | --------- | -------- | -------- | @@ -79,47 +143,86 @@ The temperature fields look good, but the humidity must now be changed. We can f ## Add a field override -A field override rule can customize the visualization settings for a specific field or series. - -1. Edit the panel to which you want to add an override. -1. In the panel options side pane, click **Add field override** at the bottom of the pane. - -1. Select which fields an override rule will be applied to: - - **Fields with name:** Select a field from the list of all available fields. Properties you add to a rule with this selector are only applied to this single field. - - **Fields with name matching regex:** Specify fields to override with a regular expression. Properties you add to a rule with this selector are applied to all fields where the field name match the regex. This override doesn't rename the field; to do this, use the [Rename by regex transformation]({{< relref "../query-transform-data/transform-data/#rename-by-regex" >}}). - - **Fields with type:** Select fields by type, such as string, numeric, and so on. Properties you add to a rule with this selector are applied to all fields that match the selected type. - - **Fields returned by query:** Select all fields returned by a specific query, such as A, B, or C. Properties you add to a rule with this selector are applied to all fields returned by the selected query. +To add a field override, follow these steps: + +1. Navigate to the panel to which you want to add the data link. +1. Hover over any part of the panel to display the menu icon in the upper-right corner. +1. Click the menu icon and select **Edit** to open the panel editor. +1. At the bottom of the panel editor pane, click **Add field override**. +1. Select the fields to which the override will be applied: + - **Fields with name** + - **Fields with name matching regex** + - **Fields with type** + - **Fields returned by query** + - **Fields with values** 1. Click **Add override property**. 1. Select the field option that you want to apply. -1. Enter options by adding values in the fields. To return options to default values, delete the white text in the fields. -1. Continue to add overrides to this field by clicking **Add override property**, or you can click **Add override** and select a different field to add overrides to. -1. When finished, click **Save** to save all panel edits to the dashboard. +1. Continue to add overrides to this field by clicking **Add override property**. +1. Add as many overrides as you need. +1. When you're finished, click **Save** to save all panel edits to the dashboard. -## Delete a field override +## Edit a field override -Delete a field override when you no longer need it. When you delete an override, the appearance of value defaults to its original format. This change impacts dashboards and dashboard users that rely on an affected panel. +To edit a field override, follow these steps: -1. Edit the panel that contains the override you want to delete. -1. In panel options side pane, scroll down until you see the overrides. -1. Click the override you want to delete and then click the associated trash icon. +1. Navigate to the panel to which you want to add the data link. +1. Hover over any part of the panel to display the menu icon in the upper-right corner. +1. Click the menu icon and select **Edit** to open the panel editor. +1. In the panel editor pane, click the **Overrides** tab. +1. Locate the override you want to change. +1. Perform any of the following tasks: + - Edit settings on existing overrides or field selection parameters. + - Delete existing override properties by clicking the **X** next to the property. + - Delete an override entirely by clicking the trash icon at the top-right corner. -## View field overrides +The changes you make take effect immediately. -You can view field overrides in the panel display options. +{{% docs/reference %}} +[bar chart]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/bar-chart" +[bar chart]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/bar-chart" -1. Edit the panel that contains the overrides you want to view. -1. In panel options side pane, scroll down until you see the overrides. +[bar gauge]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/bar-gauge" +[bar gauge]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/bar-gauge" -> The override settings that appear on the **All** tab are the same as the settings that appear on the **Overrides** tab. +[candlestick]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/candlestick" +[candlestick]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/candlestick" -## Edit a field override +[canvas]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/canvas" +[canvas]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/canvas" -Edit a field override when you want to make changes to an override setting. The change you make takes effect immediately. +[gauge]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/gauge" +[gauge]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/gauge" -1. Edit the panel that contains the overrides you want to edit. -1. In panel options side pane, scroll down until you see the overrides. -1. Locate the override that you want to change. -1. Perform any of the following: - - Edit settings on existing overrides or field selection parameters. - - Delete existing override properties by clicking the **X** next to the property. - - Add an override properties by clicking **Add override property**. +[geomap]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/geomap" +[geomap]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/geomap" + +[heatmap]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/heatmap" +[heatmap]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/heatmap" + +[histogram]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/histogram" +[histogram]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/histogram" + +[pie chart]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/pie-chart" +[pie chart]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/pie-chart" + +[stat]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/stat" +[stat]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/stat" + +[state timeline]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/state-timeline" +[state timeline]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/state-timeline" + +[status history]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/status-history" +[status history]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/status-history" + +[table]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/table" +[table]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/table" + +[time series]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/time-series" +[time series]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/time-series" + +[trend]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/trend" +[trend]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/trend" + +[Rename by regex transformation]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/transform-data#rename-by-regex" +[Rename by regex transformation]: "/docs/grafana-cloud -> /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/transform-data#rename-by-regex" +{{% /docs/reference %}} diff --git a/docs/sources/panels-visualizations/configure-panel-options/index.md b/docs/sources/panels-visualizations/configure-panel-options/index.md index e6946c8567d18..72483d4e456e8 100644 --- a/docs/sources/panels-visualizations/configure-panel-options/index.md +++ b/docs/sources/panels-visualizations/configure-panel-options/index.md @@ -25,111 +25,66 @@ weight: 50 # Configure panel options -A Grafana panel is a visual representation of data that you can customize by defining a data source query, transforming and formatting data, and configuring visualization settings. +There are settings common to all visualizations, which you set in the **Panel options** section of the panel editor pane. The following sections describe these options as well as how to set them. -A panel editor includes a query builder and a series of options that you can use to transform data and add information to your panels. +## Panel options -This topic describes how to: +Set the following options to provide basic information about a panel and define basic display elements: -- Open a panel for editing -- Add a panel title and description -- View a panel JSON model -- Configure repeating rows and panels +| Option | Description | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Title | Text entered in this field appears at the top of your panel in the panel editor and in the dashboard. You can use [variables you have defined][] in the **Title** field, but not [global variables][]. | +| Description | Text entered in this field appears in a tooltip in the upper-left corner of the panel. Add a description to a panel to share with users any important information about it, such as its purpose. You can use [variables you have defined][] in the **Description** field, but not [global variables][]. | +| Transparent background | Toggle this switch on and off to control whether or not the panel has the same background color as the dashboard. | +| Panel links | Add [links to the panel][] to create shortcuts to other dashboards, panels, and external websites. Access panel links by clicking the icon next to the panel title. | +| Repeat options | Set whether to repeat the panel for each value in the selected variable. For more information, refer to [Configure repeating panels](#configure-repeating-panels). | -## Edit a panel - -After you add a panel to a dashboard, you can open it at any time to change or update queries, add data transformation, and change visualization settings. - -1. Open the dashboard that contains the panel you want to edit. - -1. Hover over any part of the panel to display the actions menu on the top right corner. - -1. Click the menu and select **Edit**. - - ![Panel with menu displayed](/media/docs/grafana/screenshot-panel-menu.png) - - To use a keyboard shortcut to open the panel, hover over the panel and press `e`. - - The panel opens in edit mode. - -## Add a title and description to a panel - -You can use generative AI to create panel titles and descriptions with the [Grafana LLM plugin][], which is currently in public preview. To enable this, refer to the [Set up generative AI features for dashboards documentation][]. Alternatively, you can take the following steps to create them yourself. - -Add a title and description to a panel to share with users any important information about the visualization. For example, use the description to document the purpose of the visualization. - -1. [Edit a panel](#edit-a-panel). - -1. In the panel display options pane, locate the **Panel options** section. - -1. Enter a **Title**. - - Text entered in this field appears at the top of your panel in the panel editor and in the dashboard. - -1. Write a description of the panel and the data you are displaying. - - Text entered in this field appears in a tooltip in the upper-left corner of the panel. - - You can use [variables you have defined][] in the **Title** and **Description** field, but not [global variables][]. - - ![Panel editor pane with Panel options section expanded](/static/img/docs/panels/panel-options-8-0.png) - -## View a panel JSON model - -Explore and export panel, panel data, and data frame JSON models. - -1. Open the dashboard that contains the panel. - -1. Hover over any part of the panel to display the actions menu on the top right corner. -1. Click the menu and select **Inspect > Panel JSON**. -1. In the **Select source** field, select one of the following options: - - - **Panel JSON:** Displays a JSON object representing the panel. - - **Panel data:** Displays a JSON object representing the data that was passed to the panel. - - **DataFrame structure:** Displays the data structure of the panel, including any transformations, field configurations, and override configurations that have been applied. - -1. To explore the JSON, click `>` to expand or collapse portions of the JSON model. +You can use generative AI to populate the **Title** and **Description** fields with the [Grafana LLM plugin][], which is currently in public preview. To enable this, refer to [Set up generative AI features for dashboards][]. ## Configure repeating panels You can configure Grafana to dynamically add panels or rows to a dashboard. A dynamic panel is a panel that the system creates based on the value of a variable. Variables dynamically change your queries across all panels in a dashboard. For more information about repeating rows, refer to [Configure repeating rows][]. -{{% admonition type="note" %}} -Repeating panels require variables to have one or more items selected; you can't repeat a panel zero times to hide it. -{{% /admonition %}} - To see an example of repeating panels, refer to [this dashboard with repeating panels](https://play.grafana.org/d/testdata-repeating/testdata-repeating-panels?orgId=1). **Before you begin:** - Ensure that the query includes a multi-value variable. -**To configure repeating panels:** - -1. [Edit the panel](#edit-a-panel) you want to repeat. - -1. On the display options pane, click **Panel options > Repeat options**. +To configure repeating panels, follow these steps: -1. Select a `direction`. +1. Navigate to the panel you want to update. +1. Hover over any part of the panel to display the menu on the top right corner. +1. Click the menu and select **Edit**. +1. Open the **Panel options** section of the panel editor pane. +1. Under **Repeat options**, select a variable in the **Repeat by variable** drop-down list. +1. Under **Repeat direction**, choose one of the following: - - Choose `horizontal` to arrange panels side-by-side. Grafana adjusts the width of a repeated panel. Currently, you can't mix other panels on a row with a repeated panel. - - Choose `vertical` to arrange panels in a column. The width of repeated panels is the same as the original, repeated panel. + - **Horizontal** - Arrange panels side-by-side. Grafana adjusts the width of a repeated panel. You can't mix other panels on a row with a repeated panel. + - **Vertical** - Arrange panels in a column. The width of repeated panels is the same as the original, repeated panel. +1. If you selected **Horizontal** in the previous step, select a value in the **Max per row** drop-down list to control the maximum number of panels that can be in a row. +1. Click **Save**. 1. To propagate changes to all panels, reload the dashboard. +You can stop a panel from repeating by selecting **Disable repeating** in the **Repeat by variable** drop-down list. + {{% docs/reference %}} -[variables you have defined]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/variables" -[variables you have defined]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/variables" +[variables you have defined]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/dashboards/variables" +[variables you have defined]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/variables" + +[global variables]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables#global-variables" +[global variables]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/variables/add-template-variables#global-variables" -[global variables]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/variables/add-template-variables#global-variables" -[global variables]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/variables/add-template-variables#global-variables" +[Configure repeating rows]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/create-dashboard#configure-repeating-rows" +[Configure repeating rows]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/build-dashboards/create-dashboard#configure-repeating-rows" -[Configure repeating rows]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/build-dashboards/create-dashboard#configure-repeating-rows" -[Configure repeating rows]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/build-dashboards/create-dashboard#configure-repeating-rows" +[Grafana LLM plugin]: "/docs/grafana/ -> /docs/grafana-cloud/alerting-and-irm/machine-learning/configure/llm-plugin" +[Grafana LLM plugin]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/machine-learning/configure/llm-plugin" -[Grafana LLM plugin]: "/docs/grafana/ -> /docs/grafana-cloud/alerting-and-irm/machine-learning/llm-plugin" -[Grafana LLM plugin]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/machine-learning/llm-plugin" +[Set up generative AI features for dashboards]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/dashboards/manage-dashboards#set-up-generative-ai-features-for-dashboards" +[Set up generative AI features for dashboards]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/manage-dashboards#set-up-generative-ai-features-for-dashboards" -[Set up generative AI features for dashboards documentation]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/manage-dashboards#set-up-generative-ai-features-for-dashboards" -[Set up generative AI features for dashboards documentation]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/manage-dashboards#set-up-generative-ai-features-for-dashboards" +[links to the panel]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/manage-dashboard-links#panel-links" +[links to the panel]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/dashboards/build-dashboards/manage-dashboard-links#panel-links" {{% /docs/reference %}} diff --git a/docs/sources/panels-visualizations/configure-standard-options/index.md b/docs/sources/panels-visualizations/configure-standard-options/index.md index 54bd1e133cc71..ea99c1062a92b 100644 --- a/docs/sources/panels-visualizations/configure-standard-options/index.md +++ b/docs/sources/panels-visualizations/configure-standard-options/index.md @@ -81,10 +81,6 @@ You can also paste a native emoji in the unit picker and pick it as a custom uni Grafana can sometimes be too aggressive in parsing strings and displaying them as numbers. To configure Grafana to show the original string value, create a field override and add a unit property with the `String` unit. -### Scale units - -By default, Grafana automatically scales the unit based on the magnitude of the value. For example, if you have a value of 0.14 kW, Grafana will display it as 140 W. Another example is that 3000 kW will be displayed at 3 MW. If you want to disable this behavior, you can toggle off the **Scale units** switch. - ### Min Lets you set the minimum value used in percentage threshold calculations. Leave blank to automatically calculate the minimum. diff --git a/docs/sources/panels-visualizations/configure-thresholds/index.md b/docs/sources/panels-visualizations/configure-thresholds/index.md index fcd47d89e4bc1..fe6e7a5bd5c3b 100644 --- a/docs/sources/panels-visualizations/configure-thresholds/index.md +++ b/docs/sources/panels-visualizations/configure-thresholds/index.md @@ -20,60 +20,101 @@ weight: 100 # Configure thresholds -This section includes information about using thresholds in your visualizations. You'll learn about thresholds, their defaults, how to add or delete a threshold, and adding a threshold to a legacy panel. +In dashboards, a threshold is a value or limit you set for a metric that's reflected visually when it's met or exceeded. Thresholds are one way you can conditionally style and color your visualizations based on query results. -## About thresholds +Using thresholds, you can color grid lines and regions in a time series visualization: +![Time series visualization with green, blue, and purple threshold lines and regions](/media/docs/grafana/panels-visualizations/screenshot-thresholds-lines-regions-v10.4.png) -A threshold is a value that you specify for a metric that is visually reflected in a dashboard when the threshold value is met or exceeded. +You can color the background or value text in a stat visualization: +![Stat visualization with three values in green and orange](/media/docs/grafana/panels-visualizations/screenshot-thresholds-value-v10.4.png) -Thresholds provide one method for you to conditionally style and color your visualizations based on query results. You can apply thresholds to most, but not all, visualizations. For more information about visualizations, refer to [Visualization panels][]. +You can define regions and region colors in a state timeline: +![State timeline with green, blue, and pink region thresholds](/media/docs/grafana/panels-visualizations/screenshot-thresholds-state-timeline-v10.4.png) -You can use thresholds to: +You can also use thresholds to: -- Color grid lines or grid areas in the [Time-series visualization][] -- Color lines in the [Time-series visualization][] -- Color the background or value text in the [Stat visualization][] -- Color the gauge and threshold markers in the [Gauge visualization][] -- Color markers in the [Geomap visualization][] -- Color cell text or background in the [Table visualization][] -- Define regions and region colors in the [State timeline visualization][] +- Color lines in a time series visualization +- Color the gauge and threshold markers in a gauge +- Color markers in a geomap +- Color cell text or background in a table -There are two types of thresholds: +## Supported visualizations -- **Absolute** thresholds are defined by a number. For example, 80 on a scale of 1 to 150. -- **Percentage** thresholds are defined relative to minimum or maximum. For example, 80 percent. +You can set thresholds in the following visualizations: + +| | | | +| -------------------------- | -------------------------------- | -------------------------------- | +| [Bar chart][bar chart] | [Geomap][geomap] | [Status history][status history] | +| [Bar gauge][bar gauge] | [Histogram][histogram] | [Table][table] | +| [Candlestick][candlestick] | [Stat][stat] | [Time series][time series] | +| [Canvas][canvas] | [State timeline][state timeline] | [Trend][trend] | +| [Gauge][gauge] | -### Default thresholds +## Default thresholds -On visualizations that support it, Grafana sets default threshold values of: +On visualizations that support thresholds, Grafana has the following default threshold settings: - 80 = red - Base = green - Mode = Absolute +- Show thresholds = Off (for some visualizations); for more information, see the [Show thresholds](#show-threshold) option. + +## Thresholds options + +You can set the following options to further define how thresholds look. + +### Threshold value + +This number is the value that triggers the threshold. You can also set the color associated with the threshold in this field. + +The **Base** value represents minus infinity. By default, it's set to the color green, which is generally the “good” color. + +### Thresholds mode -The **Base** value represents minus infinity. It is generally the “good” color. +There are two threshold modes: -## Add or delete a threshold +- **Absolute** thresholds are defined by a number. For example, 80 on a scale of 1 to 150. +- **Percentage** thresholds are defined relative to minimum or maximum. For example, 80 percent. -You can add as many thresholds to a panel as you want. Grafana automatically sorts thresholds values from highest to lowest. +### Show thresholds -Delete a threshold when it is no longer needed. When you delete a threshold, the system removes the threshold from all visualizations that include the threshold. +{{< admonition type="note" >}} +This option is supported for the bar chart, candlestick, time series, and trend visualizations. +{{< /admonition>}} -1. To add a threshold: +Set if and how thresholds are shown with the following options. - a. Edit the panel to which you want to add a threshold. +| Option | Example | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Off | | +| As lines | {{< figure max-width="500px" src="/media/docs/grafana/panels-visualizations/screenshot-thresholds-lines-v10.4.png" alt="Visualization with threshold as a line" >}} | +| As lines (dashed) | {{< figure max-width="500px" src="/media/docs/grafana/panels-visualizations/screenshot-thresholds-dashed-lines-v10.4.png" alt="Visualization with threshold as a dashed line" >}} | +| As filled regions | {{< figure max-width="500px" src="/media/docs/grafana/panels-visualizations/screenshot-thresholds-regions-v10.4.png" alt="Visualization with threshold as a region" >}} | +| As filled regions and lines | {{< figure max-width="500px" src="/media/docs/grafana/panels-visualizations/screenshot-thresholds-lines-regions-v10.4.png" alt="Visualization with threshold as a region and line" >}} | +| As filled regions and lines (dashed) | {{< figure max-width="500px" src="/media/docs/grafana/panels-visualizations/screenshot-thresholds-dashed-lines-regions-v10.4.png" alt="Visualization with threshold as a region and dashed line" >}} | - b. In the options side pane, locate the **Thresholds** section and click **+ Add threshold**. +## Add a threshold - c. Select a threshold color, number, and mode. - Threshold mode applies to all thresholds on this panel. +You can add as many thresholds to a visualization as you want. Grafana automatically sorts thresholds values from highest to lowest. - d. For a time-series panel, select a **Show thresholds** option. +1. Navigate to the panel you want to update. +1. Hover over any part of the panel you want to work on to display the menu on the top right corner. +1. Click the menu and select **Edit**. +1. Scroll to the **Thresholds** section or enter `thresholds` in the search bar at the top of the panel edit pane. +1. Click **+ Add threshold**. +1. Enter a new threshold value or use the up and down arrows at the right side of the field to increase or decrease the value incrementally. +1. Click the colored circle to the left of the threshold value to open the color picker, where you can update the threshold color. +1. Under **Thresholds mode**, select either **Absolute** or **Percentage**. +1. Under **Show thresholds**, set how the threshold is displayed or turn it off. -1. To delete a threshold, navigate to the panel that contains the threshold and click the trash icon next to the threshold you want to remove. +To delete a threshold, navigate to the panel that contains the threshold and click the trash icon next to the threshold you want to remove. ## Add a threshold to a legacy graph panel +{{< admonition type="caution" >}} +Starting with Grafana v11, the legacy graph panel will be deprecated along with all other Angular panel plugins. For more information, refer to [Angular support deprecation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/developers/angular_deprecation/). +{{< /admonition >}} + In the Graph panel visualization, thresholds enable you to add lines or sections to a graph to make it easier to recognize when the graph crosses a threshold. 1. Navigate to the graph panel to which you want to add a threshold. @@ -94,24 +135,42 @@ In the Graph panel visualization, thresholds enable you to add lines or sections 1. Click **Save** to save the changes in the dashboard. {{% docs/reference %}} -[Table visualization]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/table" -[Table visualization]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/table" +[bar chart]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/bar-chart" +[bar chart]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/bar-chart" + +[bar gauge]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/bar-gauge" +[bar gauge]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/bar-gauge" + +[candlestick]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/candlestick" +[candlestick]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/candlestick" + +[canvas]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/canvas" +[canvas]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/canvas" + +[gauge]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/gauge" +[gauge]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/gauge" + +[geomap]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/geomap" +[geomap]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/geomap" + +[histogram]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/histogram" +[histogram]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/histogram" -[Stat visualization]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/stat" -[Stat visualization]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/stat" +[stat]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/stat" +[stat]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/stat" -[Time-series visualization]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/time-series#from-thresholds" -[Time-series visualization]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/time-series#from-thresholds" +[state timeline]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/state-timeline" +[state timeline]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/state-timeline" -[State timeline visualization]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/state-timeline" -[State timeline visualization]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/state-timeline" +[status history]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/status-history" +[status history]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/status-history" -[Gauge visualization]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/gauge" -[Gauge visualization]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/gauge" +[table]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/table" +[table]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/table" -[Visualization panels]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations" -[Visualization panels]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations" +[time series]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/time-series" +[time series]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/time-series" -[Geomap visualization]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/geomap" -[Geomap visualization]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/geomap" +[trend]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/trend" +[trend]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/trend" {{% /docs/reference %}} diff --git a/docs/sources/panels-visualizations/configure-value-mappings/index.md b/docs/sources/panels-visualizations/configure-value-mappings/index.md index 377d9cb28f0fe..be516356ec54f 100644 --- a/docs/sources/panels-visualizations/configure-value-mappings/index.md +++ b/docs/sources/panels-visualizations/configure-value-mappings/index.md @@ -16,34 +16,55 @@ labels: - oss menuTitle: Configure value mappings title: Configure value mappings -description: Configure value mappings to change the visual treatment of data in your visualizations +description: Configure value mappings to change how data appears in your visualizations weight: 90 --- # Configure value mappings -In addition to field overrides, value mapping is a technique that you can use to change the visual treatment of data that appears in a visualization. +In addition to field overrides, value mapping is a technique you can use to change how data appears in a visualization. -Values mapped using value mappings bypass the unit formatting. This means that a text value mapped to a numerical value is not formatted using the configured unit. +For example, the mapping applied in the following image causes the visualization to display the text `Cold`, `Good`, and `Hot` in blue, green, and red for ranges of temperatures rather than actual temperature values. Using value mappings this way can make data faster and easier to understand and interpret. -![Value mappings example](/static/img/docs/value-mappings/value-mappings-example-8-0.png) +![Value mappings applied to a gauge visualization](/media/docs/grafana/panels-visualizations/screenshot-value-mappings-v10.4.png) -If value mappings are present in a panel, then Grafana displays a summary in the side pane of the panel editor. +Value mappings bypass unit formatting set in the **Standard options** section of panel editor, like color or number of decimal places displayed. When value mappings are present in a panel, Grafana displays a summary of them in the **Value mappings** section of the editor panel. + +## Supported visualizations + +You can configure value mappings for the following visualizations: + +| | | | +| -------------------------- | -------------------------------- | -------------------------------- | +| [Bar chart][bar chart] | [Geomap][geomap] | [Status history][status history] | +| [Bar gauge][bar gauge] | [Histogram][histogram] | [Table][table] | +| [Candlestick][candlestick] | [Pie chart][pie chart] | [Time series][time series] | +| [Canvas][canvas] | [Stat][stat] | [Trend][trend] | +| [Gauge][gauge] | [State timeline][state timeline] | | ## Types of value mappings -{{% admonition type="note" %}} -The new value mappings are not compatible with some visualizations, such as Graph (old), Text, and Heatmap. -{{% /admonition %}} +Grafana supports the following value mapping types: + +### Value + +A **Value** mapping maps specific values to text and a color. For example, you can configure a mapping so that all instances of the value `10` appear as **Perfection!** rather than the number. Use **Value** mapping when you want to format a single value. +![The value 10 mapped to the text Perfection!](/media/docs/grafana/panels-visualizations/screenshot-map-value-v10.4.png) -Grafana supports the following value mappings: +### Range -- **Value:** Maps text values to a color or different display text. For example, you can configure a value mapping so that all instances of the value `10` appear as **Perfection!** rather than the number. -- **Range:** Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display **Low** or **High** rather than the number. -- **Regex:** Maps regular expressions to replacement text and a color. For example, if a value is `www.example.com`, you can configure a regex value mapping so that Grafana displays **www** and truncates the domain. -- **Special** Maps special values like `Null`, `NaN` (not a number), and boolean values like `true` and `false` to a display text and color. For example, you can configure a special value mapping so that `null` values appear as **N/A**. +A **Range** mapping maps numerical ranges to text and a color. For example, if a value is within a certain range, you can configure a range value mapping to display **Low** or **High** rather than the number. Use **Range** mapping when you want to format multiple, continuous values. +![Ranges of numbers mapped to the text Low and High with colors yellow and red](/media/docs/grafana/panels-visualizations/screenshot-map-range-v10.4.png) -You can also use the dots on the left to drag and reorder value mappings in the list. +### Regex + +A **Regex** mapping maps regular expressions to text and a color. For example, if a value is `www.example.com`, you can configure a regular expression value mapping so that Grafana displays **www** and truncates the domain. Use the **Regex** mapping when you want to format the text and color of a regular expression value. +![A regular expression used to truncate full URLs to the text wwww](/media/docs/grafana/panels-visualizations/screenshot-map-regex-v10.4.png) + +### Special + +A **Special** mapping maps special values like `Null`, `NaN` (not a number), and boolean values like `true` and `false` to text and color. For example, you can configure a special value mapping so that `null` values appear as **N/A**. Use the **Special** mapping when you want to format uncommon, boolean, or empty values. +![The value null mapped to the text N/A](/media/docs/grafana/panels-visualizations/screenshot-map-special-v10.4.png) ## Examples @@ -51,19 +72,19 @@ Refer to the following examples to learn more about value mapping. ### Time series example -The following image shows a time series visualization with value mappings. Value mapping colors are not applied to this visualization, but the display text is shown on the axis. +The following image shows a time series visualization with value mappings. Value mapping colors aren't applied to this visualization, but the display text is shown on the axis. ![Value mappings time series example](/static/img/docs/value-mappings/value-mappings-summary-example-8-0.png) ### Stat example -The following image shows a Stat visualization with value mappings and text colors applied. You can hide the sparkline so it doesn't interfere with the values. +The following image shows a stat visualization with value mappings and text colors applied. You can hide the sparkline so it doesn't interfere with the values. ![Value mappings stat example](/static/img/docs/value-mappings/value-mappings-stat-example-8-0.png) ### Bar gauge example -The following image shows a bar gauge visualization with value mappings. The value mapping colors are applied to the text, but not to the gauges. +The following image shows a bar gauge visualization with value mappings. Note that the value mapping colors are applied to the text, but not to the gauges. ![Value mappings bar gauge example](/static/img/docs/value-mappings/value-mappings-bar-gauge-example-8-0.png) @@ -73,64 +94,67 @@ The following image shows a table visualization with value mappings. If you want ![Value mappings table example](/static/img/docs/value-mappings/value-mappings-table-example-8-0.png) -## Map a value +## Add a value mapping + +1. Navigate to the panel you want to update. +1. Hover over any part of the panel you want to work on to display the menu on the top right corner. +1. Click the menu and select **Edit**. +1. Scroll to the **Value mappings** section and expand it. +1. Click **Add value mappings**. +1. Click **Add a new mapping** and then select one of the following: -Map a value when you want to format a single value. + - **Value** - Enter a single value to match. + - **Range** - Enter the beginning and ending values of a range to match. + - **Regex** - Enter a regular expression pattern to match. + - **Special** - Select a special value to match. -1. Open a panel for which you want to map a value. -1. In panel display options, locate the **Value mappings** section and click **Add value mappings**. -1. Click **Add a new mapping** and then select **Value**. -1. Enter the value for Grafana to match. 1. (Optional) Enter display text. 1. (Optional) Set the color. +1. (Optional) Set an icon (canvas visualizations only). 1. Click **Update** to save the value mapping. -![Map a value](/static/img/docs/value-mappings/map-value-8-0.png) +After you've added a mapping, the **Edit value mappings** button replaces the **Add value mappings** button. Click the edit button to add or update mappings. -## Map a range +{{% docs/reference %}} +[bar chart]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/bar-chart" +[bar chart]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/bar-chart" -Map a range of values when you want to format multiple, continuous values. +[bar gauge]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/bar-gauge" +[bar gauge]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/bar-gauge" -1. Edit the panel for which you want to map a range of values. -1. In panel display options, in the **Value mappings** section, click **Add value mappings**. -1. Click **Add a new mapping** and then select **Range**. -1. Enter the beginning and ending values in the range for Grafana to match. -1. (Optional) Enter display text. -1. (Optional) Set the color. -1. Click **Update** to save the value mapping. +[candlestick]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/candlestick" +[candlestick]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/candlestick" -![Map a range](/static/img/docs/value-mappings/map-range-8-0.png) +[canvas]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/canvas" +[canvas]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/canvas" -## Map a regular expression +[gauge]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/gauge" +[gauge]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/gauge" -Map a regular expression when you want to format the text and color of a regular expression value. +[geomap]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/geomap" +[geomap]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/geomap" -1. Edit the panel for which you want to map a regular expression. -1. In the **Value mappings** section of the panel display options, click **Add value mappings**. -1. Click **Add a new mapping** and then select **Regex**. -1. Enter the regular expression pattern for Grafana to match. -1. (Optional) Enter display text. -1. (Optional) Set the color. -1. Click **Update** to save the value mapping. +[histogram]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/histogram" +[histogram]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/histogram" -## Map a special value +[pie chart]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/pie-chart" +[pie chart]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/pie-chart" -Map a special value when you want to format uncommon, boolean, or empty values. +[stat]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/stat" +[stat]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/stat" -1. Edit the panel for which you want to map a special value. -1. In panel display options, locate the **Value mappings** section and click **Add value mappings**. -1. Click **Add a new mapping** and then select **Special**. -1. Select the special value for Grafana to match. -1. (Optional) Enter display text. -1. (Optional) Set the color. -1. Click **Update** to save the value mapping. +[state timeline]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/state-timeline" +[state timeline]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/state-timeline" -![Map a value](/static/img/docs/value-mappings/map-special-value-8-0.png) +[status history]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/status-history" +[status history]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/status-history" -## Edit a value mapping +[table]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/table" +[table]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/table" -You can edit a value mapping at any time. +[time series]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/time-series" +[time series]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/time-series" -1. Edit the panel that contains the value mapping you want to edit. -1. In the panel display options, in the **Value mappings** section, click **Edit value mappings**. -1. Make the changes and click **Update**. +[trend]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/trend" +[trend]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/trend" +{{% /docs/reference %}} diff --git a/docs/sources/panels-visualizations/query-transform-data/_index.md b/docs/sources/panels-visualizations/query-transform-data/_index.md index e0c3bad4a1515..356fca6533c97 100644 --- a/docs/sources/panels-visualizations/query-transform-data/_index.md +++ b/docs/sources/panels-visualizations/query-transform-data/_index.md @@ -158,6 +158,11 @@ Panel data source query options include: - **Min interval:** Sets a minimum limit for the automatically calculated interval, which is typically the minimum scrape interval. If a data point is saved every 15 seconds, you don't benefit from having an interval lower than that. You can also set this to a higher minimum than the scrape interval to retrieve queries that are more coarse-grained and well-functioning. + + {{% admonition type="note" %}} + The **Min interval** corresponds to the min step in Prometheus. Changing the Prometheus interval can change the start and end of the query range because Prometheus aligns the range to the interval. Refer to [Min step](https://grafana.com/docs/grafana/latest/datasources/prometheus/query-editor/#min-step) for more details. + {{% /admonition %}} + - **Interval:** Sets a time span that you can use when aggregating or grouping data points by time. Grafana automatically calculates an appropriate interval that you can use as a variable in templated queries. diff --git a/docs/sources/panels-visualizations/query-transform-data/expression-queries/index.md b/docs/sources/panels-visualizations/query-transform-data/expression-queries/index.md index 1019ab3e16a73..18f6ef930c1ce 100644 --- a/docs/sources/panels-visualizations/query-transform-data/expression-queries/index.md +++ b/docs/sources/panels-visualizations/query-transform-data/expression-queries/index.md @@ -104,7 +104,7 @@ So if you have numbers with labels like `{host=web01}` in `$A` and another numbe - An item with no labels will join to anything. - If both `$A` and `$B` each contain only one item (one series, or one number), they will join. - If labels are exact math they will join. -- If labels are a subset of the other, for example and item in `$A` is labeled `{host=A,dc=MIA}` and and item in `$B` is labeled `{host=A}` they will join. +- If labels are a subset of the other, for example and item in `$A` is labeled `{host=A,dc=MIA}` and item in `$B` is labeled `{host=A}` they will join. - Currently, if within a variable such as `$A` there are different tag _keys_ for each item, the join behavior is undefined. The relational and logical operators return 0 for false 1 for true. diff --git a/docs/sources/panels-visualizations/query-transform-data/share-query/index.md b/docs/sources/panels-visualizations/query-transform-data/share-query/index.md index a7d1eeb2e01a0..45a9e833d7080 100644 --- a/docs/sources/panels-visualizations/query-transform-data/share-query/index.md +++ b/docs/sources/panels-visualizations/query-transform-data/share-query/index.md @@ -21,7 +21,8 @@ The Dashboard data source lets you select a panel in your dashboard that contain This strategy can drastically reduce the number of queries being made when you for example have several panels visualizing the same data. 1. [Create a dashboard][]. -1. Change the title to "Source panel". You'll use this panel as a source for the other panels. +1. Create a panel. +1. Change the panel title to "Source panel". You'll use this panel as a source for the other panels. 1. Define the [query][] or queries that you want share. If you don't have a data source available, use the **Grafana** data source, which returns a random time series that you can use for testing. diff --git a/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md b/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md index 3da20ccd2d42e..68a6861b948b6 100644 --- a/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md +++ b/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md @@ -101,7 +101,9 @@ You can disable or hide one or more transformations by clicking on the eye icon If your panel uses more than one query, you can filter these and apply the selected transformation to only one of the queries. To do this, click the filter icon on the top right of the transformation row. This opens a drop-down with a list of queries used on the panel. From here, you can select the query you want to transform. -Note that the filter icon is always displayed if your panel has more than one query, but it may not work if previous transformations for merging the queries' outputs are applied. This is because one transformation takes the output of the previous one. +You can also filter by annotations (which includes exemplars) to apply transformations to them. When you do so, the list of fields changes to reflect those in the annotation or exemplar tooltip. + +The filter icon is always displayed if your panel has more than one query or source of data (that is, panel or annotation data) but it may not work if previous transformations for merging the queries’ outputs are applied. This is because one transformation takes the output of the previous one. ## Delete a transformation @@ -154,7 +156,7 @@ Use this transformation to add a new field calculated from two other fields. Eac - **Alias** - (Optional) Enter the name of your new field. If you leave this blank, then the field will be named to match the calculation. - **Replace all fields** - (Optional) Select this option if you want to hide all other fields and display only your calculated field in the visualization. -> **Note:** **Cumulative functions** and **Window functions** modes are currently in private preview. Grafana Labs offers support on a best-effort basis, and breaking changes might occur prior to the feature being made generally available. +> **Note:** **Cumulative functions** and **Window functions** modes are currently in public preview. Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. Enable the `addFieldFromCalculationStatFunctions` feature toggle in Grafana to use this feature. Contact Grafana Support to enable this feature in Grafana Cloud. In the example below, we added two fields together and named them Sum. @@ -519,7 +521,7 @@ Use this transformation to customize the output of a string field. This transfor This transformation provides a convenient way to standardize and tailor the presentation of string data for better visualization and analysis. -> **Note:** This transformation is currently in private preview. Grafana Labs offers support on a best-effort basis, and breaking changes might occur prior to the feature being made generally available. +> **Note:** This transformation is currently in public preview. Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. Enable the `formatString` feature toggle in Grafana to use this feature. Contact Grafana Support to enable this feature in Grafana Cloud. ### Format time @@ -627,6 +629,50 @@ We can generate a matrix using the values of 'Server Status' as column names, th Use this transformation to construct a matrix by specifying fields from your query results. The matrix output reflects the relationships between the unique values in these fields. This helps you present complex relationships in a clear and structured matrix format. +### Group to nested table + +Use this transformation to group the data by a specified field (column) value and process calculations on each group. Records are generated that share the same grouped field value, to be displayed in a nested table. + +To calculate a statistic for a field, click the selection box next to it and select the **Calculate** option: + +{{< figure src="/static/img/docs/transformations/nested-table-select-calculation.png" class="docs-image--no-shadow" max-width= "1100px" alt="A select box showing the Group and Calculate options for the transformation." >}} + +Once **Calculate** has been selected, another selection box will appear next to the respective field which will allow statistics to be selected: + +{{< figure src="/static/img/docs/transformations/nested-table-select-stat.png" class="docs-image--no-shadow" max-width= "1100px" alt="A select box showing available statistic calculations once the calculate option for the field has been selected." >}} + +For information about available calculations, refer to [Calculation types][]. + +Here's an example of original data: + +| Time | Server ID | CPU Temperature | Server Status | +| ------------------- | --------- | --------------- | ------------- | +| 2020-07-07 11:34:20 | server 1 | 80 | Shutdown | +| 2020-07-07 11:34:20 | server 3 | 62 | OK | +| 2020-07-07 10:32:20 | server 2 | 90 | Overload | +| 2020-07-07 10:31:22 | server 3 | 55 | OK | +| 2020-07-07 09:30:57 | server 3 | 62 | Rebooting | +| 2020-07-07 09:30:05 | server 2 | 88 | OK | +| 2020-07-07 09:28:06 | server 1 | 80 | OK | +| 2020-07-07 09:25:05 | server 2 | 88 | OK | +| 2020-07-07 09:23:07 | server 1 | 86 | OK | + +This transformation has two steps. First, specify one or more fields by which to group the data. This groups all the same values of those fields together, as if you sorted them. For instance, if you group by the Server ID field, Grafana groups the data this way: + +| Server ID | | +| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| server 1 | <table><th><tr><td>Time</td><td>CPU Temperature</td><td>Server Status</td></tr></th><tbody><tr><td>2020-07-07 11:34:20</td><td>80</td><td>Shutdown</td></tr><tr><td>2020-07-07 09:28:06</td><td>80</td><td>OK</td></tr><tr><td>2020-07-07 09:23:07</td><td>86</td><td>OK</td></tr></tbody></table> | +| server 2 | <table><th><tr><td>Time</td><td>CPU Temperature</td><td>Server Status</td></tr></th><tbody><tr><td>2020-07-07 10:32:20</td><td>90</td><td>Overload</td></tr><tr><td>2020-07-07 09:30:05</td><td>88</td><td>OK</td></tr><tr><td>2020-07-07 09:25:05</td><td>88</td><td>OK</td></tr></tbody></table> | +| server 3 | <table><th><tr><td>Time</td><td>CPU Temperature</td><td>Server Status</td></tr></th><tbody><tr><td>2020-07-07 11:34:20</td><td>62</td><td>OK</td></tr><tr><td>2020-07-07 10:31:22</td><td>55</td><td>OK</td></tr><tr><td>2020-07-07 09:30:57</td><td>62</td><td>Rebooting</td></tr></tbody></table> | + +After choosing the field by which you want to group your data, you can add various calculations on the other fields and apply the calculation to each group of rows. For instance, you might want to calculate the average CPU temperature for each of those servers. To do so, add the **mean calculation** applied on the CPU Temperature field to get the following result: + +| Server ID | CPU Temperatute (mean) | | +| --------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| server 1 | 82 | <table><th><tr><td>Time</td><td>Server Status</td></tr></th><tbody><tr><td>2020-07-07 11:34:20</td><td>Shutdown</td></tr><tr><td>2020-07-07 09:28:06</td><td>OK</td></tr><tr><td>2020-07-07 09:23:07</td><td>OK</td></tr></tbody></table> | +| server 2 | 88.6 | <table><th><tr><td>Time</td><td>Server Status</td></tr></th><tbody><tr><td>2020-07-07 10:32:20</td><td>Overload</td></tr><tr><td>2020-07-07 09:30:05</td><td>OK</td></tr><tr><td>2020-07-07 09:25:05</td><td>OK</td></tr></tbody></table> | +| server 3 | 59.6 | <table><th><tr><td>Time</td><td>Server Status</td></tr></th><tbody><tr><td>2020-07-07 11:34:20</td><td>OK</td></tr><tr><td>2020-07-07 10:31:22</td><td>OK</td></tr><tr><td>2020-07-07 09:30:57</td><td>Rebooting</td></tr></tbody></table> | + ### Create heatmap Use this transformation to prepare histogram data for visualizing trends over time. Similar to the heatmap visualization, this transformation converts histogram metrics into temporal buckets. @@ -1261,9 +1307,13 @@ This transformation allows you to manipulate and analyze geospatial data, enabli ### Time series to table transform -Use this transformation to convert time series results into a table, transforming a time series data frame into a **Trend** field. The **Trend** field can then be rendered using the [sparkline cell type][], generating an inline sparkline for each table row. If there are multiple time series queries, each will result in a separate table data frame. These can be joined using join or merge transforms to produce a single table with multiple sparklines per row. +Use this transformation to convert time series results into a table, transforming a time series data frame into a **Trend** field which can then be used with the [sparkline cell type][]. If there are multiple time series queries, each will result in a separate table data frame. These can be joined using join or merge transforms to produce a single table with multiple sparklines per row. + +{{< figure src="/static/img/docs/transformations/table-sparklines.png" class="docs-image--no-shadow" max-width= "1100px" alt="A table panel showing multiple values and their corresponding sparklines." >}} + +For each generated **Trend** field value, a calculation function can be selected. This value is displayed next to the sparkline and will be used for sorting table rows. -For each generated **Trend** field value, a calculation function can be selected. The default is **Last non-null value**. This value is displayed next to the sparkline and used for sorting table rows. +{{< figure src="/static/img/docs/transformations/timeseries-table-select-stat.png" class="docs-image--no-shadow" max-width= "1100px" alt="A select box showing available statistics that can be calculated." >}} > **Note:** This transformation is available in Grafana 9.5+ as an opt-in beta feature. Modify the Grafana [configuration file][] to use it. @@ -1278,7 +1328,7 @@ There are two different models: - **Polynomial regression** - Fits a polynomial function to the data. {{< figure src="/static/img/docs/transformations/polynomial-regression.png" class="docs-image--no-shadow" max-width= "1100px" alt="A time series visualization with a curved line representing the polynomial function" >}} -> **Note:** This transformation is currently in private preview. Grafana Labs offers support on a best-effort basis, and breaking changes might occur prior to the feature being made generally available. +> **Note:** This transformation is currently in public preview. Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. Enable the `regressionTransformation` feature toggle in Grafana to use this feature. Contact Grafana Support to enable this feature in Grafana Cloud. {{% docs/reference %}} [Table panel]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/table" diff --git a/docs/sources/panels-visualizations/visualizations/_index.md b/docs/sources/panels-visualizations/visualizations/_index.md index 10042851d851b..b5c26e0f79ffa 100644 --- a/docs/sources/panels-visualizations/visualizations/_index.md +++ b/docs/sources/panels-visualizations/visualizations/_index.md @@ -20,6 +20,8 @@ weight: 10 Grafana offers a variety of visualizations to support different use cases. This section of the documentation highlights the built-in visualizations, their options and typical usage. +{{< youtube id="JwF6FgeotaU" >}} + {{% admonition type="note" %}} If you are unsure which visualization to pick, Grafana can provide visualization suggestions based on the panel query. When you select a visualization, Grafana will show a preview with that visualization applied. {{% /admonition %}} @@ -33,6 +35,7 @@ If you are unsure which visualization to pick, Grafana can provide visualization - [Heatmap][] visualizes data in two dimensions, used typically for the magnitude of a phenomenon. - [Pie chart][] is typically used where proportionality is important. - [Candlestick][] is typically for financial data where the focus is price/data movement. + - [Gauge][] is the traditional rounded visual showing how far a single metric is from a threshold. - Stats & numbers - [Stat][] for big stats and optional sparkline. - [Bar gauge][] is a horizontal or vertical bar gauge. @@ -42,12 +45,18 @@ If you are unsure which visualization to pick, Grafana can provide visualization - [Node graph][] for directed graphs or networks. - [Traces][] is the main visualization for traces. - [Flame graph][] is the main visualization for profiling. + - [Canvas][] allows you to explicitly place elements within static and dynamic layouts. + - [Geomap][] helps you visualize geospatial data. - Widgets - [Dashboard list][] can list dashboards. - [Alert list][] can list alerts. - [Text][] can show markdown and html. - [News][] can show RSS feeds. +The following video shows you how to create gauge, time series line graph, stats, logs, and node graph visualizations: + +{{< youtube id="yNRnLyVntUw" >}} + ## Get more You can add more visualization types by installing panel [panel plugins](https://grafana.com/grafana/plugins/?type=panel). @@ -122,6 +131,12 @@ A state timeline shows discrete state changes over time. When used with time ser [Flame graph]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/flame-graph" [Flame graph]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/flame-graph" +[Canvas]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/canvas" +[Canvas]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/canvas" + +[Geomap]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/geomap" +[Geomap]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/geomap" + [Status history]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/status-history" [Status history]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/status-history" diff --git a/docs/sources/panels-visualizations/visualizations/canvas/index.md b/docs/sources/panels-visualizations/visualizations/canvas/index.md index 1b0b4e41ab1e1..106b7d03d3d46 100644 --- a/docs/sources/panels-visualizations/visualizations/canvas/index.md +++ b/docs/sources/panels-visualizations/visualizations/canvas/index.md @@ -85,6 +85,16 @@ You can edit your canvas inline while in the context of dashboard mode. {{< video-embed src="/static/img/docs/canvas-panel/canvas-inline-editor-9-2-0.mp4" max-width="750px" caption="Inline editor demo" >}} +### Pan and zoom + +You can enable panning and zooming in a canvas. This allows you to both create and navigate more complex designs. + +{{< docs/public-preview product="Canvas pan and zoom" featureFlag="`canvasPanelPanZoom`" >}} + +{{< figure src="/media/docs/grafana/screenshot-grafana-10-3-canvas-pan-zoom-setting.png" max-width="300px" alt="Canvas pan zoom control" >}} + +{{< video-embed src="/media/docs/grafana/2024-01-05-Canvas-Pan-&-Zoom-Enablement-Video.mp4" max-width="750px" caption="Canvas pan and zoom enablement video" >}} + ### Context menu The context menu lets you perform common tasks quickly and efficiently. Supported functionality includes opening / closing the inline editor, duplicating an element, deleting an element, and more. @@ -97,6 +107,16 @@ When right clicking an element, you are able to edit, delete, duplicate, and mod {{< figure src="/static/img/docs/canvas-panel/canvas-context-menu-9-2-0.png" max-width="750px" caption="Canvas element context menu" >}} +### Element snapping and alignment + +When you're moving elements around the canvas, snapping and alignment guides help you create more precise layouts. + +{{% admonition type="note" %}} +Currently, element snapping and alignment only works when the canvas is not zoomed in. +{{% /admonition %}} + +<!-- TODO: Add gif showcasing feature (when creating what's new entry for 10.4) --> + ## Canvas options ### Inline editing @@ -107,7 +127,7 @@ The inline editing toggle lets you lock or unlock the canvas. When turned off th ### Data links -Canvases support [data links](https://grafana.com/docs/grafana/latest/panels-visualizations/configure-data-links/). You can create a data link for a metric-value element and display it for all elements that use the field name by following these steps: +Canvases support [data links](https://grafana.com/docs/grafana/latest/panels-visualizations/configure-data-links/), but only for metric-value, text, rectangle, and ellipse elements. You can add a data link by following these steps: 1. Set an element to be tied to a field value. 1. Turn off the inline editing toggle. diff --git a/docs/sources/panels-visualizations/visualizations/geomap/index.md b/docs/sources/panels-visualizations/visualizations/geomap/index.md index ce21ee4262142..8abc60912e778 100644 --- a/docs/sources/panels-visualizations/visualizations/geomap/index.md +++ b/docs/sources/panels-visualizations/visualizations/geomap/index.md @@ -282,6 +282,26 @@ The GeoJSON layer allows you to select and load a static GeoJSON file from the f - **Add style rule** creates additional style rules. - **Display tooltip** allows you to toggle tooltips for the layer. +{{% admonition type="note" %}} +Styles can be set within the "properties" object of the GeoJSON with support for the following geometries: + +- Polygon, MultiPolygon + + - **"fill"** - The color of the interior of the polygon(s) + - **"fill-opacity"** - The opacity of the interior of the polygon(s) + - **"stroke-width"** - The width of the line component of the polygon(s) + +- Point, MultiPoint + + - **"marker-color"** - The color of the point(s) + - **"marker-size"** - The size of the point(s) + +- LineString, MultiLineString + - **"stroke"** - The color of the line(s) + - **"stroke-width"** - The width of the line(s) + +{{% /admonition %}} + ## Night / Day layer The Night / Day layer displays night and day regions based on the current time range. @@ -457,7 +477,7 @@ A map from a collaborative free geographic world database. ### More Information -- [**About Open Street Map**](https://www.openstreetmap.org/about)\ +- [**About Open Street Map**](https://www.openstreetmap.org/about) ## ArcGIS layer @@ -536,7 +556,7 @@ Displays measure tools in the upper right corner. Measurements appear only when - **Double-click** to end measurement {{% admonition type="note" %}} -<br /- When you change measurement type or units, the previous measurement is removed from the map. <br /- If the control is closed and then re-opened, the most recent measurement is displayed. <br /- A measurement can be modified by clicking and dragging on it. +When you change measurement type or units, the previous measurement is removed from the map. If the control is closed and then re-opened, the most recent measurement is displayed. A measurement can be modified by clicking and dragging on it. {{% /admonition %}} #### Length diff --git a/docs/sources/panels-visualizations/visualizations/histogram/index.md b/docs/sources/panels-visualizations/visualizations/histogram/index.md index 3c6c2cc13fb29..45f468196e0a9 100644 --- a/docs/sources/panels-visualizations/visualizations/histogram/index.md +++ b/docs/sources/panels-visualizations/visualizations/histogram/index.md @@ -71,8 +71,6 @@ Transparency of the gradient is calculated based on the values on the Y-axis. Th Gradient color is generated based on the hue of the line color. -{{< docs/shared lookup="visualizations/tooltip-mode.md" source="grafana" version="<GRAFANA VERSION>" >}} - {{< docs/shared lookup="visualizations/legend-mode.md" source="grafana" version="<GRAFANA VERSION>" >}} ### Legend calculations diff --git a/docs/sources/panels-visualizations/visualizations/node-graph/index.md b/docs/sources/panels-visualizations/visualizations/node-graph/index.md index 96fe3cb7bfa2e..eb28da6aeaf10 100644 --- a/docs/sources/panels-visualizations/visualizations/node-graph/index.md +++ b/docs/sources/panels-visualizations/visualizations/node-graph/index.md @@ -104,13 +104,21 @@ Required fields: Optional fields: -| Field name | Type | Description | -| ------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| mainstat | string/number | First stat shown in the overlay when hovering over the edge. It can be a string showing the value as is or it can be a number. If it is a number, any unit associated with that field is also shown | -| secondarystat | string/number | Same as mainStat, but shown right under it. | -| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the edge. Use `config.displayName` for more human readable label. | -| thickness | number | The thickness of the edge. Default: `1` | -| highlighted | boolean | Sets whether the edge should be highlighted. Useful, for example, to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` | +| Field name | Type | Description | +| --------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| mainstat | string/number | First stat shown in the overlay when hovering over the edge. It can be a string showing the value as is or it can be a number. If it is a number, any unit associated with that field is also shown | +| secondarystat | string/number | Same as mainStat, but shown right under it. | +| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the edge. Use `config.displayName` for more human readable label. | +| thickness | number | The thickness of the edge. Default: `1` | +| highlighted | boolean | Sets whether the edge should be highlighted. Useful, for example, to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` | +| color | string | Sets the default color of the edge. It can be an acceptable HTML color string. Default: `#999` | +| strokeDasharray | string | Sets the pattern of dashes and gaps used to render the edge. If unset, a solid line is used as edge. For more information and examples, refer to the [`stroke-dasharray` MDN documentation](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray). | + +{{< admonition type="caution" >}} +Starting with 10.5, `highlighted` is deprecated. +It will be removed in a future release. +Use `color` to indicate a highlighted edge state instead. +{{< /admonition >}} ### Nodes data frame structure diff --git a/docs/sources/panels-visualizations/visualizations/table/index.md b/docs/sources/panels-visualizations/visualizations/table/index.md index d78221b79d0af..52c26ff7c933b 100644 --- a/docs/sources/panels-visualizations/visualizations/table/index.md +++ b/docs/sources/panels-visualizations/visualizations/table/index.md @@ -27,7 +27,7 @@ weight: 100 # Table -Tables are very flexible, supporting multiple modes for time series and for tables, annotation, and raw JSON data. This visualization also provides date formatting, value formatting, and coloring options. +Tables are very flexible, supporting multiple modes for time series and for tables, annotation, and raw JSON data. This visualization also provides date formatting, value formatting, and coloring options. In addition to formatting and coloring options, Grafana also provides a variety of _Cell types_ which you can use to display gauges, sparklines, and other rich data displays. {{< figure src="/static/img/docs/tables/table_visualization.png" max-width="1200px" lightbox="true" caption="Table visualization" >}} @@ -148,10 +148,12 @@ If you have a field value that is an image URL or a base64 encoded image you can ### Sparkline -Shows value rendered as a sparkline. Requires [time series to table][] data transform. +Shows values rendered as a sparkline. You can show sparklines using the [Time series to table transformation][] on data with multiple time series to process it into a format the table can show. {{< figure src="/static/img/docs/tables/sparkline2.png" max-width="500px" caption="Sparkline" class="docs-image--no-shadow" >}} +You can be customize sparklines with many of the same options as the [Time series panel][] including line width, fill opacity, and more. You can also change the color of the sparkline by updating the [color scheme][] in the _Standard options_ section of the panel configuration. + ## Cell value inspect Enables value inspection from table cell. The raw value is presented in a modal window. @@ -191,6 +193,14 @@ To filter column values, click the filter (funnel) icon next to a column title. Click the check box next to the values that you want to display. Enter text in the search field at the top to show those values in the display so that you can select them rather than scroll to find them. +Choose from several operators to display column values: + +- **Contains** - Matches a regex pattern (operator by default). +- **Expression** - Evaluates a boolean expression. The character `$` represents the column value in the expression (for example, "$ >= 10 && $ <= 12"). +- The typical comparison operators: `=`, `!=`, `<`, `<=`, `>`, `>=`. + +Click the check box above the **Ok** and **Cancel** buttons to add or remove all displayed values to/from the filter. + ### Clear column filters Columns with filters applied have a blue funnel displayed next to the title. @@ -218,8 +228,14 @@ If you want to show the number of rows in the dataset instead of the number of v [calculations]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/query-transform-data/calculation-types" [calculations]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/calculation-types" -[time series to table]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/query-transform-data/transform-data#time-series-to-table-transform" -[time series to table]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/query-transform-data/transform-data#time-series-to-table-transform" +[Time series to table transformation]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/query-transform-data/transform-data#time-series-to-table-transform" +[Time series to table transformation]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/query-transform-data/transform-data#time-series-to-table-transform" + +[Time series panel]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/time-series" +[Time series panel]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/time-series" + +[color scheme]: "/docs/grafana/ -> /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/configure-standard-options#color-scheme" +[color scheme]: "/docs/grafana-cloud -> /docs/grafana-cloud/visualizations/panels-visualizations/configure-standard-options#color-scheme" [configuration file]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana#configuration-file-location" [configuration file]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/setup-grafana/configure-grafana#configuration-file-location" diff --git a/docs/sources/panels-visualizations/visualizations/time-series/index.md b/docs/sources/panels-visualizations/visualizations/time-series/index.md index ace758155b924..e41c20fa2bb32 100644 --- a/docs/sources/panels-visualizations/visualizations/time-series/index.md +++ b/docs/sources/panels-visualizations/visualizations/time-series/index.md @@ -50,6 +50,10 @@ Tooltip options control the information overlay that appears when you hover over {{< docs/shared lookup="visualizations/tooltip-mode.md" source="grafana" version="<GRAFANA VERSION>" >}} +### Hover proximity + +This option controls how close your cursor must be to a data point before the tooltip appears. Values are in pixels. + ## Legend options Legend options control the series names and statistics that appear under or to the right of the graph. diff --git a/docs/sources/release-notes/release-notes-7-4-0.md b/docs/sources/release-notes/release-notes-7-4-0.md index 57e16aca6f57d..7f40ad03dbe67 100644 --- a/docs/sources/release-notes/release-notes-7-4-0.md +++ b/docs/sources/release-notes/release-notes-7-4-0.md @@ -163,7 +163,7 @@ Issue [#29407](https://github.com/grafana/grafana/issues/29407) We have upgraded AngularJS from version 1.6.6 to 1.8.2. Due to this upgrade some old angular plugins might stop working and will require a small update. This is due to the deprecation and removal of pre-assigned bindings. So if your custom angular controllers expect component bindings in the controller constructor you need to move this code to an `$onInit` function. For more details on how to migrate AngularJS code open the [migration guide](https://docs.angularjs.org/guide/migration) and search for **pre-assigning bindings**. -In order not to break all angular panel plugins and data sources we have some custom [angular inject behavior](https://github.com/grafana/grafana/blob/master/public/app/core/injectorMonkeyPatch.ts) that makes sure that bindings for these controllers are still set before constructor is called so many old angular panels and data source plugins will still work. Issue [#28736](https://github.com/grafana/grafana/issues/28736) +In order not to break all angular panel plugins and data sources we have some custom [angular inject behavior](https://github.com/grafana/grafana/blob/main/public/app/core/injectorMonkeyPatch.ts) that makes sure that bindings for these controllers are still set before constructor is called so many old angular panels and data source plugins will still work. Issue [#28736](https://github.com/grafana/grafana/issues/28736) ### Deprecations diff --git a/docs/sources/release-notes/release-notes-8-0-0-beta1.md b/docs/sources/release-notes/release-notes-8-0-0-beta1.md index a1f3d5fd38578..1f4e450c62613 100644 --- a/docs/sources/release-notes/release-notes-8-0-0-beta1.md +++ b/docs/sources/release-notes/release-notes-8-0-0-beta1.md @@ -131,7 +131,7 @@ Issue [#33352](https://github.com/grafana/grafana/issues/33352) ### Plugin development fixes & changes - **Button**: Introduce buttonStyle prop. [#33384](https://github.com/grafana/grafana/pull/33384), [@jackw](https://github.com/jackw) -- **DataQueryRequest**: Remove deprecated props showingGraph and showingTabel and exploreMode. [#31876](https://github.com/grafana/grafana/pull/31876), [@torkelo](https://github.com/torkelo) +- **DataQueryRequest**: Remove deprecated props showingGraph and showingTable and exploreMode. [#31876](https://github.com/grafana/grafana/pull/31876), [@torkelo](https://github.com/torkelo) - **grafana/ui**: Update React Hook Form to v7. [#33328](https://github.com/grafana/grafana/pull/33328), [@Clarity-89](https://github.com/Clarity-89) - **IconButton**: Introduce variant for red and blue icon buttons. [#33479](https://github.com/grafana/grafana/pull/33479), [@jackw](https://github.com/jackw) - **Plugins**: Expose the `getTimeZone` function to be able to get the current selected timeZone. [#31900](https://github.com/grafana/grafana/pull/31900), [@mckn](https://github.com/mckn) diff --git a/docs/sources/release-notes/release-notes-8-2-0.md b/docs/sources/release-notes/release-notes-8-2-0.md index 25d61e450ea6f..e03b005aa18d9 100644 --- a/docs/sources/release-notes/release-notes-8-2-0.md +++ b/docs/sources/release-notes/release-notes-8-2-0.md @@ -34,7 +34,7 @@ title: Release notes for Grafana 8.2.0 #### Potential failure to start in Ubuntu 18.04 / Debian 9 / CentOS -- In Grafana v8.2.0, this change can prevent the `grafana-server` service from starting on older versions of systemd, present on Ubuntu 18.04 and slightly older versions of Debian. If running one of those versions, please wait until v8.2.1 is released before upgrading. If you still want to upgrade or have already ugpraded, a simple fix is available here: https://github.com/grafana/grafana/issues/40162#issuecomment-938060240 Issue [#38109](https://github.com/grafana/grafana/issues/38109) +- In Grafana v8.2.0, this change can prevent the `grafana-server` service from starting on older versions of systemd, present on Ubuntu 18.04 and slightly older versions of Debian. If running one of those versions, please wait until v8.2.1 is released before upgrading. If you still want to upgrade or have already upgraded, a simple fix is available here: https://github.com/grafana/grafana/issues/40162#issuecomment-938060240 Issue [#38109](https://github.com/grafana/grafana/issues/38109) ### Plugin development fixes & changes diff --git a/docs/sources/release-notes/release-notes-9-0-4.md b/docs/sources/release-notes/release-notes-9-0-4.md index 8e8f4f68c4e4d..2fa37b35bbe08 100644 --- a/docs/sources/release-notes/release-notes-9-0-4.md +++ b/docs/sources/release-notes/release-notes-9-0-4.md @@ -18,7 +18,7 @@ title: Release notes for Grafana 9.0.4 - **Browse/Search:** Make browser back work properly when visiting Browse or search. [#52271](https://github.com/grafana/grafana/pull/52271), [@torkelo](https://github.com/torkelo) - **Logs:** Improve getLogRowContext API. [#52130](https://github.com/grafana/grafana/pull/52130), [@gabor](https://github.com/gabor) - **Loki:** Improve handling of empty responses. [#52397](https://github.com/grafana/grafana/pull/52397), [@gabor](https://github.com/gabor) -- **Plugins:** Always validate root URL if specified in signature manfiest. [#52332](https://github.com/grafana/grafana/pull/52332), [@wbrowne](https://github.com/wbrowne) +- **Plugins:** Always validate root URL if specified in signature manifest. [#52332](https://github.com/grafana/grafana/pull/52332), [@wbrowne](https://github.com/wbrowne) - **Preferences:** Get home dashboard from teams. [#52225](https://github.com/grafana/grafana/pull/52225), [@sakjur](https://github.com/sakjur) - **SQLStore:** Support Upserting multiple rows. [#52228](https://github.com/grafana/grafana/pull/52228), [@joeblubaugh](https://github.com/joeblubaugh) - **Traces:** Add more template variables in Tempo & Zipkin. [#52306](https://github.com/grafana/grafana/pull/52306), [@joey-grafana](https://github.com/joey-grafana) diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 1067b50ea53bd..a9a5f3437d4c2 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -23,7 +23,7 @@ After you add custom options, [uncomment](#remove-comments-in-the-ini-files) the The default settings for a Grafana instance are stored in the `$WORKING_DIR/conf/defaults.ini` file. _Do not_ change this file. -Depending on your OS, your custom configuration file is either the `$WORKING_DIR/conf/defaults.ini` file or the `/usr/local/etc/grafana/grafana.ini` file. The custom configuration file path can be overridden using the `--config` parameter. +Depending on your OS, your custom configuration file is either the `$WORKING_DIR/conf/custom.ini` file or the `/usr/local/etc/grafana/grafana.ini` file. The custom configuration file path can be overridden using the `--config` parameter. ### Linux @@ -148,20 +148,6 @@ Options are `production` and `development`. Default is `production`. _Do not_ ch Set the name of the grafana-server instance. Used in logging, internal metrics, and clustering info. Defaults to: `${HOSTNAME}`, which will be replaced with environment variable `HOSTNAME`, if that is empty or does not exist Grafana will try to use system calls to get the machine name. -## force_migration - -{{% admonition type="note" %}} -This option is deprecated - [See `clean_upgrade` option]({{< relref "#clean_upgrade" >}}) instead. -{{% /admonition %}} - -When you restart Grafana to rollback from Grafana Alerting to legacy alerting, delete any existing Grafana Alerting data, such as alert rules, contact points, and notification policies. Default is `false`. - -If `false` or unset, existing Grafana Alerting data is not changed or deleted when rolling back to legacy alerting. - -{{% admonition type="note" %}} -It should be kept false or unset when not needed, as it may cause unintended data loss if left enabled. -{{% /admonition %}} - <hr /> ## [paths] @@ -369,7 +355,7 @@ The maximum number of connections in the idle connection pool. ### max_open_conn -The maximum number of open connections to the database. +The maximum number of open connections to the database. For MYSQL, configure this setting on both Grafana and the database. For more information, refer to [`sysvar_max_connections`](https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_max_connections). ### conn_max_lifetime @@ -388,6 +374,10 @@ Set to `true` to log the sql calls and execution times. For Postgres, use use any [valid libpq `sslmode`](https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS), e.g.`disable`, `require`, `verify-full`, etc. For MySQL, use either `true`, `false`, or `skip-verify`. +### ssl_sni + +For Postgres, set to `0` to disable [Server Name Indication](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLSNI). This is enabled by default on SSL-enabled connections. + ### isolation_level Only the MySQL driver supports isolation levels in Grafana. In case the value is empty, the driver's default isolation level is applied. Available options are "READ-UNCOMMITTED", "READ-COMMITTED", "REPEATABLE-READ" or "SERIALIZABLE". @@ -707,7 +697,6 @@ The core features that depend on angular are: - Old graph panel - Old table panel -- Legacy alerting edit rule UI These features each have supported alternatives, and we recommend using them. @@ -833,7 +822,11 @@ The available options are `Viewer` (default), `Admin`, `Editor`, and `None`. For ### verify_email_enabled -Require email validation before sign up completes. Default is `false`. +Require email validation before sign up completes or when updating a user email address. Default is `false`. + +### login_default_org_id + +Set the default organization for users when they sign in. The default is `-1`. ### login_hint @@ -878,6 +871,12 @@ The duration in time a user invitation remains valid before expiring. This setting should be expressed as a duration. Examples: 6h (hours), 2d (days), 1w (week). Default is `24h` (24 hours). The minimum supported duration is `15m` (15 minutes). +### verification_email_max_lifetime_duration + +The duration in time a verification email, used to update the email address of a user, remains valid before expiring. +This setting should be expressed as a duration. Examples: 6h (hours), 2d (days), 1w (week). +Default is 1h (1 hour). + ### hidden_users This is a comma-separated list of usernames. Users specified here are hidden in the Grafana UI. They are still visible to Grafana administrators and to themselves. @@ -1281,6 +1280,10 @@ Name to be used as client identity for EHLO in SMTP dialog, default is `<instanc Either "OpportunisticStartTLS", "MandatoryStartTLS", "NoStartTLS". Default is `empty`. +### enable_tracing + +Enable trace propagation in e-mail headers, using the `traceparent`, `tracestate` and (optionally) `baggage` fields. Default is `false`. To enable, you must first configure tracing in one of the `tracing.oentelemetry.*` sections. + <hr> ## [smtp.static_headers] @@ -1510,9 +1513,9 @@ For more information about the Grafana alerts, refer to [About Grafana Alerting] ### enabled -Enable or disable Grafana Alerting. If disabled, all your legacy alerting data will be available again. The default value is `true`. +Enable or disable Grafana Alerting. The default value is `true`. -Alerting Rules migrated from dashboards and panels will include a link back via the `annotations`. +Alerting rules migrated from dashboards and panels will include a link back via the `annotations`. ### disabled_orgs @@ -1598,11 +1601,11 @@ The interval string is a possibly signed sequence of decimal numbers, followed b ### execute_alerts -Enable or disable alerting rule execution. The default value is `true`. The alerting UI remains visible. This option has a [legacy version in the alerting section]({{< relref "#execute_alerts-1" >}}) that takes precedence. +Enable or disable alerting rule execution. The default value is `true`. The alerting UI remains visible. ### evaluation_timeout -Sets the alert evaluation timeout when fetching data from the data source. The default value is `30s`. This option has a [legacy version in the alerting section]({{< relref "#evaluation_timeout_seconds" >}}) that takes precedence. +Sets the alert evaluation timeout when fetching data from the data source. The default value is `30s`. The timeout string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m. @@ -1612,7 +1615,7 @@ Sets a maximum number of times we'll attempt to evaluate an alert rule before gi ### min_interval -Sets the minimum interval to enforce between rule evaluations. The default value is `10s` which equals the scheduler interval. Rules will be adjusted if they are less than this value or if they are not multiple of the scheduler interval (10s). Higher values can help with resource management as we'll schedule fewer evaluations over time. This option has [a legacy version in the alerting section]({{< relref "#min_interval_seconds" >}}) that takes precedence. +Sets the minimum interval to enforce between rule evaluations. The default value is `10s` which equals the scheduler interval. Rules will be adjusted if they are less than this value or if they are not multiple of the scheduler interval (10s). Higher values can help with resource management as we'll schedule fewer evaluations over time. The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m. @@ -1622,7 +1625,7 @@ The interval string is a possibly signed sequence of decimal numbers, followed b ## [unified_alerting.screenshots] -For more information about screenshots, refer to [Images in notifications]({{< relref "../../alerting/manage-notifications/images-in-notifications" >}}). +For more information about screenshots, refer to [Images in notifications]({{< relref "../../alerting/configure-notifications/template-notifications/images-in-notifications" >}}). ### capture @@ -1650,71 +1653,15 @@ For example: `disabled_labels=grafana_folder` <hr> -## [unified_alerting.upgrade] - -For more information about upgrading to Grafana Alerting, refer to [Upgrade Alerting](/docs/grafana/next/alerting/set-up/migrating-alerts/). - -### clean_upgrade - -When you restart Grafana to upgrade from legacy alerting to Grafana Alerting, delete any existing Grafana Alerting data from a previous upgrade, such as alert rules, contact points, and notification policies. Default is `false`. - -If `false` or unset, existing Grafana Alerting data is not changed or deleted when you switch between legacy and Unified Alerting. - -{{% admonition type="note" %}} -It should be kept false when not needed, as it may cause unintended data loss if left enabled. -{{% /admonition %}} - -<hr> - -## [alerting] +## [unified_alerting.state_history.annotations] -For more information about the legacy dashboard alerting feature in Grafana, refer to [the legacy Grafana alerts](/docs/grafana/v8.5/alerting/old-alerting/). +This section controls retention of annotations automatically created while evaluating alert rules when alerting state history backend is configured to be annotations (see setting [unified_alerting.state_history].backend) -### enabled - -Set to `true` to [enable legacy dashboard alerting]({{< relref "#unified_alerting" >}}). The default value is `false`. - -### execute_alerts - -Turns off alert rule execution, but alerting is still visible in the Grafana UI. - -### error_or_timeout - -Default setting for new alert rules. Defaults to categorize error and timeouts as alerting. (alerting, keep_state) - -### nodata_or_nullvalues - -Defines how Grafana handles nodata or null values in alerting. Options are `alerting`, `no_data`, `keep_state`, and `ok`. Default is `no_data`. - -### concurrent_render_limit - -Alert notifications can include images, but rendering many images at the same time can overload the server. -This limit protects the server from render overloading and ensures notifications are sent out quickly. Default value is `5`. - -### evaluation_timeout_seconds - -Sets the alert calculation timeout. Default value is `30`. - -### notification_timeout_seconds - -Sets the alert notification timeout. Default value is `30`. - -### max_attempts - -Sets a maximum limit on attempts to sending alert notifications. Default value is `3`. - -### min_interval_seconds - -Sets the minimum interval between rule evaluations. Default value is `1`. - -> **Note.** This setting has precedence over each individual rule frequency. If a rule frequency is lower than this value, then this value is enforced. - -### max_annotation_age = +### max_age -Configures for how long alert annotations are stored. Default is 0, which keeps them forever. -This setting should be expressed as a duration. Examples: 6h (hours), 10d (days), 2w (weeks), 1M (month). +Configures for how long alert annotations are stored. Default is 0, which keeps them forever. This setting should be expressed as an duration. Ex 6h (hours), 10d (days), 2w (weeks), 1M (month). -### max_annotations_to_keep = +### max_annotations_to_keep Configures max number of alert annotations that Grafana stores. Default value is 0, which keeps all alert annotations. @@ -2164,6 +2111,18 @@ If the remote HTTP image renderer service runs on a different server than the Gr Concurrent render request limit affects when the /render HTTP endpoint is used. Rendering many images at the same time can overload the server, which this setting can help protect against by only allowing a certain number of concurrent requests. Default is `30`. +### default_image_width + +Configures the width of the rendered image. The default width is `1000`. + +### default_image_height + +Configures the height of the rendered image. The default height is `500`. + +### default_image_scale + +Configures the scale of the rendered image. The default scale is `1`. + ## [panels] ### enable_alpha @@ -2172,7 +2131,11 @@ Set to `true` if you want to test alpha panels that are not yet ready for genera ### disable_sanitize_html -If set to true Grafana will allow script tags in text panels. Not recommended as it enables XSS vulnerabilities. Default is false. This setting was introduced in Grafana v6.0. +{{% admonition type="note" %}} +This configuration is not available in Grafana Cloud instances. +{{% /admonition %}} + +If set to true Grafana will allow script tags in text panels. Not recommended as it enables XSS vulnerabilities. Default is false. ## [plugins] diff --git a/docs/sources/setup-grafana/configure-grafana/configure-custom-branding/index.md b/docs/sources/setup-grafana/configure-grafana/configure-custom-branding/index.md index 4aa2132d3a38b..f67f42b842fda 100644 --- a/docs/sources/setup-grafana/configure-grafana/configure-custom-branding/index.md +++ b/docs/sources/setup-grafana/configure-grafana/configure-custom-branding/index.md @@ -6,7 +6,6 @@ description: Change the look of Grafana to match your corporate brand. labels: products: - enterprise - - oss title: Configure custom branding weight: 300 --- diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 553823c3986c3..4a040f36d7ec3 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -24,62 +24,78 @@ Some features are enabled by default. You can disable these feature by setting t | `disableEnvelopeEncryption` | Disable envelope encryption (emergency only) | | | `publicDashboards` | [Deprecated] Public dashboards are now enabled by default; to disable them, use the configuration setting. This feature toggle will be removed in the next major version. | Yes | | `featureHighlights` | Highlight Grafana Enterprise features | | +| `correlations` | Correlations page | | | `exploreContentOutline` | Content outline sidebar | Yes | -| `newVizTooltips` | New visualizations tooltips UX | | -| `dataConnectionsConsole` | Enables a new top-level page called Connections. This page is an experiment that provides a better experience when you install and configure data sources and other plugins. | Yes | | `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes | | `redshiftAsyncQueryDataSupport` | Enable async query data support for Redshift | Yes | | `athenaAsyncQueryDataSupport` | Enable async query data support for Athena | Yes | -| `cloudwatchNewRegionsHandler` | Refactor of /regions endpoint, no user-facing changes | Yes | | `nestedFolderPicker` | Enables the new folder picker to work with nested folders. Requires the nestedFolders feature toggle | Yes | -| `emptyDashboardPage` | Enable the redesigned user interface of a dashboard page that includes no panels | Yes | -| `disablePrometheusExemplarSampling` | Disable Prometheus exemplar sampling | | | `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes | | `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | Yes | | `prometheusMetricEncyclopedia` | Adds the metrics explorer component to the Prometheus query builder as an option in metric select | Yes | | `influxdbBackendMigration` | Query InfluxDB InfluxQL without the proxy | Yes | -| `clientTokenRotation` | Replaces the current in-request token rotation so that the client initiates the rotation | Yes | | `prometheusDataplane` | Changes responses to from Prometheus to be compliant with the dataplane specification. In particular, when this feature toggle is active, the numeric `Field.Name` is set from 'Value' to the value of the `__name__` label. | Yes | | `lokiMetricDataplane` | Changes metric responses from Loki to be compliant with the dataplane specification. | Yes | | `dataplaneFrontendFallback` | Support dataplane contract field name change for transformations and field name matchers where the name is different | Yes | -| `useCachingService` | When active, the new query and resource caching implementation using a wire service inject replaces the previous middleware implementation. | Yes | | `enableElasticsearchBackendQuerying` | Enable the processing of queries and responses in the Elasticsearch data source through backend | Yes | -| `advancedDataSourcePicker` | Enable a new data source picker with contextual information, recently used order and advanced mode | Yes | -| `cloudWatchLogsMonacoEditor` | Enables the Monaco editor for CloudWatch Logs queries | Yes | | `recordedQueriesMulti` | Enables writing multiple items from a single query within Recorded Queries | Yes | +| `logsExploreTableVisualisation` | A table visualisation for logs in Explore | Yes | | `transformationsRedesign` | Enables the transformations redesign | Yes | -| `grafanaAPIServer` | Enable Kubernetes API Server for Grafana resources | Yes | -| `splitScopes` | Support faster dashboard and folder search by splitting permission scopes into parts | Yes | +| `traceQLStreaming` | Enables response streaming of TraceQL queries of the Tempo data source | | +| `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled | Yes | | `prometheusConfigOverhaulAuth` | Update the Prometheus configuration page with the new auth component | Yes | | `influxdbSqlSupport` | Enable InfluxDB SQL query language support with new querying UI | Yes | +| `alertingNoDataErrorExecution` | Changes how Alerting state manager handles execution of NoData/Error | Yes | +| `angularDeprecationUI` | Display Angular warnings in dashboards and panels | Yes | | `alertingInsights` | Show the new alerting insights landing page | Yes | -| `cloudWatchWildCardDimensionValues` | Fetches dimension values from CloudWatch to correctly label wildcard dimensions | Yes | -| `displayAnonymousStats` | Enables anonymous stats to be shown in the UI for Grafana | Yes | +| `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes | +| `recoveryThreshold` | Enables feature recovery threshold (aka hysteresis) for threshold server-side expression | Yes | +| `lokiStructuredMetadata` | Enables the loki data source to request structured metadata from the Loki server | Yes | +| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | Yes | | `lokiQueryHints` | Enables query hints for Loki | Yes | +| `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | | +| `betterPageScrolling` | Removes CustomScrollbar from the UI, relying on native browser scrollbars | Yes | ## Preview feature toggles -| Feature toggle name | Description | -| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `panelTitleSearch` | Search for dashboards using panel title | -| `migrationLocking` | Lock database during migrations | -| `correlations` | Correlations page | -| `autoMigrateOldPanels` | Migrate old angular panels to supported versions (graph, table-old, worldmap, etc) | -| `disableAngular` | Dynamic flag to disable angular at runtime. The preferred method is to set `angular_support_enabled` to `false` in the [security] settings, which allows you to change the state at runtime. | -| `grpcServer` | Run the GRPC server | -| `accessControlOnCall` | Access control primitives for OnCall | -| `nestedFolders` | Enable folder nesting | -| `alertingNoNormalState` | Stop maintaining state of alerts that are not firing | -| `renderAuthJWT` | Uses JWT-based auth for rendering instead of relying on remote cache | -| `refactorVariablesTimeRange` | Refactor time range variables flow to reduce number of API calls made when query variables are chained | -| `faroDatasourceSelector` | Enable the data source selector within the Frontend Apps section of the Frontend Observability | -| `enableDatagridEditing` | Enables the edit functionality in the datagrid panel | -| `sqlDatasourceDatabaseSelection` | Enables previous SQL data source dataset dropdown behavior | -| `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the `useCachingService` feature toggle is enabled and the datasource has caching and async query support enabled | -| `dashgpt` | Enable AI powered features in dashboards | -| `reportingRetries` | Enables rendering retries for the reporting feature | -| `transformationsVariableSupport` | Allows using variables in transformations | -| `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches | +| Feature toggle name | Description | +| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `panelTitleSearch` | Search for dashboards using panel title | +| `migrationLocking` | Lock database during migrations | +| `autoMigrateOldPanels` | Migrate old angular panels to supported versions (graph, table-old, worldmap, etc) | +| `autoMigrateGraphPanel` | Migrate old graph panel to supported time series panel - broken out from autoMigrateOldPanels to enable granular tracking | +| `autoMigrateTablePanel` | Migrate old table panel to supported table panel - broken out from autoMigrateOldPanels to enable granular tracking | +| `autoMigratePiechartPanel` | Migrate old piechart panel to supported piechart panel - broken out from autoMigrateOldPanels to enable granular tracking | +| `autoMigrateWorldmapPanel` | Migrate old worldmap panel to supported geomap panel - broken out from autoMigrateOldPanels to enable granular tracking | +| `autoMigrateStatPanel` | Migrate old stat panel to supported stat panel - broken out from autoMigrateOldPanels to enable granular tracking | +| `disableAngular` | Dynamic flag to disable angular at runtime. The preferred method is to set `angular_support_enabled` to `false` in the [security] settings, which allows you to change the state at runtime. | +| `newVizTooltips` | New visualizations tooltips UX | +| `returnToPrevious` | Enables the return to previous context functionality | +| `grpcServer` | Run the GRPC server | +| `accessControlOnCall` | Access control primitives for OnCall | +| `nestedFolders` | Enable folder nesting | +| `alertingNoNormalState` | Stop maintaining state of alerts that are not firing | +| `renderAuthJWT` | Uses JWT-based auth for rendering instead of relying on remote cache | +| `refactorVariablesTimeRange` | Refactor time range variables flow to reduce number of API calls made when query variables are chained | +| `faroDatasourceSelector` | Enable the data source selector within the Frontend Apps section of the Frontend Observability | +| `enableDatagridEditing` | Enables the edit functionality in the datagrid panel | +| `sqlDatasourceDatabaseSelection` | Enables previous SQL data source dataset dropdown behavior | +| `dashgpt` | Enable AI powered features in dashboards | +| `reportingRetries` | Enables rendering retries for the reporting feature | +| `externalServiceAccounts` | Automatic service account and token setup for plugins | +| `formatString` | Enable format string transformer | +| `transformationsVariableSupport` | Allows using variables in transformations | +| `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches | +| `teamHttpHeaders` | Enables Team LBAC for datasources to apply team headers to the client requests | +| `awsDatasourcesNewFormStyling` | Applies new form styling for configuration and query editors in AWS plugins | +| `managedPluginsInstall` | Install managed plugins directly from plugins catalog | +| `addFieldFromCalculationStatFunctions` | Add cumulative and window functions to the add field from calculation transformation | +| `pdfTables` | Enables generating table data as PDF in reporting | +| `ssoSettingsApi` | Enables the SSO settings API and the OAuth configuration UIs in Grafana | +| `canvasPanelPanZoom` | Allow pan and zoom in canvas panel | +| `alertingSimplifiedRouting` | Enables users to easily configure alert notifications by specifying a contact point directly when editing or creating an alert rule | +| `regressionTransformation` | Enables regression analysis transformation | +| `groupToNestedTableTransformation` | Enables the group to nested table transformation | ## Experimental feature toggles @@ -93,12 +109,10 @@ Experimental features might be changed or removed without prior notice. | `lokiExperimentalStreaming` | Support new streaming approach for loki (prototype, needs special loki build) | | `storage` | Configurable storage for dashboards, datasources, and resources | | `datasourceQueryMultiStatus` | Introduce HTTP 207 Multi Status for api/ds/query | -| `traceToMetrics` | Enable trace to metrics links | | `canvasPanelNesting` | Allow elements nesting | | `scenes` | Experimental framework to build interactive dashboards | | `disableSecretsCompatibility` | Disable duplicated secret storage in legacy tables | | `logRequestsInstrumentedAsUnknown` | Logs the path for requests that are instrumented as unknown | -| `dockedMegaMenu` | Enable support for a persistent (docked) navigation menu | | `showDashboardValidationWarnings` | Show warnings when dashboards do not validate against the schema | | `mysqlAnsiQuotes` | Use double quotes to escape keyword in a MySQL query | | `alertingBacktesting` | Rule backtesting API for alerting | @@ -118,40 +132,25 @@ Experimental features might be changed or removed without prior notice. | `dashboardEmbed` | Allow embedding dashboard for external use in Code editors | | `frontendSandboxMonitorOnly` | Enables monitor only in the plugin frontend sandbox (if enabled) | | `lokiFormatQuery` | Enables the ability to format Loki queries | -| `exploreScrollableLogsContainer` | Improves the scrolling behavior of logs in Explore | | `pluginsDynamicAngularDetectionPatterns` | Enables fetching Angular detection patterns for plugins from GCOM and fallback to hardcoded ones | | `vizAndWidgetSplit` | Split panels between visualizations and widgets | | `prometheusIncrementalQueryInstrumentation` | Adds RudderStack events to incremental queries | -| `logsExploreTableVisualisation` | A table visualisation for logs in Explore | | `awsDatasourcesTempCredentials` | Support temporary security credentials in AWS plugins for Grafana Cloud customers | | `mlExpressions` | Enable support for Machine Learning in server-side expressions | -| `traceQLStreaming` | Enables response streaming of TraceQL queries of the Tempo data source | | `metricsSummary` | Enables metrics summary queries in the Tempo data source | | `featureToggleAdminPage` | Enable admin page for managing feature toggles from the Grafana front-end | -| `traceToProfiles` | Enables linking between traces and profiles | -| `tracesEmbeddedFlameGraph` | Enables embedding a flame graph in traces | | `permissionsFilterRemoveSubquery` | Alternative permission filter implementation that does not use subqueries for fetching the dashboard folder | -| `angularDeprecationUI` | Display new Angular deprecation-related UI features | -| `sseGroupByDatasource` | Send query to the same datasource in a single request when using server side expressions | -| `requestInstrumentationStatusSource` | Include a status source label for request metrics and logs | +| `aiGeneratedDashboardChanges` | Enable AI powered features for dashboards to auto-summary changes when saving | +| `sseGroupByDatasource` | Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch. | | `libraryPanelRBAC` | Enables RBAC support for library panels | | `wargamesTesting` | Placeholder feature flag for internal testing | | `externalCorePlugins` | Allow core plugins to be loaded as external | | `pluginsAPIMetrics` | Sends metrics of public grafana packages usage by plugins | -| `httpSLOLevels` | Adds SLO level to http request metrics | | `idForwarding` | Generate signed id token for identity that can be forwarded to plugins and external services | -| `panelMonitoring` | Enables panel monitoring through logs and measurements | | `enableNativeHTTPHistogram` | Enables native HTTP Histograms | | `kubernetesPlaylists` | Use the kubernetes API in the frontend for playlists, and route /api/playlist requests to k8s | -| `kubernetesSnapshots` | Use the kubernetes API in the frontend to support playlists | -| `recoveryThreshold` | Enables feature recovery threshold (aka hysteresis) for threshold server-side expression | -| `lokiStructuredMetadata` | Enables the loki data source to request structured metadata from the Loki server | -| `teamHttpHeaders` | Enables datasources to apply team headers to the client requests | -| `awsDatasourcesNewFormStyling` | Applies new form styling for configuration and query editors in AWS plugins | +| `kubernetesSnapshots` | Routes snapshot requests from /api to the /apis endpoint | | `cachingOptimizeSerializationMemoryUsage` | If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses. | -| `pluginsInstrumentationStatusSource` | Include a status source label for plugin request metrics and logs | -| `costManagementUi` | Toggles the display of the cost management ui plugin | -| `managedPluginsInstall` | Install managed plugins directly from plugins catalog | | `prometheusPromQAIL` | Prometheus and AI/ML to assist users in creating a query | | `alertmanagerRemoteSecondary` | Enable Grafana to sync configuration and state with a remote Alertmanager. | | `alertmanagerRemotePrimary` | Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager. | @@ -159,13 +158,22 @@ Experimental features might be changed or removed without prior notice. | `annotationPermissionUpdate` | Separate annotation permissions from dashboard permissions to allow for more granular control. | | `extractFieldsNameDeduplication` | Make sure extracted field names are unique in the dataframe | | `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | +| `dashboardSceneSolo` | Enables rendering dashboards using scenes for solo panels | | `dashboardScene` | Enables dashboard rendering using scenes for all roles | -| `canvasPanelPanZoom` | Allow pan and zoom in canvas panel | | `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | | `flameGraphItemCollapsing` | Allow collapsing of flame graph items | -| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | | `pluginsSkipHostEnvVars` | Disables passing host environment variable to plugin processes | | `tableSharedCrosshair` | Enables shared crosshair in table panel | +| `kubernetesFeatureToggles` | Use the kubernetes API for feature toggle management in the frontend | +| `enablePluginsTracingByDefault` | Enable plugin tracing for all external plugins | +| `newFolderPicker` | Enables the nested folder picker without having nested folders enabled | +| `onPremToCloudMigrations` | In-development feature that will allow users to easily migrate their on-prem Grafana instances to Grafana Cloud. | +| `promQLScope` | In-development feature that will allow injection of labels into prometheus queries. | +| `sqlExpressions` | Enables using SQL and DuckDB functions as Expressions. | +| `nodeGraphDotLayout` | Changed the layout algorithm for the node graph | +| `newPDFRendering` | New implementation for the dashboard to PDF rendering | +| `kubernetesAggregator` | Enable grafana aggregator | +| `expressionParser` | Enable new expression parser | ## Development feature toggles @@ -174,8 +182,7 @@ The following toggles require explicitly setting Grafana's [app mode]({{< relref | Feature toggle name | Description | | -------------------------------------- | -------------------------------------------------------------- | | `unifiedStorage` | SQL-based k8s storage | -| `externalServiceAuth` | Starts an OAuth2 authentication provider for external services | | `grafanaAPIServerWithExperimentalAPIs` | Register experimental APIs with the k8s API server | | `grafanaAPIServerEnsureKubectlAccess` | Start an additional https handler and write kubectl options | +| `kubernetesQueryServiceRewrite` | Rewrite requests targeting /ds/query to the query service | | `panelTitleSearchInV1` | Enable searching for dashboards using panel title in search v1 | -| `ssoSettingsApi` | Enables the SSO settings API | diff --git a/docs/sources/setup-grafana/configure-security/_index.md b/docs/sources/setup-grafana/configure-security/_index.md index 2212d5f489db1..c638203fc8d46 100644 --- a/docs/sources/setup-grafana/configure-security/_index.md +++ b/docs/sources/setup-grafana/configure-security/_index.md @@ -33,11 +33,11 @@ Request security is available in Grafana Enterprise v7.4 and later versions. Configure a firewall to restrict Grafana from making network requests to sensitive internal web services. -There are many firewall tools available, refer to the documentation for your specific security tool. For example, Linux users can use [iptables](https://en.wikipedia.org/wiki/Iptables). +There are many firewall tools available. Refer to the documentation for your specific security tool. For example, Linux users can use [iptables](https://en.wikipedia.org/wiki/Iptables). ## Proxy server -Require all network requests being made by Grafana to go through a proxy server. +You can require all network requests made by Grafana to go through a proxy server. Self-hosted reverse proxy options include but are not limited to: @@ -47,9 +47,9 @@ Self-hosted reverse proxy options include but are not limited to: ## Limit Viewer query permissions -Users with the Viewer role can enter _any possible query_ in _any_ of the data sources available in the **organization**, not just the queries that are defined on the dashboards for which the user has Viewer permissions. +Users with the `Viewer role` can enter _any possible query_ in _any_ of the data sources available in the **organization**, not just the queries that are defined on the dashboards for which the user has Viewer permissions. -**For example:** In a Grafana instance with one data source, one dashboard, and one panel that has one query defined, you might assume that a Viewer can only see the result of the query defined in that panel. Actually, the Viewer has access to send any query to the data source. With a command-line tool like curl (there are lots of tools for this), the Viewer can make their own query to the data source and potentially access sensitive data. +For example, in a Grafana instance with one data source, one dashboard, and one panel that has one query defined, you might assume that a Viewer can only see the result of the query defined in that panel. Actually, the Viewer has access to send any query to the data source. With a command-line tool like curl (there are many tools for this), the Viewer can make their own query to the data source and potentially access sensitive data. To address this vulnerability, you can restrict data source query access in the following ways: @@ -58,8 +58,10 @@ To address this vulnerability, you can restrict data source query access in the ## Implications of enabling anonymous access to dashboards -When you enable anonymous access to a dashboard, it is publicly available. This section lists the security implications of enabling Anonymous access. +When you enable anonymous access in Grafana, any visitor or user can use Grafana as a Viewer without signing in. This section lists the security implications of enabling Anonymous access. -- Anyone with the URL can access the dashboard. -- Anyone can make view calls to the API and list all folders, dashboards, and data sources. +- Anyone with the URL of a dashboard accessible by the Viewer role can access that dashboard. +- New dashboards are publicly available unless the dashboard creator hides them from **all Viewers**. +- Anyone can edit or delete dashboards that have granted Edit or Admin abilities to Viewers. +- Anyone can make `view` calls to the API and list all folders, dashboards, and data sources. - Anyone can make arbitrary queries to any data source that the Grafana instance is configured with. diff --git a/docs/sources/setup-grafana/configure-security/audit-grafana.md b/docs/sources/setup-grafana/configure-security/audit-grafana.md index f071fe3e795e1..268279c5025d7 100644 --- a/docs/sources/setup-grafana/configure-security/audit-grafana.md +++ b/docs/sources/setup-grafana/configure-security/audit-grafana.md @@ -269,41 +269,6 @@ external group. \* `resources` may also contain a third item with `"type":` set to `"user"` or `"team"`. -#### Alerts and notification channels management - -| Action | Distinguishing fields | -| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| Save alert manager configuration | `{"action": "update", "requestUri": "/api/alertmanager/RECIPIENT/config/api/v1/alerts"}` | -| Reset alert manager configuration | `{"action": "delete", "requestUri": "/api/alertmanager/RECIPIENT/config/api/v1/alerts"}` | -| Create silence | `{"action": "create", "requestUri": "/api/alertmanager/RECIPIENT/api/v2/silences"}` | -| Delete silence | `{"action": "delete", "requestUri": "/api/alertmanager/RECIPIENT/api/v2/silences/SILENCE-ID"}` | -| Create alert | `{"action": "create", "requestUri": "/api/ruler/RECIPIENT/api/v2/alerts"}` | -| Create or update rule group | `{"action": "create-update", "requestUri": "/api/ruler/RECIPIENT/api/v1/rules/NAMESPACE"}` | -| Delete rule group | `{"action": "delete", "requestUri": "/api/ruler/RECIPIENT/api/v1/rules/NAMESPACE/GROUP-NAME"}` | -| Delete namespace | `{"action": "delete", "requestUri": "/api/ruler/RECIPIENT/api/v1/rules/NAMESPACE"}` | -| Test Grafana managed receivers | `{"action": "test", "requestUri": "/api/alertmanager/RECIPIENT/config/api/v1/receivers/test"}` | -| Create or update the NGalert configuration of the user's organization | `{"action": "create-update", "requestUri": "/api/v1/ngalert/admin_config"}` | -| Delete the NGalert configuration of the user's organization | `{"action": "delete", "requestUri": "/api/v1/ngalert/admin_config"}` | - -Where the following: - -- `RECIPIENT` is `grafana` for requests handled by Grafana or the data source UID for requests forwarded to a data source. -- `NAMESPACE` is the string identifier for the rules namespace. -- `GROUP-NAME` is the string identifier for the rules group. -- `SILENCE-ID` is the ID of the affected silence. - -The following legacy alerting actions are still supported: - -| Action | Distinguishing fields | -| --------------------------------- | --------------------------------------------------------------------- | -| Test alert rule | `{"action": "test", "resources": [{"type": "panel"}]}` | -| Pause alert | `{"action": "pause", "resources": [{"type": "alert"}]}` | -| Pause all alerts | `{"action": "pause-all"}` | -| Test alert notification channel | `{"action": "test", "resources": [{"type": "alert-notification"}]}` | -| Create alert notification channel | `{"action": "create", "resources": [{"type": "alert-notification"}]}` | -| Update alert notification channel | `{"action": "update", "resources": [{"type": "alert-notification"}]}` | -| Delete alert notification channel | `{"action": "delete", "resources": [{"type": "alert-notification"}]}` | - #### Reporting | Action | Distinguishing fields | @@ -338,7 +303,6 @@ The following legacy alerting actions are still supported: | Reload provisioned dashboards | `{"action": "provisioning-dashboards"}` | | Reload provisioned datasources | `{"action": "provisioning-datasources"}` | | Reload provisioned plugins | `{"action": "provisioning-plugins"}` | -| Reload provisioned notifications | `{"action": "provisioning-notifications"}` | | Reload provisioned alerts | `{"action": "provisioning-alerts"}` | | Reload provisioned access control | `{"action": "provisioning-accesscontrol"}` | diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/_index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/_index.md index ab42698568193..0a495fb49a056 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/_index.md @@ -19,19 +19,19 @@ Grafana provides many ways to authenticate users. Some authentication integratio The following table shows all supported authentication providers and the features available for them. [Team sync]({{< relref "../configure-team-sync" >}}) and [active sync]({{< relref "./enhanced-ldap#active-ldap-synchronization" >}}) are only available in Grafana Enterprise. -| Provider | Multi Org Mapping | Enforce Sync | Role Mapping | Grafana Admin Mapping | Team Sync | Allowed groups | Active Sync | Skip OrgRole mapping | Auto Login | Single Logout | -| :-------------------------------------------------- | :---------------- | :----------- | :----------- | :-------------------- | :-------- | :------------- | :---------- | :------------------- | :--------- | :------------ | -| [Auth Proxy]({{< relref "./auth-proxy" >}}) | no | yes | yes | no | yes | no | N/A | no | N/A | N/A | -| [Azure AD OAuth]({{< relref "./azuread" >}}) | no | yes | yes | yes | yes | yes | N/A | yes | yes | yes | -| [Generic OAuth]({{< relref "./generic-oauth" >}}) | no | yes | yes | yes | yes | no | N/A | yes | yes | yes | -| [GitHub OAuth]({{< relref "./github" >}}) | no | yes | yes | yes | yes | yes | N/A | yes | yes | yes | -| [GitLab OAuth]({{< relref "./gitlab" >}}) | no | yes | yes | yes | yes | yes | N/A | yes | yes | yes | -| [Google OAuth]({{< relref "./google" >}}) | no | no | no | no | yes | no | N/A | no | yes | yes | -| [Grafana.com OAuth]({{< relref "./grafana-com" >}}) | no | no | yes | no | N/A | N/A | N/A | yes | yes | yes | -| [Okta OAuth]({{< relref "./okta" >}}) | no | yes | yes | yes | yes | yes | N/A | yes | yes | yes | -| [SAML]({{< relref "./saml" >}}) (Enterprise only) | yes | yes | yes | yes | yes | yes | N/A | yes | yes | yes | -| [LDAP]({{< relref "./ldap" >}}) | yes | yes | yes | yes | yes | yes | yes | no | N/A | N/A | -| [JWT Proxy]({{< relref "./jwt" >}}) | no | yes | yes | yes | no | no | N/A | no | N/A | N/A | +| Provider | Multi Org Mapping | Enforce Sync | Role Mapping | Grafana Admin Mapping | Team Sync | Allowed groups | Active Sync | Skip OrgRole mapping | Auto Login | Single Logout | +| :---------------------------------------------------- | :---------------- | :----------- | :----------- | :-------------------- | :-------- | :------------- | :---------- | :------------------- | :--------- | :------------ | +| [Auth Proxy]({{< relref "./auth-proxy" >}}) | no | yes | yes | no | yes | no | N/A | no | N/A | N/A | +| [Azure AD OAuth]({{< relref "./azuread" >}}) | no | yes | yes | yes | yes | yes | N/A | yes | yes | yes | +| [Generic OAuth]({{< relref "./generic-oauth" >}}) | no | yes | yes | yes | yes | no | N/A | yes | yes | yes | +| [GitHub OAuth]({{< relref "./github" >}}) | no | yes | yes | yes | yes | yes | N/A | yes | yes | yes | +| [GitLab OAuth]({{< relref "./gitlab" >}}) | no | yes | yes | yes | yes | yes | N/A | yes | yes | yes | +| [Google OAuth]({{< relref "./google" >}}) | no | no | no | no | yes | no | N/A | no | yes | yes | +| [Grafana.com OAuth]({{< relref "./grafana-cloud" >}}) | no | no | yes | no | N/A | N/A | N/A | yes | yes | yes | +| [Okta OAuth]({{< relref "./okta" >}}) | no | yes | yes | yes | yes | yes | N/A | yes | yes | yes | +| [SAML]({{< relref "./saml" >}}) (Enterprise only) | yes | yes | yes | yes | yes | yes | N/A | yes | yes | yes | +| [LDAP]({{< relref "./ldap" >}}) | yes | yes | yes | yes | yes | yes | yes | no | N/A | N/A | +| [JWT Proxy]({{< relref "./jwt" >}}) | no | yes | yes | yes | no | no | N/A | no | N/A | N/A | N/A = Not applicable @@ -76,6 +76,12 @@ In scenarios where you have multiple identity providers of the same type, there - Check if the identity provider supports account federation. In such cases, you can configure it once and let your identity provider federate the accounts from different providers. - If SAML is supported by the identity provider, you can configure one [Generic OAuth]({{< relref "./generic-oauth" >}}) and one [SAML]({{< relref "./saml" >}}) (Enterprise only). +## Using the same email address to login with different identity providers + +If users want to use the same email address with multiple identity providers (for example, Grafana.Com OAuth and Google OAuth), you can configure Grafana to use the email address as the unique identifier for the user. This is done by enabling the `oauth_allow_insecure_email_lookup` option, which is disabled by default. Please note that enabling this option can lower the security of your Grafana instance. If you enable this option, you should also ensure that the `Allowed organization`, `Allowed groups` and `Allowed domains` settings are configured correctly to prevent unauthorized access. + +To enable this option, refer to the [Enable email lookup](#enable-email-lookup) section. + ## Grafana Auth Grafana of course has a built in user authentication system with password authentication enabled by default. You can @@ -178,6 +184,20 @@ We strongly recommend against enabling email lookups, however it is possible to oauth_allow_insecure_email_lookup = true ``` +You can also enable email lookup using the API: + +{{% admonition type="note" %}} +Available in [Grafana Enterprise]({{< relref "../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud]({{< relref "../../../introduction/grafana-cloud" >}}) since Grafana v10.4. +{{% /admonition %}} + +``` +curl --request PUT \ + --url http://{slug}.grafana.com/api/admin/settings \ + --header 'Authorization: Bearer glsa_yourserviceaccounttoken' \ + --header 'Content-Type: application/json' \ + --data '{ "updates": { "auth": { "oauth_allow_insecure_email_lookup": "true" }}}' +``` + ### Automatic OAuth login Set to true to attempt login with specific OAuth provider automatically, skipping the login screen. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/auth-proxy/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/auth-proxy/index.md index 48825b13b99d5..5ed036bb6ed8b 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/auth-proxy/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/auth-proxy/index.md @@ -36,7 +36,8 @@ header_property = username auto_sign_up = true # Define cache time to live in minutes # If combined with Grafana LDAP integration it is also the sync interval -sync_ttl = 60 +# Set to 0 to always fetch and sync the latest user data +sync_ttl = 15 # Limit where auth proxy requests come from by configuring a list of IP addresses. # This can be used to prevent users spoofing the X-WEBAUTH-USER header. # Example `whitelist = 192.168.1.1, 192.168.1.0/24, 2001::23, 2001::0/120` @@ -231,6 +232,10 @@ ProxyPassReverse / http://grafana:3000/ With our Grafana and Apache containers running, you can now connect to http://localhost/ and log in using the username/password we created in the htpasswd file. +{{% admonition type="note" %}} +If the user is deleted from Grafana, the user will be not be able to login and resync until after the `sync_ttl` has expired. +{{% /admonition %}} + ### Team Sync (Enterprise only) > Only available in Grafana Enterprise v6.3+ @@ -252,7 +257,7 @@ Once that's done. You can verify your mappings by querying the API. ```bash # First, inspect your teams and obtain the corresponding ID of the team we want to inspect the groups for. -curl -H "X-WEBAUTH-USER: admin" http://localhost:3000/api/teams/search +curl -H "X-WEBAUTH-USER: admin" -H "X-WEBAUTH-GROUPS: lokiteamOnExternalSystem" http://localhost:3000/api/teams/search { "totalCount": 2, "teams": [ @@ -280,7 +285,7 @@ curl -H "X-WEBAUTH-USER: admin" http://localhost:3000/api/teams/search } # Then, query the groups for that particular team. In our case, the Loki team which has an ID of "2". -curl -H "X-WEBAUTH-USER: admin" http://localhost:3000/api/teams/2/groups +curl -H "X-WEBAUTH-USER: admin" -H "X-WEBAUTH-GROUPS: lokiteamOnExternalSystem" http://localhost:3000/api/teams/2/groups [ { "orgId": 1, @@ -304,6 +309,10 @@ curl -H "X-WEBAUTH-USER: leonard" -H "X-WEBAUTH-GROUPS: lokiteamOnExternalSystem With this, the user `leonard` will be automatically placed into the Loki team as part of Grafana authentication. +{{% admonition type="note" %}} +An empty `X-WEBAUTH-GROUPS` or the absence of a groups header will remove the user from all teams. +{{% /admonition %}} + [Learn more about Team Sync]({{< relref "../../configure-team-sync" >}}) ## Login token and session cookie diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md index 9b6f5b7ad5ce1..8d0b79451e740 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md @@ -21,6 +21,10 @@ weight: 800 The Azure AD authentication allows you to use an Azure Active Directory tenant as an identity provider for Grafana. You can use Azure AD application roles to assign users and groups to Grafana roles from the Azure Portal. +{{% admonition type="note" %}} +If Users use the same email address in Azure AD that they use with other authentication providers (such as Grafana.com), you need to do additional configuration to ensure that the users are matched correctly. Please refer to [Using the same email address to login with different identity providers]({{< relref "../../configure-authentication#using-the-same-email-address-to-login-with-different-identity-providers" >}}) for more information. +{{% /admonition %}} + ## Create the Azure AD application To enable the Azure AD OAuth2, register your application with Azure AD. @@ -58,7 +62,7 @@ To enable the Azure AD OAuth2, register your application with Azure AD. 1. Click **Users and Groups**. 1. Click **Add user/group** to add a user or group to the Grafana roles. -#### Configure application roles for Grafana in the Azure Portal +### Configure application roles for Grafana in the Azure Portal This section describes setting up basic application roles for Grafana within the Azure Portal. For more information, see [Add app roles to your application and receive them in the token](https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-app-roles-in-apps). @@ -78,7 +82,7 @@ This section describes setting up basic application roles for Grafana within the 1. Click **Apply**. -#### Configure application roles for Grafana in the manifest file +### Configure application roles for Grafana in the manifest file If you prefer to configure the application roles for Grafana in the manifest file, complete the following steps: @@ -161,7 +165,60 @@ If the setting is set to `false`, the user is assigned the role of `Admin` of th } ``` -## Enable Azure AD OAuth in Grafana +## Before you begin + +Ensure that you have followed the steps in [Create the Azure AD application](#create-the-azure-ad-application) before you begin. + +## Configure Azure AD authentication client using the Grafana UI + +{{% admonition type="note" %}} +Available in Public Preview in Grafana 10.4 behind the `ssoSettingsApi` feature toggle. +{{% /admonition %}} + +As a Grafana Admin, you can configure your Azure AD OAuth2 client from within Grafana using the Grafana UI. To do this, navigate to the **Administration > Authentication > Azure AD** page and fill in the form. If you have a current configuration in the Grafana configuration file, the form will be pre-populated with those values. Otherwise the form will contain default values. + +After you have filled in the form, click **Save** to save the configuration. If the save was successful, Grafana will apply the new configurations. + +If you need to reset changes you made in the UI back to the default values, click **Reset**. After you have reset the changes, Grafana will apply the configuration from the Grafana configuration file (if there is any configuration) or the default values. + +{{% admonition type="note" %}} +If you run Grafana in high availability mode, configuration changes may not get applied to all Grafana instances immediately. You may need to wait a few minutes for the configuration to propagate to all Grafana instances. +{{% /admonition %}} + +## Configure Azure AD authentication client using the Terraform provider + +{{% admonition type="note" %}} +Available in Public Preview in Grafana 10.4 behind the `ssoSettingsApi` feature toggle. Supported in the Terraform provider since v2.12.0. +{{% /admonition %}} + +```terraform +resource "grafana_sso_settings" "azuread_sso_settings" { + provider_name = "azuread" + oauth2_settings { + name = "Azure AD" + auth_url = "https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/authorize" + token_url = "https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/token" + client_id = "APPLICATION_ID" + client_secret = "CLIENT_SECRET" + allow_sign_up = true + auto_login = false + scopes = "openid email profile" + allowed_organizations = "TENANT_ID" + role_attribute_strict = false + allow_assign_grafana_admin = false + skip_org_role_sync = false + use_pkce = true + } +} +``` + +Refer to [Terraform Registry](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/sso_settings) for a complete reference on using the `grafana_sso_settings` resource. + +## Configure Azure AD authentication client using the Grafana configuration file + +Ensure that you have access to the [Grafana configuration file]({{< relref "../../../configure-grafana#configuration-file-location" >}}). + +### Enable Azure AD OAuth in Grafana Add the following to the [Grafana configuration file]({{< relref "../../../configure-grafana#configuration-file-location" >}}): @@ -340,7 +397,7 @@ Admin consent might be required for this permission. Admin consent may be required for this permission. {{% /admonition %}} -### Force fetching groups from Microsoft graph API +### Force fetching groups from Microsoft Graph API To force fetching groups from Microsoft Graph API instead of the `id_token`. You can use the `force_use_graph_api` config option. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/enhanced-ldap/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/enhanced-ldap/index.md index 02aea423bd064..fd02e51fcb850 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/enhanced-ldap/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/enhanced-ldap/index.md @@ -30,11 +30,11 @@ The enhanced LDAP integration adds additional functionality on top of the [LDAP ## LDAP group synchronization for teams -{{< figure src="/static/img/docs/enterprise/team_members_ldap.png" class="docs-image--no-shadow docs-image--right" max-width= "600px" >}} - With enhanced LDAP integration, you can set up synchronization between LDAP groups and teams. This enables LDAP users that are members of certain LDAP groups to automatically be added or removed as members to certain teams in Grafana. +![LDAP group synchronization](/static/img/docs/enterprise/team_members_ldap.png) + Grafana keeps track of all synchronized users in teams, and you can see which users have been synchronized from LDAP in the team members list, see `LDAP` label in screenshot. This mechanism allows Grafana to remove an existing synchronized user from a team when its LDAP group membership changes. This mechanism also allows you to manually add a user as member of a team, and it will not be removed when the user signs in. This gives you flexibility to combine LDAP group memberships and Grafana team memberships. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md index 0e0c2d60d40d2..6ca73040d020a 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md @@ -27,23 +27,76 @@ Grafana provides OAuth2 integrations for the following auth providers: - [GitHub OAuth]({{< relref "../github" >}}) - [GitLab OAuth]({{< relref "../gitlab" >}}) - [Google OAuth]({{< relref "../google" >}}) -- [Grafana Com OAuth]({{< relref "../grafana-com" >}}) +- [Grafana Com OAuth]({{< relref "../grafana-cloud" >}}) - [Keycloak OAuth]({{< relref "../keycloak" >}}) - [Okta OAuth]({{< relref "../okta" >}}) If your OAuth2 provider is not listed, you can use generic OAuth2 authentication. -This topic describes how to configure generic OAuth2 authentication and includes [examples of setting up generic OAuth2]({{< relref "#examples-of-setting-up-generic-oauth2" >}}) with specific OAuth2 providers. +This topic describes how to configure generic OAuth2 authentication using different methods and includes [examples of setting up generic OAuth2]({{< relref "#examples-of-setting-up-generic-oauth2" >}}) with specific OAuth2 providers. ## Before you begin To follow this guide: -- Ensure that you have access to the [Grafana configuration file]({{< relref "../../../configure-grafana#configuration-file-location" >}}). - Ensure you know how to create an OAuth2 application with your OAuth2 provider. Consult the documentation of your OAuth2 provider for more information. +- Ensure your identity provider returns OpenID UserInfo compatible information such as the `sub` claim. - If you are using refresh tokens, ensure you know how to set them up with your OAuth2 provider. Consult the documentation of your OAuth2 provider for more information. -## Steps +{{% admonition type="note" %}} +If Users use the same email address in Azure AD that they use with other authentication providers (such as Grafana.com), you need to do additional configuration to ensure that the users are matched correctly. Please refer to the [Using the same email address to login with different identity providers]({{< relref "../../configure-authentication#using-the-same-email-address-to-login-with-different-identity-providers" >}}) documentation for more information. +{{% /admonition %}} + +## Configure generic OAuth authentication client using the Grafana UI + +{{% admonition type="note" %}} +Available in Public Preview in Grafana 10.4 behind the `ssoSettingsApi` feature toggle. +{{% /admonition %}} + +As a Grafana Admin, you can configure Generic OAuth2 client from within Grafana using the Generic OAuth UI. To do this, navigate to **Administration > Authentication > Generic OAuth** page and fill in the form. If you have a current configuration in the Grafana configuration file then the form will be pre-populated with those values otherwise the form will contain default values. + +After you have filled in the form, click **Save** to save the configuration. If the save was successful, Grafana will apply the new configurations. + +If you need to reset changes you made in the UI back to the default values, click **Reset**. After you have reset the changes, Grafana will apply the configuration from the Grafana configuration file (if there is any configuration) or the default values. + +{{% admonition type="note" %}} +If you run Grafana in high availability mode, configuration changes may not get applied to all Grafana instances immediately. You may need to wait a few minutes for the configuration to propagate to all Grafana instances. +{{% /admonition %}} + +Refer to [configuration options]({{< relref "#configuration-options" >}}) for more information. + +## Configure generic OAuth authentication client using the Terraform provider + +{{% admonition type="note" %}} +Available in Public Preview in Grafana 10.4 behind the `ssoSettingsApi` feature toggle. Supported in the Terraform provider since v2.12.0. +{{% /admonition %}} + +```terraform +resource "grafana_sso_settings" "generic_sso_settings" { + provider_name = "generic_oauth" + oauth2_settings { + name = "Auth0" + auth_url = "https://<domain>/authorize" + token_url = "https://<domain>/oauth/token" + api_url = "https://<domain>/userinfo" + client_id = "<client id>" + client_secret = "<client secret>" + allow_sign_up = true + auto_login = false + scopes = "openid profile email offline_access" + use_pkce = true + use_refresh_token = true + } +} +``` + +Refer to [Terraform Registry](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/sso_settings) for a complete reference on using the `grafana_sso_settings` resource. + +## Configure generic OAuth authentication client using the Grafana configuration file + +Ensure that you have access to the [Grafana configuration file]({{< relref "../../../configure-grafana#configuration-file-location" >}}). + +### Steps To integrate your OAuth2 provider with Grafana using our generic OAuth2 authentication, follow these steps: @@ -79,48 +132,6 @@ To integrate your OAuth2 provider with Grafana using our generic OAuth2 authenti You should now see a generic OAuth2 login button on the login page and be able to log in or sign up with your OAuth2 provider. -## Configuration options - -The following table outlines the various generic OAuth2 configuration options. You can apply these options as environment variables, similar to any other configuration within Grafana. - -| Setting | Required | Description | Default | -| ---------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | -| `enabled` | No | Enables generic OAuth2 authentication. | `false` | -| `name` | No | Name that refers to the generic OAuth2 authentication from the Grafana user interface. | `OAuth` | -| `icon` | No | Icon used for the generic OAuth2 authentication in the Grafana user interface. | `signin` | -| `client_id` | Yes | Client ID provided by your OAuth2 app. | | -| `client_secret` | Yes | Client secret provided by your OAuth2 app. | | -| `auth_url` | Yes | Authorization endpoint of your OAuth2 provider. | | -| `token_url` | Yes | Endpoint used to obtain the OAuth2 access token. | | -| `api_url` | Yes | Endpoint used to obtain user information compatible with [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo). | | -| `auth_style` | No | Name of the [OAuth2 AuthStyle](https://pkg.go.dev/golang.org/x/oauth2#AuthStyle) to be used when ID token is requested from OAuth2 provider. It determines how `client_id` and `client_secret` are sent to Oauth2 provider. Available values are `AutoDetect`, `InParams` and `InHeader`. | `AutoDetect` | -| `scopes` | No | List of comma- or space-separated OAuth2 scopes. | `user:email` | -| `empty_scopes` | No | Set to `true` to use an empty scope during authentication. | `false` | -| `allow_sign_up` | No | Controls Grafana user creation through the generic OAuth2 login. Only existing Grafana users can log in with generic OAuth if set to `false`. | `true` | -| `auto_login` | No | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | -| `id_token_attribute_name` | No | The name of the key used to extract the ID token from the returned OAuth2 token. | `id_token` | -| `login_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for user login lookup from the user ID token. For more information on how user login is retrieved, refer to [Configure login]({{< relref "#configure-login" >}}). | | -| `name_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for user name lookup from the user ID token. This name will be used as the user's display name. For more information on how user display name is retrieved, refer to [Configure display name]({{< relref "#configure-display-name" >}}). | | -| `email_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for user email lookup from the user information. For more information on how user email is retrieved, refer to [Configure email address]({{< relref "#configure-email-address" >}}). | | -| `email_attribute_name` | No | Name of the key to use for user email lookup within the `attributes` map of OAuth2 ID token. For more information on how user email is retrieved, refer to [Configure email address]({{< relref "#configure-email-address" >}}). | `email:primary` | -| `role_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no role is found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation should be a valid Grafana role (`Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | | -| `role_attribute_strict` | No | Set to `true` to deny user login if the Grafana role cannot be extracted using `role_attribute_path`. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` | -| `allow_assign_grafana_admin` | No | Set to `true` to enable automatic sync of the Grafana server administrator role. If this option is set to `true` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user the server administrator privileges and organization administrator role. If this option is set to `false` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user only organization administrator role. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` | -| `skip_org_role_sync` | No | Set to `true` to stop automatically syncing user roles. This will allow you to set organization roles for your users from within Grafana manually. | `false` | -| `groups_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for user group lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no groups are found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation should be a string array of groups. | | -| `allowed_groups` | No | List of comma- or space-separated groups. The user should be a member of at least one group to log in. If you configure `allowed_groups`, you must also configure `groups_attribute_path`. | | -| `allowed_organizations` | No | List of comma- or space-separated organizations. The user should be a member of at least one organization to log in. | | -| `allowed_domains` | No | List comma- or space-separated domains. The user should belong to at least one domain to log in. | | -| `team_ids` | No | String list of team IDs. If set, the user must be a member of one of the given teams to log in. If you configure `team_ids`, you must also configure `teams_url` and `team_ids_attribute_path`. | | -| `team_ids_attribute_path` | No | The [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana team ID lookup within the results returned by the `teams_url` endpoint. | | -| `teams_url` | No | The URL used to query for team IDs. If not set, the default value is `/teams`. If you configure `teams_url`, you must also configure `team_ids_attribute_path`. | | -| `tls_skip_verify_insecure` | No | If set to `true`, the client accepts any certificate presented by the server and any host name in that certificate. _You should only use this for testing_, because this mode leaves SSL/TLS susceptible to man-in-the-middle attacks. | `false` | -| `tls_client_cert` | No | The path to the certificate. | | -| `tls_client_key` | No | The path to the key. | | -| `tls_client_ca` | No | The path to the trusted certificate authority list. | | -| `use_pkce` | No | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `false` | -| `use_refresh_token` | No | Set to `true` to use refresh token and check access token expiration. | `false` | - ### Configure login Grafana can resolve a user's login from the OAuth2 ID token or user information retrieved from the OAuth2 UserInfo endpoint. @@ -168,7 +179,7 @@ Refer to the following table for information on what to configure based on how t | Another field of the user information from the UserInfo endpoint. | Set `email_attribute_path` configuration option. | | Email address marked as primary from the `/emails` endpoint of <br /> the OAuth2 provider (obtained by appending `/emails` to the URL <br /> configured with `api_url`) | N/A | -## Configure a refresh token +### Configure a refresh token > **Note:** This feature is behind the `accessTokenExpirationCheck` feature toggle. @@ -183,7 +194,7 @@ To configure generic OAuth2 to use a refresh token, set `use_refresh_token` conf > **Note:** The `accessTokenExpirationCheck` feature toggle will be removed in Grafana v10.3.0 and the `use_refresh_token` configuration value will be used instead for configuring refresh token fetching and access token expiration check. -## Configure role mapping +### Configure role mapping Unless `skip_org_role_sync` option is enabled, the user's role will be set to the role retrieved from the auth provider upon user login. @@ -197,11 +208,11 @@ This setting denies user access if no role or an invalid role is returned. To ease configuration of a proper JMESPath expression, go to [JMESPath](http://jmespath.org/) to test and evaluate expressions with custom payloads. -### Role mapping examples +#### Role mapping examples This section includes examples of JMESPath expressions used for role mapping. -#### Map user organization role +##### Map user organization role In this example, the user has been granted the role of an `Editor`. The role assigned is based on the value of the property `role`, which must be a valid Grafana role such as `Admin`, `Editor`, `Viewer` or `None`. @@ -247,7 +258,7 @@ Config: role_attribute_path = contains(info.groups[*], 'admin') && 'Admin' || contains(info.groups[*], 'editor') && 'Editor' || 'Viewer' ``` -#### Map server administrator role +##### Map server administrator role In the following example, the user is granted the Grafana server administrator role. @@ -274,7 +285,7 @@ role_attribute_path = contains(info.roles[*], 'admin') && 'GrafanaAdmin' || cont allow_assign_grafana_admin = true ``` -#### Map one role to all users +##### Map one role to all users In this example, all users will be assigned `Viewer` role regardless of the user information received from the identity provider. @@ -285,7 +296,7 @@ role_attribute_path = "'Viewer'" skip_org_role_sync = false ``` -## Configure team synchronization +### Configure team synchronization > **Note:** Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/). @@ -297,7 +308,7 @@ For information on configuring OAuth2 groups with Grafana using the `groups_attr To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}). -### Team synchronization example +#### Team synchronization example Configuration: @@ -322,10 +333,86 @@ Payload: } ``` +## Configuration options + +The following table outlines the various generic OAuth2 configuration options. You can apply these options as environment variables, similar to any other configuration within Grafana. + +| Setting | Required | Description | Default | +| ---------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | +| `enabled` | No | Enables generic OAuth2 authentication. | `false` | +| `name` | No | Name that refers to the generic OAuth2 authentication from the Grafana user interface. | `OAuth` | +| `icon` | No | Icon used for the generic OAuth2 authentication in the Grafana user interface. | `signin` | +| `client_id` | Yes | Client ID provided by your OAuth2 app. | | +| `client_secret` | Yes | Client secret provided by your OAuth2 app. | | +| `auth_url` | Yes | Authorization endpoint of your OAuth2 provider. | | +| `token_url` | Yes | Endpoint used to obtain the OAuth2 access token. | | +| `api_url` | Yes | Endpoint used to obtain user information compatible with [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo). | | +| `auth_style` | No | Name of the [OAuth2 AuthStyle](https://pkg.go.dev/golang.org/x/oauth2#AuthStyle) to be used when ID token is requested from OAuth2 provider. It determines how `client_id` and `client_secret` are sent to Oauth2 provider. Available values are `AutoDetect`, `InParams` and `InHeader`. | `AutoDetect` | +| `scopes` | No | List of comma- or space-separated OAuth2 scopes. | `user:email` | +| `empty_scopes` | No | Set to `true` to use an empty scope during authentication. | `false` | +| `allow_sign_up` | No | Controls Grafana user creation through the generic OAuth2 login. Only existing Grafana users can log in with generic OAuth if set to `false`. | `true` | +| `auto_login` | No | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | +| `id_token_attribute_name` | No | The name of the key used to extract the ID token from the returned OAuth2 token. | `id_token` | +| `login_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for user login lookup from the user ID token. For more information on how user login is retrieved, refer to [Configure login]({{< relref "#configure-login" >}}). | | +| `name_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for user name lookup from the user ID token. This name will be used as the user's display name. For more information on how user display name is retrieved, refer to [Configure display name]({{< relref "#configure-display-name" >}}). | | +| `email_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for user email lookup from the user information. For more information on how user email is retrieved, refer to [Configure email address]({{< relref "#configure-email-address" >}}). | | +| `email_attribute_name` | No | Name of the key to use for user email lookup within the `attributes` map of OAuth2 ID token. For more information on how user email is retrieved, refer to [Configure email address]({{< relref "#configure-email-address" >}}). | `email:primary` | +| `role_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no role is found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation should be a valid Grafana role (`Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | | +| `role_attribute_strict` | No | Set to `true` to deny user login if the Grafana role cannot be extracted using `role_attribute_path`. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` | +| `allow_assign_grafana_admin` | No | Set to `true` to enable automatic sync of the Grafana server administrator role. If this option is set to `true` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user the server administrator privileges and organization administrator role. If this option is set to `false` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user only organization administrator role. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` | +| `skip_org_role_sync` | No | Set to `true` to stop automatically syncing user roles. This will allow you to set organization roles for your users from within Grafana manually. | `false` | +| `groups_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for user group lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no groups are found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation should be a string array of groups. | | +| `allowed_groups` | No | List of comma- or space-separated groups. The user should be a member of at least one group to log in. If you configure `allowed_groups`, you must also configure `groups_attribute_path`. | | +| `allowed_organizations` | No | List of comma- or space-separated organizations. The user should be a member of at least one organization to log in. | | +| `allowed_domains` | No | List comma- or space-separated domains. The user should belong to at least one domain to log in. | | +| `team_ids` | No | String list of team IDs. If set, the user must be a member of one of the given teams to log in. If you configure `team_ids`, you must also configure `teams_url` and `team_ids_attribute_path`. | | +| `team_ids_attribute_path` | No | The [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana team ID lookup within the results returned by the `teams_url` endpoint. | | +| `teams_url` | No | The URL used to query for team IDs. If not set, the default value is `/teams`. If you configure `teams_url`, you must also configure `team_ids_attribute_path`. | | +| `tls_skip_verify_insecure` | No | If set to `true`, the client accepts any certificate presented by the server and any host name in that certificate. _You should only use this for testing_, because this mode leaves SSL/TLS susceptible to man-in-the-middle attacks. | `false` | +| `tls_client_cert` | No | The path to the certificate. | | +| `tls_client_key` | No | The path to the key. | | +| `tls_client_ca` | No | The path to the trusted certificate authority list. | | +| `use_pkce` | No | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `false` | +| `use_refresh_token` | No | Set to `true` to use refresh token and check access token expiration. | `false` | + ## Examples of setting up generic OAuth2 This section includes examples of setting up generic OAuth2 integration. +### Set up OAuth2 with Descope + +To set up generic OAuth2 authentication with Descope, follow these steps: + +1. Create a Descope Project [here](https://app.descope.com/gettingStarted), and go through the Getting Started Wizard to configure your authentication. You can skip step if you already have Descope project set up. + +1. If you wish to use a flow besides `Sign Up or In`, go to the **IdP Applications** menu in the console, and select your IdP application. Then alter the **Flow Hosting URL** query parameter `?flow=sign-up-or-in` to change which flow id you wish to use. + +1. Click **Save**. + +1. Update the `[auth.generic_oauth]` section of the Grafana configuration file using the values from the **Settings** tab: + + {{% admonition type="note" %}} + You can get your Client ID (Descope Project ID) under [Project Settings](https://app.descope.com/settings/project). Your Client Secret (Descope Access Key) can be generated under [Access Keys](https://app.descope.com/accesskeys). + {{% /admonition %}} + + ```bash + [auth.generic_oauth] + enabled = true + allow_sign_up = true + auto_login = false + team_ids = + allowed_organizations = + name = Descope + client_id = <Descope Project ID> + client_secret = <Descope Access Key> + scopes = openid profile email descope.claims descope.custom_claims + auth_url = https://api.descope.com/oauth2/v1/authorize + token_url = https://api.descope.com/oauth2/v1/token + api_url = https://api.descope.com/oauth2/v1/userinfo + use_pkce = true + use_refresh_token = true + ``` + ### Set up OAuth2 with Auth0 To set up generic OAuth2 authentication with Auth0, follow these steps: diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md index bc01183bf2b72..4ca721eaf6291 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md @@ -23,14 +23,63 @@ weight: 900 This topic describes how to configure GitHub OAuth2 authentication. +{{% admonition type="note" %}} +If Users use the same email address in GitHub that they use with other authentication providers (such as Grafana.com), you need to do additional configuration to ensure that the users are matched correctly. Please refer to the [Using the same email address to login with different identity providers]({{< relref "../../configure-authentication#using-the-same-email-address-to-login-with-different-identity-providers" >}}) documentation for more information. +{{% /admonition %}} + ## Before you begin -To follow this guide: +Ensure you know how to create a GitHub OAuth app. Consult GitHub's documentation on [creating an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) for more information. + +## Configure GitHub authentication client using the Grafana UI + +{{% admonition type="note" %}} +Available in Public Preview in Grafana 10.4 behind the `ssoSettingsApi` feature toggle. +{{% /admonition %}} + +As a Grafana Admin, you can configure GitHub OAuth2 client from within Grafana using the GitHub UI. To do this, navigate to **Administration > Authentication > GitHub** page and fill in the form. If you have a current configuration in the Grafana configuration file, the form will be pre-populated with those values. Otherwise the form will contain default values. + +After you have filled in the form, click **Save** . If the save was successful, Grafana will apply the new configurations. + +If you need to reset changes you made in the UI back to the default values, click **Reset**. After you have reset the changes, Grafana will apply the configuration from the Grafana configuration file (if there is any configuration) or the default values. + +{{% admonition type="note" %}} +If you run Grafana in high availability mode, configuration changes may not get applied to all Grafana instances immediately. You may need to wait a few minutes for the configuration to propagate to all Grafana instances. +{{% /admonition %}} + +Refer to [configuration options]({{< relref "#configuration-options" >}}) for more information. + +## Configure GitHub authentication client using the Terraform provider + +{{% admonition type="note" %}} +Available in Public Preview in Grafana 10.4 behind the `ssoSettingsApi` feature toggle. Supported in the Terraform provider since v2.12.0. +{{% /admonition %}} + +```terraform +resource "grafana_sso_settings" "github_sso_settings" { + provider_name = "github" + oauth2_settings { + name = "Github" + client_id = "YOUR_GITHUB_APP_CLIENT_ID" + client_secret = "YOUR_GITHUB_APP_CLIENT_SECRET" + allow_sign_up = true + auto_login = false + scopes = "user:email,read:org" + team_ids = "150,300" + allowed_organizations = "[\"My Organization\", \"Octocats\"]" + allowed_domains = "mycompany.com mycompany.org" + role_attribute_path = "[login=='octocat'][0] && 'GrafanaAdmin' || 'Viewer'" + } +} +``` + +Go to [Terraform Registry](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/sso_settings) for a complete reference on using the `grafana_sso_settings` resource. + +## Configure GitHub authentication client using the Grafana configuration file -- Ensure that you have access to the [Grafana configuration file]({{< relref "../../../configure-grafana#configuration-file-location" >}}). -- Ensure you know how to create a GitHub OAuth app. Consult GitHub's documentation on [creating an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) for more information. +Ensure that you have access to the [Grafana configuration file]({{< relref "../../../configure-grafana#configuration-file-location" >}}). -## Steps +### Configure GitHub authentication To configure GitHub authentication with Grafana, follow these steps: @@ -56,36 +105,7 @@ To configure GitHub authentication with Grafana, follow these steps: You should now see a GitHub login button on the login page and be able to log in or sign up with your GitHub accounts. -## Configuration options - -The table below describes all GitHub OAuth configuration options. Like any other Grafana configuration, you can apply these options as environment variables. - -| Setting | Required | Description | Default | -| ---------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| `enabled` | No | Whether GitHub OAuth authentication is allowed. | `false` | -| `name` | No | Name used to refer to the GitHub authentication in the Grafana user interface. | `GitHub` | -| `icon` | No | Icon used for GitHub authentication in the Grafana user interface. | `github` | -| `client_id` | Yes | Client ID provided by your GitHub OAuth app. | | -| `client_secret` | Yes | Client secret provided by your GitHub OAuth app. | | -| `auth_url` | Yes | Authorization endpoint of your GitHub OAuth provider. | `https://github.com/login/oauth/authorize` | -| `token_url` | Yes | Endpoint used to obtain GitHub OAuth access token. | `https://github.com/login/oauth/access_token` | -| `api_url` | Yes | Endpoint used to obtain GitHub user information compatible with [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo). | `https://api.github.com/user` | -| `scopes` | No | List of comma- or space-separated GitHub OAuth scopes. | `user:email,read:org` | -| `allow_sign_up` | No | Whether to allow new Grafana user creation through GitHub login. If set to `false`, then only existing Grafana users can log in with GitHub OAuth. | `true` | -| `auto_login` | No | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | -| `role_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the user information obtained from the UserInfo endpoint. If no role is found, Grafana creates a JSON data with `groups` key that maps to GitHub teams obtained from GitHub's [`/api/user/teams`](https://docs.github.com/en/rest/teams/teams#list-teams-for-the-authenticated-user) endpoint, and evaluates the expression using this data. The result of the evaluation should be a valid Grafana role (`Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | | -| `role_attribute_strict` | No | Set to `true` to deny user login if the Grafana role cannot be extracted using `role_attribute_path`. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` | -| `allow_assign_grafana_admin` | No | Set to `true` to enable automatic sync of the Grafana server administrator role. If this option is set to `true` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user the server administrator privileges and organization administrator role. If this option is set to `false` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user only organization administrator role. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` | -| `skip_org_role_sync` | No | Set to `true` to stop automatically syncing user roles. | `false` | -| `allowed_organizations` | No | List of comma- or space-separated organizations. User must be a member of at least one organization to log in. | | -| `allowed_domains` | No | List of comma- or space-separated domains. User must belong to at least one domain to log in. | | -| `team_ids` | No | Integer list of team IDs. If set, user has to be a member of one of the given teams to log in. | | -| `tls_skip_verify_insecure` | No | If set to `true`, the client accepts any certificate presented by the server and any host name in that certificate. _You should only use this for testing_, because this mode leaves SSL/TLS susceptible to man-in-the-middle attacks. | `false` | -| `tls_client_cert` | No | The path to the certificate. | | -| `tls_client_key` | No | The path to the key. | | -| `tls_client_ca` | No | The path to the trusted certificate authority list. | | - -## Configure role mapping +### Configure role mapping Unless `skip_org_role_sync` option is enabled, the user's role will be set to the role retrieved from GitHub upon user login. @@ -99,11 +119,11 @@ This setting denies user access if no role or an invalid role is returned. To ease configuration of a proper JMESPath expression, go to [JMESPath](http://jmespath.org/) to test and evaluate expressions with custom payloads. -### Role mapping examples +#### Role mapping examples This section includes examples of JMESPath expressions used for role mapping. -#### Map roles using GitHub user information +##### Map roles using GitHub user information In this example, the user with login `octocat` has been granted the `Admin` role. All other users are granted the `Viewer` role. @@ -112,7 +132,7 @@ All other users are granted the `Viewer` role. role_attribute_path = [login=='octocat'][0] && 'Admin' || 'Viewer' ``` -#### Map roles using GitHub teams +##### Map roles using GitHub teams In this example, the user from GitHub team `my-github-team` has been granted the `Editor` role. All other users are granted the `Viewer` role. @@ -121,7 +141,7 @@ All other users are granted the `Viewer` role. role_attribute_path = contains(groups[*], '@my-github-organization/my-github-team') && 'Editor' || 'Viewer' ``` -### Map server administrator role +#### Map server administrator role In this example, the user with login `octocat` has been granted the `Admin` organization role as well as the Grafana server admin role. All other users are granted the `Viewer` role. @@ -130,7 +150,7 @@ All other users are granted the `Viewer` role. role_attribute_path = [login=='octocat'][0] && 'GrafanaAdmin' || 'Viewer' ``` -#### Map one role to all users +##### Map one role to all users In this example, all users will be assigned `Viewer` role regardless of the user information received from the identity provider. @@ -139,23 +159,7 @@ role_attribute_path = "'Viewer'" skip_org_role_sync = false ``` -## Configure team synchronization - -> **Note:** Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/). - -By using Team Sync, you can map teams from your GitHub organization to teams within Grafana. This will automatically assign users to the appropriate teams. -Teams for each user are synchronized when the user logs in. - -GitHub teams can be referenced in two ways: - -- `https://github.com/orgs/<org>/teams/<slug>` -- `@<org>/<slug>` - -For example, `https://github.com/orgs/grafana/teams/developers` or `@grafana/developers`. - -To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}). - -## Example of GitHub configuration in Grafana +### Example of GitHub configuration in Grafana This section includes an example of GitHub configuration in the Grafana configuration file. @@ -175,3 +179,50 @@ allowed_organizations = ["My Organization", "Octocats"] allowed_domains = mycompany.com mycompany.org role_attribute_path = [login=='octocat'][0] && 'GrafanaAdmin' || 'Viewer' ``` + +## Configure team synchronization + +{{< admonition type="note" >}} +Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and Grafana Cloud. +{{< /admonition >}} + +By using Team Sync, you can map teams from your GitHub organization to teams within Grafana. This will automatically assign users to the appropriate teams. +Teams for each user are synchronized when the user logs in. + +GitHub teams can be referenced in two ways: + +- `https://github.com/orgs/<org>/teams/<slug>` +- `@<org>/<slug>` + +Examples: `https://github.com/orgs/grafana/teams/developers` or `@grafana/developers`. + +To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}). + +## Configuration options + +The table below describes all GitHub OAuth configuration options. Like any other Grafana configuration, you can apply these options as environment variables. + +| Setting | Required | Description | Default | +| ---------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| `enabled` | No | Whether GitHub OAuth authentication is allowed. | `false` | +| `name` | No | Name used to refer to the GitHub authentication in the Grafana user interface. | `GitHub` | +| `icon` | No | Icon used for GitHub authentication in the Grafana user interface. | `github` | +| `client_id` | Yes | Client ID provided by your GitHub OAuth app. | | +| `client_secret` | Yes | Client secret provided by your GitHub OAuth app. | | +| `auth_url` | Yes | Authorization endpoint of your GitHub OAuth provider. | `https://github.com/login/oauth/authorize` | +| `token_url` | Yes | Endpoint used to obtain GitHub OAuth access token. | `https://github.com/login/oauth/access_token` | +| `api_url` | Yes | Endpoint used to obtain GitHub user information compatible with [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo). | `https://api.github.com/user` | +| `scopes` | No | List of comma- or space-separated GitHub OAuth scopes. | `user:email,read:org` | +| `allow_sign_up` | No | Whether to allow new Grafana user creation through GitHub login. If set to `false`, then only existing Grafana users can log in with GitHub OAuth. | `true` | +| `auto_login` | No | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | +| `role_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the user information obtained from the UserInfo endpoint. If no role is found, Grafana creates a JSON data with `groups` key that maps to GitHub teams obtained from GitHub's [`/api/user/teams`](https://docs.github.com/en/rest/teams/teams#list-teams-for-the-authenticated-user) endpoint, and evaluates the expression using this data. The result of the evaluation should be a valid Grafana role (`Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | | +| `role_attribute_strict` | No | Set to `true` to deny user login if the Grafana role cannot be extracted using `role_attribute_path`. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` | +| `allow_assign_grafana_admin` | No | Set to `true` to enable automatic sync of the Grafana server administrator role. If this option is set to `true` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user the server administrator privileges and organization administrator role. If this option is set to `false` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user only organization administrator role. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` | +| `skip_org_role_sync` | No | Set to `true` to stop automatically syncing user roles. | `false` | +| `allowed_organizations` | No | List of comma- or space-separated organizations. User must be a member of at least one organization to log in. | | +| `allowed_domains` | No | List of comma- or space-separated domains. User must belong to at least one domain to log in. | | +| `team_ids` | No | Integer list of team IDs. If set, user has to be a member of one of the given teams to log in. | | +| `tls_skip_verify_insecure` | No | If set to `true`, the client accepts any certificate presented by the server and any host name in that certificate. _You should only use this for testing_, because this mode leaves SSL/TLS susceptible to man-in-the-middle attacks. | `false` | +| `tls_client_cert` | No | The path to the certificate. | | +| `tls_client_key` | No | The path to the key. | | +| `tls_client_ca` | No | The path to the trusted certificate authority list. | | diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md index 8f2b81073317c..0541095257e7e 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md @@ -23,14 +23,65 @@ weight: 1000 This topic describes how to configure GitLab OAuth2 authentication. +{{% admonition type="note" %}} +If Users use the same email address in GitLab that they use with other authentication providers (such as Grafana.com), you need to do additional configuration to ensure that the users are matched correctly. Please refer to the [Using the same email address to login with different identity providers]({{< relref "../../configure-authentication#using-the-same-email-address-to-login-with-different-identity-providers" >}}) documentation for more information. +{{% /admonition %}} + ## Before you begin -To follow this guide: +Ensure you know how to create a GitLab OAuth application. Consult GitLab's documentation on [creating a GitLab OAuth application](https://docs.gitlab.com/ee/integration/oauth_provider.html) for more information. + +## Configure GitLab authentication client using the Grafana UI + +{{% admonition type="note" %}} +Available in Public Preview in Grafana 10.4 behind the `ssoSettingsApi` feature toggle. +{{% /admonition %}} + +As a Grafana Admin, you can configure GitLab OAuth2 client from within Grafana using the GitLab UI. To do this, navigate to **Administration > Authentication > GitLab** page and fill in the form. If you have a current configuration in the Grafana configuration file then the form will be pre-populated with those values otherwise the form will contain default values. + +After you have filled in the form, click **Save** to save the configuration. If the save was successful, Grafana will apply the new configurations. + +If you need to reset changes you made in the UI back to the default values, click **Reset**. After you have reset the changes, Grafana will apply the configuration from the Grafana configuration file (if there is any configuration) or the default values. + +{{% admonition type="note" %}} +If you run Grafana in high availability mode, configuration changes may not get applied to all Grafana instances immediately. You may need to wait a few minutes for the configuration to propagate to all Grafana instances. +{{% /admonition %}} + +Refer to [configuration options]({{< relref "#configuration-options" >}}) for more information. + +## Configure GitLab authentication client using the Terraform provider + +{{% admonition type="note" %}} +Available in Public Preview in Grafana 10.4 behind the `ssoSettingsApi` feature toggle. Supported in the Terraform provider since v2.12.0. +{{% /admonition %}} + +```terraform +resource "grafana_sso_settings" "gitlab_sso_settings" { + provider_name = "gitlab" + oauth2_settings { + name = "Gitlab" + client_id = "YOUR_GITLAB_APPLICATION_ID" + client_secret = "YOUR_GITLAB_APPLICATION_SECRET" + allow_sign_up = true + auto_login = false + scopes = "openid email profile" + allowed_domains = "mycompany.com mycompany.org" + role_attribute_path = "contains(groups[*], 'example-group') && 'Editor' || 'Viewer'" + role_attribute_strict = false + allowed_groups = "[\"admins\", \"software engineers\", \"developers/frontend\"]" + use_pkce = true + use_refresh_token = true + } +} +``` -- Ensure that you have access to the [Grafana configuration file]({{< relref "../../../configure-grafana#configuration-file-location" >}}). -- Ensure you know how to create a GitLab OAuth application. Consult GitLab's documentation on [creating a GitLab OAuth application](https://docs.gitlab.com/ee/integration/oauth_provider.html) for more information. +Go to [Terraform Registry](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/sso_settings) for a complete reference on using the `grafana_sso_settings` resource. -## Steps +## Configure GitLab authentication client using the Grafana configuration file + +Ensure that you have access to the [Grafana configuration file]({{< relref "../../../configure-grafana#configuration-file-location" >}}). + +### Steps To configure GitLab authentication with Grafana, follow these steps: @@ -63,36 +114,6 @@ To configure GitLab authentication with Grafana, follow these steps: You should now see a GitLab login button on the login page and be able to log in or sign up with your GitLab accounts. -## Configuration options - -The table below describes all GitLab OAuth configuration options. Like any other Grafana configuration, you can apply these options as environment variables. - -| Setting | Required | Description | Default | -| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -| `enabled` | Yes | Whether GitLab OAuth authentication is allowed. | `false` | -| `client_id` | Yes | Client ID provided by your GitLab OAuth app. | | -| `client_secret` | Yes | Client secret provided by your GitLab OAuth app. | | -| `auth_url` | Yes | Authorization endpoint of your GitLab OAuth provider. If you use your own instance of GitLab instead of gitlab.com, adjust `auth_url` by replacing the `gitlab.com` hostname with your own. | `https://gitlab.com/oauth/authorize` | -| `token_url` | Yes | Endpoint used to obtain GitLab OAuth access token. If you use your own instance of GitLab instead of gitlab.com, adjust `token_url` by replacing the `gitlab.com` hostname with your own. | `https://gitlab.com/oauth/token` | -| `api_url` | No | Grafana uses `<api_url>/user` endpoint to obtain GitLab user information compatible with [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo). | `https://gitlab.com/api/v4` | -| `name` | No | Name used to refer to the GitLab authentication in the Grafana user interface. | `GitLab` | -| `icon` | No | Icon used for GitLab authentication in the Grafana user interface. | `gitlab` | -| `scopes` | No | List of comma or space-separated GitLab OAuth scopes. | `openid email profile` | -| `allow_sign_up` | No | Whether to allow new Grafana user creation through GitLab login. If set to `false`, then only existing Grafana users can log in with GitLab OAuth. | `true` | -| `auto_login` | No | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | -| `role_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the GitLab OAuth token. If no role is found, Grafana creates a JSON data with `groups` key that maps to groups obtained from GitLab's `/oauth/userinfo` endpoint, and evaluates the expression using this data. Finally, if a valid role is still not found, the expression is evaluated against the user information retrieved from `api_url/users` endpoint and groups retrieved from `api_url/groups` endpoint. The result of the evaluation should be a valid Grafana role (`Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | | -| `role_attribute_strict` | No | Set to `true` to deny user login if the Grafana role cannot be extracted using `role_attribute_path`. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` | -| `allow_assign_grafana_admin` | No | Set to `true` to enable automatic sync of the Grafana server administrator role. If this option is set to `true` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user the server administrator privileges and organization administrator role. If this option is set to `false` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user only organization administrator role. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` | -| `skip_org_role_sync` | No | Set to `true` to stop automatically syncing user roles. | `false` | -| `allowed_domains` | No | List of comma or space-separated domains. User must belong to at least one domain to log in. | | -| `allowed_groups` | No | List of comma or space-separated groups. The user should be a member of at least one group to log in. | | -| `tls_skip_verify_insecure` | No | If set to `true`, the client accepts any certificate presented by the server and any host name in that certificate. _You should only use this for testing_, because this mode leaves SSL/TLS susceptible to man-in-the-middle attacks. | `false` | -| `tls_client_cert` | No | The path to the certificate. | | -| `tls_client_key` | No | The path to the key. | | -| `tls_client_ca` | No | The path to the trusted certificate authority list. | | -| `use_pkce` | No | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `true` | -| `use_refresh_token` | No | Set to `true` to use refresh token and check access token expiration. The `accessTokenExpirationCheck` feature toggle should also be enabled to use refresh token. | `true` | - ### Configure a refresh token > Available in Grafana v9.3 and later versions. @@ -119,7 +140,7 @@ GitLab's groups are referenced by the group name. For example, `developers`. To Note that in GitLab, the group or subgroup name does not always match its display name, especially if the display name contains spaces or special characters. Make sure you always use the group or subgroup name as it appears in the URL of the group or subgroup. -## Configure role mapping +### Configure role mapping Unless `skip_org_role_sync` option is enabled, the user's role will be set to the role retrieved from GitLab upon user login. @@ -173,20 +194,7 @@ role_attribute_path = "'Viewer'" skip_org_role_sync = false ``` -## Configure team synchronization - -> **Note:** Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/). - -By using Team Sync, you can map GitLab groups to teams within Grafana. This will automatically assign users to the appropriate teams. -Teams for each user are synchronized when the user logs in. - -GitLab groups are referenced by the group name. For example, `developers`. To reference a subgroup `frontend`, use `developers/frontend`. -Note that in GitLab, the group or subgroup name does not always match its display name, especially if the display name contains spaces or special characters. -Make sure you always use the group or subgroup name as it appears in the URL of the group or subgroup. - -To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}). - -## Example of GitLab configuration in Grafana +### Example of GitLab configuration in Grafana This section includes an example of GitLab configuration in the Grafana configuration file. @@ -210,3 +218,46 @@ tls_skip_verify_insecure = false use_pkce = true use_refresh_token = true ``` + +## Configure team synchronization + +> **Note:** Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/). + +By using Team Sync, you can map GitLab groups to teams within Grafana. This will automatically assign users to the appropriate teams. +Teams for each user are synchronized when the user logs in. + +GitLab groups are referenced by the group name. For example, `developers`. To reference a subgroup `frontend`, use `developers/frontend`. +Note that in GitLab, the group or subgroup name does not always match its display name, especially if the display name contains spaces or special characters. +Make sure you always use the group or subgroup name as it appears in the URL of the group or subgroup. + +To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}). + +## Configuration options + +The table below describes all GitLab OAuth configuration options. Like any other Grafana configuration, you can apply these options as environment variables. + +| Setting | Required | Description | Default | +| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| `enabled` | Yes | Whether GitLab OAuth authentication is allowed. | `false` | +| `client_id` | Yes | Client ID provided by your GitLab OAuth app. | | +| `client_secret` | Yes | Client secret provided by your GitLab OAuth app. | | +| `auth_url` | Yes | Authorization endpoint of your GitLab OAuth provider. If you use your own instance of GitLab instead of gitlab.com, adjust `auth_url` by replacing the `gitlab.com` hostname with your own. | `https://gitlab.com/oauth/authorize` | +| `token_url` | Yes | Endpoint used to obtain GitLab OAuth access token. If you use your own instance of GitLab instead of gitlab.com, adjust `token_url` by replacing the `gitlab.com` hostname with your own. | `https://gitlab.com/oauth/token` | +| `api_url` | No | Grafana uses `<api_url>/user` endpoint to obtain GitLab user information compatible with [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo). | `https://gitlab.com/api/v4` | +| `name` | No | Name used to refer to the GitLab authentication in the Grafana user interface. | `GitLab` | +| `icon` | No | Icon used for GitLab authentication in the Grafana user interface. | `gitlab` | +| `scopes` | No | List of comma or space-separated GitLab OAuth scopes. | `openid email profile` | +| `allow_sign_up` | No | Whether to allow new Grafana user creation through GitLab login. If set to `false`, then only existing Grafana users can log in with GitLab OAuth. | `true` | +| `auto_login` | No | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | +| `role_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the GitLab OAuth token. If no role is found, Grafana creates a JSON data with `groups` key that maps to groups obtained from GitLab's `/oauth/userinfo` endpoint, and evaluates the expression using this data. Finally, if a valid role is still not found, the expression is evaluated against the user information retrieved from `api_url/users` endpoint and groups retrieved from `api_url/groups` endpoint. The result of the evaluation should be a valid Grafana role (`Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | | +| `role_attribute_strict` | No | Set to `true` to deny user login if the Grafana role cannot be extracted using `role_attribute_path`. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` | +| `allow_assign_grafana_admin` | No | Set to `true` to enable automatic sync of the Grafana server administrator role. If this option is set to `true` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user the server administrator privileges and organization administrator role. If this option is set to `false` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user only organization administrator role. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` | +| `skip_org_role_sync` | No | Set to `true` to stop automatically syncing user roles. | `false` | +| `allowed_domains` | No | List of comma or space-separated domains. User must belong to at least one domain to log in. | | +| `allowed_groups` | No | List of comma or space-separated groups. The user should be a member of at least one group to log in. | | +| `tls_skip_verify_insecure` | No | If set to `true`, the client accepts any certificate presented by the server and any host name in that certificate. _You should only use this for testing_, because this mode leaves SSL/TLS susceptible to man-in-the-middle attacks. | `false` | +| `tls_client_cert` | No | The path to the certificate. | | +| `tls_client_key` | No | The path to the key. | | +| `tls_client_ca` | No | The path to the trusted certificate authority list. | | +| `use_pkce` | No | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `true` | +| `use_refresh_token` | No | Set to `true` to use refresh token and check access token expiration. The `accessTokenExpirationCheck` feature toggle should also be enabled to use refresh token. | `true` | diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md index 73c2b2a049710..eca7ab7253acd 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md @@ -16,6 +16,10 @@ weight: 1100 To enable Google OAuth2 you must register your application with Google. Google will generate a client ID and secret key for you to use. +{{% admonition type="note" %}} +If Users use the same email address in Google that they use with other authentication providers (such as Grafana.com), you need to do additional configuration to ensure that the users are matched correctly. Please refer to the [Using the same email address to login with different identity providers]({{< relref "../../configure-authentication#using-the-same-email-address-to-login-with-different-identity-providers" >}}) documentation for more information. +{{% /admonition %}} + ## Create Google OAuth keys First, you need to create a Google OAuth Client: @@ -31,7 +35,52 @@ First, you need to create a Google OAuth Client: 1. Click Create 1. Copy the Client ID and Client Secret from the 'OAuth Client' modal -## Enable Google OAuth in Grafana +## Configure Google authentication client using the Grafana UI + +{{% admonition type="note" %}} +Available in Public Preview in Grafana 10.4 behind the `ssoSettingsApi` feature toggle. +{{% /admonition %}} + +As a Grafana Admin, you can configure Google OAuth2 client from within Grafana using the Google UI. To do this, navigate to **Administration > Authentication > Google** page and fill in the form. If you have a current configuration in the Grafana configuration file then the form will be pre-populated with those values otherwise the form will contain default values. + +After you have filled in the form, click **Save**. If the save was successful, Grafana will apply the new configurations. + +If you need to reset changes made in the UI back to the default values, click **Reset**. After you have reset the changes, Grafana will apply the configuration from the Grafana configuration file (if there is any configuration) or the default values. + +{{% admonition type="note" %}} +If you run Grafana in high availability mode, configuration changes may not get applied to all Grafana instances immediately. You may need to wait a few minutes for the configuration to propagate to all Grafana instances. +{{% /admonition %}} + +## Configure Google authentication client using the Terraform provider + +{{% admonition type="note" %}} +Available in Public Preview in Grafana 10.4 behind the `ssoSettingsApi` feature toggle. Supported in the Terraform provider since v2.12.0. +{{% /admonition %}} + +```terraform +resource "grafana_sso_settings" "google_sso_settings" { + provider_name = "google" + oauth2_settings { + name = "Google" + client_id = "CLIENT_ID" + client_secret = "CLIENT_SECRET" + allow_sign_up = true + auto_login = false + scopes = "openid email profile" + allowed_domains = "mycompany.com mycompany.org" + hosted_domain = "mycompany.com" + use_pkce = true + } +} +``` + +Go to [Terraform Registry](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/sso_settings) for a complete reference on using the `grafana_sso_settings` resource. + +## Configure Google authentication client using the Grafana configuration file + +Ensure that you have access to the [Grafana configuration file]({{< relref "../../../configure-grafana#configuration-file-location" >}}). + +### Enable Google OAuth in Grafana Specify the Client ID and Secret in the [Grafana configuration file]({{< relref "../../../configure-grafana#configuration-file-location" >}}). For example: @@ -66,7 +115,15 @@ automatically signed up. You may specify a domain to be passed as `hd` query parameter accepted by Google's OAuth 2.0 authentication API. Refer to Google's OAuth [documentation](https://developers.google.com/identity/openid-connect/openid-connect#hd-param). -### PKCE +{{% admonition type="note" %}} +Since Grafana 10.3.0, the `hd` parameter retrieved from Google ID token is also used to determine the user's hosted domain. The Google Oauth `allowed_domains` configuration option is used to restrict access to users from a specific domain. If the `allowed_domains` configuration option is set, the `hd` parameter from the Google ID token must match the `allowed_domains` configuration option. If the `hd` parameter from the Google ID token does not match the `allowed_domains` configuration option, the user is denied access. + +When an account does not belong to a google workspace, the hd claim will not be available. + +This validation is enabled by default. To disable this validation, set the `validate_hd` configuration option to `false`. The `allowed_domains` configuration option will use the email claim to validate the domain. +{{% /admonition %}} + +#### PKCE IETF's [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) introduces "proof key for code exchange" (PKCE) which provides @@ -75,7 +132,7 @@ interception attacks. PKCE will be required in [OAuth 2.1](https://datatracker.i > You can disable PKCE in Grafana by setting `use_pkce` to `false` in the`[auth.google]` section. -### Configure refresh token +#### Configure refresh token > Available in Grafana v9.3 and later versions. @@ -91,7 +148,7 @@ Refresh token fetching and access token expiration check is enabled by default f The `accessTokenExpirationCheck` feature toggle has been removed in Grafana v10.3.0 and the `use_refresh_token` configuration value will be used instead for configuring refresh token fetching and access token expiration check. {{% /admonition %}} -### Configure automatic login +#### Configure automatic login Set `auto_login` option to true to attempt login automatically, skipping the login screen. This setting is ignored if multiple auth providers are configured to use auto login. @@ -123,7 +180,7 @@ With team sync, you can easily add users to teams by utilizing their Google grou To learn more about Team Sync, refer to [Configure Team Sync]({{< relref "../../configure-team-sync" >}}). -### Configure allowed groups +#### Configure allowed groups > Available in Grafana v10.2.0 and later versions. @@ -134,7 +191,7 @@ Google groups are referenced by the group email key. For example, `developers@go > Note: Add the `https://www.googleapis.com/auth/cloud-identity.groups.readonly` scope to your Grafana `[auth.google]` scopes configuration to retrieve groups -## Configure role mapping +#### Configure role mapping > Available in Grafana v10.2.0 and later versions. @@ -151,11 +208,11 @@ To ease configuration of a proper JMESPath expression, go to [JMESPath](http://j > By default skip_org_role_sync is enabled. skip_org_role_sync will default to false in Grafana v10.3.0 and later versions. -### Role mapping examples +##### Role mapping examples This section includes examples of JMESPath expressions used for role mapping. -#### Map roles using user information from OAuth token +###### Map roles using user information from OAuth token In this example, the user with email `admin@company.com` has been granted the `Admin` role. All other users are granted the `Viewer` role. @@ -165,7 +222,7 @@ role_attribute_path = email=='admin@company.com' && 'Admin' || 'Viewer' skip_org_role_sync = false ``` -#### Map roles using groups +###### Map roles using groups In this example, the user from Google group 'example-group@google.com' have been granted the `Editor` role. All other users are granted the `Viewer` role. @@ -177,7 +234,7 @@ skip_org_role_sync = false > Note: Add the `https://www.googleapis.com/auth/cloud-identity.groups.readonly` scope to your Grafana `[auth.google]` scopes configuration to retrieve groups -#### Map server administrator role +###### Map server administrator role In this example, the user with email `admin@company.com` has been granted the `Admin` organization role as well as the Grafana server admin role. All other users are granted the `Viewer` role. @@ -188,7 +245,7 @@ skip_org_role_sync = false role_attribute_path = email=='admin@company.com' && 'GrafanaAdmin' || 'Viewer' ``` -#### Map one role to all users +###### Map one role to all users In this example, all users will be assigned `Viewer` role regardless of the user information received from the identity provider. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/grafana-com/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/grafana-cloud/index.md similarity index 54% rename from docs/sources/setup-grafana/configure-security/configure-authentication/grafana-com/index.md rename to docs/sources/setup-grafana/configure-security/configure-authentication/grafana-cloud/index.md index ffed7b045e84b..c2b2e25eb0bed 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/grafana-com/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/grafana-cloud/index.md @@ -1,29 +1,29 @@ --- aliases: - - ../../../auth/grafana-com/ -description: Grafana Com Authentication + - ../../../auth/grafana-cloud/ +description: Grafana Cloud Authentication labels: products: - cloud -menuTitle: Grafana Com OAuth2 -title: Configure Grafana Com authentication +menuTitle: Grafana Cloud OAuth2 +title: Configure Grafana Cloud authentication weight: 1200 --- -# Configure Grafana Com authentication +# Configure Grafana Cloud authentication -To enable GrafanaCom as your authentication provider, you configure it to generate a client ID and a secret key. +To enable Grafana Cloud as the Identity Provider for a Grafana instance, generate a client ID and client secret and apply the configuration to Grafana. -## Create GrafanaCom OAuth keys +## Create Grafana Cloud OAuth Client Credentials -To use GrafanaCom authentication: +To use Grafana Cloud authentication: -1. Log in to [GrafanaCom](/). +1. Log in to [Grafana Cloud](/). 1. To create an OAuth client, locate your organization and click **OAuth Clients**. 1. Click **Add OAuth Client Application**. 1. Add the name and URL of your running Grafana instance. 1. Click **Add OAuth Client**. -1. Copy the client ID and secret key or the configuration that has been generated. +1. Copy the client ID and client secret or the configuration that has been generated. The following snippet shows an example configuration: @@ -50,7 +50,7 @@ auto_login = true ## Skip organization role sync -To prevent the sync of org roles from Grafana.com, set `skip_org_role_sync` to `true`. This is useful if you want to manage the organization roles for your users from within Grafana. +If a user signs in with their Grafana Cloud credentials, their assigned org role overrides the role defined in the Grafana instance. To prevent Grafana Cloud roles from synchronizing, set `skip_org_role_sync` to `true`. This is useful if you want to manage the organization roles for your users from within Grafana. ```ini [auth.grafana_com] diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/grafana/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/grafana/index.md index 17630be98554b..2b912ce8f8d85 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/grafana/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/grafana/index.md @@ -60,6 +60,25 @@ api_key_max_seconds_to_live = -1 You can make Grafana accessible without any login required by enabling anonymous access in the configuration file. For more information, refer to [Anonymous authentication]({{< relref "../../configure-authentication#anonymous-authentication" >}}). +#### Anonymous devices + +The anonymous devices feature enhances the management and monitoring of anonymous access within your Grafana instance. This feature is part of ongoing efforts to provide more control and transparency over anonymous usage. + +Users can now view anonymous usage statistics, including the count of devices and users over the last 30 days. + +- Go to **Administration -> Users** to access the anonymous devices tab. +- A new stat for the usage stats page -> Usage & Stats page shows the active anonymous devices last 30 days. + +The number of anonymous devices is not limited by default. The configuration option `device_limit` allows you to enforce a limit on the number of anonymous devices. This enables you to have greater control over the usage within your Grafana instance and keep the usage within the limits of your environment. Once the limit is reached, any new devices that try to access Grafana will be denied access. + +#### Anonymous users + +{{< admonition type="note" >}} +Anonymous users are charged as active users in Grafana Enterprise +{{< /admonition >}} + +#### Configuration + Example: ```bash @@ -81,17 +100,6 @@ device_limit = If you change your organization name in the Grafana UI this setting needs to be updated to match the new name. -#### Anonymous devices - -The anonymous devices feature enhances the management and monitoring of anonymous access within your Grafana instance. This feature is part of ongoing efforts to provide more control and transparency over anonymous usage. - -Users can now view anonymous usage statistics, including the count of devices and users over the last 30 days. - -- Go to **Administration -> Users** to access the anonymous devices tab. -- A new stat for the usage stats page -> Usage & Stats page shows the active anonymous devices last 30 days. - -The number of anonymous devices is not limited by default. The configuration option `device_limit` allows you to enforce a limit on the number of anonymous devices. This enables you to have greater control over the usage within your Grafana instance and keep the usage within the limits of your environment. Once the limit is reached, any new devices that try to access Grafana will be denied access. - ### Basic authentication Basic auth is enabled by default and works with the built in Grafana user password authentication system and LDAP @@ -104,6 +112,27 @@ To disable basic auth: enabled = false ``` +### Strong password policy + +By default, the password policy for all basic auth users is set to a minimum of four characters. You can enable a stronger password policy with the `password_policy` configuration option. + +With the `password_policy` option enabled, new and updated passwords must meet the following criteria: + +- At least 12 characters +- At least one uppercase letter +- At least one lowercase letter +- At least one number +- At least one special character + +```bash +[auth.basic] +password_policy = true +``` + +{{% admonition type="note" %}} +Existing passwords that don't comply with the new password policy will not be impacted until the user updates their password. +{{% /admonition %}} + ### Disable login form You can hide the Grafana login form using the below configuration settings. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md index 2d0a30f8fe38e..6f2dbea11abe5 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md @@ -24,6 +24,10 @@ Keycloak OAuth2 authentication allows users to log in to Grafana using their Key Refer to [Generic OAuth authentication]({{< relref "../generic-oauth" >}}) for extra configuration options available for this provider. +{{% admonition type="note" %}} +If Users use the same email address in Keycloak that they use with other authentication providers (such as Grafana.com), you need to do additional configuration to ensure that the users are matched correctly. Please refer to the [Using the same email address to login with different identity providers]({{< relref "../../configure-authentication#using-the-same-email-address-to-login-with-different-identity-providers" >}}) documentation for more information. +{{% /admonition %}} + You may have to set the `root_url` option of `[server]` for the callback URL to be correct. For example in case you are serving Grafana behind a proxy. @@ -137,14 +141,15 @@ To enable Single Logout, you need to add the following option to the configurati ```ini [auth.generic_oauth] -signout_redirect_url = https://<PROVIDER_DOMAIN>/auth/realms/<REALM_NAME>/protocol/openid-connect/logout?post_logout_redirect_uri=https%3A%2F%2<GRAFANA_DOMAIN>%2Flogin +signout_redirect_url = https://<PROVIDER_DOMAIN>/auth/realms/<REALM_NAME>/protocol/openid-connect/logout?post_logout_redirect_uri=https%3A%2F%2F<GRAFANA_DOMAIN>%2Flogin ``` As an example, `<PROVIDER_DOMAIN>` can be `keycloak-demo.grafana.org`, `<REALM_NAME>` can be `grafana` and `<GRAFANA_DOMAIN>` can be `play.grafana.org`. -> **Note**: Grafana does not support `id_token_hints`. From keycloak 18, it is necessary to disable `id_token_hints` enforcement in keycloak for -> single logout to work. [Documentation reference](https://www.keycloak.org/2022/04/keycloak-1800-released#_openid_connect_logout). +{{% admonition type="note" %}} +Grafana supports ID token hints for single logout. Grafana automatically adds the `id_token_hint` parameter to the logout request if it detects OAuth as the authentication method. +{{% /admonition %}} ## Allow assigning Grafana Admin diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/ldap/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/ldap/index.md index c15e2f2dbd7fb..eece776c8815c 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/ldap/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/ldap/index.md @@ -18,9 +18,11 @@ weight: 300 The LDAP integration in Grafana allows your Grafana users to login with their LDAP credentials. You can also specify mappings between LDAP group memberships and Grafana Organization user roles. -> [Enhanced LDAP authentication]({{< relref "../enhanced-ldap" >}}) is available in [Grafana Cloud](/docs/grafana-cloud/) and in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}). +{{% admonition type="note" %}} +[Enhanced LDAP authentication]({{< relref "../enhanced-ldap" >}}) is available in [Grafana Cloud](/docs/grafana-cloud/) and in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}). +{{% /admonition %}} -> Refer to [Role-based access control]({{< relref "../../../../administration/roles-and-permissions/access-control" >}}) to understand how you can control access with role-based permissions. +Refer to [Role-based access control]({{< relref "../../../../administration/roles-and-permissions/access-control" >}}) to understand how you can control access with role-based permissions. ## Supported LDAP Servers @@ -127,6 +129,10 @@ member_of = "memberOf" email = "email" ``` +{{% admonition type="note" %}} +Whenever you modify the ldap.toml file, you must restart Grafana in order for the change(s) to take effect. +{{% /admonition %}} + ### Using environment variables You can interpolate variables in the TOML configuration from environment variables. For instance, you could externalize your `bind_password` that way: @@ -135,31 +141,31 @@ You can interpolate variables in the TOML configuration from environment variabl bind_password = "${LDAP_ADMIN_PASSWORD}" ``` -## LDAP Debug View +## LDAP debug view -> Only available in Grafana v6.4+ +{{% admonition type="note" %}} +Available in Grafana v6.4+ +{{% /admonition %}} -Grafana has an LDAP debug view built-in which allows you to test your LDAP configuration directly within Grafana. At the moment of writing, only Grafana admins can use the LDAP debug view. +Grafana has an LDAP debug view built-in which allows you to test your LDAP configuration directly within Grafana. Only Grafana admins can use the LDAP debug view. Within this view, you'll be able to see which LDAP servers are currently reachable and test your current configuration. -{{< figure src="/static/img/docs/ldap_debug.png" class="docs-image--no-shadow" max-width="600px" >}} +{{< figure src="/static/img/docs/ldap_debug.png" class="docs-image--no-shadow" max-width="600px" alt="LDAP testing" >}} -To use the debug view: +To use the debug view, complete the following steps: 1. Type the username of a user that exists within any of your LDAP server(s) 1. Then, press "Run" -1. If the user is found within any of your LDAP instances, the mapping information is displayed +1. If the user is found within any of your LDAP instances, the mapping information is displayed. -{{< figure src="/static/img/docs/ldap_debug_mapping_testing.png" class="docs-image--no-shadow" max-width="600px" >}} +{{< figure src="/static/img/docs/ldap_debug_mapping_testing.png" class="docs-image--no-shadow" max-width="600px" alt="LDAP mapping displayed" >}} [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) users with [enhanced LDAP integration]({{< relref "../enhanced-ldap" >}}) enabled can also see sync status in the debug view. This requires the `ldap.status:read` permission. -{{< figure src="/static/img/docs/ldap_sync_debug.png" class="docs-image--no-shadow" max-width="600px" >}} - -### Bind +{{< figure src="/static/img/docs/ldap_sync_debug.png" class="docs-image--no-shadow" max-width="600px" alt="LDAP sync status" >}} -#### Bind and Bind Password +### Bind and bind password By default the configuration expects you to specify a bind DN and bind password. This should be a read only user that can perform LDAP searches. When the user DN is found a second bind is performed with the user provided username and password (in the normal Grafana login form). @@ -169,7 +175,7 @@ bind_dn = "cn=admin,dc=grafana,dc=org" bind_password = "grafana" ``` -#### Single Bind Example +#### Single bind example If you can provide a single bind expression that matches all possible users, you can skip the second bind and bind against the user DN directly. This allows you to not specify a bind_password in the configuration file. @@ -183,7 +189,7 @@ The search filter and search bases settings are still needed to perform the LDAP ### POSIX schema -If your LDAP server does not support the memberOf attribute add these options: +If your LDAP server does not support the `memberOf` attribute, add the following options: ```bash ## Group search filter, to retrieve the groups of which the user is a member (only set if memberOf attribute is not available) @@ -194,7 +200,7 @@ group_search_base_dns = ["ou=groups,dc=grafana,dc=org"] group_search_filter_user_attribute = "uid" ``` -### Group Mappings +### Group mappings In `[[servers.group_mappings]]` you can map an LDAP group to a Grafana organization and role. These will be synced every time the user logs in, with LDAP being the authoritative source. @@ -231,8 +237,12 @@ org_role = "Viewer" | `org_id` | No | The Grafana organization database id. Setting this allows for multiple group_dn's to be assigned to the same `org_role` provided the `org_id` differs | `1` (default org id) | | `grafana_admin` | No | When `true` makes user of `group_dn` Grafana server admin. A Grafana server admin has admin access over all organizations and users. Available in Grafana v5.3 and above | `false` | -Note: Commenting out a group mapping requires also commenting out the header of -said group or it will fail validation as an empty mapping. Example: +{{% admonition type="note" %}} +Commenting out a group mapping requires also commenting out the header of +said group or it will fail validation as an empty mapping. +{{% /admonition %}} + +Example: ```bash [[servers]] @@ -265,7 +275,7 @@ To configure `group_search_filter`: **Active Directory example:** Active Directory groups store the Distinguished Names (DNs) of members, so your filter will need to know the DN for the user based only on the submitted username. -Multiple DN templates can be searched by combining filters with the LDAP OR-operator. Two examples: +Multiple DN templates are searched by combining filters with the LDAP OR-operator. Two examples: ```bash group_search_filter = "(member:1.2.840.113556.1.4.1941:=%s)" @@ -281,10 +291,12 @@ group_search_filter_user_attribute = "cn" For more information on AD searches see [Microsoft's Search Filter Syntax](https://docs.microsoft.com/en-us/windows/desktop/adsi/search-filter-syntax) documentation. -For troubleshooting, by changing `member_of` in `[servers.attributes]` to "dn" it will show you more accurate group memberships when [debug is enabled](#troubleshooting). +For troubleshooting, changing `member_of` in `[servers.attributes]` to "dn" will show you more accurate group memberships when [debug is enabled](#troubleshooting). ## Configuration examples +The following examples describe different LDAP configuration options. + ### OpenLDAP [OpenLDAP](http://www.openldap.org/) is an open source directory service. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md index b11bcf1102155..9cc47511f53bf 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md @@ -16,14 +16,65 @@ weight: 1400 {{< docs/shared lookup="auth/intro.md" source="grafana" version="<GRAFANA VERSION>" >}} +{{% admonition type="note" %}} +If Users use the same email address in Okta that they use with other authentication providers (such as Grafana.com), you need to do additional configuration to ensure that the users are matched correctly. Please refer to the [Using the same email address to login with different identity providers]({{< relref "../../configure-authentication#using-the-same-email-address-to-login-with-different-identity-providers" >}}) documentation for more information. +{{% /admonition %}} + ## Before you begin -To follow this guide: +To follow this guide, ensure you have permissions in your Okta workspace to create an OIDC app. + +## Configure Okta authentication client using the Grafana UI + +{{% admonition type="note" %}} +Available in Public Preview in Grafana 10.4 behind the `ssoSettingsApi` feature toggle. +{{% /admonition %}} + +As a Grafana Admin, you can configure Okta OAuth2 client from within Grafana using the Okta UI. To do this, navigate to **Administration > Authentication > Okta** page and fill in the form. If you have a current configuration in the Grafana configuration file then the form will be pre-populated with those values otherwise the form will contain default values. + +After you have filled in the form, click **Save**. If the save was successful, Grafana will apply the new configurations. + +If you need to reset changes you made in the UI back to the default values, click **Reset**. After you have reset the changes, Grafana will apply the configuration from the Grafana configuration file (if there is any configuration) or the default values. + +{{% admonition type="note" %}} +If you run Grafana in high availability mode, configuration changes may not get applied to all Grafana instances immediately. You may need to wait a few minutes for the configuration to propagate to all Grafana instances. +{{% /admonition %}} + +Refer to [configuration options]({{< relref "#configuration-options" >}}) for more information. + +## Configure Okta authentication client using the Terraform provider + +{{% admonition type="note" %}} +Available in Public Preview in Grafana 10.4 behind the `ssoSettingsApi` feature toggle. Supported in the Terraform provider since v2.12.0. +{{% /admonition %}} + +```terraform +resource "grafana_sso_settings" "okta_sso_settings" { + provider_name = "okta" + oauth2_settings { + name = "Okta" + auth_url = "https://<okta tenant id>.okta.com/oauth2/v1/authorize" + token_url = "https://<okta tenant id>.okta.com/oauth2/v1/token" + api_url = "https://<okta tenant id>.okta.com/oauth2/v1/userinfo" + client_id = "CLIENT_ID" + client_secret = "CLIENT_SECRET" + allow_sign_up = true + auto_login = false + scopes = "openid profile email offline_access" + role_attribute_path = "contains(groups[*], 'Example::DevOps') && 'Admin' || 'None'" + role_attribute_strict = true + allowed_groups = "Example::DevOps,Example::Dev,Example::QA" + } +} +``` + +Go to [Terraform Registry](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/sso_settings) for a complete reference on using the `grafana_sso_settings` resource. -- Ensure that you have access to the [Grafana configuration file]({{< relref "../../../configure-grafana#configuration-file-location" >}}). -- Ensure you have permissions in your Okta workspace to create an OIDC app. +## Configure Okta authentication client using the Grafana configuration file -## Steps +Ensure that you have access to the [Grafana configuration file]({{< relref "../../../configure-grafana#configuration-file-location" >}}). + +### Steps To integrate your Okta OIDC provider with Grafana using our Okta OIDC integration, follow these steps: @@ -87,31 +138,6 @@ role_attribute_strict = true allowed_groups = "Example::DevOps" "Example::Dev" "Example::QA" ``` -## Configuration options - -The following table outlines the various Okta OIDC configuration options. You can apply these options as environment variables, similar to any other configuration within Grafana. - -| Setting | Required | Description | Default | -| ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | -| `enabled` | No | Enables Okta OIDC authentication. | `false` | -| `name` | No | Name that refers to the Okta OIDC authentication from the Grafana user interface. | `Okta` | -| `icon` | No | Icon used for the Okta OIDC authentication in the Grafana user interface. | `okta` | -| `client_id` | Yes | Client ID provided by your Okta OIDC app. | | -| `client_secret` | Yes | Client secret provided by your Okta OIDC app. | | -| `auth_url` | Yes | Authorization endpoint of your Okta OIDC provider. | | -| `token_url` | Yes | Endpoint used to obtain the Okta OIDC access token. | | -| `api_url` | Yes | Endpoint used to obtain user information. | | -| `scopes` | No | List of comma- or space-separated Okta OIDC scopes. | `openid profile email groups` | -| `allow_sign_up` | No | Controls Grafana user creation through the Okta OIDC login. Only existing Grafana users can log in with Okta OIDC if set to `false`. | `true` | -| `auto_login` | No | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | -| `role_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the Okta OIDC ID token. If no role is found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation should be a valid Grafana role (`Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | | -| `role_attribute_strict` | No | Set to `true` to deny user login if the Grafana role cannot be extracted using `role_attribute_path`. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` | -| `skip_org_role_sync` | No | Set to `true` to stop automatically syncing user roles. This will allow you to set organization roles for your users from within Grafana manually. | `false` | -| `allowed_groups` | No | List of comma- or space-separated groups. The user should be a member of at least one group to log in. | | -| `allowed_domains` | No | List comma- or space-separated domains. The user should belong to at least one domain to log in. | | -| `use_pkce` | No | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `true` | -| `use_refresh_token` | No | Set to `true` to use refresh token and check access token expiration. | `false` | - ### Configure a refresh token > Available in Grafana v9.3 and later versions. @@ -157,3 +183,28 @@ the correct teams. Okta groups can be referenced by group names, like `Admins` or `Editors`. To learn more about Team Sync, refer to [Configure Team Sync]({{< relref "../../configure-team-sync" >}}). + +## Configuration options + +The following table outlines the various Okta OIDC configuration options. You can apply these options as environment variables, similar to any other configuration within Grafana. + +| Setting | Required | Description | Default | +| ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| `enabled` | No | Enables Okta OIDC authentication. | `false` | +| `name` | No | Name that refers to the Okta OIDC authentication from the Grafana user interface. | `Okta` | +| `icon` | No | Icon used for the Okta OIDC authentication in the Grafana user interface. | `okta` | +| `client_id` | Yes | Client ID provided by your Okta OIDC app. | | +| `client_secret` | Yes | Client secret provided by your Okta OIDC app. | | +| `auth_url` | Yes | Authorization endpoint of your Okta OIDC provider. | | +| `token_url` | Yes | Endpoint used to obtain the Okta OIDC access token. | | +| `api_url` | Yes | Endpoint used to obtain user information. | | +| `scopes` | No | List of comma- or space-separated Okta OIDC scopes. | `openid profile email groups` | +| `allow_sign_up` | No | Controls Grafana user creation through the Okta OIDC login. Only existing Grafana users can log in with Okta OIDC if set to `false`. | `true` | +| `auto_login` | No | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | +| `role_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the Okta OIDC ID token. If no role is found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation should be a valid Grafana role (`Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | | +| `role_attribute_strict` | No | Set to `true` to deny user login if the Grafana role cannot be extracted using `role_attribute_path`. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` | +| `skip_org_role_sync` | No | Set to `true` to stop automatically syncing user roles. This will allow you to set organization roles for your users from within Grafana manually. | `false` | +| `allowed_groups` | No | List of comma- or space-separated groups. The user should be a member of at least one group to log in. | | +| `allowed_domains` | No | List comma- or space-separated domains. The user should belong to at least one domain to log in. | | +| `use_pkce` | No | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `true` | +| `use_refresh_token` | No | Set to `true` to use refresh token and check access token expiration. | `false` | diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/index.md index 78669d9cbd1f5..f043a4bf71100 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/index.md @@ -69,7 +69,7 @@ By default, SP-initiated requests are enabled. For instructions on how to enable - [`assertion_attribute_email`]({{< relref "../../../configure-grafana/enterprise-configuration#assertion_attribute_email" >}}) - [`assertion_attribute_name`]({{< relref "../../../configure-grafana/enterprise-configuration#assertion_attribute_name" >}}) - [`assertion_attribute_groups`]({{< relref "../../../configure-grafana/enterprise-configuration#assertion_attribute_groups" >}}) -1. Save the configuration file and and then restart the Grafana server. +1. Save the configuration file and then restart the Grafana server. When you are finished, the Grafana configuration might look like this example: diff --git a/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-aws-kms/index.md b/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-aws-kms/index.md index 32e1c77a2799e..f27851f1bd4ba 100644 --- a/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-aws-kms/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-aws-kms/index.md @@ -73,8 +73,6 @@ You can use an encryption key from AWS Key Management Service to encrypt secrets available_encryption_providers = awskms.example-encryption-key ``` - **> Note:** The encryption key that is stored in the `secret_key` field is still used by Grafana’s legacy alerting system to encrypt secrets, for decrypting existing secrets, or it is used as the default provider when external providers are not configured. Do not change or remove that value when adding a new KMS provider. - 7. [Restart Grafana](/docs/grafana/latest/installation/restart-grafana/). 8. (Optional) From the command line and the root directory of Grafana, re-encrypt all of the secrets within the Grafana database with the new key using the following command: @@ -83,6 +81,6 @@ You can use an encryption key from AWS Key Management Service to encrypt secrets If you do not re-encrypt existing secrets, then they will remain encrypted by the previous encryption key. Users will still be able to access them. - **> Note:** This process could take a few minutes to complete, depending on the number of secrets (such as data sources or alert notification channels) in your database. Users might experience errors while this process is running, and alert notifications might not be sent. + **> Note:** This process could take a few minutes to complete, depending on the number of secrets (such as data sources) in your database. Users might experience errors while this process is running, and alert notifications might not be sent. - **> Note:** If you are updating this encryption key during the initial setup of Grafana before any data sources, alert notification channels, or dashboards have been created, then this step is not necessary because there are no secrets in Grafana to migrate. + **> Note:** If you are updating this encryption key during the initial setup of Grafana before any data sources or dashboards have been created, then this step is not necessary because there are no secrets in Grafana to migrate. diff --git a/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-azure-key-vault/index.md b/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-azure-key-vault/index.md index 7a6b8736c02a2..e90f81bc6d0ab 100644 --- a/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-azure-key-vault/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-azure-key-vault/index.md @@ -71,8 +71,6 @@ You can use an encryption key from Azure Key Vault to encrypt secrets in the Gra available_encryption_providers = azurekv.example-encryption-key ``` - **> Note:** The encryption key stored in the `secret_key` field is still used by Grafana’s legacy alerting system to encrypt secrets. Do not change or remove that value. - 9. [Restart Grafana](/docs/grafana/latest/installation/restart-grafana/). 10. (Optional) From the command line and the root directory of Grafana Enterprise, re-encrypt all of the secrets within the Grafana database with the new key using the following command: @@ -81,6 +79,6 @@ You can use an encryption key from Azure Key Vault to encrypt secrets in the Gra If you do not re-encrypt existing secrets, then they will remain encrypted by the previous encryption key. Users will still be able to access them. - **> Note:** This process could take a few minutes to complete, depending on the number of secrets (such as data sources or alert notification channels) in your database. Users might experience errors while this process is running, and alert notifications might not be sent. + **> Note:** This process could take a few minutes to complete, depending on the number of secrets (such as data sources) in your database. Users might experience errors while this process is running, and alert notifications might not be sent. - **> Note:** If you are updating this encryption key during the initial setup of Grafana before any data sources, alert notification channels, or dashboards have been created, then this step is not necessary because there are no secrets in Grafana to migrate. + **> Note:** If you are updating this encryption key during the initial setup of Grafana before any data sources or dashboards have been created, then this step is not necessary because there are no secrets in Grafana to migrate. diff --git a/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-google-cloud-kms/index.md b/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-google-cloud-kms/index.md index 95433c423f572..240836fa303ec 100644 --- a/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-google-cloud-kms/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-google-cloud-kms/index.md @@ -60,8 +60,6 @@ You can use an encryption key from Google Cloud Key Management Service to encryp available_encryption_providers = googlekms.example-encryption-key ``` - **> Note:** The encryption key stored in the `secret_key` field is still used by Grafana’s legacy alerting system to encrypt secrets. Do not change or remove that value. - 8. [Restart Grafana](/docs/grafana/latest/installation/restart-grafana/). 9. (Optional) From the command line and the root directory of Grafana Enterprise, re-encrypt all of the secrets within the Grafana database with the new key using the following command: @@ -70,6 +68,6 @@ You can use an encryption key from Google Cloud Key Management Service to encryp If you do not re-encrypt existing secrets, then they will remain encrypted by the previous encryption key. Users will still be able to access them. - **> Note:** This process could take a few minutes to complete, depending on the number of secrets (such as data sources or alert notification channels) in your database. Users might experience errors while this process is running, and alert notifications might not be sent. + **> Note:** This process could take a few minutes to complete, depending on the number of secrets (such as data sources) in your database. Users might experience errors while this process is running, and alert notifications might not be sent. - **> Note:** If you are updating this encryption key during the initial setup of Grafana before any data sources, alert notification channels, or dashboards have been created, then this step is not necessary because there are no secrets in Grafana to migrate. + **> Note:** If you are updating this encryption key during the initial setup of Grafana before any data sources or dashboards have been created, then this step is not necessary because there are no secrets in Grafana to migrate. diff --git a/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-hashicorp-key-vault/index.md b/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-hashicorp-key-vault/index.md index b7626e5d60368..76bfd46686a3e 100644 --- a/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-hashicorp-key-vault/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-hashicorp-key-vault/index.md @@ -67,8 +67,6 @@ You can use an encryption key from Hashicorp Vault to encrypt secrets in the Gra available_encryption_providers = hashicorpvault.example-encryption-key ``` - **> Note:** The encryption key stored in the `secret_key` field is still used by Grafana’s legacy alerting system to encrypt secrets. Do not change or remove that value. - 7. [Restart Grafana](/docs/grafana/latest/installation/restart-grafana/). 8. (Optional) From the command line and the root directory of Grafana Enterprise, re-encrypt all of the secrets within the Grafana database with the new key using the following command: @@ -77,6 +75,6 @@ You can use an encryption key from Hashicorp Vault to encrypt secrets in the Gra If you do not re-encrypt existing secrets, then they will remain encrypted by the previous encryption key. Users will still be able to access them. - **> Note:** This process could take a few minutes to complete, depending on the number of secrets (such as data sources or alert notification channels) in your database. Users might experience errors while this process is running, and alert notifications might not be sent. + **> Note:** This process could take a few minutes to complete, depending on the number of secrets (such as data sources) in your database. Users might experience errors while this process is running, and alert notifications might not be sent. - **> Note:** If you are updating this encryption key during the initial setup of Grafana before any data sources, alert notification channels, or dashboards have been created, then this step is not necessary because there are no secrets in Grafana to migrate. + **> Note:** If you are updating this encryption key during the initial setup of Grafana before any data sources or dashboards have been created, then this step is not necessary because there are no secrets in Grafana to migrate. diff --git a/docs/sources/setup-grafana/configure-security/configure-request-security.md b/docs/sources/setup-grafana/configure-security/configure-request-security.md index 9a2fb05cb9a04..437786ed07fb1 100644 --- a/docs/sources/setup-grafana/configure-security/configure-request-security.md +++ b/docs/sources/setup-grafana/configure-security/configure-request-security.md @@ -8,7 +8,6 @@ labels: products: - cloud - enterprise - - oss title: Configure request security weight: 1100 --- diff --git a/docs/sources/setup-grafana/configure-security/configure-security-hardening/index.md b/docs/sources/setup-grafana/configure-security/configure-security-hardening/index.md index fd68b7cc40aaa..bc0dae58b4a08 100644 --- a/docs/sources/setup-grafana/configure-security/configure-security-hardening/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-security-hardening/index.md @@ -101,7 +101,7 @@ To enable trusted types in report mode, where inputs that have not been sanitize - Enable `content_security_policy_report_only` in the configuration. - Add `require-trusted-types-for 'script'` to the `content_security_policy_report_only_template` in the configuration. -As this is a feature currently in development, things may break. If they do, or if you have any other feedback, feel free to [leave a comment](https://github.com/grafana/grafana/discussions/66823). +As this is a feature currently in development, things may break. If they do, or if you have any other feedback, feel free to [open an issue](https://github.com/grafana/grafana/issues/new/choose). ## Additional security hardening diff --git a/docs/sources/setup-grafana/configure-security/configure-team-sync.md b/docs/sources/setup-grafana/configure-security/configure-team-sync.md index f7410d14c711d..07f251ad25cbb 100644 --- a/docs/sources/setup-grafana/configure-security/configure-team-sync.md +++ b/docs/sources/setup-grafana/configure-security/configure-team-sync.md @@ -40,11 +40,12 @@ This mechanism allows Grafana to remove an existing synchronized user from a tea If you have already grouped some users into a team, then you can synchronize that team with an external group. -{{< figure src="/static/img/docs/enterprise/team_add_external_group.png" class="docs-image--no-shadow docs-image--right" max-width= "600px" >}} - 1. In Grafana, navigate to **Administration > Users and access > Teams**. 1. Select a team. 1. Go to the External group sync tab, and click **Add group**. + + ![External group sync](/static/img/docs/enterprise/team_add_external_group.png) + 1. Insert the value of the group you want to sync with. This becomes the Grafana `GroupID`. Examples: diff --git a/docs/sources/setup-grafana/image-rendering/_index.md b/docs/sources/setup-grafana/image-rendering/_index.md index 61fd88e13dcd6..cee1b8434328c 100644 --- a/docs/sources/setup-grafana/image-rendering/_index.md +++ b/docs/sources/setup-grafana/image-rendering/_index.md @@ -30,7 +30,7 @@ You can also render a PNG by hovering over the panel to display the actions menu ## Alerting and render limits -Alert notifications can include images, but rendering many images at the same time can overload the server where the renderer is running. For instructions of how to configure this, see [concurrent_render_limit]({{< relref "../configure-grafana#concurrent_render_limit" >}}). +Alert notifications can include images, but rendering many images at the same time can overload the server where the renderer is running. For instructions of how to configure this, see [max_concurrent_screenshots]({{< relref "../configure-grafana#max_concurrent_screenshots" >}}). ## Install Grafana Image Renderer plugin diff --git a/docs/sources/setup-grafana/installation/docker/index.md b/docs/sources/setup-grafana/installation/docker/index.md index 1ac0f2042fca3..47aa7d0f4a099 100644 --- a/docs/sources/setup-grafana/installation/docker/index.md +++ b/docs/sources/setup-grafana/installation/docker/index.md @@ -15,7 +15,7 @@ weight: 400 This topic guides you through installing Grafana via the official Docker images. Specifically, it covers running Grafana via the Docker command line interface (CLI) and docker-compose. -{{< youtube id="FlDfcMbSLXs" >}} +{{< youtube id="FlDfcMbSLXs" start="703">}} Grafana Docker images come in two editions: diff --git a/docs/sources/setup-grafana/installation/helm/index.md b/docs/sources/setup-grafana/installation/helm/index.md new file mode 100644 index 0000000000000..b49c8271ca8c4 --- /dev/null +++ b/docs/sources/setup-grafana/installation/helm/index.md @@ -0,0 +1,381 @@ +--- +aliases: + - ../../installation/helm/ +description: Guide for deploying Grafana using Helm Charts +labels: + products: + - enterprise + - oss +menuTitle: Grafana on Helm Charts +title: Deploy Grafana using Helm Charts +weight: 500 +--- + +# Deploy Grafana using Helm Charts + +This topic includes instructions for installing and running Grafana on Kubernetes using Helm Charts. + +[Helm](https://helm.sh/) is an open-source command line tool used for managing Kubernetes applications. It is a graduate project in the [CNCF Landscape](https://www.cncf.io/projects/helm/). + +{{% admonition type="note" %}} +The Grafana open-source community offers Helm Charts for running it on Kubernetes. Please be aware that the code is provided without any warranties. If you encounter any problems, you can report them to the [Official GitHub repository](https://github.com/grafana/helm-charts/). +{{% /admonition %}} + +## Before you begin + +To install Grafana using Helm, ensure you have completed the following: + +- Install a Kubernetes server on your machine. For information about installing Kubernetes, refer to [Install Kubernetes](https://kubernetes.io/docs/setup/). +- Install the latest stable version of Helm. For information on installing Helm, refer to [Install Helm](https://helm.sh/docs/intro/install/). + +## Install Grafana using Helm + +When you install Grafana using Helm, you complete the following tasks: + +1. Set up the Grafana Helm repository, which provides a space in which you will install Grafana. + +1. Deploy Grafana using Helm, which installs Grafana into a namespace. + +1. Accessing Grafana, which provides steps to sign into Grafana. + +### Set up the Grafana Helm repository + +To set up the Grafana Helm repository so that you download the correct Grafana Helm charts on your machine, complete the following steps: + +1. To add the Grafana repository, use the following command syntax: + + `helm repo add <DESIRED-NAME> <HELM-REPO-URL>` + + The following example adds the `grafana` Helm repository. + + ```bash + helm repo add grafana https://grafana.github.io/helm-charts + ``` + +1. Run the following command to verify the repository was added: + + ```bash + helm repo list + ``` + + After you add the repository, you should see an output similar to the following: + + ```bash + NAME URL + grafana https://grafana.github.io/helm-charts + ``` + +1. Run the following command to update the repository to download the latest Grafana Helm charts: + + ```bash + helm repo update + ``` + +### Deploy the Grafana Helm charts + +After you have set up the Grafana Helm repository, you can start to deploy it on your Kubernetes cluster. + +When you deploy Grafana Helm charts, use a separate namespace instead of relying on the default namespace. The default namespace might already have other applications running, which can lead to conflicts and other potential issues. + +When you create a new namespace in Kubernetes, you can better organize, allocate, and manage cluster resources. For more information, refer to [Namespaces](https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/). + +1. To create a namespace, run the following command: + + ```bash + kubectl create namespace monitoring + ``` + + You will see an output similar to this, which means that the namespace has been successfully created: + + ```bash + namespace/monitoring created + ``` + +1. Search for the official `grafana/grafana` repository using the command: + + `helm search repo <repo-name/package-name>` + + For example, the following command provides a list of the Grafana Helm Charts from which you will install the latest version of the Grafana chart. + + ```bash + helm search repo grafana/grafana + ``` + +1. Run the following command to deploy the Grafana Helm Chart inside your namespace. + + ```bash + helm install my-grafana grafana/grafana --namespace monitoring + ``` + + Where: + + - `helm install`: Installs the chart by deploying it on the Kubernetes cluster + - `my-grafana`: The logical chart name that you provided + - `grafana/grafana`: The repository and package name to install + - `--namespace`: The Kubernetes namespace (i.e. `monitoring`) where you want to deploy the chart + +1. To verify the deployment status, run the following command and verify that `deployed` appears in the **STATUS** column: + + ```bash + helm list -n monitoring + ``` + + You should see an output similar to the following: + + ```bash + NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION + my-grafana monitoring 1 2024-01-13 23:06:42.737989554 +0000 UTC deployed grafana-6.59.0 10.1.0 + ``` + +1. To check the overall status of all the objects in the namespace, run the following command: + + ```bash + kubectl get all -n monitoring + ``` + + If you encounter errors or warnings in the **STATUS** column, check the logs and refer to the Troubleshooting section of this documentation. + +### Access Grafana + +This section describes the steps you must complete to access Grafana via web browser. + +1. Run the following `helm get notes` command: + + ```bash + helm get notes my-grafana -n monitoring + ``` + + This command will print out the chart notes. You will the output `NOTES` that provide the complete instructions about: + + - How to decode the login password for the Grafana admin account + - Access Grafana service to the web browser + +1. To get the Grafana admin password, run the command as follows: + + ```bash + kubectl get secret --namespace monitoring my-grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo + ``` + + It will give you a decoded `base64` string output which is the password for the admin account. + +1. Save the decoded password to a file on your machine. + +1. To access Grafana service on the web browser, run the following command: + + ```bash + export POD_NAME=$(kubectl get pods --namespace monitoring -l "app.kubernetes.io/name=grafana,app.kubernetes.io/instance=my-grafana" -o jsonpath="{.items[0].metadata.name}") + ``` + + The above command will export a shell variable named `POD_NAME` that will save the complete name of the pod which got deployed. + +1. Run the following port forwarding command to direct the Grafana pod to listen to port `3000`: + + ```bash + kubectl --namespace monitoring port-forward $POD_NAME 3000 + ``` + + For more information about port-forwarding, refer to [Use Port Forwarding to Access Applications in a Cluster](https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/). + +1. Navigate to `127.0.0.1:3000` in your browser. + +1. The Grafana sign-in page appears. + +1. To sign in, enter `admin` for the username. + +1. For the password paste it which you have saved to a file after decoding it earlier. + +## Customize Grafana default configuration + +Helm is a popular package manager for Kubernetes. It bundles Kubernetes resource manifests to be re-used across different environments. These manifests are written in a templating language, allowing you to provide configuration values via `values.yaml` file, or in-line using Helm, to replace the placeholders in the manifest where these configurations should reside. + +The `values.yaml` file allows you to customize the chart's configuration by specifying values for various parameters such as image versions, resource limits, service configurations, etc. + +By modifying the values in the `values.yaml` file, you can tailor the deployment of a Helm chart to your specific requirements by using the helm install or upgrade commands. For more information about configuring Helm, refer to [Values Files](https://helm.sh/docs/chart_template_guide/values_files/). + +### Download the values.yaml file + +In order to make any configuration changes, download the `values.yaml` file from the Grafana Helm Charts repository: + +https://github.com/grafana/helm-charts/edit/main/charts/grafana/values.yaml + +{{% admonition type="note" %}} +Depending on your use case requirements, you can use a single YAML file that contains your configuration changes or you can create multiple YAML files. +{{% /admonition %}} + +### Enable persistent storage **(recommended)** + +By default, persistent storage is disabled, which means that Grafana uses ephemeral storage, and all data will be stored within the container's file system. This data will be lost if the container is stopped, restarted, or if the container crashes. + +It is highly recommended that you enable persistent storage in Grafana Helm charts if you want to ensure that your data persists and is not lost in case of container restarts or failures. + +Enabling persistent storage in Grafana Helm charts ensures a reliable solution for running Grafana in production environments. + +To enable the persistent storage in the Grafana Helm charts, complete the following steps: + +1. Open the `values.yaml` file in your favorite editor. + +1. Edit the values and under the section of `persistence`, change the `enable` flag from `false` to `true` + + ```yaml + ....... + ............ + ...... + persistence: + type: pvc + enabled: true + # storageClassName: default + ....... + ............ + ...... + ``` + +1. Run the following `helm upgrade` command by specifying the `values.yaml` file to make the changes take effect: + + ```bash + helm upgrade my-grafana grafana/grafana -f values.yaml -n monitoring + ``` + +The PVC will now store all your data such as dashboards, data sources, and so on. + +### Install plugins (e.g. Zabbix app, Clock panel, etc.) + +You can install plugins in Grafana from the official and community [plugins page](https://grafana.com/grafana/plugins). These plugins allow you to add new visualization types, data sources, and applications to help you better visualize your data. + +Grafana currently supports three types of plugins: panel, data source, and app. For more information on managing plugins, refer to [Plugin Management](https://grafana.com/docs/grafana/latest/administration/plugin-management/). + +To install plugins in the Grafana Helm Charts, complete the following steps: + +1. Open the `values.yaml` file in your favorite editor. + +1. Find the line that says `plugins:` and under that section, define the plugins that you want to install. + + ```yaml + ....... + ............ + ...... + plugins: + # here we are installing two plugins, make sure to keep the indentation correct as written here. + + - alexanderzobnin-zabbix-app + - grafana-clock-panel + ....... + ............ + ...... + ``` + +1. Save the changes and use the `helm upgrade` command to get these plugins installed: + + ```bash + helm upgrade my-grafana grafana/grafana -f values.yaml -n monitoring + ``` + +1. Navigate to `127.0.0.1:3000` in your browser. + +1. Login with admin credentials when the Grafana sign-in page appears. + +1. Navigate to UI -> Administration -> Plugins + +1. Search for the above plugins and they should be marked as installed. + +## Troubleshooting + +This section includes troubleshooting tips you might find helpful when deploying Grafana on Kubernetes via Helm. + +### Collect logs + +It is important to view the Grafana server logs while troubleshooting any issues. + +To check the Grafana logs, run the following command: + +```bash +# dump Pod logs for a Deployment (single-container case) + +kubectl logs --namespace=monitoring deploy/my-grafana +``` + +If you have multiple containers running in the deployment, run the following command to obtain the logs only for the Grafana deployment: + +```bash +# dump Pod logs for a Deployment (multi-container case) + +kubectl logs --namespace=monitoring deploy/grafana -c my-grafana +``` + +For more information about accessing Kubernetes application logs, refer to [Pods](https://kubernetes.io/docs/reference/kubectl/cheatsheet/#interacting-with-running-pods) and [Deployments](https://kubernetes.io/docs/reference/kubectl/cheatsheet/#interacting-with-deployments-and-services). + +### Increase log levels + +By default, the Grafana log level is set to `info`, but you can increase it to `debug` mode to fetch information needed to diagnose and troubleshoot a problem. For more information about Grafana log levels, refer to [Configuring logs](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana#log). + +To increase log level to `debug` mode, use the following steps: + +1. Open the `values.yaml` file in your favorite editor and search for the string `grafana.ini` and there you will find a section about log mode. + +1. Add level: `debug` just below the line `mode: console` + + ```yaml + # This is the values.yaml file + ..... + ....... + .... + grafana.ini: + paths: + data: /var/lib/grafana/ + ..... + ....... + .... + mode: console + level: debug + ``` + + Make sure to keep the indentation level the same otherwise it will not work. + +1. Now to apply this, run the `helm upgrade` command as follows: + + ```bash + helm upgrade my-grafana grafana/grafana -f values.yaml -n monitoring + ``` + +1. To verify it, access the Grafana UI in the browser using the provided `IP:Port`. The Grafana sign-in page appears. + +1. To sign in to Grafana, enter `admin` for the username and paste the password which was decoded earlier. Navigate to Server Admin > Settings and then search for log. You should see the level to `debug` mode. + +### Reset Grafana admin secrets (login credentials) + +By default the login credentials for the super admin account are generated via `secrets`. However, this can be changed easily. To achieve this, use the following steps: + +1. Edit the `values.yaml` file and search for the string `adminPassword`. There you can define a new password: + + ```yaml + # Administrator credentials when not using an existing secret (see below) + adminUser: admin + adminPassword: admin + ``` + +1. Then use the `helm upgrade` command as follows: + + ```bash + helm upgrade my-grafana grafana/grafana -f values.yaml -n monitoring + ``` + + This command will now make your super admin login credentials as `admin` for both username and password. + +1. To verify it, sign in to Grafana, enter `admin` for both username and password. You should be able to login as super admin. + +## Uninstall the Grafana deployment + +To uninstall the Grafana deployment, run the command: + +`helm uninstall <RELEASE-NAME> <NAMESPACE-NAME>` + +```bash +helm uninstall my-grafana -n monitoring +``` + +This deletes all of the objects from the given namespace monitoring. + +If you want to delete the namespace `monitoring`, then run the command: + +```bash +kubectl delete namespace monitoring +``` diff --git a/docs/sources/setup-grafana/installation/kubernetes/index.md b/docs/sources/setup-grafana/installation/kubernetes/index.md index b9a0231fdb0a4..5db13ecea67d3 100644 --- a/docs/sources/setup-grafana/installation/kubernetes/index.md +++ b/docs/sources/setup-grafana/installation/kubernetes/index.md @@ -15,7 +15,7 @@ weight: 500 On this page, you will find instructions for installing and running Grafana on Kubernetes using Kubernetes manifests for the setup. If Helm is your preferred option, refer to [Grafana Helm community charts](https://github.com/grafana/helm-charts). -Watch this video to learn more about installing Grafana on Kubernetes: {{< vimeo 871940219 >}} +Watch this video to learn more about installing Grafana on Kubernetes: {{< youtube id="DEv5wtZxNCk" start="1872">}} ## Before you begin diff --git a/docs/sources/setup-grafana/installation/mac/index.md b/docs/sources/setup-grafana/installation/mac/index.md index dbb775f2a79a0..b00e49c6f8e93 100644 --- a/docs/sources/setup-grafana/installation/mac/index.md +++ b/docs/sources/setup-grafana/installation/mac/index.md @@ -61,6 +61,10 @@ To install Grafana on macOS using the standalone binaries, complete the followin ./bin/grafana server ``` +Alternatively, watch the Grafana for Beginners video below: + +{{< youtube id="T51Qa7eE3W8" >}} + ## Next steps - [Start the Grafana server]({{< relref "../../start-restart-grafana" >}}) diff --git a/docs/sources/setup-grafana/set-up-for-high-availability.md b/docs/sources/setup-grafana/set-up-for-high-availability.md index 9d9b184d3add8..b36b64f1dd840 100644 --- a/docs/sources/setup-grafana/set-up-for-high-availability.md +++ b/docs/sources/setup-grafana/set-up-for-high-availability.md @@ -41,11 +41,9 @@ Once you have a Postgres or MySQL database available, you can configure your mul ## Alerting high availability -Grafana Alerting provides a [high availability mode]({{< relref "../alerting/fundamentals/high-availability" >}}). +Grafana Alerting provides a high availability mode. It preserves the semantics of legacy dashboard alerting by executing all alerts on every server and by sending notifications only once per alert. Load distribution between servers is not supported at this time. -It preserves the semantics of legacy dashboard alerting by executing all alerts on every server and by sending notifications only once per alert. Load distribution between servers is not supported at this time. - -For instructions on setting up alerting high availability, refer to [Enable alerting high availability]({{< relref "../alerting/set-up/configure-high-availability" >}}). +For further information and instructions on setting up alerting high availability, refer to [Enable alerting high availability]({{< relref "../alerting/set-up/configure-high-availability" >}}). **Legacy dashboard alerts** diff --git a/docs/sources/shared/alerts/alerting_provisioning.md b/docs/sources/shared/alerts/alerting_provisioning.md new file mode 100644 index 0000000000000..cfaca0ffcb891 --- /dev/null +++ b/docs/sources/shared/alerts/alerting_provisioning.md @@ -0,0 +1,1709 @@ +--- +labels: + products: + - enterprise + - oss +title: 'Alerting Provisioning HTTP API ' +--- + +The Alerting provisioning API can be used to create, modify, and delete resources relevant to [Grafana Managed alerts]({{< relref "/docs/grafana/latest/alerting/alerting-rules/create-grafana-managed-rule" >}}). And is the one used by our [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs). + +For managing resources related to [data source-managed alerts]({{< relref "/docs/grafana/latest/alerting/alerting-rules/create-grafana-managed-rule" >}}) including Recording Rules, you can use [Mimir tool](https://grafana.com/docs/mimir/latest/manage/tools/mimirtool/) and [Cortex tool](https://github.com/grafana/cortex-tools#cortextool) respectively. + +## Information + +### Version + +1.1.0 + +## Content negotiation + +### Consumes + +- application/json + +### Produces + +- application/json +- text/yaml +- application/yaml + +## All endpoints + +### Alert rules + +| Method | URI | Name | Summary | +| ------ | ---------------------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------- | +| DELETE | /api/v1/provisioning/alert-rules/:uid | [route delete alert rule](#route-delete-alert-rule) | Delete a specific alert rule by UID. | +| GET | /api/v1/provisioning/alert-rules/:uid | [route get alert rule](#route-get-alert-rule) | Get a specific alert rule by UID. | +| POST | /api/v1/provisioning/alert-rules | [route post alert rule](#route-post-alert-rule) | Create a new alert rule. | +| PUT | /api/v1/provisioning/alert-rules/:uid | [route put alert rule](#route-put-alert-rule) | Update an existing alert rule. | +| GET | /api/v1/provisioning/alert-rules/:uid/export | [route get alert rule export](#route-get-alert-rule-export) | Export an alert rule in provisioning file format. | +| GET | /api/v1/provisioning/folder/:folderUid/rule-groups/:group | [route get alert rule group](#route-get-alert-rule-group) | Get a rule group. | +| PUT | /api/v1/provisioning/folder/:folderUid/rule-groups/:group | [route put alert rule group](#route-put-alert-rule-group) | Update the interval of a rule group or modify the rules of the group. | +| GET | /api/v1/provisioning/folder/:folderUid/rule-groups/:group/export | [route get alert rule group export](#route-get-alert-rule-group-export) | Export an alert rule group in provisioning file format. | +| GET | /api/v1/provisioning/alert-rules | [route get alert rules](#route-get-alert-rules) | Get all the alert rules. | +| GET | /api/v1/provisioning/alert-rules/export | [route get alert rules export](#route-get-alert-rules-export) | Export all alert rules in provisioning file format. | + +#### Example alert rules template + +```json +{ + "title": "TEST-API_1", + "ruleGroup": "API", + "folderUID": "FOLDER", + "noDataState": "OK", + "execErrState": "OK", + "for": "5m", + "orgId": 1, + "uid": "", + "condition": "B", + "annotations": { + "summary": "test_api_1" + }, + "labels": { + "API": "test1" + }, + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": " XXXXXXXXX-XXXXXXXXX-XXXXXXXXXX", + "model": { + "expr": "up", + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "A" + } + }, + { + "refId": "B", + "queryType": "", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "-100", + "model": { + "conditions": [ + { + "evaluator": { + "params": [6], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": ["A"] + }, + "reducer": { + "params": [], + "type": "last" + }, + "type": "query" + } + ], + "datasource": { + "type": "__expr__", + "uid": "-100" + }, + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "B", + "type": "classic_conditions" + } + } + ] +} +``` + +### Contact points + +| Method | URI | Name | Summary | +| ------ | ------------------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------ | +| DELETE | /api/v1/provisioning/contact-points/:uid | [route delete contactpoints](#route-delete-contactpoints) | Delete a contact point. | +| GET | /api/v1/provisioning/contact-points | [route get contactpoints](#route-get-contactpoints) | Get all the contact points. | +| POST | /api/v1/provisioning/contact-points | [route post contactpoints](#route-post-contactpoints) | Create a contact point. | +| PUT | /api/v1/provisioning/contact-points/:uid | [route put contactpoint](#route-put-contactpoint) | Update an existing contact point. | +| GET | /api/v1/provisioning/contact-points/export | [route get contactpoints export](#route-get-contactpoints-export) | Export all contact points in provisioning file format. | + +### Notification policies + +| Method | URI | Name | Summary | +| ------ | ------------------------------------ | ------------------------------------------------------------- | ---------------------------------------------------------------- | +| DELETE | /api/v1/provisioning/policies | [route reset policy tree](#route-reset-policy-tree) | Clears the notification policy tree. | +| GET | /api/v1/provisioning/policies | [route get policy tree](#route-get-policy-tree) | Get the notification policy tree. | +| PUT | /api/v1/provisioning/policies | [route put policy tree](#route-put-policy-tree) | Sets the notification policy tree. | +| GET | /api/v1/provisioning/policies/export | [route get policy tree export](#route-get-policy-tree-export) | Export the notification policy tree in provisioning file format. | + +### Mute timings + +| Method | URI | Name | Summary | +| ------ | ---------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------- | +| DELETE | /api/v1/provisioning/mute-timings/:name | [route delete mute timing](#route-delete-mute-timing) | Delete a mute timing. | +| GET | /api/v1/provisioning/mute-timings/:name | [route get mute timing](#route-get-mute-timing) | Get a mute timing. | +| GET | /api/v1/provisioning/mute-timings | [route get mute timings](#route-get-mute-timings) | Get all the mute timings. | +| POST | /api/v1/provisioning/mute-timings | [route post mute timing](#route-post-mute-timing) | Create a new mute timing. | +| PUT | /api/v1/provisioning/mute-timings/:name | [route put mute timing](#route-put-mute-timing) | Replace an existing mute timing. | +| GET | /api/v1/provisioning/mute-timings/export | [route get mute timings export](#route-get-mute-timings-export) | Export all mute timings in provisioning file format. | +| GET | /api/v1/provisioning/mute-timings/:name/export | [route get mute timing export](#route-get-mute-timing-export) | Export a mute timing in provisioning file format. | + +### Templates + +| Method | URI | Name | Summary | +| ------ | ------------------------------------ | ----------------------------------------------- | ----------------------------------------- | +| DELETE | /api/v1/provisioning/templates/:name | [route delete template](#route-delete-template) | Delete a template. | +| GET | /api/v1/provisioning/templates/:name | [route get template](#route-get-template) | Get a notification template. | +| GET | /api/v1/provisioning/templates | [route get templates](#route-get-templates) | Get all notification templates. | +| PUT | /api/v1/provisioning/templates/:name | [route put template](#route-put-template) | Create or update a notification template. | + +## Edit resources in the Grafana UI + +By default, you cannot edit API-provisioned alerting resources in Grafana. To enable editing these resources in the Grafana UI, add the `X-Disable-Provenance` header to the following requests in the API: + +- `POST /api/v1/provisioning/alert-rules` +- `PUT /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}` (calling this endpoint will change provenance for all alert rules within the alert group) +- `POST /api/v1/provisioning/contact-points` +- `POST /api/v1/provisioning/mute-timings` +- `PUT /api/v1/provisioning/policies` +- `PUT /api/v1/provisioning/templates/{name}` + +To reset the notification policy tree to the default and unlock it for editing in the Grafana UI, use the `DELETE /api/v1/provisioning/policies` endpoint. + +## Paths + +### <span id="route-delete-alert-rule"></span> Delete a specific alert rule by UID. (_RouteDeleteAlertRule_) + +``` +DELETE /api/v1/provisioning/alert-rules/:uid +``` + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | ------ | -------- | --------- | :------: | ------- | --------------------------------------------------------- | +| UID | `path` | string | `string` | | ✓ | | Alert rule UID | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ----------------------------------- | ---------- | ---------------------------------------- | :---------: | --------------------------------------------- | +| [204](#route-delete-alert-rule-204) | No Content | The alert rule was deleted successfully. | | [schema](#route-delete-alert-rule-204-schema) | + +#### Responses + +##### <span id="route-delete-alert-rule-204"></span> 204 - The alert rule was deleted successfully. + +Status: No Content + +###### <span id="route-delete-alert-rule-204-schema"></span> Schema + +### <span id="route-delete-contactpoints"></span> Delete a contact point. (_RouteDeleteContactpoints_) + +``` +DELETE /api/v1/provisioning/contact-points/:uid +``` + +#### Consumes + +- application/json + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ------------------------------------------ | +| UID | `path` | string | `string` | | ✓ | | UID is the contact point unique identifier | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| -------------------------------------- | ---------- | ------------------------------------------- | :---------: | ------------------------------------------------ | +| [204](#route-delete-contactpoints-204) | No Content | The contact point was deleted successfully. | | [schema](#route-delete-contactpoints-204-schema) | + +#### Responses + +##### <span id="route-delete-contactpoints-204"></span> 204 - The contact point was deleted successfully. + +Status: No Content + +###### <span id="route-delete-contactpoints-204-schema"></span> Schema + +### <span id="route-delete-mute-timing"></span> Delete a mute timing. (_RouteDeleteMuteTiming_) + +``` +DELETE /api/v1/provisioning/mute-timings/:name +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ---------------- | +| name | `path` | string | `string` | | ✓ | | Mute timing name | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ------------------------------------ | ---------- | ----------------------------------------- | :---------: | ---------------------------------------------- | +| [204](#route-delete-mute-timing-204) | No Content | The mute timing was deleted successfully. | | [schema](#route-delete-mute-timing-204-schema) | + +#### Responses + +##### <span id="route-delete-mute-timing-204"></span> 204 - The mute timing was deleted successfully. + +Status: No Content + +###### <span id="route-delete-mute-timing-204-schema"></span> Schema + +### <span id="route-delete-template"></span> Delete a template. (_RouteDeleteTemplate_) + +``` +DELETE /api/v1/provisioning/templates/:name +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ------------- | +| name | `path` | string | `string` | | ✓ | | Template Name | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | ---------- | -------------------------------------- | :---------: | ------------------------------------------- | +| [204](#route-delete-template-204) | No Content | The template was deleted successfully. | | [schema](#route-delete-template-204-schema) | + +#### Responses + +##### <span id="route-delete-template-204"></span> 204 - The template was deleted successfully. + +Status: No Content + +###### <span id="route-delete-template-204-schema"></span> Schema + +### <span id="route-get-alert-rule"></span> Get a specific alert rule by UID. (_RouteGetAlertRule_) + +``` +GET /api/v1/provisioning/alert-rules/:uid +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | -------------- | +| UID | `path` | string | `string` | | ✓ | | Alert rule UID | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| -------------------------------- | --------- | -------------------- | :---------: | ------------------------------------------ | +| [200](#route-get-alert-rule-200) | OK | ProvisionedAlertRule | | [schema](#route-get-alert-rule-200-schema) | +| [404](#route-get-alert-rule-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-404-schema) | + +#### Responses + +##### <span id="route-get-alert-rule-200"></span> 200 - ProvisionedAlertRule + +Status: OK + +###### <span id="route-get-alert-rule-200-schema"></span> Schema + +[ProvisionedAlertRule](#provisioned-alert-rule) + +##### <span id="route-get-alert-rule-404"></span> 404 - Not found. + +Status: Not Found + +###### <span id="route-get-alert-rule-404-schema"></span> Schema + +### <span id="route-get-alert-rule-export"></span> Export an alert rule in provisioning file format. (_RouteGetAlertRuleExport_) + +``` +GET /api/v1/provisioning/alert-rules/:uid/export +``` + +#### Produces + +- application/json +- application/yaml +- text/yaml + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | +| UID | `path` | string | `string` | | ✓ | | Alert rule UID | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | +| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------------- | --------- | ------------------ | :---------: | ------------------------------------------------- | +| [200](#route-get-alert-rule-export-200) | OK | AlertingFileExport | | [schema](#route-get-alert-rule-export-200-schema) | +| [404](#route-get-alert-rule-export-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-export-404-schema) | + +#### Responses + +##### <span id="route-get-alert-rule-export-200"></span> 200 - AlertingFileExport + +Status: OK + +###### <span id="route-get-alert-rule-export-200-schema"></span> Schema + +[AlertingFileExport](#alerting-file-export) + +##### <span id="route-get-alert-rule-export-404"></span> 404 - Not found. + +Status: Not Found + +###### <span id="route-get-alert-rule-export-404-schema"></span> Schema + +### <span id="route-get-alert-rule-group"></span> Get a rule group. (_RouteGetAlertRuleGroup_) + +``` +GET /api/v1/provisioning/folder/:folderUid/rule-groups/:group +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| --------- | ------ | ------ | -------- | --------- | :------: | ------- | ----------- | +| FolderUID | `path` | string | `string` | | ✓ | | | +| Group | `path` | string | `string` | | ✓ | | | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| -------------------------------------- | --------- | -------------- | :---------: | ------------------------------------------------ | +| [200](#route-get-alert-rule-group-200) | OK | AlertRuleGroup | | [schema](#route-get-alert-rule-group-200-schema) | +| [404](#route-get-alert-rule-group-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-group-404-schema) | + +#### Responses + +##### <span id="route-get-alert-rule-group-200"></span> 200 - AlertRuleGroup + +Status: OK + +###### <span id="route-get-alert-rule-group-200-schema"></span> Schema + +[AlertRuleGroup](#alert-rule-group) + +##### <span id="route-get-alert-rule-group-404"></span> 404 - Not found. + +Status: Not Found + +###### <span id="route-get-alert-rule-group-404-schema"></span> Schema + +### <span id="route-get-alert-rule-group-export"></span> Export an alert rule group in provisioning file format. (_RouteGetAlertRuleGroupExport_) + +``` +GET /api/v1/provisioning/folder/:folderUid/rule-groups/:group/export +``` + +#### Produces + +- application/json +- application/yaml +- text/yaml + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| --------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | +| FolderUID | `path` | string | `string` | | ✓ | | | +| Group | `path` | string | `string` | | ✓ | | | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | +| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------------------- | --------- | ------------------ | :---------: | ------------------------------------------------------- | +| [200](#route-get-alert-rule-group-export-200) | OK | AlertingFileExport | | [schema](#route-get-alert-rule-group-export-200-schema) | +| [404](#route-get-alert-rule-group-export-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-group-export-404-schema) | + +#### Responses + +##### <span id="route-get-alert-rule-group-export-200"></span> 200 - AlertingFileExport + +Status: OK + +###### <span id="route-get-alert-rule-group-export-200-schema"></span> Schema + +[AlertingFileExport](#alerting-file-export) + +##### <span id="route-get-alert-rule-group-export-404"></span> 404 - Not found. + +Status: Not Found + +###### <span id="route-get-alert-rule-group-export-404-schema"></span> Schema + +### <span id="route-get-alert-rules"></span> Get all the alert rules. (_RouteGetAlertRules_) + +``` +GET /api/v1/provisioning/alert-rules +``` + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | ------ | --------------------- | :---------: | ------------------------------------------- | +| [200](#route-get-alert-rules-200) | OK | ProvisionedAlertRules | | [schema](#route-get-alert-rules-200-schema) | + +#### Responses + +##### <span id="route-get-alert-rules-200"></span> 200 - ProvisionedAlertRules + +Status: OK + +###### <span id="route-get-alert-rules-200-schema"></span> Schema + +[ProvisionedAlertRules](#provisioned-alert-rules) + +### <span id="route-get-alert-rules-export"></span> Export all alert rules in provisioning file format. (_RouteGetAlertRulesExport_) + +``` +GET /api/v1/provisioning/alert-rules/export +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | +| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ---------------------------------------- | --------- | ------------------ | :---------: | -------------------------------------------------- | +| [200](#route-get-alert-rules-export-200) | OK | AlertingFileExport | | [schema](#route-get-alert-rules-export-200-schema) | +| [404](#route-get-alert-rules-export-404) | Not Found | Not found. | | [schema](#route-get-alert-rules-export-404-schema) | + +#### Responses + +##### <span id="route-get-alert-rules-export-200"></span> 200 - AlertingFileExport + +Status: OK + +###### <span id="route-get-alert-rules-export-200-schema"></span> Schema + +[AlertingFileExport](#alerting-file-export) + +##### <span id="route-get-alert-rules-export-404"></span> 404 - Not found. + +Status: Not Found + +###### <span id="route-get-alert-rules-export-404-schema"></span> Schema + +### <span id="route-get-contactpoints"></span> Get all the contact points. (_RouteGetContactpoints_) + +``` +GET /api/v1/provisioning/contact-points +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------- | ------ | -------- | --------- | :------: | ------- | -------------- | +| name | `query` | string | `string` | | | | Filter by name | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ----------------------------------- | ------ | ------------- | :---------: | --------------------------------------------- | +| [200](#route-get-contactpoints-200) | OK | ContactPoints | | [schema](#route-get-contactpoints-200-schema) | + +#### Responses + +##### <span id="route-get-contactpoints-200"></span> 200 - ContactPoints + +Status: OK + +###### <span id="route-get-contactpoints-200-schema"></span> Schema + +[ContactPoints](#contact-points) + +### <span id="route-get-contactpoints-export"></span> Export all contact points in provisioning file format. (_RouteGetContactpointsExport_) + +``` +GET /api/v1/provisioning/contact-points/export +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------- | ------- | ------- | -------- | --------- | :------: | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| decrypt | `query` | boolean | `bool` | | | | Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings. | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | +| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | +| name | `query` | string | `string` | | | | Filter by name | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ------------------------------------------ | --------- | ------------------ | :---------: | ---------------------------------------------------- | +| [200](#route-get-contactpoints-export-200) | OK | AlertingFileExport | | [schema](#route-get-contactpoints-export-200-schema) | +| [403](#route-get-contactpoints-export-403) | Forbidden | PermissionDenied | | [schema](#route-get-contactpoints-export-403-schema) | + +#### Responses + +##### <span id="route-get-contactpoints-export-200"></span> 200 - AlertingFileExport + +Status: OK + +###### <span id="route-get-contactpoints-export-200-schema"></span> Schema + +[AlertingFileExport](#alerting-file-export) + +##### <span id="route-get-contactpoints-export-403"></span> 403 - PermissionDenied + +Status: Forbidden + +###### <span id="route-get-contactpoints-export-403-schema"></span> Schema + +[PermissionDenied](#permission-denied) + +### <span id="route-get-mute-timing"></span> Get a mute timing. (_RouteGetMuteTiming_) + +``` +GET /api/v1/provisioning/mute-timings/:name +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ---------------- | +| name | `path` | string | `string` | | ✓ | | Mute timing name | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | --------- | ---------------- | :---------: | ------------------------------------------- | +| [200](#route-get-mute-timing-200) | OK | MuteTimeInterval | | [schema](#route-get-mute-timing-200-schema) | +| [404](#route-get-mute-timing-404) | Not Found | Not found. | | [schema](#route-get-mute-timing-404-schema) | + +#### Responses + +##### <span id="route-get-mute-timing-200"></span> 200 - MuteTimeInterval + +Status: OK + +###### <span id="route-get-mute-timing-200-schema"></span> Schema + +[MuteTimeInterval](#mute-time-interval) + +##### <span id="route-get-mute-timing-404"></span> 404 - Not found. + +Status: Not Found + +###### <span id="route-get-mute-timing-404-schema"></span> Schema + +### <span id="route-get-mute-timings"></span> Get all the mute timings. (_RouteGetMuteTimings_) + +``` +GET /api/v1/provisioning/mute-timings +``` + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ---------------------------------- | ------ | ----------- | :---------: | -------------------------------------------- | +| [200](#route-get-mute-timings-200) | OK | MuteTimings | | [schema](#route-get-mute-timings-200-schema) | + +#### Responses + +##### <span id="route-get-mute-timings-200"></span> 200 - MuteTimings + +Status: OK + +###### <span id="route-get-mute-timings-200-schema"></span> Schema + +[MuteTimings](#mute-timings) + +### <span id="route-get-mute-timings-export"></span> Export all mute timings in provisioning file format. (_RouteGetMuteTimingsExport_) + +``` +GET /api/v1/provisioning/mute-timings/export +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | +| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ----------------------------------------- | --------- | ----------------- | :---------: | --------------------------------------------------- | +| [200](#route-get-mute-timings-export-200) | OK | MuteTimingsExport | | [schema](#route-get-mute-timings-export-200-schema) | +| [403](#route-get-mute-timings-export-403) | Forbidden | PermissionDenied | | [schema](#route-get-mute-timings-export-403-schema) | + +#### Responses + +##### <span id="route-get-mute-timings-export-200"></span> 200 - MuteTimingsExport + +Status: OK + +###### <span id="route-get-mute-timings-export-200-schema"></span> Schema + +[AlertingFileExport](#alerting-file-export) + +##### <span id="route-get-mute-timings-export-403"></span> 403 - PermissionDenied + +Status: Forbidden + +###### <span id="route-get-mute-timings-export-403-schema"></span> Schema + +[PermissionDenied](#permission-denied) + +### <span id="route-get-mute-timing-export"></span> Export a mute timing in provisioning file format. (_RouteGetMuteTimingExport_) + +``` +GET /api/v1/provisioning/mute-timings/:name/export +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | +| name | `path` | string | `string` | | ✓ | | Mute timing name. | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | +| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ---------------------------------------- | --------- | ---------------- | :---------: | --------------------------------------------------- | +| [200](#route-get-mute-timing-export-200) | OK | MuteTimingExport | | [schema](#route-get-mute-timings-export-200-schema) | +| [403](#route-get-mute-timing-export-403) | Forbidden | PermissionDenied | | [schema](#route-get-mute-timings-export-403-schema) | + +#### Responses + +##### <span id="route-get-mute-timing-export-200"></span> 200 - MuteTimingExport + +Status: OK + +###### <span id="route-get-mute-timing-export-200-schema"></span> Schema + +[AlertingFileExport](#alerting-file-export) + +##### <span id="route-get-mute-timing-export-403"></span> 403 - PermissionDenied + +Status: Forbidden + +###### <span id="route-get-mute-timing-export-403-schema"></span> Schema + +[PermissionDenied](#permission-denied) + +### <span id="route-get-policy-tree"></span> Get the notification policy tree. (_RouteGetPolicyTree_) + +``` +GET /api/v1/provisioning/policies +``` + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | ------ | ----------- | :---------: | ------------------------------------------- | +| [200](#route-get-policy-tree-200) | OK | Route | | [schema](#route-get-policy-tree-200-schema) | + +#### Responses + +##### <span id="route-get-policy-tree-200"></span> 200 - Route + +Status: OK + +###### <span id="route-get-policy-tree-200-schema"></span> Schema + +[Route](#route) + +### <span id="route-get-policy-tree-export"></span> Export the notification policy tree in provisioning file format. (_RouteGetPolicyTreeExport_) + +``` +GET /api/v1/provisioning/policies/export +``` + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ---------------------------------------- | --------- | ------------------ | :---------: | -------------------------------------------------- | +| [200](#route-get-policy-tree-export-200) | OK | AlertingFileExport | | [schema](#route-get-policy-tree-export-200-schema) | +| [404](#route-get-policy-tree-export-404) | Not Found | NotFound | | [schema](#route-get-policy-tree-export-404-schema) | + +#### Responses + +##### <span id="route-get-policy-tree-export-200"></span> 200 - AlertingFileExport + +Status: OK + +###### <span id="route-get-policy-tree-export-200-schema"></span> Schema + +[AlertingFileExport](#alerting-file-export) + +##### <span id="route-get-policy-tree-export-404"></span> 404 - NotFound + +Status: Not Found + +###### <span id="route-get-policy-tree-export-404-schema"></span> Schema + +[NotFound](#not-found) + +### <span id="route-get-template"></span> Get a notification template. (_RouteGetTemplate_) + +``` +GET /api/v1/provisioning/templates/:name +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ------------- | +| name | `path` | string | `string` | | ✓ | | Template Name | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ------------------------------ | --------- | -------------------- | :---------: | ---------------------------------------- | +| [200](#route-get-template-200) | OK | NotificationTemplate | | [schema](#route-get-template-200-schema) | +| [404](#route-get-template-404) | Not Found | Not found. | | [schema](#route-get-template-404-schema) | + +#### Responses + +##### <span id="route-get-template-200"></span> 200 - NotificationTemplate + +Status: OK + +###### <span id="route-get-template-200-schema"></span> Schema + +[NotificationTemplate](#notification-template) + +##### <span id="route-get-template-404"></span> 404 - Not found. + +Status: Not Found + +###### <span id="route-get-template-404-schema"></span> Schema + +### <span id="route-get-templates"></span> Get all notification templates. (_RouteGetTemplates_) + +``` +GET /api/v1/provisioning/templates +``` + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ------------------------------- | --------- | --------------------- | :---------: | ----------------------------------------- | +| [200](#route-get-templates-200) | OK | NotificationTemplates | | [schema](#route-get-templates-200-schema) | +| [404](#route-get-templates-404) | Not Found | Not found. | | [schema](#route-get-templates-404-schema) | + +#### Responses + +##### <span id="route-get-templates-200"></span> 200 - NotificationTemplates + +Status: OK + +###### <span id="route-get-templates-200-schema"></span> Schema + +[NotificationTemplates](#notification-templates) + +##### <span id="route-get-templates-404"></span> 404 - Not found. + +Status: Not Found + +###### <span id="route-get-templates-404-schema"></span> Schema + +### <span id="route-post-alert-rule"></span> Create a new alert rule. (_RoutePostAlertRule_) + +``` +POST /api/v1/provisioning/alert-rules +``` + +#### Consumes + +- application/json + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [ProvisionedAlertRule](#provisioned-alert-rule) | `models.ProvisionedAlertRule` | | | | | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | ----------- | -------------------- | :---------: | ------------------------------------------- | +| [201](#route-post-alert-rule-201) | Created | ProvisionedAlertRule | | [schema](#route-post-alert-rule-201-schema) | +| [400](#route-post-alert-rule-400) | Bad Request | ValidationError | | [schema](#route-post-alert-rule-400-schema) | + +#### Responses + +##### <span id="route-post-alert-rule-201"></span> 201 - ProvisionedAlertRule + +Status: Created + +###### <span id="route-post-alert-rule-201-schema"></span> Schema + +[ProvisionedAlertRule](#provisioned-alert-rule) + +##### <span id="route-post-alert-rule-400"></span> 400 - ValidationError + +Status: Bad Request + +###### <span id="route-post-alert-rule-400-schema"></span> Schema + +[ValidationError](#validation-error) + +### <span id="route-post-contactpoints"></span> Create a contact point. (_RoutePostContactpoints_) + +``` +POST /api/v1/provisioning/contact-points +``` + +#### Consumes + +- application/json + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [EmbeddedContactPoint](#embedded-contact-point) | `models.EmbeddedContactPoint` | | | | | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ------------------------------------ | ----------- | -------------------- | :---------: | ---------------------------------------------- | +| [202](#route-post-contactpoints-202) | Accepted | EmbeddedContactPoint | | [schema](#route-post-contactpoints-202-schema) | +| [400](#route-post-contactpoints-400) | Bad Request | ValidationError | | [schema](#route-post-contactpoints-400-schema) | + +#### Responses + +##### <span id="route-post-contactpoints-202"></span> 202 - EmbeddedContactPoint + +Status: Accepted + +###### <span id="route-post-contactpoints-202-schema"></span> Schema + +[EmbeddedContactPoint](#embedded-contact-point) + +##### <span id="route-post-contactpoints-400"></span> 400 - ValidationError + +Status: Bad Request + +###### <span id="route-post-contactpoints-400-schema"></span> Schema + +[ValidationError](#validation-error) + +### <span id="route-post-mute-timing"></span> Create a new mute timing. (_RoutePostMuteTiming_) + +``` +POST /api/v1/provisioning/mute-timings +``` + +#### Consumes + +- application/json + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | --------------------------------------- | ------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [MuteTimeInterval](#mute-time-interval) | `models.MuteTimeInterval` | | | | | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ---------------------------------- | ----------- | ---------------- | :---------: | -------------------------------------------- | +| [201](#route-post-mute-timing-201) | Created | MuteTimeInterval | | [schema](#route-post-mute-timing-201-schema) | +| [400](#route-post-mute-timing-400) | Bad Request | ValidationError | | [schema](#route-post-mute-timing-400-schema) | + +#### Responses + +##### <span id="route-post-mute-timing-201"></span> 201 - MuteTimeInterval + +Status: Created + +###### <span id="route-post-mute-timing-201-schema"></span> Schema + +[MuteTimeInterval](#mute-time-interval) + +##### <span id="route-post-mute-timing-400"></span> 400 - ValidationError + +Status: Bad Request + +###### <span id="route-post-mute-timing-400-schema"></span> Schema + +[ValidationError](#validation-error) + +### <span id="route-put-alert-rule"></span> Update an existing alert rule. (_RoutePutAlertRule_) + +``` +PUT /api/v1/provisioning/alert-rules/:uid +``` + +#### Consumes + +- application/json + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | +| UID | `path` | string | `string` | | ✓ | | Alert rule UID | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [ProvisionedAlertRule](#provisioned-alert-rule) | `models.ProvisionedAlertRule` | | | | | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| -------------------------------- | ----------- | -------------------- | :---------: | ------------------------------------------ | +| [200](#route-put-alert-rule-200) | OK | ProvisionedAlertRule | | [schema](#route-put-alert-rule-200-schema) | +| [400](#route-put-alert-rule-400) | Bad Request | ValidationError | | [schema](#route-put-alert-rule-400-schema) | + +#### Responses + +##### <span id="route-put-alert-rule-200"></span> 200 - ProvisionedAlertRule + +Status: OK + +###### <span id="route-put-alert-rule-200-schema"></span> Schema + +[ProvisionedAlertRule](#provisioned-alert-rule) + +##### <span id="route-put-alert-rule-400"></span> 400 - ValidationError + +Status: Bad Request + +###### <span id="route-put-alert-rule-400-schema"></span> Schema + +[ValidationError](#validation-error) + +### <span id="route-put-alert-rule-group"></span> Update the interval or alert rules of a rule group. (_RoutePutAlertRuleGroup_) + +``` +PUT /api/v1/provisioning/folder/:folderUid/rule-groups/:group +``` + +#### Consumes + +- application/json + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | ----------------------------------- | ----------------------- | --------- | :------: | ------- | ------------------------------------------------------------------------------------------------------- | +| FolderUID | `path` | string | `string` | | ✓ | | | +| Group | `path` | string | `string` | | ✓ | | | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [AlertRuleGroup](#alert-rule-group) | `models.AlertRuleGroup` | | | | This action is idempotent and rules included in this body will overwrite configured rules for the group | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| -------------------------------------- | ----------- | --------------- | :---------: | ------------------------------------------------ | +| [200](#route-put-alert-rule-group-200) | OK | AlertRuleGroup | | [schema](#route-put-alert-rule-group-200-schema) | +| [400](#route-put-alert-rule-group-400) | Bad Request | ValidationError | | [schema](#route-put-alert-rule-group-400-schema) | + +#### Responses + +##### <span id="route-put-alert-rule-group-200"></span> 200 - AlertRuleGroup + +Status: OK + +###### <span id="route-put-alert-rule-group-200-schema"></span> Schema + +[AlertRuleGroup](#alert-rule-group) + +##### <span id="route-put-alert-rule-group-400"></span> 400 - ValidationError + +Status: Bad Request + +###### <span id="route-put-alert-rule-group-400-schema"></span> Schema + +[ValidationError](#validation-error) + +### <span id="route-put-contactpoint"></span> Update an existing contact point. (_RoutePutContactpoint_) + +``` +PUT /api/v1/provisioning/contact-points/:uid +``` + +#### Consumes + +- application/json + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | +| UID | `path` | string | `string` | | ✓ | | UID is the contact point unique identifier | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [EmbeddedContactPoint](#embedded-contact-point) | `models.EmbeddedContactPoint` | | | | | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ---------------------------------- | ----------- | --------------- | :---------: | -------------------------------------------- | +| [202](#route-put-contactpoint-202) | Accepted | Ack | | [schema](#route-put-contactpoint-202-schema) | +| [400](#route-put-contactpoint-400) | Bad Request | ValidationError | | [schema](#route-put-contactpoint-400-schema) | + +#### Responses + +##### <span id="route-put-contactpoint-202"></span> 202 - Ack + +Status: Accepted + +###### <span id="route-put-contactpoint-202-schema"></span> Schema + +[Ack](#ack) + +##### <span id="route-put-contactpoint-400"></span> 400 - ValidationError + +Status: Bad Request + +###### <span id="route-put-contactpoint-400-schema"></span> Schema + +[ValidationError](#validation-error) + +### <span id="route-put-mute-timing"></span> Replace an existing mute timing. (_RoutePutMuteTiming_) + +``` +PUT /api/v1/provisioning/mute-timings/:name +``` + +#### Consumes + +- application/json + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | --------------------------------------- | ------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | +| name | `path` | string | `string` | | ✓ | | Mute timing name | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [MuteTimeInterval](#mute-time-interval) | `models.MuteTimeInterval` | | | | | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | ----------- | ---------------- | :---------: | ------------------------------------------- | +| [200](#route-put-mute-timing-200) | OK | MuteTimeInterval | | [schema](#route-put-mute-timing-200-schema) | +| [400](#route-put-mute-timing-400) | Bad Request | ValidationError | | [schema](#route-put-mute-timing-400-schema) | + +#### Responses + +##### <span id="route-put-mute-timing-200"></span> 200 - MuteTimeInterval + +Status: OK + +###### <span id="route-put-mute-timing-200-schema"></span> Schema + +[MuteTimeInterval](#mute-time-interval) + +##### <span id="route-put-mute-timing-400"></span> 400 - ValidationError + +Status: Bad Request + +###### <span id="route-put-mute-timing-400-schema"></span> Schema + +[ValidationError](#validation-error) + +### <span id="route-put-policy-tree"></span> Sets the notification policy tree. (_RoutePutPolicyTree_) + +``` +PUT /api/v1/provisioning/policies +``` + +#### Consumes + +- application/json + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | --------------- | -------------- | --------- | :------: | ------- | --------------------------------------------------------- | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [Route](#route) | `models.Route` | | | | The new notification routing tree to use | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | ----------- | --------------- | :---------: | ------------------------------------------- | +| [202](#route-put-policy-tree-202) | Accepted | Ack | | [schema](#route-put-policy-tree-202-schema) | +| [400](#route-put-policy-tree-400) | Bad Request | ValidationError | | [schema](#route-put-policy-tree-400-schema) | + +#### Responses + +##### <span id="route-put-policy-tree-202"></span> 202 - Ack + +Status: Accepted + +###### <span id="route-put-policy-tree-202-schema"></span> Schema + +[Ack](#ack) + +##### <span id="route-put-policy-tree-400"></span> 400 - ValidationError + +Status: Bad Request + +###### <span id="route-put-policy-tree-400-schema"></span> Schema + +[ValidationError](#validation-error) + +### <span id="route-put-template"></span> Create or update a notification template. (_RoutePutTemplate_) + +``` +PUT /api/v1/provisioning/templates/:name +``` + +#### Consumes + +- application/json + +{{% responsive-table %}} + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | ------------------------------------------------------------- | ------------------------------------ | --------- | :------: | ------- | --------------------------------------------------------- | +| name | `path` | string | `string` | | ✓ | | Template Name | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [NotificationTemplateContent](#notification-template-content) | `models.NotificationTemplateContent` | | | | | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ------------------------------ | ----------- | -------------------- | :---------: | ---------------------------------------- | +| [202](#route-put-template-202) | Accepted | NotificationTemplate | | [schema](#route-put-template-202-schema) | +| [400](#route-put-template-400) | Bad Request | ValidationError | | [schema](#route-put-template-400-schema) | + +#### Responses + +##### <span id="route-put-template-202"></span> 202 - NotificationTemplate + +Status: Accepted + +###### <span id="route-put-template-202-schema"></span> Schema + +[NotificationTemplate](#notification-template) + +##### <span id="route-put-template-400"></span> 400 - ValidationError + +Status: Bad Request + +###### <span id="route-put-template-400-schema"></span> Schema + +[ValidationError](#validation-error) + +### <span id="route-reset-policy-tree"></span> Clears the notification policy tree. (_RouteResetPolicyTree_) + +``` +DELETE /api/v1/provisioning/policies +``` + +#### Consumes + +- application/json + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ----------------------------------- | -------- | ----------- | :---------: | --------------------------------------------- | +| [202](#route-reset-policy-tree-202) | Accepted | Ack | | [schema](#route-reset-policy-tree-202-schema) | + +#### Responses + +##### <span id="route-reset-policy-tree-202"></span> 202 - Ack + +Status: Accepted + +###### <span id="route-reset-policy-tree-202-schema"></span> Schema + +[Ack](#ack) + +## Models + +### <span id="ack"></span> Ack + +[interface{}](#interface) + +### <span id="alert-query"></span> AlertQuery + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| --------------------------------------------------------- | ----------------------------------------- | ------------------- | :------: | ------- | ------------------------------------------------------------------------------------------------------ | ------- | +| datasourceUid | string | `string` | | | Grafana data source unique identifier; it should be '**expr**' for a Server Side Expression operation. | | +| model | [interface{}](#interface) | `interface{}` | | | JSON is the raw JSON query and includes the above properties as well as custom properties. | | +| queryType | string | `string` | | | QueryType is an optional identifier for the type of query. | +| It can be used to distinguish different types of queries. | | +| refId | string | `string` | | | RefID is the unique identifier of the query, set by the frontend call. | | +| relativeTimeRange | [RelativeTimeRange](#relative-time-range) | `RelativeTimeRange` | | | | | + +{{% /responsive-table %}} + +### <span id="alert-query-export"></span> AlertQueryExport + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ----------------- | ----------------------------------------- | ------------------- | :------: | ------- | ----------- | ------- | +| datasourceUid | string | `string` | | | | | +| model | [interface{}](#interface) | `interface{}` | | | | | +| queryType | string | `string` | | | | | +| refId | string | `string` | | | | | +| relativeTimeRange | [RelativeTimeRange](#relative-time-range) | `RelativeTimeRange` | | | | | + +{{% /responsive-table %}} + +### <span id="alert-rule-export"></span> AlertRuleExport + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ------------ | ----------------------------------------- | --------------------- | :------: | ------- | ----------- | ------- | +| annotations | map of string | `map[string]string` | | | | | +| condition | string | `string` | | | | | +| dashboardUid | string | `string` | | | | | +| data | [][AlertQueryExport](#alert-query-export) | `[]*AlertQueryExport` | | | | | +| execErrState | string | `string` | | | | | +| for | [Duration](#duration) | `Duration` | | | | | +| isPaused | boolean | `bool` | | | | | +| labels | map of string | `map[string]string` | | | | | +| noDataState | string | `string` | | | | | +| panelId | int64 (formatted integer) | `int64` | | | | | +| title | string | `string` | | | | | +| uid | string | `string` | | | | | + +{{% /responsive-table %}} + +### <span id="alert-rule-group"></span> AlertRuleGroup + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| --------- | ------------------------------------------------- | ------------------------- | :------: | ------- | ----------- | ------- | +| folderUid | string | `string` | | | | | +| interval | int64 (formatted integer) | `int64` | | | | | +| rules | [][ProvisionedAlertRule](#provisioned-alert-rule) | `[]*ProvisionedAlertRule` | | | | | +| title | string | `string` | | | | | + +{{% /responsive-table %}} + +### <span id="alert-rule-group-export"></span> AlertRuleGroupExport + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| -------- | --------------------------------------- | -------------------- | :------: | ------- | ----------- | ------- | +| folder | string | `string` | | | | | +| interval | [Duration](#duration) | `Duration` | | | | | +| name | string | `string` | | | | | +| orgId | int64 (formatted integer) | `int64` | | | | | +| rules | [][AlertRuleExport](#alert-rule-export) | `[]*AlertRuleExport` | | | | | + +{{% /responsive-table %}} + +### <span id="alerting-file-export"></span> AlertingFileExport + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ------------- | --------------------------------------------------------- | ----------------------------- | :------: | ------- | ----------- | ------- | +| apiVersion | int64 (formatted integer) | `int64` | | | | | +| contactPoints | [][ContactPointExport](#contact-point-export) | `[]*ContactPointExport` | | | | | +| groups | [][AlertRuleGroupExport](#alert-rule-group-export) | `[]*AlertRuleGroupExport` | | | | | +| policies | [][NotificationPolicyExport](#notification-policy-export) | `[]*NotificationPolicyExport` | | | | | + +{{% /responsive-table %}} + +### <span id="contact-point-export"></span> ContactPointExport + +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +| --------- | ------------------------------------ | ------------------- | :------: | ------- | ----------- | ------- | +| name | string | `string` | | | | | +| orgId | int64 (formatted integer) | `int64` | | | | | +| receivers | [][ReceiverExport](#receiver-export) | `[]*ReceiverExport` | | | | | + +### <span id="contact-points"></span> ContactPoints + +[][EmbeddedContactPoint](#embedded-contact-point) + +### <span id="duration"></span> Duration + +| Name | Type | Go type | Default | Description | Example | +| -------- | ------------------------- | ------- | ------- | ----------- | ------- | +| Duration | int64 (formatted integer) | int64 | | | | + +### <span id="embedded-contact-point"></span> EmbeddedContactPoint + +> EmbeddedContactPoint is the contact point type that is used +> by grafanas embedded alertmanager implementation. + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ------------------------------------ | ----------------------- | -------- | :------: | ------- | ----------------------------------------------------------------- | --------- | +| disableResolveMessage | boolean | `bool` | | | | `false` | +| name | string | `string` | | | Name is used as grouping key in the UI. Contact points with the | +| same name will be grouped in the UI. | `webhook_1` | +| provenance | string | `string` | | | | | +| settings | [JSON](#json) | `JSON` | ✓ | | | | +| type | string | `string` | ✓ | | | `webhook` | +| uid | string | `string` | | | UID is the unique identifier of the contact point. The UID can be | +| set by the user. | `my_external_reference` | + +{{% /responsive-table %}} + +### <span id="json"></span> Json + +[interface{}](#interface) + +### <span id="match-regexps"></span> MatchRegexps + +[MatchRegexps](#match-regexps) + +### <span id="match-type"></span> MatchType + +| Name | Type | Go type | Default | Description | Example | +| --------- | ------------------------- | ------- | ------- | ----------- | ------- | +| MatchType | int64 (formatted integer) | int64 | | | | + +### <span id="matcher"></span> Matcher + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ----- | ------------------------ | ----------- | :------: | ------- | ----------- | ------- | +| Name | string | `string` | | | | | +| Type | [MatchType](#match-type) | `MatchType` | | | | | +| Value | string | `string` | | | | | + +{{% /responsive-table %}} + +### <span id="matchers"></span> Matchers + +> Matchers is a slice of Matchers that is sortable, implements Stringer, and +> provides a Matches method to match a LabelSet against all Matchers in the +> slice. Note that some users of Matchers might require it to be sorted. + +[][Matcher](#matcher) + +### <span id="mute-time-interval"></span> MuteTimeInterval + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| -------------- | -------------------------------- | ----------------- | :------: | ------- | ----------- | ------- | +| name | string | `string` | | | | | +| time_intervals | [][TimeInterval](#time-interval) | `[]*TimeInterval` | | | | | + +{{% /responsive-table %}} + +### <span id="mute-timing-export"></span> MuteTimingExport + +**Properties** + +### <span id="mute-timings-export"></span> MuteTimingsExport + +**Properties** + +### <span id="mute-timings"></span> MuteTimings + +[][MuteTimeInterval](#mute-time-interval) + +### <span id="not-found"></span> NotFound + +[interface{}](#interface) + +### <span id="notification-policy-export"></span> NotificationPolicyExport + +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +| ------ | ---------------------------- | ------------- | :------: | ------- | ----------- | ------- | +| Policy | [RouteExport](#route-export) | `RouteExport` | | | inline | | +| orgId | int64 (formatted integer) | `int64` | | | | | + +### <span id="notification-template"></span> NotificationTemplate + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ---------- | ------------------------- | ------------ | :------: | ------- | ----------- | ------- | +| name | string | `string` | | | | | +| provenance | [Provenance](#provenance) | `Provenance` | | | | | +| template | string | `string` | | | | | + +{{% /responsive-table %}} + +### <span id="notification-template-content"></span> NotificationTemplateContent + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| -------- | ------ | -------- | :------: | ------- | ----------- | ------- | +| template | string | `string` | | | | | + +{{% /responsive-table %}} + +### <span id="notification-templates"></span> NotificationTemplates + +[][NotificationTemplate](#notification-template) + +### <span id="object-matchers"></span> ObjectMatchers + +[Matchers](#matchers) + +#### Inlined models + +### <span id="permission-denied"></span> PermissionDenied + +[interface{}](#interface) + +### <span id="provenance"></span> Provenance + +| Name | Type | Go type | Default | Description | Example | +| ---------- | ------ | ------- | ------- | ----------- | ------- | +| Provenance | string | string | | | | + +### <span id="provisioned-alert-rule"></span> ProvisionedAlertRule + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ------------ | ---------------------------- | ------------------- | :------: | ------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| annotations | map of string | `map[string]string` | | | | `{"runbook_url":"https://supercoolrunbook.com/page/13"}` | +| condition | string | `string` | ✓ | | | `A` | +| data | [][AlertQuery](#alert-query) | `[]*AlertQuery` | ✓ | | | `[{"datasourceUid":"__expr__","model":{"conditions":[{"evaluator":{"params":[0,0],"type":"gt"},"operator":{"type":"and"},"query":{"params":[]},"reducer":{"params":[],"type":"avg"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1 == 1","hide":false,"intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"},"queryType":"","refId":"A","relativeTimeRange":{"from":0,"to":0}}]` | +| execErrState | string | `string` | ✓ | | | | +| folderUID | string | `string` | ✓ | | | `project_x` | +| for | [Duration](#duration) | `Duration` | ✓ | | | | +| id | int64 (formatted integer) | `int64` | | | | | +| isPaused | boolean | `bool` | | | | `false` | +| labels | map of string | `map[string]string` | | | | `{"team":"sre-team-1"}` | +| noDataState | string | `string` | ✓ | | | | +| orgID | int64 (formatted integer) | `int64` | ✓ | | | | +| provenance | [Provenance](#provenance) | `Provenance` | | | | | +| ruleGroup | string | `string` | ✓ | | | `eval_group_1` | +| title | string | `string` | ✓ | | | `Always firing` | +| uid | string | `string` | | | | | +| updated | date-time (formatted string) | `strfmt.DateTime` | | | | | + +{{% /responsive-table %}} + +### <span id="provisioned-alert-rules"></span> ProvisionedAlertRules + +[][ProvisionedAlertRule](#provisioned-alert-rule) + +### <span id="raw-message"></span> RawMessage + +[interface{}](#interface) + +### <span id="receiver-export"></span> ReceiverExport + +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +| --------------------- | -------------------------- | ------------ | :------: | ------- | ----------- | ------- | +| disableResolveMessage | boolean | `bool` | | | | | +| settings | [RawMessage](#raw-message) | `RawMessage` | | | | | +| type | string | `string` | | | | | +| uid | string | `string` | | | | | + +### <span id="regexp"></span> Regexp + +> A Regexp is safe for concurrent use by multiple goroutines, +> except for configuration methods, such as Longest. + +[interface{}](#interface) + +### <span id="relative-time-range"></span> RelativeTimeRange + +> RelativeTimeRange is the per query start and end time +> for requests. + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ---- | --------------------- | ---------- | :------: | ------- | ----------- | ------- | +| from | [Duration](#duration) | `Duration` | | | | | +| to | [Duration](#duration) | `Duration` | | | | | + +{{% /responsive-table %}} + +### <span id="route"></span> Route + +> A Route is a node that contains definitions of how to handle alerts. This is modified +> from the upstream alertmanager in that it adds the ObjectMatchers property. + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ------------------- | ---------------------------------- | ------------------- | :------: | ------- | --------------------------------------- | ------- | +| continue | boolean | `bool` | | | | | +| group_by | []string | `[]string` | | | | | +| group_interval | string | `string` | | | | | +| group_wait | string | `string` | | | | | +| match | map of string | `map[string]string` | | | Deprecated. Remove before v1.0 release. | | +| match_re | [MatchRegexps](#match-regexps) | `MatchRegexps` | | | | | +| matchers | [Matchers](#matchers) | `Matchers` | | | | | +| mute_time_intervals | []string | `[]string` | | | | | +| object_matchers | [ObjectMatchers](#object-matchers) | `ObjectMatchers` | | | | | +| provenance | [Provenance](#provenance) | `Provenance` | | | | | +| receiver | string | `string` | | | | | +| repeat_interval | string | `string` | | | | | +| routes | [][Route](#route) | `[]*Route` | | | | | + +{{% /responsive-table %}} + +### <span id="route-export"></span> RouteExport + +> RouteExport is the provisioned file export of definitions.Route. This is needed to hide fields that aren't usable in +> provisioning file format. An alternative would be to define a custom MarshalJSON and MarshalYAML that excludes them. + +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +| ------------------- | ---------------------------------- | ------------------- | :------: | ------- | --------------------------------------- | ------- | +| continue | boolean | `bool` | | | | | +| group_by | []string | `[]string` | | | | | +| group_interval | string | `string` | | | | | +| group_wait | string | `string` | | | | | +| match | map of string | `map[string]string` | | | Deprecated. Remove before v1.0 release. | | +| match_re | [MatchRegexps](#match-regexps) | `MatchRegexps` | | | | | +| matchers | [Matchers](#matchers) | `Matchers` | | | | | +| mute_time_intervals | []string | `[]string` | | | | | +| object_matchers | [ObjectMatchers](#object-matchers) | `ObjectMatchers` | | | | | +| receiver | string | `string` | | | | | +| repeat_interval | string | `string` | | | | | +| routes | [][RouteExport](#route-export) | `[]*RouteExport` | | | | | + +### <span id="time-interval"></span> TimeInterval + +> TimeInterval describes intervals of time. ContainsTime will tell you if a golang time is contained +> within the interval. + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ------------- | -------------------------- | -------------- | :------: | ------- | ----------- | ------- | +| days_of_month | []string | `[]string` | | | | | +| location | string | `string` | | | | | +| months | []string | `[]string` | | | | | +| times | [][TimeRange](#time-range) | `[]*TimeRange` | | | | | +| weekdays | []string | `[]string` | | | | | +| years | []string | `[]string` | | | | | + +{{% /responsive-table %}} + +### <span id="time-range"></span> TimeRange + +> For example, 4:00PM to End of the day would Begin at 1020 and End at 1440. + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ----------- | ------------------------- | ------- | :------: | ------- | ----------- | ------- | +| EndMinute | int64 (formatted integer) | `int64` | | | | | +| StartMinute | int64 (formatted integer) | `int64` | | | | | + +{{% /responsive-table %}} + +### <span id="validation-error"></span> ValidationError + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ---- | ------ | -------- | :------: | ------- | ----------- | --------------- | +| msg | string | `string` | | | | `error message` | + +{{% /responsive-table %}} diff --git a/docs/sources/shared/back-up/back-up-grafana.md b/docs/sources/shared/back-up/back-up-grafana.md index c12ec9c6d4c71..db2f437663f66 100644 --- a/docs/sources/shared/back-up/back-up-grafana.md +++ b/docs/sources/shared/back-up/back-up-grafana.md @@ -17,8 +17,10 @@ Copy Grafana configuration files that you might have modified in your Grafana de The Grafana configuration files are located in the following directories: -- Default configuration: `$WORKING_DIR/conf/defaults.ini` -- Custom configuration: `$WORKING_DIR/conf/custom.ini` +- Default configuration: `$WORKING_DIR/defaults.ini` (Don't change this file) +- Custom configuration: `$WORKING_DIR/custom.ini` + +For more information on where to find configuration files, refer to [Configuration file location](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#configuration-file-location). {{% admonition type="note" %}} If you installed Grafana using the `deb` or `rpm` packages, then your configuration file is located at diff --git a/docs/sources/shared/datasources/pyroscope-profile-tracing-intro.md b/docs/sources/shared/datasources/pyroscope-profile-tracing-intro.md new file mode 100644 index 0000000000000..b7c9df002fcf2 --- /dev/null +++ b/docs/sources/shared/datasources/pyroscope-profile-tracing-intro.md @@ -0,0 +1,123 @@ +--- +headless: true +labels: + products: + - enterprise + - oss +--- + +[//]: # 'This file documents the introductory material for traces to profiling for the Pyroscope data source.' +[//]: # 'This shared file is included in these locations:' +[//]: # '/grafana/docs/sources/datasources/pyroscope/profiling-and-tracing.md' +[//]: # '/website/docs/grafana-cloud/data-configuration/traces/traces-query-editor.md' +[//]: # '/docs/sources/view-and-analyze-profile-data/profile-tracing/_index.md' +[//]: # +[//]: # 'If you make changes to this file, verify that the meaning and content are not changed in any place where the file is included.' +[//]: # 'Any links should be fully qualified and not relative: /docs/grafana/ instead of ../grafana/.' + +<!-- Profiling and tracing integration --> + +Profiles, continuous profiling, and distributed traces are all tools that can be used to improve the performance and reliability of applications. +However, each tool has its own strengths and weaknesses, and it is important to choose the right tool for the job as well as understand when to use both. + +## Profiling + +Profiling offers a deep-dive into an application's performance at the code level, highlighting resource usage and performance hotspots. + +<table> + <tr> + <th scope="row">Usage</th> + <td>During development, major releases, or upon noticing performance quirks.</td> + </tr> + <tr> + <th scope="row">Benefits</th> + <td> + <ul> + <li>Business: Boosts user experience through enhanced application performance.</li> + <li>Technical: Gives clear insights into code performance and areas of refinement.</li> + </ul> + </td> + </tr> + <tr> + <th scope="row">Example</th> + <td>A developer uses profiling upon noting slow app performance, identifies a CPU-heavy function, and optimizes it.</td> + </tr> +</table> + +## Continuous profiling + +Continuous profiling provides ongoing performance insights, capturing long-term trends and intermittent issues. + +<table> + <tr> + <th scope="row">Usage</th> + <td>Mainly in production, especially for high-priority applications.</td> + </tr> + <tr> + <th scope="row">Benefits</th> + <td> + <ul> + <li>Business: Preemptively addresses inefficiencies, potentially saving costs.</li> + <li>Technical: Highlights performance trends and issues like potential memory leaks over time.</li> + </ul> + </td> + </tr> + <tr> + <th scope="row">Example</th> + <td>A month-long data from continuous profiling suggests increasing memory consumption, hinting at a memory leak.</td> + </tr> +</table> + +## Distributed tracing + +Traces requests as they cross multiple services, revealing interactions and service dependencies. + +<table> + <tr> + <th scope="row">Usage</th> + <td>Essential for systems like microservices where requests touch multiple services.</td> + </tr> + <tr> + <th scope="row">Benefits</th> + <td> + <ul> + <li>Business: Faster issue resolution, reduced downtimes, and strengthened customer trust.</li> + <li>Technical: A broad view of the system's structure, revealing bottlenecks and inter-service dependencies.</li> + </ul> + </td> + </tr> + <tr> + <th scope="row">Example</th> + <td>In e-commerce, a user's checkout request might involve various services. Tracing depicts this route, pinpointing where time is most spent.</td> + </tr> +</table> + +## Combined power of tracing and profiling + +When used together, tracing and profiling provide a powerful tool for understanding system and application performance. + +<table> + <tr> + <th scope="row">Usage</th> + <td>For comprehensive system-to-code insights, especially when diagnosing complex issues spread across services and codebases.</td> + </tr> + <tr> + <th scope="row">Benefits</th> + <td> + <ul> + <li>Business: Reduces downtime, optimizes user experience, and safeguards revenues.</li> + <li>Technical: + <ul> + <li>Holistic view: Tracing pinpoints bottle-necked services, while profiling delves into the responsible code segments.</li> + <li>End-to-end insight: Visualizes a request's full journey and the performance of individual code parts.</li> + <li>Efficient diagnosis: Tracing identifies service latency; profiling zeroes in on its cause, be it database queries, API calls, or specific code inefficiencies.</li> + </ul> + </li> + </ul> + </td> + </tr> + <tr> + <th scope="row">Example</th> + <td>Tracing reveals latency in a payment service. Combined with profiling, it's found that a particular function, making third-party validation calls, is the culprit. This insight guides optimization, refining system efficiency.</td> + </tr> +</table> diff --git a/docs/sources/shared/datasources/tempo-editor-traceql.md b/docs/sources/shared/datasources/tempo-editor-traceql.md index 24209ed6d6e44..65e5a992dd465 100644 --- a/docs/sources/shared/datasources/tempo-editor-traceql.md +++ b/docs/sources/shared/datasources/tempo-editor-traceql.md @@ -25,7 +25,7 @@ To learn more about how to query by TraceQL, refer to the [TraceQL documentation The TraceQL query editor, located on the **Explore** > **TraceQL** tab in Grafana, lets you search by trace ID and write TraceQL queries using autocomplete. -![The TraceQL query editor](/static/img/docs/tempo/screenshot-traceql-query-editor-v10.png) +![The TraceQL query editor](/media/docs/tempo/traceql/screenshot-traceql-query-editor-v10.png) ## Enable the query editor @@ -58,7 +58,7 @@ To query a particular trace by its trace ID: 1. Enter the trace ID into the query field. For example: `41928b92edf1cdbe0ba6594baee5ae9` 1. Click **Run query** or use the keyboard shortcut Shift + Enter. -![Search for a trace ID using the TraceQL query editor](/static/img/docs/tempo/screenshot-traceql-editor-traceID.png) +![Search for a trace ID using the TraceQL query editor](/media/docs/tempo/traceql/screenshot-traceql-editor-traceID.png) ## Use autocomplete to write queries @@ -66,32 +66,36 @@ You can use the query editor’s autocomplete suggestions to write queries. The editor detects span sets to provide relevant autocomplete options. It uses regular expressions (regex) to detect where it's inside a spanset and provide attribute names, scopes, intrinsic names, logic operators, or attribute values from Tempo's API, depending on what's expected for the current situation. -![Query editor showing the auto-complete feature](/static/img/docs/tempo/screenshot-traceql-query-editor-auto-complete-v10.png) +![Query editor showing the auto-complete feature](/media/docs/tempo/traceql/screenshot-traceql-query-editor-auto-complete-v10.png) To create a query using autocomplete, follow these steps: -1. Use the steps above to access the query editor and begin your query. +1. From the menu, choose **Explore**, select the desired Tempo data source, and navigate to the **TraceQL** tab. 1. Enter your query. As you type your query, autocomplete suggestions appear as a drop-down. Each letter you enter refines the autocomplete options to match. 1. Use your mouse or arrow keys to select any option you wish. When the desired option is highlighted, press Tab on your keyboard to add the selection to your query. -1. Once your query is complete, select **Run query** to perform the query. +1. Once your query is complete, select **Run query**. ## View query results Query results for both the editor and the builder are returned in a table. Selecting the Trace ID or Span ID provides more detailed information. -![Query editor showing span results](/static/img/docs/tempo/screenshot-traceql-query-editor-results-v10.png) +Selecting the trace ID from the returned results opens a trace diagram. Selecting a span from the returned results opens a trace diagram and reveals the relevant span in the trace diagram (the highlighted blue line). + +In the trace diagram, the bold text on the left side of each span indicates the service name, for example `mythical-requester: requester`, and it is hidden when subsequent spans have the same service name (nested spans). +Each service has a color assigned to it, which is visible to the left of the name and timeline in the graph. +Spans with the same color belong to the same service. The grey text to the right of the service name indicates the span name. -Selecting the trace ID from the returned results opens a trace diagram. Selecting a span from the returned results opens a trace diagram and reveals the relevant span in the trace diagram (above, the highlighted blue line). +![Query editor showing span results](/media/docs/tempo/traceql/screenshot-traceql-query-editor-results-v10.png) ### Streaming results The Tempo data source supports streaming responses to TraceQL queries so you can see partial query results as they come in without waiting for the whole query to finish. {{% admonition type="note" %}} -To use this experimental feature, enable the `traceQLStreaming` feature toggle. If you’re using Grafana Cloud and would like to enable this feature, please contact customer support. +To use this feature in Grafana OSS v10.1 and later, enable the `traceQLStreaming` feature toggle. This capability is enabled by default in Grafana Cloud. {{% /admonition %}} Streaming is available for both the **Search** and **TraceQL** query types, and you'll get immediate visibility of incoming traces on the results table. diff --git a/docs/sources/shared/datasources/tempo-search-traceql.md b/docs/sources/shared/datasources/tempo-search-traceql.md index 15ce534aaa9ac..64ff336aa612e 100644 --- a/docs/sources/shared/datasources/tempo-search-traceql.md +++ b/docs/sources/shared/datasources/tempo-search-traceql.md @@ -19,7 +19,7 @@ labels: The TraceQL query builder, located on the **Explore** > **Query type** > **Search** in Grafana, provides drop-downs and text fields to help you write a query. -![The TraceQL query builder](/static/img/docs/tempo/screenshot-traceql-query-type-search-v10.png) +![The TraceQL query builder](/media/docs/tempo/traceql/screenshot-traceql-query-type-search-v10.png) The builder lets you run the most common queries in as few clicks as possible. You don't need to know the underlying query language or database architecture to use it. @@ -32,14 +32,14 @@ You can use the query builder to search trace data by resource service name, spa In addition, you can add query builder blocks, view the query history, and use the **Inspector** to see details. -{{< figure src="/static/img/docs/queries/screenshot-tempods-query-search.png" class="docs-image--no-shadow" max-width="750px" caption="Screenshot of the Tempo Search query type" >}} +{{< figure src="/media/docs/tempo/traceql/screenshot-tempods-query-search.png" class="docs-image--no-shadow" max-width="750px" caption="Screenshot of the Tempo Search query type" >}} ## Perform a search To perform a search, you need to select filters and/or tags and then run the query. The results appear underneath the query builder. -The screenshot below identifies the areas used to perform a search. +The screenshot identifies the areas used to perform a search. -{{< figure src="/static/img/docs/queries/screenshot-tempods-query-search-parts.png" class="docs-image--no-shadow" max-width="750px" caption="Parts of Tempo Search query type" >}} +{{< figure src="/media/docs/tempo/traceql/screenshot-tempods-query-search-parts.png" class="docs-image--no-shadow" max-width="750px" caption="Parts of Tempo Search query type" >}} | Number | Name | Action | Comment | | :----- | :----------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------- | @@ -109,23 +109,26 @@ To add a tag, follow these steps: Using **Aggregate by**, you can calculate RED metrics (total span count, percent erroring spans, and latency information) for spans of `kind=server` that match your filter criteria, grouped by one or more attributes. This capability is based on the [metrics summary API](/docs/grafana-cloud/monitor-infrastructure/traces/metrics-summary-api/). Metrics summary only calculates summaries based on spans received within the last hour. +For additional information, refer to [Traces to metrics: Ad-hoc RED metrics in Grafana Tempo with `Aggregate by`](https://grafana.com/blog/2023/12/07/traces-to-metrics-ad-hoc-red-metrics-in-grafana-tempo-with-aggregate-by/). -{{< youtube id="g97CjKOZqT4" >}} +<!--Impromptu RED metrics with Aggregate by --> + +{{< youtube id="xOolCpm2F8c" >}} When you use **Aggregate by**, the selections you make determine how the information is reported in the Table. Every combination that matches selections in your data is listed in the table. Each aggregate value, for example `intrinsic`:`name`, has a corresponding column in the results table. -For example, **names** matching `GET /:endpoint` with a **span.http.user_agent** of `k6/0.46` appeared in 31,466 spans. Instead of being listed by traces and associated spans, the query results are grouped by the the selections in **Aggregate by**. +For example, **names** matching `GET /:endpoint` with a **span.http.user_agent** of `k6/0.46` appeared in 31,466 spans. Instead of being listed by traces and associated spans, the query results are grouped by the selections in **Aggregate by**. The RED metrics are calculated for every name and user agent combination found in your data. -![Use Aggregate by to calculate RED metrics for spans and group by attributes](/static/img/docs/tempo/screenshot-traces-aggregate-by.png) - The screenshot shows all of the successful HTTP `status_code` API calls against the `mystical-server` service. The results are shown in the same order used in **Aggregate by**. For example, **Aggregate by** lists `intrinsic.name` followed by `span.http.user_agent`. The first column in the results Table shows **name** and then **span.http.user_agent**. +![Use Aggregate by to calculate RED metrics for spans and group by attributes](/media/docs/tempo/traceql/screenshot-traces-aggregate-by.png) + To use this capability: 1. In the **Aggregate by** row, select a scope from the first drop-down box. For example, `span`. @@ -134,6 +137,10 @@ To use this capability: 1. Optional: Select a **Time range** to expand or narrow the data set for an hour's range. 1. Select **Run query**. +<!-- Explanation of how to use feature --> + +{{< youtube id="g97CjKOZqT4" >}} + ### Optional: Add queries Using **Add query**, you can have successive queries that run in sequential order. @@ -147,7 +154,7 @@ Select **Run query** to run the TraceQL query (1 in the screenshot). Queries can take a little while to return results. The results appear in a table underneath the query builder. Selecting a Trace ID (2 in the screenshot) displays more detailed information (3 in the screenshot). -{{< figure src="/static/img/docs/queries/screenshot-tempods-query-results.png" class="docs-image--no-shadow" max-width="750px" caption="Tempo Search query type results" >}} +{{< figure src="/media/docs/tempo/traceql/screenshot-tempods-query-results.png" class="docs-image--no-shadow" max-width="750px" caption="Tempo Search query type results" >}} #### Streaming results diff --git a/docs/sources/shared/datasources/tempo-traces-to-profiles.md b/docs/sources/shared/datasources/tempo-traces-to-profiles.md index 2a2d96581581e..02c4edd3fb725 100644 --- a/docs/sources/shared/datasources/tempo-traces-to-profiles.md +++ b/docs/sources/shared/datasources/tempo-traces-to-profiles.md @@ -16,67 +16,83 @@ labels: <!-- # Trace to profiles --> -{{< docs/experimental product="Trace to profiles" featureFlag="traceToProfiles" >}} - Using Trace to profiles, you can use Grafana’s ability to correlate different signals by adding the functionality to link between traces and profiles. **Trace to profiles** lets you link your Grafana Pyroscope data source to tracing data. When configured, this connection lets you run queries from a trace span into the profile data. +{{< youtube id="AG8VzfFMLxo" >}} + There are two ways to configure the trace to profiles feature: -- Use a simplified configuration with default query, or +- Use a basic configuration with default query, or - Configure a custom query where you can use a template language to interpolate variables from the trace or span. +{{< admonition type="note">}} +Traces to profile requires a Tempo data source with Traces to profiles configured and a Pyroscope data source. This integration supports profile data generated using [Go](/docs/pyroscope/<PYROSCOPE_VERSION>/configure-client/trace-span-profiles/go-span-profiles/), [Ruby](/docs/pyroscope/<PYROSCOPE_VERSION>/configure-client/trace-span-profiles/ruby-span-profiles/), and [Java](/docs/pyroscope/<PYROSCOPE_VERSION>/configure-client/trace-span-profiles/java-span-profiles/) instrumentation SDKs. + +As with traces, your application needs to be instrumented to emit profiling data. For more information, refer to [Linking tracing and profiling with span profiles](/docs/pyroscope/<PYROSCOPE_VERSION>/configure-client/trace-span-profiles/). +{{< /admonition >}} + To use trace to profiles, navigate to **Explore** and query a trace. Each span now links to your queries. Clicking a link runs the query in a split panel. If tags are configured, Grafana dynamically inserts the span attribute values into the query. The query runs over the time range of the (span start time - 60) to (span end time + 60 seconds). -![Selecting a link in the span queries the profile data source](/static/img/docs/tempo/profiles/tempo-profiles-Span-link-profile-data-source.png) +![Selecting a link in the span queries the profile data source](/media/docs/tempo/profiles/tempo-trace-to-profile.png) + +To use trace to profiles, you must have a configured Grafana Pyroscope data source. For more information, refer to the [Grafana Pyroscope data source](/docs/grafana/<GRAFANA_VERSION>/datasources/grafana-pyroscope/) documentation. + +**Embedded flame graphs** are also inserted into each span details section that has a linked profile (requires a configured Grafana Pyroscope data source). +This lets you see resource consumption in a flame graph visualization for each span without having to navigate away from the current view. +Hover over a particular block in the flame graph to see more details about the resources being consumed. + +## Configuration options -To use trace to profiles, you must have a configured Grafana Pyroscope data source. For more information, refer to the [Grafana Pyroscope data source documentation](/docs/grafana/latest/datasources/grafana-pyroscope/). +The following table describes options for configuring your Trace to profiles settings: -## Use a simple configuration +| Setting name | Description | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Data source** | Defines the target data source. You can currently select a Pyroscope \[profiling\] data source. | +| **Tags** | Defines the tags to use in the profile query. Default: `cluster`, `hostname`, `namespace`, `pod`, `service.name`, `service.namespace`. You can change the tag name for example to remove dots from the name if they are not allowed in the target data source. For example, map `http.status` to `http_status`. | +| **Profile type** | Defines the profile type that used in the query. | +| **Use custom query** | Toggles use of custom query with interpolation. | +| **Query** | Input to write custom query. Use variable interpolation to customize it with variables from span. | -To use a simple configuration, follow these steps: +## Use a basic configuration -1. Select a Pyroscope data source from the **Data source** drop-down. -1. Optional: Choose any tags to use in the query. If left blank, the default values of `service.name` and `service.namespace` are used. +To use a basic configuration, follow these steps: - The tags you configure must be present in the spans attributes or resources for a trace to profiles span link to appear. You can optionally configure a new name for the tag. This is useful for example if the tag has dots in the name and the target data source doesn't allow using dots in labels. In that case you can for example remap `service.name` to `service_name`. +1. In the left menu, select **Connections** > **Data sources**. +1. Select your configured Tempo data source from the **Data source** list. +1. Scroll down to the **Traces to profiles** section. +1. Select a Pyroscope data source in the **Data source** drop-down. +1. Optional: Add one or more tags to use in the query. If left blank, the default values of `service.name` and `service.namespace` are used. + + The tags you configure must be present in the spans attributes or resources for a trace-to-profiles span link to appear. + + You can optionally configure a new name for the tag. This is useful if the tag has dots in the name and the target data source doesn't allow dots in labels. In that case, you can remap `service.name` to `service_name`. 1. Select one or more profile types to use in the query. Select the drop-down and choose options from the menu. The profile type or app must be selected for the query to be valid. Grafana doesn't show any data if the profile type or app isn’t selected when a query runs. - ![Traces to profile configuration options in the Tempo data source](/static/img/docs/tempo/profiles/Tempo-data-source-profiles-Settings.png) + ![Traces to profile configuration options in the Tempo data source](/media/docs/tempo/profiles/Tempo-data-source-profiles-Settings.png) -1. Do not select **Use custom query**. 1. Select **Save and Test**. +If you have configured a Pyroscope data source and no profile data is available or the **Profiles for this span** +button and the embedded flame graph isn't visible, verify that the `pyroscope.profile.id` key-value pair exists in your span tags. + ## Configure a custom query To use a custom query with the configuration, follow these steps: -1. Select a Pyroscope data source from the **Data source** drop-down. -1. Optional: Choose any tags that will be used in the query. If left blank, the default values of `service.name` and `service.namespace` are used. +1. In the left menu, select **Connections** > **Data sources**. +1. Select a configured Tempo data source from the **Data source** list. +1. Scroll down to the **Traces to profiles** section. +1. Select a Pyroscope data source in the **Data source** drop-down. +1. Optional: Choose any tags to use in the query. If left blank, the default values of `service.name` and `service.namespace` are used. - These tags can be used in the custom query with `${__tags}` variable. This variable interpolates the mapped tags as list in an appropriate syntax for the data source and will only include the tags that were present in the span omitting those that weren’t present. You can optionally configure a new name for the tag. This is useful in cases where the tag has dots in the name and the target data source doesn't allow using dots in labels. For example, you can remap `service.name` to `service_name` in such a case. If you don’t map any tags here, you can still use any tag in the query like this `method="${__span.tags.method}"`. - -1. Select one or more profile types to use in the query. Select the drop-down and choose options from the menu. -1. Switch on **Use custom query** to enter a custom query. -1. Specify a custom query to be used to query profile data. You can use various variables to make that query relevant for current span. The link is shown only if all the variables are interpolated with non-empty values to prevent creating an invalid query. You can interpolate the configured tags using the `$__tags` keyword. -1. Select **Save and Test**. + These tags can be used in the custom query with `${__tags}` variable. This variable interpolates the mapped tags as list in an appropriate syntax for the data source. Only the tags that were present in the span are included; tags that aren't present are omitted. You can also configure a new name for the tag. This is useful in cases where the tag has dots in the name and the target data source doesn't allow using dots in labels. For example, you can remap `service.name` to `service_name`. If you don’t map any tags here, you can still use any tag in the query, for example: `method="${__span.tags.method}"`. You can learn more about custom query variables [here](/docs/grafana/<GRAFANA_VERSION>/datasources/tempo/configure-tempo-data-source/#custom-query-variables). -## Variables that can be used in a custom query - -To use a variable you need to wrap it in `${}`. For example, `${__span.name}`. - -| Variable name | Description | -| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **\_\_tags** | This variable uses the tag mapping from the UI to create a label matcher string in the specific data source syntax. The variable only uses tags that are present in the span. The link is still created even if only one of those tags is present in the span. You can use this if all tags are not required for the query to be useful. | -| **\_\_span.spanId** | The ID of the span. | -| **\_\_span.traceId** | The ID of the trace. | -| **\_\_span.duration** | The duration of the span. | -| **\_\_span.name** | Name of the span. | -| **\_\_span.tags** | Namespace for the tags in the span. To access a specific tag named `version`, you would use `${__span.tags.version}`. In case the tag contains dot, you have to access it as `${__span.tags["http.status"]}`. | -| **\_\_trace.traceId** | The ID of the trace. | -| **\_\_trace.duration** | The duration of the trace. | -| **\_\_trace.name** | The name of the trace. | +1. Select one or more profile types to use in the query. Select the drop-down and choose options from the menu. +1. Switch on **Use custom query** to enter a custom query. +1. Specify a custom query to be used to query profile data. You can use various variables to make that query relevant for current span. The link is shown only if all the variables are interpolated with non-empty values to prevent creating an invalid query. You can interpolate the configured tags using the `$__tags` keyword. +1. Select **Save and Test**. diff --git a/docs/sources/shared/upgrade/upgrade-common-tasks.md b/docs/sources/shared/upgrade/upgrade-common-tasks.md index 3c53e3d44f3de..7e84a837d4da8 100644 --- a/docs/sources/shared/upgrade/upgrade-common-tasks.md +++ b/docs/sources/shared/upgrade/upgrade-common-tasks.md @@ -8,13 +8,13 @@ title: Upgrade guide common tasks ## Upgrade Grafana -The following sections provide instructions for how to upgrade Grafana based on your installation method. +The following sections provide instructions for how to upgrade Grafana based on your installation method. For more information on where to find configuration files, refer to [Configuration file location](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#configuration-file-location). ### Debian To upgrade Grafana installed from a Debian package (`.deb`), complete the following steps: -1. In your current installation of Grafana, save your custom configuration changes to a file named `<grafana_install_dir>/conf/custom.ini`. +1. In your current installation of Grafana, save your custom configuration changes to a file named `<grafana_install_dir>/grafana.ini`. This enables you to upgrade Grafana without the risk of losing your configuration changes. @@ -32,7 +32,7 @@ To upgrade Grafana installed from a Debian package (`.deb`), complete the follow To upgrade Grafana installed from the Grafana Labs APT repository, complete the following steps: -1. In your current installation of Grafana, save your custom configuration changes to a file named `<grafana_install_dir>/conf/custom.ini`. +1. In your current installation of Grafana, save your custom configuration changes to a file named `<grafana_install_dir>/grafana.ini`. This enables you to upgrade Grafana without the risk of losing your configuration changes. @@ -49,7 +49,7 @@ Grafana automatically updates when you run `apt-get upgrade`. To upgrade Grafana installed from the binary `.tar.gz` package, complete the following steps: -1. In your current installation of Grafana, save your custom configuration changes to a file named `<grafana_install_dir>/conf/custom.ini`. +1. In your current installation of Grafana, save your custom configuration changes to the custom configuration file, `custom.ini` or `grafana.ini`. This enables you to upgrade Grafana without the risk of losing your configuration changes. @@ -61,7 +61,7 @@ To upgrade Grafana installed from the binary `.tar.gz` package, complete the fol To upgrade Grafana installed using RPM or YUM complete the following steps: -1. In your current installation of Grafana, save your custom configuration changes to a file named `<grafana_install_dir>/conf/custom.ini`. +1. In your current installation of Grafana, save your custom configuration changes to a file named `<grafana_install_dir>/grafana.ini`. This enables you to upgrade Grafana without the risk of losing your configuration changes. @@ -84,7 +84,7 @@ To upgrade Grafana installed using RPM or YUM complete the following steps: To upgrade Grafana running in a Docker container, complete the following steps: -1. In your current installation of Grafana, save your custom configuration changes to a file named `<grafana_install_dir>/conf/custom.ini`. +1. Use Grafana [environment variables](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#override-configuration-with-environment-variables) to save your custom configurations; this is the recommended method. Alternatively, you can view your configuration files manually by accessing the deployed container. This enables you to upgrade Grafana without the risk of losing your configuration changes. @@ -119,7 +119,7 @@ To upgrade Grafana installed on Windows, complete the following steps: To upgrade Grafana installed on Mac, complete the following steps: -1. In your current installation of Grafana, save your custom configuration changes to a file named `<grafana_install_dir>/conf/custom.ini`. +1. In your current installation of Grafana, save your custom configuration changes to the custom configuration file, `custom.ini`. This enables you to upgrade Grafana without the risk of losing your configuration changes. diff --git a/docs/sources/shared/visualizations/connect-null-values.md b/docs/sources/shared/visualizations/connect-null-values.md index 4336568cd3994..66fa2d507aca4 100644 --- a/docs/sources/shared/visualizations/connect-null-values.md +++ b/docs/sources/shared/visualizations/connect-null-values.md @@ -8,6 +8,6 @@ Choose how null values, which are gaps in the data, appear on the graph. Null va ![Connect null values option](/static/img/docs/time-series-panel/connect-null-values-option-v9.png) -- **Never:** Time series data points with gaps in the the data are never connected. -- **Always:** Time series data points with gaps in the the data are always connected. +- **Never:** Time series data points with gaps in the data are never connected. +- **Always:** Time series data points with gaps in the data are always connected. - **Threshold:** Specify a threshold above which gaps in the data are no longer connected. This can be useful when the connected gaps in the data are of a known size and/or within a known range, and gaps outside this range should no longer be connected. diff --git a/docs/sources/shared/visualizations/disconnect-values.md b/docs/sources/shared/visualizations/disconnect-values.md index 63e6472c3636a..1888102d7e856 100644 --- a/docs/sources/shared/visualizations/disconnect-values.md +++ b/docs/sources/shared/visualizations/disconnect-values.md @@ -8,5 +8,5 @@ Choose whether to set a threshold above which values in the data should be disco {{< figure src="/media/docs/grafana/screenshot-grafana-10-1-disconnect-values.png" max-width="750px" alt="Disconnect values options" >}} -- **Never:** Time series data points in the the data are never disconnected. +- **Never:** Time series data points in the data are never disconnected. - **Threshold:** Specify a threshold above which values in the data are disconnected. This can be useful when desired values in the data are of a known size and/or within a known range, and values outside this range should no longer be connected. diff --git a/docs/sources/tutorials/create-alerts-with-logs/index.md b/docs/sources/tutorials/create-alerts-with-logs/index.md index faaad9a283981..87eaf7bb994e3 100644 --- a/docs/sources/tutorials/create-alerts-with-logs/index.md +++ b/docs/sources/tutorials/create-alerts-with-logs/index.md @@ -31,7 +31,6 @@ In this tutorial, you'll: ## Before you begin -- Ensure you’re on Grafana 8 or later with [Grafana Alerting](https://grafana.com/docs/grafana/latest/alerting/set-up/migrating-alerts/) enabled. - Ensure you’ve [configured a Loki datasource](https://grafana.com/docs/grafana/latest/datasources/loki/#configure-the-data-source) in Grafana. - If you already have logs to work with, you can skip the optional sections and go straight to [create an alert](#create-an-alert). - If you want to use a log-generating sample script to create the logs demonstrated in this tutorial, refer to the optional steps: @@ -199,72 +198,77 @@ This optional step uses a python script to generate the sample logs used in this 1. Install Python3 on your local machine if needed. 1. Copy the python script below and paste it into a new file on your local machine. - ``` - #!/bin/env python3 - ​ - import datetime - import math - import random - import sys - import time - - - ​# Simulation parameters - requests_per_second = 2 - failure_rate = 0.05 - get_post_ratio = 0.9 - get_average_duration_ms = 500 - post_average_duration_ms = 2000 - ​ - ​ - while True: - # Exponential distribution random value of average 1/lines_per_second. - d = random.expovariate(requests_per_second) - time.sleep(d) - if random.random() < failure_rate: - status = "500" - else: - status = "200" - if random.random() < get_post_ratio: - method = "GET" - duration_ms = math.floor(random.expovariate(1/get_average_duration_ms)) - else: - method = "POST" - duration_ms = math.floor(random.expovariate(1/post_average_duration_ms)) - timestamp = datetime.datetime.now(tz=datetime.timezone.utc).isoformat() - print(f"{timestamp} level=info method={method} url=/ status={status} duration={duration_ms}ms") - sys.stdout.flush() - ``` +``` + +#!/bin/env python3 + +import datetime +import math +import random +import sys +import time + + +requests_per_second = 2 +failure_rate = 0.05 +get_post_ratio = 0.9 +get_average_duration_ms = 500 +post_average_duration_ms = 2000 + + +while True: + + # Exponential distribution random value of average 1/lines_per_second. + d = random.expovariate(requests_per_second) + time.sleep(d) + if random.random() < failure_rate: + status = "500" + else: + status = "200" + if random.random() < get_post_ratio: + method = "GET" + duration_ms = math.floor(random.expovariate(1/get_average_duration_ms)) + else: + method = "POST" + duration_ms = math.floor(random.expovariate(1/post_average_duration_ms)) + timestamp = datetime.datetime.now(tz=datetime.timezone.utc).isoformat() + print(f"{timestamp} level=info method={method} url=/ status={status} duration={duration_ms}ms") + sys.stdout.flush() + +``` 1. Give the script executable permissions. - In a terminal window on linux-based systems run the command: +In a terminal window on linux-based systems run the command: - ``` - chmod 755 ./web-server-logs-simulator.py - ``` +``` + +chmod 755 ./web-server-logs-simulator.py + + +``` 1. Run the script. - - Use `tee` to direct the script output to the console and the specified file path. For example, if promtail is - configured to monitor `/var/log` for `.log` files you can direct the script output to `/var/log/web_requests.log` file. +- Use `tee` to direct the script output to the console and the specified file path. For example, if promtail is + configured to monitor `/var/log` for `.log` files you can direct the script output to `/var/log/web_requests.log` file. - - To avoid running the script with elevated permissions, create the log file manually and change the permissions for the output file only. +- To avoid running the script with elevated permissions, create the log file manually and change the permissions for the output file only. - ``` - sudo touch /var/log/web_requests.log - chmod 755 /var/log/web_requests.log - python3 ./web-server-logs-simulator.py | tee -a /var/log/web_requests.log - ``` + ``` + sudo touch /var/log/web_requests.log + chmod 755 /var/log/web_requests.log + python3 ./web-server-logs-simulator.py | tee -a /var/log/web_requests.log + ``` 1. Verify that the logs are showing up in Grafana’s Explore view: - - Navigate to explore in Grafana. - - Select the Loki datasource from the drop-down. - - Check the toggle for **builder | code** in the top right corner of the query box and switch the query mode to builder if it’s not already selected. - - Select the filename label from the drop-down and choose your `web_requests.log` file from the value drop-down. - - Click **Run Query**. - - You should see logs and a graph of log volume. +- Navigate to explore in Grafana. +- Select the Loki datasource from the drop-down. +- Check the toggle for **builder | code** in the top right corner of the query box and switch the query mode to builder if it’s not already selected. +- Select the filename label from the drop-down and choose your `web_requests.log` file from the value drop-down. +- Click **Run Query**. +- You should see logs and a graph of log volume. ### Troubleshooting the script @@ -288,115 +292,135 @@ that generates the sample logs used in this tutorial to create alerts. 1. Start a command line from a directory of your choice. 1. From that directory, get a `docker-compose.yaml` file to run Grafana, Loki, and Promtail: - **Bash** +**Bash** - ``` - wget https://raw.githubusercontent.com/grafana/loki/v2.8.0/production/docker-compose.yaml -O docker-compose.yaml - ``` +``` - **Windows Powershell** +wget https://raw.githubusercontent.com/grafana/loki/v2.8.0/production/docker-compose.yaml -O docker-compose.yaml - ``` - $client = new-object System.Net.WebClient - $client.DownloadFile("https://raw.githubusercontent.com/grafana/loki/v2.8.0/production/docker-compose.yaml", - "C:\Users\$Env:UserName\Desktop\docker-compose.yaml") - #downloads the file to the Desktop - ``` +``` + +**Windows Powershell** + +``` + +$client = new-object System.Net.WebClient +$client.DownloadFile("https://raw.githubusercontent.com/grafana/loki/v2.8.0/production/docker-compose.yaml", +"C:\Users\$Env:UserName\Desktop\docker-compose.yaml") +#downloads the file to the Desktop + +``` 1. Run the container - ``` - docker compose up -d - ``` +``` + +docker compose up -d + +``` 1. Create and edit a python file that will generate logs. - **Bash** +**Bash** - ``` - touch web-server-logs-simulator.py && nano web-server-logs-simulator.py - ``` +``` - **Windows Powershell** +touch web-server-logs-simulator.py && nano web-server-logs-simulator.py - ``` - New-Item web-server-logs-simulator.py ; notepad web-server-logs-simulator.py - ``` +``` + +**Windows Powershell** + +``` + +New-Item web-server-logs-simulator.py ; notepad web-server-logs-simulator.py + +``` 1. Paste the following code into the file - ``` - #!/bin/env python3 - - import datetime - import math - import random - import sys - import time - - - - requests_per_second = 2 - failure_rate = 0.05 - get_post_ratio = 0.9 - get_average_duration_ms = 500 - post_average_duration_ms = 2000 - - - while True: - # Exponential distribution random value of average 1/lines_per_second. - d = random.expovariate(requests_per_second) - time.sleep(d) - if random.random() < failure_rate: - status = "500" - else: - status = "200" - if random.random() < get_post_ratio: - method = "GET" - duration_ms = math.floor(random.expovariate(1/get_average_duration_ms)) - else: - method = "POST" - duration_ms = math.floor(random.expovariate(1/post_average_duration_ms)) - timestamp = datetime.datetime.now(tz=datetime.timezone.utc).isoformat() - print(f"{timestamp} level=info method={method} url=/ status={status} duration={duration_ms}ms") - sys.stdout.flush() - ``` +``` + +#!/bin/env python3 + +import datetime +import math +import random +import sys +import time + + + +requests_per_second = 2 +failure_rate = 0.05 +get_post_ratio = 0.9 +get_average_duration_ms = 500 +post_average_duration_ms = 2000 + + +while True: + + # Exponential distribution random value of average 1/lines_per_second. + d = random.expovariate(requests_per_second) + time.sleep(d) + if random.random() < failure_rate: + status = "500" + else: + status = "200" + if random.random() < get_post_ratio: + method = "GET" + duration_ms = math.floor(random.expovariate(1/get_average_duration_ms)) + else: + method = "POST" + duration_ms = math.floor(random.expovariate(1/post_average_duration_ms)) + timestamp = datetime.datetime.now(tz=datetime.timezone.utc).isoformat() + print(f"{timestamp} level=info method={method} url=/ status={status} duration={duration_ms}ms") + sys.stdout.flush() + +``` 1. Execute the log-generating python script. - In a terminal window on linux-based systems run the command: +In a terminal window on linux-based systems run the command: - ``` - chmod 755 ./web-server-logs-simulator.py - ``` +``` - - Use `tee` to direct the script output to the console and the specified file path. For example, if promtail is - configured to monitor `/var/log` for `.log` files you can direct the script output to `/var/log/web_requests.log` file. +chmod 755 ./web-server-logs-simulator.py - - To avoid running the script with elevated permissions, create the log file manually and change the permissions for the output file only. +``` - ``` - sudo touch /var/log/web_requests.log - chmod 755 /var/log/web_requests.log - python3 ./web-server-logs-simulator.py | tee -a /var/log/web_requests.log - ``` +- Use `tee` to direct the script output to the console and the specified file path. For example, if promtail is + configured to monitor `/var/log` for `.log` files you can direct the script output to `/var/log/web_requests.log` file. - **Running on Windows** +- To avoid running the script with elevated permissions, create the log file manually and change the permissions for the output file only. - Run Powershell as administrator +``` - ``` - python ./web-server-logs-simulator.py | Tee-Object "C:\ProgramFiles\GrafanaLabs\grafana\var\log\web_requests.log" - ``` +sudo touch /var/log/web_requests.log +chmod 755 /var/log/web_requests.log +python3 ./web-server-logs-simulator.py | tee -a /var/log/web_requests.log + + +``` + +**Running on Windows** + +Run Powershell as administrator + +``` + +python ./web-server-logs-simulator.py | Tee-Object "C:\ProgramFiles\GrafanaLabs\grafana\var\log\web_requests.log" + +``` 1. Verify that the logs are showing up in Grafana’s Explore view: - - Navigate to explore in Grafana. - - Select the Loki datasource from the drop-down. - - Check the toggle for **builder | code** in the top right corner of the query box and switch the query mode to builder if it’s not already selected. - - Select the filename label from the drop-down and choose your `web_requests.log` file from the value drop-down. - - Click **Run Query**. - - You should see logs and a graph of log volume. +- Navigate to explore in Grafana. +- Select the Loki datasource from the drop-down. +- Check the toggle for **builder | code** in the top right corner of the query box and switch the query mode to builder if it’s not already selected. +- Select the filename label from the drop-down and choose your `web_requests.log` file from the value drop-down. +- Click **Run Query**. +- You should see logs and a graph of log volume. ### Troubleshooting the script diff --git a/docs/sources/tutorials/grafana-fundamentals/index.md b/docs/sources/tutorials/grafana-fundamentals/index.md index 1aa2197e4ee93..bf01467d3bd67 100644 --- a/docs/sources/tutorials/grafana-fundamentals/index.md +++ b/docs/sources/tutorials/grafana-fundamentals/index.md @@ -27,6 +27,13 @@ In this tutorial, you'll learn how to use Grafana to set up a monitoring solutio - Annotate dashboards - Set up alerts +Alternatively, you can also watch our Grafana for Beginners series where we discuss fundamental concepts to help you get started with Grafana. + +<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden"> + <iframe src="https://www.youtube.com/embed/videoseries?si=ueLa_QEXz20IWnGt&list=PLDGkOdUX1Ujo27m6qiTPPCpFHVfyKq9jT" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen> + </iframe> +</div> + {{% class "prerequisite-section" %}} ### Prerequisites @@ -40,7 +47,9 @@ In this tutorial, you'll learn how to use Grafana to set up a monitoring solutio This tutorial uses a sample application to demonstrate some of the features in Grafana. To complete the exercises in this tutorial, you need to download the files to your local machine. -In this step, you'll set up the sample application, as well as supporting services, such as [Prometheus](https://prometheus.io/) and [Loki](/oss/loki/). +In this step, you'll set up the sample application, as well as supporting services, such as [Loki](/oss/loki/). + +> **Note:** [Prometheus](https://prometheus.io/), a popular time series database (TSDB), has already been configured as a data source as part of this tutorial. 1. Clone the [github.com/grafana/tutorial-environment](https://github.com/grafana/tutorial-environment) repository. @@ -96,47 +105,13 @@ To add a link: To vote for a link, click the triangle icon next to the name of the link. -## Log in to Grafana - -Grafana is an open-source platform for monitoring and observability that lets you visualize and explore the state of your systems. - -1. Open a new tab. -1. Browse to [localhost:3000](http://localhost:3000). -1. In **email or username**, enter **admin**. -1. In **password**, enter **admin**. -1. Click **Log In**. - - The first time you log in, you're asked to change your password: - -1. In **New password**, enter your new password. -1. In **Confirm new password**, enter the same password. -1. Click **Save**. - -The first thing you see is the Home dashboard, which helps you get started. - -In the top left corner, you can see the menu icon. Clicking it will open the _sidebar_, the main menu for navigating Grafana. - -## Add a metrics data source - -The sample application exposes metrics which are stored in [Prometheus](https://prometheus.io/), a popular time series database (TSDB). - -To be able to visualize the metrics from Prometheus, you first need to add it as a data source in Grafana. - -1. In the sidebar, click **Connections** and then **Data sources**. -1. Click **Add data source**. -1. In the list of data sources, click **Prometheus**. -1. In the URL box, enter **http\://prometheus:9090**. -1. Scroll to the bottom of the page and click **Save & test**. - - Prometheus is now available as a data source in Grafana. - ## Explore your metrics Grafana Explore is a workflow for troubleshooting and data exploration. In this step, you'll be using Explore to create ad-hoc queries to understand the metrics exposed by the sample application. > Ad-hoc queries are queries that are made interactively, with the purpose of exploring data. An ad-hoc query is commonly followed by another, more specific query. -1. Click the menu icon and, in the sidebar, click **Explore**. The Prometheus data source that you added will already be selected. +1. Click the menu icon and, in the sidebar, click **Explore**. A dropdown menu for the list of available data sources is on the upper-left side. The Prometheus data source will already be selected. If not, choose Prometheus. 1. Confirm that you're in code mode by checking the **Builder/Code** toggle at the top right corner of the query panel. 1. In the query editor, where it says _Enter a PromQL query…_, enter `tns_request_duration_seconds_count` and then press Shift + Enter. A graph appears. @@ -166,7 +141,7 @@ Grafana Explore is a workflow for troubleshooting and data exploration. In this 1. Back in Grafana, in the upper-right corner, click the _time picker_, and select **Last 5 minutes**. By zooming in on the last few minutes, it's easier to see when you receive new data. -Depending on your use case, you might want to group on other labels. Try grouping by other labels, such as `status_code`, by changing the `by(route)` part of the query. +Depending on your use case, you might want to group on other labels. Try grouping by other labels, such as `status_code`, by changing the `by(route)` part of the query to `by(status_code)`. ## Add a logging data source @@ -178,7 +153,7 @@ Grafana supports log data sources, like [Loki](/oss/loki/). Just like for metric 1. In the URL box, enter [http://loki:3100](http://loki:3100). 1. Scroll to the bottom of the page and click **Save & Test** to save your changes. -Loki is now available as a data source in Grafana. +You should see the message "Data source successfully connected." Loki is now available as a data source in Grafana. ## Explore your logs @@ -237,6 +212,10 @@ Every panel consists of a _query_ and a _visualization_. The query defines _what 1. Click the **Save dashboard** (disk) icon at the top of the dashboard to save your dashboard. 1. Enter a name in the **Dashboard name** field and then click **Save**. + You should now have a panel added to your dashboard. + + {{< figure src="/media/tutorials/grafana-fundamentals-dashboard.png" alt="A panel in a Grafana dashboard" caption="A panel in a Grafana dashboard" >}} + ## Annotate events When things go bad, it often helps if you understand the context in which the failure occurred. Time of last deploy, system changes, or database migration can offer insight into what might have caused an outage. Annotations allow you to represent such events directly on your graphs. @@ -254,11 +233,13 @@ Grafana also lets you annotate a time interval, with _region annotations_. Add a region annotation: -1. Press Ctrl (or Cmd on macOS), then click and drag across the graph to select an area. +1. Press Ctrl (or Cmd on macOS) and hold, then click and drag across the graph to select an area. 1. In **Description**, enter **Performed load tests**. 1. In **Tags**, enter **testing**. 1. Click **Save**. +### Using annotations to correlate logs with metrics + Manually annotating your dashboard is fine for those single events. For regularly occurring events, such as deploying a new release, Grafana supports querying annotations from one of your data sources. Let's create an annotation using the Loki data source we added earlier. 1. At the top of the dashboard, click the **Dashboard settings** (gear) icon. @@ -274,9 +255,13 @@ Manually annotating your dashboard is fine for those single events. For regularl 1. Click **Apply**. Grafana displays the Annotations list, with your new annotation. 1. Click on your dashboard name to return to your dashboard. 1. At the top of your dashboard, there is now a toggle to display the results of the newly created annotation query. Press it if it's not already enabled. +1. Click the **Save dashboard** icon to save the changes. +1. To test the changes, go back to the [sample application](http://localhost:8081), post a new link without a URL to generate an error in your browser that says `empty url`. The log lines returned by your query are now displayed as annotations in the graph. +{{< figure src="/media/tutorials/annotations-grafana-dashboard.png" alt="A panel in a Grafana dashboard with log queries from Loki displayed as annotations" caption="Displaying log queries from Loki as annotations" >}} + Being able to combine data from multiple data sources in one graph allows you to correlate information from both Prometheus and Loki. Annotations also work very well alongside alerts. In the next and final section, we will set up an alert for our app `grafana.news` and then we will trigger it. This will provide a quick intro to our new Alerting platform. @@ -289,7 +274,16 @@ Grafana's new alerting platform debuted with Grafana 8. A year later, with Grafa The most basic alert consists of two parts: -1. A _Contact point_ - A Contact point defines how Grafana delivers an alert. When the conditions of an _alert rule_ are met, Grafana notifies the contact points, or channels, configured for that alert. Some popular channels include email, webhooks, Slack notifications, and PagerDuty notifications. +1. A _Contact point_ - A Contact point defines how Grafana delivers an alert. When the conditions of an _alert rule_ are met, Grafana notifies the contact points, or channels, configured for that alert. + + Some popular channels include: + + - Email + - [Webhooks](#create-a-contact-point-for-grafana-managed-alerts) + - [Telegram](https://grafana.com/blog/2023/12/28/how-to-integrate-grafana-alerting-and-telegram/) + - Slack + - PagerDuty + 1. An _Alert rule_ - An Alert rule defines one or more _conditions_ that Grafana regularly evaluates. When these evaluations meet the rule's criteria, the alert is triggered. To begin, let's set up a webhook contact point. Once we have a usable endpoint, we'll write an alert rule and trigger a notification. @@ -299,12 +293,10 @@ To begin, let's set up a webhook contact point. Once we have a usable endpoint, In this step, we'll set up a new contact point. This contact point will use the _webhooks_ channel. In order to make this work, we also need an endpoint for our webhook channel to receive the alert. We will use [requestbin.com](https://requestbin.com) to quickly set up that test endpoint. This way we can make sure that our alert is actually sending a notification somewhere. 1. Browse to [requestbin.com](https://requestbin.com). -1. Under the **Create Request Bin** button -1. From RequestBin, Copy the endpoint URL. - -Your request bin is now waiting for the first request. +1. Under the **Create Request Bin** button, click the link to create a **public bin** instead. +1. From Request Bin, copy the endpoint URL. -<!-- 1. Copy the endpoint URL. --> +Your Request Bin is now waiting for the first request. Next, let's configure a Contact Point in Grafana's Alerting UI to send notifications to our Request Bin. @@ -314,8 +306,8 @@ Next, let's configure a Contact Point in Grafana's Alerting UI to send notificat 1. In **Integration**, choose **Webhook**. 1. In **URL**, paste the endpoint to your request bin. -1. Click **Test** to send a test alert to your request bin. -1. Navigate back to the request bin you created earlier. On the left side, there's now a `POST /` entry. Click it to see what information Grafana sent. +1. Click **Test**, and then click **Send test notification** to send a test alert to your request bin. +1. Navigate back to the Request Bin you created earlier. On the left side, there's now a `POST /` entry. Click it to see what information Grafana sent. 1. Return to Grafana and click **Save contact point**. We have now created a dummy webhook endpoint and created a new Alerting Contact Point in Grafana. Now we can create an alert rule and link it to this new channel. @@ -329,17 +321,24 @@ Now that Grafana knows how to notify us, it's time to set up an alert rule: 1. For **Section 1**, name the rule `fundamentals-test`. 1. For **Section 2**, Find the **query A** box. Choose your Prometheus datasource. Note that the rule type should automatically switch to Grafana-managed alert. 1. Switch to code mode by checking the Builder/Code toggle. -1. Enter the same query that we used in our earlier panel `sum(rate(tns_request_duration_seconds_count[5m])) by(route)` +1. Enter the same Prometheus query that we used in our earlier panel: + + ``` + sum(rate(tns_request_duration_seconds_count[5m])) by(route) + ``` + 1. Press **Preview**. You should see some data returned. -1. Keep expressions “B” and "C" as they are. These expressions (Reduce and Threshold, respectively) come by default when creating a new rule. Expression "B", selects the last value of our query “A”, while the Threshold expression "C" will check if the last value from expression "B" is above a specific value. In addition, the Threshold expression is the alert condition by default. Enter `0.2` as threshold value [You can read more about queries and conditions here](https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/queries-conditions/#expression-queries). -1. In **Section 3**, in Folder, create a new folder, by typing a name for the folder. This folder will contain our alerts. For example: `fundamentals`. Then, click + add new or hit enter twice. +1. Keep expressions “B” and "C" as they are. These expressions (Reduce and Threshold, respectively) come by default when creating a new rule. Expression "B", selects the last value of our query “A”, while the Threshold expression "C" will check if the last value from expression "B" is above a specific value. In addition, the Threshold expression is the alert condition by default. Enter `0.2` as threshold value. [You can read more about queries and conditions here](https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/queries-conditions/#expression-queries). +1. In **Section 3**, in Folder, create a new folder, by clicking `New folder` and typing a name for the folder. This folder will contain our alerts. For example: `fundamentals`. Then, click `create`. 1. In the Evaluation group, repeat the above step to create a new one. We will name it `fundamentals` too. -1. Choose an Evaluation interval (how often the alert will be evaluated). For example, every `30s` (30 seconds). -1. Set the pending period . This is the time that a condition has to be met until the alert enters in Firing state and a notification is sent. Enter `0s`. For the purposes of this tutorial, the evaluation interval is intentionally short. This makes it easier to test. This setting makes Grafana wait until an alert has fired for a given time before Grafana sends the notification. +1. Choose an Evaluation interval (how often the alert will be evaluated). For example, every `10s` (10 seconds). +1. Set the pending period. This is the time that a condition has to be met until the alert enters in Firing state and a notification is sent. Enter `0s`. For the purposes of this tutorial, the evaluation interval is intentionally short. This makes it easier to test. This setting makes Grafana wait until an alert has fired for a given time before Grafana sends the notification. 1. In **Section 4**, you can optionally add some sample text to your summary message. [Read more about message templating here](/docs/grafana/latest/alerting/unified-alerting/message-templating/). 1. Click **Save rule and exit** at the top of the page. 1. In Grafana's sidebar, navigate to **Notification policies**. -1. Under **Default policy**, select **...** › **Edit** and change the **Default contact point** to **RequestBin**. +1. Under **Default policy**, select **...** › **Edit** and change the **Default contact point** from **grafana-default-email** to **RequestBin**. +1. Expand the **Timing options** dropdown and under **Group wait** and **Group interval** update the value to `30s` for testing purposes. Group wait is the time Grafana waits before sending the first notification for a new group of alerts. In contrast, group interval is the time Grafana waits before sending notifications about changes to the group. +1. Click **Update default policy**. As a system grows, admins can use the **Notification policies** setting to organize and match alert rules to specific contact points. @@ -349,10 +348,30 @@ Now that Grafana knows how to notify us, it's time to set up an alert rule: We have now configured an alert rule and a contact point. Now let's see if we can trigger a Grafana Managed Alert by generating some traffic on our sample application. 1. Browse to [localhost:8081](http://localhost:8081). -1. Repeatedly click the vote button or refresh the page to generate a traffic spike. +1. Add a new title and URL, repeatedly click the vote button, or refresh the page to generate a traffic spike. Once the query `sum(rate(tns_request_duration_seconds_count[5m])) by(route)` returns a value greater than `0.2` Grafana will trigger our alert. Browse to the Request Bin we created earlier and find the sent Grafana alert notification with details and metadata. +### Display Grafana Alerts to your dashboard + +In most cases, it's also valuable to display Grafana Alerts as annotations to your dashboard. Check out the video tutorial below to learn how to display alerting to your dashboard. + +{{< youtube id="ClLp-iSoaSY" >}} + +Let's see how we can configure this. + +1. In Grafana's sidebar, hover over the **Alerting** (bell) icon and then click **Alert rules**. +1. Expand the `fundamentals > fundamentals` folder to view our created alert rule. +1. Click the **Edit** icon and scroll down to **Section 4**. +1. Click the **Link dashboard and panel** button and select the dashboard and panel to which you want the alert to be added as an annotation. +1. Click **Confirm** and **Save rule and exit** to save all the changes. +1. In Grafana's sidebar, navigate to the dashboard by clicking **Dashboards** and selecting the dashboard you created. +1. To test the changes, follow the steps listed to [trigger a Grafana Managed Alert](#trigger-a-grafana-managed-alert). + + You should now see a red, broken heart icon beside the panel name, signifying that the alert has been triggered. An annotation for the alert, represented as a vertical red line, is also displayed. + + {{< figure src="/media/tutorials/grafana-alert-on-dashboard.png" alt="A panel in a Grafana dashboard with alerting and annotations configured" caption="Displaying Grafana Alerts on a dashboard" >}} + ## Summary In this tutorial you learned about fundamental features of Grafana. To do so, we ran several Docker containers on your local machine. When you are ready to clean up this local tutorial environment, run the following command: diff --git a/docs/sources/tutorials/install-grafana-on-raspberry-pi/index.md b/docs/sources/tutorials/install-grafana-on-raspberry-pi/index.md index 2bc96509a6499..90ee346133142 100644 --- a/docs/sources/tutorials/install-grafana-on-raspberry-pi/index.md +++ b/docs/sources/tutorials/install-grafana-on-raspberry-pi/index.md @@ -51,7 +51,7 @@ Follow the directions on the website to download and install the imager. #### Install Raspberry Pi OS -Now it is time to install Raspberry Pi OS. +Now it's time to install Raspberry Pi OS. 1. Insert the SD card into your regular computer from which you plan to install Raspberry Pi OS. 1. Run the Raspberry Pi Imager that you downloaded and installed. @@ -69,7 +69,7 @@ While you _could_ fire up the Raspberry Pi now, we don't yet have any way of acc 1. **(Optional)** Create a file called `wpa_supplicant.conf` in the boot directory: - ``` + ```bash ctrl_interface=/var/run/wpa_supplicant update_config=1 country=<Insert 2 letter ISO 3166-1 country code here> @@ -89,13 +89,16 @@ All the necessary files are now on the SD card. Let's start up the Raspberry Pi. #### Connect remotely via SSH 1. Open up your terminal and enter the following command: - ``` + + ```bash ssh pi@<ip address> ``` + 1. SSH warns you that the authenticity of the host can't be established. Type "yes" to continue connecting. 1. When asked for a password, enter the default password: `raspberry`. 1. Once you're logged in, change the default password: - ``` + + ```bash passwd ``` @@ -107,18 +110,20 @@ Now that you've got the Raspberry Pi up and running, the next step is to install 1. Add the APT key used to authenticate packages: - ``` - wget -q -O - https://packages.grafana.com/gpg.key | sudo apt-key add - + ```bash + sudo mkdir -p /etc/apt/keyrings/ + wget -q -O - https://apt.grafana.com/gpg.key | gpg --dearmor | sudo tee /etc/apt/keyrings/grafana.gpg > /dev/null ``` 1. Add the Grafana APT repository: - ``` - echo "deb https://packages.grafana.com/oss/deb stable main" | sudo tee -a /etc/apt/sources.list.d/grafana.list + ```bash + echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" | sudo tee /etc/apt/sources.list.d/grafana.list ``` 1. Install Grafana: - ``` + + ```bash sudo apt-get update sudo apt-get install -y grafana ``` @@ -127,13 +132,13 @@ Grafana is now installed, but not yet running. To make sure Grafana starts up ev 1. Enable the Grafana server: - ``` + ```bash sudo /bin/systemctl enable grafana-server ``` 1. Start the Grafana server: - ``` + ```bash sudo /bin/systemctl start grafana-server ``` @@ -143,7 +148,7 @@ Grafana is now installed, but not yet running. To make sure Grafana starts up ev 1. Log in to Grafana with the default username `admin`, and the default password `admin`. 1. Change the password for the admin user when asked. -Congratulations! Grafana is now running on your Raspberry Pi. If the Raspberry Pi is ever restarted or turned off, Grafana will start up whenever the machine regains power. +Congratulations, Grafana is now running on your Raspberry Pi. If the Raspberry Pi is ever restarted or turned off, Grafana will start up whenever the machine regains power. ## Summary diff --git a/docs/sources/tutorials/provision-dashboards-and-data-sources/index.md b/docs/sources/tutorials/provision-dashboards-and-data-sources/index.md index 89de23a0556b0..5e03387a8dda4 100644 --- a/docs/sources/tutorials/provision-dashboards-and-data-sources/index.md +++ b/docs/sources/tutorials/provision-dashboards-and-data-sources/index.md @@ -44,7 +44,6 @@ Grafana supports configuration as code through _provisioning_. The resources tha - [Dashboards](/docs/grafana/latest/administration/provisioning/#dashboards) - [Data sources](/docs/grafana/latest/administration/provisioning/#datasources) -- [Alert notification channels](/docs/grafana/latest/administration/provisioning/#alert-notification-channels) ## Set the provisioning directory @@ -69,8 +68,6 @@ provisioning/ <yaml files> dashboards/ <yaml files> - notifiers/ - <yaml files> ``` Next, we'll look at how to provision a data source. diff --git a/docs/sources/tutorials/run-grafana-behind-a-proxy/index.md b/docs/sources/tutorials/run-grafana-behind-a-proxy/index.md index 1c91e80579a2b..10008a7c04f95 100644 --- a/docs/sources/tutorials/run-grafana-behind-a-proxy/index.md +++ b/docs/sources/tutorials/run-grafana-behind-a-proxy/index.md @@ -34,23 +34,9 @@ domain = example.com - Restart Grafana for the new changes to take effect. -You can also serve Grafana behind a _sub path_, such as `http://example.com/grafana`. +## Configure reverse proxy -To serve Grafana behind a sub path: - -- Include the sub path at the end of the `root_url`. -- Set `serve_from_sub_path` to `true`. - -```bash -[server] -domain = example.com -root_url = %(protocol)s://%(domain)s:%(http_port)s/grafana/ -serve_from_sub_path = true -``` - -Next, you need to configure your reverse proxy. - -## Configure NGINX +### Configure NGINX [NGINX](https://www.nginx.com) is a high performance load balancer, web server, and reverse proxy. @@ -129,7 +115,7 @@ server { } ``` -If your Grafana configuration does not set `serve_from_sub_path` to true then you need to add a rewrite rule to each location block: +Add a rewrite rule to each location block: ``` rewrite ^/grafana/(.*) /$1 break; @@ -139,7 +125,7 @@ If your Grafana configuration does not set `serve_from_sub_path` to true then yo If Grafana is being served from behind a NGINX proxy with TLS termination enabled, then the `root_url` should be set accordingly. For example, if Grafana is being served from `https://example.com/grafana` then the `root_url` should be set to `https://example.com/grafana/` or `https://%(domain)s/grafana/` (and the corresponding `domain` should be set to `example.com`) in the `server` section of the Grafana configuration file. The `protocol` setting should be set to `http`, because the TLS handshake is being handled by NGINX. {{% /admonition %}} -## Configure HAProxy +### Configure HAProxy To configure HAProxy to serve Grafana under a _sub path_: @@ -149,16 +135,16 @@ frontend http-in use_backend grafana_backend if { path /grafana } or { path_beg /grafana/ } backend grafana_backend + server grafana localhost:3000 # Requires haproxy >= 1.6 http-request set-path %[path,regsub(^/grafana/?,/)] - # Works for haproxy < 1.6 # reqrep ^([^\ ]*\ /)grafana[/]?(.*) \1\2 server grafana localhost:3000 ``` -## Configure IIS +### Configure IIS > IIS requires that the URL Rewrite module is installed. @@ -185,7 +171,7 @@ This is the rewrite rule that is generated in the `web.config`: See the [tutorial on IIS URL Rewrites](/tutorials/iis/) for more in-depth instructions. -## Configure Traefik +### Configure Traefik [Traefik](https://traefik.io/traefik/) Cloud Native Reverse Proxy / Load Balancer / Edge Router @@ -233,6 +219,18 @@ http: - url: http://192.168.30.10:3000 ``` -## Summary +## Alternative for serving Grafana under a sub path + +**Warning:** You only need this, if you do not handle the sub path serving via your reverse proxy configuration. + +If you don't want or can't use the reverse proxy to handle serving Grafana from a _sub path_, you can set the config variable `server_from_sub_path` to `true`. -In this tutorial you learned how to run Grafana behind a reverse proxy. +1. Include the sub path at the end of the `root_url`. +2. Set `serve_from_sub_path` to `true`: + +```bash +[server] +domain = example.com +root_url = %(protocol)s://%(domain)s:%(http_port)s/grafana/ +serve_from_sub_path = true +``` diff --git a/docs/sources/upgrade-guide/upgrade-v10.3/index.md b/docs/sources/upgrade-guide/upgrade-v10.3/index.md new file mode 100644 index 0000000000000..b3db718bb144c --- /dev/null +++ b/docs/sources/upgrade-guide/upgrade-v10.3/index.md @@ -0,0 +1,23 @@ +--- +description: Guide for upgrading to Grafana v10.3 +keywords: + - grafana + - configuration + - documentation + - upgrade + - '10.3' + - '10.2.3' +title: Upgrade to Grafana v10.3 +menuTitle: Upgrade to v10.3 +weight: 1400 +--- + +# Upgrade to Grafana v10.3 + +{{< docs/shared lookup="upgrade/intro.md" source="grafana" version="<GRAFANA VERSION>" >}} + +{{< docs/shared lookup="back-up/back-up-grafana.md" source="grafana" version="<GRAFANA VERSION>" leveloffset="+1" >}} + +{{< docs/shared lookup="upgrade/upgrade-common-tasks.md" source="grafana" version="<GRAFANA VERSION>" >}} + +## Technical notes diff --git a/docs/sources/upgrade-guide/upgrade-v10.4/index.md b/docs/sources/upgrade-guide/upgrade-v10.4/index.md new file mode 100644 index 0000000000000..678641ec4cc42 --- /dev/null +++ b/docs/sources/upgrade-guide/upgrade-v10.4/index.md @@ -0,0 +1,37 @@ +--- +description: Guide for upgrading to Grafana v10.4 +keywords: + - grafana + - configuration + - documentation + - upgrade + - '10.4' +title: Upgrade to Grafana v10.4 +menuTitle: Upgrade to v10.4 +weight: 1300 +--- + +# Upgrade to Grafana v10.4 + +{{< docs/shared lookup="upgrade/intro.md" source="grafana" version="<GRAFANA VERSION>" >}} + +{{< docs/shared lookup="back-up/back-up-grafana.md" source="grafana" version="<GRAFANA VERSION>" leveloffset="+1" >}} + +{{< docs/shared lookup="upgrade/upgrade-common-tasks.md" source="grafana" version="<GRAFANA VERSION>" >}} + +## Technical notes + +### Legacy alerting -> Grafana Alerting dry-run on start + +If you haven't already upgraded to Grafana Alerting from legacy Alerting, Grafana will initiate a dry-run of the upgrade every time the instance starts. This is in preparation for the removal of legacy Alerting in Grafana v11. The dry-run logs the results of the upgrade attempt and identifies any issues requiring attention before you can successfully execute the upgrade. No changes are made during the dry-run. + +You can disable this behavior using the feature flag `alertingUpgradeDryrunOnStart`: + +```toml +[feature_toggles] +alertingUpgradeDryrunOnStart=false +``` + +{{% admonition type="note" %}} +We strongly encourage you to review the [upgrade guide](https://grafana.com/docs/grafana/v10.4/alerting/set-up/migrating-alerts/) and perform the necessary upgrade steps prior to v11. +{{% /admonition %}} diff --git a/docs/sources/whatsnew/_index.md b/docs/sources/whatsnew/_index.md index 94f81981907a9..5e7be79bb36da 100644 --- a/docs/sources/whatsnew/_index.md +++ b/docs/sources/whatsnew/_index.md @@ -76,6 +76,8 @@ For a complete list of every change, with links to pull requests and related iss ## Grafana 10 +- [What's new in 10.4](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/whatsnew/whats-new-in-v10-4/) +- [What's new in 10.3](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/whatsnew/whats-new-in-v10-3/) - [What's new in 10.2](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/whatsnew/whats-new-in-v10-2/) - [What's new in 10.1]({{< relref "whats-new-in-v10-1/" >}}) - [What's new in 10.0]({{< relref "whats-new-in-v10-0/" >}}) diff --git a/docs/sources/whatsnew/whats-new-in-v10-0.md b/docs/sources/whatsnew/whats-new-in-v10-0.md index 8fd36a5bca36c..df622eac4025e 100644 --- a/docs/sources/whatsnew/whats-new-in-v10-0.md +++ b/docs/sources/whatsnew/whats-new-in-v10-0.md @@ -19,7 +19,7 @@ weight: -37 Welcome to Grafana 10.0! Read on to learn about changes to search and navigation, dashboards and visualizations, and security and authentication. -For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). For the specific steps we recommend when you upgrade to v10.0, check out our [Upgrade Guide]({{< relref "../upgrade-guide/upgrade-v10.0/index.md" >}}). +For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). For the specific steps we recommend when you upgrade to v10.0, check out our [Upgrade Guide]({{< relref "../upgrade-guide/upgrade-v10.0/index.md" >}}). <!-- Template below ## Feature @@ -418,3 +418,9 @@ Some data sources, like MySQL databases, Prometheus instances or Elasticsearch c To query these data sources from Grafana Cloud, you've had to open your private network to a range of IP addresses, a non-starter for many IT Security teams. The challenge is, how do you connect to your private data from Grafana Cloud, without exposing your network? The answer is Private Data Source Connect (PDC), available now in public preview in Grafana Cloud Pro and Advanced. PDC uses SOCKS over SSH to establish a secure connection between a lightweight PDC agent you deploy on your network and your Grafana Cloud stack. PDC keeps the network connection totally under your control. It’s easy to set up and manage, uses industry-standard security protocols, and works across public cloud vendors and a wide variety of secure networks. Learn more in our [Private data source connect documentation](/docs/grafana-cloud/data-configuration/configure-private-datasource-connect). + +## Plugins + +### App plugins can start using react-router v6 + +We've added support for using `react-router` v6 in app plugins. However, we still support the use of `react-router` v5 for plugins that need to support a minimum Grafana version earlier than v10. For more information, refer to our [react-router migration guide](https://grafana.com/developers/plugin-tools/migration-guides/update-from-grafana-versions/migrate-9_x-to-10_x#update-to-react-router-v6). diff --git a/docs/sources/whatsnew/whats-new-in-v10-1.md b/docs/sources/whatsnew/whats-new-in-v10-1.md index da051b687e58a..5e007c5250641 100644 --- a/docs/sources/whatsnew/whats-new-in-v10-1.md +++ b/docs/sources/whatsnew/whats-new-in-v10-1.md @@ -19,7 +19,7 @@ weight: -38 Welcome to Grafana 10.1! Read on to learn about changes to dashboards and visualizations, data sources, security and authentication and more. We're particularly excited about a set of improvements to visualizing logs from [Loki](https://grafana.com/products/cloud/logs/) and other logging data sources in Explore mode, and our Flame graph panel, used to visualize profiling data from [Pyroscope](https://grafana.com/blog/2023/03/15/pyroscope-grafana-phlare-join-for-oss-continuous-profiling/?pg=oss-phlare&plcmt=top-promo-banner) and other continuous profiling data sources. -For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). For the specific steps we recommend when you upgrade to v10.1, check out our [Upgrade Guide]({{< relref "../upgrade-guide/upgrade-v10.1/index.md" >}}). +For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). For the specific steps we recommend when you upgrade to v10.1, check out our [Upgrade Guide]({{< relref "../upgrade-guide/upgrade-v10.1/index.md" >}}). <!-- Template below ## Feature diff --git a/docs/sources/whatsnew/whats-new-in-v10-2.md b/docs/sources/whatsnew/whats-new-in-v10-2.md index 51ebe726deb53..02d1ddbbdf7ee 100644 --- a/docs/sources/whatsnew/whats-new-in-v10-2.md +++ b/docs/sources/whatsnew/whats-new-in-v10-2.md @@ -19,7 +19,7 @@ weight: -39 Welcome to Grafana 10.2! Read on to learn about changes to dashboards and visualizations, data sources, security and authentication, and more. We’re particularly excited about the addition of generative AI features for dashboards, a new kind of basic role, and improvements to visualization transformations. -For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). For the specific steps we recommend when you upgrade to v10.2, check out our [Upgrade Guide](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/upgrade-guide/upgrade-v10.2/). +For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). For the specific steps we recommend when you upgrade to v10.2, check out our [Upgrade Guide](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/upgrade-guide/upgrade-v10.2/). <!-- Template below @@ -100,7 +100,7 @@ You can now use generative AI to assist you in your Grafana dashboards. So far g - **Generate panel and dashboard titles and descriptions** - You can now generate a title and description for your panel or dashboard based on the data you've added to it. This is useful when you want to quickly visualize your data and don't want to spend time coming up with a title or description. - **Generate dashboard save changes summary** - You can now generate a summary of the changes you've made to a dashboard when you save it. This is great for effortlessly tracking the history of a dashboard. -To enable these features, you must first enable the `dashgpt` [feature toggle](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/feature-toggles/#experimental-feature-toggles). Then install and configure Grafana's LLM app plugin. For more information, refer to the [Grafana LLM app plugin documentation](https://grafana.com/docs/grafana-cloud/alerting-and-irm/machine-learning/llm-plugin/). +To enable these features, you must first enable the `dashgpt` [feature toggle](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/feature-toggles/#experimental-feature-toggles). Then install and configure Grafana's LLM app plugin. For more information, refer to the [Grafana LLM app plugin documentation](https://grafana.com/docs/grafana-cloud/alerting-and-irm/machine-learning/configure/llm-plugin/). When enabled, look for the **✨ Auto generate** option next to the **Title** and **Description** fields in your panels and dashboards, or when you press the **Save** button. diff --git a/docs/sources/whatsnew/whats-new-in-v10-3.md b/docs/sources/whatsnew/whats-new-in-v10-3.md new file mode 100644 index 0000000000000..329f1e5ffedb4 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v10-3.md @@ -0,0 +1,410 @@ +--- +description: Feature and improvement highlights for Grafana v10.3 +keywords: + - grafana + - new + - documentation + - '10.3' + - '10.2.3' + - release notes +labels: +products: + - cloud + - enterprise + - oss +title: What's new in Grafana v10.3 +weight: -40 +--- + +# What’s new in Grafana v10.3 + +Welcome to Grafana 10.3! Read on to learn about changes to navigation, visualizations and transformations, alerting, profiling, and logs. + +We've also included here features released in Grafana 10.2.3, as well as breaking changes from that release. Features that were included in the 10.2.3 release are marked with an asterisk. + +For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). For the specific steps we recommend when you upgrade to v10.3, check out our [Upgrade Guide](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/upgrade-guide/upgrade-v10.3/). + +## Breaking changes + +For Grafana v10.3, we've also provided a list of [breaking changes](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/breaking-changes/breaking-changes-v10-3) to help you upgrade with greater confidence. For information about these along with guidance on how to proceed, refer to [Breaking changes in Grafana v10.3](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/breaking-changes/breaking-changes-v10-3/). + +<!-- Template below + +## Feature +<!-- Name of contributor --> +<!--_[Generally available | Available in private/public preview | Experimental] in Grafana [Open Source, Enterprise, all editions of Grafana, some combination of self-managed and Cloud]_ +Description. Include an overview of the feature and problem it solves, and where to learn more (like a link to the docs). +{{% admonition type="note" %}} +Use full URLs for links. When linking to versioned docs, replace the version with the version interpolation placeholder (for example, <GRAFANA_VERSION>, <TEMPO_VERSION>, <MIMIR_VERSION>) so the system can determine the correct set of docs to point to. For example, "https://grafana.com/docs/grafana/latest/administration/" becomes "https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/". +{{% /admonition %}} + +<!--Add an image, GIF or video as below--> + +<!--{{< figure src="/media/docs/grafana/dashboards/WidgetVizSplit.png" max-width="750px" caption="DESCRIPTIVE CAPTION" >}} + +<!--Learn how to upload images here: https://grafana.com/docs/writers-toolkit/write/image-guidelines/#where-to-store-media-assets--> +<!----> + +## Navigation updates\* + +<!--Laura Benz--> + +_Available in public preview in Grafana Open Source and Enterprise_ + +The improved navigation menu gives you a better overview by showing all levels of navigation items in a more compact design. We also implemented a better dock and improved scrolling behavior. Furthermore, we improved the structure of the nav menu and added several new items. + +{{< youtube id="IhpghtVykLc" >}} + +## Table data in PDF reports + +<!--Agnès Toulet--> + +_Available in public preview in Grafana Enterprise and Grafana Cloud_ + +We've improved the reporting experience with options to make all of your table data accessible in PDFs. Previously, if your dashboard included large table visualizations, you couldn't see all of the table data in your PDF report. Unlike in Grafana, you couldn't scroll in the PDF table visualization or click on the page numbers. With this new feature, you now have the option to see all the data directly in your PDF without losing your dashboard layout. + +We've added two format options to the report creation form: + +- **Include table data as PDF appendix** - Adds an appendix to your dashboard PDF. +- **Attach a separate PDF of table data** - Generates a separate PDF file for your table panel data. + +To try out this feature, enable the `pdfTables` [feature toggle](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/feature-toggles/) or contact Grafana Support to have it enabled in on your Grafana Cloud stack. + +{{< youtube id="1fzQQI8O838" >}} + +## Dashboards and visualizations + +### Moving average and trend lines using transformations + +<!--Oscar Kilhed--> + +_Available in public preview in all editions of Grafana_ + +#### Moving average\* + +Sometimes your data is too noisy to quickly grasp what's going on. A common way to address this issue is to calculate the moving mean, or moving average, to filter out some of that noise. Luckily, many data sources already support calculating the moving mean, but when the support is lacking or you're not well versed in the query language, until now, you were stuck with the noise. + +{{< figure src="/media/docs/grafana/transformations/noisy-sensor-data.png" caption="Noisy data can hide the general trend of your data." alt="Graph displaying noisy sensor data" max-width="300px" >}} + +By selecting the **Window functions** mode and using **Mean** as the calculation for the **Add field from calculation** transformation, Grafana adds a field with the moving mean for your selected field. + +{{< figure src="/media/docs/grafana/transformations/noisy-sensor-data-moving-average.png" caption="Calculating the moving mean of your data will make it easier to grasp what's going on." alt="Graph displaying the moving mean of noisy data" >}} + +The **Window functions** mode also supports moving variance and moving standard deviation calculations if you need to analyze the volatility of your metric. + +[Documentation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/transform-data/#add-field-from-calculation) + +#### Trend lines\* + +We're also adding some basic statistical analysis features as a way to help you visualize trends in your data. The **Regression analysis** transformation will fit a mathematical function to your data and display it as predicted data points in a separate data frame. + +{{< figure src="/media/docs/grafana/transformations/trendlines.png" caption="Linear and polynomial regression trendlines" alt="Graph with trendlines" >}} + +The transformation currently supports linear regression and polynomial regression to the fifth-degree. + +### Canvas visualization supports pan and zoom + +<!--Nathan Marrs--> + +_Available in public preview in all editions of Grafana_ + +Canvas visualizations now support panning and zooming. This allows you to both create and navigate more complex designs. + +To enable this feature, you must first enable the `canvasPanelPanZoom` [feature toggle](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/feature-toggles/). + +{{< youtube id="CF-HFkcytRA" >}} + +[Documentation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/canvas/) + +### Improved tooltips in visualizations\* + +<!--Nathan Marrs--> + +_Available in public preview in all editions of Grafana_ + +We've introduced enhanced tooltips as part of our standardization initiative, unifying the tooltip architecture for a consistent user experience across panels. Packed with features like color indicators, time uniformity, and improved support for long labels, these tooltips go beyond a cosmetic redesign, bringing fundamental changes to elevate your data visualization experience. Stay tuned for more updates! + +To try out the new tooltips, enable the `newVizTooltips` [feature toggle](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/feature-toggles/). Enhanced tooltips have been implemented for the following visualizations: + +- Time series +- Trend +- Heatmap +- Status history +- Candlestick +- State timeline +- XY Chart +- and more coming soon! + +{{% admonition type="note" %}} +As this is an ongoing project, the dashboard shared cursor and annotations features are not yet fully supported. +{{% /admonition %}} + +{{< youtube id="0Rp6FYfHu6Q" >}} + +### Plot enum values in your time series and state timeline visualizations\* + +<!--Nathan Marrs--> + +_Generally available in all editions of Grafana_ + +You can now plot enum values in your time series and state timeline visualizations. This feature is useful when you want to visualize the state of a system, such as the status of a service or the health of a device. For example, you can use this feature to visualize the status of a service as `ON`, `STANDBY`, or `OFF`. To display enum values you can [use the convert field transform](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/transform-data/#convert-field-type). + +{{< youtube id="FG0hBFfgpps" >}} + +### View percent change in stat visualizations + +<!--Nathan Marrs--> + +_Generally available in all editions of Grafana_ + +You can now view percent change in stat visualizations. This makes it easier to understand your data by showing how metrics are changing over time. + +{{< youtube id="mB9FU0myZo8" >}} + +[Documentation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/stat/#show-percent-change) + +#### Apply data transformations to annotations + +<!--Nathan Marrs--> + +_Generally available in all editions of Grafana_ + +You can now apply data transformations to annotation data. For example, you can now configure how exemplar data is displayed in tooltips. + +{{< video-embed src="/media/docs/grafana/screen-recording-10-3-data-transformations-annotation-support.mp4" caption="Configure how exemplar data appears in tooltip" >}} + +### New Transformations UI experience and documentation upgrades + +<!--Jev Forsberg--> + +_Generally available in all editions of Grafana_ + +We've revamped the Transformations user interface to make it cleaner, more user-friendly, and overall better for visualizing, selecting, and comprehending transformation options for your data. + +#### Improved UI\* + +In the past, transformations were applied through a dropdown menu, indicated solely by text names like Merge, Sort, JoinByLabels, etc. Now, we've introduced a much more user-friendly interface. A convenient drawer allows seamless access to all transformation options, each accompanied by visual/graphical representations and a brief description. These enhancements are designed to enhance the user's comprehension of their data transformation choices. + +{{< figure src="/media/docs/grafana/transformations/transformations_ui_drawer_selector.png" caption="The new Transformation UI drawer" alt="Transformation UI drawer" >}} + +#### In-App documentation + +We've also streamlined the user experience by integrating documentation directly into the core Grafana application. Gone are the days of navigating to a separate browser page for Transformation documentation. Now, users can conveniently access documentation within the app interface, providing a more seamless and efficient way to understand and utilize various features. This enhancement aims to save time and enhance user convenience, ensuring that valuable information is readily available at their fingertips. + +{{< figure src="/media/docs/grafana/transformations/transformations_internal_documentation.png" caption="Transformation documentation is now internally available inside the Grafana app itself." alt="Transformation documentation internally available" >}} + +### Copy and paste time range + +<!--Haris Rozajac--> + +_Generally available in all editions of Grafana_ + +Copying and pasting time range in the time range picker is now available. For example, you can copy a time range in **Explore** and paste it into **Dashboards** and vice versa. You can also copy and paste a time range using the new keyboard shortcuts `t+c` and `t+v`, respectively. + +## Profiles + +### Trace to Profiles\* + +<!--Joey Tawadrous--> + +_Experimental in all editions of Grafana_ + +Using Trace to profiles, you can use Grafana’s ability to correlate different signals by adding the functionality to link between traces and profiles. + +**Trace to profiles** lets you link your Grafana Pyroscope data source to tracing data. When configured, this connection lets you run queries from a trace span into the profile data. + +There are two ways to configure the trace to profiles feature: + +- Use a simplified configuration with default query, or +- Configure a custom query where you can use a template language to interpolate variables from the trace or span. + +{{< figure src="/static/img/docs/tempo/profiles/tempo-trace-to-profile.png" caption="Trace to profiles screenshot" alt="Trace to profiles screenshot" >}} + +To try out **Trace to profiles**, enable the 'traceToProfiles' feature toggle. + +If you would also like to try out the **Embedded Flame Graph** feature, please enable the 'tracesEmbeddedFlameGraph' feature toggle. + +Note: in order to determine that there is a profile for a given span and render the 'Profiles for this span' button or the embedded flame graph in the span details, the 'pyroscope.profile.id' key-value pair must exist in your span tags. + +{{< youtube id="AG8VzfFMLxo" >}} + +[Documentation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/tempo/configure-tempo-data-source/#trace-to-profiles) + +### FlameGraph: Collapsing similar items in the graph\* + +<!--Andrej Ocenas--> + +_Experimental in all editions of Grafana_ + +Sometimes profile stacks contain lots of levels with similar repeating items, for example long stacks of framework code that usually isn't of interest but takes up a lot of visual real estate. With this feature, instead of rendering all of the similar items we render only one and allow to expand those collapsed items on demand. + +To try it out, enable the ‘traceToProfiles’ feature toggle. To enable it in your Grafana Cloud stack, contact Grafana Support. + +{{< youtube id="Y1c32Cf5nSE" >}} + +## Alerting + +### Alerting insights\* + +<!-- George Robinson --> + +_Generally available in all editions of Grafana_ + +Use Alerting insights to monitor your alerting data, discover key trends about your organization’s alert management performance, and find patterns in why things go wrong. + +### Export alerting resources to Terraform + +<!-- Yuri Tseretyan--> + +_Generally available in all editions of Grafana_ + +Export your alerting resources, such as alert rules, contact points, and notification policies as Terraform resources. A new “Modify export” mode for alert rules enables you to edit provisioned alert rules and export a modified version. + +### Contact points list view redesign + +<!-- Brenda Muir --> + +_Generally available in all editions of Grafana_ + +The Contact points list view has been redesigned and split into two tabs: Contact Points and Notification Templates, making it easier to view all contact point information at a glance. You can now search for name and type of contact points and integrations, view how many notification policies each contact point is being used for, and navigate directly to the linked notification policies. + +{{< youtube id="_eOhSmbYK8Q" >}} + +### Create alerts from panels\* + +<!-- Brenda Muir --> + +_Generally available in all editions of Grafana_ + +Create alerts from dashboard panels. You can reuse the panel queries and create alerts based on them. + +### Support for adding responders to Opsgenie alerting contact point\* + +<!--Ryan Kehoe--> + +_Generally available in all editions of Grafana_ + +The Opsgenie contact point has been extended to allow users to optionally fill out responder information for their integration. Responders tell Opsgenie who an alert should notify according to their escalation policies and routing rules. + +### Recovery thresholds for alerts + +<!--Ryan Kehoe--> + +_Generally available in all editions of Grafana_ + +To reduce the noise of flapping alerts, you can set a recovery threshold different to the alert threshold. + +Flapping alerts occur when a metric hovers around the alert threshold condition and may lead to frequent state changes, resulting in too many notifications being generated. + +## Logs + +### Logs Table UI + +<!--Galen Kistler--> + +_Available in public preview in all editions of Grafana_ + +Table view was created to help facilitate ease of use in a point and click UI, as opposed to datasource specific query language formatting options, like loki's line_format. + +Tables can be configured and shared with team members via explore URLs or by adding the table to a dashboard panel. + +{{< youtube id="OAZeqqNpEjc" >}} + +## Data sources + +### Data source Admin permission\* + +<!--Ieva Vasiljeva--> + +_Generally available in Grafana Enterprise and Grafana Cloud_ + +In addition to `Query` and `Edit` access, you can now grant users, teams, or basic roles `Admin` access to data sources. Users with `Admin` access to a data source can grant and revoke permissions to the data source, as well as to manage query caching settings for the data source. Users are automatically granted `Admin` access to data sources that they create. + +### Redshift and Athena: Async query caching + +<!--Isabella Siu--> + +_Generally available in Grafana Enterprise, Grafana Cloud Advanced and Cloud Pro_ + +Introducing query caching for async queries in the Athena and Redshift data source plugins. We previously introduced async queries for the Athena and Redshift plugins, and this feature adds support for caching those queries. To use this, you must have query caching enabled for the Athena or Redshift data source you wish to cache. This feature was previously available behind a feature toggle and is now generally available and enabled by default. + +{{% admonition type="note" %}} + +The `useCachingService` feature toggle must also be enabled to use this feature. + +{{% /admonition %}} + +### Loki data source improvements: "or" filter syntax, filter by label types, derived fields by labels + +<!--Sven Grossmann--> +<!--enablement videos to come?--> + +_Generally available in all editions of Grafana_ + +Introducing several improvements to the Loki data source. + +{{< youtube id="ievPSzmCrAk" >}} + +#### Line filter "or" syntax\* + +Loki's line filter syntax is great to find specific substrings of your log lines. If users want to find multiple different substrings it was cumbersome to use the regex `=~` operator. With this change it is possible to chain multiple strings with the existing filter operators. + +Example: + +``` +{app="foo"} |= "foo" or "bar" != "baz" or "qux" +``` + +#### Filter based on label type\* + +Grafana users can use the action buttons in the log details to filter for specific labels. Those would be always added as a LabelFilter expression regardless of the type of the label. Now, filtered labels will be added either to the stream selector if the label is an indexed label, or as a LabelFilter expression if the label is a parsed label or part of structured metadata. + +#### Derived fields based on labels\* + +Derived fields or data links are a concept to add correlations based on your log lines. Previously it was only possible to add derived fields based on a regular expression of your log line and doing it based on labels was not possible. With this change derived fields can be added either based on a regex of a log line or based on a label, parsed label or structured metadata. + +The following example would add the derived field `traceID regex` based on a regular expression and another `app label` field based on the `app` label. + +{{< figure src="/media/docs/grafana/2024-01-05_loki-derived-fields.png" alt="Derived fields added based on a regular expression and an app label">}} + +### InfluxDB native SQL support + +<!--Ismail Simsek--> + +_Generally available in all editions of Grafana_ + +InfluxDB introduced [a new version, 3.0](https://www.influxdata.com/blog/introducing-influxdb-3-0/), in April. With this new version, InfluxDB has put [Flux in maintenance mode](https://www.influxdata.com/blog/the-plan-for-influxdb-3-0-open-source/#heading4). But with the new version we have a new querying language, [Native SQL](https://www.influxdata.com/products/sql/). With v10.3.0, Grafana has built-in support for SQL query language in InfluxDB. + +All you need to do is set up your InfluxDB Cloud Account and create your InfluxDB data source on Grafana with the query language "SQL" selected. + +{{< youtube id="jGclGsv5PBA" >}} + +## Authentication and authorization + +### Grafana Anonymous Access\* + +<!--Eric Leijonmarck--> + +_Generally available in Grafana Open Source and Enterprise_ + +We've identified a need for users who enable anonymous authentication to monitor the anonymous devices connected to their Grafana instance. This feature is part of our ongoing efforts to enhance control and transparency regarding anonymous usage. + +Anonymous access now allows users, including those in open-source and enterprise self-managed environments, to view and monitor their anonymous access. They can also set a device limit, configuring a specific number of anonymous devices to connect to their instance. + +Once this limit is reached, any new devices attempting to connect will be denied access until existing devices disconnect. + +The anonymous devices feature improves the management and monitoring of anonymous access within your Grafana instance. + +**Anonymous Device:** + +When anonymous access has been enabled, any device which accesses Grafana in the last 30 days (without logging in) is considered an active anonymous device. Users can now view anonymous devices on the users page, anonymous usage statistics, including the count of devices and users over this period. + +**Grafana UI:** + +- Navigate to Administration -> Users to access the anonymous devices tab. + +- A new statistic has been added to the Usage & Stats page, displaying active anonymous devices from the last 30 days. + +{{< youtube id="B72X3_9e-ds" >}} + +[Documentation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/grafana/) diff --git a/docs/sources/whatsnew/whats-new-in-v10-4.md b/docs/sources/whatsnew/whats-new-in-v10-4.md new file mode 100644 index 0000000000000..2b8f9f4a2e682 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v10-4.md @@ -0,0 +1,282 @@ +--- +description: Feature and improvement highlights for Grafana v10.4 +keywords: + - grafana + - new + - documentation + - '10.4' + - release notes +labels: +products: + - cloud + - enterprise + - oss +title: What's new in Grafana v10.4 +weight: -41 +--- + +# What’s new in Grafana v10.4 + +Welcome to Grafana 10.4! This minor release contains some notable improvements in its own right, as well as early previews of functionality we intend to turn on by default in Grafana v11. Read on to learn about a quicker way to set up alert notifications, an all-new UI for configuring single sign-on, and improvements to our Canvas, Geomap, and Table panels. + +For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). For the specific steps we recommend when you upgrade to v10.4, check out our [Upgrade Guide](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/upgrade-guide/upgrade-v10.4/). + +<!-- Template below + +## Feature +<!-- Name of contributor --> +<!--_[Generally available | Available in private/public preview | Experimental] in Grafana [Open Source, Enterprise, all editions of Grafana, some combination of self-managed and Cloud]_ +Description. Include an overview of the feature and problem it solves, and where to learn more (like a link to the docs). +{{% admonition type="note" %}} +Use full URLs for links. When linking to versioned docs, replace the version with the version interpolation placeholder (for example, <GRAFANA_VERSION>, <TEMPO_VERSION>, <MIMIR_VERSION>) so the system can determine the correct set of docs to point to. For example, "https://grafana.com/docs/grafana/latest/administration/" becomes "https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/". +{{% /admonition %}} + +<!--Add an image, GIF or video as below--> + +<!--{{< figure src="/media/docs/grafana/dashboards/WidgetVizSplit.png" max-width="750px" caption="DESCRIPTIVE CAPTION" >}} + +<!--Learn how to upload images here: https://grafana.com/docs/writers-toolkit/write/image-guidelines/#where-to-store-media-assets--> +<!----> + +## Dashboards and visualizations + +### AngularJS plugin warnings in dashboards + +<!-- #grafana-deprecate-angularjs--> + +_Generally available in all editions of Grafana_ + +AngularJS support in Grafana was deprecated in v9 and will be turned off by default in Grafana v11. When this happens, any plugin which depended on AngularJS will not load, and dashboard panels will be unable to show data. + +To help you understand where you may be impacted, Grafana now displays a warning banner in any dashboard with a dependency on an AngularJS plugin. Additionally, warning icons are present in any panel where the panel plugin or underlying data source plugin has an AngularJS dependency. + +This complements the existing warnings already present on the **Plugins** page under the administration menu. + +In addition, you can use our [detect-angular-dashboards](https://github.com/grafana/detect-angular-dashboards) open source tool, which can be run against any Grafana instance to generate a report listing all dashboards that have a dependency on an AngularJS plugin, as well as which plugins are in use. This tool also supports the detection of [private plugins](https://grafana.com/legal/plugins/) that are dependent on AngularJS, however this particular feature requires Grafana v10.1.0 or higher. + +Use the aforementioned tooling and warnings to plan migrations to React based [visualizations](https://grafana.com/docs/grafana/latest/panels-visualizations/) and [data sources](https://grafana.com/docs/grafana/latest/datasources/) included in Grafana or from the [Grafana plugins catalog](https://grafana.com/grafana/plugins/). + +To learn more, refer to the [Angular support deprecation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/developers/angular_deprecation/), which includes [recommended alternative plugins](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/developers/angular_deprecation/angular-plugins/). + +{{< youtube id="XlEVs6g8dC8" >}} + +[Documentation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/developers/angular_deprecation/) + +### Data visualization quality of life improvements + +<!-- Nathan Marrs --> + +_Generally available in all editions of Grafana_ + +We’ve made a number of small improvements to the data visualization experience in Grafana. + +#### Geomap geojson layer now supports styling + +You can now visualize geojson styles such as polygons, point color/size, and line strings. To learn more, [refer to the documentation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/geomap/#geojson-layer). + +![Geomap marker symbol alignment](/media/docs/grafana/screenshot-grafana-10-4-geomap-geojson-styling-support.png) + +#### Canvas elements now support snapping and aligning + +You can precisely place elements in a canvas with ease as elements now snap into place and align with one another. + +{{< video-embed src="/media/docs/grafana/screen-recording-10-4-canvas-element-snapping.mp4" caption="Canvas element snapping and alignment" >}} + +#### View data links inline in table visualizations + +You can now view your data links inline to help you keep your tables visually streamlined. + +![Table inline datalink support](/media/docs/grafana/gif-grafana-10-4-table-inline-datalink.gif) + +### Create subtables in table visualizations with Group to nested tables + +<!-- Nathan Marrs --> + +_Available in public preview in all editions of Grafana_ + +You can now create subtables out of your data using the new **Group to nested tables** transformation. To use this feature, enable the `groupToNestedTableTransformation` [feature toggle](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/feature-toggles/#preview-feature-toggles). + +{{< video-embed src="/media/docs/grafana/screen-recording-10-4-table-group-to-nested-table-transformation.mp4" caption="Group to nested tables transformation" >}} + +### Set library panel permissions with RBAC + +<!-- #grafana-dashboards --> + +_Generally available in Grafana Enterprise and Grafana Cloud_ + +We've added the option to manage library panel permissions through role-based access control (RBAC). With this feature, you can choose who can create, edit, and read library panels. RBAC provides a standardized way of granting, changing, and revoking access when it comes to viewing and modifying Grafana resources, such as dashboards, reports, and administrative settings. + +[Documentation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/manage-library-panels/) + +### Tooltip improvements + +<!--Adela Almasan--> + +_Available in public preview in all editions of Grafana_ + +We’ve made a number of small improvements to the way tooltips work in Grafana. To try out the new tooltips, enable the `newVizTooltips` [feature toggle](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/feature-toggles/). + +**Copy on click support** + +You can now copy the content from within a tooltip by clicking on the text. + +![Tooltip](/media/docs/grafana/gif-grafana-10-4-tooltip–copy.gif) + +**Scrollable content** + +You can now scroll the content of a tooltip, which allows you to view long lists. This is currently supported in the time series, candlestick, and trend visualizations. We'll add more improvements to the scrolling functionality in a future version. + +![Tooltip](/media/docs/grafana/gif-grafana-10-4-tooltip-content-scroll.gif) + +**Added tooltip options for candlestick visualization** + +The default tooltip options are now also visible in candlestick visualizations. + +**Hover proximity option in time series** + +We've added a tooltip hover proximity limit option (in pixels), which makes it possible to reduce the number of hovered-over data points under the cursor when two datasets are not aligned in time. + +![Time Series hover proximity](/media/docs/grafana/gif-grafana-10-4-hover-proximity.gif) + +## Return to previous + +<!-- #grafana-frontend-platform--> + +_Available in public preview in all editions of Grafana_ + +When you're browsing Grafana - for example, exploring the dashboard and metrics related to an alert - it's easy to end up far from where you started and hard get back to where you came from. The ‘Return to previous’ button is an easy way to go back to the previous context, like the alert rule that kicked off your exploration. This first release works for Alerts, and we plan to expand to other apps and features in Grafana in future releases to make it easier to navigate around. + +Return to Previous is rolling out across Grafana Cloud now. To try Return to Previous in self-managed Grafana, turn on the `returnToPrevious` [feature toggle](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/feature-toggles/) in Grafana v10.4 or newer. + +{{< youtube id="-Y3qPfD2wrA" >}} + +{{< admonition type="note" >}} +The term **context** refers to applications in Grafana like Incident and OnCall, as well as core features like Explore and Dashboards. + +To notice a change in your context, look at Grafana's breadcrumbs. If you go from _Home > **Dashboards**_ to _Home > **Explore**_, you've changed context. If you go from _Home > **Dashboards** > Playlist > Edit playlist_ to _Home > **Dashboards** > Reporting > Settings_, you are in the same context. +{{< /admonition >}} + +## Alerting + +### Simplified Alert Notification Routing + +<!-- #alerting --> + +_Generally available in all editions of Grafana_ + +This feature simplifies your options for configuring where your notifications are sent when an alert rule fires. Choose an existing contact point directly from within the alert rule creation form without the need to label match notification policies.  You can also set optional muting, grouping, and timing settings directly in the alert rule. + +Simplified routing inherits the alert rule RBAC, increasing control over notification routing while preventing accidental notification policy updates, ensuring critical notifications make it to their intended contact point destination. + +To try out Simplified Alert Notification Routing enable the `alertingSimplifiedRouting` feature toggle. + +{{< youtube id="uBBQ-_pWSNs" >}} + +### Grafana Alerting upgrade with rule preview + +<!-- #alerting --> + +_Generally available in all editions of Grafana_ + +Users looking to migrate to the new Grafana Alerting product can do so with confidence with the Grafana Alerting migration preview tool. The migration preview tool allows users to view, edit, and delete migrated rules prior cutting over, with the option to roll back to Legacy Alerting. + +[Documentation](https://grafana.com/docs/grafana/v10.4/alerting/set-up/migrating-alerts/#upgrade-with-preview-recommended) + +### Rule evaluation spread over the entire evaluation interval + +<!-- #alerting --> + +_Generally available in all editions of Grafana_ + +Grafana Alerting previously evaluated rules at the start of the evaluation interval. This created a sudden spike of resource utilization, impacting data sources. Rule evaluation is now spread over the entire interval for smoother performance utilization of data sources. + +### UTF-8 Support for Prometheus and Mimir Alertmanagers + +<!-- #alerting --> + +_Generally available in all editions of Grafana_ + +Grafana can now be used to manage both Prometheus and Mimir Alertmanagers with UTF-8 configurations. For more information, please see the +[release notes for Alertmanager 0.27.0](https://github.com/prometheus/alertmanager/releases). + +## Authentication and authorization + +### SSO Settings UI and Terraform resource for configuring OAuth providers + +<!-- #proj-grafana-sso-config, #identity-access or Mihaly Gyongyosi (@Misi) --> + +_Available in public preview in all editions of Grafana_ + +Configuring OAuth providers was a bit cumbersome in Grafana: Grafana Cloud users had to reach out to Grafana Support, self-hosted users had to manually edit the configuration file, set up environment variables, and then they had to restart Grafana. On Cloud, the Advanced Auth page is there to configure some of the providers, but configuring Generic OAuth hasn’t been available until now and there was no way to manage the settings through the Grafana UI, nor was there a way to manage the settings through Terraform or the Grafana API. + +Our goal is to make setting up SSO for your Grafana instance simple and fast. + +To get there, we are introducing easier self-serve configuration options for OAuth in Grafana. All of the currently supported OAuth providers are now available for configuration through the Grafana UI, Terraform and via the API. From the UI, you can also now manage all of the settings for the Generic OAuth provider. + +We are working on adding complete support for configuring all other supported OAuth providers as well, such as GitHub, GitLab, Google, Microsoft Azure AD and Okta. You can already manage some of these settings via the new self-serve configuration options, and we’re working on adding more at the moment. + +![Screenshot of the Authentication provider list page](/media/docs/grafana-cloud/screenshot-sso-settings-ui-public-prev-v10.4.png) + +{{< youtube id="xXW2eRTbjDY" >}} + +[Documentation](https://grafana.com/docs/grafana/next/setup-grafana/configure-security/configure-authentication/) + +## Data sources + +{{< admonition type="note" >}} +The following data sources are released separately from Grafana itself. They are included here for extra visibility. +{{< /admonition >}} + +### PagerDuty enterprise data source for Grafana + +<!-- #enterprise-datasources --> + +_Generally available in Grafana Enterprise and Grafana Cloud_ + +PagerDuty enterprise data source plugin for Grafana allows you to query incidents data or visualize incidents using annotations. + +{{< admonition type="note" >}} +Plugin is currently in a preview phase. +{{< /admonition >}} + +You can find more information and how to configure the plugin in the [documentation](https://grafana.com/docs/plugins/grafana-pagerduty-datasource/latest/). + +Screenshots: + +{{< figure src="/media/docs/plugins/PagerDuty-incidents-annotation.png" caption="PagerDuty data source annotation editor" alt="PagerDuty data source annotation editor" >}} + +{{< figure src="/media/docs/plugins/PagerDuty-incidents-real-life-example.png" caption="Incidents annotations from PagerDuty data source on a dashboard panel" alt="Incidents annotations from PagerDuty data source on a dashboard panel" >}} + +{{< youtube id="dCklm2DaVqQ" >}} + +### SurrealDB Data Source + +<!-- #grafana-partner-datasources, @adamyeats--> + +_Experimental in all editions of Grafana_ + +A SurrealDB data source has been [added to the Plugin Catalog](https://grafana.com/grafana/plugins/grafana-surrealdb-datasource/), enabling the integration of [SurrealDB](https://surrealdb.com/), a real-time, multi-model database, with Grafana's visualization capabilities. This datasource allows users to directly query and visualize data from SurrealDB within Grafana, using SurrealDB's query language. + +The SurrealDB data source launches with just the basics today. You can write queries in SurrealQL using the built-in query editor, although many Grafana features like macros are not supported for now. + +You can find more information and how to configure the plugin [on Github](https://github.com/grafana/surrealdb-datasource). + +{{< figure src="/media/images/dashboards/surrealdb-dashboard-example.png" alt="Grafana dashboard using SurrealDB data source" >}} + +[Documentation](https://grafana.com/grafana/plugins/grafana-surrealdb-datasource/) + +## Table Visualization for Logs + +<!-- #observability-logs --> + +_Generally available in all editions of Grafana_ + +The table visualization for logs, announced in public preview for Grafana 10.3, is generally available in Cloud (all editions) and with Grafana 10.4. + +New to the table visualization with 10.4: + +- the ability to sort columns +- data type autodetection of fields +- autodetection and clean formatting of json fields + +Try it out today! diff --git a/docs/sources/whatsnew/whats-new-in-v7-0.md b/docs/sources/whatsnew/whats-new-in-v7-0.md index 13a2e0bf458db..eaf2dd2c16d20 100644 --- a/docs/sources/whatsnew/whats-new-in-v7-0.md +++ b/docs/sources/whatsnew/whats-new-in-v7-0.md @@ -21,7 +21,7 @@ weight: -27 # What's new in Grafana v7.0 -This topic includes the release notes for Grafana v7.0. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). +This topic includes the release notes for Grafana v7.0. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). This major release of Grafana is the next step in our Observability story. It includes powerful new features for manipulating, transforming, and doing math on data. Grafana Enterprise has the first version of Usage analytics, which will help Grafana Admins better manage large, corporate Grafana ecosystems. @@ -233,4 +233,4 @@ Okta gets its own provider which adds support for Team Sync. Read more about it ## Changelog -Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/main/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v7-1.md b/docs/sources/whatsnew/whats-new-in-v7-1.md index 96ec010f93770..65e9b5f854428 100644 --- a/docs/sources/whatsnew/whats-new-in-v7-1.md +++ b/docs/sources/whatsnew/whats-new-in-v7-1.md @@ -21,7 +21,7 @@ weight: -28 # What's new in Grafana v7.1 -This topic includes the release notes for the Grafana v7.1. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). +This topic includes the release notes for the Grafana v7.1. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). The main highlights are: @@ -116,4 +116,4 @@ With Grafana Enterprise 7.1, you can generate reports on a [monthly schedule]({{ ## Changelog -Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/main/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v7-2.md b/docs/sources/whatsnew/whats-new-in-v7-2.md index 031039a679d97..0020f4c71e1a9 100644 --- a/docs/sources/whatsnew/whats-new-in-v7-2.md +++ b/docs/sources/whatsnew/whats-new-in-v7-2.md @@ -21,7 +21,7 @@ weight: -29 # What's new in Grafana v7.2 -This topic includes the release notes for the Grafana v7.2. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). +This topic includes the release notes for the Grafana v7.2. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). The main highlights are: @@ -150,7 +150,7 @@ For more information, refer to [Report settings]({{< relref "../dashboards/share ## Changelog -Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/main/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. ## What's new in other parts of the Grafana ecosystem diff --git a/docs/sources/whatsnew/whats-new-in-v7-3.md b/docs/sources/whatsnew/whats-new-in-v7-3.md index 66f2f85dcd084..ca2c0d75c66cb 100644 --- a/docs/sources/whatsnew/whats-new-in-v7-3.md +++ b/docs/sources/whatsnew/whats-new-in-v7-3.md @@ -21,7 +21,7 @@ weight: -30 # What's new in Grafana v7.3 -This topic includes the release notes for Grafana v7.3. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) or the [Patch release notes](#patch-release-notes). +This topic includes the release notes for Grafana v7.3. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/main/CHANGELOG.md) or the [Patch release notes](#patch-release-notes). The main highlights are: @@ -154,7 +154,7 @@ IdP-initiated single sign on (SSO) allows the user to log in directly from the S ## Changelog -Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/main/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. ## Patch release notes diff --git a/docs/sources/whatsnew/whats-new-in-v7-4.md b/docs/sources/whatsnew/whats-new-in-v7-4.md index 634d52df3a9c7..e9009182bd032 100644 --- a/docs/sources/whatsnew/whats-new-in-v7-4.md +++ b/docs/sources/whatsnew/whats-new-in-v7-4.md @@ -21,7 +21,7 @@ weight: -31 # What's new in Grafana v7.4 -This topic includes the release notes for Grafana v7.4. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). +This topic includes the release notes for Grafana v7.4. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). Check out the [New Features in 7.4](https://play.grafana.org/d/nP8rcffGk/1-new-features-in-v7-4?orgId=1) dashboard on Grafana Play! @@ -245,4 +245,4 @@ In order to minimize the confusion with Constant variable usage, we've removed t ## Changelog -Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/main/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v7-5.md b/docs/sources/whatsnew/whats-new-in-v7-5.md index 65ad89f4a346f..6f842b4baf668 100644 --- a/docs/sources/whatsnew/whats-new-in-v7-5.md +++ b/docs/sources/whatsnew/whats-new-in-v7-5.md @@ -21,7 +21,7 @@ weight: -32 # What's new in Grafana v7.5 -This topic includes the release notes for Grafana v7.5. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). +This topic includes the release notes for Grafana v7.5. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). ## Grafana OSS features diff --git a/docs/sources/whatsnew/whats-new-in-v8-0.md b/docs/sources/whatsnew/whats-new-in-v8-0.md index 7c78870d4b55e..c63d3f40f6fd9 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-0.md +++ b/docs/sources/whatsnew/whats-new-in-v8-0.md @@ -21,7 +21,7 @@ weight: -33 # What's new in Grafana v8.0 -This topic includes the release notes for Grafana v8.0. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). +This topic includes the release notes for Grafana v8.0. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). ## Grafana OSS features diff --git a/docs/sources/whatsnew/whats-new-in-v8-1.md b/docs/sources/whatsnew/whats-new-in-v8-1.md index 431ffcc442420..d42b7b0b36632 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-1.md +++ b/docs/sources/whatsnew/whats-new-in-v8-1.md @@ -25,7 +25,7 @@ weight: -33 Grafana 8.1 builds upon our promise of a composable, open observability platform with new panels and extends functionality launched in Grafana 8.0. We’ve got new Geomap and Annotations panels, and some great updates to the Time Series panel. We’ve also got new transformations and updates to data sources. For our enterprise customers, there are additions to fine grained access control, updates to the reporting schedule and query caching, and more. Read on to learn more. -In addition to what is summarized here, you might also be interested in our announcement blog post. For all the technical details, check out the complete [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). +In addition to what is summarized here, you might also be interested in our announcement blog post. For all the technical details, check out the complete [CHANGELOG.md](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). ## Grafana OSS features diff --git a/docs/sources/whatsnew/whats-new-in-v8-2.md b/docs/sources/whatsnew/whats-new-in-v8-2.md index c6a44c0ea1856..3afee62b4a3f8 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-2.md +++ b/docs/sources/whatsnew/whats-new-in-v8-2.md @@ -27,7 +27,7 @@ The plugin catalog is now on by default in Grafana 8.2. Using the plugin catalog Grafana Enterprise includes a revamped Stats and Licensing page, new role-based access control permissions, and improvements that make usage insights and reporting easier to access. -We’ve summarized what’s new in the release here, but you might also be interested in the announcement blog post. If you’d like all the details you can check out the [release notes](/docs/grafana/next/release-notes/release-notes-8-2-0/) and complete [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). +We’ve summarized what’s new in the release here, but you might also be interested in the announcement blog post. If you’d like all the details you can check out the [release notes](/docs/grafana/next/release-notes/release-notes-8-2-0/) and complete [CHANGELOG.md](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). ## OSS diff --git a/docs/sources/whatsnew/whats-new-in-v8-3.md b/docs/sources/whatsnew/whats-new-in-v8-3.md index dbb49d7f07cbb..c24dd159676a5 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-3.md +++ b/docs/sources/whatsnew/whats-new-in-v8-3.md @@ -25,7 +25,7 @@ Grafana 8.3 is an exciting release for Grafana Labs. This release includes the n For Open Source users it also marks the first time Grafana Alerting, formerly unified alerting, is enabled by default for new Grafana installations. Grafana Alerting in 8.3 is the flexible, single pane of glass for all your alerts. Included in this release is expanded provisioning support for notifiers, contact points, and alert rules, alongside auditing and role-based access control for our Enterprise customers. -We’ve summarized what’s new in the release here, but you might also be interested in the announcement blog post as well. If you’d like all the details you can check out the complete [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). +We’ve summarized what’s new in the release here, but you might also be interested in the announcement blog post as well. If you’d like all the details you can check out the complete [CHANGELOG.md](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). ## Grafana OSS diff --git a/docs/sources/whatsnew/whats-new-in-v8-4.md b/docs/sources/whatsnew/whats-new-in-v8-4.md index dc810e68152be..0827828511f06 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-4.md +++ b/docs/sources/whatsnew/whats-new-in-v8-4.md @@ -23,7 +23,7 @@ weight: -33 We’re excited to announce Grafana v8.4, with a variety of improvements that focus on Grafana’s usability, performance, and security. Read on to learn about Alerting enhancements like a WeCom contact point, improved Alert panel and custom mute timings, as well as visualization improvements and details to help you share playlists more easily. In Grafana Enterprise, we’ve made caching more powerful to save you time and money while loading dashboards, boosted database encryption to keep secrets safe in your Grafana database, and made usability improvements to Recorded Queries, which allow you to track any data point over time. -We’ve summarized what’s new in the release here, but you might also be interested in the announcement blog post. If you’d like all the details you can check out the complete [changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). +We’ve summarized what’s new in the release here, but you might also be interested in the announcement blog post. If you’d like all the details you can check out the complete [changelog](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). ## OSS diff --git a/docs/sources/whatsnew/whats-new-in-v9-0.md b/docs/sources/whatsnew/whats-new-in-v9-0.md index ee2d306d68578..9d9cc1818da0a 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-0.md +++ b/docs/sources/whatsnew/whats-new-in-v9-0.md @@ -90,7 +90,7 @@ The new heatmap by default assumes that the data is pre-bucketed. So if your que Grafana Alerting is now on by default if you upgrade from an earlier version of Grafana. If you have been using legacy alerting in an earlier version of Grafana and you upgrade to Grafana 9 your alert rules will be automatically migrated and the legacy alerting interface will be replaced by the Grafana Alerting interface. -Grafana Alerting, called unified alerting in Grafana 8, has been available since June, 2021 now provides feature parity with legacy alerting and many additional benefits. To find out more on the process to revert back to legacy alerts if needed, click [here]({{< relref "../alerting/set-up/migrating-alerts#opt-out" >}}). Note that if you do revert back (by setting the Grafana config flag GF_UNIFIED_ALERTING_ENABLED to false), that we expect to remove legacy alerting in the next major Grafana release, Grafana 10. +Grafana Alerting, called unified alerting in Grafana 8, has been available since June, 2021 now provides feature parity with legacy alerting and many additional benefits. To find out more on the process to revert back to legacy alerts if needed, click [here](https://grafana.com/docs/grafana/v10.2/alerting/set-up/migrating-alerts/#opt-out). Note that if you do revert back (by setting the Grafana config flag GF_UNIFIED_ALERTING_ENABLED to false), that we expect to remove legacy alerting in the next major Grafana release, Grafana 10. ### Alert state history for Grafana managed alerts diff --git a/docs/sources/whatsnew/whats-new-in-v9-1.md b/docs/sources/whatsnew/whats-new-in-v9-1.md index 554d8fdb6d3e3..cab0a2696d425 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-1.md +++ b/docs/sources/whatsnew/whats-new-in-v9-1.md @@ -24,7 +24,7 @@ weight: -33 We're excited to announce Grafana v9.1, with a variety of improvements that focus on Grafana's usability, performance, and security. Read on to learn about new options to share and embed dashboards, search and navigation enhancements, new panel options, and additional authentication features. You can also find out more about new single sign-on and role-based access control options in Grafana Enterprise, and more. -For details, see the complete [changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). +For details, see the complete [changelog](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). ## OSS diff --git a/docs/sources/whatsnew/whats-new-in-v9-2.md b/docs/sources/whatsnew/whats-new-in-v9-2.md index d6186427acf43..4deeb3a386a27 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-2.md +++ b/docs/sources/whatsnew/whats-new-in-v9-2.md @@ -21,7 +21,7 @@ weight: -33 Welcome to Grafana v9.2, a hefty minor release with a swath of improvements that help you create and share dashboards and alerts. Read on to learn about progress on public dashboards, our new panel help menu, custom branding in Grafana Enterprise, and improvements to access control. -If you'd prefer to dig into the details, check out the complete [changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). +If you'd prefer to dig into the details, check out the complete [changelog](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). ## Panel help menu diff --git a/docs/sources/whatsnew/whats-new-in-v9-3.md b/docs/sources/whatsnew/whats-new-in-v9-3.md index 06fecd41a6c92..f780b4890aff3 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-3.md +++ b/docs/sources/whatsnew/whats-new-in-v9-3.md @@ -19,7 +19,7 @@ weight: -34 # What's new in Grafana v9.3 -Welcome to Grafana 9.3! Read on to learn about our navigation overhaul, support for four new languages, new panels and transformations, several often-requested auth improvements, usability improvements to Alerting, and more. For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). +Welcome to Grafana 9.3! Read on to learn about our navigation overhaul, support for four new languages, new panels and transformations, several often-requested auth improvements, usability improvements to Alerting, and more. For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). ## New navigation diff --git a/docs/sources/whatsnew/whats-new-in-v9-4.md b/docs/sources/whatsnew/whats-new-in-v9-4.md index f6ce74e6cffc0..4f6e9fb5e59fd 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-4.md +++ b/docs/sources/whatsnew/whats-new-in-v9-4.md @@ -17,7 +17,7 @@ weight: -35 # What's new in Grafana v9.4 -Welcome to Grafana 9.4! Read on to learn about changes to search and navigation, dashboards and visualizations, and authentication and security. For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). +Welcome to Grafana 9.4! Read on to learn about changes to search and navigation, dashboards and visualizations, and authentication and security. For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). ## Search and navigation diff --git a/docs/sources/whatsnew/whats-new-in-v9-5.md b/docs/sources/whatsnew/whats-new-in-v9-5.md index 37c016ef6d966..36751d91b5ab6 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-5.md +++ b/docs/sources/whatsnew/whats-new-in-v9-5.md @@ -19,7 +19,7 @@ weight: -36 Welcome to Grafana 9.5! We're excited to share some major updates to Grafana's navigation, tons of usability improvements to Alerting, and some promising experiments to help you query your Prometheus metrics. Also, read on to learn about our continued migration from API keys to service accounts, as well as deprecation of plugins that use Angular and a field in the InfluxDB data source. -For more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). For the specific steps we recommend when you upgrade to v9.5, check out our [Upgrade Guide]({{< relref "../upgrade-guide/upgrade-v9.5/index.md" >}}). +For more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). For the specific steps we recommend when you upgrade to v9.5, check out our [Upgrade Guide]({{< relref "../upgrade-guide/upgrade-v9.5/index.md" >}}). <!-- Template below diff --git a/docs/sources/whatsnew/whats-new-next/README.md b/docs/sources/whatsnew/whats-new-next/README.md index 8ce45f6906c1f..ac1d70cf30eb0 100644 --- a/docs/sources/whatsnew/whats-new-next/README.md +++ b/docs/sources/whatsnew/whats-new-next/README.md @@ -1,3 +1,7 @@ +--- +draft: true +--- + # Contribute to 'What's new in Grafana Cloud' To contribute to [What's new in Grafana Cloud](https://grafana.com/docs/grafana-cloud/whatsnew/), refer to [Contribute to What’s new or release notes](https://grafana.com/docs/writers-toolkit/contribute-documentation/contribute-release-notes/). diff --git a/e2e/cloud-plugins-suite/azure-monitor.spec.ts b/e2e/cloud-plugins-suite/azure-monitor.spec.ts index 4f61c10ce0b87..d58f2f776e79c 100644 --- a/e2e/cloud-plugins-suite/azure-monitor.spec.ts +++ b/e2e/cloud-plugins-suite/azure-monitor.spec.ts @@ -89,62 +89,26 @@ const addAzureMonitorVariable = ( .type(`${type.replace('Azure', '').trim()}{enter}`); switch (type) { case AzureQueryType.ResourceGroupsQuery: - e2eSelectors.variableEditor.subscription - .input() - .find('input') - .type(`${options?.subscription}{enter}`); + e2eSelectors.variableEditor.subscription.input().find('input').type(`${options?.subscription}{enter}`); break; case AzureQueryType.LocationsQuery: - e2eSelectors.variableEditor.subscription - .input() - .find('input') - .type(`${options?.subscription}{enter}`); + e2eSelectors.variableEditor.subscription.input().find('input').type(`${options?.subscription}{enter}`); break; case AzureQueryType.NamespacesQuery: - e2eSelectors.variableEditor.subscription - .input() - .find('input') - .type(`${options?.subscription}{enter}`); - e2eSelectors.variableEditor.resourceGroup - .input() - .find('input') - .type(`${options?.resourceGroup}{enter}`); + e2eSelectors.variableEditor.subscription.input().find('input').type(`${options?.subscription}{enter}`); + e2eSelectors.variableEditor.resourceGroup.input().find('input').type(`${options?.resourceGroup}{enter}`); break; case AzureQueryType.ResourceNamesQuery: - e2eSelectors.variableEditor.subscription - .input() - .find('input') - .type(`${options?.subscription}{enter}`); - e2eSelectors.variableEditor.resourceGroup - .input() - .find('input') - .type(`${options?.resourceGroup}{enter}`); - e2eSelectors.variableEditor.namespace - .input() - .find('input') - .type(`${options?.namespace}{enter}`); - e2eSelectors.variableEditor.region - .input() - .find('input') - .type(`${options?.region}{enter}`); + e2eSelectors.variableEditor.subscription.input().find('input').type(`${options?.subscription}{enter}`); + e2eSelectors.variableEditor.resourceGroup.input().find('input').type(`${options?.resourceGroup}{enter}`); + e2eSelectors.variableEditor.namespace.input().find('input').type(`${options?.namespace}{enter}`); + e2eSelectors.variableEditor.region.input().find('input').type(`${options?.region}{enter}`); break; case AzureQueryType.MetricNamesQuery: - e2eSelectors.variableEditor.subscription - .input() - .find('input') - .type(`${options?.subscription}{enter}`); - e2eSelectors.variableEditor.resourceGroup - .input() - .find('input') - .type(`${options?.resourceGroup}{enter}`); - e2eSelectors.variableEditor.namespace - .input() - .find('input') - .type(`${options?.namespace}{enter}`); - e2eSelectors.variableEditor.resource - .input() - .find('input') - .type(`${options?.resource}{enter}`); + e2eSelectors.variableEditor.subscription.input().find('input').type(`${options?.subscription}{enter}`); + e2eSelectors.variableEditor.resourceGroup.input().find('input').type(`${options?.resourceGroup}{enter}`); + e2eSelectors.variableEditor.namespace.input().find('input').type(`${options?.namespace}{enter}`); + e2eSelectors.variableEditor.resource.input().find('input').type(`${options?.resource}{enter}`); break; } e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click(); diff --git a/e2e/dashboards-suite/dashboard-browse.spec.ts b/e2e/dashboards-suite/dashboard-browse.spec.ts index 867a274a2d1fe..4e9728b7a6559 100644 --- a/e2e/dashboards-suite/dashboard-browse.spec.ts +++ b/e2e/dashboards-suite/dashboard-browse.spec.ts @@ -16,11 +16,11 @@ describe('Dashboard browse', () => { e2e.pages.BrowseDashboards.table.row('E2E Test - Import Dashboard').should('be.visible'); // gdev dashboards folder is collapsed - its content should not be visible - e2e.pages.BrowseDashboards.table.row('Alerting with TestData').should('not.exist'); + e2e.pages.BrowseDashboards.table.row('Bar Gauge Demo').should('not.exist'); // should click a folder and see it's children e2e.pages.BrowseDashboards.table.row('gdev dashboards').find('[aria-label^="Expand folder"]').click(); - e2e.pages.BrowseDashboards.table.row('Alerting with TestData').should('be.visible'); + e2e.pages.BrowseDashboards.table.row('Bar Gauge Demo').should('be.visible'); // Open the new folder drawer cy.contains('button', 'New').click(); diff --git a/e2e/dashboards-suite/dashboard-public-create.spec.ts b/e2e/dashboards-suite/dashboard-public-create.spec.ts index 68cb2c6ae5165..7f95df2c61112 100644 --- a/e2e/dashboards-suite/dashboard-public-create.spec.ts +++ b/e2e/dashboards-suite/dashboard-public-create.spec.ts @@ -14,7 +14,7 @@ describe('Public dashboards', () => { cy.wait('@query'); // Open sharing modal - e2e.pages.ShareDashboardModal.shareButton().click(); + e2e.pages.Dashboard.DashNav.shareButton().click(); // Select public dashboards tab e2e.pages.ShareDashboardModal.PublicDashboard.Tab().click(); @@ -74,7 +74,7 @@ describe('Public dashboards', () => { e2e.pages.Dashboard.DashNav.publicDashboardTag().should('exist'); // Open sharing modal - e2e.pages.ShareDashboardModal.shareButton().click(); + e2e.pages.Dashboard.DashNav.shareButton().click(); // Select public dashboards tab cy.intercept('GET', '/api/dashboards/uid/ZqZnVvFZz/public-dashboards').as('query-public-dashboard'); @@ -114,7 +114,7 @@ describe('Public dashboards', () => { cy.wait('@query'); // Open sharing modal - e2e.pages.ShareDashboardModal.shareButton().click(); + e2e.pages.Dashboard.DashNav.shareButton().click(); // Select public dashboards tab cy.intercept('GET', '/api/dashboards/uid/ZqZnVvFZz/public-dashboards').as('query-public-dashboard'); diff --git a/e2e/dashboards-suite/dashboard-public-templating.spec.ts b/e2e/dashboards-suite/dashboard-public-templating.spec.ts index d02533f27438e..ba819895fc2a9 100644 --- a/e2e/dashboards-suite/dashboard-public-templating.spec.ts +++ b/e2e/dashboards-suite/dashboard-public-templating.spec.ts @@ -10,7 +10,7 @@ describe('Create a public dashboard with template variables shows a template var e2e.flows.openDashboard({ uid: 'HYaGDGIMk' }); // Open sharing modal - e2e.pages.ShareDashboardModal.shareButton().click(); + e2e.pages.Dashboard.DashNav.shareButton().click(); // Select public dashboards tab e2e.pages.ShareDashboardModal.PublicDashboard.Tab().click(); diff --git a/e2e/dashboards-suite/embedded-dashboard.spec.ts b/e2e/dashboards-suite/embedded-dashboard.spec.ts new file mode 100644 index 0000000000000..8c52271994c2f --- /dev/null +++ b/e2e/dashboards-suite/embedded-dashboard.spec.ts @@ -0,0 +1,24 @@ +import { selectors } from '@grafana/e2e-selectors'; + +import { e2e } from '../utils'; +import { fromBaseUrl } from '../utils/support/url'; + +describe('Embedded dashboard', function () { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('open test page', function () { + cy.visit(fromBaseUrl('/dashboards/embedding-test')); + + // Verify pie charts are rendered + cy.get( + `[data-viz-panel-key="panel-11"] [data-testid^="${selectors.components.Panels.Visualization.PieChart.svgSlice}"]` + ).should('have.length', 5); + + // Verify no url sync + e2e.components.TimePicker.openButton().click(); + cy.get('label:contains("Last 1 hour")').click(); + cy.url().should('eq', fromBaseUrl('/dashboards/embedding-test')); + }); +}); diff --git a/e2e/dashboards-suite/general-dashboards.spec.ts b/e2e/dashboards-suite/general-dashboards.spec.ts new file mode 100644 index 0000000000000..00584030c23f8 --- /dev/null +++ b/e2e/dashboards-suite/general-dashboards.spec.ts @@ -0,0 +1,33 @@ +import { e2e } from '../utils'; + +const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard'; + +describe('Dashboards', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('should restore scroll position', () => { + e2e.flows.openDashboard({ uid: PAGE_UNDER_TEST }); + e2e.components.Panels.Panel.title('Panel #1').should('be.visible'); + + // scroll to the bottom + e2e.pages.Dashboard.DashNav.navV2() + .parent() + .parent() // Note, this will probably fail when we change the custom scrollbars + .scrollTo('bottom', { + timeout: 5 * 1000, + }); + + // The last panel should be visible... + e2e.components.Panels.Panel.title('Panel #50').should('be.visible'); + + // Then we open and close the panel editor + e2e.components.Panels.Panel.menu('Panel #50').click({ force: true }); // it only shows on hover + e2e.components.Panels.Panel.menuItems('Edit').click(); + e2e.components.PanelEditor.applyButton().click(); + + // And the last panel should still be visible! + e2e.components.Panels.Panel.title('Panel #50').should('be.visible'); + }); +}); diff --git a/e2e/dashboards-suite/new-datasource-variable.spec.ts b/e2e/dashboards-suite/new-datasource-variable.spec.ts index 5f62f6d42a1e6..17a46749c2363 100644 --- a/e2e/dashboards-suite/new-datasource-variable.spec.ts +++ b/e2e/dashboards-suite/new-datasource-variable.spec.ts @@ -25,12 +25,14 @@ describe('Variables - Datasource', () => { e2e.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.datasourceSelect().within(() => { cy.get('input').type('Prometheus{enter}'); }); - e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption() - .eq(0) - .should('have.text', 'gdev-prometheus'); - e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption() - .eq(1) - .should('have.text', 'gdev-slow-prometheus'); + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should( + 'contain.text', + 'gdev-prometheus' + ); + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should( + 'contain.text', + 'gdev-slow-prometheus' + ); // Navigate back to the homepage and change the selected variable value e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click(); diff --git a/e2e/dashboards-suite/new-query-variable.spec.ts b/e2e/dashboards-suite/new-query-variable.spec.ts index 3ffdda493108c..5239060c2d759 100644 --- a/e2e/dashboards-suite/new-query-variable.spec.ts +++ b/e2e/dashboards-suite/new-query-variable.spec.ts @@ -69,7 +69,7 @@ describe('Variables - Query - Add variable', () => { }); e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should('not.exist'); - e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInputV2().should('not.exist'); + e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput().should('not.exist'); }); it('adding a single value query variable', () => { @@ -152,7 +152,7 @@ describe('Variables - Query - Add variable', () => { cy.get('input[type="checkbox"]').click({ force: true }).should('be.checked'); }); - e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInputV2().within((input) => { + e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput().within((input) => { expect(input.attr('placeholder')).equals('blank = auto'); expect(input.val()).equals(''); }); diff --git a/e2e/panels-suite/panelEdit_transforms.spec.ts b/e2e/panels-suite/panelEdit_transforms.spec.ts index c71f649791c61..eb3029b8d982e 100644 --- a/e2e/panels-suite/panelEdit_transforms.spec.ts +++ b/e2e/panels-suite/panelEdit_transforms.spec.ts @@ -6,7 +6,7 @@ describe('Panel edit tests - transformations', () => { }); it('Tests transformations editor', () => { - e2e.flows.openDashboard({ uid: '5SdHCadmz', queryParams: { editPanel: 3 } }); + e2e.flows.openDashboard({ uid: 'TkZXxlNG3', queryParams: { editPanel: 47 } }); e2e.components.Tab.title('Transform data').should('be.visible').click(); e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click(); @@ -14,4 +14,15 @@ describe('Panel edit tests - transformations', () => { e2e.components.Transforms.Reduce.calculationsLabel().scrollIntoView().should('be.visible'); e2e.components.Transforms.Reduce.modeLabel().should('be.visible'); }); + + it('Tests case where transformations can be disabled and not clear out panel data', () => { + e2e.flows.openDashboard({ uid: 'TkZXxlNG3', queryParams: { editPanel: 47 } }); + + e2e.components.Tab.title('Transform data').should('be.visible').click(); + e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click(); + e2e.components.TransformTab.newTransform('Reduce').scrollIntoView().should('be.visible').click(); + e2e.components.Transforms.disableTransformationButton().should('be.visible').click(); + + e2e.components.Panels.Panel.PanelDataErrorMessage().should('not.exist'); + }); }); diff --git a/e2e/plugin-e2e/plugin-e2e-api-tests/README.md b/e2e/plugin-e2e/plugin-e2e-api-tests/README.md new file mode 100644 index 0000000000000..477a6c384dc73 --- /dev/null +++ b/e2e/plugin-e2e/plugin-e2e-api-tests/README.md @@ -0,0 +1,8 @@ +# @grafana/plugin-e2e API tests + +The purpose of the E2E tests in this directory is not to test the plugins per se - it's to verify that the fixtures, models and expect matchers provided by the [`@grafana/plugin-e2e`](https://github.com/grafana/plugin-tools/tree/main/packages/plugin-e2e) package are compatible with the latest version of Grafana. If you find that any of these tests are failing, it's probably due to one of the following reasons: + +- you have changed a value of a selector defined in @grafana/e2e-selector +- you have made structural changes to the UI + +For information on how to address this, follow the instructions in the [contributing guidelines](https://github.com/grafana/plugin-tools/blob/main/packages/plugin-e2e/CONTRIBUTING.md#how-to-fix-broken-test-scenarios-after-changes-in-grafana) for the @grafana/plugin-e2e package in the plugin-tools repository. diff --git a/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/annotationEditPage.spec.ts b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/annotationEditPage.spec.ts new file mode 100644 index 0000000000000..1a11fdd6a562d --- /dev/null +++ b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/annotationEditPage.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from '@grafana/plugin-e2e'; +import { AlertVariant } from '@grafana/ui'; + +import { + successfulAnnotationQueryWithData, + failedAnnotationQueryWithMultipleErrors, + successfulAnnotationQueryWithoutData, + failedAnnotationQuery, +} from '../mocks/queries'; + +interface Scenario { + name: string; + mock: object; + text: string; + severity: AlertVariant; + status: number; +} + +const scenarios: Scenario[] = [ + { name: 'error', severity: 'error', mock: failedAnnotationQuery, text: 'Google API Error 400', status: 400 }, + { + name: 'multiple errors', + severity: 'error', + mock: failedAnnotationQueryWithMultipleErrors, + text: 'Google API Error 400Google API Error 401', + status: 400, + }, + { + name: 'data', + severity: 'success', + mock: successfulAnnotationQueryWithData, + text: '2 events (from 2 fields)', + status: 200, + }, + { + name: 'empty result', + severity: 'warning', + mock: successfulAnnotationQueryWithoutData, + text: 'No events found', + status: 200, + }, +]; + +for (const scenario of scenarios) { + test(`annotation query data with ${scenario.name}`, async ({ annotationEditPage, page }) => { + annotationEditPage.mockQueryDataResponse(scenario.mock, scenario.status); + await annotationEditPage.datasource.set('gdev-testdata'); + await page.getByLabel('Scenario').last().fill('CSV Content'); + await page.keyboard.press('Tab'); + await annotationEditPage.runQuery(); + await expect(annotationEditPage).toHaveAlert(scenario.severity, { hasText: scenario.text }); + }); +} diff --git a/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/datasourceConfigPage.spec.ts b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/datasourceConfigPage.spec.ts new file mode 100644 index 0000000000000..1191f153fb422 --- /dev/null +++ b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/datasourceConfigPage.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@grafana/plugin-e2e'; + +import { formatExpectError } from '../errors'; + +test.describe('test createDataSourceConfigPage fixture, saveAndTest and toBeOK matcher', () => { + test('invalid credentials should return an error', async ({ createDataSourceConfigPage, page }) => { + const configPage = await createDataSourceConfigPage({ type: 'prometheus' }); + await page.getByPlaceholder('http://localhost:9090').fill('http://localhost:9090'); + await expect( + configPage.saveAndTest(), + formatExpectError('Expected save data source config to fail when Prometheus server is not running') + ).not.toBeOK(); + }); + + test('valid credentials should return a 200 status code', async ({ createDataSourceConfigPage, page }) => { + const configPage = await createDataSourceConfigPage({ type: 'prometheus' }); + configPage.mockHealthCheckResponse({ status: 200 }); + await page.getByPlaceholder('http://localhost:9090').fill('http://localhost:9090'); + await expect( + configPage.saveAndTest(), + formatExpectError('Expected data source config to be successfully saved') + ).toBeOK(); + }); +}); + +test.describe('test data source with frontend only health check', () => { + test('valid credentials should display a success alert on the page', async ({ + createDataSourceConfigPage, + page, + selectors, + }) => { + const configPage = await createDataSourceConfigPage({ type: 'zipkin' }); + const healthCheckPath = `${selectors.apis.DataSource.proxy(configPage.datasource.uid)}/api/v2/services`; + await page.route(healthCheckPath, async (route) => { + await route.fulfill({ status: 200, body: 'OK' }); + }); + await page.getByPlaceholder('http://localhost:9411').fill('http://localhost:9411'); + await expect(configPage.saveAndTest({ path: healthCheckPath })).toBeOK(); + await expect( + configPage, + formatExpectError('Expected data source config to display success alert after save') + ).toHaveAlert('success', { hasNotText: 'Datasource updated' }); + }); +}); diff --git a/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/explorePage.spec.ts b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/explorePage.spec.ts new file mode 100644 index 0000000000000..d8780318f5a59 --- /dev/null +++ b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/explorePage.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@grafana/plugin-e2e'; + +import { formatExpectError } from '../errors'; + +test('query data response should be OK when query is valid', async ({ explorePage }) => { + await explorePage.datasource.set('gdev-testdata'); + await expect(explorePage.runQuery(), formatExpectError('Expected Explore query to execute successfully')).toBeOK(); +}); + +test('query data response should not be OK when query is invalid', async ({ explorePage }) => { + await explorePage.datasource.set('gdev-testdata'); + const queryEditorRow = await explorePage.getQueryEditorRow('A'); + await queryEditorRow.getByLabel('Labels').fill('invalid-label-format'); + await expect(explorePage.runQuery(), formatExpectError('Expected Explore query to fail')).not.toBeOK(); +}); diff --git a/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/featureToggles.spec.ts b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/featureToggles.spec.ts new file mode 100644 index 0000000000000..48ded217e40d3 --- /dev/null +++ b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/featureToggles.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@grafana/plugin-e2e'; + +const TRUTHY_CUSTOM_TOGGLE = 'custom_toggle1'; +const FALSY_CUSTOM_TOGGLE = 'custom_toggle2'; + +// override the feature toggles defined in playwright.config.ts only for tests in this file +test.use({ + featureToggles: { + [TRUTHY_CUSTOM_TOGGLE]: true, + [FALSY_CUSTOM_TOGGLE]: false, + }, +}); + +test('should set and check feature toggles correctly', async ({ isFeatureToggleEnabled }) => { + expect(await isFeatureToggleEnabled(TRUTHY_CUSTOM_TOGGLE)).toBeTruthy(); + expect(await isFeatureToggleEnabled(FALSY_CUSTOM_TOGGLE)).toBeFalsy(); +}); diff --git a/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts new file mode 100644 index 0000000000000..d6135f30a7e4d --- /dev/null +++ b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts @@ -0,0 +1,101 @@ +import { expect, test, DashboardPage } from '@grafana/plugin-e2e'; + +import { formatExpectError } from '../errors'; +import { successfulDataQuery } from '../mocks/queries'; + +const REACT_TABLE_DASHBOARD = { uid: 'U_bZIMRMk' }; + +test.describe('panel edit page', () => { + test('table panel data assertions with provisioned dashboard', async ({ gotoPanelEditPage }) => { + const panelEditPage = await gotoPanelEditPage({ dashboard: REACT_TABLE_DASHBOARD, id: '4' }); + await expect( + panelEditPage.panel.locator, + formatExpectError('Could not locate panel in panel edit page') + ).toBeVisible(); + await expect( + panelEditPage.panel.fieldNames, + formatExpectError('Could not locate header elements in table panel') + ).toContainText(['Field', 'Max', 'Mean', 'Last']); + }); + + test('table panel data assertions', async ({ panelEditPage }) => { + await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200); + await panelEditPage.datasource.set('gdev-testdata'); + await panelEditPage.setVisualization('Table'); + await panelEditPage.refreshPanel(); + await expect( + panelEditPage.panel.locator, + formatExpectError('Could not locate panel in panel edit page') + ).toBeVisible(); + await expect( + panelEditPage.panel.fieldNames, + formatExpectError('Could not locate header elements in table panel') + ).toContainText(['col1', 'col2']); + await expect(panelEditPage.panel.data, formatExpectError('Could not locate headers in table panel')).toContainText([ + 'val1', + 'val2', + 'val3', + 'val4', + ]); + }); + + test('timeseries panel - table view assertions', async ({ panelEditPage }) => { + await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200); + await panelEditPage.datasource.set('gdev-testdata'); + await panelEditPage.setVisualization('Time series'); + await panelEditPage.refreshPanel(); + await panelEditPage.toggleTableView(); + await expect( + panelEditPage.panel.locator, + formatExpectError('Could not locate panel in panel edit page') + ).toBeVisible(); + await expect( + panelEditPage.panel.fieldNames, + formatExpectError('Could not locate header elements in table panel') + ).toContainText(['col1', 'col2']); + await expect( + panelEditPage.panel.data, + formatExpectError('Could not locate data elements in table panel') + ).toContainText(['val1', 'val2', 'val3', 'val4']); + }); +}); + +test.describe('dashboard page', () => { + test('getting panel by title', async ({ gotoDashboardPage }) => { + const dashboardPage = await gotoDashboardPage(REACT_TABLE_DASHBOARD); + await dashboardPage.goto(); + const panel = await dashboardPage.getPanelByTitle('Colored background'); + await expect(panel.fieldNames).toContainText(['Field', 'Max', 'Mean', 'Last']); + }); + + test('getting panel by id', async ({ gotoDashboardPage }) => { + const dashboardPage = await gotoDashboardPage(REACT_TABLE_DASHBOARD); + await dashboardPage.goto(); + const panel = await dashboardPage.getPanelById('4'); + await expect(panel.fieldNames, formatExpectError('Could not locate header elements in table panel')).toContainText([ + 'Field', + 'Max', + 'Mean', + 'Last', + ]); + }); +}); + +test.describe('explore page', () => { + test('table panel', async ({ explorePage }) => { + const url = + 'left=%7B"datasource":"grafana","queries":%5B%7B"queryType":"randomWalk","refId":"A","datasource":%7B"type":"datasource","uid":"grafana"%7D%7D%5D,"range":%7B"from":"1547161200000","to":"1576364400000"%7D%7D&orgId=1'; + await explorePage.goto({ + queryParams: new URLSearchParams(url), + }); + await expect( + explorePage.timeSeriesPanel.locator, + formatExpectError('Could not locate time series panel in explore page') + ).toBeVisible(); + await expect( + explorePage.tablePanel.locator, + formatExpectError('Could not locate table panel in explore page') + ).toBeVisible(); + await expect(explorePage.tablePanel.fieldNames).toContainText(['time', 'A-series']); + }); +}); diff --git a/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts new file mode 100644 index 0000000000000..bab1b57ac7e8a --- /dev/null +++ b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts @@ -0,0 +1,86 @@ +import { DashboardPage, expect, test } from '@grafana/plugin-e2e'; + +import { formatExpectError } from '../errors'; +import { successfulDataQuery } from '../mocks/queries'; +import { scenarios } from '../mocks/resources'; + +const PANEL_TITLE = 'Table panel E2E test'; +const TABLE_VIZ_NAME = 'Table'; +const STANDARD_OTIONS_CATEGORY = 'Standard options'; +const DISPLAY_NAME_LABEL = 'Display name'; + +test.describe('query editor query data', () => { + test('query data response should be OK when query is valid', async ({ panelEditPage }) => { + await panelEditPage.datasource.set('gdev-testdata'); + await expect( + panelEditPage.refreshPanel(), + formatExpectError('Expected panel query to execute successfully') + ).toBeOK(); + }); + + test('query data response should not be OK and panel error should be displayed when query is invalid', async ({ + panelEditPage, + }) => { + await panelEditPage.datasource.set('gdev-testdata'); + const queryEditorRow = await panelEditPage.getQueryEditorRow('A'); + await queryEditorRow.getByLabel('Labels').fill('invalid-label-format'); + await expect(panelEditPage.refreshPanel(), formatExpectError('Expected panel query to fail')).not.toBeOK(); + await expect( + panelEditPage.panel.getErrorIcon(), + formatExpectError('Expected panel error to be displayed after query execution') + ).toBeVisible(); + }); +}); + +test.describe('query editor with mocked responses', () => { + test('and resource `scenarios` is mocked', async ({ selectors, dashboardPage }) => { + await dashboardPage.mockResourceResponse('scenarios', scenarios); + const panelEditPage = await dashboardPage.addPanel(); + await panelEditPage.datasource.set('gdev-testdata'); + const queryEditorRow = await panelEditPage.getQueryEditorRow('A'); + await queryEditorRow.getByLabel('Scenario').last().click(); + await expect( + panelEditPage.getByTestIdOrAriaLabel(selectors.components.Select.option), + formatExpectError('Expected certain select options to be displayed after clicking on the select input') + ).toHaveText(scenarios.map((s) => s.name)); + }); + + test('mocked query data response', async ({ panelEditPage, selectors }) => { + await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200); + await panelEditPage.datasource.set('gdev-testdata'); + await panelEditPage.setVisualization(TABLE_VIZ_NAME); + await panelEditPage.refreshPanel(); + await expect( + panelEditPage.panel.getErrorIcon(), + formatExpectError('Did not expect panel error to be displayed after query execution') + ).not.toBeVisible(); + await expect( + panelEditPage.getByTestIdOrAriaLabel(selectors.components.Panels.Visualization.Table.body), + formatExpectError('Expected certain select options to be displayed after clicking on the select input') + ).toHaveText('val1val2val3val4'); + }); +}); + +test.describe('edit panel plugin settings', () => { + test('change viz to table panel, set panel title and collapse section', async ({ + panelEditPage, + selectors, + page, + }) => { + await panelEditPage.setVisualization(TABLE_VIZ_NAME); + await expect( + panelEditPage.getByTestIdOrAriaLabel(selectors.components.PanelEditor.toggleVizPicker), + formatExpectError('Expected panel visualization to be set to table') + ).toHaveText(TABLE_VIZ_NAME); + await panelEditPage.setPanelTitle(PANEL_TITLE); + await expect( + panelEditPage.getByTestIdOrAriaLabel(selectors.components.Panels.Panel.title(PANEL_TITLE)), + formatExpectError('Expected panel title to be updated') + ).toBeVisible(); + await panelEditPage.collapseSection(STANDARD_OTIONS_CATEGORY); + await expect( + page.getByText(DISPLAY_NAME_LABEL), + formatExpectError('Expected section to be collapsed') + ).toBeVisible(); + }); +}); diff --git a/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/variableEditPage.spec.ts b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/variableEditPage.spec.ts new file mode 100644 index 0000000000000..f76a5bc83a0bb --- /dev/null +++ b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/variableEditPage.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@grafana/plugin-e2e'; + +import { formatExpectError } from '../errors'; +import { prometheusLabels } from '../mocks/resources'; + +test('variable query with mocked response', async ({ variableEditPage, page }) => { + variableEditPage.mockResourceResponse('api/v1/labels?*', prometheusLabels); + await variableEditPage.datasource.set('gdev-prometheus'); + await variableEditPage.getByTestIdOrAriaLabel('Query type').fill('Label names'); + await page.keyboard.press('Tab'); + await variableEditPage.runQuery(); + await expect( + variableEditPage, + formatExpectError('Expected variable edit page to display certain label names after query execution') + ).toDisplayPreviews(prometheusLabels.data); +}); diff --git a/e2e/plugin-e2e/plugin-e2e-api-tests/as-viewer-user/permissions.spec.ts b/e2e/plugin-e2e/plugin-e2e-api-tests/as-viewer-user/permissions.spec.ts new file mode 100644 index 0000000000000..6346151f2cd74 --- /dev/null +++ b/e2e/plugin-e2e/plugin-e2e-api-tests/as-viewer-user/permissions.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@grafana/plugin-e2e'; + +test('should redirect to start page when permissions to navigate to page is missing', async ({ page }) => { + await page.goto('/'); + const homePageTitle = await page.title(); + await page.goto('/datasources', { waitUntil: 'networkidle' }); + expect(await page.title()).toEqual(homePageTitle); +}); + +test('current user should have viewer role', async ({ page, request }) => { + await page.goto('/'); + const response = await request.get('/api/user/orgs'); + await expect(response).toBeOK(); + await expect(await response.json()).toContainEqual(expect.objectContaining({ role: 'Viewer' })); +}); diff --git a/e2e/plugin-e2e/plugin-e2e-api-tests/errors.ts b/e2e/plugin-e2e/plugin-e2e-api-tests/errors.ts new file mode 100644 index 0000000000000..db7f985806ae1 --- /dev/null +++ b/e2e/plugin-e2e/plugin-e2e-api-tests/errors.ts @@ -0,0 +1,4 @@ +export const formatExpectError = (message: string) => { + return `Error while verifying @grafana/plugin-e2e scenarios: ${message}. + See https://github.com/grafana/grafana/blob/main/plugin-e2e/plugin-e2e-api-tests/README.md for more information.`; +}; diff --git a/e2e/plugin-e2e/plugin-e2e-api-tests/mocks/queries.ts b/e2e/plugin-e2e/plugin-e2e-api-tests/mocks/queries.ts new file mode 100644 index 0000000000000..d39c8dc02dc24 --- /dev/null +++ b/e2e/plugin-e2e/plugin-e2e-api-tests/mocks/queries.ts @@ -0,0 +1,138 @@ +export const successfulDataQuery = { + results: { + A: { + status: 200, + frames: [ + { + schema: { + refId: 'A', + fields: [ + { + name: 'col1', + type: 'string', + typeInfo: { + frame: 'string', + nullable: true, + }, + }, + { + name: 'col2', + type: 'string', + typeInfo: { + frame: 'string', + nullable: true, + }, + }, + ], + }, + data: { + values: [ + ['val1', 'val3'], + ['val2', 'val4'], + ], + }, + }, + ], + }, + }, +}; + +export const failedAnnotationQuery: object = { + results: { + Anno: { + error: 'Google API Error 400', + errorSource: '', + status: 500, + }, + }, +}; + +export const failedAnnotationQueryWithMultipleErrors: object = { + results: { + Anno1: { + error: 'Google API Error 400', + errorSource: '', + status: 400, + }, + Anno2: { + error: 'Google API Error 401', + errorSource: '', + status: 401, + }, + }, +}; + +export const successfulAnnotationQueryWithData: object = { + results: { + Anno: { + status: 200, + frames: [ + { + schema: { + refId: 'Anno', + fields: [ + { + name: 'time', + type: 'time', + typeInfo: { + frame: 'time.Time', + nullable: true, + }, + }, + { + name: 'col2', + type: 'string', + typeInfo: { + frame: 'string', + nullable: true, + }, + }, + ], + }, + data: { + values: [ + [1702973084093, 1702973084099], + ['val1', 'val2'], + ], + }, + }, + ], + }, + }, +}; + +export const successfulAnnotationQueryWithoutData: object = { + results: { + Anno: { + status: 200, + frames: [ + { + schema: { + refId: 'Anno', + fields: [ + { + name: 'time', + type: 'time', + typeInfo: { + frame: 'time.Time', + nullable: true, + }, + }, + { + name: 'col2', + type: 'string', + typeInfo: { + frame: 'string', + nullable: true, + }, + }, + ], + }, + data: { + values: [], + }, + }, + ], + }, + }, +}; diff --git a/e2e/plugin-e2e/plugin-e2e-api-tests/mocks/resources.ts b/e2e/plugin-e2e/plugin-e2e-api-tests/mocks/resources.ts new file mode 100644 index 0000000000000..658a74921ea0e --- /dev/null +++ b/e2e/plugin-e2e/plugin-e2e-api-tests/mocks/resources.ts @@ -0,0 +1,19 @@ +export const scenarios = [ + { + description: '', + id: 'annotations', + name: 'Annotations', + stringInput: '', + }, + { + description: '', + id: 'arrow', + name: 'Load Apache Arrow Data', + stringInput: '', + }, +]; + +export const prometheusLabels = { + status: 'success', + data: ['__name__', 'action', 'active', 'address'], +}; diff --git a/e2e/utils/support/monaco.ts b/e2e/utils/support/monaco.ts new file mode 100644 index 0000000000000..54c528877edc4 --- /dev/null +++ b/e2e/utils/support/monaco.ts @@ -0,0 +1,7 @@ +import { e2e } from '../index'; + +export function waitForMonacoToLoad() { + e2e.components.QueryField.container().children('[data-testid="Spinner"]').should('not.exist'); + cy.window().its('monaco').should('exist'); + cy.get('.monaco-editor textarea:first').should('exist'); +} diff --git a/e2e/utils/support/types.ts b/e2e/utils/support/types.ts index 193ad190b246a..e7ccfe91db705 100644 --- a/e2e/utils/support/types.ts +++ b/e2e/utils/support/types.ts @@ -12,14 +12,14 @@ export type E2EFunctionWithOnlyOptions = (options?: CypressOptions) => Cypress.C export type TypeSelectors<S> = S extends StringSelector ? E2EFunctionWithOnlyOptions : S extends FunctionSelector - ? E2EFunction - : S extends CssSelector - ? E2EFunction - : S extends UrlSelector - ? E2EVisit & Omit<E2EFunctions<S>, 'url'> - : S extends Record<string, string | FunctionSelector | CssSelector | UrlSelector | Selectors> - ? E2EFunctions<S> - : S; + ? E2EFunction + : S extends CssSelector + ? E2EFunction + : S extends UrlSelector + ? E2EVisit & Omit<E2EFunctions<S>, 'url'> + : S extends Record<string, string | FunctionSelector | CssSelector | UrlSelector | Selectors> + ? E2EFunctions<S> + : S; export type E2EFunctions<S extends Selectors> = { [P in keyof S]: TypeSelectors<S[P]>; diff --git a/e2e/various-suite/exemplars.spec.ts b/e2e/various-suite/exemplars.spec.ts index 62d1ce43ea887..60f5838652092 100644 --- a/e2e/various-suite/exemplars.spec.ts +++ b/e2e/various-suite/exemplars.spec.ts @@ -1,4 +1,5 @@ import { e2e } from '../utils'; +import { waitForMonacoToLoad } from '../utils/support/monaco'; const dataSourceName = 'PromExemplar'; const addDataSource = () => { @@ -10,7 +11,7 @@ const addDataSource = () => { e2e.components.DataSource.Prometheus.configPage.exemplarsAddButton().click(); e2e.components.DataSource.Prometheus.configPage.internalLinkSwitch().check({ force: true }); e2e.components.DataSource.Prometheus.configPage.connectionSettings().type('http://prom-url:9090'); - e2e.components.DataSourcePicker.inputV2().click({ force: true }).should('have.focus'); + e2e.components.DataSourcePicker.inputV2().click().should('have.focus'); cy.contains('gdev-tempo').scrollIntoView().should('be.visible').click(); }, @@ -57,12 +58,8 @@ describe('Exemplars', () => { // Switch to code editor e2e.components.RadioButton.container().filter(':contains("Code")').click(); - // we need to wait for the query-field being lazy-loaded, in two steps: - // 1. first we wait for the text 'Loading...' to appear - // 1. then we wait for the text 'Loading...' to disappear - const monacoLoadingText = 'Loading...'; - e2e.components.QueryField.container().should('be.visible').should('have.text', monacoLoadingText); - e2e.components.QueryField.container().should('be.visible').should('not.have.text', monacoLoadingText); + // Wait for lazy loading Monaco + waitForMonacoToLoad(); e2e.components.TimePicker.openButton().click(); e2e.components.TimePicker.fromField().clear().type('2021-07-10 17:10:00'); @@ -72,7 +69,7 @@ describe('Exemplars', () => { cy.get(`[data-testid="time-series-zoom-to-data"]`).click(); - e2e.components.DataSource.Prometheus.exemplarMarker().first().trigger('mouseover'); + e2e.components.DataSource.Prometheus.exemplarMarker().first().trigger('mousemove'); cy.contains('Query with gdev-tempo').click(); e2e.components.TraceViewer.spanBar().should('have.length', 11); }); diff --git a/e2e/various-suite/explore.spec.ts b/e2e/various-suite/explore.spec.ts index 0207aa5d82a44..69d74c29e988a 100644 --- a/e2e/various-suite/explore.spec.ts +++ b/e2e/various-suite/explore.spec.ts @@ -10,14 +10,6 @@ describe('Explore', () => { e2e.pages.Explore.General.container().should('have.length', 1); e2e.components.RefreshPicker.runButtonV2().should('have.length', 1); - // delete query history queries that would be unrelated - e2e.components.QueryTab.queryHistoryButton().should('be.visible').click(); - cy.get('button[title="Delete query"]').each((button) => { - button.trigger('click'); - }); - cy.get('button[title="Delete query"]').should('not.exist'); - e2e.components.QueryTab.queryHistoryButton().should('be.visible').click(); - e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer() .scrollIntoView() .should('be.visible') @@ -26,17 +18,5 @@ describe('Explore', () => { }); cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').click(); - - const canvases = cy.get('canvas'); - canvases.should('have.length', 1); - - // Both queries above should have been run and be shown in the query history - e2e.components.QueryTab.queryHistoryButton().should('be.visible').click(); - e2e.components.QueryHistory.queryText().should('have.length', 1).should('contain', 'csv_metric_values'); - - // delete all queries - cy.get('button[title="Delete query"]').each((button) => { - button.trigger('click'); - }); }); }); diff --git a/e2e/various-suite/frontend-sandbox-app.spec.ts b/e2e/various-suite/frontend-sandbox-app.spec.ts index 9bcf4d49832db..b3e17ece6a93a 100644 --- a/e2e/various-suite/frontend-sandbox-app.spec.ts +++ b/e2e/various-suite/frontend-sandbox-app.spec.ts @@ -26,21 +26,14 @@ describe('Datasource sandbox', () => { }); it('Loads the app page without the sandbox div wrapper', () => { - e2e.pages.Home.visit(); - e2e.components.NavBar.Toggle.button().click(); - e2e.components.NavToolbar.container().get('[aria-label="Expand section Apps"]').click(); - e2e.components.NavMenu.item().contains('Sandbox app test plugin').click(); + cy.visit(`/a/${APP_ID}`); cy.wait(200); // wait to prevent false positives because cypress checks too fast cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('not.exist'); cy.get('div[data-testid="sandbox-app-test-page-one"]').should('exist'); }); it('Loads the app configuration without the sandbox div wrapper', () => { - e2e.pages.Home.visit(); - e2e.components.NavBar.Toggle.button().click(); - e2e.components.NavToolbar.container().get('[aria-label="Expand section Apps"]').click(); - e2e.components.NavMenu.item().contains('Apps').click(); - cy.get('a[aria-label="Tab Sandbox App Page"]').click(); + cy.visit(`/plugins/${APP_ID}`); cy.wait(200); // wait to prevent false positives because cypress checks too fast cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('not.exist'); cy.get('div[data-testid="sandbox-app-test-config-page"]').should('exist'); @@ -55,20 +48,13 @@ describe('Datasource sandbox', () => { }); it('Loads the app page with the sandbox div wrapper', () => { - e2e.pages.Home.visit(); - e2e.components.NavBar.Toggle.button().click(); - e2e.components.NavToolbar.container().get('[aria-label="Expand section Apps"]').click(); - e2e.components.NavMenu.item().contains('Sandbox app test plugin').click(); + cy.visit(`/a/${APP_ID}`); cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('exist'); cy.get('div[data-testid="sandbox-app-test-page-one"]').should('exist'); }); it('Loads the app configuration with the sandbox div wrapper', () => { - e2e.pages.Home.visit(); - e2e.components.NavBar.Toggle.button().click(); - e2e.components.NavToolbar.container().get('[aria-label="Expand section Apps"]').click(); - e2e.components.NavMenu.item().contains('Apps').click(); - cy.get('a[aria-label="Tab Sandbox App Page"]').click(); + cy.visit(`/plugins/${APP_ID}`); cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('exist'); cy.get('div[data-testid="sandbox-app-test-config-page"]').should('exist'); }); diff --git a/e2e/various-suite/graph-auto-migrate.spec.ts b/e2e/various-suite/graph-auto-migrate.spec.ts index 4360477ce6114..eedb7a043e616 100644 --- a/e2e/various-suite/graph-auto-migrate.spec.ts +++ b/e2e/various-suite/graph-auto-migrate.spec.ts @@ -1,19 +1,51 @@ import { e2e } from '../utils'; + const DASHBOARD_ID = 'XMjIZPmik'; const DASHBOARD_NAME = 'Panel Tests - Graph Time Regions'; +const UPLOT_MAIN_DIV_SELECTOR = '[data-testid="uplot-main-div"]'; describe('Auto-migrate graph panel', () => { beforeEach(() => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); }); - it('Annotation markers exist for time regions', () => { + it('Graph panel is migrated with `autoMigrateOldPanels` feature toggle', () => { e2e.flows.openDashboard({ uid: DASHBOARD_ID }); cy.contains(DASHBOARD_NAME).should('be.visible'); - cy.contains('uplot-main-div').should('not.exist'); + cy.get(UPLOT_MAIN_DIV_SELECTOR).should('not.exist'); e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.autoMigrateOldPanels': true } }); + cy.get(UPLOT_MAIN_DIV_SELECTOR).should('exist'); + }); + + it('Graph panel is migrated with config `disableAngular` feature toggle', () => { + e2e.flows.openDashboard({ uid: DASHBOARD_ID }); + cy.contains(DASHBOARD_NAME).should('be.visible'); + cy.get(UPLOT_MAIN_DIV_SELECTOR).should('not.exist'); + + e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.disableAngular': true } }); + + cy.get(UPLOT_MAIN_DIV_SELECTOR).should('exist'); + }); + + it('Graph panel is migrated with `autoMigrateGraphPanel` feature toggle', () => { + e2e.flows.openDashboard({ uid: DASHBOARD_ID }); + cy.contains(DASHBOARD_NAME).should('be.visible'); + cy.get(UPLOT_MAIN_DIV_SELECTOR).should('not.exist'); + + e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.autoMigrateGraphPanel': true } }); + + cy.get(UPLOT_MAIN_DIV_SELECTOR).should('exist'); + }); + + it('Annotation markers exist for time regions', () => { + e2e.flows.openDashboard({ uid: DASHBOARD_ID }); + cy.contains(DASHBOARD_NAME).should('be.visible'); + cy.get(UPLOT_MAIN_DIV_SELECTOR).should('not.exist'); + + e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.autoMigrateGraphPanel': true } }); + e2e.components.Panels.Panel.title('Business Hours') .should('exist') .within(() => { @@ -32,7 +64,7 @@ describe('Auto-migrate graph panel', () => { e2e.pages.Dashboard.Annotations.marker().should('exist'); }); - cy.get('body').children().find('.scrollbar-view').first().scrollTo('bottom'); + cy.get('#pageContent .scrollbar-view').first().scrollTo('bottom'); e2e.components.Panels.Panel.title('05:00') .should('exist') diff --git a/e2e/various-suite/helpers/prometheus-helpers.ts b/e2e/various-suite/helpers/prometheus-helpers.ts new file mode 100644 index 0000000000000..265095ecd6591 --- /dev/null +++ b/e2e/various-suite/helpers/prometheus-helpers.ts @@ -0,0 +1,62 @@ +import { e2e } from '../../utils'; + +/** + * Create a Prom data source + */ +export function createPromDS(dataSourceID: string, name: string): void { + // login + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); + + // select the prometheus DS + e2e.pages.AddDataSource.visit(); + e2e.pages.AddDataSource.dataSourcePluginsV2(dataSourceID) + .scrollIntoView() + .should('be.visible') // prevents flakiness + .click(); + + // add url for DS to save without error + e2e.components.DataSource.Prometheus.configPage.connectionSettings().type('http://prom-url:9090'); + + // name the DS + e2e.pages.DataSource.name().clear(); + e2e.pages.DataSource.name().type(name); + e2e.pages.DataSource.saveAndTest().click(); +} + +export function getResources() { + cy.intercept(/__name__/g, metricResponse); + + cy.intercept(/metadata/g, metadataResponse); + + cy.intercept(/labels/g, labelsResponse); +} + +const metricResponse = { + status: 'success', + data: ['metric1', 'metric2'], +}; + +const metadataResponse = { + status: 'success', + data: { + metric1: [ + { + type: 'counter', + help: 'metric1 help', + unit: '', + }, + ], + metric2: [ + { + type: 'counter', + help: 'metric2 help', + unit: '', + }, + ], + }, +}; + +const labelsResponse = { + status: 'success', + data: ['__name__', 'action', 'active', 'backend'], +}; diff --git a/e2e/various-suite/loki-editor.spec.ts b/e2e/various-suite/loki-editor.spec.ts index 189c9d5a131cb..42bf063c1370b 100644 --- a/e2e/various-suite/loki-editor.spec.ts +++ b/e2e/various-suite/loki-editor.spec.ts @@ -1,4 +1,5 @@ import { e2e } from '../utils'; +import { waitForMonacoToLoad } from '../utils/support/monaco'; const dataSourceName = 'LokiEditor'; const addDataSource = () => { @@ -39,11 +40,7 @@ describe('Loki Query Editor', () => { e2e.components.RadioButton.container().filter(':contains("Code")').click(); - // Wait for lazy loading - const monacoLoadingText = 'Loading...'; - - e2e.components.QueryField.container().should('be.visible').should('have.text', monacoLoadingText); - e2e.components.QueryField.container().should('be.visible').should('not.have.text', monacoLoadingText); + waitForMonacoToLoad(); // adds closing braces around empty value e2e.components.QueryField.container().type('time('); diff --git a/e2e/various-suite/loki-query-builder.spec.ts b/e2e/various-suite/loki-query-builder.spec.ts index a635fff25673f..1707a32dd859f 100644 --- a/e2e/various-suite/loki-query-builder.spec.ts +++ b/e2e/various-suite/loki-query-builder.spec.ts @@ -43,6 +43,10 @@ describe('Loki query builder', () => { req.reply({ status: 'success', data: ['instance1', 'instance2'] }); }).as('valuesRequest'); + cy.intercept(/index\/stats/, (req) => { + req.reply({ streams: 2, chunks: 2660, bytes: 2721792, entries: 14408 }); + }); + // Go to Explore and choose Loki data source e2e.pages.Explore.visit(); e2e.components.DataSourcePicker.container().should('be.visible').click(); @@ -68,21 +72,20 @@ describe('Loki query builder', () => { // Add labels to remove error e2e.components.QueryBuilder.labelSelect().should('be.visible').click(); // wait until labels are loaded and set on the component before starting to type + e2e.components.QueryBuilder.labelSelect().children('div').children('input').type('i'); cy.wait('@labelsRequest'); - e2e.components.QueryBuilder.labelSelect().children('div').children('input').type('instance{enter}'); + e2e.components.QueryBuilder.labelSelect().children('div').children('input').type('nstance{enter}'); e2e.components.QueryBuilder.matchOperatorSelect() .should('be.visible') - .click() + .click({ force: true }) .children('div') .children('input') .type('=~{enter}', { force: true }); e2e.components.QueryBuilder.valueSelect().should('be.visible').click(); + e2e.components.QueryBuilder.valueSelect().children('div').children('input').type('instance1{enter}'); cy.wait('@valuesRequest'); - e2e.components.QueryBuilder.valueSelect() - .children('div') - .children('input') - .type('instance1{enter}') - .type('instance2{enter}'); + e2e.components.QueryBuilder.valueSelect().children('div').children('input').type('instance2{enter}'); + cy.contains(MISSING_LABEL_FILTER_ERROR_MESSAGE).should('not.exist'); cy.contains(finalQuery).should('be.visible'); diff --git a/e2e/various-suite/loki-table-explore-to-dash.spec.ts b/e2e/various-suite/loki-table-explore-to-dash.spec.ts index 5a6bb85dade50..190a5ea5a14de 100644 --- a/e2e/various-suite/loki-table-explore-to-dash.spec.ts +++ b/e2e/various-suite/loki-table-explore-to-dash.spec.ts @@ -147,10 +147,18 @@ describe('Loki Query Editor', () => { cy.contains('Code').click({ force: true }); // Wait for lazy loading - const monacoLoadingText = 'Loading...'; - - e2e.components.QueryField.container().should('be.visible').should('have.text', monacoLoadingText); - e2e.components.QueryField.container().should('be.visible').should('not.have.text', monacoLoadingText); + // const monacoLoadingText = 'Loading...'; + + // e2e.components.QueryField.container().should('be.visible').should('have.text', monacoLoadingText); + e2e.components.QueryField.container() + .find('.view-overlays[role="presentation"]') + .get('.cdr') + .then(($el) => { + const win = $el[0].ownerDocument.defaultView; + const after = win.getComputedStyle($el[0], '::after'); + const content = after.getPropertyValue('content'); + expect(content).to.eq('"Enter a Loki query (run with Shift+Enter)"'); + }); // Write a simple query e2e.components.QueryField.container().type('query').type('{instance="instance1"'); diff --git a/e2e/various-suite/mysql.spec.ts b/e2e/various-suite/mysql.spec.ts index 99746b008cada..b78db4bfe18fb 100644 --- a/e2e/various-suite/mysql.spec.ts +++ b/e2e/various-suite/mysql.spec.ts @@ -34,9 +34,12 @@ describe('MySQL datasource', () => { cy.wait('@datasets'); }); - it('code editor autocomplete should handle table name escaping/quoting', () => { + it.skip('code editor autocomplete should handle table name escaping/quoting', () => { e2e.components.RadioButton.container().filter(':contains("Code")').click(); + e2e.components.CodeEditor.container().children('[data-testid="Spinner"]').should('not.exist'); + cy.window().its('monaco').should('exist'); + cy.get('textarea').type('S{downArrow}{enter}'); cy.wait('@tables'); cy.get('.suggest-widget').contains(tableNameWithSpecialCharacter).should('be.visible'); @@ -88,8 +91,10 @@ describe('MySQL datasource', () => { cy.get("[aria-label='Macros value selector']").should('be.visible').click(); selectOption('timeFilter'); - // Validate that the timeFilter macro was added + e2e.components.CodeEditor.container().children('[data-testid="Spinner"]').should('not.exist'); + cy.window().its('monaco').should('exist'); + // Validate that the timeFilter macro was added e2e.components.CodeEditor.container() .get('textarea') .should( diff --git a/e2e/various-suite/navigation.spec.ts b/e2e/various-suite/navigation.spec.ts index b068d56f3ad37..099a0c875f4c5 100644 --- a/e2e/various-suite/navigation.spec.ts +++ b/e2e/various-suite/navigation.spec.ts @@ -6,11 +6,7 @@ describe('Docked Navigation', () => { cy.viewport(1280, 800); e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - cy.visit(fromBaseUrl('/'), { - onBeforeLoad(window) { - window.localStorage.setItem('grafana.featureToggles', 'dockedMegaMenu=1'); - }, - }); + cy.visit(fromBaseUrl('/')); }); it('should remain docked when reloading the page', () => { diff --git a/e2e/various-suite/prometheus-annotations.spec.ts b/e2e/various-suite/prometheus-annotations.spec.ts new file mode 100644 index 0000000000000..b32620dda70c7 --- /dev/null +++ b/e2e/various-suite/prometheus-annotations.spec.ts @@ -0,0 +1,75 @@ +import { selectors } from '@grafana/e2e-selectors'; + +import { e2e } from '../utils'; +import { addDashboard } from '../utils/flows'; + +import { createPromDS, getResources } from './helpers/prometheus-helpers'; + +const DATASOURCE_ID = 'Prometheus'; + +const DATASOURCE_NAME = 'aprometheusAnnotationDS'; + +/** + * Click dashboard settings and then the variables tab + * + */ +function navigateToAnnotations() { + e2e.components.PageToolbar.item('Dashboard settings').click(); + e2e.components.Tab.title('Annotations').click(); +} + +function addPrometheusAnnotation(annotationName: string) { + e2e.pages.Dashboard.Settings.Annotations.List.addAnnotationCTAV2().click(); + getResources(); + e2e.pages.Dashboard.Settings.Annotations.Settings.name().clear().type(annotationName); + e2e.components.DataSourcePicker.container().should('be.visible').click(); + cy.contains(DATASOURCE_NAME).scrollIntoView().should('be.visible').click(); +} + +describe('Prometheus annotations', () => { + beforeEach(() => { + createPromDS(DATASOURCE_ID, DATASOURCE_NAME); + }); + + it('should navigate to variable query editor', () => { + const annotationName = 'promAnnotation'; + addDashboard(); + navigateToAnnotations(); + addPrometheusAnnotation(annotationName); + + e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser + .openButton() + .contains('Metrics browser') + .click(); + + e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.selectMetric().should('exist').type('met'); + + e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser + .metricList() + .should('exist') + .contains('metric1') + .click(); + + e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useQuery().should('exist').click(); + + e2e.components.DataSource.Prometheus.queryEditor.code.queryField().should('exist').contains('metric1'); + + // check for other parts of the annotations + // min step + cy.get(`#${selectors.components.DataSource.Prometheus.annotations.minStep}`); + + // title + e2e.components.DataSource.Prometheus.annotations.title().scrollIntoView().should('exist'); + // tags + e2e.components.DataSource.Prometheus.annotations.tags().scrollIntoView().should('exist'); + // text + e2e.components.DataSource.Prometheus.annotations.text().scrollIntoView().should('exist'); + // series value as timestamp + e2e.components.DataSource.Prometheus.annotations.seriesValueAsTimestamp().scrollIntoView().should('exist'); + + e2e.pages.Dashboard.Settings.Annotations.NewAnnotation.previewInDashboard().click(); + + // check that annotation exists + cy.get('body').contains(annotationName); + }); +}); diff --git a/e2e/various-suite/prometheus-config.spec.ts b/e2e/various-suite/prometheus-config.spec.ts new file mode 100644 index 0000000000000..9903b2393329e --- /dev/null +++ b/e2e/various-suite/prometheus-config.spec.ts @@ -0,0 +1,111 @@ +import { selectors } from '@grafana/e2e-selectors'; + +import { e2e } from '../utils'; + +const DATASOURCE_ID = 'Prometheus'; +const DATASOURCE_TYPED_NAME = 'PrometheusDatasourceInstance'; + +describe('Prometheus config', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); + + e2e.pages.AddDataSource.visit(); + e2e.pages.AddDataSource.dataSourcePluginsV2(DATASOURCE_ID) + .scrollIntoView() + .should('be.visible') // prevents flakiness + .click({ force: true }); + }); + + it(`should have the following components: + connection settings + managed alerts + scrape interval + query timeout + default editor + disable metric lookup + prometheus type + cache level + incremental querying + disable recording rules + custom query parameters + http method + `, () => { + // connection settings + e2e.components.DataSource.Prometheus.configPage.connectionSettings().should('be.visible'); + // managed alerts + cy.get(`#${selectors.components.DataSource.Prometheus.configPage.manageAlerts}`).scrollIntoView().should('exist'); + // scrape interval + e2e.components.DataSource.Prometheus.configPage.scrapeInterval().scrollIntoView().should('exist'); + // query timeout + e2e.components.DataSource.Prometheus.configPage.queryTimeout().scrollIntoView().should('exist'); + // default editor + e2e.components.DataSource.Prometheus.configPage.defaultEditor().scrollIntoView().should('exist'); + // disable metric lookup + cy.get(`#${selectors.components.DataSource.Prometheus.configPage.disableMetricLookup}`) + .scrollIntoView() + .should('exist'); + // prometheus type + e2e.components.DataSource.Prometheus.configPage.prometheusType().scrollIntoView().should('exist'); + // cache level + e2e.components.DataSource.Prometheus.configPage.cacheLevel().scrollIntoView().should('exist'); + // incremental querying + cy.get(`#${selectors.components.DataSource.Prometheus.configPage.incrementalQuerying}`) + .scrollIntoView() + .should('exist'); + // disable recording rules + cy.get(`#${selectors.components.DataSource.Prometheus.configPage.disableRecordingRules}`) + .scrollIntoView() + .should('exist'); + // custom query parameters + e2e.components.DataSource.Prometheus.configPage.customQueryParameters().scrollIntoView().should('exist'); + // http method + e2e.components.DataSource.Prometheus.configPage.httpMethod().scrollIntoView().should('exist'); + }); + + it('should save the default editor when navigating to explore', () => { + e2e.components.DataSource.Prometheus.configPage.defaultEditor().scrollIntoView().should('exist').click(); + + selectOption('Builder'); + + e2e.components.DataSource.Prometheus.configPage.connectionSettings().type('http://prom-url:9090'); + + e2e.pages.DataSource.name().clear(); + e2e.pages.DataSource.name().type(DATASOURCE_TYPED_NAME); + e2e.pages.DataSource.saveAndTest().click(); + + e2e.pages.Explore.visit(); + + e2e.components.DataSourcePicker.container().should('be.visible').click(); + + e2e.components.DataSourcePicker.container().type(`${DATASOURCE_TYPED_NAME}{enter}`); + + e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist'); + }); + + it('should allow a user to add the version when the Prom type is selected', () => { + e2e.components.DataSource.Prometheus.configPage.prometheusType().scrollIntoView().should('exist').click(); + + selectOption('Prometheus'); + + e2e.components.DataSource.Prometheus.configPage.prometheusVersion().scrollIntoView().should('exist'); + }); + + it('should have a cache level component', () => { + e2e.components.DataSource.Prometheus.configPage.cacheLevel().scrollIntoView().should('exist'); + }); + + it('should allow a user to select a query overlap window when incremental querying is selected', () => { + cy.get(`#${selectors.components.DataSource.Prometheus.configPage.incrementalQuerying}`) + .scrollIntoView() + .should('exist') + .check({ force: true }); + + e2e.components.DataSource.Prometheus.configPage.queryOverlapWindow().scrollIntoView().should('exist'); + }); + + // exemplars tested in exemplar.spec +}); + +export function selectOption(option: string) { + cy.get("[aria-label='Select option']").contains(option).should('be.visible').click(); +} diff --git a/e2e/various-suite/prometheus-editor.spec.ts b/e2e/various-suite/prometheus-editor.spec.ts new file mode 100644 index 0000000000000..b5b6b7874b87e --- /dev/null +++ b/e2e/various-suite/prometheus-editor.spec.ts @@ -0,0 +1,182 @@ +import { selectors } from '@grafana/e2e-selectors'; + +import { e2e } from '../utils'; + +import { getResources } from './helpers/prometheus-helpers'; + +const DATASOURCE_ID = 'Prometheus'; + +type editorType = 'Code' | 'Builder'; + +/** + * Login, create and save a Prometheus data source, navigate to code or builder + * + * @param editorType 'Code' or 'Builder' + */ +function navigateToEditor(editorType: editorType, name: string): void { + // login + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); + + // select the prometheus DS + e2e.pages.AddDataSource.visit(); + e2e.pages.AddDataSource.dataSourcePluginsV2(DATASOURCE_ID) + .scrollIntoView() + .should('be.visible') // prevents flakiness + .click(); + + // choose default editor + e2e.components.DataSource.Prometheus.configPage.defaultEditor().scrollIntoView().should('exist').click(); + selectOption(editorType); + + // add url for DS to save without error + e2e.components.DataSource.Prometheus.configPage.connectionSettings().type('http://prom-url:9090'); + + // name the DS + e2e.pages.DataSource.name().clear(); + e2e.pages.DataSource.name().type(name); + e2e.pages.DataSource.saveAndTest().click(); + + // visit explore + e2e.pages.Explore.visit(); + + // choose the right DS + e2e.components.DataSourcePicker.container().should('be.visible').click(); + cy.contains(name).scrollIntoView().should('be.visible').click(); +} + +describe('Prometheus query editor', () => { + it('should have a kickstart component', () => { + navigateToEditor('Code', 'prometheus'); + e2e.components.QueryBuilder.queryPatterns().scrollIntoView().should('exist'); + }); + + it('should have an explain component', () => { + navigateToEditor('Code', 'prometheus'); + e2e.components.DataSource.Prometheus.queryEditor.explain().scrollIntoView().should('exist'); + }); + + it('should have an editor toggle component', () => { + navigateToEditor('Code', 'prometheus'); + e2e.components.DataSource.Prometheus.queryEditor.editorToggle().scrollIntoView().should('exist'); + }); + + it('should have an options component with legend, format, step, type and exemplars', () => { + navigateToEditor('Code', 'prometheus'); + // open options + e2e.components.DataSource.Prometheus.queryEditor.options().scrollIntoView().should('exist').click(); + // check options + e2e.components.DataSource.Prometheus.queryEditor.legend().scrollIntoView().should('exist'); + e2e.components.DataSource.Prometheus.queryEditor.format().scrollIntoView().should('exist'); + cy.get(`#${selectors.components.DataSource.Prometheus.queryEditor.step}`).scrollIntoView().should('exist'); + e2e.components.DataSource.Prometheus.queryEditor.type().scrollIntoView().should('exist'); + cy.get(`#${selectors.components.DataSource.Prometheus.queryEditor.exemplars}`).scrollIntoView().should('exist'); + }); + + describe('Code editor', () => { + it('navigates to the code editor with editor type as code', () => { + navigateToEditor('Code', 'prometheusCode'); + }); + + it('navigates to the code editor and opens the metrics browser with metric search, labels, label values, and all components', () => { + navigateToEditor('Code', 'prometheusCode'); + + getResources(); + + e2e.components.DataSource.Prometheus.queryEditor.code.queryField().should('exist'); + + e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser + .openButton() + .contains('Metrics browser') + .click(); + + e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.selectMetric().should('exist'); + e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelNamesFilter().should('exist'); + e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelValuesFilter().should('exist'); + e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useQuery().should('exist'); + e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useAsRateQuery().should('exist'); + e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.validateSelector().should('exist'); + e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.clear().should('exist'); + }); + + it('selects a metric in the metrics browser and uses the query', () => { + navigateToEditor('Code', 'prometheusCode'); + + getResources(); + + e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser + .openButton() + .contains('Metrics browser') + .click(); + + e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.selectMetric().should('exist').type('met'); + + e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser + .metricList() + .should('exist') + .contains('metric1') + .click(); + + e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useQuery().should('exist').click(); + + e2e.components.DataSource.Prometheus.queryEditor.code.queryField().should('exist').contains('metric1'); + }); + }); + + describe('Query builder', () => { + it('navigates to the query builder with editor type as code', () => { + navigateToEditor('Builder', 'prometheusBuilder'); + }); + + it('the query builder contains metric select, label filters and operations', () => { + navigateToEditor('Builder', 'prometheusBuilder'); + + getResources(); + + e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist'); + e2e.components.QueryBuilder.labelSelect().should('exist'); + e2e.components.QueryBuilder.matchOperatorSelect().should('exist'); + e2e.components.QueryBuilder.valueSelect().should('exist'); + }); + + it('can select a metric and provide a hint', () => { + navigateToEditor('Builder', 'prometheusBuilder'); + + getResources(); + + e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist').click(); + + selectOption('metric1'); + + e2e.components.DataSource.Prometheus.queryEditor.builder.hints().contains('hint: add rate'); + }); + + it('should have the metrics explorer opened via the metric select', () => { + navigateToEditor('Builder', 'prometheusBuilder'); + + getResources(); + + e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist').click(); + + selectOption('Metrics explorer'); + + e2e.components.DataSource.Prometheus.queryEditor.builder.metricsExplorer().should('exist'); + }); + + // NEED TO COMPLETE QUEY ADVISOR WORK OR FIGURE OUT HOW TO ENABLE EXPERIMENTAL FEATURE TOGGLES + // it('should have a query advisor when enabled with feature toggle', () => { + // cy.window().then((win) => { + // win.localStorage.setItem('grafana.featureToggles', 'prometheusPromQAIL=0'); + + // navigateToEditor('Builder', 'prometheusBuilder'); + + // getResources(); + + // e2e.components.DataSource.Prometheus.queryEditor.builder.queryAdvisor().should('exist'); + // }); + // }); + }); +}); + +function selectOption(option: string) { + cy.get("[aria-label='Select option']").contains(option).should('be.visible').click(); +} diff --git a/e2e/various-suite/prometheus-variable-editor.spec.ts b/e2e/various-suite/prometheus-variable-editor.spec.ts new file mode 100644 index 0000000000000..5133e34587045 --- /dev/null +++ b/e2e/various-suite/prometheus-variable-editor.spec.ts @@ -0,0 +1,122 @@ +import { e2e } from '../utils'; +import { addDashboard } from '../utils/flows'; + +import { createPromDS, getResources } from './helpers/prometheus-helpers'; + +const DATASOURCE_ID = 'Prometheus'; + +const DATASOURCE_NAME = 'prometheusVariableDS'; + +/** + * Click dashboard settings and then the variables tab + */ +function navigateToVariables() { + e2e.components.PageToolbar.item('Dashboard settings').click(); + e2e.components.Tab.title('Variables').click(); +} + +/** + * Begin the process of adding a query type variable for a Prometheus data source + * + * @param variableName the name of the variable as a label of the variable dropdown + */ +function addPrometheusQueryVariable(variableName: string) { + e2e.pages.Dashboard.Settings.Variables.List.addVariableCTAV2().click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2().clear().type(variableName); + e2e.components.DataSourcePicker.container().should('be.visible').click(); + cy.contains(DATASOURCE_NAME).scrollIntoView().should('be.visible').click(); + + getResources(); +} + +/** + * Create a Prometheus variable and navigate to the query editor to check that it is available to use. + * + * @param variableName name the variable + * @param queryType query type of 'Label names', 'Label values', 'Metrics', 'Query result', 'Series query' or 'Classic query'. These types should be imported from the Prometheus library eventually but not now because we are in the process of decoupling the DS from core grafana. + */ +function variableFlowToQueryEditor(variableName: string, queryType: string) { + addDashboard(); + navigateToVariables(); + addPrometheusQueryVariable(variableName); + + // select query type + e2e.components.DataSource.Prometheus.variableQueryEditor.queryType().click(); + selectOption(queryType); + + // apply the variable + e2e.pages.Dashboard.Settings.Variables.Edit.General.applyButton().click(); + + // close to return to dashboard + e2e.pages.Dashboard.Settings.Actions.close().click(); + + // add visualization + e2e.pages.AddDashboard.itemButton('Create new panel button').should('be.visible').click(); + + // close the data source picker modal + cy.get('[aria-label="Close"]').click(); + + // select prom data source from the data source list with the useful data-testid + e2e.components.DataSourcePicker.inputV2().click({ force: true }).type(`${DATASOURCE_NAME}{enter}`); + + // confirm the variable exists in the correct input + // use the variable query type from the library in the future + switch (queryType) { + case 'Label names': + e2e.components.QueryBuilder.labelSelect().should('exist').click({ force: true }); + selectOption(`${variableName}`); + case 'Label values': + e2e.components.QueryBuilder.valueSelect().should('exist').click({ force: true }); + selectOption(`${variableName}`); + case 'Metrics': + e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist').click({ force: true }); + selectOption(`${variableName}`); + default: + // do nothing + } +} + +describe('Prometheus variable query editor', () => { + beforeEach(() => { + createPromDS(DATASOURCE_ID, DATASOURCE_NAME); + }); + + it('should navigate to variable query editor', () => { + addDashboard(); + navigateToVariables(); + }); + + it('should select a query type for a Prometheus variable query', () => { + addDashboard(); + navigateToVariables(); + addPrometheusQueryVariable('labelsVariable'); + + // select query type + e2e.components.DataSource.Prometheus.variableQueryEditor.queryType().click(); + + selectOption('Label names'); + }); + + it('should create a label names variable that is selectable in the label select in query builder', () => { + addDashboard(); + navigateToVariables(); + variableFlowToQueryEditor('labelnames', 'Label names'); + }); + + it('should create a label values variable that is selectable in the label values select in query builder', () => { + addDashboard(); + navigateToVariables(); + variableFlowToQueryEditor('labelvalues', 'Label values'); + }); + + it('should create a metric names variable that is selectable in the metric select in query builder', () => { + addDashboard(); + navigateToVariables(); + variableFlowToQueryEditor('metrics', 'Metrics'); + }); +}); + +function selectOption(option: string) { + cy.get("[aria-label='Select option']").contains(option).should('be.visible').click(); +} diff --git a/e2e/various-suite/query-editor.spec.ts b/e2e/various-suite/query-editor.spec.ts index 2263f1ffacfeb..3441f2b4c0d99 100644 --- a/e2e/various-suite/query-editor.spec.ts +++ b/e2e/various-suite/query-editor.spec.ts @@ -1,4 +1,5 @@ import { e2e } from '../utils'; +import { waitForMonacoToLoad } from '../utils/support/monaco'; describe('Query editor', () => { beforeEach(() => { @@ -14,13 +15,8 @@ describe('Query editor', () => { e2e.components.RadioButton.container().filter(':contains("Code")').click(); - // we need to wait for the query-field being lazy-loaded, in two steps: - // it is a two-step process: - // 1. first we wait for the text 'Loading...' to appear - // 1. then we wait for the text 'Loading...' to disappear - const monacoLoadingText = 'Loading...'; - e2e.components.QueryField.container().should('be.visible').should('have.text', monacoLoadingText); - e2e.components.QueryField.container().should('be.visible').should('not.have.text', monacoLoadingText); + waitForMonacoToLoad(); + e2e.components.QueryField.container().type(queryText, { parseSpecialCharSequences: false }).type('{backspace}'); cy.contains(queryText.slice(0, -1)).should('be.visible'); diff --git a/e2e/various-suite/solo-route.spec.ts b/e2e/various-suite/solo-route.spec.ts index 1cfba1b70b7af..c508ace3aef1c 100644 --- a/e2e/various-suite/solo-route.spec.ts +++ b/e2e/various-suite/solo-route.spec.ts @@ -11,4 +11,34 @@ describe('Solo Route', () => { cy.get('canvas').should('have.length', 6); }); + + it('Can view solo panel in scenes', () => { + // open Panel Tests - Graph NG + e2e.pages.SoloPanel.visit( + 'TkZXxlNG3/panel-tests-graph-ng?orgId=1&from=1699954597665&to=1699956397665&panelId=54&__feature.dashboardSceneSolo=true' + ); + + e2e.components.Panels.Panel.title('Interpolation: Step before').should('exist'); + cy.contains('uplot-main-div').should('not.exist'); + }); + + it('Can view solo repeated panel in scenes', () => { + // open Panel Tests - Graph NG + e2e.pages.SoloPanel.visit( + 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true' + ); + + e2e.components.Panels.Panel.title('server=B').should('exist'); + cy.contains('uplot-main-div').should('not.exist'); + }); + + it('Can view solo in repeaterd row and panel in scenes', () => { + // open Panel Tests - Graph NG + e2e.pages.SoloPanel.visit( + 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-2-row-2-clone-2&__feature.dashboardSceneSolo=true' + ); + + e2e.components.Panels.Panel.title('server = D, pod = Sod').should('exist'); + cy.contains('uplot-main-div').should('not.exist'); + }); }); diff --git a/emails/templates/invited_to_org.mjml b/emails/templates/invited_to_org.mjml index 8444cae188735..848a2c9a59787 100644 --- a/emails/templates/invited_to_org.mjml +++ b/emails/templates/invited_to_org.mjml @@ -4,7 +4,7 @@ <!-- css styling --> <mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" /> <mj-head> - <!-- ⬇ Don't forget to specifify an email subject! Use the HTML comment below ⬇ --> + <!-- ⬇ Don't forget to specify an email subject! Use the HTML comment below ⬇ --> <mj-title> {{ Subject .Subject .TemplateData "{{ .InvitedBy }} has added you to the {{ .OrgName }} organization" }} </mj-title> diff --git a/emails/templates/new_user_invite.mjml b/emails/templates/new_user_invite.mjml index 4f94d0f879a94..eecc501b041b1 100644 --- a/emails/templates/new_user_invite.mjml +++ b/emails/templates/new_user_invite.mjml @@ -4,7 +4,7 @@ <!-- css styling --> <mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" /> <mj-head> - <!-- ⬇ Don't forget to specifify an email subject below! ⬇ --> + <!-- ⬇ Don't forget to specify an email subject below! ⬇ --> <mj-title> {{ Subject .Subject .TemplateData "{{ .InvitedBy }} has invited you to join Grafana" }} </mj-title> diff --git a/emails/templates/ng_alert_notification.mjml b/emails/templates/ng_alert_notification.mjml index 2a75a01053571..adf7cb1fda56e 100644 --- a/emails/templates/ng_alert_notification.mjml +++ b/emails/templates/ng_alert_notification.mjml @@ -4,7 +4,7 @@ <!-- css styling --> <mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" /> <mj-head> - <!-- ⬇ Don't forget to specifify an email subject below! ⬇ --> + <!-- ⬇ Don't forget to specify an email subject below! ⬇ --> <mj-title> {{ Subject .Subject .TemplateData "{{ .Title }}" }} </mj-title> diff --git a/emails/templates/reset_password.mjml b/emails/templates/reset_password.mjml index 8a0161f453365..81614a7029035 100644 --- a/emails/templates/reset_password.mjml +++ b/emails/templates/reset_password.mjml @@ -4,7 +4,7 @@ <!-- css styling --> <mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" /> <mj-head> - <!-- ⬇ Don't forget to specifify an email subject below! ⬇ --> + <!-- ⬇ Don't forget to specify an email subject below! ⬇ --> <mj-title> {{ Subject .Subject .TemplateData "Reset your Grafana password - {{.Name}}" }} </mj-title> diff --git a/emails/templates/signup_started.mjml b/emails/templates/signup_started.mjml index ff211fbd95cba..19beca31247bc 100644 --- a/emails/templates/signup_started.mjml +++ b/emails/templates/signup_started.mjml @@ -4,7 +4,7 @@ <!-- css styling --> <mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" /> <mj-head> - <!-- ⬇ Don't forget to specifify an email subject below! ⬇ --> + <!-- ⬇ Don't forget to specify an email subject below! ⬇ --> <mj-title> {{ Subject .Subject .TemplateData "Welcome to Grafana, please complete your sign up!" }} </mj-title> diff --git a/emails/templates/verify_email.mjml b/emails/templates/verify_email.mjml new file mode 100644 index 0000000000000..b830496868bb5 --- /dev/null +++ b/emails/templates/verify_email.mjml @@ -0,0 +1,40 @@ +<mjml> + <!-- global variables --> + <mj-include path="./partials/_globals.mjml" /> + <!-- css styling --> + <mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" /> + <mj-head> + <!-- ⬇ Don't forget to specify an email subject below! ⬇ --> + <mj-title> + {{ Subject .Subject .TemplateData "Verify your email - {{.Name}}" }} + </mj-title> + <mj-include path="./partials/layout/head.mjml" /> + </mj-head> + <mj-body> + <mj-section> + <mj-include path="./partials/layout/header.mjml" /> + </mj-section> + <mj-section css-class="background"> + <mj-column> + <mj-text> + <h2>Hi {{ .Name }},</h2> + </mj-text> + <mj-text> + Please click the following link to verify your email within <strong>{{ .VerificationEmailLifetimeHours }} hour(s)</strong>. + </mj-text> + <mj-button href="{{ .AppUrl }}user/email/update?code={{ .Code }}"> + Verify Email + </mj-button> + <mj-text> + You can also copy and paste this link into your browser directly: + </mj-text> + <mj-text> + <a rel="noopener" href="{{ .AppUrl }}user/email/update?code={{ .Code }}">{{ .AppUrl }}user/email/update?code={{ .Code }}</a> + </mj-text> + </mj-column> + </mj-section> + <mj-section> + <mj-include path="./partials/layout/footer.mjml" /> + </mj-section> + </mj-body> +</mjml> diff --git a/emails/templates/verify_email.txt b/emails/templates/verify_email.txt new file mode 100644 index 0000000000000..8af1d2c1935c4 --- /dev/null +++ b/emails/templates/verify_email.txt @@ -0,0 +1,6 @@ +[[HiddenSubject .Subject "Verify your email - [[.Name]]"]] + +Hi [[.Name]], + +Copy and paste the following link directly in your browser to verify your email within [[.VerificationEmailLifetimeHours]] hour(s). +[[.AppUrl]]user/email/update?code=[[.Code]] diff --git a/emails/templates/welcome_on_signup.mjml b/emails/templates/welcome_on_signup.mjml index 686842eb8ce12..918a4ebf7d735 100644 --- a/emails/templates/welcome_on_signup.mjml +++ b/emails/templates/welcome_on_signup.mjml @@ -4,7 +4,7 @@ <!-- css styling --> <mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" /> <mj-head> - <!-- ⬇ Don't forget to specifify an email subject below! ⬇ --> + <!-- ⬇ Don't forget to specify an email subject below! ⬇ --> <mj-title> {{ Subject .Subject .TemplateData "Welcome to Grafana" }} </mj-title> diff --git a/embed.go b/embed.go index 570415e0034d6..e022a032c4503 100644 --- a/embed.go +++ b/embed.go @@ -6,5 +6,5 @@ import ( // CueSchemaFS embeds all schema-related CUE files in the Grafana project. // -//go:embed cue.mod/module.cue kinds/*.cue kinds/*/*.cue packages/grafana-schema/src/common/*.cue public/app/plugins/*/*/*.cue public/app/plugins/*/*/plugin.json pkg/plugins/*/*.cue +//go:embed cue.mod/module.cue kinds/*.cue kinds/*/*.cue packages/grafana-schema/src/common/*.cue public/app/plugins/*/*/*.cue public/app/plugins/*/*/plugin.json var CueSchemaFS embed.FS diff --git a/go.mod b/go.mod index 2d1ccd95e0f12..31149a5745e8d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana -go 1.21 +go 1.21.0 // Override docker/docker to avoid: // go: github.com/drone-runners/drone-runner-docker@v1.8.2 requires @@ -10,42 +10,33 @@ replace github.com/docker/docker => github.com/moby/moby v23.0.4+incompatible // contains openapi encoder fixes. remove ASAP replace cuelang.org/go => github.com/grafana/cue v0.0.0-20230926092038-971951014e3f // @grafana/grafana-as-code -// TODO: following otel replaces to pin the libraries so k8s.io/apiserver doesn't downgrade us inadvertantly -// will need bumps as we upgrade otel in Grafana -replace ( - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp => go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // @grafana/backend-platform - go.opentelemetry.io/otel => go.opentelemetry.io/otel v1.21.0 // @grafana/backend-platform - go.opentelemetry.io/otel/metric => go.opentelemetry.io/otel/metric v1.21.0 // @grafana/backend-platform - go.opentelemetry.io/otel/trace => go.opentelemetry.io/otel/trace v1.21.0 // @grafana/backend-platform -) - // Override Prometheus version because Prometheus v2.X is tagged as v0.X for Go modules purposes and Go assumes // that v1.Y is higher than v0.X, so when we resolve dependencies if any dependency imports v1.Y we'd // import that instead of v0.X even though v0.X is newer. -replace github.com/prometheus/prometheus => github.com/prometheus/prometheus v0.43.0 +replace github.com/prometheus/prometheus => github.com/prometheus/prometheus v0.49.0 // The v0.120.0 is needed for now to be compatible with grafana/thema. replace github.com/getkin/kin-openapi => github.com/getkin/kin-openapi v0.120.0 require ( - cloud.google.com/go/storage v1.30.1 // @grafana/backend-platform + cloud.google.com/go/storage v1.36.0 // @grafana/backend-platform cuelang.org/go v0.6.0-0.dev // @grafana/grafana-as-code - github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // @grafana/backend-platform + github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // @grafana/partner-datasources github.com/Azure/go-autorest/autorest v0.11.29 // @grafana/backend-platform github.com/BurntSushi/toml v1.3.2 // @grafana/grafana-authnz-team github.com/Masterminds/semver v1.5.0 // @grafana/backend-platform github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // @grafana/backend-platform - github.com/aws/aws-sdk-go v1.44.325 // @grafana/aws-datasources + github.com/aws/aws-sdk-go v1.50.8 // @grafana/aws-datasources github.com/beevik/etree v1.2.0 // @grafana/backend-platform github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-squad-backend - github.com/blang/semver/v4 v4.0.0 // @grafana/grafana-delivery + github.com/blang/semver/v4 v4.0.0 // @grafana/grafana-release-guild github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b // @grafana/backend-platform github.com/centrifugal/centrifuge v0.30.2 // @grafana/grafana-app-platform-squad github.com/crewjam/saml v0.4.13 // @grafana/grafana-authnz-team github.com/fatih/color v1.15.0 // @grafana/backend-platform github.com/gchaincl/sqlhooks v1.3.0 // @grafana/backend-platform github.com/go-ldap/ldap/v3 v3.4.4 // @grafana/grafana-authnz-team - github.com/go-openapi/strfmt v0.21.9 // @grafana/alerting-squad-backend + github.com/go-openapi/strfmt v0.22.0 // @grafana/alerting-squad-backend github.com/go-redis/redis/v8 v8.11.5 // @grafana/backend-platform github.com/go-sourcemap/sourcemap v2.1.3+incompatible // @grafana/backend-platform github.com/go-sql-driver/mysql v1.7.1 // @grafana/backend-platform @@ -56,16 +47,16 @@ require ( github.com/golang/mock v1.6.0 // @grafana/alerting-squad-backend github.com/golang/snappy v0.0.4 // @grafana/alerting-squad-backend github.com/google/go-cmp v0.6.0 // @grafana/backend-platform - github.com/google/uuid v1.4.0 // @grafana/backend-platform + github.com/google/uuid v1.6.0 // @grafana/backend-platform github.com/google/wire v0.5.0 // @grafana/backend-platform github.com/gorilla/websocket v1.5.0 // @grafana/grafana-app-platform-squad - github.com/grafana/alerting v0.0.0-20231221110807-c17ec6241a66 // @grafana/alerting-squad-backend + github.com/grafana/alerting v0.0.0-20240306130925-bc622368256d // @grafana/alerting-squad-backend github.com/grafana/cuetsy v0.1.11 // @grafana/grafana-as-code - github.com/grafana/grafana-aws-sdk v0.19.1 // @grafana/aws-datasources - github.com/grafana/grafana-azure-sdk-go v1.11.0 // @grafana/backend-platform - github.com/grafana/grafana-plugin-sdk-go v0.197.0 // @grafana/plugins-platform-backend + github.com/grafana/grafana-aws-sdk v0.25.0 // @grafana/aws-datasources + github.com/grafana/grafana-azure-sdk-go v1.12.0 // @grafana/partner-datasources + github.com/grafana/grafana-plugin-sdk-go v0.215.0 // @grafana/plugins-platform-backend github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/backend-platform - github.com/hashicorp/go-hclog v1.6.1 // @grafana/plugins-platform-backend + github.com/hashicorp/go-hclog v1.6.2 // @grafana/plugins-platform-backend github.com/hashicorp/go-plugin v1.6.0 // @grafana/plugins-platform-backend github.com/hashicorp/go-version v1.6.0 // @grafana/backend-platform github.com/hashicorp/hcl/v2 v2.17.0 // @grafana/alerting-squad-backend @@ -76,22 +67,23 @@ require ( github.com/lib/pq v1.10.9 // @grafana/backend-platform github.com/linkedin/goavro/v2 v2.10.0 // @grafana/backend-platform github.com/m3db/prometheus_remote_client_golang v0.4.4 // @grafana/backend-platform - github.com/magefile/mage v1.15.0 // @grafana/grafana-delivery - github.com/mattn/go-isatty v0.0.18 // @grafana/backend-platform - github.com/mattn/go-sqlite3 v1.14.16 // @grafana/backend-platform - github.com/matttproud/golang_protobuf_extensions v1.0.4 // @grafana/alerting-squad-backend + github.com/magefile/mage v1.15.0 // @grafana/grafana-release-guild + github.com/mattn/go-isatty v0.0.19 // @grafana/backend-platform + github.com/mattn/go-sqlite3 v1.14.19 // @grafana/backend-platform + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect; @grafana/alerting-squad-backend github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // @grafana/grafana-operator-experience-squad github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // @grafana/alerting-squad-backend - github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/prometheus/alertmanager v0.25.0 // @grafana/alerting-squad-backend - github.com/prometheus/client_golang v1.17.0 // @grafana/alerting-squad-backend + github.com/prometheus/alertmanager v0.26.0 // @grafana/alerting-squad-backend + github.com/prometheus/client_golang v1.18.0 // @grafana/alerting-squad-backend github.com/prometheus/client_model v0.5.0 // @grafana/backend-platform - github.com/prometheus/common v0.45.0 // @grafana/alerting-squad-backend + github.com/prometheus/common v0.46.0 // @grafana/alerting-squad-backend github.com/prometheus/prometheus v1.8.2-0.20221021121301-51a44e6657c3 // @grafana/alerting-squad-backend github.com/robfig/cron/v3 v3.0.1 // @grafana/backend-platform github.com/russellhaering/goxmldsig v1.4.0 // @grafana/backend-platform + github.com/scottlepp/go-duck v0.0.15 // @grafana/grafana-app-platform-squad github.com/stretchr/testify v1.8.4 // @grafana/backend-platform github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf // @grafana/backend-platform github.com/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f // @grafana/backend-platform @@ -100,22 +92,22 @@ require ( github.com/vectordotdev/go-datemath v0.1.1-0.20220323213446-f3954d0b18ae // @grafana/backend-platform github.com/yalue/merged_fs v1.2.2 // @grafana/grafana-as-code github.com/yudai/gojsondiff v1.0.0 // @grafana/backend-platform - go.opentelemetry.io/collector/pdata v1.0.0-rc8 // @grafana/backend-platform - go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // @grafana/grafana-operator-experience-squad + go.opentelemetry.io/collector/pdata v1.0.1 // @grafana/backend-platform + go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.49.0 // @grafana/grafana-operator-experience-squad go.opentelemetry.io/otel/exporters/jaeger v1.10.0 // @grafana/backend-platform - go.opentelemetry.io/otel/sdk v1.21.0 // @grafana/backend-platform - go.opentelemetry.io/otel/trace v1.21.0 // @grafana/backend-platform - golang.org/x/crypto v0.17.0 // @grafana/backend-platform - golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // @grafana/alerting-squad-backend - golang.org/x/net v0.19.0 // @grafana/oss-big-tent @grafana/partner-datasources - golang.org/x/oauth2 v0.15.0 // @grafana/grafana-authnz-team - golang.org/x/sync v0.4.0 // @grafana/alerting-squad-backend - golang.org/x/time v0.3.0 // @grafana/backend-platform - golang.org/x/tools v0.13.0 // @grafana/grafana-as-code + go.opentelemetry.io/otel/sdk v1.24.0 // @grafana/backend-platform + go.opentelemetry.io/otel/trace v1.24.0 // @grafana/backend-platform + golang.org/x/crypto v0.19.0 // @grafana/backend-platform + golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // @grafana/alerting-squad-backend + golang.org/x/net v0.21.0 // @grafana/oss-big-tent @grafana/partner-datasources + golang.org/x/oauth2 v0.16.0 // @grafana/grafana-authnz-team + golang.org/x/sync v0.6.0 // @grafana/alerting-squad-backend + golang.org/x/time v0.5.0 // @grafana/backend-platform + golang.org/x/tools v0.17.0 // indirect; @grafana/grafana-as-code gonum.org/v1/gonum v0.12.0 // @grafana/observability-metrics - google.golang.org/api v0.148.0 // @grafana/backend-platform - google.golang.org/grpc v1.59.0 // @grafana/plugins-platform-backend - google.golang.org/protobuf v1.31.0 // @grafana/plugins-platform-backend + google.golang.org/api v0.155.0 // @grafana/backend-platform + google.golang.org/grpc v1.62.1 // @grafana/plugins-platform-backend + google.golang.org/protobuf v1.32.0 // @grafana/plugins-platform-backend gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/ini.v1 v1.67.0 // @grafana/alerting-squad-backend gopkg.in/mail.v2 v2.3.1 // @grafana/backend-platform @@ -127,7 +119,7 @@ require ( ) require ( - github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1 // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect @@ -135,8 +127,8 @@ require ( github.com/Azure/go-autorest/logger v0.2.1 // indirect github.com/Azure/go-autorest/tracing v0.6.0 // indirect github.com/FZambia/eagle v0.1.0 // indirect - github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect - github.com/andybalholm/brotli v1.0.4 // @grafana/partner-datasources + github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 // indirect + github.com/andybalholm/brotli v1.0.5 // @grafana/partner-datasources github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect @@ -151,22 +143,22 @@ require ( github.com/emicklei/proto v1.10.0 // indirect github.com/go-kit/log v0.2.1 // @grafana/backend-platform github.com/go-logfmt/logfmt v0.6.0 // indirect - github.com/go-openapi/analysis v0.21.4 // indirect - github.com/go-openapi/errors v0.20.4 // indirect - github.com/go-openapi/jsonpointer v0.20.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/loads v0.21.2 // @grafana/alerting-squad-backend - github.com/go-openapi/runtime v0.26.2 // @grafana/alerting-squad-backend - github.com/go-openapi/spec v0.20.11 // indirect - github.com/go-openapi/swag v0.22.4 // indirect - github.com/go-openapi/validate v0.22.3 // indirect + github.com/go-openapi/analysis v0.22.2 // indirect + github.com/go-openapi/errors v0.21.0 // indirect + github.com/go-openapi/jsonpointer v0.20.2 // indirect + github.com/go-openapi/jsonreference v0.20.4 // indirect + github.com/go-openapi/loads v0.21.5 // @grafana/alerting-squad-backend + github.com/go-openapi/runtime v0.27.1 // @grafana/alerting-squad-backend + github.com/go-openapi/spec v0.20.14 // indirect + github.com/go-openapi/swag v0.22.9 // indirect + github.com/go-openapi/validate v0.23.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // @grafana/backend-platform github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect - github.com/golang/glog v1.1.2 // indirect + github.com/golang/glog v1.2.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // @grafana/backend-platform github.com/google/btree v1.1.2 // indirect - github.com/google/flatbuffers v23.1.21+incompatible // indirect + github.com/google/flatbuffers v23.5.26+incompatible // indirect github.com/googleapis/gax-go/v2 v2.12.0 // @grafana/backend-platform github.com/gorilla/mux v1.8.0 // @grafana/backend-platform github.com/grafana/grafana-google-sdk-go v0.1.0 // @grafana/partner-datasources @@ -174,7 +166,7 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-msgpack v0.5.5 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect; @grafana/alerting-squad - github.com/hashicorp/go-sockaddr v1.0.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.6 // indirect github.com/hashicorp/golang-lru v0.6.0 // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/igm/sockjs-go/v3 v3.0.2 // indirect @@ -186,7 +178,7 @@ require ( github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattetti/filebuffer v1.0.1 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect - github.com/miekg/dns v1.1.51 // indirect + github.com/miekg/dns v1.1.57 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // @grafana/alerting-squad-backend @@ -196,16 +188,15 @@ require ( github.com/olekukonko/tablewriter v0.0.5 // @grafana/backend-platform github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/common/sigv4 v0.1.0 // indirect - github.com/prometheus/exporter-toolkit v0.10.0 // indirect - github.com/prometheus/procfs v0.11.1 // indirect + github.com/prometheus/exporter-toolkit v0.11.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect github.com/protocolbuffers/txtpbfmt v0.0.0-20220428173112-74888fd59c2b // indirect github.com/rs/cors v1.10.1 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/segmentio/encoding v0.3.6 // indirect github.com/sergi/go-diff v1.3.1 // indirect - github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect + github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/objx v0.5.0 // indirect github.com/uber/jaeger-lib v2.4.1+incompatible // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect @@ -214,16 +205,16 @@ require ( go.opencensus.io v0.24.0 // indirect go.uber.org/atomic v1.11.0 // @grafana/alerting-squad-backend go.uber.org/goleak v1.3.0 // indirect - golang.org/x/sys v0.15.0 // indirect + golang.org/x/sys v0.17.0 // indirect golang.org/x/text v0.14.0 // @grafana/backend-platform - golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20231012201019-e917dd12ba7a // indirect; @grafana/backend-platform + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 // indirect; @grafana/backend-platform ) require ( - cloud.google.com/go/kms v1.15.2 // @grafana/backend-platform - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.4.0 // @grafana/backend-platform + cloud.google.com/go/kms v1.15.5 // @grafana/backend-platform + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1 // @grafana/backend-platform github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.9.0 // @grafana/backend-platform github.com/Azure/azure-storage-blob-go v0.15.0 // @grafana/backend-platform github.com/Azure/go-autorest/autorest/adal v0.9.23 // @grafana/backend-platform @@ -232,66 +223,66 @@ require ( github.com/blugelabs/bluge_segment_api v0.2.0 // @grafana/backend-platform github.com/bufbuild/connect-go v1.10.0 // @grafana/observability-traces-and-profiling github.com/dlmiddlecote/sqlstats v1.0.2 // @grafana/backend-platform - github.com/drone/drone-cli v1.6.1 // @grafana/grafana-delivery + github.com/drone/drone-cli v1.6.1 // @grafana/grafana-release-guild github.com/getkin/kin-openapi v0.120.0 // @grafana/grafana-operator-experience-squad github.com/golang-migrate/migrate/v4 v4.7.0 // @grafana/backend-platform - github.com/google/go-github v17.0.0+incompatible // @grafana/grafana-delivery - github.com/google/go-github/v45 v45.2.0 // @grafana/grafana-delivery + github.com/google/go-github v17.0.0+incompatible // @grafana/grafana-release-guild + github.com/google/go-github/v45 v45.2.0 // @grafana/grafana-release-guild github.com/grafana/codejen v0.0.3 // @grafana/dataviz-squad - github.com/grafana/dskit v0.0.0-20230706162620-5081d8ed53e6 // @grafana/backend-platform - github.com/huandu/xstrings v1.3.1 // @grafana/partner-datasources + github.com/grafana/dskit v0.0.0-20240104111617-ea101a3b86eb // @grafana/backend-platform + github.com/huandu/xstrings v1.3.2 // @grafana/partner-datasources github.com/jmoiron/sqlx v1.3.5 // @grafana/backend-platform github.com/matryer/is v1.4.0 // @grafana/grafana-as-code github.com/urfave/cli v1.22.14 // @grafana/backend-platform - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 // @grafana/plugins-platform-backend - go.opentelemetry.io/contrib/propagators/jaeger v1.21.1 // @grafana/backend-platform - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 // @grafana/backend-platform - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0 // @grafana/backend-platform + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // @grafana/plugins-platform-backend + go.opentelemetry.io/contrib/propagators/jaeger v1.22.0 // @grafana/backend-platform + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // @grafana/backend-platform + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 // @grafana/backend-platform gocloud.dev v0.25.0 // @grafana/grafana-app-platform-squad ) require ( buf.build/gen/go/parca-dev/parca/bufbuild/connect-go v1.4.1-20221222094228-8b1d3d0f62e6.1 // @grafana/observability-traces-and-profiling buf.build/gen/go/parca-dev/parca/protocolbuffers/go v1.28.1-20221222094228-8b1d3d0f62e6.4 // @grafana/observability-traces-and-profiling - github.com/Masterminds/semver/v3 v3.1.1 // @grafana/grafana-delivery + github.com/Masterminds/semver/v3 v3.1.1 // @grafana/grafana-release-guild github.com/alicebob/miniredis/v2 v2.30.1 // @grafana/alerting-squad-backend github.com/dave/dst v0.27.2 // @grafana/grafana-as-code - github.com/go-jose/go-jose/v3 v3.0.1 // @grafana/grafana-authnz-team + github.com/go-jose/go-jose/v3 v3.0.3 // @grafana/grafana-authnz-team github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics - github.com/grafana/dataplane/sdata v0.0.6 // @grafana/observability-metrics + github.com/grafana/dataplane/sdata v0.0.7 // @grafana/observability-metrics github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482 // @grafana/grafana-as-code github.com/grafana/tempo v1.5.1-0.20230524121406-1dc1bfe7085b // @grafana/observability-traces-and-profiling github.com/grafana/thema v0.0.0-20230712153715-375c1b45f3ed // @grafana/grafana-as-code - github.com/microsoft/go-mssqldb v1.5.0 // @grafana/grafana-bi-squad - github.com/ory/fosite v0.44.1-0.20230317114349-45a6785cc54f // @grafana/grafana-authnz-team + github.com/microsoft/go-mssqldb v1.6.1-0.20240214161942-b65008136246 // @grafana/grafana-bi-squad github.com/redis/go-redis/v9 v9.0.2 // @grafana/alerting-squad-backend - github.com/weaveworks/common v0.0.0-20230511094633-334485600903 // @grafana/alerting-squad-backend - github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // @grafana/grafana-as-code - go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1 // @grafana/backend-platform - golang.org/x/mod v0.12.0 // @grafana/backend-platform - gopkg.in/square/go-jose.v2 v2.6.0 // @grafana/grafana-authnz-team + go.opentelemetry.io/contrib/samplers/jaegerremote v0.18.0 // @grafana/backend-platform + golang.org/x/mod v0.14.0 // @grafana/backend-platform k8s.io/utils v0.0.0-20230726121419-3b25d923346b // @grafana/partner-datasources ) require ( - github.com/spf13/cobra v1.7.0 // @grafana/grafana-app-platform-squad - go.opentelemetry.io/otel v1.21.0 // @grafana/backend-platform - k8s.io/apimachinery v0.29.0 // @grafana/grafana-app-platform-squad - k8s.io/apiserver v0.29.0 // @grafana/grafana-app-platform-squad - k8s.io/client-go v0.29.0 // @grafana/grafana-app-platform-squad - k8s.io/component-base v0.29.0 // @grafana/grafana-app-platform-squad - k8s.io/klog/v2 v2.110.1 // @grafana/grafana-app-platform-squad - k8s.io/kube-openapi v0.0.0-20231214164306-ab13479f8bf8 // @grafana/grafana-app-platform-squad + github.com/spf13/cobra v1.8.0 // @grafana/grafana-app-platform-squad + go.opentelemetry.io/otel v1.24.0 // @grafana/backend-platform + k8s.io/api v0.29.2 // @grafana/grafana-app-platform-squad + k8s.io/apimachinery v0.29.2 // @grafana/grafana-app-platform-squad + k8s.io/apiserver v0.29.2 // @grafana/grafana-app-platform-squad + k8s.io/client-go v0.29.2 // @grafana/grafana-app-platform-squad + k8s.io/component-base v0.29.2 // @grafana/grafana-app-platform-squad + k8s.io/klog/v2 v2.120.1 // @grafana/grafana-app-platform-squad + k8s.io/kube-aggregator v0.29.0 // @grafana/grafana-app-platform-squad + k8s.io/kube-openapi v0.0.0-20240220201932-37d671a357a5 // @grafana/grafana-app-platform-squad ) require github.com/grafana/gofpdf v0.0.0-20231002120153-857cc45be447 // @grafana/sharing-squad require github.com/grafana/pyroscope/api v0.3.0 // @grafana/observability-traces-and-profiling -require github.com/apache/arrow/go/v13 v13.0.0 // @grafana/observability-metrics +require github.com/grafana/pyroscope-go/godeltaprof v0.1.6 // @grafana/observability-traces-and-profiling + +require github.com/apache/arrow/go/v15 v15.0.0 // @grafana/observability-metrics require ( - cloud.google.com/go v0.110.8 // indirect + cloud.google.com/go v0.112.0 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect github.com/Azure/azure-pipeline-go v0.2.3 // indirect github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect @@ -299,7 +290,6 @@ require ( github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/agext/levenshtein v1.2.1 // indirect github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect - github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/armon/go-metrics v0.4.1 // indirect @@ -307,32 +297,27 @@ require ( github.com/buildkite/yaml v2.1.0+incompatible // indirect github.com/bwmarrin/snowflake v0.3.0 // @grafan/grafana-app-platform-squad github.com/centrifugal/protocol v0.10.0 // indirect - github.com/cloudflare/circl v1.3.3 // indirect + github.com/cloudflare/circl v1.3.7 // indirect github.com/cockroachdb/errors v1.9.1 // indirect github.com/cockroachdb/logtags v0.0.0-20211118104740-dabe8e521a4f // indirect github.com/cockroachdb/redact v1.1.3 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect - github.com/cristalhq/jwt/v4 v4.0.2 // indirect - github.com/dave/jennifer v1.5.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dgraph-io/ristretto v0.1.1 // indirect - github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect - github.com/docker/distribution v2.8.1+incompatible // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/docker/distribution v2.8.2+incompatible // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/drone-runners/drone-runner-docker v1.8.2 // indirect github.com/drone/drone-go v1.7.1 // indirect github.com/drone/envsubst v1.0.3 // indirect github.com/drone/runner-go v1.12.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/ecordell/optgen v0.0.6 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect - github.com/evanphx/json-patch v4.12.0+incompatible // indirect + github.com/evanphx/json-patch v5.6.0+incompatible // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/getsentry/sentry-go v0.12.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect - github.com/goccy/go-json v0.10.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect github.com/gogo/googleapis v1.4.1 // indirect github.com/gogo/status v1.1.1 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect @@ -340,25 +325,19 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/s2a-go v0.1.7 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.1 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/grafana/regexp v0.0.0-20221123153739-15dc172cd2db // indirect - github.com/grafana/sqlds/v2 v2.3.10 // indirect - github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect - github.com/hashicorp/go-retryablehttp v0.7.2 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.2 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect; @grafana/alerting-squad-backend github.com/hashicorp/memberlist v0.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/yaml v0.2.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect - github.com/magiconair/properties v1.8.6 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-ieproxy v0.0.3 // indirect - github.com/mattn/goveralls v0.0.6 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 //@grafana/grafana-authnz-team github.com/mitchellh/reflectwalk v1.0.2 // indirect @@ -367,12 +346,6 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.0.3-0.20220512140940-7b36cea86235 // indirect github.com/opentracing-contrib/go-stdlib v1.0.0 // indirect - github.com/ory/go-acc v0.2.6 // indirect - github.com/ory/go-convenience v0.1.0 // indirect - github.com/ory/viper v1.7.5 // indirect - github.com/ory/x v0.0.214 // indirect - github.com/pborman/uuid v1.2.0 // indirect - github.com/pelletier/go-toml v1.9.5 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/redis/rueidis v1.0.16 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect @@ -381,16 +354,12 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/shopspring/decimal v1.2.0 // indirect - github.com/spf13/afero v1.9.2 // indirect github.com/spf13/cast v1.5.0 // indirect - github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/stoewer/go-strcase v1.2.0 // indirect - github.com/subosito/gotenv v1.4.1 // indirect + github.com/spf13/pflag v1.0.5 // @grafana-app-platform-squad + github.com/stoewer/go-strcase v1.3.0 // indirect github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect github.com/unknwon/com v1.0.1 // indirect github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 // indirect - github.com/weaveworks/promrus v1.2.0 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect github.com/yuin/gopher-lua v1.1.0 // indirect github.com/zclconf/go-cty v1.13.0 // indirect @@ -398,44 +367,43 @@ require ( go.etcd.io/etcd/api/v3 v3.5.10 // indirect go.etcd.io/etcd/client/pkg/v3 v3.5.10 // indirect go.etcd.io/etcd/client/v3 v3.5.10 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect - go.opentelemetry.io/otel/metric v1.21.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.24.0 // indirect - golang.org/x/term v0.15.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20231002182017-d307bd883b97 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b // indirect + go.uber.org/zap v1.26.0 // indirect + golang.org/x/term v0.17.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 // indirect gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - k8s.io/api v0.29.0 // indirect - k8s.io/kms v0.29.0 // indirect - lukechampine.com/uint128 v1.2.0 // indirect + k8s.io/kms v0.29.2 // indirect + lukechampine.com/uint128 v1.3.0 // indirect modernc.org/cc/v3 v3.40.0 // indirect modernc.org/ccgo/v3 v3.16.13 // indirect - modernc.org/libc v1.22.2 // indirect + modernc.org/libc v1.22.4 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect modernc.org/opt v0.1.3 // indirect - modernc.org/sqlite v1.20.4 // indirect + modernc.org/sqlite v1.21.2 // indirect modernc.org/strutil v1.1.3 // indirect modernc.org/token v1.1.0 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.28.0 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // @grafana-app-platform-squad sigs.k8s.io/yaml v1.3.0 // indirect; @grafana-app-platform-squad ) require ( - cloud.google.com/go/compute v1.23.0 // indirect - cloud.google.com/go/iam v1.1.2 // indirect + cloud.google.com/go/compute v1.23.3 // indirect + cloud.google.com/go/iam v1.1.5 // indirect filippo.io/age v1.1.1 // @grafana/grafana-authnz-team - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.8.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.0 // indirect - github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1 // indirect github.com/Masterminds/sprig/v3 v3.2.2 // @grafana/backend-platform - github.com/Microsoft/go-winio v0.6.0 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // @grafana/plugins-platform-backend github.com/RoaringBitmap/roaring v0.9.4 // indirect github.com/axiomhq/hyperloglog v0.0.0-20191112132149-a4c4c47bc57f // indirect @@ -447,55 +415,102 @@ require ( github.com/blevesearch/vellum v1.0.7 // indirect github.com/blugelabs/ice v1.0.0 // indirect github.com/caio/go-tdigest v3.1.0+incompatible // indirect - github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2 // indirect + github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/dgryski/go-metro v0.0.0-20211217172704-adc40b04c140 // indirect - github.com/docker/docker v23.0.4+incompatible // @grafana/grafana-delivery + github.com/docker/docker v24.0.7+incompatible // @grafana/grafana-release-guild github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 // indirect github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 // indirect - github.com/go-logr/logr v1.3.0 // @grafana/grafana-app-platform-squad + github.com/go-logr/logr v1.4.1 // @grafana/grafana-app-platform-squad github.com/go-logr/stdr v1.2.2 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.2 // indirect - github.com/hmarr/codeowners v1.1.2 // @grafana/grafana-as-code - github.com/imdario/mergo v0.3.13 // indirect - github.com/klauspost/compress v1.16.5 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect + github.com/imdario/mergo v0.3.16 // indirect + github.com/klauspost/compress v1.17.4 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/labstack/echo/v4 v4.10.2 // indirect github.com/labstack/gommon v0.4.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mschoch/smat v0.2.0 // indirect - github.com/pierrec/lz4/v4 v4.1.17 // indirect + github.com/pierrec/lz4/v4 v4.1.18 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wk8/go-ordered-map v1.0.0 // @grafana/backend-platform github.com/xlab/treeprint v1.2.0 // @grafana/observability-traces-and-profiling - go.opentelemetry.io/proto/otlp v1.0.0 // indirect + go.opentelemetry.io/proto/otlp v1.1.0 // indirect ) require ( + github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df // indirect github.com/go-errors/errors v1.4.2 // indirect - github.com/golang-jwt/jwt/v5 v5.0.0 // indirect + github.com/golang-jwt/jwt/v5 v5.2.0 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 // @grafana/backend-platform - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect + github.com/moby/spdystream v0.2.0 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect +) + +require k8s.io/code-generator v0.29.1 // @grafana/grafana-app-platform-squad + +require github.com/spyzhov/ajson v0.9.0 // @grafana/grafana-app-platform-squad + +require github.com/fullstorydev/grpchan v1.1.1 // @grafana/backend-platform + +// This needs to be here for other projects that import grafana/grafana +// For local development grafana/grafana will always use the local files +// Check go.work file for details +require github.com/grafana/grafana/pkg/promlib v0.0.2 // @grafana/observability-metrics + +require ( + github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c // indirect + github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40 // indirect + github.com/apache/thrift v0.18.1 // indirect + github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240226124929-648abdbd0ea4 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana/pkg/apiserver v0.0.0-20240226124929-648abdbd0ea4 // @grafana/grafana-app-platform-squad +) + +require ( + github.com/bufbuild/protocompile v0.4.0 // indirect + github.com/grafana/sqlds/v3 v3.2.0 // indirect + github.com/jhump/protoreflect v1.15.1 // indirect + github.com/klauspost/asmfmt v1.3.2 // indirect + github.com/krasun/gosqlparser v1.0.5 // @grafana/grafana-app-platform-squad + github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 // indirect + github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mithrandie/csvq v1.17.10 // indirect + github.com/mithrandie/csvq-driver v1.6.8 // indirect + github.com/mithrandie/go-file/v2 v2.1.0 // indirect + github.com/mithrandie/go-text v1.5.4 // indirect + github.com/mithrandie/ternary v1.1.1 // indirect + github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2 // @grafana/grafana-app-platform-squad +) + +require github.com/jackc/pgx/v5 v5.5.5 // @grafana/oss-big-tent + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/invopop/jsonschema v0.12.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect ) // Use fork of crewjam/saml with fixes for some issues until changes get merged into upstream replace github.com/crewjam/saml => github.com/grafana/saml v0.4.15-0.20231025143828-a6c0e9b86a4c +// replace github.com/google/cel-go => github.com/google/cel-go v0.16.1 + // Thema's thema CLI requires cobra, which eventually works its way down to go-hclog@v1.0.0. // Upgrading affects backend plugins: https://github.com/grafana/grafana/pull/47653#discussion_r850508593 // No harm to Thema because it's only a dependency in its main package. replace github.com/hashicorp/go-hclog => github.com/hashicorp/go-hclog v0.16.1 -// This is a patched v0.8.2 intended to fix session.Find (and others) silently ignoring SQLITE_BUSY errors. This could -// happen, for example, during a read when the sqlite db is under heavy write load. -// This patch cherry picks compatible fixes from upstream xorm PR#1998 and can be reverted on upgrade to xorm v1.2.0+. -// This has also been patched to support the azuresql driver that is a thin wrapper for the mssql driver with azure authentication support. -//replace xorm.io/xorm => github.com/grafana/xorm v0.8.3-0.20230627081928-d04aa38aa209 -replace xorm.io/xorm => ./pkg/util/xorm - // Use our fork of the upstream alertmanagers. // This is required in order to get notification delivery errors from the receivers API. -replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20231027171310-70c52bf65758 +replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20240208102907-e82436ce63e6 exclude github.com/mattn/go-sqlite3 v2.0.3+incompatible + +// Use our fork xorm. go.work currently overrides this and points to the local ./pkg/util/xorm directory. +replace xorm.io/xorm => github.com/grafana/grafana/pkg/util/xorm v0.0.1 diff --git a/go.sum b/go.sum index 4df40d8363e83..353117d54ff6d 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,3 @@ -bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8= buf.build/gen/go/grpc-ecosystem/grpc-gateway/protocolbuffers/go v1.28.1-20221127060915-a1ecdc58eccd.4/go.mod h1:92ejKVTiuvnKoAtRlpJpIxKfloI935DDqhs0NCRx+KM= buf.build/gen/go/parca-dev/parca/bufbuild/connect-go v1.4.1-20221222094228-8b1d3d0f62e6.1 h1:wQ75SnlaD0X30PnrmA+07A/5fnQWrAHy1mzv+CPB5Oo= buf.build/gen/go/parca-dev/parca/bufbuild/connect-go v1.4.1-20221222094228-8b1d3d0f62e6.1/go.mod h1:VYzBTKhjl92cl3sv+xznQcJHCezU7qnI0FhBAUb4n8c= @@ -8,7 +7,6 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.41.0/go.mod h1:OauMR7DV8fzvZIl2qg6rkaIhD/vmgk4iwEw/h6ercmg= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= @@ -45,68 +43,167 @@ cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRY cloud.google.com/go v0.105.0/go.mod h1:PrLgOJNe5nfE9UMxKxgXj4mD3voiP+YQ6gdt6KMFOKM= cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I= cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY= -cloud.google.com/go v0.110.8 h1:tyNdfIxjzaWctIiLYOTalaLKZ17SI44SKFW26QbOhME= +cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw= +cloud.google.com/go v0.110.4/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI= +cloud.google.com/go v0.110.6/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI= +cloud.google.com/go v0.110.7/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI= cloud.google.com/go v0.110.8/go.mod h1:Iz8AkXJf1qmxC3Oxoep8R1T36w8B92yU29PcBhHO5fk= +cloud.google.com/go v0.110.9/go.mod h1:rpxevX/0Lqvlbc88b7Sc1SPNdyK1riNBTUU6JXhYNpM= +cloud.google.com/go v0.110.10/go.mod h1:v1OoFqYxiBkUrruItNM3eT4lLByNjxmJSV/xDKJNnic= +cloud.google.com/go v0.112.0 h1:tpFCD7hpHFlQ8yPwT3x+QeXqc2T6+n6T+hmABHfDUSM= +cloud.google.com/go v0.112.0/go.mod h1:3jEEVwZ/MHU4djK5t5RHuKOA/GbLddgTdVubX1qnPD4= cloud.google.com/go/accessapproval v1.4.0/go.mod h1:zybIuC3KpDOvotz59lFe5qxRZx6C75OtwbisN56xYB4= cloud.google.com/go/accessapproval v1.5.0/go.mod h1:HFy3tuiGvMdcd/u+Cu5b9NkO1pEICJ46IR82PoUdplw= cloud.google.com/go/accessapproval v1.6.0/go.mod h1:R0EiYnwV5fsRFiKZkPHr6mwyk2wxUJ30nL4j2pcFY2E= +cloud.google.com/go/accessapproval v1.7.1/go.mod h1:JYczztsHRMK7NTXb6Xw+dwbs/WnOJxbo/2mTI+Kgg68= +cloud.google.com/go/accessapproval v1.7.2/go.mod h1:/gShiq9/kK/h8T/eEn1BTzalDvk0mZxJlhfw0p+Xuc0= +cloud.google.com/go/accessapproval v1.7.3/go.mod h1:4l8+pwIxGTNqSf4T3ds8nLO94NQf0W/KnMNuQ9PbnP8= +cloud.google.com/go/accessapproval v1.7.4/go.mod h1:/aTEh45LzplQgFYdQdwPMR9YdX0UlhBmvB84uAmQKUc= cloud.google.com/go/accesscontextmanager v1.3.0/go.mod h1:TgCBehyr5gNMz7ZaH9xubp+CE8dkrszb4oK9CWyvD4o= cloud.google.com/go/accesscontextmanager v1.4.0/go.mod h1:/Kjh7BBu/Gh83sv+K60vN9QE5NJcd80sU33vIe2IFPE= cloud.google.com/go/accesscontextmanager v1.6.0/go.mod h1:8XCvZWfYw3K/ji0iVnp+6pu7huxoQTLmxAbVjbloTtM= +cloud.google.com/go/accesscontextmanager v1.7.0/go.mod h1:CEGLewx8dwa33aDAZQujl7Dx+uYhS0eay198wB/VumQ= +cloud.google.com/go/accesscontextmanager v1.8.0/go.mod h1:uI+AI/r1oyWK99NN8cQ3UK76AMelMzgZCvJfsi2c+ps= +cloud.google.com/go/accesscontextmanager v1.8.1/go.mod h1:JFJHfvuaTC+++1iL1coPiG1eu5D24db2wXCDWDjIrxo= +cloud.google.com/go/accesscontextmanager v1.8.2/go.mod h1:E6/SCRM30elQJ2PKtFMs2YhfJpZSNcJyejhuzoId4Zk= +cloud.google.com/go/accesscontextmanager v1.8.3/go.mod h1:4i/JkF2JiFbhLnnpnfoTX5vRXfhf9ukhU1ANOTALTOQ= +cloud.google.com/go/accesscontextmanager v1.8.4/go.mod h1:ParU+WbMpD34s5JFEnGAnPBYAgUHozaTmDJU7aCU9+M= cloud.google.com/go/aiplatform v1.22.0/go.mod h1:ig5Nct50bZlzV6NvKaTwmplLLddFx0YReh9WfTO5jKw= cloud.google.com/go/aiplatform v1.24.0/go.mod h1:67UUvRBKG6GTayHKV8DBv2RtR1t93YRu5B1P3x99mYY= cloud.google.com/go/aiplatform v1.27.0/go.mod h1:Bvxqtl40l0WImSb04d0hXFU7gDOiq9jQmorivIiWcKg= cloud.google.com/go/aiplatform v1.35.0/go.mod h1:7MFT/vCaOyZT/4IIFfxH4ErVg/4ku6lKv3w0+tFTgXQ= +cloud.google.com/go/aiplatform v1.36.1/go.mod h1:WTm12vJRPARNvJ+v6P52RDHCNe4AhvjcIZ/9/RRHy/k= +cloud.google.com/go/aiplatform v1.37.0/go.mod h1:IU2Cv29Lv9oCn/9LkFiiuKfwrRTq+QQMbW+hPCxJGZw= +cloud.google.com/go/aiplatform v1.45.0/go.mod h1:Iu2Q7sC7QGhXUeOhAj/oCK9a+ULz1O4AotZiqjQ8MYA= +cloud.google.com/go/aiplatform v1.48.0/go.mod h1:Iu2Q7sC7QGhXUeOhAj/oCK9a+ULz1O4AotZiqjQ8MYA= +cloud.google.com/go/aiplatform v1.50.0/go.mod h1:IRc2b8XAMTa9ZmfJV1BCCQbieWWvDnP1A8znyz5N7y4= +cloud.google.com/go/aiplatform v1.51.0/go.mod h1:IRc2b8XAMTa9ZmfJV1BCCQbieWWvDnP1A8znyz5N7y4= +cloud.google.com/go/aiplatform v1.51.1/go.mod h1:kY3nIMAVQOK2XDqDPHaOuD9e+FdMA6OOpfBjsvaFSOo= +cloud.google.com/go/aiplatform v1.51.2/go.mod h1:hCqVYB3mY45w99TmetEoe8eCQEwZEp9WHxeZdcv9phw= +cloud.google.com/go/aiplatform v1.52.0/go.mod h1:pwZMGvqe0JRkI1GWSZCtnAfrR4K1bv65IHILGA//VEU= +cloud.google.com/go/aiplatform v1.54.0/go.mod h1:pwZMGvqe0JRkI1GWSZCtnAfrR4K1bv65IHILGA//VEU= cloud.google.com/go/analytics v0.11.0/go.mod h1:DjEWCu41bVbYcKyvlws9Er60YE4a//bK6mnhWvQeFNI= cloud.google.com/go/analytics v0.12.0/go.mod h1:gkfj9h6XRf9+TS4bmuhPEShsh3hH8PAZzm/41OOhQd4= +cloud.google.com/go/analytics v0.17.0/go.mod h1:WXFa3WSym4IZ+JiKmavYdJwGG/CvpqiqczmL59bTD9M= cloud.google.com/go/analytics v0.18.0/go.mod h1:ZkeHGQlcIPkw0R/GW+boWHhCOR43xz9RN/jn7WcqfIE= +cloud.google.com/go/analytics v0.19.0/go.mod h1:k8liqf5/HCnOUkbawNtrWWc+UAzyDlW89doe8TtoDsE= +cloud.google.com/go/analytics v0.21.2/go.mod h1:U8dcUtmDmjrmUTnnnRnI4m6zKn/yaA5N9RlEkYFHpQo= +cloud.google.com/go/analytics v0.21.3/go.mod h1:U8dcUtmDmjrmUTnnnRnI4m6zKn/yaA5N9RlEkYFHpQo= +cloud.google.com/go/analytics v0.21.4/go.mod h1:zZgNCxLCy8b2rKKVfC1YkC2vTrpfZmeRCySM3aUbskA= +cloud.google.com/go/analytics v0.21.5/go.mod h1:BQtOBHWTlJ96axpPPnw5CvGJ6i3Ve/qX2fTxR8qWyr8= +cloud.google.com/go/analytics v0.21.6/go.mod h1:eiROFQKosh4hMaNhF85Oc9WO97Cpa7RggD40e/RBy8w= cloud.google.com/go/apigateway v1.3.0/go.mod h1:89Z8Bhpmxu6AmUxuVRg/ECRGReEdiP3vQtk4Z1J9rJk= cloud.google.com/go/apigateway v1.4.0/go.mod h1:pHVY9MKGaH9PQ3pJ4YLzoj6U5FUDeDFBllIz7WmzJoc= cloud.google.com/go/apigateway v1.5.0/go.mod h1:GpnZR3Q4rR7LVu5951qfXPJCHquZt02jf7xQx7kpqN8= +cloud.google.com/go/apigateway v1.6.1/go.mod h1:ufAS3wpbRjqfZrzpvLC2oh0MFlpRJm2E/ts25yyqmXA= +cloud.google.com/go/apigateway v1.6.2/go.mod h1:CwMC90nnZElorCW63P2pAYm25AtQrHfuOkbRSHj0bT8= +cloud.google.com/go/apigateway v1.6.3/go.mod h1:k68PXWpEs6BVDTtnLQAyG606Q3mz8pshItwPXjgv44Y= +cloud.google.com/go/apigateway v1.6.4/go.mod h1:0EpJlVGH5HwAN4VF4Iec8TAzGN1aQgbxAWGJsnPCGGY= cloud.google.com/go/apigeeconnect v1.3.0/go.mod h1:G/AwXFAKo0gIXkPTVfZDd2qA1TxBXJ3MgMRBQkIi9jc= cloud.google.com/go/apigeeconnect v1.4.0/go.mod h1:kV4NwOKqjvt2JYR0AoIWo2QGfoRtn/pkS3QlHp0Ni04= cloud.google.com/go/apigeeconnect v1.5.0/go.mod h1:KFaCqvBRU6idyhSNyn3vlHXc8VMDJdRmwDF6JyFRqZ8= +cloud.google.com/go/apigeeconnect v1.6.1/go.mod h1:C4awq7x0JpLtrlQCr8AzVIzAaYgngRqWf9S5Uhg+wWs= +cloud.google.com/go/apigeeconnect v1.6.2/go.mod h1:s6O0CgXT9RgAxlq3DLXvG8riw8PYYbU/v25jqP3Dy18= +cloud.google.com/go/apigeeconnect v1.6.3/go.mod h1:peG0HFQ0si2bN15M6QSjEW/W7Gy3NYkWGz7pFz13cbo= +cloud.google.com/go/apigeeconnect v1.6.4/go.mod h1:CapQCWZ8TCjnU0d7PobxhpOdVz/OVJ2Hr/Zcuu1xFx0= cloud.google.com/go/apigeeregistry v0.4.0/go.mod h1:EUG4PGcsZvxOXAdyEghIdXwAEi/4MEaoqLMLDMIwKXY= cloud.google.com/go/apigeeregistry v0.5.0/go.mod h1:YR5+s0BVNZfVOUkMa5pAR2xGd0A473vA5M7j247o1wM= +cloud.google.com/go/apigeeregistry v0.6.0/go.mod h1:BFNzW7yQVLZ3yj0TKcwzb8n25CFBri51GVGOEUcgQsc= +cloud.google.com/go/apigeeregistry v0.7.1/go.mod h1:1XgyjZye4Mqtw7T9TsY4NW10U7BojBvG4RMD+vRDrIw= +cloud.google.com/go/apigeeregistry v0.7.2/go.mod h1:9CA2B2+TGsPKtfi3F7/1ncCCsL62NXBRfM6iPoGSM+8= +cloud.google.com/go/apigeeregistry v0.8.1/go.mod h1:MW4ig1N4JZQsXmBSwH4rwpgDonocz7FPBSw6XPGHmYw= +cloud.google.com/go/apigeeregistry v0.8.2/go.mod h1:h4v11TDGdeXJDJvImtgK2AFVvMIgGWjSb0HRnBSjcX8= cloud.google.com/go/apikeys v0.4.0/go.mod h1:XATS/yqZbaBK0HOssf+ALHp8jAlNHUgyfprvNcBIszU= cloud.google.com/go/apikeys v0.5.0/go.mod h1:5aQfwY4D+ewMMWScd3hm2en3hCj+BROlyrt3ytS7KLI= +cloud.google.com/go/apikeys v0.6.0/go.mod h1:kbpXu5upyiAlGkKrJgQl8A0rKNNJ7dQ377pdroRSSi8= cloud.google.com/go/appengine v1.4.0/go.mod h1:CS2NhuBuDXM9f+qscZ6V86m1MIIqPj3WC/UoEuR1Sno= cloud.google.com/go/appengine v1.5.0/go.mod h1:TfasSozdkFI0zeoxW3PTBLiNqRmzraodCWatWI9Dmak= cloud.google.com/go/appengine v1.6.0/go.mod h1:hg6i0J/BD2cKmDJbaFSYHFyZkgBEfQrDg/X0V5fJn84= +cloud.google.com/go/appengine v1.7.0/go.mod h1:eZqpbHFCqRGa2aCdope7eC0SWLV1j0neb/QnMJVWx6A= +cloud.google.com/go/appengine v1.7.1/go.mod h1:IHLToyb/3fKutRysUlFO0BPt5j7RiQ45nrzEJmKTo6E= +cloud.google.com/go/appengine v1.8.1/go.mod h1:6NJXGLVhZCN9aQ/AEDvmfzKEfoYBlfB80/BHiKVputY= +cloud.google.com/go/appengine v1.8.2/go.mod h1:WMeJV9oZ51pvclqFN2PqHoGnys7rK0rz6s3Mp6yMvDo= +cloud.google.com/go/appengine v1.8.3/go.mod h1:2oUPZ1LVZ5EXi+AF1ihNAF+S8JrzQ3till5m9VQkrsk= +cloud.google.com/go/appengine v1.8.4/go.mod h1:TZ24v+wXBujtkK77CXCpjZbnuTvsFNT41MUaZ28D6vg= cloud.google.com/go/area120 v0.5.0/go.mod h1:DE/n4mp+iqVyvxHN41Vf1CR602GiHQjFPusMFW6bGR4= cloud.google.com/go/area120 v0.6.0/go.mod h1:39yFJqWVgm0UZqWTOdqkLhjoC7uFfgXRC8g/ZegeAh0= +cloud.google.com/go/area120 v0.7.0/go.mod h1:a3+8EUD1SX5RUcCs3MY5YasiO1z6yLiNLRiFrykbynY= cloud.google.com/go/area120 v0.7.1/go.mod h1:j84i4E1RboTWjKtZVWXPqvK5VHQFJRF2c1Nm69pWm9k= +cloud.google.com/go/area120 v0.8.1/go.mod h1:BVfZpGpB7KFVNxPiQBuHkX6Ed0rS51xIgmGyjrAfzsg= +cloud.google.com/go/area120 v0.8.2/go.mod h1:a5qfo+x77SRLXnCynFWPUZhnZGeSgvQ+Y0v1kSItkh4= +cloud.google.com/go/area120 v0.8.3/go.mod h1:5zj6pMzVTH+SVHljdSKC35sriR/CVvQZzG/Icdyriw0= +cloud.google.com/go/area120 v0.8.4/go.mod h1:jfawXjxf29wyBXr48+W+GyX/f8fflxp642D/bb9v68M= cloud.google.com/go/artifactregistry v1.6.0/go.mod h1:IYt0oBPSAGYj/kprzsBjZ/4LnG/zOcHyFHjWPCi6SAQ= cloud.google.com/go/artifactregistry v1.7.0/go.mod h1:mqTOFOnGZx8EtSqK/ZWcsm/4U8B77rbcLP6ruDU2Ixk= cloud.google.com/go/artifactregistry v1.8.0/go.mod h1:w3GQXkJX8hiKN0v+at4b0qotwijQbYUqF2GWkZzAhC0= cloud.google.com/go/artifactregistry v1.9.0/go.mod h1:2K2RqvA2CYvAeARHRkLDhMDJ3OXy26h3XW+3/Jh2uYc= +cloud.google.com/go/artifactregistry v1.11.1/go.mod h1:lLYghw+Itq9SONbCa1YWBoWs1nOucMH0pwXN1rOBZFI= cloud.google.com/go/artifactregistry v1.11.2/go.mod h1:nLZns771ZGAwVLzTX/7Al6R9ehma4WUEhZGWV6CeQNQ= +cloud.google.com/go/artifactregistry v1.12.0/go.mod h1:o6P3MIvtzTOnmvGagO9v/rOjjA0HmhJ+/6KAXrmYDCI= +cloud.google.com/go/artifactregistry v1.13.0/go.mod h1:uy/LNfoOIivepGhooAUpL1i30Hgee3Cu0l4VTWHUC08= +cloud.google.com/go/artifactregistry v1.14.1/go.mod h1:nxVdG19jTaSTu7yA7+VbWL346r3rIdkZ142BSQqhn5E= +cloud.google.com/go/artifactregistry v1.14.2/go.mod h1:Xk+QbsKEb0ElmyeMfdHAey41B+qBq3q5R5f5xD4XT3U= +cloud.google.com/go/artifactregistry v1.14.3/go.mod h1:A2/E9GXnsyXl7GUvQ/2CjHA+mVRoWAXC0brg2os+kNI= +cloud.google.com/go/artifactregistry v1.14.4/go.mod h1:SJJcZTMv6ce0LDMUnihCN7WSrI+kBSFV0KIKo8S8aYU= +cloud.google.com/go/artifactregistry v1.14.6/go.mod h1:np9LSFotNWHcjnOgh8UVK0RFPCTUGbO0ve3384xyHfE= cloud.google.com/go/asset v1.5.0/go.mod h1:5mfs8UvcM5wHhqtSv8J1CtxxaQq3AdBxxQi2jGW/K4o= cloud.google.com/go/asset v1.7.0/go.mod h1:YbENsRK4+xTiL+Ofoj5Ckf+O17kJtgp3Y3nn4uzZz5s= cloud.google.com/go/asset v1.8.0/go.mod h1:mUNGKhiqIdbr8X7KNayoYvyc4HbbFO9URsjbytpUaW0= cloud.google.com/go/asset v1.9.0/go.mod h1:83MOE6jEJBMqFKadM9NLRcs80Gdw76qGuHn8m3h8oHQ= cloud.google.com/go/asset v1.10.0/go.mod h1:pLz7uokL80qKhzKr4xXGvBQXnzHn5evJAEAtZiIb0wY= cloud.google.com/go/asset v1.11.1/go.mod h1:fSwLhbRvC9p9CXQHJ3BgFeQNM4c9x10lqlrdEUYXlJo= +cloud.google.com/go/asset v1.12.0/go.mod h1:h9/sFOa4eDIyKmH6QMpm4eUK3pDojWnUhTgJlk762Hg= +cloud.google.com/go/asset v1.13.0/go.mod h1:WQAMyYek/b7NBpYq/K4KJWcRqzoalEsxz/t/dTk4THw= +cloud.google.com/go/asset v1.14.1/go.mod h1:4bEJ3dnHCqWCDbWJ/6Vn7GVI9LerSi7Rfdi03hd+WTQ= +cloud.google.com/go/asset v1.15.0/go.mod h1:tpKafV6mEut3+vN9ScGvCHXHj7FALFVta+okxFECHcg= +cloud.google.com/go/asset v1.15.1/go.mod h1:yX/amTvFWRpp5rcFq6XbCxzKT8RJUam1UoboE179jU4= +cloud.google.com/go/asset v1.15.2/go.mod h1:B6H5tclkXvXz7PD22qCA2TDxSVQfasa3iDlM89O2NXs= +cloud.google.com/go/asset v1.15.3/go.mod h1:yYLfUD4wL4X589A9tYrv4rFrba0QlDeag0CMcM5ggXU= cloud.google.com/go/assuredworkloads v1.5.0/go.mod h1:n8HOZ6pff6re5KYfBXcFvSViQjDwxFkAkmUFffJRbbY= cloud.google.com/go/assuredworkloads v1.6.0/go.mod h1:yo2YOk37Yc89Rsd5QMVECvjaMKymF9OP+QXWlKXUkXw= cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVoYoxeLBoj4XkKYscNI= cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo= cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0= cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E= +cloud.google.com/go/assuredworkloads v1.11.1/go.mod h1:+F04I52Pgn5nmPG36CWFtxmav6+7Q+c5QyJoL18Lry0= +cloud.google.com/go/assuredworkloads v1.11.2/go.mod h1:O1dfr+oZJMlE6mw0Bp0P1KZSlj5SghMBvTpZqIcUAW4= +cloud.google.com/go/assuredworkloads v1.11.3/go.mod h1:vEjfTKYyRUaIeA0bsGJceFV2JKpVRgyG2op3jfa59Zs= +cloud.google.com/go/assuredworkloads v1.11.4/go.mod h1:4pwwGNwy1RP0m+y12ef3Q/8PaiWrIDQ6nD2E8kvWI9U= cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0= cloud.google.com/go/automl v1.6.0/go.mod h1:ugf8a6Fx+zP0D59WLhqgTDsQI9w07o64uf/Is3Nh5p8= cloud.google.com/go/automl v1.7.0/go.mod h1:RL9MYCCsJEOmt0Wf3z9uzG0a7adTT1fe+aObgSpkCt8= cloud.google.com/go/automl v1.8.0/go.mod h1:xWx7G/aPEe/NP+qzYXktoBSDfjO+vnKMGgsApGJJquM= cloud.google.com/go/automl v1.12.0/go.mod h1:tWDcHDp86aMIuHmyvjuKeeHEGq76lD7ZqfGLN6B0NuU= +cloud.google.com/go/automl v1.13.1/go.mod h1:1aowgAHWYZU27MybSCFiukPO7xnyawv7pt3zK4bheQE= +cloud.google.com/go/automl v1.13.2/go.mod h1:gNY/fUmDEN40sP8amAX3MaXkxcqPIn7F1UIIPZpy4Mg= +cloud.google.com/go/automl v1.13.3/go.mod h1:Y8KwvyAZFOsMAPqUCfNu1AyclbC6ivCUF/MTwORymyY= +cloud.google.com/go/automl v1.13.4/go.mod h1:ULqwX/OLZ4hBVfKQaMtxMSTlPx0GqGbWN8uA/1EqCP8= cloud.google.com/go/baremetalsolution v0.3.0/go.mod h1:XOrocE+pvK1xFfleEnShBlNAXf+j5blPPxrhjKgnIFc= cloud.google.com/go/baremetalsolution v0.4.0/go.mod h1:BymplhAadOO/eBa7KewQ0Ppg4A4Wplbn+PsFKRLo0uI= cloud.google.com/go/baremetalsolution v0.5.0/go.mod h1:dXGxEkmR9BMwxhzBhV0AioD0ULBmuLZI8CdwalUxuss= +cloud.google.com/go/baremetalsolution v1.1.1/go.mod h1:D1AV6xwOksJMV4OSlWHtWuFNZZYujJknMAP4Qa27QIA= +cloud.google.com/go/baremetalsolution v1.2.0/go.mod h1:68wi9AwPYkEWIUT4SvSGS9UJwKzNpshjHsH4lzk8iOw= +cloud.google.com/go/baremetalsolution v1.2.1/go.mod h1:3qKpKIw12RPXStwQXcbhfxVj1dqQGEvcmA+SX/mUR88= +cloud.google.com/go/baremetalsolution v1.2.2/go.mod h1:O5V6Uu1vzVelYahKfwEWRMaS3AbCkeYHy3145s1FkhM= +cloud.google.com/go/baremetalsolution v1.2.3/go.mod h1:/UAQ5xG3faDdy180rCUv47e0jvpp3BFxT+Cl0PFjw5g= cloud.google.com/go/batch v0.3.0/go.mod h1:TR18ZoAekj1GuirsUsR1ZTKN3FC/4UDnScjT8NXImFE= cloud.google.com/go/batch v0.4.0/go.mod h1:WZkHnP43R/QCGQsZ+0JyG4i79ranE2u8xvjq/9+STPE= cloud.google.com/go/batch v0.7.0/go.mod h1:vLZN95s6teRUqRQ4s3RLDsH8PvboqBK+rn1oevL159g= +cloud.google.com/go/batch v1.3.1/go.mod h1:VguXeQKXIYaeeIYbuozUmBR13AfL4SJP7IltNPS+A4A= +cloud.google.com/go/batch v1.4.1/go.mod h1:KdBmDD61K0ovcxoRHGrN6GmOBWeAOyCgKD0Mugx4Fkk= +cloud.google.com/go/batch v1.5.0/go.mod h1:KdBmDD61K0ovcxoRHGrN6GmOBWeAOyCgKD0Mugx4Fkk= +cloud.google.com/go/batch v1.5.1/go.mod h1:RpBuIYLkQu8+CWDk3dFD/t/jOCGuUpkpX+Y0n1Xccs8= +cloud.google.com/go/batch v1.6.1/go.mod h1:urdpD13zPe6YOK+6iZs/8/x2VBRofvblLpx0t57vM98= +cloud.google.com/go/batch v1.6.3/go.mod h1:J64gD4vsNSA2O5TtDB5AAux3nJ9iV8U3ilg3JDBYejU= cloud.google.com/go/beyondcorp v0.2.0/go.mod h1:TB7Bd+EEtcw9PCPQhCJtJGjk/7TC6ckmnSFS+xwTfm4= cloud.google.com/go/beyondcorp v0.3.0/go.mod h1:E5U5lcrcXMsCuoDNyGrpyTm/hn7ne941Jz2vmksAxW8= cloud.google.com/go/beyondcorp v0.4.0/go.mod h1:3ApA0mbhHx6YImmuubf5pyW8srKnCEPON32/5hj+RmM= +cloud.google.com/go/beyondcorp v0.5.0/go.mod h1:uFqj9X+dSfrheVp7ssLTaRHd2EHqSL4QZmH4e8WXGGU= +cloud.google.com/go/beyondcorp v0.6.1/go.mod h1:YhxDWw946SCbmcWo3fAhw3V4XZMSpQ/VYfcKGAEU8/4= +cloud.google.com/go/beyondcorp v1.0.0/go.mod h1:YhxDWw946SCbmcWo3fAhw3V4XZMSpQ/VYfcKGAEU8/4= +cloud.google.com/go/beyondcorp v1.0.1/go.mod h1:zl/rWWAFVeV+kx+X2Javly7o1EIQThU4WlkynffL/lk= +cloud.google.com/go/beyondcorp v1.0.2/go.mod h1:m8cpG7caD+5su+1eZr+TSvF6r21NdLJk4f9u4SP2Ntc= +cloud.google.com/go/beyondcorp v1.0.3/go.mod h1:HcBvnEd7eYr+HGDd5ZbuVmBYX019C6CEXBonXbCVwJo= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= @@ -116,34 +213,84 @@ cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM7 cloud.google.com/go/bigquery v1.42.0/go.mod h1:8dRTJxhtG+vwBKzE5OseQn/hiydoQN3EedCaOdYmxRA= cloud.google.com/go/bigquery v1.43.0/go.mod h1:ZMQcXHsl+xmU1z36G2jNGZmKp9zNY5BUua5wDgmNCfw= cloud.google.com/go/bigquery v1.44.0/go.mod h1:0Y33VqXTEsbamHJvJHdFmtqHvMIY28aK1+dFsvaChGc= +cloud.google.com/go/bigquery v1.47.0/go.mod h1:sA9XOgy0A8vQK9+MWhEQTY6Tix87M/ZurWFIxmF9I/E= cloud.google.com/go/bigquery v1.48.0/go.mod h1:QAwSz+ipNgfL5jxiaK7weyOhzdoAy1zFm0Nf1fysJac= +cloud.google.com/go/bigquery v1.49.0/go.mod h1:Sv8hMmTFFYBlt/ftw2uN6dFdQPzBlREY9yBh7Oy7/4Q= +cloud.google.com/go/bigquery v1.50.0/go.mod h1:YrleYEh2pSEbgTBZYMJ5SuSr0ML3ypjRB1zgf7pvQLU= +cloud.google.com/go/bigquery v1.52.0/go.mod h1:3b/iXjRQGU4nKa87cXeg6/gogLjO8C6PmuM8i5Bi/u4= +cloud.google.com/go/bigquery v1.53.0/go.mod h1:3b/iXjRQGU4nKa87cXeg6/gogLjO8C6PmuM8i5Bi/u4= +cloud.google.com/go/bigquery v1.55.0/go.mod h1:9Y5I3PN9kQWuid6183JFhOGOW3GcirA5LpsKCUn+2ec= +cloud.google.com/go/bigquery v1.56.0/go.mod h1:KDcsploXTEY7XT3fDQzMUZlpQLHzE4itubHrnmhUrZA= +cloud.google.com/go/bigquery v1.57.1/go.mod h1:iYzC0tGVWt1jqSzBHqCr3lrRn0u13E8e+AqowBsDgug= cloud.google.com/go/billing v1.4.0/go.mod h1:g9IdKBEFlItS8bTtlrZdVLWSSdSyFUZKXNS02zKMOZY= cloud.google.com/go/billing v1.5.0/go.mod h1:mztb1tBc3QekhjSgmpf/CV4LzWXLzCArwpLmP2Gm88s= cloud.google.com/go/billing v1.6.0/go.mod h1:WoXzguj+BeHXPbKfNWkqVtDdzORazmCjraY+vrxcyvI= cloud.google.com/go/billing v1.7.0/go.mod h1:q457N3Hbj9lYwwRbnlD7vUpyjq6u5U1RAOArInEiD5Y= cloud.google.com/go/billing v1.12.0/go.mod h1:yKrZio/eu+okO/2McZEbch17O5CB5NpZhhXG6Z766ss= +cloud.google.com/go/billing v1.13.0/go.mod h1:7kB2W9Xf98hP9Sr12KfECgfGclsH3CQR0R08tnRlRbc= +cloud.google.com/go/billing v1.16.0/go.mod h1:y8vx09JSSJG02k5QxbycNRrN7FGZB6F3CAcgum7jvGA= +cloud.google.com/go/billing v1.17.0/go.mod h1:Z9+vZXEq+HwH7bhJkyI4OQcR6TSbeMrjlpEjO2vzY64= +cloud.google.com/go/billing v1.17.1/go.mod h1:Z9+vZXEq+HwH7bhJkyI4OQcR6TSbeMrjlpEjO2vzY64= +cloud.google.com/go/billing v1.17.2/go.mod h1:u/AdV/3wr3xoRBk5xvUzYMS1IawOAPwQMuHgHMdljDg= +cloud.google.com/go/billing v1.17.3/go.mod h1:z83AkoZ7mZwBGT3yTnt6rSGI1OOsHSIi6a5M3mJ8NaU= +cloud.google.com/go/billing v1.17.4/go.mod h1:5DOYQStCxquGprqfuid/7haD7th74kyMBHkjO/OvDtk= cloud.google.com/go/binaryauthorization v1.1.0/go.mod h1:xwnoWu3Y84jbuHa0zd526MJYmtnVXn0syOjaJgy4+dM= cloud.google.com/go/binaryauthorization v1.2.0/go.mod h1:86WKkJHtRcv5ViNABtYMhhNWRrD1Vpi//uKEy7aYEfI= cloud.google.com/go/binaryauthorization v1.3.0/go.mod h1:lRZbKgjDIIQvzYQS1p99A7/U1JqvqeZg0wiI5tp6tg0= cloud.google.com/go/binaryauthorization v1.4.0/go.mod h1:tsSPQrBd77VLplV70GUhBf/Zm3FsKmgSqgm4UmiDItk= cloud.google.com/go/binaryauthorization v1.5.0/go.mod h1:OSe4OU1nN/VswXKRBmciKpo9LulY41gch5c68htf3/Q= +cloud.google.com/go/binaryauthorization v1.6.1/go.mod h1:TKt4pa8xhowwffiBmbrbcxijJRZED4zrqnwZ1lKH51U= +cloud.google.com/go/binaryauthorization v1.7.0/go.mod h1:Zn+S6QqTMn6odcMU1zDZCJxPjU2tZPV1oDl45lWY154= +cloud.google.com/go/binaryauthorization v1.7.1/go.mod h1:GTAyfRWYgcbsP3NJogpV3yeunbUIjx2T9xVeYovtURE= +cloud.google.com/go/binaryauthorization v1.7.2/go.mod h1:kFK5fQtxEp97m92ziy+hbu+uKocka1qRRL8MVJIgjv0= +cloud.google.com/go/binaryauthorization v1.7.3/go.mod h1:VQ/nUGRKhrStlGr+8GMS8f6/vznYLkdK5vaKfdCIpvU= cloud.google.com/go/certificatemanager v1.3.0/go.mod h1:n6twGDvcUBFu9uBgt4eYvvf3sQ6My8jADcOVwHmzadg= cloud.google.com/go/certificatemanager v1.4.0/go.mod h1:vowpercVFyqs8ABSmrdV+GiFf2H/ch3KyudYQEMM590= cloud.google.com/go/certificatemanager v1.6.0/go.mod h1:3Hh64rCKjRAX8dXgRAyOcY5vQ/fE1sh8o+Mdd6KPgY8= +cloud.google.com/go/certificatemanager v1.7.1/go.mod h1:iW8J3nG6SaRYImIa+wXQ0g8IgoofDFRp5UMzaNk1UqI= +cloud.google.com/go/certificatemanager v1.7.2/go.mod h1:15SYTDQMd00kdoW0+XY5d9e+JbOPjp24AvF48D8BbcQ= +cloud.google.com/go/certificatemanager v1.7.3/go.mod h1:T/sZYuC30PTag0TLo28VedIRIj1KPGcOQzjWAptHa00= +cloud.google.com/go/certificatemanager v1.7.4/go.mod h1:FHAylPe/6IIKuaRmHbjbdLhGhVQ+CWHSD5Jq0k4+cCE= cloud.google.com/go/channel v1.8.0/go.mod h1:W5SwCXDJsq/rg3tn3oG0LOxpAo6IMxNa09ngphpSlnk= cloud.google.com/go/channel v1.9.0/go.mod h1:jcu05W0my9Vx4mt3/rEHpfxc9eKi9XwsdDL8yBMbKUk= cloud.google.com/go/channel v1.11.0/go.mod h1:IdtI0uWGqhEeatSB62VOoJ8FSUhJ9/+iGkJVqp74CGE= +cloud.google.com/go/channel v1.12.0/go.mod h1:VkxCGKASi4Cq7TbXxlaBezonAYpp1GCnKMY6tnMQnLU= +cloud.google.com/go/channel v1.16.0/go.mod h1:eN/q1PFSl5gyu0dYdmxNXscY/4Fi7ABmeHCJNf/oHmc= +cloud.google.com/go/channel v1.17.0/go.mod h1:RpbhJsGi/lXWAUM1eF4IbQGbsfVlg2o8Iiy2/YLfVT0= +cloud.google.com/go/channel v1.17.1/go.mod h1:xqfzcOZAcP4b/hUDH0GkGg1Sd5to6di1HOJn/pi5uBQ= +cloud.google.com/go/channel v1.17.2/go.mod h1:aT2LhnftnyfQceFql5I/mP8mIbiiJS4lWqgXA815zMk= +cloud.google.com/go/channel v1.17.3/go.mod h1:QcEBuZLGGrUMm7kNj9IbU1ZfmJq2apotsV83hbxX7eE= cloud.google.com/go/cloudbuild v1.3.0/go.mod h1:WequR4ULxlqvMsjDEEEFnOG5ZSRSgWOywXYDb1vPE6U= cloud.google.com/go/cloudbuild v1.4.0/go.mod h1:5Qwa40LHiOXmz3386FrjrYM93rM/hdRr7b53sySrTqA= +cloud.google.com/go/cloudbuild v1.6.0/go.mod h1:UIbc/w9QCbH12xX+ezUsgblrWv+Cv4Tw83GiSMHOn9M= cloud.google.com/go/cloudbuild v1.7.0/go.mod h1:zb5tWh2XI6lR9zQmsm1VRA+7OCuve5d8S+zJUul8KTg= +cloud.google.com/go/cloudbuild v1.9.0/go.mod h1:qK1d7s4QlO0VwfYn5YuClDGg2hfmLZEb4wQGAbIgL1s= +cloud.google.com/go/cloudbuild v1.10.1/go.mod h1:lyJg7v97SUIPq4RC2sGsz/9tNczhyv2AjML/ci4ulzU= +cloud.google.com/go/cloudbuild v1.13.0/go.mod h1:lyJg7v97SUIPq4RC2sGsz/9tNczhyv2AjML/ci4ulzU= +cloud.google.com/go/cloudbuild v1.14.0/go.mod h1:lyJg7v97SUIPq4RC2sGsz/9tNczhyv2AjML/ci4ulzU= +cloud.google.com/go/cloudbuild v1.14.1/go.mod h1:K7wGc/3zfvmYWOWwYTgF/d/UVJhS4pu+HAy7PL7mCsU= +cloud.google.com/go/cloudbuild v1.14.2/go.mod h1:Bn6RO0mBYk8Vlrt+8NLrru7WXlQ9/RDWz2uo5KG1/sg= +cloud.google.com/go/cloudbuild v1.14.3/go.mod h1:eIXYWmRt3UtggLnFGx4JvXcMj4kShhVzGndL1LwleEM= +cloud.google.com/go/cloudbuild v1.15.0/go.mod h1:eIXYWmRt3UtggLnFGx4JvXcMj4kShhVzGndL1LwleEM= cloud.google.com/go/clouddms v1.3.0/go.mod h1:oK6XsCDdW4Ib3jCCBugx+gVjevp2TMXFtgxvPSee3OM= cloud.google.com/go/clouddms v1.4.0/go.mod h1:Eh7sUGCC+aKry14O1NRljhjyrr0NFC0G2cjwX0cByRk= cloud.google.com/go/clouddms v1.5.0/go.mod h1:QSxQnhikCLUw13iAbffF2CZxAER3xDGNHjsTAkQJcQA= +cloud.google.com/go/clouddms v1.6.1/go.mod h1:Ygo1vL52Ov4TBZQquhz5fiw2CQ58gvu+PlS6PVXCpZI= +cloud.google.com/go/clouddms v1.7.0/go.mod h1:MW1dC6SOtI/tPNCciTsXtsGNEM0i0OccykPvv3hiYeM= +cloud.google.com/go/clouddms v1.7.1/go.mod h1:o4SR8U95+P7gZ/TX+YbJxehOCsM+fe6/brlrFquiszk= +cloud.google.com/go/clouddms v1.7.2/go.mod h1:Rk32TmWmHo64XqDvW7jgkFQet1tUKNVzs7oajtJT3jU= +cloud.google.com/go/clouddms v1.7.3/go.mod h1:fkN2HQQNUYInAU3NQ3vRLkV2iWs8lIdmBKOx4nrL6Hc= cloud.google.com/go/cloudtasks v1.5.0/go.mod h1:fD92REy1x5woxkKEkLdvavGnPJGEn8Uic9nWuLzqCpY= cloud.google.com/go/cloudtasks v1.6.0/go.mod h1:C6Io+sxuke9/KNRkbQpihnW93SWDU3uXt92nu85HkYI= cloud.google.com/go/cloudtasks v1.7.0/go.mod h1:ImsfdYWwlWNJbdgPIIGJWC+gemEGTBK/SunNQQNCAb4= cloud.google.com/go/cloudtasks v1.8.0/go.mod h1:gQXUIwCSOI4yPVK7DgTVFiiP0ZW/eQkydWzwVMdHxrI= cloud.google.com/go/cloudtasks v1.9.0/go.mod h1:w+EyLsVkLWHcOaqNEyvcKAsWp9p29dL6uL9Nst1cI7Y= +cloud.google.com/go/cloudtasks v1.10.0/go.mod h1:NDSoTLkZ3+vExFEWu2UJV1arUyzVDAiZtdWcsUyNwBs= +cloud.google.com/go/cloudtasks v1.11.1/go.mod h1:a9udmnou9KO2iulGscKR0qBYjreuX8oHwpmFsKspEvM= +cloud.google.com/go/cloudtasks v1.12.1/go.mod h1:a9udmnou9KO2iulGscKR0qBYjreuX8oHwpmFsKspEvM= +cloud.google.com/go/cloudtasks v1.12.2/go.mod h1:A7nYkjNlW2gUoROg1kvJrQGhJP/38UaWwsnuBDOBVUk= +cloud.google.com/go/cloudtasks v1.12.3/go.mod h1:GPVXhIOSGEaR+3xT4Fp72ScI+HjHffSS4B8+BaBB5Ys= +cloud.google.com/go/cloudtasks v1.12.4/go.mod h1:BEPu0Gtt2dU6FxZHNqqNdGqIG86qyWKBPGnsb7udGY0= cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw= cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= @@ -158,8 +305,16 @@ cloud.google.com/go/compute v1.13.0/go.mod h1:5aPTS0cUNMIc1CE546K+Th6weJUNQErARy cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo= cloud.google.com/go/compute v1.15.1/go.mod h1:bjjoF/NtFUrkD/urWfdHaKuOPDR5nWIs63rR+SXhcpA= cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs= -cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY= +cloud.google.com/go/compute v1.19.0/go.mod h1:rikpw2y+UMidAe9tISo04EHNOIf42RLYF/q8Bs93scU= +cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE= +cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI= +cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= +cloud.google.com/go/compute v1.21.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= +cloud.google.com/go/compute v1.23.1/go.mod h1:CqB3xpmPKKt3OJpW2ndFIXnA9A4xAy/F3Xp1ixncW78= +cloud.google.com/go/compute v1.23.2/go.mod h1:JJ0atRC0J/oWYiiVBmsSsrRnh92DhZPG4hFDcR04Rns= +cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk= +cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= cloud.google.com/go/compute/metadata v0.1.0/go.mod h1:Z1VN+bulIf6bt4P/C37K4DyZYZEXYonfTBHHFPO/4UU= cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM= @@ -168,12 +323,34 @@ cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2Aawl cloud.google.com/go/contactcenterinsights v1.3.0/go.mod h1:Eu2oemoePuEFc/xKFPjbTuPSj0fYJcPls9TFlPNnHHY= cloud.google.com/go/contactcenterinsights v1.4.0/go.mod h1:L2YzkGbPsv+vMQMCADxJoT9YiTTnSEd6fEvCeHTYVck= cloud.google.com/go/contactcenterinsights v1.6.0/go.mod h1:IIDlT6CLcDoyv79kDv8iWxMSTZhLxSCofVV5W6YFM/w= +cloud.google.com/go/contactcenterinsights v1.9.1/go.mod h1:bsg/R7zGLYMVxFFzfh9ooLTruLRCG9fnzhH9KznHhbM= +cloud.google.com/go/contactcenterinsights v1.10.0/go.mod h1:bsg/R7zGLYMVxFFzfh9ooLTruLRCG9fnzhH9KznHhbM= +cloud.google.com/go/contactcenterinsights v1.11.0/go.mod h1:hutBdImE4XNZ1NV4vbPJKSFOnQruhC5Lj9bZqWMTKiU= +cloud.google.com/go/contactcenterinsights v1.11.1/go.mod h1:FeNP3Kg8iteKM80lMwSk3zZZKVxr+PGnAId6soKuXwE= +cloud.google.com/go/contactcenterinsights v1.11.2/go.mod h1:A9PIR5ov5cRcd28KlDbmmXE8Aay+Gccer2h4wzkYFso= +cloud.google.com/go/contactcenterinsights v1.11.3/go.mod h1:HHX5wrz5LHVAwfI2smIotQG9x8Qd6gYilaHcLLLmNis= +cloud.google.com/go/contactcenterinsights v1.12.0/go.mod h1:HHX5wrz5LHVAwfI2smIotQG9x8Qd6gYilaHcLLLmNis= cloud.google.com/go/container v1.6.0/go.mod h1:Xazp7GjJSeUYo688S+6J5V+n/t+G5sKBTFkKNudGRxg= cloud.google.com/go/container v1.7.0/go.mod h1:Dp5AHtmothHGX3DwwIHPgq45Y8KmNsgN3amoYfxVkLo= cloud.google.com/go/container v1.13.1/go.mod h1:6wgbMPeQRw9rSnKBCAJXnds3Pzj03C4JHamr8asWKy4= +cloud.google.com/go/container v1.14.0/go.mod h1:3AoJMPhHfLDxLvrlVWaK57IXzaPnLaZq63WX59aQBfM= +cloud.google.com/go/container v1.15.0/go.mod h1:ft+9S0WGjAyjDggg5S06DXj+fHJICWg8L7isCQe9pQA= +cloud.google.com/go/container v1.22.1/go.mod h1:lTNExE2R7f+DLbAN+rJiKTisauFCaoDq6NURZ83eVH4= +cloud.google.com/go/container v1.24.0/go.mod h1:lTNExE2R7f+DLbAN+rJiKTisauFCaoDq6NURZ83eVH4= +cloud.google.com/go/container v1.26.0/go.mod h1:YJCmRet6+6jnYYRS000T6k0D0xUXQgBSaJ7VwI8FBj4= +cloud.google.com/go/container v1.26.1/go.mod h1:5smONjPRUxeEpDG7bMKWfDL4sauswqEtnBK1/KKpR04= +cloud.google.com/go/container v1.26.2/go.mod h1:YlO84xCt5xupVbLaMY4s3XNE79MUJ+49VmkInr6HvF4= +cloud.google.com/go/container v1.27.1/go.mod h1:b1A1gJeTBXVLQ6GGw9/9M4FG94BEGsqJ5+t4d/3N7O4= +cloud.google.com/go/container v1.28.0/go.mod h1:b1A1gJeTBXVLQ6GGw9/9M4FG94BEGsqJ5+t4d/3N7O4= cloud.google.com/go/containeranalysis v0.5.1/go.mod h1:1D92jd8gRR/c0fGMlymRgxWD3Qw9C1ff6/T7mLgVL8I= cloud.google.com/go/containeranalysis v0.6.0/go.mod h1:HEJoiEIu+lEXM+k7+qLCci0h33lX3ZqoYFdmPcoO7s4= cloud.google.com/go/containeranalysis v0.7.0/go.mod h1:9aUL+/vZ55P2CXfuZjS4UjQ9AgXoSw8Ts6lemfmxBxI= +cloud.google.com/go/containeranalysis v0.9.0/go.mod h1:orbOANbwk5Ejoom+s+DUCTTJ7IBdBQJDcSylAx/on9s= +cloud.google.com/go/containeranalysis v0.10.1/go.mod h1:Ya2jiILITMY68ZLPaogjmOMNkwsDrWBSTyBubGXO7j0= +cloud.google.com/go/containeranalysis v0.11.0/go.mod h1:4n2e99ZwpGxpNcz+YsFT1dfOHPQFGcAC8FN2M2/ne/U= +cloud.google.com/go/containeranalysis v0.11.1/go.mod h1:rYlUOM7nem1OJMKwE1SadufX0JP3wnXj844EtZAwWLY= +cloud.google.com/go/containeranalysis v0.11.2/go.mod h1:xibioGBC1MD2j4reTyV1xY1/MvKaz+fyM9ENWhmIeP8= +cloud.google.com/go/containeranalysis v0.11.3/go.mod h1:kMeST7yWFQMGjiG9K7Eov+fPNQcGhb8mXj/UcTiWw9U= cloud.google.com/go/datacatalog v1.3.0/go.mod h1:g9svFY6tuR+j+hrTw3J2dNcmI0dzmSiyOzm8kpLq0a0= cloud.google.com/go/datacatalog v1.5.0/go.mod h1:M7GPLNQeLfWqeIm3iuiruhPzkt65+Bx8dAKvScX8jvs= cloud.google.com/go/datacatalog v1.6.0/go.mod h1:+aEyF8JKg+uXcIdAmmaMUmZ3q1b/lKLtXCmXdnc0lbc= @@ -181,39 +358,103 @@ cloud.google.com/go/datacatalog v1.7.0/go.mod h1:9mEl4AuDYWw81UGc41HonIHH7/sn52H cloud.google.com/go/datacatalog v1.8.0/go.mod h1:KYuoVOv9BM8EYz/4eMFxrr4DUKhGIOXxZoKYF5wdISM= cloud.google.com/go/datacatalog v1.8.1/go.mod h1:RJ58z4rMp3gvETA465Vg+ag8BGgBdnRPEMMSTr5Uv+M= cloud.google.com/go/datacatalog v1.12.0/go.mod h1:CWae8rFkfp6LzLumKOnmVh4+Zle4A3NXLzVJ1d1mRm0= +cloud.google.com/go/datacatalog v1.13.0/go.mod h1:E4Rj9a5ZtAxcQJlEBTLgMTphfP11/lNaAshpoBgemX8= +cloud.google.com/go/datacatalog v1.14.0/go.mod h1:h0PrGtlihoutNMp/uvwhawLQ9+c63Kz65UFqh49Yo+E= +cloud.google.com/go/datacatalog v1.14.1/go.mod h1:d2CevwTG4yedZilwe+v3E3ZBDRMobQfSG/a6cCCN5R4= +cloud.google.com/go/datacatalog v1.16.0/go.mod h1:d2CevwTG4yedZilwe+v3E3ZBDRMobQfSG/a6cCCN5R4= +cloud.google.com/go/datacatalog v1.17.1/go.mod h1:nCSYFHgtxh2MiEktWIz71s/X+7ds/UT9kp0PC7waCzE= +cloud.google.com/go/datacatalog v1.18.0/go.mod h1:nCSYFHgtxh2MiEktWIz71s/X+7ds/UT9kp0PC7waCzE= +cloud.google.com/go/datacatalog v1.18.1/go.mod h1:TzAWaz+ON1tkNr4MOcak8EBHX7wIRX/gZKM+yTVsv+A= +cloud.google.com/go/datacatalog v1.18.2/go.mod h1:SPVgWW2WEMuWHA+fHodYjmxPiMqcOiWfhc9OD5msigk= +cloud.google.com/go/datacatalog v1.18.3/go.mod h1:5FR6ZIF8RZrtml0VUao22FxhdjkoG+a0866rEnObryM= +cloud.google.com/go/datacatalog v1.19.0/go.mod h1:5FR6ZIF8RZrtml0VUao22FxhdjkoG+a0866rEnObryM= cloud.google.com/go/dataflow v0.6.0/go.mod h1:9QwV89cGoxjjSR9/r7eFDqqjtvbKxAK2BaYU6PVk9UM= cloud.google.com/go/dataflow v0.7.0/go.mod h1:PX526vb4ijFMesO1o202EaUmouZKBpjHsTlCtB4parQ= cloud.google.com/go/dataflow v0.8.0/go.mod h1:Rcf5YgTKPtQyYz8bLYhFoIV/vP39eL7fWNcSOyFfLJE= +cloud.google.com/go/dataflow v0.9.1/go.mod h1:Wp7s32QjYuQDWqJPFFlnBKhkAtiFpMTdg00qGbnIHVw= +cloud.google.com/go/dataflow v0.9.2/go.mod h1:vBfdBZ/ejlTaYIGB3zB4T08UshH70vbtZeMD+urnUSo= +cloud.google.com/go/dataflow v0.9.3/go.mod h1:HI4kMVjcHGTs3jTHW/kv3501YW+eloiJSLxkJa/vqFE= +cloud.google.com/go/dataflow v0.9.4/go.mod h1:4G8vAkHYCSzU8b/kmsoR2lWyHJD85oMJPHMtan40K8w= cloud.google.com/go/dataform v0.3.0/go.mod h1:cj8uNliRlHpa6L3yVhDOBrUXH+BPAO1+KFMQQNSThKo= cloud.google.com/go/dataform v0.4.0/go.mod h1:fwV6Y4Ty2yIFL89huYlEkwUPtS7YZinZbzzj5S9FzCE= cloud.google.com/go/dataform v0.5.0/go.mod h1:GFUYRe8IBa2hcomWplodVmUx/iTL0FrsauObOM3Ipr0= cloud.google.com/go/dataform v0.6.0/go.mod h1:QPflImQy33e29VuapFdf19oPbE4aYTJxr31OAPV+ulA= +cloud.google.com/go/dataform v0.7.0/go.mod h1:7NulqnVozfHvWUBpMDfKMUESr+85aJsC/2O0o3jWPDE= +cloud.google.com/go/dataform v0.8.1/go.mod h1:3BhPSiw8xmppbgzeBbmDvmSWlwouuJkXsXsb8UBih9M= +cloud.google.com/go/dataform v0.8.2/go.mod h1:X9RIqDs6NbGPLR80tnYoPNiO1w0wenKTb8PxxlhTMKM= +cloud.google.com/go/dataform v0.8.3/go.mod h1:8nI/tvv5Fso0drO3pEjtowz58lodx8MVkdV2q0aPlqg= +cloud.google.com/go/dataform v0.9.1/go.mod h1:pWTg+zGQ7i16pyn0bS1ruqIE91SdL2FDMvEYu/8oQxs= cloud.google.com/go/datafusion v1.4.0/go.mod h1:1Zb6VN+W6ALo85cXnM1IKiPw+yQMKMhB9TsTSRDo/38= cloud.google.com/go/datafusion v1.5.0/go.mod h1:Kz+l1FGHB0J+4XF2fud96WMmRiq/wj8N9u007vyXZ2w= cloud.google.com/go/datafusion v1.6.0/go.mod h1:WBsMF8F1RhSXvVM8rCV3AeyWVxcC2xY6vith3iw3S+8= +cloud.google.com/go/datafusion v1.7.1/go.mod h1:KpoTBbFmoToDExJUso/fcCiguGDk7MEzOWXUsJo0wsI= +cloud.google.com/go/datafusion v1.7.2/go.mod h1:62K2NEC6DRlpNmI43WHMWf9Vg/YvN6QVi8EVwifElI0= +cloud.google.com/go/datafusion v1.7.3/go.mod h1:eoLt1uFXKGBq48jy9LZ+Is8EAVLnmn50lNncLzwYokE= +cloud.google.com/go/datafusion v1.7.4/go.mod h1:BBs78WTOLYkT4GVZIXQCZT3GFpkpDN4aBY4NDX/jVlM= cloud.google.com/go/datalabeling v0.5.0/go.mod h1:TGcJ0G2NzcsXSE/97yWjIZO0bXj0KbVlINXMG9ud42I= cloud.google.com/go/datalabeling v0.6.0/go.mod h1:WqdISuk/+WIGeMkpw/1q7bK/tFEZxsrFJOJdY2bXvTQ= cloud.google.com/go/datalabeling v0.7.0/go.mod h1:WPQb1y08RJbmpM3ww0CSUAGweL0SxByuW2E+FU+wXcM= +cloud.google.com/go/datalabeling v0.8.1/go.mod h1:XS62LBSVPbYR54GfYQsPXZjTW8UxCK2fkDciSrpRFdY= +cloud.google.com/go/datalabeling v0.8.2/go.mod h1:cyDvGHuJWu9U/cLDA7d8sb9a0tWLEletStu2sTmg3BE= +cloud.google.com/go/datalabeling v0.8.3/go.mod h1:tvPhpGyS/V7lqjmb3V0TaDdGvhzgR1JoW7G2bpi2UTI= +cloud.google.com/go/datalabeling v0.8.4/go.mod h1:Z1z3E6LHtffBGrNUkKwbwbDxTiXEApLzIgmymj8A3S8= cloud.google.com/go/dataplex v1.3.0/go.mod h1:hQuRtDg+fCiFgC8j0zV222HvzFQdRd+SVX8gdmFcZzA= cloud.google.com/go/dataplex v1.4.0/go.mod h1:X51GfLXEMVJ6UN47ESVqvlsRplbLhcsAt0kZCCKsU0A= cloud.google.com/go/dataplex v1.5.2/go.mod h1:cVMgQHsmfRoI5KFYq4JtIBEUbYwc3c7tXmIDhRmNNVQ= +cloud.google.com/go/dataplex v1.6.0/go.mod h1:bMsomC/aEJOSpHXdFKFGQ1b0TDPIeL28nJObeO1ppRs= +cloud.google.com/go/dataplex v1.8.1/go.mod h1:7TyrDT6BCdI8/38Uvp0/ZxBslOslP2X2MPDucliyvSE= +cloud.google.com/go/dataplex v1.9.0/go.mod h1:7TyrDT6BCdI8/38Uvp0/ZxBslOslP2X2MPDucliyvSE= +cloud.google.com/go/dataplex v1.9.1/go.mod h1:7TyrDT6BCdI8/38Uvp0/ZxBslOslP2X2MPDucliyvSE= +cloud.google.com/go/dataplex v1.10.1/go.mod h1:1MzmBv8FvjYfc7vDdxhnLFNskikkB+3vl475/XdCDhs= +cloud.google.com/go/dataplex v1.10.2/go.mod h1:xdC8URdTrCrZMW6keY779ZT1cTOfV8KEPNsw+LTRT1Y= +cloud.google.com/go/dataplex v1.11.1/go.mod h1:mHJYQQ2VEJHsyoC0OdNyy988DvEbPhqFs5OOLffLX0c= +cloud.google.com/go/dataplex v1.11.2/go.mod h1:mHJYQQ2VEJHsyoC0OdNyy988DvEbPhqFs5OOLffLX0c= cloud.google.com/go/dataproc v1.7.0/go.mod h1:CKAlMjII9H90RXaMpSxQ8EU6dQx6iAYNPcYPOkSbi8s= cloud.google.com/go/dataproc v1.8.0/go.mod h1:5OW+zNAH0pMpw14JVrPONsxMQYMBqJuzORhIBfBn9uI= cloud.google.com/go/dataproc v1.12.0/go.mod h1:zrF3aX0uV3ikkMz6z4uBbIKyhRITnxvr4i3IjKsKrw4= +cloud.google.com/go/dataproc/v2 v2.0.1/go.mod h1:7Ez3KRHdFGcfY7GcevBbvozX+zyWGcwLJvvAMwCaoZ4= +cloud.google.com/go/dataproc/v2 v2.2.0/go.mod h1:lZR7AQtwZPvmINx5J87DSOOpTfof9LVZju6/Qo4lmcY= +cloud.google.com/go/dataproc/v2 v2.2.1/go.mod h1:QdAJLaBjh+l4PVlVZcmrmhGccosY/omC1qwfQ61Zv/o= +cloud.google.com/go/dataproc/v2 v2.2.2/go.mod h1:aocQywVmQVF4i8CL740rNI/ZRpsaaC1Wh2++BJ7HEJ4= +cloud.google.com/go/dataproc/v2 v2.2.3/go.mod h1:G5R6GBc9r36SXv/RtZIVfB8SipI+xVn0bX5SxUzVYbY= +cloud.google.com/go/dataproc/v2 v2.3.0/go.mod h1:G5R6GBc9r36SXv/RtZIVfB8SipI+xVn0bX5SxUzVYbY= cloud.google.com/go/dataqna v0.5.0/go.mod h1:90Hyk596ft3zUQ8NkFfvICSIfHFh1Bc7C4cK3vbhkeo= cloud.google.com/go/dataqna v0.6.0/go.mod h1:1lqNpM7rqNLVgWBJyk5NF6Uen2PHym0jtVJonplVsDA= cloud.google.com/go/dataqna v0.7.0/go.mod h1:Lx9OcIIeqCrw1a6KdO3/5KMP1wAmTc0slZWwP12Qq3c= +cloud.google.com/go/dataqna v0.8.1/go.mod h1:zxZM0Bl6liMePWsHA8RMGAfmTG34vJMapbHAxQ5+WA8= +cloud.google.com/go/dataqna v0.8.2/go.mod h1:KNEqgx8TTmUipnQsScOoDpq/VlXVptUqVMZnt30WAPs= +cloud.google.com/go/dataqna v0.8.3/go.mod h1:wXNBW2uvc9e7Gl5k8adyAMnLush1KVV6lZUhB+rqNu4= +cloud.google.com/go/dataqna v0.8.4/go.mod h1:mySRKjKg5Lz784P6sCov3p1QD+RZQONRMRjzGNcFd0c= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/datastore v1.10.0/go.mod h1:PC5UzAmDEkAmkfaknstTYbNpgE49HAgW2J1gcgUfmdM= +cloud.google.com/go/datastore v1.11.0/go.mod h1:TvGxBIHCS50u8jzG+AW/ppf87v1of8nwzFNgEZU1D3c= +cloud.google.com/go/datastore v1.12.0/go.mod h1:KjdB88W897MRITkvWWJrg2OUtrR5XVj1EoLgSp6/N70= +cloud.google.com/go/datastore v1.12.1/go.mod h1:KjdB88W897MRITkvWWJrg2OUtrR5XVj1EoLgSp6/N70= +cloud.google.com/go/datastore v1.13.0/go.mod h1:KjdB88W897MRITkvWWJrg2OUtrR5XVj1EoLgSp6/N70= +cloud.google.com/go/datastore v1.14.0/go.mod h1:GAeStMBIt9bPS7jMJA85kgkpsMkvseWWXiaHya9Jes8= +cloud.google.com/go/datastore v1.15.0/go.mod h1:GAeStMBIt9bPS7jMJA85kgkpsMkvseWWXiaHya9Jes8= cloud.google.com/go/datastream v1.2.0/go.mod h1:i/uTP8/fZwgATHS/XFu0TcNUhuA0twZxxQ3EyCUQMwo= cloud.google.com/go/datastream v1.3.0/go.mod h1:cqlOX8xlyYF/uxhiKn6Hbv6WjwPPuI9W2M9SAXwaLLQ= cloud.google.com/go/datastream v1.4.0/go.mod h1:h9dpzScPhDTs5noEMQVWP8Wx8AFBRyS0s8KWPx/9r0g= cloud.google.com/go/datastream v1.5.0/go.mod h1:6TZMMNPwjUqZHBKPQ1wwXpb0d5VDVPl2/XoS5yi88q4= cloud.google.com/go/datastream v1.6.0/go.mod h1:6LQSuswqLa7S4rPAOZFVjHIG3wJIjZcZrw8JDEDJuIs= +cloud.google.com/go/datastream v1.7.0/go.mod h1:uxVRMm2elUSPuh65IbZpzJNMbuzkcvu5CjMqVIUHrww= +cloud.google.com/go/datastream v1.9.1/go.mod h1:hqnmr8kdUBmrnk65k5wNRoHSCYksvpdZIcZIEl8h43Q= +cloud.google.com/go/datastream v1.10.0/go.mod h1:hqnmr8kdUBmrnk65k5wNRoHSCYksvpdZIcZIEl8h43Q= +cloud.google.com/go/datastream v1.10.1/go.mod h1:7ngSYwnw95YFyTd5tOGBxHlOZiL+OtpjheqU7t2/s/c= +cloud.google.com/go/datastream v1.10.2/go.mod h1:W42TFgKAs/om6x/CdXX5E4oiAsKlH+e8MTGy81zdYt0= +cloud.google.com/go/datastream v1.10.3/go.mod h1:YR0USzgjhqA/Id0Ycu1VvZe8hEWwrkjuXrGbzeDOSEA= cloud.google.com/go/deploy v1.4.0/go.mod h1:5Xghikd4VrmMLNaF6FiRFDlHb59VM59YoDQnOUdsH/c= cloud.google.com/go/deploy v1.5.0/go.mod h1:ffgdD0B89tToyW/U/D2eL0jN2+IEV/3EMuXHA0l4r+s= cloud.google.com/go/deploy v1.6.0/go.mod h1:f9PTHehG/DjCom3QH0cntOVRm93uGBDt2vKzAPwpXQI= +cloud.google.com/go/deploy v1.8.0/go.mod h1:z3myEJnA/2wnB4sgjqdMfgxCA0EqC3RBTNcVPs93mtQ= +cloud.google.com/go/deploy v1.11.0/go.mod h1:tKuSUV5pXbn67KiubiUNUejqLs4f5cxxiCNCeyl0F2g= +cloud.google.com/go/deploy v1.13.0/go.mod h1:tKuSUV5pXbn67KiubiUNUejqLs4f5cxxiCNCeyl0F2g= +cloud.google.com/go/deploy v1.13.1/go.mod h1:8jeadyLkH9qu9xgO3hVWw8jVr29N1mnW42gRJT8GY6g= +cloud.google.com/go/deploy v1.14.1/go.mod h1:N8S0b+aIHSEeSr5ORVoC0+/mOPUysVt8ae4QkZYolAw= +cloud.google.com/go/deploy v1.14.2/go.mod h1:e5XOUI5D+YGldyLNZ21wbp9S8otJbBE4i88PtO9x/2g= +cloud.google.com/go/deploy v1.15.0/go.mod h1:e5XOUI5D+YGldyLNZ21wbp9S8otJbBE4i88PtO9x/2g= cloud.google.com/go/dialogflow v1.15.0/go.mod h1:HbHDWs33WOGJgn6rfzBW1Kv807BE3O1+xGbn59zZWI4= cloud.google.com/go/dialogflow v1.16.1/go.mod h1:po6LlzGfK+smoSmTBnbkIZY2w8ffjz/RcGSS+sh1el0= cloud.google.com/go/dialogflow v1.17.0/go.mod h1:YNP09C/kXA1aZdBgC/VtXX74G/TKn7XVCcVumTflA+8= @@ -221,58 +462,138 @@ cloud.google.com/go/dialogflow v1.18.0/go.mod h1:trO7Zu5YdyEuR+BhSNOqJezyFQ3aUzz cloud.google.com/go/dialogflow v1.19.0/go.mod h1:JVmlG1TwykZDtxtTXujec4tQ+D8SBFMoosgy+6Gn0s0= cloud.google.com/go/dialogflow v1.29.0/go.mod h1:b+2bzMe+k1s9V+F2jbJwpHPzrnIyHihAdRFMtn2WXuM= cloud.google.com/go/dialogflow v1.31.0/go.mod h1:cuoUccuL1Z+HADhyIA7dci3N5zUssgpBJmCzI6fNRB4= +cloud.google.com/go/dialogflow v1.32.0/go.mod h1:jG9TRJl8CKrDhMEcvfcfFkkpp8ZhgPz3sBGmAUYJ2qE= +cloud.google.com/go/dialogflow v1.38.0/go.mod h1:L7jnH+JL2mtmdChzAIcXQHXMvQkE3U4hTaNltEuxXn4= +cloud.google.com/go/dialogflow v1.40.0/go.mod h1:L7jnH+JL2mtmdChzAIcXQHXMvQkE3U4hTaNltEuxXn4= +cloud.google.com/go/dialogflow v1.43.0/go.mod h1:pDUJdi4elL0MFmt1REMvFkdsUTYSHq+rTCS8wg0S3+M= +cloud.google.com/go/dialogflow v1.44.0/go.mod h1:pDUJdi4elL0MFmt1REMvFkdsUTYSHq+rTCS8wg0S3+M= +cloud.google.com/go/dialogflow v1.44.1/go.mod h1:n/h+/N2ouKOO+rbe/ZnI186xImpqvCVj2DdsWS/0EAk= +cloud.google.com/go/dialogflow v1.44.2/go.mod h1:QzFYndeJhpVPElnFkUXxdlptx0wPnBWLCBT9BvtC3/c= +cloud.google.com/go/dialogflow v1.44.3/go.mod h1:mHly4vU7cPXVweuB5R0zsYKPMzy240aQdAu06SqBbAQ= cloud.google.com/go/dlp v1.6.0/go.mod h1:9eyB2xIhpU0sVwUixfBubDoRwP+GjeUoxxeueZmqvmM= cloud.google.com/go/dlp v1.7.0/go.mod h1:68ak9vCiMBjbasxeVD17hVPxDEck+ExiHavX8kiHG+Q= cloud.google.com/go/dlp v1.9.0/go.mod h1:qdgmqgTyReTz5/YNSSuueR8pl7hO0o9bQ39ZhtgkWp4= +cloud.google.com/go/dlp v1.10.1/go.mod h1:IM8BWz1iJd8njcNcG0+Kyd9OPnqnRNkDV8j42VT5KOI= +cloud.google.com/go/dlp v1.10.2/go.mod h1:ZbdKIhcnyhILgccwVDzkwqybthh7+MplGC3kZVZsIOQ= +cloud.google.com/go/dlp v1.10.3/go.mod h1:iUaTc/ln8I+QT6Ai5vmuwfw8fqTk2kaz0FvCwhLCom0= +cloud.google.com/go/dlp v1.11.1/go.mod h1:/PA2EnioBeXTL/0hInwgj0rfsQb3lpE3R8XUJxqUNKI= cloud.google.com/go/documentai v1.7.0/go.mod h1:lJvftZB5NRiFSX4moiye1SMxHx0Bc3x1+p9e/RfXYiU= cloud.google.com/go/documentai v1.8.0/go.mod h1:xGHNEB7CtsnySCNrCFdCyyMz44RhFEEX2Q7UD0c5IhU= cloud.google.com/go/documentai v1.9.0/go.mod h1:FS5485S8R00U10GhgBC0aNGrJxBP8ZVpEeJ7PQDZd6k= cloud.google.com/go/documentai v1.10.0/go.mod h1:vod47hKQIPeCfN2QS/jULIvQTugbmdc0ZvxxfQY1bg4= cloud.google.com/go/documentai v1.16.0/go.mod h1:o0o0DLTEZ+YnJZ+J4wNfTxmDVyrkzFvttBXXtYRMHkM= +cloud.google.com/go/documentai v1.18.0/go.mod h1:F6CK6iUH8J81FehpskRmhLq/3VlwQvb7TvwOceQ2tbs= +cloud.google.com/go/documentai v1.20.0/go.mod h1:yJkInoMcK0qNAEdRnqY/D5asy73tnPe88I1YTZT+a8E= +cloud.google.com/go/documentai v1.22.0/go.mod h1:yJkInoMcK0qNAEdRnqY/D5asy73tnPe88I1YTZT+a8E= +cloud.google.com/go/documentai v1.22.1/go.mod h1:LKs22aDHbJv7ufXuPypzRO7rG3ALLJxzdCXDPutw4Qc= +cloud.google.com/go/documentai v1.23.0/go.mod h1:LKs22aDHbJv7ufXuPypzRO7rG3ALLJxzdCXDPutw4Qc= +cloud.google.com/go/documentai v1.23.2/go.mod h1:Q/wcRT+qnuXOpjAkvOV4A+IeQl04q2/ReT7SSbytLSo= +cloud.google.com/go/documentai v1.23.4/go.mod h1:4MYAaEMnADPN1LPN5xboDR5QVB6AgsaxgFdJhitlE2Y= +cloud.google.com/go/documentai v1.23.5/go.mod h1:ghzBsyVTiVdkfKaUCum/9bGBEyBjDO4GfooEcYKhN+g= cloud.google.com/go/domains v0.6.0/go.mod h1:T9Rz3GasrpYk6mEGHh4rymIhjlnIuB4ofT1wTxDeT4Y= cloud.google.com/go/domains v0.7.0/go.mod h1:PtZeqS1xjnXuRPKE/88Iru/LdfoRyEHYA9nFQf4UKpg= cloud.google.com/go/domains v0.8.0/go.mod h1:M9i3MMDzGFXsydri9/vW+EWz9sWb4I6WyHqdlAk0idE= +cloud.google.com/go/domains v0.9.1/go.mod h1:aOp1c0MbejQQ2Pjf1iJvnVyT+z6R6s8pX66KaCSDYfE= +cloud.google.com/go/domains v0.9.2/go.mod h1:3YvXGYzZG1Temjbk7EyGCuGGiXHJwVNmwIf+E/cUp5I= +cloud.google.com/go/domains v0.9.3/go.mod h1:29k66YNDLDY9LCFKpGFeh6Nj9r62ZKm5EsUJxAl84KU= +cloud.google.com/go/domains v0.9.4/go.mod h1:27jmJGShuXYdUNjyDG0SodTfT5RwLi7xmH334Gvi3fY= cloud.google.com/go/edgecontainer v0.1.0/go.mod h1:WgkZ9tp10bFxqO8BLPqv2LlfmQF1X8lZqwW4r1BTajk= cloud.google.com/go/edgecontainer v0.2.0/go.mod h1:RTmLijy+lGpQ7BXuTDa4C4ssxyXT34NIuHIgKuP4s5w= cloud.google.com/go/edgecontainer v0.3.0/go.mod h1:FLDpP4nykgwwIfcLt6zInhprzw0lEi2P1fjO6Ie0qbc= +cloud.google.com/go/edgecontainer v1.0.0/go.mod h1:cttArqZpBB2q58W/upSG++ooo6EsblxDIolxa3jSjbY= +cloud.google.com/go/edgecontainer v1.1.1/go.mod h1:O5bYcS//7MELQZs3+7mabRqoWQhXCzenBu0R8bz2rwk= +cloud.google.com/go/edgecontainer v1.1.2/go.mod h1:wQRjIzqxEs9e9wrtle4hQPSR1Y51kqN75dgF7UllZZ4= +cloud.google.com/go/edgecontainer v1.1.3/go.mod h1:Ll2DtIABzEfaxaVSbwj3QHFaOOovlDFiWVDu349jSsA= +cloud.google.com/go/edgecontainer v1.1.4/go.mod h1:AvFdVuZuVGdgaE5YvlL1faAoa1ndRR/5XhXZvPBHbsE= cloud.google.com/go/errorreporting v0.3.0/go.mod h1:xsP2yaAp+OAW4OIm60An2bbLpqIhKXdWR/tawvl7QzU= cloud.google.com/go/essentialcontacts v1.3.0/go.mod h1:r+OnHa5jfj90qIfZDO/VztSFqbQan7HV75p8sA+mdGI= cloud.google.com/go/essentialcontacts v1.4.0/go.mod h1:8tRldvHYsmnBCHdFpvU+GL75oWiBKl80BiqlFh9tp+8= cloud.google.com/go/essentialcontacts v1.5.0/go.mod h1:ay29Z4zODTuwliK7SnX8E86aUF2CTzdNtvv42niCX0M= +cloud.google.com/go/essentialcontacts v1.6.2/go.mod h1:T2tB6tX+TRak7i88Fb2N9Ok3PvY3UNbUsMag9/BARh4= +cloud.google.com/go/essentialcontacts v1.6.3/go.mod h1:yiPCD7f2TkP82oJEFXFTou8Jl8L6LBRPeBEkTaO0Ggo= +cloud.google.com/go/essentialcontacts v1.6.4/go.mod h1:iju5Vy3d9tJUg0PYMd1nHhjV7xoCXaOAVabrwLaPBEM= +cloud.google.com/go/essentialcontacts v1.6.5/go.mod h1:jjYbPzw0x+yglXC890l6ECJWdYeZ5dlYACTFL0U/VuM= cloud.google.com/go/eventarc v1.7.0/go.mod h1:6ctpF3zTnaQCxUjHUdcfgcA1A2T309+omHZth7gDfmc= cloud.google.com/go/eventarc v1.8.0/go.mod h1:imbzxkyAU4ubfsaKYdQg04WS1NvncblHEup4kvF+4gw= cloud.google.com/go/eventarc v1.10.0/go.mod h1:u3R35tmZ9HvswGRBnF48IlYgYeBcPUCjkr4BTdem2Kw= +cloud.google.com/go/eventarc v1.11.0/go.mod h1:PyUjsUKPWoRBCHeOxZd/lbOOjahV41icXyUY5kSTvVY= +cloud.google.com/go/eventarc v1.12.1/go.mod h1:mAFCW6lukH5+IZjkvrEss+jmt2kOdYlN8aMx3sRJiAI= +cloud.google.com/go/eventarc v1.13.0/go.mod h1:mAFCW6lukH5+IZjkvrEss+jmt2kOdYlN8aMx3sRJiAI= +cloud.google.com/go/eventarc v1.13.1/go.mod h1:EqBxmGHFrruIara4FUQ3RHlgfCn7yo1HYsu2Hpt/C3Y= +cloud.google.com/go/eventarc v1.13.2/go.mod h1:X9A80ShVu19fb4e5sc/OLV7mpFUKZMwfJFeeWhcIObM= +cloud.google.com/go/eventarc v1.13.3/go.mod h1:RWH10IAZIRcj1s/vClXkBgMHwh59ts7hSWcqD3kaclg= cloud.google.com/go/filestore v1.3.0/go.mod h1:+qbvHGvXU1HaKX2nD0WEPo92TP/8AQuCVEBXNY9z0+w= cloud.google.com/go/filestore v1.4.0/go.mod h1:PaG5oDfo9r224f8OYXURtAsY+Fbyq/bLYoINEK8XQAI= cloud.google.com/go/filestore v1.5.0/go.mod h1:FqBXDWBp4YLHqRnVGveOkHDf8svj9r5+mUDLupOWEDs= +cloud.google.com/go/filestore v1.6.0/go.mod h1:di5unNuss/qfZTw2U9nhFqo8/ZDSc466dre85Kydllg= +cloud.google.com/go/filestore v1.7.1/go.mod h1:y10jsorq40JJnjR/lQ8AfFbbcGlw3g+Dp8oN7i7FjV4= +cloud.google.com/go/filestore v1.7.2/go.mod h1:TYOlyJs25f/omgj+vY7/tIG/E7BX369triSPzE4LdgE= +cloud.google.com/go/filestore v1.7.3/go.mod h1:Qp8WaEERR3cSkxToxFPHh/b8AACkSut+4qlCjAmKTV0= +cloud.google.com/go/filestore v1.7.4/go.mod h1:S5JCxIbFjeBhWMTfIYH2Jx24J6BqjwpkkPl+nBA5DlI= +cloud.google.com/go/filestore v1.8.0/go.mod h1:S5JCxIbFjeBhWMTfIYH2Jx24J6BqjwpkkPl+nBA5DlI= cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE= +cloud.google.com/go/firestore v1.11.0/go.mod h1:b38dKhgzlmNNGTNZZwe7ZRFEuRab1Hay3/DBsIGKKy4= +cloud.google.com/go/firestore v1.12.0/go.mod h1:b38dKhgzlmNNGTNZZwe7ZRFEuRab1Hay3/DBsIGKKy4= +cloud.google.com/go/firestore v1.13.0/go.mod h1:QojqqOh8IntInDUSTAh0c8ZsPYAr68Ma8c5DWOy8xb8= +cloud.google.com/go/firestore v1.14.0/go.mod h1:96MVaHLsEhbvkBEdZgfN+AS/GIkco1LRpH9Xp9YZfzQ= cloud.google.com/go/functions v1.6.0/go.mod h1:3H1UA3qiIPRWD7PeZKLvHZ9SaQhR26XIJcC0A5GbvAk= cloud.google.com/go/functions v1.7.0/go.mod h1:+d+QBcWM+RsrgZfV9xo6KfA1GlzJfxcfZcRPEhDDfzg= cloud.google.com/go/functions v1.8.0/go.mod h1:RTZ4/HsQjIqIYP9a9YPbU+QFoQsAlYgrwOXJWHn1POY= cloud.google.com/go/functions v1.9.0/go.mod h1:Y+Dz8yGguzO3PpIjhLTbnqV1CWmgQ5UwtlpzoyquQ08= cloud.google.com/go/functions v1.10.0/go.mod h1:0D3hEOe3DbEvCXtYOZHQZmD+SzYsi1YbI7dGvHfldXw= +cloud.google.com/go/functions v1.12.0/go.mod h1:AXWGrF3e2C/5ehvwYo/GH6O5s09tOPksiKhz+hH8WkA= +cloud.google.com/go/functions v1.13.0/go.mod h1:EU4O007sQm6Ef/PwRsI8N2umygGqPBS/IZQKBQBcJ3c= +cloud.google.com/go/functions v1.15.1/go.mod h1:P5yNWUTkyU+LvW/S9O6V+V423VZooALQlqoXdoPz5AE= +cloud.google.com/go/functions v1.15.2/go.mod h1:CHAjtcR6OU4XF2HuiVeriEdELNcnvRZSk1Q8RMqy4lE= +cloud.google.com/go/functions v1.15.3/go.mod h1:r/AMHwBheapkkySEhiZYLDBwVJCdlRwsm4ieJu35/Ug= +cloud.google.com/go/functions v1.15.4/go.mod h1:CAsTc3VlRMVvx+XqXxKqVevguqJpnVip4DdonFsX28I= cloud.google.com/go/gaming v1.5.0/go.mod h1:ol7rGcxP/qHTRQE/RO4bxkXq+Fix0j6D4LFPzYTIrDM= cloud.google.com/go/gaming v1.6.0/go.mod h1:YMU1GEvA39Qt3zWGyAVA9bpYz/yAhTvaQ1t2sK4KPUA= cloud.google.com/go/gaming v1.7.0/go.mod h1:LrB8U7MHdGgFG851iHAfqUdLcKBdQ55hzXy9xBJz0+w= cloud.google.com/go/gaming v1.8.0/go.mod h1:xAqjS8b7jAVW0KFYeRUxngo9My3f33kFmua++Pi+ggM= cloud.google.com/go/gaming v1.9.0/go.mod h1:Fc7kEmCObylSWLO334NcO+O9QMDyz+TKC4v1D7X+Bc0= +cloud.google.com/go/gaming v1.10.1/go.mod h1:XQQvtfP8Rb9Rxnxm5wFVpAp9zCQkJi2bLIb7iHGwB3s= cloud.google.com/go/gkebackup v0.2.0/go.mod h1:XKvv/4LfG829/B8B7xRkk8zRrOEbKtEam6yNfuQNH60= cloud.google.com/go/gkebackup v0.3.0/go.mod h1:n/E671i1aOQvUxT541aTkCwExO/bTer2HDlj4TsBRAo= cloud.google.com/go/gkebackup v0.4.0/go.mod h1:byAyBGUwYGEEww7xsbnUTBHIYcOPy/PgUWUtOeRm9Vg= +cloud.google.com/go/gkebackup v1.3.0/go.mod h1:vUDOu++N0U5qs4IhG1pcOnD1Mac79xWy6GoBFlWCWBU= +cloud.google.com/go/gkebackup v1.3.1/go.mod h1:vUDOu++N0U5qs4IhG1pcOnD1Mac79xWy6GoBFlWCWBU= +cloud.google.com/go/gkebackup v1.3.2/go.mod h1:OMZbXzEJloyXMC7gqdSB+EOEQ1AKcpGYvO3s1ec5ixk= +cloud.google.com/go/gkebackup v1.3.3/go.mod h1:eMk7/wVV5P22KBakhQnJxWSVftL1p4VBFLpv0kIft7I= +cloud.google.com/go/gkebackup v1.3.4/go.mod h1:gLVlbM8h/nHIs09ns1qx3q3eaXcGSELgNu1DWXYz1HI= cloud.google.com/go/gkeconnect v0.5.0/go.mod h1:c5lsNAg5EwAy7fkqX/+goqFsU1Da/jQFqArp+wGNr/o= cloud.google.com/go/gkeconnect v0.6.0/go.mod h1:Mln67KyU/sHJEBY8kFZ0xTeyPtzbq9StAVvEULYK16A= cloud.google.com/go/gkeconnect v0.7.0/go.mod h1:SNfmVqPkaEi3bF/B3CNZOAYPYdg7sU+obZ+QTky2Myw= +cloud.google.com/go/gkeconnect v0.8.1/go.mod h1:KWiK1g9sDLZqhxB2xEuPV8V9NYzrqTUmQR9shJHpOZw= +cloud.google.com/go/gkeconnect v0.8.2/go.mod h1:6nAVhwchBJYgQCXD2pHBFQNiJNyAd/wyxljpaa6ZPrY= +cloud.google.com/go/gkeconnect v0.8.3/go.mod h1:i9GDTrfzBSUZGCe98qSu1B8YB8qfapT57PenIb820Jo= +cloud.google.com/go/gkeconnect v0.8.4/go.mod h1:84hZz4UMlDCKl8ifVW8layK4WHlMAFeq8vbzjU0yJkw= cloud.google.com/go/gkehub v0.9.0/go.mod h1:WYHN6WG8w9bXU0hqNxt8rm5uxnk8IH+lPY9J2TV7BK0= cloud.google.com/go/gkehub v0.10.0/go.mod h1:UIPwxI0DsrpsVoWpLB0stwKCP+WFVG9+y977wO+hBH0= cloud.google.com/go/gkehub v0.11.0/go.mod h1:JOWHlmN+GHyIbuWQPl47/C2RFhnFKH38jH9Ascu3n0E= +cloud.google.com/go/gkehub v0.12.0/go.mod h1:djiIwwzTTBrF5NaXCGv3mf7klpEMcST17VBTVVDcuaw= +cloud.google.com/go/gkehub v0.14.1/go.mod h1:VEXKIJZ2avzrbd7u+zeMtW00Y8ddk/4V9511C9CQGTY= +cloud.google.com/go/gkehub v0.14.2/go.mod h1:iyjYH23XzAxSdhrbmfoQdePnlMj2EWcvnR+tHdBQsCY= +cloud.google.com/go/gkehub v0.14.3/go.mod h1:jAl6WafkHHW18qgq7kqcrXYzN08hXeK/Va3utN8VKg8= +cloud.google.com/go/gkehub v0.14.4/go.mod h1:Xispfu2MqnnFt8rV/2/3o73SK1snL8s9dYJ9G2oQMfc= cloud.google.com/go/gkemulticloud v0.3.0/go.mod h1:7orzy7O0S+5kq95e4Hpn7RysVA7dPs8W/GgfUtsPbrA= cloud.google.com/go/gkemulticloud v0.4.0/go.mod h1:E9gxVBnseLWCk24ch+P9+B2CoDFJZTyIgLKSalC7tuI= cloud.google.com/go/gkemulticloud v0.5.0/go.mod h1:W0JDkiyi3Tqh0TJr//y19wyb1yf8llHVto2Htf2Ja3Y= +cloud.google.com/go/gkemulticloud v0.6.1/go.mod h1:kbZ3HKyTsiwqKX7Yw56+wUGwwNZViRnxWK2DVknXWfw= +cloud.google.com/go/gkemulticloud v1.0.0/go.mod h1:kbZ3HKyTsiwqKX7Yw56+wUGwwNZViRnxWK2DVknXWfw= +cloud.google.com/go/gkemulticloud v1.0.1/go.mod h1:AcrGoin6VLKT/fwZEYuqvVominLriQBCKmbjtnbMjG8= +cloud.google.com/go/gkemulticloud v1.0.2/go.mod h1:+ee5VXxKb3H1l4LZAcgWB/rvI16VTNTrInWxDjAGsGo= +cloud.google.com/go/gkemulticloud v1.0.3/go.mod h1:7NpJBN94U6DY1xHIbsDqB2+TFZUfjLUKLjUX8NGLor0= cloud.google.com/go/grafeas v0.2.0/go.mod h1:KhxgtF2hb0P191HlY5besjYm6MqTSTj3LSI+M+ByZHc= +cloud.google.com/go/grafeas v0.3.0/go.mod h1:P7hgN24EyONOTMyeJH6DxG4zD7fwiYa5Q6GUgyFSOU8= cloud.google.com/go/gsuiteaddons v1.3.0/go.mod h1:EUNK/J1lZEZO8yPtykKxLXI6JSVN2rg9bN8SXOa0bgM= cloud.google.com/go/gsuiteaddons v1.4.0/go.mod h1:rZK5I8hht7u7HxFQcFei0+AtfS9uSushomRlg+3ua1o= cloud.google.com/go/gsuiteaddons v1.5.0/go.mod h1:TFCClYLd64Eaa12sFVmUyG62tk4mdIsI7pAnSXRkcFo= +cloud.google.com/go/gsuiteaddons v1.6.1/go.mod h1:CodrdOqRZcLp5WOwejHWYBjZvfY0kOphkAKpF/3qdZY= +cloud.google.com/go/gsuiteaddons v1.6.2/go.mod h1:K65m9XSgs8hTF3X9nNTPi8IQueljSdYo9F+Mi+s4MyU= +cloud.google.com/go/gsuiteaddons v1.6.3/go.mod h1:sCFJkZoMrLZT3JTb8uJqgKPNshH2tfXeCwTFRebTq48= +cloud.google.com/go/gsuiteaddons v1.6.4/go.mod h1:rxtstw7Fx22uLOXBpsvb9DUbC+fiXs7rF4U29KHM/pE= cloud.google.com/go/iam v0.1.0/go.mod h1:vcUNEa0pEm0qRVpmWepWaFMIAI8/hjB9mO8rNCJtF6c= cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw= cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= @@ -282,104 +603,245 @@ cloud.google.com/go/iam v0.7.0/go.mod h1:H5Br8wRaDGNc8XP3keLc4unfUUZeyH3Sfl9XpQE cloud.google.com/go/iam v0.8.0/go.mod h1:lga0/y3iH6CX7sYqypWJ33hf7kkfXJag67naqGESjkE= cloud.google.com/go/iam v0.11.0/go.mod h1:9PiLDanza5D+oWFZiH1uG+RnRCfEGKoyl6yo4cgWZGY= cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB2GYAY= -cloud.google.com/go/iam v1.1.2 h1:gacbrBdWcoVmGLozRuStX45YKvJtzIjJdAolzUs1sm4= +cloud.google.com/go/iam v0.13.0/go.mod h1:ljOg+rcNfzZ5d6f1nAUJ8ZIxOaZUVoS14bKCtaLZ/D0= +cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8= +cloud.google.com/go/iam v1.1.0/go.mod h1:nxdHjaKfCr7fNYx/HJMM8LgiMugmveWlkatear5gVyk= +cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU= cloud.google.com/go/iam v1.1.2/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU= +cloud.google.com/go/iam v1.1.3/go.mod h1:3khUlaBXfPKKe7huYgEpDn6FtgRyMEqbkvBxrQyY5SE= +cloud.google.com/go/iam v1.1.4/go.mod h1:l/rg8l1AaA+VFMho/HYx2Vv6xinPSLMF8qfhRPIZ0L8= +cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI= +cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8= cloud.google.com/go/iap v1.4.0/go.mod h1:RGFwRJdihTINIe4wZ2iCP0zF/qu18ZwyKxrhMhygBEc= cloud.google.com/go/iap v1.5.0/go.mod h1:UH/CGgKd4KyohZL5Pt0jSKE4m3FR51qg6FKQ/z/Ix9A= cloud.google.com/go/iap v1.6.0/go.mod h1:NSuvI9C/j7UdjGjIde7t7HBz+QTwBcapPE07+sSRcLk= +cloud.google.com/go/iap v1.7.0/go.mod h1:beqQx56T9O1G1yNPph+spKpNibDlYIiIixiqsQXxLIo= +cloud.google.com/go/iap v1.7.1/go.mod h1:WapEwPc7ZxGt2jFGB/C/bm+hP0Y6NXzOYGjpPnmMS74= +cloud.google.com/go/iap v1.8.1/go.mod h1:sJCbeqg3mvWLqjZNsI6dfAtbbV1DL2Rl7e1mTyXYREQ= +cloud.google.com/go/iap v1.9.0/go.mod h1:01OFxd1R+NFrg78S+hoPV5PxEzv22HXaNqUUlmNHFuY= +cloud.google.com/go/iap v1.9.1/go.mod h1:SIAkY7cGMLohLSdBR25BuIxO+I4fXJiL06IBL7cy/5Q= +cloud.google.com/go/iap v1.9.2/go.mod h1:GwDTOs047PPSnwRD0Us5FKf4WDRcVvHg1q9WVkKBhdI= +cloud.google.com/go/iap v1.9.3/go.mod h1:DTdutSZBqkkOm2HEOTBzhZxh2mwwxshfD/h3yofAiCw= cloud.google.com/go/ids v1.1.0/go.mod h1:WIuwCaYVOzHIj2OhN9HAwvW+DBdmUAdcWlFxRl+KubM= cloud.google.com/go/ids v1.2.0/go.mod h1:5WXvp4n25S0rA/mQWAg1YEEBBq6/s+7ml1RDCW1IrcY= cloud.google.com/go/ids v1.3.0/go.mod h1:JBdTYwANikFKaDP6LtW5JAi4gubs57SVNQjemdt6xV4= +cloud.google.com/go/ids v1.4.1/go.mod h1:np41ed8YMU8zOgv53MMMoCntLTn2lF+SUzlM+O3u/jw= +cloud.google.com/go/ids v1.4.2/go.mod h1:3vw8DX6YddRu9BncxuzMyWn0g8+ooUjI2gslJ7FH3vk= +cloud.google.com/go/ids v1.4.3/go.mod h1:9CXPqI3GedjmkjbMWCUhMZ2P2N7TUMzAkVXYEH2orYU= +cloud.google.com/go/ids v1.4.4/go.mod h1:z+WUc2eEl6S/1aZWzwtVNWoSZslgzPxAboS0lZX0HjI= cloud.google.com/go/iot v1.3.0/go.mod h1:r7RGh2B61+B8oz0AGE+J72AhA0G7tdXItODWsaA2oLs= cloud.google.com/go/iot v1.4.0/go.mod h1:dIDxPOn0UvNDUMD8Ger7FIaTuvMkj+aGk94RPP0iV+g= cloud.google.com/go/iot v1.5.0/go.mod h1:mpz5259PDl3XJthEmh9+ap0affn/MqNSP4My77Qql9o= +cloud.google.com/go/iot v1.6.0/go.mod h1:IqdAsmE2cTYYNO1Fvjfzo9po179rAtJeVGUvkLN3rLE= +cloud.google.com/go/iot v1.7.1/go.mod h1:46Mgw7ev1k9KqK1ao0ayW9h0lI+3hxeanz+L1zmbbbk= +cloud.google.com/go/iot v1.7.2/go.mod h1:q+0P5zr1wRFpw7/MOgDXrG/HVA+l+cSwdObffkrpnSg= +cloud.google.com/go/iot v1.7.3/go.mod h1:t8itFchkol4VgNbHnIq9lXoOOtHNR3uAACQMYbN9N4I= +cloud.google.com/go/iot v1.7.4/go.mod h1:3TWqDVvsddYBG++nHSZmluoCAVGr1hAcabbWZNKEZLk= cloud.google.com/go/kms v1.1.0/go.mod h1:WdbppnCDMDpOvoYBMn1+gNmOeEoZYqAv+HeuKARGCXI= cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxsnnOA= cloud.google.com/go/kms v1.5.0/go.mod h1:QJS2YY0eJGBg3mnDfuaCyLauWwBJiHRboYxJ++1xJNg= cloud.google.com/go/kms v1.6.0/go.mod h1:Jjy850yySiasBUDi6KFUwUv2n1+o7QZFyuUJg6OgjA0= +cloud.google.com/go/kms v1.8.0/go.mod h1:4xFEhYFqvW+4VMELtZyxomGSYtSQKzM178ylFW4jMAg= cloud.google.com/go/kms v1.9.0/go.mod h1:qb1tPTgfF9RQP8e1wq4cLFErVuTJv7UsSC915J8dh3w= -cloud.google.com/go/kms v1.15.2 h1:lh6qra6oC4AyWe5fUUUBe/S27k12OHAleOOOw6KakdE= +cloud.google.com/go/kms v1.10.0/go.mod h1:ng3KTUtQQU9bPX3+QGLsflZIHlkbn8amFAMY63m8d24= +cloud.google.com/go/kms v1.10.1/go.mod h1:rIWk/TryCkR59GMC3YtHtXeLzd634lBbKenvyySAyYI= +cloud.google.com/go/kms v1.11.0/go.mod h1:hwdiYC0xjnWsKQQCQQmIQnS9asjYVSK6jtXm+zFqXLM= +cloud.google.com/go/kms v1.12.1/go.mod h1:c9J991h5DTl+kg7gi3MYomh12YEENGrf48ee/N/2CDM= +cloud.google.com/go/kms v1.15.0/go.mod h1:c9J991h5DTl+kg7gi3MYomh12YEENGrf48ee/N/2CDM= cloud.google.com/go/kms v1.15.2/go.mod h1:3hopT4+7ooWRCjc2DxgnpESFxhIraaI2IpAVUEhbT/w= +cloud.google.com/go/kms v1.15.3/go.mod h1:AJdXqHxS2GlPyduM99s9iGqi2nwbviBbhV/hdmt4iOQ= +cloud.google.com/go/kms v1.15.4/go.mod h1:L3Sdj6QTHK8dfwK5D1JLsAyELsNMnd3tAIwGS4ltKpc= +cloud.google.com/go/kms v1.15.5 h1:pj1sRfut2eRbD9pFRjNnPNg/CzJPuQAzUujMIM1vVeM= +cloud.google.com/go/kms v1.15.5/go.mod h1:cU2H5jnp6G2TDpUGZyqTCoy1n16fbubHZjmVXSMtwDI= cloud.google.com/go/language v1.4.0/go.mod h1:F9dRpNFQmJbkaop6g0JhSBXCNlO90e1KWx5iDdxbWic= cloud.google.com/go/language v1.6.0/go.mod h1:6dJ8t3B+lUYfStgls25GusK04NLh3eDLQnWM3mdEbhI= cloud.google.com/go/language v1.7.0/go.mod h1:DJ6dYN/W+SQOjF8e1hLQXMF21AkH2w9wiPzPCJa2MIE= cloud.google.com/go/language v1.8.0/go.mod h1:qYPVHf7SPoNNiCL2Dr0FfEFNil1qi3pQEyygwpgVKB8= cloud.google.com/go/language v1.9.0/go.mod h1:Ns15WooPM5Ad/5no/0n81yUetis74g3zrbeJBE+ptUY= +cloud.google.com/go/language v1.10.1/go.mod h1:CPp94nsdVNiQEt1CNjF5WkTcisLiHPyIbMhvR8H2AW0= +cloud.google.com/go/language v1.11.0/go.mod h1:uDx+pFDdAKTY8ehpWbiXyQdz8tDSYLJbQcXsCkjYyvQ= +cloud.google.com/go/language v1.11.1/go.mod h1:Xyid9MG9WOX3utvDbpX7j3tXDmmDooMyMDqgUVpH17U= +cloud.google.com/go/language v1.12.1/go.mod h1:zQhalE2QlQIxbKIZt54IASBzmZpN/aDASea5zl1l+J4= +cloud.google.com/go/language v1.12.2/go.mod h1:9idWapzr/JKXBBQ4lWqVX/hcadxB194ry20m/bTrhWc= cloud.google.com/go/lifesciences v0.5.0/go.mod h1:3oIKy8ycWGPUyZDR/8RNnTOYevhaMLqh5vLUXs9zvT8= cloud.google.com/go/lifesciences v0.6.0/go.mod h1:ddj6tSX/7BOnhxCSd3ZcETvtNr8NZ6t/iPhY2Tyfu08= cloud.google.com/go/lifesciences v0.8.0/go.mod h1:lFxiEOMqII6XggGbOnKiyZ7IBwoIqA84ClvoezaA/bo= +cloud.google.com/go/lifesciences v0.9.1/go.mod h1:hACAOd1fFbCGLr/+weUKRAJas82Y4vrL3O5326N//Wc= +cloud.google.com/go/lifesciences v0.9.2/go.mod h1:QHEOO4tDzcSAzeJg7s2qwnLM2ji8IRpQl4p6m5Z9yTA= +cloud.google.com/go/lifesciences v0.9.3/go.mod h1:gNGBOJV80IWZdkd+xz4GQj4mbqaz737SCLHn2aRhQKM= +cloud.google.com/go/lifesciences v0.9.4/go.mod h1:bhm64duKhMi7s9jR9WYJYvjAFJwRqNj+Nia7hF0Z7JA= cloud.google.com/go/logging v1.6.1/go.mod h1:5ZO0mHHbvm8gEmeEUHrmDlTDSu5imF6MUP9OfilNXBw= cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M= +cloud.google.com/go/logging v1.8.1/go.mod h1:TJjR+SimHwuC8MZ9cjByQulAMgni+RkXeI3wwctHJEI= cloud.google.com/go/longrunning v0.1.1/go.mod h1:UUFxuDWkv22EuY93jjmDMFT5GPQKeFVJBIF6QlTqdsE= cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc= cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo= +cloud.google.com/go/longrunning v0.4.2/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ= +cloud.google.com/go/longrunning v0.5.0/go.mod h1:0JNuqRShmscVAhIACGtskSAWtqtOoPkwP0YF1oVEchc= +cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc= +cloud.google.com/go/longrunning v0.5.2/go.mod h1:nqo6DQbNV2pXhGDbDMoN2bWz68MjZUzqv2YttZiveCs= +cloud.google.com/go/longrunning v0.5.3/go.mod h1:y/0ga59EYu58J6SHmmQOvekvND2qODbu8ywBBW7EK7Y= +cloud.google.com/go/longrunning v0.5.4/go.mod h1:zqNVncI0BOP8ST6XQD1+VcvuShMmq7+xFSzOL++V0dI= cloud.google.com/go/managedidentities v1.3.0/go.mod h1:UzlW3cBOiPrzucO5qWkNkh0w33KFtBJU281hacNvsdE= cloud.google.com/go/managedidentities v1.4.0/go.mod h1:NWSBYbEMgqmbZsLIyKvxrYbtqOsxY1ZrGM+9RgDqInM= cloud.google.com/go/managedidentities v1.5.0/go.mod h1:+dWcZ0JlUmpuxpIDfyP5pP5y0bLdRwOS4Lp7gMni/LA= +cloud.google.com/go/managedidentities v1.6.1/go.mod h1:h/irGhTN2SkZ64F43tfGPMbHnypMbu4RB3yl8YcuEak= +cloud.google.com/go/managedidentities v1.6.2/go.mod h1:5c2VG66eCa0WIq6IylRk3TBW83l161zkFvCj28X7jn8= +cloud.google.com/go/managedidentities v1.6.3/go.mod h1:tewiat9WLyFN0Fi7q1fDD5+0N4VUoL0SCX0OTCthZq4= +cloud.google.com/go/managedidentities v1.6.4/go.mod h1:WgyaECfHmF00t/1Uk8Oun3CQ2PGUtjc3e9Alh79wyiM= cloud.google.com/go/maps v0.1.0/go.mod h1:BQM97WGyfw9FWEmQMpZ5T6cpovXXSd1cGmFma94eubI= cloud.google.com/go/maps v0.6.0/go.mod h1:o6DAMMfb+aINHz/p/jbcY+mYeXBoZoxTfdSQ8VAJaCw= +cloud.google.com/go/maps v0.7.0/go.mod h1:3GnvVl3cqeSvgMcpRlQidXsPYuDGQ8naBis7MVzpXsY= +cloud.google.com/go/maps v1.3.0/go.mod h1:6mWTUv+WhnOwAgjVsSW2QPPECmW+s3PcRyOa9vgG/5s= +cloud.google.com/go/maps v1.4.0/go.mod h1:6mWTUv+WhnOwAgjVsSW2QPPECmW+s3PcRyOa9vgG/5s= +cloud.google.com/go/maps v1.4.1/go.mod h1:BxSa0BnW1g2U2gNdbq5zikLlHUuHW0GFWh7sgML2kIY= +cloud.google.com/go/maps v1.5.1/go.mod h1:NPMZw1LJwQZYCfz4y+EIw+SI+24A4bpdFJqdKVr0lt4= +cloud.google.com/go/maps v1.6.1/go.mod h1:4+buOHhYXFBp58Zj/K+Lc1rCmJssxxF4pJ5CJnhdz18= cloud.google.com/go/mediatranslation v0.5.0/go.mod h1:jGPUhGTybqsPQn91pNXw0xVHfuJ3leR1wj37oU3y1f4= cloud.google.com/go/mediatranslation v0.6.0/go.mod h1:hHdBCTYNigsBxshbznuIMFNe5QXEowAuNmmC7h8pu5w= cloud.google.com/go/mediatranslation v0.7.0/go.mod h1:LCnB/gZr90ONOIQLgSXagp8XUW1ODs2UmUMvcgMfI2I= +cloud.google.com/go/mediatranslation v0.8.1/go.mod h1:L/7hBdEYbYHQJhX2sldtTO5SZZ1C1vkapubj0T2aGig= +cloud.google.com/go/mediatranslation v0.8.2/go.mod h1:c9pUaDRLkgHRx3irYE5ZC8tfXGrMYwNZdmDqKMSfFp8= +cloud.google.com/go/mediatranslation v0.8.3/go.mod h1:F9OnXTy336rteOEywtY7FOqCk+J43o2RF638hkOQl4Y= +cloud.google.com/go/mediatranslation v0.8.4/go.mod h1:9WstgtNVAdN53m6TQa5GjIjLqKQPXe74hwSCxUP6nj4= cloud.google.com/go/memcache v1.4.0/go.mod h1:rTOfiGZtJX1AaFUrOgsMHX5kAzaTQ8azHiuDoTPzNsE= cloud.google.com/go/memcache v1.5.0/go.mod h1:dk3fCK7dVo0cUU2c36jKb4VqKPS22BTkf81Xq617aWM= cloud.google.com/go/memcache v1.6.0/go.mod h1:XS5xB0eQZdHtTuTF9Hf8eJkKtR3pVRCcvJwtm68T3rA= cloud.google.com/go/memcache v1.7.0/go.mod h1:ywMKfjWhNtkQTxrWxCkCFkoPjLHPW6A7WOTVI8xy3LY= cloud.google.com/go/memcache v1.9.0/go.mod h1:8oEyzXCu+zo9RzlEaEjHl4KkgjlNDaXbCQeQWlzNFJM= +cloud.google.com/go/memcache v1.10.1/go.mod h1:47YRQIarv4I3QS5+hoETgKO40InqzLP6kpNLvyXuyaA= +cloud.google.com/go/memcache v1.10.2/go.mod h1:f9ZzJHLBrmd4BkguIAa/l/Vle6uTHzHokdnzSWOdQ6A= +cloud.google.com/go/memcache v1.10.3/go.mod h1:6z89A41MT2DVAW0P4iIRdu5cmRTsbsFn4cyiIx8gbwo= +cloud.google.com/go/memcache v1.10.4/go.mod h1:v/d8PuC8d1gD6Yn5+I3INzLR01IDn0N4Ym56RgikSI0= cloud.google.com/go/metastore v1.5.0/go.mod h1:2ZNrDcQwghfdtCwJ33nM0+GrBGlVuh8rakL3vdPY3XY= cloud.google.com/go/metastore v1.6.0/go.mod h1:6cyQTls8CWXzk45G55x57DVQ9gWg7RiH65+YgPsNh9s= cloud.google.com/go/metastore v1.7.0/go.mod h1:s45D0B4IlsINu87/AsWiEVYbLaIMeUSoxlKKDqBGFS8= cloud.google.com/go/metastore v1.8.0/go.mod h1:zHiMc4ZUpBiM7twCIFQmJ9JMEkDSyZS9U12uf7wHqSI= cloud.google.com/go/metastore v1.10.0/go.mod h1:fPEnH3g4JJAk+gMRnrAnoqyv2lpUCqJPWOodSaf45Eo= +cloud.google.com/go/metastore v1.11.1/go.mod h1:uZuSo80U3Wd4zi6C22ZZliOUJ3XeM/MlYi/z5OAOWRA= +cloud.google.com/go/metastore v1.12.0/go.mod h1:uZuSo80U3Wd4zi6C22ZZliOUJ3XeM/MlYi/z5OAOWRA= +cloud.google.com/go/metastore v1.13.0/go.mod h1:URDhpG6XLeh5K+Glq0NOt74OfrPKTwS62gEPZzb5SOk= +cloud.google.com/go/metastore v1.13.1/go.mod h1:IbF62JLxuZmhItCppcIfzBBfUFq0DIB9HPDoLgWrVOU= +cloud.google.com/go/metastore v1.13.2/go.mod h1:KS59dD+unBji/kFebVp8XU/quNSyo8b6N6tPGspKszA= +cloud.google.com/go/metastore v1.13.3/go.mod h1:K+wdjXdtkdk7AQg4+sXS8bRrQa9gcOr+foOMF2tqINE= cloud.google.com/go/monitoring v1.1.0/go.mod h1:L81pzz7HKn14QCMaCs6NTQkdBnE87TElyanS95vIcl4= cloud.google.com/go/monitoring v1.4.0/go.mod h1:y6xnxfwI3hTFWOdkOaD7nfJVlwuC3/mS/5kvtT131p4= cloud.google.com/go/monitoring v1.7.0/go.mod h1:HpYse6kkGo//7p6sT0wsIC6IBDET0RhIsnmlA53dvEk= cloud.google.com/go/monitoring v1.8.0/go.mod h1:E7PtoMJ1kQXWxPjB6mv2fhC5/15jInuulFdYYtlcvT4= cloud.google.com/go/monitoring v1.12.0/go.mod h1:yx8Jj2fZNEkL/GYZyTLS4ZtZEZN8WtDEiEqG4kLK50w= +cloud.google.com/go/monitoring v1.13.0/go.mod h1:k2yMBAB1H9JT/QETjNkgdCGD9bPF712XiLTVr+cBrpw= +cloud.google.com/go/monitoring v1.15.1/go.mod h1:lADlSAlFdbqQuwwpaImhsJXu1QSdd3ojypXrFSMr2rM= +cloud.google.com/go/monitoring v1.16.0/go.mod h1:Ptp15HgAyM1fNICAojDMoNc/wUmn67mLHQfyqbw+poY= +cloud.google.com/go/monitoring v1.16.1/go.mod h1:6HsxddR+3y9j+o/cMJH6q/KJ/CBTvM/38L/1m7bTRJ4= +cloud.google.com/go/monitoring v1.16.2/go.mod h1:B44KGwi4ZCF8Rk/5n+FWeispDXoKSk9oss2QNlXJBgc= +cloud.google.com/go/monitoring v1.16.3/go.mod h1:KwSsX5+8PnXv5NJnICZzW2R8pWTis8ypC4zmdRD63Tw= cloud.google.com/go/networkconnectivity v1.4.0/go.mod h1:nOl7YL8odKyAOtzNX73/M5/mGZgqqMeryi6UPZTk/rA= cloud.google.com/go/networkconnectivity v1.5.0/go.mod h1:3GzqJx7uhtlM3kln0+x5wyFvuVH1pIBJjhCpjzSt75o= cloud.google.com/go/networkconnectivity v1.6.0/go.mod h1:OJOoEXW+0LAxHh89nXd64uGG+FbQoeH8DtxCHVOMlaM= cloud.google.com/go/networkconnectivity v1.7.0/go.mod h1:RMuSbkdbPwNMQjB5HBWD5MpTBnNm39iAVpC3TmsExt8= cloud.google.com/go/networkconnectivity v1.10.0/go.mod h1:UP4O4sWXJG13AqrTdQCD9TnLGEbtNRqjuaaA7bNjF5E= +cloud.google.com/go/networkconnectivity v1.11.0/go.mod h1:iWmDD4QF16VCDLXUqvyspJjIEtBR/4zq5hwnY2X3scM= +cloud.google.com/go/networkconnectivity v1.12.1/go.mod h1:PelxSWYM7Sh9/guf8CFhi6vIqf19Ir/sbfZRUwXh92E= +cloud.google.com/go/networkconnectivity v1.13.0/go.mod h1:SAnGPes88pl7QRLUen2HmcBSE9AowVAcdug8c0RSBFk= +cloud.google.com/go/networkconnectivity v1.14.0/go.mod h1:SAnGPes88pl7QRLUen2HmcBSE9AowVAcdug8c0RSBFk= +cloud.google.com/go/networkconnectivity v1.14.1/go.mod h1:LyGPXR742uQcDxZ/wv4EI0Vu5N6NKJ77ZYVnDe69Zug= +cloud.google.com/go/networkconnectivity v1.14.2/go.mod h1:5UFlwIisZylSkGG1AdwK/WZUaoz12PKu6wODwIbFzJo= +cloud.google.com/go/networkconnectivity v1.14.3/go.mod h1:4aoeFdrJpYEXNvrnfyD5kIzs8YtHg945Og4koAjHQek= cloud.google.com/go/networkmanagement v1.4.0/go.mod h1:Q9mdLLRn60AsOrPc8rs8iNV6OHXaGcDdsIQe1ohekq8= cloud.google.com/go/networkmanagement v1.5.0/go.mod h1:ZnOeZ/evzUdUsnvRt792H0uYEnHQEMaz+REhhzJRcf4= cloud.google.com/go/networkmanagement v1.6.0/go.mod h1:5pKPqyXjB/sgtvB5xqOemumoQNB7y95Q7S+4rjSOPYY= +cloud.google.com/go/networkmanagement v1.8.0/go.mod h1:Ho/BUGmtyEqrttTgWEe7m+8vDdK74ibQc+Be0q7Fof0= +cloud.google.com/go/networkmanagement v1.9.0/go.mod h1:UTUaEU9YwbCAhhz3jEOHr+2/K/MrBk2XxOLS89LQzFw= +cloud.google.com/go/networkmanagement v1.9.1/go.mod h1:CCSYgrQQvW73EJawO2QamemYcOb57LvrDdDU51F0mcI= +cloud.google.com/go/networkmanagement v1.9.2/go.mod h1:iDGvGzAoYRghhp4j2Cji7sF899GnfGQcQRQwgVOWnDw= +cloud.google.com/go/networkmanagement v1.9.3/go.mod h1:y7WMO1bRLaP5h3Obm4tey+NquUvB93Co1oh4wpL+XcU= cloud.google.com/go/networksecurity v0.5.0/go.mod h1:xS6fOCoqpVC5zx15Z/MqkfDwH4+m/61A3ODiDV1xmiQ= cloud.google.com/go/networksecurity v0.6.0/go.mod h1:Q5fjhTr9WMI5mbpRYEbiexTzROf7ZbDzvzCrNl14nyU= cloud.google.com/go/networksecurity v0.7.0/go.mod h1:mAnzoxx/8TBSyXEeESMy9OOYwo1v+gZ5eMRnsT5bC8k= +cloud.google.com/go/networksecurity v0.8.0/go.mod h1:B78DkqsxFG5zRSVuwYFRZ9Xz8IcQ5iECsNrPn74hKHU= +cloud.google.com/go/networksecurity v0.9.1/go.mod h1:MCMdxOKQ30wsBI1eI659f9kEp4wuuAueoC9AJKSPWZQ= +cloud.google.com/go/networksecurity v0.9.2/go.mod h1:jG0SeAttWzPMUILEHDUvFYdQTl8L/E/KC8iZDj85lEI= +cloud.google.com/go/networksecurity v0.9.3/go.mod h1:l+C0ynM6P+KV9YjOnx+kk5IZqMSLccdBqW6GUoF4p/0= +cloud.google.com/go/networksecurity v0.9.4/go.mod h1:E9CeMZ2zDsNBkr8axKSYm8XyTqNhiCHf1JO/Vb8mD1w= cloud.google.com/go/notebooks v1.2.0/go.mod h1:9+wtppMfVPUeJ8fIWPOq1UnATHISkGXGqTkxeieQ6UY= cloud.google.com/go/notebooks v1.3.0/go.mod h1:bFR5lj07DtCPC7YAAJ//vHskFBxA5JzYlH68kXVdk34= cloud.google.com/go/notebooks v1.4.0/go.mod h1:4QPMngcwmgb6uw7Po99B2xv5ufVoIQ7nOGDyL4P8AgA= cloud.google.com/go/notebooks v1.5.0/go.mod h1:q8mwhnP9aR8Hpfnrc5iN5IBhrXUy8S2vuYs+kBJ/gu0= cloud.google.com/go/notebooks v1.7.0/go.mod h1:PVlaDGfJgj1fl1S3dUwhFMXFgfYGhYQt2164xOMONmE= +cloud.google.com/go/notebooks v1.8.0/go.mod h1:Lq6dYKOYOWUCTvw5t2q1gp1lAp0zxAxRycayS0iJcqQ= +cloud.google.com/go/notebooks v1.9.1/go.mod h1:zqG9/gk05JrzgBt4ghLzEepPHNwE5jgPcHZRKhlC1A8= +cloud.google.com/go/notebooks v1.10.0/go.mod h1:SOPYMZnttHxqot0SGSFSkRrwE29eqnKPBJFqgWmiK2k= +cloud.google.com/go/notebooks v1.10.1/go.mod h1:5PdJc2SgAybE76kFQCWrTfJolCOUQXF97e+gteUUA6A= +cloud.google.com/go/notebooks v1.11.1/go.mod h1:V2Zkv8wX9kDCGRJqYoI+bQAaoVeE5kSiz4yYHd2yJwQ= +cloud.google.com/go/notebooks v1.11.2/go.mod h1:z0tlHI/lREXC8BS2mIsUeR3agM1AkgLiS+Isov3SS70= cloud.google.com/go/optimization v1.1.0/go.mod h1:5po+wfvX5AQlPznyVEZjGJTMr4+CAkJf2XSTQOOl9l4= cloud.google.com/go/optimization v1.2.0/go.mod h1:Lr7SOHdRDENsh+WXVmQhQTrzdu9ybg0NecjHidBq6xs= cloud.google.com/go/optimization v1.3.1/go.mod h1:IvUSefKiwd1a5p0RgHDbWCIbDFgKuEdB+fPPuP0IDLI= +cloud.google.com/go/optimization v1.4.1/go.mod h1:j64vZQP7h9bO49m2rVaTVoNM0vEBEN5eKPUPbZyXOrk= +cloud.google.com/go/optimization v1.5.0/go.mod h1:evo1OvTxeBRBu6ydPlrIRizKY/LJKo/drDMMRKqGEUU= +cloud.google.com/go/optimization v1.5.1/go.mod h1:NC0gnUD5MWVAF7XLdoYVPmYYVth93Q6BUzqAq3ZwtV8= +cloud.google.com/go/optimization v1.6.1/go.mod h1:hH2RYPTTM9e9zOiTaYPTiGPcGdNZVnBSBxjIAJzUkqo= +cloud.google.com/go/optimization v1.6.2/go.mod h1:mWNZ7B9/EyMCcwNl1frUGEuY6CPijSkz88Fz2vwKPOY= cloud.google.com/go/orchestration v1.3.0/go.mod h1:Sj5tq/JpWiB//X/q3Ngwdl5K7B7Y0KZ7bfv0wL6fqVA= cloud.google.com/go/orchestration v1.4.0/go.mod h1:6W5NLFWs2TlniBphAViZEVhrXRSMgUGDfW7vrWKvsBk= cloud.google.com/go/orchestration v1.6.0/go.mod h1:M62Bevp7pkxStDfFfTuCOaXgaaqRAga1yKyoMtEoWPQ= +cloud.google.com/go/orchestration v1.8.1/go.mod h1:4sluRF3wgbYVRqz7zJ1/EUNc90TTprliq9477fGobD8= +cloud.google.com/go/orchestration v1.8.2/go.mod h1:T1cP+6WyTmh6LSZzeUhvGf0uZVmJyTx7t8z7Vg87+A0= +cloud.google.com/go/orchestration v1.8.3/go.mod h1:xhgWAYqlbYjlz2ftbFghdyqENYW+JXuhBx9KsjMoGHs= +cloud.google.com/go/orchestration v1.8.4/go.mod h1:d0lywZSVYtIoSZXb0iFjv9SaL13PGyVOKDxqGxEf/qI= cloud.google.com/go/orgpolicy v1.4.0/go.mod h1:xrSLIV4RePWmP9P3tBl8S93lTmlAxjm06NSm2UTmKvE= cloud.google.com/go/orgpolicy v1.5.0/go.mod h1:hZEc5q3wzwXJaKrsx5+Ewg0u1LxJ51nNFlext7Tanwc= cloud.google.com/go/orgpolicy v1.10.0/go.mod h1:w1fo8b7rRqlXlIJbVhOMPrwVljyuW5mqssvBtU18ONc= +cloud.google.com/go/orgpolicy v1.11.0/go.mod h1:2RK748+FtVvnfuynxBzdnyu7sygtoZa1za/0ZfpOs1M= +cloud.google.com/go/orgpolicy v1.11.1/go.mod h1:8+E3jQcpZJQliP+zaFfayC2Pg5bmhuLK755wKhIIUCE= +cloud.google.com/go/orgpolicy v1.11.2/go.mod h1:biRDpNwfyytYnmCRWZWxrKF22Nkz9eNVj9zyaBdpm1o= +cloud.google.com/go/orgpolicy v1.11.3/go.mod h1:oKAtJ/gkMjum5icv2aujkP4CxROxPXsBbYGCDbPO8MM= +cloud.google.com/go/orgpolicy v1.11.4/go.mod h1:0+aNV/nrfoTQ4Mytv+Aw+stBDBjNf4d8fYRA9herfJI= cloud.google.com/go/osconfig v1.7.0/go.mod h1:oVHeCeZELfJP7XLxcBGTMBvRO+1nQ5tFG9VQTmYS2Fs= cloud.google.com/go/osconfig v1.8.0/go.mod h1:EQqZLu5w5XA7eKizepumcvWx+m8mJUhEwiPqWiZeEdg= cloud.google.com/go/osconfig v1.9.0/go.mod h1:Yx+IeIZJ3bdWmzbQU4fxNl8xsZ4amB+dygAwFPlvnNo= cloud.google.com/go/osconfig v1.10.0/go.mod h1:uMhCzqC5I8zfD9zDEAfvgVhDS8oIjySWh+l4WK6GnWw= cloud.google.com/go/osconfig v1.11.0/go.mod h1:aDICxrur2ogRd9zY5ytBLV89KEgT2MKB2L/n6x1ooPw= +cloud.google.com/go/osconfig v1.12.0/go.mod h1:8f/PaYzoS3JMVfdfTubkowZYGmAhUCjjwnjqWI7NVBc= +cloud.google.com/go/osconfig v1.12.1/go.mod h1:4CjBxND0gswz2gfYRCUoUzCm9zCABp91EeTtWXyz0tE= +cloud.google.com/go/osconfig v1.12.2/go.mod h1:eh9GPaMZpI6mEJEuhEjUJmaxvQ3gav+fFEJon1Y8Iw0= +cloud.google.com/go/osconfig v1.12.3/go.mod h1:L/fPS8LL6bEYUi1au832WtMnPeQNT94Zo3FwwV1/xGM= +cloud.google.com/go/osconfig v1.12.4/go.mod h1:B1qEwJ/jzqSRslvdOCI8Kdnp0gSng0xW4LOnIebQomA= cloud.google.com/go/oslogin v1.4.0/go.mod h1:YdgMXWRaElXz/lDk1Na6Fh5orF7gvmJ0FGLIs9LId4E= cloud.google.com/go/oslogin v1.5.0/go.mod h1:D260Qj11W2qx/HVF29zBg+0fd6YCSjSqLUkY/qEenQU= cloud.google.com/go/oslogin v1.6.0/go.mod h1:zOJ1O3+dTU8WPlGEkFSh7qeHPPSoxrcMbbK1Nm2iX70= cloud.google.com/go/oslogin v1.7.0/go.mod h1:e04SN0xO1UNJ1M5GP0vzVBFicIe4O53FOfcixIqTyXo= cloud.google.com/go/oslogin v1.9.0/go.mod h1:HNavntnH8nzrn8JCTT5fj18FuJLFJc4NaZJtBnQtKFs= +cloud.google.com/go/oslogin v1.10.1/go.mod h1:x692z7yAue5nE7CsSnoG0aaMbNoRJRXO4sn73R+ZqAs= +cloud.google.com/go/oslogin v1.11.0/go.mod h1:8GMTJs4X2nOAUVJiPGqIWVcDaF0eniEto3xlOxaboXE= +cloud.google.com/go/oslogin v1.11.1/go.mod h1:OhD2icArCVNUxKqtK0mcSmKL7lgr0LVlQz+v9s1ujTg= +cloud.google.com/go/oslogin v1.12.1/go.mod h1:VfwTeFJGbnakxAY236eN8fsnglLiVXndlbcNomY4iZU= +cloud.google.com/go/oslogin v1.12.2/go.mod h1:CQ3V8Jvw4Qo4WRhNPF0o+HAM4DiLuE27Ul9CX9g2QdY= cloud.google.com/go/phishingprotection v0.5.0/go.mod h1:Y3HZknsK9bc9dMi+oE8Bim0lczMU6hrX0UpADuMefr0= cloud.google.com/go/phishingprotection v0.6.0/go.mod h1:9Y3LBLgy0kDTcYET8ZH3bq/7qni15yVUoAxiFxnlSUA= cloud.google.com/go/phishingprotection v0.7.0/go.mod h1:8qJI4QKHoda/sb/7/YmMQ2omRLSLYSu9bU0EKCNI+Lk= +cloud.google.com/go/phishingprotection v0.8.1/go.mod h1:AxonW7GovcA8qdEk13NfHq9hNx5KPtfxXNeUxTDxB6I= +cloud.google.com/go/phishingprotection v0.8.2/go.mod h1:LhJ91uyVHEYKSKcMGhOa14zMMWfbEdxG032oT6ECbC8= +cloud.google.com/go/phishingprotection v0.8.3/go.mod h1:3B01yO7T2Ra/TMojifn8EoGd4G9jts/6cIO0DgDY9J8= +cloud.google.com/go/phishingprotection v0.8.4/go.mod h1:6b3kNPAc2AQ6jZfFHioZKg9MQNybDg4ixFd4RPZZ2nE= cloud.google.com/go/policytroubleshooter v1.3.0/go.mod h1:qy0+VwANja+kKrjlQuOzmlvscn4RNsAc0e15GGqfMxg= cloud.google.com/go/policytroubleshooter v1.4.0/go.mod h1:DZT4BcRw3QoO8ota9xw/LKtPa8lKeCByYeKTIf/vxdE= cloud.google.com/go/policytroubleshooter v1.5.0/go.mod h1:Rz1WfV+1oIpPdN2VvvuboLVRsB1Hclg3CKQ53j9l8vw= +cloud.google.com/go/policytroubleshooter v1.6.0/go.mod h1:zYqaPTsmfvpjm5ULxAyD/lINQxJ0DDsnWOP/GZ7xzBc= +cloud.google.com/go/policytroubleshooter v1.7.1/go.mod h1:0NaT5v3Ag1M7U5r0GfDCpUFkWd9YqpubBWsQlhanRv0= +cloud.google.com/go/policytroubleshooter v1.8.0/go.mod h1:tmn5Ir5EToWe384EuboTcVQT7nTag2+DuH3uHmKd1HU= +cloud.google.com/go/policytroubleshooter v1.9.0/go.mod h1:+E2Lga7TycpeSTj2FsH4oXxTnrbHJGRlKhVZBLGgU64= +cloud.google.com/go/policytroubleshooter v1.9.1/go.mod h1:MYI8i0bCrL8cW+VHN1PoiBTyNZTstCg2WUw2eVC4c4U= +cloud.google.com/go/policytroubleshooter v1.10.1/go.mod h1:5C0rhT3TDZVxAu8813bwmTvd57Phbl8mr9F4ipOsxEs= +cloud.google.com/go/policytroubleshooter v1.10.2/go.mod h1:m4uF3f6LseVEnMV6nknlN2vYGRb+75ylQwJdnOXfnv0= cloud.google.com/go/privatecatalog v0.5.0/go.mod h1:XgosMUvvPyxDjAVNDYxJ7wBW8//hLDDYmnsNcMGq1K0= cloud.google.com/go/privatecatalog v0.6.0/go.mod h1:i/fbkZR0hLN29eEWiiwue8Pb+GforiEIBnV9yrRUOKI= cloud.google.com/go/privatecatalog v0.7.0/go.mod h1:2s5ssIFO69F5csTXcwBP7NPFTZvps26xGzvQ2PQaBYg= +cloud.google.com/go/privatecatalog v0.8.0/go.mod h1:nQ6pfaegeDAq/Q5lrfCQzQLhubPiZhSaNhIgfJlnIXs= +cloud.google.com/go/privatecatalog v0.9.1/go.mod h1:0XlDXW2unJXdf9zFz968Hp35gl/bhF4twwpXZAW50JA= +cloud.google.com/go/privatecatalog v0.9.2/go.mod h1:RMA4ATa8IXfzvjrhhK8J6H4wwcztab+oZph3c6WmtFc= +cloud.google.com/go/privatecatalog v0.9.3/go.mod h1:K5pn2GrVmOPjXz3T26mzwXLcKivfIJ9R5N79AFCF9UE= +cloud.google.com/go/privatecatalog v0.9.4/go.mod h1:SOjm93f+5hp/U3PqMZAHTtBtluqLygrDrVO8X8tYtG0= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= @@ -388,8 +850,13 @@ cloud.google.com/go/pubsub v1.19.0/go.mod h1:/O9kmSe9bb9KRnIAWkzmqhPjHo6LtzGOBYd cloud.google.com/go/pubsub v1.26.0/go.mod h1:QgBH3U/jdJy/ftjPhTkyXNj543Tin1pRYcdcPRnFIRI= cloud.google.com/go/pubsub v1.27.1/go.mod h1:hQN39ymbV9geqBnfQq6Xf63yNhUAhv9CZhzp5O6qsW0= cloud.google.com/go/pubsub v1.28.0/go.mod h1:vuXFpwaVoIPQMGXqRyUQigu/AX1S3IWugR9xznmcXX8= +cloud.google.com/go/pubsub v1.30.0/go.mod h1:qWi1OPS0B+b5L+Sg6Gmc9zD1Y+HaM0MdUr7LsupY1P4= +cloud.google.com/go/pubsub v1.32.0/go.mod h1:f+w71I33OMyxf9VpMVcZbnG5KSUkCOUHYpFd5U1GdRc= +cloud.google.com/go/pubsub v1.33.0/go.mod h1:f+w71I33OMyxf9VpMVcZbnG5KSUkCOUHYpFd5U1GdRc= cloud.google.com/go/pubsublite v1.5.0/go.mod h1:xapqNQ1CuLfGi23Yda/9l4bBCKz/wC3KIJ5gKcxveZg= cloud.google.com/go/pubsublite v1.6.0/go.mod h1:1eFCS0U11xlOuMFV/0iBqw3zP12kddMeCbj/F3FSj9k= +cloud.google.com/go/pubsublite v1.7.0/go.mod h1:8hVMwRXfDfvGm3fahVbtDbiLePT3gpoiJYJY+vxWxVM= +cloud.google.com/go/pubsublite v1.8.1/go.mod h1:fOLdU4f5xldK4RGJrBMm+J7zMWNj/k4PxwEZXy39QS0= cloud.google.com/go/recaptchaenterprise v1.3.1/go.mod h1:OdD+q+y4XGeAlxRaMn1Y7/GveP6zmq76byL6tjPE7d4= cloud.google.com/go/recaptchaenterprise/v2 v2.1.0/go.mod h1:w9yVqajwroDNTfGuhmOjPDN//rZGySaf6PtFVcSCa7o= cloud.google.com/go/recaptchaenterprise/v2 v2.2.0/go.mod h1:/Zu5jisWGeERrd5HnlS3EUGb/D335f9k51B/FVil0jk= @@ -397,78 +864,165 @@ cloud.google.com/go/recaptchaenterprise/v2 v2.3.0/go.mod h1:O9LwGCjrhGHBQET5CA7d cloud.google.com/go/recaptchaenterprise/v2 v2.4.0/go.mod h1:Am3LHfOuBstrLrNCBrlI5sbwx9LBg3te2N6hGvHn2mE= cloud.google.com/go/recaptchaenterprise/v2 v2.5.0/go.mod h1:O8LzcHXN3rz0j+LBC91jrwI3R+1ZSZEWrfL7XHgNo9U= cloud.google.com/go/recaptchaenterprise/v2 v2.6.0/go.mod h1:RPauz9jeLtB3JVzg6nCbe12qNoaa8pXc4d/YukAmcnA= +cloud.google.com/go/recaptchaenterprise/v2 v2.7.0/go.mod h1:19wVj/fs5RtYtynAPJdDTb69oW0vNHYDBTbB4NvMD9c= +cloud.google.com/go/recaptchaenterprise/v2 v2.7.2/go.mod h1:kR0KjsJS7Jt1YSyWFkseQ756D45kaYNTlDPPaRAvDBU= +cloud.google.com/go/recaptchaenterprise/v2 v2.8.0/go.mod h1:QuE8EdU9dEnesG8/kG3XuJyNsjEqMlMzg3v3scCJ46c= +cloud.google.com/go/recaptchaenterprise/v2 v2.8.1/go.mod h1:JZYZJOeZjgSSTGP4uz7NlQ4/d1w5hGmksVgM0lbEij0= +cloud.google.com/go/recaptchaenterprise/v2 v2.8.2/go.mod h1:kpaDBOpkwD4G0GVMzG1W6Doy1tFFC97XAV3xy+Rd/pw= +cloud.google.com/go/recaptchaenterprise/v2 v2.8.3/go.mod h1:Dak54rw6lC2gBY8FBznpOCAR58wKf+R+ZSJRoeJok4w= +cloud.google.com/go/recaptchaenterprise/v2 v2.8.4/go.mod h1:Dak54rw6lC2gBY8FBznpOCAR58wKf+R+ZSJRoeJok4w= cloud.google.com/go/recommendationengine v0.5.0/go.mod h1:E5756pJcVFeVgaQv3WNpImkFP8a+RptV6dDLGPILjvg= cloud.google.com/go/recommendationengine v0.6.0/go.mod h1:08mq2umu9oIqc7tDy8sx+MNJdLG0fUi3vaSVbztHgJ4= cloud.google.com/go/recommendationengine v0.7.0/go.mod h1:1reUcE3GIu6MeBz/h5xZJqNLuuVjNg1lmWMPyjatzac= +cloud.google.com/go/recommendationengine v0.8.1/go.mod h1:MrZihWwtFYWDzE6Hz5nKcNz3gLizXVIDI/o3G1DLcrE= +cloud.google.com/go/recommendationengine v0.8.2/go.mod h1:QIybYHPK58qir9CV2ix/re/M//Ty10OxjnnhWdaKS1Y= +cloud.google.com/go/recommendationengine v0.8.3/go.mod h1:m3b0RZV02BnODE9FeSvGv1qibFo8g0OnmB/RMwYy4V8= +cloud.google.com/go/recommendationengine v0.8.4/go.mod h1:GEteCf1PATl5v5ZsQ60sTClUE0phbWmo3rQ1Js8louU= cloud.google.com/go/recommender v1.5.0/go.mod h1:jdoeiBIVrJe9gQjwd759ecLJbxCDED4A6p+mqoqDvTg= cloud.google.com/go/recommender v1.6.0/go.mod h1:+yETpm25mcoiECKh9DEScGzIRyDKpZ0cEhWGo+8bo+c= cloud.google.com/go/recommender v1.7.0/go.mod h1:XLHs/W+T8olwlGOgfQenXBTbIseGclClff6lhFVe9Bs= cloud.google.com/go/recommender v1.8.0/go.mod h1:PkjXrTT05BFKwxaUxQmtIlrtj0kph108r02ZZQ5FE70= cloud.google.com/go/recommender v1.9.0/go.mod h1:PnSsnZY7q+VL1uax2JWkt/UegHssxjUVVCrX52CuEmQ= +cloud.google.com/go/recommender v1.10.1/go.mod h1:XFvrE4Suqn5Cq0Lf+mCP6oBHD/yRMA8XxP5sb7Q7gpA= +cloud.google.com/go/recommender v1.11.0/go.mod h1:kPiRQhPyTJ9kyXPCG6u/dlPLbYfFlkwHNRwdzPVAoII= +cloud.google.com/go/recommender v1.11.1/go.mod h1:sGwFFAyI57v2Hc5LbIj+lTwXipGu9NW015rkaEM5B18= +cloud.google.com/go/recommender v1.11.2/go.mod h1:AeoJuzOvFR/emIcXdVFkspVXVTYpliRCmKNYDnyBv6Y= +cloud.google.com/go/recommender v1.11.3/go.mod h1:+FJosKKJSId1MBFeJ/TTyoGQZiEelQQIZMKYYD8ruK4= cloud.google.com/go/redis v1.7.0/go.mod h1:V3x5Jq1jzUcg+UNsRvdmsfuFnit1cfe3Z/PGyq/lm4Y= cloud.google.com/go/redis v1.8.0/go.mod h1:Fm2szCDavWzBk2cDKxrkmWBqoCiL1+Ctwq7EyqBCA/A= cloud.google.com/go/redis v1.9.0/go.mod h1:HMYQuajvb2D0LvMgZmLDZW8V5aOC/WxstZHiy4g8OiA= cloud.google.com/go/redis v1.10.0/go.mod h1:ThJf3mMBQtW18JzGgh41/Wld6vnDDc/F/F35UolRZPM= cloud.google.com/go/redis v1.11.0/go.mod h1:/X6eicana+BWcUda5PpwZC48o37SiFVTFSs0fWAJ7uQ= +cloud.google.com/go/redis v1.13.1/go.mod h1:VP7DGLpE91M6bcsDdMuyCm2hIpB6Vp2hI090Mfd1tcg= +cloud.google.com/go/redis v1.13.2/go.mod h1:0Hg7pCMXS9uz02q+LoEVl5dNHUkIQv+C/3L76fandSA= +cloud.google.com/go/redis v1.13.3/go.mod h1:vbUpCKUAZSYzFcWKmICnYgRAhTFg9r+djWqFxDYXi4U= +cloud.google.com/go/redis v1.14.1/go.mod h1:MbmBxN8bEnQI4doZPC1BzADU4HGocHBk2de3SbgOkqs= cloud.google.com/go/resourcemanager v1.3.0/go.mod h1:bAtrTjZQFJkiWTPDb1WBjzvc6/kifjj4QBYuKCCoqKA= cloud.google.com/go/resourcemanager v1.4.0/go.mod h1:MwxuzkumyTX7/a3n37gmsT3py7LIXwrShilPh3P1tR0= cloud.google.com/go/resourcemanager v1.5.0/go.mod h1:eQoXNAiAvCf5PXxWxXjhKQoTMaUSNrEfg+6qdf/wots= +cloud.google.com/go/resourcemanager v1.6.0/go.mod h1:YcpXGRs8fDzcUl1Xw8uOVmI8JEadvhRIkoXXUNVYcVo= +cloud.google.com/go/resourcemanager v1.7.0/go.mod h1:HlD3m6+bwhzj9XCouqmeiGuni95NTrExfhoSrkC/3EI= +cloud.google.com/go/resourcemanager v1.9.1/go.mod h1:dVCuosgrh1tINZ/RwBufr8lULmWGOkPS8gL5gqyjdT8= +cloud.google.com/go/resourcemanager v1.9.2/go.mod h1:OujkBg1UZg5lX2yIyMo5Vz9O5hf7XQOSV7WxqxxMtQE= +cloud.google.com/go/resourcemanager v1.9.3/go.mod h1:IqrY+g0ZgLsihcfcmqSe+RKp1hzjXwG904B92AwBz6U= +cloud.google.com/go/resourcemanager v1.9.4/go.mod h1:N1dhP9RFvo3lUfwtfLWVxfUWq8+KUQ+XLlHLH3BoFJ0= cloud.google.com/go/resourcesettings v1.3.0/go.mod h1:lzew8VfESA5DQ8gdlHwMrqZs1S9V87v3oCnKCWoOuQU= cloud.google.com/go/resourcesettings v1.4.0/go.mod h1:ldiH9IJpcrlC3VSuCGvjR5of/ezRrOxFtpJoJo5SmXg= cloud.google.com/go/resourcesettings v1.5.0/go.mod h1:+xJF7QSG6undsQDfsCJyqWXyBwUoJLhetkRMDRnIoXA= +cloud.google.com/go/resourcesettings v1.6.1/go.mod h1:M7mk9PIZrC5Fgsu1kZJci6mpgN8o0IUzVx3eJU3y4Jw= +cloud.google.com/go/resourcesettings v1.6.2/go.mod h1:mJIEDd9MobzunWMeniaMp6tzg4I2GvD3TTmPkc8vBXk= +cloud.google.com/go/resourcesettings v1.6.3/go.mod h1:pno5D+7oDYkMWZ5BpPsb4SO0ewg3IXcmmrUZaMJrFic= +cloud.google.com/go/resourcesettings v1.6.4/go.mod h1:pYTTkWdv2lmQcjsthbZLNBP4QW140cs7wqA3DuqErVI= cloud.google.com/go/retail v1.8.0/go.mod h1:QblKS8waDmNUhghY2TI9O3JLlFk8jybHeV4BF19FrE4= cloud.google.com/go/retail v1.9.0/go.mod h1:g6jb6mKuCS1QKnH/dpu7isX253absFl6iE92nHwlBUY= cloud.google.com/go/retail v1.10.0/go.mod h1:2gDk9HsL4HMS4oZwz6daui2/jmKvqShXKQuB2RZ+cCc= cloud.google.com/go/retail v1.11.0/go.mod h1:MBLk1NaWPmh6iVFSz9MeKG/Psyd7TAgm6y/9L2B4x9Y= cloud.google.com/go/retail v1.12.0/go.mod h1:UMkelN/0Z8XvKymXFbD4EhFJlYKRx1FGhQkVPU5kF14= +cloud.google.com/go/retail v1.14.1/go.mod h1:y3Wv3Vr2k54dLNIrCzenyKG8g8dhvhncT2NcNjb/6gE= +cloud.google.com/go/retail v1.14.2/go.mod h1:W7rrNRChAEChX336QF7bnMxbsjugcOCPU44i5kbLiL8= +cloud.google.com/go/retail v1.14.3/go.mod h1:Omz2akDHeSlfCq8ArPKiBxlnRpKEBjUH386JYFLUvXo= +cloud.google.com/go/retail v1.14.4/go.mod h1:l/N7cMtY78yRnJqp5JW8emy7MB1nz8E4t2yfOmklYfg= cloud.google.com/go/run v0.2.0/go.mod h1:CNtKsTA1sDcnqqIFR3Pb5Tq0usWxJJvsWOCPldRU3Do= cloud.google.com/go/run v0.3.0/go.mod h1:TuyY1+taHxTjrD0ZFk2iAR+xyOXEA0ztb7U3UNA0zBo= cloud.google.com/go/run v0.8.0/go.mod h1:VniEnuBwqjigv0A7ONfQUaEItaiCRVujlMqerPPiktM= +cloud.google.com/go/run v0.9.0/go.mod h1:Wwu+/vvg8Y+JUApMwEDfVfhetv30hCG4ZwDR/IXl2Qg= +cloud.google.com/go/run v1.2.0/go.mod h1:36V1IlDzQ0XxbQjUx6IYbw8H3TJnWvhii963WW3B/bo= +cloud.google.com/go/run v1.3.0/go.mod h1:S/osX/4jIPZGg+ssuqh6GNgg7syixKe3YnprwehzHKU= +cloud.google.com/go/run v1.3.1/go.mod h1:cymddtZOzdwLIAsmS6s+Asl4JoXIDm/K1cpZTxV4Q5s= +cloud.google.com/go/run v1.3.2/go.mod h1:SIhmqArbjdU/D9M6JoHaAqnAMKLFtXaVdNeq04NjnVE= +cloud.google.com/go/run v1.3.3/go.mod h1:WSM5pGyJ7cfYyYbONVQBN4buz42zFqwG67Q3ch07iK4= cloud.google.com/go/scheduler v1.4.0/go.mod h1:drcJBmxF3aqZJRhmkHQ9b3uSSpQoltBPGPxGAWROx6s= cloud.google.com/go/scheduler v1.5.0/go.mod h1:ri073ym49NW3AfT6DZi21vLZrG07GXr5p3H1KxN5QlI= cloud.google.com/go/scheduler v1.6.0/go.mod h1:SgeKVM7MIwPn3BqtcBntpLyrIJftQISRrYB5ZtT+KOk= cloud.google.com/go/scheduler v1.7.0/go.mod h1:jyCiBqWW956uBjjPMMuX09n3x37mtyPJegEWKxRsn44= cloud.google.com/go/scheduler v1.8.0/go.mod h1:TCET+Y5Gp1YgHT8py4nlg2Sew8nUHMqcpousDgXJVQc= +cloud.google.com/go/scheduler v1.9.0/go.mod h1:yexg5t+KSmqu+njTIh3b7oYPheFtBWGcbVUYF1GGMIc= +cloud.google.com/go/scheduler v1.10.1/go.mod h1:R63Ldltd47Bs4gnhQkmNDse5w8gBRrhObZ54PxgR2Oo= +cloud.google.com/go/scheduler v1.10.2/go.mod h1:O3jX6HRH5eKCA3FutMw375XHZJudNIKVonSCHv7ropY= +cloud.google.com/go/scheduler v1.10.3/go.mod h1:8ANskEM33+sIbpJ+R4xRfw/jzOG+ZFE8WVLy7/yGvbc= +cloud.google.com/go/scheduler v1.10.4/go.mod h1:MTuXcrJC9tqOHhixdbHDFSIuh7xZF2IysiINDuiq6NI= +cloud.google.com/go/scheduler v1.10.5/go.mod h1:MTuXcrJC9tqOHhixdbHDFSIuh7xZF2IysiINDuiq6NI= cloud.google.com/go/secretmanager v1.3.0/go.mod h1:+oLTkouyiYiabAQNugCeTS3PAArGiMJuBqvJnJsyH+U= cloud.google.com/go/secretmanager v1.6.0/go.mod h1:awVa/OXF6IiyaU1wQ34inzQNc4ISIDIrId8qE5QGgKA= cloud.google.com/go/secretmanager v1.8.0/go.mod h1:hnVgi/bN5MYHd3Gt0SPuTPPp5ENina1/LxM+2W9U9J4= cloud.google.com/go/secretmanager v1.9.0/go.mod h1:b71qH2l1yHmWQHt9LC80akm86mX8AL6X1MA01dW8ht4= cloud.google.com/go/secretmanager v1.10.0/go.mod h1:MfnrdvKMPNra9aZtQFvBcvRU54hbPD8/HayQdlUgJpU= +cloud.google.com/go/secretmanager v1.11.1/go.mod h1:znq9JlXgTNdBeQk9TBW/FnR/W4uChEKGeqQWAJ8SXFw= +cloud.google.com/go/secretmanager v1.11.2/go.mod h1:MQm4t3deoSub7+WNwiC4/tRYgDBHJgJPvswqQVB1Vss= +cloud.google.com/go/secretmanager v1.11.3/go.mod h1:0bA2o6FabmShrEy328i67aV+65XoUFFSmVeLBn/51jI= +cloud.google.com/go/secretmanager v1.11.4/go.mod h1:wreJlbS9Zdq21lMzWmJ0XhWW2ZxgPeahsqeV/vZoJ3w= cloud.google.com/go/security v1.5.0/go.mod h1:lgxGdyOKKjHL4YG3/YwIL2zLqMFCKs0UbQwgyZmfJl4= cloud.google.com/go/security v1.7.0/go.mod h1:mZklORHl6Bg7CNnnjLH//0UlAlaXqiG7Lb9PsPXLfD0= cloud.google.com/go/security v1.8.0/go.mod h1:hAQOwgmaHhztFhiQ41CjDODdWP0+AE1B3sX4OFlq+GU= cloud.google.com/go/security v1.9.0/go.mod h1:6Ta1bO8LXI89nZnmnsZGp9lVoVWXqsVbIq/t9dzI+2Q= cloud.google.com/go/security v1.10.0/go.mod h1:QtOMZByJVlibUT2h9afNDWRZ1G96gVywH8T5GUSb9IA= cloud.google.com/go/security v1.12.0/go.mod h1:rV6EhrpbNHrrxqlvW0BWAIawFWq3X90SduMJdFwtLB8= +cloud.google.com/go/security v1.13.0/go.mod h1:Q1Nvxl1PAgmeW0y3HTt54JYIvUdtcpYKVfIB8AOMZ+0= +cloud.google.com/go/security v1.15.1/go.mod h1:MvTnnbsWnehoizHi09zoiZob0iCHVcL4AUBj76h9fXA= +cloud.google.com/go/security v1.15.2/go.mod h1:2GVE/v1oixIRHDaClVbHuPcZwAqFM28mXuAKCfMgYIg= +cloud.google.com/go/security v1.15.3/go.mod h1:gQ/7Q2JYUZZgOzqKtw9McShH+MjNvtDpL40J1cT+vBs= +cloud.google.com/go/security v1.15.4/go.mod h1:oN7C2uIZKhxCLiAAijKUCuHLZbIt/ghYEo8MqwD/Ty4= cloud.google.com/go/securitycenter v1.13.0/go.mod h1:cv5qNAqjY84FCN6Y9z28WlkKXyWsgLO832YiWwkCWcU= cloud.google.com/go/securitycenter v1.14.0/go.mod h1:gZLAhtyKv85n52XYWt6RmeBdydyxfPeTrpToDPw4Auc= cloud.google.com/go/securitycenter v1.15.0/go.mod h1:PeKJ0t8MoFmmXLXWm41JidyzI3PJjd8sXWaVqg43WWk= cloud.google.com/go/securitycenter v1.16.0/go.mod h1:Q9GMaLQFUD+5ZTabrbujNWLtSLZIZF7SAR0wWECrjdk= cloud.google.com/go/securitycenter v1.18.1/go.mod h1:0/25gAzCM/9OL9vVx4ChPeM/+DlfGQJDwBy/UC8AKK0= +cloud.google.com/go/securitycenter v1.19.0/go.mod h1:LVLmSg8ZkkyaNy4u7HCIshAngSQ8EcIRREP3xBnyfag= +cloud.google.com/go/securitycenter v1.23.0/go.mod h1:8pwQ4n+Y9WCWM278R8W3nF65QtY172h4S8aXyI9/hsQ= +cloud.google.com/go/securitycenter v1.23.1/go.mod h1:w2HV3Mv/yKhbXKwOCu2i8bCuLtNP1IMHuiYQn4HJq5s= +cloud.google.com/go/securitycenter v1.24.1/go.mod h1:3h9IdjjHhVMXdQnmqzVnM7b0wMn/1O/U20eWVpMpZjI= +cloud.google.com/go/securitycenter v1.24.2/go.mod h1:l1XejOngggzqwr4Fa2Cn+iWZGf+aBLTXtB/vXjy5vXM= cloud.google.com/go/servicecontrol v1.4.0/go.mod h1:o0hUSJ1TXJAmi/7fLJAedOovnujSEvjKCAFNXPQ1RaU= cloud.google.com/go/servicecontrol v1.5.0/go.mod h1:qM0CnXHhyqKVuiZnGKrIurvVImCs8gmqWsDoqe9sU1s= +cloud.google.com/go/servicecontrol v1.10.0/go.mod h1:pQvyvSRh7YzUF2efw7H87V92mxU8FnFDawMClGCNuAA= cloud.google.com/go/servicecontrol v1.11.0/go.mod h1:kFmTzYzTUIuZs0ycVqRHNaNhgR+UMUpw9n02l/pY+mc= +cloud.google.com/go/servicecontrol v1.11.1/go.mod h1:aSnNNlwEFBY+PWGQ2DoM0JJ/QUXqV5/ZD9DOLB7SnUk= cloud.google.com/go/servicedirectory v1.4.0/go.mod h1:gH1MUaZCgtP7qQiI+F+A+OpeKF/HQWgtAddhTbhL2bs= cloud.google.com/go/servicedirectory v1.5.0/go.mod h1:QMKFL0NUySbpZJ1UZs3oFAmdvVxhhxB6eJ/Vlp73dfg= cloud.google.com/go/servicedirectory v1.6.0/go.mod h1:pUlbnWsLH9c13yGkxCmfumWEPjsRs1RlmJ4pqiNjVL4= cloud.google.com/go/servicedirectory v1.7.0/go.mod h1:5p/U5oyvgYGYejufvxhgwjL8UVXjkuw7q5XcG10wx1U= cloud.google.com/go/servicedirectory v1.8.0/go.mod h1:srXodfhY1GFIPvltunswqXpVxFPpZjf8nkKQT7XcXaY= +cloud.google.com/go/servicedirectory v1.9.0/go.mod h1:29je5JjiygNYlmsGz8k6o+OZ8vd4f//bQLtvzkPPT/s= +cloud.google.com/go/servicedirectory v1.10.1/go.mod h1:Xv0YVH8s4pVOwfM/1eMTl0XJ6bzIOSLDt8f8eLaGOxQ= +cloud.google.com/go/servicedirectory v1.11.0/go.mod h1:Xv0YVH8s4pVOwfM/1eMTl0XJ6bzIOSLDt8f8eLaGOxQ= +cloud.google.com/go/servicedirectory v1.11.1/go.mod h1:tJywXimEWzNzw9FvtNjsQxxJ3/41jseeILgwU/QLrGI= +cloud.google.com/go/servicedirectory v1.11.2/go.mod h1:KD9hCLhncWRV5jJphwIpugKwM5bn1x0GyVVD4NO8mGg= +cloud.google.com/go/servicedirectory v1.11.3/go.mod h1:LV+cHkomRLr67YoQy3Xq2tUXBGOs5z5bPofdq7qtiAw= cloud.google.com/go/servicemanagement v1.4.0/go.mod h1:d8t8MDbezI7Z2R1O/wu8oTggo3BI2GKYbdG4y/SJTco= cloud.google.com/go/servicemanagement v1.5.0/go.mod h1:XGaCRe57kfqu4+lRxaFEAuqmjzF0r+gWHjWqKqBvKFo= cloud.google.com/go/servicemanagement v1.6.0/go.mod h1:aWns7EeeCOtGEX4OvZUWCCJONRZeFKiptqKf1D0l/Jc= +cloud.google.com/go/servicemanagement v1.8.0/go.mod h1:MSS2TDlIEQD/fzsSGfCdJItQveu9NXnUniTrq/L8LK4= cloud.google.com/go/serviceusage v1.3.0/go.mod h1:Hya1cozXM4SeSKTAgGXgj97GlqUvF5JaoXacR1JTP/E= cloud.google.com/go/serviceusage v1.4.0/go.mod h1:SB4yxXSaYVuUBYUml6qklyONXNLt83U0Rb+CXyhjEeU= cloud.google.com/go/serviceusage v1.5.0/go.mod h1:w8U1JvqUqwJNPEOTQjrMHkw3IaIFLoLsPLvsE3xueec= +cloud.google.com/go/serviceusage v1.6.0/go.mod h1:R5wwQcbOWsyuOfbP9tGdAnCAc6B9DRwPG1xtWMDeuPA= cloud.google.com/go/shell v1.3.0/go.mod h1:VZ9HmRjZBsjLGXusm7K5Q5lzzByZmJHf1d0IWHEN5X4= cloud.google.com/go/shell v1.4.0/go.mod h1:HDxPzZf3GkDdhExzD/gs8Grqk+dmYcEjGShZgYa9URw= cloud.google.com/go/shell v1.6.0/go.mod h1:oHO8QACS90luWgxP3N9iZVuEiSF84zNyLytb+qE2f9A= +cloud.google.com/go/shell v1.7.1/go.mod h1:u1RaM+huXFaTojTbW4g9P5emOrrmLE69KrxqQahKn4g= +cloud.google.com/go/shell v1.7.2/go.mod h1:KqRPKwBV0UyLickMn0+BY1qIyE98kKyI216sH/TuHmc= +cloud.google.com/go/shell v1.7.3/go.mod h1:cTTEz/JdaBsQAeTQ3B6HHldZudFoYBOqjteev07FbIc= +cloud.google.com/go/shell v1.7.4/go.mod h1:yLeXB8eKLxw0dpEmXQ/FjriYrBijNsONpwnWsdPqlKM= cloud.google.com/go/spanner v1.41.0/go.mod h1:MLYDBJR/dY4Wt7ZaMIQ7rXOTLjYrmxLE/5ve9vFfWos= cloud.google.com/go/spanner v1.44.0/go.mod h1:G8XIgYdOK+Fbcpbs7p2fiprDw4CaZX63whnSMLVBxjk= +cloud.google.com/go/spanner v1.45.0/go.mod h1:FIws5LowYz8YAE1J8fOS7DJup8ff7xJeetWEo5REA2M= +cloud.google.com/go/spanner v1.47.0/go.mod h1:IXsJwVW2j4UKs0eYDqodab6HgGuA1bViSqW4uH9lfUI= +cloud.google.com/go/spanner v1.49.0/go.mod h1:eGj9mQGK8+hkgSVbHNQ06pQ4oS+cyc4tXXd6Dif1KoM= +cloud.google.com/go/spanner v1.50.0/go.mod h1:eGj9mQGK8+hkgSVbHNQ06pQ4oS+cyc4tXXd6Dif1KoM= +cloud.google.com/go/spanner v1.51.0/go.mod h1:c5KNo5LQ1X5tJwma9rSQZsXNBDNvj4/n8BVc3LNahq0= +cloud.google.com/go/spanner v1.53.0/go.mod h1:liG4iCeLqm5L3fFLU5whFITqP0e0orsAW1uUSrd4rws= cloud.google.com/go/speech v1.6.0/go.mod h1:79tcr4FHCimOp56lwC01xnt/WPJZc4v3gzyT7FoBkCM= cloud.google.com/go/speech v1.7.0/go.mod h1:KptqL+BAQIhMsj1kOP2la5DSEEerPDuOP/2mmkhHhZQ= cloud.google.com/go/speech v1.8.0/go.mod h1:9bYIl1/tjsAnMgKGHKmBZzXKEkGgtU+MpdDPTE9f7y0= cloud.google.com/go/speech v1.9.0/go.mod h1:xQ0jTcmnRFFM2RfX/U+rk6FQNUF6DQlydUSyoooSpco= cloud.google.com/go/speech v1.14.1/go.mod h1:gEosVRPJ9waG7zqqnsHpYTOoAS4KouMRLDFMekpJ0J0= +cloud.google.com/go/speech v1.15.0/go.mod h1:y6oH7GhqCaZANH7+Oe0BhgIogsNInLlz542tg3VqeYI= +cloud.google.com/go/speech v1.17.1/go.mod h1:8rVNzU43tQvxDaGvqOhpDqgkJTFowBpDvCJ14kGlJYo= +cloud.google.com/go/speech v1.19.0/go.mod h1:8rVNzU43tQvxDaGvqOhpDqgkJTFowBpDvCJ14kGlJYo= +cloud.google.com/go/speech v1.19.1/go.mod h1:WcuaWz/3hOlzPFOVo9DUsblMIHwxP589y6ZMtaG+iAA= +cloud.google.com/go/speech v1.19.2/go.mod h1:2OYFfj+Ch5LWjsaSINuCZsre/789zlcCI3SY4oAi2oI= +cloud.google.com/go/speech v1.20.1/go.mod h1:wwolycgONvfz2EDU8rKuHRW3+wc9ILPsAWoikBEWavY= +cloud.google.com/go/speech v1.21.0/go.mod h1:wwolycgONvfz2EDU8rKuHRW3+wc9ILPsAWoikBEWavY= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= @@ -480,65 +1034,143 @@ cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc= cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s= cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y= -cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM= +cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4= cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E= +cloud.google.com/go/storage v1.36.0 h1:P0mOkAcaJxhCTvAkMhxMfrTKiNcub4YmmPBtlhAyTr8= +cloud.google.com/go/storage v1.36.0/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8= cloud.google.com/go/storagetransfer v1.5.0/go.mod h1:dxNzUopWy7RQevYFHewchb29POFv3/AaBgnhqzqiK0w= cloud.google.com/go/storagetransfer v1.6.0/go.mod h1:y77xm4CQV/ZhFZH75PLEXY0ROiS7Gh6pSKrM8dJyg6I= cloud.google.com/go/storagetransfer v1.7.0/go.mod h1:8Giuj1QNb1kfLAiWM1bN6dHzfdlDAVC9rv9abHot2W4= +cloud.google.com/go/storagetransfer v1.8.0/go.mod h1:JpegsHHU1eXg7lMHkvf+KE5XDJ7EQu0GwNJbbVGanEw= +cloud.google.com/go/storagetransfer v1.10.0/go.mod h1:DM4sTlSmGiNczmV6iZyceIh2dbs+7z2Ayg6YAiQlYfA= +cloud.google.com/go/storagetransfer v1.10.1/go.mod h1:rS7Sy0BtPviWYTTJVWCSV4QrbBitgPeuK4/FKa4IdLs= +cloud.google.com/go/storagetransfer v1.10.2/go.mod h1:meIhYQup5rg9juQJdyppnA/WLQCOguxtk1pr3/vBWzA= +cloud.google.com/go/storagetransfer v1.10.3/go.mod h1:Up8LY2p6X68SZ+WToswpQbQHnJpOty/ACcMafuey8gc= cloud.google.com/go/talent v1.1.0/go.mod h1:Vl4pt9jiHKvOgF9KoZo6Kob9oV4lwd/ZD5Cto54zDRw= cloud.google.com/go/talent v1.2.0/go.mod h1:MoNF9bhFQbiJ6eFD3uSsg0uBALw4n4gaCaEjBw9zo8g= cloud.google.com/go/talent v1.3.0/go.mod h1:CmcxwJ/PKfRgd1pBjQgU6W3YBwiewmUzQYH5HHmSCmM= cloud.google.com/go/talent v1.4.0/go.mod h1:ezFtAgVuRf8jRsvyE6EwmbTK5LKciD4KVnHuDEFmOOA= cloud.google.com/go/talent v1.5.0/go.mod h1:G+ODMj9bsasAEJkQSzO2uHQWXHHXUomArjWQQYkqK6c= +cloud.google.com/go/talent v1.6.2/go.mod h1:CbGvmKCG61mkdjcqTcLOkb2ZN1SrQI8MDyma2l7VD24= +cloud.google.com/go/talent v1.6.3/go.mod h1:xoDO97Qd4AK43rGjJvyBHMskiEf3KulgYzcH6YWOVoo= +cloud.google.com/go/talent v1.6.4/go.mod h1:QsWvi5eKeh6gG2DlBkpMaFYZYrYUnIpo34f6/V5QykY= +cloud.google.com/go/talent v1.6.5/go.mod h1:Mf5cma696HmE+P2BWJ/ZwYqeJXEeU0UqjHFXVLadEDI= cloud.google.com/go/texttospeech v1.4.0/go.mod h1:FX8HQHA6sEpJ7rCMSfXuzBcysDAuWusNNNvN9FELDd8= cloud.google.com/go/texttospeech v1.5.0/go.mod h1:oKPLhR4n4ZdQqWKURdwxMy0uiTS1xU161C8W57Wkea4= cloud.google.com/go/texttospeech v1.6.0/go.mod h1:YmwmFT8pj1aBblQOI3TfKmwibnsfvhIBzPXcW4EBovc= +cloud.google.com/go/texttospeech v1.7.1/go.mod h1:m7QfG5IXxeneGqTapXNxv2ItxP/FS0hCZBwXYqucgSk= +cloud.google.com/go/texttospeech v1.7.2/go.mod h1:VYPT6aTOEl3herQjFHYErTlSZJ4vB00Q2ZTmuVgluD4= +cloud.google.com/go/texttospeech v1.7.3/go.mod h1:Av/zpkcgWfXlDLRYob17lqMstGZ3GqlvJXqKMp2u8so= +cloud.google.com/go/texttospeech v1.7.4/go.mod h1:vgv0002WvR4liGuSd5BJbWy4nDn5Ozco0uJymY5+U74= cloud.google.com/go/tpu v1.3.0/go.mod h1:aJIManG0o20tfDQlRIej44FcwGGl/cD0oiRyMKG19IQ= cloud.google.com/go/tpu v1.4.0/go.mod h1:mjZaX8p0VBgllCzF6wcU2ovUXN9TONFLd7iz227X2Xg= cloud.google.com/go/tpu v1.5.0/go.mod h1:8zVo1rYDFuW2l4yZVY0R0fb/v44xLh3llq7RuV61fPM= +cloud.google.com/go/tpu v1.6.1/go.mod h1:sOdcHVIgDEEOKuqUoi6Fq53MKHJAtOwtz0GuKsWSH3E= +cloud.google.com/go/tpu v1.6.2/go.mod h1:NXh3NDwt71TsPZdtGWgAG5ThDfGd32X1mJ2cMaRlVgU= +cloud.google.com/go/tpu v1.6.3/go.mod h1:lxiueqfVMlSToZY1151IaZqp89ELPSrk+3HIQ5HRkbY= +cloud.google.com/go/tpu v1.6.4/go.mod h1:NAm9q3Rq2wIlGnOhpYICNI7+bpBebMJbh0yyp3aNw1Y= cloud.google.com/go/trace v1.0.0/go.mod h1:4iErSByzxkyHWzzlAj63/Gmjz0NH1ASqhJguHpGcr6A= cloud.google.com/go/trace v1.2.0/go.mod h1:Wc8y/uYyOhPy12KEnXG9XGrvfMz5F5SrYecQlbW1rwM= cloud.google.com/go/trace v1.3.0/go.mod h1:FFUE83d9Ca57C+K8rDl/Ih8LwOzWIV1krKgxg6N0G28= cloud.google.com/go/trace v1.4.0/go.mod h1:UG0v8UBqzusp+z63o7FK74SdFE+AXpCLdFb1rshXG+Y= cloud.google.com/go/trace v1.8.0/go.mod h1:zH7vcsbAhklH8hWFig58HvxcxyQbaIqMarMg9hn5ECA= +cloud.google.com/go/trace v1.9.0/go.mod h1:lOQqpE5IaWY0Ixg7/r2SjixMuc6lfTFeO4QGM4dQWOk= +cloud.google.com/go/trace v1.10.1/go.mod h1:gbtL94KE5AJLH3y+WVpfWILmqgc6dXcqgNXdOPAQTYk= +cloud.google.com/go/trace v1.10.2/go.mod h1:NPXemMi6MToRFcSxRl2uDnu/qAlAQ3oULUphcHGh1vA= +cloud.google.com/go/trace v1.10.3/go.mod h1:Ke1bgfc73RV3wUFml+uQp7EsDw4dGaETLxB7Iq/r4CY= +cloud.google.com/go/trace v1.10.4/go.mod h1:Nso99EDIK8Mj5/zmB+iGr9dosS/bzWCJ8wGmE6TXNWY= cloud.google.com/go/translate v1.3.0/go.mod h1:gzMUwRjvOqj5i69y/LYLd8RrNQk+hOmIXTi9+nb3Djs= cloud.google.com/go/translate v1.4.0/go.mod h1:06Dn/ppvLD6WvA5Rhdp029IX2Mi3Mn7fpMRLPvXT5Wg= +cloud.google.com/go/translate v1.5.0/go.mod h1:29YDSYveqqpA1CQFD7NQuP49xymq17RXNaUDdc0mNu0= cloud.google.com/go/translate v1.6.0/go.mod h1:lMGRudH1pu7I3n3PETiOB2507gf3HnfLV8qlkHZEyos= +cloud.google.com/go/translate v1.7.0/go.mod h1:lMGRudH1pu7I3n3PETiOB2507gf3HnfLV8qlkHZEyos= +cloud.google.com/go/translate v1.8.1/go.mod h1:d1ZH5aaOA0CNhWeXeC8ujd4tdCFw8XoNWRljklu5RHs= +cloud.google.com/go/translate v1.8.2/go.mod h1:d1ZH5aaOA0CNhWeXeC8ujd4tdCFw8XoNWRljklu5RHs= +cloud.google.com/go/translate v1.9.0/go.mod h1:d1ZH5aaOA0CNhWeXeC8ujd4tdCFw8XoNWRljklu5RHs= +cloud.google.com/go/translate v1.9.1/go.mod h1:TWIgDZknq2+JD4iRcojgeDtqGEp154HN/uL6hMvylS8= +cloud.google.com/go/translate v1.9.2/go.mod h1:E3Tc6rUTsQkVrXW6avbUhKJSr7ZE3j7zNmqzXKHqRrY= +cloud.google.com/go/translate v1.9.3/go.mod h1:Kbq9RggWsbqZ9W5YpM94Q1Xv4dshw/gr/SHfsl5yCZ0= cloud.google.com/go/video v1.8.0/go.mod h1:sTzKFc0bUSByE8Yoh8X0mn8bMymItVGPfTuUBUyRgxk= cloud.google.com/go/video v1.9.0/go.mod h1:0RhNKFRF5v92f8dQt0yhaHrEuH95m068JYOvLZYnJSw= +cloud.google.com/go/video v1.12.0/go.mod h1:MLQew95eTuaNDEGriQdcYn0dTwf9oWiA4uYebxM5kdg= cloud.google.com/go/video v1.13.0/go.mod h1:ulzkYlYgCp15N2AokzKjy7MQ9ejuynOJdf1tR5lGthk= +cloud.google.com/go/video v1.14.0/go.mod h1:SkgaXwT+lIIAKqWAJfktHT/RbgjSuY6DobxEp0C5yTQ= +cloud.google.com/go/video v1.15.0/go.mod h1:SkgaXwT+lIIAKqWAJfktHT/RbgjSuY6DobxEp0C5yTQ= +cloud.google.com/go/video v1.17.1/go.mod h1:9qmqPqw/Ib2tLqaeHgtakU+l5TcJxCJbhFXM7UJjVzU= +cloud.google.com/go/video v1.19.0/go.mod h1:9qmqPqw/Ib2tLqaeHgtakU+l5TcJxCJbhFXM7UJjVzU= +cloud.google.com/go/video v1.20.0/go.mod h1:U3G3FTnsvAGqglq9LxgqzOiBc/Nt8zis8S+850N2DUM= +cloud.google.com/go/video v1.20.1/go.mod h1:3gJS+iDprnj8SY6pe0SwLeC5BUW80NjhwX7INWEuWGU= +cloud.google.com/go/video v1.20.2/go.mod h1:lrixr5JeKNThsgfM9gqtwb6Okuqzfo4VrY2xynaViTA= +cloud.google.com/go/video v1.20.3/go.mod h1:TnH/mNZKVHeNtpamsSPygSR0iHtvrR/cW1/GDjN5+GU= cloud.google.com/go/videointelligence v1.6.0/go.mod h1:w0DIDlVRKtwPCn/C4iwZIJdvC69yInhW0cfi+p546uU= cloud.google.com/go/videointelligence v1.7.0/go.mod h1:k8pI/1wAhjznARtVT9U1llUaFNPh7muw8QyOUpavru4= cloud.google.com/go/videointelligence v1.8.0/go.mod h1:dIcCn4gVDdS7yte/w+koiXn5dWVplOZkE+xwG9FgK+M= cloud.google.com/go/videointelligence v1.9.0/go.mod h1:29lVRMPDYHikk3v8EdPSaL8Ku+eMzDljjuvRs105XoU= cloud.google.com/go/videointelligence v1.10.0/go.mod h1:LHZngX1liVtUhZvi2uNS0VQuOzNi2TkY1OakiuoUOjU= +cloud.google.com/go/videointelligence v1.11.1/go.mod h1:76xn/8InyQHarjTWsBR058SmlPCwQjgcvoW0aZykOvo= +cloud.google.com/go/videointelligence v1.11.2/go.mod h1:ocfIGYtIVmIcWk1DsSGOoDiXca4vaZQII1C85qtoplc= +cloud.google.com/go/videointelligence v1.11.3/go.mod h1:tf0NUaGTjU1iS2KEkGWvO5hRHeCkFK3nPo0/cOZhZAo= +cloud.google.com/go/videointelligence v1.11.4/go.mod h1:kPBMAYsTPFiQxMLmmjpcZUMklJp3nC9+ipJJtprccD8= cloud.google.com/go/vision v1.2.0/go.mod h1:SmNwgObm5DpFBme2xpyOyasvBc1aPdjvMk2bBk0tKD0= cloud.google.com/go/vision/v2 v2.2.0/go.mod h1:uCdV4PpN1S0jyCyq8sIM42v2Y6zOLkZs+4R9LrGYwFo= cloud.google.com/go/vision/v2 v2.3.0/go.mod h1:UO61abBx9QRMFkNBbf1D8B1LXdS2cGiiCRx0vSpZoUo= cloud.google.com/go/vision/v2 v2.4.0/go.mod h1:VtI579ll9RpVTrdKdkMzckdnwMyX2JILb+MhPqRbPsY= cloud.google.com/go/vision/v2 v2.5.0/go.mod h1:MmaezXOOE+IWa+cS7OhRRLK2cNv1ZL98zhqFFZaaH2E= cloud.google.com/go/vision/v2 v2.6.0/go.mod h1:158Hes0MvOS9Z/bDMSFpjwsUrZ5fPrdwuyyvKSGAGMY= +cloud.google.com/go/vision/v2 v2.7.0/go.mod h1:H89VysHy21avemp6xcf9b9JvZHVehWbET0uT/bcuY/0= +cloud.google.com/go/vision/v2 v2.7.2/go.mod h1:jKa8oSYBWhYiXarHPvP4USxYANYUEdEsQrloLjrSwJU= +cloud.google.com/go/vision/v2 v2.7.3/go.mod h1:V0IcLCY7W+hpMKXK1JYE0LV5llEqVmj+UJChjvA1WsM= +cloud.google.com/go/vision/v2 v2.7.4/go.mod h1:ynDKnsDN/0RtqkKxQZ2iatv3Dm9O+HfRb5djl7l4Vvw= +cloud.google.com/go/vision/v2 v2.7.5/go.mod h1:GcviprJLFfK9OLf0z8Gm6lQb6ZFUulvpZws+mm6yPLM= cloud.google.com/go/vmmigration v1.2.0/go.mod h1:IRf0o7myyWFSmVR1ItrBSFLFD/rJkfDCUTO4vLlJvsE= cloud.google.com/go/vmmigration v1.3.0/go.mod h1:oGJ6ZgGPQOFdjHuocGcLqX4lc98YQ7Ygq8YQwHh9A7g= cloud.google.com/go/vmmigration v1.5.0/go.mod h1:E4YQ8q7/4W9gobHjQg4JJSgXXSgY21nA5r8swQV+Xxc= +cloud.google.com/go/vmmigration v1.6.0/go.mod h1:bopQ/g4z+8qXzichC7GW1w2MjbErL54rk3/C843CjfY= +cloud.google.com/go/vmmigration v1.7.1/go.mod h1:WD+5z7a/IpZ5bKK//YmT9E047AD+rjycCAvyMxGJbro= +cloud.google.com/go/vmmigration v1.7.2/go.mod h1:iA2hVj22sm2LLYXGPT1pB63mXHhrH1m/ruux9TwWLd8= +cloud.google.com/go/vmmigration v1.7.3/go.mod h1:ZCQC7cENwmSWlwyTrZcWivchn78YnFniEQYRWQ65tBo= +cloud.google.com/go/vmmigration v1.7.4/go.mod h1:yBXCmiLaB99hEl/G9ZooNx2GyzgsjKnw5fWcINRgD70= cloud.google.com/go/vmwareengine v0.1.0/go.mod h1:RsdNEf/8UDvKllXhMz5J40XxDrNJNN4sagiox+OI208= cloud.google.com/go/vmwareengine v0.2.2/go.mod h1:sKdctNJxb3KLZkE/6Oui94iw/xs9PRNC2wnNLXsHvH8= +cloud.google.com/go/vmwareengine v0.3.0/go.mod h1:wvoyMvNWdIzxMYSpH/R7y2h5h3WFkx6d+1TIsP39WGY= +cloud.google.com/go/vmwareengine v0.4.1/go.mod h1:Px64x+BvjPZwWuc4HdmVhoygcXqEkGHXoa7uyfTgSI0= +cloud.google.com/go/vmwareengine v1.0.0/go.mod h1:Px64x+BvjPZwWuc4HdmVhoygcXqEkGHXoa7uyfTgSI0= +cloud.google.com/go/vmwareengine v1.0.1/go.mod h1:aT3Xsm5sNx0QShk1Jc1B8OddrxAScYLwzVoaiXfdzzk= +cloud.google.com/go/vmwareengine v1.0.2/go.mod h1:xMSNjIk8/itYrz1JA8nV3Ajg4L4n3N+ugP8JKzk3OaA= +cloud.google.com/go/vmwareengine v1.0.3/go.mod h1:QSpdZ1stlbfKtyt6Iu19M6XRxjmXO+vb5a/R6Fvy2y4= cloud.google.com/go/vpcaccess v1.4.0/go.mod h1:aQHVbTWDYUR1EbTApSVvMq1EnT57ppDmQzZ3imqIk4w= cloud.google.com/go/vpcaccess v1.5.0/go.mod h1:drmg4HLk9NkZpGfCmZ3Tz0Bwnm2+DKqViEpeEpOq0m8= cloud.google.com/go/vpcaccess v1.6.0/go.mod h1:wX2ILaNhe7TlVa4vC5xce1bCnqE3AeH27RV31lnmZes= +cloud.google.com/go/vpcaccess v1.7.1/go.mod h1:FogoD46/ZU+JUBX9D606X21EnxiszYi2tArQwLY4SXs= +cloud.google.com/go/vpcaccess v1.7.2/go.mod h1:mmg/MnRHv+3e8FJUjeSibVFvQF1cCy2MsFaFqxeY1HU= +cloud.google.com/go/vpcaccess v1.7.3/go.mod h1:YX4skyfW3NC8vI3Fk+EegJnlYFatA+dXK4o236EUCUc= +cloud.google.com/go/vpcaccess v1.7.4/go.mod h1:lA0KTvhtEOb/VOdnH/gwPuOzGgM+CWsmGu6bb4IoMKk= cloud.google.com/go/webrisk v1.4.0/go.mod h1:Hn8X6Zr+ziE2aNd8SliSDWpEnSS1u4R9+xXZmFiHmGE= cloud.google.com/go/webrisk v1.5.0/go.mod h1:iPG6fr52Tv7sGk0H6qUFzmL3HHZev1htXuWDEEsqMTg= cloud.google.com/go/webrisk v1.6.0/go.mod h1:65sW9V9rOosnc9ZY7A7jsy1zoHS5W9IAXv6dGqhMQMc= cloud.google.com/go/webrisk v1.7.0/go.mod h1:mVMHgEYH0r337nmt1JyLthzMr6YxwN1aAIEc2fTcq7A= cloud.google.com/go/webrisk v1.8.0/go.mod h1:oJPDuamzHXgUc+b8SiHRcVInZQuybnvEW72PqTc7sSg= +cloud.google.com/go/webrisk v1.9.1/go.mod h1:4GCmXKcOa2BZcZPn6DCEvE7HypmEJcJkr4mtM+sqYPc= +cloud.google.com/go/webrisk v1.9.2/go.mod h1:pY9kfDgAqxUpDBOrG4w8deLfhvJmejKB0qd/5uQIPBc= +cloud.google.com/go/webrisk v1.9.3/go.mod h1:RUYXe9X/wBDXhVilss7EDLW9ZNa06aowPuinUOPCXH8= +cloud.google.com/go/webrisk v1.9.4/go.mod h1:w7m4Ib4C+OseSr2GL66m0zMBywdrVNTDKsdEsfMl7X0= cloud.google.com/go/websecurityscanner v1.3.0/go.mod h1:uImdKm2wyeXQevQJXeh8Uun/Ym1VqworNDlBXQevGMo= cloud.google.com/go/websecurityscanner v1.4.0/go.mod h1:ebit/Fp0a+FWu5j4JOmJEV8S8CzdTkAS77oDsiSqYWQ= cloud.google.com/go/websecurityscanner v1.5.0/go.mod h1:Y6xdCPy81yi0SQnDY1xdNTNpfY1oAgXUlcfN3B3eSng= +cloud.google.com/go/websecurityscanner v1.6.1/go.mod h1:Njgaw3rttgRHXzwCB8kgCYqv5/rGpFCsBOvPbYgszpg= +cloud.google.com/go/websecurityscanner v1.6.2/go.mod h1:7YgjuU5tun7Eg2kpKgGnDuEOXWIrh8x8lWrJT4zfmas= +cloud.google.com/go/websecurityscanner v1.6.3/go.mod h1:x9XANObUFR+83Cya3g/B9M/yoHVqzxPnFtgF8yYGAXw= +cloud.google.com/go/websecurityscanner v1.6.4/go.mod h1:mUiyMQ+dGpPPRkHgknIZeCzSHJ45+fY4F52nZFDHm2o= cloud.google.com/go/workflows v1.6.0/go.mod h1:6t9F5h/unJz41YqfBmqSASJSXccBLtD1Vwf+KmJENM0= cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoISEXH2bcHC3M= cloud.google.com/go/workflows v1.8.0/go.mod h1:ysGhmEajwZxGn1OhGOGKsTXc5PyxOc0vfKf5Af+to4M= cloud.google.com/go/workflows v1.9.0/go.mod h1:ZGkj1aFIOd9c8Gerkjjq7OW7I5+l6cSvT3ujaO/WwSA= cloud.google.com/go/workflows v1.10.0/go.mod h1:fZ8LmRmZQWacon9UCX1r/g/DfAXx5VcPALq2CxzdePw= +cloud.google.com/go/workflows v1.11.1/go.mod h1:Z+t10G1wF7h8LgdY/EmRcQY8ptBD/nvofaL6FqlET6g= +cloud.google.com/go/workflows v1.12.0/go.mod h1:PYhSk2b6DhZ508tj8HXKaBh+OFe+xdl0dHF/tJdzPQM= +cloud.google.com/go/workflows v1.12.1/go.mod h1:5A95OhD/edtOhQd/O741NSfIMezNTbCwLM1P1tBRGHM= +cloud.google.com/go/workflows v1.12.2/go.mod h1:+OmBIgNqYJPVggnMo9nqmizW0qEXHhmnAzK/CnBqsHc= +cloud.google.com/go/workflows v1.12.3/go.mod h1:fmOUeeqEwPzIU81foMjTRQIdwQHADi/vEr1cx9R1m5g= contrib.go.opencensus.io/exporter/aws v0.0.0-20200617204711-c478e41e60e9/go.mod h1:uu1P0UCM/6RbsMrgPa98ll8ZcHM858i/AD06a9aLRCA= contrib.go.opencensus.io/exporter/stackdriver v0.13.10/go.mod h1:I5htMbyta491eUxufwwZPQdcKvvgzMB4O9ni41YnIM8= contrib.go.opencensus.io/integrations/ocsql v0.1.7/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE= @@ -556,24 +1188,47 @@ github.com/Azure/azure-pipeline-go v0.2.3 h1:7U9HBg1JFK3jHl5qmo4CTZKFTVgMwdFHMVt github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k= github.com/Azure/azure-sdk-for-go v51.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v59.3.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/azure-sdk-for-go v65.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.8.0 h1:9kDVnTz3vbfweTqAUmk/a/pH5pWFCHtvRpHYC0G/dcA= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0/go.mod h1:ON4tFdPTwRcgWEaVDrN3584Ef+b7GgSJaXxe5fW9t4M= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.8.0/go.mod h1:3Ug6Qzto9anB6mGlEdgYMDF5zHQ+wwhEaYR4s17PHMw= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0/go.mod h1:uReU2sSxZExRPBAg3qKzmAucSi51+SP1OhohieR821Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1 h1:lGlwhPtrX6EVml1hO0ivjkUxsSyl4dsiw9qcA1k/3IQ= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1/go.mod h1:RKUqNu35KJYcVG/fqTRqmuXJZYNhYkBrnC/hX7yGbTA= github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0/go.mod h1:OQeznEEkTZ9OrhHJoDD8ZDq51FHgXjqtP9z6bEwBq9U= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.4.0 h1:BMAjVKJM0U/CYF27gA0ZMmXGkOcvfFtD0oHVZ1TIPRI= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.0.0/go.mod h1:+6sju8gk8FRmSajX3Oz4G5Gm7P+mbqE9FVaXXFYTkCM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.0/go.mod h1:NBanQUfSWiWn3QEpWDTCU0IjBECKOYvl2R8xdRtMtiM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.4.0/go.mod h1:1fXstnBMas5kzG+S3q8UoJcmyU6nUeunJcMDHcRYHhs= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1 h1:sO0/P7g68FrryJzljemN+6GTssUXdANk6aJ7T1ZxnsQ= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo= github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 h1:sXr+ck84g/ZlZUOZiNELInmMgOsuGwdjjVkEIde0OtY= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.2.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.0/go.mod h1:s4kgfzA0covAXNicZHDMN58jExvcng2mC/DepXiF1EI= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1 h1:6oNBlSdi1QqM1PNW7FPA6xOGA5UNsXnkaYZz9vdPGhA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1/go.mod h1:s4kgfzA0covAXNicZHDMN58jExvcng2mC/DepXiF1EI= github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.9.0 h1:TOFrNxfjslms5nLLIMjW7N0+zSALX4KiGsptmpb16AA= github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.9.0/go.mod h1:EAyXOW1F6BTJPiK2pDvmnvxOHPxoTYWoqBeIlql+QhI= github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.0 h1:Lg6BW0VPmCwcMlvOviL3ruHFO+H9tZNqscK0AeuFjGM= github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.0/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v4 v4.2.1 h1:UPeCRD+XY7QlaGQte2EVI2iOcWvUYA2XY8w5T/8v0NQ= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v4 v4.2.1/go.mod h1:oGV6NlB0cvi1ZbYRR2UN44QHxWFyGk+iylgD0qaMXjA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.0.0/go.mod h1:ceIuwmxDWptoW3eCqSXlnPsZFKh4X+R38dWPv7GS9Vs= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.1.2/go.mod h1:FbdwsQ2EzwvXxOPcMFYO8ogEc9uMMIj3YkmCdXdAFmk= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.0.0/go.mod h1:243D9iHbcQXoFUtgHJwL7gl2zx1aDuDMjvBZVGr2uW0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0 h1:QM6sE5k2ZT/vI5BEe0r7mqjsUSnhVBFbOsVkEuaEfiA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0/go.mod h1:243D9iHbcQXoFUtgHJwL7gl2zx1aDuDMjvBZVGr2uW0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v2 v2.2.1 h1:bWh0Z2rOEDfB/ywv/l0iHN1JgyazE6kW/aIA89+CEK0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v2 v2.2.1/go.mod h1:Bzf34hhAE9NSxailk8xVeLEZbUjOXcC+GnU1mMKdhLw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.0.0/go.mod h1:s1tW/At+xHqjNFvWU4G0c0Qv33KOhvbGNj0RCTQDV8s= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.0 h1:yfJe15aSwEQ6Oo6J+gdfdulPNoZ3TEhmbhLIoxZcA+U= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.0/go.mod h1:Q28U+75mpCaSCDowNEmhIo/rmgdkqmkmzI7N6TGR4UY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v0.8.0 h1:T028gtTPiYt/RMUfs8nVsAL7FDQrfLlrm/NnRG/zcC4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v0.8.0/go.mod h1:cw4zVQgBby0Z5f2v0itn6se2dDP17nTjbZFXW5uPyHA= github.com/Azure/azure-service-bus-go v0.11.5/go.mod h1:MI6ge2CuQWBVq+ly456MY7XqNLJip5LO1iSFodbNLbU= github.com/Azure/azure-storage-blob-go v0.14.0/go.mod h1:SMqIBi+SuiQH32bvyjngEewEeXoPfKMgWlBDaYf6fck= github.com/Azure/azure-storage-blob-go v0.15.0 h1:rXtgp8tN1p29GvpGgfJetavIG0V7OgcSXPpwp3tx6qk= @@ -588,14 +1243,12 @@ github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSW github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= github.com/Azure/go-autorest/autorest v0.11.19/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= github.com/Azure/go-autorest/autorest v0.11.22/go.mod h1:BAWYUWGPEtKPzjVkp0Q6an0MJcJDsoh5Z1BFAEFs4Xs= -github.com/Azure/go-autorest/autorest v0.11.28/go.mod h1:MrkzG3Y3AH668QyF9KRk5neJnGgmhQ6krbhR8Q5eMvA= github.com/Azure/go-autorest/autorest v0.11.29 h1:I4+HL/JDvErx2LjyzaVxllw2lRDB5/BT2Bm4g20iqYw= github.com/Azure/go-autorest/autorest v0.11.29/go.mod h1:ZtEzC4Jy2JDrZLxvWs8LrBWEBycl1hbT1eknI8MtfAs= github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A= github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M= github.com/Azure/go-autorest/autorest/adal v0.9.14/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M= github.com/Azure/go-autorest/autorest/adal v0.9.17/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= -github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= github.com/Azure/go-autorest/autorest/adal v0.9.22/go.mod h1:XuAbAEUv2Tta//+voMI038TrJBqjKam0me7qR+L8Cmk= github.com/Azure/go-autorest/autorest/adal v0.9.23 h1:Yepx8CvFxwNKpH6ja7RZ+sKX+DWYNldbLiALMC3BTz8= github.com/Azure/go-autorest/autorest/adal v0.9.23/go.mod h1:5pcMqFkdPhviJdlEy3kC/v1ZLnQl0MH6XA5YCcMhy4c= @@ -616,41 +1269,42 @@ github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUM github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e h1:NeAW1fUYUEWhft7pkxDf6WoUvEZJ/uOKsvtpjLnn8MU= github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= -github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDmCTgBzIEPFElEF+FK0SdjAor06dRq2Go927dnQ6o= -github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1 h1:WpB/QDNLpMw72xHJc34BNNykqSOeEJDAWkhf0u12/Jk= +github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4= +github.com/AzureAD/microsoft-authentication-library-for-go v0.7.0/go.mod h1:BDJ5qMFKx9DugEg3+uQSDCdbYPr5s9vBTrL9P8TpqOU= github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1 h1:DzHpqpoJVaCgOUdVHxE8QB52S6NiVdDQvGlny1qvPqA= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo= -github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/Code-Hex/go-generics-cache v1.3.1 h1:i8rLwyhoyhaerr7JpjtYjJZUcCbWOdiYO3fZXLiEC4g= +github.com/Code-Hex/go-generics-cache v1.3.1/go.mod h1:qxcC9kRVrct9rHeiYpFWSoW1vxyillCVzX13KZG8dl4= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= -github.com/DataDog/datadog-go v4.0.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/FZambia/eagle v0.1.0 h1:9gyX6x+xjoIfglgyPTcYm7dvY7FJ93us1QY5De4CyXA= github.com/FZambia/eagle v0.1.0/go.mod h1:YjGSPVkQTNcVLfzEUQJNgW9ScPR0K4u/Ky0yeFa4oDA= github.com/GoogleCloudPlatform/cloudsql-proxy v1.29.0/go.mod h1:spvB9eLJH9dutlbPSRmHvSXXHOwGRyeXh1jVdquA2G8= github.com/HdrHistogram/hdrhistogram-go v1.1.0/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM= github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= +github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c h1:RGWPOewvKIROun94nF7v2cua9qP+thov/7M50KEoeSU= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= -github.com/Masterminds/semver/v3 v3.0.3/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Masterminds/sprig/v3 v3.2.1/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8= github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= -github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= -github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= -github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= @@ -658,7 +1312,6 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= -github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= @@ -676,7 +1329,6 @@ github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f/go.mod h1:f3H github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= -github.com/ajg/form v0.0.0-20160822230020-523a5da1a92f/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= @@ -684,33 +1336,44 @@ github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3 github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= github.com/alecthomas/kingpin/v2 v2.3.1/go.mod h1:oYL5vtsvEHZGHxU7DMp32Dvx+qL+ptGn6lWaot2vCNE= github.com/alecthomas/kingpin/v2 v2.3.2/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= +github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 h1:ez/4by2iGztzR4L0zgAOR8lTQK9VlyBVVd7G4omaOQs= +github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= github.com/alicebob/miniredis/v2 v2.30.1 h1:HM1rlQjq1bm9yQcsawJqSZBJ9AYgxvjkMsNtddh90+g= github.com/alicebob/miniredis/v2 v2.30.1/go.mod h1:b25qWj4fCEsBeAAR2mlb0ufImGC6uH3VlUfb/HS5zKg= -github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df h1:7RFfzj4SSt6nnvCPbCqijJi1nWCd+TqAT3bYCStRC18= github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df/go.mod h1:pSwJ0fSY5KhvocuWSx4fz3BA8OrA1bQn+K1Eli3BRwM= github.com/apache/arrow/go/arrow v0.0.0-20210223225224-5bea62493d91/go.mod h1:c9sxoIT3YgLxH4UhLOCKaBlEojuMhVYpk4Ntv3opUTQ= +github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40 h1:q4dksr6ICHXqG5hm0ZW5IHyeEJXoIJSOZeBLmWPNeIQ= +github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40/go.mod h1:Q7yQnSMnLvcXlZ8RV+jwz/6y1rQTqbX6C82SndT52Zs= github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= -github.com/apache/arrow/go/v13 v13.0.0 h1:kELrvDQuKZo8csdWYqBQfyi431x6Zs/YJTEgUuSVcWk= -github.com/apache/arrow/go/v13 v13.0.0/go.mod h1:W69eByFNO0ZR30q1/7Sr9d83zcVZmF2MiP3fFYAWJOc= +github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI= +github.com/apache/arrow/go/v12 v12.0.0/go.mod h1:d+tV/eHZZ7Dz7RPrFKtPK02tpr+c9/PEd/zm8mDS9Vg= +github.com/apache/arrow/go/v15 v15.0.0 h1:1zZACWf85oEZY5/kd9dsQS7i+2G5zVQcbKTHgslqHNA= +github.com/apache/arrow/go/v15 v15.0.0/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= +github.com/apache/thrift v0.18.1 h1:lNhK/1nqjbwbiOPDBPFJVKxgDEGSepKuTh6OLiXW8kg= +github.com/apache/thrift v0.18.1/go.mod h1:rdQn/dCcDKEWjjylUeueum4vQEjG2v8v2PqriUnbr+I= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= @@ -721,28 +1384,24 @@ github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= -github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= -github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= -github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/aws/aws-sdk-go v1.23.19/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.40.45/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= -github.com/aws/aws-sdk-go v1.44.217/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= -github.com/aws/aws-sdk-go v1.44.317/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= -github.com/aws/aws-sdk-go v1.44.325 h1:jF/L99fJSq/BfiLmUOflO/aM+LwcqBm0Fe/qTK5xxuI= -github.com/aws/aws-sdk-go v1.44.325/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/aws/aws-sdk-go v1.48.14/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= +github.com/aws/aws-sdk-go v1.50.8 h1:gY0WoOW+/Wz6XmYSgDH9ge3wnAevYDSQWPxxJvqAkP4= +github.com/aws/aws-sdk-go v1.50.8/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= github.com/aws/aws-sdk-go-v2 v1.9.1/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= github.com/aws/aws-sdk-go-v2 v1.16.2 h1:fqlCk6Iy3bnCumtrLz9r3mJ/2gUT0pJ0wLFVIdWh+JA= @@ -783,13 +1442,14 @@ github.com/aws/aws-sdk-go-v2/service/sso v1.11.3 h1:frW4ikGcxfAEDfmQqWgMLp+F1n4n github.com/aws/aws-sdk-go-v2/service/sso v1.11.3/go.mod h1:7UQ/e69kU7LDPtY40OyoHYgRmgfGM4mgsLYtcObdveU= github.com/aws/aws-sdk-go-v2/service/sts v1.16.3 h1:cJGRyzCSVwZC7zZZ1xbx9m32UnrKydRYhOvcD1NYP9Q= github.com/aws/aws-sdk-go-v2/service/sts v1.16.3/go.mod h1:bfBj0iVmsUyUg4weDB4NxktD9rDGeKSVWnjTnwbx9b8= -github.com/aws/aws-xray-sdk-go v0.9.4/go.mod h1:XtMKdBQfpVut+tJEwI7+dJFRxxRdxHDyVNp2tHXRq04= github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= github.com/aws/smithy-go v1.11.2 h1:eG/N+CcUMAvsdffgMvjMKwfyDzIkjM6pfxMJ8Mzc6mE= github.com/aws/smithy-go v1.11.2/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM= github.com/axiomhq/hyperloglog v0.0.0-20191112132149-a4c4c47bc57f h1:y06x6vGnFYfXUoVMbrcP1Uzpj4JG01eB5vRps9G8agM= github.com/axiomhq/hyperloglog v0.0.0-20191112132149-a4c4c47bc57f/go.mod h1:2stgcRjl6QmW+gU2h5E7BQXg4HU0gzxKWDuT5HviN9s= github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/beevik/etree v1.2.0 h1:l7WETslUG/T+xOPs47dtd6jov2Ii/8/OjCldk5fYfQw= github.com/beevik/etree v1.2.0/go.mod h1:aiPf89g/1k3AShMVAzriilpcE4R/Vuor90y83zVZWFc= @@ -828,7 +1488,6 @@ github.com/blugelabs/ice v1.0.0 h1:um7wf9e6jbkTVCrOyQq3tKK43fBMOvLUYxbj3Qtc4eo= github.com/blugelabs/ice v1.0.0/go.mod h1:gNfFPk5zM+yxJROhthxhVQYjpBO9amuxWXJQ2Lo+IbQ= github.com/bmatcuk/doublestar v1.1.1 h1:YroD6BJCZBYx06yYFEWvUuKVWQn3vLLQAVmDmvTSaiQ= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/bmatcuk/doublestar/v2 v2.0.3/go.mod h1:QMmcs3H2AUQICWhfzLXz+IYln8lRQmTZRptLie8RgRw= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= @@ -842,6 +1501,7 @@ github.com/bufbuild/connect-go v1.10.0 h1:QAJ3G9A1OYQW2Jbk3DeoJbkCxuKArrvZgDt47m github.com/bufbuild/connect-go v1.10.0/go.mod h1:CAIePUgkDR5pAFaylSMtNK45ANQjp9JvpluG20rhpV8= github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/buildkite/yaml v2.1.0+incompatible h1:xirI+ql5GzfikVNDmt+yeiXpf/v1Gt03qXTtT5WXdr8= github.com/buildkite/yaml v2.1.0+incompatible/go.mod h1:UoU8vbcwu1+vjZq01+KrpSeLBgQQIjL/H7Y6KwikUrI= @@ -852,11 +1512,8 @@ github.com/caio/go-tdigest v3.1.0+incompatible h1:uoVMJ3Q5lXmVLCCqaMGHLBWnbGoN6L github.com/caio/go-tdigest v3.1.0+incompatible/go.mod h1:sHQM/ubZStBUmF1WbB8FAm8q9GjDajLC5T7ydxE3JHI= github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= github.com/casbin/casbin/v2 v2.37.0/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg= -github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= -github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= -github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -873,8 +1530,9 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= -github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2 h1:XCdvHbz3LhewBHN7+mQPx0sg/Hxil/1USnBmxkjHcmY= -github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U= +github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89 h1:aPflPkRFkVwbW6dmcVqfgwp1i+UWGFH6VgR1Jim5Ygc= +github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= @@ -882,14 +1540,14 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= -github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575/go.mod h1:9d6lWj8KzO/fd/NrVaLscBKmPigpZpn5YawRPw+e3Yo= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -902,15 +1560,15 @@ github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20230112175826-46e39c7b9b43/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4 h1:/inchEIKaYC1Akx+H+gqO04wryn5h75LSazbRlnya1k= +github.com/cncf/xds/go v0.0.0-20230310173818-32f1caf87195/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20230428030218-4003588d1b74/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20231128003011-0fa0005c9caa h1:jQCWAUqqlij9Pgj2i/PB79y4KOPYVyFYdROxgaCwdTQ= +github.com/cncf/xds/go v0.0.0-20231128003011-0fa0005c9caa/go.mod h1:x/1Gn8zydmfq8dk6e9PdstVsDgu9RuyIIJqAaF//0IM= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/cockroachdb/apd/v2 v2.0.2 h1:weh8u7Cneje73dDh+2tEVLUvyBc89iwepWCD8b8034E= github.com/cockroachdb/apd/v2 v2.0.2/go.mod h1:DDxRlzC2lo3/vSlmSoS7JkqbbrARPuFOGr0B9pvN3Gw= github.com/cockroachdb/cockroach-go v0.0.0-20181001143604-e0a95dfd547c/go.mod h1:XGLbWH/ujMcbPbhZq52Nv6UrCghb1yGn//133kEsvDk= -github.com/cockroachdb/cockroach-go v0.0.0-20190925194419-606b3d062051/go.mod h1:XGLbWH/ujMcbPbhZq52Nv6UrCghb1yGn//133kEsvDk= -github.com/cockroachdb/cockroach-go v0.0.0-20200312223839-f565e4789405/go.mod h1:XGLbWH/ujMcbPbhZq52Nv6UrCghb1yGn//133kEsvDk= github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= github.com/cockroachdb/errors v1.9.1 h1:yFVvsI0VxmRShfawbt/laCIDy/mtTqqnvoNgiy5bEV8= @@ -921,14 +1579,8 @@ github.com/cockroachdb/redact v1.1.3 h1:AKZds10rFSIj7qADf0g46UixK8NNLwWTNdCIGS5w github.com/cockroachdb/redact v1.1.3/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM= -github.com/codegangsta/negroni v1.0.0/go.mod h1:v0y3T5G7Y1UlFfyxFn/QLRU4a2EuNau2iZY63YTKWo0= github.com/containerd/containerd v1.2.7/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.3.4/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= -github.com/containerd/containerd v1.4.3/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= -github.com/containerd/continuity v0.0.0-20181203112020-004b46473808/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= -github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= -github.com/containerd/continuity v0.0.0-20200107194136-26c1120b8d41/go.mod h1:Dq467ZllaHgAtVp4p1xUQWBrFXR9s/wyoTpG8zOJGkY= -github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= @@ -939,22 +1591,17 @@ github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/coreos/go-systemd/v22 v22.4.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/cristalhq/jwt/v4 v4.0.2 h1:g/AD3h0VicDamtlM70GWGElp8kssQEv+5wYd7L9WOhU= -github.com/cristalhq/jwt/v4 v4.0.2/go.mod h1:HnYraSNKDRag1DZP92rYHyrjyQHnVEHPNqesmzs+miQ= -github.com/cucumber/godog v0.8.1/go.mod h1:vSh3r/lM+psC1BPXvdkSEuNjmXfpVqrMGYAElF6hxnA= github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8= github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg= github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4/go.mod h1:+bmmJDNmKlhWNG+gwWCkaBoTy39Fs+bzRxVBzoTQbIc= @@ -965,21 +1612,14 @@ github.com/cznic/ql v1.2.0/go.mod h1:FbpzhyZrqr0PVlK6ury+PoW3T0ODUV22OeWIxcaOrSE github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65/go.mod h1:q2w6Bg5jeox1B+QkJ6Wp/+Vn0G/bo3f1uY7Fn3vivIQ= github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc= github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc/go.mod h1:Y1SNZ4dRUOKXshKUbwUapqNncRrho4mkjQebgEHZLj8= -github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14/go.mod h1:Sth2QfxfATb/nW4EsrSi2KyJmbcniZ8TgTaji17D6ms= -github.com/dave/brenda v1.1.0/go.mod h1:4wCUr6gSlu5/1Tk7akE5X7UorwiQ8Rij0SKH3/BGMOM= -github.com/dave/courtney v0.3.0/go.mod h1:BAv3hA06AYfNUjfjQr+5gc6vxeBVOupLqrColj+QSD8= github.com/dave/dst v0.27.2 h1:4Y5VFTkhGLC1oddtNwuxxe36pnyLxMFXT51FOzH8Ekc= github.com/dave/dst v0.27.2/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= -github.com/dave/gopackages v0.0.0-20170318123100-46e7023ec56e/go.mod h1:i00+b/gKdIDIxuLDFob7ustLAVqhsZRk2qVZrArELGQ= -github.com/dave/jennifer v1.4.0/go.mod h1:fIb+770HOpJ2fmN9EPPKOqm1vMGhB+TwXKMZhrIygKg= github.com/dave/jennifer v1.5.0 h1:HmgPN93bVDpkQyYbqhCHj5QlgvUkvEOzMyEvKLgCRrg= github.com/dave/jennifer v1.5.0/go.mod h1:4MnyiFIlZS3l5tSDn8VnzE6ffAhYBMB2SZntBsZGUok= -github.com/dave/kerr v0.0.0-20170318121727-bc25dd6abe8e/go.mod h1:qZqlPyPvfsDJt+3wHJ1EvSXDuVjFTK0j2p/ca+gtsb8= -github.com/dave/patsy v0.0.0-20210517141501-957256f50cba/go.mod h1:qfR88CgEGLoiqDaE+xxDCi5QA5v4vUoW0UCX2Nd5Tlc= -github.com/dave/rebecca v0.9.1/go.mod h1:N6XYdMD/OKw3lkF3ywh8Z6wPGuwNFDNtWYEMFWEmXBA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= github.com/deepmap/oapi-codegen v1.12.4 h1:pPmn6qI9MuOtCz82WY2Xaw46EQjgvxednXXrP7g5Q2s= github.com/deepmap/oapi-codegen v1.12.4/go.mod h1:3lgHGMu6myQ2vqbbTXH2H1o4eXFTGnFiDaOaKKl5yas= @@ -989,23 +1629,16 @@ github.com/dennwc/varint v1.0.0 h1:kGNFFSSw8ToIy3obO/kKr8U9GZYUAxQEVuix4zfDWzE= github.com/dennwc/varint v1.0.0/go.mod h1:hnItb35rvZvJrbTALZtY/iQfDs48JKRG1RPpgziApxA= github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY= github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4= -github.com/dgraph-io/ristretto v0.0.1/go.mod h1:T40EBc7CJke8TkpiYfGGKAeFjSaxuFXhuXRyumBd6RE= -github.com/dgraph-io/ristretto v0.0.2/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= -github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= -github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= -github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= -github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= github.com/dgryski/go-metro v0.0.0-20211217172704-adc40b04c140 h1:y7y0Oa6UawqTFPCDw9JG6pdKt4F9pAhHv0B7FMGaGD0= github.com/dgryski/go-metro v0.0.0-20211217172704-adc40b04c140/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dhui/dktest v0.3.0/go.mod h1:cyzIUfGsBEbZ6BT7tnXqAShHSXCZhSNmFl70sZ7c1yc= -github.com/digitalocean/godo v1.97.0 h1:p9w1yCcWMZcxFSLPToNGXA96WfUVLXqoHti6GzVomL4= -github.com/digitalocean/godo v1.97.0/go.mod h1:NRpFznZFvhHjBoqZAaOD3khVzsJ3EibzKqFL4R60dmA= +github.com/digitalocean/godo v1.106.0 h1:m5iErwl3xHovGFlawd50n54ntgXHt1BLsvU6BXsVxEU= +github.com/digitalocean/godo v1.106.0/go.mod h1:R6EmmWI8CT1+fCtjWY9UCB+L5uufuZH13wk3YhxycCs= github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/dlmiddlecote/sqlstats v1.0.2 h1:gSU11YN23D/iY50A2zVYwgXgy072khatTsIW6UPjUtI= @@ -1015,8 +1648,8 @@ github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/docker/distribution v2.7.0+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/distribution v2.8.1+incompatible h1:Q50tZOPR6T/hjNsyc9g8/syEs6bk8XXApsHjKukMl68= -github.com/docker/distribution v2.8.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= +github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/go-connections v0.3.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= @@ -1038,24 +1671,17 @@ github.com/drone/runner-go v1.12.0 h1:zUjDj9ylsJ4n4Mvy4znddq/Z4EBzcUXzTltpzokKtg github.com/drone/runner-go v1.12.0/go.mod h1:vu4pPPYDoeN6vdYQAY01GGGsAIW4aLganJNaa8Fx8zE= github.com/drone/signal v1.0.0/go.mod h1:S8t92eFT0g4WUgEc/LxG+LCuiskpMNsG0ajAMGnyZpc= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/dustin/go-humanize v0.0.0-20180713052910-9f541cc9db5d/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= -github.com/ecordell/optgen v0.0.6 h1:aSknPe6ZUBrjwHGp2+6XfmfCGYGD6W0ZDfCmmsrS7s4= -github.com/ecordell/optgen v0.0.6/go.mod h1:bAPkLVWcBlTX5EkXW0UTPRj3+yjq2I6VLgH8OasuQEM= github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ= github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q= github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= -github.com/elastic/go-sysinfo v1.1.1/go.mod h1:i1ZYdU10oLNfRzq4vq62BEwD2fH8KaWh6eh0ikPT9F0= -github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU= -github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= -github.com/elazarl/goproxy v0.0.0-20181003060214-f58a169a71a5/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 h1:1L0aalTpPz7YlMxETKpmQoWMBkeiuorElZIXoNmgiPE= github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= @@ -1063,7 +1689,7 @@ github.com/elazarl/goproxy/ext v0.0.0-20220115173737-adb46da277ac h1:9yrT5tmn9Zc github.com/elazarl/goproxy/ext v0.0.0-20220115173737-adb46da277ac/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= github.com/emicklei/go-restful/v3 v3.8.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/emicklei/go-restful/v3 v3.10.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.10.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emicklei/proto v1.10.0 h1:pDGyFRVV5RvV+nkBK9iy3q67FBy9Xa7vwrOTE+g5aGw= @@ -1080,16 +1706,23 @@ github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go. github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= github.com/envoyproxy/go-control-plane v0.10.3/go.mod h1:fJJn/j26vwOu972OllsvAgJJM//w9BV6Fxbg2LuVd34= github.com/envoyproxy/go-control-plane v0.11.0/go.mod h1:VnHyVMpzcLvCFt9yUz1UnCwHLhwx1WguiVDV7pTG/tI= -github.com/envoyproxy/go-control-plane v0.11.1 h1:wSUXTlLfiAQRWs2F+p+EKOY9rUyis1MyGqJ2DIk5HpM= +github.com/envoyproxy/go-control-plane v0.11.1-0.20230524094728-9239064ad72f/go.mod h1:sfYdkwUW4BA3PbKjySwjJy+O4Pu0h62rlqCMHNk+K+Q= github.com/envoyproxy/go-control-plane v0.11.1/go.mod h1:uhMcXKCQMEJHiAb0w+YGefQLaTEw+YhGluxZkrTmD0g= +github.com/envoyproxy/go-control-plane v0.12.0 h1:4X+VP1GHd1Mhj6IB5mMeGbLCleqxjletLK6K0rbxyZI= +github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.6.7/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo= github.com/envoyproxy/protoc-gen-validate v0.9.1/go.mod h1:OKNgG7TCp5pF4d6XftA0++PMirau2/yoOwVac3AbF2w= -github.com/envoyproxy/protoc-gen-validate v1.0.2 h1:QkIBuU5k+x7/QXPvPPnWXWlCdaBFApVqftFV6k087DA= +github.com/envoyproxy/protoc-gen-validate v0.10.0/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= +github.com/envoyproxy/protoc-gen-validate v0.10.1/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= +github.com/envoyproxy/protoc-gen-validate v1.0.1/go.mod h1:0vj8bNkYbSTNS2PIyH87KZaeN4x9zpL9Qt8fQC7d+vs= github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= +github.com/envoyproxy/protoc-gen-validate v1.0.4 h1:gVPz/FMfvh57HdSJQyvBtF00j8JU4zdyUgIUNhlgg0A= +github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew= github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw= -github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= @@ -1099,12 +1732,10 @@ github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYF github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= -github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/flowstack/go-jsonschema v0.1.1/go.mod h1:yL7fNggx1o8rm9RlgXv7hTBWxdBM0rVwpMwimd3F3N0= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= @@ -1118,10 +1749,11 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= -github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fsouza/fake-gcs-server v1.7.0/go.mod h1:5XIRs4YvwNbNoz+1JF8j6KLAyDh7RHGAyAK3EP2EsNk= +github.com/fullstorydev/grpchan v1.1.1 h1:heQqIJlAv5Cnks9a70GRL2EJke6QQoUB25VGR6TZQas= +github.com/fullstorydev/grpchan v1.1.1/go.mod h1:f4HpiV8V6htfY/K44GWV1ESQzHBTq7DinhzqQ95lpgc= github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= github.com/gchaincl/sqlhooks v1.3.0 h1:yKPXxW9a5CjXaVf2HkQn6wn7TZARvbAOAelr3H8vK2Y= github.com/gchaincl/sqlhooks v1.3.0/go.mod h1:9BypXnereMT0+Ys8WGWHqzgkkOfHIhyeUCqXC24ra34= @@ -1137,11 +1769,8 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/gin-gonic/gin v1.7.3/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY= -github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= -github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A= github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-bindata/go-bindata v3.1.1+incompatible/go.mod h1:xK8Dsgwmeed+BBsSy2XTopBn/8uK2HWuGSnA11C3Joo= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -1155,8 +1784,8 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= -github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA= -github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= +github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= +github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= @@ -1180,113 +1809,75 @@ github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTg github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.2.3 h1:a9vnzlIBPQBBkeaR9IuMUfmVOrQlkoC4YfPoFkX3T7A= github.com/go-logr/zapr v1.2.3/go.mod h1:eIauM6P8qSvTw5o2ez6UEAfGjQKrxQTl5EoK+Qa2oG4= github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= -github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI= -github.com/go-openapi/analysis v0.17.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= -github.com/go-openapi/analysis v0.18.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= -github.com/go-openapi/analysis v0.19.2/go.mod h1:3P1osvZa9jKjb8ed2TPng3f0i/UY9snX6gxi44djMjk= -github.com/go-openapi/analysis v0.19.4/go.mod h1:3P1osvZa9jKjb8ed2TPng3f0i/UY9snX6gxi44djMjk= -github.com/go-openapi/analysis v0.19.5/go.mod h1:hkEAkxagaIvIP7VTn8ygJNkd4kAYON2rCu0v0ObL0AU= -github.com/go-openapi/analysis v0.19.10/go.mod h1:qmhS3VNFxBlquFJ0RGoDtylO9y4pgTAUNE9AEEMdlJQ= github.com/go-openapi/analysis v0.21.2/go.mod h1:HZwRk4RRisyG8vx2Oe6aqeSQcoxRp47Xkp3+K6q+LdY= -github.com/go-openapi/analysis v0.21.4 h1:ZDFLvSNxpDaomuCueM0BlSXxpANBlFYiBvr+GXrvIHc= github.com/go-openapi/analysis v0.21.4/go.mod h1:4zQ35W4neeZTqh3ol0rv/O8JBbka9QyAgQRPp9y3pfo= -github.com/go-openapi/errors v0.17.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= -github.com/go-openapi/errors v0.18.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= -github.com/go-openapi/errors v0.19.2/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= -github.com/go-openapi/errors v0.19.3/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= -github.com/go-openapi/errors v0.19.6/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= +github.com/go-openapi/analysis v0.21.5/go.mod h1:25YcZosX9Lwz2wBsrFrrsL8bmjjXdlyP6zsr2AMy29M= +github.com/go-openapi/analysis v0.22.0/go.mod h1:acDnkkCI2QxIo8sSIPgmp1wUlRohV7vfGtAIVae73b0= +github.com/go-openapi/analysis v0.22.2 h1:ZBmNoP2h5omLKr/srIC9bfqrUGzT6g6gNv03HE9Vpj0= +github.com/go-openapi/analysis v0.22.2/go.mod h1:pDF4UbZsQTo/oNuRfAWWd4dAh4yuYf//LYorPTjrpvo= github.com/go-openapi/errors v0.19.8/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= github.com/go-openapi/errors v0.19.9/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= -github.com/go-openapi/errors v0.20.0/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= github.com/go-openapi/errors v0.20.2/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= -github.com/go-openapi/errors v0.20.3/go.mod h1:Z3FlZ4I8jEGxjUK+bugx3on2mIAk4txuAOhlsB1FSgk= -github.com/go-openapi/errors v0.20.4 h1:unTcVm6PispJsMECE3zWgvG4xTiKda1LIR5rCRWLG6M= github.com/go-openapi/errors v0.20.4/go.mod h1:Z3FlZ4I8jEGxjUK+bugx3on2mIAk4txuAOhlsB1FSgk= -github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= -github.com/go-openapi/jsonpointer v0.18.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= -github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= +github.com/go-openapi/errors v0.21.0 h1:FhChC/duCnfoLj1gZ0BgaBmzhJC2SL/sJr8a2vAobSY= +github.com/go-openapi/errors v0.21.0/go.mod h1:jxNTMUxRCKj65yb/okJGEtahVd7uvWnuWfj53bse4ho= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.20.0 h1:ESKJdU9ASRfaPNOPRx12IUyA1vn3R9GiE3KYD14BXdQ= github.com/go-openapi/jsonpointer v0.20.0/go.mod h1:6PGzBjjIIumbLYysB73Klnms1mwnU4G3YHOECG3CedA= -github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= -github.com/go-openapi/jsonreference v0.18.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= -github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= -github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= +github.com/go-openapi/jsonpointer v0.20.1/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= +github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= +github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= github.com/go-openapi/jsonreference v0.20.1/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/loads v0.17.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= -github.com/go-openapi/loads v0.18.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= -github.com/go-openapi/loads v0.19.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= -github.com/go-openapi/loads v0.19.2/go.mod h1:QAskZPMX5V0C2gvfkGZzJlINuP7Hx/4+ix5jWFxsNPs= -github.com/go-openapi/loads v0.19.3/go.mod h1:YVfqhUCdahYwR3f3iiwQLhicVRvLlU/WO5WPaZvcvSI= -github.com/go-openapi/loads v0.19.5/go.mod h1:dswLCAdonkRufe/gSUC3gN8nTSaB9uaS2es0x5/IbjY= +github.com/go-openapi/jsonreference v0.20.3/go.mod h1:FviDZ46i9ivh810gqzFLl5NttD5q3tSlMLqLr6okedM= +github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU= +github.com/go-openapi/jsonreference v0.20.4/go.mod h1:5pZJyJP2MnYCpoeoMAql78cCHauHj0V9Lhc506VOpw4= github.com/go-openapi/loads v0.21.1/go.mod h1:/DtAMXXneXFjbQMGEtbamCZb+4x7eGwkvZCvBmwUG+g= -github.com/go-openapi/loads v0.21.2 h1:r2a/xFIYeZ4Qd2TnGpWDIQNcP80dIaZgf704za8enro= github.com/go-openapi/loads v0.21.2/go.mod h1:Jq58Os6SSGz0rzh62ptiu8Z31I+OTHqmULx5e/gJbNw= -github.com/go-openapi/runtime v0.0.0-20180920151709-4f900dc2ade9/go.mod h1:6v9a6LTXWQCdL8k1AO3cvqx5OtZY/Y9wKTgaoP6YRfA= -github.com/go-openapi/runtime v0.19.0/go.mod h1:OwNfisksmmaZse4+gpV3Ne9AyMOlP1lt4sK4FXt0O64= -github.com/go-openapi/runtime v0.19.4/go.mod h1:X277bwSUBxVlCYR3r7xgZZGKVvBd/29gLDlFGtJ8NL4= -github.com/go-openapi/runtime v0.19.15/go.mod h1:dhGWCTKRXlAfGnQG0ONViOZpjfg0m2gUt9nTQPQZuoo= -github.com/go-openapi/runtime v0.19.26/go.mod h1:BvrQtn6iVb2QmiVXRsFAm6ZCAZBpbVKFfN6QWCp582M= -github.com/go-openapi/runtime v0.26.0/go.mod h1:QgRGeZwrUcSHdeh4Ka9Glvo0ug1LC5WyE+EV88plZrQ= -github.com/go-openapi/runtime v0.26.2 h1:elWyB9MacRzvIVgAZCBJmqTi7hBzU0hlKD4IvfX0Zl0= -github.com/go-openapi/runtime v0.26.2/go.mod h1:O034jyRZ557uJKzngbMDJXkcKJVzXJiymdSfgejrcRw= -github.com/go-openapi/spec v0.17.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= -github.com/go-openapi/spec v0.18.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= -github.com/go-openapi/spec v0.19.2/go.mod h1:sCxk3jxKgioEJikev4fgkNmwS+3kuYdJtcsZsD5zxMY= -github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= -github.com/go-openapi/spec v0.19.6/go.mod h1:Hm2Jr4jv8G1ciIAo+frC/Ft+rR2kQDh8JHKHb3gWUSk= -github.com/go-openapi/spec v0.19.8/go.mod h1:Hm2Jr4jv8G1ciIAo+frC/Ft+rR2kQDh8JHKHb3gWUSk= +github.com/go-openapi/loads v0.21.3/go.mod h1:Y3aMR24iHbKHppOj91nQ/SHc0cuPbAr4ndY4a02xydc= +github.com/go-openapi/loads v0.21.5 h1:jDzF4dSoHw6ZFADCGltDb2lE4F6De7aWSpe+IcsRzT0= +github.com/go-openapi/loads v0.21.5/go.mod h1:PxTsnFBoBe+z89riT+wYt3prmSBP6GDAQh2l9H1Flz8= +github.com/go-openapi/runtime v0.27.1 h1:ae53yaOoh+fx/X5Eaq8cRmavHgDma65XPZuvBqvJYto= +github.com/go-openapi/runtime v0.27.1/go.mod h1:fijeJEiEclyS8BRurYE1DE5TLb9/KZl6eAdbzjsrlLU= github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= -github.com/go-openapi/spec v0.20.8/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= github.com/go-openapi/spec v0.20.9/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= -github.com/go-openapi/spec v0.20.11 h1:J/TzFDLTt4Rcl/l1PmyErvkqlJDncGvPTMnCI39I4gY= -github.com/go-openapi/spec v0.20.11/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= -github.com/go-openapi/strfmt v0.17.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= -github.com/go-openapi/strfmt v0.18.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= -github.com/go-openapi/strfmt v0.19.0/go.mod h1:+uW+93UVvGGq2qGaZxdDeJqSAqBqBdl+ZPMF/cC8nDY= -github.com/go-openapi/strfmt v0.19.2/go.mod h1:0yX7dbo8mKIvc3XSKp7MNfxw4JytCfCD6+bY1AVL9LU= -github.com/go-openapi/strfmt v0.19.3/go.mod h1:0yX7dbo8mKIvc3XSKp7MNfxw4JytCfCD6+bY1AVL9LU= -github.com/go-openapi/strfmt v0.19.4/go.mod h1:eftuHTlB/dI8Uq8JJOyRlieZf+WkkxUuk0dgdHXr2Qk= -github.com/go-openapi/strfmt v0.19.5/go.mod h1:eftuHTlB/dI8Uq8JJOyRlieZf+WkkxUuk0dgdHXr2Qk= +github.com/go-openapi/spec v0.20.12/go.mod h1:iSCgnBcwbMW9SfzJb8iYynXvcY6C/QFrI7otzF7xGM4= +github.com/go-openapi/spec v0.20.13/go.mod h1:8EOhTpBoFiask8rrgwbLC3zmJfz4zsCUueRuPM6GNkw= +github.com/go-openapi/spec v0.20.14 h1:7CBlRnw+mtjFGlPDRZmAMnq35cRzI91xj03HVyUi/Do= +github.com/go-openapi/spec v0.20.14/go.mod h1:8EOhTpBoFiask8rrgwbLC3zmJfz4zsCUueRuPM6GNkw= github.com/go-openapi/strfmt v0.21.0/go.mod h1:ZRQ409bWMj+SOgXofQAGTIo2Ebu72Gs+WaRADcS5iNg= github.com/go-openapi/strfmt v0.21.1/go.mod h1:I/XVKeLc5+MM5oPNN7P6urMOpuLXEcNrCX/rPGuWb0k= github.com/go-openapi/strfmt v0.21.3/go.mod h1:k+RzNO0Da+k3FrrynSNN8F7n/peCmQQqbbXjtDfvmGg= -github.com/go-openapi/strfmt v0.21.7/go.mod h1:adeGTkxE44sPyLk0JV235VQAO/ZXUr8KAzYjclFs3ew= -github.com/go-openapi/strfmt v0.21.9 h1:LnEGOO9qyEC1v22Bzr323M98G13paIUGPU7yeJtG9Xs= github.com/go-openapi/strfmt v0.21.9/go.mod h1:0k3v301mglEaZRJdDDGSlN6Npq4VMVU69DE0LUyf7uA= -github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= -github.com/go-openapi/swag v0.18.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= -github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/strfmt v0.21.10/go.mod h1:vNDMwbilnl7xKiO/Ve/8H8Bb2JIInBnH+lqiw6QWgis= +github.com/go-openapi/strfmt v0.22.0 h1:Ew9PnEYc246TwrEspvBdDHS4BVKXy/AOVsfqGDgAcaI= +github.com/go-openapi/strfmt v0.22.0/go.mod h1:HzJ9kokGIju3/K6ap8jL+OlGAbjpSv27135Yr9OivU4= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.19.7/go.mod h1:ao+8BpOPyKdpQz3AOJfbeEVpLmWAvlT1IfTe5McPyhY= -github.com/go-openapi/swag v0.19.9/go.mod h1:ao+8BpOPyKdpQz3AOJfbeEVpLmWAvlT1IfTe5McPyhY= -github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/validate v0.18.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4= -github.com/go-openapi/validate v0.19.2/go.mod h1:1tRCw7m3jtI8eNWEEliiAqUIcBztB2KDnRCRMUi7GTA= -github.com/go-openapi/validate v0.19.3/go.mod h1:90Vh6jjkTn+OT1Eefm0ZixWNFjhtOH7vS9k0lo6zwJo= -github.com/go-openapi/validate v0.19.10/go.mod h1:RKEZTUWDkxKQxN2jDT7ZnZi2bhZlbNMAuKvKB+IaGx8= +github.com/go-openapi/swag v0.22.5/go.mod h1:Gl91UqO+btAM0plGGxHqJcQZ1ZTy6jbmridBTsDy8A0= +github.com/go-openapi/swag v0.22.6/go.mod h1:Gl91UqO+btAM0plGGxHqJcQZ1ZTy6jbmridBTsDy8A0= +github.com/go-openapi/swag v0.22.9 h1:XX2DssF+mQKM2DHsbgZK74y/zj4mo9I99+89xUmuZCE= +github.com/go-openapi/swag v0.22.9/go.mod h1:3/OXnFfnMAwBD099SwYRk7GD3xOrr1iL7d/XNLXVVwE= github.com/go-openapi/validate v0.22.1/go.mod h1:rjnrwK57VJ7A8xqfpAOEKRH8yQSGUriMu5/zuPSQ1hg= -github.com/go-openapi/validate v0.22.3 h1:KxG9mu5HBRYbecRb37KRCihvGGtND2aXziBAv0NNfyI= -github.com/go-openapi/validate v0.22.3/go.mod h1:kVxh31KbfsxU8ZyoHaDbLBWU5CnMdqBUEtadQ2G4d5M= +github.com/go-openapi/validate v0.22.4/go.mod h1:qm6O8ZIcPVdSY5219468Jv7kBdGvkiZLPOmqnqTUZ2A= +github.com/go-openapi/validate v0.23.0 h1:2l7PJLzCis4YUGEoW6eoQw3WhyM65WSIcjX6SQnlfDw= +github.com/go-openapi/validate v0.23.0/go.mod h1:EeiAZ5bmpSIOJV1WLfyYF9qp/B1ZgSaEpHTJHtN5cbE= github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= @@ -1296,14 +1887,13 @@ github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GO github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= -github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48/go.mod h1:dZGr0i9PLlaaTD4H/hoZIDjQ+r6xq8mgbRzHZf7f2J8= -github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= -github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= +github.com/go-resty/resty/v2 v2.9.1/go.mod h1:4/GYJVjh9nhkhGR6AUNW3XhpDYNUr+Uvy9gV/VGZIy4= +github.com/go-resty/resty/v2 v2.10.0 h1:Qla4W/+TMmv0fOeeRqzEpXPLfTUnR5HZ1+lGs+CkiCo= +github.com/go-resty/resty/v2 v2.10.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= @@ -1321,270 +1911,46 @@ github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL github.com/go-zookeeper/zk v1.0.3 h1:7M2kwOsc//9VeeFiPtf+uSJlVpU66x9Ba5+8XK7/TDg= github.com/go-zookeeper/zk v1.0.3/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= -github.com/gobuffalo/attrs v0.1.0/go.mod h1:fmNpaWyHM0tRm8gCZWKx8yY9fvaNLo2PyzBNSrBZ5Hw= -github.com/gobuffalo/buffalo v0.12.8-0.20181004233540-fac9bb505aa8/go.mod h1:sLyT7/dceRXJUxSsE813JTQtA3Eb1vjxWfo/N//vXIY= -github.com/gobuffalo/buffalo v0.13.0/go.mod h1:Mjn1Ba9wpIbpbrD+lIDMy99pQ0H0LiddMIIDGse7qT4= -github.com/gobuffalo/buffalo-plugins v1.0.2/go.mod h1:pOp/uF7X3IShFHyobahTkTLZaeUXwb0GrUTb9ngJWTs= -github.com/gobuffalo/buffalo-plugins v1.0.4/go.mod h1:pWS1vjtQ6uD17MVFWf7i3zfThrEKWlI5+PYLw/NaDB4= -github.com/gobuffalo/buffalo-plugins v1.4.3/go.mod h1:uCzTY0woez4nDMdQjkcOYKanngeUVRO2HZi7ezmAjWY= -github.com/gobuffalo/buffalo-plugins v1.5.1/go.mod h1:jbmwSZK5+PiAP9cC09VQOrGMZFCa/P0UMlIS3O12r5w= -github.com/gobuffalo/buffalo-plugins v1.6.4/go.mod h1:/+N1aophkA2jZ1ifB2O3Y9yGwu6gKOVMtUmJnbg+OZI= -github.com/gobuffalo/buffalo-plugins v1.6.5/go.mod h1:0HVkbgrVs/MnPZ/FOseDMVanCTm2RNcdM0PuXcL1NNI= -github.com/gobuffalo/buffalo-plugins v1.6.7/go.mod h1:ZGZRkzz2PiKWHs0z7QsPBOTo2EpcGRArMEym6ghKYgk= -github.com/gobuffalo/buffalo-plugins v1.6.9/go.mod h1:yYlYTrPdMCz+6/+UaXg5Jm4gN3xhsvsQ2ygVatZV5vw= -github.com/gobuffalo/buffalo-plugins v1.6.11/go.mod h1:eAA6xJIL8OuynJZ8amXjRmHND6YiusVAaJdHDN1Lu8Q= -github.com/gobuffalo/buffalo-plugins v1.8.2/go.mod h1:9te6/VjEQ7pKp7lXlDIMqzxgGpjlKoAcAANdCgoR960= -github.com/gobuffalo/buffalo-plugins v1.8.3/go.mod h1:IAWq6vjZJVXebIq2qGTLOdlXzmpyTZ5iJG5b59fza5U= -github.com/gobuffalo/buffalo-plugins v1.9.4/go.mod h1:grCV6DGsQlVzQwk6XdgcL3ZPgLm9BVxlBmXPMF8oBHI= -github.com/gobuffalo/buffalo-plugins v1.10.0/go.mod h1:4osg8d9s60txLuGwXnqH+RCjPHj9K466cDFRl3PErHI= -github.com/gobuffalo/buffalo-plugins v1.11.0/go.mod h1:rtIvAYRjYibgmWhnjKmo7OadtnxuMG5ZQLr25ozAzjg= -github.com/gobuffalo/buffalo-plugins v1.15.0/go.mod h1:BqSx01nwgKUQr/MArXzFkSD0QvdJidiky1OKgyfgrK8= -github.com/gobuffalo/buffalo-pop v1.0.5/go.mod h1:Fw/LfFDnSmB/vvQXPvcXEjzP98Tc+AudyNWUBWKCwQ8= github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg= -github.com/gobuffalo/envy v1.6.4/go.mod h1:Abh+Jfw475/NWtYMEt+hnJWRiC8INKWibIMyNt1w2Mc= -github.com/gobuffalo/envy v1.6.5/go.mod h1:N+GkhhZ/93bGZc6ZKhJLP6+m+tCNPKwgSpH9kaifseQ= -github.com/gobuffalo/envy v1.6.6/go.mod h1:N+GkhhZ/93bGZc6ZKhJLP6+m+tCNPKwgSpH9kaifseQ= -github.com/gobuffalo/envy v1.6.7/go.mod h1:N+GkhhZ/93bGZc6ZKhJLP6+m+tCNPKwgSpH9kaifseQ= -github.com/gobuffalo/envy v1.6.8/go.mod h1:N+GkhhZ/93bGZc6ZKhJLP6+m+tCNPKwgSpH9kaifseQ= -github.com/gobuffalo/envy v1.6.9/go.mod h1:N+GkhhZ/93bGZc6ZKhJLP6+m+tCNPKwgSpH9kaifseQ= -github.com/gobuffalo/envy v1.6.10/go.mod h1:X0CFllQjTV5ogsnUrg+Oks2yTI+PU2dGYBJOEI2D1Uo= -github.com/gobuffalo/envy v1.6.11/go.mod h1:Fiq52W7nrHGDggFPhn2ZCcHw4u/rqXkqo+i7FB6EAcg= -github.com/gobuffalo/envy v1.6.12/go.mod h1:qJNrJhKkZpEW0glh5xP2syQHH5kgdmgsKss2Kk8PTP0= github.com/gobuffalo/envy v1.6.15/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= -github.com/gobuffalo/envy v1.7.1/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w= -github.com/gobuffalo/envy v1.8.1/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w= -github.com/gobuffalo/envy v1.9.0/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w= -github.com/gobuffalo/events v1.0.3/go.mod h1:Txo8WmqScapa7zimEQIwgiJBvMECMe9gJjsKNPN3uZw= -github.com/gobuffalo/events v1.0.7/go.mod h1:z8txf6H9jWhQ5Scr7YPLWg/cgXBRj8Q4uYI+rsVCCSQ= -github.com/gobuffalo/events v1.0.8/go.mod h1:A5KyqT1sA+3GJiBE4QKZibse9mtOcI9nw8gGrDdqYGs= -github.com/gobuffalo/events v1.1.3/go.mod h1:9yPGWYv11GENtzrIRApwQRMYSbUgCsZ1w6R503fCfrk= -github.com/gobuffalo/events v1.1.4/go.mod h1:09/YRRgZHEOts5Isov+g9X2xajxdvOAcUuAHIX/O//A= -github.com/gobuffalo/events v1.1.5/go.mod h1:3YUSzgHfYctSjEjLCWbkXP6djH2M+MLaVRzb4ymbAK0= -github.com/gobuffalo/events v1.1.7/go.mod h1:6fGqxH2ing5XMb3EYRq9LEkVlyPGs4oO/eLzh+S8CxY= -github.com/gobuffalo/events v1.1.8/go.mod h1:UFy+W6X6VbCWS8k2iT81HYX65dMtiuVycMy04cplt/8= -github.com/gobuffalo/events v1.1.9/go.mod h1:/0nf8lMtP5TkgNbzYxR6Bl4GzBy5s5TebgNTdRfRbPM= -github.com/gobuffalo/events v1.3.1/go.mod h1:9JOkQVoyRtailYVE/JJ2ZQ/6i4gTjM5t2HsZK4C1cSA= -github.com/gobuffalo/events v1.4.1/go.mod h1:SjXgWKpeSuvQDvGhgMz5IXx3Czu+IbL+XPLR41NvVQY= -github.com/gobuffalo/fizz v1.0.12/go.mod h1:C0sltPxpYK8Ftvf64kbsQa2yiCZY4RZviurNxXdAKwc= -github.com/gobuffalo/fizz v1.9.8/go.mod h1:w1FEn1yKNVCc49KnADGyYGRPH7jFON3ak4Bj1yUudHo= -github.com/gobuffalo/fizz v1.10.0/go.mod h1:J2XGPO0AfJ1zKw7+2BA+6FEGAkyEsdCOLvN93WCT2WI= -github.com/gobuffalo/flect v0.0.0-20180907193754-dc14d8acaf9f/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA= -github.com/gobuffalo/flect v0.0.0-20181002182613-4571df4b1daf/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA= -github.com/gobuffalo/flect v0.0.0-20181007231023-ae7ed6bfe683/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA= -github.com/gobuffalo/flect v0.0.0-20181018182602-fd24a256709f/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA= -github.com/gobuffalo/flect v0.0.0-20181019110701-3d6f0b585514/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA= -github.com/gobuffalo/flect v0.0.0-20181024204909-8f6be1a8c6c2/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA= -github.com/gobuffalo/flect v0.0.0-20181104133451-1f6e9779237a/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA= -github.com/gobuffalo/flect v0.0.0-20181114183036-47375f6d8328/go.mod h1:0HvNbHdfh+WOvDSIASqJOSxTOWSxCCUF++k/Y53v9rI= -github.com/gobuffalo/flect v0.0.0-20181210151238-24a2b68e0316/go.mod h1:en58vff74S9b99Eg42Dr+/9yPu437QjlNsO/hBYPuOk= -github.com/gobuffalo/flect v0.0.0-20190104192022-4af577e09bf2/go.mod h1:en58vff74S9b99Eg42Dr+/9yPu437QjlNsO/hBYPuOk= -github.com/gobuffalo/flect v0.0.0-20190117212819-a62e61d96794/go.mod h1:397QT6v05LkZkn07oJXXT6y9FCfwC8Pug0WA2/2mE9k= github.com/gobuffalo/flect v0.1.0/go.mod h1:d2ehjJqGOH/Kjqcoz+F7jHTBbmDb38yXA598Hb50EGs= github.com/gobuffalo/flect v0.1.1/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= github.com/gobuffalo/flect v0.1.3/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= -github.com/gobuffalo/flect v0.1.5/go.mod h1:W3K3X9ksuZfir8f/LrfVtWmCDQFfayuylOJ7sz/Fj80= -github.com/gobuffalo/flect v0.2.0/go.mod h1:W3K3X9ksuZfir8f/LrfVtWmCDQFfayuylOJ7sz/Fj80= -github.com/gobuffalo/flect v0.2.1/go.mod h1:vmkQwuZYhN5Pc4ljYQZzP+1sq+NEkK+lh20jmEmX3jc= -github.com/gobuffalo/genny v0.0.0-20180924032338-7af3a40f2252/go.mod h1:tUTQOogrr7tAQnhajMSH6rv1BVev34H2sa1xNHMy94g= -github.com/gobuffalo/genny v0.0.0-20181003150629-3786a0744c5d/go.mod h1:WAd8HmjMVrnkAZbmfgH5dLBUchsZfqzp/WS5sQz+uTM= -github.com/gobuffalo/genny v0.0.0-20181005145118-318a41a134cc/go.mod h1:WAd8HmjMVrnkAZbmfgH5dLBUchsZfqzp/WS5sQz+uTM= -github.com/gobuffalo/genny v0.0.0-20181007153042-b8de7d566757/go.mod h1:+oG5Ljrw04czAHbPXREwaFojJbpUvcIy4DiOnbEJFTA= -github.com/gobuffalo/genny v0.0.0-20181012161047-33e5f43d83a6/go.mod h1:+oG5Ljrw04czAHbPXREwaFojJbpUvcIy4DiOnbEJFTA= -github.com/gobuffalo/genny v0.0.0-20181017160347-90a774534246/go.mod h1:+oG5Ljrw04czAHbPXREwaFojJbpUvcIy4DiOnbEJFTA= -github.com/gobuffalo/genny v0.0.0-20181024195656-51392254bf53/go.mod h1:o9GEH5gn5sCKLVB5rHFC4tq40rQ3VRUzmx6WwmaqISE= -github.com/gobuffalo/genny v0.0.0-20181025145300-af3f81d526b8/go.mod h1:uZ1fFYvdcP8mu0B/Ynarf6dsGvp7QFIpk/QACUuFUVI= -github.com/gobuffalo/genny v0.0.0-20181027191429-94d6cfb5c7fc/go.mod h1:x7SkrQQBx204Y+O9EwRXeszLJDTaWN0GnEasxgLrQTA= -github.com/gobuffalo/genny v0.0.0-20181027195209-3887b7171c4f/go.mod h1:JbKx8HSWICu5zyqWOa0dVV1pbbXOHusrSzQUprW6g+w= -github.com/gobuffalo/genny v0.0.0-20181106193839-7dcb0924caf1/go.mod h1:x61yHxvbDCgQ/7cOAbJCacZQuHgB0KMSzoYcw5debjU= -github.com/gobuffalo/genny v0.0.0-20181107223128-f18346459dbe/go.mod h1:utQD3aKKEsdb03oR+Vi/6ztQb1j7pO10N3OBoowRcSU= -github.com/gobuffalo/genny v0.0.0-20181114215459-0a4decd77f5d/go.mod h1:kN2KZ8VgXF9VIIOj/GM0Eo7YK+un4Q3tTreKOf0q1ng= -github.com/gobuffalo/genny v0.0.0-20181119162812-e8ff4adce8bb/go.mod h1:BA9htSe4bZwBDJLe8CUkoqkypq3hn3+CkoHqVOW718E= -github.com/gobuffalo/genny v0.0.0-20181127225641-2d959acc795b/go.mod h1:l54xLXNkteX/PdZ+HlgPk1qtcrgeOr3XUBBPDbH+7CQ= -github.com/gobuffalo/genny v0.0.0-20181128191930-77e34f71ba2a/go.mod h1:FW/D9p7cEEOqxYA71/hnrkOWm62JZ5ZNxcNIVJEaWBU= -github.com/gobuffalo/genny v0.0.0-20181203165245-fda8bcce96b1/go.mod h1:wpNSANu9UErftfiaAlz1pDZclrYzLtO5lALifODyjuM= -github.com/gobuffalo/genny v0.0.0-20181203201232-849d2c9534ea/go.mod h1:wpNSANu9UErftfiaAlz1pDZclrYzLtO5lALifODyjuM= -github.com/gobuffalo/genny v0.0.0-20181206121324-d6fb8a0dbe36/go.mod h1:wpNSANu9UErftfiaAlz1pDZclrYzLtO5lALifODyjuM= -github.com/gobuffalo/genny v0.0.0-20181207164119-84844398a37d/go.mod h1:y0ysCHGGQf2T3vOhCrGHheYN54Y/REj0ayd0Suf4C/8= -github.com/gobuffalo/genny v0.0.0-20181211165820-e26c8466f14d/go.mod h1:sHnK+ZSU4e2feXP3PA29ouij6PUEiN+RCwECjCTB3yM= -github.com/gobuffalo/genny v0.0.0-20190104222617-a71664fc38e7/go.mod h1:QPsQ1FnhEsiU8f+O0qKWXz2RE4TiDqLVChWkBuh1WaY= -github.com/gobuffalo/genny v0.0.0-20190112155932-f31a84fcacf5/go.mod h1:CIaHCrSIuJ4il6ka3Hub4DR4adDrGoXGEEt2FbBxoIo= github.com/gobuffalo/genny v0.0.0-20190329151137-27723ad26ef9/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk= github.com/gobuffalo/genny v0.0.0-20190403191548-3ca520ef0d9e/go.mod h1:80lIj3kVJWwOrXWWMRzzdhW3DsrdjILVil/SFKBzF28= github.com/gobuffalo/genny v0.1.0/go.mod h1:XidbUqzak3lHdS//TPu2OgiFB+51Ur5f7CSnXZ/JDvo= github.com/gobuffalo/genny v0.1.1/go.mod h1:5TExbEyY48pfunL4QSXxlDOmdsD44RRq4mVZ0Ex28Xk= -github.com/gobuffalo/genny v0.2.0/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk= -github.com/gobuffalo/genny v0.3.0/go.mod h1:ywJ2CoXrTZj7rbS8HTbzv7uybnLKlsNSBhEQ+yFI3E8= -github.com/gobuffalo/genny v0.6.0/go.mod h1:Vigx9VDiNscYpa/LwrURqGXLSIbzTfapt9+K6gF1kTA= -github.com/gobuffalo/genny/v2 v2.0.5/go.mod h1:kRkJuAw9mdI37AiEYjV4Dl+TgkBDYf8HZVjLkqe5eBg= github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211/go.mod h1:vEHJk/E9DmhejeLeNt7UVvlSGv3ziL+djtTr3yyzcOw= -github.com/gobuffalo/github_flavored_markdown v1.0.4/go.mod h1:uRowCdK+q8d/RF0Kt3/DSalaIXbb0De/dmTqMQdkQ4I= -github.com/gobuffalo/github_flavored_markdown v1.0.5/go.mod h1:U0643QShPF+OF2tJvYNiYDLDGDuQmJZXsf/bHOJPsMY= -github.com/gobuffalo/github_flavored_markdown v1.0.7/go.mod h1:w93Pd9Lz6LvyQXEG6DktTPHkOtCbr+arAD5mkwMzXLI= -github.com/gobuffalo/github_flavored_markdown v1.1.0/go.mod h1:TSpTKWcRTI0+v7W3x8dkSKMLJSUpuVitlptCkpeY8ic= github.com/gobuffalo/gogen v0.0.0-20190315121717-8f38393713f5/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360= github.com/gobuffalo/gogen v0.1.0/go.mod h1:8NTelM5qd8RZ15VjQTFkAW6qOMx5wBbW4dSCS3BY8gg= github.com/gobuffalo/gogen v0.1.1/go.mod h1:y8iBtmHmGc4qa3urIyo1shvOD8JftTtfcKi+71xfDNE= -github.com/gobuffalo/gogen v0.2.0/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360= -github.com/gobuffalo/helpers v0.2.2/go.mod h1:xYbzUdCUpVzLwLnqV8HIjT6hmG0Cs7YIBCJkNM597jw= -github.com/gobuffalo/helpers v0.2.4/go.mod h1:NX7v27yxPDOPTgUFYmJ5ow37EbxdoLraucOGvMNawyk= -github.com/gobuffalo/helpers v0.5.0/go.mod h1:stpgxJ2C7T99NLyAxGUnYMM2zAtBk5NKQR0SIbd05j4= -github.com/gobuffalo/helpers v0.6.0/go.mod h1:pncVrer7x/KRvnL5aJABLAuT/RhKRR9klL6dkUOhyv8= -github.com/gobuffalo/helpers v0.6.1/go.mod h1:wInbDi0vTJKZBviURTLRMFLE4+nF2uRuuL2fnlYo7w4= -github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM= -github.com/gobuffalo/httptest v1.0.2/go.mod h1:7T1IbSrg60ankme0aDLVnEY0h056g9M1/ZvpVThtB7E= -github.com/gobuffalo/licenser v0.0.0-20180924033006-eae28e638a42/go.mod h1:Ubo90Np8gpsSZqNScZZkVXXAo5DGhTb+WYFIjlnog8w= -github.com/gobuffalo/licenser v0.0.0-20181025145548-437d89de4f75/go.mod h1:x3lEpYxkRG/XtGCUNkio+6RZ/dlOvLzTI9M1auIwFcw= -github.com/gobuffalo/licenser v0.0.0-20181027200154-58051a75da95/go.mod h1:BzhaaxGd1tq1+OLKObzgdCV9kqVhbTulxOpYbvMQWS0= -github.com/gobuffalo/licenser v0.0.0-20181109171355-91a2a7aac9a7/go.mod h1:m+Ygox92pi9bdg+gVaycvqE8RVSjZp7mWw75+K5NPHk= -github.com/gobuffalo/licenser v0.0.0-20181128165715-cc7305f8abed/go.mod h1:oU9F9UCE+AzI/MueCKZamsezGOOHfSirltllOVeRTAE= -github.com/gobuffalo/licenser v0.0.0-20181203160806-fe900bbede07/go.mod h1:ph6VDNvOzt1CdfaWC+9XwcBnlSTBz2j49PBwum6RFaU= -github.com/gobuffalo/licenser v0.0.0-20181211173111-f8a311c51159/go.mod h1:ve/Ue99DRuvnTaLq2zKa6F4KtHiYf7W046tDjuGYPfM= -github.com/gobuffalo/licenser v1.1.0/go.mod h1:ZVWE6uKUE3rGf7sedUHWVjNWrEgxaUQLVFL+pQiWpfY= -github.com/gobuffalo/logger v0.0.0-20181022175615-46cfb361fc27/go.mod h1:8sQkgyhWipz1mIctHF4jTxmJh1Vxhp7mP8IqbljgJZo= -github.com/gobuffalo/logger v0.0.0-20181027144941-73d08d2bb969/go.mod h1:7uGg2duHKpWnN4+YmyKBdLXfhopkAdVM6H3nKbyFbz8= -github.com/gobuffalo/logger v0.0.0-20181027193913-9cf4dd0efe46/go.mod h1:7uGg2duHKpWnN4+YmyKBdLXfhopkAdVM6H3nKbyFbz8= -github.com/gobuffalo/logger v0.0.0-20181109185836-3feeab578c17/go.mod h1:oNErH0xLe+utO+OW8ptXMSA5DkiSEDW1u3zGIt8F9Ew= -github.com/gobuffalo/logger v0.0.0-20181117211126-8e9b89b7c264/go.mod h1:5etB91IE0uBlw9k756fVKZJdS+7M7ejVhmpXXiSFj0I= -github.com/gobuffalo/logger v0.0.0-20181127160119-5b956e21995c/go.mod h1:+HxKANrR9VGw9yN3aOAppJKvhO05ctDi63w4mDnKv2U= github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2/go.mod h1:QdxcLw541hSGtBnhUc4gaNIXRjiDppFGaDqzbrBd3v8= -github.com/gobuffalo/logger v1.0.0/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs= -github.com/gobuffalo/logger v1.0.1/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs= -github.com/gobuffalo/logger v1.0.3/go.mod h1:SoeejUwldiS7ZsyCBphOGURmWdwUFXs0J7TCjEhjKxM= -github.com/gobuffalo/makr v1.1.5/go.mod h1:Y+o0btAH1kYAMDJW/TX3+oAXEu0bmSLLoC9mIFxtzOw= -github.com/gobuffalo/mapi v1.0.0/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= github.com/gobuffalo/mapi v1.0.2/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= -github.com/gobuffalo/mapi v1.1.0/go.mod h1:pqQ1XAqvpy/JYtRwoieNps2yU8MFiMxBUpAm2FBtQ50= -github.com/gobuffalo/mapi v1.2.1/go.mod h1:giGJ2AUESRepOFYAzWpq8Gf/s/QDryxoEHisQtFN3cY= -github.com/gobuffalo/meta v0.0.0-20181018155829-df62557efcd3/go.mod h1:XTTOhwMNryif3x9LkTTBO/Llrveezd71u3quLd0u7CM= -github.com/gobuffalo/meta v0.0.0-20181018192820-8c6cef77dab3/go.mod h1:E94EPzx9NERGCY69UWlcj6Hipf2uK/vnfrF4QD0plVE= -github.com/gobuffalo/meta v0.0.0-20181025145500-3a985a084b0a/go.mod h1:YDAKBud2FP7NZdruCSlmTmDOZbVSa6bpK7LJ/A/nlKg= -github.com/gobuffalo/meta v0.0.0-20181114191255-b130ebedd2f7/go.mod h1:K6cRZ29ozr4Btvsqkjvg5nDFTLOgTqf03KA70Ks0ypE= -github.com/gobuffalo/meta v0.0.0-20181127070345-0d7e59dd540b/go.mod h1:RLO7tMvE0IAKAM8wny1aN12pvEKn7EtkBLkUZR00Qf8= -github.com/gobuffalo/meta v0.0.0-20190120163247-50bbb1fa260d/go.mod h1:KKsH44nIK2gA8p0PJmRT9GvWJUdphkDUA8AJEvFWiqM= -github.com/gobuffalo/meta v0.0.0-20190329152330-e161e8a93e3b/go.mod h1:mCRSy5F47tjK8yaIDcJad4oe9fXxY5gLrx3Xx2spK+0= -github.com/gobuffalo/meta v0.3.0/go.mod h1:cpr6mrUX5H/B4wEP86Gdq568TK4+dKUD8oRPl698RUw= -github.com/gobuffalo/mw-basicauth v1.0.3/go.mod h1:dg7+ilMZOKnQFHDefUzUHufNyTswVUviCBgF244C1+0= -github.com/gobuffalo/mw-contenttype v0.0.0-20180802152300-74f5a47f4d56/go.mod h1:7EvcmzBbeCvFtQm5GqF9ys6QnCxz2UM1x0moiWLq1No= -github.com/gobuffalo/mw-csrf v0.0.0-20180802151833-446ff26e108b/go.mod h1:sbGtb8DmDZuDUQoxjr8hG1ZbLtZboD9xsn6p77ppcHo= -github.com/gobuffalo/mw-forcessl v0.0.0-20180802152810-73921ae7a130/go.mod h1:JvNHRj7bYNAMUr/5XMkZaDcw3jZhUZpsmzhd//FFWmQ= -github.com/gobuffalo/mw-i18n v0.0.0-20180802152014-e3060b7e13d6/go.mod h1:91AQfukc52A6hdfIfkxzyr+kpVYDodgAeT5cjX1UIj4= -github.com/gobuffalo/mw-paramlogger v0.0.0-20181005191442-d6ee392ec72e/go.mod h1:6OJr6VwSzgJMqWMj7TYmRUqzNe2LXu/W1rRW4MAz/ME= -github.com/gobuffalo/mw-tokenauth v0.0.0-20181001105134-8545f626c189/go.mod h1:UqBF00IfKvd39ni5+yI5MLMjAf4gX7cDKN/26zDOD6c= -github.com/gobuffalo/nulls v0.2.0/go.mod h1:w4q8RoSCEt87Q0K0sRIZWYeIxkxog5mh3eN3C/n+dUc= -github.com/gobuffalo/nulls v0.3.0/go.mod h1:UP49vd/k+bcaz6m0cHMyuk8oQ7XgLnkfxeiVoPAvBSs= -github.com/gobuffalo/packd v0.0.0-20181027182251-01ad393492c8/go.mod h1:SmdBdhj6uhOsg1Ui4SFAyrhuc7U4VCildosO5IDJ3lc= -github.com/gobuffalo/packd v0.0.0-20181027190505-aafc0d02c411/go.mod h1:SmdBdhj6uhOsg1Ui4SFAyrhuc7U4VCildosO5IDJ3lc= -github.com/gobuffalo/packd v0.0.0-20181027194105-7ae579e6d213/go.mod h1:SmdBdhj6uhOsg1Ui4SFAyrhuc7U4VCildosO5IDJ3lc= -github.com/gobuffalo/packd v0.0.0-20181031195726-c82734870264/go.mod h1:Yf2toFaISlyQrr5TfO3h6DB9pl9mZRmyvBGQb/aQ/pI= -github.com/gobuffalo/packd v0.0.0-20181104210303-d376b15f8e96/go.mod h1:Yf2toFaISlyQrr5TfO3h6DB9pl9mZRmyvBGQb/aQ/pI= -github.com/gobuffalo/packd v0.0.0-20181111195323-b2e760a5f0ff/go.mod h1:Yf2toFaISlyQrr5TfO3h6DB9pl9mZRmyvBGQb/aQ/pI= -github.com/gobuffalo/packd v0.0.0-20181114190715-f25c5d2471d7/go.mod h1:Yf2toFaISlyQrr5TfO3h6DB9pl9mZRmyvBGQb/aQ/pI= -github.com/gobuffalo/packd v0.0.0-20181124090624-311c6248e5fb/go.mod h1:Foenia9ZvITEvG05ab6XpiD5EfBHPL8A6hush8SJ0o8= -github.com/gobuffalo/packd v0.0.0-20181207120301-c49825f8f6f4/go.mod h1:LYc0TGKFBBFTRC9dg2pcRcMqGCTMD7T2BIMP7OBuQAA= -github.com/gobuffalo/packd v0.0.0-20181212173646-eca3b8fd6687/go.mod h1:LYc0TGKFBBFTRC9dg2pcRcMqGCTMD7T2BIMP7OBuQAA= github.com/gobuffalo/packd v0.0.0-20190315124812-a385830c7fc0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= -github.com/gobuffalo/packd v0.2.0/go.mod h1:k2CkHP3bjbqL2GwxwhxUy1DgnlbW644hkLC9iIUvZwY= -github.com/gobuffalo/packd v0.3.0/go.mod h1:zC7QkmNkYVGKPw4tHpBQ+ml7W/3tIebgeo1b36chA3Q= -github.com/gobuffalo/packd v1.0.0/go.mod h1:6VTc4htmJRFB7u1m/4LeMTWjFoYrUiBkU9Fdec9hrhI= -github.com/gobuffalo/packr v1.13.7/go.mod h1:KkinLIn/n6+3tVXMwg6KkNvWwVsrRAz4ph+jgpk3Z24= -github.com/gobuffalo/packr v1.15.0/go.mod h1:t5gXzEhIviQwVlNx/+3SfS07GS+cZ2hn76WLzPp6MGI= -github.com/gobuffalo/packr v1.15.1/go.mod h1:IeqicJ7jm8182yrVmNbM6PR4g79SjN9tZLH8KduZZwE= -github.com/gobuffalo/packr v1.19.0/go.mod h1:MstrNkfCQhd5o+Ct4IJ0skWlxN8emOq8DsoT1G98VIU= -github.com/gobuffalo/packr v1.20.0/go.mod h1:JDytk1t2gP+my1ig7iI4NcVaXr886+N0ecUga6884zw= -github.com/gobuffalo/packr v1.21.0/go.mod h1:H00jGfj1qFKxscFJSw8wcL4hpQtPe1PfU2wa6sg/SR0= -github.com/gobuffalo/packr v1.22.0/go.mod h1:Qr3Wtxr3+HuQEwWqlLnNW4t1oTvK+7Gc/Rnoi/lDFvA= -github.com/gobuffalo/packr/v2 v2.0.0-rc.8/go.mod h1:y60QCdzwuMwO2R49fdQhsjCPv7tLQFR0ayzxxla9zes= -github.com/gobuffalo/packr/v2 v2.0.0-rc.9/go.mod h1:fQqADRfZpEsgkc7c/K7aMew3n4aF1Kji7+lIZeR98Fc= -github.com/gobuffalo/packr/v2 v2.0.0-rc.10/go.mod h1:4CWWn4I5T3v4c1OsJ55HbHlUEKNWMITG5iIkdr4Px4w= -github.com/gobuffalo/packr/v2 v2.0.0-rc.11/go.mod h1:JoieH/3h3U4UmatmV93QmqyPUdf4wVM9HELaHEu+3fk= -github.com/gobuffalo/packr/v2 v2.0.0-rc.12/go.mod h1:FV1zZTsVFi1DSCboO36Xgs4pzCZBjB/tDV9Cz/lSaR8= -github.com/gobuffalo/packr/v2 v2.0.0-rc.13/go.mod h1:2Mp7GhBFMdJlOK8vGfl7SYtfMP3+5roE39ejlfjw0rA= -github.com/gobuffalo/packr/v2 v2.0.0-rc.14/go.mod h1:06otbrNvDKO1eNQ3b8hst+1010UooI2MFg+B2Ze4MV8= -github.com/gobuffalo/packr/v2 v2.0.0-rc.15/go.mod h1:IMe7H2nJvcKXSF90y4X1rjYIRlNMJYCxEhssBXNZwWs= github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= -github.com/gobuffalo/packr/v2 v2.4.0/go.mod h1:ra341gygw9/61nSjAbfwcwh8IrYL4WmR4IsPkPBhQiY= -github.com/gobuffalo/packr/v2 v2.5.2/go.mod h1:sgEE1xNZ6G0FNN5xn9pevVu4nywaxHvgup67xisti08= -github.com/gobuffalo/packr/v2 v2.7.1/go.mod h1:qYEvAazPaVxy7Y7KR0W8qYEE+RymX74kETFqjFoFlOc= -github.com/gobuffalo/packr/v2 v2.8.0/go.mod h1:PDk2k3vGevNE3SwVyVRgQCCXETC9SaONCNSXT1Q8M1g= -github.com/gobuffalo/plush v3.7.16+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI= -github.com/gobuffalo/plush v3.7.20+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI= -github.com/gobuffalo/plush v3.7.21+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI= -github.com/gobuffalo/plush v3.7.22+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI= -github.com/gobuffalo/plush v3.7.23+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI= -github.com/gobuffalo/plush v3.7.30+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI= -github.com/gobuffalo/plush v3.7.31+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI= -github.com/gobuffalo/plush v3.7.32+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI= -github.com/gobuffalo/plush v3.8.2+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI= -github.com/gobuffalo/plush v3.8.3+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI= -github.com/gobuffalo/plush/v4 v4.0.0/go.mod h1:ErFS3UxKqEb8fpFJT7lYErfN/Nw6vHGiDMTjxpk5bQ0= -github.com/gobuffalo/plushgen v0.0.0-20181128164830-d29dcb966cb2/go.mod h1:r9QwptTFnuvSaSRjpSp4S2/4e2D3tJhARYbvEBcKSb4= -github.com/gobuffalo/plushgen v0.0.0-20181203163832-9fc4964505c2/go.mod h1:opEdT33AA2HdrIwK1aibqnTJDVVKXC02Bar/GT1YRVs= -github.com/gobuffalo/plushgen v0.0.0-20181207152837-eedb135bd51b/go.mod h1:Lcw7HQbEVm09sAQrCLzIxuhFbB3nAgp4c55E+UlynR0= -github.com/gobuffalo/plushgen v0.0.0-20190104222512-177cd2b872b3/go.mod h1:tYxCozi8X62bpZyKXYHw1ncx2ZtT2nFvG42kuLwYjoc= -github.com/gobuffalo/plushgen v0.1.2/go.mod h1:3U71v6HWZpVER1nInTXeAwdoRNsRd4W8aeIa1Lyp+Bk= -github.com/gobuffalo/pop v4.8.2+incompatible/go.mod h1:DwBz3SD5SsHpTZiTubcsFWcVDpJWGsxjVjMPnkiThWg= -github.com/gobuffalo/pop v4.8.3+incompatible/go.mod h1:DwBz3SD5SsHpTZiTubcsFWcVDpJWGsxjVjMPnkiThWg= -github.com/gobuffalo/pop v4.8.4+incompatible/go.mod h1:DwBz3SD5SsHpTZiTubcsFWcVDpJWGsxjVjMPnkiThWg= -github.com/gobuffalo/pop v4.13.1+incompatible/go.mod h1:DwBz3SD5SsHpTZiTubcsFWcVDpJWGsxjVjMPnkiThWg= -github.com/gobuffalo/pop/v5 v5.0.11/go.mod h1:mZJHJbA3cy2V18abXYuVop2ldEJ8UZ2DK6qOekC5u5g= -github.com/gobuffalo/pop/v5 v5.3.1/go.mod h1:vcEDhh6cJ3WVENqJDFt/6z7zNb7lLnlN8vj3n5G9rYA= -github.com/gobuffalo/release v1.0.35/go.mod h1:VtHFAKs61vO3wboCec5xr9JPTjYyWYcvaM3lclkc4x4= -github.com/gobuffalo/release v1.0.38/go.mod h1:VtHFAKs61vO3wboCec5xr9JPTjYyWYcvaM3lclkc4x4= -github.com/gobuffalo/release v1.0.42/go.mod h1:RPs7EtafH4oylgetOJpGP0yCZZUiO4vqHfTHJjSdpug= -github.com/gobuffalo/release v1.0.52/go.mod h1:RPs7EtafH4oylgetOJpGP0yCZZUiO4vqHfTHJjSdpug= -github.com/gobuffalo/release v1.0.53/go.mod h1:FdF257nd8rqhNaqtDWFGhxdJ/Ig4J7VcS3KL7n/a+aA= -github.com/gobuffalo/release v1.0.54/go.mod h1:Pe5/RxRa/BE8whDpGfRqSI7D1a0evGK1T4JDm339tJc= -github.com/gobuffalo/release v1.0.61/go.mod h1:mfIO38ujUNVDlBziIYqXquYfBF+8FDHUjKZgYC1Hj24= -github.com/gobuffalo/release v1.0.72/go.mod h1:NP5NXgg/IX3M5XmHmWR99D687/3Dt9qZtTK/Lbwc1hU= -github.com/gobuffalo/release v1.1.1/go.mod h1:Sluak1Xd6kcp6snkluR1jeXAogdJZpFFRzTYRs/2uwg= -github.com/gobuffalo/release v1.1.3/go.mod h1:CuXc5/m+4zuq8idoDt1l4va0AXAn/OSs08uHOfMVr8E= -github.com/gobuffalo/release v1.1.6/go.mod h1:18naWa3kBsqO0cItXZNJuefCKOENpbbUIqRL1g+p6z0= -github.com/gobuffalo/release v1.7.0/go.mod h1:xH2NjAueVSY89XgC4qx24ojEQ4zQ9XCGVs5eXwJTkEs= -github.com/gobuffalo/shoulders v1.0.1/go.mod h1:V33CcVmaQ4gRUmHKwq1fiTXuf8Gp/qjQBUL5tHPmvbA= -github.com/gobuffalo/shoulders v1.0.4/go.mod h1:LqMcHhKRuBPMAYElqOe3POHiZ1x7Ry0BE8ZZ84Bx+k4= -github.com/gobuffalo/syncx v0.0.0-20181120191700-98333ab04150/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= -github.com/gobuffalo/syncx v0.0.0-20181120194010-558ac7de985f/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= -github.com/gobuffalo/syncx v0.1.0/go.mod h1:Mg/s+5pv7IgxEp6sA+NFpqS4o2x+R9dQNwbwT0iuOGQ= -github.com/gobuffalo/tags v2.0.11+incompatible/go.mod h1:9XmhOkyaB7UzvuY4UoZO4s67q8/xRMVJEaakauVQYeY= -github.com/gobuffalo/tags v2.0.14+incompatible/go.mod h1:9XmhOkyaB7UzvuY4UoZO4s67q8/xRMVJEaakauVQYeY= -github.com/gobuffalo/tags v2.0.15+incompatible/go.mod h1:9XmhOkyaB7UzvuY4UoZO4s67q8/xRMVJEaakauVQYeY= -github.com/gobuffalo/tags v2.1.0+incompatible/go.mod h1:9XmhOkyaB7UzvuY4UoZO4s67q8/xRMVJEaakauVQYeY= -github.com/gobuffalo/tags v2.1.7+incompatible/go.mod h1:9XmhOkyaB7UzvuY4UoZO4s67q8/xRMVJEaakauVQYeY= -github.com/gobuffalo/tags/v3 v3.0.2/go.mod h1:ZQeN6TCTiwAFnS0dNcbDtSgZDwNKSpqajvVtt6mlYpA= -github.com/gobuffalo/tags/v3 v3.1.0/go.mod h1:ZQeN6TCTiwAFnS0dNcbDtSgZDwNKSpqajvVtt6mlYpA= -github.com/gobuffalo/uuid v2.0.3+incompatible/go.mod h1:ErhIzkRhm0FtRuiE/PeORqcw4cVi1RtSpnwYrxuvkfE= -github.com/gobuffalo/uuid v2.0.4+incompatible/go.mod h1:ErhIzkRhm0FtRuiE/PeORqcw4cVi1RtSpnwYrxuvkfE= -github.com/gobuffalo/uuid v2.0.5+incompatible/go.mod h1:ErhIzkRhm0FtRuiE/PeORqcw4cVi1RtSpnwYrxuvkfE= -github.com/gobuffalo/validate v2.0.3+incompatible/go.mod h1:N+EtDe0J8252BgfzQUChBgfd6L93m9weay53EWFVsMM= -github.com/gobuffalo/validate v2.0.4+incompatible/go.mod h1:N+EtDe0J8252BgfzQUChBgfd6L93m9weay53EWFVsMM= -github.com/gobuffalo/validate/v3 v3.0.0/go.mod h1:HFpjq+AIiA2RHoQnQVTFKF/ZpUPXwyw82LgyDPxQ9r0= -github.com/gobuffalo/validate/v3 v3.1.0/go.mod h1:HFpjq+AIiA2RHoQnQVTFKF/ZpUPXwyw82LgyDPxQ9r0= -github.com/gobuffalo/validate/v3 v3.2.0/go.mod h1:PrhDOdDHxtN8KUgMvF3TDL0r1YZXV4sQnyFX/EmeETY= -github.com/gobuffalo/x v0.0.0-20181003152136-452098b06085/go.mod h1:WevpGD+5YOreDJznWevcn8NTmQEW5STSBgIkpkjzqXc= -github.com/gobuffalo/x v0.0.0-20181007152206-913e47c59ca7/go.mod h1:9rDPXaB3kXdKWzMc4odGQQdG2e2DIEmANy5aSJ9yesY= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA= -github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA= github.com/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4/go.mod h1:4Fw1eo5iaEhDUs8XyuhSVCVy52Jq3L+/3GJgYkwc+/0= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gofrs/uuid v3.1.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/gofrs/uuid/v3 v3.1.2/go.mod h1:xPwMqoocQ1L5G6pXX5BcE7N5jlzn2o19oqAKxwZW/kI= github.com/gogo/googleapis v0.0.0-20180223154316-0cd9801be74a/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= github.com/gogo/googleapis v1.4.1 h1:1Yx4Myt7BxzvUr5ldGSbwYiZG6t9wGBZ+8/fX3Wvtq0= @@ -1593,22 +1959,22 @@ github.com/gogo/protobuf v0.0.0-20170307180453-100ba4e88506/go.mod h1:r8qH/GZQm5 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= -github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/gogo/status v1.0.3/go.mod h1:SavQ51ycCLnc7dGyJxp8YAmudx8xqiVrRf+6IXRsugc= github.com/gogo/status v1.1.0/go.mod h1:BFv9nrluPLmrS0EmGVvLaPNmRosr9KapBYd5/hpY1WM= github.com/gogo/status v1.1.1 h1:DuHXlSFHNKqTQ+/ACf5Vs6r4X/dH2EgIzR9Vr+H65kg= github.com/gogo/status v1.1.1/go.mod h1:jpG3dM5QPcqu19Hg8lkUhBFBa3TcLs1DG7+2Jqci7oU= +github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-migrate/migrate/v4 v4.7.0 h1:gONcHxHApDTKXDyLH/H97gEHmpu1zcnnbAaq2zgrPrs= github.com/golang-migrate/migrate/v4 v4.7.0/go.mod h1:Qvut3N4xKWjoH3sokBccML6WyHSnggXm/DvMMnTsQIc= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= @@ -1618,14 +1984,13 @@ github.com/golang-sql/sqlexp v0.0.0-20170517235910-f1bb20e5a188/go.mod h1:vXjM/+ github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= -github.com/golang/gddo v0.0.0-20180828051604-96d2a289f41e/go.mod h1:xEhNfoBDX1hzLm2Nf80qUvZ2sVwoMZ8d6IE2SrsQfh4= -github.com/golang/gddo v0.0.0-20190904175337-72a348e765d2/go.mod h1:xEhNfoBDX1hzLm2Nf80qUvZ2sVwoMZ8d6IE2SrsQfh4= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= -github.com/golang/glog v1.1.2 h1:DVjP2PbBOzHyzA+dn3WhHIq4NdVu3Q+pvivFICf/7fo= +github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ= +github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68= +github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -1641,7 +2006,6 @@ github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71 github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= -github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -1678,11 +2042,10 @@ github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl76 github.com/google/cel-go v0.17.7 h1:6ebJFzu1xO2n7TLtN+UBqShGBhlD85bhvglh5DpcfqQ= github.com/google/cel-go v0.17.7/go.mod h1:HXZKzB0LXqer5lHHgfWAnlYwJaQBDKMjxjulNQzhwhY= github.com/google/flatbuffers v1.11.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/flatbuffers v2.0.0+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -github.com/google/flatbuffers v23.1.21+incompatible h1:bUqzx/MXCDxuS0hRJL2EfjyZL3uQrPbMocUa8zGqsTA= -github.com/google/flatbuffers v23.1.21+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -github.com/google/gnostic v0.5.7-v3refs/go.mod h1:73MKFl6jIHelAJNaBGFzt3SPtZULs9dYrGFt8OiIsHQ= -github.com/google/gnostic v0.6.9/go.mod h1:Nm8234We1lq6iB9OmlgNv3nH91XLLVZHCDayfA3xq+E= +github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= +github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -1706,7 +2069,8 @@ github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4r github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-github/v45 v45.2.0 h1:5oRLszbrkvxDDqBCNj2hjDZMKmvexaZ1xw/FCD+K3FI= github.com/google/go-github/v45 v45.2.0/go.mod h1:FObaZJEDSTa/WGCzZ2Z3eoCDXWJKMenWWTrd8jrta28= -github.com/google/go-jsonnet v0.16.0/go.mod h1:sOcuej3UW1vpPTZOr8L7RQimqai1a57bt5j22LzGZCw= +github.com/google/go-pkcs11 v0.2.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= +github.com/google/go-pkcs11 v0.2.1-0.20230907215043-c6f79328ddf9/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= @@ -1741,20 +2105,25 @@ github.com/google/pprof v0.0.0-20210506205249-923b5ab0fc1a/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20230228050547-1710fef4ab10 h1:CqYfpuYIjnlNxM3msdyPRKabhXZWbKjf3Q8BWROFBso= -github.com/google/pprof v0.0.0-20230228050547-1710fef4ab10/go.mod h1:79YE0hCXdHag9sBkw2o+N/YnZtTkXi0UT9Nnixa5eYk= +github.com/google/pprof v0.0.0-20231205033806-a5a03c77bf08 h1:PxlBVtIFHR/mtWk2i0gTEdCz+jBnqiuHNSki0epDbVs= +github.com/google/pprof v0.0.0-20231205033806-a5a03c77bf08/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/s2a-go v0.1.0/go.mod h1:OJpEgntRZo8ugHpF9hkoLJbS5dSI20XZeXJ9JVywLlM= +github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= +github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.1.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8= github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU= github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= @@ -1762,8 +2131,10 @@ github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg= github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= -github.com/googleapis/enterprise-certificate-proxy v0.3.1 h1:SBWmZhjUDRorQxrN0nwzf+AHBxnbFjViHQS4P0yVpmQ= -github.com/googleapis/enterprise-certificate-proxy v0.3.1/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/enterprise-certificate-proxy v0.2.4/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= +github.com/googleapis/enterprise-certificate-proxy v0.2.5/go.mod h1:RxW0N9901Cko1VOCW3SXCpWP+mlIEkk2tP7jnHy9a3w= +github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= @@ -1774,39 +2145,33 @@ github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo= github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY= github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8= +github.com/googleapis/gax-go/v2 v2.7.1/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI= +github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI= +github.com/googleapis/gax-go/v2 v2.10.0/go.mod h1:4UOEnMCrxsSqQ940WnTiD6qJ63le2ev3xfyagutxiPw= +github.com/googleapis/gax-go/v2 v2.11.0/go.mod h1:DxmR61SGKkGLa2xigwuZIQpkCI2S5iydzRfb3peWZJI= github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= -github.com/gophercloud/gophercloud v1.2.0 h1:1oXyj4g54KBg/kFtCdMM6jtxSzeIyg8wv4z1HoGPp1E= -github.com/gophercloud/gophercloud v1.2.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= -github.com/gopherjs/gopherjs v0.0.0-20181004151105-1babbf986f6f/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gophercloud/gophercloud v1.8.0 h1:TM3Jawprb2NrdOnvcHhWJalmKmAmOGgfZElM/3oBYCk= +github.com/gophercloud/gophercloud v1.8.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1/go.mod h1:YeAe0gNeiNT5hoiZRI4yiOky6jVdNvfO2N6Kav/HmxY= -github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/gorilla/sessions v1.1.2/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= -github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= -github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= -github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gotestyourself/gotestyourself v1.3.0/go.mod h1:zZKM6oeNM8k+FRljX1mnzVYeS8wiGgQyvST1/GafPbY= -github.com/gotestyourself/gotestyourself v2.2.0+incompatible/go.mod h1:zZKM6oeNM8k+FRljX1mnzVYeS8wiGgQyvST1/GafPbY= -github.com/grafana/alerting v0.0.0-20231221110807-c17ec6241a66 h1:qX3Reeg7jiLIgXSvxWJj/r/EngiMdiFev+FIsKpjFiE= -github.com/grafana/alerting v0.0.0-20231221110807-c17ec6241a66/go.mod h1:lR9bhQrESIeOqKtC4Y+fK4mqtLmJFDffFt9q4cWRa8k= +github.com/grafana/alerting v0.0.0-20240306130925-bc622368256d h1:YxLsj/C75sW90gzYK27XEaJ1sL89lYxuntmHaytFP80= +github.com/grafana/alerting v0.0.0-20240306130925-bc622368256d/go.mod h1:0nHKO0w8OTemvZ3eh7+s1EqGGhgbs0kvkTeLU1FrbTw= github.com/grafana/codejen v0.0.3 h1:tAWxoTUuhgmEqxJPOLtJoxlPBbMULFwKFOcRsPRPXDw= github.com/grafana/codejen v0.0.3/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s= github.com/grafana/cue v0.0.0-20230926092038-971951014e3f h1:TmYAMnqg3d5KYEAaT6PtTguL2GjLfvr6wnAX8Azw6tQ= @@ -1815,28 +2180,36 @@ github.com/grafana/cuetsy v0.1.11 h1:I3IwBhF+UaQxRM79HnImtrAn8REGdb5M3+C4QrYHoWk github.com/grafana/cuetsy v0.1.11/go.mod h1:Ix97+CPD8ws9oSSxR3/Lf4ahU1I4Np83kjJmDVnLZvc= github.com/grafana/dataplane/examples v0.0.1 h1:K9M5glueWyLoL4//H+EtTQq16lXuHLmOhb6DjSCahzA= github.com/grafana/dataplane/examples v0.0.1/go.mod h1:h5YwY8s407/17XF5/dS8XrUtsTVV2RnuW8+m1Mp46mg= -github.com/grafana/dataplane/sdata v0.0.6 h1:Ejlj8d1Hvy/uDLeI4sOvL34Y8WLlVDd9iN270F+8aTw= -github.com/grafana/dataplane/sdata v0.0.6/go.mod h1:Jvs5ddpGmn6vcxT7tCTWAZ1mgi4sbcdFt9utQx5uMAU= -github.com/grafana/dskit v0.0.0-20230706162620-5081d8ed53e6 h1:/19OPOCKP95g9hKLn1mN2dR/qBE4+oEY2F9XZ7G1xJM= -github.com/grafana/dskit v0.0.0-20230706162620-5081d8ed53e6/go.mod h1:M03k2fzuQ2n9TVE1xfVKTESibxsXdw0wYfWT3+9Owp4= +github.com/grafana/dataplane/sdata v0.0.7 h1:CImITypIyS1jxijCR6xqKx71JnYAxcwpH9ChK0gH164= +github.com/grafana/dataplane/sdata v0.0.7/go.mod h1:Jvs5ddpGmn6vcxT7tCTWAZ1mgi4sbcdFt9utQx5uMAU= +github.com/grafana/dskit v0.0.0-20240104111617-ea101a3b86eb h1:AWE6+kvtE18HP+lRWNUCyvymyrFSXs6TcS2vXIXGIuw= +github.com/grafana/dskit v0.0.0-20240104111617-ea101a3b86eb/go.mod h1:kkWM4WUV230bNG3urVRWPBnSJHs64y/0RmWjftnnn0c= github.com/grafana/gofpdf v0.0.0-20231002120153-857cc45be447 h1:jxJJ5z0GxqhWFbQUsys3BHG8jnmniJ2Q74tXAG1NaDo= github.com/grafana/gofpdf v0.0.0-20231002120153-857cc45be447/go.mod h1:IxsY6mns6Q5sAnWcrptrgUrSglTZJXH/kXr9nbpb/9I= -github.com/grafana/grafana-aws-sdk v0.19.1 h1:5GBiOv2AgdyjwlgAX+dtgPtXU4FgMTD9rfQUPQseEpQ= -github.com/grafana/grafana-aws-sdk v0.19.1/go.mod h1:ntq2NDH12Y2Fkbc6fozpF8kYsJM9k6KNr+Xfo5w3/iM= -github.com/grafana/grafana-azure-sdk-go v1.11.0 h1:nc6MgOZ5fIaxvBfZjYU5rSqB4zaD7rlU8BqnGcXZtWk= -github.com/grafana/grafana-azure-sdk-go v1.11.0/go.mod h1:5a3FuG2lEsYNop9HDNgTO1bx4ExCgsjvrFhpuqolYAU= +github.com/grafana/grafana-aws-sdk v0.25.0 h1:XNi3iA/C/KPArmVbQfbwKQROaIotd38nCRjNE6P1UP0= +github.com/grafana/grafana-aws-sdk v0.25.0/go.mod h1:3zghFF6edrxn0d6k6X9HpGZXDH+VfA+MwD2Pc/9X0ec= +github.com/grafana/grafana-azure-sdk-go v1.12.0 h1:q71M2QxMlBqRZOXc5mFAycJWuZqQ3hPTzVEo1r3CUTY= +github.com/grafana/grafana-azure-sdk-go v1.12.0/go.mod h1:SAlwLdEuox4vw8ZaeQwnepYXnhznnQQdstJbcw8LH68= github.com/grafana/grafana-google-sdk-go v0.1.0 h1:LKGY8z2DSxKjYfr2flZsWgTRTZ6HGQbTqewE3JvRaNA= github.com/grafana/grafana-google-sdk-go v0.1.0/go.mod h1:Vo2TKWfDVmNTELBUM+3lkrZvFtBws0qSZdXhQxRdJrE= github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 h1:r+mU5bGMzcXCRVAuOrTn54S80qbfVkvTdUJZfSfTNbs= github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ= -github.com/grafana/grafana-plugin-sdk-go v0.94.0/go.mod h1:3VXz4nCv6wH5SfgB3mlW39s+c+LetqSCjFj7xxPC5+M= github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk= -github.com/grafana/grafana-plugin-sdk-go v0.197.0 h1:5oUAQfa3gv5AX8Qhkoyuaj5E4hXbdR5mfa9P4zNQ0IE= -github.com/grafana/grafana-plugin-sdk-go v0.197.0/go.mod h1:HC20FRnHgZprNqfcMRbrQ35gV25RctpHnRO+JbgmdqQ= +github.com/grafana/grafana-plugin-sdk-go v0.215.0 h1:02gwVsqYi1I+U48/MQR61eOMxiXE7KNKC8QsiMJ//qA= +github.com/grafana/grafana-plugin-sdk-go v0.215.0/go.mod h1:nBsh3jRItKQUXDF2BQkiQCPxqrsSQeb+7hiFyJTO1RE= +github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240226124929-648abdbd0ea4 h1:hpyusz8c3yRFoJPlA0o34rWnsLbaOOBZleqRhFBi5Lg= +github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240226124929-648abdbd0ea4/go.mod h1:vrRQJuNprTWqwm6JPxHf3BoTJhvO15QMEjQ7Q/YUOnI= +github.com/grafana/grafana/pkg/apiserver v0.0.0-20240226124929-648abdbd0ea4 h1:tIbI5zgos92vwJ8lV3zwHwuxkV03GR3FGLkFW9V5LxY= +github.com/grafana/grafana/pkg/apiserver v0.0.0-20240226124929-648abdbd0ea4/go.mod h1:vpYI6DHvFO595rpQGooUjcyicjt9rOevldDdW79peV0= +github.com/grafana/grafana/pkg/promlib v0.0.2 h1:yy7iwHlHH7Hl/n7ix9+RPIKg3CcKCASMehv2N04hcrY= +github.com/grafana/grafana/pkg/util/xorm v0.0.1 h1:72QZjxWIWpSeOF8ob4aMV058kfgZyeetkAB8dmeti2o= +github.com/grafana/grafana/pkg/util/xorm v0.0.1/go.mod h1:eNfbB9f2jM8o9RfwqwjY8SYm5tvowJ8Ly+iE4P9rXII= github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482 h1:1YNoeIhii4UIIQpCPU+EXidnqf449d0C3ZntAEt4KSo= github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482/go.mod h1:GNcfpy5+SY6RVbNGQW264gC0r336Dm+0zgQ5vt6+M8Y= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20231027171310-70c52bf65758 h1:ATUhvJSJwzdzhnmzUI92fxVFqyqmcnzJ47wtHTK3LW4= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20231027171310-70c52bf65758/go.mod h1:MmLemcsGjpbOwEeT3k7K+gnvIImXgkatCfVX6sOtx80= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20240208102907-e82436ce63e6 h1:CBm0rwLCPDyarg9/bHJ50rBLYmyMDoyCWpgRMITZhdA= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20240208102907-e82436ce63e6/go.mod h1:8Ia/R3urPmbzJ8OsdvmZvIprDwvwmYCmUbwBL+jlPOE= +github.com/grafana/pyroscope-go/godeltaprof v0.1.6 h1:nEdZ8louGAplSvIJi1HVp7kWvFvdiiYg3COLlTwJiFo= +github.com/grafana/pyroscope-go/godeltaprof v0.1.6/go.mod h1:Tk376Nbldo4Cha9RgiU7ik8WKFkNpfds98aUzS8omLE= github.com/grafana/pyroscope/api v0.3.0 h1:WcVKNZ8JlriJnD28wTkZray0wGo8dGkizSJXnbG7Gd8= github.com/grafana/pyroscope/api v0.3.0/go.mod h1:JggA80ToAAUACYGfwL49XoFk5aN5ecHp4pNIZhlk9Uc= github.com/grafana/regexp v0.0.0-20221122212121-6b5c0a4cb7fd/go.mod h1:M5qHK+eWfAv8VR/265dIuEpL3fNfeC21tXXp9itM24A= @@ -1844,14 +2217,13 @@ github.com/grafana/regexp v0.0.0-20221123153739-15dc172cd2db h1:7aN5cccjIqCLTzed github.com/grafana/regexp v0.0.0-20221123153739-15dc172cd2db/go.mod h1:M5qHK+eWfAv8VR/265dIuEpL3fNfeC21tXXp9itM24A= github.com/grafana/saml v0.4.15-0.20231025143828-a6c0e9b86a4c h1:1pHLC1ZTz7N5QI3jzCs5sqmVvAKe+JwGnpp9lQ+iUjY= github.com/grafana/saml v0.4.15-0.20231025143828-a6c0e9b86a4c/go.mod h1:S4+611dxnKt8z/ulbvaJzcgSHsuhjVc1QHNTcr1R7Fw= -github.com/grafana/sqlds/v2 v2.3.10 h1:HWKhE0vR6LoEiE+Is8CSZOgaB//D1yqb2ntkass9Fd4= -github.com/grafana/sqlds/v2 v2.3.10/go.mod h1:c6ibxnxRVGxV/0YkEgvy7QpQH/lyifFyV7K/14xvdIs= +github.com/grafana/sqlds/v3 v3.2.0 h1:WXuYEaFfiCvgm8kK2ixx44/zAEjFzCylA2+RF3GBqZA= +github.com/grafana/sqlds/v3 v3.2.0/go.mod h1:kH0WuHUR3j0Q7IEymbm2JiaPckUhRCbqjV9ajaBAnmM= github.com/grafana/tempo v1.5.1-0.20230524121406-1dc1bfe7085b h1:mDlkqgTEJuK7vjPG44f3ZMtId5AAYLWHvBVbiGqIOOQ= github.com/grafana/tempo v1.5.1-0.20230524121406-1dc1bfe7085b/go.mod h1:UK7kTP5llPeRcGBOe5mm4QTNTd0k/mAqTVSOFdDH6AU= github.com/grafana/thema v0.0.0-20230712153715-375c1b45f3ed h1:TMtHc+B0SSNw2in6Ro1dAiBYSPRp4NzKgndFDfupt18= github.com/grafana/thema v0.0.0-20230712153715-375c1b45f3ed/go.mod h1:3zLJnssFRPCnebCBRlq53t5LgYv9P1mbj0XMozZMTww= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= @@ -1859,28 +2231,27 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vb github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340/go.mod h1:3bDW6wMZJB7tiONtC/1Xpicra6Wp5GgbTbQWCbI5fkc= -github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.15.2/go.mod h1:7pdNwVWBBHGiCxa9lAszqCJMbfTISJ7oMftp8+UGV08= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.2 h1:dygLcbEBA+t/P7ck6a8AkXv6juQ4cK0RHBoh32jxhHM= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.2/go.mod h1:Ap9RLCIJVtgQg1/BBgVEfypOAySvvlcpcVQkSzJCH4Y= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= github.com/hanwen/go-fuse v1.0.0/go.mod h1:unqXarDXqzAk0rt98O2tVndEPIpUgLD9+rwFisZH3Ok= github.com/hanwen/go-fuse/v2 v2.1.0/go.mod h1:oRyA5eK+pvJyv5otpO/DgccS8y/RvYMaO00GgRLGryc= github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= github.com/hashicorp/consul/api v1.10.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0= -github.com/hashicorp/consul/api v1.20.0 h1:9IHTjNVSZ7MIwjlW3N3a7iGiykCMDpxZu8jsxFJh0yc= -github.com/hashicorp/consul/api v1.20.0/go.mod h1:nR64eD44KQ59Of/ECwt2vUmIK2DKsDzAwTmwmLl8Wpo= +github.com/hashicorp/consul/api v1.26.1 h1:5oSXOO5fboPZeW5SN+TdGFP/BILDgBm19OrPZ/pICIM= +github.com/hashicorp/consul/api v1.26.1/go.mod h1:B4sQTeaSO16NtynqrAdwOlahJ7IUDZM9cj2420xYL8A= github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= -github.com/hashicorp/consul/sdk v0.13.1/go.mod h1:SW/mM4LbKfqmMvcFu8v+eiQQ7oitXEFeiBe9StxERb0= -github.com/hashicorp/cronexpr v1.1.1 h1:NJZDd87hGXjoZBdvyCF9mX4DCq5Wy7+A/w+A7q0wn6c= -github.com/hashicorp/cronexpr v1.1.1/go.mod h1:P4wA0KBl9C5q2hABiMO7cp6jcIg96CDh1Efb3g1PWA4= +github.com/hashicorp/consul/sdk v0.15.0/go.mod h1:r/OmRRPbHOe0yxNahLw7G9x5WG17E1BIECMtCjcPSNo= +github.com/hashicorp/cronexpr v1.1.2 h1:wG/ZYIKT+RT3QkOdgYc+xsKWVRgnxJ1OJtjjy84fJ9A= +github.com/hashicorp/cronexpr v1.1.2/go.mod h1:P4wA0KBl9C5q2hABiMO7cp6jcIg96CDh1Efb3g1PWA4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -1904,20 +2275,19 @@ github.com/hashicorp/go-plugin v1.2.2/go.mod h1:F9eH4LrE/ZsRdbwhfjs9k9HoDUwAHnYt github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A= github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= -github.com/hashicorp/go-retryablehttp v0.6.8/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= -github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0= -github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/hashicorp/go-retryablehttp v0.7.4 h1:ZQgVdpTdAL7WpMIwLzCfbalOcSUdkDZnpUv3/+BxzFA= +github.com/hashicorp/go-retryablehttp v0.7.4/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/go-sockaddr v1.0.6 h1:RSG8rKU28VTUTvEKghe5gIhIQpv8evvNpnDEyqO4u9I= +github.com/hashicorp/go-sockaddr v1.0.6/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= @@ -1930,9 +2300,8 @@ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4= github.com/hashicorp/golang-lru v0.6.0/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/golang-lru/v2 v2.0.2 h1:Dwmkdr5Nc/oBiXgJS3CDHNhJtIHkuZ3DZF5twqnfBdU= -github.com/hashicorp/golang-lru/v2 v2.0.2/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl/v2 v2.17.0 h1:z1XvSUyXd1HP10U4lrLg5e0JMVz6CPaJvAgxM0KNZVY= github.com/hashicorp/hcl/v2 v2.17.0/go.mod h1:gJyW2PTShkJqQBKpAmPO3yxMxIuoXkOF2TpqXzrQyx4= @@ -1945,8 +2314,8 @@ github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOn github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR/prTM= github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= -github.com/hashicorp/nomad/api v0.0.0-20230308192510-48e7d70fcd4b h1:EkuSTU8c/63q4LMayj8ilgg/4I5PXDFVcnqKfs9qcwI= -github.com/hashicorp/nomad/api v0.0.0-20230308192510-48e7d70fcd4b/go.mod h1:bKUb1ytds5KwUioHdvdq9jmrDqCThv95si0Ub7iNeBg= +github.com/hashicorp/nomad/api v0.0.0-20230721134942-515895c7690c h1:Nc3Mt2BAnq0/VoLEntF/nipX+K1S7pG+RgwiitSv6v0= +github.com/hashicorp/nomad/api v0.0.0-20230721134942-515895c7690c/go.mod h1:O23qLAZuCx4htdY9zBaO4cJPXgleSFEdq6D/sezGgYE= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= @@ -1957,26 +2326,25 @@ github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKe github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= -github.com/hetznercloud/hcloud-go v1.41.0 h1:KJGFRRc68QiVu4PrEP5BmCQVveCP2CM26UGQUKGpIUs= -github.com/hetznercloud/hcloud-go v1.41.0/go.mod h1:NaHg47L6C77mngZhwBG652dTAztYrsZ2/iITJKhQkHA= -github.com/hmarr/codeowners v1.1.2 h1:CdmLJ0e2s2aaH21+3HW1HXJyJqz+OCEiMBfD9iZb3n8= -github.com/hmarr/codeowners v1.1.2/go.mod h1:+ez+YARvfVhzL1MzY0f2+D/VusjODs4iRj3tO/IxBMw= +github.com/hetznercloud/hcloud-go/v2 v2.4.0 h1:MqlAE+w125PLvJRCpAJmEwrIxoVdUdOyuFUhE/Ukbok= +github.com/hetznercloud/hcloud-go/v2 v2.4.0/go.mod h1:l7fA5xsncFBzQTyw29/dw5Yr88yEGKKdc6BHf24ONS0= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs= github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= +github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo= github.com/hydrogen18/memlistener v0.0.0-20200120041712-dcc25e7acd91/go.mod h1:qEIFzExnS6016fRpRfxrExeVn2gbClQA99gQhnIcdhE= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20220517205856-0058ec4f073c/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= +github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/igm/sockjs-go/v3 v3.0.2 h1:2m0k53w0DBiGozeQUIEPR6snZFmpFpYvVsGnfLPNXbE= github.com/igm/sockjs-go/v3 v3.0.2/go.mod h1:UqchsOjeagIBFHvd+RZpLaVRbCwGilEC08EDHsD1jYE= github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= -github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -1988,11 +2356,12 @@ github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= github.com/influxdata/line-protocol v0.0.0-20210311194329-9aa0e372d097 h1:vilfsDSy7TDxedi9gyBkMvAirat/oRcL0lFdJBf6tdM= github.com/influxdata/line-protocol v0.0.0-20210311194329-9aa0e372d097/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= -github.com/inhies/go-bytesize v0.0.0-20201103132853-d0aed0d254f8/go.mod h1:KrtyD5PFj++GKkFS/7/RRrfnRhAMGQwy75GLCHWrCNs= +github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= +github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= -github.com/ionos-cloud/sdk-go/v6 v6.1.4 h1:BJHhFA8Q1SZC7VOXqKKr2BV2ysQ2/4hlk1e4hZte7GY= -github.com/ionos-cloud/sdk-go/v6 v6.1.4/go.mod h1:Ox3W0iiEz0GHnfY9e5LmAxwklsxguuNFEUSu0gVRTME= +github.com/ionos-cloud/sdk-go/v6 v6.1.10 h1:3815Q2Hw/wc4cJ8wD7bwfsmDsdfIEp80B7BQMj0YP2w= +github.com/ionos-cloud/sdk-go/v6 v6.1.10/go.mod h1:EzEgRIDxBELvfoa/uBN0kOQaqovLjUWEB7iW4/Q+t4k= github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI= github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0= github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7UaeZTSWddIk= @@ -2005,9 +2374,6 @@ github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80s github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= -github.com/jackc/pgconn v1.3.2/go.mod h1:LvCquS3HbBKwgl7KbX9KyqEIumJAbm1UMcTvGaIf3bM= -github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI= -github.com/jackc/pgconn v1.6.0/go.mod h1:yeseQo4xhQbgyJs2c87RAXOH2i624N0Fh1KSPJya7qo= github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= @@ -2016,51 +2382,46 @@ github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bY github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= -github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgproto3/v2 v2.0.2/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.2.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= -github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0= -github.com/jackc/pgtype v1.3.0/go.mod h1:b0JqxHvPmljG+HQ5IsvQ0yqeSi4nGcDTVjFoiLDb0Ik= github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= github.com/jackc/pgtype v1.10.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= github.com/jackc/pgx v3.2.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= -github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= -github.com/jackc/pgx/v4 v4.4.1/go.mod h1:6iSW+JznC0YT+SgBn7rNxoEBsBgSmnC5FwyCekOGUiE= -github.com/jackc/pgx/v4 v4.6.0/go.mod h1:vPh43ZzxijXUVJ+t/EmXBtFmbFVO72cuneCT9oAlxAg= github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= github.com/jackc/pgx/v4 v4.15.0/go.mod h1:D/zyOyXiaM1TmVWnOM18p0xdDtdakRBa0RsVGI3U3bw= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jandelgado/gcov2lcov v1.0.4-0.20210120124023-b83752c6dc08/go.mod h1:NnSxK6TMlg1oGDBfGelGbjgorT5/L3cchlbtgFYZSss= -github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= -github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= -github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= -github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= -github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= -github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/jhump/gopoet v0.0.0-20190322174617-17282ff210b3/go.mod h1:me9yfT6IJSlOL3FCfrg+L6yzUEZ+5jW6WHt4Sk+UPUI= +github.com/jhump/gopoet v0.1.0/go.mod h1:me9yfT6IJSlOL3FCfrg+L6yzUEZ+5jW6WHt4Sk+UPUI= +github.com/jhump/goprotoc v0.5.0/go.mod h1:VrbvcYrQOrTi3i0Vf+m+oqQWk9l72mjkJCYo7UvLHRQ= github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74= +github.com/jhump/protoreflect v1.11.0/go.mod h1:U7aMIjN0NWq9swDP7xDdoMfRHb35uiuTd3Z9nFXJf5E= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= @@ -2069,12 +2430,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/jmoiron/sqlx v0.0.0-20180614180643-0dae4fefe7c0/go.mod h1:IiEW3SEiiErVyFdH8NTuWjSifiEQKUoyK3LNqr2kCHU= -github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= -github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= -github.com/joho/godotenv v1.2.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= @@ -2104,16 +2461,8 @@ github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+ github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= -github.com/karrick/godirwalk v1.7.5/go.mod h1:2c9FRhkDxdIbgkOnCEvnSWs71Bhugbl46shStcFDJ34= -github.com/karrick/godirwalk v1.7.7/go.mod h1:2c9FRhkDxdIbgkOnCEvnSWs71Bhugbl46shStcFDJ34= -github.com/karrick/godirwalk v1.7.8/go.mod h1:2c9FRhkDxdIbgkOnCEvnSWs71Bhugbl46shStcFDJ34= github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= -github.com/karrick/godirwalk v1.10.9/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= -github.com/karrick/godirwalk v1.10.12/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= -github.com/karrick/godirwalk v1.15.3/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= -github.com/karrick/godirwalk v1.15.5/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= -github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= github.com/kataras/golog v0.0.10/go.mod h1:yJ8YKCmyL+nWjERB90Qwn+bdyBZsaQwU3bTVFgkFIp8= github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYbq3UhfoFmE= github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE= @@ -2123,28 +2472,27 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= -github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= -github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.13.1/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= -github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= -github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= -github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= -github.com/knadh/koanf v0.14.1-0.20201201075439-e0853799f9ec/go.mod h1:H5mEFsTeWizwFXHKtsITL5ipsLTuAMQoGuQpp+1JL9U= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00= github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM= -github.com/konsorten/go-windows-terminal-sequences v0.0.0-20180402223658-b729f2633dfe/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -2157,12 +2505,12 @@ github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NB github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/krasun/gosqlparser v1.0.5 h1:sHaexkxGb9NrAcjZ3mUs6u33iJ9qhR2fH7XrpZekMt8= +github.com/krasun/gosqlparser v1.0.5/go.mod h1:aXCTW1xnPl4qAaNROeqESauGJ8sqhoB4OFEIOVIDYI4= github.com/kshvakov/clickhouse v1.3.5/go.mod h1:DMzX7FxRymoNkVgizH0DWAL8Cur7wHLgx3MUnGwJqpE= github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -2173,14 +2521,13 @@ github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/leesper/go_rng v0.0.0-20190531154944-a612b043e353 h1:X/79QL0b4YJVO5+OsPH9rF2u428CIrGL/jLmPsoOQQ4= github.com/leesper/go_rng v0.0.0-20190531154944-a612b043e353/go.mod h1:N0SVk0uhy+E1PZ3C9ctsPRlvOPAFPkCNlcPBDkt0N3U= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= -github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -2189,13 +2536,12 @@ github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-b github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= github.com/linkedin/goavro/v2 v2.10.0 h1:eTBIRoInBM88gITGXYtUSqqxLTFXfOsJBiX8ZMW0o4U= github.com/linkedin/goavro/v2 v2.10.0/go.mod h1:UgQUb2N/pmueQYH9bfqFioWxzYCZXSfF8Jw03O5sjqA= -github.com/linode/linodego v1.14.1 h1:uGxQyy0BidoEpLGdvfi4cPgEW+0YUFsEGrLEhcTfjNc= -github.com/linode/linodego v1.14.1/go.mod h1:NJlzvlNtdMRRkXb0oN6UWzUkj6t+IBsyveHgZ5Ppjyk= -github.com/luna-duclos/instrumentedsql v0.0.0-20181127104832-b7d587d28109/go.mod h1:PWUIzhtavmOR965zfawVsHXbEuU1G29BPZ/CB3C7jXk= -github.com/luna-duclos/instrumentedsql v1.1.2/go.mod h1:4LGbEqDnopzNAiyxPPDXhLspyunZxgPTMJBKtC6U0BQ= -github.com/luna-duclos/instrumentedsql v1.1.3/go.mod h1:9J1njvFds+zN7y85EDhN9XNQLANWwZt2ULeIC8yMNYs= +github.com/linode/linodego v1.25.0 h1:zYMz0lTasD503jBu3tSRhzEmXHQN1zptCw5o71ibyyU= +github.com/linode/linodego v1.25.0/go.mod h1:BMZI0pMM/YGjBis7pIXDPbcgYfCZLH0/UvzqtsGtG1c= github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= +github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= +github.com/lyft/protoc-gen-star/v2 v2.0.3/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= github.com/m3db/prometheus_remote_client_golang v0.4.4 h1:DsAIjVKoCp7Ym35tAOFL1OuMLIdIikAEHeNPHY+yyM8= github.com/m3db/prometheus_remote_client_golang v0.4.4/go.mod h1:wHfVbA3eAK6dQvKjCkHhusWYegCk3bDGkA15zymSHdc= @@ -2203,39 +2549,14 @@ github.com/magefile/mage v1.11.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXq github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.7.1/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/markbates/deplist v1.0.4/go.mod h1:gRRbPbbuA8TmMiRvaOzUlRfzfjeCCBqX2A6arxN01MM= -github.com/markbates/deplist v1.0.5/go.mod h1:gRRbPbbuA8TmMiRvaOzUlRfzfjeCCBqX2A6arxN01MM= -github.com/markbates/deplist v1.1.3/go.mod h1:BF7ioVzAJYEtzQN/os4rt8H8Ti3h0T7EoN+7eyALktE= -github.com/markbates/errx v1.1.0/go.mod h1:PLa46Oex9KNbVDZhKel8v1OT7hD5JZ2eI7AHhA0wswc= -github.com/markbates/going v1.0.2/go.mod h1:UWCk3zm0UKefHZ7l8BNqi26UyiEMniznk8naLdTcy6c= -github.com/markbates/grift v1.0.4/go.mod h1:wbmtW74veyx+cgfwFhlnnMWqhoz55rnHR47oMXzsyVs= -github.com/markbates/hmax v1.0.0/go.mod h1:cOkR9dktiESxIMu+65oc/r/bdY4bE8zZw3OLhLx0X2c= -github.com/markbates/inflect v1.0.0/go.mod h1:oTeZL2KHA7CUX6X+fovmK9OvIOFuqu0TwdQrZjLTh88= -github.com/markbates/inflect v1.0.1/go.mod h1:uv3UVNBe5qBIfCm8O8Q+DW+S1EopeyINj+Ikhc7rnCk= -github.com/markbates/inflect v1.0.3/go.mod h1:1fR9+pO2KHEO9ZRtto13gDwwZaAKstQzferVeWqbgNs= -github.com/markbates/inflect v1.0.4/go.mod h1:1fR9+pO2KHEO9ZRtto13gDwwZaAKstQzferVeWqbgNs= -github.com/markbates/oncer v0.0.0-20180924031910-e862a676800b/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= -github.com/markbates/oncer v0.0.0-20180924034138-723ad0170a46/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= -github.com/markbates/oncer v0.0.0-20181014194634-05fccaae8fc4/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= -github.com/markbates/oncer v1.0.0/go.mod h1:Z59JA581E9GP6w96jai+TGqafHPW+cPfRxz2aSZ0mcI= -github.com/markbates/pkger v0.17.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI= -github.com/markbates/refresh v1.4.10/go.mod h1:NDPHvotuZmTmesXxr95C9bjlw1/0frJwtME2dzcVKhc= -github.com/markbates/safe v1.0.0/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= -github.com/markbates/sigtx v1.0.0/go.mod h1:QF1Hv6Ic6Ca6W+T+DL0Y/ypborFKyvUY9HmuCD4VeTc= -github.com/markbates/willie v1.0.9/go.mod h1:fsrFVWl91+gXpx/6dv715j7i11fYPfZ9ZGfH0DQzY7w= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= @@ -2268,45 +2589,43 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= -github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= +github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= -github.com/mattn/goveralls v0.0.6 h1:cr8Y0VMo/MnEZBjxNN/vh6G90SZ7IMb6lms1dzMoO+Y= -github.com/mattn/goveralls v0.0.6/go.mod h1:h8b4ow6FxSPMQHF6o2ve3qsclnffZjYTNEKmLesRwqw= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= -github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= +github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8= -github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= -github.com/microsoft/go-mssqldb v1.5.0 h1:CgENxkwtOBNj3Jg6T1X209y2blCfTTcwuOlznd2k9fk= -github.com/microsoft/go-mssqldb v1.5.0/go.mod h1:lmWsjHD8XX/Txr0f8ZqgbEZSC+BZjmEQy/Ms+rLrvho= +github.com/microsoft/go-mssqldb v1.6.1-0.20240214161942-b65008136246 h1:KT4vTYcHqj5C5hMK5kSpyAk7MnFqfHVWLL4VqMq66S8= +github.com/microsoft/go-mssqldb v1.6.1-0.20240214161942-b65008136246/go.mod h1:00mDtPbeQCRGC1HwOOR5K/gr30P1NcEG0vx6Kbv2aJU= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= -github.com/miekg/dns v1.1.51 h1:0+Xg7vObnhrz/4ZCZcZh7zPXlmU0aveS2HDBd0m0qSo= -github.com/miekg/dns v1.1.51/go.mod h1:2Z9d3CP1LQWihRZUf29mQ19yDThaI4DAYzte2CaQW5c= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= github.com/minio/highwayhash v1.0.1/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY= github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= +github.com/mitchellh/cli v1.1.5/go.mod h1:v8+iFts2sPIKUV1ltktPXMCC8fumSKFItNcD2cLtRR4= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= @@ -2323,10 +2642,7 @@ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTS github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.0.0/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.2.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= @@ -2334,13 +2650,22 @@ github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mithrandie/csvq v1.17.10 h1:ba8W6rWgB6LfIhY1ttmgXzKNcCoVtT4e6zuZTaGuAQg= +github.com/mithrandie/csvq v1.17.10/go.mod h1:ALXIPvYIbBEJvcoB41WSQhhLqOXT+2P4VommU+2DLLc= +github.com/mithrandie/csvq-driver v1.6.8 h1:0rF4yZ0ByIECznd9Ld+Ry5tIEYq/zxbb3QYuni/JWFk= +github.com/mithrandie/csvq-driver v1.6.8/go.mod h1:SrUKsCbaFKaaxKrptvLJ882CFMJD2hJAjv+Ev1HcUM8= +github.com/mithrandie/go-file/v2 v2.1.0 h1:XA5Tl+73GXMDvgwSE3Sg0uC5FkLr3hnXs8SpUas0hyg= +github.com/mithrandie/go-file/v2 v2.1.0/go.mod h1:9YtTF3Xo59GqC1Pxw6KyGVcM/qubAMlxVsqI/u9r++c= +github.com/mithrandie/go-text v1.5.4 h1:2LIASku5RuCqxa6O6eOvQwQ0k5FYWP1ID2hk9egYYGc= +github.com/mithrandie/go-text v1.5.4/go.mod h1:yaVYauF3TLf7LvjGrrQB/mffIkohXTXJpW9zQ206UL8= +github.com/mithrandie/ternary v1.1.1 h1:k/joD6UGVYxHixYmSR8EGgDFNONBMqyD373xT4QRdC4= +github.com/mithrandie/ternary v1.1.1/go.mod h1:0D9Ba3+09K2TdSZO7/bFCC0GjSXetCvYuYq0u8FY/1g= github.com/moby/moby v23.0.4+incompatible h1:A/pe8vi9KIKhNbzR0G3wW4ACKDsMgXILBveMqiJNa8M= github.com/moby/moby v23.0.4+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc= +github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= -github.com/moby/term v0.0.0-20200915141129-7f0af18e79f2/go.mod h1:TjQg8pa4iejrUrjiz0MCtMV38jdMNW4doKSiBrEvCQQ= github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw= github.com/moby/term v0.0.0-20221205130635-1aeaba878587 h1:HfkjXDfhgVaN5rmueG8cL8KKeFNecRCXFhaJ2qZ5SKA= github.com/moby/term v0.0.0-20221205130635-1aeaba878587/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= @@ -2354,13 +2679,12 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -github.com/monoculum/formam v0.0.0-20180901015400-4e68be1d79ba/go.mod h1:RKgILGEJq24YyJ2ban8EO0RUVSJlF1pGsEvoLEACr/Q= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/moul/http2curl v0.0.0-20170919181001-9ac6cf4d929b/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de h1:D5x39vF5KCwKQaw+OC9ZPiLVHXz3UFw2+psEX+gYcto= github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de/go.mod h1:kJun4WP5gFuHZgRjZUWWuH1DTxCtxbHDOIJsudS8jzY= @@ -2372,6 +2696,7 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8/go.mod h1:86wM1zFnC6/uDBfZGNwB65O+pR2OFi5q/YQaEUid1qA= github.com/natessilva/dag v0.0.0-20180124060714-7194b8dcc5c4/go.mod h1:cojhOHk1gbMeklOyDP2oKKLftefXoJreOQGOrXk+Z38= @@ -2388,7 +2713,6 @@ github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxzi github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s= github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -github.com/nicksnyder/go-i18n v1.10.0/go.mod h1:HrK7VCrbOvQoUAQ7Vpy7i87N7JZZZ7R2xBGjv0j365Q= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= @@ -2399,17 +2723,11 @@ github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/oleiade/reflections v1.0.0/go.mod h1:RbATFBbKYkVdqmSFtx13Bb/tVhR0lgOBXunWTZKeL4w= -github.com/oleiade/reflections v1.0.1 h1:D1XO3LVEYroYskEsoSiGItp9RUxG6jWnCVvrqH0HHQM= -github.com/oleiade/reflections v1.0.1/go.mod h1:rdFxbxq4QXVZWj0F+e9jqjDkc7dbp97vkRixKo2JR60= github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.9.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E= @@ -2421,14 +2739,16 @@ github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47 github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk= github.com/onsi/ginkgo/v2 v2.3.0/go.mod h1:Eew0uilEqZmIEZr8JrvYlvOM7Rr6xzTmMV8AyFNU9d0= github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo= +github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw= +github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo= +github.com/onsi/ginkgo/v2 v2.8.1/go.mod h1:N1/NbDngAFcSLdyZ+/aYTYGSlq9qMCS/cNKGJjy+csc= +github.com/onsi/ginkgo/v2 v2.9.0/go.mod h1:4xkjoL/tZv4SMWeww56BU5kAt19mVB47gTWxmrTcxyk= +github.com/onsi/ginkgo/v2 v2.9.1/go.mod h1:FEcmzVcCHl+4o9bQZVab+4dC9+j+91t2FHSzmGAPfuo= +github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= +github.com/onsi/ginkgo/v2 v2.9.4/go.mod h1:gCQYp2Q+kSoIj7ykSVb9nskRSsR6PUj4AiLywzIhbKM= github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= -github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= -github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.6.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY= @@ -2437,7 +2757,13 @@ github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9 github.com/onsi/gomega v1.20.1/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo= github.com/onsi/gomega v1.21.1/go.mod h1:iYAIXgPSaDHak0LCMA+AWBpIKBr8WZicMxnE8luStNc= github.com/onsi/gomega v1.22.1/go.mod h1:x6n7VNe4hw0vkyYUM4mjIXx3JbLiPaBPNgB7PRQ1tuM= -github.com/onsi/gomega v1.23.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= +github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= +github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= +github.com/onsi/gomega v1.26.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= +github.com/onsi/gomega v1.27.1/go.mod h1:aHX5xOykVYzWOV4WqQy0sy8BQptgukenXpCXfadcIAw= +github.com/onsi/gomega v1.27.3/go.mod h1:5vG284IBtfDAmDyrK+eGyZmUgUlmi+Wngqo557cZ6Gw= +github.com/onsi/gomega v1.27.4/go.mod h1:riYq/GJKh8hhoM01HN6Vmuy93AarCXCBGpvFDK3q3fQ= +github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= @@ -2448,11 +2774,7 @@ github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zM github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/image-spec v1.0.3-0.20220512140940-7b36cea86235 h1:DxS3bbeUSCpMQr3mTez5PIDrS+yBeBsoDsftOhqB1Fg= github.com/opencontainers/image-spec v1.0.3-0.20220512140940-7b36cea86235/go.mod h1:K/JAU0m27RFhDRX4PcFdIKntROP6y5Ed6O91aZYDQfs= -github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= -github.com/opencontainers/runc v1.0.0-rc9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= -github.com/opentracing-contrib/go-grpc v0.0.0-20180928155321-4b5a12d3ff02/go.mod h1:JNdpVEzCpXBgIiv4ds+TzhN1hrtxq6ClLrTlT9OQRSc= github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= -github.com/opentracing-contrib/go-stdlib v0.0.0-20190519235532-cf7a6c988dc9/go.mod h1:PLldrQSroqzH70Xl+1DQcGnefIbqsKR7UDaiux3zV+w= github.com/opentracing-contrib/go-stdlib v1.0.0 h1:TBS7YuVotp8myLon4Pv7BtCBzOTo1DeZCld0Z63mW2w= github.com/opentracing-contrib/go-stdlib v1.0.0/go.mod h1:qtI1ogk+2JhVPIXVc6q+NHziSmy2W5GbdQZFUHADCBU= github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= @@ -2465,52 +2787,18 @@ github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJ github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= github.com/openzipkin/zipkin-go v0.2.5/go.mod h1:KpXfKdgRDnnhsxw4pNIH9Md5lyFqKUa4YDFlwRYAMyE= -github.com/ory/analytics-go/v4 v4.0.0/go.mod h1:FMx9cLRD9xN+XevPvZ5FDMfignpmcqPP6FUKnJ9/MmE= -github.com/ory/dockertest v3.3.5+incompatible/go.mod h1:1vX4m9wsvi00u5bseYwXaSnhNrne+V0E6LAcBILJdPs= -github.com/ory/dockertest/v3 v3.5.4/go.mod h1:J8ZUbNB2FOhm1cFZW9xBpDsODqsSWcyYgtJYVPcnF70= -github.com/ory/dockertest/v3 v3.6.3/go.mod h1:EFLcVUOl8qCwp9NyDAcCDtq/QviLtYswW/VbWzUnTNE= -github.com/ory/fosite v0.29.0/go.mod h1:0atSZmXO7CAcs6NPMI/Qtot8tmZYj04Nddoold4S2h0= -github.com/ory/fosite v0.44.1-0.20230317114349-45a6785cc54f h1:OFA3y3TJ2qsBXCBMXUNvTzHNBS8/kXdk4cHpJGzBKO4= -github.com/ory/fosite v0.44.1-0.20230317114349-45a6785cc54f/go.mod h1:N0WZtyPBAuXedTpwzbKl4tSYU8wpjlMQoxnKcL2m8dU= -github.com/ory/go-acc v0.0.0-20181118080137-ddc355013f90/go.mod h1:sxnvPCxChFuSmTJGj8FdMupeq1BezCiEpDjTUXQ4hf4= -github.com/ory/go-acc v0.2.6 h1:YfI+L9dxI7QCtWn2RbawqO0vXhiThdXu/RgizJBbaq0= -github.com/ory/go-acc v0.2.6/go.mod h1:4Kb/UnPcT8qRAk3IAxta+hvVapdxTLWtrr7bFLlEgpw= -github.com/ory/go-convenience v0.1.0 h1:zouLKfF2GoSGnJwGq+PE/nJAE6dj2Zj5QlTgmMTsTS8= -github.com/ory/go-convenience v0.1.0/go.mod h1:uEY/a60PL5c12nYz4V5cHY03IBmwIAEm8TWB0yn9KNs= -github.com/ory/gojsonreference v0.0.0-20190720135523-6b606c2d8ee8/go.mod h1:wsH1C4nIeeQClDtD5AH7kF1uTS6zWyqfjVDTmB0Em7A= -github.com/ory/gojsonschema v1.1.1-0.20190919112458-f254ca73d5e9/go.mod h1:BNZpdJgB74KOLSsWFvzw6roXg1I6O51WO8roMmW+T7Y= -github.com/ory/herodot v0.6.2/go.mod h1:3BOneqcyBsVybCPAJoi92KN2BpJHcmDqAMcAAaJiJow= -github.com/ory/herodot v0.7.0/go.mod h1:YXKOfAXYdQojDP5sD8m0ajowq3+QXNdtxA+QiUXBwn0= -github.com/ory/herodot v0.8.3/go.mod h1:rvLjxOAlU5omtmgjCfazQX2N82EpMfl3BytBWc1jjsk= -github.com/ory/herodot v0.9.2/go.mod h1:Da2HXR8mpwPbPrH+Gv9qV8mM5gI3v+PoJ69BA4l2RAk= -github.com/ory/jsonschema/v3 v3.0.1/go.mod h1:jgLHekkFk0uiGdEWGleC+tOm6JSSP8cbf17PnBuGXlw= -github.com/ory/viper v1.5.6/go.mod h1:TYmpFpKLxjQwvT4f0QPpkOn4sDXU1kDgAwJpgLYiQ28= -github.com/ory/viper v1.7.4/go.mod h1:T6sodNZKNGPpashUOk7EtXz2isovz8oCd57GNVkkNmE= -github.com/ory/viper v1.7.5 h1:+xVdq7SU3e1vNaCsk/ixsfxE4zylk1TJUiJrY647jUE= -github.com/ory/viper v1.7.5/go.mod h1:ypOuyJmEUb3oENywQZRgeAMwqgOyDqwboO1tj3DjTaM= -github.com/ory/x v0.0.84/go.mod h1:RXLPBG7B+hAViONVg0sHwK+U/ie1Y/NeXrq1JcARfoE= -github.com/ory/x v0.0.93/go.mod h1:lfcTaGXpTZs7IEQAW00r9EtTCOxD//SiP5uWtNiz31g= -github.com/ory/x v0.0.110/go.mod h1:DJfkE3GdakhshNhw4zlKoRaL/ozg/lcTahA9OCih2BE= -github.com/ory/x v0.0.127/go.mod h1:FwUujfFuCj5d+xgLn4fGMYPnzriR5bdAIulFXMtnK0M= -github.com/ory/x v0.0.214 h1:nz5ijvm5MVhYxWsQSuUrW1hj9F5QLZvPn/nLo5s06T4= -github.com/ory/x v0.0.214/go.mod h1:aRl57gzyD4GF0HQCekovXhv0xTZgAgiht3o8eVhsm9Q= -github.com/ovh/go-ovh v1.3.0 h1:mvZaddk4E4kLcXhzb+cxBsMPYp2pHqiQpWYkInsuZPQ= -github.com/ovh/go-ovh v1.3.0/go.mod h1:AxitLZ5HBRPyUd+Zl60Ajaag+rNTdVXWIkzfrVuTXWA= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/ovh/go-ovh v1.4.3 h1:Gs3V823zwTFpzgGLZNI6ILS4rmxZgJwJCz54Er9LwD0= +github.com/ovh/go-ovh v1.4.3/go.mod h1:AkPXVtgwB6xlKblMjRKJJmjRp+ogrE7fz2lVgcQY8SY= github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= -github.com/parnurzeal/gorequest v0.2.15/go.mod h1:3Kh2QUMJoqw3icWAecsyzkpY7UzRfDhbRdTjtNwNiUE= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= -github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g= github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo= -github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys= github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= -github.com/pelletier/go-toml v1.8.0/go.mod h1:D6yutnOGMveHEPV7VQOuvI/gXY61bv+9bAOTRnLElKs= -github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas= github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= @@ -2518,25 +2806,25 @@ github.com/performancecopilot/speed/v4 v4.0.0/go.mod h1:qxrSyuDGrTOWfV+uKRFhfxw6 github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= -github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= -github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pierrec/lz4/v4 v4.1.8/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc= -github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= +github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= -github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= +github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -2552,7 +2840,6 @@ github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= -github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= @@ -2561,11 +2848,11 @@ github.com/prometheus/client_golang v1.10.0/go.mod h1:WJM3cc3yu7XKBKa/I8WeZm+V3e github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= -github.com/prometheus/client_golang v1.13.0/go.mod h1:vTeo+zgvILHsnnj/39Ou/1fPN5nJFOEMgftOUOmlvYQ= github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y= github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= -github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= +github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= +github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -2577,10 +2864,8 @@ github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJ github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= -github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= @@ -2592,36 +2877,32 @@ github.com/prometheus/common v0.29.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+ github.com/prometheus/common v0.30.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA= -github.com/prometheus/common v0.41.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= -github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= +github.com/prometheus/common v0.46.0 h1:doXzt5ybi1HBKpsZOL0sSkaNHJJqkyfEWZGGqqScV0Y= +github.com/prometheus/common v0.46.0/go.mod h1:Tp0qkxpb9Jsg54QMe+EAmqXkSV7Evdy1BTn+g2pa/hQ= github.com/prometheus/common/assets v0.2.0/go.mod h1:D17UVUE12bHbim7HzwUvtqm6gwBEaDQ0F+hIGbFbccI= github.com/prometheus/common/sigv4 v0.1.0 h1:qoVebwtwwEhS85Czm2dSROY5fTo2PAPEVdDeppTwGX4= github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57JrvHu9k5YwTjsNtI= -github.com/prometheus/exporter-toolkit v0.8.2/go.mod h1:00shzmJL7KxcsabLWcONwpyNEuWhREOnFqZW7vadFS0= -github.com/prometheus/exporter-toolkit v0.9.1/go.mod h1:iFlTmFISCix0vyuyBmm0UqOUCTao9+RsAsKJP3YM9ec= -github.com/prometheus/exporter-toolkit v0.10.0 h1:yOAzZTi4M22ZzVxD+fhy1URTuNRj/36uQJJ5S8IPza8= github.com/prometheus/exporter-toolkit v0.10.0/go.mod h1:+sVFzuvV5JDyw+Ih6p3zFxZNVnKQa3x5qPmDSiPu4ZY= +github.com/prometheus/exporter-toolkit v0.11.0 h1:yNTsuZ0aNCNFQ3aFTD2uhPOvr4iD7fdBvKPAEGkNf+g= +github.com/prometheus/exporter-toolkit v0.11.0/go.mod h1:BVnENhnNecpwoTLiABx7mrPB/OLRIgN74qlQbV+FK1Q= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= -github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= -github.com/prometheus/prometheus v0.43.0 h1:18iCSfrbAHbXvYFvR38U1Pt4uZmU9SmDcCpCrBKUiGg= -github.com/prometheus/prometheus v0.43.0/go.mod h1:2BA14LgBeqlPuzObSEbh+Y+JwLH2GcqDlJKbF2sA6FM= -github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/prometheus/prometheus v0.49.0 h1:i0CEhreJo3ZcZNeK7ulISinCac0MgL0krVOGgNmfFRY= +github.com/prometheus/prometheus v0.49.0/go.mod h1:aDogiyqmv3aBIWDb5z5Sdcxuuf2BOfiJwOIm9JGpMnI= github.com/protocolbuffers/txtpbfmt v0.0.0-20220428173112-74888fd59c2b h1:zd/2RNzIRkoGGMjE+YIsZ85CnDIz672JK2F3Zl4vux4= github.com/protocolbuffers/txtpbfmt v0.0.0-20220428173112-74888fd59c2b/go.mod h1:KjY0wibdYKc4DYkerHSbguaf3JeIPGhNJBp2BNiFH78= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= @@ -2629,11 +2910,9 @@ github.com/redis/go-redis/v9 v9.0.2 h1:BA426Zqe/7r56kCcvxYLWe1mkaz71LKF77GwgFzSx github.com/redis/go-redis/v9 v9.0.2/go.mod h1:/xDTe9EF1LM61hek62Poq2nzQSGj0xSrEtEHbBQevps= github.com/redis/rueidis v1.0.16 h1:ieB3AqZe9GcuTWZL8PFu1Mfn+pfqjBZAJEZh7zOcwSI= github.com/redis/rueidis v1.0.16/go.mod h1:8B+r5wdnjwK3lTFml5VtxjzGOQAC+5UmujoD12pDrEo= -github.com/remyoudompheng/bigfft v0.0.0-20190728182440-6a916e37a237/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rhnvrm/simples3 v0.5.0/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.3.4 h1:3Z3Eu6FGHZWSfNKJTOUiPatWwfc7DzJRU04jFUqJODw= github.com/rivo/uniseg v0.3.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -2642,13 +2921,9 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= -github.com/rogpeppe/go-internal v1.0.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.5.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= @@ -2656,13 +2931,11 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= -github.com/rubenv/sql-migrate v0.0.0-20190212093014-1007f53448d7/go.mod h1:WS0rl9eEliYI8DPnr3TOwz4439pay+qNgzJoVya/DmY= github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys= github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= @@ -2674,69 +2947,47 @@ github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfF github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.6.0/go.mod h1:U8+INwJo3nBv1m6A/8OBXAq7Jnpspk5AxSgDyEQcea8= github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= -github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4= -github.com/santhosh-tekuri/jsonschema/v2 v2.1.0/go.mod h1:yzJzKUGV4RbWqWIBBP4wSOBqavX5saE02yirLS0OTyg= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= -github.com/scaleway/scaleway-sdk-go v1.0.0-beta.14 h1:yFl3jyaSVLNYXlnNYM5z2pagEk1dYQhfr1p20T1NyKY= -github.com/scaleway/scaleway-sdk-go v1.0.0-beta.14/go.mod h1:fCa7OJZ/9DRTnOKmxvT6pn+LPWUptQAmHF/SBJUGEcg= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.21 h1:yWfiTPwYxB0l5fGMhl/G+liULugVIHD9AU77iNLrURQ= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.21/go.mod h1:fCa7OJZ/9DRTnOKmxvT6pn+LPWUptQAmHF/SBJUGEcg= github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= +github.com/scottlepp/go-duck v0.0.15 h1:qrSF3pXlXAA4a7uxAfLYajqXLkeBjv8iW1wPdSfkMj0= +github.com/scottlepp/go-duck v0.0.15/go.mod h1:GL+hHuKdueJRrFCduwBc7A7TQk+Tetc5BPXPVtduihY= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210219220335-367fa274be2c/go.mod h1:/THDZYi7F/BsVEcYzYPqdcWFQ+1C2InkawTKfLOAnzg= -github.com/segmentio/analytics-go v3.0.1+incompatible/go.mod h1:C7CYBtQWk4vRk2RyLu0qOcbHJ18E3F1HV2C/8JvKN48= -github.com/segmentio/analytics-go v3.1.0+incompatible/go.mod h1:C7CYBtQWk4vRk2RyLu0qOcbHJ18E3F1HV2C/8JvKN48= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/segmentio/backo-go v0.0.0-20160424052352-204274ad699c/go.mod h1:kJ9mm9YmoWSkk+oQ+5Cj8DEoRCX2JT6As4kEtIIOp1M= -github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc= -github.com/segmentio/conf v1.2.0/go.mod h1:Y3B9O/PqqWqjyxyWWseyj/quPEtMu1zDp/kVbSWWaB0= github.com/segmentio/encoding v0.3.6 h1:E6lVLyDPseWEulBmCmAKPanDd3jiyGDo5gMcugCRwZQ= github.com/segmentio/encoding v0.3.6/go.mod h1:n0JeuIqEQrQoPDGsjo8UNd1iA0U8d8+oHAA4E3G3OxM= -github.com/segmentio/go-snakecase v1.1.0/go.mod h1:jk1miR5MS7Na32PZUykG89Arm+1BUSYhuGR6b7+hJto= -github.com/segmentio/objconv v1.0.1/go.mod h1:auayaH5k3137Cl4SoXTgrzQcuQDmvuVtZgS0fb1Ahys= -github.com/sercand/kuberesolver/v4 v4.0.0/go.mod h1:F4RGyuRmMAjeXHKL+w4P7AwUnPceEAPAhxUgXZjKgvM= -github.com/serenize/snaker v0.0.0-20171204205717-a683aaf2d516/go.mod h1:Yow6lPLSAXx2ifx470yD/nUe22Dv5vBvxK/UK9UUTVs= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= -github.com/shoenig/test v0.6.2/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/shoenig/test v0.6.6/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= -github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= -github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= -github.com/shurcooL/highlight_go v0.0.0-20170515013102-78fb10f4a5f8/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= -github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 h1:bUGsEnyNbVPw06Bs80sCeARAlK8lhwqGyi6UT8ymuGk= github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= -github.com/shurcooL/octicon v0.0.0-20180602230221-c42b0e3b24d9/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= -github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c h1:aqg5Vm5dwtvL+YgDpBcK1ITf3o96N/K7/wsRXQnUTEs= +github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c/go.mod h1:owqhoLW1qZoYLZzLnBw+QkPP9WZnjlSWihhxAJC1+/M= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 h1:pXY9qYc/MP5zdvqWEUH6SjNiu7VhSjuVFTFiTcphaLU= github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= -github.com/sirupsen/logrus v1.0.4-0.20170822132746-89742aefa4b2/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= -github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= -github.com/sirupsen/logrus v1.1.0/go.mod h1:zrgwTnHtNr00buQ1vSptGe8m1f/BbgsPukg8qsT7A+A= -github.com/sirupsen/logrus v1.1.1/go.mod h1:zrgwTnHtNr00buQ1vSptGe8m1f/BbgsPukg8qsT7A+A= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY= github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v0.0.0-20180222194500-ef6db91d284a/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= @@ -2744,50 +2995,34 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= -github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= -github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/afero v1.2.0/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo= -github.com/spf13/afero v1.9.2 h1:j49Hj62F0n+DaZ1dDCvhABaPNSGNkt32oRFxI33IEMw= github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= -github.com/spf13/cast v1.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.3.2-0.20200723214538-8d17101741c8/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= -github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= -github.com/spf13/cobra v0.0.6/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= -github.com/spf13/cobra v0.0.7/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= -github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.2.1/go.mod h1:P4AexN0a+C9tGAnUFNwDMYYZv3pjFuvmeiMyKRaNVlI= -github.com/spf13/viper v1.3.1/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= -github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= -github.com/sqs/goreturns v0.0.0-20181028201513-538ac6014518/go.mod h1:CKI4AZ4XmGV240rTHfO0hfE83S6/a3/Q1siZJ/vXf7A= -github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693/go.mod h1:6hSY48PjDm4UObWmGLyJE9DxYVKTgR9kbCspXXJEhcU= -github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= -github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= +github.com/spyzhov/ajson v0.9.0 h1:tF46gJGOenYVj+k9K1U1XpCxVWhmiyY5PsVCAs1+OJ0= +github.com/spyzhov/ajson v0.9.0/go.mod h1:a6oSw0MMb7Z5aD2tPoPO+jq11ETKgXUr2XktHdT8Wt8= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/amqp v1.0.0/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= @@ -2812,41 +3047,22 @@ github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/subosito/gotenv v1.1.1/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= -github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf h1:Z2X3Os7oRzpdJ75iPqWZc0HeJWFYNCvKsfpQwFpRNTA= github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0= -github.com/tidwall/gjson v1.3.2/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls= -github.com/tidwall/gjson v1.6.8/go.mod h1:zeFuBCIqD4sN/gmqBzZ4j7Jd6UcA2Fc56x7QFsv+8fI= -github.com/tidwall/gjson v1.7.1/go.mod h1:5/xDoumyyDNerp2U36lyolv46b3uF/9Bu6OfyQ9GImk= -github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= -github.com/tidwall/match v1.0.3/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= -github.com/tidwall/pretty v1.0.2/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= -github.com/tidwall/pretty v1.1.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= -github.com/tidwall/sjson v1.0.4/go.mod h1:bURseu1nuBkFpIES5cz6zBtjmYeOQmEESshn7VpF15Y= -github.com/tidwall/sjson v1.1.5/go.mod h1:VuJzsZnTowhSxWdOgsAnb886i4AjEyTkk7tNtsL7EYE= -github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f h1:A+MmlgpvrHLeUP8dkBVn4Pnf5Bp5Yk2OALm7SEJLLE8= github.com/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f/go.mod h1:OBcG9bn7sHtXgarhUEb3OfCnNsgtGnkVf41ilSZ3K3E= -github.com/uber-go/atomic v1.3.2/go.mod h1:/Ct5t2lcmbJ4OSe/waGBoaVvVqtO0bmtfVNex1PFV8g= -github.com/uber/jaeger-client-go v2.15.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= -github.com/uber/jaeger-client-go v2.22.1+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= -github.com/uber/jaeger-client-go v2.28.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= -github.com/uber/jaeger-lib v1.5.0/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= -github.com/uber/jaeger-lib v2.2.0+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg= github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= @@ -2862,8 +3078,6 @@ github.com/unknwon/com v1.0.1 h1:3d1LTxD+Lnf3soQiD4Cp/0BRB+Rsa/+RTvz8GMMzIXs= github.com/unknwon/com v1.0.1/go.mod h1:tOOxU81rwgoCLoOVVPHb6T/wt8HZygqH5id+GNnlCXM= github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 h1:4EYQaWAatQokdji3zqZloVIW/Ke1RQjYw2zHULyrHJg= github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3/go.mod h1:1xEUf2abjfP92w2GZTV+GgaRxXErwRXcClbUwrNJffU= -github.com/unrolled/secure v0.0.0-20180918153822-f340ee86eb8b/go.mod h1:mnPT77IAdsi/kV7+Es7y+pXALeV3h7G6dQF6mNYjcLA= -github.com/unrolled/secure v0.0.0-20181005190816-ff9db2ff917f/go.mod h1:mnPT77IAdsi/kV7+Es7y+pXALeV3h7G6dQF6mNYjcLA= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk= @@ -2883,12 +3097,10 @@ github.com/vectordotdev/go-datemath v0.1.1-0.20220323213446-f3954d0b18ae h1:oyiy github.com/vectordotdev/go-datemath v0.1.1-0.20220323213446-f3954d0b18ae/go.mod h1:PnwzbSst7KD3vpBzzlntZU5gjVa455Uqa5QPiKSYJzQ= github.com/vultr/govultr/v2 v2.17.2 h1:gej/rwr91Puc/tgh+j33p/BLR16UrIPnSr+AIwYWZQs= github.com/vultr/govultr/v2 v2.17.2/go.mod h1:ZFOKGWmgjytfyjeyAdhQlSWwTjh2ig+X49cAp50dzXI= -github.com/weaveworks/common v0.0.0-20230511094633-334485600903 h1:ph7R2CS/0o1gBzpzK/CioUKJVsXNVXfDGR8FZ9rMZIw= -github.com/weaveworks/common v0.0.0-20230511094633-334485600903/go.mod h1:rgbeLfJUtEr+G74cwFPR1k/4N0kDeaeSv/qhUNE4hm8= -github.com/weaveworks/promrus v1.2.0 h1:jOLf6pe6/vss4qGHjXmGz4oDJQA+AOCqEL3FvvZGz7M= -github.com/weaveworks/promrus v1.2.0/go.mod h1:SaE82+OJ91yqjrE1rsvBWVzNZKcHYFtMUyS1+Ogs/KA= github.com/wk8/go-ordered-map v1.0.0 h1:BV7z+2PaK8LTSd/mWgY12HyMAo5CEgkHqbkVq2thqr8= github.com/wk8/go-ordered-map v1.0.0/go.mod h1:9ZIbRunKbuvfPKyBP1SIKLcXNlv74YCOZ3t3VTS6gRk= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= @@ -2898,13 +3110,9 @@ github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6 github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= -github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xhit/go-str2duration v1.2.0/go.mod h1:3cPSlfZlUHVlneIVfePFWcJZsuwf+P1v2SRTV4cUmp4= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= @@ -2915,7 +3123,8 @@ github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= -github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM= +github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2 h1:zzrxE1FKn5ryBNl9eKOeqQ58Y/Qpo3Q9QNxKHX5uzzQ= +github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2/go.mod h1:hzfGeIUDq/j97IG+FhNqkowIyEcD88LrW6fyU3K3WqY= github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= github.com/yalue/merged_fs v1.2.2 h1:vXHTpJBluJryju7BBpytr3PDIkzsPMpiEknxVGPhN/I= github.com/yalue/merged_fs v1.2.2/go.mod h1:WqqchfVYQyclV2tnR7wtRhBddzBvLVR83Cjw9BKQw0M= @@ -2942,13 +3151,7 @@ github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= -github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE= -go.elastic.co/apm v1.8.0/go.mod h1:tCw6CkOJgkWnzEthFN9HUP1uL3Gjc/Ur6m7gRPLaoH0= -go.elastic.co/apm/module/apmhttp v1.8.0/go.mod h1:9LPFlEON51/lRbnWDfqAWErihIiAFDUMfMV27YjoWQ8= -go.elastic.co/apm/module/apmot v1.8.0/go.mod h1:Q5Xzabte8G/fkvDjr1jlDuOSUt9hkVWNZEHh6ZNaTjI= -go.elastic.co/fastjson v1.0.0/go.mod h1:PmeUOMMtLHQr9ZS9J9owrAVg0FkaZDRZJEFTTGHtchs= -go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA= go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= @@ -2976,16 +3179,11 @@ go.etcd.io/etcd/raft/v3 v3.5.10 h1:cgNAYe7xrsrn/5kXMSaH8kM/Ky8mAdMqGOxyYwpP0LA= go.etcd.io/etcd/raft/v3 v3.5.10/go.mod h1:odD6kr8XQXTy9oQnyMPBOr0TVe+gT0neQhElQ6jbGRc= go.etcd.io/etcd/server/v3 v3.5.10 h1:4NOGyOwD5sUZ22PiWYKmfxqoeh72z6EhYjNosKGLmZg= go.etcd.io/etcd/server/v3 v3.5.10/go.mod h1:gBplPHfs6YI0L+RpGkTQO7buDbHv5HJGG/Bst0/zIPo= -go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.mongodb.org/mongo-driver v1.1.0/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= -go.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= -go.mongodb.org/mongo-driver v1.3.0/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE= -go.mongodb.org/mongo-driver v1.3.4/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE= go.mongodb.org/mongo-driver v1.7.3/go.mod h1:NqaYOwnXWr5Pm7AOpO5QFxKJ503nbMse/R79oO62zWg= go.mongodb.org/mongo-driver v1.7.5/go.mod h1:VXEWRZ6URJIkUq2SCAyapmhH0ZLRBP+FT4xhp5Zvxng= go.mongodb.org/mongo-driver v1.10.0/go.mod h1:wsihk0Kdgv8Kqu1Anit4sfK+22vSFbUrAVEYRhCXrA8= -go.mongodb.org/mongo-driver v1.11.2/go.mod h1:s7p5vEtfbeR1gYi6pnj3c3/urpbLv2T5Sfd6Rp2HBB8= -go.mongodb.org/mongo-driver v1.11.3/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= +go.mongodb.org/mongo-driver v1.13.0/go.mod h1:/rGBTebI3XYboVmgz+Wv3Bcbl3aD0QF9zl6kDDw18rQ= go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk= go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo= go.opencensus.io v0.15.0/go.mod h1:UffZAU+4sDEINUGP/B7UfBBkq4fqLu9zXAX7ke6CHW0= @@ -2993,7 +3191,6 @@ go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.1/go.mod h1:Ap50jQcDJrx6rB6VgeeFPtuPIf3wMRvRfrfYDO6+BmA= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -3001,62 +3198,67 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/collector/pdata v1.0.0-rc8 h1:vBikWdZFsRiT5dVsLQhnE99w3edM7eem3Q9dSqMlStE= -go.opentelemetry.io/collector/pdata v1.0.0-rc8/go.mod h1:BVCBhWgclYCh7Oi6BkMiQfRa6MXv1uRTlKXuL5oBby8= -go.opentelemetry.io/contrib v0.18.0/go.mod h1:G/EtFaa6qaN7+LxqfIAT3GiZa7Wv5DTBUzl5H4LY0Kc= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 h1:SpGay3w+nEwMpfVnbqOLH5gY52/foP8RE8UzTZ1pdSE= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1/go.mod h1:4UoMYEZOC0yN/sPGH76KPkkU7zgiEWYWL9vwmbnTJPE= -go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.18.0/go.mod h1:iK1G0FgHurSJ/aYLg5LpnPI0pqdanM73S3dhyDp0Lk4= -go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 h1:gbhw/u49SS3gkPWiYweQNJGm/uJN5GkI/FrosxSHT7A= -go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1/go.mod h1:GnOaBaFQ2we3b9AGWJpsBa7v1S5RlQzlC3O7dRMxZhM= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= +go.opentelemetry.io/collector/featuregate v1.0.0/go.mod h1:xGbRuw+GbutRtVVSEy3YR2yuOlEyiUMhN2M9DJljgqY= +go.opentelemetry.io/collector/pdata v1.0.0/go.mod h1:TsDFgs4JLNG7t6x9D8kGswXUz4mme+MyNChHx8zSF6k= +go.opentelemetry.io/collector/pdata v1.0.1 h1:dGX2h7maA6zHbl5D3AsMnF1c3Nn+3EUftbVCLzeyNvA= +go.opentelemetry.io/collector/pdata v1.0.1/go.mod h1:jutXeu0QOXYY8wcZ/hege+YAnSBP3+jpTqYU1+JTI5Y= +go.opentelemetry.io/collector/semconv v0.90.1/go.mod h1:j/8THcqVxFna1FpvA2zYIsUperEtOaRaqoLYIN4doWw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.49.0 h1:RtcvQ4iw3w9NBB5yRwgA4sSa82rfId7n4atVpvKx3bY= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.49.0/go.mod h1:f/PbKbRd4cdUICWell6DmzvVJ7QrmBgFrRHjXmAXbK4= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= -go.opentelemetry.io/contrib/propagators/jaeger v1.21.1 h1:f4beMGDKiVzg9IcX7/VuWVy+oGdjx3dNJ72YehmtY5k= -go.opentelemetry.io/contrib/propagators/jaeger v1.21.1/go.mod h1:U9jhkEl8d1LL+QXY7q3kneJWJugiN3kZJV2OWz3hkBY= -go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1 h1:Qb+5A+JbIjXwO7l4HkRUhgIn4Bzz0GNS2q+qdmSx+0c= -go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1/go.mod h1:G4vNCm7fRk0kjZ6pGNLo5SpLxAUvOfSrcaegnT8TPck= -go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/contrib/propagators/jaeger v1.22.0 h1:bAHX+zN/inu+Rbqk51REmC8oXLl+Dw6pp9ldQf/onaY= +go.opentelemetry.io/contrib/propagators/jaeger v1.22.0/go.mod h1:bH9GkgkN21mscXcQP6lQJYI8XnEPDxlTN/ZOBuHDjqE= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.18.0 h1:Q9PrD94WoMolBx44ef5UWWvufpVSME0MiSymXZfedso= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.18.0/go.mod h1:tjp49JHNvreAAoWjdCHIVD7NXMjuJ3Dp/9iNOuPPlC8= +go.opentelemetry.io/otel v1.17.0/go.mod h1:I2vmBGtFaODIVMBSTPVDlJSzBDNf93k60E6Ft0nyjo0= go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= go.opentelemetry.io/otel/exporters/jaeger v1.10.0 h1:7W3aVVjEYayu/GOqOVF4mbTvnCuxF1wWu3eRxFGQXvw= go.opentelemetry.io/otel/exporters/jaeger v1.10.0/go.mod h1:n9IGyx0fgyXXZ/i0foLHNxtET9CzXHzZeKCucvRBFgA= -go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.14.0/go.mod h1:UFG7EBMRdXyFstOwH028U0sVf+AvukSGhF0g8+dmNG8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.14.0/go.mod h1:HrbCVv40OOLTABmOn1ZWty6CHXkU8DK/Urc43tHug70= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 h1:cl5P5/GIfFh4t6xyruOgJP5QiA1pw4fYYdv6nc6CBWw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0/go.mod h1:zgBdWWAu7oEEMC06MMKc5NLbA/1YDXV1sMpSqEeLQLg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.14.0/go.mod h1:5w41DY6S9gZrbjuq6Y+753e96WfPha5IcsOSZTtullM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0 h1:tIqheXEFWAZ7O8A7m+J0aPTmpJN3YQ7qetUAdkkkKpk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0/go.mod h1:nUeKExfxAQVbiVFn32YXpXZZHZ61Cc3s3Rn1pDBGAb0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.14.0/go.mod h1:+N7zNjIJv4K+DeX67XXET0P+eIciESgaFDBqh+ZJFS4= -go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 h1:Mw5xcxMwlqoJd97vwPxA8isEaIoxsta9/Q51+TTJLGE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0/go.mod h1:CQNu9bj7o7mC6U7+CA/schKEYakYXWr79ucDHTMGhCM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0/go.mod h1:/OpE/y70qVkndM0TrxT4KBoN3RsFZP0QaofcfYrj76I= +go.opentelemetry.io/otel/metric v1.17.0/go.mod h1:h4skoxdZI17AxwITdmdZjjYJQH5nzijUUjm+wtPph5o= go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= -go.opentelemetry.io/otel/oteltest v0.18.0/go.mod h1:NyierCU3/G8DLTva7KRzGii2fdxdR89zXKH1bNWY7Bo= -go.opentelemetry.io/otel/sdk v1.14.0/go.mod h1:bwIC5TjrNG6QDCHNWvW4HLHtUQ4I+VQDsnjhvyZCALM= -go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/sdk v1.17.0/go.mod h1:U87sE0f5vQB7hwUoW98pW5Rz4ZDuCFBZFNUBlSgmDFQ= go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= -go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= +go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= +go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= +go.opentelemetry.io/otel/trace v1.17.0/go.mod h1:I/4vKTgFclIsXRVucpH25X0mpFSczM7aHeaz0ZBLWjY= go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= -go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= +go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI= +go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY= go.starlark.net v0.0.0-20230525235612-a134d8f9ddca h1:VdD38733bfYv5tUZwEIskMM93VanwNIi5bIKnDrJdEY= go.starlark.net v0.0.0-20230525235612-a134d8f9ddca/go.mod h1:jxU+3+j+71eXOW14274+SmmuW82qJzl6iZSeqEtTGds= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/atomic v1.5.1/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/automaxprocs v1.5.1/go.mod h1:BF4eumQw0P9GtnuxxovUd06vwm1o18oMzFtK66vU6XU= +go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= -go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= @@ -3075,53 +3277,31 @@ go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= -go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= -go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= gocloud.dev v0.25.0 h1:Y7vDq8xj7SyM848KXf32Krda2e6jQ4CLh/mTeCSqXtk= gocloud.dev v0.25.0/go.mod h1:7HegHVCYZrMiU3IE1qtnzf/vRrDwLYnRNR3EhWX8x9Y= -golang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20180830192347-182538f80094/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181001203147-e3636079e1a4/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181009213950-7c1a557ab941/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181015023909-0c41d7ab0a0e/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181024171144-74cb1d3d52f4/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181025113841-85e1b3f9139a/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181025213731-e84da0312774/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181106171534-e4dc69e5b2fd/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181127143415-eb0de9b17e85/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190102171810-8d7daa0c54b3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190103213133-ff983b9c42bc/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190617133340-57b3e21c3d56/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200320181102-891825fb96df/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= @@ -3134,28 +3314,34 @@ golang.org/x/crypto v0.0.0-20210915214749-c084706c2272/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20221012134737-56aed061732a/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190312203227-4b39c73a6495/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE= @@ -3165,11 +3351,12 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20220328175248-053ad81199eb/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp v0.0.0-20230108222341-4b8118a2686a/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= -golang.org/x/exp v0.0.0-20230307190834-24139beb5833/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= -golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug= -golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8= +golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8= +golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -3208,43 +3395,36 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180816102801-aaf60122140d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180921000356-2f5d2388922f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180926154720-4dfa2610cdf3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181011144130-49bb7cea24b1/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181017193950-04a2e542c03f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181108082009-03003ca0c849/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181207154023-610586996380/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190320064053-1272bf9dcd53/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190424112056-4829fb13d2c6/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -3253,12 +3433,10 @@ golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191003171128-d98b1b443823/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191112182307-2180aed22343/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200219183655-46282727080f/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -3268,15 +3446,14 @@ golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -3290,15 +3467,12 @@ golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210917221730-978cfadd31cf/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211008194852-3b03d305991f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= @@ -3312,7 +3486,6 @@ golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.0.0-20220921155015-db77216a4ee9/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= @@ -3325,12 +3498,18 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= +golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20181003184128-c57b0facaced/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -3362,9 +3541,16 @@ golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec= golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= +golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= -golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= +golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI= +golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk= +golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= +golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= +golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM= golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= +golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= +golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -3383,40 +3569,28 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sys v0.0.0-20180816055513-1c9583448a9c/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180831094639-fa5fdf94c789/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180921163948-d47a0f339242/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180927150500-dad3d9fb7b6e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181005133103-4497e2df6f9e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181011152604-fa43e7bc11ba/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181022134430-8a28ead16f52/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181024145615-5cd93ef61a7c/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181025063200-d989b31c8746/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026064943-731415f00dce/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181106135930-3a76605856fd/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181206074257-70b957f3b65e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181221143128-b4a75ba826a6/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190102155601-82a175fd1598/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190116161447-11f53e031339/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -3424,10 +3598,8 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190426135247-a129542de9ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190515120540-06a5c4944438/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -3440,9 +3612,7 @@ golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191020152052-9984515f0562/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191025021431-6c3a3bfe00ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191105231009-c1f44814a5cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191112214154-59a1497f0cea/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -3451,7 +3621,6 @@ golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200121082415-34d275377bf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -3464,12 +3633,10 @@ golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200828194041-157a740278f4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -3534,14 +3701,11 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -3551,11 +3715,15 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -3568,10 +3736,16 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -3588,6 +3762,9 @@ golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= @@ -3599,41 +3776,18 @@ golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181003024731-2f84ea8ef872/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181006002542-f60d9635b16a/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181008205924-a2b3f7f249e9/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181013182035-5e66757b835f/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181017214349-06f26fdaaa28/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181024171208-a2dc47679d30/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181026183834-f60e5f99f081/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181105230042-78dc5bac0cac/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181107215632-34b416bd17b3/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181114190951-94339b83286c/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181119130350-139d099f6620/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181127195227-b4e97c0ed882/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181127232545-e782529d0ddd/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181203210056-e5f3ab76ea4b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181205224935-3576414c54a4/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181206194817-bcd4e47d0288/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181207183836-8bc39b988060/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181212172921-837e80568c09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190102213336-ca9055ed7d04/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190104182027-498d95493402/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190111214448-fc1d57b08d7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190118193359-16909d206f00/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= @@ -3652,20 +3806,14 @@ golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190613204242-ed0dc450797f/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190617190820-da514acc4774/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190624190245-7f2218787638/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190711191110-9a621aea19f8/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191004055002-72853e10c5a3/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -3676,31 +3824,25 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191224055732-dd894d0a8a40/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117220505-0cba7a3a9ee9/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200203215610-ab391d50b528/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200308013534-11ec41452d41/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200522201501-cb1345f3a375/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200717024301-6ddee64345a6/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= @@ -3719,7 +3861,7 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -3728,8 +3870,14 @@ golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= -golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= +golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= +golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM= +golang.org/x/tools v0.12.1-0.20230815132531-74c255bcf846/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -3739,10 +3887,10 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= -gonum.org/v1/gonum v0.6.2/go.mod h1:9mxDZsDKxgMAuccQkewq682L+0eCu4dCN2yonUJTCLU= gonum.org/v1/gonum v0.7.0/go.mod h1:L02bwd0sqlsvRv41G7wGWFCsVNZFv/k1xzGIxeANHGM= gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= gonum.org/v1/gonum v0.9.3/go.mod h1:TZumC3NeyVQskjXqmyWt4S3bINhy7B4eYwW69EbyX+0= @@ -3750,9 +3898,7 @@ gonum.org/v1/gonum v0.11.0/go.mod h1:fSG4YDCxxUZQJ7rKsQrj0gMOg00Il0Z96/qMA4bVQhA gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o= gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY= gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= -gonum.org/v1/netlib v0.0.0-20191229114700-bbb4dff026f8/go.mod h1:2IgXn/sJaRbePPBA1wRj8OE+QLvVaH0q8SK6TSTKlnk= gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= -gonum.org/v1/plot v0.0.0-20200111075622-4abb28f724d5/go.mod h1:+HbaZVpsa73UwN7kXGCECULRHovLRJjH+t5cFPgxErs= gonum.org/v1/plot v0.9.0/go.mod h1:3Pcqqmp6RHvJI72kgb8fThyUnav364FOsdDo2aGW5lY= gonum.org/v1/plot v0.10.1/go.mod h1:VZW5OlhkL1mysU9vaqNHnsy86inf6Ot+jB3r+BczCEo= google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= @@ -3821,8 +3967,18 @@ google.golang.org/api v0.107.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/ google.golang.org/api v0.108.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI= google.golang.org/api v0.111.0/go.mod h1:qtFHvU9mhgTJegR31csQ+rwxyUTHOKFqCKWp1J0fdw0= -google.golang.org/api v0.148.0 h1:HBq4TZlN4/1pNcu0geJZ/Q50vIwIXT532UIMYoo0vOs= -google.golang.org/api v0.148.0/go.mod h1:8/TBgwaKjfqTdacOJrOv2+2Q6fBDU1uHKK06oGSkxzU= +google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg= +google.golang.org/api v0.118.0/go.mod h1:76TtD3vkgmZ66zZzp72bUUklpmQmKlhh6sYtIjYK+5E= +google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms= +google.golang.org/api v0.124.0/go.mod h1:xu2HQurE5gi/3t1aFCvhPD781p0a3p11sdunTJ2BlP4= +google.golang.org/api v0.125.0/go.mod h1:mBwVAtz+87bEN6CbA1GtZPDOqY2R5ONPqJeIlvyo4Aw= +google.golang.org/api v0.126.0/go.mod h1:mBwVAtz+87bEN6CbA1GtZPDOqY2R5ONPqJeIlvyo4Aw= +google.golang.org/api v0.128.0/go.mod h1:Y611qgqaE92On/7g65MQgxYul3c0rEB894kniWLY750= +google.golang.org/api v0.139.0/go.mod h1:CVagp6Eekz9CjGZ718Z+sloknzkDJE7Vc1Ckj9+viBk= +google.golang.org/api v0.149.0/go.mod h1:Mwn1B7JTXrzXtnvmzQE2BD6bYZQ8DShKZDZbeN9I7qI= +google.golang.org/api v0.153.0/go.mod h1:3qNJX5eOmhiWYc67jRA/3GsDw97UFb5ivv7Y2PrriAY= +google.golang.org/api v0.155.0 h1:vBmGhCYs0djJttDNynWo44zosHlPvHmA0XiN2zP2DtA= +google.golang.org/api v0.155.0/go.mod h1:GI5qK5f40kCpHfPn6+YzGAByIKWv8ujFnmoWm7Igduk= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -3832,8 +3988,9 @@ google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180518175338-11a468237815/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= @@ -3843,8 +4000,6 @@ google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRn google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= -google.golang.org/genproto v0.0.0-20190626174449-989357319d63/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= -google.golang.org/genproto v0.0.0-20190708153700-3bdd9d9f5532/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= @@ -3870,11 +4025,9 @@ google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEY google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200806141610-86f49bd18e98/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200911024640-645f7a48b24f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= @@ -3894,6 +4047,7 @@ google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxH google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= +google.golang.org/genproto v0.0.0-20210630183607-d20f26d13c79/go.mod h1:yiaVoXHpRzHGyxV3o4DktVWY4mSUErTKaeEOq6C3t3U= google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= @@ -3915,7 +4069,6 @@ google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ6 google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220107163113-42d7afdf6368/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220111164026-67b88f271998/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= @@ -3986,13 +4139,79 @@ google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44/go.mod h1:8B0gmkoRebU8ukX6HP+4wrVQUY1+6PkQ44BSyIlflHA= google.golang.org/genproto v0.0.0-20230222225845-10f96fb3dbec/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw= google.golang.org/genproto v0.0.0-20230223222841-637eb2293923/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw= +google.golang.org/genproto v0.0.0-20230303212802-e74f57abe488/go.mod h1:TvhZT5f700eVlTNwND1xoEZQeWTB2RY/65kplwl/bFA= google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s= -google.golang.org/genproto v0.0.0-20231012201019-e917dd12ba7a h1:fwgW9j3vHirt4ObdHoYNwuO24BEZjSzbh+zPaNWoiY8= +google.golang.org/genproto v0.0.0-20230320184635-7606e756e683/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s= +google.golang.org/genproto v0.0.0-20230323212658-478b75c54725/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= +google.golang.org/genproto v0.0.0-20230330154414-c0448cd141ea/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= +google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= +google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= +google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= +google.golang.org/genproto v0.0.0-20230525234025-438c736192d0/go.mod h1:9ExIQyXL5hZrHzQceCwuSYwZZ5QZBazOcprJ5rgs3lY= +google.golang.org/genproto v0.0.0-20230526161137-0005af68ea54/go.mod h1:zqTuNwFlFRsw5zIts5VnzLQxSRqh+CGOTVMlYbY0Eyk= +google.golang.org/genproto v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:zqTuNwFlFRsw5zIts5VnzLQxSRqh+CGOTVMlYbY0Eyk= +google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:xZnkP7mREFX5MORlOPEzLMr+90PPZQ2QWzrVTWfAq64= +google.golang.org/genproto v0.0.0-20230629202037-9506855d4529/go.mod h1:xZnkP7mREFX5MORlOPEzLMr+90PPZQ2QWzrVTWfAq64= +google.golang.org/genproto v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:O9kGHb51iE/nOGvQaDUuadVYqovW56s5emA88lQnj6Y= +google.golang.org/genproto v0.0.0-20230711160842-782d3b101e98/go.mod h1:S7mY02OqCJTD0E1OiQy1F72PWFB4bZJ87cAtLPYgDR0= +google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:0ggbjUrZYpy1q+ANUS30SEoGZ53cdfwtbuG7Ptgy108= +google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5/go.mod h1:oH/ZOT02u4kWEp7oYBGYFFkCdKS/uYR9Z7+0/xuuFp8= +google.golang.org/genproto v0.0.0-20230821184602-ccc8af3d0e93/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4= +google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4= +google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4= +google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:CCviP9RmpZ1mxVr8MUjCnSiY09IbAXZxhLE6EhHIdPU= +google.golang.org/genproto v0.0.0-20231002182017-d307bd883b97/go.mod h1:t1VqOqqvce95G3hIDCT5FeO3YUc6Q4Oe24L/+rNMxRk= google.golang.org/genproto v0.0.0-20231012201019-e917dd12ba7a/go.mod h1:EMfReVxb80Dq1hhioy0sOsY9jCE46YDgHlJ7fWVUWRE= -google.golang.org/genproto/googleapis/api v0.0.0-20231002182017-d307bd883b97 h1:W18sezcAYs+3tDZX4F80yctqa12jcP1PUS2gQu1zTPU= +google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:CgAqfJo+Xmu0GwA0411Ht3OU3OntXwsGmrmjI8ioGXI= +google.golang.org/genproto v0.0.0-20231030173426-d783a09b4405/go.mod h1:3WDQMjmJk36UQhjQ89emUzb1mdaHcPeeAh4SCBKznB4= +google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY= +google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f/go.mod h1:nWSwAFPb+qfNJXsoeO3Io7zf4tMSfN8EA8RlDA04GhY= +google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 h1:KAeGQVN3M9nD0/bQXnr/ClcEMJ968gUXJQ9pwfSynuQ= +google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80/go.mod h1:cc8bqMqtv9gMOr0zHg2Vzff5ULhhL2IXP4sbcn32Dro= +google.golang.org/genproto/googleapis/api v0.0.0-20230525234020-1aefcd67740a/go.mod h1:ts19tUU+Z0ZShN1y3aPyq2+O3d5FUNNgT6FtOzmrNn8= +google.golang.org/genproto/googleapis/api v0.0.0-20230525234035-dd9d682886f9/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= +google.golang.org/genproto/googleapis/api v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= +google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= +google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= +google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:mPBs5jNgx2GuQGvFwUvVKqtn6HsUw9nP64BedgvqEsQ= +google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98/go.mod h1:rsr7RhLuwsDKL7RmgDDCUc6yaGr1iqceVb5Wv6f6YvQ= +google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:rsr7RhLuwsDKL7RmgDDCUc6yaGr1iqceVb5Wv6f6YvQ= +google.golang.org/genproto/googleapis/api v0.0.0-20230803162519-f966b187b2e5/go.mod h1:5DZzOUPCLYL3mNkQ0ms0F3EuUNZ7py1Bqeq6sxzI7/Q= +google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d/go.mod h1:KjSP20unUpOx5kyQUFa7k4OJg0qeJ7DEZflGDu2p6Bk= +google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb/go.mod h1:KjSP20unUpOx5kyQUFa7k4OJg0qeJ7DEZflGDu2p6Bk= +google.golang.org/genproto/googleapis/api v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:RdyHbowztCGQySiCvQPgWQWgWhGnouTdCflKoDBt32U= google.golang.org/genproto/googleapis/api v0.0.0-20231002182017-d307bd883b97/go.mod h1:iargEX0SFPm3xcfMI0d1domjg0ZF4Aa0p2awqyxhvF0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b h1:ZlWIi1wSK56/8hn4QcBp/j9M7Gt3U/3hZw3mC7vDICo= +google.golang.org/genproto/googleapis/api v0.0.0-20231012201019-e917dd12ba7a/go.mod h1:SUBoKXbI1Efip18FClrQVGjWcyd0QZd8KkvdP34t7ww= +google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:IBQ646DjkDkvUIsVq/cc03FUFQ9wbZu7yE396YcL870= +google.golang.org/genproto/googleapis/api v0.0.0-20231030173426-d783a09b4405/go.mod h1:oT32Z4o8Zv2xPQTg0pbVaPr0MPOH6f14RgXt7zfIpwg= +google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4= +google.golang.org/genproto/googleapis/api v0.0.0-20231127180814-3a041ad873d4/go.mod h1:k2dtGpRrbsSyKcNPKKI5sstZkrNCZwpU/ns96JoHbGg= +google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80 h1:Lj5rbfG876hIAYFjqiJnPHfhXbv+nzTWfm04Fg/XSVU= +google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80/go.mod h1:4jWUdICTdgc3Ibxmr8nAJiiLHwQBY0UI0XZcEMaFKaA= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:ylj+BE99M198VPbBh6A8d9n3w8fChvyLK3wwBOjXBFA= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20230807174057-1744710a1577/go.mod h1:NjCQG/D8JandXxM57PZbAJL1DCNL6EypA0vPPwfsc7c= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20231030173426-d783a09b4405/go.mod h1:GRUCuLdzVqZte8+Dl/D4N25yLzcGqqWaYkeVOwulFqw= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20231120223509-83a465c0220f/go.mod h1:iIgEblxoG4klcXsG0d9cpoxJ4xndv6+1FkDROCHhPRI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234015-3fc162c6f38a/go.mod h1:xURIpW9ES5+/GZhnV6beoEtxQrnkRGIfP5VQG2tCBLc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:8mL13HKkDa+IuJ8yruA3ci0q+0vsUz4m//+ottjwS5o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230731190214-cbb8c96f2d6d/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230803162519-f966b187b2e5/go.mod h1:zBEcrKX2ZOcEkHWxBPAIvYUWOKKMIhYcmNiUIu2ji3I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230920183334-c177e329c48b/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:KSqppvjFjtoCI+KGd4PELB0qLNxdJHRGqRI09mB6pQA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231012201019-e917dd12ba7a/go.mod h1:4cYg8o5yUbm77w8ZX00LhMVNl/YVBFJRYWDc0uYWMs0= google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:swOH3j0KzcDDgGUWr+SNpyTen5YrXjS3eyPzFYKc6lc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405/go.mod h1:67X1fPuzjcrkymZzZV1vvkFeTn2Rvc6lYF9MYFGCcwE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 h1:AjyfHzEPEFp/NpvfN5g+KDla3EMojjhRVZc1i7cj+oM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s= google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= @@ -4002,7 +4221,6 @@ google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLD google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.22.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= @@ -4041,12 +4259,20 @@ google.golang.org/grpc v1.50.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCD google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww= google.golang.org/grpc v1.52.0/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5vorUY= +google.golang.org/grpc v1.52.3/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5vorUY= google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= -google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= +google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= +google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8= +google.golang.org/grpc v1.56.1/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s= +google.golang.org/grpc v1.56.2/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s= +google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo= +google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= google.golang.org/grpc/cmd/protoc-gen-go-grpc v0.0.0-20200910201057-6591123024b3/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= -google.golang.org/grpc/examples v0.0.0-20210304020650-930c79186c99/go.mod h1:Ly7ZA/ARzg8fnPU9TyZIxoz33sEUuWX7txiqs8lPTgE= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -4062,12 +4288,11 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.29.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gopkg.in/DataDog/dd-trace-go.v1 v1.27.0/go.mod h1:Sp1lku8WJMvNV0kjDI4Ni/T7J/U3BO5ct5kEaoVU8+I= -gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= @@ -4083,37 +4308,24 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy gopkg.in/fsnotify/fsnotify.v1 v1.4.7 h1:XNNYLJHt73EyYiCZi6+xjupS9CpvmiDgjPTAjrBlQbo= gopkg.in/fsnotify/fsnotify.v1 v1.4.7/go.mod h1:Fyux9zXlo4rWoMSIzpn9fDAYjalPqJ/K1qJ27s+7ltE= gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= -gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= -gopkg.in/go-playground/mold.v2 v2.2.0/go.mod h1:XMyyRsGtakkDPbxXbrA5VODo6bUXyvoDjLd5l3T0XoA= gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= -gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= -gopkg.in/gorp.v1 v1.7.2/go.mod h1:Wo3h+DBQZIxATwftsglhdD/62zRFPhGhTiu5jUJmCaw= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.66.6/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/mail.v2 v2.0.0-20180731213649-a0242b2233b4/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -gopkg.in/square/go-jose.v2 v2.1.9/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= -gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= -gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= -gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= -gopkg.in/telebot.v3 v3.1.3/go.mod h1:GJKwwWqp9nSkIVN51eRKU78aB5f5OnQuWdwiIZfPbko= +gopkg.in/telebot.v3 v3.2.1/go.mod h1:GJKwwWqp9nSkIVN51eRKU78aB5f5OnQuWdwiIZfPbko= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19/go.mod h1:o4V0GXN9/CAmCsvJ0oXYZvrZOe7syiDZSN1GWGZTGzc= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -4121,7 +4333,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= @@ -4148,59 +4359,64 @@ honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= -howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= -k8s.io/api v0.26.2/go.mod h1:1kjMQsFE+QHPfskEcVNgL3+Hp88B80uj0QtSOlj8itU= -k8s.io/api v0.29.0 h1:NiCdQMY1QOp1H8lfRyeEf8eOwV6+0xA6XEE44ohDX2A= -k8s.io/api v0.29.0/go.mod h1:sdVmXoz2Bo/cb77Pxi71IPTSErEW32xa4aXwKH7gfBA= -k8s.io/apimachinery v0.26.2/go.mod h1:ats7nN1LExKHvJ9TmwootT00Yz05MuYqPXEXaVeOy5I= -k8s.io/apimachinery v0.29.0 h1:+ACVktwyicPz0oc6MTMLwa2Pw3ouLAfAon1wPLtG48o= -k8s.io/apimachinery v0.29.0/go.mod h1:eVBxQ/cwiJxH58eK/jd/vAk4mrxmVlnpBH5J2GbMeis= -k8s.io/apiserver v0.29.0 h1:Y1xEMjJkP+BIi0GSEv1BBrf1jLU9UPfAnnGGbbDdp7o= -k8s.io/apiserver v0.29.0/go.mod h1:31n78PsRKPmfpee7/l9NYEv67u6hOL6AfcE761HapDM= -k8s.io/client-go v0.26.2/go.mod h1:u5EjOuSyBa09yqqyY7m3abZeovO/7D/WehVVlZ2qcqU= -k8s.io/client-go v0.29.0 h1:KmlDtFcrdUzOYrBhXHgKw5ycWzc3ryPX5mQe0SkG3y8= -k8s.io/client-go v0.29.0/go.mod h1:yLkXH4HKMAywcrD82KMSmfYg2DlE8mepPR4JGSo5n38= -k8s.io/component-base v0.29.0 h1:T7rjd5wvLnPBV1vC4zWd/iWRbV8Mdxs+nGaoaFzGw3s= -k8s.io/component-base v0.29.0/go.mod h1:sADonFTQ9Zc9yFLghpDpmNXEdHyQmFIGbiuZbqAXQ1M= +k8s.io/api v0.28.4/go.mod h1:axWTGrY88s/5YE+JSt4uUi6NMM+gur1en2REMR7IRj0= +k8s.io/api v0.29.2 h1:hBC7B9+MU+ptchxEqTNW2DkUosJpp1P+Wn6YncZ474A= +k8s.io/api v0.29.2/go.mod h1:sdIaaKuU7P44aoyyLlikSLayT6Vb7bvJNCX105xZXY0= +k8s.io/apimachinery v0.28.4/go.mod h1:wI37ncBvfAoswfq626yPTe6Bz1c22L7uaJ8dho83mgg= +k8s.io/apimachinery v0.29.2 h1:EWGpfJ856oj11C52NRCHuU7rFDwxev48z+6DSlGNsV8= +k8s.io/apimachinery v0.29.2/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU= +k8s.io/apiserver v0.29.2 h1:+Z9S0dSNr+CjnVXQePG8TcBWHr3Q7BmAr7NraHvsMiQ= +k8s.io/apiserver v0.29.2/go.mod h1:B0LieKVoyU7ykQvPFm7XSdIHaCHSzCzQWPFa5bqbeMQ= +k8s.io/client-go v0.28.4/go.mod h1:0VDZFpgoZfelyP5Wqu0/r/TRYcLYuJ2U1KEeoaPa1N4= +k8s.io/client-go v0.29.2 h1:FEg85el1TeZp+/vYJM7hkDlSTFZ+c5nnK44DJ4FyoRg= +k8s.io/client-go v0.29.2/go.mod h1:knlvFZE58VpqbQpJNbCbctTVXcd35mMyAAwBdpt4jrA= +k8s.io/code-generator v0.29.1 h1:8ba8BdtSmAVHgAMpzThb/fuyQeTRtN7NtN7VjMcDLew= +k8s.io/code-generator v0.29.1/go.mod h1:FwFi3C9jCrmbPjekhaCYcYG1n07CYiW1+PAPCockaos= +k8s.io/component-base v0.29.2 h1:lpiLyuvPA9yV1aQwGLENYyK7n/8t6l3nn3zAtFTJYe8= +k8s.io/component-base v0.29.2/go.mod h1:BfB3SLrefbZXiBfbM+2H1dlat21Uewg/5qtKOl8degM= k8s.io/gengo v0.0.0-20210813121822-485abfe95c7c/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= -k8s.io/klog/v2 v2.90.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= -k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= +k8s.io/klog/v2 v2.100.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= -k8s.io/kms v0.29.0 h1:KJ1zaZt74CgvgV3NR7tnURJ/mJOKC5X3nwon/WdwgxI= -k8s.io/kms v0.29.0/go.mod h1:mB0f9HLxRXeXUfHfn1A7rpwOlzXI1gIWu86z6buNoYA= -k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280/go.mod h1:+Axhij7bCpeqhklhUTe3xmOn6bWxolyZEeyaFpjGtl4= -k8s.io/kube-openapi v0.0.0-20230303024457-afdc3dddf62d/go.mod h1:y5VtZWM9sHHc2ZodIH/6SHzXj+TPU5USoA8lcIeKEKY= -k8s.io/kube-openapi v0.0.0-20231214164306-ab13479f8bf8 h1:yHNkNuLjht7iq95pO9QmbjOWCguvn8mDe3lT78nqPkw= -k8s.io/kube-openapi v0.0.0-20231214164306-ab13479f8bf8/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kms v0.29.2 h1:MDsbp98gSlEQs7K7dqLKNNTwKFQRYYvO4UOlBOjNy6Y= +k8s.io/kms v0.29.2/go.mod h1:s/9RC4sYRZ/6Tn6yhNjbfJuZdb8LzlXhdlBnKizeFDo= +k8s.io/kube-aggregator v0.29.0 h1:N4fmtePxOZ+bwiK1RhVEztOU+gkoVkvterHgpwAuiTw= +k8s.io/kube-aggregator v0.29.0/go.mod h1:bjatII63ORkFg5yUFP2qm2OC49R0wwxZhRVIyJ4Z4X0= +k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9/go.mod h1:wZK2AVp1uHCp4VamDVgBP2COHZjqD1T68Rf0CM3YjSM= +k8s.io/kube-openapi v0.0.0-20240220201932-37d671a357a5 h1:QSpdNrZ9uRlV0VkqLvVO0Rqg8ioKi3oSw7O5P7pJV8M= +k8s.io/kube-openapi v0.0.0-20240220201932-37d671a357a5/go.mod h1:Pa1PvrP7ACSkuX6I7KYomY6cmMA0Tx86waBhDUgoKPw= k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= -k8s.io/utils v0.0.0-20221107191617-1a15be271d1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -k8s.io/utils v0.0.0-20230308161112-d77c459e9343/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20230406110748-d93618cff8a2/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20230711102312-30195339c3c7/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= -lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= -modernc.org/cc v1.0.0/go.mod h1:1Sk4//wdnYJiUIxnW8ddKpaOJCF37yAdqYnkxUpaYxw= +lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo= +lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= +modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20= modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc= modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw= +modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI= modernc.org/ccgo/v3 v3.16.4/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ= modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ= modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws= modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo= +modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g= modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= -modernc.org/golex v1.0.0/go.mod h1:b/QX9oBD/LhixY6NDh+IdGv17hgB+51fET1i2kPSmvk= modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= @@ -4210,9 +4426,13 @@ modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU= modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0= modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s= -modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0= +modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA= +modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0= +modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0= +modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI= modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug= -modernc.org/mathutil v1.0.0/go.mod h1:wU0vUrJsVWBZ4P6e7xtFJEhFSNsfRLJ8H458uRjg03k= +modernc.org/libc v1.22.4 h1:wymSbZb0AlrjdAVX3cjreCHTPCpPARbQXNz6BHPzdwQ= +modernc.org/libc v1.22.4/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= @@ -4220,25 +4440,28 @@ modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6 modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4= -modernc.org/sqlite v1.20.4 h1:J8+m2trkN+KKoE7jglyHYYYiaq5xmz2HoHJIiBlRzbE= -modernc.org/sqlite v1.20.4/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A= -modernc.org/strutil v1.1.0/go.mod h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs= +modernc.org/sqlite v1.18.2/go.mod h1:kvrTLEWgxUcHa2GfHBQtanR1H9ht3hTJNtKpzH9k1u0= +modernc.org/sqlite v1.21.2 h1:ixuUG0QS413Vfzyx6FWx6PYTmHaOegTY+hjzhn7L+a0= +modernc.org/sqlite v1.21.2/go.mod h1:cxbLkB5WS32DnQqeH4h4o1B0eMr8W/y8/RGuxQ3JsC0= modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw= -modernc.org/tcl v1.15.0 h1:oY+JeD11qVVSgVvodMJsu7Edf8tr5E/7tuhF5cNYz34= -modernc.org/tcl v1.15.0/go.mod h1:xRoGotBZ6dU+Zo2tca+2EqVEeMmOUBzHnhIwq4YrVnE= +modernc.org/tcl v1.13.2/go.mod h1:7CLiGIPo1M8Rv1Mitpv5akc2+8fxUd2y2UzC/MfMzy0= +modernc.org/tcl v1.15.1 h1:mOQwiEK4p7HruMZcwKTZPw/aqtGM4aY00uzWhlKKYws= +modernc.org/tcl v1.15.1/go.mod h1:aEjeGJX2gz1oWKOLDVZ2tnEWLUrIn8H+GFu+akoDhqs= modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -modernc.org/xc v1.0.0/go.mod h1:mRNCo0bvLjGhHO9WsyuKVU4q0ceiDDDoEeWDJHrNx8I= modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8= modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE= modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ= @@ -4249,10 +4472,10 @@ rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.28.0 h1:TgtAeesdhpm2SGwkQasmbeqDo8th5wOBA5h/AjTKA4I= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.28.0/go.mod h1:VHVDI/KrK4fjnV61bE2g3sA7tiETLn8sooImelsCx3Y= -sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= +sigs.k8s.io/structured-merge-diff/v4 v4.3.0/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= diff --git a/go.work b/go.work new file mode 100644 index 0000000000000..3bb653aaf4269 --- /dev/null +++ b/go.work @@ -0,0 +1,13 @@ +go 1.21.0 + +use ( + . + ./pkg/apimachinery + ./pkg/apiserver + ./pkg/promlib + ./pkg/util/xorm +) + +// when we release xorm we would like to release it like github.com/grafana/grafana/pkg/util/xorm +// but we don't want to change all the imports. so we use replace to handle this situation +replace xorm.io/xorm => ./pkg/util/xorm diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000000000..6c3d23663fdf0 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,826 @@ +buf.build/gen/go/grpc-ecosystem/grpc-gateway/bufbuild/connect-go v1.4.1-20221127060915-a1ecdc58eccd.1 h1:vp9EaPFSb75qe/793x58yE5fY1IJ/gdxb/kcDUzavtI= +buf.build/gen/go/grpc-ecosystem/grpc-gateway/bufbuild/connect-go v1.4.1-20221127060915-a1ecdc58eccd.1/go.mod h1:YDq2B5X5BChU0lxAG5MxHpDb8mx1fv9OGtF2mwOe7hY= +buf.build/gen/go/grpc-ecosystem/grpc-gateway/protocolbuffers/go v1.28.1-20221127060915-a1ecdc58eccd.4 h1:z3Xc9n8yZ5k/Xr4ZTuff76TAYP20dWy7ZBV4cGIpbkM= +cloud.google.com/go/accessapproval v1.7.4 h1:ZvLvJ952zK8pFHINjpMBY5k7LTAp/6pBf50RDMRgBUI= +cloud.google.com/go/accesscontextmanager v1.8.4 h1:Yo4g2XrBETBCqyWIibN3NHNPQKUfQqti0lI+70rubeE= +cloud.google.com/go/aiplatform v1.57.0 h1:WcZ6wDf/1qBWatmGM9Z+2BTiNjQQX54k2BekHUj93DQ= +cloud.google.com/go/aiplatform v1.58.0 h1:xyCAfpI4yUMOQ4VtHN/bdmxPQ8xoEkTwFM1nbVmuQhs= +cloud.google.com/go/aiplatform v1.58.0/go.mod h1:pwZMGvqe0JRkI1GWSZCtnAfrR4K1bv65IHILGA//VEU= +cloud.google.com/go/analytics v0.21.6 h1:fnV7B8lqyEYxCU0LKk+vUL7mTlqRAq4uFlIthIdr/iA= +cloud.google.com/go/analytics v0.22.0 h1:w8KIgW8NRUHFVKjpkwCpLaHsr685tJ+ckPStOaSCZz0= +cloud.google.com/go/analytics v0.22.0/go.mod h1:eiROFQKosh4hMaNhF85Oc9WO97Cpa7RggD40e/RBy8w= +cloud.google.com/go/apigateway v1.6.4 h1:VVIxCtVerchHienSlaGzV6XJGtEM9828Erzyr3miUGs= +cloud.google.com/go/apigeeconnect v1.6.4 h1:jSoGITWKgAj/ssVogNE9SdsTqcXnryPzsulENSRlusI= +cloud.google.com/go/apigeeregistry v0.8.2 h1:DSaD1iiqvELag+lV4VnnqUUFd8GXELu01tKVdWZrviE= +cloud.google.com/go/apikeys v0.6.0 h1:B9CdHFZTFjVti89tmyXXrO+7vSNo2jvZuHG8zD5trdQ= +cloud.google.com/go/appengine v1.8.4 h1:Qub3fqR7iA1daJWdzjp/Q0Jz0fUG0JbMc7Ui4E9IX/E= +cloud.google.com/go/area120 v0.8.4 h1:YnSO8m02pOIo6AEOgiOoUDVbw4pf+bg2KLHi4rky320= +cloud.google.com/go/artifactregistry v1.14.6 h1:/hQaadYytMdA5zBh+RciIrXZQBWK4vN7EUsrQHG+/t8= +cloud.google.com/go/asset v1.15.3 h1:uI8Bdm81s0esVWbWrTHcjFDFKNOa9aB7rI1vud1hO84= +cloud.google.com/go/asset v1.17.0 h1:dLWfTnbwyrq/Kt8Tr2JiAbre1MEvS2Bl5cAMiYAy5Pg= +cloud.google.com/go/asset v1.17.0/go.mod h1:yYLfUD4wL4X589A9tYrv4rFrba0QlDeag0CMcM5ggXU= +cloud.google.com/go/assuredworkloads v1.11.4 h1:FsLSkmYYeNuzDm8L4YPfLWV+lQaUrJmH5OuD37t1k20= +cloud.google.com/go/automl v1.13.4 h1:i9tOKXX+1gE7+rHpWKjiuPfGBVIYoWvLNIGpWgPtF58= +cloud.google.com/go/baremetalsolution v1.2.3 h1:oQiFYYCe0vwp7J8ZmF6siVKEumWtiPFJMJcGuyDVRUk= +cloud.google.com/go/batch v1.7.0 h1:AxuSPoL2fWn/rUyvWeNCNd0V2WCr+iHRCU9QO1PUmpY= +cloud.google.com/go/batch v1.7.0/go.mod h1:J64gD4vsNSA2O5TtDB5AAux3nJ9iV8U3ilg3JDBYejU= +cloud.google.com/go/beyondcorp v1.0.3 h1:VXf9SnrnSmj2BF2cHkoTHvOUp8gjsz1KJFOMW7czdsY= +cloud.google.com/go/bigquery v1.57.1 h1:FiULdbbzUxWD0Y4ZGPSVCDLvqRSyCIO6zKV7E2nf5uA= +cloud.google.com/go/bigquery v1.58.0 h1:drSd9RcPVLJP2iFMimvOB9SCSIrcl+9HD4II03Oy7A0= +cloud.google.com/go/bigquery v1.58.0/go.mod h1:0eh4mWNY0KrBTjUzLjoYImapGORq9gEPT7MWjCy9lik= +cloud.google.com/go/billing v1.18.0 h1:GvKy4xLy1zF1XPbwP5NJb2HjRxhnhxjjXxvyZ1S/IAo= +cloud.google.com/go/billing v1.18.0/go.mod h1:5DOYQStCxquGprqfuid/7haD7th74kyMBHkjO/OvDtk= +cloud.google.com/go/binaryauthorization v1.8.0 h1:PHS89lcFayWIEe0/s2jTBiEOtqghCxzc7y7bRNlifBs= +cloud.google.com/go/binaryauthorization v1.8.0/go.mod h1:VQ/nUGRKhrStlGr+8GMS8f6/vznYLkdK5vaKfdCIpvU= +cloud.google.com/go/certificatemanager v1.7.4 h1:5YMQ3Q+dqGpwUZ9X5sipsOQ1fLPsxod9HNq0+nrqc6I= +cloud.google.com/go/channel v1.17.3 h1:Rd4+fBrjiN6tZ4TR8R/38elkyEkz6oogGDr7jDyjmMY= +cloud.google.com/go/channel v1.17.4 h1:yYHOORIM+wkBy3EdwArg/WL7Lg+SoGzlKH9o3Bw2/jE= +cloud.google.com/go/channel v1.17.4/go.mod h1:QcEBuZLGGrUMm7kNj9IbU1ZfmJq2apotsV83hbxX7eE= +cloud.google.com/go/cloudbuild v1.15.0 h1:9IHfEMWdCklJ1cwouoiQrnxmP0q3pH7JUt8Hqx4Qbck= +cloud.google.com/go/clouddms v1.7.3 h1:xe/wJKz55VO1+L891a1EG9lVUgfHr9Ju/I3xh1nwF84= +cloud.google.com/go/cloudtasks v1.12.4 h1:5xXuFfAjg0Z5Wb81j2GAbB3e0bwroCeSF+5jBn/L650= +cloud.google.com/go/contactcenterinsights v1.12.1 h1:EiGBeejtDDtr3JXt9W7xlhXyZ+REB5k2tBgVPVtmNb0= +cloud.google.com/go/contactcenterinsights v1.12.1/go.mod h1:HHX5wrz5LHVAwfI2smIotQG9x8Qd6gYilaHcLLLmNis= +cloud.google.com/go/container v1.29.0 h1:jIltU529R2zBFvP8rhiG1mgeTcnT27KhU0H/1d6SQRg= +cloud.google.com/go/container v1.29.0/go.mod h1:b1A1gJeTBXVLQ6GGw9/9M4FG94BEGsqJ5+t4d/3N7O4= +cloud.google.com/go/containeranalysis v0.11.3 h1:5rhYLX+3a01drpREqBZVXR9YmWH45RnML++8NsCtuD8= +cloud.google.com/go/datacatalog v1.19.0 h1:rbYNmHwvAOOwnW2FPXYkaK3Mf1MmGqRzK0mMiIEyLdo= +cloud.google.com/go/datacatalog v1.19.2 h1:BV5sB7fPc8ccv/obwtHwQtCdLMAgI4KyaQWfkh8/mWg= +cloud.google.com/go/datacatalog v1.19.2/go.mod h1:2YbODwmhpLM4lOFe3PuEhHK9EyTzQJ5AXgIy7EDKTEE= +cloud.google.com/go/dataflow v0.9.4 h1:7VmCNWcPJBS/srN2QnStTB6nu4Eb5TMcpkmtaPVhRt4= +cloud.google.com/go/dataform v0.9.1 h1:jV+EsDamGX6cE127+QAcCR/lergVeeZdEQ6DdrxW3sQ= +cloud.google.com/go/datafusion v1.7.4 h1:Q90alBEYlMi66zL5gMSGQHfbZLB55mOAg03DhwTTfsk= +cloud.google.com/go/datalabeling v0.8.4 h1:zrq4uMmunf2KFDl/7dS6iCDBBAxBnKVDyw6+ajz3yu0= +cloud.google.com/go/dataplex v1.13.0 h1:ACVOuxwe7gP0SqEso9SLyXbcZNk5l8hjcTX+XLntI5s= +cloud.google.com/go/dataplex v1.14.0 h1:/WhVTR4v/L6ACKjlz/9CqkxkrVh2z7C44CLMUf0f60A= +cloud.google.com/go/dataplex v1.14.0/go.mod h1:mHJYQQ2VEJHsyoC0OdNyy988DvEbPhqFs5OOLffLX0c= +cloud.google.com/go/dataproc v1.12.0 h1:W47qHL3W4BPkAIbk4SWmIERwsWBaNnWm0P2sdx3YgGU= +cloud.google.com/go/dataproc/v2 v2.3.0 h1:tTVP9tTxmc8fixxOd/8s6Q6Pz/+yzn7r7XdZHretQH0= +cloud.google.com/go/dataqna v0.8.4 h1:NJnu1kAPamZDs/if3bJ3+Wb6tjADHKL83NUWsaIp2zg= +cloud.google.com/go/datastore v1.15.0 h1:0P9WcsQeTWjuD1H14JIY7XQscIPQ4Laje8ti96IC5vg= +cloud.google.com/go/datastream v1.10.3 h1:Z2sKPIB7bT2kMW5Uhxy44ZgdJzxzE5uKjavoW+EuHEE= +cloud.google.com/go/deploy v1.16.0 h1:5OVjzm8MPC5kP+Ywbs0mdE0O7AXvAUXksSyHAyMFyMg= +cloud.google.com/go/deploy v1.17.0 h1:P3SgJ+4rAktC2XqaI10G0ip/vzWluNBrC5VG0abMbLw= +cloud.google.com/go/deploy v1.17.0/go.mod h1:XBr42U5jIr64t92gcpOXxNrqL2PStQCXHuKK5GRUuYo= +cloud.google.com/go/dialogflow v1.47.0 h1:tLCWad8HZhlyUNfDzDP5m+oH6h/1Uvw/ei7B9AnsWMk= +cloud.google.com/go/dialogflow v1.48.1 h1:1Uq2jDJzjJ3M4xYB608FCCFHfW3JmrTmHIxRSd7JGmY= +cloud.google.com/go/dialogflow v1.48.1/go.mod h1:C1sjs2/g9cEwjCltkKeYp3FFpz8BOzNondEaAlCpt+A= +cloud.google.com/go/dlp v1.11.1 h1:OFlXedmPP/5//X1hBEeq3D9kUVm9fb6ywYANlpv/EsQ= +cloud.google.com/go/documentai v1.23.6 h1:0/S3AhS23+0qaFe3tkgMmS3STxgDgmE1jg4TvaDOZ9g= +cloud.google.com/go/documentai v1.23.7 h1:hlYieOXUwiJ7HpBR/vEPfr8nfSxveLVzbqbUkSK0c/4= +cloud.google.com/go/documentai v1.23.7/go.mod h1:ghzBsyVTiVdkfKaUCum/9bGBEyBjDO4GfooEcYKhN+g= +cloud.google.com/go/domains v0.9.4 h1:ua4GvsDztZ5F3xqjeLKVRDeOvJshf5QFgWGg1CKti3A= +cloud.google.com/go/edgecontainer v1.1.4 h1:Szy3Q/N6bqgQGyxqjI+6xJZbmvPvnFHp3UZr95DKcQ0= +cloud.google.com/go/errorreporting v0.3.0 h1:kj1XEWMu8P0qlLhm3FwcaFsUvXChV/OraZwA70trRR0= +cloud.google.com/go/essentialcontacts v1.6.5 h1:S2if6wkjR4JCEAfDtIiYtD+sTz/oXjh2NUG4cgT1y/Q= +cloud.google.com/go/eventarc v1.13.3 h1:+pFmO4eu4dOVipSaFBLkmqrRYG94Xl/TQZFOeohkuqU= +cloud.google.com/go/filestore v1.8.0 h1:/+wUEGwk3x3Kxomi2cP5dsR8+SIXxo7M0THDjreFSYo= +cloud.google.com/go/firestore v1.14.0 h1:8aLcKnMPoldYU3YHgu4t2exrKhLQkqaXAGqT0ljrFVw= +cloud.google.com/go/functions v1.15.4 h1:ZjdiV3MyumRM6++1Ixu6N0VV9LAGlCX4AhW6Yjr1t+U= +cloud.google.com/go/gaming v1.10.1 h1:5qZmZEWzMf8GEFgm9NeC3bjFRpt7x4S6U7oLbxaf7N8= +cloud.google.com/go/gkebackup v1.3.4 h1:KhnOrr9A1tXYIYeXKqCKbCI8TL2ZNGiD3dm+d7BDUBg= +cloud.google.com/go/gkeconnect v0.8.4 h1:1JLpZl31YhQDQeJ98tK6QiwTpgHFYRJwpntggpQQWis= +cloud.google.com/go/gkehub v0.14.4 h1:J5tYUtb3r0cl2mM7+YHvV32eL+uZQ7lONyUZnPikCEo= +cloud.google.com/go/gkemulticloud v1.0.3 h1:NmJsNX9uQ2CT78957xnjXZb26TDIMvv+d5W2vVUt0Pg= +cloud.google.com/go/gkemulticloud v1.1.0 h1:C2Suwn3uPz+Yy0bxVjTlsMrUCaDovkgvfdyIa+EnUOU= +cloud.google.com/go/gkemulticloud v1.1.0/go.mod h1:7NpJBN94U6DY1xHIbsDqB2+TFZUfjLUKLjUX8NGLor0= +cloud.google.com/go/grafeas v0.3.0 h1:oyTL/KjiUeBs9eYLw/40cpSZglUC+0F7X4iu/8t7NWs= +cloud.google.com/go/gsuiteaddons v1.6.4 h1:uuw2Xd37yHftViSI8J2hUcCS8S7SH3ZWH09sUDLW30Q= +cloud.google.com/go/iap v1.9.3 h1:M4vDbQ4TLXdaljXVZSwW7XtxpwXUUarY2lIs66m0aCM= +cloud.google.com/go/ids v1.4.4 h1:VuFqv2ctf/A7AyKlNxVvlHTzjrEvumWaZflUzBPz/M4= +cloud.google.com/go/iot v1.7.4 h1:m1WljtkZnvLTIRYW1YTOv5A6H1yKgLHR6nU7O8yf27w= +cloud.google.com/go/language v1.12.2 h1:zg9uq2yS9PGIOdc0Kz/l+zMtOlxKWonZjjo5w5YPG2A= +cloud.google.com/go/lifesciences v0.9.4 h1:rZEI/UxcxVKEzyoRS/kdJ1VoolNItRWjNN0Uk9tfexg= +cloud.google.com/go/logging v1.8.1 h1:26skQWPeYhvIasWKm48+Eq7oUqdcdbwsCVwz5Ys0FvU= +cloud.google.com/go/logging v1.9.0 h1:iEIOXFO9EmSiTjDmfpbRjOxECO7R8C7b8IXUGOj7xZw= +cloud.google.com/go/logging v1.9.0/go.mod h1:1Io0vnZv4onoUnsVUQY3HZ3Igb1nBchky0A0y7BBBhE= +cloud.google.com/go/longrunning v0.5.4 h1:w8xEcbZodnA2BbW6sVirkkoC+1gP8wS57EUUgGS0GVg= +cloud.google.com/go/managedidentities v1.6.4 h1:SF/u1IJduMqQQdJA4MDyivlIQ4SrV5qAawkr/ZEREkY= +cloud.google.com/go/maps v1.6.2 h1:WxxLo//b60nNFESefLgaBQevu8QGUmRV3+noOjCfIHs= +cloud.google.com/go/maps v1.6.3 h1:Qqs6Dza+PRp5CZO5AfgPnLwU1k3pp0IMFRDtLpT+aCA= +cloud.google.com/go/maps v1.6.3/go.mod h1:VGAn809ADswi1ASofL5lveOHPnE6Rk/SFTTBx1yuOLw= +cloud.google.com/go/mediatranslation v0.8.4 h1:VRCQfZB4s6jN0CSy7+cO3m4ewNwgVnaePanVCQh/9Z4= +cloud.google.com/go/memcache v1.10.4 h1:cdex/ayDd294XBj2cGeMe6Y+H1JvhN8y78B9UW7pxuQ= +cloud.google.com/go/metastore v1.13.3 h1:94l/Yxg9oBZjin2bzI79oK05feYefieDq0o5fjLSkC8= +cloud.google.com/go/monitoring v1.16.3 h1:mf2SN9qSoBtIgiMA4R/y4VADPWZA7VCNJA079qLaZQ8= +cloud.google.com/go/monitoring v1.17.0 h1:blrdvF0MkPPivSO041ihul7rFMhXdVp8Uq7F59DKXTU= +cloud.google.com/go/monitoring v1.17.0/go.mod h1:KwSsX5+8PnXv5NJnICZzW2R8pWTis8ypC4zmdRD63Tw= +cloud.google.com/go/networkconnectivity v1.14.3 h1:e9lUkCe2BexsqsUc2bjV8+gFBpQa54J+/F3qKVtW+wA= +cloud.google.com/go/networkmanagement v1.9.3 h1:HsQk4FNKJUX04k3OI6gUsoveiHMGvDRqlaFM2xGyvqU= +cloud.google.com/go/networksecurity v0.9.4 h1:947tNIPnj1bMGTIEBo3fc4QrrFKS5hh0bFVsHmFm4Vo= +cloud.google.com/go/notebooks v1.11.2 h1:eTOTfNL1yM6L/PCtquJwjWg7ZZGR0URFaFgbs8kllbM= +cloud.google.com/go/optimization v1.6.2 h1:iFsoexcp13cGT3k/Hv8PA5aK+FP7FnbhwDO9llnruas= +cloud.google.com/go/orchestration v1.8.4 h1:kgwZ2f6qMMYIVBtUGGoU8yjYWwMTHDanLwM/CQCFaoQ= +cloud.google.com/go/orgpolicy v1.11.4 h1:RWuXQDr9GDYhjmrredQJC7aY7cbyqP9ZuLbq5GJGves= +cloud.google.com/go/orgpolicy v1.12.0 h1:sab7cDiyfdthpAL0JkSpyw1C3mNqkXToVOhalm79PJQ= +cloud.google.com/go/orgpolicy v1.12.0/go.mod h1:0+aNV/nrfoTQ4Mytv+Aw+stBDBjNf4d8fYRA9herfJI= +cloud.google.com/go/osconfig v1.12.4 h1:OrRCIYEAbrbXdhm13/JINn9pQchvTTIzgmOCA7uJw8I= +cloud.google.com/go/oslogin v1.12.2 h1:NP/KgsD9+0r9hmHC5wKye0vJXVwdciv219DtYKYjgqE= +cloud.google.com/go/oslogin v1.13.0 h1:gbA/G4p+youIR4O/Rk6DU181QlBlpwPS16kvJwqEz8o= +cloud.google.com/go/oslogin v1.13.0/go.mod h1:xPJqLwpTZ90LSE5IL1/svko+6c5avZLluiyylMb/sRA= +cloud.google.com/go/phishingprotection v0.8.4 h1:sPLUQkHq6b4AL0czSJZ0jd6vL55GSTHz2B3Md+TCZI0= +cloud.google.com/go/policytroubleshooter v1.10.2 h1:sq+ScLP83d7GJy9+wpwYJVnY+q6xNTXwOdRIuYjvHT4= +cloud.google.com/go/privatecatalog v0.9.4 h1:Vo10IpWKbNvc/z/QZPVXgCiwfjpWoZ/wbgful4Uh/4E= +cloud.google.com/go/pubsub v1.33.0 h1:6SPCPvWav64tj0sVX/+npCBKhUi/UjJehy9op/V3p2g= +cloud.google.com/go/pubsub v1.34.0 h1:ZtPbfwfi5rLaPeSvDC29fFoE20/tQvGrUS6kVJZJvkU= +cloud.google.com/go/pubsub v1.34.0/go.mod h1:alj4l4rBg+N3YTFDDC+/YyFTs6JAjam2QfYsddcAW4c= +cloud.google.com/go/pubsublite v1.8.1 h1:pX+idpWMIH30/K7c0epN6V703xpIcMXWRjKJsz0tYGY= +cloud.google.com/go/recaptchaenterprise v1.3.1 h1:u6EznTGzIdsyOsvm+Xkw0aSuKFXQlyjGE9a4exk6iNQ= +cloud.google.com/go/recaptchaenterprise/v2 v2.9.0 h1:Zrd4LvT9PaW91X/Z13H0i5RKEv9suCLuk8zp+bfOpN4= +cloud.google.com/go/recaptchaenterprise/v2 v2.9.0/go.mod h1:Dak54rw6lC2gBY8FBznpOCAR58wKf+R+ZSJRoeJok4w= +cloud.google.com/go/recommendationengine v0.8.4 h1:JRiwe4hvu3auuh2hujiTc2qNgPPfVp+Q8KOpsXlEzKQ= +cloud.google.com/go/recommender v1.11.3 h1:VndmgyS/J3+izR8V8BHa7HV/uun8//ivQ3k5eVKKyyM= +cloud.google.com/go/recommender v1.12.0 h1:tC+ljmCCbuZ/ybt43odTFlay91n/HLIhflvaOeb0Dh4= +cloud.google.com/go/recommender v1.12.0/go.mod h1:+FJosKKJSId1MBFeJ/TTyoGQZiEelQQIZMKYYD8ruK4= +cloud.google.com/go/redis v1.14.1 h1:J9cEHxG9YLmA9o4jTSvWt/RuVEn6MTrPlYSCRHujxDQ= +cloud.google.com/go/resourcemanager v1.9.4 h1:JwZ7Ggle54XQ/FVYSBrMLOQIKoIT/uer8mmNvNLK51k= +cloud.google.com/go/resourcesettings v1.6.4 h1:yTIL2CsZswmMfFyx2Ic77oLVzfBFoWBYgpkgiSPnC4Y= +cloud.google.com/go/retail v1.14.4 h1:geqdX1FNqqL2p0ADXjPpw8lq986iv5GrVcieTYafuJQ= +cloud.google.com/go/run v1.3.3 h1:qdfZteAm+vgzN1iXzILo3nJFQbzziudkJrvd9wCf3FQ= +cloud.google.com/go/scheduler v1.10.5 h1:eMEettHlFhG5pXsoHouIM5nRT+k+zU4+GUvRtnxhuVI= +cloud.google.com/go/secretmanager v1.11.4 h1:krnX9qpG2kR2fJ+u+uNyNo+ACVhplIAS4Pu7u+4gd+k= +cloud.google.com/go/security v1.15.4 h1:sdnh4Islb1ljaNhpIXlIPgb3eYj70QWgPVDKOUYvzJc= +cloud.google.com/go/securitycenter v1.24.3 h1:crdn2Z2rFIy8WffmmhdlX3CwZJusqCiShtnrGFRwpeE= +cloud.google.com/go/securitycenter v1.24.3/go.mod h1:l1XejOngggzqwr4Fa2Cn+iWZGf+aBLTXtB/vXjy5vXM= +cloud.google.com/go/servicecontrol v1.11.1 h1:d0uV7Qegtfaa7Z2ClDzr9HJmnbJW7jn0WhZ7wOX6hLE= +cloud.google.com/go/servicedirectory v1.11.3 h1:5niCMfkw+jifmFtbBrtRedbXkJm3fubSR/KHbxSJZVM= +cloud.google.com/go/servicemanagement v1.8.0 h1:fopAQI/IAzlxnVeiKn/8WiV6zKndjFkvi+gzu+NjywY= +cloud.google.com/go/serviceusage v1.6.0 h1:rXyq+0+RSIm3HFypctp7WoXxIA563rn206CfMWdqXX4= +cloud.google.com/go/shell v1.7.4 h1:nurhlJcSVFZneoRZgkBEHumTYf/kFJptCK2eBUq/88M= +cloud.google.com/go/spanner v1.53.1 h1:xNmE0SXMSxNBuk7lRZ5G/S+A49X91zkSTt7Jn5Ptlvw= +cloud.google.com/go/spanner v1.55.0 h1:YF/A/k73EMYCjp8wcJTpkE+TcrWutHRlsCtlRSfWS64= +cloud.google.com/go/spanner v1.55.0/go.mod h1:HXEznMUVhC+PC+HDyo9YFG2Ajj5BQDkcbqB9Z2Ffxi0= +cloud.google.com/go/speech v1.21.0 h1:qkxNao58oF8ghAHE1Eghen7XepawYEN5zuZXYWaUTA4= +cloud.google.com/go/storagetransfer v1.10.3 h1:YM1dnj5gLjfL6aDldO2s4GeU8JoAvH1xyIwXre63KmI= +cloud.google.com/go/talent v1.6.5 h1:LnRJhhYkODDBoTwf6BeYkiJHFw9k+1mAFNyArwZUZAs= +cloud.google.com/go/texttospeech v1.7.4 h1:ahrzTgr7uAbvebuhkBAAVU6kRwVD0HWsmDsvMhtad5Q= +cloud.google.com/go/tpu v1.6.4 h1:XIEH5c0WeYGaVy9H+UueiTaf3NI6XNdB4/v6TFQJxtE= +cloud.google.com/go/trace v1.10.4 h1:2qOAuAzNezwW3QN+t41BtkDJOG42HywL73q8x/f6fnM= +cloud.google.com/go/translate v1.9.3 h1:t5WXTqlrk8VVJu/i3WrYQACjzYJiff5szARHiyqqPzI= +cloud.google.com/go/translate v1.10.0 h1:tncNaKmlZnayMMRX/mMM2d5AJftecznnxVBD4w070NI= +cloud.google.com/go/translate v1.10.0/go.mod h1:Kbq9RggWsbqZ9W5YpM94Q1Xv4dshw/gr/SHfsl5yCZ0= +cloud.google.com/go/video v1.20.3 h1:Xrpbm2S9UFQ1pZEeJt9Vqm5t2T/z9y/M3rNXhFoo8Is= +cloud.google.com/go/videointelligence v1.11.4 h1:YS4j7lY0zxYyneTFXjBJUj2r4CFe/UoIi/PJG0Zt/Rg= +cloud.google.com/go/vision v1.2.0 h1:/CsSTkbmO9HC8iQpxbK8ATms3OQaX3YQUeTMGCxlaK4= +cloud.google.com/go/vision/v2 v2.7.5 h1:T/ujUghvEaTb+YnFY/jiYwVAkMbIC8EieK0CJo6B4vg= +cloud.google.com/go/vmmigration v1.7.4 h1:qPNdab4aGgtaRX+51jCOtJxlJp6P26qua4o1xxUDjpc= +cloud.google.com/go/vmwareengine v1.0.3 h1:WY526PqM6QNmFHSqe2sRfK6gRpzWjmL98UFkql2+JDM= +cloud.google.com/go/vpcaccess v1.7.4 h1:zbs3V+9ux45KYq8lxxn/wgXole6SlBHHKKyZhNJoS+8= +cloud.google.com/go/webrisk v1.9.4 h1:iceR3k0BCRZgf2D/NiKviVMFfuNC9LmeNLtxUFRB/wI= +cloud.google.com/go/websecurityscanner v1.6.4 h1:5Gp7h5j7jywxLUp6NTpjNPkgZb3ngl0tUSw6ICWvtJQ= +cloud.google.com/go/workflows v1.12.3 h1:qocsqETmLAl34mSa01hKZjcqAvt699gaoFbooGGMvaM= +contrib.go.opencensus.io/exporter/aws v0.0.0-20200617204711-c478e41e60e9 h1:yxE46rQA0QaqPGqN2UnwXvgCrRqtjR1CsGSWVTRjvv4= +contrib.go.opencensus.io/exporter/prometheus v0.4.2 h1:sqfsYl5GIY/L570iT+l93ehxaWJs2/OwXtiWwew3oAg= +contrib.go.opencensus.io/exporter/prometheus v0.4.2/go.mod h1:dvEHbiKmgvbr5pjaF9fpw1KeYcjrnC1J8B+JKjsZyRQ= +contrib.go.opencensus.io/exporter/stackdriver v0.13.10 h1:a9+GZPUe+ONKUwULjlEOucMMG0qfSCCenlji0Nhqbys= +contrib.go.opencensus.io/integrations/ocsql v0.1.7 h1:G3k7C0/W44zcqkpRSFyjU9f6HZkbwIrL//qqnlqWZ60= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9 h1:VpgP7xuJadIUuKccphEpTJnWhS2jkQyMt6Y7pJCD7fY= +docker.io/go-docker v1.0.0 h1:VdXS/aNYQxyA9wdLD5z8Q8Ro688/hG8HzKxYVEVbE6s= +docker.io/go-docker v1.0.0/go.mod h1:7tiAn5a0LFmjbPDbyTPOaTTOuG1ZRNXdPA6RvKY+fpY= +filippo.io/edwards25519 v1.0.0 h1:0wAIcmJUqRdI8IJ/3eGi5/HwXZWPujYXXlkrQogz0Ek= +filippo.io/edwards25519 v1.0.0/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= +gioui.org v0.0.0-20210308172011-57750fc8a0a6 h1:K72hopUosKG3ntOPNG4OzzbuhxGuVf06fa2la1/H/Ho= +git.sr.ht/~sbinet/gg v0.3.1 h1:LNhjNn8DerC8f9DHLz6lS0YYul/b602DUxDgGkd/Aik= +github.com/99designs/basicauth-go v0.0.0-20160802081356-2a93ba0f464d h1:j6oB/WPCigdOkxtuPl1VSIiLpy7Mdsu6phQffbF19Ng= +github.com/99designs/httpsignatures-go v0.0.0-20170731043157-88528bf4ca7e h1:rl2Aq4ZODqTDkeSqQBy+fzpZPamacO1Srp8zq7jf2Sc= +github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9 h1:HD8gA2tkByhMAwYaFAX9w2l7vxvBQ5NMoxDrkhqhtn4= +github.com/Azure/azure-amqp-common-go/v3 v3.2.2 h1:CJpxNAGxP7UBhDusRUoaOn0uOorQyAYhQYLnNgkRhlY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.1.2 h1:mLY+pNLjCUeKhgnAJWAKhEUQM+RJQo2H1fuGSw1Ky1E= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.0.0 h1:ECsQtyERDVz3NP3kvDOTLvbQhqWp/x9EsGKtb4ogUr8= +github.com/Azure/azure-service-bus-go v0.11.5 h1:EVMicXGNrSX+rHRCBgm/TRQ4VUZ1m3yAYM/AB2R/SOs= +github.com/Azure/go-amqp v0.16.4 h1:/1oIXrq5zwXLHaoYDliJyiFjJSpJZMWGgtMX9e0/Z30= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.11 h1:P6bYXFoao05z5uhOQzbC3Qd8JqF3jUoocoTeIxkp2cA= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.11/go.mod h1:84w/uV8E37feW2NCJ08uT9VBfjfUHpgLVnG2InYD6cg= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.5 h1:0W/yGmFdTIT77fvdlGZ0LMISoLHFJ7Tx4U0yeB+uFs4= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.5/go.mod h1:ADQAXrkgm7acgWVUNamOgh8YNrv4p27l3Wc55oVfpzg= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4slttB4vD+b9btVEnWgL3Q00OBTzVT8B9C0c= +github.com/CloudyKit/jet/v3 v3.0.0 h1:1PwO5w5VCtlUUl+KTOBsTGZlhjWkcybsGaAau52tOy8= +github.com/DataDog/datadog-go v3.2.0+incompatible h1:qSG2N4FghB1He/r2mFrWKCaL7dXCilEuNEeAn20fdD4= +github.com/GoogleCloudPlatform/cloudsql-proxy v1.29.0 h1:YNu23BtH0PKF+fg3ykSorCp6jSTjcEtfnYLzbmcjVRA= +github.com/Joker/hpp v1.0.0 h1:65+iuJYdRXv/XyN62C1uEmmOx3432rNG/rKlX6V7Kkc= +github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/OneOfOne/xxhash v1.2.6 h1:U68crOE3y3MPttCMQGywZOLrTeF5HHJ3/vDBCJn9/bA= +github.com/OneOfOne/xxhash v1.2.6/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0= +github.com/RoaringBitmap/gocroaring v0.4.0 h1:5nufXUgWpBEUNEJXw7926YAA58ZAQRpWPrQV1xCoSjc= +github.com/RoaringBitmap/real-roaring-datasets v0.0.0-20190726190000-eb7c87156f76 h1:ZYlhPbqQFU+AHfgtCdHGDTtRW1a8geZyiE8c6Q+Sl1s= +github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398 h1:WDC6ySpJzbxGWFh4aMxFFC28wwGp5pEuoTtvA4q/qQ4= +github.com/Shopify/sarama v1.38.1 h1:lqqPUPQZ7zPqYlWpTh+LQ9bhYNu2xJL6k1SJN4WVe2A= +github.com/Shopify/sarama v1.38.1/go.mod h1:iwv9a67Ha8VNa+TifujYoWGxWnu2kNVAQdSdZ4X2o5g= +github.com/Shopify/toxiproxy v2.1.4+incompatible h1:TKdv8HiTLgE5wdJuEML90aBgNWsokNbMijUGhmcoBJc= +github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrdtl/UvroE= +github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 h1:rFw4nCn9iMW+Vajsk51NtYIcwSTkXr+JGrMd36kTDJw= +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9 h1:7kQgkwGRoLzC9K0oyXdJo7nve/bynv/KwUsxbiTlzAM= +github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19 h1:iXUgAaqDcIUGbRoy2TdeofRG/j1zpGRSEmNK05T+bi8= +github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= +github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= +github.com/alecthomas/kong v0.2.11 h1:RKeJXXWfg9N47RYfMm0+igkxBCTF4bzbneAxaqid0c4= +github.com/alecthomas/kong v0.2.11/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE= +github.com/alecthomas/participle/v2 v2.1.0 h1:z7dElHRrOEEq45F2TG5cbQihMtNTv8vwldytDj7Wrz4= +github.com/alecthomas/participle/v2 v2.1.0/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= +github.com/alicebob/miniredis v2.5.0+incompatible h1:yBHoLpsyjupjz3NL3MhKMVkR41j82Yjf3KFv7ApYzUI= +github.com/alicebob/miniredis v2.5.0+incompatible/go.mod h1:8HZjEj4yU0dwhYHky+DxYx+6BMjkBbe5ONFIF1MXffk= +github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= +github.com/apache/arrow/go/v10 v10.0.1 h1:n9dERvixoC/1JjDmBcs9FPaEryoANa2sCgVFo6ez9cI= +github.com/apache/arrow/go/v11 v11.0.0 h1:hqauxvFQxww+0mEU/2XHG6LT7eZternCZq+A5Yly2uM= +github.com/apache/arrow/go/v12 v12.0.0 h1:xtZE63VWl7qLdB0JObIXvvhGjoVNrQ9ciIHG2OK5cmc= +github.com/apache/arrow/go/v12 v12.0.1 h1:JsR2+hzYYjgSUkBSaahpqCetqZMr76djX80fF/DiJbg= +github.com/apache/arrow/go/v12 v12.0.1/go.mod h1:weuTY7JvTG/HDPtMQxEUp7pU73vkLWMLpY67QwZ/WWw= +github.com/apache/arrow/go/v13 v13.0.0 h1:kELrvDQuKZo8csdWYqBQfyi431x6Zs/YJTEgUuSVcWk= +github.com/apache/arrow/go/v13 v13.0.0/go.mod h1:W69eByFNO0ZR30q1/7Sr9d83zcVZmF2MiP3fFYAWJOc= +github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3 h1:ZSTrOEhiM5J5RFxEaFvMZVEAM1KvT1YzbEOwB2EAGjA= +github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e h1:QEF07wC0T1rKkctt1RINW/+RMTVmiwxETico2l3gxJA= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6 h1:G1bPvciwNyF7IUmKXNt9Ak3m6u9DE1rF+RmtIkBpVdA= +github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a h1:pv34s756C4pEXnjgPfGYgdhg/ZdajGhyOvzx8k+23nw= +github.com/aws/aws-lambda-go v1.13.3 h1:SuCy7H3NLyp+1Mrfp+m80jcbi9KYWAs9/BXwppwRDzY= +github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1 h1:w/fPGB0t5rWwA43mux4e9ozFSH5zF1moQemlA131PWc= +github.com/aws/aws-sdk-go-v2/service/kms v1.16.3 h1:nUP29LA4GZZPihNSo5ZcF4Rl73u+bN5IBRnrQA0jFK4= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.4 h1:EmIEXOjAdXtxa2OGM1VAajZV/i06Q8qd4kBpJd9/p1k= +github.com/aws/aws-sdk-go-v2/service/sns v1.17.4 h1:7TdmoJJBwLFyakXjfrGztejwY5Ie1JEto7YFfznCmAw= +github.com/aws/aws-sdk-go-v2/service/sqs v1.18.3 h1:uHjK81fESbGy2Y9lspub1+C6VN5W2UXTDo2A/Pm4G0U= +github.com/aws/aws-sdk-go-v2/service/ssm v1.24.1 h1:zc1YLcknvxdW/i1MuJKmEnFB2TNkOfguuQaGRvJXPng= +github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible h1:Ppm0npCCsmuR9oQaBtRuZcmILVE74aXE+AmrJj8L2ns= +github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= +github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= +github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw= +github.com/casbin/casbin/v2 v2.37.0 h1:/poEwPSovi4bTOcP752/CsTQiRz2xycyVKFG7GUhbDw= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U= +github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgfKw= +github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible h1:C29Ae4G5GtYyYMm1aztcyj/J5ckgJm2zwdDajFbx1NY= +github.com/circonus-labs/circonusllhist v0.1.3 h1:TJH+oke8D16535+jHExHj4nQvzlZrj7ug5D7I/orNUA= +github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I= +github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec h1:EdRZT3IeKQmfCSrgo8SZ8V3MEnskuJP0wCYNpe+aiXo= +github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= +github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe h1:QQ3GSy+MqSHxm/d8nCtnAiZdYFd45cYZPs8vOOIYKfk= +github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= +github.com/cockroachdb/cockroach-go v0.0.0-20181001143604-e0a95dfd547c h1:2zRrJWIt/f9c9HhNHAgrRgq0San5gRRUJTBXLkchal0= +github.com/cockroachdb/datadriven v1.0.2 h1:H9MtNqVoVhvd9nCBwOyDjUEdZCREqbIdCJD93PBm/jA= +github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd h1:qMd81Ts1T2OTKmB4acZcyKaMtRnY5Y44NuXGX2GFJ1w= +github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0 h1:sDMmm+q/3+BukdIpxwO365v/Rbspp2Nt5XntgQRXq8Q= +github.com/containerd/containerd v1.6.8 h1:h4dOFDwzHmqFEP754PgfgTeVXFnLiRc6kiqC7tplDJs= +github.com/containerd/containerd v1.6.8/go.mod h1:By6p5KqPK0/7/CgO/A6t/Gz+CUYUu2zf1hUaaymVXB0= +github.com/coreos/etcd v3.3.10+incompatible h1:jFneRYjIvLMLhDLCzuTuU4rSJUjRplcJQ7pD7MnhC04= +github.com/coreos/go-etcd v2.0.0+incompatible h1:bXhRBIXoTm9BYHS3gE0TtQuyNZyeEMux2sDi4oo5YOo= +github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk= +github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c= +github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf h1:CAKfRE2YtTUIjjh1bkBtyYFaUT/WmOqsJjgtihT0vMI= +github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= +github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= +github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo= +github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4= +github.com/cristalhq/hedgedhttp v0.9.1 h1:g68L9cf8uUyQKQJwciD0A1Vgbsz+QgCjuB1I8FAsCDs= +github.com/cristalhq/hedgedhttp v0.9.1/go.mod h1:XkqWU6qVMutbhW68NnzjWrGtH8NUx1UfYqGYtHVKIsI= +github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07 h1:UHFGPvSxX4C4YBApSPvmUfL8tTvWLj2ryqvT9K4Jcuk= +github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f h1:7uSNgsgcarNk4oiN/nNkO0J7KAjlsF5Yv5Gf/tFdHas= +github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4 h1:CVAqftqbj+exlab+8KJQrE+kNIVlQfJt58j4GxCMF1s= +github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00 h1:FHpbUtp2K8X53/b4aFNj4my5n+i3x+CQCZWNuHWH/+E= +github.com/cznic/lldb v1.1.0 h1:AIA+ham6TSJ+XkMe8imQ/g8KPzMUVWAwqUQQdtuMsHs= +github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369 h1:XNT/Zf5l++1Pyg08/HV04ppB0gKxAqtZQBRYiYrUuYk= +github.com/cznic/ql v1.2.0 h1:lcKp95ZtdF0XkWhGnVIXGF8dVD2X+ClS08tglKtf+ak= +github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65 h1:hxuZop6tSoOi0sxFzoGGYdRqNrPubyaIf9KoBG9tPiE= +github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186 h1:0rkFMAbn5KBKNpJyHQ6Prb95vIKanmAe62KxsrN+sqA= +github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc h1:YKKpTb2BrXN2GYyGaygIdis1vXbE7SSAG9axGWIMClg= +github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= +github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/denisenkom/go-mssqldb v0.12.0 h1:VtrkII767ttSPNRfFekePK3sctr+joXgO58stqQbtUA= +github.com/devigned/tab v0.1.1 h1:3mD6Kb1mUOYeLpJvTVSDwSg5ZsfSxfvxGRTxRsJsITA= +github.com/dgraph-io/badger v1.6.0 h1:DshxFxZWXUcO0xX476VJC07Xsr6ZCBVRHKZ93Oh7Evo= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= +github.com/dhui/dktest v0.3.0 h1:kwX5a7EkLcjo7VpsPQSYJcKGbXBXdjI9FGjuUj1jn6I= +github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ= +github.com/drone/drone-runtime v1.1.0 h1:IsKbwiLY6+ViNBzX0F8PERJVZZcEJm9rgxEh3uZP5IE= +github.com/drone/drone-runtime v1.1.0/go.mod h1:+osgwGADc/nyl40J0fdsf8Z09bgcBZXvXXnLOY48zYs= +github.com/drone/drone-yaml v1.2.3 h1:SWzLmzr8ARhbtw1WsVDENa8WFY2Pi9l0FVMfafVUWz8= +github.com/drone/drone-yaml v1.2.3/go.mod h1:QsqliFK8nG04AHFN9tTn9XJomRBQHD4wcejWW1uz/10= +github.com/drone/funcmap v0.0.0-20211123105308-29742f68a7d1 h1:E8hjIYiEyI+1S2XZSLpMkqT9V8+YMljFNBWrFpuVM3A= +github.com/drone/funcmap v0.0.0-20211123105308-29742f68a7d1/go.mod h1:Hph0/pT6ZxbujnE1Z6/08p5I0XXuOsppqF6NQlGOK0E= +github.com/drone/signal v1.0.0 h1:NrnM2M/4yAuU/tXs6RP1a1ZfxnaHwYkd0kJurA1p6uI= +github.com/eapache/go-resiliency v1.3.0 h1:RRL0nge+cWGlxXbUzJ7yMcq6w2XBEr19dCN6HECGaT0= +github.com/eapache/go-resiliency v1.3.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= +github.com/eapache/go-xerial-snappy v0.0.0-20230111030713-bf00bc1b83b6 h1:8yY/I9ndfrgrXUbOGObLHKBR4Fl3nZXwM2c7OYTT8hM= +github.com/eapache/go-xerial-snappy v0.0.0-20230111030713-bf00bc1b83b6/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= +github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= +github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 h1:clC1lXBpe2kTj2VHdaIu9ajZQe4kcEY9j0NsnDDBZ3o= +github.com/etcd-io/bbolt v1.3.3 h1:gSJmxrs37LgTqR/oyJBWok6k6SvXEUerFTbltIhXkBM= +github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb h1:IT4JYU7k4ikYg1SCxNI1/Tieq/NFvh6dzLdgi7eu0tM= +github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc= +github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072 h1:DddqAaWDpywytcG8w/qoQ5sAN8X12d3Z3koB0C3Rxsc= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/franela/goblin v0.0.0-20210519012713-85d372ac71e2 h1:cZqz+yOJ/R64LcKjNQOdARott/jP7BnUQ9Ah7KaZCvw= +github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8 h1:a9ENSRDFBUPkJ5lCgVZh26+ZbGyoVJG7yb5SSzF5H54= +github.com/fsouza/fake-gcs-server v1.7.0 h1:Un0BXUXrRWYSmYyC1Rqm2e2WJfTPyDy/HGMz31emTi8= +github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8= +github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= +github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= +github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-fonts/dejavu v0.1.0 h1:JSajPXURYqpr+Cu8U9bt8K+XcACIHWqWrvWCKyeFmVQ= +github.com/go-fonts/latin-modern v0.2.0 h1:5/Tv1Ek/QCr20C6ZOz15vw3g7GELYL98KWr8Hgo+3vk= +github.com/go-fonts/liberation v0.2.0 h1:jAkAWJP4S+OsrPLZM4/eC9iW7CtHy+HBXrEwZXWo5VM= +github.com/go-fonts/stix v0.1.0 h1:UlZlgrvvmT/58o573ot7NFw0vZasZ5I6bcIft/oMdgg= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I= +github.com/go-ini/ini v1.25.4 h1:Mujh4R/dH6YL8bxuISne3xX2+qcQ9p0IxKAP6ExWoUo= +github.com/go-kit/kit v0.12.0 h1:e4o3o3IsBfAKQh5Qbbiqyfu97Ku7jrO/JbohvztANh4= +github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81 h1:6zl3BbBhdnMkpSj2YY30qV3gDcVBGtFgVsV3+/i+mKQ= +github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab h1:xveKWz2iaueeTaUgdetzel+U7exyigDYBryyVfV/rZk= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-pdf/fpdf v0.6.0 h1:MlgtGIfsdMEEQJr2le6b/HNr1ZlQwxyWr77r2aj2U/8= +github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= +github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= +github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ= +github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= +github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd h1:hSkbZ9XSyjyBirMeqSqUrK+9HboWrweVlzRNqoBi2d4= +github.com/gobuffalo/depgen v0.1.0 h1:31atYa/UW9V5q8vMJ+W6wd64OaaTHUrCUXER358zLM4= +github.com/gobuffalo/envy v1.7.0 h1:GlXgaiBkmrYMHco6t4j7SacKO4XUjvh5pwXh0f4uxXU= +github.com/gobuffalo/flect v0.1.3 h1:3GQ53z7E3o00C/yy7Ko8VXqQXoJGLkrTQCLTF1EjoXU= +github.com/gobuffalo/genny v0.1.1 h1:iQ0D6SpNXIxu52WESsD+KoQ7af2e3nCfnSBoSF/hKe0= +github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211 h1:mSVZ4vj4khv+oThUfS+SQU3UuFIZ5Zo6UNcvK8E8Mz8= +github.com/gobuffalo/gogen v0.1.1 h1:dLg+zb+uOyd/mKeQUYIbwbNmfRsr9hd/WtYWepmayhI= +github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2 h1:8thhT+kUJMTMy3HlX4+y9Da+BNJck+p109tqqKp7WDs= +github.com/gobuffalo/mapi v1.0.2 h1:fq9WcL1BYrm36SzK6+aAnZ8hcp+SrmnDyAxhNx8dvJk= +github.com/gobuffalo/packd v0.1.0 h1:4sGKOD8yaYJ+dek1FDkwcxCHA40M4kfKgFHx8N2kwbU= +github.com/gobuffalo/packr/v2 v2.2.0 h1:Ir9W9XIm9j7bhhkKE9cokvtTl1vBm62A/fene/ZCj6A= +github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754 h1:tpom+2CJmpzAWj5/VEHync2rJGi+epHNIeRSWjzGA+4= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/ws v1.2.1 h1:F2aeBZrm2NDsc7vbovKrWSogd4wvfAxg0FQ89/iqOTk= +github.com/goccy/go-yaml v1.11.0 h1:n7Z+zx8S9f9KgzG6KtQKf+kwqXZlLNR2F6018Dgau54= +github.com/goccy/go-yaml v1.11.0/go.mod h1:H+mJrWtjPTJAHvRbV09MCK9xYwODM+wRTVFFTWckfng= +github.com/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4 h1:vF83LI8tAakwEwvWZtrIEx7pOySacl2TOxx6eXk4ePo= +github.com/godbus/dbus/v5 v5.0.4 h1:9349emZab16e7zQvpmsbtjc18ykshndd8y2PG3sgJbA= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws= +github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE= +github.com/google/go-jsonnet v0.18.0 h1:/6pTy6g+Jh1a1I2UMoAODkqELFiVIdOxbNwv0DDzoOg= +github.com/google/go-jsonnet v0.18.0/go.mod h1:C3fTzyVJDslXdiTqw/bTFk7vSGyCtH3MGRbDfvEwGd0= +github.com/google/go-pkcs11 v0.2.1-0.20230907215043-c6f79328ddf9 h1:OF1IPgv+F4NmqmJ98KTjdN97Vs1JxDPB3vbmYzV2dpk= +github.com/google/go-replayers/grpcreplay v1.1.0 h1:S5+I3zYyZ+GQz68OfbURDdt/+cSMqCK1wrvNx7WBzTE= +github.com/google/go-replayers/httpreplay v1.1.1 h1:H91sIMlt1NZzN7R+/ASswyouLJfW0WLW7fhyUFvDEkY= +github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/subcommands v1.0.1 h1:/eqq+otEXm5vhfBrbREPCSVQbvofip6kIz+mX5TUH7k= +github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8 h1:tlyzajkF3030q6M8SvmJSemC9DTHL/xaMa18b65+JM4= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= +github.com/grafana/e2e v0.1.1-0.20221018202458-cffd2bb71c7b h1:Ha+kSIoTutf4ytlVw/SaEclDUloYx0+FXDKJWKhNbE4= +github.com/grafana/e2e v0.1.1-0.20221018202458-cffd2bb71c7b/go.mod h1:3UsooRp7yW5/NJQBlXcTsAHOoykEhNUYXkQ3r6ehEEY= +github.com/grafana/gomemcache v0.0.0-20231023152154-6947259a0586 h1:/of8Z8taCPftShATouOrBVy6GaTTjgQd/VfNiZp/VXQ= +github.com/grafana/gomemcache v0.0.0-20231023152154-6947259a0586/go.mod h1:PGk3RjYHpxMM8HFPhKKo+vve3DdlPUELZLSDEFehPuU= +github.com/grafana/grafana-plugin-sdk-go v0.212.0/go.mod h1:qsI4ktDf0lig74u8SLPJf9zRdVxWV/W4Wi+Ox6gifgs= +github.com/grafana/grafana/pkg/promlib v0.0.2/go.mod h1:3El4NlsfALz8QQCbEGHGFvJUG+538QLMuALRhZ3pcoo= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 h1:pdN6V1QBWetyv/0+wjACpqVH+eVULgEjkurDLq3goeM= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1/go.mod h1:YvJ2f6MplWDhfxiUC3KpyTy76kYUZA4W3pTv/wdKQ9Y= +github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1:MJG/KsmcqMwFAkh8mTnAwhyKoB+sTAnY4CACC110tbU= +github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= +github.com/hamba/avro/v2 v2.17.2 h1:6PKpEWzJfNnvBgn7m2/8WYaDOUASxfDU+Jyb4ojDgFY= +github.com/hamba/avro/v2 v2.17.2/go.mod h1:Q9YK+qxAhtVrNqOhwlZTATLgLA8qxG2vtvkhK8fJ7Jo= +github.com/hanwen/go-fuse v1.0.0 h1:GxS9Zrn6c35/BnfiVsZVWmsG803xwE7eVRDvcf/BEVc= +github.com/hanwen/go-fuse/v2 v2.1.0 h1:+32ffteETaLYClUj0a3aHjZ1hOPxxaNEHiZiujuDaek= +github.com/hashicorp/consul/sdk v0.15.0 h1:2qK9nDrr4tiJKRoxPGhm6B7xJjLVIQqkjiab2M4aKjU= +github.com/hashicorp/go-syslog v1.0.0 h1:KaodqZuhUoZereWVIYmpUgZysurB1kBLX2j0MwMrUAE= +github.com/hashicorp/go.net v0.0.1 h1:sNCoNyDEvN1xa+X0baata4RdcpKwcMS6DH+xwfqPgjw= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= +github.com/hashicorp/mdns v1.0.4 h1:sY0CMhFmjIPDMlTB+HfymFHCaYLhgifZ0QhjaYKD/UQ= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hudl/fargo v1.4.0 h1:ZDDILMbB37UlAVLlWcJ2Iz1XuahZZTDZfdCKeclfq2s= +github.com/hydrogen18/memlistener v0.0.0-20200120041712-dcc25e7acd91 h1:KyZDvZ/GGn+r+Y3DKZ7UOQ/TP4xV6HNkrwiVMB1GnNY= +github.com/iancoleman/strcase v0.2.0 h1:05I4QRnGpI0m37iZQRuskXh+w77mr6Z41lwQzuHLwW0= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab h1:BA4a7pe6ZTd9F8kXETBoijjFJ/ntaa//1wiH9BZu4zU= +github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= +github.com/influxdata/influxdb v1.7.6 h1:8mQ7A/V+3noMGCt/P9pD09ISaiz9XvgCk303UYA3gcs= +github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab h1:HqW4xhhynfjrtEiiSGcQUd6vrK23iMam1FO8rI7mwig= +github.com/iris-contrib/blackfriday v2.0.0+incompatible h1:o5sHQHHm0ToHUlAJSTjW9UWicjJSDDauOOQ2AHuIVp4= +github.com/iris-contrib/go.uuid v2.0.0+incompatible h1:XZubAYg61/JwnJNbZilGjf3b3pB80+OQg2qf6c8BfWE= +github.com/iris-contrib/jade v1.1.3 h1:p7J/50I0cjo0wq/VWVCDFd8taPJbuFC+bq23SniRFX0= +github.com/iris-contrib/pongo2 v0.0.1 h1:zGP7pW51oi5eQZMIlGA3I+FHY9/HOQWDB+572yin0to= +github.com/iris-contrib/schema v0.0.1 h1:10g/WnoRR+U+XXHWKBHeNy/+tZmM2kcAVGLOsz+yaDA= +github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 h1:vr3AYkKovP8uR8AvSGGUK1IDqRa5lAAvEkZG1LKaCRc= +github.com/jackc/pgconn v1.11.0 h1:HiHArx4yFbwl91X3qqIHtUFoiIfLNJXCQRsnzkiwwaQ= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= +github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= +github.com/jackc/pgproto3/v2 v2.2.0 h1:r7JypeP2D3onoQTCxWdTpCtJ4D+qpKr0TxvoyMhZ5ns= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= +github.com/jackc/pgtype v1.10.0 h1:ILnBWrRMSXGczYvmkYD6PsYyVFUNLTnIUJHHDLmqk38= +github.com/jackc/pgx v3.2.0+incompatible h1:0Vihzu20St42/UDsvZGdNE6jak7oi/UOeMzwMPHkgFY= +github.com/jackc/pgx/v4 v4.15.0 h1:B7dTkXsdILD3MF987WGGCcg+tvLW6bZJdEcqVFeU//w= +github.com/jackc/puddle v1.2.1 h1:gI8os0wpRXFd4FiAY2dWiqRK037tjj3t7rKFeO4X5iw= +github.com/jackspirou/syscerts v0.0.0-20160531025014-b68f5469dff1 h1:9Xm8CKtMZIXgcopfdWk/qZ1rt0HjMgfMR9nxxSeK6vk= +github.com/jackspirou/syscerts v0.0.0-20160531025014-b68f5469dff1/go.mod h1:zuHl3Hh+e9P6gmBPvcqR1HjkaWHC/csgyskg6IaFKFo= +github.com/jaegertracing/jaeger v1.41.0 h1:vVNky8dP46M2RjGaZ7qRENqylW+tBFay3h57N16Ip7M= +github.com/jaegertracing/jaeger v1.41.0/go.mod h1:SIkAT75iVmA9U+mESGYuMH6UQv6V9Qy4qxo0lwfCQAc= +github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jedib0t/go-pretty/v6 v6.2.4 h1:wdaj2KHD2W+mz8JgJ/Q6L/T5dB7kyqEFI16eLq7GEmk= +github.com/jedib0t/go-pretty/v6 v6.2.4/go.mod h1:+nE9fyyHGil+PuISTCrp7avEdo6bqoMwqZnuiK2r2a0= +github.com/jhump/gopoet v0.1.0 h1:gYjOPnzHd2nzB37xYQZxj4EIQNpBrBskRqQQ3q4ZgSg= +github.com/jhump/goprotoc v0.5.0 h1:Y1UgUX+txUznfqcGdDef8ZOVlyQvnV0pKWZH08RmZuo= +github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= +github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= +github.com/jsternberg/zap-logfmt v1.2.0 h1:1v+PK4/B48cy8cfQbxL4FmmNZrjnIMr2BsnyEmXqv2o= +github.com/jsternberg/zap-logfmt v1.2.0/go.mod h1:kz+1CUmCutPWABnNkOu9hOHKdT2q3TDYCcsFy9hpqb0= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d h1:c93kUJDtVAXFEhsCh5jSxyOJmFHuzcihnslQiX8Urwo= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5 h1:PJr+ZMXIecYc1Ey2zucXdR73SMBtgjPgwa31099IMv0= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 h1:uC1QfSlInpQF+M0ao65imhwqKnz3Q2z/d8PWZRMQvDM= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= +github.com/karrick/godirwalk v1.10.3 h1:lOpSw2vJP0y5eLBW906QwKsUK/fe/QDyoqM5rnnuPDY= +github.com/kataras/golog v0.0.10 h1:vRDRUmwacco/pmBAm8geLn8rHEdc+9Z4NAr5Sh7TG/4= +github.com/kataras/iris/v12 v12.1.8 h1:O3gJasjm7ZxpxwTH8tApZsvf274scSGQAUpNe47c37U= +github.com/kataras/neffos v0.0.14 h1:pdJaTvUG3NQfeMbbVCI8JT2T5goPldyyfUB2PJfh1Bs= +github.com/kataras/pio v0.0.2 h1:6NAi+uPJ/Zuid6mrAKlgpbI11/zK/lV4B2rxWaJN98Y= +github.com/kataras/sitemap v0.0.5 h1:4HCONX5RLgVy6G4RkYOV3vKNcma9p236LdGOipJsaFE= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kisielk/errcheck v1.5.0 h1:e8esj/e4R+SAOwFwN+n3zr0nYeCyeweozKfO23MvHzY= +github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid v1.2.1 h1:vJi+O/nMdFt0vqm8NZBI6wzALWdA2X+egi0ogNyrC/w= +github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs= +github.com/knadh/koanf v1.5.0/go.mod h1:Hgyjp4y8v44hpZtPzs7JZfRAW5AhN7KfZcwv1RYggDs= +github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 h1:T+h1c/A9Gawja4Y9mFVWj2vyii2bbUNDw3kt9VxK2EY= +github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI= +github.com/kshvakov/clickhouse v1.3.5 h1:PDTYk9VYgbjPAWry3AoDREeMgOVUFij6bh6IjlloHL0= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= +github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= +github.com/lestrrat-go/blackmagic v1.0.0 h1:XzdxDbuQTz0RZZEmdU7cnQxUtFUzgCSPq8RCz4BxIi4= +github.com/lestrrat-go/blackmagic v1.0.0/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A= +github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc= +github.com/lestrrat-go/jwx v1.2.25 h1:tAx93jN2SdPvFn08fHNAhqFJazn5mBBOB8Zli0g0otA= +github.com/lestrrat-go/jwx v1.2.25/go.mod h1:zoNuZymNl5lgdcu6P7K6ie2QRll5HVfF4xwxBBK1NxY= +github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4= +github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743 h1:143Bb8f8DuGWck/xpNUOckBVYfFbBTnLevfRZ1aVVqo= +github.com/lightstep/lightstep-tracer-go v0.18.1 h1:vi1F1IQ8N7hNWytK9DpJsUfQhGuNSc19z330K6vl4zk= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/lyft/protoc-gen-star v0.6.1 h1:erE0rdztuaDq3bpGifD95wfoPrSZc95nGA6tbiNYh6M= +github.com/lyft/protoc-gen-star/v2 v2.0.3 h1:/3+/2sWyXeMLzKd1bX+ixWKgEMsULrIivpDsuaF441o= +github.com/lyft/protoc-gen-validate v0.0.13 h1:KNt/RhmQTOLr7Aj8PsJ7mTronaFyx80mRTT9qF261dA= +github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= +github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2 h1:JgVTCPf0uBVcUSWpyXmGpgOc62nK5HWUBKAGc3Qqa5k= +github.com/markbates/safe v1.0.1 h1:yjZkbvRM6IzKj9tlu/zMJLS0n/V351OZWRnF3QfaUxI= +github.com/matryer/moq v0.2.7 h1:RtpiPUM8L7ZSCbSwK+QcZH/E9tgqAkFjKQxsRs25b4w= +github.com/matryer/moq v0.2.7/go.mod h1:kITsx543GOENm48TUAQyJ9+SAvFSr7iGQXPoth/VUBk= +github.com/mattn/goveralls v0.0.2 h1:7eJB6EqsPhRVxvwEXGnqdO2sJI0PTsrWoTMXEk9/OQc= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= +github.com/mediocregopher/radix/v3 v3.4.2 h1:galbPBjIwmyREgwGCfQEN4X8lxbJnKBYurgz+VfcStA= +github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s= +github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.52 h1:8XhG36F6oKQUDDSuz6dY3rioMzovKjW40W6ANuN0Dps= +github.com/minio/minio-go/v7 v7.0.52/go.mod h1:IbbodHyjUAguneyucUaahv+VMNs/EOTV9du7A7/Z3HU= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= +github.com/mitchellh/cli v1.1.5 h1:OxRIeJXpAMztws/XHlN2vu6imG5Dpq+j61AzAX5fLng= +github.com/mitchellh/gox v0.4.0 h1:lfGJxY7ToLJQjHHwi0EX6uYBdK78egf954SQl13PQJc= +github.com/mitchellh/iochan v1.0.0 h1:C+X3KsSTLFVBr/tK1eYN/vs4rJcvsiLU338UhYPJWeY= +github.com/mithrandie/readline-csvq v1.2.1 h1:4cfeYeVSrqKEWi/1t7CjyhFD2yS6fm+l+oe+WyoSNlI= +github.com/mithrandie/readline-csvq v1.2.1/go.mod h1:ydD9Eyp3/wn8KPSNbKmMZe4RQQauCuxi26yEo4N40dk= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5 h1:8Q0qkMVC/MmWkpIdlvZgcv2o2jrlF6zqVOh7W5YHdMA= +github.com/montanaflynn/stats v0.7.0 h1:r3y12KyNxj/Sb/iOE46ws+3mS1+MZca1wlHQFPsY/JU= +github.com/mostynb/go-grpc-compression v1.1.17 h1:N9t6taOJN3mNTTi0wDf4e3lp/G/ON1TP67Pn0vTUA9I= +github.com/mostynb/go-grpc-compression v1.1.17/go.mod h1:FUSBr0QjKqQgoDG/e0yiqlR6aqyXC39+g/hFLDfSsEY= +github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs= +github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8 h1:P48LjvUQpTReR3TQRbxSeSBsMXzfK0uol7eRcr7VBYQ= +github.com/natessilva/dag v0.0.0-20180124060714-7194b8dcc5c4 h1:dnMxwus89s86tI8rcGVp2HwZzlz7c5o92VOy7dSckBQ= +github.com/nats-io/jwt v1.2.2 h1:w3GMTO969dFg+UOKTmmyuu7IGdusK+7Ytlt//OYH/uU= +github.com/nats-io/jwt/v2 v2.0.3 h1:i/O6cmIsjpcQyWDYNcq2JyZ3/VTF8SJ4JWluI5OhpvI= +github.com/nats-io/nats-server/v2 v2.5.0 h1:wsnVaaXH9VRSg+A2MVg5Q727/CqxnmPLGFQ3YZYKTQg= +github.com/nats-io/nats.go v1.12.1 h1:+0ndxwUPz3CmQ2vjbXdkC1fo3FdiOQDim4gl3Mge8Qo= +github.com/nats-io/nkeys v0.3.0 h1:cgM5tL53EvYRU+2YLXIK0G2mJtK12Ft9oeooSZMA2G8= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/oklog/oklog v0.3.2 h1:wVfs8F+in6nTBMkA7CbRw+zZMIB7nNM825cM1wuzoTk= +github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= +github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= +github.com/open-telemetry/opentelemetry-collector-contrib/exporter/jaegerexporter v0.74.0 h1:0dve/IbuHfQOnlIBQQwpCxIeMp7uig9DQVuvisWPDRs= +github.com/open-telemetry/opentelemetry-collector-contrib/exporter/jaegerexporter v0.74.0/go.mod h1:bIeSj+SaZdP3CE9Xae+zurdQC6DXX0tPP6NAEVmgtt4= +github.com/open-telemetry/opentelemetry-collector-contrib/exporter/kafkaexporter v0.74.0 h1:MrVOfBTNBe4n/daZjV4yvHZRR0Jg/MOCl/mNwymHwDM= +github.com/open-telemetry/opentelemetry-collector-contrib/exporter/kafkaexporter v0.74.0/go.mod h1:v4H2ATSrKfOTbQnmjCxpvuOjrO/GUURAgey9RzrPsuQ= +github.com/open-telemetry/opentelemetry-collector-contrib/exporter/zipkinexporter v0.74.0 h1:8Kk5g5PKQBUV3idjJy1NWVLLReEzjnB8C1lFgQxZ0TI= +github.com/open-telemetry/opentelemetry-collector-contrib/exporter/zipkinexporter v0.74.0/go.mod h1:UtVfxZGhPU2OvDh7H8o67VKWG9qHAHRNkhmZUWqCvME= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.74.0 h1:vU5ZebauzCuYNXFlQaWaYnOfjoOAnS+Sc8+oNWoHkbM= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.74.0/go.mod h1:TEu3TnUv1TuyHtjllrUDQ/ImpyD+GrkDejZv4hxl3G8= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/sharedcomponent v0.74.0 h1:COFBWXiWnhRs9x1oYJbDg5cyiNAozp8sycriD9+1/7E= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/sharedcomponent v0.74.0/go.mod h1:cAKlYKU+/8mk6ETOnD+EAi5gpXZjDrGweAB9YTYrv/g= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/translator/jaeger v0.74.0 h1:ww1pPXfAM0WHsymQnsN+s4B9DgwQC+GyoBq0t27JV/k= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/translator/jaeger v0.74.0/go.mod h1:OpEw7tyCg+iG1ywEgZ03qe5sP/8fhYdtWCMoqA8JCug= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/translator/opencensus v0.74.0 h1:0Fh6OjlUB9HlnX90/gGiyyFvnmNBv6inj7bSaVqQ7UQ= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/translator/opencensus v0.74.0/go.mod h1:13ekplz1UmvK99Vz2VjSBWPYqoRBEax5LPmA1tFHnhA= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/translator/zipkin v0.74.0 h1:A5xoBaMHX1WzLfvlqK6NBXq4XIbuSVJIpec5r6PDE7U= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/translator/zipkin v0.74.0/go.mod h1:TJT7HkhFPrJic30Vk4seF/eRk8sa0VQ442Xq/qd+DLY= +github.com/open-telemetry/opentelemetry-collector-contrib/receiver/jaegerreceiver v0.74.0 h1:pWNSPCKD+V4rC+MnZj8uErEbcsYUpEqU3InNYyafAPY= +github.com/open-telemetry/opentelemetry-collector-contrib/receiver/jaegerreceiver v0.74.0/go.mod h1:0lXcDf6LUbtDxZZO3zDbRzMuL7gL1Q0FPOR8/3IBwaQ= +github.com/open-telemetry/opentelemetry-collector-contrib/receiver/kafkareceiver v0.74.0 h1:NWd9+rQTd6pELLf3copo7CEuNgKp90kgyhPozpwax2U= +github.com/open-telemetry/opentelemetry-collector-contrib/receiver/kafkareceiver v0.74.0/go.mod h1:anSbwGOousKpnNAVMNP5YieA4KOFuEzHkvya0vvtsaI= +github.com/open-telemetry/opentelemetry-collector-contrib/receiver/opencensusreceiver v0.74.0 h1:Law7+BImq8DIBsdniSX8Iy2/GH5CRHpT1gsRaC9ZT8A= +github.com/open-telemetry/opentelemetry-collector-contrib/receiver/opencensusreceiver v0.74.0/go.mod h1:uiW3V9EX8A5DOoxqDLuSh++ewHr+owtonCSiqMcpy3w= +github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceiver v0.74.0 h1:2uysjsaqkf9STFeJN/M6i/sSYEN5pZJ94Qd2/Hg1pKE= +github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceiver v0.74.0/go.mod h1:qoGuayD7cAtshnKosIQHd6dobcn6/sqgUn0v/Cg2UB8= +github.com/opentracing-contrib/go-grpc v0.0.0-20210225150812-73cb765af46e h1:4cPxUYdgaGzZIT5/j0IfqOrrXmq6bG8AwvwisMXpdrg= +github.com/opentracing-contrib/go-grpc v0.0.0-20210225150812-73cb765af46e/go.mod h1:DYR5Eij8rJl8h7gblRrOZ8g0kW1umSpKqYIBTgeDtLo= +github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492 h1:lM6RxxfUMrYL/f8bWEUqdXrANWtrL7Nndbm9iFN0DlU= +github.com/opentracing/basictracer-go v1.0.0 h1:YyUAhaEfjoWXclZVJ9sGoNct7j4TVk7lZWlQw5UXuoo= +github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5 h1:ZCnq+JUrvXcDVhX/xRolRBZifmabN1HcS1wrPSvxhrU= +github.com/openzipkin/zipkin-go v0.4.1 h1:kNd/ST2yLLWhaWrkgchya40TJabe8Hioj9udfPcEO5A= +github.com/openzipkin/zipkin-go v0.4.1/go.mod h1:qY0VqDSN1pOBN94dBc6w2GJlWLiovAyg7Qt6/I9HecM= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/pact-foundation/pact-go v1.0.4 h1:OYkFijGHoZAYbOIb1LWXrwKQbMMRUv1oQ89blD2Mh2Q= +github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml/v2 v2.0.5 h1:ipoSadvV8oGUjnUbMub59IDPPwfxF694nG/jwbMiyQg= +github.com/performancecopilot/speed v3.0.0+incompatible h1:2WnRzIquHa5QxaJKShDkLM+sc0JPuwhXzK8OYOyt3Vg= +github.com/performancecopilot/speed/v4 v4.0.0 h1:VxEDCmdkfbQYDlcr/GC9YoN9PQ6p8ulk9xVsepYy9ZY= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/phpdave11/gofpdf v1.4.2 h1:KPKiIbfwbvC/wOncwhrpRdXVj2CZTCFlw4wnoyjtHfQ= +github.com/phpdave11/gofpdi v1.0.13 h1:o61duiW8M9sMlkVXWlvP92sZJtGKENvW3VExs6dZukQ= +github.com/pierrec/lz4 v2.0.5+incompatible h1:2xWsjqPFWcplujydGg4WmhC/6fZqK42wMM8aXeqhl0I= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= +github.com/pkg/profile v1.2.1 h1:F++O52m40owAmADcojzM+9gyjmMOY/T4oYJkgFDH8RE= +github.com/pkg/sftp v1.13.1 h1:I2qBYMChEhIjOgazfJmV3/mZM256btk6wkCDRmW7JYs= +github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8cTqKc= +github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/common/assets v0.2.0 h1:0P5OrzoHrYBOSM1OigWL3mY8ZvV2N4zIE/5AahrSrfM= +github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= +github.com/prometheus/statsd_exporter v0.22.7 h1:7Pji/i2GuhK6Lu7DHrtTkFmNBCudCPT1pX2CziuyQR0= +github.com/prometheus/statsd_exporter v0.22.7/go.mod h1:N/TevpjkIh9ccs6nuzY3jQn9dFqnUakOjnEuMPJJJnI= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= +github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4 h1:BN/Nyn2nWMoqGRA7G7paDNDqTXE30mXGqzzybrfo05w= +github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.15.0 h1:uPRuwkWF4J6fGsJ2R0Gn2jB1EQiav9k3S6CSdygQJXY= +github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= +github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245 h1:K1Xf3bKttbF+koVGaX5xngRIZ5bVjbmPnaxE/dR08uY= +github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk= +github.com/sagikazarmark/crypt v0.6.0 h1:REOEXCs/NFY/1jOCEouMuT4zEniE5YoXbvpC5X/TLF8= +github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da h1:p3Vo3i64TCLY7gIfzeQaUJ+kppEO5WQG3cL8iE8tGHU= +github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= +github.com/schollz/closestmatch v2.1.0+incompatible h1:Uel2GXEpJqOWBrlyI+oY9LTiyyjYS17cCYRqP13/SHk= +github.com/segmentio/fasthash v0.0.0-20180216231524-a72b379d632e h1:uO75wNGioszjmIzcY/tvdDYKRLVvzggtAmmJkn9j4GQ= +github.com/segmentio/fasthash v0.0.0-20180216231524-a72b379d632e/go.mod h1:tm/wZFQ8e24NYaBGIlnO2WGCAi67re4HHuOm0sftE/M= +github.com/segmentio/parquet-go v0.0.0-20230427215636-d483faba23a5 h1:7CWCjaHrXSUCHrRhIARMGDVKdB82tnPAQMmANeflKOw= +github.com/segmentio/parquet-go v0.0.0-20230427215636-d483faba23a5/go.mod h1:+J0xQnJjm8DuQUHBO7t57EnmPbstT6+b45+p3DC9k1Q= +github.com/sercand/kuberesolver/v4 v4.0.0 h1:frL7laPDG/lFm5n98ODmWnn+cvPpzlkf3LhzuPhcHP4= +github.com/sercand/kuberesolver/v4 v4.0.0/go.mod h1:F4RGyuRmMAjeXHKL+w4P7AwUnPceEAPAhxUgXZjKgvM= +github.com/sercand/kuberesolver/v5 v5.1.1 h1:CYH+d67G0sGBj7q5wLK61yzqJJ8gLLC8aeprPTHb6yY= +github.com/sercand/kuberesolver/v5 v5.1.1/go.mod h1:Fs1KbKhVRnB2aDWN12NjKCB+RgYMWZJ294T3BtmVCpQ= +github.com/shirou/gopsutil/v3 v3.23.2 h1:PAWSuiAszn7IhPMBtXsbSCafej7PqUOvY6YywlQUExU= +github.com/shirou/gopsutil/v3 v3.23.2/go.mod h1:gv0aQw33GLo3pG8SiWKiQrbDzbRY1K80RyZJ7V4Th1M= +github.com/shoenig/test v0.6.6 h1:Oe8TPH9wAbv++YPNDKJWUnI8Q4PPWCx3UbOfH+FxiMU= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/sony/gobreaker v0.4.1 h1:oMnRNZXX5j85zso6xCPRNPtmAycat+WcoKbklScLDgQ= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.9.2 h1:j49Hj62F0n+DaZ1dDCvhABaPNSGNkt32oRFxI33IEMw= +github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY= +github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/viper v1.14.0 h1:Rg7d3Lo706X9tHsJMUjdiwMpHB7W8WnSVOssIY+JElU= +github.com/spf13/viper v1.14.0/go.mod h1:WT//axPky3FdvXHzGw33dNdXXXfFQqmEalje+egj8As= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad h1:fiWzISvDn0Csy5H0iwgAuJGQTUpVfEMJJd4nRFXogbc= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= +github.com/streadway/amqp v1.0.0 h1:kuuDrUJFZL1QYL9hUNuCxNObNzB0bV/ZG5jV3RWAQgo= +github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e h1:mOtuXaRAbVZsxAHVdPR3IjfmN8T1h2iczJLynhLybf8= +github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= +github.com/substrait-io/substrait-go v0.4.2 h1:buDnjsb3qAqTaNbOR7VKmNgXf4lYQxWEcnSGUWBtmN8= +github.com/substrait-io/substrait-go v0.4.2/go.mod h1:qhpnLmrcvAnlZsUyPXZRqldiHapPTXC3t7xFgDi3aQg= +github.com/tidwall/gjson v1.14.2 h1:6BBkirS0rAHjumnjHF6qgy5d2YAJ1TLIaFE2lzfOLqo= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tklauser/go-sysconf v0.3.11 h1:89WgdJhk5SNwJfu+GKyYveZ4IaJ7xAkecBo+KdJV0CM= +github.com/tklauser/go-sysconf v0.3.11/go.mod h1:GqXfhXY3kiPa0nAXPDIQIWzJbMCB7AmcWpGR8lSZfqI= +github.com/tklauser/numcpus v0.6.0 h1:kebhY2Qt+3U6RNK7UqpYNA+tJ23IBEGKkB7JQBfDYms= +github.com/tklauser/numcpus v0.6.0/go.mod h1:FEZLMke0lhOUG6w2JadTzp0a+Nl8PF/GFkQ5UVIcaL4= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926 h1:G3dpKMzFDjgEh2q1Z7zUUtKa8ViPtH+ocF0bE0g00O8= +github.com/uber-go/atomic v1.4.0 h1:yOuPqEq4ovnhEjpHmfFwsqBXDYbQeT6Nb0bwD6XnD5o= +github.com/uber-go/atomic v1.4.0/go.mod h1:/Ct5t2lcmbJ4OSe/waGBoaVvVqtO0bmtfVNex1PFV8g= +github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= +github.com/valyala/fasthttp v1.6.0 h1:uWF8lgKmeaIewWVPwi4GRq2P6+R46IgYZdxWtM+GtEY= +github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a h1:0R4NLDRDZX6JcmhJgXi5E4b8Wg84ihbmUKp/GvSPEzc= +github.com/vinzenz/yaml v0.0.0-20170920082545-91409cdd725d h1:3wDi6J5APMqaHBVPuVd7RmHD2gRTfqbdcVSpCNoUWtk= +github.com/vinzenz/yaml v0.0.0-20170920082545-91409cdd725d/go.mod h1:mb5taDqMnJiZNRQ3+02W2IFG+oEz1+dTuCXkp4jpkfo= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/weaveworks/common v0.0.0-20230511094633-334485600903 h1:ph7R2CS/0o1gBzpzK/CioUKJVsXNVXfDGR8FZ9rMZIw= +github.com/weaveworks/common v0.0.0-20230511094633-334485600903/go.mod h1:rgbeLfJUtEr+G74cwFPR1k/4N0kDeaeSv/qhUNE4hm8= +github.com/weaveworks/promrus v1.2.0 h1:jOLf6pe6/vss4qGHjXmGz4oDJQA+AOCqEL3FvvZGz7M= +github.com/weaveworks/promrus v1.2.0/go.mod h1:SaE82+OJ91yqjrE1rsvBWVzNZKcHYFtMUyS1+Ogs/KA= +github.com/willf/bitset v1.1.11 h1:N7Z7E9UvjW+sGsEl7k/SJrvY2reP1A07MrGuCjIOjRE= +github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI= +github.com/willf/bloom v2.0.3+incompatible h1:QDacWdqcAUI1MPOwIQZRy9kOR7yxfyEmxX8Wdm2/JPA= +github.com/willf/bloom v2.0.3+incompatible/go.mod h1:MmAltL9pDMNTrvUkxdg0k0q5I0suxmuwp3KbyrZLOZ8= +github.com/xanzy/go-gitlab v0.15.0 h1:rWtwKTgEnXyNUGrOArN7yyc3THRkpYcKXIXia9abywQ= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c h1:u40Z8hqBAAQyv+vATcGgV0YCnDjqSL7/q/JyPhhJSPk= +github.com/xdg/stringprep v1.0.0 h1:d9X0esnoa3dFsV0FG35rAT0RIhYFlPq7MiP+DW89La0= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xhit/go-str2duration v1.2.0 h1:BcV5u025cITWxEQKGWr1URRzrcXtu7uk8+luz3Yuhwc= +github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77 h1:ESFSdwYZvkeru3RtdrYueztKhOBCSAAzS4Gf+k0tEow= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= +github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= +github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b h1:FosyBZYxY34Wul7O/MSKey3txpPYyCqVO5ZyceuQJEI= +github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= +github.com/zenazn/goji v1.0.1 h1:4lbD8Mx2h7IvloP7r2C0D6ltZP6Ufip8Hn0wmSK5LR8= +github.com/zenazn/goji v1.0.1/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b h1:7gd+rd8P3bqcn/96gOZa3F5dpJr/vEiDQYlNb/y2uNs= +go.opentelemetry.io/collector v0.74.0 h1:0s2DKWczGj/pLTsXGb1P+Je7dyuGx9Is4/Dri1+cS7g= +go.opentelemetry.io/collector v0.74.0/go.mod h1:7NjZAvkhQ6E+NLN4EAH2hw3Nssi+F14t7mV7lMNXCto= +go.opentelemetry.io/collector/component v0.74.0 h1:W32ILPgbA5LO+m9Se61hbbtiLM6FYusNM36K5/CCOi0= +go.opentelemetry.io/collector/component v0.74.0/go.mod h1:zHbWqbdmnHeIZAuO3s1Fo/kWPC2oKuolIhlPmL4bzyo= +go.opentelemetry.io/collector/confmap v0.74.0 h1:tl4fSHC/MXZiEvsZhDhd03TgzvArOe69Qn020sZsTfQ= +go.opentelemetry.io/collector/confmap v0.74.0/go.mod h1:NvUhMS2v8rniLvDAnvGjYOt0qBohk6TIibb1NuyVB1Q= +go.opentelemetry.io/collector/consumer v0.74.0 h1:+kjT/ixG+4SVSHg7u9mQe0+LNDc6PuG8Wn2hoL/yGYk= +go.opentelemetry.io/collector/consumer v0.74.0/go.mod h1:MuGqt8/OKVAOjrh5WHr1TR2qwHizy64ZP2uNSr+XpvI= +go.opentelemetry.io/collector/exporter v0.74.0 h1:VZxDuVz9kJM/Yten3xA/abJwLJNkxLThiao6E1ULW7c= +go.opentelemetry.io/collector/exporter v0.74.0/go.mod h1:kw5YoorpKqEpZZ/a5ODSoYFK1mszzcKBNORd32S8Z7c= +go.opentelemetry.io/collector/exporter/otlpexporter v0.74.0 h1:YKvTeYcBrJwbcXNy65fJ/xytUSMurpYn/KkJD0x+DAY= +go.opentelemetry.io/collector/exporter/otlpexporter v0.74.0/go.mod h1:cRbvsnpSxzySoTSnXbOGPQZu9KHlEyKkTeE21f9Q1p4= +go.opentelemetry.io/collector/featuregate v1.0.0 h1:5MGqe2v5zxaoo73BUOvUTunftX5J8RGrbFsC2Ha7N3g= +go.opentelemetry.io/collector/receiver v0.74.0 h1:jlgBFa0iByvn8VuX27UxtqiPiZE8ejmU5lb1nSptWD8= +go.opentelemetry.io/collector/receiver v0.74.0/go.mod h1:SQkyATvoZCJefNkI2jnrR63SOdrmDLYCnQqXJ7ACqn0= +go.opentelemetry.io/collector/receiver/otlpreceiver v0.74.0 h1:e/X/W0z2Jtpy3Yd3CXkmEm9vSpKq/P3pKUrEVMUFBRw= +go.opentelemetry.io/collector/receiver/otlpreceiver v0.74.0/go.mod h1:9X9/RYFxJIaK0JLlRZ0PpmQSSlYpY+r4KsTOj2jWj14= +go.opentelemetry.io/collector/semconv v0.90.1 h1:2fkQZbefQBbIcNb9Rk1mRcWlFZgQOk7CpST1e1BK8eg= +go.opentelemetry.io/contrib v0.18.0 h1:uqBh0brileIvG6luvBjdxzoFL8lxDGuhxJWsvK3BveI= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.42.0/go.mod h1:5z+/ZWJQKXa9YT34fQNx5K8Hd1EoIhvtUygUQPqEOgQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1/go.mod h1:4UoMYEZOC0yN/sPGH76KPkkU7zgiEWYWL9vwmbnTJPE= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.47.0/go.mod h1:r9vWsPS/3AQItv3OSlEJ/E4mbrhUbbw18meOjArPtKQ= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1/go.mod h1:GnOaBaFQ2we3b9AGWJpsBa7v1S5RlQzlC3O7dRMxZhM= +go.opentelemetry.io/contrib/propagators/b3 v1.15.0 h1:bMaonPyFcAvZ4EVzkUNkfnUHP5Zi63CIDlA3dRsEg8Q= +go.opentelemetry.io/contrib/propagators/b3 v1.15.0/go.mod h1:VjU0g2v6HSQ+NwfifambSLAeBgevjIcqmceaKWEzl0c= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.16.0/go.mod h1:StxwPndBVNZD2sZez0RQ0SP/129XGCd4aEmVGaw1/QM= +go.opentelemetry.io/otel/bridge/opencensus v0.37.0 h1:ieH3gw7b1eg90ARsFAlAsX5LKVZgnCYfaDwRrK6xLHU= +go.opentelemetry.io/otel/bridge/opencensus v0.37.0/go.mod h1:ddiK+1PE68l/Xk04BGTh9Y6WIcxcLrmcVxVlS0w5WZ0= +go.opentelemetry.io/otel/bridge/opentracing v1.10.0 h1:WzAVGovpC1s7KD5g4taU6BWYZP3QGSDVTlbRu9fIHw8= +go.opentelemetry.io/otel/bridge/opentracing v1.10.0/go.mod h1:J7GLR/uxxqMAzZptsH0pjte3Ep4GacTCrbGBoDuHBqk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0/go.mod h1:0+KuTDyKL4gjKCF75pHOX4wuzYDUZYfAQdSu43o+Z2I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 h1:digkEZCJWobwBqMwC0cwCq8/wkkRy/OowZg5OArWZrM= +go.opentelemetry.io/otel/exporters/prometheus v0.37.0 h1:NQc0epfL0xItsmGgSXgfbH2C1fq2VLXkZoDFsfRNHpc= +go.opentelemetry.io/otel/exporters/prometheus v0.37.0/go.mod h1:hB8qWjsStK36t50/R0V2ULFb4u95X/Q6zupXLgvjTh8= +go.opentelemetry.io/otel/sdk v1.22.0/go.mod h1:iu7luyVGYovrRpe2fmj3CVKouQNdTOkxtLzPvPz1DOc= +go.opentelemetry.io/otel/sdk/metric v0.39.0 h1:Kun8i1eYf48kHH83RucG93ffz0zGV1sh46FAScOTuDI= +go.opentelemetry.io/otel/sdk/metric v0.39.0/go.mod h1:piDIRgjcK7u0HCL5pCA4e74qpK/jk3NiUoAHATVAmiI= +go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/mock v0.2.0 h1:TaP3xedm7JaAgScZO7tlvlKrqT0p7I6OsdGB5YNSMDU= +go.uber.org/mock v0.2.0/go.mod h1:J0y0rp9L3xiff1+ZBfKxlC1fz2+aO16tw0tsDOixfuM= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= +go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/image v0.0.0-20220302094943-723b81ca9867 h1:TcHcE0vrmgzNH1v3ppjcMGbhG5+9fMuvOmUYwNEF4q4= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= +gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc= +gonum.org/v1/plot v0.10.1 h1:dnifSs43YJuNMDzB7v8wV64O4ABBHReuAVAoBxqBqS4= +google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3/go.mod h1:5RBcpGRxr25RbDzY5w+dmaqpSEvl8Gwl1x2CICf60ic= +google.golang.org/genproto v0.0.0-20231212172506-995d672761c0/go.mod h1:l/k7rMz0vFTBPy+tFSGvXEd3z+BcoG1k7EHbqm+YBsY= +google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:+Rvu7ElI+aLzyDQhpHMFMMltsD6m7nqpuWDd2CwJw3k= +google.golang.org/genproto/googleapis/api v0.0.0-20231211222908-989df2bf70f3/go.mod h1:k2dtGpRrbsSyKcNPKKI5sstZkrNCZwpU/ns96JoHbGg= +google.golang.org/genproto/googleapis/api v0.0.0-20231212172506-995d672761c0/go.mod h1:CAny0tYF+0/9rmDB9fahA9YLzX3+AEVl1qXbv5hhj6c= +google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917/go.mod h1:CmlNWB9lSezaYELKS5Ym1r44VrrbPUa7JTvw+6MbpJ0= +google.golang.org/genproto/googleapis/api v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:B5xPO//w8qmBDjGReYLpR6UJPnkldGkCSMoH/2vxJeg= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20231120223509-83a465c0220f h1:hL+1ptbhFoeL1HcROQ8OGXaqH0jYRRibgWQWco0/Ugc= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20231212172506-995d672761c0 h1:Y6QQt9D/syZt/Qgnz5a1y2O3WunQeeVDfS9+Xr82iFA= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20231212172506-995d672761c0/go.mod h1:guYXGPwC6jwxgWKW5Y405fKWOFNwlvUlUnzyp9i0uqo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231212172506-995d672761c0/go.mod h1:FUoWkonphQm3RhTS+kOEhF8h0iDpm4tdXolVCeZ9KKA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917/go.mod h1:xtjpI3tXFPP051KaWnhvxkiubL/6dJ18vLVf7q2pTOU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:daQN87bsDqDoe316QbbvX60nMoJQa4r6Ds0ZuoAe5yA= +google.golang.org/grpc v1.60.1/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM= +google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= +google.golang.org/grpc v1.61.1/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE= +gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= +gopkg.in/cheggaaa/pb.v1 v1.0.25 h1:Ev7yu1/f6+d+b3pi5vPdRPc6nNtP1umSfcWiEfRqv6I= +gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/gcfg.v1 v1.2.3 h1:m8OOJ4ccYHnx2f4gQwpno8nAX5OGOh7RLaaz0pj3Ogs= +gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= +gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ= +gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec h1:RlWgLqCMMIYYEVcAR5MDsuHlVkaIPDAF+5Dehzg8L5A= +gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU= +gopkg.in/resty.v1 v1.12.0 h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI= +gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= +gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg= +gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= +gopkg.in/telebot.v3 v3.2.1 h1:3I4LohaAyJBiivGmkfB+CiVu7QFOWkuZ4+KHgO/G3rs= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +honnef.co/go/tools v0.1.3 h1:qTakTkI6ni6LFD5sBwwsdSO+AQqbSIxOauHTTQKZ/7o= +k8s.io/api v0.29.0/go.mod h1:sdVmXoz2Bo/cb77Pxi71IPTSErEW32xa4aXwKH7gfBA= +k8s.io/apiextensions-apiserver v0.26.2 h1:/yTG2B9jGY2Q70iGskMf41qTLhL9XeNN2KhI0uDgwko= +k8s.io/apiextensions-apiserver v0.26.2/go.mod h1:Y7UPgch8nph8mGCuVk0SK83LnS8Esf3n6fUBgew8SH8= +k8s.io/apimachinery v0.29.0/go.mod h1:eVBxQ/cwiJxH58eK/jd/vAk4mrxmVlnpBH5J2GbMeis= +k8s.io/apiserver v0.29.0/go.mod h1:31n78PsRKPmfpee7/l9NYEv67u6hOL6AfcE761HapDM= +k8s.io/client-go v0.29.0/go.mod h1:yLkXH4HKMAywcrD82KMSmfYg2DlE8mepPR4JGSo5n38= +k8s.io/component-base v0.29.0/go.mod h1:sADonFTQ9Zc9yFLghpDpmNXEdHyQmFIGbiuZbqAXQ1M= +k8s.io/gengo v0.0.0-20230829151522-9cce18d56c01 h1:pWEwq4Asjm4vjW7vcsmijwBhOr1/shsbSYiWXmNGlks= +k8s.io/gengo v0.0.0-20230829151522-9cce18d56c01/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= +k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= +k8s.io/kms v0.29.0/go.mod h1:mB0f9HLxRXeXUfHfn1A7rpwOlzXI1gIWu86z6buNoYA= +k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= +k8s.io/kube-openapi v0.0.0-20231214164306-ab13479f8bf8/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= +nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= +rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE= +rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= +rsc.io/quote/v3 v3.1.0 h1:9JKUTTIUgS6kzR9mK1YuGKv6Nl+DijDNIc0ghT58FaY= +rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4= +sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0 h1:ucqkfpjg9WzSUubAO62csmucvxl4/JeW3F4I4909XkM= diff --git a/hack/README.md b/hack/README.md index 226636e032e12..5a8819a9db09b 100644 --- a/hack/README.md +++ b/hack/README.md @@ -14,12 +14,16 @@ total 0 lrwxr-xr-x 1 ryan staff 37 Oct 5 09:34 grafana -> /Users/ryan/workspace/grafana/grafana ``` -The current workflow (sorry!) is to: +The current workflow is to run the following: -1. update the script to point to the group+version you want -2. run the `update-codegen.sh` script. This will produce a bunch of new files -3. move `pkg/generated/openapi/zz_generated.openapi.go` to `pkg/apis/{group/version}/zz_generated.openapi.go`. -4. edit the package name so it is {version} and remove the boilerplate k8s kinds -5. `rm -rf pkg/generated` -- we are not yet using most of the generated client stuff +```shell +# ensure k8s.io/code-generator pkg is up to date +go mod download -Once we are more comfortable with the outputs and process, we will build these steps into a more standard codegen pattern, but until then... happy hacking! +# the happy path +./hack/update-codegen.sh + + +Note that the script deletes existing openapi go code and regenerates in place so that you will temporarily see +deleted files in your `git status`. After a successful run, you should see them restored. +``` diff --git a/hack/externalTools.go b/hack/externalTools.go new file mode 100644 index 0000000000000..d4f5221e890d0 --- /dev/null +++ b/hack/externalTools.go @@ -0,0 +1,7 @@ +package hack + +import ( + "k8s.io/code-generator/pkg/util" +) + +var _ = util.CurrentPackage diff --git a/hack/make-aggregator-pki.sh b/hack/make-aggregator-pki.sh new file mode 100755 index 0000000000000..0eac9f2ae19ba --- /dev/null +++ b/hack/make-aggregator-pki.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail + +rm -rf data/grafana-aggregator + +mkdir -p data/grafana-aggregator + +openssl req -nodes -new -x509 -keyout data/grafana-aggregator/ca.key -out data/grafana-aggregator/ca.crt +openssl req -out data/grafana-aggregator/client.csr -new -newkey rsa:4096 -nodes -keyout data/grafana-aggregator/client.key \ + -subj "/CN=development/O=system:masters" \ + -addext "extendedKeyUsage = clientAuth" +openssl x509 -req -days 365 -in data/grafana-aggregator/client.csr -CA data/grafana-aggregator/ca.crt -CAkey data/grafana-aggregator/ca.key \ + -set_serial 01 \ + -sha256 -out data/grafana-aggregator/client.crt \ + -copy_extensions=copyall + +openssl req -out data/grafana-aggregator/server.csr -new -newkey rsa:4096 -nodes -keyout data/grafana-aggregator/server.key \ + -subj "/CN=localhost/O=aggregated" \ + -addext "subjectAltName = DNS:v0alpha1.example.grafana.app.default.svc,DNS:localhost" \ + -addext "extendedKeyUsage = serverAuth, clientAuth" +openssl x509 -req -days 365 -in data/grafana-aggregator/server.csr -CA data/grafana-aggregator/ca.crt -CAkey data/grafana-aggregator/ca.key \ + -set_serial 02 \ + -sha256 -out data/grafana-aggregator/server.crt \ + -copy_extensions=copyall diff --git a/hack/openapi-codegen.sh b/hack/openapi-codegen.sh new file mode 100644 index 0000000000000..070966375dcf1 --- /dev/null +++ b/hack/openapi-codegen.sh @@ -0,0 +1,174 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Provenance-includes-location: https://github.com/kubernetes/code-generator/blob/master/kube_codegen.sh +# Provenance-includes-license: Apache-2.0 +# Provenance-includes-copyright: The Kubernetes Authors. + +## NOTE: The following is a fork of the original gen_openapi helper in k8s.io/code-generator +## It allows us to generate separate openapi packages per api group. + +# Generate openapi code +# +# Args: +# +# --input-pkg-single <string> +# The root directory of a single grafana API Group. +# +# --output-base <string> +# The root directory under which to emit code. The concatenation of +# <output-base> + <input-pkg-single> must be valid. +# +# --report-filename <string = "/dev/null"> +# The filename of the API violations report in the input pkg directory. +# +# --update-report +# If specified, update the report file in place, rather than diffing it. +# +# --boilerplate <string = path_to_kube_codegen_boilerplate> +# An optional override for the header file to insert into generated files. + +set -o errexit +set -o nounset +set -o pipefail + +KUBE_CODEGEN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" + +source "${CODEGEN_PKG}/kube_codegen.sh" +# +function grafana::codegen::gen_openapi() { + local in_pkg_single="" + local out_base="" + local report="/dev/null" + local update_report="" + local include_common_input_dirs="" + local boilerplate="${KUBE_CODEGEN_ROOT}/hack/boilerplate.go.txt" + local v="${KUBE_VERBOSE:-0}" + + while [ "$#" -gt 0 ]; do + case "$1" in + "--input-pkg-single") + in_pkg_single="$2" + shift 2 + ;; + "--include-common-input-dirs") + if [ "$2" == "true" ]; then + COMMON_INPUT_DIRS='--input-dirs "k8s.io/apimachinery/pkg/apis/meta/v1" --input-dirs "k8s.io/apimachinery/pkg/runtime" --input-dirs "k8s.io/apimachinery/pkg/version"' + else + COMMON_INPUT_DIRS="" + fi + shift 2 + ;; + "--output-base") + out_base="$2" + shift 2 + ;; + "--report-filename") + report="$2" + shift 2 + ;; + "--update-report") + update_report="true" + shift + ;; + "--boilerplate") + boilerplate="$2" + shift 2 + ;; + *) + echo "unknown argument: $1" >&2 + return 1 + ;; + esac + done + + if [ -z "${in_pkg_single}" ]; then + echo "--input-pkg-single is required" >&2 + return 1 + fi + + if [ -z "${report}" ]; then + echo "--report-filename is required" >&2 + return 1 + fi + + if [ -z "${out_base}" ]; then + echo "--output-base is required" >&2 + return 1 + fi + + ( + # To support running this from anywhere, first cd into this directory, + # and then install with forced module mode on and fully qualified name. + cd "${KUBE_CODEGEN_ROOT}" + BINS=( + openapi-gen + ) + # shellcheck disable=2046 # printf word-splitting is intentional + GO111MODULE=on go install $(printf "k8s.io/code-generator/cmd/%s " "${BINS[@]}") + ) + # Go installs in $GOBIN if defined, and $GOPATH/bin otherwise + gobin="${GOBIN:-$(go env GOPATH)/bin}" + + # These tools all assume out-dir == in-dir. + root="${out_base}/${in_pkg_single}" + mkdir -p "${root}" + root="$(cd "${root}" && pwd -P)" + + local input_pkgs=() + while read -r dir; do + pkg="$(cd "${dir}" && GO111MODULE=on go list -find .)" + input_pkgs+=("${pkg}") + done < <( + ( kube::codegen::internal::git_grep -l --null \ + -e '+k8s:openapi-gen=' \ + ":(glob)${root}"/'**/*.go' \ + || true \ + ) | while read -r -d $'\0' F; do dirname "${F}"; done \ + | LC_ALL=C sort -u + ) + + + local new_report="" + if [ "${#input_pkgs[@]}" != 0 ]; then + echo "Generating openapi code for ${#input_pkgs[@]} targets" + + kube::codegen::internal::git_find -z \ + ":(glob)${root}"/'**/zz_generated.openapi.go' \ + | xargs -0 rm -f + + local inputs=() + for arg in "${input_pkgs[@]}"; do + inputs+=("--input-dirs" "$arg") + done + + new_report="${root}/${report}.tmp" + if [ -n "${update_report}" ]; then + new_report="${root}/${report}" + fi + + "${gobin}/openapi-gen" \ + -v "${v}" \ + -O zz_generated.openapi \ + --go-header-file "${boilerplate}" \ + --output-base "${out_base}" \ + --output-package "${in_pkg_single}" \ + --report-filename "${new_report}" \ + ${COMMON_INPUT_DIRS} \ + "${inputs[@]}" + fi + + touch "${root}/${report}" # in case it doesn't exist yet + if [[ -z "${new_report}" ]]; then + return 0 + fi + if ! diff -u "${root}/${report}" "${new_report}"; then + echo -e "ERROR:" + echo -e "\tAPI rule check failed for ${root}/${report}: new reported violations" + echo -e "\tPlease read api/api-rules/README.md" + return 1 + fi + + # if all goes well, remove the temporary reports + if [ -z "${update_report}" ]; then + rm -f "${new_report}" + fi +} diff --git a/hack/update-codegen.sh b/hack/update-codegen.sh index eeef438ab32ad..bb6e20502cbb0 100755 --- a/hack/update-codegen.sh +++ b/hack/update-codegen.sh @@ -1,45 +1,87 @@ #!/usr/bin/env bash -# Copyright 2017 The Kubernetes Authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# SPDX-License-Identifier: AGPL-3.0-only +# Provenance-includes-location: https://github.com/kubernetes/sample-apiserver/blob/master/hack/update-codegen.sh +# Provenance-includes-license: Apache-2.0 +# Provenance-includes-copyright: The Kubernetes Authors. set -o errexit set -o nounset set -o pipefail SCRIPT_ROOT=$(dirname "${BASH_SOURCE[0]}")/.. -CODEGEN_PKG=${CODEGEN_PKG:-$(cd "${SCRIPT_ROOT}"; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo $GOPATH/pkg/mod/k8s.io/code-generator@v0.27.1)} +CODEGEN_PKG=${CODEGEN_PKG:-$(cd "${SCRIPT_ROOT}"; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo $(go env GOPATH)/pkg/mod/k8s.io/code-generator@v0.29.1)} OUTDIR="${HOME}/go/src" +OPENAPI_VIOLATION_EXCEPTIONS_FILENAME="zz_generated.openapi_violation_exceptions.list" -echo $OUTDIR - -CLIENTSET_NAME_VERSIONED=clientset \ -CLIENTSET_PKG_NAME=clientset \ -"${CODEGEN_PKG}/generate-groups.sh" "all" \ - github.com/grafana/grafana/pkg/generated \ - github.com/grafana/grafana/pkg/apis \ - "folders:v0alpha1" \ - --output-base "${OUTDIR}" \ - --go-header-file "${SCRIPT_ROOT}/hack/boilerplate.go.txt" - -CLIENTSET_NAME_VERSIONED=clientset \ -CLIENTSET_PKG_NAME=clientset \ -"${CODEGEN_PKG}/generate-internal-groups.sh" "deepcopy,defaulter,conversion,openapi" \ - github.com/grafana/grafana/pkg/generated \ - github.com/grafana/grafana/pkg/apis \ - github.com/grafana/grafana/pkg/apis \ - "folders:v0alpha1" \ - --output-base "${OUTDIR}" \ - --go-header-file "${SCRIPT_ROOT}/hack/boilerplate.go.txt" +source "${CODEGEN_PKG}/kube_codegen.sh" +source "$(dirname "${BASH_SOURCE[0]}")/openapi-codegen.sh" + +selected_pkg="${1-}" + +grafana::codegen:run() { + local generate_root=$1 + local skipped="true" + for api_pkg in $(grafana:codegen:lsdirs ./${generate_root}/apis); do + if [[ "${selected_pkg}" != "" && ${api_pkg} != $selected_pkg ]]; then + continue + fi + echo "Generating code for ${generate_root}/apis/${api_pkg}..." + echo "=============================================" + skipped="false" + include_common_input_dirs=$([[ ${api_pkg} == "common" ]] && echo "true" || echo "false") + + kube::codegen::gen_helpers \ + --input-pkg-root github.com/grafana/grafana/${generate_root}/apis/${api_pkg} \ + --output-base "${OUTDIR}" \ + --boilerplate "${SCRIPT_ROOT}/hack/boilerplate.go.txt" + + for pkg_version in $(grafana:codegen:lsdirs ./${generate_root}/apis/${api_pkg}); do + grafana::codegen::gen_openapi \ + --input-pkg-single github.com/grafana/grafana/${generate_root}/apis/${api_pkg}/${pkg_version} \ + --output-base "${OUTDIR}" \ + --report-filename "${OPENAPI_VIOLATION_EXCEPTIONS_FILENAME}" \ + --update-report \ + --boilerplate "${SCRIPT_ROOT}/hack/boilerplate.go.txt" \ + --include-common-input-dirs ${include_common_input_dirs} + + violations_file="${OUTDIR}/github.com/grafana/grafana/${generate_root}/apis/${api_pkg}/${pkg_version}/${OPENAPI_VIOLATION_EXCEPTIONS_FILENAME}" + # delete violation exceptions file, if empty + if ! grep -q . "${violations_file}"; then + echo "Deleting ${violations_file} since it is empty" + rm ${violations_file} + fi + + echo "" + done + done + + if [[ "${skipped}" == "true" ]]; then + echo "no apis matching ${selected_pkg}. skipping..." + echo + return 0 + fi + + echo "Generating client code..." + echo "-------------------------" + + kube::codegen::gen_client \ + --with-watch \ + --with-applyconfig \ + --input-pkg-root github.com/grafana/grafana/${generate_root}/apis \ + --output-pkg-root github.com/grafana/grafana/${generate_root}/generated \ + --output-base "${OUTDIR}" \ + --boilerplate "${SCRIPT_ROOT}/hack/boilerplate.go.txt" + + echo "" +} + +grafana:codegen:lsdirs() { + ls -d $1/*/ | xargs basename -a +} + +grafana::codegen:run pkg +grafana::codegen:run pkg/apimachinery + +echo "done." diff --git a/jest.config.js b/jest.config.js index 979c33eec3dff..cc797aa32ad8a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,6 +4,7 @@ process.env.TZ = 'Pacific/Easter'; // UTC-06:00 or UTC-05:00 depending on daylight savings const esModules = [ + '@glideapps/glide-data-grid', 'ol', 'd3', 'd3-color', @@ -12,6 +13,11 @@ const esModules = [ 'internmap', 'robust-predicates', 'leven', + 'nanoid', + 'monaco-promql', + '@kusto/monaco-kusto', + 'monaco-editor', + 'lodash-es', ].join('|'); module.exports = { @@ -23,8 +29,8 @@ module.exports = { transformIgnorePatterns: [ `/node_modules/(?!${esModules})`, // exclude es modules to prevent TS complaining ], - moduleDirectories: ['public'], - roots: ['<rootDir>/public/app', '<rootDir>/public/test', '<rootDir>/packages'], + moduleDirectories: ['public', 'node_modules'], + roots: ['<rootDir>/public/app', '<rootDir>/public/test', '<rootDir>/packages', '<rootDir>/scripts'], testRegex: '(\\.|/)(test)\\.(jsx?|tsx?)$', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], setupFiles: ['jest-canvas-mock', './public/test/jest-setup.ts'], @@ -38,7 +44,9 @@ module.exports = { '\\.svg': '<rootDir>/public/test/mocks/svg.ts', '\\.css': '<rootDir>/public/test/mocks/style.ts', 'react-inlinesvg': '<rootDir>/public/test/mocks/react-inlinesvg.tsx', - 'monaco-editor/esm/vs/editor/editor.api': '<rootDir>/public/test/mocks/monaco.ts', + // resolve directly as monaco and kusto don't have main property in package.json which jest needs + '^monaco-editor$': 'monaco-editor/esm/vs/editor/editor.api.js', + '@kusto/monaco-kusto': '@kusto/monaco-kusto/release/esm/monaco.contribution.js', // near-membrane-dom won't work in a nodejs environment. '@locker/near-membrane-dom': '<rootDir>/public/test/mocks/nearMembraneDom.ts', '^@grafana/schema/dist/esm/(.*)$': '<rootDir>/packages/grafana-schema/src/$1', diff --git a/kinds/dashboard/dashboard_kind.cue b/kinds/dashboard/dashboard_kind.cue index 85e3a45040bdb..b366e2c96ff14 100644 --- a/kinds/dashboard/dashboard_kind.cue +++ b/kinds/dashboard/dashboard_kind.cue @@ -70,7 +70,7 @@ lineage: schemas: [{ weekStart?: string // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". - refresh?: string | false + refresh?: string // Version of the JSON schema, incremented each time a Grafana update brings // changes to said schema. @@ -199,9 +199,17 @@ lineage: schemas: [{ multi?: bool | *false // Options that can be selected for a variable. options?: [...#VariableOption] + // Options to config when to refresh a variable refresh?: #VariableRefresh // Options sort order sort?: #VariableSort + // Whether all value option is available or not + includeAll?: bool | *false + // Custom all value + allValue?: string + // Optional field, if you want to extract part of a series name or metric node segment. + // Named capture groups can be used to separate the display text and value. + regex?: string ... } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) @@ -283,7 +291,7 @@ lineage: schemas: [{ // `textbox`: Display a free text input field with an optional default value. // `custom`: Define the variable options manually using a comma-separated list. // `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables - #VariableType: "query" | "adhoc" | "constant" | "datasource" | "interval" | "textbox" | "custom" | "system" @cuetsy(kind="type") @grafanamaturity(NeedsExpertReview) + #VariableType: "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" | "system" @cuetsy(kind="type") @grafanamaturity(NeedsExpertReview) // Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value. // Continuous color interpolates a color using the percentage of a value relative to min and max. @@ -438,6 +446,8 @@ lineage: schemas: [{ disabled?: bool // Optional frame matcher. When missing it will be applied to all results filter?: #MatcherConfig + // Where to pull DataFrames from as input to transformation + topic?: "series" | "annotations" | "alertStates" // replaced with common.DataTopic // Options to be passed to the transformer // Valid options depend on the transformer id options: _ @@ -447,11 +457,13 @@ lineage: schemas: [{ // It defines the default config for the time picker and the refresh picker for the specific dashboard. #TimePickerConfig: { // Whether timepicker is visible or not. - hidden: bool | *false + hidden?: bool | *false // Interval options available in the refresh picker dropdown. - refresh_intervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + refresh_intervals?: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. - time_options: [...string] | *["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + time_options?: [...string] | *["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. + nowDelay?: string } @cuetsy(kind="interface") @grafana(TSVeneer="type") // 0 for no shared crosshair or tooltip (default). @@ -482,6 +494,8 @@ lineage: schemas: [{ external: bool @grafanamaturity(NeedsExpertReview) // external url, if snapshot was shared in external grafana instance externalUrl: string @grafanamaturity(NeedsExpertReview) + // original url, url of the dashboard that was snapshotted + originalUrl: string @grafanamaturity(NeedsExpertReview) // Unique identifier of the snapshot id: uint32 @grafanamaturity(NeedsExpertReview) // Optional, defined the unique key of the snapshot, required if external is true @@ -509,9 +523,6 @@ lineage: schemas: [{ // The version of the plugin that is used for this panel. This is used to find the plugin to display the panel and to migrate old panel configs. pluginVersion?: string - // Tags for the panel. - tags?: [...string] - // Depends on the panel plugin. See the plugin documentation for details. targets?: [...#Target] @@ -580,6 +591,12 @@ lineage: schemas: [{ // Dynamically load the panel libraryPanel?: #LibraryPanelRef + // Sets panel queries cache timeout. + cacheTimeout?: string + + // Overrides the data source configured time-to-live for a query cache item in milliseconds + queryCachingTTL?: number + // It depends on the panel plugin. They are specified by the Options field in panel plugin schemas. options?: {...} @grafanamaturity(NeedsExpertReview) diff --git a/kinds/gen.go b/kinds/gen.go index 00225af2b3140..32b7be8eba6e7 100644 --- a/kinds/gen.go +++ b/kinds/gen.go @@ -38,16 +38,9 @@ func main() { // All the jennies that comprise the core kinds generator pipeline coreKindsGen.Append( - &codegen.ResourceGoTypesJenny{}, - &codegen.SubresourceGoTypesJenny{}, - codegen.CoreKindJenny(cuectx.GoCoreKindParentPath, nil), - codegen.BaseCoreRegistryJenny(filepath.Join("pkg", "registry", "corekind"), cuectx.GoCoreKindParentPath), - codegen.LatestMajorsOrXJenny( - cuectx.TSCoreKindParentPath, - true, // forcing group so that we ignore the top level resource (for now) - codegen.TSResourceJenny{}), + &codegen.GoSpecJenny{}, + codegen.LatestMajorsOrXJenny(cuectx.TSCoreKindParentPath), codegen.TSVeneerIndexJenny(filepath.Join("packages", "grafana-schema", "src")), - codegen.DocsJenny(filepath.Join("docs", "sources", "developers", "kinds", "core")), ) header := codegen.SlashHeaderMapper("kinds/gen.go") @@ -96,6 +89,16 @@ func main() { die(err) } + // Merging k8 resources + rawResources, err := genRawResources(kinddirs) + if err != nil { + die(err) + } + + if err = jfs.Merge(rawResources); err != nil { + die(err) + } + if _, set := os.LookupEnv("CODEGEN_VERIFY"); set { if err = jfs.Verify(context.Background(), groot); err != nil { die(fmt.Errorf("generated code is out of sync with inputs:\n%s\nrun `make gen-cue` to regenerate", err)) @@ -182,3 +185,53 @@ func die(err error) { fmt.Fprint(os.Stderr, err, "\n") os.Exit(1) } + +// Resource generation without using Thema +func genRawResources(dirs []os.DirEntry) (*codejen.FS, error) { + jenny := codejen.JennyListWithNamer[[]codegen.CueSchema](func(_ []codegen.CueSchema) string { + return "RawResources" + }) + + jenny.Append( + &codegen.K8ResourcesJenny{}, + &codegen.CoreRegistryJenny{}, + ) + + header := codegen.SlashHeaderMapper("kinds/gen.go") + jenny.AddPostprocessors(header) + + return jenny.GenerateFS(loadCueFiles(dirs)) +} + +func loadCueFiles(dirs []os.DirEntry) []codegen.CueSchema { + ctx := cuectx.GrafanaCUEContext() + values := make([]codegen.CueSchema, 0) + for _, dir := range dirs { + if !dir.IsDir() { + continue + } + + entries, err := os.ReadDir(dir.Name()) + if err != nil { + fmt.Fprintf(os.Stderr, "error opening %s directory: %s", dir, err) + os.Exit(1) + } + + // It's assuming that we only have one file in each folder + entry := filepath.Join(dir.Name(), entries[0].Name()) + cueFile, err := os.ReadFile(entry) + if err != nil { + fmt.Fprintf(os.Stderr, "unable to open %s/%s file: %s", dir, entries[0].Name(), err) + os.Exit(1) + } + + sch := codegen.CueSchema{ + FilePath: "./" + filepath.Join(cuectx.CoreDefParentPath, entry), + CueFile: ctx.CompileBytes(cueFile), + } + + values = append(values, sch) + } + + return values +} diff --git a/latest.json b/latest.json index 6d1632424b57c..8186768eabb82 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,5 @@ { + "__message": "This file is now deprecated, and will be removed in a future release. No further updates should be made to this file", "stable": "10.2.3", "testing": "10.2.3" } diff --git a/lefthook.yml b/lefthook.yml index 1eabdd84da5d0..9c3bed5f53846 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -12,7 +12,8 @@ pre-commit: commands: frontend-betterer: glob: '*.{ts,tsx}' - run: yarn betterer precommit {staged_files} + run: | + yarn betterer precommit {staged_files} stage_fixed: true frontend-lint: diff --git a/lerna.json b/lerna.json index 636d8d0046a1e..48d6b6b8c448a 100644 --- a/lerna.json +++ b/lerna.json @@ -1,4 +1,5 @@ { + "$schema": "node_modules/lerna/schemas/lerna-schema.json", "npmClient": "yarn", - "version": "10.3.0-pre" + "version": "11.0.0-pre" } diff --git a/nx.json b/nx.json new file mode 100644 index 0000000000000..6e521df58c4aa --- /dev/null +++ b/nx.json @@ -0,0 +1,18 @@ +{ + "tasksRunnerOptions": { + "default": { + "runner": "nx/tasks-runners/default", + "options": { + "cacheableOperations": ["build"] + } + } + }, + "targetDefaults": { + "build": { + "outputs": ["{projectRoot}/dist"] + } + }, + "affected": { + "defaultBase": "main" + } +} diff --git a/package.json b/package.json index 3401f9e56763e..90add7b5a5f36 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,12 @@ "license": "AGPL-3.0-only", "private": true, "name": "grafana", - "version": "10.3.0-pre", + "version": "11.0.0-pre", "repository": "github:grafana/grafana", "scripts": { - "prebuild": "yarn i18n:compile && yarn plugin:build", - "build": "yarn prebuild & NODE_ENV=production webpack --progress --config scripts/webpack/webpack.prod.js", - "build:nominify": "yarn run build --env noMinify=1", - "dev": "yarn prebuild & NODE_ENV=dev webpack --progress --color --config scripts/webpack/webpack.dev.js", + "build": "NODE_ENV=production nx exec --verbose -- webpack --config scripts/webpack/webpack.prod.js", + "build:nominify": "yarn run build -- --env noMinify=1", + "dev": "NODE_ENV=dev nx exec -- webpack --config scripts/webpack/webpack.dev.js", "e2e": "./e2e/start-and-run-suite", "e2e:debug": "./e2e/start-and-run-suite debug", "e2e:dev": "./e2e/start-and-run-suite dev", @@ -17,6 +16,9 @@ "e2e:enterprise": "./e2e/start-and-run-suite enterprise", "e2e:enterprise:dev": "./e2e/start-and-run-suite enterprise dev", "e2e:enterprise:debug": "./e2e/start-and-run-suite enterprise debug", + "e2e:playwright": "yarn playwright test", + "e2e:playwright:ui": "yarn playwright test --ui", + "e2e:playwright:report": "yarn playwright show-report", "test": "jest --notify --watch", "test:coverage": "jest --coverage", "test:coverage:changes": "jest --coverage --changedSince=origin/main", @@ -24,23 +26,23 @@ "lint": "yarn run lint:ts && yarn run lint:sass", "lint:ts": "eslint . --ext .js,.tsx,.ts --cache", "lint:sass": "yarn stylelint '{public/sass,packages}/**/*.scss' --cache", - "test:ci": "yarn i18n:compile && mkdir -p reports/junit && JEST_JUNIT_OUTPUT_DIR=reports/junit jest --ci --reporters=default --reporters=jest-junit -w ${TEST_MAX_WORKERS:-100%}", + "test:ci": "mkdir -p reports/junit && JEST_JUNIT_OUTPUT_DIR=reports/junit jest --ci --reporters=default --reporters=jest-junit -w ${TEST_MAX_WORKERS:-100%}", "lint:fix": "yarn lint:ts --fix", - "packages:build": "lerna run build --ignore '@grafana-plugins/*'", + "packages:build": "nx run-many -t build --projects='@grafana/*'", "packages:clean": "rimraf ./npm-artifacts && lerna run clean --parallel", "packages:prepare": "lerna version --no-push --no-git-tag-version --force-publish --exact", "packages:pack": "mkdir -p ./npm-artifacts && lerna exec --no-private -- yarn pack --out \"../../npm-artifacts/%s-%v.tgz\"", - "packages:typecheck": "lerna run typecheck", - "prettier:check": "prettier --check --list-different=false --log-level=warn \"**/*.{ts,tsx,scss,md,mdx}\"", - "prettier:checkDocs": "prettier --check --list-different=false --log-level=warn \"docs/**/*.md\" \"*.md\" \"packages/**/*.{ts,tsx,scss,md,mdx}\"", - "prettier:write": "prettier --list-different \"**/*.{js,ts,tsx,scss,md,mdx}\" --write", - "start": "yarn themes:generate && yarn dev --watch", - "start:noTsCheck": "yarn start --env noTsCheck=1", - "start:noLint": "yarn start --env noTsCheck=1 --env noLint=1", + "packages:typecheck": "nx run-many -t typecheck --projects='@grafana/*'", + "prettier:check": "prettier --check --list-different=false --log-level=warn \"**/*.{ts,tsx,scss,md,mdx,json}\"", + "prettier:checkDocs": "prettier --check --list-different=false --log-level=warn \"docs/**/*.md\" \"*.md\" \"packages/**/*.{ts,tsx,scss,md,mdx,json}\"", + "prettier:write": "prettier --list-different \"**/*.{js,ts,tsx,scss,md,mdx,json}\" --write", + "start": "NODE_ENV=dev nx exec -- webpack --config scripts/webpack/webpack.dev.js --watch", + "start:noTsCheck": "yarn start -- --env noTsCheck=1", + "start:noLint": "yarn start -- --env noTsCheck=1 --env noLint=1", "stats": "webpack --mode production --config scripts/webpack/webpack.prod.js --profile --json > compilation-stats.json", "storybook": "yarn workspace @grafana/ui storybook --ci", "storybook:build": "yarn workspace @grafana/ui storybook:build", - "themes:generate": "esbuild --target=es6 ./scripts/cli/generateSassVariableFiles.ts --bundle --platform=node --tsconfig=./scripts/cli/tsconfig.json | node", + "themes-generate": "esbuild --target=es6 ./scripts/cli/generateSassVariableFiles.ts --bundle --platform=node --tsconfig=./scripts/cli/tsconfig.json | node", "themes:usage": "eslint . --ext .tsx,.ts --ignore-pattern '*.test.ts*' --ignore-pattern '*.spec.ts*' --cache --rule '{ @grafana/theme-token-usage: \"error\" }'", "typecheck": "tsc --noEmit && yarn run packages:typecheck", "plugins:build-bundled": "find plugins-bundled -name package.json -not -path '*/node_modules/*' -execdir yarn build \\;", @@ -48,274 +50,262 @@ "ci:test-frontend": "yarn run test:ci", "i18n:clean": "rimraf public/locales/en-US/grafana.json", "i18n:extract": "yarn run i18next -c public/locales/i18next-parser.config.js 'public/**/*.{tsx,ts}' 'packages/grafana-ui/**/*.{tsx,ts}' && yarn i18n:pseudo", - "i18n:compile": "echo 'no i18n compile yet, all good'", "i18n:pseudo": "node ./public/locales/pseudo.js", "i18n:stats": "node ./scripts/cli/reportI18nStats.mjs", "betterer": "betterer", + "betterer:json": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/bettererResultsToJson.ts", "betterer:merge": "betterer merge", "betterer:stats": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/reportBettererStats.ts", "betterer:issues": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/generateBettererIssues.ts", "generate-icons-bundle-cache-file": "node ./scripts/generate-icon-bundle.js", - "plugin:build": "lerna run build --ignore=\"@grafana/*\" --ignore=\"@grafana-plugins/input-datasource\"", - "plugin:build:commit": "lerna run build:commit --ignore=\"@grafana/*\" --ignore=\"@grafana-plugins/input-datasource\"", - "plugin:build:dev": "lerna run dev --ignore=\"@grafana/*\" --ignore=\"@grafana-plugins/input-datasource\"" + "plugin:build": "nx run-many -t build --projects='@grafana-plugins/*' --exclude \"@grafana-plugins/input-datasource\"", + "plugin:build:commit": "nx run-many -t build:commit --projects='@grafana-plugins/*' --exclude \"@grafana-plugins/input-datasource\"", + "plugin:build:dev": "nx run-many -t dev --projects='@grafana-plugins/*' --exclude \"@grafana-plugins/input-datasource\"" }, "grafana": { - "whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v10-3/", + "whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v11-0/", "releaseNotesUrl": "https://grafana.com/docs/grafana/next/release-notes/" }, "devDependencies": { - "@babel/core": "7.23.2", - "@babel/plugin-proposal-class-properties": "7.18.6", - "@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6", - "@babel/plugin-proposal-object-rest-spread": "7.20.7", - "@babel/plugin-proposal-optional-chaining": "7.21.0", - "@babel/plugin-syntax-dynamic-import": "7.8.3", - "@babel/plugin-transform-react-constant-elements": "7.22.5", - "@babel/plugin-transform-runtime": "7.23.2", - "@babel/plugin-transform-typescript": "7.22.9", - "@babel/preset-env": "7.23.2", - "@babel/preset-react": "7.22.5", - "@babel/preset-typescript": "7.23.2", - "@babel/runtime": "7.23.2", + "@babel/runtime": "7.24.0", "@betterer/betterer": "5.4.0", "@betterer/cli": "5.4.0", "@betterer/eslint": "5.4.0", - "@cypress/webpack-preprocessor": "6.0.0", + "@cypress/webpack-preprocessor": "6.0.1", "@emotion/eslint-plugin": "11.11.0", - "@grafana/eslint-config": "6.0.1", + "@grafana/eslint-config": "7.0.0", "@grafana/eslint-plugin": "link:./packages/grafana-eslint-rules", + "@grafana/plugin-e2e": "^0.21.0", "@grafana/tsconfig": "^1.3.0-rc1", - "@pmmmwh/react-refresh-webpack-plugin": "0.5.10", - "@react-types/button": "3.9.0", - "@react-types/menu": "3.9.2", - "@react-types/overlays": "3.8.0", - "@react-types/shared": "3.21.0", + "@manypkg/get-packages": "^2.2.0", + "@playwright/test": "1.42.1", + "@pmmmwh/react-refresh-webpack-plugin": "0.5.11", + "@react-types/button": "3.9.2", + "@react-types/menu": "3.9.7", + "@react-types/overlays": "3.8.5", + "@react-types/shared": "3.22.1", "@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1", - "@swc/core": "1.3.38", - "@swc/helpers": "0.4.14", - "@testing-library/dom": "9.3.3", - "@testing-library/jest-dom": "6.1.4", - "@testing-library/react": "14.0.0", - "@testing-library/user-event": "14.5.1", - "@types/angular": "1.8.5", - "@types/angular-route": "1.7.3", + "@swc/core": "1.4.2", + "@swc/helpers": "0.5.6", + "@testing-library/dom": "9.3.4", + "@testing-library/jest-dom": "6.4.2", + "@testing-library/react": "14.2.1", + "@testing-library/user-event": "14.5.2", + "@types/angular": "1.8.9", + "@types/angular-route": "1.7.6", "@types/chance": "^1.1.3", "@types/common-tags": "^1.8.0", - "@types/d3": "7.4.0", + "@types/d3": "7.4.3", "@types/d3-force": "^3.0.0", - "@types/d3-scale-chromatic": "3.0.0", - "@types/debounce-promise": "3.1.6", + "@types/d3-scale-chromatic": "3.0.3", + "@types/debounce-promise": "3.1.9", "@types/diff": "^5", - "@types/eslint": "8.44.0", - "@types/file-saver": "2.0.5", + "@types/eslint": "8.56.5", + "@types/eslint-scope": "^3.7.7", + "@types/file-saver": "2.0.7", "@types/glob": "^8.0.0", - "@types/google.analytics": "^0.0.42", - "@types/gtag.js": "^0.0.12", + "@types/google.analytics": "^0.0.46", + "@types/gtag.js": "^0.0.19", "@types/history": "4.7.11", - "@types/hoist-non-react-statics": "3.3.1", - "@types/jest": "29.5.4", - "@types/jquery": "3.5.16", + "@types/hoist-non-react-statics": "3.3.5", + "@types/jest": "29.5.12", + "@types/jquery": "3.5.29", "@types/js-yaml": "^4.0.5", "@types/jsurl": "^1.2.28", - "@types/lodash": "4.14.195", + "@types/lodash": "4.17.0", "@types/logfmt": "^1.2.3", "@types/lucene": "^2", - "@types/marked": "5.0.1", - "@types/mousetrap": "1.6.11", - "@types/node": "20.8.10", + "@types/marked": "5.0.2", + "@types/mousetrap": "1.6.15", + "@types/node": "20.11.28", "@types/node-forge": "^1", - "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.2.0", - "@types/papaparse": "5.3.7", - "@types/pluralize": "^0.0.30", - "@types/prismjs": "1.26.0", - "@types/react": "18.2.15", - "@types/react-beautiful-dnd": "13.1.4", - "@types/react-dom": "18.2.7", - "@types/react-grid-layout": "1.3.2", - "@types/react-highlight-words": "0.16.4", + "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.2.4", + "@types/papaparse": "5.3.14", + "@types/pluralize": "^0.0.33", + "@types/prismjs": "1.26.3", + "@types/react": "18.2.66", + "@types/react-beautiful-dnd": "13.1.8", + "@types/react-dom": "18.2.22", + "@types/react-grid-layout": "1.3.5", + "@types/react-highlight-words": "0.16.7", + "@types/react-router": "5.1.20", "@types/react-router-dom": "5.3.3", - "@types/react-table": "7.7.14", - "@types/react-test-renderer": "18.0.0", - "@types/react-transition-group": "4.4.6", - "@types/react-virtualized-auto-sizer": "1.0.1", - "@types/react-window": "1.8.5", + "@types/react-table": "7.7.19", + "@types/react-test-renderer": "18.0.7", + "@types/react-transition-group": "4.4.10", + "@types/react-virtualized-auto-sizer": "1.0.4", + "@types/react-window": "1.8.8", "@types/react-window-infinite-loader": "^1", - "@types/redux-mock-store": "1.0.3", - "@types/semver": "7.5.0", + "@types/redux-mock-store": "1.0.6", + "@types/semver": "7.5.8", "@types/slate": "0.47.11", - "@types/slate-plain-serializer": "0.7.2", + "@types/slate-plain-serializer": "0.7.5", "@types/slate-react": "0.22.9", - "@types/systemjs": "6.13.1", - "@types/testing-library__jest-dom": "5.14.8", - "@types/tinycolor2": "1.4.3", - "@types/uuid": "9.0.2", + "@types/systemjs": "6.13.5", + "@types/testing-library__jest-dom": "5.14.9", + "@types/tinycolor2": "1.4.6", + "@types/uuid": "9.0.8", "@types/webpack-assets-manifest": "^5", - "@types/yargs": "17.0.24", - "@typescript-eslint/eslint-plugin": "5.42.0", - "@typescript-eslint/parser": "5.42.0", - "autoprefixer": "10.4.14", - "babel-jest": "29.7.0", - "babel-loader": "9.1.3", - "babel-plugin-angularjs-annotate": "0.10.0", - "babel-plugin-macros": "3.1.0", + "@types/yargs": "17.0.32", + "@typescript-eslint/eslint-plugin": "6.21.0", + "@typescript-eslint/parser": "6.21.0", + "autoprefixer": "10.4.18", "blob-polyfill": "7.0.20220408", "browserslist": "^4.21.4", "chance": "^1.0.10", "chrome-remote-interface": "0.33.0", "codeowners": "^5.1.1", - "copy-webpack-plugin": "11.0.0", - "core-js": "3.33.0", - "css-loader": "6.8.1", - "css-minimizer-webpack-plugin": "5.0.1", + "copy-webpack-plugin": "12.0.2", + "core-js": "3.36.0", + "css-loader": "6.10.0", + "css-minimizer-webpack-plugin": "6.0.0", "cypress": "13.1.0", "cypress-file-upload": "5.0.8", - "esbuild": "0.18.12", - "esbuild-loader": "3.0.1", - "esbuild-plugin-browserslist": "^0.8.0", - "eslint": "8.52.0", - "eslint-config-prettier": "8.8.0", + "esbuild": "0.20.1", + "esbuild-loader": "4.1.0", + "esbuild-plugin-browserslist": "^0.11.0", + "eslint": "8.57.0", + "eslint-config-prettier": "9.1.0", "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jest": "27.6.0", - "eslint-plugin-jsdoc": "46.8.2", - "eslint-plugin-jsx-a11y": "6.7.1", + "eslint-plugin-jest": "27.9.0", + "eslint-plugin-jsdoc": "48.2.1", + "eslint-plugin-jsx-a11y": "6.8.0", "eslint-plugin-lodash": "7.4.0", - "eslint-plugin-react": "7.33.2", + "eslint-plugin-react": "7.34.0", "eslint-plugin-react-hooks": "4.6.0", - "eslint-webpack-plugin": "4.0.1", - "expose-loader": "4.1.0", - "fork-ts-checker-webpack-plugin": "8.0.0", - "glob": "10.3.3", - "html-loader": "4.2.0", - "html-webpack-plugin": "5.5.3", + "eslint-scope": "^8.0.0", + "eslint-webpack-plugin": "4.1.0", + "expose-loader": "5.0.0", + "fork-ts-checker-webpack-plugin": "9.0.2", + "glob": "10.3.10", + "html-loader": "5.0.0", + "html-webpack-plugin": "5.6.0", "http-server": "14.1.1", - "i18next-parser": "6.6.0", + "i18next-parser": "8.13.0", "jest": "29.7.0", "jest-canvas-mock": "2.5.2", "jest-date-mock": "1.0.8", "jest-environment-jsdom": "29.7.0", - "jest-fail-on-console": "3.1.1", + "jest-fail-on-console": "3.1.2", "jest-junit": "16.0.0", "jest-matcher-utils": "29.7.0", - "lerna": "7.4.1", - "mini-css-extract-plugin": "2.7.6", - "msw": "1.3.2", + "lerna": "8.1.2", + "mini-css-extract-plugin": "2.8.1", + "msw": "2.2.3", "mutationobserver-shim": "0.3.7", "ngtemplate-loader": "2.1.0", "node-notifier": "10.0.1", - "postcss": "8.4.31", - "postcss-loader": "7.3.3", - "postcss-reporter": "7.0.5", - "postcss-scss": "4.0.6", - "prettier": "3.0.0", + "nx": "18.0.8", + "postcss": "8.4.35", + "postcss-loader": "8.1.1", + "postcss-reporter": "7.1.0", + "postcss-scss": "4.0.9", + "prettier": "3.2.5", "react-refresh": "0.14.0", "react-select-event": "5.5.1", "react-simple-compat": "1.2.3", "react-test-renderer": "18.2.0", "redux-mock-store": "1.5.4", - "rimraf": "5.0.1", - "rudder-sdk-js": "2.43.0", - "sass": "1.69.4", - "sass-loader": "13.3.2", - "style-loader": "3.3.3", - "stylelint": "15.11.0", - "stylelint-config-prettier": "9.0.5", - "stylelint-config-sass-guidelines": "10.0.0", - "terser-webpack-plugin": "5.3.9", + "rimraf": "5.0.5", + "rudder-sdk-js": "2.48.3", + "sass": "1.70.0", + "sass-loader": "14.1.1", + "style-loader": "3.3.4", + "stylelint": "16.2.1", + "stylelint-config-sass-guidelines": "11.0.0", + "terser-webpack-plugin": "5.3.10", "testing-library-selector": "0.3.1", "tracelib": "1.0.1", - "ts-jest": "29.1.1", - "ts-node": "10.9.1", - "typescript": "5.2.2", - "webpack": "5.89.0", - "webpack-bundle-analyzer": "4.9.0", + "ts-jest": "29.1.2", + "ts-node": "10.9.2", + "typescript": "5.3.3", + "webpack": "5.90.3", + "webpack-assets-manifest": "^5.1.0", + "webpack-bundle-analyzer": "4.10.1", "webpack-cli": "5.1.4", - "webpack-dev-server": "4.15.1", + "webpack-dev-server": "5.0.3", "webpack-manifest-plugin": "5.0.0", "webpack-merge": "5.10.0", + "webpackbar": "^6.0.0", "yaml": "^2.0.0", "yargs": "^17.5.1" }, "dependencies": { "@daybrush/utils": "1.13.0", "@emotion/css": "11.11.2", - "@emotion/react": "11.11.1", + "@emotion/react": "11.11.4", "@fingerprintjs/fingerprintjs": "^3.4.2", - "@glideapps/glide-data-grid": "^5.2.1", - "@grafana-plugins/grafana-testdata-datasource": "workspace:*", - "@grafana-plugins/parca": "workspace:*", - "@grafana/aws-sdk": "0.3.1", + "@floating-ui/react": "0.26.9", + "@glideapps/glide-data-grid": "^6.0.0", + "@grafana/aws-sdk": "0.3.2", "@grafana/data": "workspace:*", "@grafana/e2e-selectors": "workspace:*", - "@grafana/experimental": "1.7.4", - "@grafana/faro-core": "^1.3.5", - "@grafana/faro-web-sdk": "^1.3.5", + "@grafana/experimental": "1.7.10", + "@grafana/faro-core": "^1.3.6", + "@grafana/faro-web-sdk": "^1.3.6", "@grafana/flamegraph": "workspace:*", - "@grafana/google-sdk": "0.1.1", - "@grafana/lezer-logql": "0.2.2", - "@grafana/lezer-traceql": "0.0.12", + "@grafana/google-sdk": "0.1.2", + "@grafana/lezer-logql": "0.2.3", "@grafana/monaco-logql": "^0.0.7", + "@grafana/o11y-ds-frontend": "workspace:*", + "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "1.28.5", + "@grafana/scenes": "3.13.3", "@grafana/schema": "workspace:*", + "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", "@kusto/monaco-kusto": "^7.4.0", - "@leeoniya/ufuzzy": "1.0.13", - "@lezer/common": "1.0.2", - "@lezer/highlight": "1.1.3", + "@leeoniya/ufuzzy": "1.0.14", + "@lezer/common": "1.2.1", + "@lezer/highlight": "1.2.0", "@lezer/lr": "1.3.3", - "@locker/near-membrane-dom": "0.13.3", - "@locker/near-membrane-shared": "0.13.3", - "@locker/near-membrane-shared-dom": "0.13.3", - "@opentelemetry/api": "1.6.0", + "@locker/near-membrane-dom": "0.13.6", + "@locker/near-membrane-shared": "0.13.6", + "@locker/near-membrane-shared-dom": "0.13.6", + "@msagl/core": "^1.1.16", + "@msagl/parser": "^1.1.16", + "@opentelemetry/api": "1.7.0", "@opentelemetry/exporter-collector": "0.25.0", - "@opentelemetry/semantic-conventions": "1.17.1", + "@opentelemetry/semantic-conventions": "1.21.0", "@popperjs/core": "2.11.8", "@prometheus-io/lezer-promql": "^0.37.0-rc.1", - "@react-aria/button": "3.8.0", - "@react-aria/dialog": "3.5.3", - "@react-aria/focus": "3.13.0", - "@react-aria/interactions": "3.16.0", - "@react-aria/menu": "3.10.0", - "@react-aria/overlays": "3.15.0", - "@react-aria/utils": "3.18.0", - "@react-awesome-query-builder/core": "6.4.1", - "@react-awesome-query-builder/ui": "6.4.1", - "@react-stately/collections": "3.9.0", - "@react-stately/menu": "3.5.3", - "@react-stately/tree": "3.7.0", + "@react-aria/dialog": "3.5.12", + "@react-aria/focus": "3.16.2", + "@react-aria/overlays": "3.21.1", + "@react-aria/utils": "3.23.2", + "@react-awesome-query-builder/core": "6.4.2", + "@react-awesome-query-builder/ui": "6.4.2", "@reduxjs/toolkit": "1.9.5", "@remix-run/router": "^1.5.0", "@testing-library/react-hooks": "^8.0.1", - "@types/react-resizable": "3.0.4", - "@types/trusted-types": "2.0.3", - "@types/webpack-env": "1.18.1", + "@types/react-resizable": "3.0.7", + "@types/trusted-types": "2.0.7", + "@types/webpack-env": "1.18.4", "@visx/event": "3.3.0", "@visx/gradient": "3.3.0", "@visx/group": "3.3.0", - "@visx/scale": "3.3.0", - "@visx/shape": "3.3.0", + "@visx/scale": "3.5.0", + "@visx/shape": "3.5.0", "@visx/tooltip": "3.3.0", - "@welldone-software/why-did-you-render": "7.0.1", + "@welldone-software/why-did-you-render": "8.0.1", "angular": "1.8.3", "angular-bindonce": "0.3.1", "angular-route": "1.8.3", "angular-sanitize": "1.8.3", "ansicolor": "1.1.100", - "app": "link:./public/app", "baron": "3.0.3", "brace": "0.11.1", "calculate-size": "1.1.1", - "centrifuge": "4.0.1", - "classnames": "2.3.2", + "centrifuge": "5.0.2", + "classnames": "2.5.1", "combokeys": "^3.0.0", "comlink": "4.4.1", "common-tags": "1.8.2", - "d3": "7.8.5", + "d3": "7.9.0", "d3-force": "3.0.0", - "d3-scale-chromatic": "3.0.0", - "dangerously-set-html-content": "1.0.9", - "date-fns": "2.30.0", + "d3-scale-chromatic": "3.1.0", + "dangerously-set-html-content": "1.1.0", + "date-fns": "3.5.0", "debounce-promise": "3.1.2", "diff": "^5.1.0", "emotion": "11.0.0", @@ -326,102 +316,97 @@ "framework-utils": "^1.1.0", "history": "4.10.1", "hoist-non-react-statics": "3.3.2", - "i18next": "^22.0.0", + "i18next": "^23.0.0", "i18next-browser-languagedetector": "^7.0.2", - "immer": "10.0.2", - "immutable": "4.3.1", - "jquery": "3.7.0", + "immer": "10.0.4", + "immutable": "4.3.5", + "jquery": "3.7.1", "js-yaml": "^4.1.0", "json-markup": "^1.1.0", "json-source-map": "0.6.1", "jsurl": "^0.1.5", - "kbar": "0.1.0-beta.44", + "kbar": "0.1.0-beta.45", "leven": "^4.0.0", "lodash": "4.17.21", "logfmt": "^1.3.2", - "lru-cache": "10.0.0", + "lru-cache": "10.2.0", "lru-memoize": "^1.1.0", "lucene": "^2.1.1", - "marked": "5.1.1", - "marked-mangle": "1.1.0", + "marked": "12.0.1", + "marked-mangle": "1.1.7", "memoize-one": "6.0.0", "ml-regression-polynomial": "^3.0.0", "ml-regression-simple-linear": "^3.0.0", - "moment": "2.29.4", - "moment-timezone": "0.5.43", + "moment": "2.30.1", + "moment-timezone": "0.5.45", "monaco-editor": "0.34.0", "monaco-promql": "1.7.4", "mousetrap": "1.6.5", "mousetrap-global-bind": "1.1.0", - "moveable": "0.43.1", + "moveable": "0.53.0", + "nanoid": "^5.0.4", "node-forge": "^1.3.1", "ol": "7.4.0", - "ol-ext": "4.0.10", + "ol-ext": "4.0.17", "papaparse": "5.4.1", "pluralize": "^8.0.0", "prismjs": "1.29.0", "prop-types": "15.8.1", "pseudoizer": "^0.1.0", - "rc-cascader": "3.20.0", - "rc-drawer": "6.5.2", - "rc-slider": "10.3.1", + "rc-cascader": "3.24.0", + "rc-drawer": "7.1.0", + "rc-slider": "10.5.0", "rc-time-picker": "3.7.3", - "rc-tree": "5.8.0", - "re-resizable": "6.9.9", + "rc-tree": "5.8.5", + "re-resizable": "6.9.11", "react": "18.2.0", "react-beautiful-dnd": "13.1.1", "react-diff-viewer": "^3.1.1", "react-dom": "18.2.0", - "react-draggable": "4.4.5", + "react-draggable": "4.4.6", "react-dropzone": "^14.2.3", - "react-grid-layout": "1.4.2", + "react-grid-layout": "1.4.4", "react-highlight-words": "0.20.0", - "react-hook-form": "7.5.3", + "react-hook-form": "^7.49.2", "react-i18next": "^12.0.0", "react-inlinesvg": "3.0.2", - "react-loading-skeleton": "3.3.1", - "react-moveable": "0.46.1", - "react-popper": "2.3.0", - "react-popper-tooltip": "4.4.2", + "react-loading-skeleton": "3.4.0", + "react-moveable": "0.56.0", "react-redux": "8.1.3", "react-resizable": "3.0.5", "react-responsive-carousel": "^3.2.23", + "react-router": "5.3.3", "react-router-dom": "5.3.3", "react-router-dom-v5-compat": "^6.10.0", - "react-select": "5.7.4", + "react-select": "5.8.0", "react-split-pane": "0.1.92", "react-table": "7.8.0", "react-transition-group": "4.4.5", - "react-use": "17.4.0", + "react-use": "17.5.0", "react-virtual": "2.10.4", - "react-virtualized-auto-sizer": "1.0.7", - "react-window": "1.8.9", + "react-virtualized-auto-sizer": "1.0.24", + "react-window": "1.8.10", "react-window-infinite-loader": "1.0.9", "react-zoom-pan-pinch": "^3.3.0", "redux": "4.2.1", "redux-thunk": "2.4.2", - "regenerator-runtime": "0.14.0", + "regenerator-runtime": "0.14.1", "reselect": "4.1.8", "rxjs": "7.8.1", - "sass": "link:./public/sass", - "selecto": "1.26.0", - "semver": "7.5.4", + "selecto": "1.26.3", + "semver": "7.6.0", "slate": "0.47.9", "slate-plain-serializer": "0.7.13", "slate-react": "0.22.10", - "sql-formatter-plus": "^1.3.6", "symbol-observable": "4.0.0", - "test": "link:./public/test", "tether-drop": "https://github.com/torkelo/drop", "tinycolor2": "1.6.0", - "tslib": "2.6.0", + "tslib": "2.6.2", "tween-functions": "^1.2.0", - "uplot": "1.6.28", - "uuid": "9.0.0", - "vendor": "link:./public/vendor", + "uplot": "1.6.30", + "uuid": "9.0.1", "visjs-network": "4.25.0", - "webpack-assets-manifest": "^5.1.0", - "whatwg-fetch": "3.6.2", + "whatwg-fetch": "3.6.20", "xlsx": "https://cdn.sheetjs.com/xlsx-0.19.1/xlsx-0.19.1.tgz" }, "resolutions": { @@ -430,6 +415,8 @@ "ngtemplate-loader/loader-utils": "^2.0.0", "semver@~7.0.0": "7.5.4", "semver@7.3.4": "7.5.4", + "debug@npm:^0.7.2": "2.6.9", + "debug@npm:^0.7.4": "2.6.9", "slate-dev-environment@^0.2.2": "patch:slate-dev-environment@npm:0.2.5#.yarn/patches/slate-dev-environment-npm-0.2.5-9aeb7da7b5.patch", "react-split-pane@0.1.92": "patch:react-split-pane@npm:0.1.92#.yarn/patches/react-split-pane-npm-0.1.92-93dbf51dff.patch", "@storybook/blocks@7.4.5": "patch:@storybook/blocks@npm%3A7.4.5#./.yarn/patches/@storybook-blocks-npm-7.4.5-5a2374564a.patch", @@ -446,9 +433,9 @@ "engines": { "node": ">= 20" }, - "packageManager": "yarn@4.0.0", + "packageManager": "yarn@4.1.0", "dependenciesMeta": { - "prettier@3.0.0": { + "prettier@3.2.5": { "unplugged": true } } diff --git a/packages/README.md b/packages/README.md index d77c6e0c5dc55..b90745433ec6e 100644 --- a/packages/README.md +++ b/packages/README.md @@ -36,7 +36,7 @@ Every commit to main that has changes within the `packages` directory is a subje > All of the steps below must be performed on a release branch, according to Grafana Release Guide. -> You must be logged in to NPM as part of Grafana NPM org before attempting to publish to the npm registery. +> You must be logged in to NPM as part of Grafana NPM org before attempting to publish to the npm registry. 1. Run `yarn packages:clean` script from the root directory. This will delete any previous builds of the packages. 2. Run `yarn packages:prepare` script from the root directory. This performs tests on the packages and prompts for the version of the packages. The version should be the same as the one being released. diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index b17a82b18c579..388f309f9c253 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/data", - "version": "10.3.0-pre", + "version": "11.0.0-pre", "description": "Grafana Data Library", "keywords": [ "typescript" @@ -35,66 +35,55 @@ "postpack": "mv package.json.bak package.json" }, "dependencies": { - "@braintree/sanitize-url": "6.0.2", - "@grafana/schema": "10.3.0-pre", + "@braintree/sanitize-url": "7.0.0", + "@grafana/schema": "11.0.0-pre", "@types/d3-interpolate": "^3.0.0", - "@types/string-hash": "1.1.1", + "@types/string-hash": "1.1.3", "d3-interpolate": "3.0.1", - "date-fns": "2.30.0", - "dompurify": "^2.4.3", + "date-fns": "3.5.0", + "dompurify": "^3.0.0", "eventemitter3": "5.0.1", "fast_array_intersect": "1.1.0", "history": "4.10.1", "lodash": "4.17.21", - "marked": "5.1.1", - "marked-mangle": "1.1.0", - "moment": "2.29.4", - "moment-timezone": "0.5.43", + "marked": "12.0.1", + "marked-mangle": "1.1.7", + "moment": "2.30.1", + "moment-timezone": "0.5.45", "ol": "7.4.0", "papaparse": "5.4.1", - "react-use": "17.4.0", - "regenerator-runtime": "0.14.0", + "react-use": "17.5.0", "rxjs": "7.8.1", "string-hash": "^1.1.3", "tinycolor2": "1.6.0", - "tslib": "2.6.0", - "uplot": "1.6.28", + "tslib": "2.6.2", + "uplot": "1.6.30", "xss": "^1.0.14" }, "devDependencies": { - "@grafana/tsconfig": "^1.2.0-rc1", - "@rollup/plugin-commonjs": "25.0.2", - "@rollup/plugin-json": "6.0.0", + "@grafana/tsconfig": "^1.3.0-rc1", "@rollup/plugin-node-resolve": "15.2.3", - "@testing-library/dom": "9.3.3", - "@testing-library/jest-dom": "6.1.4", - "@testing-library/react": "14.0.0", - "@testing-library/user-event": "14.5.1", - "@types/dompurify": "^2", + "@types/dompurify": "^3.0.0", "@types/history": "4.7.11", - "@types/jest": "29.5.4", - "@types/jquery": "3.5.16", - "@types/lodash": "4.14.195", - "@types/marked": "5.0.1", - "@types/node": "20.8.10", - "@types/papaparse": "5.3.7", - "@types/react": "18.2.15", - "@types/react-dom": "18.2.7", - "@types/testing-library__jest-dom": "5.14.8", - "@types/tinycolor2": "1.4.3", + "@types/lodash": "4.17.0", + "@types/marked": "5.0.2", + "@types/node": "20.11.28", + "@types/papaparse": "5.3.14", + "@types/react": "18.2.66", + "@types/react-dom": "18.2.22", + "@types/tinycolor2": "1.4.6", "esbuild": "0.18.12", "react": "18.2.0", "react-dom": "18.2.0", - "react-test-renderer": "18.2.0", - "rimraf": "5.0.1", + "rimraf": "5.0.5", "rollup": "2.79.1", "rollup-plugin-dts": "^5.0.0", "rollup-plugin-esbuild": "5.0.0", "rollup-plugin-node-externals": "^5.0.0", - "typescript": "5.2.2" + "typescript": "5.3.3" }, "peerDependencies": { - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "react": "^18.0.0", + "react-dom": "^18.0.0" } } diff --git a/packages/grafana-data/src/context/plugins/usePluginContext.tsx b/packages/grafana-data/src/context/plugins/usePluginContext.tsx index e2ea105662916..51723446b81fd 100644 --- a/packages/grafana-data/src/context/plugins/usePluginContext.tsx +++ b/packages/grafana-data/src/context/plugins/usePluginContext.tsx @@ -1,7 +1,5 @@ import { useContext } from 'react'; -import { PluginMeta } from '../../types'; - import { Context, PluginContextType } from './PluginContext'; export function usePluginContext(): PluginContextType { @@ -11,21 +9,3 @@ export function usePluginContext(): PluginContextType { } return context; } - -export function usePluginMeta(): PluginMeta { - const context = usePluginContext(); - - return context.meta; -} - -export function usePluginJsonData() { - const context = usePluginContext(); - - return context.meta.jsonData; -} - -export function usePluginVersion() { - const context = usePluginContext(); - - return context.meta.info.version; -} diff --git a/packages/grafana-data/src/dataframe/ArrayDataFrame.test.ts b/packages/grafana-data/src/dataframe/ArrayDataFrame.test.ts index 75222ede2d0b0..624829ba778d3 100644 --- a/packages/grafana-data/src/dataframe/ArrayDataFrame.test.ts +++ b/packages/grafana-data/src/dataframe/ArrayDataFrame.test.ts @@ -101,4 +101,44 @@ describe('Array DataFrame', () => { } `); }); + + test('Handles first null value', () => { + const f = arrayToDataFrame([null, { id: 'abc' }]); + expect(f).toMatchInlineSnapshot(` + { + "fields": [ + { + "config": {}, + "name": "id", + "type": "string", + "values": [ + null, + "abc", + ], + }, + ], + "length": 2, + } + `); + }); + + test('Handles first undefined value', () => { + const f = arrayToDataFrame([undefined, { id: 'abc' }]); + expect(f).toMatchInlineSnapshot(` + { + "fields": [ + { + "config": {}, + "name": "id", + "type": "string", + "values": [ + undefined, + "abc", + ], + }, + ], + "length": 2, + } + `); + }); }); diff --git a/packages/grafana-data/src/dataframe/ArrayDataFrame.ts b/packages/grafana-data/src/dataframe/ArrayDataFrame.ts index aa06d36c02a0b..06092b59da939 100644 --- a/packages/grafana-data/src/dataframe/ArrayDataFrame.ts +++ b/packages/grafana-data/src/dataframe/ArrayDataFrame.ts @@ -21,11 +21,13 @@ export class ArrayDataFrame<T = any> implements DataFrame { } /** - * arrayToDataFrame will convert any array into a DataFrame + * arrayToDataFrame will convert any array into a DataFrame. + * @param source - can be an array of objects or an array of simple values. + * @param names - will be used for ordering of fields. Source needs to be array of objects if names are provided. * * @public */ -export function arrayToDataFrame(source: any[], names?: string[]): DataFrame { +export function arrayToDataFrame(source: Array<Record<string, unknown>> | unknown[], names?: string[]): DataFrame { const df: DataFrame = { fields: [], length: source.length, @@ -34,30 +36,46 @@ export function arrayToDataFrame(source: any[], names?: string[]): DataFrame { return df; } + // If names are provided then we assume the source is an array of objects with the names as keys (field names). This + // makes ordering of the fields predictable. if (names) { + if (!isObjectArray(source)) { + throw new Error('source is not an array of objects'); + } + for (const name of names) { df.fields.push( makeFieldFromValues( name, - source.map((v) => v[name]) + source.map((v) => (v ? v[name] : v)) ) ); } return df; } - const first = source.find((v) => v != null); // first not null|undefined - if (first != null) { - if (typeof first === 'object') { - df.fields = Object.keys(first).map((name) => { - return makeFieldFromValues( - name, - source.map((v) => v[name]) - ); - }); - } else { - df.fields.push(makeFieldFromValues(TIME_SERIES_VALUE_FIELD_NAME, source)); - } + const firstDefined = source.find((v) => v); // first not null|undefined + // This means if the source is lots of null/undefined values we throw that away and return empty dataFrame. This is + // different to how we preserve null/undefined values if there is some defined rows. Not sure this inconsistency + // is by design or not. + if (firstDefined === null) { + return df; + } + + // If is an array of objects we use the keys as field names. + if (isObjectArray(source)) { + // We need to do this to please TS. We know source is array of objects and that there is some object in there but + // TS still thinks it can all be undefined|nulls. + const first = source.find((v) => v); + df.fields = Object.keys(first || {}).map((name) => { + return makeFieldFromValues( + name, + source.map((v) => (v ? v[name] : v)) + ); + }); + } else { + // Otherwise source should be an array of simple values, so we create single field data frame. + df.fields.push(makeFieldFromValues(TIME_SERIES_VALUE_FIELD_NAME, source)); } return df; } @@ -67,3 +85,8 @@ function makeFieldFromValues(name: string, values: unknown[]): Field { f.type = guessFieldTypeForField(f) ?? FieldType.other; return f; } + +function isObjectArray(arr: unknown[]): arr is Array<Record<string, unknown> | null | undefined> { + const first = arr.find((v) => v); // first not null|undefined + return arr.length > 0 && typeof first === 'object'; +} diff --git a/packages/grafana-data/src/dataframe/MutableDataFrame.ts b/packages/grafana-data/src/dataframe/MutableDataFrame.ts index 84a02a33679f4..4437aa44b5e5a 100644 --- a/packages/grafana-data/src/dataframe/MutableDataFrame.ts +++ b/packages/grafana-data/src/dataframe/MutableDataFrame.ts @@ -11,7 +11,7 @@ import { guessFieldTypeFromValue, guessFieldTypeForField, toDataFrameDTO } from export type MutableField<T = any> = Field<T>; /** @deprecated */ -type MutableVectorCreator = (buffer?: any[]) => any[]; +type MutableVectorCreator = (buffer?: unknown[]) => unknown[]; export const MISSING_VALUE = undefined; // Treated as connected in new graph panel @@ -243,7 +243,7 @@ export class MutableDataFrame<T = any> extends FunctionalVector<T> implements Da throw new Error('Unable to set value beyond current length'); } - const obj = (value as any) || {}; + const obj = (value as Record<string, unknown>) || {}; for (const field of this.fields) { field.values[index] = obj[field.name]; } @@ -253,7 +253,7 @@ export class MutableDataFrame<T = any> extends FunctionalVector<T> implements Da * Get an object with a property for each field in the DataFrame */ get(idx: number): T { - const v: any = {}; + const v: Record<string, unknown> = {}; for (const field of this.fields) { v[field.name] = field.values[idx]; } diff --git a/packages/grafana-data/src/dataframe/index.ts b/packages/grafana-data/src/dataframe/index.ts index 5c505a16670a8..9f35f56765d72 100644 --- a/packages/grafana-data/src/dataframe/index.ts +++ b/packages/grafana-data/src/dataframe/index.ts @@ -13,5 +13,7 @@ export { isTimeSeriesFrame, isTimeSeriesFrames, isTimeSeriesField, + getRowUniqueId, + addRow, } from './utils'; export { StreamingDataFrame, StreamingFrameAction, type StreamingFrameOptions, closestIdx } from './StreamingDataFrame'; diff --git a/packages/grafana-data/src/dataframe/utils.test.ts b/packages/grafana-data/src/dataframe/utils.test.ts index b291b732fd4ac..fcac25607df11 100644 --- a/packages/grafana-data/src/dataframe/utils.test.ts +++ b/packages/grafana-data/src/dataframe/utils.test.ts @@ -1,7 +1,7 @@ import { FieldType } from '../types'; -import { toDataFrame } from './processDataFrame'; -import { anySeriesWithTimeField } from './utils'; +import { createDataFrame, toDataFrame } from './processDataFrame'; +import { anySeriesWithTimeField, addRow } from './utils'; describe('anySeriesWithTimeField', () => { describe('single frame', () => { @@ -77,3 +77,30 @@ describe('anySeriesWithTimeField', () => { }); }); }); + +describe('addRow', () => { + const frame = createDataFrame({ + fields: [ + { name: 'name', type: FieldType.string }, + { name: 'date', type: FieldType.time }, + { name: 'number', type: FieldType.number }, + ], + }); + const date = Date.now(); + + it('adds row to data frame as object', () => { + addRow(frame, { name: 'A', date, number: 1 }); + expect(frame.fields[0].values[0]).toBe('A'); + expect(frame.fields[1].values[0]).toBe(date); + expect(frame.fields[2].values[0]).toBe(1); + expect(frame.length).toBe(1); + }); + + it('adds row to data frame as array', () => { + addRow(frame, ['B', date, 42]); + expect(frame.fields[0].values[1]).toBe('B'); + expect(frame.fields[1].values[1]).toBe(date); + expect(frame.fields[2].values[1]).toBe(42); + expect(frame.length).toBe(2); + }); +}); diff --git a/packages/grafana-data/src/dataframe/utils.ts b/packages/grafana-data/src/dataframe/utils.ts index ea9dbd3432723..8f45ddd1b92fe 100644 --- a/packages/grafana-data/src/dataframe/utils.ts +++ b/packages/grafana-data/src/dataframe/utils.ts @@ -86,3 +86,40 @@ export function anySeriesWithTimeField(data: DataFrame[]) { export function hasTimeField(data: DataFrame): boolean { return data.fields.some((field) => field.type === FieldType.time); } + +/** + * Get row id based on the meta.uniqueRowIdFields attribute. + * @param dataFrame + * @param rowIndex + */ +export function getRowUniqueId(dataFrame: DataFrame, rowIndex: number) { + if (dataFrame.meta?.uniqueRowIdFields === undefined) { + return undefined; + } + return dataFrame.meta.uniqueRowIdFields.map((fieldIndex) => dataFrame.fields[fieldIndex].values[rowIndex]).join('-'); +} + +/** + * Simple helper to add values to a data frame. Doesn't do any validation so make sure you are adding the right types + * of values. + * @param dataFrame + * @param row Either an array of values or an object with keys that match the field names. + */ +export function addRow(dataFrame: DataFrame, row: Record<string, unknown> | unknown[]) { + if (row instanceof Array) { + for (let i = 0; i < row.length; i++) { + dataFrame.fields[i].values.push(row[i]); + } + } else { + for (const field of dataFrame.fields) { + field.values.push(row[field.name]); + } + } + try { + dataFrame.length++; + } catch (e) { + // Unfortunate but even though DataFrame as interface defines length some implementation of DataFrame only have + // length getter. In that case it will throw and so we just skip and assume they defined a `getter` for length that + // does not need any external updating. + } +} diff --git a/packages/grafana-data/src/datetime/durationutil.ts b/packages/grafana-data/src/datetime/durationutil.ts index c490554a2bc38..c8049a641bbb5 100644 --- a/packages/grafana-data/src/datetime/durationutil.ts +++ b/packages/grafana-data/src/datetime/durationutil.ts @@ -1,6 +1,4 @@ -import { Duration, Interval, isAfter } from 'date-fns'; -import add from 'date-fns/add'; -import intervalToDuration from 'date-fns/intervalToDuration'; +import { add, Duration, intervalToDuration, Interval, isAfter } from 'date-fns'; const durationMap: { [key in Required<keyof Duration>]: string[] } = { years: ['y', 'Y', 'years'], diff --git a/packages/grafana-data/src/events/types.ts b/packages/grafana-data/src/events/types.ts index 5ef531a46f9ec..f5e111eb2fc9e 100644 --- a/packages/grafana-data/src/events/types.ts +++ b/packages/grafana-data/src/events/types.ts @@ -19,10 +19,22 @@ export abstract class BusEventBase implements BusEvent { readonly payload?: any; readonly origin?: EventBus; + /** @internal */ + tags?: Set<string>; + constructor() { //@ts-ignore this.type = this.__proto__.constructor.type; } + + /** + * @internal + * Tag event for finer-grained filtering in subscribers + */ + setTags(tags: string[]) { + this.tags = new Set(tags); + return this; + } } /** diff --git a/packages/grafana-data/src/field/displayProcessor.ts b/packages/grafana-data/src/field/displayProcessor.ts index 97bdd23a99fe5..9cdef2760a69b 100644 --- a/packages/grafana-data/src/field/displayProcessor.ts +++ b/packages/grafana-data/src/field/displayProcessor.ts @@ -80,7 +80,7 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP const canTrimTrailingDecimalZeros = !hasDateUnit && !hasCurrencyUnit && !hasBoolUnit && !isLocaleFormat && isNumType && config.decimals == null; - const formatFunc = getValueFormat(unit || 'none', config.unitScale); + const formatFunc = getValueFormat(unit || 'none'); const scaleFunc = getScaleCalculator(field, options.theme); return (value: unknown, adjacentDecimals?: DecimalCount) => { diff --git a/packages/grafana-data/src/field/fieldColor.ts b/packages/grafana-data/src/field/fieldColor.ts index 289177150defd..29bbc1f801107 100644 --- a/packages/grafana-data/src/field/fieldColor.ts +++ b/packages/grafana-data/src/field/fieldColor.ts @@ -10,7 +10,7 @@ import { RegistryItem } from '../utils'; import { Registry } from '../utils/Registry'; import { getScaleCalculator, ColorScaleValue } from './scale'; -import { fallBackTreshold } from './thresholds'; +import { fallBackThreshold } from './thresholds'; /** @beta */ export type FieldValueColorCalculator = (value: number, percent: number, Threshold?: Threshold) => string; @@ -46,7 +46,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => { description: 'Derive colors from thresholds', getCalculator: (_field, theme) => { return (_value, _percent, threshold) => { - const thresholdSafe = threshold ?? fallBackTreshold; + const thresholdSafe = threshold ?? fallBackThreshold; return theme.visualization.getColorByName(thresholdSafe.color); }; }, @@ -251,7 +251,7 @@ export function getFieldSeriesColor(field: Field, theme: GrafanaTheme2): ColorSc if (!mode.isByValue) { return { color: mode.getCalculator(field, theme)(0, 0), - threshold: fallBackTreshold, + threshold: fallBackThreshold, percent: 1, }; } diff --git a/packages/grafana-data/src/field/fieldComparers.ts b/packages/grafana-data/src/field/fieldComparers.ts index 972b435cd3540..3f525b0640585 100644 --- a/packages/grafana-data/src/field/fieldComparers.ts +++ b/packages/grafana-data/src/field/fieldComparers.ts @@ -5,7 +5,6 @@ import { Field, FieldType } from '../types/dataFrame'; type IndexComparer = (a: number, b: number) => number; -/** @public */ export const fieldIndexComparer = (field: Field, reverse = false): IndexComparer => { const values = field.values; @@ -26,8 +25,7 @@ export const fieldIndexComparer = (field: Field, reverse = false): IndexComparer } }; -/** @public */ -export const timeComparer = (a: unknown, b: unknown): number => { +const timeComparer = (a: unknown, b: unknown): number => { if (!a || !b) { return falsyComparer(a, b); } @@ -49,20 +47,18 @@ export const timeComparer = (a: unknown, b: unknown): number => { return 0; }; -/** @public */ -export const numericComparer = (a: number, b: number): number => { +const numericComparer = (a: number, b: number): number => { return a - b; }; -/** @public */ -export const stringComparer = (a: string, b: string): number => { +const stringComparer = (a: string, b: string): number => { if (!a || !b) { return falsyComparer(a, b); } return a.localeCompare(b); }; -export const booleanComparer = (a: boolean, b: boolean): number => { +const booleanComparer = (a: boolean, b: boolean): number => { return falsyComparer(a, b); }; diff --git a/packages/grafana-data/src/field/fieldDisplay.ts b/packages/grafana-data/src/field/fieldDisplay.ts index 9833f10e2a76f..ff33188eff373 100644 --- a/packages/grafana-data/src/field/fieldDisplay.ts +++ b/packages/grafana-data/src/field/fieldDisplay.ts @@ -204,6 +204,25 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi } } + // If there is only one row in the data frame, then set the + // valueRowIndex to that one row. This allows the data macros in + // things like links to access other fields from the data frame. + // + // If there were more rows, it still may be sane to set the row + // index, but it may be confusing; the calculation may have + // selected a value from a different row or it may have aggregated + // the values from multiple rows, so to make just the first row + // available would be arbitrary. For now, the users will have to + // ensure that the data frame has just one row if they want data + // link referencing other fields to work. + // + // TODO: A more complete solution here would be to allow the + // calculation to report a relevant row and use that value. For + // example, a common calculation is 'lastNotNull'. It'd be nifty to + // know which row the display value corresponds to in that case if + // there were potentially many + const valueRowIndex = dataFrame.length === 1 ? 0 : undefined; + values.push({ name: calc, field: config, @@ -215,6 +234,7 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi ? () => fieldLinksSupplier({ calculatedValue: displayValue, + valueRowIndex, }) : () => [], hasLinks: hasLinks(field), diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index 8d358a80320aa..67426e6f53864 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -124,7 +124,7 @@ describe('Global MinMax', () => { }); }); - describe('when value values are zeo', () => { + describe('when values are zero', () => { it('then global min max should be correct', () => { const frame = toDataFrame({ fields: [ diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index dcbd4fb97b334..4047a589d9606 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -206,6 +206,13 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra for (const nestedFrames of field.values) { for (let nfIndex = 0; nfIndex < nestedFrames.length; nfIndex++) { for (const valueField of nestedFrames[nfIndex].fields) { + // Get display processor for nested fields + valueField.display = getDisplayProcessor({ + field: valueField, + theme: options.theme, + timeZone: options.timeZone, + }); + valueField.state = { scopedVars: { __dataContext: { diff --git a/packages/grafana-data/src/field/fieldState.test.ts b/packages/grafana-data/src/field/fieldState.test.ts index a017962f37b6c..33275a58cebc3 100644 --- a/packages/grafana-data/src/field/fieldState.test.ts +++ b/packages/grafana-data/src/field/fieldState.test.ts @@ -15,11 +15,6 @@ function checkScenario(scenario: TitleScenario): string { return getFieldDisplayName(field, frame, scenario.frames); } -jest.mock('lodash', () => ({ - ...jest.requireActual('lodash'), - isEqual: jest.fn().mockImplementation((obj1, obj2) => obj1 === obj2), -})); - describe('getFieldDisplayName', () => { it('Should add suffix for comparison frames', () => { const frame = toDataFrame({ diff --git a/packages/grafana-data/src/field/fieldState.ts b/packages/grafana-data/src/field/fieldState.ts index 04f1cc5abef2c..7ae0552baa21c 100644 --- a/packages/grafana-data/src/field/fieldState.ts +++ b/packages/grafana-data/src/field/fieldState.ts @@ -1,5 +1,3 @@ -import { isEqual } from 'lodash'; - import { DataFrame, Field, TIME_SERIES_VALUE_FIELD_NAME, FieldType, TIME_SERIES_TIME_FIELD_NAME } from '../types'; import { formatLabels } from '../utils/labels'; @@ -167,7 +165,7 @@ export function getUniqueFieldName(field: Field, frame?: DataFrame) { for (let i = 0; i < frame.fields.length; i++) { const otherField = frame.fields[i]; - if (isEqual(field, otherField)) { + if (field === otherField) { foundSelf = true; if (dupeCount > 0) { diff --git a/packages/grafana-data/src/field/thresholds.ts b/packages/grafana-data/src/field/thresholds.ts index 8328483aa20ea..a756af1711495 100644 --- a/packages/grafana-data/src/field/thresholds.ts +++ b/packages/grafana-data/src/field/thresholds.ts @@ -1,10 +1,10 @@ import { Threshold, FALLBACK_COLOR, Field, ThresholdsMode } from '../types'; -export const fallBackTreshold: Threshold = { value: 0, color: FALLBACK_COLOR }; +export const fallBackThreshold: Threshold = { value: 0, color: FALLBACK_COLOR }; export function getActiveThreshold(value: number, thresholds: Threshold[] | undefined): Threshold { if (!thresholds || thresholds.length === 0) { - return fallBackTreshold; + return fallBackThreshold; } let active = thresholds[0]; diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 0b4e525f30c74..5b14f5dc2347e 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -5,7 +5,6 @@ */ export * from './utils'; export * from './types'; -export * from './vector'; export * from './dataframe'; export * from './transformations'; export * from './datetime'; @@ -16,6 +15,7 @@ export * from './events'; export * from './themes'; export * from './monaco'; export * from './geo/layer'; +export * from './query'; export { type ValueMatcherOptions, type BasicValueMatcherOptions, @@ -43,3 +43,8 @@ export { export { usePluginContext } from './context/plugins/usePluginContext'; export { isDataSourcePluginContext } from './context/plugins/guards'; export { getLinksSupplier } from './field/fieldOverrides'; + +// deprecated +export { CircularVector } from './vector/CircularVector'; +export { vectorator } from './vector/FunctionalVector'; +export { ArrayVector } from './vector/ArrayVector'; diff --git a/packages/grafana-data/src/monaco/languageRegistry.ts b/packages/grafana-data/src/monaco/languageRegistry.ts index fcfc843ab3180..f12cca2932027 100644 --- a/packages/grafana-data/src/monaco/languageRegistry.ts +++ b/packages/grafana-data/src/monaco/languageRegistry.ts @@ -4,7 +4,7 @@ import { Registry, RegistryItem } from '../utils/Registry'; * @alpha */ export interface MonacoLanguageRegistryItem extends RegistryItem { - init: () => Promise<void>; + init: () => Worker; } /** diff --git a/packages/grafana-data/src/panel/getPanelOptionsWithDefaults.ts b/packages/grafana-data/src/panel/getPanelOptionsWithDefaults.ts index f3a8d44faf7c2..3bcdf8883ae25 100644 --- a/packages/grafana-data/src/panel/getPanelOptionsWithDefaults.ts +++ b/packages/grafana-data/src/panel/getPanelOptionsWithDefaults.ts @@ -14,7 +14,7 @@ import { ThresholdsConfig, ThresholdsMode } from '../types/thresholds'; import { PanelPlugin } from './PanelPlugin'; -export interface Props { +interface Props { plugin: PanelPlugin; currentFieldConfig: FieldConfigSource; currentOptions: Record<string, unknown>; diff --git a/packages/grafana-data/src/query/index.ts b/packages/grafana-data/src/query/index.ts new file mode 100644 index 0000000000000..b01ae2451d54f --- /dev/null +++ b/packages/grafana-data/src/query/index.ts @@ -0,0 +1 @@ +export * from './refId'; diff --git a/packages/grafana-data/src/query/refId.test.ts b/packages/grafana-data/src/query/refId.test.ts new file mode 100644 index 0000000000000..c818f6156cce1 --- /dev/null +++ b/packages/grafana-data/src/query/refId.test.ts @@ -0,0 +1,35 @@ +import { DataQuery } from '@grafana/schema'; + +import { getNextRefId } from '.'; + +export interface TestQuery extends DataQuery { + name?: string; +} + +function dataQueryHelper(ids: string[]): DataQuery[] { + return ids.map((letter) => { + return { refId: letter }; + }); +} + +const singleDataQuery: DataQuery[] = dataQueryHelper('ABCDE'.split('')); +const outOfOrderDataQuery: DataQuery[] = dataQueryHelper('ABD'.split('')); +const singleExtendedDataQuery: DataQuery[] = dataQueryHelper('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')); + +describe('Get next refId char', () => { + it('should return next char', () => { + expect(getNextRefId(singleDataQuery)).toEqual('F'); + }); + + it('should get first char', () => { + expect(getNextRefId([])).toEqual('A'); + }); + + it('should get the first available character if a query has been deleted out of order', () => { + expect(getNextRefId(outOfOrderDataQuery)).toEqual('C'); + }); + + it('should append a new char and start from AA when Z is reached', () => { + expect(getNextRefId(singleExtendedDataQuery)).toEqual('AA'); + }); +}); diff --git a/packages/grafana-data/src/query/refId.ts b/packages/grafana-data/src/query/refId.ts new file mode 100644 index 0000000000000..bdfa87ef68af5 --- /dev/null +++ b/packages/grafana-data/src/query/refId.ts @@ -0,0 +1,23 @@ +import { DataQuery } from '@grafana/schema'; + +/** + * Finds the next available refId for a query + */ +export const getNextRefId = (queries: DataQuery[]): string => { + for (let num = 0; ; num++) { + const refId = getRefId(num); + if (!queries.some((query) => query.refId === refId)) { + return refId; + } + } +}; + +function getRefId(num: number): string { + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + + if (num < letters.length) { + return letters[num]; + } else { + return getRefId(Math.floor(num / letters.length) - 1) + letters[num % letters.length]; + } +} diff --git a/packages/grafana-data/src/text/markdown.ts b/packages/grafana-data/src/text/markdown.ts index 3ddfd83b4df2b..446e95e6f828b 100644 --- a/packages/grafana-data/src/text/markdown.ts +++ b/packages/grafana-data/src/text/markdown.ts @@ -1,4 +1,4 @@ -import { marked } from 'marked'; +import { marked, MarkedOptions } from 'marked'; import { mangle } from 'marked-mangle'; import { sanitizeTextPanelContent } from './sanitize'; @@ -10,13 +10,9 @@ export interface RenderMarkdownOptions { breaks?: boolean; } -const markdownOptions = { - headerIds: false, +const markdownOptions: MarkedOptions = { pedantic: false, gfm: true, - smartLists: true, - smartypants: false, - xhtml: false, breaks: false, }; @@ -36,6 +32,12 @@ export function renderMarkdown(str?: string, options?: RenderMarkdownOptions): s } const html = marked(str || '', opts); + // `marked()` returns a promise if using any extensions that require async processing. + // we don't use any async extensions, but there is no way for typescript to know this, so we need to check the type. + if (typeof html !== 'string') { + throw new Error('Failed to process markdown synchronously.'); + } + if (options?.noSanitize) { return html; } @@ -51,6 +53,13 @@ export function renderTextPanelMarkdown(str?: string, options?: RenderMarkdownOp } const html = marked(str || ''); + + // `marked()` returns a promise if using any extensions that require async processing. + // we don't use any async extensions, but there is no way for typescript to know this, so we need to check the type. + if (typeof html !== 'string') { + throw new Error('Failed to process markdown synchronously.'); + } + if (options?.noSanitize) { return html; } diff --git a/packages/grafana-data/src/themes/createTypography.ts b/packages/grafana-data/src/themes/createTypography.ts index 1473adc2bb8d2..3f11990c0e7fa 100644 --- a/packages/grafana-data/src/themes/createTypography.ts +++ b/packages/grafana-data/src/themes/createTypography.ts @@ -114,6 +114,7 @@ export function createTypography(colors: ThemeColors, typographyInput: ThemeTypo h6: buildVariant(fontWeightMedium, 14, 22, 0.15), body: buildVariant(fontWeightRegular, fontSize, 22, 0.15), bodySmall: buildVariant(fontWeightRegular, 12, 18, 0.15), + code: { ...buildVariant(fontWeightRegular, 14, 16, 0.15), fontFamily: fontFamilyMonospace }, }; const size = { @@ -152,4 +153,5 @@ export interface ThemeTypographyVariantTypes { h6: ThemeTypographyVariant; body: ThemeTypographyVariant; bodySmall: ThemeTypographyVariant; + code: ThemeTypographyVariant; } diff --git a/packages/grafana-data/src/transformations/fieldReducer.test.ts b/packages/grafana-data/src/transformations/fieldReducer.test.ts index ebe4fd2a592da..f96321508b388 100644 --- a/packages/grafana-data/src/transformations/fieldReducer.test.ts +++ b/packages/grafana-data/src/transformations/fieldReducer.test.ts @@ -3,7 +3,7 @@ import { difference } from 'lodash'; import { createDataFrame, guessFieldTypeFromValue } from '../dataframe/processDataFrame'; import { Field, FieldType, NullValueMode } from '../types/index'; -import { fieldReducers, ReducerID, reduceField } from './fieldReducer'; +import { fieldReducers, ReducerID, reduceField, defaultCalcs } from './fieldReducer'; /** * Run a reducer and get back the value @@ -63,6 +63,15 @@ describe('Stats Calculators', () => { expect(stats.count).toEqual(2); }); + it('should handle undefined field data without crashing', () => { + const stats = reduceField({ + field: { name: 'a', values: undefined as unknown as unknown[], config: {}, type: FieldType.number }, + reducers: [ReducerID.first, ReducerID.last, ReducerID.mean, ReducerID.count], + }); + + expect(stats).toEqual(defaultCalcs); + }); + it('should support a single stat also', () => { basicTable.fields[0].state = undefined; // clear the cache const stats = reduceField({ diff --git a/packages/grafana-data/src/transformations/fieldReducer.ts b/packages/grafana-data/src/transformations/fieldReducer.ts index dd8b1f98acfa9..b303654b89500 100644 --- a/packages/grafana-data/src/transformations/fieldReducer.ts +++ b/packages/grafana-data/src/transformations/fieldReducer.ts @@ -41,6 +41,7 @@ export interface FieldReducerInfo extends RegistryItem { // Internal details emptyInputResult?: unknown; // typically null, but some things like 'count' & 'sum' should be zero standard: boolean; // The most common stats can all be calculated in a single pass + preservesUnits: boolean; // Whether this reducer preserves units, certain ones don't e.g. count, distinct count, etc, reduce?: FieldReducer; } @@ -84,7 +85,7 @@ export function reduceField(options: ReduceFieldOptions): FieldCalcs { // Return early for empty series // This lets the concrete implementations assume at least one row const data = field.values; - if (data.length < 1) { + if (data && data.length < 1) { const calcs: FieldCalcs = { ...field.state.calcs }; for (const reducer of queue) { calcs[reducer.id] = reducer.emptyInputResult !== null ? reducer.emptyInputResult : null; @@ -141,6 +142,7 @@ export const fieldReducers = new Registry<FieldReducerInfo>(() => [ standard: true, aliasIds: ['current'], reduce: calculateLastNotNull, + preservesUnits: true, }, { id: ReducerID.last, @@ -148,6 +150,7 @@ export const fieldReducers = new Registry<FieldReducerInfo>(() => [ description: 'Last value', standard: true, reduce: calculateLast, + preservesUnits: true, }, { id: ReducerID.firstNotNull, @@ -155,17 +158,33 @@ export const fieldReducers = new Registry<FieldReducerInfo>(() => [ description: 'First non-null value (also excludes NaNs)', standard: true, reduce: calculateFirstNotNull, + preservesUnits: true, + }, + { + id: ReducerID.first, + name: 'First', + description: 'First Value', + standard: true, + reduce: calculateFirst, + preservesUnits: true, + }, + { id: ReducerID.min, name: 'Min', description: 'Minimum Value', standard: true, preservesUnits: true }, + { id: ReducerID.max, name: 'Max', description: 'Maximum Value', standard: true, preservesUnits: true }, + { + id: ReducerID.mean, + name: 'Mean', + description: 'Average Value', + standard: true, + aliasIds: ['avg'], + preservesUnits: true, }, - { id: ReducerID.first, name: 'First', description: 'First Value', standard: true, reduce: calculateFirst }, - { id: ReducerID.min, name: 'Min', description: 'Minimum Value', standard: true }, - { id: ReducerID.max, name: 'Max', description: 'Maximum Value', standard: true }, - { id: ReducerID.mean, name: 'Mean', description: 'Average Value', standard: true, aliasIds: ['avg'] }, { id: ReducerID.variance, name: 'Variance', description: 'Variance of all values in a field', standard: false, reduce: calculateStdDev, + preservesUnits: true, }, { id: ReducerID.stdDev, @@ -173,6 +192,7 @@ export const fieldReducers = new Registry<FieldReducerInfo>(() => [ description: 'Standard deviation of all values in a field', standard: false, reduce: calculateStdDev, + preservesUnits: true, }, { id: ReducerID.sum, @@ -181,6 +201,7 @@ export const fieldReducers = new Registry<FieldReducerInfo>(() => [ emptyInputResult: 0, standard: true, aliasIds: ['total'], + preservesUnits: true, }, { id: ReducerID.count, @@ -188,36 +209,42 @@ export const fieldReducers = new Registry<FieldReducerInfo>(() => [ description: 'Number of values in response', emptyInputResult: 0, standard: true, + preservesUnits: false, }, { id: ReducerID.range, name: 'Range', description: 'Difference between minimum and maximum values', standard: true, + preservesUnits: true, }, { id: ReducerID.delta, name: 'Delta', description: 'Cumulative change in value', standard: true, + preservesUnits: true, }, { id: ReducerID.step, name: 'Step', description: 'Minimum interval between values', standard: true, + preservesUnits: true, }, { id: ReducerID.diff, name: 'Difference', description: 'Difference between first and last values', standard: true, + preservesUnits: true, }, { id: ReducerID.logmin, name: 'Min (above zero)', description: 'Used for log min scale', standard: true, + preservesUnits: true, }, { id: ReducerID.allIsZero, @@ -225,6 +252,7 @@ export const fieldReducers = new Registry<FieldReducerInfo>(() => [ description: 'All values are zero', emptyInputResult: false, standard: true, + preservesUnits: true, }, { id: ReducerID.allIsNull, @@ -232,6 +260,7 @@ export const fieldReducers = new Registry<FieldReducerInfo>(() => [ description: 'All values are null', emptyInputResult: true, standard: true, + preservesUnits: false, }, { id: ReducerID.changeCount, @@ -239,6 +268,7 @@ export const fieldReducers = new Registry<FieldReducerInfo>(() => [ description: 'Number of times the value changes', standard: false, reduce: calculateChangeCount, + preservesUnits: false, }, { id: ReducerID.distinctCount, @@ -246,12 +276,14 @@ export const fieldReducers = new Registry<FieldReducerInfo>(() => [ description: 'Number of distinct values', standard: false, reduce: calculateDistinctCount, + preservesUnits: false, }, { id: ReducerID.diffperc, name: 'Difference percent', description: 'Percentage difference between first and last values', standard: true, + preservesUnits: false, }, { id: ReducerID.allValues, @@ -259,6 +291,7 @@ export const fieldReducers = new Registry<FieldReducerInfo>(() => [ description: 'Returns an array with all values', standard: false, reduce: (field: Field) => ({ allValues: [...field.values] }), + preservesUnits: false, }, { id: ReducerID.uniqueValues, @@ -268,36 +301,45 @@ export const fieldReducers = new Registry<FieldReducerInfo>(() => [ reduce: (field: Field) => ({ uniqueValues: [...new Set(field.values)], }), + preservesUnits: false, }, ]); +// Used for test cases +export const defaultCalcs: FieldCalcs = { + sum: 0, + max: -Number.MAX_VALUE, + min: Number.MAX_VALUE, + logmin: Number.MAX_VALUE, + mean: null, + last: null, + first: null, + lastNotNull: null, + firstNotNull: null, + count: 0, + nonNullCount: 0, + allIsNull: true, + allIsZero: true, + range: null, + diff: null, + delta: 0, + step: Number.MAX_VALUE, + diffperc: 0, + + // Just used for calculations -- not exposed as a stat + previousDeltaUp: true, +}; + export function doStandardCalcs(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { - const calcs: FieldCalcs = { - sum: 0, - max: -Number.MAX_VALUE, - min: Number.MAX_VALUE, - logmin: Number.MAX_VALUE, - mean: null, - last: null, - first: null, - lastNotNull: null, - firstNotNull: null, - count: 0, - nonNullCount: 0, - allIsNull: true, - allIsZero: true, - range: null, - diff: null, - delta: 0, - step: Number.MAX_VALUE, - diffperc: 0, - - // Just used for calculations -- not exposed as a stat - previousDeltaUp: true, - }; + const calcs: FieldCalcs = { ...defaultCalcs }; const data = field.values; + // early return for undefined / empty series + if (!data) { + return calcs; + } + const isNumberField = field.type === FieldType.number || field.type === FieldType.time; for (let i = 0; i < data.length; i++) { diff --git a/packages/grafana-data/src/transformations/index.ts b/packages/grafana-data/src/transformations/index.ts index 4cdd4ea2f8bfb..3d3afdebfbe80 100644 --- a/packages/grafana-data/src/transformations/index.ts +++ b/packages/grafana-data/src/transformations/index.ts @@ -23,3 +23,4 @@ export { ensureTimeField } from './transformers/convertFieldType'; // Required for Sparklines util to work in @grafana/data, but ideally kept internal export { applyNullInsertThreshold } from './transformers/nulls/nullInsertThreshold'; +export { nullToValue } from './transformers/nulls/nullToValue'; diff --git a/packages/grafana-data/src/transformations/matchers.ts b/packages/grafana-data/src/transformations/matchers.ts index 3ad60ca07cc30..21ec33dda24ed 100644 --- a/packages/grafana-data/src/transformations/matchers.ts +++ b/packages/grafana-data/src/transformations/matchers.ts @@ -21,6 +21,7 @@ import { getNullValueMatchers } from './matchers/valueMatchers/nullMatchers'; import { getNumericValueMatchers } from './matchers/valueMatchers/numericMatchers'; import { getRangeValueMatchers } from './matchers/valueMatchers/rangeMatchers'; import { getRegexValueMatcher } from './matchers/valueMatchers/regexMatchers'; +import { getSubstringValueMatchers } from './matchers/valueMatchers/substringMatchers'; export { type FieldValueMatcherConfig } from './matchers/fieldValueMatcher'; @@ -59,6 +60,7 @@ export const valueMatchers = new Registry<ValueMatcherInfo>(() => { ...getNullValueMatchers(), ...getNumericValueMatchers(), ...getEqualValueMatchers(), + ...getSubstringValueMatchers(), ...getRangeValueMatchers(), ...getRegexValueMatcher(), ]; diff --git a/packages/grafana-data/src/transformations/matchers/ids.ts b/packages/grafana-data/src/transformations/matchers/ids.ts index 5830e62f5fbc9..415e4fe21a89a 100644 --- a/packages/grafana-data/src/transformations/matchers/ids.ts +++ b/packages/grafana-data/src/transformations/matchers/ids.ts @@ -52,5 +52,7 @@ export enum ValueMatcherID { lowerOrEqual = 'lowerOrEqual', equal = 'equal', notEqual = 'notEqual', + substring = 'substring', + notSubstring = 'notSubstring', between = 'between', } diff --git a/packages/grafana-data/src/transformations/matchers/nameMatcher.ts b/packages/grafana-data/src/transformations/matchers/nameMatcher.ts index 7befe20ee8814..52d44a36b19b6 100644 --- a/packages/grafana-data/src/transformations/matchers/nameMatcher.ts +++ b/packages/grafana-data/src/transformations/matchers/nameMatcher.ts @@ -41,7 +41,7 @@ const fieldNameMatcher: FieldMatcherInfo<string> = { defaultOptions: '', get: (name: string): FieldMatcher => { - const uniqueNames = new Set<string>([name] ?? []); + const uniqueNames = new Set<string>([name]); const fallback = fieldNameFallback(uniqueNames); diff --git a/packages/grafana-data/src/transformations/matchers/predicates.ts b/packages/grafana-data/src/transformations/matchers/predicates.ts index ef26e05be1e7f..7939f95aec0e9 100644 --- a/packages/grafana-data/src/transformations/matchers/predicates.ts +++ b/packages/grafana-data/src/transformations/matchers/predicates.ts @@ -184,11 +184,11 @@ export const alwaysFieldMatcher = (field: Field) => { return true; }; -export const alwaysFrameMatcher = (frame: DataFrame) => { +const alwaysFrameMatcher = (frame: DataFrame) => { return true; }; -export const neverFieldMatcher = (field: Field) => { +const neverFieldMatcher = (field: Field) => { return false; }; @@ -196,7 +196,7 @@ export const notTimeFieldMatcher = (field: Field) => { return field.type !== FieldType.time; }; -export const neverFrameMatcher = (frame: DataFrame) => { +const neverFrameMatcher = (frame: DataFrame) => { return false; }; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/substringMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/substringMatchers.test.ts new file mode 100644 index 0000000000000..f9f7789cd66fd --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/substringMatchers.test.ts @@ -0,0 +1,130 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value substring to matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: ['24', null, '10', 'asd', '42'], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.substring, + options: { + value: '2', + }, + }); + + it('should match when option value is a substring', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + // Added for https://github.com/grafana/grafana/pull/83548#pullrequestreview-1904931540 where the matcher was not handling null values + it('should be a mismatch if the option is null and should not cause errors', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match when option value is different', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should match when option value is a substring', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 4; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); +}); + +describe('value not substring matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: ['24', null, '050', 'asd', '42', '0'], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.notSubstring, + options: { + value: '5', + }, + }); + + it('should not match if the value is "0" and the option value is "0"', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 5; + + const zeroMatcher = getValueMatcher({ + id: ValueMatcherID.notSubstring, + options: { + value: '0', + }, + }); + + expect(zeroMatcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should match when option value is a substring', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match when option value is different', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should match when value is null because null its not a substring', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 4; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('it should not match if the option value is empty string', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + const emptyMatcher = getValueMatcher({ + id: ValueMatcherID.notSubstring, + options: { + value: '', + }, + }); + + expect(emptyMatcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/substringMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/substringMatchers.ts new file mode 100644 index 0000000000000..07d5d8486abb3 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/substringMatchers.ts @@ -0,0 +1,41 @@ +import { Field, FieldType } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; + +import { BasicValueMatcherOptions } from './types'; + +const isSubstringMatcher: ValueMatcherInfo<BasicValueMatcherOptions> = { + id: ValueMatcherID.substring, + name: 'Contains substring', + description: 'Match where value for given field is a substring to options value.', + get: (options) => { + return (valueIndex: number, field: Field) => { + const value = field.values[valueIndex]; + return (value && value.includes(options.value)) || options.value === ''; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is similar to the value.`; + }, + isApplicable: (field) => field.type === FieldType.string, + getDefaultOptions: () => ({ value: '' }), +}; + +const isNotSubstringValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions> = { + id: ValueMatcherID.notSubstring, + name: 'Does not contain substring', + description: 'Match where value for given field is not a substring to options value.', + get: (options) => { + return (valueIndex: number, field: Field) => { + const value = field.values[valueIndex]; + return typeof value === 'string' && options.value !== '' && !value.includes(options.value); + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is not similar to the value.`; + }, + isApplicable: (field) => field.type === FieldType.string, + getDefaultOptions: () => ({ value: '' }), +}; + +export const getSubstringValueMatchers = (): ValueMatcherInfo[] => [isSubstringMatcher, isNotSubstringValueMatcher]; diff --git a/packages/grafana-data/src/transformations/transformDataFrame.ts b/packages/grafana-data/src/transformations/transformDataFrame.ts index f9baf411d6a49..6717b6565a560 100644 --- a/packages/grafana-data/src/transformations/transformDataFrame.ts +++ b/packages/grafana-data/src/transformations/transformDataFrame.ts @@ -55,19 +55,6 @@ const postProcessTransform = return after; } - // Add a key to the metadata if the data changed - for (const series of after) { - if (!series.meta) { - series.meta = {}; - } - - if (!series.meta.transformations) { - series.meta.transformations = [info.id]; - } else { - series.meta.transformations = [...series.meta.transformations, info.id]; - } - } - // Add back the filtered out frames if (matcher) { // keep the frame order the same diff --git a/packages/grafana-data/src/transformations/transformers.ts b/packages/grafana-data/src/transformations/transformers.ts index 4086a5613fece..3344a7fb45093 100644 --- a/packages/grafana-data/src/transformations/transformers.ts +++ b/packages/grafana-data/src/transformations/transformers.ts @@ -9,6 +9,7 @@ import { filterByValueTransformer } from './transformers/filterByValue'; import { formatStringTransformer } from './transformers/formatString'; import { formatTimeTransformer } from './transformers/formatTime'; import { groupByTransformer } from './transformers/groupBy'; +import { groupToNestedTable } from './transformers/groupToNestedTable'; import { groupingToMatrixTransformer } from './transformers/groupingToMatrix'; import { histogramTransformer } from './transformers/histogram'; import { joinByFieldTransformer } from './transformers/joinByField'; @@ -53,4 +54,5 @@ export const standardTransformers = { convertFieldTypeTransformer, groupingToMatrixTransformer, limitTransformer, + groupToNestedTable, }; diff --git a/packages/grafana-data/src/transformations/transformers/calculateField.ts b/packages/grafana-data/src/transformations/transformers/calculateField.ts index e50dc3b268b1d..b50f1bcac2d9a 100644 --- a/packages/grafana-data/src/transformations/transformers/calculateField.ts +++ b/packages/grafana-data/src/transformations/transformers/calculateField.ts @@ -61,7 +61,7 @@ export interface BinaryOptions { right: string; } -export interface IndexOptions { +interface IndexOptions { asPercentile: boolean; } diff --git a/packages/grafana-data/src/transformations/transformers/convertFieldType.test.ts b/packages/grafana-data/src/transformations/transformers/convertFieldType.test.ts index 432ae01e9fefa..15dfed729c8f8 100644 --- a/packages/grafana-data/src/transformations/transformers/convertFieldType.test.ts +++ b/packages/grafana-data/src/transformations/transformers/convertFieldType.test.ts @@ -359,6 +359,38 @@ describe('field convert types transformer', () => { ]); }); + it('will support custom join separators', () => { + const options = { + conversions: [{ targetField: 'vals', destinationType: FieldType.string, joinWith: '|' }], + }; + + const arrayValues = toDataFrame({ + fields: [ + { + name: 'vals', + type: FieldType.other, + values: [ + ['a', 'b', 2], + [3, 'x', 'y'], + ], + }, + ], + }); + + const stringified = convertFieldTypes(options, [arrayValues]); + expect( + stringified[0].fields.map((f) => ({ + type: f.type, + values: f.values, + })) + ).toEqual([ + { + type: FieldType.string, + values: ['a|b|2', '3|x|y'], + }, + ]); + }); + it('will convert time fields to strings', () => { const options = { conversions: [{ targetField: 'time', destinationType: FieldType.string, dateFormat: 'YYYY-MM' }], diff --git a/packages/grafana-data/src/transformations/transformers/convertFieldType.ts b/packages/grafana-data/src/transformations/transformers/convertFieldType.ts index b076ef4e6ee02..669a9480bd11a 100644 --- a/packages/grafana-data/src/transformations/transformers/convertFieldType.ts +++ b/packages/grafana-data/src/transformations/transformers/convertFieldType.ts @@ -27,6 +27,10 @@ export interface ConvertFieldTypeOptions { * Date format to parse a string datetime */ dateFormat?: string; + /** + * When converting an array to a string, the values can be joined with a custom separator + */ + joinWith?: string; /** * When converting a date to a string an option timezone. */ @@ -103,7 +107,7 @@ export function convertFieldType(field: Field, opts: ConvertFieldTypeOptions): F case FieldType.number: return fieldToNumberField(field); case FieldType.string: - return fieldToStringField(field, opts.dateFormat, { timeZone: opts.timezone }); + return fieldToStringField(field, opts.dateFormat, { timeZone: opts.timezone }, opts.joinWith); case FieldType.boolean: return fieldToBooleanField(field); case FieldType.enum: @@ -192,7 +196,8 @@ function fieldToBooleanField(field: Field): Field { export function fieldToStringField( field: Field, dateFormat?: string, - parseOptions?: DateTimeOptionsWhenParsing + parseOptions?: DateTimeOptionsWhenParsing, + joinWith?: string ): Field { let values = field.values; @@ -202,7 +207,12 @@ export function fieldToStringField( break; case FieldType.other: - values = values.map((v) => JSON.stringify(v)); + values = values.map((v) => { + if (joinWith?.length && Array.isArray(v)) { + return v.join(joinWith); + } + return JSON.stringify(v); // will quote strings and avoid "object" + }); break; default: diff --git a/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts b/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts index ae2b231b680ed..0f63477d51bc9 100644 --- a/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts +++ b/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts @@ -109,11 +109,6 @@ describe('ensureColumns transformer', () => { }, ], "length": 2, - "meta": { - "transformations": [ - "ensureColumns", - ], - }, } `); }); diff --git a/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts b/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts index 06db191240e37..b61174d2e1393 100644 --- a/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts +++ b/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts @@ -12,18 +12,10 @@ import { FilterByValueType, } from './filterByValue'; import { DataTransformerID } from './ids'; +import * as utils from './utils'; -let transformationSupport = false; - -jest.mock('./utils', () => { - const actual = jest.requireActual('./utils'); - return { - ...actual, - transformationsVariableSupport: () => { - return transformationSupport; - }, - }; -}); +const mockTransformationsVariableSupport = jest.spyOn(utils, 'transformationsVariableSupport'); +mockTransformationsVariableSupport.mockReturnValue(false); const seriesAWithSingleField = toDataFrame({ name: 'A', @@ -122,7 +114,7 @@ describe('FilterByValue transformer', () => { }); it('should interpolate dashboard variables', async () => { - transformationSupport = true; + mockTransformationsVariableSupport.mockReturnValue(true); const lower: MatcherConfig<BasicValueMatcherOptions<string | number>> = { id: ValueMatcherID.lower, @@ -164,10 +156,11 @@ describe('FilterByValue transformer', () => { }, ]); }); - transformationSupport = false; }); it('should not interpolate dashboard variables when feature toggle is off', async () => { + mockTransformationsVariableSupport.mockReturnValue(false); + const lower: MatcherConfig<BasicValueMatcherOptions<number | string>> = { id: ValueMatcherID.lower, options: { value: 'notinterpolating' }, diff --git a/packages/grafana-data/src/transformations/transformers/formatTime.test.ts b/packages/grafana-data/src/transformations/transformers/formatTime.test.ts index bf3700418a39a..fe5096226a370 100644 --- a/packages/grafana-data/src/transformations/transformers/formatTime.test.ts +++ b/packages/grafana-data/src/transformations/transformers/formatTime.test.ts @@ -2,7 +2,7 @@ import { toDataFrame } from '../../dataframe/processDataFrame'; import { FieldType } from '../../types/dataFrame'; import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; -import { createTimeFormatter, formatTimeTransformer } from './formatTime'; +import { applyFormatTime, formatTimeTransformer } from './formatTime'; describe('Format Time Transformer', () => { beforeAll(() => { @@ -16,7 +16,6 @@ describe('Format Time Transformer', () => { timezone: 'utc', }; - const formatter = createTimeFormatter(options.timeField, options.outputFormat, options.timezone); const frame = toDataFrame({ fields: [ { @@ -27,8 +26,32 @@ describe('Format Time Transformer', () => { ], }); - const newFrame = formatter(frame.fields); - expect(newFrame[0].values).toEqual(['2021-02', '2023-07', '2023-04', '2023-07', '2023-08']); + const newFrames = applyFormatTime(options, [frame]); + expect(newFrames[0].fields[0].values).toEqual(['2021-02', '2023-07', '2023-04', '2023-07', '2023-08']); + }); + + it('will match on getFieldDisplayName', () => { + const options = { + timeField: 'Created', + outputFormat: 'YYYY-MM', + timezone: 'utc', + }; + + const frame = toDataFrame({ + fields: [ + { + name: 'created', + type: FieldType.time, + values: [1612939600000, 1689192000000, 1682025600000, 1690328089000, 1691011200000], + config: { + displayName: 'Created', + }, + }, + ], + }); + + const newFrames = applyFormatTime(options, [frame]); + expect(newFrames[0].fields[0].values).toEqual(['2021-02', '2023-07', '2023-04', '2023-07', '2023-08']); }); it('will handle formats with times', () => { @@ -38,7 +61,6 @@ describe('Format Time Transformer', () => { timezone: 'utc', }; - const formatter = createTimeFormatter(options.timeField, options.outputFormat, options.timezone); const frame = toDataFrame({ fields: [ { @@ -49,8 +71,8 @@ describe('Format Time Transformer', () => { ], }); - const newFrame = formatter(frame.fields); - expect(newFrame[0].values).toEqual([ + const newFrames = applyFormatTime(options, [frame]); + expect(newFrames[0].fields[0].values).toEqual([ '2021-02 6:46:40 am', '2023-07 8:00:00 pm', '2023-04 9:20:00 pm', @@ -66,7 +88,6 @@ describe('Format Time Transformer', () => { timezone: 'utc', }; - const formatter = createTimeFormatter(options.timeField, options.outputFormat, options.timezone); const frame = toDataFrame({ fields: [ { @@ -77,8 +98,8 @@ describe('Format Time Transformer', () => { ], }); - const newFrame = formatter(frame.fields); - expect(newFrame[0].values).toEqual([ + const newFrames = applyFormatTime(options, [frame]); + expect(newFrames[0].fields[0].values).toEqual([ '2021-02 6:46:40 am', '2023-07 8:00:00 pm', '2023-04 9:20:00 pm', diff --git a/packages/grafana-data/src/transformations/transformers/formatTime.ts b/packages/grafana-data/src/transformations/transformers/formatTime.ts index bd5a496562b32..62314055dc3ac 100644 --- a/packages/grafana-data/src/transformations/transformers/formatTime.ts +++ b/packages/grafana-data/src/transformations/transformers/formatTime.ts @@ -2,8 +2,9 @@ import { map } from 'rxjs/operators'; import { TimeZone } from '@grafana/schema'; -import { DataFrame, Field, TransformationApplicabilityLevels } from '../../types'; -import { DataTransformerInfo } from '../../types/transformations'; +import { cacheFieldDisplayNames } from '../../field'; +import { DataFrame, TransformationApplicabilityLevels } from '../../types'; +import { DataTransformContext, DataTransformerInfo } from '../../types/transformations'; import { fieldToStringField } from './convertFieldType'; import { DataTransformerID } from './ids'; @@ -34,21 +35,10 @@ export const formatTimeTransformer: DataTransformerInfo<FormatTimeTransformerOpt }, isApplicableDescription: 'The Format time transformation requires a time field to work. No time field could be found.', - operator: (options) => (source) => + operator: (options, ctx) => (source) => source.pipe( map((data) => { - // If a field and a format are configured - // then format the time output - const formatter = createTimeFormatter(options.timeField, options.outputFormat, options.timezone); - - if (!Array.isArray(data) || data.length === 0) { - return data; - } - - return data.map((frame) => ({ - ...frame, - fields: formatter(frame.fields), - })); + return applyFormatTime(options, data, ctx); }) ), }; @@ -56,21 +46,27 @@ export const formatTimeTransformer: DataTransformerInfo<FormatTimeTransformerOpt /** * @internal */ -export const createTimeFormatter = (timeField: string, outputFormat: string, timezone: string) => (fields: Field[]) => { - return fields.map((field) => { - // Find the configured field - if (field.name === timeField) { - // Update values to use the configured format - let formattedField = null; - if (timezone) { - formattedField = fieldToStringField(field, outputFormat, { timeZone: timezone }); - } else { - formattedField = fieldToStringField(field, outputFormat); - } +export const applyFormatTime = ( + { timeField, outputFormat, timezone }: FormatTimeTransformerOptions, + data: DataFrame[], + ctx?: DataTransformContext +) => { + if (!Array.isArray(data) || data.length === 0) { + return data; + } - return formattedField; - } + cacheFieldDisplayNames(data); + + outputFormat = ctx?.interpolate(outputFormat) ?? outputFormat; + + return data.map((frame) => ({ + ...frame, + fields: frame.fields.map((field) => { + if (field.state?.displayName === timeField) { + field = fieldToStringField(field, outputFormat, { timeZone: timezone }); + } - return field; - }); + return field; + }), + })); }; diff --git a/packages/grafana-data/src/transformations/transformers/groupBy.ts b/packages/grafana-data/src/transformations/transformers/groupBy.ts index 3fd95db316b63..fd5e28aa7e6e3 100644 --- a/packages/grafana-data/src/transformations/transformers/groupBy.ts +++ b/packages/grafana-data/src/transformations/transformers/groupBy.ts @@ -22,6 +22,10 @@ export interface GroupByTransformerOptions { fields: Record<string, GroupByFieldOptions>; } +interface FieldMap { + [key: string]: Field; +} + export const groupByTransformer: DataTransformerInfo<GroupByTransformerOptions> = { id: DataTransformerID.groupBy, name: 'Group by', @@ -75,64 +79,19 @@ export const groupByTransformer: DataTransformerInfo<GroupByTransformerOptions> const processed: DataFrame[] = []; for (const frame of data) { - const groupByFields: Field[] = []; - - for (const field of frame.fields) { - if (shouldGroupOnField(field, options)) { - groupByFields.push(field); - } - } - + // Create a list of fields to group on + // If there are none we skip the rest + const groupByFields: Field[] = frame.fields.filter((field) => shouldGroupOnField(field, options)); if (groupByFields.length === 0) { - continue; // No group by field in this frame, ignore the frame + continue; } // Group the values by fields and groups so we can get all values for a // group for a given field. - const valuesByGroupKey = new Map<string, Record<string, Field>>(); - for (let rowIndex = 0; rowIndex < frame.length; rowIndex++) { - const groupKey = String(groupByFields.map((field) => field.values[rowIndex])); - const valuesByField = valuesByGroupKey.get(groupKey) ?? {}; - - if (!valuesByGroupKey.has(groupKey)) { - valuesByGroupKey.set(groupKey, valuesByField); - } - - for (let field of frame.fields) { - const fieldName = getFieldDisplayName(field); + const valuesByGroupKey = groupValuesByKey(frame, groupByFields); - if (!valuesByField[fieldName]) { - valuesByField[fieldName] = { - name: fieldName, - type: field.type, - config: { ...field.config }, - values: [], - }; - } - - valuesByField[fieldName].values.push(field.values[rowIndex]); - } - } - - const fields: Field[] = []; - - for (const field of groupByFields) { - const values: unknown[] = []; - const fieldName = getFieldDisplayName(field); - - valuesByGroupKey.forEach((value) => { - values.push(value[fieldName].values[0]); - }); - - fields.push({ - name: field.name, - type: field.type, - config: { - ...field.config, - }, - values: values, - }); - } + // Add the grouped fields to the resulting fields of the transformation + const fields: Field[] = createGroupedFields(groupByFields, valuesByGroupKey); // Then for each calculations configured, compute and add a new field (column) for (const field of frame.fields) { @@ -197,7 +156,7 @@ const shouldCalculateField = (field: Field, options: GroupByTransformerOptions): ); }; -const detectFieldType = (aggregation: string, sourceField: Field, targetField: Field): FieldType => { +function detectFieldType(aggregation: string, sourceField: Field, targetField: Field): FieldType { switch (aggregation) { case ReducerID.allIsNull: return FieldType.boolean; @@ -209,4 +168,75 @@ const detectFieldType = (aggregation: string, sourceField: Field, targetField: F default: return guessFieldTypeForField(targetField) ?? FieldType.string; } -}; +} + +/** + * Groups values together by key. This will create a mapping of strings + * to _FieldMaps_ that will then be used to group values on. + * + * @param frame + * The dataframe containing the data to group. + * @param groupByFields + * An array of fields to group on. + */ +export function groupValuesByKey(frame: DataFrame, groupByFields: Field[]) { + const valuesByGroupKey = new Map<string, FieldMap>(); + + for (let rowIndex = 0; rowIndex < frame.length; rowIndex++) { + const groupKey = String(groupByFields.map((field) => field.values[rowIndex])); + const valuesByField = valuesByGroupKey.get(groupKey) ?? {}; + + if (!valuesByGroupKey.has(groupKey)) { + valuesByGroupKey.set(groupKey, valuesByField); + } + + for (let field of frame.fields) { + const fieldName = getFieldDisplayName(field); + + if (!valuesByField[fieldName]) { + valuesByField[fieldName] = { + name: fieldName, + type: field.type, + config: { ...field.config }, + values: [], + }; + } + + valuesByField[fieldName].values.push(field.values[rowIndex]); + } + } + + return valuesByGroupKey; +} + +/** + * Create new fields which will be used to display grouped values. + * + * @param groupByFields + * @param valuesByGroupKey + * @returns + * Returns an array of fields that have been grouped. + */ +export function createGroupedFields(groupByFields: Field[], valuesByGroupKey: Map<string, FieldMap>): Field[] { + const fields: Field[] = []; + + for (const field of groupByFields) { + const values: unknown[] = []; + const fieldName = getFieldDisplayName(field); + + valuesByGroupKey.forEach((value) => { + values.push(value[fieldName].values[0]); + }); + + fields.push({ + name: field.name, + type: field.type, + config: { + ...field.config, + }, + values, + }); + } + + return fields; +} diff --git a/packages/grafana-data/src/transformations/transformers/groupToNestedTable.test.ts b/packages/grafana-data/src/transformations/transformers/groupToNestedTable.test.ts new file mode 100644 index 0000000000000..d36a0ce1adf71 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/groupToNestedTable.test.ts @@ -0,0 +1,243 @@ +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { DataTransformerConfig, Field, FieldType } from '../../types'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { ReducerID } from '../fieldReducer'; +import { transformDataFrame } from '../transformDataFrame'; + +import { GroupByOperationID, GroupByTransformerOptions } from './groupBy'; +import { groupToNestedTable, GroupToNestedTableTransformerOptions } from './groupToNestedTable'; +import { DataTransformerID } from './ids'; + +describe('GroupToSubframe transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([groupToNestedTable]); + }); + + it('should group values by message and place values in subframe', async () => { + const testSeries = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] }, + { name: 'message', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] }, + { name: 'values', type: FieldType.string, values: [1, 2, 2, 3, 3, 3] }, + ], + }); + + const cfg: DataTransformerConfig<GroupToNestedTableTransformerOptions> = { + id: DataTransformerID.groupToNestedTable, + options: { + fields: { + message: { + operation: GroupByOperationID.groupBy, + aggregations: [], + }, + }, + }, + }; + + await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + { + name: 'message', + type: FieldType.string, + config: {}, + values: ['one', 'two', 'three'], + }, + { + name: 'Nested frames', + type: FieldType.nestedFrames, + config: {}, + values: [ + [ + { + meta: { custom: { noHeader: false } }, + length: 1, + fields: [ + { name: 'time', type: 'time', config: {}, values: [3000] }, + { name: 'values', type: 'string', config: {}, values: [1] }, + ], + }, + ], + [ + { + meta: { custom: { noHeader: false } }, + length: 2, + fields: [ + { + name: 'time', + type: 'time', + config: {}, + values: [4000, 5000], + }, + { + name: 'values', + type: 'string', + config: {}, + values: [2, 2], + }, + ], + }, + ], + [ + { + meta: { custom: { noHeader: false } }, + length: 3, + fields: [ + { + name: 'time', + type: 'time', + config: {}, + values: [6000, 7000, 8000], + }, + { + name: 'values', + type: 'string', + config: {}, + values: [3, 3, 3], + }, + ], + }, + ], + ], + }, + ]; + + expect(result[0].fields).toEqual(expected); + }); + }); + + it('should group by message, compute a few calculations for each group of values, and place other values in a subframe', async () => { + const testSeries = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] }, + { name: 'message', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] }, + { name: 'values', type: FieldType.number, values: [1, 2, 2, 3, 3, 3] }, + { name: 'intVal', type: FieldType.number, values: [1, 2, 3, 4, 5, 6] }, + { name: 'floatVal', type: FieldType.number, values: [1.1, 2.3, 3.6, 4.8, 5.7, 6.9] }, + ], + }); + + const cfg: DataTransformerConfig<GroupByTransformerOptions> = { + id: DataTransformerID.groupToNestedTable, + options: { + fields: { + message: { + operation: GroupByOperationID.groupBy, + aggregations: [], + }, + values: { + operation: GroupByOperationID.aggregate, + aggregations: [ReducerID.sum], + }, + }, + }, + }; + + await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + { + name: 'message', + type: FieldType.string, + config: {}, + values: ['one', 'two', 'three'], + }, + { + name: 'values (sum)', + values: [1, 4, 9], + type: FieldType.number, + config: {}, + }, + { + config: {}, + name: 'Nested frames', + type: FieldType.nestedFrames, + values: [ + [ + { + meta: { custom: { noHeader: false } }, + length: 1, + fields: [ + { + name: 'time', + type: 'time', + config: {}, + values: [3000], + }, + { + name: 'intVal', + type: 'number', + config: {}, + values: [1], + }, + { + name: 'floatVal', + type: 'number', + config: {}, + values: [1.1], + }, + ], + }, + ], + [ + { + meta: { custom: { noHeader: false } }, + length: 2, + fields: [ + { + name: 'time', + type: 'time', + config: {}, + values: [4000, 5000], + }, + { + name: 'intVal', + type: 'number', + config: {}, + values: [2, 3], + }, + { + name: 'floatVal', + type: 'number', + config: {}, + values: [2.3, 3.6], + }, + ], + }, + ], + [ + { + meta: { custom: { noHeader: false } }, + length: 3, + fields: [ + { + name: 'time', + type: 'time', + config: {}, + values: [6000, 7000, 8000], + }, + { + name: 'intVal', + type: 'number', + config: {}, + values: [4, 5, 6], + }, + { + name: 'floatVal', + type: 'number', + config: {}, + values: [4.8, 5.7, 6.9], + }, + ], + }, + ], + ], + }, + ]; + + expect(result[0].fields).toEqual(expected); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/groupToNestedTable.ts b/packages/grafana-data/src/transformations/transformers/groupToNestedTable.ts new file mode 100644 index 0000000000000..aa2d1f9c883b3 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/groupToNestedTable.ts @@ -0,0 +1,235 @@ +import { map } from 'rxjs/operators'; + +import { guessFieldTypeForField } from '../../dataframe/processDataFrame'; +import { getFieldDisplayName } from '../../field/fieldState'; +import { DataFrame, Field, FieldType } from '../../types/dataFrame'; +import { DataTransformerInfo } from '../../types/transformations'; +import { ReducerID, reduceField } from '../fieldReducer'; + +import { GroupByFieldOptions, createGroupedFields, groupValuesByKey } from './groupBy'; +import { DataTransformerID } from './ids'; + +export const SHOW_NESTED_HEADERS_DEFAULT = true; + +enum GroupByOperationID { + aggregate = 'aggregate', + groupBy = 'groupby', +} + +export interface GroupToNestedTableTransformerOptions { + showSubframeHeaders?: boolean; + fields: Record<string, GroupByFieldOptions>; +} + +interface FieldMap { + [key: string]: Field; +} + +export const groupToNestedTable: DataTransformerInfo<GroupToNestedTableTransformerOptions> = { + id: DataTransformerID.groupToNestedTable, + name: 'Group to nested tables', + description: 'Group data by a field value and create nested tables with the grouped data', + defaultOptions: { + showSubframeHeaders: SHOW_NESTED_HEADERS_DEFAULT, + fields: {}, + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + map((data) => { + const hasValidConfig = Object.keys(options.fields).find( + (name) => options.fields[name].operation === GroupByOperationID.groupBy + ); + if (!hasValidConfig) { + return data; + } + + const processed: DataFrame[] = []; + + for (const frame of data) { + // Create a list of fields to group on + // If there are none we skip the rest + const groupByFields: Field[] = frame.fields.filter((field) => shouldGroupOnField(field, options)); + if (groupByFields.length === 0) { + continue; + } + + // Group the values by fields and groups so we can get all values for a + // group for a given field. + const valuesByGroupKey = groupValuesByKey(frame, groupByFields); + + // Add the grouped fields to the resulting fields of the transformation + const fields: Field[] = createGroupedFields(groupByFields, valuesByGroupKey); + + // Group data into sub frames so they will display as tables + const subFrames: DataFrame[][] = groupToSubframes(valuesByGroupKey, options); + + // Then for each calculations configured, compute and add a new field (column) + for (let i = 0; i < frame.fields.length; i++) { + const field = frame.fields[i]; + + if (!shouldCalculateField(field, options)) { + continue; + } + + const fieldName = getFieldDisplayName(field); + const aggregations = options.fields[fieldName].aggregations; + const valuesByAggregation: Record<string, unknown[]> = {}; + + valuesByGroupKey.forEach((value) => { + const fieldWithValuesForGroup = value[fieldName]; + const results = reduceField({ + field: fieldWithValuesForGroup, + reducers: aggregations, + }); + + for (const aggregation of aggregations) { + if (!Array.isArray(valuesByAggregation[aggregation])) { + valuesByAggregation[aggregation] = []; + } + valuesByAggregation[aggregation].push(results[aggregation]); + } + }); + + for (const aggregation of aggregations) { + const aggregationField: Field = { + name: `${fieldName} (${aggregation})`, + values: valuesByAggregation[aggregation], + type: FieldType.other, + config: {}, + }; + + aggregationField.type = detectFieldType(aggregation, field, aggregationField); + fields.push(aggregationField); + } + } + + fields.push({ + config: {}, + name: 'Nested frames', + type: FieldType.nestedFrames, + values: subFrames, + }); + + processed.push({ + fields, + length: valuesByGroupKey.size, + }); + } + + return processed; + }) + ), +}; + +/** + * Given the appropriate data, create a sub-frame + * which can then be displayed in a sub-table. + */ +function createSubframe(fields: Field[], frameLength: number, options: GroupToNestedTableTransformerOptions) { + const showHeaders = + options.showSubframeHeaders === undefined ? SHOW_NESTED_HEADERS_DEFAULT : options.showSubframeHeaders; + + return { + meta: { custom: { noHeader: !showHeaders } }, + length: frameLength, + fields, + }; +} + +/** + * Determines whether a field should be grouped on. + * + * @returns boolean + * This will return _true_ if a field should be grouped on and _false_ if it should not. + */ +const shouldGroupOnField = (field: Field, options: GroupToNestedTableTransformerOptions): boolean => { + const fieldName = getFieldDisplayName(field); + return options?.fields[fieldName]?.operation === GroupByOperationID.groupBy; +}; + +/** + * Determines whether field aggregations should be calculated + * @returns boolean + * This will return _true_ if a field should be calculated and _false_ if it should not. + */ +const shouldCalculateField = (field: Field, options: GroupToNestedTableTransformerOptions): boolean => { + const fieldName = getFieldDisplayName(field); + return ( + options?.fields[fieldName]?.operation === GroupByOperationID.aggregate && + Array.isArray(options?.fields[fieldName].aggregations) && + options?.fields[fieldName].aggregations.length > 0 + ); +}; + +/** + * Detect the type of field given the relevant aggregation. + */ +const detectFieldType = (aggregation: string, sourceField: Field, targetField: Field): FieldType => { + switch (aggregation) { + case ReducerID.allIsNull: + return FieldType.boolean; + case ReducerID.last: + case ReducerID.lastNotNull: + case ReducerID.first: + case ReducerID.firstNotNull: + return sourceField.type; + default: + return guessFieldTypeForField(targetField) ?? FieldType.string; + } +}; + +/** + * Group values into subframes so that they'll be displayed + * inside of a subtable. + * + * @param valuesByGroupKey + * A mapping of group keys to their respective grouped values. + * @param options + * Transformation options, which are used to find ungrouped/unaggregated fields. + * @returns + */ +function groupToSubframes( + valuesByGroupKey: Map<string, FieldMap>, + options: GroupToNestedTableTransformerOptions +): DataFrame[][] { + const subFrames: DataFrame[][] = []; + + // Construct a subframe of any fields + // that aren't being group on or reduced + for (const [, value] of valuesByGroupKey) { + const nestedFields: Field[] = []; + + for (const [fieldName, field] of Object.entries(value)) { + const fieldOpts = options.fields[fieldName]; + + if (fieldOpts === undefined) { + nestedFields.push(field); + } + // Depending on the configuration form state all of the following are possible + else if ( + fieldOpts.aggregations === undefined || + (fieldOpts.operation === GroupByOperationID.aggregate && fieldOpts.aggregations.length === 0) || + fieldOpts.operation === null || + fieldOpts.operation === undefined + ) { + nestedFields.push(field); + } + } + + // If there are any values in the subfields + // push a new subframe with the fields + // otherwise push an empty frame + if (nestedFields.length > 0) { + subFrames.push([createSubframe(nestedFields, nestedFields[0].values.length, options)]); + } else { + subFrames.push([createSubframe([], 0, options)]); + } + } + + return subFrames; +} diff --git a/packages/grafana-data/src/transformations/transformers/histogram.test.ts b/packages/grafana-data/src/transformations/transformers/histogram.test.ts index 64a952d2196af..7bbd03397a015 100644 --- a/packages/grafana-data/src/transformations/transformers/histogram.test.ts +++ b/packages/grafana-data/src/transformations/transformers/histogram.test.ts @@ -22,6 +22,14 @@ describe('histogram frames frames', () => { fields: [{ name: 'C', type: FieldType.number, values: [5, 6, 7, 8, 9] }], }); + const series3 = toDataFrame({ + fields: [{ name: 'D', type: FieldType.number, values: [1, 2, 3, null, null] }], + }); + + const series4 = toDataFrame({ + fields: [{ name: 'E', type: FieldType.number, values: [4, 5, null, 6, null], config: { noValue: '0' } }], + }); + const out = histogramFieldsToFrame(buildHistogram([series1, series2])!); expect( out.fields.map((f) => ({ @@ -188,5 +196,87 @@ describe('histogram frames frames', () => { }, ] `); + + // NULLs filtering test + const out3 = histogramFieldsToFrame(buildHistogram([series3])!); + expect( + out3.fields.map((f) => ({ + name: f.name, + values: f.values, + })) + ).toMatchInlineSnapshot(` + [ + { + "name": "xMin", + "values": [ + 1, + 2, + 3, + ], + }, + { + "name": "xMax", + "values": [ + 2, + 3, + 4, + ], + }, + { + "name": "D", + "values": [ + 1, + 1, + 1, + ], + }, + ] + `); + + // noValue nulls test + const out4 = histogramFieldsToFrame(buildHistogram([series4])!); + expect( + out4.fields.map((f) => ({ + name: f.name, + values: f.values, + config: f.config, + })) + ).toMatchInlineSnapshot(` + [ + { + "config": {}, + "name": "xMin", + "values": [ + 0, + 4, + 5, + 6, + ], + }, + { + "config": {}, + "name": "xMax", + "values": [ + 1, + 5, + 6, + 7, + ], + }, + { + "config": { + "noValue": "0", + "unit": undefined, + }, + "name": "E", + "values": [ + 2, + 1, + 1, + 1, + ], + }, + ] + `); }); }); diff --git a/packages/grafana-data/src/transformations/transformers/histogram.ts b/packages/grafana-data/src/transformations/transformers/histogram.ts index 7edf737f62d34..39d54ffeda96a 100644 --- a/packages/grafana-data/src/transformations/transformers/histogram.ts +++ b/packages/grafana-data/src/transformations/transformers/histogram.ts @@ -8,6 +8,7 @@ import { roundDecimals } from '../../utils'; import { DataTransformerID } from './ids'; import { AlignedData, join } from './joinDataFrames'; +import { nullToValueField } from './nulls/nullToValue'; import { transformationsVariableSupport } from './utils'; /** @@ -38,10 +39,13 @@ export const histogramBucketSizes = [ ]; /* eslint-enable */ +const DEFAULT_BUCKET_COUNT = 30; + const histFilter: number[] = []; const histSort = (a: number, b: number) => a - b; export interface HistogramTransformerInputs { + bucketCount?: number; bucketSize?: string | number; bucketOffset?: string | number; combine?: boolean; @@ -51,6 +55,7 @@ export interface HistogramTransformerInputs { * @alpha */ export interface HistogramTransformerOptions { + bucketCount?: number; bucketSize?: number; // 0 is auto bucketOffset?: number; // xMin?: number; @@ -64,6 +69,10 @@ export interface HistogramTransformerOptions { * @internal */ export const histogramFieldInfo = { + bucketCount: { + name: 'Bucket count', + description: 'approx bucket count', + }, bucketSize: { name: 'Bucket size', description: undefined, @@ -318,15 +327,34 @@ export function getHistogramFields(frame: DataFrame): HistogramFields | undefine return undefined; } -const APPROX_BUCKETS = 20; - /** * @alpha */ export function buildHistogram(frames: DataFrame[], options?: HistogramTransformerOptions): HistogramFields | null { let bucketSize = options?.bucketSize; + let bucketCount = options?.bucketCount ?? DEFAULT_BUCKET_COUNT; let bucketOffset = options?.bucketOffset ?? 0; + // replace or filter nulls from numeric fields + frames = frames.map((frame) => { + return { + ...frame, + fields: frame.fields.map((field) => { + if (field.type === FieldType.number) { + const noValue = Number(field.config.noValue); + + if (!Number.isNaN(noValue)) { + field = nullToValueField(field, noValue); + } else { + field = { ...field, values: field.values.filter((v) => v != null) }; + } + } + + return field; + }), + }; + }); + // if bucket size is auto, try to calc from all numeric fields if (!bucketSize || bucketSize < 0) { let allValues: number[] = []; @@ -340,8 +368,6 @@ export function buildHistogram(frames: DataFrame[], options?: HistogramTransform } } - allValues = allValues.filter((v) => v != null); - allValues.sort((a, b) => a - b); let smallestDelta = Infinity; @@ -364,7 +390,7 @@ export function buildHistogram(frames: DataFrame[], options?: HistogramTransform let range = max - min; - const targetSize = range / APPROX_BUCKETS; + const targetSize = range / bucketCount; // choose bucket for (let i = 0; i < histogramBucketSizes.length; i++) { @@ -377,7 +403,7 @@ export function buildHistogram(frames: DataFrame[], options?: HistogramTransform } } - const getBucket = (v: number) => incrRoundDn(v - bucketOffset, bucketSize!) + bucketOffset; + const getBucket = (v: number) => roundDecimals(incrRoundDn(v - bucketOffset, bucketSize!) + bucketOffset, 9); // guess number of decimals let bucketDecimals = (('' + bucketSize).match(/\.\d+$/) ?? ['.'])[0].length - 1; @@ -456,7 +482,12 @@ export function buildHistogram(frames: DataFrame[], options?: HistogramTransform name: 'count', values: vals, type: FieldType.number, - state: undefined, + state: { + ...counts[0].state, + displayName: 'Count', + multipleFrames: false, + origin: { frameIndex: 0, fieldIndex: 2 }, + }, }, ]; } else { diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts index 4d5f7e2dd9e18..06ec0cd627726 100644 --- a/packages/grafana-data/src/transformations/transformers/ids.ts +++ b/packages/grafana-data/src/transformations/transformers/ids.ts @@ -40,4 +40,5 @@ export enum DataTransformerID { formatTime = 'formatTime', formatString = 'formatString', regression = 'regression', + groupToNestedTable = 'groupToNestedTable', } diff --git a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts index f19b6b0ccb75f..0b994b794023c 100644 --- a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts +++ b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts @@ -56,6 +56,16 @@ export interface JoinOptions { */ keepOriginIndices?: boolean; + /** + * @internal -- keep any pre-cached state.displayName + */ + keepDisplayNames?: boolean; + + /** + * @internal -- Optionally specify how to treat null values + */ + nullMode?: (field: Field) => JoinNullMode; + /** * @internal -- Optionally specify a join mode (outer or inner) */ @@ -90,6 +100,13 @@ export function joinDataFrames(options: JoinOptions): DataFrame | undefined { return; } + const nullMode = + options.nullMode ?? + ((field: Field) => { + let spanNulls = field.config.custom?.spanNulls; + return spanNulls === true ? NULL_REMOVE : spanNulls === -1 ? NULL_RETAIN : NULL_EXPAND; + }); + if (options.frames.length === 1) { let frame = options.frames[0]; let frameCopy = frame; @@ -181,8 +198,7 @@ export function joinDataFrames(options: JoinOptions): DataFrame | undefined { } // Support the standard graph span nulls field config - let spanNulls = field.config.custom?.spanNulls; - nullModesFrame.push(spanNulls === true ? NULL_REMOVE : spanNulls === -1 ? NULL_RETAIN : NULL_EXPAND); + nullModesFrame.push(nullMode(field)); let labels = field.labels ?? {}; let name = field.name; @@ -223,8 +239,10 @@ export function joinDataFrames(options: JoinOptions): DataFrame | undefined { for (const field of fields) { a.push(field.values); originalFields.push(field); - // clear field displayName state - delete field.state?.displayName; + if (!options.keepDisplayNames) { + // clear field displayName state + delete field.state?.displayName; + } // store frame field order for tabular data join frameFieldsOrder.push(fieldsOrder); fieldsOrder++; @@ -367,9 +385,9 @@ export type AlignedData = | [xValues: number[] | TypedArray, ...yValues: Array<Array<number | null | undefined> | TypedArray>]; // nullModes -const NULL_REMOVE = 0; // nulls are converted to undefined (e.g. for spanGaps: true) -const NULL_RETAIN = 1; // nulls are retained, with alignment artifacts set to undefined (default) -const NULL_EXPAND = 2; // nulls are expanded to include any adjacent alignment artifacts +export const NULL_REMOVE = 0; // nulls are converted to undefined (e.g. for spanGaps: true) +export const NULL_RETAIN = 1; // nulls are retained, with alignment artifacts set to undefined (default) +export const NULL_EXPAND = 2; // nulls are expanded to include any adjacent alignment artifacts type JoinNullMode = number; // NULL_IGNORE | NULL_RETAIN | NULL_EXPAND; diff --git a/packages/grafana-data/src/transformations/transformers/nulls/nullToValue.ts b/packages/grafana-data/src/transformations/transformers/nulls/nullToValue.ts index 4c3ba5a81e683..a2d2eeb88c7e6 100644 --- a/packages/grafana-data/src/transformations/transformers/nulls/nullToValue.ts +++ b/packages/grafana-data/src/transformations/transformers/nulls/nullToValue.ts @@ -1,27 +1,31 @@ -import { DataFrame } from '../../../types'; +import { DataFrame, Field } from '../../../types'; export function nullToValue(frame: DataFrame) { return { ...frame, fields: frame.fields.map((field) => { - const noValue = +field.config?.noValue!; + const noValue = Number(field.config.noValue); if (!Number.isNaN(noValue)) { - const transformedVals = field.values.slice(); - - for (let i = 0; i < transformedVals.length; i++) { - if (transformedVals[i] === null) { - transformedVals[i] = noValue; - } - } - - return { - ...field, - values: transformedVals, - }; + return nullToValueField(field, noValue); } else { return field; } }), }; } + +export function nullToValueField(field: Field, noValue: number) { + const transformedVals = field.values.slice(); + + for (let i = 0; i < transformedVals.length; i++) { + if (transformedVals[i] === null) { + transformedVals[i] = noValue; + } + } + + return { + ...field, + values: transformedVals, + }; +} diff --git a/packages/grafana-data/src/transformations/transformers/reduce.test.ts b/packages/grafana-data/src/transformations/transformers/reduce.test.ts index 95c175f66a0cc..3cb46763a540f 100644 --- a/packages/grafana-data/src/transformations/transformers/reduce.test.ts +++ b/packages/grafana-data/src/transformations/transformers/reduce.test.ts @@ -21,7 +21,7 @@ const seriesAWithMultipleFields = toDataFrame({ name: 'A', fields: [ { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, - { name: 'temperature', type: FieldType.number, values: [3, 4, 5, 6] }, + { name: 'temperature', type: FieldType.number, values: [3, 4, 5, 6, 6] }, { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, ], }); @@ -52,7 +52,14 @@ describe('Reducer Transformer', () => { const cfg = { id: DataTransformerID.reduce, options: { - reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.last], + reducers: [ + ReducerID.first, + ReducerID.min, + ReducerID.max, + ReducerID.last, + ReducerID.uniqueValues, + ReducerID.count, + ], }, }; @@ -90,6 +97,25 @@ describe('Reducer Transformer', () => { values: [6, 10000.6, 7, 11000.7], config: {}, }, + { + // expect type other + name: 'All unique values', + type: FieldType.other, + values: [ + [3, 4, 5, 6], + [10000.3, 10000.4, 10000.5, 10000.6], + [1, 3, 5, 7], + [11000.1, 11000.3, 11000.5, 11000.7], + ], + config: {}, + }, + { + // expect type number + name: 'Count', + type: FieldType.number, + values: [5, 4, 4, 4], + config: {}, + }, ]; expect(processed.length).toEqual(1); diff --git a/packages/grafana-data/src/transformations/transformers/reduce.ts b/packages/grafana-data/src/transformations/transformers/reduce.ts index 89c684e051d2d..d7e8ba8cda583 100644 --- a/packages/grafana-data/src/transformations/transformers/reduce.ts +++ b/packages/grafana-data/src/transformations/transformers/reduce.ts @@ -46,8 +46,8 @@ export const reduceTransformer: DataTransformerInfo<ReduceTransformerOptions> = const matcher = options.fields ? getFieldMatcher(options.fields) : options.includeTimeField && options.mode === ReduceTransformerMode.ReduceFields - ? alwaysFieldMatcher - : notTimeFieldMatcher; + ? alwaysFieldMatcher + : notTimeFieldMatcher; // Collapse all matching fields into a single row if (options.mode === ReduceTransformerMode.ReduceFields) { @@ -64,7 +64,7 @@ export const reduceTransformer: DataTransformerInfo<ReduceTransformerOptions> = /** * @internal only exported for testing */ -export function reduceSeriesToRows( +function reduceSeriesToRows( data: DataFrame[], matcher: FieldMatcher, reducerId: ReducerID[], @@ -156,7 +156,7 @@ export function reduceSeriesToRows( return mergeResults(processed); } -export function getDistinctLabelKeys(frames: DataFrame[]): string[] { +function getDistinctLabelKeys(frames: DataFrame[]): string[] { const keys = new Set<string>(); for (const frame of frames) { for (const field of frame.fields) { @@ -173,7 +173,7 @@ export function getDistinctLabelKeys(frames: DataFrame[]): string[] { /** * @internal only exported for testing */ -export function mergeResults(data: DataFrame[]): DataFrame | undefined { +function mergeResults(data: DataFrame[]): DataFrame | undefined { if (!data?.length) { return undefined; } @@ -211,7 +211,6 @@ export function reduceFields(data: DataFrame[], matcher: FieldMatcher, reducerId const calculators = fieldReducers.list(reducerId); const reducers = calculators.map((c) => c.id); const processed: DataFrame[] = []; - for (const series of data) { const fields: Field[] = []; for (const field of series.fields) { @@ -224,6 +223,7 @@ export function reduceFields(data: DataFrame[], matcher: FieldMatcher, reducerId const value = results[reducer]; const copy = { ...field, + type: getFieldType(reducer, field), values: [value], }; copy.state = undefined; @@ -248,3 +248,18 @@ export function reduceFields(data: DataFrame[], matcher: FieldMatcher, reducerId return processed; } + +function getFieldType(reducer: string, field: Field) { + switch (reducer) { + case ReducerID.allValues: + case ReducerID.uniqueValues: + return FieldType.other; + case ReducerID.first: + case ReducerID.firstNotNull: + case ReducerID.last: + case ReducerID.lastNotNull: + return field.type; + default: + return FieldType.number; + } +} diff --git a/packages/grafana-data/src/transformations/transformers/seriesToRows.test.ts b/packages/grafana-data/src/transformations/transformers/seriesToRows.test.ts index 49829c3e3777c..23f6de762256a 100644 --- a/packages/grafana-data/src/transformations/transformers/seriesToRows.test.ts +++ b/packages/grafana-data/src/transformations/transformers/seriesToRows.test.ts @@ -11,6 +11,33 @@ describe('Series to rows', () => { mockTransformationsRegistry([seriesToRowsTransformer]); }); + it('should do transform even with only one series', async () => { + const cfg: DataTransformerConfig<SeriesToRowsTransformerOptions> = { + id: DataTransformerID.seriesToRows, + options: {}, + }; + + const seriesA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1000] }, + { name: 'Temp', type: FieldType.number, values: [1] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA])).toEmitValuesWith((received) => { + const result = received[0]; + + const expected: Field[] = [ + createField('Time', FieldType.time, [1000]), + createField('Metric', FieldType.string, ['A']), + createField('Value', FieldType.number, [1]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + it('combine two series into one', async () => { const cfg: DataTransformerConfig<SeriesToRowsTransformerOptions> = { id: DataTransformerID.seriesToRows, diff --git a/packages/grafana-data/src/transformations/transformers/seriesToRows.ts b/packages/grafana-data/src/transformations/transformers/seriesToRows.ts index bbfcfcb4b147c..cf0793ef7d019 100644 --- a/packages/grafana-data/src/transformations/transformers/seriesToRows.ts +++ b/packages/grafana-data/src/transformations/transformers/seriesToRows.ts @@ -25,7 +25,7 @@ export const seriesToRowsTransformer: DataTransformerInfo<SeriesToRowsTransforme operator: (options) => (source) => source.pipe( map((data) => { - if (!Array.isArray(data) || data.length <= 1) { + if (!Array.isArray(data) || data.length === 0) { return data; } diff --git a/packages/grafana-data/src/transformations/transformers/sortBy.ts b/packages/grafana-data/src/transformations/transformers/sortBy.ts index 7cd99acce363d..5831581562120 100644 --- a/packages/grafana-data/src/transformations/transformers/sortBy.ts +++ b/packages/grafana-data/src/transformations/transformers/sortBy.ts @@ -43,7 +43,7 @@ export const sortByTransformer: DataTransformerInfo<SortByTransformerOptions> = ), }; -export function sortDataFrames(data: DataFrame[], sort: SortByField[], ctx: DataTransformContext): DataFrame[] { +function sortDataFrames(data: DataFrame[], sort: SortByField[], ctx: DataTransformContext): DataFrame[] { return data.map((frame) => { const s = attachFieldIndex(frame, sort, ctx); if (s.length && s[0].index != null) { diff --git a/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts b/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts index 0765f1987a334..b5a0e74f34f07 100644 --- a/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts +++ b/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts @@ -30,7 +30,7 @@ export interface OptionsEditorItem<TOptions, TSettings, TEditorProps, TValue> /** * Describes an API for option editors UI builder */ -export interface OptionsUIRegistryBuilderAPI< +interface OptionsUIRegistryBuilderAPI< TOptions, TEditorProps, T extends OptionsEditorItem<TOptions, any, TEditorProps, any>, diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index fcb58a7ccb1c4..626ab368bf24b 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -111,9 +111,7 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP return this; } - configureExtensionComponent<Context extends object>( - extension: Omit<PluginExtensionComponentConfig<Context>, 'type'> - ) { + configureExtensionComponent<Props = {}>(extension: Omit<PluginExtensionComponentConfig<Props>, 'type'>) { this._extensionConfigs.push({ ...extension, type: PluginExtensionTypes.component, diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 90a1ebbb3d2a5..def3242e7c54d 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -14,7 +14,10 @@ import { GrafanaTheme, IconName, NavLinkDTO, OrgRole } from '.'; * @public */ export interface BuildInfo { + // This MUST be a semver-ish version string, such as "11.0.0-54321" version: string; + // Version to show in the UI instead of version + versionString: string; commit: string; env: string; edition: GrafanaEdition; @@ -105,6 +108,7 @@ export interface AnalyticsSettings { export interface CurrentUserDTO { isSignedIn: boolean; id: number; + uid: string; externalUserId: string; login: string; email: string; @@ -135,7 +139,7 @@ export interface BootData { user: CurrentUserDTO; settings: GrafanaConfig; navTree: NavLinkDTO[]; - themePaths: { + assets: { light: string; dark: string; }; @@ -165,10 +169,6 @@ export interface GrafanaConfig { allowOrgCreate: boolean; disableLoginForm: boolean; defaultDatasource: string; - alertingEnabled: boolean; - alertingErrorOrTimeout: string; - alertingNoDataOrNullValues: string; - alertingMinInterval: number; authProxyEnabled: boolean; exploreEnabled: boolean; queryHistoryEnabled: boolean; @@ -224,9 +224,18 @@ export interface GrafanaConfig { rudderstackIntegrationsUrl: string | undefined; sqlConnectionLimits: SqlConnectionLimits; sharedWithMeFolderUID?: string; + rootFolderUID?: string; + localFileSystemAvailable?: boolean; + cloudMigrationIsTarget?: boolean; // The namespace to use for kubernetes apiserver requests namespace: string; + + /** + * Language used in Grafana's UI. This is after the user's preference (or deteceted locale) is resolved to one of + * Grafana's supported language. + */ + language: string | undefined; } export interface SqlConnectionLimits { @@ -259,4 +268,7 @@ export interface AuthSettings { GoogleSkipOrgRoleSync?: boolean; // @deprecated -- this is no longer used and will be removed in Grafana 11 GenericOAuthSkipOrgRoleSync?: boolean; + + disableLogin?: boolean; + basicAuthStrongPasswordPolicy?: boolean; } diff --git a/packages/grafana-data/src/types/data.ts b/packages/grafana-data/src/types/data.ts index b8130318bcfa0..13a0eb789dcea 100644 --- a/packages/grafana-data/src/types/data.ts +++ b/packages/grafana-data/src/types/data.ts @@ -33,6 +33,7 @@ export const preferredVisualizationTypes = [ export type PreferredVisualisationType = (typeof preferredVisualizationTypes)[number]; /** + * Should be kept in sync with https://github.com/grafana/grafana-plugin-sdk-go/blob/main/data/frame_meta.go * @public */ export interface QueryResultMeta { @@ -53,9 +54,6 @@ export interface QueryResultMeta { /** Meta Notices */ notices?: QueryResultMetaNotice[]; - /** Used to track transformation ids that where part of the processing */ - transformations?: string[]; - /** Currently used to show results in Explore only in preferred visualisation option */ preferredVisualisationType?: PreferredVisualisationType; @@ -107,6 +105,14 @@ export interface QueryResultMeta { limit?: number; // used by log models and loki json?: boolean; // used to keep track of old json doc values instant?: boolean; + + /** + * Array of field indices which values create a unique id for each row. Ideally this should be globally unique ID + * but that isn't guarantied. Should help with keeping track and deduplicating rows in visualizations, especially + * with streaming data with frequent updates. + * Example: TraceID in Tempo, table name + primary key in SQL + */ + uniqueRowIdFields?: number[]; } export interface QueryResultMetaStat extends FieldConfig { diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index 3b9d1a4326b26..28b4dbadf254d 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -5,7 +5,6 @@ import { DecimalCount, DisplayProcessor, DisplayValue, DisplayValueAlignmentFact import { FieldColor } from './fieldColor'; import { ThresholdsConfig } from './thresholds'; import { ValueMapping } from './valueMapping'; -import { Vector } from './vector'; /** @public */ export enum FieldType { @@ -13,13 +12,17 @@ export enum FieldType { number = 'number', string = 'string', boolean = 'boolean', + // Used to detect that the value is some kind of trace data to help with the visualisation and processing. trace = 'trace', geo = 'geo', enum = 'enum', other = 'other', // Object, Array, etc frame = 'frame', // DataFrame - nestedFrames = 'nestedFrames', // @alpha Nested DataFrames + + // @alpha Nested DataFrames. This is for example used with tables where expanding a row will show a nested table. + // The value should be DataFrame[] even if it is a single frame. + nestedFrames = 'nestedFrames', } /** @@ -68,7 +71,6 @@ export interface FieldConfig<TOptions = any> { // Numeric Options unit?: string; - unitScale?: boolean; decimals?: DecimalCount; // Significant digits (for display) min?: number | null; max?: number | null; @@ -130,7 +132,7 @@ export interface ValueLinkConfig { valueRowIndex?: number; } -export interface Field<T = any, V = Vector<T>> { +export interface Field<T = any> { /** * Name of the field (column) */ @@ -146,10 +148,8 @@ export interface Field<T = any, V = Vector<T>> { /** * The raw field values - * In Grafana 10, this accepts both simple arrays and the Vector interface - * In Grafana 11, the Vector interface will be removed */ - values: V | T[]; + values: T[]; /** * When type === FieldType.Time, this can optionally store @@ -261,7 +261,7 @@ export interface FieldDTO<T = any> { name: string; // The column name type?: FieldType; config?: FieldConfig; - values?: Vector<T> | T[]; // toJSON will always be T[], input could be either + values?: T[]; labels?: Labels; } diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index 68faf1d72e35e..ddf62d0d259bf 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -287,7 +287,7 @@ abstract class DataSourceApi< /** * Get tag keys for adhoc filters */ - getTagKeys?(options?: DataSourceGetTagKeysOptions): Promise<MetricFindValue[]>; + getTagKeys?(options?: DataSourceGetTagKeysOptions<TQuery>): Promise<MetricFindValue[]>; /** * Get tag values for adhoc filters @@ -367,7 +367,7 @@ abstract class DataSourceApi< /** * Options argument to DataSourceAPI.getTagKeys */ -export interface DataSourceGetTagKeysOptions { +export interface DataSourceGetTagKeysOptions<TQuery extends DataQuery = DataQuery> { /** * The other existing filters or base filters. New in v10.3 */ @@ -376,6 +376,7 @@ export interface DataSourceGetTagKeysOptions { * Context time range. New in v10.3 */ timeRange?: TimeRange; + queries?: TQuery[]; } /** @@ -428,10 +429,6 @@ export interface QueryEditorProps< */ data?: PanelData; range?: TimeRange; - /** - * @deprecated This is not used anymore and will be removed in a future release. - */ - exploreId?: string; history?: Array<HistoryItem<TQuery>>; queries?: DataQuery[]; app?: CoreApp; @@ -444,15 +441,6 @@ export enum ExploreMode { Tracing = 'Tracing', } -/** - * @deprecated use QueryEditorProps instead - */ -export type ExploreQueryFieldProps< - DSType extends DataSourceApi<TQuery, TOptions>, - TQuery extends DataQuery = DataQuery, - TOptions extends DataSourceJsonData = DataSourceJsonData, -> = QueryEditorProps<DSType, TQuery, TOptions>; - export interface QueryEditorHelpProps<TQuery extends DataQuery = DataQuery> { datasource: DataSourceApi<TQuery>; query: TQuery; @@ -554,10 +542,12 @@ export interface DataQueryRequest<TQuery extends DataQuery = DataQuery> { rangeRaw?: RawTimeRange; timeInfo?: string; // The query time description (blue text in the upper right) panelId?: number; + panelPluginId?: string; dashboardUID?: string; /** Filters to dynamically apply to all queries */ filters?: AdHocVariableFilter[]; + groupByKeys?: string[]; // Request Timing startTime: number; @@ -619,6 +609,7 @@ export interface DataSourceJsonData { profile?: string; manageAlerts?: boolean; alertmanagerUid?: string; + disableGrafanaCache?: boolean; } /** diff --git a/packages/grafana-data/src/types/explore.ts b/packages/grafana-data/src/types/explore.ts index 537104f041874..90f21ae568b57 100644 --- a/packages/grafana-data/src/types/explore.ts +++ b/packages/grafana-data/src/types/explore.ts @@ -58,9 +58,7 @@ export interface ExploreLogsPanelState { export interface SplitOpenOptions<T extends AnyQuery = AnyQuery> { datasourceUid: string; - /** @deprecated Will be removed in a future version. Use queries instead. */ - query?: T; - queries?: T[]; + queries: T[]; range?: TimeRange; panelsState?: ExplorePanelsState; correlationHelperData?: ExploreCorrelationHelperData; diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 20e089f1f9044..7fb36ef715377 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -31,30 +31,30 @@ export interface FeatureToggles { correlations?: boolean; exploreContentOutline?: boolean; datasourceQueryMultiStatus?: boolean; - traceToMetrics?: boolean; autoMigrateOldPanels?: boolean; + autoMigrateGraphPanel?: boolean; + autoMigrateTablePanel?: boolean; + autoMigratePiechartPanel?: boolean; + autoMigrateWorldmapPanel?: boolean; + autoMigrateStatPanel?: boolean; disableAngular?: boolean; canvasPanelNesting?: boolean; newVizTooltips?: boolean; scenes?: boolean; disableSecretsCompatibility?: boolean; logRequestsInstrumentedAsUnknown?: boolean; - dataConnectionsConsole?: boolean; topnav?: boolean; - dockedMegaMenu?: boolean; + returnToPrevious?: boolean; grpcServer?: boolean; unifiedStorage?: boolean; cloudWatchCrossAccountQuerying?: boolean; redshiftAsyncQueryDataSupport?: boolean; athenaAsyncQueryDataSupport?: boolean; - cloudwatchNewRegionsHandler?: boolean; showDashboardValidationWarnings?: boolean; mysqlAnsiQuotes?: boolean; accessControlOnCall?: boolean; nestedFolders?: boolean; nestedFolderPicker?: boolean; - emptyDashboardPage?: boolean; - disablePrometheusExemplarSampling?: boolean; alertingBacktesting?: boolean; editPanelCSVDragAndDrop?: boolean; alertingNoNormalState?: boolean; @@ -65,7 +65,7 @@ export interface FeatureToggles { prometheusMetricEncyclopedia?: boolean; influxdbBackendMigration?: boolean; influxqlStreamingParser?: boolean; - clientTokenRotation?: boolean; + influxdbRunQueriesInParallel?: boolean; prometheusDataplane?: boolean; lokiMetricDataplane?: boolean; lokiLogsDataplane?: boolean; @@ -76,11 +76,8 @@ export interface FeatureToggles { alertStateHistoryLokiOnly?: boolean; unifiedRequestLog?: boolean; renderAuthJWT?: boolean; - externalServiceAuth?: boolean; refactorVariablesTimeRange?: boolean; - useCachingService?: boolean; enableElasticsearchBackendQuerying?: boolean; - advancedDataSourcePicker?: boolean; faroDatasourceSelector?: boolean; enableDatagridEditing?: boolean; extraThemes?: boolean; @@ -90,8 +87,6 @@ export interface FeatureToggles { frontendSandboxMonitorOnly?: boolean; sqlDatasourceDatabaseSelection?: boolean; lokiFormatQuery?: boolean; - cloudWatchLogsMonacoEditor?: boolean; - exploreScrollableLogsContainer?: boolean; recordedQueriesMulti?: boolean; pluginsDynamicAngularDetectionPatterns?: boolean; vizAndWidgetSplit?: boolean; @@ -102,14 +97,10 @@ export interface FeatureToggles { mlExpressions?: boolean; traceQLStreaming?: boolean; metricsSummary?: boolean; - grafanaAPIServer?: boolean; grafanaAPIServerWithExperimentalAPIs?: boolean; grafanaAPIServerEnsureKubectlAccess?: boolean; featureToggleAdminPage?: boolean; awsAsyncQueryCaching?: boolean; - splitScopes?: boolean; - traceToProfiles?: boolean; - tracesEmbeddedFlameGraph?: boolean; permissionsFilterRemoveSubquery?: boolean; prometheusConfigOverhaulAuth?: boolean; configurableSchedulerTick?: boolean; @@ -117,18 +108,16 @@ export interface FeatureToggles { alertingNoDataErrorExecution?: boolean; angularDeprecationUI?: boolean; dashgpt?: boolean; + aiGeneratedDashboardChanges?: boolean; reportingRetries?: boolean; sseGroupByDatasource?: boolean; - requestInstrumentationStatusSource?: boolean; libraryPanelRBAC?: boolean; lokiRunQueriesInParallel?: boolean; wargamesTesting?: boolean; alertingInsights?: boolean; externalCorePlugins?: boolean; pluginsAPIMetrics?: boolean; - httpSLOLevels?: boolean; idForwarding?: boolean; - cloudWatchWildCardDimensionValues?: boolean; externalServiceAccounts?: boolean; panelMonitoring?: boolean; enableNativeHTTPHistogram?: boolean; @@ -136,6 +125,7 @@ export interface FeatureToggles { transformationsVariableSupport?: boolean; kubernetesPlaylists?: boolean; kubernetesSnapshots?: boolean; + kubernetesQueryServiceRewrite?: boolean; cloudWatchBatchQueries?: boolean; recoveryThreshold?: boolean; lokiStructuredMetadata?: boolean; @@ -143,8 +133,6 @@ export interface FeatureToggles { awsDatasourcesNewFormStyling?: boolean; cachingOptimizeSerializationMemoryUsage?: boolean; panelTitleSearchInV1?: boolean; - pluginsInstrumentationStatusSource?: boolean; - costManagementUi?: boolean; managedPluginsInstall?: boolean; prometheusPromQAIL?: boolean; addFieldFromCalculationStatFunctions?: boolean; @@ -154,6 +142,7 @@ export interface FeatureToggles { annotationPermissionUpdate?: boolean; extractFieldsNameDeduplication?: boolean; dashboardSceneForViewers?: boolean; + dashboardSceneSolo?: boolean; dashboardScene?: boolean; panelFilterVariable?: boolean; pdfTables?: boolean; @@ -161,14 +150,31 @@ export interface FeatureToggles { canvasPanelPanZoom?: boolean; logsInfiniteScrolling?: boolean; flameGraphItemCollapsing?: boolean; - alertingDetailsViewV2?: boolean; datatrails?: boolean; alertingSimplifiedRouting?: boolean; logRowsPopoverMenu?: boolean; pluginsSkipHostEnvVars?: boolean; tableSharedCrosshair?: boolean; regressionTransformation?: boolean; - displayAnonymousStats?: boolean; - alertStateHistoryAnnotationsFromLoki?: boolean; lokiQueryHints?: boolean; + kubernetesFeatureToggles?: boolean; + enablePluginsTracingByDefault?: boolean; + cloudRBACRoles?: boolean; + alertingQueryOptimization?: boolean; + newFolderPicker?: boolean; + jitterAlertRulesWithinGroups?: boolean; + onPremToCloudMigrations?: boolean; + alertingSaveStatePeriodic?: boolean; + promQLScope?: boolean; + sqlExpressions?: boolean; + nodeGraphDotLayout?: boolean; + groupToNestedTableTransformation?: boolean; + newPDFRendering?: boolean; + kubernetesAggregator?: boolean; + expressionParser?: boolean; + groupByVariable?: boolean; + betterPageScrolling?: boolean; + scopeFilters?: boolean; + emailVerificationEnforcement?: boolean; + ssoSettingsSAML?: boolean; } diff --git a/packages/grafana-data/src/types/fieldOverrides.ts b/packages/grafana-data/src/types/fieldOverrides.ts index dad3547523c42..1ca74a3be0628 100644 --- a/packages/grafana-data/src/types/fieldOverrides.ts +++ b/packages/grafana-data/src/types/fieldOverrides.ts @@ -46,8 +46,7 @@ export interface SystemConfigOverrideRule extends ConfigOverrideRule { */ export function isSystemOverrideWithRef<T extends SystemConfigOverrideRule>(ref: string) { return (override: ConfigOverrideRule): override is T => { - const overrideAs = override as T; - return overrideAs.__systemRef === ref; + return '__systemRef' in override && override.__systemRef === ref; }; } @@ -58,7 +57,7 @@ export function isSystemOverrideWithRef<T extends SystemConfigOverrideRule>(ref: * @internal */ export const isSystemOverride = (override: ConfigOverrideRule): override is SystemConfigOverrideRule => { - return typeof (override as SystemConfigOverrideRule)?.__systemRef === 'string'; + return '__systemRef' in override && typeof override.__systemRef === 'string'; }; export interface FieldConfigSource<TOptions = any> { @@ -69,7 +68,7 @@ export interface FieldConfigSource<TOptions = any> { overrides: ConfigOverrideRule[]; } -export interface FieldOverrideContext extends StandardEditorContext<any, any> { +export interface FieldOverrideContext extends StandardEditorContext<any> { field?: Field; dataFrameIndex?: number; // The index for the selected field frame } diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index c1889cf1b6ff4..6b3a862a301f4 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -23,6 +23,7 @@ export const availableIconsIndex = { 'align-right': true, 'application-observability': true, apps: true, + 'archive-alt': true, arrow: true, 'arrow-down': true, 'arrow-from-right': true, @@ -33,6 +34,7 @@ export const availableIconsIndex = { 'arrow-up': true, 'arrows-h': true, 'arrows-v': true, + asserts: true, 'expand-arrows': true, at: true, ai: true, @@ -81,6 +83,7 @@ export const availableIconsIndex = { dashboard: true, database: true, 'dice-three': true, + docker: true, 'document-info': true, 'download-alt': true, draggabledots: true, @@ -101,6 +104,7 @@ export const availableIconsIndex = { 'file-blank': true, 'file-copy-alt': true, 'file-download': true, + 'file-edit-alt': true, 'file-landscape-alt': true, filter: true, flip: true, @@ -133,6 +137,7 @@ export const availableIconsIndex = { 'gf-pin': true, 'gf-prometheus': true, 'gf-traces': true, + globe: true, grafana: true, 'graph-bar': true, heart: true, @@ -221,6 +226,7 @@ export const availableIconsIndex = { 'toggle-on': true, 'toggle-off': true, 'trash-alt': true, + unarchive: true, unlock: true, upload: true, user: true, diff --git a/packages/grafana-data/src/types/logs.ts b/packages/grafana-data/src/types/logs.ts index e578e48209181..d9edfca6bc2e8 100644 --- a/packages/grafana-data/src/types/logs.ts +++ b/packages/grafana-data/src/types/logs.ts @@ -4,7 +4,7 @@ import { DataQuery } from '@grafana/schema'; import { KeyValue, Labels } from './data'; import { DataFrame } from './dataFrame'; -import { DataQueryRequest, DataQueryResponse, QueryFixAction, QueryFixType } from './datasource'; +import { DataQueryRequest, DataQueryResponse, DataSourceApi, QueryFixAction, QueryFixType } from './datasource'; import { AbsoluteTimeRange } from './time'; export { LogsDedupStrategy, LogsSortOrder } from '@grafana/schema'; @@ -185,6 +185,7 @@ export type SupplementaryQueryOptions = LogsVolumeOption | LogsSampleOptions; */ export type LogsVolumeOption = { type: SupplementaryQueryType.LogsVolume; + field?: string; }; /** @@ -225,36 +226,45 @@ export interface DataSourceWithSupplementaryQueriesSupport<TQuery extends DataQu /** * Returns an observable that will be used to fetch supplementary data based on the provided * supplementary query type and original request. + * @deprecated Use getSupplementaryQueryRequest() instead */ - getDataProvider( + getDataProvider?( type: SupplementaryQueryType, request: DataQueryRequest<TQuery> ): Observable<DataQueryResponse> | undefined; + /** + * Receives a SupplementaryQueryType and a DataQueryRequest and returns a new DataQueryRequest to fetch supplementary data. + * If provided type or request is not suitable for a supplementary data request, returns undefined. + */ + getSupplementaryRequest?( + type: SupplementaryQueryType, + request: DataQueryRequest<TQuery>, + options?: SupplementaryQueryOptions + ): DataQueryRequest<TQuery> | undefined; /** * Returns supplementary query types that data source supports. */ getSupportedSupplementaryQueryTypes(): SupplementaryQueryType[]; /** * Returns a supplementary query to be used to fetch supplementary data based on the provided type and original query. - * If provided query is not suitable for provided supplementary query type, undefined should be returned. + * If the provided query is not suitable for the provided supplementary query type, undefined should be returned. */ getSupplementaryQuery(options: SupplementaryQueryOptions, originalQuery: TQuery): TQuery | undefined; } export const hasSupplementaryQuerySupport = <TQuery extends DataQuery>( - datasource: unknown, + datasource: DataSourceApi | (DataSourceApi & DataSourceWithSupplementaryQueriesSupport<TQuery>), type: SupplementaryQueryType -): datasource is DataSourceWithSupplementaryQueriesSupport<TQuery> => { +): datasource is DataSourceApi & DataSourceWithSupplementaryQueriesSupport<TQuery> => { if (!datasource) { return false; } - const withSupplementaryQueriesSupport = datasource as DataSourceWithSupplementaryQueriesSupport<TQuery>; - return ( - withSupplementaryQueriesSupport.getDataProvider !== undefined && - withSupplementaryQueriesSupport.getSupplementaryQuery !== undefined && - withSupplementaryQueriesSupport.getSupportedSupplementaryQueryTypes().includes(type) + ('getDataProvider' in datasource || 'getSupplementaryRequest' in datasource) && + 'getSupplementaryQuery' in datasource && + 'getSupportedSupplementaryQueryTypes' in datasource && + datasource.getSupportedSupplementaryQueryTypes().includes(type) ); }; diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index 0f6acfe430aee..066d879df3d19 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -26,6 +26,8 @@ export interface NavLinkDTO { pluginId?: string; // Whether the page is used to create a new resource. We may place these in a different position in the UI. isCreateAction?: boolean; + // Optional keywords to match on when searching (e.g. in the CommandPalette) + keywords?: string[]; } export interface NavModelItem extends NavLinkDTO { diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index 7d07e91f0189d..5c934d3b299e6 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -121,6 +121,10 @@ export interface PluginInclude { // "Admin", "Editor" or "Viewer". If set then the include will only show up in the navigation if the user has the required roles. role?: string; + // if action is set then the include will only show up in the navigation if the user has the required permission. + // The action will take precedence over the role. + action?: string; + // Adds the "page" or "dashboard" type includes to the navigation if set to `true`. addToNav?: boolean; diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 68fa3c7d1d889..e7a26223c34d7 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -32,9 +32,9 @@ export type PluginExtensionLink = PluginExtensionBase & { category?: string; }; -export type PluginExtensionComponent = PluginExtensionBase & { +export type PluginExtensionComponent<Props = {}> = PluginExtensionBase & { type: PluginExtensionTypes.component; - component: React.ComponentType; + component: React.ComponentType<Props>; }; export type PluginExtension = PluginExtensionLink | PluginExtensionComponent; @@ -77,16 +77,14 @@ export type PluginExtensionLinkConfig<Context extends object = object> = { category?: string; }; -export type PluginExtensionComponentConfig<Context extends object = object> = { +export type PluginExtensionComponentConfig<Props = {}> = { type: PluginExtensionTypes.component; title: string; description: string; // The React component that will be rendered as the extension - // (This component receives the context as a prop when it is rendered. You can just return `null` from the component to hide for certain contexts) - component: React.ComponentType<{ - context?: Context; - }>; + // (This component receives contextual information as props when it is rendered. You can just return `null` from the component to hide it.) + component: React.ComponentType<Props>; // The unique identifier of the Extension Point // (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum) diff --git a/packages/grafana-data/src/types/query.ts b/packages/grafana-data/src/types/query.ts index 30579b1e7dcfb..870a0b95094ba 100644 --- a/packages/grafana-data/src/types/query.ts +++ b/packages/grafana-data/src/types/query.ts @@ -1,4 +1,8 @@ -import { DataQuery as SchemaDataQuery, DataSourceRef as SchemaDataSourceRef } from '@grafana/schema'; +import { + DataQuery as SchemaDataQuery, + DataSourceRef as SchemaDataSourceRef, + DataTopic as SchemaDataTopic, +} from '@grafana/schema'; /** * @deprecated use the type from @grafana/schema @@ -13,12 +17,9 @@ export interface DataSourceRef extends SchemaDataSourceRef {} /** * Attached to query results (not persisted) * - * @public + * @deprecated use the type from @grafana/schema */ -export enum DataTopic { - Annotations = 'annotations', - AlertStates = 'alertStates', -} +export { SchemaDataTopic as DataTopic }; /** * Abstract representation of any label-based query diff --git a/packages/grafana-data/src/types/templateVars.ts b/packages/grafana-data/src/types/templateVars.ts index 86d5c50594d61..1b01e5b9e537d 100644 --- a/packages/grafana-data/src/types/templateVars.ts +++ b/packages/grafana-data/src/types/templateVars.ts @@ -1,4 +1,5 @@ import { LoadingState } from './data'; +import { MetricFindValue } from './datasource'; import { DataSourceRef } from './query'; export type VariableType = TypedVariableModel['type']; @@ -13,6 +14,7 @@ export interface VariableModel { export type TypedVariableModel = | QueryVariableModel | AdHocVariableModel + | GroupByVariableModel | ConstantVariableModel | DataSourceVariableModel | IntervalVariableModel @@ -62,6 +64,16 @@ export interface AdHocVariableModel extends BaseVariableModel { * Filters that are always applied to the lookup of keys. Not shown in the AdhocFilterBuilder UI. */ baseFilters?: AdHocVariableFilter[]; + /** + * Static keys that override any dynamic keys from the datasource. + */ + defaultKeys?: MetricFindValue[]; +} + +export interface GroupByVariableModel extends VariableWithOptions { + type: 'groupby'; + datasource: DataSourceRef | null; + multi: true; } export interface VariableOption { @@ -164,4 +176,5 @@ export interface BaseVariableModel { state: LoadingState; error: any | null; description: string | null; + usedInRepeat?: boolean; } diff --git a/packages/grafana-data/src/types/vector.ts b/packages/grafana-data/src/types/vector.ts index be903d96abf56..06358553e9a2a 100644 --- a/packages/grafana-data/src/types/vector.ts +++ b/packages/grafana-data/src/types/vector.ts @@ -53,82 +53,3 @@ export function patchArrayVectorProrotypeMethods() { } //this function call is intentional patchArrayVectorProrotypeMethods(); - -/** @deprecated use a simple Array<T> */ -export interface Vector<T = any> extends Array<T> { - length: number; - - /** - * Access the value by index (Like an array) - * - * @deprecated use a simple Array<T> - */ - get(index: number): T; - - /** - * Set a value - * - * @deprecated use a simple Array<T> - */ - set: (index: number, value: T) => void; - - /** - * Adds the value to the vector - * Same as Array.push() - * - * @deprecated use a simple Array<T> - */ - add: (value: T) => void; - - /** - * Get the results as an array. - * - * @deprecated use a simple Array<T> - */ - toArray(): T[]; -} - -/** - * Apache arrow vectors are Read/Write - * - * @deprecated -- this is now part of the base Vector interface - */ -export interface ReadWriteVector<T = any> extends Vector<T> {} - -/** - * Vector with standard manipulation functions - * - * @deprecated -- this is now part of the base Vector interface - */ -export interface MutableVector<T = any> extends ReadWriteVector<T> {} - -/** - * This is an extremely inefficient Vector wrapper that allows vectors to - * be treated as arrays. We should avoid using this wrapper, but it is helpful - * for a clean migration to arrays - * - * @deprecated - */ -export function makeArrayIndexableVector<T extends Vector>(v: T): T { - return new Proxy<T>(v, { - get(target: Vector, property: string, receiver: Vector) { - if (typeof property !== 'symbol') { - const idx = +property; - if (String(idx) === property) { - return target.get(idx); - } - } - return Reflect.get(target, property, receiver); - }, - set(target: Vector, property: string, value: unknown, receiver: Vector) { - if (typeof property !== 'symbol') { - const idx = +property; - if (String(idx) === property) { - target.set(idx, value); - return true; - } - } - return Reflect.set(target, property, value, receiver); - }, - }); -} diff --git a/packages/grafana-data/src/utils/OptionsUIBuilders.ts b/packages/grafana-data/src/utils/OptionsUIBuilders.ts index 0743f1008e10f..87c70303ac937 100644 --- a/packages/grafana-data/src/utils/OptionsUIBuilders.ts +++ b/packages/grafana-data/src/utils/OptionsUIBuilders.ts @@ -1,3 +1,5 @@ +import { set, cloneDeep } from 'lodash'; + import { numberOverrideProcessor, selectOverrideProcessor, @@ -185,7 +187,24 @@ export class NestedPanelOptionsBuilder<TSub = any> implements OptionsEditorItem< constructor(public cfg: NestedPanelOptions<TSub>) { this.path = cfg.path; this.category = cfg.category; - this.defaultValue = cfg.defaultValue; + this.defaultValue = this.getDefaultValue(cfg); + } + + private getDefaultValue(cfg: NestedPanelOptions<TSub>): TSub { + let result = isObject(cfg.defaultValue) ? cloneDeep(cfg.defaultValue) : {}; + + const builder = new PanelOptionsEditorBuilder<TSub>(); + cfg.build(builder, { data: [] }); + + for (const item of builder.getItems()) { + if (item.defaultValue != null) { + set(result, item.path, item.defaultValue); + } + } + + // TSub is defined as type any and we need to cast it back + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return result as TSub; } getBuilder = () => { diff --git a/packages/grafana-data/src/utils/arrayUtils.test.ts b/packages/grafana-data/src/utils/arrayUtils.test.ts index b41c3e728b7d6..132580a6415c1 100644 --- a/packages/grafana-data/src/utils/arrayUtils.test.ts +++ b/packages/grafana-data/src/utils/arrayUtils.test.ts @@ -1,6 +1,6 @@ import { SortOrder } from '@grafana/schema'; -import { sortValues } from './arrayUtils'; +import { insertAfterImmutably, insertBeforeImmutably, sortValues } from './arrayUtils'; describe('arrayUtils', () => { describe('sortValues', () => { @@ -30,4 +30,52 @@ describe('arrayUtils', () => { expect(sorted).toEqual(expected); }); }); + + describe('insertBeforeImmutably', () => { + const original = [1, 2, 3]; + + it.each` + item | index | expected + ${4} | ${1} | ${[1, 4, 2, 3]} + ${4} | ${2} | ${[1, 2, 4, 3]} + ${0} | ${0} | ${[0, 1, 2, 3]} + `('add $item before $index', ({ item, index, expected }) => { + const output = insertBeforeImmutably(original, item, index); + expect(output).toStrictEqual(expected); + }); + + it('should throw when out of bounds', () => { + expect(() => { + insertBeforeImmutably([], 1, -1); + }).toThrow(); + + expect(() => { + insertBeforeImmutably([], 1, 3); + }).toThrow(); + }); + }); + + describe('insertAfterImmutably', () => { + const original = [1, 2, 3]; + + it.each` + item | index | expected + ${4} | ${1} | ${[1, 2, 4, 3]} + ${4} | ${0} | ${[1, 4, 2, 3]} + ${4} | ${2} | ${[1, 2, 3, 4]} + `('add $item after $index', ({ item, index, expected }) => { + const output = insertAfterImmutably(original, item, index); + expect(output).toStrictEqual(expected); + }); + + it('should throw when out of bounds', () => { + expect(() => { + insertAfterImmutably([], 1, -1); + }).toThrow(); + + expect(() => { + insertAfterImmutably([], 1, 3); + }).toThrow(); + }); + }); }); diff --git a/packages/grafana-data/src/utils/arrayUtils.ts b/packages/grafana-data/src/utils/arrayUtils.ts index fe56786fedc24..843c4d01e9545 100644 --- a/packages/grafana-data/src/utils/arrayUtils.ts +++ b/packages/grafana-data/src/utils/arrayUtils.ts @@ -7,6 +7,30 @@ export function moveItemImmutably<T>(arr: T[], from: number, to: number) { return clone; } +/** @internal */ +export function insertBeforeImmutably<T>(array: T[], item: T, index: number): T[] { + if (index < 0 || index > array.length) { + throw new Error('Index out of bounds'); + } + + const clone = [...array]; + clone.splice(index, 0, item); + + return clone; +} + +/** @internal */ +export function insertAfterImmutably<T>(array: T[], item: T, index: number): T[] { + if (index < 0 || index > array.length) { + throw new Error('Index out of bounds'); + } + + const clone = [...array]; + clone.splice(index + 1, 0, item); + + return clone; +} + /** * Given a sort order and a value, return a function that can be used to sort values * Null/undefined/empty string values are always sorted to the end regardless of the sort order provided diff --git a/packages/grafana-data/src/utils/location.ts b/packages/grafana-data/src/utils/location.ts index 44d42f247a5ce..5c0a2167025e5 100644 --- a/packages/grafana-data/src/utils/location.ts +++ b/packages/grafana-data/src/utils/location.ts @@ -69,7 +69,7 @@ const assureBaseUrl = (url: string): string => { * @param searchParamsToUpdate * @returns */ -const getUrlForPartial = (location: Location<any>, searchParamsToUpdate: UrlQueryMap) => { +const getUrlForPartial = (location: Location, searchParamsToUpdate: UrlQueryMap) => { const searchParams = urlUtil.parseKeyValue( location.search.startsWith('?') ? location.search.substring(1) : location.search ); diff --git a/packages/grafana-data/src/utils/nodeGraph.ts b/packages/grafana-data/src/utils/nodeGraph.ts index b846348a7a700..4cb05d054c3cf 100644 --- a/packages/grafana-data/src/utils/nodeGraph.ts +++ b/packages/grafana-data/src/utils/nodeGraph.ts @@ -15,7 +15,7 @@ export enum NodeGraphDataFrameFieldNames { // grafana/ui [nodes] icon = 'icon', // Defines a single color if string (hex or html named value) or color mode config can be used as threshold or - // gradient. arc__ fields must not be defined if used [nodes] + // gradient. arc__ fields must not be defined if used [nodes + edges] color = 'color', // Id of the source node [required] [edges] @@ -32,6 +32,10 @@ export enum NodeGraphDataFrameFieldNames { // Thickness of the edge [edges] thickness = 'thickness', - // Whether the node or edge should be highlighted (e.g., shown in red) in the UI + // Whether the node or edge should be highlighted (e.g., shown in red) in the UI [nodes + edges] + // @deprecated -- for edges use color instead highlighted = 'highlighted', + + // Defines the stroke dash array for the edge [edges]. See SVG strokeDasharray definition for syntax. + strokeDasharray = 'strokedasharray', } diff --git a/packages/grafana-data/src/valueFormats/categories.ts b/packages/grafana-data/src/valueFormats/categories.ts index d1793c68ff0c5..79ef525e249f4 100644 --- a/packages/grafana-data/src/valueFormats/categories.ts +++ b/packages/grafana-data/src/valueFormats/categories.ts @@ -34,7 +34,7 @@ import { booleanValueFormatter, } from './valueFormats'; -export const getCategories = (scalable = true): ValueFormatCategory[] => [ +export const getCategories = (): ValueFormatCategory[] => [ { name: 'Misc', formats: [ @@ -45,6 +45,7 @@ export const getCategories = (scalable = true): ValueFormatCategory[] => [ id: 'short', fn: scaledUnits(1000, ['', ' K', ' Mil', ' Bil', ' Tri', ' Quadr', ' Quint', ' Sext', ' Sept']), }, + { name: 'SI short', id: 'sishort', fn: SIPrefix('') }, { name: 'Percent (0-100)', id: 'percent', fn: toPercent }, { name: 'Percent (0.0-1.0)', id: 'percentunit', fn: toPercentUnit }, { name: 'Humidity (%H)', id: 'humidity', fn: toFixedUnit('%H') }, @@ -88,14 +89,14 @@ export const getCategories = (scalable = true): ValueFormatCategory[] => [ { name: 'Computation', formats: [ - { name: 'FLOP/s', id: 'flops', fn: SIPrefix('FLOPS', 0, scalable) }, - { name: 'MFLOP/s', id: 'mflops', fn: SIPrefix('FLOPS', 2, scalable) }, - { name: 'GFLOP/s', id: 'gflops', fn: SIPrefix('FLOPS', 3, scalable) }, - { name: 'TFLOP/s', id: 'tflops', fn: SIPrefix('FLOPS', 4, scalable) }, - { name: 'PFLOP/s', id: 'pflops', fn: SIPrefix('FLOPS', 5, scalable) }, - { name: 'EFLOP/s', id: 'eflops', fn: SIPrefix('FLOPS', 6, scalable) }, - { name: 'ZFLOP/s', id: 'zflops', fn: SIPrefix('FLOPS', 7, scalable) }, - { name: 'YFLOP/s', id: 'yflops', fn: SIPrefix('FLOPS', 8, scalable) }, + { name: 'FLOP/s', id: 'flops', fn: SIPrefix('FLOPS') }, + { name: 'MFLOP/s', id: 'mflops', fn: SIPrefix('FLOPS', 2) }, + { name: 'GFLOP/s', id: 'gflops', fn: SIPrefix('FLOPS', 3) }, + { name: 'TFLOP/s', id: 'tflops', fn: SIPrefix('FLOPS', 4) }, + { name: 'PFLOP/s', id: 'pflops', fn: SIPrefix('FLOPS', 5) }, + { name: 'EFLOP/s', id: 'eflops', fn: SIPrefix('FLOPS', 6) }, + { name: 'ZFLOP/s', id: 'zflops', fn: SIPrefix('FLOPS', 7) }, + { name: 'YFLOP/s', id: 'yflops', fn: SIPrefix('FLOPS', 8) }, ], }, { @@ -145,55 +146,56 @@ export const getCategories = (scalable = true): ValueFormatCategory[] => [ { name: 'Malaysian Ringgit (RM)', id: 'currencyMYR', fn: currency('RM') }, { name: 'CFP franc (XPF)', id: 'currencyXPF', fn: currency('XPF') }, { name: 'Bulgarian Lev (BGN)', id: 'currencyBGN', fn: currency('BGN') }, + { name: 'Guaraní (₲)', id: 'currencyPYG', fn: currency('₲') }, ], }, { name: 'Data', formats: [ { name: 'bytes(IEC)', id: 'bytes', fn: binaryPrefix('B') }, - { name: 'bytes(SI)', id: 'decbytes', fn: SIPrefix('B', 0, scalable) }, + { name: 'bytes(SI)', id: 'decbytes', fn: SIPrefix('B') }, { name: 'bits(IEC)', id: 'bits', fn: binaryPrefix('b') }, - { name: 'bits(SI)', id: 'decbits', fn: SIPrefix('b', 0, scalable) }, + { name: 'bits(SI)', id: 'decbits', fn: SIPrefix('b') }, { name: 'kibibytes', id: 'kbytes', fn: binaryPrefix('B', 1) }, - { name: 'kilobytes', id: 'deckbytes', fn: SIPrefix('B', 1, scalable) }, + { name: 'kilobytes', id: 'deckbytes', fn: SIPrefix('B', 1) }, { name: 'mebibytes', id: 'mbytes', fn: binaryPrefix('B', 2) }, - { name: 'megabytes', id: 'decmbytes', fn: SIPrefix('B', 2, scalable) }, + { name: 'megabytes', id: 'decmbytes', fn: SIPrefix('B', 2) }, { name: 'gibibytes', id: 'gbytes', fn: binaryPrefix('B', 3) }, - { name: 'gigabytes', id: 'decgbytes', fn: SIPrefix('B', 3, scalable) }, + { name: 'gigabytes', id: 'decgbytes', fn: SIPrefix('B', 3) }, { name: 'tebibytes', id: 'tbytes', fn: binaryPrefix('B', 4) }, - { name: 'terabytes', id: 'dectbytes', fn: SIPrefix('B', 4, scalable) }, + { name: 'terabytes', id: 'dectbytes', fn: SIPrefix('B', 4) }, { name: 'pebibytes', id: 'pbytes', fn: binaryPrefix('B', 5) }, - { name: 'petabytes', id: 'decpbytes', fn: SIPrefix('B', 5, scalable) }, + { name: 'petabytes', id: 'decpbytes', fn: SIPrefix('B', 5) }, ], }, { name: 'Data rate', formats: [ - { name: 'packets/sec', id: 'pps', fn: SIPrefix('p/s', 0, scalable) }, + { name: 'packets/sec', id: 'pps', fn: SIPrefix('p/s') }, { name: 'bytes/sec(IEC)', id: 'binBps', fn: binaryPrefix('B/s') }, - { name: 'bytes/sec(SI)', id: 'Bps', fn: SIPrefix('B/s', 0, scalable) }, + { name: 'bytes/sec(SI)', id: 'Bps', fn: SIPrefix('B/s') }, { name: 'bits/sec(IEC)', id: 'binbps', fn: binaryPrefix('b/s') }, - { name: 'bits/sec(SI)', id: 'bps', fn: SIPrefix('b/s', 0, scalable) }, + { name: 'bits/sec(SI)', id: 'bps', fn: SIPrefix('b/s') }, { name: 'kibibytes/sec', id: 'KiBs', fn: binaryPrefix('B/s', 1) }, { name: 'kibibits/sec', id: 'Kibits', fn: binaryPrefix('b/s', 1) }, - { name: 'kilobytes/sec', id: 'KBs', fn: SIPrefix('B/s', 1, scalable) }, - { name: 'kilobits/sec', id: 'Kbits', fn: SIPrefix('b/s', 1, scalable) }, + { name: 'kilobytes/sec', id: 'KBs', fn: SIPrefix('B/s', 1) }, + { name: 'kilobits/sec', id: 'Kbits', fn: SIPrefix('b/s', 1) }, { name: 'mebibytes/sec', id: 'MiBs', fn: binaryPrefix('B/s', 2) }, { name: 'mebibits/sec', id: 'Mibits', fn: binaryPrefix('b/s', 2) }, - { name: 'megabytes/sec', id: 'MBs', fn: SIPrefix('B/s', 2, scalable) }, - { name: 'megabits/sec', id: 'Mbits', fn: SIPrefix('b/s', 2, scalable) }, + { name: 'megabytes/sec', id: 'MBs', fn: SIPrefix('B/s', 2) }, + { name: 'megabits/sec', id: 'Mbits', fn: SIPrefix('b/s', 2) }, { name: 'gibibytes/sec', id: 'GiBs', fn: binaryPrefix('B/s', 3) }, { name: 'gibibits/sec', id: 'Gibits', fn: binaryPrefix('b/s', 3) }, - { name: 'gigabytes/sec', id: 'GBs', fn: SIPrefix('B/s', 3, scalable) }, - { name: 'gigabits/sec', id: 'Gbits', fn: SIPrefix('b/s', 3, scalable) }, + { name: 'gigabytes/sec', id: 'GBs', fn: SIPrefix('B/s', 3) }, + { name: 'gigabits/sec', id: 'Gbits', fn: SIPrefix('b/s', 3) }, { name: 'tebibytes/sec', id: 'TiBs', fn: binaryPrefix('B/s', 4) }, { name: 'tebibits/sec', id: 'Tibits', fn: binaryPrefix('b/s', 4) }, - { name: 'terabytes/sec', id: 'TBs', fn: SIPrefix('B/s', 4, scalable) }, - { name: 'terabits/sec', id: 'Tbits', fn: SIPrefix('b/s', 4, scalable) }, + { name: 'terabytes/sec', id: 'TBs', fn: SIPrefix('B/s', 4) }, + { name: 'terabits/sec', id: 'Tbits', fn: SIPrefix('b/s', 4) }, { name: 'pebibytes/sec', id: 'PiBs', fn: binaryPrefix('B/s', 5) }, { name: 'pebibits/sec', id: 'Pibits', fn: binaryPrefix('b/s', 5) }, - { name: 'petabytes/sec', id: 'PBs', fn: SIPrefix('B/s', 5, scalable) }, - { name: 'petabits/sec', id: 'Pbits', fn: SIPrefix('b/s', 5, scalable) }, + { name: 'petabytes/sec', id: 'PBs', fn: SIPrefix('B/s', 5) }, + { name: 'petabits/sec', id: 'Pbits', fn: SIPrefix('b/s', 5) }, ], }, { @@ -216,46 +218,46 @@ export const getCategories = (scalable = true): ValueFormatCategory[] => [ { name: 'Energy', formats: [ - { name: 'Watt (W)', id: 'watt', fn: SIPrefix('W', 0, scalable) }, - { name: 'Kilowatt (kW)', id: 'kwatt', fn: SIPrefix('W', 1, scalable) }, - { name: 'Megawatt (MW)', id: 'megwatt', fn: SIPrefix('W', 2, scalable) }, - { name: 'Gigawatt (GW)', id: 'gwatt', fn: SIPrefix('W', 3, scalable) }, - { name: 'Milliwatt (mW)', id: 'mwatt', fn: SIPrefix('W', -1, scalable) }, + { name: 'Watt (W)', id: 'watt', fn: SIPrefix('W') }, + { name: 'Kilowatt (kW)', id: 'kwatt', fn: SIPrefix('W', 1) }, + { name: 'Megawatt (MW)', id: 'megwatt', fn: SIPrefix('W', 2) }, + { name: 'Gigawatt (GW)', id: 'gwatt', fn: SIPrefix('W', 3) }, + { name: 'Milliwatt (mW)', id: 'mwatt', fn: SIPrefix('W', -1) }, { name: 'Watt per square meter (W/m²)', id: 'Wm2', fn: toFixedUnit('W/m²') }, - { name: 'Volt-Ampere (VA)', id: 'voltamp', fn: SIPrefix('VA', 0, scalable) }, - { name: 'Kilovolt-Ampere (kVA)', id: 'kvoltamp', fn: SIPrefix('VA', 1, scalable) }, - { name: 'Volt-Ampere reactive (VAr)', id: 'voltampreact', fn: SIPrefix('VAr', 0, scalable) }, - { name: 'Kilovolt-Ampere reactive (kVAr)', id: 'kvoltampreact', fn: SIPrefix('VAr', 1, scalable) }, + { name: 'Volt-Ampere (VA)', id: 'voltamp', fn: SIPrefix('VA') }, + { name: 'Kilovolt-Ampere (kVA)', id: 'kvoltamp', fn: SIPrefix('VA', 1) }, + { name: 'Volt-Ampere reactive (VAr)', id: 'voltampreact', fn: SIPrefix('VAr') }, + { name: 'Kilovolt-Ampere reactive (kVAr)', id: 'kvoltampreact', fn: SIPrefix('VAr', 1) }, { name: 'Watt-hour (Wh)', id: 'watth', fn: SIPrefix('Wh') }, - { name: 'Watt-hour per Kilogram (Wh/kg)', id: 'watthperkg', fn: SIPrefix('Wh/kg', 0, scalable) }, - { name: 'Kilowatt-hour (kWh)', id: 'kwatth', fn: SIPrefix('Wh', 1, scalable) }, - { name: 'Kilowatt-min (kWm)', id: 'kwattm', fn: SIPrefix('W-Min', 1, scalable) }, - { name: 'Megawatt-hour (MWh)', id: 'mwatth', fn: SIPrefix('Wh', 2, scalable) }, - { name: 'Ampere-hour (Ah)', id: 'amph', fn: SIPrefix('Ah', 0, scalable) }, - { name: 'Kiloampere-hour (kAh)', id: 'kamph', fn: SIPrefix('Ah', 1, scalable) }, - { name: 'Milliampere-hour (mAh)', id: 'mamph', fn: SIPrefix('Ah', -1, scalable) }, - { name: 'Joule (J)', id: 'joule', fn: SIPrefix('J', 0, scalable) }, - { name: 'Electron volt (eV)', id: 'ev', fn: SIPrefix('eV', 0, scalable) }, - { name: 'Ampere (A)', id: 'amp', fn: SIPrefix('A', 0, scalable) }, - { name: 'Kiloampere (kA)', id: 'kamp', fn: SIPrefix('A', 1, scalable) }, - { name: 'Milliampere (mA)', id: 'mamp', fn: SIPrefix('A', -1, scalable) }, - { name: 'Volt (V)', id: 'volt', fn: SIPrefix('V', 0, scalable) }, - { name: 'Kilovolt (kV)', id: 'kvolt', fn: SIPrefix('V', 1, scalable) }, - { name: 'Millivolt (mV)', id: 'mvolt', fn: SIPrefix('V', -1, scalable) }, - { name: 'Decibel-milliwatt (dBm)', id: 'dBm', fn: SIPrefix('dBm', 0, scalable) }, - { name: 'Milliohm (mΩ)', id: 'mohm', fn: SIPrefix('Ω', -1, scalable) }, - { name: 'Ohm (Ω)', id: 'ohm', fn: SIPrefix('Ω', 0, scalable) }, - { name: 'Kiloohm (kΩ)', id: 'kohm', fn: SIPrefix('Ω', 1, scalable) }, - { name: 'Megaohm (MΩ)', id: 'Mohm', fn: SIPrefix('Ω', 2, scalable) }, - { name: 'Farad (F)', id: 'farad', fn: SIPrefix('F', 0, scalable) }, - { name: 'Microfarad (µF)', id: 'µfarad', fn: SIPrefix('F', -2, scalable) }, - { name: 'Nanofarad (nF)', id: 'nfarad', fn: SIPrefix('F', -3, scalable) }, - { name: 'Picofarad (pF)', id: 'pfarad', fn: SIPrefix('F', -4, scalable) }, - { name: 'Femtofarad (fF)', id: 'ffarad', fn: SIPrefix('F', -5, scalable) }, - { name: 'Henry (H)', id: 'henry', fn: SIPrefix('H', 0, scalable) }, - { name: 'Millihenry (mH)', id: 'mhenry', fn: SIPrefix('H', -1, scalable) }, - { name: 'Microhenry (µH)', id: 'µhenry', fn: SIPrefix('H', -2, scalable) }, - { name: 'Lumens (Lm)', id: 'lumens', fn: SIPrefix('Lm', 0, scalable) }, + { name: 'Watt-hour per Kilogram (Wh/kg)', id: 'watthperkg', fn: SIPrefix('Wh/kg') }, + { name: 'Kilowatt-hour (kWh)', id: 'kwatth', fn: SIPrefix('Wh', 1) }, + { name: 'Kilowatt-min (kWm)', id: 'kwattm', fn: SIPrefix('W-Min', 1) }, + { name: 'Megawatt-hour (MWh)', id: 'mwatth', fn: SIPrefix('Wh', 2) }, + { name: 'Ampere-hour (Ah)', id: 'amph', fn: SIPrefix('Ah') }, + { name: 'Kiloampere-hour (kAh)', id: 'kamph', fn: SIPrefix('Ah', 1) }, + { name: 'Milliampere-hour (mAh)', id: 'mamph', fn: SIPrefix('Ah', -1) }, + { name: 'Joule (J)', id: 'joule', fn: SIPrefix('J') }, + { name: 'Electron volt (eV)', id: 'ev', fn: SIPrefix('eV') }, + { name: 'Ampere (A)', id: 'amp', fn: SIPrefix('A') }, + { name: 'Kiloampere (kA)', id: 'kamp', fn: SIPrefix('A', 1) }, + { name: 'Milliampere (mA)', id: 'mamp', fn: SIPrefix('A', -1) }, + { name: 'Volt (V)', id: 'volt', fn: SIPrefix('V') }, + { name: 'Kilovolt (kV)', id: 'kvolt', fn: SIPrefix('V', 1) }, + { name: 'Millivolt (mV)', id: 'mvolt', fn: SIPrefix('V', -1) }, + { name: 'Decibel-milliwatt (dBm)', id: 'dBm', fn: SIPrefix('dBm') }, + { name: 'Milliohm (mΩ)', id: 'mohm', fn: SIPrefix('Ω', -1) }, + { name: 'Ohm (Ω)', id: 'ohm', fn: SIPrefix('Ω') }, + { name: 'Kiloohm (kΩ)', id: 'kohm', fn: SIPrefix('Ω', 1) }, + { name: 'Megaohm (MΩ)', id: 'Mohm', fn: SIPrefix('Ω', 2) }, + { name: 'Farad (F)', id: 'farad', fn: SIPrefix('F') }, + { name: 'Microfarad (µF)', id: 'µfarad', fn: SIPrefix('F', -2) }, + { name: 'Nanofarad (nF)', id: 'nfarad', fn: SIPrefix('F', -3) }, + { name: 'Picofarad (pF)', id: 'pfarad', fn: SIPrefix('F', -4) }, + { name: 'Femtofarad (fF)', id: 'ffarad', fn: SIPrefix('F', -5) }, + { name: 'Henry (H)', id: 'henry', fn: SIPrefix('H') }, + { name: 'Millihenry (mH)', id: 'mhenry', fn: SIPrefix('H', -1) }, + { name: 'Microhenry (µH)', id: 'µhenry', fn: SIPrefix('H', -2) }, + { name: 'Lumens (Lm)', id: 'lumens', fn: SIPrefix('Lm') }, ], }, { @@ -274,52 +276,52 @@ export const getCategories = (scalable = true): ValueFormatCategory[] => [ { name: 'Force', formats: [ - { name: 'Newton-meters (Nm)', id: 'forceNm', fn: SIPrefix('Nm', 0, scalable) }, - { name: 'Kilonewton-meters (kNm)', id: 'forcekNm', fn: SIPrefix('Nm', 1, scalable) }, - { name: 'Newtons (N)', id: 'forceN', fn: SIPrefix('N', 0, scalable) }, - { name: 'Kilonewtons (kN)', id: 'forcekN', fn: SIPrefix('N', 1, scalable) }, + { name: 'Newton-meters (Nm)', id: 'forceNm', fn: SIPrefix('Nm') }, + { name: 'Kilonewton-meters (kNm)', id: 'forcekNm', fn: SIPrefix('Nm', 1) }, + { name: 'Newtons (N)', id: 'forceN', fn: SIPrefix('N') }, + { name: 'Kilonewtons (kN)', id: 'forcekN', fn: SIPrefix('N', 1) }, ], }, { name: 'Hash rate', formats: [ - { name: 'hashes/sec', id: 'Hs', fn: SIPrefix('H/s', 0, scalable) }, - { name: 'kilohashes/sec', id: 'KHs', fn: SIPrefix('H/s', 1, scalable) }, - { name: 'megahashes/sec', id: 'MHs', fn: SIPrefix('H/s', 2, scalable) }, - { name: 'gigahashes/sec', id: 'GHs', fn: SIPrefix('H/s', 3, scalable) }, - { name: 'terahashes/sec', id: 'THs', fn: SIPrefix('H/s', 4, scalable) }, - { name: 'petahashes/sec', id: 'PHs', fn: SIPrefix('H/s', 5, scalable) }, - { name: 'exahashes/sec', id: 'EHs', fn: SIPrefix('H/s', 6, scalable) }, + { name: 'hashes/sec', id: 'Hs', fn: SIPrefix('H/s') }, + { name: 'kilohashes/sec', id: 'KHs', fn: SIPrefix('H/s', 1) }, + { name: 'megahashes/sec', id: 'MHs', fn: SIPrefix('H/s', 2) }, + { name: 'gigahashes/sec', id: 'GHs', fn: SIPrefix('H/s', 3) }, + { name: 'terahashes/sec', id: 'THs', fn: SIPrefix('H/s', 4) }, + { name: 'petahashes/sec', id: 'PHs', fn: SIPrefix('H/s', 5) }, + { name: 'exahashes/sec', id: 'EHs', fn: SIPrefix('H/s', 6) }, ], }, { name: 'Mass', formats: [ - { name: 'milligram (mg)', id: 'massmg', fn: SIPrefix('g', -1, scalable) }, - { name: 'gram (g)', id: 'massg', fn: SIPrefix('g', 0, scalable) }, + { name: 'milligram (mg)', id: 'massmg', fn: SIPrefix('g', -1) }, + { name: 'gram (g)', id: 'massg', fn: SIPrefix('g') }, { name: 'pound (lb)', id: 'masslb', fn: toFixedUnit('lb') }, - { name: 'kilogram (kg)', id: 'masskg', fn: SIPrefix('g', 1, scalable) }, + { name: 'kilogram (kg)', id: 'masskg', fn: SIPrefix('g', 1) }, { name: 'metric ton (t)', id: 'masst', fn: toFixedUnit('t') }, ], }, { name: 'Length', formats: [ - { name: 'millimeter (mm)', id: 'lengthmm', fn: SIPrefix('m', -1, scalable) }, + { name: 'millimeter (mm)', id: 'lengthmm', fn: SIPrefix('m', -1) }, { name: 'inch (in)', id: 'lengthin', fn: toFixedUnit('in') }, { name: 'feet (ft)', id: 'lengthft', fn: toFixedUnit('ft') }, - { name: 'meter (m)', id: 'lengthm', fn: SIPrefix('m', 0, scalable) }, - { name: 'kilometer (km)', id: 'lengthkm', fn: SIPrefix('m', 1, scalable) }, + { name: 'meter (m)', id: 'lengthm', fn: SIPrefix('m') }, + { name: 'kilometer (km)', id: 'lengthkm', fn: SIPrefix('m', 1) }, { name: 'mile (mi)', id: 'lengthmi', fn: toFixedUnit('mi') }, ], }, { name: 'Pressure', formats: [ - { name: 'Millibars', id: 'pressurembar', fn: SIPrefix('bar', -1, scalable) }, - { name: 'Bars', id: 'pressurebar', fn: SIPrefix('bar', 0, scalable) }, - { name: 'Kilobars', id: 'pressurekbar', fn: SIPrefix('bar', 1, scalable) }, - { name: 'Pascals', id: 'pressurepa', fn: SIPrefix('Pa', 0, scalable) }, + { name: 'Millibars', id: 'pressurembar', fn: SIPrefix('bar', -1) }, + { name: 'Bars', id: 'pressurebar', fn: SIPrefix('bar') }, + { name: 'Kilobars', id: 'pressurekbar', fn: SIPrefix('bar', 1) }, + { name: 'Pascals', id: 'pressurepa', fn: SIPrefix('Pa') }, { name: 'Hectopascals', id: 'pressurehpa', fn: toFixedUnit('hPa') }, { name: 'Kilopascals', id: 'pressurekpa', fn: toFixedUnit('kPa') }, { name: 'Inches of mercury', id: 'pressurehg', fn: toFixedUnit('"Hg') }, @@ -329,29 +331,29 @@ export const getCategories = (scalable = true): ValueFormatCategory[] => [ { name: 'Radiation', formats: [ - { name: 'Becquerel (Bq)', id: 'radbq', fn: SIPrefix('Bq', 0, scalable) }, - { name: 'curie (Ci)', id: 'radci', fn: SIPrefix('Ci', 0, scalable) }, - { name: 'Gray (Gy)', id: 'radgy', fn: SIPrefix('Gy', 0, scalable) }, - { name: 'rad', id: 'radrad', fn: SIPrefix('rad', 0, scalable) }, - { name: 'Sievert (Sv)', id: 'radsv', fn: SIPrefix('Sv', 0, scalable) }, - { name: 'milliSievert (mSv)', id: 'radmsv', fn: SIPrefix('Sv', -1, scalable) }, - { name: 'microSievert (µSv)', id: 'radusv', fn: SIPrefix('Sv', -2, scalable) }, - { name: 'rem', id: 'radrem', fn: SIPrefix('rem', 0, scalable) }, - { name: 'Exposure (C/kg)', id: 'radexpckg', fn: SIPrefix('C/kg', 0, scalable) }, - { name: 'roentgen (R)', id: 'radr', fn: SIPrefix('R', 0, scalable) }, - { name: 'Sievert/hour (Sv/h)', id: 'radsvh', fn: SIPrefix('Sv/h', 0, scalable) }, - { name: 'milliSievert/hour (mSv/h)', id: 'radmsvh', fn: SIPrefix('Sv/h', -1, scalable) }, - { name: 'microSievert/hour (µSv/h)', id: 'radusvh', fn: SIPrefix('Sv/h', -2, scalable) }, + { name: 'Becquerel (Bq)', id: 'radbq', fn: SIPrefix('Bq') }, + { name: 'curie (Ci)', id: 'radci', fn: SIPrefix('Ci') }, + { name: 'Gray (Gy)', id: 'radgy', fn: SIPrefix('Gy') }, + { name: 'rad', id: 'radrad', fn: SIPrefix('rad') }, + { name: 'Sievert (Sv)', id: 'radsv', fn: SIPrefix('Sv') }, + { name: 'milliSievert (mSv)', id: 'radmsv', fn: SIPrefix('Sv', -1) }, + { name: 'microSievert (µSv)', id: 'radusv', fn: SIPrefix('Sv', -2) }, + { name: 'rem', id: 'radrem', fn: SIPrefix('rem') }, + { name: 'Exposure (C/kg)', id: 'radexpckg', fn: SIPrefix('C/kg') }, + { name: 'roentgen (R)', id: 'radr', fn: SIPrefix('R') }, + { name: 'Sievert/hour (Sv/h)', id: 'radsvh', fn: SIPrefix('Sv/h') }, + { name: 'milliSievert/hour (mSv/h)', id: 'radmsvh', fn: SIPrefix('Sv/h', -1) }, + { name: 'microSievert/hour (µSv/h)', id: 'radusvh', fn: SIPrefix('Sv/h', -2) }, ], }, { name: 'Rotational Speed', formats: [ { name: 'Revolutions per minute (rpm)', id: 'rotrpm', fn: toFixedUnit('rpm') }, - { name: 'Hertz (Hz)', id: 'rothz', fn: SIPrefix('Hz', 0, scalable) }, - { name: 'Kilohertz (kHz)', id: 'rotkhz', fn: SIPrefix('Hz', 1, scalable) }, - { name: 'Megahertz (MHz)', id: 'rotmhz', fn: SIPrefix('Hz', 2, scalable) }, - { name: 'Gigahertz (GHz)', id: 'rotghz', fn: SIPrefix('Hz', 3, scalable) }, + { name: 'Hertz (Hz)', id: 'rothz', fn: SIPrefix('Hz') }, + { name: 'Kilohertz (kHz)', id: 'rotkhz', fn: SIPrefix('Hz', 1) }, + { name: 'Megahertz (MHz)', id: 'rotmhz', fn: SIPrefix('Hz', 2) }, + { name: 'Gigahertz (GHz)', id: 'rotghz', fn: SIPrefix('Hz', 3) }, { name: 'Radians per second (rad/s)', id: 'rotrads', fn: toFixedUnit('rad/s') }, { name: 'Degrees per second (°/s)', id: 'rotdegs', fn: toFixedUnit('°/s') }, ], @@ -367,7 +369,7 @@ export const getCategories = (scalable = true): ValueFormatCategory[] => [ { name: 'Time', formats: [ - { name: 'Hertz (1/s)', id: 'hertz', fn: SIPrefix('Hz', 0, scalable) }, + { name: 'Hertz (1/s)', id: 'hertz', fn: SIPrefix('Hz') }, { name: 'nanoseconds (ns)', id: 'ns', fn: toNanoSeconds }, { name: 'microseconds (µs)', id: 'µs', fn: toMicroSeconds }, { name: 'milliseconds (ms)', id: 'ms', fn: toMilliSeconds }, @@ -420,8 +422,8 @@ export const getCategories = (scalable = true): ValueFormatCategory[] => [ { name: 'Volume', formats: [ - { name: 'millilitre (mL)', id: 'mlitre', fn: SIPrefix('L', -1, scalable) }, - { name: 'litre (L)', id: 'litre', fn: SIPrefix('L', 0, scalable) }, + { name: 'millilitre (mL)', id: 'mlitre', fn: SIPrefix('L', -1) }, + { name: 'litre (L)', id: 'litre', fn: SIPrefix('L') }, { name: 'cubic meter', id: 'm3', fn: toFixedUnit('m³') }, { name: 'Normal cubic meter', id: 'Nm3', fn: toFixedUnit('Nm³') }, { name: 'cubic decimeter', id: 'dm3', fn: toFixedUnit('dm³') }, diff --git a/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts b/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts index fe230f5023b4a..4eb18bf8c5087 100644 --- a/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts +++ b/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts @@ -102,13 +102,6 @@ export function toMilliSeconds(size: number, decimals?: DecimalCount, scaledDeci return toFixedScaled(size / 31536000000, decimals, ' year'); } -export function trySubstract(value1: DecimalCount, value2: DecimalCount): DecimalCount { - if (value1 !== null && value1 !== undefined && value2 !== null && value2 !== undefined) { - return value1 - value2; - } - return undefined; -} - export function toSeconds(size: number, decimals?: DecimalCount): FormattedValue { if (size === null) { return { text: '' }; diff --git a/packages/grafana-data/src/valueFormats/symbolFormatters.test.ts b/packages/grafana-data/src/valueFormats/symbolFormatters.test.ts index 0fe8684bc5f77..05273473e84a8 100644 --- a/packages/grafana-data/src/valueFormats/symbolFormatters.test.ts +++ b/packages/grafana-data/src/valueFormats/symbolFormatters.test.ts @@ -82,30 +82,3 @@ describe('SIPrefix', () => { expect(text).toEqual(expectedText); }); }); - -describe('SIPrefix with scalable set to false', () => { - const symbol = 'V'; - const fmtFunc = SIPrefix(symbol, 0, false); - - it.each` - value | expectedSuffix | expectedText - ${0} | ${' V'} | ${'0'} - ${999} | ${' V'} | ${'999'} - ${1000} | ${' V'} | ${'1000'} - ${1000000} | ${' V'} | ${'1000000'} - ${1000000000} | ${' V'} | ${'1000000000'} - ${1000000000000} | ${' V'} | ${'1000000000000'} - ${1000000000000000} | ${' V'} | ${'1000000000000000'} - ${-1000000000000} | ${' V'} | ${'-1000000000000'} - ${-1000000000} | ${' V'} | ${'-1000000000'} - ${-1000000} | ${' V'} | ${'-1000000'} - ${-1000} | ${' V'} | ${'-1000'} - ${-999} | ${' V'} | ${'-999'} - `('when called with value:{$value}', ({ value, expectedSuffix, expectedText }) => { - const { prefix, suffix, text } = fmtFunc(value); - - expect(prefix).toEqual(undefined); - expect(suffix).toEqual(expectedSuffix); - expect(text).toEqual(expectedText); - }); -}); diff --git a/packages/grafana-data/src/valueFormats/symbolFormatters.ts b/packages/grafana-data/src/valueFormats/symbolFormatters.ts index 1c633bbb8f4f6..d9cdc85616c56 100644 --- a/packages/grafana-data/src/valueFormats/symbolFormatters.ts +++ b/packages/grafana-data/src/valueFormats/symbolFormatters.ts @@ -1,6 +1,6 @@ import { DecimalCount } from '../types/displayValue'; -import { scaledUnits, ValueFormatter, toFixedUnit } from './valueFormats'; +import { scaledUnits, ValueFormatter } from './valueFormats'; export function currency(symbol: string, asSuffix?: boolean): ValueFormatter { const units = ['', 'K', 'M', 'B', 'T']; @@ -41,10 +41,7 @@ export function binaryPrefix(unit: string, offset = 0): ValueFormatter { return scaledUnits(1024, units, offset); } -export function SIPrefix(unit: string, offset = 0, scalable = true): ValueFormatter { +export function SIPrefix(unit: string, offset = 0): ValueFormatter { const units = SI_PREFIXES.map((p) => ' ' + p + unit); - if (!scalable) { - return toFixedUnit(SI_PREFIXES[SI_BASE_INDEX + offset] + unit); - } return scaledUnits(1000, units, SI_BASE_INDEX + offset); } diff --git a/packages/grafana-data/src/valueFormats/valueFormats.test.ts b/packages/grafana-data/src/valueFormats/valueFormats.test.ts index 40adc6a7bf2f8..b0776ae4b4103 100644 --- a/packages/grafana-data/src/valueFormats/valueFormats.test.ts +++ b/packages/grafana-data/src/valueFormats/valueFormats.test.ts @@ -154,16 +154,4 @@ describe('valueFormats', () => { expect(fmt0).toEqual(fmt1); }); }); - - describe('getValueFormat with scalable set to false', () => { - it.each` - value | expected - ${1000000} | ${'1000000 W'} - ${0.001} | ${'0.00100 W'} - `('should return a fixed unit regardless of the input value', ({ value, expected }) => { - const result = getValueFormat('watt', false)(value); - const full = formattedValueToString(result); - expect(full).toBe(expected); - }); - }); }); diff --git a/packages/grafana-data/src/valueFormats/valueFormats.ts b/packages/grafana-data/src/valueFormats/valueFormats.ts index 4f7333681ac71..e4d99adcc91b0 100644 --- a/packages/grafana-data/src/valueFormats/valueFormats.ts +++ b/packages/grafana-data/src/valueFormats/valueFormats.ts @@ -43,7 +43,6 @@ export interface ValueFormatterIndex { // Globals & formats cache let categories: ValueFormatCategory[] = []; const index: ValueFormatterIndex = {}; -const indexScalable: ValueFormatterIndex = {}; let hasBuiltIndex = false; export function toFixed(value: number, decimals?: DecimalCount): string { @@ -200,7 +199,6 @@ export function stringFormater(value: number): FormattedValue { function buildFormats() { categories = getCategories(); - const nonScalableCategories = getCategories(false); for (const cat of categories) { for (const format of cat.formats) { @@ -208,28 +206,18 @@ function buildFormats() { } } - for (const cat of nonScalableCategories) { - for (const format of cat.formats) { - indexScalable[format.id] = format.fn; - } - } - // Resolve units pointing to old IDs [{ from: 'farenheit', to: 'fahrenheit' }].forEach((alias) => { - const f0 = index[alias.to]; - if (f0) { - index[alias.from] = f0; - } - const f1 = indexScalable[alias.to]; - if (f1) { - indexScalable[alias.from] = f1; + const f = index[alias.to]; + if (f) { + index[alias.from] = f; } }); hasBuiltIndex = true; } -export function getValueFormat(id?: string | null, scalable = true): ValueFormatter { +export function getValueFormat(id?: string | null): ValueFormatter { if (!id) { return toFixedUnit(''); } @@ -238,7 +226,7 @@ export function getValueFormat(id?: string | null, scalable = true): ValueFormat buildFormats(); } - const fmt = scalable ? index[id] : indexScalable[id]; + const fmt = index[id]; if (!fmt && id) { let idx = id.indexOf(':'); @@ -290,14 +278,11 @@ export function getValueFormat(id?: string | null, scalable = true): ValueFormat return fmt; } -export function getValueFormatterIndex(scalable = true): ValueFormatterIndex { +export function getValueFormatterIndex(): ValueFormatterIndex { if (!hasBuiltIndex) { buildFormats(); } - if (scalable) { - return indexScalable; - } return index; } diff --git a/packages/grafana-data/src/vector/AppendedVectors.test.ts b/packages/grafana-data/src/vector/AppendedVectors.test.ts deleted file mode 100644 index 29ef1dabe0241..0000000000000 --- a/packages/grafana-data/src/vector/AppendedVectors.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { AppendedVectors } from './AppendedVectors'; -import { ArrayVector } from './ArrayVector'; - -describe('Check Appending Vector', () => { - it('should transparently join them', () => { - jest.spyOn(console, 'warn').mockImplementation(); - const appended = new AppendedVectors(); - appended.append(new ArrayVector([1, 2, 3])); - appended.append(new ArrayVector([4, 5, 6])); - appended.append(new ArrayVector([7, 8, 9])); - expect(appended.length).toEqual(9); - expect(appended[0]).toEqual(1); - expect(appended[1]).toEqual(2); - expect(appended[100]).toEqual(undefined); - - appended.setLength(5); - expect(appended.length).toEqual(5); - appended.append(new ArrayVector(['a', 'b', 'c'])); - expect(appended.length).toEqual(8); - expect(appended.toArray()).toEqual([1, 2, 3, 4, 5, 'a', 'b', 'c']); - - appended.setLength(2); - appended.setLength(6); - appended.append(new ArrayVector(['x', 'y', 'z'])); - expect(appended.toArray()).toEqual([1, 2, undefined, undefined, undefined, undefined, 'x', 'y', 'z']); - }); -}); diff --git a/packages/grafana-data/src/vector/AppendedVectors.ts b/packages/grafana-data/src/vector/AppendedVectors.ts deleted file mode 100644 index af9873a69c26b..0000000000000 --- a/packages/grafana-data/src/vector/AppendedVectors.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Vector, makeArrayIndexableVector } from '../types/vector'; - -import { FunctionalVector } from './FunctionalVector'; -import { vectorToArray } from './vectorToArray'; - -interface AppendedVectorInfo<T> { - start: number; - end: number; - values: Vector<T>; -} - -/** - * This may be more trouble than it is worth. This trades some computation time for - * RAM -- rather than allocate a new array the size of all previous arrays, this just - * points the correct index to their original array values - * - * @deprecated use a simple Arrays. NOTE this is not used in grafana core - */ -export class AppendedVectors<T = any> extends FunctionalVector<T> { - length = 0; - source: Array<AppendedVectorInfo<T>> = []; - - constructor(startAt = 0) { - super(); - this.length = startAt; - return makeArrayIndexableVector(this); - } - - /** - * Make the vector look like it is this long - */ - setLength(length: number) { - if (length > this.length) { - // make the vector longer (filling with undefined) - this.length = length; - } else if (length < this.length) { - // make the array shorter - const sources: Array<AppendedVectorInfo<T>> = []; - for (const src of this.source) { - sources.push(src); - if (src.end > length) { - src.end = length; - break; - } - } - this.source = sources; - this.length = length; - } - } - - append(v: Vector<T>): AppendedVectorInfo<T> { - const info = { - start: this.length, - end: this.length + v.length, - values: v, - }; - this.length = info.end; - this.source.push(info); - return info; - } - - get(index: number): T { - for (let i = 0; i < this.source.length; i++) { - const src = this.source[i]; - if (index >= src.start && index < src.end) { - return src.values[index - src.start]; - } - } - return undefined as unknown as T; - } - - toArray(): T[] { - return vectorToArray(this); - } - - toJSON(): T[] { - return vectorToArray(this); - } -} diff --git a/packages/grafana-data/src/vector/ArrayVector.test.ts b/packages/grafana-data/src/vector/ArrayVector.test.ts index 3d9ae35fc5f9f..eb0c53dfa0c30 100644 --- a/packages/grafana-data/src/vector/ArrayVector.test.ts +++ b/packages/grafana-data/src/vector/ArrayVector.test.ts @@ -2,6 +2,9 @@ import { Field, FieldType } from '../types'; import { ArrayVector } from './ArrayVector'; +// There's lots of @ts-expect-error here, because we actually expect it to be a typescript error +// to further encourge developers not to use ArrayVector + describe('ArrayVector', () => { beforeEach(() => { jest.spyOn(console, 'warn').mockImplementation(); @@ -9,12 +12,14 @@ describe('ArrayVector', () => { it('should init 150k with 65k Array.push() chonking', () => { const arr = Array.from({ length: 150e3 }, (v, i) => i); + /// @ts-expect-error const av = new ArrayVector(arr); expect(av.toArray()).toEqual(arr); }); it('should support add and push', () => { + /// @ts-expect-error const av = new ArrayVector<number>(); av.add(1); av.push(2); @@ -28,17 +33,26 @@ describe('ArrayVector', () => { name: 'test', config: {}, type: FieldType.number, + /// @ts-expect-error values: new ArrayVector(), // this defaults to `new ArrayVector<any>()` }; expect(field).toBeDefined(); // Before collapsing Vector, ReadWriteVector, and MutableVector these all worked fine + + /// @ts-expect-error field.values = new ArrayVector(); + /// @ts-expect-error field.values = new ArrayVector(undefined); + /// @ts-expect-error field.values = new ArrayVector([1, 2, 3]); + /// @ts-expect-error field.values = new ArrayVector([]); + /// @ts-expect-error field.values = new ArrayVector([1, undefined]); + /// @ts-expect-error field.values = new ArrayVector([null]); + /// @ts-expect-error field.values = new ArrayVector(['a', 'b', 'c']); expect(field.values.length).toBe(3); }); diff --git a/packages/grafana-data/src/vector/ArrayVector.ts b/packages/grafana-data/src/vector/ArrayVector.ts index 01b615162b78c..0673f12f74a81 100644 --- a/packages/grafana-data/src/vector/ArrayVector.ts +++ b/packages/grafana-data/src/vector/ArrayVector.ts @@ -6,12 +6,12 @@ let notified = false; * * @deprecated use a simple Array<T> */ -export class ArrayVector<T = any> extends Array<T> { +export class ArrayVector<T = unknown> extends Array<T> { get buffer() { return this; } - set buffer(values: any[]) { + set buffer(values: T[]) { this.length = 0; const len = values?.length; @@ -27,10 +27,10 @@ export class ArrayVector<T = any> extends Array<T> { } /** - * This any type is here to make the change type changes in v10 non breaking for plugins. - * Before you could technically assign field.values any typed ArrayVector no matter what the Field<T> T type was. + * ArrayVector is deprecated and should not be used. If you get a Typescript error here, use plain arrays for field.values. */ - constructor(buffer?: any[]) { + // `never` is used to force a build-type error from Typescript to encourage developers to move away from using this + constructor(buffer: never) { super(); this.buffer = buffer ?? []; diff --git a/packages/grafana-data/src/vector/AsNumberVector.ts b/packages/grafana-data/src/vector/AsNumberVector.ts deleted file mode 100644 index cc47ad3d7ec8c..0000000000000 --- a/packages/grafana-data/src/vector/AsNumberVector.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Vector } from '../types'; - -/** - * This will force all values to be numbers - * - * @public - * @deprecated use a simple Arrays. NOTE: Not used in grafana core - */ -export class AsNumberVector extends Array<number> { - constructor(field: Vector) { - super(); - return field.map((v) => +v); - } -} diff --git a/packages/grafana-data/src/vector/BinaryOperationVector.test.ts b/packages/grafana-data/src/vector/BinaryOperationVector.test.ts deleted file mode 100644 index 10ab760f1bb28..0000000000000 --- a/packages/grafana-data/src/vector/BinaryOperationVector.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { binaryOperators, BinaryOperationID } from '../utils/binaryOperators'; - -import { ArrayVector } from './ArrayVector'; -import { BinaryOperationVector } from './BinaryOperationVector'; -import { ConstantVector } from './ConstantVector'; - -describe('ScaledVector', () => { - it('should support multiply operations', () => { - jest.spyOn(console, 'warn').mockImplementation(); - const source = new ArrayVector([1, 2, 3, 4]); - const scale = 2.456; - const operation = binaryOperators.get(BinaryOperationID.Multiply).operation; - const v = new BinaryOperationVector(source, new ConstantVector(scale, source.length), operation); - expect(v.length).toEqual(source.length); - // Accessed with getters - for (let i = 0; i < 4; i++) { - expect(v.get(i)).toEqual(source.get(i) * scale); - } - // Accessed with array index - for (let i = 0; i < 4; i++) { - expect(v[i]).toEqual(source[i] * scale); - } - }); -}); diff --git a/packages/grafana-data/src/vector/BinaryOperationVector.ts b/packages/grafana-data/src/vector/BinaryOperationVector.ts deleted file mode 100644 index 4848e040e2fa4..0000000000000 --- a/packages/grafana-data/src/vector/BinaryOperationVector.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Vector } from '../types/vector'; -import { BinaryOperation } from '../utils/binaryOperators'; - -/** - * @public - * @deprecated use a simple Arrays. NOTE: Not used in grafana core - */ -export class BinaryOperationVector extends Array<number> { - constructor(left: Vector<number>, right: Vector<number>, operation: BinaryOperation) { - super(); - - const arr = new Array(left.length); - for (let i = 0; i < arr.length; i++) { - arr[i] = operation(left[i], right[i]); - } - return arr; - } -} diff --git a/packages/grafana-data/src/vector/CircularVector.ts b/packages/grafana-data/src/vector/CircularVector.ts index fa3bd2503a8a2..221f7c488e553 100644 --- a/packages/grafana-data/src/vector/CircularVector.ts +++ b/packages/grafana-data/src/vector/CircularVector.ts @@ -1,5 +1,3 @@ -import { makeArrayIndexableVector } from '../types'; - import { FunctionalVector } from './FunctionalVector'; interface CircularOptions<T> { @@ -36,7 +34,27 @@ export class CircularVector<T = any> extends FunctionalVector<T> { if (options.capacity) { this.setCapacity(options.capacity); } - return makeArrayIndexableVector(this); + return new Proxy(this, { + get(target: CircularVector<T>, property: string, receiver: CircularVector<T>) { + if (typeof property !== 'symbol') { + const idx = +property; + if (String(idx) === property) { + return target.get(idx); + } + } + return Reflect.get(target, property, receiver); + }, + set(target: CircularVector<T>, property: string, value: T, receiver: CircularVector<T>) { + if (typeof property !== 'symbol') { + const idx = +property; + if (String(idx) === property) { + target.set(idx, value); + return true; + } + } + return Reflect.set(target, property, value, receiver); + }, + }); } /** diff --git a/packages/grafana-data/src/vector/ConstantVector.test.ts b/packages/grafana-data/src/vector/ConstantVector.test.ts deleted file mode 100644 index 2d94fc3442871..0000000000000 --- a/packages/grafana-data/src/vector/ConstantVector.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ConstantVector } from './ConstantVector'; - -describe('ConstantVector', () => { - it('should support constant values', () => { - const value = 3.5; - const v = new ConstantVector(value, 7); - expect(v.length).toEqual(7); - - expect(v.get(0)).toEqual(value); - expect(v.get(1)).toEqual(value); - - // Now check all of them - for (let i = 0; i < 7; i++) { - expect(v.get(i)).toEqual(value); - expect(v[i]).toEqual(value); - } - }); -}); diff --git a/packages/grafana-data/src/vector/ConstantVector.ts b/packages/grafana-data/src/vector/ConstantVector.ts deleted file mode 100644 index 0eb19fc763679..0000000000000 --- a/packages/grafana-data/src/vector/ConstantVector.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @public - * @deprecated use a simple Arrays. NOTE: Not used in grafana core. - */ -export class ConstantVector<T = any> extends Array<T> { - constructor(value: T, len: number) { - super(); - return new Array<T>(len).fill(value); - } -} diff --git a/packages/grafana-data/src/vector/FormattedVector.ts b/packages/grafana-data/src/vector/FormattedVector.ts deleted file mode 100644 index 8e4cd9be94926..0000000000000 --- a/packages/grafana-data/src/vector/FormattedVector.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { DisplayProcessor } from '../types'; -import { Vector } from '../types/vector'; -import { formattedValueToString } from '../valueFormats'; - -/** - * @public - * @deprecated use a simple Arrays. NOTE: not used in grafana core. - */ -export class FormattedVector<T = any> extends Array<string> { - constructor(source: Vector<T>, formatter: DisplayProcessor) { - super(); - return source.map((v) => formattedValueToString(formatter(v))); - } -} diff --git a/packages/grafana-data/src/vector/FunctionalVector.ts b/packages/grafana-data/src/vector/FunctionalVector.ts index 5c2984c4b7234..bcd13d96090bb 100644 --- a/packages/grafana-data/src/vector/FunctionalVector.ts +++ b/packages/grafana-data/src/vector/FunctionalVector.ts @@ -1,12 +1,8 @@ -import { Vector } from '../types'; - -import { vectorToArray } from './vectorToArray'; - /** * @public * @deprecated use a simple Arrays */ -export abstract class FunctionalVector<T = any> implements Vector<T> { +export abstract class FunctionalVector<T = unknown> { abstract get length(): number; abstract get(index: number): T; @@ -55,7 +51,11 @@ export abstract class FunctionalVector<T = any> implements Vector<T> { } toArray(): T[] { - return vectorToArray(this); + const arr = new Array<T>(this.length); + for (let i = 0; i < this.length; i++) { + arr[i] = this.get(i); + } + return arr; } join(separator?: string | undefined): string { @@ -183,7 +183,7 @@ const emptyarray: any[] = []; * * @deprecated use a simple Arrays */ -export function vectorator<T>(vector: Vector<T>) { +export function vectorator<T>(vector: FunctionalVector<T>) { return { *[Symbol.iterator]() { for (let i = 0; i < vector.length; i++) { diff --git a/packages/grafana-data/src/vector/IndexVector.ts b/packages/grafana-data/src/vector/IndexVector.ts deleted file mode 100644 index 1dfeb92ab3630..0000000000000 --- a/packages/grafana-data/src/vector/IndexVector.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Field, FieldType } from '../types'; - -/** - * IndexVector is a simple vector implementation that returns the index value - * for each element in the vector. It is functionally equivolant a vector backed - * by an array with values: `[0,1,2,...,length-1]` - * - * @deprecated use a simple Arrays. NOTE: not used in grafana core - */ -export class IndexVector extends Array<number> { - constructor(len: number) { - super(); - const arr = new Array(len); - for (let i = 0; i < len; i++) { - arr[i] = i; - } - return arr; - } - - /** - * Returns a field representing the range [0 ... length-1] - * - * @deprecated - */ - static newField(len: number): Field<number> { - return { - name: '', - values: new IndexVector(len), - type: FieldType.number, - config: { - min: 0, - max: len - 1, - }, - }; - } -} diff --git a/packages/grafana-data/src/vector/SortedVector.test.ts b/packages/grafana-data/src/vector/SortedVector.test.ts deleted file mode 100644 index 0d7a6c556a4e3..0000000000000 --- a/packages/grafana-data/src/vector/SortedVector.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ArrayVector } from './ArrayVector'; -import { SortedVector } from './SortedVector'; - -describe('SortedVector', () => { - it('Should support sorting', () => { - jest.spyOn(console, 'warn').mockImplementation(); - const values = new ArrayVector([1, 5, 2, 4]); - const sorted = new SortedVector(values, [0, 2, 3, 1]); - expect(sorted.toArray()).toEqual([1, 2, 4, 5]); - - // The proxy should still be an instance of SortedVector (used in timeseries) - expect(sorted instanceof SortedVector).toBeTruthy(); - }); -}); diff --git a/packages/grafana-data/src/vector/SortedVector.ts b/packages/grafana-data/src/vector/SortedVector.ts deleted file mode 100644 index 8ed64671688e9..0000000000000 --- a/packages/grafana-data/src/vector/SortedVector.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { makeArrayIndexableVector, Vector } from '../types/vector'; - -import { FunctionalVector } from './FunctionalVector'; -import { vectorToArray } from './vectorToArray'; - -/** - * Values are returned in the order defined by the input parameter - * - * @deprecated use a simple Arrays - */ -export class SortedVector<T = any> extends FunctionalVector<T> { - constructor( - private source: Vector<T>, - private order: number[] - ) { - super(); - return makeArrayIndexableVector(this); - } - - get length(): number { - return this.source.length; - } - - get(index: number): T { - return this.source.get(this.order[index]); - } - - toArray(): T[] { - return vectorToArray(this); - } - - toJSON(): T[] { - return vectorToArray(this); - } - - getOrderArray(): number[] { - return this.order; - } -} diff --git a/packages/grafana-data/src/vector/index.ts b/packages/grafana-data/src/vector/index.ts deleted file mode 100644 index 5401189d278b0..0000000000000 --- a/packages/grafana-data/src/vector/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export * from './AppendedVectors'; -export * from './ArrayVector'; -export * from './CircularVector'; -export * from './ConstantVector'; -export * from './BinaryOperationVector'; -export * from './SortedVector'; -export * from './FormattedVector'; -export * from './IndexVector'; -export * from './AsNumberVector'; - -export { vectorator } from './FunctionalVector'; diff --git a/packages/grafana-data/src/vector/vectorToArray.ts b/packages/grafana-data/src/vector/vectorToArray.ts deleted file mode 100644 index 7ec23d9c2612e..0000000000000 --- a/packages/grafana-data/src/vector/vectorToArray.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Vector } from '../types/vector'; - -/** @deprecated use a simple Arrays */ -export function vectorToArray<T>(v: Vector<T>): T[] { - const arr: T[] = Array(v.length); - for (let i = 0; i < v.length; i++) { - arr[i] = v.get(i); - } - return arr; -} diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index 97046a3d053c0..03e38eedcaa4a 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/e2e-selectors", - "version": "10.3.0-pre", + "version": "11.0.0-pre", "description": "Grafana End-to-End Test Selectors Library", "keywords": [ "cli", @@ -39,19 +39,18 @@ "postpack": "mv package.json.bak package.json" }, "devDependencies": { - "@rollup/plugin-commonjs": "25.0.2", "@rollup/plugin-node-resolve": "15.2.3", - "@types/node": "20.8.10", + "@types/node": "20.11.28", "esbuild": "0.18.12", - "rimraf": "5.0.1", + "rimraf": "5.0.5", "rollup": "2.79.1", "rollup-plugin-dts": "^5.0.0", "rollup-plugin-esbuild": "5.0.0", "rollup-plugin-node-externals": "^5.0.0" }, "dependencies": { - "@grafana/tsconfig": "^1.2.0-rc1", - "tslib": "2.6.0", - "typescript": "5.2.2" + "@grafana/tsconfig": "^1.3.0-rc1", + "tslib": "2.6.2", + "typescript": "5.3.3" } } diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 6819076b2d58a..357187506e74e 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -22,6 +22,8 @@ export const Components = { fromField: 'data-testid Time Range from field', toField: 'data-testid Time Range to field', applyTimeRange: 'data-testid TimePicker submit button', + copyTimeRange: 'data-testid TimePicker copy button', + pasteTimeRange: 'data-testid TimePicker paste button', calendar: { label: 'data-testid Time Range calendar', openButton: 'data-testid Open time range calendar', @@ -53,18 +55,90 @@ export const Components = { }, }, DataSourceHttpSettings: { - urlInput: 'Datasource HTTP settings url', + urlInput: 'data-testid Datasource HTTP settings url', }, Jaeger: { traceIDInput: 'Trace ID', }, Prometheus: { configPage: { - connectionSettings: 'Data source connection URL', + connectionSettings: 'Data source connection URL', // aria-label in grafana experimental + manageAlerts: 'prometheus-alerts-manager', // id for switch component + scrapeInterval: 'data-testid scrape interval', + queryTimeout: 'data-testid query timeout', + defaultEditor: 'data-testid default editor', + disableMetricLookup: 'disable-metric-lookup', // id for switch component + prometheusType: 'data-testid prometheus type', + prometheusVersion: 'data-testid prometheus version', + cacheLevel: 'data-testid cache level', + incrementalQuerying: 'prometheus-incremental-querying', // id for switch component + queryOverlapWindow: 'data-testid query overlap window', + disableRecordingRules: 'disable-recording-rules', // id for switch component + customQueryParameters: 'data-testid custom query parameters', + httpMethod: 'data-testid http method', exemplarsAddButton: 'data-testid Add exemplar config button', internalLinkSwitch: 'data-testid Internal link switch', }, + queryEditor: { + // kickstart: '', see QueryBuilder queryPatterns below + explain: 'data-testid prometheus explain switch wrapper', + editorToggle: 'data-testid QueryEditorModeToggle', // wrapper for toggle + options: 'data-testid prometheus options', // wrapper for options group + legend: 'data-testid prometheus legend wrapper', // wrapper for multiple compomnents + format: 'data-testid prometheus format', + step: 'prometheus-step', // id for autosize component + type: 'data-testid prometheus type', //wrapper for radio button group + exemplars: 'prometheus-exemplars', // id for editor switch component + builder: { + // see QueryBuilder below for commented selectors + // labelSelect: 'data-testid Select label', + // valueSelect: 'data-testid Select value', + // matchOperatorSelect: 'data-testid Select match operator', + metricSelect: 'data-testid metric select', + hints: 'data-testid prometheus hints', // wrapper for hints component + metricsExplorer: 'data-testid metrics explorer', + queryAdvisor: 'data-testid query advisor', + }, + code: { + queryField: 'data-testid prometheus query field', + metricsBrowser: { + openButton: 'data-testid open metrics browser', + selectMetric: 'data-testid select a metric', + metricList: 'data-testid metric list', + labelNamesFilter: 'data-testid label names filter', + labelValuesFilter: 'data-testid label values filter', + useQuery: 'data-testid use query', + useAsRateQuery: 'data-testid use as rate query', + validateSelector: 'data-testid validate selector', + clear: 'data-testid clear', + }, + }, + }, exemplarMarker: 'data-testid Exemplar marker', + variableQueryEditor: { + queryType: 'data-testid query type', + labelnames: { + metricRegex: 'data-testid label names metric regex', + }, + labelValues: { + labelSelect: 'data-testid label values label select', + // metric select see queryEditor: builder for more context + // label select for metric filtering see queryEditor: builder for more context + }, + metricNames: { + metricRegex: 'data-testid metric names metric regex', + }, + varQueryResult: 'data-testid variable query result', + seriesQuery: 'data-testid prometheus series query', + classicQuery: 'data-testid prometheus classic query', + }, + annotations: { + minStep: 'prometheus-annotation-min-step', // id for autosize input + title: 'data-testid prometheus annotation title', + tags: 'data-testid prometheus annotation tags', + text: 'data-testid prometheus annotation text', + seriesValueAsTimestamp: 'data-testid prometheus annotation series value as timestamp', + }, }, }, Menu: { @@ -90,6 +164,7 @@ export const Components = { container: 'data-testid hover-header-container', dragIcon: 'data-testid drag-icon', }, + PanelDataErrorMessage: 'data-testid Panel data error message', }, Visualization: { Graph: { @@ -135,6 +210,7 @@ export const Components = { contract: 'Drawer contract', close: 'data-testid Drawer close', rcContentWrapper: () => '.rc-drawer-content-wrapper', + subtitle: 'data-testid drawer subtitle', }, }, PanelEditor: { @@ -145,6 +221,7 @@ export const Components = { content: 'Panel editor option pane content', select: 'Panel editor option pane select', fieldLabel: (type: string) => `${type} field property editor`, + fieldInput: (title: string) => `data-testid Panel editor option pane field input ${title}`, }, // not sure about the naming *DataPane* DataPane: { @@ -209,7 +286,7 @@ export const Components = { rows: 'Query editor row', }, QueryEditorRow: { - actionButton: (title: string) => `${title}`, + actionButton: (title: string) => `data-testid ${title}`, title: (refId: string) => `Query editor row title ${refId}`, container: (refId: string) => `Query editor row ${refId}`, }, @@ -231,6 +308,7 @@ export const Components = { }, Transforms: { card: (name: string) => `data-testid New transform ${name}`, + disableTransformationButton: 'data-testid Disable transformation button', Reduce: { modeLabel: 'data-testid Transform mode label', calculationsLabel: 'data-testid Transform calculations label', @@ -259,6 +337,7 @@ export const Components = { searchInput: 'data-testid search transformations', noTransformationsMessage: 'data-testid no transformations message', addTransformationButton: 'data-testid add transformation button', + removeAllTransformationsButton: 'data-testid remove all transformations button', }, NavBar: { Configuration: { @@ -287,7 +366,7 @@ export const Components = { button: (title: string) => `QueryEditor toolbar item button ${title}`, }, BackButton: { - backArrow: 'Go Back', + backArrow: 'data-testid Go Back', }, OptionsGroup: { group: (title?: string) => (title ? `Options group ${title}` : 'Options group'), @@ -314,7 +393,7 @@ export const Components = { */ container: 'Folder picker select container', containerV2: 'data-testid Folder picker select container', - input: 'Select a folder', + input: 'data-testid folder-picker-input', }, ReadonlyFolderPicker: { container: 'data-testid Readonly folder picker select container', @@ -326,6 +405,11 @@ export const Components = { */ input: () => 'input[id="data-source-picker"]', inputV2: 'data-testid Select a data source', + dataSourceList: 'data-testid Data source list dropdown', + advancedModal: { + dataSourceList: 'data-testid Data source list', + builtInDataSourceList: 'data-testid Built in data source list', + }, }, TimeZonePicker: { /** @@ -333,6 +417,7 @@ export const Components = { */ container: 'Time zone picker select container', containerV2: 'data-testid Time zone picker select container', + changeTimeSettingsButton: 'data-testid Time zone picker Change time settings button', }, WeekStartPicker: { /** @@ -382,7 +467,7 @@ export const Components = { link: 'data-testid Dashboard link', }, LoadingIndicator: { - icon: 'Loading indicator', + icon: 'data-testid Loading indicator', }, CallToActionCard: { /** @@ -444,6 +529,10 @@ export const Components = { Annotations: { annotationsTypeInput: 'annotations-type-input', annotationsChoosePanelInput: 'choose-panels-input', + editor: { + testButton: 'data-testid annotations-test-button', + resultContainer: 'data-testid annotations-query-result-container', + }, }, Tooltip: { container: 'data-testid tooltip', diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 2ab4555591fbe..7b820ee07fcd9 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -56,6 +56,12 @@ export const Pages = { nav: 'Dashboard navigation', navV2: 'data-testid Dashboard navigation', publicDashboardTag: 'data-testid public dashboard tag', + shareButton: 'data-testid share-button', + playlistControls: { + prev: 'data-testid playlist previous dashboard button', + stop: 'data-testid playlist stop dashboard button', + next: 'data-testid playlist next dashboard button', + }, }, SubMenu: { submenu: 'Dashboard submenu', @@ -94,6 +100,7 @@ export const Pages = { */ addAnnotationCTA: Components.CallToActionCard.button('Add annotation query'), addAnnotationCTAV2: Components.CallToActionCard.buttonV2('Add annotation query'), + annotations: 'data-testid list-annotations', }, Settings: { name: 'Annotations settings name input', @@ -102,6 +109,10 @@ export const Pages = { panelFilterSelect: 'data-testid annotations-panel-filter', showInLabel: 'show-in-label', previewInDashboard: 'data-testid annotations-preview', + delete: 'data-testid annotations-delete', + apply: 'data-testid annotations-apply', + enable: 'data-testid annotation-enable', + hide: 'data-testid annotation-hide', }, }, Variables: { @@ -136,23 +147,22 @@ export const Pages = { generalLabelInputV2: 'data-testid Variable editor Form Label field', generalHideSelect: 'Variable editor Form Hide select', generalHideSelectV2: 'data-testid Variable editor Form Hide select', - selectionOptionsMultiSwitch: 'Variable editor Form Multi switch', - selectionOptionsIncludeAllSwitch: 'Variable editor Form IncludeAll switch', - selectionOptionsCustomAllInput: 'Variable editor Form IncludeAll field', - selectionOptionsCustomAllInputV2: 'data-testid Variable editor Form IncludeAll field', - previewOfValuesOption: 'Variable editor Preview of Values option', - submitButton: 'Variable editor Submit button', + selectionOptionsMultiSwitch: 'data-testid Variable editor Form Multi switch', + selectionOptionsIncludeAllSwitch: 'data-testid Variable editor Form IncludeAll switch', + selectionOptionsCustomAllInput: 'data-testid Variable editor Form IncludeAll field', + previewOfValuesOption: 'data-testid Variable editor Preview of Values option', + submitButton: 'data-testid Variable editor Run Query button', applyButton: 'data-testid Variable editor Apply button', }, QueryVariable: { - queryOptionsDataSourceSelect: Components.DataSourcePicker.container, + queryOptionsDataSourceSelect: Components.DataSourcePicker.inputV2, queryOptionsRefreshSelect: 'Variable editor Form Query Refresh select', queryOptionsRefreshSelectV2: 'data-testid Variable editor Form Query Refresh select', queryOptionsRegExInput: 'Variable editor Form Query RegEx field', queryOptionsRegExInputV2: 'data-testid Variable editor Form Query RegEx field', queryOptionsSortSelect: 'Variable editor Form Query Sort select', queryOptionsSortSelectV2: 'data-testid Variable editor Form Query Sort select', - queryOptionsQueryInput: 'Variable editor Form Default Variable Query Editor textarea', + queryOptionsQueryInput: 'data-testid Variable editor Form Default Variable Query Editor textarea', valueGroupsTagsEnabledSwitch: 'Variable editor Form Query UseTags switch', valueGroupsTagsTagsQueryInput: 'Variable editor Form Query TagsQuery field', valueGroupsTagsTagsValuesQueryInput: 'Variable editor Form Query TagsValuesQuery field', @@ -173,6 +183,19 @@ export const Pages = { }, IntervalVariable: { intervalsValueInput: 'data-testid interval variable intervals input', + autoEnabledCheckbox: 'data-testid interval variable auto value checkbox', + stepCountIntervalSelect: 'data-testid interval variable step count input', + minIntervalInput: 'data-testid interval variable mininum interval input', + }, + GroupByVariable: { + dataSourceSelect: Components.DataSourcePicker.inputV2, + infoText: 'data-testid group by variable info text', + modeToggle: 'data-testid group by variable mode toggle', + }, + AdHocFiltersVariable: { + datasourceSelect: Components.DataSourcePicker.inputV2, + infoText: 'data-testid ad-hoc filters variable info text', + modeToggle: 'data-testid ad-hoc filters variable mode toggle', }, }, }, @@ -208,7 +231,6 @@ export const Pages = { linkToRenderedImage: 'Link to rendered image', }, ShareDashboardModal: { - shareButton: 'Share dashboard', PublicDashboard: { Tab: 'Tab Public dashboard', WillBePublicCheckbox: 'data-testid public dashboard will be public checkbox', @@ -329,6 +351,9 @@ export const Pages = { UsersListPage: { container: 'data-testid users-list-page', }, + UserAnonListPage: { + container: 'data-testid user-anon-list-page', + }, UsersListPublicDashboardsPage: { container: 'data-testid users-list-public-dashboards-page', DashboardsListModal: { diff --git a/packages/grafana-e2e/cypress/fixtures/exemplars-query-response.json b/packages/grafana-e2e/cypress/fixtures/exemplars-query-response.json index c90d2c8457e87..6c26a9fbf7d9d 100644 --- a/packages/grafana-e2e/cypress/fixtures/exemplars-query-response.json +++ b/packages/grafana-e2e/cypress/fixtures/exemplars-query-response.json @@ -23,50 +23,16 @@ "data": { "values": [ [ - 1633619595000, - 1633619610000, - 1633619625000, - 1633619640000, - 1633619655000, - 1633619670000, - 1633619685000, - 1633619700000, - 1633619715000, - 1633619730000, - 1633619745000, - 1633619760000, - 1633619775000, - 1633619790000, - 1633619805000, - 1633619820000, - 1633619835000, - 1633619850000, - 1633619865000, - 1633619880000, - 1633619895000 + 1633619595000, 1633619610000, 1633619625000, 1633619640000, 1633619655000, 1633619670000, 1633619685000, + 1633619700000, 1633619715000, 1633619730000, 1633619745000, 1633619760000, 1633619775000, 1633619790000, + 1633619805000, 1633619820000, 1633619835000, 1633619850000, 1633619865000, 1633619880000, 1633619895000 ], [ - 0.07245212135073513, - 0.07253198890830721, - 0.07247862573797707, - 0.07238248338231042, - 0.07221687487740913, - 0.07223291298743946, - 0.07225427016727755, - 0.024531677091864545, - 0.02317081920915543, - 0.07548902139580993, - 0.0777721702857508, - 0.07768649905047344, - 0.07782257603228229, - 0.07788810213200052, - 0.07791835055437593, - 0.07798387201529966, - 0.07790826751849372, - 0.07794858648610933, - 0.07778729925797964, - 0.07769657495236215, - 0.077550401329267 + 0.07245212135073513, 0.07253198890830721, 0.07247862573797707, 0.07238248338231042, 0.07221687487740913, + 0.07223291298743946, 0.07225427016727755, 0.024531677091864545, 0.02317081920915543, + 0.07548902139580993, 0.0777721702857508, 0.07768649905047344, 0.07782257603228229, 0.07788810213200052, + 0.07791835055437593, 0.07798387201529966, 0.07790826751849372, 0.07794858648610933, 0.07778729925797964, + 0.07769657495236215, 0.077550401329267 ] ] } @@ -113,54 +79,15 @@ "data": { "values": [ [ - 1633619598000, - 1633619622000, - 1633619625000, - 1633619646000, - 1633619658000, - 1633619682000, - 1633619695000, - 1633619712000, - 1633619712000, - 1633619724000, - 1633619717000, - 1633619742000, - 1633619757000, - 1633619771000, - 1633619784000, - 1633619801000, - 1633619806000, - 1633619833000, - 1633619833000, - 1633619845000, - 1633619862000, - 1633619877000, - 1633619889000 + 1633619598000, 1633619622000, 1633619625000, 1633619646000, 1633619658000, 1633619682000, 1633619695000, + 1633619712000, 1633619712000, 1633619724000, 1633619717000, 1633619742000, 1633619757000, 1633619771000, + 1633619784000, 1633619801000, 1633619806000, 1633619833000, 1633619833000, 1633619845000, 1633619862000, + 1633619877000, 1633619889000 ], [ - 0.0146153, - 0.0118506, - 0.0473847, - 0.026997, - 0.0164318, - 0.0113532, - 0.0105197, - 0.162789, - 0.0556026, - 0.148856, - 0.0433809, - 0.0117758, - 0.0114496, - 0.0114099, - 0.0421927, - 0.0134148, - 0.0152827, - 0.6975967, - 0.0394788, - 0.0137441, - 0.0110939, - 0.0104496, - 0.0101284 + 0.0146153, 0.0118506, 0.0473847, 0.026997, 0.0164318, 0.0113532, 0.0105197, 0.162789, 0.0556026, + 0.148856, 0.0433809, 0.0117758, 0.0114496, 0.0114099, 0.0421927, 0.0134148, 0.0152827, 0.6975967, + 0.0394788, 0.0137441, 0.0110939, 0.0104496, 0.0101284 ], [ "app:80", diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json index 97e65cf358113..09bb0bb405aa4 100644 --- a/packages/grafana-e2e/package.json +++ b/packages/grafana-e2e/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/e2e", - "version": "10.3.0-pre", + "version": "11.0.0-pre", "description": "Grafana End-to-End Test Library", "keywords": [ "cli", @@ -63,9 +63,9 @@ "@babel/core": "7.23.2", "@babel/preset-env": "7.23.2", "@cypress/webpack-preprocessor": "5.17.1", - "@grafana/e2e-selectors": "10.3.0-pre", - "@grafana/schema": "10.3.0-pre", - "@grafana/tsconfig": "^1.2.0-rc1", + "@grafana/e2e-selectors": "11.0.0-pre", + "@grafana/schema": "11.0.0-pre", + "@grafana/tsconfig": "^1.3.0-rc1", "@mochajs/json-file-reporter": "^1.2.0", "babel-loader": "9.1.3", "blink-diff": "1.0.13", diff --git a/packages/grafana-e2e/src/support/types.ts b/packages/grafana-e2e/src/support/types.ts index e44a978194c4e..60a275882ad72 100644 --- a/packages/grafana-e2e/src/support/types.ts +++ b/packages/grafana-e2e/src/support/types.ts @@ -14,14 +14,14 @@ export type E2EFunctionWithOnlyOptions = (options?: CypressOptions) => Cypress.C export type TypeSelectors<S> = S extends StringSelector ? E2EFunctionWithOnlyOptions : S extends FunctionSelector - ? E2EFunction - : S extends CssSelector - ? E2EFunction - : S extends UrlSelector - ? E2EVisit & Omit<E2EFunctions<S>, 'url'> - : S extends Record<any, any> - ? E2EFunctions<S> - : S; + ? E2EFunction + : S extends CssSelector + ? E2EFunction + : S extends UrlSelector + ? E2EVisit & Omit<E2EFunctions<S>, 'url'> + : S extends Record<any, any> + ? E2EFunctions<S> + : S; export type E2EFunctions<S extends Selectors> = { [P in keyof S]: TypeSelectors<S[P]>; diff --git a/packages/grafana-eslint-rules/README.md b/packages/grafana-eslint-rules/README.md index cf90384e326db..1bd800c979e25 100644 --- a/packages/grafana-eslint-rules/README.md +++ b/packages/grafana-eslint-rules/README.md @@ -1,6 +1,6 @@ # Grafana ESLint Rules -This package contains custom eslint rules for use within the Grafana codebase only. They're extremley specific to our codebase, and are of little use to anyone else. They're not published to NPM, and are consumed through the Yarn workspace. +This package contains custom eslint rules for use within the Grafana codebase only. They're extremely specific to our codebase, and are of little use to anyone else. They're not published to NPM, and are consumed through the Yarn workspace. ## Rules diff --git a/packages/grafana-eslint-rules/package.json b/packages/grafana-eslint-rules/package.json index ecabd01befb5a..28cbed28e0eb8 100644 --- a/packages/grafana-eslint-rules/package.json +++ b/packages/grafana-eslint-rules/package.json @@ -1,7 +1,7 @@ { "name": "@grafana/eslint-plugin", "description": "ESLint rules for use within the Grafana repo. Not suitable (or supported) for external use.", - "version": "10.3.0-pre", + "version": "11.0.0-pre", "main": "./index.cjs", "author": "Grafana Labs", "license": "Apache-2.0", @@ -11,12 +11,12 @@ "directory": "packages/grafana-eslint-rules" }, "dependencies": { - "@typescript-eslint/utils": "^5.46.1" + "@typescript-eslint/utils": "^6.0.0" }, "devDependencies": { - "@typescript-eslint/types": "^5.46.1", - "eslint": "8.52.0", - "tslib": "2.6.0" + "@typescript-eslint/types": "^6.0.0", + "eslint": "8.57.0", + "tslib": "2.6.2" }, "private": true } diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index c41e2a11da3d8..c1c3371f8a1e6 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/flamegraph", - "version": "10.3.0-pre", + "version": "11.0.0-pre", "description": "Grafana flamegraph visualization component", "keywords": [ "grafana", @@ -44,32 +44,32 @@ ], "dependencies": { "@emotion/css": "11.11.2", - "@grafana/data": "10.3.0-pre", - "@grafana/ui": "10.3.0-pre", - "@leeoniya/ufuzzy": "1.0.13", + "@grafana/data": "11.0.0-pre", + "@grafana/ui": "11.0.0-pre", + "@leeoniya/ufuzzy": "1.0.14", "d3": "^7.8.5", "lodash": "4.17.21", "react": "18.2.0", - "react-use": "17.4.0", - "react-virtualized-auto-sizer": "1.0.7", + "react-use": "17.5.0", + "react-virtualized-auto-sizer": "1.0.24", "tinycolor2": "1.6.0", - "tslib": "2.6.0" + "tslib": "2.6.2" }, "devDependencies": { - "@babel/core": "7.23.2", - "@babel/preset-env": "7.23.2", - "@babel/preset-react": "7.22.5", - "@grafana/tsconfig": "^1.2.0-rc1", + "@babel/core": "7.24.0", + "@babel/preset-env": "7.24.0", + "@babel/preset-react": "7.23.3", + "@grafana/tsconfig": "^1.3.0-rc1", "@rollup/plugin-node-resolve": "15.2.3", "@testing-library/jest-dom": "^6.1.2", - "@testing-library/react": "14.0.0", - "@testing-library/user-event": "14.5.1", + "@testing-library/react": "14.2.1", + "@testing-library/user-event": "14.5.2", "@types/d3": "^7", "@types/jest": "^29.5.4", - "@types/lodash": "4.14.195", - "@types/react": "18.2.15", - "@types/react-virtualized-auto-sizer": "1.0.1", - "@types/tinycolor2": "1.4.3", + "@types/lodash": "4.17.0", + "@types/react": "18.2.66", + "@types/react-virtualized-auto-sizer": "1.0.4", + "@types/tinycolor2": "1.4.6", "babel-jest": "29.7.0", "jest": "^29.6.4", "jest-canvas-mock": "2.5.2", @@ -77,12 +77,12 @@ "rollup-plugin-dts": "^5.0.0", "rollup-plugin-esbuild": "5.0.0", "rollup-plugin-node-externals": "^5.0.0", - "ts-jest": "29.1.1", - "ts-node": "10.9.1", - "typescript": "5.2.2" + "ts-jest": "29.1.2", + "ts-node": "10.9.2", + "typescript": "5.3.3" }, "peerDependencies": { - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "react": "^18.0.0", + "react-dom": "^18.0.0" } } diff --git a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.test.tsx b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.test.tsx index 93fb6b71eb370..90fdf831406af 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.test.tsx +++ b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { createDataFrame } from '@grafana/data'; -import { ColorScheme } from '../types'; +import { ColorScheme, SelectedView } from '../types'; import FlameGraph from './FlameGraph'; import { FlameGraphDataContainer } from './dataTransform'; @@ -23,7 +23,7 @@ jest.mock('react-use', () => { }); describe('FlameGraph', () => { - function setup() { + function setup(props?: Partial<React.ComponentProps<typeof FlameGraph>>) { const flameGraphData = createDataFrame(data); const container = new FlameGraphDataContainer(flameGraphData, { collapsing: true }); @@ -47,6 +47,9 @@ describe('FlameGraph', () => { onFocusPillClick={onFocusPillClick} onSandwichPillClick={onSandwichPillClick} colorScheme={ColorScheme.ValueBased} + selectedView={SelectedView.FlameGraph} + search={''} + {...props} /> ); return { @@ -80,18 +83,31 @@ describe('FlameGraph', () => { expect(screen.getByText('16.5 Bil | 16.5 Bil samples (Count)')).toBeDefined(); }); - it('should render context menu', async () => { + it('should render context menu + extra items', async () => { const event = new MouseEvent('click', { bubbles: true }); Object.defineProperty(event, 'offsetX', { get: () => 10 }); Object.defineProperty(event, 'offsetY', { get: () => 10 }); Object.defineProperty(HTMLCanvasElement.prototype, 'clientWidth', { configurable: true, value: 500 }); - setup(); + setup({ + getExtraContextMenuButtons: (clickedItemData, data, state) => { + expect(clickedItemData).toMatchObject({ posX: 0, posY: 0, label: 'total' }); + expect(data.length).toEqual(1101); + expect(state).toEqual({ + selectedView: SelectedView.FlameGraph, + isDiff: false, + search: '', + collapseConfig: undefined, + }); + return [{ label: 'test extra item', icon: 'eye', onClick: () => {} }]; + }, + }); const canvas = screen.getByTestId('flameGraph') as HTMLCanvasElement; expect(canvas).toBeInTheDocument(); expect(screen.queryByTestId('contextMenu')).not.toBeInTheDocument(); fireEvent(canvas, event); expect(screen.getByTestId('contextMenu')).toBeInTheDocument(); + expect(screen.getByText('test extra item')).toBeInTheDocument(); }); }); diff --git a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx index 6bd6bb1ab9fb4..0a207cb94ea72 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx +++ b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx @@ -22,9 +22,10 @@ import React, { useEffect, useState } from 'react'; import { Icon } from '@grafana/ui'; import { PIXELS_PER_LEVEL } from '../constants'; -import { ClickedItemData, ColorScheme, ColorSchemeDiff, TextAlign } from '../types'; +import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from '../types'; import FlameGraphCanvas from './FlameGraphCanvas'; +import { GetExtraContextMenuButtonsFunction } from './FlameGraphContextMenu'; import FlameGraphMetadata from './FlameGraphMetadata'; import { CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform'; @@ -45,7 +46,10 @@ type Props = { onSandwichPillClick: () => void; colorScheme: ColorScheme | ColorSchemeDiff; showFlameGraphOnly?: boolean; + getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction; collapsing?: boolean; + selectedView: SelectedView; + search: string; }; const FlameGraph = ({ @@ -64,7 +68,10 @@ const FlameGraph = ({ onSandwichPillClick, colorScheme, showFlameGraphOnly, + getExtraContextMenuButtons, collapsing, + selectedView, + search, }: Props) => { const styles = getStyles(); @@ -122,7 +129,10 @@ const FlameGraph = ({ showFlameGraphOnly, collapsedMap, setCollapsedMap, + getExtraContextMenuButtons, collapsing, + search, + selectedView, }; const canvas = levelsCallers ? ( <> diff --git a/packages/grafana-flamegraph/src/FlameGraph/FlameGraphCanvas.tsx b/packages/grafana-flamegraph/src/FlameGraph/FlameGraphCanvas.tsx index c5b067abea201..3d4f43a644e28 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/FlameGraphCanvas.tsx +++ b/packages/grafana-flamegraph/src/FlameGraph/FlameGraphCanvas.tsx @@ -3,9 +3,9 @@ import React, { MouseEvent as ReactMouseEvent, useCallback, useEffect, useRef, u import { useMeasure } from 'react-use'; import { PIXELS_PER_LEVEL } from '../constants'; -import { ClickedItemData, ColorScheme, ColorSchemeDiff, TextAlign } from '../types'; +import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from '../types'; -import FlameGraphContextMenu from './FlameGraphContextMenu'; +import FlameGraphContextMenu, { GetExtraContextMenuButtonsFunction } from './FlameGraphContextMenu'; import FlameGraphTooltip from './FlameGraphTooltip'; import { CollapseConfig, CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform'; import { getBarX, useFlameRender } from './rendering'; @@ -37,6 +37,10 @@ type Props = { collapsedMap: CollapsedMap; setCollapsedMap: (collapsedMap: CollapsedMap) => void; collapsing?: boolean; + getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction; + + selectedView: SelectedView; + search: string; }; const FlameGraphCanvas = ({ @@ -61,6 +65,9 @@ const FlameGraphCanvas = ({ collapsedMap, setCollapsedMap, collapsing, + getExtraContextMenuButtons, + selectedView, + search, }: Props) => { const styles = getStyles(); @@ -186,6 +193,7 @@ const FlameGraphCanvas = ({ /> {!showFlameGraphOnly && clickedItemData && ( <FlameGraphContextMenu + data={data} itemData={clickedItemData} collapsing={collapsing} collapseConfig={collapsedMap.get(clickedItemData.item)} @@ -214,6 +222,9 @@ const FlameGraphCanvas = ({ }} allGroupsCollapsed={Array.from(collapsedMap.values()).every((i) => i.collapsed)} allGroupsExpanded={Array.from(collapsedMap.values()).every((i) => !i.collapsed)} + getExtraContextMenuButtons={getExtraContextMenuButtons} + selectedView={selectedView} + search={search} /> )} </div> diff --git a/packages/grafana-flamegraph/src/FlameGraph/FlameGraphContextMenu.tsx b/packages/grafana-flamegraph/src/FlameGraph/FlameGraphContextMenu.tsx index 3a370f5dc86e5..c84d4810f420c 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/FlameGraphContextMenu.tsx +++ b/packages/grafana-flamegraph/src/FlameGraph/FlameGraphContextMenu.tsx @@ -1,12 +1,26 @@ import React from 'react'; -import { MenuItem, MenuGroup, ContextMenu } from '@grafana/ui'; +import { DataFrame } from '@grafana/data'; +import { MenuItem, MenuGroup, ContextMenu, IconName } from '@grafana/ui'; -import { ClickedItemData } from '../types'; +import { ClickedItemData, SelectedView } from '../types'; -import { CollapseConfig } from './dataTransform'; +import { CollapseConfig, FlameGraphDataContainer } from './dataTransform'; + +export type GetExtraContextMenuButtonsFunction = ( + clickedItemData: ClickedItemData, + data: DataFrame, + state: { selectedView: SelectedView; isDiff: boolean; search: string; collapseConfig?: CollapseConfig } +) => ExtraContextMenuButton[]; + +export type ExtraContextMenuButton = { + label: string; + icon: IconName; + onClick: () => void; +}; type Props = { + data: FlameGraphDataContainer; itemData: ClickedItemData; onMenuItemClick: () => void; onItemFocus: () => void; @@ -15,13 +29,17 @@ type Props = { onCollapseGroup: () => void; onExpandAllGroups: () => void; onCollapseAllGroups: () => void; + getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction; collapseConfig?: CollapseConfig; collapsing?: boolean; allGroupsCollapsed?: boolean; allGroupsExpanded?: boolean; + selectedView: SelectedView; + search: string; }; const FlameGraphContextMenu = ({ + data, itemData, onMenuItemClick, onItemFocus, @@ -31,11 +49,21 @@ const FlameGraphContextMenu = ({ onCollapseGroup, onExpandAllGroups, onCollapseAllGroups, + getExtraContextMenuButtons, collapsing, allGroupsExpanded, allGroupsCollapsed, + selectedView, + search, }: Props) => { function renderItems() { + const extraButtons = + getExtraContextMenuButtons?.(itemData, data.data, { + selectedView, + isDiff: data.isDiffFlamegraph(), + search, + collapseConfig, + }) || []; return ( <> <MenuItem @@ -63,7 +91,9 @@ const FlameGraphContextMenu = ({ onMenuItemClick(); }} /> - + {extraButtons.map(({ label, icon, onClick }) => { + return <MenuItem label={label} icon={icon} onClick={() => onClick()} key={label} />; + })} {collapsing && ( <MenuGroup label={'Grouping'}> {collapseConfig ? ( diff --git a/packages/grafana-flamegraph/src/FlameGraph/dataTransform.ts b/packages/grafana-flamegraph/src/FlameGraph/dataTransform.ts index 1fb8461947643..142500c926e57 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/dataTransform.ts +++ b/packages/grafana-flamegraph/src/FlameGraph/dataTransform.ts @@ -272,7 +272,7 @@ export class FlameGraphDataContainer { } isDiffFlamegraph() { - return this.valueRightField && this.selfRightField; + return Boolean(this.valueRightField && this.selfRightField); } getLabel(index: number) { diff --git a/packages/grafana-flamegraph/src/FlameGraph/rendering.ts b/packages/grafana-flamegraph/src/FlameGraph/rendering.ts index 7e28eba707946..96b25b93abb0d 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/rendering.ts +++ b/packages/grafana-flamegraph/src/FlameGraph/rendering.ts @@ -358,8 +358,8 @@ function useColorFunction( (colorScheme === ColorSchemeDiff.Default || colorScheme === ColorSchemeDiff.DiffColorBlind) ? getBarColorByDiff(item.value, item.valueRight!, totalTicks, totalTicksRight!, colorScheme) : colorScheme === ColorScheme.ValueBased - ? getBarColorByValue(item.value, totalTicks, rangeMin, rangeMax) - : getBarColorByPackage(label, theme); + ? getBarColorByValue(item.value, totalTicks, rangeMin, rangeMax) + : getBarColorByPackage(label, theme); if (matchedLabels) { // Means we are searching, we use color for matches and gray the rest diff --git a/packages/grafana-flamegraph/src/FlameGraphContainer.tsx b/packages/grafana-flamegraph/src/FlameGraphContainer.tsx index df8435c0dadfa..c4962e5b181ec 100644 --- a/packages/grafana-flamegraph/src/FlameGraphContainer.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphContainer.tsx @@ -7,6 +7,7 @@ import { DataFrame, GrafanaTheme2 } from '@grafana/data'; import { ThemeContext } from '@grafana/ui'; import FlameGraph from './FlameGraph/FlameGraph'; +import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu'; import { FlameGraphDataContainer } from './FlameGraph/dataTransform'; import FlameGraphHeader from './FlameGraphHeader'; import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer'; @@ -52,6 +53,11 @@ export type Props = { */ extraHeaderElements?: React.ReactNode; + /** + * Extra buttons that will be shown in the context menu when user clicks on a Node. + */ + getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction; + /** * If true the flamegraph will be rendered on top of the table. */ @@ -80,6 +86,7 @@ const FlameGraphContainer = ({ vertical, showFlameGraphOnly, disableCollapsing, + getExtraContextMenuButtons, }: Props) => { const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>(); @@ -169,6 +176,9 @@ const FlameGraphContainer = ({ colorScheme={colorScheme} showFlameGraphOnly={showFlameGraphOnly} collapsing={!disableCollapsing} + getExtraContextMenuButtons={getExtraContextMenuButtons} + selectedView={selectedView} + search={search} /> ); @@ -238,7 +248,7 @@ const FlameGraphContainer = ({ stickyHeader={Boolean(stickyHeader)} extraHeaderElements={extraHeaderElements} vertical={vertical} - isDiffMode={Boolean(dataContainer.isDiffFlamegraph())} + isDiffMode={dataContainer.isDiffFlamegraph()} /> )} diff --git a/packages/grafana-flamegraph/src/types.ts b/packages/grafana-flamegraph/src/types.ts index c4616a2684742..aa8a84de8822c 100644 --- a/packages/grafana-flamegraph/src/types.ts +++ b/packages/grafana-flamegraph/src/types.ts @@ -1,5 +1,9 @@ import { LevelItem } from './FlameGraph/dataTransform'; +export { type FlameGraphDataContainer } from './FlameGraph/dataTransform'; + +export { type ExtraContextMenuButton } from './FlameGraph/FlameGraphContextMenu'; + export type ClickedItemData = { posX: number; posY: number; diff --git a/packages/grafana-o11y-ds-frontend/package.json b/packages/grafana-o11y-ds-frontend/package.json new file mode 100644 index 0000000000000..5a5a8dee56b82 --- /dev/null +++ b/packages/grafana-o11y-ds-frontend/package.json @@ -0,0 +1,50 @@ +{ + "author": "Grafana Labs", + "license": "AGPL-3.0-only", + "name": "@grafana/o11y-ds-frontend", + "private": true, + "version": "11.0.0-pre", + "description": "Library to manage traces in Grafana.", + "sideEffects": false, + "repository": { + "type": "git", + "url": "http://github.com/grafana/grafana.git", + "directory": "packages/grafana-o11y-ds-frontend" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "typecheck": "tsc --emitDeclarationOnly false --noEmit" + }, + "dependencies": { + "@emotion/css": "11.11.2", + "@grafana/data": "11.0.0-pre", + "@grafana/e2e-selectors": "11.0.0-pre", + "@grafana/experimental": "1.7.10", + "@grafana/runtime": "11.0.0-pre", + "@grafana/schema": "11.0.0-pre", + "@grafana/ui": "11.0.0-pre", + "react-use": "17.5.0", + "rxjs": "7.8.1", + "tslib": "2.6.2" + }, + "devDependencies": { + "@grafana/tsconfig": "^1.3.0-rc1", + "@testing-library/jest-dom": "^6.1.2", + "@testing-library/react": "14.2.1", + "@testing-library/user-event": "14.5.2", + "@types/jest": "^29.5.4", + "@types/react": "18.2.66", + "@types/systemjs": "6.13.5", + "@types/testing-library__jest-dom": "5.14.9", + "jest": "^29.6.4", + "react": "18.2.0", + "ts-jest": "29.1.2", + "ts-node": "10.9.2", + "typescript": "5.3.3" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } +} diff --git a/public/app/core/components/IntervalInput/IntervalInput.test.tsx b/packages/grafana-o11y-ds-frontend/src/IntervalInput/IntervalInput.test.tsx similarity index 100% rename from public/app/core/components/IntervalInput/IntervalInput.test.tsx rename to packages/grafana-o11y-ds-frontend/src/IntervalInput/IntervalInput.test.tsx diff --git a/public/app/core/components/IntervalInput/IntervalInput.tsx b/packages/grafana-o11y-ds-frontend/src/IntervalInput/IntervalInput.tsx similarity index 100% rename from public/app/core/components/IntervalInput/IntervalInput.tsx rename to packages/grafana-o11y-ds-frontend/src/IntervalInput/IntervalInput.tsx diff --git a/public/app/core/components/IntervalInput/validation.test.ts b/packages/grafana-o11y-ds-frontend/src/IntervalInput/validation.test.ts similarity index 100% rename from public/app/core/components/IntervalInput/validation.test.ts rename to packages/grafana-o11y-ds-frontend/src/IntervalInput/validation.test.ts diff --git a/public/app/core/components/IntervalInput/validation.ts b/packages/grafana-o11y-ds-frontend/src/IntervalInput/validation.ts similarity index 100% rename from public/app/core/components/IntervalInput/validation.ts rename to packages/grafana-o11y-ds-frontend/src/IntervalInput/validation.ts diff --git a/packages/grafana-o11y-ds-frontend/src/LocalStorageValueProvider/LocalStorageValueProvider.tsx b/packages/grafana-o11y-ds-frontend/src/LocalStorageValueProvider/LocalStorageValueProvider.tsx new file mode 100644 index 0000000000000..b81935d433740 --- /dev/null +++ b/packages/grafana-o11y-ds-frontend/src/LocalStorageValueProvider/LocalStorageValueProvider.tsx @@ -0,0 +1,49 @@ +import React, { useEffect, useState } from 'react'; + +import { store } from '../store'; + +export interface Props<T> { + storageKey: string; + defaultValue: T; + children: (value: T, onSaveToStore: (value: T) => void, onDeleteFromStore: () => void) => React.ReactNode; +} + +export const LocalStorageValueProvider = <T,>(props: Props<T>) => { + const { children, storageKey, defaultValue } = props; + + const [state, setState] = useState({ value: store.getObject(props.storageKey, props.defaultValue) }); + + useEffect(() => { + const onStorageUpdate = (v: StorageEvent) => { + if (v.key === storageKey) { + setState({ value: store.getObject(props.storageKey, props.defaultValue) }); + } + }; + + window.addEventListener('storage', onStorageUpdate); + + return () => { + window.removeEventListener('storage', onStorageUpdate); + }; + }); + + const onSaveToStore = (value: T) => { + try { + store.setObject(storageKey, value); + } catch (error) { + console.error(error); + } + setState({ value }); + }; + + const onDeleteFromStore = () => { + try { + store.delete(storageKey); + } catch (error) { + console.log(error); + } + setState({ value: defaultValue }); + }; + + return <>{children(state.value, onSaveToStore, onDeleteFromStore)}</>; +}; diff --git a/public/app/core/components/NodeGraphSettings.tsx b/packages/grafana-o11y-ds-frontend/src/NodeGraph/NodeGraphSettings.tsx similarity index 94% rename from public/app/core/components/NodeGraphSettings.tsx rename to packages/grafana-o11y-ds-frontend/src/NodeGraph/NodeGraphSettings.tsx index 5fb7dcb3eb157..6544b33386999 100644 --- a/public/app/core/components/NodeGraphSettings.tsx +++ b/packages/grafana-o11y-ds-frontend/src/NodeGraph/NodeGraphSettings.tsx @@ -7,11 +7,9 @@ import { GrafanaTheme2, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; -import { ConfigSubSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/experimental'; import { InlineField, InlineFieldRow, InlineSwitch, useStyles2 } from '@grafana/ui'; -import { ConfigDescriptionLink } from './ConfigDescriptionLink'; - export interface NodeGraphOptions { enabled?: boolean; } diff --git a/packages/grafana-o11y-ds-frontend/src/SpanBar/SpanBarSettings.tsx b/packages/grafana-o11y-ds-frontend/src/SpanBar/SpanBarSettings.tsx new file mode 100644 index 0000000000000..81780446eddc9 --- /dev/null +++ b/packages/grafana-o11y-ds-frontend/src/SpanBar/SpanBarSettings.tsx @@ -0,0 +1,110 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { + DataSourceJsonData, + DataSourcePluginOptionsEditorProps, + GrafanaTheme2, + toOption, + updateDatasourcePluginJsonDataOption, +} from '@grafana/data'; +import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/experimental'; +import { InlineField, InlineFieldRow, Input, Select, useStyles2 } from '@grafana/ui'; + +export interface SpanBarOptions { + type?: string; + tag?: string; +} + +export interface SpanBarOptionsData extends DataSourceJsonData { + spanBar?: SpanBarOptions; +} + +export const NONE = 'None'; +export const DURATION = 'Duration'; +export const TAG = 'Tag'; + +interface Props extends DataSourcePluginOptionsEditorProps<SpanBarOptionsData> {} + +export default function SpanBarSettings({ options, onOptionsChange }: Props) { + const styles = useStyles2(getStyles); + const selectOptions = [NONE, DURATION, TAG].map(toOption); + + return ( + <div className={css({ width: '100%' })}> + <InlineFieldRow className={styles.row}> + <InlineField label="Label" labelWidth={26} tooltip="Default: duration" grow> + <Select + inputId="label" + options={selectOptions} + value={options.jsonData.spanBar?.type || ''} + onChange={(v) => { + updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'spanBar', { + ...options.jsonData.spanBar, + type: v?.value ?? '', + }); + }} + placeholder="Duration" + isClearable + aria-label={'select-label-name'} + width={40} + /> + </InlineField> + </InlineFieldRow> + {options.jsonData.spanBar?.type === TAG && ( + <InlineFieldRow className={styles.row}> + <InlineField + label="Tag key" + labelWidth={26} + tooltip="Tag key which will be used to get the tag value. A span's attributes and resources will be searched for the tag key" + > + <Input + type="text" + placeholder="Enter tag key" + onChange={(v) => + updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'spanBar', { + ...options.jsonData.spanBar, + tag: v.currentTarget.value, + }) + } + value={options.jsonData.spanBar?.tag || ''} + width={40} + /> + </InlineField> + </InlineFieldRow> + )} + </div> + ); +} + +export const SpanBarSection = ({ options, onOptionsChange }: DataSourcePluginOptionsEditorProps) => { + let suffix = options.type; + suffix += options.type === 'tempo' ? '/configure-tempo-data-source/#span-bar' : '/#span-bar'; + + return ( + <ConfigSubSection + title="Span bar" + description={ + <ConfigDescriptionLink + description="Add additional info next to the service and operation on a span bar row in the trace view." + suffix={suffix} + feature="the span bar" + /> + } + > + <SpanBarSettings options={options} onOptionsChange={onOptionsChange} /> + </ConfigSubSection> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + infoText: css({ + label: 'infoText', + paddingBottom: theme.spacing(2), + color: theme.colors.text.secondary, + }), + row: css({ + label: 'row', + alignItems: 'baseline', + }), +}); diff --git a/packages/grafana-o11y-ds-frontend/src/TemporaryAlert.test.tsx b/packages/grafana-o11y-ds-frontend/src/TemporaryAlert.test.tsx new file mode 100644 index 0000000000000..c8ae7b1c1a489 --- /dev/null +++ b/packages/grafana-o11y-ds-frontend/src/TemporaryAlert.test.tsx @@ -0,0 +1,28 @@ +import { act, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TemporaryAlert } from './TemporaryAlert'; + +describe('TemporaryAlert', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('full component life cycle', async () => { + render(<TemporaryAlert severity="error" text="" />); + expect(screen.queryByTestId('data-testid Alert error')).not.toBeInTheDocument(); + + render(<TemporaryAlert severity="error" text="Error message" />); + expect(screen.getByTestId('data-testid Alert error')).toBeInTheDocument(); + expect(screen.getByText('Error message')).toBeInTheDocument(); + + act(() => jest.runAllTimers()); + expect(screen.queryByTestId('data-testid Alert error')).not.toBeInTheDocument(); + expect(screen.queryByText('Error message')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/grafana-o11y-ds-frontend/src/TemporaryAlert.tsx b/packages/grafana-o11y-ds-frontend/src/TemporaryAlert.tsx new file mode 100644 index 0000000000000..8a7003a576ce7 --- /dev/null +++ b/packages/grafana-o11y-ds-frontend/src/TemporaryAlert.tsx @@ -0,0 +1,74 @@ +import { css } from '@emotion/css'; +import React, { useEffect, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Alert, AlertVariant, useTheme2 } from '@grafana/ui'; + +enum AlertTimeout { + Error = 7000, + Info = 3000, + Success = 3000, + Warning = 5000, +} + +const getStyle = (theme: GrafanaTheme2) => { + return css({ + position: 'absolute', + zIndex: theme.zIndex.portal, + top: 0, + right: 10, + }); +}; + +const timeoutMap = { + ['error']: AlertTimeout.Error, + ['info']: AlertTimeout.Info, + ['success']: AlertTimeout.Success, + ['warning']: AlertTimeout.Warning, +}; + +type AlertProps = { + // Severity of the alert. Controls the style of the alert (e.g., background color) + severity: AlertVariant; + // Displayed message. If set to empty string, the alert is not displayed + text: string; +}; + +export const TemporaryAlert = (props: AlertProps) => { + const style = getStyle(useTheme2()); + const [visible, setVisible] = useState(false); + const [timer, setTimer] = useState<NodeJS.Timeout>(); + + useEffect(() => { + return () => { + if (timer) { + clearTimeout(timer); + } + }; + }, [timer]); + + useEffect(() => { + if (props.text !== '') { + setVisible(true); + + const timer = setTimeout(() => { + setVisible(false); + }, timeoutMap[props.severity]); + setTimer(timer); + } + }, [props.severity, props.text]); + + return ( + <> + {visible && ( + <Alert + className={style} + elevated={true} + onRemove={() => setVisible(false)} + severity={props.severity} + title={props.text} + /> + )} + </> + ); +}; diff --git a/public/app/core/components/TraceToLogs/TagMappingInput.tsx b/packages/grafana-o11y-ds-frontend/src/TraceToLogs/TagMappingInput.tsx similarity index 93% rename from public/app/core/components/TraceToLogs/TagMappingInput.tsx rename to packages/grafana-o11y-ds-frontend/src/TraceToLogs/TagMappingInput.tsx index 2dea321ab0245..d263682a92fac 100644 --- a/public/app/core/components/TraceToLogs/TagMappingInput.tsx +++ b/packages/grafana-o11y-ds-frontend/src/TraceToLogs/TagMappingInput.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { InlineLabel, SegmentInput, ToolbarButton, useStyles2 } from '@grafana/ui'; -import { ToolbarButtonVariant } from '@grafana/ui/src/components/ToolbarButton'; import { TraceToLogsTag } from './TraceToLogsSettings'; @@ -13,8 +12,6 @@ interface Props { id?: string; } -const VARIANT = 'none' as ToolbarButtonVariant; - export const TagMappingInput = ({ values, onChange, id }: Props) => { const styles = useStyles2(getStyles); @@ -60,7 +57,6 @@ export const TagMappingInput = ({ values, onChange, id }: Props) => { onClick={() => onChange([...values.slice(0, idx), ...values.slice(idx + 1)])} className={cx(styles.removeTag, 'query-part')} aria-label="Remove tag" - variant={VARIANT} type="button" icon="times" /> @@ -71,7 +67,6 @@ export const TagMappingInput = ({ values, onChange, id }: Props) => { className="query-part" aria-label="Add tag" type="button" - variant={VARIANT} icon="plus" /> ) : null} @@ -84,7 +79,6 @@ export const TagMappingInput = ({ values, onChange, id }: Props) => { className="query-part" aria-label="Add tag" type="button" - variant={VARIANT} /> )} </div> diff --git a/public/app/core/components/TraceToLogs/TraceToLogsSettings.test.tsx b/packages/grafana-o11y-ds-frontend/src/TraceToLogs/TraceToLogsSettings.test.tsx similarity index 100% rename from public/app/core/components/TraceToLogs/TraceToLogsSettings.test.tsx rename to packages/grafana-o11y-ds-frontend/src/TraceToLogs/TraceToLogsSettings.test.tsx diff --git a/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx b/packages/grafana-o11y-ds-frontend/src/TraceToLogs/TraceToLogsSettings.tsx similarity index 96% rename from public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx rename to packages/grafana-o11y-ds-frontend/src/TraceToLogs/TraceToLogsSettings.tsx index 5a4b605faf1f7..bc290883d657c 100644 --- a/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx +++ b/packages/grafana-o11y-ds-frontend/src/TraceToLogs/TraceToLogsSettings.tsx @@ -2,10 +2,9 @@ import { css } from '@emotion/css'; import React, { useCallback, useMemo } from 'react'; import { DataSourceJsonData, DataSourceInstanceSettings, DataSourcePluginOptionsEditorProps } from '@grafana/data'; -import { ConfigSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSection } from '@grafana/experimental'; +import { DataSourcePicker } from '@grafana/runtime'; import { InlineField, InlineFieldRow, Input, InlineSwitch } from '@grafana/ui'; -import { ConfigDescriptionLink } from 'app/core/components/ConfigDescriptionLink'; -import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; import { IntervalInput } from '../IntervalInput/IntervalInput'; @@ -230,7 +229,7 @@ function IdFilter(props: IdFilterProps) { label={`Filter by ${props.type} ID`} labelWidth={26} grow - tooltip={`Filters logs by ${props.type} ID`} + tooltip={`Filters logs by ${props.type} ID, where the ${props.type} ID should be part of the log line`} > <InlineSwitch id={props.id} diff --git a/public/app/core/components/TraceToMetrics/TraceToMetricsSettings.tsx b/packages/grafana-o11y-ds-frontend/src/TraceToMetrics/TraceToMetricsSettings.tsx similarity index 94% rename from public/app/core/components/TraceToMetrics/TraceToMetricsSettings.tsx rename to packages/grafana-o11y-ds-frontend/src/TraceToMetrics/TraceToMetricsSettings.tsx index a5fdc088962a0..90249d4dd3faa 100644 --- a/public/app/core/components/TraceToMetrics/TraceToMetricsSettings.tsx +++ b/packages/grafana-o11y-ds-frontend/src/TraceToMetrics/TraceToMetricsSettings.tsx @@ -8,11 +8,10 @@ import { GrafanaTheme2, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; -import { ConfigSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSection } from '@grafana/experimental'; +import { DataSourcePicker } from '@grafana/runtime'; import { Button, InlineField, InlineFieldRow, Input, useStyles2 } from '@grafana/ui'; -import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; -import { ConfigDescriptionLink } from '../ConfigDescriptionLink'; import { IntervalInput } from '../IntervalInput/IntervalInput'; import { TagMappingInput } from '../TraceToLogs/TagMappingInput'; import { getTimeShiftLabel, getTimeShiftTooltip, invalidTimeShiftError } from '../TraceToLogs/TraceToLogsSettings'; @@ -229,17 +228,17 @@ export const TraceToMetricsSection = ({ options, onOptionsChange }: DataSourcePl }; const getStyles = (theme: GrafanaTheme2) => ({ - infoText: css` - padding-bottom: ${theme.spacing(2)}; - color: ${theme.colors.text.secondary}; - `, - row: css` - label: row; - align-items: baseline; - `, - queryRow: css` - label: queryRow; - display: flex; - flex-flow: wrap; - `, + infoText: { + paddingBottom: theme.spacing(2), + color: theme.colors.text.secondary, + }, + row: css({ + label: 'row', + alignItems: 'baseline', + }), + queryRow: css({ + label: 'queryRow', + display: 'flex', + flexFlow: 'wrap', + }), }); diff --git a/public/app/core/components/TraceToProfiles/TraceToProfilesSettings.test.tsx b/packages/grafana-o11y-ds-frontend/src/TraceToProfiles/TraceToProfilesSettings.test.tsx similarity index 100% rename from public/app/core/components/TraceToProfiles/TraceToProfilesSettings.test.tsx rename to packages/grafana-o11y-ds-frontend/src/TraceToProfiles/TraceToProfilesSettings.test.tsx diff --git a/public/app/core/components/TraceToProfiles/TraceToProfilesSettings.tsx b/packages/grafana-o11y-ds-frontend/src/TraceToProfiles/TraceToProfilesSettings.tsx similarity index 86% rename from public/app/core/components/TraceToProfiles/TraceToProfilesSettings.tsx rename to packages/grafana-o11y-ds-frontend/src/TraceToProfiles/TraceToProfilesSettings.tsx index 41d5c7d7d57a6..ebabc60dd0109 100644 --- a/public/app/core/components/TraceToProfiles/TraceToProfilesSettings.tsx +++ b/packages/grafana-o11y-ds-frontend/src/TraceToProfiles/TraceToProfilesSettings.tsx @@ -8,16 +8,14 @@ import { DataSourcePluginOptionsEditorProps, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; -import { ConfigSection } from '@grafana/experimental'; -import { getDataSourceSrv } from '@grafana/runtime'; +import { ConfigDescriptionLink, ConfigSection } from '@grafana/experimental'; +import { DataSourcePicker, DataSourceWithBackend, getDataSourceSrv } from '@grafana/runtime'; import { InlineField, InlineFieldRow, Input, InlineSwitch } from '@grafana/ui'; -import { ConfigDescriptionLink } from 'app/core/components/ConfigDescriptionLink'; -import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; -import { ProfileTypesCascader } from 'app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/ProfileTypesCascader'; -import { PyroscopeDataSource } from 'app/plugins/datasource/grafana-pyroscope-datasource/datasource'; -import { ProfileTypeMessage } from 'app/plugins/datasource/grafana-pyroscope-datasource/types'; import { TagMappingInput } from '../TraceToLogs/TagMappingInput'; +import { ProfileTypesCascader } from '../pyroscope/ProfileTypesCascader'; +import { ProfileTypeMessage } from '../pyroscope/types'; + export interface TraceToProfilesOptions { datasourceUid?: string; tags?: Array<{ key: string; value?: string }>; @@ -48,20 +46,19 @@ export function TraceToProfilesSettings({ options, onOptionsChange }: Props) { return await getDataSourceSrv().get(options.jsonData.tracesToProfiles?.datasourceUid); }, [options.jsonData.tracesToProfiles?.datasourceUid]); - useEffect(() => { + const { value: pTypes } = useAsync(async () => { if ( - dataSource && - dataSource instanceof PyroscopeDataSource && + dataSource instanceof DataSourceWithBackend && supportedDataSourceTypes.includes(dataSource.type) && dataSource.uid === options.jsonData.tracesToProfiles?.datasourceUid ) { - dataSource.getProfileTypes().then((profileTypes) => { - setProfileTypes(profileTypes); - }); - } else { - setProfileTypes([]); + return await dataSource?.getResource('profileTypes'); } - }, [dataSource, onOptionsChange, options, supportedDataSourceTypes]); + }, [dataSource]); + + useEffect(() => { + setProfileTypes(pTypes ?? []); + }, [pTypes]); return ( <div className={css({ width: '100%' })}> @@ -173,7 +170,7 @@ export const TraceToProfilesSection = ({ options, onOptionsChange }: DataSourceP description={ <ConfigDescriptionLink description="Navigate from a trace span to the selected data source's profiles." - suffix={`${options.type}/#trace-to-profiles`} + suffix={`${options.type}/configure-tempo-data-source/#trace-to-profiles`} feature="trace to profiles" /> } diff --git a/packages/grafana-o11y-ds-frontend/src/combineResponses.test.ts b/packages/grafana-o11y-ds-frontend/src/combineResponses.test.ts new file mode 100644 index 0000000000000..aa1b8ca07ad04 --- /dev/null +++ b/packages/grafana-o11y-ds-frontend/src/combineResponses.test.ts @@ -0,0 +1,779 @@ +import { + DataFrame, + DataFrameType, + DataQueryResponse, + Field, + FieldType, + LoadingState, + PanelData, + QueryResultMetaStat, + getDefaultTimeRange, +} from '@grafana/data'; + +import { cloneQueryResponse, combinePanelData, combineResponses } from './combineResponses'; + +describe('cloneQueryResponse', () => { + const { logFrameA } = getMockFrames(); + const responseA: DataQueryResponse = { + data: [logFrameA], + }; + it('clones query responses', () => { + const clonedA = cloneQueryResponse(responseA); + expect(clonedA).not.toBe(responseA); + expect(clonedA).toEqual(clonedA); + }); +}); + +describe('combineResponses', () => { + it('combines logs frames', () => { + const { logFrameA, logFrameB } = getMockFrames(); + const responseA: DataQueryResponse = { + data: [logFrameA], + }; + const responseB: DataQueryResponse = { + data: [logFrameB], + }; + expect(combineResponses(responseA, responseB)).toEqual({ + data: [ + { + fields: [ + { + config: {}, + name: 'Time', + type: 'time', + values: [1, 2, 3, 4], + }, + { + config: {}, + name: 'Line', + type: 'string', + values: ['line3', 'line4', 'line1', 'line2'], + }, + { + config: {}, + name: 'labels', + type: 'other', + values: [ + { + otherLabel: 'other value', + }, + { + label: 'value', + }, + { + otherLabel: 'other value', + }, + ], + }, + { + config: {}, + name: 'tsNs', + type: 'string', + values: ['1000000', '2000000', '3000000', '4000000'], + }, + { + config: {}, + name: 'id', + type: 'string', + values: ['id3', 'id4', 'id1', 'id2'], + }, + ], + length: 4, + meta: { + custom: { + frameType: 'LabeledTimeValues', + }, + stats: [ + { + displayName: 'Summary: total bytes processed', + unit: 'decbytes', + value: 33, + }, + ], + }, + refId: 'A', + }, + ], + }); + }); + + it('combines logs frames with transformed fields', () => { + const { logFrameA, logFrameB } = getMockFrames(); + const { logFrameB: originalLogFrameB } = getMockFrames(); + + // Pseudo shuffle fields + logFrameB.fields.sort((a: Field, b: Field) => (a.name < b.name ? -1 : 1)); + expect(logFrameB.fields).not.toEqual(originalLogFrameB.fields); + + const responseA: DataQueryResponse = { + data: [logFrameA], + }; + const responseB: DataQueryResponse = { + data: [logFrameB], + }; + expect(combineResponses(responseA, responseB)).toEqual({ + data: [ + { + fields: [ + { + config: {}, + name: 'Time', + type: 'time', + values: [1, 2, 3, 4], + }, + { + config: {}, + name: 'Line', + type: 'string', + values: ['line3', 'line4', 'line1', 'line2'], + }, + { + config: {}, + name: 'labels', + type: 'other', + values: [ + { + otherLabel: 'other value', + }, + { + label: 'value', + }, + { + otherLabel: 'other value', + }, + ], + }, + { + config: {}, + name: 'tsNs', + type: 'string', + values: ['1000000', '2000000', '3000000', '4000000'], + }, + { + config: {}, + name: 'id', + type: 'string', + values: ['id3', 'id4', 'id1', 'id2'], + }, + ], + length: 4, + meta: { + custom: { + frameType: 'LabeledTimeValues', + }, + stats: [ + { + displayName: 'Summary: total bytes processed', + unit: 'decbytes', + value: 33, + }, + ], + }, + refId: 'A', + }, + ], + }); + }); + + it('combines metric frames', () => { + const { metricFrameA, metricFrameB } = getMockFrames(); + const responseA: DataQueryResponse = { + data: [metricFrameA], + }; + const responseB: DataQueryResponse = { + data: [metricFrameB], + }; + expect(combineResponses(responseA, responseB)).toEqual({ + data: [ + { + fields: [ + { + config: {}, + name: 'Time', + type: 'time', + values: [1000000, 2000000, 3000000, 4000000], + }, + { + config: {}, + name: 'Value', + type: 'number', + values: [6, 7, 5, 4], + labels: { + level: 'debug', + }, + }, + ], + length: 4, + meta: { + type: 'timeseries-multi', + stats: [ + { + displayName: 'Summary: total bytes processed', + unit: 'decbytes', + value: 33, + }, + ], + }, + refId: 'A', + }, + ], + }); + }); + + it('combines and identifies new frames in the response', () => { + const { metricFrameA, metricFrameB, metricFrameC } = getMockFrames(); + const responseA: DataQueryResponse = { + data: [metricFrameA], + }; + const responseB: DataQueryResponse = { + data: [metricFrameB, metricFrameC], + }; + expect(combineResponses(responseA, responseB)).toEqual({ + data: [ + { + fields: [ + { + config: {}, + name: 'Time', + type: 'time', + values: [1000000, 2000000, 3000000, 4000000], + }, + { + config: {}, + name: 'Value', + type: 'number', + values: [6, 7, 5, 4], + labels: { + level: 'debug', + }, + }, + ], + length: 4, + meta: { + type: 'timeseries-multi', + stats: [ + { + displayName: 'Summary: total bytes processed', + unit: 'decbytes', + value: 33, + }, + ], + }, + refId: 'A', + }, + metricFrameC, + ], + }); + }); + + it('combines frames prioritizing refIds over names', () => { + const { metricFrameA, metricFrameB } = getMockFrames(); + const dataFrameA = { + ...metricFrameA, + refId: 'A', + name: 'A', + }; + const dataFrameB = { + ...metricFrameB, + refId: 'B', + name: 'A', + }; + const responseA: DataQueryResponse = { + data: [dataFrameA], + }; + const responseB: DataQueryResponse = { + data: [dataFrameB], + }; + expect(combineResponses(responseA, responseB)).toEqual({ + data: [dataFrameA, dataFrameB], + }); + }); + + it('combines frames in a new response instance', () => { + const { metricFrameA, metricFrameB } = getMockFrames(); + const responseA: DataQueryResponse = { + data: [metricFrameA], + }; + const responseB: DataQueryResponse = { + data: [metricFrameB], + }; + expect(combineResponses(null, responseA)).not.toBe(responseA); + expect(combineResponses(null, responseB)).not.toBe(responseB); + }); + + it('combine when first param has errors', () => { + const { metricFrameA, metricFrameB } = getMockFrames(); + const errorA = { + message: 'errorA', + }; + const responseA: DataQueryResponse = { + data: [metricFrameA], + error: errorA, + errors: [errorA], + }; + const responseB: DataQueryResponse = { + data: [metricFrameB], + }; + + const combined = combineResponses(responseA, responseB); + expect(combined.data[0].length).toBe(4); + expect(combined.error?.message).toBe('errorA'); + expect(combined.errors).toHaveLength(1); + expect(combined.errors?.[0]?.message).toBe('errorA'); + }); + + it('combine when second param has errors', () => { + const { metricFrameA, metricFrameB } = getMockFrames(); + const responseA: DataQueryResponse = { + data: [metricFrameA], + }; + const errorB = { + message: 'errorB', + }; + const responseB: DataQueryResponse = { + data: [metricFrameB], + error: errorB, + errors: [errorB], + }; + + const combined = combineResponses(responseA, responseB); + expect(combined.data[0].length).toBe(4); + expect(combined.error?.message).toBe('errorB'); + expect(combined.errors).toHaveLength(1); + expect(combined.errors?.[0]?.message).toBe('errorB'); + }); + + it('combine when both params have errors', () => { + const { metricFrameA, metricFrameB } = getMockFrames(); + const errorA = { + message: 'errorA', + }; + const errorB = { + message: 'errorB', + }; + const responseA: DataQueryResponse = { + data: [metricFrameA], + error: errorA, + errors: [errorA], + }; + const responseB: DataQueryResponse = { + data: [metricFrameB], + error: errorB, + errors: [errorB], + }; + + const combined = combineResponses(responseA, responseB); + expect(combined.data[0].length).toBe(4); + expect(combined.error?.message).toBe('errorA'); + expect(combined.errors).toHaveLength(2); + expect(combined.errors?.[0]?.message).toBe('errorA'); + expect(combined.errors?.[1]?.message).toBe('errorB'); + }); + + it('combines frames with nanoseconds', () => { + const { logFrameA, logFrameB } = getMockFrames(); + logFrameA.fields[0].nanos = [333333, 444444]; + logFrameB.fields[0].nanos = [111111, 222222]; + const responseA: DataQueryResponse = { + data: [logFrameA], + }; + const responseB: DataQueryResponse = { + data: [logFrameB], + }; + expect(combineResponses(responseA, responseB)).toEqual({ + data: [ + { + fields: [ + { + config: {}, + name: 'Time', + type: 'time', + values: [1, 2, 3, 4], + nanos: [111111, 222222, 333333, 444444], + }, + { + config: {}, + name: 'Line', + type: 'string', + values: ['line3', 'line4', 'line1', 'line2'], + }, + { + config: {}, + name: 'labels', + type: 'other', + values: [ + { + otherLabel: 'other value', + }, + { + label: 'value', + }, + { + otherLabel: 'other value', + }, + ], + }, + { + config: {}, + name: 'tsNs', + type: 'string', + values: ['1000000', '2000000', '3000000', '4000000'], + }, + { + config: {}, + name: 'id', + type: 'string', + values: ['id3', 'id4', 'id1', 'id2'], + }, + ], + length: 4, + meta: { + custom: { + frameType: 'LabeledTimeValues', + }, + stats: [ + { + displayName: 'Summary: total bytes processed', + unit: 'decbytes', + value: 33, + }, + ], + }, + refId: 'A', + }, + ], + }); + }); + + describe('combine stats', () => { + const { metricFrameA } = getMockFrames(); + const makeResponse = (stats?: QueryResultMetaStat[]): DataQueryResponse => ({ + data: [ + { + ...metricFrameA, + meta: { + ...metricFrameA.meta, + stats, + }, + }, + ], + }); + it('two values', () => { + const responseA = makeResponse([ + { displayName: 'Ingester: total reached', value: 1 }, + { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 11 }, + ]); + const responseB = makeResponse([ + { displayName: 'Ingester: total reached', value: 2 }, + { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 22 }, + ]); + + expect(combineResponses(responseA, responseB).data[0].meta.stats).toStrictEqual([ + { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 33 }, + ]); + }); + + it('one value', () => { + const responseA = makeResponse([ + { displayName: 'Ingester: total reached', value: 1 }, + { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 11 }, + ]); + const responseB = makeResponse(); + + expect(combineResponses(responseA, responseB).data[0].meta.stats).toStrictEqual([ + { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 11 }, + ]); + + expect(combineResponses(responseB, responseA).data[0].meta.stats).toStrictEqual([ + { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 11 }, + ]); + }); + + it('no value', () => { + const responseA = makeResponse(); + const responseB = makeResponse(); + expect(combineResponses(responseA, responseB).data[0].meta.stats).toHaveLength(0); + }); + }); +}); + +describe('combinePanelData', () => { + it('combines series within PanelData instances', () => { + const { logFrameA, logFrameB } = getMockFrames(); + const panelDataA: PanelData = { + state: LoadingState.Done, + series: [logFrameA], + timeRange: getDefaultTimeRange(), + }; + const panelDataB: PanelData = { + state: LoadingState.Done, + series: [logFrameB], + timeRange: getDefaultTimeRange(), + }; + expect(combinePanelData(panelDataA, panelDataB)).toEqual({ + state: panelDataA.state, + series: [ + { + fields: [ + { + config: {}, + name: 'Time', + type: 'time', + values: [1, 2, 3, 4], + }, + { + config: {}, + name: 'Line', + type: 'string', + values: ['line3', 'line4', 'line1', 'line2'], + }, + { + config: {}, + name: 'labels', + type: 'other', + values: [ + { + otherLabel: 'other value', + }, + { + label: 'value', + }, + { + otherLabel: 'other value', + }, + ], + }, + { + config: {}, + name: 'tsNs', + type: 'string', + values: ['1000000', '2000000', '3000000', '4000000'], + }, + { + config: {}, + name: 'id', + type: 'string', + values: ['id3', 'id4', 'id1', 'id2'], + }, + ], + length: 4, + meta: { + custom: { + frameType: 'LabeledTimeValues', + }, + stats: [ + { + displayName: 'Summary: total bytes processed', + unit: 'decbytes', + value: 33, + }, + ], + }, + refId: 'A', + }, + ], + timeRange: panelDataA.timeRange, + }); + }); +}); + +export function getMockFrames() { + const logFrameA: DataFrame = { + refId: 'A', + fields: [ + { + name: 'Time', + type: FieldType.time, + config: {}, + values: [3, 4], + }, + { + name: 'Line', + type: FieldType.string, + config: {}, + values: ['line1', 'line2'], + }, + { + name: 'labels', + type: FieldType.other, + config: {}, + values: [ + { + label: 'value', + }, + { + otherLabel: 'other value', + }, + ], + }, + { + name: 'tsNs', + type: FieldType.string, + config: {}, + values: ['3000000', '4000000'], + }, + { + name: 'id', + type: FieldType.string, + config: {}, + values: ['id1', 'id2'], + }, + ], + meta: { + custom: { + frameType: 'LabeledTimeValues', + }, + stats: [ + { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 11 }, + { displayName: 'Ingester: total reached', value: 1 }, + ], + }, + length: 2, + }; + + const logFrameB: DataFrame = { + refId: 'A', + fields: [ + { + name: 'Time', + type: FieldType.time, + config: {}, + values: [1, 2], + }, + { + name: 'Line', + type: FieldType.string, + config: {}, + values: ['line3', 'line4'], + }, + { + name: 'labels', + type: FieldType.other, + config: {}, + values: [ + { + otherLabel: 'other value', + }, + ], + }, + { + name: 'tsNs', + type: FieldType.string, + config: {}, + values: ['1000000', '2000000'], + }, + { + name: 'id', + type: FieldType.string, + config: {}, + values: ['id3', 'id4'], + }, + ], + meta: { + custom: { + frameType: 'LabeledTimeValues', + }, + stats: [ + { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 22 }, + { displayName: 'Ingester: total reached', value: 2 }, + ], + }, + length: 2, + }; + + const metricFrameA: DataFrame = { + refId: 'A', + fields: [ + { + name: 'Time', + type: FieldType.time, + config: {}, + values: [3000000, 4000000], + }, + { + name: 'Value', + type: FieldType.number, + config: {}, + values: [5, 4], + labels: { + level: 'debug', + }, + }, + ], + meta: { + type: DataFrameType.TimeSeriesMulti, + stats: [ + { displayName: 'Ingester: total reached', value: 1 }, + { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 11 }, + ], + }, + length: 2, + }; + + const metricFrameB: DataFrame = { + refId: 'A', + fields: [ + { + name: 'Time', + type: FieldType.time, + config: {}, + values: [1000000, 2000000], + }, + { + name: 'Value', + type: FieldType.number, + config: {}, + values: [6, 7], + labels: { + level: 'debug', + }, + }, + ], + meta: { + type: DataFrameType.TimeSeriesMulti, + stats: [ + { displayName: 'Ingester: total reached', value: 2 }, + { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 22 }, + ], + }, + length: 2, + }; + + const metricFrameC: DataFrame = { + refId: 'A', + name: 'some-time-series', + fields: [ + { + name: 'Time', + type: FieldType.time, + config: {}, + values: [3000000, 4000000], + }, + { + name: 'Value', + type: FieldType.number, + config: {}, + values: [6, 7], + labels: { + level: 'error', + }, + }, + ], + meta: { + type: DataFrameType.TimeSeriesMulti, + stats: [ + { displayName: 'Ingester: total reached', value: 2 }, + { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 33 }, + ], + }, + length: 2, + }; + + return { + logFrameA, + logFrameB, + metricFrameA, + metricFrameB, + metricFrameC, + }; +} diff --git a/packages/grafana-o11y-ds-frontend/src/combineResponses.ts b/packages/grafana-o11y-ds-frontend/src/combineResponses.ts new file mode 100644 index 0000000000000..05e290663eda6 --- /dev/null +++ b/packages/grafana-o11y-ds-frontend/src/combineResponses.ts @@ -0,0 +1,169 @@ +import { + DataFrame, + DataFrameType, + DataQueryResponse, + DataQueryResponseData, + Field, + FieldType, + PanelData, + QueryResultMetaStat, + shallowCompare, +} from '@grafana/data'; + +export function combinePanelData(currentData: PanelData, newData: PanelData): PanelData { + const series = combineResponses({ data: currentData.series }, { data: newData.series }).data; + return { ...currentData, series }; +} + +export function combineResponses(currentResult: DataQueryResponse | null, newResult: DataQueryResponse) { + if (!currentResult) { + return cloneQueryResponse(newResult); + } + + newResult.data.forEach((newFrame) => { + const currentFrame = currentResult.data.find((frame) => shouldCombine(frame, newFrame)); + if (!currentFrame) { + currentResult.data.push(cloneDataFrame(newFrame)); + return; + } + combineFrames(currentFrame, newFrame); + }); + + const mergedErrors = [...(currentResult.errors ?? []), ...(newResult.errors ?? [])]; + + // we make sure to have `.errors` as undefined, instead of empty-array + // when no errors. + + if (mergedErrors.length > 0) { + currentResult.errors = mergedErrors; + } + + // the `.error` attribute is obsolete now, + // but we have to maintain it, otherwise + // some grafana parts do not behave well. + // we just choose the old error, if it exists, + // otherwise the new error, if it exists. + const mergedError = currentResult.error ?? newResult.error; + if (mergedError != null) { + currentResult.error = mergedError; + } + + const mergedTraceIds = [...(currentResult.traceIds ?? []), ...(newResult.traceIds ?? [])]; + if (mergedTraceIds.length > 0) { + currentResult.traceIds = mergedTraceIds; + } + + return currentResult; +} + +function combineFrames(dest: DataFrame, source: DataFrame) { + // `dest` and `source` might have more or less fields, we need to go through all of them + const totalFields = Math.max(dest.fields.length, source.fields.length); + for (let i = 0; i < totalFields; i++) { + // For now, skip undefined fields that exist in the new frame + if (!dest.fields[i]) { + continue; + } + // Index is not reliable when frames have disordered fields, or an extra/missing field, so we find them by name. + // If the field has no name, we fallback to the old index version. + const sourceField = dest.fields[i].name + ? source.fields.find((f) => f.name === dest.fields[i].name) + : source.fields[i]; + if (!sourceField) { + continue; + } + dest.fields[i].values = [].concat.apply(sourceField.values, dest.fields[i].values); + if (sourceField.nanos) { + const nanos: number[] = dest.fields[i].nanos?.slice() || []; + dest.fields[i].nanos = source.fields[i].nanos?.concat(nanos); + } + } + dest.length += source.length; + dest.meta = { + ...dest.meta, + stats: getCombinedMetadataStats(dest.meta?.stats ?? [], source.meta?.stats ?? []), + }; +} + +const TOTAL_BYTES_STAT = 'Summary: total bytes processed'; +// This is specific for Loki +function getCombinedMetadataStats( + destStats: QueryResultMetaStat[], + sourceStats: QueryResultMetaStat[] +): QueryResultMetaStat[] { + // in the current approach, we only handle a single stat + const destStat = destStats.find((s) => s.displayName === TOTAL_BYTES_STAT); + const sourceStat = sourceStats.find((s) => s.displayName === TOTAL_BYTES_STAT); + + if (sourceStat != null && destStat != null) { + return [{ value: sourceStat.value + destStat.value, displayName: TOTAL_BYTES_STAT, unit: destStat.unit }]; + } + + // maybe one of them exist + const eitherStat = sourceStat ?? destStat; + if (eitherStat != null) { + return [eitherStat]; + } + + return []; +} + +/** + * Deep clones a DataQueryResponse + */ +export function cloneQueryResponse(response: DataQueryResponse): DataQueryResponse { + const newResponse = { + ...response, + data: response.data.map(cloneDataFrame), + }; + return newResponse; +} + +function cloneDataFrame(frame: DataQueryResponseData): DataQueryResponseData { + return { + ...frame, + fields: frame.fields.map((field: Field) => ({ + ...field, + values: field.values, + })), + }; +} + +function shouldCombine(frame1: DataFrame, frame2: DataFrame): boolean { + if (frame1.refId !== frame2.refId) { + return false; + } + + const frameType1 = frame1.meta?.type; + const frameType2 = frame2.meta?.type; + + if (frameType1 !== frameType2) { + // we do not join things that have a different type + return false; + } + + // metric range query data + if (frameType1 === DataFrameType.TimeSeriesMulti) { + const field1 = frame1.fields.find((f) => f.type === FieldType.number); + const field2 = frame2.fields.find((f) => f.type === FieldType.number); + if (field1 === undefined || field2 === undefined) { + // should never happen + return false; + } + + return shallowCompare(field1.labels ?? {}, field2.labels ?? {}); + } + + // logs query data + // logs use a special attribute in the dataframe's "custom" section + // because we do not have a good "frametype" value for them yet. + const customType1 = frame1.meta?.custom?.frameType; + const customType2 = frame2.meta?.custom?.frameType; + + if (customType1 === 'LabeledTimeValues' && customType2 === 'LabeledTimeValues') { + return true; + } + + // should never reach here + return false; +} diff --git a/packages/grafana-o11y-ds-frontend/src/index.ts b/packages/grafana-o11y-ds-frontend/src/index.ts new file mode 100644 index 0000000000000..c2f7e931a2743 --- /dev/null +++ b/packages/grafana-o11y-ds-frontend/src/index.ts @@ -0,0 +1,18 @@ +/** + * A library containing logic to manage traces. + * + * @packageDocumentation + */ + +export * from './IntervalInput/IntervalInput'; +export * from './NodeGraph/NodeGraphSettings'; +export * from './SpanBar/SpanBarSettings'; +export * from './TemporaryAlert'; +export * from './TraceToLogs/TagMappingInput'; +export * from './TraceToLogs/TraceToLogsSettings'; +export * from './TraceToMetrics/TraceToMetricsSettings'; +export * from './TraceToProfiles/TraceToProfilesSettings'; +export * from './utils'; +export * from './store'; +export * from './LocalStorageValueProvider/LocalStorageValueProvider'; +export * from './combineResponses'; diff --git a/packages/grafana-o11y-ds-frontend/src/pyroscope/ProfileTypesCascader.tsx b/packages/grafana-o11y-ds-frontend/src/pyroscope/ProfileTypesCascader.tsx new file mode 100644 index 0000000000000..da34d4c191961 --- /dev/null +++ b/packages/grafana-o11y-ds-frontend/src/pyroscope/ProfileTypesCascader.tsx @@ -0,0 +1,85 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +import { Cascader, CascaderOption } from '@grafana/ui'; + +import { PyroscopeDataSource } from './datasource'; +import { ProfileTypeMessage } from './types'; + +type Props = { + initialProfileTypeId?: string; + profileTypes?: ProfileTypeMessage[]; + onChange: (value: string) => void; + placeholder?: string; + width?: number; +}; + +export function ProfileTypesCascader(props: Props) { + const cascaderOptions = useCascaderOptions(props.profileTypes); + + return ( + <Cascader + placeholder={props.placeholder} + separator={'-'} + displayAllSelectedLevels={true} + initialValue={props.initialProfileTypeId} + allowCustomValue={true} + onSelect={props.onChange} + options={cascaderOptions} + changeOnSelect={false} + width={props.width ?? 26} + /> + ); +} + +// Turn profileTypes into cascader options +function useCascaderOptions(profileTypes?: ProfileTypeMessage[]): CascaderOption[] { + return useMemo(() => { + if (!profileTypes) { + return []; + } + let mainTypes = new Map<string, CascaderOption>(); + // Classify profile types by name then sample type. + // The profileTypes are something like cpu:sample:nanoseconds:sample:count or app.something.something + for (let profileType of profileTypes) { + let parts: string[] = []; + if (profileType.id.indexOf(':') > -1) { + parts = profileType.id.split(':'); + } + + const [name, type] = parts; + + if (!mainTypes.has(name)) { + mainTypes.set(name, { + label: name, + value: name, + items: [], + }); + } + mainTypes.get(name)?.items!.push({ + label: type, + value: profileType.id, + }); + } + return Array.from(mainTypes.values()); + }, [profileTypes]); +} + +/** + * Loads the profile types. + * + * This is exported and not used directly in the ProfileTypesCascader component because in some case we need to know + * the profileTypes before rendering the cascader. + * @param datasource + */ +export function useProfileTypes(datasource: PyroscopeDataSource) { + const [profileTypes, setProfileTypes] = useState<ProfileTypeMessage[]>(); + + useEffect(() => { + (async () => { + const profileTypes = await datasource.getProfileTypes(); + setProfileTypes(profileTypes); + })(); + }, [datasource]); + + return profileTypes; +} diff --git a/packages/grafana-o11y-ds-frontend/src/pyroscope/dataquery.gen.ts b/packages/grafana-o11y-ds-frontend/src/pyroscope/dataquery.gen.ts new file mode 100644 index 0000000000000..1b4ed70c46f11 --- /dev/null +++ b/packages/grafana-o11y-ds-frontend/src/pyroscope/dataquery.gen.ts @@ -0,0 +1,38 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. +// +// Generated by: +// public/app/plugins/gen.go +// Using jennies: +// TSTypesJenny +// PluginTSTypesJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +import * as common from '@grafana/schema'; + +export type PyroscopeQueryType = ('metrics' | 'profile' | 'both'); + +export const defaultPyroscopeQueryType: PyroscopeQueryType = 'both'; + +export interface GrafanaPyroscope extends common.DataQuery { + /** + * Allows to group the results. + */ + groupBy: Array<string>; + /** + * Specifies the query label selectors. + */ + labelSelector: string; + /** + * Sets the maximum number of nodes in the flamegraph. + */ + maxNodes?: number; + /** + * Specifies the type of profile to query. + */ + profileTypeId: string; + /** + * Specifies the query span selectors. + */ + spanSelector?: Array<string>; +} diff --git a/packages/grafana-o11y-ds-frontend/src/pyroscope/datasource.ts b/packages/grafana-o11y-ds-frontend/src/pyroscope/datasource.ts new file mode 100644 index 0000000000000..39bd35fb177e8 --- /dev/null +++ b/packages/grafana-o11y-ds-frontend/src/pyroscope/datasource.ts @@ -0,0 +1,13 @@ +import { Observable } from 'rxjs'; + +import { CoreApp, DataQueryRequest, DataQueryResponse, ScopedVars } from '@grafana/data'; +import { DataSourceWithBackend } from '@grafana/runtime'; + +import { PyroscopeDataSourceOptions, ProfileTypeMessage, Query } from './types'; + +export abstract class PyroscopeDataSource extends DataSourceWithBackend<Query, PyroscopeDataSourceOptions> { + abstract applyTemplateVariables(query: Query, scopedVars: ScopedVars): Query; + abstract getDefaultQuery(app: CoreApp): Partial<Query>; + abstract getProfileTypes(): Promise<ProfileTypeMessage[]>; + abstract query(request: DataQueryRequest<Query>): Observable<DataQueryResponse>; +} diff --git a/packages/grafana-o11y-ds-frontend/src/pyroscope/types.ts b/packages/grafana-o11y-ds-frontend/src/pyroscope/types.ts new file mode 100644 index 0000000000000..563ad0df686d2 --- /dev/null +++ b/packages/grafana-o11y-ds-frontend/src/pyroscope/types.ts @@ -0,0 +1,16 @@ +import { DataSourceJsonData } from '@grafana/data'; + +import { GrafanaPyroscope, PyroscopeQueryType } from './dataquery.gen'; + +export interface ProfileTypeMessage { + id: string; + label: string; +} + +export interface PyroscopeDataSourceOptions extends DataSourceJsonData { + minStep?: string; +} + +export interface Query extends GrafanaPyroscope { + queryType: PyroscopeQueryType; +} diff --git a/packages/grafana-o11y-ds-frontend/src/store.ts b/packages/grafana-o11y-ds-frontend/src/store.ts new file mode 100644 index 0000000000000..3942f408a739f --- /dev/null +++ b/packages/grafana-o11y-ds-frontend/src/store.ts @@ -0,0 +1,64 @@ +type StoreValue = string | number | boolean | null; + +export class Store { + get(key: string) { + return window.localStorage[key]; + } + + set(key: string, value: StoreValue) { + window.localStorage[key] = value; + } + + getBool(key: string, def: boolean): boolean { + if (def !== void 0 && !this.exists(key)) { + return def; + } + return window.localStorage[key] === 'true'; + } + + getObject<T = unknown>(key: string): T | undefined; + getObject<T = unknown>(key: string, def: T): T; + getObject<T = unknown>(key: string, def?: T) { + let ret = def; + if (this.exists(key)) { + const json = window.localStorage[key]; + try { + ret = JSON.parse(json); + } catch (error) { + console.error(`Error parsing store object: ${key}. Returning default: ${def}. [${error}]`); + } + } + return ret; + } + + /* Returns true when successfully stored, throws error if not successfully stored */ + setObject(key: string, value: unknown) { + let json; + try { + json = JSON.stringify(value); + } catch (error) { + throw new Error(`Could not stringify object: ${key}. [${error}]`); + } + try { + this.set(key, json); + } catch (error) { + // Likely hitting storage quota + const errorToThrow = new Error(`Could not save item in localStorage: ${key}. [${error}]`); + if (error instanceof Error) { + errorToThrow.name = error.name; + } + throw errorToThrow; + } + return true; + } + + exists(key: string) { + return window.localStorage[key] !== void 0; + } + + delete(key: string) { + window.localStorage.removeItem(key); + } +} + +export const store = new Store(); diff --git a/public/app/core/utils/tracing.ts b/packages/grafana-o11y-ds-frontend/src/utils.ts similarity index 97% rename from public/app/core/utils/tracing.ts rename to packages/grafana-o11y-ds-frontend/src/utils.ts index c0e947876280a..3687db979e864 100644 --- a/public/app/core/utils/tracing.ts +++ b/packages/grafana-o11y-ds-frontend/src/utils.ts @@ -65,6 +65,7 @@ export function makeSpanMap<T>(getSpan: (index: number) => { span: T; id: string } } } + // Discussion on this type assertion here: https://github.com/grafana/grafana/pull/80362/files#r1451019375 return spanMap as { [id: string]: { span: T; children: string[] } }; } diff --git a/packages/grafana-o11y-ds-frontend/tsconfig.json b/packages/grafana-o11y-ds-frontend/tsconfig.json new file mode 100644 index 0000000000000..03aa83f469f7d --- /dev/null +++ b/packages/grafana-o11y-ds-frontend/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "declarationDir": "./compiled", + "emitDeclarationOnly": true, + "isolatedModules": true, + "rootDirs": ["."] + }, + "exclude": ["dist/**/*"], + "extends": "@grafana/tsconfig", + "include": ["src/**/*.ts*", "../../public/app/types/*.d.ts", "../grafana-ui/src/types/*.d.ts"] +} diff --git a/packages/grafana-plugin-configs/package.json b/packages/grafana-plugin-configs/package.json index db5e3783f72d2..403539da5ed8a 100644 --- a/packages/grafana-plugin-configs/package.json +++ b/packages/grafana-plugin-configs/package.json @@ -2,19 +2,19 @@ "name": "@grafana/plugin-configs", "description": "Shared dependencies and files for core plugins", "private": true, - "version": "10.3.0-pre", + "version": "11.0.0-pre", "dependencies": { - "tslib": "2.6.0" + "tslib": "2.6.2" }, "devDependencies": { - "@grafana/tsconfig": "^1.2.0-rc1", - "copy-webpack-plugin": "11.0.0", - "eslint-webpack-plugin": "4.0.1", - "fork-ts-checker-webpack-plugin": "8.0.0", - "glob": "10.3.3", + "@grafana/tsconfig": "^1.3.0-rc1", + "copy-webpack-plugin": "12.0.2", + "eslint-webpack-plugin": "4.1.0", + "fork-ts-checker-webpack-plugin": "9.0.2", + "glob": "10.3.10", "replace-in-file-webpack-plugin": "1.0.6", - "swc-loader": "0.2.3", - "webpack": "5.89.0" + "swc-loader": "0.2.6", + "webpack": "5.90.3" }, - "packageManager": "yarn@3.6.0" + "packageManager": "yarn@4.1.0" } diff --git a/packages/grafana-plugin-configs/webpack.config.ts b/packages/grafana-plugin-configs/webpack.config.ts index ce38d1090f8b2..6722a506f7004 100644 --- a/packages/grafana-plugin-configs/webpack.config.ts +++ b/packages/grafana-plugin-configs/webpack.config.ts @@ -17,6 +17,10 @@ function skipFiles(f: string): boolean { // avoid copying tsconfig.json return false; } + if (f.includes('/package.json')) { + // avoid copying package.json + return false; + } return true; } @@ -28,7 +32,7 @@ const config = async (env: Record<string, unknown>): Promise<Configuration> => { buildDependencies: { config: [__filename], }, - cacheDirectory: path.resolve(__dirname, '../../.yarn/.cache/webpack', path.basename(process.cwd())), + cacheDirectory: path.resolve(__dirname, '../../node_modules/.cache/webpack', path.basename(process.cwd())), }, context: process.cwd(), @@ -86,7 +90,7 @@ const config = async (env: Record<string, unknown>): Promise<Configuration> => { loader: require.resolve('swc-loader'), options: { jsc: { - baseUrl: '.', + baseUrl: path.resolve(__dirname), target: 'es2015', loose: false, parser: { @@ -140,6 +144,7 @@ const config = async (env: Record<string, unknown>): Promise<Configuration> => { }, path: path.resolve(process.cwd(), DIST_DIR), publicPath: `public/plugins/${pluginJson.id}/`, + uniqueName: pluginJson.id, }, plugins: [ @@ -179,18 +184,6 @@ const config = async (env: Record<string, unknown>): Promise<Configuration> => { }, ], }, - { - dir: path.resolve(DIST_DIR), - files: ['package.json'], - rules: [ - { - search: `"version": "${getPackageJson().version}"`, - replace: env.commit - ? `"version": "${getPackageJson().version}-${env.commit}"` - : `"version": "${getPackageJson().version}"`, - }, - ], - }, ]), env.development ? new ForkTsCheckerWebpackPlugin({ @@ -207,7 +200,7 @@ const config = async (env: Record<string, unknown>): Promise<Configuration> => { lintDirtyModulesOnly: true, // don't lint on start, only lint changed files cacheLocation: path.resolve( __dirname, - '../../.yarn/.cache/eslint-webpack-plugin', + '../../node_modules/.cache/eslint-webpack-plugin', path.basename(process.cwd()), '.eslintcache' ), @@ -220,6 +213,8 @@ const config = async (env: Record<string, unknown>): Promise<Configuration> => { unsafeCache: true, }, + stats: 'minimal', + watchOptions: { ignored: ['**/node_modules', '**/dist', '**/.yarn'], }, diff --git a/packages/grafana-prometheus/CHANGELOG.md b/packages/grafana-prometheus/CHANGELOG.md new file mode 100644 index 0000000000000..8d0fac75805ea --- /dev/null +++ b/packages/grafana-prometheus/CHANGELOG.md @@ -0,0 +1,3 @@ +# (2024-02-16) + +First public release. This release provides Prometheus exports in Grafana. Please be aware this is in the alpha state and there is likely to be breaking changes. diff --git a/packages/grafana-prometheus/LICENSE_AGPL b/packages/grafana-prometheus/LICENSE_AGPL new file mode 100644 index 0000000000000..be3f7b28e564e --- /dev/null +++ b/packages/grafana-prometheus/LICENSE_AGPL @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +<https://www.gnu.org/licenses/>. diff --git a/packages/grafana-prometheus/README.md b/packages/grafana-prometheus/README.md new file mode 100644 index 0000000000000..efbdb5791e11a --- /dev/null +++ b/packages/grafana-prometheus/README.md @@ -0,0 +1,13 @@ +# Grafana Prometheus Library + +> **@grafana/prometheus is currently in ALPHA**. + +@grafana/prometheus is a collection of components used to build a Prometheus data source plugin in [Grafana](https://github.com/grafana/grafana). + +See [package source](https://github.com/grafana/grafana/tree/main/packages/grafana-prometheus) for more details. + +## Installation + +`yarn add @grafana/prometheus` + +`npm install @grafana/prometheus` diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json new file mode 100644 index 0000000000000..6502a948ac3aa --- /dev/null +++ b/packages/grafana-prometheus/package.json @@ -0,0 +1,150 @@ +{ + "author": "Grafana Labs", + "license": "AGPL-3.0-only", + "name": "@grafana/prometheus", + "version": "11.0.0-pre", + "description": "Grafana Prometheus Library", + "keywords": [ + "typescript" + ], + "sideEffects": false, + "repository": { + "type": "git", + "url": "http://github.com/grafana/grafana.git", + "directory": "packages/grafana-prometheus" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "./dist", + "./README.md", + "./CHANGELOG.md", + "./LICENSE_AGPL" + ], + "publishConfig": { + "main": "dist/index.js", + "module": "dist/esm/index.js", + "types": "dist/index.d.ts", + "access": "public" + }, + "scripts": { + "build": "tsc -p ./tsconfig.build.json && rollup -c rollup.config.ts", + "bundle": "rollup -c rollup.config.ts", + "clean": "rimraf ./dist ./compiled ./package.tgz", + "typecheck": "tsc --emitDeclarationOnly false --noEmit", + "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-packagejson.js", + "postpack": "mv package.json.bak package.json" + }, + "dependencies": { + "@emotion/css": "11.11.2", + "@floating-ui/react": "0.26.9", + "@grafana/data": "11.0.0-pre", + "@grafana/experimental": "1.7.10", + "@grafana/faro-web-sdk": "1.4.2", + "@grafana/runtime": "11.0.0-pre", + "@grafana/schema": "11.0.0-pre", + "@grafana/ui": "11.0.0-pre", + "@leeoniya/ufuzzy": "1.0.14", + "@lezer/common": "1.2.1", + "@lezer/highlight": "1.2.0", + "@lezer/lr": "1.3.3", + "@prometheus-io/lezer-promql": "^0.37.0-rc.1", + "@reduxjs/toolkit": "1.9.5", + "d3": "7.9.0", + "date-fns": "3.5.0", + "debounce-promise": "3.1.2", + "eventemitter3": "5.0.1", + "lodash": "4.17.21", + "lru-cache": "10.2.0", + "marked": "12.0.1", + "marked-mangle": "1.1.7", + "moment": "2.30.1", + "moment-timezone": "0.5.45", + "monaco-promql": "1.7.4", + "pluralize": "8.0.0", + "prismjs": "1.29.0", + "react-beautiful-dnd": "13.1.1", + "react-highlight-words": "0.20.0", + "react-select": "5.8.0", + "react-use": "17.5.0", + "react-window": "1.8.10", + "rxjs": "7.8.1", + "semver": "7.6.0", + "tslib": "2.6.2", + "uuid": "9.0.1", + "whatwg-fetch": "3.6.20" + }, + "devDependencies": { + "@emotion/eslint-plugin": "11.11.0", + "@grafana/e2e": "11.0.0-pre", + "@grafana/e2e-selectors": "11.0.0-pre", + "@grafana/tsconfig": "^1.3.0-rc1", + "@rollup/plugin-image": "3.0.3", + "@rollup/plugin-node-resolve": "15.2.3", + "@swc/core": "1.4.2", + "@swc/helpers": "0.5.6", + "@testing-library/dom": "9.3.4", + "@testing-library/jest-dom": "6.4.2", + "@testing-library/react": "14.2.1", + "@testing-library/user-event": "14.5.2", + "@types/d3": "7.4.3", + "@types/debounce-promise": "3.1.9", + "@types/eslint": "8.56.5", + "@types/jest": "29.5.12", + "@types/jquery": "3.5.29", + "@types/lodash": "4.17.0", + "@types/marked": "5.0.2", + "@types/node": "20.11.28", + "@types/pluralize": "^0.0.33", + "@types/prismjs": "1.26.3", + "@types/react": "18.2.66", + "@types/react-beautiful-dnd": "13.1.8", + "@types/react-dom": "18.2.22", + "@types/react-highlight-words": "0.16.7", + "@types/react-window": "1.8.8", + "@types/semver": "7.5.8", + "@types/testing-library__jest-dom": "5.14.9", + "@types/uuid": "9.0.8", + "@typescript-eslint/eslint-plugin": "6.21.0", + "@typescript-eslint/parser": "6.21.0", + "copy-webpack-plugin": "12.0.2", + "css-loader": "6.10.0", + "esbuild": "0.18.12", + "eslint": "8.57.0", + "eslint-config-prettier": "9.1.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-jest": "27.9.0", + "eslint-plugin-jsdoc": "48.2.1", + "eslint-plugin-jsx-a11y": "6.8.0", + "eslint-plugin-lodash": "7.4.0", + "eslint-plugin-react": "7.34.0", + "eslint-plugin-react-hooks": "4.6.0", + "eslint-webpack-plugin": "4.1.0", + "fork-ts-checker-webpack-plugin": "9.0.2", + "glob": "10.3.10", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0", + "jest-matcher-utils": "29.7.0", + "prettier": "3.2.5", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-select-event": "5.5.1", + "react-test-renderer": "18.2.0", + "rollup": "2.79.1", + "rollup-plugin-dts": "^5.0.0", + "rollup-plugin-esbuild": "5.0.0", + "rollup-plugin-node-externals": "^5.0.0", + "sass": "1.70.0", + "sass-loader": "14.1.1", + "style-loader": "3.3.4", + "testing-library-selector": "0.3.1", + "ts-node": "10.9.2", + "typescript": "5.3.3", + "webpack": "5.90.3", + "webpack-cli": "5.1.4" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } +} diff --git a/packages/grafana-prometheus/rollup.config.ts b/packages/grafana-prometheus/rollup.config.ts new file mode 100644 index 0000000000000..080514c27d5d5 --- /dev/null +++ b/packages/grafana-prometheus/rollup.config.ts @@ -0,0 +1,38 @@ +import image from '@rollup/plugin-image'; +import resolve from '@rollup/plugin-node-resolve'; +import path from 'path'; +import dts from 'rollup-plugin-dts'; +import esbuild from 'rollup-plugin-esbuild'; +import { externals } from 'rollup-plugin-node-externals'; + +const pkg = require('./package.json'); + +export default [ + { + input: 'src/index.ts', + plugins: [externals({ deps: true, packagePath: './package.json' }), resolve(), esbuild(), image()], + output: [ + { + format: 'cjs', + sourcemap: true, + dir: path.dirname(pkg.publishConfig.main), + }, + { + format: 'esm', + sourcemap: true, + dir: path.dirname(pkg.publishConfig.module), + preserveModules: true, + // @ts-expect-error (TS cannot assure that `process.env.PROJECT_CWD` is a string) + preserveModulesRoot: path.join(process.env.PROJECT_CWD, `packages/grafana-prometheus/src`), + }, + ], + }, + { + input: './compiled/index.d.ts', + plugins: [dts()], + output: { + file: pkg.publishConfig.types, + format: 'es', + }, + }, +]; diff --git a/packages/grafana-prometheus/src/add_label_to_query.test.ts b/packages/grafana-prometheus/src/add_label_to_query.test.ts new file mode 100644 index 0000000000000..08c32e3c39896 --- /dev/null +++ b/packages/grafana-prometheus/src/add_label_to_query.test.ts @@ -0,0 +1,114 @@ +import { addLabelToQuery } from './add_label_to_query'; + +describe('addLabelToQuery()', () => { + it('should add label to simple query', () => { + expect(() => { + addLabelToQuery('foo', '', ''); + }).toThrow(); + expect(addLabelToQuery('foo', 'bar', 'baz')).toBe('foo{bar="baz"}'); + expect(addLabelToQuery('foo{}', 'bar', 'baz')).toBe('foo{bar="baz"}'); + expect(addLabelToQuery('foo{x="yy"}', 'bar', 'baz')).toBe('foo{x="yy", bar="baz"}'); + expect(addLabelToQuery('metric > 0.001', 'foo', 'bar')).toBe('metric{foo="bar"} > 0.001'); + }); + + it('should add custom operator', () => { + expect(addLabelToQuery('foo{}', 'bar', 'baz', '!=')).toBe('foo{bar!="baz"}'); + expect(addLabelToQuery('foo{x="yy"}', 'bar', 'baz', '!=')).toBe('foo{x="yy", bar!="baz"}'); + }); + + it('should not modify ranges', () => { + expect(addLabelToQuery('rate(metric[1m])', 'foo', 'bar')).toBe('rate(metric{foo="bar"}[1m])'); + }); + + it('should detect in-order function use', () => { + expect(addLabelToQuery('sum by (xx) (foo)', 'bar', 'baz')).toBe('sum by (xx) (foo{bar="baz"})'); + }); + + it('should convert number Infinity to +Inf', () => { + expect( + addLabelToQuery('sum(rate(prometheus_tsdb_compaction_chunk_size_bytes_bucket[5m])) by (le)', 'le', Infinity) + ).toBe('sum(rate(prometheus_tsdb_compaction_chunk_size_bytes_bucket{le="+Inf"}[5m])) by (le)'); + }); + + it('should handle selectors with punctuation', () => { + expect(addLabelToQuery('foo{instance="my-host.com:9100"}', 'bar', 'baz')).toBe( + 'foo{instance="my-host.com:9100", bar="baz"}' + ); + expect(addLabelToQuery('foo:metric:rate1m', 'bar', 'baz')).toBe('foo:metric:rate1m{bar="baz"}'); + expect(addLabelToQuery('avg(foo:metric:rate1m{a="b"})', 'bar', 'baz')).toBe( + 'avg(foo:metric:rate1m{a="b", bar="baz"})' + ); + expect(addLabelToQuery('foo{list="a,b,c"}', 'bar', 'baz')).toBe('foo{list="a,b,c", bar="baz"}'); + }); + + it('should work on arithmetical expressions', () => { + expect(addLabelToQuery('foo + foo', 'bar', 'baz')).toBe('foo{bar="baz"} + foo{bar="baz"}'); + expect(addLabelToQuery('foo{x="yy"} + metric', 'bar', 'baz')).toBe('foo{x="yy", bar="baz"} + metric{bar="baz"}'); + expect(addLabelToQuery('avg(foo) + sum(xx_yy)', 'bar', 'baz')).toBe('avg(foo{bar="baz"}) + sum(xx_yy{bar="baz"})'); + expect(addLabelToQuery('foo{x="yy"} * metric{y="zz",a="bb"} * metric2', 'bar', 'baz')).toBe( + 'foo{x="yy", bar="baz"} * metric{y="zz", a="bb", bar="baz"} * metric2{bar="baz"}' + ); + }); + + it('should not add duplicate labels to a query', () => { + expect(addLabelToQuery(addLabelToQuery('foo{x="yy"}', 'bar', 'baz', '!='), 'bar', 'baz', '!=')).toBe( + 'foo{x="yy", bar!="baz"}' + ); + expect(addLabelToQuery(addLabelToQuery('rate(metric[1m])', 'foo', 'bar'), 'foo', 'bar')).toBe( + 'rate(metric{foo="bar"}[1m])' + ); + expect(addLabelToQuery(addLabelToQuery('foo{list="a,b,c"}', 'bar', 'baz'), 'bar', 'baz')).toBe( + 'foo{list="a,b,c", bar="baz"}' + ); + expect(addLabelToQuery(addLabelToQuery('avg(foo) + sum(xx_yy)', 'bar', 'baz'), 'bar', 'baz')).toBe( + 'avg(foo{bar="baz"}) + sum(xx_yy{bar="baz"})' + ); + }); + + it('should not remove filters', () => { + expect(addLabelToQuery('{x="y"} |="yy"', 'bar', 'baz')).toBe('{x="y", bar="baz"} |="yy"'); + expect(addLabelToQuery('{x="y"} |="yy" !~"xx"', 'bar', 'baz')).toBe('{x="y", bar="baz"} |="yy" !~"xx"'); + }); + + it('should add labels to metrics with logical operators', () => { + expect(addLabelToQuery('foo_info or bar_info', 'bar', 'baz')).toBe('foo_info{bar="baz"} or bar_info{bar="baz"}'); + expect(addLabelToQuery('foo_info and bar_info', 'bar', 'baz')).toBe('foo_info{bar="baz"} and bar_info{bar="baz"}'); + }); + + it('should not add ad-hoc filter to template variables', () => { + expect(addLabelToQuery('sum(rate({job="foo"}[2m])) by (value $variable)', 'bar', 'baz')).toBe( + 'sum(rate({job="foo", bar="baz"}[2m])) by (value $variable)' + ); + }); + + it('should not add ad-hoc filter to range', () => { + expect(addLabelToQuery('avg(rate((my_metric{job="foo"} > 0)[3h:])) by (label)', 'bar', 'baz')).toBe( + 'avg(rate((my_metric{job="foo", bar="baz"} > 0)[3h:])) by (label)' + ); + }); + it('should not add ad-hoc filter to labels in label list provided with the group modifier', () => { + expect( + addLabelToQuery( + 'max by (id, name, type) (my_metric{type=~"foo|bar|baz-test"}) * on(id) group_right(id, type, name) sum by (id) (my_metric) * 1000', + 'bar', + 'baz' + ) + ).toBe( + 'max by (id, name, type) (my_metric{type=~"foo|bar|baz-test", bar="baz"}) * on(id) group_right(id, type, name) sum by (id) (my_metric{bar="baz"}) * 1000' + ); + }); + it('should not add ad-hoc filter to labels in label list provided with the group modifier', () => { + expect(addLabelToQuery('rate(my_metric[${__range_s}s])', 'bar', 'baz')).toBe( + 'rate(my_metric{bar="baz"}[${__range_s}s])' + ); + }); + it('should not add ad-hoc filter to labels to math operations', () => { + expect(addLabelToQuery('count(my_metric{job!="foo"} < (5*1024*1024*1024) or vector(0)) - 1', 'bar', 'baz')).toBe( + 'count(my_metric{job!="foo", bar="baz"} < (5*1024*1024*1024) or vector(0)) - 1' + ); + }); + + it('should not add ad-hoc filter bool operator', () => { + expect(addLabelToQuery('ALERTS < bool 1', 'bar', 'baz')).toBe('ALERTS{bar="baz"} < bool 1'); + }); +}); diff --git a/packages/grafana-prometheus/src/add_label_to_query.ts b/packages/grafana-prometheus/src/add_label_to_query.ts new file mode 100644 index 0000000000000..696e0cbb8dcf5 --- /dev/null +++ b/packages/grafana-prometheus/src/add_label_to_query.ts @@ -0,0 +1,100 @@ +import { parser, VectorSelector } from '@prometheus-io/lezer-promql'; + +import { PromQueryModeller } from './querybuilder/PromQueryModeller'; +import { buildVisualQueryFromString } from './querybuilder/parsing'; +import { QueryBuilderLabelFilter } from './querybuilder/shared/types'; +import { PromVisualQuery } from './querybuilder/types'; + +/** + * Adds label filter to existing query. Useful for query modification for example for ad hoc filters. + * + * It uses PromQL parser to find instances of metric and labels, alters them and then splices them back into the query. + * Ideally we could use the parse -> change -> render is a simple 3 steps but right now building the visual query + * object does not support all possible queries. + * + * So instead this just operates on substrings of the query with labels and operates just on those. This makes this + * more robust and can alter even invalid queries, and preserves in general the query structure and whitespace. + * @param query + * @param key + * @param value + * @param operator + */ +export function addLabelToQuery(query: string, key: string, value: string | number, operator = '='): string { + if (!key || !value) { + throw new Error('Need label to add to query.'); + } + + const vectorSelectorPositions = getVectorSelectorPositions(query); + if (!vectorSelectorPositions.length) { + return query; + } + + const filter = toLabelFilter(key, value, operator); + return addFilter(query, vectorSelectorPositions, filter); +} + +type VectorSelectorPosition = { from: number; to: number; query: PromVisualQuery }; + +/** + * Parse the string and get all VectorSelector positions in the query together with parsed representation of the vector + * selector. + * @param query + */ +function getVectorSelectorPositions(query: string): VectorSelectorPosition[] { + const tree = parser.parse(query); + const positions: VectorSelectorPosition[] = []; + tree.iterate({ + enter: ({ to, from, type }): false | void => { + if (type.id === VectorSelector) { + const visQuery = buildVisualQueryFromString(query.substring(from, to)); + positions.push({ query: visQuery.query, from, to }); + return false; + } + }, + }); + return positions; +} + +function toLabelFilter(key: string, value: string | number, operator: string): QueryBuilderLabelFilter { + // We need to make sure that we convert the value back to string because it may be a number + const transformedValue = value === Infinity ? '+Inf' : value.toString(); + return { label: key, op: operator, value: transformedValue }; +} + +function addFilter( + query: string, + vectorSelectorPositions: VectorSelectorPosition[], + filter: QueryBuilderLabelFilter +): string { + const modeller = new PromQueryModeller(); + let newQuery = ''; + let prev = 0; + + for (let i = 0; i < vectorSelectorPositions.length; i++) { + // This is basically just doing splice on a string for each matched vector selector. + + const match = vectorSelectorPositions[i]; + const isLast = i === vectorSelectorPositions.length - 1; + + const start = query.substring(prev, match.from); + const end = isLast ? query.substring(match.to) : ''; + + if (!labelExists(match.query.labels, filter)) { + // We don't want to add duplicate labels. + match.query.labels.push(filter); + } + const newLabels = modeller.renderQuery(match.query); + newQuery += start + newLabels + end; + prev = match.to; + } + return newQuery; +} + +/** + * Check if label exists in the list of labels but ignore the operator. + * @param labels + * @param filter + */ +function labelExists(labels: QueryBuilderLabelFilter[], filter: QueryBuilderLabelFilter) { + return labels.find((label) => label.label === filter.label && label.value === filter.value); +} diff --git a/packages/grafana-prometheus/src/components/AnnotationQueryEditor.tsx b/packages/grafana-prometheus/src/components/AnnotationQueryEditor.tsx new file mode 100644 index 0000000000000..0cbaf5978cdd9 --- /dev/null +++ b/packages/grafana-prometheus/src/components/AnnotationQueryEditor.tsx @@ -0,0 +1,139 @@ +import React from 'react'; + +import { AnnotationQuery } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { EditorField, EditorRow, EditorRows, EditorSwitch } from '@grafana/experimental'; +import { AutoSizeInput, Input, Space } from '@grafana/ui'; + +import { PromQueryCodeEditor } from '../querybuilder/components/PromQueryCodeEditor'; +import { PromQuery } from '../types'; + +import { PromQueryEditorProps } from './types'; + +type Props = PromQueryEditorProps & { + annotation?: AnnotationQuery<PromQuery>; + onAnnotationChange?: (annotation: AnnotationQuery<PromQuery>) => void; +}; + +export function AnnotationQueryEditor(props: Props) { + // This is because of problematic typing. See AnnotationQueryEditorProps in grafana-data/annotations.ts. + const annotation = props.annotation!; + const onAnnotationChange = props.onAnnotationChange!; + const query = { expr: annotation.expr, refId: annotation.name, interval: annotation.step }; + + return ( + <> + <EditorRows> + <PromQueryCodeEditor + {...props} + query={query} + showExplain={false} + onChange={(query) => { + onAnnotationChange({ + ...annotation, + expr: query.expr, + }); + }} + /> + <EditorRow> + <EditorField + label="Min step" + tooltip={ + <> + An additional lower limit for the step parameter of the Prometheus query and for the{' '} + <code>$__interval</code> and <code>$__rate_interval</code> variables. + </> + } + > + <AutoSizeInput + type="text" + aria-label="Set lower limit for the step parameter" + placeholder={'auto'} + minWidth={10} + onCommitChange={(ev) => { + onAnnotationChange({ + ...annotation, + step: ev.currentTarget.value, + }); + }} + defaultValue={query.interval} + id={selectors.components.DataSource.Prometheus.annotations.minStep} + /> + </EditorField> + </EditorRow> + </EditorRows> + <Space v={0.5} /> + <EditorRow> + <EditorField + label="Title" + tooltip={ + 'Use either the name or a pattern. For example, {{instance}} is replaced with label value for the label instance.' + } + > + <Input + type="text" + placeholder="{{alertname}}" + value={annotation.titleFormat} + onChange={(event) => { + onAnnotationChange({ + ...annotation, + titleFormat: event.currentTarget.value, + }); + }} + data-testid={selectors.components.DataSource.Prometheus.annotations.title} + /> + </EditorField> + <EditorField label="Tags"> + <Input + type="text" + placeholder="label1,label2" + value={annotation.tagKeys} + onChange={(event) => { + onAnnotationChange({ + ...annotation, + tagKeys: event.currentTarget.value, + }); + }} + data-testid={selectors.components.DataSource.Prometheus.annotations.tags} + /> + </EditorField> + <EditorField + label="Text" + tooltip={ + 'Use either the name or a pattern. For example, {{instance}} is replaced with label value for the label instance.' + } + > + <Input + type="text" + placeholder="{{instance}}" + value={annotation.textFormat} + onChange={(event) => { + onAnnotationChange({ + ...annotation, + textFormat: event.currentTarget.value, + }); + }} + data-testid={selectors.components.DataSource.Prometheus.annotations.text} + /> + </EditorField> + <EditorField + label="Series value as timestamp" + tooltip={ + 'The unit of timestamp is milliseconds. If the unit of the series value is seconds, multiply its range vector by 1000.' + } + > + <EditorSwitch + value={annotation.useValueForTime} + onChange={(event) => { + onAnnotationChange({ + ...annotation, + useValueForTime: event.currentTarget.value, + }); + }} + data-testid={selectors.components.DataSource.Prometheus.annotations.seriesValueAsTimestamp} + /> + </EditorField> + </EditorRow> + </> + ); +} diff --git a/packages/grafana-prometheus/src/components/PromCheatSheet.tsx b/packages/grafana-prometheus/src/components/PromCheatSheet.tsx new file mode 100644 index 0000000000000..347d65473011a --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromCheatSheet.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + +import { QueryEditorHelpProps } from '@grafana/data'; + +import { PromQuery } from '../types'; + +const CHEAT_SHEET_ITEMS = [ + { + title: 'Request Rate', + expression: 'rate(http_request_total[5m])', + label: + 'Given an HTTP request counter, this query calculates the per-second average request rate over the last 5 minutes.', + }, + { + title: '95th Percentile of Request Latencies', + expression: 'histogram_quantile(0.95, sum(rate(prometheus_http_request_duration_seconds_bucket[5m])) by (le))', + label: 'Calculates the 95th percentile of HTTP request rate over 5 minute windows.', + }, + { + title: 'Alerts Firing', + expression: 'sort_desc(sum(sum_over_time(ALERTS{alertstate="firing"}[24h])) by (alertname))', + label: 'Sums up the alerts that have been firing over the last 24 hours.', + }, + { + title: 'Step', + label: + 'Defines the graph resolution using a duration format (15s, 1m, 3h, ...). Small steps create high-resolution graphs but can be slow over larger time ranges. Using a longer step lowers the resolution and smooths the graph by producing fewer datapoints. If no step is given the resolution is calculated automatically.', + }, +]; + +export const PromCheatSheet = (props: QueryEditorHelpProps<PromQuery>) => ( + <div> + <h2>PromQL Cheat Sheet</h2> + {CHEAT_SHEET_ITEMS.map((item, index) => ( + <div className="cheat-sheet-item" key={index}> + <div className="cheat-sheet-item__title">{item.title}</div> + {item.expression ? ( + <button + type="button" + className="cheat-sheet-item__example" + onClick={(e) => props.onClickExample({ refId: 'A', expr: item.expression })} + > + <code>{item.expression}</code> + </button> + ) : null} + <div className="cheat-sheet-item__label">{item.label}</div> + </div> + ))} + </div> +); diff --git a/packages/grafana-prometheus/src/components/PromExemplarField.tsx b/packages/grafana-prometheus/src/components/PromExemplarField.tsx new file mode 100644 index 0000000000000..e09a6260c98b1 --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromExemplarField.tsx @@ -0,0 +1,79 @@ +import { css, cx } from '@emotion/css'; +import React, { useEffect, useState } from 'react'; +import { usePrevious } from 'react-use'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { IconButton, InlineLabel, Tooltip, useStyles2 } from '@grafana/ui'; + +import { PrometheusDatasource } from '../datasource'; +import { PromQuery } from '../types'; + +interface Props { + onChange: (exemplar: boolean) => void; + datasource: PrometheusDatasource; + query: PromQuery; + 'data-testid'?: string; +} + +export function PromExemplarField({ datasource, onChange, query, ...rest }: Props) { + const [error, setError] = useState<string | null>(null); + const styles = useStyles2(getStyles); + const prevError = usePrevious(error); + + useEffect(() => { + if (!datasource.exemplarsAvailable) { + setError('Exemplars for this query are not available'); + onChange(false); + } else if (query.instant && !query.range) { + setError('Exemplars are not available for instant queries'); + onChange(false); + } else { + setError(null); + // If error is cleared, we want to change exemplar to true + if (prevError && !error) { + onChange(true); + } + } + }, [datasource.exemplarsAvailable, query.instant, query.range, onChange, prevError, error]); + + const iconButtonStyles = cx( + { + [styles.activeIcon]: !!query.exemplar, + }, + styles.eyeIcon + ); + + return ( + <InlineLabel width="auto" data-testid={rest['data-testid']}> + <Tooltip content={error ?? ''}> + <div className={styles.iconWrapper}> + Exemplars + <IconButton + name="eye" + tooltip={!!query.exemplar ? 'Disable query with exemplars' : 'Enable query with exemplars'} + disabled={!!error} + className={iconButtonStyles} + onClick={() => { + onChange(!query.exemplar); + }} + /> + </div> + </Tooltip> + </InlineLabel> + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + eyeIcon: css` + margin-left: ${theme.spacing(2)}; + `, + activeIcon: css` + color: ${theme.colors.primary.main}; + `, + iconWrapper: css` + display: flex; + align-items: center; + `, + }; +} diff --git a/packages/grafana-prometheus/src/components/PromExploreExtraField.test.tsx b/packages/grafana-prometheus/src/components/PromExploreExtraField.test.tsx new file mode 100644 index 0000000000000..4c22e2f16f204 --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromExploreExtraField.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { PrometheusDatasource } from '../datasource'; +import { PromQuery } from '../types'; + +import { + PromExploreExtraField, + PromExploreExtraFieldProps, + promExploreExtraFieldTestIds, +} from './PromExploreExtraField'; + +const setup = (propOverrides?: PromExploreExtraFieldProps) => { + const query = { exemplar: false } as PromQuery; + const datasource = {} as PrometheusDatasource; + const onChange = jest.fn(); + const onRunQuery = jest.fn(); + + const props: PromExploreExtraFieldProps = { + onChange, + onRunQuery, + query, + datasource, + }; + + Object.assign(props, propOverrides); + + return render(<PromExploreExtraField {...props} />); +}; + +describe('PromExploreExtraField', () => { + it('should render step field', () => { + setup(); + expect(screen.getByTestId(promExploreExtraFieldTestIds.stepField)).toBeInTheDocument(); + }); + + it('should render query type field', () => { + setup(); + expect(screen.getByTestId(promExploreExtraFieldTestIds.queryTypeField)).toBeInTheDocument(); + }); +}); diff --git a/packages/grafana-prometheus/src/components/PromExploreExtraField.tsx b/packages/grafana-prometheus/src/components/PromExploreExtraField.tsx new file mode 100644 index 0000000000000..c9ad0a79e36b8 --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromExploreExtraField.tsx @@ -0,0 +1,145 @@ +import { css, cx } from '@emotion/css'; +import { isEqual } from 'lodash'; +import React, { memo, useCallback } from 'react'; +import { usePrevious } from 'react-use'; + +import { InlineFormLabel, RadioButtonGroup } from '@grafana/ui'; + +import { PrometheusDatasource } from '../datasource'; +import { PromQuery } from '../types'; + +import { PromExemplarField } from './PromExemplarField'; + +export interface PromExploreExtraFieldProps { + query: PromQuery; + onChange: (value: PromQuery) => void; + onRunQuery: () => void; + datasource: PrometheusDatasource; +} + +export const PromExploreExtraField = memo(({ query, datasource, onChange, onRunQuery }: PromExploreExtraFieldProps) => { + const rangeOptions = getQueryTypeOptions(true); + const prevQuery = usePrevious(query); + + const onExemplarChange = useCallback( + (exemplar: boolean) => { + if (!isEqual(query, prevQuery) || exemplar !== query.exemplar) { + onChange({ ...query, exemplar }); + } + }, + [prevQuery, query, onChange] + ); + + function onChangeQueryStep(interval: string) { + onChange({ ...query, interval }); + } + + function onStepChange(e: React.SyntheticEvent<HTMLInputElement>) { + if (e.currentTarget.value !== query.interval) { + onChangeQueryStep(e.currentTarget.value); + } + } + + function onReturnKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { + if (e.key === 'Enter' && e.shiftKey) { + onRunQuery(); + } + } + + const onQueryTypeChange = getQueryTypeChangeHandler(query, onChange); + + return ( + <div + aria-label="Prometheus extra field" + className="gf-form-inline" + data-testid={promExploreExtraFieldTestIds.extraFieldEditor} + > + {/*Query type field*/} + <div + data-testid={promExploreExtraFieldTestIds.queryTypeField} + className={cx( + 'gf-form explore-input-margin', + css` + flex-wrap: nowrap; + ` + )} + aria-label="Query type field" + > + <InlineFormLabel width="auto">Query type</InlineFormLabel> + + <RadioButtonGroup + options={rangeOptions} + value={query.range && query.instant ? 'both' : query.instant ? 'instant' : 'range'} + onChange={onQueryTypeChange} + /> + </div> + {/*Step field*/} + <div + data-testid={promExploreExtraFieldTestIds.stepField} + className={cx( + 'gf-form', + css` + flex-wrap: nowrap; + ` + )} + aria-label="Step field" + > + <InlineFormLabel + width={6} + tooltip={ + 'Time units and built-in variables can be used here, for example: $__interval, $__rate_interval, 5s, 1m, 3h, 1d, 1y (Default if no unit is specified: s)' + } + > + Min step + </InlineFormLabel> + <input + type={'text'} + className="gf-form-input width-4" + placeholder={'auto'} + onChange={onStepChange} + onKeyDown={onReturnKeyDown} + value={query.interval ?? ''} + /> + </div> + + <PromExemplarField onChange={onExemplarChange} datasource={datasource} query={query} /> + </div> + ); +}); + +PromExploreExtraField.displayName = 'PromExploreExtraField'; + +export function getQueryTypeOptions(includeBoth: boolean) { + const rangeOptions = [ + { value: 'range', label: 'Range', description: 'Run query over a range of time' }, + { + value: 'instant', + label: 'Instant', + description: 'Run query against a single point in time. For this query, the "To" time is used', + }, + ]; + + if (includeBoth) { + rangeOptions.push({ value: 'both', label: 'Both', description: 'Run an Instant query and a Range query' }); + } + + return rangeOptions; +} + +export function getQueryTypeChangeHandler(query: PromQuery, onChange: (update: PromQuery) => void) { + return (queryType: string) => { + if (queryType === 'instant') { + onChange({ ...query, instant: true, range: false, exemplar: false }); + } else if (queryType === 'range') { + onChange({ ...query, instant: false, range: true }); + } else { + onChange({ ...query, instant: true, range: true }); + } + }; +} + +export const promExploreExtraFieldTestIds = { + extraFieldEditor: 'prom-editor-extra-field', + stepField: 'prom-editor-extra-field-step', + queryTypeField: 'prom-editor-extra-field-query-type', +}; diff --git a/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx b/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx new file mode 100644 index 0000000000000..832cc37355fb2 --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from '@testing-library/react'; +import { noop } from 'lodash'; +import React from 'react'; + +import { CoreApp } from '@grafana/data'; + +import { PrometheusDatasource } from '../datasource'; + +import { PromQueryEditorByApp } from './PromQueryEditorByApp'; +import { alertingTestIds } from './PromQueryEditorForAlerting'; +import { Props } from './monaco-query-field/MonacoQueryFieldProps'; + +// the monaco-based editor uses lazy-loading and that does not work +// well with this test, and we do not need the monaco-related +// functionality in this test anyway, so we mock it out. +jest.mock('./monaco-query-field/MonacoQueryFieldLazy', () => { + const fakeQueryField = (props: Props) => { + return <input onBlur={(e) => props.onBlur(e.currentTarget.value)} data-testid={'dummy-code-input'} type={'text'} />; + }; + return { + MonacoQueryFieldLazy: fakeQueryField, + }; +}); + +function setup(app: CoreApp): { onRunQuery: jest.Mock } { + const dataSource = { + createQuery: jest.fn((q) => q), + getInitHints: () => [], + getPrometheusTime: jest.fn((date, roundup) => 123), + getQueryHints: jest.fn(() => []), + getDebounceTimeInMilliseconds: jest.fn(() => 300), + languageProvider: { + start: () => Promise.resolve([]), + syntax: () => {}, + getLabelKeys: () => [], + metrics: [], + }, + } as unknown as PrometheusDatasource; + const onRunQuery = jest.fn(); + + render( + <PromQueryEditorByApp + app={app} + onChange={noop} + onRunQuery={onRunQuery} + datasource={dataSource} + query={{ refId: 'A', expr: '' }} + /> + ); + + return { + onRunQuery, + }; +} + +describe('PromQueryEditorByApp', () => { + it('should render simplified query editor for cloud alerting', async () => { + setup(CoreApp.CloudAlerting); + + expect(await screen.findByTestId(alertingTestIds.editor)).toBeInTheDocument(); + }); + + it('should render editor selector for unkown apps', () => { + setup(CoreApp.Unknown); + + expect(screen.getByTestId('QueryEditorModeToggle')).toBeInTheDocument(); + expect(screen.queryByTestId(alertingTestIds.editor)).toBeNull(); + }); + + it('should render editor selector for explore', () => { + setup(CoreApp.Explore); + + expect(screen.getByTestId('QueryEditorModeToggle')).toBeInTheDocument(); + expect(screen.queryByTestId(alertingTestIds.editor)).toBeNull(); + }); + + it('should render editor selector for dashboard', () => { + setup(CoreApp.Dashboard); + + expect(screen.getByTestId('QueryEditorModeToggle')).toBeInTheDocument(); + expect(screen.queryByTestId(alertingTestIds.editor)).toBeNull(); + }); +}); diff --git a/packages/grafana-prometheus/src/components/PromQueryEditorByApp.tsx b/packages/grafana-prometheus/src/components/PromQueryEditorByApp.tsx new file mode 100644 index 0000000000000..843bb30284ab6 --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromQueryEditorByApp.tsx @@ -0,0 +1,21 @@ +import React, { memo } from 'react'; + +import { CoreApp } from '@grafana/data'; + +import { PromQueryEditorSelector } from '../querybuilder/components/PromQueryEditorSelector'; + +import { PromQueryEditorForAlerting } from './PromQueryEditorForAlerting'; +import { PromQueryEditorProps } from './types'; + +function PromQueryEditorByAppBase(props: PromQueryEditorProps) { + const { app } = props; + + switch (app) { + case CoreApp.CloudAlerting: + return <PromQueryEditorForAlerting {...props} />; + default: + return <PromQueryEditorSelector {...props} />; + } +} + +export const PromQueryEditorByApp = memo(PromQueryEditorByAppBase); diff --git a/packages/grafana-prometheus/src/components/PromQueryEditorForAlerting.tsx b/packages/grafana-prometheus/src/components/PromQueryEditorForAlerting.tsx new file mode 100644 index 0000000000000..5adac3afeb518 --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromQueryEditorForAlerting.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { PromQueryField } from './PromQueryField'; +import { PromQueryEditorProps } from './types'; + +export function PromQueryEditorForAlerting(props: PromQueryEditorProps) { + const { datasource, query, range, data, onChange, onRunQuery } = props; + + return ( + <PromQueryField + datasource={datasource} + query={query} + onRunQuery={onRunQuery} + onChange={onChange} + history={[]} + range={range} + data={data} + data-testid={alertingTestIds.editor} + /> + ); +} + +export const alertingTestIds = { + editor: 'prom-editor-cloud-alerting', +}; diff --git a/packages/grafana-prometheus/src/components/PromQueryField.test.tsx b/packages/grafana-prometheus/src/components/PromQueryField.test.tsx new file mode 100644 index 0000000000000..700f73574d5f7 --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromQueryField.test.tsx @@ -0,0 +1,183 @@ +import { getByTestId, render, screen, waitFor } from '@testing-library/react'; +// @ts-ignore +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { CoreApp, DataFrame, LoadingState, PanelData } from '@grafana/data'; + +import { PrometheusDatasource } from '../datasource'; +import PromQlLanguageProvider from '../language_provider'; + +import { PromQueryField } from './PromQueryField'; +import { Props } from './monaco-query-field/MonacoQueryFieldProps'; + +// the monaco-based editor uses lazy-loading and that does not work +// well with this test, and we do not need the monaco-related +// functionality in this test anyway, so we mock it out. +jest.mock('./monaco-query-field/MonacoQueryFieldLazy', () => { + const fakeQueryField = (props: Props) => { + return <input onBlur={(e) => props.onBlur(e.currentTarget.value)} data-testid={'dummy-code-input'} type={'text'} />; + }; + return { + MonacoQueryFieldLazy: fakeQueryField, + }; +}); + +const defaultProps = { + datasource: { + languageProvider: { + start: () => Promise.resolve([]), + syntax: () => {}, + getLabelKeys: () => [], + metrics: [], + }, + getInitHints: () => [], + } as unknown as PrometheusDatasource, + query: { + expr: '', + refId: '', + }, + onRunQuery: () => {}, + onChange: () => {}, + history: [], +}; + +describe('PromQueryField', () => { + beforeAll(() => { + // @ts-ignore + window.getSelection = () => {}; + }); + + it('renders metrics chooser regularly if lookups are not disabled in the datasource settings', async () => { + const queryField = render(<PromQueryField {...defaultProps} />); + + // wait for component to render + await screen.findByRole('button'); + + expect(queryField.getAllByRole('button')).toHaveLength(1); + }); + + it('renders a disabled metrics chooser if lookups are disabled in datasource settings', async () => { + const props = defaultProps; + props.datasource.lookupsDisabled = true; + const queryField = render(<PromQueryField {...props} />); + + // wait for component to render + await screen.findByRole('button'); + + const bcButton = queryField.getByRole('button'); + expect(bcButton).toBeDisabled(); + }); + + it('renders an initial hint if no data and initial hint provided', async () => { + const props = defaultProps; + props.datasource.lookupsDisabled = true; + props.datasource.getInitHints = () => [{ label: 'Initial hint', type: 'INFO' }]; + render(<PromQueryField {...props} />); + + // wait for component to render + await screen.findByRole('button'); + + expect(screen.getByText('Initial hint')).toBeInTheDocument(); + }); + + it('renders query hint if data, query hint and initial hint provided', async () => { + const props = defaultProps; + props.datasource.lookupsDisabled = true; + props.datasource.getInitHints = () => [{ label: 'Initial hint', type: 'INFO' }]; + props.datasource.getQueryHints = () => [{ label: 'Query hint', type: 'INFO' }]; + render( + <PromQueryField + {...props} + data={ + { + series: [{ name: 'test name' }] as DataFrame[], + state: LoadingState.Done, + } as PanelData + } + /> + ); + + // wait for component to render + await screen.findByRole('button'); + + expect(screen.getByText('Query hint')).toBeInTheDocument(); + expect(screen.queryByText('Initial hint')).not.toBeInTheDocument(); + }); + + it('refreshes metrics when the data source changes', async () => { + const defaultProps = { + query: { expr: '', refId: '' }, + onRunQuery: () => {}, + onChange: () => {}, + history: [], + }; + const metrics = ['foo', 'bar']; + const queryField = render( + <PromQueryField + datasource={ + { + languageProvider: makeLanguageProvider({ metrics: [metrics] }), + getInitHints: () => [], + } as unknown as PrometheusDatasource + } + {...defaultProps} + /> + ); + + // wait for component to render + await screen.findByRole('button'); + + const changedMetrics = ['baz', 'moo']; + queryField.rerender( + <PromQueryField + // @ts-ignore + datasource={{ + languageProvider: makeLanguageProvider({ metrics: [changedMetrics] }), + }} + {...defaultProps} + /> + ); + + // If we check the label browser right away it should be in loading state + let labelBrowser = screen.getByRole('button'); + expect(labelBrowser).toHaveTextContent('Loading'); + + // wait for component to rerender + labelBrowser = await screen.findByRole('button'); + await waitFor(() => { + expect(labelBrowser).toHaveTextContent('Metrics browser'); + }); + }); + + it('should not run query onBlur', async () => { + const onRunQuery = jest.fn(); + const { container } = render(<PromQueryField {...defaultProps} app={CoreApp.Explore} onRunQuery={onRunQuery} />); + + // wait for component to rerender + await screen.findByRole('button'); + + const input = getByTestId(container, 'dummy-code-input'); + expect(input).toBeInTheDocument(); + await userEvent.type(input, 'metric'); + + // blur element + await userEvent.click(document.body); + expect(onRunQuery).not.toHaveBeenCalled(); + }); +}); + +function makeLanguageProvider(options: { metrics: string[][] }) { + const metricsStack = [...options.metrics]; + return { + histogramMetrics: [], + metrics: [], + metricsMetadata: {}, + lookupsDisabled: false, + getLabelKeys: () => [], + start() { + this.metrics = metricsStack.shift(); + return Promise.resolve([]); + }, + } as any as PromQlLanguageProvider; +} diff --git a/packages/grafana-prometheus/src/components/PromQueryField.tsx b/packages/grafana-prometheus/src/components/PromQueryField.tsx new file mode 100644 index 0000000000000..3ceab6316093b --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromQueryField.tsx @@ -0,0 +1,290 @@ +import { cx } from '@emotion/css'; +import React, { ReactNode } from 'react'; + +import { isDataFrame, QueryEditorProps, QueryHint, TimeRange, toLegacyResponseData } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { reportInteraction } from '@grafana/runtime'; +import { clearButtonStyles, Icon, Themeable2, withTheme2 } from '@grafana/ui'; + +import { PrometheusDatasource } from '../datasource'; +import { LocalStorageValueProvider } from '../gcopypaste/app/core/components/LocalStorageValueProvider'; +import { + CancelablePromise, + isCancelablePromiseRejection, + makePromiseCancelable, +} from '../gcopypaste/app/core/utils/CancelablePromise'; +import { roundMsToMin } from '../language_utils'; +import { PromOptions, PromQuery } from '../types'; + +import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser'; +import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper'; + +const LAST_USED_LABELS_KEY = 'grafana.datasources.prometheus.browser.labels'; + +function getChooserText(metricsLookupDisabled: boolean, hasSyntax: boolean, hasMetrics: boolean) { + if (metricsLookupDisabled) { + return '(Disabled)'; + } + + if (!hasSyntax) { + return 'Loading metrics...'; + } + + if (!hasMetrics) { + return '(No metrics found)'; + } + + return 'Metrics browser'; +} + +interface PromQueryFieldProps extends QueryEditorProps<PrometheusDatasource, PromQuery, PromOptions>, Themeable2 { + ExtraFieldElement?: ReactNode; + 'data-testid'?: string; +} + +interface PromQueryFieldState { + labelBrowserVisible: boolean; + syntaxLoaded: boolean; + hint: QueryHint | null; +} + +class PromQueryFieldClass extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> { + declare languageProviderInitializationPromise: CancelablePromise<any>; + + constructor(props: PromQueryFieldProps) { + super(props); + + this.state = { + labelBrowserVisible: false, + syntaxLoaded: false, + hint: null, + }; + } + + componentDidMount() { + if (this.props.datasource.languageProvider) { + this.refreshMetrics(); + } + this.refreshHint(); + } + + componentWillUnmount() { + if (this.languageProviderInitializationPromise) { + this.languageProviderInitializationPromise.cancel(); + } + } + + componentDidUpdate(prevProps: PromQueryFieldProps) { + const { + data, + datasource: { languageProvider }, + range, + } = this.props; + + if (languageProvider !== prevProps.datasource.languageProvider) { + // We reset this only on DS change so we do not flesh loading state on every rangeChange which happens on every + // query run if using relative range. + this.setState({ + syntaxLoaded: false, + }); + } + + const changedRangeToRefresh = this.rangeChangedToRefresh(range, prevProps.range); + // We want to refresh metrics when language provider changes and/or when range changes (we round up intervals to a minute) + if (languageProvider !== prevProps.datasource.languageProvider || changedRangeToRefresh) { + this.refreshMetrics(); + } + + if (data && prevProps.data && prevProps.data.series !== data.series) { + this.refreshHint(); + } + } + + refreshHint = () => { + const { datasource, query, data } = this.props; + const initHints = datasource.getInitHints(); + const initHint = initHints.length > 0 ? initHints[0] : null; + + if (!data || data.series.length === 0) { + this.setState({ + hint: initHint, + }); + return; + } + + const result = isDataFrame(data.series[0]) ? data.series.map(toLegacyResponseData) : data.series; + const queryHints = datasource.getQueryHints(query, result); + let queryHint = queryHints.length > 0 ? queryHints[0] : null; + + this.setState({ hint: queryHint ?? initHint }); + }; + + refreshMetrics = async () => { + const { + range, + datasource: { languageProvider }, + } = this.props; + + this.languageProviderInitializationPromise = makePromiseCancelable(languageProvider.start(range)); + + try { + const remainingTasks = await this.languageProviderInitializationPromise.promise; + await Promise.all(remainingTasks); + this.onUpdateLanguage(); + } catch (err) { + if (isCancelablePromiseRejection(err) && err.isCanceled) { + // do nothing, promise was canceled + } else { + throw err; + } + } + }; + + rangeChangedToRefresh(range?: TimeRange, prevRange?: TimeRange): boolean { + if (range && prevRange) { + const sameMinuteFrom = roundMsToMin(range.from.valueOf()) === roundMsToMin(prevRange.from.valueOf()); + const sameMinuteTo = roundMsToMin(range.to.valueOf()) === roundMsToMin(prevRange.to.valueOf()); + // If both are same, don't need to refresh. + return !(sameMinuteFrom && sameMinuteTo); + } + return false; + } + + /** + * TODO #33976: Remove this, add histogram group (query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;) + */ + onChangeLabelBrowser = (selector: string) => { + this.onChangeQuery(selector, true); + this.setState({ labelBrowserVisible: false }); + }; + + onChangeQuery = (value: string, override?: boolean) => { + // Send text change to parent + const { query, onChange, onRunQuery } = this.props; + if (onChange) { + const nextQuery: PromQuery = { ...query, expr: value }; + onChange(nextQuery); + + if (override && onRunQuery) { + onRunQuery(); + } + } + }; + + onClickChooserButton = () => { + this.setState((state) => ({ labelBrowserVisible: !state.labelBrowserVisible })); + + reportInteraction('user_grafana_prometheus_metrics_browser_clicked', { + editorMode: this.state.labelBrowserVisible ? 'metricViewClosed' : 'metricViewOpen', + app: this.props?.app ?? '', + }); + }; + + onClickHintFix = () => { + const { datasource, query, onChange, onRunQuery } = this.props; + const { hint } = this.state; + if (hint?.fix?.action) { + onChange(datasource.modifyQuery(query, hint.fix.action)); + } + onRunQuery(); + }; + + onUpdateLanguage = () => { + const { + datasource: { languageProvider }, + } = this.props; + const { metrics } = languageProvider; + + if (!metrics) { + return; + } + + this.setState({ syntaxLoaded: true }); + }; + + render() { + const { + datasource, + datasource: { languageProvider }, + query, + ExtraFieldElement, + history = [], + theme, + } = this.props; + + const { labelBrowserVisible, syntaxLoaded, hint } = this.state; + const hasMetrics = languageProvider.metrics.length > 0; + const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics); + const buttonDisabled = !(syntaxLoaded && hasMetrics); + + return ( + <LocalStorageValueProvider<string[]> storageKey={LAST_USED_LABELS_KEY} defaultValue={[]}> + {(lastUsedLabels, onLastUsedLabelsSave, onLastUsedLabelsDelete) => { + return ( + <> + <div + className="gf-form-inline gf-form-inline--xs-view-flex-column flex-grow-1" + data-testid={this.props['data-testid']} + > + <button + className="gf-form-label query-keyword pointer" + onClick={this.onClickChooserButton} + disabled={buttonDisabled} + type="button" + data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.openButton} + > + {chooserText} + <Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} /> + </button> + + <div className="gf-form gf-form--grow flex-shrink-1 min-width-15"> + <MonacoQueryFieldWrapper + languageProvider={languageProvider} + history={history} + onChange={this.onChangeQuery} + onRunQuery={this.props.onRunQuery} + initialValue={query.expr ?? ''} + placeholder="Enter a PromQL query…" + datasource={datasource} + /> + </div> + </div> + {labelBrowserVisible && ( + <div className="gf-form"> + <PrometheusMetricsBrowser + languageProvider={languageProvider} + onChange={this.onChangeLabelBrowser} + lastUsedLabels={lastUsedLabels || []} + storeLastUsedLabels={onLastUsedLabelsSave} + deleteLastUsedLabels={onLastUsedLabelsDelete} + timeRange={this.props.range} + /> + </div> + )} + + {ExtraFieldElement} + {hint ? ( + <div className="query-row-break"> + <div className="prom-query-field-info text-warning"> + {hint.label}{' '} + {hint.fix ? ( + <button + type="button" + className={cx(clearButtonStyles(theme), 'text-link', 'muted')} + onClick={this.onClickHintFix} + > + {hint.fix.label} + </button> + ) : null} + </div> + </div> + ) : null} + </> + ); + }} + </LocalStorageValueProvider> + ); + } +} + +export const PromQueryField = withTheme2(PromQueryFieldClass); diff --git a/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx new file mode 100644 index 0000000000000..e3ba496356bea --- /dev/null +++ b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx @@ -0,0 +1,324 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { createTheme } from '@grafana/data'; + +import PromQlLanguageProvider from '../language_provider'; + +import { + BrowserProps, + buildSelector, + facetLabels, + SelectableLabel, + UnthemedPrometheusMetricsBrowser, +} from './PrometheusMetricsBrowser'; + +describe('buildSelector()', () => { + it('returns an empty selector for no labels', () => { + expect(buildSelector([])).toEqual('{}'); + }); + it('returns an empty selector for selected labels with no values', () => { + const labels: SelectableLabel[] = [{ name: 'foo', selected: true }]; + expect(buildSelector(labels)).toEqual('{}'); + }); + it('returns an empty selector for one selected label with no selected values', () => { + const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar' }] }]; + expect(buildSelector(labels)).toEqual('{}'); + }); + it('returns a simple selector from a selected label with a selected value', () => { + const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar', selected: true }] }]; + expect(buildSelector(labels)).toEqual('{foo="bar"}'); + }); + it('metric selector without labels', () => { + const labels: SelectableLabel[] = [{ name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] }]; + expect(buildSelector(labels)).toEqual('foo{}'); + }); + it('selector with multiple metrics', () => { + const labels: SelectableLabel[] = [ + { + name: '__name__', + selected: true, + values: [ + { name: 'foo', selected: true }, + { name: 'bar', selected: true }, + ], + }, + ]; + expect(buildSelector(labels)).toEqual('{__name__=~"foo|bar"}'); + }); + it('metric selector with labels', () => { + const labels: SelectableLabel[] = [ + { name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] }, + { name: 'bar', selected: true, values: [{ name: 'baz', selected: true }] }, + ]; + expect(buildSelector(labels)).toEqual('foo{bar="baz"}'); + }); +}); + +describe('facetLabels()', () => { + const possibleLabels = { + cluster: ['dev'], + namespace: ['alertmanager'], + }; + const labels: SelectableLabel[] = [ + { name: 'foo', selected: true, values: [{ name: 'bar' }] }, + { name: 'cluster', values: [{ name: 'dev' }, { name: 'ops' }, { name: 'prod' }] }, + { name: 'namespace', values: [{ name: 'alertmanager' }] }, + ]; + + it('returns no labels given an empty label set', () => { + expect(facetLabels([], {})).toEqual([]); + }); + + it('marks all labels as hidden when no labels are possible', () => { + const result = facetLabels(labels, {}); + expect(result.length).toEqual(labels.length); + expect(result[0].hidden).toBeTruthy(); + expect(result[0].values).toBeUndefined(); + }); + + it('keeps values as facetted when they are possible', () => { + const result = facetLabels(labels, possibleLabels); + expect(result.length).toEqual(labels.length); + expect(result[0].hidden).toBeTruthy(); + expect(result[0].values).toBeUndefined(); + expect(result[1].hidden).toBeFalsy(); + expect(result[1].values!.length).toBe(1); + expect(result[1].values![0].name).toBe('dev'); + }); + + it('does not facet out label values that are currently being facetted', () => { + const result = facetLabels(labels, possibleLabels, 'cluster'); + expect(result.length).toEqual(labels.length); + expect(result[0].hidden).toBeTruthy(); + expect(result[1].hidden).toBeFalsy(); + // 'cluster' is being facetted, should show all 3 options even though only 1 is possible + expect(result[1].values!.length).toBe(3); + expect(result[2].values!.length).toBe(1); + }); +}); + +describe('PrometheusMetricsBrowser', () => { + const setupProps = (): BrowserProps => { + const mockLanguageProvider = { + start: () => Promise.resolve(), + getLabelValues: (name: string) => { + switch (name) { + case 'label1': + return ['value1-1', 'value1-2']; + case 'label2': + return ['value2-1', 'value2-2']; + case 'label3': + return ['value3-1', 'value3-2']; + } + return []; + }, + // This must always call the series endpoint + // until we refactor all of the metrics browser + // to never use the series endpoint. + // The metrics browser expects both label names and label values. + // The labels endpoint with match does not supply label values + // and so using it breaks the metrics browser. + fetchSeriesLabels: (selector: string) => { + switch (selector) { + case '{label1="value1-1"}': + return { label1: ['value1-1'], label2: ['value2-1'], label3: ['value3-1'] }; + case '{label1=~"value1-1|value1-2"}': + return { label1: ['value1-1', 'value1-2'], label2: ['value2-1'], label3: ['value3-1', 'value3-2'] }; + } + // Allow full set by default + return { + label1: ['value1-1', 'value1-2'], + label2: ['value2-1', 'value2-2'], + }; + }, + getLabelKeys: () => ['label1', 'label2', 'label3'], + }; + + const defaults: BrowserProps = { + theme: createTheme({ colors: { mode: 'dark' } }), + onChange: () => {}, + autoSelect: 0, + languageProvider: mockLanguageProvider as unknown as PromQlLanguageProvider, + lastUsedLabels: [], + storeLastUsedLabels: () => {}, + deleteLastUsedLabels: () => {}, + }; + + return defaults; + }; + + // Clear label selection manually because it's saved in localStorage + afterEach(async () => { + const clearBtn = screen.getByLabelText('Selector clear button'); + await userEvent.click(clearBtn); + }); + + it('renders and loader shows when empty, and then first set of labels', async () => { + const props = setupProps(); + render(<UnthemedPrometheusMetricsBrowser {...props} />); + // Loading appears and dissappears + screen.getByText(/Loading labels/); + await waitFor(() => { + expect(screen.queryByText(/Loading labels/)).not.toBeInTheDocument(); + }); + // Initial set of labels is available and not selected + expect(screen.queryByRole('option', { name: 'label1' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'label1', selected: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'label2' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'label2', selected: true })).not.toBeInTheDocument(); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); + }); + + it('allows label and value selection/deselection', async () => { + const props = setupProps(); + render(<UnthemedPrometheusMetricsBrowser {...props} />); + // Selecting label2 + const label2 = await screen.findByRole('option', { name: 'label2', selected: false }); + expect(screen.queryByRole('list', { name: /Values/ })).not.toBeInTheDocument(); + await userEvent.click(label2); + expect(screen.queryByRole('option', { name: 'label2', selected: true })).toBeInTheDocument(); + // List of values for label2 appears + expect(screen.queryByLabelText(/Values for/)).toHaveTextContent('label2'); + expect(screen.queryByRole('option', { name: 'value2-1' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'value2-2' })).toBeInTheDocument(); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); + // Selecting label1, list for its values appears + const label1 = await screen.findByRole('option', { name: 'label1', selected: false }); + await userEvent.click(label1); + expect(screen.queryByRole('option', { name: 'label1', selected: true })).toBeInTheDocument(); + await screen.findByLabelText('Values for label1'); + expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(2); + // Selecting value2-2 of label2 + const value = await screen.findByRole('option', { name: 'value2-2', selected: false }); + await userEvent.click(value); + await screen.findByRole('option', { name: 'value2-2', selected: true }); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-2"}'); + // Selecting value2-1 of label2, both values now selected + const value2 = await screen.findByRole('option', { name: 'value2-1', selected: false }); + await userEvent.click(value2); + // await screen.findByRole('option', {name: 'value2-1', selected: true}); + await screen.findByText('{label2=~"value2-1|value2-2"}'); + // Deselecting value2-2, one value should remain + const selectedValue = await screen.findByRole('option', { name: 'value2-2', selected: true }); + await userEvent.click(selectedValue); + await screen.findByRole('option', { name: 'value2-1', selected: true }); + await screen.findByRole('option', { name: 'value2-2', selected: false }); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-1"}'); + }); + + it('allows label selection from multiple labels', async () => { + const props = setupProps(); + render(<UnthemedPrometheusMetricsBrowser {...props} />); + + // Selecting label2 + const label2 = await screen.findByRole('option', { name: 'label2', selected: false }); + await userEvent.click(label2); + // List of values for label2 appears + expect(screen.queryByLabelText(/Values for/)).toHaveTextContent('label2'); + expect(screen.queryByRole('option', { name: 'value2-1' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'value2-2' })).toBeInTheDocument(); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); + // Selecting label1, list for its values appears + const label1 = await screen.findByRole('option', { name: 'label1', selected: false }); + await userEvent.click(label1); + await screen.findByLabelText('Values for label1'); + expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(2); + // Selecting value2-1 of label2 + const value2 = await screen.findByRole('option', { name: 'value2-1', selected: false }); + await userEvent.click(value2); + await screen.findByText('{label2="value2-1"}'); + + // Selecting value from label1 for combined selector + const value1 = await screen.findByRole('option', { name: 'value1-2', selected: false }); + await userEvent.click(value1); + await screen.findByRole('option', { name: 'value1-2', selected: true }); + await screen.findByText('{label1="value1-2",label2="value2-1"}'); + // Deselect label1 should remove label and value + const selectedLabel = (await screen.findAllByRole('option', { name: /label1/, selected: true }))[0]; + await userEvent.click(selectedLabel); + await screen.findByRole('option', { name: /label1/, selected: false }); + expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(1); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-1"}'); + }); + + it('allows clearing the label selection', async () => { + const props = setupProps(); + render(<UnthemedPrometheusMetricsBrowser {...props} />); + + // Selecting label2 + const label2 = await screen.findByRole('option', { name: 'label2', selected: false }); + await userEvent.click(label2); + // List of values for label2 appears + expect(screen.queryByLabelText(/Values for/)).toHaveTextContent('label2'); + expect(screen.queryByRole('option', { name: 'value2-1' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'value2-2' })).toBeInTheDocument(); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); + // Selecting label1, list for its values appears + const label1 = await screen.findByRole('option', { name: 'label1', selected: false }); + await userEvent.click(label1); + await screen.findByLabelText('Values for label1'); + expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(2); + // Selecting value2-1 of label2 + const value2 = await screen.findByRole('option', { name: 'value2-1', selected: false }); + await userEvent.click(value2); + await screen.findByText('{label2="value2-1"}'); + + // Clear selector + const clearBtn = screen.getByLabelText('Selector clear button'); + await userEvent.click(clearBtn); + await screen.findByRole('option', { name: 'label2', selected: false }); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); + }); + + it('filters values by input text', async () => { + const props = setupProps(); + render(<UnthemedPrometheusMetricsBrowser {...props} />); + // Selecting label2 and label1 + const label2 = await screen.findByRole('option', { name: /label2/, selected: false }); + await userEvent.click(label2); + const label1 = await screen.findByRole('option', { name: /label1/, selected: false }); + await userEvent.click(label1); + await screen.findByLabelText('Values for label1'); + await screen.findByLabelText('Values for label2'); + expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4); + // Typing '1' to filter for values + await userEvent.type(screen.getByLabelText('Filter expression for label values'), '1'); + expect(screen.getByLabelText('Filter expression for label values')).toHaveValue('1'); + expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(3); + expect(screen.queryByRole('option', { name: 'value2-2' })).not.toBeInTheDocument(); + }); + + it('facets labels', async () => { + const props = setupProps(); + render(<UnthemedPrometheusMetricsBrowser {...props} />); + // Selecting label2 and label1 + const label2 = await screen.findByRole('option', { name: /label2/, selected: false }); + await userEvent.click(label2); + const label1 = await screen.findByRole('option', { name: /label1/, selected: false }); + await userEvent.click(label1); + await screen.findByLabelText('Values for label1'); + await screen.findByLabelText('Values for label2'); + expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4); + expect(screen.queryByRole('option', { name: /label3/ })).toHaveTextContent('label3'); + // Click value1-1 which triggers facetting for value3-x, and still show all value1-x + const value1 = await screen.findByRole('option', { name: 'value1-1', selected: false }); + await userEvent.click(value1); + await waitFor(() => expect(screen.queryByRole('option', { name: 'value2-2' })).not.toBeInTheDocument()); + expect(screen.queryByRole('option', { name: 'value1-2' })).toBeInTheDocument(); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{label1="value1-1"}'); + expect(screen.queryByRole('option', { name: /label3/ })).toHaveTextContent('label3 (1)'); + // Click value1-2 for which facetting will allow more values for value3-x + const value12 = await screen.findByRole('option', { name: 'value1-2', selected: false }); + await userEvent.click(value12); + await screen.findByRole('option', { name: 'value1-2', selected: true }); + await screen.findByRole('option', { name: /label3/, selected: false }); + await userEvent.click(screen.getByRole('option', { name: /label3/ })); + await screen.findByLabelText('Values for label3'); + expect(screen.queryByRole('option', { name: 'value1-1', selected: true })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'value1-2', selected: true })).toBeInTheDocument(); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{label1=~"value1-1|value1-2"}'); + expect(screen.queryAllByRole('option', { name: /label3/ })[0]).toHaveTextContent('label3 (2)'); + }); +}); diff --git a/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx new file mode 100644 index 0000000000000..79ae235574e13 --- /dev/null +++ b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx @@ -0,0 +1,684 @@ +import { css, cx } from '@emotion/css'; +import React, { ChangeEvent } from 'react'; +import { FixedSizeList } from 'react-window'; + +import { GrafanaTheme2, TimeRange } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { + BrowserLabel as PromLabel, + Button, + HorizontalGroup, + Input, + Label, + LoadingPlaceholder, + stylesFactory, + withTheme2, +} from '@grafana/ui'; + +import PromQlLanguageProvider from '../language_provider'; +import { escapeLabelValueInExactSelector, escapeLabelValueInRegexSelector } from '../language_utils'; + +// Hard limit on labels to render +const EMPTY_SELECTOR = '{}'; +const METRIC_LABEL = '__name__'; +const LIST_ITEM_SIZE = 25; + +export interface BrowserProps { + languageProvider: PromQlLanguageProvider; + onChange: (selector: string) => void; + theme: GrafanaTheme2; + autoSelect?: number; + hide?: () => void; + lastUsedLabels: string[]; + storeLastUsedLabels: (labels: string[]) => void; + deleteLastUsedLabels: () => void; + timeRange?: TimeRange; +} + +interface BrowserState { + labels: SelectableLabel[]; + labelSearchTerm: string; + metricSearchTerm: string; + status: string; + error: string; + validationStatus: string; + valueSearchTerm: string; +} + +interface FacettableValue { + name: string; + selected?: boolean; + details?: string; +} + +export interface SelectableLabel { + name: string; + selected?: boolean; + loading?: boolean; + values?: FacettableValue[]; + hidden?: boolean; + facets?: number; +} + +export function buildSelector(labels: SelectableLabel[]): string { + let singleMetric = ''; + const selectedLabels = []; + for (const label of labels) { + if ((label.name === METRIC_LABEL || label.selected) && label.values && label.values.length > 0) { + const selectedValues = label.values.filter((value) => value.selected).map((value) => value.name); + if (selectedValues.length > 1) { + selectedLabels.push(`${label.name}=~"${selectedValues.map(escapeLabelValueInRegexSelector).join('|')}"`); + } else if (selectedValues.length === 1) { + if (label.name === METRIC_LABEL) { + singleMetric = selectedValues[0]; + } else { + selectedLabels.push(`${label.name}="${escapeLabelValueInExactSelector(selectedValues[0])}"`); + } + } + } + } + return [singleMetric, '{', selectedLabels.join(','), '}'].join(''); +} + +export function facetLabels( + labels: SelectableLabel[], + possibleLabels: Record<string, string[]>, + lastFacetted?: string +): SelectableLabel[] { + return labels.map((label) => { + const possibleValues = possibleLabels[label.name]; + if (possibleValues) { + let existingValues: FacettableValue[]; + if (label.name === lastFacetted && label.values) { + // Facetting this label, show all values + existingValues = label.values; + } else { + // Keep selection in other facets + const selectedValues: Set<string> = new Set( + label.values?.filter((value) => value.selected).map((value) => value.name) || [] + ); + // Values for this label have not been requested yet, let's use the facetted ones as the initial values + existingValues = possibleValues.map((value) => ({ name: value, selected: selectedValues.has(value) })); + } + return { + ...label, + loading: false, + values: existingValues, + hidden: !possibleValues, + facets: existingValues.length, + }; + } + + // Label is facetted out, hide all values + return { ...label, loading: false, hidden: !possibleValues, values: undefined, facets: 0 }; + }); +} + +const getStyles = stylesFactory((theme: GrafanaTheme2) => ({ + wrapper: css` + background-color: ${theme.colors.background.secondary}; + padding: ${theme.spacing(1)}; + width: 100%; + `, + list: css` + margin-top: ${theme.spacing(1)}; + display: flex; + flex-wrap: wrap; + max-height: 200px; + overflow: auto; + align-content: flex-start; + `, + section: css` + & + & { + margin: ${theme.spacing(2)} 0; + } + position: relative; + `, + selector: css` + font-family: ${theme.typography.fontFamilyMonospace}; + margin-bottom: ${theme.spacing(1)}; + `, + status: css` + padding: ${theme.spacing(0.5)}; + color: ${theme.colors.text.secondary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + /* using absolute positioning because flex interferes with ellipsis */ + position: absolute; + width: 50%; + right: 0; + text-align: right; + transition: opacity 100ms linear; + opacity: 0; + `, + statusShowing: css` + opacity: 1; + `, + error: css` + color: ${theme.colors.error.main}; + `, + valueList: css` + margin-right: ${theme.spacing(1)}; + resize: horizontal; + `, + valueListWrapper: css` + border-left: 1px solid ${theme.colors.border.medium}; + margin: ${theme.spacing(1)} 0; + padding: ${theme.spacing(1)} 0 ${theme.spacing(1)} ${theme.spacing(1)}; + `, + valueListArea: css` + display: flex; + flex-wrap: wrap; + margin-top: ${theme.spacing(1)}; + `, + valueTitle: css` + margin-left: -${theme.spacing(0.5)}; + margin-bottom: ${theme.spacing(1)}; + `, + validationStatus: css` + padding: ${theme.spacing(0.5)}; + margin-bottom: ${theme.spacing(1)}; + color: ${theme.colors.text.maxContrast}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + `, +})); + +/** + * TODO #33976: Remove duplicated code. The component is very similar to LokiLabelBrowser.tsx. Check if it's possible + * to create a single, generic component. + */ +export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserProps, BrowserState> { + valueListsRef = React.createRef<HTMLDivElement>(); + state: BrowserState = { + labels: [], + labelSearchTerm: '', + metricSearchTerm: '', + status: 'Ready', + error: '', + validationStatus: '', + valueSearchTerm: '', + }; + + onChangeLabelSearch = (event: ChangeEvent<HTMLInputElement>) => { + this.setState({ labelSearchTerm: event.target.value }); + }; + + onChangeMetricSearch = (event: ChangeEvent<HTMLInputElement>) => { + this.setState({ metricSearchTerm: event.target.value }); + }; + + onChangeValueSearch = (event: ChangeEvent<HTMLInputElement>) => { + this.setState({ valueSearchTerm: event.target.value }); + }; + + onClickRunQuery = () => { + const selector = buildSelector(this.state.labels); + this.props.onChange(selector); + }; + + onClickRunRateQuery = () => { + const selector = buildSelector(this.state.labels); + const query = `rate(${selector}[$__rate_interval])`; + this.props.onChange(query); + }; + + onClickClear = () => { + this.setState((state) => { + const labels: SelectableLabel[] = state.labels.map((label) => ({ + ...label, + values: undefined, + selected: false, + loading: false, + hidden: false, + facets: undefined, + })); + return { + labels, + labelSearchTerm: '', + metricSearchTerm: '', + status: '', + error: '', + validationStatus: '', + valueSearchTerm: '', + }; + }); + this.props.deleteLastUsedLabels(); + // Get metrics + this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR); + }; + + onClickLabel = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => { + const label = this.state.labels.find((l) => l.name === name); + if (!label) { + return; + } + // Toggle selected state + const selected = !label.selected; + let nextValue: Partial<SelectableLabel> = { selected }; + if (label.values && !selected) { + // Deselect all values if label was deselected + const values = label.values.map((value) => ({ ...value, selected: false })); + nextValue = { ...nextValue, facets: 0, values }; + } + // Resetting search to prevent empty results + this.setState({ labelSearchTerm: '' }); + this.updateLabelState(name, nextValue, '', () => this.doFacettingForLabel(name)); + }; + + onClickValue = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => { + const label = this.state.labels.find((l) => l.name === name); + if (!label || !label.values) { + return; + } + // Resetting search to prevent empty results + this.setState({ labelSearchTerm: '' }); + // Toggling value for selected label, leaving other values intact + const values = label.values.map((v) => ({ ...v, selected: v.name === value ? !v.selected : v.selected })); + this.updateLabelState(name, { values }, '', () => this.doFacetting(name)); + }; + + onClickMetric = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => { + // Finding special metric label + const label = this.state.labels.find((l) => l.name === name); + if (!label || !label.values) { + return; + } + // Resetting search to prevent empty results + this.setState({ metricSearchTerm: '' }); + // Toggling value for selected label, leaving other values intact + const values = label.values.map((v) => ({ + ...v, + selected: v.name === value || v.selected ? !v.selected : v.selected, + })); + // Toggle selected state of special metrics label + const selected = values.some((v) => v.selected); + this.updateLabelState(name, { selected, values }, '', () => this.doFacetting(name)); + }; + + onClickValidate = () => { + const selector = buildSelector(this.state.labels); + this.validateSelector(selector); + }; + + updateLabelState(name: string, updatedFields: Partial<SelectableLabel>, status = '', cb?: () => void) { + this.setState((state) => { + const labels: SelectableLabel[] = state.labels.map((label) => { + if (label.name === name) { + return { ...label, ...updatedFields }; + } + return label; + }); + // New status overrides errors + const error = status ? '' : state.error; + return { labels, status, error, validationStatus: '' }; + }, cb); + } + + componentDidMount() { + const { languageProvider, lastUsedLabels } = this.props; + if (languageProvider) { + const selectedLabels: string[] = lastUsedLabels; + languageProvider.start(this.props.timeRange).then(() => { + let rawLabels: string[] = languageProvider.getLabelKeys(); + // Get metrics + this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR); + // Auto-select previously selected labels + const labels: SelectableLabel[] = rawLabels.map((label, i, arr) => ({ + name: label, + selected: selectedLabels.includes(label), + loading: false, + })); + // Pre-fetch values for selected labels + this.setState({ labels }, () => { + this.state.labels.forEach((label) => { + if (label.selected) { + this.fetchValues(label.name, EMPTY_SELECTOR); + } + }); + }); + }); + } + } + + doFacettingForLabel(name: string) { + const label = this.state.labels.find((l) => l.name === name); + if (!label) { + return; + } + const selectedLabels = this.state.labels.filter((label) => label.selected).map((label) => label.name); + this.props.storeLastUsedLabels(selectedLabels); + if (label.selected) { + // Refetch values for newly selected label... + if (!label.values) { + this.fetchValues(name, buildSelector(this.state.labels)); + } + } else { + // Only need to facet when deselecting labels + this.doFacetting(); + } + } + + doFacetting = (lastFacetted?: string) => { + const selector = buildSelector(this.state.labels); + if (selector === EMPTY_SELECTOR) { + // Clear up facetting + const labels: SelectableLabel[] = this.state.labels.map((label) => { + return { ...label, facets: 0, values: undefined, hidden: false }; + }); + this.setState({ labels }, () => { + // Get fresh set of values + this.state.labels.forEach( + (label) => (label.selected || label.name === METRIC_LABEL) && this.fetchValues(label.name, selector) + ); + }); + } else { + // Do facetting + this.fetchSeries(selector, lastFacetted); + } + }; + + async fetchValues(name: string, selector: string) { + const { languageProvider } = this.props; + this.updateLabelState(name, { loading: true }, `Fetching values for ${name}`); + try { + let rawValues = await languageProvider.getLabelValues(name); + // If selector changed, clear loading state and discard result by returning early + if (selector !== buildSelector(this.state.labels)) { + this.updateLabelState(name, { loading: false }); + return; + } + const values: FacettableValue[] = []; + const { metricsMetadata } = languageProvider; + for (const labelValue of rawValues) { + const value: FacettableValue = { name: labelValue }; + // Adding type/help text to metrics + if (name === METRIC_LABEL && metricsMetadata) { + const meta = metricsMetadata[labelValue]; + if (meta) { + value.details = `(${meta.type}) ${meta.help}`; + } + } + values.push(value); + } + this.updateLabelState(name, { values, loading: false }); + } catch (error) { + console.error(error); + } + } + + async fetchSeries(selector: string, lastFacetted?: string) { + const { languageProvider } = this.props; + if (lastFacetted) { + this.updateLabelState(lastFacetted, { loading: true }, `Facetting labels for ${selector}`); + } + try { + const possibleLabels = await languageProvider.fetchSeriesLabels(selector, true); + // If selector changed, clear loading state and discard result by returning early + if (selector !== buildSelector(this.state.labels)) { + if (lastFacetted) { + this.updateLabelState(lastFacetted, { loading: false }); + } + return; + } + if (Object.keys(possibleLabels).length === 0) { + this.setState({ error: `Empty results, no matching label for ${selector}` }); + return; + } + const labels: SelectableLabel[] = facetLabels(this.state.labels, possibleLabels, lastFacetted); + this.setState({ labels, error: '' }); + if (lastFacetted) { + this.updateLabelState(lastFacetted, { loading: false }); + } + } catch (error) { + console.error(error); + } + } + + async validateSelector(selector: string) { + const { languageProvider } = this.props; + this.setState({ validationStatus: `Validating selector ${selector}`, error: '' }); + const streams = await languageProvider.fetchSeries(selector); + this.setState({ validationStatus: `Selector is valid (${streams.length} series found)` }); + } + + render() { + const { theme } = this.props; + const { labels, labelSearchTerm, metricSearchTerm, status, error, validationStatus, valueSearchTerm } = this.state; + const styles = getStyles(theme); + if (labels.length === 0) { + return ( + <div className={styles.wrapper}> + <LoadingPlaceholder text="Loading labels..." /> + </div> + ); + } + + // Filter metrics + let metrics = labels.find((label) => label.name === METRIC_LABEL); + if (metrics && metricSearchTerm) { + metrics = { + ...metrics, + values: metrics.values?.filter((value) => value.selected || value.name.includes(metricSearchTerm)), + }; + } + + // Filter labels + let nonMetricLabels = labels.filter((label) => !label.hidden && label.name !== METRIC_LABEL); + if (labelSearchTerm) { + nonMetricLabels = nonMetricLabels.filter((label) => label.selected || label.name.includes(labelSearchTerm)); + } + + // Filter non-metric label values + let selectedLabels = nonMetricLabels.filter((label) => label.selected && label.values); + if (valueSearchTerm) { + selectedLabels = selectedLabels.map((label) => ({ + ...label, + values: label.values?.filter((value) => value.selected || value.name.includes(valueSearchTerm)), + })); + } + const selector = buildSelector(this.state.labels); + const empty = selector === EMPTY_SELECTOR; + const metricCount = metrics?.values?.length || 0; + + return ( + <div className={styles.wrapper}> + <HorizontalGroup align="flex-start" spacing="lg"> + <div> + <div className={styles.section}> + <Label description="Once a metric is selected only possible labels are shown.">1. Select a metric</Label> + <div> + <Input + onChange={this.onChangeMetricSearch} + aria-label="Filter expression for metric" + value={metricSearchTerm} + data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.selectMetric} + /> + </div> + <div + role="list" + className={styles.valueListWrapper} + data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.metricList} + > + <FixedSizeList + height={Math.min(450, metricCount * LIST_ITEM_SIZE)} + itemCount={metricCount} + itemSize={LIST_ITEM_SIZE} + itemKey={(i) => metrics!.values![i].name} + width={300} + className={styles.valueList} + > + {({ index, style }) => { + const value = metrics?.values?.[index]; + if (!value) { + return null; + } + return ( + <div style={style}> + <PromLabel + name={metrics!.name} + value={value?.name} + title={value.details} + active={value?.selected} + onClick={this.onClickMetric} + searchTerm={metricSearchTerm} + /> + </div> + ); + }} + </FixedSizeList> + </div> + </div> + </div> + + <div> + <div className={styles.section}> + <Label description="Once label values are selected, only possible label combinations are shown."> + 2. Select labels to search in + </Label> + <div> + <Input + onChange={this.onChangeLabelSearch} + aria-label="Filter expression for label" + value={labelSearchTerm} + data-testid={ + selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelNamesFilter + } + /> + </div> + {/* Using fixed height here to prevent jumpy layout */} + <div className={styles.list} style={{ height: 120 }}> + {nonMetricLabels.map((label) => ( + <PromLabel + key={label.name} + name={label.name} + loading={label.loading} + active={label.selected} + hidden={label.hidden} + facets={label.facets} + onClick={this.onClickLabel} + searchTerm={labelSearchTerm} + /> + ))} + </div> + </div> + <div className={styles.section}> + <Label description="Use the search field to find values across selected labels."> + 3. Select (multiple) values for your labels + </Label> + <div> + <Input + onChange={this.onChangeValueSearch} + aria-label="Filter expression for label values" + value={valueSearchTerm} + data-testid={ + selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelValuesFilter + } + /> + </div> + <div className={styles.valueListArea} ref={this.valueListsRef}> + {selectedLabels.map((label) => ( + <div + role="list" + key={label.name} + aria-label={`Values for ${label.name}`} + className={styles.valueListWrapper} + > + <div className={styles.valueTitle}> + <PromLabel + name={label.name} + loading={label.loading} + active={label.selected} + hidden={label.hidden} + //If no facets, we want to show number of all label values + facets={label.facets || label.values?.length} + onClick={this.onClickLabel} + /> + </div> + <FixedSizeList + height={Math.min(200, LIST_ITEM_SIZE * (label.values?.length || 0))} + itemCount={label.values?.length || 0} + itemSize={28} + itemKey={(i) => label.values![i].name} + width={200} + className={styles.valueList} + > + {({ index, style }) => { + const value = label.values?.[index]; + if (!value) { + return null; + } + return ( + <div style={style}> + <PromLabel + name={label.name} + value={value?.name} + active={value?.selected} + onClick={this.onClickValue} + searchTerm={valueSearchTerm} + /> + </div> + ); + }} + </FixedSizeList> + </div> + ))} + </div> + </div> + </div> + </HorizontalGroup> + + <div className={styles.section}> + <Label>4. Resulting selector</Label> + <div aria-label="selector" className={styles.selector}> + {selector} + </div> + {validationStatus && <div className={styles.validationStatus}>{validationStatus}</div>} + <HorizontalGroup> + <Button + data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useQuery} + aria-label="Use selector for query button" + disabled={empty} + onClick={this.onClickRunQuery} + > + Use query + </Button> + <Button + data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useAsRateQuery} + aria-label="Use selector as metrics button" + variant="secondary" + disabled={empty} + onClick={this.onClickRunRateQuery} + > + Use as rate query + </Button> + <Button + data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.validateSelector} + aria-label="Validate submit button" + variant="secondary" + disabled={empty} + onClick={this.onClickValidate} + > + Validate selector + </Button> + <Button + data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.clear} + aria-label="Selector clear button" + variant="secondary" + onClick={this.onClickClear} + > + Clear + </Button> + <div className={cx(styles.status, (status || error) && styles.statusShowing)}> + <span className={error ? styles.error : ''}>{error || status}</span> + </div> + </HorizontalGroup> + </div> + </div> + ); + } +} + +export const PrometheusMetricsBrowser = withTheme2(UnthemedPrometheusMetricsBrowser); diff --git a/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx b/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx new file mode 100644 index 0000000000000..0d3a877069d7b --- /dev/null +++ b/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx @@ -0,0 +1,392 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { dateTime, TimeRange } from '@grafana/data'; + +import { PrometheusDatasource } from '../datasource'; +import { selectOptionInTest } from '../gcopypaste/test/helpers/selectOptionInTest'; +import PrometheusLanguageProvider from '../language_provider'; +import { migrateVariableEditorBackToVariableSupport } from '../migrations/variableMigration'; +import { PromVariableQuery, PromVariableQueryType, StandardPromVariableQuery } from '../types'; + +import { PromVariableQueryEditor, Props, variableMigration } from './VariableQueryEditor'; + +const refId = 'PrometheusVariableQueryEditor-VariableQuery'; + +describe('PromVariableQueryEditor', () => { + let props: Props; + + test('Migrates from standard variable support to custom variable query', () => { + const query: StandardPromVariableQuery = { + query: 'label_names()', + refId: 'StandardVariableQuery', + }; + + const migration: PromVariableQuery = variableMigration(query); + + const expected: PromVariableQuery = { + qryType: PromVariableQueryType.LabelNames, + refId: 'PrometheusDatasource-VariableQuery', + }; + + expect(migration).toEqual(expected); + }); + + test('Allows for use of variables to interpolate label names in the label values query type.', () => { + const query: StandardPromVariableQuery = { + query: 'label_values($label_name)', + refId: 'StandardVariableQuery', + }; + + const migration: PromVariableQuery = variableMigration(query); + + const expected: PromVariableQuery = { + qryType: PromVariableQueryType.LabelValues, + label: '$label_name', + refId: 'PrometheusDatasource-VariableQuery', + }; + + expect(migration).toEqual(expected); + }); + + test('Migrates from jsonnet grafana as code variable to custom variable query', () => { + const query = 'label_names()'; + + const migration: PromVariableQuery = variableMigration(query); + + const expected: PromVariableQuery = { + qryType: PromVariableQueryType.LabelNames, + refId: 'PrometheusDatasource-VariableQuery', + }; + + expect(migration).toEqual(expected); + }); + + test('Migrates label filters to the query object for label_values()', () => { + const query: StandardPromVariableQuery = { + query: 'label_values(metric{label="value"},name)', + refId: 'StandardVariableQuery', + }; + + const migration: PromVariableQuery = variableMigration(query); + + const expected: PromVariableQuery = { + qryType: PromVariableQueryType.LabelValues, + label: 'name', + metric: 'metric', + labelFilters: [ + { + label: 'label', + op: '=', + value: 'value', + }, + ], + refId: 'PrometheusDatasource-VariableQuery', + }; + + expect(migration).toEqual(expected); + }); + + test('Migrates a query object with label filters to an expression correctly', () => { + const query: PromVariableQuery = { + qryType: PromVariableQueryType.LabelValues, + label: 'name', + metric: 'metric', + labelFilters: [ + { + label: 'label', + op: '=', + value: 'value', + }, + ], + refId: 'PrometheusDatasource-VariableQuery', + }; + + const migration: string = migrateVariableEditorBackToVariableSupport(query); + + const expected = 'label_values(metric{label="value"},name)'; + + expect(migration).toEqual(expected); + }); + + test('Migrates a query object with no metric and only label filters to an expression correctly', () => { + const query: PromVariableQuery = { + qryType: PromVariableQueryType.LabelValues, + label: 'name', + labelFilters: [ + { + label: 'label', + op: '=', + value: 'value', + }, + ], + refId: 'PrometheusDatasource-VariableQuery', + }; + + const migration: string = migrateVariableEditorBackToVariableSupport(query); + + const expected = 'label_values({label="value"},name)'; + + expect(migration).toEqual(expected); + }); + + beforeEach(() => { + props = { + datasource: { + hasLabelsMatchAPISupport: () => true, + languageProvider: { + start: () => Promise.resolve([]), + syntax: () => {}, + getLabelKeys: () => [], + metrics: [], + metricsMetadata: {}, + getLabelValues: jest.fn().mockImplementation(() => ['that']), + fetchLabelsWithMatch: jest.fn().mockImplementation(() => Promise.resolve({ those: 'those' })), + } as Partial<PrometheusLanguageProvider> as PrometheusLanguageProvider, + getInitHints: () => [], + getDebounceTimeInMilliseconds: jest.fn(), + getTagKeys: jest.fn().mockImplementation(() => Promise.resolve(['this'])), + getVariables: jest.fn().mockImplementation(() => []), + metricFindQuery: jest.fn().mockImplementation(() => Promise.resolve(['that'])), + getSeriesLabels: jest.fn().mockImplementation(() => Promise.resolve(['that'])), + } as Partial<PrometheusDatasource> as PrometheusDatasource, + query: { + refId: 'test', + query: 'label_names()', + }, + onRunQuery: () => {}, + onChange: () => {}, + history: [], + }; + }); + + test('Displays a group of function options', async () => { + render(<PromVariableQueryEditor {...props} />); + + const select = screen.getByLabelText('Query type').parentElement!; + await userEvent.click(select); + + await waitFor(() => expect(screen.getAllByText('Label names')).toHaveLength(2)); + await waitFor(() => expect(screen.getByText('Label values')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Metrics')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Query result')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Series query')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Classic query')).toBeInTheDocument()); + }); + + test('Calls onChange for label_names(match) query', async () => { + const onChange = jest.fn(); + + props.query = { + refId: 'test', + query: '', + match: 'that', + }; + + render(<PromVariableQueryEditor {...props} onChange={onChange} />); + + await selectOptionInTest(screen.getByLabelText('Query type'), 'Label names'); + + expect(onChange).toHaveBeenCalledWith({ + query: 'label_names(that)', + refId, + qryType: 0, + }); + }); + + test('Calls onChange for label_names, label_values, metrics, query result and and classic query.', async () => { + const onChange = jest.fn(); + + props.query = { + refId: 'test', + query: '', + }; + + render(<PromVariableQueryEditor {...props} onChange={onChange} />); + + await selectOptionInTest(screen.getByLabelText('Query type'), 'Label names'); + await selectOptionInTest(screen.getByLabelText('Query type'), 'Label values'); + await selectOptionInTest(screen.getByLabelText('Query type'), 'Metrics'); + await selectOptionInTest(screen.getByLabelText('Query type'), 'Query result'); + await selectOptionInTest(screen.getByLabelText('Query type'), 'Classic query'); + + expect(onChange).toHaveBeenCalledTimes(5); + }); + + test('Does not call onChange for series query', async () => { + const onChange = jest.fn(); + + render(<PromVariableQueryEditor {...props} onChange={onChange} />); + + await selectOptionInTest(screen.getByLabelText('Query type'), 'Series query'); + + expect(onChange).not.toHaveBeenCalled(); + }); + + test('Calls onChange for metrics() after input', async () => { + const onChange = jest.fn(); + + props.query = { + refId: 'test', + query: 'label_names()', + }; + + render(<PromVariableQueryEditor {...props} onChange={onChange} />); + + await selectOptionInTest(screen.getByLabelText('Query type'), 'Metrics'); + const metricInput = screen.getByLabelText('Metric selector'); + await userEvent.type(metricInput, 'a'); + const queryType = screen.getByLabelText('Query type'); + // click elsewhere to trigger the onBlur + await userEvent.click(queryType); + + await waitFor(() => + expect(onChange).toHaveBeenCalledWith({ + query: 'metrics(a)', + refId, + qryType: 2, + }) + ); + }); + + test('Calls onChange for label_values() after selecting label', async () => { + const onChange = jest.fn(); + + props.query = { + refId: 'test', + query: 'label_names()', + qryType: 0, + }; + + render(<PromVariableQueryEditor {...props} onChange={onChange} />); + + await selectOptionInTest(screen.getByLabelText('Query type'), 'Label values'); + const labelSelect = screen.getByLabelText('label-select'); + await userEvent.type(labelSelect, 'this'); + await selectOptionInTest(labelSelect, 'this'); + + await waitFor(() => + expect(onChange).toHaveBeenCalledWith({ + query: 'label_values(this)', + refId, + qryType: 1, + }) + ); + }); + + test('Calls onChange for label_values() after selecting metric', async () => { + const onChange = jest.fn(); + + props.query = { + refId: 'test', + query: 'label_names()', + }; + + render(<PromVariableQueryEditor {...props} onChange={onChange} />); + + await selectOptionInTest(screen.getByLabelText('Query type'), 'Label values'); + const labelSelect = screen.getByLabelText('label-select'); + await userEvent.type(labelSelect, 'this'); + await selectOptionInTest(labelSelect, 'this'); + + const metricSelect = screen.getByLabelText('Metric'); + await userEvent.type(metricSelect, 'that'); + await selectOptionInTest(metricSelect, 'that'); + + await waitFor(() => + expect(onChange).toHaveBeenCalledWith({ + query: 'label_values(that,this)', + refId, + qryType: 1, + }) + ); + }); + + test('Calls onChange for query_result() with argument onBlur', async () => { + const onChange = jest.fn(); + + props.query = { + refId: 'test', + query: 'query_result(a)', + }; + + render(<PromVariableQueryEditor {...props} onChange={onChange} />); + + const labelSelect = screen.getByLabelText('Prometheus Query'); + await userEvent.click(labelSelect); + const functionSelect = screen.getByLabelText('Query type').parentElement!; + await userEvent.click(functionSelect); + + expect(onChange).toHaveBeenCalledWith({ + query: 'query_result(a)', + refId, + qryType: 3, + }); + }); + + test('Calls onChange for Match[] series with argument onBlur', async () => { + const onChange = jest.fn(); + + props.query = { + refId: 'test', + query: '{a: "example"}', + }; + + render(<PromVariableQueryEditor {...props} onChange={onChange} />); + + const labelSelect = screen.getByLabelText('Series Query'); + await userEvent.click(labelSelect); + const functionSelect = screen.getByLabelText('Query type').parentElement!; + await userEvent.click(functionSelect); + + expect(onChange).toHaveBeenCalledWith({ + query: '{a: "example"}', + refId, + qryType: 4, + }); + }); + + test('Calls onChange for classic query onBlur', async () => { + const onChange = jest.fn(); + + props.query = { + refId: 'test', + qryType: 5, + query: 'label_values(instance)', + }; + + render(<PromVariableQueryEditor {...props} onChange={onChange} />); + + const labelSelect = screen.getByLabelText('Classic Query'); + await userEvent.click(labelSelect); + const functionSelect = screen.getByLabelText('Query type').parentElement!; + await userEvent.click(functionSelect); + + expect(onChange).toHaveBeenCalledWith({ + query: 'label_values(instance)', + refId, + qryType: 5, + }); + }); + + test('Calls language provider with the time range received in props', async () => { + const now = dateTime('2023-09-16T21:26:00Z'); + const range: TimeRange = { + from: dateTime(now).subtract(2, 'days'), + to: now, + raw: { + from: 'now-2d', + to: 'now', + }, + }; + props.range = range; + + const languageProviderStartMock = jest.fn(); + props.datasource.languageProvider.start = languageProviderStartMock; + + render(<PromVariableQueryEditor {...props} />); + + expect(languageProviderStartMock).toHaveBeenCalledWith(range); + }); +}); diff --git a/packages/grafana-prometheus/src/components/VariableQueryEditor.tsx b/packages/grafana-prometheus/src/components/VariableQueryEditor.tsx new file mode 100644 index 0000000000000..fce9bf1e1dfe4 --- /dev/null +++ b/packages/grafana-prometheus/src/components/VariableQueryEditor.tsx @@ -0,0 +1,437 @@ +import React, { FormEvent, useCallback, useEffect, useState } from 'react'; + +import { QueryEditorProps, SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { InlineField, InlineFieldRow, Input, Select, TextArea } from '@grafana/ui'; + +import { PrometheusDatasource } from '../datasource'; +import { + migrateVariableEditorBackToVariableSupport, + migrateVariableQueryToEditor, +} from '../migrations/variableMigration'; +import { promQueryModeller } from '../querybuilder/PromQueryModeller'; +import { MetricsLabelsSection } from '../querybuilder/components/MetricsLabelsSection'; +import { QueryBuilderLabelFilter } from '../querybuilder/shared/types'; +import { PromVisualQuery } from '../querybuilder/types'; +import { + PromOptions, + PromQuery, + PromVariableQuery, + PromVariableQueryType as QueryType, + StandardPromVariableQuery, +} from '../types'; + +export const variableOptions = [ + { label: 'Label names', value: QueryType.LabelNames }, + { label: 'Label values', value: QueryType.LabelValues }, + { label: 'Metrics', value: QueryType.MetricNames }, + { label: 'Query result', value: QueryType.VarQueryResult }, + { label: 'Series query', value: QueryType.SeriesQuery }, + { label: 'Classic query', value: QueryType.ClassicQuery }, +]; + +export type Props = QueryEditorProps<PrometheusDatasource, PromQuery, PromOptions, PromVariableQuery>; + +const refId = 'PrometheusVariableQueryEditor-VariableQuery'; + +export const PromVariableQueryEditor = ({ onChange, query, datasource, range }: Props) => { + // to select the query type, i.e. label_names, label_values, etc. + const [qryType, setQryType] = useState<number | undefined>(undefined); + // list of variables for each function + const [label, setLabel] = useState(''); + + const [labelNamesMatch, setLabelNamesMatch] = useState(''); + + // metric is used for both label_values() and metric() + // label_values() metric requires a whole/complete metric + // metric() is expected to be a part of a metric string + const [metric, setMetric] = useState(''); + // varQuery is a whole query, can include math/rates/etc + const [varQuery, setVarQuery] = useState(''); + // seriesQuery is only a whole + const [seriesQuery, setSeriesQuery] = useState(''); + + // the original variable query implementation, e.g. label_value(metric, label_name) + const [classicQuery, setClassicQuery] = useState(''); + + // list of label names for label_values(), /api/v1/labels, contains the same results as label_names() function + const [labelOptions, setLabelOptions] = useState<Array<SelectableValue<string>>>([]); + + // label filters have been added as a filter for metrics in label values query type + const [labelFilters, setLabelFilters] = useState<QueryBuilderLabelFilter[]>([]); + + useEffect(() => { + datasource.languageProvider.start(range); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!query) { + return; + } + + if (query.qryType === QueryType.ClassicQuery) { + setQryType(query.qryType); + setClassicQuery(query.query ?? ''); + } else { + // 1. Changing from standard to custom variable editor changes the string attr from expr to query + // 2. jsonnet grafana as code passes a variable as a string + const variableQuery = variableMigration(query); + + setLabelNamesMatch(variableQuery.match ?? ''); + setQryType(variableQuery.qryType); + setLabel(variableQuery.label ?? ''); + setMetric(variableQuery.metric ?? ''); + setLabelFilters(variableQuery.labelFilters ?? []); + setVarQuery(variableQuery.varQuery ?? ''); + setSeriesQuery(variableQuery.seriesQuery ?? ''); + setClassicQuery(variableQuery.classicQuery ?? ''); + } + }, [query]); + + // set the label names options for the label values var query + useEffect(() => { + if (qryType !== QueryType.LabelValues) { + return; + } + const variables = datasource.getVariables().map((variable: string) => ({ label: variable, value: variable })); + if (!metric) { + // get all the labels + datasource.getTagKeys({ filters: [] }).then((labelNames: Array<{ text: string }>) => { + const names = labelNames.map(({ text }) => ({ label: text, value: text })); + setLabelOptions([...variables, ...names]); + }); + } else { + // fetch the labels filtered by the metric + const labelToConsider = [{ label: '__name__', op: '=', value: metric }]; + const expr = promQueryModeller.renderLabels(labelToConsider); + + datasource.languageProvider.fetchLabelsWithMatch(expr).then((labelsIndex: Record<string, string[]>) => { + const labelNames = Object.keys(labelsIndex); + const names = labelNames.map((value) => ({ label: value, value: value })); + setLabelOptions([...variables, ...names]); + }); + } + }, [datasource, qryType, metric]); + + const onChangeWithVariableString = ( + updateVar: { [key: string]: QueryType | string }, + updLabelFilters?: QueryBuilderLabelFilter[] + ) => { + const queryVar = { + qryType, + label, + metric, + match: labelNamesMatch, + varQuery, + seriesQuery, + classicQuery, + refId: 'PrometheusVariableQueryEditor-VariableQuery', + }; + + let updateLabelFilters = updLabelFilters ? { labelFilters: updLabelFilters } : { labelFilters: labelFilters }; + + const updatedVar = { ...queryVar, ...updateVar, ...updateLabelFilters }; + + const queryString = migrateVariableEditorBackToVariableSupport(updatedVar); + + // setting query.query property allows for update of variable definition + onChange({ + query: queryString, + qryType: updatedVar.qryType, + refId, + }); + }; + + /** Call onchange for label names query type change */ + const onQueryTypeChange = (newType: SelectableValue<QueryType>) => { + setQryType(newType.value); + if (newType.value !== QueryType.SeriesQuery) { + onChangeWithVariableString({ qryType: newType.value ?? 0 }); + } + }; + + /** Call onchange for label select when query type is label values */ + const onLabelChange = (newLabel: SelectableValue<string>) => { + const newLabelvalue = newLabel && newLabel.value ? newLabel.value : ''; + setLabel(newLabelvalue); + if (qryType === QueryType.LabelValues && newLabelvalue) { + onChangeWithVariableString({ label: newLabelvalue }); + } + }; + + /** + * Call onChange for MetricsLabels component change for label values query type + * if there is a label (required) and + * if the labels or metric are updated. + */ + const metricsLabelsChange = (update: PromVisualQuery) => { + setMetric(update.metric); + setLabelFilters(update.labels); + + const updMetric = update.metric; + const updLabelFilters = update.labels ?? []; + + if (qryType === QueryType.LabelValues && label && (updMetric || updLabelFilters)) { + onChangeWithVariableString({ qryType, metric: updMetric }, updLabelFilters); + } + }; + + const onLabelNamesMatchChange = (regex: string) => { + if (qryType === QueryType.LabelNames) { + onChangeWithVariableString({ qryType, match: regex }); + } + }; + + /** + * Call onchange for metric change if metrics names (regex) query type + * Debounce this because to not call the API for every keystroke. + */ + const onMetricChange = (value: string) => { + if (qryType === QueryType.MetricNames && value) { + onChangeWithVariableString({ metric: value }); + } + }; + + /** + * Do not call onchange for variable query result when query type is var query result + * because the query may not be finished typing and an error is returned + * for incorrectly formatted series. Call onchange for blur instead. + */ + const onVarQueryChange = (e: FormEvent<HTMLTextAreaElement>) => { + setVarQuery(e.currentTarget.value); + }; + + /** + * Do not call onchange for seriesQuery when query type is series query + * because the series may not be finished typing and an error is returned + * for incorrectly formatted series. Call onchange for blur instead. + */ + const onSeriesQueryChange = (e: FormEvent<HTMLInputElement>) => { + setSeriesQuery(e.currentTarget.value); + }; + + const onClassicQueryChange = (e: FormEvent<HTMLInputElement>) => { + setClassicQuery(e.currentTarget.value); + }; + + const promVisualQuery = useCallback(() => { + return { metric: metric, labels: labelFilters, operations: [] }; + }, [metric, labelFilters]); + + return ( + <> + <InlineFieldRow> + <InlineField + label="Query type" + labelWidth={20} + tooltip={ + <div>The Prometheus data source plugin provides the following query types for template variables.</div> + } + > + <Select + placeholder="Select query type" + aria-label="Query type" + onChange={onQueryTypeChange} + value={qryType} + options={variableOptions} + width={25} + data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.queryType} + /> + </InlineField> + </InlineFieldRow> + + {qryType === QueryType.LabelValues && ( + <> + <InlineFieldRow> + <InlineField + label="Label" + labelWidth={20} + required + aria-labelledby="label-select" + tooltip={ + <div> + Returns a list of label values for the label name in all metrics unless the metric is specified. + </div> + } + > + <Select + aria-label="label-select" + onChange={onLabelChange} + value={label} + options={labelOptions} + width={25} + allowCustomValue + isClearable={true} + data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.labelValues.labelSelect} + /> + </InlineField> + </InlineFieldRow> + {/* Used to select an optional metric with optional label filters */} + <MetricsLabelsSection + query={promVisualQuery()} + datasource={datasource} + onChange={metricsLabelsChange} + variableEditor={true} + /> + </> + )} + + {qryType === QueryType.LabelNames && ( + <InlineFieldRow> + <InlineField + label="Metric regex" + labelWidth={20} + aria-labelledby="Metric regex" + tooltip={<div>Returns a list of label names, optionally filtering by specified metric regex.</div>} + > + <Input + type="text" + aria-label="Metric regex" + placeholder="Metric regex" + value={labelNamesMatch} + onBlur={(event) => { + setLabelNamesMatch(event.currentTarget.value); + onLabelNamesMatchChange(event.currentTarget.value); + }} + onChange={(e) => { + setLabelNamesMatch(e.currentTarget.value); + }} + width={25} + data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.labelnames.metricRegex} + /> + </InlineField> + </InlineFieldRow> + )} + + {qryType === QueryType.MetricNames && ( + <InlineFieldRow> + <InlineField + label="Metric regex" + labelWidth={20} + aria-labelledby="Metric selector" + tooltip={<div>Returns a list of metrics matching the specified metric regex.</div>} + > + <Input + type="text" + aria-label="Metric selector" + placeholder="Metric regex" + value={metric} + onChange={(e) => { + setMetric(e.currentTarget.value); + }} + onBlur={(e) => { + setMetric(e.currentTarget.value); + onMetricChange(e.currentTarget.value); + }} + width={25} + data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.metricNames.metricRegex} + /> + </InlineField> + </InlineFieldRow> + )} + + {qryType === QueryType.VarQueryResult && ( + <InlineFieldRow> + <InlineField + label="Query" + labelWidth={20} + tooltip={ + <div> + Returns a list of Prometheus query results for the query. This can include Prometheus functions, i.e. + sum(go_goroutines). + </div> + } + > + <TextArea + type="text" + aria-label="Prometheus Query" + placeholder="Prometheus Query" + value={varQuery} + onChange={onVarQueryChange} + onBlur={() => { + if (qryType === QueryType.VarQueryResult && varQuery) { + onChangeWithVariableString({ qryType }); + } + }} + cols={100} + data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.varQueryResult} + /> + </InlineField> + </InlineFieldRow> + )} + + {qryType === QueryType.SeriesQuery && ( + <InlineFieldRow> + <InlineField + label="Series Query" + labelWidth={20} + tooltip={ + <div> + Enter enter a metric with labels, only a metric or only labels, i.e. + go_goroutines{instance="localhost:9090"}, go_goroutines, or + {instance="localhost:9090"}. Returns a list of time series associated with the + entered data. + </div> + } + > + <Input + type="text" + aria-label="Series Query" + placeholder="Series Query" + value={seriesQuery} + onChange={onSeriesQueryChange} + onBlur={() => { + if (qryType === QueryType.SeriesQuery && seriesQuery) { + onChangeWithVariableString({ qryType }); + } + }} + width={100} + data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.seriesQuery} + /> + </InlineField> + </InlineFieldRow> + )} + + {qryType === QueryType.ClassicQuery && ( + <InlineFieldRow> + <InlineField + label="Classic Query" + labelWidth={20} + tooltip={ + <div> + The original implemetation of the Prometheus variable query editor. Enter a string with the correct + query type and parameters as described in these docs. For example, label_values(label, metric). + </div> + } + > + <Input + type="text" + aria-label="Classic Query" + placeholder="Classic Query" + value={classicQuery} + onChange={onClassicQueryChange} + onBlur={() => { + if (qryType === QueryType.ClassicQuery && classicQuery) { + onChangeWithVariableString({ qryType }); + } + }} + width={100} + data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.classicQuery} + /> + </InlineField> + </InlineFieldRow> + )} + </> + ); +}; + +export function variableMigration(query: string | PromVariableQuery | StandardPromVariableQuery): PromVariableQuery { + if (typeof query === 'string') { + return migrateVariableQueryToEditor(query); + } else if (query.query) { + return migrateVariableQueryToEditor(query.query); + } else { + return query; + } +} diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx new file mode 100644 index 0000000000000..2404760aad8a6 --- /dev/null +++ b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx @@ -0,0 +1,324 @@ +import { css } from '@emotion/css'; +import { parser } from '@prometheus-io/lezer-promql'; +import { debounce } from 'lodash'; +import { promLanguageDefinition } from 'monaco-promql'; +import React, { useEffect, useRef } from 'react'; +import { useLatest } from 'react-use'; +import { v4 as uuidv4 } from 'uuid'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { Monaco, monacoTypes, ReactMonacoEditor, useTheme2 } from '@grafana/ui'; + +import { Props } from './MonacoQueryFieldProps'; +import { getOverrideServices } from './getOverrideServices'; +import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider'; +import { placeHolderScopedVars, validateQuery } from './monaco-completion-provider/validation'; +import { language, languageConfiguration } from './promql'; + +const options: monacoTypes.editor.IStandaloneEditorConstructionOptions = { + codeLens: false, + contextmenu: false, + // we need `fixedOverflowWidgets` because otherwise in grafana-dashboards + // the popup is clipped by the panel-visualizations. + fixedOverflowWidgets: true, + folding: false, + fontSize: 14, + lineDecorationsWidth: 8, // used as "padding-left" + lineNumbers: 'off', + minimap: { enabled: false }, + overviewRulerBorder: false, + overviewRulerLanes: 0, + padding: { + // these numbers were picked so that visually this matches the previous version + // of the query-editor the best + top: 4, + bottom: 5, + }, + renderLineHighlight: 'none', + scrollbar: { + vertical: 'hidden', + verticalScrollbarSize: 8, // used as "padding-right" + horizontal: 'hidden', + horizontalScrollbarSize: 0, + alwaysConsumeMouseWheel: false, + }, + scrollBeyondLastLine: false, + suggest: getSuggestOptions(), + suggestFontSize: 12, + wordWrap: 'on', +}; + +// this number was chosen by testing various values. it might be necessary +// because of the width of the border, not sure. +//it needs to do 2 things: +// 1. when the editor is single-line, it should make the editor height be visually correct +// 2. when the editor is multi-line, the editor should not be "scrollable" (meaning, +// you do a scroll-movement in the editor, and it will scroll the content by a couple pixels +// up & down. this we want to avoid) +const EDITOR_HEIGHT_OFFSET = 2; + +const PROMQL_LANG_ID = promLanguageDefinition.id; + +// we must only run the promql-setup code once +let PROMQL_SETUP_STARTED = false; + +function ensurePromQL(monaco: Monaco) { + if (PROMQL_SETUP_STARTED === false) { + PROMQL_SETUP_STARTED = true; + const { aliases, extensions, mimetypes } = promLanguageDefinition; + monaco.languages.register({ id: PROMQL_LANG_ID, aliases, extensions, mimetypes }); + + // @ts-ignore + monaco.languages.setMonarchTokensProvider(PROMQL_LANG_ID, language); + // @ts-ignore + monaco.languages.setLanguageConfiguration(PROMQL_LANG_ID, languageConfiguration); + } +} + +const getStyles = (theme: GrafanaTheme2, placeholder: string) => { + return { + container: css` + border-radius: ${theme.shape.radius.default}; + border: 1px solid ${theme.components.input.borderColor}; + `, + placeholder: css` + ::after { + content: '${placeholder}'; + font-family: ${theme.typography.fontFamilyMonospace}; + opacity: 0.6; + } + `, + }; +}; + +const MonacoQueryField = (props: Props) => { + const id = uuidv4(); + + // we need only one instance of `overrideServices` during the lifetime of the react component + const overrideServicesRef = useRef(getOverrideServices()); + const containerRef = useRef<HTMLDivElement>(null); + const { languageProvider, history, onBlur, onRunQuery, initialValue, placeholder, onChange, datasource } = props; + + const lpRef = useLatest(languageProvider); + const historyRef = useLatest(history); + const onRunQueryRef = useLatest(onRunQuery); + const onBlurRef = useLatest(onBlur); + const onChangeRef = useLatest(onChange); + + const autocompleteDisposeFun = useRef<(() => void) | null>(null); + + const theme = useTheme2(); + const styles = getStyles(theme, placeholder); + + useEffect(() => { + // when we unmount, we unregister the autocomplete-function, if it was registered + return () => { + autocompleteDisposeFun.current?.(); + }; + }, []); + + return ( + <div + data-testid={selectors.components.QueryField.container} + className={styles.container} + // NOTE: we will be setting inline-style-width/height on this element + ref={containerRef} + > + <ReactMonacoEditor + overrideServices={overrideServicesRef.current} + options={options} + language="promql" + value={initialValue} + beforeMount={(monaco) => { + ensurePromQL(monaco); + }} + onMount={(editor, monaco) => { + const isEditorFocused = editor.createContextKey<boolean>('isEditorFocused' + id, false); + // we setup on-blur + editor.onDidBlurEditorWidget(() => { + isEditorFocused.set(false); + onBlurRef.current(editor.getValue()); + }); + editor.onDidFocusEditorText(() => { + isEditorFocused.set(true); + }); + + // we construct a DataProvider object + const getHistory = () => + Promise.resolve(historyRef.current.map((h) => h.query.expr).filter((expr) => expr !== undefined)); + + const getAllMetricNames = () => { + const { metrics, metricsMetadata } = lpRef.current; + const result = metrics.map((m) => { + const metaItem = metricsMetadata?.[m]; + return { + name: m, + help: metaItem?.help ?? '', + type: metaItem?.type ?? '', + }; + }); + + return Promise.resolve(result); + }; + + const getAllLabelNames = () => Promise.resolve(lpRef.current.getLabelKeys()); + + const getLabelValues = (labelName: string) => lpRef.current.getLabelValues(labelName); + + const getSeriesValues = lpRef.current.getSeriesValues; + + const getSeriesLabels = lpRef.current.getSeriesLabels; + + const dataProvider = { + getHistory, + getAllMetricNames, + getAllLabelNames, + getLabelValues, + getSeriesValues, + getSeriesLabels, + }; + const completionProvider = getCompletionProvider(monaco, dataProvider); + + // completion-providers in monaco are not registered directly to editor-instances, + // they are registered to languages. this makes it hard for us to have + // separate completion-providers for every query-field-instance + // (but we need that, because they might connect to different datasources). + // the trick we do is, we wrap the callback in a "proxy", + // and in the proxy, the first thing is, we check if we are called from + // "our editor instance", and if not, we just return nothing. if yes, + // we call the completion-provider. + const filteringCompletionProvider: monacoTypes.languages.CompletionItemProvider = { + ...completionProvider, + provideCompletionItems: (model, position, context, token) => { + // if the model-id does not match, then this call is from a different editor-instance, + // not "our instance", so return nothing + if (editor.getModel()?.id !== model.id) { + return { suggestions: [] }; + } + return completionProvider.provideCompletionItems(model, position, context, token); + }, + }; + + const { dispose } = monaco.languages.registerCompletionItemProvider( + PROMQL_LANG_ID, + filteringCompletionProvider + ); + + autocompleteDisposeFun.current = dispose; + // this code makes the editor resize itself so that the content fits + // (it will grow taller when necessary) + // FIXME: maybe move this functionality into CodeEditor, like: + // <CodeEditor resizingMode="single-line"/> + const updateElementHeight = () => { + const containerDiv = containerRef.current; + if (containerDiv !== null) { + const pixelHeight = editor.getContentHeight(); + containerDiv.style.height = `${pixelHeight + EDITOR_HEIGHT_OFFSET}px`; + containerDiv.style.width = '100%'; + const pixelWidth = containerDiv.clientWidth; + editor.layout({ width: pixelWidth, height: pixelHeight }); + } + }; + + editor.onDidContentSizeChange(updateElementHeight); + updateElementHeight(); + + // Whenever the editor changes, lets save the last value so the next query for this editor will be up-to-date. + // This change is being introduced to fix a bug where you can submit a query via shift+enter: + // If you clicked into another field and haven't un-blurred the active field, + // then the query that is run will be stale, as the reference is only updated + // with the value of the last blurred input. + // This can run quite slowly, so we're debouncing this which should accomplish two things + // 1. Should prevent this function from blocking the current call stack by pushing into the web API callback queue + // 2. Should prevent a bunch of duplicates of this function being called as the user is typing + const updateCurrentEditorValue = debounce(() => { + const editorValue = editor.getValue(); + onChangeRef.current(editorValue); + }, lpRef.current.datasource.getDebounceTimeInMilliseconds()); + + editor.getModel()?.onDidChangeContent(() => { + updateCurrentEditorValue(); + }); + + // handle: shift + enter + // FIXME: maybe move this functionality into CodeEditor? + editor.addCommand( + monaco.KeyMod.Shift | monaco.KeyCode.Enter, + () => { + onRunQueryRef.current(editor.getValue()); + }, + 'isEditorFocused' + id + ); + + /* Something in this configuration of monaco doesn't bubble up [mod]+K, which the + command palette uses. Pass the event out of monaco manually + */ + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, function () { + global.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })); + }); + + if (placeholder) { + const placeholderDecorators = [ + { + range: new monaco.Range(1, 1, 1, 1), + options: { + className: styles.placeholder, + isWholeLine: true, + }, + }, + ]; + + let decorators: string[] = []; + + const checkDecorators: () => void = () => { + const model = editor.getModel(); + + if (!model) { + return; + } + + const newDecorators = model.getValueLength() === 0 ? placeholderDecorators : []; + decorators = model.deltaDecorations(decorators, newDecorators); + }; + + checkDecorators(); + editor.onDidChangeModelContent(checkDecorators); + + editor.onDidChangeModelContent((e) => { + const model = editor.getModel(); + if (!model) { + return; + } + const query = model.getValue(); + const errors = + validateQuery( + query, + datasource.interpolateString(query, placeHolderScopedVars), + model.getLinesContent(), + parser + ) || []; + + const markers = errors.map(({ error, ...boundary }) => ({ + message: `${ + error ? `Error parsing "${error}"` : 'Parse error' + }. The query appears to be incorrect and could fail to be executed.`, + severity: monaco.MarkerSeverity.Error, + ...boundary, + })); + + monaco.editor.setModelMarkers(model, 'owner', markers); + }); + } + }} + /> + </div> + ); +}; + +// we will lazy-load this module using React.lazy, +// and that only supports default-exports, +// so we have to default-export this, even if +// it is against the style-guidelines. + +export default MonacoQueryField; diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryFieldLazy.tsx b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryFieldLazy.tsx new file mode 100644 index 0000000000000..797d48b6c2b5d --- /dev/null +++ b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryFieldLazy.tsx @@ -0,0 +1,14 @@ +import React, { Suspense } from 'react'; + +import MonacoQueryField from './MonacoQueryField'; +import { Props } from './MonacoQueryFieldProps'; + +// const Field = React.lazy(() => import('./MonacoQueryField')); + +export const MonacoQueryFieldLazy = (props: Props) => { + return ( + <Suspense fallback={null}> + <MonacoQueryField {...props} /> + </Suspense> + ); +}; diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryFieldProps.ts b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryFieldProps.ts new file mode 100644 index 0000000000000..1383b565828cf --- /dev/null +++ b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryFieldProps.ts @@ -0,0 +1,21 @@ +import { HistoryItem } from '@grafana/data'; + +import { PrometheusDatasource } from '../../datasource'; +import type PromQlLanguageProvider from '../../language_provider'; +import { PromQuery } from '../../types'; + +// we need to store this in a separate file, +// because we have an async-wrapper around, +// the react-component, and it needs the same +// props as the sync-component. +export type Props = { + initialValue: string; + languageProvider: PromQlLanguageProvider; + history: Array<HistoryItem<PromQuery>>; + placeholder: string; + onRunQuery: (value: string) => void; + onBlur: (value: string) => void; + // onChange will never initiate a query, it just denotes that a query value has been changed + onChange: (value: string) => void; + datasource: PrometheusDatasource; +}; diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryFieldWrapper.tsx b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryFieldWrapper.tsx new file mode 100644 index 0000000000000..7f69ce5475891 --- /dev/null +++ b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryFieldWrapper.tsx @@ -0,0 +1,34 @@ +import React, { useRef } from 'react'; + +import { MonacoQueryFieldLazy } from './MonacoQueryFieldLazy'; +import { Props as MonacoProps } from './MonacoQueryFieldProps'; + +type Props = Omit<MonacoProps, 'onRunQuery' | 'onBlur'> & { + onChange: (query: string) => void; + onRunQuery: () => void; +}; + +export const MonacoQueryFieldWrapper = (props: Props) => { + const lastRunValueRef = useRef<string | null>(null); + const { onRunQuery, onChange, ...rest } = props; + + const handleRunQuery = (value: string) => { + lastRunValueRef.current = value; + onChange(value); + onRunQuery(); + }; + + const handleBlur = (value: string) => { + onChange(value); + }; + + /** + * Handles changes without running any queries + * @param value + */ + const handleChange = (value: string) => { + onChange(value); + }; + + return <MonacoQueryFieldLazy onChange={handleChange} onRunQuery={handleRunQuery} onBlur={handleBlur} {...rest} />; +}; diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/getOverrideServices.ts b/packages/grafana-prometheus/src/components/monaco-query-field/getOverrideServices.ts new file mode 100644 index 0000000000000..b3bc45a3f579f --- /dev/null +++ b/packages/grafana-prometheus/src/components/monaco-query-field/getOverrideServices.ts @@ -0,0 +1,116 @@ +import { monacoTypes } from '@grafana/ui'; + +// this thing here is a workaround in a way. +// what we want to achieve, is that when the autocomplete-window +// opens, the "second, extra popup" with the extra help, +// also opens automatically. +// but there is no API to achieve it. +// the way to do it is to implement the `storageService` +// interface, and provide our custom implementation, +// which will default to `true` for the correct string-key. +// unfortunately, while the typescript-interface exists, +// it is not exported from monaco-editor, +// so we cannot rely on typescript to make sure +// we do it right. all we can do is to manually +// lookup the interface, and make sure we code our code right. +// our code is a "best effort" approach, +// i am not 100% how the `scope` and `target` things work, +// but so far it seems to work ok. +// i would use an another approach, if there was one available. + +function makeStorageService() { + // we need to return an object that fulfills this interface: + // https://github.com/microsoft/vscode/blob/ff1e16eebb93af79fd6d7af1356c4003a120c563/src/vs/platform/storage/common/storage.ts#L37 + // unfortunately it is not export from monaco-editor + + const strings = new Map<string, string>(); + + // we want this to be true by default + strings.set('expandSuggestionDocs', true.toString()); + + return { + // we do not implement the on* handlers + onDidChangeValue: (data: unknown): void => undefined, + onDidChangeTarget: (data: unknown): void => undefined, + onWillSaveState: (data: unknown): void => undefined, + + get: (key: string, scope: unknown, fallbackValue?: string): string | undefined => { + return strings.get(key) ?? fallbackValue; + }, + + getBoolean: (key: string, scope: unknown, fallbackValue?: boolean): boolean | undefined => { + const val = strings.get(key); + if (val !== undefined) { + // the interface-docs say the value will be converted + // to a boolean but do not specify how, so we improvise + return val === 'true'; + } else { + return fallbackValue; + } + }, + + getNumber: (key: string, scope: unknown, fallbackValue?: number): number | undefined => { + const val = strings.get(key); + if (val !== undefined) { + return parseInt(val, 10); + } else { + return fallbackValue; + } + }, + + store: ( + key: string, + value: string | boolean | number | undefined | null, + scope: unknown, + target: unknown + ): void => { + // the interface-docs say if the value is nullish, it should act as delete + if (value === null || value === undefined) { + strings.delete(key); + } else { + strings.set(key, value.toString()); + } + }, + + remove: (key: string, scope: unknown): void => { + strings.delete(key); + }, + + keys: (scope: unknown, target: unknown): string[] => { + return Array.from(strings.keys()); + }, + + logStorage: (): void => { + console.log('logStorage: not implemented'); + }, + + migrate: (): Promise<void> => { + // we do not implement this + return Promise.resolve(undefined); + }, + + isNew: (scope: unknown): boolean => { + // we create a new storage for every session, we do not persist it, + // so we return `true`. + return true; + }, + + flush: (reason?: unknown): Promise<void> => { + // we do not implement this + return Promise.resolve(undefined); + }, + }; +} + +let overrideServices: monacoTypes.editor.IEditorOverrideServices | null = null; + +export function getOverrideServices(): monacoTypes.editor.IEditorOverrideServices { + // only have one instance of this for every query editor + if (overrideServices === null) { + overrideServices = { + storageService: makeStorageService(), + }; + } + + return overrideServices; +} diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts new file mode 100644 index 0000000000000..fd9352bf28a5a --- /dev/null +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts @@ -0,0 +1,208 @@ +import { escapeLabelValueInExactSelector } from '../../../language_utils'; +import { FUNCTIONS } from '../../../promql'; + +import type { Label, Situation } from './situation'; +import { NeverCaseError } from './util'; +// FIXME: we should not load this from the "outside", but we cannot do that while we have the "old" query-field too + +export type CompletionType = 'HISTORY' | 'FUNCTION' | 'METRIC_NAME' | 'DURATION' | 'LABEL_NAME' | 'LABEL_VALUE'; + +type Completion = { + type: CompletionType; + label: string; + insertText: string; + detail?: string; + documentation?: string; + triggerOnInsert?: boolean; +}; + +type Metric = { + name: string; + help: string; + type: string; +}; + +export type DataProvider = { + getHistory: () => Promise<string[]>; + getAllMetricNames: () => Promise<Metric[]>; + getAllLabelNames: () => Promise<string[]>; + getLabelValues: (labelName: string) => Promise<string[]>; + getSeriesValues: (name: string, match: string) => Promise<string[]>; + getSeriesLabels: (selector: string, otherLabels: Label[]) => Promise<string[]>; +}; + +// we order items like: history, functions, metrics + +async function getAllMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> { + const metrics = await dataProvider.getAllMetricNames(); + return metrics.map((metric) => ({ + type: 'METRIC_NAME', + label: metric.name, + insertText: metric.name, + detail: `${metric.name} : ${metric.type}`, + documentation: metric.help, + })); +} + +const FUNCTION_COMPLETIONS: Completion[] = FUNCTIONS.map((f) => ({ + type: 'FUNCTION', + label: f.label, + insertText: f.insertText ?? '', // i don't know what to do when this is nullish. it should not be. + detail: f.detail, + documentation: f.documentation, +})); + +async function getAllFunctionsAndMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> { + const metricNames = await getAllMetricNamesCompletions(dataProvider); + return [...FUNCTION_COMPLETIONS, ...metricNames]; +} + +const DURATION_COMPLETIONS: Completion[] = [ + '$__interval', + '$__range', + '$__rate_interval', + '1m', + '5m', + '10m', + '30m', + '1h', + '1d', +].map((text) => ({ + type: 'DURATION', + label: text, + insertText: text, +})); + +async function getAllHistoryCompletions(dataProvider: DataProvider): Promise<Completion[]> { + // function getAllHistoryCompletions(queryHistory: PromHistoryItem[]): Completion[] { + // NOTE: the typescript types are wrong. historyItem.query.expr can be undefined + const allHistory = await dataProvider.getHistory(); + // FIXME: find a better history-limit + return allHistory.slice(0, 10).map((expr) => ({ + type: 'HISTORY', + label: expr, + insertText: expr, + })); +} + +function makeSelector(metricName: string | undefined, labels: Label[]): string { + const allLabels = [...labels]; + + // we transform the metricName to a label, if it exists + if (metricName !== undefined) { + allLabels.push({ name: '__name__', value: metricName, op: '=' }); + } + + const allLabelTexts = allLabels.map( + (label) => `${label.name}${label.op}"${escapeLabelValueInExactSelector(label.value)}"` + ); + + return `{${allLabelTexts.join(',')}}`; +} + +async function getLabelNames( + metric: string | undefined, + otherLabels: Label[], + dataProvider: DataProvider +): Promise<string[]> { + if (metric === undefined && otherLabels.length === 0) { + // if there is no filtering, we have to use a special endpoint + return dataProvider.getAllLabelNames(); + } else { + const selector = makeSelector(metric, otherLabels); + return await dataProvider.getSeriesLabels(selector, otherLabels); + } +} + +async function getLabelNamesForCompletions( + metric: string | undefined, + suffix: string, + triggerOnInsert: boolean, + otherLabels: Label[], + dataProvider: DataProvider +): Promise<Completion[]> { + const labelNames = await getLabelNames(metric, otherLabels, dataProvider); + return labelNames.map((text) => ({ + type: 'LABEL_NAME', + label: text, + insertText: `${text}${suffix}`, + triggerOnInsert, + })); +} + +async function getLabelNamesForSelectorCompletions( + metric: string | undefined, + otherLabels: Label[], + dataProvider: DataProvider +): Promise<Completion[]> { + return getLabelNamesForCompletions(metric, '=', true, otherLabels, dataProvider); +} + +async function getLabelNamesForByCompletions( + metric: string | undefined, + otherLabels: Label[], + dataProvider: DataProvider +): Promise<Completion[]> { + return getLabelNamesForCompletions(metric, '', false, otherLabels, dataProvider); +} + +async function getLabelValues( + metric: string | undefined, + labelName: string, + otherLabels: Label[], + dataProvider: DataProvider +): Promise<string[]> { + if (metric === undefined && otherLabels.length === 0) { + // if there is no filtering, we have to use a special endpoint + return dataProvider.getLabelValues(labelName); + } else { + const selector = makeSelector(metric, otherLabels); + return await dataProvider.getSeriesValues(labelName, selector); + } +} + +async function getLabelValuesForMetricCompletions( + metric: string | undefined, + labelName: string, + betweenQuotes: boolean, + otherLabels: Label[], + dataProvider: DataProvider +): Promise<Completion[]> { + const values = await getLabelValues(metric, labelName, otherLabels, dataProvider); + return values.map((text) => ({ + type: 'LABEL_VALUE', + label: text, + insertText: betweenQuotes ? text : `"${text}"`, // FIXME: escaping strange characters? + })); +} + +export async function getCompletions(situation: Situation, dataProvider: DataProvider): Promise<Completion[]> { + switch (situation.type) { + case 'IN_DURATION': + return DURATION_COMPLETIONS; + case 'IN_FUNCTION': + return getAllFunctionsAndMetricNamesCompletions(dataProvider); + case 'AT_ROOT': { + return getAllFunctionsAndMetricNamesCompletions(dataProvider); + } + case 'EMPTY': { + const metricNames = await getAllMetricNamesCompletions(dataProvider); + const historyCompletions = await getAllHistoryCompletions(dataProvider); + return [...historyCompletions, ...FUNCTION_COMPLETIONS, ...metricNames]; + } + case 'IN_LABEL_SELECTOR_NO_LABEL_NAME': + return getLabelNamesForSelectorCompletions(situation.metricName, situation.otherLabels, dataProvider); + case 'IN_GROUPING': + return getLabelNamesForByCompletions(situation.metricName, situation.otherLabels, dataProvider); + case 'IN_LABEL_SELECTOR_WITH_LABEL_NAME': + return getLabelValuesForMetricCompletions( + situation.metricName, + situation.labelName, + situation.betweenQuotes, + situation.otherLabels, + dataProvider + ); + default: + throw new NeverCaseError(situation); + } +} diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts new file mode 100644 index 0000000000000..f9b6a2e2b47cf --- /dev/null +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts @@ -0,0 +1,113 @@ +import type { Monaco, monacoTypes } from '@grafana/ui'; + +import { CompletionType, DataProvider, getCompletions } from './completions'; +import { getSituation } from './situation'; +import { NeverCaseError } from './util'; + +export function getSuggestOptions(): monacoTypes.editor.ISuggestOptions { + return { + // monaco-editor sometimes provides suggestions automatically, i am not + // sure based on what, seems to be by analyzing the words already + // written. + // to try it out: + // - enter `go_goroutines{job~` + // - have the cursor at the end of the string + // - press ctrl-enter + // - you will get two suggestions + // those were not provided by grafana, they are offered automatically. + // i want to remove those. the only way i found is: + // - every suggestion-item has a `kind` attribute, + // that controls the icon to the left of the suggestion. + // - items auto-generated by monaco have `kind` set to `text`. + // - we make sure grafana-provided suggestions do not have `kind` set to `text`. + // - and then we tell monaco not to show suggestions of kind `text` + showWords: false, + }; +} + +function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): monacoTypes.languages.CompletionItemKind { + switch (type) { + case 'DURATION': + return monaco.languages.CompletionItemKind.Unit; + case 'FUNCTION': + return monaco.languages.CompletionItemKind.Variable; + case 'HISTORY': + return monaco.languages.CompletionItemKind.Snippet; + case 'LABEL_NAME': + return monaco.languages.CompletionItemKind.Enum; + case 'LABEL_VALUE': + return monaco.languages.CompletionItemKind.EnumMember; + case 'METRIC_NAME': + return monaco.languages.CompletionItemKind.Constructor; + default: + throw new NeverCaseError(type); + } +} + +export function getCompletionProvider( + monaco: Monaco, + dataProvider: DataProvider +): monacoTypes.languages.CompletionItemProvider { + const provideCompletionItems = ( + model: monacoTypes.editor.ITextModel, + position: monacoTypes.Position + ): monacoTypes.languages.ProviderResult<monacoTypes.languages.CompletionList> => { + const word = model.getWordAtPosition(position); + const range = + word != null + ? monaco.Range.lift({ + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }) + : monaco.Range.fromPositions(position); + // documentation says `position` will be "adjusted" in `getOffsetAt` + // i don't know what that means, to be sure i clone it + + const positionClone = { + column: position.column, + lineNumber: position.lineNumber, + }; + + // Check to see if the browser supports window.getSelection() + if (window.getSelection) { + const selectedText = window.getSelection()?.toString(); + // If the user has selected text, adjust the cursor position to be at the start of the selection, instead of the end + if (selectedText && selectedText.length > 0) { + positionClone.column = positionClone.column - selectedText.length; + } + } + + const offset = model.getOffsetAt(positionClone); + const situation = getSituation(model.getValue(), offset); + const completionsPromise = situation != null ? getCompletions(situation, dataProvider) : Promise.resolve([]); + return completionsPromise.then((items) => { + // monaco by-default alphabetically orders the items. + // to stop it, we use a number-as-string sortkey, + // so that monaco keeps the order we use + const maxIndexDigits = items.length.toString().length; + const suggestions: monacoTypes.languages.CompletionItem[] = items.map((item, index) => ({ + kind: getMonacoCompletionItemKind(item.type, monaco), + label: item.label, + insertText: item.insertText, + detail: item.detail, + documentation: item.documentation, + sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have + range, + command: item.triggerOnInsert + ? { + id: 'editor.action.triggerSuggest', + title: '', + } + : undefined, + })); + return { suggestions }; + }); + }; + + return { + triggerCharacters: ['{', ',', '[', '(', '=', '~', ' ', '"'], + provideCompletionItems, + }; +} diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.test.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.test.ts new file mode 100644 index 0000000000000..8c7be6b592b09 --- /dev/null +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.test.ts @@ -0,0 +1,185 @@ +import { getSituation, Situation } from './situation'; + +// we use the `^` character as the cursor-marker in the string. +function assertSituation(situation: string, expectedSituation: Situation | null) { + // first we find the cursor-position + const pos = situation.indexOf('^'); + if (pos === -1) { + throw new Error('cursor missing'); + } + + // we remove the cursor-marker from the string + const text = situation.replace('^', ''); + + // sanity check, make sure no more cursor-markers remain + if (text.indexOf('^') !== -1) { + throw new Error('multiple cursors'); + } + + const result = getSituation(text, pos); + + if (expectedSituation === null) { + expect(result).toStrictEqual(null); + } else { + expect(result).toMatchObject(expectedSituation); + } +} + +describe('situation', () => { + it('handles things', () => { + assertSituation('^', { + type: 'EMPTY', + }); + + assertSituation('sum(one) / ^', { + type: 'AT_ROOT', + }); + + assertSituation('sum(^)', { + type: 'IN_FUNCTION', + }); + + assertSituation('sum(one) / sum(^)', { + type: 'IN_FUNCTION', + }); + + assertSituation('something{}[^]', { + type: 'IN_DURATION', + }); + + assertSituation('something{label~^}', null); + }); + + it('handles label names', () => { + assertSituation('something{^}', { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + metricName: 'something', + otherLabels: [], + }); + + assertSituation('sum(something) by (^)', { + type: 'IN_GROUPING', + metricName: 'something', + otherLabels: [], + }); + + assertSituation('sum by (^) (something)', { + type: 'IN_GROUPING', + metricName: 'something', + otherLabels: [], + }); + + assertSituation('something{one="val1",two!="val2",three=~"val3",four!~"val4",^}', { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + metricName: 'something', + otherLabels: [ + { name: 'one', value: 'val1', op: '=' }, + { name: 'two', value: 'val2', op: '!=' }, + { name: 'three', value: 'val3', op: '=~' }, + { name: 'four', value: 'val4', op: '!~' }, + ], + }); + + assertSituation('{^}', { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + otherLabels: [], + }); + + assertSituation('{one="val1",^}', { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + otherLabels: [{ name: 'one', value: 'val1', op: '=' }], + }); + + // single-quoted label-values with escape + assertSituation("{one='val\\'1',^}", { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + otherLabels: [{ name: 'one', value: "val'1", op: '=' }], + }); + + // double-quoted label-values with escape + assertSituation('{one="val\\"1",^}', { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + otherLabels: [{ name: 'one', value: 'val"1', op: '=' }], + }); + + // backticked label-values with escape (the escape should not be interpreted) + assertSituation('{one=`val\\"1`,^}', { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + otherLabels: [{ name: 'one', value: 'val\\"1', op: '=' }], + }); + }); + + it('handles label values', () => { + assertSituation('something{job=^}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + metricName: 'something', + labelName: 'job', + betweenQuotes: false, + otherLabels: [], + }); + + assertSituation('something{job!=^}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + metricName: 'something', + labelName: 'job', + betweenQuotes: false, + otherLabels: [], + }); + + assertSituation('something{job=~^}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + metricName: 'something', + labelName: 'job', + betweenQuotes: false, + otherLabels: [], + }); + + assertSituation('something{job!~^}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + metricName: 'something', + labelName: 'job', + betweenQuotes: false, + otherLabels: [], + }); + + assertSituation('something{job=^,host="h1"}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + metricName: 'something', + labelName: 'job', + betweenQuotes: false, + otherLabels: [{ name: 'host', value: 'h1', op: '=' }], + }); + + assertSituation('something{job="j1",host="^"}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + metricName: 'something', + labelName: 'host', + betweenQuotes: true, + otherLabels: [{ name: 'job', value: 'j1', op: '=' }], + }); + + assertSituation('something{job="j1"^}', null); + assertSituation('something{job="j1" ^ }', null); + assertSituation('something{job="j1" ^ , }', null); + + assertSituation('{job=^,host="h1"}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName: 'job', + betweenQuotes: false, + otherLabels: [{ name: 'host', value: 'h1', op: '=' }], + }); + + assertSituation('something{one="val1",two!="val2",three=^,four=~"val4",five!~"val5"}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + metricName: 'something', + labelName: 'three', + betweenQuotes: false, + otherLabels: [ + { name: 'one', value: 'val1', op: '=' }, + { name: 'two', value: 'val2', op: '!=' }, + { name: 'four', value: 'val4', op: '=~' }, + { name: 'five', value: 'val5', op: '!~' }, + ], + }); + }); +}); diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.ts new file mode 100644 index 0000000000000..e6f569fe80655 --- /dev/null +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.ts @@ -0,0 +1,569 @@ +import type { SyntaxNode, Tree } from '@lezer/common'; +import { + AggregateExpr, + AggregateModifier, + EqlRegex, + EqlSingle, + FunctionCallBody, + GroupingLabels, + Identifier, + LabelMatcher, + LabelMatchers, + LabelMatchList, + LabelName, + MatchOp, + MatrixSelector, + MetricIdentifier, + Neq, + NeqRegex, + parser, + PromQL, + StringLiteral, + VectorSelector, +} from '@prometheus-io/lezer-promql'; + +import { NeverCaseError } from './util'; + +type Direction = 'parent' | 'firstChild' | 'lastChild' | 'nextSibling'; + +type NodeTypeId = + | 0 // this is used as error-id + | typeof AggregateExpr + | typeof AggregateModifier + | typeof FunctionCallBody + | typeof GroupingLabels + | typeof Identifier + | typeof LabelMatcher + | typeof LabelMatchers + | typeof LabelMatchList + | typeof LabelName + | typeof MetricIdentifier + | typeof PromQL + | typeof StringLiteral + | typeof VectorSelector + | typeof MatrixSelector + | typeof MatchOp + | typeof EqlSingle + | typeof Neq + | typeof EqlRegex + | typeof NeqRegex; + +type Path = Array<[Direction, NodeTypeId]>; + +function move(node: SyntaxNode, direction: Direction): SyntaxNode | null { + switch (direction) { + case 'parent': + return node.parent; + case 'firstChild': + return node.firstChild; + case 'lastChild': + return node.lastChild; + case 'nextSibling': + return node.nextSibling; + default: + throw new NeverCaseError(direction); + } +} + +function walk(node: SyntaxNode, path: Path): SyntaxNode | null { + let current: SyntaxNode | null = node; + for (const [direction, expectedType] of path) { + current = move(current, direction); + if (current === null) { + // we could not move in the direction, we stop + return null; + } + if (current.type.id !== expectedType) { + // the reached node has wrong type, we stop + return null; + } + } + return current; +} + +function getNodeText(node: SyntaxNode, text: string): string { + return text.slice(node.from, node.to); +} + +function parsePromQLStringLiteral(text: string): string { + // if it is a string-literal, it is inside quotes of some kind + const inside = text.slice(1, text.length - 1); + + // FIXME: support https://prometheus.io/docs/prometheus/latest/querying/basics/#string-literals + // FIXME: maybe check other promql code, if all is supported or not + + // for now we do only some very simple un-escaping + + // we start with double-quotes + if (text.startsWith('"') && text.endsWith('"')) { + // NOTE: this is not 100% perfect, we only unescape the double-quote, + // there might be other characters too + return inside.replace(/\\"/, '"'); + } + + // then single-quote + if (text.startsWith("'") && text.endsWith("'")) { + // NOTE: this is not 100% perfect, we only unescape the single-quote, + // there might be other characters too + return inside.replace(/\\'/, "'"); + } + + // then backticks + if (text.startsWith('`') && text.endsWith('`')) { + return inside; + } + + throw new Error('FIXME: invalid string literal'); +} + +type LabelOperator = '=' | '!=' | '=~' | '!~'; + +export type Label = { + name: string; + value: string; + op: LabelOperator; +}; + +export type Situation = + | { + type: 'IN_FUNCTION'; + } + | { + type: 'AT_ROOT'; + } + | { + type: 'EMPTY'; + } + | { + type: 'IN_DURATION'; + } + | { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME'; + metricName?: string; + otherLabels: Label[]; + } + | { + type: 'IN_GROUPING'; + metricName: string; + otherLabels: Label[]; + } + | { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME'; + metricName?: string; + labelName: string; + betweenQuotes: boolean; + otherLabels: Label[]; + }; + +type Resolver = { + path: NodeTypeId[]; + fun: (node: SyntaxNode, text: string, pos: number) => Situation | null; +}; + +function isPathMatch(resolverPath: NodeTypeId[], cursorPath: number[]): boolean { + return resolverPath.every((item, index) => item === cursorPath[index]); +} + +const ERROR_NODE_NAME: NodeTypeId = 0; // this is used as error-id + +const RESOLVERS: Resolver[] = [ + { + path: [LabelMatchers, VectorSelector], + fun: resolveLabelKeysWithEquals, + }, + { + path: [PromQL], + fun: resolveTopLevel, + }, + { + path: [FunctionCallBody], + fun: resolveInFunction, + }, + { + path: [StringLiteral, LabelMatcher], + fun: resolveLabelMatcher, + }, + { + path: [ERROR_NODE_NAME, LabelMatcher], + fun: resolveLabelMatcher, + }, + { + path: [ERROR_NODE_NAME, MatrixSelector], + fun: resolveDurations, + }, + { + path: [GroupingLabels], + fun: resolveLabelsForGrouping, + }, +]; + +const LABEL_OP_MAP = new Map<number, LabelOperator>([ + [EqlSingle, '='], + [EqlRegex, '=~'], + [Neq, '!='], + [NeqRegex, '!~'], +]); + +function getLabelOp(opNode: SyntaxNode): LabelOperator | null { + const opChild = opNode.firstChild; + if (opChild === null) { + return null; + } + + return LABEL_OP_MAP.get(opChild.type.id) ?? null; +} + +function getLabel(labelMatcherNode: SyntaxNode, text: string): Label | null { + if (labelMatcherNode.type.id !== LabelMatcher) { + return null; + } + + const nameNode = walk(labelMatcherNode, [['firstChild', LabelName]]); + + if (nameNode === null) { + return null; + } + + const opNode = walk(nameNode, [['nextSibling', MatchOp]]); + if (opNode === null) { + return null; + } + + const op = getLabelOp(opNode); + if (op === null) { + return null; + } + + const valueNode = walk(labelMatcherNode, [['lastChild', StringLiteral]]); + + if (valueNode === null) { + return null; + } + + const name = getNodeText(nameNode, text); + const value = parsePromQLStringLiteral(getNodeText(valueNode, text)); + + return { name, value, op }; +} + +function getLabels(labelMatchersNode: SyntaxNode, text: string): Label[] { + if (labelMatchersNode.type.id !== LabelMatchers) { + return []; + } + + let listNode: SyntaxNode | null = walk(labelMatchersNode, [['firstChild', LabelMatchList]]); + + const labels: Label[] = []; + + while (listNode !== null) { + const matcherNode = walk(listNode, [['lastChild', LabelMatcher]]); + if (matcherNode === null) { + // unexpected, we stop + return []; + } + + const label = getLabel(matcherNode, text); + if (label !== null) { + labels.push(label); + } + + // there might be more labels + listNode = walk(listNode, [['firstChild', LabelMatchList]]); + } + + // our labels-list is last-first, so we reverse it + labels.reverse(); + + return labels; +} + +function getNodeChildren(node: SyntaxNode): SyntaxNode[] { + let child: SyntaxNode | null = node.firstChild; + const children: SyntaxNode[] = []; + while (child !== null) { + children.push(child); + child = child.nextSibling; + } + return children; +} + +function getNodeInSubtree(node: SyntaxNode, typeId: NodeTypeId): SyntaxNode | null { + // first we try the current node + if (node.type.id === typeId) { + return node; + } + + // then we try the children + const children = getNodeChildren(node); + for (const child of children) { + const n = getNodeInSubtree(child, typeId); + if (n !== null) { + return n; + } + } + + return null; +} + +function resolveLabelsForGrouping(node: SyntaxNode, text: string, pos: number): Situation | null { + const aggrExpNode = walk(node, [ + ['parent', AggregateModifier], + ['parent', AggregateExpr], + ]); + if (aggrExpNode === null) { + return null; + } + const bodyNode = aggrExpNode.getChild(FunctionCallBody); + if (bodyNode === null) { + return null; + } + + const metricIdNode = getNodeInSubtree(bodyNode, MetricIdentifier); + if (metricIdNode === null) { + return null; + } + + const idNode = walk(metricIdNode, [['firstChild', Identifier]]); + if (idNode === null) { + return null; + } + + const metricName = getNodeText(idNode, text); + return { + type: 'IN_GROUPING', + metricName, + otherLabels: [], + }; +} + +function resolveLabelMatcher(node: SyntaxNode, text: string, pos: number): Situation | null { + // we can arrive here in two situation. `node` is either: + // - a StringNode (like in `{job="^"}`) + // - or an error node (like in `{job=^}`) + const inStringNode = !node.type.isError; + + const parent = walk(node, [['parent', LabelMatcher]]); + if (parent === null) { + return null; + } + + const labelNameNode = walk(parent, [['firstChild', LabelName]]); + if (labelNameNode === null) { + return null; + } + + const labelName = getNodeText(labelNameNode, text); + + // now we need to go up, to the parent of LabelMatcher, + // there can be one or many `LabelMatchList` parents, we have + // to go through all of them + + const firstListNode = walk(parent, [['parent', LabelMatchList]]); + if (firstListNode === null) { + return null; + } + + let listNode = firstListNode; + + // we keep going through the parent-nodes + // as long as they are LabelMatchList. + // as soon as we reawch LabelMatchers, we stop + let labelMatchersNode: SyntaxNode | null = null; + while (labelMatchersNode === null) { + const p = listNode.parent; + if (p === null) { + return null; + } + + const { id } = p.type; + + switch (id) { + case LabelMatchList: + //we keep looping + listNode = p; + continue; + case LabelMatchers: + // we reached the end, we can stop the loop + labelMatchersNode = p; + continue; + default: + // we reached some other node, we stop + return null; + } + } + + // now we need to find the other names + const allLabels = getLabels(labelMatchersNode, text); + + // we need to remove "our" label from all-labels, if it is in there + const otherLabels = allLabels.filter((label) => label.name !== labelName); + + const metricNameNode = walk(labelMatchersNode, [ + ['parent', VectorSelector], + ['firstChild', MetricIdentifier], + ['firstChild', Identifier], + ]); + + if (metricNameNode === null) { + // we are probably in a situation without a metric name + return { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName, + betweenQuotes: inStringNode, + otherLabels, + }; + } + + const metricName = getNodeText(metricNameNode, text); + + return { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + metricName, + labelName, + betweenQuotes: inStringNode, + otherLabels, + }; +} + +function resolveTopLevel(node: SyntaxNode, text: string, pos: number): Situation { + return { + type: 'AT_ROOT', + }; +} + +function resolveInFunction(node: SyntaxNode, text: string, pos: number): Situation { + return { + type: 'IN_FUNCTION', + }; +} + +function resolveDurations(node: SyntaxNode, text: string, pos: number): Situation { + return { + type: 'IN_DURATION', + }; +} + +function subTreeHasError(node: SyntaxNode): boolean { + return getNodeInSubtree(node, ERROR_NODE_NAME) !== null; +} + +function resolveLabelKeysWithEquals(node: SyntaxNode, text: string, pos: number): Situation | null { + // for example `something{^}` + + // there are some false positives that can end up in this situation, that we want + // to eliminate: + // `something{a~^}` (if this subtree contains any error-node, we stop) + if (subTreeHasError(node)) { + return null; + } + + // next false positive: + // `something{a="1"^}` + const child = walk(node, [['firstChild', LabelMatchList]]); + if (child !== null) { + // means the label-matching part contains at least one label already. + // + // in this case, we will need to have a `,` character at the end, + // to be able to suggest adding the next label. + // the area between the end-of-the-child-node and the cursor-pos + // must contain a `,` in this case. + const textToCheck = text.slice(child.to, pos); + + if (!textToCheck.includes(',')) { + return null; + } + } + + const metricNameNode = walk(node, [ + ['parent', VectorSelector], + ['firstChild', MetricIdentifier], + ['firstChild', Identifier], + ]); + + const otherLabels = getLabels(node, text); + + if (metricNameNode === null) { + // we are probably in a situation without a metric name. + return { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + otherLabels, + }; + } + + const metricName = getNodeText(metricNameNode, text); + + return { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + metricName, + otherLabels, + }; +} + +// we find the first error-node in the tree that is at the cursor-position. +// NOTE: this might be too slow, might need to optimize it +// (ideas: we do not need to go into every subtree, based on from/to) +// also, only go to places that are in the sub-tree of the node found +// by default by lezer. problem is, `next()` will go upward too, +// and we do not want to go higher than our node +function getErrorNode(tree: Tree, pos: number): SyntaxNode | null { + const cur = tree.cursorAt(pos); + while (true) { + if (cur.from === pos && cur.to === pos) { + const { node } = cur; + if (node.type.isError) { + return node; + } + } + + if (!cur.next()) { + break; + } + } + return null; +} + +export function getSituation(text: string, pos: number): Situation | null { + // there is a special-case when we are at the start of writing text, + // so we handle that case first + + if (text === '') { + return { + type: 'EMPTY', + }; + } + + /* + PromQL + Expr + VectorSelector + LabelMatchers + */ + const tree = parser.parse(text); + + // if the tree contains error, it is very probable that + // our node is one of those error-nodes. + // also, if there are errors, the node lezer finds us, + // might not be the best node. + // so first we check if there is an error-node at the cursor-position + // @ts-ignore + const maybeErrorNode = getErrorNode(tree, pos); + + const cur = maybeErrorNode != null ? maybeErrorNode.cursor() : tree.cursorAt(pos); + const currentNode = cur.node; + + const ids = [cur.type.id]; + while (cur.parent()) { + ids.push(cur.type.id); + } + + for (let resolver of RESOLVERS) { + // i do not use a foreach because i want to stop as soon + // as i find something + if (isPathMatch(resolver.path, ids)) { + // @ts-ignore + return resolver.fun(currentNode, text, pos); + } + } + + return null; +} diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/util.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/util.ts new file mode 100644 index 0000000000000..35babca0d09c9 --- /dev/null +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/util.ts @@ -0,0 +1,25 @@ +// this helper class is used to make typescript warn you when you forget +// a case-block in a switch statement. +// example code that triggers the typescript-error: +// +// const x:'A'|'B'|'C' = 'A'; +// +// switch(x) { +// case 'A': +// // something +// case 'B': +// // something +// default: +// throw new NeverCaseError(x); +// } +// +// +// typescript will show an error in this case, +// when you add the missing `case 'C'` code, +// the problem will be fixed. + +export class NeverCaseError extends Error { + constructor(value: never) { + super('should never happen'); + } +} diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/validation.test.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/validation.test.ts new file mode 100644 index 0000000000000..c51c764e8fa8a --- /dev/null +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/validation.test.ts @@ -0,0 +1,85 @@ +import { parser } from '@prometheus-io/lezer-promql'; + +import { validateQuery } from './validation'; + +describe('Monaco Query Validation', () => { + test('Identifies empty queries as valid', () => { + expect(validateQuery('', '', [], parser)).toBeFalsy(); + }); + + test.each([ + 'access_evaluation_duration_sum{job="grafana"}', + 'http_requests_total{job="apiserver", handler="/api/comments"}[5m]', + 'http_requests_total{job=~".*server"}', + 'rate(http_requests_total[5m])[30m:1m]', + 'max_over_time(deriv(rate(distance_covered_total[5s])[30s:5s])[10m:])', + 'rate(http_requests_total[5m])', + 'topk(3, sum by (app, proc) (rate(instance_cpu_time_ns[5m])))', + ])('Identifies valid queries', (query: string) => { + expect(validateQuery(query, query, [], parser)).toBeFalsy(); + }); + + test('Identifies invalid queries', () => { + // Missing } at the end + let query = 'access_evaluation_duration_sum{job="grafana"'; + expect(validateQuery(query, query, [query], parser)).toEqual([ + { + endColumn: 45, + endLineNumber: 1, + error: '{job="grafana"', + startColumn: 31, + startLineNumber: 1, + }, + ]); + + // Missing handler="value" + query = 'http_requests_total{job="apiserver", handler}[5m]'; + expect(validateQuery(query, query, [query], parser)).toEqual([ + { + endColumn: 45, + endLineNumber: 1, + error: 'handler', + startColumn: 38, + startLineNumber: 1, + }, + ]); + + // Missing : in [30s:5s] + query = 'max_over_time(deriv(rate(distance_covered_total[5s])[30s5s])[10m:])'; + expect(validateQuery(query, query, [query], parser)).toEqual([ + { + endColumn: 60, + endLineNumber: 1, + error: 'rate(distance_covered_total[5s])[30s5s]', + startColumn: 21, + startLineNumber: 1, + }, + ]); + }); + + test('Identifies valid multi-line queries', () => { + const query = ` +sum by (job) ( + rate(http_requests_total[5m]) +)`; + const queryLines = query.split('\n'); + expect(validateQuery(query, query, queryLines, parser)).toBeFalsy(); + }); + + test('Identifies invalid multi-line queries', () => { + const query = ` +sum by (job) ( + rate(http_requests_total[]) +)`; + const queryLines = query.split('\n'); + expect(validateQuery(query, query, queryLines, parser)).toEqual([ + { + endColumn: 30, + endLineNumber: 3, + error: '', + startColumn: 30, + startLineNumber: 3, + }, + ]); + }); +}); diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/validation.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/validation.ts new file mode 100644 index 0000000000000..8b9cb8e6766aa --- /dev/null +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/validation.ts @@ -0,0 +1,126 @@ +import { SyntaxNode } from '@lezer/common'; +import { LRParser } from '@lezer/lr'; + +// Although 0 isn't explicitly provided in the @grafana/lezer-logql library as the error node ID, it does appear to be the ID of error nodes within lezer. +export const ErrorId = 0; + +interface ParserErrorBoundary { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + error: string; +} + +interface ParseError { + text: string; + node: SyntaxNode; +} + +/** + * Conceived to work in combination with the MonacoQueryField component. + * Given an original query, and it's interpolated version, it will return an array of ParserErrorBoundary + * objects containing nodes which are actual errors. The interpolated version (even with placeholder variables) + * is required because variables look like errors for Lezer. + * @internal + */ +export function validateQuery( + query: string, + interpolatedQuery: string, + queryLines: string[], + parser: LRParser +): ParserErrorBoundary[] | false { + if (!query) { + return false; + } + + /** + * To provide support to variable interpolation in query validation, we run the parser in the interpolated + * query. If there are errors there, we trace them back to the original unparsed query, so we can more + * accurately highlight the error in the query, since it's likely that the variable name and variable value + * have different lengths. With this, we also exclude irrelevant parser errors that are produced by + * lezer not understanding $variables and $__variables, which usually generate 2 or 3 error SyntaxNode. + */ + const interpolatedErrors: ParseError[] = parseQuery(interpolatedQuery, parser); + if (!interpolatedErrors.length) { + return false; + } + + let parseErrors: ParseError[] = interpolatedErrors; + if (query !== interpolatedQuery) { + const queryErrors: ParseError[] = parseQuery(query, parser); + parseErrors = interpolatedErrors.flatMap( + (interpolatedError) => + queryErrors.filter((queryError) => interpolatedError.text === queryError.text) || interpolatedError + ); + } + + return parseErrors.map((parseError) => findErrorBoundary(query, queryLines, parseError)).filter(isErrorBoundary); +} + +function parseQuery(query: string, parser: LRParser) { + const parseErrors: ParseError[] = []; + const tree = parser.parse(query); + tree.iterate({ + enter: (nodeRef): false | void => { + if (nodeRef.type.id === ErrorId) { + const node = nodeRef.node; + parseErrors.push({ + node: node, + text: query.substring(node.from, node.to), + }); + } + }, + }); + return parseErrors; +} + +function findErrorBoundary(query: string, queryLines: string[], parseError: ParseError): ParserErrorBoundary | null { + if (queryLines.length === 1) { + const isEmptyString = parseError.node.from === parseError.node.to; + const errorNode = isEmptyString && parseError.node.parent ? parseError.node.parent : parseError.node; + const error = isEmptyString ? query.substring(errorNode.from, errorNode.to) : parseError.text; + return { + startLineNumber: 1, + startColumn: errorNode.from + 1, + endLineNumber: 1, + endColumn: errorNode.to + 1, + error, + }; + } + + let startPos = 0, + endPos = 0; + for (let line = 0; line < queryLines.length; line++) { + endPos = startPos + queryLines[line].length; + + if (parseError.node.from > endPos) { + startPos += queryLines[line].length + 1; + continue; + } + + return { + startLineNumber: line + 1, + startColumn: parseError.node.from - startPos + 1, + endLineNumber: line + 1, + endColumn: parseError.node.to - startPos + 1, + error: parseError.text, + }; + } + + return null; +} + +function isErrorBoundary(boundary: ParserErrorBoundary | null): boundary is ParserErrorBoundary { + return boundary !== null; +} + +export const placeHolderScopedVars = { + __interval: { text: '1s', value: '1s' }, + __rate_interval: { text: '1s', value: '1s' }, + __auto: { text: '1s', value: '1s' }, + __interval_ms: { text: '1000', value: 1000 }, + __range_ms: { text: '1000', value: 1000 }, + __range_s: { text: '1', value: 1 }, + __range: { text: '1s', value: '1s' }, +}; diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/promql.ts b/packages/grafana-prometheus/src/components/monaco-query-field/promql.ts new file mode 100644 index 0000000000000..af7ab116c9503 --- /dev/null +++ b/packages/grafana-prometheus/src/components/monaco-query-field/promql.ts @@ -0,0 +1,247 @@ +// The MIT License (MIT) +// +// Copyright (c) Celian Garcia and Augustin Husson @ Amadeus IT Group +// +// 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. +'use strict'; +// import { languages } from "monaco-editor"; +// noinspection JSUnusedGlobalSymbols +export const languageConfiguration = { + // the default separators except `@$` + wordPattern: /(-?\d*\.\d\w*)|([^`~!#%^&*()\-=+\[{\]}\\|;:'",.<>\/?\s]+)/g, + // Not possible to make comments in PromQL syntax + comments: { + lineComment: '#', + }, + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'], + ], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], + surroundingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + { open: '<', close: '>' }, + ], + folding: {}, +}; +// PromQL Aggregation Operators +// (https://prometheus.io/docs/prometheus/latest/querying/operators/#aggregation-operators) +const aggregations = [ + 'sum', + 'min', + 'max', + 'avg', + 'group', + 'stddev', + 'stdvar', + 'count', + 'count_values', + 'bottomk', + 'topk', + 'quantile', +]; +// PromQL functions +// (https://prometheus.io/docs/prometheus/latest/querying/functions/) +const functions = [ + 'abs', + 'absent', + 'ceil', + 'changes', + 'clamp_max', + 'clamp_min', + 'day_of_month', + 'day_of_week', + 'days_in_month', + 'delta', + 'deriv', + 'exp', + 'floor', + 'histogram_quantile', + 'holt_winters', + 'hour', + 'idelta', + 'increase', + 'irate', + 'label_join', + 'label_replace', + 'ln', + 'log2', + 'log10', + 'minute', + 'month', + 'predict_linear', + 'rate', + 'resets', + 'round', + 'scalar', + 'sort', + 'sort_desc', + 'sqrt', + 'time', + 'timestamp', + 'vector', + 'year', +]; +// PromQL specific functions: Aggregations over time +// (https://prometheus.io/docs/prometheus/latest/querying/functions/#aggregation_over_time) +const aggregationsOverTime = []; +for (let _i = 0, aggregations_1 = aggregations; _i < aggregations_1.length; _i++) { + let agg = aggregations_1[_i]; + aggregationsOverTime.push(agg + '_over_time'); +} +// PromQL vector matching + the by and without clauses +// (https://prometheus.io/docs/prometheus/latest/querying/operators/#vector-matching) +const vectorMatching = ['on', 'ignoring', 'group_right', 'group_left', 'by', 'without']; +// Produce a regex matching elements : (elt1|elt2|...) +const vectorMatchingRegex = + '(' + + vectorMatching.reduce(function (prev, curr) { + return prev + '|' + curr; + }) + + ')'; +// PromQL Operators +// (https://prometheus.io/docs/prometheus/latest/querying/operators/) +const operators = ['+', '-', '*', '/', '%', '^', '==', '!=', '>', '<', '>=', '<=', 'and', 'or', 'unless']; +// PromQL offset modifier +// (https://prometheus.io/docs/prometheus/latest/querying/basics/#offset-modifier) +const offsetModifier = ['offset']; +// Merging all the keywords in one list +const keywords = aggregations + .concat(functions) + .concat(aggregationsOverTime) + .concat(vectorMatching) + .concat(offsetModifier); +// noinspection JSUnusedGlobalSymbols +export const language = { + ignoreCase: false, + defaultToken: '', + tokenPostfix: '.promql', + keywords: keywords, + operators: operators, + vectorMatching: vectorMatchingRegex, + // we include these common regular expressions + symbols: /[=><!~?:&|+\-*\/^%]+/, + escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/, + digits: /\d+(_+\d+)*/, + octaldigits: /[0-7]+(_+[0-7]+)*/, + binarydigits: /[0-1]+(_+[0-1]+)*/, + hexdigits: /[[0-9a-fA-F]+(_+[0-9a-fA-F]+)*/, + integersuffix: /(ll|LL|u|U|l|L)?(ll|LL|u|U|l|L)?/, + floatsuffix: /[fFlL]?/, + // The main tokenizer for our languages + tokenizer: { + root: [ + // 'by', 'without' and vector matching + [/@vectorMatching\s*(?=\()/, 'type', '@clauses'], + // labels + [/[a-z_]\w*(?=\s*(=|!=|=~|!~))/, 'tag'], + // comments + [/(^#.*$)/, 'comment'], + // all keywords have the same color + [ + /[a-zA-Z_]\w*/, + { + cases: { + '@keywords': 'type', + '@default': 'identifier', + }, + }, + ], + // strings + [/"([^"\\]|\\.)*$/, 'string.invalid'], + [/'([^'\\]|\\.)*$/, 'string.invalid'], + [/"/, 'string', '@string_double'], + [/'/, 'string', '@string_single'], + [/`/, 'string', '@string_backtick'], + // whitespace + { include: '@whitespace' }, + // delimiters and operators + [/[{}()\[\]]/, '@brackets'], + [/[<>](?!@symbols)/, '@brackets'], + [ + /@symbols/, + { + cases: { + '@operators': 'delimiter', + '@default': '', + }, + }, + ], + // numbers + [/\d+[smhdwy]/, 'number'], + [/\d*\d+[eE]([\-+]?\d+)?(@floatsuffix)/, 'number.float'], + [/\d*\.\d+([eE][\-+]?\d+)?(@floatsuffix)/, 'number.float'], + [/0[xX][0-9a-fA-F']*[0-9a-fA-F](@integersuffix)/, 'number.hex'], + [/0[0-7']*[0-7](@integersuffix)/, 'number.octal'], + [/0[bB][0-1']*[0-1](@integersuffix)/, 'number.binary'], + [/\d[\d']*\d(@integersuffix)/, 'number'], + [/\d(@integersuffix)/, 'number'], + ], + string_double: [ + [/[^\\"]+/, 'string'], + [/@escapes/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/"/, 'string', '@pop'], + ], + string_single: [ + [/[^\\']+/, 'string'], + [/@escapes/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/'/, 'string', '@pop'], + ], + string_backtick: [ + [/[^\\`$]+/, 'string'], + [/@escapes/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/`/, 'string', '@pop'], + ], + clauses: [ + [/[^(,)]/, 'tag'], + [/\)/, 'identifier', '@pop'], + ], + whitespace: [[/[ \t\r\n]+/, 'white']], + }, +}; +// noinspection JSUnusedGlobalSymbols +// export const completionItemProvider = { +// provideCompletionItems: function () { +// // To simplify, we made the choice to never create automatically the parenthesis behind keywords +// // It is because in PromQL, some keywords need parenthesis behind, some don't, some can have but it's optional. +// const suggestions = keywords.map(function (value) { +// return { +// label: value, +// kind: languages.CompletionItemKind.Keyword, +// insertText: value, +// insertTextRules: languages.CompletionItemInsertTextRule.InsertAsSnippet +// }; +// }); +// return { suggestions: suggestions }; +// } +// }; diff --git a/packages/grafana-prometheus/src/components/types.ts b/packages/grafana-prometheus/src/components/types.ts new file mode 100644 index 0000000000000..419b86ea5bd3b --- /dev/null +++ b/packages/grafana-prometheus/src/components/types.ts @@ -0,0 +1,6 @@ +import { QueryEditorProps } from '@grafana/data'; + +import { PrometheusDatasource } from '../datasource'; +import { PromOptions, PromQuery } from '../types'; + +export type PromQueryEditorProps = QueryEditorProps<PrometheusDatasource, PromQuery, PromOptions>; diff --git a/packages/grafana-prometheus/src/configuration/AlertingSettingsOverhaul.tsx b/packages/grafana-prometheus/src/configuration/AlertingSettingsOverhaul.tsx new file mode 100644 index 0000000000000..11bbe3d2361c6 --- /dev/null +++ b/packages/grafana-prometheus/src/configuration/AlertingSettingsOverhaul.tsx @@ -0,0 +1,61 @@ +import { cx } from '@emotion/css'; +import React from 'react'; + +import { DataSourceJsonData, DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { ConfigSubSection } from '@grafana/experimental'; +import { InlineField, Switch, useTheme2 } from '@grafana/ui'; + +import { docsTip, overhaulStyles } from './ConfigEditor'; + +interface Props<T extends DataSourceJsonData> + extends Pick<DataSourcePluginOptionsEditorProps<T>, 'options' | 'onOptionsChange'> {} + +interface AlertingConfig extends DataSourceJsonData { + manageAlerts?: boolean; +} + +export function AlertingSettingsOverhaul<T extends AlertingConfig>({ + options, + onOptionsChange, +}: Props<T>): JSX.Element { + const theme = useTheme2(); + // imported GrafanaTheme2 from @grafana/data does not match type of same from @grafana/ui + // @ts-ignore + const styles = overhaulStyles(theme); + + return ( + <ConfigSubSection title="Alerting" className={cx(styles.container, styles.alertingTop)}> + <div className="gf-form-group"> + <div className="gf-form-inline"> + <div className="gf-form"> + <InlineField + labelWidth={30} + label="Manage alerts via Alerting UI" + disabled={options.readOnly} + tooltip={ + <> + Manage alert rules for this data source. To manage other alerting resources, add an Alertmanager data + source. {docsTip()} + </> + } + interactive={true} + className={styles.switchField} + > + <Switch + value={options.jsonData.manageAlerts !== false} + onChange={(event) => + onOptionsChange({ + ...options, + jsonData: { ...options.jsonData, manageAlerts: event!.currentTarget.checked }, + }) + } + id={selectors.components.DataSource.Prometheus.configPage.manageAlerts} + /> + </InlineField> + </div> + </div> + </div> + </ConfigSubSection> + ); +} diff --git a/packages/grafana-prometheus/src/configuration/ConfigEditor.test.tsx b/packages/grafana-prometheus/src/configuration/ConfigEditor.test.tsx new file mode 100644 index 0000000000000..9ce9e66c563b4 --- /dev/null +++ b/packages/grafana-prometheus/src/configuration/ConfigEditor.test.tsx @@ -0,0 +1,93 @@ +import React from 'react'; + +import { FieldValidationMessage } from '@grafana/ui'; + +import { validateInput } from './ConfigEditor'; +import { DURATION_REGEX, MULTIPLE_DURATION_REGEX } from './PromSettings'; + +const VALID_URL_REGEX = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/; + +const error = <FieldValidationMessage>Value is not valid</FieldValidationMessage>; +// replaces promSettingsValidationEvents to display a <FieldValidationMessage> onBlur for duration input errors +describe('promSettings validateInput', () => { + it.each` + value | expected + ${'1ms'} | ${true} + ${'1M'} | ${true} + ${'1w'} | ${true} + ${'1d'} | ${true} + ${'1h'} | ${true} + ${'1m'} | ${true} + ${'1s'} | ${true} + ${'1y'} | ${true} + `( + "Single duration regex, when calling the rule with correct formatted value: '$value' then result should be '$expected'", + ({ value, expected }) => { + expect(validateInput(value, DURATION_REGEX)).toBe(expected); + } + ); + + it.each` + value | expected + ${'1M 2s'} | ${true} + ${'1w 2d'} | ${true} + ${'1d 2m'} | ${true} + ${'1h 2m'} | ${true} + ${'1m 2s'} | ${true} + `( + "Multiple duration regex, when calling the rule with correct formatted value: '$value' then result should be '$expected'", + ({ value, expected }) => { + expect(validateInput(value, MULTIPLE_DURATION_REGEX)).toBe(expected); + } + ); + + it.each` + value | expected + ${'1 ms'} | ${error} + ${'1x'} | ${error} + ${' '} | ${error} + ${'w'} | ${error} + ${'1.0s'} | ${error} + `( + "when calling the rule with incorrect formatted value: '$value' then result should be '$expected'", + ({ value, expected }) => { + expect(validateInput(value, DURATION_REGEX)).toStrictEqual(expected); + } + ); + + it.each` + value | expected + ${'frp://'} | ${error} + ${'htp://'} | ${error} + ${'httpss:??'} | ${error} + ${'http@//'} | ${error} + ${'http:||'} | ${error} + ${'http://'} | ${error} + ${'https://'} | ${error} + ${'ftp://'} | ${error} + `( + "Url incorrect formatting, when calling the rule with correct formatted value: '$value' then result should be '$expected'", + ({ value, expected }) => { + expect(validateInput(value, VALID_URL_REGEX)).toStrictEqual(expected); + } + ); + + it.each` + value | expected + ${'ftp://example'} | ${true} + ${'http://example'} | ${true} + ${'https://example'} | ${true} + `( + "Url correct formatting, when calling the rule with correct formatted value: '$value' then result should be '$expected'", + ({ value, expected }) => { + expect(validateInput(value, VALID_URL_REGEX)).toBe(expected); + } + ); + + it('should display a custom validation message', () => { + const invalidDuration = 'invalid'; + const customMessage = 'This is invalid'; + const errorWithCustomMessage = <FieldValidationMessage>{customMessage}</FieldValidationMessage>; + expect(validateInput(invalidDuration, DURATION_REGEX, customMessage)).toStrictEqual(errorWithCustomMessage); + }); +}); diff --git a/packages/grafana-prometheus/src/configuration/ConfigEditor.tsx b/packages/grafana-prometheus/src/configuration/ConfigEditor.tsx new file mode 100644 index 0000000000000..86a6f68725387 --- /dev/null +++ b/packages/grafana-prometheus/src/configuration/ConfigEditor.tsx @@ -0,0 +1,139 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { DataSourcePluginOptionsEditorProps, GrafanaTheme2 } from '@grafana/data'; +import { ConfigSection, DataSourceDescription, AdvancedHttpSettings } from '@grafana/experimental'; +import { config } from '@grafana/runtime'; +import { Alert, FieldValidationMessage, useTheme2 } from '@grafana/ui'; + +import { PromOptions } from '../types'; + +import { AlertingSettingsOverhaul } from './AlertingSettingsOverhaul'; +import { DataSourceHttpSettingsOverhaul } from './DataSourceHttpSettingsOverhaul'; +import { PromSettings } from './PromSettings'; + +export const PROM_CONFIG_LABEL_WIDTH = 30; + +export type PrometheusConfigProps = DataSourcePluginOptionsEditorProps<PromOptions>; + +export const ConfigEditor = (props: PrometheusConfigProps) => { + const { options, onOptionsChange } = props; + const theme = useTheme2(); + const styles = overhaulStyles(theme); + + return ( + <> + {options.access === 'direct' && ( + <Alert title="Error" severity="error"> + Browser access mode in the Prometheus data source is no longer available. Switch to server access mode. + </Alert> + )} + <DataSourceDescription + dataSourceName="Prometheus" + docsLink="https://grafana.com/docs/grafana/latest/datasources/prometheus/configure-prometheus-data-source/" + /> + <hr className={`${styles.hrTopSpace} ${styles.hrBottomSpace}`} /> + <DataSourceHttpSettingsOverhaul + options={options} + onOptionsChange={onOptionsChange} + secureSocksDSProxyEnabled={config.secureSocksDSProxyEnabled} + /> + <hr /> + <ConfigSection + className={styles.advancedSettings} + title="Advanced settings" + description="Additional settings are optional settings that can be configured for more control over your data source." + > + <AdvancedHttpSettings + className={styles.advancedHTTPSettingsMargin} + config={options} + onChange={onOptionsChange} + /> + <AlertingSettingsOverhaul<PromOptions> options={options} onOptionsChange={onOptionsChange} /> + <PromSettings options={options} onOptionsChange={onOptionsChange} /> + </ConfigSection> + </> + ); +}; + +/** + * Use this to return a url in a tooltip in a field. Don't forget to make the field interactive to be able to click on the tooltip + * @param url + * @returns + */ +export function docsTip(url?: string) { + const docsUrl = 'https://grafana.com/docs/grafana/latest/datasources/prometheus/#configure-the-data-source'; + + return ( + <a href={url ? url : docsUrl} target="_blank" rel="noopener noreferrer"> + Visit docs for more details here. + </a> + ); +} + +export const validateInput = ( + input: string, + pattern: string | RegExp, + errorMessage?: string +): boolean | JSX.Element => { + const defaultErrorMessage = 'Value is not valid'; + if (input && !input.match(pattern)) { + return <FieldValidationMessage>{errorMessage ? errorMessage : defaultErrorMessage}</FieldValidationMessage>; + } else { + return true; + } +}; + +export function overhaulStyles(theme: GrafanaTheme2) { + return { + additionalSettings: css` + margin-bottom: 25px; + `, + secondaryGrey: css` + color: ${theme.colors.secondary.text}; + opacity: 65%; + `, + inlineError: css` + margin: 0px 0px 4px 245px; + `, + switchField: css` + align-items: center; + `, + sectionHeaderPadding: css` + padding-top: 32px; + `, + sectionBottomPadding: css` + padding-bottom: 28px; + `, + subsectionText: css` + font-size: 12px; + `, + hrBottomSpace: css` + margin-bottom: 56px; + `, + hrTopSpace: css` + margin-top: 50px; + `, + textUnderline: css` + text-decoration: underline; + `, + versionMargin: css` + margin-bottom: 12px; + `, + advancedHTTPSettingsMargin: css` + margin: 24px 0 8px 0; + `, + advancedSettings: css` + padding-top: 32px; + `, + alertingTop: css` + margin-top: 40px !important; + `, + overhaulPageHeading: css` + font-weight: 400; + `, + container: css` + maxwidth: 578; + `, + }; +} diff --git a/packages/grafana-prometheus/src/configuration/DataSourceHttpSettingsOverhaul.tsx b/packages/grafana-prometheus/src/configuration/DataSourceHttpSettingsOverhaul.tsx new file mode 100644 index 0000000000000..86af446174066 --- /dev/null +++ b/packages/grafana-prometheus/src/configuration/DataSourceHttpSettingsOverhaul.tsx @@ -0,0 +1,97 @@ +import React from 'react'; + +import { DataSourceSettings } from '@grafana/data'; +import { Auth, AuthMethod, ConnectionSettings, convertLegacyAuthProps } from '@grafana/experimental'; +import { SecureSocksProxySettings, useTheme2 } from '@grafana/ui'; + +import { PromOptions } from '../types'; + +import { docsTip, overhaulStyles } from './ConfigEditor'; + +export type DataSourceHttpSettingsProps = { + options: DataSourceSettings<PromOptions, {}>; + onOptionsChange: (options: DataSourceSettings<PromOptions, {}>) => void; + secureSocksDSProxyEnabled: boolean; +}; + +export const DataSourceHttpSettingsOverhaul = (props: DataSourceHttpSettingsProps) => { + const { options, onOptionsChange, secureSocksDSProxyEnabled } = props; + + const newAuthProps = convertLegacyAuthProps({ + config: options, + onChange: onOptionsChange, + }); + + const theme = useTheme2(); + const styles = overhaulStyles(theme); + + function returnSelectedMethod() { + return newAuthProps.selectedMethod; + } + + // Do we need this switch anymore? Update the language. + let urlTooltip; + switch (options.access) { + case 'direct': + urlTooltip = ( + <> + Your access method is <em>Browser</em>, this means the URL needs to be accessible from the browser. + {docsTip()} + </> + ); + break; + case 'proxy': + urlTooltip = ( + <> + Your access method is <em>Server</em>, this means the URL needs to be accessible from the grafana + backend/server. + {docsTip()} + </> + ); + break; + default: + urlTooltip = <>Specify a complete HTTP URL (for example http://your_server:8080) {docsTip()}</>; + } + + return ( + <> + <ConnectionSettings + urlPlaceholder="http://localhost:9090" + config={options} + onChange={onOptionsChange} + urlLabel="Prometheus server URL" + urlTooltip={urlTooltip} + /> + <hr className={`${styles.hrTopSpace} ${styles.hrBottomSpace}`} /> + <Auth + // Reshaped legacy props + {...newAuthProps} + // Still need to call `onAuthMethodSelect` function from + // `newAuthProps` to store the legacy data correctly. + // Also make sure to store the data about your component + // being selected/unselected. + onAuthMethodSelect={(method) => { + onOptionsChange({ + ...options, + basicAuth: method === AuthMethod.BasicAuth, + withCredentials: method === AuthMethod.CrossSiteCredentials, + jsonData: { + ...options.jsonData, + oauthPassThru: method === AuthMethod.OAuthForward, + }, + }); + }} + // If your method is selected pass its id to `selectedMethod`, + // otherwise pass the id from converted legacy data + selectedMethod={returnSelectedMethod()} + /> + <div className={styles.sectionBottomPadding} /> + {secureSocksDSProxyEnabled && ( + <> + <SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} /> + <div className={styles.sectionBottomPadding} /> + </> + )} + </> + ); +}; diff --git a/packages/grafana-prometheus/src/configuration/ExemplarSetting.tsx b/packages/grafana-prometheus/src/configuration/ExemplarSetting.tsx new file mode 100644 index 0000000000000..75614934cf5ae --- /dev/null +++ b/packages/grafana-prometheus/src/configuration/ExemplarSetting.tsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react'; + +import { DataSourceInstanceSettings } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { DataSourcePicker } from '@grafana/runtime'; +import { Button, InlineField, Input, Switch, useTheme2 } from '@grafana/ui'; + +import { ExemplarTraceIdDestination } from '../types'; + +import { docsTip, overhaulStyles, PROM_CONFIG_LABEL_WIDTH } from './ConfigEditor'; + +type Props = { + value: ExemplarTraceIdDestination; + onChange: (value: ExemplarTraceIdDestination) => void; + onDelete: () => void; + disabled?: boolean; +}; + +export function ExemplarSetting({ value, onChange, onDelete, disabled }: Props) { + const [isInternalLink, setIsInternalLink] = useState(Boolean(value.datasourceUid)); + + const theme = useTheme2(); + const styles = overhaulStyles(theme); + + return ( + <div className="gf-form-group"> + <InlineField + label="Internal link" + labelWidth={PROM_CONFIG_LABEL_WIDTH} + disabled={disabled} + tooltip={ + <> + Enable this option if you have an internal link. When enabled, this reveals the data source selector. Select + the backend tracing data store for your exemplar data. {docsTip()} + </> + } + interactive={true} + className={styles.switchField} + > + <> + <Switch + value={isInternalLink} + data-testid={selectors.components.DataSource.Prometheus.configPage.internalLinkSwitch} + onChange={(ev) => setIsInternalLink(ev.currentTarget.checked)} + /> + </> + </InlineField> + + {isInternalLink ? ( + <InlineField + label="Data source" + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={<>The data source the exemplar is going to navigate to. {docsTip()}</>} + disabled={disabled} + interactive={true} + > + <DataSourcePicker + tracing={true} + current={value.datasourceUid} + noDefault={true} + width={40} + onChange={(ds: DataSourceInstanceSettings) => + onChange({ + ...value, + datasourceUid: ds.uid, + url: undefined, + }) + } + /> + </InlineField> + ) : ( + <InlineField + label="URL" + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={<>The URL of the trace backend the user would go to see its trace. {docsTip()}</>} + disabled={disabled} + interactive={true} + > + <Input + placeholder="https://example.com/${__value.raw}" + spellCheck={false} + width={40} + value={value.url} + onChange={(event) => + onChange({ + ...value, + datasourceUid: undefined, + url: event.currentTarget.value, + }) + } + /> + </InlineField> + )} + + <InlineField + label="URL Label" + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={<>Use to override the button label on the exemplar traceID field. {docsTip()}</>} + disabled={disabled} + interactive={true} + > + <Input + placeholder="Go to example.com" + spellCheck={false} + width={40} + value={value.urlDisplayLabel} + onChange={(event) => + onChange({ + ...value, + urlDisplayLabel: event.currentTarget.value, + }) + } + /> + </InlineField> + <InlineField + label="Label name" + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={<>The name of the field in the labels object that should be used to get the traceID. {docsTip()}</>} + disabled={disabled} + interactive={true} + > + <Input + placeholder="traceID" + spellCheck={false} + width={40} + value={value.name} + onChange={(event) => + onChange({ + ...value, + name: event.currentTarget.value, + }) + } + /> + </InlineField> + {!disabled && ( + <InlineField label="Remove exemplar link" labelWidth={PROM_CONFIG_LABEL_WIDTH} disabled={disabled}> + <Button + variant="destructive" + title="Remove exemplar link" + icon="times" + onClick={(event) => { + event.preventDefault(); + onDelete(); + }} + /> + </InlineField> + )} + </div> + ); +} diff --git a/packages/grafana-prometheus/src/configuration/ExemplarsSettings.tsx b/packages/grafana-prometheus/src/configuration/ExemplarsSettings.tsx new file mode 100644 index 0000000000000..6ffece606a0c1 --- /dev/null +++ b/packages/grafana-prometheus/src/configuration/ExemplarsSettings.tsx @@ -0,0 +1,67 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { ConfigSubSection } from '@grafana/experimental'; +import { Button, useTheme2 } from '@grafana/ui'; + +import { ExemplarTraceIdDestination } from '../types'; + +import { overhaulStyles } from './ConfigEditor'; +import { ExemplarSetting } from './ExemplarSetting'; + +type Props = { + options?: ExemplarTraceIdDestination[]; + onChange: (value: ExemplarTraceIdDestination[]) => void; + disabled?: boolean; +}; + +export function ExemplarsSettings({ options, onChange, disabled }: Props) { + const theme = useTheme2(); + const styles = overhaulStyles(theme); + return ( + <div className={styles.sectionBottomPadding}> + <ConfigSubSection title="Exemplars" className={styles.container}> + {options && + options.map((option, index) => { + return ( + <ExemplarSetting + key={index} + value={option} + onChange={(newField) => { + const newOptions = [...options]; + newOptions.splice(index, 1, newField); + onChange(newOptions); + }} + onDelete={() => { + const newOptions = [...options]; + newOptions.splice(index, 1); + onChange(newOptions); + }} + disabled={disabled} + /> + ); + })} + + {!disabled && ( + <Button + variant="secondary" + data-testid={selectors.components.DataSource.Prometheus.configPage.exemplarsAddButton} + className={css` + margin-bottom: 10px; + `} + icon="plus" + onClick={(event) => { + event.preventDefault(); + const newOptions = [...(options || []), { name: 'traceID' }]; + onChange(newOptions); + }} + > + Add + </Button> + )} + {disabled && !options && <i>No exemplars configurations</i>} + </ConfigSubSection> + </div> + ); +} diff --git a/packages/grafana-prometheus/src/configuration/PromFlavorVersions.ts b/packages/grafana-prometheus/src/configuration/PromFlavorVersions.ts new file mode 100644 index 0000000000000..bce2dd88a87b9 --- /dev/null +++ b/packages/grafana-prometheus/src/configuration/PromFlavorVersions.ts @@ -0,0 +1,99 @@ +export const PromFlavorVersions: { [index: string]: Array<{ value?: string; label: string }> } = { + Prometheus: [ + { value: undefined, label: 'Please select' }, + { value: '2.0.0', label: '< 2.14.x' }, + { value: '2.14.0', label: '2.14.x' }, + { value: '2.15.0', label: '2.15.x' }, + { value: '2.16.0', label: '2.16.x' }, + { value: '2.17.0', label: '2.17.x' }, + { value: '2.18.0', label: '2.18.x' }, + { value: '2.19.0', label: '2.19.x' }, + { value: '2.20.0', label: '2.20.x' }, + { value: '2.21.0', label: '2.21.x' }, + { value: '2.22.0', label: '2.22.x' }, + { value: '2.23.0', label: '2.23.x' }, + { value: '2.24.0', label: '2.24.x' }, + { value: '2.25.0', label: '2.25.x' }, + { value: '2.26.0', label: '2.26.x' }, + { value: '2.27.0', label: '2.27.x' }, + { value: '2.28.0', label: '2.28.x' }, + { value: '2.29.0', label: '2.29.x' }, + { value: '2.30.0', label: '2.30.x' }, + { value: '2.31.0', label: '2.31.x' }, + { value: '2.32.0', label: '2.32.x' }, + { value: '2.33.0', label: '2.33.x' }, + { value: '2.34.0', label: '2.34.x' }, + { value: '2.35.0', label: '2.35.x' }, + { value: '2.36.0', label: '2.36.x' }, + { value: '2.37.0', label: '2.37.x' }, + { value: '2.38.0', label: '2.38.x' }, + { value: '2.39.0', label: '2.39.x' }, + { value: '2.40.0', label: '2.40.x' }, + { value: '2.41.0', label: '2.41.x' }, + { value: '2.42.0', label: '2.42.x' }, + { value: '2.43.0', label: '2.43.x' }, + { value: '2.44.0', label: '2.44.x' }, + { value: '2.45.0', label: '2.45.x' }, + { value: '2.46.0', label: '2.46.x' }, + { value: '2.47.0', label: '2.47.x' }, + { value: '2.48.0', label: '2.48.x' }, + { value: '2.49.0', label: '2.49.x' }, + { value: '2.50.0', label: '2.50.x' }, + + // This value will be returned for future versions of prometheus until we add new entries to this object + { value: '2.50.1', label: '> 2.50.x' }, + ], + Mimir: [ + { value: undefined, label: 'Please select' }, + { value: '2.0.0', label: '2.0.x' }, + { value: '2.1.0', label: '2.1.x' }, + { value: '2.2.0', label: '2.2.x' }, + { value: '2.3.0', label: '2.3.x' }, + { value: '2.4.0', label: '2.4.x' }, + { value: '2.5.0', label: '2.5.x' }, + { value: '2.6.0', label: '2.6.x' }, + { value: '2.7.0', label: '2.7.x' }, + { value: '2.8.0', label: '2.8.x' }, + { value: '2.9.0', label: '2.9.x' }, + { value: '2.9.1', label: '> 2.9.x' }, + ], + Thanos: [ + { value: undefined, label: 'Please select' }, + { value: '0.0.0', label: '< 0.16.x' }, + { value: '0.16.0', label: '0.16.x' }, + { value: '0.17.0', label: '0.17.x' }, + { value: '0.18.0', label: '0.18.x' }, + { value: '0.19.0', label: '0.19.x' }, + { value: '0.20.0', label: '0.20.x' }, + { value: '0.21.0', label: '0.21.x' }, + { value: '0.22.0', label: '0.22.x' }, + { value: '0.23.0', label: '0.23.x' }, + { value: '0.24.0', label: '0.24.x' }, + { value: '0.25.0', label: '0.25.x' }, + { value: '0.26.0', label: '0.26.x' }, + { value: '0.27.0', label: '0.27.x' }, + { value: '0.28.0', label: '0.28.x' }, + { value: '0.29.0', label: '0.29.x' }, + { value: '0.30.0', label: '0.30.x' }, + { value: '0.31.0', label: '0.31.x' }, + { value: '0.31.1', label: '> 0.31.x' }, + ], + Cortex: [ + { value: undefined, label: 'Please select' }, + { value: '0.0.0', label: '< 1.0.0' }, + { value: '1.0.0', label: '1.0.0' }, + { value: '1.1.0', label: '1.1.x' }, + { value: '1.2.0', label: '1.2.x' }, + { value: '1.3.0', label: '1.3.x' }, + { value: '1.4.0', label: '1.4.x' }, + { value: '1.5.0', label: '1.5.x' }, + { value: '1.6.0', label: '1.6.x' }, + { value: '1.7.0', label: '1.7.x' }, + { value: '1.8.0', label: '1.8.x' }, + { value: '1.9.0', label: '1.9.x' }, + { value: '1.10.0', label: '1.10.x' }, + { value: '1.11.0', label: '1.11.x' }, + { value: '1.13.0', label: '1.13.x' }, + { value: '1.14.0', label: '> 1.13.x' }, + ], +}; diff --git a/packages/grafana-prometheus/src/configuration/PromSettings.test.tsx b/packages/grafana-prometheus/src/configuration/PromSettings.test.tsx new file mode 100644 index 0000000000000..a030646ccdb31 --- /dev/null +++ b/packages/grafana-prometheus/src/configuration/PromSettings.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import React, { SyntheticEvent } from 'react'; + +import { SelectableValue } from '@grafana/data'; + +import { getValueFromEventItem, PromSettings } from './PromSettings'; +import { createDefaultConfigOptions } from './mocks'; + +describe('PromSettings', () => { + describe('getValueFromEventItem', () => { + describe('when called with undefined', () => { + it('then it should return empty string', () => { + const result = getValueFromEventItem( + undefined as unknown as SyntheticEvent<HTMLInputElement> | SelectableValue<string> + ); + expect(result).toEqual(''); + }); + }); + + describe('when called with an input event', () => { + it('then it should return value from currentTarget', () => { + const value = 'An input value'; + const result = getValueFromEventItem({ currentTarget: { value } }); + expect(result).toEqual(value); + }); + }); + + describe('when called with a select event', () => { + it('then it should return value', () => { + const value = 'A select value'; + const result = getValueFromEventItem({ value }); + expect(result).toEqual(value); + }); + }); + }); + + describe('PromSettings component', () => { + const defaultProps = createDefaultConfigOptions(); + + it('should show POST httpMethod if no httpMethod', () => { + const options = defaultProps; + options.url = ''; + options.jsonData.httpMethod = ''; + + render(<PromSettings onOptionsChange={() => {}} options={options} />); + expect(screen.getByText('POST')).toBeInTheDocument(); + }); + it('should show POST httpMethod if POST httpMethod is configured', () => { + const options = defaultProps; + options.url = 'test_url'; + options.jsonData.httpMethod = 'POST'; + + render(<PromSettings onOptionsChange={() => {}} options={options} />); + expect(screen.getByText('POST')).toBeInTheDocument(); + }); + it('should show GET httpMethod if GET httpMethod is configured', () => { + const options = defaultProps; + options.url = 'test_url'; + options.jsonData.httpMethod = 'GET'; + + render(<PromSettings onOptionsChange={() => {}} options={options} />); + expect(screen.getByText('GET')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/grafana-prometheus/src/configuration/PromSettings.tsx b/packages/grafana-prometheus/src/configuration/PromSettings.tsx new file mode 100644 index 0000000000000..d2b01bfc19d71 --- /dev/null +++ b/packages/grafana-prometheus/src/configuration/PromSettings.tsx @@ -0,0 +1,478 @@ +import React, { SyntheticEvent, useState } from 'react'; + +import { + DataSourcePluginOptionsEditorProps, + onUpdateDatasourceJsonDataOptionChecked, + SelectableValue, + updateDatasourcePluginJsonDataOption, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { ConfigSubSection } from '@grafana/experimental'; +import { InlineField, Input, Select, Switch, useTheme2 } from '@grafana/ui'; + +import { QueryEditorMode } from '../querybuilder/shared/types'; +import { defaultPrometheusQueryOverlapWindow } from '../querycache/QueryCache'; +import { PromApplication, PrometheusCacheLevel, PromOptions } from '../types'; + +import { docsTip, overhaulStyles, PROM_CONFIG_LABEL_WIDTH, validateInput } from './ConfigEditor'; +import { ExemplarsSettings } from './ExemplarsSettings'; +import { PromFlavorVersions } from './PromFlavorVersions'; + +const httpOptions = [ + { value: 'POST', label: 'POST' }, + { value: 'GET', label: 'GET' }, +]; + +const editorOptions = [ + { value: QueryEditorMode.Builder, label: 'Builder' }, + { value: QueryEditorMode.Code, label: 'Code' }, +]; + +const cacheValueOptions = [ + { value: PrometheusCacheLevel.Low, label: 'Low' }, + { value: PrometheusCacheLevel.Medium, label: 'Medium' }, + { value: PrometheusCacheLevel.High, label: 'High' }, + { value: PrometheusCacheLevel.None, label: 'None' }, +]; + +type PrometheusSelectItemsType = Array<{ value: PromApplication; label: PromApplication }>; + +const prometheusFlavorSelectItems: PrometheusSelectItemsType = [ + { value: PromApplication.Prometheus, label: PromApplication.Prometheus }, + { value: PromApplication.Cortex, label: PromApplication.Cortex }, + { value: PromApplication.Mimir, label: PromApplication.Mimir }, + { value: PromApplication.Thanos, label: PromApplication.Thanos }, +]; + +type Props = Pick<DataSourcePluginOptionsEditorProps<PromOptions>, 'options' | 'onOptionsChange'>; + +// single duration input +export const DURATION_REGEX = /^$|^\d+(ms|[Mwdhmsy])$/; + +// multiple duration input +export const MULTIPLE_DURATION_REGEX = /(\d+)(.+)/; + +const durationError = 'Value is not valid, you can use number with time unit specifier: y, M, w, d, h, m, s'; + +export const PromSettings = (props: Props) => { + const { options, onOptionsChange } = props; + + // We are explicitly adding httpMethod so, it is correctly displayed in dropdown. + // This way, it is more predictable for users. + if (!options.jsonData.httpMethod) { + options.jsonData.httpMethod = 'POST'; + } + + const theme = useTheme2(); + const styles = overhaulStyles(theme); + + type ValidDuration = { + timeInterval: string; + queryTimeout: string; + incrementalQueryOverlapWindow: string; + }; + + const [validDuration, updateValidDuration] = useState<ValidDuration>({ + timeInterval: '', + queryTimeout: '', + incrementalQueryOverlapWindow: '', + }); + + return ( + <> + <ConfigSubSection title="Interval behaviour" className={styles.container}> + <div className="gf-form-group"> + {/* Scrape interval */} + <div className="gf-form-inline"> + <div className="gf-form"> + <InlineField + label="Scrape interval" + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={ + <> + This interval is how frequently Prometheus scrapes targets. Set this to the typical scrape and + evaluation interval configured in your Prometheus config file. If you set this to a greater value + than your Prometheus config file interval, Grafana will evaluate the data according to this interval + and you will see less data points. Defaults to 15s. {docsTip()} + </> + } + interactive={true} + disabled={options.readOnly} + > + <> + <Input + className="width-20" + value={options.jsonData.timeInterval} + spellCheck={false} + placeholder="15s" + onChange={onChangeHandler('timeInterval', options, onOptionsChange)} + onBlur={(e) => + updateValidDuration({ + ...validDuration, + timeInterval: e.currentTarget.value, + }) + } + data-testid={selectors.components.DataSource.Prometheus.configPage.scrapeInterval} + /> + {validateInput(validDuration.timeInterval, DURATION_REGEX, durationError)} + </> + </InlineField> + </div> + </div> + {/* Query Timeout */} + <div className="gf-form-inline"> + <div className="gf-form"> + <InlineField + label="Query timeout" + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={<>Set the Prometheus query timeout. {docsTip()}</>} + interactive={true} + disabled={options.readOnly} + > + <> + <Input + className="width-20" + value={options.jsonData.queryTimeout} + onChange={onChangeHandler('queryTimeout', options, onOptionsChange)} + spellCheck={false} + placeholder="60s" + onBlur={(e) => + updateValidDuration({ + ...validDuration, + queryTimeout: e.currentTarget.value, + }) + } + data-testid={selectors.components.DataSource.Prometheus.configPage.queryTimeout} + /> + {validateInput(validDuration.queryTimeout, DURATION_REGEX, durationError)} + </> + </InlineField> + </div> + </div> + </div> + </ConfigSubSection> + + <ConfigSubSection title="Query editor" className={styles.container}> + <div className="gf-form-group"> + <div className="gf-form"> + <InlineField + label="Default editor" + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={<>Set default editor option for all users of this data source. {docsTip()}</>} + interactive={true} + disabled={options.readOnly} + > + <Select + aria-label={`Default Editor (Code or Builder)`} + options={editorOptions} + value={ + editorOptions.find((o) => o.value === options.jsonData.defaultEditor) ?? + editorOptions.find((o) => o.value === QueryEditorMode.Builder) + } + onChange={onChangeHandler('defaultEditor', options, onOptionsChange)} + width={40} + data-testid={selectors.components.DataSource.Prometheus.configPage.defaultEditor} + /> + </InlineField> + </div> + <div className="gf-form"> + <InlineField + labelWidth={PROM_CONFIG_LABEL_WIDTH} + label="Disable metrics lookup" + tooltip={ + <> + Checking this option will disable the metrics chooser and metric/label support in the query + field's autocomplete. This helps if you have performance issues with bigger Prometheus instances.{' '} + {docsTip()} + </> + } + interactive={true} + disabled={options.readOnly} + className={styles.switchField} + > + <Switch + value={options.jsonData.disableMetricsLookup ?? false} + onChange={onUpdateDatasourceJsonDataOptionChecked(props, 'disableMetricsLookup')} + id={selectors.components.DataSource.Prometheus.configPage.disableMetricLookup} + /> + </InlineField> + </div> + </div> + </ConfigSubSection> + + <ConfigSubSection title="Performance" className={styles.container}> + {!options.jsonData.prometheusType && !options.jsonData.prometheusVersion && options.readOnly && ( + <div className={styles.versionMargin}> + For more information on configuring prometheus type and version in data sources, see the{' '} + <a + className={styles.textUnderline} + href="https://grafana.com/docs/grafana/latest/administration/provisioning/" + > + provisioning documentation + </a> + . + </div> + )} + <div className="gf-form-group"> + <div className="gf-form-inline"> + <div className="gf-form"> + <InlineField + label="Prometheus type" + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={ + <> + {/* , and attempt to detect the version */} + Set this to the type of your prometheus database, e.g. Prometheus, Cortex, Mimir or Thanos. Changing + this field will save your current settings. Certain types of Prometheus supports or does not support + various APIs. For example, some types support regex matching for label queries to improve + performance. Some types have an API for metadata. If you set this incorrectly you may experience odd + behavior when querying metrics and labels. Please check your Prometheus documentation to ensure you + enter the correct type. {docsTip()} + </> + } + interactive={true} + disabled={options.readOnly} + > + <Select + aria-label="Prometheus type" + options={prometheusFlavorSelectItems} + value={prometheusFlavorSelectItems.find((o) => o.value === options.jsonData.prometheusType)} + onChange={onChangeHandler('prometheusType', options, onOptionsChange)} + width={40} + data-testid={selectors.components.DataSource.Prometheus.configPage.prometheusType} + /> + </InlineField> + </div> + </div> + <div className="gf-form-inline"> + {options.jsonData.prometheusType && ( + <div className="gf-form"> + <InlineField + label={`${options.jsonData.prometheusType} version`} + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={ + <> + Use this to set the version of your {options.jsonData.prometheusType} instance if it is not + automatically configured. {docsTip()} + </> + } + interactive={true} + disabled={options.readOnly} + > + <Select + aria-label={`${options.jsonData.prometheusType} type`} + options={PromFlavorVersions[options.jsonData.prometheusType]} + value={PromFlavorVersions[options.jsonData.prometheusType]?.find( + (o) => o.value === options.jsonData.prometheusVersion + )} + onChange={onChangeHandler('prometheusVersion', options, onOptionsChange)} + width={40} + data-testid={selectors.components.DataSource.Prometheus.configPage.prometheusVersion} + /> + </InlineField> + </div> + )} + </div> + + <div className="gf-form-inline"> + <div className="gf-form max-width-30"> + <InlineField + label="Cache level" + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={ + <> + Sets the browser caching level for editor queries. Higher cache settings are recommended for high + cardinality data sources. + </> + } + interactive={true} + disabled={options.readOnly} + > + <Select + width={40} + onChange={onChangeHandler('cacheLevel', options, onOptionsChange)} + options={cacheValueOptions} + value={ + cacheValueOptions.find((o) => o.value === options.jsonData.cacheLevel) ?? PrometheusCacheLevel.Low + } + data-testid={selectors.components.DataSource.Prometheus.configPage.cacheLevel} + /> + </InlineField> + </div> + </div> + + <div className="gf-form-inline"> + <div className="gf-form max-width-30"> + <InlineField + label="Incremental querying (beta)" + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={ + <> + This feature will change the default behavior of relative queries to always request fresh data from + the prometheus instance, instead query results will be cached, and only new records are requested. + Turn this on to decrease database and network load. + </> + } + interactive={true} + className={styles.switchField} + disabled={options.readOnly} + > + <Switch + value={options.jsonData.incrementalQuerying ?? false} + onChange={onUpdateDatasourceJsonDataOptionChecked(props, 'incrementalQuerying')} + id={selectors.components.DataSource.Prometheus.configPage.incrementalQuerying} + /> + </InlineField> + </div> + </div> + + <div className="gf-form-inline"> + {options.jsonData.incrementalQuerying && ( + <InlineField + label="Query overlap window" + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={ + <> + Set a duration like 10m or 120s or 0s. Default of 10 minutes. This duration will be added to the + duration of each incremental request. + </> + } + interactive={true} + disabled={options.readOnly} + > + <> + <Input + onBlur={(e) => + updateValidDuration({ + ...validDuration, + incrementalQueryOverlapWindow: e.currentTarget.value, + }) + } + className="width-20" + value={options.jsonData.incrementalQueryOverlapWindow ?? defaultPrometheusQueryOverlapWindow} + onChange={onChangeHandler('incrementalQueryOverlapWindow', options, onOptionsChange)} + spellCheck={false} + data-testid={selectors.components.DataSource.Prometheus.configPage.queryOverlapWindow} + /> + {validateInput(validDuration.incrementalQueryOverlapWindow, MULTIPLE_DURATION_REGEX, durationError)} + </> + </InlineField> + )} + </div> + + <div className="gf-form-inline"> + <div className="gf-form max-width-30"> + <InlineField + label="Disable recording rules (beta)" + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={<>This feature will disable recording rules Turn this on to improve dashboard performance</>} + interactive={true} + className={styles.switchField} + disabled={options.readOnly} + > + <Switch + value={options.jsonData.disableRecordingRules ?? false} + onChange={onUpdateDatasourceJsonDataOptionChecked(props, 'disableRecordingRules')} + id={selectors.components.DataSource.Prometheus.configPage.disableRecordingRules} + /> + </InlineField> + </div> + </div> + </div> + </ConfigSubSection> + + <ConfigSubSection title="Other" className={styles.container}> + <div className="gf-form-group"> + <div className="gf-form-inline"> + <div className="gf-form max-width-30"> + <InlineField + label="Custom query parameters" + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={ + <> + Add custom parameters to the Prometheus query URL. For example timeout, partial_response, dedup, or + max_source_resolution. Multiple parameters should be concatenated together with an ‘&’. {docsTip()} + </> + } + interactive={true} + disabled={options.readOnly} + > + <Input + className="width-20" + value={options.jsonData.customQueryParameters} + onChange={onChangeHandler('customQueryParameters', options, onOptionsChange)} + spellCheck={false} + placeholder="Example: max_source_resolution=5m&timeout=10" + data-testid={selectors.components.DataSource.Prometheus.configPage.customQueryParameters} + /> + </InlineField> + </div> + </div> + <div className="gf-form-inline"> + {/* HTTP Method */} + <div className="gf-form"> + <InlineField + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={ + <> + You can use either POST or GET HTTP method to query your Prometheus data source. POST is the + recommended method as it allows bigger queries. Change this to GET if you have a Prometheus version + older than 2.1 or if POST requests are restricted in your network. {docsTip()} + </> + } + interactive={true} + label="HTTP method" + disabled={options.readOnly} + > + <Select + width={40} + aria-label="Select HTTP method" + options={httpOptions} + value={httpOptions.find((o) => o.value === options.jsonData.httpMethod)} + onChange={onChangeHandler('httpMethod', options, onOptionsChange)} + data-testid={selectors.components.DataSource.Prometheus.configPage.httpMethod} + /> + </InlineField> + </div> + </div> + </div> + </ConfigSubSection> + + <ExemplarsSettings + options={options.jsonData.exemplarTraceIdDestinations} + onChange={(exemplarOptions) => + updateDatasourcePluginJsonDataOption( + { onOptionsChange, options }, + 'exemplarTraceIdDestinations', + exemplarOptions + ) + } + disabled={options.readOnly} + /> + </> + ); +}; + +export const getValueFromEventItem = (eventItem: SyntheticEvent<HTMLInputElement> | SelectableValue<string>) => { + if (!eventItem) { + return ''; + } + + if ('currentTarget' in eventItem) { + return eventItem.currentTarget.value; + } + + return eventItem.value; +}; + +const onChangeHandler = + (key: keyof PromOptions, options: Props['options'], onOptionsChange: Props['onOptionsChange']) => + (eventItem: SyntheticEvent<HTMLInputElement> | SelectableValue<string>) => { + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + [key]: getValueFromEventItem(eventItem), + }, + }); + }; diff --git a/packages/grafana-prometheus/src/configuration/mocks.ts b/packages/grafana-prometheus/src/configuration/mocks.ts new file mode 100644 index 0000000000000..dc52090298b14 --- /dev/null +++ b/packages/grafana-prometheus/src/configuration/mocks.ts @@ -0,0 +1,14 @@ +import { DataSourceSettings } from '@grafana/data'; + +import { getMockDataSource } from '../gcopypaste/app/features/datasources/__mocks__/dataSourcesMocks'; +import { PromOptions } from '../types'; + +export function createDefaultConfigOptions(): DataSourceSettings<PromOptions> { + return getMockDataSource<PromOptions>({ + jsonData: { + timeInterval: '1m', + queryTimeout: '1m', + httpMethod: 'GET', + }, + }); +} diff --git a/packages/grafana-prometheus/src/dashboards/grafana_stats.json b/packages/grafana-prometheus/src/dashboards/grafana_stats.json new file mode 100644 index 0000000000000..a121de7fe5a8e --- /dev/null +++ b/packages/grafana-prometheus/src/dashboards/grafana_stats.json @@ -0,0 +1,1187 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "8.1.0-pre" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "stat", + "name": "Stat", + "version": "" + }, + { + "type": "panel", + "id": "table-old", + "name": "Table (old)", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Metrics about Grafana", + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "links": [ + { + "icon": "external link", + "tags": [], + "targetBlank": true, + "title": "Available metrics", + "type": "link", + "url": "/metrics" + }, + { + "icon": "external link", + "tags": [], + "targetBlank": true, + "title": "Grafana docs", + "type": "link", + "url": "https://grafana.com/docs/grafana/latest/" + }, + { + "icon": "external link", + "tags": [], + "targetBlank": true, + "title": "Prometheus docs", + "type": "link", + "url": "http://prometheus.io/docs/introduction/overview/" + } + ], + "panels": [ + { + "cacheTimeout": null, + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 0, + "mappings": [ + { + "options": { + "0": { + "text": ":(" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "rgba(222, 3, 3, 0.9)", + "value": null + }, + { + "color": "rgb(234, 245, 234)", + "value": 1 + }, + { + "color": "rgb(235, 244, 235)", + "value": 10000 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 5, + "x": 0, + "y": 0 + }, + "id": 4, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": ["mean"], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "up{job=\"grafana\"}", + "format": "time_series", + "instant": true, + "intervalFactor": 2, + "refId": "A", + "step": 60 + } + ], + "title": "Active instances", + "type": "stat" + }, + { + "cacheTimeout": null, + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 5, + "x": 5, + "y": 0 + }, + "id": 8, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "none", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": ["mean"], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "grafana_stat_totals_dashboard", + "format": "time_series", + "instant": true, + "intervalFactor": 2, + "refId": "A", + "step": 60 + } + ], + "title": "Dashboard count", + "type": "stat" + }, + { + "cacheTimeout": null, + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 5, + "x": 10, + "y": 0 + }, + "id": 9, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "none", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": ["mean"], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "grafana_stat_total_users", + "format": "time_series", + "instant": true, + "intervalFactor": 2, + "refId": "A", + "step": 60 + } + ], + "title": "User count", + "type": "stat" + }, + { + "cacheTimeout": null, + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 5, + "x": 15, + "y": 0 + }, + "id": 10, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "none", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": ["mean"], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "grafana_stat_total_playlists", + "format": "time_series", + "instant": true, + "intervalFactor": 2, + "refId": "A", + "step": 60 + } + ], + "title": "Playlist count", + "type": "stat" + }, + { + "columns": [], + "datasource": "${DS_PROMETHEUS}", + "fontSize": "100%", + "gridPos": { + "h": 5, + "w": 4, + "x": 20, + "y": 0 + }, + "id": 17, + "links": [], + "pageSize": null, + "scroll": false, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "alias": "Time", + "align": "auto", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "link": false, + "pattern": "Time", + "type": "hidden" + }, + { + "alias": "", + "align": "auto", + "colorMode": null, + "colors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"], + "decimals": 0, + "pattern": "/.*/", + "thresholds": [], + "type": "number", + "unit": "short" + } + ], + "targets": [ + { + "expr": "topk(1, grafana_info or grafana_build_info)", + "format": "time_series", + "instant": true, + "intervalFactor": 2, + "legendFormat": "{{version}}", + "refId": "A", + "step": 20 + } + ], + "title": "Grafana version", + "transform": "timeseries_to_rows", + "type": "table-old" + }, + { + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "400" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#447EBC", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "500" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#BF1B00", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 10, + "x": 0, + "y": 5 + }, + "id": 15, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "sum by (status_code) (irate(grafana_http_request_duration_seconds_count[5m]))", + "format": "time_series", + "intervalFactor": 3, + "legendFormat": "{{status_code}}", + "refId": "B", + "step": 15, + "target": "dev.grafana.cb-office.alerting.active_alerts" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "http status codes", + "type": "timeseries" + }, + { + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "400" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#447EBC", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "500" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#BF1B00", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 10, + "x": 10, + "y": 5 + }, + "id": 11, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "sum(irate(grafana_api_response_status_total[5m]))", + "format": "time_series", + "intervalFactor": 4, + "legendFormat": "api", + "refId": "A", + "step": 20 + }, + { + "expr": "sum(irate(grafana_proxy_response_status_total[5m]))", + "format": "time_series", + "intervalFactor": 4, + "legendFormat": "proxy", + "refId": "B", + "step": 20 + }, + { + "expr": "sum(irate(grafana_page_response_status_total[5m]))", + "format": "time_series", + "intervalFactor": 4, + "legendFormat": "web", + "refId": "C", + "step": 20 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Requests by routing group", + "type": "timeseries" + }, + { + "columns": [], + "datasource": "${DS_PROMETHEUS}", + "fontSize": "100%", + "gridPos": { + "h": 10, + "w": 4, + "x": 20, + "y": 5 + }, + "height": "", + "id": 12, + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "alias": "Time", + "align": "auto", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "link": false, + "pattern": "Time", + "type": "hidden" + }, + { + "alias": "", + "align": "auto", + "colorMode": null, + "colors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"], + "decimals": 0, + "pattern": "/.*/", + "thresholds": [], + "type": "number", + "unit": "short" + } + ], + "targets": [ + { + "expr": "sort(topk(8, sum by (handler) (grafana_http_request_duration_seconds_count)))", + "format": "time_series", + "instant": true, + "intervalFactor": 10, + "legendFormat": "{{handler}}", + "refId": "A", + "step": 100 + } + ], + "title": "Most used handlers", + "transform": "timeseries_to_rows", + "type": "table-old" + }, + { + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "alerting" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#890F02", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "ok" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#7EB26D", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 5, + "w": 12, + "x": 0, + "y": 15 + }, + "id": 6, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "increase(grafana_alerting_active_alerts[1m])", + "format": "time_series", + "intervalFactor": 3, + "legendFormat": "{{state}}", + "refId": "A", + "step": 15 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Grafana active alerts", + "type": "timeseries" + }, + { + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "alerting" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#890F02", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "alertname" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#BF1B00", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "firing alerts" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#BF1B00", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "ok" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#7EB26D", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 5, + "w": 12, + "x": 12, + "y": 15 + }, + "id": 18, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": " sum (ALERTS)", + "format": "time_series", + "intervalFactor": 3, + "legendFormat": "firing alerts", + "refId": "A", + "step": 15 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Prometheus alerts", + "type": "timeseries" + }, + { + "datasource": "${DS_PROMETHEUS}", + "description": "Aggregated over all Grafana nodes.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "avg gc duration" + }, + "properties": [ + { + "id": "unit", + "value": "decbytes" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "allocated memory" + }, + "properties": [ + { + "id": "unit", + "value": "decbytes" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "used memory" + }, + "properties": [ + { + "id": "unit", + "value": "decbytes" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "memory usage" + }, + "properties": [ + { + "id": "unit", + "value": "decbytes" + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 20 + }, + "id": 7, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "sum(go_goroutines{job=\"grafana\"})", + "format": "time_series", + "hide": false, + "intervalFactor": 4, + "legendFormat": "go routines", + "refId": "A", + "step": 8, + "target": "select metric", + "type": "timeserie" + }, + { + "expr": "sum(process_resident_memory_bytes{job=\"grafana\"})", + "format": "time_series", + "intervalFactor": 4, + "legendFormat": "memory usage", + "refId": "B", + "step": 8 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Grafana performance", + "type": "timeseries" + } + ], + "revision": "1.0", + "schemaVersion": 30, + "tags": ["grafana", "prometheus"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, + "timezone": "", + "title": "Grafana metrics", + "uid": "isFoa0z7k", + "version": 3 +} diff --git a/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json b/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json new file mode 100644 index 0000000000000..3d4cea64f05ba --- /dev/null +++ b/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json @@ -0,0 +1,1403 @@ +{ + "__inputs": [ + { + "name": "DS_GDEV-PROMETHEUS", + "label": "gdev-prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "8.1.0-pre" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "stat", + "name": "Stat", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 1, + "id": null, + "links": [ + { + "icon": "info", + "tags": [], + "targetBlank": true, + "title": "Grafana Docs", + "tooltip": "", + "type": "link", + "url": "https://grafana.com/docs/grafana/latest/" + }, + { + "icon": "info", + "tags": [], + "targetBlank": true, + "title": "Prometheus Docs", + "type": "link", + "url": "http://prometheus.io/docs/introduction/overview/" + } + ], + "panels": [ + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "prometheus" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#C15C17", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "{instance=\"localhost:9090\",job=\"prometheus\"}" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#CCA300", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 3, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "sum(irate(prometheus_tsdb_head_samples_appended_total{job=\"prometheus\"}[5m]))", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "samples", + "metric": "", + "refId": "A", + "step": 20 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Samples Appended", + "type": "timeseries" + }, + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 14, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "topk(5, max(scrape_duration_seconds) by (job))", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{job}}", + "metric": "", + "refId": "A", + "step": 20 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Scrape Duration", + "type": "timeseries" + }, + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 16, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "sum(process_resident_memory_bytes{job=\"prometheus\"})", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "p8s process resident memory", + "refId": "D", + "step": 20 + }, + { + "expr": "process_virtual_memory_bytes{job=\"prometheus\"}", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "virtual memory", + "refId": "C", + "step": 20 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Memory Profile", + "type": "timeseries" + }, + { + "cacheTimeout": null, + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "0": { + "text": "None" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "rgba(50, 172, 45, 0.97)", + "value": null + }, + { + "color": "rgba(237, 129, 40, 0.89)", + "value": 0.1 + }, + { + "color": "rgba(245, 54, 54, 0.9)", + "value": 1 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 37, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": ["max"], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "prometheus_tsdb_wal_corruptions_total{job=\"prometheus\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 60 + } + ], + "title": "WAL Corruptions", + "type": "stat" + }, + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 0, + "y": 6 + }, + "id": 29, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "sum(prometheus_tsdb_head_active_appenders{job=\"prometheus\"})", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "active_appenders", + "metric": "", + "refId": "A", + "step": 20 + }, + { + "expr": "sum(process_open_fds{job=\"prometheus\"})", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "open_fds", + "refId": "B", + "step": 20 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Active Appenders", + "type": "timeseries" + }, + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "prometheus" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F9BA8F", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "{instance=\"localhost:9090\",interval=\"5s\",job=\"prometheus\"}" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F9BA8F", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 6, + "y": 6 + }, + "id": 2, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "prometheus_tsdb_blocks_loaded{job=\"prometheus\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "blocks", + "refId": "A", + "step": 20 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Blocks Loaded", + "type": "timeseries" + }, + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 12, + "y": 6 + }, + "id": 33, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "prometheus_tsdb_head_chunks{job=\"prometheus\"}", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "chunks", + "refId": "A", + "step": 20 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Head Chunks", + "type": "timeseries" + }, + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "duration-p99" + }, + "properties": [ + { + "id": "unit", + "value": "s" + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 6 + }, + "id": 36, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "prometheus_tsdb_head_gc_duration_seconds{job=\"prometheus\",quantile=\"0.99\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "duration-p99", + "refId": "A", + "step": 20 + }, + { + "expr": "irate(prometheus_tsdb_head_gc_duration_seconds_count{job=\"prometheus\"}[5m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "collections", + "refId": "B", + "step": 20 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Head Block GC Activity", + "type": "timeseries" + }, + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "duration-p99" + }, + "properties": [ + { + "id": "unit", + "value": "s" + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 12 + }, + "id": 20, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "histogram_quantile(0.99, sum(rate(prometheus_tsdb_compaction_duration_bucket{job=\"prometheus\"}[5m])) by (le))", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "duration-{{p99}}", + "refId": "A", + "step": 20 + }, + { + "expr": "irate(prometheus_tsdb_compactions_total{job=\"prometheus\"}[5m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "compactions", + "refId": "B", + "step": 20 + }, + { + "expr": "irate(prometheus_tsdb_compactions_failed_total{job=\"prometheus\"}[5m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "failed", + "refId": "C", + "step": 20 + }, + { + "expr": "irate(prometheus_tsdb_compactions_triggered_total{job=\"prometheus\"}[5m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "triggered", + "refId": "D", + "step": 20 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Compaction Activity", + "type": "timeseries" + }, + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 8, + "y": 12 + }, + "id": 32, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "rate(prometheus_tsdb_reloads_total{job=\"prometheus\"}[5m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "reloads", + "refId": "A", + "step": 20 + }, + { + "expr": "rate(prometheus_tsdb_reloads_failures_total{job=\"prometheus\"}[5m])", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "failures", + "refId": "B", + "step": 20 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Reload Count", + "type": "timeseries" + }, + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 16, + "y": 12 + }, + "id": 38, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "prometheus_engine_query_duration_seconds{job=\"prometheus\", quantile=\"0.99\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{slice}}_p99", + "refId": "A", + "step": 20 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Query Durations", + "type": "timeseries" + }, + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 18 + }, + "id": 35, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "max(prometheus_rule_group_duration_seconds{job=\"prometheus\"}) by (quantile)", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{quantile}}", + "refId": "A", + "step": 10 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Rule Group Eval Duration", + "type": "timeseries" + }, + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 18 + }, + "id": 39, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "rate(prometheus_rule_group_iterations_missed_total{job=\"prometheus\"}[5m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "missed", + "refId": "B", + "step": 10 + }, + { + "expr": "rate(prometheus_rule_group_iterations_total{job=\"prometheus\"}[5m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "iterations", + "refId": "A", + "step": 10 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Rule Group Eval Activity", + "type": "timeseries" + } + ], + "refresh": "1m", + "revision": "1.0", + "schemaVersion": 30, + "tags": ["prometheus"], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "now": true, + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, + "timezone": "browser", + "title": "Prometheus 2.0 Stats", + "uid": "UDdpyzz7z", + "version": 1 +} diff --git a/packages/grafana-prometheus/src/dashboards/prometheus_stats.json b/packages/grafana-prometheus/src/dashboards/prometheus_stats.json new file mode 100644 index 0000000000000..9169006b89514 --- /dev/null +++ b/packages/grafana-prometheus/src/dashboards/prometheus_stats.json @@ -0,0 +1,834 @@ +{ + "__inputs": [ + { + "name": "DS_GDEV-PROMETHEUS", + "label": "gdev-prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "8.1.0-pre" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "stat", + "name": "Stat", + "version": "" + }, + { + "type": "panel", + "id": "text", + "name": "Text", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1624859749459, + "links": [ + { + "icon": "info", + "tags": [], + "targetBlank": true, + "title": "Grafana Docs", + "tooltip": "", + "type": "link", + "url": "https://grafana.com/docs/grafana/latest/" + }, + { + "icon": "info", + "tags": [], + "targetBlank": true, + "title": "Prometheus Docs", + "type": "link", + "url": "http://prometheus.io/docs/introduction/overview/" + } + ], + "panels": [ + { + "cacheTimeout": null, + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 1, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 5, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "none", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "(time() - process_start_time_seconds{job=\"prometheus\", instance=~\"$node\"})", + "intervalFactor": 2, + "refId": "A" + } + ], + "title": "Uptime", + "type": "stat" + }, + { + "cacheTimeout": null, + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "rgb(31, 120, 193)", + "mode": "fixed" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "rgba(50, 172, 45, 0.97)", + "value": null + }, + { + "color": "rgba(237, 129, 40, 0.89)", + "value": 1 + }, + { + "color": "rgba(245, 54, 54, 0.9)", + "value": 5 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 6, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "none", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "prometheus_local_storage_memory_series{instance=~\"$node\"}", + "intervalFactor": 2, + "refId": "A" + } + ], + "title": "Local Storage Memory Series", + "type": "stat" + }, + { + "cacheTimeout": null, + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "0": { + "text": "Empty" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "rgba(50, 172, 45, 0.97)", + "value": null + }, + { + "color": "rgba(237, 129, 40, 0.89)", + "value": 500 + }, + { + "color": "rgba(245, 54, 54, 0.9)", + "value": 4000 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 7, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "prometheus_local_storage_indexing_queue_length{instance=~\"$node\"}", + "intervalFactor": 2, + "refId": "A" + } + ], + "title": "Internal Storage Queue Length", + "type": "stat" + }, + { + "datasource": null, + "editable": true, + "error": false, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 9, + "links": [], + "options": { + "content": "<span style=\"font-family: 'Open Sans', 'Helvetica Neue', Helvetica; font-size: 25px;vertical-align: text-top;color: #bbbfc2;margin-left: 10px;\">Prometheus</span>\n\n<p style=\"margin-top: 10px;\">You're using Prometheus, an open-source systems monitoring and alerting toolkit originally built at SoundCloud. For more information, check out the <a href=\"https://grafana.com/\">Grafana</a> and <a href=\"http://prometheus.io/\">Prometheus</a> projects.</p>", + "mode": "html" + }, + "pluginVersion": "8.1.0-pre", + "style": {}, + "transparent": true, + "type": "text" + }, + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "prometheus" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#C15C17", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "{instance=\"localhost:9090\",job=\"prometheus\"}" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#C15C17", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 18, + "x": 0, + "y": 5 + }, + "id": 3, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "rate(prometheus_local_storage_ingested_samples_total{instance=~\"$node\"}[5m])", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{job}}", + "metric": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Samples ingested (rate-5m)", + "type": "timeseries" + }, + { + "datasource": null, + "editable": true, + "error": false, + "gridPos": { + "h": 6, + "w": 4, + "x": 18, + "y": 5 + }, + "id": 8, + "links": [], + "options": { + "content": "#### Samples Ingested\nThis graph displays the count of samples ingested by the Prometheus server, as measured over the last 5 minutes, per time series in the range vector. When troubleshooting an issue on IRC or GitHub, this is often the first stat requested by the Prometheus team. ", + "mode": "markdown" + }, + "pluginVersion": "8.1.0-pre", + "style": {}, + "transparent": true, + "type": "text" + }, + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "prometheus" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F9BA8F", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "{instance=\"localhost:9090\",interval=\"5s\",job=\"prometheus\"}" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F9BA8F", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 10, + "x": 0, + "y": 11 + }, + "id": 2, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "rate(prometheus_target_interval_length_seconds_count{instance=~\"$node\"}[5m])", + "intervalFactor": 2, + "legendFormat": "{{job}}", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Target Scrapes (last 5m)", + "type": "timeseries" + }, + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 10, + "y": 11 + }, + "id": 14, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "prometheus_target_interval_length_seconds{quantile!=\"0.01\", quantile!=\"0.05\",instance=~\"$node\"}", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{quantile}} ({{interval}})", + "metric": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Scrape Duration", + "type": "timeseries" + }, + { + "datasource": null, + "editable": true, + "error": false, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 11 + }, + "id": 11, + "links": [], + "options": { + "content": "#### Scrapes\nPrometheus scrapes metrics from instrumented jobs, either directly or via an intermediary push gateway for short-lived jobs. Target scrapes will show how frequently targets are scraped, as measured over the last 5 minutes, per time series in the range vector. Scrape Duration will show how long the scrapes are taking, with percentiles available as series. ", + "mode": "markdown" + }, + "pluginVersion": "8.1.0-pre", + "style": {}, + "transparent": true, + "type": "text" + }, + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 18, + "x": 0, + "y": 18 + }, + "id": 12, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "prometheus_evaluator_duration_seconds{quantile!=\"0.01\", quantile!=\"0.05\",instance=~\"$node\"}", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{quantile}}", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Rule Eval Duration", + "type": "timeseries" + }, + { + "datasource": null, + "editable": true, + "error": false, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 18 + }, + "id": 15, + "links": [], + "options": { + "content": "#### Rule Evaluation Duration\nThis graph panel plots the duration for all evaluations to execute. The 50th percentile, 90th percentile and 99th percentile are shown as three separate series to help identify outliers that may be skewing the data.", + "mode": "markdown" + }, + "pluginVersion": "8.1.0-pre", + "style": {}, + "transparent": true, + "type": "text" + } + ], + "refresh": false, + "revision": "1.0", + "schemaVersion": 30, + "tags": ["prometheus"], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "${DS_GDEV-PROMETHEUS}", + "definition": "", + "description": null, + "error": null, + "hide": 0, + "includeAll": false, + "label": "HOST:", + "multi": false, + "name": "node", + "options": [], + "query": { + "query": "label_values(prometheus_build_info, instance)", + "refId": "gdev-prometheus-node-Variable-Query" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "tagValuesQuery": "", + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": { + "now": true, + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, + "timezone": "browser", + "title": "Prometheus Stats", + "uid": "rpfmFFz7z", + "version": 2 +} diff --git a/packages/grafana-schema/src/raw/composable/prometheus/dataquery/x/PrometheusDataQuery_types.gen.ts b/packages/grafana-prometheus/src/dataquery.ts similarity index 74% rename from packages/grafana-schema/src/raw/composable/prometheus/dataquery/x/PrometheusDataQuery_types.gen.ts rename to packages/grafana-prometheus/src/dataquery.ts index 4b0ccc7fa7c61..8609fc789140f 100644 --- a/packages/grafana-schema/src/raw/composable/prometheus/dataquery/x/PrometheusDataQuery_types.gen.ts +++ b/packages/grafana-prometheus/src/dataquery.ts @@ -1,26 +1,13 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// public/app/plugins/gen.go -// Using jennies: -// TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny -// -// Run 'make gen-cue' from repository root to regenerate. - import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; - export enum QueryEditorMode { Builder = 'builder', Code = 'code', } -export type PromQueryFormat = ('time_series' | 'table' | 'heatmap'); +export type PromQueryFormat = 'time_series' | 'table' | 'heatmap'; -export interface PrometheusDataQuery extends common.DataQuery { +export interface Prometheus extends common.DataQuery { /** * Specifies which editor is being used to prepare the query. It can be "code" or "builder" */ @@ -54,4 +41,7 @@ export interface PrometheusDataQuery extends common.DataQuery { * Returns a Range vector, comprised of a set of time series containing a range of data points over time for each time series */ range?: boolean; + scope?: { + matchers: string; + }; } diff --git a/packages/grafana-prometheus/src/datasource.test.ts b/packages/grafana-prometheus/src/datasource.test.ts new file mode 100644 index 0000000000000..72f9c4572d8c2 --- /dev/null +++ b/packages/grafana-prometheus/src/datasource.test.ts @@ -0,0 +1,1296 @@ +import { cloneDeep } from 'lodash'; +import { lastValueFrom, of } from 'rxjs'; + +import { + AnnotationEvent, + AnnotationQueryRequest, + CoreApp, + CustomVariableModel, + DataQueryRequest, + DataSourceInstanceSettings, + dateTime, + LoadingState, + rangeUtil, + TimeRange, + VariableHide, +} from '@grafana/data'; +import { TemplateSrv } from '@grafana/runtime'; + +import { + alignRange, + extractRuleMappingFromGroups, + PrometheusDatasource, + prometheusRegularEscape, + prometheusSpecialRegexEscape, +} from './datasource'; +import PromQlLanguageProvider from './language_provider'; +import { PromApplication, PrometheusCacheLevel, PromOptions, PromQuery, PromQueryRequest } from './types'; + +const fetchMock = jest.fn().mockReturnValue(of(createDefaultPromResponse())); + +jest.mock('./metric_find_query'); +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getBackendSrv: () => ({ + fetch: fetchMock, + }), +})); + +const replaceMock = jest.fn().mockImplementation((a: string, ...rest: unknown[]) => a); + +const templateSrvStub = { + replace: replaceMock, +} as unknown as TemplateSrv; + +const fromSeconds = 1674500289215; +const toSeconds = 1674500349215; + +const mockTimeRangeOld: TimeRange = { + from: dateTime(1531468681), + to: dateTime(1531489712), + raw: { + from: '1531468681', + to: '1531489712', + }, +}; + +const mockTimeRange: TimeRange = { + from: dateTime(fromSeconds), + to: dateTime(toSeconds), + raw: { + from: fromSeconds.toString(), + to: toSeconds.toString(), + }, +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('PrometheusDatasource', () => { + let ds: PrometheusDatasource; + const instanceSettings = { + url: 'proxied', + id: 1, + uid: 'ABCDEF', + access: 'proxy', + user: 'test', + password: 'mupp', + jsonData: { + customQueryParameters: '', + cacheLevel: PrometheusCacheLevel.Low, + } as Partial<PromOptions>, + } as unknown as DataSourceInstanceSettings<PromOptions>; + + beforeEach(() => { + ds = new PrometheusDatasource(instanceSettings, templateSrvStub); + }); + + // Some functions are required by the parent datasource class to provide functionality such as ad-hoc filters, which requires the definition of the getTagKeys, and getTagValues functions + describe('Datasource contract', () => { + it('has function called getTagKeys', () => { + expect(Object.getOwnPropertyNames(Object.getPrototypeOf(ds))).toContain('getTagKeys'); + }); + it('has function called getTagValues', () => { + expect(Object.getOwnPropertyNames(Object.getPrototypeOf(ds))).toContain('getTagValues'); + }); + }); + + describe('Query', () => { + it('throws if using direct access', async () => { + const instanceSettings = { + url: 'proxied', + directUrl: 'direct', + user: 'test', + password: 'mupp', + access: 'direct', + jsonData: { + customQueryParameters: '', + prometheusVersion: '2.20.0', + prometheusType: PromApplication.Prometheus, + }, + } as unknown as DataSourceInstanceSettings<PromOptions>; + const range = { from: time({ seconds: 63 }), to: time({ seconds: 183 }) }; + const directDs = new PrometheusDatasource(instanceSettings, templateSrvStub); + + await expect( + lastValueFrom( + directDs.query( + createDataRequest( + [ + { + expr: '', + refId: 'A', + }, + { expr: '', refId: 'B' }, + ], + { app: CoreApp.Dashboard } + ) + ) + ) + ).rejects.toMatchObject({ message: expect.stringMatching('Browser access') }); + + // Cannot test because some other tests need "./metric_find_query" to be mocked and that prevents this to be + // tested. Checked manually that this ends up with throwing + // await expect(directDs.metricFindQuery('label_names(foo)')).rejects.toBeDefined(); + + await expect( + directDs.annotationQuery({ + range: { ...range, raw: range }, + rangeRaw: range, + // Should be DataModel but cannot import that here from the main app. Needs to be moved to package first. + dashboard: {}, + annotation: { + expr: 'metric', + name: 'test', + enable: true, + iconColor: '', + }, + }) + ).rejects.toMatchObject({ + message: expect.stringMatching('Browser access'), + }); + + const errorMock = jest.spyOn(console, 'error').mockImplementation(() => {}); + + await directDs.getTagKeys({ filters: [] }); + // Language provider currently catches and just logs the error + expect(errorMock).toHaveBeenCalledTimes(1); + + await expect(directDs.getTagValues({ filters: [], key: 'A' })).rejects.toMatchObject({ + message: expect.stringMatching('Browser access'), + }); + }); + }); + + describe('Datasource metadata requests', () => { + it('should perform a GET request with the default config', () => { + ds.metadataRequest('/foo', { bar: 'baz baz', foo: 'foo' }); + expect(fetchMock.mock.calls.length).toBe(1); + expect(fetchMock.mock.calls[0][0].method).toBe('GET'); + expect(fetchMock.mock.calls[0][0].url).toContain('bar=baz%20baz&foo=foo'); + }); + it('should still perform a GET request with the DS HTTP method set to POST and not POST-friendly endpoint', () => { + const postSettings = cloneDeep(instanceSettings); + postSettings.jsonData.httpMethod = 'POST'; + const promDs = new PrometheusDatasource(postSettings, templateSrvStub); + promDs.metadataRequest('/foo'); + expect(fetchMock.mock.calls.length).toBe(1); + expect(fetchMock.mock.calls[0][0].method).toBe('GET'); + }); + it('should try to perform a POST request with the DS HTTP method set to POST and POST-friendly endpoint', () => { + const postSettings = cloneDeep(instanceSettings); + postSettings.jsonData.httpMethod = 'POST'; + const promDs = new PrometheusDatasource(postSettings, templateSrvStub); + promDs.metadataRequest('api/v1/series', { bar: 'baz baz', foo: 'foo' }); + expect(fetchMock.mock.calls.length).toBe(1); + expect(fetchMock.mock.calls[0][0].method).toBe('POST'); + expect(fetchMock.mock.calls[0][0].url).not.toContain('bar=baz%20baz&foo=foo'); + expect(fetchMock.mock.calls[0][0].data).toEqual({ bar: 'baz baz', foo: 'foo' }); + }); + }); + + describe('customQueryParams', () => { + describe('with GET http method', () => { + const promDs = new PrometheusDatasource( + { ...instanceSettings, jsonData: { customQueryParameters: 'customQuery=123', httpMethod: 'GET' } }, + templateSrvStub + ); + + it('added to metadata request', () => { + promDs.metadataRequest('/foo'); + expect(fetchMock.mock.calls.length).toBe(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/datasources/uid/ABCDEF/resources/foo?customQuery=123'); + }); + }); + + describe('with POST http method', () => { + const promDs = new PrometheusDatasource( + { ...instanceSettings, jsonData: { customQueryParameters: 'customQuery=123', httpMethod: 'POST' } }, + templateSrvStub + ); + + it('added to metadata request with non-POST endpoint', () => { + promDs.metadataRequest('/foo'); + expect(fetchMock.mock.calls.length).toBe(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/datasources/uid/ABCDEF/resources/foo?customQuery=123'); + }); + + it('added to metadata request with POST endpoint', () => { + promDs.metadataRequest('/api/v1/labels'); + expect(fetchMock.mock.calls.length).toBe(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/datasources/uid/ABCDEF/resources/api/v1/labels'); + expect(fetchMock.mock.calls[0][0].data.customQuery).toBe('123'); + }); + }); + }); + + describe('When using adhoc filters', () => { + const DEFAULT_QUERY_EXPRESSION = 'metric{job="foo"} - metric'; + const target: PromQuery = { expr: DEFAULT_QUERY_EXPRESSION, refId: 'A' }; + + it('should not modify expression with no filters', () => { + const result = ds.createQuery( + target, + { interval: '15s', range: getMockTimeRange() } as DataQueryRequest<PromQuery>, + 0, + 0 + ); + expect(result).toMatchObject({ expr: DEFAULT_QUERY_EXPRESSION }); + }); + + it('should add filters to expression', () => { + const filters = [ + { + key: 'k1', + operator: '=', + value: 'v1', + }, + { + key: 'k2', + operator: '!=', + value: 'v2', + }, + ]; + const result = ds.createQuery( + target, + { interval: '15s', range: getMockTimeRange(), filters } as DataQueryRequest<PromQuery>, + 0, + 0 + ); + expect(result).toMatchObject({ expr: 'metric{job="foo", k1="v1", k2!="v2"} - metric{k1="v1", k2!="v2"}' }); + }); + + it('should add escaping if needed to regex filter expressions', () => { + const filters = [ + { + key: 'k1', + operator: '=~', + value: 'v.*', + }, + { + key: 'k2', + operator: '=~', + value: `v'.*`, + }, + ]; + + const result = ds.createQuery( + target, + { interval: '15s', range: getMockTimeRange(), filters } as DataQueryRequest<PromQuery>, + 0, + 0 + ); + expect(result).toMatchObject({ + expr: `metric{job="foo", k1=~"v.*", k2=~"v\\\\'.*"} - metric{k1=~"v.*", k2=~"v\\\\'.*"}`, + }); + }); + }); + + describe('Test query range snapping', () => { + it('test default 1 minute quantization', () => { + const dataSource = new PrometheusDatasource( + { + ...instanceSettings, + jsonData: { ...instanceSettings.jsonData, cacheLevel: PrometheusCacheLevel.Low }, + }, + templateSrvStub as unknown as TemplateSrv + ); + const quantizedRange = dataSource.getAdjustedInterval(mockTimeRange); + // For "1 minute" the window contains all the minutes, so a query from 1:11:09 - 1:12:09 becomes 1:11 - 1:13 + expect(parseInt(quantizedRange.end, 10) - parseInt(quantizedRange.start, 10)).toBe(120); + }); + + it('test 10 minute quantization', () => { + const dataSource = new PrometheusDatasource( + { + ...instanceSettings, + jsonData: { ...instanceSettings.jsonData, cacheLevel: PrometheusCacheLevel.Medium }, + }, + templateSrvStub as unknown as TemplateSrv + ); + const quantizedRange = dataSource.getAdjustedInterval(mockTimeRange); + expect(parseInt(quantizedRange.end, 10) - parseInt(quantizedRange.start, 10)).toBe(600); + }); + + it('test 60 minute quantization', () => { + const dataSource = new PrometheusDatasource( + { + ...instanceSettings, + jsonData: { ...instanceSettings.jsonData, cacheLevel: PrometheusCacheLevel.High }, + }, + templateSrvStub as unknown as TemplateSrv + ); + const quantizedRange = dataSource.getAdjustedInterval(mockTimeRange); + expect(parseInt(quantizedRange.end, 10) - parseInt(quantizedRange.start, 10)).toBe(3600); + }); + + it('test quantization turned off', () => { + const dataSource = new PrometheusDatasource( + { + ...instanceSettings, + jsonData: { ...instanceSettings.jsonData, cacheLevel: PrometheusCacheLevel.None }, + }, + templateSrvStub as unknown as TemplateSrv + ); + const quantizedRange = dataSource.getAdjustedInterval(mockTimeRange); + expect(parseInt(quantizedRange.end, 10) - parseInt(quantizedRange.start, 10)).toBe( + (toSeconds - fromSeconds) / 1000 + ); + }); + }); + + describe('alignRange', () => { + it('does not modify already aligned intervals with perfect step', () => { + const range = alignRange(0, 3, 3, 0); + expect(range.start).toEqual(0); + expect(range.end).toEqual(3); + }); + + it('does modify end-aligned intervals to reflect number of steps possible', () => { + const range = alignRange(1, 6, 3, 0); + expect(range.start).toEqual(0); + expect(range.end).toEqual(6); + }); + + it('does align intervals that are a multiple of steps', () => { + const range = alignRange(1, 4, 3, 0); + expect(range.start).toEqual(0); + expect(range.end).toEqual(3); + }); + + it('does align intervals that are not a multiple of steps', () => { + const range = alignRange(1, 5, 3, 0); + expect(range.start).toEqual(0); + expect(range.end).toEqual(3); + }); + + it('does align intervals with local midnight -UTC offset', () => { + //week range, location 4+ hours UTC offset, 24h step time + const range = alignRange(4 * 60 * 60, (7 * 24 + 4) * 60 * 60, 24 * 60 * 60, -4 * 60 * 60); //04:00 UTC, 7 day range + expect(range.start).toEqual(4 * 60 * 60); + expect(range.end).toEqual((7 * 24 + 4) * 60 * 60); + }); + + it('does align intervals with local midnight +UTC offset', () => { + //week range, location 4- hours UTC offset, 24h step time + const range = alignRange(20 * 60 * 60, (8 * 24 - 4) * 60 * 60, 24 * 60 * 60, 4 * 60 * 60); //20:00 UTC on day1, 7 days later is 20:00 on day8 + expect(range.start).toEqual(20 * 60 * 60); + expect(range.end).toEqual((8 * 24 - 4) * 60 * 60); + }); + }); + + describe('extractRuleMappingFromGroups()', () => { + it('returns empty mapping for no rule groups', () => { + expect(extractRuleMappingFromGroups([])).toEqual({}); + }); + + it('returns a mapping for recording rules only', () => { + const groups = [ + { + rules: [ + { + name: 'HighRequestLatency', + query: 'job:request_latency_seconds:mean5m{job="myjob"} > 0.5', + type: 'alerting', + }, + { + name: 'job:http_inprogress_requests:sum', + query: 'sum(http_inprogress_requests) by (job)', + type: 'recording', + }, + ], + file: '/rules.yaml', + interval: 60, + name: 'example', + }, + ]; + const mapping = extractRuleMappingFromGroups(groups); + expect(mapping).toEqual({ 'job:http_inprogress_requests:sum': 'sum(http_inprogress_requests) by (job)' }); + }); + }); + + describe('Prometheus regular escaping', () => { + it('should not escape non-string', () => { + expect(prometheusRegularEscape(12)).toEqual(12); + }); + + it('should not escape simple string', () => { + expect(prometheusRegularEscape('cryptodepression')).toEqual('cryptodepression'); + }); + + it("should escape '", () => { + expect(prometheusRegularEscape("looking'glass")).toEqual("looking\\\\'glass"); + }); + + it('should escape \\', () => { + expect(prometheusRegularEscape('looking\\glass')).toEqual('looking\\\\glass'); + }); + + it('should escape multiple characters', () => { + expect(prometheusRegularEscape("'looking'glass'")).toEqual("\\\\'looking\\\\'glass\\\\'"); + }); + + it('should escape multiple different characters', () => { + expect(prometheusRegularEscape("'loo\\king'glass'")).toEqual("\\\\'loo\\\\king\\\\'glass\\\\'"); + }); + }); + + describe('Prometheus regexes escaping', () => { + it('should not escape simple string', () => { + expect(prometheusSpecialRegexEscape('cryptodepression')).toEqual('cryptodepression'); + }); + + it('should escape $^*+?.()|\\', () => { + expect(prometheusSpecialRegexEscape("looking'glass")).toEqual("looking\\\\'glass"); + expect(prometheusSpecialRegexEscape('looking{glass')).toEqual('looking\\\\{glass'); + expect(prometheusSpecialRegexEscape('looking}glass')).toEqual('looking\\\\}glass'); + expect(prometheusSpecialRegexEscape('looking[glass')).toEqual('looking\\\\[glass'); + expect(prometheusSpecialRegexEscape('looking]glass')).toEqual('looking\\\\]glass'); + expect(prometheusSpecialRegexEscape('looking$glass')).toEqual('looking\\\\$glass'); + expect(prometheusSpecialRegexEscape('looking^glass')).toEqual('looking\\\\^glass'); + expect(prometheusSpecialRegexEscape('looking*glass')).toEqual('looking\\\\*glass'); + expect(prometheusSpecialRegexEscape('looking+glass')).toEqual('looking\\\\+glass'); + expect(prometheusSpecialRegexEscape('looking?glass')).toEqual('looking\\\\?glass'); + expect(prometheusSpecialRegexEscape('looking.glass')).toEqual('looking\\\\.glass'); + expect(prometheusSpecialRegexEscape('looking(glass')).toEqual('looking\\\\(glass'); + expect(prometheusSpecialRegexEscape('looking)glass')).toEqual('looking\\\\)glass'); + expect(prometheusSpecialRegexEscape('looking\\glass')).toEqual('looking\\\\\\\\glass'); + expect(prometheusSpecialRegexEscape('looking|glass')).toEqual('looking\\\\|glass'); + }); + + it('should escape multiple special characters', () => { + expect(prometheusSpecialRegexEscape('+looking$glass?')).toEqual('\\\\+looking\\\\$glass\\\\?'); + }); + }); + + describe('When interpolating variables', () => { + let customVariable: CustomVariableModel; + beforeEach(() => { + customVariable = { + id: '', + global: false, + multi: false, + includeAll: false, + allValue: null, + query: '', + options: [], + current: {}, + name: '', + type: 'custom', + error: null, + rootStateKey: '', + state: LoadingState.Done, + description: '', + label: undefined, + hide: VariableHide.dontHide, + skipUrlSync: false, + index: -1, + }; + }); + + describe('and value is a string', () => { + it('should only escape single quotes', () => { + expect(ds.interpolateQueryExpr("abc'$^*{}[]+?.()|", customVariable)).toEqual("abc\\\\'$^*{}[]+?.()|"); + }); + }); + + describe('and value is a number', () => { + it('should return a number', () => { + expect(ds.interpolateQueryExpr(1000 as unknown as string, customVariable)).toEqual(1000); + }); + }); + + describe('and variable allows multi-value', () => { + beforeEach(() => { + customVariable.multi = true; + }); + + it('should regex escape values if the value is a string', () => { + expect(ds.interpolateQueryExpr('looking*glass', customVariable)).toEqual('looking\\\\*glass'); + }); + + it('should return pipe separated values if the value is an array of strings', () => { + expect(ds.interpolateQueryExpr(['a|bc', 'de|f'], customVariable)).toEqual('(a\\\\|bc|de\\\\|f)'); + }); + + it('should return 1 regex escaped value if there is just 1 value in an array of strings', () => { + expect(ds.interpolateQueryExpr(['looking*glass'], customVariable)).toEqual('looking\\\\*glass'); + }); + }); + + describe('and variable allows all', () => { + beforeEach(() => { + customVariable.includeAll = true; + }); + + it('should regex escape values if the array is a string', () => { + expect(ds.interpolateQueryExpr('looking*glass', customVariable)).toEqual('looking\\\\*glass'); + }); + + it('should return pipe separated values if the value is an array of strings', () => { + expect(ds.interpolateQueryExpr(['a|bc', 'de|f'], customVariable)).toEqual('(a\\\\|bc|de\\\\|f)'); + }); + + it('should return 1 regex escaped value if there is just 1 value in an array of strings', () => { + expect(ds.interpolateQueryExpr(['looking*glass'], customVariable)).toEqual('looking\\\\*glass'); + }); + }); + }); + + describe('interpolateVariablesInQueries', () => { + it('should call replace function 2 times', () => { + const query: PromQuery = { + expr: 'test{job="testjob"}', + format: 'time_series', + interval: '$Interval', + refId: 'A', + }; + const interval = '10m'; + replaceMock.mockReturnValue(interval); + + const queries = ds.interpolateVariablesInQueries([query], { Interval: { text: interval, value: interval } }); + expect(templateSrvStub.replace).toBeCalledTimes(2); + expect(queries[0].interval).toBe(interval); + }); + + it('should call enhanceExprWithAdHocFilters', () => { + ds.enhanceExprWithAdHocFilters = jest.fn(); + const queries = [ + { + refId: 'A', + expr: 'rate({bar="baz", job="foo"} [5m]', + }, + ]; + ds.interpolateVariablesInQueries(queries, {}); + expect(ds.enhanceExprWithAdHocFilters).toHaveBeenCalled(); + }); + }); + + describe('applyTemplateVariables', () => { + afterAll(() => { + replaceMock.mockImplementation((a: string, ...rest: unknown[]) => a); + }); + + it('should call replace function for legendFormat', () => { + const query = { + expr: 'test{job="bar"}', + legendFormat: '$legend', + refId: 'A', + }; + const legend = 'baz'; + replaceMock.mockReturnValue(legend); + + const interpolatedQuery = ds.applyTemplateVariables(query, { legend: { text: legend, value: legend } }); + expect(interpolatedQuery.legendFormat).toBe(legend); + }); + + it('should call replace function for interval', () => { + const query = { + expr: 'test{job="bar"}', + interval: '$step', + refId: 'A', + }; + const step = '5s'; + replaceMock.mockReturnValue(step); + + const interpolatedQuery = ds.applyTemplateVariables(query, { step: { text: step, value: step } }); + expect(interpolatedQuery.interval).toBe(step); + }); + + it('should call replace function for expr', () => { + const query = { + expr: 'test{job="$job"}', + refId: 'A', + }; + const job = 'bar'; + replaceMock.mockReturnValue(job); + + const interpolatedQuery = ds.applyTemplateVariables(query, { job: { text: job, value: job } }); + expect(interpolatedQuery.expr).toBe(job); + }); + + it('should add ad-hoc filters to expr', () => { + replaceMock.mockImplementation((a: string) => a); + const filters = [ + { + key: 'k1', + operator: '=', + value: 'v1', + }, + { + key: 'k2', + operator: '!=', + value: 'v2', + }, + ]; + + const query = { + expr: 'test{job="bar"}', + refId: 'A', + }; + + const result = ds.applyTemplateVariables(query, {}, filters); + expect(result).toMatchObject({ expr: 'test{job="bar", k1="v1", k2!="v2"}' }); + }); + + it('should add ad-hoc filters only to expr', () => { + replaceMock.mockImplementation((a: string) => a?.replace('$A', '99') ?? a); + const filters = [ + { + key: 'k1', + operator: '=', + value: 'v1', + }, + { + key: 'k2', + operator: '!=', + value: 'v2', + }, + ]; + + const query = { + expr: 'test{job="bar"} > $A', + refId: 'A', + }; + + const result = ds.applyTemplateVariables(query, {}, filters); + expect(result).toMatchObject({ expr: 'test{job="bar", k1="v1", k2!="v2"} > 99' }); + }); + + it('should add ad-hoc filters only to expr and expression has template variable as label value??', () => { + const searchPattern = /\$A/g; + replaceMock.mockImplementation((a: string) => a?.replace(searchPattern, '99') ?? a); + const filters = [ + { + key: 'k1', + operator: '=', + value: 'v1', + }, + { + key: 'k2', + operator: '!=', + value: 'v2', + }, + ]; + + const query = { + expr: 'test{job="$A"} > $A', + refId: 'A', + }; + + const result = ds.applyTemplateVariables(query, {}, filters); + expect(result).toMatchObject({ expr: 'test{job="99", k1="v1", k2!="v2"} > 99' }); + }); + }); + + describe('metricFindQuery', () => { + beforeEach(() => { + const prometheusDatasource = new PrometheusDatasource( + { ...instanceSettings, jsonData: { ...instanceSettings.jsonData, cacheLevel: PrometheusCacheLevel.None } }, + templateSrvStub + ); + const query = 'query_result(topk(5,rate(http_request_duration_microseconds_count[$__interval])))'; + prometheusDatasource.metricFindQuery(query, { range: mockTimeRangeOld }); + }); + + it('should call templateSrv.replace with scopedVars', () => { + expect(replaceMock.mock.calls[0][1]).toBeDefined(); + }); + + it('should have the correct range and range_ms', () => { + const range = replaceMock.mock.calls[0][1].__range; + const rangeMs = replaceMock.mock.calls[0][1].__range_ms; + const rangeS = replaceMock.mock.calls[0][1].__range_s; + expect(range).toEqual({ text: '21s', value: '21s' }); + expect(rangeMs).toEqual({ text: 21031, value: 21031 }); + expect(rangeS).toEqual({ text: 21, value: 21 }); + }); + + it('should pass the default interval value', () => { + const interval = replaceMock.mock.calls[0][1].__interval; + const intervalMs = replaceMock.mock.calls[0][1].__interval_ms; + expect(interval).toEqual({ text: '15s', value: '15s' }); + expect(intervalMs).toEqual({ text: 15000, value: 15000 }); + }); + }); +}); + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; + +const time = ({ hours = 0, seconds = 0, minutes = 0 }) => dateTime(hours * HOUR + minutes * MINUTE + seconds * SECOND); + +describe('PrometheusDatasource2', () => { + const instanceSettings = { + url: 'proxied', + id: 1, + uid: 'ABCDEF', + user: 'test', + password: 'mupp', + jsonData: { httpMethod: 'GET', cacheLevel: PrometheusCacheLevel.None }, + } as unknown as DataSourceInstanceSettings<PromOptions>; + + let ds: PrometheusDatasource; + beforeEach(() => { + ds = new PrometheusDatasource(instanceSettings, templateSrvStub); + }); + + describe('annotationQuery', () => { + let results: AnnotationEvent[]; + const options = { + annotation: { + expr: 'ALERTS{alertstate="firing"}', + tagKeys: 'job', + titleFormat: '{{alertname}}', + textFormat: '{{instance}}', + }, + range: { + from: time({ seconds: 63 }), + to: time({ seconds: 123 }), + }, + } as unknown as AnnotationQueryRequest<PromQuery>; + + const response = createAnnotationResponse(); + const emptyResponse = createEmptyAnnotationResponse(); + + describe('handle result with empty fields', () => { + it('should return empty results', async () => { + fetchMock.mockImplementation(() => of(emptyResponse)); + + await ds.annotationQuery(options).then((data) => { + results = data; + }); + + expect(results.length).toBe(0); + }); + }); + + describe('when time series query is cancelled', () => { + it('should return empty results', async () => { + fetchMock.mockImplementation(() => of({ cancelled: true })); + + await ds.annotationQuery(options).then((data) => { + results = data; + }); + + expect(results).toEqual([]); + }); + }); + + describe('not use useValueForTime', () => { + beforeEach(async () => { + options.annotation.useValueForTime = false; + fetchMock.mockImplementation(() => of(response)); + + await ds.annotationQuery(options).then((data) => { + results = data; + }); + }); + + it('should return annotation list', () => { + expect(results.length).toBe(1); + expect(results[0].tags).toContain('testjob'); + expect(results[0].title).toBe('InstanceDown'); + expect(results[0].text).toBe('testinstance'); + expect(results[0].time).toBe(123); + }); + }); + + describe('use useValueForTime', () => { + beforeEach(async () => { + options.annotation.useValueForTime = true; + fetchMock.mockImplementation(() => of(response)); + + await ds.annotationQuery(options).then((data) => { + results = data; + }); + }); + + it('should return annotation list', () => { + expect(results[0].time).toEqual(456); + }); + }); + + describe('step parameter', () => { + beforeEach(() => { + fetchMock.mockImplementation(() => of(response)); + }); + + it('should use default step for short range if no interval is given', () => { + const query = { + ...options, + range: { + from: time({ seconds: 63 }), + to: time({ seconds: 123 }), + }, + } as AnnotationQueryRequest<PromQuery>; + ds.annotationQuery(query); + const req = fetchMock.mock.calls[0][0]; + expect(req.data.queries[0].interval).toBe('60s'); + }); + + it('should use default step for short range when annotation step is empty string', () => { + const query = { + ...options, + annotation: { + ...options.annotation, + step: '', + }, + range: { + from: time({ seconds: 63 }), + to: time({ seconds: 123 }), + }, + } as unknown as AnnotationQueryRequest<PromQuery>; + ds.annotationQuery(query); + const req = fetchMock.mock.calls[0][0]; + expect(req.data.queries[0].interval).toBe('60s'); + }); + + it('should use custom step for short range', () => { + const annotation = { + ...options.annotation, + step: '10s', + }; + const query = { + ...options, + annotation, + range: { + from: time({ seconds: 63 }), + to: time({ seconds: 123 }), + }, + } as unknown as AnnotationQueryRequest<PromQuery>; + ds.annotationQuery(query); + const req = fetchMock.mock.calls[0][0]; + expect(req.data.queries[0].interval).toBe('10s'); + }); + }); + + describe('region annotations for sectors', () => { + const options = { + annotation: { + expr: 'ALERTS{alertstate="firing"}', + tagKeys: 'job', + titleFormat: '{{alertname}}', + textFormat: '{{instance}}', + }, + range: { + from: time({ seconds: 63 }), + to: time({ seconds: 900 }), + }, + } as unknown as AnnotationQueryRequest; + + async function runAnnotationQuery(data: number[][]) { + let response = createAnnotationResponse(); + response.data.results['X'].frames[0].data.values = data; + + options.annotation.useValueForTime = false; + fetchMock.mockImplementation(() => of(response)); + + return ds.annotationQuery(options); + } + + it('should handle gaps and inactive values', async () => { + const results = await runAnnotationQuery([ + [2 * 60000, 3 * 60000, 5 * 60000, 6 * 60000, 7 * 60000, 8 * 60000, 9 * 60000], + [1, 1, 1, 1, 1, 0, 1], + ]); + expect(results.map((result) => [result.time, result.timeEnd])).toEqual([ + [120000, 180000], + [300000, 420000], + [540000, 540000], + ]); + }); + + it('should handle single region', async () => { + const results = await runAnnotationQuery([ + [2 * 60000, 3 * 60000], + [1, 1], + ]); + expect(results.map((result) => [result.time, result.timeEnd])).toEqual([[120000, 180000]]); + }); + + it('should handle 0 active regions', async () => { + const results = await runAnnotationQuery([ + [2 * 60000, 3 * 60000, 5 * 60000], + [0, 0, 0], + ]); + expect(results.length).toBe(0); + }); + + it('should handle single active value', async () => { + const results = await runAnnotationQuery([[2 * 60000], [1]]); + expect(results.map((result) => [result.time, result.timeEnd])).toEqual([[120000, 120000]]); + }); + }); + + describe('with template variables', () => { + afterAll(() => { + replaceMock.mockImplementation((a: string, ...rest: unknown[]) => a); + }); + + it('should interpolate variables in query expr', () => { + const query = { + ...options, + annotation: { + ...options.annotation, + expr: '$variable', + }, + range: { + from: time({ seconds: 1 }), + to: time({ seconds: 2 }), + }, + } as unknown as AnnotationQueryRequest<PromQuery>; + const interpolated = 'interpolated_expr'; + replaceMock.mockReturnValue(interpolated); + ds.annotationQuery(query); + const req = fetchMock.mock.calls[0][0]; + expect(req.data.queries[0].expr).toBe(interpolated); + }); + }); + }); + + describe('The __rate_interval variable', () => { + const target = { expr: 'rate(process_cpu_seconds_total[$__rate_interval])', refId: 'A' }; + + beforeEach(() => { + replaceMock.mockClear(); + }); + + it('should be 4 times the scrape interval if interval + scrape interval is lower', () => { + ds.createQuery(target, { interval: '15s', range: getMockTimeRange() } as DataQueryRequest<PromQuery>, 0, 300); + expect(replaceMock.mock.calls[1][1]['__rate_interval'].value).toBe('60s'); + }); + it('should be interval + scrape interval if 4 times the scrape interval is lower', () => { + ds.createQuery( + target, + { + interval: '5m', + range: getMockTimeRange(), + } as DataQueryRequest<PromQuery>, + 0, + 10080 + ); + expect(replaceMock.mock.calls[1][1]['__rate_interval'].value).toBe('315s'); + }); + it('should fall back to a scrape interval of 15s if min step is set to 0, resulting in 4*15s = 60s', () => { + ds.createQuery( + { ...target, interval: '' }, + { interval: '15s', range: getMockTimeRange() } as DataQueryRequest<PromQuery>, + 0, + 300 + ); + expect(replaceMock.mock.calls[1][1]['__rate_interval'].value).toBe('60s'); + }); + it('should be 4 times the scrape interval if min step set to 1m and interval is 15s', () => { + // For a 5m graph, $__interval is 15s + ds.createQuery( + { ...target, interval: '1m' }, + { interval: '15s', range: getMockTimeRange() } as DataQueryRequest<PromQuery>, + 0, + 300 + ); + expect(replaceMock.mock.calls[2][1]['__rate_interval'].value).toBe('240s'); + }); + it('should be interval + scrape interval if min step set to 1m and interval is 5m', () => { + // For a 7d graph, $__interval is 5m + ds.createQuery( + { ...target, interval: '1m' }, + { interval: '5m', range: getMockTimeRange() } as DataQueryRequest<PromQuery>, + 0, + 10080 + ); + expect(replaceMock.mock.calls[2][1]['__rate_interval'].value).toBe('360s'); + }); + it('should be interval + scrape interval if resolution is set to 1/2 and interval is 10m', () => { + // For a 7d graph, $__interval is 10m + ds.createQuery( + { ...target, intervalFactor: 2 }, + { interval: '10m', range: getMockTimeRange() } as DataQueryRequest<PromQuery>, + 0, + 10080 + ); + expect(replaceMock.mock.calls[1][1]['__rate_interval'].value).toBe('1215s'); + }); + it('should be 4 times the scrape interval if resolution is set to 1/2 and interval is 15s', () => { + // For a 5m graph, $__interval is 15s + ds.createQuery( + { ...target, intervalFactor: 2 }, + { interval: '15s', range: getMockTimeRange() } as DataQueryRequest<PromQuery>, + 0, + 300 + ); + expect(replaceMock.mock.calls[1][1]['__rate_interval'].value).toBe('60s'); + }); + it('should interpolate min step if set', () => { + replaceMock.mockImplementation((_: string) => '15s'); + ds.createQuery( + { ...target, interval: '$int' }, + { interval: '15s', range: getMockTimeRange() } as DataQueryRequest<PromQuery>, + 0, + 300 + ); + expect(replaceMock.mock.calls).toHaveLength(3); + replaceMock.mockImplementation((str) => str); + }); + }); + + it('should give back 1 exemplar target when multiple queries with exemplar enabled and same metric', () => { + const targetA: PromQuery = { + refId: 'A', + expr: 'histogram_quantile(0.95, sum(rate(tns_request_duration_seconds_bucket[5m])) by (le))', + exemplar: true, + }; + const targetB: PromQuery = { + refId: 'B', + expr: 'histogram_quantile(0.5, sum(rate(tns_request_duration_seconds_bucket[5m])) by (le))', + exemplar: true, + }; + + ds.languageProvider = { + histogramMetrics: ['tns_request_duration_seconds_bucket'], + } as PromQlLanguageProvider; + + const request = { + targets: [targetA, targetB], + interval: '1s', + panelId: '', + } as unknown as DataQueryRequest<PromQuery>; + + const Aexemplars = ds.shouldRunExemplarQuery(targetA, request); + const BExpemplars = ds.shouldRunExemplarQuery(targetB, request); + + expect(Aexemplars).toBe(true); + expect(BExpemplars).toBe(false); + }); +}); + +describe('When querying prometheus via check headers X-Dashboard-Id X-Panel-Id and X-Dashboard-UID', () => { + const options = { panelId: 2, dashboardUID: 'WFlOM-jM1' } as DataQueryRequest<PromQuery>; + const httpOptions = { + headers: {} as { [key: string]: number | undefined }, + } as PromQueryRequest; + const instanceSettings = { + url: 'proxied', + directUrl: 'direct', + user: 'test', + password: 'mupp', + access: 'proxy', + jsonData: { httpMethod: 'POST' }, + } as unknown as DataSourceInstanceSettings<PromOptions>; + + let ds: PrometheusDatasource; + beforeEach(() => { + ds = new PrometheusDatasource(instanceSettings, templateSrvStub as unknown as TemplateSrv); + }); + + it('with proxy access tracing headers should be added', () => { + ds._addTracingHeaders(httpOptions, options); + expect(httpOptions.headers['X-Panel-Id']).toBe(options.panelId); + expect(httpOptions.headers['X-Dashboard-UID']).toBe(options.dashboardUID); + }); + + it('with direct access tracing headers should not be added', () => { + const instanceSettings = { + url: 'proxied', + directUrl: 'direct', + user: 'test', + password: 'mupp', + jsonData: { httpMethod: 'POST' }, + } as unknown as DataSourceInstanceSettings<PromOptions>; + + const mockDs = new PrometheusDatasource({ ...instanceSettings, url: 'http://127.0.0.1:8000' }, templateSrvStub); + mockDs._addTracingHeaders(httpOptions, options); + expect(httpOptions.headers['X-Dashboard-Id']).toBe(undefined); + expect(httpOptions.headers['X-Panel-Id']).toBe(undefined); + expect(httpOptions.headers['X-Dashboard-UID']).toBe(undefined); + }); +}); + +describe('modifyQuery', () => { + describe('when called with ADD_FILTER', () => { + describe('and query has no labels', () => { + it('then the correct label should be added', () => { + const query: PromQuery = { refId: 'A', expr: 'go_goroutines' }; + const action = { options: { key: 'cluster', value: 'us-cluster' }, type: 'ADD_FILTER' }; + const instanceSettings = { jsonData: {} } as unknown as DataSourceInstanceSettings<PromOptions>; + const ds = new PrometheusDatasource(instanceSettings, templateSrvStub); + + const result = ds.modifyQuery(query, action); + + expect(result.refId).toEqual('A'); + expect(result.expr).toEqual('go_goroutines{cluster="us-cluster"}'); + }); + }); + + describe('and query has labels', () => { + it('then the correct label should be added', () => { + const query: PromQuery = { refId: 'A', expr: 'go_goroutines{cluster="us-cluster"}' }; + const action = { options: { key: 'pod', value: 'pod-123' }, type: 'ADD_FILTER' }; + const instanceSettings = { jsonData: {} } as unknown as DataSourceInstanceSettings<PromOptions>; + const ds = new PrometheusDatasource(instanceSettings, templateSrvStub); + + const result = ds.modifyQuery(query, action); + + expect(result.refId).toEqual('A'); + expect(result.expr).toEqual('go_goroutines{cluster="us-cluster", pod="pod-123"}'); + }); + }); + }); + + describe('when called with ADD_FILTER_OUT', () => { + describe('and query has no labels', () => { + it('then the correct label should be added', () => { + const query: PromQuery = { refId: 'A', expr: 'go_goroutines' }; + const action = { options: { key: 'cluster', value: 'us-cluster' }, type: 'ADD_FILTER_OUT' }; + const instanceSettings = { jsonData: {} } as unknown as DataSourceInstanceSettings<PromOptions>; + const ds = new PrometheusDatasource(instanceSettings, templateSrvStub); + + const result = ds.modifyQuery(query, action); + + expect(result.refId).toEqual('A'); + expect(result.expr).toEqual('go_goroutines{cluster!="us-cluster"}'); + }); + }); + + describe('and query has labels', () => { + it('then the correct label should be added', () => { + const query: PromQuery = { refId: 'A', expr: 'go_goroutines{cluster="us-cluster"}' }; + const action = { options: { key: 'pod', value: 'pod-123' }, type: 'ADD_FILTER_OUT' }; + const instanceSettings = { jsonData: {} } as unknown as DataSourceInstanceSettings<PromOptions>; + const ds = new PrometheusDatasource(instanceSettings, templateSrvStub); + + const result = ds.modifyQuery(query, action); + + expect(result.refId).toEqual('A'); + expect(result.expr).toEqual('go_goroutines{cluster="us-cluster", pod!="pod-123"}'); + }); + }); + }); +}); + +function createDataRequest(targets: PromQuery[], overrides?: Partial<DataQueryRequest>): DataQueryRequest<PromQuery> { + const defaults: DataQueryRequest<PromQuery> = { + intervalMs: 15000, + requestId: 'createDataRequest', + startTime: 0, + timezone: 'browser', + app: CoreApp.Dashboard, + targets: targets.map((t, i) => ({ + instant: false, + start: dateTime().subtract(5, 'minutes'), + end: dateTime(), + ...t, + })), + range: { + from: dateTime(), + to: dateTime(), + raw: { + from: '', + to: '', + }, + }, + interval: '15s', + scopedVars: {}, + }; + + return Object.assign(defaults, overrides || {}) as DataQueryRequest<PromQuery>; +} + +function createDefaultPromResponse() { + return { + data: { + data: { + result: [ + { + metric: { + __name__: 'test_metric', + }, + values: [[1568369640, 1]], + }, + ], + resultType: 'matrix', + }, + }, + }; +} + +function createAnnotationResponse() { + const response = { + data: { + results: { + X: { + frames: [ + { + schema: { + name: 'bar', + refId: 'X', + fields: [ + { + name: 'Time', + type: 'time', + typeInfo: { + frame: 'time.Time', + }, + }, + { + name: 'Value', + type: 'number', + typeInfo: { + frame: 'float64', + }, + labels: { + __name__: 'ALERTS', + alertname: 'InstanceDown', + alertstate: 'firing', + instance: 'testinstance', + job: 'testjob', + }, + }, + ], + }, + data: { + values: [[123], [456]], + }, + }, + ], + }, + }, + }, + }; + + return { ...response }; +} + +function createEmptyAnnotationResponse() { + const response = { + data: { + results: { + X: { + frames: [ + { + schema: { + name: 'bar', + refId: 'X', + fields: [], + }, + data: { + values: [], + }, + }, + ], + }, + }, + }, + }; + + return { ...response }; +} + +function getMockTimeRange(range = '6h'): TimeRange { + return rangeUtil.convertRawToRange({ + from: `now-${range}`, + to: 'now', + }); +} diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts new file mode 100644 index 0000000000000..956b4a268be27 --- /dev/null +++ b/packages/grafana-prometheus/src/datasource.ts @@ -0,0 +1,1014 @@ +import { defaults } from 'lodash'; +import { lastValueFrom, Observable, throwError } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; +import semver from 'semver/preload'; + +import { + AbstractQuery, + AdHocVariableFilter, + AnnotationEvent, + AnnotationQueryRequest, + CoreApp, + DataFrame, + DataQueryRequest, + DataQueryResponse, + DataSourceGetTagKeysOptions, + DataSourceGetTagValuesOptions, + DataSourceInstanceSettings, + DataSourceWithQueryExportSupport, + DataSourceWithQueryImportSupport, + dateTime, + getDefaultTimeRange, + LegacyMetricFindQueryOptions, + MetricFindValue, + QueryFixAction, + rangeUtil, + renderLegendFormat, + ScopedVars, + TimeRange, +} from '@grafana/data'; +import { + BackendDataSourceResponse, + BackendSrvRequest, + DataSourceWithBackend, + FetchResponse, + getBackendSrv, + getTemplateSrv, + isFetchError, + TemplateSrv, + toDataQueryResponse, +} from '@grafana/runtime'; + +import { addLabelToQuery } from './add_label_to_query'; +import { AnnotationQueryEditor } from './components/AnnotationQueryEditor'; +import PrometheusLanguageProvider from './language_provider'; +import { + expandRecordingRules, + getClientCacheDurationInMinutes, + getPrometheusTime, + getRangeSnapInterval, +} from './language_utils'; +import { PrometheusMetricFindQuery } from './metric_find_query'; +import { getInitHints, getQueryHints } from './query_hints'; +import { promQueryModeller } from './querybuilder/PromQueryModeller'; +import { QueryBuilderLabelFilter, QueryEditorMode } from './querybuilder/shared/types'; +import { CacheRequestInfo, defaultPrometheusQueryOverlapWindow, QueryCache } from './querycache/QueryCache'; +import { getOriginalMetricName, transformV2 } from './result_transformer'; +import { trackQuery } from './tracking'; +import { + ExemplarTraceIdDestination, + PromApplication, + PrometheusCacheLevel, + PromOptions, + PromQuery, + PromQueryRequest, +} from './types'; +import { PrometheusVariableSupport } from './variables'; + +const ANNOTATION_QUERY_STEP_DEFAULT = '60s'; +const GET_AND_POST_METADATA_ENDPOINTS = ['api/v1/query', 'api/v1/query_range', 'api/v1/series', 'api/v1/labels']; + +export const InstantQueryRefIdIndex = '-Instant'; + +export class PrometheusDatasource + extends DataSourceWithBackend<PromQuery, PromOptions> + implements DataSourceWithQueryImportSupport<PromQuery>, DataSourceWithQueryExportSupport<PromQuery> +{ + type: string; + ruleMappings: { [index: string]: string }; + hasIncrementalQuery: boolean; + url: string; + id: number; + access: 'direct' | 'proxy'; + basicAuth: any; + withCredentials: any; + interval: string; + queryTimeout: string | undefined; + httpMethod: string; + languageProvider: PrometheusLanguageProvider; + exemplarTraceIdDestinations: ExemplarTraceIdDestination[] | undefined; + lookupsDisabled: boolean; + customQueryParameters: any; + datasourceConfigurationPrometheusFlavor?: PromApplication; + datasourceConfigurationPrometheusVersion?: string; + disableRecordingRules: boolean; + defaultEditor?: QueryEditorMode; + exemplarsAvailable: boolean; + cacheLevel: PrometheusCacheLevel; + cache: QueryCache<PromQuery>; + + constructor( + instanceSettings: DataSourceInstanceSettings<PromOptions>, + private readonly templateSrv: TemplateSrv = getTemplateSrv(), + languageProvider?: PrometheusLanguageProvider + ) { + super(instanceSettings); + + this.type = 'prometheus'; + this.id = instanceSettings.id; + this.url = instanceSettings.url!; + this.access = instanceSettings.access; + this.basicAuth = instanceSettings.basicAuth; + this.withCredentials = instanceSettings.withCredentials; + this.interval = instanceSettings.jsonData.timeInterval || '15s'; + this.queryTimeout = instanceSettings.jsonData.queryTimeout; + this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET'; + this.exemplarTraceIdDestinations = instanceSettings.jsonData.exemplarTraceIdDestinations; + this.hasIncrementalQuery = instanceSettings.jsonData.incrementalQuerying ?? false; + this.ruleMappings = {}; + this.languageProvider = languageProvider ?? new PrometheusLanguageProvider(this); + this.lookupsDisabled = instanceSettings.jsonData.disableMetricsLookup ?? false; + this.customQueryParameters = new URLSearchParams(instanceSettings.jsonData.customQueryParameters); + this.datasourceConfigurationPrometheusFlavor = instanceSettings.jsonData.prometheusType; + this.datasourceConfigurationPrometheusVersion = instanceSettings.jsonData.prometheusVersion; + this.defaultEditor = instanceSettings.jsonData.defaultEditor; + this.disableRecordingRules = instanceSettings.jsonData.disableRecordingRules ?? false; + this.variables = new PrometheusVariableSupport(this, this.templateSrv); + this.exemplarsAvailable = true; + this.cacheLevel = instanceSettings.jsonData.cacheLevel ?? PrometheusCacheLevel.Low; + + this.cache = new QueryCache({ + getTargetSignature: this.getPrometheusTargetSignature.bind(this), + overlapString: instanceSettings.jsonData.incrementalQueryOverlapWindow ?? defaultPrometheusQueryOverlapWindow, + profileFunction: this.getPrometheusProfileData.bind(this), + }); + + // This needs to be here and cannot be static because of how annotations typing affects casting of data source + // objects to DataSourceApi types. + // We don't use the default processing for prometheus. + // See standardAnnotationSupport.ts/[shouldUseMappingUI|shouldUseLegacyRunner] + this.annotations = { + QueryEditor: AnnotationQueryEditor, + }; + } + + init = async () => { + if (!this.disableRecordingRules) { + this.loadRules(); + } + this.exemplarsAvailable = await this.areExemplarsAvailable(); + }; + + getQueryDisplayText(query: PromQuery) { + return query.expr; + } + + getPrometheusProfileData(request: DataQueryRequest<PromQuery>, targ: PromQuery) { + return { + interval: targ.interval ?? request.interval, + expr: this.interpolateString(targ.expr), + datasource: 'Prometheus', + }; + } + + /** + * Get target signature for query caching + * @param request + * @param query + */ + getPrometheusTargetSignature(request: DataQueryRequest<PromQuery>, query: PromQuery) { + const targExpr = this.interpolateString(query.expr); + return `${targExpr}|${query.interval ?? request.interval}|${JSON.stringify(request.rangeRaw ?? '')}|${ + query.exemplar + }`; + } + + hasLabelsMatchAPISupport(): boolean { + return ( + // https://github.com/prometheus/prometheus/releases/tag/v2.24.0 + this._isDatasourceVersionGreaterOrEqualTo('2.24.0', PromApplication.Prometheus) || + // All versions of Mimir support matchers for labels API + this._isDatasourceVersionGreaterOrEqualTo('2.0.0', PromApplication.Mimir) || + // https://github.com/cortexproject/cortex/discussions/4542 + this._isDatasourceVersionGreaterOrEqualTo('1.11.0', PromApplication.Cortex) || + // https://github.com/thanos-io/thanos/pull/3566 + //https://github.com/thanos-io/thanos/releases/tag/v0.18.0 + this._isDatasourceVersionGreaterOrEqualTo('0.18.0', PromApplication.Thanos) + ); + } + + _isDatasourceVersionGreaterOrEqualTo(targetVersion: string, targetFlavor: PromApplication): boolean { + // User hasn't configured flavor/version yet, default behavior is to support labels match api support + if (!this.datasourceConfigurationPrometheusVersion || !this.datasourceConfigurationPrometheusFlavor) { + return true; + } + + if (targetFlavor !== this.datasourceConfigurationPrometheusFlavor) { + return false; + } + + return semver.gte(this.datasourceConfigurationPrometheusVersion, targetVersion); + } + + _addTracingHeaders(httpOptions: PromQueryRequest, options: DataQueryRequest<PromQuery>) { + httpOptions.headers = {}; + if (this.access === 'proxy') { + httpOptions.headers['X-Dashboard-UID'] = options.dashboardUID; + httpOptions.headers['X-Panel-Id'] = options.panelId; + } + } + + directAccessError() { + return throwError( + () => + new Error( + 'Browser access mode in the Prometheus datasource is no longer available. Switch to server access mode.' + ) + ); + } + + /** + * Any request done from this data source should go through here as it contains some common processing for the + * request. Any processing done here needs to be also copied on the backend as this goes through data source proxy + * but not through the same code as alerting. + */ + _request<T = unknown>( + url: string, + data: Record<string, string> | null, + overrides: Partial<BackendSrvRequest> = {} + ): Observable<FetchResponse<T>> { + if (this.access === 'direct') { + return this.directAccessError(); + } + + data = data || {}; + for (const [key, value] of this.customQueryParameters) { + if (data[key] == null) { + data[key] = value; + } + } + + let queryUrl = this.url + url; + if (url.startsWith(`/api/datasources/uid/${this.uid}`)) { + // This url is meant to be a replacement for the whole URL. Replace the entire URL + queryUrl = url; + } + + const options: BackendSrvRequest = defaults(overrides, { + url: queryUrl, + method: this.httpMethod, + headers: {}, + }); + + if (options.method === 'GET') { + if (data && Object.keys(data).length) { + options.url = + options.url + + (options.url.search(/\?/) >= 0 ? '&' : '?') + + Object.entries(data) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + } + } else { + options.headers!['Content-Type'] = 'application/x-www-form-urlencoded'; + options.data = data; + } + + if (this.basicAuth || this.withCredentials) { + options.withCredentials = true; + } + + if (this.basicAuth) { + options.headers!.Authorization = this.basicAuth; + } + + return getBackendSrv().fetch<T>(options); + } + + async importFromAbstractQueries(abstractQueries: AbstractQuery[]): Promise<PromQuery[]> { + return abstractQueries.map((abstractQuery) => this.languageProvider.importFromAbstractQuery(abstractQuery)); + } + + async exportToAbstractQueries(queries: PromQuery[]): Promise<AbstractQuery[]> { + return queries.map((query) => this.languageProvider.exportToAbstractQuery(query)); + } + + // Use this for tab completion features, wont publish response to other components + async metadataRequest<T = any>(url: string, params = {}, options?: Partial<BackendSrvRequest>) { + // If URL includes endpoint that supports POST and GET method, try to use configured method. This might fail as POST is supported only in v2.10+. + if (GET_AND_POST_METADATA_ENDPOINTS.some((endpoint) => url.includes(endpoint))) { + try { + return await lastValueFrom( + this._request<T>(`/api/datasources/uid/${this.uid}/resources${url}`, params, { + method: this.httpMethod, + hideFromInspector: true, + showErrorAlert: false, + ...options, + }) + ); + } catch (err) { + // If status code of error is Method Not Allowed (405) and HTTP method is POST, retry with GET + if (this.httpMethod === 'POST' && isFetchError(err) && (err.status === 405 || err.status === 400)) { + console.warn(`Couldn't use configured POST HTTP method for this request. Trying to use GET method instead.`); + } else { + throw err; + } + } + } + + return await lastValueFrom( + this._request<T>(`/api/datasources/uid/${this.uid}/resources${url}`, params, { + method: 'GET', + hideFromInspector: true, + ...options, + }) + ); // toPromise until we change getTagValues, getLabelNames to Observable + } + + interpolateQueryExpr(value: string | string[] = [], variable: any) { + // if no multi or include all do not regexEscape + if (!variable.multi && !variable.includeAll) { + return prometheusRegularEscape(value); + } + + if (typeof value === 'string') { + return prometheusSpecialRegexEscape(value); + } + + const escapedValues = value.map((val) => prometheusSpecialRegexEscape(val)); + + if (escapedValues.length === 1) { + return escapedValues[0]; + } + + return '(' + escapedValues.join('|') + ')'; + } + + targetContainsTemplate(target: PromQuery) { + return this.templateSrv.containsTemplate(target.expr); + } + + shouldRunExemplarQuery(target: PromQuery, request: DataQueryRequest<PromQuery>): boolean { + if (target.exemplar) { + // We check all already processed targets and only create exemplar target for not used metric names + const metricName = this.languageProvider.histogramMetrics.find((m) => target.expr.includes(m)); + // Remove targets that weren't processed yet (in targets array they are after current target) + const currentTargetIdx = request.targets.findIndex((t) => t.refId === target.refId); + const targets = request.targets.slice(0, currentTargetIdx).filter((t) => !t.hide); + + if (!metricName || (metricName && !targets.some((t) => t.expr.includes(metricName)))) { + return true; + } + return false; + } + return false; + } + + processTargetV2(target: PromQuery, request: DataQueryRequest<PromQuery>) { + const processedTargets: PromQuery[] = []; + const processedTarget = { + ...target, + exemplar: this.shouldRunExemplarQuery(target, request), + requestId: request.panelId + target.refId, + // We need to pass utcOffsetSec to backend to calculate aligned range + utcOffsetSec: request.range.to.utcOffset() * 60, + }; + if (target.instant && target.range) { + // We have query type "Both" selected + // We should send separate queries with different refId + processedTargets.push( + { + ...processedTarget, + refId: processedTarget.refId, + instant: false, + }, + { + ...processedTarget, + refId: processedTarget.refId + InstantQueryRefIdIndex, + range: false, + } + ); + } else { + processedTargets.push(processedTarget); + } + + return processedTargets; + } + + query(request: DataQueryRequest<PromQuery>): Observable<DataQueryResponse> { + if (this.access === 'direct') { + return this.directAccessError(); + } + + let fullOrPartialRequest: DataQueryRequest<PromQuery>; + let requestInfo: CacheRequestInfo<PromQuery> | undefined = undefined; + const hasInstantQuery = request.targets.some((target) => target.instant); + + // Don't cache instant queries + if (this.hasIncrementalQuery && !hasInstantQuery) { + requestInfo = this.cache.requestInfo(request); + fullOrPartialRequest = requestInfo.requests[0]; + } else { + fullOrPartialRequest = request; + } + + const targets = fullOrPartialRequest.targets.map((target) => this.processTargetV2(target, fullOrPartialRequest)); + const startTime = new Date(); + return super.query({ ...fullOrPartialRequest, targets: targets.flat() }).pipe( + map((response) => { + const amendedResponse = { + ...response, + data: this.cache.procFrames(request, requestInfo, response.data), + }; + return transformV2(amendedResponse, request, { + exemplarTraceIdDestinations: this.exemplarTraceIdDestinations, + }); + }), + tap((response: DataQueryResponse) => { + trackQuery(response, request, startTime); + }) + ); + } + + createQuery(target: PromQuery, options: DataQueryRequest<PromQuery>, start: number, end: number) { + const query: PromQueryRequest = { + hinting: target.hinting, + instant: target.instant, + exemplar: target.exemplar, + step: 0, + expr: '', + refId: target.refId, + start: 0, + end: 0, + }; + const range = Math.ceil(end - start); + + // options.interval is the dynamically calculated interval + let interval: number = rangeUtil.intervalToSeconds(options.interval); + // Minimum interval ("Min step"), if specified for the query, or same as interval otherwise. + const minInterval = rangeUtil.intervalToSeconds( + this.templateSrv.replace(target.interval || options.interval, options.scopedVars) + ); + // Scrape interval as specified for the query ("Min step") or otherwise taken from the datasource. + // Min step field can have template variables in it, make sure to replace it. + const scrapeInterval = target.interval + ? rangeUtil.intervalToSeconds(this.templateSrv.replace(target.interval, options.scopedVars)) + : rangeUtil.intervalToSeconds(this.interval); + + const intervalFactor = target.intervalFactor || 1; + // Adjust the interval to take into account any specified minimum and interval factor plus Prometheus limits + const adjustedInterval = this.adjustInterval(interval, minInterval, range, intervalFactor); + let scopedVars = { + ...options.scopedVars, + ...this.getRangeScopedVars(options.range), + ...this.getRateIntervalScopedVariable(adjustedInterval, scrapeInterval), + }; + // If the interval was adjusted, make a shallow copy of scopedVars with updated interval vars + if (interval !== adjustedInterval) { + interval = adjustedInterval; + scopedVars = Object.assign({}, options.scopedVars, { + __interval: { text: interval + 's', value: interval + 's' }, + __interval_ms: { text: interval * 1000, value: interval * 1000 }, + ...this.getRateIntervalScopedVariable(interval, scrapeInterval), + ...this.getRangeScopedVars(options.range), + }); + } + + query.step = interval; + + let expr = target.expr; + + // Apply adhoc filters + expr = this.enhanceExprWithAdHocFilters(options.filters, expr); + + // Only replace vars in expression after having (possibly) updated interval vars + query.expr = this.templateSrv.replace(expr, scopedVars, this.interpolateQueryExpr); + + // Align query interval with step to allow query caching and to ensure + // that about-same-time query results look the same. + const adjusted = alignRange(start, end, query.step, options.range.to.utcOffset() * 60); + query.start = adjusted.start; + query.end = adjusted.end; + this._addTracingHeaders(query, options); + + return query; + } + + getRateIntervalScopedVariable(interval: number, scrapeInterval: number) { + // Fall back to the default scrape interval of 15s if scrapeInterval is 0 for some reason. + if (scrapeInterval === 0) { + scrapeInterval = 15; + } + const rateInterval = Math.max(interval + scrapeInterval, 4 * scrapeInterval); + return { __rate_interval: { text: rateInterval + 's', value: rateInterval + 's' } }; + } + + adjustInterval(interval: number, minInterval: number, range: number, intervalFactor: number) { + // Prometheus will drop queries that might return more than 11000 data points. + // Calculate a safe interval as an additional minimum to take into account. + // Fractional safeIntervals are allowed, however serve little purpose if the interval is greater than 1 + // If this is the case take the ceil of the value. + let safeInterval = range / 11000; + if (safeInterval > 1) { + safeInterval = Math.ceil(safeInterval); + } + return Math.max(interval * intervalFactor, minInterval, safeInterval); + } + + metricFindQuery(query: string, options?: LegacyMetricFindQueryOptions) { + if (!query) { + return Promise.resolve([]); + } + + const scopedVars = { + __interval: { text: this.interval, value: this.interval }, + __interval_ms: { text: rangeUtil.intervalToMs(this.interval), value: rangeUtil.intervalToMs(this.interval) }, + ...this.getRangeScopedVars(options?.range ?? getDefaultTimeRange()), + }; + const interpolated = this.templateSrv.replace(query, scopedVars, this.interpolateQueryExpr); + const metricFindQuery = new PrometheusMetricFindQuery(this, interpolated); + return metricFindQuery.process(options?.range ?? getDefaultTimeRange()); + } + + getRangeScopedVars(range: TimeRange) { + const msRange = range.to.diff(range.from); + const sRange = Math.round(msRange / 1000); + return { + __range_ms: { text: msRange, value: msRange }, + __range_s: { text: sRange, value: sRange }, + __range: { text: sRange + 's', value: sRange + 's' }, + }; + } + + async annotationQuery(options: AnnotationQueryRequest<PromQuery>): Promise<AnnotationEvent[]> { + if (this.access === 'direct') { + const error = new Error( + 'Browser access mode in the Prometheus datasource is no longer available. Switch to server access mode.' + ); + return Promise.reject(error); + } + + const annotation = options.annotation; + const { expr = '' } = annotation; + + if (!expr) { + return Promise.resolve([]); + } + + const step = options.annotation.step || ANNOTATION_QUERY_STEP_DEFAULT; + const queryModel = { + expr, + range: true, + instant: false, + exemplar: false, + interval: step, + refId: 'X', + datasource: this.getRef(), + }; + + return await lastValueFrom( + getBackendSrv() + .fetch<BackendDataSourceResponse>({ + url: '/api/ds/query', + method: 'POST', + headers: this.getRequestHeaders(), + data: { + from: (getPrometheusTime(options.range.from, false) * 1000).toString(), + to: (getPrometheusTime(options.range.to, true) * 1000).toString(), + queries: [this.applyTemplateVariables(queryModel, {})], + }, + requestId: `prom-query-${annotation.name}`, + }) + .pipe( + map((rsp: FetchResponse<BackendDataSourceResponse>) => { + return this.processAnnotationResponse(options, rsp.data); + }) + ) + ); + } + + processAnnotationResponse = (options: AnnotationQueryRequest<PromQuery>, data: BackendDataSourceResponse) => { + const frames: DataFrame[] = toDataQueryResponse({ data: data }).data; + if (!frames || !frames.length) { + return []; + } + + const annotation = options.annotation; + const { tagKeys = '', titleFormat = '', textFormat = '' } = annotation; + + const step = rangeUtil.intervalToSeconds(annotation.step || ANNOTATION_QUERY_STEP_DEFAULT) * 1000; + const tagKeysArray = tagKeys.split(','); + + const eventList: AnnotationEvent[] = []; + + for (const frame of frames) { + if (frame.fields.length === 0) { + continue; + } + const timeField = frame.fields[0]; + const valueField = frame.fields[1]; + const labels = valueField?.labels || {}; + + const tags = Object.keys(labels) + .filter((label) => tagKeysArray.includes(label)) + .map((label) => labels[label]); + + const timeValueTuple: Array<[number, number]> = []; + + let idx = 0; + valueField.values.forEach((value: string) => { + let timeStampValue: number; + let valueValue: number; + const time = timeField.values[idx]; + + // If we want to use value as a time, we use value as timeStampValue and valueValue will be 1 + if (options.annotation.useValueForTime) { + timeStampValue = Math.floor(parseFloat(value)); + valueValue = 1; + } else { + timeStampValue = Math.floor(parseFloat(time)); + valueValue = parseFloat(value); + } + + idx++; + timeValueTuple.push([timeStampValue, valueValue]); + }); + + const activeValues = timeValueTuple.filter((value) => value[1] > 0); + const activeValuesTimestamps = activeValues.map((value) => value[0]); + + // Instead of creating singular annotation for each active event we group events into region if they are less + // or equal to `step` apart. + let latestEvent: AnnotationEvent | null = null; + + for (const timestamp of activeValuesTimestamps) { + // We already have event `open` and we have new event that is inside the `step` so we just update the end. + if (latestEvent && (latestEvent.timeEnd ?? 0) + step >= timestamp) { + latestEvent.timeEnd = timestamp; + continue; + } + + // Event exists but new one is outside of the `step` so we add it to eventList. + if (latestEvent) { + eventList.push(latestEvent); + } + + // We start a new region. + latestEvent = { + time: timestamp, + timeEnd: timestamp, + annotation, + title: renderLegendFormat(titleFormat, labels), + tags, + text: renderLegendFormat(textFormat, labels), + }; + } + + if (latestEvent) { + // Finish up last point if we have one + latestEvent.timeEnd = activeValuesTimestamps[activeValuesTimestamps.length - 1]; + eventList.push(latestEvent); + } + } + + return eventList; + }; + + // By implementing getTagKeys and getTagValues we add ad-hoc filters functionality + // this is used to get label keys, a.k.a label names + // it is used in metric_find_query.ts + // and in Tempo here grafana/public/app/plugins/datasource/tempo/QueryEditor/ServiceGraphSection.tsx + async getTagKeys(options: DataSourceGetTagKeysOptions<PromQuery>): Promise<MetricFindValue[]> { + if (!options || options.filters.length === 0) { + await this.languageProvider.fetchLabels(options.timeRange); + return this.languageProvider.getLabelKeys().map((k) => ({ value: k, text: k })); + } + + const labelFilters: QueryBuilderLabelFilter[] = options.filters.map((f) => ({ + label: f.key, + value: f.value, + op: f.operator, + })); + const expr = promQueryModeller.renderLabels(labelFilters); + + let labelsIndex: Record<string, string[]> = await this.languageProvider.fetchLabelsWithMatch(expr); + + // filter out already used labels + return Object.keys(labelsIndex) + .filter((labelName) => !options.filters.find((filter) => filter.key === labelName)) + .map((k) => ({ value: k, text: k })); + } + + // By implementing getTagKeys and getTagValues we add ad-hoc filters functionality + async getTagValues(options: DataSourceGetTagValuesOptions) { + const labelFilters: QueryBuilderLabelFilter[] = options.filters.map((f) => ({ + label: f.key, + value: f.value, + op: f.operator, + })); + + const expr = promQueryModeller.renderLabels(labelFilters); + + if (this.hasLabelsMatchAPISupport()) { + return (await this.languageProvider.fetchSeriesValuesWithMatch(options.key, expr, options.timeRange)).map( + (v) => ({ + value: v, + text: v, + }) + ); + } + + const params = this.getTimeRangeParams(options.timeRange ?? getDefaultTimeRange()); + const result = await this.metadataRequest(`/api/v1/label/${options.key}/values`, params); + return result?.data?.data?.map((value: any) => ({ text: value })) ?? []; + } + + interpolateVariablesInQueries( + queries: PromQuery[], + scopedVars: ScopedVars, + filters?: AdHocVariableFilter[] + ): PromQuery[] { + let expandedQueries = queries; + if (queries && queries.length) { + expandedQueries = queries.map((query) => { + const interpolatedQuery = this.templateSrv.replace(query.expr, scopedVars, this.interpolateQueryExpr); + const withAdhocFilters = this.enhanceExprWithAdHocFilters(filters, interpolatedQuery); + + const expandedQuery = { + ...query, + datasource: this.getRef(), + expr: withAdhocFilters, + interval: this.templateSrv.replace(query.interval, scopedVars), + }; + return expandedQuery; + }); + } + return expandedQueries; + } + + getQueryHints(query: PromQuery, result: any[]) { + return getQueryHints(query.expr ?? '', result, this); + } + + getInitHints() { + return getInitHints(this); + } + + async loadRules() { + try { + const res = await this.metadataRequest('/api/v1/rules', {}, { showErrorAlert: false }); + const groups = res.data?.data?.groups; + + if (groups) { + this.ruleMappings = extractRuleMappingFromGroups(groups); + } + } catch (e) { + console.log('Rules API is experimental. Ignore next error.'); + console.error(e); + } + } + + async areExemplarsAvailable() { + try { + const res = await this.metadataRequest( + '/api/v1/query_exemplars', + { + query: 'test', + start: dateTime().subtract(30, 'minutes').valueOf().toString(), + end: dateTime().valueOf().toString(), + }, + { + // Avoid alerting the user if this test fails + showErrorAlert: false, + } + ); + if (res.data.status === 'success') { + return true; + } + return false; + } catch (err) { + return false; + } + } + + modifyQuery(query: PromQuery, action: QueryFixAction): PromQuery { + let expression = query.expr ?? ''; + switch (action.type) { + case 'ADD_FILTER': { + const { key, value } = action.options ?? {}; + if (key && value) { + expression = addLabelToQuery(expression, key, value); + } + + break; + } + case 'ADD_FILTER_OUT': { + const { key, value } = action.options ?? {}; + if (key && value) { + expression = addLabelToQuery(expression, key, value, '!='); + } + break; + } + case 'ADD_HISTOGRAM_QUANTILE': { + expression = `histogram_quantile(0.95, sum(rate(${expression}[$__rate_interval])) by (le))`; + break; + } + case 'ADD_RATE': { + expression = `rate(${expression}[$__rate_interval])`; + break; + } + case 'ADD_SUM': { + expression = `sum(${expression.trim()}) by ($1)`; + break; + } + case 'EXPAND_RULES': { + if (action.options) { + expression = expandRecordingRules(expression, action.options); + } + break; + } + default: + break; + } + return { ...query, expr: expression }; + } + + /** + * Returns the adjusted "snapped" interval parameters + */ + getAdjustedInterval(timeRange: TimeRange): { start: string; end: string } { + return getRangeSnapInterval(this.cacheLevel, timeRange); + } + + /** + * This will return a time range that always includes the users current time range, + * and then a little extra padding to round up/down to the nearest nth minute, + * defined by the result of the getCacheDurationInMinutes. + * + * For longer cache durations, and shorter query durations, + * the window we're calculating might be much bigger then the user's current window, + * resulting in us returning labels/values that might not be applicable for the given window, + * this is a necessary trade-off if we want to cache larger durations + */ + getTimeRangeParams(timeRange: TimeRange): { start: string; end: string } { + return { + start: getPrometheusTime(timeRange.from, false).toString(), + end: getPrometheusTime(timeRange.to, true).toString(), + }; + } + + getOriginalMetricName(labelData: { [key: string]: string }) { + return getOriginalMetricName(labelData); + } + + enhanceExprWithAdHocFilters(filters: AdHocVariableFilter[] | undefined, expr: string) { + if (!filters || filters.length === 0) { + return expr; + } + + const finalQuery = filters.reduce((acc: string, filter: { key?: any; operator?: any; value?: any }) => { + const { key, operator } = filter; + let { value } = filter; + if (operator === '=~' || operator === '!~') { + value = prometheusRegularEscape(value); + } + return addLabelToQuery(acc, key, value, operator); + }, expr); + return finalQuery; + } + + // Used when running queries through backend + filterQuery(query: PromQuery): boolean { + if (query.hide || !query.expr) { + return false; + } + return true; + } + + // Used when running queries through backend + applyTemplateVariables(target: PromQuery, scopedVars: ScopedVars, filters?: AdHocVariableFilter[]) { + const variables = { ...scopedVars }; + + // We want to interpolate these variables on backend. + // The pre-calculated values are replaced withe the variable strings. + variables.__interval = { + value: '$__interval', + }; + variables.__interval_ms = { + value: '$__interval_ms', + }; + + // interpolate expression + const expr = this.templateSrv.replace(target.expr, variables, this.interpolateQueryExpr); + + // Add ad hoc filters + const exprWithAdHocFilters = this.enhanceExprWithAdHocFilters(filters, expr); + + return { + ...target, + expr: exprWithAdHocFilters, + interval: this.templateSrv.replace(target.interval, variables), + legendFormat: this.templateSrv.replace(target.legendFormat, variables), + }; + } + + getVariables(): string[] { + return this.templateSrv.getVariables().map((v) => `$${v.name}`); + } + + interpolateString(string: string, scopedVars?: ScopedVars) { + return this.templateSrv.replace(string, scopedVars, this.interpolateQueryExpr); + } + + getDebounceTimeInMilliseconds(): number { + switch (this.cacheLevel) { + case PrometheusCacheLevel.Medium: + return 600; + case PrometheusCacheLevel.High: + return 1200; + default: + return 350; + } + } + + getDaysToCacheMetadata(): number { + switch (this.cacheLevel) { + case PrometheusCacheLevel.Medium: + return 7; + case PrometheusCacheLevel.High: + return 30; + default: + return 1; + } + } + + getCacheDurationInMinutes(): number { + return getClientCacheDurationInMinutes(this.cacheLevel); + } + + getDefaultQuery(app: CoreApp): PromQuery { + const defaults = { + refId: 'A', + expr: '', + range: true, + instant: false, + }; + + if (app === CoreApp.UnifiedAlerting) { + return { + ...defaults, + instant: true, + range: false, + }; + } + + if (app === CoreApp.Explore) { + return { + ...defaults, + instant: true, + range: true, + }; + } + + return defaults; + } +} + +/** + * Align query range to step. + * Rounds start and end down to a multiple of step. + * @param start Timestamp marking the beginning of the range. + * @param end Timestamp marking the end of the range. + * @param step Interval to align start and end with. + * @param utcOffsetSec Number of seconds current timezone is offset from UTC + */ +export function alignRange( + start: number, + end: number, + step: number, + utcOffsetSec: number +): { end: number; start: number } { + const alignedEnd = Math.floor((end + utcOffsetSec) / step) * step - utcOffsetSec; + const alignedStart = Math.floor((start + utcOffsetSec) / step) * step - utcOffsetSec; + return { + end: alignedEnd, + start: alignedStart, + }; +} + +export function extractRuleMappingFromGroups(groups: any[]) { + return groups.reduce( + (mapping, group) => + group.rules + .filter((rule: any) => rule.type === 'recording') + .reduce( + (acc: { [key: string]: string }, rule: any) => ({ + ...acc, + [rule.name]: rule.query, + }), + mapping + ), + {} + ); +} + +// NOTE: these two functions are very similar to the escapeLabelValueIn* functions +// in language_utils.ts, but they are not exactly the same algorithm, and we found +// no way to reuse one in the another or vice versa. +export function prometheusRegularEscape(value: unknown) { + return typeof value === 'string' ? value.replace(/\\/g, '\\\\').replace(/'/g, "\\\\'") : value; +} + +export function prometheusSpecialRegexEscape(value: unknown) { + return typeof value === 'string' ? value.replace(/\\/g, '\\\\\\\\').replace(/[$^*{}\[\]\'+?.()|]/g, '\\\\$&') : value; +} diff --git a/packages/grafana-prometheus/src/gcopypaste/app/core/components/LocalStorageValueProvider/LocalStorageValueProvider.tsx b/packages/grafana-prometheus/src/gcopypaste/app/core/components/LocalStorageValueProvider/LocalStorageValueProvider.tsx new file mode 100644 index 0000000000000..d0558fe43ee8c --- /dev/null +++ b/packages/grafana-prometheus/src/gcopypaste/app/core/components/LocalStorageValueProvider/LocalStorageValueProvider.tsx @@ -0,0 +1,49 @@ +import React, { useEffect, useState } from 'react'; + +import store from '../../store'; + +export interface Props<T> { + storageKey: string; + defaultValue: T; + children: (value: T, onSaveToStore: (value: T) => void, onDeleteFromStore: () => void) => React.ReactNode; +} + +export const LocalStorageValueProvider = <T,>(props: Props<T>) => { + const { children, storageKey, defaultValue } = props; + + const [state, setState] = useState({ value: store.getObject(props.storageKey, props.defaultValue) }); + + useEffect(() => { + const onStorageUpdate = (v: StorageEvent) => { + if (v.key === storageKey) { + setState({ value: store.getObject(props.storageKey, props.defaultValue) }); + } + }; + + window.addEventListener('storage', onStorageUpdate); + + return () => { + window.removeEventListener('storage', onStorageUpdate); + }; + }); + + const onSaveToStore = (value: T) => { + try { + store.setObject(storageKey, value); + } catch (error) { + console.error(error); + } + setState({ value }); + }; + + const onDeleteFromStore = () => { + try { + store.delete(storageKey); + } catch (error) { + console.log(error); + } + setState({ value: defaultValue }); + }; + + return <>{children(state.value, onSaveToStore, onDeleteFromStore)}</>; +}; diff --git a/packages/grafana-prometheus/src/gcopypaste/app/core/components/LocalStorageValueProvider/index.tsx b/packages/grafana-prometheus/src/gcopypaste/app/core/components/LocalStorageValueProvider/index.tsx new file mode 100644 index 0000000000000..e4bde7ccaeadb --- /dev/null +++ b/packages/grafana-prometheus/src/gcopypaste/app/core/components/LocalStorageValueProvider/index.tsx @@ -0,0 +1 @@ +export { LocalStorageValueProvider } from './LocalStorageValueProvider'; diff --git a/packages/grafana-prometheus/src/gcopypaste/app/core/store.ts b/packages/grafana-prometheus/src/gcopypaste/app/core/store.ts new file mode 100644 index 0000000000000..28587a7d428be --- /dev/null +++ b/packages/grafana-prometheus/src/gcopypaste/app/core/store.ts @@ -0,0 +1,65 @@ +type StoreValue = string | number | boolean | null; + +export class Store { + get(key: string) { + return window.localStorage[key]; + } + + set(key: string, value: StoreValue) { + window.localStorage[key] = value; + } + + getBool(key: string, def: boolean): boolean { + if (def !== void 0 && !this.exists(key)) { + return def; + } + return window.localStorage[key] === 'true'; + } + + getObject<T = unknown>(key: string): T | undefined; + getObject<T = unknown>(key: string, def: T): T; + getObject<T = unknown>(key: string, def?: T) { + let ret = def; + if (this.exists(key)) { + const json = window.localStorage[key]; + try { + ret = JSON.parse(json); + } catch (error) { + console.error(`Error parsing store object: ${key}. Returning default: ${def}. [${error}]`); + } + } + return ret; + } + + /* Returns true when successfully stored, throws error if not successfully stored */ + setObject(key: string, value: unknown) { + let json; + try { + json = JSON.stringify(value); + } catch (error) { + throw new Error(`Could not stringify object: ${key}. [${error}]`); + } + try { + this.set(key, json); + } catch (error) { + // Likely hitting storage quota + const errorToThrow = new Error(`Could not save item in localStorage: ${key}. [${error}]`); + if (error instanceof Error) { + errorToThrow.name = error.name; + } + throw errorToThrow; + } + return true; + } + + exists(key: string) { + return window.localStorage[key] !== void 0; + } + + delete(key: string) { + window.localStorage.removeItem(key); + } +} + +const store = new Store(); +export default store; diff --git a/packages/grafana-prometheus/src/gcopypaste/app/core/utils/CancelablePromise.ts b/packages/grafana-prometheus/src/gcopypaste/app/core/utils/CancelablePromise.ts new file mode 100644 index 0000000000000..221e5ef2bb656 --- /dev/null +++ b/packages/grafana-prometheus/src/gcopypaste/app/core/utils/CancelablePromise.ts @@ -0,0 +1,31 @@ +// https://github.com/facebook/react/issues/5465 + +export interface CancelablePromise<T> { + promise: Promise<T>; + cancel: () => void; +} + +export interface CancelablePromiseRejection { + isCanceled: boolean; +} + +export function isCancelablePromiseRejection(promise: unknown): promise is CancelablePromiseRejection { + return typeof promise === 'object' && promise !== null && 'isCanceled' in promise; +} + +export const makePromiseCancelable = <T>(promise: Promise<T>): CancelablePromise<T> => { + let hasCanceled_ = false; + + const wrappedPromise = new Promise<T>((resolve, reject) => { + const canceledPromiseRejection: CancelablePromiseRejection = { isCanceled: true }; + promise.then((val) => (hasCanceled_ ? reject(canceledPromiseRejection) : resolve(val))); + promise.catch((error) => (hasCanceled_ ? reject(canceledPromiseRejection) : reject(error))); + }); + + return { + promise: wrappedPromise, + cancel() { + hasCanceled_ = true; + }, + }; +}; diff --git a/packages/grafana-prometheus/src/gcopypaste/app/core/utils/query.ts b/packages/grafana-prometheus/src/gcopypaste/app/core/utils/query.ts new file mode 100644 index 0000000000000..4084e418f18f7 --- /dev/null +++ b/packages/grafana-prometheus/src/gcopypaste/app/core/utils/query.ts @@ -0,0 +1,20 @@ +import { DataQuery } from '@grafana/data'; + +export const getNextRefIdChar = (queries: DataQuery[]): string => { + for (let num = 0; ; num++) { + const refId = getRefId(num); + if (!queries.some((query) => query.refId === refId)) { + return refId; + } + } +}; + +function getRefId(num: number): string { + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + + if (num < letters.length) { + return letters[num]; + } else { + return getRefId(Math.floor(num / letters.length) - 1) + letters[num % letters.length]; + } +} diff --git a/packages/grafana-prometheus/src/gcopypaste/app/features/datasources/__mocks__/dataSourcesMocks.ts b/packages/grafana-prometheus/src/gcopypaste/app/features/datasources/__mocks__/dataSourcesMocks.ts new file mode 100644 index 0000000000000..db78a03b28919 --- /dev/null +++ b/packages/grafana-prometheus/src/gcopypaste/app/features/datasources/__mocks__/dataSourcesMocks.ts @@ -0,0 +1,30 @@ +import { merge } from 'lodash'; + +import { DataSourceJsonData, DataSourceSettings } from '@grafana/data'; + +export const getMockDataSource = <T extends DataSourceJsonData>( + overrides?: Partial<DataSourceSettings<T>> +): DataSourceSettings<T> => + merge( + { + access: '', + basicAuth: false, + basicAuthUser: '', + withCredentials: false, + database: '', + id: 13, + uid: 'x', + isDefault: false, + jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' }, + name: 'gdev-prometheus', + typeName: 'Prometheus', + orgId: 1, + readOnly: false, + type: 'prometheus', + typeLogoUrl: 'packages/grafana-prometheus/src/img/prometheus_logo.svg', + url: '', + user: '', + secureJsonFields: {}, + }, + overrides + ); diff --git a/packages/grafana-prometheus/src/gcopypaste/app/features/live/data/amendTimeSeries.ts b/packages/grafana-prometheus/src/gcopypaste/app/features/live/data/amendTimeSeries.ts new file mode 100644 index 0000000000000..39b01600d46e1 --- /dev/null +++ b/packages/grafana-prometheus/src/gcopypaste/app/features/live/data/amendTimeSeries.ts @@ -0,0 +1,93 @@ +import { closestIdx } from '@grafana/data'; + +export type Table = [times: number[], ...values: any[][]]; + +// prevTable and nextTable are assumed sorted ASC on reference [0] arrays +// nextTable is assumed to be contiguous, only edges are checked for overlap +// ...so prev: [1,2,5] + next: [3,4,6] -> [1,2,3,4,6] +export function amendTable(prevTable: Table, nextTable: Table): Table { + let [prevTimes] = prevTable; + let [nextTimes] = nextTable; + + let pLen = prevTimes.length; + let pStart = prevTimes[0]; + let pEnd = prevTimes[pLen - 1]; + + let nLen = nextTimes.length; + let nStart = nextTimes[0]; + let nEnd = nextTimes[nLen - 1]; + + let outTable: Table; + + if (pLen) { + if (nLen) { + // append, no overlap + if (nStart > pEnd) { + outTable = prevTable.map((_, i) => prevTable[i].concat(nextTable[i])) as Table; + } + // prepend, no overlap + else if (nEnd < pStart) { + outTable = nextTable.map((_, i) => nextTable[i].concat(prevTable[i])) as Table; + } + // full replace + else if (nStart <= pStart && nEnd >= pEnd) { + outTable = nextTable; + } + // partial replace + else if (nStart > pStart && nEnd < pEnd) { + } + // append, with overlap + else if (nStart >= pStart) { + let idx = closestIdx(nStart, prevTimes); + idx = prevTimes[idx] < nStart ? idx - 1 : idx; + outTable = prevTable.map((_, i) => prevTable[i].slice(0, idx).concat(nextTable[i])) as Table; + } + // prepend, with overlap + else if (nEnd >= pStart) { + let idx = closestIdx(nEnd, prevTimes); + idx = prevTimes[idx] < nEnd ? idx : idx + 1; + outTable = nextTable.map((_, i) => nextTable[i].concat(prevTable[i].slice(idx))) as Table; + } + } else { + outTable = prevTable; + } + } else { + if (nLen) { + outTable = nextTable; + } else { + outTable = [[]]; + } + } + + return outTable!; +} + +export function trimTable(table: Table, fromTime: number, toTime: number): Table { + let [times, ...vals] = table; + let fromIdx: number | undefined; + let toIdx: number | undefined; + + // trim to bounds + if (times[0] < fromTime) { + fromIdx = closestIdx(fromTime, times); + + if (times[fromIdx] < fromTime) { + fromIdx++; + } + } + + if (times[times.length - 1] > toTime) { + toIdx = closestIdx(toTime, times); + + if (times[toIdx] > toTime) { + toIdx--; + } + } + + if (fromIdx != null || toIdx != null) { + times = times.slice(fromIdx ?? 0, toIdx); + vals = vals.map(vals2 => vals2.slice(fromIdx ?? 0, toIdx)); + } + + return [times, ...vals]; +} diff --git a/packages/grafana-prometheus/src/gcopypaste/packages/grafana-ui/src/components/Select/SelectBase.tsx b/packages/grafana-prometheus/src/gcopypaste/packages/grafana-ui/src/components/Select/SelectBase.tsx new file mode 100644 index 0000000000000..9c6e81eec9ff9 --- /dev/null +++ b/packages/grafana-prometheus/src/gcopypaste/packages/grafana-ui/src/components/Select/SelectBase.tsx @@ -0,0 +1,126 @@ +import { cx } from '@emotion/css'; +import { max } from 'lodash'; +import React, { RefCallback } from 'react'; +import { MenuListProps } from 'react-select'; +import { FixedSizeList as List } from 'react-window'; + +import { SelectableValue, toIconName } from '@grafana/data'; +import { CustomScrollbar, Icon, getSelectStyles, useTheme2 } from '@grafana/ui'; + +interface SelectMenuProps { + maxHeight: number; + innerRef: RefCallback<HTMLDivElement>; + innerProps: {}; +} + +export const SelectMenu = ({ children, maxHeight, innerRef, innerProps }: React.PropsWithChildren<SelectMenuProps>) => { + const theme = useTheme2(); + const styles = getSelectStyles(theme); + + return ( + <div {...innerProps} className={styles.menu} style={{ maxHeight }} aria-label="Select options menu"> + <CustomScrollbar scrollRefCallback={innerRef} autoHide={false} autoHeightMax="inherit" hideHorizontalTrack> + {children} + </CustomScrollbar> + </div> + ); +}; + +SelectMenu.displayName = 'SelectMenu'; + +const VIRTUAL_LIST_ITEM_HEIGHT = 37; +const VIRTUAL_LIST_WIDTH_ESTIMATE_MULTIPLIER = 7; + +// A virtualized version of the SelectMenu, descriptions for SelectableValue options not supported since those are of a variable height. +// +// To support the virtualized list we have to "guess" the width of the menu container based on the longest available option. +// the reason for this is because all of the options will be positioned absolute, this takes them out of the document and no space +// is created for them, thus the container can't grow to accomodate. +// +// VIRTUAL_LIST_ITEM_HEIGHT and WIDTH_ESTIMATE_MULTIPLIER are both magic numbers. +// Some characters (such as emojis and other unicode characters) may consist of multiple code points in which case the width would be inaccurate (but larger than needed). +export const VirtualizedSelectMenu = ({ children, maxHeight, options, getValue }: MenuListProps<SelectableValue>) => { + const theme = useTheme2(); + const styles = getSelectStyles(theme); + const [value] = getValue(); + + const valueIndex = value ? options.findIndex((option: SelectableValue<unknown>) => option.value === value.value) : 0; + const initialOffset = valueIndex * VIRTUAL_LIST_ITEM_HEIGHT; + + if (!Array.isArray(children)) { + return null; + } + + const longestOption = max(options.map((option) => option.label?.length)) ?? 0; + const widthEstimate = longestOption * VIRTUAL_LIST_WIDTH_ESTIMATE_MULTIPLIER; + const heightEstimate = Math.min(options.length * VIRTUAL_LIST_ITEM_HEIGHT, maxHeight); + + return ( + <List + className={styles.menu} + height={heightEstimate} + width={widthEstimate} + aria-label="Select options menu" + itemCount={children.length} + itemSize={VIRTUAL_LIST_ITEM_HEIGHT} + initialScrollOffset={initialOffset} + > + {({ index, style }) => <div style={{ ...style, overflow: 'hidden' }}>{children[index]}</div>} + </List> + ); +}; + +VirtualizedSelectMenu.displayName = 'VirtualizedSelectMenu'; + +interface SelectMenuOptionProps<T> { + isDisabled: boolean; + isFocused: boolean; + isSelected: boolean; + innerProps: JSX.IntrinsicElements['div']; + innerRef: RefCallback<HTMLDivElement>; + renderOptionLabel?: (value: SelectableValue<T>) => JSX.Element; + data: SelectableValue<T>; +} + +export const SelectMenuOptions = ({ + children, + data, + innerProps, + innerRef, + isFocused, + isSelected, + renderOptionLabel, +}: React.PropsWithChildren<SelectMenuOptionProps<unknown>>) => { + const theme = useTheme2(); + const styles = getSelectStyles(theme); + const icon = data.icon ? toIconName(data.icon) : undefined; + // We are removing onMouseMove and onMouseOver from innerProps because they cause the whole + // list to re-render everytime the user hovers over an option. This is a performance issue. + // See https://github.com/JedWatson/react-select/issues/3128#issuecomment-451936743 + const { onMouseMove, onMouseOver, ...rest } = innerProps; + + return ( + <div + ref={innerRef} + className={cx( + styles.option, + isFocused && styles.optionFocused, + isSelected && styles.optionSelected, + data.isDisabled && styles.optionDisabled + )} + {...rest} + aria-label="Select option" + title={data.title} + > + {icon && <Icon name={icon} className={styles.optionIcon} />} + {data.imgUrl && <img className={styles.optionImage} src={data.imgUrl} alt={data.label || String(data.value)} />} + <div className={styles.optionBody}> + <span>{renderOptionLabel ? renderOptionLabel(data) : children}</span> + {data.description && <div className={styles.optionDescription}>{data.description}</div>} + {data.component && <data.component />} + </div> + </div> + ); +}; + +SelectMenuOptions.displayName = 'SelectMenuOptions'; diff --git a/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/index.ts b/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/index.ts new file mode 100644 index 0000000000000..b6f8a091aaa51 --- /dev/null +++ b/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/index.ts @@ -0,0 +1,10 @@ +import { Observable } from 'rxjs'; + +import { toEmitValues } from './toEmitValues'; +import { toEmitValuesWith } from './toEmitValuesWith'; +import { ObservableMatchers } from './types'; + +export const matchers: ObservableMatchers<void, Observable<any>> = { + toEmitValues, + toEmitValuesWith, +}; diff --git a/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValues.test.ts b/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValues.test.ts new file mode 100644 index 0000000000000..71edff27e8d3c --- /dev/null +++ b/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValues.test.ts @@ -0,0 +1,140 @@ +import { interval, Observable, of, throwError } from 'rxjs'; +import { map, mergeMap, take } from 'rxjs/operators'; + +import { OBSERVABLE_TEST_TIMEOUT_IN_MS } from './types'; + +describe('toEmitValues matcher', () => { + describe('failing tests', () => { + describe('passing null in expect', () => { + it('should fail', async () => { + const observable = null as unknown as Observable<number>; + + const rejects = expect(() => expect(observable).toEmitValues([1, 2, 3])).rejects; + await rejects.toThrow(); + }); + }); + + describe('passing undefined in expect', () => { + it('should fail', async () => { + const observable = undefined as unknown as Observable<number>; + + const rejects = expect(() => expect(observable).toEmitValues([1, 2, 3])).rejects; + await rejects.toThrow(); + }); + }); + + describe('passing number instead of Observable in expect', () => { + it('should fail', async () => { + const observable = 1 as unknown as Observable<number>; + + const rejects = expect(() => expect(observable).toEmitValues([1, 2, 3])).rejects; + await rejects.toThrow(); + }); + }); + + describe('wrong number of emitted values', () => { + it('should fail', async () => { + const observable = interval(10).pipe(take(3)); + + const rejects = expect(() => expect(observable).toEmitValues([0, 1])).rejects; + await rejects.toThrow(); + }); + }); + + describe('wrong emitted values', () => { + it('should fail', async () => { + const observable = interval(10).pipe(take(3)); + + const rejects = expect(() => expect(observable).toEmitValues([1, 2, 3])).rejects; + await rejects.toThrow(); + }); + }); + + describe('wrong emitted value types', () => { + it('should fail', async () => { + const observable = interval(10).pipe(take(3)) as unknown as Observable<string>; + + const rejects = expect(() => expect(observable).toEmitValues(['0', '1', '2'])).rejects; + await rejects.toThrow(); + }); + }); + + describe(`observable that does not complete within ${OBSERVABLE_TEST_TIMEOUT_IN_MS}ms`, () => { + it('should fail', async () => { + const observable = interval(600); + + const rejects = expect(() => expect(observable).toEmitValues([0])).rejects; + await rejects.toThrow(); + }); + }); + }); + + describe('passing tests', () => { + describe('correct emitted values', () => { + it('should pass with correct message', async () => { + const observable = interval(10).pipe(take(3)); + await expect(observable).toEmitValues([0, 1, 2]); + }); + }); + + describe('using nested arrays', () => { + it('should pass with correct message', async () => { + const observable = interval(10).pipe( + map((interval) => [{ text: interval.toString(), value: interval }]), + take(3) + ); + await expect(observable).toEmitValues([ + [{ text: '0', value: 0 }], + [{ text: '1', value: 1 }], + [{ text: '2', value: 2 }], + ]); + }); + }); + + describe('using nested objects', () => { + it('should pass with correct message', async () => { + const observable = interval(10).pipe( + map((interval) => ({ inner: { text: interval.toString(), value: interval } })), + take(3) + ); + await expect(observable).toEmitValues([ + { inner: { text: '0', value: 0 } }, + { inner: { text: '1', value: 1 } }, + { inner: { text: '2', value: 2 } }, + ]); + }); + }); + + describe('correct emitted values with throw', () => { + it('should pass with correct message', async () => { + const observable = interval(10).pipe( + map((interval) => { + if (interval > 1) { + throw 'an error'; + } + + return interval; + }) + ) as unknown as Observable<string | number>; + + await expect(observable).toEmitValues([0, 1, 'an error']); + }); + }); + + describe('correct emitted values with throwError', () => { + it('should pass with correct message', async () => { + const observable = interval(10).pipe( + mergeMap((interval) => { + if (interval === 1) { + return throwError('an error'); + } + + return of(interval); + }) + ) as unknown as Observable<string | number>; + + await expect(observable).toEmitValues([0, 'an error']); + }); + }); + }); +}); diff --git a/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValues.ts b/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValues.ts new file mode 100644 index 0000000000000..d2987471a38c8 --- /dev/null +++ b/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValues.ts @@ -0,0 +1,90 @@ +import { matcherHint, printExpected, printReceived } from 'jest-matcher-utils'; +import { isEqual } from 'lodash'; +import { Observable, Subscription } from 'rxjs'; + +import { expectObservable, forceObservableCompletion } from './utils'; + +function passMessage(received: unknown[], expected: unknown[]) { + return `${matcherHint('.not.toEmitValues')} + + Expected observable to emit values: + ${printExpected(expected)} + Received: + ${printReceived(received)} + `; +} + +function failMessage(received: unknown[], expected: unknown[]) { + return `${matcherHint('.toEmitValues')} + + Expected observable to emit values: + ${printExpected(expected)} + Received: + ${printReceived(received)} + `; +} + +function tryExpectations(received: unknown[], expected: unknown[]): jest.CustomMatcherResult { + try { + if (received.length !== expected.length) { + return { + pass: false, + message: () => failMessage(received, expected), + }; + } + + for (let index = 0; index < received.length; index++) { + const left = received[index]; + const right = expected[index]; + + if (!isEqual(left, right)) { + return { + pass: false, + message: () => failMessage(received, expected), + }; + } + } + + return { + pass: true, + message: () => passMessage(received, expected), + }; + } catch (err) { + const message = err instanceof Error ? err.message : 'An unknown error occurred'; + return { + pass: false, + message: () => message, + }; + } +} + +export function toEmitValues(received: Observable<unknown>, expected: unknown[]): Promise<jest.CustomMatcherResult> { + const failsChecks = expectObservable(received); + if (failsChecks) { + return Promise.resolve(failsChecks); + } + + return new Promise((resolve) => { + const receivedValues: unknown[] = []; + const subscription = new Subscription(); + + subscription.add( + received.subscribe({ + next: (value) => { + receivedValues.push(value); + }, + error: (err) => { + receivedValues.push(err); + subscription.unsubscribe(); + resolve(tryExpectations(receivedValues, expected)); + }, + complete: () => { + subscription.unsubscribe(); + resolve(tryExpectations(receivedValues, expected)); + }, + }) + ); + + forceObservableCompletion(subscription, resolve); + }); +} diff --git a/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValuesWith.test.ts b/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValuesWith.test.ts new file mode 100644 index 0000000000000..7ca3eed1c57f1 --- /dev/null +++ b/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValuesWith.test.ts @@ -0,0 +1,153 @@ +import { interval, Observable, of, throwError } from 'rxjs'; +import { map, mergeMap, take } from 'rxjs/operators'; + +import { OBSERVABLE_TEST_TIMEOUT_IN_MS } from './types'; + +describe('toEmitValuesWith matcher', () => { + describe('failing tests', () => { + describe('passing null in expect', () => { + it('should fail with correct message', async () => { + const observable = null as unknown as Observable<number>; + + const rejects = expect(() => + expect(observable).toEmitValuesWith((received) => { + expect(received).toEqual([1, 2, 3]); + }) + ).rejects; + + await rejects.toThrow(); + }); + }); + + describe('passing undefined in expect', () => { + it('should fail with correct message', async () => { + const observable = undefined as unknown as Observable<number>; + + const rejects = expect(() => + expect(observable).toEmitValuesWith((received) => { + expect(received).toEqual([1, 2, 3]); + }) + ).rejects; + + await rejects.toThrow(); + }); + }); + + describe('passing number instead of Observable in expect', () => { + it('should fail with correct message', async () => { + const observable = 1 as unknown as Observable<number>; + + const rejects = expect(() => + expect(observable).toEmitValuesWith((received) => { + expect(received).toEqual([1, 2, 3]); + }) + ).rejects; + + await rejects.toThrow(); + }); + }); + + describe('wrong number of emitted values', () => { + it('should fail with correct message', async () => { + const observable = interval(10).pipe(take(3)); + + const rejects = expect(() => + expect(observable).toEmitValuesWith((received) => { + expect(received).toEqual([0, 1]); + }) + ).rejects; + + await rejects.toThrow(); + }); + }); + + describe('wrong emitted values', () => { + it('should fail with correct message', async () => { + const observable = interval(10).pipe(take(3)); + + const rejects = expect(() => + expect(observable).toEmitValuesWith((received) => { + expect(received).toEqual([1, 2, 3]); + }) + ).rejects; + + await rejects.toThrow(); + }); + }); + + describe('wrong emitted value types', () => { + it('should fail with correct message', async () => { + const observable = interval(10).pipe(take(3)) as unknown as Observable<string>; + + const rejects = expect(() => + expect(observable).toEmitValuesWith((received) => { + expect(received).toEqual(['0', '1', '2']); + }) + ).rejects; + + await rejects.toThrow(); + }); + }); + + describe(`observable that does not complete within ${OBSERVABLE_TEST_TIMEOUT_IN_MS}ms`, () => { + it('should fail with correct message', async () => { + const observable = interval(600); + + const rejects = expect(() => + expect(observable).toEmitValuesWith((received) => { + expect(received).toEqual([0]); + }) + ).rejects; + + await rejects.toThrow(); + }); + }); + }); + + describe('passing tests', () => { + describe('correct emitted values', () => { + it('should pass with correct message', async () => { + const observable = interval(10).pipe(take(3)); + await expect(observable).toEmitValuesWith((received) => { + expect(received).toEqual([0, 1, 2]); + }); + }); + }); + + describe('correct emitted values with throw', () => { + it('should pass with correct message', async () => { + const observable = interval(10).pipe( + map((interval) => { + if (interval > 1) { + throw 'an error'; + } + + return interval; + }) + ); + + await expect(observable).toEmitValuesWith((received) => { + expect(received).toEqual([0, 1, 'an error']); + }); + }); + }); + + describe('correct emitted values with throwError', () => { + it('should pass with correct message', async () => { + const observable = interval(10).pipe( + mergeMap((interval) => { + if (interval === 1) { + return throwError('an error'); + } + + return of(interval); + }) + ); + + await expect(observable).toEmitValuesWith((received) => { + expect(received).toEqual([0, 'an error']); + }); + }); + }); + }); +}); diff --git a/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValuesWith.ts b/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValuesWith.ts new file mode 100644 index 0000000000000..d9a53e965a08c --- /dev/null +++ b/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValuesWith.ts @@ -0,0 +1,62 @@ +import { matcherHint, printReceived } from 'jest-matcher-utils'; +import { Observable, Subscription } from 'rxjs'; + +import { expectObservable, forceObservableCompletion } from './utils'; + +function tryExpectations(received: unknown[], expectations: (received: unknown[]) => void): jest.CustomMatcherResult { + try { + expectations(received); + return { + pass: true, + message: () => `${matcherHint('.not.toEmitValues')} + + Expected observable to complete with + ${printReceived(received)} + `, + }; + } catch (err) { + return { + pass: false, + message: () => 'failed ' + err, + }; + } +} + +/** + * Collect all the values emitted by the observables (also errors) and pass them to the expectations functions after + * the observable ended (or emitted error). If Observable does not complete within OBSERVABLE_TEST_TIMEOUT_IN_MS the + * test fails. + */ +export function toEmitValuesWith( + received: Observable<any>, + expectations: (actual: any[]) => void +): Promise<jest.CustomMatcherResult> { + const failsChecks = expectObservable(received); + if (failsChecks) { + return Promise.resolve(failsChecks); + } + + return new Promise((resolve) => { + const receivedValues: any[] = []; + const subscription = new Subscription(); + + subscription.add( + received.subscribe({ + next: (value) => { + receivedValues.push(value); + }, + error: (err) => { + receivedValues.push(err); + subscription.unsubscribe(); + resolve(tryExpectations(receivedValues, expectations)); + }, + complete: () => { + subscription.unsubscribe(); + resolve(tryExpectations(receivedValues, expectations)); + }, + }) + ); + + forceObservableCompletion(subscription, resolve); + }); +} diff --git a/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/types.ts b/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/types.ts new file mode 100644 index 0000000000000..c67b32897f52a --- /dev/null +++ b/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/types.ts @@ -0,0 +1,13 @@ +import { Observable } from 'rxjs'; + +export const OBSERVABLE_TEST_TIMEOUT_IN_MS = 1000; + +export type ObservableType<T> = T extends Observable<infer V> ? V : never; + +export interface ObservableMatchers<R, T = {}> extends jest.ExpectExtendMap { + toEmitValues<E = ObservableType<T>>(received: T, expected: E[]): Promise<jest.CustomMatcherResult>; + toEmitValuesWith<E = ObservableType<T>>( + received: T, + expectations: (received: E[]) => void + ): Promise<jest.CustomMatcherResult>; +} diff --git a/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/utils.ts b/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/utils.ts new file mode 100644 index 0000000000000..e7758bb917786 --- /dev/null +++ b/packages/grafana-prometheus/src/gcopypaste/public/test/matchers/utils.ts @@ -0,0 +1,63 @@ +import { matcherHint, printExpected, printReceived } from 'jest-matcher-utils'; +import { asapScheduler, Subscription, timer, isObservable } from 'rxjs'; + +import { OBSERVABLE_TEST_TIMEOUT_IN_MS } from './types'; + +export function forceObservableCompletion(subscription: Subscription, resolve: (args: any) => void) { + const timeoutObservable = timer(OBSERVABLE_TEST_TIMEOUT_IN_MS, asapScheduler); + + subscription.add( + timeoutObservable.subscribe(() => { + subscription.unsubscribe(); + resolve({ + pass: false, + message: () => + `${matcherHint('.toEmitValues')} + + Expected ${printReceived('Observable')} to be ${printExpected( + `completed within ${OBSERVABLE_TEST_TIMEOUT_IN_MS}ms` + )} but it did not.`, + }); + }) + ); +} + +export function expectObservableToBeDefined(received: unknown): jest.CustomMatcherResult | null { + if (received) { + return null; + } + + return { + pass: false, + message: () => `${matcherHint('.toEmitValues')} + +Expected ${printReceived(received)} to be ${printExpected('defined')}.`, + }; +} + +export function expectObservableToBeObservable(received: unknown): jest.CustomMatcherResult | null { + if (isObservable(received)) { + return null; + } + + return { + pass: false, + message: () => `${matcherHint('.toEmitValues')} + +Expected ${printReceived(received)} to be ${printExpected('an Observable')}.`, + }; +} + +export function expectObservable(received: unknown): jest.CustomMatcherResult | null { + const toBeDefined = expectObservableToBeDefined(received); + if (toBeDefined) { + return toBeDefined; + } + + const toBeObservable = expectObservableToBeObservable(received); + if (toBeObservable) { + return toBeObservable; + } + + return null; +} diff --git a/packages/grafana-prometheus/src/gcopypaste/test/helpers/selectOptionInTest.ts b/packages/grafana-prometheus/src/gcopypaste/test/helpers/selectOptionInTest.ts new file mode 100644 index 0000000000000..c1ff432daccde --- /dev/null +++ b/packages/grafana-prometheus/src/gcopypaste/test/helpers/selectOptionInTest.ts @@ -0,0 +1,23 @@ +import { Matcher, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { select } from 'react-select-event'; +import { byRole } from 'testing-library-selector'; + +// Used to select an option or options from a Select in unit tests +export const selectOptionInTest = async ( + input: HTMLElement, + optionOrOptions: string | RegExp | Array<string | RegExp> +) => await waitFor(() => select(input, optionOrOptions, { container: document.body })); + +// Finds the parent of the Select so you can assert if it has a value +export const getSelectParent = (input: HTMLElement) => + input.parentElement?.parentElement?.parentElement?.parentElement?.parentElement; + +export const clickSelectOption = async (selectElement: HTMLElement, optionText: string): Promise<void> => { + await userEvent.click(byRole('combobox').get(selectElement)); + await selectOptionInTest(selectElement, optionText); +}; +export const clickSelectOptionMatch = async (selectElement: HTMLElement, optionText: Matcher): Promise<void> => { + await userEvent.click(byRole('combobox').get(selectElement)); + await selectOptionInTest(selectElement, optionText as string); +}; diff --git a/packages/grafana-prometheus/src/img/cortex_logo.svg b/packages/grafana-prometheus/src/img/cortex_logo.svg new file mode 100644 index 0000000000000..48ec08428e14e --- /dev/null +++ b/packages/grafana-prometheus/src/img/cortex_logo.svg @@ -0,0 +1 @@ +<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g fill="#3b697e"><path d="m64.001 32c0-17.646-14.356-32.001-32.001-32.001-17.646 0-32.001 14.355-32.001 32.001s14.355 32.001 32.001 32.001 32.001-14.355 32.001-32.001zm-62.592 0c0-16.868 13.723-30.591 30.591-30.591s30.591 13.723 30.591 30.591-13.723 30.591-30.591 30.591-30.591-13.723-30.591-30.591z" fill-rule="nonzero"/><circle cx="49.3" cy="32.725" r="5.296"/><circle cx="31.875" cy="52.94" r="5.296"/><path d="m31.148 33.103 3.295-8.185 2.988 8.538h2.684c.226 2.71 1.641 5.186 3.861 6.757l-8.659 8.659.665.665 8.808-8.808c1.378.774 2.932 1.18 4.512 1.18 5.056 0 9.216-4.16 9.216-9.215 0-5.056-4.16-9.216-9.216-9.216-1.464 0-2.907.349-4.209 1.017l-9.039-8.587c.797-.953 1.234-2.156 1.234-3.399 0-2.906-2.391-5.297-5.297-5.297s-5.297 2.391-5.297 5.297 2.391 5.297 5.297 5.297c1.242 0 2.445-.436 3.397-1.233l8.86 8.417c-2.542 1.666-4.102 4.486-4.162 7.525h-1.987l-3.594-10.267-3.992 9.915h-4.457l-3.788 8.766-3.341-8.353-.01.004v-.09h-3.378c-.124-2.816-2.474-5.064-5.292-5.064-2.907 0-5.298 2.391-5.298 5.298 0 2.906 2.391 5.298 5.298 5.298 2.64 0 4.898-1.975 5.25-4.592h2.759l3.978 9.947 4.44-10.274zm18.152-8.685c4.54 0 8.276 3.736 8.276 8.276s-3.736 8.276-8.276 8.276-8.276-3.736-8.276-8.276c.005-4.538 3.737-8.271 8.276-8.276z" fill-rule="nonzero"/></g></svg> \ No newline at end of file diff --git a/packages/grafana-prometheus/src/img/mimir_logo.svg b/packages/grafana-prometheus/src/img/mimir_logo.svg new file mode 100644 index 0000000000000..bd9605f592b5a --- /dev/null +++ b/packages/grafana-prometheus/src/img/mimir_logo.svg @@ -0,0 +1 @@ +<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientTransform="matrix(0 14.3827 -14.3827 0 7.68261 2.43042)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#f2c144"/><stop offset=".24" stop-color="#f1a03b"/><stop offset=".57" stop-color="#f17a31"/><stop offset=".84" stop-color="#f0632a"/><stop offset="1" stop-color="#f05a28"/></linearGradient><path d="m1.941 13.102h2.194l1.538-2.953-1.065-2.043zm11.935-5.079-1.2 2.303 1.406 2.742 1.2-2.313zm-.364-.705-2.562-4.971-1.263 2.228 2.623 5.049zm-3.991 3.039 1.429 2.743h2.436l-2.648-5.083zm-5.276-2.952-1.195-2.305-1.525 2.933 1.172 2.269zm-3.077 1.327-1.09 2.152 1.2 2.179 1.063-2.057zm8.113-3.494-1.27 2.243 1.146 2.179 1.219-2.34zm-4.792-2.98-1.096 2.133 4.264 8.154 1.137-2.182-2.155-4.135z" fill="url(#a)" fill-rule="nonzero"/></svg> \ No newline at end of file diff --git a/packages/grafana-prometheus/src/img/prometheus_logo.svg b/packages/grafana-prometheus/src/img/prometheus_logo.svg new file mode 100644 index 0000000000000..4c4448862e602 --- /dev/null +++ b/packages/grafana-prometheus/src/img/prometheus_logo.svg @@ -0,0 +1 @@ +<svg width="2490" height="2500" viewBox="0 0 256 257" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M128.001.667C57.311.667 0 57.971 0 128.664c0 70.69 57.311 127.998 128.001 127.998S256 199.354 256 128.664C256 57.97 198.689.667 128.001.667zm0 239.56c-20.112 0-36.419-13.435-36.419-30.004h72.838c0 16.566-16.306 30.004-36.419 30.004zm60.153-39.94H67.842V178.47h120.314v21.816h-.002zm-.432-33.045H68.185c-.398-.458-.804-.91-1.188-1.375-12.315-14.954-15.216-22.76-18.032-30.716-.048-.262 14.933 3.06 25.556 5.45 0 0 5.466 1.265 13.458 2.722-7.673-8.994-12.23-20.428-12.23-32.116 0-25.658 19.68-48.079 12.58-66.201 6.91.562 14.3 14.583 14.8 36.505 7.346-10.152 10.42-28.69 10.42-40.056 0-11.769 7.755-25.44 15.512-25.907-6.915 11.396 1.79 21.165 9.53 45.4 2.902 9.103 2.532 24.423 4.772 34.138.744-20.178 4.213-49.62 17.014-59.784-5.647 12.8.836 28.818 5.27 36.518 7.154 12.424 11.49 21.836 11.49 39.638 0 11.936-4.407 23.173-11.84 31.958 8.452-1.586 14.289-3.016 14.289-3.016l27.45-5.355c.002-.002-3.987 16.401-19.314 32.197z" fill="#DA4E31"/></svg> \ No newline at end of file diff --git a/packages/grafana-prometheus/src/img/thanos_logo.svg b/packages/grafana-prometheus/src/img/thanos_logo.svg new file mode 100644 index 0000000000000..1e7be78577644 --- /dev/null +++ b/packages/grafana-prometheus/src/img/thanos_logo.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="2.41 2.16 355.42 355.17"><path fill="#fff" d="M9.49808 9.14822v341.59786h241.93485a99.66306 99.66306 0 0 0 99.657-99.663V9.14822zm266.263 215.32837h12.75033v12.75018h-12.75035zm-4.00963-54.89h20.71531v20.72131h-20.71533zm-3.186-54.10261h27.09335v27.11729h-27.09343zm-43.739 159.90321h12.7381v12.75018h-12.75017zm-3.98558-55.53932h20.72129V240.587h-20.72129zm-3.186-53.44731h27.12344v27.09337h-27.09339zm3.186-27.00322v-20.71528h20.72129v20.71527zm-97.82954 135.98981h12.75021v12.75018h-12.75021zm-3.98558-54.8901h20.72131v20.69121h-20.72124zm3.98558-34.1748v-12.75019h12.75021v12.75018zm-3.98558-67.64019h20.72131v20.71527h-20.72124zM72.10081 275.38715H84.875v12.75018H72.10081zm0-50.91056H84.875v12.75018H72.10081zm-3.97955-54.89h20.7153v20.72131h-20.7153zm-3.19206-54.10265h27.09939v27.11729H64.9292zM53.01459 52.67679h254.53469v50.91052H205.74622v203.6302h-50.90449v-203.6302H53.01459z"/></svg> diff --git a/packages/grafana-prometheus/src/index.ts b/packages/grafana-prometheus/src/index.ts new file mode 100644 index 0000000000000..76302ec9570be --- /dev/null +++ b/packages/grafana-prometheus/src/index.ts @@ -0,0 +1,87 @@ +// The Grafana Prometheus library exports a number of components. +// There are main components that can be imported directly into your plugin module.ts file. +// There are also more granular components that can be used to build components, for example, the config section can be built with granular parts to allow for custom auths. + +// COMPONENTS/ +// Main export +export { PromQueryEditorByApp } from './components/PromQueryEditorByApp'; +// The parts +export { MonacoQueryFieldLazy } from './components/monaco-query-field/MonacoQueryFieldLazy'; +export { AnnotationQueryEditor } from './components/AnnotationQueryEditor'; +export { PromCheatSheet } from './components/PromCheatSheet'; +export { PrometheusMetricsBrowser } from './components/PrometheusMetricsBrowser'; +export { PromExemplarField } from './components/PromExemplarField'; +export { PromExploreExtraField } from './components/PromExploreExtraField'; +export { PromQueryEditorForAlerting } from './components/PromQueryEditorForAlerting'; +export { PromQueryField } from './components/PromQueryField'; +export { PromVariableQueryEditor } from './components/VariableQueryEditor'; + +// CONFIGURATION/ +// Main export +export { + ConfigEditor, + docsTip, + overhaulStyles, + validateInput, + PROM_CONFIG_LABEL_WIDTH, +} from './configuration/ConfigEditor'; +// The parts +export { AlertingSettingsOverhaul } from './configuration/AlertingSettingsOverhaul'; +export { DataSourceHttpSettingsOverhaul } from './configuration/DataSourceHttpSettingsOverhaul'; +export { ExemplarSetting } from './configuration/ExemplarSetting'; +export { ExemplarsSettings } from './configuration/ExemplarsSettings'; +export { PromFlavorVersions } from './configuration/PromFlavorVersions'; +export { PromSettings } from './configuration/PromSettings'; + +// QUERYBUILDER/ +// The parts (The query builder is imported into PromQueryEditorByApp) +export { QueryPattern } from './querybuilder/QueryPattern'; +export { QueryPatternsModal } from './querybuilder/QueryPatternsModal'; + +// QUERYBUILDER/COMPONENTS/ +export { LabelFilterItem } from './querybuilder/components/LabelFilterItem'; +export { LabelFilters } from './querybuilder/components/LabelFilters'; +export { LabelParamEditor } from './querybuilder/components/LabelParamEditor'; +export { MetricSelect } from './querybuilder/components/MetricSelect'; +export { MetricsLabelsSection } from './querybuilder/components/MetricsLabelsSection'; +export { NestedQuery } from './querybuilder/components/NestedQuery'; +export { NestedQueryList } from './querybuilder/components/NestedQueryList'; +export { PromQueryBuilder } from './querybuilder/components/PromQueryBuilder'; +export { PromQueryBuilderContainer } from './querybuilder/components/PromQueryBuilderContainer'; +export { PromQueryBuilderExplained } from './querybuilder/components/PromQueryBuilderExplained'; +export { PromQueryBuilderOptions } from './querybuilder/components/PromQueryBuilderOptions'; +export { PromQueryCodeEditor } from './querybuilder/components/PromQueryCodeEditor'; +export { PromQueryEditorSelector } from './querybuilder/components/PromQueryEditorSelector'; +export { PromQueryLegendEditor } from './querybuilder/components/PromQueryLegendEditor'; +export { QueryPreview } from './querybuilder/components/QueryPreview'; +export { MetricsModal } from './querybuilder/components/metrics-modal/MetricsModal'; +export { PromQail } from './querybuilder/components/promQail/PromQail'; + +// SRC/ +// Main export +export { PrometheusDatasource } from './datasource'; +// The parts +export { addLabelToQuery } from './add_label_to_query'; +export { type QueryEditorMode, type PromQueryFormat, type Prometheus } from './dataquery'; +export { PrometheusMetricFindQuery } from './metric_find_query'; +export { promqlGrammar } from './promql'; +export { getQueryHints, getInitHints } from './query_hints'; +export { transformV2, transformDFToTable } from './result_transformer'; +export { + type PromQuery, + type PrometheusCacheLevel, + type PromApplication, + type PromOptions, + type ExemplarTraceIdDestination, + type PromQueryRequest, + type PromMetricsMetadataItem, + type PromMetricsMetadata, + type PromValue, + type PromMetric, + type PromBuildInfoResponse, + type LegendFormatMode, + type PromVariableQueryType, + type PromVariableQuery, + type StandardPromVariableQuery, +} from './types'; +export { PrometheusVariableSupport } from './variables'; diff --git a/packages/grafana-prometheus/src/language_provider.mock.ts b/packages/grafana-prometheus/src/language_provider.mock.ts new file mode 100644 index 0000000000000..731a1977ca97b --- /dev/null +++ b/packages/grafana-prometheus/src/language_provider.mock.ts @@ -0,0 +1,18 @@ +export class EmptyLanguageProviderMock { + metrics = []; + constructor() {} + start() { + return new Promise((resolve) => { + resolve(''); + }); + } + getLabelKeys = jest.fn().mockReturnValue([]); + getLabelValues = jest.fn().mockReturnValue([]); + getSeries = jest.fn().mockReturnValue({ __name__: [] }); + fetchSeries = jest.fn().mockReturnValue([]); + fetchSeriesLabels = jest.fn().mockReturnValue([]); + fetchSeriesLabelsMatch = jest.fn().mockReturnValue([]); + fetchLabelsWithMatch = jest.fn().mockReturnValue([]); + fetchLabels = jest.fn(); + loadMetricsMetadata = jest.fn(); +} diff --git a/packages/grafana-prometheus/src/language_provider.test.ts b/packages/grafana-prometheus/src/language_provider.test.ts new file mode 100644 index 0000000000000..574fec92ef02d --- /dev/null +++ b/packages/grafana-prometheus/src/language_provider.test.ts @@ -0,0 +1,393 @@ +import { AbstractLabelOperator, dateTime, TimeRange } from '@grafana/data'; + +import { Label } from './components/monaco-query-field/monaco-completion-provider/situation'; +import { PrometheusDatasource } from './datasource'; +import LanguageProvider from './language_provider'; +import { getClientCacheDurationInMinutes, getPrometheusTime, getRangeSnapInterval } from './language_utils'; +import { PrometheusCacheLevel } from './types'; + +const now = new Date(1681300293392).getTime(); +const timeRangeDurationSeconds = 1; +const toPrometheusTime = getPrometheusTime(dateTime(now), false); +const fromPrometheusTime = getPrometheusTime(dateTime(now - timeRangeDurationSeconds * 1000), false); +const toPrometheusTimeString = toPrometheusTime.toString(10); +const fromPrometheusTimeString = fromPrometheusTime.toString(10); + +const getMockTimeRange = (): TimeRange => { + return { + to: dateTime(now).utc(), + from: dateTime(now).subtract(timeRangeDurationSeconds, 'second').utc(), + raw: { + from: fromPrometheusTimeString, + to: toPrometheusTimeString, + }, + }; +}; + +const getTimeRangeParams = ( + timRange: TimeRange, + override?: Partial<{ start: string; end: string }> +): { start: string; end: string } => ({ + start: fromPrometheusTimeString, + end: toPrometheusTimeString, + ...override, +}); + +const getMockQuantizedTimeRangeParams = (override?: Partial<TimeRange>): TimeRange => ({ + from: dateTime(fromPrometheusTime * 1000), + to: dateTime(toPrometheusTime * 1000), + raw: { + from: `now-${timeRangeDurationSeconds}s`, + to: 'now', + }, + ...override, +}); + +describe('Language completion provider', () => { + const defaultDatasource: PrometheusDatasource = { + metadataRequest: () => ({ data: { data: [] } }), + getTimeRangeParams: getTimeRangeParams, + interpolateString: (string: string) => string, + hasLabelsMatchAPISupport: () => false, + getQuantizedTimeRangeParams: () => + getRangeSnapInterval(PrometheusCacheLevel.None, getMockQuantizedTimeRangeParams()), + getDaysToCacheMetadata: () => 1, + getAdjustedInterval: () => getRangeSnapInterval(PrometheusCacheLevel.None, getMockQuantizedTimeRangeParams()), + cacheLevel: PrometheusCacheLevel.None, + } as unknown as PrometheusDatasource; + + describe('cleanText', () => { + const cleanText = new LanguageProvider(defaultDatasource).cleanText; + it('does not remove metric or label keys', () => { + expect(cleanText('foo')).toBe('foo'); + expect(cleanText('foo_bar')).toBe('foo_bar'); + }); + + it('keeps trailing space but removes leading', () => { + expect(cleanText('foo ')).toBe('foo '); + expect(cleanText(' foo')).toBe('foo'); + }); + + it('removes label syntax', () => { + expect(cleanText('foo="bar')).toBe('bar'); + expect(cleanText('foo!="bar')).toBe('bar'); + expect(cleanText('foo=~"bar')).toBe('bar'); + expect(cleanText('foo!~"bar')).toBe('bar'); + expect(cleanText('{bar')).toBe('bar'); + }); + + it('removes previous operators', () => { + expect(cleanText('foo + bar')).toBe('bar'); + expect(cleanText('foo+bar')).toBe('bar'); + expect(cleanText('foo - bar')).toBe('bar'); + expect(cleanText('foo * bar')).toBe('bar'); + expect(cleanText('foo / bar')).toBe('bar'); + expect(cleanText('foo % bar')).toBe('bar'); + expect(cleanText('foo ^ bar')).toBe('bar'); + expect(cleanText('foo and bar')).toBe('bar'); + expect(cleanText('foo or bar')).toBe('bar'); + expect(cleanText('foo unless bar')).toBe('bar'); + expect(cleanText('foo == bar')).toBe('bar'); + expect(cleanText('foo != bar')).toBe('bar'); + expect(cleanText('foo > bar')).toBe('bar'); + expect(cleanText('foo < bar')).toBe('bar'); + expect(cleanText('foo >= bar')).toBe('bar'); + expect(cleanText('foo <= bar')).toBe('bar'); + expect(cleanText('memory')).toBe('memory'); + }); + + it('removes aggregation syntax', () => { + expect(cleanText('(bar')).toBe('bar'); + expect(cleanText('(foo,bar')).toBe('bar'); + expect(cleanText('(foo, bar')).toBe('bar'); + }); + + it('removes range syntax', () => { + expect(cleanText('[1m')).toBe('1m'); + }); + }); + + describe('getSeriesLabels', () => { + it('should call labels endpoint', () => { + const languageProvider = new LanguageProvider({ + ...defaultDatasource, + hasLabelsMatchAPISupport: () => true, + } as PrometheusDatasource); + const getSeriesLabels = languageProvider.getSeriesLabels; + const requestSpy = jest.spyOn(languageProvider, 'request'); + + const labelName = 'job'; + const labelValue = 'grafana'; + getSeriesLabels(`{${labelName}="${labelValue}"}`, [{ name: labelName, value: labelValue, op: '=' }] as Label[]); + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy).toHaveBeenCalledWith( + `/api/v1/labels`, + [], + { + end: toPrometheusTimeString, + 'match[]': '{job="grafana"}', + start: fromPrometheusTimeString, + }, + undefined + ); + }); + + it('should call series endpoint', () => { + const languageProvider = new LanguageProvider({ + ...defaultDatasource, + getAdjustedInterval: (timeRange: TimeRange) => + getRangeSnapInterval(PrometheusCacheLevel.None, getMockQuantizedTimeRangeParams()), + } as PrometheusDatasource); + const getSeriesLabels = languageProvider.getSeriesLabels; + const requestSpy = jest.spyOn(languageProvider, 'request'); + + const labelName = 'job'; + const labelValue = 'grafana'; + getSeriesLabels(`{${labelName}="${labelValue}"}`, [{ name: labelName, value: labelValue, op: '=' }] as Label[]); + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy).toHaveBeenCalledWith( + '/api/v1/series', + [], + { + end: toPrometheusTimeString, + 'match[]': '{job="grafana"}', + start: fromPrometheusTimeString, + }, + undefined + ); + }); + + it('should call labels endpoint with quantized start', () => { + const timeSnapMinutes = getClientCacheDurationInMinutes(PrometheusCacheLevel.Low); + const languageProvider = new LanguageProvider({ + ...defaultDatasource, + hasLabelsMatchAPISupport: () => true, + cacheLevel: PrometheusCacheLevel.Low, + getAdjustedInterval: (timeRange: TimeRange) => + getRangeSnapInterval(PrometheusCacheLevel.Low, getMockQuantizedTimeRangeParams()), + getCacheDurationInMinutes: () => timeSnapMinutes, + } as PrometheusDatasource); + const getSeriesLabels = languageProvider.getSeriesLabels; + const requestSpy = jest.spyOn(languageProvider, 'request'); + + const labelName = 'job'; + const labelValue = 'grafana'; + getSeriesLabels(`{${labelName}="${labelValue}"}`, [{ name: labelName, value: labelValue, op: '=' }] as Label[]); + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy).toHaveBeenCalledWith( + `/api/v1/labels`, + [], + { + end: ( + dateTime(fromPrometheusTime * 1000) + .add(timeSnapMinutes, 'minute') + .startOf('minute') + .valueOf() / 1000 + ).toString(), + 'match[]': '{job="grafana"}', + start: ( + dateTime(toPrometheusTime * 1000) + .startOf('minute') + .valueOf() / 1000 + ).toString(), + }, + { headers: { 'X-Grafana-Cache': `private, max-age=${timeSnapMinutes * 60}` } } + ); + }); + }); + + describe('getSeriesValues', () => { + it('should call old series endpoint and should use match[] parameter', () => { + const languageProvider = new LanguageProvider({ + ...defaultDatasource, + } as PrometheusDatasource); + const getSeriesValues = languageProvider.getSeriesValues; + const requestSpy = jest.spyOn(languageProvider, 'request'); + getSeriesValues('job', '{job="grafana"}'); + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy).toHaveBeenCalledWith( + '/api/v1/series', + [], + { + end: toPrometheusTimeString, + 'match[]': '{job="grafana"}', + start: fromPrometheusTimeString, + }, + undefined + ); + }); + + it('should call new series endpoint and should use match[] parameter', () => { + const languageProvider = new LanguageProvider({ + ...defaultDatasource, + hasLabelsMatchAPISupport: () => true, + } as PrometheusDatasource); + const getSeriesValues = languageProvider.getSeriesValues; + const requestSpy = jest.spyOn(languageProvider, 'request'); + const labelName = 'job'; + const labelValue = 'grafana'; + getSeriesValues(labelName, `{${labelName}="${labelValue}"}`); + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy).toHaveBeenCalledWith( + `/api/v1/label/${labelName}/values`, + [], + { + end: toPrometheusTimeString, + 'match[]': `{${labelName}="${labelValue}"}`, + start: fromPrometheusTimeString, + }, + undefined + ); + }); + + it('should call old series endpoint and should use match[] parameter and interpolate the template variables', () => { + const languageProvider = new LanguageProvider({ + ...defaultDatasource, + interpolateString: (string: string) => string.replace(/\$/, 'interpolated-'), + } as PrometheusDatasource); + const getSeriesValues = languageProvider.getSeriesValues; + const requestSpy = jest.spyOn(languageProvider, 'request'); + getSeriesValues('job', '{instance="$instance", job="grafana"}'); + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy).toHaveBeenCalledWith( + '/api/v1/series', + [], + { + end: toPrometheusTimeString, + 'match[]': '{instance="interpolated-instance", job="grafana"}', + start: fromPrometheusTimeString, + }, + undefined + ); + }); + }); + + describe('fetchSeries', () => { + it('should use match[] parameter', async () => { + const languageProvider = new LanguageProvider(defaultDatasource); + const timeRange = getMockTimeRange(); + await languageProvider.start(timeRange); + const requestSpy = jest.spyOn(languageProvider, 'request'); + await languageProvider.fetchSeries('{job="grafana"}'); + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy).toHaveBeenCalledWith( + '/api/v1/series', + {}, + { + end: toPrometheusTimeString, + 'match[]': '{job="grafana"}', + start: fromPrometheusTimeString, + }, + undefined + ); + }); + }); + + describe('fetchSeriesLabels', () => { + it('should interpolate variable in series', () => { + const languageProvider = new LanguageProvider({ + ...defaultDatasource, + interpolateString: (string: string) => string.replace(/\$/, 'interpolated-'), + } as PrometheusDatasource); + const fetchSeriesLabels = languageProvider.fetchSeriesLabels; + const requestSpy = jest.spyOn(languageProvider, 'request'); + fetchSeriesLabels('$metric'); + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy).toHaveBeenCalledWith( + '/api/v1/series', + [], + { + end: toPrometheusTimeString, + 'match[]': 'interpolated-metric', + start: fromPrometheusTimeString, + }, + undefined + ); + }); + }); + + describe('fetchLabelValues', () => { + it('should interpolate variable in series', () => { + const languageProvider = new LanguageProvider({ + ...defaultDatasource, + interpolateString: (string: string) => string.replace(/\$/, 'interpolated-'), + } as PrometheusDatasource); + const fetchLabelValues = languageProvider.fetchLabelValues; + const requestSpy = jest.spyOn(languageProvider, 'request'); + fetchLabelValues('$job'); + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy).toHaveBeenCalledWith( + '/api/v1/label/interpolated-job/values', + [], + { + end: toPrometheusTimeString, + start: fromPrometheusTimeString, + }, + undefined + ); + }); + }); + + describe('disabled metrics lookup', () => { + it('issues metadata requests when lookup is not disabled', async () => { + const datasource: PrometheusDatasource = { + ...defaultDatasource, + metadataRequest: jest.fn(() => ({ data: { data: ['foo', 'bar'] as string[] } })), + lookupsDisabled: false, + } as unknown as PrometheusDatasource; + const mockedMetadataRequest = jest.mocked(datasource.metadataRequest); + const instance = new LanguageProvider(datasource); + + expect(mockedMetadataRequest.mock.calls.length).toBe(0); + await instance.start(); + expect(mockedMetadataRequest.mock.calls.length).toBeGreaterThan(0); + }); + + it('doesnt blow up if metadata or fetchLabels rejects', async () => { + jest.spyOn(console, 'error').mockImplementation(); + const datasource: PrometheusDatasource = { + ...defaultDatasource, + metadataRequest: jest.fn(() => Promise.reject('rejected')), + lookupsDisabled: false, + } as unknown as PrometheusDatasource; + const mockedMetadataRequest = jest.mocked(datasource.metadataRequest); + const instance = new LanguageProvider(datasource); + + expect(mockedMetadataRequest.mock.calls.length).toBe(0); + const result = await instance.start(); + expect(result[0]).toBeUndefined(); + expect(result[1]).toEqual([]); + expect(mockedMetadataRequest.mock.calls.length).toBe(3); + }); + }); + + describe('Query imports', () => { + it('returns empty queries', async () => { + const instance = new LanguageProvider(defaultDatasource); + const result = await instance.importFromAbstractQuery({ refId: 'bar', labelMatchers: [] }); + expect(result).toEqual({ refId: 'bar', expr: '', range: true }); + }); + + describe('exporting to abstract query', () => { + it('exports labels with metric name', async () => { + const instance = new LanguageProvider(defaultDatasource); + const abstractQuery = instance.exportToAbstractQuery({ + refId: 'bar', + expr: 'metric_name{label1="value1", label2!="value2", label3=~"value3", label4!~"value4"}', + instant: true, + range: false, + }); + expect(abstractQuery).toMatchObject({ + refId: 'bar', + labelMatchers: [ + { name: 'label1', operator: AbstractLabelOperator.Equal, value: 'value1' }, + { name: 'label2', operator: AbstractLabelOperator.NotEqual, value: 'value2' }, + { name: 'label3', operator: AbstractLabelOperator.EqualRegEx, value: 'value3' }, + { name: 'label4', operator: AbstractLabelOperator.NotEqualRegEx, value: 'value4' }, + { name: '__name__', operator: AbstractLabelOperator.Equal, value: 'metric_name' }, + ], + }); + }); + }); + }); +}); diff --git a/packages/grafana-prometheus/src/language_provider.ts b/packages/grafana-prometheus/src/language_provider.ts new file mode 100644 index 0000000000000..1f0a23f0a1d50 --- /dev/null +++ b/packages/grafana-prometheus/src/language_provider.ts @@ -0,0 +1,378 @@ +import { once } from 'lodash'; +import Prism from 'prismjs'; + +import { + AbstractLabelMatcher, + AbstractLabelOperator, + AbstractQuery, + getDefaultTimeRange, + LanguageProvider, + TimeRange, +} from '@grafana/data'; +import { BackendSrvRequest } from '@grafana/runtime'; + +import { Label } from './components/monaco-query-field/monaco-completion-provider/situation'; +import { PrometheusDatasource } from './datasource'; +import { + extractLabelMatchers, + fixSummariesMetadata, + processHistogramMetrics, + processLabels, + toPromLikeQuery, +} from './language_utils'; +import PromqlSyntax from './promql'; +import { PrometheusCacheLevel, PromMetricsMetadata, PromQuery } from './types'; + +const DEFAULT_KEYS = ['job', 'instance']; +const EMPTY_SELECTOR = '{}'; +// Max number of items (metrics, labels, values) that we display as suggestions. Prevents from running out of memory. +export const SUGGESTIONS_LIMIT = 10000; + +const buildCacheHeaders = (durationInSeconds: number) => { + return { + headers: { + 'X-Grafana-Cache': `private, max-age=${durationInSeconds}`, + }, + }; +}; + +export function getMetadataString(metric: string, metadata: PromMetricsMetadata): string | undefined { + if (!metadata[metric]) { + return undefined; + } + const { type, help } = metadata[metric]; + return `${type.toUpperCase()}: ${help}`; +} + +export function getMetadataHelp(metric: string, metadata: PromMetricsMetadata): string | undefined { + if (!metadata[metric]) { + return undefined; + } + return metadata[metric].help; +} + +export function getMetadataType(metric: string, metadata: PromMetricsMetadata): string | undefined { + if (!metadata[metric]) { + return undefined; + } + return metadata[metric].type; +} + +const PREFIX_DELIMITER_REGEX = + /(="|!="|=~"|!~"|\{|\[|\(|\+|-|\/|\*|%|\^|\band\b|\bor\b|\bunless\b|==|>=|!=|<=|>|<|=|~|,)/; + +const secondsInDay = 86400; +export default class PromQlLanguageProvider extends LanguageProvider { + histogramMetrics: string[]; + timeRange: TimeRange; + metrics: string[]; + metricsMetadata?: PromMetricsMetadata; + declare startTask: Promise<any>; + datasource: PrometheusDatasource; + labelKeys: string[] = []; + declare labelFetchTs: number; + + constructor(datasource: PrometheusDatasource, initialValues?: Partial<PromQlLanguageProvider>) { + super(); + + this.datasource = datasource; + this.histogramMetrics = []; + this.timeRange = getDefaultTimeRange(); + this.metrics = []; + + Object.assign(this, initialValues); + } + + getDefaultCacheHeaders() { + if (this.datasource.cacheLevel !== PrometheusCacheLevel.None) { + return buildCacheHeaders(this.datasource.getCacheDurationInMinutes() * 60); + } + return; + } + + // Strip syntax chars so that typeahead suggestions can work on clean inputs + cleanText(s: string) { + const parts = s.split(PREFIX_DELIMITER_REGEX); + const last = parts.pop()!; + return last.trimLeft().replace(/"$/, '').replace(/^"/, ''); + } + + get syntax() { + return PromqlSyntax; + } + + request = async (url: string, defaultValue: any, params = {}, options?: Partial<BackendSrvRequest>): Promise<any> => { + try { + const res = await this.datasource.metadataRequest(url, params, options); + return res.data.data; + } catch (error) { + console.error(error); + } + + return defaultValue; + }; + + start = async (timeRange?: TimeRange): Promise<any[]> => { + this.timeRange = timeRange ?? getDefaultTimeRange(); + + if (this.datasource.lookupsDisabled) { + return []; + } + + this.metrics = (await this.fetchLabelValues('__name__')) || []; + this.histogramMetrics = processHistogramMetrics(this.metrics).sort(); + return Promise.all([this.loadMetricsMetadata(), this.fetchLabels()]); + }; + + async loadMetricsMetadata() { + const headers = buildCacheHeaders(this.datasource.getDaysToCacheMetadata() * secondsInDay); + this.metricsMetadata = fixSummariesMetadata( + await this.request( + '/api/v1/metadata', + {}, + {}, + { + showErrorAlert: false, + ...headers, + } + ) + ); + } + + getLabelKeys(): string[] { + return this.labelKeys; + } + + importFromAbstractQuery(labelBasedQuery: AbstractQuery): PromQuery { + return toPromLikeQuery(labelBasedQuery); + } + + exportToAbstractQuery(query: PromQuery): AbstractQuery { + const promQuery = query.expr; + if (!promQuery || promQuery.length === 0) { + return { refId: query.refId, labelMatchers: [] }; + } + const tokens = Prism.tokenize(promQuery, PromqlSyntax); + const labelMatchers: AbstractLabelMatcher[] = extractLabelMatchers(tokens); + const nameLabelValue = getNameLabelValue(promQuery, tokens); + if (nameLabelValue && nameLabelValue.length > 0) { + labelMatchers.push({ + name: '__name__', + operator: AbstractLabelOperator.Equal, + value: nameLabelValue, + }); + } + + return { + refId: query.refId, + labelMatchers, + }; + } + + async getSeries(selector: string, withName?: boolean): Promise<Record<string, string[]>> { + if (this.datasource.lookupsDisabled) { + return {}; + } + try { + if (selector === EMPTY_SELECTOR) { + return await this.fetchDefaultSeries(); + } else { + return await this.fetchSeriesLabels(selector, withName); + } + } catch (error) { + // TODO: better error handling + console.error(error); + return {}; + } + } + + /** + * @param key + */ + fetchLabelValues = async (key: string): Promise<string[]> => { + const params = this.datasource.getAdjustedInterval(this.timeRange); + const interpolatedName = this.datasource.interpolateString(key); + const url = `/api/v1/label/${interpolatedName}/values`; + const value = await this.request(url, [], params, this.getDefaultCacheHeaders()); + return value ?? []; + }; + + async getLabelValues(key: string): Promise<string[]> { + return await this.fetchLabelValues(key); + } + + /** + * Fetches all label keys + */ + async fetchLabels(timeRange?: TimeRange): Promise<string[]> { + if (timeRange) { + this.timeRange = timeRange; + } + const url = '/api/v1/labels'; + const params = this.datasource.getAdjustedInterval(this.timeRange); + this.labelFetchTs = Date.now().valueOf(); + + const res = await this.request(url, [], params, this.getDefaultCacheHeaders()); + if (Array.isArray(res)) { + this.labelKeys = res.slice().sort(); + } + + return []; + } + + /** + * Gets series values + * Function to replace old getSeries calls in a way that will provide faster endpoints for new prometheus instances, + * while maintaining backward compatability + * @param labelName + * @param selector + */ + getSeriesValues = async (labelName: string, selector: string): Promise<string[]> => { + if (!this.datasource.hasLabelsMatchAPISupport()) { + const data = await this.getSeries(selector); + return data[labelName] ?? []; + } + return await this.fetchSeriesValuesWithMatch(labelName, selector); + }; + + /** + * Fetches all values for a label, with optional match[] + * @param name + * @param match + * @param timeRange + */ + fetchSeriesValuesWithMatch = async ( + name: string, + match?: string, + timeRange: TimeRange = this.timeRange + ): Promise<string[]> => { + const interpolatedName = name ? this.datasource.interpolateString(name) : null; + const interpolatedMatch = match ? this.datasource.interpolateString(match) : null; + const range = this.datasource.getAdjustedInterval(timeRange); + const urlParams = { + ...range, + ...(interpolatedMatch && { 'match[]': interpolatedMatch }), + }; + + const value = await this.request( + `/api/v1/label/${interpolatedName}/values`, + [], + urlParams, + this.getDefaultCacheHeaders() + ); + return value ?? []; + }; + + /** + * Gets series labels + * Function to replace old getSeries calls in a way that will provide faster endpoints for new prometheus instances, + * while maintaining backward compatability. The old API call got the labels and the values in a single query, + * but with the new query we need two calls, one to get the labels, and another to get the values. + * + * @param selector + * @param otherLabels + */ + getSeriesLabels = async (selector: string, otherLabels: Label[]): Promise<string[]> => { + let possibleLabelNames, data: Record<string, string[]>; + + if (!this.datasource.hasLabelsMatchAPISupport()) { + data = await this.getSeries(selector); + possibleLabelNames = Object.keys(data); // all names from prometheus + } else { + // Exclude __name__ from output + otherLabels.push({ name: '__name__', value: '', op: '!=' }); + data = await this.fetchSeriesLabelsMatch(selector); + possibleLabelNames = Object.keys(data); + } + + const usedLabelNames = new Set(otherLabels.map((l) => l.name)); // names used in the query + return possibleLabelNames.filter((l) => !usedLabelNames.has(l)); + }; + + /** + * Fetch labels using the best endpoint that datasource supports. + * This is cached by its args but also by the global timeRange currently selected as they can change over requested time. + * @param name + * @param withName + */ + fetchLabelsWithMatch = async (name: string, withName?: boolean): Promise<Record<string, string[]>> => { + if (this.datasource.hasLabelsMatchAPISupport()) { + return this.fetchSeriesLabelsMatch(name, withName); + } else { + return this.fetchSeriesLabels(name, withName); + } + }; + + /** + * Fetch labels for a series using /series endpoint. This is cached by its args but also by the global timeRange currently selected as + * they can change over requested time. + * @param name + * @param withName + */ + fetchSeriesLabels = async (name: string, withName?: boolean): Promise<Record<string, string[]>> => { + const interpolatedName = this.datasource.interpolateString(name); + const range = this.datasource.getAdjustedInterval(this.timeRange); + const urlParams = { + ...range, + 'match[]': interpolatedName, + }; + const url = `/api/v1/series`; + + const data = await this.request(url, [], urlParams, this.getDefaultCacheHeaders()); + const { values } = processLabels(data, withName); + return values; + }; + + /** + * Fetch labels for a series using /labels endpoint. This is cached by its args but also by the global timeRange currently selected as + * they can change over requested time. + * @param name + * @param withName + */ + fetchSeriesLabelsMatch = async (name: string, withName?: boolean): Promise<Record<string, string[]>> => { + const interpolatedName = this.datasource.interpolateString(name); + const range = this.datasource.getAdjustedInterval(this.timeRange); + const urlParams = { + ...range, + 'match[]': interpolatedName, + }; + const url = `/api/v1/labels`; + + const data: string[] = await this.request(url, [], urlParams, this.getDefaultCacheHeaders()); + // Convert string array to Record<string , []> + return data.reduce((ac, a) => ({ ...ac, [a]: '' }), {}); + }; + + /** + * Fetch series for a selector. Use this for raw results. Use fetchSeriesLabels() to get labels. + * @param match + */ + fetchSeries = async (match: string): Promise<Array<Record<string, string>>> => { + const url = '/api/v1/series'; + const range = this.datasource.getTimeRangeParams(this.timeRange); + const params = { ...range, 'match[]': match }; + return await this.request(url, {}, params, this.getDefaultCacheHeaders()); + }; + + /** + * Fetch this only one as we assume this won't change over time. This is cached differently from fetchSeriesLabels + * because we can cache more aggressively here and also we do not want to invalidate this cache the same way as in + * fetchSeriesLabels. + */ + fetchDefaultSeries = once(async () => { + const values = await Promise.all(DEFAULT_KEYS.map((key) => this.fetchLabelValues(key))); + return DEFAULT_KEYS.reduce((acc, key, i) => ({ ...acc, [key]: values[i] }), {}); + }); +} + +function getNameLabelValue(promQuery: string, tokens: any): string { + let nameLabelValue = ''; + + for (const token of tokens) { + if (typeof token === 'string') { + nameLabelValue = token; + break; + } + } + return nameLabelValue; +} diff --git a/packages/grafana-prometheus/src/language_utils.test.ts b/packages/grafana-prometheus/src/language_utils.test.ts new file mode 100644 index 0000000000000..fe6d1dd02c981 --- /dev/null +++ b/packages/grafana-prometheus/src/language_utils.test.ts @@ -0,0 +1,518 @@ +import { Moment } from 'moment'; + +import { AbstractLabelOperator, AbstractQuery, DateTime, dateTime, TimeRange } from '@grafana/data'; + +import { + escapeLabelValueInExactSelector, + escapeLabelValueInRegexSelector, + expandRecordingRules, + fixSummariesMetadata, + getPrometheusTime, + getRangeSnapInterval, + parseSelector, + toPromLikeQuery, + truncateResult, +} from './language_utils'; +import { PrometheusCacheLevel } from './types'; + +describe('parseSelector()', () => { + let parsed; + + it('returns a clean selector from an empty selector', () => { + parsed = parseSelector('{}', 1); + expect(parsed.selector).toBe('{}'); + expect(parsed.labelKeys).toEqual([]); + }); + + it('returns a clean selector from an unclosed selector', () => { + const parsed = parseSelector('{foo'); + expect(parsed.selector).toBe('{}'); + }); + + it('returns the selector sorted by label key', () => { + parsed = parseSelector('{foo="bar"}'); + expect(parsed.selector).toBe('{foo="bar"}'); + expect(parsed.labelKeys).toEqual(['foo']); + + parsed = parseSelector('{foo="bar",baz="xx"}'); + expect(parsed.selector).toBe('{baz="xx",foo="bar"}'); + }); + + it('returns a clean selector from an incomplete one', () => { + parsed = parseSelector('{foo}'); + expect(parsed.selector).toBe('{}'); + + parsed = parseSelector('{foo="bar",baz}'); + expect(parsed.selector).toBe('{foo="bar"}'); + + parsed = parseSelector('{foo="bar",baz="}'); + expect(parsed.selector).toBe('{foo="bar"}'); + + // Cursor in value area counts as incomplete + parsed = parseSelector('{foo="bar",baz=""}', 16); + expect(parsed.selector).toBe('{foo="bar"}'); + + parsed = parseSelector('{foo="bar",baz="4"}', 17); + expect(parsed.selector).toBe('{foo="bar"}'); + }); + + it('throws if not inside a selector', () => { + expect(() => parseSelector('foo{}', 0)).toThrow(); + expect(() => parseSelector('foo{} + bar{}', 5)).toThrow(); + }); + + it('returns the selector nearest to the cursor offset', () => { + expect(() => parseSelector('{foo="bar"} + {foo="bar"}', 0)).toThrow(); + + parsed = parseSelector('{foo="bar"} + {foo="bar"}', 1); + expect(parsed.selector).toBe('{foo="bar"}'); + + parsed = parseSelector('{foo="bar"} + {baz="xx"}', 1); + expect(parsed.selector).toBe('{foo="bar"}'); + + parsed = parseSelector('{baz="xx"} + {foo="bar"}', 16); + expect(parsed.selector).toBe('{foo="bar"}'); + }); + + it('returns a selector with metric if metric is given', () => { + parsed = parseSelector('bar{foo}', 4); + expect(parsed.selector).toBe('{__name__="bar"}'); + + parsed = parseSelector('baz{foo="bar"}', 13); + expect(parsed.selector).toBe('{__name__="baz",foo="bar"}'); + + parsed = parseSelector('bar:metric:1m{}', 14); + expect(parsed.selector).toBe('{__name__="bar:metric:1m"}'); + }); +}); + +describe('fixSummariesMetadata', () => { + const synthetics = { + ALERTS: { + type: 'counter', + help: 'Time series showing pending and firing alerts. The sample value is set to 1 as long as the alert is in the indicated active (pending or firing) state.', + }, + }; + it('returns only synthetics on empty metadata', () => { + expect(fixSummariesMetadata({})).toEqual({ ...synthetics }); + }); + + it('returns unchanged metadata if no summary is present', () => { + const metadataRaw = { + foo: [{ type: 'not_a_summary', help: 'foo help' }], + }; + + const metadata = { + foo: { type: 'not_a_summary', help: 'foo help' }, + }; + expect(fixSummariesMetadata(metadataRaw)).toEqual({ ...metadata, ...synthetics }); + }); + + it('returns metadata with added count and sum for a summary', () => { + const metadata = { + foo: [{ type: 'not_a_summary', help: 'foo help' }], + bar: [{ type: 'summary', help: 'bar help' }], + }; + const expected = { + foo: { type: 'not_a_summary', help: 'foo help' }, + bar: { type: 'summary', help: 'bar help' }, + bar_count: { + type: 'counter', + help: 'Count of events that have been observed for the base metric (bar help)', + }, + bar_sum: { type: 'counter', help: 'Total sum of all observed values for the base metric (bar help)' }, + }; + expect(fixSummariesMetadata(metadata)).toEqual({ ...expected, ...synthetics }); + }); + + it('returns metadata with added bucket/count/sum for a histogram', () => { + const metadata = { + foo: [{ type: 'not_a_histogram', help: 'foo help' }], + bar: [{ type: 'histogram', help: 'bar help' }], + }; + const expected = { + foo: { type: 'not_a_histogram', help: 'foo help' }, + bar: { type: 'histogram', help: 'bar help' }, + bar_bucket: { type: 'counter', help: 'Cumulative counters for the observation buckets (bar help)' }, + bar_count: { + type: 'counter', + help: 'Count of events that have been observed for the histogram metric (bar help)', + }, + bar_sum: { type: 'counter', help: 'Total sum of all observed values for the histogram metric (bar help)' }, + }; + expect(fixSummariesMetadata(metadata)).toEqual({ ...expected, ...synthetics }); + }); +}); + +describe('expandRecordingRules()', () => { + it('returns query w/o recording rules as is', () => { + expect(expandRecordingRules('metric', {})).toBe('metric'); + expect(expandRecordingRules('metric + metric', {})).toBe('metric + metric'); + expect(expandRecordingRules('metric{}', {})).toBe('metric{}'); + }); + + it('does not modify recording rules name in label values', () => { + expect(expandRecordingRules('{__name__="metric"} + bar', { metric: 'foo', bar: 'super' })).toBe( + '{__name__="metric"} + super' + ); + }); + + it('returns query with expanded recording rules', () => { + expect(expandRecordingRules('metric', { metric: 'foo' })).toBe('foo'); + expect(expandRecordingRules('metric + metric', { metric: 'foo' })).toBe('foo + foo'); + expect(expandRecordingRules('metric{}', { metric: 'foo' })).toBe('foo{}'); + expect(expandRecordingRules('metric[]', { metric: 'foo' })).toBe('foo[]'); + expect(expandRecordingRules('metric + foo', { metric: 'foo', foo: 'bar' })).toBe('foo + bar'); + }); + + it('returns query with labels with expanded recording rules', () => { + expect( + expandRecordingRules('metricA{label1="value1"} / metricB{label2="value2"}', { metricA: 'fooA', metricB: 'fooB' }) + ).toBe('fooA{label1="value1"} / fooB{label2="value2"}'); + expect( + expandRecordingRules('metricA{label1="value1",label2="value,2"}', { + metricA: 'rate(fooA[])', + }) + ).toBe('rate(fooA{label1="value1", label2="value,2"}[])'); + expect( + expandRecordingRules('metricA{label1="value1"} / metricB{label2="value2"}', { + metricA: 'rate(fooA[])', + metricB: 'rate(fooB[])', + }) + ).toBe('rate(fooA{label1="value1"}[]) / rate(fooB{label2="value2"}[])'); + expect( + expandRecordingRules('metricA{label1="value1",label2="value2"} / metricB{label3="value3"}', { + metricA: 'rate(fooA[])', + metricB: 'rate(fooB[])', + }) + ).toBe('rate(fooA{label1="value1", label2="value2"}[]) / rate(fooB{label3="value3"}[])'); + }); + + it('expands the query even it is wrapped with parentheses', () => { + expect( + expandRecordingRules('sum (metric{label1="value1"}) by (env)', { metric: 'foo{labelInside="valueInside"}' }) + ).toBe('sum (foo{labelInside="valueInside", label1="value1"}) by (env)'); + }); + + it('expands the query with regex match', () => { + expect( + expandRecordingRules('sum (metric{label1=~"/value1/(sa|sb)"}) by (env)', { + metric: 'foo{labelInside="valueInside"}', + }) + ).toBe('sum (foo{labelInside="valueInside", label1=~"/value1/(sa|sb)"}) by (env)'); + }); + + it('ins:metric:per{pid="val-42", comp="api"}', () => { + const query = `aaa:111{pid="val-42", comp="api"} + bbb:222{pid="val-42"}`; + const mapping = { + 'aaa:111': + '(max without (mp) (targetMetric{device=~"/dev/(sda1|sdb)"}) / max without (mp) (targetMetric2{device=~"/dev/(sda1|sdb)"}))', + 'bbb:222': '(targetMetric2{device=~"/dev/(sda1|sdb)"})', + }; + const expected = `(max without (mp) (targetMetric{device=~"/dev/(sda1|sdb)", pid="val-42", comp="api"}) / max without (mp) (targetMetric2{device=~"/dev/(sda1|sdb)", pid="val-42", comp="api"})) + (targetMetric2{device=~"/dev/(sda1|sdb)", pid="val-42"})`; + const result = expandRecordingRules(query, mapping); + expect(result).toBe(expected); + }); +}); + +describe('escapeLabelValueInExactSelector()', () => { + it('handles newline characters', () => { + expect(escapeLabelValueInExactSelector('t\nes\nt')).toBe('t\\nes\\nt'); + }); + + it('handles backslash characters', () => { + expect(escapeLabelValueInExactSelector('t\\es\\t')).toBe('t\\\\es\\\\t'); + }); + + it('handles double-quote characters', () => { + expect(escapeLabelValueInExactSelector('t"es"t')).toBe('t\\"es\\"t'); + }); + + it('handles all together', () => { + expect(escapeLabelValueInExactSelector('t\\e"st\nl\nab"e\\l')).toBe('t\\\\e\\"st\\nl\\nab\\"e\\\\l'); + }); +}); + +describe('escapeLabelValueInRegexSelector()', () => { + it('handles newline characters', () => { + expect(escapeLabelValueInRegexSelector('t\nes\nt')).toBe('t\\nes\\nt'); + }); + + it('handles backslash characters', () => { + expect(escapeLabelValueInRegexSelector('t\\es\\t')).toBe('t\\\\\\\\es\\\\\\\\t'); + }); + + it('handles double-quote characters', () => { + expect(escapeLabelValueInRegexSelector('t"es"t')).toBe('t\\"es\\"t'); + }); + + it('handles regex-meaningful characters', () => { + expect(escapeLabelValueInRegexSelector('t+es$t')).toBe('t\\\\+es\\\\$t'); + }); + + it('handles all together', () => { + expect(escapeLabelValueInRegexSelector('t\\e"s+t\nl\n$ab"e\\l')).toBe( + 't\\\\\\\\e\\"s\\\\+t\\nl\\n\\\\$ab\\"e\\\\\\\\l' + ); + }); +}); + +describe('getRangeSnapInterval', () => { + it('will not change input if set to no cache', () => { + const intervalSeconds = 10 * 60; // 10 minutes + const now = new Date().valueOf(); + + const expectedFrom = dateTime(now - intervalSeconds * 1000); + const expectedTo = dateTime(now); + + const range: TimeRange = { + from: expectedFrom, + to: expectedTo, + } as TimeRange; + + expect(getRangeSnapInterval(PrometheusCacheLevel.None, range)).toEqual({ + start: getPrometheusTime(expectedFrom, false).toString(), + end: getPrometheusTime(expectedTo, true).toString(), + }); + }); + + it('will snap range to closest minute', () => { + const queryDurationMinutes = 10; + const intervalSeconds = queryDurationMinutes * 60; // 10 minutes + const now = 1680901009826; + const nowPlusOneMinute = now + 1000 * 60; + const nowPlusTwoMinute = now + 1000 * 60 * 2; + + const nowTime = dateTime(now) as Moment; + + const expectedFrom = nowTime.clone().startOf('minute').subtract(queryDurationMinutes, 'minute'); + const expectedTo = nowTime.clone().startOf('minute').add(1, 'minute'); + + const range: TimeRange = { + from: dateTime(now - intervalSeconds * 1000), + to: dateTime(now), + } as TimeRange; + + const range2: TimeRange = { + from: dateTime(nowPlusOneMinute - intervalSeconds * 1000), + to: dateTime(nowPlusOneMinute), + raw: { + from: dateTime(nowPlusOneMinute - intervalSeconds * 1000), + to: dateTime(nowPlusOneMinute), + }, + }; + const range3: TimeRange = { + from: dateTime(nowPlusTwoMinute - intervalSeconds * 1000), + to: dateTime(nowPlusTwoMinute), + raw: { + from: dateTime(nowPlusTwoMinute - intervalSeconds * 1000), + to: dateTime(nowPlusTwoMinute), + }, + }; + + const first = getRangeSnapInterval(PrometheusCacheLevel.Low, range); + const second = getRangeSnapInterval(PrometheusCacheLevel.Low, range2); + const third = getRangeSnapInterval(PrometheusCacheLevel.Low, range3); + + expect(first).toEqual({ + start: getPrometheusTime(expectedFrom as DateTime, false).toString(10), + end: getPrometheusTime(expectedTo as DateTime, false).toString(10), + }); + + expect(second).toEqual({ + start: getPrometheusTime(expectedFrom.clone().add(1, 'minute') as DateTime, false).toString(10), + end: getPrometheusTime(expectedTo.clone().add(1, 'minute') as DateTime, false).toString(10), + }); + + expect(third).toEqual({ + start: getPrometheusTime(expectedFrom.clone().add(2, 'minute') as DateTime, false).toString(10), + end: getPrometheusTime(expectedTo.clone().add(2, 'minute') as DateTime, false).toString(10), + }); + }); + + it('will snap range to closest 10 minute', () => { + const queryDurationMinutes = 60; + const intervalSeconds = queryDurationMinutes * 60; // 10 minutes + const now = 1680901009826; + const nowPlusOneMinute = now + 1000 * 60; + const nowPlusTwoMinute = now + 1000 * 60 * 2; + + const nowTime = dateTime(now) as Moment; + const nowTimePlusOne = dateTime(nowPlusOneMinute) as Moment; + const nowTimePlusTwo = dateTime(nowPlusTwoMinute) as Moment; + + const calculateClosest10 = (date: Moment): Moment => { + const numberOfMinutes = Math.floor(date.minutes() / 10) * 10; + const numberOfHours = numberOfMinutes < 60 ? date.hours() : date.hours() + 1; + return date + .clone() + .minutes(numberOfMinutes % 60) + .hours(numberOfHours); + }; + + const expectedFromFirst = calculateClosest10( + nowTime.clone().startOf('minute').subtract(queryDurationMinutes, 'minute') + ); + const expectedToFirst = calculateClosest10(nowTime.clone().startOf('minute').add(1, 'minute')); + + const expectedFromSecond = calculateClosest10( + nowTimePlusOne.clone().startOf('minute').subtract(queryDurationMinutes, 'minute') + ); + const expectedToSecond = calculateClosest10(nowTimePlusOne.clone().startOf('minute').add(1, 'minute')); + + const expectedFromThird = calculateClosest10( + nowTimePlusTwo.clone().startOf('minute').subtract(queryDurationMinutes, 'minute') + ); + const expectedToThird = calculateClosest10(nowTimePlusTwo.clone().startOf('minute').add(1, 'minute')); + + const range: TimeRange = { + from: dateTime(now - intervalSeconds * 1000), + to: dateTime(now), + } as TimeRange; + + const range2: TimeRange = { + from: dateTime(nowPlusOneMinute - intervalSeconds * 1000), + to: dateTime(nowPlusOneMinute), + raw: { + from: dateTime(nowPlusOneMinute - intervalSeconds * 1000), + to: dateTime(nowPlusOneMinute), + }, + }; + const range3: TimeRange = { + from: dateTime(nowPlusTwoMinute - intervalSeconds * 1000), + to: dateTime(nowPlusTwoMinute), + raw: { + from: dateTime(nowPlusTwoMinute - intervalSeconds * 1000), + to: dateTime(nowPlusTwoMinute), + }, + }; + + const first = getRangeSnapInterval(PrometheusCacheLevel.Medium, range); + const second = getRangeSnapInterval(PrometheusCacheLevel.Medium, range2); + const third = getRangeSnapInterval(PrometheusCacheLevel.Medium, range3); + + expect(first).toEqual({ + start: getPrometheusTime(expectedFromFirst as DateTime, false).toString(10), + end: getPrometheusTime(expectedToFirst as DateTime, false).toString(10), + }); + + expect(second).toEqual({ + start: getPrometheusTime(expectedFromSecond.clone() as DateTime, false).toString(10), + end: getPrometheusTime(expectedToSecond.clone() as DateTime, false).toString(10), + }); + + expect(third).toEqual({ + start: getPrometheusTime(expectedFromThird.clone() as DateTime, false).toString(10), + end: getPrometheusTime(expectedToThird.clone() as DateTime, false).toString(10), + }); + }); + + it('will snap range to closest 60 minute', () => { + const queryDurationMinutes = 120; + const intervalSeconds = queryDurationMinutes * 60; + const now = 1680901009826; + const nowPlusOneMinute = now + 1000 * 60; + const nowPlusTwoMinute = now + 1000 * 60 * 2; + + const nowTime = dateTime(now) as Moment; + const nowTimePlusOne = dateTime(nowPlusOneMinute) as Moment; + const nowTimePlusTwo = dateTime(nowPlusTwoMinute) as Moment; + + const calculateClosest60 = (date: Moment): Moment => { + const numberOfMinutes = Math.floor(date.minutes() / 60) * 60; + const numberOfHours = numberOfMinutes < 60 ? date.hours() : date.hours() + 1; + return date + .clone() + .minutes(numberOfMinutes % 60) + .hours(numberOfHours); + }; + + const expectedFromFirst = calculateClosest60( + nowTime.clone().startOf('minute').subtract(queryDurationMinutes, 'minute') + ); + const expectedToFirst = calculateClosest60(nowTime.clone().startOf('minute').add(1, 'minute')); + + const expectedFromSecond = calculateClosest60( + nowTimePlusOne.clone().startOf('minute').subtract(queryDurationMinutes, 'minute') + ); + const expectedToSecond = calculateClosest60(nowTimePlusOne.clone().startOf('minute').add(1, 'minute')); + + const expectedFromThird = calculateClosest60( + nowTimePlusTwo.clone().startOf('minute').subtract(queryDurationMinutes, 'minute') + ); + const expectedToThird = calculateClosest60(nowTimePlusTwo.clone().startOf('minute').add(1, 'minute')); + + const range: TimeRange = { + from: dateTime(now - intervalSeconds * 1000), + to: dateTime(now), + } as TimeRange; + + const range2: TimeRange = { + from: dateTime(nowPlusOneMinute - intervalSeconds * 1000), + to: dateTime(nowPlusOneMinute), + raw: { + from: dateTime(nowPlusOneMinute - intervalSeconds * 1000), + to: dateTime(nowPlusOneMinute), + }, + }; + const range3: TimeRange = { + from: dateTime(nowPlusTwoMinute - intervalSeconds * 1000), + to: dateTime(nowPlusTwoMinute), + raw: { + from: dateTime(nowPlusTwoMinute - intervalSeconds * 1000), + to: dateTime(nowPlusTwoMinute), + }, + }; + + const first = getRangeSnapInterval(PrometheusCacheLevel.High, range); + const second = getRangeSnapInterval(PrometheusCacheLevel.High, range2); + const third = getRangeSnapInterval(PrometheusCacheLevel.High, range3); + + expect(first).toEqual({ + start: getPrometheusTime(expectedFromFirst as DateTime, false).toString(10), + end: getPrometheusTime(expectedToFirst as DateTime, false).toString(10), + }); + + expect(second).toEqual({ + start: getPrometheusTime(expectedFromSecond.clone() as DateTime, false).toString(10), + end: getPrometheusTime(expectedToSecond.clone() as DateTime, false).toString(10), + }); + + expect(third).toEqual({ + start: getPrometheusTime(expectedFromThird.clone() as DateTime, false).toString(10), + end: getPrometheusTime(expectedToThird.clone() as DateTime, false).toString(10), + }); + }); +}); + +describe('toPromLikeQuery', () => { + it('export abstract query to PromQL-like query', () => { + const abstractQuery: AbstractQuery = { + refId: 'bar', + labelMatchers: [ + { name: 'label1', operator: AbstractLabelOperator.Equal, value: 'value1' }, + { name: 'label2', operator: AbstractLabelOperator.NotEqual, value: 'value2' }, + { name: 'label3', operator: AbstractLabelOperator.EqualRegEx, value: 'value3' }, + { name: 'label4', operator: AbstractLabelOperator.NotEqualRegEx, value: 'value4' }, + ], + }; + + expect(toPromLikeQuery(abstractQuery)).toMatchObject({ + refId: 'bar', + expr: '{label1="value1", label2!="value2", label3=~"value3", label4!~"value4"}', + range: true, + }); + }); +}); + +describe('truncateResult', () => { + it('truncates array longer then 1k from the start of array', () => { + // creates an array of 1k + 1 elements with values from 0 to 1k + const array = Array.from(Array(1001).keys()); + expect(array[1000]).toBe(1000); + truncateResult(array); + expect(array.length).toBe(1000); + expect(array[0]).toBe(0); + expect(array[999]).toBe(999); + }); +}); diff --git a/packages/grafana-prometheus/src/language_utils.ts b/packages/grafana-prometheus/src/language_utils.ts new file mode 100644 index 0000000000000..ce5e43e7e58e4 --- /dev/null +++ b/packages/grafana-prometheus/src/language_utils.ts @@ -0,0 +1,533 @@ +import { invert } from 'lodash'; +import { Token } from 'prismjs'; + +import { + AbstractLabelMatcher, + AbstractLabelOperator, + AbstractQuery, + DataQuery, + dateMath, + DateTime, + incrRoundDn, + TimeRange, +} from '@grafana/data'; + +import { addLabelToQuery } from './add_label_to_query'; +import { SUGGESTIONS_LIMIT } from './language_provider'; +import { PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from './querybuilder/components/MetricSelect'; +import { PrometheusCacheLevel, PromMetricsMetadata, PromMetricsMetadataItem } from './types'; + +export const processHistogramMetrics = (metrics: string[]) => { + const resultSet: Set<string> = new Set(); + const regexp = new RegExp('_bucket($|:)'); + for (let index = 0; index < metrics.length; index++) { + const metric = metrics[index]; + const isHistogramValue = regexp.test(metric); + if (isHistogramValue) { + resultSet.add(metric); + } + } + return [...resultSet]; +}; + +export function processLabels(labels: Array<{ [key: string]: string }>, withName = false) { + // For processing we are going to use sets as they have significantly better performance than arrays + // After we process labels, we will convert sets to arrays and return object with label values in arrays + const valueSet: { [key: string]: Set<string> } = {}; + labels.forEach((label) => { + const { __name__, ...rest } = label; + if (withName) { + valueSet['__name__'] = valueSet['__name__'] || new Set(); + if (!valueSet['__name__'].has(__name__)) { + valueSet['__name__'].add(__name__); + } + } + + Object.keys(rest).forEach((key) => { + if (!valueSet[key]) { + valueSet[key] = new Set(); + } + if (!valueSet[key].has(rest[key])) { + valueSet[key].add(rest[key]); + } + }); + }); + + // valueArray that we are going to return in the object + const valueArray: { [key: string]: string[] } = {}; + limitSuggestions(Object.keys(valueSet)).forEach((key) => { + valueArray[key] = limitSuggestions(Array.from(valueSet[key])); + }); + + return { values: valueArray, keys: Object.keys(valueArray) }; +} + +// const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/; +export const selectorRegexp = /\{[^}]*?(\}|$)/; + +// This will capture 4 groups. Example label filter => {instance="10.4.11.4:9003"} +// 1. label: instance +// 2. operator: = +// 3. value: "10.4.11.4:9003" +// 4. comma: if there is a comma it will give , +// 5. space: if there is a space after comma it will give the whole space +// comma and space is useful for addLabelsToExpression function +export const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")(,)?(\s*)?/g; + +export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any[]; selector: string } { + if (!query.match(selectorRegexp)) { + // Special matcher for metrics + if (query.match(/^[A-Za-z:][\w:]*$/)) { + return { + selector: `{__name__="${query}"}`, + labelKeys: ['__name__'], + }; + } + throw new Error('Query must contain a selector: ' + query); + } + + // Check if inside a selector + const prefix = query.slice(0, cursorOffset); + const prefixOpen = prefix.lastIndexOf('{'); + const prefixClose = prefix.lastIndexOf('}'); + if (prefixOpen === -1) { + throw new Error('Not inside selector, missing open brace: ' + prefix); + } + if (prefixClose > -1 && prefixClose > prefixOpen) { + throw new Error('Not inside selector, previous selector already closed: ' + prefix); + } + const suffix = query.slice(cursorOffset); + const suffixCloseIndex = suffix.indexOf('}'); + const suffixClose = suffixCloseIndex + cursorOffset; + const suffixOpenIndex = suffix.indexOf('{'); + const suffixOpen = suffixOpenIndex + cursorOffset; + if (suffixClose === -1) { + throw new Error('Not inside selector, missing closing brace in suffix: ' + suffix); + } + if (suffixOpenIndex > -1 && suffixOpen < suffixClose) { + throw new Error('Not inside selector, next selector opens before this one closed: ' + suffix); + } + + // Extract clean labels to form clean selector, incomplete labels are dropped + const selector = query.slice(prefixOpen, suffixClose); + const labels: { [key: string]: { value: string; operator: string } } = {}; + selector.replace(labelRegexp, (label, key, operator, value) => { + const labelOffset = query.indexOf(label); + const valueStart = labelOffset + key.length + operator.length + 1; + const valueEnd = labelOffset + key.length + operator.length + value.length - 1; + // Skip label if cursor is in value + if (cursorOffset < valueStart || cursorOffset > valueEnd) { + labels[key] = { value, operator }; + } + return ''; + }); + + // Add metric if there is one before the selector + const metricPrefix = query.slice(0, prefixOpen); + const metricMatch = metricPrefix.match(/[A-Za-z:][\w:]*$/); + if (metricMatch) { + labels['__name__'] = { value: `"${metricMatch[0]}"`, operator: '=' }; + } + + // Build sorted selector + const labelKeys = Object.keys(labels).sort(); + const cleanSelector = labelKeys.map((key) => `${key}${labels[key].operator}${labels[key].value}`).join(','); + + const selectorString = ['{', cleanSelector, '}'].join(''); + + return { labelKeys, selector: selectorString }; +} + +export function expandRecordingRules(query: string, mapping: { [name: string]: string }): string { + const getRuleRegex = (ruleName: string) => new RegExp(`(\\s|\\(|^)(${ruleName})(\\s|$|\\(|\\[|\\{)`, 'ig'); + + // For each mapping key we iterate over the query and split them in parts. + // recording:rule{label=~"/label/value"} * some:other:rule{other_label="value"} + // We want to keep parts in here like this: + // recording:rule + // {label=~"/label/value"} * + // some:other:rule + // {other_label="value"} + const tmpSplitParts = Object.keys(mapping).reduce<string[]>( + (prev, curr) => { + let parts: string[] = []; + let tmpParts: string[] = []; + let removeIdx: number[] = []; + + // we iterate over prev because it might be like this after first loop + // recording:rule and {label=~"/label/value"} * some:other:rule{other_label="value"} + // so we need to split the second part too + prev.filter(Boolean).forEach((p, i) => { + const doesMatch = p.match(getRuleRegex(curr)); + if (doesMatch) { + parts = p.split(curr); + if (parts.length === 2) { + // this is the case when we have such result for this query + // max (metric{label="value"}) + // "max(", "{label="value"}" + removeIdx.push(i); + tmpParts.push(...[parts[0], curr, parts[1]].filter(Boolean)); + } else if (parts.length > 2) { + // this is the case when we have such query + // metric + metric + // when we split it we have such data + // "", " + ", "" + removeIdx.push(i); + parts = parts.map((p) => (p === '' ? curr : p)); + tmpParts.push(...parts); + } + } + }); + + // if we have idx to remove that means we split the value in that index. + // No need to keep it. Have the new split values instead. + removeIdx.forEach((ri) => (prev[ri] = '')); + prev = prev.filter(Boolean); + prev.push(...tmpParts); + + return prev; + }, + [query] + ); + + // we have the separate parts. we need to replace the metric and apply the labels if there is any + let labelFound = false; + const trulyExpandedQuery = tmpSplitParts.map((tsp, i) => { + // if we know this loop tsp is a label, not the metric we want to expand + if (labelFound) { + labelFound = false; + return ''; + } + + // check if the mapping is there + if (mapping[tsp]) { + const recordingRule = mapping[tsp]; + // it is a recording rule. if the following is a label then apply it + if (i + 1 !== tmpSplitParts.length && tmpSplitParts[i + 1].match(labelRegexp)) { + // the next value in the loop is label. Let's apply labels to the metric + labelFound = true; + const labels = tmpSplitParts[i + 1]; + const invalidLabelsRegex = /(\)\{|\}\{|\]\{)/; + return addLabelsToExpression(recordingRule + labels, invalidLabelsRegex); + } else { + // it is not a recording rule and might be a binary operation in between two recording rules + // So no need to do anything. just return it. + return recordingRule; + } + } + + return tsp; + }); + + // Remove empty strings and merge them + return trulyExpandedQuery.filter(Boolean).join(''); +} + +function addLabelsToExpression(expr: string, invalidLabelsRegexp: RegExp) { + const match = expr.match(invalidLabelsRegexp); + if (!match) { + return expr; + } + + // Split query into 2 parts - before the invalidLabelsRegex match and after. + const indexOfRegexMatch = match.index ?? 0; + const exprBeforeRegexMatch = expr.slice(0, indexOfRegexMatch + 1); + const exprAfterRegexMatch = expr.slice(indexOfRegexMatch + 1); + + // Create arrayOfLabelObjects with label objects that have key, operator and value. + const arrayOfLabelObjects: Array<{ + key: string; + operator: string; + value: string; + comma?: string; + space?: string; + }> = []; + exprAfterRegexMatch.replace(labelRegexp, (label, key, operator, value, comma, space) => { + arrayOfLabelObjects.push({ key, operator, value, comma, space }); + return ''; + }); + + // Loop through all label objects and add them to query. + // As a starting point we have valid query without the labels. + let result = exprBeforeRegexMatch; + arrayOfLabelObjects.filter(Boolean).forEach((obj) => { + // Remove extra set of quotes from obj.value + const value = obj.value.slice(1, -1); + result = addLabelToQuery(result, obj.key, value, obj.operator); + }); + + // reconstruct the labels + let existingLabel = arrayOfLabelObjects.reduce((prev, curr) => { + prev += `${curr.key}${curr.operator}${curr.value}${curr.comma ?? ''}${curr.space ?? ''}`; + return prev; + }, ''); + + // Check if there is anything besides labels + // Useful for this kind of metrics sum (recording_rule_metric{label1="value1"}) by (env) + // if we don't check this part, ) by (env) part will be lost + existingLabel = '{' + existingLabel + '}'; + const potentialLeftOver = exprAfterRegexMatch.replace(existingLabel, ''); + + return result + potentialLeftOver; +} + +/** + * Adds metadata for synthetic metrics for which the API does not provide metadata. + * See https://github.com/grafana/grafana/issues/22337 for details. + * + * @param metadata HELP and TYPE metadata from /api/v1/metadata + */ +export function fixSummariesMetadata(metadata: { [metric: string]: PromMetricsMetadataItem[] }): PromMetricsMetadata { + if (!metadata) { + return metadata; + } + const baseMetadata: PromMetricsMetadata = {}; + const summaryMetadata: PromMetricsMetadata = {}; + for (const metric in metadata) { + // NOTE: based on prometheus-documentation, we can receive + // multiple metadata-entries for the given metric, it seems + // it happens when the same metric is on multiple targets + // and their help-text differs + // (https://prometheus.io/docs/prometheus/latest/querying/api/#querying-metric-metadata) + // for now we just use the first entry. + const item = metadata[metric][0]; + baseMetadata[metric] = item; + + if (item.type === 'histogram') { + summaryMetadata[`${metric}_bucket`] = { + type: 'counter', + help: `Cumulative counters for the observation buckets (${item.help})`, + }; + summaryMetadata[`${metric}_count`] = { + type: 'counter', + help: `Count of events that have been observed for the histogram metric (${item.help})`, + }; + summaryMetadata[`${metric}_sum`] = { + type: 'counter', + help: `Total sum of all observed values for the histogram metric (${item.help})`, + }; + } + if (item.type === 'summary') { + summaryMetadata[`${metric}_count`] = { + type: 'counter', + help: `Count of events that have been observed for the base metric (${item.help})`, + }; + summaryMetadata[`${metric}_sum`] = { + type: 'counter', + help: `Total sum of all observed values for the base metric (${item.help})`, + }; + } + } + // Synthetic series + const syntheticMetadata: PromMetricsMetadata = {}; + syntheticMetadata['ALERTS'] = { + type: 'counter', + help: 'Time series showing pending and firing alerts. The sample value is set to 1 as long as the alert is in the indicated active (pending or firing) state.', + }; + + return { ...baseMetadata, ...summaryMetadata, ...syntheticMetadata }; +} + +export function roundMsToMin(milliseconds: number): number { + return roundSecToMin(milliseconds / 1000); +} + +export function roundSecToMin(seconds: number): number { + return Math.floor(seconds / 60); +} + +// Returns number of minutes rounded up to the nearest nth minute +export function roundSecToNextMin(seconds: number, secondsToRound = 1): number { + return Math.ceil(seconds / 60) - (Math.ceil(seconds / 60) % secondsToRound); +} + +export function limitSuggestions(items: string[]) { + return items.slice(0, SUGGESTIONS_LIMIT); +} + +export function addLimitInfo(items: any[] | undefined): string { + return items && items.length >= SUGGESTIONS_LIMIT ? `, limited to the first ${SUGGESTIONS_LIMIT} received items` : ''; +} + +// NOTE: the following 2 exported functions are very similar to the prometheus*Escape +// functions in datasource.ts, but they are not exactly the same algorithm, and we found +// no way to reuse one in the another or vice versa. + +// Prometheus regular-expressions use the RE2 syntax (https://github.com/google/re2/wiki/Syntax), +// so every character that matches something in that list has to be escaped. +// the list of metacharacters is: *+?()|\.[]{}^$ +// we make a javascript regular expression that matches those characters: +const RE2_METACHARACTERS = /[*+?()|\\.\[\]{}^$]/g; + +function escapePrometheusRegexp(value: string): string { + return value.replace(RE2_METACHARACTERS, '\\$&'); +} + +// based on the openmetrics-documentation, the 3 symbols we have to handle are: +// - \n ... the newline character +// - \ ... the backslash character +// - " ... the double-quote character +export function escapeLabelValueInExactSelector(labelValue: string): string { + return labelValue.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"'); +} + +export function escapeLabelValueInRegexSelector(labelValue: string): string { + return escapeLabelValueInExactSelector(escapePrometheusRegexp(labelValue)); +} + +const FromPromLikeMap: Record<string, AbstractLabelOperator> = { + '=': AbstractLabelOperator.Equal, + '!=': AbstractLabelOperator.NotEqual, + '=~': AbstractLabelOperator.EqualRegEx, + '!~': AbstractLabelOperator.NotEqualRegEx, +}; + +const ToPromLikeMap: Record<AbstractLabelOperator, string> = invert(FromPromLikeMap) as Record< + AbstractLabelOperator, + string +>; + +export function toPromLikeExpr(labelBasedQuery: AbstractQuery): string { + const expr = labelBasedQuery.labelMatchers + .map((selector: AbstractLabelMatcher) => { + const operator = ToPromLikeMap[selector.operator]; + if (operator) { + return `${selector.name}${operator}"${selector.value}"`; + } else { + return ''; + } + }) + .filter((e: string) => e !== '') + .join(', '); + + return expr ? `{${expr}}` : ''; +} + +export function toPromLikeQuery(labelBasedQuery: AbstractQuery): PromLikeQuery { + return { + refId: labelBasedQuery.refId, + expr: toPromLikeExpr(labelBasedQuery), + range: true, + }; +} + +export interface PromLikeQuery extends DataQuery { + expr: string; + range: boolean; +} + +function getMaybeTokenStringContent(token: Token): string { + if (typeof token.content === 'string') { + return token.content; + } + + return ''; +} + +export function extractLabelMatchers(tokens: Array<string | Token>): AbstractLabelMatcher[] { + const labelMatchers: AbstractLabelMatcher[] = []; + + for (const token of tokens) { + if (!(token instanceof Token)) { + continue; + } + + if (token.type === 'context-labels') { + let labelKey = ''; + let labelValue = ''; + let labelOperator = ''; + + const contentTokens = Array.isArray(token.content) ? token.content : [token.content]; + + for (let currentToken of contentTokens) { + if (typeof currentToken === 'string') { + let currentStr: string; + currentStr = currentToken; + if (currentStr === '=' || currentStr === '!=' || currentStr === '=~' || currentStr === '!~') { + labelOperator = currentStr; + } + } else if (currentToken instanceof Token) { + switch (currentToken.type) { + case 'label-key': + labelKey = getMaybeTokenStringContent(currentToken); + break; + case 'label-value': + labelValue = getMaybeTokenStringContent(currentToken); + labelValue = labelValue.substring(1, labelValue.length - 1); + const labelComparator = FromPromLikeMap[labelOperator]; + if (labelComparator) { + labelMatchers.push({ name: labelKey, operator: labelComparator, value: labelValue }); + } + break; + } + } + } + } + } + + return labelMatchers; +} + +/** + * Calculates new interval "snapped" to the closest Nth minute, depending on cache level datasource setting + * @param cacheLevel + * @param range + */ +export function getRangeSnapInterval( + cacheLevel: PrometheusCacheLevel, + range: TimeRange +): { start: string; end: string } { + // Don't round the range if we're not caching + if (cacheLevel === PrometheusCacheLevel.None) { + return { + start: getPrometheusTime(range.from, false).toString(), + end: getPrometheusTime(range.to, true).toString(), + }; + } + // Otherwise round down to the nearest nth minute for the start time + const startTime = getPrometheusTime(range.from, false); + // const startTimeQuantizedSeconds = roundSecToLastMin(startTime, getClientCacheDurationInMinutes(cacheLevel)) * 60; + const startTimeQuantizedSeconds = incrRoundDn(startTime, getClientCacheDurationInMinutes(cacheLevel) * 60); + + // And round up to the nearest nth minute for the end time + const endTime = getPrometheusTime(range.to, true); + const endTimeQuantizedSeconds = roundSecToNextMin(endTime, getClientCacheDurationInMinutes(cacheLevel)) * 60; + + // If the interval was too short, we could have rounded both start and end to the same time, if so let's add one step to the end + if (startTimeQuantizedSeconds === endTimeQuantizedSeconds) { + const endTimePlusOneStep = endTimeQuantizedSeconds + getClientCacheDurationInMinutes(cacheLevel) * 60; + return { start: startTimeQuantizedSeconds.toString(), end: endTimePlusOneStep.toString() }; + } + + const start = startTimeQuantizedSeconds.toString(); + const end = endTimeQuantizedSeconds.toString(); + + return { start, end }; +} + +export function getClientCacheDurationInMinutes(cacheLevel: PrometheusCacheLevel) { + switch (cacheLevel) { + case PrometheusCacheLevel.Medium: + return 10; + case PrometheusCacheLevel.High: + return 60; + default: + return 1; + } +} + +export function getPrometheusTime(date: string | DateTime, roundUp: boolean) { + if (typeof date === 'string') { + date = dateMath.parse(date, roundUp)!; + } + + return Math.ceil(date.valueOf() / 1000); +} + +export function truncateResult<T>(array: T[], limit?: number): T[] { + if (limit === undefined) { + limit = PROMETHEUS_QUERY_BUILDER_MAX_RESULTS; + } + array.length = Math.min(array.length, limit); + return array; +} diff --git a/packages/grafana-prometheus/src/metric_find_query.test.ts b/packages/grafana-prometheus/src/metric_find_query.test.ts new file mode 100644 index 0000000000000..a410189fc42db --- /dev/null +++ b/packages/grafana-prometheus/src/metric_find_query.test.ts @@ -0,0 +1,417 @@ +import { Observable, of } from 'rxjs'; + +import 'whatwg-fetch'; // fetch polyfill needed backendSrv +import { DataSourceInstanceSettings, TimeRange, toUtc } from '@grafana/data'; +import { BackendDataSourceResponse, BackendSrvRequest, FetchResponse, TemplateSrv } from '@grafana/runtime'; + +import { PrometheusDatasource } from './datasource'; +import { getPrometheusTime } from './language_utils'; +import { PrometheusMetricFindQuery } from './metric_find_query'; +import { PromApplication, PromOptions } from './types'; + +const fetchMock = jest.fn((options: BackendSrvRequest): Observable<FetchResponse<BackendDataSourceResponse>> => { + return of({} as unknown as FetchResponse); +}); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getBackendSrv: () => { + return { + fetch: fetchMock, + }; + }, +})); + +const instanceSettings = { + url: 'proxied', + id: 1, + uid: 'ABCDEF', + user: 'test', + password: 'mupp', + jsonData: { + httpMethod: 'GET', + prometheusVersion: '2.20.0', + prometheusType: PromApplication.Prometheus, + }, +} as Partial<DataSourceInstanceSettings<PromOptions>> as DataSourceInstanceSettings<PromOptions>; +const raw: TimeRange = { + from: toUtc('2018-04-25 10:00'), + to: toUtc('2018-04-25 11:00'), + raw: { + from: '2018-04-25 10:00', + to: '2018-04-25 11:00', + }, +}; + +const templateSrvStub = { + getAdhocFilters: jest.fn().mockImplementation(() => []), + replace: jest.fn().mockImplementation((a: string) => a), +} as unknown as TemplateSrv; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('PrometheusMetricFindQuery', () => { + let legacyPrometheusDatasource: PrometheusDatasource; + let prometheusDatasource: PrometheusDatasource; + beforeEach(() => { + legacyPrometheusDatasource = new PrometheusDatasource(instanceSettings, templateSrvStub); + prometheusDatasource = new PrometheusDatasource( + { + ...instanceSettings, + jsonData: { ...instanceSettings.jsonData, prometheusVersion: '2.2.0', prometheusType: PromApplication.Mimir }, + }, + templateSrvStub + ); + }); + + const setupMetricFindQuery = ( + data: { + query: string; + response: { + data: unknown; + }; + }, + datasource?: PrometheusDatasource + ) => { + fetchMock.mockImplementation(() => of({ status: 'success', data: data.response } as unknown as FetchResponse)); + return new PrometheusMetricFindQuery(datasource ?? legacyPrometheusDatasource, data.query); + }; + + describe('When performing metricFindQuery', () => { + it('label_names() should generate label name search query', async () => { + const query = setupMetricFindQuery({ + query: 'label_names()', + response: { + data: ['name1', 'name2', 'name3'], + }, + }); + const results = await query.process(raw); + + expect(results).toHaveLength(3); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith({ + method: 'GET', + url: `/api/datasources/uid/ABCDEF/resources/api/v1/labels?start=${raw.from.unix()}&end=${raw.to.unix()}`, + hideFromInspector: true, + showErrorAlert: false, + headers: { + 'X-Grafana-Cache': 'private, max-age=60', + }, + }); + }); + + it('label_values(resource) should generate label search query', async () => { + const query = setupMetricFindQuery({ + query: 'label_values(resource)', + response: { + data: ['value1', 'value2', 'value3'], + }, + }); + const results = await query.process(raw); + + expect(results).toHaveLength(3); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith({ + method: 'GET', + url: `/api/datasources/uid/ABCDEF/resources/api/v1/label/resource/values?start=${raw.from.unix()}&end=${raw.to.unix()}`, + hideFromInspector: true, + headers: {}, + }); + }); + + const emptyFilters = ['{}', '{ }', ' { } ', ' {} ']; + + emptyFilters.forEach((emptyFilter) => { + const queryString = `label_values(${emptyFilter}, resource)`; + it(`Empty filter, query, ${queryString} should just generate label search query`, async () => { + const query = setupMetricFindQuery({ + query: queryString, + response: { + data: ['value1', 'value2', 'value3'], + }, + }); + const results = await query.process(raw); + + expect(results).toHaveLength(3); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith({ + method: 'GET', + url: `/api/datasources/uid/ABCDEF/resources/api/v1/label/resource/values?start=${raw.from.unix()}&end=${raw.to.unix()}`, + hideFromInspector: true, + headers: {}, + }); + }); + }); + + // <LegacyPrometheus> + it('label_values(metric, resource) should generate series query with correct time', async () => { + const query = setupMetricFindQuery({ + query: 'label_values(metric, resource)', + response: { + data: [ + { __name__: 'metric', resource: 'value1' }, + { __name__: 'metric', resource: 'value2' }, + { __name__: 'metric', resource: 'value3' }, + ], + }, + }); + const results = await query.process(raw); + + expect(results).toHaveLength(3); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith({ + method: 'GET', + url: `/api/datasources/uid/ABCDEF/resources/api/v1/series?match${encodeURIComponent( + '[]' + )}=metric&start=${raw.from.unix()}&end=${raw.to.unix()}`, + hideFromInspector: true, + showErrorAlert: false, + headers: {}, + }); + }); + + it('label_values(metric{label1="foo", label2="bar", label3="baz"}, resource) should generate series query with correct time', async () => { + const query = setupMetricFindQuery({ + query: 'label_values(metric{label1="foo", label2="bar", label3="baz"}, resource)', + response: { + data: [ + { __name__: 'metric', resource: 'value1' }, + { __name__: 'metric', resource: 'value2' }, + { __name__: 'metric', resource: 'value3' }, + ], + }, + }); + const results = await query.process(raw); + + expect(results).toHaveLength(3); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith({ + method: 'GET', + url: '/api/datasources/uid/ABCDEF/resources/api/v1/series?match%5B%5D=metric%7Blabel1%3D%22foo%22%2C%20label2%3D%22bar%22%2C%20label3%3D%22baz%22%7D&start=1524650400&end=1524654000', + hideFromInspector: true, + showErrorAlert: false, + headers: {}, + }); + }); + + it('label_values(metric, resource) result should not contain empty string', async () => { + const query = setupMetricFindQuery({ + query: 'label_values(metric, resource)', + response: { + data: [ + { __name__: 'metric', resource: 'value1' }, + { __name__: 'metric', resource: 'value2' }, + { __name__: 'metric', resource: '' }, + ], + }, + }); + const results = await query.process(raw); + + expect(results).toHaveLength(2); + expect(results[0].text).toBe('value1'); + expect(results[1].text).toBe('value2'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith({ + method: 'GET', + url: `/api/datasources/uid/ABCDEF/resources/api/v1/series?match${encodeURIComponent( + '[]' + )}=metric&start=${raw.from.unix()}&end=${raw.to.unix()}`, + hideFromInspector: true, + showErrorAlert: false, + headers: {}, + }); + }); + // </LegacyPrometheus> + + it('metrics(metric.*) should generate metric name query', async () => { + const query = setupMetricFindQuery({ + query: 'metrics(metric.*)', + response: { + data: ['metric1', 'metric2', 'metric3', 'nomatch'], + }, + }); + const results = await query.process(raw); + + expect(results).toHaveLength(3); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith({ + method: 'GET', + url: `/api/datasources/uid/ABCDEF/resources/api/v1/label/__name__/values?start=${raw.from.unix()}&end=${raw.to.unix()}`, + hideFromInspector: true, + headers: {}, + }); + }); + + it('query_result(metric) should generate metric name query', async () => { + const query = setupMetricFindQuery({ + query: 'query_result(metric)', + response: { + data: { + resultType: 'vector', + result: [ + { + metric: { __name__: 'metric', job: 'testjob' }, + value: [1443454528.0, '3846'], + }, + ], + }, + }, + }); + const results = await query.process(raw); + + expect(results).toHaveLength(1); + expect(results[0].text).toBe('metric{job="testjob"} 3846 1443454528000'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith({ + method: 'GET', + url: `/api/datasources/uid/ABCDEF/resources/api/v1/query?query=metric&time=${raw.to.unix()}`, + headers: {}, + hideFromInspector: true, + showErrorAlert: false, + }); + }); + + it('query_result(metric) should pass time parameter to datasource.metric_find_query', async () => { + const query = setupMetricFindQuery({ + query: 'query_result(metric)', + response: { + data: { + resultType: 'vector', + result: [ + { + metric: { __name__: 'metric', job: 'testjob' }, + value: [1443454528.0, '3846'], + }, + ], + }, + }, + }); + const results = await query.process(raw); + + const expectedTime = getPrometheusTime(raw.to, true); + + expect(results).toHaveLength(1); + expect(results[0].text).toBe('metric{job="testjob"} 3846 1443454528000'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith({ + method: 'GET', + url: `/api/datasources/uid/ABCDEF/resources/api/v1/query?query=metric&time=${expectedTime}`, + headers: {}, + hideFromInspector: true, + showErrorAlert: false, + }); + }); + + it('query_result(metric) should handle scalar resultTypes separately', async () => { + const query = setupMetricFindQuery({ + query: 'query_result(1+1)', + response: { + data: { + resultType: 'scalar', + result: [1443454528.0, '2'], + }, + }, + }); + const results = await query.process(raw); + expect(results).toHaveLength(1); + expect(results[0].text).toBe('2'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith({ + method: 'GET', + url: `/api/datasources/uid/ABCDEF/resources/api/v1/query?query=1%2B1&time=${raw.to.unix()}`, + headers: {}, + hideFromInspector: true, + showErrorAlert: false, + }); + }); + + it('up{job="job1"} should fallback using generate series query', async () => { + const query = setupMetricFindQuery({ + query: 'up{job="job1"}', + response: { + data: [ + { __name__: 'up', instance: '127.0.0.1:1234', job: 'job1' }, + { __name__: 'up', instance: '127.0.0.1:5678', job: 'job1' }, + { __name__: 'up', instance: '127.0.0.1:9102', job: 'job1' }, + ], + }, + }); + const results = await query.process(raw); + + expect(results).toHaveLength(3); + expect(results[0].text).toBe('up{instance="127.0.0.1:1234",job="job1"}'); + expect(results[1].text).toBe('up{instance="127.0.0.1:5678",job="job1"}'); + expect(results[2].text).toBe('up{instance="127.0.0.1:9102",job="job1"}'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith({ + method: 'GET', + url: `/api/datasources/uid/ABCDEF/resources/api/v1/series?match${encodeURIComponent('[]')}=${encodeURIComponent( + 'up{job="job1"}' + )}&start=${raw.from.unix()}&end=${raw.to.unix()}`, + hideFromInspector: true, + showErrorAlert: false, + headers: {}, + }); + }); + + // <ModernPrometheus> + it('label_values(metric, resource) should generate label values query with correct time', async () => { + const metricName = 'metricName'; + const resourceName = 'resourceName'; + const query = setupMetricFindQuery( + { + query: `label_values(${metricName}, ${resourceName})`, + response: { + data: [ + { __name__: `${metricName}`, resourceName: 'value1' }, + { __name__: `${metricName}`, resourceName: 'value2' }, + { __name__: `${metricName}`, resourceName: 'value3' }, + ], + }, + }, + prometheusDatasource + ); + const results = await query.process(raw); + + expect(results).toHaveLength(3); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith({ + method: 'GET', + url: `/api/datasources/uid/ABCDEF/resources/api/v1/label/${resourceName}/values?match${encodeURIComponent( + '[]' + )}=${metricName}&start=${raw.from.unix()}&end=${raw.to.unix()}`, + hideFromInspector: true, + headers: {}, + }); + }); + + it('label_values(metric{label1="foo", label2="bar", label3="baz"}, resource) should generate label values query with correct time', async () => { + const metricName = 'metricName'; + const resourceName = 'resourceName'; + const label1Name = 'label1'; + const label1Value = 'label1Value'; + const query = setupMetricFindQuery( + { + query: `label_values(${metricName}{${label1Name}="${label1Value}"}, ${resourceName})`, + response: { + data: [{ __name__: metricName, resourceName: label1Value }], + }, + }, + prometheusDatasource + ); + const results = await query.process(raw); + + expect(results).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith({ + method: 'GET', + url: `/api/datasources/uid/ABCDEF/resources/api/v1/label/${resourceName}/values?match%5B%5D=${metricName}%7B${label1Name}%3D%22${label1Value}%22%7D&start=1524650400&end=1524654000`, + hideFromInspector: true, + headers: {}, + }); + }); + // </ ModernPrometheus> + }); +}); diff --git a/packages/grafana-prometheus/src/metric_find_query.ts b/packages/grafana-prometheus/src/metric_find_query.ts new file mode 100644 index 0000000000000..ad3e229c5c071 --- /dev/null +++ b/packages/grafana-prometheus/src/metric_find_query.ts @@ -0,0 +1,203 @@ +import { chain, map as _map, uniq } from 'lodash'; + +import { getDefaultTimeRange, MetricFindValue, TimeRange } from '@grafana/data'; + +import { PrometheusDatasource } from './datasource'; +import { getPrometheusTime } from './language_utils'; +import { + PrometheusLabelNamesRegex, + PrometheusLabelNamesRegexWithMatch, + PrometheusMetricNamesRegex, + PrometheusQueryResultRegex, +} from './migrations/variableMigration'; + +export class PrometheusMetricFindQuery { + range: TimeRange; + + constructor( + private datasource: PrometheusDatasource, + private query: string + ) { + this.datasource = datasource; + this.query = query; + this.range = getDefaultTimeRange(); + } + + process(timeRange: TimeRange): Promise<MetricFindValue[]> { + this.range = timeRange; + const labelNamesRegex = PrometheusLabelNamesRegex; + const labelNamesRegexWithMatch = PrometheusLabelNamesRegexWithMatch; + const labelValuesRegex = /^label_values\((?:(.+),\s*)?([a-zA-Z_][a-zA-Z0-9_]*)\)\s*$/; + const metricNamesRegex = PrometheusMetricNamesRegex; + const queryResultRegex = PrometheusQueryResultRegex; + const labelNamesQuery = this.query.match(labelNamesRegex); + const labelNamesMatchQuery = this.query.match(labelNamesRegexWithMatch); + + if (labelNamesMatchQuery) { + const selector = `{__name__=~".*${labelNamesMatchQuery[1]}.*"}`; + return this.datasource.languageProvider.getSeriesLabels(selector, []).then((results) => + results.map((result) => ({ + text: result, + })) + ); + } + + if (labelNamesQuery) { + return this.datasource.getTagKeys({ filters: [], timeRange }); + } + + const labelValuesQuery = this.query.match(labelValuesRegex); + if (labelValuesQuery) { + const filter = labelValuesQuery[1]; + const label = labelValuesQuery[2]; + if (isFilterDefined(filter)) { + return this.labelValuesQuery(label, filter); + } else { + // Exclude the filter part of the expression because it is blank or empty + return this.labelValuesQuery(label); + } + } + + const metricNamesQuery = this.query.match(metricNamesRegex); + if (metricNamesQuery) { + return this.metricNameQuery(metricNamesQuery[1]); + } + + const queryResultQuery = this.query.match(queryResultRegex); + if (queryResultQuery) { + return this.queryResultQuery(queryResultQuery[1]); + } + + // if query contains full metric name, return metric name and label list + const expressions = ['label_values()', 'metrics()', 'query_result()']; + if (!expressions.includes(this.query)) { + return this.metricNameAndLabelsQuery(this.query); + } + + return Promise.resolve([]); + } + + labelValuesQuery(label: string, metric?: string) { + const start = getPrometheusTime(this.range.from, false); + const end = getPrometheusTime(this.range.to, true); + const params = { ...(metric && { 'match[]': metric }), start: start.toString(), end: end.toString() }; + + if (!metric || this.datasource.hasLabelsMatchAPISupport()) { + const url = `/api/v1/label/${label}/values`; + + return this.datasource.metadataRequest(url, params).then((result: any) => { + return _map(result.data.data, (value) => { + return { text: value }; + }); + }); + } else { + const url = `/api/v1/series`; + + return this.datasource.metadataRequest(url, params).then((result: any) => { + const _labels = _map(result.data.data, (metric) => { + return metric[label] || ''; + }).filter((label) => { + return label !== ''; + }); + + return uniq(_labels).map((metric) => { + return { + text: metric, + expandable: true, + }; + }); + }); + } + } + + metricNameQuery(metricFilterPattern: string) { + const start = getPrometheusTime(this.range.from, false); + const end = getPrometheusTime(this.range.to, true); + const params = { + start: start.toString(), + end: end.toString(), + }; + const url = `/api/v1/label/__name__/values`; + + return this.datasource.metadataRequest(url, params).then((result: any) => { + return chain(result.data.data) + .filter((metricName) => { + const r = new RegExp(metricFilterPattern); + return r.test(metricName); + }) + .map((matchedMetricName) => { + return { + text: matchedMetricName, + expandable: true, + }; + }) + .value(); + }); + } + + queryResultQuery(query: string) { + const url = '/api/v1/query'; + const params = { + query, + time: getPrometheusTime(this.range.to, true).toString(), + }; + return this.datasource.metadataRequest(url, params).then((result: any) => { + switch (result.data.data.resultType) { + case 'scalar': // [ <unix_time>, "<scalar_value>" ] + case 'string': // [ <unix_time>, "<string_value>" ] + return [ + { + text: result.data.data.result[1] || '', + expandable: false, + }, + ]; + case 'vector': + return _map(result.data.data.result, (metricData) => { + let text = metricData.metric.__name__ || ''; + delete metricData.metric.__name__; + text += + '{' + + _map(metricData.metric, (v, k) => { + return k + '="' + v + '"'; + }).join(',') + + '}'; + text += ' ' + metricData.value[1] + ' ' + metricData.value[0] * 1000; + + return { + text: text, + expandable: true, + }; + }); + default: + throw Error(`Unknown/Unhandled result type: [${result.data.data.resultType}]`); + } + }); + } + + metricNameAndLabelsQuery(query: string): Promise<MetricFindValue[]> { + const start = getPrometheusTime(this.range.from, false); + const end = getPrometheusTime(this.range.to, true); + const params = { + 'match[]': query, + start: start.toString(), + end: end.toString(), + }; + + const url = `/api/v1/series`; + const self = this; + + return this.datasource.metadataRequest(url, params).then((result: any) => { + return _map(result.data.data, (metric: { [key: string]: string }) => { + return { + text: self.datasource.getOriginalMetricName(metric), + expandable: true, + }; + }); + }); + } +} + +function isFilterDefined(filter: string) { + // We consider blank strings or the empty filter {} as an undefined filter + return filter && filter.split(' ').join('') !== '{}'; +} diff --git a/packages/grafana-prometheus/src/migrations/variableMigration.ts b/packages/grafana-prometheus/src/migrations/variableMigration.ts new file mode 100644 index 0000000000000..302191f98fbe7 --- /dev/null +++ b/packages/grafana-prometheus/src/migrations/variableMigration.ts @@ -0,0 +1,137 @@ +import { promQueryModeller } from '../querybuilder/PromQueryModeller'; +import { buildVisualQueryFromString } from '../querybuilder/parsing'; +import { PromVariableQuery, PromVariableQueryType as QueryType } from '../types'; + +export const PrometheusLabelNamesRegex = /^label_names\(\)\s*$/; +// Note that this regex is different from the one in metric_find_query.ts because this is used pre-interpolation +export const PrometheusLabelValuesRegex = /^label_values\((?:(.+),\s*)?([a-zA-Z_$][a-zA-Z0-9_]*)\)\s*$/; +export const PrometheusMetricNamesRegex = /^metrics\((.+)\)\s*$/; +export const PrometheusQueryResultRegex = /^query_result\((.+)\)\s*$/; +export const PrometheusLabelNamesRegexWithMatch = /^label_names\((.+)\)\s*$/; + +export function migrateVariableQueryToEditor(rawQuery: string | PromVariableQuery): PromVariableQuery { + // If not string, we assume PromVariableQuery + if (typeof rawQuery !== 'string') { + return rawQuery; + } + + const queryBase = { + refId: 'PrometheusDatasource-VariableQuery', + qryType: QueryType.LabelNames, + }; + + const labelNamesMatchQuery = rawQuery.match(PrometheusLabelNamesRegexWithMatch); + + if (labelNamesMatchQuery) { + return { + ...queryBase, + qryType: QueryType.LabelNames, + match: labelNamesMatchQuery[1], + }; + } + + const labelNames = rawQuery.match(PrometheusLabelNamesRegex); + if (labelNames) { + return { + ...queryBase, + qryType: QueryType.LabelNames, + }; + } + + const labelValuesCheck = rawQuery.match(/^label_values\(/); + if (labelValuesCheck) { + const labelValues = rawQuery.match(PrometheusLabelValuesRegex); + const label = labelValues ? labelValues[2] : ''; + const metric = labelValues ? labelValues[1] : ''; + + if (metric) { + const visQuery = buildVisualQueryFromString(metric); + return { + ...queryBase, + qryType: QueryType.LabelValues, + label, + metric: visQuery.query.metric, + labelFilters: visQuery.query.labels, + }; + } else { + return { + ...queryBase, + qryType: QueryType.LabelValues, + label, + }; + } + } + + const metricNamesCheck = rawQuery.match(/^metrics\(/); + if (metricNamesCheck) { + const metricNames = rawQuery.match(PrometheusMetricNamesRegex); + const metric = metricNames ? metricNames[1] : ''; + return { + ...queryBase, + qryType: QueryType.MetricNames, + metric, + }; + } + + const queryResultCheck = rawQuery.match(/^query_result\(/); + if (queryResultCheck) { + const queryResult = rawQuery.match(PrometheusQueryResultRegex); + const varQuery = queryResult ? queryResult[1] : ''; + return { + ...queryBase, + qryType: QueryType.VarQueryResult, + varQuery, + }; + } + + // seriesQuery does not have a function and no regex above + if (!labelNames && !labelValuesCheck && !metricNamesCheck && !queryResultCheck) { + return { + ...queryBase, + qryType: QueryType.SeriesQuery, + seriesQuery: rawQuery, + }; + } + + return queryBase; +} + +// migrate it back to a string with the correct varialbes in place +export function migrateVariableEditorBackToVariableSupport(QueryVariable: PromVariableQuery): string { + switch (QueryVariable.qryType) { + case QueryType.LabelNames: + if (QueryVariable.match) { + return `label_names(${QueryVariable.match})`; + } + return 'label_names()'; + case QueryType.LabelValues: + if (QueryVariable.metric || (QueryVariable.labelFilters && QueryVariable.labelFilters.length !== 0)) { + const visualQueryQuery = { + metric: QueryVariable.metric, + labels: QueryVariable.labelFilters ?? [], + operations: [], + }; + + const metric = promQueryModeller.renderQuery(visualQueryQuery); + return `label_values(${metric},${QueryVariable.label})`; + } else { + return `label_values(${QueryVariable.label})`; + } + case QueryType.MetricNames: + return `metrics(${QueryVariable.metric})`; + case QueryType.VarQueryResult: + const varQuery = removeLineBreaks(QueryVariable.varQuery); + return `query_result(${varQuery})`; + case QueryType.SeriesQuery: + return QueryVariable.seriesQuery ?? ''; + case QueryType.ClassicQuery: + return QueryVariable.classicQuery ?? ''; + } + + return ''; +} + +// allow line breaks in query result textarea +function removeLineBreaks(input?: string) { + return input ? input.replace(/[\r\n]+/gm, '') : ''; +} diff --git a/packages/grafana-prometheus/src/module.test.ts b/packages/grafana-prometheus/src/module.test.ts new file mode 100644 index 0000000000000..171fd4916374e --- /dev/null +++ b/packages/grafana-prometheus/src/module.test.ts @@ -0,0 +1,7 @@ +import { plugin as PrometheusDatasourcePlugin } from './module'; + +describe('module', () => { + it('should have metrics query field in panels and Explore', () => { + expect(PrometheusDatasourcePlugin.components.QueryEditor).toBeDefined(); + }); +}); diff --git a/packages/grafana-prometheus/src/module.ts b/packages/grafana-prometheus/src/module.ts new file mode 100644 index 0000000000000..8fdb90c8bd99e --- /dev/null +++ b/packages/grafana-prometheus/src/module.ts @@ -0,0 +1,13 @@ +// DONT NEED THIS BUT MAYBE EXPORT THIS TO CORE PROM + +import { DataSourcePlugin } from '@grafana/data'; + +import { PromCheatSheet } from './components/PromCheatSheet'; +import { PromQueryEditorByApp } from './components/PromQueryEditorByApp'; +import { ConfigEditor } from './configuration/ConfigEditor'; +import { PrometheusDatasource } from './datasource'; + +export const plugin = new DataSourcePlugin(PrometheusDatasource) + .setQueryEditor(PromQueryEditorByApp) + .setConfigEditor(ConfigEditor) + .setQueryEditorHelp(PromCheatSheet); diff --git a/packages/grafana-prometheus/src/promql.test.ts b/packages/grafana-prometheus/src/promql.test.ts new file mode 100644 index 0000000000000..ff78a805ef2f0 --- /dev/null +++ b/packages/grafana-prometheus/src/promql.test.ts @@ -0,0 +1,23 @@ +import Prism from 'prismjs'; + +import promql from './promql'; + +describe('Loki syntax', () => { + it('should highlight Loki query correctly', () => { + expect(Prism.highlight('{key="val#ue"}', promql, 'promql')).toBe( + '<span class="token context-labels"><span class="token punctuation">{</span><span class="token label-key attr-name">key</span>=<span class="token label-value attr-value">"val#ue"</span></span><span class="token punctuation">}</span>' + ); + expect(Prism.highlight('{key="#value"}', promql, 'promql')).toBe( + '<span class="token context-labels"><span class="token punctuation">{</span><span class="token label-key attr-name">key</span>=<span class="token label-value attr-value">"#value"</span></span><span class="token punctuation">}</span>' + ); + expect(Prism.highlight('{key="value#"}', promql, 'promql')).toBe( + '<span class="token context-labels"><span class="token punctuation">{</span><span class="token label-key attr-name">key</span>=<span class="token label-value attr-value">"value#"</span></span><span class="token punctuation">}</span>' + ); + expect(Prism.highlight('#test{key="value"}', promql, 'promql')).toBe( + '<span class="token comment">#test{key="value"}</span>' + ); + expect(Prism.highlight('{key="value"}#test', promql, 'promql')).toBe( + '<span class="token context-labels"><span class="token punctuation">{</span><span class="token label-key attr-name">key</span>=<span class="token label-value attr-value">"value"</span></span><span class="token punctuation">}</span><span class="token comment">#test</span>' + ); + }); +}); diff --git a/packages/grafana-prometheus/src/promql.ts b/packages/grafana-prometheus/src/promql.ts new file mode 100644 index 0000000000000..c37f52796bafe --- /dev/null +++ b/packages/grafana-prometheus/src/promql.ts @@ -0,0 +1,607 @@ +import { Grammar } from 'prismjs'; + +import { CompletionItem } from '@grafana/ui'; + +// When changing RATE_RANGES, check if Loki/LogQL ranges should be changed too +// @see public/app/plugins/datasource/loki/LanguageProvider.ts +export const RATE_RANGES: CompletionItem[] = [ + { label: '$__interval', sortValue: '$__interval' }, + { label: '$__rate_interval', sortValue: '$__rate_interval' }, + { label: '$__range', sortValue: '$__range' }, + { label: '1m', sortValue: '00:01:00' }, + { label: '5m', sortValue: '00:05:00' }, + { label: '10m', sortValue: '00:10:00' }, + { label: '30m', sortValue: '00:30:00' }, + { label: '1h', sortValue: '01:00:00' }, + { label: '1d', sortValue: '24:00:00' }, +]; + +export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without']; +export const LOGICAL_OPERATORS = ['or', 'and', 'unless']; + +const TRIGONOMETRIC_FUNCTIONS: CompletionItem[] = [ + { + label: 'acos', + insertText: 'acos', + detail: 'acos(v instant-vector)', + documentation: 'calculates the arccosine of all elements in v', + }, + { + label: 'acosh', + insertText: 'acosh', + detail: 'acosh(v instant-vector)', + documentation: 'calculates the inverse hyperbolic cosine of all elements in v', + }, + { + label: 'asin', + insertText: 'asin', + detail: 'asin(v instant-vector)', + documentation: 'calculates the arcsine of all elements in v', + }, + { + label: 'asinh', + insertText: 'asinh', + detail: 'asinh(v instant-vector)', + documentation: 'calculates the inverse hyperbolic sine of all elements in v', + }, + { + label: 'atan', + insertText: 'atan', + detail: 'atan(v instant-vector)', + documentation: 'calculates the arctangent of all elements in v', + }, + { + label: 'atanh', + insertText: 'atanh', + detail: 'atanh(v instant-vector)', + documentation: 'calculates the inverse hyperbolic tangent of all elements in v', + }, + { + label: 'cos', + insertText: 'cos', + detail: 'cos(v instant-vector)', + documentation: 'calculates the cosine of all elements in v', + }, + { + label: 'cosh', + insertText: 'cosh', + detail: 'cosh(v instant-vector)', + documentation: 'calculates the hyperbolic cosine of all elements in v', + }, + { + label: 'sin', + insertText: 'sin', + detail: 'sin(v instant-vector)', + documentation: 'calculates the sine of all elements in v', + }, + { + label: 'sinh', + insertText: 'sinh', + detail: 'sinh(v instant-vector)', + documentation: 'calculates the hyperbolic sine of all elements in v', + }, + { + label: 'tan', + insertText: 'tan', + detail: 'tan(v instant-vector)', + documentation: 'calculates the tangent of all elements in v', + }, + { + label: 'tanh', + insertText: 'tanh', + detail: 'tanh(v instant-vector)', + documentation: 'calculates the hyperbolic tangent of all elements in v', + }, +]; + +const AGGREGATION_OPERATORS: CompletionItem[] = [ + { + label: 'sum', + insertText: 'sum', + documentation: 'Calculate sum over dimensions', + }, + { + label: 'min', + insertText: 'min', + documentation: 'Select minimum over dimensions', + }, + { + label: 'max', + insertText: 'max', + documentation: 'Select maximum over dimensions', + }, + { + label: 'avg', + insertText: 'avg', + documentation: 'Calculate the average over dimensions', + }, + { + label: 'group', + insertText: 'group', + documentation: 'All values in the resulting vector are 1', + }, + { + label: 'stddev', + insertText: 'stddev', + documentation: 'Calculate population standard deviation over dimensions', + }, + { + label: 'stdvar', + insertText: 'stdvar', + documentation: 'Calculate population standard variance over dimensions', + }, + { + label: 'count', + insertText: 'count', + documentation: 'Count number of elements in the vector', + }, + { + label: 'count_values', + insertText: 'count_values', + documentation: 'Count number of elements with the same value', + }, + { + label: 'bottomk', + insertText: 'bottomk', + documentation: 'Smallest k elements by sample value', + }, + { + label: 'topk', + insertText: 'topk', + documentation: 'Largest k elements by sample value', + }, + { + label: 'quantile', + insertText: 'quantile', + documentation: 'Calculate φ-quantile (0 ≤ φ ≤ 1) over dimensions', + }, +]; + +export const FUNCTIONS = [ + ...AGGREGATION_OPERATORS, + ...TRIGONOMETRIC_FUNCTIONS, + { + insertText: 'abs', + label: 'abs', + detail: 'abs(v instant-vector)', + documentation: 'Returns the input vector with all sample values converted to their absolute value.', + }, + { + insertText: 'absent', + label: 'absent', + detail: 'absent(v instant-vector)', + documentation: + 'Returns an empty vector if the vector passed to it has any elements and a 1-element vector with the value 1 if the vector passed to it has no elements. This is useful for alerting on when no time series exist for a given metric name and label combination.', + }, + { + insertText: 'absent_over_time', + label: 'absent_over_time', + detail: 'absent(v range-vector)', + documentation: + 'Returns an empty vector if the range vector passed to it has any elements and a 1-element vector with the value 1 if the range vector passed to it has no elements.', + }, + { + insertText: 'ceil', + label: 'ceil', + detail: 'ceil(v instant-vector)', + documentation: 'Rounds the sample values of all elements in `v` up to the nearest integer.', + }, + { + insertText: 'changes', + label: 'changes', + detail: 'changes(v range-vector)', + documentation: + 'For each input time series, `changes(v range-vector)` returns the number of times its value has changed within the provided time range as an instant vector.', + }, + { + insertText: 'clamp', + label: 'clamp', + detail: 'clamp(v instant-vector, min scalar, max scalar)', + documentation: + 'Clamps the sample values of all elements in `v` to have a lower limit of `min` and an upper limit of `max`.', + }, + { + insertText: 'clamp_max', + label: 'clamp_max', + detail: 'clamp_max(v instant-vector, max scalar)', + documentation: 'Clamps the sample values of all elements in `v` to have an upper limit of `max`.', + }, + { + insertText: 'clamp_min', + label: 'clamp_min', + detail: 'clamp_min(v instant-vector, min scalar)', + documentation: 'Clamps the sample values of all elements in `v` to have a lower limit of `min`.', + }, + { + insertText: 'count_scalar', + label: 'count_scalar', + detail: 'count_scalar(v instant-vector)', + documentation: + 'Returns the number of elements in a time series vector as a scalar. This is in contrast to the `count()` aggregation operator, which always returns a vector (an empty one if the input vector is empty) and allows grouping by labels via a `by` clause.', + }, + { + insertText: 'deg', + label: 'deg', + detail: 'deg(v instant-vector)', + documentation: 'Converts radians to degrees for all elements in v', + }, + { + insertText: 'day_of_month', + label: 'day_of_month', + detail: 'day_of_month(v=vector(time()) instant-vector)', + documentation: 'Returns the day of the month for each of the given times in UTC. Returned values are from 1 to 31.', + }, + { + insertText: 'day_of_week', + label: 'day_of_week', + detail: 'day_of_week(v=vector(time()) instant-vector)', + documentation: + 'Returns the day of the week for each of the given times in UTC. Returned values are from 0 to 6, where 0 means Sunday etc.', + }, + { + insertText: 'day_of_year', + label: 'day_of_year', + detail: 'day_of_year(v=vector(time()) instant-vector)', + documentation: + 'Returns the day of the year for each of the given times in UTC. Returned values are from 1 to 365 for non-leap years, and 1 to 366 in leap years.', + }, + { + insertText: 'days_in_month', + label: 'days_in_month', + detail: 'days_in_month(v=vector(time()) instant-vector)', + documentation: + 'Returns number of days in the month for each of the given times in UTC. Returned values are from 28 to 31.', + }, + { + insertText: 'delta', + label: 'delta', + detail: 'delta(v range-vector)', + documentation: + 'Calculates the difference between the first and last value of each time series element in a range vector `v`, returning an instant vector with the given deltas and equivalent labels. The delta is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if the sample values are all integers.', + }, + { + insertText: 'deriv', + label: 'deriv', + detail: 'deriv(v range-vector)', + documentation: + 'Calculates the per-second derivative of the time series in a range vector `v`, using simple linear regression.', + }, + { + insertText: 'drop_common_labels', + label: 'drop_common_labels', + detail: 'drop_common_labels(instant-vector)', + documentation: 'Drops all labels that have the same name and value across all series in the input vector.', + }, + { + insertText: 'exp', + label: 'exp', + detail: 'exp(v instant-vector)', + documentation: + 'Calculates the exponential function for all elements in `v`.\nSpecial cases are:\n* `Exp(+Inf) = +Inf` \n* `Exp(NaN) = NaN`', + }, + { + insertText: 'floor', + label: 'floor', + detail: 'floor(v instant-vector)', + documentation: 'Rounds the sample values of all elements in `v` down to the nearest integer.', + }, + { + insertText: 'histogram_quantile', + label: 'histogram_quantile', + detail: 'histogram_quantile(φ float, b instant-vector)', + documentation: + 'Calculates the φ-quantile (0 ≤ φ ≤ 1) from the buckets `b` of a histogram. The samples in `b` are the counts of observations in each bucket. Each sample must have a label `le` where the label value denotes the inclusive upper bound of the bucket. (Samples without such a label are silently ignored.) The histogram metric type automatically provides time series with the `_bucket` suffix and the appropriate labels.', + }, + { + insertText: 'holt_winters', + label: 'holt_winters', + detail: 'holt_winters(v range-vector, sf scalar, tf scalar)', + documentation: + 'Produces a smoothed value for time series based on the range in `v`. The lower the smoothing factor `sf`, the more importance is given to old data. The higher the trend factor `tf`, the more trends in the data is considered. Both `sf` and `tf` must be between 0 and 1.', + }, + { + insertText: 'hour', + label: 'hour', + detail: 'hour(v=vector(time()) instant-vector)', + documentation: 'Returns the hour of the day for each of the given times in UTC. Returned values are from 0 to 23.', + }, + { + insertText: 'idelta', + label: 'idelta', + detail: 'idelta(v range-vector)', + documentation: + 'Calculates the difference between the last two samples in the range vector `v`, returning an instant vector with the given deltas and equivalent labels.', + }, + { + insertText: 'increase', + label: 'increase', + detail: 'increase(v range-vector)', + documentation: + 'Calculates the increase in the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. The increase is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if a counter increases only by integer increments.', + }, + { + insertText: 'irate', + label: 'irate', + detail: 'irate(v range-vector)', + documentation: + 'Calculates the per-second instant rate of increase of the time series in the range vector. This is based on the last two data points. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for.', + }, + { + insertText: 'label_join', + label: 'label_join', + detail: + 'label_join(v instant-vector, dst_label string, separator string, src_label_1 string, src_label_2 string, ...)', + documentation: + 'For each timeseries in `v`, joins all the values of all the `src_labels` using `separator` and returns the timeseries with the label `dst_label` containing the joined value. There can be any number of `src_labels` in this function.', + }, + { + insertText: 'label_replace', + label: 'label_replace', + detail: 'label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)', + documentation: + "For each timeseries in `v`, `label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)` matches the regular expression `regex` against the label `src_label`. If it matches, then the timeseries is returned with the label `dst_label` replaced by the expansion of `replacement`. `$1` is replaced with the first matching subgroup, `$2` with the second etc. If the regular expression doesn't match then the timeseries is returned unchanged.", + }, + { + insertText: 'ln', + label: 'ln', + detail: 'ln(v instant-vector)', + documentation: + 'Calculates the natural logarithm for all elements in `v`.\nSpecial cases are:\n * `ln(+Inf) = +Inf`\n * `ln(0) = -Inf`\n * `ln(x < 0) = NaN`\n * `ln(NaN) = NaN`', + }, + { + insertText: 'log2', + label: 'log2', + detail: 'log2(v instant-vector)', + documentation: + 'Calculates the binary logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.', + }, + { + insertText: 'log10', + label: 'log10', + detail: 'log10(v instant-vector)', + documentation: + 'Calculates the decimal logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.', + }, + { + insertText: 'minute', + label: 'minute', + detail: 'minute(v=vector(time()) instant-vector)', + documentation: + 'Returns the minute of the hour for each of the given times in UTC. Returned values are from 0 to 59.', + }, + { + insertText: 'month', + label: 'month', + detail: 'month(v=vector(time()) instant-vector)', + documentation: + 'Returns the month of the year for each of the given times in UTC. Returned values are from 1 to 12, where 1 means January etc.', + }, + { + insertText: 'pi', + label: 'pi', + detail: 'pi()', + documentation: 'Returns pi', + }, + { + insertText: 'predict_linear', + label: 'predict_linear', + detail: 'predict_linear(v range-vector, t scalar)', + documentation: + 'Predicts the value of time series `t` seconds from now, based on the range vector `v`, using simple linear regression.', + }, + { + insertText: 'rad', + label: 'rad', + detail: 'rad(v instant-vector)', + documentation: 'Converts degrees to radians for all elements in v', + }, + { + insertText: 'rate', + label: 'rate', + detail: 'rate(v range-vector)', + documentation: + "Calculates the per-second average rate of increase of the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. Also, the calculation extrapolates to the ends of the time range, allowing for missed scrapes or imperfect alignment of scrape cycles with the range's time period.", + }, + { + insertText: 'resets', + label: 'resets', + detail: 'resets(v range-vector)', + documentation: + 'For each input time series, `resets(v range-vector)` returns the number of counter resets within the provided time range as an instant vector. Any decrease in the value between two consecutive samples is interpreted as a counter reset.', + }, + { + insertText: 'round', + label: 'round', + detail: 'round(v instant-vector, to_nearest=1 scalar)', + documentation: + 'Rounds the sample values of all elements in `v` to the nearest integer. Ties are resolved by rounding up. The optional `to_nearest` argument allows specifying the nearest multiple to which the sample values should be rounded. This multiple may also be a fraction.', + }, + { + insertText: 'scalar', + label: 'scalar', + detail: 'scalar(v instant-vector)', + documentation: + 'Given a single-element input vector, `scalar(v instant-vector)` returns the sample value of that single element as a scalar. If the input vector does not have exactly one element, `scalar` will return `NaN`.', + }, + { + insertText: 'sgn', + label: 'sgn', + detail: 'sgn(v instant-vector)', + documentation: + 'Returns a vector with all sample values converted to their sign, defined as this: 1 if v is positive, -1 if v is negative and 0 if v is equal to zero.', + }, + { + insertText: 'sort', + label: 'sort', + detail: 'sort(v instant-vector)', + documentation: 'Returns vector elements sorted by their sample values, in ascending order.', + }, + { + insertText: 'sort_desc', + label: 'sort_desc', + detail: 'sort_desc(v instant-vector)', + documentation: 'Returns vector elements sorted by their sample values, in descending order.', + }, + { + insertText: 'sqrt', + label: 'sqrt', + detail: 'sqrt(v instant-vector)', + documentation: 'Calculates the square root of all elements in `v`.', + }, + { + insertText: 'time', + label: 'time', + detail: 'time()', + documentation: + 'Returns the number of seconds since January 1, 1970 UTC. Note that this does not actually return the current time, but the time at which the expression is to be evaluated.', + }, + { + insertText: 'timestamp', + label: 'timestamp', + detail: 'timestamp(v instant-vector)', + documentation: + 'Returns the timestamp of each of the samples of the given vector as the number of seconds since January 1, 1970 UTC.', + }, + { + insertText: 'vector', + label: 'vector', + detail: 'vector(s scalar)', + documentation: 'Returns the scalar `s` as a vector with no labels.', + }, + { + insertText: 'year', + label: 'year', + detail: 'year(v=vector(time()) instant-vector)', + documentation: 'Returns the year for each of the given times in UTC.', + }, + { + insertText: 'avg_over_time', + label: 'avg_over_time', + detail: 'avg_over_time(range-vector)', + documentation: 'The average value of all points in the specified interval.', + }, + { + insertText: 'min_over_time', + label: 'min_over_time', + detail: 'min_over_time(range-vector)', + documentation: 'The minimum value of all points in the specified interval.', + }, + { + insertText: 'max_over_time', + label: 'max_over_time', + detail: 'max_over_time(range-vector)', + documentation: 'The maximum value of all points in the specified interval.', + }, + { + insertText: 'sum_over_time', + label: 'sum_over_time', + detail: 'sum_over_time(range-vector)', + documentation: 'The sum of all values in the specified interval.', + }, + { + insertText: 'count_over_time', + label: 'count_over_time', + detail: 'count_over_time(range-vector)', + documentation: 'The count of all values in the specified interval.', + }, + { + insertText: 'quantile_over_time', + label: 'quantile_over_time', + detail: 'quantile_over_time(scalar, range-vector)', + documentation: 'The φ-quantile (0 ≤ φ ≤ 1) of the values in the specified interval.', + }, + { + insertText: 'stddev_over_time', + label: 'stddev_over_time', + detail: 'stddev_over_time(range-vector)', + documentation: 'The population standard deviation of the values in the specified interval.', + }, + { + insertText: 'stdvar_over_time', + label: 'stdvar_over_time', + detail: 'stdvar_over_time(range-vector)', + documentation: 'The population standard variance of the values in the specified interval.', + }, + { + insertText: 'last_over_time', + label: 'last_over_time', + detail: 'last_over_time(range-vector)', + documentation: 'The most recent point value in specified interval.', + }, + { + insertText: 'present_over_time', + label: 'present_over_time', + detail: 'present_over_time(range-vector)', + documentation: 'The value 1 for any series in the specified interval.', + }, +]; + +export const PROM_KEYWORDS = FUNCTIONS.map((keyword) => keyword.label); + +export const promqlGrammar: Grammar = { + comment: { + pattern: /#.*/, + }, + 'context-aggregation': { + pattern: /((by|without)\s*)\([^)]*\)/, // by () + lookbehind: true, + inside: { + 'label-key': { + pattern: /[^(),\s][^,)]*[^),\s]*/, + alias: 'attr-name', + }, + punctuation: /[()]/, + }, + }, + 'context-labels': { + pattern: /\{[^}]*(?=}?)/, + greedy: true, + inside: { + comment: { + pattern: /#.*/, + }, + 'label-key': { + pattern: /[a-z_]\w*(?=\s*(=|!=|=~|!~))/, + alias: 'attr-name', + greedy: true, + }, + 'label-value': { + pattern: /"(?:\\.|[^\\"])*"/, + greedy: true, + alias: 'attr-value', + }, + punctuation: /[{]/, + }, + }, + function: new RegExp(`\\b(?:${FUNCTIONS.map((f) => f.label).join('|')})(?=\\s*\\()`, 'i'), + 'context-range': [ + { + pattern: /\[[^\]]*(?=])/, // [1m] + inside: { + 'range-duration': { + pattern: /\b\d+[smhdwy]\b/i, + alias: 'number', + }, + }, + }, + { + pattern: /(offset\s+)\w+/, // offset 1m + lookbehind: true, + inside: { + 'range-duration': { + pattern: /\b\d+[smhdwy]\b/i, + alias: 'number', + }, + }, + }, + ], + idList: { + pattern: /\d+(\|\d+)+/, + alias: 'number', + }, + number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/, + operator: new RegExp(`/[-+*/=%^~]|&&?|\\|?\\||!=?|<(?:=>?|<|>)?|>[>=]?|\\b(?:${OPERATORS.join('|')})\\b`, 'i'), + punctuation: /[{};()`,.]/, +}; + +export default promqlGrammar; diff --git a/packages/grafana-prometheus/src/query_hints.test.ts b/packages/grafana-prometheus/src/query_hints.test.ts new file mode 100644 index 0000000000000..7089ea3e2e146 --- /dev/null +++ b/packages/grafana-prometheus/src/query_hints.test.ts @@ -0,0 +1,192 @@ +import { PrometheusDatasource } from './datasource'; +import { getQueryHints, SUM_HINT_THRESHOLD_COUNT } from './query_hints'; + +describe('getQueryHints()', () => { + it('returns no hints for no series', () => { + expect(getQueryHints('', [])).toEqual([]); + }); + + it('returns no hints for empty series', () => { + expect(getQueryHints('', [{ datapoints: [] }])).toEqual([]); + }); + + it('returns a rate hint for a counter metric', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('metric_total', series); + + expect(hints!.length).toBe(1); + expect(hints![0]).toMatchObject({ + label: 'Selected metric looks like a counter.', + fix: { + action: { + type: 'ADD_RATE', + query: 'metric_total', + }, + }, + }); + }); + + it('returns a certain rate hint for a counter metric', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const mock: unknown = { languageProvider: { metricsMetadata: { foo: { type: 'counter' } } } }; + const datasource = mock as PrometheusDatasource; + + let hints = getQueryHints('foo', series, datasource); + expect(hints!.length).toBe(1); + expect(hints![0]).toMatchObject({ + label: 'Selected metric is a counter.', + fix: { + action: { + type: 'ADD_RATE', + query: 'foo', + }, + }, + }); + + // Test substring match not triggering hint + hints = getQueryHints('foo_foo', series, datasource); + expect(hints).toEqual([]); + }); + + it('returns no rate hint for a counter metric that already has a rate', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('rate(metric_total[1m])', series); + expect(hints).toEqual([]); + }); + + it('returns no rate hint for a counter metric that already has an increase', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('increase(metric_total[1m])', series); + expect(hints).toEqual([]); + }); + + it('returns a rate hint with action for a counter metric with labels', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('metric_total{job="grafana"}', series); + expect(hints!.length).toBe(1); + expect(hints![0].label).toContain('Selected metric looks like a counter'); + expect(hints![0].fix).toBeDefined(); + }); + + it('returns a rate hint w/o action for a complex counter metric', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('sum(metric_total)', series); + expect(hints!.length).toBe(1); + expect(hints![0].label).toContain('rate()'); + expect(hints![0].fix).toBeUndefined(); + }); + + it('returns a histogram hint for a bucket series', () => { + const series = [{ datapoints: [[23, 1000]] }]; + const hints = getQueryHints('metric_bucket', series); + expect(hints!.length).toBe(1); + expect(hints![0]).toMatchObject({ + label: 'Selected metric has buckets.', + fix: { + action: { + type: 'ADD_HISTOGRAM_QUANTILE', + query: 'metric_bucket', + }, + }, + }); + }); + + it('returns a histogram hint with action for a bucket with labels', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('metric_bucket{job="grafana"}', series); + expect(hints!.length).toBe(1); + expect(hints![0].label).toContain('Selected metric has buckets.'); + expect(hints![0].fix).toBeDefined(); + }); + + it('returns a sum hint when many time series results are returned for a simple metric', () => { + const seriesCount = SUM_HINT_THRESHOLD_COUNT; + const series = Array.from({ length: seriesCount }, (_) => ({ + datapoints: [ + [0, 0], + [0, 0], + ], + })); + const hints = getQueryHints('metric', series); + expect(hints!.length).toBe(1); + expect(hints![0]).toMatchObject({ + type: 'ADD_SUM', + label: 'Many time series results returned.', + fix: { + label: 'Consider aggregating with sum().', + action: { + type: 'ADD_SUM', + query: 'metric', + preventSubmit: true, + }, + }, + }); + }); + + it('should not return rate hint for a recorded query', () => { + const seriesCount = SUM_HINT_THRESHOLD_COUNT; + const series = Array.from({ length: seriesCount }, (_) => ({ + datapoints: [ + [0, 0], + [0, 0], + ], + })); + let hints = getQueryHints('node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate', series); + expect(hints!.length).toBe(0); + + hints = getQueryHints('node_namespace_pod_container:container_cpu_usage_seconds_total', series); + expect(hints!.length).toBe(0); + + hints = getQueryHints('container_cpu_usage_seconds_total:irate_total', series); + expect(hints!.length).toBe(0); + }); +}); diff --git a/packages/grafana-prometheus/src/query_hints.ts b/packages/grafana-prometheus/src/query_hints.ts new file mode 100644 index 0000000000000..8033048bb3e87 --- /dev/null +++ b/packages/grafana-prometheus/src/query_hints.ts @@ -0,0 +1,151 @@ +import { size } from 'lodash'; + +import { QueryFix, QueryHint } from '@grafana/data'; + +import { PrometheusDatasource } from './datasource'; + +/** + * Number of time series results needed before starting to suggest sum aggregation hints + */ +export const SUM_HINT_THRESHOLD_COUNT = 20; + +export function getQueryHints(query: string, series?: any[], datasource?: PrometheusDatasource): QueryHint[] { + const hints = []; + + // ..._bucket metric needs a histogram_quantile() + const histogramMetric = query.trim().match(/^\w+_bucket$|^\w+_bucket{.*}$/); + if (histogramMetric) { + const label = 'Selected metric has buckets.'; + hints.push({ + type: 'HISTOGRAM_QUANTILE', + label, + fix: { + label: 'Consider calculating aggregated quantile by adding histogram_quantile().', + action: { + type: 'ADD_HISTOGRAM_QUANTILE', + query, + }, + }, + }); + } + + // Check for need of rate() + if (query.indexOf('rate(') === -1 && query.indexOf('increase(') === -1) { + // Use metric metadata for exact types + const nameMatch = query.match(/\b((?<!:)\w+_(total|sum|count)(?!:))\b/); + let counterNameMetric = nameMatch ? nameMatch[1] : ''; + const metricsMetadata = datasource?.languageProvider?.metricsMetadata; + let certain = false; + + if (metricsMetadata) { + // Tokenize the query into its identifiers (see https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels) + const queryTokens = Array.from(query.matchAll(/\$?[a-zA-Z_:][a-zA-Z0-9_:]*/g)) + .map(([match]) => match) + // Exclude variable identifiers + .filter((token) => !token.startsWith('$')) + // Split composite keys to match the tokens returned by the language provider + .flatMap((token) => token.split(':')); + // Determine whether any of the query identifier tokens refers to a counter metric + counterNameMetric = + queryTokens.find((metricName) => { + // Only considering first type information, could be non-deterministic + const metadata = metricsMetadata[metricName]; + if (metadata && metadata.type.toLowerCase() === 'counter') { + certain = true; + return true; + } else { + return false; + } + }) ?? ''; + } + + if (counterNameMetric) { + // FixableQuery consists of metric name and optionally label-value pairs. We are not offering fix for complex queries yet. + const fixableQuery = query.trim().match(/^\w+$|^\w+{.*}$/); + const verb = certain ? 'is' : 'looks like'; + let label = `Selected metric ${verb} a counter.`; + let fix: QueryFix | undefined; + + if (fixableQuery) { + fix = { + label: 'Consider calculating rate of counter by adding rate().', + action: { + type: 'ADD_RATE', + query, + }, + }; + } else { + label = `${label} Consider calculating rate of counter by adding rate().`; + } + + hints.push({ + type: 'APPLY_RATE', + label, + fix, + }); + } + } + + // Check for recording rules expansion + if (datasource && datasource.ruleMappings) { + const mapping = datasource.ruleMappings; + const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => { + if (query.search(ruleName) > -1) { + return { + ...acc, + [ruleName]: mapping[ruleName], + }; + } + return acc; + }, {}); + if (size(mappingForQuery) > 0) { + const label = 'Query contains recording rules.'; + hints.push({ + type: 'EXPAND_RULES', + label, + fix: { + label: 'Expand rules', + action: { + type: 'EXPAND_RULES', + query, + options: mappingForQuery, + }, + }, + }); + } + } + + if (series && series.length >= SUM_HINT_THRESHOLD_COUNT) { + const simpleMetric = query.trim().match(/^\w+$/); + if (simpleMetric) { + hints.push({ + type: 'ADD_SUM', + label: 'Many time series results returned.', + fix: { + label: 'Consider aggregating with sum().', + action: { + type: 'ADD_SUM', + query: query, + preventSubmit: true, + }, + }, + }); + } + } + + return hints; +} + +export function getInitHints(datasource: PrometheusDatasource): QueryHint[] { + const hints = []; + + // Hint for big disabled lookups + if (datasource.lookupsDisabled) { + hints.push({ + label: `Labels and metrics lookup was disabled in data source settings.`, + type: 'INFO', + }); + } + + return hints; +} diff --git a/packages/grafana-prometheus/src/querybuilder/PromQueryModeller.test.ts b/packages/grafana-prometheus/src/querybuilder/PromQueryModeller.test.ts new file mode 100644 index 0000000000000..f81ab15cbc9d9 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/PromQueryModeller.test.ts @@ -0,0 +1,335 @@ +import { PromQueryModeller } from './PromQueryModeller'; +import { PromOperationId } from './types'; + +describe('PromQueryModeller', () => { + const modeller = new PromQueryModeller(); + + it('Can render query with metric only', () => { + expect( + modeller.renderQuery({ + metric: 'my_totals', + labels: [], + operations: [], + }) + ).toBe('my_totals'); + }); + + it('Can render query with label filters', () => { + expect( + modeller.renderQuery({ + metric: 'my_totals', + labels: [ + { label: 'cluster', op: '=', value: 'us-east' }, + { label: 'job', op: '=~', value: 'abc' }, + ], + operations: [], + }) + ).toBe('my_totals{cluster="us-east", job=~"abc"}'); + }); + + it('Can render query with function', () => { + expect( + modeller.renderQuery({ + metric: 'my_totals', + labels: [], + operations: [{ id: 'sum', params: [] }], + }) + ).toBe('sum(my_totals)'); + }); + + it('Can render query with function with parameter to left of inner expression', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [], + operations: [{ id: PromOperationId.HistogramQuantile, params: [0.86] }], + }) + ).toBe('histogram_quantile(0.86, metric)'); + }); + + it('Can render query with function with function parameters to the right of inner expression', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [], + operations: [{ id: PromOperationId.LabelReplace, params: ['server', '$1', 'instance', 'as(.*)d'] }], + }) + ).toBe('label_replace(metric, "server", "$1", "instance", "as(.*)d")'); + }); + + it('Can group by expressions', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [], + operations: [{ id: '__sum_by', params: ['server', 'job'] }], + }) + ).toBe('sum by(server, job) (metric)'); + }); + + it('Can render avg around a group by', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [], + operations: [ + { id: '__sum_by', params: ['server', 'job'] }, + { id: 'avg', params: [] }, + ], + }) + ).toBe('avg(sum by(server, job) (metric))'); + }); + + it('Can use aggregation without label', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [], + operations: [{ id: '__sum_without', params: ['server', 'job'] }], + }) + ).toBe('sum without(server, job) (metric)'); + }); + + it('Can render aggregations with parameters', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [], + operations: [{ id: 'topk', params: [5] }], + }) + ).toBe('topk(5, metric)'); + }); + + it('Can render rate', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [{ label: 'pod', op: '=', value: 'A' }], + operations: [{ id: PromOperationId.Rate, params: ['$__rate_interval'] }], + }) + ).toBe('rate(metric{pod="A"}[$__rate_interval])'); + }); + + it('Can render increase', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [{ label: 'pod', op: '=', value: 'A' }], + operations: [{ id: PromOperationId.Increase, params: ['$__interval'] }], + }) + ).toBe('increase(metric{pod="A"}[$__interval])'); + }); + + it('Can render rate with custom range-vector', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [{ label: 'pod', op: '=', value: 'A' }], + operations: [{ id: PromOperationId.Rate, params: ['10m'] }], + }) + ).toBe('rate(metric{pod="A"}[10m])'); + }); + + it('Can render multiply operation', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [], + operations: [{ id: PromOperationId.MultiplyBy, params: [1000] }], + }) + ).toBe('metric * 1000'); + }); + + it('Can render query with simple binary query', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [], + binaryQueries: [ + { + operator: '/', + query: { + metric: 'metric_b', + labels: [], + operations: [], + }, + }, + ], + }) + ).toBe('metric_a / metric_b'); + }); + + it('Can render query with multiple binary queries and nesting', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [], + binaryQueries: [ + { + operator: '+', + query: { + metric: 'metric_b', + labels: [], + operations: [], + }, + }, + { + operator: '+', + query: { + metric: 'metric_c', + labels: [], + operations: [], + }, + }, + ], + }) + ).toBe('metric_a + metric_b + metric_c'); + }); + + it('Can render query with nested query with binary op', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [], + binaryQueries: [ + { + operator: '/', + query: { + metric: 'metric_b', + labels: [], + operations: [{ id: PromOperationId.MultiplyBy, params: [1000] }], + }, + }, + ], + }) + ).toBe('metric_a / (metric_b * 1000)'); + }); + + it('Can render query with nested binary query with parentheses', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [], + binaryQueries: [ + { + operator: '/', + query: { + metric: 'metric_b', + labels: [], + operations: [], + binaryQueries: [ + { + operator: '*', + query: { + metric: 'metric_c', + labels: [], + operations: [], + }, + }, + ], + }, + }, + ], + }) + ).toBe('metric_a / (metric_b * metric_c)'); + }); + + it('Should add parantheis around first query if it has binary op', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [{ id: PromOperationId.MultiplyBy, params: [1000] }], + binaryQueries: [ + { + operator: '/', + query: { + metric: 'metric_b', + labels: [], + operations: [], + }, + }, + ], + }) + ).toBe('(metric_a * 1000) / metric_b'); + }); + + it('Can render functions that require a range as a parameter', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [{ id: 'holt_winters', params: ['5m', 0.5, 0.5] }], + }) + ).toBe('holt_winters(metric_a[5m], 0.5, 0.5)'); + }); + it('Can render functions that require parameters left of a range', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [{ id: 'quantile_over_time', params: ['5m', 1] }], + }) + ).toBe('quantile_over_time(1, metric_a[5m])'); + }); + it('Can render the label_join function', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [{ id: 'label_join', params: ['label_1', ',', 'label_2'] }], + }) + ).toBe('label_join(metric_a, "label_1", ",", "label_2")'); + }); + + it('Can render label_join with extra parameters', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [{ id: 'label_join', params: ['label_1', ', ', 'label_2', 'label_3', 'label_4', 'label_5'] }], + }) + ).toBe('label_join(metric_a, "label_1", ", ", "label_2", "label_3", "label_4", "label_5")'); + }); + + it('can render vector matchers', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [], + binaryQueries: [ + { + operator: '/', + vectorMatches: 'le, foo', + vectorMatchesType: 'on', + query: { + metric: 'metric_b', + labels: [], + operations: [], + }, + }, + ], + }) + ).toBe('metric_a / on(le, foo) metric_b'); + }); + + it('can render bool in binary ops', () => { + expect( + modeller.renderQuery({ + metric: 'cluster_namespace_slug_dialer_name', + labels: [], + operations: [ + { + id: '__less_or_equal', + params: [2, true], + }, + ], + }) + ).toBe('cluster_namespace_slug_dialer_name <= bool 2'); + }); +}); diff --git a/packages/grafana-prometheus/src/querybuilder/PromQueryModeller.ts b/packages/grafana-prometheus/src/querybuilder/PromQueryModeller.ts new file mode 100644 index 0000000000000..894ba907ed1c0 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/PromQueryModeller.ts @@ -0,0 +1,93 @@ +import { FUNCTIONS } from '../promql'; + +import { getAggregationOperations } from './aggregations'; +import { getOperationDefinitions } from './operations'; +import { LokiAndPromQueryModellerBase } from './shared/LokiAndPromQueryModellerBase'; +import { PromQueryPattern, PromQueryPatternType, PromVisualQueryOperationCategory } from './types'; + +export class PromQueryModeller extends LokiAndPromQueryModellerBase { + constructor() { + super(() => { + const allOperations = [...getOperationDefinitions(), ...getAggregationOperations()]; + for (const op of allOperations) { + const func = FUNCTIONS.find((x) => x.insertText === op.id); + if (func) { + op.documentation = func.documentation; + } + } + return allOperations; + }); + + this.setOperationCategories([ + PromVisualQueryOperationCategory.Aggregations, + PromVisualQueryOperationCategory.RangeFunctions, + PromVisualQueryOperationCategory.Functions, + PromVisualQueryOperationCategory.BinaryOps, + PromVisualQueryOperationCategory.Trigonometric, + PromVisualQueryOperationCategory.Time, + ]); + } + + getQueryPatterns(): PromQueryPattern[] { + return [ + { + name: 'Rate then sum', + type: PromQueryPatternType.Rate, + operations: [ + { id: 'rate', params: ['$__rate_interval'] }, + { id: 'sum', params: [] }, + ], + }, + { + name: 'Rate then sum by(label) then avg', + type: PromQueryPatternType.Rate, + operations: [ + { id: 'rate', params: ['$__rate_interval'] }, + { id: '__sum_by', params: [''] }, + { id: 'avg', params: [] }, + ], + }, + { + name: 'Histogram quantile on rate', + type: PromQueryPatternType.Histogram, + operations: [ + { id: 'rate', params: ['$__rate_interval'] }, + { id: '__sum_by', params: ['le'] }, + { id: 'histogram_quantile', params: [0.95] }, + ], + }, + { + name: 'Histogram quantile on increase', + type: PromQueryPatternType.Histogram, + operations: [ + { id: 'increase', params: ['$__rate_interval'] }, + { id: '__max_by', params: ['le'] }, + { id: 'histogram_quantile', params: [0.95] }, + ], + }, + { + name: 'Binary Query', + type: PromQueryPatternType.Binary, + operations: [ + { id: 'rate', params: ['$__rate_interval'] }, + { id: 'sum', params: [] }, + ], + binaryQueries: [ + { + operator: '/', + query: { + metric: '', + labels: [], + operations: [ + { id: 'rate', params: ['$__rate_interval'] }, + { id: 'sum', params: [] }, + ], + }, + }, + ], + }, + ]; + } +} + +export const promQueryModeller = new PromQueryModeller(); diff --git a/packages/grafana-prometheus/src/querybuilder/QueryPattern.tsx b/packages/grafana-prometheus/src/querybuilder/QueryPattern.tsx new file mode 100644 index 0000000000000..083396cda3999 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/QueryPattern.tsx @@ -0,0 +1,118 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, Card, useStyles2 } from '@grafana/ui'; + +import promqlGrammar from '../promql'; + +import { promQueryModeller } from './PromQueryModeller'; +import { RawQuery } from './shared/RawQuery'; +import { PromQueryPattern } from './types'; + +type Props = { + pattern: PromQueryPattern; + hasNewQueryOption: boolean; + hasPreviousQuery: boolean | string; + selectedPatternName: string | null; + setSelectedPatternName: (name: string | null) => void; + onPatternSelect: (pattern: PromQueryPattern, selectAsNewQuery?: boolean) => void; +}; + +export const QueryPattern = (props: Props) => { + const { pattern, onPatternSelect, hasNewQueryOption, hasPreviousQuery, selectedPatternName, setSelectedPatternName } = + props; + + const styles = useStyles2(getStyles); + const lang = { grammar: promqlGrammar, name: 'promql' }; + + return ( + <Card className={styles.card}> + <Card.Heading>{pattern.name}</Card.Heading> + <div className={styles.rawQueryContainer}> + <RawQuery + aria-label={`${pattern.name} raw query`} + query={promQueryModeller.renderQuery({ + labels: [], + operations: pattern.operations, + binaryQueries: pattern.binaryQueries, + })} + lang={lang} + className={styles.rawQuery} + /> + </div> + <Card.Actions> + {selectedPatternName !== pattern.name ? ( + <Button + size="sm" + aria-label="use this query button" + onClick={() => { + if (hasPreviousQuery) { + // If user has previous query, we need to confirm that they want to apply this query pattern + setSelectedPatternName(pattern.name); + } else { + onPatternSelect(pattern); + } + }} + > + Use this query + </Button> + ) : ( + <> + <div className={styles.spacing}> + {`If you would like to use this query, ${ + hasNewQueryOption + ? 'you can either apply this query pattern or create a new query' + : 'this query pattern will be applied to your current query' + }.`} + </div> + <Button size="sm" aria-label="back button" fill="outline" onClick={() => setSelectedPatternName(null)}> + Back + </Button> + <Button + size="sm" + aria-label="apply query starter button" + onClick={() => { + onPatternSelect(pattern); + }} + > + Apply query + </Button> + {hasNewQueryOption && ( + <Button + size="sm" + aria-label="create new query button" + onClick={() => { + onPatternSelect(pattern, true); + }} + > + Create new query + </Button> + )} + </> + )} + </Card.Actions> + </Card> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + card: css` + width: 49.5%; + display: flex; + flex-direction: column; + `, + rawQueryContainer: css` + flex-grow: 1; + `, + rawQuery: css` + background-color: ${theme.colors.background.primary}; + padding: ${theme.spacing(1)}; + margin-top: ${theme.spacing(1)}; + `, + spacing: css` + margin-bottom: ${theme.spacing(1)}; + `, + }; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.test.tsx b/packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.test.tsx new file mode 100644 index 0000000000000..a8a2b3c9e0262 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.test.tsx @@ -0,0 +1,143 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { promQueryModeller } from './PromQueryModeller'; +import { QueryPatternsModal } from './QueryPatternsModal'; +import { PromQueryPatternType } from './types'; + +// don't care about interaction tracking in our unit tests +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), +})); + +const defaultProps = { + isOpen: true, + onClose: jest.fn(), + onChange: jest.fn(), + onAddQuery: jest.fn(), + query: { + refId: 'A', + expr: 'sum(rate({job="grafana"}[$__rate_interval]))', + }, + queries: [ + { + refId: 'A', + expr: 'go_goroutines{instance="localhost:9090"}', + }, + ], +}; + +const queryPatterns = { + rateQueryPatterns: promQueryModeller + .getQueryPatterns() + .filter((pattern) => pattern.type === PromQueryPatternType.Rate), + histogramQueryPatterns: promQueryModeller + .getQueryPatterns() + .filter((pattern) => pattern.type === PromQueryPatternType.Histogram), + binaryQueryPatterns: promQueryModeller + .getQueryPatterns() + .filter((pattern) => pattern.type === PromQueryPatternType.Binary), +}; + +describe('QueryPatternsModal', () => { + it('renders the modal', () => { + render(<QueryPatternsModal {...defaultProps} />); + expect(screen.getByText('Kick start your query')).toBeInTheDocument(); + }); + it('renders collapsible elements with all query pattern types', () => { + render(<QueryPatternsModal {...defaultProps} />); + Object.values(PromQueryPatternType).forEach((pattern) => { + expect(screen.getByText(new RegExp(`${pattern} query starters`, 'i'))).toBeInTheDocument(); + }); + }); + it('can open and close query patterns section', async () => { + render(<QueryPatternsModal {...defaultProps} />); + await userEvent.click(screen.getByText('Rate query starters')); + expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Rate query starters')); + expect(screen.queryByText(queryPatterns.rateQueryPatterns[0].name)).not.toBeInTheDocument(); + }); + + it('can open and close multiple query patterns section', async () => { + render(<QueryPatternsModal {...defaultProps} />); + await userEvent.click(screen.getByText('Rate query starters')); + expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Histogram query starters')); + expect(screen.getByText(queryPatterns.histogramQueryPatterns[0].name)).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Rate query starters')); + expect(screen.queryByText(queryPatterns.rateQueryPatterns[0].name)).not.toBeInTheDocument(); + + // Histogram patterns should still be open + expect(screen.getByText(queryPatterns.histogramQueryPatterns[0].name)).toBeInTheDocument(); + }); + + it('uses pattern if there is no existing query', async () => { + render(<QueryPatternsModal {...defaultProps} query={{ expr: '', refId: 'A' }} />); + await userEvent.click(screen.getByText('Rate query starters')); + expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument(); + const firstUseQueryButton = screen.getAllByRole('button', { name: 'use this query button' })[0]; + await userEvent.click(firstUseQueryButton); + await waitFor(() => { + expect(defaultProps.onChange).toHaveBeenCalledWith({ + expr: 'sum(rate([$__rate_interval]))', + refId: 'A', + }); + }); + }); + + it('gives warning when selecting pattern if there are already existing query', async () => { + render(<QueryPatternsModal {...defaultProps} />); + await userEvent.click(screen.getByText('Rate query starters')); + expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument(); + const firstUseQueryButton = screen.getAllByRole('button', { name: 'use this query button' })[0]; + await userEvent.click(firstUseQueryButton); + + expect(screen.getByText(/you can either apply this query pattern or create a new query/)).toBeInTheDocument(); + }); + + it('can use create new query when selecting pattern if there is already existing query', async () => { + render(<QueryPatternsModal {...defaultProps} />); + await userEvent.click(screen.getByText('Rate query starters')); + expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument(); + const firstUseQueryButton = screen.getAllByRole('button', { name: 'use this query button' })[0]; + await userEvent.click(firstUseQueryButton); + const createNewQueryButton = screen.getByRole('button', { name: 'create new query button' }); + expect(createNewQueryButton).toBeInTheDocument(); + await userEvent.click(createNewQueryButton); + await waitFor(() => { + expect(defaultProps.onAddQuery).toHaveBeenCalledWith({ + expr: 'sum(rate([$__rate_interval]))', + refId: 'B', + }); + }); + }); + + it('does not show create new query option if onAddQuery function is not provided ', async () => { + render(<QueryPatternsModal {...defaultProps} onAddQuery={undefined} />); + await userEvent.click(screen.getByText('Rate query starters')); + expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument(); + const useQueryButton = screen.getAllByRole('button', { name: 'use this query button' })[0]; + await userEvent.click(useQueryButton); + expect(screen.queryByRole('button', { name: 'Create new query' })).not.toBeInTheDocument(); + expect(screen.getByText(/this query pattern will be applied to your current query/)).toBeInTheDocument(); + }); + + it('applies binary query patterns to query', async () => { + render(<QueryPatternsModal {...defaultProps} query={{ expr: '', refId: 'A' }} />); + await userEvent.click(screen.getByText('Binary query starters')); + expect(screen.getByText(queryPatterns.binaryQueryPatterns[0].name)).toBeInTheDocument(); + const firstUseQueryButton = screen.getAllByRole('button', { name: 'use this query button' })[0]; + await userEvent.click(firstUseQueryButton); + await waitFor(() => { + expect(defaultProps.onChange).toHaveBeenCalledWith({ + expr: 'sum(rate([$__rate_interval])) / sum(rate([$__rate_interval]))', + refId: 'A', + }); + }); + }); +}); diff --git a/packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.tsx b/packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.tsx new file mode 100644 index 0000000000000..f98a778e778c6 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.tsx @@ -0,0 +1,132 @@ +import { css } from '@emotion/css'; +import { capitalize } from 'lodash'; +import React, { useMemo, useState } from 'react'; + +import { CoreApp, DataQuery, GrafanaTheme2 } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { Button, Collapse, Modal, useStyles2 } from '@grafana/ui'; + +import { getNextRefIdChar } from '../gcopypaste/app/core/utils/query'; +import { PromQuery } from '../types'; + +import { promQueryModeller } from './PromQueryModeller'; +import { QueryPattern } from './QueryPattern'; +import { buildVisualQueryFromString } from './parsing'; +import { PromQueryPattern, PromQueryPatternType } from './types'; + +type Props = { + isOpen: boolean; + query: PromQuery; + queries: DataQuery[] | undefined; + app?: CoreApp; + onClose: () => void; + onChange: (query: PromQuery) => void; + onAddQuery?: (query: PromQuery) => void; +}; + +export const QueryPatternsModal = (props: Props) => { + const { isOpen, onClose, onChange, onAddQuery, query, queries, app } = props; + const [openTabs, setOpenTabs] = useState<string[]>([]); + const [selectedPatternName, setSelectedPatternName] = useState<string | null>(null); + + const styles = useStyles2(getStyles); + const hasNewQueryOption = !!onAddQuery; + const hasPreviousQuery = useMemo(() => { + const visualQuery = buildVisualQueryFromString(query.expr ?? ''); + // has anything entered in the query, metric, labels, operations, or binary queries + const hasOperations = visualQuery.query.operations.length > 0, + hasMetric = visualQuery.query.metric, + hasLabels = visualQuery.query.labels.length > 0, + hasBinaryQueries = visualQuery.query.binaryQueries ? visualQuery.query.binaryQueries.length > 0 : false; + + return hasOperations || hasMetric || hasLabels || hasBinaryQueries; + }, [query.expr]); + + const onPatternSelect = (pattern: PromQueryPattern, selectAsNewQuery = false) => { + const visualQuery = buildVisualQueryFromString(selectAsNewQuery ? '' : query.expr); + reportInteraction('grafana_prom_kickstart_your_query_selected', { + app: app ?? '', + editorMode: query.editorMode, + selectedPattern: pattern.name, + preSelectedOperationsCount: visualQuery.query.operations.length, + preSelectedLabelsCount: visualQuery.query.labels.length, + createNewQuery: hasNewQueryOption && selectAsNewQuery, + }); + + visualQuery.query.operations = pattern.operations; + visualQuery.query.binaryQueries = pattern.binaryQueries; + if (hasNewQueryOption && selectAsNewQuery) { + onAddQuery({ + ...query, + refId: getNextRefIdChar(queries ?? [query]), + expr: promQueryModeller.renderQuery(visualQuery.query), + }); + } else { + onChange({ + ...query, + expr: promQueryModeller.renderQuery(visualQuery.query), + }); + } + setSelectedPatternName(null); + onClose(); + }; + + return ( + <Modal aria-label="Kick start your query modal" isOpen={isOpen} title="Kick start your query" onDismiss={onClose}> + <div className={styles.spacing}> + Kick start your query by selecting one of these queries. You can then continue to complete your query. + </div> + {Object.values(PromQueryPatternType).map((patternType) => { + return ( + <Collapse + aria-label={`open and close ${patternType} query starter card`} + key={patternType} + label={`${capitalize(patternType)} query starters`} + isOpen={openTabs.includes(patternType)} + collapsible={true} + onToggle={() => + setOpenTabs((tabs) => + // close tab if it's already open, otherwise open it + tabs.includes(patternType) ? tabs.filter((t) => t !== patternType) : [...tabs, patternType] + ) + } + > + <div className={styles.cardsContainer}> + {promQueryModeller + .getQueryPatterns() + .filter((pattern) => pattern.type === patternType) + .map((pattern) => ( + <QueryPattern + key={pattern.name} + pattern={pattern} + hasNewQueryOption={hasNewQueryOption} + hasPreviousQuery={hasPreviousQuery} + onPatternSelect={onPatternSelect} + selectedPatternName={selectedPatternName} + setSelectedPatternName={setSelectedPatternName} + /> + ))} + </div> + </Collapse> + ); + })} + <Button aria-label="close kick start your query modal" variant="secondary" onClick={onClose}> + Close + </Button> + </Modal> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + cardsContainer: css` + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + `, + spacing: css` + margin-bottom: ${theme.spacing(1)}; + `, + }; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/aggregations.ts b/packages/grafana-prometheus/src/querybuilder/aggregations.ts new file mode 100644 index 0000000000000..18a2f72b68778 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/aggregations.ts @@ -0,0 +1,62 @@ +import { + createAggregationOperation, + createAggregationOperationWithParam, + getPromOperationDisplayName, + getRangeVectorParamDef, +} from './operationUtils'; +import { addOperationWithRangeVector } from './operations'; +import { QueryBuilderOperation, QueryBuilderOperationDef } from './shared/types'; +import { PromOperationId, PromVisualQueryOperationCategory } from './types'; + +export function getAggregationOperations(): QueryBuilderOperationDef[] { + return [ + ...createAggregationOperation(PromOperationId.Sum), + ...createAggregationOperation(PromOperationId.Avg), + ...createAggregationOperation(PromOperationId.Min), + ...createAggregationOperation(PromOperationId.Max), + ...createAggregationOperation(PromOperationId.Count), + ...createAggregationOperationWithParam(PromOperationId.TopK, { + params: [{ name: 'K-value', type: 'number' }], + defaultParams: [5], + }), + ...createAggregationOperationWithParam(PromOperationId.BottomK, { + params: [{ name: 'K-value', type: 'number' }], + defaultParams: [5], + }), + ...createAggregationOperationWithParam(PromOperationId.CountValues, { + params: [{ name: 'Identifier', type: 'string' }], + defaultParams: ['count'], + }), + createAggregationOverTime(PromOperationId.SumOverTime), + createAggregationOverTime(PromOperationId.AvgOverTime), + createAggregationOverTime(PromOperationId.MinOverTime), + createAggregationOverTime(PromOperationId.MaxOverTime), + createAggregationOverTime(PromOperationId.CountOverTime), + createAggregationOverTime(PromOperationId.LastOverTime), + createAggregationOverTime(PromOperationId.PresentOverTime), + createAggregationOverTime(PromOperationId.AbsentOverTime), + createAggregationOverTime(PromOperationId.StddevOverTime), + ]; +} + +function createAggregationOverTime(name: string): QueryBuilderOperationDef { + return { + id: name, + name: getPromOperationDisplayName(name), + params: [getRangeVectorParamDef()], + defaultParams: ['$__interval'], + alternativesKey: 'overtime function', + category: PromVisualQueryOperationCategory.RangeFunctions, + renderer: operationWithRangeVectorRenderer, + addOperationHandler: addOperationWithRangeVector, + }; +} + +function operationWithRangeVectorRenderer( + model: QueryBuilderOperation, + def: QueryBuilderOperationDef, + innerExpr: string +) { + let rangeVector = (model.params ?? [])[0] ?? '$__interval'; + return `${def.id}(${innerExpr}[${rangeVector}])`; +} diff --git a/packages/grafana-prometheus/src/querybuilder/binaryScalarOperations.ts b/packages/grafana-prometheus/src/querybuilder/binaryScalarOperations.ts new file mode 100644 index 0000000000000..e596fd2033d87 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/binaryScalarOperations.ts @@ -0,0 +1,120 @@ +import { defaultAddOperationHandler } from './operationUtils'; +import { QueryBuilderOperation, QueryBuilderOperationDef, QueryBuilderOperationParamDef } from './shared/types'; +import { PromOperationId, PromVisualQueryOperationCategory } from './types'; + +export const binaryScalarDefs = [ + { + id: PromOperationId.Addition, + name: 'Add scalar', + sign: '+', + }, + { + id: PromOperationId.Subtraction, + name: 'Subtract scalar', + sign: '-', + }, + { + id: PromOperationId.MultiplyBy, + name: 'Multiply by scalar', + sign: '*', + }, + { + id: PromOperationId.DivideBy, + name: 'Divide by scalar', + sign: '/', + }, + { + id: PromOperationId.Modulo, + name: 'Modulo by scalar', + sign: '%', + }, + { + id: PromOperationId.Exponent, + name: 'Exponent', + sign: '^', + }, + { + id: PromOperationId.EqualTo, + name: 'Equal to', + sign: '==', + comparison: true, + }, + { + id: PromOperationId.NotEqualTo, + name: 'Not equal to', + sign: '!=', + comparison: true, + }, + { + id: PromOperationId.GreaterThan, + name: 'Greater than', + sign: '>', + comparison: true, + }, + { + id: PromOperationId.LessThan, + name: 'Less than', + sign: '<', + comparison: true, + }, + { + id: PromOperationId.GreaterOrEqual, + name: 'Greater or equal to', + sign: '>=', + comparison: true, + }, + { + id: PromOperationId.LessOrEqual, + name: 'Less or equal to', + sign: '<=', + comparison: true, + }, +]; + +export const binaryScalarOperatorToOperatorName = binaryScalarDefs.reduce< + Record<string, { id: string; comparison?: boolean }> +>((acc, def) => { + acc[def.sign] = { + id: def.id, + comparison: def.comparison, + }; + return acc; +}, {}); + +// Not sure about this one. It could also be a more generic 'Simple math operation' where user specifies +// both the operator and the operand in a single input +export const binaryScalarOperations: QueryBuilderOperationDef[] = binaryScalarDefs.map((opDef) => { + const params: QueryBuilderOperationParamDef[] = [{ name: 'Value', type: 'number' }]; + const defaultParams: any[] = [2]; + if (opDef.comparison) { + params.push({ + name: 'Bool', + type: 'boolean', + description: 'If checked comparison will return 0 or 1 for the value rather than filtering.', + }); + defaultParams.push(false); + } + + return { + id: opDef.id, + name: opDef.name, + params, + defaultParams, + alternativesKey: 'binary scalar operations', + category: PromVisualQueryOperationCategory.BinaryOps, + renderer: getSimpleBinaryRenderer(opDef.sign), + addOperationHandler: defaultAddOperationHandler, + }; +}); + +function getSimpleBinaryRenderer(operator: string) { + return function binaryRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + let param = model.params[0]; + let bool = ''; + if (model.params.length === 2) { + bool = model.params[1] ? ' bool' : ''; + } + + return `${innerExpr} ${operator}${bool} ${param}`; + }; +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/LabelFilterItem.tsx b/packages/grafana-prometheus/src/querybuilder/components/LabelFilterItem.tsx new file mode 100644 index 0000000000000..c1a28d8f96ff1 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/LabelFilterItem.tsx @@ -0,0 +1,194 @@ +import debounce from 'debounce-promise'; +import React, { useState } from 'react'; + +import { SelectableValue, toOption } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { AccessoryButton, InputGroup } from '@grafana/experimental'; +import { AsyncSelect, Select } from '@grafana/ui'; + +import { truncateResult } from '../../language_utils'; +import { QueryBuilderLabelFilter } from '../shared/types'; + +export interface LabelFilterItemProps { + defaultOp: string; + item: Partial<QueryBuilderLabelFilter>; + onChange: (value: QueryBuilderLabelFilter) => void; + onGetLabelNames: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<SelectableValue[]>; + onGetLabelValues: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<SelectableValue[]>; + onDelete: () => void; + invalidLabel?: boolean; + invalidValue?: boolean; + getLabelValuesAutofillSuggestions: (query: string, labelName?: string) => Promise<SelectableValue[]>; + debounceDuration: number; +} + +export function LabelFilterItem({ + item, + defaultOp, + onChange, + onDelete, + onGetLabelNames, + onGetLabelValues, + invalidLabel, + invalidValue, + getLabelValuesAutofillSuggestions, + debounceDuration, +}: LabelFilterItemProps) { + const [state, setState] = useState<{ + labelNames?: SelectableValue[]; + labelValues?: SelectableValue[]; + isLoadingLabelNames?: boolean; + isLoadingLabelValues?: boolean; + }>({}); + // there's a bug in react-select where the menu doesn't recalculate its position when the options are loaded asynchronously + // see https://github.com/grafana/grafana/issues/63558 + // instead, we explicitly control the menu visibility and prevent showing it until the options have fully loaded + const [labelNamesMenuOpen, setLabelNamesMenuOpen] = useState(false); + const [labelValuesMenuOpen, setLabelValuesMenuOpen] = useState(false); + + const isMultiSelect = (operator = item.op) => { + return operators.find((op) => op.label === operator)?.isMultiValue; + }; + + const getSelectOptionsFromString = (item?: string): string[] => { + if (item) { + const regExp = /\(([^)]+)\)/; + const matches = item?.match(regExp); + + if (matches && matches[0].indexOf('|') > 0) { + return [item]; + } + + if (item.indexOf('|') > 0) { + return item.split('|'); + } + return [item]; + } + return []; + }; + + const labelValueSearch = debounce( + (query: string) => getLabelValuesAutofillSuggestions(query, item.label), + debounceDuration + ); + + const itemValue = item?.value ?? ''; + + return ( + <div key={itemValue} data-testid="prometheus-dimensions-filter-item"> + <InputGroup> + {/* Label name select, loads all values at once */} + <Select + placeholder="Select label" + data-testid={selectors.components.QueryBuilder.labelSelect} + inputId="prometheus-dimensions-filter-item-key" + width="auto" + value={item.label ? toOption(item.label) : null} + allowCustomValue + onOpenMenu={async () => { + setState({ isLoadingLabelNames: true }); + const labelNames = await onGetLabelNames(item); + setLabelNamesMenuOpen(true); + setState({ labelNames, isLoadingLabelNames: undefined }); + }} + onCloseMenu={() => { + setLabelNamesMenuOpen(false); + }} + isOpen={labelNamesMenuOpen} + isLoading={state.isLoadingLabelNames ?? false} + options={state.labelNames} + onChange={(change) => { + if (change.label) { + onChange({ + ...item, + op: item.op ?? defaultOp, + label: change.label, + // eslint-ignore + } as QueryBuilderLabelFilter); + } + }} + invalid={invalidLabel} + /> + + {/* Operator select i.e. = =~ != !~ */} + <Select + data-testid={selectors.components.QueryBuilder.matchOperatorSelect} + className="query-segment-operator" + value={toOption(item.op ?? defaultOp)} + options={operators} + width="auto" + onChange={(change) => { + if (change.value != null) { + onChange({ + ...item, + op: change.value, + value: isMultiSelect(change.value) ? item.value : getSelectOptionsFromString(item?.value)[0], + // eslint-ignore + } as QueryBuilderLabelFilter); + } + }} + /> + + {/* Label value async select: autocomplete calls prometheus API */} + <AsyncSelect + placeholder="Select value" + data-testid={selectors.components.QueryBuilder.valueSelect} + inputId="prometheus-dimensions-filter-item-value" + width="auto" + value={ + isMultiSelect() + ? getSelectOptionsFromString(itemValue).map(toOption) + : getSelectOptionsFromString(itemValue).map(toOption)[0] + } + allowCustomValue + onOpenMenu={async () => { + setState({ isLoadingLabelValues: true }); + const labelValues = await onGetLabelValues(item); + truncateResult(labelValues); + setLabelValuesMenuOpen(true); + setState({ + ...state, + labelValues, + isLoadingLabelValues: undefined, + }); + }} + onCloseMenu={() => { + setLabelValuesMenuOpen(false); + }} + isOpen={labelValuesMenuOpen} + defaultOptions={state.labelValues} + isMulti={isMultiSelect()} + isLoading={state.isLoadingLabelValues} + loadOptions={labelValueSearch} + onChange={(change) => { + if (change.value) { + onChange({ + ...item, + value: change.value, + op: item.op ?? defaultOp, + // eslint-ignore + } as QueryBuilderLabelFilter); + } else { + const changes = change + .map((change: { label?: string }) => { + return change.label; + }) + .join('|'); + // eslint-ignore + onChange({ ...item, value: changes, op: item.op ?? defaultOp } as QueryBuilderLabelFilter); + } + }} + invalid={invalidValue} + /> + <AccessoryButton aria-label={`remove-${item.label}`} icon="times" variant="secondary" onClick={onDelete} /> + </InputGroup> + </div> + ); +} + +const operators = [ + { label: '=', value: '=', isMultiValue: false }, + { label: '!=', value: '!=', isMultiValue: false }, + { label: '=~', value: '=~', isMultiValue: true }, + { label: '!~', value: '!~', isMultiValue: true }, +]; diff --git a/packages/grafana-prometheus/src/querybuilder/components/LabelFilters.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/LabelFilters.test.tsx new file mode 100644 index 0000000000000..fcd1b06b72a20 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/LabelFilters.test.tsx @@ -0,0 +1,163 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { ComponentProps } from 'react'; + +import { selectOptionInTest } from '../../gcopypaste/test/helpers/selectOptionInTest'; +import { getLabelSelects } from '../testUtils'; + +import { LabelFilters, MISSING_LABEL_FILTER_ERROR_MESSAGE, LabelFiltersProps } from './LabelFilters'; + +describe('LabelFilters', () => { + it('renders empty input without labels', async () => { + setup(); + expect(screen.getAllByText('Select label')).toHaveLength(1); + expect(screen.getAllByText('Select value')).toHaveLength(1); + expect(screen.getByText(/=/)).toBeInTheDocument(); + expect(getAddButton()).toBeInTheDocument(); + }); + + it('renders multiple labels', async () => { + setup({ + labelsFilters: [ + { label: 'foo', op: '=', value: 'bar' }, + { label: 'baz', op: '!=', value: 'qux' }, + { label: 'quux', op: '=~', value: 'quuz' }, + ], + }); + expect(screen.getByText(/foo/)).toBeInTheDocument(); + expect(screen.getByText(/bar/)).toBeInTheDocument(); + expect(screen.getByText(/baz/)).toBeInTheDocument(); + expect(screen.getByText(/qux/)).toBeInTheDocument(); + expect(screen.getByText(/quux/)).toBeInTheDocument(); + expect(screen.getByText(/quuz/)).toBeInTheDocument(); + expect(getAddButton()).toBeInTheDocument(); + }); + + it('renders multiple values for regex selectors', async () => { + setup({ + labelsFilters: [ + { label: 'bar', op: '!~', value: 'baz|bat|bau' }, + { label: 'foo', op: '!~', value: 'fop|for|fos' }, + ], + }); + expect(screen.getByText(/bar/)).toBeInTheDocument(); + expect(screen.getByText(/baz/)).toBeInTheDocument(); + expect(screen.getByText(/bat/)).toBeInTheDocument(); + expect(screen.getByText(/bau/)).toBeInTheDocument(); + expect(screen.getByText(/foo/)).toBeInTheDocument(); + expect(screen.getByText(/for/)).toBeInTheDocument(); + expect(screen.getByText(/fos/)).toBeInTheDocument(); + expect(getAddButton()).toBeInTheDocument(); + }); + + it('adds new label', async () => { + const { onChange } = setup({ labelsFilters: [{ label: 'foo', op: '=', value: 'bar' }] }); + await userEvent.click(getAddButton()); + expect(screen.getAllByText('Select label')).toHaveLength(1); + expect(screen.getAllByText('Select value')).toHaveLength(1); + const { name, value } = getLabelSelects(1); + await selectOptionInTest(name, 'baz'); + await selectOptionInTest(value, 'qux'); + expect(onChange).toBeCalledWith([ + { label: 'foo', op: '=', value: 'bar' }, + { label: 'baz', op: '=', value: 'qux' }, + ]); + }); + + it('removes label', async () => { + const { onChange } = setup({ labelsFilters: [{ label: 'foo', op: '=', value: 'bar' }] }); + await userEvent.click(screen.getByLabelText(/remove-foo/)); + expect(onChange).toBeCalledWith([]); + }); + + it('removes label but preserves a label with a value of empty string', async () => { + const { onChange } = setup({ + labelsFilters: [ + { label: 'lab', op: '=', value: 'bel' }, + { label: 'foo', op: '=', value: 'bar' }, + { label: 'le', op: '=', value: '' }, + ], + }); + await userEvent.click(screen.getByLabelText(/remove-foo/)); + expect(onChange).toBeCalledWith([ + { label: 'lab', op: '=', value: 'bel' }, + { label: 'le', op: '=', value: '' }, + ]); + expect(screen.queryByText('bar')).toBeNull(); + }); + + it('renders empty input when labels are deleted from outside ', async () => { + const { rerender } = setup({ labelsFilters: [{ label: 'foo', op: '=', value: 'bar' }] }); + expect(screen.getByText(/foo/)).toBeInTheDocument(); + expect(screen.getByText(/bar/)).toBeInTheDocument(); + rerender( + <LabelFilters + onChange={jest.fn()} + onGetLabelNames={jest.fn()} + getLabelValuesAutofillSuggestions={jest.fn()} + onGetLabelValues={jest.fn()} + labelsFilters={[]} + debounceDuration={300} + /> + ); + expect(screen.getAllByText('Select label')).toHaveLength(1); + expect(screen.getAllByText('Select value')).toHaveLength(1); + expect(screen.getByText(/=/)).toBeInTheDocument(); + expect(getAddButton()).toBeInTheDocument(); + }); + + it('does split regex in the middle of a label value when the value contains the char |', () => { + setup({ labelsFilters: [{ label: 'foo', op: '=~', value: 'boop|par' }] }); + + expect(screen.getByText('boop')).toBeInTheDocument(); + expect(screen.getByText('par')).toBeInTheDocument(); + }); + + it('does not split regex in between parentheses inside of a label value that contains the char |', () => { + setup({ labelsFilters: [{ label: 'foo', op: '=~', value: '(b|p)ar' }] }); + + expect(screen.getByText('(b|p)ar')).toBeInTheDocument(); + }); + + it('shows error when filter with empty strings and label filter is required', async () => { + setup({ labelsFilters: [{ label: '', op: '=', value: '' }], labelFilterRequired: true }); + expect(screen.getByText(MISSING_LABEL_FILTER_ERROR_MESSAGE)).toBeInTheDocument(); + }); + + it('shows error when no filter and label filter is required', async () => { + setup({ labelsFilters: [], labelFilterRequired: true }); + expect(screen.getByText(MISSING_LABEL_FILTER_ERROR_MESSAGE)).toBeInTheDocument(); + }); +}); + +function setup(propOverrides?: Partial<ComponentProps<typeof LabelFilters>>) { + const defaultProps: LabelFiltersProps = { + onChange: jest.fn(), + getLabelValuesAutofillSuggestions: async (query: string, labelName?: string) => [ + { label: 'bar', value: 'bar' }, + { label: 'qux', value: 'qux' }, + { label: 'quux', value: 'quux' }, + ], + onGetLabelNames: async () => [ + { label: 'foo', value: 'foo' }, + { label: 'bar', value: 'bar' }, + { label: 'baz', value: 'baz' }, + ], + onGetLabelValues: async () => [ + { label: 'bar', value: 'bar' }, + { label: 'qux', value: 'qux' }, + { label: 'quux', value: 'quux' }, + ], + debounceDuration: 300, + labelsFilters: [], + }; + + const props = { ...defaultProps, ...propOverrides }; + + const { rerender } = render(<LabelFilters {...props} />); + return { ...props, rerender }; +} + +function getAddButton() { + return screen.getByLabelText(/Add/); +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/LabelFilters.tsx b/packages/grafana-prometheus/src/querybuilder/components/LabelFilters.tsx new file mode 100644 index 0000000000000..09f5fd1d82259 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/LabelFilters.tsx @@ -0,0 +1,114 @@ +import { css, cx } from '@emotion/css'; +import { isEqual } from 'lodash'; +import React, { useEffect, useState } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { EditorField, EditorFieldGroup, EditorList } from '@grafana/experimental'; +import { InlineFieldRow, InlineLabel } from '@grafana/ui'; + +import { QueryBuilderLabelFilter } from '../shared/types'; + +import { LabelFilterItem } from './LabelFilterItem'; + +export const MISSING_LABEL_FILTER_ERROR_MESSAGE = 'Select at least 1 label filter (label and value)'; + +export interface LabelFiltersProps { + labelsFilters: QueryBuilderLabelFilter[]; + onChange: (labelFilters: Array<Partial<QueryBuilderLabelFilter>>) => void; + onGetLabelNames: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<SelectableValue[]>; + onGetLabelValues: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<SelectableValue[]>; + /** If set to true, component will show error message until at least 1 filter is selected */ + labelFilterRequired?: boolean; + getLabelValuesAutofillSuggestions: (query: string, labelName?: string) => Promise<SelectableValue[]>; + debounceDuration: number; + variableEditor?: boolean; +} + +export function LabelFilters({ + labelsFilters, + onChange, + onGetLabelNames, + onGetLabelValues, + labelFilterRequired, + getLabelValuesAutofillSuggestions, + debounceDuration, + variableEditor, +}: LabelFiltersProps) { + const defaultOp = '='; + const [items, setItems] = useState<Array<Partial<QueryBuilderLabelFilter>>>([{ op: defaultOp }]); + + useEffect(() => { + if (labelsFilters.length > 0) { + setItems(labelsFilters); + } else { + setItems([{ op: defaultOp }]); + } + }, [labelsFilters]); + + const onLabelsChange = (newItems: Array<Partial<QueryBuilderLabelFilter>>) => { + setItems(newItems); + + // Extract full label filters with both label & value + const newLabels = newItems.filter((x) => x.label != null && x.value != null); + if (!isEqual(newLabels, labelsFilters)) { + onChange(newLabels); + } + }; + + const hasLabelFilter = items.some((item) => item.label && item.value); + + const editorList = () => { + return ( + <EditorList + items={items} + onChange={onLabelsChange} + renderItem={(item: Partial<QueryBuilderLabelFilter>, onChangeItem, onDelete) => ( + <LabelFilterItem + debounceDuration={debounceDuration} + item={item} + defaultOp={defaultOp} + onChange={onChangeItem} + onDelete={onDelete} + onGetLabelNames={onGetLabelNames} + onGetLabelValues={onGetLabelValues} + invalidLabel={labelFilterRequired && !item.label} + invalidValue={labelFilterRequired && !item.value} + getLabelValuesAutofillSuggestions={getLabelValuesAutofillSuggestions} + /> + )} + /> + ); + }; + + return ( + <> + {variableEditor ? ( + <InlineFieldRow> + <div + className={cx(css` + display: flex; + `)} + > + <InlineLabel + width={20} + tooltip={<div>Optional: used to filter the metric select for this query type.</div>} + > + Label filters + </InlineLabel> + {editorList()} + </div> + </InlineFieldRow> + ) : ( + <EditorFieldGroup> + <EditorField + label="Label filters" + error={MISSING_LABEL_FILTER_ERROR_MESSAGE} + invalid={labelFilterRequired && !hasLabelFilter} + > + {editorList()} + </EditorField> + </EditorFieldGroup> + )} + </> + ); +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/LabelParamEditor.tsx b/packages/grafana-prometheus/src/querybuilder/components/LabelParamEditor.tsx new file mode 100644 index 0000000000000..6cf4e9a9b74ca --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/LabelParamEditor.tsx @@ -0,0 +1,60 @@ +import React, { useState } from 'react'; + +import { DataSourceApi, SelectableValue, toOption } from '@grafana/data'; +import { Select } from '@grafana/ui'; + +import { promQueryModeller } from '../PromQueryModeller'; +import { getOperationParamId } from '../operationUtils'; +import { QueryBuilderLabelFilter, QueryBuilderOperationParamEditorProps } from '../shared/types'; +import { PromVisualQuery } from '../types'; + +export function LabelParamEditor({ + onChange, + index, + operationId, + value, + query, + datasource, +}: QueryBuilderOperationParamEditorProps) { + const [state, setState] = useState<{ + options?: SelectableValue[]; + isLoading?: boolean; + }>({}); + + return ( + <Select + inputId={getOperationParamId(operationId, index)} + autoFocus={value === '' ? true : undefined} + openMenuOnFocus + onOpenMenu={async () => { + setState({ isLoading: true }); + const options = await loadGroupByLabels(query, datasource); + setState({ options, isLoading: undefined }); + }} + isLoading={state.isLoading} + allowCustomValue + noOptionsMessage="No labels found" + loadingMessage="Loading labels" + options={state.options} + value={toOption(value as string)} + onChange={(value) => onChange(index, value.value!)} + /> + ); +} + +async function loadGroupByLabels(query: PromVisualQuery, datasource: DataSourceApi): Promise<SelectableValue[]> { + let labels: QueryBuilderLabelFilter[] = query.labels; + + // This function is used by both Prometheus and Loki and this the only difference. + if (datasource.type === 'prometheus') { + labels = [{ label: '__name__', op: '=', value: query.metric }, ...query.labels]; + } + + const expr = promQueryModeller.renderLabels(labels); + const result = await datasource.languageProvider.fetchLabelsWithMatch(expr); + + return Object.keys(result).map((x) => ({ + label: x, + value: x, + })); +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/MetricSelect.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/MetricSelect.test.tsx new file mode 100644 index 0000000000000..a05617688379d --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/MetricSelect.test.tsx @@ -0,0 +1,191 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { DataSourceInstanceSettings, MetricFindValue } from '@grafana/data'; + +import { PrometheusDatasource } from '../../datasource'; +import { PromOptions } from '../../types'; + +import { + formatPrometheusLabelFilters, + formatPrometheusLabelFiltersToString, + MetricSelect, + MetricSelectProps, +} from './MetricSelect'; + +const instanceSettings = { + url: 'proxied', + id: 1, + user: 'test', + password: 'mupp', + jsonData: { httpMethod: 'GET' }, +} as unknown as DataSourceInstanceSettings<PromOptions>; + +const dataSourceMock = new PrometheusDatasource(instanceSettings); +const mockValues = [{ label: 'random_metric' }, { label: 'unique_metric' }, { label: 'more_unique_metric' }]; + +// Mock metricFindQuery which will call backend API +//@ts-ignore +dataSourceMock.metricFindQuery = jest.fn((query: string) => { + // Use the label values regex to get the values inside the label_values function call + const labelValuesRegex = /^label_values\((?:(.+),\s*)?([a-zA-Z_][a-zA-Z0-9_]*)\)\s*$/; + const queryValueArray = query.match(labelValuesRegex) as RegExpMatchArray; + const queryValueRaw = queryValueArray[1] as string; + + // Remove the wrapping regex + const queryValue = queryValueRaw.substring(queryValueRaw.indexOf('".*') + 3, queryValueRaw.indexOf('.*"')); + + // Run the regex that we'd pass into prometheus API against the strings in the test + return Promise.resolve( + mockValues + .filter((value) => value.label.match(queryValue)) + .map((result) => { + return { + text: result.label, + }; + }) as MetricFindValue[] + ); +}); + +const props: MetricSelectProps = { + labelsFilters: [], + datasource: dataSourceMock, + query: { + metric: '', + labels: [], + operations: [], + }, + onChange: jest.fn(), + onGetMetrics: jest.fn().mockResolvedValue(mockValues), + metricLookupDisabled: false, +}; + +describe('MetricSelect', () => { + it('shows all metric options', async () => { + render(<MetricSelect {...props} />); + await openMetricSelect(); + await waitFor(() => expect(screen.getByText('random_metric')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('unique_metric')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('more_unique_metric')).toBeInTheDocument()); + await waitFor(() => expect(screen.getAllByLabelText('Select option')).toHaveLength(3)); + }); + + it('truncates list of metrics to 1000', async () => { + const manyMockValues = [...Array(1001).keys()].map((idx: number) => { + return { label: 'random_metric' + idx }; + }); + + props.onGetMetrics = jest.fn().mockResolvedValue(manyMockValues); + + render(<MetricSelect {...props} />); + await openMetricSelect(); + await waitFor(() => expect(screen.getAllByLabelText('Select option')).toHaveLength(1000)); + }); + + it('shows option to set custom value when typing', async () => { + render(<MetricSelect {...props} />); + await openMetricSelect(); + const input = screen.getByRole('combobox'); + await userEvent.type(input, 'custom value'); + await waitFor(() => expect(screen.getByText('custom value')).toBeInTheDocument()); + }); + + it('shows searched options when typing', async () => { + render(<MetricSelect {...props} />); + await openMetricSelect(); + const input = screen.getByRole('combobox'); + await userEvent.type(input, 'unique'); + await waitFor(() => expect(screen.getAllByLabelText('Select option')).toHaveLength(3)); + }); + + it('searches on split words', async () => { + render(<MetricSelect {...props} />); + await openMetricSelect(); + const input = screen.getByRole('combobox'); + await userEvent.type(input, 'more unique'); + await waitFor(() => expect(screen.getAllByLabelText('Select option')).toHaveLength(2)); + }); + + it('searches on multiple split words', async () => { + render(<MetricSelect {...props} />); + await openMetricSelect(); + const input = screen.getByRole('combobox'); + await userEvent.type(input, 'more unique metric'); + await waitFor(() => expect(screen.getAllByLabelText('Select option')).toHaveLength(2)); + }); + + it('highlights matching string', async () => { + render(<MetricSelect {...props} />); + await openMetricSelect(); + const input = screen.getByRole('combobox'); + await userEvent.type(input, 'more'); + await waitFor(() => expect(document.querySelectorAll('mark')).toHaveLength(1)); + }); + + it('highlights multiple matching strings in 1 input row', async () => { + render(<MetricSelect {...props} />); + await openMetricSelect(); + const input = screen.getByRole('combobox'); + await userEvent.type(input, 'more metric'); + await waitFor(() => expect(document.querySelectorAll('mark')).toHaveLength(2)); + }); + + it('highlights multiple matching strings in multiple input rows', async () => { + render(<MetricSelect {...props} />); + await openMetricSelect(); + const input = screen.getByRole('combobox'); + await userEvent.type(input, 'unique metric'); + await waitFor(() => expect(document.querySelectorAll('mark')).toHaveLength(4)); + }); + + it('does not highlight matching string in create option', async () => { + render(<MetricSelect {...props} />); + await openMetricSelect(); + const input = screen.getByRole('combobox'); + await userEvent.type(input, 'new'); + await waitFor(() => expect(document.querySelector('mark')).not.toBeInTheDocument()); + }); + + it('label filters properly join', () => { + const query = formatPrometheusLabelFilters([ + { + value: 'value', + label: 'label', + op: '=', + }, + { + value: 'value2', + label: 'label2', + op: '=', + }, + ]); + query.forEach((label) => { + expect(label.includes(',', 0)); + }); + }); + it('label filter creation', () => { + const labels = [ + { + value: 'value', + label: 'label', + op: '=', + }, + { + value: 'value2', + label: 'label2', + op: '=', + }, + ]; + + const queryString = formatPrometheusLabelFiltersToString('query', labels); + queryString.split(',').forEach((queryChunk) => { + expect(queryChunk.length).toBeGreaterThan(1); // must be longer then ',' + }); + }); +}); + +async function openMetricSelect() { + const select = screen.getByText('Select metric').parentElement!; + await userEvent.click(select); +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/MetricSelect.tsx b/packages/grafana-prometheus/src/querybuilder/components/MetricSelect.tsx new file mode 100644 index 0000000000000..baa54b5bd6489 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/MetricSelect.tsx @@ -0,0 +1,423 @@ +import { css } from '@emotion/css'; +import debounce from 'debounce-promise'; +import React, { RefCallback, useCallback, useState } from 'react'; +import Highlighter from 'react-highlight-words'; + +import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { EditorField, EditorFieldGroup } from '@grafana/experimental'; +import { config } from '@grafana/runtime'; +import { + AsyncSelect, + Button, + CustomScrollbar, + FormatOptionLabelMeta, + getSelectStyles, + Icon, + InlineField, + InlineFieldRow, + useStyles2, + useTheme2, +} from '@grafana/ui'; + +import { PrometheusDatasource } from '../../datasource'; +import { SelectMenuOptions } from '../../gcopypaste/packages/grafana-ui/src/components/Select/SelectBase'; +import { truncateResult } from '../../language_utils'; +import { regexifyLabelValuesQueryString } from '../parsingUtils'; +import { QueryBuilderLabelFilter } from '../shared/types'; +import { PromVisualQuery } from '../types'; + +import { MetricsModal } from './metrics-modal/MetricsModal'; +import { tracking } from './metrics-modal/state/helpers'; + +// We are matching words split with space +const splitSeparator = ' '; + +export interface MetricSelectProps { + metricLookupDisabled: boolean; + query: PromVisualQuery; + onChange: (query: PromVisualQuery) => void; + onGetMetrics: () => Promise<SelectableValue[]>; + datasource: PrometheusDatasource; + labelsFilters: QueryBuilderLabelFilter[]; + onBlur?: () => void; + variableEditor?: boolean; +} + +export const PROMETHEUS_QUERY_BUILDER_MAX_RESULTS = 1000; + +export function MetricSelect({ + datasource, + query, + onChange, + onGetMetrics, + labelsFilters, + metricLookupDisabled, + onBlur, + variableEditor, +}: MetricSelectProps) { + const styles = useStyles2(getStyles); + const [state, setState] = useState<{ + metrics?: Array<SelectableValue<any>>; + isLoading?: boolean; + metricsModalOpen?: boolean; + initialMetrics?: string[]; + resultsTruncated?: boolean; + }>({}); + + const prometheusMetricEncyclopedia = config.featureToggles.prometheusMetricEncyclopedia; + + const metricsModalOption: SelectableValue[] = [ + { + value: 'BrowseMetrics', + label: 'Metrics explorer', + description: 'Browse and filter all metrics and metadata with a fuzzy search', + }, + ]; + + const customFilterOption = useCallback( + (option: SelectableValue<any>, searchQuery: string) => { + const label = option.label ?? option.value; + if (!label) { + return false; + } + + // custom value is not a string label but a react node + if (!label.toLowerCase) { + return true; + } + + const searchWords = searchQuery.split(splitSeparator); + + return searchWords.reduce((acc, cur) => { + const matcheSearch = label.toLowerCase().includes(cur.toLowerCase()); + + let browseOption = false; + if (prometheusMetricEncyclopedia) { + browseOption = label === 'Metrics explorer'; + } + + return acc && (matcheSearch || browseOption); + }, true); + }, + [prometheusMetricEncyclopedia] + ); + + const formatOptionLabel = useCallback( + (option: SelectableValue<any>, meta: FormatOptionLabelMeta<any>) => { + // For newly created custom value we don't want to add highlight + if (option['__isNew__']) { + return option.label; + } + // only matches on input, does not match on regex + // look into matching for regex input + return ( + <Highlighter + searchWords={meta.inputValue.split(splitSeparator)} + textToHighlight={option.label ?? ''} + highlightClassName={styles.highlight} + /> + ); + }, + [styles.highlight] + ); + + /** + * Reformat the query string and label filters to return all valid results for current query editor state + */ + const formatKeyValueStringsForLabelValuesQuery = ( + query: string, + labelsFilters?: QueryBuilderLabelFilter[] + ): string => { + const queryString = regexifyLabelValuesQueryString(query); + + return formatPrometheusLabelFiltersToString(queryString, labelsFilters); + }; + + /** + * Gets label_values response from prometheus API for current autocomplete query string and any existing labels filters + */ + const getMetricLabels = (query: string) => { + // Since some customers can have millions of metrics, whenever the user changes the autocomplete text we want to call the backend and request all metrics that match the current query string + const results = datasource.metricFindQuery(formatKeyValueStringsForLabelValuesQuery(query, labelsFilters)); + return results.then((results) => { + const resultsLength = results.length; + truncateResult(results); + + if (resultsLength > results.length) { + setState({ ...state, resultsTruncated: true }); + } else { + setState({ ...state, resultsTruncated: false }); + } + + const resultsOptions = results.map((result) => { + return { + label: result.text, + value: result.text, + }; + }); + + if (prometheusMetricEncyclopedia) { + return [...metricsModalOption, ...resultsOptions]; + } else { + return resultsOptions; + } + }); + }; + + // When metric and label lookup is disabled we won't request labels + const metricLookupDisabledSearch = () => Promise.resolve([]); + + const debouncedSearch = debounce( + (query: string) => getMetricLabels(query), + datasource.getDebounceTimeInMilliseconds() + ); + + // No type found for the common select props so typing as any + // https://github.com/grafana/grafana/blob/main/packages/grafana-ui/src/components/Select/SelectBase.tsx/#L212-L263 + // eslint-disable-next-line + const CustomOption = (props: any) => { + const option = props.data; + + if (option.value === 'BrowseMetrics') { + const isFocused = props.isFocused ? styles.focus : ''; + + return ( + // TODO: fix keyboard a11y + // eslint-disable-next-line jsx-a11y/no-static-element-interactions + <div + {...props.innerProps} + ref={props.innerRef} + className={`${styles.customOptionWidth} metric-encyclopedia-open`} + aria-label="Select option" + onKeyDown={(e) => { + // if there is no metric and the m.e. is enabled, open the modal + if (e.code === 'Enter') { + setState({ ...state, metricsModalOpen: true }); + } + }} + > + { + <div className={`${styles.customOption} ${isFocused} metric-encyclopedia-open`}> + <div> + <div className="metric-encyclopedia-open">{option.label}</div> + <div className={`${styles.customOptionDesc} metric-encyclopedia-open`}>{option.description}</div> + </div> + <Button + fill="text" + size="sm" + variant="secondary" + onClick={() => setState({ ...state, metricsModalOpen: true })} + className="metric-encyclopedia-open" + > + Open + <Icon name="arrow-right" /> + </Button> + </div> + } + </div> + ); + } + + return SelectMenuOptions(props); + }; + + interface SelectMenuProps { + maxHeight: number; + innerRef: RefCallback<HTMLDivElement>; + innerProps: {}; + } + + const CustomMenu = ({ children, maxHeight, innerRef, innerProps }: React.PropsWithChildren<SelectMenuProps>) => { + const theme = useTheme2(); + const stylesMenu = getSelectStyles(theme); + + // Show the results trucated warning only if the options are loaded and the results are truncated + // The children are a react node(options loading node) or an array(not a valid element) + const optionsLoaded = !React.isValidElement(children) && state.resultsTruncated; + + return ( + <div + {...innerProps} + className={`${stylesMenu.menu} ${styles.customMenuContainer}`} + style={{ maxHeight }} + aria-label="Select options menu" + > + <CustomScrollbar scrollRefCallback={innerRef} autoHide={false} autoHeightMax="inherit" hideHorizontalTrack> + {children} + </CustomScrollbar> + {optionsLoaded && ( + <div className={styles.customMenuFooter}> + <div> + Only the top 1000 metrics are displayed in the metric select. Use the metrics explorer to view all + metrics. + </div> + </div> + )} + </div> + ); + }; + + const asyncSelect = () => { + return ( + <AsyncSelect + data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.metricSelect} + isClearable={variableEditor ? true : false} + inputId="prometheus-metric-select" + className={styles.select} + value={query.metric ? toOption(query.metric) : undefined} + placeholder={'Select metric'} + allowCustomValue + formatOptionLabel={formatOptionLabel} + filterOption={customFilterOption} + onOpenMenu={async () => { + if (metricLookupDisabled) { + return; + } + setState({ isLoading: true }); + const metrics = await onGetMetrics(); + const initialMetrics: string[] = metrics.map((m) => m.value); + const resultsLength = metrics.length; + + if (metrics.length > PROMETHEUS_QUERY_BUILDER_MAX_RESULTS) { + truncateResult(metrics); + } + + if (prometheusMetricEncyclopedia) { + setState({ + // add the modal butoon option to the options + metrics: [...metricsModalOption, ...metrics], + isLoading: undefined, + // pass the initial metrics into the metrics explorer + initialMetrics: initialMetrics, + resultsTruncated: resultsLength > metrics.length, + }); + } else { + setState({ + metrics, + isLoading: undefined, + resultsTruncated: resultsLength > metrics.length, + }); + } + }} + loadOptions={metricLookupDisabled ? metricLookupDisabledSearch : debouncedSearch} + isLoading={state.isLoading} + defaultOptions={state.metrics} + onChange={(input) => { + const value = input?.value; + if (value) { + // if there is no metric and the m.e. is enabled, open the modal + if (prometheusMetricEncyclopedia && value === 'BrowseMetrics') { + tracking('grafana_prometheus_metric_encyclopedia_open', null, '', query); + setState({ ...state, metricsModalOpen: true }); + } else { + onChange({ ...query, metric: value }); + } + } else { + onChange({ ...query, metric: '' }); + } + }} + components={ + prometheusMetricEncyclopedia ? { Option: CustomOption, MenuList: CustomMenu } : { MenuList: CustomMenu } + } + onBlur={onBlur ? onBlur : () => {}} + /> + ); + }; + + return ( + <> + {prometheusMetricEncyclopedia && !datasource.lookupsDisabled && state.metricsModalOpen && ( + <MetricsModal + datasource={datasource} + isOpen={state.metricsModalOpen} + onClose={() => setState({ ...state, metricsModalOpen: false })} + query={query} + onChange={onChange} + initialMetrics={state.initialMetrics ?? []} + /> + )} + {/* format the ui for either the query editor or the variable editor */} + {variableEditor ? ( + <InlineFieldRow> + <InlineField + label="Metric" + labelWidth={20} + tooltip={<div>Optional: returns a list of label values for the label name in the specified metric.</div>} + > + {asyncSelect()} + </InlineField> + </InlineFieldRow> + ) : ( + <EditorFieldGroup> + <EditorField label="Metric">{asyncSelect()}</EditorField> + </EditorFieldGroup> + )} + </> + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + select: css` + min-width: 125px; + `, + highlight: css` + label: select__match-highlight; + background: inherit; + padding: inherit; + color: ${theme.colors.warning.contrastText}; + background-color: ${theme.colors.warning.main}; + `, + customOption: css` + padding: 8px; + display: flex; + justify-content: space-between; + cursor: pointer; + :hover { + background-color: ${theme.colors.emphasize(theme.colors.background.primary, 0.1)}; + } + `, + customOptionlabel: css` + color: ${theme.colors.text.primary}; + `, + customOptionDesc: css` + color: ${theme.colors.text.secondary}; + font-size: ${theme.typography.size.xs}; + opacity: 50%; + `, + focus: css` + background-color: ${theme.colors.emphasize(theme.colors.background.primary, 0.1)}; + `, + customOptionWidth: css` + min-width: 400px; + `, + customMenuFooter: css` + flex: 0; + display: flex; + justify-content: space-between; + padding: ${theme.spacing(1.5)}; + border-top: 1px solid ${theme.colors.border.weak}; + color: ${theme.colors.text.secondary}; + `, + customMenuContainer: css` + display: flex; + flex-direction: column; + background: ${theme.colors.background.primary}; + box-shadow: ${theme.shadows.z3}; + `, +}); + +export const formatPrometheusLabelFiltersToString = ( + queryString: string, + labelsFilters: QueryBuilderLabelFilter[] | undefined +): string => { + const filterArray = labelsFilters ? formatPrometheusLabelFilters(labelsFilters) : []; + + return `label_values({__name__=~".*${queryString}"${filterArray ? filterArray.join('') : ''}},__name__)`; +}; + +export const formatPrometheusLabelFilters = (labelsFilters: QueryBuilderLabelFilter[]): string[] => { + return labelsFilters.map((label) => { + return `,${label.label}="${label.value}"`; + }); +}; diff --git a/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx b/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx new file mode 100644 index 0000000000000..b27af15c16830 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx @@ -0,0 +1,252 @@ +import React, { useCallback } from 'react'; + +import { SelectableValue } from '@grafana/data'; + +import { PrometheusDatasource } from '../../datasource'; +import { getMetadataString } from '../../language_provider'; +import { truncateResult } from '../../language_utils'; +import { promQueryModeller } from '../PromQueryModeller'; +import { regexifyLabelValuesQueryString } from '../parsingUtils'; +import { QueryBuilderLabelFilter } from '../shared/types'; +import { PromVisualQuery } from '../types'; + +import { LabelFilters } from './LabelFilters'; +import { MetricSelect } from './MetricSelect'; + +export interface MetricsLabelsSectionProps { + query: PromVisualQuery; + datasource: PrometheusDatasource; + onChange: (update: PromVisualQuery) => void; + variableEditor?: boolean; + onBlur?: () => void; +} + +export function MetricsLabelsSection({ + datasource, + query, + onChange, + onBlur, + variableEditor, +}: MetricsLabelsSectionProps) { + // fixing the use of 'as' from refactoring + // @ts-ignore + const onChangeLabels = (labels) => { + onChange({ ...query, labels }); + }; + /** + * Map metric metadata to SelectableValue for Select component and also adds defined template variables to the list. + */ + const withTemplateVariableOptions = useCallback( + async (optionsPromise: Promise<SelectableValue[]>): Promise<SelectableValue[]> => { + const variables = datasource.getVariables(); + const options = await optionsPromise; + return [ + ...variables.map((value: string) => ({ label: value, value })), + ...options.map((option: SelectableValue) => ({ + label: option.value, + value: option.value, + title: option.description, + })), + ]; + }, + [datasource] + ); + + /** + * Function kicked off when user interacts with label in label filters. + * Formats a promQL expression and passes that off to helper functions depending on API support + * @param forLabel + */ + const onGetLabelNames = async (forLabel: Partial<QueryBuilderLabelFilter>): Promise<SelectableValue[]> => { + // If no metric we need to use a different method + if (!query.metric) { + await datasource.languageProvider.fetchLabels(); + return datasource.languageProvider.getLabelKeys().map((k) => ({ value: k })); + } + + const labelsToConsider = query.labels.filter((x) => x !== forLabel); + labelsToConsider.push({ label: '__name__', op: '=', value: query.metric }); + const expr = promQueryModeller.renderLabels(labelsToConsider); + + let labelsIndex: Record<string, string[]> = await datasource.languageProvider.fetchLabelsWithMatch(expr); + + // filter out already used labels + return Object.keys(labelsIndex) + .filter((labelName) => !labelsToConsider.find((filter) => filter.label === labelName)) + .map((k) => ({ value: k })); + }; + + const getLabelValuesAutocompleteSuggestions = ( + queryString?: string, + labelName?: string + ): Promise<SelectableValue[]> => { + const forLabel = { + label: labelName ?? '__name__', + op: '=~', + value: regexifyLabelValuesQueryString(`.*${queryString}`), + }; + const labelsToConsider = query.labels.filter((x) => x.label !== forLabel.label); + labelsToConsider.push(forLabel); + if (query.metric) { + labelsToConsider.push({ label: '__name__', op: '=', value: query.metric }); + } + const interpolatedLabelsToConsider = labelsToConsider.map((labelObject) => ({ + ...labelObject, + label: datasource.interpolateString(labelObject.label), + value: datasource.interpolateString(labelObject.value), + })); + const expr = promQueryModeller.renderLabels(interpolatedLabelsToConsider); + let response: Promise<SelectableValue[]>; + if (datasource.hasLabelsMatchAPISupport()) { + response = getLabelValuesFromLabelValuesAPI(forLabel, expr); + } else { + response = getLabelValuesFromSeriesAPI(forLabel, expr); + } + + return response.then((response: SelectableValue[]) => { + truncateResult(response); + return response; + }); + }; + + /** + * Helper function to fetch and format label value results from legacy API + * @param forLabel + * @param promQLExpression + */ + const getLabelValuesFromSeriesAPI = ( + forLabel: Partial<QueryBuilderLabelFilter>, + promQLExpression: string + ): Promise<SelectableValue[]> => { + if (!forLabel.label) { + return Promise.resolve([]); + } + const result = datasource.languageProvider.fetchSeries(promQLExpression); + const forLabelInterpolated = datasource.interpolateString(forLabel.label); + return result.then((result) => { + // This query returns duplicate values, scrub them out + const set = new Set<string>(); + result.forEach((labelValue) => { + const labelNameString = labelValue[forLabelInterpolated]; + set.add(labelNameString); + }); + + return Array.from(set).map((labelValues: string) => ({ label: labelValues, value: labelValues })); + }); + }; + + /** + * Helper function to fetch label values from a promql string expression and a label + * @param forLabel + * @param promQLExpression + */ + const getLabelValuesFromLabelValuesAPI = ( + forLabel: Partial<QueryBuilderLabelFilter>, + promQLExpression: string + ): Promise<SelectableValue[]> => { + if (!forLabel.label) { + return Promise.resolve([]); + } + return datasource.languageProvider.fetchSeriesValuesWithMatch(forLabel.label, promQLExpression).then((response) => { + return response.map((v) => ({ + value: v, + label: v, + })); + }); + }; + + /** + * Function kicked off when users interact with the value of the label filters + * Formats a promQL expression and passes that into helper functions depending on API support + * @param forLabel + */ + const onGetLabelValues = async (forLabel: Partial<QueryBuilderLabelFilter>): Promise<SelectableValue[]> => { + if (!forLabel.label) { + return []; + } + // If no metric is selected, we can get the raw list of labels + if (!query.metric) { + return (await datasource.languageProvider.getLabelValues(forLabel.label)).map((v) => ({ value: v })); + } + + const labelsToConsider = query.labels.filter((x) => x !== forLabel); + labelsToConsider.push({ label: '__name__', op: '=', value: query.metric }); + + const interpolatedLabelsToConsider = labelsToConsider.map((labelObject) => ({ + ...labelObject, + label: datasource.interpolateString(labelObject.label), + value: datasource.interpolateString(labelObject.value), + })); + + const expr = promQueryModeller.renderLabels(interpolatedLabelsToConsider); + + if (datasource.hasLabelsMatchAPISupport()) { + return getLabelValuesFromLabelValuesAPI(forLabel, expr); + } else { + return getLabelValuesFromSeriesAPI(forLabel, expr); + } + }; + + const onGetMetrics = useCallback(() => { + return withTemplateVariableOptions(getMetrics(datasource, query)); + }, [datasource, query, withTemplateVariableOptions]); + + return ( + <> + <MetricSelect + query={query} + onChange={onChange} + onGetMetrics={onGetMetrics} + datasource={datasource} + labelsFilters={query.labels} + metricLookupDisabled={datasource.lookupsDisabled} + onBlur={onBlur ? onBlur : () => {}} + variableEditor={variableEditor} + /> + <LabelFilters + debounceDuration={datasource.getDebounceTimeInMilliseconds()} + getLabelValuesAutofillSuggestions={getLabelValuesAutocompleteSuggestions} + labelsFilters={query.labels} + onChange={onChangeLabels} + onGetLabelNames={(forLabel) => withTemplateVariableOptions(onGetLabelNames(forLabel))} + onGetLabelValues={(forLabel) => withTemplateVariableOptions(onGetLabelValues(forLabel))} + variableEditor={variableEditor} + /> + </> + ); +} + +/** + * Returns list of metrics, either all or filtered by query param. It also adds description string to each metric if it + * exists. + * @param datasource + * @param query + */ +async function getMetrics( + datasource: PrometheusDatasource, + query: PromVisualQuery +): Promise<Array<{ value: string; description?: string }>> { + // Makes sure we loaded the metadata for metrics. Usually this is done in the start() method of the provider but we + // don't use it with the visual builder and there is no need to run all the start() setup anyway. + if (!datasource.languageProvider.metricsMetadata) { + await datasource.languageProvider.loadMetricsMetadata(); + } + + // Error handling for when metrics metadata returns as undefined + if (!datasource.languageProvider.metricsMetadata) { + datasource.languageProvider.metricsMetadata = {}; + } + + let metrics: string[]; + if (query.labels.length > 0) { + const expr = promQueryModeller.renderLabels(query.labels); + metrics = (await datasource.languageProvider.getSeries(expr, true))['__name__'] ?? []; + } else { + metrics = (await datasource.languageProvider.getLabelValues('__name__')) ?? []; + } + + return metrics.map((m) => ({ + value: m, + description: getMetadataString(m, datasource.languageProvider.metricsMetadata!), + })); +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/NestedQuery.tsx b/packages/grafana-prometheus/src/querybuilder/components/NestedQuery.tsx new file mode 100644 index 0000000000000..ecba58984548a --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/NestedQuery.tsx @@ -0,0 +1,129 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2, toOption } from '@grafana/data'; +import { EditorRows, FlexItem } from '@grafana/experimental'; +import { AutoSizeInput, IconButton, Select, useStyles2 } from '@grafana/ui'; + +import { PrometheusDatasource } from '../../datasource'; +import { binaryScalarDefs } from '../binaryScalarOperations'; +import { PromVisualQueryBinary } from '../types'; + +import { PromQueryBuilder } from './PromQueryBuilder'; + +export interface NestedQueryProps { + nestedQuery: PromVisualQueryBinary; + datasource: PrometheusDatasource; + index: number; + onChange: (index: number, update: PromVisualQueryBinary) => void; + onRemove: (index: number) => void; + onRunQuery: () => void; + showExplain: boolean; +} + +export const NestedQuery = React.memo<NestedQueryProps>((props) => { + const { nestedQuery, index, datasource, onChange, onRemove, onRunQuery, showExplain } = props; + const styles = useStyles2(getStyles); + + return ( + <div className={styles.card}> + <div className={styles.header}> + <div className={styles.name}>Operator</div> + <Select + width="auto" + options={operators} + value={toOption(nestedQuery.operator)} + onChange={(value) => { + onChange(index, { + ...nestedQuery, + operator: value.value!, + }); + }} + /> + <div className={styles.name}>Vector matches</div> + <div className={styles.vectorMatchWrapper}> + <Select<PromVisualQueryBinary['vectorMatchesType']> + width="auto" + value={nestedQuery.vectorMatchesType || 'on'} + allowCustomValue + options={[ + { value: 'on', label: 'on' }, + { value: 'ignoring', label: 'ignoring' }, + ]} + onChange={(val) => { + onChange(index, { + ...nestedQuery, + vectorMatchesType: val.value, + }); + }} + /> + <AutoSizeInput + className={styles.vectorMatchInput} + minWidth={20} + defaultValue={nestedQuery.vectorMatches} + onCommitChange={(evt) => { + onChange(index, { + ...nestedQuery, + vectorMatches: evt.currentTarget.value, + vectorMatchesType: nestedQuery.vectorMatchesType || 'on', + }); + }} + /> + </div> + <FlexItem grow={1} /> + <IconButton name="times" size="sm" onClick={() => onRemove(index)} tooltip="Remove match" /> + </div> + <div className={styles.body}> + <EditorRows> + <PromQueryBuilder + showExplain={showExplain} + query={nestedQuery.query} + datasource={datasource} + onRunQuery={onRunQuery} + onChange={(update) => { + onChange(index, { ...nestedQuery, query: update }); + }} + /> + </EditorRows> + </div> + </div> + ); +}); + +const operators = binaryScalarDefs.map((def) => ({ label: def.sign, value: def.sign })); + +NestedQuery.displayName = 'NestedQuery'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + card: css({ + label: 'card', + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + }), + header: css({ + label: 'header', + padding: theme.spacing(0.5, 0.5, 0.5, 1), + gap: theme.spacing(1), + display: 'flex', + alignItems: 'center', + }), + name: css({ + label: 'name', + whiteSpace: 'nowrap', + }), + body: css({ + label: 'body', + paddingLeft: theme.spacing(2), + }), + vectorMatchInput: css({ + label: 'vectorMatchInput', + marginLeft: -1, + }), + vectorMatchWrapper: css({ + label: 'vectorMatchWrapper', + display: 'flex', + }), + }; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/components/NestedQueryList.tsx b/packages/grafana-prometheus/src/querybuilder/components/NestedQueryList.tsx new file mode 100644 index 0000000000000..fb9e60229fe14 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/NestedQueryList.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import { Stack } from '@grafana/ui'; + +import { PrometheusDatasource } from '../../datasource'; +import { PromVisualQuery, PromVisualQueryBinary } from '../types'; + +import { NestedQuery } from './NestedQuery'; + +export interface NestedQueryListProps { + query: PromVisualQuery; + datasource: PrometheusDatasource; + onChange: (query: PromVisualQuery) => void; + onRunQuery: () => void; + showExplain: boolean; +} + +export function NestedQueryList(props: NestedQueryListProps) { + const { query, datasource, onChange, onRunQuery, showExplain } = props; + const nestedQueries = query.binaryQueries ?? []; + + const onNestedQueryUpdate = (index: number, update: PromVisualQueryBinary) => { + const updatedList = [...nestedQueries]; + updatedList.splice(index, 1, update); + onChange({ ...query, binaryQueries: updatedList }); + }; + + const onRemove = (index: number) => { + const updatedList = [...nestedQueries.slice(0, index), ...nestedQueries.slice(index + 1)]; + onChange({ ...query, binaryQueries: updatedList }); + }; + + return ( + <Stack direction="column" gap={1}> + {nestedQueries.map((nestedQuery, index) => ( + <NestedQuery + key={index.toString()} + nestedQuery={nestedQuery} + index={index} + onChange={onNestedQueryUpdate} + datasource={datasource} + onRemove={onRemove} + onRunQuery={onRunQuery} + showExplain={showExplain} + /> + ))} + </Stack> + ); +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx new file mode 100644 index 0000000000000..54f44c46f1369 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx @@ -0,0 +1,392 @@ +import { getByText, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { + DataSourceInstanceSettings, + DataSourcePluginMeta, + LoadingState, + MutableDataFrame, + PanelData, + QueryHint, + TimeRange, +} from '@grafana/data'; +import { config, TemplateSrv } from '@grafana/runtime'; + +import { PrometheusDatasource } from '../../datasource'; +import PromQlLanguageProvider from '../../language_provider'; +import { EmptyLanguageProviderMock } from '../../language_provider.mock'; +import { PromApplication, PromOptions } from '../../types'; +import { getLabelSelects } from '../testUtils'; +import { PromVisualQuery } from '../types'; + +import { PromQueryBuilder } from './PromQueryBuilder'; +import { EXPLAIN_LABEL_FILTER_CONTENT } from './PromQueryBuilderExplained'; + +const defaultQuery: PromVisualQuery = { + metric: 'random_metric', + labels: [], + operations: [], +}; + +const bugQuery: PromVisualQuery = { + metric: 'random_metric', + labels: [{ label: 'instance', op: '=', value: 'localhost:9090' }], + operations: [ + { + id: 'rate', + params: ['auto'], + }, + { + id: '__sum_by', + params: ['instance', 'job'], + }, + ], + binaryQueries: [ + { + operator: '/', + query: { + metric: 'metric2', + labels: [{ label: 'foo', op: '=', value: 'bar' }], + operations: [ + { + id: '__avg_by', + params: ['app'], + }, + ], + }, + }, + ], +}; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('PromQueryBuilder', () => { + it('shows empty just with metric selected', async () => { + setup(); + // Add label + expect(screen.getByLabelText('Add')).toBeInTheDocument(); + expect(screen.getByTitle('Add operation')).toBeInTheDocument(); + }); + + it('renders all the query sections', async () => { + setup(bugQuery); + expect(screen.getByText('random_metric')).toBeInTheDocument(); + expect(screen.getByText('localhost:9090')).toBeInTheDocument(); + expect(screen.getByText('Rate')).toBeInTheDocument(); + const sumBys = screen.getAllByTestId('operations.1.wrapper'); + expect(getByText(sumBys[0], 'instance')).toBeInTheDocument(); + expect(getByText(sumBys[0], 'job')).toBeInTheDocument(); + + const avgBys = screen.getAllByTestId('operations.0.wrapper'); + expect(getByText(avgBys[1], 'app')).toBeInTheDocument(); + expect(screen.getByText('Operator')).toBeInTheDocument(); + expect(screen.getByText('Vector matches')).toBeInTheDocument(); + }); + + it('tries to load metrics without labels', async () => { + const { languageProvider, container } = setup(); + await openMetricSelect(container); + await waitFor(() => expect(languageProvider.getLabelValues).toBeCalledWith('__name__')); + }); + + it('tries to load metrics with labels', async () => { + const { languageProvider, container } = setup({ + ...defaultQuery, + labels: [{ label: 'label_name', op: '=', value: 'label_value' }], + }); + await openMetricSelect(container); + await waitFor(() => expect(languageProvider.getSeries).toBeCalledWith('{label_name="label_value"}', true)); + }); + + it('tries to load variables in metric field', async () => { + const { datasource, container } = setup(); + datasource.getVariables = jest.fn().mockReturnValue([]); + await openMetricSelect(container); + await waitFor(() => expect(datasource.getVariables).toBeCalled()); + }); + + it('checks if the LLM plugin is enabled when the `prometheusPromQAIL` feature is enabled', async () => { + jest.replaceProperty(config, 'featureToggles', { + prometheusPromQAIL: true, + }); + const mockIsLLMPluginEnabled = jest.fn(); + mockIsLLMPluginEnabled.mockResolvedValue(true); + jest.spyOn(require('./promQail/state/helpers'), 'isLLMPluginEnabled').mockImplementation(mockIsLLMPluginEnabled); + setup(); + await waitFor(() => expect(mockIsLLMPluginEnabled).toHaveBeenCalledTimes(1)); + }); + + it('does not check if the LLM plugin is enabled when the `prometheusPromQAIL` feature is disabled', async () => { + jest.replaceProperty(config, 'featureToggles', { + prometheusPromQAIL: false, + }); + const mockIsLLMPluginEnabled = jest.fn(); + mockIsLLMPluginEnabled.mockResolvedValue(true); + jest.spyOn(require('./promQail/state/helpers'), 'isLLMPluginEnabled').mockImplementation(mockIsLLMPluginEnabled); + setup(); + await waitFor(() => expect(mockIsLLMPluginEnabled).toHaveBeenCalledTimes(0)); + }); + + // <LegacyPrometheus> + it('tries to load labels when metric selected', async () => { + const { languageProvider } = setup(); + await openLabelNameSelect(); + await waitFor(() => expect(languageProvider.fetchLabelsWithMatch).toBeCalledWith('{__name__="random_metric"}')); + }); + + it('tries to load variables in label field', async () => { + const { datasource } = setup(); + datasource.getVariables = jest.fn().mockReturnValue([]); + await openLabelNameSelect(); + await waitFor(() => expect(datasource.getVariables).toBeCalled()); + }); + + it('tries to load labels when metric selected and other labels are already present', async () => { + const { languageProvider } = setup({ + ...defaultQuery, + labels: [ + { label: 'label_name', op: '=', value: 'label_value' }, + { label: 'foo', op: '=', value: 'bar' }, + ], + }); + await openLabelNameSelect(1); + await waitFor(() => + expect(languageProvider.fetchLabelsWithMatch).toBeCalledWith( + '{label_name="label_value", __name__="random_metric"}' + ) + ); + }); + //</LegacyPrometheus> + + it('tries to load labels when metric is not selected', async () => { + const { languageProvider } = setup({ + ...defaultQuery, + metric: '', + }); + await openLabelNameSelect(); + await waitFor(() => expect(languageProvider.fetchLabels).toBeCalled()); + }); + + it('shows hints for histogram metrics', async () => { + const { container } = setup({ + metric: 'histogram_metric_bucket', + labels: [], + operations: [], + }); + await openMetricSelect(container); + await userEvent.click(screen.getByText('histogram_metric_bucket')); + await waitFor(() => expect(screen.getByText('hint: add histogram_quantile')).toBeInTheDocument()); + }); + + it('shows hints for counter metrics', async () => { + const { container } = setup({ + metric: 'histogram_metric_sum', + labels: [], + operations: [], + }); + await openMetricSelect(container); + await userEvent.click(screen.getByText('histogram_metric_sum')); + await waitFor(() => expect(screen.getByText('hint: add rate')).toBeInTheDocument()); + }); + + it('shows hints for counter metrics', async () => { + const { container } = setup({ + metric: 'histogram_metric_sum', + labels: [], + operations: [], + }); + await openMetricSelect(container); + await userEvent.click(screen.getByText('histogram_metric_sum')); + await waitFor(() => expect(screen.getByText('hint: add rate')).toBeInTheDocument()); + }); + + it('shows multiple hints', async () => { + const data: PanelData = { + series: [], + state: LoadingState.Done, + timeRange: {} as TimeRange, + }; + for (let i = 0; i < 25; i++) { + data.series.push(new MutableDataFrame()); + } + const { container } = setup( + { + metric: 'histogram_metric_sum', + labels: [], + operations: [], + }, + data + ); + await openMetricSelect(container); + await userEvent.click(screen.getByText('histogram_metric_sum')); + await waitFor(() => expect(screen.getAllByText(/hint:/)).toHaveLength(2)); + }); + + it('shows explain section when showExplain is true', async () => { + const { datasource } = createDatasource(); + const props = createProps(datasource); + props.showExplain = true; + render( + <PromQueryBuilder + {...props} + query={{ + metric: 'histogram_metric_sum', + labels: [], + operations: [], + }} + /> + ); + expect(await screen.findByText(EXPLAIN_LABEL_FILTER_CONTENT)).toBeInTheDocument(); + }); + + it('does not show explain section when showExplain is false', async () => { + const { datasource } = createDatasource(); + const props = createProps(datasource); + render( + <PromQueryBuilder + {...props} + query={{ + metric: 'histogram_metric_sum', + labels: [], + operations: [], + }} + /> + ); + expect(await screen.queryByText(EXPLAIN_LABEL_FILTER_CONTENT)).not.toBeInTheDocument(); + }); + + it('renders hint if initial hint provided', async () => { + const { datasource } = createDatasource(); + datasource.getInitHints = (): QueryHint[] => [ + { + label: 'Initial hint', + type: 'warning', + }, + ]; + const props = createProps(datasource); + render( + <PromQueryBuilder + {...props} + query={{ + metric: 'histogram_metric_sum', + labels: [], + operations: [], + }} + /> + ); + expect(await screen.queryByText('Initial hint')).toBeInTheDocument(); + }); + + it('renders no hint if no initial hint provided', async () => { + const { datasource } = createDatasource(); + datasource.getInitHints = (): QueryHint[] => []; + const props = createProps(datasource); + render( + <PromQueryBuilder + {...props} + query={{ + metric: 'histogram_metric_sum', + labels: [], + operations: [], + }} + /> + ); + expect(await screen.queryByText('Initial hint')).not.toBeInTheDocument(); + }); + + // <ModernPrometheus> + it('tries to load labels when metric selected modern prom', async () => { + const { languageProvider } = setup(undefined, undefined, { + jsonData: { prometheusVersion: '2.38.1', prometheusType: PromApplication.Prometheus }, + }); + await openLabelNameSelect(); + await waitFor(() => expect(languageProvider.fetchLabelsWithMatch).toBeCalledWith('{__name__="random_metric"}')); + }); + + it('tries to load variables in label field modern prom', async () => { + const { datasource } = setup(undefined, undefined, { + jsonData: { prometheusVersion: '2.38.1', prometheusType: PromApplication.Prometheus }, + }); + datasource.getVariables = jest.fn().mockReturnValue([]); + await openLabelNameSelect(); + await waitFor(() => expect(datasource.getVariables).toBeCalled()); + }); + + it('tries to load labels when metric selected and other labels are already present modern prom', async () => { + const { languageProvider } = setup( + { + ...defaultQuery, + labels: [ + { label: 'label_name', op: '=', value: 'label_value' }, + { label: 'foo', op: '=', value: 'bar' }, + ], + }, + undefined, + { jsonData: { prometheusVersion: '2.38.1', prometheusType: PromApplication.Prometheus } } + ); + await openLabelNameSelect(1); + await waitFor(() => + expect(languageProvider.fetchLabelsWithMatch).toBeCalledWith( + '{label_name="label_value", __name__="random_metric"}' + ) + ); + }); + //</ModernPrometheus> +}); + +function createDatasource(options?: Partial<DataSourceInstanceSettings<PromOptions>>) { + const languageProvider = new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider; + const datasource = new PrometheusDatasource( + { + url: '', + jsonData: {}, + meta: {} as DataSourcePluginMeta, + ...options, + } as DataSourceInstanceSettings<PromOptions>, + mockTemplateSrv(), + languageProvider + ); + return { datasource, languageProvider }; +} + +function createProps(datasource: PrometheusDatasource, data?: PanelData) { + return { + datasource, + onRunQuery: () => {}, + onChange: () => {}, + data, + showExplain: false, + }; +} + +function setup( + query: PromVisualQuery = defaultQuery, + data?: PanelData, + datasourceOptionsOverride?: Partial<DataSourceInstanceSettings<PromOptions>> +) { + const { datasource, languageProvider } = createDatasource(datasourceOptionsOverride); + const props = createProps(datasource, data); + const { container } = render(<PromQueryBuilder {...props} query={query} />); + return { languageProvider, datasource, container }; +} + +async function openMetricSelect(container: HTMLElement) { + const select = container.querySelector('#prometheus-metric-select'); + if (select) { + await userEvent.click(select); + } +} + +async function openLabelNameSelect(index = 0) { + const { name } = getLabelSelects(index); + await userEvent.click(name); +} + +function mockTemplateSrv(): TemplateSrv { + return { + getVariables: () => [], + } as unknown as TemplateSrv; +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx new file mode 100644 index 0000000000000..e05b1c2caa258 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx @@ -0,0 +1,149 @@ +import { css } from '@emotion/css'; +import React, { useEffect, useState } from 'react'; + +import { DataSourceApi, PanelData } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { EditorRow } from '@grafana/experimental'; +import { config } from '@grafana/runtime'; +import { Drawer } from '@grafana/ui'; + +import { PrometheusDatasource } from '../../datasource'; +import promqlGrammar from '../../promql'; +import { promQueryModeller } from '../PromQueryModeller'; +import { buildVisualQueryFromString } from '../parsing'; +import { OperationExplainedBox } from '../shared/OperationExplainedBox'; +import { OperationList } from '../shared/OperationList'; +import { OperationListExplained } from '../shared/OperationListExplained'; +import { OperationsEditorRow } from '../shared/OperationsEditorRow'; +import { QueryBuilderHints } from '../shared/QueryBuilderHints'; +import { RawQuery } from '../shared/RawQuery'; +import { QueryBuilderOperation } from '../shared/types'; +import { PromVisualQuery } from '../types'; + +import { MetricsLabelsSection } from './MetricsLabelsSection'; +import { NestedQueryList } from './NestedQueryList'; +import { EXPLAIN_LABEL_FILTER_CONTENT } from './PromQueryBuilderExplained'; +import { PromQail } from './promQail/PromQail'; +import { QueryAssistantButton } from './promQail/QueryAssistantButton'; +import { isLLMPluginEnabled } from './promQail/state/helpers'; + +export interface PromQueryBuilderProps { + query: PromVisualQuery; + datasource: PrometheusDatasource; + onChange: (update: PromVisualQuery) => void; + onRunQuery: () => void; + data?: PanelData; + showExplain: boolean; +} + +export const PromQueryBuilder = React.memo<PromQueryBuilderProps>((props) => { + const { datasource, query, onChange, onRunQuery, data, showExplain } = props; + const [highlightedOp, setHighlightedOp] = useState<QueryBuilderOperation | undefined>(); + const [showDrawer, setShowDrawer] = useState<boolean>(false); + const [llmAppEnabled, updateLlmAppEnabled] = useState<boolean>(false); + const { prometheusPromQAIL } = config.featureToggles; // AI/ML + Prometheus + + const lang = { grammar: promqlGrammar, name: 'promql' }; + + const initHints = datasource.getInitHints(); + + useEffect(() => { + async function checkLlms() { + const check = await isLLMPluginEnabled(); + updateLlmAppEnabled(check); + } + + if (prometheusPromQAIL) { + checkLlms(); + } + }, [prometheusPromQAIL]); + + return ( + <> + {prometheusPromQAIL && showDrawer && ( + <Drawer closeOnMaskClick={false} onClose={() => setShowDrawer(false)}> + <PromQail + query={query} + closeDrawer={() => setShowDrawer(false)} + onChange={onChange} + datasource={datasource} + /> + </Drawer> + )} + <EditorRow> + <MetricsLabelsSection query={query} onChange={onChange} datasource={datasource} /> + </EditorRow> + {initHints.length ? ( + <div className="query-row-break"> + <div className="prom-query-field-info text-warning"> + {initHints[0].label}{' '} + {initHints[0].fix ? ( + <button type="button" className={'text-warning'}> + {initHints[0].fix.label} + </button> + ) : null} + </div> + </div> + ) : null} + {showExplain && ( + <OperationExplainedBox + stepNumber={1} + title={<RawQuery query={`${query.metric} ${promQueryModeller.renderLabels(query.labels)}`} lang={lang} />} + > + {EXPLAIN_LABEL_FILTER_CONTENT} + </OperationExplainedBox> + )} + <OperationsEditorRow> + <OperationList<PromVisualQuery> + queryModeller={promQueryModeller} + // eslint-ignore + datasource={datasource as DataSourceApi} + query={query} + onChange={onChange} + onRunQuery={onRunQuery} + highlightedOp={highlightedOp} + /> + {prometheusPromQAIL && ( + <div + className={css({ + padding: '0 0 0 6px', + })} + > + <QueryAssistantButton llmAppEnabled={llmAppEnabled} metric={query.metric} setShowDrawer={setShowDrawer} /> + </div> + )} + <div data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.hints}> + <QueryBuilderHints<PromVisualQuery> + datasource={datasource} + query={query} + onChange={onChange} + data={data} + queryModeller={promQueryModeller} + buildVisualQueryFromString={buildVisualQueryFromString} + /> + </div> + </OperationsEditorRow> + {showExplain && ( + <OperationListExplained<PromVisualQuery> + lang={lang} + query={query} + stepNumber={2} + queryModeller={promQueryModeller} + onMouseEnter={(op) => setHighlightedOp(op)} + onMouseLeave={() => setHighlightedOp(undefined)} + /> + )} + {query.binaryQueries && query.binaryQueries.length > 0 && ( + <NestedQueryList + query={query} + datasource={datasource} + onChange={onChange} + onRunQuery={onRunQuery} + showExplain={showExplain} + /> + )} + </> + ); +}); + +PromQueryBuilder.displayName = 'PromQueryBuilder'; diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.test.tsx new file mode 100644 index 0000000000000..5172172fd32a3 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.test.tsx @@ -0,0 +1,64 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data'; + +import { PrometheusDatasource } from '../../datasource'; +import PromQlLanguageProvider from '../../language_provider'; +import { EmptyLanguageProviderMock } from '../../language_provider.mock'; +import { PromQuery } from '../../types'; +import { getOperationParamId } from '../operationUtils'; +import { addOperationInQueryBuilder } from '../testUtils'; + +import { PromQueryBuilderContainer } from './PromQueryBuilderContainer'; + +describe('PromQueryBuilderContainer', () => { + it('translates query between string and model', async () => { + const { props } = setup({ expr: 'rate(metric_test{job="testjob"}[$__rate_interval])' }); + + expect(screen.getByText('metric_test')).toBeInTheDocument(); + await addOperationInQueryBuilder('Range functions', 'Rate'); + expect(props.onChange).toBeCalledWith({ + expr: 'rate(metric_test{job="testjob"}[$__rate_interval])', + refId: 'A', + }); + }); + + it('Can add rest param', async () => { + const { container } = setup({ expr: 'sum(ALERTS)' }); + await userEvent.click(screen.getByTestId('operations.0.add-rest-param')); + + waitFor(() => { + expect(container.querySelector(`${getOperationParamId('0', 0)}`)).toBeInTheDocument(); + }); + }); +}); + +function setup(queryOverrides: Partial<PromQuery> = {}) { + const languageProvider = new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider; + const datasource = new PrometheusDatasource( + { + url: '', + jsonData: {}, + meta: {} as DataSourcePluginMeta, + } as DataSourceInstanceSettings, + undefined, + languageProvider + ); + + const props = { + datasource, + query: { + refId: 'A', + expr: '', + ...queryOverrides, + }, + onRunQuery: jest.fn(), + onChange: jest.fn(), + showExplain: false, + }; + + const { container } = render(<PromQueryBuilderContainer {...props} />); + return { languageProvider, datasource, container, props }; +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.tsx new file mode 100644 index 0000000000000..46d3dc0562585 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.tsx @@ -0,0 +1,121 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import React, { useEffect, useReducer } from 'react'; + +import { PanelData } from '@grafana/data'; +import { config } from '@grafana/runtime'; + +import { PrometheusDatasource } from '../../datasource'; +import { PromQuery } from '../../types'; +import { promQueryModeller } from '../PromQueryModeller'; +import { buildVisualQueryFromString } from '../parsing'; +import { PromVisualQuery } from '../types'; + +import { PromQueryBuilder } from './PromQueryBuilder'; +import { QueryPreview } from './QueryPreview'; +import { getSettings, MetricsModalSettings } from './metrics-modal/state/state'; + +export interface PromQueryBuilderContainerProps { + query: PromQuery; + datasource: PrometheusDatasource; + onChange: (update: PromQuery) => void; + onRunQuery: () => void; + data?: PanelData; + showExplain: boolean; +} + +export interface State { + visQuery?: PromVisualQuery; + expr: string; +} + +const prometheusMetricEncyclopedia = config.featureToggles.prometheusMetricEncyclopedia; + +/** + * This component is here just to contain the translation logic between string query and the visual query builder model. + */ +export function PromQueryBuilderContainer(props: PromQueryBuilderContainerProps) { + const { query, onChange, onRunQuery, datasource, data, showExplain } = props; + const [state, dispatch] = useReducer(stateSlice.reducer, { expr: query.expr }); + // Only rebuild visual query if expr changes from outside + useEffect(() => { + dispatch(exprChanged(query.expr)); + + if (prometheusMetricEncyclopedia) { + dispatch( + setMetricsModalSettings({ + useBackend: query.useBackend ?? false, + disableTextWrap: query.disableTextWrap ?? false, + fullMetaSearch: query.fullMetaSearch ?? false, + includeNullMetadata: query.includeNullMetadata ?? true, + }) + ); + } + }, [query]); + + useEffect(() => { + datasource.languageProvider.start(data?.timeRange); + }, [data?.timeRange, datasource.languageProvider]); + + const onVisQueryChange = (visQuery: PromVisualQuery) => { + const expr = promQueryModeller.renderQuery(visQuery); + dispatch(visualQueryChange({ visQuery, expr })); + + if (prometheusMetricEncyclopedia) { + const metricsModalSettings = getSettings(visQuery); + onChange({ ...props.query, expr: expr, ...metricsModalSettings }); + } else { + onChange({ ...props.query, expr: expr }); + } + }; + + if (!state.visQuery) { + return null; + } + + return ( + <> + <PromQueryBuilder + query={state.visQuery} + datasource={datasource} + onChange={onVisQueryChange} + onRunQuery={onRunQuery} + data={data} + showExplain={showExplain} + /> + {<QueryPreview query={query.expr} />} + </> + ); +} + +const initialState: State = { + expr: '', +}; + +const stateSlice = createSlice({ + name: 'prom-builder-container', + initialState, + reducers: { + visualQueryChange: (state, action: PayloadAction<{ visQuery: PromVisualQuery; expr: string }>) => { + state.expr = action.payload.expr; + state.visQuery = action.payload.visQuery; + }, + exprChanged: (state, action: PayloadAction<string>) => { + if (!state.visQuery || state.expr !== action.payload) { + state.expr = action.payload; + const parseResult = buildVisualQueryFromString(action.payload ?? ''); + + state.visQuery = parseResult.query; + } + }, + setMetricsModalSettings: (state, action: PayloadAction<MetricsModalSettings>) => { + if (state.visQuery && prometheusMetricEncyclopedia) { + state.visQuery.useBackend = action.payload.useBackend; + state.visQuery.disableTextWrap = action.payload.disableTextWrap; + state.visQuery.fullMetaSearch = action.payload.fullMetaSearch; + state.visQuery.includeNullMetadata = action.payload.includeNullMetadata; + } + }, + }, +}); + +const { visualQueryChange, exprChanged, setMetricsModalSettings } = stateSlice.actions; diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderExplained.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderExplained.tsx new file mode 100644 index 0000000000000..38389fbc6e480 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderExplained.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { Stack } from '@grafana/ui'; + +import promqlGrammar from '../../promql'; +import { promQueryModeller } from '../PromQueryModeller'; +import { buildVisualQueryFromString } from '../parsing'; +import { OperationExplainedBox } from '../shared/OperationExplainedBox'; +import { OperationListExplained } from '../shared/OperationListExplained'; +import { RawQuery } from '../shared/RawQuery'; +import { PromVisualQuery } from '../types'; + +export const EXPLAIN_LABEL_FILTER_CONTENT = 'Fetch all series matching metric name and label filters.'; + +export interface PromQueryBuilderExplainedProps { + query: string; +} + +export const PromQueryBuilderExplained = React.memo<PromQueryBuilderExplainedProps>(({ query }) => { + const visQuery = buildVisualQueryFromString(query || '').query; + const lang = { grammar: promqlGrammar, name: 'promql' }; + + return ( + <Stack gap={0.5} direction="column"> + <OperationExplainedBox + stepNumber={1} + title={<RawQuery query={`${visQuery.metric} ${promQueryModeller.renderLabels(visQuery.labels)}`} lang={lang} />} + > + {EXPLAIN_LABEL_FILTER_CONTENT} + </OperationExplainedBox> + <OperationListExplained<PromVisualQuery> + stepNumber={2} + queryModeller={promQueryModeller} + query={visQuery} + lang={lang} + /> + </Stack> + ); +}); + +PromQueryBuilderExplained.displayName = 'PromQueryBuilderExplained'; diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.test.tsx new file mode 100644 index 0000000000000..331092c1962d6 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.test.tsx @@ -0,0 +1,134 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { CoreApp } from '@grafana/data'; + +import { selectOptionInTest } from '../../gcopypaste/test/helpers/selectOptionInTest'; +import { PromQuery } from '../../types'; +import { getQueryWithDefaults } from '../state'; + +import { PromQueryBuilderOptions } from './PromQueryBuilderOptions'; + +describe('PromQueryBuilderOptions', () => { + it('Can change query type', async () => { + const { props } = setup(); + + await userEvent.click(screen.getByRole('button', { name: /Options/ })); + expect(screen.getByLabelText('Range')).toBeChecked(); + + await userEvent.click(screen.getByLabelText('Instant')); + + expect(props.onChange).toHaveBeenCalledWith({ + ...props.query, + instant: true, + range: false, + exemplar: false, + }); + }); + + it('Can set query type to "Both" on render for PanelEditor', async () => { + setup({ instant: true, range: true }); + + await userEvent.click(screen.getByRole('button', { name: /Options/ })); + + expect(screen.getByLabelText('Both')).toBeChecked(); + }); + + it('Can set query type to "Both" on render for Explorer', async () => { + setup({ instant: true, range: true }, CoreApp.Explore); + + await userEvent.click(screen.getByRole('button', { name: /Options/ })); + + expect(screen.getByLabelText('Both')).toBeChecked(); + }); + + it('Legend format default to Auto', () => { + setup(); + expect(screen.getByText('Legend: Auto')).toBeInTheDocument(); + }); + + it('Can change legend format to verbose', async () => { + const { props } = setup(); + + await userEvent.click(screen.getByRole('button', { name: /Options/ })); + + let legendModeSelect = screen.getByText('Auto').parentElement!; + await userEvent.click(legendModeSelect); + + await selectOptionInTest(legendModeSelect, 'Verbose'); + + expect(props.onChange).toHaveBeenCalledWith({ + ...props.query, + legendFormat: '', + }); + }); + + it('Can change legend format to custom', async () => { + const { props } = setup(); + + await userEvent.click(screen.getByRole('button', { name: /Options/ })); + + let legendModeSelect = screen.getByText('Auto').parentElement!; + await userEvent.click(legendModeSelect); + + await selectOptionInTest(legendModeSelect, 'Custom'); + + expect(props.onChange).toHaveBeenCalledWith({ + ...props.query, + legendFormat: '{{label_name}}', + }); + }); + + it('Handle defaults with undefined range', () => { + setup(getQueryWithDefaults({ refId: 'A', expr: '', range: undefined, instant: true }, CoreApp.Dashboard)); + + expect(screen.getByText('Type: Instant')).toBeInTheDocument(); + }); + + it('Should show "Exemplars: false" by default', () => { + setup(); + expect(screen.getByText('Exemplars: false')).toBeInTheDocument(); + }); + + it('Should show "Exemplars: false" when query has "Exemplars: false"', () => { + setup({ exemplar: false }); + expect(screen.getByText('Exemplars: false')).toBeInTheDocument(); + }); + + it('Should show "Exemplars: true" when query has "Exemplars: true"', () => { + setup({ exemplar: true }); + expect(screen.getByText('Exemplars: true')).toBeInTheDocument(); + }); +}); + +function setup(queryOverrides: Partial<PromQuery> = {}, app: CoreApp = CoreApp.PanelEditor) { + const props = { + app, + query: { + ...getQueryWithDefaults( + { + refId: 'A', + expr: '', + range: true, + instant: false, + } as PromQuery, + CoreApp.PanelEditor + ), + ...queryOverrides, + }, + onRunQuery: jest.fn(), + onChange: jest.fn(), + uiOptions: { + exemplars: true, + type: true, + format: true, + minStep: true, + legend: true, + resolution: true, + }, + }; + + const { container } = render(<PromQueryBuilderOptions {...props} />); + return { container, props }; +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx new file mode 100644 index 0000000000000..50788f2c6295c --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx @@ -0,0 +1,165 @@ +import React, { SyntheticEvent } from 'react'; + +import { CoreApp, SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { EditorField, EditorRow, EditorSwitch } from '@grafana/experimental'; +import { AutoSizeInput, RadioButtonGroup, Select } from '@grafana/ui'; + +import { getQueryTypeChangeHandler, getQueryTypeOptions } from '../../components/PromExploreExtraField'; +import { PromQueryFormat } from '../../dataquery'; +import { PromQuery } from '../../types'; +import { QueryOptionGroup } from '../shared/QueryOptionGroup'; + +import { FORMAT_OPTIONS, INTERVAL_FACTOR_OPTIONS } from './PromQueryEditorSelector'; +import { getLegendModeLabel, PromQueryLegendEditor } from './PromQueryLegendEditor'; + +export interface UIOptions { + exemplars: boolean; + type: boolean; + format: boolean; + minStep: boolean; + legend: boolean; + resolution: boolean; +} + +export interface PromQueryBuilderOptionsProps { + query: PromQuery; + app?: CoreApp; + onChange: (update: PromQuery) => void; + onRunQuery: () => void; +} + +export const PromQueryBuilderOptions = React.memo<PromQueryBuilderOptionsProps>( + ({ query, app, onChange, onRunQuery }) => { + const onChangeFormat = (value: SelectableValue<PromQueryFormat>) => { + onChange({ ...query, format: value.value }); + onRunQuery(); + }; + + const onChangeStep = (evt: React.FormEvent<HTMLInputElement>) => { + onChange({ ...query, interval: evt.currentTarget.value }); + onRunQuery(); + }; + + const queryTypeOptions = getQueryTypeOptions( + app === CoreApp.Explore || app === CoreApp.Correlations || app === CoreApp.PanelEditor + ); + + const onQueryTypeChange = getQueryTypeChangeHandler(query, onChange); + + const onExemplarChange = (event: SyntheticEvent<HTMLInputElement>) => { + const isEnabled = event.currentTarget.checked; + onChange({ ...query, exemplar: isEnabled }); + onRunQuery(); + }; + + const onIntervalFactorChange = (value: SelectableValue<number>) => { + onChange({ ...query, intervalFactor: value.value }); + onRunQuery(); + }; + + const formatOption = FORMAT_OPTIONS.find((option) => option.value === query.format) || FORMAT_OPTIONS[0]; + const queryTypeValue = getQueryTypeValue(query); + const queryTypeLabel = queryTypeOptions.find((x) => x.value === queryTypeValue)!.label; + + return ( + <EditorRow> + <div data-testid={selectors.components.DataSource.Prometheus.queryEditor.options}> + <QueryOptionGroup + title="Options" + collapsedInfo={getCollapsedInfo(query, formatOption.label!, queryTypeLabel, app)} + > + <PromQueryLegendEditor + legendFormat={query.legendFormat} + onChange={(legendFormat) => onChange({ ...query, legendFormat })} + onRunQuery={onRunQuery} + /> + <EditorField + label="Min step" + tooltip={ + <> + An additional lower limit for the step parameter of the Prometheus query and for the{' '} + <code>$__interval</code> and <code>$__rate_interval</code> variables. + </> + } + > + <AutoSizeInput + type="text" + aria-label="Set lower limit for the step parameter" + placeholder={'auto'} + minWidth={10} + onCommitChange={onChangeStep} + defaultValue={query.interval} + id={selectors.components.DataSource.Prometheus.queryEditor.step} + /> + </EditorField> + <EditorField label="Format"> + <Select + data-testid={selectors.components.DataSource.Prometheus.queryEditor.format} + value={formatOption} + allowCustomValue + onChange={onChangeFormat} + options={FORMAT_OPTIONS} + /> + </EditorField> + <EditorField label="Type" data-testid={selectors.components.DataSource.Prometheus.queryEditor.type}> + <RadioButtonGroup options={queryTypeOptions} value={queryTypeValue} onChange={onQueryTypeChange} /> + </EditorField> + {shouldShowExemplarSwitch(query, app) && ( + <EditorField label="Exemplars"> + <EditorSwitch + value={query.exemplar || false} + onChange={onExemplarChange} + id={selectors.components.DataSource.Prometheus.queryEditor.exemplars} + /> + </EditorField> + )} + {query.intervalFactor && query.intervalFactor > 1 && ( + <EditorField label="Resolution"> + <Select + aria-label="Select resolution" + isSearchable={false} + options={INTERVAL_FACTOR_OPTIONS} + onChange={onIntervalFactorChange} + value={INTERVAL_FACTOR_OPTIONS.find((option) => option.value === query.intervalFactor)} + /> + </EditorField> + )} + </QueryOptionGroup> + </div> + </EditorRow> + ); + } +); + +function shouldShowExemplarSwitch(query: PromQuery, app?: CoreApp) { + if (app === CoreApp.UnifiedAlerting || !query.range) { + return false; + } + + return true; +} + +function getQueryTypeValue(query: PromQuery) { + return query.range && query.instant ? 'both' : query.instant ? 'instant' : 'range'; +} + +function getCollapsedInfo(query: PromQuery, formatOption: string, queryType: string, app?: CoreApp): string[] { + const items: string[] = []; + + items.push(`Legend: ${getLegendModeLabel(query.legendFormat)}`); + items.push(`Format: ${formatOption}`); + items.push(`Step: ${query.interval ?? 'auto'}`); + items.push(`Type: ${queryType}`); + + if (shouldShowExemplarSwitch(query, app)) { + if (query.exemplar) { + items.push(`Exemplars: true`); + } else { + items.push(`Exemplars: false`); + } + } + return items; +} + +PromQueryBuilderOptions.displayName = 'PromQueryBuilderOptions'; diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.test.tsx new file mode 100644 index 0000000000000..5554f33997dc5 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data'; + +import { PrometheusDatasource } from '../../datasource'; +import PromQlLanguageProvider from '../../language_provider'; +import { EmptyLanguageProviderMock } from '../../language_provider.mock'; + +import { EXPLAIN_LABEL_FILTER_CONTENT } from './PromQueryBuilderExplained'; +import { PromQueryCodeEditor } from './PromQueryCodeEditor'; + +jest.mock('../../components/monaco-query-field/MonacoQueryFieldWrapper', () => { + const fakeQueryField = () => <div>prometheus query field</div>; + return { MonacoQueryFieldWrapper: fakeQueryField }; +}); + +function createDatasource() { + const languageProvider = new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider; + const datasource = new PrometheusDatasource( + { + url: '', + jsonData: {}, + meta: {} as DataSourcePluginMeta, + } as DataSourceInstanceSettings, + undefined, + languageProvider + ); + return { datasource, languageProvider }; +} + +function createProps(datasource: PrometheusDatasource) { + return { + datasource, + onRunQuery: () => {}, + onChange: () => {}, + showExplain: false, + }; +} + +describe('PromQueryCodeEditor', () => { + it('shows explain section when showExplain is true', async () => { + const { datasource } = createDatasource(); + const props = createProps(datasource); + props.showExplain = true; + render(<PromQueryCodeEditor {...props} query={{ expr: '', refId: 'refid', interval: '1s' }} />); + + // wait for component to render + await screen.findByRole('button'); + + expect(screen.getByText(EXPLAIN_LABEL_FILTER_CONTENT)).toBeInTheDocument(); + }); + + it('does not show explain section when showExplain is false', async () => { + const { datasource } = createDatasource(); + const props = createProps(datasource); + render(<PromQueryCodeEditor {...props} query={{ expr: '', refId: 'refid', interval: '1s' }} />); + + // wait for component to render + await screen.findByRole('button'); + + expect(screen.queryByText(EXPLAIN_LABEL_FILTER_CONTENT)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx new file mode 100644 index 0000000000000..75bc63055e648 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx @@ -0,0 +1,52 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { useStyles2 } from '@grafana/ui'; + +import { PromQueryField } from '../../components/PromQueryField'; +import { PromQueryEditorProps } from '../../components/types'; + +import { PromQueryBuilderExplained } from './PromQueryBuilderExplained'; + +type PromQueryCodeEditorProps = PromQueryEditorProps & { + showExplain: boolean; +}; + +export function PromQueryCodeEditor(props: PromQueryCodeEditorProps) { + const { query, datasource, range, onRunQuery, onChange, data, app, showExplain } = props; + const styles = useStyles2(getStyles); + + return ( + <div + data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.queryField} + className={styles.wrapper} + > + <PromQueryField + datasource={datasource} + query={query} + range={range} + onRunQuery={onRunQuery} + onChange={onChange} + history={[]} + data={data} + app={app} + /> + + {showExplain && <PromQueryBuilderExplained query={query.expr} />} + </div> + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + // This wrapper styling can be removed after the old PromQueryEditor is removed. + // This is removing margin bottom on the old legacy inline form styles + wrapper: css` + .gf-form { + margin-bottom: 0; + } + `, + }; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.test.tsx new file mode 100644 index 0000000000000..9e9079188f37b --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.test.tsx @@ -0,0 +1,256 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { cloneDeep, defaultsDeep } from 'lodash'; +import React from 'react'; + +import { CoreApp, PluginMeta, PluginType } from '@grafana/data'; + +import { PromQueryEditorProps } from '../../components/types'; +import { PrometheusDatasource } from '../../datasource'; +import PromQlLanguageProvider from '../../language_provider'; +import { EmptyLanguageProviderMock } from '../../language_provider.mock'; +import { PromQuery } from '../../types'; +import { QueryEditorMode } from '../shared/types'; + +import { EXPLAIN_LABEL_FILTER_CONTENT } from './PromQueryBuilderExplained'; +import { PromQueryEditorSelector } from './PromQueryEditorSelector'; + +// We need to mock this because it seems jest has problem importing monaco in tests +jest.mock('../../components/monaco-query-field/MonacoQueryFieldWrapper', () => { + return { + MonacoQueryFieldWrapper: () => { + return 'MonacoQueryFieldWrapper'; + }, + }; +}); + +jest.mock('app/core/store', () => { + return { + get() { + return undefined; + }, + set() {}, + getObject(key: string, defaultValue: unknown) { + return defaultValue; + }, + }; +}); + +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), + }; +}); + +const defaultQuery = { + refId: 'A', + expr: 'metric{label1="foo", label2="bar"}', +}; + +const defaultMeta: PluginMeta = { + id: '', + name: '', + type: PluginType.datasource, + info: { + author: { + name: 'tester', + }, + description: 'testing', + links: [], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '', + version: '', + }, + module: '', + baseUrl: '', +}; + +const getDefaultDatasource = (jsonDataOverrides = {}) => + new PrometheusDatasource( + { + id: 1, + uid: '', + type: 'prometheus', + name: 'prom-test', + access: 'proxy', + url: '', + jsonData: jsonDataOverrides, + meta: defaultMeta, + readOnly: false, + }, + undefined, + new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider + ); + +const defaultProps = { + datasource: getDefaultDatasource(), + query: defaultQuery, + onRunQuery: () => {}, + onChange: () => {}, +}; + +describe('PromQueryEditorSelector', () => { + it('shows code editor if expr and nothing else', async () => { + // We opt for showing code editor for queries created before this feature was added + render(<PromQueryEditorSelector {...defaultProps} />); + await expectCodeEditor(); + }); + + it('shows code editor if no expr and nothing else since defaultEditor is code', async () => { + renderWithDatasourceDefaultEditorMode(QueryEditorMode.Code); + await expectCodeEditor(); + }); + + it('shows builder if no expr and nothing else since defaultEditor is builder', async () => { + renderWithDatasourceDefaultEditorMode(QueryEditorMode.Builder); + await expectBuilder(); + }); + + it('shows code editor when code mode is set', async () => { + renderWithMode(QueryEditorMode.Code); + await expectCodeEditor(); + }); + + it('shows builder when builder mode is set', async () => { + renderWithMode(QueryEditorMode.Builder); + await expectBuilder(); + }); + + it('shows Run Queries button in Dashboards', async () => { + renderWithProps({}, { app: CoreApp.Dashboard }); + await expectRunQueriesButton(); + }); + + it('hides Run Queries button in Explore', async () => { + renderWithProps({}, { app: CoreApp.Explore }); + await expectCodeEditor(); + expectNoRunQueriesButton(); + }); + + it('hides Run Queries button in Correlations Page', async () => { + renderWithProps({}, { app: CoreApp.Correlations }); + await expectCodeEditor(); + expectNoRunQueriesButton(); + }); + + it('changes to builder mode', async () => { + const { onChange } = renderWithMode(QueryEditorMode.Code); + await switchToMode(QueryEditorMode.Builder); + expect(onChange).toBeCalledWith({ + refId: 'A', + expr: defaultQuery.expr, + range: true, + editorMode: QueryEditorMode.Builder, + }); + }); + + it('Should show raw query', async () => { + renderWithProps({ + editorMode: QueryEditorMode.Builder, + expr: 'my_metric', + }); + expect(screen.getByLabelText('selector').textContent).toBe('my_metric'); + }); + + it('Can enable explain', async () => { + renderWithMode(QueryEditorMode.Builder); + expect(screen.queryByText(EXPLAIN_LABEL_FILTER_CONTENT)).not.toBeInTheDocument(); + await userEvent.click(screen.getByLabelText('Explain')); + expect(await screen.findByText(EXPLAIN_LABEL_FILTER_CONTENT)).toBeInTheDocument(); + }); + + it('changes to code mode', async () => { + const { onChange } = renderWithMode(QueryEditorMode.Builder); + await switchToMode(QueryEditorMode.Code); + expect(onChange).toBeCalledWith({ + refId: 'A', + expr: defaultQuery.expr, + range: true, + editorMode: QueryEditorMode.Code, + }); + }); + + it('parses query when changing to builder mode', async () => { + const { rerender } = renderWithProps({ + refId: 'A', + expr: 'rate(test_metric{instance="host.docker.internal:3000"}[$__interval])', + editorMode: QueryEditorMode.Code, + }); + await switchToMode(QueryEditorMode.Builder); + rerender( + <PromQueryEditorSelector + {...defaultProps} + query={{ + refId: 'A', + expr: 'rate(test_metric{instance="host.docker.internal:3000"}[$__interval])', + editorMode: QueryEditorMode.Builder, + }} + /> + ); + + await screen.queryAllByText('test_metric'); + expect(screen.getByText('host.docker.internal:3000')).toBeInTheDocument(); + expect(screen.getByText('Rate')).toBeInTheDocument(); + expect(screen.getByText('$__interval')).toBeInTheDocument(); + }); +}); + +function renderWithMode(mode: QueryEditorMode) { + return renderWithProps({ editorMode: mode }); +} + +function renderWithDatasourceDefaultEditorMode(mode: QueryEditorMode) { + const props = { + ...defaultProps, + datasource: getDefaultDatasource({ + defaultEditor: mode, + }), + query: { + refId: 'B', + expr: '', + }, + onRunQuery: () => {}, + onChange: () => {}, + }; + render(<PromQueryEditorSelector {...props} />); +} + +function renderWithProps(overrides?: Partial<PromQuery>, componentProps: Partial<PromQueryEditorProps> = {}) { + const query = defaultsDeep(overrides ?? {}, cloneDeep(defaultQuery)); + const onChange = jest.fn(); + + const allProps = { ...defaultProps, ...componentProps }; + const stuff = render(<PromQueryEditorSelector {...allProps} query={query} onChange={onChange} />); + return { onChange, ...stuff }; +} + +async function expectCodeEditor() { + expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument(); +} + +async function expectBuilder() { + expect(await screen.findByText('Metric')).toBeInTheDocument(); +} + +async function expectRunQueriesButton() { + expect(await screen.findByRole('button', { name: /run queries/i })).toBeInTheDocument(); +} + +function expectNoRunQueriesButton() { + expect(screen.queryByRole('button', { name: /run queries/i })).not.toBeInTheDocument(); +} + +async function switchToMode(mode: QueryEditorMode) { + const label = { + [QueryEditorMode.Code]: /Code/, + [QueryEditorMode.Builder]: /Builder/, + }[mode]; + + const switchEl = screen.getByLabelText(label); + await userEvent.click(switchEl); +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.tsx new file mode 100644 index 0000000000000..f85090e0cadec --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.tsx @@ -0,0 +1,166 @@ +import { isEqual, map } from 'lodash'; +import React, { SyntheticEvent, useCallback, useEffect, useState } from 'react'; + +import { CoreApp, LoadingState, SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { EditorHeader, EditorRows, FlexItem } from '@grafana/experimental'; +import { reportInteraction } from '@grafana/runtime'; +import { Button, ConfirmModal, Space } from '@grafana/ui'; + +import { PromQueryEditorProps } from '../../components/types'; +import { PromQueryFormat } from '../../dataquery'; +import { PromQuery } from '../../types'; +import { QueryPatternsModal } from '../QueryPatternsModal'; +import { promQueryEditorExplainKey, useFlag } from '../hooks/useFlag'; +import { buildVisualQueryFromString } from '../parsing'; +import { QueryEditorModeToggle } from '../shared/QueryEditorModeToggle'; +import { QueryHeaderSwitch } from '../shared/QueryHeaderSwitch'; +import { QueryEditorMode } from '../shared/types'; +import { changeEditorMode, getQueryWithDefaults } from '../state'; + +import { PromQueryBuilderContainer } from './PromQueryBuilderContainer'; +import { PromQueryBuilderOptions } from './PromQueryBuilderOptions'; +import { PromQueryCodeEditor } from './PromQueryCodeEditor'; + +export const FORMAT_OPTIONS: Array<SelectableValue<PromQueryFormat>> = [ + { label: 'Time series', value: 'time_series' }, + { label: 'Table', value: 'table' }, + { label: 'Heatmap', value: 'heatmap' }, +]; + +export const INTERVAL_FACTOR_OPTIONS: Array<SelectableValue<number>> = map([1, 2, 3, 4, 5, 10], (value: number) => ({ + value, + label: '1/' + value, +})); + +type Props = PromQueryEditorProps; + +export const PromQueryEditorSelector = React.memo<Props>((props) => { + const { + onChange, + onRunQuery, + data, + app, + onAddQuery, + datasource: { defaultEditor }, + queries, + } = props; + + const [parseModalOpen, setParseModalOpen] = useState(false); + const [queryPatternsModalOpen, setQueryPatternsModalOpen] = useState(false); + const [dataIsStale, setDataIsStale] = useState(false); + const { flag: explain, setFlag: setExplain } = useFlag(promQueryEditorExplainKey); + + const query = getQueryWithDefaults(props.query, app, defaultEditor); + // This should be filled in from the defaults by now. + const editorMode = query.editorMode!; + + const onEditorModeChange = useCallback( + (newMetricEditorMode: QueryEditorMode) => { + reportInteraction('user_grafana_prometheus_editor_mode_clicked', { + newEditor: newMetricEditorMode, + previousEditor: query.editorMode ?? '', + newQuery: !query.expr, + app: app ?? '', + }); + + if (newMetricEditorMode === QueryEditorMode.Builder) { + const result = buildVisualQueryFromString(query.expr || ''); + // If there are errors, give user a chance to decide if they want to go to builder as that can lose some data. + if (result.errors.length) { + setParseModalOpen(true); + return; + } + } + changeEditorMode(query, newMetricEditorMode, onChange); + }, + [onChange, query, app] + ); + + useEffect(() => { + setDataIsStale(false); + }, [data]); + + const onChangeInternal = (query: PromQuery) => { + if (!isEqual(query, props.query)) { + setDataIsStale(true); + } + onChange(query); + }; + + const onShowExplainChange = (e: SyntheticEvent<HTMLInputElement>) => { + setExplain(e.currentTarget.checked); + }; + + return ( + <> + <ConfirmModal + isOpen={parseModalOpen} + title="Parsing error: Switch to the builder mode?" + body="There is a syntax error, or the query structure cannot be visualized when switching to the builder mode. Parts of the query may be lost. " + confirmText="Continue" + onConfirm={() => { + changeEditorMode(query, QueryEditorMode.Builder, onChange); + setParseModalOpen(false); + }} + onDismiss={() => setParseModalOpen(false)} + /> + <QueryPatternsModal + isOpen={queryPatternsModalOpen} + onClose={() => setQueryPatternsModalOpen(false)} + query={query} + queries={queries} + app={app} + onChange={onChange} + onAddQuery={onAddQuery} + /> + <EditorHeader> + <Button + data-testid={selectors.components.QueryBuilder.queryPatterns} + variant="secondary" + size="sm" + onClick={() => setQueryPatternsModalOpen((prevValue) => !prevValue)} + > + Kick start your query + </Button> + <div data-testid={selectors.components.DataSource.Prometheus.queryEditor.explain}> + <QueryHeaderSwitch label="Explain" value={explain} onChange={onShowExplainChange} /> + </div> + <FlexItem grow={1} /> + {app !== CoreApp.Explore && app !== CoreApp.Correlations && ( + <Button + variant={dataIsStale ? 'primary' : 'secondary'} + size="sm" + onClick={onRunQuery} + icon={data?.state === LoadingState.Loading ? 'spinner' : undefined} + disabled={data?.state === LoadingState.Loading} + > + Run queries + </Button> + )} + <div data-testid={selectors.components.DataSource.Prometheus.queryEditor.editorToggle}> + <QueryEditorModeToggle mode={editorMode} onChange={onEditorModeChange} /> + </div> + </EditorHeader> + <Space v={0.5} /> + <EditorRows> + {editorMode === QueryEditorMode.Code && ( + <PromQueryCodeEditor {...props} query={query} showExplain={explain} onChange={onChangeInternal} /> + )} + {editorMode === QueryEditorMode.Builder && ( + <PromQueryBuilderContainer + query={query} + datasource={props.datasource} + onChange={onChangeInternal} + onRunQuery={props.onRunQuery} + data={data} + showExplain={explain} + /> + )} + <PromQueryBuilderOptions query={query} app={props.app} onChange={onChange} onRunQuery={onRunQuery} /> + </EditorRows> + </> + ); +}); + +PromQueryEditorSelector.displayName = 'PromQueryEditorSelector'; diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryLegendEditor.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryLegendEditor.tsx new file mode 100644 index 0000000000000..440278b4c5027 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryLegendEditor.tsx @@ -0,0 +1,121 @@ +import React, { useRef } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { EditorField } from '@grafana/experimental'; +import { AutoSizeInput, Select } from '@grafana/ui'; + +import { LegendFormatMode } from '../../types'; + +export interface PromQueryLegendEditorProps { + legendFormat: string | undefined; + onChange: (legendFormat: string) => void; + onRunQuery: () => void; +} + +const legendModeOptions = [ + { + label: 'Auto', + value: LegendFormatMode.Auto, + description: 'Only includes unique labels', + }, + { label: 'Verbose', value: LegendFormatMode.Verbose, description: 'All label names and values' }, + { label: 'Custom', value: LegendFormatMode.Custom, description: 'Provide a naming template' }, +]; + +/** + * Tests for this component are on the parent level (PromQueryBuilderOptions). + */ +export const PromQueryLegendEditor = React.memo<PromQueryLegendEditorProps>( + ({ legendFormat, onChange, onRunQuery }) => { + const mode = getLegendMode(legendFormat); + const inputRef = useRef<HTMLInputElement | null>(null); + + const onLegendFormatChanged = (evt: React.FormEvent<HTMLInputElement>) => { + let newFormat = evt.currentTarget.value; + if (newFormat.length === 0) { + newFormat = LegendFormatMode.Auto; + } + + if (newFormat !== legendFormat) { + onChange(newFormat); + onRunQuery(); + } + }; + + const onLegendModeChanged = (value: SelectableValue<LegendFormatMode>) => { + switch (value.value!) { + case LegendFormatMode.Auto: + onChange(LegendFormatMode.Auto); + break; + case LegendFormatMode.Custom: + onChange('{{label_name}}'); + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.setSelectionRange(2, 12, 'forward'); + }, 10); + break; + case LegendFormatMode.Verbose: + onChange(''); + break; + } + onRunQuery(); + }; + + return ( + <EditorField + label="Legend" + tooltip="Series name override or template. Ex. {{hostname}} will be replaced with label value for hostname." + data-testid={selectors.components.DataSource.Prometheus.queryEditor.legend} + > + <> + {mode === LegendFormatMode.Custom && ( + <AutoSizeInput + id="legendFormat" + minWidth={22} + placeholder="auto" + defaultValue={legendFormat} + onCommitChange={onLegendFormatChanged} + ref={inputRef} + /> + )} + {mode !== LegendFormatMode.Custom && ( + <Select + inputId="legend.mode" + isSearchable={false} + placeholder="Select legend mode" + options={legendModeOptions} + width={22} + onChange={onLegendModeChanged} + value={legendModeOptions.find((x) => x.value === mode)} + /> + )} + </> + </EditorField> + ); + } +); + +PromQueryLegendEditor.displayName = 'PromQueryLegendEditor'; + +function getLegendMode(legendFormat: string | undefined) { + // This special value means the new smart minimal series naming + if (legendFormat === LegendFormatMode.Auto) { + return LegendFormatMode.Auto; + } + + // Missing or empty legend format is the old verbose behavior + if (legendFormat == null || legendFormat === '') { + return LegendFormatMode.Verbose; + } + + return LegendFormatMode.Custom; +} + +export function getLegendModeLabel(legendFormat: string | undefined) { + const mode = getLegendMode(legendFormat); + if (mode !== LegendFormatMode.Custom) { + return legendModeOptions.find((x) => x.value === mode)?.label; + } + return legendFormat; +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/QueryPreview.tsx b/packages/grafana-prometheus/src/querybuilder/components/QueryPreview.tsx new file mode 100644 index 0000000000000..0f731ec0b3db8 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/QueryPreview.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { EditorFieldGroup, EditorRow } from '@grafana/experimental'; + +import promqlGrammar from '../../promql'; +import { RawQuery } from '../shared/RawQuery'; + +export interface QueryPreviewProps { + query: string; +} + +export function QueryPreview({ query }: QueryPreviewProps) { + if (!query) { + return null; + } + + return ( + <EditorRow> + <EditorFieldGroup> + <RawQuery query={query} lang={{ grammar: promqlGrammar, name: 'promql' }} /> + </EditorFieldGroup> + </EditorRow> + ); +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/AdditionalSettings.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/AdditionalSettings.tsx new file mode 100644 index 0000000000000..c5a7d10484939 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/AdditionalSettings.tsx @@ -0,0 +1,85 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Icon, Switch, Tooltip, useTheme2 } from '@grafana/ui'; + +import { metricsModaltestIds } from './MetricsModal'; +import { placeholders } from './state/helpers'; +import { MetricsModalState } from './state/state'; + +type AdditionalSettingsProps = { + state: MetricsModalState; + onChangeFullMetaSearch: () => void; + onChangeIncludeNullMetadata: () => void; + onChangeDisableTextWrap: () => void; + onChangeUseBackend: () => void; +}; + +export function AdditionalSettings(props: AdditionalSettingsProps) { + const { state, onChangeFullMetaSearch, onChangeIncludeNullMetadata, onChangeDisableTextWrap, onChangeUseBackend } = + props; + + const theme = useTheme2(); + const styles = getStyles(theme); + + return ( + <> + <div className={styles.selectItem}> + <Switch + data-testid={metricsModaltestIds.searchWithMetadata} + value={state.fullMetaSearch} + disabled={state.useBackend || !state.hasMetadata} + onChange={() => onChangeFullMetaSearch()} + /> + <div className={styles.selectItemLabel}>{placeholders.metadataSearchSwitch}</div> + </div> + <div className={styles.selectItem}> + <Switch + value={state.includeNullMetadata} + disabled={!state.hasMetadata} + onChange={() => onChangeIncludeNullMetadata()} + /> + <div className={styles.selectItemLabel}>{placeholders.includeNullMetadata}</div> + </div> + <div className={styles.selectItem}> + <Switch value={state.disableTextWrap} onChange={() => onChangeDisableTextWrap()} /> + <div className={styles.selectItemLabel}>Disable text wrap</div> + </div> + <div className={styles.selectItem}> + <Switch + data-testid={metricsModaltestIds.setUseBackend} + value={state.useBackend} + onChange={() => onChangeUseBackend()} + /> + <div className={styles.selectItemLabel}>{placeholders.setUseBackend} </div> + <Tooltip + content={'Filter metric names by regex search, using an additional call on the Prometheus API.'} + placement="bottom-end" + > + <Icon name="info-circle" size="xs" className={styles.settingsIcon} /> + </Tooltip> + </div> + </> + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + settingsIcon: css` + color: ${theme.colors.text.secondary}; + `, + selectItem: css` + display: flex; + flex-direction: row; + align-items: center; + padding: 4px 0; + `, + selectItemLabel: css` + margin: 0 0 0 ${theme.spacing(1)}; + align-self: center; + color: ${theme.colors.text.secondary}; + font-size: 12px; + `, + }; +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/FeedbackLink.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/FeedbackLink.tsx new file mode 100644 index 0000000000000..4ba1d881147b5 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/FeedbackLink.tsx @@ -0,0 +1,40 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Icon, useStyles2, Stack } from '@grafana/ui'; + +export interface Props { + feedbackUrl?: string; +} + +export function FeedbackLink({ feedbackUrl }: Props) { + const styles = useStyles2(getStyles); + + return ( + <Stack> + <a + href={feedbackUrl} + className={styles.link} + title="The metrics explorer is new, please let us know how we can improve it" + target="_blank" + rel="noreferrer noopener" + > + <Icon name="comment-alt-message" /> Give feedback + </a> + </Stack> + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + link: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + ':hover': { + color: theme.colors.text.link, + }, + margin: `-25px 0 30px 0`, + }), + }; +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx new file mode 100644 index 0000000000000..f12d7c6708075 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx @@ -0,0 +1,261 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data'; + +import { PrometheusDatasource } from '../../../datasource'; +import PromQlLanguageProvider from '../../../language_provider'; +import { EmptyLanguageProviderMock } from '../../../language_provider.mock'; +import { PromOptions } from '../../../types'; +import { PromVisualQuery } from '../../types'; + +import { MetricsModal, metricsModaltestIds } from './MetricsModal'; + +// don't care about interaction tracking in our unit tests +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), +})); + +describe('MetricsModal', () => { + it('renders the modal', async () => { + setup(defaultQuery, listOfMetrics); + await waitFor(() => { + expect(screen.getByText('Metrics explorer')).toBeInTheDocument(); + }); + }); + + it('renders a list of metrics', async () => { + setup(defaultQuery, listOfMetrics); + await waitFor(() => { + expect(screen.getByText('all-metrics')).toBeInTheDocument(); + }); + }); + + it('renders a list of metrics filtered by labels in the PromVisualQuery', async () => { + const query: PromVisualQuery = { + metric: 'random_metric', + labels: [ + { + op: '=', + label: 'action', + value: 'add_presence', + }, + ], + operations: [], + }; + + setup(query, ['with-labels'], true); + await waitFor(() => { + expect(screen.getByText('with-labels')).toBeInTheDocument(); + }); + }); + + it('displays a type for a metric when the metric is clicked', async () => { + setup(defaultQuery, listOfMetrics); + await waitFor(() => { + expect(screen.getByText('all-metrics')).toBeInTheDocument(); + }); + + const interactiveMetric = screen.getByText('all-metrics'); + + await userEvent.click(interactiveMetric); + + expect(screen.getByText('all-metrics-type')).toBeInTheDocument(); + }); + + it('displays a description for a metric', async () => { + setup(defaultQuery, listOfMetrics); + await waitFor(() => { + expect(screen.getByText('all-metrics')).toBeInTheDocument(); + }); + + const interactiveMetric = screen.getByText('all-metrics'); + + await userEvent.click(interactiveMetric); + + expect(screen.getByText('all-metrics-help')).toBeInTheDocument(); + }); + + // Filtering + it('has a filter for selected type', async () => { + setup(defaultQuery, listOfMetrics); + + await waitFor(() => { + const selectType = screen.getByText('Filter by type'); + expect(selectType).toBeInTheDocument(); + }); + }); + + // Pagination + it('shows metrics within a range by pagination', async () => { + // default resultsPerPage is 100 + setup(defaultQuery, listOfMetrics); + await waitFor(() => { + expect(screen.getByText('all-metrics')).toBeInTheDocument(); + expect(screen.getByText('a_bucket')).toBeInTheDocument(); + expect(screen.getByText('a')).toBeInTheDocument(); + expect(screen.getByText('b')).toBeInTheDocument(); + expect(screen.getByText('c')).toBeInTheDocument(); + expect(screen.getByText('d')).toBeInTheDocument(); + expect(screen.getByText('e')).toBeInTheDocument(); + expect(screen.getByText('f')).toBeInTheDocument(); + expect(screen.getByText('g')).toBeInTheDocument(); + expect(screen.getByText('h')).toBeInTheDocument(); + }); + }); + + it('does not show metrics outside a range by pagination', async () => { + // default resultsPerPage is 10 + setup(defaultQuery, listOfMetrics); + await waitFor(() => { + const metricOutsideRange = screen.queryByText('j'); + expect(metricOutsideRange).toBeNull(); + }); + }); + + it('shows results metrics per page chosen by the user', async () => { + setup(defaultQuery, listOfMetrics); + const resultsPerPageInput = screen.getByTestId(metricsModaltestIds.resultsPerPage); + await userEvent.type(resultsPerPageInput, '12'); + const metricInsideRange = screen.getByText('j'); + expect(metricInsideRange).toBeInTheDocument(); + }); + + it('paginates lots of metrics and does not run out of memory', async () => { + const lotsOfMetrics: string[] = [...Array(100000).keys()].map((i) => '' + i); + setup(defaultQuery, lotsOfMetrics); + await waitFor(() => { + // doesn't break on loading + expect(screen.getByText('0')).toBeInTheDocument(); + }); + const resultsPerPageInput = screen.getByTestId(metricsModaltestIds.resultsPerPage); + // doesn't break on changing results per page + await userEvent.type(resultsPerPageInput, '11'); + const metricInsideRange = screen.getByText('9'); + expect(metricInsideRange).toBeInTheDocument(); + }); + + // Fuzzy search + it('searches and filter by metric name with a fuzzy search', async () => { + // search for a_bucket by name + setup(defaultQuery, listOfMetrics); + let metricAll: HTMLElement | null; + let metricABucket: HTMLElement | null; + await waitFor(() => { + metricAll = screen.getByText('all-metrics'); + metricABucket = screen.getByText('a_bucket'); + expect(metricAll).toBeInTheDocument(); + expect(metricABucket).toBeInTheDocument(); + }); + const searchMetric = screen.getByTestId(metricsModaltestIds.searchMetric); + expect(searchMetric).toBeInTheDocument(); + await userEvent.type(searchMetric, 'a_buck'); + + await waitFor(() => { + metricAll = screen.queryByText('all-metrics'); + expect(metricAll).toBeNull(); + }); + }); + + it('searches by name and description with a fuzzy search when setting is turned on', async () => { + // search for a_bucket by metadata type counter but only type countt + setup(defaultQuery, listOfMetrics); + let metricABucket: HTMLElement | null; + + await waitFor(() => { + metricABucket = screen.getByText('a_bucket'); + expect(metricABucket).toBeInTheDocument(); + }); + + const showSettingsButton = screen.getByTestId(metricsModaltestIds.showAdditionalSettings); + expect(showSettingsButton).toBeInTheDocument(); + await userEvent.click(showSettingsButton); + + const metadataSwitch = screen.getByTestId(metricsModaltestIds.searchWithMetadata); + expect(metadataSwitch).toBeInTheDocument(); + await userEvent.click(metadataSwitch); + + const searchMetric = screen.getByTestId(metricsModaltestIds.searchMetric); + expect(searchMetric).toBeInTheDocument(); + await userEvent.type(searchMetric, 'functions'); + + await waitFor(() => { + metricABucket = screen.getByText('a_bucket'); + expect(metricABucket).toBeInTheDocument(); + }); + }); +}); + +const defaultQuery: PromVisualQuery = { + metric: 'random_metric', + labels: [], + operations: [], +}; + +const listOfMetrics: string[] = ['all-metrics', 'a_bucket', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']; + +function createDatasource(withLabels?: boolean) { + const languageProvider = new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider; + + // display different results if their are labels selected in the PromVisualQuery + if (withLabels) { + languageProvider.metricsMetadata = { + 'with-labels': { + type: 'with-labels-type', + help: 'with-labels-help', + }, + }; + } else { + // all metrics + languageProvider.metricsMetadata = { + 'all-metrics': { + type: 'all-metrics-type', + help: 'all-metrics-help', + }, + a: { + type: 'counter', + help: 'a-metric-help', + }, + a_bucket: { + type: 'counter', + help: 'for functions', + }, + // missing metadata for other metrics is tested for, see below + }; + } + + const datasource = new PrometheusDatasource( + { + url: '', + jsonData: {}, + meta: {} as DataSourcePluginMeta, + } as DataSourceInstanceSettings<PromOptions>, + undefined, + languageProvider + ); + return datasource; +} + +function createProps(query: PromVisualQuery, datasource: PrometheusDatasource, metrics: string[]) { + return { + datasource, + isOpen: true, + onChange: jest.fn(), + onClose: jest.fn(), + query: query, + initialMetrics: metrics, + }; +} + +function setup(query: PromVisualQuery, metrics: string[], withlabels?: boolean) { + const withLabels: boolean = query.labels.length > 0; + const datasource = createDatasource(withLabels); + const props = createProps(query, datasource, metrics); + + // render the modal only + const { container } = render(<MetricsModal {...props} />); + + return container; +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx new file mode 100644 index 0000000000000..6e02715a8040e --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx @@ -0,0 +1,414 @@ +import { cx } from '@emotion/css'; +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import debounce from 'debounce-promise'; +import React, { useCallback, useEffect, useMemo, useReducer } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { + Button, + ButtonGroup, + Icon, + Input, + Modal, + MultiSelect, + Pagination, + Spinner, + Toggletip, + useTheme2, +} from '@grafana/ui'; + +import { PrometheusDatasource } from '../../../datasource'; +import { PromVisualQuery } from '../../types'; + +import { AdditionalSettings } from './AdditionalSettings'; +import { FeedbackLink } from './FeedbackLink'; +import { ResultsTable } from './ResultsTable'; +import { + calculatePageList, + calculateResultsPerPage, + displayedMetrics, + getBackendSearchMetrics, + placeholders, + promTypes, + setMetrics, + tracking, +} from './state/helpers'; +import { + DEFAULT_RESULTS_PER_PAGE, + initialState, + MAXIMUM_RESULTS_PER_PAGE, + MetricsModalMetadata, + // stateSlice, +} from './state/state'; +import { getStyles } from './styles'; +import { MetricsData, PromFilterOption } from './types'; +import { debouncedFuzzySearch } from './uFuzzy'; + +export type MetricsModalProps = { + datasource: PrometheusDatasource; + isOpen: boolean; + query: PromVisualQuery; + onClose: () => void; + onChange: (query: PromVisualQuery) => void; + initialMetrics: string[]; +}; + +export const MetricsModal = (props: MetricsModalProps) => { + const { datasource, isOpen, onClose, onChange, query, initialMetrics } = props; + + const [state, dispatch] = useReducer(stateSlice.reducer, initialState(query)); + + const theme = useTheme2(); + const styles = getStyles(theme, state.disableTextWrap); + + /** + * loads metrics and metadata on opening modal and switching off useBackend + */ + const updateMetricsMetadata = useCallback(async () => { + // *** Loading Gif + dispatch(setIsLoading(true)); + + const data: MetricsModalMetadata = await setMetrics(datasource, query, initialMetrics); + dispatch( + buildMetrics({ + isLoading: false, + hasMetadata: data.hasMetadata, + metrics: data.metrics, + metaHaystackDictionary: data.metaHaystackDictionary, + nameHaystackDictionary: data.nameHaystackDictionary, + totalMetricCount: data.metrics.length, + filteredMetricCount: data.metrics.length, + }) + ); + }, [query, datasource, initialMetrics]); + + useEffect(() => { + updateMetricsMetadata(); + }, [updateMetricsMetadata]); + + const typeOptions: SelectableValue[] = promTypes.map((t: PromFilterOption) => { + return { + value: t.value, + label: t.value, + description: t.description, + }; + }); + + /** + * The backend debounced search + */ + const debouncedBackendSearch = useMemo( + () => + debounce(async (metricText: string) => { + dispatch(setIsLoading(true)); + + const metrics = await getBackendSearchMetrics(metricText, query.labels, datasource); + + dispatch( + filterMetricsBackend({ + metrics: metrics, + filteredMetricCount: metrics.length, + isLoading: false, + }) + ); + }, datasource.getDebounceTimeInMilliseconds()), + [datasource, query] + ); + + function fuzzyNameDispatch(haystackData: string[][]) { + dispatch(setNameHaystack(haystackData)); + } + + function fuzzyMetaDispatch(haystackData: string[][]) { + dispatch(setMetaHaystack(haystackData)); + } + + function searchCallback(query: string, fullMetaSearchVal: boolean) { + if (state.useBackend && query === '') { + // get all metrics data if a user erases everything in the input + updateMetricsMetadata(); + } else if (state.useBackend) { + debouncedBackendSearch(query); + } else { + // search either the names or all metadata + // fuzzy search go! + if (fullMetaSearchVal) { + debouncedFuzzySearch(Object.keys(state.metaHaystackDictionary), query, fuzzyMetaDispatch); + } else { + debouncedFuzzySearch(Object.keys(state.nameHaystackDictionary), query, fuzzyNameDispatch); + } + } + } + + /* Settings switches */ + const additionalSettings = ( + <AdditionalSettings + state={state} + onChangeFullMetaSearch={() => { + const newVal = !state.fullMetaSearch; + dispatch(setFullMetaSearch(newVal)); + onChange({ ...query, fullMetaSearch: newVal }); + searchCallback(state.fuzzySearchQuery, newVal); + }} + onChangeIncludeNullMetadata={() => { + dispatch(setIncludeNullMetadata(!state.includeNullMetadata)); + onChange({ ...query, includeNullMetadata: !state.includeNullMetadata }); + }} + onChangeDisableTextWrap={() => { + dispatch(setDisableTextWrap()); + onChange({ ...query, disableTextWrap: !state.disableTextWrap }); + tracking('grafana_prom_metric_encycopedia_disable_text_wrap_interaction', state, ''); + }} + onChangeUseBackend={() => { + const newVal = !state.useBackend; + dispatch(setUseBackend(newVal)); + onChange({ ...query, useBackend: newVal }); + if (newVal === false) { + // rebuild the metrics metadata if we turn off useBackend + updateMetricsMetadata(); + } else { + // check if there is text in the browse search and update + if (state.fuzzySearchQuery !== '') { + debouncedBackendSearch(state.fuzzySearchQuery); + } + // otherwise wait for user typing + } + }} + /> + ); + + return ( + <Modal + data-testid={metricsModaltestIds.metricModal} + isOpen={isOpen} + title="Metrics explorer" + onDismiss={onClose} + aria-label="Browse metrics" + className={styles.modal} + > + <FeedbackLink feedbackUrl="https://forms.gle/DEMAJHoAMpe3e54CA" /> + <div + className={styles.inputWrapper} + data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.metricsExplorer} + > + <div className={cx(styles.inputItem, styles.inputItemFirst)}> + <Input + autoFocus={true} + data-testid={metricsModaltestIds.searchMetric} + placeholder={placeholders.browse} + value={state.fuzzySearchQuery} + onInput={(e) => { + const value = e.currentTarget.value ?? ''; + dispatch(setFuzzySearchQuery(value)); + searchCallback(value, state.fullMetaSearch); + }} + /> + </div> + {state.hasMetadata && ( + <div className={styles.inputItem}> + <MultiSelect + data-testid={metricsModaltestIds.selectType} + inputId="my-select" + options={typeOptions} + value={state.selectedTypes} + placeholder={placeholders.type} + onChange={(v) => dispatch(setSelectedTypes(v))} + /> + </div> + )} + <div> + <Spinner className={`${styles.loadingSpinner} ${state.isLoading ? styles.visible : ''}`} /> + </div> + <div className={styles.inputItem}> + <Toggletip + aria-label="Additional settings" + content={additionalSettings} + placement="bottom-end" + closeButton={false} + > + <ButtonGroup className={styles.settingsBtn}> + <Button + variant="secondary" + size="md" + onClick={() => dispatch(showAdditionalSettings())} + data-testid={metricsModaltestIds.showAdditionalSettings} + className={styles.noBorder} + > + Additional Settings + </Button> + <Button + className={styles.noBorder} + variant="secondary" + icon={state.showAdditionalSettings ? 'angle-up' : 'angle-down'} + /> + </ButtonGroup> + </Toggletip> + </div> + </div> + <div className={styles.resultsData}> + {query.metric && <i className={styles.currentlySelected}>Currently selected: {query.metric}</i>} + {query.labels.length > 0 && ( + <div className={styles.resultsDataFiltered}> + <Icon name="info-circle" size="sm" /> + <div className={styles.resultsDataFilteredText}> +  These metrics have been pre-filtered by labels chosen in the label filters. + </div> + </div> + )} + </div> + <div className={styles.results}> + {state.metrics && ( + <ResultsTable + metrics={displayedMetrics(state, dispatch)} + onChange={onChange} + onClose={onClose} + query={query} + state={state} + disableTextWrap={state.disableTextWrap} + /> + )} + </div> + <div className={styles.resultsFooter}> + <div className={styles.resultsAmount}> + Showing {state.filteredMetricCount} of {state.totalMetricCount} results + </div> + <Pagination + currentPage={state.pageNum ?? 1} + numberOfPages={calculatePageList(state).length} + onNavigate={(val: number) => { + const page = val ?? 1; + dispatch(setPageNum(page)); + }} + /> + <div className={styles.resultsPerPageWrapper}> + <p className={styles.resultsPerPageLabel}># Results per page </p> + <Input + data-testid={metricsModaltestIds.resultsPerPage} + value={calculateResultsPerPage(state.resultsPerPage, DEFAULT_RESULTS_PER_PAGE, MAXIMUM_RESULTS_PER_PAGE)} + placeholder="results per page" + width={10} + title={'The maximum results per page is ' + MAXIMUM_RESULTS_PER_PAGE} + type="number" + onInput={(e) => { + const value = +e.currentTarget.value; + + if (isNaN(value) || value >= MAXIMUM_RESULTS_PER_PAGE) { + return; + } + + dispatch(setResultsPerPage(value)); + }} + /> + </div> + </div> + </Modal> + ); +}; + +export const metricsModaltestIds = { + metricModal: 'metric-modal', + searchMetric: 'search-metric', + searchWithMetadata: 'search-with-metadata', + selectType: 'select-type', + metricCard: 'metric-card', + useMetric: 'use-metric', + searchPage: 'search-page', + resultsPerPage: 'results-per-page', + setUseBackend: 'set-use-backend', + showAdditionalSettings: 'show-additional-settings', +}; + +const stateSlice = createSlice({ + name: 'metrics-modal-state', + initialState: initialState(), + reducers: { + filterMetricsBackend: ( + state, + action: PayloadAction<{ + metrics: MetricsData; + filteredMetricCount: number; + isLoading: boolean; + }> + ) => { + state.metrics = action.payload.metrics; + state.filteredMetricCount = action.payload.filteredMetricCount; + state.isLoading = action.payload.isLoading; + }, + buildMetrics: (state, action: PayloadAction<MetricsModalMetadata>) => { + state.isLoading = action.payload.isLoading; + state.metrics = action.payload.metrics; + state.hasMetadata = action.payload.hasMetadata; + state.metaHaystackDictionary = action.payload.metaHaystackDictionary; + state.nameHaystackDictionary = action.payload.nameHaystackDictionary; + state.totalMetricCount = action.payload.totalMetricCount; + state.filteredMetricCount = action.payload.filteredMetricCount; + }, + setIsLoading: (state, action: PayloadAction<boolean>) => { + state.isLoading = action.payload; + }, + setFilteredMetricCount: (state, action: PayloadAction<number>) => { + state.filteredMetricCount = action.payload; + }, + setResultsPerPage: (state, action: PayloadAction<number>) => { + state.resultsPerPage = action.payload; + }, + setPageNum: (state, action: PayloadAction<number>) => { + state.pageNum = action.payload; + }, + setFuzzySearchQuery: (state, action: PayloadAction<string>) => { + state.fuzzySearchQuery = action.payload; + state.pageNum = 1; + }, + setNameHaystack: (state, action: PayloadAction<string[][]>) => { + state.nameHaystackOrder = action.payload[0]; + state.nameHaystackMatches = action.payload[1]; + }, + setMetaHaystack: (state, action: PayloadAction<string[][]>) => { + state.metaHaystackOrder = action.payload[0]; + state.metaHaystackMatches = action.payload[1]; + }, + setFullMetaSearch: (state, action: PayloadAction<boolean>) => { + state.fullMetaSearch = action.payload; + state.pageNum = 1; + }, + setIncludeNullMetadata: (state, action: PayloadAction<boolean>) => { + state.includeNullMetadata = action.payload; + state.pageNum = 1; + }, + setSelectedTypes: (state, action: PayloadAction<Array<SelectableValue<string>>>) => { + state.selectedTypes = action.payload; + state.pageNum = 1; + }, + setUseBackend: (state, action: PayloadAction<boolean>) => { + state.useBackend = action.payload; + state.fullMetaSearch = false; + state.pageNum = 1; + }, + setDisableTextWrap: (state) => { + state.disableTextWrap = !state.disableTextWrap; + }, + showAdditionalSettings: (state) => { + state.showAdditionalSettings = !state.showAdditionalSettings; + }, + }, +}); + +// actions to update the state +export const { + setIsLoading, + buildMetrics, + filterMetricsBackend, + setResultsPerPage, + setPageNum, + setFuzzySearchQuery, + setNameHaystack, + setMetaHaystack, + setFullMetaSearch, + setIncludeNullMetadata, + setSelectedTypes, + setUseBackend, + setDisableTextWrap, + showAdditionalSettings, + setFilteredMetricCount, +} = stateSlice.actions; diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/ResultsTable.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/ResultsTable.tsx new file mode 100644 index 0000000000000..a474098bbc079 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/ResultsTable.tsx @@ -0,0 +1,252 @@ +import { css } from '@emotion/css'; +import React, { ReactElement } from 'react'; +import Highlighter from 'react-highlight-words'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, Icon, Tooltip, useTheme2 } from '@grafana/ui'; + +import { docsTip } from '../../../configuration/ConfigEditor'; +import { PromVisualQuery } from '../../types'; + +import { tracking } from './state/helpers'; +import { MetricsModalState } from './state/state'; +import { MetricData, MetricsData } from './types'; + +type ResultsTableProps = { + metrics: MetricsData; + onChange: (query: PromVisualQuery) => void; + onClose: () => void; + query: PromVisualQuery; + state: MetricsModalState; + disableTextWrap: boolean; +}; + +export function ResultsTable(props: ResultsTableProps) { + const { metrics, onChange, onClose, query, state, disableTextWrap } = props; + + const theme = useTheme2(); + const styles = getStyles(theme, disableTextWrap); + + function selectMetric(metric: MetricData) { + if (metric.value) { + onChange({ ...query, metric: metric.value }); + tracking('grafana_prom_metric_encycopedia_tracking', state, metric.value); + onClose(); + } + } + + function metaRows(metric: MetricData) { + if (state.fullMetaSearch && metric) { + return ( + <> + <td>{displayType(metric.type ?? '')}</td> + <td> + <Highlighter + textToHighlight={metric.description ?? ''} + searchWords={state.metaHaystackMatches} + autoEscape + highlightClassName={styles.matchHighLight} + /> + </td> + </> + ); + } else { + return ( + <> + <td>{displayType(metric.type ?? '')}</td> + <td>{metric.description ?? ''}</td> + </> + ); + } + } + + function addHelpIcon(fullType: string, descriptiveType: string, link: string) { + return ( + <> + {fullType} + <span className={styles.tooltipSpace}> + <Tooltip + content={ + <> + When creating a {descriptiveType}, Prometheus exposes multiple series with the type counter.{' '} + {docsTip(link)} + </> + } + placement="bottom-start" + interactive={true} + > + <Icon name="info-circle" size="xs" /> + </Tooltip> + </span> + </> + ); + } + + function displayType(type: string | null) { + if (!type) { + return ''; + } + + if (type.includes('(summary)')) { + return addHelpIcon(type, 'summary', 'https://prometheus.io/docs/concepts/metric_types/#summary'); + } + + if (type.includes('(histogram)')) { + return addHelpIcon(type, 'histogram', 'https://prometheus.io/docs/concepts/metric_types/#histogram'); + } + + return type; + } + + function noMetricsMessages(): ReactElement { + let message; + + if (!state.fuzzySearchQuery) { + message = 'There are no metrics found in the data source.'; + } + + if (query.labels.length > 0) { + message = 'There are no metrics found. Try to expand your label filters.'; + } + + if (state.fuzzySearchQuery || state.selectedTypes.length > 0) { + message = 'There are no metrics found. Try to expand your search and filters.'; + } + + return ( + <tr className={styles.noResults}> + <td colSpan={3}>{message}</td> + </tr> + ); + } + + function textHighlight(state: MetricsModalState) { + if (state.useBackend) { + // highlight the input only for the backend search + // this highlight is equivalent to how the metric select highlights + // look into matching on regex input + return [state.fuzzySearchQuery]; + } else if (state.fullMetaSearch) { + // highlight the matches in the ufuzzy metaHaystack + return state.metaHaystackMatches; + } else { + // highlight the ufuzzy name matches + return state.nameHaystackMatches; + } + } + + return ( + <table className={styles.table}> + <thead className={styles.stickyHeader}> + <tr> + <th className={`${styles.nameWidth} ${styles.tableHeaderPadding}`}>Name</th> + {state.hasMetadata && ( + <> + <th className={`${styles.typeWidth} ${styles.tableHeaderPadding}`}>Type</th> + <th className={`${styles.descriptionWidth} ${styles.tableHeaderPadding}`}>Description</th> + </> + )} + <th className={styles.selectButtonWidth}> </th> + </tr> + </thead> + <tbody> + <> + {metrics.length > 0 && + metrics.map((metric: MetricData, idx: number) => { + return ( + <tr key={metric?.value ?? idx} className={styles.row}> + <td className={styles.nameOverflow}> + <Highlighter + textToHighlight={metric?.value ?? ''} + searchWords={textHighlight(state)} + autoEscape + highlightClassName={styles.matchHighLight} + /> + </td> + {state.hasMetadata && metaRows(metric)} + <td> + <Button + size="md" + variant="secondary" + onClick={() => selectMetric(metric)} + className={styles.centerButton} + > + Select + </Button> + </td> + </tr> + ); + })} + {metrics.length === 0 && !state.isLoading && noMetricsMessages()} + </> + </tbody> + </table> + ); +} + +const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { + return { + table: css` + ${disableTextWrap ? '' : 'table-layout: fixed;'} + border-radius: ${theme.shape.radius.default}; + width: 100%; + white-space: ${disableTextWrap ? 'nowrap' : 'normal'}; + td { + padding: ${theme.spacing(1)}; + } + + td, + th { + min-width: ${theme.spacing(3)}; + border-bottom: 1px solid ${theme.colors.border.weak}; + } + `, + row: css` + label: row; + border-bottom: 1px solid ${theme.colors.border.weak} + &:last-child { + border-bottom: 0; + } + `, + tableHeaderPadding: css` + padding: 8px; + `, + matchHighLight: css` + background: inherit; + color: ${theme.components.textHighlight.text}; + background-color: ${theme.components.textHighlight.background}; + `, + nameWidth: css` + ${disableTextWrap ? '' : 'width: 37.5%;'} + `, + nameOverflow: css` + ${disableTextWrap ? '' : 'overflow-wrap: anywhere;'} + `, + typeWidth: css` + ${disableTextWrap ? '' : 'width: 15%;'} + `, + descriptionWidth: css` + ${disableTextWrap ? '' : 'width: 35%;'} + `, + selectButtonWidth: css` + ${disableTextWrap ? '' : 'width: 12.5%;'} + `, + stickyHeader: css` + position: sticky; + top: 0; + background-color: ${theme.colors.background.primary}; + `, + noResults: css` + text-align: center; + color: ${theme.colors.text.secondary}; + `, + tooltipSpace: css` + margin-left: 4px; + `, + centerButton: css` + display: block; + margin: auto; + border: none; + `, + }; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/index.ts b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/index.ts new file mode 100644 index 0000000000000..3f073bdca3ff8 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/index.ts @@ -0,0 +1 @@ +export * from './MetricsModal'; diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/helpers.ts b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/helpers.ts new file mode 100644 index 0000000000000..e8a40d02b0cf5 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/helpers.ts @@ -0,0 +1,260 @@ +import { AnyAction } from '@reduxjs/toolkit'; + +import { reportInteraction } from '@grafana/runtime'; + +import { PrometheusDatasource } from '../../../../datasource'; +import { getMetadataHelp, getMetadataType } from '../../../../language_provider'; +import { regexifyLabelValuesQueryString } from '../../../parsingUtils'; +import { QueryBuilderLabelFilter } from '../../../shared/types'; +import { PromVisualQuery } from '../../../types'; +import { setFilteredMetricCount } from '../MetricsModal'; +import { HaystackDictionary, MetricData, MetricsData, PromFilterOption } from '../types'; + +import { MetricsModalMetadata, MetricsModalState } from './state'; + +// const { setFilteredMetricCount } = stateSlice.actions; + +export async function setMetrics( + datasource: PrometheusDatasource, + query: PromVisualQuery, + initialMetrics?: string[] +): Promise<MetricsModalMetadata> { + // metadata is set in the metric select now + // use this to disable metadata search and display + let hasMetadata = true; + const metadata = datasource.languageProvider.metricsMetadata; + if (metadata && Object.keys(metadata).length === 0) { + hasMetadata = false; + } + + let nameHaystackDictionaryData: HaystackDictionary = {}; + let metaHaystackDictionaryData: HaystackDictionary = {}; + + // pass in metrics from getMetrics in the query builder, reduced in the metric select + let metricsData: MetricsData | undefined; + + metricsData = initialMetrics?.map((m: string) => { + const metricData = buildMetricData(m, datasource); + + const metaDataString = `${m}¦${metricData.description}`; + + nameHaystackDictionaryData[m] = metricData; + metaHaystackDictionaryData[metaDataString] = metricData; + + return metricData; + }); + + return { + isLoading: false, + hasMetadata: hasMetadata, + metrics: metricsData ?? [], + metaHaystackDictionary: metaHaystackDictionaryData, + nameHaystackDictionary: nameHaystackDictionaryData, + totalMetricCount: metricsData?.length ?? 0, + filteredMetricCount: metricsData?.length ?? 0, + }; +} + +/** + * Builds the metric data object with type and description + * + * @param metric The metric name + * @param datasource The Prometheus datasource for mapping metradata to the metric name + * @returns A MetricData object. + */ +function buildMetricData(metric: string, datasource: PrometheusDatasource): MetricData { + let type = getMetadataType(metric, datasource.languageProvider.metricsMetadata!); + + const description = getMetadataHelp(metric, datasource.languageProvider.metricsMetadata!); + + ['histogram', 'summary'].forEach((t) => { + if (description?.toLowerCase().includes(t) && type !== t) { + type += ` (${t})`; + } + }); + + const metricData: MetricData = { + value: metric, + type: type, + description: description, + }; + + return metricData; +} + +/** + * The filtered and paginated metrics displayed in the modal + * */ +export function displayedMetrics(state: MetricsModalState, dispatch: React.Dispatch<AnyAction>) { + const filteredSorted: MetricsData = filterMetrics(state); + + if (!state.isLoading && state.filteredMetricCount !== filteredSorted.length) { + dispatch(setFilteredMetricCount(filteredSorted.length)); + } + + return sliceMetrics(filteredSorted, state.pageNum, state.resultsPerPage); +} + +/** + * Filter the metrics with all the options, fuzzy, type, null metadata + */ +export function filterMetrics(state: MetricsModalState): MetricsData { + let filteredMetrics: MetricsData = state.metrics; + + if (state.fuzzySearchQuery && !state.useBackend) { + if (state.fullMetaSearch) { + filteredMetrics = state.metaHaystackOrder.map((needle: string) => state.metaHaystackDictionary[needle]); + } else { + filteredMetrics = state.nameHaystackOrder.map((needle: string) => state.nameHaystackDictionary[needle]); + } + } + + if (state.selectedTypes.length > 0) { + filteredMetrics = filteredMetrics.filter((m: MetricData, idx) => { + // Matches type + const matchesSelectedType = state.selectedTypes.some((t) => { + if (m.type && t.value) { + return m.type.includes(t.value); + } + + if (!m.type && t.value === 'no type') { + return true; + } + + return false; + }); + + // when a user filters for type, only return metrics with defined types + return matchesSelectedType; + }); + } + + if (!state.includeNullMetadata) { + filteredMetrics = filteredMetrics.filter((m: MetricData) => { + return m.type !== undefined && m.description !== undefined; + }); + } + + return filteredMetrics; +} + +export function calculatePageList(state: MetricsModalState) { + if (!state.metrics.length) { + return []; + } + + const calcResultsPerPage: number = state.resultsPerPage === 0 ? 1 : state.resultsPerPage; + + const pages = Math.floor(filterMetrics(state).length / calcResultsPerPage) + 1; + + return [...Array(pages).keys()].map((i) => i + 1); +} + +export function sliceMetrics(metrics: MetricsData, pageNum: number, resultsPerPage: number) { + const calcResultsPerPage: number = resultsPerPage === 0 ? 1 : resultsPerPage; + const start: number = pageNum === 1 ? 0 : (pageNum - 1) * calcResultsPerPage; + const end: number = start + calcResultsPerPage; + return metrics.slice(start, end); +} + +export const calculateResultsPerPage = (results: number, defaultResults: number, max: number) => { + if (results < 1) { + return 1; + } + + if (results > max) { + return max; + } + + return results ?? defaultResults; +}; + +/** + * The backend query that replaces the uFuzzy search when the option 'useBackend' has been selected + * this is a regex search either to the series or labels Prometheus endpoint + * depending on which the Prometheus type or version supports + * @param metricText + * @param labels + * @param datasource + */ +export async function getBackendSearchMetrics( + metricText: string, + labels: QueryBuilderLabelFilter[], + datasource: PrometheusDatasource +): Promise<Array<{ value: string }>> { + const queryString = regexifyLabelValuesQueryString(metricText); + + const labelsParams = labels.map((label) => { + return `,${label.label}="${label.value}"`; + }); + + const params = `label_values({__name__=~".*${queryString}"${labels ? labelsParams.join() : ''}},__name__)`; + + const results = datasource.metricFindQuery(params); + + return await results.then((results) => { + return results.map((result) => buildMetricData(result.text, datasource)); + }); +} + +export function tracking(event: string, state?: MetricsModalState | null, metric?: string, query?: PromVisualQuery) { + switch (event) { + case 'grafana_prom_metric_encycopedia_tracking': + reportInteraction(event, { + metric: metric, + hasMetadata: state?.hasMetadata, + totalMetricCount: state?.totalMetricCount, + fuzzySearchQuery: state?.fuzzySearchQuery, + fullMetaSearch: state?.fullMetaSearch, + selectedTypes: state?.selectedTypes, + useRegexSearch: state?.useBackend, + includeResultsWithoutMetadata: state?.includeNullMetadata, + }); + case 'grafana_prom_metric_encycopedia_disable_text_wrap_interaction': + reportInteraction(event, { + disableTextWrap: state?.disableTextWrap, + }); + case 'grafana_prometheus_metric_encyclopedia_open': + reportInteraction(event, { + query: query, + }); + } +} + +export const promTypes: PromFilterOption[] = [ + { + value: 'counter', + description: + 'A cumulative metric that represents a single monotonically increasing counter whose value can only increase or be reset to zero on restart.', + }, + { + value: 'gauge', + description: 'A metric that represents a single numerical value that can arbitrarily go up and down.', + }, + { + value: 'histogram', + description: + 'A histogram samples observations (usually things like request durations or response sizes) and counts them in configurable buckets.', + }, + { + value: 'summary', + description: + 'A summary samples observations (usually things like request durations and response sizes) and can calculate configurable quantiles over a sliding time window.', + }, + { + value: 'unknown', + description: 'These metrics have been given the type unknown in the metadata.', + }, + { + value: 'no type', + description: 'These metrics have no defined type in the metadata.', + }, +]; + +export const placeholders = { + browse: 'Search metrics by name', + metadataSearchSwitch: 'Include description in search', + type: 'Filter by type', + includeNullMetadata: 'Include results with no metadata', + setUseBackend: 'Enable regex search', +}; diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/state.ts b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/state.ts new file mode 100644 index 0000000000000..f22bd73ea4b30 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/state.ts @@ -0,0 +1,116 @@ +import { SelectableValue } from '@grafana/data'; + +import { PromVisualQuery } from '../../../types'; +import { HaystackDictionary, MetricsData } from '../types'; + +export const DEFAULT_RESULTS_PER_PAGE = 100; +export const MAXIMUM_RESULTS_PER_PAGE = 1000; + +/** + * Initial state for the metrics explorer + * @returns + */ +export function initialState(query?: PromVisualQuery): MetricsModalState { + return { + isLoading: true, + metrics: [], + hasMetadata: true, + metaHaystackDictionary: {}, + metaHaystackMatches: [], + metaHaystackOrder: [], + nameHaystackDictionary: {}, + nameHaystackOrder: [], + nameHaystackMatches: [], + totalMetricCount: 0, + filteredMetricCount: null, + resultsPerPage: DEFAULT_RESULTS_PER_PAGE, + pageNum: 1, + fuzzySearchQuery: '', + fullMetaSearch: query?.fullMetaSearch ?? false, + includeNullMetadata: query?.includeNullMetadata ?? true, + selectedTypes: [], + useBackend: query?.useBackend ?? false, + disableTextWrap: query?.disableTextWrap ?? false, + showAdditionalSettings: false, + }; +} + +/** + * The metrics explorer state object + */ +export interface MetricsModalState { + /** Used for the loading spinner */ + isLoading: boolean; + /** + * Initial collection of metrics. + * The frontend filters do not impact this, but + * it is reduced by the backend search. + */ + metrics: MetricsData; + /** Field for disabling type select and switches that rely on metadata */ + hasMetadata: boolean; + /** Used to display metrics and help with fuzzy order */ + nameHaystackDictionary: HaystackDictionary; + /** Used to sort name fuzzy search by relevance */ + nameHaystackOrder: string[]; + /** Used to highlight text in fuzzy matches */ + nameHaystackMatches: string[]; + /** Used to display metrics and help with fuzzy order for search across all metadata */ + metaHaystackDictionary: HaystackDictionary; + /** Used to sort meta fuzzy search by relevance */ + metaHaystackOrder: string[]; + /** Used to highlight text in fuzzy matches */ + metaHaystackMatches: string[]; + /** Total results computed on initialization */ + totalMetricCount: number; + /** Set after filtering metrics */ + filteredMetricCount: number | null; + /** Pagination field for showing results in table */ + resultsPerPage: number; + /** Pagination field */ + pageNum: number; + /** The text query used to match metrics */ + fuzzySearchQuery: string; + /** Enables the fuzzy meatadata search */ + fullMetaSearch: boolean; + /** Includes results that are missing type and description */ + includeNullMetadata: boolean; + /** Filter by prometheus type */ + selectedTypes: Array<SelectableValue<string>>; + /** Filter by the series match endpoint instead of the fuzzy search */ + useBackend: boolean; + /** Disable text wrap for descriptions in the results table */ + disableTextWrap: boolean; + /** Display toggle switches for settings */ + showAdditionalSettings: boolean; +} + +/** + * Type for the useEffect get metadata function + */ +export type MetricsModalMetadata = { + isLoading: boolean; + metrics: MetricsData; + hasMetadata: boolean; + metaHaystackDictionary: HaystackDictionary; + nameHaystackDictionary: HaystackDictionary; + totalMetricCount: number; + filteredMetricCount: number | null; +}; + +// for updating the settings in the PromQuery model +export function getSettings(visQuery: PromVisualQuery): MetricsModalSettings { + return { + useBackend: visQuery?.useBackend ?? false, + disableTextWrap: visQuery?.disableTextWrap ?? false, + fullMetaSearch: visQuery?.fullMetaSearch ?? false, + includeNullMetadata: visQuery.includeNullMetadata ?? false, + }; +} + +export type MetricsModalSettings = { + useBackend?: boolean; + disableTextWrap?: boolean; + fullMetaSearch?: boolean; + includeNullMetadata?: boolean; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/styles.ts b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/styles.ts new file mode 100644 index 0000000000000..e8eb2fe86dd76 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/styles.ts @@ -0,0 +1,101 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { + return { + modal: css` + width: 85vw; + ${theme.breakpoints.down('md')} { + width: 100%; + } + ${theme.breakpoints.up('xl')} { + width: 60%; + } + `, + inputWrapper: css` + display: flex; + flex-direction: row; + flex-wrap: wrap; + `, + inputItemFirst: css` + flex-basis: 40%; + padding-right: 16px; + ${theme.breakpoints.down('md')} { + padding-right: 0px; + padding-bottom: 16px; + } + `, + inputItem: css` + flex-grow: 1; + flex-basis: 20%; + ${theme.breakpoints.down('md')} { + min-width: 100%; + } + `, + selectWrapper: css` + margin-bottom: ${theme.spacing(1)}; + `, + resultsAmount: css` + color: ${theme.colors.text.secondary}; + font-size: 0.85rem; + padding: 0 0 4px 0; + `, + resultsData: css` + margin: 4px 0 ${theme.spacing(2)} 0; + `, + resultsDataCount: css` + margin: 0; + `, + resultsDataFiltered: css` + color: ${theme.colors.text.secondary}; + text-align: center; + border: solid 1px rgba(204, 204, 220, 0.25); + padding: 7px; + `, + resultsDataFilteredText: css` + display: inline; + vertical-align: text-top; + `, + results: css` + height: calc(80vh - 310px); + overflow-y: scroll; + `, + resultsFooter: css` + margin-top: 24px; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + position: sticky; + `, + currentlySelected: css` + color: grey; + opacity: 75%; + font-size: 0.75rem; + `, + loadingSpinner: css` + visibility: hidden; + `, + visible: css` + visibility: visible; + `, + settingsBtn: css` + float: right; + `, + noBorder: css` + border: none; + `, + resultsPerPageLabel: css` + color: ${theme.colors.text.secondary}; + opacity: 75%; + padding-top: 5px; + font-size: 0.85rem; + margin-right: 8px; + `, + resultsPerPageWrapper: css` + display: flex; + `, + }; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/types.ts b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/types.ts new file mode 100644 index 0000000000000..d4be9a4863869 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/types.ts @@ -0,0 +1,30 @@ +export type MetricsData = MetricData[]; + +export type MetricData = { + value: string; + type?: string | null; + description?: string; +}; + +export type PromFilterOption = { + value: string; + description: string; +}; + +export interface HaystackDictionary { + [needle: string]: MetricData; +} + +export type UFuzzyInfo = { + idx: number[]; + start: number[]; + chars: number[]; + terms: number[]; + interIns: number[]; + intraIns: number[]; + interLft2: number[]; + interRgt2: number[]; + interLft1: number[]; + interRgt1: number[]; + ranges: number[][]; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/uFuzzy.ts b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/uFuzzy.ts new file mode 100644 index 0000000000000..7c7e5eb44ea2b --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/uFuzzy.ts @@ -0,0 +1,45 @@ +import uFuzzy from '@leeoniya/ufuzzy'; +import { debounce as debounceLodash } from 'lodash'; + +const uf = new uFuzzy({ + intraMode: 1, + intraIns: 1, + intraSub: 1, + intraTrn: 1, + intraDel: 1, +}); + +export function fuzzySearch(haystack: string[], query: string, dispatcher: (data: string[][]) => void) { + const [idxs, info, order] = uf.search(haystack, query, 0, 1e5); + + let haystackOrder: string[] = []; + let matchesSet: Set<string> = new Set(); + if (idxs && order) { + /** + * get the fuzzy matches for hilighting + * @param part + * @param matched + */ + const mark = (part: string, matched: boolean) => { + if (matched) { + matchesSet.add(part); + } + }; + + // Iterate to create the order of needles(queries) and the matches + for (let i = 0; i < order.length; i++) { + let infoIdx = order[i]; + + /** Evaluate the match, get the matches for highlighting */ + uFuzzy.highlight(haystack[info.idx[infoIdx]], info.ranges[infoIdx], mark); + /** Get the order */ + haystackOrder.push(haystack[info.idx[infoIdx]]); + } + + dispatcher([haystackOrder, [...matchesSet]]); + } else if (!query) { + dispatcher([[], []]); + } +} + +export const debouncedFuzzySearch = debounceLodash(fuzzySearch, 300); diff --git a/packages/grafana-prometheus/src/querybuilder/components/promQail/PromQail.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/promQail/PromQail.test.tsx new file mode 100644 index 0000000000000..b4056fb8bca2e --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/promQail/PromQail.test.tsx @@ -0,0 +1,148 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data'; + +import { PrometheusDatasource } from '../../../datasource'; +import PromQlLanguageProvider from '../../../language_provider'; +import { EmptyLanguageProviderMock } from '../../../language_provider.mock'; +import { PromOptions } from '../../../types'; +import { PromVisualQuery } from '../../types'; + +import { PromQail, queryAssistanttestIds } from './PromQail'; + +// don't care about interaction tracking in our unit tests +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), +})); + +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +describe('PromQail', () => { + it('renders the drawer', async () => { + setup(defaultQuery); + await waitFor(() => { + expect(screen.getByText('Query advisor')).toBeInTheDocument(); + }); + }); + + it('shows an option to not show security warning', async () => { + setup(defaultQuery); + await waitFor(() => { + expect(screen.getByText("Don't show this message again")).toBeInTheDocument(); + }); + }); + + it('shows selected metric and asks for a prompt', async () => { + setup(defaultQuery); + + await clickSecurityButton(); + + await waitFor(() => { + expect(screen.getByText('random_metric')).toBeInTheDocument(); + expect(screen.getByText('Do you know what you want to query?')).toBeInTheDocument(); + }); + }); + + it('displays a prompt when the user knows what they want to query', async () => { + setup(defaultQuery); + + await clickSecurityButton(); + + await waitFor(() => { + expect(screen.getByText('random_metric')).toBeInTheDocument(); + expect(screen.getByText('Do you know what you want to query?')).toBeInTheDocument(); + }); + + const aiPrompt = screen.getByTestId(queryAssistanttestIds.clickForAi); + + await userEvent.click(aiPrompt); + + await waitFor(() => { + expect(screen.getByText('What kind of data do you want to see with your metric?')).toBeInTheDocument(); + }); + }); + + it('does not display a prompt when choosing historical', async () => { + setup(defaultQuery); + + await clickSecurityButton(); + + await waitFor(() => { + expect(screen.getByText('random_metric')).toBeInTheDocument(); + expect(screen.getByText('Do you know what you want to query?')).toBeInTheDocument(); + }); + + const historicalPrompt = screen.getByTestId(queryAssistanttestIds.clickForHistorical); + + await userEvent.click(historicalPrompt); + + await waitFor(() => { + expect(screen.queryByText('What kind of data do you want to see with your metric?')).toBeNull(); + }); + }); +}); + +const defaultQuery: PromVisualQuery = { + metric: 'random_metric', + labels: [], + operations: [], +}; + +function createDatasource(withLabels?: boolean) { + const languageProvider = new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider; + + languageProvider.metricsMetadata = { + 'all-metrics': { + type: 'all-metrics-type', + help: 'all-metrics-help', + }, + a: { + type: 'counter', + help: 'a-metric-help', + }, + a_bucket: { + type: 'counter', + help: 'for functions', + }, + }; + + const datasource = new PrometheusDatasource( + { + url: '', + jsonData: {}, + meta: {} as DataSourcePluginMeta, + } as DataSourceInstanceSettings<PromOptions>, + undefined, + languageProvider + ); + return datasource; +} + +function createProps(query: PromVisualQuery, datasource: PrometheusDatasource) { + return { + datasource, + onChange: jest.fn(), + closeDrawer: jest.fn(), + query: query, + }; +} + +function setup(query: PromVisualQuery) { + const withLabels: boolean = query.labels.length > 0; + const datasource = createDatasource(withLabels); + const props = createProps(query, datasource); + + // render the drawer only + const { container } = render(<PromQail {...props} />); + + return container; +} + +async function clickSecurityButton() { + const securityInfoButton = screen.getByTestId(queryAssistanttestIds.securityInfoButton); + + await userEvent.click(securityInfoButton); +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/promQail/PromQail.tsx b/packages/grafana-prometheus/src/querybuilder/components/promQail/PromQail.tsx new file mode 100644 index 0000000000000..8777f8c48ab4c --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/promQail/PromQail.tsx @@ -0,0 +1,616 @@ +import { css, cx } from '@emotion/css'; +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import React, { useEffect, useReducer, useRef, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { Alert, Button, Checkbox, Input, Spinner, useTheme2 } from '@grafana/ui'; + +import { PrometheusDatasource } from '../../../datasource'; +import store from '../../../gcopypaste/app/core/store'; +import { PromVisualQuery } from '../../types'; + +import { QuerySuggestionContainer } from './QuerySuggestionContainer'; +// @ts-ignore until we can get these added for icons +import AI_Logo_color from './resources/AI_Logo_color.svg'; +import { promQailExplain, promQailSuggest } from './state/helpers'; +import { createInteraction, initialState } from './state/state'; +import { Interaction, SuggestionType } from './types'; + +export type PromQailProps = { + query: PromVisualQuery; + closeDrawer: () => void; + onChange: (query: PromVisualQuery) => void; + datasource: PrometheusDatasource; +}; + +const SKIP_STARTING_MESSAGE = 'SKIP_STARTING_MESSAGE'; + +export const PromQail = (props: PromQailProps) => { + const { query, closeDrawer, onChange, datasource } = props; + const skipStartingMessage = store.getBool(SKIP_STARTING_MESSAGE, false); + + const [state, dispatch] = useReducer(stateSlice.reducer, initialState(query, !skipStartingMessage)); + + const [labelNames, setLabelNames] = useState<string[]>([]); + + const suggestions = state.interactions.reduce((acc, int) => acc + int.suggestions.length, 0); + + const responsesEndRef = useRef(null); + + const scrollToBottom = () => { + if (responsesEndRef) { + // @ts-ignore for React.MutableRefObject + responsesEndRef?.current?.scrollIntoView({ behavior: 'smooth' }); + } + }; + + useEffect(() => { + // only scroll when an interaction has been added or the suggestions have been updated + scrollToBottom(); + }, [state.interactions.length, suggestions]); + + useEffect(() => { + const fetchLabels = async () => { + let labelsIndex: Record<string, string[]> = await datasource.languageProvider.fetchLabelsWithMatch(query.metric); + setLabelNames(Object.keys(labelsIndex)); + }; + fetchLabels(); + }, [query, datasource]); + + const theme = useTheme2(); + const styles = getStyles(theme); + + return ( + <div className={styles.containerPadding}> + {/* Query Advisor */} + {/* header */} + <div className={styles.header}> + <h3>Query advisor</h3> + <Button icon="times" fill="text" variant="secondary" onClick={closeDrawer} /> + </div> + {/* Starting message */} + <div> + <div className={styles.iconSection}> + <img src={AI_Logo_color} alt="AI logo color" /> Assistant + </div> + {state.showStartingMessage ? ( + <> + <div className={styles.dataList}> + <ol> + <li className={styles.textPadding}> + Query Advisor suggests queries based on a metric and requests you type in. + </li> + <li className={styles.textPadding}> + Query Advisor sends Prometheus metrics, labels and metadata to the LLM provider you've configured. + Be sure to align its usage with your company's internal policies. + </li> + <li className={styles.textPadding}> + An AI-suggested query may not fully answer your question. Always take a moment to understand a query + before you use it. + </li> + </ol> + </div> + <Alert + title={''} + severity={'info'} + key={'promqail-llm-app'} + className={cx(styles.textPadding, styles.noMargin)} + > + Query Advisor is currently in Private Preview. Feedback is appreciated and can be provided on explanations + and suggestions. + </Alert> + + {/* don't show this message again, store in localstorage */} + <div className={styles.textPadding}> + <Checkbox + checked={state.indicateCheckbox} + value={state.indicateCheckbox} + onChange={() => { + const val = store.getBool(SKIP_STARTING_MESSAGE, false); + store.set(SKIP_STARTING_MESSAGE, !val); + dispatch(indicateCheckbox(!val)); + }} + label="Don't show this message again" + /> + </div> + <div className={styles.rightButtonsWrapper}> + <div className={styles.rightButtons}> + <Button className={styles.leftButton} fill="outline" variant="secondary" onClick={closeDrawer}> + Cancel + </Button> + <Button + fill="solid" + variant="primary" + onClick={() => dispatch(showStartingMessage(false))} + data-testid={queryAssistanttestIds.securityInfoButton} + > + Continue + </Button> + </div> + </div> + </> + ) : ( + <div className={styles.bodySmall}> + {/* MAKE THIS TABLE RESPONSIVE */} + {/* FIT SUPER LONG METRICS AND LABELS IN HERE */} + <div className={styles.textPadding}>Here is the metric you have selected:</div> + <div className={styles.infoContainerWrapper}> + <div className={styles.infoContainer}> + <table className={styles.metricTable}> + <tbody> + <tr> + <td className={styles.metricTableName}>metric</td> + <td className={styles.metricTableValue}>{state.query.metric}</td> + <td> + <Button + fill="outline" + variant="secondary" + onClick={closeDrawer} + className={styles.metricTableButton} + size={'sm'} + > + Choose new metric + </Button> + </td> + </tr> + {state.query.labels.map((label, idx) => { + const text = idx === 0 ? 'labels' : ''; + return ( + <tr key={`${label.label}-${idx}`}> + <td>{text}</td> + <td className={styles.metricTableValue}>{`${label.label}${label.op}${label.value}`}</td> + <td> </td> + </tr> + ); + })} + </tbody> + </table> + </div> + </div> + + {/* Ask if you know what you want to query? */} + {!state.askForQueryHelp && state.interactions.length === 0 && ( + <> + <div className={styles.queryQuestion}>Do you know what you want to query?</div> + <div className={styles.rightButtonsWrapper}> + <div className={styles.rightButtons}> + <Button + className={styles.leftButton} + fill="solid" + variant="secondary" + data-testid={queryAssistanttestIds.clickForHistorical} + onClick={() => { + const isLoading = true; + const suggestionType = SuggestionType.Historical; + dispatch(addInteraction({ suggestionType, isLoading })); + reportInteraction('grafana_prometheus_promqail_know_what_you_want_to_query', { + promVisualQuery: query, + doYouKnow: 'no', + }); + promQailSuggest(dispatch, 0, query, labelNames, datasource); + }} + > + No + </Button> + <Button + fill="solid" + variant="primary" + data-testid={queryAssistanttestIds.clickForAi} + onClick={() => { + reportInteraction('grafana_prometheus_promqail_know_what_you_want_to_query', { + promVisualQuery: query, + doYouKnow: 'yes', + }); + const isLoading = false; + const suggestionType = SuggestionType.AI; + dispatch(addInteraction({ suggestionType, isLoading })); + }} + > + Yes + </Button> + </div> + </div> + </> + )} + + {state.interactions.map((interaction: Interaction, idx: number) => { + return ( + <div key={idx}> + {interaction.suggestionType === SuggestionType.AI ? ( + <> + <div className={styles.textPadding}>What kind of data do you want to see with your metric?</div> + <div className={cx(styles.secondaryText, styles.bottomMargin)}> + <div>You do not need to enter in a metric or a label again in the prompt.</div> + <div>Example: I want to monitor request latency, not errors.</div> + </div> + <div className={styles.inputPadding}> + <Input + value={interaction.prompt} + spellCheck={false} + placeholder="Enter prompt" + disabled={interaction.suggestions.length > 0} + onChange={(e) => { + const prompt = e.currentTarget.value; + + const payload = { + idx: idx, + interaction: { ...interaction, prompt }, + }; + + dispatch(updateInteraction(payload)); + }} + /> + </div> + {interaction.suggestions.length === 0 ? ( + interaction.isLoading ? ( + <> + <div className={styles.loadingMessageContainer}> + Waiting for OpenAI <Spinner className={styles.floatRight} /> + </div> + </> + ) : ( + <> + <div className={styles.rightButtonsWrapper}> + <div className={styles.rightButtons}> + <Button + className={styles.leftButton} + fill="outline" + variant="secondary" + onClick={closeDrawer} + > + Cancel + </Button> + <Button + className={styles.leftButton} + fill="outline" + variant="secondary" + onClick={() => { + // JUST SUGGEST QUERIES AND SHOW THE LIST + const newInteraction: Interaction = { + ...interaction, + suggestionType: SuggestionType.Historical, + isLoading: true, + }; + + const payload = { + idx: idx, + interaction: newInteraction, + }; + + reportInteraction('grafana_prometheus_promqail_suggest_query_instead', { + promVisualQuery: query, + }); + + dispatch(updateInteraction(payload)); + promQailSuggest(dispatch, idx, query, labelNames, datasource, newInteraction); + }} + > + Suggest queries instead + </Button> + <Button + fill="solid" + variant="primary" + data-testid={queryAssistanttestIds.submitPrompt + idx} + onClick={() => { + const newInteraction: Interaction = { + ...interaction, + isLoading: true, + }; + + const payload = { + idx: idx, + interaction: newInteraction, + }; + + reportInteraction('grafana_prometheus_promqail_prompt_submitted', { + promVisualQuery: query, + prompt: interaction.prompt, + }); + + dispatch(updateInteraction(payload)); + // add the suggestions in the API call + promQailSuggest(dispatch, idx, query, labelNames, datasource, interaction); + }} + > + Submit + </Button> + </div> + </div> + </> + ) + ) : ( + // LIST OF SUGGESTED QUERIES FROM AI + <QuerySuggestionContainer + suggestionType={SuggestionType.AI} + querySuggestions={interaction.suggestions} + closeDrawer={closeDrawer} + nextInteraction={() => { + const isLoading = false; + const suggestionType = SuggestionType.AI; + dispatch(addInteraction({ suggestionType, isLoading })); + }} + queryExplain={(suggIdx: number) => + interaction.suggestions[suggIdx].explanation === '' + ? promQailExplain(dispatch, idx, query, interaction, suggIdx, datasource) + : interaction.suggestions[suggIdx].explanation + } + onChange={onChange} + prompt={interaction.prompt ?? ''} + /> + )} + </> + ) : // HISTORICAL SUGGESTIONS + interaction.isLoading ? ( + <> + <div className={styles.loadingMessageContainer}> + Waiting for OpenAI <Spinner className={styles.floatRight} /> + </div> + </> + ) : ( + // LIST OF SUGGESTED QUERIES FROM HISTORICAL DATA + <QuerySuggestionContainer + suggestionType={SuggestionType.Historical} + querySuggestions={interaction.suggestions} + closeDrawer={closeDrawer} + nextInteraction={() => { + const isLoading = false; + const suggestionType = SuggestionType.AI; + dispatch(addInteraction({ suggestionType, isLoading })); + }} + queryExplain={(suggIdx: number) => + interaction.suggestions[suggIdx].explanation === '' + ? promQailExplain(dispatch, idx, query, interaction, suggIdx, datasource) + : interaction.suggestions[suggIdx].explanation + } + onChange={onChange} + prompt={interaction.prompt ?? ''} + /> + )} + </div> + ); + })} + </div> + )} + </div> + <div ref={responsesEndRef} /> + </div> + ); +}; + +export const getStyles = (theme: GrafanaTheme2) => { + return { + sectionPadding: css({ + padding: '20px', + }), + header: css({ + display: 'flex', + + button: { + marginLeft: 'auto', + }, + }), + iconSection: css({ + padding: '0 0 10px 0', + color: `${theme.colors.text.secondary}`, + + img: { + paddingRight: '4px', + }, + }), + rightButtonsWrapper: css({ + display: 'flex', + }), + rightButtons: css({ + marginLeft: 'auto', + }), + leftButton: css({ + marginRight: '10px', + }), + dataList: css({ + padding: '0px 28px 0px 28px', + }), + textPadding: css({ + paddingBottom: '12px', + }), + containerPadding: css({ + padding: '28px', + }), + infoContainer: css({ + border: `${theme.colors.border.strong}`, + padding: '16px', + backgroundColor: `${theme.colors.background.secondary}`, + borderRadius: `8px`, + borderBottomLeftRadius: 0, + }), + infoContainerWrapper: css({ + paddingBottom: '24px', + }), + metricTable: css({ + width: '100%', + }), + metricTableName: css({ + width: '15%', + }), + metricTableValue: css({ + fontFamily: `${theme.typography.fontFamilyMonospace}`, + fontSize: `${theme.typography.bodySmall.fontSize}`, + overflow: 'scroll', + textWrap: 'nowrap', + maxWidth: '150px', + width: '60%', + maskImage: `linear-gradient(to right, rgba(0, 0, 0, 1) 90%, rgba(0, 0, 0, 0))`, + }), + metricTableButton: css({ + float: 'right', + }), + queryQuestion: css({ + textAlign: 'end', + padding: '8px 0', + }), + secondaryText: css({ + color: `${theme.colors.text.secondary}`, + }), + loadingMessageContainer: css({ + border: `${theme.colors.border.strong}`, + padding: `16px`, + backgroundColor: `${theme.colors.background.secondary}`, + marginBottom: `20px`, + borderRadius: `8px`, + color: `${theme.colors.text.secondary}`, + fontStyle: 'italic', + }), + floatRight: css({ + float: 'right', + }), + codeText: css({ + fontFamily: `${theme.typography.fontFamilyMonospace}`, + fontSize: `${theme.typography.bodySmall.fontSize}`, + }), + bodySmall: css({ + fontSize: `${theme.typography.bodySmall.fontSize}`, + }), + explainPadding: css({ + paddingLeft: '26px', + }), + bottomMargin: css({ + marginBottom: '20px', + }), + topPadding: css({ + paddingTop: '22px', + }), + doc: css({ + textDecoration: 'underline', + }), + afterButtons: css({ + display: 'flex', + justifyContent: 'flex-end', + }), + feedbackStyle: css({ + margin: 0, + textAlign: 'right', + paddingTop: '22px', + paddingBottom: '22px', + }), + nextInteractionHeight: css({ + height: '88px', + }), + center: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }), + inputPadding: css({ + paddingBottom: '24px', + }), + querySuggestion: css({ + display: 'flex', + flexWrap: 'nowrap', + }), + longCode: css({ + width: '90%', + textWrap: 'nowrap', + overflow: 'scroll', + maskImage: `linear-gradient(to right, rgba(0, 0, 0, 1) 90%, rgba(0, 0, 0, 0))`, + + div: { + display: 'inline-block', + }, + }), + useButton: css({ + marginLeft: 'auto', + }), + suggestionFeedback: css({ + textAlign: 'left', + }), + feedbackQuestion: css({ + display: 'flex', + padding: '8px 0px', + h6: { marginBottom: 0 }, + i: { + marginTop: '1px', + }, + }), + explationTextInput: css({ + paddingLeft: '24px', + }), + submitFeedback: css({ + padding: '16px 0', + }), + noMargin: css({ + margin: 0, + }), + enableButtonTooltip: css({ + padding: 8, + }), + enableButtonTooltipText: css({ + color: `${theme.colors.text.secondary}`, + ul: { + marginLeft: 16, + }, + }), + link: css({ + color: `${theme.colors.text.link} !important`, + }), + }; +}; + +export const queryAssistanttestIds = { + promQail: 'prom-qail', + securityInfoButton: 'security-info-button', + clickForHistorical: 'click-for-historical', + clickForAi: 'click-for-ai', + submitPrompt: 'submit-prompt', + refinePrompt: 'refine-prompt', +}; + +const stateSlice = createSlice({ + name: 'metrics-modal-state', + initialState: initialState(), + reducers: { + showExplainer: (state, action: PayloadAction<boolean>) => { + state.showExplainer = action.payload; + }, + showStartingMessage: (state, action: PayloadAction<boolean>) => { + state.showStartingMessage = action.payload; + }, + indicateCheckbox: (state, action: PayloadAction<boolean>) => { + state.indicateCheckbox = action.payload; + }, + askForQueryHelp: (state, action: PayloadAction<boolean>) => { + state.askForQueryHelp = action.payload; + }, + /* + * start working on a collection of interactions + * { + * askForhelp y n + * prompt question + * queries querySuggestions + * } + * + */ + addInteraction: (state, action: PayloadAction<{ suggestionType: SuggestionType; isLoading: boolean }>) => { + // AI or Historical? + const interaction = createInteraction(action.payload.suggestionType, action.payload.isLoading); + const interactions = state.interactions; + state.interactions = interactions.concat([interaction]); + }, + updateInteraction: (state, action: PayloadAction<{ idx: number; interaction: Interaction }>) => { + // update the interaction by index + // will most likely be the last interaction but we might update previous by giving them cues of helpful or not + const index = action.payload.idx; + const updInteraction = action.payload.interaction; + + state.interactions = state.interactions.map((interaction: Interaction, idx: number) => { + if (idx === index) { + return updInteraction; + } + + return interaction; + }); + }, + }, +}); + +// actions to update the state +export const { showStartingMessage, indicateCheckbox, addInteraction, updateInteraction } = stateSlice.actions; diff --git a/packages/grafana-prometheus/src/querybuilder/components/promQail/QueryAssistantButton.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/promQail/QueryAssistantButton.test.tsx new file mode 100644 index 0000000000000..2b64277c22ca1 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/promQail/QueryAssistantButton.test.tsx @@ -0,0 +1,51 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { QueryAssistantButton } from './QueryAssistantButton'; + +const setShowDrawer = jest.fn(() => {}); + +describe('QueryAssistantButton', () => { + it('renders the button', async () => { + const props = createProps(true, 'metric', setShowDrawer); + render(<QueryAssistantButton {...props} />); + expect(screen.getByText('Get query suggestions')).toBeInTheDocument(); + }); + + it('shows the LLM app disabled message when LLM app is not set up with vector DB', async () => { + const props = createProps(false, 'metric', setShowDrawer); + render(<QueryAssistantButton {...props} />); + const button = screen.getByText('Get query suggestions'); + await userEvent.hover(button); + await waitFor(() => { + expect(screen.getByText('Install and enable the LLM plugin')).toBeInTheDocument(); + }); + }); + + it('shows the message to select a metric when LLM is enabled and no metric is selected', async () => { + const props = createProps(true, '', setShowDrawer); + render(<QueryAssistantButton {...props} />); + const button = screen.getByText('Get query suggestions'); + await userEvent.hover(button); + await waitFor(() => { + expect(screen.getByText('First, select a metric.')).toBeInTheDocument(); + }); + }); + + it('calls setShowDrawer when button is clicked', async () => { + const props = createProps(true, 'metric', setShowDrawer); + render(<QueryAssistantButton {...props} />); + const button = screen.getByText('Get query suggestions'); + fireEvent.click(button); + expect(setShowDrawer).toHaveBeenCalled(); + }); +}); + +function createProps(llmAppEnabled: boolean, metric: string, setShowDrawer: () => void) { + return { + llmAppEnabled, + metric, + setShowDrawer, + }; +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/promQail/QueryAssistantButton.tsx b/packages/grafana-prometheus/src/querybuilder/components/promQail/QueryAssistantButton.tsx new file mode 100644 index 0000000000000..5445134b64b96 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/promQail/QueryAssistantButton.tsx @@ -0,0 +1,87 @@ +import React from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { reportInteraction } from '@grafana/runtime'; +import { Button, Tooltip, useTheme2 } from '@grafana/ui'; + +import { getStyles } from './PromQail'; +import AI_Logo_color from './resources/AI_Logo_color.svg'; + +export type Props = { + llmAppEnabled: boolean; + metric: string; + setShowDrawer: (show: boolean) => void; +}; + +export function QueryAssistantButton(props: Props) { + const { llmAppEnabled, metric, setShowDrawer } = props; + + const llmAppDisabled = !llmAppEnabled; + const noMetricSelected = !metric; + + const theme = useTheme2(); + const styles = getStyles(theme); + + const button = () => { + return ( + <Button + variant={'secondary'} + onClick={() => { + reportInteraction('grafana_prometheus_promqail_ai_button_clicked', { + metric: metric, + }); + setShowDrawer(true); + }} + disabled={!metric || !llmAppEnabled} + data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.queryAdvisor} + > + <img height={16} src={AI_Logo_color} alt="AI logo black and white" /> + {'\u00A0'}Get query suggestions + </Button> + ); + }; + + const selectMetricMessage = ( + <Tooltip content={'First, select a metric.'} placement={'bottom-end'}> + {button()} + </Tooltip> + ); + + const llmAppMessage = ( + <Tooltip + interactive={true} + placement={'auto-end'} + content={ + <div className={styles.enableButtonTooltip}> + <h6>Query Advisor is disabled</h6> + <div className={styles.enableButtonTooltipText}>To enable Query Advisor you must:</div> + <div className={styles.enableButtonTooltipText}> + <ul> + <li> + <a + href={'https://grafana.com/docs/grafana-cloud/alerting-and-irm/machine-learning/llm-plugin/'} + target="_blank" + rel="noreferrer noopener" + className={styles.link} + > + Install and enable the LLM plugin + </a> + </li> + <li>Select a metric</li> + </ul> + </div> + </div> + } + > + {button()} + </Tooltip> + ); + + if (llmAppDisabled) { + return llmAppMessage; + } else if (noMetricSelected) { + return selectMetricMessage; + } else { + return button(); + } +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/promQail/QuerySuggestionContainer.tsx b/packages/grafana-prometheus/src/querybuilder/components/promQail/QuerySuggestionContainer.tsx new file mode 100644 index 0000000000000..1a52194b9cb41 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/promQail/QuerySuggestionContainer.tsx @@ -0,0 +1,101 @@ +import { cx } from '@emotion/css'; +import React, { useState } from 'react'; + +import { Button, useTheme2 } from '@grafana/ui'; + +import { PromVisualQuery } from '../../types'; + +import { getStyles, queryAssistanttestIds } from './PromQail'; +import { QuerySuggestionItem } from './QuerySuggestionItem'; +import { QuerySuggestion, SuggestionType } from './types'; + +export type Props = { + querySuggestions: QuerySuggestion[]; + suggestionType: SuggestionType; + closeDrawer: () => void; + nextInteraction: () => void; + queryExplain: (idx: number) => void; + onChange: (query: PromVisualQuery) => void; + prompt: string; +}; + +export function QuerySuggestionContainer(props: Props) { + const { suggestionType, querySuggestions, closeDrawer, nextInteraction, queryExplain, onChange, prompt } = props; + + const [hasNextInteraction, updateHasNextInteraction] = useState<boolean>(false); + + const theme = useTheme2(); + const styles = getStyles(theme); + + let text, secondaryText, refineText; + + if (suggestionType === SuggestionType.Historical) { + text = `Here are ${querySuggestions.length} query suggestions:`; + refineText = 'I want to write a prompt'; + } else if (suggestionType === SuggestionType.AI) { + text = text = 'Here is your query suggestion:'; + secondaryText = + 'This query is based off of natural language descriptions of the most commonly used PromQL queries.'; + refineText = 'Refine prompt'; + } + + return ( + <> + {suggestionType === SuggestionType.Historical ? ( + <div className={styles.bottomMargin}>{text}</div> + ) : ( + <> + <div className={styles.textPadding}>{text}</div> + <div className={cx(styles.secondaryText, styles.bottomMargin)}>{secondaryText}</div> + </> + )} + + <div className={styles.infoContainerWrapper}> + <div className={styles.infoContainer}> + {querySuggestions.map((qs: QuerySuggestion, idx: number) => { + return ( + <QuerySuggestionItem + historical={suggestionType === SuggestionType.Historical} + querySuggestion={qs} + key={idx} + order={idx + 1} + queryExplain={queryExplain} + onChange={onChange} + closeDrawer={closeDrawer} + last={idx === querySuggestions.length - 1} + // for feedback rudderstack events + allSuggestions={querySuggestions.reduce((acc: string, qs: QuerySuggestion) => { + return acc + '$$' + qs.query; + }, '')} + prompt={prompt ?? ''} + /> + ); + })} + </div> + </div> + {!hasNextInteraction && ( + <div className={styles.nextInteractionHeight}> + <div className={cx(styles.afterButtons, styles.textPadding)}> + <Button + onClick={() => { + updateHasNextInteraction(true); + nextInteraction(); + }} + data-testid={queryAssistanttestIds.refinePrompt} + fill="outline" + variant="secondary" + size="md" + > + {refineText} + </Button> + </div> + <div className={cx(styles.textPadding, styles.floatRight)}> + <Button fill="outline" variant="secondary" size="md" onClick={closeDrawer}> + Cancel + </Button> + </div> + </div> + )} + </> + ); +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/promQail/QuerySuggestionItem.tsx b/packages/grafana-prometheus/src/querybuilder/components/promQail/QuerySuggestionItem.tsx new file mode 100644 index 0000000000000..9076faae6bc27 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/promQail/QuerySuggestionItem.tsx @@ -0,0 +1,321 @@ +import { cx } from '@emotion/css'; +import React, { FormEvent, useState } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { Button, RadioButtonList, Spinner, TextArea, Toggletip, useTheme2 } from '@grafana/ui'; + +import { buildVisualQueryFromString } from '../../parsing'; +import { PromVisualQuery } from '../../types'; + +import { getStyles } from './PromQail'; +import { QuerySuggestion } from './types'; + +export type Props = { + querySuggestion: QuerySuggestion; + order: number; + queryExplain: (idx: number) => void; + historical: boolean; + onChange: (query: PromVisualQuery) => void; + closeDrawer: () => void; + last: boolean; + prompt: string; + allSuggestions: string | undefined; +}; + +const suggestionOptions: SelectableValue[] = [ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, +]; +const explationOptions: SelectableValue[] = [ + { label: 'Too vague', value: 'too vague' }, + { label: 'Too technical', value: 'too technical' }, + { label: 'Inaccurate', value: 'inaccurate' }, + { label: 'Other', value: 'other' }, +]; + +export function QuerySuggestionItem(props: Props) { + const { querySuggestion, order, queryExplain, historical, onChange, closeDrawer, last, allSuggestions, prompt } = + props; + const [showExp, updShowExp] = useState<boolean>(false); + + const [gaveExplanationFeedback, updateGaveExplanationFeedback] = useState<boolean>(false); + const [gaveSuggestionFeedback, updateGaveSuggestionFeedback] = useState<boolean>(false); + + const [suggestionFeedback, setSuggestionFeedback] = useState({ + radioInput: '', + text: '', + }); + + const [explanationFeedback, setExplanationFeedback] = useState({ + radioInput: '', + text: '', + }); + + const theme = useTheme2(); + const styles = getStyles(theme); + + const { query, explanation } = querySuggestion; + + const feedbackToggleTip = (type: string) => { + const updateRadioFeedback = (value: string) => { + if (type === 'explanation') { + setExplanationFeedback({ + ...explanationFeedback, + radioInput: value, + }); + } else { + setSuggestionFeedback({ + ...suggestionFeedback, + radioInput: value, + }); + } + }; + + const updateTextFeedback = (e: FormEvent<HTMLTextAreaElement>) => { + if (type === 'explanation') { + setExplanationFeedback({ + ...explanationFeedback, + text: e.currentTarget.value, + }); + } else { + setSuggestionFeedback({ + ...suggestionFeedback, + text: e.currentTarget.value, + }); + } + }; + + const disabledButton = () => + type === 'explanation' ? !explanationFeedback.radioInput : !suggestionFeedback.radioInput; + + const questionOne = + type === 'explanation' ? 'Why was the explanation not helpful?' : 'Were the query suggestions helpful?'; + + return ( + <div className={styles.suggestionFeedback}> + <div> + <div className={styles.feedbackQuestion}> + <h6>{questionOne}</h6> + <i>(Required)</i> + </div> + <RadioButtonList + name="default" + options={type === 'explanation' ? explationOptions : suggestionOptions} + value={type === 'explanation' ? explanationFeedback.radioInput : suggestionFeedback.radioInput} + onChange={updateRadioFeedback} + /> + </div> + <div className={cx(type === 'explanation' && styles.explationTextInput)}> + {type !== 'explanation' && ( + <div className={styles.feedbackQuestion}> + <h6>How can we improve the query suggestions?</h6> + </div> + )} + <TextArea + type="text" + aria-label="Promqail suggestion text" + placeholder="Enter your feedback" + value={type === 'explanation' ? explanationFeedback.text : suggestionFeedback.text} + onChange={updateTextFeedback} + cols={100} + /> + </div> + + <div className={styles.submitFeedback}> + <Button + variant="primary" + size="sm" + disabled={disabledButton()} + onClick={() => { + // submit the rudderstack event + if (type === 'explanation') { + explanationFeedbackEvent( + explanationFeedback.radioInput, + explanationFeedback.text, + querySuggestion, + historical, + prompt + ); + updateGaveExplanationFeedback(true); + } else { + suggestionFeedbackEvent( + suggestionFeedback.radioInput, + suggestionFeedback.text, + allSuggestions ?? '', + historical, + prompt + ); + updateGaveSuggestionFeedback(true); + } + }} + > + Submit + </Button> + </div> + </div> + ); + }; + + return ( + <> + <div className={styles.querySuggestion}> + <div title={query} className={cx(styles.codeText, styles.longCode)}> + {`${order}. ${query}`} + </div> + <div className={styles.useButton}> + <Button + variant="primary" + size="sm" + onClick={() => { + reportInteraction('grafana_prometheus_promqail_use_query_button_clicked', { + query: querySuggestion.query, + }); + const pvq = buildVisualQueryFromString(querySuggestion.query); + // check for errors! + onChange(pvq.query); + closeDrawer(); + }} + > + Use + </Button> + </div> + </div> + <div> + <Button + fill="text" + variant="secondary" + icon={showExp ? 'angle-up' : 'angle-down'} + onClick={() => { + updShowExp(!showExp); + queryExplain(order - 1); + }} + className={cx(styles.bodySmall)} + size="sm" + > + Explainer + </Button> + {!showExp && order !== 5 && <div className={styles.textPadding}></div>} + + {showExp && !querySuggestion.explanation && ( + <div className={styles.center}> + <Spinner /> + </div> + )} + {showExp && querySuggestion.explanation && ( + <> + <div className={cx(styles.bodySmall, styles.explainPadding)}> + <div className={styles.textPadding}>This query is trying to answer the question:</div> + <div className={styles.textPadding}>{explanation}</div> + <div className={styles.textPadding}> + Learn more with this{' '} + <a + className={styles.doc} + href={'https://prometheus.io/docs/prometheus/latest/querying/examples/#query-examples'} + target="_blank" + rel="noopener noreferrer" + > + Prometheus doc + </a> + </div> + + <div className={cx(styles.rightButtons, styles.secondaryText)}> + Was this explanation helpful? + <div className={styles.floatRight}> + {!gaveExplanationFeedback ? ( + <> + <Button + fill="outline" + variant="secondary" + size="sm" + className={styles.leftButton} + onClick={() => { + explanationFeedbackEvent('Yes', '', querySuggestion, historical, prompt); + updateGaveExplanationFeedback(true); + }} + > + Yes + </Button> + <Toggletip + aria-label="Suggestion feedback" + content={feedbackToggleTip('explanation')} + placement="bottom-end" + closeButton={true} + > + <Button variant="success" size="sm"> + No + </Button> + </Toggletip> + </> + ) : ( + 'Thank you for your feedback!' + )} + </div> + </div> + </div> + + {!last && <hr />} + </> + )} + {last && ( + <div className={cx(styles.feedbackStyle)}> + {!gaveSuggestionFeedback ? ( + <Toggletip + aria-label="Suggestion feedback" + content={feedbackToggleTip('suggestion')} + placement="bottom-end" + closeButton={true} + > + <Button fill="outline" variant="secondary" size="sm"> + Give feedback on suggestions + </Button> + </Toggletip> + ) : ( + // do this weird thing because the toggle tip doesn't allow an extra close function + <Button fill="outline" variant="secondary" size="sm" disabled={true}> + Thank you for your feedback! + </Button> + )} + </div> + )} + </div> + </> + ); +} + +function explanationFeedbackEvent( + radioInputFeedback: string, + textFeedback: string, + querySuggestion: QuerySuggestion, + historical: boolean, + prompt: string +) { + const event = 'grafana_prometheus_promqail_explanation_feedback'; + + reportInteraction(event, { + helpful: radioInputFeedback, + textFeedback: textFeedback, + suggestionType: historical ? 'historical' : 'AI', + query: querySuggestion.query, + explanation: querySuggestion.explanation, + prompt: prompt, + }); +} + +function suggestionFeedbackEvent( + radioInputFeedback: string, + textFeedback: string, + allSuggestions: string, + historical: boolean, + prompt: string +) { + const event = 'grafana_prometheus_promqail_suggestion_feedback'; + + reportInteraction(event, { + helpful: radioInputFeedback, + textFeedback: textFeedback, + suggestionType: historical ? 'historical' : 'AI', + allSuggestions: allSuggestions, + prompt: prompt, + }); +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/promQail/index.ts b/packages/grafana-prometheus/src/querybuilder/components/promQail/index.ts new file mode 100644 index 0000000000000..3002f1a70e811 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/promQail/index.ts @@ -0,0 +1 @@ +export * from './PromQail'; diff --git a/packages/grafana-prometheus/src/querybuilder/components/promQail/prompts.ts b/packages/grafana-prometheus/src/querybuilder/components/promQail/prompts.ts new file mode 100644 index 0000000000000..b92bd2d2f1431 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/promQail/prompts.ts @@ -0,0 +1,114 @@ +export const ExplainSystemPrompt = `You are an expert in Prometheus, the event monitoring and alerting application. + +You are given relevant PromQL documentation, a type and description for a Prometheus metric, and a PromQL query on that metric. Using the provided information for reference, please explain what the output of a given query is in 1 sentences. Do not walk through what the functions do separately, make your answer concise. + +Input will be in the form: + + +PromQL Documentation: +<PromQL documentation> + +PromQL Metrics Metadata: +<metric_name>(<metric type of the metric queried>): <description of what the metric means> + +PromQL Expression: +<PromQL query> + +Examples of input and output +---------- +PromQL Documentation: +A counter is a cumulative metric that represents a single monotonically increasing counter whose value can only increase or be reset to zero on restart. For example, you can use a counter to represent the number of requests served, tasks completed, or errors. +topk (largest k elements by sample value) +sum (calculate sum over dimensions) +rate(v range-vector) calculates the per-second average rate of increase of the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. + +PromQL Metrics Metadata: +traces_exporter_sent_spans(counter): Number of spans successfully sent to destination. + +PromQL Expression: +topk(3, sum by(cluster) (rate(traces_exporter_sent_spans{exporter="otlp"}[5m]))) + +This query is trying to answer the question: +What is the top 3 clusters that have successfully sent the most number of spans to the destination? +`; + +export type ExplainUserPromptParams = { + documentation: string; + metricName: string; + metricType: string; + metricMetadata: string; + query: string; +}; + +export function GetExplainUserPrompt({ + documentation, + metricName, + metricType, + metricMetadata, + query, +}: ExplainUserPromptParams): string { + if (documentation === '') { + documentation = 'No documentation provided.'; + } + if (metricMetadata === '') { + metricMetadata = 'No description provided.'; + } + return ` + PromQL Documentation: + ${documentation} + + PromQL Metrics Metadata: + ${metricName}(${metricType}): ${metricMetadata} + + PromQL Expression: + ${query} + + This query is trying to answer the question: + `; +} + +export const SuggestSystemPrompt = `You are a Prometheus Query Language (PromQL) expert assistant inside Grafana. +When the user asks a question, respond with a valid PromQL query and only the query. + +To help you answer the question, you will receive: +- List of potentially relevant PromQL templates with descriptions, ranked by semantic search score +- Prometheus metric +- Metric type +- Available Prometheus metric labels +- User question + +Policy: +- Do not invent labels names, you can only use the available labels +- For rate queries, use the $__rate_interval variable`; + +// rewrite with a type +export type SuggestUserPromptParams = { + promql: string; + question: string; + metricType: string; + labels: string; + templates: string; +}; + +export function GetSuggestUserPrompt({ + promql, + question, + metricType, + labels, + templates, +}: SuggestUserPromptParams): string { + if (templates === '') { + templates = 'No templates provided.'; + } else { + templates = templates.replace(/\n/g, '\n '); + } + return `Relevant PromQL templates: + ${templates} + + Prometheus metric: ${promql} + Metric type: ${metricType} + Available Prometheus metric labels: ${labels} + User question: ${question} + + \`\`\`promql`; +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/promQail/resources/AI_Logo_bw.svg b/packages/grafana-prometheus/src/querybuilder/components/promQail/resources/AI_Logo_bw.svg new file mode 100644 index 0000000000000..546516b5f13d3 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/promQail/resources/AI_Logo_bw.svg @@ -0,0 +1,4 @@ +<svg width="17" height="18" viewBox="0 0 17 18" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M6.03027 12.6328C4.88965 12.6328 4.00293 11.9844 4.00293 10.8047C4.00293 9.44141 5.16699 9.14453 6.30371 9.01172C7.38184 8.88672 7.81934 8.89453 7.81934 8.46875V8.44141C7.81934 7.76172 7.43262 7.36719 6.67871 7.36719C5.89355 7.36719 5.45605 7.77734 5.28418 8.20312L4.18652 7.95312C4.57715 6.85937 5.57715 6.42188 6.66309 6.42188C7.61621 6.42188 8.99121 6.76953 8.99121 8.51563V12.5H7.85059V11.6797H7.80371C7.58105 12.1289 7.02246 12.6328 6.03027 12.6328ZM6.28418 11.6953C7.25684 11.6953 7.82324 11.0469 7.82324 10.3359V9.5625C7.65527 9.73047 6.75684 9.83203 6.37793 9.88281C5.70215 9.97266 5.14746 10.1953 5.14746 10.8203C5.14746 11.3984 5.62402 11.6953 6.28418 11.6953ZM10.5469 12.5V6.5H11.7148V12.5H10.5469ZM11.1367 5.57422C10.7305 5.57422 10.3984 5.26172 10.3984 4.87891C10.3984 4.49609 10.7305 4.17969 11.1367 4.17969C11.5391 4.17969 11.875 4.49609 11.875 4.87891C11.875 5.26172 11.5391 5.57422 11.1367 5.57422Z" fill="white"/> +<path d="M5 0.875H12C14.5543 0.875 16.625 2.94568 16.625 5.5V12.5C16.625 15.0543 14.5543 17.125 12 17.125H0.375V5.5C0.375 2.94568 2.44568 0.875 5 0.875Z" stroke="white" stroke-width="0.75"/> +</svg> diff --git a/packages/grafana-prometheus/src/querybuilder/components/promQail/resources/AI_Logo_color.svg b/packages/grafana-prometheus/src/querybuilder/components/promQail/resources/AI_Logo_color.svg new file mode 100644 index 0000000000000..72160a937404a --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/promQail/resources/AI_Logo_color.svg @@ -0,0 +1,11 @@ +<svg width="26" height="27" viewBox="0 0 26 27" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M0 9.5C0 4.52944 4.02944 0.5 9 0.5H17C21.9706 0.5 26 4.52944 26 9.5V15.5C26 20.4706 21.9706 24.5 17 24.5H0V9.5Z" fill="url(#paint0_linear_68_17626)"/> +<path d="M8.91193 18.7053C7.14915 18.7053 5.77876 17.7031 5.77876 15.88C5.77876 13.7731 7.57777 13.3143 9.33452 13.109C11.0007 12.9158 11.6768 12.9279 11.6768 12.2699V12.2276C11.6768 11.1772 11.0792 10.5675 9.91406 10.5675C8.70064 10.5675 8.0245 11.2013 7.75888 11.8594L6.0625 11.473C6.66619 9.78267 8.21165 9.10653 9.88992 9.10653C11.3629 9.10653 13.4879 9.64382 13.4879 12.3423V18.5H11.7251V17.2322H11.6527C11.3086 17.9265 10.4453 18.7053 8.91193 18.7053ZM9.30433 17.2564C10.8075 17.2564 11.6829 16.2543 11.6829 15.1555V13.9602C11.4233 14.2198 10.0348 14.3768 9.44922 14.4553C8.40483 14.5941 7.54759 14.9382 7.54759 15.9041C7.54759 16.7976 8.28409 17.2564 9.30433 17.2564ZM15.8921 18.5V9.22727H17.6972V18.5H15.8921ZM16.8037 7.79652C16.1759 7.79652 15.6627 7.31357 15.6627 6.72195C15.6627 6.13033 16.1759 5.64133 16.8037 5.64133C17.4255 5.64133 17.9447 6.13033 17.9447 6.72195C17.9447 7.31357 17.4255 7.79652 16.8037 7.79652Z" fill="white"/> +<path d="M0 24.5H3L0 26.5V24.5Z" fill="#5B5CC2"/> +<defs> +<linearGradient id="paint0_linear_68_17626" x1="4.76666" y1="-5.1" x2="24.472" y2="5.4613" gradientUnits="userSpaceOnUse"> +<stop offset="0.0333246" stop-color="#965AFB"/> +<stop offset="1" stop-color="#096174"/> +</linearGradient> +</defs> +</svg> diff --git a/packages/grafana-prometheus/src/querybuilder/components/promQail/state/helpers.test.ts b/packages/grafana-prometheus/src/querybuilder/components/promQail/state/helpers.test.ts new file mode 100644 index 0000000000000..323848bebdc93 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/promQail/state/helpers.test.ts @@ -0,0 +1,74 @@ +import { llms } from '@grafana/experimental'; + +import { guessMetricType, isLLMPluginEnabled } from './helpers'; + +// Mock the grafana-experimental llms module +jest.mock('@grafana/experimental', () => ({ + llms: { + openai: { + health: jest.fn(), + }, + vector: { + health: jest.fn(), + }, + }, +})); + +describe('isLLMPluginEnabled', () => { + it('should return true if LLM plugin is enabled', async () => { + jest.mocked(llms.openai.health).mockResolvedValue({ ok: true, configured: true }); + jest.mocked(llms.vector.health).mockResolvedValue({ ok: true, enabled: true }); + + const enabled = await isLLMPluginEnabled(); + + expect(enabled).toBe(true); + }); + + it('should return false if LLM plugin is not enabled', async () => { + jest.mocked(llms.openai.health).mockResolvedValue({ ok: false, configured: false }); + jest.mocked(llms.vector.health).mockResolvedValue({ ok: false, enabled: false }); + + const enabled = await isLLMPluginEnabled(); + + expect(enabled).toBe(false); + }); + + it('should return false if LLM plugin is enabled but health check fails', async () => { + jest.mocked(llms.openai.health).mockResolvedValue({ ok: false, configured: true }); + jest.mocked(llms.vector.health).mockResolvedValue({ ok: false, enabled: true }); + + const enabled = await isLLMPluginEnabled(); + + expect(enabled).toBe(false); + }); +}); + +const metricListWithType = [ + // below is summary metric family + ['go_gc_duration_seconds', 'summary'], + ['go_gc_duration_seconds_count', 'summary'], + ['go_gc_duration_seconds_sum', 'summary'], + // below is histogram metric family + ['go_gc_heap_allocs_by_size_bytes_total_bucket', 'histogram'], + ['go_gc_heap_allocs_by_size_bytes_total_count', 'histogram'], + ['go_gc_heap_allocs_by_size_bytes_total_sum', 'histogram'], + // below are counters + ['go_gc_heap_allocs_bytes_total', 'counter'], + ['scrape_samples_post_metric_relabeling', 'counter'], + // below are gauges + ['go_gc_heap_goal_bytes', 'gauge'], + ['nounderscorename', 'gauge'], + // below is both a histogram & summary + ['alertmanager_http_response_size_bytes', 'histogram,summary'], + ['alertmanager_http_response_size_bytes_bucket', 'histogram,summary'], + ['alertmanager_http_response_size_bytes_count', 'histogram,summary'], + ['alertmanager_http_response_size_bytes_sum', 'histogram,summary'], +]; + +const metricList = metricListWithType.map((item) => item[0]); + +describe('guessMetricType', () => { + it.each(metricListWithType)("where input is '%s'", (metric: string, metricType: string) => { + expect(guessMetricType(metric, metricList)).toBe(metricType); + }); +}); diff --git a/packages/grafana-prometheus/src/querybuilder/components/promQail/state/helpers.ts b/packages/grafana-prometheus/src/querybuilder/components/promQail/state/helpers.ts new file mode 100644 index 0000000000000..8bf207da75efe --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/promQail/state/helpers.ts @@ -0,0 +1,418 @@ +import { AnyAction } from 'redux'; + +import { llms } from '@grafana/experimental'; +import { reportInteraction } from '@grafana/runtime'; + +import { PrometheusDatasource } from '../../../../datasource'; +import { getMetadataHelp, getMetadataType } from '../../../../language_provider'; +import { promQueryModeller } from '../../../PromQueryModeller'; +import { buildVisualQueryFromString } from '../../../parsing'; +import { PromVisualQuery } from '../../../types'; +import { updateInteraction } from '../PromQail'; +import { + ExplainSystemPrompt, + GetExplainUserPrompt, + SuggestSystemPrompt, + GetSuggestUserPrompt, + SuggestUserPromptParams, +} from '../prompts'; +import { Interaction, QuerySuggestion, SuggestionType } from '../types'; + +import { createInteraction } from './state'; +import { getTemplateSuggestions } from './templates'; + +const OPENAI_MODEL_NAME = 'gpt-3.5-turbo-1106'; +const promQLTemplatesCollection = 'grafana.promql.templates'; + +interface TemplateSearchResult { + description: string | null; + metric_type: string | null; + promql: string | null; +} + +export function getExplainMessage( + query: string, + metric: string, + datasource: PrometheusDatasource +): llms.openai.Message[] { + let metricMetadata = ''; + let metricType = ''; + + const pvq = buildVisualQueryFromString(query); + + if (datasource.languageProvider.metricsMetadata) { + metricType = getMetadataType(metric, datasource.languageProvider.metricsMetadata) ?? ''; + metricMetadata = getMetadataHelp(metric, datasource.languageProvider.metricsMetadata) ?? ''; + } + + const documentationBody = pvq.query.operations + .map((op) => { + const def = promQueryModeller.getOperationDef(op.id); + if (!def) { + return ''; + } + const title = def.renderer(op, def, '<expr>'); + const body = def.explainHandler ? def.explainHandler(op, def) : def.documentation; + + if (!body) { + return ''; + } + return `### ${title}:\n${body}`; + }) + .filter((item) => item !== '') + .join('\n'); + + return [ + { role: 'system', content: ExplainSystemPrompt }, + { + role: 'user', + content: GetExplainUserPrompt({ + documentation: documentationBody, + metricName: metric, + metricType: metricType, + metricMetadata: metricMetadata, + query: query, + }), + }, + ]; +} + +function getSuggestMessages({ + promql, + question, + metricType, + labels, + templates, +}: SuggestUserPromptParams): llms.openai.Message[] { + return [ + { role: 'system', content: SuggestSystemPrompt }, + { role: 'user', content: GetSuggestUserPrompt({ promql, question, metricType, labels, templates }) }, + ]; +} + +/** + * Calls the API and adds suggestions to the interaction + * + * @param dispatch + * @param idx + * @param interaction + * @returns + */ +export async function promQailExplain( + dispatch: React.Dispatch<AnyAction>, + idx: number, + query: PromVisualQuery, + interaction: Interaction, + suggIdx: number, + datasource: PrometheusDatasource +) { + const suggestedQuery = interaction.suggestions[suggIdx].query; + + const promptMessages = getExplainMessage(suggestedQuery, query.metric, datasource); + const interactionToUpdate = interaction; + + return llms.openai + .streamChatCompletions({ + model: OPENAI_MODEL_NAME, + messages: promptMessages, + temperature: 0, + }) + .pipe(llms.openai.accumulateContent()) + .subscribe((response) => { + const updatedSuggestions = interactionToUpdate.suggestions.map((sg: QuerySuggestion, sidx: number) => { + if (suggIdx === sidx) { + return { + query: interactionToUpdate.suggestions[suggIdx].query, + explanation: response, + }; + } + + return sg; + }); + + const payload = { + idx, + interaction: { + ...interactionToUpdate, + suggestions: updatedSuggestions, + explanationIsLoading: false, + }, + }; + dispatch(updateInteraction(payload)); + }); +} + +/** + * Check if sublist is fully contained in the superlist + * + * @param sublist + * @param superlist + * @returns true if fully contained, else false + */ +function isContainedIn(sublist: string[], superlist: string[]): boolean { + for (const item of sublist) { + if (!superlist.includes(item)) { + return false; + } + } + return true; +} + +/** + * Guess the type of a metric, based on its name and its relation to other metrics available + * + * @param metric - name of metric whose type to guess + * @param allMetrics - list of all available metrics + * @returns - the guess of the type (string): counter,gauge,summary,histogram,'histogram,summary' + */ +export function guessMetricType(metric: string, allMetrics: string[]): string { + const synthetic_metrics = new Set<string>([ + 'up', + 'scrape_duration_seconds', + 'scrape_samples_post_metric_relabeling', + 'scrape_series_added', + 'scrape_samples_scraped', + 'ALERTS', + 'ALERTS_FOR_STATE', + ]); + + if (synthetic_metrics.has(metric)) { + // these are all known to be counters + return 'counter'; + } + if (metric.startsWith(':')) { + // probably recording rule + return 'gauge'; + } + if (metric.endsWith('_info')) { + // typically series of 1s only, the labels are the useful part. TODO: add 'info' type + return 'counter'; + } + + if (metric.endsWith('_created') || metric.endsWith('_total')) { + // prometheus naming style recommends counters to have these suffixes. + return 'counter'; + } + + const underscoreIndex = metric.lastIndexOf('_'); + if (underscoreIndex < 0) { + // No underscores in the name at all, very little info to go on. Guess + return 'gauge'; + } + + // See if the suffix is histogram-y or summary-y + const [root, suffix] = [metric.slice(0, underscoreIndex), metric.slice(underscoreIndex + 1)]; + + if (['bucket', 'count', 'sum'].includes(suffix)) { + // Might be histogram + summary + let familyMetrics = [`${root}_bucket`, `${root}_count`, `${root}_sum`, root]; + if (isContainedIn(familyMetrics, allMetrics)) { + return 'histogram,summary'; + } + + // Might be a histogram, if so all these metrics should exist too: + familyMetrics = [`${root}_bucket`, `${root}_count`, `${root}_sum`]; + if (isContainedIn(familyMetrics, allMetrics)) { + return 'histogram'; + } + + // Or might be a summary + familyMetrics = [`${root}_sum`, `${root}_count`, root]; + if (isContainedIn(familyMetrics, allMetrics)) { + return 'summary'; + } + + // Otherwise it's probably just a counter! + return 'counter'; + } + + // One case above doesn't catch: summary or histogram,summary where the non-suffixed metric is chosen + const familyMetrics = [`${metric}_sum`, `${metric}_count`, metric]; + if (isContainedIn(familyMetrics, allMetrics)) { + if (allMetrics.includes(`${metric}_bucket`)) { + return 'histogram,summary'; + } else { + return 'summary'; + } + } + + // All else fails, guess gauge + return 'gauge'; +} + +/** + * Generate a suitable filter structure for the VectorDB call + * @param types: list of metric types to include in the result + * @returns the structure to pass to the vectorDB call. + */ +function generateMetricTypeFilters(types: string[]) { + return types.map((type) => ({ + metric_type: { + $eq: type, + }, + })); +} + +/** + * Taking in a metric name, try to guess its corresponding metric _family_ name + * @param metric name + * @returns metric family name + */ +function guessMetricFamily(metric: string): string { + if (metric.endsWith('_bucket') || metric.endsWith('_count') || metric.endsWith('_sum')) { + return metric.slice(0, metric.lastIndexOf('_')); + } + return metric; +} + +/** + * Check if the LLM plugin is enabled. + * Used in the PromQueryBuilder to enable/disable the button based on openai and vector db checks + * @returns true if the LLM plugin is enabled. + */ +export async function isLLMPluginEnabled(): Promise<boolean> { + // Check if the LLM plugin is enabled. + // If not, we won't be able to make requests, so return early. + const openaiEnabled = llms.openai.health().then((response) => response.ok); + const vectorEnabled = llms.vector.health().then((response) => response.ok); + // combine 2 promises + return Promise.all([openaiEnabled, vectorEnabled]).then((results) => { + return results.every((result) => result); + }); +} + +/** + * Calls the API and adds suggestions to the interaction + * + * @param dispatch + * @param idx + * @param interaction + * @returns + */ +export async function promQailSuggest( + dispatch: React.Dispatch<AnyAction>, + idx: number, + query: PromVisualQuery, + labelNames: string[], + datasource: PrometheusDatasource, + interaction?: Interaction +) { + const interactionToUpdate = interaction ? interaction : createInteraction(SuggestionType.Historical); + + // Decide metric type + let metricType = ''; + // Makes sure we loaded the metadata for metrics. Usually this is done in the start() method of the + // provider but we only need the metadata here. + if (!datasource.languageProvider.metricsMetadata) { + await datasource.languageProvider.loadMetricsMetadata(); + } + if (datasource.languageProvider.metricsMetadata) { + // `datasource.languageProvider.metricsMetadata` is a list of metric family names (with desired type) + // from the datasource metadata endoint, but unfortunately the expanded _sum, _count, _bucket raw + // metric names are also generated and populating this list (all of type counter). We want the metric + // family type, so need to guess the metric family name from the chosen metric name, and test if that + // metric family has a type specified. + const metricFamilyGuess = guessMetricFamily(query.metric); + metricType = getMetadataType(metricFamilyGuess, datasource.languageProvider.metricsMetadata) ?? ''; + } + if (metricType === '') { + // fallback to heuristic guess + metricType = guessMetricType(query.metric, datasource.languageProvider.metrics); + } + + if (interactionToUpdate.suggestionType === SuggestionType.Historical) { + return new Promise<void>((resolve) => { + return setTimeout(() => { + const suggestions = getTemplateSuggestions( + query.metric, + metricType, + promQueryModeller.renderLabels(query.labels) + ); + + const payload = { + idx, + interaction: { ...interactionToUpdate, suggestions: suggestions, isLoading: false }, + }; + dispatch(updateInteraction(payload)); + resolve(); + }, 1000); + }); + } else { + type SuggestionBody = { + metric: string; + labels: string; + prompt?: string; + }; + + // get all available labels + const metricLabels = await datasource.languageProvider.fetchLabelsWithMatch(query.metric); + + let feedTheAI: SuggestionBody = { + metric: query.metric, + // drop __name__ label because it's not useful + labels: Object.keys(metricLabels) + .filter((label) => label !== '__name__') + .join(','), + }; + + // @ts-ignore llms types issue + let results: Array<llms.vector.SearchResult<TemplateSearchResult>> = []; + if (interaction?.suggestionType === SuggestionType.AI) { + feedTheAI = { ...feedTheAI, prompt: interaction.prompt }; + + // @ts-ignore llms types issue + results = await llms.vector.search<TemplateSearchResult>({ + query: interaction.prompt, + collection: promQLTemplatesCollection, + topK: 5, + filter: { + $or: generateMetricTypeFilters(metricType.split(',').concat(['*'])), + }, + }); + reportInteraction('grafana_prometheus_promqail_vector_results', { + metric: query.metric, + prompt: interaction.prompt, + results: results, + }); + // TODO: handle errors from vector search + } + + const resultsString = results + .map((r) => { + return `${r.payload.promql} | ${r.payload.description} (score=${(r.score * 100).toFixed(1)})`; + }) + .join('\n'); + + const promptMessages = getSuggestMessages({ + promql: query.metric, + question: interaction ? interaction.prompt : '', + metricType: metricType, + labels: labelNames.join(', '), + templates: resultsString, + }); + + return llms.openai + .streamChatCompletions({ + model: OPENAI_MODEL_NAME, + messages: promptMessages, + temperature: 0.5, + }) + .pipe(llms.openai.accumulateContent()) + .subscribe((response) => { + const payload = { + idx, + interaction: { + ...interactionToUpdate, + suggestions: [ + { + query: response, + explanation: '', + }, + ], + isLoading: false, + }, + }; + dispatch(updateInteraction(payload)); + }); + } +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/promQail/state/state.ts b/packages/grafana-prometheus/src/querybuilder/components/promQail/state/state.ts new file mode 100644 index 0000000000000..2c00053f1dc41 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/promQail/state/state.ts @@ -0,0 +1,43 @@ +import { PromVisualQuery } from '../../../types'; +import { Interaction, SuggestionType } from '../types'; + +/** + * Initial state for PromQAIL + * @param query the prometheus query with metric and possible labels + */ +export function initialState(query?: PromVisualQuery, showStartingMessage?: boolean): PromQailState { + return { + query: query ?? { + metric: '', + labels: [], + operations: [], + }, + showExplainer: false, + showStartingMessage: showStartingMessage ?? true, + indicateCheckbox: false, + askForQueryHelp: false, + interactions: [], + }; +} + +/** + * The PromQAIL state object + */ +export interface PromQailState { + query: PromVisualQuery; + showExplainer: boolean; + showStartingMessage: boolean; + indicateCheckbox: boolean; + askForQueryHelp: boolean; + interactions: Interaction[]; +} + +export function createInteraction(suggestionType: SuggestionType, isLoading?: boolean): Interaction { + return { + suggestionType: suggestionType, + prompt: '', + suggestions: [], + isLoading: isLoading ?? false, + explanationIsLoading: false, + }; +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/promQail/state/templates.ts b/packages/grafana-prometheus/src/querybuilder/components/promQail/state/templates.ts new file mode 100644 index 0000000000000..2a56e9f4fbbef --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/promQail/state/templates.ts @@ -0,0 +1,337 @@ +import { QuerySuggestion } from '../types'; + +interface TemplateData { + template: string; + description: string; +} + +export const generalTemplates: TemplateData[] = [ + { + template: 'metric_a{}', + description: 'Get the data for "metric_a"', + }, + { + template: 'avg by(c) (metric_a{})', + description: 'Average of all series in "metric_a" grouped by the label "c"', + }, + { + template: 'count by(d) (metric_a{})', + description: 'Number of series in the metric "metric_a" grouped by the label "d"', + }, + { + template: 'sum by(g) (sum_over_time(metric_a{}[1h]))', + description: + 'For each series in the metric "metric_a", sum all values over 1 hour, then group those series by label "g" and sum.', + }, + { + template: 'count(metric_a{})', + description: 'Count of series in the metric "metric_a"', + }, + { + template: '(metric_a{})', + description: 'Get the data for "metric_a"', + }, + { + template: 'count_over_time(metric_a{}[1h])', + description: 'Number of series of metric_a in a 1 hour interval', + }, + { + template: 'changes(metric_a{}[1m])', + description: 'Number of times the values of each series in metric_a have changed in 1 minute periods', + }, + { + template: 'count(count by(g) (metric_a{}))', + description: 'Total number of series in metric_a', + }, + { + template: 'last_over_time(metric_a{}[1h])', + description: 'For each series in metric_a, get the last value in the 1 hour period.', + }, + { + template: 'sum by(g) (count_over_time(metric_a{}[1h]))', + description: 'Grouped sum over the label "g" of the number of series of metric_a in a 1 hour period', + }, + { + template: 'count(metric_a{} == 99)', + description: 'Number of series of metric_a that have value 99', + }, + { + template: 'min(metric_a{})', + description: 'At each timestamp, find the minimum of all series of the metric "metric_a"', + }, + { + template: 'metric_a{} != 99', + description: 'Series of metric_a which do not have the value 99', + }, + { + template: 'metric_a{} - 99', + description: 'metric_a minus 99', + }, + { + template: 'quantile_over_time(0.99,metric_a{}[1h])', + description: 'The 99th quantile of values of metric_a in 1 hour', + }, + { + template: 'count_values("aaaa",metric_a{})', + description: 'Count number of label values for a label named "aaaa"', + }, +]; + +export const counterTemplates: TemplateData[] = [ + { + template: 'sum by(d) (rate(metric_a{}[1h]))', + description: + 'Sum of the rate of increase or decrease of the metric "metric_a" per 1 hour period, grouped by the label "d"', + }, + { + template: 'rate(metric_a{}[1m])', + description: 'Rate of change of the metric "metric_a" over 1 minute', + }, + { + template: 'sum by(a) (increase(metric_a{}[5m]))', + description: + 'Taking the metric "metric_a" find the increase in 5 minute periods of each series and aggregate sum over the label "a"', + }, + { + template: 'sum(rate(metric_a{}[1m]))', + description: 'Total rate of change of all series of metric "metric_a" in 1 minute intervals', + }, + { + template: 'sum(increase(metric_a{}[10m]))', + description: 'Total increase for each series of metric "metric_a" in 10 minute intervals', + }, + { + template: 'increase(metric_a{}[1h])', + description: 'Increase in all series of "metric_a" in 1 hour period', + }, + { + template: 'sum by(d) (irate(metric_a{}[1h]))', + description: 'Sum of detailed rate of change of the metric "metric_a" over 1 hour grouped by label "d"', + }, + { + template: 'irate(metric_a{}[1h])', + description: 'Detailed rate of change of the metric "metric_a" over 1 hour', + }, + { + template: 'avg by(d) (rate(metric_a{}[1h]))', + description: + 'Taking the rate of change of the metric "metric_a" in a 1 hour period, group by the label "d" and find the average of each group', + }, + { + template: 'topk(5,sum by(g) (rate(metric_a{}[1h])))', + description: 'Top 5 of the summed groups "g" of the rate of change of metric_a', + }, + { + template: 'sum(rate(metric_a{}[1h])) / sum(rate(metric_a{}[1h]))', + description: 'Relative sums of metric_a with different labels', + }, + { + template: 'histogram_quantile(99,rate(metric_a{}[1h]))', + description: '99th percentile of the rate of change of metric_a in 1 hour periods', + }, + { + template: 'avg(rate(metric_a{}[1m]))', + description: 'Average of the rate of all series of metric_a in 1 minute periods', + }, + { + template: 'rate(metric_a{}[5m]) > 99', + description: 'Show series of metric_a only if their rate over 5 minutes is greater than 99', + }, + { + template: 'count by(g) (rate(metric_a{}[1h]))', + description: 'Count of series of metric_a over all labels "g"', + }, +]; + +export const histogramTemplates: TemplateData[] = [ + { + template: 'histogram_quantile(99,sum by(le) (rate(metric_a{}[1h])))', + description: + 'Calculate the rate at which the metric "metric_a" is increasing or decreasing, summed over each bucket label "le", and then calculates the 99th percentile of those rates.', + }, + { + template: 'histogram_quantile(99,sum by(g) (metric_a{}))', + description: '99th percentile of the sum of metric_a grouped by label "g"', + }, + { + template: 'histogram_quantile(99,sum by(g) (irate(metric_a{}[1h])))', + description: '99th percentile of the grouped by "g" sum of the rate of each series in metric_a in an hour', + }, + { + template: 'histogram_quantile(99,metric_a{})', + description: '99th percentile of metric_a', + }, +]; + +export const gaugeTemplates: TemplateData[] = [ + { + template: 'sum by(c) (metric_a{})', + description: 'Sum the metric "metric_a" by each value in label "c"', + }, + { + template: 'sum(metric_a{})', + description: 'Total sum of all the series of the metric named "metric_a"', + }, + { + template: 'max by(dd) (metric_a{})', + description: 'Grouping the series the metric "metric_a" by the label "dd", get the maximum value of each group', + }, + { + template: 'max(metric_a{})', + description: 'Maximum value of all series of the metric "metric_a" ', + }, + { + template: 'avg(metric_a{})', + description: 'Average value of all the series of metric "metric_a"', + }, + { + template: 'metric_a{} > 99', + description: 'Show only the series of metric "metric_a" which currently have value greater than 99', + }, + { + template: 'metric_a{} / 99', + description: 'Values for "metric_a" all divided by 99', + }, + { + template: 'metric_a{} == 99', + description: 'Show series of metric_a that have value 99', + }, + { + template: 'sum_over_time(metric_a{}[1h])', + description: 'Sum each series of metric_a over 1 hour', + }, + { + template: 'avg_over_time(metric_a{}[1h])', + description: 'Average of each series of metric_a in a 1 hour period', + }, + { + template: 'sum(sum_over_time(metric_a{}[1h]))', + description: 'Sum of all values in all series in a 1 hour period', + }, + { + template: 'delta(metric_a{}[1m])', + description: 'Span or delta (maximum - minimum) of values of the metric "metric_a" in a 1 minute period. ', + }, + { + template: 'avg by(g) (avg_over_time(metric_a{}[1h]))', + description: + 'For 1 hour, take each series and find the average, then group by label "g" and find the average of each group', + }, + { + template: 'max_over_time(metric_a{}[1h])', + description: 'Maximum values of each series in metric "metric_a" in a 1 hour period', + }, + { + template: 'metric_a{} * 99', + description: 'Values of metric_a multiplied by 99', + }, + { + template: 'metric_a{} < 99', + description: 'Series of metric_a that have values less than 99', + }, + { + template: 'max by() (max_over_time(metric_a{}[1h]))', + description: 'Find maximum value of all series in 1 hour periods', + }, + { + template: 'topk(99,metric_a{})', + description: 'First 5 series of metric_a that have the highest values', + }, + { + template: 'min by(g) (metric_a{})', + description: 'Minimum values of the series of metric_a grouped by label "g"', + }, + { + template: 'topk(10,sum by(g) (metric_a{}))', + description: "Top 10 of the series of metric_a grouped and summed by the label 'g'", + }, + { + template: 'avg(avg_over_time(metric_a{}[1h]))', + description: 'Average of all values inside a 1 hour period', + }, + { + template: 'quantile by(h) (0.95,metric_a{})', + description: 'Calculate 95th percentile of metric_a when aggregated by the label "h"', + }, + { + template: 'avg by(g) (metric_a{} > 99)', + description: + 'Taking all series of metric_a with value greater than 99, group by label "g" and find the average of each group', + }, + { + template: 'sum(metric_a{}) / 99', + description: 'Sum of all series of metric_a divided by 99', + }, + { + template: 'count(sum by(g) (metric_a{}))', + description: 'Number of series of metric_a grouped by the label "g"', + }, + { + template: 'max(max_over_time(metric_a{}[1h]))', + description: 'Find the max value of all series of metric_a in a 1 hour period', + }, +]; + +function processTemplate(templateData: TemplateData, metric: string, labels: string): QuerySuggestion { + return { + query: templateData.template.replace('metric_a', metric).replace('{}', labels), + explanation: templateData.description.replace('metric_a', metric), + }; +} + +export function getTemplateSuggestions(metricName: string, metricType: string, labels: string): QuerySuggestion[] { + let templateSuggestions: QuerySuggestion[] = []; + switch (metricType) { + case 'counter': + templateSuggestions = templateSuggestions.concat( + counterTemplates + .map((t) => processTemplate(t, metricName, labels)) + .sort(() => Math.random() - 0.5) + .slice(0, 2) + ); + templateSuggestions = templateSuggestions.concat( + generalTemplates + .map((t) => processTemplate(t, metricName, labels)) + .sort(() => Math.random() - 0.5) + .slice(0, 3) + ); + break; + case 'gauge': + templateSuggestions = templateSuggestions.concat( + gaugeTemplates + .map((t) => processTemplate(t, metricName, labels)) + .sort(() => Math.random() - 0.5) + .slice(0, 2) + ); + templateSuggestions = templateSuggestions.concat( + generalTemplates + .map((t) => processTemplate(t, metricName, labels)) + .sort(() => Math.random() - 0.5) + .slice(0, 3) + ); + break; + case 'histogram': + templateSuggestions = templateSuggestions.concat( + histogramTemplates + .map((t) => processTemplate(t, metricName, labels)) + .sort(() => Math.random() - 0.5) + .slice(0, 2) + ); + templateSuggestions = templateSuggestions.concat( + generalTemplates + .map((t) => processTemplate(t, metricName, labels)) + .sort(() => Math.random() - 0.5) + .slice(0, 3) + ); + break; + default: + templateSuggestions = templateSuggestions.concat( + generalTemplates + .map((t) => processTemplate(t, metricName, labels)) + .sort(() => Math.random() - 0.5) + .slice(0, 5) + ); + break; + } + return templateSuggestions; +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/promQail/types.ts b/packages/grafana-prometheus/src/querybuilder/components/promQail/types.ts new file mode 100644 index 0000000000000..e8c5005cfacae --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/promQail/types.ts @@ -0,0 +1,17 @@ +export type QuerySuggestion = { + query: string; + explanation: string; +}; + +export enum SuggestionType { + Historical = 'historical', + AI = 'AI', +} + +export type Interaction = { + prompt: string; + suggestionType: SuggestionType; + suggestions: QuerySuggestion[]; + isLoading: boolean; + explanationIsLoading: boolean; +}; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/hooks/useFlag.test.ts b/packages/grafana-prometheus/src/querybuilder/hooks/useFlag.test.ts similarity index 51% rename from public/app/plugins/datasource/prometheus/querybuilder/shared/hooks/useFlag.test.ts rename to packages/grafana-prometheus/src/querybuilder/hooks/useFlag.test.ts index 4c2ada605b278..4c7ca4e6393ba 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/hooks/useFlag.test.ts +++ b/packages/grafana-prometheus/src/querybuilder/hooks/useFlag.test.ts @@ -1,10 +1,9 @@ import { act, renderHook } from '@testing-library/react'; -import { lokiQueryEditorExplainKey, promQueryEditorExplainKey, useFlag } from './useFlag'; +import { promQueryEditorExplainKey, useFlag } from './useFlag'; describe('useFlag Hook', () => { beforeEach(() => { - window.localStorage.removeItem(lokiQueryEditorExplainKey); window.localStorage.removeItem(promQueryEditorExplainKey); }); @@ -21,17 +20,4 @@ describe('useFlag Hook', () => { }); expect(result.current.flag).toBe(false); }); - - it('should update different flags at once without conflict', () => { - const { result } = renderHook(() => useFlag(promQueryEditorExplainKey, false)); - expect(result.current.flag).toBe(false); - act(() => { - result.current.setFlag(true); - }); - expect(result.current.flag).toBe(true); - - const { result: result2 } = renderHook(() => useFlag(lokiQueryEditorExplainKey, false)); - expect(result.current.flag).toBe(true); - expect(result2.current.flag).toBe(false); - }); }); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/hooks/useFlag.ts b/packages/grafana-prometheus/src/querybuilder/hooks/useFlag.ts similarity index 63% rename from public/app/plugins/datasource/prometheus/querybuilder/shared/hooks/useFlag.ts rename to packages/grafana-prometheus/src/querybuilder/hooks/useFlag.ts index fe0630a70c30a..183a956d45a55 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/hooks/useFlag.ts +++ b/packages/grafana-prometheus/src/querybuilder/hooks/useFlag.ts @@ -1,17 +1,10 @@ import { useCallback, useState } from 'react'; -import store from '../../../../../../core/store'; +import store from '../../gcopypaste/app/core/store'; export const promQueryEditorExplainKey = 'PrometheusQueryEditorExplainDefault'; -export const promQueryEditorRawQueryKey = 'PrometheusQueryEditorRawQueryDefault'; -export const lokiQueryEditorExplainKey = 'LokiQueryEditorExplainDefault'; -export const lokiQueryEditorRawQueryKey = 'LokiQueryEditorRawQueryDefault'; -export type QueryEditorFlags = - | typeof promQueryEditorExplainKey - | typeof promQueryEditorRawQueryKey - | typeof lokiQueryEditorExplainKey - | typeof lokiQueryEditorRawQueryKey; +export type QueryEditorFlags = typeof promQueryEditorExplainKey; function getFlagValue(key: QueryEditorFlags, defaultValue = false): boolean { const val = store.get(key); @@ -26,7 +19,7 @@ type UseFlagHookReturnType = { flag: boolean; setFlag: (val: boolean) => void }; /** * - * Use and store value of explain/rawquery switch in local storage. + * Use and store value of explain switch in local storage. * Needs to be a hook with local state to trigger re-renders. */ export function useFlag(key: QueryEditorFlags, defaultValue = false): UseFlagHookReturnType { diff --git a/packages/grafana-prometheus/src/querybuilder/operationUtils.test.ts b/packages/grafana-prometheus/src/querybuilder/operationUtils.test.ts new file mode 100644 index 0000000000000..da1aabe924ec9 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/operationUtils.test.ts @@ -0,0 +1,208 @@ +import { + createAggregationOperation, + createAggregationOperationWithParam, + getOperationParamId, + isConflictingSelector, +} from './operationUtils'; + +describe('createAggregationOperation', () => { + it('returns correct aggregation definitions with overrides', () => { + expect(createAggregationOperation('test_aggregation', { category: 'test_category' })).toMatchObject([ + { + addOperationHandler: {}, + alternativesKey: 'plain aggregations', + category: 'test_category', + defaultParams: [], + explainHandler: {}, + id: 'test_aggregation', + name: 'Test aggregation', + paramChangedHandler: {}, + params: [ + { + name: 'By label', + optional: true, + restParam: true, + type: 'string', + }, + ], + renderer: {}, + }, + { + alternativesKey: 'aggregations by', + category: 'test_category', + defaultParams: [''], + explainHandler: {}, + hideFromList: true, + id: '__test_aggregation_by', + name: 'Test aggregation by', + paramChangedHandler: {}, + params: [ + { + editor: {}, + name: 'Label', + optional: true, + restParam: true, + type: 'string', + }, + ], + renderer: {}, + }, + { + alternativesKey: 'aggregations by', + category: 'test_category', + defaultParams: [''], + explainHandler: {}, + hideFromList: true, + id: '__test_aggregation_without', + name: 'Test aggregation without', + paramChangedHandler: {}, + params: [ + { + name: 'Label', + optional: true, + restParam: true, + type: 'string', + }, + ], + renderer: {}, + }, + ]); + }); +}); + +describe('createAggregationOperationWithParams', () => { + it('returns correct aggregation definitions with overrides and params', () => { + expect( + createAggregationOperationWithParam( + 'test_aggregation', + { + params: [{ name: 'K-value', type: 'number' }], + defaultParams: [5], + }, + { category: 'test_category' } + ) + ).toMatchObject([ + { + addOperationHandler: {}, + alternativesKey: 'plain aggregations', + category: 'test_category', + defaultParams: [5], + explainHandler: {}, + id: 'test_aggregation', + name: 'Test aggregation', + paramChangedHandler: {}, + params: [ + { name: 'K-value', type: 'number' }, + { name: 'By label', optional: true, restParam: true, type: 'string' }, + ], + renderer: {}, + }, + { + alternativesKey: 'aggregations by', + category: 'test_category', + defaultParams: [5, ''], + explainHandler: {}, + hideFromList: true, + id: '__test_aggregation_by', + name: 'Test aggregation by', + paramChangedHandler: {}, + params: [ + { name: 'K-value', type: 'number' }, + { editor: {}, name: 'Label', optional: true, restParam: true, type: 'string' }, + ], + renderer: {}, + }, + { + alternativesKey: 'aggregations by', + category: 'test_category', + defaultParams: [5, ''], + explainHandler: {}, + hideFromList: true, + id: '__test_aggregation_without', + name: 'Test aggregation without', + paramChangedHandler: {}, + params: [ + { name: 'K-value', type: 'number' }, + { name: 'Label', optional: true, restParam: true, type: 'string' }, + ], + renderer: {}, + }, + ]); + }); + it('returns correct query string using aggregation definitions with overrides and number type param', () => { + const def = createAggregationOperationWithParam( + 'test_aggregation', + { + params: [{ name: 'K-value', type: 'number' }], + defaultParams: [5], + }, + { category: 'test_category' } + ); + + const topKByDefinition = def[1]; + expect( + topKByDefinition.renderer( + { id: '__topk_by', params: ['5', 'source', 'place'] }, + def[1], + 'rate({place="luna"} |= `` [5m])' + ) + ).toBe('test_aggregation by(source, place) (5, rate({place="luna"} |= `` [5m]))'); + }); + + it('returns correct query string using aggregation definitions with overrides and string type param', () => { + const def = createAggregationOperationWithParam( + 'test_aggregation', + { + params: [{ name: 'Identifier', type: 'string' }], + defaultParams: ['count'], + }, + { category: 'test_category' } + ); + + const countValueDefinition = def[1]; + expect( + countValueDefinition.renderer( + { id: 'count_values', params: ['5', 'source', 'place'] }, + def[1], + 'rate({place="luna"} |= `` [5m])' + ) + ).toBe('test_aggregation by(source, place) ("5", rate({place="luna"} |= `` [5m]))'); + }); +}); + +describe('isConflictingSelector', () => { + it('returns true if selector is conflicting', () => { + const newLabel = { label: 'job', op: '!=', value: 'tns/app' }; + const labels = [ + { label: 'job', op: '=', value: 'tns/app' }, + { label: 'job', op: '!=', value: 'tns/app' }, + ]; + expect(isConflictingSelector(newLabel, labels)).toBe(true); + }); + + it('returns false if selector is not complete', () => { + const newLabel = { label: 'job', op: '', value: 'tns/app' }; + const labels = [ + { label: 'job', op: '=', value: 'tns/app' }, + { label: 'job', op: '', value: 'tns/app' }, + ]; + expect(isConflictingSelector(newLabel, labels)).toBe(false); + }); + + it('returns false if selector is not conflicting', () => { + const newLabel = { label: 'host', op: '=', value: 'docker-desktop' }; + const labels = [ + { label: 'job', op: '=', value: 'tns/app' }, + { label: 'host', op: '=', value: 'docker-desktop' }, + ]; + expect(isConflictingSelector(newLabel, labels)).toBe(false); + }); +}); + +describe('getOperationParamId', () => { + it('Generates correct id for operation param', () => { + const operationId = 'abc'; + const paramId = 0; + expect(getOperationParamId(operationId, paramId)).toBe('operations.abc.param.0'); + }); +}); diff --git a/packages/grafana-prometheus/src/querybuilder/operationUtils.ts b/packages/grafana-prometheus/src/querybuilder/operationUtils.ts new file mode 100644 index 0000000000000..b7eec3ec160ff --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/operationUtils.ts @@ -0,0 +1,351 @@ +import { capitalize } from 'lodash'; +import pluralize from 'pluralize'; + +import { SelectableValue } from '@grafana/data/src'; + +import { LabelParamEditor } from './components/LabelParamEditor'; +import { + QueryBuilderLabelFilter, + QueryBuilderOperation, + QueryBuilderOperationDef, + QueryBuilderOperationParamDef, + QueryBuilderOperationParamValue, + QueryWithOperations, +} from './shared/types'; +import { PromVisualQueryOperationCategory } from './types'; + +export function functionRendererLeft(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + const params = renderParams(model, def, innerExpr); + const str = model.id + '('; + + if (innerExpr) { + params.push(innerExpr); + } + + return str + params.join(', ') + ')'; +} + +export function functionRendererRight(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + const params = renderParams(model, def, innerExpr); + const str = model.id + '('; + + if (innerExpr) { + params.unshift(innerExpr); + } + + return str + params.join(', ') + ')'; +} + +function rangeRendererWithParams( + model: QueryBuilderOperation, + def: QueryBuilderOperationDef, + innerExpr: string, + renderLeft: boolean +) { + if (def.params.length < 2) { + throw `Cannot render a function with params of length [${def.params.length}]`; + } + + let rangeVector = (model.params ?? [])[0] ?? '5m'; + + // Next frame the remaining parameters, but get rid of the first one because it's used to move the + // instant vector into a range vector. + const params = renderParams( + { + ...model, + params: model.params.slice(1), + }, + { + ...def, + params: def.params.slice(1), + defaultParams: def.defaultParams.slice(1), + }, + innerExpr + ); + + const str = model.id + '('; + + // Depending on the renderLeft variable, render parameters to the left or right + // renderLeft === true (renderLeft) => (param1, param2, rangeVector[...]) + // renderLeft === false (renderRight) => (rangeVector[...], param1, param2) + if (innerExpr) { + renderLeft ? params.push(`${innerExpr}[${rangeVector}]`) : params.unshift(`${innerExpr}[${rangeVector}]`); + } + + // stick everything together + return str + params.join(', ') + ')'; +} + +export function rangeRendererRightWithParams( + model: QueryBuilderOperation, + def: QueryBuilderOperationDef, + innerExpr: string +) { + return rangeRendererWithParams(model, def, innerExpr, false); +} + +export function rangeRendererLeftWithParams( + model: QueryBuilderOperation, + def: QueryBuilderOperationDef, + innerExpr: string +) { + return rangeRendererWithParams(model, def, innerExpr, true); +} + +function renderParams(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + return (model.params ?? []).map((value, index) => { + const paramDef = def.params[index]; + if (paramDef.type === 'string') { + return '"' + value + '"'; + } + + return value; + }); +} + +export function defaultAddOperationHandler<T extends QueryWithOperations>(def: QueryBuilderOperationDef, query: T) { + const newOperation: QueryBuilderOperation = { + id: def.id, + params: def.defaultParams, + }; + + return { + ...query, + operations: [...query.operations, newOperation], + }; +} + +export function getPromOperationDisplayName(funcName: string) { + return capitalize(funcName.replace(/_/g, ' ')); +} + +export function getOperationParamId(operationId: string, paramIndex: number) { + return `operations.${operationId}.param.${paramIndex}`; +} + +export function getRangeVectorParamDef(withRateInterval = false): QueryBuilderOperationParamDef { + const param: QueryBuilderOperationParamDef = { + name: 'Range', + type: 'string', + options: [ + { + label: '$__interval', + value: '$__interval', + // tooltip: 'Dynamic interval based on max data points, scrape and min interval', + }, + { label: '1m', value: '1m' }, + { label: '5m', value: '5m' }, + { label: '10m', value: '10m' }, + { label: '1h', value: '1h' }, + { label: '24h', value: '24h' }, + ], + }; + + if (withRateInterval) { + (param.options as Array<SelectableValue<string>>).unshift({ + label: '$__rate_interval', + value: '$__rate_interval', + // tooltip: 'Always above 4x scrape interval', + }); + } + + return param; +} + +export function createAggregationOperation( + name: string, + overrides: Partial<QueryBuilderOperationDef> = {} +): QueryBuilderOperationDef[] { + const operations: QueryBuilderOperationDef[] = [ + { + id: name, + name: getPromOperationDisplayName(name), + params: [ + { + name: 'By label', + type: 'string', + restParam: true, + optional: true, + }, + ], + defaultParams: [], + alternativesKey: 'plain aggregations', + category: PromVisualQueryOperationCategory.Aggregations, + renderer: functionRendererLeft, + paramChangedHandler: getOnLabelAddedHandler(`__${name}_by`), + explainHandler: getAggregationExplainer(name, ''), + addOperationHandler: defaultAddOperationHandler, + ...overrides, + }, + { + id: `__${name}_by`, + name: `${getPromOperationDisplayName(name)} by`, + params: [ + { + name: 'Label', + type: 'string', + restParam: true, + optional: true, + editor: LabelParamEditor, + }, + ], + defaultParams: [''], + alternativesKey: 'aggregations by', + category: PromVisualQueryOperationCategory.Aggregations, + renderer: getAggregationByRenderer(name), + paramChangedHandler: getLastLabelRemovedHandler(name), + explainHandler: getAggregationExplainer(name, 'by'), + addOperationHandler: defaultAddOperationHandler, + hideFromList: true, + ...overrides, + }, + { + id: `__${name}_without`, + name: `${getPromOperationDisplayName(name)} without`, + params: [ + { + name: 'Label', + type: 'string', + restParam: true, + optional: true, + editor: LabelParamEditor, + }, + ], + defaultParams: [''], + alternativesKey: 'aggregations by', + category: PromVisualQueryOperationCategory.Aggregations, + renderer: getAggregationWithoutRenderer(name), + paramChangedHandler: getLastLabelRemovedHandler(name), + explainHandler: getAggregationExplainer(name, 'without'), + addOperationHandler: defaultAddOperationHandler, + hideFromList: true, + ...overrides, + }, + ]; + + return operations; +} + +export function createAggregationOperationWithParam( + name: string, + paramsDef: { params: QueryBuilderOperationParamDef[]; defaultParams: QueryBuilderOperationParamValue[] }, + overrides: Partial<QueryBuilderOperationDef> = {} +): QueryBuilderOperationDef[] { + const operations = createAggregationOperation(name, overrides); + operations[0].params.unshift(...paramsDef.params); + operations[1].params.unshift(...paramsDef.params); + operations[2].params.unshift(...paramsDef.params); + operations[0].defaultParams = paramsDef.defaultParams; + operations[1].defaultParams = [...paramsDef.defaultParams, '']; + operations[2].defaultParams = [...paramsDef.defaultParams, '']; + operations[1].renderer = getAggregationByRendererWithParameter(name); + operations[2].renderer = getAggregationByRendererWithParameter(name); + return operations; +} + +function getAggregationByRenderer(aggregation: string) { + return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + return `${aggregation} by(${model.params.join(', ')}) (${innerExpr})`; + }; +} + +function getAggregationWithoutRenderer(aggregation: string) { + return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + return `${aggregation} without(${model.params.join(', ')}) (${innerExpr})`; + }; +} + +/** + * Very simple poc implementation, needs to be modified to support all aggregation operators + */ +export function getAggregationExplainer(aggregationName: string, mode: 'by' | 'without' | '') { + return function aggregationExplainer(model: QueryBuilderOperation) { + const labels = model.params.map((label) => `\`${label}\``).join(' and '); + const labelWord = pluralize('label', model.params.length); + + switch (mode) { + case 'by': + return `Calculates ${aggregationName} over dimensions while preserving ${labelWord} ${labels}.`; + case 'without': + return `Calculates ${aggregationName} over the dimensions ${labels}. All other labels are preserved.`; + default: + return `Calculates ${aggregationName} over the dimensions.`; + } + }; +} + +function getAggregationByRendererWithParameter(aggregation: string) { + return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + const restParamIndex = def.params.findIndex((param) => param.restParam); + const params = model.params.slice(0, restParamIndex); + const restParams = model.params.slice(restParamIndex); + + return `${aggregation} by(${restParams.join(', ')}) (${params + .map((param, idx) => (def.params[idx].type === 'string' ? `\"${param}\"` : param)) + .join(', ')}, ${innerExpr})`; + }; +} + +/** + * This function will transform operations without labels to their plan aggregation operation + */ +export function getLastLabelRemovedHandler(changeToOperationId: string) { + return function onParamChanged(index: number, op: QueryBuilderOperation, def: QueryBuilderOperationDef) { + // If definition has more params then is defined there are no optional rest params anymore. + // We then transform this operation into a different one + if (op.params.length < def.params.length) { + return { + ...op, + id: changeToOperationId, + }; + } + + return op; + }; +} + +export function getOnLabelAddedHandler(changeToOperationId: string) { + return function onParamChanged(index: number, op: QueryBuilderOperation, def: QueryBuilderOperationDef) { + // Check if we actually have the label param. As it's optional the aggregation can have one less, which is the + // case of just simple aggregation without label. When user adds the label it now has the same number of params + // as its definition, and now we can change it to its `_by` variant. + if (op.params.length === def.params.length) { + return { + ...op, + id: changeToOperationId, + }; + } + return op; + }; +} + +export function isConflictingSelector( + newLabel: Partial<QueryBuilderLabelFilter>, + labels: Array<Partial<QueryBuilderLabelFilter>> +): boolean { + if (!newLabel.label || !newLabel.op || !newLabel.value) { + return false; + } + + if (labels.length < 2) { + return false; + } + + const operationIsNegative = newLabel.op.toString().startsWith('!'); + + const candidates = labels.filter( + (label) => label.label === newLabel.label && label.value === newLabel.value && label.op !== newLabel.op + ); + + const conflict = candidates.some((candidate) => { + if (operationIsNegative && candidate?.op?.toString().startsWith('!') === false) { + return true; + } + if (operationIsNegative === false && candidate?.op?.toString().startsWith('!')) { + return true; + } + return false; + }); + + return conflict; +} diff --git a/packages/grafana-prometheus/src/querybuilder/operations.ts b/packages/grafana-prometheus/src/querybuilder/operations.ts new file mode 100644 index 0000000000000..3b445efa37e00 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/operations.ts @@ -0,0 +1,376 @@ +import { binaryScalarOperations } from './binaryScalarOperations'; +import { LabelParamEditor } from './components/LabelParamEditor'; +import { + defaultAddOperationHandler, + functionRendererLeft, + functionRendererRight, + getPromOperationDisplayName, + getRangeVectorParamDef, + rangeRendererLeftWithParams, + rangeRendererRightWithParams, +} from './operationUtils'; +import { + QueryBuilderOperation, + QueryBuilderOperationDef, + QueryWithOperations, + VisualQueryModeller, +} from './shared/types'; +import { PromOperationId, PromVisualQuery, PromVisualQueryOperationCategory } from './types'; + +export function getOperationDefinitions(): QueryBuilderOperationDef[] { + const list: QueryBuilderOperationDef[] = [ + { + id: PromOperationId.HistogramQuantile, + name: 'Histogram quantile', + params: [{ name: 'Quantile', type: 'number', options: [0.99, 0.95, 0.9, 0.75, 0.5, 0.25] }], + defaultParams: [0.9], + category: PromVisualQueryOperationCategory.Functions, + renderer: functionRendererLeft, + addOperationHandler: defaultAddOperationHandler, + }, + { + id: PromOperationId.LabelReplace, + name: 'Label replace', + params: [ + { name: 'Destination label', type: 'string' }, + { name: 'Replacement', type: 'string' }, + { name: 'Source label', type: 'string' }, + { name: 'Regex', type: 'string' }, + ], + category: PromVisualQueryOperationCategory.Functions, + defaultParams: ['', '$1', '', '(.*)'], + renderer: functionRendererRight, + addOperationHandler: defaultAddOperationHandler, + }, + { + id: PromOperationId.Ln, + name: 'Ln', + params: [], + defaultParams: [], + category: PromVisualQueryOperationCategory.Functions, + renderer: functionRendererLeft, + addOperationHandler: defaultAddOperationHandler, + }, + createRangeFunction(PromOperationId.Changes), + createRangeFunction(PromOperationId.Rate, true), + createRangeFunction(PromOperationId.Irate), + createRangeFunction(PromOperationId.Increase, true), + createRangeFunction(PromOperationId.Idelta), + createRangeFunction(PromOperationId.Delta), + createFunction({ + id: PromOperationId.HoltWinters, + params: [ + getRangeVectorParamDef(), + { name: 'Smoothing Factor', type: 'number' }, + { name: 'Trend Factor', type: 'number' }, + ], + defaultParams: ['$__interval', 0.5, 0.5], + alternativesKey: 'range function', + category: PromVisualQueryOperationCategory.RangeFunctions, + renderer: rangeRendererRightWithParams, + addOperationHandler: addOperationWithRangeVector, + changeTypeHandler: operationTypeChangedHandlerForRangeFunction, + }), + createFunction({ + id: PromOperationId.PredictLinear, + params: [getRangeVectorParamDef(), { name: 'Seconds from now', type: 'number' }], + defaultParams: ['$__interval', 60], + alternativesKey: 'range function', + category: PromVisualQueryOperationCategory.RangeFunctions, + renderer: rangeRendererRightWithParams, + addOperationHandler: addOperationWithRangeVector, + changeTypeHandler: operationTypeChangedHandlerForRangeFunction, + }), + createFunction({ + id: PromOperationId.QuantileOverTime, + params: [getRangeVectorParamDef(), { name: 'Quantile', type: 'number' }], + defaultParams: ['$__interval', 0.5], + alternativesKey: 'overtime function', + category: PromVisualQueryOperationCategory.RangeFunctions, + renderer: rangeRendererLeftWithParams, + addOperationHandler: addOperationWithRangeVector, + changeTypeHandler: operationTypeChangedHandlerForRangeFunction, + }), + ...binaryScalarOperations, + { + id: PromOperationId.NestedQuery, + name: 'Binary operation with query', + params: [], + defaultParams: [], + category: PromVisualQueryOperationCategory.BinaryOps, + renderer: (model, def, innerExpr) => innerExpr, + addOperationHandler: addNestedQueryHandler, + }, + createFunction({ id: PromOperationId.Abs }), + createFunction({ id: PromOperationId.Absent }), + createFunction({ + id: PromOperationId.Acos, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.Acosh, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.Asin, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.Asinh, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.Atan, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.Atanh, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ id: PromOperationId.Ceil }), + createFunction({ + id: PromOperationId.Clamp, + name: 'Clamp', + params: [ + { name: 'Minimum Scalar', type: 'number' }, + { name: 'Maximum Scalar', type: 'number' }, + ], + defaultParams: [1, 1], + }), + + createFunction({ + id: PromOperationId.ClampMax, + params: [{ name: 'Maximum Scalar', type: 'number' }], + defaultParams: [1], + }), + createFunction({ + id: PromOperationId.ClampMin, + params: [{ name: 'Minimum Scalar', type: 'number' }], + defaultParams: [1], + }), + createFunction({ + id: PromOperationId.Cos, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.Cosh, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.DayOfMonth, + category: PromVisualQueryOperationCategory.Time, + }), + createFunction({ + id: PromOperationId.DayOfWeek, + category: PromVisualQueryOperationCategory.Time, + }), + createFunction({ + id: PromOperationId.DayOfYear, + category: PromVisualQueryOperationCategory.Time, + }), + createFunction({ + id: PromOperationId.DaysInMonth, + category: PromVisualQueryOperationCategory.Time, + }), + createFunction({ id: PromOperationId.Deg }), + createRangeFunction(PromOperationId.Deriv), + // + createFunction({ id: PromOperationId.Exp }), + createFunction({ id: PromOperationId.Floor }), + createFunction({ id: PromOperationId.Group }), + createFunction({ id: PromOperationId.Hour }), + createFunction({ + id: PromOperationId.LabelJoin, + params: [ + { + name: 'Destination Label', + type: 'string', + editor: LabelParamEditor, + }, + { + name: 'Separator', + type: 'string', + }, + { + name: 'Source Label', + type: 'string', + restParam: true, + optional: true, + editor: LabelParamEditor, + }, + ], + defaultParams: ['', ',', ''], + renderer: labelJoinRenderer, + addOperationHandler: labelJoinAddOperationHandler, + }), + createFunction({ id: PromOperationId.Log10 }), + createFunction({ id: PromOperationId.Log2 }), + createFunction({ id: PromOperationId.Minute }), + createFunction({ id: PromOperationId.Month }), + createFunction({ + id: PromOperationId.Pi, + renderer: (model) => `${model.id}()`, + }), + createFunction({ + id: PromOperationId.Quantile, + params: [{ name: 'Value', type: 'number' }], + defaultParams: [1], + renderer: functionRendererLeft, + }), + createFunction({ id: PromOperationId.Rad }), + createRangeFunction(PromOperationId.Resets), + createFunction({ + id: PromOperationId.Round, + category: PromVisualQueryOperationCategory.Functions, + params: [{ name: 'To Nearest', type: 'number' }], + defaultParams: [1], + }), + createFunction({ id: PromOperationId.Scalar }), + createFunction({ id: PromOperationId.Sgn }), + createFunction({ id: PromOperationId.Sin, category: PromVisualQueryOperationCategory.Trigonometric }), + createFunction({ + id: PromOperationId.Sinh, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ id: PromOperationId.Sort }), + createFunction({ id: PromOperationId.SortDesc }), + createFunction({ id: PromOperationId.Sqrt }), + createFunction({ id: PromOperationId.Stddev }), + createFunction({ + id: PromOperationId.Tan, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.Tanh, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.Time, + renderer: (model) => `${model.id}()`, + }), + createFunction({ id: PromOperationId.Timestamp }), + createFunction({ + id: PromOperationId.Vector, + params: [{ name: 'Value', type: 'number' }], + defaultParams: [1], + renderer: (model) => `${model.id}(${model.params[0]})`, + }), + createFunction({ id: PromOperationId.Year }), + ]; + + return list; +} + +export function createFunction(definition: Partial<QueryBuilderOperationDef>): QueryBuilderOperationDef { + return { + ...definition, + id: definition.id!, + name: definition.name ?? getPromOperationDisplayName(definition.id!), + params: definition.params ?? [], + defaultParams: definition.defaultParams ?? [], + category: definition.category ?? PromVisualQueryOperationCategory.Functions, + renderer: definition.renderer ?? (definition.params ? functionRendererRight : functionRendererLeft), + addOperationHandler: definition.addOperationHandler ?? defaultAddOperationHandler, + }; +} + +export function createRangeFunction(name: string, withRateInterval = false): QueryBuilderOperationDef { + return { + id: name, + name: getPromOperationDisplayName(name), + params: [getRangeVectorParamDef(withRateInterval)], + defaultParams: [withRateInterval ? '$__rate_interval' : '$__interval'], + alternativesKey: 'range function', + category: PromVisualQueryOperationCategory.RangeFunctions, + renderer: operationWithRangeVectorRenderer, + addOperationHandler: addOperationWithRangeVector, + changeTypeHandler: operationTypeChangedHandlerForRangeFunction, + }; +} + +function operationTypeChangedHandlerForRangeFunction( + operation: QueryBuilderOperation, + newDef: QueryBuilderOperationDef +) { + // validate current parameter + if (operation.params[0] === '$__rate_interval' && newDef.defaultParams[0] !== '$__rate_interval') { + operation.params = newDef.defaultParams; + } else if (operation.params[0] === '$__interval' && newDef.defaultParams[0] !== '$__interval') { + operation.params = newDef.defaultParams; + } + + return operation; +} + +export function operationWithRangeVectorRenderer( + model: QueryBuilderOperation, + def: QueryBuilderOperationDef, + innerExpr: string +) { + let rangeVector = (model.params ?? [])[0] ?? '5m'; + return `${def.id}(${innerExpr}[${rangeVector}])`; +} + +/** + * Since there can only be one operation with range vector this will replace the current one (if one was added ) + */ +export function addOperationWithRangeVector( + def: QueryBuilderOperationDef, + query: PromVisualQuery, + modeller: VisualQueryModeller +) { + const newOperation: QueryBuilderOperation = { + id: def.id, + params: def.defaultParams, + }; + + if (query.operations.length > 0) { + // If operation exists it has to be in the registry so no point to check if it was found + const firstOp = modeller.getOperationDef(query.operations[0].id)!; + + if (firstOp.addOperationHandler === addOperationWithRangeVector) { + return { + ...query, + operations: [newOperation, ...query.operations.slice(1)], + }; + } + } + + return { + ...query, + operations: [newOperation, ...query.operations], + }; +} + +function addNestedQueryHandler(def: QueryBuilderOperationDef, query: PromVisualQuery): PromVisualQuery { + return { + ...query, + binaryQueries: [ + ...(query.binaryQueries ?? []), + { + operator: '/', + query, + }, + ], + }; +} + +function labelJoinRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + if (typeof model.params[1] !== 'string') { + throw 'The separator must be a string'; + } + const separator = `"${model.params[1]}"`; + return `${model.id}(${innerExpr}, "${model.params[0]}", ${separator}, "${model.params.slice(2).join(separator)}")`; +} + +function labelJoinAddOperationHandler<T extends QueryWithOperations>(def: QueryBuilderOperationDef, query: T) { + const newOperation: QueryBuilderOperation = { + id: def.id, + params: def.defaultParams, + }; + + return { + ...query, + operations: [...query.operations, newOperation], + }; +} diff --git a/packages/grafana-prometheus/src/querybuilder/parsing.test.ts b/packages/grafana-prometheus/src/querybuilder/parsing.test.ts new file mode 100644 index 0000000000000..08fa72b7b8a1f --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/parsing.test.ts @@ -0,0 +1,720 @@ +import { buildVisualQueryFromString } from './parsing'; +import { PromOperationId, PromVisualQuery } from './types'; + +describe('buildVisualQueryFromString', () => { + it('creates no errors for empty query', () => { + expect(buildVisualQueryFromString('')).toEqual( + noErrors({ + labels: [], + operations: [], + metric: '', + }) + ); + }); + it('parses simple binary comparison', () => { + expect(buildVisualQueryFromString('{app="aggregator"} == 11')).toEqual({ + query: { + labels: [ + { + label: 'app', + op: '=', + value: 'aggregator', + }, + ], + metric: '', + operations: [ + { + id: PromOperationId.EqualTo, + params: [11, false], + }, + ], + }, + errors: [], + }); + }); + + // This still fails because loki doesn't properly parse the bool operator + it('parses simple query with with boolean operator', () => { + expect(buildVisualQueryFromString('{app="aggregator"} == bool 12')).toEqual({ + query: { + labels: [ + { + label: 'app', + op: '=', + value: 'aggregator', + }, + ], + metric: '', + operations: [ + { + id: PromOperationId.EqualTo, + params: [12, true], + }, + ], + }, + errors: [], + }); + }); + it('parses simple query', () => { + expect(buildVisualQueryFromString('counters_logins{app="frontend"}')).toEqual( + noErrors({ + metric: 'counters_logins', + labels: [ + { + op: '=', + value: 'frontend', + label: 'app', + }, + ], + operations: [], + }) + ); + }); + + describe('nested binary operation errors in visual query editor', () => { + // Visual query builder does not currently have support for nested binary operations, for now we should throw an error in the UI letting users know that their query will be misinterpreted + it('throws error when visual query parse is ambiguous', () => { + expect( + buildVisualQueryFromString('topk(5, node_arp_entries / node_arp_entries{cluster="dev-eu-west-2"})') + ).toMatchObject({ + errors: [ + { + from: 8, + text: 'Query parsing is ambiguous.', + to: 68, + }, + ], + }); + }); + it('throws error when visual query parse with aggregation is ambiguous (scalar)', () => { + expect(buildVisualQueryFromString('topk(5, 1 / 2)')).toMatchObject({ + errors: [ + { + from: 8, + text: 'Query parsing is ambiguous.', + to: 13, + }, + ], + }); + }); + it('throws error when visual query parse with functionCall is ambiguous', () => { + expect( + buildVisualQueryFromString( + 'clamp_min(sum by(cluster)(rate(X{le="2.5"}[5m]))+sum by (cluster) (rate(X{le="5"}[5m])), 0.001)' + ) + ).toMatchObject({ + errors: [ + { + from: 10, + text: 'Query parsing is ambiguous.', + to: 87, + }, + ], + }); + }); + it('does not throw error when visual query parse is unambiguous', () => { + expect( + buildVisualQueryFromString('topk(5, node_arp_entries) / node_arp_entries{cluster="dev-eu-west-2"}') + ).toMatchObject({ + errors: [], + }); + }); + it('does not throw error when visual query parse is unambiguous (scalar)', () => { + // Note this topk query with scalars is not valid in prometheus, but it does not currently throw an error during parse + expect(buildVisualQueryFromString('topk(5, 1) / 2')).toMatchObject({ + errors: [], + }); + }); + it('does not throw error when visual query parse is unambiguous, function call', () => { + // Note this topk query with scalars is not valid in prometheus, but it does not currently throw an error during parse + expect( + buildVisualQueryFromString( + 'clamp_min(sum by(cluster) (rate(X{le="2.5"}[5m])), 0.001) + sum by(cluster) (rate(X{le="5"}[5m]))' + ) + ).toMatchObject({ + errors: [], + }); + }); + }); + + it('parses query with rate and interval', () => { + expect(buildVisualQueryFromString('rate(counters_logins{app="frontend"}[5m])')).toEqual( + noErrors({ + metric: 'counters_logins', + labels: [ + { + op: '=', + value: 'frontend', + label: 'app', + }, + ], + operations: [ + { + id: 'rate', + params: ['5m'], + }, + ], + }) + ); + }); + + it('parses query with nested query and interval variable', () => { + expect( + buildVisualQueryFromString( + 'avg(rate(access_evaluation_duration_count{instance="host.docker.internal:3000"}[$__rate_interval]))' + ) + ).toEqual({ + errors: [], + query: { + metric: 'access_evaluation_duration_count', + labels: [ + { + op: '=', + value: 'host.docker.internal:3000', + label: 'instance', + }, + ], + operations: [ + { + id: 'rate', + params: ['$__rate_interval'], + }, + { + id: 'avg', + params: [], + }, + ], + }, + }); + }); + + it('parses query with aggregation by labels', () => { + const visQuery = { + metric: 'metric_name', + labels: [ + { + label: 'instance', + op: '=', + value: 'internal:3000', + }, + ], + operations: [ + { + id: '__sum_by', + params: ['app', 'version'], + }, + ], + }; + expect(buildVisualQueryFromString('sum(metric_name{instance="internal:3000"}) by (app, version)')).toEqual( + noErrors(visQuery) + ); + expect(buildVisualQueryFromString('sum by (app, version)(metric_name{instance="internal:3000"})')).toEqual( + noErrors(visQuery) + ); + }); + + it('parses query with aggregation without labels', () => { + const visQuery = { + metric: 'metric_name', + labels: [ + { + label: 'instance', + op: '=', + value: 'internal:3000', + }, + ], + operations: [ + { + id: '__sum_without', + params: ['app', 'version'], + }, + ], + }; + expect(buildVisualQueryFromString('sum(metric_name{instance="internal:3000"}) without (app, version)')).toEqual( + noErrors(visQuery) + ); + expect(buildVisualQueryFromString('sum without (app, version)(metric_name{instance="internal:3000"})')).toEqual( + noErrors(visQuery) + ); + }); + + it('parses aggregation with params', () => { + expect(buildVisualQueryFromString('topk(5, http_requests_total)')).toEqual( + noErrors({ + metric: 'http_requests_total', + labels: [], + operations: [ + { + id: 'topk', + params: [5], + }, + ], + }) + ); + }); + + it('parses aggregation with params and labels', () => { + expect(buildVisualQueryFromString('topk by(instance, job) (5, http_requests_total)')).toEqual( + noErrors({ + metric: 'http_requests_total', + labels: [], + operations: [ + { + id: '__topk_by', + params: [5, 'instance', 'job'], + }, + ], + }) + ); + }); + + it('parses function with argument', () => { + expect( + buildVisualQueryFromString('histogram_quantile(0.99, rate(counters_logins{app="backend"}[$__rate_interval]))') + ).toEqual({ + errors: [], + query: { + metric: 'counters_logins', + labels: [{ label: 'app', op: '=', value: 'backend' }], + operations: [ + { + id: 'rate', + params: ['$__rate_interval'], + }, + { + id: 'histogram_quantile', + params: [0.99], + }, + ], + }, + }); + }); + + it('parses function with multiple arguments', () => { + expect( + buildVisualQueryFromString( + 'label_replace(avg_over_time(http_requests_total{instance="foo"}[$__interval]), "instance", "$1", "", "(.*)")' + ) + ).toEqual({ + errors: [], + query: { + metric: 'http_requests_total', + labels: [{ label: 'instance', op: '=', value: 'foo' }], + operations: [ + { + id: 'avg_over_time', + params: ['$__interval'], + }, + { + id: 'label_replace', + params: ['instance', '$1', '', '(.*)'], + }, + ], + }, + }); + }); + + it('parses binary operation with scalar', () => { + expect(buildVisualQueryFromString('avg_over_time(http_requests_total{instance="foo"}[$__interval]) / 2')).toEqual({ + errors: [], + query: { + metric: 'http_requests_total', + labels: [{ label: 'instance', op: '=', value: 'foo' }], + operations: [ + { + id: 'avg_over_time', + params: ['$__interval'], + }, + { + id: '__divide_by', + params: [2], + }, + ], + }, + }); + }); + + it('parses binary operation with 2 queries', () => { + expect( + buildVisualQueryFromString('avg_over_time(http_requests_total{instance="foo"}[$__interval]) / sum(logins_count)') + ).toEqual({ + errors: [], + query: { + metric: 'http_requests_total', + labels: [{ label: 'instance', op: '=', value: 'foo' }], + operations: [{ id: 'avg_over_time', params: ['$__interval'] }], + binaryQueries: [ + { + operator: '/', + query: { + metric: 'logins_count', + labels: [], + operations: [{ id: 'sum', params: [] }], + }, + }, + ], + }, + }); + }); + + it('parses template variables in strings', () => { + expect(buildVisualQueryFromString('http_requests_total{instance="$label_variable"}')).toEqual( + noErrors({ + metric: 'http_requests_total', + labels: [{ label: 'instance', op: '=', value: '$label_variable' }], + operations: [], + }) + ); + }); + + it('parses template variables for metric', () => { + expect(buildVisualQueryFromString('$metric_variable{instance="foo"}')).toEqual( + noErrors({ + metric: '$metric_variable', + labels: [{ label: 'instance', op: '=', value: 'foo' }], + operations: [], + }) + ); + + expect(buildVisualQueryFromString('${metric_variable:fmt}{instance="foo"}')).toEqual( + noErrors({ + metric: '${metric_variable:fmt}', + labels: [{ label: 'instance', op: '=', value: 'foo' }], + operations: [], + }) + ); + + expect(buildVisualQueryFromString('[[metric_variable:fmt]]{instance="foo"}')).toEqual( + noErrors({ + metric: '[[metric_variable:fmt]]', + labels: [{ label: 'instance', op: '=', value: 'foo' }], + operations: [], + }) + ); + }); + + it('parses template variables in label name', () => { + expect(buildVisualQueryFromString('metric{${variable_label}="foo"}')).toEqual( + noErrors({ + metric: 'metric', + labels: [{ label: '${variable_label}', op: '=', value: 'foo' }], + operations: [], + }) + ); + }); + + it('Throws error when undefined', () => { + expect(() => buildVisualQueryFromString(undefined as unknown as string)).toThrow( + "Cannot read properties of undefined (reading 'replace')" + ); + }); + + it('Works with empty string', () => { + expect(buildVisualQueryFromString('')).toEqual( + noErrors({ + metric: '', + labels: [], + operations: [], + }) + ); + }); + + it('fails to parse variable for function', () => { + expect(buildVisualQueryFromString('${func_var}(metric{bar="foo"})')).toEqual({ + errors: [ + { + text: '(', + from: 20, + to: 21, + parentType: 'VectorSelector', + }, + { + text: 'metric', + from: 21, + to: 27, + parentType: 'VectorSelector', + }, + ], + query: { + metric: '${func_var}', + labels: [{ label: 'bar', op: '=', value: 'foo' }], + operations: [], + }, + }); + }); + + it('fails to parse malformed query', () => { + expect(buildVisualQueryFromString('asdf-metric{bar="})')).toEqual({ + errors: [ + { + text: '', + from: 19, + to: 19, + parentType: 'LabelMatchers', + }, + ], + query: { + metric: 'asdf', + labels: [], + operations: [], + binaryQueries: [ + { + operator: '-', + query: { + metric: 'metric', + labels: [{ label: 'bar', op: '=', value: '})' }], + operations: [], + }, + }, + ], + }, + }); + }); + + it('fails to parse malformed query 2', () => { + expect(buildVisualQueryFromString('ewafweaf{afea=afe}')).toEqual({ + errors: [ + { + text: 'afe}', + from: 14, + to: 18, + parentType: 'LabelMatcher', + }, + ], + query: { + metric: 'ewafweaf', + labels: [{ label: 'afea', op: '=', value: '' }], + operations: [], + }, + }); + }); + + it('parses query without metric', () => { + expect(buildVisualQueryFromString('label_replace(rate([$__rate_interval]), "", "$1", "", "(.*)")')).toEqual({ + errors: [], + query: { + metric: '', + labels: [], + operations: [ + { id: 'rate', params: ['$__rate_interval'] }, + { + id: 'label_replace', + params: ['', '$1', '', '(.*)'], + }, + ], + }, + }); + }); + + it('lone aggregation without params', () => { + expect(buildVisualQueryFromString('sum()')).toEqual({ + errors: [], + query: { + metric: '', + labels: [], + operations: [{ id: 'sum', params: [] }], + }, + }); + }); + + it('handles multiple binary scalar operations', () => { + expect(buildVisualQueryFromString('cluster_namespace_slug_dialer_name + 1 - 1 / 1 * 1 % 1 ^ 1')).toEqual({ + errors: [], + query: { + metric: 'cluster_namespace_slug_dialer_name', + labels: [], + operations: [ + { + id: '__addition', + params: [1], + }, + { + id: '__subtraction', + params: [1], + }, + { + id: '__divide_by', + params: [1], + }, + { + id: '__multiply_by', + params: [1], + }, + { + id: '__modulo', + params: [1], + }, + { + id: '__exponent', + params: [1], + }, + ], + }, + }); + }); + + it('handles scalar comparison operators', () => { + expect(buildVisualQueryFromString('cluster_namespace_slug_dialer_name <= 2.5')).toEqual({ + errors: [], + query: { + metric: 'cluster_namespace_slug_dialer_name', + labels: [], + operations: [ + { + id: '__less_or_equal', + params: [2.5, false], + }, + ], + }, + }); + }); + + it('handles bool with comparison operator', () => { + expect(buildVisualQueryFromString('cluster_namespace_slug_dialer_name <= bool 2')).toEqual({ + errors: [], + query: { + metric: 'cluster_namespace_slug_dialer_name', + labels: [], + operations: [ + { + id: '__less_or_equal', + params: [2, true], + }, + ], + }, + }); + }); + + it('handles multiple binary operations', () => { + expect(buildVisualQueryFromString('foo{x="yy"} * metric{y="zz",a="bb"} * metric2')).toEqual({ + errors: [], + query: { + metric: 'foo', + labels: [{ label: 'x', op: '=', value: 'yy' }], + operations: [], + binaryQueries: [ + { + operator: '*', + query: { + metric: 'metric', + labels: [ + { label: 'y', op: '=', value: 'zz' }, + { label: 'a', op: '=', value: 'bb' }, + ], + operations: [], + }, + }, + { + operator: '*', + query: { + metric: 'metric2', + labels: [], + operations: [], + }, + }, + ], + }, + }); + }); + + it('handles multiple binary operations and scalar', () => { + expect(buildVisualQueryFromString('foo{x="yy"} * metric{y="zz",a="bb"} * 2')).toEqual({ + errors: [], + query: { + metric: 'foo', + labels: [{ label: 'x', op: '=', value: 'yy' }], + operations: [ + { + id: '__multiply_by', + params: [2], + }, + ], + binaryQueries: [ + { + operator: '*', + query: { + metric: 'metric', + labels: [ + { label: 'y', op: '=', value: 'zz' }, + { label: 'a', op: '=', value: 'bb' }, + ], + operations: [], + }, + }, + ], + }, + }); + }); + + it('handles binary operation with vector matchers', () => { + expect(buildVisualQueryFromString('foo * on(foo, bar) metric')).toEqual({ + errors: [], + query: { + metric: 'foo', + labels: [], + operations: [], + binaryQueries: [ + { + operator: '*', + vectorMatches: 'foo, bar', + vectorMatchesType: 'on', + query: { metric: 'metric', labels: [], operations: [] }, + }, + ], + }, + }); + + expect(buildVisualQueryFromString('foo * ignoring(foo) metric')).toEqual({ + errors: [], + query: { + metric: 'foo', + labels: [], + operations: [], + binaryQueries: [ + { + operator: '*', + vectorMatches: 'foo', + vectorMatchesType: 'ignoring', + query: { metric: 'metric', labels: [], operations: [] }, + }, + ], + }, + }); + }); + + it('reports error on parenthesis', () => { + expect(buildVisualQueryFromString('foo / (bar + baz)')).toEqual({ + errors: [ + { + from: 6, + parentType: 'Expr', + text: '(bar + baz)', + to: 17, + }, + ], + query: { + metric: 'foo', + labels: [], + operations: [], + binaryQueries: [ + { + operator: '/', + query: { + binaryQueries: [{ operator: '+', query: { labels: [], metric: 'baz', operations: [] } }], + metric: 'bar', + labels: [], + operations: [], + }, + }, + ], + }, + }); + }); +}); + +function noErrors(query: PromVisualQuery) { + return { + errors: [], + query, + }; +} diff --git a/packages/grafana-prometheus/src/querybuilder/parsing.ts b/packages/grafana-prometheus/src/querybuilder/parsing.ts new file mode 100644 index 0000000000000..de1486efc2a59 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/parsing.ts @@ -0,0 +1,468 @@ +import { SyntaxNode } from '@lezer/common'; +import { + AggregateExpr, + AggregateModifier, + AggregateOp, + BinaryExpr, + BinModifiers, + Expr, + FunctionCall, + FunctionCallArgs, + FunctionCallBody, + FunctionIdentifier, + GroupingLabel, + GroupingLabelList, + GroupingLabels, + LabelMatcher, + LabelName, + MatchOp, + MetricIdentifier, + NumberLiteral, + On, + OnOrIgnoring, + ParenExpr, + parser, + StringLiteral, + VectorSelector, + Without, +} from '@prometheus-io/lezer-promql'; + +import { binaryScalarOperatorToOperatorName } from './binaryScalarOperations'; +import { + ErrorId, + getAllByType, + getLeftMostChild, + getString, + makeBinOp, + makeError, + replaceVariables, +} from './parsingUtils'; +import { QueryBuilderLabelFilter, QueryBuilderOperation } from './shared/types'; +import { PromVisualQuery, PromVisualQueryBinary } from './types'; + +/** + * Parses a PromQL query into a visual query model. + * + * It traverses the tree and uses sort of state machine to update the query model. The query model is modified + * during the traversal and sent to each handler as context. + * + * @param expr + */ +export function buildVisualQueryFromString(expr: string): Context { + const replacedExpr = replaceVariables(expr); + const tree = parser.parse(replacedExpr); + const node = tree.topNode; + + // This will be modified in the handlers. + const visQuery: PromVisualQuery = { + metric: '', + labels: [], + operations: [], + }; + const context: Context = { + query: visQuery, + errors: [], + }; + + try { + handleExpression(replacedExpr, node, context); + } catch (err) { + // Not ideal to log it here, but otherwise we would lose the stack trace. + console.error(err); + if (err instanceof Error) { + context.errors.push({ + text: err.message, + }); + } + } + + // If we have empty query, we want to reset errors + if (isEmptyQuery(context.query)) { + context.errors = []; + } + + // We don't want parsing errors related to Grafana global variables + if (isValidPromQLMinusGrafanaGlobalVariables(expr)) { + context.errors = []; + } + + return context; +} + +interface ParsingError { + text: string; + from?: number; + to?: number; + parentType?: string; +} + +interface Context { + query: PromVisualQuery; + errors: ParsingError[]; +} + +function isValidPromQLMinusGrafanaGlobalVariables(expr: string) { + const context: Context = { + query: { + metric: '', + labels: [], + operations: [], + }, + errors: [], + }; + + expr = expr.replace(/\$__interval/g, '1s'); + expr = expr.replace(/\$__interval_ms/g, '1000'); + expr = expr.replace(/\$__rate_interval/g, '1s'); + expr = expr.replace(/\$__range_ms/g, '1000'); + expr = expr.replace(/\$__range_s/g, '1'); + expr = expr.replace(/\$__range/g, '1s'); + + const tree = parser.parse(expr); + const node = tree.topNode; + + try { + handleExpression(expr, node, context); + } catch (err) { + return false; + } + + return context.errors.length === 0; +} + +/** + * Handler for default state. It will traverse the tree and call the appropriate handler for each node. The node + * handled here does not necessarily need to be of type == Expr. + * @param expr + * @param node + * @param context + */ +export function handleExpression(expr: string, node: SyntaxNode, context: Context) { + const visQuery = context.query; + + switch (node.type.id) { + case MetricIdentifier: { + // Expectation is that there is only one of those per query. + visQuery.metric = getString(expr, node); + break; + } + + case LabelMatcher: { + // Same as MetricIdentifier should be just one per query. + visQuery.labels.push(getLabel(expr, node)); + const err = node.getChild(ErrorId); + if (err) { + context.errors.push(makeError(expr, err)); + } + break; + } + + case FunctionCall: { + handleFunction(expr, node, context); + break; + } + + case AggregateExpr: { + handleAggregation(expr, node, context); + break; + } + + case BinaryExpr: { + handleBinary(expr, node, context); + break; + } + + case ErrorId: { + if (isIntervalVariableError(node)) { + break; + } + context.errors.push(makeError(expr, node)); + break; + } + + default: { + if (node.type.id === ParenExpr) { + // We don't support parenthesis in the query to group expressions. We just report error but go on with the + // parsing. + context.errors.push(makeError(expr, node)); + } + // Any other nodes we just ignore and go to its children. This should be fine as there are lots of wrapper + // nodes that can be skipped. + // TODO: there are probably cases where we will just skip nodes we don't support and we should be able to + // detect those and report back. + let child = node.firstChild; + while (child) { + handleExpression(expr, child, context); + child = child.nextSibling; + } + } + } +} + +function isIntervalVariableError(node: SyntaxNode) { + return node.prevSibling?.type.id === Expr && node.prevSibling?.firstChild?.type.id === VectorSelector; +} + +function getLabel(expr: string, node: SyntaxNode): QueryBuilderLabelFilter { + const label = getString(expr, node.getChild(LabelName)); + const op = getString(expr, node.getChild(MatchOp)); + const value = getString(expr, node.getChild(StringLiteral)).replace(/"/g, ''); + return { + label, + op, + value, + }; +} + +const rangeFunctions = ['changes', 'rate', 'irate', 'increase', 'delta']; + +/** + * Handle function call which is usually and identifier and its body > arguments. + * @param expr + * @param node + * @param context + */ +function handleFunction(expr: string, node: SyntaxNode, context: Context) { + const visQuery = context.query; + const nameNode = node.getChild(FunctionIdentifier); + const funcName = getString(expr, nameNode); + + const body = node.getChild(FunctionCallBody); + const callArgs = body!.getChild(FunctionCallArgs); + const params = []; + let interval = ''; + + // This is a bit of a shortcut to get the interval argument. Reasons are + // - interval is not part of the function args per promQL grammar but we model it as argument for the function in + // the query model. + // - it is easier to handle template variables this way as template variable is an error for the parser + if (rangeFunctions.includes(funcName) || funcName.endsWith('_over_time')) { + let match = getString(expr, node).match(/\[(.+)\]/); + if (match?.[1]) { + interval = match[1]; + params.push(match[1]); + } + } + + const op = { id: funcName, params }; + // We unshift operations to keep the more natural order that we want to have in the visual query editor. + visQuery.operations.unshift(op); + + if (callArgs) { + if (getString(expr, callArgs) === interval + ']') { + // This is a special case where we have a function with a single argument and it is the interval. + // This happens when you start adding operations in query builder and did not set a metric yet. + return; + } + updateFunctionArgs(expr, callArgs, context, op); + } +} + +/** + * Handle aggregation as they are distinct type from other functions. + * @param expr + * @param node + * @param context + */ +function handleAggregation(expr: string, node: SyntaxNode, context: Context) { + const visQuery = context.query; + const nameNode = node.getChild(AggregateOp); + let funcName = getString(expr, nameNode); + + const modifier = node.getChild(AggregateModifier); + const labels = []; + + if (modifier) { + const byModifier = modifier.getChild(`By`); + if (byModifier && funcName) { + funcName = `__${funcName}_by`; + } + + const withoutModifier = modifier.getChild(Without); + if (withoutModifier) { + funcName = `__${funcName}_without`; + } + + labels.push(...getAllByType(expr, modifier, GroupingLabel)); + } + + const body = node.getChild(FunctionCallBody); + const callArgs = body!.getChild(FunctionCallArgs); + const callArgsExprChild = callArgs?.getChild(Expr); + const binaryExpressionWithinAggregationArgs = callArgsExprChild?.getChild(BinaryExpr); + + if (binaryExpressionWithinAggregationArgs) { + context.errors.push({ + text: 'Query parsing is ambiguous.', + from: binaryExpressionWithinAggregationArgs.from, + to: binaryExpressionWithinAggregationArgs.to, + }); + } + + const op: QueryBuilderOperation = { id: funcName, params: [] }; + visQuery.operations.unshift(op); + updateFunctionArgs(expr, callArgs, context, op); + // We add labels after params in the visual query editor. + op.params.push(...labels); +} + +/** + * Handle (probably) all types of arguments that function or aggregation can have. + * + * FunctionCallArgs are nested bit weirdly basically its [firstArg, ...rest] where rest is again FunctionCallArgs so + * we cannot just get all the children and iterate them as arguments we have to again recursively traverse through + * them. + * + * @param expr + * @param node + * @param context + * @param op - We need the operation to add the params to as an additional context. + */ +function updateFunctionArgs(expr: string, node: SyntaxNode | null, context: Context, op: QueryBuilderOperation) { + if (!node) { + return; + } + switch (node.type.id) { + // In case we have an expression we don't know what kind so we have to look at the child as it can be anything. + case Expr: + // FunctionCallArgs are nested bit weirdly as mentioned so we have to go one deeper in this case. + case FunctionCallArgs: { + let child = node.firstChild; + + while (child) { + const callArgsExprChild = child.getChild(Expr); + const binaryExpressionWithinFunctionArgs = callArgsExprChild?.getChild(BinaryExpr); + + if (binaryExpressionWithinFunctionArgs) { + context.errors.push({ + text: 'Query parsing is ambiguous.', + from: binaryExpressionWithinFunctionArgs.from, + to: binaryExpressionWithinFunctionArgs.to, + }); + } + + updateFunctionArgs(expr, child, context, op); + child = child.nextSibling; + } + + break; + } + + case NumberLiteral: { + op.params.push(parseFloat(getString(expr, node))); + break; + } + + case StringLiteral: { + op.params.push(getString(expr, node).replace(/"/g, '')); + break; + } + + default: { + // Means we get to something that does not seem like simple function arg and is probably nested query so jump + // back to main context + handleExpression(expr, node, context); + } + } +} + +/** + * Right now binary expressions can be represented in 2 way in visual query. As additional operation in case it is + * just operation with scalar or it creates a binaryQuery when it's 2 queries. + * @param expr + * @param node + * @param context + */ +function handleBinary(expr: string, node: SyntaxNode, context: Context) { + const visQuery = context.query; + const left = node.firstChild!; + const op = getString(expr, left.nextSibling); + const binModifier = getBinaryModifier(expr, node.getChild(BinModifiers)); + + const right = node.lastChild!; + + const opDef = binaryScalarOperatorToOperatorName[op]; + + const leftNumber = left.getChild(NumberLiteral); + const rightNumber = right.getChild(NumberLiteral); + + const rightBinary = right.getChild(BinaryExpr); + + if (leftNumber) { + // TODO: this should be already handled in case parent is binary expression as it has to be added to parent + // if query starts with a number that isn't handled now. + } else { + // If this is binary we don't really know if there is a query or just chained scalars. So + // we have to traverse a bit deeper to know + handleExpression(expr, left, context); + } + + if (rightNumber) { + visQuery.operations.push(makeBinOp(opDef, expr, right, !!binModifier?.isBool)); + } else if (rightBinary) { + // Due to the way binary ops are parsed we can get a binary operation on the right that starts with a number which + // is a factor for a current binary operation. So we have to add it as an operation now. + const leftMostChild = getLeftMostChild(right); + if (leftMostChild?.type.id === NumberLiteral) { + visQuery.operations.push(makeBinOp(opDef, expr, leftMostChild, !!binModifier?.isBool)); + } + + // If we added the first number literal as operation here we still can continue and handle the rest as the first + // number will be just skipped. + handleExpression(expr, right, context); + } else { + visQuery.binaryQueries = visQuery.binaryQueries || []; + const binQuery: PromVisualQueryBinary = { + operator: op, + query: { + metric: '', + labels: [], + operations: [], + }, + }; + if (binModifier?.isMatcher) { + binQuery.vectorMatchesType = binModifier.matchType; + binQuery.vectorMatches = binModifier.matches; + } + visQuery.binaryQueries.push(binQuery); + handleExpression(expr, right, { + query: binQuery.query, + errors: context.errors, + }); + } +} + +function getBinaryModifier( + expr: string, + node: SyntaxNode | null +): + | { isBool: true; isMatcher: false } + | { isBool: false; isMatcher: true; matches: string; matchType: 'ignoring' | 'on' } + | undefined { + if (!node) { + return undefined; + } + if (node.getChild('Bool')) { + return { isBool: true, isMatcher: false }; + } else { + const matcher = node.getChild(OnOrIgnoring); + if (!matcher) { + // Not sure what this could be, maybe should be an error. + return undefined; + } + const labels = getString(expr, matcher.getChild(GroupingLabels)?.getChild(GroupingLabelList)); + return { + isMatcher: true, + isBool: false, + matches: labels, + matchType: matcher.getChild(On) ? 'on' : 'ignoring', + }; + } +} + +function isEmptyQuery(query: PromVisualQuery) { + if (query.labels.length === 0 && query.operations.length === 0 && !query.metric) { + return true; + } + return false; +} diff --git a/packages/grafana-prometheus/src/querybuilder/parsingUtils.test.ts b/packages/grafana-prometheus/src/querybuilder/parsingUtils.test.ts new file mode 100644 index 0000000000000..81c8d255ca63f --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/parsingUtils.test.ts @@ -0,0 +1,42 @@ +import { parser } from '@prometheus-io/lezer-promql'; + +import { getLeftMostChild, getString, replaceVariables } from './parsingUtils'; + +describe('getLeftMostChild', () => { + it('return left most child', () => { + const tree = parser.parse('sum_over_time(foo{bar="baz"}[5m])'); + const child = getLeftMostChild(tree.topNode); + expect(child).toBeDefined(); + expect(child!.name).toBe('SumOverTime'); + }); +}); + +describe('replaceVariables', () => { + it('should replace variables', () => { + expect(replaceVariables('sum_over_time([[metric_var]]{bar="${app}"}[$__interval])')).toBe( + 'sum_over_time(__V_1__metric_var__V__{bar="__V_2__app__V__"}[__V_0____interval__V__])' + ); + }); +}); + +describe('getString', () => { + it('should return correct string representation of the node', () => { + const expr = 'sum_over_time(foo{bar="baz"}[5m])'; + const tree = parser.parse(expr); + const child = getLeftMostChild(tree.topNode); + expect(getString(expr, child)).toBe('sum_over_time'); + }); + + it('should return string with correct variables', () => { + const expr = 'sum_over_time(__V_1__metric_var__V__{bar="__V_2__app__V__"}[__V_0____interval__V__])'; + const tree = parser.parse(expr); + expect(getString(expr, tree.topNode)).toBe('sum_over_time([[metric_var]]{bar="${app}"}[$__interval])'); + }); + + it('is symmetrical with replaceVariables', () => { + const expr = 'sum_over_time([[metric_var]]{bar="${app}"}[$__interval])'; + const replaced = replaceVariables(expr); + const tree = parser.parse(replaced); + expect(getString(replaced, tree.topNode)).toBe(expr); + }); +}); diff --git a/packages/grafana-prometheus/src/querybuilder/parsingUtils.ts b/packages/grafana-prometheus/src/querybuilder/parsingUtils.ts new file mode 100644 index 0000000000000..10be8692ebc6b --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/parsingUtils.ts @@ -0,0 +1,140 @@ +import { SyntaxNode, TreeCursor } from '@lezer/common'; + +import { QueryBuilderOperation, QueryBuilderOperationParamValue } from './shared/types'; + +// Although 0 isn't explicitly provided in the lezer-promql library as the error node ID, it does appear to be the ID of error nodes within lezer. +export const ErrorId = 0; + +export function getLeftMostChild(cur: SyntaxNode): SyntaxNode { + return cur.firstChild ? getLeftMostChild(cur.firstChild) : cur; +} + +export function makeError(expr: string, node: SyntaxNode) { + return { + text: getString(expr, node), + // TODO: this are positions in the string with the replaced variables. Means it cannot be used to show exact + // placement of the error for the user. We need some translation table to positions before the variable + // replace. + from: node.from, + to: node.to, + parentType: node.parent?.name, + }; +} + +// Taken from template_srv, but copied so to not mess with the regex.index which is manipulated in the service +/* + * This regex matches 3 types of variable reference with an optional format specifier + * \$(\w+) $var1 + * \[\[([\s\S]+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]] + * \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3} + */ +const variableRegex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g; + +/** + * As variables with $ are creating parsing errors, we first replace them with magic string that is parsable and at + * the same time we can get the variable and its format back from it. + * @param expr + */ +export function replaceVariables(expr: string) { + return expr.replace(variableRegex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { + const fmt = fmt2 || fmt3; + let variable = var1; + let varType = '0'; + + if (var2) { + variable = var2; + varType = '1'; + } + + if (var3) { + variable = var3; + varType = '2'; + } + + return `__V_${varType}__` + variable + '__V__' + (fmt ? '__F__' + fmt + '__F__' : ''); + }); +} + +const varTypeFunc = [ + (v: string, f?: string) => `\$${v}`, + (v: string, f?: string) => `[[${v}${f ? `:${f}` : ''}]]`, + (v: string, f?: string) => `\$\{${v}${f ? `:${f}` : ''}\}`, +]; + +/** + * Get back the text with variables in their original format. + * @param expr + */ +export function returnVariables(expr: string) { + return expr.replace(/__V_(\d)__(.+?)__V__(?:__F__(\w+)__F__)?/g, (match, type, v, f) => { + return varTypeFunc[parseInt(type, 10)](v, f); + }); +} + +/** + * Get the actual string of the expression. That is not stored in the tree so we have to get the indexes from the node + * and then based on that get it from the expression. + * @param expr + * @param node + */ +export function getString(expr: string, node: SyntaxNode | TreeCursor | null | undefined) { + if (!node) { + return ''; + } + return returnVariables(expr.substring(node.from, node.to)); +} + +/** + * Create simple scalar binary op object. + * @param opDef - definition of the op to be created + * @param expr + * @param numberNode - the node for the scalar + * @param hasBool - whether operation has a bool modifier. Is used only for ops for which it makes sense. + */ +export function makeBinOp( + opDef: { id: string; comparison?: boolean }, + expr: string, + numberNode: SyntaxNode, + hasBool: boolean +): QueryBuilderOperation { + const params: QueryBuilderOperationParamValue[] = [parseFloat(getString(expr, numberNode))]; + if (opDef.comparison) { + params.push(hasBool); + } + return { + id: opDef.id, + params, + }; +} + +/** + * Get all nodes with type in the tree. This traverses the tree so it is safe only when you know there shouldn't be + * too much nesting but you just want to skip some of the wrappers. For example getting function args this way would + * not be safe is it would also find arguments of nested functions. + * @param expr + * @param cur + * @param type - can be string or number, some data-sources (loki) haven't migrated over to using numeric constants defined in the lezer parsing library (e.g. lezer-promql). + * @todo Remove string type definition when all data-sources have migrated to numeric constants + */ +export function getAllByType(expr: string, cur: SyntaxNode, type: number | string): string[] { + if (cur.type.id === type || cur.name === type) { + return [getString(expr, cur)]; + } + const values: string[] = []; + let pos = 0; + let child = cur.childAfter(pos); + while (child) { + values.push(...getAllByType(expr, child, type)); + pos = child.to; + child = cur.childAfter(pos); + } + return values; +} + +/** + * There aren't any spaces in the metric names, so let's introduce a wildcard into the regex for each space to better facilitate a fuzzy search + */ +export const regexifyLabelValuesQueryString = (query: string) => { + const queryArray = query.split(' '); + return queryArray.map((query) => `${query}.*`).join(''); +}; diff --git a/packages/grafana-prometheus/src/querybuilder/shared/LokiAndPromQueryModellerBase.ts b/packages/grafana-prometheus/src/querybuilder/shared/LokiAndPromQueryModellerBase.ts new file mode 100644 index 0000000000000..81afc5a276234 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/shared/LokiAndPromQueryModellerBase.ts @@ -0,0 +1,122 @@ +import { Registry } from '@grafana/data'; + +import { PromVisualQueryOperationCategory } from '../types'; + +import { QueryBuilderLabelFilter, QueryBuilderOperation, QueryBuilderOperationDef, VisualQueryModeller } from './types'; + +export interface VisualQueryBinary<T> { + operator: string; + vectorMatchesType?: 'on' | 'ignoring'; + vectorMatches?: string; + query: T; +} + +export interface PromLokiVisualQuery { + metric?: string; + labels: QueryBuilderLabelFilter[]; + operations: QueryBuilderOperation[]; + binaryQueries?: Array<VisualQueryBinary<PromLokiVisualQuery>>; +} + +export abstract class LokiAndPromQueryModellerBase implements VisualQueryModeller { + protected operationsRegistry: Registry<QueryBuilderOperationDef>; + private categories: string[] = []; + + constructor(getOperations: () => QueryBuilderOperationDef[]) { + this.operationsRegistry = new Registry<QueryBuilderOperationDef>(getOperations); + } + + protected setOperationCategories(categories: string[]) { + this.categories = categories; + } + + getOperationsForCategory(category: string) { + return this.operationsRegistry.list().filter((op) => op.category === category && !op.hideFromList); + } + + getAlternativeOperations(key: string) { + return this.operationsRegistry.list().filter((op) => op.alternativesKey && op.alternativesKey === key); + } + + getCategories() { + return this.categories; + } + + getOperationDef(id: string): QueryBuilderOperationDef | undefined { + return this.operationsRegistry.getIfExists(id); + } + + renderOperations(queryString: string, operations: QueryBuilderOperation[]) { + for (const operation of operations) { + const def = this.operationsRegistry.getIfExists(operation.id); + if (!def) { + throw new Error(`Could not find operation ${operation.id} in the registry`); + } + queryString = def.renderer(operation, def, queryString); + } + + return queryString; + } + + renderBinaryQueries(queryString: string, binaryQueries?: Array<VisualQueryBinary<PromLokiVisualQuery>>) { + if (binaryQueries) { + for (const binQuery of binaryQueries) { + queryString = `${this.renderBinaryQuery(queryString, binQuery)}`; + } + } + return queryString; + } + + private renderBinaryQuery(leftOperand: string, binaryQuery: VisualQueryBinary<PromLokiVisualQuery>) { + let result = leftOperand + ` ${binaryQuery.operator} `; + + if (binaryQuery.vectorMatches) { + result += `${binaryQuery.vectorMatchesType}(${binaryQuery.vectorMatches}) `; + } + + return result + this.renderQuery(binaryQuery.query, true); + } + + renderLabels(labels: QueryBuilderLabelFilter[]) { + if (labels.length === 0) { + return ''; + } + + let expr = '{'; + for (const filter of labels) { + if (expr !== '{') { + expr += ', '; + } + + expr += `${filter.label}${filter.op}"${filter.value}"`; + } + + return expr + `}`; + } + + renderQuery(query: PromLokiVisualQuery, nested?: boolean) { + let queryString = `${query.metric ?? ''}${this.renderLabels(query.labels)}`; + queryString = this.renderOperations(queryString, query.operations); + + if (!nested && this.hasBinaryOp(query) && Boolean(query.binaryQueries?.length)) { + queryString = `(${queryString})`; + } + + queryString = this.renderBinaryQueries(queryString, query.binaryQueries); + + if (nested && (this.hasBinaryOp(query) || Boolean(query.binaryQueries?.length))) { + queryString = `(${queryString})`; + } + + return queryString; + } + + hasBinaryOp(query: PromLokiVisualQuery): boolean { + return ( + query.operations.find((op) => { + const def = this.getOperationDef(op.id); + return def?.category === PromVisualQueryOperationCategory.BinaryOps; + }) !== undefined + ); + } +} diff --git a/packages/grafana-prometheus/src/querybuilder/shared/OperationEditor.tsx b/packages/grafana-prometheus/src/querybuilder/shared/OperationEditor.tsx new file mode 100644 index 0000000000000..cd4dd77ff7287 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/shared/OperationEditor.tsx @@ -0,0 +1,308 @@ +import { css, cx } from '@emotion/css'; +import React, { useEffect, useId, useState } from 'react'; +import { Draggable } from 'react-beautiful-dnd'; + +import { DataSourceApi, GrafanaTheme2, TimeRange } from '@grafana/data'; +import { Button, Icon, Stack, Tooltip, useStyles2 } from '@grafana/ui'; + +import { getOperationParamId } from '../operationUtils'; + +import { OperationHeader } from './OperationHeader'; +import { getOperationParamEditor } from './OperationParamEditor'; +import { + QueryBuilderOperation, + QueryBuilderOperationDef, + QueryBuilderOperationParamDef, + QueryBuilderOperationParamValue, + VisualQueryModeller, +} from './types'; + +export interface Props { + operation: QueryBuilderOperation; + index: number; + query: any; + datasource: DataSourceApi; + queryModeller: VisualQueryModeller; + onChange: (index: number, update: QueryBuilderOperation) => void; + onRemove: (index: number) => void; + onRunQuery: () => void; + flash?: boolean; + highlight?: boolean; + timeRange?: TimeRange; +} + +export function OperationEditor({ + operation, + index, + onRemove, + onChange, + onRunQuery, + queryModeller, + query, + datasource, + flash, + highlight, + timeRange, +}: Props) { + const styles = useStyles2(getStyles); + const def = queryModeller.getOperationDef(operation.id); + const shouldFlash = useFlash(flash); + const id = useId(); + + if (!def) { + return <span>Operation {operation.id} not found</span>; + } + + const onParamValueChanged = (paramIdx: number, value: QueryBuilderOperationParamValue) => { + const update: QueryBuilderOperation = { ...operation, params: [...operation.params] }; + update.params[paramIdx] = value; + callParamChangedThenOnChange(def, update, index, paramIdx, onChange); + }; + + const onAddRestParam = () => { + const update: QueryBuilderOperation = { ...operation, params: [...operation.params, ''] }; + callParamChangedThenOnChange(def, update, index, operation.params.length, onChange); + }; + + const onRemoveRestParam = (paramIdx: number) => { + const update: QueryBuilderOperation = { + ...operation, + params: [...operation.params.slice(0, paramIdx), ...operation.params.slice(paramIdx + 1)], + }; + callParamChangedThenOnChange(def, update, index, paramIdx, onChange); + }; + + const operationElements: React.ReactNode[] = []; + + for (let paramIndex = 0; paramIndex < operation.params.length; paramIndex++) { + const paramDef = def.params[Math.min(def.params.length - 1, paramIndex)]; + const Editor = getOperationParamEditor(paramDef); + + operationElements.push( + <div className={styles.paramRow} key={`${paramIndex}-1`}> + {!paramDef.hideName && ( + <div className={styles.paramName}> + <label htmlFor={getOperationParamId(id, paramIndex)}>{paramDef.name}</label> + {paramDef.description && ( + <Tooltip placement="top" content={paramDef.description} theme="info"> + <Icon name="info-circle" size="sm" className={styles.infoIcon} /> + </Tooltip> + )} + </div> + )} + <div className={styles.paramValue}> + <Stack gap={0.5} direction="row" alignItems="center"> + <Editor + index={paramIndex} + paramDef={paramDef} + value={operation.params[paramIndex]} + operation={operation} + operationId={id} + onChange={onParamValueChanged} + onRunQuery={onRunQuery} + query={query} + datasource={datasource} + timeRange={timeRange} + /> + {paramDef.restParam && (operation.params.length > def.params.length || paramDef.optional) && ( + <Button + data-testid={`operations.${index}.remove-rest-param`} + size="sm" + fill="text" + icon="times" + variant="secondary" + title={`Remove ${paramDef.name}`} + onClick={() => onRemoveRestParam(paramIndex)} + /> + )} + </Stack> + </div> + </div> + ); + } + + // Handle adding button for rest params + let restParam: React.ReactNode | undefined; + if (def.params.length > 0) { + const lastParamDef = def.params[def.params.length - 1]; + if (lastParamDef.restParam) { + restParam = renderAddRestParamButton(lastParamDef, onAddRestParam, index, operation.params.length, styles); + } + } + + return ( + <Draggable draggableId={`operation-${index}`} index={index}> + {(provided) => ( + <div + className={cx(styles.card, (shouldFlash || highlight) && styles.cardHighlight)} + ref={provided.innerRef} + {...provided.draggableProps} + data-testid={`operations.${index}.wrapper`} + > + <OperationHeader + operation={operation} + dragHandleProps={provided.dragHandleProps} + def={def} + index={index} + onChange={onChange} + onRemove={onRemove} + queryModeller={queryModeller} + /> + <div className={styles.body}>{operationElements}</div> + {restParam} + {index < query.operations.length - 1 && ( + <div className={styles.arrow}> + <div className={styles.arrowLine} /> + <div className={styles.arrowArrow} /> + </div> + )} + </div> + )} + </Draggable> + ); +} + +/** + * When flash is switched on makes sure it is switched of right away, so we just flash the highlight and then fade + * out. + * @param flash + */ +function useFlash(flash?: boolean) { + const [keepFlash, setKeepFlash] = useState(true); + useEffect(() => { + let t: ReturnType<typeof setTimeout>; + if (flash) { + t = setTimeout(() => { + setKeepFlash(false); + }, 1000); + } else { + setKeepFlash(true); + } + + return () => clearTimeout(t); + }, [flash]); + + return keepFlash && flash; +} + +function renderAddRestParamButton( + paramDef: QueryBuilderOperationParamDef, + onAddRestParam: () => void, + operationIndex: number, + paramIndex: number, + styles: OperationEditorStyles +) { + return ( + <div className={styles.restParam} key={`${paramIndex}-2`}> + <Button + size="sm" + icon="plus" + title={`Add ${paramDef.name}`.trimEnd()} + variant="secondary" + onClick={onAddRestParam} + data-testid={`operations.${operationIndex}.add-rest-param`} + > + {paramDef.name} + </Button> + </div> + ); +} + +function callParamChangedThenOnChange( + def: QueryBuilderOperationDef, + operation: QueryBuilderOperation, + operationIndex: number, + paramIndex: number, + onChange: (index: number, update: QueryBuilderOperation) => void +) { + if (def.paramChangedHandler) { + onChange(operationIndex, def.paramChangedHandler(paramIndex, operation, def)); + } else { + onChange(operationIndex, operation); + } +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + cardWrapper: css({ + alignItems: 'stretch', + }), + error: css({ + marginBottom: theme.spacing(1), + }), + card: css({ + background: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.medium}`, + cursor: 'grab', + borderRadius: theme.shape.radius.default, + marginBottom: theme.spacing(1), + position: 'relative', + transition: 'all 0.5s ease-in 0s', + height: '100%', + }), + cardError: css({ + boxShadow: `0px 0px 4px 0px ${theme.colors.warning.main}`, + border: `1px solid ${theme.colors.warning.main}`, + }), + cardHighlight: css({ + boxShadow: `0px 0px 4px 0px ${theme.colors.primary.border}`, + border: `1px solid ${theme.colors.primary.border}`, + }), + infoIcon: css({ + marginLeft: theme.spacing(0.5), + color: theme.colors.text.secondary, + ':hover': { + color: theme.colors.text.primary, + }, + }), + body: css({ + margin: theme.spacing(1, 1, 0.5, 1), + display: 'table', + }), + paramRow: css({ + label: 'paramRow', + display: 'table-row', + verticalAlign: 'middle', + }), + paramName: css({ + display: 'table-cell', + padding: theme.spacing(0, 1, 0, 0), + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.fontWeightMedium, + verticalAlign: 'middle', + height: '32px', + }), + paramValue: css({ + label: 'paramValue', + display: 'table-cell', + verticalAlign: 'middle', + }), + restParam: css({ + padding: theme.spacing(0, 1, 1, 1), + }), + arrow: css({ + position: 'absolute', + top: '0', + right: '-18px', + display: 'flex', + }), + arrowLine: css({ + height: '2px', + width: '8px', + backgroundColor: theme.colors.border.strong, + position: 'relative', + top: '14px', + }), + arrowArrow: css({ + width: 0, + height: 0, + borderTop: `5px solid transparent`, + borderBottom: `5px solid transparent`, + borderLeft: `7px solid ${theme.colors.border.strong}`, + position: 'relative', + top: '10px', + }), + }; +}; + +type OperationEditorStyles = ReturnType<typeof getStyles>; diff --git a/packages/grafana-prometheus/src/querybuilder/shared/OperationExplainedBox.tsx b/packages/grafana-prometheus/src/querybuilder/shared/OperationExplainedBox.tsx new file mode 100644 index 0000000000000..f38597c3c1035 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/shared/OperationExplainedBox.tsx @@ -0,0 +1,77 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2, renderMarkdown } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +export interface Props { + title?: React.ReactNode; + children?: React.ReactNode; + markdown?: string; + stepNumber?: number; +} + +export function OperationExplainedBox({ title, stepNumber, markdown, children }: Props) { + const styles = useStyles2(getStyles); + + return ( + <div className={styles.box}> + {stepNumber !== undefined && <div className={styles.stepNumber}>{stepNumber}</div>} + <div className={styles.boxInner}> + {title && ( + <div className={styles.header}> + <span>{title}</span> + </div> + )} + <div className={styles.body}> + {markdown && <div dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown) }}></div>} + {children} + </div> + </div> + </div> + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + box: css({ + background: theme.colors.background.secondary, + padding: theme.spacing(1), + borderRadius: theme.shape.radius.default, + position: 'relative', + }), + boxInner: css({ + marginLeft: theme.spacing(4), + }), + stepNumber: css({ + fontWeight: theme.typography.fontWeightMedium, + background: theme.colors.secondary.main, + width: '20px', + height: '20px', + borderRadius: theme.shape.radius.circle, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + position: 'absolute', + top: '10px', + left: '11px', + fontSize: theme.typography.bodySmall.fontSize, + }), + header: css({ + paddingBottom: theme.spacing(0.5), + display: 'flex', + alignItems: 'center', + fontFamily: theme.typography.fontFamilyMonospace, + }), + body: css({ + color: theme.colors.text.secondary, + 'p:last-child': { + margin: 0, + }, + a: { + color: theme.colors.text.link, + textDecoration: 'underline', + }, + }), + }; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/shared/OperationHeader.tsx b/packages/grafana-prometheus/src/querybuilder/shared/OperationHeader.tsx new file mode 100644 index 0000000000000..a47c747c9d58a --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/shared/OperationHeader.tsx @@ -0,0 +1,121 @@ +import { css } from '@emotion/css'; +import React, { useState } from 'react'; +import { DraggableProvided } from 'react-beautiful-dnd'; + +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { FlexItem } from '@grafana/experimental'; +import { Button, Select, useStyles2 } from '@grafana/ui'; + +import { OperationInfoButton } from './OperationInfoButton'; +import { QueryBuilderOperation, QueryBuilderOperationDef, VisualQueryModeller } from './types'; + +export interface Props { + operation: QueryBuilderOperation; + def: QueryBuilderOperationDef; + index: number; + queryModeller: VisualQueryModeller; + dragHandleProps?: DraggableProvided['dragHandleProps']; + onChange: (index: number, update: QueryBuilderOperation) => void; + onRemove: (index: number) => void; +} + +interface State { + isOpen?: boolean; + alternatives?: Array<SelectableValue<QueryBuilderOperationDef>>; +} + +export const OperationHeader = React.memo<Props>( + ({ operation, def, index, onChange, onRemove, queryModeller, dragHandleProps }) => { + const styles = useStyles2(getStyles); + const [state, setState] = useState<State>({}); + + const onToggleSwitcher = () => { + if (state.isOpen) { + setState({ ...state, isOpen: false }); + } else { + const alternatives = queryModeller + .getAlternativeOperations(def.alternativesKey!) + .map((alt) => ({ label: alt.name, value: alt })); + setState({ isOpen: true, alternatives }); + } + }; + + return ( + <div className={styles.header}> + {!state.isOpen && ( + <> + <div {...dragHandleProps}>{def.name ?? def.id}</div> + <FlexItem grow={1} /> + <div className={`${styles.operationHeaderButtons} operation-header-show-on-hover`}> + <Button + icon="angle-down" + size="sm" + onClick={onToggleSwitcher} + fill="text" + variant="secondary" + title="Click to view alternative operations" + /> + <OperationInfoButton def={def} operation={operation} /> + <Button + icon="times" + size="sm" + onClick={() => onRemove(index)} + fill="text" + variant="secondary" + title="Remove operation" + /> + </div> + </> + )} + {state.isOpen && ( + <div className={styles.selectWrapper}> + <Select + autoFocus + openMenuOnFocus + placeholder="Replace with" + options={state.alternatives} + isOpen={true} + onCloseMenu={onToggleSwitcher} + onChange={(value) => { + if (value.value) { + // Operation should exist if it is selectable + const newDef = queryModeller.getOperationDef(value.value.id)!; + + // copy default params, and override with all current params + const newParams = [...newDef.defaultParams]; + for (let i = 0; i < Math.min(operation.params.length, newParams.length); i++) { + if (newDef.params[i].type === def.params[i].type) { + newParams[i] = operation.params[i]; + } + } + + const changedOp = { ...operation, params: newParams, id: value.value.id }; + onChange(index, def.changeTypeHandler ? def.changeTypeHandler(changedOp, newDef) : changedOp); + } + }} + /> + </div> + )} + </div> + ); + } +); + +OperationHeader.displayName = 'OperationHeader'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + header: css({ + borderBottom: `1px solid ${theme.colors.border.medium}`, + padding: theme.spacing(0.5, 0.5, 0.5, 1), + display: 'flex', + alignItems: 'center', + }), + operationHeaderButtons: css({ + opacity: 1, + }), + selectWrapper: css({ + paddingRight: theme.spacing(2), + }), + }; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/shared/OperationInfoButton.tsx b/packages/grafana-prometheus/src/querybuilder/shared/OperationInfoButton.tsx new file mode 100644 index 0000000000000..56f125093903f --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/shared/OperationInfoButton.tsx @@ -0,0 +1,121 @@ +import { css } from '@emotion/css'; +import { + autoUpdate, + flip, + offset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, +} from '@floating-ui/react'; +import React, { useState } from 'react'; + +import { GrafanaTheme2, renderMarkdown } from '@grafana/data'; +import { FlexItem } from '@grafana/experimental'; +import { Button, Portal, useStyles2 } from '@grafana/ui'; + +import { QueryBuilderOperation, QueryBuilderOperationDef } from './types'; + +export interface Props { + operation: QueryBuilderOperation; + def: QueryBuilderOperationDef; +} + +export const OperationInfoButton = React.memo<Props>(({ def, operation }) => { + const styles = useStyles2(getStyles); + const [show, setShow] = useState(false); + + // the order of middleware is important! + const middleware = [ + offset(16), + flip({ + fallbackAxisSideDirection: 'end', + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ]; + + const { context, refs, floatingStyles } = useFloating({ + open: show, + placement: 'top', + onOpenChange: setShow, + middleware, + whileElementsMounted: autoUpdate, + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]); + + return ( + <> + <Button + title="Click to show description" + ref={refs.setReference} + icon="info-circle" + size="sm" + variant="secondary" + fill="text" + {...getReferenceProps()} + /> + {show && ( + <Portal> + <div ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()} className={styles.docBox}> + <div className={styles.docBoxHeader}> + <span>{def.renderer(operation, def, '<expr>')}</span> + <FlexItem grow={1} /> + <Button + icon="times" + onClick={() => setShow(false)} + fill="text" + variant="secondary" + title="Remove operation" + /> + </div> + <div + className={styles.docBoxBody} + dangerouslySetInnerHTML={{ __html: getOperationDocs(def, operation) }} + ></div> + </div> + </Portal> + )} + </> + ); +}); + +OperationInfoButton.displayName = 'OperationDocs'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + docBox: css({ + overflow: 'hidden', + background: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.strong}`, + boxShadow: theme.shadows.z3, + maxWidth: '600px', + padding: theme.spacing(1), + borderRadius: theme.shape.radius.default, + zIndex: theme.zIndex.tooltip, + }), + docBoxHeader: css({ + fontSize: theme.typography.h5.fontSize, + fontFamily: theme.typography.fontFamilyMonospace, + paddingBottom: theme.spacing(1), + display: 'flex', + alignItems: 'center', + }), + docBoxBody: css({ + // The markdown paragraph has a marginBottom this removes it + marginBottom: theme.spacing(-1), + color: theme.colors.text.secondary, + }), + }; +}; + +function getOperationDocs(def: QueryBuilderOperationDef, op: QueryBuilderOperation): string { + return renderMarkdown(def.explainHandler ? def.explainHandler(op, def) : def.documentation ?? 'no docs'); +} diff --git a/packages/grafana-prometheus/src/querybuilder/shared/OperationList.test.tsx b/packages/grafana-prometheus/src/querybuilder/shared/OperationList.test.tsx new file mode 100644 index 0000000000000..02d95b87236b3 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/shared/OperationList.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { DataSourceApi, DataSourceInstanceSettings } from '@grafana/data'; + +import { PrometheusDatasource } from '../../datasource'; +import PromQlLanguageProvider from '../../language_provider'; +import { EmptyLanguageProviderMock } from '../../language_provider.mock'; +import { PromOptions } from '../../types'; +import { promQueryModeller } from '../PromQueryModeller'; +import { addOperationInQueryBuilder } from '../testUtils'; +import { PromVisualQuery } from '../types'; + +import { OperationList } from './OperationList'; + +const defaultQuery: PromVisualQuery = { + metric: 'random_metric', + labels: [{ label: 'instance', op: '=', value: 'localhost:9090' }], + operations: [ + { + id: 'rate', + params: ['auto'], + }, + { + id: '__sum_by', + params: ['instance', 'job'], + }, + ], +}; + +describe('OperationList', () => { + it('renders operations', async () => { + setup(); + expect(screen.getByText('Rate')).toBeInTheDocument(); + expect(screen.getByText('Sum by')).toBeInTheDocument(); + }); + + it('removes an operation', async () => { + const { onChange } = setup(); + const removeOperationButtons = screen.getAllByTitle('Remove operation'); + expect(removeOperationButtons).toHaveLength(2); + await userEvent.click(removeOperationButtons[1]); + expect(onChange).toBeCalledWith({ + labels: [{ label: 'instance', op: '=', value: 'localhost:9090' }], + metric: 'random_metric', + operations: [{ id: 'rate', params: ['auto'] }], + }); + }); + + it('adds an operation', async () => { + const { onChange } = setup(); + await addOperationInQueryBuilder('Aggregations', 'Min'); + expect(onChange).toBeCalledWith({ + labels: [{ label: 'instance', op: '=', value: 'localhost:9090' }], + metric: 'random_metric', + operations: [ + { id: 'rate', params: ['auto'] }, + { id: '__sum_by', params: ['instance', 'job'] }, + { id: 'min', params: [] }, + ], + }); + }); +}); + +function setup(query: PromVisualQuery = defaultQuery) { + const languageProvider = new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider; + const props = { + datasource: new PrometheusDatasource( + { + url: '', + jsonData: {}, + meta: {}, + } as DataSourceInstanceSettings<PromOptions>, + undefined, + languageProvider + ) as DataSourceApi, + onRunQuery: () => {}, + onChange: jest.fn(), + queryModeller: promQueryModeller, + }; + + render(<OperationList {...props} query={query} />); + return props; +} diff --git a/packages/grafana-prometheus/src/querybuilder/shared/OperationList.tsx b/packages/grafana-prometheus/src/querybuilder/shared/OperationList.tsx new file mode 100644 index 0000000000000..6e104e2675fcd --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/shared/OperationList.tsx @@ -0,0 +1,204 @@ +import { css } from '@emotion/css'; +import React, { useState } from 'react'; +import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd'; +import { useMountedState, usePrevious } from 'react-use'; + +import { DataSourceApi, GrafanaTheme2, TimeRange } from '@grafana/data'; +import { Button, Cascader, CascaderOption, useStyles2, Stack } from '@grafana/ui'; + +import { OperationEditor } from './OperationEditor'; +import { QueryBuilderOperation, QueryWithOperations, VisualQueryModeller } from './types'; + +export interface Props<T extends QueryWithOperations> { + query: T; + datasource: DataSourceApi; + onChange: (query: T) => void; + onRunQuery: () => void; + queryModeller: VisualQueryModeller; + explainMode?: boolean; + highlightedOp?: QueryBuilderOperation; + timeRange?: TimeRange; +} + +export function OperationList<T extends QueryWithOperations>({ + query, + datasource, + queryModeller, + onChange, + onRunQuery, + highlightedOp, + timeRange, +}: Props<T>) { + const styles = useStyles2(getStyles); + const { operations } = query; + + const opsToHighlight = useOperationsHighlight(operations); + + const [cascaderOpen, setCascaderOpen] = useState(false); + + const onOperationChange = (index: number, update: QueryBuilderOperation) => { + const updatedList = [...operations]; + updatedList.splice(index, 1, update); + onChange({ ...query, operations: updatedList }); + }; + + const onRemove = (index: number) => { + const updatedList = [...operations.slice(0, index), ...operations.slice(index + 1)]; + onChange({ ...query, operations: updatedList }); + }; + + const addOptions: CascaderOption[] = queryModeller.getCategories().map((category) => { + return { + value: category, + label: category, + items: queryModeller.getOperationsForCategory(category).map((operation) => ({ + value: operation.id, + label: operation.name, + isLeaf: true, + })), + }; + }); + + const onAddOperation = (value: string) => { + const operationDef = queryModeller.getOperationDef(value); + if (!operationDef) { + return; + } + onChange(operationDef.addOperationHandler(operationDef, query, queryModeller)); + setCascaderOpen(false); + }; + + const onDragEnd = (result: DropResult) => { + if (!result.destination) { + return; + } + + const updatedList = [...operations]; + const element = updatedList[result.source.index]; + updatedList.splice(result.source.index, 1); + updatedList.splice(result.destination.index, 0, element); + onChange({ ...query, operations: updatedList }); + }; + + const onCascaderBlur = () => { + setCascaderOpen(false); + }; + + return ( + <Stack gap={1} direction="column"> + <Stack gap={1}> + {operations.length > 0 && ( + <DragDropContext onDragEnd={onDragEnd}> + <Droppable droppableId="sortable-field-mappings" direction="horizontal"> + {(provided) => ( + <div className={styles.operationList} ref={provided.innerRef} {...provided.droppableProps}> + {operations.map((op, index) => { + return ( + <OperationEditor + key={op.id + JSON.stringify(op.params) + index} + queryModeller={queryModeller} + index={index} + operation={op} + query={query} + datasource={datasource} + onChange={onOperationChange} + onRemove={onRemove} + onRunQuery={onRunQuery} + flash={opsToHighlight[index]} + highlight={highlightedOp === op} + timeRange={timeRange} + /> + ); + })} + {provided.placeholder} + </div> + )} + </Droppable> + </DragDropContext> + )} + <div className={styles.addButton}> + {cascaderOpen ? ( + <Cascader + options={addOptions} + onSelect={onAddOperation} + onBlur={onCascaderBlur} + autoFocus={true} + alwaysOpen={true} + hideActiveLevelLabel={true} + placeholder={'Search'} + /> + ) : ( + <Button icon={'plus'} variant={'secondary'} onClick={() => setCascaderOpen(true)} title={'Add operation'}> + Operations + </Button> + )} + </div> + </Stack> + </Stack> + ); +} + +/** + * Returns indexes of operations that should be highlighted. We check the diff of operations added but at the same time + * we want to highlight operations only after the initial render, so we check for mounted state and calculate the diff + * only after. + * @param operations + */ +function useOperationsHighlight(operations: QueryBuilderOperation[]) { + const isMounted = useMountedState(); + const prevOperations = usePrevious(operations); + + if (!isMounted()) { + return operations.map(() => false); + } + + if (!prevOperations) { + return operations.map(() => true); + } + + let newOps: boolean[] = []; + + if (prevOperations.length - 1 === operations.length && operations.every((op) => prevOperations.includes(op))) { + // In case we remove one op and does not change any ops then don't highlight anything. + return operations.map(() => false); + } + if (prevOperations.length + 1 === operations.length && prevOperations.every((op) => operations.includes(op))) { + // If we add a single op just find it and highlight just that. + const newOp = operations.find((op) => !prevOperations.includes(op)); + newOps = operations.map((op) => { + return op === newOp; + }); + } else { + // Default diff of all ops. + newOps = operations.map((op, index) => { + return !isSameOp(op.id, prevOperations[index]?.id); + }); + } + return newOps; +} + +function isSameOp(op1?: string, op2?: string) { + return op1 === op2 || `__${op1}_by` === op2 || op1 === `__${op2}_by`; +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + heading: css({ + label: 'heading', + fontSize: 12, + fontWeight: theme.typography.fontWeightMedium, + marginBottom: 0, + }), + operationList: css({ + label: 'operationList', + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(2), + }), + addButton: css({ + label: 'addButton', + width: 126, + paddingBottom: theme.spacing(1), + }), + }; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/shared/OperationListExplained.tsx b/packages/grafana-prometheus/src/querybuilder/shared/OperationListExplained.tsx new file mode 100644 index 0000000000000..bfc7fba380854 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/shared/OperationListExplained.tsx @@ -0,0 +1,55 @@ +import { Grammar } from 'prismjs'; +import React from 'react'; + +import { OperationExplainedBox } from './OperationExplainedBox'; +import { RawQuery } from './RawQuery'; +import { QueryBuilderOperation, QueryWithOperations, VisualQueryModeller } from './types'; + +export interface Props<T extends QueryWithOperations> { + query: T; + queryModeller: VisualQueryModeller; + explainMode?: boolean; + stepNumber: number; + lang: { + grammar: Grammar; + name: string; + }; + onMouseEnter?: (op: QueryBuilderOperation, index: number) => void; + onMouseLeave?: (op: QueryBuilderOperation, index: number) => void; +} + +export function OperationListExplained<T extends QueryWithOperations>({ + query, + queryModeller, + stepNumber, + lang, + onMouseEnter, + onMouseLeave, +}: Props<T>) { + return ( + <> + {query.operations.map((op, index) => { + const def = queryModeller.getOperationDef(op.id); + if (!def) { + return `Operation ${op.id} not found`; + } + const title = def.renderer(op, def, '<expr>'); + const body = def.explainHandler ? def.explainHandler(op, def) : def.documentation ?? 'no docs'; + + return ( + <div + key={index} + onMouseEnter={() => onMouseEnter?.(op, index)} + onMouseLeave={() => onMouseLeave?.(op, index)} + > + <OperationExplainedBox + stepNumber={index + stepNumber} + title={<RawQuery query={title} lang={lang} />} + markdown={body} + /> + </div> + ); + })} + </> + ); +} diff --git a/packages/grafana-prometheus/src/querybuilder/shared/OperationParamEditor.tsx b/packages/grafana-prometheus/src/querybuilder/shared/OperationParamEditor.tsx new file mode 100644 index 0000000000000..e9bbbff644b51 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/shared/OperationParamEditor.tsx @@ -0,0 +1,130 @@ +import { css } from '@emotion/css'; +import React, { ComponentType } from 'react'; + +import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data'; +import { AutoSizeInput, Button, Checkbox, Select, useStyles2, Stack } from '@grafana/ui'; + +import { getOperationParamId } from '../operationUtils'; + +import { QueryBuilderOperationParamDef, QueryBuilderOperationParamEditorProps } from './types'; + +export function getOperationParamEditor( + paramDef: QueryBuilderOperationParamDef +): ComponentType<QueryBuilderOperationParamEditorProps> { + if (paramDef.editor) { + return paramDef.editor; + } + + if (paramDef.options) { + return SelectInputParamEditor; + } + + switch (paramDef.type) { + case 'boolean': + return BoolInputParamEditor; + case 'number': + case 'string': + default: + return SimpleInputParamEditor; + } +} + +function SimpleInputParamEditor(props: QueryBuilderOperationParamEditorProps) { + return ( + <AutoSizeInput + id={getOperationParamId(props.operationId, props.index)} + defaultValue={props.value?.toString()} + minWidth={props.paramDef.minWidth} + placeholder={props.paramDef.placeholder} + title={props.paramDef.description} + maxWidth={(props.paramDef.minWidth || 20) * 3} + onCommitChange={(evt) => { + props.onChange(props.index, evt.currentTarget.value); + if (props.paramDef.runQueryOnEnter && evt.type === 'keydown') { + props.onRunQuery(); + } + }} + /> + ); +} + +function BoolInputParamEditor(props: QueryBuilderOperationParamEditorProps) { + return ( + <Checkbox + id={getOperationParamId(props.operationId, props.index)} + value={Boolean(props.value)} + onChange={(evt) => props.onChange(props.index, evt.currentTarget.checked)} + /> + ); +} + +function SelectInputParamEditor({ + paramDef, + value, + index, + operationId, + onChange, +}: QueryBuilderOperationParamEditorProps) { + const styles = useStyles2(getStyles); + let selectOptions = paramDef.options as SelectableValue[]; + + if (!selectOptions[0]?.label) { + selectOptions = paramDef.options!.map((option) => ({ + label: option.toString(), + value: option, + })); + } + + let valueOption = selectOptions.find((x) => x.value === value) ?? toOption(value as string); + + // If we have optional options param and don't have value, we want to render button with which we add optional options. + // This makes it easier to understand what needs to be selected and what is optional. + if (!value && paramDef.optional) { + return ( + <div className={styles.optionalParam}> + <Button + size="sm" + variant="secondary" + title={`Add ${paramDef.name}`} + icon="plus" + onClick={() => onChange(index, selectOptions[0].value)} + > + {paramDef.name} + </Button> + </div> + ); + } + + return ( + <Stack gap={0.5} direction="row" alignItems="center"> + <Select + id={getOperationParamId(operationId, index)} + value={valueOption} + options={selectOptions} + placeholder={paramDef.placeholder} + allowCustomValue={true} + onChange={(value) => onChange(index, value.value!)} + width={paramDef.minWidth || 'auto'} + /> + {paramDef.optional && ( + <Button + data-testid={`operations.${index}.remove-param`} + size="sm" + fill="text" + icon="times" + variant="secondary" + title={`Remove ${paramDef.name}`} + onClick={() => onChange(index, '')} + /> + )} + </Stack> + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + optionalParam: css({ + marginTop: theme.spacing(1), + }), + }; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/shared/OperationsEditorRow.tsx b/packages/grafana-prometheus/src/querybuilder/shared/OperationsEditorRow.tsx new file mode 100644 index 0000000000000..00ebc654f068e --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/shared/OperationsEditorRow.tsx @@ -0,0 +1,29 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2, Stack } from '@grafana/ui'; + +interface Props { + children: React.ReactNode; +} + +export function OperationsEditorRow({ children }: Props) { + const styles = useStyles2(getStyles); + + return ( + <div className={styles.root}> + <Stack gap={1}>{children}</Stack> + </div> + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + root: css({ + padding: theme.spacing(1, 1, 0, 1), + backgroundColor: theme.colors.background.secondary, + borderRadius: theme.shape.radius.default, + }), + }; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/shared/QueryBuilderHints.tsx b/packages/grafana-prometheus/src/querybuilder/shared/QueryBuilderHints.tsx new file mode 100644 index 0000000000000..91389eb495bd9 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/shared/QueryBuilderHints.tsx @@ -0,0 +1,87 @@ +import { css } from '@emotion/css'; +import React, { useEffect, useState } from 'react'; + +import { GrafanaTheme2, PanelData, QueryHint } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { Button, Tooltip, useStyles2 } from '@grafana/ui'; + +import { PrometheusDatasource } from '../../datasource'; + +import { LokiAndPromQueryModellerBase, PromLokiVisualQuery } from './LokiAndPromQueryModellerBase'; + +export interface Props<T extends PromLokiVisualQuery> { + query: T; + datasource: PrometheusDatasource; + queryModeller: LokiAndPromQueryModellerBase; + buildVisualQueryFromString: (expr: string) => { query: T }; + onChange: (update: T) => void; + data?: PanelData; +} + +export const QueryBuilderHints = <T extends PromLokiVisualQuery>({ + datasource, + query: visualQuery, + onChange, + data, + queryModeller, + buildVisualQueryFromString, +}: Props<T>) => { + const [hints, setHints] = useState<QueryHint[]>([]); + const styles = useStyles2(getStyles); + + useEffect(() => { + const query = { expr: queryModeller.renderQuery(visualQuery), refId: '' }; + // For now show only actionable hints + const hints = datasource.getQueryHints(query, data?.series || []).filter((hint) => hint.fix?.action); + setHints(hints); + }, [datasource, visualQuery, data, queryModeller]); + + return ( + <> + {hints.length > 0 && ( + <div className={styles.container}> + {hints.map((hint) => { + return ( + <Tooltip content={`${hint.label} ${hint.fix?.label}`} key={hint.type}> + <Button + onClick={() => { + reportInteraction('grafana_query_builder_hints_clicked', { + hint: hint.type, + datasourceType: datasource.type, + }); + + if (hint?.fix?.action) { + const query = { expr: queryModeller.renderQuery(visualQuery), refId: '' }; + const newQuery = datasource.modifyQuery(query, hint.fix.action); + const newVisualQuery = buildVisualQueryFromString(newQuery.expr); + return onChange(newVisualQuery.query); + } + }} + fill="outline" + size="sm" + className={styles.hint} + > + hint: {hint.fix?.title || hint.fix?.action?.type.toLowerCase().replace('_', ' ')} + </Button> + </Tooltip> + ); + })} + </div> + )} + </> + ); +}; + +QueryBuilderHints.displayName = 'QueryBuilderHints'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css` + display: flex; + align-items: start; + `, + hint: css` + margin-right: ${theme.spacing(1)}; + `, + }; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/shared/QueryEditorModeToggle.tsx b/packages/grafana-prometheus/src/querybuilder/shared/QueryEditorModeToggle.tsx new file mode 100644 index 0000000000000..b47d89b6301e6 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/shared/QueryEditorModeToggle.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { RadioButtonGroup } from '@grafana/ui'; + +import { QueryEditorMode } from './types'; + +export interface Props { + mode: QueryEditorMode; + onChange: (mode: QueryEditorMode) => void; +} + +const editorModes = [ + { label: 'Builder', value: QueryEditorMode.Builder }, + { label: 'Code', value: QueryEditorMode.Code }, +]; + +export function QueryEditorModeToggle({ mode, onChange }: Props) { + return ( + <div data-testid={'QueryEditorModeToggle'}> + <RadioButtonGroup options={editorModes} size="sm" value={mode} onChange={onChange} /> + </div> + ); +} diff --git a/packages/grafana-prometheus/src/querybuilder/shared/QueryHeaderSwitch.tsx b/packages/grafana-prometheus/src/querybuilder/shared/QueryHeaderSwitch.tsx new file mode 100644 index 0000000000000..b7088bb3f8b7d --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/shared/QueryHeaderSwitch.tsx @@ -0,0 +1,39 @@ +import { css } from '@emotion/css'; +import { uniqueId } from 'lodash'; +import React, { HTMLProps, useRef } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Switch, useStyles2, Stack } from '@grafana/ui'; + +export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'value' | 'ref'> { + value?: boolean; + label: string; +} + +export function QueryHeaderSwitch({ label, ...inputProps }: Props) { + const dashedLabel = label.replace(' ', '-'); + const switchIdRef = useRef(uniqueId(`switch-${dashedLabel}`)); + const styles = useStyles2(getStyles); + + return ( + <Stack gap={1}> + <label htmlFor={switchIdRef.current} className={styles.switchLabel}> + {label} + </label> + <Switch {...inputProps} id={switchIdRef.current} /> + </Stack> + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + switchLabel: css({ + color: theme.colors.text.secondary, + cursor: 'pointer', + fontSize: theme.typography.bodySmall.fontSize, + '&:hover': { + color: theme.colors.text.primary, + }, + }), + }; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/shared/QueryOptionGroup.tsx b/packages/grafana-prometheus/src/querybuilder/shared/QueryOptionGroup.tsx new file mode 100644 index 0000000000000..b0c6951b0928f --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/shared/QueryOptionGroup.tsx @@ -0,0 +1,85 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import { useToggle } from 'react-use'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Collapse, useStyles2, Stack } from '@grafana/ui'; + +export interface Props { + title: string; + collapsedInfo: string[]; + children: React.ReactNode; +} + +export function QueryOptionGroup({ title, children, collapsedInfo }: Props) { + const [isOpen, toggleOpen] = useToggle(false); + const styles = useStyles2(getStyles); + + return ( + <div className={styles.wrapper}> + <Collapse + className={styles.collapse} + collapsible + isOpen={isOpen} + onToggle={toggleOpen} + label={ + <Stack gap={0}> + <h6 className={styles.title}>{title}</h6> + {!isOpen && ( + <div className={styles.description}> + {collapsedInfo.map((x, i) => ( + <span key={i}>{x}</span> + ))} + </div> + )} + </Stack> + } + > + <div className={styles.body}>{children}</div> + </Collapse> + </div> + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + collapse: css({ + backgroundColor: 'unset', + border: 'unset', + marginBottom: 0, + + ['> button']: { + padding: theme.spacing(0, 1), + }, + }), + wrapper: css({ + width: '100%', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'baseline', + }), + title: css({ + flexGrow: 1, + overflow: 'hidden', + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.fontWeightMedium, + margin: 0, + }), + description: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.bodySmall.fontWeight, + paddingLeft: theme.spacing(2), + gap: theme.spacing(2), + display: 'flex', + }), + body: css({ + display: 'flex', + gap: theme.spacing(2), + flexWrap: 'wrap', + }), + tooltip: css({ + marginRight: theme.spacing(0.25), + }), + }; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/shared/RawQuery.tsx b/packages/grafana-prometheus/src/querybuilder/shared/RawQuery.tsx new file mode 100644 index 0000000000000..b2cc3d8a37272 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/shared/RawQuery.tsx @@ -0,0 +1,38 @@ +import { css, cx } from '@emotion/css'; +import Prism, { Grammar } from 'prismjs'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useTheme2 } from '@grafana/ui'; + +export interface Props { + query: string; + lang: { + grammar: Grammar; + name: string; + }; + className?: string; +} + +export function RawQuery({ query, lang, className }: Props) { + const theme = useTheme2(); + const styles = getStyles(theme); + const highlighted = Prism.highlight(query, lang.grammar, lang.name); + + return ( + <div + className={cx(styles.editorField, 'prism-syntax-highlight', className)} + aria-label="selector" + dangerouslySetInnerHTML={{ __html: highlighted }} + /> + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + editorField: css({ + fontFamily: theme.typography.fontFamilyMonospace, + fontSize: theme.typography.bodySmall.fontSize, + }), + }; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/shared/types.ts b/packages/grafana-prometheus/src/querybuilder/shared/types.ts new file mode 100644 index 0000000000000..73d46de0a7400 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/shared/types.ts @@ -0,0 +1,112 @@ +/** + * Shared types that can be reused by Loki and other data sources + */ +import { ComponentType } from 'react'; + +import { DataSourceApi, RegistryItem, SelectableValue, TimeRange } from '@grafana/data'; + +export interface QueryBuilderLabelFilter { + label: string; + op: string; + value: string; +} + +export interface QueryBuilderOperation { + id: string; + params: QueryBuilderOperationParamValue[]; +} + +export interface QueryWithOperations { + operations: QueryBuilderOperation[]; +} + +export interface QueryBuilderOperationDef<T = any> extends RegistryItem { + documentation?: string; + params: QueryBuilderOperationParamDef[]; + defaultParams: QueryBuilderOperationParamValue[]; + category: string; + hideFromList?: boolean; + alternativesKey?: string; + /** Can be used to control operation placement when adding a new operations, lower are placed first */ + orderRank?: number; + renderer: QueryBuilderOperationRenderer; + addOperationHandler: QueryBuilderAddOperationHandler<T>; + paramChangedHandler?: QueryBuilderOnParamChangedHandler; + explainHandler?: QueryBuilderExplainOperationHandler; + changeTypeHandler?: (op: QueryBuilderOperation, newDef: QueryBuilderOperationDef<T>) => QueryBuilderOperation; +} + +export type QueryBuilderAddOperationHandler<T> = ( + def: QueryBuilderOperationDef, + query: T, + modeller: VisualQueryModeller +) => T; + +export type QueryBuilderExplainOperationHandler = (op: QueryBuilderOperation, def?: QueryBuilderOperationDef) => string; + +export type QueryBuilderOnParamChangedHandler = ( + index: number, + operation: QueryBuilderOperation, + operationDef: QueryBuilderOperationDef +) => QueryBuilderOperation; + +export type QueryBuilderOperationRenderer = ( + model: QueryBuilderOperation, + def: QueryBuilderOperationDef, + innerExpr: string +) => string; + +export type QueryBuilderOperationParamValue = string | number | boolean; + +export interface QueryBuilderOperationParamDef { + name: string; + type: 'string' | 'number' | 'boolean'; + options?: string[] | number[] | Array<SelectableValue<string>>; + hideName?: boolean; + restParam?: boolean; + optional?: boolean; + placeholder?: string; + description?: string; + minWidth?: number; + editor?: ComponentType<QueryBuilderOperationParamEditorProps>; + runQueryOnEnter?: boolean; +} + +export interface QueryBuilderOperationEditorProps { + operation: QueryBuilderOperation; + index: number; + query: any; + datasource: DataSourceApi; + queryModeller: VisualQueryModeller; + onChange: (index: number, update: QueryBuilderOperation) => void; + onRemove: (index: number) => void; +} + +export interface QueryBuilderOperationParamEditorProps { + value?: QueryBuilderOperationParamValue; + paramDef: QueryBuilderOperationParamDef; + /** Parameter index */ + index: number; + operation: QueryBuilderOperation; + operationId: string; + query: any; + datasource: DataSourceApi; + timeRange?: TimeRange; + onChange: (index: number, value: QueryBuilderOperationParamValue) => void; + onRunQuery: () => void; +} + +export enum QueryEditorMode { + Code = 'code', + Builder = 'builder', +} + +export interface VisualQueryModeller { + getOperationsForCategory(category: string): QueryBuilderOperationDef[]; + + getAlternativeOperations(key: string): QueryBuilderOperationDef[]; + + getCategories(): string[]; + + getOperationDef(id: string): QueryBuilderOperationDef | undefined; +} diff --git a/packages/grafana-prometheus/src/querybuilder/state.test.ts b/packages/grafana-prometheus/src/querybuilder/state.test.ts new file mode 100644 index 0000000000000..a96f876ada11b --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/state.test.ts @@ -0,0 +1,64 @@ +import { CoreApp } from '@grafana/data'; + +import { PromQuery } from '../types'; + +import { QueryEditorMode } from './shared/types'; +import { changeEditorMode, getQueryWithDefaults } from './state'; + +describe('getQueryWithDefaults(', () => { + it('should set defaults', () => { + expect(getQueryWithDefaults({ expr: '', refId: 'A' } as PromQuery, CoreApp.Dashboard)).toEqual({ + editorMode: 'builder', + expr: '', + legendFormat: '__auto', + range: true, + refId: 'A', + }); + }); + + it('should set both range and instant to true when in Explore', () => { + expect(getQueryWithDefaults({ expr: '', refId: 'A' } as PromQuery, CoreApp.Explore)).toEqual({ + editorMode: 'builder', + expr: '', + legendFormat: '__auto', + range: true, + instant: true, + refId: 'A', + }); + }); + + it('should not set both instant and range for Prometheus queries in Alert Creation', () => { + expect( + getQueryWithDefaults({ expr: '', refId: 'A', range: true, instant: true } as PromQuery, CoreApp.UnifiedAlerting) + ).toEqual({ + editorMode: 'builder', + expr: '', + legendFormat: '__auto', + range: true, + instant: false, + refId: 'A', + }); + }); + + it('changing editor mode with blank query should change default', () => { + changeEditorMode({ refId: 'A', expr: '' }, QueryEditorMode.Code, (query) => { + expect(query.editorMode).toBe(QueryEditorMode.Code); + }); + + expect(getQueryWithDefaults({ expr: '', refId: 'A' } as PromQuery, CoreApp.Dashboard).editorMode).toEqual( + QueryEditorMode.Code + ); + }); + + it('should return default editor mode when it is provided', () => { + expect( + getQueryWithDefaults({ expr: '', refId: 'A' } as PromQuery, CoreApp.Dashboard, QueryEditorMode.Code) + ).toEqual({ + editorMode: 'code', + expr: '', + legendFormat: '__auto', + range: true, + refId: 'A', + }); + }); +}); diff --git a/packages/grafana-prometheus/src/querybuilder/state.ts b/packages/grafana-prometheus/src/querybuilder/state.ts new file mode 100644 index 0000000000000..06ef89c33fc36 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/state.ts @@ -0,0 +1,72 @@ +import { CoreApp } from '@grafana/data'; + +import store from '../gcopypaste/app/core/store'; +import { LegendFormatMode, PromQuery } from '../types'; + +import { QueryEditorMode } from './shared/types'; + +const queryEditorModeDefaultLocalStorageKey = 'PrometheusQueryEditorModeDefault'; + +export function changeEditorMode(query: PromQuery, editorMode: QueryEditorMode, onChange: (query: PromQuery) => void) { + // If empty query store new mode as default + if (query.expr === '') { + store.set(queryEditorModeDefaultLocalStorageKey, editorMode); + } + + onChange({ ...query, editorMode }); +} + +function getDefaultEditorMode(expr: string, defaultEditor: QueryEditorMode = QueryEditorMode.Builder): QueryEditorMode { + // If we already have an expression default to code view + if (expr != null && expr !== '') { + return QueryEditorMode.Code; + } + + const value: QueryEditorMode = store.get(queryEditorModeDefaultLocalStorageKey); + switch (value) { + case QueryEditorMode.Builder: + case QueryEditorMode.Code: + return value; + default: + return defaultEditor; + } +} + +/** + * Returns query with defaults, and boolean true/false depending on change was required + */ +export function getQueryWithDefaults( + query: PromQuery & { expr?: string }, + app: CoreApp | undefined, + defaultEditor?: QueryEditorMode +): PromQuery { + let result = query; + + if (!query.editorMode) { + result = { ...query, editorMode: getDefaultEditorMode(query.expr, defaultEditor) }; + } + + // default query expr is now empty string, set in getDefaultQuery + // While expr is required in the types, it is not always defined at runtime, so we need to check for undefined and default to an empty string to prevent runtime errors + if (!query.expr) { + result = { ...result, expr: '', legendFormat: LegendFormatMode.Auto }; + } + + if (query.range == null && query.instant == null) { + // Default to range query + result = { ...result, range: true }; + + // In explore we default to both instant & range + if (app === CoreApp.Explore) { + result.instant = true; + } + } + + // Unified Alerting does not support "both" for query type – fall back to "range". + const isBothInstantAndRange = query.instant && query.range; + if (app === CoreApp.UnifiedAlerting && isBothInstantAndRange) { + result = { ...result, instant: false, range: true }; + } + + return result; +} diff --git a/packages/grafana-prometheus/src/querybuilder/testUtils.ts b/packages/grafana-prometheus/src/querybuilder/testUtils.ts new file mode 100644 index 0000000000000..818d05756b473 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/testUtils.ts @@ -0,0 +1,27 @@ +import { getAllByRole, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +export function getLabelSelects(index = 0) { + const labels = screen.getByText(/Label filters/); + const selects = getAllByRole(labels.parentElement!.parentElement!.parentElement!, 'combobox'); + return { + name: selects[3 * index], + value: selects[3 * index + 2], + }; +} + +export async function addOperationInQueryBuilder(section: string, op: string) { + const addOperationButton = screen.getByTitle('Add operation'); + expect(addOperationButton).toBeInTheDocument(); + await userEvent.click(addOperationButton); + const sectionItem = await screen.findByTitle(section); + expect(sectionItem).toBeInTheDocument(); + // Weirdly the await userEvent.click doesn't work here, it reports the item has pointer-events: none. Don't see that + // anywhere when debugging so not sure what style is it picking up. + await userEvent.click(sectionItem.children[0], { pointerEventsCheck: 0 }); + const opItem = screen.getByTitle(op); + expect(opItem).toBeInTheDocument(); + // Weirdly the await userEvent.click doesn't work here, it reports the item has pointer-events: none. Don't see that + // anywhere when debugging so not sure what style is it picking up. + await userEvent.click(opItem, { pointerEventsCheck: 0 }); +} diff --git a/packages/grafana-prometheus/src/querybuilder/types.ts b/packages/grafana-prometheus/src/querybuilder/types.ts new file mode 100644 index 0000000000000..24750f21a96d6 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/types.ts @@ -0,0 +1,139 @@ +import { VisualQueryBinary } from './shared/LokiAndPromQueryModellerBase'; +import { QueryBuilderLabelFilter, QueryBuilderOperation } from './shared/types'; + +/** + * Visual query model + */ +export interface PromVisualQuery { + metric: string; + labels: QueryBuilderLabelFilter[]; + operations: QueryBuilderOperation[]; + binaryQueries?: PromVisualQueryBinary[]; + // metrics explorer additional settings + useBackend?: boolean; + disableTextWrap?: boolean; + includeNullMetadata?: boolean; + fullMetaSearch?: boolean; +} + +export type PromVisualQueryBinary = VisualQueryBinary<PromVisualQuery>; + +export enum PromVisualQueryOperationCategory { + Aggregations = 'Aggregations', + RangeFunctions = 'Range functions', + Functions = 'Functions', + BinaryOps = 'Binary operations', + Trigonometric = 'Trigonometric', + Time = 'Time Functions', +} + +export enum PromOperationId { + Abs = 'abs', + Absent = 'absent', + AbsentOverTime = 'absent_over_time', + Acos = 'acos', + Acosh = 'acosh', + Asin = 'asin', + Asinh = 'asinh', + Atan = 'atan', + Atanh = 'atanh', + Avg = 'avg', + AvgOverTime = 'avg_over_time', + BottomK = 'bottomk', + Ceil = 'ceil', + Changes = 'changes', + Clamp = 'clamp', + ClampMax = 'clamp_max', + ClampMin = 'clamp_min', + Cos = 'cos', + Cosh = 'cosh', + Count = 'count', + CountOverTime = 'count_over_time', + CountScalar = 'count_scalar', + CountValues = 'count_values', + DayOfMonth = 'day_of_month', + DayOfWeek = 'day_of_week', + DayOfYear = 'day_of_year', + DaysInMonth = 'days_in_month', + Deg = 'deg', + Delta = 'delta', + Deriv = 'deriv', + DropCommonLabels = 'drop_common_labels', + Exp = 'exp', + Floor = 'floor', + Group = 'group', + HistogramQuantile = 'histogram_quantile', + HoltWinters = 'holt_winters', + Hour = 'hour', + Idelta = 'idelta', + Increase = 'increase', + Irate = 'irate', + LabelJoin = 'label_join', + LabelReplace = 'label_replace', + Last = 'last', + LastOverTime = 'last_over_time', + Ln = 'ln', + Log10 = 'log10', + Log2 = 'log2', + Max = 'max', + MaxOverTime = 'max_over_time', + Min = 'min', + MinOverTime = 'min_over_time', + Minute = 'minute', + Month = 'month', + Pi = 'pi', + PredictLinear = 'predict_linear', + Present = 'present', + PresentOverTime = 'present_over_time', + Quantile = 'quantile', + QuantileOverTime = 'quantile_over_time', + Rad = 'rad', + Rate = 'rate', + Resets = 'resets', + Round = 'round', + Scalar = 'scalar', + Sgn = 'sgn', + Sin = 'sin', + Sinh = 'sinh', + Sort = 'sort', + SortDesc = 'sort_desc', + Sqrt = 'sqrt', + Stddev = 'stddev', + StddevOverTime = 'stddev_over_time', + Sum = 'sum', + SumOverTime = 'sum_over_time', + Tan = 'tan', + Tanh = 'tanh', + Time = 'time', + Timestamp = 'timestamp', + TopK = 'topk', + Vector = 'vector', + Year = 'year', + // Binary ops + Addition = '__addition', + Subtraction = '__subtraction', + MultiplyBy = '__multiply_by', + DivideBy = '__divide_by', + Modulo = '__modulo', + Exponent = '__exponent', + NestedQuery = '__nested_query', + EqualTo = '__equal_to', + NotEqualTo = '__not_equal_to', + GreaterThan = '__greater_than', + LessThan = '__less_than', + GreaterOrEqual = '__greater_or_equal', + LessOrEqual = '__less_or_equal', +} + +export enum PromQueryPatternType { + Rate = 'rate', + Histogram = 'histogram', + Binary = 'binary', +} + +export interface PromQueryPattern { + name: string; + operations: QueryBuilderOperation[]; + type: PromQueryPatternType; + binaryQueries?: PromVisualQueryBinary[]; +} diff --git a/packages/grafana-prometheus/src/querycache/QueryCache.test.ts b/packages/grafana-prometheus/src/querycache/QueryCache.test.ts new file mode 100644 index 0000000000000..d0f385a75165a --- /dev/null +++ b/packages/grafana-prometheus/src/querycache/QueryCache.test.ts @@ -0,0 +1,521 @@ +import moment from 'moment'; + +import { DataFrame, DataQueryRequest, DateTime, dateTime, TimeRange } from '@grafana/data'; + +import { QueryEditorMode } from '../querybuilder/shared/types'; +import { PromQuery } from '../types'; + +import { DatasourceProfileData, QueryCache } from './QueryCache'; +import { IncrementalStorageDataFrameScenarios } from './QueryCacheTestData'; + +// Will not interpolate vars! +const interpolateStringTest = (query: PromQuery) => { + return query.expr; +}; + +const getPrometheusTargetSignature = (request: DataQueryRequest<PromQuery>, targ: PromQuery) => { + return `${interpolateStringTest(targ)}|${targ.interval ?? request.interval}|${JSON.stringify( + request.rangeRaw ?? '' + )}|${targ.exemplar}`; +}; + +const mockPromRequest = (request?: Partial<DataQueryRequest<PromQuery>>): DataQueryRequest<PromQuery> => { + // Histogram + const defaultRequest: DataQueryRequest<PromQuery> = { + app: 'undefined', + requestId: '', + timezone: '', + range: { + from: moment('2023-01-30T19:33:01.332Z') as DateTime, + to: moment('2023-01-30T20:33:01.332Z') as DateTime, + raw: { from: 'now-1h', to: 'now' }, + }, + interval: '15s', + intervalMs: 15000, + targets: [ + { + datasource: { type: 'prometheus', uid: 'OPQv8Kc4z' }, + editorMode: QueryEditorMode.Code, + exemplar: false, + expr: 'sum by(le) (rate(cortex_request_duration_seconds_bucket{cluster="dev-us-central-0", job="cortex-dev-01/cortex-gw-internal", namespace="cortex-dev-01"}[$__rate_interval]))', + format: 'heatmap', + legendFormat: '{{le}}', + range: true, + refId: 'A', + utcOffsetSec: -21600, + }, + ], + maxDataPoints: 871, + scopedVars: { + __interval: { text: '15s', value: '15s' }, + __interval_ms: { text: '15000', value: 15000 }, + }, + startTime: 1675110781332, + rangeRaw: { from: 'now-1h', to: 'now' }, + }; + return { + ...defaultRequest, + ...request, + }; +}; + +const getPromProfileData = (request: DataQueryRequest, targ: PromQuery): DatasourceProfileData => { + return { + expr: targ.expr, + interval: targ.interval ?? request.interval, + datasource: 'prom', + }; +}; + +describe('QueryCache: Generic', function () { + it('instantiates', () => { + const storage = new QueryCache({ + getTargetSignature: () => '', + overlapString: '10m', + }); + expect(storage).toBeInstanceOf(QueryCache); + }); + + it('will not modify or crash with empty response', () => { + const storage = new QueryCache({ + getTargetSignature: () => '', + overlapString: '10m', + }); + const firstFrames: DataFrame[] = []; + const secondFrames: DataFrame[] = []; + + const cache = new Map<string, string>(); + + // start time of scenario + const firstFrom = dateTime(new Date(1675262550000)); + // End time of scenario + const firstTo = dateTime(new Date(1675262550000)).add(6, 'hours'); + + const firstRange: TimeRange = { + from: firstFrom, + to: firstTo, + raw: { + from: 'now-6h', + to: 'now', + }, + }; + + // Same query 2 minutes later + const numberOfSamplesLater = 4; + const interval = 30000; + + const secondFrom = dateTime(new Date(1675262550000 + interval * numberOfSamplesLater)); + const secondTo = dateTime(new Date(1675262550000 + interval * numberOfSamplesLater)).add(6, 'hours'); + + const secondRange: TimeRange = { + from: secondFrom, + to: secondTo, + raw: { + from: 'now-6h', + to: 'now', + }, + }; + + const targetSignature = `'1=1'|${interval}|${JSON.stringify(secondRange.raw)}`; + const dashboardId = `dashid`; + const panelId = 2; + const targetIdentity = `${dashboardId}|${panelId}|A`; + + cache.set(targetIdentity, targetSignature); + + const firstStoredFrames = storage.procFrames( + mockPromRequest({ + range: firstRange, + dashboardUID: dashboardId, + panelId: panelId, + }), + { + requests: [], // unused + targSigs: cache, + shouldCache: true, + }, + firstFrames + ); + + const cached = storage.cache.get(targetIdentity); + + expect(cached?.frames[0].fields[0].values.length).toEqual(firstFrames[0]?.fields[0]?.values?.length); + expect(firstStoredFrames[0]?.fields[0].values.length).toEqual(firstFrames[0]?.fields[0]?.values?.length); + + // Should return the request frames unaltered + expect(firstStoredFrames).toEqual(firstFrames); + + const secondRequest = mockPromRequest({ + range: secondRange, + dashboardUID: dashboardId, + panelId: panelId, + }); + + const secondStoredFrames = storage.procFrames( + secondRequest, + { + requests: [], // unused + targSigs: cache, + shouldCache: true, + }, + secondFrames + ); + + const storageLengthAfterSubsequentQuery = storage.cache.get(targetIdentity); + + expect(secondStoredFrames).toEqual([]); + + storageLengthAfterSubsequentQuery?.frames.forEach((dataFrame, index) => { + const secondFramesLength = secondFrames[index].fields[0].values.length; + const firstFramesLength = firstFrames[index].fields[0].values.length; + + const cacheLength = dataFrame.fields[0].values.length; + + // Cache can contain more, but never less + expect(cacheLength).toBeGreaterThanOrEqual(secondFramesLength + firstFramesLength - (20 + numberOfSamplesLater)); + + // Fewer results are sent in incremental result + expect(firstFramesLength).toBeGreaterThan(secondFramesLength); + }); + }); +}); + +describe('QueryCache: Prometheus', function () { + it('Merges incremental queries in storage', () => { + const scenarios = [ + IncrementalStorageDataFrameScenarios.histogram.getSeriesWithGapAtEnd(), + IncrementalStorageDataFrameScenarios.histogram.getSeriesWithGapInMiddle(), + IncrementalStorageDataFrameScenarios.histogram.getSeriesWithGapAtStart(), + ]; + scenarios.forEach((scenario, index) => { + const storage = new QueryCache<PromQuery>({ + getTargetSignature: getPrometheusTargetSignature, + overlapString: '10m', + profileFunction: getPromProfileData, + }); + const firstFrames = scenario.first.dataFrames as unknown as DataFrame[]; + const secondFrames = scenario.second.dataFrames as unknown as DataFrame[]; + + const targetSignatures = new Map<string, string>(); + + // start time of scenario + const firstFrom = dateTime(new Date(1675262550000)); + // End time of scenario + const firstTo = dateTime(new Date(1675262550000)).add(6, 'hours'); + + const firstRange: TimeRange = { + from: firstFrom, + to: firstTo, + raw: { + from: 'now-6h', + to: 'now', + }, + }; + + // Same query 2 minutes later + const numberOfSamplesLater = 4; + const interval = 30000; + + const secondFrom = dateTime(new Date(1675262550000 + interval * numberOfSamplesLater)); + const secondTo = dateTime(new Date(1675262550000 + interval * numberOfSamplesLater)).add(6, 'hours'); + + const secondRange: TimeRange = { + from: secondFrom, + to: secondTo, + raw: { + from: 'now-6h', + to: 'now', + }, + }; + + const dashboardId = `dashid--${index}`; + const panelId = 2 + index; + + // This can't change + const targetIdentity = `${dashboardId}|${panelId}|A`; + + const request = mockPromRequest({ + range: firstRange, + dashboardUID: dashboardId, + panelId: panelId, + }); + + // But the signature can, and we should clean up any non-matching signatures + const targetSignature = getPrometheusTargetSignature(request, request.targets[0]); + + targetSignatures.set(targetIdentity, targetSignature); + + const firstStoredFrames = storage.procFrames( + request, + { + requests: [], // unused + targSigs: targetSignatures, + shouldCache: true, + }, + firstFrames + ); + + const cached = storage.cache.get(targetIdentity); + + // I would expect that the number of values received from the API should be the same as the cached values? + expect(cached?.frames[0].fields[0].values.length).toEqual(firstFrames[0].fields[0].values.length); + + // Should return the request frames unaltered + expect(firstStoredFrames).toEqual(firstFrames); + + const secondRequest = mockPromRequest({ + range: secondRange, + dashboardUID: dashboardId, + panelId: panelId, + }); + + const secondStoredFrames = storage.procFrames( + secondRequest, + { + requests: [], // unused + targSigs: targetSignatures, + shouldCache: true, + }, + secondFrames + ); + + const storageLengthAfterSubsequentQuery = storage.cache.get(targetIdentity); + + storageLengthAfterSubsequentQuery?.frames.forEach((dataFrame, index) => { + const secondFramesLength = secondFrames[index].fields[0].values.length; + const firstFramesLength = firstFrames[index].fields[0].values.length; + + const cacheLength = dataFrame.fields[0].values.length; + + // Cache can contain more, but never less + expect(cacheLength).toBeGreaterThanOrEqual( + secondFramesLength + firstFramesLength - (20 + numberOfSamplesLater) + ); + + // Fewer results are sent in incremental result + expect(firstFramesLength).toBeGreaterThan(secondFramesLength); + }); + + // All of the new values should be the ones that were stored, this is overkill + secondFrames.forEach((frame, frameIdx) => { + frame.fields.forEach((field, fieldIdx) => { + secondFrames[frameIdx].fields[fieldIdx].values.forEach((value) => { + expect(secondStoredFrames[frameIdx].fields[fieldIdx].values).toContain(value); + }); + }); + }); + + const secondRequestModified = { + ...secondRequest, + range: { + ...secondRequest.range, + to: dateTime(secondRequest.range.to.valueOf() + 30000), + }, + }; + const cacheRequest = storage.requestInfo(secondRequestModified); + expect(cacheRequest.requests[0].targets).toEqual(secondRequestModified.targets); + expect(cacheRequest.requests[0].range.to).toEqual(secondRequestModified.range.to); + expect(cacheRequest.requests[0].range.raw).toEqual(secondRequestModified.range.raw); + expect(cacheRequest.requests[0].range.from.valueOf() - 21000000).toEqual( + secondRequestModified.range.from.valueOf() + ); + expect(cacheRequest.shouldCache).toBe(true); + }); + }); + + it('Will evict old dataframes, and use stored data when user shortens query window', () => { + const storage = new QueryCache<PromQuery>({ + getTargetSignature: getPrometheusTargetSignature, + overlapString: '10m', + profileFunction: getPromProfileData, + }); + + // Initial request with all data for time range + const firstFrames = IncrementalStorageDataFrameScenarios.histogram.evictionRequests.first + .dataFrames as unknown as DataFrame[]; + + // Shortened request 30s later + const secondFrames = IncrementalStorageDataFrameScenarios.histogram.evictionRequests.second + .dataFrames as unknown as DataFrame[]; + + // Now the user waits a minute and changes the query duration to just the last 5 minutes, luckily the interval hasn't changed, so we can still use the data in storage except for the latest minute + const thirdFrames = IncrementalStorageDataFrameScenarios.histogram.evictionRequests.second + .dataFrames as unknown as DataFrame[]; + + const cache = new Map<string, string>(); + const interval = 15000; + + // start time of scenario + const firstFrom = dateTime(new Date(1675107180000)); + const firstTo = dateTime(new Date(1675107180000)).add(1, 'hours'); + const firstRange: TimeRange = { + from: firstFrom, + to: firstTo, + raw: { + from: 'now-1h', + to: 'now', + }, + }; + + // 30 seconds later + const secondNumberOfSamplesLater = 2; + const secondFrom = dateTime(new Date(1675107180000 + interval * secondNumberOfSamplesLater)); + const secondTo = dateTime(new Date(1675107180000 + interval * secondNumberOfSamplesLater)).add(1, 'hours'); + const secondRange: TimeRange = { + from: secondFrom, + to: secondTo, + raw: { + from: 'now-1h', + to: 'now', + }, + }; + + // 1 minute + 30 seconds later, but 5 minute viewing window + const thirdNumberOfSamplesLater = 6; + const thirdFrom = dateTime(new Date(1675107180000 + interval * thirdNumberOfSamplesLater)); + const thirdTo = dateTime(new Date(1675107180000 + interval * thirdNumberOfSamplesLater)).add(5, 'minutes'); + const thirdRange: TimeRange = { + from: thirdFrom, + to: thirdTo, + raw: { + from: 'now-5m', + to: 'now', + }, + }; + + // Signifier definition + + const dashboardId = `dashid`; + const panelId = 200; + + const targetIdentity = `${dashboardId}|${panelId}|A`; + + const request = mockPromRequest({ + range: firstRange, + dashboardUID: dashboardId, + panelId: panelId, + }); + + const requestInfo = { + requests: [], // unused + targSigs: cache, + shouldCache: true, + }; + const targetSignature = `1=1|${interval}|${JSON.stringify(request.rangeRaw ?? '')}`; + cache.set(targetIdentity, targetSignature); + + const firstQueryResult = storage.procFrames(request, requestInfo, firstFrames); + + const firstMergedLength = firstQueryResult[0].fields[0].values.length; + + const secondQueryResult = storage.procFrames( + mockPromRequest({ + range: secondRange, + dashboardUID: dashboardId, + panelId: panelId, + }), + { + requests: [], // unused + targSigs: cache, + shouldCache: true, + }, + secondFrames + ); + + const secondMergedLength = secondQueryResult[0].fields[0].values.length; + + // Since the step is 15s, and the request was 30 seconds later, we should have 2 extra frames, but we should evict the first two, so we should get the same length + expect(firstMergedLength).toEqual(secondMergedLength); + expect(firstQueryResult[0].fields[0].values[2]).toEqual(secondQueryResult[0].fields[0].values[0]); + expect(firstQueryResult[0].fields[0].values[0] + 30000).toEqual(secondQueryResult[0].fields[0].values[0]); + + cache.set(targetIdentity, `'1=1'|${interval}|${JSON.stringify(thirdRange.raw)}`); + + storage.procFrames( + mockPromRequest({ + range: thirdRange, + dashboardUID: dashboardId, + panelId: panelId, + }), + { + requests: [], // unused + targSigs: cache, + shouldCache: true, + }, + thirdFrames + ); + + const cachedAfterThird = storage.cache.get(targetIdentity); + const storageLengthAfterThirdQuery = cachedAfterThird?.frames[0].fields[0].values.length; + expect(storageLengthAfterThirdQuery).toEqual(20); + }); + + it('Will build signature using target overrides', () => { + const targetInterval = '30s'; + const requestInterval = '15s'; + + const target: PromQuery = { + datasource: { type: 'prometheus', uid: 'OPQv8Kc4z' }, + editorMode: QueryEditorMode.Code, + exemplar: false, + expr: 'sum by(le) (rate(cortex_request_duration_seconds_bucket{cluster="dev-us-central-0", job="cortex-dev-01/cortex-gw-internal", namespace="cortex-dev-01"}[$__rate_interval]))', + format: 'heatmap', + interval: targetInterval, + legendFormat: '{{le}}', + range: true, + refId: 'A', + utcOffsetSec: -21600, + }; + + const request = mockPromRequest({ + interval: requestInterval, + targets: [target], + }); + const targSig = getPrometheusTargetSignature(request, target); + expect(targSig).toContain(targetInterval); + expect(targSig.includes(requestInterval)).toBeFalsy(); + }); + + it('will not modify request with absolute duration', () => { + const request = mockPromRequest({ + range: { + from: moment('2023-01-30T19:33:01.332Z') as DateTime, + to: moment('2023-01-30T20:33:01.332Z') as DateTime, + raw: { from: '2023-01-30T19:33:01.332Z', to: '2023-01-30T20:33:01.332Z' }, + }, + rangeRaw: { from: '2023-01-30T19:33:01.332Z', to: '2023-01-30T20:33:01.332Z' }, + }); + const storage = new QueryCache<PromQuery>({ + getTargetSignature: getPrometheusTargetSignature, + overlapString: '10m', + profileFunction: getPromProfileData, + }); + const cacheRequest = storage.requestInfo(request); + expect(cacheRequest.requests[0]).toBe(request); + expect(cacheRequest.shouldCache).toBe(false); + }); + + it('mark request as shouldCache', () => { + const request = mockPromRequest(); + const storage = new QueryCache<PromQuery>({ + getTargetSignature: getPrometheusTargetSignature, + overlapString: '10m', + profileFunction: getPromProfileData, + }); + const cacheRequest = storage.requestInfo(request); + expect(cacheRequest.requests[0]).toBe(request); + expect(cacheRequest.shouldCache).toBe(true); + }); + + it('Should modify request', () => { + const request = mockPromRequest(); + const storage = new QueryCache<PromQuery>({ + getTargetSignature: getPrometheusTargetSignature, + overlapString: '10m', + profileFunction: getPromProfileData, + }); + const cacheRequest = storage.requestInfo(request); + expect(cacheRequest.requests[0]).toBe(request); + expect(cacheRequest.shouldCache).toBe(true); + }); +}); diff --git a/packages/grafana-prometheus/src/querycache/QueryCache.ts b/packages/grafana-prometheus/src/querycache/QueryCache.ts new file mode 100644 index 0000000000000..0cf5f84c2f521 --- /dev/null +++ b/packages/grafana-prometheus/src/querycache/QueryCache.ts @@ -0,0 +1,441 @@ +import { + DataFrame, + DataQueryRequest, + dateTime, + durationToMilliseconds, + Field, + incrRoundDn, + isValidDuration, + parseDuration, +} from '@grafana/data'; +import { faro } from '@grafana/faro-web-sdk'; +import { config, reportInteraction } from '@grafana/runtime'; + +import { amendTable, Table, trimTable } from '../gcopypaste/app/features/live/data/amendTimeSeries'; +import { PromQuery } from '../types'; + +// dashboardUID + panelId + refId +// (must be stable across query changes, time range changes / interval changes / panel resizes / template variable changes) +type TargetIdent = string; + +type RequestID = string; + +// query + template variables + interval + raw time range +// used for full target cache busting -> full range re-query +type TargetSig = string; + +type TimestampMs = number; + +type SupportedQueryTypes = PromQuery; + +// string matching requirements defined in durationutil.ts +export const defaultPrometheusQueryOverlapWindow = '10m'; + +interface TargetCache { + sig: TargetSig; + prevTo: TimestampMs; + frames: DataFrame[]; +} + +export interface CacheRequestInfo<T extends SupportedQueryTypes> { + requests: Array<DataQueryRequest<T>>; + targSigs: Map<TargetIdent, TargetSig>; + shouldCache: boolean; +} + +export interface DatasourceProfileData { + interval?: string; + expr: string; + datasource: string; +} + +interface ProfileData extends DatasourceProfileData { + identity: string; + bytes: number | null; + dashboardUID: string; + panelId?: number; + from: string; + queryRangeSeconds: number; + refreshIntervalMs: number; +} + +/** + * Get field identity + * This is the string used to uniquely identify a field within a "target" + * @param field + */ +export const getFieldIdent = (field: Field) => `${field.type}|${field.name}|${JSON.stringify(field.labels ?? '')}`; + +/** + * NOMENCLATURE + * Target: The request target (DataQueryRequest), i.e. a specific query reference within a panel + * Ident: Identity: the string that is not expected to change + * Sig: Signature: the string that is expected to change, upon which we wipe the cache fields + */ +export class QueryCache<T extends SupportedQueryTypes> { + private overlapWindowMs: number; + private getTargetSignature: (request: DataQueryRequest<T>, target: T) => string; + private getProfileData?: (request: DataQueryRequest<T>, target: T) => DatasourceProfileData; + + private perfObeserver?: PerformanceObserver; + private shouldProfile: boolean; + + // send profile events every 10 minutes + sendEventsInterval = 60000 * 10; + + pendingRequestIdsToTargSigs = new Map<RequestID, ProfileData>(); + + pendingAccumulatedEvents = new Map< + string, + { + requestCount: number; + savedBytesTotal: number; + initialRequestSize: number; + lastRequestSize: number; + panelId: string; + dashId: string; + expr: string; + refreshIntervalMs: number; + sent: boolean; + datasource: string; + from: string; + queryRangeSeconds: number; + } + >(); + + cache = new Map<TargetIdent, TargetCache>(); + + constructor(options: { + getTargetSignature: (request: DataQueryRequest<T>, target: T) => string; + overlapString: string; + profileFunction?: (request: DataQueryRequest<T>, target: T) => DatasourceProfileData; + }) { + const unverifiedOverlap = options.overlapString; + if (isValidDuration(unverifiedOverlap)) { + const duration = parseDuration(unverifiedOverlap); + this.overlapWindowMs = durationToMilliseconds(duration); + } else { + const duration = parseDuration(defaultPrometheusQueryOverlapWindow); + this.overlapWindowMs = durationToMilliseconds(duration); + } + + if ( + (config.grafanaJavascriptAgent.enabled || config.featureToggles?.prometheusIncrementalQueryInstrumentation) && + options.profileFunction !== undefined + ) { + this.profile(); + this.shouldProfile = true; + } else { + this.shouldProfile = false; + } + this.getProfileData = options.profileFunction; + this.getTargetSignature = options.getTargetSignature; + } + + private profile() { + // Check if PerformanceObserver is supported, and if we have Faro enabled for internal profiling + if (typeof PerformanceObserver === 'function') { + this.perfObeserver = new PerformanceObserver((list: PerformanceObserverEntryList) => { + list.getEntries().forEach((entry) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const entryTypeCast: PerformanceResourceTiming = entry as PerformanceResourceTiming; + + // Safari support for this is coming in 16.4: + // https://caniuse.com/mdn-api_performanceresourcetiming_transfersize + // Gating that this exists to prevent runtime errors + const isSupported = typeof entryTypeCast?.transferSize === 'number'; + + if (entryTypeCast?.initiatorType === 'fetch' && isSupported) { + let fetchUrl = entryTypeCast.name; + + if (fetchUrl.includes('/api/ds/query')) { + let match = fetchUrl.match(/requestId=([a-z\d]+)/i); + + if (match) { + let requestId = match[1]; + + const requestTransferSize = Math.round(entryTypeCast.transferSize); + const currentRequest = this.pendingRequestIdsToTargSigs.get(requestId); + + if (currentRequest) { + const entries = this.pendingRequestIdsToTargSigs.entries(); + + for (let [, value] of entries) { + if (value.identity === currentRequest.identity && value.bytes !== null) { + const previous = this.pendingAccumulatedEvents.get(value.identity); + + const savedBytes = value.bytes - requestTransferSize; + + this.pendingAccumulatedEvents.set(value.identity, { + datasource: value.datasource ?? 'N/A', + requestCount: (previous?.requestCount ?? 0) + 1, + savedBytesTotal: (previous?.savedBytesTotal ?? 0) + savedBytes, + initialRequestSize: value.bytes, + lastRequestSize: requestTransferSize, + panelId: currentRequest.panelId?.toString() ?? '', + dashId: currentRequest.dashboardUID ?? '', + expr: currentRequest.expr ?? '', + refreshIntervalMs: currentRequest.refreshIntervalMs ?? 0, + sent: false, + from: currentRequest.from ?? '', + queryRangeSeconds: currentRequest.queryRangeSeconds ?? 0, + }); + + // We don't need to save each subsequent request, only the first one + this.pendingRequestIdsToTargSigs.delete(requestId); + + return; + } + } + + // If we didn't return above, this should be the first request, let's save the observed size + this.pendingRequestIdsToTargSigs.set(requestId, { ...currentRequest, bytes: requestTransferSize }); + } + } + } + } + }); + }); + + this.perfObeserver.observe({ type: 'resource', buffered: false }); + + setInterval(this.sendPendingTrackingEvents, this.sendEventsInterval); + + // Send any pending profile information when the user navigates away + window.addEventListener('beforeunload', this.sendPendingTrackingEvents); + } + } + + sendPendingTrackingEvents = () => { + const entries = this.pendingAccumulatedEvents.entries(); + + for (let [key, value] of entries) { + if (!value.sent) { + const event = { + datasource: value.datasource.toString(), + requestCount: value.requestCount.toString(), + savedBytesTotal: value.savedBytesTotal.toString(), + initialRequestSize: value.initialRequestSize.toString(), + lastRequestSize: value.lastRequestSize.toString(), + panelId: value.panelId.toString(), + dashId: value.dashId.toString(), + expr: value.expr.toString(), + refreshIntervalMs: value.refreshIntervalMs.toString(), + from: value.from.toString(), + queryRangeSeconds: value.queryRangeSeconds.toString(), + }; + + if (config.featureToggles.prometheusIncrementalQueryInstrumentation) { + reportInteraction('grafana_incremental_queries_profile', event); + } else if (faro.api.pushEvent) { + faro.api.pushEvent('incremental query response size', event, 'no-interaction', { + skipDedupe: true, + }); + } + + this.pendingAccumulatedEvents.set(key, { + ...value, + sent: true, + requestCount: 0, + savedBytesTotal: 0, + initialRequestSize: 0, + lastRequestSize: 0, + }); + } + } + }; + + // can be used to change full range request to partial, split into multiple requests + requestInfo(request: DataQueryRequest<T>): CacheRequestInfo<T> { + // TODO: align from/to to interval to increase probability of hitting backend cache + + const newFrom = request.range.from.valueOf(); + const newTo = request.range.to.valueOf(); + + // only cache 'now'-relative queries (that can benefit from a backfill cache) + const shouldCache = request.rangeRaw?.to?.toString() === 'now'; + + // all targets are queried together, so we check for any that causes group cache invalidation & full re-query + let doPartialQuery = shouldCache; + let prevTo: TimestampMs | undefined = undefined; + + const refreshIntervalMs = request.intervalMs; + + // pre-compute reqTargSigs + const reqTargSigs = new Map<TargetIdent, TargetSig>(); + request.targets.forEach((targ) => { + let targIdent = `${request.dashboardUID}|${request.panelId}|${targ.refId}`; + let targSig = this.getTargetSignature(request, targ); // ${request.maxDataPoints} ? + + if (this.shouldProfile && this.getProfileData) { + this.pendingRequestIdsToTargSigs.set(request.requestId, { + ...this.getProfileData(request, targ), + identity: targIdent + '|' + targSig, + bytes: null, + panelId: request.panelId, + dashboardUID: request.dashboardUID ?? '', + from: request.rangeRaw?.from.toString() ?? '', + queryRangeSeconds: request.range.to.diff(request.range.from, 'seconds') ?? '', + refreshIntervalMs: refreshIntervalMs ?? 0, + }); + } + + reqTargSigs.set(targIdent, targSig); + }); + + // figure out if new query range or new target props trigger full cache invalidation & re-query + for (const [targIdent, targSig] of reqTargSigs) { + let cached = this.cache.get(targIdent); + let cachedSig = cached?.sig; + + if (cachedSig !== targSig) { + doPartialQuery = false; + } else { + // only do partial queries when new request range follows prior request range (possibly with overlap) + // e.g. now-6h with refresh <= 6h + prevTo = cached?.prevTo ?? Infinity; + + doPartialQuery = newTo > prevTo && newFrom <= prevTo; + } + + if (!doPartialQuery) { + break; + } + } + + if (doPartialQuery && prevTo) { + // clamp to make sure we don't re-query previous 10m when newFrom is ahead of it (e.g. 5min range, 30s refresh) + let newFromPartial = Math.max(prevTo - this.overlapWindowMs, newFrom); + + const newToDate = dateTime(newTo); + const newFromPartialDate = dateTime(incrRoundDn(newFromPartial, request.intervalMs)); + + // modify to partial query + request = { + ...request, + range: { + ...request.range, + from: newFromPartialDate, + to: newToDate, + }, + }; + } else { + reqTargSigs.forEach((targSig, targIdent) => { + this.cache.delete(targIdent); + }); + } + + return { + requests: [request], + targSigs: reqTargSigs, + shouldCache, + }; + } + + // should amend existing cache with new frames and return full response + procFrames( + request: DataQueryRequest<T>, + requestInfo: CacheRequestInfo<T> | undefined, + respFrames: DataFrame[] + ): DataFrame[] { + if (requestInfo?.shouldCache) { + const newFrom = request.range.from.valueOf(); + const newTo = request.range.to.valueOf(); + + // group frames by targets + const respByTarget = new Map<TargetIdent, DataFrame[]>(); + + respFrames.forEach((frame: DataFrame) => { + let targIdent = `${request.dashboardUID}|${request.panelId}|${frame.refId}`; + + let frames = respByTarget.get(targIdent); + + if (!frames) { + frames = []; + respByTarget.set(targIdent, frames); + } + + frames.push(frame); + }); + + let outFrames: DataFrame[] = []; + + respByTarget.forEach((respFrames, targIdent) => { + let cachedFrames = (targIdent ? this.cache.get(targIdent)?.frames : null) ?? []; + + respFrames.forEach((respFrame: DataFrame) => { + // skip empty frames + if (respFrame.length === 0 || respFrame.fields.length === 0) { + return; + } + + // frames are identified by their second (non-time) field's name + labels + // TODO: maybe also frame.meta.type? + let respFrameIdent = getFieldIdent(respFrame.fields[1]); + + let cachedFrame = cachedFrames.find((cached) => getFieldIdent(cached.fields[1]) === respFrameIdent); + + if (!cachedFrame) { + // append new unknown frames + cachedFrames.push(respFrame); + } else { + // we assume that fields cannot appear/disappear and will all exist in same order + + // amend & re-cache + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + let prevTable: Table = cachedFrame.fields.map((field) => field.values) as Table; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + let nextTable: Table = respFrame.fields.map((field) => field.values) as Table; + + let amendedTable = amendTable(prevTable, nextTable); + if (amendedTable) { + for (let i = 0; i < amendedTable.length; i++) { + cachedFrame.fields[i].values = amendedTable[i]; + } + cachedFrame.length = cachedFrame.fields[0].values.length; + } + } + }); + + // trim all frames to in-view range, evict those that end up with 0 length + let nonEmptyCachedFrames: DataFrame[] = []; + + cachedFrames.forEach((frame) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + let table: Table = frame.fields.map((field) => field.values) as Table; + + let trimmed = trimTable(table, newFrom, newTo); + + if (trimmed[0].length > 0) { + for (let i = 0; i < trimmed.length; i++) { + frame.fields[i].values = trimmed[i]; + } + nonEmptyCachedFrames.push(frame); + } + }); + + this.cache.set(targIdent, { + sig: requestInfo.targSigs.get(targIdent)!, + frames: nonEmptyCachedFrames, + prevTo: newTo, + }); + + outFrames.push(...nonEmptyCachedFrames); + }); + + // transformV2 mutates field values for heatmap de-accum, and modifies field order, so we gotta clone here, for now :( + respFrames = outFrames.map((frame) => ({ + ...frame, + fields: frame.fields.map((field) => ({ + ...field, + config: { + ...field.config, // prevents mutatative exemplars links (re)enrichment + }, + values: field.values.slice(), + })), + })); + } + + return respFrames; + } +} diff --git a/packages/grafana-prometheus/src/querycache/QueryCacheTestData.ts b/packages/grafana-prometheus/src/querycache/QueryCacheTestData.ts new file mode 100644 index 0000000000000..ec4d5154bf248 --- /dev/null +++ b/packages/grafana-prometheus/src/querycache/QueryCacheTestData.ts @@ -0,0 +1,862 @@ +import { clone } from 'lodash'; + +/** + * + * @param length - Number of values to add + * @param start - First timestamp (ms) + * @param step - step duration (ms) + */ +export const getMockTimeFrameArray = (length: number, start: number, step: number) => { + let timeValues: number[] = []; + for (let i = 0; i < length; i++) { + timeValues.push(start + i * step); + } + + return timeValues; +}; + +/** + * @param length - number of "Values" to add + * @param values + * @param high + */ +export const getMockValueFrameArray = (length: number, values = 0): number[] => { + return Array(length).fill(values); +}; + +const timeFrameWithMissingValuesInMiddle = getMockTimeFrameArray(721, 1675262550000, 30000); +const timeFrameWithMissingValuesAtStart = getMockTimeFrameArray(721, 1675262550000, 30000); +const timeFrameWithMissingValuesAtEnd = getMockTimeFrameArray(721, 1675262550000, 30000); + +// Deleting some out the middle +timeFrameWithMissingValuesInMiddle.splice(360, 721 - 684); +timeFrameWithMissingValuesAtStart.splice(0, 721 - 684); +timeFrameWithMissingValuesAtEnd.splice(721 - 684, 721 - 684); + +const mockLabels = { + __name__: 'cortex_request_duration_seconds_bucket', + cluster: 'dev-us-central-0', + container: 'aggregator', + instance: 'aggregator-7:aggregator:http-metrics', + job: 'mimir-dev-11/aggregator', + le: '0.5', + method: 'GET', + namespace: 'mimir-dev-11', + pod: 'aggregator-7', + route: 'metrics', + status_code: '200', + ws: 'false', +}; + +const twoRequestsOneCachedMissingData = { + first: { + request: { + app: 'panel-viewer', + requestId: 'Q100', + panelId: 19, + dashboardId: 884, + dashboardUID: 'dtngicc4z', + range: { + from: '2023-02-01T14:42:54.929Z', + to: '2023-02-01T20:42:54.929Z', + raw: { from: 'now-6h', to: 'now' }, + }, + interval: '30s', + intervalMs: 30000, + targets: [ + { + datasource: { type: 'prometheus', uid: 'OPQv8Kc4z' }, + editorMode: 'code', + expr: '', + legendFormat: '', + range: true, + refId: 'A', + exemplar: false, + requestId: '19A', + utcOffsetSec: -21600, + }, + ], + startTime: 1675284174929, + rangeRaw: { from: 'now-6h', to: 'now' }, + }, + dataFrames: [ + { + name: '+Inf', + refId: 'A', + fields: [ + { + name: 'Time', + type: 'time', + typeInfo: { frame: 'time.Time' }, + config: { interval: 30000 }, + // Delete values from the middle + values: timeFrameWithMissingValuesInMiddle, + entities: {}, + }, + { + name: 'Value', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { ...mockLabels, le: '+Inf' }, + config: { displayNameFromDS: '+Inf' }, + values: getMockValueFrameArray(684, 1), + entities: {}, + }, + ], + length: 684, + }, + { + name: '0.5', + refId: 'A', + meta: { + type: 'timeseries-multi', + custom: { resultType: 'matrix' }, + executedQueryString: + 'Expr: {__name__="cortex_request_duration_seconds_bucket", cluster="dev-us-central-0", container="aggregator", instance=~"aggregator-7:aggregator:http-metrics|aggregator-6:aggregator:http-metrics", job="mimir-dev-11/aggregator", le=~"\\\\+Inf|0.5", method="GET", namespace="mimir-dev-11", pod="aggregator-7"}\nStep: 30s', + preferredVisualisationType: 'graph', + }, + fields: [ + { + name: 'Time', + type: 'time', + typeInfo: { frame: 'time.Time' }, + config: { interval: 30000 }, + values: timeFrameWithMissingValuesInMiddle, + entities: {}, + }, + { + name: 'Value', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { ...mockLabels, le: '0.5' }, + config: { displayNameFromDS: '0.5' }, + values: getMockValueFrameArray(684, 25349), + entities: {}, + }, + ], + length: 684, + }, + ], + originalRange: undefined, + timeSrv: { from: 'now-6h', to: 'now' }, + }, + second: { + request: { + app: 'panel-viewer', + requestId: 'Q101', + timezone: 'browser', + panelId: 19, + dashboardId: 884, + dashboardUID: 'dtngicc4z', + publicDashboardAccessToken: '', + range: { + from: '2023-02-01T14:44:01.928Z', + to: '2023-02-01T20:44:01.928Z', + raw: { from: 'now-6h', to: 'now' }, + }, + timeInfo: '', + interval: '30s', + intervalMs: 30000, + targets: [ + { + datasource: { type: 'prometheus', uid: 'OPQv8Kc4z' }, + editorMode: 'code', + expr: '{__name__="cortex_request_duration_seconds_bucket", cluster="dev-us-central-0", container="aggregator", instance=~"aggregator-7:aggregator:http-metrics|aggregator-6:aggregator:http-metrics", job="mimir-dev-11/aggregator", le=~"\\\\+Inf|0.5", method="GET", namespace="mimir-dev-11", pod="aggregator-7"}', + legendFormat: '{{le}}', + range: true, + refId: 'A', + exemplar: false, + requestId: '19A', + utcOffsetSec: -21600, + }, + ], + maxDataPoints: 775, + scopedVars: { __interval: { text: '30s', value: '30s' }, __interval_ms: { text: '30000', value: 30000 } }, + startTime: 1675284241929, + rangeRaw: { from: 'now-6h', to: 'now' }, + }, + dataFrames: [ + { + name: '+Inf', + refId: 'A', + meta: { + type: 'timeseries-multi', + custom: { resultType: 'matrix' }, + executedQueryString: + 'Expr: {__name__="cortex_request_duration_seconds_bucket", cluster="dev-us-central-0", container="aggregator", instance=~"aggregator-7:aggregator:http-metrics|aggregator-6:aggregator:http-metrics", job="mimir-dev-11/aggregator", le=~"\\\\+Inf|0.5", method="GET", namespace="mimir-dev-11", pod="aggregator-7"}\nStep: 30s', + preferredVisualisationType: 'graph', + }, + fields: [ + { + name: 'Time', + type: 'time', + typeInfo: { frame: 'time.Time' }, + config: { interval: 30000 }, + values: getMockTimeFrameArray(24, 1675283550000, 30000), + entities: {}, + }, + { + name: 'Value', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { ...mockLabels, le: '+Inf' }, + config: { displayNameFromDS: '+Inf' }, + values: getMockValueFrameArray(24, 1), + entities: {}, + }, + ], + length: 24, + }, + { + name: '0.5', + refId: 'A', + meta: { + type: 'timeseries-multi', + custom: { resultType: 'matrix' }, + executedQueryString: + 'Expr: {__name__="cortex_request_duration_seconds_bucket", cluster="dev-us-central-0", container="aggregator", instance=~"aggregator-7:aggregator:http-metrics|aggregator-6:aggregator:http-metrics", job="mimir-dev-11/aggregator", le=~"\\\\+Inf|0.5", method="GET", namespace="mimir-dev-11", pod="aggregator-7"}\nStep: 30s', + preferredVisualisationType: 'graph', + }, + fields: [ + { + name: 'Time', + type: 'time', + typeInfo: { frame: 'time.Time' }, + config: { interval: 30000 }, + values: getMockTimeFrameArray(21, 1675283550000, 30000), + entities: {}, + }, + { + name: 'Value', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { + __name__: 'cortex_request_duration_seconds_bucket', + cluster: 'dev-us-central-0', + container: 'aggregator', + instance: 'aggregator-7:aggregator:http-metrics', + job: 'mimir-dev-11/aggregator', + le: '0.5', + method: 'GET', + namespace: 'mimir-dev-11', + pod: 'aggregator-7', + route: 'metrics', + status_code: '200', + ws: 'false', + }, + config: { displayNameFromDS: '0.5' }, + values: getMockValueFrameArray(21, 2), + entities: {}, + }, + ], + length: 21, + }, + ], + originalRange: { end: 1675284241920, start: 1675262641920 }, + timeSrv: { from: 'now-6h', to: 'now' }, + }, +}; + +export const IncrementalStorageDataFrameScenarios = { + histogram: { + // 3 requests, one 30 seconds after the first, and then the user waits a minute and shortens to a 5 minute query window from 1 hour to force frames to get evicted + evictionRequests: { + first: { + request: { + range: { + from: '2023-01-30T19:33:01.332Z', + to: '2023-01-30T20:33:01.332Z', + raw: { from: 'now-1h', to: 'now' }, + }, + interval: '15s', + intervalMs: 15000, + targets: [ + { + datasource: { type: 'prometheus', uid: 'OPQv8Kc4z' }, + editorMode: 'code', + exemplar: false, + expr: 'sum by(le) (rate(cortex_request_duration_seconds_bucket{cluster="dev-us-central-0", job="cortex-dev-01/cortex-gw-internal", namespace="cortex-dev-01"}[$__rate_interval]))', + format: 'heatmap', + legendFormat: '{{le}}', + range: true, + refId: 'A', + requestId: '2A', + utcOffsetSec: -21600, + }, + ], + maxDataPoints: 871, + scopedVars: { + __interval: { text: '15s', value: '15s' }, + __interval_ms: { text: '15000', value: 15000 }, + }, + startTime: 1675110781332, + rangeRaw: { from: 'now-1h', to: 'now' }, + }, + dataFrames: [ + { + name: '0.005', + refId: 'A', + meta: { + type: 'heatmap-rows', + custom: { resultType: 'matrix' }, + executedQueryString: + 'Expr: sum by(le) (rate(cortex_request_duration_seconds_bucket{cluster="dev-us-central-0", job="cortex-dev-01/cortex-gw-internal", namespace="cortex-dev-01"}[1m0s]))\nStep: 15s', + }, + fields: [ + { + name: 'Time', + type: 'time', + typeInfo: { frame: 'time.Time' }, + config: { interval: 15000 }, + values: getMockTimeFrameArray(241, 1675107180000, 15000), + entities: {}, + }, + { + name: '0.005', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.005' }, + config: { displayNameFromDS: '0.005' }, + values: getMockValueFrameArray(241, 2.8), + entities: {}, + }, + { + name: '0.01', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.01' }, + config: { displayNameFromDS: '0.01' }, + values: getMockValueFrameArray(241, 2.8), + entities: {}, + }, + { + name: '0.025', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.025' }, + config: { displayNameFromDS: '0.025' }, + values: getMockValueFrameArray(241, 2.8), + entities: {}, + }, + { + name: '0.05', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.05' }, + config: { displayNameFromDS: '0.05' }, + values: getMockValueFrameArray(241, 2.8), + entities: {}, + }, + { + name: '0.1', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.1' }, + config: { displayNameFromDS: '0.1' }, + values: getMockValueFrameArray(241, 2.8), + entities: {}, + }, + { + name: '0.25', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.25' }, + config: { displayNameFromDS: '0.25' }, + values: getMockValueFrameArray(241, 2.8), + entities: {}, + }, + { + name: '0.5', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.5' }, + config: { displayNameFromDS: '0.5' }, + values: getMockValueFrameArray(241, 2.8), + entities: {}, + }, + { + name: '1.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '1.0' }, + config: { displayNameFromDS: '1.0' }, + values: getMockValueFrameArray(241, 2.8), + entities: {}, + }, + { + name: '2.5', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '2.5' }, + config: { displayNameFromDS: '2.5' }, + values: getMockValueFrameArray(241, 2.8), + entities: {}, + }, + { + name: '5.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '5.0' }, + config: { displayNameFromDS: '5.0' }, + values: getMockValueFrameArray(241, 2.8), + entities: {}, + }, + { + name: '10.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '10.0' }, + config: { displayNameFromDS: '10.0' }, + values: getMockValueFrameArray(241, 2.8), + entities: {}, + }, + { + name: '25.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '25.0' }, + config: { displayNameFromDS: '25.0' }, + values: getMockValueFrameArray(241, 2.8), + entities: {}, + }, + { + name: '50.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '50.0' }, + config: { displayNameFromDS: '50.0' }, + values: getMockValueFrameArray(241, 2.8), + entities: {}, + }, + { + name: '100.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '100.0' }, + config: { displayNameFromDS: '100.0' }, + values: getMockValueFrameArray(241, 2.8), + entities: {}, + }, + { + name: '+Inf', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '+Inf' }, + config: { displayNameFromDS: '+Inf' }, + values: getMockValueFrameArray(241, 2.8), + entities: {}, + }, + ], + length: 241, + }, + ], + }, + second: { + request: { + range: { + from: '2023-01-30T19:33:31.357Z', + to: '2023-01-30T20:33:31.357Z', + raw: { from: 'now-1h', to: 'now' }, + }, + interval: '15s', + intervalMs: 15000, + targets: [ + { + datasource: { type: 'prometheus' }, + editorMode: 'code', + exemplar: false, + expr: 'sum by(le) (rate(cortex_request_duration_seconds_bucket{cluster="dev-us-central-0", job="cortex-dev-01/cortex-gw-internal", namespace="cortex-dev-01"}[$__rate_interval]))', + format: 'heatmap', + legendFormat: '{{le}}', + range: true, + refId: 'A', + requestId: '2A', + utcOffsetSec: -21600, + }, + ], + maxDataPoints: 871, + scopedVars: { + __interval: { text: '15s', value: '15s' }, + __interval_ms: { text: '15000', value: 15000 }, + }, + startTime: 1675110811357, + rangeRaw: { from: 'now-1h', to: 'now' }, + }, + dataFrames: [ + { + name: '0.005', + refId: 'A', + meta: { + type: 'heatmap-rows', + custom: { resultType: 'matrix' }, + executedQueryString: + 'Expr: sum by(le) (rate(cortex_request_duration_seconds_bucket{cluster="dev-us-central-0", job="cortex-dev-01/cortex-gw-internal", namespace="cortex-dev-01"}[1m0s]))\nStep: 15s', + }, + fields: [ + { + name: 'Time', + type: 'time', + typeInfo: { frame: 'time.Time' }, + config: { interval: 15000 }, + values: getMockTimeFrameArray(43, 1675110180000, 15000), + entities: {}, + }, + { + name: '0.005', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.005' }, + config: { displayNameFromDS: '0.005' }, + values: getMockValueFrameArray(43, 2.8), + entities: {}, + }, + { + name: '0.01', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.01' }, + config: { displayNameFromDS: '0.01' }, + values: getMockValueFrameArray(43, 2.8), + entities: {}, + }, + { + name: '0.025', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.025' }, + config: { displayNameFromDS: '0.025' }, + values: getMockValueFrameArray(43, 2.8), + entities: {}, + }, + { + name: '0.05', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.05' }, + config: { displayNameFromDS: '0.05' }, + values: getMockValueFrameArray(43, 2.8), + entities: {}, + }, + { + name: '0.1', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.1' }, + config: { displayNameFromDS: '0.1' }, + values: getMockValueFrameArray(43, 2.8), + entities: {}, + }, + { + name: '0.25', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.25' }, + config: { displayNameFromDS: '0.25' }, + values: getMockValueFrameArray(43, 2.8), + entities: {}, + }, + { + name: '0.5', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.5' }, + config: { displayNameFromDS: '0.5' }, + values: getMockValueFrameArray(43, 2.8), + entities: {}, + }, + { + name: '1.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '1.0' }, + config: { displayNameFromDS: '1.0' }, + values: getMockValueFrameArray(43, 2.8), + entities: {}, + }, + { + name: '2.5', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '2.5' }, + config: { displayNameFromDS: '2.5' }, + values: getMockValueFrameArray(43, 2.8), + entities: {}, + }, + { + name: '5.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '5.0' }, + config: { displayNameFromDS: '5.0' }, + values: getMockValueFrameArray(43, 2.8), + entities: {}, + }, + { + name: '10.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '10.0' }, + config: { displayNameFromDS: '10.0' }, + values: getMockValueFrameArray(43, 2.8), + entities: {}, + }, + { + name: '25.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '25.0' }, + config: { displayNameFromDS: '25.0' }, + values: getMockValueFrameArray(43, 2.8), + entities: {}, + }, + { + name: '50.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '50.0' }, + config: { displayNameFromDS: '50.0' }, + values: getMockValueFrameArray(43, 2.8), + entities: {}, + }, + { + name: '100.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '100.0' }, + config: { displayNameFromDS: '100.0' }, + values: getMockValueFrameArray(43, 2.8), + entities: {}, + }, + { + name: '+Inf', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '+Inf' }, + config: { displayNameFromDS: '+Inf' }, + values: getMockValueFrameArray(43, 2.8), + entities: {}, + }, + ], + length: 43, + }, + ], + }, + third: { + request: { + range: { + from: '2023-01-30T20:33:31.357Z', + to: '2023-01-30T20:34:31.357Z', + raw: { from: 'now-5m', to: 'now' }, + }, + interval: '15s', + intervalMs: 15000, + targets: [ + { + datasource: { type: 'prometheus' }, + editorMode: 'code', + exemplar: false, + expr: 'sum by(le) (rate(cortex_request_duration_seconds_bucket{cluster="dev-us-central-0", job="cortex-dev-01/cortex-gw-internal", namespace="cortex-dev-01"}[$__rate_interval]))', + format: 'heatmap', + legendFormat: '{{le}}', + range: true, + refId: 'A', + requestId: '2A', + utcOffsetSec: -21600, + }, + ], + maxDataPoints: 871, + scopedVars: { + __interval: { text: '15s', value: '15s' }, + __interval_ms: { text: '15000', value: 15000 }, + }, + startTime: 1675110811357, + rangeRaw: { from: 'now-1h', to: 'now' }, + }, + dataFrames: [ + { + name: '0.005', + refId: 'A', + meta: { + type: 'heatmap-rows', + custom: { resultType: 'matrix' }, + executedQueryString: + 'Expr: sum by(le) (rate(cortex_request_duration_seconds_bucket{cluster="dev-us-central-0", job="cortex-dev-01/cortex-gw-internal", namespace="cortex-dev-01"}[1m0s]))\nStep: 15s', + }, + fields: [ + { + name: 'Time', + type: 'time', + typeInfo: { frame: 'time.Time' }, + config: { interval: 15000 }, + values: getMockTimeFrameArray(20, 1675110810000, 15000), + entities: {}, + }, + { + name: '0.005', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.005' }, + config: { displayNameFromDS: '0.005' }, + values: getMockValueFrameArray(20, 4.3), + entities: {}, + }, + { + name: '0.01', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.01' }, + config: { displayNameFromDS: '0.01' }, + values: getMockValueFrameArray(20, 4.3), + entities: {}, + }, + { + name: '0.025', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.025' }, + config: { displayNameFromDS: '0.025' }, + values: getMockValueFrameArray(20, 4.3), + entities: {}, + }, + { + name: '0.05', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.05' }, + config: { displayNameFromDS: '0.05' }, + values: getMockValueFrameArray(20, 4.3), + entities: {}, + }, + { + name: '0.1', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.1' }, + config: { displayNameFromDS: '0.1' }, + values: getMockValueFrameArray(20, 4.3), + entities: {}, + }, + { + name: '0.25', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.25' }, + config: { displayNameFromDS: '0.25' }, + values: getMockValueFrameArray(20, 4.3), + entities: {}, + }, + + // Sometimes we don't always get new values, the preprocessing will need to back-fill any missing values + { + name: '0.5', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '0.5' }, + config: { displayNameFromDS: '0.5' }, + values: getMockValueFrameArray(10, 4.3), + entities: {}, + }, + { + name: '1.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '1.0' }, + config: { displayNameFromDS: '1.0' }, + values: getMockValueFrameArray(20, 4.3), + entities: {}, + }, + { + name: '2.5', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '2.5' }, + config: { displayNameFromDS: '2.5' }, + values: getMockValueFrameArray(20, 4.3), + entities: {}, + }, + { + name: '5.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '5.0' }, + config: { displayNameFromDS: '5.0' }, + values: getMockValueFrameArray(20, 4.3), + entities: {}, + }, + { + name: '10.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '10.0' }, + config: { displayNameFromDS: '10.0' }, + values: getMockValueFrameArray(20, 4.3), + entities: {}, + }, + { + name: '25.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '25.0' }, + config: { displayNameFromDS: '25.0' }, + values: getMockValueFrameArray(10, 4.3), + entities: {}, + }, + { + name: '50.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '50.0' }, + config: { displayNameFromDS: '50.0' }, + values: getMockValueFrameArray(20, 4.3), + entities: {}, + }, + { + name: '100.0', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '100.0' }, + config: { displayNameFromDS: '100.0' }, + values: getMockValueFrameArray(20, 4.3), + entities: {}, + }, + { + name: '+Inf', + type: 'number', + typeInfo: { frame: 'float64' }, + labels: { le: '+Inf' }, + config: { displayNameFromDS: '+Inf' }, + values: getMockValueFrameArray(20, 4.3), + entities: {}, + }, + ], + length: 43, + }, + ], + }, + }, + + getSeriesWithGapAtEnd: (countOfSeries = 2) => { + const templateClone = clone(twoRequestsOneCachedMissingData); + for (let i = 0; i < countOfSeries - 1; i++) { + templateClone.first.dataFrames[i].fields[0].values = timeFrameWithMissingValuesAtEnd; + } + return templateClone; + }, + + getSeriesWithGapAtStart: (countOfSeries = 2) => { + const templateClone = clone(twoRequestsOneCachedMissingData); + for (let i = 0; i < countOfSeries - 1; i++) { + templateClone.first.dataFrames[i].fields[0].values = timeFrameWithMissingValuesAtStart; + } + return templateClone; + }, + + getSeriesWithGapInMiddle: (countOfSeries = 2) => { + const templateClone = clone(twoRequestsOneCachedMissingData); + for (let i = 0; i < countOfSeries - 1; i++) { + templateClone.first.dataFrames[i].fields[0].values = timeFrameWithMissingValuesInMiddle; + } + return templateClone; + }, + }, +}; diff --git a/packages/grafana-prometheus/src/result_transformer.test.ts b/packages/grafana-prometheus/src/result_transformer.test.ts new file mode 100644 index 0000000000000..bc2f251767f2c --- /dev/null +++ b/packages/grafana-prometheus/src/result_transformer.test.ts @@ -0,0 +1,1164 @@ +import { + cacheFieldDisplayNames, + createDataFrame, + FieldType, + type DataQueryRequest, + type DataQueryResponse, + type PreferredVisualisationType, +} from '@grafana/data'; + +import { + parseSampleValue, + sortSeriesByLabel, + transformDFToTable, + transformToHistogramOverTime, + transformV2, +} from './result_transformer'; +import { PromQuery } from './types'; + +jest.mock('@grafana/runtime', () => ({ + getTemplateSrv: () => ({ + replace: (str: string) => str, + }), + getDataSourceSrv: () => { + return { + getInstanceSettings: (uid: string) => { + const uids = ['Tempo', 'jaeger']; + return uids.find((u) => u === uid) ? { name: uid } : undefined; + }, + }; + }, + config: { + featureToggles: { + prometheusDataplane: true, + }, + }, +})); + +describe('Prometheus Result Transformer', () => { + describe('parse variants of "+Inf" and "-Inf" strings', () => { + it('+Inf', () => { + expect(parseSampleValue('+Inf')).toEqual(Number.POSITIVE_INFINITY); + }); + it('Inf', () => { + expect(parseSampleValue('Inf')).toEqual(Number.POSITIVE_INFINITY); + }); + it('inf', () => { + expect(parseSampleValue('inf')).toEqual(Number.POSITIVE_INFINITY); + }); + it('+Infinity', () => { + expect(parseSampleValue('+Infinity')).toEqual(Number.POSITIVE_INFINITY); + }); + it('+infinity', () => { + expect(parseSampleValue('+infinity')).toEqual(Number.POSITIVE_INFINITY); + }); + it('infinity', () => { + expect(parseSampleValue('infinity')).toEqual(Number.POSITIVE_INFINITY); + }); + + it('-Inf', () => { + expect(parseSampleValue('-Inf')).toEqual(Number.NEGATIVE_INFINITY); + }); + + it('-inf', () => { + expect(parseSampleValue('-inf')).toEqual(Number.NEGATIVE_INFINITY); + }); + + it('-Infinity', () => { + expect(parseSampleValue('-Infinity')).toEqual(Number.NEGATIVE_INFINITY); + }); + + it('-infinity', () => { + expect(parseSampleValue('-infinity')).toEqual(Number.NEGATIVE_INFINITY); + }); + }); + + describe('sortSeriesByLabel() should use frame.fields[1].state?.displayName when available', () => { + let frames = [ + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2, 3] }, + { + type: FieldType.number, + values: [4, 5, 6], + config: { + displayNameFromDS: '2', + }, + labels: { + offset_days: '2', + }, + }, + ], + }), + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2, 3] }, + { + type: FieldType.number, + values: [7, 8, 9], + config: { + displayNameFromDS: '1', + }, + labels: { + offset_days: '1', + }, + }, + ], + }), + ]; + + it('sorts by displayNameFromDS', () => { + cacheFieldDisplayNames(frames); + + let sorted = frames.slice().sort(sortSeriesByLabel); + + expect(sorted[0]).toEqual(frames[1]); + expect(sorted[1]).toEqual(frames[0]); + }); + }); + + describe('transformV2', () => { + it('results with time_series format should be enriched with preferredVisualisationType', () => { + const request = { + targets: [ + { + format: 'time_series', + refId: 'A', + }, + ], + } as unknown as DataQueryRequest<PromQuery>; + const response = { + state: 'Done', + data: [ + { + fields: [], + length: 2, + name: 'ALERTS', + refId: 'A', + }, + ], + } as unknown as DataQueryResponse; + const series = transformV2(response, request, {}); + expect(series).toEqual({ + data: [{ fields: [], length: 2, meta: { preferredVisualisationType: 'graph' }, name: 'ALERTS', refId: 'A' }], + state: 'Done', + }); + }); + + it('dataplane handling, adds displayNameFromDs from calculateFieldDisplayName() when __name__ is the field name when legendFormat is auto', () => { + const request = { + targets: [ + { + format: 'time_series', + refId: 'A', + legendFormat: '__auto', + }, + ], + } as unknown as DataQueryRequest<PromQuery>; + const response = { + state: 'Done', + data: [ + { + fields: [ + { + name: 'Time', + type: 'time', + values: [1], + typeInfo: { frame: 'time.Time' }, + }, + { + name: 'up', + labels: { __name__: 'up' }, + config: {}, + values: [1], + }, + ], + length: 1, + refId: 'A', + meta: { + type: 'timeseries-multi', + typeVersion: [0, 1], + }, + }, + ], + } as unknown as DataQueryResponse; + const series = transformV2(response, request, {}); + expect(series).toEqual({ + data: [ + { + fields: [ + { + name: 'Time', + type: 'time', + values: [1], + typeInfo: { frame: 'time.Time' }, + }, + { + config: { displayNameFromDS: 'up' }, + labels: { __name__: 'up' }, + name: 'up', + values: [1], + }, + ], + length: 1, + meta: { + type: 'timeseries-multi', + typeVersion: [0, 1], + preferredVisualisationType: 'graph', + }, + refId: 'A', + }, + ], + state: 'Done', + }); + }); + + it('results with table format should be transformed to table dataFrames', () => { + const request = { + targets: [ + { + format: 'table', + refId: 'A', + }, + ], + } as unknown as DataQueryRequest<PromQuery>; + const response = { + state: 'Done', + data: [ + createDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'value', + type: FieldType.number, + values: [6, 5, 4], + labels: { label1: 'value1', label2: 'value2' }, + }, + ], + }), + ], + } as unknown as DataQueryResponse; + const series = transformV2(response, request, {}); + + expect(series.data[0].fields[0].name).toEqual('Time'); + expect(series.data[0].fields[1].name).toEqual('label1'); + expect(series.data[0].fields[2].name).toEqual('label2'); + expect(series.data[0].fields[3].name).toEqual('Value'); + expect(series.data[0].meta?.preferredVisualisationType).toEqual('rawPrometheus'); + }); + + it('results with table format and multiple data frames should be transformed to 1 table dataFrame', () => { + const request = { + targets: [ + { + format: 'table', + refId: 'A', + }, + ], + } as unknown as DataQueryRequest<PromQuery>; + const response = { + state: 'Done', + data: [ + createDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'value', + type: FieldType.number, + values: [6, 5, 4], + labels: { label1: 'value1', label2: 'value2' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [2, 3, 7] }, + { + name: 'value', + type: FieldType.number, + values: [2, 3, 7], + labels: { label3: 'value3', label4: 'value4' }, + }, + ], + }), + ], + } as unknown as DataQueryResponse; + const series = transformV2(response, request, {}); + + expect(series.data.length).toEqual(1); + expect(series.data[0].fields[0].name).toEqual('Time'); + expect(series.data[0].fields[1].name).toEqual('label1'); + expect(series.data[0].fields[2].name).toEqual('label2'); + expect(series.data[0].fields[3].name).toEqual('label3'); + expect(series.data[0].fields[4].name).toEqual('label4'); + expect(series.data[0].fields[5].name).toEqual('Value'); + expect(series.data[0].meta?.preferredVisualisationType).toEqual('rawPrometheus' as PreferredVisualisationType); + }); + + it('results with table and time_series format should be correctly transformed', () => { + const options = { + targets: [ + { + format: 'table', + refId: 'A', + }, + { + format: 'time_series', + refId: 'B', + }, + ], + } as unknown as DataQueryRequest<PromQuery>; + const response = { + state: 'Done', + data: [ + createDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'value', + type: FieldType.number, + values: [6, 5, 4], + labels: { label1: 'value1', label2: 'value2' }, + }, + ], + }), + createDataFrame({ + refId: 'B', + fields: [ + { name: 'time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'value', + type: FieldType.number, + values: [6, 5, 4], + labels: { label1: 'value1', label2: 'value2' }, + }, + ], + }), + ], + } as unknown as DataQueryResponse; + const series = transformV2(response, options, {}); + expect(series.data[0].fields.length).toEqual(2); + expect(series.data[0].meta?.preferredVisualisationType).toEqual('graph'); + expect(series.data[1].fields.length).toEqual(4); + expect(series.data[1].meta?.preferredVisualisationType).toEqual('rawPrometheus' as PreferredVisualisationType); + }); + + // Heatmap frames can either have a name of the metric, or if there is no metric, a name of "Value" + it('results with heatmap format (no metric name) should be correctly transformed', () => { + const options = { + targets: [ + { + format: 'heatmap', + refId: 'A', + }, + ], + } as unknown as DataQueryRequest<PromQuery>; + const response = { + state: 'Done', + data: [ + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [10, 10, 0], + labels: { le: '1' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [30, 10, 40], + labels: { le: '+Inf' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [20, 10, 30], + labels: { le: '2' }, + }, + ], + }), + ], + } as unknown as DataQueryResponse; + + const series = transformV2(response, options, {}); + expect(series.data[0].fields.length).toEqual(4); + expect(series.data[0].fields[1].values).toEqual([10, 10, 0]); + expect(series.data[0].fields[2].values).toEqual([10, 0, 30]); + expect(series.data[0].fields[3].values).toEqual([10, 0, 10]); + expect(series.data[0].fields[1].name).toEqual('1'); + expect(series.data[0].fields[2].name).toEqual('2'); + expect(series.data[0].fields[3].name).toEqual('+Inf'); + }); + + it('results with heatmap format (with metric name) should be correctly transformed', () => { + const options = { + targets: [ + { + format: 'heatmap', + refId: 'A', + }, + ], + } as unknown as DataQueryRequest<PromQuery>; + const response = { + state: 'Done', + data: [ + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'metric_name', + type: FieldType.number, + values: [10, 10, 0], + labels: { le: '1', __name__: 'metric_name' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'metric_name', + type: FieldType.number, + values: [30, 10, 40], + labels: { le: '+Inf', __name__: 'metric_name' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'metric_name', + type: FieldType.number, + values: [20, 10, 30], + labels: { le: '2', __name__: 'metric_name' }, + }, + ], + }), + ], + } as unknown as DataQueryResponse; + + const series = transformV2(response, options, {}); + expect(series.data[0].fields.length).toEqual(4); + expect(series.data[0].fields[1].values).toEqual([10, 10, 0]); + expect(series.data[0].fields[2].values).toEqual([10, 0, 30]); + expect(series.data[0].fields[3].values).toEqual([10, 0, 10]); + expect(series.data[0].fields[1].name).toEqual('1'); + expect(series.data[0].fields[2].name).toEqual('2'); + expect(series.data[0].fields[3].name).toEqual('+Inf'); + }); + + it('results with heatmap format (no metric name) from multiple queries should be correctly transformed', () => { + const options = { + targets: [ + { + format: 'heatmap', + refId: 'A', + }, + { + format: 'heatmap', + refId: 'B', + }, + ], + } as unknown as DataQueryRequest<PromQuery>; + const response = { + state: 'Done', + data: [ + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [10, 10, 0], + labels: { le: '1' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [20, 10, 30], + labels: { le: '2' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [30, 10, 40], + labels: { le: '+Inf' }, + }, + ], + }), + createDataFrame({ + refId: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [10, 10, 0], + labels: { le: '1' }, + }, + ], + }), + createDataFrame({ + refId: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [20, 10, 30], + labels: { le: '2' }, + }, + ], + }), + createDataFrame({ + refId: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [30, 10, 40], + labels: { le: '+Inf' }, + }, + ], + }), + ], + } as unknown as DataQueryResponse; + + const series = transformV2(response, options, {}); + expect(series.data[0].fields.length).toEqual(4); + expect(series.data[0].fields[1].values).toEqual([10, 10, 0]); + expect(series.data[0].fields[2].values).toEqual([10, 0, 30]); + expect(series.data[0].fields[3].values).toEqual([10, 0, 10]); + }); + it('results with heatmap format (with metric name) from multiple queries should be correctly transformed', () => { + const options = { + targets: [ + { + format: 'heatmap', + refId: 'A', + }, + { + format: 'heatmap', + refId: 'B', + }, + ], + } as unknown as DataQueryRequest<PromQuery>; + const response = { + state: 'Done', + data: [ + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'metric_name', + type: FieldType.number, + values: [10, 10, 0], + labels: { le: '1', __name__: 'metric_name' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'metric_name', + type: FieldType.number, + values: [20, 10, 30], + labels: { le: '2', __name__: 'metric_name' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'metric_name', + type: FieldType.number, + values: [30, 10, 40], + labels: { le: '+Inf', __name__: 'metric_name' }, + }, + ], + }), + createDataFrame({ + refId: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'metric_name', + type: FieldType.number, + values: [10, 10, 0], + labels: { le: '1', __name__: 'metric_name' }, + }, + ], + }), + createDataFrame({ + refId: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'metric_name', + type: FieldType.number, + values: [20, 10, 30], + labels: { le: '2', __name__: 'metric_name' }, + }, + ], + }), + createDataFrame({ + refId: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'metric_name', + type: FieldType.number, + values: [30, 10, 40], + labels: { le: '+Inf', __name__: 'metric_name' }, + }, + ], + }), + ], + } as unknown as DataQueryResponse; + + const series = transformV2(response, options, {}); + expect(series.data[0].fields.length).toEqual(4); + expect(series.data[0].fields[1].values).toEqual([10, 10, 0]); + expect(series.data[0].fields[2].values).toEqual([10, 0, 30]); + expect(series.data[0].fields[3].values).toEqual([10, 0, 10]); + }); + + it('results with heatmap format and multiple histograms should be grouped and de-accumulated by non-le labels', () => { + const options = { + targets: [ + { + format: 'heatmap', + refId: 'A', + }, + ], + } as unknown as DataQueryRequest<PromQuery>; + const response = { + state: 'Done', + data: [ + // 10 + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [10, 10, 0], + labels: { le: '1', additionalProperty: '10' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [20, 10, 30], + labels: { le: '2', additionalProperty: '10' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [30, 10, 40], + labels: { le: '+Inf', additionalProperty: '10' }, + }, + ], + }), + // 20 + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [0, 10, 10], + labels: { le: '1', additionalProperty: '20' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [20, 10, 40], + labels: { le: '2', additionalProperty: '20' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [30, 10, 60], + labels: { le: '+Inf', additionalProperty: '20' }, + }, + ], + }), + // 30 + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [30, 30, 60], + labels: { le: '1', additionalProperty: '30' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [30, 40, 60], + labels: { le: '2', additionalProperty: '30' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [40, 40, 60], + labels: { le: '+Inf', additionalProperty: '30' }, + }, + ], + }), + ], + } as unknown as DataQueryResponse; + + const series = transformV2(response, options, {}); + expect(series.data[0].fields.length).toEqual(4); + expect(series.data[0].fields[1].values).toEqual([10, 10, 0]); + expect(series.data[0].fields[2].values).toEqual([10, 0, 30]); + expect(series.data[0].fields[3].values).toEqual([10, 0, 10]); + + expect(series.data[1].fields[1].values).toEqual([0, 10, 10]); + expect(series.data[1].fields[2].values).toEqual([20, 0, 30]); + expect(series.data[1].fields[3].values).toEqual([10, 0, 20]); + + expect(series.data[2].fields[1].values).toEqual([30, 30, 60]); + expect(series.data[2].fields[2].values).toEqual([0, 10, 0]); + expect(series.data[2].fields[3].values).toEqual([10, 0, 0]); + }); + + it('Retains exemplar frames when data returned is a heatmap', () => { + const options = { + targets: [ + { + format: 'heatmap', + refId: 'A', + }, + ], + } as unknown as DataQueryRequest<PromQuery>; + const response = { + state: 'Done', + data: [ + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [10, 10, 0], + labels: { le: '1' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + name: 'exemplar', + meta: { + custom: { + resultType: 'exemplar', + }, + }, + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4, 3, 2, 1] }, + { + name: 'Value', + type: FieldType.number, + values: [30, 10, 40, 90, 14, 21], + labels: { le: '6' }, + }, + { + name: 'Test', + type: FieldType.string, + values: ['hello', 'doctor', 'name', 'continue', 'yesterday', 'tomorrow'], + labels: { le: '6' }, + }, + ], + }), + ], + } as unknown as DataQueryResponse; + + const series = transformV2(response, options, {}); + expect(series.data[0].fields.length).toEqual(2); + expect(series.data.length).toEqual(2); + expect(series.data[1].fields[2].values).toEqual(['hello', 'doctor', 'name', 'continue', 'yesterday', 'tomorrow']); + expect(series.data[1].fields.length).toEqual(3); + }); + + it('should not add a link with an error when exemplarTraceIdDestinations is not configured properly', () => { + const response = { + state: 'Done', + data: [ + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'Value', + type: FieldType.number, + values: [10, 10, 0], + labels: { le: '1' }, + }, + ], + }), + createDataFrame({ + refId: 'A', + name: 'exemplar', + meta: { + custom: { + resultType: 'exemplar', + }, + }, + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4, 3, 2, 1] }, + { + name: 'Value', + type: FieldType.number, + values: [30, 10, 40, 90, 14, 21], + labels: { le: '6' }, + }, + { + name: 'traceID', + type: FieldType.string, + values: ['unknown'], + labels: { le: '6' }, + }, + ], + }), + ], + } as unknown as DataQueryResponse; + const request = { + targets: [ + { + format: 'heatmap', + refId: 'A', + }, + ], + } as unknown as DataQueryRequest<PromQuery>; + const testOptions = { + exemplarTraceIdDestinations: [ + { + name: 'traceID', + datasourceUid: 'unknown', + }, + ], + }; + + const series = transformV2(response, request, testOptions); + expect(series.data[1].fields.length).toEqual(3); + expect(series.data[1].name).toEqual('exemplar'); + const traceField = series.data[1].fields.find((f) => f.name === 'traceID'); + expect(traceField).toBeDefined(); + expect(traceField!.config.links?.length).toBe(0); + }); + + it('should convert values less than 1e-9 to 0', () => { + // pulled from real response + const bucketValues = [ + [0.22222222222222218, 0.24444444444444444, 0.19999999999999996], // le=0.005 + [0.39999999999999997, 0.44444444444444436, 0.42222222222222217], + [0.3999999999999999, 0.44444444444444436, 0.42222222222222217], + [0.3999999999999999, 0.44444444444444436, 0.42222222222222217], + [0.3999999999999999, 0.44444444444444436, 0.42222222222222217], + [0.3999999999999999, 0.44444444444444436, 0.42222222222222217], + [0.39999999999999997, 0.44444444444444436, 0.42222222222222217], + [0.39999999999999997, 0.44444444444444436, 0.42222222222222217], + [0.3999999999999999, 0.44444444444444436, 0.42222222222222217], + [0.3999999999999999, 0.44444444444444436, 0.42222222222222217], + [0.3999999999999999, 0.44444444444444436, 0.42222222222222217], + [0.4666666666666666, 0.5111111111111111, 0.4888888888888888], + [0.4666666666666666, 0.5111111111111111, 0.4888888888888888], + [0.46666666666666656, 0.5111111111111111, 0.4888888888888888], + [0.46666666666666656, 0.5111111111111111, 0.4888888888888888], // le=+Inf + ]; + + const frames = bucketValues.map((vals) => + createDataFrame({ + refId: 'A', + fields: [ + { type: FieldType.time, values: [1, 2, 3] }, + { + type: FieldType.number, + values: vals.slice(), + }, + ], + }) + ); + + const fieldValues = transformToHistogramOverTime(frames).map((frame) => frame.fields[1].values); + + expect(fieldValues).toEqual([ + [0.22222222222222218, 0.24444444444444444, 0.19999999999999996], + [0.17777777777777778, 0.19999999999999993, 0.2222222222222222], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0.06666666666666671, 0.06666666666666671, 0.06666666666666665], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ]); + }); + + it('should throw an error if the series does not contain number-type values', () => { + const response = { + state: 'Done', + data: [ + ['10', '10', '0'], + ['20', '10', '30'], + ['20', '10', '35'], + ].map((values) => + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { name: 'Value', type: FieldType.string, values }, + ], + }) + ), + } as unknown as DataQueryResponse; + const request = { + targets: [ + { + format: 'heatmap', + refId: 'A', + }, + ], + } as unknown as DataQueryRequest<PromQuery>; + + expect(() => transformV2(response, request, {})).toThrow(); + }); + }); + + describe('transformDFToTable', () => { + it('transforms dataFrame with response length 1 to table dataFrame', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'value', + type: FieldType.number, + values: [6, 5, 4], + labels: { label1: 'value1', label2: 'value2' }, + }, + ], + }); + + const tableDf = transformDFToTable([df])[0]; + expect(tableDf.fields.length).toBe(4); + expect(tableDf.fields[0].name).toBe('Time'); + expect(tableDf.fields[1].name).toBe('label1'); + expect(tableDf.fields[1].values[0]).toBe('value1'); + expect(tableDf.fields[2].name).toBe('label2'); + expect(tableDf.fields[2].values[0]).toBe('value2'); + expect(tableDf.fields[3].name).toBe('Value'); + }); + + it('transforms dataFrame with response length 2 to table dataFrame', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'value', + type: FieldType.number, + values: [6, 5, 4], + labels: { label1: 'value1', label2: 'value2' }, + }, + ], + }); + + const tableDf = transformDFToTable([df])[0]; + expect(tableDf.fields.length).toBe(4); + expect(tableDf.fields[0].name).toBe('Time'); + expect(tableDf.fields[1].name).toBe('label1'); + expect(tableDf.fields[1].values[0]).toBe('value1'); + expect(tableDf.fields[2].name).toBe('label2'); + expect(tableDf.fields[2].values[0]).toBe('value2'); + expect(tableDf.fields[3].name).toBe('Value'); + }); + + // Queries do not always return results + it('transforms dataFrame and empty dataFrame mock responses to table dataFrames', () => { + const value1 = 'value1'; + const value2 = 'value2'; + + const dataframes = [ + createDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'value', + type: FieldType.number, + values: [6, 5, 4], + labels: { label1: value1, label2: value2 }, + }, + ], + }), + createDataFrame({ + refId: 'B', + fields: [], + }), + ]; + + const transformedTableDataFrames = transformDFToTable(dataframes); + // Expect the first query to still return valid results + expect(transformedTableDataFrames[0].fields.length).toBe(4); + expect(transformedTableDataFrames[0].fields[0].name).toBe('Time'); + expect(transformedTableDataFrames[0].fields[1].name).toBe('label1'); + expect(transformedTableDataFrames[0].fields[1].values[0]).toBe(value1); + expect(transformedTableDataFrames[0].fields[2].name).toBe('label2'); + expect(transformedTableDataFrames[0].fields[2].values[0]).toBe(value2); + expect(transformedTableDataFrames[0].fields[3].name).toBe('Value #A'); + + // Expect the invalid/empty results not to throw an error and to return empty arrays + expect(transformedTableDataFrames[1].fields[1].labels).toBe(undefined); + expect(transformedTableDataFrames[1].fields[1].name).toBe('Value #B'); + expect(transformedTableDataFrames[1].fields[1].values).toEqual([]); + expect(transformedTableDataFrames[1].fields[0].values).toEqual([]); + }); + + it('transforms dataframes with metadata resolving from their refIds', () => { + const value1 = 'value1'; + const value2 = 'value2'; + const executedQueryForRefA = 'Expr: avg_over_time(access_evaluation_duration_bucket[15s])\nStep: 15s'; + const executedQueryForRefB = 'Expr: avg_over_time(access_evaluation_duration_bucket[5m])\nStep: 15s'; + + const dataframes = [ + createDataFrame({ + refId: 'A', + meta: { + typeVersion: [0, 1], + custom: { + resultType: 'vector', + }, + executedQueryString: executedQueryForRefA, + }, + fields: [ + { name: 'time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'value', + type: FieldType.number, + values: [6, 5, 4], + labels: { label1: value1, label2: value2 }, + }, + ], + }), + createDataFrame({ + refId: 'B', + meta: { + typeVersion: [0, 1], + custom: { + resultType: 'vector', + }, + executedQueryString: executedQueryForRefB, + }, + fields: [ + { name: 'time', type: FieldType.time, values: [6, 5, 4] }, + { + name: 'value', + type: FieldType.number, + values: [6, 5, 4], + labels: { label1: value1, label2: value2 }, + }, + ], + }), + ]; + + const transformedTableDataFrames = transformDFToTable(dataframes); + expect(transformedTableDataFrames[0].meta).toBeTruthy(); + expect(transformedTableDataFrames[1].meta).toBeTruthy(); + expect(transformedTableDataFrames[0].meta?.executedQueryString).toEqual(executedQueryForRefA); + expect(transformedTableDataFrames[1].meta?.executedQueryString).toEqual(executedQueryForRefB); + }); + }); +}); diff --git a/packages/grafana-prometheus/src/result_transformer.ts b/packages/grafana-prometheus/src/result_transformer.ts new file mode 100644 index 0000000000000..ac93f7955a840 --- /dev/null +++ b/packages/grafana-prometheus/src/result_transformer.ts @@ -0,0 +1,423 @@ +import { flatten, forOwn, groupBy, partition } from 'lodash'; + +import { + CoreApp, + DataFrame, + DataFrameType, + DataLink, + DataQueryRequest, + DataQueryResponse, + DataTopic, + Field, + FieldType, + getDisplayProcessor, + getFieldDisplayName, + Labels, + TIME_SERIES_TIME_FIELD_NAME, + TIME_SERIES_VALUE_FIELD_NAME, +} from '@grafana/data'; +import { config, getDataSourceSrv } from '@grafana/runtime'; + +import { ExemplarTraceIdDestination, PromMetric, PromQuery, PromValue } from './types'; + +// handles case-insensitive Inf, +Inf, -Inf (with optional "inity" suffix) +const INFINITY_SAMPLE_REGEX = /^[+-]?inf(?:inity)?$/i; + +const isTableResult = (dataFrame: DataFrame, options: DataQueryRequest<PromQuery>): boolean => { + // We want to process vector and scalar results in Explore as table + if ( + options.app === CoreApp.Explore && + (dataFrame.meta?.custom?.resultType === 'vector' || dataFrame.meta?.custom?.resultType === 'scalar') + ) { + return true; + } + + // We want to process all dataFrames with target.format === 'table' as table + const target = options.targets.find((target) => target.refId === dataFrame.refId); + return target?.format === 'table'; +}; + +const isCumulativeHeatmapResult = (dataFrame: DataFrame, options: DataQueryRequest<PromQuery>): boolean => { + if (dataFrame.meta?.type === DataFrameType.HeatmapCells) { + return false; + } + + const target = options.targets.find((target) => target.refId === dataFrame.refId); + return target?.format === 'heatmap'; +}; + +// V2 result transformer used to transform query results from queries that were run through prometheus backend +export function transformV2( + response: DataQueryResponse, + request: DataQueryRequest<PromQuery>, + options: { exemplarTraceIdDestinations?: ExemplarTraceIdDestination[] } +) { + // migration for dataplane field name issue + if (config.featureToggles.prometheusDataplane) { + // update displayNameFromDS in the field config + response.data.forEach((f: DataFrame) => { + const target = request.targets.find((t) => t.refId === f.refId); + // check that the legend is selected as auto + if (target && target.legendFormat === '__auto') { + f.fields.forEach((field) => { + if (field.labels?.__name__ && field.labels?.__name__ === field.name) { + const fieldCopy = { ...field, name: TIME_SERIES_VALUE_FIELD_NAME }; + field.config.displayNameFromDS = getFieldDisplayName(fieldCopy, f, response.data); + } + }); + } + }); + } + + const [tableFrames, framesWithoutTable] = partition<DataFrame>(response.data, (df) => isTableResult(df, request)); + const processedTableFrames = transformDFToTable(tableFrames); + + const [exemplarFrames, framesWithoutTableAndExemplars] = partition<DataFrame>( + framesWithoutTable, + (df) => df.meta?.custom?.resultType === 'exemplar' + ); + + // EXEMPLAR FRAMES: We enrich exemplar frames with data links and add dataTopic meta info + const { exemplarTraceIdDestinations: destinations } = options; + const processedExemplarFrames = exemplarFrames.map((dataFrame) => { + if (destinations?.length) { + for (const exemplarTraceIdDestination of destinations) { + const traceIDField = dataFrame.fields.find((field) => field.name === exemplarTraceIdDestination.name); + if (traceIDField) { + const links = getDataLinks(exemplarTraceIdDestination); + traceIDField.config.links = traceIDField.config.links?.length + ? [...traceIDField.config.links, ...links] + : links; + } + } + } + + return { ...dataFrame, meta: { ...dataFrame.meta, dataTopic: DataTopic.Annotations } }; + }); + + const [heatmapResults, framesWithoutTableHeatmapsAndExemplars] = partition<DataFrame>( + framesWithoutTableAndExemplars, + (df) => isCumulativeHeatmapResult(df, request) + ); + + // this works around the fact that we only get back frame.name with le buckets when legendFormat == {{le}}...which is not the default + heatmapResults.forEach((df) => { + if (df.name == null) { + let f = df.fields.find((f) => f.type === FieldType.number); + + if (f) { + let le = f.labels?.le; + + if (le) { + // this is used for sorting the frames by numeric ascending le labels for de-accum + df.name = le; + // this is used for renaming the Value fields to le label + f.config.displayNameFromDS = le; + } + } + } + }); + + // Group heatmaps by query + const heatmapResultsGroupedByQuery = groupBy<DataFrame>(heatmapResults, (h) => h.refId); + + // Initialize empty array to push grouped histogram frames to + let processedHeatmapResultsGroupedByQuery: DataFrame[][] = []; + + // Iterate through every query in this heatmap + for (const query in heatmapResultsGroupedByQuery) { + // Get reference to dataFrames for heatmap + const heatmapResultsGroup = heatmapResultsGroupedByQuery[query]; + + // Create a new grouping by iterating through the data frames... + const heatmapResultsGroupedByValues = groupBy<DataFrame>(heatmapResultsGroup, (dataFrame) => { + // Each data frame has `Time` and `Value` properties, we want to get the values + const values = dataFrame.fields.find((field) => field.type === FieldType.number); + // Specific functionality for special "le" quantile heatmap value, we know if this value exists, that we do not want to calculate the heatmap density across data frames from the same quartile + if (values?.labels && HISTOGRAM_QUANTILE_LABEL_NAME in values.labels) { + const { le, ...notLE } = values?.labels; + return Object.values(notLE).join(); + } + + // Return a string made from the concatenation of this frame's values to represent a grouping in the query + return Object.values(values?.labels ?? []).join(); + }); + + // Then iterate through the resultant object + forOwn(heatmapResultsGroupedByValues, (dataFrames, key) => { + // Sort frames within each grouping + const sortedHeatmap = dataFrames.sort(sortSeriesByLabel); + // And push the sorted grouping with the rest + processedHeatmapResultsGroupedByQuery.push(mergeHeatmapFrames(transformToHistogramOverTime(sortedHeatmap))); + }); + } + + // Everything else is processed as time_series result and graph preferredVisualisationType + const otherFrames = framesWithoutTableHeatmapsAndExemplars.map((dataFrame) => { + const df: DataFrame = { + ...dataFrame, + meta: { + ...dataFrame.meta, + preferredVisualisationType: 'graph', + }, + }; + return df; + }); + + const flattenedProcessedHeatmapFrames = flatten(processedHeatmapResultsGroupedByQuery); + + return { + ...response, + data: [...otherFrames, ...processedTableFrames, ...flattenedProcessedHeatmapFrames, ...processedExemplarFrames], + }; +} + +const HISTOGRAM_QUANTILE_LABEL_NAME = 'le'; + +export function transformDFToTable(dfs: DataFrame[]): DataFrame[] { + // If no dataFrames or if 1 dataFrames with no values, return original dataFrame + if (dfs.length === 0 || (dfs.length === 1 && dfs[0].length === 0)) { + return dfs; + } + + // Group results by refId and process dataFrames with the same refId as 1 dataFrame + const dataFramesByRefId = groupBy(dfs, 'refId'); + const refIds = Object.keys(dataFramesByRefId); + + const frames = refIds.map((refId) => { + // Create timeField, valueField and labelFields + const valueText = getValueText(refIds.length, refId); + const valueField = getValueField({ data: [], valueName: valueText }); + const timeField = getTimeField([]); + const labelFields: Field[] = []; + + // Fill labelsFields with labels from dataFrames + dataFramesByRefId[refId].forEach((df) => { + const frameValueField = df.fields[1]; + const promLabels = frameValueField?.labels ?? {}; + + Object.keys(promLabels) + .sort() + .forEach((label) => { + // If we don't have label in labelFields, add it + if (!labelFields.some((l) => l.name === label)) { + const numberField = label === HISTOGRAM_QUANTILE_LABEL_NAME; + labelFields.push({ + name: label, + config: { filterable: true }, + type: numberField ? FieldType.number : FieldType.string, + values: [], + }); + } + }); + }); + + // Fill valueField, timeField and labelFields with values + dataFramesByRefId[refId].forEach((df) => { + const timeFields = df.fields[0]?.values ?? []; + const dataFields = df.fields[1]?.values ?? []; + timeFields.forEach((value) => timeField.values.push(value)); + dataFields.forEach((value) => { + valueField.values.push(parseSampleValue(value)); + const labelsForField = df.fields[1].labels ?? {}; + labelFields.forEach((field) => field.values.push(getLabelValue(labelsForField, field.name))); + }); + }); + + const fields = [timeField, ...labelFields, valueField]; + return { + refId, + fields, + // Prometheus specific UI for instant queries + meta: { + ...dataFramesByRefId[refId][0].meta, + preferredVisualisationType: 'rawPrometheus' as const, + }, + length: timeField.values.length, + }; + }); + return frames; +} + +function getValueText(responseLength: number, refId = '') { + return responseLength > 1 ? `Value #${refId}` : 'Value'; +} + +function getDataLinks(options: ExemplarTraceIdDestination): DataLink[] { + const dataLinks: DataLink[] = []; + + if (options.datasourceUid) { + const dataSourceSrv = getDataSourceSrv(); + const dsSettings = dataSourceSrv.getInstanceSettings(options.datasourceUid); + + // dsSettings is undefined because of the reasons below: + // - permissions issues (probably most likely) + // - deleted datasource + // - misconfiguration + if (dsSettings) { + dataLinks.push({ + title: options.urlDisplayLabel || `Query with ${dsSettings?.name}`, + url: '', + internal: { + query: { query: '${__value.raw}', queryType: 'traceql' }, + datasourceUid: options.datasourceUid, + datasourceName: dsSettings?.name ?? 'Data source not found', + }, + }); + } + } + + if (options.url) { + dataLinks.push({ + title: options.urlDisplayLabel || `Go to ${options.url}`, + url: options.url, + targetBlank: true, + }); + } + return dataLinks; +} + +function getLabelValue(metric: PromMetric, label: string): string | number { + if (metric.hasOwnProperty(label)) { + if (label === HISTOGRAM_QUANTILE_LABEL_NAME) { + return parseSampleValue(metric[label]); + } + return metric[label]; + } + return ''; +} + +function getTimeField(data: PromValue[], isMs = false): Field<number> { + return { + name: TIME_SERIES_TIME_FIELD_NAME, + type: FieldType.time, + config: {}, + values: data.map((val) => (isMs ? val[0] : val[0] * 1000)), + }; +} + +type ValueFieldOptions = { + data: PromValue[]; + valueName?: string; + parseValue?: boolean; + labels?: Labels; + displayNameFromDS?: string; +}; + +function getValueField({ + data, + valueName = TIME_SERIES_VALUE_FIELD_NAME, + parseValue = true, + labels, + displayNameFromDS, +}: ValueFieldOptions): Field { + return { + name: valueName, + type: FieldType.number, + display: getDisplayProcessor(), + config: { + displayNameFromDS, + }, + labels, + values: data.map((val) => (parseValue ? parseSampleValue(val[1]) : val[1])), + }; +} + +export function getOriginalMetricName(labelData: { [key: string]: string }) { + const metricName = labelData.__name__ || ''; + delete labelData.__name__; + const labelPart = Object.entries(labelData) + .map((label) => `${label[0]}="${label[1]}"`) + .join(','); + return `${metricName}{${labelPart}}`; +} + +function mergeHeatmapFrames(frames: DataFrame[]): DataFrame[] { + if (frames.length === 0 || (frames.length === 1 && frames[0].length === 0)) { + return []; + } + + const timeField = frames[0].fields.find((field) => field.type === FieldType.time)!; + const countFields = frames.map((frame) => { + let field = frame.fields.find((field) => field.type === FieldType.number)!; + + return { + ...field, + name: field.config.displayNameFromDS!, + }; + }); + + return [ + { + ...frames[0], + meta: { + ...frames[0].meta, + type: DataFrameType.HeatmapRows, + }, + fields: [timeField!, ...countFields], + }, + ]; +} + +/** @internal */ +export function transformToHistogramOverTime(seriesList: DataFrame[]): DataFrame[] { + /* t1 = timestamp1, t2 = timestamp2 etc. + t1 t2 t3 t1 t2 t3 + le10 10 10 0 => 10 10 0 + le20 20 10 30 => 10 0 30 + le30 30 10 35 => 10 0 5 + */ + + for (let i = seriesList.length - 1; i > 0; i--) { + const topSeries = seriesList[i].fields.find((s) => s.type === FieldType.number); + const bottomSeries = seriesList[i - 1].fields.find((s) => s.type === FieldType.number); + if (!topSeries || !bottomSeries) { + throw new Error('Prometheus heatmap transform error: data should be a time series'); + } + + for (let j = 0; j < topSeries.values.length; j++) { + const bottomPoint = bottomSeries.values[j] || [0]; + topSeries.values[j] -= bottomPoint; + + if (topSeries.values[j] < 1e-9) { + topSeries.values[j] = 0; + } + } + } + + return seriesList; +} + +export function sortSeriesByLabel(s1: DataFrame, s2: DataFrame): number { + let le1, le2; + + try { + // the state.displayName conditions are here because we also use this sorting util fn + // in panels where isHeatmapResult was false but we still want to sort numerically-named + // fields after the full unique displayName is cached in field state + le1 = parseSampleValue(s1.fields[1].state?.displayName ?? s1.name ?? s1.fields[1].name); + le2 = parseSampleValue(s2.fields[1].state?.displayName ?? s2.name ?? s2.fields[1].name); + } catch (err) { + // fail if not integer. might happen with bad queries + console.error(err); + return 0; + } + + if (le1 > le2) { + return 1; + } + + if (le1 < le2) { + return -1; + } + + return 0; +} + +/** @internal */ +export function parseSampleValue(value: string): number { + if (INFINITY_SAMPLE_REGEX.test(value)) { + return value[0] === '-' ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY; + } + return parseFloat(value); +} diff --git a/packages/grafana-prometheus/src/tracking.ts b/packages/grafana-prometheus/src/tracking.ts new file mode 100644 index 0000000000000..76706984df3bf --- /dev/null +++ b/packages/grafana-prometheus/src/tracking.ts @@ -0,0 +1,46 @@ +import { CoreApp, DataQueryRequest, DataQueryResponse } from '@grafana/data'; +import { config, reportInteraction } from '@grafana/runtime'; + +import { PromQuery } from './types'; + +export function trackQuery( + response: DataQueryResponse, + request: DataQueryRequest<PromQuery> & { targets: PromQuery[] }, + startTime: Date +): void { + const { app, targets: queries } = request; + // We do want to track panel-editor and explore + // We do not want to track queries from the dashboard or viewing a panel + // also included in the tracking is cloud-alerting, unified-alerting, and unknown + if (app === CoreApp.Dashboard || app === CoreApp.PanelViewer) { + return; + } + + for (const query of queries) { + reportInteraction('grafana_prometheus_query_executed', { + app, + grafana_version: config.buildInfo.version, + has_data: response.data.some((frame) => frame.length > 0), + has_error: response.error !== undefined, + expr: query.expr, + format: query.format, + instant: query.instant, + range: query.range, + exemplar: query.exemplar, + hinting: query.hinting, + interval: query.interval, + intervalFactor: query.intervalFactor, + utcOffsetSec: query.utcOffsetSec, + legend: query.legendFormat, + valueWithRefId: query.valueWithRefId, + requestId: request.requestId, + showingGraph: query.showingGraph, + showingTable: query.showingTable, + editor_mode: query.editorMode, + simultaneously_sent_query_count: queries.length, + time_range_from: request?.range?.from?.toISOString(), + time_range_to: request?.range?.to?.toISOString(), + time_taken: Date.now() - startTime.getTime(), + }); + } +} diff --git a/packages/grafana-prometheus/src/types.ts b/packages/grafana-prometheus/src/types.ts new file mode 100644 index 0000000000000..f3c5730b3805d --- /dev/null +++ b/packages/grafana-prometheus/src/types.ts @@ -0,0 +1,141 @@ +import { DataSourceJsonData } from '@grafana/data'; +import { DataQuery } from '@grafana/schema'; + +import { Prometheus as GenPromQuery } from './dataquery'; +import { QueryBuilderLabelFilter, QueryEditorMode } from './querybuilder/shared/types'; + +export interface PromQuery extends GenPromQuery, DataQuery { + /** + * Timezone offset to align start & end time on backend + */ + utcOffsetSec?: number; + valueWithRefId?: boolean; + showingGraph?: boolean; + showingTable?: boolean; + hinting?: boolean; + interval?: string; + // store the metrics explorer additional settings + useBackend?: boolean; + disableTextWrap?: boolean; + fullMetaSearch?: boolean; + includeNullMetadata?: boolean; +} + +export enum PrometheusCacheLevel { + Low = 'Low', + Medium = 'Medium', + High = 'High', + None = 'None', +} + +export enum PromApplication { + Cortex = 'Cortex', + Mimir = 'Mimir', + Prometheus = 'Prometheus', + Thanos = 'Thanos', +} + +export interface PromOptions extends DataSourceJsonData { + timeInterval?: string; + queryTimeout?: string; + httpMethod?: string; + customQueryParameters?: string; + disableMetricsLookup?: boolean; + exemplarTraceIdDestinations?: ExemplarTraceIdDestination[]; + prometheusType?: PromApplication; + prometheusVersion?: string; + cacheLevel?: PrometheusCacheLevel; + defaultEditor?: QueryEditorMode; + incrementalQuerying?: boolean; + incrementalQueryOverlapWindow?: string; + disableRecordingRules?: boolean; + sigV4Auth?: boolean; + oauthPassThru?: boolean; +} + +export type ExemplarTraceIdDestination = { + name: string; + url?: string; + urlDisplayLabel?: string; + datasourceUid?: string; +}; + +export interface PromQueryRequest extends PromQuery { + step?: number; + requestId?: string; + start: number; + end: number; + headers?: any; +} + +export interface PromMetricsMetadataItem { + type: string; + help: string; + unit?: string; +} + +export interface PromMetricsMetadata { + [metric: string]: PromMetricsMetadataItem; +} + +export type PromValue = [number, any]; + +export interface PromMetric { + __name__?: string; + + [index: string]: any; +} + +export interface PromBuildInfoResponse { + data: { + application?: string; + version: string; + revision: string; + features?: { + ruler_config_api?: 'true' | 'false'; + alertmanager_config_api?: 'true' | 'false'; + query_sharding?: 'true' | 'false'; + federated_rules?: 'true' | 'false'; + }; + [key: string]: unknown; + }; + status: 'success'; +} + +/** + * Auto = query.legendFormat == '__auto' + * Verbose = query.legendFormat == null/undefined/'' + * Custom query.legendFormat.length > 0 && query.legendFormat !== '__auto' + */ +export enum LegendFormatMode { + Auto = '__auto', + Verbose = '__verbose', + Custom = '__custom', +} + +export enum PromVariableQueryType { + LabelNames, + LabelValues, + MetricNames, + VarQueryResult, + SeriesQuery, + ClassicQuery, +} + +export interface PromVariableQuery extends DataQuery { + query?: string; + expr?: string; + qryType?: PromVariableQueryType; + label?: string; + metric?: string; + varQuery?: string; + seriesQuery?: string; + labelFilters?: QueryBuilderLabelFilter[]; + match?: string; + classicQuery?: string; +} + +export type StandardPromVariableQuery = { + query: string; + refId: string; +}; diff --git a/packages/grafana-prometheus/src/typings/jest.d.ts b/packages/grafana-prometheus/src/typings/jest.d.ts new file mode 100644 index 0000000000000..b6cdf700ac92a --- /dev/null +++ b/packages/grafana-prometheus/src/typings/jest.d.ts @@ -0,0 +1,17 @@ +import { Observable } from 'rxjs'; + +type ObservableType<T> = T extends Observable<infer V> ? V : never; + +declare global { + namespace jest { + interface Matchers<R, T = {}> { + toEmitValues<E = ObservableType<T>>(expected: E[]): Promise<CustomMatcherResult>; + /** + * Collect all the values emitted by the observables (also errors) and pass them to the expectations functions after + * the observable ended (or emitted error). If Observable does not complete within OBSERVABLE_TEST_TIMEOUT_IN_MS the + * test fails. + */ + toEmitValuesWith<E = ObservableType<T>>(expectations: (received: E[]) => void): Promise<CustomMatcherResult>; + } + } +} diff --git a/packages/grafana-prometheus/src/variables.ts b/packages/grafana-prometheus/src/variables.ts new file mode 100644 index 0000000000000..fcf545e0d1cea --- /dev/null +++ b/packages/grafana-prometheus/src/variables.ts @@ -0,0 +1,58 @@ +import { from, Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { CustomVariableSupport, DataQueryRequest, DataQueryResponse, rangeUtil } from '@grafana/data'; +import { getTemplateSrv, TemplateSrv } from '@grafana/runtime'; + +import { PromVariableQueryEditor } from './components/VariableQueryEditor'; +import { PrometheusDatasource } from './datasource'; +import { PrometheusMetricFindQuery } from './metric_find_query'; +import { PromVariableQuery } from './types'; + +export class PrometheusVariableSupport extends CustomVariableSupport<PrometheusDatasource> { + constructor( + private readonly datasource: PrometheusDatasource, + private readonly templateSrv: TemplateSrv = getTemplateSrv() + ) { + super(); + } + + editor = PromVariableQueryEditor; + + query(request: DataQueryRequest<PromVariableQuery>): Observable<DataQueryResponse> { + // Handling grafana as code from jsonnet variable queries which are strings and not objects + // Previously, when using StandardVariableSupport + // the variable query string was changed to be on the expr attribute + // Now, using CustomVariableSupport, + // the variable query is changed to the query attribute. + // So, without standard variable support changing the query string to the expr attribute, + // the variable query string is coming in as it is written in jsonnet, + // where it is just a string. Here is where we handle that. + let query: string | undefined; + if (typeof request.targets[0] === 'string') { + query = request.targets[0]; + } else { + query = request.targets[0].query; + } + + if (!query) { + return of({ data: [] }); + } + + const scopedVars = { + ...request.scopedVars, + __interval: { text: this.datasource.interval, value: this.datasource.interval }, + __interval_ms: { + text: rangeUtil.intervalToMs(this.datasource.interval), + value: rangeUtil.intervalToMs(this.datasource.interval), + }, + ...this.datasource.getRangeScopedVars(request.range), + }; + + const interpolated = this.templateSrv.replace(query, scopedVars, this.datasource.interpolateQueryExpr); + const metricFindQuery = new PrometheusMetricFindQuery(this.datasource, interpolated); + const metricFindStream = from(metricFindQuery.process(request.range)); + + return metricFindStream.pipe(map((results) => ({ data: results }))); + } +} diff --git a/packages/grafana-prometheus/tsconfig.build.json b/packages/grafana-prometheus/tsconfig.build.json new file mode 100644 index 0000000000000..54309163ebc77 --- /dev/null +++ b/packages/grafana-prometheus/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "exclude": ["dist", "node_modules", "test", "**/*.test.ts*"], + "extends": "./tsconfig.json" +} diff --git a/packages/grafana-prometheus/tsconfig.json b/packages/grafana-prometheus/tsconfig.json new file mode 100644 index 0000000000000..16a6f15ea71fb --- /dev/null +++ b/packages/grafana-prometheus/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "alwaysStrict": true, + "declarationDir": "./compiled", + "emitDeclarationOnly": true, + "isolatedModules": true, + "rootDirs": ["."] + }, + "exclude": ["dist/**/*"], + "extends": "@grafana/tsconfig", + "include": ["src/**/*.ts*", "../../public/app/types/*.d.ts", "../grafana-ui/src/types/*.d.ts"] +} diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 398f4f746762d..d622e1bd00fa3 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/runtime", - "version": "10.3.0-pre", + "version": "11.0.0-pre", "description": "Grafana Runtime Library", "keywords": [ "grafana", @@ -37,46 +37,46 @@ "postpack": "mv package.json.bak package.json" }, "dependencies": { - "@grafana/data": "10.3.0-pre", - "@grafana/e2e-selectors": "10.3.0-pre", - "@grafana/faro-web-sdk": "^1.3.5", - "@grafana/ui": "10.3.0-pre", + "@grafana/data": "11.0.0-pre", + "@grafana/e2e-selectors": "11.0.0-pre", + "@grafana/faro-web-sdk": "^1.3.6", + "@grafana/schema": "11.0.0-pre", + "@grafana/ui": "11.0.0-pre", "history": "4.10.1", "lodash": "4.17.21", "rxjs": "7.8.1", - "systemjs": "6.14.2", + "systemjs": "6.14.3", "systemjs-cjs-extra": "0.2.0", - "tslib": "2.6.0" + "tslib": "2.6.2" }, "devDependencies": { - "@grafana/tsconfig": "^1.2.0-rc1", - "@rollup/plugin-commonjs": "25.0.2", + "@grafana/tsconfig": "^1.3.0-rc1", "@rollup/plugin-node-resolve": "15.2.3", - "@testing-library/dom": "9.3.3", - "@testing-library/react": "14.0.0", - "@testing-library/user-event": "14.5.1", - "@types/angular": "1.8.5", + "@rollup/plugin-terser": "0.1.0", + "@testing-library/dom": "9.3.4", + "@testing-library/react": "14.2.1", + "@testing-library/user-event": "14.5.2", + "@types/angular": "1.8.9", "@types/history": "4.7.11", - "@types/jest": "29.5.4", - "@types/lodash": "4.14.195", - "@types/react": "18.2.15", - "@types/react-dom": "18.2.7", - "@types/systemjs": "6.13.1", + "@types/jest": "29.5.12", + "@types/lodash": "4.17.0", + "@types/react": "18.2.66", + "@types/react-dom": "18.2.22", + "@types/systemjs": "6.13.5", "esbuild": "0.18.12", "lodash": "4.17.21", "react": "18.2.0", "react-dom": "18.2.0", - "rimraf": "5.0.1", + "rimraf": "5.0.5", "rollup": "2.79.1", "rollup-plugin-dts": "^5.0.0", "rollup-plugin-esbuild": "5.0.0", "rollup-plugin-node-externals": "^5.0.0", "rollup-plugin-sourcemaps": "0.6.3", - "rollup-plugin-terser": "7.0.2", - "typescript": "5.2.2" + "typescript": "5.3.3" }, "peerDependencies": { - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "react": "^18.0.0", + "react-dom": "^18.0.0" } } diff --git a/packages/grafana-runtime/src/analytics/types.ts b/packages/grafana-runtime/src/analytics/types.ts index 742b9aac991b9..b67b5ef3d270f 100644 --- a/packages/grafana-runtime/src/analytics/types.ts +++ b/packages/grafana-runtime/src/analytics/types.ts @@ -29,6 +29,7 @@ export interface DataRequestInfo extends Partial<DashboardInfo> { datasourceUid: string; datasourceType: string; panelId?: number; + panelPluginId?: string; panelName?: string; duration: number; error?: string; diff --git a/packages/grafana-runtime/src/components/EmbeddedDashboard.tsx b/packages/grafana-runtime/src/components/EmbeddedDashboard.tsx new file mode 100644 index 0000000000000..92a78e5d4ea24 --- /dev/null +++ b/packages/grafana-runtime/src/components/EmbeddedDashboard.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +export interface EmbeddedDashboardProps { + uid?: string; + /** + * Use this property to override initial time and variable state. + * Example: ?from=now-5m&to=now&var-varname=value1 + */ + initialState?: string; + /** + * Is called when ever the internal embedded dashboards url state changes. + * Can be used to sync the internal url state (Which is not synced to URL) with the external context, or to + * preserve some of the state when moving to other embedded dashboards. + */ + onStateChange?: (state: string) => void; +} + +/** + * Returns a React component that renders an embedded dashboard. + * @alpha + */ +export let EmbeddedDashboard: React.ComponentType<EmbeddedDashboardProps> = () => { + throw new Error('EmbeddedDashboard requires runtime initialization'); +}; + +/** + * + * @internal + */ +export function setEmbeddedDashboard(component: React.ComponentType<EmbeddedDashboardProps>) { + EmbeddedDashboard = component; +} diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 3ab369f4510c8..8a7dae0694179 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -21,11 +21,17 @@ import { export interface AzureSettings { cloud?: string; + clouds?: AzureCloudInfo[]; managedIdentityEnabled: boolean; workloadIdentityEnabled: boolean; userIdentityEnabled: boolean; } +export interface AzureCloudInfo { + name: string; + displayName: string; +} + export type AppPluginConfig = { id: string; path: string; @@ -57,10 +63,6 @@ export class GrafanaBootConfig implements GrafanaConfig { feedbackLinksEnabled = true; disableLoginForm = false; defaultDatasource = ''; // UID - alertingEnabled = false; - alertingErrorOrTimeout = ''; - alertingNoDataOrNullValues = ''; - alertingMinInterval = 1; angularSupportEnabled = false; authProxyEnabled = false; exploreEnabled = false; @@ -99,6 +101,9 @@ export class GrafanaBootConfig implements GrafanaConfig { licenseInfo: LicenseInfo = {} as LicenseInfo; rendererAvailable = false; rendererVersion = ''; + rendererDefaultImageWidth = 1000; + rendererDefaultImageHeight = 500; + rendererDefaultImageScale = 1; secretsManagerPluginEnabled = false; supportBundlesEnabled = false; http2Enabled = false; @@ -166,6 +171,16 @@ export class GrafanaBootConfig implements GrafanaConfig { tokenExpirationDayLimit: undefined; disableFrontendSandboxForPlugins: string[] = []; + sharedWithMeFolderUID: string | undefined; + rootFolderUID: string | undefined; + localFileSystemAvailable: boolean | undefined; + cloudMigrationIsTarget: boolean | undefined; + + /** + * Language used in Grafana's UI. This is after the user's preference (or deteceted locale) is resolved to one of + * Grafana's supported language. + */ + language: string | undefined; constructor(options: GrafanaBootConfig) { this.bootData = options.bootData; @@ -197,10 +212,7 @@ export class GrafanaBootConfig implements GrafanaConfig { systemDateFormats.update(this.dateFormats); } - if (this.buildInfo.env === 'development') { - overrideFeatureTogglesFromUrl(this); - } - + overrideFeatureTogglesFromUrl(this); overrideFeatureTogglesFromLocalStorage(this); if (this.featureToggles.disableAngular) { @@ -237,11 +249,28 @@ function overrideFeatureTogglesFromUrl(config: GrafanaBootConfig) { return; } + const isLocalDevEnv = config.buildInfo.env === 'development'; + + const prodUrlAllowedFeatureFlags = new Set([ + 'autoMigrateOldPanels', + 'autoMigrateGraphPanel', + 'autoMigrateTablePanel', + 'autoMigratePiechartPanel', + 'autoMigrateWorldmapPanel', + 'autoMigrateStatPanel', + 'disableAngular', + ]); + const params = new URLSearchParams(window.location.search); params.forEach((value, key) => { if (key.startsWith('__feature.')) { const featureToggles = config.featureToggles as Record<string, boolean>; const featureName = key.substring(10); + + if (!isLocalDevEnv && !prodUrlAllowedFeatureFlags.has(featureName)) { + return; + } + const toggleState = value === 'true' || value === ''; // browser rewrites true as '' if (toggleState !== featureToggles[key]) { featureToggles[featureName] = toggleState; diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index d9ff32a39c090..fe98d4ab6196a 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -15,7 +15,7 @@ export { } from './utils/plugin'; export { reportMetaAnalytics, reportInteraction, reportPageview, reportExperimentView } from './analytics/utils'; export { featureEnabled } from './utils/licensing'; -export { logInfo, logDebug, logWarning, logError } from './utils/logging'; +export { logInfo, logDebug, logWarning, logError, createMonitoringLogger } from './utils/logging'; export { DataSourceWithBackend, HealthCheckError, @@ -55,3 +55,5 @@ export { createDataSourcePluginEventProperties, } from './analytics/plugins/eventProperties'; export { usePluginInteractionReporter } from './analytics/plugins/usePluginInteractionReporter'; +export { setReturnToPreviousHook, useReturnToPrevious } from './utils/returnToPrevious'; +export { type EmbeddedDashboardProps, EmbeddedDashboard, setEmbeddedDashboard } from './components/EmbeddedDashboard'; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts index 66f2cf2abc3a9..dcd52b200b267 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts @@ -41,10 +41,15 @@ export const getPluginLinkExtensions: GetPluginExtensions<PluginExtensionLink> = }; }; -export const getPluginComponentExtensions: GetPluginExtensions<PluginExtensionComponent> = (options) => { +// This getter doesn't support the `context` option (contextual information can be passed in as component props) +export const getPluginComponentExtensions = <Props = {}>(options: { + extensionPointId: string; + limitPerPlugin?: number; +}): { extensions: Array<PluginExtensionComponent<Props>> } => { const { extensions } = getPluginExtensions(options); + const componentExtensions = extensions.filter(isPluginExtensionComponent) as Array<PluginExtensionComponent<Props>>; return { - extensions: extensions.filter(isPluginExtensionComponent), + extensions: componentExtensions, }; }; diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts index 7085f0dc47a8c..204fc4bec2c0f 100644 --- a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts @@ -80,6 +80,7 @@ enum PluginRequestHeaders { DatasourceUID = 'X-Datasource-Uid', // can be used for routing/ load balancing DashboardUID = 'X-Dashboard-Uid', // mainly useful for debugging slow queries PanelID = 'X-Panel-Id', // mainly useful for debugging slow queries + PanelPluginId = 'X-Panel-Plugin-Id', QueryGroupID = 'X-Query-Group-Id', // mainly useful to find related queries with query splitting FromExpression = 'X-Grafana-From-Expr', // used by datasources to identify expression queries SkipQueryCache = 'X-Cache-Skip', // used by datasources to skip the query cache @@ -226,6 +227,9 @@ class DataSourceWithBackend< if (request.panelId) { headers[PluginRequestHeaders.PanelID] = `${request.panelId}`; } + if (request.panelPluginId) { + headers[PluginRequestHeaders.PanelPluginId] = `${request.panelPluginId}`; + } if (request.queryGroupId) { headers[PluginRequestHeaders.QueryGroupID] = `${request.queryGroupId}`; } @@ -268,7 +272,7 @@ class DataSourceWithBackend< * Apply template variables for explore */ interpolateVariablesInQueries(queries: TQuery[], scopedVars: ScopedVars, filters?: AdHocVariableFilter[]): TQuery[] { - return queries.map((q) => this.applyTemplateVariables(q, scopedVars, filters) as TQuery); + return queries.map((q) => this.applyTemplateVariables(q, scopedVars, filters)); } /** @@ -290,7 +294,7 @@ class DataSourceWithBackend< * * @virtual */ - applyTemplateVariables(query: TQuery, scopedVars: ScopedVars, filters?: AdHocVariableFilter[]): Record<string, any> { + applyTemplateVariables(query: TQuery, scopedVars: ScopedVars, filters?: AdHocVariableFilter[]) { return query; } @@ -323,7 +327,7 @@ class DataSourceWithBackend< /** * Send a POST request to the datasource resource path */ - async postResource<T = any>( + async postResource<T = unknown>( path: string, data?: BackendSrvRequest['data'], options?: Partial<BackendSrvRequest> diff --git a/packages/grafana-runtime/src/utils/logging.ts b/packages/grafana-runtime/src/utils/logging.ts index 2643cabf9b86f..d7c94da53d839 100644 --- a/packages/grafana-runtime/src/utils/logging.ts +++ b/packages/grafana-runtime/src/utils/logging.ts @@ -1,4 +1,4 @@ -import { faro, LogLevel, LogContext } from '@grafana/faro-web-sdk'; +import { faro, LogContext, LogLevel } from '@grafana/faro-web-sdk'; import { config } from '../config'; @@ -52,6 +52,85 @@ export function logDebug(message: string, contexts?: LogContext) { */ export function logError(err: Error, contexts?: LogContext) { if (config.grafanaJavascriptAgent.enabled) { - faro.api.pushError(err); + faro.api.pushError(err, { + context: contexts, + }); + } +} + +/** + * Log a measurement + * + * @public + */ +export type MeasurementValues = Record<string, number>; +export function logMeasurement(type: string, values: MeasurementValues, context?: LogContext) { + if (config.grafanaJavascriptAgent.enabled) { + faro.api.pushMeasurement({ + type, + values, + context, + }); } } + +/** + * Creates a monitoring logger with four levels of logging methods: `logDebug`, `logInfo`, `logWarning`, and `logError`. + * These methods use `faro.api.pushX` web SDK methods to report these logs or errors to the Faro collector. + * + * @param {string} source - Identifier for the source of the log messages. + * @param {LogContext} [defaultContext] - Context to be included in every log message. + * + * @returns {Object} Logger object with four methods: + * - `logDebug(message: string, contexts?: LogContext)`: Logs a debug message. + * - `logInfo(message: string, contexts?: LogContext)`: Logs an informational message. + * - `logWarning(message: string, contexts?: LogContext)`: Logs a warning message. + * - `logError(error: Error, contexts?: LogContext)`: Logs an error message. + * - `logMeasurement(measurement: Omit<MeasurementEvent, 'timestamp'>, contexts?: LogContext)`: Logs a measurement. + * Each method combines the `defaultContext` (if provided), the `source`, and an optional `LogContext` parameter into a full context that is included with the log message. + */ +export function createMonitoringLogger(source: string, defaultContext?: LogContext) { + const createFullContext = (contexts?: LogContext) => ({ + source: source, + ...defaultContext, + ...contexts, + }); + + return { + /** + * Logs a debug message with optional additional context. + * @param {string} message - The debug message to be logged. + * @param {LogContext} [contexts] - Optional additional context to be included. + */ + logDebug: (message: string, contexts?: LogContext) => logDebug(message, createFullContext(contexts)), + + /** + * Logs an informational message with optional additional context. + * @param {string} message - The informational message to be logged. + * @param {LogContext} [contexts] - Optional additional context to be included. + */ + logInfo: (message: string, contexts?: LogContext) => logInfo(message, createFullContext(contexts)), + + /** + * Logs a warning message with optional additional context. + * @param {string} message - The warning message to be logged. + * @param {LogContext} [contexts] - Optional additional context to be included. + */ + logWarning: (message: string, contexts?: LogContext) => logWarning(message, createFullContext(contexts)), + + /** + * Logs an error with optional additional context. + * @param {Error} error - The error object to be logged. + * @param {LogContext} [contexts] - Optional additional context to be included. + */ + logError: (error: Error, contexts?: LogContext) => logError(error, createFullContext(contexts)), + + /** + * Logs an measurement with optional additional context. + * @param {MeasurementEvent} measurement - The measurement object to be recorded. + * @param {LogContext} [contexts] - Optional additional context to be included. + */ + logMeasurement: (type: string, measurement: MeasurementValues, contexts?: LogContext) => + logMeasurement(type, measurement, createFullContext(contexts)), + }; +} diff --git a/packages/grafana-runtime/src/utils/plugin.ts b/packages/grafana-runtime/src/utils/plugin.ts index ce2ab40b5b3c3..9a368e849680d 100644 --- a/packages/grafana-runtime/src/utils/plugin.ts +++ b/packages/grafana-runtime/src/utils/plugin.ts @@ -62,11 +62,3 @@ export function getPluginImportUtils(): PluginImportUtils { return pluginImportUtils; } - -// Grafana relies on RequireJS for Monaco Editor to load. -// The SystemJS AMD extra creates a global define which causes RequireJS to silently bail. -// Here we move and reset global define so Monaco Editor loader script continues to work. -// @ts-ignore -window.__grafana_amd_define = window.define; -// @ts-ignore -window.define = undefined; diff --git a/packages/grafana-runtime/src/utils/returnToPrevious.ts b/packages/grafana-runtime/src/utils/returnToPrevious.ts new file mode 100644 index 0000000000000..7810eb46c0a9b --- /dev/null +++ b/packages/grafana-runtime/src/utils/returnToPrevious.ts @@ -0,0 +1,23 @@ +type ReturnToPreviousHook = () => (title: string, href?: string) => void; + +let rtpHook: ReturnToPreviousHook | undefined = undefined; + +export const setReturnToPreviousHook = (hook: ReturnToPreviousHook) => { + rtpHook = hook; +}; + +/** + * Guidelines: + * - Only use the ‘Return to previous’ functionality when the user is sent to another context, such as from Alerting to a dashboard. + * - Specify a button title that identifies the page to return to in the most understandable way. Do not use text such as ‘Back to the previous page’. Be specific. + */ +export const useReturnToPrevious: ReturnToPreviousHook = () => { + if (!rtpHook) { + if (process.env.NODE_ENV !== 'production') { + throw new Error('useReturnToPrevious hook not found in @grafana/runtime'); + } + return () => console.error('ReturnToPrevious hook not found'); + } + + return rtpHook(); +}; diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index 0cac85c065b12..6c508c5d96e0d 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/schema", - "version": "10.3.0-pre", + "version": "11.0.0-pre", "description": "Grafana Schema Library", "keywords": [ "typescript" @@ -36,20 +36,18 @@ "postpack": "mv package.json.bak package.json" }, "devDependencies": { - "@grafana/tsconfig": "^1.2.0-rc1", - "@rollup/plugin-commonjs": "25.0.2", - "@rollup/plugin-json": "6.0.0", + "@grafana/tsconfig": "^1.3.0-rc1", "@rollup/plugin-node-resolve": "15.2.3", "esbuild": "0.18.12", "glob": "^10.2.7", - "rimraf": "5.0.1", + "rimraf": "5.0.5", "rollup": "2.79.1", "rollup-plugin-dts": "^5.0.0", "rollup-plugin-esbuild": "5.0.0", "rollup-plugin-node-externals": "^5.0.0", - "typescript": "5.2.2" + "typescript": "5.3.3" }, "dependencies": { - "tslib": "2.6.0" + "tslib": "2.6.2" } } diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts index b05a4bdf35944..e95004ac43ac8 100644 --- a/packages/grafana-schema/src/common/common.gen.ts +++ b/packages/grafana-schema/src/common/common.gen.ts @@ -8,6 +8,16 @@ // Run 'make gen-cue' from repository root to regenerate. +/** + * A topic is attached to DataFrame metadata in query results. + * This specifies where the data should be used. + */ +export enum DataTopic { + AlertStates = 'alertStates', + Annotations = 'annotations', + Series = 'series', +} + /** * TODO docs */ @@ -399,7 +409,7 @@ export interface HideableFieldConfig { /** * TODO docs */ -export enum GraphTresholdsStyleMode { +export enum GraphThresholdsStyleMode { Area = 'area', Dashed = 'dashed', DashedAndArea = 'dashed+area', @@ -413,7 +423,7 @@ export enum GraphTresholdsStyleMode { * TODO docs */ export interface GraphThresholdsStyleConfig { - mode: GraphTresholdsStyleMode; + mode: GraphThresholdsStyleMode; } /** @@ -656,6 +666,8 @@ export enum BarGaugeSizing { * TODO docs */ export interface VizTooltipOptions { + maxHeight?: number; + maxWidth?: number; mode: TooltipDisplayMode; sort: SortOrder; } @@ -675,6 +687,7 @@ export enum TableCellDisplayMode { ColorBackgroundSolid = 'color-background-solid', ColorText = 'color-text', Custom = 'custom', + DataLinks = 'data-links', Gauge = 'gauge', GradientGauge = 'gradient-gauge', Image = 'image', @@ -751,6 +764,13 @@ export interface TableImageCellOptions { type: TableCellDisplayMode.Image; } +/** + * Show data links in the cell + */ +export interface TableDataLinksCellOptions { + type: TableCellDisplayMode.DataLinks; +} + /** * Gauge cell options */ @@ -789,7 +809,7 @@ export enum TableCellHeight { * Table cell options. Each cell has a display mode * and other potential options for that display. */ -export type TableCellOptions = (TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableJsonViewCellOptions); +export type TableCellOptions = (TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableDataLinksCellOptions | TableJsonViewCellOptions); /** * Use UTC/GMT timezone diff --git a/packages/grafana-schema/src/common/data.cue b/packages/grafana-schema/src/common/data.cue new file mode 100644 index 0000000000000..33cb5ab2f4e5c --- /dev/null +++ b/packages/grafana-schema/src/common/data.cue @@ -0,0 +1,5 @@ +package common + +// A topic is attached to DataFrame metadata in query results. +// This specifies where the data should be used. +DataTopic: "series" | "annotations" | "alertStates" @cuetsy(kind="enum",memberNames="Series|Annotations|AlertStates") \ No newline at end of file diff --git a/packages/grafana-schema/src/common/mudball.cue b/packages/grafana-schema/src/common/mudball.cue index ce4c0626d4b62..d08fbeff1537e 100644 --- a/packages/grafana-schema/src/common/mudball.cue +++ b/packages/grafana-schema/src/common/mudball.cue @@ -122,11 +122,11 @@ HideableFieldConfig: { } @cuetsy(kind="interface") // TODO docs -GraphTresholdsStyleMode: "off" | "line" | "dashed" | "area" | "line+area" | "dashed+area" | "series" @cuetsy(kind="enum",memberNames="Off|Line|Dashed|Area|LineAndArea|DashedAndArea|Series") +GraphThresholdsStyleMode: "off" | "line" | "dashed" | "area" | "line+area" | "dashed+area" | "series" @cuetsy(kind="enum",memberNames="Off|Line|Dashed|Area|LineAndArea|DashedAndArea|Series") // TODO docs GraphThresholdsStyleConfig: { - mode: GraphTresholdsStyleMode + mode: GraphThresholdsStyleMode } @cuetsy(kind="interface") // TODO docs @@ -256,6 +256,8 @@ BarGaugeSizing: "auto" | "manual" @cuetsy(kind="enum") VizTooltipOptions: { mode: TooltipDisplayMode sort: SortOrder + maxWidth?: number + maxHeight?: number } @cuetsy(kind="interface") Labels: { diff --git a/packages/grafana-schema/src/common/table.cue b/packages/grafana-schema/src/common/table.cue index 27026f6d79b13..c6aa05df53e58 100644 --- a/packages/grafana-schema/src/common/table.cue +++ b/packages/grafana-schema/src/common/table.cue @@ -4,7 +4,7 @@ package common // in the table such as colored text, JSON, gauge, etc. // The color-background-solid, gradient-gauge, and lcd-gauge // modes are deprecated in favor of new cell subOptions -TableCellDisplayMode: "auto" | "color-text" | "color-background" | "color-background-solid" | "gradient-gauge" | "lcd-gauge" | "json-view" | "basic" | "image" | "gauge" | "sparkline"| "custom" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image|Gauge|Sparkline|Custom") +TableCellDisplayMode: "auto" | "color-text" | "color-background" | "color-background-solid" | "gradient-gauge" | "lcd-gauge" | "json-view" | "basic" | "image" | "gauge" | "sparkline" | "data-links" | "custom" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image|Gauge|Sparkline|DataLinks|Custom") // Display mode to the "Colored Background" display // mode for table cells. Either displays a solid color (basic mode) @@ -48,6 +48,11 @@ TableImageCellOptions: { type: TableCellDisplayMode & "image" } @cuetsy(kind="interface") +// Show data links in the cell +TableDataLinksCellOptions: { + type: TableCellDisplayMode & "data-links" +} @cuetsy(kind="interface") + // Gauge cell options TableBarGaugeCellOptions: { type: TableCellDisplayMode & "gauge" @@ -73,7 +78,7 @@ TableCellHeight: "sm" | "md" | "lg" @cuetsy(kind="enum") // Table cell options. Each cell has a display mode // and other potential options for that display. -TableCellOptions: TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableJsonViewCellOptions @cuetsy(kind="type") +TableCellOptions: TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableDataLinksCellOptions | TableJsonViewCellOptions @cuetsy(kind="type") // Field options for each field within a table (e.g 10, "The String", 64.20, etc.) // Generally defines alignment, filtering capabilties, display options, etc. diff --git a/packages/grafana-schema/src/raw/accesspolicy/x/accesspolicy_types.gen.ts b/packages/grafana-schema/src/raw/accesspolicy/x/accesspolicy_types.gen.ts index 814c5cf3eaf8a..7e18ea5077715 100644 --- a/packages/grafana-schema/src/raw/accesspolicy/x/accesspolicy_types.gen.ts +++ b/packages/grafana-schema/src/raw/accesspolicy/x/accesspolicy_types.gen.ts @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// TSResourceJenny +// TSTypesJenny // LatestMajorsOrXJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/packages/grafana-schema/src/raw/composable/alertgroups/panelcfg/x/AlertGroupsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/alertgroups/panelcfg/x/AlertGroupsPanelCfg_types.gen.ts deleted file mode 100644 index a4186f57bc535..0000000000000 --- a/packages/grafana-schema/src/raw/composable/alertgroups/panelcfg/x/AlertGroupsPanelCfg_types.gen.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// public/app/plugins/gen.go -// Using jennies: -// TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -export const pluginVersion = "10.3.0-pre"; - -export interface Options { - /** - * Name of the alertmanager used as a source for alerts - */ - alertmanager: string; - /** - * Expand all alert groups by default - */ - expandAll: boolean; - /** - * Comma-separated list of values used to filter alert results - */ - labels: string; -} diff --git a/packages/grafana-schema/src/raw/composable/annotationslist/panelcfg/x/AnnotationsListPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/annotationslist/panelcfg/x/AnnotationsListPanelCfg_types.gen.ts index 08803997ade75..e10efab2613ad 100644 --- a/packages/grafana-schema/src/raw/composable/annotationslist/panelcfg/x/AnnotationsListPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/annotationslist/panelcfg/x/AnnotationsListPanelCfg_types.gen.ts @@ -4,12 +4,11 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface Options { limit: number; diff --git a/packages/grafana-schema/src/raw/composable/azuremonitor/dataquery/x/AzureMonitorDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/azuremonitor/dataquery/x/AzureMonitorDataQuery_types.gen.ts index 712b35a32f894..d6d068b7e1eae 100644 --- a/packages/grafana-schema/src/raw/composable/azuremonitor/dataquery/x/AzureMonitorDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/azuremonitor/dataquery/x/AzureMonitorDataQuery_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "1.0.0"; +export const pluginVersion = "%VERSION%"; export interface AzureMonitorQuery extends common.DataQuery { /** diff --git a/packages/grafana-schema/src/raw/composable/barchart/panelcfg/x/BarChartPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/barchart/panelcfg/x/BarChartPanelCfg_types.gen.ts index c86a3d75477aa..2a50f0f9e9cab 100644 --- a/packages/grafana-schema/src/raw/composable/barchart/panelcfg/x/BarChartPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/barchart/panelcfg/x/BarChartPanelCfg_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip, common.OptionsWithTextFormatting { /** diff --git a/packages/grafana-schema/src/raw/composable/bargauge/panelcfg/x/BarGaugePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/bargauge/panelcfg/x/BarGaugePanelCfg_types.gen.ts index 5c9b76974086d..be73664fe4626 100644 --- a/packages/grafana-schema/src/raw/composable/bargauge/panelcfg/x/BarGaugePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/bargauge/panelcfg/x/BarGaugePanelCfg_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface Options extends common.SingleStatBaseOptions { displayMode: common.BarGaugeDisplayMode; diff --git a/packages/grafana-schema/src/raw/composable/candlestick/panelcfg/x/CandlestickPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/candlestick/panelcfg/x/CandlestickPanelCfg_types.gen.ts index d1550e115a4ac..9a89d9b2d9481 100644 --- a/packages/grafana-schema/src/raw/composable/candlestick/panelcfg/x/CandlestickPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/candlestick/panelcfg/x/CandlestickPanelCfg_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export enum VizDisplayMode { Candles = 'candles', @@ -64,7 +63,7 @@ export const defaultCandlestickColors: Partial<CandlestickColors> = { up: 'green', }; -export interface Options extends common.OptionsWithLegend { +export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip { /** * Sets the style of the candlesticks */ diff --git a/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts index ec11610309c58..a1b30c0904b39 100644 --- a/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as ui from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export enum HorizontalConstraint { Center = 'center', @@ -84,8 +83,13 @@ export interface CanvasConnection { source: ConnectionCoordinates; target: ConnectionCoordinates; targetName?: string; + vertices?: Array<ConnectionCoordinates>; } +export const defaultCanvasConnection: Partial<CanvasConnection> = { + vertices: [], +}; + export interface CanvasElementOptions { background?: BackgroundConfig; border?: LineConfig; diff --git a/packages/grafana-schema/src/raw/composable/cloudwatch/dataquery/x/CloudWatchDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/cloudwatch/dataquery/x/CloudWatchDataQuery_types.gen.ts index 70dc8cebf2df1..f184ea7b49358 100644 --- a/packages/grafana-schema/src/raw/composable/cloudwatch/dataquery/x/CloudWatchDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/cloudwatch/dataquery/x/CloudWatchDataQuery_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface MetricStat { /** diff --git a/packages/grafana-schema/src/raw/composable/dashboardlist/panelcfg/x/DashboardListPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/dashboardlist/panelcfg/x/DashboardListPanelCfg_types.gen.ts index 1fe577db3c610..2fba962cd851f 100644 --- a/packages/grafana-schema/src/raw/composable/dashboardlist/panelcfg/x/DashboardListPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/dashboardlist/panelcfg/x/DashboardListPanelCfg_types.gen.ts @@ -4,12 +4,11 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface Options { /** diff --git a/packages/grafana-schema/src/raw/composable/datagrid/panelcfg/x/DatagridPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/datagrid/panelcfg/x/DatagridPanelCfg_types.gen.ts index 3f353f364a835..77016b81efb28 100644 --- a/packages/grafana-schema/src/raw/composable/datagrid/panelcfg/x/DatagridPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/datagrid/panelcfg/x/DatagridPanelCfg_types.gen.ts @@ -4,12 +4,11 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface Options { selectedSeries: number; diff --git a/packages/grafana-schema/src/raw/composable/debug/panelcfg/x/DebugPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/debug/panelcfg/x/DebugPanelCfg_types.gen.ts index ff1ca55673819..8634d6e0b8515 100644 --- a/packages/grafana-schema/src/raw/composable/debug/panelcfg/x/DebugPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/debug/panelcfg/x/DebugPanelCfg_types.gen.ts @@ -4,12 +4,11 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export type UpdateConfig = { render: boolean, diff --git a/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts index bd1acb20ba2f6..2aeca4e52f3eb 100644 --- a/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export type BucketAggregation = (DateHistogram | Histogram | Terms | Filters | GeoHashGrid | Nested); diff --git a/packages/grafana-schema/src/raw/composable/gauge/panelcfg/x/GaugePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/gauge/panelcfg/x/GaugePanelCfg_types.gen.ts index 8f39d550f155d..dc14651339435 100644 --- a/packages/grafana-schema/src/raw/composable/gauge/panelcfg/x/GaugePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/gauge/panelcfg/x/GaugePanelCfg_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface Options extends common.SingleStatBaseOptions { minVizHeight: number; diff --git a/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts index 871d8ca1e6a80..a842a04bbf955 100644 --- a/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as ui from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface Options { basemap: ui.MapLayerOptions; diff --git a/packages/grafana-schema/src/raw/composable/googlecloudmonitoring/dataquery/x/GoogleCloudMonitoringDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/googlecloudmonitoring/dataquery/x/GoogleCloudMonitoringDataQuery_types.gen.ts index 3dcb1a8595be9..0185e3000b146 100644 --- a/packages/grafana-schema/src/raw/composable/googlecloudmonitoring/dataquery/x/GoogleCloudMonitoringDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/googlecloudmonitoring/dataquery/x/GoogleCloudMonitoringDataQuery_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "1.0.0"; +export const pluginVersion = "%VERSION%"; export interface CloudMonitoringQuery extends common.DataQuery { /** diff --git a/packages/grafana-schema/src/raw/composable/grafanapyroscope/dataquery/x/GrafanaPyroscopeDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/grafanapyroscope/dataquery/x/GrafanaPyroscopeDataQuery_types.gen.ts index 30e597171618c..1c5e39ee7168a 100644 --- a/packages/grafana-schema/src/raw/composable/grafanapyroscope/dataquery/x/GrafanaPyroscopeDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/grafanapyroscope/dataquery/x/GrafanaPyroscopeDataQuery_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "%VERSION%"; export type PyroscopeQueryType = ('metrics' | 'profile' | 'both'); diff --git a/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts index 4ed9a2f545045..97ef088e2c663 100644 --- a/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as ui from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; /** * Controls the color mode of the heatmap @@ -129,10 +128,12 @@ export interface FilterValueRange { * Controls tooltip options */ export interface HeatmapTooltip { + maxHeight?: number; + maxWidth?: number; /** - * Controls if the tooltip is shown + * Controls how the tooltip is shown */ - show: boolean; + mode: ui.TooltipDisplayMode; /** * Controls if the tooltip shows a color scale in header */ @@ -266,7 +267,7 @@ export const defaultOptions: Partial<Options> = { }, showValue: ui.VisibilityMode.Auto, tooltip: { - show: true, + mode: ui.TooltipDisplayMode.Single, yHistogram: false, showColorScale: false, }, diff --git a/packages/grafana-schema/src/raw/composable/histogram/panelcfg/x/HistogramPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/histogram/panelcfg/x/HistogramPanelCfg_types.gen.ts index 9f8052b21bf76..3d1b420d49bf7 100644 --- a/packages/grafana-schema/src/raw/composable/histogram/panelcfg/x/HistogramPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/histogram/panelcfg/x/HistogramPanelCfg_types.gen.ts @@ -4,16 +4,19 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip { + /** + * Bucket count (approx) + */ + bucketCount?: number; /** * Offset buckets by this amount */ @@ -29,6 +32,7 @@ export interface Options extends common.OptionsWithLegend, common.OptionsWithToo } export const defaultOptions: Partial<Options> = { + bucketCount: 30, bucketOffset: 0, }; diff --git a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts index 8ff4bf643a74b..6c53480ae0ff6 100644 --- a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface Options { dedupStrategy: common.LogsDedupStrategy; @@ -19,6 +18,7 @@ export interface Options { prettifyLogMessage: boolean; showCommonLabels: boolean; showLabels: boolean; + showLogContextToggle: boolean; showTime: boolean; sortOrder: common.LogsSortOrder; wrapLogMessage: boolean; diff --git a/packages/grafana-schema/src/raw/composable/loki/dataquery/x/LokiDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/loki/dataquery/x/LokiDataQuery_types.gen.ts index c1ed7748d119b..738ccee4c15f2 100644 --- a/packages/grafana-schema/src/raw/composable/loki/dataquery/x/LokiDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/loki/dataquery/x/LokiDataQuery_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export enum QueryEditorMode { Builder = 'builder', @@ -26,6 +25,7 @@ export enum LokiQueryType { export enum SupportingQueryType { DataSample = 'dataSample', + InfiniteScroll = 'infiniteScroll', LogsSample = 'logsSample', LogsVolume = 'logsVolume', } diff --git a/packages/grafana-schema/src/raw/composable/news/panelcfg/x/NewsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/news/panelcfg/x/NewsPanelCfg_types.gen.ts index b0c436d796604..22c3031560c28 100644 --- a/packages/grafana-schema/src/raw/composable/news/panelcfg/x/NewsPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/news/panelcfg/x/NewsPanelCfg_types.gen.ts @@ -4,12 +4,11 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface Options { /** diff --git a/packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts index bbc7bf9a6ad19..b1b404140b4cc 100644 --- a/packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts @@ -4,12 +4,11 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface ArcOption { /** diff --git a/packages/grafana-schema/src/raw/composable/parca/dataquery/x/ParcaDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/parca/dataquery/x/ParcaDataQuery_types.gen.ts index a4d6fd645a38a..61aeb06a3d1b7 100644 --- a/packages/grafana-schema/src/raw/composable/parca/dataquery/x/ParcaDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/parca/dataquery/x/ParcaDataQuery_types.gen.ts @@ -4,8 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/packages/grafana-schema/src/raw/composable/piechart/panelcfg/x/PieChartPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/piechart/panelcfg/x/PieChartPanelCfg_types.gen.ts index ec27259305fb7..5b1987582b7ed 100644 --- a/packages/grafana-schema/src/raw/composable/piechart/panelcfg/x/PieChartPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/piechart/panelcfg/x/PieChartPanelCfg_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; /** * Select the pie chart display style. diff --git a/packages/grafana-schema/src/raw/composable/stat/panelcfg/x/StatPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/stat/panelcfg/x/StatPanelCfg_types.gen.ts index d0abdbb27fe9d..03a4a6db04056 100644 --- a/packages/grafana-schema/src/raw/composable/stat/panelcfg/x/StatPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/stat/panelcfg/x/StatPanelCfg_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface Options extends common.SingleStatBaseOptions { colorMode: common.BigValueColorMode; diff --git a/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts index e4493d028493a..f4cbf92bc07c0 100644 --- a/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as ui from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones { /** diff --git a/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts index 0dc31c87435c8..a9900905d1980 100644 --- a/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as ui from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones { /** diff --git a/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts index c891a59b1a485..dc2f9f23d8058 100644 --- a/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as ui from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface Options { /** diff --git a/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts index 539dc05c02259..fbb226a11f3fd 100644 --- a/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "%VERSION%"; export interface TempoQuery extends common.DataQuery { filters: Array<TraceqlFilter>; @@ -44,9 +43,9 @@ export interface TempoQuery extends common.DataQuery { */ serviceMapIncludeNamespace?: boolean; /** - * Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"} + * Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"}. Providing multiple values will produce union of results for each filter, using PromQL OR operator internally. */ - serviceMapQuery?: string; + serviceMapQuery?: (string | Array<string>); /** * @deprecated Query traces by service name */ @@ -70,10 +69,7 @@ export const defaultTempoQuery: Partial<TempoQuery> = { groupBy: [], }; -/** - * search = Loki search, nativeSearch = Tempo search for backwards compatibility - */ -export type TempoQueryType = ('traceql' | 'traceqlSearch' | 'search' | 'serviceMap' | 'upload' | 'nativeSearch' | 'traceId' | 'clear'); +export type TempoQueryType = ('traceql' | 'traceqlSearch' | 'serviceMap' | 'upload' | 'nativeSearch' | 'traceId' | 'clear'); /** * The state of the TraceQL streaming search query diff --git a/packages/grafana-schema/src/raw/composable/testdata/dataquery/x/TestDataDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/testdata/dataquery/x/TestDataDataQuery_types.gen.ts index de0ca9740f027..b7f57fd7e2caa 100644 --- a/packages/grafana-schema/src/raw/composable/testdata/dataquery/x/TestDataDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/testdata/dataquery/x/TestDataDataQuery_types.gen.ts @@ -4,8 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -50,7 +49,7 @@ export interface StreamingQuery { noise: number; speed: number; spread: number; - type: ('signal' | 'logs' | 'fetch'); + type: ('signal' | 'logs' | 'fetch' | 'traces'); url?: string; } @@ -75,7 +74,8 @@ export interface SimulationQuery { export interface NodesQuery { count?: number; - type?: ('random' | 'response' | 'random edges'); + seed?: number; + type?: ('random' | 'response_small' | 'response_medium' | 'random edges' | 'feature_showcase'); } export interface USAQuery { diff --git a/packages/grafana-schema/src/raw/composable/text/panelcfg/x/TextPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/text/panelcfg/x/TextPanelCfg_types.gen.ts index 10a6554136953..0c6d63f1ccbfe 100644 --- a/packages/grafana-schema/src/raw/composable/text/panelcfg/x/TextPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/text/panelcfg/x/TextPanelCfg_types.gen.ts @@ -4,12 +4,11 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export enum TextMode { Code = 'code', diff --git a/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts index 98cc40cd5eeea..cc065b62af36e 100644 --- a/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts @@ -4,17 +4,17 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; export interface Options extends common.OptionsWithTimezones { legend: common.VizLegendOptions; + orientation?: common.VizOrientation; tooltip: common.VizTooltipOptions; } diff --git a/packages/grafana-schema/src/raw/composable/trend/panelcfg/x/TrendPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/trend/panelcfg/x/TrendPanelCfg_types.gen.ts index 7d981861db206..2e196719417ea 100644 --- a/packages/grafana-schema/src/raw/composable/trend/panelcfg/x/TrendPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/trend/panelcfg/x/TrendPanelCfg_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; /** * Identical to timeseries... except it does not have timezone settings diff --git a/packages/grafana-schema/src/raw/composable/xychart/panelcfg/x/XYChartPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/xychart/panelcfg/x/XYChartPanelCfg_types.gen.ts index 3423f7bd33644..c9a7b21117bcc 100644 --- a/packages/grafana-schema/src/raw/composable/xychart/panelcfg/x/XYChartPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/xychart/panelcfg/x/XYChartPanelCfg_types.gen.ts @@ -4,14 +4,13 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// LatestMajorsOrXJenny -// PluginEachMajorJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; -export const pluginVersion = "10.3.0-pre"; +export const pluginVersion = "11.0.0-pre"; /** * Auto is "table" in the UI @@ -57,6 +56,7 @@ export const defaultFieldConfig: Partial<FieldConfig> = { }; export interface ScatterSeriesConfig extends FieldConfig { + frame?: number; name?: string; x?: string; y?: string; diff --git a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts index 8f4854b5bcb33..9b3d3b545044f 100644 --- a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts +++ b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// TSResourceJenny +// TSTypesJenny // LatestMajorsOrXJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -126,6 +126,10 @@ export const defaultAnnotationQuery: Partial<AnnotationQuery> = { * A variable is a placeholder for a value. You can use variables in metric queries and in panel titles. */ export interface VariableModel { + /** + * Custom all value + */ + allValue?: string; /** * Shows current selected variable text/value on the dashboard */ @@ -142,6 +146,10 @@ export interface VariableModel { * Visibility configuration for the variable */ hide?: VariableHide; + /** + * Whether all value option is available or not + */ + includeAll?: boolean; /** * Optional display name */ @@ -162,7 +170,15 @@ export interface VariableModel { * Query used to fetch values for a variable */ query?: (string | Record<string, unknown>); + /** + * Options to config when to refresh a variable + */ refresh?: VariableRefresh; + /** + * Optional field, if you want to extract part of a series name or metric node segment. + * Named capture groups can be used to separate the display text and value. + */ + regex?: string; /** * Whether the variable value should be managed by URL query params or not */ @@ -178,6 +194,7 @@ export interface VariableModel { } export const defaultVariableModel: Partial<VariableModel> = { + includeAll: false, multi: false, options: [], skipUrlSync: false, @@ -332,7 +349,7 @@ export type DashboardLinkType = ('link' | 'dashboards'); * `custom`: Define the variable options manually using a comma-separated list. * `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables */ -export type VariableType = ('query' | 'adhoc' | 'constant' | 'datasource' | 'interval' | 'textbox' | 'custom' | 'system'); +export type VariableType = ('query' | 'adhoc' | 'groupby' | 'constant' | 'datasource' | 'interval' | 'textbox' | 'custom' | 'system'); /** * Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value. @@ -623,6 +640,10 @@ export interface DataTransformerConfig { * Valid options depend on the transformer id */ options: unknown; + /** + * Where to pull DataFrames from as input to transformation + */ + topic?: ('series' | 'annotations' | 'alertStates'); // replaced with common.DataTopic } /** @@ -633,15 +654,19 @@ export interface TimePickerConfig { /** * Whether timepicker is visible or not. */ - hidden: boolean; + hidden?: boolean; + /** + * Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. + */ + nowDelay?: string; /** * Interval options available in the refresh picker dropdown. */ - refresh_intervals: Array<string>; + refresh_intervals?: Array<string>; /** * Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. */ - time_options: Array<string>; + time_options?: Array<string>; } export const defaultTimePickerConfig: Partial<TimePickerConfig> = { @@ -667,6 +692,10 @@ export const defaultDashboardCursorSync: DashboardCursorSync = DashboardCursorSy * Dashboard panels are the basic visualization building blocks. */ export interface Panel { + /** + * Sets panel queries cache timeout. + */ + cacheTimeout?: string; /** * The datasource used in all targets. */ @@ -723,6 +752,10 @@ export interface Panel { * The version of the plugin that is used for this panel. This is used to find the plugin to display the panel and to migrate old panel configs. */ pluginVersion?: string; + /** + * Overrides the data source configured time-to-live for a query cache item in milliseconds + */ + queryCachingTTL?: number; /** * Name of template variable to repeat for. */ @@ -732,10 +765,6 @@ export interface Panel { * `h` for horizontal, `v` for vertical. */ repeatDirection?: ('h' | 'v'); - /** - * Tags for the panel. - */ - tags?: Array<string>; /** * Depends on the panel plugin. See the plugin documentation for details. */ @@ -781,7 +810,6 @@ export interface Panel { export const defaultPanel: Partial<Panel> = { links: [], repeatDirection: 'h', - tags: [], targets: [], transformations: [], transparent: false, @@ -1037,7 +1065,7 @@ export interface Dashboard { /** * Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". */ - refresh?: (string | false); + refresh?: string; /** * This property should only be used in dashboards defined by plugins. It is a quick check * to see if the version has changed since the last time. @@ -1068,6 +1096,10 @@ export interface Dashboard { * external url, if snapshot was shared in external grafana instance */ externalUrl: string; + /** + * original url, url of the dashboard that was snapshotted + */ + originalUrl: string; /** * Unique identifier of the snapshot */ diff --git a/packages/grafana-schema/src/raw/librarypanel/x/librarypanel_types.gen.ts b/packages/grafana-schema/src/raw/librarypanel/x/librarypanel_types.gen.ts index d3b967e7a76a5..97cba2a5c5ee5 100644 --- a/packages/grafana-schema/src/raw/librarypanel/x/librarypanel_types.gen.ts +++ b/packages/grafana-schema/src/raw/librarypanel/x/librarypanel_types.gen.ts @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// TSResourceJenny +// TSTypesJenny // LatestMajorsOrXJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/packages/grafana-schema/src/raw/preferences/x/preferences_types.gen.ts b/packages/grafana-schema/src/raw/preferences/x/preferences_types.gen.ts index 033e4233d4b12..b685f299f2ef7 100644 --- a/packages/grafana-schema/src/raw/preferences/x/preferences_types.gen.ts +++ b/packages/grafana-schema/src/raw/preferences/x/preferences_types.gen.ts @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// TSResourceJenny +// TSTypesJenny // LatestMajorsOrXJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/packages/grafana-schema/src/raw/publicdashboard/x/publicdashboard_types.gen.ts b/packages/grafana-schema/src/raw/publicdashboard/x/publicdashboard_types.gen.ts index 9a1ae8596fd71..b94e4baefe385 100644 --- a/packages/grafana-schema/src/raw/publicdashboard/x/publicdashboard_types.gen.ts +++ b/packages/grafana-schema/src/raw/publicdashboard/x/publicdashboard_types.gen.ts @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// TSResourceJenny +// TSTypesJenny // LatestMajorsOrXJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/packages/grafana-schema/src/raw/role/x/role_types.gen.ts b/packages/grafana-schema/src/raw/role/x/role_types.gen.ts index 781665e925063..88cadf639fef5 100644 --- a/packages/grafana-schema/src/raw/role/x/role_types.gen.ts +++ b/packages/grafana-schema/src/raw/role/x/role_types.gen.ts @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// TSResourceJenny +// TSTypesJenny // LatestMajorsOrXJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/packages/grafana-schema/src/raw/rolebinding/x/rolebinding_types.gen.ts b/packages/grafana-schema/src/raw/rolebinding/x/rolebinding_types.gen.ts index a41535cc5df31..ea6d65fbf4e07 100644 --- a/packages/grafana-schema/src/raw/rolebinding/x/rolebinding_types.gen.ts +++ b/packages/grafana-schema/src/raw/rolebinding/x/rolebinding_types.gen.ts @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// TSResourceJenny +// TSTypesJenny // LatestMajorsOrXJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/packages/grafana-schema/src/raw/team/x/team_types.gen.ts b/packages/grafana-schema/src/raw/team/x/team_types.gen.ts index fef0a47a87467..4f136372c3764 100644 --- a/packages/grafana-schema/src/raw/team/x/team_types.gen.ts +++ b/packages/grafana-schema/src/raw/team/x/team_types.gen.ts @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// TSResourceJenny +// TSTypesJenny // LatestMajorsOrXJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/packages/grafana-schema/src/veneer/dashboard.types.ts b/packages/grafana-schema/src/veneer/dashboard.types.ts index 5e1db960f4de2..9976f4dec9008 100644 --- a/packages/grafana-schema/src/veneer/dashboard.types.ts +++ b/packages/grafana-schema/src/veneer/dashboard.types.ts @@ -1,4 +1,4 @@ -import { DataSourceRef as CommonDataSourceRef, DataSourceRef } from '../common/common.gen'; +import { DataSourceRef as CommonDataSourceRef, DataSourceRef, DataTopic } from '../common/common.gen'; import * as raw from '../raw/dashboard/x/dashboard_types.gen'; import { DataQuery } from './common.types'; @@ -59,6 +59,7 @@ export interface MatcherConfig<TConfig = any> extends raw.MatcherConfig { export interface DataTransformerConfig<TOptions = any> extends raw.DataTransformerConfig { options: TOptions; + topic?: DataTopic; } export interface TimePickerConfig extends raw.TimePickerConfig {} diff --git a/packages/grafana-sql/package.json b/packages/grafana-sql/package.json new file mode 100644 index 0000000000000..38e603772220b --- /dev/null +++ b/packages/grafana-sql/package.json @@ -0,0 +1,54 @@ +{ + "author": "Grafana Labs", + "license": "AGPL-3.0-only", + "private": true, + "name": "@grafana/sql", + "version": "11.0.0-pre", + "repository": { + "type": "git", + "url": "http://github.com/grafana/grafana.git", + "directory": "packages/grafana-sql" + }, + "main": "src/index.ts", + "scripts": { + "typecheck": "tsc --emitDeclarationOnly false --noEmit" + }, + "dependencies": { + "@emotion/css": "11.11.2", + "@grafana/data": "11.0.0-pre", + "@grafana/experimental": "1.7.10", + "@grafana/runtime": "11.0.0-pre", + "@grafana/ui": "11.0.0-pre", + "@react-awesome-query-builder/ui": "6.4.2", + "@types/lodash": "4.17.0", + "@types/react-virtualized-auto-sizer": "1.0.4", + "@types/uuid": "9.0.8", + "immutable": "4.3.5", + "lodash": "4.17.21", + "react-use": "17.5.0", + "react-virtualized-auto-sizer": "1.0.24", + "rxjs": "7.8.1", + "sql-formatter-plus": "^1.3.6", + "tslib": "2.6.2", + "uuid": "9.0.1" + }, + "devDependencies": { + "@grafana/tsconfig": "^1.3.0-rc1", + "@testing-library/jest-dom": "^6.1.2", + "@testing-library/react": "14.2.1", + "@testing-library/react-hooks": "^8.0.1", + "@testing-library/user-event": "14.5.2", + "@types/jest": "^29.5.4", + "@types/react": "18.2.66", + "@types/systemjs": "6.13.5", + "@types/testing-library__jest-dom": "5.14.9", + "jest": "^29.6.4", + "react": "18.2.0", + "ts-jest": "29.1.2", + "ts-node": "10.9.2", + "typescript": "5.3.3" + }, + "peerDependencies": { + "@grafana/runtime": "10.4.0-pre" + } +} diff --git a/public/app/features/plugins/sql/ResponseParser.test.ts b/packages/grafana-sql/src/ResponseParser.test.ts similarity index 100% rename from public/app/features/plugins/sql/ResponseParser.test.ts rename to packages/grafana-sql/src/ResponseParser.test.ts diff --git a/public/app/features/plugins/sql/ResponseParser.ts b/packages/grafana-sql/src/ResponseParser.ts similarity index 100% rename from public/app/features/plugins/sql/ResponseParser.ts rename to packages/grafana-sql/src/ResponseParser.ts diff --git a/public/app/features/plugins/sql/components/ConfirmModal.tsx b/packages/grafana-sql/src/components/ConfirmModal.tsx similarity index 100% rename from public/app/features/plugins/sql/components/ConfirmModal.tsx rename to packages/grafana-sql/src/components/ConfirmModal.tsx diff --git a/public/app/features/plugins/sql/components/DatasetSelector.tsx b/packages/grafana-sql/src/components/DatasetSelector.tsx similarity index 100% rename from public/app/features/plugins/sql/components/DatasetSelector.tsx rename to packages/grafana-sql/src/components/DatasetSelector.tsx diff --git a/public/app/features/plugins/sql/components/ErrorBoundary.tsx b/packages/grafana-sql/src/components/ErrorBoundary.tsx similarity index 100% rename from public/app/features/plugins/sql/components/ErrorBoundary.tsx rename to packages/grafana-sql/src/components/ErrorBoundary.tsx diff --git a/public/app/features/plugins/sql/components/QueryEditor.tsx b/packages/grafana-sql/src/components/QueryEditor.tsx similarity index 97% rename from public/app/features/plugins/sql/components/QueryEditor.tsx rename to packages/grafana-sql/src/components/QueryEditor.tsx index 25c4e4779ff22..cfefaf3793572 100644 --- a/public/app/features/plugins/sql/components/QueryEditor.tsx +++ b/packages/grafana-sql/src/components/QueryEditor.tsx @@ -2,7 +2,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useAsync } from 'react-use'; import { QueryEditorProps } from '@grafana/data'; -import { EditorMode, Space } from '@grafana/experimental'; +import { EditorMode } from '@grafana/experimental'; +import { Space } from '@grafana/ui'; import { SqlDatasource } from '../datasource/SqlDatasource'; import { applyQueryDefaults } from '../defaults'; diff --git a/public/app/features/plugins/sql/components/QueryEditorFeatureFlag.utils.ts b/packages/grafana-sql/src/components/QueryEditorFeatureFlag.utils.ts similarity index 100% rename from public/app/features/plugins/sql/components/QueryEditorFeatureFlag.utils.ts rename to packages/grafana-sql/src/components/QueryEditorFeatureFlag.utils.ts diff --git a/public/app/features/plugins/sql/components/QueryHeader.tsx b/packages/grafana-sql/src/components/QueryHeader.tsx similarity index 98% rename from public/app/features/plugins/sql/components/QueryHeader.tsx rename to packages/grafana-sql/src/components/QueryHeader.tsx index 0450a67ebc4c4..c27c7a018c62b 100644 --- a/public/app/features/plugins/sql/components/QueryHeader.tsx +++ b/packages/grafana-sql/src/components/QueryHeader.tsx @@ -3,9 +3,9 @@ import { useCopyToClipboard } from 'react-use'; import { v4 as uuidv4 } from 'uuid'; import { SelectableValue } from '@grafana/data'; -import { EditorField, EditorHeader, EditorMode, EditorRow, FlexItem, InlineSelect, Space } from '@grafana/experimental'; +import { EditorField, EditorHeader, EditorMode, EditorRow, FlexItem, InlineSelect } from '@grafana/experimental'; import { reportInteraction } from '@grafana/runtime'; -import { Button, InlineSwitch, RadioButtonGroup, Tooltip } from '@grafana/ui'; +import { Button, InlineSwitch, RadioButtonGroup, Tooltip, Space } from '@grafana/ui'; import { QueryWithDefaults } from '../defaults'; import { SQLQuery, QueryFormat, QueryRowFilter, QUERY_FORMAT_OPTIONS, DB, SQLDialect } from '../types'; diff --git a/public/app/features/plugins/sql/components/SqlComponents.test.tsx b/packages/grafana-sql/src/components/SqlComponents.test.tsx similarity index 100% rename from public/app/features/plugins/sql/components/SqlComponents.test.tsx rename to packages/grafana-sql/src/components/SqlComponents.test.tsx diff --git a/public/app/features/plugins/sql/components/SqlComponents.testHelpers.ts b/packages/grafana-sql/src/components/SqlComponents.testHelpers.ts similarity index 100% rename from public/app/features/plugins/sql/components/SqlComponents.testHelpers.ts rename to packages/grafana-sql/src/components/SqlComponents.testHelpers.ts diff --git a/public/app/features/plugins/sql/components/TableSelector.tsx b/packages/grafana-sql/src/components/TableSelector.tsx similarity index 100% rename from public/app/features/plugins/sql/components/TableSelector.tsx rename to packages/grafana-sql/src/components/TableSelector.tsx diff --git a/public/app/features/plugins/sql/components/configuration/ConnectionLimits.tsx b/packages/grafana-sql/src/components/configuration/ConnectionLimits.tsx similarity index 81% rename from public/app/features/plugins/sql/components/configuration/ConnectionLimits.tsx rename to packages/grafana-sql/src/components/configuration/ConnectionLimits.tsx index 10f0eebb1472c..667bc7fe5cb96 100644 --- a/public/app/features/plugins/sql/components/configuration/ConnectionLimits.tsx +++ b/packages/grafana-sql/src/components/configuration/ConnectionLimits.tsx @@ -3,25 +3,17 @@ import React from 'react'; import { DataSourceSettings } from '@grafana/data'; import { ConfigSubSection, Stack } from '@grafana/experimental'; import { config } from '@grafana/runtime'; -import { Field, Icon, InlineLabel, Input, Label, Switch, Tooltip } from '@grafana/ui'; +import { Field, Icon, InlineLabel, Label, Switch, Tooltip } from '@grafana/ui'; import { SQLConnectionLimits, SQLOptions } from '../../types'; +import { NumberInput } from './NumberInput'; + interface Props<T> { onOptionsChange: Function; options: DataSourceSettings<SQLOptions>; } -function toNumber(text: string): number { - if (text.trim() === '') { - // calling `Number('')` returns zero, - // so we have to handle this case - return NaN; - } - - return Number(text); -} - export const ConnectionLimits = <T extends SQLConnectionLimits>(props: Props<T>) => { const { onOptionsChange, options } = props; const jsonData = options.jsonData; @@ -115,15 +107,11 @@ export const ConnectionLimits = <T extends SQLConnectionLimits>(props: Props<T>) </Label> } > - <Input - type="number" - placeholder="unlimited" - defaultValue={jsonData.maxOpenConns} - onChange={(e) => { - const newVal = toNumber(e.currentTarget.value); - if (!Number.isNaN(newVal)) { - onMaxConnectionsChanged(newVal); - } + <NumberInput + value={jsonData.maxOpenConns} + defaultValue={config.sqlConnectionLimits.maxOpenConns} + onChange={(value) => { + onMaxConnectionsChanged(value); }} width={labelWidth} /> @@ -133,7 +121,7 @@ export const ConnectionLimits = <T extends SQLConnectionLimits>(props: Props<T>) label={ <Label> <Stack gap={0.5}> - <span>Auto Max Idle</span> + <span>Auto max idle</span> <Tooltip content={ <span> @@ -176,18 +164,13 @@ export const ConnectionLimits = <T extends SQLConnectionLimits>(props: Props<T>) {autoIdle ? ( <InlineLabel width={labelWidth}>{options.jsonData.maxIdleConns}</InlineLabel> ) : ( - <Input - type="number" - placeholder="2" - defaultValue={jsonData.maxIdleConns} - onChange={(e) => { - const newVal = toNumber(e.currentTarget.value); - if (!Number.isNaN(newVal)) { - onJSONDataNumberChanged('maxIdleConns')(newVal); - } + <NumberInput + value={jsonData.maxIdleConns} + defaultValue={config.sqlConnectionLimits.maxIdleConns} + onChange={(value) => { + onJSONDataNumberChanged('maxIdleConns')(value); }} width={labelWidth} - disabled={autoIdle} /> )} </Field> @@ -211,15 +194,11 @@ export const ConnectionLimits = <T extends SQLConnectionLimits>(props: Props<T>) </Label> } > - <Input - type="number" - placeholder="14400" - defaultValue={jsonData.connMaxLifetime} - onChange={(e) => { - const newVal = toNumber(e.currentTarget.value); - if (!Number.isNaN(newVal)) { - onJSONDataNumberChanged('connMaxLifetime')(newVal); - } + <NumberInput + value={jsonData.connMaxLifetime} + defaultValue={config.sqlConnectionLimits.connMaxLifetime} + onChange={(value) => { + onJSONDataNumberChanged('connMaxLifetime')(value); }} width={labelWidth} /> diff --git a/public/app/features/plugins/sql/components/configuration/Divider.tsx b/packages/grafana-sql/src/components/configuration/Divider.tsx similarity index 100% rename from public/app/features/plugins/sql/components/configuration/Divider.tsx rename to packages/grafana-sql/src/components/configuration/Divider.tsx diff --git a/packages/grafana-sql/src/components/configuration/NumberInput.tsx b/packages/grafana-sql/src/components/configuration/NumberInput.tsx new file mode 100644 index 0000000000000..875dc921116b0 --- /dev/null +++ b/packages/grafana-sql/src/components/configuration/NumberInput.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import { Input } from '@grafana/ui/src/components/Input/Input'; + +type NumberInputProps = { + value: number; + defaultValue: number; + onChange: (value: number) => void; + width: number; +}; + +export function NumberInput({ value, defaultValue, onChange, width }: NumberInputProps) { + const [isEmpty, setIsEmpty] = React.useState(false); + return ( + <Input + type="number" + placeholder={String(defaultValue)} + value={isEmpty ? '' : value} + onChange={(e) => { + if (e.currentTarget.value?.trim() === '') { + setIsEmpty(true); + onChange(defaultValue); + } else { + setIsEmpty(false); + const newVal = Number(e.currentTarget.value); + if (!Number.isNaN(newVal)) { + onChange(newVal); + } + } + }} + width={width} + /> + ); +} diff --git a/public/app/features/plugins/sql/components/configuration/TLSSecretsConfig.tsx b/packages/grafana-sql/src/components/configuration/TLSSecretsConfig.tsx similarity index 100% rename from public/app/features/plugins/sql/components/configuration/TLSSecretsConfig.tsx rename to packages/grafana-sql/src/components/configuration/TLSSecretsConfig.tsx diff --git a/public/app/features/plugins/sql/components/configuration/useMigrateDatabaseFields.test.ts b/packages/grafana-sql/src/components/configuration/useMigrateDatabaseFields.test.ts similarity index 96% rename from public/app/features/plugins/sql/components/configuration/useMigrateDatabaseFields.test.ts rename to packages/grafana-sql/src/components/configuration/useMigrateDatabaseFields.test.ts index cda44ce59245e..a193b4a273e51 100644 --- a/public/app/features/plugins/sql/components/configuration/useMigrateDatabaseFields.test.ts +++ b/packages/grafana-sql/src/components/configuration/useMigrateDatabaseFields.test.ts @@ -16,6 +16,9 @@ jest.mock('@grafana/runtime', () => { }, }, logDebug: jest.fn(), + createMonitoringLogger: jest.fn().mockReturnValue({ + logDebug: jest.fn(), + }), }; }); diff --git a/public/app/features/plugins/sql/components/configuration/useMigrateDatabaseFields.ts b/packages/grafana-sql/src/components/configuration/useMigrateDatabaseFields.ts similarity index 90% rename from public/app/features/plugins/sql/components/configuration/useMigrateDatabaseFields.ts rename to packages/grafana-sql/src/components/configuration/useMigrateDatabaseFields.ts index 2a7ffbe845833..fec8770975368 100644 --- a/public/app/features/plugins/sql/components/configuration/useMigrateDatabaseFields.ts +++ b/packages/grafana-sql/src/components/configuration/useMigrateDatabaseFields.ts @@ -1,9 +1,10 @@ import { useEffect } from 'react'; import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; -import { logDebug, config } from '@grafana/runtime'; +import { config } from '@grafana/runtime'; import { SQLOptions } from '../../types'; +import { sqlPluginLogger } from '../../utils/logging'; /** * 1. Moves the database field from the options object to jsonData.database and empties the database field. @@ -20,7 +21,7 @@ export function useMigrateDatabaseFields<T extends SQLOptions, S = {}>({ // Migrate the database field from the column into the jsonData object if (options.database) { - logDebug(`Migrating from options.database with value ${options.database} for ${options.name}`); + sqlPluginLogger.logDebug(`Migrating from options.database with value ${options.database} for ${options.name}`); newOptions.database = ''; newOptions.jsonData = { ...jsonData, database: options.database }; optionsUpdated = true; @@ -35,7 +36,7 @@ export function useMigrateDatabaseFields<T extends SQLOptions, S = {}>({ ) { const { maxOpenConns, maxIdleConns } = config.sqlConnectionLimits; - logDebug( + sqlPluginLogger.logDebug( `Setting default max open connections to ${maxOpenConns} and setting max idle connection to ${maxIdleConns}` ); diff --git a/public/app/features/plugins/sql/components/index.ts b/packages/grafana-sql/src/components/index.ts similarity index 100% rename from public/app/features/plugins/sql/components/index.ts rename to packages/grafana-sql/src/components/index.ts diff --git a/public/app/features/plugins/sql/components/query-editor-raw/QueryEditorRaw.tsx b/packages/grafana-sql/src/components/query-editor-raw/QueryEditorRaw.tsx similarity index 100% rename from public/app/features/plugins/sql/components/query-editor-raw/QueryEditorRaw.tsx rename to packages/grafana-sql/src/components/query-editor-raw/QueryEditorRaw.tsx diff --git a/public/app/features/plugins/sql/components/query-editor-raw/QueryToolbox.tsx b/packages/grafana-sql/src/components/query-editor-raw/QueryToolbox.tsx similarity index 100% rename from public/app/features/plugins/sql/components/query-editor-raw/QueryToolbox.tsx rename to packages/grafana-sql/src/components/query-editor-raw/QueryToolbox.tsx diff --git a/public/app/features/plugins/sql/components/query-editor-raw/QueryValidator.tsx b/packages/grafana-sql/src/components/query-editor-raw/QueryValidator.tsx similarity index 100% rename from public/app/features/plugins/sql/components/query-editor-raw/QueryValidator.tsx rename to packages/grafana-sql/src/components/query-editor-raw/QueryValidator.tsx diff --git a/public/app/features/plugins/sql/components/query-editor-raw/README.md b/packages/grafana-sql/src/components/query-editor-raw/README.md similarity index 100% rename from public/app/features/plugins/sql/components/query-editor-raw/README.md rename to packages/grafana-sql/src/components/query-editor-raw/README.md diff --git a/public/app/features/plugins/sql/components/query-editor-raw/RawEditor.tsx b/packages/grafana-sql/src/components/query-editor-raw/RawEditor.tsx similarity index 100% rename from public/app/features/plugins/sql/components/query-editor-raw/RawEditor.tsx rename to packages/grafana-sql/src/components/query-editor-raw/RawEditor.tsx diff --git a/public/app/features/plugins/sql/components/visual-query-builder/AwesomeQueryBuilder.tsx b/packages/grafana-sql/src/components/visual-query-builder/AwesomeQueryBuilder.tsx similarity index 100% rename from public/app/features/plugins/sql/components/visual-query-builder/AwesomeQueryBuilder.tsx rename to packages/grafana-sql/src/components/visual-query-builder/AwesomeQueryBuilder.tsx diff --git a/public/app/features/plugins/sql/components/visual-query-builder/GroupByRow.tsx b/packages/grafana-sql/src/components/visual-query-builder/GroupByRow.tsx similarity index 100% rename from public/app/features/plugins/sql/components/visual-query-builder/GroupByRow.tsx rename to packages/grafana-sql/src/components/visual-query-builder/GroupByRow.tsx diff --git a/public/app/features/plugins/sql/components/visual-query-builder/OrderByRow.tsx b/packages/grafana-sql/src/components/visual-query-builder/OrderByRow.tsx similarity index 95% rename from public/app/features/plugins/sql/components/visual-query-builder/OrderByRow.tsx rename to packages/grafana-sql/src/components/visual-query-builder/OrderByRow.tsx index 58c8ce3158abc..224f7e5c3fc23 100644 --- a/public/app/features/plugins/sql/components/visual-query-builder/OrderByRow.tsx +++ b/packages/grafana-sql/src/components/visual-query-builder/OrderByRow.tsx @@ -2,8 +2,8 @@ import { uniqueId } from 'lodash'; import React, { useCallback } from 'react'; import { SelectableValue, toOption } from '@grafana/data'; -import { EditorField, InputGroup, Space } from '@grafana/experimental'; -import { Input, RadioButtonGroup, Select } from '@grafana/ui'; +import { EditorField, InputGroup } from '@grafana/experimental'; +import { Input, RadioButtonGroup, Select, Space } from '@grafana/ui'; import { SQLExpression } from '../../types'; import { setPropertyField } from '../../utils/sql.utils'; diff --git a/public/app/features/plugins/sql/components/visual-query-builder/Preview.tsx b/packages/grafana-sql/src/components/visual-query-builder/Preview.tsx similarity index 100% rename from public/app/features/plugins/sql/components/visual-query-builder/Preview.tsx rename to packages/grafana-sql/src/components/visual-query-builder/Preview.tsx diff --git a/public/app/features/plugins/sql/components/visual-query-builder/SQLGroupByRow.tsx b/packages/grafana-sql/src/components/visual-query-builder/SQLGroupByRow.tsx similarity index 100% rename from public/app/features/plugins/sql/components/visual-query-builder/SQLGroupByRow.tsx rename to packages/grafana-sql/src/components/visual-query-builder/SQLGroupByRow.tsx diff --git a/public/app/features/plugins/sql/components/visual-query-builder/SQLOrderByRow.tsx b/packages/grafana-sql/src/components/visual-query-builder/SQLOrderByRow.tsx similarity index 100% rename from public/app/features/plugins/sql/components/visual-query-builder/SQLOrderByRow.tsx rename to packages/grafana-sql/src/components/visual-query-builder/SQLOrderByRow.tsx diff --git a/public/app/features/plugins/sql/components/visual-query-builder/SQLSelectRow.tsx b/packages/grafana-sql/src/components/visual-query-builder/SQLSelectRow.tsx similarity index 100% rename from public/app/features/plugins/sql/components/visual-query-builder/SQLSelectRow.tsx rename to packages/grafana-sql/src/components/visual-query-builder/SQLSelectRow.tsx diff --git a/public/app/features/plugins/sql/components/visual-query-builder/SQLWhereRow.tsx b/packages/grafana-sql/src/components/visual-query-builder/SQLWhereRow.tsx similarity index 100% rename from public/app/features/plugins/sql/components/visual-query-builder/SQLWhereRow.tsx rename to packages/grafana-sql/src/components/visual-query-builder/SQLWhereRow.tsx diff --git a/public/app/features/plugins/sql/components/visual-query-builder/SelectRow.tsx b/packages/grafana-sql/src/components/visual-query-builder/SelectRow.tsx similarity index 100% rename from public/app/features/plugins/sql/components/visual-query-builder/SelectRow.tsx rename to packages/grafana-sql/src/components/visual-query-builder/SelectRow.tsx diff --git a/public/app/features/plugins/sql/components/visual-query-builder/VisualEditor.tsx b/packages/grafana-sql/src/components/visual-query-builder/VisualEditor.tsx similarity index 100% rename from public/app/features/plugins/sql/components/visual-query-builder/VisualEditor.tsx rename to packages/grafana-sql/src/components/visual-query-builder/VisualEditor.tsx diff --git a/public/app/features/plugins/sql/components/visual-query-builder/WhereRow.tsx b/packages/grafana-sql/src/components/visual-query-builder/WhereRow.tsx similarity index 100% rename from public/app/features/plugins/sql/components/visual-query-builder/WhereRow.tsx rename to packages/grafana-sql/src/components/visual-query-builder/WhereRow.tsx diff --git a/public/app/features/plugins/sql/components/visual-query-builder/index.ts b/packages/grafana-sql/src/components/visual-query-builder/index.ts similarity index 100% rename from public/app/features/plugins/sql/components/visual-query-builder/index.ts rename to packages/grafana-sql/src/components/visual-query-builder/index.ts diff --git a/public/app/features/plugins/sql/constants.ts b/packages/grafana-sql/src/constants.ts similarity index 100% rename from public/app/features/plugins/sql/constants.ts rename to packages/grafana-sql/src/constants.ts diff --git a/public/app/features/plugins/sql/datasource/SqlDatasource.ts b/packages/grafana-sql/src/datasource/SqlDatasource.ts similarity index 95% rename from public/app/features/plugins/sql/datasource/SqlDatasource.ts rename to packages/grafana-sql/src/datasource/SqlDatasource.ts index 0d4540e91ce43..ce5d3bc7ea2a3 100644 --- a/public/app/features/plugins/sql/datasource/SqlDatasource.ts +++ b/packages/grafana-sql/src/datasource/SqlDatasource.ts @@ -9,7 +9,6 @@ import { DataQueryRequest, DataQueryResponse, DataSourceInstanceSettings, - DataSourceRef, MetricFindValue, ScopedVars, CoreApp, @@ -117,10 +116,7 @@ export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLO return !query.hide; } - applyTemplateVariables( - target: SQLQuery, - scopedVars: ScopedVars - ): Record<string, string | DataSourceRef | SQLQuery['format']> { + applyTemplateVariables(target: SQLQuery, scopedVars: ScopedVars) { return { refId: target.refId, datasource: this.getRef(), @@ -212,7 +208,15 @@ export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLO format: QueryFormat.Table, }; - const response = await this.runMetaQuery(interpolatedQuery, range); + // NOTE: we can remove this try-catch when https://github.com/grafana/grafana/issues/82250 + // is fixed. + let response; + try { + response = await this.runMetaQuery(interpolatedQuery, range); + } catch (error) { + console.error(error); + throw new Error('error when executing the sql query'); + } return this.getResponseParser().transformMetricFindResponse(response); } diff --git a/public/app/features/plugins/sql/defaults.ts b/packages/grafana-sql/src/defaults.ts similarity index 100% rename from public/app/features/plugins/sql/defaults.ts rename to packages/grafana-sql/src/defaults.ts diff --git a/public/app/features/plugins/sql/expressions.ts b/packages/grafana-sql/src/expressions.ts similarity index 100% rename from public/app/features/plugins/sql/expressions.ts rename to packages/grafana-sql/src/expressions.ts diff --git a/packages/grafana-sql/src/index.ts b/packages/grafana-sql/src/index.ts new file mode 100644 index 0000000000000..a88463c3775b8 --- /dev/null +++ b/packages/grafana-sql/src/index.ts @@ -0,0 +1,22 @@ +export type { + DB, + RAQBFieldTypes, + SQLExpression, + SQLOptions, + SQLQuery, + SqlQueryModel, + SQLSelectableValue, +} from './types'; +export { QueryFormat } from './types'; // this is an enum, we cannot export-type it +export { SqlDatasource } from './datasource/SqlDatasource'; +export { formatSQL } from './utils/formatSQL'; +export { ConnectionLimits } from './components/configuration/ConnectionLimits'; +export { Divider } from './components/configuration/Divider'; +export { TLSSecretsConfig } from './components/configuration/TLSSecretsConfig'; +export { useMigrateDatabaseFields } from './components/configuration/useMigrateDatabaseFields'; +export { SqlQueryEditor } from './components/QueryEditor'; +export type { QueryHeaderProps } from './components/QueryHeader'; +export { createSelectClause, haveColumns } from './utils/sql.utils'; +export { applyQueryDefaults } from './defaults'; +export { makeVariable } from './utils/testHelpers'; +export { QueryEditorExpressionType } from './expressions'; diff --git a/public/app/features/plugins/sql/types.ts b/packages/grafana-sql/src/types.ts similarity index 100% rename from public/app/features/plugins/sql/types.ts rename to packages/grafana-sql/src/types.ts diff --git a/public/app/features/plugins/sql/utils/formatSQL.ts b/packages/grafana-sql/src/utils/formatSQL.ts similarity index 100% rename from public/app/features/plugins/sql/utils/formatSQL.ts rename to packages/grafana-sql/src/utils/formatSQL.ts diff --git a/packages/grafana-sql/src/utils/logging.ts b/packages/grafana-sql/src/utils/logging.ts new file mode 100644 index 0000000000000..9590018435918 --- /dev/null +++ b/packages/grafana-sql/src/utils/logging.ts @@ -0,0 +1,3 @@ +import { createMonitoringLogger } from '@grafana/runtime'; + +export const sqlPluginLogger = createMonitoringLogger('features.plugins.sql'); diff --git a/public/app/features/plugins/sql/utils/migration.test.ts b/packages/grafana-sql/src/utils/migration.test.ts similarity index 100% rename from public/app/features/plugins/sql/utils/migration.test.ts rename to packages/grafana-sql/src/utils/migration.test.ts diff --git a/public/app/features/plugins/sql/utils/migration.ts b/packages/grafana-sql/src/utils/migration.ts similarity index 100% rename from public/app/features/plugins/sql/utils/migration.ts rename to packages/grafana-sql/src/utils/migration.ts diff --git a/public/app/features/plugins/sql/utils/sql.utils.ts b/packages/grafana-sql/src/utils/sql.utils.ts similarity index 100% rename from public/app/features/plugins/sql/utils/sql.utils.ts rename to packages/grafana-sql/src/utils/sql.utils.ts diff --git a/public/app/features/plugins/sql/utils/testHelpers.ts b/packages/grafana-sql/src/utils/testHelpers.ts similarity index 100% rename from public/app/features/plugins/sql/utils/testHelpers.ts rename to packages/grafana-sql/src/utils/testHelpers.ts diff --git a/public/app/features/plugins/sql/utils/useSqlChange.ts b/packages/grafana-sql/src/utils/useSqlChange.ts similarity index 100% rename from public/app/features/plugins/sql/utils/useSqlChange.ts rename to packages/grafana-sql/src/utils/useSqlChange.ts diff --git a/packages/grafana-sql/tsconfig.json b/packages/grafana-sql/tsconfig.json new file mode 100644 index 0000000000000..a2f6548df35e4 --- /dev/null +++ b/packages/grafana-sql/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "declarationDir": "./compiled", + "emitDeclarationOnly": true, + "isolatedModules": true, + "strict": true, + "rootDirs": ["."] + }, + "exclude": ["dist/**/*"], + "extends": "@grafana/tsconfig", + "include": ["src/**/*.ts*", "../../public/app/types/*.d.ts", "../grafana-ui/src/types/*.d.ts"] +} diff --git a/packages/grafana-ui/.storybook/preview.ts b/packages/grafana-ui/.storybook/preview.ts index effdab3a18f99..e58cfed723017 100644 --- a/packages/grafana-ui/.storybook/preview.ts +++ b/packages/grafana-ui/.storybook/preview.ts @@ -50,6 +50,10 @@ const preview: Preview = { // We should be able to use the builtin alphabetical sort, but is broken in SB 7.0 // https://github.com/storybookjs/storybook/issues/22470 storySort: (a, b) => { + // Skip sorting for stories with nosort tag + if (a.tags.includes('nosort') || b.tags.includes('nosort')) { + return 0; + } if (a.title.startsWith('Docs Overview')) { if (b.title.startsWith('Docs Overview')) { return 0; diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 62054c80636bc..c1f46dc6531be 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/ui", - "version": "10.3.0-pre", + "version": "11.0.0-pre", "description": "Grafana Components Library", "keywords": [ "grafana", @@ -48,73 +48,69 @@ ], "dependencies": { "@emotion/css": "11.11.2", - "@emotion/react": "11.11.1", - "@grafana/data": "10.3.0-pre", - "@grafana/e2e-selectors": "10.3.0-pre", - "@grafana/faro-web-sdk": "^1.3.5", - "@grafana/schema": "10.3.0-pre", - "@leeoniya/ufuzzy": "1.0.13", + "@emotion/react": "11.11.4", + "@floating-ui/react": "0.26.9", + "@grafana/data": "11.0.0-pre", + "@grafana/e2e-selectors": "11.0.0-pre", + "@grafana/faro-web-sdk": "^1.3.6", + "@grafana/schema": "11.0.0-pre", + "@leeoniya/ufuzzy": "1.0.14", "@monaco-editor/react": "4.6.0", "@popperjs/core": "2.11.8", - "@react-aria/button": "3.8.0", - "@react-aria/dialog": "3.5.3", - "@react-aria/focus": "3.13.0", - "@react-aria/menu": "3.10.0", - "@react-aria/overlays": "3.15.0", - "@react-aria/utils": "3.18.0", - "@react-stately/menu": "3.5.3", + "@react-aria/dialog": "3.5.12", + "@react-aria/focus": "3.16.2", + "@react-aria/overlays": "3.21.1", + "@react-aria/utils": "3.23.2", "ansicolor": "1.1.100", "calculate-size": "1.1.1", - "classnames": "2.3.2", - "d3": "7.8.5", - "date-fns": "2.30.0", + "classnames": "2.5.1", + "d3": "7.9.0", + "date-fns": "3.5.0", "hoist-non-react-statics": "3.3.2", - "i18next": "^22.0.0", + "i18next": "^23.0.0", "i18next-browser-languagedetector": "^7.0.2", - "immutable": "4.3.1", + "immutable": "4.3.5", "is-hotkey": "0.2.0", - "jquery": "3.7.0", + "jquery": "3.7.1", "lodash": "4.17.21", "micro-memoize": "^4.1.2", - "moment": "2.29.4", + "moment": "2.30.1", "monaco-editor": "0.34.0", "ol": "7.4.0", "prismjs": "1.29.0", - "rc-cascader": "3.20.0", - "rc-drawer": "6.5.2", - "rc-slider": "10.3.1", + "rc-cascader": "3.24.0", + "rc-drawer": "7.1.0", + "rc-slider": "10.5.0", "rc-time-picker": "^3.7.3", - "rc-tooltip": "6.1.1", + "rc-tooltip": "6.2.0", "react-beautiful-dnd": "13.1.1", - "react-calendar": "4.6.0", + "react-calendar": "4.8.0", "react-colorful": "5.6.1", "react-custom-scrollbars-2": "4.5.0", "react-dropzone": "14.2.3", "react-highlight-words": "0.20.0", - "react-hook-form": "7.5.3", + "react-hook-form": "^7.49.2", "react-i18next": "^12.0.0", "react-inlinesvg": "3.0.2", - "react-loading-skeleton": "3.3.1", - "react-popper": "2.3.0", - "react-popper-tooltip": "4.4.2", + "react-loading-skeleton": "3.4.0", "react-router-dom": "5.3.3", - "react-select": "5.7.4", + "react-select": "5.8.0", "react-table": "7.8.0", "react-transition-group": "4.4.5", - "react-use": "17.4.0", - "react-window": "1.8.9", + "react-use": "17.5.0", + "react-window": "1.8.10", "rxjs": "7.8.1", "slate": "0.47.9", "slate-plain-serializer": "0.7.13", "slate-react": "0.22.10", "tinycolor2": "1.6.0", - "tslib": "2.6.0", - "uplot": "1.6.28", - "uuid": "9.0.0" + "tslib": "2.6.2", + "uplot": "1.6.30", + "uuid": "9.0.1" }, "devDependencies": { - "@babel/core": "7.23.2", - "@grafana/tsconfig": "^1.2.0-rc1", + "@babel/core": "7.24.0", + "@grafana/tsconfig": "^1.3.0-rc1", "@rollup/plugin-node-resolve": "15.2.3", "@storybook/addon-a11y": "7.4.5", "@storybook/addon-actions": "7.4.5", @@ -131,65 +127,65 @@ "@storybook/react": "7.4.5", "@storybook/react-webpack5": "7.4.5", "@storybook/theming": "7.4.5", - "@testing-library/dom": "9.3.3", - "@testing-library/jest-dom": "6.1.4", - "@testing-library/react": "14.0.0", - "@testing-library/user-event": "14.5.1", + "@testing-library/dom": "9.3.4", + "@testing-library/jest-dom": "6.4.2", + "@testing-library/react": "14.2.1", + "@testing-library/user-event": "14.5.2", "@types/common-tags": "^1.8.0", - "@types/d3": "7.4.0", - "@types/hoist-non-react-statics": "3.3.1", - "@types/is-hotkey": "0.1.7", - "@types/jest": "29.5.4", - "@types/jquery": "3.5.16", - "@types/lodash": "4.14.195", - "@types/mock-raf": "1.0.3", - "@types/node": "20.8.10", - "@types/prismjs": "1.26.0", - "@types/react": "18.2.15", - "@types/react-beautiful-dnd": "13.1.4", + "@types/d3": "7.4.3", + "@types/hoist-non-react-statics": "3.3.5", + "@types/is-hotkey": "0.1.10", + "@types/jest": "29.5.12", + "@types/jquery": "3.5.29", + "@types/lodash": "4.17.0", + "@types/mock-raf": "1.0.6", + "@types/node": "20.11.28", + "@types/prismjs": "1.26.3", + "@types/react": "18.2.66", + "@types/react-beautiful-dnd": "13.1.8", "@types/react-calendar": "3.9.0", - "@types/react-color": "3.0.6", - "@types/react-dom": "18.2.7", - "@types/react-highlight-words": "0.16.4", + "@types/react-color": "3.0.12", + "@types/react-dom": "18.2.22", + "@types/react-highlight-words": "0.16.7", "@types/react-router-dom": "5.3.3", - "@types/react-table": "7.7.14", - "@types/react-test-renderer": "18.0.0", - "@types/react-transition-group": "4.4.6", - "@types/react-window": "1.8.5", + "@types/react-table": "7.7.19", + "@types/react-test-renderer": "18.0.7", + "@types/react-transition-group": "4.4.10", + "@types/react-window": "1.8.8", "@types/slate": "0.47.11", - "@types/slate-plain-serializer": "0.7.2", + "@types/slate-plain-serializer": "0.7.5", "@types/slate-react": "0.22.9", - "@types/testing-library__jest-dom": "5.14.8", - "@types/tinycolor2": "1.4.3", - "@types/uuid": "9.0.2", + "@types/testing-library__jest-dom": "5.14.9", + "@types/tinycolor2": "1.4.6", + "@types/uuid": "9.0.8", "common-tags": "1.8.2", - "core-js": "3.33.0", - "css-loader": "6.8.1", - "csstype": "3.1.2", + "core-js": "3.36.0", + "css-loader": "6.10.0", + "csstype": "3.1.3", "esbuild": "0.18.12", - "expose-loader": "4.1.0", + "expose-loader": "5.0.0", "mock-raf": "1.0.1", "process": "^0.11.10", "react": "18.2.0", "react-dom": "18.2.0", "react-select-event": "^5.1.0", "react-test-renderer": "18.2.0", - "rimraf": "5.0.1", + "rimraf": "5.0.5", "rollup": "2.79.1", "rollup-plugin-copy": "3.5.0", "rollup-plugin-dts": "^5.0.0", "rollup-plugin-esbuild": "5.0.0", "rollup-plugin-node-externals": "^5.0.0", - "sass-loader": "13.3.2", + "sass-loader": "14.1.1", "storybook": "7.4.5", "storybook-addon-turbo-build": "2.0.1", "storybook-dark-mode": "3.0.1", - "style-loader": "3.3.3", - "typescript": "5.2.2", - "webpack": "5.89.0" + "style-loader": "3.3.4", + "typescript": "5.3.3", + "webpack": "5.90.3" }, "peerDependencies": { - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "react": "^18.0.0", + "react-dom": "^18.0.0" } } diff --git a/packages/grafana-ui/src/components/Alert/Alert.tsx b/packages/grafana-ui/src/components/Alert/Alert.tsx index fb0cf2c080c12..98bbf23669ef0 100644 --- a/packages/grafana-ui/src/components/Alert/Alert.tsx +++ b/packages/grafana-ui/src/components/Alert/Alert.tsx @@ -74,7 +74,9 @@ export const Alert = React.forwardRef<HTMLDivElement, Props>( </Box> <Box paddingY={1} grow={1}> - <Text weight="medium">{title}</Text> + <Text color="primary" weight="medium"> + {title} + </Text> {children && <div className={styles.content}>{children}</div>} </Box> {/* If onRemove is specified, giving preference to onRemove */} @@ -151,6 +153,7 @@ const getStyles = ( color: color.text, }), content: css({ + color: theme.colors.text.primary, paddingTop: hasTitle ? theme.spacing(0.5) : 0, maxHeight: '50vh', overflowY: 'auto', diff --git a/packages/grafana-ui/src/components/AutoSaveField/AutoSaveField.tsx b/packages/grafana-ui/src/components/AutoSaveField/AutoSaveField.tsx index 9aa84af51e08d..da268d610944c 100644 --- a/packages/grafana-ui/src/components/AutoSaveField/AutoSaveField.tsx +++ b/packages/grafana-ui/src/components/AutoSaveField/AutoSaveField.tsx @@ -104,17 +104,12 @@ export function AutoSaveField<T = string>(props: Props<T>) { )} </Field> {fieldState.isLoading && ( - <InlineToast referenceElement={fieldRef.current} placement="right" alternativePlacement="bottom"> + <InlineToast referenceElement={fieldRef.current} placement="right"> Saving <EllipsisAnimated /> </InlineToast> )} {fieldState.showSuccess && ( - <InlineToast - suffixIcon={'check'} - referenceElement={fieldRef.current} - placement="right" - alternativePlacement="bottom" - > + <InlineToast suffixIcon={'check'} referenceElement={fieldRef.current} placement="right"> Saved! </InlineToast> )} diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.story.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.story.tsx index 34bf52b492057..3527c9bfe76dd 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValue.story.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValue.story.tsx @@ -57,6 +57,50 @@ interface StoryProps extends Partial<Props> { valueText: string; } +export const ApplyNoValue: Story<StoryProps> = ({ + valueText, + title, + colorMode, + graphMode, + height, + width, + color, + textMode, + justifyMode, +}) => { + const theme = useTheme2(); + const sparkline: FieldSparkline = { + y: { + name: '', + values: [1, 2, 3, null, null], + type: FieldType.number, + state: { range: { min: 1, max: 4, delta: 3 } }, + config: { + noValue: '0', + }, + }, + }; + + return ( + <BigValue + theme={theme} + width={width} + height={height} + colorMode={colorMode} + graphMode={graphMode} + textMode={textMode} + justifyMode={justifyMode} + value={{ + text: valueText, + numeric: 5022, + color: color, + title, + }} + sparkline={graphMode === BigValueGraphMode.None ? undefined : sparkline} + /> + ); +}; + export const Basic: Story<StoryProps> = ({ valueText, title, @@ -111,4 +155,16 @@ Basic.args = { textMode: BigValueTextMode.Auto, }; +ApplyNoValue.args = { + valueText: '$5022', + title: 'Total Earnings', + colorMode: BigValueColorMode.Value, + graphMode: BigValueGraphMode.Area, + justifyMode: BigValueJustifyMode.Auto, + width: 400, + height: 300, + color: 'red', + textMode: BigValueTextMode.Auto, +}; + export default meta; diff --git a/packages/grafana-ui/src/components/Button/Button.tsx b/packages/grafana-ui/src/components/Button/Button.tsx index b2f8bf3bf32aa..7423a05a4d3e4 100644 --- a/packages/grafana-ui/src/components/Button/Button.tsx +++ b/packages/grafana-ui/src/components/Button/Button.tsx @@ -1,4 +1,4 @@ -import { css, CSSObject, cx } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import React, { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react'; import { GrafanaTheme2, ThemeRichColor } from '@grafana/data'; @@ -224,7 +224,7 @@ export const getButtonStyles = (props: StyleProps) => { }; }; -function getButtonVariantStyles(theme: GrafanaTheme2, color: ThemeRichColor, fill: ButtonFill): CSSObject { +function getButtonVariantStyles(theme: GrafanaTheme2, color: ThemeRichColor, fill: ButtonFill) { let outlineBorderColor = color.border; let borderColor = 'transparent'; let hoverBorderColor = 'transparent'; @@ -293,7 +293,7 @@ function getButtonVariantStyles(theme: GrafanaTheme2, color: ThemeRichColor, fil } function getPropertiesForDisabled(theme: GrafanaTheme2, variant: ButtonVariant, fill: ButtonFill) { - const disabledStyles: CSSObject = { + const disabledStyles = { cursor: 'not-allowed', boxShadow: 'none', color: theme.colors.text.disabled, diff --git a/packages/grafana-ui/src/components/ButtonCascader/_ButtonCascader.scss b/packages/grafana-ui/src/components/ButtonCascader/_ButtonCascader.scss index 63ba29bdd31ea..a7878bc96cf94 100644 --- a/packages/grafana-ui/src/components/ButtonCascader/_ButtonCascader.scss +++ b/packages/grafana-ui/src/components/ButtonCascader/_ButtonCascader.scss @@ -125,13 +125,15 @@ position: relative; &:after { - content: '>'; - font-size: 12px; - color: $text-color-weak; + background: $text-color-weak; + content: ''; + height: 24px; + mask: url(../img/icons/unicons/angle-right.svg); + mask-type: luminance; position: absolute; - right: 16px; - top: 0; - line-height: 32px; + right: 0px; + top: calc((32px - 24px) / 2); + width: 24px; } } } diff --git a/packages/grafana-ui/src/components/Card/Card.mdx b/packages/grafana-ui/src/components/Card/Card.mdx deleted file mode 100644 index 2e604d527f7b9..0000000000000 --- a/packages/grafana-ui/src/components/Card/Card.mdx +++ /dev/null @@ -1,450 +0,0 @@ -import { Meta, Preview, ArgTypes } from '@storybook/blocks'; -import { Card } from './Card'; -import { Button } from '../Button'; -import { IconButton } from '../IconButton/IconButton'; -import { TagList } from '../Tags/TagList'; - -export const logo = 'https://grafana.com/static/assets/img/apple-touch-icon.png'; - -<Meta title="MDX/Card" component={Card} /> - -# Card - -## Usage - -### Basic - -A basic `Card` component expects at least a heading, used as a title. - -```jsx -<Card> - <Card.Heading>Filter by name</Card.Heading> - <Card.Description>Filter data by query.</Card.Description> -</Card> -``` - -<Preview> - <Card> - <Card.Heading>Filter by name</Card.Heading> - <Card.Description>Filter data by query.</Card.Description> - </Card> -</Preview> - -### Multiple metadata elements - -For providing metadata elements, which can be any extra information for the card, `Card.Meta` component should be used. If metadata consists of multiple strings, each of them has to be escaped (wrapped in brackets `{}`) or better passed in as an array. - -```jsx -<Card> - <Card.Heading>Test dashboard</Card.Heading> - <Card.Meta>{['Folder: Test', 'Views: 100']}</Card.Meta> -</Card> -``` - -<Preview> - <Card> - <Card.Heading>Test dashboard</Card.Heading> - <Card.Meta>{['Folder: Test', 'Views: 100']}</Card.Meta> - </Card> -</Preview> - -Metadata also accepts HTML elements, which could be links, for example. For elements, that are not strings, a `key` prop has to be manually specified. - -```jsx -<Card> - <Card.Heading>Test dashboard</Card.Heading> - <Card.Meta> - Grafana - <a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom"> - https://ops-us-east4.grafana.net/api/prom - </a> - </Card.Meta> -</Card> -``` - -<Preview> - <Card> - <Card.Heading>Test dashboard</Card.Heading> - <Card.Meta> - Grafana - <a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom"> - https://ops-us-east4.grafana.net/api/prom - </a> - </Card.Meta> - </Card> -</Preview> - -The separator for multiple metadata elements defaults to a vertical line `|`, but can be customised. - -```jsx -<Card> - <Card.Heading>Test dashboard</Card.Heading> - <Card.Meta separator={'-'}> - Grafana - <a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom"> - https://ops-us-east4.grafana.net/api/prom - </a> - </Card.Meta> -</Card> -``` - -<Preview> - <Card> - <Card.Heading>Test dashboard</Card.Heading> - <Card.Meta separator={'-'}> - Grafana - <a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom"> - https://ops-us-east4.grafana.net/api/prom - </a> - </Card.Meta> - </Card> -</Preview> - -### Tags - -Tags can be rendered inside the Card, by being wrapped in `Card.Tags` component. Note that this component does not provide any tag styling and that should be handled by the children. It is recommended to use it with Grafana-UI's `TagList` component. - -```jsx -<Card> - <Card.Heading>Test dashboard</Card.Heading> - <Card.Description>Card with a list of tags</Card.Description> - <Card.Tags> - <TagList tags={['tag1', 'tag2', 'tag3']} onClick={(tag) => console.log(tag)} /> - </Card.Tags> -</Card> -``` - -<Preview> - <Card> - <Card.Heading>Test dashboard</Card.Heading> - <Card.Description>Card with a list of tags</Card.Description> - <Card.Tags> - <TagList tags={['tag1', 'tag2', 'tag3']} onClick={(tag) => console.log(tag)} /> - </Card.Tags> - </Card> -</Preview> - -### As a link - -Card can be used as a clickable link item by specifying `href` prop. - -```jsx -<Card href="https://grafana.com"> - <Card.Heading>Redirect to Grafana</Card.Heading> - <Card.Description>Clicking this card will redirect to grafana website</Card.Description> -</Card> -``` - -<Preview> - <Card href="https://grafana.com"> - <Card.Heading>Redirect to Grafana</Card.Heading> - <Card.Description>Clicking this card will redirect to grafana website</Card.Description> - </Card> -</Preview> - -### As a button - -Card can be used as a clickable buttons item by specifying `onClick` prop. - -```jsx -<Card onClick={() => alert('Hello, Grafana!')}> - <Card.Heading>Hello, Grafana</Card.Heading> - <Card.Description>Clicking this card will create an alert</Card.Description> -</Card> -``` - -<Preview> - <Card onClick={() => alert('Hello, Grafana!')}> - <Card.Heading>Hello, Grafana</Card.Heading> - <Card.Description>Clicking this card will create an alert</Card.Description> - </Card> -</Preview> - -> **Note**: When used in conjunction with [Metadata elements](#multiple-metadata-elements), clicking on any element -> inside `<Card.Meta>` will prevent the card action to be executed (either `href` to be followed or `onClick` to be called). -> -> Example: - -```jsx -<Card onClick={() => alert('Hello, Grafana!')}> - <Card.Heading>Hello, Grafana</Card.Heading> - <Card.Meta>Clicking on this text (Meta) WILL NOT trigger the alert!</Card.Meta> - <Card.Description>Clicking on this text (Description) WILL trigger the alert!</Card.Description> -</Card> -``` - -<Preview> - <Card onClick={() => alert('Hello, Grafana!')}> - <Card.Heading>Hello, Grafana</Card.Heading> - <Card.Meta>Clicking on this text (Meta) WILL NOT trigger the alert!</Card.Meta> - <Card.Description>Clicking on this text (Description) WILL trigger the alert!</Card.Description> - </Card> -</Preview> - -### Inside a list item - -To render cards in a list, it is possible to nest them inside `li` items. - -```jsx -<ul> - <li> - <Card> - <Card.Heading>List card item</Card.Heading> - <Card.Description>Card that is rendered inside li element.</Card.Description> - </Card> - </li> - <li> - <Card> - <Card.Heading>List card item</Card.Heading> - <Card.Description>Card that is rendered inside li element.</Card.Description> - </Card> - </li> - <li> - <Card> - <Card.Heading>List card item</Card.Heading> - <Card.Description>Card that is rendered inside li element.</Card.Description> - </Card> - </li> - <li> - <Card> - <Card.Heading>List card item</Card.Heading> - <Card.Description>Card that is rendered inside li element.</Card.Description> - </Card> - </li> -</ul> -``` - -<Preview> - <ul style={{ padding: '20px', maxWidth: '800px', listStyle: 'none', display: 'grid' }}> - <li> - <Card> - <Card.Heading>List card item</Card.Heading> - <Card.Description>Card that is rendered inside li element.</Card.Description> - </Card> - </li> - <li> - <Card> - <Card.Heading>List card item</Card.Heading> - <Card.Description>Card that is rendered inside li element.</Card.Description> - </Card> - </li> - <li> - <Card> - <Card.Heading>List card item</Card.Heading> - <Card.Description>Card that is rendered inside li element.</Card.Description> - </Card> - </li> - <li> - <Card> - <Card.Heading>List card item</Card.Heading> - <Card.Description>Card that is rendered inside li element.</Card.Description> - </Card> - </li> - </ul> -</Preview> - -### With media elements - -Cards can also be rendered with media content such icons or images. Such elements need to be wrapped in `Card.Figure` component. - -```jsx -<Card> - <Card.Heading>1-ops-tools1-fallback</Card.Heading> - <Card.Figure> - <img src={logo} alt="Grafana Logo" width="40" height="40" /> - </Card.Figure> - <Card.Meta> - Grafana - <a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom"> - https://ops-us-east4.grafana.net/api/prom - </a> - </Card.Meta> -</Card> -``` - -<Preview> - <Card> - <Card.Heading>1-ops-tools1-fallback</Card.Heading> - <Card.Figure> - <img src={logo} alt="Grafana Logo" width="40" height="40" /> - </Card.Figure> - <Card.Meta> - Grafana - <a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom"> - https://ops-us-east4.grafana.net/api/prom - </a> - </Card.Meta> - </Card> -</Preview> - -### Action Cards - -Cards also accept primary and secondary actions. Usually the primary actions are displayed as buttons while secondary actions are displayed as icon buttons. The actions need to be wrappd in `Card.Actions` and `Card.SecondaryActions` components respectively. - -```jsx -<Card> - <Card.Heading>1-ops-tools1-fallback</Card.Heading> - <Card.Meta> - Grafana - <a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom"> - https://ops-us-east4.grafana.net/api/prom - </a> - </Card.Meta> - <Card.Figure> - <img src={logo} alt="Grafana Logo" width="40" height="40" /> - </Card.Figure> - <Card.Actions> - <Button key="settings" variant="secondary"> - Settings - </Button> - <Button key="explore" variant="secondary"> - Explore - </Button> - </Card.Actions> - <Card.SecondaryActions> - <IconButton key="showAll" name="apps" tooltip="Show all dashboards for this data source" /> - <IconButton key="delete" name="trash-alt" tooltip="Delete this data source" /> - </Card.SecondaryActions> -</Card> -``` - -<Preview> - <Card> - <Card.Heading>1-ops-tools1-fallback</Card.Heading> - <Card.Meta> - Grafana - <a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom"> - https://ops-us-east4.grafana.net/api/prom - </a> - </Card.Meta> - <Card.Figure> - <img src={logo} alt="Grafana Logo" width="40" height="40" /> - </Card.Figure> - <Card.Actions> - <Button key="settings" variant="secondary"> - Settings - </Button> - <Button key="explore" variant="secondary"> - Explore - </Button> - </Card.Actions> - <Card.SecondaryActions> - <IconButton key="showAll" name="apps" tooltip="Show all dashboards for this data source" /> - <IconButton key="delete" name="trash-alt" tooltip="Delete this data source" /> - </Card.SecondaryActions> - </Card> -</Preview> - -### Disabled state - -Card can have a disabled state, effectively making it and its actions non-clickable. If there are any actions, they will be disabled instead of the whole card. - -```jsx -<Card disabled> - <Card.Heading>1-ops-tools1-fallback</Card.Heading> - <Card.Meta> - Grafana - <a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom"> - https://ops-us-east4.grafana.net/api/prom - </a> - </Card.Meta> - <Card.Figure> - <img src={logo} alt="Grafana Logo" width="40" height="40" /> - </Card.Figure> -</Card> -``` - -<Preview> - <Card disabled> - <Card.Heading>1-ops-tools1-fallback</Card.Heading> - <Card.Meta> - Grafana - <a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom"> - https://ops-us-east4.grafana.net/api/prom - </a> - </Card.Meta> - <Card.Figure> - <img src={logo} alt="Grafana Logo" width="40" height="40" /> - </Card.Figure> - </Card> -</Preview> - -```jsx -<Card disabled> - <Card.Heading>1-ops-tools1-fallback</Card.Heading> - <Card.Meta> - Grafana - <a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom"> - https://ops-us-east4.grafana.net/api/prom - </a> - </Card.Meta> - <Card.Figure> - <img src={logo} alt="Grafana Logo" width="40" height="40" /> - </Card.Figure> - <Card.Actions> - <Button key="settings" variant="secondary"> - Settings - </Button> - <Button key="explore" variant="secondary"> - Explore - </Button> - </Card.Actions> - <Card.SecondaryActions> - <IconButton key="showAll" name="apps" tooltip="Show all dashboards for this data source" /> - <IconButton key="delete" name="trash-alt" tooltip="Delete this data source" /> - </Card.SecondaryActions> -</Card> -``` - -<Preview> - <Card disabled> - <Card.Heading>1-ops-tools1-fallback</Card.Heading> - <Card.Meta> - Grafana - <a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom"> - https://ops-us-east4.grafana.net/api/prom - </a> - </Card.Meta> - <Card.Figure> - <img src={logo} alt="Grafana Logo" width="40" height="40" /> - </Card.Figure> - <Card.Actions> - <Button key="settings" variant="secondary"> - Settings - </Button> - <Button key="explore" variant="secondary"> - Explore - </Button> - </Card.Actions> - <Card.SecondaryActions> - <IconButton key="showAll" name="apps" tooltip="Show all dashboards for this data source" /> - <IconButton key="delete" name="trash-alt" tooltip="Delete this data source" /> - </Card.SecondaryActions> - </Card> -</Preview> - -### Selectable - -```jsx -<Card isSelected disabled> - <Card.Heading>Option #1</Card.Heading> - <Card.Meta>This is a really great option, you won't regret it.</Card.Meta> - <Card.Figure> - <img src={logo} alt="Grafana Logo" width="40" height="40" /> - </Card.Figure> -</Card> -``` - -<Preview> - <Card isSelected disabled> - <Card.Heading>Option #1</Card.Heading> - <Card.Description>This is a really great option, you won't regret it.</Card.Description> - <Card.Figure> - <img src={logo} alt="Grafana Logo" width="40" height="40" /> - </Card.Figure> - </Card> -</Preview> - -### Props - -<ArgTypes of={Card} /> diff --git a/packages/grafana-ui/src/components/Card/Card.story.tsx b/packages/grafana-ui/src/components/Card/Card.story.tsx index f690442139429..c53e01798b011 100644 --- a/packages/grafana-ui/src/components/Card/Card.story.tsx +++ b/packages/grafana-ui/src/components/Card/Card.story.tsx @@ -3,30 +3,30 @@ import React from 'react'; import { Button } from '../Button'; import { IconButton } from '../IconButton/IconButton'; -import { VerticalGroup } from '../Layout/Layout'; import { TagList } from '../Tags/TagList'; import { Card } from './Card'; -import mdx from './Card.mdx'; const logo = 'https://grafana.com/static/assets/img/apple-touch-icon.png'; const meta: Meta<typeof Card> = { title: 'General/Card', component: Card, + // nosort is a custom tag used so the stories shown in docs keep the order they are defined in the file + tags: ['autodocs', 'nosort'], parameters: { - docs: { - page: mdx, - }, controls: { exclude: ['onClick', 'href', 'heading', 'description', 'className'], }, }, }; -export const Basic: StoryFn<typeof Card> = ({ disabled }) => { +/** + * A basic Card component expects at least a heading, used as a title. + */ +export const Basic: StoryFn<typeof Card> = (args) => { return ( - <Card disabled={disabled}> + <Card {...args}> <Card.Heading>Filter by name</Card.Heading> <Card.Description> Filter data by query. This is useful if you are sharing the results from a different panel that has many queries @@ -36,46 +36,159 @@ export const Basic: StoryFn<typeof Card> = ({ disabled }) => { ); }; -export const AsLink: StoryFn<typeof Card> = ({ disabled }) => { +/** + * For providing metadata elements, which can be any extra information for the card, Card.Meta component should be used. + * If metadata consists of multiple strings, each of them has to be escaped (wrapped in brackets {}) or better passed in as an array. + */ +export const MultipleMetadataElements: StoryFn<typeof Card> = (args) => { + return ( + <Card> + <Card.Heading>Test dashboard</Card.Heading> + <Card.Meta>{['Folder: Test', 'Views: 100']}</Card.Meta> + </Card> + ); +}; + +/** + * Metadata also accepts HTML elements, which could be links, for example. + * For elements, that are not strings, a `key` prop has to be manually specified. + */ +export const ComplexMetadataElements: StoryFn<typeof Card> = (args) => { + return ( + <Card> + <Card.Heading>Test dashboard</Card.Heading> + <Card.Meta> + <>Grafana</> + <a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom"> + <>https://ops-us-east4.grafana.net/api/prom</> + </a> + </Card.Meta> + </Card> + ); +}; + +/** + * The separator for multiple metadata elements defaults to a vertical line `|`, but can be customised. + */ +export const MultipleMetadataWithCustomSeparator: StoryFn<typeof Card> = (args) => { return ( - <VerticalGroup> - <Card href="https://grafana.com" disabled={disabled}> - <Card.Heading>Filter by name</Card.Heading> - <Card.Description> - Filter data by query. This is useful if you are sharing the results from a different panel that has many - queries and you want to only visualize a subset of that in this panel. - </Card.Description> - </Card> - <Card href="https://grafana.com" disabled={disabled}> - <Card.Heading>Filter by name2</Card.Heading> - <Card.Description> - Filter data by query. This is useful if you are sharing the results from a different panel that has many - queries and you want to only visualize a subset of that in this panel. - </Card.Description> - </Card> - <Card href="https://grafana.com" disabled={disabled}> - <Card.Heading>Production system overview</Card.Heading> - <Card.Meta>Meta tags</Card.Meta> - </Card> - </VerticalGroup> + <Card> + <Card.Heading>Test dashboard</Card.Heading> + <Card.Meta separator={'-'}> + Grafana + <a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom"> + https://ops-us-east4.grafana.net/api/prom + </a> + </Card.Meta> + </Card> ); }; -export const WithTags: StoryFn<typeof Card> = ({ disabled }) => { +/** + * Tags can be rendered inside the Card, by being wrapped in `Card.Tags` component. + * Note that this component does not provide any tag styling and that should be handled by the children. + * It is recommended to use it with Grafana-UI's `TagList` component. + */ +export const Tags: StoryFn<typeof Card> = (args) => { return ( - <Card disabled={disabled}> - <Card.Heading>Elasticsearch – Custom Templated Query</Card.Heading> - <Card.Meta>Elastic Search</Card.Meta> + <Card> + <Card.Heading>Test dashboard</Card.Heading> + <Card.Description>Card with a list of tags</Card.Description> <Card.Tags> - <TagList tags={['elasticsearch', 'test', 'testdata']} onClick={(tag) => console.log('tag', tag)} /> + <TagList tags={['tag1', 'tag2', 'tag3']} onClick={(tag) => console.log(tag)} /> </Card.Tags> </Card> ); }; -export const WithMedia: StoryFn<typeof Card> = ({ disabled }) => { +/** + * Card can be used as a clickable link item by specifying `href` prop. + */ +export const AsALink: StoryFn<typeof Card> = (args) => { + return ( + <Card href="https://grafana.com"> + <Card.Heading>Redirect to Grafana</Card.Heading> + <Card.Description>Clicking this card will redirect to grafana website</Card.Description> + </Card> + ); +}; + +/** + * Card can be used as a clickable buttons item by specifying `onClick` prop. + * **Note:** When used in conjunction with [Metadata elements](#multiple-metadata-elements), clicking on any element + * inside `<Card.Meta>` will prevent the card action to be executed (either `href` to be followed or `onClick` to be called). + */ +export const AsAButton: StoryFn<typeof Card> = (args) => { + return ( + <Card onClick={() => alert('Hello, Grafana!')}> + <Card.Heading>Hello, Grafana</Card.Heading> + <Card.Description>Clicking this card will create an alert</Card.Description> + </Card> + ); +}; + +/** + * To render cards in a list, it is possible to nest them inside `li` items. + */ +export const InsideAListItem: StoryFn<typeof Card> = (args) => { + return ( + <ul style={{ padding: '20px', listStyle: 'none', display: 'grid' }}> + <li> + <Card> + <Card.Heading>List card item</Card.Heading> + <Card.Description>Card that is rendered inside li element.</Card.Description> + </Card> + </li> + <li> + <Card> + <Card.Heading>List card item</Card.Heading> + <Card.Description>Card that is rendered inside li element.</Card.Description> + </Card> + </li> + <li> + <Card> + <Card.Heading>List card item</Card.Heading> + <Card.Description>Card that is rendered inside li element.</Card.Description> + </Card> + </li> + <li> + <Card> + <Card.Heading>List card item</Card.Heading> + <Card.Description>Card that is rendered inside li element.</Card.Description> + </Card> + </li> + </ul> + ); +}; + +/** + * Cards can also be rendered with media content such icons or images. Such elements need to be wrapped in `Card.Figure` component. + */ +export const WithMediaElements: StoryFn<typeof Card> = (args) => { + return ( + <Card> + <Card.Heading>1-ops-tools1-fallback</Card.Heading> + <Card.Figure> + <img src={logo} alt="Grafana Logo" width="40" height="40" /> + </Card.Figure> + <Card.Meta> + Grafana + <a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom"> + https://ops-us-east4.grafana.net/api/prom + </a> + </Card.Meta> + </Card> + ); +}; + +/** + * Cards also accept primary and secondary actions. Usually the primary actions are displayed as buttons + * while secondary actions are displayed as icon buttons. The actions need to be wrapped in `Card.Actions` + * and `Card.SecondaryActions` components respectively. + */ +export const ActionCards: StoryFn<typeof Card> = (args) => { return ( - <Card disabled={disabled}> + <Card {...args}> <Card.Heading>1-ops-tools1-fallback</Card.Heading> <Card.Meta> Prometheus @@ -86,21 +199,38 @@ export const WithMedia: StoryFn<typeof Card> = ({ disabled }) => { <Card.Figure> <img src={logo} alt="Prometheus Logo" height="40" width="40" /> </Card.Figure> + <Card.Actions> + <Button key="settings" variant="secondary"> + Settings + </Button> + <Button key="explore" variant="secondary"> + Explore + </Button> + </Card.Actions> + <Card.SecondaryActions> + <IconButton key="showAll" name="apps" tooltip="Show all dashboards for this data source" /> + <IconButton key="delete" name="trash-alt" tooltip="Delete this data source" /> + </Card.SecondaryActions> </Card> ); }; -export const WithActions: StoryFn<typeof Card> = ({ disabled }) => { + +/** + * Card can have a disabled state, effectively making it and its actions non-clickable. + * If there are any actions, they will be disabled instead of the whole card. + */ +export const DisabledState: StoryFn<typeof Card> = (args) => { return ( - <Card disabled={disabled}> + <Card disabled> <Card.Heading>1-ops-tools1-fallback</Card.Heading> <Card.Meta> - Prometheus - <a key="link2" href="https://ops-us-east4.grafana.net/api/prom"> + Grafana + <a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom"> https://ops-us-east4.grafana.net/api/prom </a> </Card.Meta> <Card.Figure> - <img src={logo} alt="Prometheus Logo" height="40" width="40" /> + <img src={logo} alt="Grafana Logo" width="40" height="40" /> </Card.Figure> <Card.Actions> <Button key="settings" variant="secondary"> @@ -118,9 +248,21 @@ export const WithActions: StoryFn<typeof Card> = ({ disabled }) => { ); }; -export const Full: StoryFn<typeof Card> = ({ disabled }) => { +export const Selectable: StoryFn<typeof Card> = () => { return ( - <Card disabled={disabled}> + <Card isSelected disabled> + <Card.Heading>Option #1</Card.Heading> + <Card.Description>This is a really great option, you will not regret it.</Card.Description> + <Card.Figure> + <img src={logo} alt="Grafana Logo" width="40" height="40" /> + </Card.Figure> + </Card> + ); +}; + +export const Full: StoryFn<typeof Card> = (args) => { + return ( + <Card {...args}> <Card.Heading>Card title</Card.Heading> <Card.Description> Description, body text. Greetings! Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod @@ -155,28 +297,4 @@ export const Full: StoryFn<typeof Card> = ({ disabled }) => { ); }; -export const Selected: StoryFn<typeof Card> = () => { - return ( - <Card isSelected> - <Card.Heading>Spaces</Card.Heading> - <Card.Description>Spaces are the superior form of indenting code.</Card.Description> - <Card.Figure> - <img src={logo} alt="Grafana Logo" width="40" height="40" /> - </Card.Figure> - </Card> - ); -}; - -export const NotSelected: StoryFn<typeof Card> = () => { - return ( - <Card isSelected={false}> - <Card.Heading>Tabs</Card.Heading> - <Card.Description>Tabs are the preferred way of indentation.</Card.Description> - <Card.Figure> - <img src={logo} alt="Grafana Logo" width="40" height="40" /> - </Card.Figure> - </Card> - ); -}; - export default meta; diff --git a/packages/grafana-ui/src/components/Card/Card.tsx b/packages/grafana-ui/src/components/Card/Card.tsx index 4ebb077f1fa7b..08f363dda255f 100644 --- a/packages/grafana-ui/src/components/Card/Card.tsx +++ b/packages/grafana-ui/src/components/Card/Card.tsx @@ -23,6 +23,8 @@ export interface Props extends Omit<CardContainerProps, 'disableEvents' | 'disab /** @deprecated Use `Card.Description` instead */ description?: string; isSelected?: boolean; + /** If true, the padding of the Card will be smaller */ + isCompact?: boolean; } export interface CardInterface extends FC<Props> { @@ -47,7 +49,16 @@ const CardContext = React.createContext<{ * * @public */ -export const Card: CardInterface = ({ disabled, href, onClick, children, isSelected, className, ...htmlProps }) => { +export const Card: CardInterface = ({ + disabled, + href, + onClick, + children, + isSelected, + isCompact, + className, + ...htmlProps +}) => { const hasHeadingComponent = useMemo( () => React.Children.toArray(children).some((c) => React.isValidElement(c) && c.type === Heading), [children] @@ -55,7 +66,7 @@ export const Card: CardInterface = ({ disabled, href, onClick, children, isSelec const disableHover = disabled || (!onClick && !href); const onCardClick = onClick && !disabled ? onClick : undefined; - const styles = useStyles2(getCardContainerStyles, disabled, disableHover, isSelected); + const styles = useStyles2(getCardContainerStyles, disabled, disableHover, isSelected, isCompact); return ( <CardContainer diff --git a/packages/grafana-ui/src/components/Card/CardContainer.tsx b/packages/grafana-ui/src/components/Card/CardContainer.tsx index e4a52c9f1f23d..8879a87ed4f55 100644 --- a/packages/grafana-ui/src/components/Card/CardContainer.tsx +++ b/packages/grafana-ui/src/components/Card/CardContainer.tsx @@ -70,7 +70,8 @@ export const getCardContainerStyles = ( theme: GrafanaTheme2, disabled = false, disableHover = false, - isSelected?: boolean + isSelected?: boolean, + isCompact?: boolean ) => { const isSelectable = isSelected !== undefined; @@ -88,7 +89,7 @@ export const getCardContainerStyles = ( "Figure Description Tags" "Figure Actions Secondary"`, width: '100%', - padding: theme.spacing(2), + padding: theme.spacing(isCompact ? 1 : 2), background: theme.colors.background.secondary, borderRadius: theme.shape.radius.default, marginBottom: '8px', diff --git a/packages/grafana-ui/src/components/Cascader/Cascader.test.tsx b/packages/grafana-ui/src/components/Cascader/Cascader.test.tsx index e39b75043601a..56807ba60b3f3 100644 --- a/packages/grafana-ui/src/components/Cascader/Cascader.test.tsx +++ b/packages/grafana-ui/src/components/Cascader/Cascader.test.tsx @@ -1,7 +1,9 @@ import { act, render, screen } from '@testing-library/react'; -import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'; +import userEvent from '@testing-library/user-event'; import React from 'react'; +import { Field } from '../Forms/Field'; + import { Cascader, CascaderOption, CascaderProps } from './Cascader'; const options = [ @@ -118,9 +120,8 @@ describe('Cascader', () => { expect(screen.queryByDisplayValue('First/Second')).not.toBeInTheDocument(); await userEvent.click(screen.getByPlaceholderText(placeholder)); - // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed - await userEvent.click(screen.getByText('First'), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - await userEvent.click(screen.getByText('Second'), { pointerEventsCheck: PointerEventsCheckLevel.Never }); + await userEvent.click(screen.getByText('First')); + await userEvent.click(screen.getByText('Second')); expect(screen.getByDisplayValue('First / Second')).toBeInTheDocument(); }); @@ -141,9 +142,8 @@ describe('Cascader', () => { expect(screen.queryByDisplayValue('First/Second')).not.toBeInTheDocument(); await userEvent.click(screen.getByPlaceholderText(placeholder)); - // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed - await userEvent.click(screen.getByText('First'), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - await userEvent.click(screen.getByText('Second'), { pointerEventsCheck: PointerEventsCheckLevel.Never }); + await userEvent.click(screen.getByText('First')); + await userEvent.click(screen.getByText('Second')); expect(screen.getByDisplayValue(`First${separator}Second`)).toBeInTheDocument(); }); @@ -154,9 +154,8 @@ describe('Cascader', () => { ); await userEvent.click(screen.getByPlaceholderText(placeholder)); - // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed - await userEvent.click(screen.getByText('First'), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - await userEvent.click(screen.getByText('Second'), { pointerEventsCheck: PointerEventsCheckLevel.Never }); + await userEvent.click(screen.getByText('First')); + await userEvent.click(screen.getByText('Second')); expect(screen.getByDisplayValue('Second')).toBeInTheDocument(); }); @@ -165,10 +164,19 @@ describe('Cascader', () => { render(<Cascader placeholder={placeholder} options={options} onSelect={jest.fn()} />); await userEvent.click(screen.getByPlaceholderText(placeholder)); - // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed - await userEvent.click(screen.getByText('First'), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - await userEvent.click(screen.getByText('Second'), { pointerEventsCheck: PointerEventsCheckLevel.Never }); + await userEvent.click(screen.getByText('First')); + await userEvent.click(screen.getByText('Second')); expect(screen.getByDisplayValue('Second')).toBeInTheDocument(); }); + + it('should be properly associated with the Field label', () => { + render( + <Field label={'Cascader label'}> + <Cascader options={options} onSelect={jest.fn()} id={'cascader'} /> + </Field> + ); + + expect(screen.getByRole('textbox', { name: 'Cascader label' })).toBeInTheDocument(); + }); }); diff --git a/packages/grafana-ui/src/components/Cascader/Cascader.tsx b/packages/grafana-ui/src/components/Cascader/Cascader.tsx index 27c8f03f1760d..2e99cc652609e 100644 --- a/packages/grafana-ui/src/components/Cascader/Cascader.tsx +++ b/packages/grafana-ui/src/components/Cascader/Cascader.tsx @@ -6,7 +6,9 @@ import React, { PureComponent } from 'react'; import { SelectableValue } from '@grafana/data'; import { Icon } from '../Icon/Icon'; +import { IconButton } from '../IconButton/IconButton'; import { Input } from '../Input/Input'; +import { Stack } from '../Layout/Stack/Stack'; import { Select } from '../Select/Select'; import { onChangeCascader } from './optionMappings'; @@ -37,6 +39,11 @@ export interface CascaderProps { /** Don't show what is selected in the cascader input/search. Useful when input is used just as search and the cascader is hidden after selection. */ hideActiveLevelLabel?: boolean; + disabled?: boolean; + /** ID for the underlying Select/Cascader component */ + id?: string; + /** Whether you can clear the selected value or not */ + isClearable?: boolean; } interface CascaderState { @@ -45,6 +52,7 @@ interface CascaderState { //Array for cascade navigation rcValue: SelectableValue<string[]>; activeLabel: string; + inputValue: string; } export interface CascaderOption { @@ -83,6 +91,7 @@ export class Cascader extends PureComponent<CascaderProps, CascaderState> { focusCascade: false, rcValue, activeLabel, + inputValue: '', }; } @@ -93,7 +102,7 @@ export class Cascader extends PureComponent<CascaderProps, CascaderState> { for (const option of options) { const cpy = [...optionPath]; cpy.push(option); - if (!option.items) { + if (!option.items || option.items.length === 0) { selectOptions.push({ singleLabel: cpy[cpy.length - 1].label, label: cpy.map((o) => o.label).join(this.props.separator || DEFAULT_SEPARATOR), @@ -133,31 +142,38 @@ export class Cascader extends PureComponent<CascaderProps, CascaderState> { const activeLabel = this.props.hideActiveLevelLabel ? '' : this.props.displayAllSelectedLevels - ? selectedOptions.map((option) => option.label).join(this.props.separator || DEFAULT_SEPARATOR) - : selectedOptions[selectedOptions.length - 1].label; - this.setState({ - rcValue: value, + ? selectedOptions.map((option) => option.label).join(this.props.separator || DEFAULT_SEPARATOR) + : selectedOptions[selectedOptions.length - 1].label; + const state: CascaderState = { + rcValue: { value, label: activeLabel }, focusCascade: true, activeLabel, - }); - + isSearching: false, + inputValue: activeLabel, + }; + this.setState(state); this.props.onSelect(selectedOptions[selectedOptions.length - 1].value); }; //For select onSelect = (obj: SelectableValue<string[]>) => { const valueArray = obj.value || []; - this.setState({ - activeLabel: this.props.displayAllSelectedLevels ? obj.label : obj.singleLabel || '', - rcValue: valueArray, + const activeLabel = this.props.displayAllSelectedLevels ? obj.label : obj.singleLabel || ''; + const state: CascaderState = { + activeLabel: activeLabel, + inputValue: activeLabel, + rcValue: { value: valueArray, label: activeLabel }, isSearching: false, - }); + focusCascade: false, + }; + this.setState(state); this.props.onSelect(valueArray[valueArray.length - 1]); }; onCreateOption = (value: string) => { this.setState({ activeLabel: value, + inputValue: value, rcValue: [], isSearching: false, }); @@ -187,26 +203,36 @@ export class Cascader extends PureComponent<CascaderProps, CascaderState> { }; onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { - if (['ArrowDown', 'ArrowUp', 'Enter', 'ArrowLeft', 'ArrowRight', 'Backspace'].includes(e.key)) { + if (['ArrowDown', 'ArrowUp', 'Enter', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { return; } + const { activeLabel } = this.state; this.setState({ focusCascade: false, isSearching: true, + inputValue: activeLabel, }); }; onSelectInputChange = (value: string) => { - if (value === '') { - this.setState({ - isSearching: false, - }); - } + this.setState({ + inputValue: value, + }); }; render() { - const { allowCustomValue, formatCreateLabel, placeholder, width, changeOnSelect, options } = this.props; - const { focusCascade, isSearching, rcValue, activeLabel } = this.state; + const { + allowCustomValue, + formatCreateLabel, + placeholder, + width, + changeOnSelect, + options, + disabled, + id, + isClearable, + } = this.props; + const { focusCascade, isSearching, rcValue, activeLabel, inputValue } = this.state; const searchableOptions = this.getSearchableOptions(options); @@ -224,6 +250,9 @@ export class Cascader extends PureComponent<CascaderProps, CascaderState> { formatCreateLabel={formatCreateLabel} width={width} onInputChange={this.onSelectInputChange} + disabled={disabled} + inputValue={inputValue} + inputId={id} /> ) : ( <RCCascader @@ -234,6 +263,7 @@ export class Cascader extends PureComponent<CascaderProps, CascaderState> { fieldNames={{ label: 'label', value: 'value', children: 'items' }} expandIcon={null} open={this.props.alwaysOpen} + disabled={disabled} > <div className={disableDivFocus}> <Input @@ -245,12 +275,24 @@ export class Cascader extends PureComponent<CascaderProps, CascaderState> { onKeyDown={this.onInputKeyDown} onChange={() => {}} suffix={ - focusCascade ? ( - <Icon name="angle-up" /> - ) : ( - <Icon name="angle-down" style={{ marginBottom: 0, marginLeft: '4px' }} /> - ) + <Stack gap={0.5}> + {isClearable && activeLabel !== '' && ( + <IconButton + name="times" + aria-label="Clear selection" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + this.setState({ rcValue: [], activeLabel: '', inputValue: '' }); + this.props.onSelect(''); + }} + /> + )} + <Icon name={focusCascade ? 'angle-up' : 'angle-down'} /> + </Stack> } + disabled={disabled} + id={id} /> </div> </RCCascader> diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx index 65912a39d6a26..57fc7cfe6ce56 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx @@ -92,23 +92,6 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => { color: theme.colors.text.primary, maxWidth: '400px', fontSize: theme.typography.size.sm, - // !important because these styles are also provided to popper via .popper classes from Tooltip component - // hope to get rid of those soon - padding: '15px !important', - '& [data-placement^="top"]': { - paddingLeft: '0 !important', - paddingRight: '0 !important', - }, - '& [data-placement^="bottom"]': { - paddingLeft: '0 !important', - paddingRight: '0 !important', - }, - '& [data-placement^="left"]': { - paddingTop: '0 !important', - }, - '& [data-placement^="right"]': { - paddingTop: '0 !important', - }, }), }; }); diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx index e12ed64745501..850da68b49e4b 100644 --- a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx @@ -1,14 +1,13 @@ import { cx, css } from '@emotion/css'; -import React, { PureComponent, ReactElement } from 'react'; +import React, { ReactElement, useEffect, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { stylesFactory, withTheme2 } from '../../themes'; -import { Themeable2 } from '../../types'; +import { useStyles2 } from '../../themes'; import { ComponentSize } from '../../types/size'; import { Button, ButtonVariant } from '../Button'; -export interface Props extends Themeable2 { +export interface Props { /** Confirm action callback */ onConfirm(): void; children: string | ReactElement; @@ -24,182 +23,161 @@ export interface Props extends Themeable2 { confirmVariant?: ButtonVariant; /** Hide confirm actions when after of them is clicked */ closeOnConfirm?: boolean; - /** Move focus to button when mounted */ - autoFocus?: boolean; - /** Optional on click handler for the original button */ onClick?(): void; /** Callback for the cancel action */ onCancel?(): void; } -interface State { - showConfirm: boolean; -} - -class UnThemedConfirmButton extends PureComponent<Props, State> { - mainButtonRef = React.createRef<HTMLButtonElement>(); - confirmButtonRef = React.createRef<HTMLButtonElement>(); - state: State = { - showConfirm: false, - }; +export const ConfirmButton = ({ + children, + className, + closeOnConfirm, + confirmText = 'Save', + confirmVariant = 'primary', + disabled = false, + onCancel, + onClick, + onConfirm, + size = 'md', +}: Props) => { + const mainButtonRef = useRef<HTMLButtonElement>(null); + const confirmButtonRef = useRef<HTMLButtonElement>(null); + const [showConfirm, setShowConfirm] = useState(false); + const [shouldRestoreFocus, setShouldRestoreFocus] = useState(false); + const styles = useStyles2(getStyles); + + useEffect(() => { + if (showConfirm) { + confirmButtonRef.current?.focus(); + setShouldRestoreFocus(true); + } else { + if (shouldRestoreFocus) { + mainButtonRef.current?.focus(); + setShouldRestoreFocus(false); + } + } + }, [shouldRestoreFocus, showConfirm]); - onClickButton = (event: React.MouseEvent<HTMLButtonElement>) => { + const onClickButton = (event: React.MouseEvent<HTMLButtonElement>) => { if (event) { event.preventDefault(); } - this.setState( - { - showConfirm: true, - }, - () => { - if (this.props.autoFocus && this.confirmButtonRef.current) { - this.confirmButtonRef.current.focus(); - } - } - ); - - if (this.props.onClick) { - this.props.onClick(); - } + setShowConfirm(true); + onClick?.(); }; - onClickCancel = (event: React.MouseEvent<HTMLButtonElement>) => { + const onClickCancel = (event: React.MouseEvent<HTMLButtonElement>) => { if (event) { event.preventDefault(); } - this.setState( - { - showConfirm: false, - }, - () => { - this.mainButtonRef.current?.focus(); - } - ); - if (this.props.onCancel) { - this.props.onCancel(); - } + setShowConfirm(false); + mainButtonRef.current?.focus(); + onCancel?.(); }; - onConfirm = (event: React.MouseEvent<HTMLButtonElement>) => { + + const onClickConfirm = (event: React.MouseEvent<HTMLButtonElement>) => { if (event) { event.preventDefault(); } - this.props.onConfirm(); - if (this.props.closeOnConfirm) { - this.setState({ - showConfirm: false, - }); + onConfirm?.(); + if (closeOnConfirm) { + setShowConfirm(false); } }; - render() { - const { - theme, - className, - size, - disabled, - confirmText, - confirmVariant: confirmButtonVariant, - children, - } = this.props; - const styles = getStyles(theme); - const buttonClass = cx( - className, - this.state.showConfirm ? styles.buttonHide : styles.buttonShow, - disabled && styles.buttonDisabled - ); - const confirmButtonClass = cx( - styles.confirmButton, - this.state.showConfirm ? styles.confirmButtonShow : styles.confirmButtonHide - ); - - const onClick = disabled ? () => {} : this.onClickButton; - - return ( - <span className={styles.buttonContainer}> - <div className={cx(disabled && styles.disabled)}> - <span className={buttonClass}> - {typeof children === 'string' ? ( - <Button size={size} fill="text" onClick={onClick} ref={this.mainButtonRef}> - {children} - </Button> - ) : ( - React.cloneElement(children, { onClick, ref: this.mainButtonRef }) - )} - </span> - </div> + const buttonClass = cx(className, styles.mainButton, { + [styles.mainButtonHide]: showConfirm, + }); + const confirmButtonClass = cx(styles.confirmButton, { + [styles.confirmButtonHide]: !showConfirm, + }); + const confirmButtonContainerClass = cx(styles.confirmButtonContainer, { + [styles.confirmButtonContainerHide]: !showConfirm, + }); + + return ( + <div className={styles.container}> + <span className={buttonClass}> + {typeof children === 'string' ? ( + <Button disabled={disabled} size={size} fill="text" onClick={onClickButton} ref={mainButtonRef}> + {children} + </Button> + ) : ( + React.cloneElement(children, { disabled, onClick: onClickButton, ref: mainButtonRef }) + )} + </span> + <div className={confirmButtonContainerClass}> <span className={confirmButtonClass}> - <Button size={size} variant={confirmButtonVariant} onClick={this.onConfirm} ref={this.confirmButtonRef}> + <Button size={size} variant={confirmVariant} onClick={onClickConfirm} ref={confirmButtonRef}> {confirmText} </Button> - <Button size={size} fill="text" onClick={this.onClickCancel}> + <Button size={size} fill="text" onClick={onClickCancel}> Cancel </Button> </span> - </span> - ); - } -} - -export const ConfirmButton = withTheme2(UnThemedConfirmButton); + </div> + </div> + ); +}; +ConfirmButton.displayName = 'ConfirmButton'; -const getStyles = stylesFactory((theme: GrafanaTheme2) => { +const getStyles = (theme: GrafanaTheme2) => { return { - buttonContainer: css({ - display: 'flex', + container: css({ alignItems: 'center', + display: 'flex', justifyContent: 'flex-end', + position: 'relative', }), - buttonDisabled: css({ - textDecoration: 'none', - color: theme.colors.text.primary, - opacity: 0.65, - pointerEvents: 'none', - }), - buttonShow: css({ + mainButton: css({ opacity: 1, - transition: 'opacity 0.1s ease', + transition: theme.transitions.create(['opacity'], { + duration: theme.transitions.duration.shortest, + easing: theme.transitions.easing.easeOut, + }), zIndex: 2, }), - buttonHide: css({ + mainButtonHide: css({ opacity: 0, - transition: 'opacity 0.1s ease, visibility 0 0.1s', + transition: theme.transitions.create(['opacity', 'visibility'], { + duration: theme.transitions.duration.shortest, + easing: theme.transitions.easing.easeIn, + }), visibility: 'hidden', zIndex: 0, }), + confirmButtonContainer: css({ + overflow: 'visible', + position: 'absolute', + pointerEvents: 'all', + right: 0, + }), + confirmButtonContainerHide: css({ + overflow: 'hidden', + pointerEvents: 'none', + }), confirmButton: css({ alignItems: 'flex-start', background: theme.colors.background.primary, display: 'flex', - position: 'absolute', - pointerEvents: 'none', - }), - confirmButtonShow: css({ - zIndex: 1, opacity: 1, - transition: 'opacity 0.08s ease-out, transform 0.1s ease-out', transform: 'translateX(0)', - pointerEvents: 'all', + transition: theme.transitions.create(['opacity', 'transform'], { + duration: theme.transitions.duration.shortest, + easing: theme.transitions.easing.easeOut, + }), + zIndex: 1, }), confirmButtonHide: css({ opacity: 0, + transform: 'translateX(100%)', + transition: theme.transitions.create(['opacity', 'transform', 'visibility'], { + duration: theme.transitions.duration.shortest, + easing: theme.transitions.easing.easeIn, + }), visibility: 'hidden', - transition: 'opacity 0.12s ease-in, transform 0.14s ease-in, visibility 0s 0.12s', - transform: 'translateX(100px)', - }), - disabled: css({ - cursor: 'not-allowed', }), }; -}); - -// Declare defaultProps directly on the themed component so they are displayed -// in the props table -ConfirmButton.defaultProps = { - size: 'md', - confirmText: 'Save', - disabled: false, - confirmVariant: 'primary', }; -ConfirmButton.displayName = 'ConfirmButton'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx index 55186d2352fdd..ecc981cf3a2f1 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx @@ -1,7 +1,7 @@ import { css, cx } from '@emotion/css'; +import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/react'; import Prism, { Grammar, LanguageMap } from 'prismjs'; -import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; -import { Popper as ReactPopper } from 'react-popper'; +import React, { memo, useEffect, useRef, useState } from 'react'; import usePrevious from 'react-use/lib/usePrevious'; import { Value } from 'slate'; import Plain from 'slate-plain-serializer'; @@ -86,6 +86,29 @@ export const DataLinkInput = memo( const prevLinkUrl = usePrevious<Value>(linkUrl); const [scrollTop, setScrollTop] = useState(0); + // the order of middleware is important! + const middleware = [ + offset(({ rects }) => ({ + alignmentAxis: rects.reference.width, + })), + flip({ + fallbackAxisSideDirection: 'start', + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ]; + + const { refs, floatingStyles } = useFloating({ + open: showingSuggestions, + placement: 'bottom-start', + onOpenChange: setShowingSuggestions, + middleware, + whileElementsMounted: autoUpdate, + strategy: 'fixed', + }); + // Workaround for https://github.com/ianstormtaylor/slate/issues/2927 const stateRef = useRef({ showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange }); stateRef.current = { showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange }; @@ -96,12 +119,11 @@ export const DataLinkInput = memo( setScrollTop(getElementPosition(activeRef.current, suggestionsIndex)); }, [suggestionsIndex]); - // SelectionReference is used to position the variables suggestion relatively to current DOM selection - const selectionRef = useMemo(() => new SelectionReference(), []); - const onKeyDown = React.useCallback((event: React.KeyboardEvent, next: () => void) => { if (!stateRef.current.showingSuggestions) { if (event.key === '=' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) { + const selectionRef = new SelectionReference(); + refs.setReference(selectionRef); return setShowingSuggestions(true); } return next(); @@ -181,49 +203,21 @@ export const DataLinkInput = memo( <div id="data-link-input" className="slate-query-field"> {showingSuggestions && ( <Portal> - <ReactPopper - referenceElement={selectionRef} - placement="bottom-end" - modifiers={[ - { - name: 'preventOverflow', - enabled: true, - options: { - rootBoundary: 'viewport', - }, - }, - { - name: 'arrow', - enabled: false, - }, - { - name: 'offset', - options: { - offset: [250, 0], - }, - }, - ]} - > - {({ ref, style, placement }) => { - return ( - <div ref={ref} style={style} data-placement={placement} className={styles.suggestionsWrapper}> - <CustomScrollbar - scrollTop={scrollTop} - autoHeightMax="300px" - setScrollTop={({ scrollTop }) => setScrollTop(scrollTop)} - > - <DataLinkSuggestions - activeRef={activeRef} - suggestions={stateRef.current.suggestions} - onSuggestionSelect={onVariableSelect} - onClose={() => setShowingSuggestions(false)} - activeIndex={suggestionsIndex} - /> - </CustomScrollbar> - </div> - ); - }} - </ReactPopper> + <div ref={refs.setFloating} style={floatingStyles}> + <CustomScrollbar + scrollTop={scrollTop} + autoHeightMax="300px" + setScrollTop={({ scrollTop }) => setScrollTop(scrollTop)} + > + <DataLinkSuggestions + activeRef={activeRef} + suggestions={stateRef.current.suggestions} + onSuggestionSelect={onVariableSelect} + onClose={() => setShowingSuggestions(false)} + activeIndex={suggestionsIndex} + /> + </CustomScrollbar> + </div> </Portal> )} <Editor diff --git a/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx index 2f5d295ef2ccb..998c614738ea9 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useId } from 'react'; import { SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -153,12 +153,15 @@ export const DataSourceHttpSettings = (props: HttpSettingsProps) => { const inputStyle = cx({ [`width-20`]: true, [notValidStyle]: !isValidUrl }); + const fromFieldId = useId(); + const urlInput = ( <Input + id={fromFieldId} className={inputStyle} placeholder={defaultUrl} value={dataSourceConfig.url} - aria-label={selectors.components.DataSource.DataSourceHttpSettings.urlInput} + data-testid={selectors.components.DataSource.DataSourceHttpSettings.urlInput} onChange={(event) => onSettingsChange({ url: event.currentTarget.value })} disabled={dataSourceConfig.readOnly} /> diff --git a/packages/grafana-ui/src/components/DateTimePickers/DatePicker/DatePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/DatePicker/DatePicker.tsx index c2a7d8f64578c..358d985a169fb 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/DatePicker/DatePicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/DatePicker/DatePicker.tsx @@ -67,7 +67,6 @@ export const getStyles = (theme: GrafanaTheme2) => { return { modal: css({ zIndex: theme.zIndex.modal, - position: 'absolute', boxShadow: theme.shadows.z3, backgroundColor: theme.colors.background.primary, border: `1px solid ${theme.colors.border.weak}`, diff --git a/packages/grafana-ui/src/components/DateTimePickers/DatePickerWithInput/DatePickerWithInput.tsx b/packages/grafana-ui/src/components/DateTimePickers/DatePickerWithInput/DatePickerWithInput.tsx index 581d6b72e9679..515160fceed96 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/DatePickerWithInput/DatePickerWithInput.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/DatePickerWithInput/DatePickerWithInput.tsx @@ -1,7 +1,8 @@ import { css } from '@emotion/css'; -import React, { ChangeEvent } from 'react'; +import { autoUpdate, flip, shift, useClick, useDismiss, useFloating, useInteractions } from '@floating-ui/react'; +import React, { ChangeEvent, useState } from 'react'; -import { dateTime } from '@grafana/data'; +import { GrafanaTheme2, dateTime } from '@grafana/data'; import { useStyles2 } from '../../../themes'; import { Props as InputProps, Input } from '../../Input/Input'; @@ -35,17 +36,41 @@ export const DatePickerWithInput = ({ placeholder = 'Date', ...rest }: DatePickerWithInputProps) => { - const [open, setOpen] = React.useState(false); + const [open, setOpen] = useState(false); const styles = useStyles2(getStyles); + // the order of middleware is important! + // see https://floating-ui.com/docs/arrow#order + const middleware = [ + flip({ + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ]; + + const { context, refs, floatingStyles } = useFloating({ + open, + placement: 'bottom-start', + onOpenChange: setOpen, + middleware, + whileElementsMounted: autoUpdate, + strategy: 'fixed', + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]); + return ( <div className={styles.container}> <Input + ref={refs.setReference} type="text" autoComplete={'off'} placeholder={placeholder} value={value ? formatDate(value) : value} - onClick={() => setOpen(true)} onChange={(ev: ChangeEvent<HTMLInputElement>) => { // Allow resetting the date if (ev.target.value === '') { @@ -54,25 +79,28 @@ export const DatePickerWithInput = ({ }} className={styles.input} {...rest} + {...getReferenceProps()} /> - <DatePicker - isOpen={open} - value={value && typeof value !== 'string' ? value : dateTime().toDate()} - minDate={minDate} - maxDate={maxDate} - onChange={(ev) => { - onChange(ev); - if (closeOnSelect) { - setOpen(false); - } - }} - onClose={() => setOpen(false)} - /> + <div className={styles.popover} ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()}> + <DatePicker + isOpen={open} + value={value && typeof value !== 'string' ? value : dateTime().toDate()} + minDate={minDate} + maxDate={maxDate} + onChange={(ev) => { + onChange(ev); + if (closeOnSelect) { + setOpen(false); + } + }} + onClose={() => setOpen(false)} + /> + </div> </div> ); }; -const getStyles = () => { +const getStyles = (theme: GrafanaTheme2) => { return { container: css({ position: 'relative', @@ -84,5 +112,8 @@ const getStyles = () => { WebkitAppearance: 'none', }, }), + popover: css({ + zIndex: theme.zIndex.tooltip, + }), }; }; diff --git a/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.tsx index e0e7f45a113fa..4dfb5180d4f1b 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.tsx @@ -1,10 +1,10 @@ import { css, cx } from '@emotion/css'; +import { autoUpdate, flip, shift, useFloating } from '@floating-ui/react'; import { useDialog } from '@react-aria/dialog'; import { FocusScope } from '@react-aria/focus'; import { useOverlay } from '@react-aria/overlays'; import React, { FormEvent, ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import Calendar from 'react-calendar'; -import { usePopper } from 'react-popper'; import { useMedia } from 'react-use'; import { dateTimeFormat, DateTime, dateTime, GrafanaTheme2, isDateTime } from '@grafana/data'; @@ -76,11 +76,24 @@ export const DateTimePicker = ({ const isFullscreen = useMedia(`(min-width: ${theme.breakpoints.values.lg}px)`); const styles = useStyles2(getStyles); - const [markerElement, setMarkerElement] = useState<HTMLInputElement | null>(); - const [selectorElement, setSelectorElement] = useState<HTMLDivElement | null>(); + // the order of middleware is important! + // see https://floating-ui.com/docs/arrow#order + const middleware = [ + flip({ + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ]; - const popper = usePopper(markerElement, selectorElement, { + const { refs, floatingStyles } = useFloating({ + open: isOpen, placement: 'bottom-start', + onOpenChange: setOpen, + middleware, + whileElementsMounted: autoUpdate, + strategy: 'fixed', }); const onApply = useCallback( @@ -107,7 +120,7 @@ export const DateTimePicker = ({ isFullscreen={isFullscreen} onOpen={onOpen} label={label} - ref={setMarkerElement} + ref={refs.setReference} showSeconds={showSeconds} /> {isOpen ? ( @@ -122,8 +135,8 @@ export const DateTimePicker = ({ onClose={() => setOpen(false)} maxDate={maxDate} minDate={minDate} - ref={setSelectorElement} - style={popper.styles.popper} + ref={refs.setFloating} + style={floatingStyles} showSeconds={showSeconds} disabledHours={disabledHours} disabledMinutes={disabledMinutes} diff --git a/packages/grafana-ui/src/components/DateTimePickers/RelativeTimeRangePicker/RelativeTimeRangePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/RelativeTimeRangePicker/RelativeTimeRangePicker.tsx index ec797abbb770b..b7f5b76bda34c 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/RelativeTimeRangePicker/RelativeTimeRangePicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/RelativeTimeRangePicker/RelativeTimeRangePicker.tsx @@ -1,9 +1,9 @@ import { css, cx } from '@emotion/css'; +import { autoUpdate, flip, shift, useClick, useDismiss, useFloating, useInteractions } from '@floating-ui/react'; import { useDialog } from '@react-aria/dialog'; import { FocusScope } from '@react-aria/focus'; import { useOverlay } from '@react-aria/overlays'; import React, { FormEvent, useCallback, useRef, useState } from 'react'; -import { usePopper } from 'react-popper'; import { RelativeTimeRange, GrafanaTheme2, TimeOption } from '@grafana/data'; @@ -59,12 +59,31 @@ export function RelativeTimeRangePicker(props: RelativeTimeRangePickerProps) { ); const { dialogProps } = useDialog({}, ref); - const [markerElement, setMarkerElement] = useState<HTMLDivElement | null>(null); - const [selectorElement, setSelectorElement] = useState<HTMLDivElement | null>(null); - const popper = usePopper(markerElement, selectorElement, { - placement: 'auto-start', + // the order of middleware is important! + // see https://floating-ui.com/docs/arrow#order + const middleware = [ + flip({ + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ]; + + const { context, refs, floatingStyles } = useFloating({ + open: isOpen, + placement: 'bottom-start', + onOpenChange: setIsOpen, + middleware, + whileElementsMounted: autoUpdate, + strategy: 'fixed', }); + const click = useClick(context); + const dismiss = useDismiss(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]); + const styles = useStyles2(getStyles(from.validation.errorMessage, to.validation.errorMessage)); const onChangeTimeOption = (option: TimeOption) => { @@ -109,8 +128,14 @@ export function RelativeTimeRangePicker(props: RelativeTimeRangePickerProps) { }; return ( - <div className={styles.container} ref={setMarkerElement}> - <button className={styles.pickerInput} type="button" onClick={onOpen}> + <div className={styles.container}> + <button + ref={refs.setReference} + className={styles.pickerInput} + type="button" + onClick={onOpen} + {...getReferenceProps()} + > <span className={styles.clockIcon}> <Icon name="clock-nine" /> </span> @@ -126,12 +151,7 @@ export function RelativeTimeRangePicker(props: RelativeTimeRangePickerProps) { <div role="presentation" className={styles.backdrop} {...underlayProps} /> <FocusScope contain autoFocus restoreFocus> <div ref={ref} {...overlayProps} {...dialogProps}> - <div - className={styles.content} - ref={setSelectorElement} - style={popper.styles.popper} - {...popper.attributes} - > + <div className={styles.content} ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()}> <div className={styles.body}> <CustomScrollbar className={styles.leftSide} hideHorizontalTrack> <TimeRangeList diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangeInput.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangeInput.tsx index 8a63f528743ad..03b48a4d7ffe2 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangeInput.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangeInput.tsx @@ -78,7 +78,7 @@ export const TimeRangeInput = ({ <button type="button" className={styles.pickerInput} - aria-label={selectors.components.TimePicker.openButton} + data-testid={selectors.components.TimePicker.openButton} onClick={onOpen} > {showIcon && <Icon name="clock-nine" size={'sm'} className={styles.icon} />} diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx index d59f7b6ec858b..0954bd995124b 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx @@ -39,6 +39,7 @@ export interface TimeRangePickerProps { onMoveBackward: () => void; onMoveForward: () => void; onZoom: () => void; + onError?: (error?: string) => void; history?: TimeRange[]; hideQuickRanges?: boolean; widthOverride?: number; @@ -58,6 +59,7 @@ export function TimeRangePicker(props: TimeRangePickerProps) { onMoveBackward, onMoveForward, onZoom, + onError, timeZone, fiscalYearStartMonth, timeSyncButton, @@ -151,7 +153,7 @@ export function TimeRangePicker(props: TimeRangePickerProps) { {isOpen && ( <div data-testid={selectors.components.TimePicker.overlayContent}> <div role="presentation" className={cx(modalBackdrop, styles.backdrop)} {...underlayProps} /> - <FocusScope contain autoFocus> + <FocusScope contain autoFocus restoreFocus> <section className={styles.content} ref={overlayRef} {...overlayProps} {...dialogProps}> <TimePickerContent timeZone={timeZone} @@ -165,6 +167,7 @@ export function TimeRangePicker(props: TimeRangePickerProps) { onChangeTimeZone={onChangeTimeZone} onChangeFiscalYearStartMonth={onChangeFiscalYearStartMonth} hideQuickRanges={hideQuickRanges} + onError={onError} /> </section> </FocusScope> diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx index a0bc70b19ccf3..d7245a9bbcdf4 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx @@ -22,6 +22,7 @@ interface Props { onChange: (timeRange: TimeRange) => void; onChangeTimeZone: (timeZone: TimeZone) => void; onChangeFiscalYearStartMonth?: (month: number) => void; + onError?: (error?: string) => void; timeZone?: TimeZone; fiscalYearStartMonth?: number; quickOptions?: TimeOption[]; @@ -82,7 +83,6 @@ export const TimePickerContentWithScreenSize = (props: PropsWithScreenSize) => { <div className={styles.timeRangeFilter}> <FilterInput width={0} - autoFocus={true} value={searchTerm} onChange={setSearchQuery} placeholder={t('time-picker.content.filter-placeholder', 'Search quick ranges')} @@ -122,7 +122,7 @@ export const TimePickerContent = (props: Props) => { }; const NarrowScreenForm = (props: FormProps) => { - const { value, hideQuickRanges, onChange, timeZone, historyOptions = [], showHistory } = props; + const { value, hideQuickRanges, onChange, timeZone, historyOptions = [], showHistory, onError } = props; const styles = useStyles2(getNarrowScreenStyles); const isAbsolute = isDateTime(value.raw.from) || isDateTime(value.raw.to); const [collapsedFlag, setCollapsedFlag] = useState(!isAbsolute); @@ -156,7 +156,13 @@ const NarrowScreenForm = (props: FormProps) => { {!collapsed && ( <div className={styles.body} id="expanded-timerange"> <div className={styles.form}> - <TimeRangeContent value={value} onApply={onChange} timeZone={timeZone} isFullscreen={false} /> + <TimeRangeContent + value={value} + onApply={onChange} + timeZone={timeZone} + isFullscreen={false} + onError={onError} + /> </div> {showHistory && ( <TimeRangeList @@ -173,7 +179,7 @@ const NarrowScreenForm = (props: FormProps) => { }; const FullScreenForm = (props: FormProps) => { - const { onChange, value, timeZone, fiscalYearStartMonth, isReversed, historyOptions } = props; + const { onChange, value, timeZone, fiscalYearStartMonth, isReversed, historyOptions, onError } = props; const styles = useStyles2(getFullScreenStyles, props.hideQuickRanges); const onChangeTimeOption = (timeOption: TimeOption) => { return onChange(mapOptionToTimeRange(timeOption, timeZone)); @@ -194,6 +200,7 @@ const FullScreenForm = (props: FormProps) => { onApply={onChange} isFullscreen={true} isReversed={isReversed} + onError={onError} /> </div> {props.showHistory && ( @@ -273,6 +280,8 @@ const getStyles = ( borderRadius: theme.shape.radius.default, border: `1px solid ${theme.colors.border.weak}`, [`${isReversed ? 'left' : 'right'}`]: 0, + display: 'flex', + flexDirection: 'column', }), body: css({ display: 'flex', @@ -285,7 +294,7 @@ const getStyles = ( flexDirection: 'column', borderRight: `${isReversed ? 'none' : `1px solid ${theme.colors.border.weak}`}`, width: `${!hideQuickRanges ? '60%' : '100%'}`, - overflow: 'hidden', + overflow: 'auto', order: isReversed ? 1 : 0, }), rightSide: css({ diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx index 67e14033bdf2b..31c40a0fb0a3b 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx @@ -73,7 +73,12 @@ export const TimePickerFooter = (props: Props) => { <TimeZoneOffset timeZone={timeZone} timestamp={timestamp} /> </div> <div className={style.spacer} /> - <Button variant="secondary" onClick={onToggleChangeTimeSettings} size="sm"> + <Button + data-testid={selectors.components.TimeZonePicker.changeTimeSettingsButton} + variant="secondary" + onClick={onToggleChangeTimeSettings} + size="sm" + > <Trans i18nKey="time-picker.footer.change-settings-button">Change time settings</Trans> </Button> </section> diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx index a03b7c5ea7d9f..40a1964b22422 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, RenderResult } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { dateTimeParse, TimeRange } from '@grafana/data'; @@ -10,6 +11,15 @@ type TimeRangeFormRenderResult = RenderResult & { getCalendarDayByLabelText(label: string): HTMLButtonElement; }; +const mockClipboard = { + writeText: jest.fn(), + readText: jest.fn(), +}; + +Object.defineProperty(global.navigator, 'clipboard', { + value: mockClipboard, +}); + const defaultTimeRange: TimeRange = { from: dateTimeParse('2021-06-17 00:00:00', { timeZone: 'utc' }), to: dateTimeParse('2021-06-19 23:59:00', { timeZone: 'utc' }), @@ -19,6 +29,11 @@ const defaultTimeRange: TimeRange = { }, }; +const customRawTimeRange = { + from: '2023-06-17 00:00:00', + to: '2023-06-19 23:59:00', +}; + function setup(initial: TimeRange = defaultTimeRange, timeZone = 'utc'): TimeRangeFormRenderResult { const result = render( <TimeRangeContent isFullscreen={true} value={initial} onApply={() => {}} timeZone={timeZone} /> @@ -34,7 +49,7 @@ function setup(initial: TimeRange = defaultTimeRange, timeZone = 'utc'): TimeRan } describe('TimeRangeForm', () => { - it('should render form correcty', () => { + it('should render form correctly', () => { const { getByLabelText, getByText, getAllByRole } = setup(); expect(getByText('Apply time range')).toBeInTheDocument(); @@ -105,6 +120,26 @@ describe('TimeRangeForm', () => { expect(to).toHaveClass('react-calendar__tile--rangeEnd'); }); + it('should copy time range to clipboard', async () => { + const { getByTestId } = setup(); + + await userEvent.click(getByTestId('data-testid TimePicker copy button')); + expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith( + JSON.stringify({ from: defaultTimeRange.raw.from, to: defaultTimeRange.raw.to }) + ); + }); + + it('should paste time range from clipboard', async () => { + const { getByTestId, getByLabelText } = setup(); + + mockClipboard.readText.mockResolvedValue(JSON.stringify(customRawTimeRange)); + + await userEvent.click(getByTestId('data-testid TimePicker paste button')); + + expect(getByLabelText('From')).toHaveValue(customRawTimeRange.from); + expect(getByLabelText('To')).toHaveValue(customRawTimeRange.to); + }); + describe('dates error handling', () => { it('should show error on invalid dates', () => { const invalidTimeRange: TimeRange = { diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx index 4cbf3748b4d10..d5a53aa10f30c 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx @@ -33,6 +33,7 @@ interface Props { fiscalYearStartMonth?: number; roundup?: boolean; isReversed?: boolean; + onError?: (error?: string) => void; } interface InputState { @@ -47,7 +48,15 @@ const ERROR_MESSAGES = { }; export const TimeRangeContent = (props: Props) => { - const { value, isFullscreen = false, timeZone, onApply: onApplyFromProps, isReversed, fiscalYearStartMonth } = props; + const { + value, + isFullscreen = false, + timeZone, + onApply: onApplyFromProps, + isReversed, + fiscalYearStartMonth, + onError, + } = props; const [fromValue, toValue] = valueToState(value.raw.from, value.raw.to, timeZone); const style = useStyles2(getStyles); @@ -99,6 +108,29 @@ export const TimeRangeContent = (props: Props) => { } }; + const onCopy = () => { + const raw: RawTimeRange = { from: from.value, to: to.value }; + navigator.clipboard.writeText(JSON.stringify(raw)); + }; + + const onPaste = async () => { + const raw = await navigator.clipboard.readText(); + let range; + + try { + range = JSON.parse(raw); + } catch (error) { + if (onError) { + onError(raw); + } + return; + } + + const [fromValue, toValue] = valueToState(range.from, range.to, timeZone); + setFrom(fromValue); + setTo(toValue); + }; + const fiscalYear = rangeUtil.convertRawToRange({ from: 'now/fy', to: 'now/fy' }, timeZone, fiscalYearStartMonth); const fiscalYearMessage = t('time-picker.range-content.fiscal-year', 'Fiscal year'); @@ -159,9 +191,27 @@ export const TimeRangeContent = (props: Props) => { </Field> {fyTooltip} </div> - <Button data-testid={selectors.components.TimePicker.applyTimeRange} type="button" onClick={onApply}> - <Trans i18nKey="time-picker.range-content.apply-button">Apply time range</Trans> - </Button> + <div className={style.buttonsContainer}> + <Button + data-testid={selectors.components.TimePicker.copyTimeRange} + icon="copy" + variant="secondary" + tooltip={t('time-picker.copy-paste.tooltip-copy', 'Copy time range to clipboard')} + type="button" + onClick={onCopy} + /> + <Button + data-testid={selectors.components.TimePicker.pasteTimeRange} + icon="clipboard-alt" + variant="secondary" + tooltip={t('time-picker.copy-paste.tooltip-paste', 'Paste time range')} + type="button" + onClick={onPaste} + /> + <Button data-testid={selectors.components.TimePicker.applyTimeRange} type="button" onClick={onApply}> + <Trans i18nKey="time-picker.range-content.apply-button">Apply time range</Trans> + </Button> + </div> <TimePickerCalendar isFullscreen={isFullscreen} @@ -220,6 +270,11 @@ function getStyles(theme: GrafanaTheme2) { fieldContainer: css({ display: 'flex', }), + buttonsContainer: css({ + display: 'flex', + gap: theme.spacing(0.5), + marginTop: theme.spacing(1), + }), tooltip: css({ paddingLeft: theme.spacing(1), paddingTop: theme.spacing(3), diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker/TimeZoneOffset.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker/TimeZoneOffset.tsx index 0c1ca7fbb7d36..e9e269d2c15ec 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker/TimeZoneOffset.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker/TimeZoneOffset.tsx @@ -33,9 +33,6 @@ export const formatUtcOffset = (timestamp: number, timeZone: TimeZone): string = format: 'Z', }); - if (offset === '+00:00') { - return 'UTC'; - } return `UTC${offset}`; }; diff --git a/packages/grafana-ui/src/components/DragHandle/DragHandle.tsx b/packages/grafana-ui/src/components/DragHandle/DragHandle.tsx new file mode 100644 index 0000000000000..1c48a33992a05 --- /dev/null +++ b/packages/grafana-ui/src/components/DragHandle/DragHandle.tsx @@ -0,0 +1,105 @@ +import { css, cx } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export type DragHandlePosition = 'middle' | 'start' | 'end'; + +export const getDragStyles = (theme: GrafanaTheme2, handlePosition?: DragHandlePosition) => { + const position = handlePosition || 'middle'; + const baseColor = theme.colors.emphasize(theme.colors.background.secondary, 0.15); + const hoverColor = theme.colors.primary.border; + const clickTargetSize = theme.spacing(2); + const handlebarThickness = 4; + const handlebarWidth = 200; + let verticalOffset = '50%'; + let horizontalOffset = '50%'; + + switch (position) { + case 'start': { + verticalOffset = '0%'; + horizontalOffset = '0%'; + break; + } + case 'end': { + verticalOffset = '100%'; + horizontalOffset = '100%'; + break; + } + } + + const dragHandleBase = css({ + position: 'relative', + + '&:before': { + content: '""', + position: 'absolute', + transition: theme.transitions.create('border-color'), + zIndex: 1, + }, + + '&:after': { + background: baseColor, + content: '""', + position: 'absolute', + transition: theme.transitions.create('background'), + transform: 'translate(-50%, -50%)', + borderRadius: theme.shape.radius.pill, + zIndex: 1, + }, + + '&:hover': { + '&:before': { + borderColor: hoverColor, + }, + + '&:after': { + background: hoverColor, + }, + }, + }); + + return { + dragHandleVertical: cx( + dragHandleBase, + css({ + cursor: 'col-resize', + width: clickTargetSize, + + '&:before': { + borderRight: '1px solid transparent', + height: '100%', + left: verticalOffset, + transform: 'translateX(-50%)', + }, + + '&:after': { + left: verticalOffset, + top: '50%', + height: handlebarWidth, + width: handlebarThickness, + }, + }) + ), + dragHandleHorizontal: cx( + dragHandleBase, + css({ + height: clickTargetSize, + cursor: 'row-resize', + + '&:before': { + borderTop: '1px solid transparent', + top: horizontalOffset, + transform: 'translateY(-50%)', + width: '100%', + }, + + '&:after': { + left: '50%', + top: horizontalOffset, + height: handlebarThickness, + width: handlebarWidth, + }, + }) + ), + }; +}; diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.tsx index d36c05ff73362..3459c21ba599e 100644 --- a/packages/grafana-ui/src/components/Drawer/Drawer.tsx +++ b/packages/grafana-ui/src/components/Drawer/Drawer.tsx @@ -3,7 +3,7 @@ import { useDialog } from '@react-aria/dialog'; import { FocusScope } from '@react-aria/focus'; import { useOverlay } from '@react-aria/overlays'; import RcDrawer from 'rc-drawer'; -import React, { ReactNode, useEffect } from 'react'; +import React, { ReactNode, useCallback, useEffect, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -11,6 +11,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { useStyles2 } from '../../themes'; import { t } from '../../utils/i18n'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; +import { getDragStyles } from '../DragHandle/DragHandle'; import { IconButton } from '../IconButton/IconButton'; import { Text } from '../Text/Text'; @@ -37,7 +38,6 @@ export interface Props { * sm = width 25vw & min-width 384px * md = width 50vw & min-width 568px * lg = width 75vw & min-width 744px - * xl = width 85vw & min-width 744px **/ size?: 'sm' | 'md' | 'lg'; /** Tabs */ @@ -62,7 +62,12 @@ export function Drawer({ size = 'md', tabs, }: Props) { + const [drawerWidth, onMouseDown, onTouchStart] = useResizebleDrawer(); + const styles = useStyles2(getStyles); + const sizeStyles = useStyles2(getSizeStyles, size, drawerWidth ?? width); + const dragStyles = useStyles2(getDragStyles); + const overlayRef = React.useRef(null); const { dialogProps, titleProps } = useDialog({}, overlayRef); const { overlayProps } = useOverlay( @@ -77,8 +82,7 @@ export function Drawer({ // Adds body class while open so the toolbar nav can hide some actions while drawer is open useBodyClassWhileOpen(); - // Apply size styles (unless deprecated width prop is used) - const rootClass = cx(styles.drawer, !width && styles.sizes[size]); + const rootClass = cx(styles.drawer, sizeStyles); const content = <div className={styles.content}>{children}</div>; return ( @@ -86,11 +90,10 @@ export function Drawer({ open={true} onClose={onClose} placement="right" - // Important to set this to empty string so that the width can be controlled by the css - width={width ?? ''} getContainer={'.main-view'} className={styles.drawerContent} rootClassName={rootClass} + width={''} motion={{ motionAppear: true, motionName: styles.drawerMotion, @@ -114,6 +117,12 @@ export function Drawer({ {...dialogProps} ref={overlayRef} > + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} + <div + className={cx(dragStyles.dragHandleVertical, styles.resizer)} + onMouseDown={onMouseDown} + onTouchStart={onTouchStart} + /> {typeof title === 'string' && ( <div className={cx(styles.header, Boolean(tabs) && styles.headerWithTabs)}> <div className={styles.actions}> @@ -122,14 +131,18 @@ export function Drawer({ variant="secondary" onClick={onClose} data-testid={selectors.components.Drawer.General.close} - tooltip={t(`drawer.close`, 'Close Drawer')} + tooltip={t(`grafana-ui.drawer.close`, 'Close')} /> </div> <div className={styles.titleWrapper}> <Text element="h3" {...titleProps}> {title} </Text> - {subtitle && <div className={styles.subtitle}>{subtitle}</div>} + {subtitle && ( + <div className={styles.subtitle} data-testid={selectors.components.Drawer.General.subtitle}> + {subtitle} + </div> + )} {tabs && <div className={styles.tabsWrapper}>{tabs}</div>} </div> </div> @@ -142,6 +155,63 @@ export function Drawer({ ); } +function useResizebleDrawer(): [ + string | undefined, + React.EventHandler<React.MouseEvent>, + React.EventHandler<React.TouchEvent>, +] { + const [drawerWidth, setDrawerWidth] = useState<string | undefined>(undefined); + + const onMouseMove = useCallback((e: MouseEvent) => { + setDrawerWidth(getCustomDrawerWidth(e.clientX)); + }, []); + + const onTouchMove = useCallback((e: TouchEvent) => { + const touch = e.touches[0]; + setDrawerWidth(getCustomDrawerWidth(touch.clientX)); + }, []); + + const onMouseUp = useCallback( + (e: MouseEvent) => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }, + [onMouseMove] + ); + + const onTouchEnd = useCallback( + (e: TouchEvent) => { + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', onTouchEnd); + }, + [onTouchMove] + ); + + function onMouseDown(e: React.MouseEvent<HTMLDivElement>) { + e.stopPropagation(); + e.preventDefault(); + // we will only add listeners when needed, and remove them afterward + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + } + + function onTouchStart(e: React.TouchEvent<HTMLDivElement>) { + e.stopPropagation(); + e.preventDefault(); + // we will only add listeners when needed, and remove them afterward + document.addEventListener('touchmove', onTouchMove); + document.addEventListener('touchend', onTouchEnd); + } + + return [drawerWidth, onMouseDown, onTouchStart]; +} + +function getCustomDrawerWidth(clientX: number) { + let offsetRight = document.body.offsetWidth - (clientX - document.body.offsetLeft); + let widthPercent = Math.min((offsetRight / document.body.clientWidth) * 100, 98).toFixed(2); + return `${widthPercent}vw`; +} + function useBodyClassWhileOpen() { useEffect(() => { if (!document.body) { @@ -164,14 +234,15 @@ const getStyles = (theme: GrafanaTheme2) => { height: '100%', flex: '1 1 0', minHeight: '100%', + position: 'relative', }), drawer: css({ '.main-view &': { - top: 81, + top: 80, }, '.main-view--search-bar-hidden &': { - top: 41, + top: 40, }, '.main-view--chrome-hidden &': { @@ -180,47 +251,13 @@ const getStyles = (theme: GrafanaTheme2) => { '.rc-drawer-content-wrapper': { boxShadow: theme.shadows.z3, - - [theme.breakpoints.down('sm')]: { - width: `calc(100% - ${theme.spacing(2)}) !important`, - minWidth: '0 !important', - }, }, }), - sizes: { - sm: css({ - '.rc-drawer-content-wrapper': { - label: 'drawer-sm', - width: '25vw', - minWidth: theme.spacing(48), - }, - }), - md: css({ - '.rc-drawer-content-wrapper': { - label: 'drawer-md', - width: '50vw', - minWidth: theme.spacing(60), - }, - }), - lg: css({ - '.rc-drawer-content-wrapper': { - label: 'drawer-lg', - width: '85vw', - minWidth: theme.spacing(93), - - [theme.breakpoints.down('md')]: { - width: `calc(100% - ${theme.spacing(2)}) !important`, - minWidth: 0, - }, - }, - }), - }, drawerContent: css({ backgroundColor: `${theme.colors.background.primary} !important`, display: 'flex', + overflow: 'unset', flexDirection: 'column', - overflow: 'hidden', - zIndex: theme.zIndex.dropdown, }), drawerMotion: css({ '&-appear': { @@ -251,11 +288,11 @@ const getStyles = (theme: GrafanaTheme2) => { right: 0, '.main-view &': { - top: 81, + top: 80, }, '.main-view--search-bar-hidden &': { - top: 41, + top: 40, }, '.main-view--chrome-hidden &': { @@ -306,5 +343,37 @@ const getStyles = (theme: GrafanaTheme2) => { paddingLeft: theme.spacing(2), margin: theme.spacing(1, -1, -3, -3), }), + resizer: css({ + top: 0, + left: theme.spacing(-1), + bottom: 0, + position: 'absolute', + zIndex: theme.zIndex.modal, + }), }; }; + +const drawerSizes = { + sm: { width: '25vw', minWidth: 384 }, + md: { width: '50vw', minWidth: 568 }, + lg: { width: '75vw', minWidth: 744 }, +}; + +function getSizeStyles(theme: GrafanaTheme2, size: 'sm' | 'md' | 'lg', overrideWidth: number | string | undefined) { + let width = overrideWidth ?? drawerSizes[size].width; + let minWidth = drawerSizes[size].minWidth; + + return css({ + '.rc-drawer-content-wrapper': { + label: `drawer-content-wrapper-${size}`, + width: width, + minWidth: minWidth, + overflow: 'unset', + + [theme.breakpoints.down('md')]: { + width: `calc(100% - ${theme.spacing(2)}) !important`, + minWidth: 0, + }, + }, + }); +} diff --git a/packages/grafana-ui/src/components/Dropdown/ButtonSelect.tsx b/packages/grafana-ui/src/components/Dropdown/ButtonSelect.tsx index 1a1e6e5b37e67..e6fb86ea88258 100644 --- a/packages/grafana-ui/src/components/Dropdown/ButtonSelect.tsx +++ b/packages/grafana-ui/src/components/Dropdown/ButtonSelect.tsx @@ -1,14 +1,20 @@ import { css } from '@emotion/css'; -import { useButton } from '@react-aria/button'; +import { + autoUpdate, + flip, + offset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, +} from '@floating-ui/react'; import { FocusScope } from '@react-aria/focus'; -import { useMenuTrigger } from '@react-aria/menu'; -import { useMenuTriggerState } from '@react-stately/menu'; -import React, { HTMLAttributes } from 'react'; +import React, { HTMLAttributes, useState } from 'react'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { useStyles2 } from '../../themes/ThemeContext'; -import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper'; import { Menu } from '../Menu/Menu'; import { MenuItem } from '../Menu/MenuItem'; import { ToolbarButton, ToolbarButtonVariant } from '../ToolbarButton'; @@ -33,53 +39,72 @@ export interface Props<T> extends HTMLAttributes<HTMLButtonElement> { const ButtonSelectComponent = <T,>(props: Props<T>) => { const { className, options, value, onChange, narrow, variant, ...restProps } = props; const styles = useStyles2(getStyles); - const state = useMenuTriggerState({}); + const [isOpen, setIsOpen] = useState(false); - const ref = React.useRef(null); - const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref); - const { buttonProps } = useButton(menuTriggerProps, ref); + // the order of middleware is important! + const middleware = [ + offset(0), + flip({ + fallbackAxisSideDirection: 'end', + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ]; + + const { context, refs, floatingStyles } = useFloating({ + open: isOpen, + placement: 'bottom-end', + onOpenChange: setIsOpen, + middleware, + whileElementsMounted: autoUpdate, + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]); const onChangeInternal = (item: SelectableValue<T>) => { onChange(item); - state.close(); + setIsOpen(false); }; return ( <div className={styles.wrapper}> <ToolbarButton className={className} - isOpen={state.isOpen} + isOpen={isOpen} narrow={narrow} variant={variant} - ref={ref} - {...buttonProps} + ref={refs.setReference} + {...getReferenceProps()} {...restProps} > {value?.label || (value?.value != null ? String(value?.value) : null)} </ToolbarButton> - {state.isOpen && ( - <div className={styles.menuWrapper}> - <ClickOutsideWrapper onClick={state.close} parent={document} includeButtonPress={false}> - <FocusScope contain autoFocus restoreFocus> - {/* - tabIndex=-1 is needed here to support highlighting text within the menu when using FocusScope - see https://github.com/adobe/react-spectrum/issues/1604#issuecomment-781574668 - */} - <Menu tabIndex={-1} onClose={state.close} {...menuProps} autoFocus={!!menuProps.autoFocus}> - {options.map((item) => ( - <MenuItem - key={`${item.value}`} - label={item.label ?? String(item.value)} - onClick={() => onChangeInternal(item)} - active={item.value === value?.value} - ariaChecked={item.value === value?.value} - ariaLabel={item.ariaLabel || item.label} - role="menuitemradio" - /> - ))} - </Menu> - </FocusScope> - </ClickOutsideWrapper> + {isOpen && ( + <div className={styles.menuWrapper} ref={refs.setFloating} {...getFloatingProps()} style={floatingStyles}> + <FocusScope contain autoFocus restoreFocus> + {/* + tabIndex=-1 is needed here to support highlighting text within the menu when using FocusScope + see https://github.com/adobe/react-spectrum/issues/1604#issuecomment-781574668 + */} + <Menu tabIndex={-1} onClose={() => setIsOpen(false)}> + {options.map((item) => ( + <MenuItem + key={`${item.value}`} + label={item.label ?? String(item.value)} + onClick={() => onChangeInternal(item)} + active={item.value === value?.value} + ariaChecked={item.value === value?.value} + ariaLabel={item.ariaLabel || item.label} + role="menuitemradio" + /> + ))} + </Menu> + </FocusScope> </div> )} </div> @@ -100,10 +125,7 @@ const getStyles = (theme: GrafanaTheme2) => { display: 'inline-flex', }), menuWrapper: css({ - position: 'absolute', zIndex: theme.zIndex.dropdown, - top: theme.spacing(4), - right: 0, }), }; }; diff --git a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx index 3db8ed9c69f18..759c96b0dc73d 100644 --- a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx +++ b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx @@ -1,10 +1,20 @@ import { css } from '@emotion/css'; +import { + autoUpdate, + flip, + offset as floatingUIOffset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, +} from '@floating-ui/react'; import { FocusScope } from '@react-aria/focus'; import React, { useEffect, useRef, useState } from 'react'; -import { usePopperTooltip } from 'react-popper-tooltip'; import { CSSTransition } from 'react-transition-group'; import { ReactUtils } from '../../utils'; +import { getPlacement } from '../../utils/tooltipUtils'; import { Portal } from '../Portal/Portal'; import { TooltipPlacement } from '../Tooltip/types'; @@ -25,17 +35,33 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi onVisibleChange?.(show); }, [onVisibleChange, show]); - const { getArrowProps, getTooltipProps, setTooltipRef, setTriggerRef, visible } = usePopperTooltip({ - visible: show, - placement: placement, - onVisibleChange: setShow, - interactive: true, - delayHide: 0, - delayShow: 0, - offset: offset ?? [0, 8], - trigger: ['click'], + // the order of middleware is important! + const middleware = [ + floatingUIOffset({ + mainAxis: offset?.[0] ?? 8, + crossAxis: offset?.[1] ?? 0, + }), + flip({ + fallbackAxisSideDirection: 'end', + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ]; + + const { context, refs, floatingStyles } = useFloating({ + open: show, + placement: getPlacement(placement), + onOpenChange: setShow, + middleware, + whileElementsMounted: autoUpdate, }); + const click = useClick(context); + const dismiss = useDismiss(context); + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]); + const animationDuration = 150; const animationStyles = getStyles(animationDuration); @@ -44,7 +70,7 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi }; const handleKeys = (event: React.KeyboardEvent) => { - if (event.key === 'Escape' || event.key === 'Tab') { + if (event.key === 'Tab') { setShow(false); } }; @@ -52,9 +78,10 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi return ( <> {React.cloneElement(children, { - ref: setTriggerRef, + ref: refs.setReference, + ...getReferenceProps(), })} - {visible && ( + {show && ( <Portal> <FocusScope autoFocus restoreFocus contain> {/* @@ -62,8 +89,7 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi see https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} - <div ref={setTooltipRef} {...getTooltipProps()} onClick={onOverlayClicked} onKeyDown={handleKeys}> - <div {...getArrowProps({ className: 'tooltip-arrow' })} /> + <div ref={refs.setFloating} style={floatingStyles} onClick={onOverlayClicked} onKeyDown={handleKeys}> <CSSTransition nodeRef={transitionRef} appear={true} @@ -71,7 +97,7 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi timeout={{ appear: animationDuration, exit: 0, enter: 0 }} classNames={animationStyles} > - <div ref={transitionRef}>{ReactUtils.renderOrCallToRender(overlay, {})}</div> + <div ref={transitionRef}>{ReactUtils.renderOrCallToRender(overlay, { ...getFloatingProps() })}</div> </CSSTransition> </div> </FocusScope> diff --git a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx index 808ed22c4ca5a..4c5760a377e84 100644 --- a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx +++ b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -import React, { PureComponent, ReactNode, ComponentType } from 'react'; +import React, { PureComponent, ReactNode, ComponentType, ErrorInfo } from 'react'; import { faro } from '@grafana/faro-web-sdk'; @@ -6,9 +6,7 @@ import { Alert } from '../Alert/Alert'; import { ErrorWithStack } from './ErrorWithStack'; -export interface ErrorInfo { - componentStack: string; -} +export type { ErrorInfo }; export interface ErrorBoundaryApi { error: Error | null; diff --git a/packages/grafana-ui/src/components/Forms/Checkbox.tsx b/packages/grafana-ui/src/components/Forms/Checkbox.tsx index 1802382d588db..74716ec57d560 100644 --- a/packages/grafana-ui/src/components/Forms/Checkbox.tsx +++ b/packages/grafana-ui/src/components/Forms/Checkbox.tsx @@ -12,7 +12,7 @@ export interface CheckboxProps extends Omit<HTMLProps<HTMLInputElement>, 'value' /** Label to display next to checkbox */ label?: string; /** Description to display under the label */ - description?: string; + description?: string | React.ReactElement; /** Current value of the checkbox */ value?: boolean; /** htmlValue allows to specify the input "value" attribute */ diff --git a/packages/grafana-ui/src/components/Forms/FieldArray.mdx b/packages/grafana-ui/src/components/Forms/FieldArray.mdx index 04060391c1d61..8f428a46141a2 100644 --- a/packages/grafana-ui/src/components/Forms/FieldArray.mdx +++ b/packages/grafana-ui/src/components/Forms/FieldArray.mdx @@ -7,6 +7,8 @@ import { FieldArray } from './FieldArray'; `FieldArray` provides a way to render a list of dynamic inputs. It exposes the functionality of `useFieldArray` in [react-hook-form](https://react-hook-form.com/advanced-usage/#FieldArrays). `FieldArray` must be wrapped at some level by a `<Form>` element. +**Note: This component is deprecated and will be removed in the future versions of grafana/ui. Use the `useFieldArray` hook from react-hook-form instead.** + ### Usage ```jsx diff --git a/packages/grafana-ui/src/components/Forms/FieldArray.tsx b/packages/grafana-ui/src/components/Forms/FieldArray.tsx index bab7d3ebae1fa..69ebbbca65789 100644 --- a/packages/grafana-ui/src/components/Forms/FieldArray.tsx +++ b/packages/grafana-ui/src/components/Forms/FieldArray.tsx @@ -7,6 +7,9 @@ export interface FieldArrayProps extends UseFieldArrayProps { children: (api: FieldArrayApi) => JSX.Element; } +/** + * @deprecated use the `useFieldArray` hook from react-hook-form instead + */ export const FieldArray: FC<FieldArrayProps> = ({ name, control, children, ...rest }) => { const { fields, append, prepend, remove, swap, move, insert } = useFieldArray({ control, diff --git a/packages/grafana-ui/src/components/Forms/Form.mdx b/packages/grafana-ui/src/components/Forms/Form.mdx index 35757b43dddc6..3c85eb86636ff 100644 --- a/packages/grafana-ui/src/components/Forms/Form.mdx +++ b/packages/grafana-ui/src/components/Forms/Form.mdx @@ -7,6 +7,8 @@ import { Form } from './Form'; Form component provides a way to build simple forms at Grafana. It is built on top of [react-hook-form](https://react-hook-form.com/) library and incorporates the same concepts while adjusting the API slightly. +**Note: This component is deprecated and will be removed in the future versions of grafana/ui. Use the `useForm` hook from react-hook-form instead.** + ## Usage ```tsx diff --git a/packages/grafana-ui/src/components/Forms/Form.tsx b/packages/grafana-ui/src/components/Forms/Form.tsx index d0362bd0824aa..065dab5c7941d 100644 --- a/packages/grafana-ui/src/components/Forms/Form.tsx +++ b/packages/grafana-ui/src/components/Forms/Form.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import React, { HTMLProps, useEffect } from 'react'; -import { useForm, Mode, DeepPartial, UnpackNestedValue, SubmitHandler, FieldValues } from 'react-hook-form'; +import { useForm, Mode, DefaultValues, SubmitHandler, FieldValues } from 'react-hook-form'; import { FormAPI } from '../../types'; @@ -8,13 +8,16 @@ interface FormProps<T extends FieldValues> extends Omit<HTMLProps<HTMLFormElemen validateOn?: Mode; validateOnMount?: boolean; validateFieldsOnMount?: string | string[]; - defaultValues?: UnpackNestedValue<DeepPartial<T>>; + defaultValues?: DefaultValues<T>; onSubmit: SubmitHandler<T>; children: (api: FormAPI<T>) => React.ReactNode; /** Sets max-width for container. Use it instead of setting individual widths on inputs.*/ maxWidth?: number | 'none'; } +/** + * @deprecated use the `useForm` hook from react-hook-form instead + */ export function Form<T extends FieldValues>({ defaultValues, onSubmit, diff --git a/packages/grafana-ui/src/components/Icon/utils.ts b/packages/grafana-ui/src/components/Icon/utils.ts index 0d379f36c7a25..229aa5361e080 100644 --- a/packages/grafana-ui/src/components/Icon/utils.ts +++ b/packages/grafana-ui/src/components/Icon/utils.ts @@ -1,4 +1,4 @@ -import { IconName, IconSize } from '../../types/icon'; +import { IconName, IconSize, IconType } from '../../types/icon'; const alwaysMonoIcons: IconName[] = [ 'grafana', @@ -10,7 +10,7 @@ const alwaysMonoIcons: IconName[] = [ 'circle-mono', ]; -export function getIconSubDir(name: IconName, type: string): string { +export function getIconSubDir(name: IconName, type: IconType): string { if (name?.startsWith('gf-')) { return 'custom'; } else if (alwaysMonoIcons.includes(name)) { diff --git a/packages/grafana-ui/src/components/InlineToast/InlineToast.tsx b/packages/grafana-ui/src/components/InlineToast/InlineToast.tsx index efbda8b83909f..7efde97f352f4 100644 --- a/packages/grafana-ui/src/components/InlineToast/InlineToast.tsx +++ b/packages/grafana-ui/src/components/InlineToast/InlineToast.tsx @@ -1,11 +1,10 @@ -import { css, cx, keyframes } from '@emotion/css'; -import { BasePlacement } from '@popperjs/core'; -import React, { useState } from 'react'; -import { usePopper } from 'react-popper'; +import { css, cx } from '@emotion/css'; +import { autoUpdate, flip, offset, shift, Side, useFloating, useTransitionStyles } from '@floating-ui/react'; +import React, { useLayoutEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2 } from '../../themes'; +import { useStyles2, useTheme2 } from '../../themes'; import { IconName } from '../../types'; import { Icon } from '../Icon/Icon'; import { Portal } from '../Portal/Portal'; @@ -14,39 +13,59 @@ export interface InlineToastProps { children: React.ReactNode; suffixIcon?: IconName; referenceElement: HTMLElement | null; - placement: BasePlacement; - /** Placement to use if there is not enough space to show the full toast with the original placement*/ - alternativePlacement?: BasePlacement; + placement: Side; + /** + * @deprecated + * Placement to use if there is not enough space to show the full toast with the original placement + * This is now done automatically. + */ + alternativePlacement?: Side; } -export function InlineToast({ - referenceElement, - children, - suffixIcon, - placement, - alternativePlacement, -}: InlineToastProps) { - const [indicatorElement, setIndicatorElement] = useState<HTMLElement | null>(null); - const [toastPlacement, setToastPlacement] = useState(placement); - const popper = usePopper(referenceElement, indicatorElement, { placement: toastPlacement }); +export function InlineToast({ referenceElement, children, suffixIcon, placement }: InlineToastProps) { const styles = useStyles2(getStyles); - const placementStyles = useStyles2(getPlacementStyles); + const theme = useTheme2(); - React.useEffect(() => { - if (alternativePlacement && shouldUseAlt(placement, indicatorElement, referenceElement)) { - setToastPlacement(alternativePlacement); - } - }, [alternativePlacement, placement, indicatorElement, referenceElement]); + // the order of middleware is important! + // `arrow` should almost always be at the end + // see https://floating-ui.com/docs/arrow#order + const middleware = [ + offset(8), + flip({ + fallbackAxisSideDirection: 'end', + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ]; + + const { context, refs, floatingStyles } = useFloating({ + open: true, + placement, + middleware, + whileElementsMounted: autoUpdate, + strategy: 'fixed', + }); + + useLayoutEffect(() => { + refs.setReference(referenceElement); + }, [referenceElement, refs]); + + const { styles: placementStyles } = useTransitionStyles(context, { + initial: ({ side }) => { + return { + opacity: 0, + transform: getInitialTransform(side, theme), + }; + }, + duration: theme.transitions.duration.shortest, + }); return ( <Portal> - <div - style={{ display: 'inline-block', ...popper.styles.popper }} - {...popper.attributes.popper} - ref={setIndicatorElement} - aria-live="polite" - > - <span className={cx(styles.root, placementStyles[toastPlacement])}> + <div style={{ display: 'inline-block', ...floatingStyles }} ref={refs.setFloating} aria-live="polite"> + <span className={cx(styles.root)} style={placementStyles}> {children && <span>{children}</span>} {suffixIcon && <Icon name={suffixIcon} />} </span> @@ -71,68 +90,17 @@ const getStyles = (theme: GrafanaTheme2) => { }; }; -//To calculate if the InlineToast is displayed off-screen and should use the alternative placement -const shouldUseAlt = ( - placement: BasePlacement, - indicatorElement: HTMLElement | null, - referenceElement: HTMLElement | null -) => { - const indicatorSizes = indicatorElement?.getBoundingClientRect(); - const referenceSizes = referenceElement?.getBoundingClientRect(); - if (!indicatorSizes || !referenceSizes) { - return false; - } +const getInitialTransform = (placement: InlineToastProps['placement'], theme: GrafanaTheme2) => { + const gap = 1; + switch (placement) { - case 'right': - return indicatorSizes.width + referenceSizes.right > window.innerWidth; + case 'top': + return `translateY(${theme.spacing(gap)})`; case 'bottom': - return indicatorSizes.height + referenceSizes.bottom > window.innerHeight; + return `translateY(-${theme.spacing(gap)})`; case 'left': - return referenceSizes.left - indicatorSizes.width < 0; - case 'top': - return referenceSizes.top - indicatorSizes.height < 0; - default: - return false; + return `translateX(${theme.spacing(gap)})`; + case 'right': + return `translateX(-${theme.spacing(gap)})`; } }; - -const createAnimation = (fromX: string | number, fromY: string | number) => - keyframes({ - from: { - opacity: 0, - transform: `translate(${fromX}, ${fromY})`, - }, - - to: { - opacity: 1, - transform: 'translate(0, 0px)', - }, - }); - -const getPlacementStyles = (theme: GrafanaTheme2): Record<InlineToastProps['placement'], string> => { - const gap = 1; - - const placementTopAnimation = createAnimation(0, theme.spacing(gap)); - const placementBottomAnimation = createAnimation(0, theme.spacing(gap * -1)); - const placementLeftAnimation = createAnimation(theme.spacing(gap), 0); - const placementRightAnimation = createAnimation(theme.spacing(gap * -1), 0); - - return { - top: css({ - marginBottom: theme.spacing(gap), - animation: `${placementTopAnimation} ease-out 100ms`, - }), - bottom: css({ - marginTop: theme.spacing(gap), - animation: `${placementBottomAnimation} ease-out 100ms`, - }), - left: css({ - marginRight: theme.spacing(gap), - animation: `${placementLeftAnimation} ease-out 100ms`, - }), - right: css({ - marginLeft: theme.spacing(gap), - animation: `${placementRightAnimation} ease-out 100ms`, - }), - }; -}; diff --git a/packages/grafana-ui/src/components/InputControl.tsx b/packages/grafana-ui/src/components/InputControl.tsx index ab9d7a4771f8d..3eec30ac53bb5 100644 --- a/packages/grafana-ui/src/components/InputControl.tsx +++ b/packages/grafana-ui/src/components/InputControl.tsx @@ -2,4 +2,8 @@ * Rollup does not support renamed exports so do not change this to export { Controller as InputControl } ... */ import { Controller } from 'react-hook-form'; + +/** + * @deprecated use the `Controller` component from react-hook-form instead + */ export const InputControl = Controller; diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx index 744a73c791ceb..1e9418850aa16 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.test.tsx @@ -117,15 +117,28 @@ describe('InteractiveTable', () => { expect(screen.queryByRole('button', { name: /previous/i })).not.toBeInTheDocument(); }); - it('renders pagination controls if pageSize is set', () => { + it('renders pagination controls if pageSize is set and more items than page size', () => { const columns: Array<Column<TableData>> = [{ id: 'id', header: 'ID' }]; - const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; - render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} pageSize={10} />); + const data: TableData[] = [ + { id: '1', value: '1', country: 'Sweden' }, + { id: '2', value: '2', country: 'Belgium' }, + ]; + render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} pageSize={1} />); expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument(); }); + + it('does not render pagination controls if pageSize is set and fewer items than page size', () => { + const columns: Array<Column<TableData>> = [{ id: 'id', header: 'ID' }]; + const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }]; + render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} pageSize={10} />); + + expect(screen.queryByRole('button', { name: /next/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /previous/i })).not.toBeInTheDocument(); + }); }); + describe('headerTooltip', () => { it('does not render tooltips if headerTooltips is not set', () => { const columns: Array<Column<TableData>> = [{ id: 'id', header: 'ID' }]; diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx index 946d9d36779e4..cdd8df052b8ba 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx @@ -179,7 +179,8 @@ export function InteractiveTable<TableData extends object>({ const tableHooks: Array<PluginHook<TableData>> = [useSortBy, useExpanded]; - const paginationEnabled = pageSize > 0; + const multiplePages = data.length > pageSize; + const paginationEnabled = pageSize > 0 && multiplePages; if (paginationEnabled) { tableHooks.push(usePagination); diff --git a/packages/grafana-ui/src/components/Layout/Box/Box.story.tsx b/packages/grafana-ui/src/components/Layout/Box/Box.story.tsx index 203f9f1310838..b564a3d247671 100644 --- a/packages/grafana-ui/src/components/Layout/Box/Box.story.tsx +++ b/packages/grafana-ui/src/components/Layout/Box/Box.story.tsx @@ -73,6 +73,7 @@ Basic.argTypes = { paddingBottom: SpacingTokenControl, paddingLeft: SpacingTokenControl, paddingRight: SpacingTokenControl, + direction: { control: 'select', options: ['row', 'row-reverse', 'column', 'column-reverse'] }, display: { control: 'select', options: ['flex', 'block', 'inline', 'none'] }, backgroundColor: { control: 'select', options: backgroundOptions }, borderStyle: { control: 'select', options: borderStyleOptions }, diff --git a/packages/grafana-ui/src/components/Layout/Box/Box.tsx b/packages/grafana-ui/src/components/Layout/Box/Box.tsx index a590d41a0cd27..3f4a36c695038 100644 --- a/packages/grafana-ui/src/components/Layout/Box/Box.tsx +++ b/packages/grafana-ui/src/components/Layout/Box/Box.tsx @@ -4,10 +4,10 @@ import React, { ElementType, forwardRef, PropsWithChildren } from 'react'; import { GrafanaTheme2, ThemeSpacingTokens, ThemeShape, ThemeShadows } from '@grafana/data'; import { useStyles2 } from '../../../themes'; -import { AlignItems, FlexProps, JustifyContent } from '../types'; +import { AlignItems, Direction, FlexProps, JustifyContent } from '../types'; import { ResponsiveProp, getResponsiveStyle } from '../utils/responsiveness'; -type Display = 'flex' | 'block' | 'inline' | 'none'; +type Display = 'flex' | 'block' | 'inline' | 'inline-block' | 'none'; export type BackgroundColor = keyof GrafanaTheme2['colors']['background'] | 'error' | 'success' | 'warning' | 'info'; export type BorderStyle = 'solid' | 'dashed'; export type BorderColor = keyof GrafanaTheme2['colors']['border'] | 'error' | 'success' | 'warning' | 'info'; @@ -54,6 +54,7 @@ interface BoxProps extends FlexProps, Omit<React.HTMLAttributes<HTMLElement>, 'c // Flex Props alignItems?: ResponsiveProp<AlignItems>; + direction?: ResponsiveProp<Direction>; justifyContent?: ResponsiveProp<JustifyContent>; gap?: ResponsiveProp<ThemeSpacingTokens>; @@ -91,6 +92,7 @@ export const Box = forwardRef<HTMLElement, PropsWithChildren<BoxProps>>((props, borderColor, borderStyle, borderRadius, + direction, justifyContent, alignItems, boxShadow, @@ -123,6 +125,7 @@ export const Box = forwardRef<HTMLElement, PropsWithChildren<BoxProps>>((props, borderColor, borderStyle, borderRadius, + direction, justifyContent, alignItems, boxShadow, @@ -188,6 +191,7 @@ const getStyles = ( borderColor: BoxProps['borderColor'], borderStyle: BoxProps['borderStyle'], borderRadius: BoxProps['borderRadius'], + direction: BoxProps['direction'], justifyContent: BoxProps['justifyContent'], alignItems: BoxProps['alignItems'], boxShadow: BoxProps['boxShadow'], @@ -247,6 +251,9 @@ const getStyles = ( getResponsiveStyle(theme, backgroundColor, (val) => ({ backgroundColor: customBackgroundColor(val, theme), })), + getResponsiveStyle(theme, direction, (val) => ({ + flexDirection: val, + })), getResponsiveStyle(theme, grow, (val) => ({ flexGrow: val, })), diff --git a/packages/grafana-ui/src/components/Layout/Grid/Grid.story.tsx b/packages/grafana-ui/src/components/Layout/Grid/Grid.story.tsx index 2c73d9d0f43f9..d4dadc083ad1d 100644 --- a/packages/grafana-ui/src/components/Layout/Grid/Grid.story.tsx +++ b/packages/grafana-ui/src/components/Layout/Grid/Grid.story.tsx @@ -6,6 +6,10 @@ import { useTheme2 } from '../../../themes'; import { Grid } from './Grid'; import mdx from './Grid.mdx'; +const dimensions = Array.from({ length: 9 }).map(() => ({ + minHeight: `${Math.random() * 100 + 100}px`, +})); + const meta: Meta<typeof Grid> = { title: 'General/Layout/Grid', component: Grid, @@ -22,15 +26,21 @@ const meta: Meta<typeof Grid> = { export const ColumnsNumber: StoryFn<typeof Grid> = (args) => { const theme = useTheme2(); return ( - <Grid gap={args.gap} columns={args.columns}> + <Grid {...args}> {Array.from({ length: 9 }).map((_, i) => ( - <div key={i} style={{ background: theme.colors.background.secondary, textAlign: 'center' }}> + <div key={i} style={{ background: theme.colors.background.secondary, textAlign: 'center', ...dimensions[i] }}> N# {i} </div> ))} </Grid> ); }; +ColumnsNumber.argTypes = { + alignItems: { + control: 'select', + options: ['stretch', 'flex-start', 'flex-end', 'center', 'baseline', 'start', 'end', 'self-start', 'self-end'], + }, +}; ColumnsNumber.args = { columns: 3, }; diff --git a/packages/grafana-ui/src/components/Layout/Grid/Grid.tsx b/packages/grafana-ui/src/components/Layout/Grid/Grid.tsx index 0a452686b8a9a..4225bb6074b15 100644 --- a/packages/grafana-ui/src/components/Layout/Grid/Grid.tsx +++ b/packages/grafana-ui/src/components/Layout/Grid/Grid.tsx @@ -4,12 +4,14 @@ import React, { forwardRef, HTMLAttributes } from 'react'; import { GrafanaTheme2, ThemeSpacingTokens } from '@grafana/data'; import { useStyles2 } from '../../../themes'; +import { AlignItems } from '../types'; import { getResponsiveStyle, ResponsiveProp } from '../utils/responsiveness'; interface GridPropsBase extends Omit<HTMLAttributes<HTMLDivElement>, 'className' | 'style'> { children: NonNullable<React.ReactNode>; /** Specifies the gutters between columns and rows. It is overwritten when a column or row gap has a value. */ gap?: ResponsiveProp<ThemeSpacingTokens>; + alignItems?: ResponsiveProp<AlignItems>; } interface PropsWithColumns extends GridPropsBase { @@ -30,8 +32,8 @@ interface PropsWithMinColumnWidth extends GridPropsBase { type GridProps = PropsWithColumns | PropsWithMinColumnWidth; export const Grid = forwardRef<HTMLDivElement, GridProps>((props, ref) => { - const { children, gap, columns, minColumnWidth, ...rest } = props; - const styles = useStyles2(getGridStyles, gap, columns, minColumnWidth); + const { alignItems, children, gap, columns, minColumnWidth, ...rest } = props; + const styles = useStyles2(getGridStyles, gap, columns, minColumnWidth, alignItems); return ( <div ref={ref} {...rest} className={styles.grid}> @@ -46,7 +48,8 @@ const getGridStyles = ( theme: GrafanaTheme2, gap: GridProps['gap'], columns: GridProps['columns'], - minColumnWidth: GridProps['minColumnWidth'] + minColumnWidth: GridProps['minColumnWidth'], + alignItems: GridProps['alignItems'] ) => { return { grid: css([ @@ -62,6 +65,9 @@ const getGridStyles = ( getResponsiveStyle(theme, columns, (val) => ({ gridTemplateColumns: `repeat(${val}, 1fr)`, })), + getResponsiveStyle(theme, alignItems, (val) => ({ + alignItems: val, + })), ]), }; }; diff --git a/packages/grafana-ui/src/components/Layout/Layout.tsx b/packages/grafana-ui/src/components/Layout/Layout.tsx index d56b07d4b6b0e..7fcd44af45432 100644 --- a/packages/grafana-ui/src/components/Layout/Layout.tsx +++ b/packages/grafana-ui/src/components/Layout/Layout.tsx @@ -30,6 +30,9 @@ export interface ContainerProps { shrink?: number; } +/** + * @deprecated use Stack component instead + */ export const Layout = ({ children, orientation = Orientation.Horizontal, @@ -58,6 +61,9 @@ export const Layout = ({ ); }; +/** + * @deprecated use Stack component instead + */ export const HorizontalGroup = ({ children, spacing, @@ -79,6 +85,10 @@ export const HorizontalGroup = ({ {children} </Layout> ); + +/** + * @deprecated use Stack component with the "column" direction instead + */ export const VerticalGroup = ({ children, spacing, diff --git a/packages/grafana-ui/src/components/Layout/Space.mdx b/packages/grafana-ui/src/components/Layout/Space.mdx new file mode 100644 index 0000000000000..47bc0cb8e546d --- /dev/null +++ b/packages/grafana-ui/src/components/Layout/Space.mdx @@ -0,0 +1,22 @@ +import { Meta, ArgTypes } from '@storybook/blocks'; +import { Space } from './Space'; + +<Meta title="MDX|Space" component={Space} /> + +# Space + +The `Space` component is a component used to add space between elements. Horizontal space is added using the `h` prop, while vertical space is added using the `v` prop. When adding horizontal space between inline or inline-block elements, the `layout` props should be set to `inline`, otherwise the `block` value of the prop can be used. + +### Usage + +#### When to use + +Use the `Space` component to add space between elements that cannot be spaced using flex or grid layout. + +#### When not to use + +Do not use the `Space` component to add space between elements inside the `Stack` component. Instead, use the `gap` prop on the `Stack` component. + +### Props + +<ArgTypes of={Space} /> diff --git a/packages/grafana-ui/src/components/Layout/Space.story.tsx b/packages/grafana-ui/src/components/Layout/Space.story.tsx new file mode 100644 index 0000000000000..fd6ca746db73b --- /dev/null +++ b/packages/grafana-ui/src/components/Layout/Space.story.tsx @@ -0,0 +1,70 @@ +import { StoryFn, Meta } from '@storybook/react'; +import React from 'react'; + +import { SpacingTokenControl } from '../../utils/storybook/themeStorybookControls'; + +import { Box } from './Box/Box'; +import { Space } from './Space'; +import mdx from './Space.mdx'; + +const meta: Meta<typeof Space> = { + title: 'General/Layout/Space', + component: Space, + parameters: { + docs: { + page: mdx, + }, + }, + argTypes: { + v: SpacingTokenControl, + h: SpacingTokenControl, + }, +}; + +export default meta; + +export const Horizontal: StoryFn<typeof Space> = (args) => { + return ( + <div style={{ display: 'flex' }}> + <Box borderStyle={'solid'} padding={1}> + Box without space + </Box> + <Box borderStyle={'solid'} padding={1}> + Box with space on the right + </Box> + <Space {...args} /> + <Box borderStyle={'solid'} padding={1}> + Box without space + </Box> + </div> + ); +}; + +Horizontal.args = { + v: 0, + h: 2, + layout: 'inline', +}; + +export const Vertical: StoryFn<typeof Space> = (args) => { + return ( + <div> + <Box borderStyle={'solid'} padding={1}> + Box without space + </Box> + <Box borderStyle={'solid'} padding={1}> + Box with bottom space + </Box> + <Space {...args} /> + <Box borderStyle={'solid'} padding={1}> + Box without space + </Box> + </div> + ); +}; + +Vertical.args = { + v: 2, + h: 0, + layout: 'block', +}; diff --git a/packages/grafana-ui/src/components/Layout/Space.tsx b/packages/grafana-ui/src/components/Layout/Space.tsx new file mode 100644 index 0000000000000..6e3c53ed2eead --- /dev/null +++ b/packages/grafana-ui/src/components/Layout/Space.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { ThemeSpacingTokens } from '@grafana/data'; + +import { Box } from './Box/Box'; +import { ResponsiveProp } from './utils/responsiveness'; + +export interface SpaceProps { + /** + * The amount of vertical space to use. + */ + v?: ResponsiveProp<ThemeSpacingTokens>; + /** + * The amount of horizontal space to use. + */ + h?: ResponsiveProp<ThemeSpacingTokens>; + /** + * The layout of the space. If set to `inline`, the component will behave like an inline-block element, + * otherwise it will behave like a block element. + */ + layout?: 'block' | 'inline'; +} + +export const Space = ({ v = 0, h = 0, layout }: SpaceProps) => { + return <Box paddingRight={h} paddingBottom={v} display={layout === 'inline' ? 'inline-block' : 'block'} />; +}; diff --git a/packages/grafana-ui/src/components/Link/TextLink.story.tsx b/packages/grafana-ui/src/components/Link/TextLink.story.tsx index a4dcdc74a3169..354a9d7ae06b2 100644 --- a/packages/grafana-ui/src/components/Link/TextLink.story.tsx +++ b/packages/grafana-ui/src/components/Link/TextLink.story.tsx @@ -18,7 +18,10 @@ const meta: Meta = { controls: { exclude: ['href', 'external'] }, }, argTypes: { - variant: { control: 'select', options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'body', 'bodySmall', undefined] }, + variant: { + control: 'select', + options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'body', 'bodySmall', undefined], + }, weight: { control: 'select', options: ['bold', 'medium', 'light', 'regular', undefined], diff --git a/packages/grafana-ui/src/components/Link/TextLink.test.tsx b/packages/grafana-ui/src/components/Link/TextLink.test.tsx new file mode 100644 index 0000000000000..442bbcf10258b --- /dev/null +++ b/packages/grafana-ui/src/components/Link/TextLink.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; + +import { GrafanaConfig, locationUtil } from '@grafana/data'; + +import { TextLink } from './TextLink'; + +describe('TextLink', () => { + let windowSpy: jest.SpyInstance; + + beforeAll(() => { + windowSpy = jest.spyOn(window, 'location', 'get'); + windowSpy.mockImplementation(() => ({ + origin: 'http://www.grafana.com', + })); + }); + + afterAll(() => { + windowSpy.mockRestore(); + }); + + const link = 'http://www.grafana.com/grafana/after-sub-url'; + it('should keep the whole url, including app sub url, if external', () => { + locationUtil.initialize({ + config: { appSubUrl: '/grafana' } as GrafanaConfig, + getVariablesUrlParams: jest.fn(), + getTimeRangeForUrl: jest.fn(), + }); + + render( + <TextLink href={link} external> + Link to Grafana + </TextLink> + ); + expect(screen.getByRole('link')).toHaveAttribute('href', link); + }); + it('should turn it into a relative url, if not external', () => { + locationUtil.initialize({ + config: { appSubUrl: '/grafana' } as GrafanaConfig, + getVariablesUrlParams: jest.fn(), + getTimeRangeForUrl: jest.fn(), + }); + + render( + <MemoryRouter> + <TextLink href={link}>Link to Grafana</TextLink> + </MemoryRouter> + ); + expect(screen.getByRole('link')).toHaveAttribute('href', '/after-sub-url'); + }); +}); diff --git a/packages/grafana-ui/src/components/Link/TextLink.tsx b/packages/grafana-ui/src/components/Link/TextLink.tsx index b925308738a5c..2d5a253bee800 100644 --- a/packages/grafana-ui/src/components/Link/TextLink.tsx +++ b/packages/grafana-ui/src/components/Link/TextLink.tsx @@ -10,6 +10,8 @@ import { customWeight } from '../Text/utils'; import { Link } from './Link'; +type TextLinkVariants = keyof Omit<ThemeTypographyVariantTypes, 'code'>; + interface TextLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'target' | 'rel'> { /** url to which redirect the user, external or internal */ href: string; @@ -19,8 +21,8 @@ interface TextLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 't external?: boolean; /** True when the link will be displayed inline with surrounding text, false if it will be displayed as a block. Depending on this prop correspondant default styles will be applied */ inline?: boolean; - /** The default variant is 'body'. To fit another styles set the correspondent variant as it is necessary also to adjust the icon size */ - variant?: keyof ThemeTypographyVariantTypes; + /** The default variant is 'body'. To fit another styles set the correspondent variant as it is necessary also to adjust the icon size. `code` is excluded, as it is not fit for links. */ + variant?: TextLinkVariants; /** Override the default weight for the used variant */ weight?: 'light' | 'regular' | 'medium' | 'bold'; /** Set the icon to be shown. An external link will show the 'external-link-alt' icon as default.*/ @@ -29,7 +31,7 @@ interface TextLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 't } const svgSizes: { - [key in keyof ThemeTypographyVariantTypes]: IconSize; + [key in TextLinkVariants]: IconSize; } = { h1: 'xl', h2: 'xl', @@ -46,19 +48,25 @@ export const TextLink = forwardRef<HTMLAnchorElement, TextLinkProps>( { href, color = 'link', external = false, inline = true, variant = 'body', weight, icon, children, ...rest }, ref ) => { - const validUrl = locationUtil.stripBaseFromUrl(textUtil.sanitizeUrl(href ?? '')); + const validUrl = textUtil.sanitizeUrl(href ?? ''); const theme = useTheme2(); const styles = getLinkStyles(theme, inline, variant, weight, color); const externalIcon = icon || 'external-link-alt'; - return external ? ( - <a href={validUrl} ref={ref} {...rest} target="_blank" rel="noreferrer" className={styles}> - {children} - <Icon size={svgSizes[variant] || 'md'} name={externalIcon} /> - </a> - ) : ( - <Link ref={ref} href={validUrl} {...rest} className={styles}> + if (external) { + return ( + <a href={validUrl} ref={ref} {...rest} target="_blank" rel="noreferrer" className={styles}> + {children} + <Icon size={svgSizes[variant] || 'md'} name={externalIcon} /> + </a> + ); + } + + const strippedUrl = locationUtil.stripBaseFromUrl(validUrl); + + return ( + <Link ref={ref} href={strippedUrl} {...rest} className={styles}> {children} {icon && <Icon name={icon} size={svgSizes[variant] || 'md'} />} </Link> diff --git a/packages/grafana-ui/src/components/Modal/Modal.tsx b/packages/grafana-ui/src/components/Modal/Modal.tsx index af41c8cc78b4a..cacd311585a03 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.tsx @@ -88,7 +88,7 @@ export function Modal(props: PropsWithChildren<Props>) { name="times" size="xl" onClick={onDismiss} - tooltip={t('grafana-ui.modal.close-tooltip', 'Close')} + aria-label={t('grafana-ui.modal.close-tooltip', 'Close')} /> </div> </div> diff --git a/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx b/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx index a7ac0d7fb0d9a..7ab4854ec2c0e 100644 --- a/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx +++ b/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx @@ -119,14 +119,12 @@ class UnthemedCodeEditor extends PureComponent<Props> { } }); - const languagePromise = this.loadCustomLanguage(); - if (onChange) { editor.getModel()?.onDidChangeContent(() => onChange(editor.getValue())); } if (onEditorDidMount) { - languagePromise.then(() => onEditorDidMount(editor, monaco)); + onEditorDidMount(editor, monaco); } }; diff --git a/packages/grafana-ui/src/components/Monaco/ReactMonacoEditor.tsx b/packages/grafana-ui/src/components/Monaco/ReactMonacoEditor.tsx index a6db1ce5fc346..0f0ab1b3ee31a 100644 --- a/packages/grafana-ui/src/components/Monaco/ReactMonacoEditor.tsx +++ b/packages/grafana-ui/src/components/Monaco/ReactMonacoEditor.tsx @@ -1,4 +1,5 @@ -import MonacoEditor, { loader as monacoEditorLoader, Monaco } from '@monaco-editor/react'; +import Editor, { loader as monacoEditorLoader, Monaco } from '@monaco-editor/react'; +import * as monaco from 'monaco-editor'; import React, { useCallback } from 'react'; import { useTheme2 } from '../../themes'; @@ -6,11 +7,8 @@ import { useTheme2 } from '../../themes'; import defineThemes from './theme'; import type { ReactMonacoEditorProps } from './types'; -monacoEditorLoader.config({ - paths: { - vs: (window.__grafana_public_path__ ?? 'public/') + 'lib/monaco/min/vs', - }, -}); +// pass the monaco editor to the loader to bypass requirejs +monacoEditorLoader.config({ monaco }); export const ReactMonacoEditor = (props: ReactMonacoEditorProps) => { const { beforeMount } = props; @@ -25,10 +23,6 @@ export const ReactMonacoEditor = (props: ReactMonacoEditorProps) => { ); return ( - <MonacoEditor - {...props} - theme={theme.isDark ? 'grafana-dark' : 'grafana-light'} - beforeMount={onMonacoBeforeMount} - /> + <Editor {...props} theme={theme.isDark ? 'grafana-dark' : 'grafana-light'} beforeMount={onMonacoBeforeMount} /> ); }; diff --git a/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx b/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx index c7d70c4918803..3d9e76057507f 100644 --- a/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx +++ b/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx @@ -92,7 +92,7 @@ export const PageToolbar = React.memo( tooltip="Go back (Esc)" tooltipPlacement="bottom" size="xxl" - aria-label={selectors.components.BackButton.backArrow} + data-testid={selectors.components.BackButton.backArrow} onClick={onGoBack} /> </div> diff --git a/packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx b/packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx index e597c17ca3e5f..77255379cdaf1 100644 --- a/packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx @@ -32,7 +32,7 @@ export const LoadingIndicator = ({ onCancel, loading }: LoadingIndicatorProps) = name="sync" size="sm" onClick={onCancel} - aria-label={selectors.components.LoadingIndicator.icon} + data-testid={selectors.components.LoadingIndicator.icon} /> </Tooltip> ); diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index 4b2bd1c1c1722..4f4d690a42d04 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -25,7 +25,7 @@ export type PanelChromeProps = (AutoSize | FixedDimensions) & (Collapsible | Hov interface BaseProps { padding?: PanelPadding; - title?: string; + title?: string | React.ReactElement; description?: string | (() => string); titleItems?: ReactNode; menu?: ReactElement | (() => ReactElement); @@ -161,13 +161,13 @@ export function PanelChrome({ actions = leftItems; } - const testid = title ? selectors.components.Panels.Panel.title(title) : 'Panel'; + const testid = typeof title === 'string' ? selectors.components.Panels.Panel.title(title) : 'Panel'; const headerContent = ( <> {/* Non collapsible title */} {!collapsible && title && ( - <h6 title={title} className={styles.title}> + <h6 title={typeof title === 'string' ? title : undefined} className={styles.title}> {title} </h6> )} @@ -246,7 +246,7 @@ export function PanelChrome({ <> <HoverWidget menu={menu} - title={title} + title={typeof title === 'string' ? title : undefined} offset={hoverHeaderOffset} dragClass={dragClass} onOpenMenu={onOpenMenu} @@ -275,7 +275,7 @@ export function PanelChrome({ {menu && ( <PanelMenu menu={menu} - title={title} + title={typeof title === 'string' ? title : undefined} placement="bottom-end" menuButtonClass={cx(styles.menuItem, dragClassCancel, showOnHoverClass)} onOpenMenu={onOpenMenu} @@ -358,15 +358,6 @@ const getStyles = (theme: GrafanaTheme2) => { display: 'flex', flexDirection: 'column', - '> *': { - zIndex: 0, - }, - - // matches .react-grid-item styles in _dashboard_grid.scss to ensure any contained tooltips occlude adjacent panels - '&:hover, &:active, &:focus': { - zIndex: theme.zIndex.activePanel, - }, - '.show-on-hover': { opacity: '0', visibility: 'hidden', diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts index ede3ffbe4ebd3..8159f3a5a4a63 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts +++ b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts @@ -6,7 +6,6 @@ import { DashboardCursorSync, AnnotationEventUIModel, ThresholdsConfig, - SplitOpen, CoreApp, DataFrame, DataLinkPostProcessor, @@ -70,13 +69,6 @@ export interface PanelContext { */ onThresholdsChange?: (thresholds: ThresholdsConfig) => void; - /** - * onSplitOpen is used in Explore to open the split view. It can be used in panels which has intercations and used in Explore as well. - * For example TimeSeries panel. - * @deprecated will be removed in the future. It's not needed as visualization can just field.getLinks now - */ - onSplitOpen?: SplitOpen; - /** For instance state that can be shared between panel & options UI */ instanceState?: any; diff --git a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx index 1ea9e8e216325..7420a8034e0d0 100644 --- a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx +++ b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx @@ -1,4 +1,4 @@ -import formatDuration from 'date-fns/formatDuration'; +import { formatDuration } from 'date-fns'; import React, { PureComponent } from 'react'; import { SelectableValue, parseDuration } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/Select/Select.mdx b/packages/grafana-ui/src/components/Select/Select.mdx index 78318d5931487..5af27ea2344ba 100644 --- a/packages/grafana-ui/src/components/Select/Select.mdx +++ b/packages/grafana-ui/src/components/Select/Select.mdx @@ -1,8 +1,11 @@ -import { ArgTypes, Preview } from '@storybook/blocks'; -import { Select, AsyncSelect, MultiSelect, AsyncMultiSelect } from './Select'; -import { generateOptions } from './mockOptions'; +import { ArgTypes } from '@storybook/blocks'; +import { Select, AsyncSelect } from './Select'; -# Select variants +# Select + +Select is the base for every component on this page. The approaches mentioned here are also applicable to `AsyncSelect`, `MultiSelect`, `AsyncMultiSelect`. + +## Select variants Select is an input with the ability to search and create new values. It should be used when you have a list of options. If the data has a tree structure, consider using `Cascader` instead. Select has some features: @@ -12,10 +15,6 @@ Select has some features: - Select from async data - Create custom values that aren't in the list -## Select - -Select is the base for every component on this page. The approaches mentioned here are also applicable to `AsyncSelect`, `MultiSelect`, `AsyncMultiSelect`. - ### Options format There are four properties for each option: @@ -61,6 +60,57 @@ const SelectComponent = () => { }; ``` +### Resetting selected value from outside the component + +If you want to reset the selected value from outside the component, e.g. if there are two Select components that should be in sync, you can set the dependent Select value to `null` in the `onChange` handler of the first Select component. + +```tsx +import React, { useState } from 'react'; +import { Select } from '@grafana/ui'; + +const SelectComponent = () => { + const [person, setPerson] = useState<string | undefined>(''); + const [team, setTeam] = useState<string | undefined | null>(''); + + return ( + <form> + <Select + onChange={({ value }) => { + setPerson(value); + setTeam(null); // Setting the team to null will reset the selected value in the team Select + }} + options={[ + { + value: 'option1', + label: 'Option 1', + }, + { + value: 'option2', + label: 'Option 2', + }, + ]} + value={person} + backspaceRemovesValue + /> + <Select + onChange={({ value }) => setTeam(value)} + options={[ + { + value: 'team1', + label: 'Team 1', + }, + { + value: 'team', + label: 'Team 2', + }, + ]} + value={team} + /> + </form> + ); +}; +``` + ## AsyncSelect Like regular Select, but handles fetching options asynchronously. Use the `loadOptions` prop for the async function that loads the options. If `defaultOptions` is set to `true`, `loadOptions` will be called when the component is mounted. @@ -83,7 +133,6 @@ const basicSelectAsync = () => { /> ); }; - ``` Where the async function could look like this: @@ -126,7 +175,7 @@ const multiSelect = () => { Like MultiSelect but handles data asynchronously with the `loadOptions` prop. -# Testing +## Testing Using React Testing Library, you can select the `<Select />` using its matching label, such as the label assigned with the `inputId` prop. Use the `react-select-event` package to select values from the options. @@ -156,6 +205,6 @@ it('should call onChange', () => { }); ``` -# Props +## Props <ArgTypes of={Select} /> diff --git a/packages/grafana-ui/src/components/Select/Select.story.tsx b/packages/grafana-ui/src/components/Select/Select.story.tsx index 7bb9537e7d64f..b3e8161aba9c3 100644 --- a/packages/grafana-ui/src/components/Select/Select.story.tsx +++ b/packages/grafana-ui/src/components/Select/Select.story.tsx @@ -105,6 +105,7 @@ export const Basic: Story<StoryProps> = (args) => { </> ); }; + export const BasicVirtualizedList: Story<StoryProps> = (args) => { const [value, setValue] = useState<SelectableValue<string>>(); @@ -123,6 +124,7 @@ export const BasicVirtualizedList: Story<StoryProps> = (args) => { </> ); }; + /** * Uses plain values instead of SelectableValue<T> */ @@ -143,6 +145,7 @@ export const BasicSelectPlainValue: Story<StoryProps> = (args) => { </> ); }; + /** * Uses plain values instead of SelectableValue<T> */ diff --git a/packages/grafana-ui/src/components/Select/SelectBase.test.tsx b/packages/grafana-ui/src/components/Select/SelectBase.test.tsx index 4b4eb14b8bb52..a17c1e6e3269e 100644 --- a/packages/grafana-ui/src/components/Select/SelectBase.test.tsx +++ b/packages/grafana-ui/src/components/Select/SelectBase.test.tsx @@ -1,13 +1,16 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React, { useState } from 'react'; +import { select } from 'react-select-event'; import { SelectableValue } from '@grafana/data'; -import { selectOptionInTest } from '../../../../../public/test/helpers/selectOptionInTest'; - import { SelectBase } from './SelectBase'; +// Used to select an option or options from a Select in unit tests +const selectOptionInTest = async (input: HTMLElement, optionOrOptions: string | RegExp | Array<string | RegExp>) => + await waitFor(() => select(input, optionOrOptions, { container: document.body })); + describe('SelectBase', () => { const onChangeHandler = jest.fn(); const options: Array<SelectableValue<number>> = [ diff --git a/packages/grafana-ui/src/components/Select/SelectMenu.tsx b/packages/grafana-ui/src/components/Select/SelectMenu.tsx index b7b9af15e5e1c..5ed9d32c9b741 100644 --- a/packages/grafana-ui/src/components/Select/SelectMenu.tsx +++ b/packages/grafana-ui/src/components/Select/SelectMenu.tsx @@ -50,7 +50,7 @@ export const VirtualizedSelectMenu = ({ children, maxHeight, options, getValue } const [value] = getValue(); const valueIndex = value ? options.findIndex((option: SelectableValue<unknown>) => option.value === value.value) : 0; - const initialOffset = valueIndex * VIRTUAL_LIST_ITEM_HEIGHT; + const valueYOffset = valueIndex * VIRTUAL_LIST_ITEM_HEIGHT; if (!Array.isArray(children)) { return null; @@ -60,6 +60,9 @@ export const VirtualizedSelectMenu = ({ children, maxHeight, options, getValue } const widthEstimate = longestOption * VIRTUAL_LIST_WIDTH_ESTIMATE_MULTIPLIER; const heightEstimate = Math.min(options.length * VIRTUAL_LIST_ITEM_HEIGHT, maxHeight); + // Try to scroll to keep current value in the middle + const scrollOffset = Math.max(0, valueYOffset - heightEstimate / 2); + return ( <List className={styles.menu} @@ -68,7 +71,7 @@ export const VirtualizedSelectMenu = ({ children, maxHeight, options, getValue } aria-label="Select options menu" itemCount={children.length} itemSize={VIRTUAL_LIST_ITEM_HEIGHT} - initialScrollOffset={initialOffset} + initialScrollOffset={scrollOffset} > {({ index, style }) => <div style={{ ...style, overflow: 'hidden' }}>{children[index]}</div>} </List> diff --git a/packages/grafana-ui/src/components/Select/resetSelectStyles.ts b/packages/grafana-ui/src/components/Select/resetSelectStyles.ts index 3f1e31abff714..0449fd94e0621 100644 --- a/packages/grafana-ui/src/components/Select/resetSelectStyles.ts +++ b/packages/grafana-ui/src/components/Select/resetSelectStyles.ts @@ -13,7 +13,7 @@ export default function resetSelectStyles(theme: GrafanaTheme2) { groupHeading: () => ({}), indicatorsContainer: () => ({}), indicatorSeparator: () => ({}), - input: function (originalStyles: CSSObjectWithLabel): CSSObjectWithLabel { + input: function (originalStyles: CSSObjectWithLabel) { return { ...originalStyles, color: 'inherit', @@ -37,7 +37,7 @@ export default function resetSelectStyles(theme: GrafanaTheme2) { multiValueRemove: () => ({}), noOptionsMessage: () => ({}), option: () => ({}), - placeholder: (originalStyles: CSSObjectWithLabel): CSSObjectWithLabel => ({ + placeholder: (originalStyles: CSSObjectWithLabel) => ({ ...originalStyles, color: theme.colors.text.secondary, }), diff --git a/packages/grafana-ui/src/components/Select/types.ts b/packages/grafana-ui/src/components/Select/types.ts index 41051f7cd05f9..9ab7b93066463 100644 --- a/packages/grafana-ui/src/components/Select/types.ts +++ b/packages/grafana-ui/src/components/Select/types.ts @@ -37,7 +37,7 @@ export interface SelectCommonProps<T> { filterOption?: (option: SelectableValue<T>, searchQuery: string) => boolean; formatOptionLabel?: (item: SelectableValue<T>, formatOptionMeta: FormatOptionLabelMeta<T>) => React.ReactNode; /** Function for formatting the text that is displayed when creating a new value*/ - formatCreateLabel?: (input: string) => string; + formatCreateLabel?: (input: string) => React.ReactNode; getOptionLabel?: (item: SelectableValue<T>) => React.ReactNode; getOptionValue?: (item: SelectableValue<T>) => T | undefined; hideSelectedOptions?: boolean; diff --git a/packages/grafana-ui/src/components/Slider/HandleTooltip.tsx b/packages/grafana-ui/src/components/Slider/HandleTooltip.tsx index bd195649b0811..50845a0608d44 100644 --- a/packages/grafana-ui/src/components/Slider/HandleTooltip.tsx +++ b/packages/grafana-ui/src/components/Slider/HandleTooltip.tsx @@ -1,17 +1,11 @@ import { css } from '@emotion/css'; -import Tooltip from 'rc-tooltip'; +import Tooltip, { TooltipRef } from 'rc-tooltip'; import React, { useEffect, useRef } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../themes/ThemeContext'; -// this is now typed in rc-tooltip, but they don't export it :( -// let's mirror the interface here. if there's any discrepancy, we'll get a type error -interface RCTooltipRef { - forceAlign: () => {}; -} - const HandleTooltip = (props: { value: number; children: React.ReactElement; @@ -21,7 +15,7 @@ const HandleTooltip = (props: { }) => { const { value, children, visible, placement, tipFormatter, ...restProps } = props; - const tooltipRef = useRef<RCTooltipRef>(null); + const tooltipRef = useRef<TooltipRef>(null); const rafRef = useRef<number | null>(null); const styles = useStyles2(tooltipStyles); diff --git a/packages/grafana-ui/src/components/Slider/RangeSlider.tsx b/packages/grafana-ui/src/components/Slider/RangeSlider.tsx index 83e97e58f9c5f..a95ca126ffbe3 100644 --- a/packages/grafana-ui/src/components/Slider/RangeSlider.tsx +++ b/packages/grafana-ui/src/components/Slider/RangeSlider.tsx @@ -34,7 +34,7 @@ export const RangeSlider = ({ [onChange] ); - const handleAfterChange = useCallback( + const handleChangeComplete = useCallback( (v: number | number[]) => { const value = typeof v === 'number' ? [v, v] : v; onAfterChange?.(value); @@ -69,7 +69,7 @@ export const RangeSlider = ({ defaultValue={value} range={true} onChange={handleChange} - onAfterChange={handleAfterChange} + onChangeComplete={handleChangeComplete} vertical={!isHorizontal} reverse={reverse} handleRender={tipHandleRender} diff --git a/packages/grafana-ui/src/components/Slider/Slider.tsx b/packages/grafana-ui/src/components/Slider/Slider.tsx index 7f7dc200ceba3..4a52fb1ca333f 100644 --- a/packages/grafana-ui/src/components/Slider/Slider.tsx +++ b/packages/grafana-ui/src/components/Slider/Slider.tsx @@ -76,7 +76,7 @@ export const Slider = ({ [max, min] ); - const handleAfterChange = useCallback( + const handleChangeComplete = useCallback( (v: number | number[]) => { const value = typeof v === 'number' ? v : v[0]; onAfterChange?.(value); @@ -99,7 +99,7 @@ export const Slider = ({ defaultValue={value} value={sliderValue} onChange={onSliderChange} - onAfterChange={handleAfterChange} + onChangeComplete={handleChangeComplete} vertical={!isHorizontal} reverse={reverse} ariaLabelForHandle={ariaLabelForHandle} diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index 288aa4077b36a..d56ecd4283b73 100644 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -10,6 +10,7 @@ import { FieldSparkline, FieldType, getFieldColorModeForField, + nullToValue, } from '@grafana/data'; import { AxisPlacement, @@ -62,7 +63,8 @@ export class Sparkline extends PureComponent<SparklineProps, State> { } static getDerivedStateFromProps(props: SparklineProps, state: State) { - const frame = preparePlotFrame(props.sparkline, props.config); + const _frame = preparePlotFrame(props.sparkline, props.config); + const frame = nullToValue(_frame); if (!frame) { return { ...state }; } @@ -96,6 +98,12 @@ export class Sparkline extends PureComponent<SparklineProps, State> { getYRange(field: Field): Range.MinMax { let { min, max } = this.state.alignedDataFrame.fields[1].state?.range!; + const noValue = +this.state.alignedDataFrame.fields[1].config?.noValue!; + + if (!Number.isNaN(noValue)) { + min = Math.min(min!, +noValue); + max = Math.max(max!, +noValue); + } if (min === max) { if (min === 0) { diff --git a/packages/grafana-ui/src/components/Splitter/useSplitter.mdx b/packages/grafana-ui/src/components/Splitter/useSplitter.mdx new file mode 100644 index 0000000000000..332782e1d92df --- /dev/null +++ b/packages/grafana-ui/src/components/Splitter/useSplitter.mdx @@ -0,0 +1,34 @@ +import { Meta, ArgTypes } from '@storybook/blocks'; +import { Box, useSplitter, Text } from '@grafana/ui'; + +# useSplitter + +The splitter creates two resizable panes, either horizontally or vertically. + +### Usage + +```tsx +import { useSplitter } from '@grafana/ui'; + +const { containerProps, primaryProps, secondaryProps, splitterProps } = useSplitter({ + direction: 'row', + initialSize: 0.5, + dragPosition: 'end', +}); + +return ( + <div {...containerProps}> + <div {...primaryProps}> + <Box display="flex" grow={1} backgroundColor="primary" padding={2}> + Primary + </Box> + </div> + <div {...splitterProps} /> + <div {...secondaryProps}> + <Box display="flex" grow={1} backgroundColor="primary" padding={2}> + Secondary + </Box> + </div> + </div> +); +``` diff --git a/packages/grafana-ui/src/components/Splitter/useSplitter.story.tsx b/packages/grafana-ui/src/components/Splitter/useSplitter.story.tsx new file mode 100644 index 0000000000000..ca929b79efc33 --- /dev/null +++ b/packages/grafana-ui/src/components/Splitter/useSplitter.story.tsx @@ -0,0 +1,71 @@ +import { Meta, StoryFn } from '@storybook/react'; +import React from 'react'; + +import { Box } from '@grafana/ui'; + +import { DashboardStoryCanvas } from '../../utils/storybook/DashboardStoryCanvas'; + +import { UseSplitterOptions, useSplitter } from './useSplitter'; +import mdx from './useSplitter.mdx'; + +const meta: Meta = { + title: 'General/Layout/useSplitter', + parameters: { + docs: { page: mdx }, + controls: { + exclude: [], + }, + }, + argTypes: { + initialSize: { control: { type: 'number', min: 0.1, max: 1 } }, + direction: { control: { type: 'radio' }, options: ['row', 'column'] }, + dragPosition: { control: { type: 'radio' }, options: ['start', 'middle', 'end'] }, + hasSecondPane: { type: 'boolean', options: [true, false] }, + }, +}; + +interface StoryOptions extends UseSplitterOptions { + hasSecondPane: boolean; +} + +export const Basic: StoryFn<StoryOptions> = (options) => { + const { containerProps, primaryProps, secondaryProps, splitterProps } = useSplitter({ + ...options, + }); + + if (!options.hasSecondPane) { + primaryProps.style.flexGrow = 1; + } + + return ( + <DashboardStoryCanvas> + <div style={{ display: 'flex', width: '700px', height: '500px' }}> + <div {...containerProps}> + <div {...primaryProps}> + <Box display="flex" grow={1} backgroundColor="primary" padding={2}> + Primary + </Box> + </div> + {options.hasSecondPane && ( + <> + <div {...splitterProps} /> + <div {...secondaryProps}> + <Box display="flex" grow={1} backgroundColor="primary" padding={2}> + Secondary + </Box> + </div> + </> + )} + </div> + </div> + </DashboardStoryCanvas> + ); +}; + +Basic.args = { + direction: 'row', + dragPosition: 'middle', + hasSecondPane: true, +}; + +export default meta; diff --git a/packages/grafana-ui/src/components/Splitter/useSplitter.ts b/packages/grafana-ui/src/components/Splitter/useSplitter.ts new file mode 100644 index 0000000000000..5f3b0b8e4a804 --- /dev/null +++ b/packages/grafana-ui/src/components/Splitter/useSplitter.ts @@ -0,0 +1,410 @@ +import { css } from '@emotion/css'; +import { clamp, throttle } from 'lodash'; +import React, { useCallback, useId, useLayoutEffect, useRef } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { useStyles2 } from '../../themes'; +import { DragHandlePosition, getDragStyles } from '../DragHandle/DragHandle'; + +export interface UseSplitterOptions { + /** + * The initial size of the primary pane between 0-1, defaults to 0.5 + */ + initialSize?: number; + direction: 'row' | 'column'; + dragPosition?: DragHandlePosition; + /** + * Called when ever the size of the primary pane changes + * @param flexSize (float from 0-1) + */ + onSizeChanged?: (flexSize: number, pixelSize: number) => void; + onResizing?: (flexSize: number, pixelSize: number) => void; +} + +const PIXELS_PER_MS = 0.3 as const; +const VERTICAL_KEYS = new Set(['ArrowUp', 'ArrowDown']); +const HORIZONTAL_KEYS = new Set(['ArrowLeft', 'ArrowRight']); + +const propsForDirection = { + row: { + dim: 'width', + axis: 'clientX', + min: 'minWidth', + max: 'maxWidth', + }, + column: { + dim: 'height', + axis: 'clientY', + min: 'minHeight', + max: 'maxHeight', + }, +} as const; + +export function useSplitter(options: UseSplitterOptions) { + const { direction, initialSize = 0.5, dragPosition = 'middle', onResizing, onSizeChanged } = options; + + const handleSize = 16; + const splitterRef = useRef<HTMLDivElement | null>(null); + const firstPaneRef = useRef<HTMLDivElement | null>(null); + const secondPaneRef = useRef<HTMLDivElement | null>(null); + const containerRef = useRef<HTMLDivElement | null>(null); + const containerSize = useRef<number | null>(null); + const primarySizeRef = useRef<'1fr' | number>('1fr'); + const firstPaneMeasurements = useRef<MeasureResult | undefined>(undefined); + const savedPos = useRef<string | undefined>(undefined); + + const measurementProp = propsForDirection[direction].dim; + const clientAxis = propsForDirection[direction].axis; + const minDimProp = propsForDirection[direction].min; + const maxDimProp = propsForDirection[direction].max; + + // Using a resize observer here, as with content or screen based width/height the ratio between panes might + // change after a window resize, so ariaValueNow needs to be updated accordingly + useResizeObserver( + containerRef.current!, + (entries) => { + for (const entry of entries) { + if (!entry.target.isSameNode(containerRef.current)) { + return; + } + + if (!firstPaneRef.current) { + return; + } + + const curSize = firstPaneRef.current!.getBoundingClientRect()[measurementProp]; + const newDims = measureElement(firstPaneRef.current); + + splitterRef.current!.ariaValueNow = ariaValue(curSize, newDims[minDimProp], newDims[maxDimProp]); + } + }, + 500, + [maxDimProp, minDimProp, direction, measurementProp] + ); + + const dragStart = useRef<number | null>(null); + const onPointerDown = useCallback( + (e: React.PointerEvent<HTMLDivElement>) => { + if (!firstPaneRef.current) { + return; + } + + // measure left-side width + primarySizeRef.current = firstPaneRef.current!.getBoundingClientRect()[measurementProp]; + containerSize.current = containerRef.current!.getBoundingClientRect()[measurementProp]; + + // set position at start of drag + dragStart.current = e[clientAxis]; + splitterRef.current!.setPointerCapture(e.pointerId); + firstPaneMeasurements.current = measureElement(firstPaneRef.current); + + savedPos.current = undefined; + }, + [measurementProp, clientAxis] + ); + + const onPointerMove = useCallback( + (e: React.PointerEvent<HTMLDivElement>) => { + if (dragStart.current !== null && primarySizeRef.current !== '1fr') { + const diff = e[clientAxis] - dragStart.current; + const dims = firstPaneMeasurements.current!; + const newSize = clamp(primarySizeRef.current + diff, dims[minDimProp], dims[maxDimProp]); + const newFlex = newSize / (containerSize.current! - handleSize); + + firstPaneRef.current!.style.flexGrow = `${newFlex}`; + secondPaneRef.current!.style.flexGrow = `${1 - newFlex}`; + splitterRef.current!.ariaValueNow = ariaValue(newSize, dims[minDimProp], dims[maxDimProp]); + + onResizing?.(newFlex, newSize); + } + }, + [handleSize, clientAxis, minDimProp, maxDimProp, onResizing] + ); + + const onPointerUp = useCallback( + (e: React.PointerEvent<HTMLDivElement>) => { + e.preventDefault(); + e.stopPropagation(); + + splitterRef.current!.releasePointerCapture(e.pointerId); + dragStart.current = null; + + if (typeof primarySizeRef.current === 'number') { + onSizeChanged?.(parseFloat(firstPaneRef.current!.style.flexGrow), primarySizeRef.current); + } + }, + [onSizeChanged] + ); + + const pressedKeys = useRef(new Set<string>()); + const keysLastHandledAt = useRef<number | null>(null); + const handlePressedKeys = useCallback( + (time: number) => { + const nothingPressed = pressedKeys.current.size === 0; + if (nothingPressed) { + keysLastHandledAt.current = null; + return; + } else if (primarySizeRef.current === '1fr') { + return; + } + + const dt = time - (keysLastHandledAt.current ?? time); + const dx = dt * PIXELS_PER_MS; + let sizeChange = 0; + + if (direction === 'row') { + if (pressedKeys.current.has('ArrowLeft')) { + sizeChange -= dx; + } + if (pressedKeys.current.has('ArrowRight')) { + sizeChange += dx; + } + } else { + if (pressedKeys.current.has('ArrowUp')) { + sizeChange -= dx; + } + if (pressedKeys.current.has('ArrowDown')) { + sizeChange += dx; + } + } + + const firstPaneDims = firstPaneMeasurements.current!; + const curSize = firstPaneRef.current!.getBoundingClientRect()[measurementProp]; + const newSize = clamp(curSize + sizeChange, firstPaneDims[minDimProp], firstPaneDims[maxDimProp]); + const newFlex = newSize / (containerSize.current! - handleSize); + + firstPaneRef.current!.style.flexGrow = `${newFlex}`; + secondPaneRef.current!.style.flexGrow = `${1 - newFlex}`; + splitterRef.current!.ariaValueNow = ariaValue(newSize, firstPaneDims[minDimProp], firstPaneDims[maxDimProp]); + + onResizing?.(newFlex, newSize); + + keysLastHandledAt.current = time; + window.requestAnimationFrame(handlePressedKeys); + }, + [direction, handleSize, minDimProp, maxDimProp, measurementProp, onResizing] + ); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent<HTMLDivElement>) => { + if (!firstPaneRef.current || !secondPaneRef.current || !splitterRef.current || !containerRef.current) { + return; + } + + if (e.key === 'Enter') { + if (savedPos.current === undefined) { + savedPos.current = firstPaneRef.current!.style.flexGrow; + firstPaneRef.current!.style.flexGrow = '0'; + secondPaneRef.current!.style.flexGrow = '1'; + } else { + firstPaneRef.current!.style.flexGrow = savedPos.current; + secondPaneRef.current!.style.flexGrow = `${1 - parseFloat(savedPos.current)}`; + savedPos.current = undefined; + } + return; + } else if (e.key === 'Home') { + firstPaneMeasurements.current = measureElement(firstPaneRef.current); + containerSize.current = containerRef.current!.getBoundingClientRect()[measurementProp]; + const newFlex = firstPaneMeasurements.current[minDimProp] / (containerSize.current - handleSize); + firstPaneRef.current.style.flexGrow = `${newFlex}`; + secondPaneRef.current.style.flexGrow = `${1 - newFlex}`; + splitterRef.current.ariaValueNow = '0'; + return; + } else if (e.key === 'End') { + firstPaneMeasurements.current = measureElement(firstPaneRef.current); + containerSize.current = containerRef.current!.getBoundingClientRect()[measurementProp]; + const newFlex = firstPaneMeasurements.current[maxDimProp] / (containerSize.current - handleSize); + firstPaneRef.current!.style.flexGrow = `${newFlex}`; + secondPaneRef.current!.style.flexGrow = `${1 - newFlex}`; + splitterRef.current!.ariaValueNow = '100'; + return; + } + + if ( + !( + (direction === 'column' && VERTICAL_KEYS.has(e.key)) || + (direction === 'row' && HORIZONTAL_KEYS.has(e.key)) + ) || + pressedKeys.current.has(e.key) + ) { + return; + } + + savedPos.current = undefined; + e.preventDefault(); + e.stopPropagation(); + primarySizeRef.current = firstPaneRef.current.getBoundingClientRect()[measurementProp]; + containerSize.current = containerRef.current!.getBoundingClientRect()[measurementProp]; + firstPaneMeasurements.current = measureElement(firstPaneRef.current); + const newKey = !pressedKeys.current.has(e.key); + + if (newKey) { + const initiateAnimationLoop = pressedKeys.current.size === 0; + pressedKeys.current.add(e.key); + + if (initiateAnimationLoop) { + window.requestAnimationFrame(handlePressedKeys); + } + } + }, + [direction, handlePressedKeys, handleSize, maxDimProp, measurementProp, minDimProp] + ); + + const onKeyUp = useCallback( + (e: React.KeyboardEvent<HTMLDivElement>) => { + if ( + (direction === 'row' && !HORIZONTAL_KEYS.has(e.key)) || + (direction === 'column' && !VERTICAL_KEYS.has(e.key)) + ) { + return; + } + + pressedKeys.current.delete(e.key); + + if (typeof primarySizeRef.current === 'number') { + onSizeChanged?.(parseFloat(firstPaneRef.current!.style.flexGrow), primarySizeRef.current); + } + }, + [direction, onSizeChanged] + ); + + const onDoubleClick = useCallback(() => { + if (!firstPaneRef.current || !secondPaneRef.current) { + return; + } + + firstPaneRef.current.style.flexGrow = '0.5'; + secondPaneRef.current.style.flexGrow = '0.5'; + const dim = measureElement(firstPaneRef.current); + firstPaneMeasurements.current = dim; + primarySizeRef.current = firstPaneRef.current!.getBoundingClientRect()[measurementProp]; + splitterRef.current!.ariaValueNow = `${ariaValue(primarySizeRef.current, dim[minDimProp], dim[maxDimProp])}`; + }, [maxDimProp, measurementProp, minDimProp]); + + const onBlur = useCallback(() => { + // If focus is lost while keys are held, stop changing panel sizes + if (pressedKeys.current.size > 0) { + pressedKeys.current.clear(); + dragStart.current = null; + + if (typeof primarySizeRef.current === 'number') { + onSizeChanged?.(parseFloat(firstPaneRef.current!.style.flexGrow), primarySizeRef.current); + } + } + }, [onSizeChanged]); + + const styles = useStyles2(getStyles, direction); + const dragStyles = useStyles2(getDragStyles, dragPosition); + const dragHandleStyle = direction === 'column' ? dragStyles.dragHandleHorizontal : dragStyles.dragHandleVertical; + const id = useId(); + + return { + containerProps: { + ref: containerRef, + className: styles.container, + }, + primaryProps: { + ref: firstPaneRef, + className: styles.panel, + style: { + [minDimProp]: 'min-content', + flexGrow: clamp(initialSize ?? 0.5, 0, 1), + }, + }, + secondaryProps: { + ref: secondPaneRef, + className: styles.panel, + style: { + flexGrow: clamp(1 - initialSize, 0, 1), + [minDimProp]: 'min-content', + }, + }, + splitterProps: { + onPointerUp, + onPointerDown, + onPointerMove, + onKeyDown, + onKeyUp, + onDoubleClick, + onBlur, + ref: splitterRef, + style: { [measurementProp]: `${handleSize}px` }, + role: 'separator', + 'aria-valuemin': 0, + 'aria-valuemax': 100, + 'aria-valuenow': initialSize * 100, + 'aria-controls': `start-panel-${id}`, + 'aria-label': 'Pane resize widget', + tabIndex: 0, + className: dragHandleStyle, + }, + }; +} + +function ariaValue(value: number, min: number, max: number) { + return `${clamp(((value - min) / (max - min)) * 100, 0, 100)}`; +} + +interface MeasureResult { + minWidth: number; + maxWidth: number; + minHeight: number; + maxHeight: number; +} + +function measureElement<T extends HTMLElement>(ref: T): MeasureResult { + const savedBodyOverflow = document.body.style.overflow; + const savedWidth = ref.style.width; + const savedHeight = ref.style.height; + const savedFlex = ref.style.flexGrow; + + document.body.style.overflow = 'hidden'; + ref.style.flexGrow = '0'; + + const { width: minWidth, height: minHeight } = ref.getBoundingClientRect(); + + ref.style.flexGrow = '100'; + const { width: maxWidth, height: maxHeight } = ref.getBoundingClientRect(); + + document.body.style.overflow = savedBodyOverflow; + ref.style.width = savedWidth; + ref.style.height = savedHeight; + ref.style.flexGrow = savedFlex; + + return { minWidth, maxWidth, minHeight, maxHeight }; +} + +function useResizeObserver( + target: Element, + cb: (entries: ResizeObserverEntry[]) => void, + throttleWait = 0, + deps?: React.DependencyList +) { + const throttledCallback = throttle(cb, throttleWait); + + useLayoutEffect(() => { + if (!target) { + return; + } + + const resizeObserver = new ResizeObserver(throttledCallback); + + resizeObserver.observe(target, { box: 'device-pixel-content-box' }); + return () => resizeObserver.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +} + +function getStyles(theme: GrafanaTheme2, direction: UseSplitterOptions['direction']) { + return { + container: css({ + display: 'flex', + flexDirection: direction === 'row' ? 'row' : 'column', + width: '100%', + flexGrow: 1, + overflow: 'hidden', + }), + panel: css({ display: 'flex', position: 'relative', flexBasis: 0 }), + }; +} diff --git a/packages/grafana-ui/src/components/Switch/Switch.story.tsx b/packages/grafana-ui/src/components/Switch/Switch.story.tsx index ee78fefe4ae5d..76594e3272d26 100644 --- a/packages/grafana-ui/src/components/Switch/Switch.story.tsx +++ b/packages/grafana-ui/src/components/Switch/Switch.story.tsx @@ -19,7 +19,6 @@ const meta: Meta<typeof Switch> = { args: { disabled: false, value: false, - transparent: false, invalid: false, }, }; @@ -29,13 +28,13 @@ export const Controlled: StoryFn<typeof Switch> = (args) => { <div> <div style={{ marginBottom: '32px' }}> <Field label="Normal switch" description="For horizontal forms" invalid={args.invalid}> - <Switch value={args.value} disabled={args.disabled} transparent={args.transparent} /> + <Switch value={args.value} disabled={args.disabled} /> </Field> </div> <div style={{ marginBottom: '32px' }}> <InlineFieldRow> <InlineField label="My switch" invalid={args.invalid}> - <InlineSwitch value={args.value} disabled={args.disabled} transparent={args.transparent} /> + <InlineSwitch value={args.value} disabled={args.disabled} /> </InlineField> </InlineFieldRow> </div> @@ -47,7 +46,6 @@ export const Controlled: StoryFn<typeof Switch> = (args) => { showLabel={true} value={args.value} disabled={args.disabled} - transparent={args.transparent} invalid={args.invalid} /> </span> @@ -62,15 +60,7 @@ export const Uncontrolled: StoryFn<typeof Switch> = (args) => { (e: React.FormEvent<HTMLInputElement>) => setChecked(e.currentTarget.checked), [setChecked] ); - return ( - <Switch - value={checked} - disabled={args.disabled} - transparent={args.transparent} - onChange={onChange} - invalid={args.invalid} - /> - ); + return <Switch value={checked} disabled={args.disabled} onChange={onChange} invalid={args.invalid} />; }; export default meta; diff --git a/packages/grafana-ui/src/components/Switch/Switch.tsx b/packages/grafana-ui/src/components/Switch/Switch.tsx index 95c401201c24c..462d517cd9b4b 100644 --- a/packages/grafana-ui/src/components/Switch/Switch.tsx +++ b/packages/grafana-ui/src/components/Switch/Switch.tsx @@ -9,8 +9,6 @@ import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins'; export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'value'> { value?: boolean; - /** Make inline switch's background and border transparent */ - transparent?: boolean; /** Show an invalid state around the input */ invalid?: boolean; } @@ -46,7 +44,10 @@ export const Switch = React.forwardRef<HTMLInputElement, Props>( Switch.displayName = 'Switch'; export interface InlineSwitchProps extends Props { + /** Label to show next to the switch */ showLabel?: boolean; + /** Make inline switch's background and border transparent */ + transparent?: boolean; } export const InlineSwitch = React.forwardRef<HTMLInputElement, InlineSwitchProps>( @@ -78,6 +79,7 @@ const getSwitchStyles = (theme: GrafanaTheme2, transparent?: boolean) => ({ width: '32px', height: '16px', position: 'relative', + lineHeight: 1, input: { opacity: 0, @@ -85,11 +87,6 @@ const getSwitchStyles = (theme: GrafanaTheme2, transparent?: boolean) => ({ zIndex: -1000, position: 'absolute', - '&:disabled + label': { - background: theme.colors.action.disabledBackground, - cursor: 'not-allowed', - }, - '&:checked + label': { background: theme.colors.primary.main, borderColor: theme.colors.primary.main, @@ -104,6 +101,22 @@ const getSwitchStyles = (theme: GrafanaTheme2, transparent?: boolean) => ({ }, }, + '&:disabled + label': { + background: theme.colors.action.disabledBackground, + borderColor: theme.colors.border.weak, + cursor: 'not-allowed', + + '&:hover': { + background: theme.colors.action.disabledBackground, + }, + }, + + '&:disabled:checked + label': { + '&::after': { + background: theme.colors.text.disabled, + }, + }, + '&:focus + label, &:focus-visible + label': getFocusStyles(theme), '&:focus:not(:focus-visible) + label': getMouseFocusStyles(theme), diff --git a/packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.tsx b/packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.tsx index 1794b1a96da8a..7a527bad32596 100644 --- a/packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.tsx +++ b/packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.tsx @@ -5,7 +5,7 @@ import { SelectableValue, GrafanaTheme2 } from '@grafana/data'; import { IconButton } from '../../components/IconButton/IconButton'; import { TabsBar, Tab, TabContent } from '../../components/Tabs'; -import { useStyles2 } from '../../themes'; +import { useStyles2, useTheme2 } from '../../themes'; import { IconName } from '../../types/icon'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; @@ -25,12 +25,14 @@ export interface TabbedContainerProps { export function TabbedContainer({ tabs, defaultTab, closeIconTooltip, onClose }: TabbedContainerProps) { const [activeTab, setActiveTab] = useState(tabs.some((tab) => tab.value === defaultTab) ? defaultTab : tabs[0].value); + const styles = useStyles2(getStyles); + const theme = useTheme2(); const onSelectTab = (item: SelectableValue<string>) => { setActiveTab(item.value!); }; - const styles = useStyles2(getStyles); + const autoHeight = `calc(100% - (${theme.components.menuTabs.height}px + ${theme.spacing(1)}))`; return ( <div className={styles.container}> @@ -46,7 +48,7 @@ export function TabbedContainer({ tabs, defaultTab, closeIconTooltip, onClose }: ))} <IconButton className={styles.close} onClick={onClose} name="times" tooltip={closeIconTooltip ?? 'Close'} /> </TabsBar> - <CustomScrollbar autoHeightMin="100%"> + <CustomScrollbar autoHeightMin={autoHeight} autoHeightMax={autoHeight}> <TabContent className={styles.tabContent}>{tabs.find((t) => t.value === activeTab)?.content}</TabContent> </CustomScrollbar> </div> @@ -60,7 +62,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ tabContent: css({ padding: theme.spacing(2), backgroundColor: theme.colors.background.primary, - height: `calc(100% - ${theme.components.menuTabs.height}px)`, + height: `100%`, }), close: css({ position: 'absolute', diff --git a/packages/grafana-ui/src/components/Table/DataLinksCell.tsx b/packages/grafana-ui/src/components/Table/DataLinksCell.tsx new file mode 100644 index 0000000000000..45d8d3eb0935c --- /dev/null +++ b/packages/grafana-ui/src/components/Table/DataLinksCell.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { getCellLinks } from '../../utils'; + +import { TableCellProps } from './types'; + +export const DataLinksCell = (props: TableCellProps) => { + const { field, row, cellProps, tableStyles } = props; + + const links = getCellLinks(field, row); + + return ( + <div {...cellProps} className={tableStyles.cellContainerText}> + {links && + links.map((link, idx) => { + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions + <span key={idx} className={tableStyles.cellLink} onClick={link.onClick}> + <a href={link.href} target={link.target}> + {link.title} + </a> + </span> + ); + })} + </div> + ); +}; diff --git a/packages/grafana-ui/src/components/Table/DefaultCell.tsx b/packages/grafana-ui/src/components/Table/DefaultCell.tsx index 186455b794659..a8f6436d7425e 100644 --- a/packages/grafana-ui/src/components/Table/DefaultCell.tsx +++ b/packages/grafana-ui/src/components/Table/DefaultCell.tsx @@ -29,6 +29,8 @@ export const DefaultCell = (props: TableCellProps) => { const [hover, setHover] = useState(false); let value: string | ReactElement; + const OG_TWEET_LENGTH = 140; // 🙏 + const onMouseLeave = () => { setHover(false); }; @@ -49,7 +51,9 @@ export const DefaultCell = (props: TableCellProps) => { const isStringValue = typeof value === 'string'; - const cellStyle = getCellStyle(tableStyles, cellOptions, displayValue, inspectEnabled, isStringValue); + // Text should wrap when the content length is less than or equal to the length of an OG tweet and it contains whitespace + const textShouldWrap = displayValue.text.length <= OG_TWEET_LENGTH && /\s/.test(displayValue.text); + const cellStyle = getCellStyle(tableStyles, cellOptions, displayValue, inspectEnabled, isStringValue, textShouldWrap); if (isStringValue) { let justifyContent = cellProps.style?.justifyContent; @@ -99,7 +103,8 @@ function getCellStyle( cellOptions: TableCellOptions, displayValue: DisplayValue, disableOverflowOnHover = false, - isStringValue = false + isStringValue = false, + shouldWrapText = false ) { // How much to darken elements depends upon if we're in dark mode const darkeningFactor = tableStyles.theme.isDark ? 1 : -0.7; @@ -127,15 +132,13 @@ function getCellStyle( // If we have definied colors return those styles // Otherwise we return default styles - if (textColor !== undefined || bgColor !== undefined) { - return tableStyles.buildCellContainerStyle(textColor, bgColor, !disableOverflowOnHover, isStringValue); - } - - if (isStringValue) { - return disableOverflowOnHover ? tableStyles.cellContainerTextNoOverflow : tableStyles.cellContainerText; - } else { - return disableOverflowOnHover ? tableStyles.cellContainerNoOverflow : tableStyles.cellContainer; - } + return tableStyles.buildCellContainerStyle( + textColor, + bgColor, + !disableOverflowOnHover, + isStringValue, + shouldWrapText + ); } function getLinkStyle(tableStyles: TableStyles, cellOptions: TableCellOptions, targetClassName: string | undefined) { diff --git a/packages/grafana-ui/src/components/Table/Filter.tsx b/packages/grafana-ui/src/components/Table/Filter.tsx index 5f4474782cfa3..dd5b4ca8ba68b 100644 --- a/packages/grafana-ui/src/components/Table/Filter.tsx +++ b/packages/grafana-ui/src/components/Table/Filter.tsx @@ -1,12 +1,13 @@ import { css, cx } from '@emotion/css'; import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { Field, GrafanaTheme2 } from '@grafana/data'; +import { Field, GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Popover } from '..'; import { useStyles2 } from '../../themes'; import { Icon } from '../Icon/Icon'; +import { REGEX_OPERATOR } from './FilterList'; import { FilterPopup } from './FilterPopup'; import { TableStyles } from './styles'; @@ -23,6 +24,8 @@ export const Filter = ({ column, field, tableStyles }: Props) => { const filterEnabled = useMemo(() => Boolean(column.filterValue), [column.filterValue]); const onShowPopover = useCallback(() => setPopoverVisible(true), [setPopoverVisible]); const onClosePopover = useCallback(() => setPopoverVisible(false), [setPopoverVisible]); + const [searchFilter, setSearchFilter] = useState(''); + const [operator, setOperator] = useState<SelectableValue<string>>(REGEX_OPERATOR); if (!field || !field.config.custom?.filterable) { return null; @@ -37,7 +40,18 @@ export const Filter = ({ column, field, tableStyles }: Props) => { <Icon name="filter" /> {isPopoverVisible && ref.current && ( <Popover - content={<FilterPopup column={column} tableStyles={tableStyles} field={field} onClose={onClosePopover} />} + content={ + <FilterPopup + column={column} + tableStyles={tableStyles} + field={field} + onClose={onClosePopover} + searchFilter={searchFilter} + setSearchFilter={setSearchFilter} + operator={operator} + setOperator={setOperator} + /> + } placement="bottom-start" referenceElement={ref.current} show diff --git a/packages/grafana-ui/src/components/Table/FilterList.tsx b/packages/grafana-ui/src/components/Table/FilterList.tsx index 2ff6d26bb41d3..1ea721809d546 100644 --- a/packages/grafana-ui/src/components/Table/FilterList.tsx +++ b/packages/grafana-ui/src/components/Table/FilterList.tsx @@ -1,10 +1,10 @@ -import { css } from '@emotion/css'; -import React, { useCallback, useMemo, useState } from 'react'; +import { css, cx } from '@emotion/css'; +import React, { useCallback, useMemo } from 'react'; import { FixedSizeList as List } from 'react-window'; -import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { GrafanaTheme2, formattedValueToString, getValueFormat, SelectableValue } from '@grafana/data'; -import { Checkbox, FilterInput, Label, VerticalGroup } from '..'; +import { ButtonSelect, Checkbox, FilterInput, Label, Stack } from '..'; import { useStyles2, useTheme2 } from '../../themes'; interface Props { @@ -12,23 +12,134 @@ interface Props { options: SelectableValue[]; onChange: (options: SelectableValue[]) => void; caseSensitive?: boolean; + showOperators?: boolean; + searchFilter: string; + setSearchFilter: (value: string) => void; + operator: SelectableValue<string>; + setOperator: (item: SelectableValue<string>) => void; } const ITEM_HEIGHT = 28; const MIN_HEIGHT = ITEM_HEIGHT * 5; -export const FilterList = ({ options, values, caseSensitive, onChange }: Props) => { - const [searchFilter, setSearchFilter] = useState(''); +const operatorSelectableValues: { [key: string]: SelectableValue<string> } = { + Contains: { label: 'Contains', value: 'Contains', description: 'Contains' }, + '=': { label: '=', value: '=', description: 'Equals' }, + '!=': { label: '!=', value: '!=', description: 'Not equals' }, + '>': { label: '>', value: '>', description: 'Greater' }, + '>=': { label: '>=', value: '>=', description: 'Greater or Equal' }, + '<': { label: '<', value: '<', description: 'Less' }, + '<=': { label: '<=', value: '<=', description: 'Less or Equal' }, + Expression: { + label: 'Expression', + value: 'Expression', + description: 'Bool Expression (Char $ represents the column value in the expression, e.g. "$ >= 10 && $ <= 12")', + }, +}; +const OPERATORS = Object.values(operatorSelectableValues); +export const REGEX_OPERATOR = operatorSelectableValues['Contains']; +const XPR_OPERATOR = operatorSelectableValues['Expression']; + +const comparableValue = (value: string): string | number | Date | boolean => { + value = value.trim().replace(/\\/g, ''); + + // Does it look like a Date (Starting with pattern YYYY-MM-DD* or YYYY/MM/DD*)? + if (/^(\d{4}-\d{2}-\d{2}|\d{4}\/\d{2}\/\d{2})/.test(value)) { + const date = new Date(value); + if (!isNaN(date.getTime())) { + const fmt = getValueFormat('dateTimeAsIso'); + return formattedValueToString(fmt(date.getTime())); + } + } + // Does it look like a Number? + const num = parseFloat(value); + if (!isNaN(num)) { + return num; + } + // Does it look like a Bool? + const lvalue = value.toLowerCase(); + if (lvalue === 'true' || lvalue === 'false') { + return lvalue === 'true'; + } + // Anything else + return value; +}; + +export const FilterList = ({ + options, + values, + caseSensitive, + showOperators, + onChange, + searchFilter, + setSearchFilter, + operator, + setOperator, +}: Props) => { const regex = useMemo(() => new RegExp(searchFilter, caseSensitive ? undefined : 'i'), [searchFilter, caseSensitive]); const items = useMemo( () => options.filter((option) => { - if (option.label === undefined) { + if (!showOperators || !searchFilter || operator.value === REGEX_OPERATOR.value) { + if (option.label === undefined) { + return false; + } + return regex.test(option.label); + } else if (operator.value === XPR_OPERATOR.value) { + if (option.value === undefined) { + return false; + } + try { + const xpr = searchFilter.replace(/\\/g, ''); + const fnc = new Function('$', `'use strict'; return ${xpr};`); + const val = comparableValue(option.value); + return fnc(val); + } catch (_) {} + return false; + } else { + if (option.value === undefined) { + return false; + } + + const value1 = comparableValue(option.value); + const value2 = comparableValue(searchFilter); + + switch (operator.value) { + case '=': + return value1 === value2; + case '!=': + return value1 !== value2; + case '>': + return value1 > value2; + case '>=': + return value1 >= value2; + case '<': + return value1 < value2; + case '<=': + return value1 <= value2; + } return false; } - return regex.test(option.label); }), - [options, regex] + [options, regex, showOperators, operator, searchFilter] + ); + const selectedItems = useMemo(() => items.filter((item) => values.includes(item)), [items, values]); + + const selectCheckValue = useMemo(() => items.length === selectedItems.length, [items, selectedItems]); + const selectCheckIndeterminate = useMemo( + () => selectedItems.length > 0 && items.length > selectedItems.length, + [items, selectedItems] + ); + const selectCheckLabel = useMemo( + () => (selectedItems.length ? `${selectedItems.length} selected` : `Select all`), + [selectedItems] + ); + const selectCheckDescription = useMemo( + () => + items.length !== selectedItems.length + ? 'Add all displayed values to the filter' + : 'Remove all displayed values from the filter', + [items, selectedItems] ); const styles = useStyles2(getStyles); @@ -47,9 +158,31 @@ export const FilterList = ({ options, values, caseSensitive, onChange }: Props) [onChange, values] ); + const onSelectChanged = useCallback(() => { + if (items.length === selectedItems.length) { + const newValues = values.filter((item) => !items.includes(item)); + onChange(newValues); + } else { + const newValues = [...new Set([...values, ...items])]; + onChange(newValues); + } + }, [onChange, values, items, selectedItems]); + return ( - <VerticalGroup spacing="md"> - <FilterInput placeholder="Filter values" onChange={setSearchFilter} value={searchFilter} /> + <Stack direction="column" gap={0.25}> + {!showOperators && <FilterInput placeholder="Filter values" onChange={setSearchFilter} value={searchFilter} />} + {showOperators && ( + <Stack direction="row" gap={0}> + <ButtonSelect + variant="canvas" + options={OPERATORS} + onChange={setOperator} + value={operator} + tooltip={operator.description} + /> + <FilterInput placeholder="Filter values" onChange={setSearchFilter} value={searchFilter} /> + </Stack> + )} {!items.length && <Label>No values</Label>} {items.length && ( <List @@ -72,7 +205,21 @@ export const FilterList = ({ options, values, caseSensitive, onChange }: Props) }} </List> )} - </VerticalGroup> + {items.length && ( + <Stack direction="column" gap={0.25}> + <div className={cx(styles.selectDivider)} /> + <div className={cx(styles.filterListRow)}> + <Checkbox + value={selectCheckValue} + indeterminate={selectCheckIndeterminate} + label={selectCheckLabel} + description={selectCheckDescription} + onChange={onSelectChanged} + /> + </div> + </Stack> + )} + </Stack> ); }; @@ -92,4 +239,10 @@ const getStyles = (theme: GrafanaTheme2) => ({ backgroundColor: theme.colors.action.hover, }, }), + selectDivider: css({ + label: 'selectDivider', + width: '100%', + borderTop: `1px solid ${theme.colors.border.medium}`, + padding: theme.spacing(0.5, 2), + }), }); diff --git a/packages/grafana-ui/src/components/Table/FilterPopup.tsx b/packages/grafana-ui/src/components/Table/FilterPopup.tsx index f1e2ec807bfd8..fcb4803f9bca4 100644 --- a/packages/grafana-ui/src/components/Table/FilterPopup.tsx +++ b/packages/grafana-ui/src/components/Table/FilterPopup.tsx @@ -15,9 +15,21 @@ interface Props { tableStyles: TableStyles; onClose: () => void; field?: Field; + searchFilter: string; + setSearchFilter: (value: string) => void; + operator: SelectableValue<string>; + setOperator: (item: SelectableValue<string>) => void; } -export const FilterPopup = ({ column: { preFilteredRows, filterValue, setFilter }, onClose, field }: Props) => { +export const FilterPopup = ({ + column: { preFilteredRows, filterValue, setFilter }, + onClose, + field, + searchFilter, + setSearchFilter, + operator, + setOperator, +}: Props) => { const theme = useTheme2(); const uniqueValues = useMemo(() => calculateUniqueFieldValues(preFilteredRows, field), [preFilteredRows, field]); const options = useMemo(() => valuesToOptions(uniqueValues), [uniqueValues]); @@ -67,7 +79,17 @@ export const FilterPopup = ({ column: { preFilteredRows, filterValue, setFilter /> </HorizontalGroup> <div className={cx(styles.listDivider)} /> - <FilterList onChange={setValues} values={values} options={options} caseSensitive={matchCase} /> + <FilterList + onChange={setValues} + values={values} + options={options} + caseSensitive={matchCase} + showOperators={true} + searchFilter={searchFilter} + setSearchFilter={setSearchFilter} + operator={operator} + setOperator={setOperator} + /> </VerticalGroup> <HorizontalGroup spacing="lg"> <HorizontalGroup> @@ -102,7 +124,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ backgroundColor: theme.colors.background.primary, border: `1px solid ${theme.colors.border.weak}`, padding: theme.spacing(2), - margin: theme.spacing(1, 0), boxShadow: theme.shadows.z3, borderRadius: theme.shape.radius.default, }), diff --git a/packages/grafana-ui/src/components/Table/HeaderRow.tsx b/packages/grafana-ui/src/components/Table/HeaderRow.tsx index 62f61e494d193..2bd1ef0480137 100644 --- a/packages/grafana-ui/src/components/Table/HeaderRow.tsx +++ b/packages/grafana-ui/src/components/Table/HeaderRow.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { HeaderGroup, Column } from 'react-table'; +import { Field } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { getFieldTypeIcon } from '../../types'; @@ -8,6 +9,7 @@ import { Icon } from '../Icon/Icon'; import { Filter } from './Filter'; import { TableStyles } from './styles'; +import { TableFieldOptions } from './types'; export interface HeaderRowProps { headerGroups: HeaderGroup[]; @@ -43,7 +45,8 @@ export const HeaderRow = (props: HeaderRowProps) => { function renderHeaderCell(column: any, tableStyles: TableStyles, showTypeIcons?: boolean) { const headerProps = column.getHeaderProps(); - const field = column.field ?? null; + const field: Field = column.field ?? null; + const tableFieldOptions: TableFieldOptions | undefined = field?.config.custom; if (column.canResize) { headerProps.style.userSelect = column.isResizing ? 'none' : 'auto'; // disables selecting text while resizing @@ -51,27 +54,37 @@ function renderHeaderCell(column: any, tableStyles: TableStyles, showTypeIcons?: headerProps.style.position = 'absolute'; headerProps.style.justifyContent = column.justifyContent; + headerProps.style.left = column.totalLeft; + + let headerContent = column.render('Header'); + + let sortHeaderContent = column.canSort && ( + <> + <button {...column.getSortByToggleProps()} className={tableStyles.headerCellLabel}> + {showTypeIcons && ( + <Icon name={getFieldTypeIcon(field)} title={field?.type} size="sm" className={tableStyles.typeIcon} /> + )} + <div>{headerContent}</div> + {column.isSorted && + (column.isSortedDesc ? ( + <Icon size="lg" name="arrow-down" className={tableStyles.sortIcon} /> + ) : ( + <Icon name="arrow-up" size="lg" className={tableStyles.sortIcon} /> + ))} + </button> + {column.canFilter && <Filter column={column} tableStyles={tableStyles} field={field} />} + </> + ); + if (sortHeaderContent && tableFieldOptions?.headerComponent) { + sortHeaderContent = <tableFieldOptions.headerComponent field={field} defaultContent={sortHeaderContent} />; + } else if (tableFieldOptions?.headerComponent) { + headerContent = <tableFieldOptions.headerComponent field={field} defaultContent={headerContent} />; + } return ( <div className={tableStyles.headerCell} {...headerProps} role="columnheader"> - {column.canSort && ( - <> - <button {...column.getSortByToggleProps()} className={tableStyles.headerCellLabel}> - {showTypeIcons && ( - <Icon name={getFieldTypeIcon(field)} title={field?.type} size="sm" className={tableStyles.typeIcon} /> - )} - <div>{column.render('Header')}</div> - {column.isSorted && - (column.isSortedDesc ? ( - <Icon size="lg" name="arrow-down" className={tableStyles.sortIcon} /> - ) : ( - <Icon name="arrow-up" size="lg" className={tableStyles.sortIcon} /> - ))} - </button> - {column.canFilter && <Filter column={column} tableStyles={tableStyles} field={field} />} - </> - )} - {!column.canSort && column.render('Header')} + {column.canSort && sortHeaderContent} + {!column.canSort && headerContent} {!column.canSort && column.canFilter && <Filter column={column} tableStyles={tableStyles} field={field} />} {column.canResize && <div {...column.getResizerProps()} className={tableStyles.resizeHandle} />} </div> diff --git a/packages/grafana-ui/src/components/Table/RowsList.tsx b/packages/grafana-ui/src/components/Table/RowsList.tsx index ebb2773421e16..6b16f4430a2f3 100644 --- a/packages/grafana-ui/src/components/Table/RowsList.tsx +++ b/packages/grafana-ui/src/components/Table/RowsList.tsx @@ -44,6 +44,7 @@ interface RowsListProps { onCellFilterAdded?: TableFilterActionCallback; timeRange?: TimeRange; footerPaginationEnabled: boolean; + initialRowIndex?: number; } export const RowsList = (props: RowsListProps) => { @@ -66,9 +67,10 @@ export const RowsList = (props: RowsListProps) => { listHeight, listRef, enableSharedCrosshair = false, + initialRowIndex = undefined, } = props; - const [rowHighlightIndex, setRowHighlightIndex] = useState<number | undefined>(undefined); + const [rowHighlightIndex, setRowHighlightIndex] = useState<number | undefined>(initialRowIndex); const theme = useTheme2(); const panelContext = usePanelContext(); @@ -203,24 +205,27 @@ export const RowsList = (props: RowsListProps) => { ({ index, style, rowHighlightIndex }: { index: number; style: CSSProperties; rowHighlightIndex?: number }) => { const indexForPagination = rowIndexForPagination(index); const row = rows[indexForPagination]; + let additionalProps: React.HTMLAttributes<HTMLDivElement> = {}; prepareRow(row); - const expandedRowStyle = tableState.expanded[row.index] ? css({ '&:hover': { background: 'inherit' } }) : {}; + const expandedRowStyle = tableState.expanded[row.id] ? css({ '&:hover': { background: 'inherit' } }) : {}; if (rowHighlightIndex !== undefined && row.index === rowHighlightIndex) { style = { ...style, backgroundColor: theme.components.table.rowHoverBackground }; + additionalProps = { + 'aria-selected': 'true', + }; } - return ( <div - {...row.getRowProps({ style })} + {...row.getRowProps({ style, ...additionalProps })} className={cx(tableStyles.row, expandedRowStyle)} onMouseEnter={() => onRowHover(index, data)} onMouseLeave={onRowLeave} > {/*add the nested data to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/} - {nestedDataField && tableState.expanded[row.index] && ( + {nestedDataField && tableState.expanded[row.id] && ( <ExpandedRow nestedData={nestedDataField} tableStyles={tableStyles} @@ -265,7 +270,7 @@ export const RowsList = (props: RowsListProps) => { const getItemSize = (index: number): number => { const indexForPagination = rowIndexForPagination(index); const row = rows[indexForPagination]; - if (tableState.expanded[row.index] && nestedDataField) { + if (tableState.expanded[row.id] && nestedDataField) { return getExpandedRowHeight(nestedDataField, index, tableStyles); } diff --git a/packages/grafana-ui/src/components/Table/Table.mdx b/packages/grafana-ui/src/components/Table/Table.mdx index f4879965e2d51..bfb25d7517c19 100644 --- a/packages/grafana-ui/src/components/Table/Table.mdx +++ b/packages/grafana-ui/src/components/Table/Table.mdx @@ -13,6 +13,12 @@ This nested fields values can contain an array of one or more dataframes. Each o For each dataframe and index in the nested field, the dataframe will be rendered as one or more sub-tables below the main dataframe row at that index. +### Unique rowId + +In some cases it makes sense to persist the opened/closed state of the sub-tables. For example: with streaming queries where a user may manipulate the state while additional data is still loading. In such cases use `dataframe.meta.uniqueRowIdFields` property to specify which fields create unique row id and table will use it to persist the state across data changes. + +## Custom dataframe properties + Each dataframe also supports using the following custom property under `dataframe.meta.custom`: - **noHeader**: boolean - Hides that sub-tables header. diff --git a/packages/grafana-ui/src/components/Table/Table.test.tsx b/packages/grafana-ui/src/components/Table/Table.test.tsx index 4b2322923d4e9..3d3fc8f5e16c3 100644 --- a/packages/grafana-ui/src/components/Table/Table.test.tsx +++ b/packages/grafana-ui/src/components/Table/Table.test.tsx @@ -4,60 +4,85 @@ import React from 'react'; import { applyFieldOverrides, createTheme, DataFrame, FieldType, toDataFrame } from '@grafana/data'; +import { Icon } from '../Icon/Icon'; + import { Table } from './Table'; -import { Props } from './types'; - -function getDefaultDataFrame(): DataFrame { - const dataFrame = toDataFrame({ - name: 'A', - fields: [ - { - name: 'time', - type: FieldType.time, - values: [1609459200000, 1609470000000, 1609462800000, 1609466400000], - config: { - custom: { - filterable: false, - }, +import { CustomHeaderRendererProps, Props } from './types'; + +// mock transition styles to ensure consistent behaviour in unit tests +jest.mock('@floating-ui/react', () => ({ + ...jest.requireActual('@floating-ui/react'), + useTransitionStyles: () => ({ + styles: {}, + }), +})); + +const dataFrameData = { + name: 'A', + fields: [ + { + name: 'time', + type: FieldType.time, + values: [1609459200000, 1609470000000, 1609462800000, 1609466400000], + config: { + custom: { + filterable: false, }, }, - { - name: 'temperature', - type: FieldType.number, - values: [10, NaN, 11, 12], - config: { - custom: { - filterable: false, - }, - links: [ - { - targetBlank: true, - title: 'Value link', - url: '${__value.text}', - }, - ], + }, + { + name: 'temperature', + type: FieldType.number, + values: [10, NaN, 11, 12], + config: { + custom: { + filterable: false, + headerComponent: (props: CustomHeaderRendererProps) => ( + <span> + {props.defaultContent} + <Icon aria-label={'header-icon'} name={'ellipsis-v'} /> + </span> + ), }, - }, - { - name: 'img', - type: FieldType.string, - values: ['data:image/png;base64,1', 'data:image/png;base64,2', 'data:image/png;base64,3'], - config: { - custom: { - filterable: false, - displayMode: 'image', + links: [ + { + targetBlank: true, + title: 'Value link', + url: '${__value.text}', }, - links: [ - { - targetBlank: true, - title: 'Image link', - url: '${__value.text}', - }, - ], + ], + }, + }, + { + name: 'img', + type: FieldType.string, + values: ['data:image/png;base64,1', 'data:image/png;base64,2', 'data:image/png;base64,3'], + config: { + custom: { + filterable: false, + displayMode: 'image', }, + links: [ + { + targetBlank: true, + title: 'Image link', + url: '${__value.text}', + }, + ], }, - ], - }); + }, + ], +}; + +const fullDataFrame = toDataFrame(dataFrameData); + +const emptyValuesDataFrame = toDataFrame({ + ...dataFrameData, + // Remove all values + fields: dataFrameData.fields.map((field) => ({ ...field, values: [] })), +}); + +function getDataFrame(dataFrame: DataFrame): DataFrame { return applyOverrides(dataFrame); } @@ -68,7 +93,7 @@ function applyOverrides(dataFrame: DataFrame) { defaults: {}, overrides: [], }, - replaceVariables: (value, vars, format) => { + replaceVariables: (value, vars, _format) => { return vars && value === '${__value.text}' ? '${__value.text} interpolation' : value; }, timeZone: 'utc', @@ -83,12 +108,13 @@ function getTestContext(propOverrides: Partial<Props> = {}) { const onColumnResize = jest.fn(); const props: Props = { ariaLabel: 'aria-label', - data: getDefaultDataFrame(), + data: getDataFrame(fullDataFrame), height: 600, width: 800, onSortByChange, onCellFilterAdded, onColumnResize, + initialRowIndex: undefined, }; Object.assign(props, propOverrides); @@ -127,16 +153,53 @@ function getRowsData(rows: HTMLElement[]): Object[] { } describe('Table', () => { - describe('when mounted without data', () => { - it('then no data to show should be displayed', () => { - getTestContext({ data: toDataFrame([]) }); - expect(getTable()).toBeInTheDocument(); - expect(screen.queryByRole('row')).not.toBeInTheDocument(); - expect(screen.getByText(/No data/i)).toBeInTheDocument(); + describe('when mounted with EMPTY data', () => { + describe('and Standard Options `No value` value is NOT set', () => { + it('the default `no data` message should be displayed', () => { + getTestContext({ data: toDataFrame([]) }); + expect(getTable()).toBeInTheDocument(); + expect(screen.queryByRole('row')).not.toBeInTheDocument(); + expect(screen.getByText(/No data/i)).toBeInTheDocument(); + }); + }); + + describe('and Standard Options `No value` value IS set', () => { + it('the `No value` Standard Options message should be displayed', () => { + const noValuesDisplayText = 'All healthy'; + getTestContext({ + data: toDataFrame([]), + fieldConfig: { defaults: { noValue: noValuesDisplayText }, overrides: [] }, + }); + expect(getTable()).toBeInTheDocument(); + expect(screen.queryByRole('row')).not.toBeInTheDocument(); + expect(screen.getByText(noValuesDisplayText)).toBeInTheDocument(); + }); }); }); describe('when mounted with data', () => { + describe('but empty values', () => { + describe('and Standard Options `No value` value is NOT set', () => { + it('the default `no data` message should be displayed', () => { + getTestContext({ data: getDataFrame(emptyValuesDataFrame) }); + expect(getTable()).toBeInTheDocument(); + expect(screen.getByText(/No data/i)).toBeInTheDocument(); + }); + }); + + describe('and Standard Options `No value` value IS set', () => { + it('the `No value` Standard Options message should be displayed', () => { + const noValuesDisplayText = 'All healthy'; + getTestContext({ + data: getDataFrame(emptyValuesDataFrame), + fieldConfig: { defaults: { noValue: noValuesDisplayText }, overrides: [] }, + }); + expect(getTable()).toBeInTheDocument(); + expect(screen.getByText(noValuesDisplayText)).toBeInTheDocument(); + }); + }); + }); + it('then correct rows should be rendered', () => { getTestContext(); expect(getTable()).toBeInTheDocument(); @@ -183,6 +246,19 @@ describe('Table', () => { }); }); + describe('custom header', () => { + it('Should be rendered', async () => { + getTestContext(); + + await userEvent.click(within(getColumnHeader(/temperature/)).getByText(/temperature/i)); + await userEvent.click(within(getColumnHeader(/temperature/)).getByText(/temperature/i)); + + const rows = within(getTable()).getAllByRole('row'); + expect(rows).toHaveLength(5); + expect(within(rows[0]).getByLabelText('header-icon')).toBeInTheDocument(); + }); + }); + describe('on filtering', () => { it('the rows should be filtered', async () => { getTestContext({ @@ -342,7 +418,7 @@ describe('Table', () => { const onColumnResize = jest.fn(); const props: Props = { ariaLabel: 'aria-label', - data: getDefaultDataFrame(), + data: getDataFrame(fullDataFrame), height: 600, width: 800, onSortByChange, @@ -484,7 +560,7 @@ describe('Table', () => { const onColumnResize = jest.fn(); const props: Props = { ariaLabel: 'aria-label', - data: getDefaultDataFrame(), + data: getDataFrame(fullDataFrame), height: 600, width: 800, onSortByChange, @@ -540,7 +616,7 @@ describe('Table', () => { }) ); - const defaultFrame = getDefaultDataFrame(); + const defaultFrame = getDataFrame(fullDataFrame); getTestContext({ data: applyOverrides({ @@ -657,4 +733,19 @@ describe('Table', () => { expect(subTable.style.height).toBe('108px'); }); }); + + describe('when mounted with scrolled to specific row', () => { + it('the row should be visible', async () => { + getTestContext({ + initialRowIndex: 2, + }); + expect(getTable()).toBeInTheDocument(); + + const rows = within(getTable()).getAllByRole('row'); + expect(rows).toHaveLength(5); + + let selected = within(getTable()).getByRole('row', { selected: true }); + expect(selected).toBeVisible(); + }); + }); }); diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index ffa4cab48bc66..b418401bc94cc 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -10,7 +10,7 @@ import { } from 'react-table'; import { VariableSizeList } from 'react-window'; -import { FieldType, ReducerID } from '@grafana/data'; +import { FieldType, ReducerID, getRowUniqueId } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { TableCellHeight } from '@grafana/schema'; @@ -29,6 +29,7 @@ import { getColumns, sortCaseInsensitive, sortNumber, getFooterItems, createFoot const COLUMN_MIN_WIDTH = 150; const FOOTER_ROW_HEIGHT = 36; +const NO_DATA_TEXT = 'No data'; export const Table = memo((props: Props) => { const { @@ -48,6 +49,8 @@ export const Table = memo((props: Props) => { cellHeight = TableCellHeight.Sm, timeRange, enableSharedCrosshair = false, + initialRowIndex = undefined, + fieldConfig, } = props; const listRef = useRef<VariableSizeList>(null); @@ -57,6 +60,7 @@ export const Table = memo((props: Props) => { const tableStyles = useTableStyles(theme, cellHeight); const headerHeight = noHeader ? 0 : tableStyles.rowHeight; const [footerItems, setFooterItems] = useState<FooterItem[] | undefined>(footerValues); + const noValuesDisplayText = fieldConfig?.defaults?.noValue ?? NO_DATA_TEXT; const footerHeight = useMemo(() => { const EXTENDED_ROW_HEIGHT = FOOTER_ROW_HEIGHT; @@ -129,8 +133,12 @@ export const Table = memo((props: Props) => { }, }); - const options: any = useMemo( - () => ({ + const hasUniqueId = !!data.meta?.uniqueRowIdFields?.length; + + const options: any = useMemo(() => { + // This is a bit hard to type with the react-table types here, the reducer does not actually match with the + // TableOptions. + const options: any = { columns: memoizedColumns, data: memoizedData, disableResizing: !resizable, @@ -139,12 +147,24 @@ export const Table = memo((props: Props) => { initialState: getInitialState(initialSortBy, memoizedColumns), autoResetFilters: false, sortTypes: { - number: sortNumber, // the builtin number type on react-table does not handle NaN values - 'alphanumeric-insensitive': sortCaseInsensitive, // should be replace with the builtin string when react-table is upgraded, see https://github.com/tannerlinsley/react-table/pull/3235 + // the builtin number type on react-table does not handle NaN values + number: sortNumber, + // should be replaced with the builtin string when react-table is upgraded, + // see https://github.com/tannerlinsley/react-table/pull/3235 + 'alphanumeric-insensitive': sortCaseInsensitive, }, - }), - [initialSortBy, memoizedColumns, memoizedData, resizable, stateReducer] - ); + }; + if (hasUniqueId) { + // row here is just always 0 because here we don't use real data but just a dummy array filled with 0. + // See memoizedData variable above. + options.getRowId = (row: Record<string, unknown>, relativeIndex: number) => getRowUniqueId(data, relativeIndex); + + // If we have unique field we assume we can count on it as being globally unique, and we don't need to reset when + // data changes. + options.autoResetExpanded = false; + } + return options; + }, [initialSortBy, memoizedColumns, memoizedData, resizable, stateReducer, hasUniqueId, data]); const { getTableProps, @@ -164,12 +184,6 @@ export const Table = memo((props: Props) => { const extendedState = state as GrafanaTableState; toggleAllRowsExpandedRef.current = toggleAllRowsExpanded; - const expandedRowsRepr = JSON.stringify(Object.keys(state.expanded)); - useEffect(() => { - // Reset the list size cache when the expanded rows change - listRef.current?.resetAfterIndex(0); - }, [expandedRowsRepr]); - /* Footer value calculation is being moved in the Table component and the footerValues prop will be deprecated. The footerValues prop is still used in the Table component for backwards compatibility. Adding the @@ -226,7 +240,18 @@ export const Table = memo((props: Props) => { setPageSize(pageSize); }, [pageSize, setPageSize]); - useResetVariableListSizeCache(extendedState, listRef, data); + useEffect(() => { + // Reset page index when data changes + // This is needed because react-table does not do this automatically + // autoResetPage is set to false because setting it to true causes the issue described in + // https://github.com/grafana/grafana/pull/67477 + if (data.length / pageSize < state.pageIndex) { + gotoPage(0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + useResetVariableListSizeCache(extendedState, listRef, data, hasUniqueId); useFixScrollbarContainer(variableSizeListScrollbarRef, tableDivRef); const onNavigate = useCallback( @@ -297,11 +322,12 @@ export const Table = memo((props: Props) => { tableStyles={tableStyles} footerPaginationEnabled={Boolean(enablePagination)} enableSharedCrosshair={enableSharedCrosshair} + initialRowIndex={initialRowIndex} /> </div> ) : ( <div style={{ height: height - headerHeight, width }} className={tableStyles.noData}> - No data + {noValuesDisplayText} </div> )} {footerItems && ( diff --git a/packages/grafana-ui/src/components/Table/TableCellInspectModal.tsx b/packages/grafana-ui/src/components/Table/TableCellInspectModal.tsx index 0f6611f844fdc..37729684b149a 100644 --- a/packages/grafana-ui/src/components/Table/TableCellInspectModal.tsx +++ b/packages/grafana-ui/src/components/Table/TableCellInspectModal.tsx @@ -14,9 +14,18 @@ interface TableCellInspectModalProps { export function TableCellInspectModal({ value, onDismiss, mode }: TableCellInspectModalProps) { let displayValue = value; if (isString(value)) { - try { - value = JSON.parse(value); - } catch {} // ignore errors + const trimmedValue = value.trim(); + // Exclude numeric strings like '123' from being displayed in code/JSON mode + if (trimmedValue[0] === '{' || trimmedValue[0] === '[' || mode === 'code') { + try { + value = JSON.parse(value); + mode = 'code'; + } catch { + mode = 'text'; + } // ignore errors + } else { + mode = 'text'; + } } else { displayValue = JSON.stringify(value, null, ' '); } diff --git a/packages/grafana-ui/src/components/Table/hooks.ts b/packages/grafana-ui/src/components/Table/hooks.ts index 870abc3c7e2e6..f6060fd15c519 100644 --- a/packages/grafana-ui/src/components/Table/hooks.ts +++ b/packages/grafana-ui/src/components/Table/hooks.ts @@ -23,7 +23,7 @@ export function useFixScrollbarContainer( // Select Table custom scrollbars const tableScrollbarView = tableDivRef.current.firstChild; - //If they exists, move the scrollbar element to the Table container scope + //If they exist, move the scrollbar element to the Table container scope if (tableScrollbarView && listVerticalScrollbarHTML) { listVerticalScrollbarHTML.remove(); if (tableScrollbarView instanceof HTMLElement) { @@ -36,41 +36,47 @@ export function useFixScrollbarContainer( } /** - react-table caches the height of cells so we need to reset them when expanding/collapsing rows - We need to take the minimum of the current expanded indexes and the previous expandedIndexes array to account - for collapsed rows, since they disappear from expandedIndexes but still keep their expanded height + react-table caches the height of cells, so we need to reset them when expanding/collapsing rows. + We use `lastExpandedOrCollapsedIndex` since collapsed rows disappear from `expandedIndexes` but still keep their expanded + height. */ export function useResetVariableListSizeCache( extendedState: GrafanaTableState, listRef: React.RefObject<VariableSizeList>, - data: DataFrame + data: DataFrame, + hasUniqueId: boolean ) { + // Make sure we trigger the reset when keys change in any way + const expandedRowsRepr = JSON.stringify(Object.keys(extendedState.expanded)); + useEffect(() => { - if (extendedState.lastExpandedIndex !== undefined) { - // Gets the expanded row with the lowest index. Needed to reset all expanded row heights from that index on - let resetIndex = extendedState.lastExpandedIndex; - const expandedIndexes = Object.keys(extendedState.expanded); - if (expandedIndexes.length > 0) { - const lowestExpandedIndex = parseInt(expandedIndexes[0], 10); - if (!isNaN(lowestExpandedIndex)) { - resetIndex = Math.min(resetIndex, lowestExpandedIndex); - } + // By default, reset all rows + let resetIndex = 0; + + // If we have unique field, extendedState.expanded keys are not row indexes but IDs so instead of trying to search + // for correct index we just reset the whole table. + if (!hasUniqueId) { + // If we don't have we reset from the last changed index. + if (Number.isFinite(extendedState.lastExpandedOrCollapsedIndex)) { + resetIndex = extendedState.lastExpandedOrCollapsedIndex!; } - const index = + // Account for paging. + resetIndex = extendedState.pageIndex === 0 ? resetIndex - 1 : resetIndex - extendedState.pageIndex - extendedState.pageIndex * extendedState.pageSize; - listRef.current?.resetAfterIndex(Math.max(index, 0)); - return; } + + listRef.current?.resetAfterIndex(Math.max(resetIndex, 0)); + return; }, [ - extendedState.lastExpandedIndex, - extendedState.toggleRowExpandedCounter, - extendedState.pageIndex, + extendedState.lastExpandedOrCollapsedIndex, extendedState.pageSize, + extendedState.pageIndex, listRef, data, - extendedState.expanded, + expandedRowsRepr, + hasUniqueId, ]); } diff --git a/packages/grafana-ui/src/components/Table/reducer.ts b/packages/grafana-ui/src/components/Table/reducer.ts index 1cac3c99ed6d0..1f450d4a8bacf 100644 --- a/packages/grafana-ui/src/components/Table/reducer.ts +++ b/packages/grafana-ui/src/components/Table/reducer.ts @@ -50,8 +50,7 @@ export function useTableStateReducer({ onColumnResize, onSortByChange, data }: P if (action.id) { return { ...newState, - lastExpandedIndex: parseInt(action.id, 10), - toggleRowExpandedCounter: newState.toggleRowExpandedCounter + 1, + lastExpandedOrCollapsedIndex: parseInt(action.id, 10), }; } } @@ -67,9 +66,7 @@ export function getInitialState( initialSortBy: Props['initialSortBy'], columns: GrafanaTableColumn[] ): Partial<GrafanaTableState> { - const state: Partial<GrafanaTableState> = { - toggleRowExpandedCounter: 0, - }; + const state: Partial<GrafanaTableState> = {}; if (initialSortBy) { state.sortBy = []; diff --git a/packages/grafana-ui/src/components/Table/styles.ts b/packages/grafana-ui/src/components/Table/styles.ts index 13632af7ff061..787e2ee5b519f 100644 --- a/packages/grafana-ui/src/components/Table/styles.ts +++ b/packages/grafana-ui/src/components/Table/styles.ts @@ -15,7 +15,8 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell color?: string, background?: string, overflowOnHover?: boolean, - asCellText?: boolean + asCellText?: boolean, + textShouldWrap?: boolean ) => { return css({ label: overflowOnHover ? 'cellContainerOverflow' : 'cellContainerNoOverflow', @@ -24,7 +25,7 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell // Cell height need to account for row border height: `${rowHeight - 1}px`, - display: asCellText ? 'block' : 'flex', + display: 'flex', ...(asCellText ? { @@ -48,10 +49,14 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell '&:hover': { overflow: overflowOnHover ? 'visible' : undefined, - width: overflowOnHover ? 'auto !important' : undefined, + width: textShouldWrap || !overflowOnHover ? 'auto' : 'auto !important', + height: textShouldWrap || overflowOnHover ? 'auto !important' : `${rowHeight - 1}px`, + minHeight: `${rowHeight - 1}px`, + wordBreak: textShouldWrap ? 'break-word' : undefined, + whiteSpace: textShouldWrap && overflowOnHover ? 'normal' : 'nowrap', boxShadow: overflowOnHover ? `0 0 2px ${theme.colors.primary.main}` : undefined, background: overflowOnHover ? background ?? theme.components.table.rowHoverBackground : undefined, - zIndex: overflowOnHover ? 1 : undefined, + zIndex: 1, '.cellActions': { visibility: 'visible', opacity: 1, @@ -179,6 +184,7 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell whiteSpace: 'nowrap', color: theme.colors.text.link, fontWeight: theme.typography.fontWeightMedium, + paddingRight: theme.spacing(1.5), '&:hover': { textDecoration: 'underline', color: theme.colors.text.link, diff --git a/packages/grafana-ui/src/components/Table/types.ts b/packages/grafana-ui/src/components/Table/types.ts index 517aefb1b90da..563a0d9ee480c 100644 --- a/packages/grafana-ui/src/components/Table/types.ts +++ b/packages/grafana-ui/src/components/Table/types.ts @@ -2,7 +2,7 @@ import { Property } from 'csstype'; import { FC } from 'react'; import { CellProps, Column, Row, TableState, UseExpandedRowProps } from 'react-table'; -import { DataFrame, Field, KeyValue, SelectableValue, TimeRange } from '@grafana/data'; +import { DataFrame, Field, KeyValue, SelectableValue, TimeRange, FieldConfigSource } from '@grafana/data'; import * as schema from '@grafana/schema'; import { TableStyles } from './styles'; @@ -67,8 +67,10 @@ export interface TableFooterCalc { } export interface GrafanaTableState extends TableState { - lastExpandedIndex?: number; - toggleRowExpandedCounter: number; + // We manually track this to know where to reset the row heights. This is needed because react-table removed the + // collapsed IDs/indexes from the state.expanded map so when collapsing we would have to do a diff of current and + // previous state.expanded to know what changed. + lastExpandedOrCollapsedIndex?: number; } export interface GrafanaTableRow extends Row, UseExpandedRowProps<{}> {} @@ -95,6 +97,9 @@ export interface Props { /** @alpha Used by SparklineCell when provided */ timeRange?: TimeRange; enableSharedCrosshair?: boolean; + // The index of the field value that the table will initialize scrolled to + initialRowIndex?: number; + fieldConfig?: FieldConfigSource; } /** @@ -119,9 +124,19 @@ export interface TableCustomCellOptions { type: schema.TableCellDisplayMode.Custom; } +/** + * @alpha + * Props that will be passed to the TableCustomCellOptions.cellComponent when rendered. + */ +export interface CustomHeaderRendererProps { + field: Field; + defaultContent: React.ReactNode; +} + // As cue/schema cannot define function types (as main point of schema is to be serializable) we have to extend the // types here with the dynamic API. This means right now this is not usable as a table panel option for example. export type TableCellOptions = schema.TableCellOptions | TableCustomCellOptions; export type TableFieldOptions = Omit<schema.TableFieldOptions, 'cellOptions'> & { cellOptions: TableCellOptions; + headerComponent?: React.ComponentType<CustomHeaderRendererProps>; }; diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.ts index efece819ba613..34a8efe5aebc0 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.ts @@ -28,6 +28,7 @@ import { } from '@grafana/schema'; import { BarGaugeCell } from './BarGaugeCell'; +import { DataLinksCell } from './DataLinksCell'; import { DefaultCell } from './DefaultCell'; import { getFooterValue } from './FooterRow'; import { GeoCell } from './GeoCell'; @@ -183,6 +184,8 @@ export function getCellComponent(displayMode: TableCellDisplayMode, field: Field return SparklineCell; case TableCellDisplayMode.JSONView: return JSONViewCell; + case TableCellDisplayMode.DataLinks: + return DataLinksCell; } if (field.type === FieldType.geo) { @@ -382,10 +385,25 @@ export function getFooterItems( } function getFormattedValue(field: Field, reducer: string[], theme: GrafanaTheme2) { - const fmt = field.display ?? getDisplayProcessor({ field, theme }); + // If we don't have anything to return then we display nothing const calc = reducer[0]; - const v = reduceField({ field, reducers: reducer })[calc]; - return formattedValueToString(fmt(v)); + if (calc === undefined) { + return ''; + } + + // Calculate the reduction + const format = field.display ?? getDisplayProcessor({ field, theme }); + const fieldCalcValue = reduceField({ field, reducers: reducer })[calc]; + + // If the reducer preserves units then format the + // end value with the field display processor + const reducerInfo = fieldReducers.get(calc); + if (reducerInfo.preservesUnits) { + return formattedValueToString(format(fieldCalcValue)); + } + + // Otherwise we simply return the formatted string + return formattedValueToString({ text: fieldCalcValue }); } // This strips the raw vales from the `rows` object. diff --git a/packages/grafana-ui/src/components/Text/Text.mdx b/packages/grafana-ui/src/components/Text/Text.mdx index 8d35135973fdf..30b148afc3aa9 100644 --- a/packages/grafana-ui/src/components/Text/Text.mdx +++ b/packages/grafana-ui/src/components/Text/Text.mdx @@ -47,6 +47,7 @@ In this documentation you can find: - Do not use the `element` prop because of its appearance, use it to organize the structure of the page. - Do not use color for emphasis as colors are related to states such as `error`, `success`, `disabled` and so on. +- Do not use the `code` variant for anything other than code snippets. <br /> <br /> diff --git a/packages/grafana-ui/src/components/Text/Text.story.tsx b/packages/grafana-ui/src/components/Text/Text.story.tsx index 01988abef65dc..0ab0427e8c9d2 100644 --- a/packages/grafana-ui/src/components/Text/Text.story.tsx +++ b/packages/grafana-ui/src/components/Text/Text.story.tsx @@ -16,7 +16,10 @@ const meta: Meta = { }, }, argTypes: { - variant: { control: 'select', options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'body', 'bodySmall', undefined] }, + variant: { + control: 'select', + options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'body', 'bodySmall', 'code', undefined], + }, weight: { control: 'select', options: ['bold', 'medium', 'light', 'regular', undefined], diff --git a/packages/grafana-ui/src/components/Text/Typography.internal.story.tsx b/packages/grafana-ui/src/components/Text/Typography.internal.story.tsx new file mode 100644 index 0000000000000..d7014936a0261 --- /dev/null +++ b/packages/grafana-ui/src/components/Text/Typography.internal.story.tsx @@ -0,0 +1,87 @@ +import { Meta, StoryFn } from '@storybook/react'; +import React, { useState } from 'react'; + +import { Divider } from '../Divider/Divider'; +import { Field } from '../Forms/Field'; +import { Stack } from '../Layout/Stack/Stack'; + +import { Text } from './Text'; + +const meta: Meta = { + title: 'General/Text', +}; + +const FONT_WEIGHTS = [/*100, 200, 300, */ 400, 500 /*600, 700, 800, 900*/]; + +export const TypographySamples: StoryFn = () => { + const [fontWeight, setFontWeight] = useState(400); + const [fontSize, setFontSize] = useState(30); + + const handleFontWeightChange = (event: React.ChangeEvent<HTMLInputElement>) => { + setFontWeight(Number(event.target.value)); + }; + + const handleFontSizeChange = (event: React.ChangeEvent<HTMLInputElement>) => { + setFontSize(Number(event.target.value)); + }; + + return ( + <div style={{ fontSynthesis: 'none' }}> + <Field label={`Font weight - ${fontWeight}`}> + <input + type="range" + min={100} + max={900} + step={10} + value={fontWeight} + onChange={handleFontWeightChange} + style={{ width: '100%', maxWidth: 400 }} + /> + </Field> + + <Field label={`Font size - ${fontSize}`}> + <input + type="range" + min={8} + max={100} + value={fontSize} + onChange={handleFontSizeChange} + style={{ width: '100%', maxWidth: 400 }} + /> + </Field> + + <div style={{ fontWeight: fontWeight, fontSize: fontSize }}>Add new time series panel to Grafana dashboard</div> + + <Divider /> + + <Stack direction="column" gap={4}> + {FONT_WEIGHTS.map((weight) => { + return ( + <div key={weight}> + <Text>Font weight {weight}</Text> + + <div style={{ fontWeight: weight, fontSize: fontSize }} contentEditable> + Add new time series panel + <br /> + Figure A⃝ #⃞ 3⃝ ×⃞ + <br /> + 3x9 12:34 3–8 +8+x + <br /> + 01 02 03 04 05 06 07 08 09 00 + <br /> + 11 12 13 14 15 16 17 18 19 10 + <div style={{ fontFeatureSettings: '"tnum"' }}> + 01 02 03 04 05 06 07 08 09 00 + <br /> + 11 12 13 14 15 16 17 18 19 10 + </div> + </div> + </div> + ); + })} + </Stack> + </div> + ); +}; + +export default meta; diff --git a/packages/grafana-ui/src/components/ThemeDemos/EmotionPerfTest.tsx b/packages/grafana-ui/src/components/ThemeDemos/EmotionPerfTest.tsx index 468f91dd50e12..3399fff3fc0bb 100644 --- a/packages/grafana-ui/src/components/ThemeDemos/EmotionPerfTest.tsx +++ b/packages/grafana-ui/src/components/ThemeDemos/EmotionPerfTest.tsx @@ -1,6 +1,6 @@ /** @jsxRuntime classic */ /** @jsx jsx */ -import { css, CSSInterpolation, cx } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import { jsx } from '@emotion/react'; import classnames from 'classnames'; import { Profiler, ProfilerOnRenderCallback, useState, FC } from 'react'; @@ -126,14 +126,7 @@ function NoStyles({ index }: TestComponentProps) { } function MeasureRender({ children, id }: { children: React.ReactNode; id: string }) { - const onRender: ProfilerOnRenderCallback = ( - id: string, - phase: 'mount' | 'update', - actualDuration: number, - baseDuration: number, - startTime: number, - commitTime: number - ) => { + const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration, startTime, commitTime) => { console.log('Profile ' + id, actualDuration); }; @@ -174,7 +167,7 @@ const getStylesObjects = (theme: GrafanaTheme2) => { }; }; -function getStylesObjectMain(theme: GrafanaTheme2): CSSInterpolation { +function getStylesObjectMain(theme: GrafanaTheme2) { return { background: 'blue', border: '1px solid red', @@ -187,12 +180,12 @@ function getStylesObjectMain(theme: GrafanaTheme2): CSSInterpolation { }; } -function getStylesObjectChild(theme: GrafanaTheme2): CSSInterpolation { +function getStylesObjectChild(theme: GrafanaTheme2) { return { padding: '2px', fontSize: '10px', boxShadow: 'none', textAlign: 'center', textDecoration: 'none', - }; + } as const; } diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx b/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx index 406a8594fed3f..1a4ebea1fec2b 100644 --- a/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx @@ -15,7 +15,7 @@ const meta: Meta<typeof Toggletip> = { page: mdx, }, controls: { - exclude: ['onClose', 'children'], + exclude: ['children'], }, }, argTypes: { diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.test.tsx b/packages/grafana-ui/src/components/Toggletip/Toggletip.test.tsx index db37c8753ee05..0bbe95cf8524f 100644 --- a/packages/grafana-ui/src/components/Toggletip/Toggletip.test.tsx +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -48,16 +48,15 @@ describe('Toggletip', () => { expect(await screen.findByTestId('toggletip-content')).toBeInTheDocument(); + // Escape should not close the toggletip + const button = screen.getByTestId('myButton'); + await userEvent.click(button); + expect(onClose).toHaveBeenCalledTimes(1); + // Close button should not close the toggletip const closeButton = screen.getByTestId('toggletip-header-close'); expect(closeButton).toBeInTheDocument(); await userEvent.click(closeButton); - expect(onClose).toHaveBeenCalledTimes(1); - - // Escape should not close the toggletip - const button = screen.getByTestId('myButton'); - await userEvent.click(button); - await userEvent.keyboard('{escape}'); expect(onClose).toHaveBeenCalledTimes(2); // Either way, the toggletip should still be visible @@ -162,7 +161,7 @@ describe('Toggletip', () => { const button = screen.getByTestId('myButton'); const afterButton = screen.getByText(afterInDom); await userEvent.click(button); - await userEvent.tab(); + const closeButton = screen.getByTestId('toggletip-header-close'); expect(closeButton).toHaveFocus(); @@ -183,14 +182,7 @@ describe('Toggletip', () => { let user: ReturnType<typeof userEvent.setup>; beforeEach(() => { - jest.useFakeTimers(); - // Need to use delay: null here to work with fakeTimers - // see https://github.com/testing-library/user-event/issues/833 - user = userEvent.setup({ delay: null }); - }); - - afterEach(() => { - jest.useRealTimers(); + user = userEvent.setup(); }); it('should restore focus to the button that opened the toggletip when closed from within the toggletip', async () => { @@ -208,11 +200,10 @@ describe('Toggletip', () => { const closeButton = await screen.findByTestId('toggletip-header-close'); expect(closeButton).toBeInTheDocument(); await user.click(closeButton); - act(() => { - jest.runAllTimers(); - }); - expect(button).toHaveFocus(); + await waitFor(() => { + expect(button).toHaveFocus(); + }); }); it('should NOT restore focus to the button that opened the toggletip when closed from outside the toggletip', async () => { @@ -239,9 +230,6 @@ describe('Toggletip', () => { afterButton.focus(); await user.keyboard('{escape}'); - act(() => { - jest.runAllTimers(); - }); expect(afterButton).toHaveFocus(); }); diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx b/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx index d1e6e2e8e78ef..f45fee4483111 100644 --- a/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx @@ -1,12 +1,24 @@ import { css, cx } from '@emotion/css'; +import { + arrow, + autoUpdate, + flip, + FloatingArrow, + FloatingFocusManager, + offset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, +} from '@floating-ui/react'; import { Placement } from '@popperjs/core'; -import React, { useCallback, useEffect, useRef } from 'react'; -import { usePopperTooltip } from 'react-popper-tooltip'; +import React, { useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2 } from '../../themes/ThemeContext'; -import { buildTooltipTheme } from '../../utils/tooltipUtils'; +import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; +import { buildTooltipTheme, getPlacement } from '../../utils/tooltipUtils'; import { IconButton } from '../IconButton/IconButton'; import { ToggletipContent } from './types'; @@ -19,7 +31,7 @@ export interface ToggletipProps { /** determine whether to show or not the close button **/ closeButton?: boolean; /** Callback function to be called when the toggletip is closed */ - onClose?: Function; + onClose?: () => void; /** The preferred placement of the toggletip */ placement?: Placement; /** The text or component that houses the content of the toggleltip */ @@ -50,94 +62,100 @@ export const Toggletip = React.memo( onOpen, show, }: ToggletipProps) => { + const arrowRef = useRef(null); + const grafanaTheme = useTheme2(); const styles = useStyles2(getStyles); const style = styles[theme]; - const contentRef = useRef(null); - const [controlledVisible, setControlledVisible] = React.useState(show); + const [controlledVisible, setControlledVisible] = useState(show); + const isOpen = show ?? controlledVisible; - const { getArrowProps, getTooltipProps, setTooltipRef, setTriggerRef, visible, update, tooltipRef, triggerRef } = - usePopperTooltip( - { - visible: show ?? controlledVisible, - placement: placement, - interactive: true, - offset: [0, 8], - // If show is undefined, the toggletip will be shown on click - trigger: 'click', - onVisibleChange: (visible: boolean) => { - if (show === undefined) { - setControlledVisible(visible); - } - if (!visible) { - onClose?.(); - } else { - onOpen?.(); - } - }, - }, - { - strategy: 'fixed', - } - ); - - const closeToggletip = useCallback( - (event: KeyboardEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>) => { - setControlledVisible(false); - onClose?.(); + // the order of middleware is important! + // `arrow` should almost always be at the end + // see https://floating-ui.com/docs/arrow#order + const middleware = [ + offset(8), + flip({ + fallbackAxisSideDirection: 'end', + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + arrow({ + element: arrowRef, + }), + ]; - if (event.target instanceof Node && tooltipRef?.contains(event.target)) { - triggerRef?.focus(); + const { context, refs, floatingStyles } = useFloating({ + open: isOpen, + placement: getPlacement(placement), + onOpenChange: (open) => { + if (show === undefined) { + setControlledVisible(open); + } + if (!open) { + onClose?.(); + } else { + onOpen?.(); } }, - [onClose, tooltipRef, triggerRef] - ); + middleware, + whileElementsMounted: autoUpdate, + strategy: 'fixed', + }); + + const click = useClick(context); + const dismiss = useDismiss(context); - useEffect(() => { - if (controlledVisible) { - const handleKeyDown = (enterKey: KeyboardEvent) => { - if (enterKey.key === 'Escape') { - closeToggletip(enterKey); - } - }; - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - } - return; - }, [controlledVisible, closeToggletip]); + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]); return ( <> {React.cloneElement(children, { - ref: setTriggerRef, + ref: refs.setReference, tabIndex: 0, - 'aria-expanded': visible, + 'aria-expanded': isOpen, + ...getReferenceProps(), })} - {visible && ( - <div - data-testid="toggletip-content" - ref={setTooltipRef} - {...getTooltipProps({ className: cx(style.container, fitContent && styles.fitContent) })} - > - {Boolean(title) && <div className={style.header}>{title}</div>} - {closeButton && ( - <div className={style.headerClose}> - <IconButton - tooltip="Close" - name="times" - data-testid="toggletip-header-close" - onClick={closeToggletip} - /> + {isOpen && ( + <FloatingFocusManager context={context} modal={false} closeOnFocusOut={false}> + <div + data-testid="toggletip-content" + className={cx(style.container, { + [styles.fitContent]: fitContent, + })} + ref={refs.setFloating} + style={floatingStyles} + {...getFloatingProps()} + > + <FloatingArrow + strokeWidth={0.3} + stroke={grafanaTheme.colors.border.weak} + className={style.arrow} + ref={arrowRef} + context={context} + /> + {Boolean(title) && <div className={style.header}>{title}</div>} + {closeButton && ( + <div className={style.headerClose}> + <IconButton + aria-label="Close" + name="times" + data-testid="toggletip-header-close" + onClick={() => { + setControlledVisible(false); + onClose?.(); + }} + /> + </div> + )} + <div className={style.body}> + {(typeof content === 'string' || React.isValidElement(content)) && content} + {typeof content === 'function' && content({})} </div> - )} - <div ref={contentRef} {...getArrowProps({ className: style.arrow })} /> - <div className={style.body}> - {(typeof content === 'string' || React.isValidElement(content)) && content} - {typeof content === 'function' && update && content({ update })} + {Boolean(footer) && <div className={style.footer}>{footer}</div>} </div> - {Boolean(footer) && <div className={style.footer}>{footer}</div>} - </div> + </FloatingFocusManager> )} </> ); diff --git a/packages/grafana-ui/src/components/Toggletip/types.ts b/packages/grafana-ui/src/components/Toggletip/types.ts index 4f3332b08101f..62d0335a8685a 100644 --- a/packages/grafana-ui/src/components/Toggletip/types.ts +++ b/packages/grafana-ui/src/components/Toggletip/types.ts @@ -1,8 +1,9 @@ -/** - * This API allows popovers to update Popper's position when e.g. popover content changes - * update is delivered to content by react-popper. - */ export interface ToggletipContentProps { + /** + * @deprecated + * This prop is deprecated and no longer has any effect as popper position updates automatically. + * It will be removed in a future release. + */ update?: () => void; } diff --git a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx index f34b96c99f964..9bebc40b83c5b 100644 --- a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx +++ b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx @@ -97,7 +97,7 @@ export const ToolbarButton = forwardRef<HTMLButtonElement, ToolbarButtonProps>( ); return tooltip ? ( - <Tooltip content={tooltip} placement="bottom"> + <Tooltip ref={ref} content={tooltip} placement="bottom"> {body} </Tooltip> ) : ( diff --git a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButtonRow.tsx b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButtonRow.tsx index c6cecb958b5a3..c3f59a9d9f9f3 100644 --- a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButtonRow.tsx +++ b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButtonRow.tsx @@ -117,7 +117,7 @@ const getStyles = (theme: GrafanaTheme2, overflowButtonOrder: number, alignment: alignItems: 'center', backgroundColor: theme.colors.background.primary, borderRadius: theme.shape.radius.default, - boxShadow: theme.shadows.z3, + boxShadow: theme.shadows.z2, display: 'flex', flexWrap: 'wrap', gap: theme.spacing(1), @@ -128,7 +128,7 @@ const getStyles = (theme: GrafanaTheme2, overflowButtonOrder: number, alignment: right: 0, top: '100%', width: 'max-content', - zIndex: theme.zIndex.sidemenu, + zIndex: theme.zIndex.dropdown, }), container: css({ alignItems: 'center', diff --git a/packages/grafana-ui/src/components/Tooltip/Popover.tsx b/packages/grafana-ui/src/components/Tooltip/Popover.tsx index cb760b3cf5e6a..81527f3cde6a7 100644 --- a/packages/grafana-ui/src/components/Tooltip/Popover.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Popover.tsx @@ -1,96 +1,102 @@ -import { Placement, VirtualElement } from '@popperjs/core'; -import React, { PureComponent } from 'react'; -import { Manager, Popper as ReactPopper, PopperArrowProps } from 'react-popper'; -import Transition from 'react-transition-group/Transition'; +import { + FloatingArrow, + arrow, + autoUpdate, + flip, + offset, + shift, + useFloating, + useTransitionStyles, +} from '@floating-ui/react'; +import React, { useLayoutEffect, useRef } from 'react'; +import { useTheme2 } from '../../themes'; +import { getPlacement } from '../../utils/tooltipUtils'; import { Portal } from '../Portal/Portal'; -import { PopoverContent } from './types'; - -const defaultTransitionStyles = { - transitionProperty: 'opacity', - transitionDuration: '200ms', - transitionTimingFunction: 'linear', - opacity: 0, -}; - -const transitionStyles: { [key: string]: object } = { - exited: { opacity: 0 }, - entering: { opacity: 0 }, - entered: { opacity: 1, transitionDelay: '0s' }, - exiting: { opacity: 0, transitionDelay: '500ms' }, -}; - -export type RenderPopperArrowFn = (props: { arrowProps: PopperArrowProps; placement: string }) => JSX.Element; +import { PopoverContent, TooltipPlacement } from './types'; interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'content'> { show: boolean; - placement?: Placement; + placement?: TooltipPlacement; content: PopoverContent; - referenceElement: HTMLElement | VirtualElement; + referenceElement: HTMLElement; wrapperClassName?: string; - renderArrow?: RenderPopperArrowFn; + renderArrow?: boolean; } -class Popover extends PureComponent<Props> { - render() { - const { content, show, placement, className, wrapperClassName, renderArrow, referenceElement, ...rest } = - this.props; +export function Popover({ + content, + show, + placement, + className, + wrapperClassName, + referenceElement, + renderArrow, + ...rest +}: Props) { + const theme = useTheme2(); + const arrowRef = useRef(null); - return ( - <Manager> - <Transition in={show} timeout={100} mountOnEnter={true} unmountOnExit={true}> - {(transitionState) => { - return ( - <Portal> - <ReactPopper - placement={placement} - referenceElement={referenceElement} - modifiers={[ - { name: 'preventOverflow', enabled: true, options: { rootBoundary: 'viewport' } }, - { - name: 'eventListeners', - options: { scroll: true, resize: true }, - }, - ]} - > - {({ ref, style, placement, arrowProps, update }) => { - return ( - <div - ref={ref} - style={{ - ...style, - ...defaultTransitionStyles, - ...transitionStyles[transitionState], - }} - data-placement={placement} - className={`${wrapperClassName}`} - {...rest} - > - <div className={className}> - {typeof content === 'string' && content} - {React.isValidElement(content) && React.cloneElement(content)} - {typeof content === 'function' && - content({ - updatePopperPosition: update, - })} - {renderArrow && - renderArrow({ - arrowProps, - placement, - })} - </div> - </div> - ); - }} - </ReactPopper> - </Portal> - ); - }} - </Transition> - </Manager> + // the order of middleware is important! + // `arrow` should almost always be at the end + // see https://floating-ui.com/docs/arrow#order + const middleware = [ + offset(8), + flip({ + fallbackAxisSideDirection: 'end', + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ]; + + if (renderArrow) { + middleware.push( + arrow({ + element: arrowRef, + }) ); } -} -export { Popover }; + const { context, refs, floatingStyles } = useFloating({ + open: show, + placement: getPlacement(placement), + middleware, + whileElementsMounted: autoUpdate, + strategy: 'fixed', + }); + + useLayoutEffect(() => { + refs.setReference(referenceElement); + }, [referenceElement, refs]); + + const { styles: placementStyles } = useTransitionStyles(context, { + initial: () => ({ + opacity: 0, + }), + duration: theme.transitions.duration.enteringScreen, + }); + + return show ? ( + <Portal> + <div + ref={refs.setFloating} + style={{ + ...floatingStyles, + ...placementStyles, + }} + className={wrapperClassName} + {...rest} + > + <div className={className}> + {renderArrow && <FloatingArrow fill={theme.colors.border.weak} ref={arrowRef} context={context} />} + {typeof content === 'string' && content} + {React.isValidElement(content) && React.cloneElement(content)} + {typeof content === 'function' && content({})} + </div> + </div> + </Portal> + ) : undefined; +} diff --git a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx index 1cc90e3cc581b..dcc37ac9f32ff 100644 --- a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx @@ -1,11 +1,23 @@ -import React, { useCallback, useEffect, useId, useState } from 'react'; -import { usePopperTooltip } from 'react-popper-tooltip'; +import { + arrow, + autoUpdate, + flip, + FloatingArrow, + offset, + shift, + useDismiss, + useFloating, + useFocus, + useHover, + useInteractions, +} from '@floating-ui/react'; +import React, { useCallback, useId, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { useStyles2 } from '../../themes/ThemeContext'; -import { buildTooltipTheme } from '../../utils/tooltipUtils'; +import { buildTooltipTheme, getPlacement } from '../../utils/tooltipUtils'; import { Portal } from '../Portal/Portal'; import { PopoverContent, TooltipPlacement } from './types'; @@ -24,53 +36,55 @@ export interface TooltipProps { export const Tooltip = React.forwardRef<HTMLElement, TooltipProps>( ({ children, theme, interactive, show, placement, content }, forwardedRef) => { + const arrowRef = useRef(null); const [controlledVisible, setControlledVisible] = useState(show); + const isOpen = show ?? controlledVisible; + + // the order of middleware is important! + // `arrow` should almost always be at the end + // see https://floating-ui.com/docs/arrow#order + const middleware = [ + offset(8), + flip({ + fallbackAxisSideDirection: 'end', + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + arrow({ + element: arrowRef, + }), + ]; + + const { context, refs, floatingStyles } = useFloating({ + open: isOpen, + placement: getPlacement(placement), + onOpenChange: setControlledVisible, + middleware, + whileElementsMounted: autoUpdate, + }); const tooltipId = useId(); - useEffect(() => { - if (controlledVisible !== false) { - const handleKeyDown = (enterKey: KeyboardEvent) => { - if (enterKey.key === 'Escape') { - setControlledVisible(false); - } - }; - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - } else { - return; - } - }, [controlledVisible]); - - const { getArrowProps, getTooltipProps, setTooltipRef, setTriggerRef, visible, update } = usePopperTooltip({ - visible: show ?? controlledVisible, - placement, - interactive, - delayHide: interactive ? 100 : 0, - offset: [0, 8], - trigger: ['hover', 'focus'], - onVisibleChange: setControlledVisible, + const hover = useHover(context, { + delay: { + close: interactive ? 100 : 0, + }, + move: false, }); + const focus = useFocus(context); + const dismiss = useDismiss(context); - const contentIsFunction = typeof content === 'function'; + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, hover, focus]); - /** - * If content is a function we need to call popper update function to make sure the tooltip is positioned correctly - * if it's close to the viewport boundary - **/ - useEffect(() => { - if (update && contentIsFunction) { - update(); - } - }, [visible, update, contentIsFunction]); + const contentIsFunction = typeof content === 'function'; const styles = useStyles2(getStyles); const style = styles[theme ?? 'info']; const handleRef = useCallback( (ref: HTMLElement | null) => { - setTriggerRef(ref); + refs.setReference(ref); if (typeof forwardedRef === 'function') { forwardedRef(ref); @@ -78,33 +92,35 @@ export const Tooltip = React.forwardRef<HTMLElement, TooltipProps>( forwardedRef.current = ref; } }, - [forwardedRef, setTriggerRef] + [forwardedRef, refs] ); + // if the child has a matching aria-label, this should take precedence over the tooltip content + // otherwise we end up double announcing things in e.g. IconButton + const childHasMatchingAriaLabel = 'aria-label' in children.props && children.props['aria-label'] === content; + return ( <> {React.cloneElement(children, { ref: handleRef, tabIndex: 0, // tooltip trigger should be keyboard focusable - 'aria-describedby': visible ? tooltipId : undefined, + 'aria-describedby': !childHasMatchingAriaLabel && isOpen ? tooltipId : undefined, + ...getReferenceProps(), })} - {visible && ( + {isOpen && ( <Portal> - <div - data-testid={selectors.components.Tooltip.container} - ref={setTooltipRef} - id={tooltipId} - role="tooltip" - {...getTooltipProps({ className: style.container })} - > - <div {...getArrowProps({ className: style.arrow })} /> - {typeof content === 'string' && content} - {React.isValidElement(content) && React.cloneElement(content)} - {contentIsFunction && - update && - content({ - updatePopperPosition: update, - })} + <div ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()}> + <FloatingArrow className={style.arrow} ref={arrowRef} context={context} /> + <div + data-testid={selectors.components.Tooltip.container} + id={tooltipId} + role="tooltip" + className={style.container} + > + {typeof content === 'string' && content} + {React.isValidElement(content) && React.cloneElement(content)} + {contentIsFunction && content({})} + </div> </div> </Portal> )} diff --git a/packages/grafana-ui/src/components/Tooltip/types.ts b/packages/grafana-ui/src/components/Tooltip/types.ts index cec0b6dd8a0c0..bd4d9ba852972 100644 --- a/packages/grafana-ui/src/components/Tooltip/types.ts +++ b/packages/grafana-ui/src/components/Tooltip/types.ts @@ -1,27 +1,14 @@ -/** - * This API allows popovers to update Popper's position when e.g. popover content changes - * updatePopperPosition is delivered to content by react-popper. - */ +import { Placement } from '@floating-ui/react'; + export interface PopoverContentProps { - // Is this used anywhere in plugins? Can we remove it or rename it to just update? + /** + * @deprecated + * This prop is deprecated and no longer has any effect as popper position updates automatically. + * It will be removed in a future release. + */ updatePopperPosition?: () => void; } export type PopoverContent = string | React.ReactElement | ((props: PopoverContentProps) => JSX.Element); -export type TooltipPlacement = - | 'auto-start' - | 'auto' - | 'auto-end' - | 'top-start' - | 'top' - | 'top-end' - | 'right-start' - | 'right' - | 'right-end' - | 'bottom-end' - | 'bottom' - | 'bottom-start' - | 'left-end' - | 'left' - | 'left-start'; +export type TooltipPlacement = Placement | 'auto' | 'auto-start' | 'auto-end'; diff --git a/packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx b/packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx index 983aded2a6ccd..b9142f46b0d26 100644 --- a/packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx +++ b/packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx @@ -62,6 +62,7 @@ export class UnitPicker extends PureComponent<UnitPickerProps> { formatCreateLabel={formatCreateLabel} options={groupOptions} placeholder="Choose" + isClearable onSelect={this.props.onChange} /> ); diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx index 41321b615220e..5a83f4908f1f7 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx @@ -126,6 +126,7 @@ const getStyles = (theme: GrafanaTheme2) => { maxWidth: '600px', textOverflow: 'ellipsis', overflow: 'hidden', + userSelect: 'text', }), labelDisabled: css({ label: 'LegendLabelDisabled', diff --git a/packages/grafana-ui/src/components/VizTooltip/HeaderLabel.tsx b/packages/grafana-ui/src/components/VizTooltip/HeaderLabel.tsx deleted file mode 100644 index 07d053479425e..0000000000000 --- a/packages/grafana-ui/src/components/VizTooltip/HeaderLabel.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; - -import { VizTooltipRow } from './VizTooltipRow'; -import { LabelValue } from './types'; - -interface Props { - headerLabel: LabelValue; -} - -export const HeaderLabel = ({ headerLabel }: Props) => { - const { label, value, color, colorIndicator } = headerLabel; - - return ( - <VizTooltipRow label={label} value={value} color={color} colorIndicator={colorIndicator} marginRight={'22px'} /> - ); -}; diff --git a/packages/grafana-ui/src/components/VizTooltip/SeriesList.tsx b/packages/grafana-ui/src/components/VizTooltip/SeriesList.tsx deleted file mode 100644 index bb10422de229a..0000000000000 --- a/packages/grafana-ui/src/components/VizTooltip/SeriesList.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; - -import { GraphSeriesValue } from '@grafana/data'; - -import { VizTooltipRow } from './VizTooltipRow'; -import { ColorIndicator } from './types'; - -export interface SeriesListProps { - series: SingleSeriesProps[]; -} - -// Based on SeriesTable, with new styling -export const SeriesList = ({ series }: SeriesListProps) => { - return ( - <> - {series.map((series, index) => { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const label = series.label as string; - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const value = series.value as string; - return ( - <VizTooltipRow - key={`${series.label}-${index}`} - label={label} - value={value} - color={series.color} - colorIndicator={ColorIndicator.series} - isActive={series.isActive} - justify={'space-between'} - /> - ); - })} - </> - ); -}; - -export interface SingleSeriesProps { - color?: string; - label?: React.ReactNode; - value?: string | GraphSeriesValue; - isActive?: boolean; - colorIndicator?: ColorIndicator; -} diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipColorIndicator.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipColorIndicator.tsx index 39e9c495f031c..5525dd2e9a56b 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipColorIndicator.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipColorIndicator.tsx @@ -8,9 +8,15 @@ import { useStyles2 } from '../../themes'; import { ColorIndicator, DEFAULT_COLOR_INDICATOR } from './types'; import { getColorIndicatorClass } from './utils'; +export enum ColorIndicatorPosition { + Leading, + Trailing, +} + interface Props { color?: string; colorIndicator?: ColorIndicator; + position?: ColorIndicatorPosition; } export type ColorIndicatorStyles = ReturnType<typeof getStyles>; @@ -18,22 +24,29 @@ export type ColorIndicatorStyles = ReturnType<typeof getStyles>; export const VizTooltipColorIndicator = ({ color = FALLBACK_COLOR, colorIndicator = DEFAULT_COLOR_INDICATOR, + position = ColorIndicatorPosition.Leading, }: Props) => { const styles = useStyles2(getStyles); return ( <span style={{ backgroundColor: color }} - className={cx(styles.colorIndicator, getColorIndicatorClass(colorIndicator, styles))} + className={cx( + position === ColorIndicatorPosition.Leading ? styles.leading : styles.trailing, + getColorIndicatorClass(colorIndicator, styles) + )} /> ); }; // @TODO Update classes/add svgs const getStyles = (theme: GrafanaTheme2) => ({ - colorIndicator: css({ + leading: css({ marginRight: theme.spacing(0.5), }), + trailing: css({ + marginLeft: theme.spacing(0.5), + }), series: css({ width: '14px', height: '4px', diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContent.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContent.tsx index c7558bcf288d8..802debb59c80a 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContent.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContent.tsx @@ -1,47 +1,46 @@ import { css } from '@emotion/css'; -import React, { ReactElement } from 'react'; +import React, { CSSProperties, ReactNode } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../themes'; import { VizTooltipRow } from './VizTooltipRow'; -import { LabelValue } from './types'; +import { VizTooltipItem } from './types'; -interface Props { - contentLabelValue: LabelValue[]; - customContent?: ReactElement[]; +interface VizTooltipContentProps { + items: VizTooltipItem[]; + children?: ReactNode; + scrollable?: boolean; + isPinned: boolean; } -export const VizTooltipContent = ({ contentLabelValue, customContent }: Props) => { +export const VizTooltipContent = ({ items, children, isPinned, scrollable = false }: VizTooltipContentProps) => { const styles = useStyles2(getStyles); + const scrollableStyle: CSSProperties = scrollable + ? { + maxHeight: 400, + overflowY: 'auto', + } + : {}; + return ( - <div className={styles.wrapper}> - <div> - {contentLabelValue.map((labelValue, i) => { - const { label, value, color, colorIndicator, colorPlacement, isActive } = labelValue; - return ( - <VizTooltipRow - key={i} - label={label} - value={value} - color={color} - colorIndicator={colorIndicator} - colorPlacement={colorPlacement} - isActive={isActive} - justify={'space-between'} - /> - ); - })} - </div> - {customContent?.map((content, i) => { - return ( - <div key={i} className={styles.customContentPadding}> - {content} - </div> - ); - })} + <div className={styles.wrapper} style={scrollableStyle}> + {items.map(({ label, value, color, colorIndicator, colorPlacement, isActive }, i) => ( + <VizTooltipRow + key={i} + label={label} + value={value} + color={color} + colorIndicator={colorIndicator} + colorPlacement={colorPlacement} + isActive={isActive} + justify={'space-between'} + isPinned={isPinned} + /> + ))} + {children} </div> ); }; @@ -55,7 +54,4 @@ const getStyles = (theme: GrafanaTheme2) => ({ borderTop: `1px solid ${theme.colors.border.medium}`, padding: theme.spacing(1), }), - customContentPadding: css({ - padding: `${theme.spacing(1)} 0`, - }), }); diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx index 9bbbf862b50b1..5713748baf158 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx @@ -6,14 +6,14 @@ import { Field, GrafanaTheme2, LinkModel } from '@grafana/data'; import { Button, ButtonProps, DataLinkButton, HorizontalGroup } from '..'; import { useStyles2 } from '../../themes'; -interface Props { +interface VizTooltipFooterProps { dataLinks: Array<LinkModel<Field>>; - canAnnotate: boolean; + annotate?: () => void; } export const ADD_ANNOTATION_ID = 'add-annotation-button'; -export const VizTooltipFooter = ({ dataLinks, canAnnotate }: Props) => { +export const VizTooltipFooter = ({ dataLinks, annotate }: VizTooltipFooterProps) => { const styles = useStyles2(getStyles); const renderDataLinks = () => { @@ -33,9 +33,9 @@ export const VizTooltipFooter = ({ dataLinks, canAnnotate }: Props) => { return ( <div className={styles.wrapper}> {dataLinks.length > 0 && <div className={styles.dataLinks}>{renderDataLinks()}</div>} - {canAnnotate && ( + {annotate != null && ( <div className={styles.addAnnotations}> - <Button icon="comment-alt" variant="secondary" size="sm" id={ADD_ANNOTATION_ID}> + <Button icon="comment-alt" variant="secondary" size="sm" id={ADD_ANNOTATION_ID} onClick={annotate}> Add annotation </Button> </div> diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipHeader.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipHeader.tsx index c2e4acb1f7671..360145db48f74 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipHeader.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipHeader.tsx @@ -1,26 +1,32 @@ import { css } from '@emotion/css'; -import React, { ReactElement } from 'react'; +import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../themes'; -import { HeaderLabel } from './HeaderLabel'; -import { VizTooltipHeaderLabelValue } from './VizTooltipHeaderLabelValue'; -import { LabelValue } from './types'; +import { VizTooltipRow } from './VizTooltipRow'; +import { VizTooltipItem } from './types'; interface Props { - headerLabel: LabelValue; - keyValuePairs?: LabelValue[]; - customValueDisplay?: ReactElement | null; + item: VizTooltipItem; + isPinned: boolean; } -export const VizTooltipHeader = ({ headerLabel, keyValuePairs, customValueDisplay }: Props) => { +export const VizTooltipHeader = ({ item, isPinned }: Props) => { const styles = useStyles2(getStyles); + const { label, value, color, colorIndicator } = item; + return ( <div className={styles.wrapper}> - <HeaderLabel headerLabel={headerLabel} /> - {customValueDisplay || <VizTooltipHeaderLabelValue keyValuePairs={keyValuePairs} />} + <VizTooltipRow + label={label} + value={value} + color={color} + colorIndicator={colorIndicator} + marginRight={'22px'} + isPinned={isPinned} + /> </div> ); }; diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipHeaderLabelValue.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipHeaderLabelValue.tsx deleted file mode 100644 index dcbf02a903796..0000000000000 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipHeaderLabelValue.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; - -import { VizTooltipRow } from './VizTooltipRow'; -import { LabelValue } from './types'; - -interface Props { - keyValuePairs?: LabelValue[]; -} - -export const VizTooltipHeaderLabelValue = ({ keyValuePairs }: Props) => ( - <> - {keyValuePairs?.map((keyValuePair, i) => ( - <VizTooltipRow - key={i} - label={keyValuePair.label} - value={keyValuePair.value} - color={keyValuePair.color} - colorIndicator={keyValuePair.colorIndicator!} - justify={'space-between'} - /> - ))} - </> -); diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipRow.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipRow.tsx index bcdc1ceac19b0..5faa6b31dfa22 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipRow.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipRow.tsx @@ -1,20 +1,31 @@ import { css, cx } from '@emotion/css'; -import React, { useState } from 'react'; +import React, { ReactNode, useEffect, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../themes'; +import { InlineToast } from '../InlineToast/InlineToast'; import { Tooltip } from '../Tooltip'; -import { VizTooltipColorIndicator } from './VizTooltipColorIndicator'; -import { ColorPlacement, LabelValue } from './types'; +import { ColorIndicatorPosition, VizTooltipColorIndicator } from './VizTooltipColorIndicator'; +import { ColorPlacement, VizTooltipItem } from './types'; -interface Props extends LabelValue { +interface VizTooltipRowProps extends Omit<VizTooltipItem, 'value'> { + value: string | number | null | ReactNode; justify?: string; isActive?: boolean; // for series list marginRight?: string; + isPinned: boolean; } +enum LabelValueTypes { + label = 'label', + value = 'value', +} + +const SUCCESSFULLY_COPIED_TEXT = 'Copied to clipboard'; +const SHOW_SUCCESS_DURATION = 2 * 1000; + export const VizTooltipRow = ({ label, value, @@ -24,12 +35,68 @@ export const VizTooltipRow = ({ justify = 'flex-start', isActive = false, marginRight = '0px', -}: Props) => { + isPinned, +}: VizTooltipRowProps) => { const styles = useStyles2(getStyles, justify, marginRight); const [showLabelTooltip, setShowLabelTooltip] = useState(false); const [showValueTooltip, setShowValueTooltip] = useState(false); + const [copiedText, setCopiedText] = useState<Record<string, string> | null>(null); + const [showCopySuccess, setShowCopySuccess] = useState(false); + + const labelRef = useRef<null | HTMLDivElement>(null); + const valueRef = useRef<null | HTMLDivElement>(null); + + useEffect(() => { + let timeoutId: ReturnType<typeof setTimeout>; + + if (showCopySuccess) { + timeoutId = setTimeout(() => { + setShowCopySuccess(false); + }, SHOW_SUCCESS_DURATION); + } + + return () => { + window.clearTimeout(timeoutId); + }; + }, [showCopySuccess]); + + const copyToClipboard = async (text: string, type: LabelValueTypes) => { + if (!(navigator?.clipboard && window.isSecureContext)) { + fallbackCopyToClipboard(text, type); + return; + } + + try { + await navigator.clipboard.writeText(text); + setCopiedText({ [`${type}`]: text }); + setShowCopySuccess(true); + } catch (error) { + setCopiedText(null); + } + }; + + const fallbackCopyToClipboard = (text: string, type: LabelValueTypes) => { + // Use a fallback method for browsers/contexts that don't support the Clipboard API. + const textarea = document.createElement('textarea'); + labelRef.current?.appendChild(textarea); + textarea.value = text; + textarea.focus(); + textarea.select(); + try { + const successful = document.execCommand('copy'); + if (successful) { + setCopiedText({ [`${type}`]: text }); + setShowCopySuccess(true); + } + } catch (err) { + console.error('Unable to copy to clipboard', err); + } + + textarea.remove(); + }; + const onMouseEnterLabel = (event: React.MouseEvent<HTMLDivElement>) => { if (event.currentTarget.offsetWidth < event.currentTarget.scrollWidth) { setShowLabelTooltip(true); @@ -53,32 +120,73 @@ export const VizTooltipRow = ({ {color && colorPlacement === ColorPlacement.first && ( <VizTooltipColorIndicator color={color} colorIndicator={colorIndicator} /> )} - <Tooltip content={label} interactive={false} show={showLabelTooltip}> - <div - className={cx(styles.label, isActive && styles.activeSeries)} - onMouseEnter={onMouseEnterLabel} - onMouseLeave={onMouseLeaveLabel} - > - {label} - </div> - </Tooltip> + {!isPinned ? ( + <div className={cx(styles.label, isActive && styles.activeSeries)}>{label}</div> + ) : ( + <> + <Tooltip content={label} interactive={false} show={showLabelTooltip}> + <> + {showCopySuccess && copiedText?.label && ( + <InlineToast placement="top" referenceElement={labelRef.current}> + {SUCCESSFULLY_COPIED_TEXT} + </InlineToast> + )} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */} + <div + className={cx(styles.label, isActive && styles.activeSeries, navigator?.clipboard && styles.copy)} + onMouseEnter={onMouseEnterLabel} + onMouseLeave={onMouseLeaveLabel} + onClick={() => copyToClipboard(label, LabelValueTypes.label)} + ref={labelRef} + > + {label} + </div> + </> + </Tooltip> + </> + )} </div> )} <div className={styles.valueWrapper}> {color && colorPlacement === ColorPlacement.leading && ( - <VizTooltipColorIndicator color={color} colorIndicator={colorIndicator} /> + <VizTooltipColorIndicator + color={color} + colorIndicator={colorIndicator} + position={ColorIndicatorPosition.Leading} + /> + )} + + {!isPinned ? ( + <div className={cx(styles.value, isActive)}>{value}</div> + ) : ( + <Tooltip content={value ? value.toString() : ''} interactive={false} show={showValueTooltip}> + <> + {showCopySuccess && copiedText?.value && ( + <InlineToast placement="top" referenceElement={valueRef.current}> + {SUCCESSFULLY_COPIED_TEXT} + </InlineToast> + )} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */} + <div + className={cx(styles.value, isActive, navigator?.clipboard && styles.copy)} + onMouseEnter={onMouseEnterValue} + onMouseLeave={onMouseLeaveValue} + onClick={() => copyToClipboard(value ? value.toString() : '', LabelValueTypes.value)} + ref={valueRef} + > + {value} + </div> + </> + </Tooltip> )} - <Tooltip content={value ? value.toString() : ''} interactive={false} show={showValueTooltip}> - <div className={cx(styles.value, isActive)} onMouseEnter={onMouseEnterValue} onMouseLeave={onMouseLeaveValue}> - {value} - </div> - </Tooltip> + {color && colorPlacement === ColorPlacement.trailing && ( - <> -   - <VizTooltipColorIndicator color={color} colorIndicator={colorIndicator} /> - </> + <VizTooltipColorIndicator + color={color} + colorIndicator={colorIndicator} + position={ColorIndicatorPosition.Trailing} + /> )} </div> </div> @@ -86,14 +194,6 @@ export const VizTooltipRow = ({ }; const getStyles = (theme: GrafanaTheme2, justify: string, marginRight: string) => ({ - wrapper: css({ - display: 'flex', - flexDirection: 'column', - flex: 1, - gap: 4, - borderTop: `1px solid ${theme.colors.border.medium}`, - padding: theme.spacing(1), - }), contentWrapper: css({ display: 'flex', alignItems: 'center', @@ -101,15 +201,12 @@ const getStyles = (theme: GrafanaTheme2, justify: string, marginRight: string) = flexWrap: 'wrap', marginRight: marginRight, }), - customContentPadding: css({ - padding: `${theme.spacing(1)} 0`, - }), label: css({ color: theme.colors.text.secondary, fontWeight: 400, textOverflow: 'ellipsis', overflow: 'hidden', - marginRight: theme.spacing(0.5), + marginRight: theme.spacing(2), }), value: css({ fontWeight: 500, @@ -125,4 +222,7 @@ const getStyles = (theme: GrafanaTheme2, justify: string, marginRight: string) = fontWeight: theme.typography.fontWeightBold, color: theme.colors.text.maxContrast, }), + copy: css({ + cursor: 'pointer', + }), }); diff --git a/packages/grafana-ui/src/components/VizTooltip/types.ts b/packages/grafana-ui/src/components/VizTooltip/types.ts index 4d33d3c263743..8a734a85092a7 100644 --- a/packages/grafana-ui/src/components/VizTooltip/types.ts +++ b/packages/grafana-ui/src/components/VizTooltip/types.ts @@ -17,13 +17,16 @@ export enum ColorPlacement { trailing = 'trailing', } -export interface LabelValue { +export interface VizTooltipItem { label: string; - value: string | number | null; + value: string; color?: string; colorIndicator?: ColorIndicator; colorPlacement?: ColorPlacement; isActive?: boolean; + + // internal/tmp for sorting + numeric?: number; } export const DEFAULT_COLOR_INDICATOR = ColorIndicator.series; diff --git a/packages/grafana-ui/src/components/VizTooltip/utils.test.ts b/packages/grafana-ui/src/components/VizTooltip/utils.test.ts index 92c59cdba5cd2..5c4189012bbfc 100644 --- a/packages/grafana-ui/src/components/VizTooltip/utils.test.ts +++ b/packages/grafana-ui/src/components/VizTooltip/utils.test.ts @@ -1,4 +1,7 @@ -import { calculateTooltipPosition } from './utils'; +import { DataFrame, FieldType } from '@grafana/data'; +import { SortOrder, TooltipDisplayMode } from '@grafana/schema'; + +import { calculateTooltipPosition, getContentItems } from './utils'; describe('utils', () => { describe('calculateTooltipPosition', () => { @@ -162,4 +165,171 @@ describe('utils', () => { }); }); }); + + describe('it tests getContentItems with numeric values', () => { + const timeValues = [1707833954056, 1707838274056, 1707842594056]; + const seriesAValues = [1, 20, 70]; + const seriesBValues = [-100, -26, null]; + + const frame = { + name: 'a', + length: timeValues.length, + fields: [ + { + name: 'time', + type: FieldType.time, + values: timeValues[0], + config: {}, + display: (value: string) => ({ + text: value, + color: undefined, + numeric: NaN, + }), + }, + { + name: 'A-series', + type: FieldType.number, + values: seriesAValues, + config: {}, + display: (value: string) => ({ + text: value, + color: undefined, + numeric: Number(value), + }), + }, + { + name: 'B-series', + type: FieldType.number, + values: seriesBValues, + config: {}, + display: (value: string) => ({ + text: value, + color: undefined, + numeric: Number(value), + }), + }, + ], + } as unknown as DataFrame; + + const fields = frame.fields; + const xField = frame.fields[0]; + const dataIdxs = [1, 1, 1]; + + it('displays one series in single mode', () => { + const rows = getContentItems(fields, xField, dataIdxs, 2, TooltipDisplayMode.Single, SortOrder.None); + expect(rows.length).toBe(1); + expect(rows[0].value).toBe('-26'); + }); + + it('displays the right content in multi mode', () => { + const rows = getContentItems(fields, xField, dataIdxs, null, TooltipDisplayMode.Multi, SortOrder.None); + expect(rows.length).toBe(2); + expect(rows[0].value).toBe('20'); + expect(rows[1].value).toBe('-26'); + }); + + it('displays the values sorted ASC', () => { + const rows = getContentItems(fields, xField, dataIdxs, null, TooltipDisplayMode.Multi, SortOrder.Ascending); + expect(rows.length).toBe(2); + expect(rows[0].value).toBe('-26'); + expect(rows[1].value).toBe('20'); + }); + + it('displays the values sorted DESC', () => { + const rows = getContentItems(fields, xField, dataIdxs, null, TooltipDisplayMode.Multi, SortOrder.Descending); + expect(rows.length).toBe(2); + expect(rows[0].value).toBe('20'); + expect(rows[1].value).toBe('-26'); + }); + + it('displays the correct value when NULL values', () => { + const rows = getContentItems(fields, xField, [2, 2, null], null, TooltipDisplayMode.Multi, SortOrder.Descending); + expect(rows.length).toBe(1); + expect(rows[0].value).toBe('70'); + }); + }); + + describe('it tests getContentItems with string values', () => { + const timeValues = [1707833954056, 1707838274056, 1707842594056]; + const seriesAValues = ['LOW', 'HIGH', 'NORMAL']; + const seriesBValues = ['NORMAL', 'LOW', 'LOW']; + + const frame = { + name: 'a', + length: timeValues.length, + fields: [ + { + name: 'time', + type: FieldType.time, + values: timeValues[0], + config: {}, + display: (value: string) => ({ + text: value, + color: undefined, + numeric: NaN, + }), + }, + { + name: 'A-series', + type: FieldType.string, + values: seriesAValues, + config: {}, + display: (value: string) => ({ + text: value, + color: undefined, + numeric: NaN, + }), + }, + { + name: 'B-series', + type: FieldType.string, + values: seriesBValues, + config: {}, + display: (value: string) => ({ + text: value, + color: undefined, + numeric: NaN, + }), + }, + ], + } as unknown as DataFrame; + + const fields = frame.fields; + const xField = frame.fields[0]; + const dataIdxs = [null, 0, 0]; + + it('displays one series in single mode', () => { + const rows = getContentItems(fields, xField, [null, null, 0], 2, TooltipDisplayMode.Single, SortOrder.None); + expect(rows.length).toBe(1); + expect(rows[0].value).toBe('NORMAL'); + }); + + it('displays the right content in multi mode', () => { + const rows = getContentItems(fields, xField, dataIdxs, 2, TooltipDisplayMode.Multi, SortOrder.None); + expect(rows.length).toBe(2); + expect(rows[0].value).toBe('LOW'); + expect(rows[1].value).toBe('NORMAL'); + }); + + it('displays the values sorted ASC', () => { + const rows = getContentItems(fields, xField, dataIdxs, 2, TooltipDisplayMode.Multi, SortOrder.Ascending); + expect(rows.length).toBe(2); + expect(rows[0].value).toBe('LOW'); + expect(rows[1].value).toBe('NORMAL'); + }); + + it('displays the values sorted DESC', () => { + const rows = getContentItems( + frame.fields, + frame.fields[0], + dataIdxs, + 2, + TooltipDisplayMode.Multi, + SortOrder.Descending + ); + expect(rows.length).toBe(2); + expect(rows[0].value).toBe('NORMAL'); + expect(rows[1].value).toBe('LOW'); + }); + }); }); diff --git a/packages/grafana-ui/src/components/VizTooltip/utils.ts b/packages/grafana-ui/src/components/VizTooltip/utils.ts index 6a229a3f8c33e..0d8058fc53118 100644 --- a/packages/grafana-ui/src/components/VizTooltip/utils.ts +++ b/packages/grafana-ui/src/components/VizTooltip/utils.ts @@ -1,5 +1,8 @@ +import { FALLBACK_COLOR, Field, FieldType, formattedValueToString } from '@grafana/data'; +import { SortOrder, TooltipDisplayMode } from '@grafana/schema'; + import { ColorIndicatorStyles } from './VizTooltipColorIndicator'; -import { ColorIndicator } from './types'; +import { ColorIndicator, ColorPlacement, VizTooltipItem } from './types'; export const calculateTooltipPosition = ( xPos = 0, @@ -66,3 +69,84 @@ export const getColorIndicatorClass = (colorIndicator: string, styles: ColorIndi return styles.value; } }; + +const numberCmp = (a: VizTooltipItem, b: VizTooltipItem) => a.numeric! - b.numeric!; +const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); +const stringCmp = (a: VizTooltipItem, b: VizTooltipItem) => collator.compare(`${a.value}`, `${b.value}`); + +export const getContentItems = ( + fields: Field[], + xField: Field, + dataIdxs: Array<number | null>, + seriesIdx: number | null | undefined, + mode: TooltipDisplayMode, + sortOrder: SortOrder, + fieldFilter = (field: Field) => true +): VizTooltipItem[] => { + let rows: VizTooltipItem[] = []; + + let allNumeric = true; + + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + + if ( + field === xField || + field.type === FieldType.time || + !fieldFilter(field) || + field.config.custom?.hideFrom?.tooltip || + field.config.custom?.hideFrom?.viz + ) { + continue; + } + + // in single mode, skip all but closest field + if (mode === TooltipDisplayMode.Single && seriesIdx !== i) { + continue; + } + + let dataIdx = dataIdxs[i]; + + // omit non-hovered + if (dataIdx == null) { + continue; + } + + if (!(field.type === FieldType.number || field.type === FieldType.boolean || field.type === FieldType.enum)) { + allNumeric = false; + } + + const v = fields[i].values[dataIdx]; + + if (v == null && field.config.noValue == null) { + continue; + } + + const display = field.display!(v); // super expensive :( + + // sort NaN and non-numeric to bottom (regardless of sort order) + const numeric = !Number.isNaN(display.numeric) + ? display.numeric + : sortOrder === SortOrder.Descending + ? Number.MIN_SAFE_INTEGER + : Number.MAX_SAFE_INTEGER; + + rows.push({ + label: field.state?.displayName ?? field.name, + value: formattedValueToString(display), + color: display.color ?? FALLBACK_COLOR, + colorIndicator: ColorIndicator.series, + colorPlacement: ColorPlacement.first, + isActive: mode === TooltipDisplayMode.Multi && seriesIdx === i, + numeric, + }); + } + + if (sortOrder !== SortOrder.None && rows.length > 1) { + const cmp = allNumeric ? numberCmp : stringCmp; + const mult = sortOrder === SortOrder.Descending ? -1 : 1; + rows.sort((a, b) => mult * cmp(a, b)); + } + + return rows; +}; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index f05bc49fd79a4..13d8a7b604b8d 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -213,6 +213,7 @@ export { Text } from './Text/Text'; export { Box } from './Layout/Box/Box'; export { Stack } from './Layout/Stack/Stack'; export { Grid } from './Layout/Grid/Grid'; +export { Space } from './Layout/Space'; export { Label } from './Forms/Label'; export { Field, type FieldProps } from './Forms/Field'; @@ -263,6 +264,8 @@ export { Avatar } from './UsersIndicator/Avatar'; // Export this until we've figured out a good approach to inline form styles. export { InlineFormLabel } from './FormLabel/FormLabel'; export { Divider } from './Divider/Divider'; +export { getDragStyles, type DragHandlePosition } from './DragHandle/DragHandle'; +export { useSplitter } from './Splitter/useSplitter'; /** @deprecated Please use non-legacy versions of these components */ const LegacyForms = { diff --git a/packages/grafana-ui/src/components/uPlot/Plot.scss b/packages/grafana-ui/src/components/uPlot/Plot.scss index 86614e7496bbe..895e3329895e6 100644 --- a/packages/grafana-ui/src/components/uPlot/Plot.scss +++ b/packages/grafana-ui/src/components/uPlot/Plot.scss @@ -9,12 +9,13 @@ background: rgba(120, 120, 130, 0.2); } -.u-cursor-x { +.u-hz .u-cursor-x, +.u-vt .u-cursor-y { border-right: 1px dashed rgba(120, 120, 130, 0.5); } -.u-cursor-y { - width: 100%; +.u-hz .u-cursor-y, +.u-vt .u-cursor-x { border-bottom: 1px dashed rgba(120, 120, 130, 0.5); } diff --git a/packages/grafana-ui/src/components/uPlot/config.ts b/packages/grafana-ui/src/components/uPlot/config.ts index 89478ff6b2710..ade88cc258bfb 100644 --- a/packages/grafana-ui/src/components/uPlot/config.ts +++ b/packages/grafana-ui/src/components/uPlot/config.ts @@ -4,7 +4,7 @@ import { BarAlignment, GraphDrawStyle, GraphGradientMode, - GraphTresholdsStyleMode, + GraphThresholdsStyleMode, LineInterpolation, VisibilityMode, StackingMode, @@ -21,7 +21,7 @@ export const graphFieldOptions: { axisPlacement: Array<SelectableValue<AxisPlacement>>; fillGradient: Array<SelectableValue<GraphGradientMode>>; stacking: Array<SelectableValue<StackingMode>>; - thresholdsDisplayModes: Array<SelectableValue<GraphTresholdsStyleMode>>; + thresholdsDisplayModes: Array<SelectableValue<GraphThresholdsStyleMode>>; } = { drawStyle: [ { label: 'Lines', value: GraphDrawStyle.Line }, @@ -73,11 +73,11 @@ export const graphFieldOptions: { ], thresholdsDisplayModes: [ - { label: 'Off', value: GraphTresholdsStyleMode.Off }, - { label: 'As lines', value: GraphTresholdsStyleMode.Line }, - { label: 'As lines (dashed)', value: GraphTresholdsStyleMode.Dashed }, - { label: 'As filled regions', value: GraphTresholdsStyleMode.Area }, - { label: 'As filled regions and lines', value: GraphTresholdsStyleMode.LineAndArea }, - { label: 'As filled regions and lines (dashed)', value: GraphTresholdsStyleMode.DashedAndArea }, + { label: 'Off', value: GraphThresholdsStyleMode.Off }, + { label: 'As lines', value: GraphThresholdsStyleMode.Line }, + { label: 'As lines (dashed)', value: GraphThresholdsStyleMode.Dashed }, + { label: 'As filled regions', value: GraphThresholdsStyleMode.Area }, + { label: 'As filled regions and lines', value: GraphThresholdsStyleMode.LineAndArea }, + { label: 'As filled regions and lines (dashed)', value: GraphThresholdsStyleMode.DashedAndArea }, ], }; diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts index f446d8129a01a..d7435b2514eba 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts @@ -213,7 +213,8 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> { } } -const timeUnitSize = { +/** @internal */ +export const timeUnitSize = { second: 1000, minute: 60 * 1000, hour: 60 * 60 * 1000, diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts index 827f1268cf9c0..2c381e896f461 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts @@ -8,7 +8,7 @@ import { VisibilityMode, ScaleOrientation, ScaleDirection, - GraphTresholdsStyleMode, + GraphThresholdsStyleMode, ScaleDistribution, } from '@grafana/schema'; @@ -379,8 +379,6 @@ describe('UPlotConfigBuilder', () => { max: 100, }); - expect(builder.getConfig().scales!['scale-y']!.auto).toEqual(false); - builder.addScale({ isTime: false, scaleKey: 'scale-y2', @@ -391,6 +389,7 @@ describe('UPlotConfigBuilder', () => { softMin: -50, }); + expect(builder.getConfig().scales!['scale-y']!.auto).toEqual(false); expect(builder.getConfig().scales!['scale-y2']!.auto).toEqual(true); }); @@ -812,7 +811,7 @@ describe('UPlotConfigBuilder', () => { steps: [], }, config: { - mode: GraphTresholdsStyleMode.Area, + mode: GraphThresholdsStyleMode.Area, }, theme: darkTheme, }); @@ -823,7 +822,7 @@ describe('UPlotConfigBuilder', () => { steps: [], }, config: { - mode: GraphTresholdsStyleMode.Area, + mode: GraphThresholdsStyleMode.Area, }, theme: darkTheme, }); diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index dd6d1da4770a8..baf292e42b4a6 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -1,17 +1,8 @@ import { merge } from 'lodash'; import uPlot, { Cursor, Band, Hooks, Select, AlignedData, Padding, Series } from 'uplot'; -import { - DataFrame, - DefaultTimeZone, - EventBus, - Field, - getTimeZoneInfo, - GrafanaTheme2, - TimeRange, - TimeZone, -} from '@grafana/data'; -import { AxisPlacement } from '@grafana/schema'; +import { DataFrame, DefaultTimeZone, Field, getTimeZoneInfo, GrafanaTheme2, TimeRange, TimeZone } from '@grafana/data'; +import { AxisPlacement, VizOrientation } from '@grafana/schema'; import { FacetedData, PlotConfig, PlotTooltipInterpolator } from '../types'; import { DEFAULT_PLOT_CONFIG, getStackingBands, pluginLog, StackingGroup } from '../utils'; @@ -58,6 +49,8 @@ export class UPlotConfigBuilder { private tooltipInterpolator: PlotTooltipInterpolator | undefined = undefined; private padding?: Padding = undefined; + private cachedConfig?: PlotConfig; + prepData: PrepData | undefined = undefined; constructor(timeZone: TimeZone = DefaultTimeZone) { @@ -190,6 +183,10 @@ export class UPlotConfigBuilder { } getConfig() { + if (this.cachedConfig) { + return this.cachedConfig; + } + const config: PlotConfig = { ...DEFAULT_PLOT_CONFIG, mode: this.mode, @@ -244,18 +241,18 @@ export class UPlotConfigBuilder { config.padding = this.padding; } - if (this.stackingGroups.length) { - this.stackingGroups.forEach((group) => { - getStackingBands(group).forEach((band) => { - this.addBand(band); - }); + this.stackingGroups.forEach((group) => { + getStackingBands(group).forEach((band) => { + this.addBand(band); }); - } + }); if (this.bands.length) { config.bands = this.bands; } + this.cachedConfig = config; + return config; } @@ -300,13 +297,14 @@ type UPlotConfigPrepOpts<T extends Record<string, unknown> = {}> = { theme: GrafanaTheme2; timeZones: TimeZone[]; getTimeRange: () => TimeRange; - eventBus: EventBus; allFrames: DataFrame[]; renderers?: Renderers; tweakScale?: (opts: ScaleProps, forField: Field) => ScaleProps; tweakAxis?: (opts: AxisProps, forField: Field) => AxisProps; // Identifies the shared key for uPlot cursor sync eventsScope?: string; + hoverProximity?: number; + orientation?: VizOrientation; } & T; /** @alpha */ diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts index 45313d9e3e5fc..1e2e6f749910c 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts @@ -51,10 +51,10 @@ export class UPlotScaleBuilder extends PlotConfigBuilder<ScaleProps, Scale> { distr === ScaleDistribution.Symlog ? 4 : distr === ScaleDistribution.Log - ? 3 - : distr === ScaleDistribution.Ordinal - ? 2 - : 1, + ? 3 + : distr === ScaleDistribution.Ordinal + ? 2 + : 1, log: distr === ScaleDistribution.Log || distr === ScaleDistribution.Symlog ? this.props.log ?? 2 : undefined, asinh: distr === ScaleDistribution.Symlog ? this.props.linearThreshold ?? 1 : undefined, } diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts index 74b4140e23da6..62e651f919b7a 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts @@ -172,6 +172,10 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> { return getScaleGradientFn(1, theme, colorMode, thresholds, hardMin, hardMax, softMin, softMax); } + if (gradientMode === GraphGradientMode.Hue) { + return getHueGradientFn(lineColor ?? FALLBACK_COLOR, 1, theme); + } + return lineColor ?? FALLBACK_COLOR; } diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotThresholds.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotThresholds.ts index 9c71a3e4a99d3..56e17f47c3f39 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotThresholds.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotThresholds.ts @@ -2,7 +2,7 @@ import tinycolor from 'tinycolor2'; import uPlot from 'uplot'; import { GrafanaTheme2, Threshold, ThresholdsConfig, ThresholdsMode } from '@grafana/data'; -import { GraphThresholdsStyleConfig, GraphTresholdsStyleMode } from '@grafana/schema'; +import { GraphThresholdsStyleConfig, GraphThresholdsStyleMode } from '@grafana/schema'; import { getGradientRange, scaleGradient } from './gradientFills'; @@ -19,8 +19,8 @@ export interface UPlotThresholdOptions { export function getThresholdsDrawHook(options: UPlotThresholdOptions) { const dashSegments = - options.config.mode === GraphTresholdsStyleMode.Dashed || - options.config.mode === GraphTresholdsStyleMode.DashedAndArea + options.config.mode === GraphThresholdsStyleMode.Dashed || + options.config.mode === GraphThresholdsStyleMode.DashedAndArea ? [10, 10] : null; @@ -123,15 +123,15 @@ export function getThresholdsDrawHook(options: UPlotThresholdOptions) { ctx.save(); switch (config.mode) { - case GraphTresholdsStyleMode.Line: - case GraphTresholdsStyleMode.Dashed: + case GraphThresholdsStyleMode.Line: + case GraphThresholdsStyleMode.Dashed: addLines(u, scaleKey, steps, theme); break; - case GraphTresholdsStyleMode.Area: + case GraphThresholdsStyleMode.Area: addAreas(u, scaleKey, steps, theme); break; - case GraphTresholdsStyleMode.LineAndArea: - case GraphTresholdsStyleMode.DashedAndArea: + case GraphThresholdsStyleMode.LineAndArea: + case GraphThresholdsStyleMode.DashedAndArea: addAreas(u, scaleKey, steps, theme); addLines(u, scaleKey, steps, theme); } diff --git a/packages/grafana-ui/src/components/uPlot/config/gradientFills.ts b/packages/grafana-ui/src/components/uPlot/config/gradientFills.ts index 2d580c2986fea..8c6ce29e37d92 100644 --- a/packages/grafana-ui/src/components/uPlot/config/gradientFills.ts +++ b/packages/grafana-ui/src/components/uPlot/config/gradientFills.ts @@ -68,8 +68,8 @@ export function getHueGradientFn( ctx ); - const color1 = tinycolor(color).spin(-15); - const color2 = tinycolor(color).spin(15); + const color1 = tinycolor(color).spin(-25).darken(5); + const color2 = tinycolor(color).saturate(20).spin(20).brighten(10); if (theme.isDark) { gradient.addColorStop(0, color2.lighten(10).setAlpha(opacity).toString()); diff --git a/packages/grafana-ui/src/components/uPlot/plugins/EventBusPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/EventBusPlugin.tsx new file mode 100644 index 0000000000000..f3528620dec95 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/EventBusPlugin.tsx @@ -0,0 +1,164 @@ +import { throttle } from 'lodash'; +import { useLayoutEffect, useRef } from 'react'; +import { Subscription } from 'rxjs'; +import { throttleTime } from 'rxjs/operators'; + +import { + DataFrame, + DataHoverClearEvent, + DataHoverEvent, + DataHoverPayload, + EventBus, + LegacyGraphHoverEvent, +} from '@grafana/data'; + +import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder'; + +interface EventBusPluginProps { + config: UPlotConfigBuilder; + eventBus: EventBus; + sync: () => boolean; + frame?: DataFrame; +} + +/** + * @alpha + */ +export const EventBusPlugin = ({ config, eventBus, sync, frame }: EventBusPluginProps) => { + const frameRef = useRef<DataFrame | undefined>(frame); + frameRef.current = frame; + + useLayoutEffect(() => { + let u: uPlot | null = null; + + const payload: DataHoverPayload = { + point: { + time: null, + }, + data: frameRef.current, + }; + + config.addHook('init', (_u) => { + u = _u; + }); + + let closestSeriesIdx: number | null = null; + + config.addHook('setSeries', (u, seriesIdx) => { + closestSeriesIdx = seriesIdx; + }); + + config.addHook('setLegend', () => { + let viaSync = u!.cursor.event == null; + + if (!viaSync && sync()) { + let dataIdx = u!.cursor.idxs!.find((v) => v != null); + + if (dataIdx == null) { + throttledClear(); + } else { + let rowIdx = dataIdx; + let colIdx = closestSeriesIdx; + + let xData = u!.data[0] ?? u!.data[1][0]; + + payload.point.time = xData[rowIdx]; + payload.rowIndex = rowIdx ?? undefined; + payload.columnIndex = colIdx ?? undefined; + payload.data = frameRef.current; + + // used by old graph panel to position tooltip + let top = u!.cursor.top!; + payload.point.panelRelY = top === 0 ? 0.001 : top > 0 ? top / u!.rect.height : 1; + + throttledHover(); + } + } + }); + + function handleCursorUpdate(evt: DataHoverEvent | LegacyGraphHoverEvent) { + const time = evt.payload?.point?.time; + + if (time) { + // Try finding left position on time axis + const left = u!.valToPos(time, 'x'); + + // let top; + + // if (left) { + // top = findMidPointYPosition(u!, u!.posToIdx(left)); + // } + + // if (!top || !left) { + // return; + // } + + u!.setCursor({ + left, + top: u!.rect.height / 2, + }); + } + } + + const subscription = new Subscription(); + + const hoverEvent = new DataHoverEvent(payload).setTags(['uplot']); + const clearEvent = new DataHoverClearEvent().setTags(['uplot']); + + let throttledHover = throttle(() => { + eventBus.publish(hoverEvent); + }, 100); + + let throttledClear = throttle(() => { + eventBus.publish(clearEvent); + }, 100); + + subscription.add( + eventBus.getStream(DataHoverEvent).subscribe({ + next: (evt) => { + // ignore uplot-emitted events, since we already use uPlot's sync + if (eventBus === evt.origin || evt.tags?.has('uplot')) { + return; + } + + handleCursorUpdate(evt); + }, + }) + ); + + // Legacy events (from flot graph) + subscription.add( + eventBus.getStream(LegacyGraphHoverEvent).subscribe({ + next: (evt) => handleCursorUpdate(evt), + }) + ); + + subscription.add( + eventBus + .getStream(DataHoverClearEvent) + .pipe(throttleTime(50)) // dont throttle here, throttle on emission + .subscribe({ + next: (evt) => { + // ignore uplot-emitted events, since we already use uPlot's sync + if (eventBus === evt.origin || evt.tags?.has('uplot')) { + return; + } + + // @ts-ignore + if (!u!.cursor._lock) { + u!.setCursor({ + left: -10, + top: -10, + }); + } + }, + }) + ); + + return () => { + subscription.unsubscribe(); + }; + }, [config]); + + return null; +}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx index 76cd2eca5e3c6..0a54874a5d071 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx @@ -1,4 +1,4 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import React, { useLayoutEffect, useRef, useReducer, CSSProperties } from 'react'; import { createPortal } from 'react-dom'; import uPlot from 'uplot'; @@ -6,11 +6,14 @@ import uPlot from 'uplot'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../../themes'; +import { getPortalContainer } from '../../Portal/Portal'; import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder'; import { CloseButton } from './CloseButton'; -export const DEFAULT_TOOLTIP_WIDTH = 280; +export const DEFAULT_TOOLTIP_WIDTH = undefined; +export const DEFAULT_TOOLTIP_HEIGHT = undefined; +export const TOOLTIP_OFFSET = 10; // todo: barchart? histogram? export const enum TooltipHoverMode { @@ -26,6 +29,8 @@ interface TooltipPlugin2Props { config: UPlotConfigBuilder; hoverMode: TooltipHoverMode; + syncTooltip?: () => boolean; + // x only queryZoom?: (range: { from: number; to: number }) => void; // y-only, via shiftKey @@ -36,8 +41,14 @@ interface TooltipPlugin2Props { dataIdxs: Array<number | null>, seriesIdx: number | null, isPinned: boolean, - dismiss: () => void + dismiss: () => void, + // selected time range (for annotation triggering) + timeRange: TimeRange2 | null, + viaSync: boolean ) => React.ReactNode; + + maxWidth?: number; + maxHeight?: number; } interface TooltipContainerState { @@ -55,6 +66,11 @@ interface TooltipContainerSize { height: number; } +export interface TimeRange2 { + from: number; + to: number; +} + function mergeState(prevState: TooltipContainerState, nextState: Partial<TooltipContainerState>) { return { ...prevState, @@ -66,14 +82,16 @@ function mergeState(prevState: TooltipContainerState, nextState: Partial<Tooltip }; } -const INITIAL_STATE: TooltipContainerState = { - style: { transform: '', pointerEvents: 'none' }, - isHovering: false, - isPinned: false, - contents: null, - plot: null, - dismiss: () => {}, -}; +function initState(): TooltipContainerState { + return { + style: { transform: '', pointerEvents: 'none' }, + isHovering: false, + isPinned: false, + contents: null, + plot: null, + dismiss: () => {}, + }; +} // min px width that triggers zoom const MIN_ZOOM_DIST = 5; @@ -83,14 +101,30 @@ const maybeZoomAction = (e?: MouseEvent | null) => e != null && !e.ctrlKey && !e /** * @alpha */ -export const TooltipPlugin2 = ({ config, hoverMode, render, clientZoom = false, queryZoom }: TooltipPlugin2Props) => { +export const TooltipPlugin2 = ({ + config, + hoverMode, + render, + clientZoom = false, + queryZoom, + maxWidth, + maxHeight, + syncTooltip = () => false, +}: TooltipPlugin2Props) => { const domRef = useRef<HTMLDivElement>(null); + const portalRoot = useRef<HTMLElement | null>(null); - const [{ plot, isHovering, isPinned, contents, style, dismiss }, setState] = useReducer(mergeState, INITIAL_STATE); + if (portalRoot.current == null) { + portalRoot.current = getPortalContainer(); + } + + const [{ plot, isHovering, isPinned, contents, style, dismiss }, setState] = useReducer(mergeState, null, initState); const sizeRef = useRef<TooltipContainerSize>(); - const styles = useStyles2(getStyles); + maxWidth = isPinned ? DEFAULT_TOOLTIP_WIDTH : maxWidth ?? DEFAULT_TOOLTIP_WIDTH; + maxHeight ??= DEFAULT_TOOLTIP_HEIGHT; + const styles = useStyles2(getStyles, maxWidth, maxHeight); const renderRef = useRef(render); renderRef.current = render; @@ -119,22 +153,27 @@ export const TooltipPlugin2 = ({ config, hoverMode, render, clientZoom = false, let _plot = plot; let _isHovering = isHovering; + let _someSeriesIdx = false; let _isPinned = isPinned; let _style = style; - let offsetX = 0; - let offsetY = 0; + let plotVisible = false; - let htmlEl = document.documentElement; - let winWidth = htmlEl.clientWidth - 16; - let winHeight = htmlEl.clientHeight - 16; + const updateHovering = () => { + if (viaSync) { + _isHovering = plotVisible && _someSeriesIdx && syncTooltip(); + } else { + _isHovering = closestSeriesIdx != null || (hoverMode === TooltipHoverMode.xAll && _someSeriesIdx); + } + }; - window.addEventListener('resize', (e) => { - winWidth = htmlEl.clientWidth - 5; - winHeight = htmlEl.clientHeight - 5; - }); + let offsetX = 0; + let offsetY = 0; + let selectedRange: TimeRange2 | null = null; + let seriesIdxs: Array<number | null> = plot?.cursor.idxs!.slice()!; let closestSeriesIdx: number | null = null; + let viaSync = false; let pendingRender = false; let pendingPinned = false; @@ -159,9 +198,7 @@ export const TooltipPlugin2 = ({ config, hoverMode, render, clientZoom = false, // in some ways this is similar to ClickOutsideWrapper.tsx const downEventOutside = (e: Event) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - let isOutside = (e.target as HTMLDivElement).closest(`.${styles.tooltipWrapper}`) !== domRef.current; - - if (isOutside) { + if (!domRef.current!.contains(e.target as Node)) { dismiss(); } }; @@ -172,8 +209,6 @@ export const TooltipPlugin2 = ({ config, hoverMode, render, clientZoom = false, if (pendingPinned) { _style = { pointerEvents: _isPinned ? 'all' : 'none' }; - domRef.current?.closest<HTMLDivElement>('.react-grid-item')?.classList.toggle('context-menu-open', _isPinned); - // @ts-ignore _plot!.cursor._lock = _isPinned; @@ -192,20 +227,24 @@ export const TooltipPlugin2 = ({ config, hoverMode, render, clientZoom = false, style: _style, isPinned: _isPinned, isHovering: _isHovering, - contents: _isHovering - ? renderRef.current(_plot!, _plot!.cursor.idxs!, closestSeriesIdx, _isPinned, dismiss) - : null, + contents: + _isHovering || selectedRange != null + ? renderRef.current(_plot!, seriesIdxs, closestSeriesIdx, _isPinned, dismiss, selectedRange, viaSync) + : null, dismiss, }; setState(state); + + selectedRange = null; }; const dismiss = () => { + let prevIsPinned = _isPinned; _isPinned = false; _isHovering = false; _plot!.setCursor({ left: -10, top: -10 }); - scheduleRender(true); + scheduleRender(prevIsPinned); }; config.addHook('init', (u) => { @@ -241,23 +280,47 @@ export const TooltipPlugin2 = ({ config, hoverMode, render, clientZoom = false, // this handles pinning u.over.addEventListener('click', (e) => { - // only pinnable tooltip is visible *and* is within proximity to series/point - if (_isHovering && closestSeriesIdx != null && !_isPinned && e.target === u.over) { - _isPinned = true; - scheduleRender(true); + if (e.target === u.over) { + if (e.ctrlKey || e.metaKey) { + let xVal; + + const isXAxisHorizontal = u.scales.x.ori === 0; + if (isXAxisHorizontal) { + xVal = u.posToVal(u.cursor.left!, 'x'); + } else { + xVal = u.posToVal(u.select.top + u.select.height, 'x'); + } + + selectedRange = { + from: xVal, + to: xVal, + }; + + scheduleRender(false); + } + // only pinnable tooltip is visible *and* is within proximity to series/point + else if (_isHovering && closestSeriesIdx != null && !_isPinned) { + _isPinned = true; + scheduleRender(true); + } } }); }); config.addHook('setSelect', (u) => { - if (clientZoom || queryZoom != null) { + const isXAxisHorizontal = u.scales.x.ori === 0; + if (!viaSync && (clientZoom || queryZoom != null)) { if (maybeZoomAction(u.cursor!.event)) { if (clientZoom && yDrag) { if (u.select.height >= MIN_ZOOM_DIST) { for (let key in u.scales!) { if (key !== 'x') { - const maxY = u.posToVal(u.select.top, key); - const minY = u.posToVal(u.select.top + u.select.height, key); + const maxY = isXAxisHorizontal + ? u.posToVal(u.select.top, key) + : u.posToVal(u.select.left + u.select.width, key); + const minY = isXAxisHorizontal + ? u.posToVal(u.select.top + u.select.height, key) + : u.posToVal(u.select.left, key); u.setScale(key, { min: minY, max: maxY }); } @@ -269,14 +332,25 @@ export const TooltipPlugin2 = ({ config, hoverMode, render, clientZoom = false, yDrag = false; } else if (queryZoom != null) { if (u.select.width >= MIN_ZOOM_DIST) { - const minX = u.posToVal(u.select.left, 'x'); - const maxX = u.posToVal(u.select.left + u.select.width, 'x'); + const minX = isXAxisHorizontal + ? u.posToVal(u.select.left, 'x') + : u.posToVal(u.select.top + u.select.height, 'x'); + const maxX = isXAxisHorizontal + ? u.posToVal(u.select.left + u.select.width, 'x') + : u.posToVal(u.select.top, 'x'); queryZoom({ from: minX, to: maxX }); yZoomed = false; } } + } else { + selectedRange = { + from: isXAxisHorizontal ? u.posToVal(u.select.left!, 'x') : u.posToVal(u.select.top + u.select.height, 'x'), + to: isXAxisHorizontal ? u.posToVal(u.select.left! + u.select.width, 'x') : u.posToVal(u.select.top, 'x'), + }; + + scheduleRender(true); } } @@ -322,98 +396,127 @@ export const TooltipPlugin2 = ({ config, hoverMode, render, clientZoom = false, yDrag = false; }); - // fires on data value hovers/unhovers (before setSeries) - config.addHook('setLegend', (u) => { - let hoveredSeriesIdx = _plot!.cursor.idxs!.findIndex((v, i) => i > 0 && v != null); - let _isHoveringNow = hoveredSeriesIdx !== -1; - - // in mode: 2 uPlot won't fire the proximity-based setSeries (below) - // so we set closestSeriesIdx here instead - // TODO: setSeries only fires for TimeSeries & Trend...not state timeline or statsus history - if (hoverMode === TooltipHoverMode.xyOne) { - closestSeriesIdx = hoveredSeriesIdx; - } - - if (_isHoveringNow) { - // create - if (!_isHovering) { - _isHovering = true; - } - } else { - // destroy...TODO: debounce this - if (_isHovering) { - _isHovering = false; - } - } - - scheduleRender(); - }); - // fires on series focus/proximity changes // e.g. to highlight the hovered/closest series // TODO: we only need this for multi/all mode? config.addHook('setSeries', (u, seriesIdx) => { - // don't jiggle focused series styling when there's only one series - // const isMultiSeries = u.series.length > 2; - - // if (hoverModeRef.current === TooltipHoverMode.xAll && closestSeriesIdx !== seriesIdx) { closestSeriesIdx = seriesIdx; + + viaSync = u.cursor.event == null; + updateHovering(); scheduleRender(); - // } }); + // fires on data value hovers/unhovers + config.addHook('setLegend', (u) => { + seriesIdxs = _plot?.cursor!.idxs!.slice()!; + _someSeriesIdx = seriesIdxs.some((v, i) => i > 0 && v != null); + + viaSync = u.cursor.event == null; + let prevIsHovering = _isHovering; + updateHovering(); + + if (_isHovering || _isHovering !== prevIsHovering) { + scheduleRender(); + } + }); + + const scrollbarWidth = 16; + let winWid = 0; + let winHgt = 0; + + const updateWinSize = () => { + _isHovering && !_isPinned && dismiss(); + + winWid = window.innerWidth - scrollbarWidth; + winHgt = window.innerHeight - scrollbarWidth; + }; + + const updatePlotVisible = () => { + plotVisible = + _plot!.rect.bottom <= winHgt && _plot!.rect.top >= 0 && _plot!.rect.left >= 0 && _plot!.rect.right <= winWid; + }; + + updateWinSize(); + config.addHook('ready', updatePlotVisible); + // fires on mousemoves config.addHook('setCursor', (u) => { + viaSync = u.cursor.event == null; + + if (!_isHovering) { + return; + } + let { left = -10, top = -10 } = u.cursor; if (left >= 0 || top >= 0) { - let { width, height } = sizeRef.current!; - let clientX = u.rect.left + left; let clientY = u.rect.top + top; - if (offsetY) { - if (clientY + height < winHeight || clientY - height < 0) { + let transform = ''; + + let { width, height } = sizeRef.current!; + + width += TOOLTIP_OFFSET; + height += TOOLTIP_OFFSET; + + if (offsetY !== 0) { + if (clientY + height < winHgt || clientY - height < 0) { offsetY = 0; } else if (offsetY !== -height) { offsetY = -height; } } else { - if (clientY + height > winHeight && clientY - height >= 0) { + if (clientY + height > winHgt && clientY - height >= 0) { offsetY = -height; } } - if (offsetX) { - if (clientX + width < winWidth || clientX - width < 0) { + if (offsetX !== 0) { + if (clientX + width < winWid || clientX - width < 0) { offsetX = 0; } else if (offsetX !== -width) { offsetX = -width; } } else { - if (clientX + width > winWidth && clientX - width >= 0) { + if (clientX + width > winWid && clientX - width >= 0) { offsetX = -width; } } - const shiftX = offsetX !== 0 ? 'translateX(-100%)' : ''; - const shiftY = offsetY !== 0 ? 'translateY(-100%)' : ''; + const shiftX = clientX + (offsetX === 0 ? TOOLTIP_OFFSET : -TOOLTIP_OFFSET); + const shiftY = clientY + (offsetY === 0 ? TOOLTIP_OFFSET : -TOOLTIP_OFFSET); + + const reflectX = offsetX === 0 ? '' : 'translateX(-100%)'; + const reflectY = offsetY === 0 ? '' : 'translateY(-100%)'; // TODO: to a transition only when switching sides // transition: transform 100ms; - const transform = `${shiftX} translateX(${left}px) ${shiftY} translateY(${top}px)`; + transform = `translateX(${shiftX}px) ${reflectX} translateY(${shiftY}px) ${reflectY}`; - if (_isHovering) { - if (domRef.current != null) { - domRef.current.style.transform = transform; - } else { - _style.transform = transform; - scheduleRender(); - } + if (domRef.current != null) { + domRef.current.style.transform = transform; + } else { + _style.transform = transform; + scheduleRender(); } } }); + + const onscroll = (e: Event) => { + updatePlotVisible(); + _isHovering && !_isPinned && e.target instanceof HTMLElement && e.target.contains(_plot!.root) && dismiss(); + }; + + window.addEventListener('resize', updateWinSize); + window.addEventListener('scroll', onscroll, true); + + return () => { + window.removeEventListener('resize', updateWinSize); + window.removeEventListener('scroll', onscroll, true); + }; }, [config]); useLayoutEffect(() => { @@ -421,33 +524,69 @@ export const TooltipPlugin2 = ({ config, hoverMode, render, clientZoom = false, if (domRef.current != null) { size.observer.observe(domRef.current); + + // since the above observer is attached after container is in DOM, we need to manually update sizeRef + // and re-trigger a cursor move to do initial positioning math + const { width, height } = domRef.current.getBoundingClientRect(); + size.width = width; + size.height = height; + + const event = plot!.cursor.event; + + // if not viaSync, re-dispatch real event + if (event != null) { + plot!.over.dispatchEvent(event); + } else { + plot!.setCursor( + { + left: plot!.cursor.left!, + top: plot!.cursor.top!, + }, + true + ); + } + } else { + size.width = 0; + size.height = 0; } - }, [domRef.current]); + }, [isHovering]); if (plot && isHovering) { return createPortal( - <div className={styles.tooltipWrapper} style={style} ref={domRef}> + <div + className={cx(styles.tooltipWrapper, isPinned && styles.pinned)} + style={style} + aria-live="polite" + aria-atomic="true" + ref={domRef} + > {isPinned && <CloseButton onClick={dismiss} />} {contents} </div>, - plot.over + portalRoot.current ); } return null; }; -const getStyles = (theme: GrafanaTheme2) => ({ +const getStyles = (theme: GrafanaTheme2, maxWidth?: number, maxHeight?: number) => ({ tooltipWrapper: css({ top: 0, left: 0, zIndex: theme.zIndex.tooltip, whiteSpace: 'pre', borderRadius: theme.shape.radius.default, - position: 'absolute', + position: 'fixed', background: theme.colors.background.primary, border: `1px solid ${theme.colors.border.weak}`, - boxShadow: `0 4px 8px ${theme.colors.background.primary}`, + boxShadow: theme.shadows.z2, userSelect: 'text', + maxWidth: maxWidth ?? 'none', + maxHeight: maxHeight ?? 'none', + overflowY: 'auto', + }), + pinned: css({ + boxShadow: theme.shadows.z3, }), }); diff --git a/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx index faed1ae95a9a0..1e412e41fcc03 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx @@ -51,14 +51,18 @@ export const ZoomPlugin = ({ onZoom, config, withZoomY = false }: ZoomPluginProp } config.addHook('setSelect', (u) => { + const isXAxisHorizontal = u.scales.x.ori === 0; if (maybeZoomAction(u.cursor!.event)) { if (withZoomY && yDrag) { if (u.select.height >= MIN_ZOOM_DIST) { for (let key in u.scales!) { if (key !== 'x') { - const maxY = u.posToVal(u.select.top, key); - const minY = u.posToVal(u.select.top + u.select.height, key); - + const maxY = isXAxisHorizontal + ? u.posToVal(u.select.top, key) + : u.posToVal(u.select.left + u.select.width, key); + const minY = isXAxisHorizontal + ? u.posToVal(u.select.top + u.select.height, key) + : u.posToVal(u.select.left, key); u.setScale(key, { min: minY, max: maxY }); } } @@ -69,8 +73,12 @@ export const ZoomPlugin = ({ onZoom, config, withZoomY = false }: ZoomPluginProp yDrag = false; } else { if (u.select.width >= MIN_ZOOM_DIST) { - const minX = u.posToVal(u.select.left, 'x'); - const maxX = u.posToVal(u.select.left + u.select.width, 'x'); + const minX = isXAxisHorizontal + ? u.posToVal(u.select.left, 'x') + : u.posToVal(u.select.top + u.select.height, 'x'); + const maxX = isXAxisHorizontal + ? u.posToVal(u.select.left + u.select.width, 'x') + : u.posToVal(u.select.top, 'x'); onZoom({ from: minX, to: maxX }); diff --git a/packages/grafana-ui/src/components/uPlot/plugins/index.ts b/packages/grafana-ui/src/components/uPlot/plugins/index.ts index b2936f2397d1a..4f07a8d80f7bb 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/index.ts +++ b/packages/grafana-ui/src/components/uPlot/plugins/index.ts @@ -1,4 +1,5 @@ export { ZoomPlugin } from './ZoomPlugin'; export { TooltipPlugin } from './TooltipPlugin'; export { TooltipPlugin2 } from './TooltipPlugin2'; +export { EventBusPlugin } from './EventBusPlugin'; export { KeyboardPlugin } from './KeyboardPlugin'; diff --git a/packages/grafana-ui/src/components/uPlot/utils.test.ts b/packages/grafana-ui/src/components/uPlot/utils.test.ts index fc6224ac5963d..3ecbc180bd951 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.test.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.test.ts @@ -1,7 +1,7 @@ import { FieldMatcherID, fieldMatchers, FieldType, MutableDataFrame } from '@grafana/data'; import { BarAlignment, GraphDrawStyle, GraphTransform, LineInterpolation, StackingMode } from '@grafana/schema'; -import { preparePlotFrame } from '../../../../../public/app/core/components/GraphNG/utils'; +import { preparePlotFrame } from '..'; import { getStackingGroups, preparePlotData2, timeFormatToTemplate } from './utils'; diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index 1a3253013ca3e..85f67b256b7b8 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -123,8 +123,8 @@ export function getStackingGroups(frame: DataFrame) { drawStyle === GraphDrawStyle.Bars ? custom.barAlignment : drawStyle === GraphDrawStyle.Line - ? custom.lineInterpolation - : null; + ? custom.lineInterpolation + : null; let stackKey = `${stackDir}|${stackingMode}|${stackingGroup}|${buildScaleKey( config, diff --git a/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap index 09f70e81c444d..2aff27a97fa13 100644 --- a/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap +++ b/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap @@ -75,13 +75,10 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "width": [Function], }, "sync": { - "filters": { - "pub": [Function], - }, "key": "__global_", "scales": [ "x", - "__fixed/na-na/na-na/auto/linear/na/number", + null, ], }, }, @@ -156,17 +153,17 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "fill": [Function], "paths": [Function], "points": { - "fill": "#ff0000", + "fill": [Function], "filter": [Function], "show": true, "size": undefined, - "stroke": "#ff0000", + "stroke": [Function], }, "pxAlign": undefined, "scale": "__fixed/na-na/na-na/auto/linear/na/number", "show": true, "spanGaps": false, - "stroke": "#ff0000", + "stroke": [Function], "value": [Function], "width": 2, }, @@ -202,17 +199,17 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "fill": [Function], "paths": [Function], "points": { - "fill": "#ff0000", + "fill": [Function], "filter": [Function], "show": true, "size": undefined, - "stroke": "#ff0000", + "stroke": [Function], }, "pxAlign": undefined, "scale": "__fixed/na-na/na-na/auto/linear/na/number", "show": true, "spanGaps": false, - "stroke": "#ff0000", + "stroke": [Function], "value": [Function], "width": 2, }, @@ -225,17 +222,17 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "fill": [Function], "paths": [Function], "points": { - "fill": "#ff0000", + "fill": [Function], "filter": [Function], "show": true, "size": undefined, - "stroke": "#ff0000", + "stroke": [Function], }, "pxAlign": undefined, "scale": "__fixed/na-na/na-na/auto/linear/na/number", "show": true, "spanGaps": false, - "stroke": "#ff0000", + "stroke": [Function], "value": [Function], "width": 2, }, diff --git a/packages/grafana-ui/src/graveyard/GraphNG/utils.test.ts b/packages/grafana-ui/src/graveyard/GraphNG/utils.test.ts index 9675cd7ca562c..23775b1df23dd 100644 --- a/packages/grafana-ui/src/graveyard/GraphNG/utils.test.ts +++ b/packages/grafana-ui/src/graveyard/GraphNG/utils.test.ts @@ -3,7 +3,7 @@ import { DashboardCursorSync, DataFrame, DefaultTimeZone, - EventBusSrv, + // EventBusSrv, FieldColorModeId, FieldConfig, FieldMatcherID, @@ -215,7 +215,6 @@ describe('GraphNG utils', () => { theme: createTheme(), timeZones: [DefaultTimeZone], getTimeRange: getDefaultTimeRange, - eventBus: new EventBusSrv(), sync: () => DashboardCursorSync.Tooltip, allFrames: [frame!], }).getConfig(); diff --git a/packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx b/packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx index 9a748236d779e..9ee08192383aa 100644 --- a/packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx +++ b/packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx @@ -19,7 +19,7 @@ export class UnthemedTimeSeries extends Component<TimeSeriesProps> { declare context: React.ContextType<typeof PanelContextRoot>; prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => { - const { eventBus, eventsScope, sync } = this.context; + const { eventsScope, sync } = this.context; const { theme, timeZone, renderers, tweakAxis, tweakScale } = this.props; return preparePlotConfigBuilder({ @@ -27,7 +27,6 @@ export class UnthemedTimeSeries extends Component<TimeSeriesProps> { theme, timeZones: Array.isArray(timeZone) ? timeZone : [timeZone], getTimeRange, - eventBus, sync, allFrames, renderers, diff --git a/packages/grafana-ui/src/graveyard/TimeSeries/utils.ts b/packages/grafana-ui/src/graveyard/TimeSeries/utils.ts index ad029097ba55e..41090a35a47f3 100644 --- a/packages/grafana-ui/src/graveyard/TimeSeries/utils.ts +++ b/packages/grafana-ui/src/graveyard/TimeSeries/utils.ts @@ -4,9 +4,6 @@ import uPlot from 'uplot'; import { DashboardCursorSync, DataFrame, - DataHoverClearEvent, - DataHoverEvent, - DataHoverPayload, FieldConfig, FieldType, formattedValueToString, @@ -21,7 +18,7 @@ import { AxisPlacement, GraphDrawStyle, GraphFieldConfig, - GraphTresholdsStyleMode, + GraphThresholdsStyleMode, VisibilityMode, ScaleDirection, ScaleOrientation, @@ -81,7 +78,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ theme, timeZones, getTimeRange, - eventBus, sync, allFrames, renderers, @@ -107,7 +103,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ } const xScaleKey = 'x'; - let xScaleUnit = '_x'; let yScaleKey = ''; const xFieldAxisPlacement = @@ -115,7 +110,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ const xFieldAxisShow = xField.config.custom?.axisPlacement !== AxisPlacement.Hidden; if (xField.type === FieldType.time) { - xScaleUnit = 'time'; builder.addScale({ scaleKey: xScaleKey, orientation: ScaleOrientation.Horizontal, @@ -173,11 +167,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ }); } } else { - // Not time! - if (xField.config.unit) { - xScaleUnit = xField.config.unit; - } - builder.addScale({ scaleKey: xScaleKey, orientation: ScaleOrientation.Horizontal, @@ -259,16 +248,16 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ return [dataMin, dataMax]; } : field.type === FieldType.enum - ? (u: uPlot, dataMin: number, dataMax: number) => { - // this is the exhaustive enum (stable) - let len = field.config.type!.enum!.text!.length; + ? (u: uPlot, dataMin: number, dataMax: number) => { + // this is the exhaustive enum (stable) + let len = field.config.type!.enum!.text!.length; - return [-1, len]; + return [-1, len]; - // these are only values that are present - // return [dataMin - 1, dataMax + 1] - } - : undefined, + // these are only values that are present + // return [dataMin - 1, dataMax + 1] + } + : undefined, decimals: field.config.decimals, }, field @@ -519,8 +508,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ // Render thresholds in graph if (customConfig.thresholdsStyle && config.thresholds) { - const thresholdDisplay = customConfig.thresholdsStyle.mode ?? GraphTresholdsStyleMode.Off; - if (thresholdDisplay !== GraphTresholdsStyleMode.Off) { + const thresholdDisplay = customConfig.thresholdsStyle.mode ?? GraphThresholdsStyleMode.Off; + if (thresholdDisplay !== GraphThresholdsStyleMode.Off) { builder.addThresholds({ config: customConfig.thresholdsStyle, thresholds: config.thresholds, @@ -609,41 +598,9 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ }; if (sync && sync() !== DashboardCursorSync.Off) { - const payload: DataHoverPayload = { - point: { - [xScaleKey]: null, - [yScaleKey]: null, - }, - data: frame, - }; - - const hoverEvent = new DataHoverEvent(payload); cursor.sync = { key: eventsScope, - filters: { - pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => { - if (sync && sync() === DashboardCursorSync.Off) { - return false; - } - - payload.rowIndex = dataIdx; - if (x < 0 && y < 0) { - payload.point[xScaleUnit] = null; - payload.point[yScaleKey] = null; - eventBus.publish(new DataHoverClearEvent()); - } else { - // convert the points - payload.point[xScaleUnit] = src.posToVal(x, xScaleKey); - payload.point[yScaleKey] = src.posToVal(y, yScaleKey); - payload.point.panelRelY = y > 0 ? y / h : 1; // used by old graph panel to position tooltip - eventBus.publish(hoverEvent); - hoverEvent.payload.down = undefined; - } - return true; - }, - }, - scales: [xScaleKey, yScaleKey], - // match: [() => true, (a, b) => a === b], + scales: [xScaleKey, null], }; } diff --git a/packages/grafana-ui/src/options/builder/tooltip.tsx b/packages/grafana-ui/src/options/builder/tooltip.tsx index 4d8db87396750..0ff12becc3ec2 100644 --- a/packages/grafana-ui/src/options/builder/tooltip.tsx +++ b/packages/grafana-ui/src/options/builder/tooltip.tsx @@ -3,7 +3,9 @@ import { OptionsWithTooltip, TooltipDisplayMode, SortOrder } from '@grafana/sche export function addTooltipOptions<T extends OptionsWithTooltip>( builder: PanelOptionsEditorBuilder<T>, - singleOnly = false + singleOnly = false, + setProximity = false, + defaultOptions?: Partial<OptionsWithTooltip> ) { const category = ['Tooltip']; const modeOptions = singleOnly @@ -28,7 +30,7 @@ export function addTooltipOptions<T extends OptionsWithTooltip>( path: 'tooltip.mode', name: 'Tooltip mode', category, - defaultValue: 'single', + defaultValue: defaultOptions?.tooltip?.mode ?? TooltipDisplayMode.Single, settings: { options: modeOptions, }, @@ -37,10 +39,43 @@ export function addTooltipOptions<T extends OptionsWithTooltip>( path: 'tooltip.sort', name: 'Values sort order', category, - defaultValue: SortOrder.None, + defaultValue: defaultOptions?.tooltip?.sort ?? SortOrder.None, showIf: (options: T) => options.tooltip?.mode === TooltipDisplayMode.Multi, settings: { options: sortOptions, }, }); + + if (setProximity) { + builder.addNumberInput({ + path: 'tooltip.hoverProximity', + name: 'Hover proximity', + description: 'How close the cursor must be to a point to trigger the tooltip, in pixels', + category, + settings: { + integer: true, + }, + }); + } + + builder + .addNumberInput({ + path: 'tooltip.maxWidth', + name: 'Max width', + category, + settings: { + integer: true, + }, + showIf: (options: T) => false, // options.tooltip?.mode !== TooltipDisplayMode.None, + }) + .addNumberInput({ + path: 'tooltip.maxHeight', + name: 'Max height', + category, + defaultValue: 600, + settings: { + integer: true, + }, + showIf: (options: T) => false, //options.tooltip?.mode !== TooltipDisplayMode.None, + }); } diff --git a/packages/grafana-ui/src/schema.ts b/packages/grafana-ui/src/schema.ts index b52babdb54282..294886e09c3e0 100644 --- a/packages/grafana-ui/src/schema.ts +++ b/packages/grafana-ui/src/schema.ts @@ -27,7 +27,7 @@ export { StackingMode, type StackingConfig, type StackableFieldConfig, - GraphTresholdsStyleMode, + GraphThresholdsStyleMode, type GraphThresholdsStyleConfig, type GraphFieldConfig, type LegendPlacement, diff --git a/packages/grafana-ui/src/slate-plugins/suggestions.test.tsx b/packages/grafana-ui/src/slate-plugins/suggestions.test.tsx new file mode 100644 index 0000000000000..039e9bcac08e5 --- /dev/null +++ b/packages/grafana-ui/src/slate-plugins/suggestions.test.tsx @@ -0,0 +1,29 @@ +import { getNumCharsToDelete } from './suggestions'; + +describe('suggestions', () => { + describe('getNumCharsToDelete', () => { + const splunkCleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%:\\]/g, '').trim(); + it.each([ + // | represents the caret position + ['$query0 ', '', '', false, 0, undefined, { forward: 0, backward: 0 }], // "|" --> "$query0 |" + ['$query0 ', '$que', '$que', false, 0, undefined, { forward: 0, backward: 4 }], // "$que|" --> "$query0 |" + ['$query0 ', '$q', '$que', false, 0, undefined, { forward: 2, backward: 2 }], // "$q|ue" --> "$query0 |" + ['$query0 ', '$que', '($que)', false, 0, splunkCleanText, { forward: 0, backward: 4 }], // "($que|)" --> "($query0 |)" + ['$query0 ', '$que', 'esarvotionUsagePercent=$que', false, 0, undefined, { forward: 0, backward: 4 }], // "esarvotionUsagePercent=$que|" --> "esarvotionUsagePercent=$query0 |" + ])( + 'should calculate the correct number of characters to delete forwards and backwards', + (suggestionText, typeaheadPrefix, typeaheadText, preserveSuffix, deleteBackwards, cleanText, expected) => { + expect( + getNumCharsToDelete( + suggestionText, + typeaheadPrefix, + typeaheadText, + preserveSuffix, + deleteBackwards, + cleanText + ) + ).toEqual(expected); + } + ); + }); +}); diff --git a/packages/grafana-ui/src/slate-plugins/suggestions.tsx b/packages/grafana-ui/src/slate-plugins/suggestions.tsx index a6366e1a06789..917895c437f24 100644 --- a/packages/grafana-ui/src/slate-plugins/suggestions.tsx +++ b/packages/grafana-ui/src/slate-plugins/suggestions.tsx @@ -2,6 +2,8 @@ import { debounce, sortBy } from 'lodash'; import React from 'react'; import { Editor, Plugin as SlatePlugin } from 'slate-react'; +import { BootData } from '@grafana/data'; + import { Typeahead } from '../components/Typeahead/Typeahead'; import { CompletionItem, SuggestionsState, TypeaheadInput, TypeaheadOutput } from '../types'; import { makeFragment, SearchFunctionType } from '../utils'; @@ -11,6 +13,12 @@ import TOKEN_MARK from './slate-prism/TOKEN_MARK'; export const TYPEAHEAD_DEBOUNCE = 250; +declare global { + interface Window { + grafanaBootData?: BootData; + } +} + // Commands added to the editor by this plugin. interface SuggestionsPluginCommands { selectSuggestion: (suggestion: CompletionItem) => Editor; @@ -157,13 +165,14 @@ export function SuggestionsPlugin({ }); } - // Remove the current, incomplete text and replace it with the selected suggestion - const backward = suggestion.deleteBackwards || typeaheadPrefix.length; - const text = cleanText ? cleanText(typeaheadText) : typeaheadText; - const suffixLength = text.length - typeaheadPrefix.length; - const offset = typeaheadText.indexOf(typeaheadPrefix); - const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText); - const forward = midWord && !preserveSuffix ? suffixLength + offset : 0; + const { forward, backward } = getNumCharsToDelete( + suggestionText, + typeaheadPrefix, + typeaheadText, + preserveSuffix, + suggestion.deleteBackwards, + cleanText + ); // If new-lines, apply suggestion as block if (suggestionText.match(/\n/)) { @@ -337,3 +346,27 @@ const handleTypeahead = async ( // Bogus edit to force re-render editor.blur().focus(); }; + +export function getNumCharsToDelete( + suggestionText: string, + typeaheadPrefix: string, + typeaheadText: string, + preserveSuffix: boolean, + deleteBackwards?: number, + cleanText?: (text: string) => string +) { + // remove the current, incomplete text and replace it with the selected suggestion + const backward = deleteBackwards || typeaheadPrefix.length; + const text = cleanText ? cleanText(typeaheadText) : typeaheadText; + const offset = typeaheadText.indexOf(typeaheadPrefix); + + const suffixLength = + offset > -1 ? text.length - offset - typeaheadPrefix.length : text.length - typeaheadPrefix.length; + const midWord = Boolean((typeaheadPrefix && suffixLength > 0) || suggestionText === typeaheadText); + const forward = midWord && !preserveSuffix ? suffixLength + offset : 0; + + return { + forward, + backward, + }; +} diff --git a/packages/grafana-ui/src/themes/GlobalStyles/elements.ts b/packages/grafana-ui/src/themes/GlobalStyles/elements.ts index bb8278efe4eb6..a37f5561b251b 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/elements.ts +++ b/packages/grafana-ui/src/themes/GlobalStyles/elements.ts @@ -1,4 +1,4 @@ -import { css, CSSObject } from '@emotion/react'; +import { css } from '@emotion/react'; import { GrafanaTheme2, ThemeTypographyVariant } from '@grafana/data'; @@ -144,7 +144,7 @@ export function getElementStyles(theme: GrafanaTheme2) { }); } -export function getVariantStyles(variant: ThemeTypographyVariant): CSSObject { +export function getVariantStyles(variant: ThemeTypographyVariant) { return { margin: 0, fontSize: variant.fontSize, diff --git a/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts b/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts index 5651553f12560..75a8ad0d4af3d 100644 --- a/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts +++ b/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts @@ -161,7 +161,6 @@ $form-icon-danger: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www // ------------------------- // Used for a bird's eye view of components dependent on the z-axis // Try to avoid customizing these :) -$zindex-active-panel: ${theme.zIndex.activePanel}; $zindex-dropdown: ${theme.zIndex.dropdown}; $zindex-navbar-fixed: ${theme.zIndex.navbarFixed}; $zindex-sidemenu: ${theme.zIndex.sidemenu}; diff --git a/packages/grafana-ui/src/themes/mixins.ts b/packages/grafana-ui/src/themes/mixins.ts index e633ae2a98320..537ccaea44c51 100644 --- a/packages/grafana-ui/src/themes/mixins.ts +++ b/packages/grafana-ui/src/themes/mixins.ts @@ -1,4 +1,3 @@ -import { CSSObject } from '@emotion/css'; import tinycolor from 'tinycolor2'; import { GrafanaTheme, GrafanaTheme2 } from '@grafana/data'; @@ -55,14 +54,14 @@ export const focusCss = (theme: GrafanaTheme | GrafanaTheme2) => { transition-timing-function: cubic-bezier(0.19, 1, 0.22, 1);`; }; -export function getMouseFocusStyles(theme: GrafanaTheme | GrafanaTheme2): CSSObject { +export function getMouseFocusStyles(theme: GrafanaTheme | GrafanaTheme2) { return { outline: 'none', boxShadow: `none`, }; } -export function getFocusStyles(theme: GrafanaTheme2): CSSObject { +export function getFocusStyles(theme: GrafanaTheme2) { return { outline: '2px dotted transparent', outlineOffset: '2px', @@ -74,7 +73,7 @@ export function getFocusStyles(theme: GrafanaTheme2): CSSObject { } // max-width is set up based on .grafana-tooltip class that's used in dashboard -export const getTooltipContainerStyles = (theme: GrafanaTheme2): CSSObject => ({ +export const getTooltipContainerStyles = (theme: GrafanaTheme2) => ({ overflow: 'hidden', background: theme.colors.background.secondary, boxShadow: theme.shadows.z2, diff --git a/packages/grafana-ui/src/types/forms.ts b/packages/grafana-ui/src/types/forms.ts index 86e5869c86143..f7e654f27c1d2 100644 --- a/packages/grafana-ui/src/types/forms.ts +++ b/packages/grafana-ui/src/types/forms.ts @@ -1,12 +1,18 @@ import { UseFormReturn, FieldValues, FieldErrors, FieldArrayMethodProps } from 'react-hook-form'; export type { SubmitHandler as FormsOnSubmit, FieldErrors as FormFieldErrors } from 'react-hook-form'; +/** + * @deprecated use the types from react-hook-form instead + */ export type FormAPI<T extends FieldValues> = Omit<UseFormReturn<T>, 'handleSubmit'> & { errors: FieldErrors<T>; }; type FieldArrayValue = Partial<FieldValues> | Array<Partial<FieldValues>>; +/** + * @deprecated use the types from react-hook-form instead + */ export interface FieldArrayApi { fields: Array<Record<string, any>>; append: (value: FieldArrayValue, options?: FieldArrayMethodProps) => void; diff --git a/packages/grafana-ui/src/utils/tooltipUtils.ts b/packages/grafana-ui/src/utils/tooltipUtils.ts index e7d555e69cfb7..a1eb2dd222a82 100644 --- a/packages/grafana-ui/src/utils/tooltipUtils.ts +++ b/packages/grafana-ui/src/utils/tooltipUtils.ts @@ -1,7 +1,23 @@ import { css } from '@emotion/css'; +import { Placement } from '@floating-ui/react'; import { colorManipulator, GrafanaTheme2 } from '@grafana/data'; +import { TooltipPlacement } from '../components/Tooltip'; + +export function getPlacement(placement?: TooltipPlacement): Placement { + switch (placement) { + case 'auto': + return 'bottom'; + case 'auto-start': + return 'bottom-start'; + case 'auto-end': + return 'bottom-end'; + default: + return placement ?? 'bottom'; + } +} + export function buildTooltipTheme( theme: GrafanaTheme2, tooltipBg: string, @@ -11,29 +27,7 @@ export function buildTooltipTheme( ) { return { arrow: css({ - height: '1rem', - width: '1rem', - position: 'absolute', - pointerEvents: 'none', - - '&::before': { - borderStyle: 'solid', - content: '""', - display: 'block', - height: 0, - margin: 'auto', - width: 0, - }, - - '&::after': { - borderStyle: 'solid', - content: '""', - display: 'block', - height: 0, - margin: 'auto', - position: 'absolute', - width: 0, - }, + fill: tooltipBg, }), container: css({ backgroundColor: tooltipBg, @@ -52,81 +46,12 @@ export function buildTooltipTheme( pointerEvents: 'none', }, - "&[data-popper-placement*='bottom'] > div[data-popper-arrow='true']": { - left: 0, - marginTop: '-7px', - top: 0, - - '&::before': { - borderColor: `transparent transparent ${toggletipBorder} transparent`, - borderWidth: '0 8px 7px 8px', - position: 'absolute', - top: '-1px', - }, - - '&::after': { - borderColor: `transparent transparent ${tooltipBg} transparent`, - borderWidth: '0 8px 7px 8px', - }, - }, - - "&[data-popper-placement*='top'] > div[data-popper-arrow='true']": { - bottom: 0, - left: 0, - marginBottom: '-14px', - - '&::before': { - borderColor: `${toggletipBorder} transparent transparent transparent`, - borderWidth: '7px 8px 0 7px', - position: 'absolute', - top: '1px', - }, - - '&::after': { - borderColor: `${tooltipBg} transparent transparent transparent`, - borderWidth: '7px 8px 0 7px', - }, - }, - - "&[data-popper-placement*='right'] > div[data-popper-arrow='true']": { - left: 0, - marginLeft: '-10px', - - '&::before': { - borderColor: `transparent ${toggletipBorder} transparent transparent`, - borderWidth: '7px 6px 7px 0', - }, - - '&::after': { - borderColor: `transparent ${tooltipBg} transparent transparent`, - borderWidth: '6px 7px 7px 0', - left: '2px', - top: '1px', - }, - }, - - "&[data-popper-placement*='left'] > div[data-popper-arrow='true']": { - marginRight: '-11px', - right: 0, - - '&::before': { - borderColor: `transparent transparent transparent ${toggletipBorder}`, - borderWidth: '7px 0 6px 7px', - }, - - '&::after': { - borderColor: `transparent transparent transparent ${tooltipBg}`, - borderWidth: '6px 0 5px 5px', - left: '1px', - top: '1px', - }, - }, - code: { border: 'none', display: 'inline', background: colorManipulator.darken(tooltipBg, 0.1), color: tooltipText, + whiteSpace: 'normal', }, pre: { diff --git a/packaging/deb/control/postinst b/packaging/deb/control/postinst index 2245b56c13151..e304646df9abe 100755 --- a/packaging/deb/control/postinst +++ b/packaging/deb/control/postinst @@ -12,11 +12,12 @@ IS_UPGRADE=false if [ "$1" = configure ]; then [ -z "$GRAFANA_USER" ] && GRAFANA_USER="grafana" [ -z "$GRAFANA_GROUP" ] && GRAFANA_GROUP="grafana" + [ -z "$GRAFANA_HOME" ] && GRAFANA_HOME="/usr/share/grafana" if ! getent group "$GRAFANA_GROUP" > /dev/null 2>&1 ; then addgroup --system "$GRAFANA_GROUP" --quiet fi if ! id "$GRAFANA_USER" > /dev/null 2>&1 ; then - adduser --system --home /usr/share/grafana --no-create-home \ + adduser --system --home "$GRAFANA_HOME" --no-create-home \ --ingroup "$GRAFANA_GROUP" --disabled-password --shell /bin/false \ "$GRAFANA_USER" fi @@ -28,34 +29,29 @@ if [ "$1" = configure ]; then # copy user config files if [ ! -f $CONF_FILE ]; then - cp /usr/share/grafana/conf/sample.ini $CONF_FILE - cp /usr/share/grafana/conf/ldap.toml /etc/grafana/ldap.toml + cp "${GRAFANA_HOME}/conf/sample.ini" $CONF_FILE + cp "${GRAFANA_HOME}/conf/ldap.toml" /etc/grafana/ldap.toml fi if [ ! -d $PROVISIONING_CFG_DIR ]; then mkdir -p $PROVISIONING_CFG_DIR/dashboards $PROVISIONING_CFG_DIR/datasources - cp /usr/share/grafana/conf/provisioning/dashboards/sample.yaml $PROVISIONING_CFG_DIR/dashboards/sample.yaml - cp /usr/share/grafana/conf/provisioning/datasources/sample.yaml $PROVISIONING_CFG_DIR/datasources/sample.yaml - fi - - if [ ! -d $PROVISIONING_CFG_DIR/notifiers ]; then - mkdir -p $PROVISIONING_CFG_DIR/notifiers - cp /usr/share/grafana/conf/provisioning/notifiers/sample.yaml $PROVISIONING_CFG_DIR/notifiers/sample.yaml + cp "${GRAFANA_HOME}/conf/provisioning/dashboards/sample.yaml" $PROVISIONING_CFG_DIR/dashboards/sample.yaml + cp "${GRAFANA_HOME}/conf/provisioning/datasources/sample.yaml" $PROVISIONING_CFG_DIR/datasources/sample.yaml fi if [ ! -d $PROVISIONING_CFG_DIR/plugins ]; then mkdir -p $PROVISIONING_CFG_DIR/plugins - cp /usr/share/grafana/conf/provisioning/plugins/sample.yaml $PROVISIONING_CFG_DIR/plugins/sample.yaml + cp "${GRAFANA_HOME}/conf/provisioning/plugins/sample.yaml" $PROVISIONING_CFG_DIR/plugins/sample.yaml fi if [ ! -d $PROVISIONING_CFG_DIR/access-control ]; then mkdir -p $PROVISIONING_CFG_DIR/access-control - cp /usr/share/grafana/conf/provisioning/access-control/sample.yaml $PROVISIONING_CFG_DIR/access-control/sample.yaml + cp "${GRAFANA_HOME}/conf/provisioning/access-control/sample.yaml" $PROVISIONING_CFG_DIR/access-control/sample.yaml fi if [ ! -d $PROVISIONING_CFG_DIR/alerting ]; then mkdir -p $PROVISIONING_CFG_DIR/alerting - cp /usr/share/grafana/conf/provisioning/alerting/sample.yaml $PROVISIONING_CFG_DIR/alerting/sample.yaml + cp "${GRAFANA_HOME}/conf/provisioning/alerting/sample.yaml" $PROVISIONING_CFG_DIR/alerting/sample.yaml fi # configuration files should not be modifiable by grafana user, as this can be a security issue diff --git a/packaging/docker/build.sh b/packaging/docker/build.sh index 5a16049d1331e..3c6ec7274cb49 100755 --- a/packaging/docker/build.sh +++ b/packaging/docker/build.sh @@ -59,7 +59,7 @@ docker_build () { esac if [ $UBUNTU_BASE = "0" ]; then libc="-musl" - base_image="${base_arch}alpine:3.18.3" + base_image="${base_arch}alpine:3.18.5" else libc="" base_image="${base_arch}ubuntu:22.04" diff --git a/packaging/rpm/control/postinst b/packaging/rpm/control/postinst index 4ec126c335540..129dc84540edf 100755 --- a/packaging/rpm/control/postinst +++ b/packaging/rpm/control/postinst @@ -56,11 +56,6 @@ if [ $1 -eq 1 ] ; then cp /usr/share/grafana/conf/provisioning/datasources/sample.yaml $PROVISIONING_CFG_DIR/datasources/sample.yaml fi - if [ ! -d $PROVISIONING_CFG_DIR/notifiers ]; then - mkdir -p $PROVISIONING_CFG_DIR/notifiers - cp /usr/share/grafana/conf/provisioning/notifiers/sample.yaml $PROVISIONING_CFG_DIR/notifiers/sample.yaml - fi - if [ ! -d $PROVISIONING_CFG_DIR/plugins ]; then mkdir -p $PROVISIONING_CFG_DIR/plugins cp /usr/share/grafana/conf/provisioning/plugins/sample.yaml $PROVISIONING_CFG_DIR/plugins/sample.yaml diff --git a/pkg/api/accesscontrol.go b/pkg/api/accesscontrol.go index 3f53a9e14ae95..c2b4be1abee65 100644 --- a/pkg/api/accesscontrol.go +++ b/pkg/api/accesscontrol.go @@ -35,14 +35,14 @@ var ( // that HTTPServer needs func (hs *HTTPServer) declareFixedRoles() error { // Declare plugins roles - if err := pluginaccesscontrol.DeclareRBACRoles(hs.accesscontrolService, hs.Cfg); err != nil { + if err := pluginaccesscontrol.DeclareRBACRoles(hs.accesscontrolService, hs.Cfg, hs.Features); err != nil { return err } provisioningWriterRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:provisioning:writer", - DisplayName: "Provisioning writer", + DisplayName: "Writer", Description: "Reload provisioning.", Group: "Provisioning", Permissions: []ac.Permission{ @@ -58,7 +58,7 @@ func (hs *HTTPServer) declareFixedRoles() error { datasourcesExplorerRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:datasources:explorer", - DisplayName: "Data source explorer", + DisplayName: "Explorer", Description: "Enable the Explore feature. Data source permissions still apply; you can only query data sources for which you have query permissions.", Group: "Data sources", Permissions: []ac.Permission{ @@ -77,7 +77,7 @@ func (hs *HTTPServer) declareFixedRoles() error { datasourcesReaderRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:datasources:reader", - DisplayName: "Data source reader", + DisplayName: "Reader", Description: "Read and query all data sources.", Group: "Data sources", Permissions: []ac.Permission{ @@ -97,7 +97,7 @@ func (hs *HTTPServer) declareFixedRoles() error { builtInDatasourceReader := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:datasources.builtin:reader", - DisplayName: "Built in data source reader", + DisplayName: "Built in reader", Description: "Read and query Grafana's built in test data sources.", Group: "Data sources", Permissions: []ac.Permission{ @@ -123,7 +123,7 @@ func (hs *HTTPServer) declareFixedRoles() error { datasourcesCreatorRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:datasources:creator", - DisplayName: "Data source creator", + DisplayName: "Creator", Description: "Create data sources.", Group: "Data sources", Permissions: []ac.Permission{ @@ -138,7 +138,7 @@ func (hs *HTTPServer) declareFixedRoles() error { datasourcesWriterRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:datasources:writer", - DisplayName: "Data source writer", + DisplayName: "Writer", Description: "Create, update, delete, read, or query data sources.", Group: "Data sources", Permissions: ac.ConcatPermissions(datasourcesReaderRole.Role.Permissions, []ac.Permission{ @@ -177,7 +177,7 @@ func (hs *HTTPServer) declareFixedRoles() error { apikeyReaderRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:apikeys:reader", - DisplayName: "APIKeys reader", + DisplayName: "Reader", Description: "Gives access to read api keys.", Group: "API Keys", Permissions: []ac.Permission{ @@ -193,7 +193,7 @@ func (hs *HTTPServer) declareFixedRoles() error { apikeyWriterRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:apikeys:writer", - DisplayName: "APIKeys writer", + DisplayName: "Writer", Description: "Gives access to add and delete api keys.", Group: "API Keys", Permissions: ac.ConcatPermissions(apikeyReaderRole.Role.Permissions, []ac.Permission{ @@ -212,7 +212,7 @@ func (hs *HTTPServer) declareFixedRoles() error { orgReaderRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:organization:reader", - DisplayName: "Organization reader", + DisplayName: "Reader", Description: "Read an organization, such as its ID, name, address, or quotas.", Group: "Organizations", Permissions: []ac.Permission{ @@ -226,7 +226,7 @@ func (hs *HTTPServer) declareFixedRoles() error { orgWriterRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:organization:writer", - DisplayName: "Organization writer", + DisplayName: "Writer", Description: "Read an organization, its quotas, or its preferences. Update organization properties, or its preferences.", Group: "Organizations", Permissions: ac.ConcatPermissions(orgReaderRole.Role.Permissions, []ac.Permission{ @@ -241,7 +241,7 @@ func (hs *HTTPServer) declareFixedRoles() error { orgMaintainerRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:organization:maintainer", - DisplayName: "Organization maintainer", + DisplayName: "Maintainer", Description: "Create, read, write, or delete an organization. Read or write an organization's quotas. Needs to be assigned globally.", Group: "Organizations", Permissions: ac.ConcatPermissions(orgReaderRole.Role.Permissions, []ac.Permission{ @@ -261,7 +261,7 @@ func (hs *HTTPServer) declareFixedRoles() error { teamsCreatorRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:teams:creator", - DisplayName: "Team creator", + DisplayName: "Creator", Description: "Create teams and read organisation users (required to manage the created teams).", Group: "Teams", Permissions: []ac.Permission{ @@ -275,7 +275,7 @@ func (hs *HTTPServer) declareFixedRoles() error { teamsReaderRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:teams:read", - DisplayName: "Team reader", + DisplayName: "Reader", Description: "List all teams.", Group: "Teams", Permissions: []ac.Permission{ @@ -288,7 +288,7 @@ func (hs *HTTPServer) declareFixedRoles() error { teamsWriterRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:teams:writer", - DisplayName: "Team writer", + DisplayName: "Writer", Description: "Create, read, write, or delete a team as well as controlling team memberships.", Group: "Teams", Permissions: []ac.Permission{ @@ -306,7 +306,7 @@ func (hs *HTTPServer) declareFixedRoles() error { annotationsReaderRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:annotations:reader", - DisplayName: "Annotation reader", + DisplayName: "Reader", Description: "Read annotations and tags", Group: "Annotations", Permissions: []ac.Permission{ @@ -336,7 +336,7 @@ func (hs *HTTPServer) declareFixedRoles() error { annotationsWriterRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:annotations:writer", - DisplayName: "Annotation writer", + DisplayName: "Writer", Description: "Update all annotations.", Group: "Annotations", Permissions: []ac.Permission{ @@ -389,7 +389,7 @@ func (hs *HTTPServer) declareFixedRoles() error { dashboardsCreatorRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:dashboards:creator", - DisplayName: "Dashboard creator", + DisplayName: "Creator", Description: "Create dashboard in general folder.", Group: "Dashboards", Permissions: []ac.Permission{ @@ -403,7 +403,7 @@ func (hs *HTTPServer) declareFixedRoles() error { dashboardsReaderRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:dashboards:reader", - DisplayName: "Dashboard reader", + DisplayName: "Reader", Description: "Read all dashboards.", Group: "Dashboards", Permissions: []ac.Permission{ @@ -416,7 +416,7 @@ func (hs *HTTPServer) declareFixedRoles() error { dashboardsWriterRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:dashboards:writer", - DisplayName: "Dashboard writer", + DisplayName: "Writer", Group: "Dashboards", Description: "Create, read, write or delete all dashboards and their permissions.", Permissions: ac.ConcatPermissions(dashboardsReaderRole.Role.Permissions, []ac.Permission{ @@ -433,7 +433,7 @@ func (hs *HTTPServer) declareFixedRoles() error { foldersCreatorRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:folders:creator", - DisplayName: "Folder creator", + DisplayName: "Creator", Description: "Create folders.", Group: "Folders", Permissions: []ac.Permission{ @@ -446,7 +446,7 @@ func (hs *HTTPServer) declareFixedRoles() error { foldersReaderRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:folders:reader", - DisplayName: "Folder reader", + DisplayName: "Reader", Description: "Read all folders and dashboards.", Group: "Folders", Permissions: []ac.Permission{ @@ -457,10 +457,25 @@ func (hs *HTTPServer) declareFixedRoles() error { Grants: []string{"Admin"}, } + // Needed to be able to list permissions on the general folder for viewers, doesn't actually grant access to any resources + generalFolderReaderRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ + Name: "fixed:folders.general:reader", + DisplayName: "General folder reader", + Description: "Access the general (root) folder.", + Group: "Folders", + Hidden: true, + Permissions: []ac.Permission{ + {Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.GeneralFolderUID)}, + }, + }, + Grants: []string{string(org.RoleViewer)}, + } + foldersWriterRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:folders:writer", - DisplayName: "Folder writer", + DisplayName: "Writer", Description: "Create, read, write or delete all folders and dashboards and their permissions.", Group: "Folders", Permissions: ac.ConcatPermissions( @@ -482,7 +497,7 @@ func (hs *HTTPServer) declareFixedRoles() error { libraryPanelsCreatorRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:library.panels:creator", - DisplayName: "Library panel creator", + DisplayName: "Creator", Description: "Create library panel in general folder.", Group: "Library panels", Permissions: []ac.Permission{ @@ -496,7 +511,7 @@ func (hs *HTTPServer) declareFixedRoles() error { libraryPanelsReaderRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:library.panels:reader", - DisplayName: "Library panel reader", + DisplayName: "Reader", Description: "Read all library panels.", Group: "Library panels", Permissions: []ac.Permission{ @@ -509,7 +524,7 @@ func (hs *HTTPServer) declareFixedRoles() error { libraryPanelsGeneralReaderRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:library.panels:general.reader", - DisplayName: "Library panel general reader", + DisplayName: "General reader", Description: "Read all library panels in general folder.", Group: "Library panels", Permissions: []ac.Permission{ @@ -522,7 +537,7 @@ func (hs *HTTPServer) declareFixedRoles() error { libraryPanelsWriterRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:library.panels:writer", - DisplayName: "Library panel writer", + DisplayName: "Writer", Group: "Library panels", Description: "Create, read, write or delete all library panels and their permissions.", Permissions: ac.ConcatPermissions(libraryPanelsReaderRole.Role.Permissions, []ac.Permission{ @@ -537,7 +552,7 @@ func (hs *HTTPServer) declareFixedRoles() error { libraryPanelsGeneralWriterRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:library.panels:general.writer", - DisplayName: "Library panel general writer", + DisplayName: "General writer", Group: "Library panels", Description: "Create, read, write or delete all library panels and their permissions in the general folder.", Permissions: ac.ConcatPermissions(libraryPanelsGeneralReaderRole.Role.Permissions, []ac.Permission{ @@ -565,7 +580,7 @@ func (hs *HTTPServer) declareFixedRoles() error { featuremgmtReaderRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:featuremgmt:reader", - DisplayName: "Feature Management reader", + DisplayName: "Reader", Description: "Read feature toggles", Group: "Feature Management", Permissions: []ac.Permission{ @@ -578,7 +593,7 @@ func (hs *HTTPServer) declareFixedRoles() error { featuremgmtWriterRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:featuremgmt:writer", - DisplayName: "Feature Management writer", + DisplayName: "Writer", Description: "Write feature toggles", Group: "Feature Management", Permissions: []ac.Permission{ @@ -593,7 +608,7 @@ func (hs *HTTPServer) declareFixedRoles() error { orgMaintainerRole, teamsCreatorRole, teamsWriterRole, teamsReaderRole, datasourcesExplorerRole, annotationsReaderRole, dashboardAnnotationsWriterRole, annotationsWriterRole, dashboardsCreatorRole, dashboardsReaderRole, dashboardsWriterRole, - foldersCreatorRole, foldersReaderRole, foldersWriterRole, apikeyReaderRole, apikeyWriterRole, + foldersCreatorRole, foldersReaderRole, generalFolderReaderRole, foldersWriterRole, apikeyReaderRole, apikeyWriterRole, publicDashboardsWriterRole, featuremgmtReaderRole, featuremgmtWriterRole, libraryPanelsCreatorRole, libraryPanelsReaderRole, libraryPanelsWriterRole, libraryPanelsGeneralReaderRole, libraryPanelsGeneralWriterRole} @@ -601,12 +616,12 @@ func (hs *HTTPServer) declareFixedRoles() error { allAnnotationsReaderRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:annotations.all:reader", - DisplayName: "Annotation reader", + DisplayName: "Reader", Description: "Read all annotations and tags", Group: "Annotations", Permissions: []ac.Permission{ {Action: ac.ActionAnnotationsRead, Scope: ac.ScopeAnnotationsTypeOrganization}, - {Action: ac.ActionAnnotationsRead, Scope: dashboards.ScopeDashboardsAll}, + {Action: ac.ActionAnnotationsRead, Scope: dashboards.ScopeFoldersAll}, }, }, Grants: []string{string(org.RoleAdmin)}, @@ -615,16 +630,16 @@ func (hs *HTTPServer) declareFixedRoles() error { allAnnotationsWriterRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: "fixed:annotations.all:writer", - DisplayName: "Annotation writer", + DisplayName: "Writer", Description: "Update all annotations.", Group: "Annotations", Permissions: []ac.Permission{ {Action: ac.ActionAnnotationsCreate, Scope: ac.ScopeAnnotationsTypeOrganization}, - {Action: ac.ActionAnnotationsCreate, Scope: dashboards.ScopeDashboardsAll}, + {Action: ac.ActionAnnotationsCreate, Scope: dashboards.ScopeFoldersAll}, {Action: ac.ActionAnnotationsDelete, Scope: ac.ScopeAnnotationsTypeOrganization}, - {Action: ac.ActionAnnotationsDelete, Scope: dashboards.ScopeDashboardsAll}, + {Action: ac.ActionAnnotationsDelete, Scope: dashboards.ScopeFoldersAll}, {Action: ac.ActionAnnotationsWrite, Scope: ac.ScopeAnnotationsTypeOrganization}, - {Action: ac.ActionAnnotationsWrite, Scope: dashboards.ScopeDashboardsAll}, + {Action: ac.ActionAnnotationsWrite, Scope: dashboards.ScopeFoldersAll}, }, }, Grants: []string{string(org.RoleAdmin)}, @@ -639,7 +654,7 @@ func (hs *HTTPServer) declareFixedRoles() error { // Metadata helpers // getAccessControlMetadata returns the accesscontrol metadata associated with a given resource func (hs *HTTPServer) getAccessControlMetadata(c *contextmodel.ReqContext, - orgID int64, prefix string, resourceID string) ac.Metadata { + prefix string, resourceID string) ac.Metadata { ids := map[string]bool{resourceID: true} return hs.getMultiAccessControlMetadata(c, prefix, ids)[resourceID] } diff --git a/pkg/api/admin.go b/pkg/api/admin.go index d298445eb8b27..da2d61c430658 100644 --- a/pkg/api/admin.go +++ b/pkg/api/admin.go @@ -62,12 +62,12 @@ func (hs *HTTPServer) AdminGetVerboseSettings(c *contextmodel.ReqContext) respon func (hs *HTTPServer) AdminGetStats(c *contextmodel.ReqContext) response.Response { adminStats, err := hs.statsService.GetAdminStats(c.Req.Context(), &stats.GetAdminStatsQuery{}) if err != nil { - return response.Error(500, "Failed to get admin stats from database", err) + return response.Error(http.StatusInternalServerError, "Failed to get admin stats from database", err) } anonymousDeviceExpiration := 30 * 24 * time.Hour devicesCount, err := hs.anonService.CountDevices(c.Req.Context(), time.Now().Add(-anonymousDeviceExpiration), time.Now().Add(time.Minute)) if err != nil { - return response.Error(500, "Failed to get anon stats from database", err) + return response.Error(http.StatusInternalServerError, "Failed to get anon stats from database", err) } adminStats.AnonymousStats.ActiveDevices = devicesCount diff --git a/pkg/api/admin_provisioning.go b/pkg/api/admin_provisioning.go index 704a2e51b997a..e46d5b486b721 100644 --- a/pkg/api/admin_provisioning.go +++ b/pkg/api/admin_provisioning.go @@ -3,6 +3,7 @@ package api import ( "context" "errors" + "net/http" "github.com/grafana/grafana/pkg/api/response" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" @@ -26,7 +27,7 @@ import ( func (hs *HTTPServer) AdminProvisioningReloadDashboards(c *contextmodel.ReqContext) response.Response { err := hs.ProvisioningService.ProvisionDashboards(c.Req.Context()) if err != nil && !errors.Is(err, context.Canceled) { - return response.Error(500, "", err) + return response.Error(http.StatusInternalServerError, "", err) } return response.Success("Dashboards config reloaded") } @@ -49,7 +50,7 @@ func (hs *HTTPServer) AdminProvisioningReloadDashboards(c *contextmodel.ReqConte func (hs *HTTPServer) AdminProvisioningReloadDatasources(c *contextmodel.ReqContext) response.Response { err := hs.ProvisioningService.ProvisionDatasources(c.Req.Context()) if err != nil { - return response.Error(500, "", err) + return response.Error(http.StatusInternalServerError, "", err) } return response.Success("Datasources config reloaded") } @@ -72,38 +73,15 @@ func (hs *HTTPServer) AdminProvisioningReloadDatasources(c *contextmodel.ReqCont func (hs *HTTPServer) AdminProvisioningReloadPlugins(c *contextmodel.ReqContext) response.Response { err := hs.ProvisioningService.ProvisionPlugins(c.Req.Context()) if err != nil { - return response.Error(500, "Failed to reload plugins config", err) + return response.Error(http.StatusInternalServerError, "Failed to reload plugins config", err) } return response.Success("Plugins config reloaded") } -// swagger:route POST /admin/provisioning/notifications/reload admin_provisioning adminProvisioningReloadNotifications -// -// Reload legacy alert notifier provisioning configurations. -// -// Reloads the provisioning config files for legacy alert notifiers again. It won’t return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning. -// If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `provisioning:reload` and scope `provisioners:notifications`. -// -// Security: -// - basic: -// -// Responses: -// 200: okResponse -// 401: unauthorisedError -// 403: forbiddenError -// 500: internalServerError -func (hs *HTTPServer) AdminProvisioningReloadNotifications(c *contextmodel.ReqContext) response.Response { - err := hs.ProvisioningService.ProvisionNotifications(c.Req.Context()) - if err != nil { - return response.Error(500, "", err) - } - return response.Success("Notifications config reloaded") -} - func (hs *HTTPServer) AdminProvisioningReloadAlerting(c *contextmodel.ReqContext) response.Response { err := hs.ProvisioningService.ProvisionAlerting(c.Req.Context()) if err != nil { - return response.Error(500, "", err) + return response.Error(http.StatusInternalServerError, "", err) } return response.Success("Alerting config reloaded") } diff --git a/pkg/api/admin_provisioning_test.go b/pkg/api/admin_provisioning_test.go index dcfe9726f2615..96dc43e7cfc69 100644 --- a/pkg/api/admin_provisioning_test.go +++ b/pkg/api/admin_provisioning_test.go @@ -71,26 +71,6 @@ func TestAPI_AdminProvisioningReload_AccessControl(t *testing.T) { expectedCode: http.StatusForbidden, url: "/api/admin/provisioning/dashboards/reload", }, - { - desc: "should work for notifications with specific scope", - expectedCode: http.StatusOK, - expectedBody: `{"message":"Notifications config reloaded"}`, - permissions: []accesscontrol.Permission{ - { - Action: ActionProvisioningReload, - Scope: ScopeProvisionersNotifications, - }, - }, - url: "/api/admin/provisioning/notifications/reload", - checkCall: func(mock provisioning.ProvisioningServiceMock) { - assert.Len(t, mock.Calls.ProvisionNotifications, 1) - }, - }, - { - desc: "should fail for notifications with no permission", - expectedCode: http.StatusForbidden, - url: "/api/admin/provisioning/notifications/reload", - }, { desc: "should work for datasources with specific scope", expectedCode: http.StatusOK, diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index b20fa1409531d..53b733bcf746e 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -115,8 +115,8 @@ func (hs *HTTPServer) AdminUpdateUserPassword(c *contextmodel.ReqContext) respon return response.Error(http.StatusBadRequest, "id is invalid", err) } - if len(form.Password) < 4 { - return response.Error(http.StatusBadRequest, "New password too short", nil) + if err := form.Password.Validate(hs.Cfg); err != nil { + return response.Err(err) } userQuery := user.GetUserByIDQuery{ID: userID} @@ -134,14 +134,14 @@ func (hs *HTTPServer) AdminUpdateUserPassword(c *contextmodel.ReqContext) respon } } - passwordHashed, err := util.EncodePassword(form.Password, usr.Salt) + passwordHashed, err := util.EncodePassword(string(form.Password), usr.Salt) if err != nil { return response.Error(http.StatusInternalServerError, "Could not encode password", err) } cmd := user.ChangeUserPasswordCommand{ UserID: userID, - NewPassword: passwordHashed, + NewPassword: user.Password(passwordHashed), } if err := hs.userService.ChangePassword(c.Req.Context(), &cmd); err != nil { @@ -196,10 +196,10 @@ func (hs *HTTPServer) AdminUpdateUserPermissions(c *contextmodel.ReqContext) res err = hs.userService.UpdatePermissions(c.Req.Context(), userID, form.IsGrafanaAdmin) if err != nil { if errors.Is(err, user.ErrLastGrafanaAdmin) { - return response.Error(400, user.ErrLastGrafanaAdmin.Error(), nil) + return response.Error(http.StatusBadRequest, user.ErrLastGrafanaAdmin.Error(), nil) } - return response.Error(500, "Failed to update user permissions", err) + return response.Error(http.StatusInternalServerError, "Failed to update user permissions", err) } return response.Success("User permissions updated") @@ -230,9 +230,9 @@ func (hs *HTTPServer) AdminDeleteUser(c *contextmodel.ReqContext) response.Respo if err := hs.userService.Delete(c.Req.Context(), &cmd); err != nil { if errors.Is(err, user.ErrUserNotFound) { - return response.Error(404, user.ErrUserNotFound.Error(), nil) + return response.Error(http.StatusNotFound, user.ErrUserNotFound.Error(), nil) } - return response.Error(500, "Failed to delete user", err) + return response.Error(http.StatusInternalServerError, "Failed to delete user", err) } g, ctx := errgroup.WithContext(c.Req.Context()) @@ -285,7 +285,7 @@ func (hs *HTTPServer) AdminDeleteUser(c *contextmodel.ReqContext) response.Respo return nil }) if err := g.Wait(); err != nil { - return response.Error(500, "Failed to delete user", err) + return response.Error(http.StatusInternalServerError, "Failed to delete user", err) } return response.Success("User deleted") @@ -315,20 +315,20 @@ func (hs *HTTPServer) AdminDisableUser(c *contextmodel.ReqContext) response.Resp // External users shouldn't be disabled from API authInfoQuery := &login.GetAuthInfoQuery{UserId: userID} if _, err := hs.authInfoService.GetAuthInfo(c.Req.Context(), authInfoQuery); !errors.Is(err, user.ErrUserNotFound) { - return response.Error(500, "Could not disable external user", nil) + return response.Error(http.StatusInternalServerError, "Could not disable external user", nil) } disableCmd := user.DisableUserCommand{UserID: userID, IsDisabled: true} if err := hs.userService.Disable(c.Req.Context(), &disableCmd); err != nil { if errors.Is(err, user.ErrUserNotFound) { - return response.Error(404, user.ErrUserNotFound.Error(), nil) + return response.Error(http.StatusNotFound, user.ErrUserNotFound.Error(), nil) } - return response.Error(500, "Failed to disable user", err) + return response.Error(http.StatusInternalServerError, "Failed to disable user", err) } err = hs.AuthTokenService.RevokeAllUserTokens(c.Req.Context(), userID) if err != nil { - return response.Error(500, "Failed to disable user", err) + return response.Error(http.StatusInternalServerError, "Failed to disable user", err) } return response.Success("User disabled") @@ -358,15 +358,15 @@ func (hs *HTTPServer) AdminEnableUser(c *contextmodel.ReqContext) response.Respo // External users shouldn't be disabled from API authInfoQuery := &login.GetAuthInfoQuery{UserId: userID} if _, err := hs.authInfoService.GetAuthInfo(c.Req.Context(), authInfoQuery); !errors.Is(err, user.ErrUserNotFound) { - return response.Error(500, "Could not enable external user", nil) + return response.Error(http.StatusInternalServerError, "Could not enable external user", nil) } disableCmd := user.DisableUserCommand{UserID: userID, IsDisabled: false} if err := hs.userService.Disable(c.Req.Context(), &disableCmd); err != nil { if errors.Is(err, user.ErrUserNotFound) { - return response.Error(404, user.ErrUserNotFound.Error(), nil) + return response.Error(http.StatusNotFound, user.ErrUserNotFound.Error(), nil) } - return response.Error(500, "Failed to enable user", err) + return response.Error(http.StatusInternalServerError, "Failed to enable user", err) } return response.Success("User enabled") diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go index c449854c322fe..d40417f72f302 100644 --- a/pkg/api/admin_users_test.go +++ b/pkg/api/admin_users_test.go @@ -320,9 +320,9 @@ func Test_AdminUpdateUserPermissions(t *testing.T) { case login.GenericOAuthModule: socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{AllowAssignGrafanaAdmin: tc.allowAssignGrafanaAdmin, Enabled: tc.authEnabled, SkipOrgRoleSync: tc.skipOrgRoleSync} case login.JWTModule: - cfg.JWTAuthEnabled = tc.authEnabled - cfg.JWTAuthSkipOrgRoleSync = tc.skipOrgRoleSync - cfg.JWTAuthAllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin + cfg.JWTAuth.Enabled = tc.authEnabled + cfg.JWTAuth.SkipOrgRoleSync = tc.skipOrgRoleSync + cfg.JWTAuth.AllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin } hs := &HTTPServer{ diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index 005c97eba57d4..1c7b9c6dcd356 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -1,1048 +1,15 @@ package api import ( - "context" - "errors" - "fmt" "net/http" - "strconv" - "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/services/alerting" - alertmodels "github.com/grafana/grafana/pkg/services/alerting/models" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" - "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/services/search" - "github.com/grafana/grafana/pkg/services/search/model" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" - "github.com/grafana/grafana/pkg/web" ) -func (hs *HTTPServer) ValidateOrgAlert(c *contextmodel.ReqContext) { - id, err := strconv.ParseInt(web.Params(c.Req)[":alertId"], 10, 64) - if err != nil { - c.JsonApiErr(http.StatusBadRequest, "alertId is invalid", nil) - return - } - query := alertmodels.GetAlertByIdQuery{ID: id} - - res, err := hs.AlertEngine.AlertStore.GetAlertById(c.Req.Context(), &query) - if err != nil { - c.JsonApiErr(404, "Alert not found", nil) - return - } - - if c.SignedInUser.GetOrgID() != res.OrgID { - c.JsonApiErr(403, "You are not allowed to edit/view alert", nil) - return - } -} - -// swagger:route GET /alerts/states-for-dashboard legacy_alerts getDashboardStates -// -// Get alert states for a dashboard. -// -// Responses: -// Responses: -// 200: getDashboardStatesResponse -// 400: badRequestError -// 500: internalServerError -func (hs *HTTPServer) GetAlertStatesForDashboard(c *contextmodel.ReqContext) response.Response { - dashboardID := c.QueryInt64("dashboardId") - - if dashboardID == 0 { - return response.Error(400, "Missing query parameter dashboardId", nil) - } - - query := alertmodels.GetAlertStatesForDashboardQuery{ - OrgID: c.SignedInUser.GetOrgID(), - DashboardID: c.QueryInt64("dashboardId"), - } - - res, err := hs.AlertEngine.AlertStore.GetAlertStatesForDashboard(c.Req.Context(), &query) - if err != nil { - return response.Error(500, "Failed to fetch alert states", err) - } - - return response.JSON(http.StatusOK, res) -} - -// swagger:route GET /alerts legacy_alerts getAlerts -// -// Get legacy alerts. -// -// Responses: -// 200: getAlertsResponse -// 401: unauthorisedError -// 500: internalServerError -func (hs *HTTPServer) GetAlerts(c *contextmodel.ReqContext) response.Response { - dashboardQuery := c.Query("dashboardQuery") - dashboardTags := c.QueryStrings("dashboardTag") - stringDashboardIDs := c.QueryStrings("dashboardId") - stringFolderIDs := c.QueryStrings("folderId") - - dashboardIDs := make([]int64, 0) - for _, id := range stringDashboardIDs { - dashboardID, err := strconv.ParseInt(id, 10, 64) - if err == nil { - dashboardIDs = append(dashboardIDs, dashboardID) - } - } - - if dashboardQuery != "" || len(dashboardTags) > 0 || len(stringFolderIDs) > 0 { - folderIDs := make([]int64, 0) - for _, id := range stringFolderIDs { - folderID, err := strconv.ParseInt(id, 10, 64) - if err == nil { - folderIDs = append(folderIDs, folderID) - } - } - - searchQuery := search.Query{ - Title: dashboardQuery, - Tags: dashboardTags, - SignedInUser: c.SignedInUser, - Limit: 1000, - OrgId: c.SignedInUser.GetOrgID(), - DashboardIds: dashboardIDs, - Type: string(model.DashHitDB), - FolderIds: folderIDs, // nolint:staticcheck - Permission: dashboardaccess.PERMISSION_VIEW, - } - - hits, err := hs.SearchService.SearchHandler(c.Req.Context(), &searchQuery) - if err != nil { - return response.Error(500, "List alerts failed", err) - } - - for _, d := range hits { - if d.Type == model.DashHitDB && d.ID > 0 { - dashboardIDs = append(dashboardIDs, d.ID) - } - } - - // if we didn't find any dashboards, return empty result - if len(dashboardIDs) == 0 { - return response.JSON(http.StatusOK, []*alertmodels.AlertListItemDTO{}) - } - } - - query := alertmodels.GetAlertsQuery{ - OrgID: c.SignedInUser.GetOrgID(), - DashboardIDs: dashboardIDs, - PanelID: c.QueryInt64("panelId"), - Limit: c.QueryInt64("limit"), - User: c.SignedInUser, - Query: c.Query("query"), - } - - states := c.QueryStrings("state") - if len(states) > 0 { - query.State = states - } - - res, err := hs.AlertEngine.AlertStore.HandleAlertsQuery(c.Req.Context(), &query) - if err != nil { - return response.Error(500, "List alerts failed", err) - } - - for _, alert := range res { - alert.URL = dashboards.GetDashboardURL(alert.DashboardUID, alert.DashboardSlug) - } - - return response.JSON(http.StatusOK, res) -} - -// swagger:route POST /alerts/test legacy_alerts testAlert -// -// Test alert. -// -// Responses: -// 200: testAlertResponse -// 400: badRequestError -// 422: unprocessableEntityError -// 403: forbiddenError -// 500: internalServerError -func (hs *HTTPServer) AlertTest(c *contextmodel.ReqContext) response.Response { - dto := dtos.AlertTestCommand{} - if err := web.Bind(c.Req, &dto); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) - } - if _, idErr := dto.Dashboard.Get("id").Int64(); idErr != nil { - return response.Error(400, "The dashboard needs to be saved at least once before you can test an alert rule", nil) - } - - res, err := hs.AlertEngine.AlertTest(c.SignedInUser.GetOrgID(), dto.Dashboard, dto.PanelId, c.SignedInUser) - if err != nil { - var validationErr alerting.ValidationError - if errors.As(err, &validationErr) { - return response.Error(422, validationErr.Error(), nil) - } - if errors.Is(err, datasources.ErrDataSourceAccessDenied) { - return response.Error(403, "Access denied to datasource", err) - } - return response.Error(500, "Failed to test rule", err) - } - - dtoRes := &dtos.AlertTestResult{ - Firing: res.Firing, - ConditionEvals: res.ConditionEvals, - State: res.Rule.State, - } - - if res.Error != nil { - dtoRes.Error = res.Error.Error() - } - - for _, log := range res.Logs { - dtoRes.Logs = append(dtoRes.Logs, &dtos.AlertTestResultLog{Message: log.Message, Data: log.Data}) - } - for _, match := range res.EvalMatches { - dtoRes.EvalMatches = append(dtoRes.EvalMatches, &dtos.EvalMatch{Metric: match.Metric, Value: match.Value}) - } - - dtoRes.TimeMs = fmt.Sprintf("%1.3fms", res.GetDurationMs()) - - return response.JSON(http.StatusOK, dtoRes) -} - -// swagger:route GET /alerts/{alert_id} legacy_alerts getAlertByID -// -// Get alert by ID. -// -// “evalMatches” data in the response is cached in the db when and only when the state of the alert changes (e.g. transitioning from “ok” to “alerting” state). -// If data from one server triggers the alert first and, before that server is seen leaving alerting state, a second server also enters a state that would trigger the alert, the second server will not be visible in “evalMatches” data. -// -// Responses: -// 200: getAlertResponse -// 401: unauthorisedError -// 500: internalServerError -func (hs *HTTPServer) GetAlert(c *contextmodel.ReqContext) response.Response { - id, err := strconv.ParseInt(web.Params(c.Req)[":alertId"], 10, 64) - if err != nil { - return response.Error(http.StatusBadRequest, "alertId is invalid", err) - } - query := alertmodels.GetAlertByIdQuery{ID: id} - - res, err := hs.AlertEngine.AlertStore.GetAlertById(c.Req.Context(), &query) - if err != nil { - return response.Error(500, "List alerts failed", err) - } - - return response.JSON(http.StatusOK, &res) -} - -func (hs *HTTPServer) GetAlertNotifiers(ngalertEnabled bool) func(*contextmodel.ReqContext) response.Response { +func (hs *HTTPServer) GetAlertNotifiers() func(*contextmodel.ReqContext) response.Response { return func(_ *contextmodel.ReqContext) response.Response { - if ngalertEnabled { - return response.JSON(http.StatusOK, channels_config.GetAvailableNotifiers()) - } - // TODO(codesome): This wont be required in 8.0 since ngalert - // will be enabled by default with no disabling. This is to be removed later. - return response.JSON(http.StatusOK, alerting.GetNotifiers()) + return response.JSON(http.StatusOK, channels_config.GetAvailableNotifiers()) } } - -// swagger:route GET /alert-notifications/lookup legacy_alerts_notification_channels getAlertNotificationLookup -// -// Get all notification channels (lookup). -// -// Returns all notification channels, but with less detailed information. Accessible by any authenticated user and is mainly used by providing alert notification channels in Grafana UI when configuring alert rule. -// -// Responses: -// 200: getAlertNotificationLookupResponse -// 401: unauthorisedError -// 403: forbiddenError -// 500: internalServerError -func (hs *HTTPServer) GetAlertNotificationLookup(c *contextmodel.ReqContext) response.Response { - alertNotifications, err := hs.getAlertNotificationsInternal(c) - if err != nil { - return response.Error(500, "Failed to get alert notifications", err) - } - - result := make([]*dtos.AlertNotificationLookup, 0) - - for _, notification := range alertNotifications { - result = append(result, dtos.NewAlertNotificationLookup(notification)) - } - - return response.JSON(http.StatusOK, result) -} - -// swagger:route GET /alert-notifications legacy_alerts_notification_channels getAlertNotificationChannels -// -// Get all notification channels. -// -// Returns all notification channels that the authenticated user has permission to view. -// -// Responses: -// 200: getAlertNotificationChannelsResponse -// 401: unauthorisedError -// 403: forbiddenError -// 500: internalServerError -func (hs *HTTPServer) GetAlertNotifications(c *contextmodel.ReqContext) response.Response { - alertNotifications, err := hs.getAlertNotificationsInternal(c) - if err != nil { - return response.Error(500, "Failed to get alert notifications", err) - } - - result := make([]*dtos.AlertNotification, 0) - - for _, notification := range alertNotifications { - result = append(result, dtos.NewAlertNotification(notification)) - } - - return response.JSON(http.StatusOK, result) -} - -func (hs *HTTPServer) getAlertNotificationsInternal(c *contextmodel.ReqContext) ([]*alertmodels.AlertNotification, error) { - query := &alertmodels.GetAllAlertNotificationsQuery{OrgID: c.SignedInUser.GetOrgID()} - return hs.AlertNotificationService.GetAllAlertNotifications(c.Req.Context(), query) -} - -// swagger:route GET /alert-notifications/{notification_channel_id} legacy_alerts_notification_channels getAlertNotificationChannelByID -// -// Get notification channel by ID. -// -// Returns the notification channel given the notification channel ID. -// -// Responses: -// 200: getAlertNotificationChannelResponse -// 401: unauthorisedError -// 403: forbiddenError -// 404: notFoundError -// 500: internalServerError -func (hs *HTTPServer) GetAlertNotificationByID(c *contextmodel.ReqContext) response.Response { - notificationId, err := strconv.ParseInt(web.Params(c.Req)[":notificationId"], 10, 64) - if err != nil { - return response.Error(http.StatusBadRequest, "notificationId is invalid", err) - } - query := &alertmodels.GetAlertNotificationsQuery{ - OrgID: c.SignedInUser.GetOrgID(), - ID: notificationId, - } - - if query.ID == 0 { - return response.Error(404, "Alert notification not found", nil) - } - - res, err := hs.AlertNotificationService.GetAlertNotifications(c.Req.Context(), query) - if err != nil { - return response.Error(500, "Failed to get alert notifications", err) - } - - if res == nil { - return response.Error(404, "Alert notification not found", nil) - } - - return response.JSON(http.StatusOK, dtos.NewAlertNotification(res)) -} - -// swagger:route GET /alert-notifications/uid/{notification_channel_uid} legacy_alerts_notification_channels getAlertNotificationChannelByUID -// -// Get notification channel by UID. -// -// Returns the notification channel given the notification channel UID. -// -// Responses: -// 200: getAlertNotificationChannelResponse -// 401: unauthorisedError -// 403: forbiddenError -// 404: notFoundError -// 500: internalServerError -func (hs *HTTPServer) GetAlertNotificationByUID(c *contextmodel.ReqContext) response.Response { - query := &alertmodels.GetAlertNotificationsWithUidQuery{ - OrgID: c.SignedInUser.GetOrgID(), - UID: web.Params(c.Req)[":uid"], - } - - if query.UID == "" { - return response.Error(404, "Alert notification not found", nil) - } - - res, err := hs.AlertNotificationService.GetAlertNotificationsWithUid(c.Req.Context(), query) - if err != nil { - return response.Error(500, "Failed to get alert notifications", err) - } - - if res == nil { - return response.Error(404, "Alert notification not found", nil) - } - - return response.JSON(http.StatusOK, dtos.NewAlertNotification(res)) -} - -// swagger:route POST /alert-notifications legacy_alerts_notification_channels createAlertNotificationChannel -// -// Create notification channel. -// -// You can find the full list of [supported notifiers](https://grafana.com/docs/grafana/latest/alerting/old-alerting/notifications/#list-of-supported-notifiers) on the alert notifiers page. -// -// Responses: -// 200: getAlertNotificationChannelResponse -// 401: unauthorisedError -// 403: forbiddenError -// 409: conflictError -// 500: internalServerError -func (hs *HTTPServer) CreateAlertNotification(c *contextmodel.ReqContext) response.Response { - cmd := alertmodels.CreateAlertNotificationCommand{} - if err := web.Bind(c.Req, &cmd); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) - } - cmd.OrgID = c.SignedInUser.GetOrgID() - - res, err := hs.AlertNotificationService.CreateAlertNotificationCommand(c.Req.Context(), &cmd) - if err != nil { - if errors.Is(err, alertmodels.ErrAlertNotificationWithSameNameExists) || errors.Is(err, alertmodels.ErrAlertNotificationWithSameUIDExists) { - return response.Error(409, "Failed to create alert notification", err) - } - var alertingErr alerting.ValidationError - if errors.As(err, &alertingErr) { - return response.Error(400, err.Error(), err) - } - return response.Error(500, "Failed to create alert notification", err) - } - - return response.JSON(http.StatusOK, dtos.NewAlertNotification(res)) -} - -// swagger:route PUT /alert-notifications/{notification_channel_id} legacy_alerts_notification_channels updateAlertNotificationChannel -// -// Update notification channel by ID. -// -// Updates an existing notification channel identified by ID. -// -// Responses: -// 200: getAlertNotificationChannelResponse -// 401: unauthorisedError -// 403: forbiddenError -// 404: notFoundError -// 500: internalServerError -func (hs *HTTPServer) UpdateAlertNotification(c *contextmodel.ReqContext) response.Response { - cmd := alertmodels.UpdateAlertNotificationCommand{} - if err := web.Bind(c.Req, &cmd); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) - } - cmd.OrgID = c.SignedInUser.GetOrgID() - - err := hs.fillWithSecureSettingsData(c.Req.Context(), &cmd) - if err != nil { - return response.Error(500, "Failed to update alert notification", err) - } - - if _, err := hs.AlertNotificationService.UpdateAlertNotification(c.Req.Context(), &cmd); err != nil { - if errors.Is(err, alertmodels.ErrAlertNotificationNotFound) { - return response.Error(404, err.Error(), err) - } - var alertingErr alerting.ValidationError - if errors.As(err, &alertingErr) { - return response.Error(400, err.Error(), err) - } - return response.Error(500, "Failed to update alert notification", err) - } - - query := alertmodels.GetAlertNotificationsQuery{ - OrgID: c.SignedInUser.GetOrgID(), - ID: cmd.ID, - } - - res, err := hs.AlertNotificationService.GetAlertNotifications(c.Req.Context(), &query) - if err != nil { - return response.Error(500, "Failed to get alert notification", err) - } - - return response.JSON(http.StatusOK, dtos.NewAlertNotification(res)) -} - -// swagger:route PUT /alert-notifications/uid/{notification_channel_uid} legacy_alerts_notification_channels updateAlertNotificationChannelByUID -// -// Update notification channel by UID. -// -// Updates an existing notification channel identified by uid. -// -// Responses: -// 200: getAlertNotificationChannelResponse -// 401: unauthorisedError -// 403: forbiddenError -// 404: notFoundError -// 500: internalServerError -func (hs *HTTPServer) UpdateAlertNotificationByUID(c *contextmodel.ReqContext) response.Response { - cmd := alertmodels.UpdateAlertNotificationWithUidCommand{} - if err := web.Bind(c.Req, &cmd); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) - } - cmd.OrgID = c.SignedInUser.GetOrgID() - cmd.UID = web.Params(c.Req)[":uid"] - - err := hs.fillWithSecureSettingsDataByUID(c.Req.Context(), &cmd) - if err != nil { - return response.Error(500, "Failed to update alert notification", err) - } - - if _, err := hs.AlertNotificationService.UpdateAlertNotificationWithUid(c.Req.Context(), &cmd); err != nil { - if errors.Is(err, alertmodels.ErrAlertNotificationNotFound) { - return response.Error(404, err.Error(), nil) - } - return response.Error(500, "Failed to update alert notification", err) - } - - query := alertmodels.GetAlertNotificationsWithUidQuery{ - OrgID: cmd.OrgID, - UID: cmd.UID, - } - - res, err := hs.AlertNotificationService.GetAlertNotificationsWithUid(c.Req.Context(), &query) - if err != nil { - return response.Error(500, "Failed to get alert notification", err) - } - - return response.JSON(http.StatusOK, dtos.NewAlertNotification(res)) -} - -func (hs *HTTPServer) fillWithSecureSettingsData(ctx context.Context, cmd *alertmodels.UpdateAlertNotificationCommand) error { - if len(cmd.SecureSettings) == 0 { - return nil - } - - query := &alertmodels.GetAlertNotificationsQuery{ - OrgID: cmd.OrgID, - ID: cmd.ID, - } - - res, err := hs.AlertNotificationService.GetAlertNotifications(ctx, query) - if err != nil { - return err - } - - secureSettings, err := hs.EncryptionService.DecryptJsonData(ctx, res.SecureSettings, setting.SecretKey) - if err != nil { - return err - } - - for k, v := range secureSettings { - if _, ok := cmd.SecureSettings[k]; !ok { - cmd.SecureSettings[k] = v - } - } - - return nil -} - -func (hs *HTTPServer) fillWithSecureSettingsDataByUID(ctx context.Context, cmd *alertmodels.UpdateAlertNotificationWithUidCommand) error { - if len(cmd.SecureSettings) == 0 { - return nil - } - - query := &alertmodels.GetAlertNotificationsWithUidQuery{ - OrgID: cmd.OrgID, - UID: cmd.UID, - } - - res, err := hs.AlertNotificationService.GetAlertNotificationsWithUid(ctx, query) - if err != nil { - return err - } - - secureSettings, err := hs.EncryptionService.DecryptJsonData(ctx, res.SecureSettings, setting.SecretKey) - if err != nil { - return err - } - - for k, v := range secureSettings { - if _, ok := cmd.SecureSettings[k]; !ok { - cmd.SecureSettings[k] = v - } - } - - return nil -} - -// swagger:route DELETE /alert-notifications/{notification_channel_id} legacy_alerts_notification_channels deleteAlertNotificationChannel -// -// Delete alert notification by ID. -// -// Deletes an existing notification channel identified by ID. -// -// Responses: -// 200: okResponse -// 401: unauthorisedError -// 403: forbiddenError -// 404: notFoundError -// 500: internalServerError -func (hs *HTTPServer) DeleteAlertNotification(c *contextmodel.ReqContext) response.Response { - notificationId, err := strconv.ParseInt(web.Params(c.Req)[":notificationId"], 10, 64) - if err != nil { - return response.Error(http.StatusBadRequest, "notificationId is invalid", err) - } - - cmd := alertmodels.DeleteAlertNotificationCommand{ - OrgID: c.SignedInUser.GetOrgID(), - ID: notificationId, - } - - if err := hs.AlertNotificationService.DeleteAlertNotification(c.Req.Context(), &cmd); err != nil { - if errors.Is(err, alertmodels.ErrAlertNotificationNotFound) { - return response.Error(404, err.Error(), nil) - } - return response.Error(500, "Failed to delete alert notification", err) - } - - return response.Success("Notification deleted") -} - -// swagger:route DELETE /alert-notifications/uid/{notification_channel_uid} legacy_alerts_notification_channels deleteAlertNotificationChannelByUID -// -// Delete alert notification by UID. -// -// Deletes an existing notification channel identified by UID. -// -// Responses: -// 200: deleteAlertNotificationChannelResponse -// 401: unauthorisedError -// 403: forbiddenError -// 404: notFoundError -// 500: internalServerError -func (hs *HTTPServer) DeleteAlertNotificationByUID(c *contextmodel.ReqContext) response.Response { - cmd := alertmodels.DeleteAlertNotificationWithUidCommand{ - OrgID: c.SignedInUser.GetOrgID(), - UID: web.Params(c.Req)[":uid"], - } - - if err := hs.AlertNotificationService.DeleteAlertNotificationWithUid(c.Req.Context(), &cmd); err != nil { - if errors.Is(err, alertmodels.ErrAlertNotificationNotFound) { - return response.Error(404, err.Error(), nil) - } - return response.Error(500, "Failed to delete alert notification", err) - } - - return response.JSON(http.StatusOK, util.DynMap{ - "message": "Notification deleted", - "id": cmd.DeletedAlertNotificationID, - }) -} - -// swagger:route POST /alert-notifications/test legacy_alerts_notification_channels notificationChannelTest -// -// Test notification channel. -// -// Sends a test notification to the channel. -// -// Responses: -// 200: okResponse -// 400: badRequestError -// 401: unauthorisedError -// 403: forbiddenError -// 412: SMTPNotEnabledError -// 500: internalServerError -func (hs *HTTPServer) NotificationTest(c *contextmodel.ReqContext) response.Response { - dto := dtos.NotificationTestCommand{} - if err := web.Bind(c.Req, &dto); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) - } - cmd := &alerting.NotificationTestCommand{ - OrgID: c.SignedInUser.GetOrgID(), - ID: dto.ID, - Name: dto.Name, - Type: dto.Type, - Settings: dto.Settings, - SecureSettings: dto.SecureSettings, - } - - if err := hs.AlertNotificationService.HandleNotificationTestCommand(c.Req.Context(), cmd); err != nil { - if errors.Is(err, notifications.ErrSmtpNotEnabled) { - return response.Error(412, err.Error(), err) - } - var alertingErr alerting.ValidationError - if errors.As(err, &alertingErr) { - return response.Error(400, err.Error(), err) - } - - return response.Error(500, "Failed to send alert notifications", err) - } - - return response.Success("Test notification sent") -} - -// swagger:route POST /alerts/{alert_id}/pause legacy_alerts pauseAlert -// -// Pause/unpause alert by id. -// -// Responses: -// 200: pauseAlertResponse -// 401: unauthorisedError -// 403: forbiddenError -// 404: notFoundError -// 500: internalServerError -func (hs *HTTPServer) PauseAlert(legacyAlertingEnabled *bool) func(c *contextmodel.ReqContext) response.Response { - if legacyAlertingEnabled == nil || !*legacyAlertingEnabled { - return func(_ *contextmodel.ReqContext) response.Response { - return response.Error(http.StatusBadRequest, "legacy alerting is disabled, so this call has no effect.", nil) - } - } - - return func(c *contextmodel.ReqContext) response.Response { - dto := dtos.PauseAlertCommand{} - if err := web.Bind(c.Req, &dto); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) - } - alertID, err := strconv.ParseInt(web.Params(c.Req)[":alertId"], 10, 64) - if err != nil { - return response.Error(http.StatusBadRequest, "alertId is invalid", err) - } - result := make(map[string]any) - result["alertId"] = alertID - - query := alertmodels.GetAlertByIdQuery{ID: alertID} - res, err := hs.AlertEngine.AlertStore.GetAlertById(c.Req.Context(), &query) - if err != nil { - return response.Error(500, "Get Alert failed", err) - } - - guardian, err := guardian.New(c.Req.Context(), res.DashboardID, c.SignedInUser.GetOrgID(), c.SignedInUser) - if err != nil { - return response.ErrOrFallback(http.StatusInternalServerError, "Error while creating permission guardian", err) - } - if canEdit, err := guardian.CanEdit(); err != nil || !canEdit { - if err != nil { - return response.Error(500, "Error while checking permissions for Alert", err) - } - - return response.Error(403, "Access denied to this dashboard and alert", nil) - } - - // Alert state validation - if res.State != alertmodels.AlertStatePaused && !dto.Paused { - result["state"] = "un-paused" - result["message"] = "Alert is already un-paused" - return response.JSON(http.StatusOK, result) - } else if res.State == alertmodels.AlertStatePaused && dto.Paused { - result["state"] = alertmodels.AlertStatePaused - result["message"] = "Alert is already paused" - return response.JSON(http.StatusOK, result) - } - - cmd := alertmodels.PauseAlertCommand{ - OrgID: c.SignedInUser.GetOrgID(), - AlertIDs: []int64{alertID}, - Paused: dto.Paused, - } - - if err := hs.AlertEngine.AlertStore.PauseAlert(c.Req.Context(), &cmd); err != nil { - return response.Error(500, "", err) - } - - resp := alertmodels.AlertStateUnknown - pausedState := "un-paused" - if cmd.Paused { - resp = alertmodels.AlertStatePaused - pausedState = "paused" - } - - result["state"] = resp - result["message"] = "Alert " + pausedState - return response.JSON(http.StatusOK, result) - } -} - -// swagger:route POST /admin/pause-all-alerts admin pauseAllAlerts -// -// Pause/unpause all (legacy) alerts. -// -// Security: -// - basic: -// -// Responses: -// 200: pauseAlertsResponse -// 401: unauthorisedError -// 403: forbiddenError -// 500: internalServerError -func (hs *HTTPServer) PauseAllAlerts(legacyAlertingEnabled *bool) func(c *contextmodel.ReqContext) response.Response { - if legacyAlertingEnabled == nil || !*legacyAlertingEnabled { - return func(_ *contextmodel.ReqContext) response.Response { - return response.Error(http.StatusBadRequest, "legacy alerting is disabled, so this call has no effect.", nil) - } - } - - return func(c *contextmodel.ReqContext) response.Response { - dto := dtos.PauseAllAlertsCommand{} - if err := web.Bind(c.Req, &dto); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) - } - updateCmd := alertmodels.PauseAllAlertCommand{ - Paused: dto.Paused, - } - - if err := hs.AlertEngine.AlertStore.PauseAllAlerts(c.Req.Context(), &updateCmd); err != nil { - return response.Error(500, "Failed to pause alerts", err) - } - - resp := alertmodels.AlertStatePending - pausedState := "un paused" - if updateCmd.Paused { - resp = alertmodels.AlertStatePaused - pausedState = "paused" - } - - result := map[string]any{ - "state": resp, - "message": "alerts " + pausedState, - "alertsAffected": updateCmd.ResultCount, - } - - return response.JSON(http.StatusOK, result) - } -} - -// swagger:parameters pauseAllAlerts -type PauseAllAlertsParams struct { - // in:body - // required:true - Body dtos.PauseAllAlertsCommand `json:"body"` -} - -// swagger:parameters deleteAlertNotificationChannel -type DeleteAlertNotificationChannelParams struct { - // in:path - // required:true - NotificationID int64 `json:"notification_channel_id"` -} - -// swagger:parameters getAlertNotificationChannelByID -type GetAlertNotificationChannelByIDParams struct { - // in:path - // required:true - NotificationID int64 `json:"notification_channel_id"` -} - -// swagger:parameters deleteAlertNotificationChannelByUID -type DeleteAlertNotificationChannelByUIDParams struct { - // in:path - // required:true - NotificationUID string `json:"notification_channel_uid"` -} - -// swagger:parameters getAlertNotificationChannelByUID -type GetAlertNotificationChannelByUIDParams struct { - // in:path - // required:true - NotificationUID string `json:"notification_channel_uid"` -} - -// swagger:parameters notificationChannelTest -type NotificationChannelTestParams struct { - // in:body - // required:true - Body dtos.NotificationTestCommand `json:"body"` -} - -// swagger:parameters createAlertNotificationChannel -type CreateAlertNotificationChannelParams struct { - // in:body - // required:true - Body alertmodels.CreateAlertNotificationCommand `json:"body"` -} - -// swagger:parameters updateAlertNotificationChannel -type UpdateAlertNotificationChannelParams struct { - // in:body - // required:true - Body alertmodels.UpdateAlertNotificationCommand `json:"body"` - // in:path - // required:true - NotificationID int64 `json:"notification_channel_id"` -} - -// swagger:parameters updateAlertNotificationChannelByUID -type UpdateAlertNotificationChannelByUIDParams struct { - // in:body - // required:true - Body alertmodels.UpdateAlertNotificationWithUidCommand `json:"body"` - // in:path - // required:true - NotificationUID string `json:"notification_channel_uid"` -} - -// swagger:parameters getAlertByID -type GetAlertByIDParams struct { - // in:path - // required:true - AlertID string `json:"alert_id"` -} - -// swagger:parameters pauseAlert -type PauseAlertParams struct { - // in:path - // required:true - AlertID string `json:"alert_id"` - // in:body - // required:true - Body dtos.PauseAlertCommand `json:"body"` -} - -// swagger:parameters getAlerts -type GetAlertsParams struct { - // Limit response to alerts in specified dashboard(s). You can specify multiple dashboards. - // in:query - // required:false - DashboardID []string `json:"dashboardId"` - // Limit response to alert for a specified panel on a dashboard. - // in:query - // required:false - PanelID int64 `json:"panelId"` - // Limit response to alerts having a name like this value. - // in:query - // required: false - Query string `json:"query"` - // Return alerts with one or more of the following alert states - // in:query - // required:false - // Description: - // * `all` - // * `no_data` - // * `paused` - // * `alerting` - // * `ok` - // * `pending` - // * `unknown` - // enum: all,no_data,paused,alerting,ok,pending,unknown - State string `json:"state"` - // Limit response to X number of alerts. - // in:query - // required:false - Limit int64 `json:"limit"` - // Limit response to alerts of dashboards in specified folder(s). You can specify multiple folders - // in:query - // required:false - // type array - // collectionFormat: multi - // - // Deprecated: use FolderUID instead - FolderID []string `json:"folderId"` - // Limit response to alerts having a dashboard name like this value./ Limit response to alerts having a dashboard name like this value. - // in:query - // required:false - DashboardQuery string `json:"dashboardQuery"` - // Limit response to alerts of dashboards with specified tags. To do an “AND” filtering with multiple tags, specify the tags parameter multiple times - // in:query - // required:false - // type: array - // collectionFormat: multi - DashboardTag []string `json:"dashboardTag"` -} - -// swagger:parameters testAlert -type TestAlertParams struct { - // in:body - Body dtos.AlertTestCommand `json:"body"` -} - -// swagger:parameters getDashboardStates -type GetDashboardStatesParams struct { - // in:query - // required: true - DashboardID int64 `json:"dashboardId"` -} - -// swagger:response pauseAlertsResponse -type PauseAllAlertsResponse struct { - // in:body - Body struct { - // AlertsAffected is the number of the affected alerts. - // required: true - AlertsAffected int64 `json:"alertsAffected"` - // required: true - Message string `json:"message"` - // Alert result state - // required true - State string `json:"state"` - } `json:"body"` -} - -// swagger:response getAlertNotificationChannelsResponse -type GetAlertNotificationChannelsResponse struct { - // The response message - // in: body - Body []*dtos.AlertNotification `json:"body"` -} - -// swagger:response getAlertNotificationLookupResponse -type LookupAlertNotificationChannelsResponse struct { - // The response message - // in: body - Body []*dtos.AlertNotificationLookup `json:"body"` -} - -// swagger:response getAlertNotificationChannelResponse -type GetAlertNotificationChannelResponse struct { - // The response message - // in: body - Body *dtos.AlertNotification `json:"body"` -} - -// swagger:response deleteAlertNotificationChannelResponse -type DeleteAlertNotificationChannelResponse struct { - // The response message - // in: body - Body struct { - // ID Identifier of the deleted notification channel. - // required: true - // example: 65 - ID int64 `json:"id"` - - // Message Message of the deleted notificatiton channel. - // required: true - Message string `json:"message"` - } `json:"body"` -} - -// swagger:response SMTPNotEnabledError -type SMTPNotEnabledError PreconditionFailedError - -// swagger:response getAlertsResponse -type GetAlertsResponse struct { - // The response message - // in: body - Body []*alertmodels.AlertListItemDTO `json:"body"` -} - -// swagger:response getAlertResponse -type GetAlertResponse struct { - // The response message - // in: body - Body *alertmodels.Alert `json:"body"` -} - -// swagger:response pauseAlertResponse -type PauseAlertResponse struct { - // in:body - Body struct { - // required: true - AlertID int64 `json:"alertId"` - // required: true - Message string `json:"message"` - // Alert result state - // required true - State string `json:"state"` - } `json:"body"` -} - -// swagger:response testAlertResponse -type TestAlertResponse struct { - // The response message - // in: body - Body *dtos.AlertTestResult `json:"body"` -} - -// swagger:response getDashboardStatesResponse -type GetDashboardStatesResponse struct { - // The response message - // in: body - Body []*alertmodels.AlertStateInfoDTO `json:"body"` -} diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index 0a440bb4b5b4c..717d8601ca2c0 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -62,14 +62,14 @@ func (hs *HTTPServer) GetAnnotations(c *contextmodel.ReqContext) response.Respon items, err := hs.annotationsRepo.Find(c.Req.Context(), query) if err != nil { - return response.Error(500, "Failed to get annotations", err) + return response.Error(http.StatusInternalServerError, "Failed to get annotations", err) } // since there are several annotations per dashboard, we can cache dashboard uid dashboardCache := make(map[int64]*string) for _, item := range items { if item.Email != "" { - item.AvatarURL = dtos.GetGravatarUrl(item.Email) + item.AvatarURL = dtos.GetGravatarUrl(hs.Cfg, item.Email) } if item.DashboardID != 0 { @@ -138,7 +138,7 @@ func (hs *HTTPServer) PostAnnotation(c *contextmodel.ReqContext) response.Respon if cmd.Text == "" { err := &AnnotationError{"text field should not be empty"} - return response.Error(400, "Failed to save annotation", err) + return response.Error(http.StatusBadRequest, "Failed to save annotation", err) } userID, err := identity.UserIdentifier(c.SignedInUser.GetNamespacedID()) @@ -160,9 +160,9 @@ func (hs *HTTPServer) PostAnnotation(c *contextmodel.ReqContext) response.Respon if err := hs.annotationsRepo.Save(c.Req.Context(), &item); err != nil { if errors.Is(err, annotations.ErrTimerangeMissing) { - return response.Error(400, "Failed to save annotation", err) + return response.Error(http.StatusBadRequest, "Failed to save annotation", err) } - return response.ErrOrFallback(500, "Failed to save annotation", err) + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to save annotation", err) } startID := item.ID @@ -200,7 +200,7 @@ func (hs *HTTPServer) PostGraphiteAnnotation(c *contextmodel.ReqContext) respons } if cmd.What == "" { err := &AnnotationError{"what field should not be empty"} - return response.Error(400, "Failed to save Graphite annotation", err) + return response.Error(http.StatusBadRequest, "Failed to save Graphite annotation", err) } text := formatGraphiteAnnotation(cmd.What, cmd.Data) @@ -220,12 +220,12 @@ func (hs *HTTPServer) PostGraphiteAnnotation(c *contextmodel.ReqContext) respons tagsArray = append(tagsArray, tagStr) } else { err := &AnnotationError{"tag should be a string"} - return response.Error(400, "Failed to save Graphite annotation", err) + return response.Error(http.StatusBadRequest, "Failed to save Graphite annotation", err) } } default: err := &AnnotationError{"unsupported tags format"} - return response.Error(400, "Failed to save Graphite annotation", err) + return response.Error(http.StatusBadRequest, "Failed to save Graphite annotation", err) } userID, err := identity.UserIdentifier(c.SignedInUser.GetNamespacedID()) @@ -242,7 +242,7 @@ func (hs *HTTPServer) PostGraphiteAnnotation(c *contextmodel.ReqContext) respons } if err := hs.annotationsRepo.Save(c.Req.Context(), &item); err != nil { - return response.ErrOrFallback(500, "Failed to save Graphite annotation", err) + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to save Graphite annotation", err) } return response.JSON(http.StatusOK, util.DynMap{ @@ -307,7 +307,7 @@ func (hs *HTTPServer) UpdateAnnotation(c *contextmodel.ReqContext) response.Resp } if err := hs.annotationsRepo.Update(c.Req.Context(), &item); err != nil { - return response.ErrOrFallback(500, "Failed to update annotation", err) + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to update annotation", err) } return response.Success("Annotation updated") @@ -386,7 +386,7 @@ func (hs *HTTPServer) PatchAnnotation(c *contextmodel.ReqContext) response.Respo } if err := hs.annotationsRepo.Update(c.Req.Context(), &existing); err != nil { - return response.ErrOrFallback(500, "Failed to update annotation", err) + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to update annotation", err) } return response.Success("Annotation patched") @@ -459,7 +459,7 @@ func (hs *HTTPServer) MassDeleteAnnotations(c *contextmodel.ReqContext) response err = hs.annotationsRepo.Delete(c.Req.Context(), deleteParams) if err != nil { - return response.Error(500, "Failed to delete annotations", err) + return response.Error(http.StatusInternalServerError, "Failed to delete annotations", err) } return response.Success("Annotations deleted") @@ -485,10 +485,10 @@ func (hs *HTTPServer) GetAnnotationByID(c *contextmodel.ReqContext) response.Res } if annotation.Email != "" { - annotation.AvatarURL = dtos.GetGravatarUrl(annotation.Email) + annotation.AvatarURL = dtos.GetGravatarUrl(hs.Cfg, annotation.Email) } - return response.JSON(200, annotation) + return response.JSON(http.StatusOK, annotation) } // swagger:route DELETE /annotations/{annotation_id} annotations deleteAnnotationByID @@ -524,7 +524,7 @@ func (hs *HTTPServer) DeleteAnnotationByID(c *contextmodel.ReqContext) response. ID: annotationID, }) if err != nil { - return response.Error(500, "Failed to delete annotation", err) + return response.Error(http.StatusInternalServerError, "Failed to delete annotation", err) } return response.Success("Annotation deleted") @@ -560,11 +560,11 @@ func findAnnotationByID(ctx context.Context, repo annotations.Repository, annota items, err := repo.Find(ctx, query) if err != nil { - return nil, response.Error(500, "Failed to find annotation", err) + return nil, response.Error(http.StatusInternalServerError, "Failed to find annotation", err) } if len(items) == 0 { - return nil, response.Error(404, "Annotation not found", nil) + return nil, response.Error(http.StatusNotFound, "Annotation not found", nil) } return items[0], nil @@ -589,7 +589,7 @@ func (hs *HTTPServer) GetAnnotationTags(c *contextmodel.ReqContext) response.Res result, err := hs.annotationsRepo.FindTags(c.Req.Context(), query) if err != nil { - return response.Error(500, "Failed to find annotation tags", err) + return response.Error(http.StatusInternalServerError, "Failed to find annotation tags", err) } return response.JSON(http.StatusOK, annotations.GetAnnotationTagsResponse{Result: result}) @@ -600,7 +600,7 @@ func (hs *HTTPServer) GetAnnotationTags(c *contextmodel.ReqContext) response.Res // where <type> is the type of annotation with id <id>. // If annotationPermissionUpdate feature toggle is enabled, dashboard annotation scope will be resolved to the corresponding // dashboard and folder scopes (eg, "dashboards:uid:<annotation_dashboard_uid>", "folders:uid:<parent_folder_uid>" etc). -func AnnotationTypeScopeResolver(annotationsRepo annotations.Repository, features *featuremgmt.FeatureManager, dashSvc dashboards.DashboardService, folderSvc folder.Service) (string, accesscontrol.ScopeAttributeResolver) { +func AnnotationTypeScopeResolver(annotationsRepo annotations.Repository, features featuremgmt.FeatureToggles, dashSvc dashboards.DashboardService, folderSvc folder.Service) (string, accesscontrol.ScopeAttributeResolver) { prefix := accesscontrol.ScopeAnnotationsProvider.GetResourceScope("") return prefix, accesscontrol.ScopeAttributeResolverFunc(func(ctx context.Context, orgID int64, initialScope string) ([]string, error) { scopeParts := strings.Split(initialScope, ":") diff --git a/pkg/api/api.go b/pkg/api/api.go index e3b93836caccc..1dde93806db8b 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -31,10 +31,10 @@ package api import ( "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/middleware/requestmeta" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/ssoutils" "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/correlations" @@ -46,12 +46,8 @@ import ( publicdashboardsapi "github.com/grafana/grafana/pkg/services/publicdashboards/api" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/web" ) -var plog = log.New("api") - // registerRoutes registers all API HTTP routes. func (hs *HTTPServer) registerRoutes() { reqNoAuth := middleware.NoAuth() @@ -59,7 +55,6 @@ func (hs *HTTPServer) registerRoutes() { reqNotSignedIn := middleware.ReqNotSignedIn reqSignedInNoAnonymous := middleware.ReqSignedInNoAnonymous reqGrafanaAdmin := middleware.ReqGrafanaAdmin - reqEditorRole := middleware.ReqEditorRole reqOrgAdmin := middleware.ReqOrgAdmin reqRoleForAppRoute := middleware.RoleAppPluginAuth(hs.AccessControl, hs.pluginStore, hs.Features, hs.log) reqSnapshotPublicModeOrSignedIn := middleware.SnapshotPublicModeOrSignedIn(hs.Cfg) @@ -108,7 +103,6 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/admin/orgs", authorizeInOrg(ac.UseGlobalOrg, ac.OrgsAccessEvaluator), hs.Index) r.Get("/admin/orgs/edit/:id", authorizeInOrg(ac.UseGlobalOrg, ac.OrgsAccessEvaluator), hs.Index) r.Get("/admin/stats", authorize(ac.EvalPermission(ac.ActionServerStatsRead)), hs.Index) - r.Get("/admin/authentication/ldap", authorize(ac.EvalPermission(ac.ActionLDAPStatusRead)), hs.Index) if hs.Features.IsEnabledGlobally(featuremgmt.FlagStorage) { r.Get("/admin/storage", reqSignedIn, hs.Index) r.Get("/admin/storage/*", reqSignedIn, hs.Index) @@ -194,6 +188,11 @@ func (hs *HTTPServer) registerRoutes() { r.Post("/api/user/signup", quota(user.QuotaTargetSrv), quota(org.QuotaTargetSrv), routing.Wrap(hs.SignUp)) r.Post("/api/user/signup/step2", routing.Wrap(hs.SignUpStep2)) + // update user email + if hs.Cfg.Smtp.Enabled && hs.Cfg.VerifyEmailEnabled { + r.Get("/user/email/update", reqSignedInNoAnonymous, routing.Wrap(hs.UpdateUserEmail)) + } + // invited r.Get("/api/user/invite/:code", routing.Wrap(hs.GetInviteInfoByCode)) r.Post("/api/user/invite/complete", routing.Wrap(hs.CompleteInvite)) @@ -218,12 +217,23 @@ func (hs *HTTPServer) registerRoutes() { // add swagger support registerSwaggerUI(r) - if hs.Features.IsEnabledGlobally(featuremgmt.FlagClientTokenRotation) { - r.Post("/api/user/auth-tokens/rotate", routing.Wrap(hs.RotateUserAuthToken)) - r.Get("/user/auth-tokens/rotate", routing.Wrap(hs.RotateUserAuthTokenRedirect)) + r.Post("/api/user/auth-tokens/rotate", routing.Wrap(hs.RotateUserAuthToken)) + r.Get("/user/auth-tokens/rotate", routing.Wrap(hs.RotateUserAuthTokenRedirect)) + + adminAuthPageEvaluator := func() ac.Evaluator { + authnSettingsEval := ssoutils.EvalAuthenticationSettings(hs.Cfg) + if hs.Features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsApi) { + return ac.EvalAny(authnSettingsEval, ssoutils.OauthSettingsEvaluator(hs.Cfg)) + } + return authnSettingsEval } - r.Get("/admin/authentication/", authorize(evalAuthenticationSettings()), hs.Index) + r.Get("/admin/authentication", authorize(adminAuthPageEvaluator()), hs.Index) + r.Get("/admin/authentication/ldap", authorize(ac.EvalPermission(ac.ActionLDAPStatusRead)), hs.Index) + if hs.Features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsApi) { + providerParam := ac.Parameter(":provider") + r.Get("/admin/authentication/:provider", authorize(ac.EvalPermission(ac.ActionSettingsRead, ac.ScopeSettingsOAuth(providerParam))), hs.Index) + } // authed api r.Group("/api", func(apiRoute routing.RouteRegister) { @@ -407,15 +417,9 @@ func (hs *HTTPServer) registerRoutes() { pluginRoute.Get("/:pluginId/metrics", reqOrgAdmin, routing.Wrap(hs.CollectPluginMetrics)) }) - if hs.Features.IsEnabledGlobally(featuremgmt.FlagFeatureToggleAdminPage) { - apiRoute.Group("/featuremgmt", func(featuremgmtRoute routing.RouteRegister) { - featuremgmtRoute.Get("/state", authorize(ac.EvalPermission(ac.ActionFeatureManagementRead)), hs.GetFeatureMgmtState) - featuremgmtRoute.Get("/", authorize(ac.EvalPermission(ac.ActionFeatureManagementRead)), hs.GetFeatureToggles) - featuremgmtRoute.Post("/", authorize(ac.EvalPermission(ac.ActionFeatureManagementWrite)), hs.UpdateFeatureToggle) - }) - } - apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings) + apiRoute.Get("/frontend/assets", hs.GetFrontendAssets) + apiRoute.Any("/datasources/proxy/:id/*", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequest) apiRoute.Any("/datasources/proxy/uid/:uid/*", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequestWithUID) apiRoute.Any("/datasources/proxy/:id", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequest) @@ -502,44 +506,13 @@ func (hs *HTTPServer) registerRoutes() { // metrics // DataSource w/ expressions - apiRoute.Post("/ds/query", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(datasources.ActionQuery)), routing.Wrap(hs.QueryMetricsV2)) - - apiRoute.Group("/alerts", func(alertsRoute routing.RouteRegister) { - alertsRoute.Post("/test", routing.Wrap(hs.AlertTest)) - alertsRoute.Post("/:alertId/pause", reqEditorRole, routing.Wrap(hs.PauseAlert(setting.AlertingEnabled))) - alertsRoute.Get("/:alertId", hs.ValidateOrgAlert, routing.Wrap(hs.GetAlert)) - alertsRoute.Get("/", routing.Wrap(hs.GetAlerts)) - alertsRoute.Get("/states-for-dashboard", routing.Wrap(hs.GetAlertStatesForDashboard)) - }, requestmeta.SetOwner(requestmeta.TeamAlerting)) - - var notifiersAuthHandler web.Handler - if hs.Cfg.UnifiedAlerting.IsEnabled() { - notifiersAuthHandler = reqSignedIn - } else { - notifiersAuthHandler = reqEditorRole - } + apiRoute.Post("/ds/query", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(datasources.ActionQuery)), hs.getDSQueryEndpoint()) - apiRoute.Get("/alert-notifiers", notifiersAuthHandler, requestmeta.SetOwner(requestmeta.TeamAlerting), routing.Wrap( - hs.GetAlertNotifiers(hs.Cfg.UnifiedAlerting.IsEnabled())), + // Unified Alerting + apiRoute.Get("/alert-notifiers", reqSignedIn, requestmeta.SetOwner(requestmeta.TeamAlerting), routing.Wrap( + hs.GetAlertNotifiers()), ) - apiRoute.Group("/alert-notifications", func(alertNotifications routing.RouteRegister) { - alertNotifications.Get("/", routing.Wrap(hs.GetAlertNotifications)) - alertNotifications.Post("/test", routing.Wrap(hs.NotificationTest)) - alertNotifications.Post("/", routing.Wrap(hs.CreateAlertNotification)) - alertNotifications.Put("/:notificationId", routing.Wrap(hs.UpdateAlertNotification)) - alertNotifications.Get("/:notificationId", routing.Wrap(hs.GetAlertNotificationByID)) - alertNotifications.Delete("/:notificationId", routing.Wrap(hs.DeleteAlertNotification)) - alertNotifications.Get("/uid/:uid", routing.Wrap(hs.GetAlertNotificationByUID)) - alertNotifications.Put("/uid/:uid", routing.Wrap(hs.UpdateAlertNotificationByUID)) - alertNotifications.Delete("/uid/:uid", routing.Wrap(hs.DeleteAlertNotificationByUID)) - }, reqEditorRole, requestmeta.SetOwner(requestmeta.TeamAlerting)) - - // alert notifications without requirement of user to be org editor - apiRoute.Group("/alert-notifications", func(orgRoute routing.RouteRegister) { - orgRoute.Get("/lookup", routing.Wrap(hs.GetAlertNotificationLookup)) - }, requestmeta.SetOwner(requestmeta.TeamAlerting)) - apiRoute.Get("/annotations", authorize(ac.EvalPermission(ac.ActionAnnotationsRead)), routing.Wrap(hs.GetAnnotations)) apiRoute.Post("/annotations/mass-delete", authorize(ac.EvalPermission(ac.ActionAnnotationsDelete)), routing.Wrap(hs.MassDeleteAnnotations)) @@ -579,7 +552,6 @@ func (hs *HTTPServer) registerRoutes() { adminRoute.Get("/settings", authorize(ac.EvalPermission(ac.ActionSettingsRead)), routing.Wrap(hs.AdminGetSettings)) adminRoute.Get("/settings-verbose", authorize(ac.EvalPermission(ac.ActionSettingsRead)), routing.Wrap(hs.AdminGetVerboseSettings)) adminRoute.Get("/stats", authorize(ac.EvalPermission(ac.ActionServerStatsRead)), routing.Wrap(hs.AdminGetStats)) - adminRoute.Post("/pause-all-alerts", reqGrafanaAdmin, routing.Wrap(hs.PauseAllAlerts(setting.AlertingEnabled))) adminRoute.Post("/encryption/rotate-data-keys", reqGrafanaAdmin, routing.Wrap(hs.AdminRotateDataEncryptionKeys)) adminRoute.Post("/encryption/reencrypt-data-keys", reqGrafanaAdmin, routing.Wrap(hs.AdminReEncryptEncryptionKeys)) @@ -592,7 +564,6 @@ func (hs *HTTPServer) registerRoutes() { adminRoute.Post("/provisioning/dashboards/reload", authorize(ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersDashboards)), routing.Wrap(hs.AdminProvisioningReloadDashboards)) adminRoute.Post("/provisioning/plugins/reload", authorize(ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersPlugins)), routing.Wrap(hs.AdminProvisioningReloadPlugins)) adminRoute.Post("/provisioning/datasources/reload", authorize(ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersDatasources)), routing.Wrap(hs.AdminProvisioningReloadDatasources)) - adminRoute.Post("/provisioning/notifications/reload", authorize(ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersNotifications)), routing.Wrap(hs.AdminProvisioningReloadNotifications)) adminRoute.Post("/provisioning/alerting/reload", authorize(ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersAlertRules)), routing.Wrap(hs.AdminProvisioningReloadAlerting)) }, reqSignedIn) @@ -624,16 +595,9 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/avatar/:hash", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), hs.AvatarCacheServer.Handler) // Snapshots - r.Post("/api/snapshots/", reqSnapshotPublicModeOrSignedIn, hs.CreateDashboardSnapshot) + r.Post("/api/snapshots/", reqSnapshotPublicModeOrSignedIn, hs.getCreatedSnapshotHandler()) r.Get("/api/snapshot/shared-options/", reqSignedIn, hs.GetSharingOptions) r.Get("/api/snapshots/:key", routing.Wrap(hs.GetDashboardSnapshot)) r.Get("/api/snapshots-delete/:deleteKey", reqSnapshotPublicModeOrSignedIn, routing.Wrap(hs.DeleteDashboardSnapshotByDeleteKey)) r.Delete("/api/snapshots/:key", reqSignedIn, routing.Wrap(hs.DeleteDashboardSnapshot)) } - -func evalAuthenticationSettings() ac.Evaluator { - return ac.EvalAny(ac.EvalAll( - ac.EvalPermission(ac.ActionSettingsWrite, ac.ScopeSettingsSAML), - ac.EvalPermission(ac.ActionSettingsRead, ac.ScopeSettingsSAML), - ), ac.EvalPermission(ac.ActionLDAPStatusRead)) -} diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go new file mode 100644 index 0000000000000..0cbeea1f75292 --- /dev/null +++ b/pkg/api/api_test.go @@ -0,0 +1,11 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} diff --git a/pkg/api/apierrors/dashboard.go b/pkg/api/apierrors/dashboard.go index 6b80347e01279..6f740d80afd57 100644 --- a/pkg/api/apierrors/dashboard.go +++ b/pkg/api/apierrors/dashboard.go @@ -7,7 +7,6 @@ import ( "net/http" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/util" @@ -30,11 +29,6 @@ func ToDashboardErrorResponse(ctx context.Context, pluginStore pluginstore.Store return response.Error(http.StatusBadRequest, err.Error(), nil) } - var validationErr alerting.ValidationError - if ok := errors.As(err, &validationErr); ok { - return response.Error(http.StatusUnprocessableEntity, validationErr.Error(), err) - } - var pluginErr dashboards.UpdatePluginDashboardError if ok := errors.As(err, &pluginErr); ok { message := fmt.Sprintf("The dashboard belongs to plugin %s.", pluginErr.PluginId) diff --git a/pkg/api/apierrors/folder.go b/pkg/api/apierrors/folder.go index 1daedcf6868ad..edad9a40a0f89 100644 --- a/pkg/api/apierrors/folder.go +++ b/pkg/api/apierrors/folder.go @@ -2,6 +2,7 @@ package apierrors import ( "errors" + "net/http" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/services/dashboards" @@ -19,25 +20,25 @@ func ToFolderErrorResponse(err error) response.Response { errors.Is(err, dashboards.ErrDashboardTypeMismatch) || errors.Is(err, dashboards.ErrDashboardInvalidUid) || errors.Is(err, dashboards.ErrDashboardUidTooLong) { - return response.Error(400, err.Error(), nil) + return response.Error(http.StatusBadRequest, err.Error(), nil) } if errors.Is(err, dashboards.ErrFolderAccessDenied) { - return response.Error(403, "Access denied", err) + return response.Error(http.StatusForbidden, "Access denied", err) } if errors.Is(err, dashboards.ErrFolderNotFound) { - return response.JSON(404, util.DynMap{"status": "not-found", "message": dashboards.ErrFolderNotFound.Error()}) + return response.JSON(http.StatusNotFound, util.DynMap{"status": "not-found", "message": dashboards.ErrFolderNotFound.Error()}) } if errors.Is(err, dashboards.ErrFolderSameNameExists) || errors.Is(err, dashboards.ErrFolderWithSameUIDExists) { - return response.Error(409, err.Error(), nil) + return response.Error(http.StatusConflict, err.Error(), nil) } if errors.Is(err, dashboards.ErrFolderVersionMismatch) { - return response.JSON(412, util.DynMap{"status": "version-mismatch", "message": dashboards.ErrFolderVersionMismatch.Error()}) + return response.JSON(http.StatusPreconditionFailed, util.DynMap{"status": "version-mismatch", "message": dashboards.ErrFolderVersionMismatch.Error()}) } - return response.ErrOrFallback(500, "Folder API error", err) + return response.ErrOrFallback(http.StatusInternalServerError, "Folder API error", err) } diff --git a/pkg/api/apikey.go b/pkg/api/apikey.go index 2ca740ce5b0f7..fafaddecb6813 100644 --- a/pkg/api/apikey.go +++ b/pkg/api/apikey.go @@ -92,9 +92,9 @@ func (hs *HTTPServer) DeleteAPIKey(c *contextmodel.ReqContext) response.Response if err != nil { var status int if errors.Is(err, apikey.ErrNotFound) { - status = 404 + status = http.StatusNotFound } else { - status = 500 + status = http.StatusInternalServerError } return response.Error(status, "Failed to delete API key", err) } diff --git a/pkg/api/avatar/avatar.go b/pkg/api/avatar/avatar.go index d331cee8664b5..864d6187af072 100644 --- a/pkg/api/avatar/avatar.go +++ b/pkg/api/avatar/avatar.go @@ -107,7 +107,7 @@ func (a *AvatarCacheServer) Handler(ctx *contextmodel.ReqContext) { return } - avatar := a.GetAvatarForHash(hash) + avatar := a.GetAvatarForHash(a.cfg, hash) ctx.Resp.Header().Set("Content-Type", "image/jpeg") @@ -123,8 +123,8 @@ func (a *AvatarCacheServer) Handler(ctx *contextmodel.ReqContext) { } } -func (a *AvatarCacheServer) GetAvatarForHash(hash string) *Avatar { - if setting.DisableGravatar { +func (a *AvatarCacheServer) GetAvatarForHash(cfg *setting.Cfg, hash string) *Avatar { + if cfg.DisableGravatar { alog.Warn("'GetGravatarForHash' called despite gravatars being disabled; returning default profile image") return a.notFound } diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 5ae9b45c5fa65..fb408d33e1fec 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -258,7 +258,7 @@ func userWithPermissions(orgID int64, permissions []accesscontrol.Permission) *u return &user.SignedInUser{IsAnonymous: true, OrgID: orgID, OrgRole: org.RoleViewer, Permissions: map[int64]map[string][]string{orgID: accesscontrol.GroupScopesByAction(permissions)}} } -func setupSimpleHTTPServer(features *featuremgmt.FeatureManager) *HTTPServer { +func setupSimpleHTTPServer(features featuremgmt.FeatureToggles) *HTTPServer { if features == nil { features = featuremgmt.WithFeatures() } diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 01f47f9e0c174..bc24cc4d36563 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -14,11 +14,11 @@ import ( "github.com/grafana/grafana/pkg/api/apierrors" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" + dashboardsV0 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" "github.com/grafana/grafana/pkg/components/dashdiffs" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/auth/identity" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" @@ -97,7 +97,7 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response return response.Error(http.StatusInternalServerError, "Error while retrieving public dashboards", err) } - if publicDashboard != nil { + if publicDashboard != nil && (hs.License.FeatureEnabled(publicdashboardModels.FeaturePublicDashboardsEmailSharing) || publicDashboard.Share != publicdashboardModels.EmailShareType) { publicDashboardEnabled = publicDashboard.IsEnabled } } @@ -141,7 +141,7 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response creator = hs.getUserLogin(c.Req.Context(), dash.CreatedBy) } - annotationPermissions := &dtos.AnnotationPermission{} + annotationPermissions := &dashboardsV0.AnnotationPermission{} if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) { hs.getAnnotationPermissionsByScope(c, &annotationPermissions.Dashboard, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dash.UID)) } else { @@ -171,12 +171,13 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response AnnotationsPermissions: annotationPermissions, PublicDashboardEnabled: publicDashboardEnabled, } - + metrics.MFolderIDsAPICount.WithLabelValues(metrics.GetDashboard).Inc() // lookup folder title // nolint:staticcheck if dash.FolderID > 0 { // nolint:staticcheck query := dashboards.GetDashboardQuery{ID: dash.FolderID, OrgID: c.SignedInUser.GetOrgID()} + metrics.MFolderIDsAPICount.WithLabelValues(metrics.GetDashboard).Inc() queryResult, err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) if err != nil { if errors.Is(err, dashboards.ErrFolderNotFound) { @@ -223,7 +224,7 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response return response.JSON(http.StatusOK, dto) } -func (hs *HTTPServer) getAnnotationPermissionsByScope(c *contextmodel.ReqContext, actions *dtos.AnnotationActions, scope string) { +func (hs *HTTPServer) getAnnotationPermissionsByScope(c *contextmodel.ReqContext, actions *dashboardsV0.AnnotationActions, scope string) { var err error evaluate := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsCreate, scope) @@ -301,6 +302,10 @@ func (hs *HTTPServer) deleteDashboard(c *contextmodel.ReqContext) response.Respo return dashboardGuardianResponse(err) } + if dash.IsFolder { + return response.Error(http.StatusBadRequest, "Use folders endpoint for deleting folders.", nil) + } + namespaceID, userIDStr := c.SignedInUser.GetNamespacedID() // disconnect all library elements for this dashboard @@ -354,6 +359,7 @@ func (hs *HTTPServer) deleteDashboard(c *contextmodel.ReqContext) response.Respo // Create / Update dashboard // // Creates a new dashboard or updates an existing dashboard. +// Note: This endpoint is not intended for creating folders, use `POST /api/folders` for that. // // Responses: // 200: postDashboardResponse @@ -373,6 +379,10 @@ func (hs *HTTPServer) PostDashboard(c *contextmodel.ReqContext) response.Respons } func (hs *HTTPServer) postDashboard(c *contextmodel.ReqContext, cmd dashboards.SaveDashboardCommand) response.Response { + if cmd.IsFolder { + return response.Error(http.StatusBadRequest, "Use folders endpoint for saving folders.", nil) + } + ctx := c.Req.Context() var err error @@ -430,7 +440,7 @@ func (hs *HTTPServer) postDashboard(c *contextmodel.ReqContext, cmd dashboards.S Overwrite: cmd.Overwrite, } - dashboard, err := hs.DashboardService.SaveDashboard(alerting.WithUAEnabled(ctx, hs.Cfg.UnifiedAlerting.IsEnabled()), dashItem, allowUiUpdate) + dashboard, err := hs.DashboardService.SaveDashboard(ctx, dashItem, allowUiUpdate) if hs.Live != nil { // Tell everyone listening that the dashboard changed @@ -973,6 +983,7 @@ func (hs *HTTPServer) RestoreDashboardVersion(c *contextmodel.ReqContext) respon saveCmd.Message = fmt.Sprintf("Restored from version %d", version.Version) // nolint:staticcheck saveCmd.FolderID = dash.FolderID + metrics.MFolderIDsAPICount.WithLabelValues(metrics.RestoreDashboardVersion).Inc() saveCmd.FolderUID = dash.FolderUID return hs.postDashboard(c, saveCmd) @@ -1018,12 +1029,6 @@ func (hs *HTTPServer) GetDashboardUIDs(c *contextmodel.ReqContext) { c.JSON(http.StatusOK, uids) } -// swagger:parameters renderReportPDF -type RenderReportPDFParams struct { - // in:path - DashboardID int64 -} - // swagger:parameters restoreDashboardVersionByID type RestoreDashboardVersionByIDParams struct { // in:body diff --git a/pkg/api/dashboard_permission.go b/pkg/api/dashboard_permission.go index c4fa0c04114eb..1754ef752b7b6 100644 --- a/pkg/api/dashboard_permission.go +++ b/pkg/api/dashboard_permission.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/auth/identity" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" @@ -60,7 +61,7 @@ func (hs *HTTPServer) GetDashboardPermissionList(c *contextmodel.ReqContext) res acl, err := hs.getDashboardACL(c.Req.Context(), c.SignedInUser, dash) if err != nil { - return response.Error(500, "Failed to get dashboard permissions", err) + return response.Error(http.StatusInternalServerError, "Failed to get dashboard permissions", err) } filteredACLs := make([]*dashboards.DashboardACLInfoDTO, 0, len(acl)) @@ -69,10 +70,10 @@ func (hs *HTTPServer) GetDashboardPermissionList(c *contextmodel.ReqContext) res continue } - perm.UserAvatarURL = dtos.GetGravatarUrl(perm.UserEmail) + perm.UserAvatarURL = dtos.GetGravatarUrl(hs.Cfg, perm.UserEmail) if perm.TeamID > 0 { - perm.TeamAvatarURL = dtos.GetGravatarUrlWithDefault(perm.TeamEmail, perm.Team) + perm.TeamAvatarURL = dtos.GetGravatarUrlWithDefault(hs.Cfg, perm.TeamEmail, perm.Team) } if perm.Slug != "" { perm.URL = dashboards.GetDashboardFolderURL(perm.IsFolder, perm.UID, perm.Slug) @@ -123,7 +124,7 @@ func (hs *HTTPServer) UpdateDashboardPermissions(c *contextmodel.ReqContext) res return response.Error(http.StatusBadRequest, "bad request data", err) } if err := validatePermissionsUpdate(apiCmd); err != nil { - return response.Error(400, err.Error(), err) + return response.Error(http.StatusBadRequest, err.Error(), err) } dashUID := web.Params(c.Req)[":uid"] @@ -193,6 +194,7 @@ func (hs *HTTPServer) getDashboardACL(ctx context.Context, user identity.Request permission := dashboardPermissionMap[hs.dashboardPermissionsService.MapActions(p)] + metrics.MFolderIDsAPICount.WithLabelValues(metrics.GetDashboardACL).Inc() acl = append(acl, &dashboards.DashboardACLInfoDTO{ OrgID: dashboard.OrgID, DashboardID: dashboard.ID, diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index 2b0c5e5471b52..561b4158d3e50 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -1,8 +1,6 @@ package api import ( - "bytes" - "encoding/json" "errors" "fmt" "net/http" @@ -10,21 +8,36 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/components/simplejson" + dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/guardian" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/util/errutil/errhttp" "github.com/grafana/grafana/pkg/web" ) -var client = &http.Client{ - Timeout: time.Second * 5, - Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, +// r.Post("/api/snapshots/" +func (hs *HTTPServer) getCreatedSnapshotHandler() web.Handler { + if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesSnapshots) { + namespaceMapper := request.GetNamespaceMapper(hs.Cfg) + return func(w http.ResponseWriter, r *http.Request) { + user, err := appcontext.User(r.Context()) + if err != nil || user == nil { + errhttp.Write(r.Context(), fmt.Errorf("no user"), w) + return + } + r.URL.Path = "/apis/dashboardsnapshot.grafana.app/v0alpha1/namespaces/" + + namespaceMapper(user.OrgID) + "/dashboardsnapshots/create" + hs.clientConfigProvider.DirectlyServeHTTP(w, r) + } + } + return hs.CreateDashboardSnapshot } // swagger:route GET /snapshot/shared-options snapshots getSharingOptions @@ -43,58 +56,6 @@ func (hs *HTTPServer) GetSharingOptions(c *contextmodel.ReqContext) { }) } -type CreateExternalSnapshotResponse struct { - Key string `json:"key"` - DeleteKey string `json:"deleteKey"` - Url string `json:"url"` - DeleteUrl string `json:"deleteUrl"` -} - -func createExternalDashboardSnapshot(cmd dashboardsnapshots.CreateDashboardSnapshotCommand, externalSnapshotUrl string) (*CreateExternalSnapshotResponse, error) { - var createSnapshotResponse CreateExternalSnapshotResponse - message := map[string]any{ - "name": cmd.Name, - "expires": cmd.Expires, - "dashboard": cmd.Dashboard, - "key": cmd.Key, - "deleteKey": cmd.DeleteKey, - } - - messageBytes, err := simplejson.NewFromAny(message).Encode() - if err != nil { - return nil, err - } - - resp, err := client.Post(externalSnapshotUrl+"/api/snapshots", "application/json", bytes.NewBuffer(messageBytes)) - if err != nil { - return nil, err - } - defer func() { - if err := resp.Body.Close(); err != nil { - plog.Warn("Failed to close response body", "err", err) - } - }() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("create external snapshot response status code %d", resp.StatusCode) - } - - if err := json.NewDecoder(resp.Body).Decode(&createSnapshotResponse); err != nil { - return nil, err - } - - return &createSnapshotResponse, nil -} - -func createOriginalDashboardURL(cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) (string, error) { - dashUID := cmd.Dashboard.Get("uid").MustString("") - if ok := util.IsValidShortUID(dashUID); !ok { - return "", fmt.Errorf("invalid dashboard UID") - } - - return fmt.Sprintf("/d/%v", dashUID), nil -} - // swagger:route POST /snapshots snapshots createDashboardSnapshot // // When creating a snapshot using the API, you have to provide the full dashboard payload including the snapshot data. This endpoint is designed for the Grafana UI. @@ -106,95 +67,13 @@ func createOriginalDashboardURL(cmd *dashboardsnapshots.CreateDashboardSnapshotC // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) CreateDashboardSnapshot(c *contextmodel.ReqContext) response.Response { - if !hs.Cfg.SnapshotEnabled { - c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) - return nil - } - - cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{} - if err := web.Bind(c.Req, &cmd); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) - } - if cmd.Name == "" { - cmd.Name = "Unnamed snapshot" - } - - userID, err := identity.UserIdentifier(c.SignedInUser.GetNamespacedID()) - if err != nil { - return response.Error(http.StatusInternalServerError, - "Failed to create external snapshot", err) - } - - var snapshotUrl string - cmd.ExternalURL = "" - cmd.OrgID = c.SignedInUser.GetOrgID() - cmd.UserID = userID - originalDashboardURL, err := createOriginalDashboardURL(&cmd) - if err != nil { - return response.Error(http.StatusInternalServerError, "Invalid app URL", err) - } - - if cmd.External { - if !hs.Cfg.ExternalEnabled { - c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil) - return nil - } - - resp, err := createExternalDashboardSnapshot(cmd, hs.Cfg.ExternalSnapshotUrl) - if err != nil { - c.JsonApiErr(http.StatusInternalServerError, "Failed to create external snapshot", err) - return nil - } - - snapshotUrl = resp.Url - cmd.Key = resp.Key - cmd.DeleteKey = resp.DeleteKey - cmd.ExternalURL = resp.Url - cmd.ExternalDeleteURL = resp.DeleteUrl - cmd.Dashboard = simplejson.New() - - metrics.MApiDashboardSnapshotExternal.Inc() - } else { - cmd.Dashboard.SetPath([]string{"snapshot", "originalUrl"}, originalDashboardURL) - - if cmd.Key == "" { - var err error - cmd.Key, err = util.GetRandomString(32) - if err != nil { - c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err) - return nil - } - } - - if cmd.DeleteKey == "" { - var err error - cmd.DeleteKey, err = util.GetRandomString(32) - if err != nil { - c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err) - return nil - } - } - - snapshotUrl = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key) - - metrics.MApiDashboardSnapshotCreate.Inc() - } - - result, err := hs.dashboardsnapshotsService.CreateDashboardSnapshot(c.Req.Context(), &cmd) - if err != nil { - c.JsonApiErr(http.StatusInternalServerError, "Failed to create snapshot", err) - return nil - } - - c.JSON(http.StatusOK, util.DynMap{ - "key": cmd.Key, - "deleteKey": cmd.DeleteKey, - "url": snapshotUrl, - "deleteUrl": setting.ToAbsUrl("api/snapshots-delete/" + cmd.DeleteKey), - "id": result.ID, - }) - return nil +func (hs *HTTPServer) CreateDashboardSnapshot(c *contextmodel.ReqContext) { + dashboardsnapshots.CreateDashboardSnapshot(c, dashboardsnapshot.SnapshotSharingOptions{ + SnapshotsEnabled: hs.Cfg.SnapshotEnabled, + ExternalEnabled: hs.Cfg.ExternalEnabled, + ExternalSnapshotName: hs.Cfg.ExternalSnapshotName, + ExternalSnapshotURL: hs.Cfg.ExternalSnapshotUrl, + }, hs.dashboardsnapshotsService) } // GET /api/snapshots/:key @@ -229,7 +108,7 @@ func (hs *HTTPServer) GetDashboardSnapshot(c *contextmodel.ReqContext) response. // expired snapshots should also be removed from db if snapshot.Expires.Before(time.Now()) { - return response.Error(404, "Dashboard snapshot not found", err) + return response.Error(http.StatusNotFound, "Dashboard snapshot not found", err) } dto := dtos.DashboardFullWithMeta{ @@ -247,38 +126,6 @@ func (hs *HTTPServer) GetDashboardSnapshot(c *contextmodel.ReqContext) response. return response.JSON(http.StatusOK, dto).SetHeader("Cache-Control", "public, max-age=3600") } -func deleteExternalDashboardSnapshot(externalUrl string) error { - resp, err := client.Get(externalUrl) - if err != nil { - return err - } - - defer func() { - if err := resp.Body.Close(); err != nil { - plog.Warn("Failed to close response body", "err", err) - } - }() - - if resp.StatusCode == 200 { - return nil - } - - // Gracefully ignore "snapshot not found" errors as they could have already - // been removed either via the cleanup script or by request. - if resp.StatusCode == 500 { - var respJson map[string]any - if err := json.NewDecoder(resp.Body).Decode(&respJson); err != nil { - return err - } - - if respJson["message"] == "Failed to get dashboard snapshot" { - return nil - } - } - - return fmt.Errorf("unexpected response when deleting external snapshot, status code: %d", resp.StatusCode) -} - // swagger:route GET /snapshots-delete/{deleteKey} snapshots deleteDashboardSnapshotByDeleteKey // // Delete Snapshot by deleteKey. @@ -299,31 +146,19 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *contextmodel.ReqCont key := web.Params(c.Req)[":deleteKey"] if len(key) == 0 { - return response.Error(404, "Snapshot not found", nil) + return response.Error(http.StatusNotFound, "Snapshot not found", nil) } - query := &dashboardsnapshots.GetDashboardSnapshotQuery{DeleteKey: key} - queryResult, err := hs.dashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query) + err := dashboardsnapshots.DeleteWithKey(c.Req.Context(), key, hs.dashboardsnapshotsService) if err != nil { - return response.Err(err) - } - - if queryResult.External { - err := deleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL) - if err != nil { - return response.Error(500, "Failed to delete external dashboard", err) + if errors.Is(err, dashboardsnapshots.ErrBaseNotFound) { + return response.Error(http.StatusNotFound, "Snapshot not found", err) } - } - - cmd := &dashboardsnapshots.DeleteDashboardSnapshotCommand{DeleteKey: queryResult.DeleteKey} - - if err := hs.dashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil { - return response.Error(500, "Failed to delete dashboard snapshot", err) + return response.Error(http.StatusInternalServerError, "Failed to delete dashboard snapshot", err) } return response.JSON(http.StatusOK, util.DynMap{ "message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.", - "id": queryResult.ID, }) } @@ -357,8 +192,12 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *contextmodel.ReqContext) respon return response.Error(http.StatusNotFound, "Failed to get dashboard snapshot", nil) } + if queryResult.OrgID != c.OrgID { + return response.Error(http.StatusUnauthorized, "OrgID mismatch", nil) + } + if queryResult.External { - err := deleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL) + err := dashboardsnapshots.DeleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL) if err != nil { return response.Error(http.StatusInternalServerError, "Failed to delete external dashboard", err) } @@ -430,7 +269,7 @@ func (hs *HTTPServer) SearchDashboardSnapshots(c *contextmodel.ReqContext) respo searchQueryResult, err := hs.dashboardsnapshotsService.SearchDashboardSnapshots(c.Req.Context(), &searchQuery) if err != nil { - return response.Error(500, "Search failed", err) + return response.Error(http.StatusInternalServerError, "Search failed", err) } dto := make([]*dashboardsnapshots.DashboardSnapshotDTO, len(searchQueryResult)) diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go index d8023a9e83e31..3fd0553610e9e 100644 --- a/pkg/api/dashboard_snapshot_test.go +++ b/pkg/api/dashboard_snapshot_test.go @@ -9,13 +9,14 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/web/webtest" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db/dbtest" @@ -148,12 +149,11 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey sc.fakeReqWithParams("GET", sc.url, map[string]string{"deleteKey": "12345"}).exec() - require.Equal(t, 200, sc.resp.Code) + require.Equal(t, 200, sc.resp.Code, "BODY: "+sc.resp.Body.String()) respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) require.NoError(t, err) assert.True(t, strings.HasPrefix(respJSON.Get("message").MustString(), "Snapshot deleted")) - assert.Equal(t, 1, respJSON.Get("id").MustInt()) assert.Equal(t, http.MethodGet, externalRequest.Method) assert.Equal(t, ts.URL, fmt.Sprintf("http://%s", externalRequest.Host)) @@ -271,7 +271,7 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) { sc.handlerFunc = hs.DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() - assert.Equal(t, http.StatusNotFound, sc.resp.Code) + assert.Equal(t, http.StatusNotFound, sc.resp.Code, "BODY: "+sc.resp.Body.String()) }, sqlmock) loggedInUserScenarioWithRole(t, @@ -282,7 +282,7 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) { sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec() - assert.Equal(t, http.StatusNotFound, sc.resp.Code) + assert.Equal(t, http.StatusNotFound, sc.resp.Code, "BODY: "+sc.resp.Body.String()) }, sqlmock) } @@ -345,7 +345,7 @@ func TestGetDashboardSnapshotFailure(t *testing.T) { sc.handlerFunc = hs.DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() - assert.Equal(t, http.StatusForbidden, sc.resp.Code) + assert.Equal(t, http.StatusForbidden, sc.resp.Code, "BODY: "+sc.resp.Body.String()) }, sqlmock) loggedInUserScenarioWithRole(t, @@ -356,7 +356,7 @@ func TestGetDashboardSnapshotFailure(t *testing.T) { sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec() - assert.Equal(t, http.StatusInternalServerError, sc.resp.Code) + assert.Equal(t, http.StatusInternalServerError, sc.resp.Code, "BODY: "+sc.resp.Body.String()) }, sqlmock) loggedInUserScenarioWithRole(t, @@ -367,7 +367,7 @@ func TestGetDashboardSnapshotFailure(t *testing.T) { sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec() - assert.Equal(t, http.StatusForbidden, sc.resp.Code) + assert.Equal(t, http.StatusForbidden, sc.resp.Code, "BODY: "+sc.resp.Body.String()) }, sqlmock) } @@ -391,6 +391,7 @@ func setUpSnapshotTest(t *testing.T, userId int64, deleteUrl string) dashboardsn res := &dashboardsnapshots.DashboardSnapshot{ ID: 1, + OrgID: 1, Key: "12345", DeleteKey: "54321", Dashboard: jsonModel, diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 2f41f7b7ad205..c53c775426edb 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -28,7 +28,6 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" - "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/annotations/annotationstest" "github.com/grafana/grafana/pkg/services/auth/identity" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" @@ -44,6 +43,7 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/librarypanels" + "github.com/grafana/grafana/pkg/services/licensing/licensingtest" "github.com/grafana/grafana/pkg/services/live" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" @@ -52,8 +52,10 @@ import ( "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/publicdashboards" "github.com/grafana/grafana/pkg/services/publicdashboards/api" + publicdashboardModels "github.com/grafana/grafana/pkg/services/publicdashboards/models" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/star/startest" + "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" @@ -272,7 +274,9 @@ func TestHTTPServer_DeleteDashboardByUID_AccessControl(t *testing.T) { pubDashService := publicdashboards.NewFakePublicDashboardService(t) pubDashService.On("DeleteByDashboard", mock.Anything, mock.Anything).Return(nil).Maybe() middleware := publicdashboards.NewFakePublicDashboardMiddleware(t) - hs.PublicDashboardsApi = api.ProvideApi(pubDashService, nil, hs.AccessControl, featuremgmt.WithFeatures(), middleware, hs.Cfg) + license := licensingtest.NewFakeLicensing() + license.On("FeatureEnabled", publicdashboardModels.FeaturePublicDashboardsEmailSharing).Return(false) + hs.PublicDashboardsApi = api.ProvideApi(pubDashService, nil, hs.AccessControl, featuremgmt.WithFeatures(), middleware, hs.Cfg, license) guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService) }) @@ -474,7 +478,6 @@ func TestDashboardAPIEndpoint(t *testing.T) { {SaveError: dashboards.ErrDashboardVersionMismatch, ExpectedStatusCode: http.StatusPreconditionFailed}, {SaveError: dashboards.ErrDashboardTitleEmpty, ExpectedStatusCode: http.StatusBadRequest}, {SaveError: dashboards.ErrDashboardFolderCannotHaveParent, ExpectedStatusCode: http.StatusBadRequest}, - {SaveError: alerting.ValidationError{Reason: "Mu"}, ExpectedStatusCode: http.StatusUnprocessableEntity}, {SaveError: dashboards.ErrDashboardTypeMismatch, ExpectedStatusCode: http.StatusBadRequest}, {SaveError: dashboards.ErrDashboardFolderWithSameNameAsDashboard, ExpectedStatusCode: http.StatusBadRequest}, {SaveError: dashboards.ErrDashboardWithSameNameAsFolder, ExpectedStatusCode: http.StatusBadRequest}, @@ -825,18 +828,18 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr dashboardPermissions := accesscontrolmock.NewMockedPermissionsService() folderSvc := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), - cfg, dashboardStore, folderStore, db.InitTestDB(t), features, nil) + cfg, dashboardStore, folderStore, db.InitTestDB(t), features, supportbundlestest.NewFakeBundleService(), nil) if dashboardService == nil { dashboardService, err = service.ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, nil, features, folderPermissions, dashboardPermissions, + cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions, ac, folderSvc, nil, ) require.NoError(t, err) } dashboardProvisioningService, err := service.ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, nil, features, folderPermissions, dashboardPermissions, + cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions, ac, folderSvc, nil, ) require.NoError(t, err) diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index a7e9405550573..67f2d0dad3a46 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -51,12 +51,12 @@ func (hs *HTTPServer) GetDataSources(c *contextmodel.ReqContext) response.Respon dataSources, err := hs.DataSourcesService.GetDataSources(c.Req.Context(), &query) if err != nil { - return response.Error(500, "Failed to query datasources", err) + return response.Error(http.StatusInternalServerError, "Failed to query datasources", err) } filtered, err := hs.dsGuardian.New(c.SignedInUser.OrgID, c.SignedInUser).FilterDatasourcesByQueryPermissions(dataSources) if err != nil { - return response.Error(500, "Failed to query datasources", err) + return response.Error(http.StatusInternalServerError, "Failed to query datasources", err) } result := make(dtos.DataSourceList, 0) @@ -125,18 +125,18 @@ func (hs *HTTPServer) GetDataSourceById(c *contextmodel.ReqContext) response.Res dataSource, err := hs.DataSourcesService.GetDataSource(c.Req.Context(), &query) if err != nil { if errors.Is(err, datasources.ErrDataSourceNotFound) { - return response.Error(404, "Data source not found", nil) + return response.Error(http.StatusNotFound, "Data source not found", nil) } if errors.Is(err, datasources.ErrDataSourceIdentifierNotSet) { - return response.Error(400, "Datasource id is missing", nil) + return response.Error(http.StatusBadRequest, "Datasource id is missing", nil) } - return response.Error(500, "Failed to query datasources", err) + return response.Error(http.StatusInternalServerError, "Failed to query datasources", err) } dto := hs.convertModelToDtos(c.Req.Context(), dataSource) // Add accesscontrol metadata - dto.AccessControl = hs.getAccessControlMetadata(c, c.SignedInUser.GetOrgID(), datasources.ScopePrefix, dto.UID) + dto.AccessControl = hs.getAccessControlMetadata(c, datasources.ScopePrefix, dto.UID) return response.JSON(http.StatusOK, &dto) } @@ -165,19 +165,19 @@ func (hs *HTTPServer) DeleteDataSourceById(c *contextmodel.ReqContext) response. } if id <= 0 { - return response.Error(400, "Missing valid datasource id", nil) + return response.Error(http.StatusBadRequest, "Missing valid datasource id", nil) } ds, err := hs.getRawDataSourceById(c.Req.Context(), id, c.SignedInUser.GetOrgID()) if err != nil { if errors.Is(err, datasources.ErrDataSourceNotFound) { - return response.Error(404, "Data source not found", nil) + return response.Error(http.StatusNotFound, "Data source not found", nil) } - return response.Error(400, "Failed to delete datasource", nil) + return response.Error(http.StatusBadRequest, "Failed to delete datasource", nil) } if ds.ReadOnly { - return response.Error(403, "Cannot delete read-only data source", nil) + return response.Error(http.StatusForbidden, "Cannot delete read-only data source", nil) } cmd := &datasources.DeleteDataSourceCommand{ID: id, OrgID: c.SignedInUser.GetOrgID(), Name: ds.Name} @@ -185,9 +185,9 @@ func (hs *HTTPServer) DeleteDataSourceById(c *contextmodel.ReqContext) response. err = hs.DataSourcesService.DeleteDataSource(c.Req.Context(), cmd) if err != nil { if errors.As(err, &secretsPluginError) { - return response.Error(500, "Failed to delete datasource: "+err.Error(), err) + return response.Error(http.StatusInternalServerError, "Failed to delete datasource: "+err.Error(), err) } - return response.Error(500, "Failed to delete datasource", err) + return response.Error(http.StatusInternalServerError, "Failed to delete datasource", err) } hs.Live.HandleDatasourceDelete(c.SignedInUser.GetOrgID(), ds.UID) @@ -222,7 +222,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Re dto := hs.convertModelToDtos(c.Req.Context(), ds) // Add accesscontrol metadata - dto.AccessControl = hs.getAccessControlMetadata(c, c.SignedInUser.GetOrgID(), datasources.ScopePrefix, dto.UID) + dto.AccessControl = hs.getAccessControlMetadata(c, datasources.ScopePrefix, dto.UID) return response.JSON(http.StatusOK, &dto) } @@ -244,19 +244,19 @@ func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response uid := web.Params(c.Req)[":uid"] if uid == "" { - return response.Error(400, "Missing datasource uid", nil) + return response.Error(http.StatusBadRequest, "Missing datasource uid", nil) } ds, err := hs.getRawDataSourceByUID(c.Req.Context(), uid, c.SignedInUser.GetOrgID()) if err != nil { if errors.Is(err, datasources.ErrDataSourceNotFound) { - return response.Error(404, "Data source not found", nil) + return response.Error(http.StatusNotFound, "Data source not found", nil) } - return response.Error(400, "Failed to delete datasource", nil) + return response.Error(http.StatusBadRequest, "Failed to delete datasource", nil) } if ds.ReadOnly { - return response.Error(403, "Cannot delete read-only data source", nil) + return response.Error(http.StatusForbidden, "Cannot delete read-only data source", nil) } cmd := &datasources.DeleteDataSourceCommand{UID: uid, OrgID: c.SignedInUser.GetOrgID(), Name: ds.Name} @@ -264,9 +264,9 @@ func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response err = hs.DataSourcesService.DeleteDataSource(c.Req.Context(), cmd) if err != nil { if errors.As(err, &secretsPluginError) { - return response.Error(500, "Failed to delete datasource: "+err.Error(), err) + return response.Error(http.StatusInternalServerError, "Failed to delete datasource: "+err.Error(), err) } - return response.Error(500, "Failed to delete datasource", err) + return response.Error(http.StatusInternalServerError, "Failed to delete datasource", err) } hs.Live.HandleDatasourceDelete(c.SignedInUser.GetOrgID(), ds.UID) @@ -294,29 +294,29 @@ func (hs *HTTPServer) DeleteDataSourceByName(c *contextmodel.ReqContext) respons name := web.Params(c.Req)[":name"] if name == "" { - return response.Error(400, "Missing valid datasource name", nil) + return response.Error(http.StatusBadRequest, "Missing valid datasource name", nil) } getCmd := &datasources.GetDataSourceQuery{Name: name, OrgID: c.SignedInUser.GetOrgID()} dataSource, err := hs.DataSourcesService.GetDataSource(c.Req.Context(), getCmd) if err != nil { if errors.Is(err, datasources.ErrDataSourceNotFound) { - return response.Error(404, "Data source not found", nil) + return response.Error(http.StatusNotFound, "Data source not found", nil) } - return response.Error(500, "Failed to delete datasource", err) + return response.Error(http.StatusInternalServerError, "Failed to delete datasource", err) } if dataSource.ReadOnly { - return response.Error(403, "Cannot delete read-only data source", nil) + return response.Error(http.StatusForbidden, "Cannot delete read-only data source", nil) } cmd := &datasources.DeleteDataSourceCommand{Name: name, OrgID: c.SignedInUser.GetOrgID()} err = hs.DataSourcesService.DeleteDataSource(c.Req.Context(), cmd) if err != nil { if errors.As(err, &secretsPluginError) { - return response.Error(500, "Failed to delete datasource: "+err.Error(), err) + return response.Error(http.StatusInternalServerError, "Failed to delete datasource: "+err.Error(), err) } - return response.Error(500, "Failed to delete datasource", err) + return response.Error(http.StatusInternalServerError, "Failed to delete datasource", err) } hs.Live.HandleDatasourceDelete(c.SignedInUser.GetOrgID(), dataSource.UID) @@ -339,16 +339,16 @@ func validateURL(cmdType string, url string) response.Response { // validateJSONData prevents the user from adding a custom header with name that matches the auth proxy header name. // This is done to prevent data source proxy from being used to circumvent auth proxy. // For more context take a look at CVE-2022-35957 -func validateJSONData(ctx context.Context, jsonData *simplejson.Json, cfg *setting.Cfg, features *featuremgmt.FeatureManager) error { +func validateJSONData(ctx context.Context, jsonData *simplejson.Json, cfg *setting.Cfg, features featuremgmt.FeatureToggles) error { if jsonData == nil { return nil } - if cfg.AuthProxyEnabled { + if cfg.AuthProxy.Enabled { for key, value := range jsonData.MustMap() { if strings.HasPrefix(key, datasources.CustomHeaderName) { header := fmt.Sprint(value) - if http.CanonicalHeaderKey(header) == http.CanonicalHeaderKey(cfg.AuthProxyHeaderName) { + if http.CanonicalHeaderKey(header) == http.CanonicalHeaderKey(cfg.AuthProxy.HeaderName) { datasourcesLogger.Error("Forbidden to add a data source header with a name equal to auth proxy header name", "headerName", key) return errors.New("validation error, invalid header name specified") } @@ -528,9 +528,9 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response. ds, err := hs.getRawDataSourceById(c.Req.Context(), cmd.ID, cmd.OrgID) if err != nil { if errors.Is(err, datasources.ErrDataSourceNotFound) { - return response.Error(404, "Data source not found", nil) + return response.Error(http.StatusNotFound, "Data source not found", nil) } - return response.Error(500, "Failed to update datasource", err) + return response.Error(http.StatusInternalServerError, "Failed to update datasource", err) } // check if LBAC rules have been modified @@ -613,7 +613,7 @@ func checkTeamHTTPHeaderPermissions(hs *HTTPServer, c *contextmodel.ReqContext, func (hs *HTTPServer) updateDataSourceByID(c *contextmodel.ReqContext, ds *datasources.DataSource, cmd datasources.UpdateDataSourceCommand) response.Response { if ds.ReadOnly { - return response.Error(403, "Cannot update read-only data source", nil) + return response.Error(http.StatusForbidden, "Cannot update read-only data source", nil) } _, err := hs.DataSourcesService.UpdateDataSource(c.Req.Context(), &cmd) @@ -641,9 +641,9 @@ func (hs *HTTPServer) updateDataSourceByID(c *contextmodel.ReqContext, ds *datas dataSource, err := hs.DataSourcesService.GetDataSource(c.Req.Context(), &query) if err != nil { if errors.Is(err, datasources.ErrDataSourceNotFound) { - return response.Error(404, "Data source not found", nil) + return response.Error(http.StatusNotFound, "Data source not found", nil) } - return response.Error(500, "Failed to query datasource", err) + return response.Error(http.StatusInternalServerError, "Failed to query datasource", err) } datasourceDTO := hs.convertModelToDtos(c.Req.Context(), dataSource) @@ -704,9 +704,9 @@ func (hs *HTTPServer) GetDataSourceByName(c *contextmodel.ReqContext) response.R dataSource, err := hs.DataSourcesService.GetDataSource(c.Req.Context(), &query) if err != nil { if errors.Is(err, datasources.ErrDataSourceNotFound) { - return response.Error(404, "Data source not found", nil) + return response.Error(http.StatusNotFound, "Data source not found", nil) } - return response.Error(500, "Failed to query datasources", err) + return response.Error(http.StatusInternalServerError, "Failed to query datasources", err) } dto := hs.convertModelToDtos(c.Req.Context(), dataSource) @@ -732,9 +732,9 @@ func (hs *HTTPServer) GetDataSourceIdByName(c *contextmodel.ReqContext) response ds, err := hs.DataSourcesService.GetDataSource(c.Req.Context(), &query) if err != nil { if errors.Is(err, datasources.ErrDataSourceNotFound) { - return response.Error(404, "Data source not found", nil) + return response.Error(http.StatusNotFound, "Data source not found", nil) } - return response.Error(500, "Failed to query datasources", err) + return response.Error(http.StatusInternalServerError, "Failed to query datasources", err) } dtos := dtos.AnyId{ diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index 8ede32a84998b..61481bec706e1 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -147,10 +147,10 @@ func TestAddDataSource_InvalidJSONData(t *testing.T) { sc := setupScenarioContext(t, "/api/datasources") hs.Cfg = setting.NewCfg() - hs.Cfg.AuthProxyEnabled = true - hs.Cfg.AuthProxyHeaderName = "X-AUTH-PROXY-HEADER" + hs.Cfg.AuthProxy.Enabled = true + hs.Cfg.AuthProxy.HeaderName = "X-AUTH-PROXY-HEADER" jsonData := simplejson.New() - jsonData.Set("httpHeaderName1", hs.Cfg.AuthProxyHeaderName) + jsonData.Set("httpHeaderName1", hs.Cfg.AuthProxy.HeaderName) sc.m.Post(sc.url, routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(datasources.AddDataSourceCommand{ @@ -201,10 +201,10 @@ func TestUpdateDataSource_InvalidJSONData(t *testing.T) { } sc := setupScenarioContext(t, "/api/datasources/1234") - hs.Cfg.AuthProxyEnabled = true - hs.Cfg.AuthProxyHeaderName = "X-AUTH-PROXY-HEADER" + hs.Cfg.AuthProxy.Enabled = true + hs.Cfg.AuthProxy.HeaderName = "X-AUTH-PROXY-HEADER" jsonData := simplejson.New() - jsonData.Set("httpHeaderName1", hs.Cfg.AuthProxyHeaderName) + jsonData.Set("httpHeaderName1", hs.Cfg.AuthProxy.HeaderName) sc.m.Put(sc.url, routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(datasources.AddDataSourceCommand{ @@ -297,7 +297,7 @@ func TestUpdateDataSourceTeamHTTPHeaders_InvalidJSONData(t *testing.T) { }, } sc := setupScenarioContext(t, fmt.Sprintf("/api/datasources/%s", tenantID)) - hs.Cfg.AuthProxyEnabled = true + hs.Cfg.AuthProxy.Enabled = true jsonData := simplejson.New() jsonData.Set("teamHttpHeaders", tc.data) diff --git a/pkg/api/dtos/alerting.go b/pkg/api/dtos/alerting.go deleted file mode 100644 index 5c0685b1be8bf..0000000000000 --- a/pkg/api/dtos/alerting.go +++ /dev/null @@ -1,137 +0,0 @@ -package dtos - -import ( - "fmt" - "time" - - "github.com/grafana/grafana/pkg/components/null" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting/models" -) - -func formatShort(interval time.Duration) string { - var result string - - hours := interval / time.Hour - if hours > 0 { - result += fmt.Sprintf("%dh", hours) - } - - remaining := interval - (hours * time.Hour) - mins := remaining / time.Minute - if mins > 0 { - result += fmt.Sprintf("%dm", mins) - } - - remaining -= (mins * time.Minute) - seconds := remaining / time.Second - if seconds > 0 { - result += fmt.Sprintf("%ds", seconds) - } - - return result -} - -func NewAlertNotification(notification *models.AlertNotification) *AlertNotification { - dto := &AlertNotification{ - Id: notification.ID, - Uid: notification.UID, - Name: notification.Name, - Type: notification.Type, - IsDefault: notification.IsDefault, - Created: notification.Created, - Updated: notification.Updated, - Frequency: formatShort(notification.Frequency), - SendReminder: notification.SendReminder, - DisableResolveMessage: notification.DisableResolveMessage, - Settings: notification.Settings, - SecureFields: map[string]bool{}, - } - - if notification.SecureSettings != nil { - for k := range notification.SecureSettings { - dto.SecureFields[k] = true - } - } - - return dto -} - -type AlertNotification struct { - Id int64 `json:"id"` - Uid string `json:"uid"` - Name string `json:"name"` - Type string `json:"type"` - IsDefault bool `json:"isDefault"` - SendReminder bool `json:"sendReminder"` - DisableResolveMessage bool `json:"disableResolveMessage"` - Frequency string `json:"frequency"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` - Settings *simplejson.Json `json:"settings"` - SecureFields map[string]bool `json:"secureFields"` -} - -func NewAlertNotificationLookup(notification *models.AlertNotification) *AlertNotificationLookup { - return &AlertNotificationLookup{ - Id: notification.ID, - Uid: notification.UID, - Name: notification.Name, - Type: notification.Type, - IsDefault: notification.IsDefault, - } -} - -type AlertNotificationLookup struct { - Id int64 `json:"id"` - Uid string `json:"uid"` - Name string `json:"name"` - Type string `json:"type"` - IsDefault bool `json:"isDefault"` -} - -type AlertTestCommand struct { - Dashboard *simplejson.Json `json:"dashboard" binding:"Required"` - PanelId int64 `json:"panelId" binding:"Required"` -} - -type AlertTestResult struct { - Firing bool `json:"firing"` - State models.AlertStateType `json:"state"` - ConditionEvals string `json:"conditionEvals"` - TimeMs string `json:"timeMs"` - Error string `json:"error,omitempty"` - EvalMatches []*EvalMatch `json:"matches,omitempty"` - Logs []*AlertTestResultLog `json:"logs,omitempty"` -} - -type AlertTestResultLog struct { - Message string `json:"message"` - Data any `json:"data"` -} - -type EvalMatch struct { - Tags map[string]string `json:"tags,omitempty"` - Metric string `json:"metric"` - Value null.Float `json:"value"` -} - -type NotificationTestCommand struct { - ID int64 `json:"id,omitempty"` - Name string `json:"name"` - Type string `json:"type"` - SendReminder bool `json:"sendReminder"` - DisableResolveMessage bool `json:"disableResolveMessage"` - Frequency string `json:"frequency"` - Settings *simplejson.Json `json:"settings"` - SecureSettings map[string]string `json:"secureSettings"` -} - -type PauseAlertCommand struct { - AlertId int64 `json:"alertId"` - Paused bool `json:"paused"` -} - -type PauseAllAlertsCommand struct { - Paused bool `json:"paused"` -} diff --git a/pkg/api/dtos/alerting_test.go b/pkg/api/dtos/alerting_test.go deleted file mode 100644 index f4c09f202cbb0..0000000000000 --- a/pkg/api/dtos/alerting_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package dtos - -import ( - "testing" - "time" -) - -func TestFormatShort(t *testing.T) { - tcs := []struct { - interval time.Duration - expected string - }{ - {interval: time.Hour, expected: "1h"}, - {interval: time.Hour + time.Minute, expected: "1h1m"}, - {interval: (time.Hour * 10) + time.Minute, expected: "10h1m"}, - {interval: (time.Hour * 10) + (time.Minute * 10) + time.Second, expected: "10h10m1s"}, - {interval: time.Minute * 10, expected: "10m"}, - } - - for _, tc := range tcs { - got := formatShort(tc.interval) - if got != tc.expected { - t.Errorf("expected %s got %s interval: %v", tc.expected, got, tc.interval) - } - - parsed, err := time.ParseDuration(tc.expected) - if err != nil { - t.Fatalf("could not parse expected duration") - } - - if parsed != tc.interval { - t.Errorf("expects the parsed duration to equal the interval. Got %v expected: %v", parsed, tc.interval) - } - } -} diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index a00b08bce5b4b..3cc53476a3a51 100644 --- a/pkg/api/dtos/dashboard.go +++ b/pkg/api/dtos/dashboard.go @@ -3,6 +3,7 @@ package dtos import ( "time" + dashboardsV0 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" "github.com/grafana/grafana/pkg/components/simplejson" ) @@ -26,25 +27,15 @@ type DashboardMeta struct { HasACL bool `json:"hasAcl" xorm:"has_acl"` IsFolder bool `json:"isFolder"` // Deprecated: use FolderUID instead - FolderId int64 `json:"folderId"` - FolderUid string `json:"folderUid"` - FolderTitle string `json:"folderTitle"` - FolderUrl string `json:"folderUrl"` - Provisioned bool `json:"provisioned"` - ProvisionedExternalId string `json:"provisionedExternalId"` - AnnotationsPermissions *AnnotationPermission `json:"annotationsPermissions"` - PublicDashboardUID string `json:"publicDashboardUid,omitempty"` - PublicDashboardEnabled bool `json:"publicDashboardEnabled,omitempty"` -} -type AnnotationPermission struct { - Dashboard AnnotationActions `json:"dashboard"` - Organization AnnotationActions `json:"organization"` -} - -type AnnotationActions struct { - CanAdd bool `json:"canAdd"` - CanEdit bool `json:"canEdit"` - CanDelete bool `json:"canDelete"` + FolderId int64 `json:"folderId"` + FolderUid string `json:"folderUid"` + FolderTitle string `json:"folderTitle"` + FolderUrl string `json:"folderUrl"` + Provisioned bool `json:"provisioned"` + ProvisionedExternalId string `json:"provisionedExternalId"` + AnnotationsPermissions *dashboardsV0.AnnotationPermission `json:"annotationsPermissions"` + PublicDashboardUID string `json:"publicDashboardUid,omitempty"` + PublicDashboardEnabled bool `json:"publicDashboardEnabled,omitempty"` } type DashboardFullWithMeta struct { diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index 5d60f6e320a3b..d1b30c6fdc7ba 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -29,12 +29,22 @@ type FrontendSettingsAuthDTO struct { GitLabSkipOrgRoleSync bool `json:"GitLabSkipOrgRoleSync"` // Deprecated: this is no longer used and will be removed in Grafana 11 OktaSkipOrgRoleSync bool `json:"OktaSkipOrgRoleSync"` + + DisableLogin bool `json:"disableLogin"` + BasicAuthStrongPasswordPolicy bool `json:"basicAuthStrongPasswordPolicy"` } type FrontendSettingsBuildInfoDTO struct { - HideVersion bool `json:"hideVersion"` - Version string `json:"version"` + HideVersion bool `json:"hideVersion"` + + // A semver-ish version string, such as "11.0.0-12345" + Version string `json:"version"` + + // A branded version string to show in the UI, such as "Grafana v11.0.0-12345" + VersionString string `json:"versionString,omitempty"` + Commit string `json:"commit"` + CommitShort string `json:"commitShort"` Buildstamp int64 `json:"buildstamp"` Edition string `json:"edition"` LatestVersion string `json:"latestVersion"` @@ -138,24 +148,20 @@ type FrontendSettingsSqlConnectionLimitsDTO struct { } type FrontendSettingsDTO struct { - DefaultDatasource string `json:"defaultDatasource"` - Datasources map[string]plugins.DataSourceDTO `json:"datasources"` - MinRefreshInterval string `json:"minRefreshInterval"` - Panels map[string]plugins.PanelDTO `json:"panels"` - Apps map[string]*plugins.AppDTO `json:"apps"` - AppUrl string `json:"appUrl"` - AppSubUrl string `json:"appSubUrl"` - AllowOrgCreate bool `json:"allowOrgCreate"` - AuthProxyEnabled bool `json:"authProxyEnabled"` - LdapEnabled bool `json:"ldapEnabled"` - JwtHeaderName string `json:"jwtHeaderName"` - JwtUrlLogin bool `json:"jwtUrlLogin"` - AlertingEnabled bool `json:"alertingEnabled"` - AlertingErrorOrTimeout string `json:"alertingErrorOrTimeout"` - AlertingNoDataOrNullValues string `json:"alertingNoDataOrNullValues"` - AlertingMinInterval int64 `json:"alertingMinInterval"` - LiveEnabled bool `json:"liveEnabled"` - AutoAssignOrg bool `json:"autoAssignOrg"` + DefaultDatasource string `json:"defaultDatasource"` + Datasources map[string]plugins.DataSourceDTO `json:"datasources"` + MinRefreshInterval string `json:"minRefreshInterval"` + Panels map[string]plugins.PanelDTO `json:"panels"` + Apps map[string]*plugins.AppDTO `json:"apps"` + AppUrl string `json:"appUrl"` + AppSubUrl string `json:"appSubUrl"` + AllowOrgCreate bool `json:"allowOrgCreate"` + AuthProxyEnabled bool `json:"authProxyEnabled"` + LdapEnabled bool `json:"ldapEnabled"` + JwtHeaderName string `json:"jwtHeaderName"` + JwtUrlLogin bool `json:"jwtUrlLogin"` + LiveEnabled bool `json:"liveEnabled"` + AutoAssignOrg bool `json:"autoAssignOrg"` VerifyEmailEnabled bool `json:"verifyEmailEnabled"` SigV4AuthEnabled bool `json:"sigV4AuthEnabled"` @@ -206,6 +212,9 @@ type FrontendSettingsDTO struct { AnonymousDeviceLimit int64 `json:"anonymousDeviceLimit"` RendererAvailable bool `json:"rendererAvailable"` RendererVersion string `json:"rendererVersion"` + RendererDefaultImageWidth int `json:"rendererDefaultImageWidth"` + RendererDefaultImageHeight int `json:"rendererDefaultImageHeight"` + RendererDefaultImageScale float64 `json:"rendererDefaultImageScale"` SecretsManagerPluginEnabled bool `json:"secretsManagerPluginEnabled"` Http2Enabled bool `json:"http2Enabled"` GrafanaJavascriptAgent setting.GrafanaJavascriptAgent `json:"grafanaJavascriptAgent"` @@ -233,6 +242,7 @@ type FrontendSettingsDTO struct { SamlName string `json:"samlName"` TokenExpirationDayLimit int `json:"tokenExpirationDayLimit"` SharedWithMeFolderUID string `json:"sharedWithMeFolderUID"` + RootFolderUID string `json:"rootFolderUID"` GeomapDefaultBaseLayerConfig *map[string]any `json:"geomapDefaultBaseLayerConfig,omitempty"` GeomapDisableCustomBaseLayer bool `json:"geomapDisableCustomBaseLayer"` @@ -240,6 +250,8 @@ type FrontendSettingsDTO struct { PublicDashboardAccessToken string `json:"publicDashboardAccessToken"` PublicDashboardsEnabled bool `json:"publicDashboardsEnabled"` + CloudMigrationIsTarget bool `json:"cloudMigrationIsTarget"` + DateFormats setting.DateFormats `json:"dateFormats,omitempty"` LoginError string `json:"loginError,omitempty"` @@ -254,4 +266,9 @@ type FrontendSettingsDTO struct { // Enterprise Licensing *FrontendSettingsLicensingDTO `json:"licensing,omitempty"` Whitelabeling *FrontendSettingsWhitelabelingDTO `json:"whitelabeling,omitempty"` + + LocalFileSystemAvailable bool `json:"localFileSystemAvailable"` + // Experimental Scope settings + ListScopesEndpoint string `json:"listScopesEndpoint"` + ListDashboardScopesEndpoint string `json:"listDashboardScopesEndpoint"` } diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index ff3aa17d5ada1..89d5bff68824c 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -26,7 +26,6 @@ type IndexViewData struct { FavIcon template.URL AppleTouchIcon template.URL AppTitle string - ContentDeliveryURL string LoadingLogo template.URL CSPContent string CSPEnabled bool @@ -34,16 +33,29 @@ type IndexViewData struct { // Nonce is a cryptographic identifier for use with Content Security Policy. Nonce string NewsFeedEnabled bool - Assets *EntryPointAssets + Assets *EntryPointAssets // Includes CDN info } type EntryPointAssets struct { - JSFiles []EntryPointAsset - CSSDark string - CSSLight string + ContentDeliveryURL string `json:"cdn,omitempty"` + JSFiles []EntryPointAsset `json:"jsFiles"` + Dark string `json:"dark"` + Light string `json:"light"` } type EntryPointAsset struct { - FilePath string - Integrity string + FilePath string `json:"filePath"` + Integrity string `json:"integrity"` +} + +func (a *EntryPointAssets) SetContentDeliveryURL(prefix string) { + if prefix == "" { + return + } + a.ContentDeliveryURL = prefix + a.Dark = prefix + a.Dark + a.Light = prefix + a.Light + for i, p := range a.JSFiles { + a.JSFiles[i].FilePath = prefix + p.FilePath + } } diff --git a/pkg/api/dtos/invite.go b/pkg/api/dtos/invite.go index 9586ba95cbd77..a0fe16472ebc0 100644 --- a/pkg/api/dtos/invite.go +++ b/pkg/api/dtos/invite.go @@ -1,6 +1,9 @@ package dtos -import "github.com/grafana/grafana/pkg/services/org" +import ( + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" +) type AddInviteForm struct { LoginOrEmail string `json:"loginOrEmail" binding:"Required"` @@ -17,10 +20,10 @@ type InviteInfo struct { } type CompleteInviteForm struct { - InviteCode string `json:"inviteCode"` - Email string `json:"email" binding:"Required"` - Name string `json:"name"` - Username string `json:"username"` - Password string `json:"password"` - ConfirmPassword string `json:"confirmPassword"` + InviteCode string `json:"inviteCode"` + Email string `json:"email" binding:"Required"` + Name string `json:"name"` + Username string `json:"username"` + Password user.Password `json:"password"` + ConfirmPassword user.Password `json:"confirmPassword"` } diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go index 64e868239a79a..8933f8b49bb79 100644 --- a/pkg/api/dtos/models.go +++ b/pkg/api/dtos/models.go @@ -30,6 +30,7 @@ type LoginCommand struct { type CurrentUser struct { IsSignedIn bool `json:"isSignedIn"` Id int64 `json:"id"` + UID string `json:"uid"` Login string `json:"login"` Email string `json:"email"` Name string `json:"name"` @@ -108,9 +109,9 @@ func (mr *MetricRequest) CloneWithQueries(queries []*simplejson.Json) MetricRequ } } -func GetGravatarUrl(text string) string { - if setting.DisableGravatar { - return setting.AppSubUrl + "/public/img/user_profile.png" +func GetGravatarUrl(cfg *setting.Cfg, text string) string { + if cfg.DisableGravatar { + return cfg.AppSubURL + "/public/img/user_profile.png" } if text == "" { @@ -118,7 +119,7 @@ func GetGravatarUrl(text string) string { } hash, _ := GetGravatarHash(text) - return fmt.Sprintf(setting.AppSubUrl+"/avatar/%x", hash) + return fmt.Sprintf(cfg.AppSubURL+"/avatar/%x", hash) } func GetGravatarHash(text string) ([]byte, bool) { @@ -133,14 +134,14 @@ func GetGravatarHash(text string) ([]byte, bool) { return hasher.Sum(nil), true } -func GetGravatarUrlWithDefault(text string, defaultText string) string { +func GetGravatarUrlWithDefault(cfg *setting.Cfg, text string, defaultText string) string { if text != "" { - return GetGravatarUrl(text) + return GetGravatarUrl(cfg, text) } text = regNonAlphaNumeric.ReplaceAllString(defaultText, "") + "@localhost" - return GetGravatarUrl(text) + return GetGravatarUrl(cfg, text) } func IsHiddenUser(userLogin string, signedInUser identity.Requester, cfg *setting.Cfg) bool { diff --git a/pkg/api/dtos/plugins.go b/pkg/api/dtos/plugins.go index 0f50b6cbd7cb5..7d768e059e4d5 100644 --- a/pkg/api/dtos/plugins.go +++ b/pkg/api/dtos/plugins.go @@ -2,7 +2,7 @@ package dtos import ( "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/plugindef" + "github.com/grafana/grafana/pkg/plugins/pfs" "github.com/grafana/grafana/pkg/services/accesscontrol" ) @@ -48,7 +48,7 @@ type PluginListItem struct { SignatureOrg string `json:"signatureOrg"` AccessControl accesscontrol.Metadata `json:"accessControl,omitempty"` AngularDetected bool `json:"angularDetected"` - IAM *plugindef.IAM `json:"iam,omitempty"` + IAM *pfs.IAM `json:"iam,omitempty"` } type PluginList []PluginListItem diff --git a/pkg/api/dtos/user.go b/pkg/api/dtos/user.go index 2cd16df1c0204..35cdb3c78b039 100644 --- a/pkg/api/dtos/user.go +++ b/pkg/api/dtos/user.go @@ -1,28 +1,30 @@ package dtos +import "github.com/grafana/grafana/pkg/services/user" + type SignUpForm struct { Email string `json:"email" binding:"Required"` } type SignUpStep2Form struct { - Email string `json:"email"` - Name string `json:"name"` - Username string `json:"username"` - Password string `json:"password"` - Code string `json:"code"` - OrgName string `json:"orgName"` + Email string `json:"email"` + Name string `json:"name"` + Username string `json:"username"` + Password user.Password `json:"password"` + Code string `json:"code"` + OrgName string `json:"orgName"` } type AdminCreateUserForm struct { - Email string `json:"email"` - Login string `json:"login"` - Name string `json:"name"` - Password string `json:"password" binding:"Required"` - OrgId int64 `json:"orgId"` + Email string `json:"email"` + Login string `json:"login"` + Name string `json:"name"` + Password user.Password `json:"password" binding:"Required"` + OrgId int64 `json:"orgId"` } type AdminUpdateUserPasswordForm struct { - Password string `json:"password" binding:"Required"` + Password user.Password `json:"password" binding:"Required"` } type AdminUpdateUserPermissionsForm struct { @@ -34,9 +36,9 @@ type SendResetPasswordEmailForm struct { } type ResetUserPasswordForm struct { - Code string `json:"code"` - NewPassword string `json:"newPassword"` - ConfirmPassword string `json:"confirmPassword"` + Code string `json:"code"` + NewPassword user.Password `json:"newPassword"` + ConfirmPassword user.Password `json:"confirmPassword"` } type UserLookupDTO struct { diff --git a/pkg/api/fakes.go b/pkg/api/fakes.go index 209a27f03048f..28b1a74744314 100644 --- a/pkg/api/fakes.go +++ b/pkg/api/fakes.go @@ -30,7 +30,7 @@ func (pm *fakePluginInstaller) Add(_ context.Context, pluginID, version string, return nil } -func (pm *fakePluginInstaller) Remove(_ context.Context, pluginID string) error { +func (pm *fakePluginInstaller) Remove(_ context.Context, pluginID, _ string) error { delete(pm.plugins, pluginID) return nil } diff --git a/pkg/api/featuremgmt.go b/pkg/api/featuremgmt.go deleted file mode 100644 index 6ebebf63d51f7..0000000000000 --- a/pkg/api/featuremgmt.go +++ /dev/null @@ -1,160 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "sort" - "strconv" - - "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/infra/log" - contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/web" -) - -func (hs *HTTPServer) GetFeatureToggles(ctx *contextmodel.ReqContext) response.Response { - cfg := hs.Cfg.FeatureManagement - enabledFeatures := hs.Features.GetEnabled(ctx.Req.Context()) - - // object being returned - dtos := make([]featuremgmt.FeatureToggleDTO, 0) - - // loop through features an add features that should be visible to dtos - for _, ft := range hs.Features.GetFlags() { - if isFeatureHidden(ft, cfg.HiddenToggles) { - continue - } - dto := featuremgmt.FeatureToggleDTO{ - Name: ft.Name, - Description: ft.Description, - Enabled: enabledFeatures[ft.Name], - ReadOnly: !isFeatureWriteable(ft, cfg.ReadOnlyToggles) || !isFeatureEditingAllowed(*hs.Cfg), - } - - dtos = append(dtos, dto) - sort.Slice(dtos, func(i, j int) bool { - return dtos[i].Name < dtos[j].Name - }) - } - - return response.JSON(http.StatusOK, dtos) -} - -func (hs *HTTPServer) UpdateFeatureToggle(ctx *contextmodel.ReqContext) response.Response { - featureMgmtCfg := hs.Cfg.FeatureManagement - if !featureMgmtCfg.AllowEditing { - return response.Error(http.StatusForbidden, "feature toggles are read-only", fmt.Errorf("feature toggles are configured to be read-only")) - } - - if featureMgmtCfg.UpdateWebhook == "" { - return response.Error(http.StatusInternalServerError, "feature toggles service is misconfigured", fmt.Errorf("[feature_management]update_webhook is not set")) - } - - cmd := featuremgmt.UpdateFeatureTogglesCommand{} - if err := web.Bind(ctx.Req, &cmd); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) - } - - payload := UpdatePayload{ - FeatureToggles: make(map[string]string, len(cmd.FeatureToggles)), - User: ctx.SignedInUser.Email, - } - - for _, t := range cmd.FeatureToggles { - // make sure flag exists, and only continue if flag is writeable - if f, ok := hs.Features.LookupFlag(t.Name); ok && isFeatureWriteable(f, hs.Cfg.FeatureManagement.ReadOnlyToggles) { - hs.log.Info("UpdateFeatureToggle: updating toggle", "toggle_name", t.Name, "enabled", t.Enabled, "username", ctx.SignedInUser.Login) - payload.FeatureToggles[t.Name] = strconv.FormatBool(t.Enabled) - } else { - hs.log.Warn("UpdateFeatureToggle: invalid toggle passed in", "toggle_name", t.Name) - return response.Error(http.StatusBadRequest, "invalid toggle passed in", fmt.Errorf("invalid toggle passed in: %s", t.Name)) - } - } - - err := sendWebhookUpdate(featureMgmtCfg, payload, hs.log) - if err != nil { - hs.log.Error("UpdateFeatureToggle: Failed to perform webhook request", "error", err) - return response.Respond(http.StatusBadRequest, "Failed to perform webhook request") - } - - hs.Features.SetRestartRequired() - - return response.Respond(http.StatusOK, "feature toggles updated successfully") -} - -func (hs *HTTPServer) GetFeatureMgmtState(ctx *contextmodel.ReqContext) response.Response { - fmState := hs.Features.GetState() - return response.Respond(http.StatusOK, fmState) -} - -// isFeatureHidden returns whether a toggle should be hidden from the admin page. -// filters out statuses Unknown, Experimental, and Private Preview -func isFeatureHidden(flag featuremgmt.FeatureFlag, hideCfg map[string]struct{}) bool { - if _, ok := hideCfg[flag.Name]; ok { - return true - } - return flag.Stage == featuremgmt.FeatureStageUnknown || flag.Stage == featuremgmt.FeatureStageExperimental || flag.Stage == featuremgmt.FeatureStagePrivatePreview || flag.HideFromAdminPage -} - -// isFeatureWriteable returns whether a toggle on the admin page can be updated by the user. -// only allows writing of GA and Deprecated toggles, and excludes the feature toggle admin page toggle -func isFeatureWriteable(flag featuremgmt.FeatureFlag, readOnlyCfg map[string]struct{}) bool { - if _, ok := readOnlyCfg[flag.Name]; ok { - return false - } - if flag.Name == featuremgmt.FlagFeatureToggleAdminPage { - return false - } - return (flag.Stage == featuremgmt.FeatureStageGeneralAvailability || flag.Stage == featuremgmt.FeatureStageDeprecated) && flag.AllowSelfServe -} - -// isFeatureEditingAllowed checks if the backend is properly configured to allow feature toggle changes from the UI -func isFeatureEditingAllowed(cfg setting.Cfg) bool { - return cfg.FeatureManagement.AllowEditing && cfg.FeatureManagement.UpdateWebhook != "" -} - -type UpdatePayload struct { - FeatureToggles map[string]string `json:"feature_toggles"` - User string `json:"user"` -} - -func sendWebhookUpdate(cfg setting.FeatureMgmtSettings, payload UpdatePayload, logger log.Logger) error { - data, err := json.Marshal(payload) - if err != nil { - return err - } - - req, err := http.NewRequest(http.MethodPost, cfg.UpdateWebhook, bytes.NewBuffer(data)) - if err != nil { - return err - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+cfg.UpdateWebhookToken) - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return err - } - defer func() { - if err := resp.Body.Close(); err != nil { - logger.Warn("Failed to close response body", "err", err) - } - }() - - if resp.StatusCode >= http.StatusBadRequest { - if body, err := io.ReadAll(resp.Body); err != nil { - return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %s", resp.StatusCode, string(body)) - } else { - return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %w", resp.StatusCode, err) - } - } - - return nil -} diff --git a/pkg/api/featuremgmt_test.go b/pkg/api/featuremgmt_test.go deleted file mode 100644 index 1b2b24cea128f..0000000000000 --- a/pkg/api/featuremgmt_test.go +++ /dev/null @@ -1,499 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "testing" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/org/orgtest" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/services/user/usertest" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/web/webtest" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGetFeatureToggles(t *testing.T) { - readPermissions := []accesscontrol.Permission{{Action: accesscontrol.ActionFeatureManagementRead}} - - t.Run("should not be able to get feature toggles without permissions", func(t *testing.T) { - result := runGetScenario(t, []*featuremgmt.FeatureFlag{}, setting.FeatureMgmtSettings{}, []accesscontrol.Permission{}, http.StatusForbidden) - assert.Len(t, result, 0) - }) - - t.Run("should be able to get feature toggles with correct permissions", func(t *testing.T) { - features := []*featuremgmt.FeatureFlag{ - { - Name: "toggle1", - Enabled: true, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, { - Name: "toggle2", - Enabled: false, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, - } - - result := runGetScenario(t, features, setting.FeatureMgmtSettings{}, readPermissions, http.StatusOK) - assert.Len(t, result, 2) - t1, _ := findResult(t, result, "toggle1") - assert.True(t, t1.Enabled) - t2, _ := findResult(t, result, "toggle2") - assert.False(t, t2.Enabled) - }) - - t.Run("toggles hidden by config are not present in the response", func(t *testing.T) { - features := []*featuremgmt.FeatureFlag{ - { - Name: "toggle1", - Enabled: true, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, { - Name: "toggle2", - Enabled: false, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, - } - settings := setting.FeatureMgmtSettings{ - HiddenToggles: map[string]struct{}{"toggle1": {}}, - } - - result := runGetScenario(t, features, settings, readPermissions, http.StatusOK) - assert.Len(t, result, 1) - assert.Equal(t, "toggle2", result[0].Name) - }) - - t.Run("toggles that are read-only by config have the readOnly field set", func(t *testing.T) { - features := []*featuremgmt.FeatureFlag{ - { - Name: "toggle1", - Enabled: true, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, { - Name: "toggle2", - Enabled: false, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, - } - settings := setting.FeatureMgmtSettings{ - HiddenToggles: map[string]struct{}{"toggle1": {}}, - ReadOnlyToggles: map[string]struct{}{"toggle2": {}}, - AllowEditing: true, - UpdateWebhook: "bogus", - } - - result := runGetScenario(t, features, settings, readPermissions, http.StatusOK) - assert.Len(t, result, 1) - assert.Equal(t, "toggle2", result[0].Name) - assert.True(t, result[0].ReadOnly) - }) - - t.Run("feature toggle defailts", func(t *testing.T) { - features := []*featuremgmt.FeatureFlag{ - { - Name: "toggle1", - Stage: featuremgmt.FeatureStageUnknown, - }, { - Name: "toggle2", - Stage: featuremgmt.FeatureStageExperimental, - }, { - Name: "toggle3", - Stage: featuremgmt.FeatureStagePrivatePreview, - }, { - Name: "toggle4", - Stage: featuremgmt.FeatureStagePublicPreview, - AllowSelfServe: true, - }, { - Name: "toggle5", - Stage: featuremgmt.FeatureStageGeneralAvailability, - AllowSelfServe: true, - }, { - Name: "toggle6", - Stage: featuremgmt.FeatureStageDeprecated, - AllowSelfServe: true, - }, { - Name: "toggle7", - Stage: featuremgmt.FeatureStageGeneralAvailability, - AllowSelfServe: false, - }, - } - - t.Run("unknown, experimental, and private preview toggles are hidden by default", func(t *testing.T) { - result := runGetScenario(t, features, setting.FeatureMgmtSettings{}, readPermissions, http.StatusOK) - assert.Len(t, result, 4) - - _, ok := findResult(t, result, "toggle1") - assert.False(t, ok) - _, ok = findResult(t, result, "toggle2") - assert.False(t, ok) - _, ok = findResult(t, result, "toggle3") - assert.False(t, ok) - }) - - t.Run("only public preview and GA with AllowSelfServe are writeable", func(t *testing.T) { - settings := setting.FeatureMgmtSettings{ - AllowEditing: true, - UpdateWebhook: "bogus", - } - result := runGetScenario(t, features, settings, readPermissions, http.StatusOK) - assert.Len(t, result, 4) - - t4, ok := findResult(t, result, "toggle4") - assert.True(t, ok) - assert.True(t, t4.ReadOnly) - t5, ok := findResult(t, result, "toggle5") - assert.True(t, ok) - assert.False(t, t5.ReadOnly) - t6, ok := findResult(t, result, "toggle6") - assert.True(t, ok) - assert.False(t, t6.ReadOnly) - }) - - t.Run("all toggles are read-only when server is misconfigured", func(t *testing.T) { - settings := setting.FeatureMgmtSettings{ - AllowEditing: false, - UpdateWebhook: "", - } - result := runGetScenario(t, features, settings, readPermissions, http.StatusOK) - assert.Len(t, result, 4) - - t4, ok := findResult(t, result, "toggle4") - assert.True(t, ok) - assert.True(t, t4.ReadOnly) - t5, ok := findResult(t, result, "toggle5") - assert.True(t, ok) - assert.True(t, t5.ReadOnly) - t6, ok := findResult(t, result, "toggle6") - assert.True(t, ok) - assert.True(t, t6.ReadOnly) - }) - }) -} - -func TestSetFeatureToggles(t *testing.T) { - writePermissions := []accesscontrol.Permission{{Action: accesscontrol.ActionFeatureManagementWrite}} - - t.Run("fails without adequate permissions", func(t *testing.T) { - res := runSetScenario(t, nil, nil, setting.FeatureMgmtSettings{}, []accesscontrol.Permission{}, http.StatusForbidden) - defer func() { require.NoError(t, res.Body.Close()) }() - }) - - t.Run("fails when toggle editing is not enabled", func(t *testing.T) { - res := runSetScenario(t, nil, nil, setting.FeatureMgmtSettings{}, writePermissions, http.StatusForbidden) - defer func() { require.NoError(t, res.Body.Close()) }() - p := readBody(t, res.Body) - assert.Equal(t, "feature toggles are read-only", p["message"]) - }) - - t.Run("fails when update toggle url is not set", func(t *testing.T) { - s := setting.FeatureMgmtSettings{ - AllowEditing: true, - } - res := runSetScenario(t, nil, nil, s, writePermissions, http.StatusInternalServerError) - defer func() { require.NoError(t, res.Body.Close()) }() - p := readBody(t, res.Body) - assert.Equal(t, "feature toggles service is misconfigured", p["message"]) - }) - - t.Run("fails with non-existent toggle", func(t *testing.T) { - features := []*featuremgmt.FeatureFlag{ - { - Name: "toggle1", - Enabled: true, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, { - Name: "toggle2", - Enabled: false, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, - } - - updates := []featuremgmt.FeatureToggleDTO{ - { - Name: "toggle3", - Enabled: true, - }, - } - - s := setting.FeatureMgmtSettings{ - AllowEditing: true, - UpdateWebhook: "random", - } - res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest) - defer func() { require.NoError(t, res.Body.Close()) }() - p := readBody(t, res.Body) - assert.Equal(t, "invalid toggle passed in", p["message"]) - }) - - t.Run("fails with read-only toggles", func(t *testing.T) { - features := []*featuremgmt.FeatureFlag{ - { - Name: featuremgmt.FlagFeatureToggleAdminPage, - Enabled: true, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, { - Name: "toggle2", - Enabled: false, - Stage: featuremgmt.FeatureStagePublicPreview, - }, { - Name: "toggle3", - Enabled: false, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, - } - - s := setting.FeatureMgmtSettings{ - AllowEditing: true, - UpdateWebhook: "random", - ReadOnlyToggles: map[string]struct{}{ - "toggle3": {}, - }, - } - - t.Run("because it is the feature toggle admin page toggle", func(t *testing.T) { - updates := []featuremgmt.FeatureToggleDTO{ - { - Name: featuremgmt.FlagFeatureToggleAdminPage, - Enabled: true, - }, - } - res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest) - defer func() { require.NoError(t, res.Body.Close()) }() - p := readBody(t, res.Body) - assert.Equal(t, "invalid toggle passed in", p["message"]) - }) - - t.Run("because it is not GA or Deprecated", func(t *testing.T) { - updates := []featuremgmt.FeatureToggleDTO{ - { - Name: "toggle2", - Enabled: true, - }, - } - res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest) - defer func() { require.NoError(t, res.Body.Close()) }() - p := readBody(t, res.Body) - assert.Equal(t, "invalid toggle passed in", p["message"]) - }) - - t.Run("because it is configured to be read-only", func(t *testing.T) { - updates := []featuremgmt.FeatureToggleDTO{ - { - Name: "toggle3", - Enabled: true, - }, - } - res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest) - defer func() { require.NoError(t, res.Body.Close()) }() - p := readBody(t, res.Body) - assert.Equal(t, "invalid toggle passed in", p["message"]) - }) - }) - - t.Run("when all conditions met", func(t *testing.T) { - features := []*featuremgmt.FeatureFlag{ - { - Name: featuremgmt.FlagFeatureToggleAdminPage, - Enabled: true, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, { - Name: "toggle2", - Enabled: false, - Stage: featuremgmt.FeatureStagePublicPreview, - }, { - Name: "toggle3", - Enabled: false, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, { - Name: "toggle4", - Enabled: false, - Stage: featuremgmt.FeatureStageGeneralAvailability, - AllowSelfServe: true, - }, { - Name: "toggle5", - Enabled: false, - Stage: featuremgmt.FeatureStageDeprecated, - AllowSelfServe: true, - }, - } - - s := setting.FeatureMgmtSettings{ - AllowEditing: true, - UpdateWebhook: "random", - UpdateWebhookToken: "token", - ReadOnlyToggles: map[string]struct{}{ - "toggle3": {}, - }, - } - - updates := []featuremgmt.FeatureToggleDTO{ - { - Name: "toggle4", - Enabled: true, - }, { - Name: "toggle5", - Enabled: false, - }, - } - t.Run("fail when webhook request is not successful", func(t *testing.T) { - webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - })) - defer webhookServer.Close() - s.UpdateWebhook = webhookServer.URL - res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest) - defer func() { require.NoError(t, res.Body.Close()) }() - assert.Equal(t, http.StatusBadRequest, res.StatusCode) - }) - - t.Run("succeed when webhook request is successul", func(t *testing.T) { - webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "Bearer "+s.UpdateWebhookToken, r.Header.Get("Authorization")) - - var req UpdatePayload - require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) - - assert.Equal(t, "true", req.FeatureToggles["toggle4"]) - assert.Equal(t, "false", req.FeatureToggles["toggle5"]) - w.WriteHeader(http.StatusOK) - })) - defer webhookServer.Close() - s.UpdateWebhook = webhookServer.URL - res := runSetScenario(t, features, updates, s, writePermissions, http.StatusOK) - defer func() { require.NoError(t, res.Body.Close()) }() - assert.Equal(t, http.StatusOK, res.StatusCode) - }) - }) -} - -func findResult(t *testing.T, result []featuremgmt.FeatureToggleDTO, name string) (featuremgmt.FeatureToggleDTO, bool) { - t.Helper() - - for _, t := range result { - if t.Name == name { - return t, true - } - } - return featuremgmt.FeatureToggleDTO{}, false -} - -func readBody(t *testing.T, rc io.ReadCloser) map[string]any { - t.Helper() - - b, err := io.ReadAll(rc) - require.NoError(t, err) - payload := map[string]any{} - require.NoError(t, json.Unmarshal(b, &payload)) - return payload -} - -func runGetScenario( - t *testing.T, - features []*featuremgmt.FeatureFlag, - settings setting.FeatureMgmtSettings, - permissions []accesscontrol.Permission, - expectedCode int, -) []featuremgmt.FeatureToggleDTO { - // Set up server and send request - cfg := setting.NewCfg() - cfg.FeatureManagement = settings - - server := SetupAPITestServer(t, func(hs *HTTPServer) { - hs.Cfg = cfg - hs.Features = featuremgmt.WithFeatureFlags(append([]*featuremgmt.FeatureFlag{{ - Name: featuremgmt.FlagFeatureToggleAdminPage, - Enabled: true, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }}, features...)) - hs.orgService = orgtest.NewOrgServiceFake() - hs.userService = &usertest.FakeUserService{ - ExpectedUser: &user.User{ID: 1}, - } - hs.log = log.New("test") - }) - req := webtest.RequestWithSignedInUser(server.NewGetRequest("/api/featuremgmt"), userWithPermissions(1, permissions)) - res, err := server.SendJSON(req) - defer func() { require.NoError(t, res.Body.Close()) }() - - // Do some general checks for every request - require.NoError(t, err) - require.Equal(t, expectedCode, res.StatusCode) - if res.StatusCode >= 400 { - return nil - } - - var result []featuremgmt.FeatureToggleDTO - err = json.NewDecoder(res.Body).Decode(&result) - require.NoError(t, err) - - for i := 0; i < len(result); { - ft := result[i] - // Always make sure admin page toggle is read-only, then remove it to make assertions easier - if ft.Name == featuremgmt.FlagFeatureToggleAdminPage { - assert.True(t, ft.ReadOnly) - result = append(result[:i], result[i+1:]...) - continue - } - - // Make sure toggles explicitly marked "hidden" by config are hidden - if _, ok := cfg.FeatureManagement.HiddenToggles[ft.Name]; ok { - t.Fail() - } - - // Make sure toggles explicitly marked "read only" by config are read only - if _, ok := cfg.FeatureManagement.ReadOnlyToggles[ft.Name]; ok { - assert.True(t, ft.ReadOnly) - } - i++ - } - - return result -} - -func runSetScenario( - t *testing.T, - serverFeatures []*featuremgmt.FeatureFlag, - updateFeatures []featuremgmt.FeatureToggleDTO, - settings setting.FeatureMgmtSettings, - permissions []accesscontrol.Permission, - expectedCode int, -) *http.Response { - // Set up server and send request - cfg := setting.NewCfg() - cfg.FeatureManagement = settings - - server := SetupAPITestServer(t, func(hs *HTTPServer) { - hs.Cfg = cfg - hs.Features = featuremgmt.WithFeatureFlags(append([]*featuremgmt.FeatureFlag{{ - Name: featuremgmt.FlagFeatureToggleAdminPage, - Enabled: true, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }}, serverFeatures...)) - hs.orgService = orgtest.NewOrgServiceFake() - hs.userService = &usertest.FakeUserService{ - ExpectedUser: &user.User{ID: 1}, - } - hs.log = log.New("test") - }) - - cmd := featuremgmt.UpdateFeatureTogglesCommand{ - FeatureToggles: updateFeatures, - } - b, err := json.Marshal(cmd) - require.NoError(t, err) - req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/featuremgmt", bytes.NewReader(b)), userWithPermissions(1, permissions)) - res, err := server.SendJSON(req) - - require.NoError(t, err) - require.NotNil(t, res) - require.Equal(t, expectedCode, res.StatusCode) - - return res -} diff --git a/pkg/api/folder.go b/pkg/api/folder.go index a3a8eff342a33..899f976a6a5f2 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/api/apierrors" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/auth/identity" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" @@ -30,7 +31,7 @@ const REDACTED = "redacted" // // Get all folders. // -// Returns all folders that the authenticated user has permission to view. +// It returns all folders that the authenticated user has permission to view. // If nested folders are enabled, it expects an additional query parameter with the parent folder UID // and returns the immediate subfolders that the authenticated user has permission to view. // If the parameter is not supplied then it returns immediate subfolders under the root @@ -42,35 +43,46 @@ const REDACTED = "redacted" // 403: forbiddenError // 500: internalServerError func (hs *HTTPServer) GetFolders(c *contextmodel.ReqContext) response.Response { - var folders []*folder.Folder - var err error + permission := dashboardaccess.PERMISSION_VIEW + if c.Query("permission") == "Edit" { + permission = dashboardaccess.PERMISSION_EDIT + } + if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagNestedFolders) { - folders, err = hs.folderService.GetChildren(c.Req.Context(), &folder.GetChildrenQuery{ + q := &folder.GetChildrenQuery{ OrgID: c.SignedInUser.GetOrgID(), Limit: c.QueryInt64("limit"), Page: c.QueryInt64("page"), UID: c.Query("parentUid"), + Permission: permission, SignedInUser: c.SignedInUser, - }) - } else { - folders, err = hs.searchFolders(c) + } + + folders, err := hs.folderService.GetChildren(c.Req.Context(), q) + if err != nil { + return apierrors.ToFolderErrorResponse(err) + } + + hits := make([]dtos.FolderSearchHit, 0) + for _, f := range folders { + hits = append(hits, dtos.FolderSearchHit{ + ID: f.ID, // nolint:staticcheck + UID: f.UID, + Title: f.Title, + ParentUID: f.ParentUID, + }) + metrics.MFolderIDsAPICount.WithLabelValues(metrics.GetFolders).Inc() + } + + return response.JSON(http.StatusOK, hits) } + hits, err := hs.searchFolders(c, permission) if err != nil { return apierrors.ToFolderErrorResponse(err) } - result := make([]dtos.FolderSearchHit, 0) - for _, f := range folders { - result = append(result, dtos.FolderSearchHit{ - ID: f.ID, // nolint:staticcheck - UID: f.UID, - Title: f.Title, - ParentUID: f.ParentUID, - }) - } - - return response.JSON(http.StatusOK, result) + return response.JSON(http.StatusOK, hits) } // swagger:route GET /folders/{folder_uid} folders getFolderByUID @@ -118,6 +130,7 @@ func (hs *HTTPServer) GetFolderByID(c *contextmodel.ReqContext) response.Respons if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) } + metrics.MFolderIDsAPICount.WithLabelValues(metrics.GetFolderByID).Inc() // nolint:staticcheck folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{ID: &id, OrgID: c.SignedInUser.GetOrgID(), SignedInUser: c.SignedInUser}) if err != nil { @@ -291,7 +304,7 @@ func (hs *HTTPServer) DeleteFolder(c *contextmodel.ReqContext) response.Response err := hs.LibraryElementService.DeleteLibraryElementsInFolder(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"]) if err != nil { if errors.Is(err, model.ErrFolderHasConnectedLibraryElements) { - return response.Error(403, "Folder could not be deleted because it contains library elements in use", err) + return response.Error(http.StatusForbidden, "Folder could not be deleted because it contains library elements in use", err) } return apierrors.ToFolderErrorResponse(err) } @@ -365,7 +378,7 @@ func (hs *HTTPServer) newToFolderDto(c *contextmodel.ReqContext, f *folder.Folde }, nil } } - + metrics.MFolderIDsAPICount.WithLabelValues(metrics.NewToFolderDTO).Inc() return dtos.Folder{ ID: f.ID, // nolint:staticcheck UID: f.UID, @@ -441,7 +454,7 @@ func (hs *HTTPServer) getFolderACMetadata(c *contextmodel.ReqContext, f *folder. return metadata, nil } -func (hs *HTTPServer) searchFolders(c *contextmodel.ReqContext) ([]*folder.Folder, error) { +func (hs *HTTPServer) searchFolders(c *contextmodel.ReqContext, permission dashboardaccess.PermissionType) ([]dtos.FolderSearchHit, error) { searchQuery := search.Query{ SignedInUser: c.SignedInUser, DashboardIds: make([]int64, 0), @@ -449,7 +462,7 @@ func (hs *HTTPServer) searchFolders(c *contextmodel.ReqContext) ([]*folder.Folde Limit: c.QueryInt64("limit"), OrgId: c.SignedInUser.GetOrgID(), Type: "dash-folder", - Permission: dashboardaccess.PERMISSION_VIEW, + Permission: permission, Page: c.QueryInt64("page"), } @@ -458,17 +471,17 @@ func (hs *HTTPServer) searchFolders(c *contextmodel.ReqContext) ([]*folder.Folde return nil, err } - folders := make([]*folder.Folder, 0) - + folderHits := make([]dtos.FolderSearchHit, 0) for _, hit := range hits { - folders = append(folders, &folder.Folder{ + folderHits = append(folderHits, dtos.FolderSearchHit{ ID: hit.ID, // nolint:staticcheck UID: hit.UID, Title: hit.Title, }) + metrics.MFolderIDsAPICount.WithLabelValues(metrics.SearchFolders).Inc() } - return folders, nil + return folderHits, nil } // swagger:parameters getFolders @@ -487,6 +500,12 @@ type GetFoldersParams struct { // in:query // required:false ParentUID string `json:"parentUid"` + // Set to `Edit` to return folders that the user can edit + // in:query + // required: false + // default:View + // Enum: Edit,View + Permission string `json:"permission"` } // swagger:parameters getFolderByUID diff --git a/pkg/api/folder_bench_test.go b/pkg/api/folder_bench_test.go index 93d293bc780b5..8e753d86d0f4c 100644 --- a/pkg/api/folder_bench_test.go +++ b/pkg/api/folder_bench_test.go @@ -41,6 +41,7 @@ import ( "github.com/grafana/grafana/pkg/services/star" "github.com/grafana/grafana/pkg/services/star/startest" "github.com/grafana/grafana/pkg/services/supportbundles/bundleregistry" + "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/team/teamimpl" @@ -95,7 +96,7 @@ func BenchmarkFolderListAndSearch(b *testing.B) { desc string url string expectedLen int - features *featuremgmt.FeatureManager + features featuremgmt.FeatureToggles }{ { desc: "impl=default nested_folders=on get root folders", @@ -206,7 +207,8 @@ func setupDB(b testing.TB) benchScenario { quotaService := quotatest.New(false, nil) cfg := setting.NewCfg() - teamSvc := teamimpl.ProvideService(db, cfg) + teamSvc, err := teamimpl.ProvideService(db, cfg) + require.NoError(b, err) orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(b, err) @@ -329,6 +331,7 @@ func setupDB(b testing.TB) benchScenario { OrgID: signedInUser.OrgID, IsFolder: false, UID: str, + FolderID: f0.ID, FolderUID: f0.UID, Slug: str, Title: str, @@ -356,6 +359,7 @@ func setupDB(b testing.TB) benchScenario { OrgID: signedInUser.OrgID, IsFolder: false, UID: str, + FolderID: f1.ID, FolderUID: f1.UID, Slug: str, Title: str, @@ -383,6 +387,7 @@ func setupDB(b testing.TB) benchScenario { OrgID: signedInUser.OrgID, IsFolder: false, UID: str, + FolderID: f1.ID, FolderUID: f2.UID, Slug: str, Title: str, @@ -423,7 +428,7 @@ func setupDB(b testing.TB) benchScenario { } } -func setupServer(b testing.TB, sc benchScenario, features *featuremgmt.FeatureManager) *web.Macaron { +func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureToggles) *web.Macaron { b.Helper() m := web.New() @@ -449,17 +454,18 @@ func setupServer(b testing.TB, sc benchScenario, features *featuremgmt.FeatureMa folderStore := folderimpl.ProvideDashboardFolderStore(sc.db) ac := acimpl.ProvideAccessControl(sc.cfg) - folderServiceWithFlagOn := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), sc.cfg, dashStore, folderStore, sc.db, features, nil) + folderServiceWithFlagOn := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), sc.cfg, dashStore, folderStore, sc.db, features, supportbundlestest.NewFakeBundleService(), nil) + cfg := setting.NewCfg() folderPermissions, err := ossaccesscontrol.ProvideFolderPermissions( - features, routing.NewRouteRegister(), sc.db, ac, license, &dashboards.FakeDashboardStore{}, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc) + cfg, features, routing.NewRouteRegister(), sc.db, ac, license, &dashboards.FakeDashboardStore{}, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc) require.NoError(b, err) dashboardPermissions, err := ossaccesscontrol.ProvideDashboardPermissions( - features, routing.NewRouteRegister(), sc.db, ac, license, &dashboards.FakeDashboardStore{}, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc) + cfg, features, routing.NewRouteRegister(), sc.db, ac, license, &dashboards.FakeDashboardStore{}, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc) require.NoError(b, err) dashboardSvc, err := dashboardservice.ProvideDashboardServiceImpl( - sc.cfg, dashStore, folderStore, nil, + sc.cfg, dashStore, folderStore, features, folderPermissions, dashboardPermissions, ac, folderServiceWithFlagOn, nil, ) @@ -489,7 +495,7 @@ func setupServer(b testing.TB, sc benchScenario, features *featuremgmt.FeatureMa } type f struct { - // ID int64 `xorm:"pk autoincr 'id'"` + ID int64 `xorm:"pk autoincr 'id'"` OrgID int64 `xorm:"org_id"` UID string `xorm:"uid"` ParentUID *string `xorm:"parent_uid"` @@ -506,8 +512,8 @@ func (f *f) TableName() string { // SQL bean helper to save tags type dashboardTag struct { - ID int64 - DashboardID int64 + ID int64 `xorm:"pk autoincr 'id'"` + DashboardID int64 `xorm:"dashboard_id"` Term string } @@ -515,10 +521,10 @@ func addFolder(orgID int64, id int64, uid string, parentUID *string) (*f, *dashb now := time.Now() title := uid f := &f{ - OrgID: orgID, - UID: uid, - Title: title, - // ID: id, + OrgID: orgID, + UID: uid, + Title: title, + ID: id, Created: now, Updated: now, ParentUID: parentUID, diff --git a/pkg/api/folder_permission.go b/pkg/api/folder_permission.go index 10e34c3ffe7f7..8901eaa30732c 100644 --- a/pkg/api/folder_permission.go +++ b/pkg/api/folder_permission.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/api/apierrors" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/auth/identity" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" @@ -37,7 +38,7 @@ func (hs *HTTPServer) GetFolderPermissionList(c *contextmodel.ReqContext) respon acl, err := hs.getFolderACL(c.Req.Context(), c.SignedInUser, folder) if err != nil { - return response.Error(500, "Failed to get folder permissions", err) + return response.Error(http.StatusInternalServerError, "Failed to get folder permissions", err) } filteredACLs := make([]*dashboards.DashboardACLInfoDTO, 0, len(acl)) @@ -45,15 +46,15 @@ func (hs *HTTPServer) GetFolderPermissionList(c *contextmodel.ReqContext) respon if perm.UserID > 0 && dtos.IsHiddenUser(perm.UserLogin, c.SignedInUser, hs.Cfg) { continue } - + metrics.MFolderIDsAPICount.WithLabelValues(metrics.GetFolderPermissionList).Inc() // nolint:staticcheck perm.FolderID = folder.ID perm.DashboardID = 0 - perm.UserAvatarURL = dtos.GetGravatarUrl(perm.UserEmail) + perm.UserAvatarURL = dtos.GetGravatarUrl(hs.Cfg, perm.UserEmail) if perm.TeamID > 0 { - perm.TeamAvatarURL = dtos.GetGravatarUrlWithDefault(perm.TeamEmail, perm.Team) + perm.TeamAvatarURL = dtos.GetGravatarUrlWithDefault(hs.Cfg, perm.TeamEmail, perm.Team) } if perm.Slug != "" { @@ -82,7 +83,7 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *contextmodel.ReqContext) respon return response.Error(http.StatusBadRequest, "bad request data", err) } if err := validatePermissionsUpdate(apiCmd); err != nil { - return response.Error(400, err.Error(), err) + return response.Error(http.StatusBadRequest, err.Error(), err) } uid := web.Params(c.Req)[":uid"] @@ -103,6 +104,7 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *contextmodel.ReqContext) respon Created: time.Now(), Updated: time.Now(), }) + metrics.MFolderIDsAPICount.WithLabelValues(metrics.UpdateFolderPermissions).Inc() } acl, err := hs.getFolderACL(c.Req.Context(), c.SignedInUser, folder) @@ -166,6 +168,7 @@ func (hs *HTTPServer) getFolderACL(ctx context.Context, user identity.Requester, IsFolder: true, Inherited: false, }) + metrics.MFolderIDsAPICount.WithLabelValues(metrics.GetFolderPermissionList).Inc() } return acl, nil diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index d7fbfff283f0c..dc5b2df4458f6 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -435,7 +435,7 @@ func TestFolderGetAPIEndpoint(t *testing.T) { type testCase struct { description string URL string - features *featuremgmt.FeatureManager + features featuremgmt.FeatureToggles expectedCode int expectedParentUIDs []string expectedParentOrgIDs []int64 diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 262185ec2e481..a9956fd42b857 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -2,12 +2,16 @@ package api import ( "context" + "crypto/sha256" "fmt" + "hash" "net/http" "slices" + "sort" "strings" "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/api/webassets" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -24,6 +28,61 @@ import ( "github.com/grafana/grafana/pkg/util" ) +// Returns a file that is easy to check for changes +// Any changes to the file means we should refresh the frontend +func (hs *HTTPServer) GetFrontendAssets(c *contextmodel.ReqContext) { + hash := sha256.New() + keys := map[string]any{} + + // BuildVersion + hash.Reset() + _, _ = hash.Write([]byte(setting.BuildVersion)) + _, _ = hash.Write([]byte(setting.BuildCommit)) + _, _ = hash.Write([]byte(fmt.Sprintf("%d", setting.BuildStamp))) + keys["version"] = fmt.Sprintf("%x", hash.Sum(nil)) + + // Plugin configs + plugins := []string{} + for _, p := range hs.pluginStore.Plugins(c.Req.Context()) { + plugins = append(plugins, fmt.Sprintf("%s@%s", p.Name, p.Info.Version)) + } + keys["plugins"] = sortedHash(plugins, hash) + + // Feature flags + enabled := []string{} + for flag, set := range hs.Features.GetEnabled(c.Req.Context()) { + if set { + enabled = append(enabled, flag) + } + } + keys["flags"] = sortedHash(enabled, hash) + + // Assets + hash.Reset() + dto, err := webassets.GetWebAssets(c.Req.Context(), hs.Cfg, hs.License) + if err == nil && dto != nil { + _, _ = hash.Write([]byte(dto.ContentDeliveryURL)) + _, _ = hash.Write([]byte(dto.Dark)) + _, _ = hash.Write([]byte(dto.Light)) + for _, f := range dto.JSFiles { + _, _ = hash.Write([]byte(f.FilePath)) + _, _ = hash.Write([]byte(f.Integrity)) + } + } + keys["assets"] = fmt.Sprintf("%x", hash.Sum(nil)) + + c.JSON(http.StatusOK, keys) +} + +func sortedHash(vals []string, hash hash.Hash) string { + hash.Reset() + sort.Strings(vals) + for _, v := range vals { + _, _ = hash.Write([]byte(v)) + } + return fmt.Sprintf("%x", hash.Sum(nil)) +} + func (hs *HTTPServer) GetFrontendSettings(c *contextmodel.ReqContext) { settings, err := hs.getFrontendSettings(c) if err != nil { @@ -35,6 +94,8 @@ func (hs *HTTPServer) GetFrontendSettings(c *contextmodel.ReqContext) { } // getFrontendSettings returns a json object with all the settings needed for front end initialisation. +// +//nolint:gocyclo func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.FrontendSettingsDTO, error) { availablePlugins, err := hs.availablePlugins(c.Req.Context(), c.SignedInUser.GetOrgID()) if err != nil { @@ -91,44 +152,46 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro hideVersion := hs.Cfg.AnonymousHideVersion && !c.IsSignedIn version := setting.BuildVersion commit := setting.BuildCommit + commitShort := getShortCommitHash(setting.BuildCommit, 10) buildstamp := setting.BuildStamp + versionString := fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, version, commitShort) if hideVersion { version = "" + versionString = setting.ApplicationName commit = "" + commitShort = "" buildstamp = 0 } hasAccess := accesscontrol.HasAccess(hs.AccessControl, c) secretsManagerPluginEnabled := kvstore.EvaluateRemoteSecretsPlugin(c.Req.Context(), hs.secretsPluginManager, hs.Cfg) == nil trustedTypesDefaultPolicyEnabled := (hs.Cfg.CSPEnabled && strings.Contains(hs.Cfg.CSPTemplate, "require-trusted-types-for")) || (hs.Cfg.CSPReportOnlyEnabled && strings.Contains(hs.Cfg.CSPReportOnlyTemplate, "require-trusted-types-for")) + isCloudMigrationTarget := hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagOnPremToCloudMigrations) && hs.Cfg.CloudMigrationIsTarget frontendSettings := &dtos.FrontendSettingsDTO{ DefaultDatasource: defaultDS, Datasources: dataSources, - MinRefreshInterval: setting.MinRefreshInterval, + MinRefreshInterval: hs.Cfg.MinRefreshInterval, Panels: panels, Apps: apps, AppUrl: hs.Cfg.AppURL, AppSubUrl: hs.Cfg.AppSubURL, - AllowOrgCreate: (setting.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin, - AuthProxyEnabled: hs.Cfg.AuthProxyEnabled, + AllowOrgCreate: (hs.Cfg.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin, + AuthProxyEnabled: hs.Cfg.AuthProxy.Enabled, LdapEnabled: hs.Cfg.LDAPAuthEnabled, - JwtHeaderName: hs.Cfg.JWTAuthHeaderName, - JwtUrlLogin: hs.Cfg.JWTAuthURLLogin, - AlertingErrorOrTimeout: setting.AlertingErrorOrTimeout, - AlertingNoDataOrNullValues: setting.AlertingNoDataOrNullValues, - AlertingMinInterval: setting.AlertingMinInterval, + JwtHeaderName: hs.Cfg.JWTAuth.HeaderName, + JwtUrlLogin: hs.Cfg.JWTAuth.URLLogin, LiveEnabled: hs.Cfg.LiveMaxConnections != 0, AutoAssignOrg: hs.Cfg.AutoAssignOrg, - VerifyEmailEnabled: setting.VerifyEmailEnabled, - SigV4AuthEnabled: setting.SigV4AuthEnabled, - AzureAuthEnabled: setting.AzureAuthEnabled, + VerifyEmailEnabled: hs.Cfg.VerifyEmailEnabled, + SigV4AuthEnabled: hs.Cfg.SigV4AuthEnabled, + AzureAuthEnabled: hs.Cfg.AzureAuthEnabled, RbacEnabled: true, - ExploreEnabled: setting.ExploreEnabled, - HelpEnabled: setting.HelpEnabled, - ProfileEnabled: setting.ProfileEnabled, - NewsFeedEnabled: setting.NewsFeedEnabled, + ExploreEnabled: hs.Cfg.ExploreEnabled, + HelpEnabled: hs.Cfg.HelpEnabled, + ProfileEnabled: hs.Cfg.ProfileEnabled, + NewsFeedEnabled: hs.Cfg.NewsFeedEnabled, QueryHistoryEnabled: hs.Cfg.QueryHistoryEnabled, GoogleAnalyticsId: hs.Cfg.GoogleAnalyticsID, GoogleAnalytics4Id: hs.Cfg.GoogleAnalytics4ID, @@ -142,12 +205,12 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro ApplicationInsightsConnectionString: hs.Cfg.ApplicationInsightsConnectionString, ApplicationInsightsEndpointUrl: hs.Cfg.ApplicationInsightsEndpointUrl, DisableLoginForm: hs.Cfg.DisableLoginForm, - DisableUserSignUp: !setting.AllowUserSignUp, - LoginHint: setting.LoginHint, - PasswordHint: setting.PasswordHint, - ExternalUserMngInfo: setting.ExternalUserMngInfo, - ExternalUserMngLinkUrl: setting.ExternalUserMngLinkUrl, - ExternalUserMngLinkName: setting.ExternalUserMngLinkName, + DisableUserSignUp: !hs.Cfg.AllowUserSignUp, + LoginHint: hs.Cfg.LoginHint, + PasswordHint: hs.Cfg.PasswordHint, + ExternalUserMngInfo: hs.Cfg.ExternalUserMngInfo, + ExternalUserMngLinkUrl: hs.Cfg.ExternalUserMngLinkUrl, + ExternalUserMngLinkName: hs.Cfg.ExternalUserMngLinkName, ViewersCanEdit: hs.Cfg.ViewersCanEdit, AngularSupportEnabled: hs.Cfg.AngularSupportEnabled, EditorsCanAdmin: hs.Cfg.EditorsCanAdmin, @@ -159,17 +222,22 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro DisableFrontendSandboxForPlugins: hs.Cfg.DisableFrontendSandboxForPlugins, PublicDashboardAccessToken: c.PublicDashboardAccessToken, PublicDashboardsEnabled: hs.Cfg.PublicDashboardsEnabled, + CloudMigrationIsTarget: isCloudMigrationTarget, SharedWithMeFolderUID: folder.SharedWithMeFolderUID, + RootFolderUID: accesscontrol.GeneralFolderUID, + LocalFileSystemAvailable: hs.Cfg.LocalFileSystemAvailable, BuildInfo: dtos.FrontendSettingsBuildInfoDTO{ HideVersion: hideVersion, Version: version, + VersionString: versionString, Commit: commit, + CommitShort: commitShort, Buildstamp: buildstamp, Edition: hs.License.Edition(), LatestVersion: hs.grafanaUpdateChecker.LatestVersion(), HasUpdate: hs.grafanaUpdateChecker.UpdateAvailable(), - Env: setting.Env, + Env: hs.Cfg.Env, }, LicenseInfo: dtos.FrontendSettingsLicenseInfoDTO{ @@ -185,6 +253,9 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro AnonymousDeviceLimit: hs.Cfg.AnonymousDeviceLimit, RendererAvailable: hs.RenderService.IsAvailable(c.Req.Context()), RendererVersion: hs.RenderService.Version(), + RendererDefaultImageWidth: hs.Cfg.RendererDefaultImageWidth, + RendererDefaultImageHeight: hs.Cfg.RendererDefaultImageHeight, + RendererDefaultImageScale: hs.Cfg.RendererDefaultImageScale, SecretsManagerPluginEnabled: secretsManagerPluginEnabled, Http2Enabled: hs.Cfg.Protocol == setting.HTTP2Scheme, GrafanaJavascriptAgent: hs.Cfg.GrafanaJavascriptAgent, @@ -244,10 +315,6 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro frontendSettings.UnifiedAlertingEnabled = *hs.Cfg.UnifiedAlerting.Enabled } - if setting.AlertingEnabled != nil { - frontendSettings.AlertingEnabled = *setting.AlertingEnabled - } - // It returns false if the provider is not enabled or the skip org role sync is false. parseSkipOrgRoleSyncEnabled := func(info *social.OAuthInfo) bool { if info == nil { @@ -258,18 +325,20 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro oauthProviders := hs.SocialService.GetOAuthInfoProviders() frontendSettings.Auth = dtos.FrontendSettingsAuthDTO{ - AuthProxyEnableLoginToken: hs.Cfg.AuthProxyEnableLoginToken, - OAuthSkipOrgRoleUpdateSync: hs.Cfg.OAuthSkipOrgRoleUpdateSync, - SAMLSkipOrgRoleSync: hs.Cfg.SAMLSkipOrgRoleSync, - LDAPSkipOrgRoleSync: hs.Cfg.LDAPSkipOrgRoleSync, - JWTAuthSkipOrgRoleSync: hs.Cfg.JWTAuthSkipOrgRoleSync, - GoogleSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GoogleProviderName]), - GrafanaComSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GrafanaComProviderName]), - GenericOAuthSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GenericOAuthProviderName]), - AzureADSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.AzureADProviderName]), - GithubSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GitHubProviderName]), - GitLabSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GitlabProviderName]), - OktaSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.OktaProviderName]), + AuthProxyEnableLoginToken: hs.Cfg.AuthProxy.EnableLoginToken, + OAuthSkipOrgRoleUpdateSync: hs.Cfg.OAuthSkipOrgRoleUpdateSync, + SAMLSkipOrgRoleSync: hs.Cfg.SAMLSkipOrgRoleSync, + LDAPSkipOrgRoleSync: hs.Cfg.LDAPSkipOrgRoleSync, + JWTAuthSkipOrgRoleSync: hs.Cfg.JWTAuth.SkipOrgRoleSync, + GoogleSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GoogleProviderName]), + GrafanaComSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GrafanaComProviderName]), + GenericOAuthSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GenericOAuthProviderName]), + AzureADSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.AzureADProviderName]), + GithubSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GitHubProviderName]), + GitLabSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GitlabProviderName]), + OktaSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.OktaProviderName]), + DisableLogin: hs.Cfg.DisableLogin, + BasicAuthStrongPasswordPolicy: hs.Cfg.BasicAuthStrongPasswordPolicy, } if hs.pluginsCDNService != nil && hs.pluginsCDNService.IsEnabled() { @@ -291,6 +360,12 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro // Set the kubernetes namespace frontendSettings.Namespace = hs.namespacer(c.SignedInUser.OrgID) + // experimental scope features + if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagScopeFilters) { + frontendSettings.ListScopesEndpoint = hs.Cfg.ScopesListScopesURL + frontendSettings.ListDashboardScopesEndpoint = hs.Cfg.ScopesListDashboardsURL + } + return frontendSettings, nil } @@ -298,6 +373,13 @@ func isSupportBundlesEnabled(hs *HTTPServer) bool { return hs.Cfg.SectionWithEnvOverrides("support_bundles").Key("enabled").MustBool(true) } +func getShortCommitHash(commitHash string, maxLength int) string { + if len(commitHash) > maxLength { + return commitHash[:maxLength] + } + return commitHash +} + func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlugins AvailablePlugins) (map[string]plugins.DataSourceDTO, error) { orgDataSources := make([]*datasources.DataSource, 0) if c.SignedInUser.GetOrgID() != 0 { diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index 4e4e53e89a879..d0fbcb0c1472e 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -19,8 +19,8 @@ import ( "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/pluginscdn" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" @@ -32,7 +32,7 @@ import ( "github.com/grafana/grafana/pkg/web" ) -func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt.FeatureManager, pstore pluginstore.Store, psettings pluginsettings.Service) (*web.Mux, *HTTPServer) { +func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.FeatureToggles, pstore pluginstore.Store, psettings pluginsettings.Service) (*web.Mux, *HTTPServer) { t.Helper() db.InitTestDB(t) // nolint:staticcheck @@ -41,11 +41,9 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt. { oldVersion := setting.BuildVersion oldCommit := setting.BuildCommit - oldEnv := setting.Env t.Cleanup(func() { setting.BuildVersion = oldVersion setting.BuildCommit = oldCommit - setting.Env = oldEnv }) } @@ -73,7 +71,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt. grafanaUpdateChecker: &updatechecker.GrafanaService{}, AccessControl: accesscontrolmock.New(), PluginSettings: pluginsSettings, - pluginsCDNService: pluginscdn.ProvideService(&config.Cfg{ + pluginsCDNService: pluginscdn.ProvideService(&config.PluginManagementCfg{ PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate, PluginSettings: cfg.PluginSettings, }), @@ -83,7 +81,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt. m := web.New() m.Use(getContextHandler(t, cfg).Middleware) - m.UseMiddleware(web.Renderer(filepath.Join(setting.StaticRootPath, "views"), "[[", "]]")) + m.UseMiddleware(web.Renderer(filepath.Join("", "views"), "[[", "]]")) m.Get("/api/frontend/settings/", hs.GetFrontendSettings) return m, hs @@ -111,7 +109,6 @@ func TestHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testing.T) { // TODO: Remove setting.BuildVersion = cfg.BuildVersion setting.BuildCommit = cfg.BuildCommit - setting.Env = cfg.Env tests := []struct { desc string @@ -125,7 +122,7 @@ func TestHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testing.T) { BuildInfo: buildInfo{ Version: setting.BuildVersion, Commit: setting.BuildCommit, - Env: setting.Env, + Env: cfg.Env, }, }, }, @@ -136,7 +133,7 @@ func TestHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testing.T) { BuildInfo: buildInfo{ Version: "", Commit: "", - Env: setting.Env, + Env: cfg.Env, }, }, }, diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index cb1c37cc3ada8..b76ec55a80c62 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -25,8 +25,8 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/grafana/grafana/pkg/services/anonymous" - grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" + grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/api/avatar" "github.com/grafana/grafana/pkg/api/routing" @@ -47,7 +47,6 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/auth" @@ -126,7 +125,7 @@ type HTTPServer struct { RouteRegister routing.RouteRegister RenderService rendering.Service Cfg *setting.Cfg - Features *featuremgmt.FeatureManager + Features featuremgmt.FeatureToggles SettingsProvider setting.Provider HooksService *hooks.HooksService navTreeService navtree.Service @@ -158,7 +157,6 @@ type HTTPServer struct { ContextHandler *contexthandler.ContextHandler LoggerMiddleware loggermw.Logger SQLStore db.DB - AlertEngine *alerting.AlertEngine AlertNG *ngalert.AlertNG LibraryPanelService librarypanels.Service LibraryElementService libraryelements.Service @@ -179,12 +177,11 @@ type HTTPServer struct { queryDataService query.Service serviceAccountsService serviceaccounts.Service authInfoService login.AuthInfoService - NotificationService *notifications.NotificationService + NotificationService notifications.Service DashboardService dashboards.DashboardService dashboardProvisioningService dashboards.DashboardProvisioningService folderService folder.Service dsGuardian guardian.DatasourceGuardianProvider - AlertNotificationService *alerting.AlertNotificationService dashboardsnapshotsService dashboardsnapshots.Service PluginSettings pluginSettings.Service AvatarCacheServer *avatar.AvatarCacheServer @@ -217,6 +214,7 @@ type HTTPServer struct { clientConfigProvider grafanaapiserver.DirectRestConfigProvider namespacer request.NamespaceMapper anonService anonymous.Service + userVerifier user.Verifier } type ServerOptions struct { @@ -225,7 +223,7 @@ type ServerOptions struct { func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routing.RouteRegister, bus bus.Bus, renderService rendering.Service, licensing licensing.Licensing, hooksService *hooks.HooksService, - cacheService *localcache.CacheService, sqlStore *sqlstore.SQLStore, alertEngine *alerting.AlertEngine, + cacheService *localcache.CacheService, sqlStore *sqlstore.SQLStore, pluginRequestValidator validations.PluginRequestValidator, pluginStaticRouteResolver plugins.StaticRouteResolver, pluginDashboardService plugindashboards.Service, pluginStore pluginstore.Store, pluginClient plugins.Client, pluginErrorResolver plugins.ErrorResolver, pluginInstaller plugins.Installer, settingsProvider setting.Provider, @@ -234,7 +232,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi correlationsService correlations.Service, remoteCache *remotecache.RemoteCache, provisioningService provisioning.ProvisioningService, accessControl accesscontrol.AccessControl, dataSourceProxy *datasourceproxy.DataSourceProxyService, searchService *search.SearchService, live *live.GrafanaLive, livePushGateway *pushhttp.Gateway, plugCtxProvider *plugincontext.Provider, - contextHandler *contexthandler.ContextHandler, loggerMiddleware loggermw.Logger, features *featuremgmt.FeatureManager, + contextHandler *contexthandler.ContextHandler, loggerMiddleware loggermw.Logger, features featuremgmt.FeatureToggles, alertNG *ngalert.AlertNG, libraryPanelService librarypanels.Service, libraryElementService libraryelements.Service, quotaService quota.Service, socialService social.Service, tracer tracing.Tracer, encryptionService encryption.Internal, grafanaUpdateChecker *updatechecker.GrafanaService, @@ -242,9 +240,9 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi dataSourcesService datasources.DataSourceService, queryDataService query.Service, pluginFileStore plugins.FileStore, serviceaccountsService serviceaccounts.Service, authInfoService login.AuthInfoService, storageService store.StorageService, - notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService, + notificationService notifications.Service, dashboardService dashboards.DashboardService, dashboardProvisioningService dashboards.DashboardProvisioningService, folderService folder.Service, - dsGuardian guardian.DatasourceGuardianProvider, alertNotificationService *alerting.AlertNotificationService, + dsGuardian guardian.DatasourceGuardianProvider, dashboardsnapshotsService dashboardsnapshots.Service, pluginSettings pluginSettings.Service, avatarCacheServer *avatar.AvatarCacheServer, preferenceService pref.Service, folderPermissionsService accesscontrol.FolderPermissionsService, @@ -259,6 +257,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService, oauthTokenService oauthtoken.OAuthTokenService, statsService stats.Service, authnService authn.Service, pluginsCDNService *pluginscdn.Service, promGatherer prometheus.Gatherer, starApi *starApi.API, promRegister prometheus.Registerer, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, anonService anonymous.Service, + userVerifier user.Verifier, ) (*HTTPServer, error) { web.Env = cfg.Env m := web.New() @@ -272,7 +271,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi HooksService: hooksService, CacheService: cacheService, SQLStore: sqlStore, - AlertEngine: alertEngine, PluginRequestValidator: pluginRequestValidator, pluginInstaller: pluginInstaller, pluginClient: pluginClient, @@ -290,7 +288,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi ShortURLService: shortURLService, QueryHistoryService: queryHistoryService, CorrelationsService: correlationsService, - Features: features, + Features: features, // a read only view of the managers state StorageService: storageService, RemoteCacheService: remoteCache, ProvisioningService: provisioningService, @@ -328,7 +326,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi dashboardProvisioningService: dashboardProvisioningService, folderService: folderService, dsGuardian: dsGuardian, - AlertNotificationService: alertNotificationService, dashboardsnapshotsService: dashboardsnapshotsService, PluginSettings: pluginSettings, AvatarCacheServer: avatarCacheServer, @@ -361,6 +358,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi clientConfigProvider: clientConfigProvider, namespacer: request.GetNamespaceMapper(cfg), anonService: anonService, + userVerifier: userVerifier, } if hs.Listener != nil { hs.log.Debug("Using provided listener") @@ -587,9 +585,6 @@ func (hs *HTTPServer) configureHttps() error { } tlsCiphers := hs.getDefaultCiphers(minTlsVersion, string(setting.HTTPSScheme)) - if err != nil { - return err - } hs.log.Info("HTTP Server TLS settings", "Min TLS Version", hs.Cfg.MinTLSVersion, "configured ciphers", util.TlsCipherIdsToString(tlsCiphers)) @@ -656,7 +651,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { m.UseMiddleware(middleware.Gziper()) } - m.UseMiddleware(middleware.Recovery(hs.Cfg)) + m.UseMiddleware(middleware.Recovery(hs.Cfg, hs.License)) m.UseMiddleware(hs.Csrf.Middleware()) hs.mapStatic(m, hs.Cfg.StaticRootPath, "build", "public/build") diff --git a/pkg/api/index.go b/pkg/api/index.go index c0527e3b3fc3b..ba722f95e5999 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -60,7 +60,7 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV locale = parts[0] } - appURL := setting.AppUrl + appURL := hs.Cfg.AppURL appSubURL := hs.Cfg.AppSubURL // special case when doing localhost call from image renderer @@ -81,31 +81,18 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV } theme := hs.getThemeForIndexData(prefs.Theme, c.Query("theme")) - assets, err := webassets.GetWebAssets(hs.Cfg) + assets, err := webassets.GetWebAssets(c.Req.Context(), hs.Cfg, hs.License) if err != nil { return nil, err } - userOrgCount := 1 - userOrgs, err := hs.orgService.GetUserOrgList(c.Req.Context(), &org.GetUserOrgListQuery{UserID: userID}) - if err != nil { - hs.log.Error("Failed to count user orgs", "error", err) - } - - if len(userOrgs) > 0 { - userOrgCount = len(userOrgs) - } - hasAccess := ac.HasAccess(hs.AccessControl, c) hasEditPerm := hasAccess(ac.EvalAny(ac.EvalPermission(dashboards.ActionDashboardsCreate), ac.EvalPermission(dashboards.ActionFoldersCreate))) - cdnURL, err := hs.Cfg.GetContentDeliveryURL(hs.License.ContentDeliveryPrefix()) - if err != nil { - return nil, err - } data := dtos.IndexViewData{ User: &dtos.CurrentUser{ Id: userID, + UID: c.UserUID, // << not set yet IsSignedIn: c.IsSignedIn, Login: c.Login, Email: c.SignedInUser.GetEmail(), @@ -113,8 +100,8 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV OrgId: c.SignedInUser.GetOrgID(), OrgName: c.OrgName, OrgRole: c.SignedInUser.GetOrgRole(), - OrgCount: userOrgCount, - GravatarUrl: dtos.GetGravatarUrl(c.SignedInUser.GetEmail()), + OrgCount: hs.getUserOrgCount(c, userID), + GravatarUrl: dtos.GetGravatarUrl(hs.Cfg, c.SignedInUser.GetEmail()), IsGrafanaAdmin: c.IsGrafanaAdmin, Theme: theme.ID, LightTheme: theme.Type == "light", @@ -125,13 +112,13 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV HelpFlags1: c.HelpFlags1, HasEditPermissionInFolders: hasEditPerm, Analytics: hs.buildUserAnalyticsSettings(c), - AuthenticatedBy: c.SignedInUser.AuthenticatedBy, + AuthenticatedBy: hs.getUserAuthenticatedBy(c, userID), }, Settings: settings, ThemeType: theme.Type, AppUrl: appURL, AppSubUrl: appSubURL, - NewsFeedEnabled: setting.NewsFeedEnabled, + NewsFeedEnabled: hs.Cfg.NewsFeedEnabled, GoogleAnalyticsId: settings.GoogleAnalyticsId, GoogleAnalytics4Id: settings.GoogleAnalytics4Id, GoogleAnalytics4SendManualPageViews: hs.Cfg.GoogleAnalytics4SendManualPageViews, @@ -147,7 +134,6 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV AppTitle: "ADF Omni", NavTree: navTree, Nonce: c.RequestNonce, - ContentDeliveryURL: cdnURL, LoadingLogo: "public/img/grafana_icon.svg", IsDevelopmentEnv: hs.Cfg.Env == setting.Dev, Assets: assets, @@ -157,7 +143,6 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV data.CSPEnabled = true data.CSPContent = middleware.ReplacePolicyVariables(hs.Cfg.CSPTemplate, appURL, c.RequestNonce) } - userPermissions, err := hs.accesscontrolService.GetUserPermissions(c.Req.Context(), c.SignedInUser, ac.Options{ReloadCache: false}) if err != nil { return nil, err @@ -165,7 +150,7 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV data.User.Permissions = ac.BuildPermissionsMap(userPermissions) - if setting.DisableGravatar { + if hs.Cfg.DisableGravatar { data.User.GravatarUrl = hs.Cfg.AppSubURL + "/public/img/user_profile.png" } @@ -176,6 +161,7 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV hs.HooksService.RunIndexDataHooks(&data, c) data.NavTree.ApplyAdminIA() + data.NavTree.ApplyHelpVersion(data.Settings.BuildInfo.VersionString) // RunIndexDataHooks can modify the version string data.NavTree.Sort() return &data, nil @@ -186,7 +172,7 @@ func (hs *HTTPServer) buildUserAnalyticsSettings(c *contextmodel.ReqContext) dto // Anonymous users do not have an email or auth info if namespace != identity.NamespaceUser { - return dtos.AnalyticsSettings{Identifier: "@" + setting.AppUrl} + return dtos.AnalyticsSettings{Identifier: "@" + hs.Cfg.AppURL} } if !c.IsSignedIn { @@ -196,10 +182,10 @@ func (hs *HTTPServer) buildUserAnalyticsSettings(c *contextmodel.ReqContext) dto userID, err := identity.IntIdentifier(namespace, id) if err != nil { hs.log.Error("Failed to parse user ID", "error", err) - return dtos.AnalyticsSettings{Identifier: "@" + setting.AppUrl} + return dtos.AnalyticsSettings{Identifier: "@" + hs.Cfg.AppURL} } - identifier := c.SignedInUser.GetEmail() + "@" + setting.AppUrl + identifier := c.SignedInUser.GetEmail() + "@" + hs.Cfg.AppURL authInfo, err := hs.authInfoService.GetAuthInfo(c.Req.Context(), &login.GetAuthInfoQuery{UserId: userID}) if err != nil && !errors.Is(err, user.ErrUserNotFound) { @@ -216,6 +202,46 @@ func (hs *HTTPServer) buildUserAnalyticsSettings(c *contextmodel.ReqContext) dto } } +func (hs *HTTPServer) getUserOrgCount(c *contextmodel.ReqContext, userID int64) int { + if userID == 0 { + return 1 + } + + userOrgs, err := hs.orgService.GetUserOrgList(c.Req.Context(), &org.GetUserOrgListQuery{UserID: userID}) + if err != nil { + hs.log.FromContext(c.Req.Context()).Error("Failed to count user orgs", "userId", userID, "error", err) + return 1 + } + + return len(userOrgs) +} + +// getUserAuthenticatedBy returns external authentication method used for user. +// If user does not have an external authentication method an empty string is returned +func (hs *HTTPServer) getUserAuthenticatedBy(c *contextmodel.ReqContext, userID int64) string { + if userID == 0 { + return "" + } + + // Special case for image renderer. Frontend relies on this information + // to render dashboards in a bit different way. + if c.IsRenderCall { + return login.RenderModule + } + + info, err := hs.authInfoService.GetAuthInfo(c.Req.Context(), &login.GetAuthInfoQuery{UserId: userID}) + // we ignore errors where a user does not have external user auth + if err != nil && !errors.Is(err, user.ErrUserNotFound) { + hs.log.FromContext(c.Req.Context()).Error("Failed to fetch auth info", "userId", c.SignedInUser.UserID, "error", err) + } + + if err != nil { + return "" + } + + return info.AuthModule +} + func hashUserIdentifier(identifier string, secret string) string { if secret == "" { return "" @@ -230,7 +256,7 @@ func hashUserIdentifier(identifier string, secret string) string { func (hs *HTTPServer) Index(c *contextmodel.ReqContext) { data, err := hs.setIndexViewData(c) if err != nil { - c.Handle(hs.Cfg, 500, "Failed to get settings", err) + c.Handle(hs.Cfg, http.StatusInternalServerError, "Failed to get settings", err) return } c.HTML(http.StatusOK, "index", data) @@ -238,17 +264,17 @@ func (hs *HTTPServer) Index(c *contextmodel.ReqContext) { func (hs *HTTPServer) NotFoundHandler(c *contextmodel.ReqContext) { if c.IsApiRequest() { - c.JsonApiErr(404, "Not found", nil) + c.JsonApiErr(http.StatusNotFound, "Not found", nil) return } data, err := hs.setIndexViewData(c) if err != nil { - c.Handle(hs.Cfg, 500, "Failed to get settings", err) + c.Handle(hs.Cfg, http.StatusInternalServerError, "Failed to get settings", err) return } - c.HTML(404, "index", data) + c.HTML(http.StatusNotFound, "index", data) } func (hs *HTTPServer) getThemeForIndexData(themePrefId string, themeURLParam string) *pref.ThemeDTO { diff --git a/pkg/api/login.go b/pkg/api/login.go index 9f6376a546906..15b84597aa3e7 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -89,16 +89,14 @@ func (hs *HTTPServer) CookieOptionsFromCfg() cookies.CookieOptions { } func (hs *HTTPServer) LoginView(c *contextmodel.ReqContext) { - if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagClientTokenRotation) { - if errors.Is(c.LookupTokenErr, authn.ErrTokenNeedsRotation) { - c.Redirect(hs.Cfg.AppSubURL + "/") - return - } + if errors.Is(c.LookupTokenErr, authn.ErrTokenNeedsRotation) { + c.Redirect(hs.Cfg.AppSubURL + "/") + return } viewData, err := setIndexViewData(hs, c) if err != nil { - c.Handle(hs.Cfg, 500, "Failed to get settings", err) + c.Handle(hs.Cfg, http.StatusInternalServerError, "Failed to get settings", err) return } @@ -127,8 +125,8 @@ func (hs *HTTPServer) LoginView(c *contextmodel.ReqContext) { if c.IsSignedIn { // Assign login token to auth proxy users if enable_login_token = true - if hs.Cfg.AuthProxyEnabled && - hs.Cfg.AuthProxyEnableLoginToken && + if hs.Cfg.AuthProxy.Enabled && + hs.Cfg.AuthProxy.EnableLoginToken && c.SignedInUser.AuthenticatedBy == loginservice.AuthProxyAuthModule { user := &user.User{ID: c.SignedInUser.UserID, Email: c.SignedInUser.Email, Login: c.SignedInUser.Login} err := hs.loginUserWithUser(user, c) @@ -198,7 +196,7 @@ func (hs *HTTPServer) LoginAPIPing(c *contextmodel.ReqContext) response.Response return response.JSON(http.StatusOK, util.DynMap{"message": "Logged in"}) } - return response.Error(401, "Unauthorized", nil) + return response.Error(http.StatusUnauthorized, "Unauthorized", nil) } func (hs *HTTPServer) LoginPost(c *contextmodel.ReqContext) response.Response { @@ -289,7 +287,7 @@ func (hs *HTTPServer) trySetEncryptedCookie(ctx *contextmodel.ReqContext, cookie return err } - cookies.WriteCookie(ctx.Resp, cookieName, hex.EncodeToString(encryptedError), 60, hs.CookieOptionsFromCfg) + cookies.WriteCookie(ctx.Resp, cookieName, hex.EncodeToString(encryptedError), maxAge, hs.CookieOptionsFromCfg) return nil } diff --git a/pkg/api/login_test.go b/pkg/api/login_test.go index 5eecd25c1e4d6..2b0ef5df7c3ff 100644 --- a/pkg/api/login_test.go +++ b/pkg/api/login_test.go @@ -53,9 +53,9 @@ func fakeSetIndexViewData(t *testing.T) { Settings: &dtos.FrontendSettingsDTO{}, NavTree: &navtree.NavTreeRoot{}, Assets: &dtos.EntryPointAssets{ - JSFiles: []dtos.EntryPointAsset{}, - CSSDark: "dark.css", - CSSLight: "light.css", + JSFiles: []dtos.EntryPointAsset{}, + Dark: "dark.css", + Light: "light.css", }, } return data, nil @@ -122,16 +122,14 @@ func TestLoginErrorCookieAPIEndpoint(t *testing.T) { }) cfg.LoginCookieName = loginCookieName - setting.SecretKey = "login_testing" - cfg.OAuthAutoLogin = true oauthError := errors.New("User not a member of one of the required organizations") encryptedError, err := hs.SecretsService.Encrypt(context.Background(), []byte(oauthError.Error()), secrets.WithoutScope()) require.NoError(t, err) expCookiePath := "/" - if len(setting.AppSubUrl) > 0 { - expCookiePath = setting.AppSubUrl + if len(cfg.AppSubURL) > 0 { + expCookiePath = cfg.AppSubURL } cookie := http.Cookie{ Name: loginErrorCookieName, @@ -602,8 +600,8 @@ func TestAuthProxyLoginWithEnableLoginTokenAndEnabledOauthAutoLogin(t *testing.T return response.Empty(http.StatusOK) }) - sc.cfg.AuthProxyEnabled = true - sc.cfg.AuthProxyEnableLoginToken = true + sc.cfg.AuthProxy.Enabled = true + sc.cfg.AuthProxy.EnableLoginToken = true sc.m.Get(sc.url, sc.defaultHandler) sc.fakeReqNoAssertions("GET", sc.url).exec() @@ -642,8 +640,8 @@ func setupAuthProxyLoginTest(t *testing.T, enableLoginToken bool) *scenarioConte return response.Empty(http.StatusOK) }) - sc.cfg.AuthProxyEnabled = true - sc.cfg.AuthProxyEnableLoginToken = enableLoginToken + sc.cfg.AuthProxy.Enabled = true + sc.cfg.AuthProxy.EnableLoginToken = enableLoginToken sc.m.Get(sc.url, sc.defaultHandler) sc.fakeReqNoAssertions("GET", sc.url).exec() diff --git a/pkg/api/metrics.go b/pkg/api/metrics.go index f6acadf135836..df798af8ca886 100644 --- a/pkg/api/metrics.go +++ b/pkg/api/metrics.go @@ -10,10 +10,14 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/middleware/requestmeta" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/util/errutil/errhttp" "github.com/grafana/grafana/pkg/web" ) @@ -33,6 +37,26 @@ func (hs *HTTPServer) handleQueryMetricsError(err error) *response.NormalRespons return response.ErrOrFallback(http.StatusInternalServerError, "Query data error", err) } +// metrics.go +func (hs *HTTPServer) getDSQueryEndpoint() web.Handler { + if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesQueryServiceRewrite) { + // DEV ONLY FEATURE FLAG! + // rewrite requests from /ds/query to the new query service + namespaceMapper := request.GetNamespaceMapper(hs.Cfg) + return func(w http.ResponseWriter, r *http.Request) { + user, err := appcontext.User(r.Context()) + if err != nil || user == nil { + errhttp.Write(r.Context(), fmt.Errorf("no user"), w) + return + } + r.URL.Path = "/apis/query.grafana.app/v0alpha1/namespaces/" + namespaceMapper(user.OrgID) + "/query" + hs.clientConfigProvider.DirectlyServeHTTP(w, r) + } + } + + return routing.Wrap(hs.QueryMetricsV2) +} + // QueryMetricsV2 returns query metrics. // swagger:route POST /ds/query ds queryMetricsWithExpressions // diff --git a/pkg/api/metrics_test.go b/pkg/api/metrics_test.go index 7eeeb89089165..8139a4c7302d1 100644 --- a/pkg/api/metrics_test.go +++ b/pkg/api/metrics_test.go @@ -18,13 +18,12 @@ import ( "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" - "github.com/grafana/grafana/pkg/plugins/config" pluginClient "github.com/grafana/grafana/pkg/plugins/manager/client" - pluginFakes "github.com/grafana/grafana/pkg/plugins/manager/fakes" "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/services/datasources" fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" @@ -77,8 +76,8 @@ func TestAPIEndpoint_Metrics_QueryMetricsV2(t *testing.T) { }, }, }, - }, &fakeDatasources.FakeDataSourceService{}, pluginSettings.ProvideService(dbtest.NewFakeDB(), - secretstest.NewFakeSecretsService()), pluginFakes.NewFakeLicensingService(), &config.Cfg{}), + }, &fakeDatasources.FakeCacheService{}, &fakeDatasources.FakeDataSourceService{}, + pluginSettings.ProvideService(dbtest.NewFakeDB(), secretstest.NewFakeSecretsService()), pluginconfig.NewFakePluginRequestConfigProvider()), ) serverFeatureEnabled := SetupAPITestServer(t, func(hs *HTTPServer) { hs.queryDataService = qds @@ -124,7 +123,8 @@ func TestAPIEndpoint_Metrics_PluginDecryptionFailure(t *testing.T) { }, }, }, - ds, pluginSettings.ProvideService(db, secretstest.NewFakeSecretsService()), pluginFakes.NewFakeLicensingService(), &config.Cfg{}, + &fakeDatasources.FakeCacheService{}, + ds, pluginSettings.ProvideService(db, secretstest.NewFakeSecretsService()), pluginconfig.NewFakePluginRequestConfigProvider(), ) qds := query.ProvideService( cfg, @@ -296,12 +296,13 @@ func TestDataSourceQueryError(t *testing.T) { &fakeDatasources.FakeCacheService{}, nil, &fakePluginRequestValidator{}, - pluginClient.ProvideService(r, &config.Cfg{}), + pluginClient.ProvideService(r), plugincontext.ProvideService(cfg, localcache.ProvideService(), &pluginstore.FakePluginStore{ PluginList: []pluginstore.Plugin{pluginstore.ToGrafanaDTO(p)}, }, - ds, pluginSettings.ProvideService(dbtest.NewFakeDB(), - secretstest.NewFakeSecretsService()), pluginFakes.NewFakeLicensingService(), &config.Cfg{}), + &fakeDatasources.FakeCacheService{}, ds, + pluginSettings.ProvideService(dbtest.NewFakeDB(), + secretstest.NewFakeSecretsService()), pluginconfig.NewFakePluginRequestConfigProvider()), ) hs.QuotaService = quotatest.New(false, nil) }) diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go index ab4f1b944b9fb..a2f08b9607b24 100644 --- a/pkg/api/org_invite.go +++ b/pkg/api/org_invite.go @@ -216,14 +216,14 @@ func (hs *HTTPServer) GetInviteInfoByCode(c *contextmodel.ReqContext) response.R queryResult, err := hs.tempUserService.GetTempUserByCode(c.Req.Context(), &query) if err != nil { if errors.Is(err, tempuser.ErrTempUserNotFound) { - return response.Error(404, "Invite not found", nil) + return response.Error(http.StatusNotFound, "Invite not found", nil) } - return response.Error(500, "Failed to get invite", err) + return response.Error(http.StatusInternalServerError, "Failed to get invite", err) } invite := queryResult if invite.Status != tempuser.TmpUserInvitePending { - return response.Error(404, "Invite not found", nil) + return response.Error(http.StatusNotFound, "Invite not found", nil) } return response.JSON(http.StatusOK, dtos.InviteInfo{ @@ -270,6 +270,10 @@ func (hs *HTTPServer) CompleteInvite(c *contextmodel.ReqContext) response.Respon } } + if err := completeInvite.Password.Validate(hs.Cfg); err != nil { + return response.Err(err) + } + cmd := user.CreateUserCommand{ Email: completeInvite.Email, Name: completeInvite.Name, @@ -281,17 +285,17 @@ func (hs *HTTPServer) CompleteInvite(c *contextmodel.ReqContext) response.Respon usr, err := hs.userService.Create(c.Req.Context(), &cmd) if err != nil { if errors.Is(err, user.ErrUserAlreadyExists) { - return response.Error(412, fmt.Sprintf("User with email '%s' or username '%s' already exists", completeInvite.Email, completeInvite.Username), err) + return response.Error(http.StatusPreconditionFailed, fmt.Sprintf("User with email '%s' or username '%s' already exists", completeInvite.Email, completeInvite.Username), err) } - return response.Error(500, "failed to create user", err) + return response.Error(http.StatusInternalServerError, "failed to create user", err) } if err := hs.bus.Publish(c.Req.Context(), &events.SignUpCompleted{ Name: usr.NameOrFallback(), Email: usr.Email, }); err != nil { - return response.Error(500, "failed to publish event", err) + return response.Error(http.StatusInternalServerError, "failed to publish event", err) } if ok, rsp := hs.applyUserInvite(c.Req.Context(), usr, invite, true); !ok { @@ -300,7 +304,7 @@ func (hs *HTTPServer) CompleteInvite(c *contextmodel.ReqContext) response.Respon err = hs.loginUserWithUser(usr, c) if err != nil { - return response.Error(500, "failed to accept invite", err) + return response.Error(http.StatusInternalServerError, "failed to accept invite", err) } metrics.MApiUserSignUpCompleted.Inc() @@ -316,7 +320,7 @@ func (hs *HTTPServer) updateTempUserStatus(ctx context.Context, code string, sta // update temp user status updateTmpUserCmd := tempuser.UpdateTempUserStatusCommand{Code: code, Status: status} if err := hs.tempUserService.UpdateTempUserStatus(ctx, &updateTmpUserCmd); err != nil { - return false, response.Error(500, "Failed to update invite status", err) + return false, response.Error(http.StatusInternalServerError, "Failed to update invite status", err) } return true, nil @@ -327,7 +331,7 @@ func (hs *HTTPServer) applyUserInvite(ctx context.Context, usr *user.User, invit addOrgUserCmd := org.AddOrgUserCommand{OrgID: invite.OrgID, UserID: usr.ID, Role: invite.Role} if err := hs.orgService.AddOrgUser(ctx, &addOrgUserCmd); err != nil { if !errors.Is(err, org.ErrOrgUserAlreadyAdded) { - return false, response.Error(500, "Error while trying to create org user", err) + return false, response.Error(http.StatusInternalServerError, "Error while trying to create org user", err) } } @@ -339,13 +343,16 @@ func (hs *HTTPServer) applyUserInvite(ctx context.Context, usr *user.User, invit if setActive { // set org to active if err := hs.userService.SetUsingOrg(ctx, &user.SetUsingOrgCommand{OrgID: invite.OrgID, UserID: usr.ID}); err != nil { - return false, response.Error(500, "Failed to set org as active", err) + return false, response.Error(http.StatusInternalServerError, "Failed to set org as active", err) } } return true, nil } +// swagger:response SMTPNotEnabledError +type SMTPNotEnabledError PreconditionFailedError + // swagger:parameters addOrgInvite type AddInviteParams struct { // in:body diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index 7a8c993bce640..401148307c884 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -124,7 +124,7 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrg(c *contextmodel.ReqContext) respo }) if err != nil { - return response.Error(500, "Failed to get users for current organization", err) + return response.Error(http.StatusInternalServerError, "Failed to get users for current organization", err) } return response.JSON(http.StatusOK, result.OrgUsers) @@ -154,7 +154,7 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrgLookup(c *contextmodel.ReqContext) }) if err != nil { - return response.Error(500, "Failed to get users for current organization", err) + return response.Error(http.StatusInternalServerError, "Failed to get users for current organization", err) } result := make([]*dtos.UserLookupDTO, 0) @@ -199,7 +199,7 @@ func (hs *HTTPServer) GetOrgUsers(c *contextmodel.ReqContext) response.Response }) if err != nil { - return response.Error(500, "Failed to get users for organization", err) + return response.Error(http.StatusInternalServerError, "Failed to get users for organization", err) } return response.JSON(http.StatusOK, result.OrgUsers) @@ -251,7 +251,7 @@ func (hs *HTTPServer) SearchOrgUsers(c *contextmodel.ReqContext) response.Respon }) if err != nil { - return response.Error(500, "Failed to get users for organization", err) + return response.Error(http.StatusInternalServerError, "Failed to get users for organization", err) } return response.JSON(http.StatusOK, result) @@ -286,7 +286,7 @@ func (hs *HTTPServer) SearchOrgUsersWithPaging(c *contextmodel.ReqContext) respo result, err := hs.searchOrgUsersHelper(c, query) if err != nil { - return response.Error(500, "Failed to get users for current organization", err) + return response.Error(http.StatusInternalServerError, "Failed to get users for current organization", err) } return response.JSON(http.StatusOK, result) @@ -305,7 +305,7 @@ func (hs *HTTPServer) searchOrgUsersHelper(c *contextmodel.ReqContext, query *or if dtos.IsHiddenUser(user.Login, c.SignedInUser, hs.Cfg) { continue } - user.AvatarURL = dtos.GetGravatarUrl(user.Email) + user.AvatarURL = dtos.GetGravatarUrl(hs.Cfg, user.Email) userIDs[fmt.Sprint(user.UserID)] = true authLabelsUserIDs = append(authLabelsUserIDs, user.UserID) @@ -323,12 +323,16 @@ func (hs *HTTPServer) searchOrgUsersHelper(c *contextmodel.ReqContext, query *or // Get accesscontrol metadata and IPD labels for users in the target org accessControlMetadata := map[string]accesscontrol.Metadata{} - if c.QueryBool("accesscontrol") && c.SignedInUser.Permissions != nil { - // TODO https://github.com/grafana/identity-access-team/issues/268 - user access control service for fetching permissions from another organization - permissions, ok := c.SignedInUser.Permissions[query.OrgID] - if ok { - accessControlMetadata = accesscontrol.GetResourcesMetadata(c.Req.Context(), permissions, "users:id:", userIDs) + if c.QueryBool("accesscontrol") { + permissions := c.SignedInUser.GetPermissions() + if query.OrgID != c.SignedInUser.GetOrgID() { + identity, err := hs.authnService.ResolveIdentity(c.Req.Context(), query.OrgID, c.SignedInUser.GetID()) + if err != nil { + return nil, err + } + permissions = identity.GetPermissions() } + accessControlMetadata = accesscontrol.GetResourcesMetadata(c.Req.Context(), permissions, "users:id:", userIDs) } for i := range filteredUsers { @@ -501,9 +505,9 @@ func (hs *HTTPServer) RemoveOrgUser(c *contextmodel.ReqContext) response.Respons func (hs *HTTPServer) removeOrgUserHelper(ctx context.Context, cmd *org.RemoveOrgUserCommand) response.Response { if err := hs.orgService.RemoveOrgUser(ctx, cmd); err != nil { if errors.Is(err, org.ErrLastOrgAdmin) { - return response.Error(400, "Cannot remove last organization admin", nil) + return response.Error(http.StatusBadRequest, "Cannot remove last organization admin", nil) } - return response.Error(500, "Failed to remove user from organization", err) + return response.Error(http.StatusInternalServerError, "Failed to remove user from organization", err) } if cmd.UserWasDeleted { diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index d4f9951bcf69c..e1820ca6158d5 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -368,6 +368,7 @@ func TestGetOrgUsersAPIEndpoint_AccessControlMetadata(t *testing.T) { } hs.authInfoService = &authinfotest.FakeService{} hs.userService = &usertest.FakeUserService{ExpectedSignedInUser: userWithPermissions(1, tt.permissions)} + hs.accesscontrolService = actest.FakeService{ExpectedPermissions: tt.permissions} }) url := "/api/orgs/1/users" diff --git a/pkg/api/password.go b/pkg/api/password.go index c04e6712b8b8c..f3b265029e492 100644 --- a/pkg/api/password.go +++ b/pkg/api/password.go @@ -97,17 +97,18 @@ func (hs *HTTPServer) ResetPassword(c *contextmodel.ReqContext) response.Respons return response.Error(http.StatusBadRequest, "Passwords do not match", nil) } - password := user.Password(form.NewPassword) - if password.IsWeak() { - return response.Error(http.StatusBadRequest, "New password is too short", nil) + if err := form.NewPassword.Validate(hs.Cfg); err != nil { + c.Logger.Warn("the new password doesn't meet the password policy criteria", "err", err) + return response.Err(err) } cmd := user.ChangeUserPasswordCommand{} cmd.UserID = userResult.ID - cmd.NewPassword, err = util.EncodePassword(form.NewPassword, userResult.Salt) + encodedPassword, err := util.EncodePassword(string(form.NewPassword), userResult.Salt) if err != nil { return response.Error(http.StatusInternalServerError, "Failed to encode password", err) } + cmd.NewPassword = user.Password(encodedPassword) if err := hs.userService.ChangePassword(c.Req.Context(), &cmd); err != nil { return response.Error(http.StatusInternalServerError, "Failed to change user password", err) diff --git a/pkg/api/playlist.go b/pkg/api/playlist.go index 4a49bc59708b6..40daad0e6f5f4 100644 --- a/pkg/api/playlist.go +++ b/pkg/api/playlist.go @@ -15,10 +15,10 @@ import ( "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1" "github.com/grafana/grafana/pkg/middleware" internalplaylist "github.com/grafana/grafana/pkg/registry/apis/playlist" + grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" - grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/playlist" "github.com/grafana/grafana/pkg/util/errutil/errhttp" "github.com/grafana/grafana/pkg/web" @@ -92,7 +92,7 @@ func (hs *HTTPServer) SearchPlaylists(c *contextmodel.ReqContext) response.Respo playlists, err := hs.playlistService.Search(c.Req.Context(), &searchQuery) if err != nil { - return response.Error(500, "Search failed", err) + return response.Error(http.StatusInternalServerError, "Search failed", err) } return response.JSON(http.StatusOK, playlists) @@ -114,7 +114,7 @@ func (hs *HTTPServer) GetPlaylist(c *contextmodel.ReqContext) response.Response dto, err := hs.playlistService.Get(c.Req.Context(), &cmd) if err != nil { - return response.Error(500, "Playlist not found", err) + return response.Error(http.StatusInternalServerError, "Playlist not found", err) } return response.JSON(http.StatusOK, dto) @@ -136,7 +136,7 @@ func (hs *HTTPServer) GetPlaylistItems(c *contextmodel.ReqContext) response.Resp dto, err := hs.playlistService.Get(c.Req.Context(), &cmd) if err != nil { - return response.Error(500, "Playlist not found", err) + return response.Error(http.StatusInternalServerError, "Playlist not found", err) } return response.JSON(http.StatusOK, dto.Items) @@ -157,7 +157,7 @@ func (hs *HTTPServer) DeletePlaylist(c *contextmodel.ReqContext) response.Respon cmd := playlist.DeletePlaylistCommand{UID: uid, OrgId: c.SignedInUser.GetOrgID()} if err := hs.playlistService.Delete(c.Req.Context(), &cmd); err != nil { - return response.Error(500, "Failed to delete playlist", err) + return response.Error(http.StatusInternalServerError, "Failed to delete playlist", err) } return response.JSON(http.StatusOK, "") @@ -182,7 +182,7 @@ func (hs *HTTPServer) CreatePlaylist(c *contextmodel.ReqContext) response.Respon p, err := hs.playlistService.Create(c.Req.Context(), &cmd) if err != nil { - return response.Error(500, "Failed to create playlist", err) + return response.Error(http.StatusInternalServerError, "Failed to create playlist", err) } return response.JSON(http.StatusOK, p) @@ -208,7 +208,7 @@ func (hs *HTTPServer) UpdatePlaylist(c *contextmodel.ReqContext) response.Respon _, err := hs.playlistService.Update(c.Req.Context(), &cmd) if err != nil { - return response.Error(500, "Failed to save playlist", err) + return response.Error(http.StatusInternalServerError, "Failed to save playlist", err) } dto, err := hs.playlistService.Get(c.Req.Context(), &playlist.GetPlaylistByUidQuery{ @@ -216,7 +216,7 @@ func (hs *HTTPServer) UpdatePlaylist(c *contextmodel.ReqContext) response.Respon OrgId: c.SignedInUser.GetOrgID(), }) if err != nil { - return response.Error(500, "Failed to load playlist", err) + return response.Error(http.StatusInternalServerError, "Failed to load playlist", err) } return response.JSON(http.StatusOK, dto) } diff --git a/pkg/api/plugin_proxy.go b/pkg/api/plugin_proxy.go index 4a81afbc34f69..1a8a3fbd1909f 100644 --- a/pkg/api/plugin_proxy.go +++ b/pkg/api/plugin_proxy.go @@ -51,7 +51,7 @@ func (hs *HTTPServer) ProxyPluginRequest(c *contextmodel.ReqContext) { } proxyPath := getProxyPath(c) - p, err := pluginproxy.NewPluginProxy(ps, plugin.Routes, c, proxyPath, hs.Cfg, hs.SecretsService, hs.tracer, pluginProxyTransport, hs.Features) + p, err := pluginproxy.NewPluginProxy(ps, plugin.Routes, c, proxyPath, hs.Cfg, hs.SecretsService, hs.tracer, pluginProxyTransport, hs.AccessControl, hs.Features) if err != nil { c.JsonApiErr(http.StatusInternalServerError, "Failed to create plugin proxy", err) return diff --git a/pkg/api/plugin_resource.go b/pkg/api/plugin_resource.go index 814f0329f8fd1..6f0530effcd2f 100644 --- a/pkg/api/plugin_resource.go +++ b/pkg/api/plugin_resource.go @@ -90,14 +90,11 @@ func (hs *HTTPServer) callPluginResourceWithDataSource(c *contextmodel.ReqContex func (hs *HTTPServer) pluginResourceRequest(c *contextmodel.ReqContext) (*http.Request, error) { clonedReq := c.Req.Clone(c.Req.Context()) rawURL := web.Params(c.Req)["*"] - if clonedReq.URL.RawQuery != "" { - rawURL += "?" + clonedReq.URL.RawQuery - } - urlPath, err := url.Parse(rawURL) - if err != nil { - return nil, err + + clonedReq.URL = &url.URL{ + Path: rawURL, + RawQuery: clonedReq.URL.RawQuery, } - clonedReq.URL = urlPath return clonedReq, nil } diff --git a/pkg/api/plugin_resource_test.go b/pkg/api/plugin_resource_test.go index df94ea6d2b807..e2ea99902e84e 100644 --- a/pkg/api/plugin_resource_test.go +++ b/pkg/api/plugin_resource_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "io" + "net/url" "path/filepath" "strings" "testing" @@ -20,7 +21,6 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" - "github.com/grafana/grafana/pkg/plugins/config" pluginClient "github.com/grafana/grafana/pkg/plugins/manager/client" "github.com/grafana/grafana/pkg/plugins/manager/fakes" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -30,6 +30,7 @@ import ( "github.com/grafana/grafana/pkg/services/oauthtoken/oauthtokentest" "github.com/grafana/grafana/pkg/services/pluginsintegration" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" "github.com/grafana/grafana/pkg/services/quota/quotatest" @@ -48,27 +49,26 @@ func TestCallResource(t *testing.T) { cfg := setting.NewCfg() cfg.StaticRootPath = staticRootPath cfg.Azure = &azsettings.AzureSettings{} - pCfg := config.Cfg{} coreRegistry := coreplugin.ProvideCoreRegistry(tracing.InitializeTracerForTest(), nil, &cloudwatch.CloudWatchService{}, nil, nil, nil, nil, nil, nil, nil, nil, testdatasource.ProvideService(), nil, nil, nil, nil, nil, nil) - textCtx := pluginsintegration.CreateIntegrationTestCtx(t, cfg, coreRegistry) + testCtx := pluginsintegration.CreateIntegrationTestCtx(t, cfg, coreRegistry) - pcp := plugincontext.ProvideService(cfg, localcache.ProvideService(), textCtx.PluginStore, &datasources.FakeDataSourceService{}, - pluginSettings.ProvideService(db.InitTestDB(t), fakeSecrets.NewFakeSecretsService()), nil, &pCfg) + pcp := plugincontext.ProvideService(cfg, localcache.ProvideService(), testCtx.PluginStore, &datasources.FakeCacheService{}, + &datasources.FakeDataSourceService{}, pluginSettings.ProvideService(db.InitTestDB(t), fakeSecrets.NewFakeSecretsService()), pluginconfig.NewFakePluginRequestConfigProvider()) srv := SetupAPITestServer(t, func(hs *HTTPServer) { hs.Cfg = cfg hs.pluginContextProvider = pcp hs.QuotaService = quotatest.New(false, nil) - hs.pluginStore = textCtx.PluginStore - hs.pluginClient = textCtx.PluginClient + hs.pluginStore = testCtx.PluginStore + hs.pluginClient = testCtx.PluginClient hs.log = log.New("test") }) t.Run("Test successful response is received for valid request", func(t *testing.T) { - req := srv.NewPostRequest("/api/plugins/grafana-testdata-datasource/resources/test", strings.NewReader("{ \"test\": true }")) + req := srv.NewPostRequest("/api/plugins/grafana-testdata-datasource/resources/test", strings.NewReader(`{"test": "true"}`)) webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{ 1: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{ {Action: pluginaccesscontrol.ActionAppAccess, Scope: pluginaccesscontrol.ScopeProvider.GetResourceAllScope()}, @@ -88,6 +88,82 @@ func TestCallResource(t *testing.T) { require.NoError(t, resp.Body.Close()) require.Equal(t, 200, resp.StatusCode) }) + + t.Run("Test successful response is received for valid request with the colon character", func(t *testing.T) { + req := srv.NewPostRequest("/api/plugins/grafana-testdata-datasource/resources/test-*,*:test-*/_mapping", strings.NewReader(`{"test": "true"}`)) + webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{ + {Action: pluginaccesscontrol.ActionAppAccess, Scope: pluginaccesscontrol.ScopeProvider.GetResourceAllScope()}, + }), + }}) + resp, err := srv.SendJSON(req) + require.NoError(t, err) + + require.NoError(t, resp.Body.Close()) + require.Equal(t, 200, resp.StatusCode) + }) + + t.Run("CallResource plugin resource request is created correctly", func(t *testing.T) { + type testdataCallResourceTestResponse struct { + Message string `json:"message"` + Request struct { + URL url.URL + Body map[string]any `json:"body"` + } `json:"request"` + } + + for _, tc := range []struct { + name string + url string + exp func(t *testing.T, resp testdataCallResourceTestResponse) + }{ + { + name: "Simple URL", + url: "/api/plugins/grafana-testdata-datasource/resources/test", + exp: func(t *testing.T, resp testdataCallResourceTestResponse) { + require.Equal(t, "Hello world from test datasource!", resp.Message) + require.Equal(t, "/test", resp.Request.URL.Path) + require.Equal(t, "true", resp.Request.Body["test"]) + require.Len(t, resp.Request.Body, 1) + require.Empty(t, resp.Request.URL.RawQuery) + require.Empty(t, resp.Request.URL.Query()) + }, + }, + { + name: "URL with query params", + url: "/api/plugins/grafana-testdata-datasource/resources/test?test=true&a=b", + exp: func(t *testing.T, resp testdataCallResourceTestResponse) { + require.Equal(t, "Hello world from test datasource!", resp.Message) + require.Equal(t, "/test", resp.Request.URL.Path) + require.Equal(t, "test=true&a=b", resp.Request.URL.RawQuery) + query := resp.Request.URL.Query() + require.Equal(t, "true", query.Get("test")) + require.Equal(t, "b", query.Get("a")) + require.Len(t, query, 2) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + req := srv.NewPostRequest(tc.url, strings.NewReader(`{"test": "true"}`)) + webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{ + {Action: pluginaccesscontrol.ActionAppAccess, Scope: pluginaccesscontrol.ScopeProvider.GetResourceAllScope()}, + }), + }}) + resp, err := srv.SendJSON(req) + require.NoError(t, err) + + var body testdataCallResourceTestResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + + tc.exp(t, body) + + require.NoError(t, resp.Body.Close()) + require.Equal(t, 200, resp.StatusCode) + }) + } + }) + pluginRegistry := fakes.NewFakePluginRegistry() require.NoError(t, pluginRegistry.Add(context.Background(), &plugins.Plugin{ JSONData: plugins.JSONData{ @@ -108,7 +184,7 @@ func TestCallResource(t *testing.T) { hs.Cfg = cfg hs.pluginContextProvider = pcp hs.QuotaService = quotatest.New(false, nil) - hs.pluginStore = textCtx.PluginStore + hs.pluginStore = testCtx.PluginStore hs.pluginClient = pc hs.log = log.New("test") }) diff --git a/pkg/api/pluginproxy/ds_auth_provider.go b/pkg/api/pluginproxy/ds_auth_provider.go index 8eeecc8b88578..c0fe875b9c4f6 100644 --- a/pkg/api/pluginproxy/ds_auth_provider.go +++ b/pkg/api/pluginproxy/ds_auth_provider.go @@ -16,6 +16,7 @@ import ( type DSInfo struct { ID int64 Updated time.Time + URL string JSONData map[string]any DecryptedSecureJSONData map[string]string } @@ -24,8 +25,8 @@ type DSInfo struct { func ApplyRoute(ctx context.Context, req *http.Request, proxyPath string, route *plugins.Route, ds DSInfo, cfg *setting.Cfg) { proxyPath = strings.TrimPrefix(proxyPath, route.Path) - data := templateData{ + URL: ds.URL, JsonData: ds.JSONData, SecureJsonData: ds.DecryptedSecureJSONData, } diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index f1b2e8e2469f0..8037c79a12366 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -19,7 +19,6 @@ import ( glog "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/services/auth" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -253,6 +252,7 @@ func (proxy *DataSourceProxy) director(req *http.Request) { ApplyRoute(req.Context(), req, proxy.proxyPath, proxy.matchedRoute, DSInfo{ ID: proxy.ds.ID, + URL: proxy.ds.URL, Updated: proxy.ds.Updated, JSONData: jsonData, DecryptedSecureJSONData: decryptedValues, @@ -270,13 +270,13 @@ func (proxy *DataSourceProxy) director(req *http.Request) { } } - if proxy.features.IsEnabled(req.Context(), featuremgmt.FlagIdForwarding) && auth.IsIDForwardingEnabledForDataSource(proxy.ds) { + if proxy.features.IsEnabled(req.Context(), featuremgmt.FlagIdForwarding) { proxyutil.ApplyForwardIDHeader(req, proxy.ctx.SignedInUser) } } func (proxy *DataSourceProxy) validateRequest() error { - if !checkWhiteList(proxy.ctx, proxy.targetUrl.Host) { + if !proxy.checkWhiteList() { return errors.New("target URL is not a valid target") } @@ -344,6 +344,8 @@ func (proxy *DataSourceProxy) logRequest() { } } + panelPluginId := proxy.ctx.Req.Header.Get("X-Panel-Plugin-Id") + ctxLogger := logger.FromContext(proxy.ctx.Req.Context()) ctxLogger.Info("Proxying incoming request", "userid", proxy.ctx.UserID, @@ -352,13 +354,14 @@ func (proxy *DataSourceProxy) logRequest() { "datasource", proxy.ds.Type, "uri", proxy.ctx.Req.RequestURI, "method", proxy.ctx.Req.Method, + "panelPluginId", panelPluginId, "body", body) } -func checkWhiteList(c *contextmodel.ReqContext, host string) bool { - if host != "" && len(setting.DataProxyWhiteList) > 0 { - if _, exists := setting.DataProxyWhiteList[host]; !exists { - c.JsonApiErr(403, "Data proxy hostname and ip are not included in whitelist", nil) +func (proxy *DataSourceProxy) checkWhiteList() bool { + if proxy.targetUrl.Host != "" && len(proxy.cfg.DataProxyWhiteList) > 0 { + if _, exists := proxy.cfg.DataProxyWhiteList[proxy.targetUrl.Host]; !exists { + proxy.ctx.JsonApiErr(403, "Data proxy hostname and ip are not included in whitelist", nil) return false } } diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index 9bc1739c82f77..13e6ed00735ba 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -45,9 +45,14 @@ import ( secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/web" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestDataSourceProxy_routeRule(t *testing.T) { cfg := &setting.Cfg{} @@ -158,6 +163,36 @@ func TestDataSourceProxy_routeRule(t *testing.T) { assert.Equal(t, "http://localhost/asd", req.URL.String()) }) + t.Run("When matching route path and has setting url", func(t *testing.T) { + ctx, req := setUp() + proxy, err := setupDSProxyTest(t, ctx, ds, routes, "api/common/some/method") + require.NoError(t, err) + proxy.matchedRoute = &plugins.Route{ + Path: "api/common", + URL: "{{.URL}}", + Headers: []plugins.Header{ + {Name: "x-header", Content: "my secret {{.SecureJsonData.key}}"}, + }, + URLParams: []plugins.URLParam{ + {Name: "{{.JsonData.queryParam}}", Content: "{{.SecureJsonData.key}}"}, + }, + } + + dsInfo := DSInfo{ + ID: ds.ID, + Updated: ds.Updated, + JSONData: jd, + DecryptedSecureJSONData: map[string]string{ + "key": "123", + }, + URL: "https://dynamic.grafana.com", + } + ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.matchedRoute, dsInfo, proxy.cfg) + + assert.Equal(t, "https://dynamic.grafana.com/some/method?apiKey=123", req.URL.String()) + assert.Equal(t, "my secret 123", req.Header.Get("x-header")) + }) + t.Run("When matching route path and has dynamic body", func(t *testing.T) { ctx, req := setUp() proxy, err := setupDSProxyTest(t, ctx, ds, routes, "api/body") diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go index b206591a656a0..c61a6a284b995 100644 --- a/pkg/api/pluginproxy/pluginproxy.go +++ b/pkg/api/pluginproxy/pluginproxy.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" @@ -22,6 +23,7 @@ import ( ) type PluginProxy struct { + accessControl ac.AccessControl ps *pluginsettings.DTO pluginRoutes []*plugins.Route ctx *contextmodel.ReqContext @@ -37,8 +39,9 @@ type PluginProxy struct { // NewPluginProxy creates a plugin proxy. func NewPluginProxy(ps *pluginsettings.DTO, routes []*plugins.Route, ctx *contextmodel.ReqContext, proxyPath string, cfg *setting.Cfg, secretsService secrets.Service, tracer tracing.Tracer, - transport *http.Transport, features featuremgmt.FeatureToggles) (*PluginProxy, error) { + transport *http.Transport, accessControl ac.AccessControl, features featuremgmt.FeatureToggles) (*PluginProxy, error) { return &PluginProxy{ + accessControl: accessControl, ps: ps, pluginRoutes: routes, ctx: ctx, @@ -67,11 +70,9 @@ func (proxy *PluginProxy) HandleRequest() { continue } - if route.ReqRole.IsValid() { - if !proxy.ctx.HasUserRole(route.ReqRole) { - proxy.ctx.JsonApiErr(http.StatusForbidden, "plugin proxy route access denied", nil) - return - } + if !proxy.hasAccessToRoute(route) { + proxy.ctx.JsonApiErr(http.StatusForbidden, "plugin proxy route access denied", nil) + return } if path, exists := params["*"]; exists { @@ -120,6 +121,21 @@ func (proxy *PluginProxy) HandleRequest() { reverseProxy.ServeHTTP(proxy.ctx.Resp, proxy.ctx.Req) } +func (proxy *PluginProxy) hasAccessToRoute(route *plugins.Route) bool { + useRBAC := proxy.features.IsEnabled(proxy.ctx.Req.Context(), featuremgmt.FlagAccessControlOnCall) && route.RequiresRBACAction() + if useRBAC { + hasAccess := ac.HasAccess(proxy.accessControl, proxy.ctx)(ac.EvalPermission(route.ReqAction)) + if !hasAccess { + proxy.ctx.Logger.Debug("plugin route is covered by RBAC, user doesn't have access", "route", proxy.ctx.Req.URL.Path) + } + return hasAccess + } + if route.ReqRole.IsValid() { + return proxy.ctx.HasUserRole(route.ReqRole) + } + return true +} + func (proxy PluginProxy) director(req *http.Request) { secureJsonData, err := proxy.secretsService.DecryptJsonData(proxy.ctx.Req.Context(), proxy.ps.SecureJSONData) if err != nil { @@ -202,6 +218,7 @@ func (proxy PluginProxy) logRequest() { } type templateData struct { + URL string JsonData map[string]any SecureJsonData map[string]string } diff --git a/pkg/api/pluginproxy/pluginproxy_test.go b/pkg/api/pluginproxy/pluginproxy_test.go index 89a5467c168d6..bbd9fe27de119 100644 --- a/pkg/api/pluginproxy/pluginproxy_test.go +++ b/pkg/api/pluginproxy/pluginproxy_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" @@ -27,7 +28,6 @@ import ( ) func TestPluginProxy(t *testing.T) { - setting.SecretKey = "password" secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) t.Run("When getting proxy headers", func(t *testing.T) { @@ -262,7 +262,8 @@ func TestPluginProxy(t *testing.T) { ps := &pluginsettings.DTO{ SecureJSONData: map[string][]byte{}, } - proxy, err := NewPluginProxy(ps, routes, ctx, "", &setting.Cfg{}, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, featuremgmt.WithFeatures()) + cfg := &setting.Cfg{} + proxy, err := NewPluginProxy(ps, routes, ctx, "", cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, acimpl.ProvideAccessControl(cfg), featuremgmt.WithFeatures()) require.NoError(t, err) proxy.HandleRequest() @@ -400,7 +401,8 @@ func TestPluginProxyRoutes(t *testing.T) { ps := &pluginsettings.DTO{ SecureJSONData: map[string][]byte{}, } - proxy, err := NewPluginProxy(ps, testRoutes, ctx, tc.proxyPath, &setting.Cfg{}, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, featuremgmt.WithFeatures()) + cfg := &setting.Cfg{} + proxy, err := NewPluginProxy(ps, testRoutes, ctx, tc.proxyPath, cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, acimpl.ProvideAccessControl(cfg), featuremgmt.WithFeatures()) require.NoError(t, err) proxy.HandleRequest() @@ -421,6 +423,121 @@ func TestPluginProxyRoutes(t *testing.T) { } } +func TestPluginProxyRoutesAccessControl(t *testing.T) { + routes := []*plugins.Route{ + { + Path: "settings", + Method: "GET", + URL: "http://localhost/api/settings", + ReqRole: org.RoleAdmin, // Protected by role + }, + { + Path: "projects", + Method: "GET", + URL: "http://localhost/api/projects", + ReqAction: "plugin-id.projects:read", // Protected by RBAC action + }, + } + + tcs := []struct { + proxyPath string + usrRole org.RoleType + usrPerms map[string][]string + expectedURLPath string + expectedStatus int + }{ + { + proxyPath: "/settings", + usrRole: org.RoleAdmin, + expectedURLPath: "/api/settings", + expectedStatus: http.StatusOK, + }, + { + proxyPath: "/settings", + usrRole: org.RoleViewer, + expectedURLPath: "/api/settings", + expectedStatus: http.StatusForbidden, + }, + { + proxyPath: "/projects", + usrPerms: map[string][]string{"plugin-id.projects:read": {}}, + expectedURLPath: "/api/projects", + expectedStatus: http.StatusOK, + }, + { + proxyPath: "/projects", + usrPerms: map[string][]string{}, + expectedURLPath: "/api/projects", + expectedStatus: http.StatusForbidden, + }, + } + + for _, tc := range tcs { + t.Run(fmt.Sprintf("Should enforce RBAC when proxying path %s %s", tc.proxyPath, http.StatusText(tc.expectedStatus)), func(t *testing.T) { + secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) + requestHandled := false + requestURL := "" + backendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestURL = r.URL.RequestURI() + w.WriteHeader(200) + _, _ = w.Write([]byte("I am the backend")) + requestHandled = true + })) + t.Cleanup(backendServer.Close) + + backendURL, err := url.Parse(backendServer.URL) + require.NoError(t, err) + + testRoutes := make([]*plugins.Route, len(routes)) + for i, r := range routes { + u, err := url.Parse(r.URL) + require.NoError(t, err) + u.Scheme = backendURL.Scheme + u.Host = backendURL.Host + testRoute := *r + testRoute.URL = u.String() + testRoutes[i] = &testRoute + } + + responseWriter := web.NewResponseWriter("GET", httptest.NewRecorder()) + + ctx := &contextmodel.ReqContext{ + Logger: logger.New("pluginproxy-test"), + SignedInUser: &user.SignedInUser{ + OrgID: 1, + OrgRole: tc.usrRole, + Permissions: map[int64]map[string][]string{1: tc.usrPerms}, + }, + Context: &web.Context{ + Req: httptest.NewRequest("GET", tc.proxyPath, nil), + Resp: responseWriter, + }, + } + ps := &pluginsettings.DTO{ + SecureJSONData: map[string][]byte{}, + } + cfg := &setting.Cfg{} + proxy, err := NewPluginProxy(ps, testRoutes, ctx, tc.proxyPath, cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, acimpl.ProvideAccessControl(cfg), featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall)) + require.NoError(t, err) + proxy.HandleRequest() + + for { + if requestHandled || ctx.Resp.Written() { + break + } + } + + require.Equal(t, tc.expectedStatus, ctx.Resp.Status()) + + if tc.expectedStatus == http.StatusForbidden { + return + } + + require.Equal(t, tc.expectedURLPath, requestURL) + }) + } +} + // getPluginProxiedRequest is a helper for easier setup of tests based on global config and ReqContext. func getPluginProxiedRequest(t *testing.T, ps *pluginsettings.DTO, secretsService secrets.Service, ctx *contextmodel.ReqContext, cfg *setting.Cfg, route *plugins.Route) *http.Request { // insert dummy route if none is specified @@ -431,7 +548,7 @@ func getPluginProxiedRequest(t *testing.T, ps *pluginsettings.DTO, secretsServic ReqRole: org.RoleEditor, } } - proxy, err := NewPluginProxy(ps, []*plugins.Route{}, ctx, "", cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, featuremgmt.WithFeatures()) + proxy, err := NewPluginProxy(ps, []*plugins.Route{}, ctx, "", cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, acimpl.ProvideAccessControl(cfg), featuremgmt.WithFeatures()) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "/api/plugin-proxy/grafana-simple-app/api/v4/alerts", nil) diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index f8cbf7ac66e0d..59209f4491053 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -21,7 +21,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/plugindef" + "github.com/grafana/grafana/pkg/plugins/pfs" "github.com/grafana/grafana/pkg/plugins/repo" ac "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" @@ -247,7 +247,7 @@ func (hs *HTTPServer) UpdatePluginSetting(c *contextmodel.ReqContext) response.R pluginID := web.Params(c.Req)[":pluginId"] if _, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID); !exists { - return response.Error(404, "Plugin not installed", nil) + return response.Error(http.StatusNotFound, "Plugin not installed", nil) } cmd.OrgId = c.SignedInUser.GetOrgID() @@ -262,7 +262,7 @@ func (hs *HTTPServer) UpdatePluginSetting(c *contextmodel.ReqContext) response.R OrgID: cmd.OrgId, EncryptedSecureJSONData: cmd.EncryptedSecureJsonData, }); err != nil { - return response.Error(500, "Failed to update plugin setting", err) + return response.Error(http.StatusInternalServerError, "Failed to update plugin setting", err) } hs.pluginContextProvider.InvalidateSettingsCache(c.Req.Context(), pluginID) @@ -274,7 +274,12 @@ func (hs *HTTPServer) GetPluginMarkdown(c *contextmodel.ReqContext) response.Res pluginID := web.Params(c.Req)[":pluginId"] name := web.Params(c.Req)[":name"] - content, err := hs.pluginMarkdown(c.Req.Context(), pluginID, name) + p, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID) + if !exists { + return response.Error(http.StatusNotFound, "Plugin not installed", nil) + } + + content, err := hs.pluginMarkdown(c.Req.Context(), pluginID, p.Info.Version, name) if err != nil { var notFound plugins.NotFoundError if errors.As(err, ¬Found) { @@ -286,7 +291,7 @@ func (hs *HTTPServer) GetPluginMarkdown(c *contextmodel.ReqContext) response.Res // fallback try readme if len(content) == 0 { - content, err = hs.pluginMarkdown(c.Req.Context(), pluginID, "readme") + content, err = hs.pluginMarkdown(c.Req.Context(), pluginID, p.Info.Version, "readme") if err != nil { if errors.Is(err, plugins.ErrFileNotExist) { return response.Error(http.StatusNotFound, plugins.ErrFileNotExist.Error(), nil) @@ -354,7 +359,7 @@ func (hs *HTTPServer) getPluginAssets(c *contextmodel.ReqContext) { // serveLocalPluginAsset returns the content of a plugin asset file from the local filesystem to the http client. func (hs *HTTPServer) serveLocalPluginAsset(c *contextmodel.ReqContext, plugin pluginstore.Plugin, assetPath string) { - f, err := hs.pluginFileStore.File(c.Req.Context(), plugin.ID, assetPath) + f, err := hs.pluginFileStore.File(c.Req.Context(), plugin.ID, plugin.Info.Version, assetPath) if err != nil { if errors.Is(err, plugins.ErrFileNotExist) { c.JsonApiErr(404, "Plugin file not found", nil) @@ -476,8 +481,12 @@ func (hs *HTTPServer) InstallPlugin(c *contextmodel.ReqContext) response.Respons func (hs *HTTPServer) UninstallPlugin(c *contextmodel.ReqContext) response.Response { pluginID := web.Params(c.Req)[":pluginId"] + plugin, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID) + if !exists { + return response.Error(http.StatusNotFound, "Plugin not installed", nil) + } - err := hs.pluginInstaller.Remove(c.Req.Context(), pluginID) + err := hs.pluginInstaller.Remove(c.Req.Context(), pluginID, plugin.Info.Version) if err != nil { if errors.Is(err, plugins.ErrPluginNotInstalled) { return response.Error(http.StatusNotFound, "Plugin not installed", err) @@ -494,19 +503,19 @@ func translatePluginRequestErrorToAPIError(err error) response.Response { return response.ErrOrFallback(http.StatusInternalServerError, "Plugin request failed", err) } -func (hs *HTTPServer) pluginMarkdown(ctx context.Context, pluginID string, name string) ([]byte, error) { +func (hs *HTTPServer) pluginMarkdown(ctx context.Context, pluginID, pluginVersion, name string) ([]byte, error) { file, err := mdFilepath(strings.ToUpper(name)) if err != nil { return make([]byte, 0), err } - md, err := hs.pluginFileStore.File(ctx, pluginID, file) + md, err := hs.pluginFileStore.File(ctx, pluginID, pluginVersion, file) if err != nil { if errors.Is(err, plugins.ErrPluginNotInstalled) { return make([]byte, 0), plugins.NotFoundError{PluginID: pluginID} } - md, err = hs.pluginFileStore.File(ctx, pluginID, strings.ToLower(file)) + md, err = hs.pluginFileStore.File(ctx, pluginID, pluginVersion, strings.ToLower(file)) if err != nil { return make([]byte, 0), nil } @@ -543,7 +552,7 @@ func (hs *HTTPServer) hasPluginRequestedPermissions(c *contextmodel.ReqContext, } // evalAllPermissions generates an evaluator with all permissions from the input slice -func evalAllPermissions(ps []plugindef.Permission) ac.Evaluator { +func evalAllPermissions(ps []pfs.Permission) ac.Evaluator { res := []ac.Evaluator{} for _, p := range ps { if p.Scope != nil { diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index 29eefb9476209..9fdcf916ebcb5 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -27,7 +27,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/fakes" "github.com/grafana/grafana/pkg/plugins/manager/filestore" "github.com/grafana/grafana/pkg/plugins/manager/registry" - "github.com/grafana/grafana/pkg/plugins/plugindef" + "github.com/grafana/grafana/pkg/plugins/pfs" "github.com/grafana/grafana/pkg/plugins/pluginscdn" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" @@ -49,6 +49,7 @@ func Test_PluginsInstallAndUninstall(t *testing.T) { canInstall := []ac.Permission{{Action: pluginaccesscontrol.ActionInstall}} cannotInstall := []ac.Permission{{Action: "plugins:cannotinstall"}} + pluginID := "grafana-test-datasource" localOrg := int64(1) globalOrg := int64(ac.GlobalOrgID) @@ -88,11 +89,17 @@ func Test_PluginsInstallAndUninstall(t *testing.T) { hs.pluginInstaller = NewFakePluginInstaller() hs.pluginFileStore = &fakes.FakePluginFileStore{} + hs.pluginStore = pluginstore.NewFakePluginStore(pluginstore.Plugin{ + JSONData: plugins.JSONData{ + ID: pluginID, + }, + }) }) t.Run(testName("Install", tc), func(t *testing.T) { input := strings.NewReader(`{"version": "1.0.2"}`) - req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/plugins/test/install", input), userWithPermissions(tc.permissionOrg, tc.permissions)) + endpoint := fmt.Sprintf("/api/plugins/%s/install", pluginID) + req := webtest.RequestWithSignedInUser(server.NewPostRequest(endpoint, input), userWithPermissions(tc.permissionOrg, tc.permissions)) res, err := server.SendJSON(req) require.NoError(t, err) require.Equal(t, tc.expectedCode, res.StatusCode) @@ -101,7 +108,8 @@ func Test_PluginsInstallAndUninstall(t *testing.T) { t.Run(testName("Uninstall", tc), func(t *testing.T) { input := strings.NewReader("{ }") - req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/plugins/test/uninstall", input), userWithPermissions(tc.permissionOrg, tc.permissions)) + endpoint := fmt.Sprintf("/api/plugins/%s/uninstall", pluginID) + req := webtest.RequestWithSignedInUser(server.NewPostRequest(endpoint, input), userWithPermissions(tc.permissionOrg, tc.permissions)) res, err := server.SendJSON(req) require.NoError(t, err) require.Equal(t, tc.expectedCode, res.StatusCode) @@ -401,14 +409,14 @@ func TestMakePluginResourceRequestContentTypeEmpty(t *testing.T) { func TestPluginMarkdown(t *testing.T) { t.Run("Plugin not installed returns error", func(t *testing.T) { pluginFileStore := &fakes.FakePluginFileStore{ - FileFunc: func(ctx context.Context, pluginID, filename string) (*plugins.File, error) { + FileFunc: func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) { return nil, plugins.ErrPluginNotInstalled }, } hs := HTTPServer{pluginFileStore: pluginFileStore} pluginID := "test-datasource" - md, err := hs.pluginMarkdown(context.Background(), pluginID, "test") + md, err := hs.pluginMarkdown(context.Background(), pluginID, "", "test") require.ErrorAs(t, err, &plugins.NotFoundError{PluginID: pluginID}) require.Equal(t, []byte{}, md) }) @@ -416,7 +424,7 @@ func TestPluginMarkdown(t *testing.T) { t.Run("File fetch will be retried using different casing if error occurs", func(t *testing.T) { var requestedFiles []string pluginFileStore := &fakes.FakePluginFileStore{ - FileFunc: func(ctx context.Context, pluginID, filename string) (*plugins.File, error) { + FileFunc: func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) { requestedFiles = append(requestedFiles, filename) return nil, errors.New("some error") }, @@ -424,7 +432,7 @@ func TestPluginMarkdown(t *testing.T) { hs := HTTPServer{pluginFileStore: pluginFileStore} - md, err := hs.pluginMarkdown(context.Background(), "", "reAdMe") + md, err := hs.pluginMarkdown(context.Background(), "", "", "reAdMe") require.NoError(t, err) require.Equal(t, []byte{}, md) require.Equal(t, []string{"README.md", "readme.md"}, requestedFiles) @@ -453,7 +461,7 @@ func TestPluginMarkdown(t *testing.T) { data := []byte{123} var requestedFiles []string pluginFileStore := &fakes.FakePluginFileStore{ - FileFunc: func(ctx context.Context, pluginID, filename string) (*plugins.File, error) { + FileFunc: func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) { requestedFiles = append(requestedFiles, filename) return &plugins.File{Content: data}, nil }, @@ -461,7 +469,7 @@ func TestPluginMarkdown(t *testing.T) { hs := HTTPServer{pluginFileStore: pluginFileStore} - md, err := hs.pluginMarkdown(context.Background(), "test-datasource", tc.filePath) + md, err := hs.pluginMarkdown(context.Background(), "test-datasource", "", tc.filePath) require.NoError(t, err) require.Equal(t, data, md) require.Equal(t, tc.expected, requestedFiles) @@ -471,7 +479,7 @@ func TestPluginMarkdown(t *testing.T) { t.Run("Non markdown file request returns an error", func(t *testing.T) { hs := HTTPServer{pluginFileStore: &fakes.FakePluginFileStore{}} - md, err := hs.pluginMarkdown(context.Background(), "", "test.json") + md, err := hs.pluginMarkdown(context.Background(), "", "", "test.json") require.ErrorIs(t, err, ErrUnexpectedFileExtension) require.Equal(t, []byte{}, md) }) @@ -480,14 +488,14 @@ func TestPluginMarkdown(t *testing.T) { data := []byte{1, 2, 3} pluginFileStore := &fakes.FakePluginFileStore{ - FileFunc: func(ctx context.Context, pluginID, filename string) (*plugins.File, error) { + FileFunc: func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) { return &plugins.File{Content: data}, nil }, } hs := HTTPServer{pluginFileStore: pluginFileStore} - md, err := hs.pluginMarkdown(context.Background(), "", "someFile") + md, err := hs.pluginMarkdown(context.Background(), "", "", "someFile") require.NoError(t, err) require.Equal(t, data, md) }) @@ -505,7 +513,7 @@ func pluginAssetScenario(t *testing.T, desc string, url string, urlPattern strin pluginStore: pluginstore.New(pluginRegistry, &fakes.FakeLoader{}), pluginFileStore: filestore.ProvideService(pluginRegistry), log: log.NewNopLogger(), - pluginsCDNService: pluginscdn.ProvideService(&config.Cfg{ + pluginsCDNService: pluginscdn.ProvideService(&config.PluginManagementCfg{ PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate, PluginSettings: cfg.PluginSettings, }), @@ -650,8 +658,8 @@ func TestHTTPServer_hasPluginRequestedPermissions(t *testing.T) { pluginReg := pluginstore.Plugin{ JSONData: plugins.JSONData{ ID: "grafana-test-app", - IAM: &plugindef.IAM{ - Permissions: []plugindef.Permission{{Action: ac.ActionUsersRead, Scope: newStr(ac.ScopeUsersAll)}, {Action: ac.ActionUsersCreate}}, + IAM: &pfs.IAM{ + Permissions: []pfs.Permission{{Action: ac.ActionUsersRead, Scope: newStr(ac.ScopeUsersAll)}, {Action: ac.ActionUsersCreate}}, }, }, } diff --git a/pkg/api/render.go b/pkg/api/render.go index 6a9811b0fd686..289d4d988c9dd 100644 --- a/pkg/api/render.go +++ b/pkg/api/render.go @@ -18,34 +18,31 @@ import ( func (hs *HTTPServer) RenderToPng(c *contextmodel.ReqContext) { queryReader, err := util.NewURLQueryReader(c.Req.URL) if err != nil { - c.Handle(hs.Cfg, 400, "Render parameters error", err) + c.Handle(hs.Cfg, http.StatusBadRequest, "Render parameters error", err) return } queryParams := fmt.Sprintf("?%s", c.Req.URL.RawQuery) - width, err := strconv.Atoi(queryReader.Get("width", "800")) - if err != nil { - c.Handle(hs.Cfg, 400, "Render parameters error", fmt.Errorf("cannot parse width as int: %s", err)) - return + width := c.QueryInt("width") + if width == 0 { + width = hs.Cfg.RendererDefaultImageWidth } - height, err := strconv.Atoi(queryReader.Get("height", "400")) - if err != nil { - c.Handle(hs.Cfg, 400, "Render parameters error", fmt.Errorf("cannot parse height as int: %s", err)) - return + height := c.QueryInt("height") + if height == 0 { + height = hs.Cfg.RendererDefaultImageHeight } timeout, err := strconv.Atoi(queryReader.Get("timeout", "60")) if err != nil { - c.Handle(hs.Cfg, 400, "Render parameters error", fmt.Errorf("cannot parse timeout as int: %s", err)) + c.Handle(hs.Cfg, http.StatusBadRequest, "Render parameters error", fmt.Errorf("cannot parse timeout as int: %s", err)) return } - scale, err := strconv.ParseFloat(queryReader.Get("scale", "1"), 64) - if err != nil { - c.Handle(hs.Cfg, 400, "Render parameters error", fmt.Errorf("cannot parse scale as float: %s", err)) - return + scale := c.QueryFloat64("scale") + if scale == 0 { + scale = hs.Cfg.RendererDefaultImageScale } headers := http.Header{} @@ -59,7 +56,9 @@ func (hs *HTTPServer) RenderToPng(c *contextmodel.ReqContext) { hs.log.Error("Failed to parse user id", "err", errID) } - result, err := hs.RenderService.Render(c.Req.Context(), rendering.Opts{ + encoding := queryReader.Get("encoding", "") + + result, err := hs.RenderService.Render(c.Req.Context(), rendering.RenderPNG, rendering.Opts{ TimeoutOpts: rendering.TimeoutOpts{ Timeout: time.Duration(timeout) * time.Second, }, @@ -72,7 +71,7 @@ func (hs *HTTPServer) RenderToPng(c *contextmodel.ReqContext) { Height: height, Path: web.Params(c.Req)["*"] + queryParams, Timezone: queryReader.Get("tz", ""), - Encoding: queryReader.Get("encoding", ""), + Encoding: encoding, ConcurrentLimit: hs.Cfg.RendererConcurrentRequestLimit, DeviceScaleFactor: scale, Headers: headers, @@ -80,15 +79,20 @@ func (hs *HTTPServer) RenderToPng(c *contextmodel.ReqContext) { }, nil) if err != nil { if errors.Is(err, rendering.ErrTimeout) { - c.Handle(hs.Cfg, 500, err.Error(), err) + c.Handle(hs.Cfg, http.StatusInternalServerError, err.Error(), err) return } - c.Handle(hs.Cfg, 500, "Rendering failed.", err) + c.Handle(hs.Cfg, http.StatusInternalServerError, "Rendering failed.", err) return } - c.Resp.Header().Set("Content-Type", "image/png") + if encoding == "pdf" { + c.Resp.Header().Set("Content-Type", "application/pdf") + } else { + c.Resp.Header().Set("Content-Type", "image/png") + } + c.Resp.Header().Set("Cache-Control", "private") http.ServeFile(c.Resp, c.Req, result.FilePath) } diff --git a/pkg/api/response/response.go b/pkg/api/response/response.go index 0b5a1e2f53c81..a1d9d24325568 100644 --- a/pkg/api/response/response.go +++ b/pkg/api/response/response.go @@ -311,10 +311,12 @@ func Respond(status int, body any) *NormalResponse { b = t case string: b = []byte(t) + case nil: + break default: var err error if b, err = json.Marshal(body); err != nil { - return Error(500, "body json marshal", err) + return Error(http.StatusInternalServerError, "body json marshal", err) } } diff --git a/pkg/api/response/response_test.go b/pkg/api/response/response_test.go index 5d8d10fdfa9a9..ec0c318194e53 100644 --- a/pkg/api/response/response_test.go +++ b/pkg/api/response/response_test.go @@ -125,3 +125,49 @@ func TestErrors(t *testing.T) { ) } } + +func TestRespond(t *testing.T) { + testCases := []struct { + name string + status int + body any + expected []byte + }{ + { + name: "with body of type []byte", + status: 200, + body: []byte("message body"), + expected: []byte("message body"), + }, + { + name: "with body of type string", + status: 400, + body: "message body", + expected: []byte("message body"), + }, + { + name: "with nil body", + status: 204, + body: nil, + expected: nil, + }, + { + name: "with body of type struct", + status: 200, + body: struct { + Name string + Value int + }{"name", 1}, + expected: []byte(`{"Name":"name","Value":1}`), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resp := Respond(tc.status, tc.body) + + require.Equal(t, tc.status, resp.status) + require.Equal(t, tc.expected, resp.body.Bytes()) + }) + } +} diff --git a/pkg/api/routing/routing.go b/pkg/api/routing/routing.go index c5f5ec09759b3..8d6dcc03c017f 100644 --- a/pkg/api/routing/routing.go +++ b/pkg/api/routing/routing.go @@ -1,6 +1,8 @@ package routing import ( + "net/http" + "github.com/grafana/grafana/pkg/api/response" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/web" @@ -8,7 +10,7 @@ import ( var ( ServerError = func(err error) response.Response { - return response.Error(500, "Server error", err) + return response.Error(http.StatusInternalServerError, "Server error", err) } ) diff --git a/pkg/api/search.go b/pkg/api/search.go index 0ff77b123f9fb..19c606f6c285f 100644 --- a/pkg/api/search.go +++ b/pkg/api/search.go @@ -31,7 +31,7 @@ func (hs *HTTPServer) Search(c *contextmodel.ReqContext) response.Response { permission := dashboardaccess.PERMISSION_VIEW if limit > 5000 { - return response.Error(422, "Limit is above maximum allowed (5000), use page parameter to access hits beyond limit", nil) + return response.Error(http.StatusUnprocessableEntity, "Limit is above maximum allowed (5000), use page parameter to access hits beyond limit", nil) } if c.Query("permission") == "Edit" { @@ -57,6 +57,7 @@ func (hs *HTTPServer) Search(c *contextmodel.ReqContext) response.Response { folderID, err := strconv.ParseInt(id, 10, 64) if err == nil { folderIDs = append(folderIDs, folderID) + metrics.MFolderIDsAPICount.WithLabelValues(metrics.Search).Inc() } } @@ -66,7 +67,7 @@ func (hs *HTTPServer) Search(c *contextmodel.ReqContext) response.Response { bothFolderIds := len(folderIDs) > 0 && len(folderUIDs) > 0 if bothDashboardIds || bothFolderIds { - return response.Error(400, "search supports UIDs or IDs, not both", nil) + return response.Error(http.StatusBadRequest, "search supports UIDs or IDs, not both", nil) } searchQuery := search.Query{ @@ -88,7 +89,7 @@ func (hs *HTTPServer) Search(c *contextmodel.ReqContext) response.Response { hits, err := hs.SearchService.SearchHandler(c.Req.Context(), &searchQuery) if err != nil { - return response.Error(500, "Search failed", err) + return response.Error(http.StatusInternalServerError, "Search failed", err) } defer c.TimeRequest(metrics.MApiDashboardSearch) diff --git a/pkg/api/short_url.go b/pkg/api/short_url.go index b7f3603c001ae..6a1292c65b8ae 100644 --- a/pkg/api/short_url.go +++ b/pkg/api/short_url.go @@ -26,7 +26,7 @@ func (hs *HTTPServer) createShortURL(c *contextmodel.ReqContext) response.Respon return response.Err(err) } - url := fmt.Sprintf("%s/goto/%s?orgId=%d", strings.TrimSuffix(setting.AppUrl, "/"), shortURL.Uid, c.SignedInUser.GetOrgID()) + url := fmt.Sprintf("%s/goto/%s?orgId=%d", strings.TrimSuffix(hs.Cfg.AppURL, "/"), shortURL.Uid, c.SignedInUser.GetOrgID()) c.Logger.Debug("Created short URL", "url", url) dto := dtos.ShortURL{ diff --git a/pkg/api/signup.go b/pkg/api/signup.go index 0f9310d35f66f..935817aaed761 100644 --- a/pkg/api/signup.go +++ b/pkg/api/signup.go @@ -14,7 +14,6 @@ import ( contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" tempuser "github.com/grafana/grafana/pkg/services/temp_user" "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -22,7 +21,7 @@ import ( // GET /api/user/signup/options func (hs *HTTPServer) GetSignUpOptions(c *contextmodel.ReqContext) response.Response { return response.JSON(http.StatusOK, util.DynMap{ - "verifyEmailEnabled": setting.VerifyEmailEnabled, + "verifyEmailEnabled": hs.Cfg.VerifyEmailEnabled, "autoAssignOrg": hs.Cfg.AutoAssignOrg, }) } @@ -34,8 +33,8 @@ func (hs *HTTPServer) SignUp(c *contextmodel.ReqContext) response.Response { if err = web.Bind(c.Req, &form); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } - if !setting.AllowUserSignUp { - return response.Error(401, "User signup is disabled", nil) + if !hs.Cfg.AllowUserSignUp { + return response.Error(http.StatusUnauthorized, "User signup is disabled", nil) } form.Email, err = ValidateAndNormalizeEmail(form.Email) @@ -46,7 +45,7 @@ func (hs *HTTPServer) SignUp(c *contextmodel.ReqContext) response.Response { existing := user.GetUserByLoginQuery{LoginOrEmail: form.Email} _, err = hs.userService.GetByLogin(c.Req.Context(), &existing) if err == nil { - return response.Error(422, "User with same email address already exists", nil) + return response.Error(http.StatusUnprocessableEntity, "User with same email address already exists", nil) } userID, errID := identity.UserIdentifier(c.SignedInUser.GetNamespacedID()) @@ -61,19 +60,19 @@ func (hs *HTTPServer) SignUp(c *contextmodel.ReqContext) response.Response { cmd.InvitedByUserID = userID cmd.Code, err = util.GetRandomString(20) if err != nil { - return response.Error(500, "Failed to generate random string", err) + return response.Error(http.StatusInternalServerError, "Failed to generate random string", err) } cmd.RemoteAddr = c.RemoteAddr() if _, err := hs.tempUserService.CreateTempUser(c.Req.Context(), &cmd); err != nil { - return response.Error(500, "Failed to create signup", err) + return response.Error(http.StatusInternalServerError, "Failed to create signup", err) } if err := hs.bus.Publish(c.Req.Context(), &events.SignUpStarted{ Email: form.Email, Code: cmd.Code, }); err != nil { - return response.Error(500, "Failed to publish event", err) + return response.Error(http.StatusInternalServerError, "Failed to publish event", err) } metrics.MApiUserSignUpStarted.Inc() @@ -86,8 +85,8 @@ func (hs *HTTPServer) SignUpStep2(c *contextmodel.ReqContext) response.Response if err := web.Bind(c.Req, &form); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } - if !setting.AllowUserSignUp { - return response.Error(401, "User signup is disabled", nil) + if !hs.Cfg.AllowUserSignUp { + return response.Error(http.StatusUnauthorized, "User signup is disabled", nil) } form.Email = strings.TrimSpace(form.Email) @@ -102,7 +101,7 @@ func (hs *HTTPServer) SignUpStep2(c *contextmodel.ReqContext) response.Response } // verify email - if setting.VerifyEmailEnabled { + if hs.Cfg.VerifyEmailEnabled { if ok, rsp := hs.verifyUserSignUpEmail(c.Req.Context(), form.Email, form.Code); !ok { return rsp } @@ -112,10 +111,10 @@ func (hs *HTTPServer) SignUpStep2(c *contextmodel.ReqContext) response.Response usr, err := hs.userService.Create(c.Req.Context(), &createUserCmd) if err != nil { if errors.Is(err, user.ErrUserAlreadyExists) { - return response.Error(401, "User with same email address already exists", nil) + return response.Error(http.StatusUnauthorized, "User with same email address already exists", nil) } - return response.Error(500, "Failed to create user", err) + return response.Error(http.StatusInternalServerError, "Failed to create user", err) } // publish signup event @@ -123,7 +122,7 @@ func (hs *HTTPServer) SignUpStep2(c *contextmodel.ReqContext) response.Response Email: usr.Email, Name: usr.NameOrFallback(), }); err != nil { - return response.Error(500, "Failed to publish event", err) + return response.Error(http.StatusInternalServerError, "Failed to publish event", err) } // mark temp user as completed @@ -135,7 +134,7 @@ func (hs *HTTPServer) SignUpStep2(c *contextmodel.ReqContext) response.Response invitesQuery := tempuser.GetTempUsersQuery{Email: form.Email, Status: tempuser.TmpUserInvitePending} invitesQueryResult, err := hs.tempUserService.GetTempUsersQuery(c.Req.Context(), &invitesQuery) if err != nil { - return response.Error(500, "Failed to query database for invites", err) + return response.Error(http.StatusInternalServerError, "Failed to query database for invites", err) } apiResponse := util.DynMap{"message": "User sign up completed successfully", "code": "redirect-to-landing-page"} @@ -148,7 +147,7 @@ func (hs *HTTPServer) SignUpStep2(c *contextmodel.ReqContext) response.Response err = hs.loginUserWithUser(usr, c) if err != nil { - return response.Error(500, "failed to login user", err) + return response.Error(http.StatusInternalServerError, "failed to login user", err) } metrics.MApiUserSignUpCompleted.Inc() @@ -162,14 +161,14 @@ func (hs *HTTPServer) verifyUserSignUpEmail(ctx context.Context, email string, c queryResult, err := hs.tempUserService.GetTempUserByCode(ctx, &query) if err != nil { if errors.Is(err, tempuser.ErrTempUserNotFound) { - return false, response.Error(404, "Invalid email verification code", nil) + return false, response.Error(http.StatusNotFound, "Invalid email verification code", nil) } - return false, response.Error(500, "Failed to read temp user", err) + return false, response.Error(http.StatusInternalServerError, "Failed to read temp user", err) } tempUser := queryResult if tempUser.Email != email { - return false, response.Error(404, "Email verification code does not match email", nil) + return false, response.Error(http.StatusNotFound, "Email verification code does not match email", nil) } return true, nil diff --git a/pkg/api/user.go b/pkg/api/user.go index d3be2f4766138..49a9572a79a9c 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -4,8 +4,11 @@ import ( "context" "errors" "net/http" + "net/mail" + "net/url" "strconv" "strings" + "time" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" @@ -14,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team" + tempuser "github.com/grafana/grafana/pkg/services/temp_user" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -61,9 +65,9 @@ func (hs *HTTPServer) getUserUserProfile(c *contextmodel.ReqContext, userID int6 userProfile, err := hs.userService.GetProfile(c.Req.Context(), &query) if err != nil { if errors.Is(err, user.ErrUserNotFound) { - return response.Error(404, user.ErrUserNotFound.Error(), nil) + return response.Error(http.StatusNotFound, user.ErrUserNotFound.Error(), nil) } - return response.Error(500, "Failed to get user", err) + return response.Error(http.StatusInternalServerError, "Failed to get user", err) } getAuthQuery := login.GetAuthInfoQuery{UserId: userID} @@ -78,8 +82,8 @@ func (hs *HTTPServer) getUserUserProfile(c *contextmodel.ReqContext, userID int6 userProfile.IsGrafanaAdminExternallySynced = login.IsGrafanaAdminExternallySynced(hs.Cfg, oauthInfo, authInfo.AuthModule) } - userProfile.AccessControl = hs.getAccessControlMetadata(c, c.SignedInUser.GetOrgID(), "global.users:id:", strconv.FormatInt(userID, 10)) - userProfile.AvatarURL = dtos.GetGravatarUrl(userProfile.Email) + userProfile.AccessControl = hs.getAccessControlMetadata(c, "global.users:id:", strconv.FormatInt(userID, 10)) + userProfile.AvatarURL = dtos.GetGravatarUrl(hs.Cfg, userProfile.Email) return response.JSON(http.StatusOK, userProfile) } @@ -99,9 +103,9 @@ func (hs *HTTPServer) GetUserByLoginOrEmail(c *contextmodel.ReqContext) response usr, err := hs.userService.GetByLogin(c.Req.Context(), &query) if err != nil { if errors.Is(err, user.ErrUserNotFound) { - return response.Error(404, user.ErrUserNotFound.Error(), nil) + return response.Error(http.StatusNotFound, user.ErrUserNotFound.Error(), nil) } - return response.Error(500, "Failed to get user", err) + return response.Error(http.StatusInternalServerError, "Failed to get user", err) } result := user.UserProfileDTO{ ID: usr.ID, @@ -125,6 +129,7 @@ func (hs *HTTPServer) GetUserByLoginOrEmail(c *contextmodel.ReqContext) response // 200: okResponse // 401: unauthorisedError // 403: forbiddenError +// 409: conflictError // 500: internalServerError func (hs *HTTPServer) UpdateSignedInUser(c *contextmodel.ReqContext) response.Response { cmd := user.UpdateUserCommand{} @@ -141,11 +146,11 @@ func (hs *HTTPServer) UpdateSignedInUser(c *contextmodel.ReqContext) response.Re return errResponse } - if hs.Cfg.AuthProxyEnabled { - if hs.Cfg.AuthProxyHeaderProperty == "email" && cmd.Email != c.SignedInUser.GetEmail() { + if hs.Cfg.AuthProxy.Enabled { + if hs.Cfg.AuthProxy.HeaderProperty == "email" && cmd.Email != c.SignedInUser.GetEmail() { return response.Error(http.StatusBadRequest, "Not allowed to change email when auth proxy is using email property", nil) } - if hs.Cfg.AuthProxyHeaderProperty == "username" && cmd.Login != c.SignedInUser.GetLogin() { + if hs.Cfg.AuthProxy.HeaderProperty == "username" && cmd.Login != c.SignedInUser.GetLogin() { return response.Error(http.StatusBadRequest, "Not allowed to change username when auth proxy is using username property", nil) } } @@ -165,6 +170,7 @@ func (hs *HTTPServer) UpdateSignedInUser(c *contextmodel.ReqContext) response.Re // 401: unauthorisedError // 403: forbiddenError // 404: notFoundError +// 409: conflictError // 500: internalServerError func (hs *HTTPServer) UpdateUser(c *contextmodel.ReqContext) response.Response { cmd := user.UpdateUserCommand{} @@ -196,13 +202,13 @@ func (hs *HTTPServer) UpdateUserActiveOrg(c *contextmodel.ReqContext) response.R } if !hs.validateUsingOrg(c.Req.Context(), userID, orgID) { - return response.Error(401, "Not a valid organization", nil) + return response.Error(http.StatusUnauthorized, "Not a valid organization", nil) } cmd := user.SetUsingOrgCommand{UserID: userID, OrgID: orgID} if err := hs.userService.SetUsingOrg(c.Req.Context(), &cmd); err != nil { - return response.Error(500, "Failed to change active organization", err) + return response.Error(http.StatusInternalServerError, "Failed to change active organization", err) } return response.Success("Active organization changed") @@ -228,6 +234,36 @@ func (hs *HTTPServer) handleUpdateUser(ctx context.Context, cmd user.UpdateUserC return response.Err(user.ErrEmptyUsernameAndEmail.Errorf("user cannot be created with empty username and email")) } + // If email is being updated, we need to verify it. Likewise, if username is being updated and the new username + // is an email, we also need to verify it. + // To avoid breaking changes, email verification is implemented in a way that if the email field is being updated, + // all the other fields being updated in the same request are disregarded. We do this because email might need to + // be verified and if so, it goes through a different code flow. + if hs.Cfg.Smtp.Enabled && hs.Cfg.VerifyEmailEnabled { + query := user.GetUserByIDQuery{ID: cmd.UserID} + usr, err := hs.userService.GetByID(ctx, &query) + if err != nil { + if errors.Is(err, user.ErrUserNotFound) { + return response.Error(http.StatusNotFound, user.ErrUserNotFound.Error(), err) + } + return response.Error(http.StatusInternalServerError, "Failed to get user", err) + } + + if len(cmd.Email) != 0 && usr.Email != cmd.Email { + normalized, err := ValidateAndNormalizeEmail(cmd.Email) + if err != nil { + return response.Error(http.StatusBadRequest, "Invalid email address", err) + } + return hs.verifyEmailUpdate(ctx, normalized, user.EmailUpdateAction, usr) + } + if len(cmd.Login) != 0 && usr.Login != cmd.Login { + normalized, err := ValidateAndNormalizeEmail(cmd.Login) + if err == nil && usr.Email != normalized { + return hs.verifyEmailUpdate(ctx, cmd.Login, user.LoginUpdateAction, usr) + } + } + } + if err := hs.userService.Update(ctx, &cmd); err != nil { if errors.Is(err, user.ErrCaseInsensitive) { return response.Error(http.StatusConflict, "Update would result in user login conflict", err) @@ -238,6 +274,61 @@ func (hs *HTTPServer) handleUpdateUser(ctx context.Context, cmd user.UpdateUserC return response.Success("User updated") } +func (hs *HTTPServer) verifyEmailUpdate(ctx context.Context, email string, field user.UpdateEmailActionType, usr *user.User) response.Response { + if err := hs.userVerifier.VerifyEmail(ctx, user.VerifyEmailCommand{ + User: *usr, + Email: email, + Action: field, + }); err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to generate email verification", err) + } + + return response.Success("Email sent for verification") +} + +// swagger:route GET /user/email/update user updateUserEmail +// +// Update user email. +// +// Update the email of user given a verification code. +// +// Responses: +// 302: okResponse +func (hs *HTTPServer) UpdateUserEmail(c *contextmodel.ReqContext) response.Response { + var err error + + q := c.Req.URL.Query() + code, err := url.QueryUnescape(q.Get("code")) + if err != nil || code == "" { + return hs.RedirectResponseWithError(c, errors.New("bad request data")) + } + + tempUser, err := hs.validateEmailCode(c.Req.Context(), code) + if err != nil { + return hs.RedirectResponseWithError(c, err) + } + + cmd, err := hs.updateCmdFromEmailVerification(c.Req.Context(), tempUser) + if err != nil { + return hs.RedirectResponseWithError(c, err) + } + + if err := hs.userService.Update(c.Req.Context(), cmd); err != nil { + if errors.Is(err, user.ErrCaseInsensitive) { + return hs.RedirectResponseWithError(c, errors.New("update would result in user login conflict")) + } + return hs.RedirectResponseWithError(c, errors.New("failed to update user")) + } + + // Mark temp user as completed + updateTmpUserCmd := tempuser.UpdateTempUserStatusCommand{Code: code, Status: tempuser.TmpUserEmailUpdateCompleted} + if err := hs.tempUserService.UpdateTempUserStatus(c.Req.Context(), &updateTmpUserCmd); err != nil { + return hs.RedirectResponseWithError(c, errors.New("failed to update verification status")) + } + + return response.Redirect(hs.Cfg.AppSubURL + "/profile") +} + func (hs *HTTPServer) isExternalUser(ctx context.Context, userID int64) (bool, error) { getAuthQuery := login.GetAuthInfoQuery{UserId: userID} var err error @@ -324,7 +415,7 @@ func (hs *HTTPServer) getUserTeamList(c *contextmodel.ReqContext, orgID int64, u } for _, team := range queryResult { - team.AvatarURL = dtos.GetGravatarUrlWithDefault(team.Email, team.Name) + team.AvatarURL = dtos.GetGravatarUrlWithDefault(hs.Cfg, team.Email, team.Name) } return response.JSON(http.StatusOK, queryResult) } @@ -490,24 +581,25 @@ func (hs *HTTPServer) ChangeUserPassword(c *contextmodel.ReqContext) response.Re } } - passwordHashed, err := util.EncodePassword(cmd.OldPassword, usr.Salt) + passwordHashed, err := util.EncodePassword(string(cmd.OldPassword), usr.Salt) if err != nil { return response.Error(http.StatusInternalServerError, "Failed to encode password", err) } - if passwordHashed != usr.Password { + if user.Password(passwordHashed) != usr.Password { return response.Error(http.StatusUnauthorized, "Invalid old password", nil) } - password := user.Password(cmd.NewPassword) - if password.IsWeak() { - return response.Error(http.StatusBadRequest, "New password is too short", nil) + if err := cmd.NewPassword.Validate(hs.Cfg); err != nil { + c.Logger.Warn("the new password doesn't meet the password policy criteria", "err", err) + return response.Err(err) } cmd.UserID = userID - cmd.NewPassword, err = util.EncodePassword(cmd.NewPassword, usr.Salt) + encodedPassword, err := util.EncodePassword(string(cmd.NewPassword), usr.Salt) if err != nil { return response.Error(http.StatusInternalServerError, "Failed to encode password", err) } + cmd.NewPassword = user.Password(encodedPassword) if err := hs.userService.ChangePassword(c.Req.Context(), &cmd); err != nil { return response.Error(http.StatusInternalServerError, "Failed to change user password", err) @@ -582,7 +674,7 @@ func (hs *HTTPServer) ClearHelpFlags(c *contextmodel.ReqContext) response.Respon } if err := hs.userService.SetUserHelpFlag(c.Req.Context(), &cmd); err != nil { - return response.Error(500, "Failed to update help flag", err) + return response.Error(http.StatusInternalServerError, "Failed to update help flag", err) } return response.JSON(http.StatusOK, &util.DynMap{"message": "Help flag set", "helpFlags1": cmd.HelpFlags1}) @@ -602,6 +694,57 @@ func getUserID(c *contextmodel.ReqContext) (int64, *response.NormalResponse) { return userID, nil } +func (hs *HTTPServer) updateCmdFromEmailVerification(ctx context.Context, tempUser *tempuser.TempUserDTO) (*user.UpdateUserCommand, error) { + userQuery := user.GetUserByLoginQuery{LoginOrEmail: tempUser.InvitedByLogin} + usr, err := hs.userService.GetByLogin(ctx, &userQuery) + if err != nil { + if errors.Is(err, user.ErrUserNotFound) { + return nil, user.ErrUserNotFound + } + return nil, errors.New("failed to get user") + } + + cmd := &user.UpdateUserCommand{UserID: usr.ID, Email: tempUser.Email} + + switch tempUser.Name { + case string(user.EmailUpdateAction): + // User updated the email field + if _, err := mail.ParseAddress(usr.Login); err == nil { + // If username was also an email, we update it to keep it in sync with the email field + cmd.Login = tempUser.Email + } + case string(user.LoginUpdateAction): + // User updated the username field with a new email + cmd.Login = tempUser.Email + default: + return nil, errors.New("trying to update email on unknown field") + } + return cmd, nil +} + +func (hs *HTTPServer) validateEmailCode(ctx context.Context, code string) (*tempuser.TempUserDTO, error) { + tempUserQuery := tempuser.GetTempUserByCodeQuery{Code: code} + tempUser, err := hs.tempUserService.GetTempUserByCode(ctx, &tempUserQuery) + if err != nil { + if errors.Is(err, tempuser.ErrTempUserNotFound) { + return nil, errors.New("invalid email verification code") + } + return nil, errors.New("failed to read temp user") + } + + if tempUser.Status != tempuser.TmpUserEmailUpdateStarted { + return nil, errors.New("invalid email verification code") + } + if !tempUser.EmailSent { + return nil, errors.New("verification email was not recorded as sent") + } + if tempUser.EmailSentOn.Add(hs.Cfg.VerificationEmailMaxLifetime).Before(time.Now()) { + return nil, errors.New("invalid email verification code") + } + + return tempUser, nil +} + // swagger:parameters searchUsers type SearchUsersParams struct { // Limit the maximum number of users to return per page diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index 16c9fcfaf513f..960d592136c14 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -5,9 +5,18 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" + "strings" "testing" "time" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/notifications" + "github.com/grafana/grafana/pkg/services/secrets/fakes" + tempuser "github.com/grafana/grafana/pkg/services/temp_user" + "github.com/grafana/grafana/pkg/services/temp_user/tempuserimpl" + "github.com/grafana/grafana/pkg/web/webtest" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" @@ -18,6 +27,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" + "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/login/social/socialtest" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" @@ -39,6 +49,8 @@ import ( "github.com/grafana/grafana/pkg/setting" ) +const newEmail = "newEmail@localhost" + func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { settings := setting.NewCfg() sqlStore := db.InitTestDB(t) @@ -64,12 +76,10 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) authInfoStore := authinfoimpl.ProvideStore(sqlStore, secretsService) srv := authinfoimpl.ProvideService( - authInfoStore, - ) + authInfoStore, remotecache.NewFakeCacheStorage(), secretsService) hs.authInfoService = srv orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotatest.New(false, nil)) require.NoError(t, err) - require.NoError(t, err) userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sc.cfg, nil, nil, quotatest.New(false, nil), supportbundlestest.NewFakeBundleService()) require.NoError(t, err) hs.userService = userSvc @@ -82,6 +92,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { } usr, err := userSvc.Create(context.Background(), &createUserCmd) require.NoError(t, err) + theUserUID := usr.UID sc.handlerFunc = hs.GetUserByID @@ -103,11 +114,12 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { } err = srv.UpdateAuthInfo(context.Background(), cmd) require.NoError(t, err) - avatarUrl := dtos.GetGravatarUrl("@test.com") + avatarUrl := dtos.GetGravatarUrl(hs.Cfg, "@test.com") sc.fakeReqWithParams("GET", sc.url, map[string]string{"id": fmt.Sprintf("%v", usr.ID)}).exec() expected := user.UserProfileDTO{ ID: 1, + UID: theUserUID, // from original request Email: "user@test.com", Name: "user", Login: "loginuser", @@ -160,7 +172,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { loggedInUserScenario(t, "When calling GET on", "/api/users", "/api/users", func(sc *scenarioContext) { userMock.ExpectedSearchUsers = mockResult - searchUsersService := searchusers.ProvideUsersService(filters.ProvideOSSSearchUserFilter(), userMock) + searchUsersService := searchusers.ProvideUsersService(sc.cfg, filters.ProvideOSSSearchUserFilter(), userMock) sc.handlerFunc = searchUsersService.SearchUsers sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() @@ -173,7 +185,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { loggedInUserScenario(t, "When calling GET with page and limit querystring parameters on", "/api/users", "/api/users", func(sc *scenarioContext) { userMock.ExpectedSearchUsers = mockResult - searchUsersService := searchusers.ProvideUsersService(filters.ProvideOSSSearchUserFilter(), userMock) + searchUsersService := searchusers.ProvideUsersService(sc.cfg, filters.ProvideOSSSearchUserFilter(), userMock) sc.handlerFunc = searchUsersService.SearchUsers sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec() @@ -186,7 +198,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { loggedInUserScenario(t, "When calling GET on", "/api/users/search", "/api/users/search", func(sc *scenarioContext) { userMock.ExpectedSearchUsers = mockResult - searchUsersService := searchusers.ProvideUsersService(filters.ProvideOSSSearchUserFilter(), userMock) + searchUsersService := searchusers.ProvideUsersService(sc.cfg, filters.ProvideOSSSearchUserFilter(), userMock) sc.handlerFunc = searchUsersService.SearchUsersWithPaging sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() @@ -202,7 +214,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { loggedInUserScenario(t, "When calling GET with page and perpage querystring parameters on", "/api/users/search", "/api/users/search", func(sc *scenarioContext) { userMock.ExpectedSearchUsers = mockResult - searchUsersService := searchusers.ProvideUsersService(filters.ProvideOSSSearchUserFilter(), userMock) + searchUsersService := searchusers.ProvideUsersService(sc.cfg, filters.ProvideOSSSearchUserFilter(), userMock) sc.handlerFunc = searchUsersService.SearchUsersWithPaging sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec() @@ -300,9 +312,9 @@ func Test_GetUserByID(t *testing.T) { case login.GenericOAuthModule: socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{AllowAssignGrafanaAdmin: tc.allowAssignGrafanaAdmin, Enabled: tc.authEnabled, SkipOrgRoleSync: tc.skipOrgRoleSync} case login.JWTModule: - cfg.JWTAuthEnabled = tc.authEnabled - cfg.JWTAuthSkipOrgRoleSync = tc.skipOrgRoleSync - cfg.JWTAuthAllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin + cfg.JWTAuth.Enabled = tc.authEnabled + cfg.JWTAuth.SkipOrgRoleSync = tc.skipOrgRoleSync + cfg.JWTAuth.AllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin } hs := &HTTPServer{ @@ -361,6 +373,685 @@ func TestHTTPServer_UpdateUser(t *testing.T) { }, hs) } +func setupUpdateEmailTests(t *testing.T, cfg *setting.Cfg) (*user.User, *HTTPServer, *notifications.NotificationServiceMock) { + t.Helper() + + sqlStore := db.InitTestDB(t) + sqlStore.Cfg = cfg + + tempUserService := tempuserimpl.ProvideService(sqlStore, cfg) + orgSvc, err := orgimpl.ProvideService(sqlStore, cfg, quotatest.New(false, nil)) + require.NoError(t, err) + userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, cfg, nil, nil, quotatest.New(false, nil), supportbundlestest.NewFakeBundleService()) + require.NoError(t, err) + + // Create test user + createUserCmd := user.CreateUserCommand{ + Email: "testuser@localhost", + Name: "testuser", + Login: "loginuser", + Company: "testCompany", + IsAdmin: true, + } + usr, err := userSvc.Create(context.Background(), &createUserCmd) + require.NoError(t, err) + + nsMock := notifications.MockNotificationService() + verifier := userimpl.ProvideVerifier(userSvc, tempUserService, nsMock) + + hs := &HTTPServer{ + Cfg: cfg, + SQLStore: sqlStore, + userService: userSvc, + tempUserService: tempUserService, + NotificationService: nsMock, + userVerifier: verifier, + } + return usr, hs, nsMock +} + +func TestUser_UpdateEmail(t *testing.T) { + cases := []struct { + Name string + Field user.UpdateEmailActionType + }{ + { + Name: "Updating Email field", + Field: user.EmailUpdateAction, + }, + { + Name: "Updating Login (username) field", + Field: user.LoginUpdateAction, + }, + } + + for _, tt := range cases { + t.Run(tt.Name, func(t *testing.T) { + t.Run("With verification disabled should update without verifying", func(t *testing.T) { + tests := []struct { + name string + smtpConfigured bool + verifyEmailEnabled bool + }{ + { + name: "SMTP not configured", + smtpConfigured: false, + verifyEmailEnabled: true, + }, + { + name: "config verify_email_enabled = false", + smtpConfigured: true, + verifyEmailEnabled: false, + }, + { + name: "config verify_email_enabled = false and SMTP not configured", + smtpConfigured: false, + verifyEmailEnabled: false, + }, + } + for _, ttt := range tests { + settings := setting.NewCfg() + settings.Smtp.Enabled = ttt.smtpConfigured + settings.VerifyEmailEnabled = ttt.verifyEmailEnabled + + usr, hs, nsMock := setupUpdateEmailTests(t, settings) + + updateUserCommand := user.UpdateUserCommand{ + Email: usr.Email, + Name: "newName", + Login: usr.Login, + UserID: usr.ID, + } + + switch tt.Field { + case user.LoginUpdateAction: + updateUserCommand.Login = newEmail + case user.EmailUpdateAction: + updateUserCommand.Email = newEmail + } + + fn := func(sc *scenarioContext) { + // User is internal + sc.authInfoService.ExpectedError = user.ErrUserNotFound + + sc.fakeReqWithParams("PUT", sc.url, nil).exec() + assert.Equal(t, http.StatusOK, sc.resp.Code) + + // Verify that no email has been sent after update + require.False(t, nsMock.EmailVerified) + + userQuery := user.GetUserByIDQuery{ID: usr.ID} + updatedUsr, err := hs.userService.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + + // Verify fields have been updated + require.NotEqual(t, usr.Name, updatedUsr.Name) + require.Equal(t, updateUserCommand.Name, updatedUsr.Name) + + switch tt.Field { + case user.LoginUpdateAction: + require.Equal(t, usr.Email, updatedUsr.Email) + require.NotEqual(t, usr.Login, updatedUsr.Login) + require.Equal(t, updateUserCommand.Login, updatedUsr.Login) + case user.EmailUpdateAction: + require.Equal(t, usr.Login, updatedUsr.Login) + require.NotEqual(t, usr.Email, updatedUsr.Email) + require.Equal(t, updateUserCommand.Email, updatedUsr.Email) + } + + // Verify other fields have been kept + require.Equal(t, usr.Company, updatedUsr.Company) + } + + updateUserScenario(t, updateUserContext{ + desc: ttt.name, + url: fmt.Sprintf("/api/users/%d", usr.ID), + routePattern: "/api/users/:id", + cmd: updateUserCommand, + fn: fn, + }, hs) + + updateSignedInUserScenario(t, updateUserContext{ + desc: ttt.name, + url: "/api/user", + routePattern: "/api/user", + cmd: updateUserCommand, + fn: fn, + }, hs) + } + }) + }) + } + + doReq := func(req *http.Request, usr *user.User) (*http.Response, error) { + r := webtest.RequestWithSignedInUser( + req, + authedUserWithPermissions( + usr.ID, + usr.OrgID, + []accesscontrol.Permission{ + { + Action: accesscontrol.ActionUsersWrite, + Scope: accesscontrol.ScopeGlobalUsersAll, + }, + }, + ), + ) + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }} + return client.Do(r) + } + + sendUpdateReq := func(server *webtest.Server, usr *user.User, body string) { + req := server.NewRequest( + http.MethodPut, + "/api/user", + strings.NewReader(body), + ) + req.Header.Add("Content-Type", "application/json") + res, err := doReq(req, usr) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + require.NoError(t, res.Body.Close()) + } + + sendVerificationReq := func(server *webtest.Server, usr *user.User, code string) { + url := fmt.Sprintf("/user/email/update?code=%s", url.QueryEscape(code)) + req := server.NewGetRequest(url) + res, err := doReq(req, usr) + require.NoError(t, err) + assert.Equal(t, http.StatusFound, res.StatusCode) + require.NoError(t, res.Body.Close()) + } + + getVerificationTempUser := func(tempUserSvc tempuser.Service, code string) *tempuser.TempUserDTO { + tmpUserQuery := tempuser.GetTempUserByCodeQuery{Code: code} + tmpUser, err := tempUserSvc.GetTempUserByCode(context.Background(), &tmpUserQuery) + require.NoError(t, err) + return tmpUser + } + + verifyEmailData := func(tempUserSvc tempuser.Service, nsMock *notifications.NotificationServiceMock, originalUsr *user.User, newEmail string) { + verification := nsMock.EmailVerification + tmpUsr := getVerificationTempUser(tempUserSvc, verification.Code) + + require.True(t, nsMock.EmailVerified) + require.Equal(t, newEmail, verification.Email) + require.Equal(t, originalUsr.ID, verification.User.ID) + require.Equal(t, tmpUsr.Code, verification.Code) + } + + verifyUserNotUpdated := func(userSvc user.Service, usr *user.User) { + userQuery := user.GetUserByIDQuery{ID: usr.ID} + checkUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.Equal(t, usr.Email, checkUsr.Email) + require.Equal(t, usr.Login, checkUsr.Login) + require.Equal(t, usr.Name, checkUsr.Name) + } + + setupScenario := func(cfg *setting.Cfg) (*webtest.Server, user.Service, tempuser.Service, *notifications.NotificationServiceMock) { + settings := setting.NewCfg() + settings.Smtp.Enabled = true + settings.VerificationEmailMaxLifetime = 1 * time.Hour + settings.VerifyEmailEnabled = true + + if cfg != nil { + settings = cfg + } + + nsMock := notifications.MockNotificationService() + sqlStore := db.InitTestDB(t) + sqlStore.Cfg = settings + + tempUserSvc := tempuserimpl.ProvideService(sqlStore, settings) + orgSvc, err := orgimpl.ProvideService(sqlStore, settings, quotatest.New(false, nil)) + require.NoError(t, err) + userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, settings, nil, nil, quotatest.New(false, nil), supportbundlestest.NewFakeBundleService()) + require.NoError(t, err) + + server := SetupAPITestServer(t, func(hs *HTTPServer) { + hs.Cfg = settings + + hs.SQLStore = sqlStore + hs.userService = userSvc + hs.tempUserService = tempUserSvc + hs.NotificationService = nsMock + hs.SecretsService = fakes.NewFakeSecretsService() + hs.userVerifier = userimpl.ProvideVerifier(userSvc, tempUserSvc, nsMock) + // User is internal + hs.authInfoService = &authinfotest.FakeService{ExpectedError: user.ErrUserNotFound} + }) + + return server, userSvc, tempUserSvc, nsMock + } + + createUser := func(userSvc user.Service, name string, email string, login string) *user.User { + createUserCmd := user.CreateUserCommand{ + Email: email, + Name: name, + Login: login, + Company: "testCompany", + IsAdmin: true, + } + usr, err := userSvc.Create(context.Background(), &createUserCmd) + require.NoError(t, err) + return usr + } + + t.Run("Update Email and disregard other fields", func(t *testing.T) { + server, userSvc, tempUserSvc, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + newName := "newName" + body := fmt.Sprintf(`{"email": "%s", "name": "%s"}`, newEmail, newName) + sendUpdateReq(server, originalUsr, body) + + // Verify email data + verifyEmailData(tempUserSvc, nsMock, originalUsr, newEmail) + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Second part of the verification flow, when user clicks email button + code := nsMock.EmailVerification.Code + sendVerificationReq(server, originalUsr, code) + + // Verify Email has been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, originalUsr.Email, updatedUsr.Email) + require.Equal(t, newEmail, updatedUsr.Email) + // Fields unchanged + require.Equal(t, originalUsr.Login, updatedUsr.Login) + require.Equal(t, originalUsr.Name, updatedUsr.Name) + require.NotEqual(t, newName, updatedUsr.Name) + }) + + t.Run("Update Email when Login was also an email should update both", func(t *testing.T) { + server, userSvc, tempUserSvc, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "email@localhost") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + body := fmt.Sprintf(`{"email": "%s"}`, newEmail) + sendUpdateReq(server, originalUsr, body) + + // Verify email data + verifyEmailData(tempUserSvc, nsMock, originalUsr, newEmail) + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Second part of the verification flow, when user clicks email button + code := nsMock.EmailVerification.Code + sendVerificationReq(server, originalUsr, code) + + // Verify Email and Login have been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, originalUsr.Email, updatedUsr.Email) + require.Equal(t, newEmail, updatedUsr.Email) + require.Equal(t, newEmail, updatedUsr.Login) + // Fields unchanged + require.Equal(t, originalUsr.Name, updatedUsr.Name) + }) + + t.Run("Update Login with an email should update Email too", func(t *testing.T) { + server, userSvc, tempUserSvc, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + body := fmt.Sprintf(`{"login": "%s"}`, newEmail) + sendUpdateReq(server, originalUsr, body) + + // Verify email data + verifyEmailData(tempUserSvc, nsMock, originalUsr, newEmail) + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Second part of the verification flow, when user clicks email button + code := nsMock.EmailVerification.Code + sendVerificationReq(server, originalUsr, code) + + // Verify Email and Login have been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, originalUsr.Email, updatedUsr.Email) + require.NotEqual(t, originalUsr.Login, updatedUsr.Login) + require.Equal(t, newEmail, updatedUsr.Email) + require.Equal(t, newEmail, updatedUsr.Login) + // Fields unchanged + require.Equal(t, originalUsr.Name, updatedUsr.Name) + }) + + t.Run("Update Login should not need verification if it is not an email", func(t *testing.T) { + server, userSvc, _, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + newLogin := "newLogin" + newName := "newName" + body := fmt.Sprintf(`{"login": "%s", "name": "%s"}`, newLogin, newName) + sendUpdateReq(server, originalUsr, body) + + // Verify that email has not been sent + require.False(t, nsMock.EmailVerified) + + // Verify Login has been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, originalUsr.Login, updatedUsr.Login) + require.NotEqual(t, originalUsr.Name, updatedUsr.Name) + require.Equal(t, newLogin, updatedUsr.Login) + require.Equal(t, newName, updatedUsr.Name) + // Fields unchanged + require.Equal(t, originalUsr.Email, updatedUsr.Email) + }) + + t.Run("Update Login should not need verification if it is being updated to the already configured email", func(t *testing.T) { + server, userSvc, _, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + body := fmt.Sprintf(`{"login": "%s"}`, originalUsr.Email) + sendUpdateReq(server, originalUsr, body) + + // Verify that email has not been sent + require.False(t, nsMock.EmailVerified) + + // Verify Login has been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, originalUsr.Login, updatedUsr.Login) + require.Equal(t, originalUsr.Email, updatedUsr.Login) + require.Equal(t, originalUsr.Email, updatedUsr.Email) + }) + + t.Run("Update Login and Email with different email values at once should disregard the Login update", func(t *testing.T) { + server, userSvc, tempUserSvc, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + newLogin := "newEmail2@localhost" + body := fmt.Sprintf(`{"email": "%s", "login": "%s"}`, newEmail, newLogin) + sendUpdateReq(server, originalUsr, body) + + // Verify email data + verifyEmailData(tempUserSvc, nsMock, originalUsr, newEmail) + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Second part of the verification flow, when user clicks email button + code := nsMock.EmailVerification.Code + sendVerificationReq(server, originalUsr, code) + + // Verify only Email has been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, originalUsr.Email, updatedUsr.Email) + require.Equal(t, newEmail, updatedUsr.Email) + // Fields unchanged + require.NotEqual(t, newLogin, updatedUsr.Login) + require.Equal(t, originalUsr.Login, updatedUsr.Login) + require.Equal(t, originalUsr.Name, updatedUsr.Name) + }) + + t.Run("Update Login and Email with different email values at once when Login was already an email should update both with Email", func(t *testing.T) { + server, userSvc, tempUserSvc, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "email@localhost") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + newLogin := "newEmail2@localhost" + body := fmt.Sprintf(`{"email": "%s", "login": "%s"}`, newEmail, newLogin) + sendUpdateReq(server, originalUsr, body) + + // Verify email data + verifyEmailData(tempUserSvc, nsMock, originalUsr, newEmail) + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Second part of the verification flow, when user clicks email button + code := nsMock.EmailVerification.Code + sendVerificationReq(server, originalUsr, code) + + // Verify only Email has been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, originalUsr.Email, updatedUsr.Email) + require.NotEqual(t, originalUsr.Login, updatedUsr.Login) + require.NotEqual(t, newLogin, updatedUsr.Login) + require.Equal(t, newEmail, updatedUsr.Email) + require.Equal(t, newEmail, updatedUsr.Login) + // Fields unchanged + require.Equal(t, originalUsr.Name, updatedUsr.Name) + }) + + t.Run("Email verification should expire", func(t *testing.T) { + cfg := setting.NewCfg() + cfg.Smtp.Enabled = true + cfg.VerificationEmailMaxLifetime = 0 // Expire instantly + cfg.VerifyEmailEnabled = true + + server, userSvc, tempUserSvc, nsMock := setupScenario(cfg) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + body := fmt.Sprintf(`{"email": "%s"}`, newEmail) + sendUpdateReq(server, originalUsr, body) + + // Verify email data + verifyEmailData(tempUserSvc, nsMock, originalUsr, newEmail) + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Second part of the verification flow, when user clicks email button + code := nsMock.EmailVerification.Code + sendVerificationReq(server, originalUsr, code) + + // Verify user has not been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, newEmail, updatedUsr.Email) + require.Equal(t, originalUsr.Email, updatedUsr.Email) + require.Equal(t, originalUsr.Login, updatedUsr.Login) + }) + + t.Run("A new verification should revoke other pending verifications", func(t *testing.T) { + server, userSvc, tempUserSvc, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // First email verification + firstNewEmail := "newEmail1@localhost" + body := fmt.Sprintf(`{"email": "%s"}`, firstNewEmail) + sendUpdateReq(server, originalUsr, body) + verifyEmailData(tempUserSvc, nsMock, originalUsr, firstNewEmail) + firstCode := nsMock.EmailVerification.Code + + // Second email verification + secondNewEmail := "newEmail2@localhost" + body = fmt.Sprintf(`{"email": "%s"}`, secondNewEmail) + sendUpdateReq(server, originalUsr, body) + verifyEmailData(tempUserSvc, nsMock, originalUsr, secondNewEmail) + secondCode := nsMock.EmailVerification.Code + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Try to follow through with the first verification unsuccessfully + sendVerificationReq(server, originalUsr, firstCode) + verifyUserNotUpdated(userSvc, originalUsr) + + // Follow through with second verification successfully + sendVerificationReq(server, originalUsr, secondCode) + + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, originalUsr.Email, updatedUsr.Email) + require.Equal(t, secondNewEmail, updatedUsr.Email) + // Fields unchanged + require.Equal(t, originalUsr.Login, updatedUsr.Login) + }) + + t.Run("Email verification should fail if code is not valid", func(t *testing.T) { + server, userSvc, tempUserSvc, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + body := fmt.Sprintf(`{"email": "%s"}`, newEmail) + sendUpdateReq(server, originalUsr, body) + + // Verify email data + verifyEmailData(tempUserSvc, nsMock, originalUsr, newEmail) + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Second part of the verification flow should fail if using the wrong code + sendVerificationReq(server, originalUsr, "notTheRightCode") + verifyUserNotUpdated(userSvc, originalUsr) + }) + + t.Run("Email verification code can only be used once", func(t *testing.T) { + server, userSvc, _, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Start email update + require.NotEqual(t, originalUsr.Email, newEmail) + + body := fmt.Sprintf(`{"email": "%s"}`, newEmail) + sendUpdateReq(server, originalUsr, body) + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Use code to verify successfully + codeToReuse := nsMock.EmailVerification.Code + sendVerificationReq(server, originalUsr, codeToReuse) + + // User should have an updated Email + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.Equal(t, newEmail, updatedUsr.Email) + + // Change email back to what it was + body = fmt.Sprintf(`{"email": "%s"}`, originalUsr.Email) + sendUpdateReq(server, originalUsr, body) + sendVerificationReq(server, originalUsr, nsMock.EmailVerification.Code) + verifyUserNotUpdated(userSvc, originalUsr) + + // Re-use code to verify new email again, unsuccessfully + sendVerificationReq(server, originalUsr, codeToReuse) + verifyUserNotUpdated(userSvc, originalUsr) + }) + + t.Run("Update Email with an email that is already being used should fail", func(t *testing.T) { + testCases := []struct { + description string + clashLogin bool + }{ + { + description: "when Email clashes", + clashLogin: false, + }, + { + description: "when Login clashes", + clashLogin: true, + }, + } + for _, tt := range testCases { + t.Run(tt.description, func(t *testing.T) { + server, userSvc, _, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name1", "email1@localhost", "login1@localhost") + badUsr := createUser(userSvc, "name2", "email2@localhost", "login2") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Update `badUsr` to use the same email as `originalUsr` + body := fmt.Sprintf(`{"email": "%s"}`, originalUsr.Email) + if tt.clashLogin { + body = fmt.Sprintf(`{"login": "%s"}`, originalUsr.Login) + } + req := server.NewRequest( + http.MethodPut, + "/api/user", + strings.NewReader(body), + ) + req.Header.Add("Content-Type", "application/json") + res, err := doReq(req, badUsr) + require.NoError(t, err) + assert.Equal(t, http.StatusConflict, res.StatusCode) + require.NoError(t, res.Body.Close()) + + // Verify that no email has been sent + require.False(t, nsMock.EmailVerified) + + // Verify user has not been updated + verifyUserNotUpdated(userSvc, badUsr) + }) + } + }) +} + type updateUserContext struct { desc string url string diff --git a/pkg/api/user_token.go b/pkg/api/user_token.go index 3bb187c32f19f..0662abcf5d7f2 100644 --- a/pkg/api/user_token.go +++ b/pkg/api/user_token.go @@ -149,14 +149,14 @@ func (hs *HTTPServer) logoutUserFromAllDevicesInternal(ctx context.Context, user _, err := hs.userService.GetByID(ctx, &userQuery) if err != nil { if errors.Is(err, user.ErrUserNotFound) { - return response.Error(404, "User not found", err) + return response.Error(http.StatusNotFound, "User not found", err) } - return response.Error(500, "Could not read user from database", err) + return response.Error(http.StatusInternalServerError, "Could not read user from database", err) } err = hs.AuthTokenService.RevokeAllUserTokens(ctx, userID) if err != nil { - return response.Error(500, "Failed to logout user", err) + return response.Error(http.StatusInternalServerError, "Failed to logout user", err) } return response.JSON(http.StatusOK, util.DynMap{ @@ -181,7 +181,7 @@ func (hs *HTTPServer) getUserAuthTokensInternal(c *contextmodel.ReqContext, user tokens, err := hs.AuthTokenService.GetUserTokens(c.Req.Context(), userID) if err != nil { - return response.Error(500, "Failed to get user auth tokens", err) + return response.Error(http.StatusInternalServerError, "Failed to get user auth tokens", err) } result := []*dtos.UserToken{} @@ -241,29 +241,29 @@ func (hs *HTTPServer) revokeUserAuthTokenInternal(c *contextmodel.ReqContext, us _, err := hs.userService.GetByID(c.Req.Context(), &userQuery) if err != nil { if errors.Is(err, user.ErrUserNotFound) { - return response.Error(404, "User not found", err) + return response.Error(http.StatusNotFound, "User not found", err) } - return response.Error(500, "Failed to get user", err) + return response.Error(http.StatusInternalServerError, "Failed to get user", err) } token, err := hs.AuthTokenService.GetUserToken(c.Req.Context(), userID, cmd.AuthTokenId) if err != nil { if errors.Is(err, auth.ErrUserTokenNotFound) { - return response.Error(404, "User auth token not found", err) + return response.Error(http.StatusNotFound, "User auth token not found", err) } - return response.Error(500, "Failed to get user auth token", err) + return response.Error(http.StatusInternalServerError, "Failed to get user auth token", err) } if c.UserToken != nil && c.UserToken.Id == token.Id { - return response.Error(400, "Cannot revoke active user auth token", nil) + return response.Error(http.StatusBadRequest, "Cannot revoke active user auth token", nil) } err = hs.AuthTokenService.RevokeToken(c.Req.Context(), token, false) if err != nil { if errors.Is(err, auth.ErrUserTokenNotFound) { - return response.Error(404, "User auth token not found", err) + return response.Error(http.StatusNotFound, "User auth token not found", err) } - return response.Error(500, "Failed to revoke user auth token", err) + return response.Error(http.StatusInternalServerError, "Failed to revoke user auth token", err) } return response.JSON(http.StatusOK, util.DynMap{ diff --git a/pkg/api/user_token_test.go b/pkg/api/user_token_test.go index c520a52fea395..62fa0c2b0db15 100644 --- a/pkg/api/user_token_test.go +++ b/pkg/api/user_token_test.go @@ -16,7 +16,6 @@ import ( "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth/authtest" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" @@ -200,7 +199,6 @@ func TestHTTPServer_RotateUserAuthToken(t *testing.T) { hs.Cfg = cfg hs.log = log.New() hs.Cfg.LoginCookieName = "grafana_session" - hs.Features = featuremgmt.WithFeatures(featuremgmt.FlagClientTokenRotation) hs.AuthTokenService = &authtest.FakeUserAuthTokenService{ RotateTokenProvider: func(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error) { return tt.rotatedToken, tt.rotatedErr diff --git a/pkg/api/webassets/webassets.go b/pkg/api/webassets/webassets.go index c1f4aa108a755..197c5f7628151 100644 --- a/pkg/api/webassets/webassets.go +++ b/pkg/api/webassets/webassets.go @@ -1,12 +1,16 @@ package webassets import ( + "context" "encoding/json" "fmt" + "io" + "net/http" "os" "path/filepath" "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/setting" ) @@ -29,27 +33,67 @@ type EntryPointInfo struct { var entryPointAssetsCache *dtos.EntryPointAssets = nil -func GetWebAssets(cfg *setting.Cfg) (*dtos.EntryPointAssets, error) { +func GetWebAssets(ctx context.Context, cfg *setting.Cfg, license licensing.Licensing) (*dtos.EntryPointAssets, error) { if cfg.Env != setting.Dev && entryPointAssetsCache != nil { return entryPointAssetsCache, nil } - result, err := readWebAssets(filepath.Join(cfg.StaticRootPath, "build", "assets-manifest.json")) - entryPointAssetsCache = result + var err error + var result *dtos.EntryPointAssets + + cdn := "" // "https://grafana-assets.grafana.net/grafana/10.3.0-64123/" + if cdn != "" { + result, err = readWebAssetsFromCDN(ctx, cdn) + } + + if result == nil { + result, err = readWebAssetsFromFile(filepath.Join(cfg.StaticRootPath, "build", "assets-manifest.json")) + if err == nil { + cdn, _ = cfg.GetContentDeliveryURL(license.ContentDeliveryPrefix()) + if cdn != "" { + result.SetContentDeliveryURL(cdn) + } + } + } + entryPointAssetsCache = result return entryPointAssetsCache, err } -func readWebAssets(manifestpath string) (*dtos.EntryPointAssets, error) { +func readWebAssetsFromFile(manifestpath string) (*dtos.EntryPointAssets, error) { //nolint:gosec - bytes, err := os.ReadFile(manifestpath) + f, err := os.Open(manifestpath) if err != nil { return nil, fmt.Errorf("failed to load assets-manifest.json %w", err) } + defer func() { + _ = f.Close() + }() + return readWebAssets(f) +} + +func readWebAssetsFromCDN(ctx context.Context, baseURL string) (*dtos.EntryPointAssets, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"public/build/assets-manifest.json", nil) + if err != nil { + return nil, err + } + response, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = response.Body.Close() + }() + dto, err := readWebAssets(response.Body) + if err == nil { + dto.SetContentDeliveryURL(baseURL) + } + return dto, err +} +func readWebAssets(r io.Reader) (*dtos.EntryPointAssets, error) { manifest := map[string]ManifestInfo{} - err = json.Unmarshal(bytes, &manifest) - if err != nil { + if err := json.NewDecoder(r).Decode(&manifest); err != nil { return nil, fmt.Errorf("failed to read assets-manifest.json %w", err) } @@ -84,8 +128,8 @@ func readWebAssets(manifestpath string) (*dtos.EntryPointAssets, error) { } return &dtos.EntryPointAssets{ - JSFiles: entryPointJSAssets, - CSSDark: entryPoints.Dark.Assets.CSS[0], - CSSLight: entryPoints.Light.Assets.CSS[0], + JSFiles: entryPointJSAssets, + Dark: entryPoints.Dark.Assets.CSS[0], + Light: entryPoints.Light.Assets.CSS[0], }, nil } diff --git a/pkg/api/webassets/webassets_test.go b/pkg/api/webassets/webassets_test.go index f12f488cfeff7..4c4f0013cacad 100644 --- a/pkg/api/webassets/webassets_test.go +++ b/pkg/api/webassets/webassets_test.go @@ -1,6 +1,7 @@ package webassets import ( + "context" "encoding/json" "testing" @@ -8,7 +9,7 @@ import ( ) func TestReadWebassets(t *testing.T) { - assets, err := readWebAssets("testdata/sample-assets-manifest.json") + assets, err := readWebAssetsFromFile("testdata/sample-assets-manifest.json") require.NoError(t, err) dto, err := json.MarshalIndent(assets, "", " ") @@ -16,33 +17,114 @@ func TestReadWebassets(t *testing.T) { //fmt.Printf("%s\n", string(dto)) require.JSONEq(t, `{ - "JSFiles": [ + "jsFiles": [ { - "FilePath": "public/build/runtime.20ed8c01880b812ed29f.js", - "Integrity": "sha256-rcdxIHk6cWgu4jiFa1a+pWlileYD/R72GaS8ZACBUdw= sha384-I/VJZQkt+TuJTvu61ihdWPds7EHfLrW5CxeQ0x9gtSqoPg9Z17Uawz1yoYaTdxqQ sha512-4CPAbh4KdTmGxHoQw4pgpYmgAquupVfwfo6UBV2cGU3vGFnEwkhq320037ETwWs+n9xB/bAMOvrdabp1SA1+8g==" + "filePath": "public/build/runtime.20ed8c01880b812ed29f.js", + "integrity": "sha256-rcdxIHk6cWgu4jiFa1a+pWlileYD/R72GaS8ZACBUdw= sha384-I/VJZQkt+TuJTvu61ihdWPds7EHfLrW5CxeQ0x9gtSqoPg9Z17Uawz1yoYaTdxqQ sha512-4CPAbh4KdTmGxHoQw4pgpYmgAquupVfwfo6UBV2cGU3vGFnEwkhq320037ETwWs+n9xB/bAMOvrdabp1SA1+8g==" }, { - "FilePath": "public/build/3951.4e474348841d792ab1ba.js", - "Integrity": "sha256-dHqXXTRA3osYhHr9rol8hOV0nC4VP0pr5tbMp5VD95Q= sha384-4QJaSTibnxdYeYsLnmXtd1+If6IkAmXlLR0uYHN5+N+fS0FegHRH7MIFaRGjiO1B sha512-vRLEeEGbxBCx0z+l/m14fSK49reqWGA9zQzsCrD+TQQBmP07YIoRPwopMMyxtKljbbRFV0bW2bUZ7ZvzOZYoIQ==" + "filePath": "public/build/3951.4e474348841d792ab1ba.js", + "integrity": "sha256-dHqXXTRA3osYhHr9rol8hOV0nC4VP0pr5tbMp5VD95Q= sha384-4QJaSTibnxdYeYsLnmXtd1+If6IkAmXlLR0uYHN5+N+fS0FegHRH7MIFaRGjiO1B sha512-vRLEeEGbxBCx0z+l/m14fSK49reqWGA9zQzsCrD+TQQBmP07YIoRPwopMMyxtKljbbRFV0bW2bUZ7ZvzOZYoIQ==" }, { - "FilePath": "public/build/3651.4e8f7603e9778e1e9b59.js", - "Integrity": "sha256-+N7caL91pVANd7C/aquAneRTjBQenCwaEKqj+3qkjxc= sha384-GQR7GyHPEwwEVph9gGYWEWvMYxkITwcOjieehbPidXZrybuQyw9cpDkjnWo1tj/w sha512-zyPM+8AxyLuECEXjb9w6Z2Sy8zmJdkfTWQphcvAb8AU4ZdkCqLmyjmOs/QQlpfKDe0wdOLyR3V9QgTDDlxtVlQ==" + "filePath": "public/build/3651.4e8f7603e9778e1e9b59.js", + "integrity": "sha256-+N7caL91pVANd7C/aquAneRTjBQenCwaEKqj+3qkjxc= sha384-GQR7GyHPEwwEVph9gGYWEWvMYxkITwcOjieehbPidXZrybuQyw9cpDkjnWo1tj/w sha512-zyPM+8AxyLuECEXjb9w6Z2Sy8zmJdkfTWQphcvAb8AU4ZdkCqLmyjmOs/QQlpfKDe0wdOLyR3V9QgTDDlxtVlQ==" }, { - "FilePath": "public/build/1272.8c79fc44bf7cd993c953.js", - "Integrity": "sha256-d7MRVimV83v4YQ5rdURfTaaFtiedXP3EMLT06gvvBuQ= sha384-8tRpYHQ+sEkZ8ptiIbKAbKPpHTJVnmaWDN56vJoWWUCzV1Q2w034wcJNKDJDJdAs sha512-cIZWoJHusF8qODBOj2j4b18ewcLLMo/92YQSwYQjln2G5e3o1bSO476ox2I2iecJ/tnhQK5j01h9BzTt3dNTrA==" + "filePath": "public/build/1272.8c79fc44bf7cd993c953.js", + "integrity": "sha256-d7MRVimV83v4YQ5rdURfTaaFtiedXP3EMLT06gvvBuQ= sha384-8tRpYHQ+sEkZ8ptiIbKAbKPpHTJVnmaWDN56vJoWWUCzV1Q2w034wcJNKDJDJdAs sha512-cIZWoJHusF8qODBOj2j4b18ewcLLMo/92YQSwYQjln2G5e3o1bSO476ox2I2iecJ/tnhQK5j01h9BzTt3dNTrA==" }, { - "FilePath": "public/build/6902.070074e8f5a989b8f4c3.js", - "Integrity": "sha256-TMo/uTZueyEHtkBzlLZzhwYKWF0epE4qbouo5xcwZkU= sha384-xylZJMtJ7+EsUBBdQZvPh+BeHJ3BnfclqI2vx/8QC9jvfYe/lhRsWW9OMJsxE/Aq sha512-EOmf+KZQMFPoTWAROL8bBLFfHhgvDH8ONycq37JaV7lz+sQOTaWBN2ZD0F/mMdOD5zueTg/Y1RAUP6apoEcHNQ==" + "filePath": "public/build/6902.070074e8f5a989b8f4c3.js", + "integrity": "sha256-TMo/uTZueyEHtkBzlLZzhwYKWF0epE4qbouo5xcwZkU= sha384-xylZJMtJ7+EsUBBdQZvPh+BeHJ3BnfclqI2vx/8QC9jvfYe/lhRsWW9OMJsxE/Aq sha512-EOmf+KZQMFPoTWAROL8bBLFfHhgvDH8ONycq37JaV7lz+sQOTaWBN2ZD0F/mMdOD5zueTg/Y1RAUP6apoEcHNQ==" }, { - "FilePath": "public/build/app.0439db6f56ee4aa501b2.js", - "Integrity": "sha256-q6muaKY7BuN2Ff+00aw69628MXatcFnLNzWRnAD98DI= sha384-gv6lAbkngOHR05bvyOR8dm/J3wIjQQWSjyxK7W8vt2rG9uxcjvvDQV7aI6YbUhfX sha512-o/0mSlJ/OoqrpGdOIWCE3ZCe8n+qqLbgNCERtx9G8FIzsv++CvIWSGbbILjOTGfnEfEQWcKMH0macVpVBSe1Og==" + "filePath": "public/build/app.0439db6f56ee4aa501b2.js", + "integrity": "sha256-q6muaKY7BuN2Ff+00aw69628MXatcFnLNzWRnAD98DI= sha384-gv6lAbkngOHR05bvyOR8dm/J3wIjQQWSjyxK7W8vt2rG9uxcjvvDQV7aI6YbUhfX sha512-o/0mSlJ/OoqrpGdOIWCE3ZCe8n+qqLbgNCERtx9G8FIzsv++CvIWSGbbILjOTGfnEfEQWcKMH0macVpVBSe1Og==" } ], - "CSSDark": "public/build/grafana.dark.a28b24b45b2bbcc628cc.css", - "CSSLight": "public/build/grafana.light.3572f6d5f8b7daa8d8d0.css" + "dark": "public/build/grafana.dark.a28b24b45b2bbcc628cc.css", + "light": "public/build/grafana.light.3572f6d5f8b7daa8d8d0.css" + }`, string(dto)) + + assets.SetContentDeliveryURL("https://grafana-assets.grafana.net/grafana/10.3.0-64123/") + + dto, err = json.MarshalIndent(assets, "", " ") + require.NoError(t, err) + //fmt.Printf("%s\n", string(dto)) + + require.JSONEq(t, `{ + "cdn": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/", + "jsFiles": [ + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/runtime.20ed8c01880b812ed29f.js", + "integrity": "sha256-rcdxIHk6cWgu4jiFa1a+pWlileYD/R72GaS8ZACBUdw= sha384-I/VJZQkt+TuJTvu61ihdWPds7EHfLrW5CxeQ0x9gtSqoPg9Z17Uawz1yoYaTdxqQ sha512-4CPAbh4KdTmGxHoQw4pgpYmgAquupVfwfo6UBV2cGU3vGFnEwkhq320037ETwWs+n9xB/bAMOvrdabp1SA1+8g==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/3951.4e474348841d792ab1ba.js", + "integrity": "sha256-dHqXXTRA3osYhHr9rol8hOV0nC4VP0pr5tbMp5VD95Q= sha384-4QJaSTibnxdYeYsLnmXtd1+If6IkAmXlLR0uYHN5+N+fS0FegHRH7MIFaRGjiO1B sha512-vRLEeEGbxBCx0z+l/m14fSK49reqWGA9zQzsCrD+TQQBmP07YIoRPwopMMyxtKljbbRFV0bW2bUZ7ZvzOZYoIQ==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/3651.4e8f7603e9778e1e9b59.js", + "integrity": "sha256-+N7caL91pVANd7C/aquAneRTjBQenCwaEKqj+3qkjxc= sha384-GQR7GyHPEwwEVph9gGYWEWvMYxkITwcOjieehbPidXZrybuQyw9cpDkjnWo1tj/w sha512-zyPM+8AxyLuECEXjb9w6Z2Sy8zmJdkfTWQphcvAb8AU4ZdkCqLmyjmOs/QQlpfKDe0wdOLyR3V9QgTDDlxtVlQ==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/1272.8c79fc44bf7cd993c953.js", + "integrity": "sha256-d7MRVimV83v4YQ5rdURfTaaFtiedXP3EMLT06gvvBuQ= sha384-8tRpYHQ+sEkZ8ptiIbKAbKPpHTJVnmaWDN56vJoWWUCzV1Q2w034wcJNKDJDJdAs sha512-cIZWoJHusF8qODBOj2j4b18ewcLLMo/92YQSwYQjln2G5e3o1bSO476ox2I2iecJ/tnhQK5j01h9BzTt3dNTrA==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/6902.070074e8f5a989b8f4c3.js", + "integrity": "sha256-TMo/uTZueyEHtkBzlLZzhwYKWF0epE4qbouo5xcwZkU= sha384-xylZJMtJ7+EsUBBdQZvPh+BeHJ3BnfclqI2vx/8QC9jvfYe/lhRsWW9OMJsxE/Aq sha512-EOmf+KZQMFPoTWAROL8bBLFfHhgvDH8ONycq37JaV7lz+sQOTaWBN2ZD0F/mMdOD5zueTg/Y1RAUP6apoEcHNQ==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/app.0439db6f56ee4aa501b2.js", + "integrity": "sha256-q6muaKY7BuN2Ff+00aw69628MXatcFnLNzWRnAD98DI= sha384-gv6lAbkngOHR05bvyOR8dm/J3wIjQQWSjyxK7W8vt2rG9uxcjvvDQV7aI6YbUhfX sha512-o/0mSlJ/OoqrpGdOIWCE3ZCe8n+qqLbgNCERtx9G8FIzsv++CvIWSGbbILjOTGfnEfEQWcKMH0macVpVBSe1Og==" + } + ], + "dark": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/grafana.dark.a28b24b45b2bbcc628cc.css", + "light": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/grafana.light.3572f6d5f8b7daa8d8d0.css" + }`, string(dto)) +} + +func TestReadWebassetsFromCDN(t *testing.T) { + t.Skip() + + assets, err := readWebAssetsFromCDN(context.Background(), "https://grafana-assets.grafana.net/grafana/10.3.0-64123/") + require.NoError(t, err) + + dto, err := json.MarshalIndent(assets, "", " ") + require.NoError(t, err) + //fmt.Printf("%s\n", string(dto)) + + require.JSONEq(t, `{ + "cdn": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/", + "jsFiles": [ + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/runtime.6d702760ddd47772f116.js", + "integrity": "sha256-6tSxwMwqd9McukcH+i56v1v+8JsVlMXPWKUCIK30yK8= sha384-dfRWJ5QfPAiQKJ9fUugmeXVdRSx8OS3XUdkEyEhxkm9CZQf9KeUyUe6fGV7VL7s9 sha512-0kjFCSBeQtdS3F9B/uqX45KMMUffYpsU7Ve7AYjy75HiBzovxRGG4hWPZD7d4Gha0Y3Oj4AmZA37TJoafptlRQ==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/7653.f5c70a70add3b711f560.js", + "integrity": "sha256-p65DYfZPt9NU7vDwlxW+sY9sK+wQ9tJgTGlCJt+LvxY= sha384-P1TDQw3ZJ4X6Fiyn6UpLpVuHq+UW3zKRUM6U0vjucSl/bjFmQJfGR9XE64uEn6sJ sha512-sPqhDs/mWUBL6txtyoTdlgyZvVfdttUAXdV39aEroYpSnl/uEoLIcNBem5mNxoh4ut4TpSb9hlW6tTD7QV07/g==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/182.0b85a6da60c3ae0a9093.js", + "integrity": "sha256-4vJBytomvJYkSsXlAo7BXDiXRsi5JVWBosIZSMCYlqs= sha384-MWfyWG85/+OvsA4E9CvG1NGiSzrp/EH37Xd/+qfdMFKmvAEGzGx9N/4xF+3N3/yj sha512-j1h6qobFAJYU+7QFdcChEeHa/FPXuArEsHJuXSYtaqrDU7oNHyW1PqFz6kNUwqE674Hutl93EeY+UsUlpZgZZQ==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/8781.91ede282a7f6078508e7.js", + "integrity": "sha256-b68VAYMTugwWaHtffKI4qCMSWTN/fg0xQv+MnSILQgg= sha384-ptDkcAAAQhuG9Mhvs6gvGIp0HIjCfAP+ysaMltIr3L5alN6Ki71Si/zO6C70YArC sha512-N5tkcDgTPcNvQymegqnx0syp0kS7wVzPnt7i5KSu/RAi6cfM9XiRfz7bZh6fcZAJxApvpL1OJhUQQwPFFBN4ZA==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/3958.1d29ae9e8eb421432f48.js", + "integrity": "sha256-9c+QGDOI8HtAzVBLA3nJOOU+LzhoENAhIEw7gGSkgWY= sha384-Y05zEdrM/ab9jzGH6segO9GyE8OTV5RvWPZFgynXX4XgvMOyWJcySqwW4RoIVo6P sha512-+ro4iXipgz1zUySd8oMbOY6XX+RjP4gi8bksFNjJGiLQOHVb/EKZKDj5UBeIE96XMd1AoEvZdymCvaft3d8oeA==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/app.18e8d3e07edcc1356a6a.js", + "integrity": "sha256-ueeH8P/rDaft7jtzRmTN4UpNtiPfhzYa7c1VbBiRLTo= sha384-SijeOWlmIMzm/WNVg5e+yMieef6LOFXMu8d2laBtaY/2m/fviGI+8W55jazWzb+C sha512-qr5MoBZ4wNTCm6aRQ5/mglO8gShmKFpvr066SJgKyAJA4j8cK0snL2XhubUNxND+KkpKAnRe7EjsHYd28/uvkw==" + } + ], + "dark": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/grafana.dark.b44253d019cd9cb46428.css", + "light": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/grafana.light.e8e11c59b604d62836be.css" }`, string(dto)) } diff --git a/pkg/apimachinery/apis/common/v0alpha1/doc.go b/pkg/apimachinery/apis/common/v0alpha1/doc.go new file mode 100644 index 0000000000000..8dbffd26f9040 --- /dev/null +++ b/pkg/apimachinery/apis/common/v0alpha1/doc.go @@ -0,0 +1,5 @@ +// +k8s:openapi-gen=true +// +k8s:defaulter-gen=TypeMeta +// +groupName=common.grafana.app + +package v0alpha1 // import "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" diff --git a/pkg/apis/types.go b/pkg/apimachinery/apis/common/v0alpha1/resource.go similarity index 76% rename from pkg/apis/types.go rename to pkg/apimachinery/apis/common/v0alpha1/resource.go index 55ab7a0d703c8..b801ed69e9edc 100644 --- a/pkg/apis/types.go +++ b/pkg/apimachinery/apis/common/v0alpha1/resource.go @@ -1,4 +1,4 @@ -package apis +package v0alpha1 import ( "k8s.io/apimachinery/pkg/api/errors" @@ -8,11 +8,13 @@ import ( ) // ResourceInfo helps define a k8s resource +// +k8s:openapi-gen=false type ResourceInfo struct { group string version string resourceName string singularName string + shortName string kind string newObj func() runtime.Object newList func() runtime.Object @@ -20,13 +22,34 @@ type ResourceInfo struct { func NewResourceInfo(group, version, resourceName, singularName, kind string, newObj func() runtime.Object, newList func() runtime.Object) ResourceInfo { - return ResourceInfo{group, version, resourceName, singularName, kind, newObj, newList} + shortName := "" // an optional alias helpful in kubectl eg ("sa" for serviceaccounts) + return ResourceInfo{group, version, resourceName, singularName, shortName, kind, newObj, newList} +} + +func (info *ResourceInfo) WithGroupAndShortName(group string, shortName string) ResourceInfo { + return ResourceInfo{ + group: group, + version: info.version, + resourceName: info.resourceName, + singularName: info.singularName, + kind: info.kind, + shortName: shortName, + newObj: info.newObj, + newList: info.newList, + } } func (info *ResourceInfo) GetSingularName() string { return info.singularName } +func (info *ResourceInfo) GetShortNames() []string { + if info.shortName == "" { + return []string{} + } + return []string{info.shortName} +} + // TypeMeta returns k8s type func (info *ResourceInfo) TypeMeta() metav1.TypeMeta { return metav1.TypeMeta{ diff --git a/pkg/apimachinery/apis/common/v0alpha1/types.go b/pkg/apimachinery/apis/common/v0alpha1/types.go new file mode 100644 index 0000000000000..63ea94a8a96e6 --- /dev/null +++ b/pkg/apimachinery/apis/common/v0alpha1/types.go @@ -0,0 +1,17 @@ +package v0alpha1 + +// Similar to +// https://dev-k8sref-io.web.app/docs/common-definitions/objectreference-/ +// ObjectReference contains enough information to let you inspect or modify the referred object. +type ObjectReference struct { + Resource string `json:"resource,omitempty"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name,omitempty"` + + // APIGroup is the name of the API group that contains the referred object. + // The empty string represents the core API group. + APIGroup string `json:"apiGroup,omitempty"` + + // APIVersion is the version of the API group that contains the referred object. + APIVersion string `json:"apiVersion,omitempty"` +} diff --git a/pkg/apimachinery/apis/common/v0alpha1/unstructured.go b/pkg/apimachinery/apis/common/v0alpha1/unstructured.go new file mode 100644 index 0000000000000..3a735623a68c9 --- /dev/null +++ b/pkg/apimachinery/apis/common/v0alpha1/unstructured.go @@ -0,0 +1,118 @@ +package v0alpha1 + +import ( + "encoding/json" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + runtime "k8s.io/apimachinery/pkg/runtime" + openapi "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" +) + +// Unstructured allows objects that do not have Golang structs registered to be manipulated +// generically. +type Unstructured struct { + // Object is a JSON compatible map with string, float, int, bool, []interface{}, + // or map[string]interface{} children. + Object map[string]any +} + +// Produce an API definition that represents map[string]any +func (u Unstructured) OpenAPIDefinition() openapi.OpenAPIDefinition { + return openapi.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{Allows: true}, + }, + VendorExtensible: spec.VendorExtensible{ + Extensions: map[string]interface{}{ + "x-kubernetes-preserve-unknown-fields": true, + }, + }, + }, + } +} + +func (u *Unstructured) UnstructuredContent() map[string]interface{} { + if u.Object == nil { + return make(map[string]interface{}) + } + return u.Object +} + +func (u *Unstructured) SetUnstructuredContent(content map[string]interface{}) { + u.Object = content +} + +// MarshalJSON ensures that the unstructured object produces proper +// JSON when passed to Go's standard JSON library. +func (u *Unstructured) MarshalJSON() ([]byte, error) { + return json.Marshal(u.Object) +} + +// UnmarshalJSON ensures that the unstructured object properly decodes +// JSON when passed to Go's standard JSON library. +func (u *Unstructured) UnmarshalJSON(b []byte) error { + return json.Unmarshal(b, &u.Object) +} + +func (u *Unstructured) DeepCopy() *Unstructured { + if u == nil { + return nil + } + out := new(Unstructured) + *out = *u + out.Object = runtime.DeepCopyJSON(u.Object) + return out +} + +func (u *Unstructured) DeepCopyInto(out *Unstructured) { + clone := u.DeepCopy() + *out = *clone +} + +func (u *Unstructured) Set(field string, value interface{}) { + if u.Object == nil { + u.Object = make(map[string]interface{}) + } + _ = unstructured.SetNestedField(u.Object, value, field) +} + +func (u *Unstructured) Remove(fields ...string) { + if u.Object == nil { + u.Object = make(map[string]interface{}) + } + unstructured.RemoveNestedField(u.Object, fields...) +} + +func (u *Unstructured) SetNestedField(value interface{}, fields ...string) { + if u.Object == nil { + u.Object = make(map[string]interface{}) + } + _ = unstructured.SetNestedField(u.Object, value, fields...) +} + +func (u *Unstructured) GetNestedString(fields ...string) string { + val, found, err := unstructured.NestedString(u.Object, fields...) + if !found || err != nil { + return "" + } + return val +} + +func (u *Unstructured) GetNestedStringSlice(fields ...string) []string { + val, found, err := unstructured.NestedStringSlice(u.Object, fields...) + if !found || err != nil { + return nil + } + return val +} + +func (u *Unstructured) GetNestedInt64(fields ...string) int64 { + val, found, err := unstructured.NestedInt64(u.Object, fields...) + if !found || err != nil { + return 0 + } + return val +} diff --git a/pkg/apis/folders/v0alpha1/zz_generated.defaults.go b/pkg/apimachinery/apis/common/v0alpha1/zz_generated.defaults.go similarity index 100% rename from pkg/apis/folders/v0alpha1/zz_generated.defaults.go rename to pkg/apimachinery/apis/common/v0alpha1/zz_generated.defaults.go diff --git a/pkg/services/grafana-apiserver/openapi.go b/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi.go similarity index 95% rename from pkg/services/grafana-apiserver/openapi.go rename to pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi.go index 742c8d7e94fcd..2dd8493f321ca 100644 --- a/pkg/services/grafana-apiserver/openapi.go +++ b/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi.go @@ -1,82 +1,121 @@ -package grafanaapiserver +//go:build !ignore_autogenerated +// +build !ignore_autogenerated -import ( - "maps" +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by openapi-gen. DO NOT EDIT. + +// This file was autogenerated by openapi-gen. Do not edit it manually! + +package v0alpha1 +import ( v1 "k8s.io/apimachinery/pkg/apis/meta/v1" common "k8s.io/kube-openapi/pkg/common" spec "k8s.io/kube-openapi/pkg/validation/spec" ) -// This should eventually live in grafana-app-sdk -func GetOpenAPIDefinitions(builders []APIGroupBuilder) common.GetOpenAPIDefinitions { - return func(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { - defs := getStandardOpenAPIDefinitions(ref) - for _, builder := range builders { - g := builder.GetOpenAPIDefinitions() - if g != nil { - out := g(ref) - maps.Copy(defs, out) - } - } - return defs +func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + return map[string]common.OpenAPIDefinition{ + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.ObjectReference": schema_apimachinery_apis_common_v0alpha1_ObjectReference(ref), + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured": Unstructured{}.OpenAPIDefinition(), + "k8s.io/apimachinery/pkg/apis/meta/v1.APIGroup": schema_pkg_apis_meta_v1_APIGroup(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.APIGroupList": schema_pkg_apis_meta_v1_APIGroupList(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.APIResource": schema_pkg_apis_meta_v1_APIResource(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.APIResourceList": schema_pkg_apis_meta_v1_APIResourceList(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.APIVersions": schema_pkg_apis_meta_v1_APIVersions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.ApplyOptions": schema_pkg_apis_meta_v1_ApplyOptions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.Condition": schema_pkg_apis_meta_v1_Condition(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.CreateOptions": schema_pkg_apis_meta_v1_CreateOptions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.DeleteOptions": schema_pkg_apis_meta_v1_DeleteOptions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.Duration": schema_pkg_apis_meta_v1_Duration(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.FieldsV1": schema_pkg_apis_meta_v1_FieldsV1(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.GetOptions": schema_pkg_apis_meta_v1_GetOptions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.GroupKind": schema_pkg_apis_meta_v1_GroupKind(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.GroupResource": schema_pkg_apis_meta_v1_GroupResource(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersion": schema_pkg_apis_meta_v1_GroupVersion(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionForDiscovery": schema_pkg_apis_meta_v1_GroupVersionForDiscovery(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionKind": schema_pkg_apis_meta_v1_GroupVersionKind(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionResource": schema_pkg_apis_meta_v1_GroupVersionResource(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.InternalEvent": schema_pkg_apis_meta_v1_InternalEvent(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelector": schema_pkg_apis_meta_v1_LabelSelector(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelectorRequirement": schema_pkg_apis_meta_v1_LabelSelectorRequirement(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.List": schema_pkg_apis_meta_v1_List(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta": schema_pkg_apis_meta_v1_ListMeta(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.ListOptions": schema_pkg_apis_meta_v1_ListOptions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.ManagedFieldsEntry": schema_pkg_apis_meta_v1_ManagedFieldsEntry(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.MicroTime": schema_pkg_apis_meta_v1_MicroTime(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta": schema_pkg_apis_meta_v1_ObjectMeta(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.OwnerReference": schema_pkg_apis_meta_v1_OwnerReference(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.PartialObjectMetadata": schema_pkg_apis_meta_v1_PartialObjectMetadata(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.PartialObjectMetadataList": schema_pkg_apis_meta_v1_PartialObjectMetadataList(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.Patch": schema_pkg_apis_meta_v1_Patch(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.PatchOptions": schema_pkg_apis_meta_v1_PatchOptions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.Preconditions": schema_pkg_apis_meta_v1_Preconditions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.RootPaths": schema_pkg_apis_meta_v1_RootPaths(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.ServerAddressByClientCIDR": schema_pkg_apis_meta_v1_ServerAddressByClientCIDR(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.Status": schema_pkg_apis_meta_v1_Status(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.StatusCause": schema_pkg_apis_meta_v1_StatusCause(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.StatusDetails": schema_pkg_apis_meta_v1_StatusDetails(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.Table": schema_pkg_apis_meta_v1_Table(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.TableColumnDefinition": schema_pkg_apis_meta_v1_TableColumnDefinition(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.TableOptions": schema_pkg_apis_meta_v1_TableOptions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.TableRow": schema_pkg_apis_meta_v1_TableRow(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.TableRowCondition": schema_pkg_apis_meta_v1_TableRowCondition(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.Time": schema_pkg_apis_meta_v1_Time(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.Timestamp": schema_pkg_apis_meta_v1_Timestamp(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.TypeMeta": schema_pkg_apis_meta_v1_TypeMeta(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.UpdateOptions": schema_pkg_apis_meta_v1_UpdateOptions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.WatchEvent": schema_pkg_apis_meta_v1_WatchEvent(ref), + "k8s.io/apimachinery/pkg/runtime.RawExtension": schema_k8sio_apimachinery_pkg_runtime_RawExtension(ref), + "k8s.io/apimachinery/pkg/runtime.TypeMeta": schema_k8sio_apimachinery_pkg_runtime_TypeMeta(ref), + "k8s.io/apimachinery/pkg/runtime.Unknown": schema_k8sio_apimachinery_pkg_runtime_Unknown(ref), + "k8s.io/apimachinery/pkg/version.Info": schema_k8sio_apimachinery_pkg_version_Info(ref), } } -func getStandardOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { - return map[string]common.OpenAPIDefinition{ - "k8s.io/apimachinery/pkg/apis/meta/v1.APIGroup": schema_pkg_apis_meta_v1_APIGroup(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.APIGroupList": schema_pkg_apis_meta_v1_APIGroupList(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.APIResource": schema_pkg_apis_meta_v1_APIResource(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.APIResourceList": schema_pkg_apis_meta_v1_APIResourceList(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.APIVersions": schema_pkg_apis_meta_v1_APIVersions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ApplyOptions": schema_pkg_apis_meta_v1_ApplyOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Condition": schema_pkg_apis_meta_v1_Condition(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.CreateOptions": schema_pkg_apis_meta_v1_CreateOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.DeleteOptions": schema_pkg_apis_meta_v1_DeleteOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Duration": schema_pkg_apis_meta_v1_Duration(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.FieldsV1": schema_pkg_apis_meta_v1_FieldsV1(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GetOptions": schema_pkg_apis_meta_v1_GetOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupKind": schema_pkg_apis_meta_v1_GroupKind(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupResource": schema_pkg_apis_meta_v1_GroupResource(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersion": schema_pkg_apis_meta_v1_GroupVersion(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionForDiscovery": schema_pkg_apis_meta_v1_GroupVersionForDiscovery(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionKind": schema_pkg_apis_meta_v1_GroupVersionKind(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionResource": schema_pkg_apis_meta_v1_GroupVersionResource(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.InternalEvent": schema_pkg_apis_meta_v1_InternalEvent(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelector": schema_pkg_apis_meta_v1_LabelSelector(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelectorRequirement": schema_pkg_apis_meta_v1_LabelSelectorRequirement(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.List": schema_pkg_apis_meta_v1_List(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta": schema_pkg_apis_meta_v1_ListMeta(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ListOptions": schema_pkg_apis_meta_v1_ListOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ManagedFieldsEntry": schema_pkg_apis_meta_v1_ManagedFieldsEntry(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.MicroTime": schema_pkg_apis_meta_v1_MicroTime(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta": schema_pkg_apis_meta_v1_ObjectMeta(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.OwnerReference": schema_pkg_apis_meta_v1_OwnerReference(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.PartialObjectMetadata": schema_pkg_apis_meta_v1_PartialObjectMetadata(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.PartialObjectMetadataList": schema_pkg_apis_meta_v1_PartialObjectMetadataList(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Patch": schema_pkg_apis_meta_v1_Patch(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.PatchOptions": schema_pkg_apis_meta_v1_PatchOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Preconditions": schema_pkg_apis_meta_v1_Preconditions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.RootPaths": schema_pkg_apis_meta_v1_RootPaths(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ServerAddressByClientCIDR": schema_pkg_apis_meta_v1_ServerAddressByClientCIDR(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Status": schema_pkg_apis_meta_v1_Status(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.StatusCause": schema_pkg_apis_meta_v1_StatusCause(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.StatusDetails": schema_pkg_apis_meta_v1_StatusDetails(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Table": schema_pkg_apis_meta_v1_Table(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.TableColumnDefinition": schema_pkg_apis_meta_v1_TableColumnDefinition(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.TableOptions": schema_pkg_apis_meta_v1_TableOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.TableRow": schema_pkg_apis_meta_v1_TableRow(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.TableRowCondition": schema_pkg_apis_meta_v1_TableRowCondition(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Time": schema_pkg_apis_meta_v1_Time(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Timestamp": schema_pkg_apis_meta_v1_Timestamp(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.TypeMeta": schema_pkg_apis_meta_v1_TypeMeta(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.UpdateOptions": schema_pkg_apis_meta_v1_UpdateOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.WatchEvent": schema_pkg_apis_meta_v1_WatchEvent(ref), - "k8s.io/apimachinery/pkg/runtime.RawExtension": schema_k8sio_apimachinery_pkg_runtime_RawExtension(ref), - "k8s.io/apimachinery/pkg/runtime.TypeMeta": schema_k8sio_apimachinery_pkg_runtime_TypeMeta(ref), - "k8s.io/apimachinery/pkg/runtime.Unknown": schema_k8sio_apimachinery_pkg_runtime_Unknown(ref), - "k8s.io/apimachinery/pkg/version.Info": schema_k8sio_apimachinery_pkg_version_Info(ref), +func schema_apimachinery_apis_common_v0alpha1_ObjectReference(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Similar to https://dev-k8sref-io.web.app/docs/common-definitions/objectreference-/ ObjectReference contains enough information to let you inspect or modify the referred object.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "resource": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "namespace": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "name": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "apiGroup": { + SchemaProps: spec.SchemaProps{ + Description: "APIGroup is the name of the API group that contains the referred object. The empty string represents the core API group.", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion is the version of the API group that contains the referred object.", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, } } @@ -512,7 +551,6 @@ func schema_pkg_apis_meta_v1_Condition(ref common.ReferenceCallback) common.Open "lastTransitionTime": { SchemaProps: spec.SchemaProps{ Description: "lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.", - Default: map[string]interface{}{}, Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Time"), }, }, @@ -996,12 +1034,6 @@ func schema_pkg_apis_meta_v1_LabelSelectorRequirement(ref common.ReferenceCallba Type: []string{"object"}, Properties: map[string]spec.Schema{ "key": { - VendorExtensible: spec.VendorExtensible{ - Extensions: spec.Extensions{ - "x-kubernetes-patch-merge-key": "key", - "x-kubernetes-patch-strategy": "merge", - }, - }, SchemaProps: spec.SchemaProps{ Description: "key is the label key that the selector applies to.", Default: "", @@ -1074,8 +1106,7 @@ func schema_pkg_apis_meta_v1_List(ref common.ReferenceCallback) common.OpenAPIDe Items: &spec.SchemaOrArray{ Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/runtime.RawExtension"), + Ref: ref("k8s.io/apimachinery/pkg/runtime.RawExtension"), }, }, }, @@ -1361,7 +1392,6 @@ func schema_pkg_apis_meta_v1_ObjectMeta(ref common.ReferenceCallback) common.Ope "creationTimestamp": { SchemaProps: spec.SchemaProps{ Description: "CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC.\n\nPopulated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", - Default: map[string]interface{}{}, Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Time"), }, }, @@ -2143,7 +2173,6 @@ func schema_pkg_apis_meta_v1_TableRow(ref common.ReferenceCallback) common.OpenA "object": { SchemaProps: spec.SchemaProps{ Description: "This field contains the requested additional information about each object based on the includeObject policy when requesting the Table. If \"None\", this field is empty, if \"Object\" this will be the default serialization of the object for the current API version, and if \"Metadata\" (the default) will contain the object metadata. Check the returned kind and apiVersion of the object before parsing. The media type of the object will always match the enclosing list - if this as a JSON table, these will be JSON encoded objects.", - Default: map[string]interface{}{}, Ref: ref("k8s.io/apimachinery/pkg/runtime.RawExtension"), }, }, @@ -2342,7 +2371,6 @@ func schema_pkg_apis_meta_v1_WatchEvent(ref common.ReferenceCallback) common.Ope "object": { SchemaProps: spec.SchemaProps{ Description: "Object is:\n * If Type is Added or Modified: the new state of the object.\n * If Type is Deleted: the state of the object immediately before deletion.\n * If Type is Error: *Status is recommended; other types may make sense\n depending on context.", - Default: map[string]interface{}{}, Ref: ref("k8s.io/apimachinery/pkg/runtime.RawExtension"), }, }, diff --git a/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 0000000000000..3061580736b52 --- /dev/null +++ b/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -0,0 +1,37 @@ +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,APIGroup,ServerAddressByClientCIDRs +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,APIGroup,Versions +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,APIGroupList,Groups +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,APIResource,Categories +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,APIResource,ShortNames +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,APIResourceList,APIResources +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,APIVersions,ServerAddressByClientCIDRs +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,APIVersions,Versions +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,ApplyOptions,DryRun +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,CreateOptions,DryRun +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,DeleteOptions,DryRun +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,FieldsV1,Raw +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,LabelSelector,MatchExpressions +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,LabelSelectorRequirement,Values +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,ObjectMeta,Finalizers +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,ObjectMeta,ManagedFields +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,ObjectMeta,OwnerReferences +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,PatchOptions,DryRun +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,RootPaths,Paths +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,StatusDetails,Causes +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,Table,ColumnDefinitions +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,Table,Rows +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,TableRow,Cells +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,TableRow,Conditions +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/apis/meta/v1,UpdateOptions,DryRun +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/runtime,RawExtension,Raw +API rule violation: list_type_missing,k8s.io/apimachinery/pkg/runtime,Unknown,Raw +API rule violation: names_match,github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1,Unstructured,Object +API rule violation: names_match,k8s.io/apimachinery/pkg/apis/meta/v1,APIResourceList,APIResources +API rule violation: names_match,k8s.io/apimachinery/pkg/apis/meta/v1,Duration,Duration +API rule violation: names_match,k8s.io/apimachinery/pkg/apis/meta/v1,InternalEvent,Object +API rule violation: names_match,k8s.io/apimachinery/pkg/apis/meta/v1,InternalEvent,Type +API rule violation: names_match,k8s.io/apimachinery/pkg/apis/meta/v1,MicroTime,Time +API rule violation: names_match,k8s.io/apimachinery/pkg/apis/meta/v1,StatusCause,Type +API rule violation: names_match,k8s.io/apimachinery/pkg/apis/meta/v1,Time,Time +API rule violation: names_match,k8s.io/apimachinery/pkg/runtime,Unknown,ContentEncoding +API rule violation: names_match,k8s.io/apimachinery/pkg/runtime,Unknown,ContentType diff --git a/pkg/apimachinery/go.mod b/pkg/apimachinery/go.mod new file mode 100644 index 0000000000000..e7d9e40affd33 --- /dev/null +++ b/pkg/apimachinery/go.mod @@ -0,0 +1,37 @@ +module github.com/grafana/grafana/pkg/apimachinery + +go 1.21.0 + +require ( + k8s.io/apimachinery v0.29.2 + k8s.io/kube-openapi v0.0.0-20240220201932-37d671a357a5 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-openapi/jsonpointer v0.20.2 // indirect + github.com/go-openapi/jsonreference v0.20.4 // indirect + github.com/go-openapi/swag v0.22.9 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.32.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.120.1 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect +) diff --git a/pkg/apimachinery/go.sum b/pkg/apimachinery/go.sum new file mode 100644 index 0000000000000..b678ff22e89ea --- /dev/null +++ b/pkg/apimachinery/go.sum @@ -0,0 +1,104 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= +github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU= +github.com/go-openapi/swag v0.22.9 h1:XX2DssF+mQKM2DHsbgZK74y/zj4mo9I99+89xUmuZCE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/apimachinery v0.29.2 h1:EWGpfJ856oj11C52NRCHuU7rFDwxev48z+6DSlGNsV8= +k8s.io/apimachinery v0.29.2/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/kube-openapi v0.0.0-20240220201932-37d671a357a5 h1:QSpdNrZ9uRlV0VkqLvVO0Rqg8ioKi3oSw7O5P7pJV8M= +k8s.io/kube-openapi v0.0.0-20240220201932-37d671a357a5/go.mod h1:Pa1PvrP7ACSkuX6I7KYomY6cmMA0Tx86waBhDUgoKPw= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/pkg/apis/dashboard/v0alpha1/doc.go b/pkg/apis/dashboard/v0alpha1/doc.go new file mode 100644 index 0000000000000..92822b1a59e72 --- /dev/null +++ b/pkg/apis/dashboard/v0alpha1/doc.go @@ -0,0 +1,6 @@ +// +k8s:deepcopy-gen=package +// +k8s:openapi-gen=true +// +k8s:defaulter-gen=TypeMeta +// +groupName=dashboard.grafana.app + +package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" diff --git a/pkg/apis/dashboard/v0alpha1/register.go b/pkg/apis/dashboard/v0alpha1/register.go new file mode 100644 index 0000000000000..1bb1bf2235166 --- /dev/null +++ b/pkg/apis/dashboard/v0alpha1/register.go @@ -0,0 +1,31 @@ +package v0alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +const ( + GROUP = "dashboard.grafana.app" + VERSION = "v0alpha1" + APIVERSION = GROUP + "/" + VERSION +) + +var DashboardResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "dashboards", "dashboard", "Dashboard", + func() runtime.Object { return &Dashboard{} }, + func() runtime.Object { return &DashboardList{} }, +) + +var DashboardSummaryResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "summary", "summary", "DashboardSummary", + func() runtime.Object { return &DashboardSummary{} }, + func() runtime.Object { return &DashboardSummaryList{} }, +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} +) diff --git a/pkg/apis/dashboard/v0alpha1/types.go b/pkg/apis/dashboard/v0alpha1/types.go new file mode 100644 index 0000000000000..df69ef7c1abda --- /dev/null +++ b/pkg/apis/dashboard/v0alpha1/types.go @@ -0,0 +1,114 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type Dashboard struct { + metav1.TypeMeta `json:",inline"` + // Standard object's metadata + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitempty"` + + // The dashboard body (unstructured for now) + Spec common.Unstructured `json:"spec"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DashboardList struct { + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ListMeta `json:"metadata,omitempty"` + + Items []Dashboard `json:"items,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DashboardSummary struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // The dashboard body + Spec DashboardSummarySpec `json:"spec,omitempty"` +} + +type DashboardSummarySpec struct { + Title string `json:"title"` + Tags []string `json:"tags,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DashboardSummaryList struct { + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ListMeta `json:"metadata,omitempty"` + + Items []DashboardSummary `json:"items,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DashboardVersionList struct { + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ListMeta `json:"metadata,omitempty"` + + Items []DashboardVersionInfo `json:"items,omitempty"` +} + +type DashboardVersionInfo struct { + // The internal ID for this version (will be replaced with resourceVersion) + Version int `json:"version"` + + // If the dashboard came from a previous version, it is set here + ParentVersion int `json:"parentVersion,omitempty"` + + // The creation timestamp for this version + Created int64 `json:"created"` + + // The user who created this version + CreatedBy string `json:"createdBy,omitempty"` + + // Message passed while saving the version + Message string `json:"message,omitempty"` +} + +// +k8s:conversion-gen:explicit-from=net/url.Values +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type VersionsQueryOptions struct { + metav1.TypeMeta `json:",inline"` + + // Path is the URL path + // +optional + Path string `json:"path,omitempty"` + + // +optional + Version int64 `json:"version,omitempty"` +} + +// Information about how the requesting user can use a given dashboard +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DashboardAccessInfo struct { + metav1.TypeMeta `json:",inline"` + + CanSave bool `json:"canSave"` + CanEdit bool `json:"canEdit"` + CanAdmin bool `json:"canAdmin"` + CanStar bool `json:"canStar"` + CanDelete bool `json:"canDelete"` + AnnotationsPermissions *AnnotationPermission `json:"annotationsPermissions"` +} + +type AnnotationPermission struct { + Dashboard AnnotationActions `json:"dashboard"` + Organization AnnotationActions `json:"organization"` +} + +type AnnotationActions struct { + CanAdd bool `json:"canAdd"` + CanEdit bool `json:"canEdit"` + CanDelete bool `json:"canDelete"` +} diff --git a/pkg/apis/dashboard/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/dashboard/v0alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000000000..2e0aa21fd2072 --- /dev/null +++ b/pkg/apis/dashboard/v0alpha1/zz_generated.deepcopy.go @@ -0,0 +1,289 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AnnotationActions) DeepCopyInto(out *AnnotationActions) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AnnotationActions. +func (in *AnnotationActions) DeepCopy() *AnnotationActions { + if in == nil { + return nil + } + out := new(AnnotationActions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AnnotationPermission) DeepCopyInto(out *AnnotationPermission) { + *out = *in + out.Dashboard = in.Dashboard + out.Organization = in.Organization + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AnnotationPermission. +func (in *AnnotationPermission) DeepCopy() *AnnotationPermission { + if in == nil { + return nil + } + out := new(AnnotationPermission) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Dashboard) DeepCopyInto(out *Dashboard) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Dashboard. +func (in *Dashboard) DeepCopy() *Dashboard { + if in == nil { + return nil + } + out := new(Dashboard) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Dashboard) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DashboardAccessInfo) DeepCopyInto(out *DashboardAccessInfo) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.AnnotationsPermissions != nil { + in, out := &in.AnnotationsPermissions, &out.AnnotationsPermissions + *out = new(AnnotationPermission) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardAccessInfo. +func (in *DashboardAccessInfo) DeepCopy() *DashboardAccessInfo { + if in == nil { + return nil + } + out := new(DashboardAccessInfo) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DashboardAccessInfo) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DashboardList) DeepCopyInto(out *DashboardList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Dashboard, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardList. +func (in *DashboardList) DeepCopy() *DashboardList { + if in == nil { + return nil + } + out := new(DashboardList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DashboardList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DashboardSummary) DeepCopyInto(out *DashboardSummary) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSummary. +func (in *DashboardSummary) DeepCopy() *DashboardSummary { + if in == nil { + return nil + } + out := new(DashboardSummary) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DashboardSummary) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DashboardSummaryList) DeepCopyInto(out *DashboardSummaryList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DashboardSummary, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSummaryList. +func (in *DashboardSummaryList) DeepCopy() *DashboardSummaryList { + if in == nil { + return nil + } + out := new(DashboardSummaryList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DashboardSummaryList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DashboardSummarySpec) DeepCopyInto(out *DashboardSummarySpec) { + *out = *in + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSummarySpec. +func (in *DashboardSummarySpec) DeepCopy() *DashboardSummarySpec { + if in == nil { + return nil + } + out := new(DashboardSummarySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DashboardVersionInfo) DeepCopyInto(out *DashboardVersionInfo) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardVersionInfo. +func (in *DashboardVersionInfo) DeepCopy() *DashboardVersionInfo { + if in == nil { + return nil + } + out := new(DashboardVersionInfo) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DashboardVersionList) DeepCopyInto(out *DashboardVersionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DashboardVersionInfo, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardVersionList. +func (in *DashboardVersionList) DeepCopy() *DashboardVersionList { + if in == nil { + return nil + } + out := new(DashboardVersionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DashboardVersionList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VersionsQueryOptions) DeepCopyInto(out *VersionsQueryOptions) { + *out = *in + out.TypeMeta = in.TypeMeta + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VersionsQueryOptions. +func (in *VersionsQueryOptions) DeepCopy() *VersionsQueryOptions { + if in == nil { + return nil + } + out := new(VersionsQueryOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VersionsQueryOptions) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} diff --git a/pkg/apis/dashboard/v0alpha1/zz_generated.defaults.go b/pkg/apis/dashboard/v0alpha1/zz_generated.defaults.go new file mode 100644 index 0000000000000..238fc2f4edcfe --- /dev/null +++ b/pkg/apis/dashboard/v0alpha1/zz_generated.defaults.go @@ -0,0 +1,19 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by defaulter-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// RegisterDefaults adds defaulters functions to the given scheme. +// Public to allow building arbitrary schemes. +// All generated defaulters are covering - they call all nested defaulters. +func RegisterDefaults(scheme *runtime.Scheme) error { + return nil +} diff --git a/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go b/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go new file mode 100644 index 0000000000000..e2fa02b3a91de --- /dev/null +++ b/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go @@ -0,0 +1,509 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by openapi-gen. DO NOT EDIT. + +// This file was autogenerated by openapi-gen. Do not edit it manually! + +package v0alpha1 + +import ( + common "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" +) + +func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + return map[string]common.OpenAPIDefinition{ + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.AnnotationActions": schema_pkg_apis_dashboard_v0alpha1_AnnotationActions(ref), + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.AnnotationPermission": schema_pkg_apis_dashboard_v0alpha1_AnnotationPermission(ref), + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.Dashboard": schema_pkg_apis_dashboard_v0alpha1_Dashboard(ref), + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.DashboardAccessInfo": schema_pkg_apis_dashboard_v0alpha1_DashboardAccessInfo(ref), + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.DashboardList": schema_pkg_apis_dashboard_v0alpha1_DashboardList(ref), + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.DashboardSummary": schema_pkg_apis_dashboard_v0alpha1_DashboardSummary(ref), + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.DashboardSummaryList": schema_pkg_apis_dashboard_v0alpha1_DashboardSummaryList(ref), + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.DashboardSummarySpec": schema_pkg_apis_dashboard_v0alpha1_DashboardSummarySpec(ref), + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.DashboardVersionInfo": schema_pkg_apis_dashboard_v0alpha1_DashboardVersionInfo(ref), + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.DashboardVersionList": schema_pkg_apis_dashboard_v0alpha1_DashboardVersionList(ref), + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.VersionsQueryOptions": schema_pkg_apis_dashboard_v0alpha1_VersionsQueryOptions(ref), + } +} + +func schema_pkg_apis_dashboard_v0alpha1_AnnotationActions(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "canAdd": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "canEdit": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "canDelete": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + }, + Required: []string{"canAdd", "canEdit", "canDelete"}, + }, + }, + } +} + +func schema_pkg_apis_dashboard_v0alpha1_AnnotationPermission(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "dashboard": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.AnnotationActions"), + }, + }, + "organization": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.AnnotationActions"), + }, + }, + }, + Required: []string{"dashboard", "organization"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.AnnotationActions"}, + } +} + +func schema_pkg_apis_dashboard_v0alpha1_Dashboard(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Description: "Standard object's metadata More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Description: "The dashboard body (unstructured for now)", + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"), + }, + }, + }, + Required: []string{"spec"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_dashboard_v0alpha1_DashboardAccessInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Information about how the requesting user can use a given dashboard", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "canSave": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "canEdit": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "canAdmin": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "canStar": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "canDelete": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "annotationsPermissions": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.AnnotationPermission"), + }, + }, + }, + Required: []string{"canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.AnnotationPermission"}, + } +} + +func schema_pkg_apis_dashboard_v0alpha1_DashboardList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.Dashboard"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.Dashboard", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_dashboard_v0alpha1_DashboardSummary(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Description: "The dashboard body", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.DashboardSummarySpec"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.DashboardSummarySpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_dashboard_v0alpha1_DashboardSummaryList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.DashboardSummary"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.DashboardSummary", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_dashboard_v0alpha1_DashboardSummarySpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "title": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "tags": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + }, + Required: []string{"title"}, + }, + }, + } +} + +func schema_pkg_apis_dashboard_v0alpha1_DashboardVersionInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "version": { + SchemaProps: spec.SchemaProps{ + Description: "The internal ID for this version (will be replaced with resourceVersion)", + Default: 0, + Type: []string{"integer"}, + Format: "int32", + }, + }, + "parentVersion": { + SchemaProps: spec.SchemaProps{ + Description: "If the dashboard came from a previous version, it is set here", + Type: []string{"integer"}, + Format: "int32", + }, + }, + "created": { + SchemaProps: spec.SchemaProps{ + Description: "The creation timestamp for this version", + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "createdBy": { + SchemaProps: spec.SchemaProps{ + Description: "The user who created this version", + Type: []string{"string"}, + Format: "", + }, + }, + "message": { + SchemaProps: spec.SchemaProps{ + Description: "Message passed while saving the version", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"version", "created"}, + }, + }, + } +} + +func schema_pkg_apis_dashboard_v0alpha1_DashboardVersionList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.DashboardVersionInfo"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.DashboardVersionInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_dashboard_v0alpha1_VersionsQueryOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "path": { + SchemaProps: spec.SchemaProps{ + Description: "Path is the URL path", + Type: []string{"string"}, + Format: "", + }, + }, + "version": { + SchemaProps: spec.SchemaProps{ + Type: []string{"integer"}, + Format: "int64", + }, + }, + }, + }, + }, + } +} diff --git a/pkg/apis/dashboard/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/dashboard/v0alpha1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 0000000000000..1428ccea7ae58 --- /dev/null +++ b/pkg/apis/dashboard/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -0,0 +1 @@ +API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1,DashboardSummarySpec,Tags diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/doc.go b/pkg/apis/dashboardsnapshot/v0alpha1/doc.go new file mode 100644 index 0000000000000..a6b2fec52cbdb --- /dev/null +++ b/pkg/apis/dashboardsnapshot/v0alpha1/doc.go @@ -0,0 +1,6 @@ +// +k8s:deepcopy-gen=package +// +k8s:openapi-gen=true +// +k8s:defaulter-gen=TypeMeta +// +groupName=dashboardsnapshot.grafana.app + +package v0alpha1 diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/register.go b/pkg/apis/dashboardsnapshot/v0alpha1/register.go new file mode 100644 index 0000000000000..57209ad7b91e4 --- /dev/null +++ b/pkg/apis/dashboardsnapshot/v0alpha1/register.go @@ -0,0 +1,25 @@ +package v0alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +const ( + GROUP = "dashboardsnapshot.grafana.app" + VERSION = "v0alpha1" + APIVERSION = GROUP + "/" + VERSION +) + +var DashboardSnapshotResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "dashboardsnapshots", "dashboardsnapshot", "DashboardSnapshot", + func() runtime.Object { return &DashboardSnapshot{} }, + func() runtime.Object { return &DashboardSnapshotList{} }, +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} +) diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/types.go b/pkg/apis/dashboardsnapshot/v0alpha1/types.go new file mode 100644 index 0000000000000..8e3aa7520f9ca --- /dev/null +++ b/pkg/apis/dashboardsnapshot/v0alpha1/types.go @@ -0,0 +1,136 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DashboardSnapshot struct { + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Snapshot summary info + Spec SnapshotInfo `json:"spec"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DashboardSnapshotList struct { + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ListMeta `json:"metadata,omitempty"` + + Items []DashboardSnapshot `json:"items,omitempty"` +} + +type SnapshotInfo struct { + Title string `json:"title,omitempty"` + // Optionally auto-remove the snapshot at a future date + Expires int64 `json:"expires,omitempty"` + // When set to true, the snapshot exists in a remote server + External bool `json:"external,omitempty"` + // The external URL where the snapshot can be seen + ExternalURL string `json:"externalUrl,omitempty"` + // The URL that created the dashboard originally + OriginalUrl string `json:"originalUrl,omitempty"` + // Snapshot creation timestamp + Timestamp string `json:"timestamp,omitempty"` +} + +// This is returned from the POST command +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DashboardSnapshotWithDeleteKey struct { + DashboardSnapshot `json:",inline"` + + // The delete key is only returned when the item is created. It is not returned from a get request + DeleteKey string `json:"deleteKey,omitempty"` +} + +// This is the snapshot returned from the subresource +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type FullDashboardSnapshot struct { + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Snapshot summary info + Info SnapshotInfo `json:"info"` + + // The raw dashboard (unstructured for now) + Dashboard common.Unstructured `json:"dashboard"` +} + +// Each tenant, may have different sharing options +// This is currently set using custom.ini, but multi-tenant support will need +// to be managed differently +type SnapshotSharingOptions struct { + SnapshotsEnabled bool `json:"snapshotEnabled"` + ExternalSnapshotURL string `json:"externalSnapshotURL,omitempty"` + ExternalSnapshotName string `json:"externalSnapshotName,omitempty"` + ExternalEnabled bool `json:"externalEnabled,omitempty"` +} + +// These are the values expected to be sent from an end user +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DashboardCreateCommand struct { + metav1.TypeMeta `json:",inline"` + + // Snapshot name + // required:false + Name string `json:"name"` + + // The complete dashboard model. + // required:true + Dashboard *common.Unstructured `json:"dashboard" binding:"Required"` + + // When the snapshot should expire in seconds in seconds. Default is never to expire. + // required:false + // default:0 + Expires int64 `json:"expires"` + + // these are passed when storing an external snapshot ref + // Save the snapshot on an external server rather than locally. + // required:false + // default: false + External bool `json:"external"` +} + +// The create response +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DashboardCreateResponse struct { + metav1.TypeMeta `json:",inline"` + + // The unique key + Key string `json:"key"` + + // A unique key that will allow delete + DeleteKey string `json:"deleteKey"` + + // Absolute URL to show the dashboard + URL string `json:"url"` + + // URL that will delete the response + DeleteURL string `json:"deleteUrl"` +} + +// Represents an options object that must be named for each namespace/team/user +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type SharingOptions struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Show the options inline + Spec SnapshotSharingOptions `json:"spec"` +} + +// Represents a list of namespaced options +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type SharingOptionsList struct { + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ListMeta `json:"metadata,omitempty"` + + Items []SharingOptions `json:"items,omitempty"` +} diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000000000..51b5075f310ea --- /dev/null +++ b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.deepcopy.go @@ -0,0 +1,271 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DashboardCreateCommand) DeepCopyInto(out *DashboardCreateCommand) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.Dashboard != nil { + in, out := &in.Dashboard, &out.Dashboard + *out = (*in).DeepCopy() + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardCreateCommand. +func (in *DashboardCreateCommand) DeepCopy() *DashboardCreateCommand { + if in == nil { + return nil + } + out := new(DashboardCreateCommand) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DashboardCreateCommand) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DashboardCreateResponse) DeepCopyInto(out *DashboardCreateResponse) { + *out = *in + out.TypeMeta = in.TypeMeta + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardCreateResponse. +func (in *DashboardCreateResponse) DeepCopy() *DashboardCreateResponse { + if in == nil { + return nil + } + out := new(DashboardCreateResponse) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DashboardCreateResponse) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DashboardSnapshot) DeepCopyInto(out *DashboardSnapshot) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSnapshot. +func (in *DashboardSnapshot) DeepCopy() *DashboardSnapshot { + if in == nil { + return nil + } + out := new(DashboardSnapshot) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DashboardSnapshot) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DashboardSnapshotList) DeepCopyInto(out *DashboardSnapshotList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DashboardSnapshot, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSnapshotList. +func (in *DashboardSnapshotList) DeepCopy() *DashboardSnapshotList { + if in == nil { + return nil + } + out := new(DashboardSnapshotList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DashboardSnapshotList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DashboardSnapshotWithDeleteKey) DeepCopyInto(out *DashboardSnapshotWithDeleteKey) { + *out = *in + in.DashboardSnapshot.DeepCopyInto(&out.DashboardSnapshot) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSnapshotWithDeleteKey. +func (in *DashboardSnapshotWithDeleteKey) DeepCopy() *DashboardSnapshotWithDeleteKey { + if in == nil { + return nil + } + out := new(DashboardSnapshotWithDeleteKey) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DashboardSnapshotWithDeleteKey) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FullDashboardSnapshot) DeepCopyInto(out *FullDashboardSnapshot) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Info = in.Info + in.Dashboard.DeepCopyInto(&out.Dashboard) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FullDashboardSnapshot. +func (in *FullDashboardSnapshot) DeepCopy() *FullDashboardSnapshot { + if in == nil { + return nil + } + out := new(FullDashboardSnapshot) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *FullDashboardSnapshot) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SharingOptions) DeepCopyInto(out *SharingOptions) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SharingOptions. +func (in *SharingOptions) DeepCopy() *SharingOptions { + if in == nil { + return nil + } + out := new(SharingOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *SharingOptions) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SharingOptionsList) DeepCopyInto(out *SharingOptionsList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]SharingOptions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SharingOptionsList. +func (in *SharingOptionsList) DeepCopy() *SharingOptionsList { + if in == nil { + return nil + } + out := new(SharingOptionsList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *SharingOptionsList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SnapshotInfo) DeepCopyInto(out *SnapshotInfo) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SnapshotInfo. +func (in *SnapshotInfo) DeepCopy() *SnapshotInfo { + if in == nil { + return nil + } + out := new(SnapshotInfo) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SnapshotSharingOptions) DeepCopyInto(out *SnapshotSharingOptions) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SnapshotSharingOptions. +func (in *SnapshotSharingOptions) DeepCopy() *SnapshotSharingOptions { + if in == nil { + return nil + } + out := new(SnapshotSharingOptions) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.defaults.go b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.defaults.go new file mode 100644 index 0000000000000..238fc2f4edcfe --- /dev/null +++ b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.defaults.go @@ -0,0 +1,19 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by defaulter-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// RegisterDefaults adds defaulters functions to the given scheme. +// Public to allow building arbitrary schemes. +// All generated defaulters are covering - they call all nested defaulters. +func RegisterDefaults(scheme *runtime.Scheme) error { + return nil +} diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi.go b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi.go new file mode 100644 index 0000000000000..7566695cfaad5 --- /dev/null +++ b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi.go @@ -0,0 +1,521 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by openapi-gen. DO NOT EDIT. + +// This file was autogenerated by openapi-gen. Do not edit it manually! + +package v0alpha1 + +import ( + common "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" +) + +func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + return map[string]common.OpenAPIDefinition{ + "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardCreateCommand": schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardCreateCommand(ref), + "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardCreateResponse": schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardCreateResponse(ref), + "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardSnapshot": schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshot(ref), + "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardSnapshotList": schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshotList(ref), + "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardSnapshotWithDeleteKey": schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshotWithDeleteKey(ref), + "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.FullDashboardSnapshot": schema_pkg_apis_dashboardsnapshot_v0alpha1_FullDashboardSnapshot(ref), + "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SharingOptions": schema_pkg_apis_dashboardsnapshot_v0alpha1_SharingOptions(ref), + "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SharingOptionsList": schema_pkg_apis_dashboardsnapshot_v0alpha1_SharingOptionsList(ref), + "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo": schema_pkg_apis_dashboardsnapshot_v0alpha1_SnapshotInfo(ref), + "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotSharingOptions": schema_pkg_apis_dashboardsnapshot_v0alpha1_SnapshotSharingOptions(ref), + } +} + +func schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardCreateCommand(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "These are the values expected to be sent from an end user", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "name": { + SchemaProps: spec.SchemaProps{ + Description: "Snapshot name required:false", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "dashboard": { + SchemaProps: spec.SchemaProps{ + Description: "The complete dashboard model. required:true", + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"), + }, + }, + "expires": { + SchemaProps: spec.SchemaProps{ + Description: "When the snapshot should expire in seconds in seconds. Default is never to expire. required:false default:0", + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "external": { + SchemaProps: spec.SchemaProps{ + Description: "these are passed when storing an external snapshot ref Save the snapshot on an external server rather than locally. required:false default: false", + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + }, + Required: []string{"name", "dashboard", "expires", "external"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"}, + } +} + +func schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardCreateResponse(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "The create response", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "key": { + SchemaProps: spec.SchemaProps{ + Description: "The unique key", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "deleteKey": { + SchemaProps: spec.SchemaProps{ + Description: "A unique key that will allow delete", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "url": { + SchemaProps: spec.SchemaProps{ + Description: "Absolute URL to show the dashboard", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "deleteUrl": { + SchemaProps: spec.SchemaProps{ + Description: "URL that will delete the response", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"key", "deleteKey", "url", "deleteUrl"}, + }, + }, + } +} + +func schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshot(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Description: "Snapshot summary info", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo"), + }, + }, + }, + Required: []string{"spec"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshotList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardSnapshot"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardSnapshot", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshotWithDeleteKey(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "This is returned from the POST command", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Description: "Snapshot summary info", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo"), + }, + }, + "deleteKey": { + SchemaProps: spec.SchemaProps{ + Description: "The delete key is only returned when the item is created. It is not returned from a get request", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"spec"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_dashboardsnapshot_v0alpha1_FullDashboardSnapshot(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "This is the snapshot returned from the subresource", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "info": { + SchemaProps: spec.SchemaProps{ + Description: "Snapshot summary info", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo"), + }, + }, + "dashboard": { + SchemaProps: spec.SchemaProps{ + Description: "The raw dashboard (unstructured for now)", + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"), + }, + }, + }, + Required: []string{"info", "dashboard"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured", "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_dashboardsnapshot_v0alpha1_SharingOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Represents an options object that must be named for each namespace/team/user", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Description: "Show the options inline", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotSharingOptions"), + }, + }, + }, + Required: []string{"spec"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotSharingOptions", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_dashboardsnapshot_v0alpha1_SharingOptionsList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Represents a list of namespaced options", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SharingOptions"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SharingOptions", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_dashboardsnapshot_v0alpha1_SnapshotInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "title": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "expires": { + SchemaProps: spec.SchemaProps{ + Description: "Optionally auto-remove the snapshot at a future date", + Type: []string{"integer"}, + Format: "int64", + }, + }, + "external": { + SchemaProps: spec.SchemaProps{ + Description: "When set to true, the snapshot exists in a remote server", + Type: []string{"boolean"}, + Format: "", + }, + }, + "externalUrl": { + SchemaProps: spec.SchemaProps{ + Description: "The external URL where the snapshot can be seen", + Type: []string{"string"}, + Format: "", + }, + }, + "originalUrl": { + SchemaProps: spec.SchemaProps{ + Description: "The URL that created the dashboard originally", + Type: []string{"string"}, + Format: "", + }, + }, + "timestamp": { + SchemaProps: spec.SchemaProps{ + Description: "Snapshot creation timestamp", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} + +func schema_pkg_apis_dashboardsnapshot_v0alpha1_SnapshotSharingOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Each tenant, may have different sharing options This is currently set using custom.ini, but multi-tenant support will need to be managed differently", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "snapshotEnabled": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "externalSnapshotURL": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "externalSnapshotName": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "externalEnabled": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + Format: "", + }, + }, + }, + Required: []string{"snapshotEnabled"}, + }, + }, + } +} diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 0000000000000..58fc05ad93000 --- /dev/null +++ b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -0,0 +1,3 @@ +API rule violation: names_match,github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1,DashboardCreateResponse,DeleteURL +API rule violation: names_match,github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1,SnapshotInfo,ExternalURL +API rule violation: names_match,github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1,SnapshotSharingOptions,SnapshotsEnabled diff --git a/pkg/apis/datasource/v0alpha1/doc.go b/pkg/apis/datasource/v0alpha1/doc.go new file mode 100644 index 0000000000000..43d5863c3c084 --- /dev/null +++ b/pkg/apis/datasource/v0alpha1/doc.go @@ -0,0 +1,6 @@ +// +k8s:deepcopy-gen=package +// +k8s:openapi-gen=true +// +k8s:defaulter-gen=TypeMeta +// +groupName=datasource.grafana.com + +package v0alpha1 diff --git a/pkg/apis/datasource/v0alpha1/register.go b/pkg/apis/datasource/v0alpha1/register.go new file mode 100644 index 0000000000000..7eae1938ef729 --- /dev/null +++ b/pkg/apis/datasource/v0alpha1/register.go @@ -0,0 +1,18 @@ +package v0alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +const ( + GROUP = "*.datasource.grafana.app" + VERSION = "v0alpha1" +) + +var GenericConnectionResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "connections", "connection", "DataSourceConnection", + func() runtime.Object { return &DataSourceConnection{} }, + func() runtime.Object { return &DataSourceConnectionList{} }, +) diff --git a/pkg/apis/datasource/v0alpha1/types.go b/pkg/apis/datasource/v0alpha1/types.go new file mode 100644 index 0000000000000..509810ff0c25e --- /dev/null +++ b/pkg/apis/datasource/v0alpha1/types.go @@ -0,0 +1,44 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DataSourceConnection struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // The display name + Title string `json:"title"` + + // Optional description for the data source (does not exist yet) + Description string `json:"description,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DataSourceConnectionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []DataSourceConnection `json:"items,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type HealthCheckResult struct { + metav1.TypeMeta `json:",inline"` + + // The string description + Status string `json:"status,omitempty"` + + // Explicit status code + Code int `json:"code,omitempty"` + + // Optional description for the data source + Message string `json:"message,omitempty"` + + // Spec depends on the plugin + Details *common.Unstructured `json:"details,omitempty"` +} diff --git a/pkg/apis/datasource/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/datasource/v0alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000000000..5f9e41d7ad2b4 --- /dev/null +++ b/pkg/apis/datasource/v0alpha1/zz_generated.deepcopy.go @@ -0,0 +1,100 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DataSourceConnection) DeepCopyInto(out *DataSourceConnection) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSourceConnection. +func (in *DataSourceConnection) DeepCopy() *DataSourceConnection { + if in == nil { + return nil + } + out := new(DataSourceConnection) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DataSourceConnection) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DataSourceConnectionList) DeepCopyInto(out *DataSourceConnectionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DataSourceConnection, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSourceConnectionList. +func (in *DataSourceConnectionList) DeepCopy() *DataSourceConnectionList { + if in == nil { + return nil + } + out := new(DataSourceConnectionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DataSourceConnectionList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HealthCheckResult) DeepCopyInto(out *HealthCheckResult) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.Details != nil { + in, out := &in.Details, &out.Details + *out = (*in).DeepCopy() + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HealthCheckResult. +func (in *HealthCheckResult) DeepCopy() *HealthCheckResult { + if in == nil { + return nil + } + out := new(HealthCheckResult) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HealthCheckResult) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} diff --git a/pkg/apis/datasource/v0alpha1/zz_generated.defaults.go b/pkg/apis/datasource/v0alpha1/zz_generated.defaults.go new file mode 100644 index 0000000000000..238fc2f4edcfe --- /dev/null +++ b/pkg/apis/datasource/v0alpha1/zz_generated.defaults.go @@ -0,0 +1,19 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by defaulter-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// RegisterDefaults adds defaulters functions to the given scheme. +// Public to allow building arbitrary schemes. +// All generated defaulters are covering - they call all nested defaulters. +func RegisterDefaults(scheme *runtime.Scheme) error { + return nil +} diff --git a/pkg/apis/datasource/v0alpha1/zz_generated.openapi.go b/pkg/apis/datasource/v0alpha1/zz_generated.openapi.go new file mode 100644 index 0000000000000..1450dc98477d4 --- /dev/null +++ b/pkg/apis/datasource/v0alpha1/zz_generated.openapi.go @@ -0,0 +1,175 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by openapi-gen. DO NOT EDIT. + +// This file was autogenerated by openapi-gen. Do not edit it manually! + +package v0alpha1 + +import ( + common "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" +) + +func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + return map[string]common.OpenAPIDefinition{ + "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.DataSourceConnection": schema_pkg_apis_datasource_v0alpha1_DataSourceConnection(ref), + "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.DataSourceConnectionList": schema_pkg_apis_datasource_v0alpha1_DataSourceConnectionList(ref), + "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.HealthCheckResult": schema_pkg_apis_datasource_v0alpha1_HealthCheckResult(ref), + } +} + +func schema_pkg_apis_datasource_v0alpha1_DataSourceConnection(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "title": { + SchemaProps: spec.SchemaProps{ + Description: "The display name", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "description": { + SchemaProps: spec.SchemaProps{ + Description: "Optional description for the data source (does not exist yet)", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"title"}, + }, + }, + Dependencies: []string{ + "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_datasource_v0alpha1_DataSourceConnectionList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.DataSourceConnection"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.DataSourceConnection", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_datasource_v0alpha1_HealthCheckResult(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "status": { + SchemaProps: spec.SchemaProps{ + Description: "The string description", + Type: []string{"string"}, + Format: "", + }, + }, + "code": { + SchemaProps: spec.SchemaProps{ + Description: "Explicit status code", + Type: []string{"integer"}, + Format: "int32", + }, + }, + "message": { + SchemaProps: spec.SchemaProps{ + Description: "Optional description for the data source", + Type: []string{"string"}, + Format: "", + }, + }, + "details": { + SchemaProps: spec.SchemaProps{ + Description: "Spec depends on the the plugin", + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"}, + } +} diff --git a/pkg/apis/example/v0alpha1/doc.go b/pkg/apis/example/v0alpha1/doc.go index 84d861cb5b70d..96849073b83cc 100644 --- a/pkg/apis/example/v0alpha1/doc.go +++ b/pkg/apis/example/v0alpha1/doc.go @@ -1,6 +1,7 @@ // +k8s:deepcopy-gen=package // +k8s:openapi-gen=true -// +groupName=example.grafana.com +// +k8s:defaulter-gen=TypeMeta +// +groupName=example.grafana.app // The testing api is a dependency free service that we can use to experiment with // api aggregation across multiple deployment models. Specifically: diff --git a/pkg/apis/example/v0alpha1/register.go b/pkg/apis/example/v0alpha1/register.go new file mode 100644 index 0000000000000..e254cad1d3472 --- /dev/null +++ b/pkg/apis/example/v0alpha1/register.go @@ -0,0 +1,30 @@ +package v0alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +const ( + GROUP = "example.grafana.app" + VERSION = "v0alpha1" + APIVERSION = GROUP + "/" + VERSION +) + +var RuntimeResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "runtime", "runtime", "RuntimeInfo", + func() runtime.Object { return &RuntimeInfo{} }, + func() runtime.Object { return &RuntimeInfo{} }, +) +var DummyResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "dummy", "dummy", "DummyResource", + func() runtime.Object { return &DummyResource{} }, + func() runtime.Object { return &DummyResourceList{} }, +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} +) diff --git a/pkg/apis/example/v0alpha1/types.go b/pkg/apis/example/v0alpha1/types.go index 343bd78738bbd..a8fef3ffe6f3a 100644 --- a/pkg/apis/example/v0alpha1/types.go +++ b/pkg/apis/example/v0alpha1/types.go @@ -2,26 +2,8 @@ package v0alpha1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - runtime "k8s.io/apimachinery/pkg/runtime" - "github.com/grafana/grafana/pkg/apis" -) - -const ( - GROUP = "example.grafana.app" - VERSION = "v0alpha1" - APIVERSION = GROUP + "/" + VERSION -) - -var RuntimeResourceInfo = apis.NewResourceInfo(GROUP, VERSION, - "runtime", "runtime", "RuntimeInfo", - func() runtime.Object { return &RuntimeInfo{} }, - func() runtime.Object { return &RuntimeInfo{} }, -) -var DummyResourceInfo = apis.NewResourceInfo(GROUP, VERSION, - "dummy", "dummy", "DummyResource", - func() runtime.Object { return &DummyResource{} }, - func() runtime.Object { return &DummyResourceList{} }, + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" ) // Mirrors the info exposed in "github.com/grafana/grafana/pkg/setting" @@ -46,13 +28,12 @@ type DummyResource struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - Spec string `json:"spec,omitempty"` + Spec common.Unstructured `json:"spec,omitempty"` } // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type DummyResourceList struct { metav1.TypeMeta `json:",inline"` - // +optional metav1.ListMeta `json:"metadata,omitempty"` Items []DummyResource `json:"items,omitempty"` diff --git a/pkg/apis/example/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/example/v0alpha1/zz_generated.deepcopy.go index 40b6a4b768804..063ad395718c4 100644 --- a/pkg/apis/example/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/example/v0alpha1/zz_generated.deepcopy.go @@ -16,6 +16,7 @@ func (in *DummyResource) DeepCopyInto(out *DummyResource) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) return } diff --git a/pkg/apis/example/v0alpha1/zz_generated.openapi.go b/pkg/apis/example/v0alpha1/zz_generated.openapi.go index 66909a8fb14f6..8c441c55064ea 100644 --- a/pkg/apis/example/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/example/v0alpha1/zz_generated.openapi.go @@ -51,15 +51,14 @@ func schema_pkg_apis_example_v0alpha1_DummyResource(ref common.ReferenceCallback }, "spec": { SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"), }, }, }, }, }, Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, } } diff --git a/pkg/apis/example/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/example/v0alpha1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 0000000000000..75c16b7b44ee4 --- /dev/null +++ b/pkg/apis/example/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -0,0 +1 @@ +API rule violation: names_match,github.com/grafana/grafana/pkg/apis/example/v0alpha1,RuntimeInfo,IsEnterprise diff --git a/pkg/apis/featuretoggle/v0alpha1/doc.go b/pkg/apis/featuretoggle/v0alpha1/doc.go new file mode 100644 index 0000000000000..222ebfd88b528 --- /dev/null +++ b/pkg/apis/featuretoggle/v0alpha1/doc.go @@ -0,0 +1,6 @@ +// +k8s:deepcopy-gen=package +// +k8s:openapi-gen=true +// +k8s:defaulter-gen=TypeMeta +// +groupName=featuretoggle.grafana.app + +package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" diff --git a/pkg/apis/featuretoggle/v0alpha1/register.go b/pkg/apis/featuretoggle/v0alpha1/register.go new file mode 100644 index 0000000000000..b1e7d06238723 --- /dev/null +++ b/pkg/apis/featuretoggle/v0alpha1/register.go @@ -0,0 +1,33 @@ +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +const ( + GROUP = "featuretoggle.grafana.app" + VERSION = "v0alpha1" + APIVERSION = GROUP + "/" + VERSION +) + +// FeatureResourceInfo represents each feature that may have a toggle +var FeatureResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "features", "feature", "Feature", + func() runtime.Object { return &Feature{} }, + func() runtime.Object { return &FeatureList{} }, +) + +// TogglesResourceInfo represents the actual configuration +var TogglesResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "featuretoggles", "featuretoggle", "FeatureToggles", + func() runtime.Object { return &FeatureToggles{} }, + func() runtime.Object { return &FeatureTogglesList{} }, +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} +) diff --git a/pkg/apis/featuretoggle/v0alpha1/types.go b/pkg/apis/featuretoggle/v0alpha1/types.go new file mode 100644 index 0000000000000..223ffd6096e30 --- /dev/null +++ b/pkg/apis/featuretoggle/v0alpha1/types.go @@ -0,0 +1,117 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +// Feature represents a feature in development and information about that feature +// It does *not* know the status, only defines properties about the feature itself +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type Feature struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec FeatureSpec `json:"spec,omitempty"` +} + +type FeatureSpec struct { + // The feature description + Description string `json:"description"` + + // Indicates the features level of stability + Stage string `json:"stage"` + + // The team who owns this feature development + Owner string `json:"codeowner,omitempty"` + + // Enabled by default for version >= + EnabledVersion string `json:"enabledVersion,omitempty"` + + // Must be run using in development mode (early dev) + RequiresDevMode bool `json:"requiresDevMode,omitempty"` + + // The flab behavior only effects frontend -- it is not used in the backend + FrontendOnly bool `json:"frontend,omitempty"` + + // The flag is used at startup, so any change requires a restart + RequiresRestart bool `json:"requiresRestart,omitempty"` + + // Allow cloud users to set the values in UI + AllowSelfServe bool `json:"allowSelfServe,omitempty"` + + // Do not show the value in the UI + HideFromAdminPage bool `json:"hideFromAdminPage,omitempty"` + + // Do not show the value in docs + HideFromDocs bool `json:"hideFromDocs,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type FeatureList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []Feature `json:"items,omitempty"` +} + +// FeatureToggles define the feature state +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type FeatureToggles struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // The configured toggles. Note this may include unknown fields + Spec map[string]bool `json:"spec"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type FeatureTogglesList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []FeatureToggles `json:"items,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ResolvedToggleState struct { + metav1.TypeMeta `json:",inline"` + + // The user is allowed to edit feature toggles on this system + AllowEditing bool `json:"allowEditing,omitempty"` + + // The system has changes that require still require a restart + RestartRequired bool `json:"restartRequired,omitempty"` + + // The currently enabled flags + Enabled map[string]bool `json:"enabled,omitempty"` + + // Details on the current status + Toggles []ToggleStatus `json:"toggles,omitempty"` +} + +type ToggleStatus struct { + // The feature toggle name + Name string `json:"name"` + + // The flag description + Description string `json:"description,omitempty"` + + // The feature toggle stage + Stage string `json:"stage"` + + // Is the flag enabled + Enabled bool `json:"enabled"` + + // Can this flag be updated + Writeable bool `json:"writeable"` + + // Where was the value configured + // eg: startup | tenant|org | user | browser + // missing means default + Source *common.ObjectReference `json:"source,omitempty"` + + // eg: unknown flag + Warning string `json:"warning,omitempty"` +} diff --git a/pkg/apis/featuretoggle/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/featuretoggle/v0alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000000000..6b06fab2b6d16 --- /dev/null +++ b/pkg/apis/featuretoggle/v0alpha1/zz_generated.deepcopy.go @@ -0,0 +1,215 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + commonv0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Feature) DeepCopyInto(out *Feature) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Feature. +func (in *Feature) DeepCopy() *Feature { + if in == nil { + return nil + } + out := new(Feature) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Feature) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FeatureList) DeepCopyInto(out *FeatureList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Feature, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureList. +func (in *FeatureList) DeepCopy() *FeatureList { + if in == nil { + return nil + } + out := new(FeatureList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *FeatureList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FeatureSpec) DeepCopyInto(out *FeatureSpec) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureSpec. +func (in *FeatureSpec) DeepCopy() *FeatureSpec { + if in == nil { + return nil + } + out := new(FeatureSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FeatureToggles) DeepCopyInto(out *FeatureToggles) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if in.Spec != nil { + in, out := &in.Spec, &out.Spec + *out = make(map[string]bool, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureToggles. +func (in *FeatureToggles) DeepCopy() *FeatureToggles { + if in == nil { + return nil + } + out := new(FeatureToggles) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *FeatureToggles) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FeatureTogglesList) DeepCopyInto(out *FeatureTogglesList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]FeatureToggles, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureTogglesList. +func (in *FeatureTogglesList) DeepCopy() *FeatureTogglesList { + if in == nil { + return nil + } + out := new(FeatureTogglesList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *FeatureTogglesList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResolvedToggleState) DeepCopyInto(out *ResolvedToggleState) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.Enabled != nil { + in, out := &in.Enabled, &out.Enabled + *out = make(map[string]bool, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Toggles != nil { + in, out := &in.Toggles, &out.Toggles + *out = make([]ToggleStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResolvedToggleState. +func (in *ResolvedToggleState) DeepCopy() *ResolvedToggleState { + if in == nil { + return nil + } + out := new(ResolvedToggleState) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ResolvedToggleState) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ToggleStatus) DeepCopyInto(out *ToggleStatus) { + *out = *in + if in.Source != nil { + in, out := &in.Source, &out.Source + *out = new(commonv0alpha1.ObjectReference) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToggleStatus. +func (in *ToggleStatus) DeepCopy() *ToggleStatus { + if in == nil { + return nil + } + out := new(ToggleStatus) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/apis/featuretoggle/v0alpha1/zz_generated.defaults.go b/pkg/apis/featuretoggle/v0alpha1/zz_generated.defaults.go new file mode 100644 index 0000000000000..238fc2f4edcfe --- /dev/null +++ b/pkg/apis/featuretoggle/v0alpha1/zz_generated.defaults.go @@ -0,0 +1,19 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by defaulter-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// RegisterDefaults adds defaulters functions to the given scheme. +// Public to allow building arbitrary schemes. +// All generated defaulters are covering - they call all nested defaulters. +func RegisterDefaults(scheme *runtime.Scheme) error { + return nil +} diff --git a/pkg/apis/featuretoggle/v0alpha1/zz_generated.openapi.go b/pkg/apis/featuretoggle/v0alpha1/zz_generated.openapi.go new file mode 100644 index 0000000000000..a7817ad4248b4 --- /dev/null +++ b/pkg/apis/featuretoggle/v0alpha1/zz_generated.openapi.go @@ -0,0 +1,438 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by openapi-gen. DO NOT EDIT. + +// This file was autogenerated by openapi-gen. Do not edit it manually! + +package v0alpha1 + +import ( + common "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" +) + +func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + return map[string]common.OpenAPIDefinition{ + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.Feature": schema_pkg_apis_featuretoggle_v0alpha1_Feature(ref), + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.FeatureList": schema_pkg_apis_featuretoggle_v0alpha1_FeatureList(ref), + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.FeatureSpec": schema_pkg_apis_featuretoggle_v0alpha1_FeatureSpec(ref), + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.FeatureToggles": schema_pkg_apis_featuretoggle_v0alpha1_FeatureToggles(ref), + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.FeatureTogglesList": schema_pkg_apis_featuretoggle_v0alpha1_FeatureTogglesList(ref), + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.ResolvedToggleState": schema_pkg_apis_featuretoggle_v0alpha1_ResolvedToggleState(ref), + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.ToggleStatus": schema_pkg_apis_featuretoggle_v0alpha1_ToggleStatus(ref), + } +} + +func schema_pkg_apis_featuretoggle_v0alpha1_Feature(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Feature represents a feature in development and information about that feature It does *not* know the status, only defines properties about the feature itself", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.FeatureSpec"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.FeatureSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_featuretoggle_v0alpha1_FeatureList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.Feature"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.Feature", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_featuretoggle_v0alpha1_FeatureSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "description": { + SchemaProps: spec.SchemaProps{ + Description: "The feature description", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "stage": { + SchemaProps: spec.SchemaProps{ + Description: "Indicates the features level of stability", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "codeowner": { + SchemaProps: spec.SchemaProps{ + Description: "The team who owns this feature development", + Type: []string{"string"}, + Format: "", + }, + }, + "enabledVersion": { + SchemaProps: spec.SchemaProps{ + Description: "Enabled by default for version >=", + Type: []string{"string"}, + Format: "", + }, + }, + "requiresDevMode": { + SchemaProps: spec.SchemaProps{ + Description: "Must be run using in development mode (early dev)", + Type: []string{"boolean"}, + Format: "", + }, + }, + "frontend": { + SchemaProps: spec.SchemaProps{ + Description: "The flab behavior only effects frontend -- it is not used in the backend", + Type: []string{"boolean"}, + Format: "", + }, + }, + "requiresRestart": { + SchemaProps: spec.SchemaProps{ + Description: "The flag is used at startup, so any change requires a restart", + Type: []string{"boolean"}, + Format: "", + }, + }, + "allowSelfServe": { + SchemaProps: spec.SchemaProps{ + Description: "Allow cloud users to set the values in UI", + Type: []string{"boolean"}, + Format: "", + }, + }, + "hideFromAdminPage": { + SchemaProps: spec.SchemaProps{ + Description: "Do not show the value in the UI", + Type: []string{"boolean"}, + Format: "", + }, + }, + "hideFromDocs": { + SchemaProps: spec.SchemaProps{ + Description: "Do not show the value in docs", + Type: []string{"boolean"}, + Format: "", + }, + }, + }, + Required: []string{"description", "stage"}, + }, + }, + } +} + +func schema_pkg_apis_featuretoggle_v0alpha1_FeatureToggles(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "FeatureToggles define the feature state", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Description: "The configured toggles. Note this may include unknown fields", + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Allows: true, + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + }, + }, + }, + }, + Required: []string{"spec"}, + }, + }, + Dependencies: []string{ + "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_featuretoggle_v0alpha1_FeatureTogglesList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.FeatureToggles"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.FeatureToggles", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_featuretoggle_v0alpha1_ResolvedToggleState(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "allowEditing": { + SchemaProps: spec.SchemaProps{ + Description: "The user is allowed to edit feature toggles on this system", + Type: []string{"boolean"}, + Format: "", + }, + }, + "restartRequired": { + SchemaProps: spec.SchemaProps{ + Description: "The system has changes that require still require a restart", + Type: []string{"boolean"}, + Format: "", + }, + }, + "enabled": { + SchemaProps: spec.SchemaProps{ + Description: "The currently enabled flags", + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Allows: true, + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + }, + }, + }, + "toggles": { + SchemaProps: spec.SchemaProps{ + Description: "Details on the current status", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.ToggleStatus"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.ToggleStatus"}, + } +} + +func schema_pkg_apis_featuretoggle_v0alpha1_ToggleStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Description: "The feature toggle name", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "description": { + SchemaProps: spec.SchemaProps{ + Description: "The flag description", + Type: []string{"string"}, + Format: "", + }, + }, + "stage": { + SchemaProps: spec.SchemaProps{ + Description: "The feature toggle stage", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "enabled": { + SchemaProps: spec.SchemaProps{ + Description: "Is the flag enabled", + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "writeable": { + SchemaProps: spec.SchemaProps{ + Description: "Can this flag be updated", + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "source": { + SchemaProps: spec.SchemaProps{ + Description: "Where was the value configured eg: startup | tenant|org | user | browser missing means default", + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.ObjectReference"), + }, + }, + "warning": { + SchemaProps: spec.SchemaProps{ + Description: "eg: unknown flag", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"name", "stage", "enabled", "writeable"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.ObjectReference"}, + } +} diff --git a/pkg/apis/featuretoggle/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/featuretoggle/v0alpha1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 0000000000000..2a3961b3eee73 --- /dev/null +++ b/pkg/apis/featuretoggle/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -0,0 +1,3 @@ +API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1,ResolvedToggleState,Toggles +API rule violation: names_match,github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1,FeatureSpec,FrontendOnly +API rule violation: names_match,github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1,FeatureSpec,Owner diff --git a/pkg/apis/folder/v0alpha1/doc.go b/pkg/apis/folder/v0alpha1/doc.go new file mode 100644 index 0000000000000..fc85f689f0ea9 --- /dev/null +++ b/pkg/apis/folder/v0alpha1/doc.go @@ -0,0 +1,6 @@ +// +k8s:deepcopy-gen=package +// +k8s:openapi-gen=true +// +k8s:defaulter-gen=TypeMeta +// +groupName=folder.grafana.app + +package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" diff --git a/pkg/apis/folder/v0alpha1/register.go b/pkg/apis/folder/v0alpha1/register.go new file mode 100644 index 0000000000000..010054ad15c44 --- /dev/null +++ b/pkg/apis/folder/v0alpha1/register.go @@ -0,0 +1,26 @@ +package v0alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +const ( + GROUP = "folder.grafana.app" + VERSION = "v0alpha1" + RESOURCE = "folders" + APIVERSION = GROUP + "/" + VERSION +) + +var FolderResourceInfo = common.NewResourceInfo(GROUP, VERSION, + RESOURCE, "folder", "Folder", + func() runtime.Object { return &Folder{} }, + func() runtime.Object { return &FolderList{} }, +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} +) diff --git a/pkg/apis/folder/v0alpha1/types.go b/pkg/apis/folder/v0alpha1/types.go new file mode 100644 index 0000000000000..05a29fc09e879 --- /dev/null +++ b/pkg/apis/folder/v0alpha1/types.go @@ -0,0 +1,72 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type Folder struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec Spec `json:"spec,omitempty"` +} + +type Spec struct { + // Describe the feature toggle + Title string `json:"title"` + + // Describe the feature toggle + Description string `json:"description,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type FolderList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []Folder `json:"items,omitempty"` +} + +// FolderInfoList returns a list of folder references (parents or children) +// Unlike FolderList, each item is not a full k8s object +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type FolderInfoList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + // +listType=map + // +listMapKey=uid + Items []FolderInfo `json:"items,omitempty"` +} + +// FolderInfo briefly describes a folder -- unlike a folder resource, +// this is a partial record of the folder metadata used for navigating parents and children +type FolderInfo struct { + // UID is the unique identifier for a folder (and the k8s name) + UID string `json:"uid"` + + // Title is the display value + Title string `json:"title"` + + // The parent folder UID + Parent string `json:"parent,omitempty"` +} + +// Access control information for the current user +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type FolderAccessInfo struct { + metav1.TypeMeta `json:",inline"` + + CanSave bool `json:"canSave"` + CanEdit bool `json:"canEdit"` + CanAdmin bool `json:"canAdmin"` + CanDelete bool `json:"canDelete"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DescendantCounts struct { + metav1.TypeMeta `json:",inline"` + + Counts map[string]int64 `json:"counts"` +} diff --git a/pkg/apis/folders/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/folder/v0alpha1/zz_generated.deepcopy.go similarity index 62% rename from pkg/apis/folders/v0alpha1/zz_generated.deepcopy.go rename to pkg/apis/folder/v0alpha1/zz_generated.deepcopy.go index d6bab889399e7..2ddf035b53334 100644 --- a/pkg/apis/folders/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/folder/v0alpha1/zz_generated.deepcopy.go @@ -11,6 +11,38 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DescendantCounts) DeepCopyInto(out *DescendantCounts) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.Counts != nil { + in, out := &in.Counts, &out.Counts + *out = make(map[string]int64, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DescendantCounts. +func (in *DescendantCounts) DeepCopy() *DescendantCounts { + if in == nil { + return nil + } + out := new(DescendantCounts) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DescendantCounts) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Folder) DeepCopyInto(out *Folder) { *out = *in @@ -39,29 +71,24 @@ func (in *Folder) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *FolderInfo) DeepCopyInto(out *FolderInfo) { +func (in *FolderAccessInfo) DeepCopyInto(out *FolderAccessInfo) { *out = *in out.TypeMeta = in.TypeMeta - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]FolderItem, len(*in)) - copy(*out, *in) - } return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FolderInfo. -func (in *FolderInfo) DeepCopy() *FolderInfo { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FolderAccessInfo. +func (in *FolderAccessInfo) DeepCopy() *FolderAccessInfo { if in == nil { return nil } - out := new(FolderInfo) + out := new(FolderAccessInfo) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *FolderInfo) DeepCopyObject() runtime.Object { +func (in *FolderAccessInfo) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -69,21 +96,52 @@ func (in *FolderInfo) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *FolderItem) DeepCopyInto(out *FolderItem) { +func (in *FolderInfo) DeepCopyInto(out *FolderInfo) { *out = *in return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FolderItem. -func (in *FolderItem) DeepCopy() *FolderItem { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FolderInfo. +func (in *FolderInfo) DeepCopy() *FolderInfo { if in == nil { return nil } - out := new(FolderItem) + out := new(FolderInfo) in.DeepCopyInto(out) return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FolderInfoList) DeepCopyInto(out *FolderInfoList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]FolderInfo, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FolderInfoList. +func (in *FolderInfoList) DeepCopy() *FolderInfoList { + if in == nil { + return nil + } + out := new(FolderInfoList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *FolderInfoList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FolderList) DeepCopyInto(out *FolderList) { *out = *in diff --git a/pkg/apis/folder/v0alpha1/zz_generated.defaults.go b/pkg/apis/folder/v0alpha1/zz_generated.defaults.go new file mode 100644 index 0000000000000..238fc2f4edcfe --- /dev/null +++ b/pkg/apis/folder/v0alpha1/zz_generated.defaults.go @@ -0,0 +1,19 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by defaulter-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// RegisterDefaults adds defaulters functions to the given scheme. +// Public to allow building arbitrary schemes. +// All generated defaulters are covering - they call all nested defaulters. +func RegisterDefaults(scheme *runtime.Scheme) error { + return nil +} diff --git a/pkg/apis/folder/v0alpha1/zz_generated.openapi.go b/pkg/apis/folder/v0alpha1/zz_generated.openapi.go new file mode 100644 index 0000000000000..71d03fb914dfe --- /dev/null +++ b/pkg/apis/folder/v0alpha1/zz_generated.openapi.go @@ -0,0 +1,333 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by openapi-gen. DO NOT EDIT. + +// This file was autogenerated by openapi-gen. Do not edit it manually! + +package v0alpha1 + +import ( + common "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" +) + +func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + return map[string]common.OpenAPIDefinition{ + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.DescendantCounts": schema_pkg_apis_folder_v0alpha1_DescendantCounts(ref), + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Folder": schema_pkg_apis_folder_v0alpha1_Folder(ref), + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderAccessInfo": schema_pkg_apis_folder_v0alpha1_FolderAccessInfo(ref), + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderInfo": schema_pkg_apis_folder_v0alpha1_FolderInfo(ref), + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderInfoList": schema_pkg_apis_folder_v0alpha1_FolderInfoList(ref), + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderList": schema_pkg_apis_folder_v0alpha1_FolderList(ref), + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Spec": schema_pkg_apis_folder_v0alpha1_Spec(ref), + } +} + +func schema_pkg_apis_folder_v0alpha1_DescendantCounts(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "counts": { + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Allows: true, + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + }, + }, + }, + }, + Required: []string{"counts"}, + }, + }, + } +} + +func schema_pkg_apis_folder_v0alpha1_Folder(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Spec"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Spec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_folder_v0alpha1_FolderAccessInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Access control information for the current user", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "canSave": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "canEdit": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "canAdmin": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "canDelete": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + }, + Required: []string{"canSave", "canEdit", "canAdmin", "canDelete"}, + }, + }, + } +} + +func schema_pkg_apis_folder_v0alpha1_FolderInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "FolderInfo briefly describes a folder -- unlike a folder resource, this is a partial record of the folder metadata used for navigating parents and children", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "uid": { + SchemaProps: spec.SchemaProps{ + Description: "UID is the unique identifier for a folder (and the k8s name)", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "title": { + SchemaProps: spec.SchemaProps{ + Description: "Title is the display value", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "parent": { + SchemaProps: spec.SchemaProps{ + Description: "The parent folder UID", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"uid", "title"}, + }, + }, + } +} + +func schema_pkg_apis_folder_v0alpha1_FolderInfoList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "FolderInfoList returns a list of folder references (parents or children) Unlike FolderList, each item is not a full k8s object", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-map-keys": []interface{}{ + "uid", + }, + "x-kubernetes-list-type": "map", + }, + }, + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderInfo"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_folder_v0alpha1_FolderList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Folder"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Folder", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_folder_v0alpha1_Spec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "title": { + SchemaProps: spec.SchemaProps{ + Description: "Describe the feature toggle", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "description": { + SchemaProps: spec.SchemaProps{ + Description: "Describe the feature toggle", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"title"}, + }, + }, + } +} diff --git a/pkg/apis/folder/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/folder/v0alpha1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 0000000000000..aa6d71f3ca967 --- /dev/null +++ b/pkg/apis/folder/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -0,0 +1 @@ +API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/folder/v0alpha1,FolderInfoList,Items diff --git a/pkg/apis/folders/v0alpha1/types.go b/pkg/apis/folders/v0alpha1/types.go deleted file mode 100644 index f51b03bd64a16..0000000000000 --- a/pkg/apis/folders/v0alpha1/types.go +++ /dev/null @@ -1,60 +0,0 @@ -package v0alpha1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - runtime "k8s.io/apimachinery/pkg/runtime" - - "github.com/grafana/grafana/pkg/apis" -) - -const ( - GROUP = "folders.grafana.app" - VERSION = "v0alpha1" - RESOURCE = "folders" - APIVERSION = GROUP + "/" + VERSION -) - -var FolderResourceInfo = apis.NewResourceInfo(GROUP, VERSION, - RESOURCE, "folder", "Folder", - func() runtime.Object { return &Folder{} }, - func() runtime.Object { return &FolderList{} }, -) - -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object -type Folder struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - // TODO, structure so the name is not in spec - Spec Spec `json:"spec,omitempty"` -} - -type Spec struct { - // Describe the feature toggle - Title string `json:"title"` - - // Describe the feature toggle - Description string `json:"description,omitempty"` -} - -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object -type FolderList struct { - metav1.TypeMeta `json:",inline"` - // +optional - metav1.ListMeta `json:"metadata,omitempty"` - - Items []Folder `json:"items,omitempty"` -} - -// FolderInfo returns a list of folder indentifiers (parents or children) -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object -type FolderInfo struct { - metav1.TypeMeta `json:",inline"` - - Items []FolderItem `json:"items"` -} - -type FolderItem struct { - Name string `json:"name"` - Title string `json:"title"` -} diff --git a/pkg/apis/folders/v0alpha1/zz_generated.openapi.go b/pkg/apis/folders/v0alpha1/zz_generated.openapi.go deleted file mode 100644 index b2b3dcb62882c..0000000000000 --- a/pkg/apis/folders/v0alpha1/zz_generated.openapi.go +++ /dev/null @@ -1,2687 +0,0 @@ -//go:build !ignore_autogenerated -// +build !ignore_autogenerated - -// SPDX-License-Identifier: AGPL-3.0-only - -// Code generated by openapi-gen. DO NOT EDIT. - -// This file was autogenerated by openapi-gen. Do not edit it manually! - -package v0alpha1 - -import ( - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - common "k8s.io/kube-openapi/pkg/common" - spec "k8s.io/kube-openapi/pkg/validation/spec" -) - -func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { - return map[string]common.OpenAPIDefinition{ - "github.com/grafana/grafana/pkg/apis/folders/v0alpha1.Folder": schema_pkg_apis_folders_v0alpha1_Folder(ref), - "github.com/grafana/grafana/pkg/apis/folders/v0alpha1.FolderInfo": schema_pkg_apis_folders_v0alpha1_FolderInfo(ref), - "github.com/grafana/grafana/pkg/apis/folders/v0alpha1.FolderItem": schema_pkg_apis_folders_v0alpha1_FolderItem(ref), - "github.com/grafana/grafana/pkg/apis/folders/v0alpha1.FolderList": schema_pkg_apis_folders_v0alpha1_FolderList(ref), - "github.com/grafana/grafana/pkg/apis/folders/v0alpha1.Spec": schema_pkg_apis_folders_v0alpha1_Spec(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.APIGroup": schema_pkg_apis_meta_v1_APIGroup(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.APIGroupList": schema_pkg_apis_meta_v1_APIGroupList(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.APIResource": schema_pkg_apis_meta_v1_APIResource(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.APIResourceList": schema_pkg_apis_meta_v1_APIResourceList(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.APIVersions": schema_pkg_apis_meta_v1_APIVersions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ApplyOptions": schema_pkg_apis_meta_v1_ApplyOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Condition": schema_pkg_apis_meta_v1_Condition(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.CreateOptions": schema_pkg_apis_meta_v1_CreateOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.DeleteOptions": schema_pkg_apis_meta_v1_DeleteOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Duration": schema_pkg_apis_meta_v1_Duration(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.FieldsV1": schema_pkg_apis_meta_v1_FieldsV1(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GetOptions": schema_pkg_apis_meta_v1_GetOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupKind": schema_pkg_apis_meta_v1_GroupKind(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupResource": schema_pkg_apis_meta_v1_GroupResource(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersion": schema_pkg_apis_meta_v1_GroupVersion(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionForDiscovery": schema_pkg_apis_meta_v1_GroupVersionForDiscovery(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionKind": schema_pkg_apis_meta_v1_GroupVersionKind(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionResource": schema_pkg_apis_meta_v1_GroupVersionResource(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.InternalEvent": schema_pkg_apis_meta_v1_InternalEvent(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelector": schema_pkg_apis_meta_v1_LabelSelector(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelectorRequirement": schema_pkg_apis_meta_v1_LabelSelectorRequirement(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.List": schema_pkg_apis_meta_v1_List(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta": schema_pkg_apis_meta_v1_ListMeta(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ListOptions": schema_pkg_apis_meta_v1_ListOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ManagedFieldsEntry": schema_pkg_apis_meta_v1_ManagedFieldsEntry(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.MicroTime": schema_pkg_apis_meta_v1_MicroTime(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta": schema_pkg_apis_meta_v1_ObjectMeta(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.OwnerReference": schema_pkg_apis_meta_v1_OwnerReference(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.PartialObjectMetadata": schema_pkg_apis_meta_v1_PartialObjectMetadata(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.PartialObjectMetadataList": schema_pkg_apis_meta_v1_PartialObjectMetadataList(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Patch": schema_pkg_apis_meta_v1_Patch(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.PatchOptions": schema_pkg_apis_meta_v1_PatchOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Preconditions": schema_pkg_apis_meta_v1_Preconditions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.RootPaths": schema_pkg_apis_meta_v1_RootPaths(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ServerAddressByClientCIDR": schema_pkg_apis_meta_v1_ServerAddressByClientCIDR(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Status": schema_pkg_apis_meta_v1_Status(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.StatusCause": schema_pkg_apis_meta_v1_StatusCause(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.StatusDetails": schema_pkg_apis_meta_v1_StatusDetails(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Table": schema_pkg_apis_meta_v1_Table(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.TableColumnDefinition": schema_pkg_apis_meta_v1_TableColumnDefinition(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.TableOptions": schema_pkg_apis_meta_v1_TableOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.TableRow": schema_pkg_apis_meta_v1_TableRow(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.TableRowCondition": schema_pkg_apis_meta_v1_TableRowCondition(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Time": schema_pkg_apis_meta_v1_Time(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Timestamp": schema_pkg_apis_meta_v1_Timestamp(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.TypeMeta": schema_pkg_apis_meta_v1_TypeMeta(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.UpdateOptions": schema_pkg_apis_meta_v1_UpdateOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.WatchEvent": schema_pkg_apis_meta_v1_WatchEvent(ref), - "k8s.io/apimachinery/pkg/runtime.RawExtension": schema_k8sio_apimachinery_pkg_runtime_RawExtension(ref), - "k8s.io/apimachinery/pkg/runtime.TypeMeta": schema_k8sio_apimachinery_pkg_runtime_TypeMeta(ref), - "k8s.io/apimachinery/pkg/runtime.Unknown": schema_k8sio_apimachinery_pkg_runtime_Unknown(ref), - "k8s.io/apimachinery/pkg/version.Info": schema_k8sio_apimachinery_pkg_version_Info(ref), - } -} - -func schema_pkg_apis_folders_v0alpha1_Folder(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), - }, - }, - "spec": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/pkg/apis/folders/v0alpha1.Spec"), - }, - }, - }, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/pkg/apis/folders/v0alpha1.Spec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, - } -} - -func schema_pkg_apis_folders_v0alpha1_FolderInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "FolderInfo returns a list of folder indentifiers (parents or children)", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "items": { - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/pkg/apis/folders/v0alpha1.FolderItem"), - }, - }, - }, - }, - }, - }, - Required: []string{"items"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/pkg/apis/folders/v0alpha1.FolderItem"}, - } -} - -func schema_pkg_apis_folders_v0alpha1_FolderItem(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "name": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "title": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"name", "title"}, - }, - }, - } -} - -func schema_pkg_apis_folders_v0alpha1_FolderList(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), - }, - }, - "items": { - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/pkg/apis/folders/v0alpha1.Folder"), - }, - }, - }, - }, - }, - }, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/pkg/apis/folders/v0alpha1.Folder", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, - } -} - -func schema_pkg_apis_folders_v0alpha1_Spec(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "title": { - SchemaProps: spec.SchemaProps{ - Description: "Describe the feature toggle", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "description": { - SchemaProps: spec.SchemaProps{ - Description: "Describe the feature toggle", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"title"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_APIGroup(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "APIGroup contains the name, the supported versions, and the preferred version of a group.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "name": { - SchemaProps: spec.SchemaProps{ - Description: "name is the name of the group.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "versions": { - SchemaProps: spec.SchemaProps{ - Description: "versions are the versions supported in this group.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionForDiscovery"), - }, - }, - }, - }, - }, - "preferredVersion": { - SchemaProps: spec.SchemaProps{ - Description: "preferredVersion is the version preferred by the API server, which probably is the storage version.", - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionForDiscovery"), - }, - }, - "serverAddressByClientCIDRs": { - SchemaProps: spec.SchemaProps{ - Description: "a map of client CIDR to server address that is serving this group. This is to help clients reach servers in the most network-efficient way possible. Clients can use the appropriate server address as per the CIDR that they match. In case of multiple matches, clients should use the longest matching CIDR. The server returns only those CIDRs that it thinks that the client can match. For example: the master will return an internal IP CIDR only, if the client reaches the server using an internal IP. Server looks at X-Forwarded-For header or X-Real-Ip header or request.RemoteAddr (in that order) to get the client IP.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ServerAddressByClientCIDR"), - }, - }, - }, - }, - }, - }, - Required: []string{"name", "versions"}, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionForDiscovery", "k8s.io/apimachinery/pkg/apis/meta/v1.ServerAddressByClientCIDR"}, - } -} - -func schema_pkg_apis_meta_v1_APIGroupList(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "APIGroupList is a list of APIGroup, to allow clients to discover the API at /apis.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "groups": { - SchemaProps: spec.SchemaProps{ - Description: "groups is a list of APIGroup.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.APIGroup"), - }, - }, - }, - }, - }, - }, - Required: []string{"groups"}, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.APIGroup"}, - } -} - -func schema_pkg_apis_meta_v1_APIResource(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "APIResource specifies the name of a resource and whether it is namespaced.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "name": { - SchemaProps: spec.SchemaProps{ - Description: "name is the plural name of the resource.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "singularName": { - SchemaProps: spec.SchemaProps{ - Description: "singularName is the singular name of the resource. This allows clients to handle plural and singular opaquely. The singularName is more correct for reporting status on a single item and both singular and plural are allowed from the kubectl CLI interface.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "namespaced": { - SchemaProps: spec.SchemaProps{ - Description: "namespaced indicates if a resource is namespaced or not.", - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "group": { - SchemaProps: spec.SchemaProps{ - Description: "group is the preferred group of the resource. Empty implies the group of the containing resource list. For subresources, this may have a different value, for example: Scale\".", - Type: []string{"string"}, - Format: "", - }, - }, - "version": { - SchemaProps: spec.SchemaProps{ - Description: "version is the preferred version of the resource. Empty implies the version of the containing resource list For subresources, this may have a different value, for example: v1 (while inside a v1beta1 version of the core resource's group)\".", - Type: []string{"string"}, - Format: "", - }, - }, - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "kind is the kind for the resource (e.g. 'Foo' is the kind for a resource 'foo')", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "verbs": { - SchemaProps: spec.SchemaProps{ - Description: "verbs is a list of supported kube verbs (this includes get, list, watch, create, update, patch, delete, deletecollection, and proxy)", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - "shortNames": { - SchemaProps: spec.SchemaProps{ - Description: "shortNames is a list of suggested short names of the resource.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - "categories": { - SchemaProps: spec.SchemaProps{ - Description: "categories is a list of the grouped resources this resource belongs to (e.g. 'all')", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - "storageVersionHash": { - SchemaProps: spec.SchemaProps{ - Description: "The hash value of the storage version, the version this resource is converted to when written to the data store. Value must be treated as opaque by clients. Only equality comparison on the value is valid. This is an alpha feature and may change or be removed in the future. The field is populated by the apiserver only if the StorageVersionHash feature gate is enabled. This field will remain optional even if it graduates.", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"name", "singularName", "namespaced", "kind", "verbs"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_APIResourceList(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "APIResourceList is a list of APIResource, it is used to expose the name of the resources supported in a specific group and version, and if the resource is namespaced.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "groupVersion": { - SchemaProps: spec.SchemaProps{ - Description: "groupVersion is the group and version this APIResourceList is for.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "resources": { - SchemaProps: spec.SchemaProps{ - Description: "resources contains the name of the resources and if they are namespaced.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.APIResource"), - }, - }, - }, - }, - }, - }, - Required: []string{"groupVersion", "resources"}, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.APIResource"}, - } -} - -func schema_pkg_apis_meta_v1_APIVersions(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "APIVersions lists the versions that are available, to allow clients to discover the API at /api, which is the root path of the legacy v1 API.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "versions": { - SchemaProps: spec.SchemaProps{ - Description: "versions are the api versions that are available.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - "serverAddressByClientCIDRs": { - SchemaProps: spec.SchemaProps{ - Description: "a map of client CIDR to server address that is serving this group. This is to help clients reach servers in the most network-efficient way possible. Clients can use the appropriate server address as per the CIDR that they match. In case of multiple matches, clients should use the longest matching CIDR. The server returns only those CIDRs that it thinks that the client can match. For example: the master will return an internal IP CIDR only, if the client reaches the server using an internal IP. Server looks at X-Forwarded-For header or X-Real-Ip header or request.RemoteAddr (in that order) to get the client IP.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ServerAddressByClientCIDR"), - }, - }, - }, - }, - }, - }, - Required: []string{"versions", "serverAddressByClientCIDRs"}, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.ServerAddressByClientCIDR"}, - } -} - -func schema_pkg_apis_meta_v1_ApplyOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "ApplyOptions may be provided when applying an API object. FieldManager is required for apply requests. ApplyOptions is equivalent to PatchOptions. It is provided as a convenience with documentation that speaks specifically to how the options fields relate to apply.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "dryRun": { - SchemaProps: spec.SchemaProps{ - Description: "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - "force": { - SchemaProps: spec.SchemaProps{ - Description: "Force is going to \"force\" Apply requests. It means user will re-acquire conflicting fields owned by other people.", - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "fieldManager": { - SchemaProps: spec.SchemaProps{ - Description: "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"force", "fieldManager"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_Condition(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Condition contains details for one aspect of the current state of this API Resource.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "type": { - SchemaProps: spec.SchemaProps{ - Description: "type of condition in CamelCase or in foo.example.com/CamelCase.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "status": { - SchemaProps: spec.SchemaProps{ - Description: "status of the condition, one of True, False, Unknown.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "observedGeneration": { - SchemaProps: spec.SchemaProps{ - Description: "observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.", - Type: []string{"integer"}, - Format: "int64", - }, - }, - "lastTransitionTime": { - SchemaProps: spec.SchemaProps{ - Description: "lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.", - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Time"), - }, - }, - "reason": { - SchemaProps: spec.SchemaProps{ - Description: "reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "message": { - SchemaProps: spec.SchemaProps{ - Description: "message is a human readable message indicating details about the transition. This may be an empty string.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"type", "status", "lastTransitionTime", "reason", "message"}, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.Time"}, - } -} - -func schema_pkg_apis_meta_v1_CreateOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "CreateOptions may be provided when creating an API object.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "dryRun": { - SchemaProps: spec.SchemaProps{ - Description: "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - "fieldManager": { - SchemaProps: spec.SchemaProps{ - Description: "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", - Type: []string{"string"}, - Format: "", - }, - }, - "fieldValidation": { - SchemaProps: spec.SchemaProps{ - Description: "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_DeleteOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "DeleteOptions may be provided when deleting an API object.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "gracePeriodSeconds": { - SchemaProps: spec.SchemaProps{ - Description: "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", - Type: []string{"integer"}, - Format: "int64", - }, - }, - "preconditions": { - SchemaProps: spec.SchemaProps{ - Description: "Must be fulfilled before a deletion is carried out. If not possible, a 409 Conflict status will be returned.", - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Preconditions"), - }, - }, - "orphanDependents": { - SchemaProps: spec.SchemaProps{ - Description: "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", - Type: []string{"boolean"}, - Format: "", - }, - }, - "propagationPolicy": { - SchemaProps: spec.SchemaProps{ - Description: "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", - Type: []string{"string"}, - Format: "", - }, - }, - "dryRun": { - SchemaProps: spec.SchemaProps{ - Description: "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - }, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.Preconditions"}, - } -} - -func schema_pkg_apis_meta_v1_Duration(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Duration is a wrapper around time.Duration which supports correct marshaling to YAML and JSON. In particular, it marshals into strings, which can be used as map keys in json.", - Type: v1.Duration{}.OpenAPISchemaType(), - Format: v1.Duration{}.OpenAPISchemaFormat(), - }, - }, - } -} - -func schema_pkg_apis_meta_v1_FieldsV1(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "FieldsV1 stores a set of fields in a data structure like a Trie, in JSON format.\n\nEach key is either a '.' representing the field itself, and will always map to an empty set, or a string representing a sub-field or item. The string will follow one of these four formats: 'f:<name>', where <name> is the name of a field in a struct, or key in a map 'v:<value>', where <value> is the exact json formatted value of a list item 'i:<index>', where <index> is position of a item in a list 'k:<keys>', where <keys> is a map of a list item's key fields to their unique values If a key maps to an empty Fields value, the field that key represents is part of the set.\n\nThe exact format is defined in sigs.k8s.io/structured-merge-diff", - Type: []string{"object"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_GetOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "GetOptions is the standard query options to the standard REST get call.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "resourceVersion": { - SchemaProps: spec.SchemaProps{ - Description: "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_GroupKind(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "GroupKind specifies a Group and a Kind, but does not force a version. This is useful for identifying concepts during lookup stages without having partially valid types", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "group": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "kind": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"group", "kind"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_GroupResource(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "GroupResource specifies a Group and a Resource, but does not force a version. This is useful for identifying concepts during lookup stages without having partially valid types", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "group": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "resource": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"group", "resource"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_GroupVersion(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "GroupVersion contains the \"group\" and the \"version\", which uniquely identifies the API.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "group": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "version": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"group", "version"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_GroupVersionForDiscovery(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "GroupVersion contains the \"group/version\" and \"version\" string of a version. It is made a struct to keep extensibility.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "groupVersion": { - SchemaProps: spec.SchemaProps{ - Description: "groupVersion specifies the API group and version in the form \"group/version\"", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "version": { - SchemaProps: spec.SchemaProps{ - Description: "version specifies the version in the form of \"version\". This is to save the clients the trouble of splitting the GroupVersion.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"groupVersion", "version"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_GroupVersionKind(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "GroupVersionKind unambiguously identifies a kind. It doesn't anonymously include GroupVersion to avoid automatic coercion. It doesn't use a GroupVersion to avoid custom marshalling", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "group": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "version": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "kind": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"group", "version", "kind"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_GroupVersionResource(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "GroupVersionResource unambiguously identifies a resource. It doesn't anonymously include GroupVersion to avoid automatic coercion. It doesn't use a GroupVersion to avoid custom marshalling", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "group": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "version": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "resource": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"group", "version", "resource"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_InternalEvent(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "InternalEvent makes watch.Event versioned", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "Type": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "Object": { - SchemaProps: spec.SchemaProps{ - Description: "Object is:\n * If Type is Added or Modified: the new state of the object.\n * If Type is Deleted: the state of the object immediately before deletion.\n * If Type is Bookmark: the object (instance of a type being watched) where\n only ResourceVersion field is set. On successful restart of watch from a\n bookmark resourceVersion, client is guaranteed to not get repeat event\n nor miss any events.\n * If Type is Error: *api.Status is recommended; other types may make sense\n depending on context.", - Ref: ref("k8s.io/apimachinery/pkg/runtime.Object"), - }, - }, - }, - Required: []string{"Type", "Object"}, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/runtime.Object"}, - } -} - -func schema_pkg_apis_meta_v1_LabelSelector(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "A label selector is a label query over a set of resources. The result of matchLabels and matchExpressions are ANDed. An empty label selector matches all objects. A null label selector matches no objects.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "matchLabels": { - SchemaProps: spec.SchemaProps{ - Description: "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is \"key\", the operator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - "matchExpressions": { - SchemaProps: spec.SchemaProps{ - Description: "matchExpressions is a list of label selector requirements. The requirements are ANDed.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelectorRequirement"), - }, - }, - }, - }, - }, - }, - }, - VendorExtensible: spec.VendorExtensible{ - Extensions: spec.Extensions{ - "x-kubernetes-map-type": "atomic", - }, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelectorRequirement"}, - } -} - -func schema_pkg_apis_meta_v1_LabelSelectorRequirement(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "key": { - SchemaProps: spec.SchemaProps{ - Description: "key is the label key that the selector applies to.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "operator": { - SchemaProps: spec.SchemaProps{ - Description: "operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "values": { - SchemaProps: spec.SchemaProps{ - Description: "values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - }, - Required: []string{"key", "operator"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_List(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "List holds a list of objects, which may not be known by the server.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Description: "Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), - }, - }, - "items": { - SchemaProps: spec.SchemaProps{ - Description: "List of objects", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/runtime.RawExtension"), - }, - }, - }, - }, - }, - }, - Required: []string{"items"}, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta", "k8s.io/apimachinery/pkg/runtime.RawExtension"}, - } -} - -func schema_pkg_apis_meta_v1_ListMeta(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "ListMeta describes metadata that synthetic resources must have, including lists and various status objects. A resource may have only one of {ObjectMeta, ListMeta}.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "selfLink": { - SchemaProps: spec.SchemaProps{ - Description: "Deprecated: selfLink is a legacy read-only field that is no longer populated by the system.", - Type: []string{"string"}, - Format: "", - }, - }, - "resourceVersion": { - SchemaProps: spec.SchemaProps{ - Description: "String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency", - Type: []string{"string"}, - Format: "", - }, - }, - "continue": { - SchemaProps: spec.SchemaProps{ - Description: "continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message.", - Type: []string{"string"}, - Format: "", - }, - }, - "remainingItemCount": { - SchemaProps: spec.SchemaProps{ - Description: "remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact.", - Type: []string{"integer"}, - Format: "int64", - }, - }, - }, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_ListOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "ListOptions is the query options to a standard REST list call.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "labelSelector": { - SchemaProps: spec.SchemaProps{ - Description: "A selector to restrict the list of returned objects by their labels. Defaults to everything.", - Type: []string{"string"}, - Format: "", - }, - }, - "fieldSelector": { - SchemaProps: spec.SchemaProps{ - Description: "A selector to restrict the list of returned objects by their fields. Defaults to everything.", - Type: []string{"string"}, - Format: "", - }, - }, - "watch": { - SchemaProps: spec.SchemaProps{ - Description: "Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion.", - Type: []string{"boolean"}, - Format: "", - }, - }, - "allowWatchBookmarks": { - SchemaProps: spec.SchemaProps{ - Description: "allowWatchBookmarks requests watch events with type \"BOOKMARK\". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored.", - Type: []string{"boolean"}, - Format: "", - }, - }, - "resourceVersion": { - SchemaProps: spec.SchemaProps{ - Description: "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - Type: []string{"string"}, - Format: "", - }, - }, - "resourceVersionMatch": { - SchemaProps: spec.SchemaProps{ - Description: "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - Type: []string{"string"}, - Format: "", - }, - }, - "timeoutSeconds": { - SchemaProps: spec.SchemaProps{ - Description: "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", - Type: []string{"integer"}, - Format: "int64", - }, - }, - "limit": { - SchemaProps: spec.SchemaProps{ - Description: "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", - Type: []string{"integer"}, - Format: "int64", - }, - }, - "continue": { - SchemaProps: spec.SchemaProps{ - Description: "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", - Type: []string{"string"}, - Format: "", - }, - }, - "sendInitialEvents": { - SchemaProps: spec.SchemaProps{ - Description: "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", - Type: []string{"boolean"}, - Format: "", - }, - }, - }, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_ManagedFieldsEntry(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "ManagedFieldsEntry is a workflow-id, a FieldSet and the group version of the resource that the fieldset applies to.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "manager": { - SchemaProps: spec.SchemaProps{ - Description: "Manager is an identifier of the workflow managing these fields.", - Type: []string{"string"}, - Format: "", - }, - }, - "operation": { - SchemaProps: spec.SchemaProps{ - Description: "Operation is the type of operation which lead to this ManagedFieldsEntry being created. The only valid values for this field are 'Apply' and 'Update'.", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the version of this resource that this field set applies to. The format is \"group/version\" just like the top-level APIVersion field. It is necessary to track the version of a field set because it cannot be automatically converted.", - Type: []string{"string"}, - Format: "", - }, - }, - "time": { - SchemaProps: spec.SchemaProps{ - Description: "Time is the timestamp of when the ManagedFields entry was added. The timestamp will also be updated if a field is added, the manager changes any of the owned fields value or removes a field. The timestamp does not update when a field is removed from the entry because another manager took it over.", - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Time"), - }, - }, - "fieldsType": { - SchemaProps: spec.SchemaProps{ - Description: "FieldsType is the discriminator for the different fields format and version. There is currently only one possible value: \"FieldsV1\"", - Type: []string{"string"}, - Format: "", - }, - }, - "fieldsV1": { - SchemaProps: spec.SchemaProps{ - Description: "FieldsV1 holds the first JSON version format as described in the \"FieldsV1\" type.", - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.FieldsV1"), - }, - }, - "subresource": { - SchemaProps: spec.SchemaProps{ - Description: "Subresource is the name of the subresource used to update that object, or empty string if the object was updated through the main resource. The value of this field is used to distinguish between managers, even if they share the same name. For example, a status update will be distinct from a regular update using the same manager name. Note that the APIVersion field is not related to the Subresource field and it always corresponds to the version of the main resource.", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.FieldsV1", "k8s.io/apimachinery/pkg/apis/meta/v1.Time"}, - } -} - -func schema_pkg_apis_meta_v1_MicroTime(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "MicroTime is version of Time with microsecond level precision.", - Type: v1.MicroTime{}.OpenAPISchemaType(), - Format: v1.MicroTime{}.OpenAPISchemaFormat(), - }, - }, - } -} - -func schema_pkg_apis_meta_v1_ObjectMeta(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "ObjectMeta is metadata that all persisted resources must have, which includes all objects users must create.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "name": { - SchemaProps: spec.SchemaProps{ - Description: "Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names", - Type: []string{"string"}, - Format: "", - }, - }, - "generateName": { - SchemaProps: spec.SchemaProps{ - Description: "GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server.\n\nIf this field is specified and the generated name exists, the server will return a 409.\n\nApplied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency", - Type: []string{"string"}, - Format: "", - }, - }, - "namespace": { - SchemaProps: spec.SchemaProps{ - Description: "Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the \"default\" namespace, but \"default\" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty.\n\nMust be a DNS_LABEL. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces", - Type: []string{"string"}, - Format: "", - }, - }, - "selfLink": { - SchemaProps: spec.SchemaProps{ - Description: "Deprecated: selfLink is a legacy read-only field that is no longer populated by the system.", - Type: []string{"string"}, - Format: "", - }, - }, - "uid": { - SchemaProps: spec.SchemaProps{ - Description: "UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations.\n\nPopulated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids", - Type: []string{"string"}, - Format: "", - }, - }, - "resourceVersion": { - SchemaProps: spec.SchemaProps{ - Description: "An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources.\n\nPopulated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency", - Type: []string{"string"}, - Format: "", - }, - }, - "generation": { - SchemaProps: spec.SchemaProps{ - Description: "A sequence number representing a specific generation of the desired state. Populated by the system. Read-only.", - Type: []string{"integer"}, - Format: "int64", - }, - }, - "creationTimestamp": { - SchemaProps: spec.SchemaProps{ - Description: "CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC.\n\nPopulated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Time"), - }, - }, - "deletionTimestamp": { - SchemaProps: spec.SchemaProps{ - Description: "DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field is set by the server when a graceful deletion is requested by the user, and is not directly settable by a client. The resource is expected to be deleted (no longer visible from resource lists, and not reachable by name) after the time in this field, once the finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. Once the deletionTimestamp is set, this value may not be unset or be set further into the future, although it may be shortened or the resource may be deleted prior to this time. For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the pod from the API. In the presence of network partitions, this object may still exist after this timestamp, until an administrator or automated process can determine the resource is fully terminated. If not set, graceful deletion of the object has not been requested.\n\nPopulated by the system when a graceful deletion is requested. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Time"), - }, - }, - "deletionGracePeriodSeconds": { - SchemaProps: spec.SchemaProps{ - Description: "Number of seconds allowed for this object to gracefully terminate before it will be removed from the system. Only set when deletionTimestamp is also set. May only be shortened. Read-only.", - Type: []string{"integer"}, - Format: "int64", - }, - }, - "labels": { - SchemaProps: spec.SchemaProps{ - Description: "Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - "annotations": { - SchemaProps: spec.SchemaProps{ - Description: "Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - "ownerReferences": { - VendorExtensible: spec.VendorExtensible{ - Extensions: spec.Extensions{ - "x-kubernetes-patch-merge-key": "uid", - "x-kubernetes-patch-strategy": "merge", - }, - }, - SchemaProps: spec.SchemaProps{ - Description: "List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. If this object is managed by a controller, then an entry in this list will point to this controller, with the controller field set to true. There cannot be more than one managing controller.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.OwnerReference"), - }, - }, - }, - }, - }, - "finalizers": { - VendorExtensible: spec.VendorExtensible{ - Extensions: spec.Extensions{ - "x-kubernetes-patch-strategy": "merge", - }, - }, - SchemaProps: spec.SchemaProps{ - Description: "Must be empty before the object is deleted from the registry. Each entry is an identifier for the responsible component that will remove the entry from the list. If the deletionTimestamp of the object is non-nil, entries in this list can only be removed. Finalizers may be processed and removed in any order. Order is NOT enforced because it introduces significant risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder it. If the finalizer list is processed in order, then this can lead to a situation in which the component responsible for the first finalizer in the list is waiting for a signal (field value, external system, or other) produced by a component responsible for a finalizer later in the list, resulting in a deadlock. Without enforced ordering finalizers are free to order amongst themselves and are not vulnerable to ordering changes in the list.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - "managedFields": { - SchemaProps: spec.SchemaProps{ - Description: "ManagedFields maps workflow-id and version to the set of fields that are managed by that workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set or understand this field. A workflow can be the user's name, a controller's name, or the name of a specific apply path like \"ci-cd\". The set of fields is always in the version that the workflow used when modifying the object.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ManagedFieldsEntry"), - }, - }, - }, - }, - }, - }, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.ManagedFieldsEntry", "k8s.io/apimachinery/pkg/apis/meta/v1.OwnerReference", "k8s.io/apimachinery/pkg/apis/meta/v1.Time"}, - } -} - -func schema_pkg_apis_meta_v1_OwnerReference(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "OwnerReference contains enough information to let you identify an owning object. An owning object must be in the same namespace as the dependent, or be cluster-scoped, so there is no namespace field.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "API version of the referent.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "name": { - SchemaProps: spec.SchemaProps{ - Description: "Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "uid": { - SchemaProps: spec.SchemaProps{ - Description: "UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "controller": { - SchemaProps: spec.SchemaProps{ - Description: "If true, this reference points to the managing controller.", - Type: []string{"boolean"}, - Format: "", - }, - }, - "blockOwnerDeletion": { - SchemaProps: spec.SchemaProps{ - Description: "If true, AND if the owner has the \"foregroundDeletion\" finalizer, then the owner cannot be deleted from the key-value store until this reference is removed. See https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion for how the garbage collector interacts with this field and enforces the foreground deletion. Defaults to false. To set this field, a user needs \"delete\" permission of the owner, otherwise 422 (Unprocessable Entity) will be returned.", - Type: []string{"boolean"}, - Format: "", - }, - }, - }, - Required: []string{"apiVersion", "kind", "name", "uid"}, - }, - VendorExtensible: spec.VendorExtensible{ - Extensions: spec.Extensions{ - "x-kubernetes-map-type": "atomic", - }, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_PartialObjectMetadata(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "PartialObjectMetadata is a generic representation of any object with ObjectMeta. It allows clients to get access to a particular ObjectMeta schema without knowing the details of the version.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Description: "Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), - }, - }, - }, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, - } -} - -func schema_pkg_apis_meta_v1_PartialObjectMetadataList(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "PartialObjectMetadataList contains a list of objects containing only their metadata", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Description: "Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), - }, - }, - "items": { - SchemaProps: spec.SchemaProps{ - Description: "items contains each of the included items.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.PartialObjectMetadata"), - }, - }, - }, - }, - }, - }, - Required: []string{"items"}, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta", "k8s.io/apimachinery/pkg/apis/meta/v1.PartialObjectMetadata"}, - } -} - -func schema_pkg_apis_meta_v1_Patch(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Patch is provided to give a concrete name and type to the Kubernetes PATCH request body.", - Type: []string{"object"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_PatchOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "PatchOptions may be provided when patching an API object. PatchOptions is meant to be a superset of UpdateOptions.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "dryRun": { - SchemaProps: spec.SchemaProps{ - Description: "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - "force": { - SchemaProps: spec.SchemaProps{ - Description: "Force is going to \"force\" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests.", - Type: []string{"boolean"}, - Format: "", - }, - }, - "fieldManager": { - SchemaProps: spec.SchemaProps{ - Description: "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch).", - Type: []string{"string"}, - Format: "", - }, - }, - "fieldValidation": { - SchemaProps: spec.SchemaProps{ - Description: "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_Preconditions(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Preconditions must be fulfilled before an operation (update, delete, etc.) is carried out.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "uid": { - SchemaProps: spec.SchemaProps{ - Description: "Specifies the target UID.", - Type: []string{"string"}, - Format: "", - }, - }, - "resourceVersion": { - SchemaProps: spec.SchemaProps{ - Description: "Specifies the target ResourceVersion", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_RootPaths(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "RootPaths lists the paths available at root. For example: \"/healthz\", \"/apis\".", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "paths": { - SchemaProps: spec.SchemaProps{ - Description: "paths are the paths available at root.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - }, - Required: []string{"paths"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_ServerAddressByClientCIDR(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "ServerAddressByClientCIDR helps the client to determine the server address that they should use, depending on the clientCIDR that they match.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "clientCIDR": { - SchemaProps: spec.SchemaProps{ - Description: "The CIDR with which clients can match their IP to figure out the server address that they should use.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "serverAddress": { - SchemaProps: spec.SchemaProps{ - Description: "Address of this server, suitable for a client that matches the above CIDR. This can be a hostname, hostname:port, IP or IP:port.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"clientCIDR", "serverAddress"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_Status(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Status is a return value for calls that don't return other objects.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Description: "Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), - }, - }, - "status": { - SchemaProps: spec.SchemaProps{ - Description: "Status of the operation. One of: \"Success\" or \"Failure\". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status", - Type: []string{"string"}, - Format: "", - }, - }, - "message": { - SchemaProps: spec.SchemaProps{ - Description: "A human-readable description of the status of this operation.", - Type: []string{"string"}, - Format: "", - }, - }, - "reason": { - SchemaProps: spec.SchemaProps{ - Description: "A machine-readable description of why this operation is in the \"Failure\" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it.", - Type: []string{"string"}, - Format: "", - }, - }, - "details": { - SchemaProps: spec.SchemaProps{ - Description: "Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type.", - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.StatusDetails"), - }, - }, - "code": { - SchemaProps: spec.SchemaProps{ - Description: "Suggested HTTP return code for this status, 0 if not set.", - Type: []string{"integer"}, - Format: "int32", - }, - }, - }, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta", "k8s.io/apimachinery/pkg/apis/meta/v1.StatusDetails"}, - } -} - -func schema_pkg_apis_meta_v1_StatusCause(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "StatusCause provides more information about an api.Status failure, including cases when multiple errors are encountered.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "reason": { - SchemaProps: spec.SchemaProps{ - Description: "A machine-readable description of the cause of the error. If this value is empty there is no information available.", - Type: []string{"string"}, - Format: "", - }, - }, - "message": { - SchemaProps: spec.SchemaProps{ - Description: "A human-readable description of the cause of the error. This field may be presented as-is to a reader.", - Type: []string{"string"}, - Format: "", - }, - }, - "field": { - SchemaProps: spec.SchemaProps{ - Description: "The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional.\n\nExamples:\n \"name\" - the field \"name\" on the current resource\n \"items[0].name\" - the field \"name\" on the first array entry in \"items\"", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_StatusDetails(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "StatusDetails is a set of additional properties that MAY be set by the server to provide additional information about a response. The Reason field of a Status object defines what attributes will be set. Clients must ignore fields that do not match the defined type of each attribute, and should assume that any attribute may be empty, invalid, or under defined.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "name": { - SchemaProps: spec.SchemaProps{ - Description: "The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described).", - Type: []string{"string"}, - Format: "", - }, - }, - "group": { - SchemaProps: spec.SchemaProps{ - Description: "The group attribute of the resource associated with the status StatusReason.", - Type: []string{"string"}, - Format: "", - }, - }, - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "uid": { - SchemaProps: spec.SchemaProps{ - Description: "UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids", - Type: []string{"string"}, - Format: "", - }, - }, - "causes": { - SchemaProps: spec.SchemaProps{ - Description: "The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.StatusCause"), - }, - }, - }, - }, - }, - "retryAfterSeconds": { - SchemaProps: spec.SchemaProps{ - Description: "If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action.", - Type: []string{"integer"}, - Format: "int32", - }, - }, - }, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.StatusCause"}, - } -} - -func schema_pkg_apis_meta_v1_Table(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Table is a tabular representation of a set of API resources. The server transforms the object into a set of preferred columns for quickly reviewing the objects.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Description: "Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), - }, - }, - "columnDefinitions": { - SchemaProps: spec.SchemaProps{ - Description: "columnDefinitions describes each column in the returned items array. The number of cells per row will always match the number of column definitions.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.TableColumnDefinition"), - }, - }, - }, - }, - }, - "rows": { - SchemaProps: spec.SchemaProps{ - Description: "rows is the list of items in the table.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.TableRow"), - }, - }, - }, - }, - }, - }, - Required: []string{"columnDefinitions", "rows"}, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta", "k8s.io/apimachinery/pkg/apis/meta/v1.TableColumnDefinition", "k8s.io/apimachinery/pkg/apis/meta/v1.TableRow"}, - } -} - -func schema_pkg_apis_meta_v1_TableColumnDefinition(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "TableColumnDefinition contains information about a column returned in the Table.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "name": { - SchemaProps: spec.SchemaProps{ - Description: "name is a human readable name for the column.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "type": { - SchemaProps: spec.SchemaProps{ - Description: "type is an OpenAPI type definition for this column, such as number, integer, string, or array. See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#data-types for more.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "format": { - SchemaProps: spec.SchemaProps{ - Description: "format is an optional OpenAPI type modifier for this column. A format modifies the type and imposes additional rules, like date or time formatting for a string. The 'name' format is applied to the primary identifier column which has type 'string' to assist in clients identifying column is the resource name. See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#data-types for more.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "description": { - SchemaProps: spec.SchemaProps{ - Description: "description is a human readable description of this column.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "priority": { - SchemaProps: spec.SchemaProps{ - Description: "priority is an integer defining the relative importance of this column compared to others. Lower numbers are considered higher priority. Columns that may be omitted in limited space scenarios should be given a higher priority.", - Default: 0, - Type: []string{"integer"}, - Format: "int32", - }, - }, - }, - Required: []string{"name", "type", "format", "description", "priority"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_TableOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "TableOptions are used when a Table is requested by the caller.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "includeObject": { - SchemaProps: spec.SchemaProps{ - Description: "includeObject decides whether to include each object along with its columnar information. Specifying \"None\" will return no object, specifying \"Object\" will return the full object contents, and specifying \"Metadata\" (the default) will return the object's metadata in the PartialObjectMetadata kind in version v1beta1 of the meta.k8s.io API group.", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_TableRow(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "TableRow is an individual row in a table.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "cells": { - SchemaProps: spec.SchemaProps{ - Description: "cells will be as wide as the column definitions array and may contain strings, numbers (float64 or int64), booleans, simple maps, lists, or null. See the type field of the column definition for a more detailed description.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Format: "", - }, - }, - }, - }, - }, - "conditions": { - SchemaProps: spec.SchemaProps{ - Description: "conditions describe additional status of a row that are relevant for a human user. These conditions apply to the row, not to the object, and will be specific to table output. The only defined condition type is 'Completed', for a row that indicates a resource that has run to completion and can be given less visual priority.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.TableRowCondition"), - }, - }, - }, - }, - }, - "object": { - SchemaProps: spec.SchemaProps{ - Description: "This field contains the requested additional information about each object based on the includeObject policy when requesting the Table. If \"None\", this field is empty, if \"Object\" this will be the default serialization of the object for the current API version, and if \"Metadata\" (the default) will contain the object metadata. Check the returned kind and apiVersion of the object before parsing. The media type of the object will always match the enclosing list - if this as a JSON table, these will be JSON encoded objects.", - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/runtime.RawExtension"), - }, - }, - }, - Required: []string{"cells"}, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.TableRowCondition", "k8s.io/apimachinery/pkg/runtime.RawExtension"}, - } -} - -func schema_pkg_apis_meta_v1_TableRowCondition(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "TableRowCondition allows a row to be marked with additional information.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "type": { - SchemaProps: spec.SchemaProps{ - Description: "Type of row condition. The only defined value is 'Completed' indicating that the object this row represents has reached a completed state and may be given less visual priority than other rows. Clients are not required to honor any conditions but should be consistent where possible about handling the conditions.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "status": { - SchemaProps: spec.SchemaProps{ - Description: "Status of the condition, one of True, False, Unknown.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "reason": { - SchemaProps: spec.SchemaProps{ - Description: "(brief) machine readable reason for the condition's last transition.", - Type: []string{"string"}, - Format: "", - }, - }, - "message": { - SchemaProps: spec.SchemaProps{ - Description: "Human readable message indicating details about last transition.", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"type", "status"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_Time(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Time is a wrapper around time.Time which supports correct marshaling to YAML and JSON. Wrappers are provided for many of the factory methods that the time package offers.", - Type: v1.Time{}.OpenAPISchemaType(), - Format: v1.Time{}.OpenAPISchemaFormat(), - }, - }, - } -} - -func schema_pkg_apis_meta_v1_Timestamp(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Timestamp is a struct that is equivalent to Time, but intended for protobuf marshalling/unmarshalling. It is generated into a serialization that matches Time. Do not use in Go structs.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "seconds": { - SchemaProps: spec.SchemaProps{ - Description: "Represents seconds of UTC time since Unix epoch 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59Z inclusive.", - Default: 0, - Type: []string{"integer"}, - Format: "int64", - }, - }, - "nanos": { - SchemaProps: spec.SchemaProps{ - Description: "Non-negative fractions of a second at nanosecond resolution. Negative second values with fractions must still have non-negative nanos values that count forward in time. Must be from 0 to 999,999,999 inclusive. This field may be limited in precision depending on context.", - Default: 0, - Type: []string{"integer"}, - Format: "int32", - }, - }, - }, - Required: []string{"seconds", "nanos"}, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_TypeMeta(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "TypeMeta describes an individual object in an API response or request with strings representing the type of the object and its API schema version. Structures that are versioned or persisted should inline TypeMeta.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_UpdateOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "UpdateOptions may be provided when updating an API object. All fields in UpdateOptions should also be present in PatchOptions.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "dryRun": { - SchemaProps: spec.SchemaProps{ - Description: "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - "fieldManager": { - SchemaProps: spec.SchemaProps{ - Description: "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", - Type: []string{"string"}, - Format: "", - }, - }, - "fieldValidation": { - SchemaProps: spec.SchemaProps{ - Description: "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - } -} - -func schema_pkg_apis_meta_v1_WatchEvent(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Event represents a single event to a watched resource.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "type": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "object": { - SchemaProps: spec.SchemaProps{ - Description: "Object is:\n * If Type is Added or Modified: the new state of the object.\n * If Type is Deleted: the state of the object immediately before deletion.\n * If Type is Error: *Status is recommended; other types may make sense\n depending on context.", - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/runtime.RawExtension"), - }, - }, - }, - Required: []string{"type", "object"}, - }, - }, - Dependencies: []string{ - "k8s.io/apimachinery/pkg/runtime.RawExtension"}, - } -} - -func schema_k8sio_apimachinery_pkg_runtime_RawExtension(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "RawExtension is used to hold extensions in external versions.\n\nTo use this, make a field which has RawExtension as its type in your external, versioned struct, and Object in your internal struct. You also need to register your various plugin types.\n\n// Internal package:\n\n\ttype MyAPIObject struct {\n\t\truntime.TypeMeta `json:\",inline\"`\n\t\tMyPlugin runtime.Object `json:\"myPlugin\"`\n\t}\n\n\ttype PluginA struct {\n\t\tAOption string `json:\"aOption\"`\n\t}\n\n// External package:\n\n\ttype MyAPIObject struct {\n\t\truntime.TypeMeta `json:\",inline\"`\n\t\tMyPlugin runtime.RawExtension `json:\"myPlugin\"`\n\t}\n\n\ttype PluginA struct {\n\t\tAOption string `json:\"aOption\"`\n\t}\n\n// On the wire, the JSON will look something like this:\n\n\t{\n\t\t\"kind\":\"MyAPIObject\",\n\t\t\"apiVersion\":\"v1\",\n\t\t\"myPlugin\": {\n\t\t\t\"kind\":\"PluginA\",\n\t\t\t\"aOption\":\"foo\",\n\t\t},\n\t}\n\nSo what happens? Decode first uses json or yaml to unmarshal the serialized data into your external MyAPIObject. That causes the raw JSON to be stored, but not unpacked. The next step is to copy (using pkg/conversion) into the internal struct. The runtime package's DefaultScheme has conversion functions installed which will unpack the JSON stored in RawExtension, turning it into the correct object type, and storing it in the Object. (TODO: In the case where the object is of an unknown type, a runtime.Unknown object will be created and stored.)", - Type: []string{"object"}, - }, - }, - } -} - -func schema_k8sio_apimachinery_pkg_runtime_TypeMeta(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "TypeMeta is shared by all top level objects. The proper way to use it is to inline it in your type, like this:\n\n\ttype MyAwesomeAPIObject struct {\n\t runtime.TypeMeta `json:\",inline\"`\n\t ... // other fields\n\t}\n\nfunc (obj *MyAwesomeAPIObject) SetGroupVersionKind(gvk *metav1.GroupVersionKind) { metav1.UpdateTypeMeta(obj,gvk) }; GroupVersionKind() *GroupVersionKind\n\nTypeMeta is provided here for convenience. You may use it directly from this package or define your own with the same fields.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "kind": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - } -} - -func schema_k8sio_apimachinery_pkg_runtime_Unknown(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Unknown allows api objects with unknown types to be passed-through. This can be used to deal with the API objects from a plug-in. Unknown objects still have functioning TypeMeta features-- kind, version, etc. metadata and field mutatation.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "kind": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "ContentEncoding": { - SchemaProps: spec.SchemaProps{ - Description: "ContentEncoding is encoding used to encode 'Raw' data. Unspecified means no encoding.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "ContentType": { - SchemaProps: spec.SchemaProps{ - Description: "ContentType is serialization method used to serialize 'Raw'. Unspecified means ContentTypeJSON.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"ContentEncoding", "ContentType"}, - }, - }, - } -} - -func schema_k8sio_apimachinery_pkg_version_Info(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Info contains versioning information. how we'll want to distribute that information.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "major": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "minor": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "gitVersion": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "gitCommit": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "gitTreeState": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "buildDate": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "goVersion": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "compiler": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "platform": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"major", "minor", "gitVersion", "gitCommit", "gitTreeState", "buildDate", "goVersion", "compiler", "platform"}, - }, - }, - } -} diff --git a/pkg/apis/folders/v0alpha1/doc.go b/pkg/apis/peakq/v0alpha1/doc.go similarity index 60% rename from pkg/apis/folders/v0alpha1/doc.go rename to pkg/apis/peakq/v0alpha1/doc.go index 99650184de252..52a9e2be69c9b 100644 --- a/pkg/apis/folders/v0alpha1/doc.go +++ b/pkg/apis/peakq/v0alpha1/doc.go @@ -1,5 +1,6 @@ // +k8s:deepcopy-gen=package // +k8s:openapi-gen=true -// +groupName=folders.grafana.app +// +k8s:defaulter-gen=TypeMeta +// +groupName=peakq.grafana.app -package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/folders/v0alpha1" +package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/peakq/v0alpha1" diff --git a/pkg/apis/peakq/v0alpha1/register.go b/pkg/apis/peakq/v0alpha1/register.go new file mode 100644 index 0000000000000..b58288d353834 --- /dev/null +++ b/pkg/apis/peakq/v0alpha1/register.go @@ -0,0 +1,51 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +const ( + GROUP = "peakq.grafana.app" + VERSION = "v0alpha1" + APIVERSION = GROUP + "/" + VERSION +) + +var QueryTemplateResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "querytemplates", "querytemplate", "QueryTemplate", + func() runtime.Object { return &QueryTemplate{} }, + func() runtime.Object { return &QueryTemplateList{} }, +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} + + // SchemaBuilder is used by standard codegen + SchemeBuilder runtime.SchemeBuilder + localSchemeBuilder = &SchemeBuilder + AddToScheme = localSchemeBuilder.AddToScheme +) + +func init() { + localSchemeBuilder.Register(addKnownTypes) +} + +// Adds the list of known types to the given scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &QueryTemplate{}, + &QueryTemplateList{}, + &RenderedQuery{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} diff --git a/pkg/apis/peakq/v0alpha1/types.go b/pkg/apis/peakq/v0alpha1/types.go new file mode 100644 index 0000000000000..0c67564df3508 --- /dev/null +++ b/pkg/apis/peakq/v0alpha1/types.go @@ -0,0 +1,32 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type QueryTemplate struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec template.QueryTemplate `json:"spec,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type QueryTemplateList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []QueryTemplate `json:"items,omitempty"` +} + +// Dummy object that represents a real query object +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type RenderedQuery struct { + metav1.TypeMeta `json:",inline"` + + // +listType=atomic + Targets []template.Target `json:"targets,omitempty"` +} diff --git a/pkg/apis/peakq/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/peakq/v0alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000000000..6f50c376eb6a3 --- /dev/null +++ b/pkg/apis/peakq/v0alpha1/zz_generated.deepcopy.go @@ -0,0 +1,105 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + template "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *QueryTemplate) DeepCopyInto(out *QueryTemplate) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new QueryTemplate. +func (in *QueryTemplate) DeepCopy() *QueryTemplate { + if in == nil { + return nil + } + out := new(QueryTemplate) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *QueryTemplate) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *QueryTemplateList) DeepCopyInto(out *QueryTemplateList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]QueryTemplate, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new QueryTemplateList. +func (in *QueryTemplateList) DeepCopy() *QueryTemplateList { + if in == nil { + return nil + } + out := new(QueryTemplateList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *QueryTemplateList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RenderedQuery) DeepCopyInto(out *RenderedQuery) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.Targets != nil { + in, out := &in.Targets, &out.Targets + *out = make([]template.Target, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RenderedQuery. +func (in *RenderedQuery) DeepCopy() *RenderedQuery { + if in == nil { + return nil + } + out := new(RenderedQuery) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RenderedQuery) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} diff --git a/pkg/apis/peakq/v0alpha1/zz_generated.defaults.go b/pkg/apis/peakq/v0alpha1/zz_generated.defaults.go new file mode 100644 index 0000000000000..238fc2f4edcfe --- /dev/null +++ b/pkg/apis/peakq/v0alpha1/zz_generated.defaults.go @@ -0,0 +1,19 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by defaulter-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// RegisterDefaults adds defaulters functions to the given scheme. +// Public to allow building arbitrary schemes. +// All generated defaulters are covering - they call all nested defaulters. +func RegisterDefaults(scheme *runtime.Scheme) error { + return nil +} diff --git a/pkg/apis/peakq/v0alpha1/zz_generated.openapi.go b/pkg/apis/peakq/v0alpha1/zz_generated.openapi.go new file mode 100644 index 0000000000000..42bdb435e5069 --- /dev/null +++ b/pkg/apis/peakq/v0alpha1/zz_generated.openapi.go @@ -0,0 +1,157 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by openapi-gen. DO NOT EDIT. + +// This file was autogenerated by openapi-gen. Do not edit it manually! + +package v0alpha1 + +import ( + common "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" +) + +func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + return map[string]common.OpenAPIDefinition{ + "github.com/grafana/grafana/pkg/apis/peakq/v0alpha1.QueryTemplate": schema_pkg_apis_peakq_v0alpha1_QueryTemplate(ref), + "github.com/grafana/grafana/pkg/apis/peakq/v0alpha1.QueryTemplateList": schema_pkg_apis_peakq_v0alpha1_QueryTemplateList(ref), + "github.com/grafana/grafana/pkg/apis/peakq/v0alpha1.RenderedQuery": schema_pkg_apis_peakq_v0alpha1_RenderedQuery(ref), + } +} + +func schema_pkg_apis_peakq_v0alpha1_QueryTemplate(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.QueryTemplate"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.QueryTemplate", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_peakq_v0alpha1_QueryTemplateList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/peakq/v0alpha1.QueryTemplate"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/peakq/v0alpha1.QueryTemplate", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_peakq_v0alpha1_RenderedQuery(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Dummy object that represents a real query object", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "targets": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.Target"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.Target"}, + } +} diff --git a/pkg/apis/playlist/v0alpha1/doc.go b/pkg/apis/playlist/v0alpha1/doc.go index 9e0c3053c16b3..72fdc02063146 100644 --- a/pkg/apis/playlist/v0alpha1/doc.go +++ b/pkg/apis/playlist/v0alpha1/doc.go @@ -1,5 +1,6 @@ // +k8s:deepcopy-gen=package // +k8s:openapi-gen=true +// +k8s:defaulter-gen=TypeMeta // +groupName=playlist.grafana.app package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1" diff --git a/pkg/apis/playlist/v0alpha1/register.go b/pkg/apis/playlist/v0alpha1/register.go new file mode 100644 index 0000000000000..c1886b2d572bc --- /dev/null +++ b/pkg/apis/playlist/v0alpha1/register.go @@ -0,0 +1,25 @@ +package v0alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +const ( + GROUP = "playlist.grafana.app" + VERSION = "v0alpha1" + APIVERSION = GROUP + "/" + VERSION +) + +var PlaylistResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "playlists", "playlist", "Playlist", + func() runtime.Object { return &Playlist{} }, + func() runtime.Object { return &PlaylistList{} }, +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} +) diff --git a/pkg/apis/playlist/v0alpha1/types.go b/pkg/apis/playlist/v0alpha1/types.go index b34a52cb63417..84e8ee0f19977 100644 --- a/pkg/apis/playlist/v0alpha1/types.go +++ b/pkg/apis/playlist/v0alpha1/types.go @@ -2,21 +2,6 @@ package v0alpha1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - runtime "k8s.io/apimachinery/pkg/runtime" - - "github.com/grafana/grafana/pkg/apis" -) - -const ( - GROUP = "playlist.grafana.app" - VERSION = "v0alpha1" - APIVERSION = GROUP + "/" + VERSION -) - -var PlaylistResourceInfo = apis.NewResourceInfo(GROUP, VERSION, - "playlists", "playlist", "Playlist", - func() runtime.Object { return &Playlist{} }, - func() runtime.Object { return &PlaylistList{} }, ) // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object @@ -51,12 +36,16 @@ type Spec struct { Items []Item `json:"items,omitempty"` } +// Type of the item. +// +enum +type ItemType string + // Defines values for ItemType. const ( ItemTypeDashboardByTag ItemType = "dashboard_by_tag" ItemTypeDashboardByUid ItemType = "dashboard_by_uid" - // deprecated -- should use UID + // Deprecated -- should use UID ItemTypeDashboardById ItemType = "dashboard_by_id" ) @@ -75,6 +64,3 @@ type Item struct { // - dashboard_by_uid: The value is the dashboard UID Value string `json:"value"` } - -// Type of the item. -type ItemType string diff --git a/pkg/apis/playlist/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/playlist/v0alpha1/zz_generated.deepcopy.go index 022f71d9be854..74637ae2fb4fc 100644 --- a/pkg/apis/playlist/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/playlist/v0alpha1/zz_generated.deepcopy.go @@ -1,21 +1,7 @@ //go:build !ignore_autogenerated // +build !ignore_autogenerated -/* -Copyright The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ +// SPDX-License-Identifier: AGPL-3.0-only // Code generated by deepcopy-gen. DO NOT EDIT. diff --git a/pkg/apis/playlist/v0alpha1/zz_generated.defaults.go b/pkg/apis/playlist/v0alpha1/zz_generated.defaults.go index ff019bc601997..238fc2f4edcfe 100644 --- a/pkg/apis/playlist/v0alpha1/zz_generated.defaults.go +++ b/pkg/apis/playlist/v0alpha1/zz_generated.defaults.go @@ -1,21 +1,7 @@ //go:build !ignore_autogenerated // +build !ignore_autogenerated -/* -Copyright The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ +// SPDX-License-Identifier: AGPL-3.0-only // Code generated by defaulter-gen. DO NOT EDIT. diff --git a/pkg/apis/playlist/v0alpha1/zz_generated.openapi.go b/pkg/apis/playlist/v0alpha1/zz_generated.openapi.go index 0e9be61c3bfa4..534cc7312dd53 100644 --- a/pkg/apis/playlist/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/playlist/v0alpha1/zz_generated.openapi.go @@ -32,10 +32,11 @@ func schema_pkg_apis_playlist_v0alpha1_Item(ref common.ReferenceCallback) common Properties: map[string]spec.Schema{ "type": { SchemaProps: spec.SchemaProps{ - Description: "Type of the item.", + Description: "Type of the item.\n\nPossible enum values:\n - `\"dashboard_by_id\"` Deprecated -- should use UID\n - `\"dashboard_by_tag\"`\n - `\"dashboard_by_uid\"`", Default: "", Type: []string{"string"}, Format: "", + Enum: []interface{}{"dashboard_by_id", "dashboard_by_tag", "dashboard_by_uid"}, }, }, "value": { diff --git a/pkg/apis/playlist/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/playlist/v0alpha1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 0000000000000..67057de3d4851 --- /dev/null +++ b/pkg/apis/playlist/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -0,0 +1 @@ +API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/playlist/v0alpha1,Spec,Items diff --git a/pkg/apis/query/v0alpha1/datasource.go b/pkg/apis/query/v0alpha1/datasource.go new file mode 100644 index 0000000000000..1a39487c9376b --- /dev/null +++ b/pkg/apis/query/v0alpha1/datasource.go @@ -0,0 +1,49 @@ +package v0alpha1 + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type DataSourceApiServerRegistry interface { + // Get the group and preferred version for a plugin + GetDatasourceGroupVersion(pluginId string) (schema.GroupVersion, error) + + // Get the list of available datasource api servers + // The values will be managed though API discovery/reconciliation + GetDatasourceApiServers(ctx context.Context) (*DataSourceApiServerList, error) +} + +// The data source resource is a reflection of the individual datasource instances +// that are exposed in the groups: {datasource}.datasource.grafana.app +// The status is updated periodically. +// The name is the plugin id +// +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DataSourceApiServer struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // The display name + Title string `json:"title"` + + // Describe the plugin + Description string `json:"description,omitempty"` + + // The group + preferred version + GroupVersion string `json:"groupVersion"` + + // Possible alternative plugin IDs + AliasIDs []string `json:"aliasIDs,omitempty"` +} + +// List of datasource plugins +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DataSourceApiServerList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []DataSourceApiServer `json:"items,omitempty"` +} diff --git a/pkg/apis/query/v0alpha1/doc.go b/pkg/apis/query/v0alpha1/doc.go new file mode 100644 index 0000000000000..d0e053d47a701 --- /dev/null +++ b/pkg/apis/query/v0alpha1/doc.go @@ -0,0 +1,6 @@ +// +k8s:deepcopy-gen=package +// +k8s:openapi-gen=true +// +k8s:defaulter-gen=TypeMeta +// +groupName=query.grafana.app + +package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/query/v0alpha1" diff --git a/pkg/apis/query/v0alpha1/query.go b/pkg/apis/query/v0alpha1/query.go new file mode 100644 index 0000000000000..dd7ce99c390b7 --- /dev/null +++ b/pkg/apis/query/v0alpha1/query.go @@ -0,0 +1,59 @@ +package v0alpha1 + +import ( + "net/http" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Generic query request with shared time across all values +// Copied from: https://github.com/grafana/grafana/blob/main/pkg/api/dtos/models.go#L62 +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type QueryDataRequest struct { + metav1.TypeMeta `json:",inline"` + + // The time range used when not included on each query + data.QueryDataRequest `json:",inline"` +} + +// Wraps backend.QueryDataResponse, however it includes TypeMeta and implements runtime.Object +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type QueryDataResponse struct { + metav1.TypeMeta `json:",inline"` + + // Backend wrapper (external dependency) + backend.QueryDataResponse `json:",inline"` +} + +// If errors exist, return multi-status +func GetResponseCode(rsp *backend.QueryDataResponse) int { + if rsp == nil { + return http.StatusInternalServerError + } + for _, v := range rsp.Responses { + if v.Error != nil { + return http.StatusMultiStatus + } + } + return http.StatusOK +} + +// Defines a query behavior in a datasource. This is a similar model to a CRD where the +// payload describes a valid query +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type QueryTypeDefinition struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec data.QueryTypeDefinitionSpec `json:"spec,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type QueryTypeDefinitionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []QueryTypeDefinition `json:"items,omitempty"` +} diff --git a/pkg/apis/query/v0alpha1/query_test.go b/pkg/apis/query/v0alpha1/query_test.go new file mode 100644 index 0000000000000..03656d5b4197a --- /dev/null +++ b/pkg/apis/query/v0alpha1/query_test.go @@ -0,0 +1,78 @@ +package v0alpha1_test + +import ( + "encoding/json" + "testing" + + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "github.com/stretchr/testify/require" + + query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" +) + +func TestParseQueriesIntoQueryDataRequest(t *testing.T) { + request := []byte(`{ + "queries": [ + { + "refId": "A", + "datasource": { + "type": "grafana-googlesheets-datasource", + "uid": "b1808c48-9fc9-4045-82d7-081781f8a553" + }, + "cacheDurationSeconds": 300, + "spreadsheet": "spreadsheetID", + "datasourceId": 4, + "intervalMs": 30000, + "maxDataPoints": 794 + }, + { + "refId": "Z", + "datasource": "old", + "maxDataPoints": 10, + "timeRange": { + "from": "100", + "to": "200" + }, + "queryType": "foo" + } + ], + "from": "1692624667389", + "to": "1692646267389" + }`) + + req := &query.QueryDataRequest{} + err := json.Unmarshal(request, req) + require.NoError(t, err) + + require.Len(t, req.Queries, 2) + require.Equal(t, "b1808c48-9fc9-4045-82d7-081781f8a553", req.Queries[0].Datasource.UID) + require.Equal(t, "spreadsheetID", req.Queries[0].GetString("spreadsheet")) + + // Write the query (with additional spreadsheetID) to JSON + out, err := json.MarshalIndent(req.Queries[0], "", " ") + require.NoError(t, err) + + // And read it back with standard JSON marshal functions + query := &data.DataQuery{} + err = json.Unmarshal(out, query) + require.NoError(t, err) + require.Equal(t, "spreadsheetID", query.GetString("spreadsheet")) + + // The second query has an explicit time range, and legacy datasource name + out, err = json.MarshalIndent(req.Queries[1], "", " ") + require.NoError(t, err) + // fmt.Printf("%s\n", string(out)) + require.JSONEq(t, `{ + "datasource": { + "type": "", ` /* NOTE! this implies legacy naming */ +` + "uid": "old" + }, + "maxDataPoints": 10, + "queryType": "foo", + "refId": "Z", + "timeRange": { + "from": "100", + "to": "200" + } + }`, string(out)) +} diff --git a/pkg/apis/query/v0alpha1/register.go b/pkg/apis/query/v0alpha1/register.go new file mode 100644 index 0000000000000..c62fc559eb758 --- /dev/null +++ b/pkg/apis/query/v0alpha1/register.go @@ -0,0 +1,31 @@ +package v0alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +const ( + GROUP = "query.grafana.app" + VERSION = "v0alpha1" + APIVERSION = GROUP + "/" + VERSION +) + +var DataSourceApiServerResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "datasourceapiservers", "datasourceapiserver", "DataSourceApiServer", + func() runtime.Object { return &DataSourceApiServer{} }, + func() runtime.Object { return &DataSourceApiServerList{} }, +) + +var QueryTypeDefinitionResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "querytypes", "querytype", "QueryTypeDefinition", + func() runtime.Object { return &QueryTypeDefinition{} }, + func() runtime.Object { return &QueryTypeDefinitionList{} }, +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} +) diff --git a/pkg/apis/query/v0alpha1/template/doc.go b/pkg/apis/query/v0alpha1/template/doc.go new file mode 100644 index 0000000000000..3585a3a5806c8 --- /dev/null +++ b/pkg/apis/query/v0alpha1/template/doc.go @@ -0,0 +1,6 @@ +// +k8s:deepcopy-gen=package +// +k8s:openapi-gen=true +// +k8s:defaulter-gen=TypeMeta +// +groupName=query.grafana.app + +package template // import "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template" diff --git a/pkg/apis/query/v0alpha1/template/format.go b/pkg/apis/query/v0alpha1/template/format.go new file mode 100644 index 0000000000000..353a49ef2d366 --- /dev/null +++ b/pkg/apis/query/v0alpha1/template/format.go @@ -0,0 +1,68 @@ +package template + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "strings" +) + +func FormatVariables(fmt VariableFormat, input []string) string { + if len(input) < 1 { + return "" + } + + // MultiValued formats + // nolint: exhaustive + switch fmt { + case FormatJSON: + v, _ := json.Marshal(input) + return string(v) + + case FormatDoubleQuote: + sb := bytes.NewBufferString("") + for idx, val := range input { + if idx > 0 { + _, _ = sb.WriteRune(',') + } + _, _ = sb.WriteRune('"') + _, _ = sb.WriteString(strings.ReplaceAll(val, `"`, `\"`)) + _, _ = sb.WriteRune('"') + } + return sb.String() + + case FormatSingleQuote: + sb := bytes.NewBufferString("") + for idx, val := range input { + if idx > 0 { + _, _ = sb.WriteRune(',') + } + _, _ = sb.WriteRune('\'') + _, _ = sb.WriteString(strings.ReplaceAll(val, `'`, `\'`)) + _, _ = sb.WriteRune('\'') + } + return sb.String() + + case FormatCSV: + sb := bytes.NewBufferString("") + w := csv.NewWriter(sb) + _ = w.Write(input) + w.Flush() + v := sb.Bytes() + return string(v[:len(v)-1]) + } + + // Single valued formats + if len(input) == 1 { + return input[0] + } + + // nolint: exhaustive + switch fmt { + case FormatPipe: + return strings.Join(input, "|") + } + + // Raw output (joined with a comma) + return strings.Join(input, ",") +} diff --git a/pkg/apis/query/v0alpha1/template/format_test.go b/pkg/apis/query/v0alpha1/template/format_test.go new file mode 100644 index 0000000000000..ace0a499efe23 --- /dev/null +++ b/pkg/apis/query/v0alpha1/template/format_test.go @@ -0,0 +1,81 @@ +package template + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFormat(t *testing.T) { + // Invalid input + require.Equal(t, "", FormatVariables(FormatCSV, nil)) + require.Equal(t, "", FormatVariables(FormatCSV, []string{})) + + type check struct { + name string + input []string + output map[VariableFormat]string + } + + tests := []check{ + { + name: "three simple variables", + input: []string{"a", "b", "c"}, + output: map[VariableFormat]string{ + FormatCSV: "a,b,c", + FormatJSON: `["a","b","c"]`, + FormatDoubleQuote: `"a","b","c"`, + FormatSingleQuote: `'a','b','c'`, + FormatPipe: `a|b|c`, + FormatRaw: "a,b,c", + }, + }, + { + name: "single value", + input: []string{"a"}, + output: map[VariableFormat]string{ + FormatCSV: "a", + FormatJSON: `["a"]`, + FormatDoubleQuote: `"a"`, + FormatSingleQuote: `'a'`, + FormatPipe: "a", + FormatRaw: "a", + }, + }, + { + name: "value with quote", + input: []string{`hello "world"`}, + output: map[VariableFormat]string{ + FormatCSV: `"hello ""world"""`, // note the double quotes + FormatJSON: `["hello \"world\""]`, + FormatDoubleQuote: `"hello \"world\""`, + FormatSingleQuote: `'hello "world"'`, + FormatPipe: `hello "world"`, + FormatRaw: `hello "world"`, + }, + }, + } + for _, test := range tests { + // Make sure all keys are set in tests + all := map[VariableFormat]bool{ + FormatRaw: true, + FormatCSV: true, + FormatJSON: true, + FormatDoubleQuote: true, + FormatSingleQuote: true, + FormatPipe: true, + } + + // Check the default (no format) matches CSV + require.Equal(t, test.output[FormatRaw], + FormatVariables("", test.input), + "test %s default values are not raw", test.name) + + // Check each input value + for format, v := range test.output { + require.Equal(t, v, FormatVariables(format, test.input), "Test: %s (format:%s)", test.name, format) + delete(all, format) + } + require.Empty(t, all, "test %s is missing cases for: %v", test.name, all) + } +} diff --git a/pkg/apis/query/v0alpha1/template/render.go b/pkg/apis/query/v0alpha1/template/render.go new file mode 100644 index 0000000000000..d657b642409f7 --- /dev/null +++ b/pkg/apis/query/v0alpha1/template/render.go @@ -0,0 +1,115 @@ +package template + +import ( + "fmt" + "sort" + + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "github.com/spyzhov/ajson" +) + +// RenderTemplate applies selected values into a query template +func RenderTemplate(qt QueryTemplate, selectedValues map[string][]string) ([]Target, error) { + targets := qt.DeepCopy().Targets + + rawTargetObjects := make([]*ajson.Node, len(qt.Targets)) + for i, t := range qt.Targets { + b, err := t.Properties.MarshalJSON() + if err != nil { + return nil, err + } + rawTargetObjects[i], err = ajson.Unmarshal(b) + if err != nil { + return nil, err + } + } + + rm := getReplacementMap(qt) + for targetIdx, byTargetIdx := range rm { + for path, reps := range byTargetIdx { + o := rawTargetObjects[targetIdx] + nodes, err := o.JSONPath(path) + if err != nil { + return nil, fmt.Errorf("failed to find path %v: %w", path, err) + } + if len(nodes) != 1 { + return nil, fmt.Errorf("expected one lead node at path %v but got %v", path, len(nodes)) + } + n := nodes[0] + if !n.IsString() { + return nil, fmt.Errorf("only string type leaf notes supported currently, %v is not a string", path) + } + s := []rune(n.String()) + s = s[1 : len(s)-1] + var offSet int64 + for _, r := range reps { + value := []rune(FormatVariables(r.format, selectedValues[r.Key])) + if r.Position == nil { + return nil, fmt.Errorf("nil position not support yet, will be full replacement") + } + s = append(s[:r.Start+offSet], append(value, s[r.End+offSet:]...)...) + offSet += int64(len(value)) - (r.End - r.Start) + } + if err = n.SetString(string(s)); err != nil { + return nil, err + } + } + } + + for i, aT := range rawTargetObjects { + raw, err := ajson.Marshal(aT) + if err != nil { + return nil, err + } + u := data.DataQuery{} + err = u.UnmarshalJSON(raw) + if err != nil { + return nil, err + } + targets[i].Properties = u + } + + return targets, nil +} + +type replacement struct { + *Position + *TemplateVariable + format VariableFormat +} + +func getReplacementMap(qt QueryTemplate) map[int]map[string][]replacement { + byTargetPath := make(map[int]map[string][]replacement) + + varMap := make(map[string]*TemplateVariable, len(qt.Variables)) + for i, v := range qt.Variables { + varMap[v.Key] = &qt.Variables[i] + } + + for i, target := range qt.Targets { + if byTargetPath[i] == nil { + byTargetPath[i] = make(map[string][]replacement) + } + for k, vReps := range target.Variables { + for rI, rep := range vReps { + byTargetPath[i][rep.Path] = append(byTargetPath[i][rep.Path], + replacement{ + Position: vReps[rI].Position, + TemplateVariable: varMap[k], + format: rep.Format, + }, + ) + } + } + } + + for idx, byTargetIdx := range byTargetPath { + for path := range byTargetIdx { + sort.Slice(byTargetPath[idx][path], func(i, j int) bool { + return byTargetPath[idx][path][i].Start < byTargetPath[idx][path][j].Start + }) + } + } + + return byTargetPath +} diff --git a/pkg/apis/query/v0alpha1/template/render_test.go b/pkg/apis/query/v0alpha1/template/render_test.go new file mode 100644 index 0000000000000..465056247c8e5 --- /dev/null +++ b/pkg/apis/query/v0alpha1/template/render_test.go @@ -0,0 +1,210 @@ +package template + +import ( + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/data" + apidata "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "github.com/stretchr/testify/require" +) + +var nestedFieldRender = QueryTemplate{ + Title: "Test", + Variables: []TemplateVariable{ + { + Key: "metricName", + }, + }, + Targets: []Target{ + { + DataType: data.FrameTypeUnknown, + //DataTypeVersion: data.FrameTypeVersion{0, 0}, + + Variables: map[string][]VariableReplacement{ + "metricName": { + { + Path: "$.nestedObject.anArray[0]", + Position: &Position{ + Start: 0, + End: 3, + }, + }, + }, + }, + Properties: apidata.NewDataQuery(map[string]any{ + "nestedObject": map[string]any{ + "anArray": []any{"foo", .2}, + }, + }), + }, + }, +} + +var nestedFieldRenderedTargets = []Target{ + { + DataType: data.FrameTypeUnknown, + Variables: map[string][]VariableReplacement{ + "metricName": { + { + Path: "$.nestedObject.anArray[0]", + Position: &Position{ + Start: 0, + End: 3, + }, + }, + }, + }, + //DataTypeVersion: data.FrameTypeVersion{0, 0}, + Properties: apidata.NewDataQuery( + map[string]any{ + "nestedObject": map[string]any{ + "anArray": []any{"up", .2}, + }, + }), + }, +} + +func TestNestedFieldRender(t *testing.T) { + rT, err := RenderTemplate(nestedFieldRender, map[string][]string{"metricName": {"up"}}) + require.NoError(t, err) + require.Equal(t, + nestedFieldRenderedTargets, + rT, + ) +} + +var multiVarTemplate = QueryTemplate{ + Title: "Test", + Variables: []TemplateVariable{ + { + Key: "metricName", + }, + { + Key: "anotherMetric", + }, + }, + Targets: []Target{ + { + DataType: data.FrameTypeUnknown, + //DataTypeVersion: data.FrameTypeVersion{0, 0}, + + Variables: map[string][]VariableReplacement{ + "metricName": { + { + Path: "$.expr", + Position: &Position{ + Start: 4, + End: 14, + }, + }, + { + Path: "$.expr", + Position: &Position{ + Start: 37, + End: 47, + }, + }, + }, + "anotherMetric": { + { + Path: "$.expr", + Position: &Position{ + Start: 21, + End: 34, + }, + }, + }, + }, + + Properties: apidata.NewDataQuery(map[string]any{ + "expr": "1 + metricName + 1 + anotherMetric + metricName", + }), + }, + }, +} + +var multiVarRenderedTargets = []Target{ + { + DataType: data.FrameTypeUnknown, + Variables: map[string][]VariableReplacement{ + "metricName": { + { + Path: "$.expr", + Position: &Position{ + Start: 4, + End: 14, + }, + }, + { + Path: "$.expr", + Position: &Position{ + Start: 37, + End: 47, + }, + }, + }, + "anotherMetric": { + { + Path: "$.expr", + Position: &Position{ + Start: 21, + End: 34, + }, + }, + }, + }, + //DataTypeVersion: data.FrameTypeVersion{0, 0}, + Properties: apidata.NewDataQuery(map[string]any{ + "expr": "1 + up + 1 + sloths_do_like_a_good_nap + up", + }), + }, +} + +func TestMultiVarTemplate(t *testing.T) { + rT, err := RenderTemplate(multiVarTemplate, map[string][]string{ + "metricName": {"up"}, + "anotherMetric": {"sloths_do_like_a_good_nap"}, + }) + require.NoError(t, err) + require.Equal(t, + multiVarRenderedTargets, + rT, + ) +} + +func TestRenderWithRune(t *testing.T) { + qt := QueryTemplate{ + Variables: []TemplateVariable{ + { + Key: "name", + }, + }, + Targets: []Target{ + { + Properties: apidata.NewDataQuery(map[string]any{ + "message": "🐦 name!", + }), + Variables: map[string][]VariableReplacement{ + "name": { + { + Path: "$.message", + Position: &Position{ + Start: 2, + End: 6, + }, + }, + }, + }, + }, + }, + } + + selectedValues := map[string][]string{ + "name": {"🦥"}, + } + + rq, err := RenderTemplate(qt, selectedValues) + require.NoError(t, err) + + require.Equal(t, "🐦 🦥!", rq[0].Properties.GetString("message")) +} diff --git a/pkg/apis/query/v0alpha1/template/types.go b/pkg/apis/query/v0alpha1/template/types.go new file mode 100644 index 0000000000000..07ef89493363d --- /dev/null +++ b/pkg/apis/query/v0alpha1/template/types.go @@ -0,0 +1,112 @@ +package template + +import ( + "github.com/grafana/grafana-plugin-sdk-go/data" + apidata "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +type QueryTemplate struct { + // A display name + Title string `json:"title,omitempty"` + + // Longer description for why it is interesting + Description string `json:"description,omitempty"` + + // The variables that can be used to render + // +listType=map + // +listMapKey=key + Variables []TemplateVariable `json:"vars,omitempty"` + + // Output variables + // +listType=set + Targets []Target `json:"targets"` +} + +type Target struct { + // DataType is the returned Dataplane type from the query. + DataType data.FrameType `json:"dataType,omitempty"` + + // DataTypeVersion is the version for the Dataplane type. + // TODO 2[uint] seems to panic, maybe implement DeepCopy on data.FrameTypeVersion? + // DataTypeVersion data.FrameTypeVersion `json:"dataTypeVersion,omitempty"` + + // Variables that will be replaced in the query + Variables map[string][]VariableReplacement `json:"variables"` + + // Query target + Properties apidata.DataQuery `json:"properties"` +} + +// TemplateVariable is the definition of a variable that will be interpolated +// in targets. +type TemplateVariable struct { + // Key is the name of the variable. + Key string `json:"key"` + + // DefaultValue is the value to be used when there is no selected value + // during render. + // +listType=atomic + DefaultValues []string `json:"defaultValues,omitempty"` + + // ValueListDefinition is the object definition used by the FE + // to get a list of possible values to select for render. + ValueListDefinition common.Unstructured `json:"valueListDefinition,omitempty"` +} + +// QueryVariable is the definition of a variable that will be interpolated +// in targets. +type VariableReplacement struct { + // Path is the location of the property within a target. + // The format for this is not figured out yet (Maybe JSONPath?). + // Idea: ["string", int, "string"] where int indicates array offset + Path string `json:"path"` + + // Positions is a list of where to perform the interpolation + // within targets during render. + // The first string is the Idx of the target as a string, since openAPI + // does not support ints as map keys + Position *Position `json:"position,omitempty"` + + // How values should be interpolated + Format VariableFormat `json:"format,omitempty"` +} + +// Define how to format values in the template. +// See: https://grafana.com/docs/grafana/latest/dashboards/variables/variable-syntax/#advanced-variable-format-options +// +enum +type VariableFormat string + +// Defines values for ItemType. +const ( + // Formats variables with multiple values as a comma-separated string. + FormatCSV VariableFormat = "csv" + + // Formats variables with multiple values as a comma-separated string. + FormatJSON VariableFormat = "json" + + // Formats single- and multi-valued variables into a comma-separated string + FormatDoubleQuote VariableFormat = "doublequote" + + // Formats single- and multi-valued variables into a comma-separated string + FormatSingleQuote VariableFormat = "singlequote" + + // Formats variables with multiple values into a pipe-separated string. + FormatPipe VariableFormat = "pipe" + + // Formats variables with multiple values into comma-separated string. + // This is the default behavior when no format is specified + FormatRaw VariableFormat = "raw" +) + +// Position is where to do replacement in the targets +// during render. +type Position struct { + // Start is the byte offset within TargetKey's property of the variable. + // It is the start location for replacements). + Start int64 `json:"start"` // TODO: byte, rune? + + // End is the byte offset of the end of the variable. + End int64 `json:"end"` +} diff --git a/pkg/apis/query/v0alpha1/template/zz_generated.deepcopy.go b/pkg/apis/query/v0alpha1/template/zz_generated.deepcopy.go new file mode 100644 index 0000000000000..e8337fcdc34d2 --- /dev/null +++ b/pkg/apis/query/v0alpha1/template/zz_generated.deepcopy.go @@ -0,0 +1,131 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package template + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Position) DeepCopyInto(out *Position) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Position. +func (in *Position) DeepCopy() *Position { + if in == nil { + return nil + } + out := new(Position) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *QueryTemplate) DeepCopyInto(out *QueryTemplate) { + *out = *in + if in.Variables != nil { + in, out := &in.Variables, &out.Variables + *out = make([]TemplateVariable, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Targets != nil { + in, out := &in.Targets, &out.Targets + *out = make([]Target, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new QueryTemplate. +func (in *QueryTemplate) DeepCopy() *QueryTemplate { + if in == nil { + return nil + } + out := new(QueryTemplate) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Target) DeepCopyInto(out *Target) { + *out = *in + if in.Variables != nil { + in, out := &in.Variables, &out.Variables + *out = make(map[string][]VariableReplacement, len(*in)) + for key, val := range *in { + var outVal []VariableReplacement + if val == nil { + (*out)[key] = nil + } else { + in, out := &val, &outVal + *out = make([]VariableReplacement, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + (*out)[key] = outVal + } + } + in.Properties.DeepCopyInto(&out.Properties) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Target. +func (in *Target) DeepCopy() *Target { + if in == nil { + return nil + } + out := new(Target) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TemplateVariable) DeepCopyInto(out *TemplateVariable) { + *out = *in + if in.DefaultValues != nil { + in, out := &in.DefaultValues, &out.DefaultValues + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.ValueListDefinition.DeepCopyInto(&out.ValueListDefinition) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemplateVariable. +func (in *TemplateVariable) DeepCopy() *TemplateVariable { + if in == nil { + return nil + } + out := new(TemplateVariable) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VariableReplacement) DeepCopyInto(out *VariableReplacement) { + *out = *in + if in.Position != nil { + in, out := &in.Position, &out.Position + *out = new(Position) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VariableReplacement. +func (in *VariableReplacement) DeepCopy() *VariableReplacement { + if in == nil { + return nil + } + out := new(VariableReplacement) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/apis/query/v0alpha1/template/zz_generated.defaults.go b/pkg/apis/query/v0alpha1/template/zz_generated.defaults.go new file mode 100644 index 0000000000000..f50cedd63968b --- /dev/null +++ b/pkg/apis/query/v0alpha1/template/zz_generated.defaults.go @@ -0,0 +1,19 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by defaulter-gen. DO NOT EDIT. + +package template + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// RegisterDefaults adds defaulters functions to the given scheme. +// Public to allow building arbitrary schemes. +// All generated defaulters are covering - they call all nested defaulters. +func RegisterDefaults(scheme *runtime.Scheme) error { + return nil +} diff --git a/pkg/apis/query/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/query/v0alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000000000..8f36003313da2 --- /dev/null +++ b/pkg/apis/query/v0alpha1/zz_generated.deepcopy.go @@ -0,0 +1,188 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DataSourceApiServer) DeepCopyInto(out *DataSourceApiServer) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if in.AliasIDs != nil { + in, out := &in.AliasIDs, &out.AliasIDs + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSourceApiServer. +func (in *DataSourceApiServer) DeepCopy() *DataSourceApiServer { + if in == nil { + return nil + } + out := new(DataSourceApiServer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DataSourceApiServer) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DataSourceApiServerList) DeepCopyInto(out *DataSourceApiServerList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DataSourceApiServer, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSourceApiServerList. +func (in *DataSourceApiServerList) DeepCopy() *DataSourceApiServerList { + if in == nil { + return nil + } + out := new(DataSourceApiServerList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DataSourceApiServerList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *QueryDataRequest) DeepCopyInto(out *QueryDataRequest) { + *out = *in + out.TypeMeta = in.TypeMeta + in.QueryDataRequest.DeepCopyInto(&out.QueryDataRequest) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new QueryDataRequest. +func (in *QueryDataRequest) DeepCopy() *QueryDataRequest { + if in == nil { + return nil + } + out := new(QueryDataRequest) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *QueryDataRequest) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *QueryDataResponse) DeepCopyInto(out *QueryDataResponse) { + *out = *in + out.TypeMeta = in.TypeMeta + in.QueryDataResponse.DeepCopyInto(&out.QueryDataResponse) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new QueryDataResponse. +func (in *QueryDataResponse) DeepCopy() *QueryDataResponse { + if in == nil { + return nil + } + out := new(QueryDataResponse) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *QueryDataResponse) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *QueryTypeDefinition) DeepCopyInto(out *QueryTypeDefinition) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new QueryTypeDefinition. +func (in *QueryTypeDefinition) DeepCopy() *QueryTypeDefinition { + if in == nil { + return nil + } + out := new(QueryTypeDefinition) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *QueryTypeDefinition) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *QueryTypeDefinitionList) DeepCopyInto(out *QueryTypeDefinitionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]QueryTypeDefinition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new QueryTypeDefinitionList. +func (in *QueryTypeDefinitionList) DeepCopy() *QueryTypeDefinitionList { + if in == nil { + return nil + } + out := new(QueryTypeDefinitionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *QueryTypeDefinitionList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} diff --git a/pkg/apis/query/v0alpha1/zz_generated.defaults.go b/pkg/apis/query/v0alpha1/zz_generated.defaults.go new file mode 100644 index 0000000000000..238fc2f4edcfe --- /dev/null +++ b/pkg/apis/query/v0alpha1/zz_generated.defaults.go @@ -0,0 +1,19 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by defaulter-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// RegisterDefaults adds defaulters functions to the given scheme. +// Public to allow building arbitrary schemes. +// All generated defaulters are covering - they call all nested defaulters. +func RegisterDefaults(scheme *runtime.Scheme) error { + return nil +} diff --git a/pkg/apis/query/v0alpha1/zz_generated.openapi.go b/pkg/apis/query/v0alpha1/zz_generated.openapi.go new file mode 100644 index 0000000000000..3c8db96187bb9 --- /dev/null +++ b/pkg/apis/query/v0alpha1/zz_generated.openapi.go @@ -0,0 +1,625 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by openapi-gen. DO NOT EDIT. + +// This file was autogenerated by openapi-gen. Do not edit it manually! + +package v0alpha1 + +import ( + common "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" +) + +func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + return map[string]common.OpenAPIDefinition{ + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceApiServer": schema_pkg_apis_query_v0alpha1_DataSourceApiServer(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceApiServerList": schema_pkg_apis_query_v0alpha1_DataSourceApiServerList(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryDataRequest": schema_pkg_apis_query_v0alpha1_QueryDataRequest(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryDataResponse": schema_pkg_apis_query_v0alpha1_QueryDataResponse(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinition": schema_pkg_apis_query_v0alpha1_QueryTypeDefinition(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinitionList": schema_pkg_apis_query_v0alpha1_QueryTypeDefinitionList(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.Position": schema_apis_query_v0alpha1_template_Position(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.QueryTemplate": schema_apis_query_v0alpha1_template_QueryTemplate(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.Target": schema_apis_query_v0alpha1_template_Target(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.TemplateVariable": schema_apis_query_v0alpha1_template_TemplateVariable(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.VariableReplacement": schema_apis_query_v0alpha1_template_VariableReplacement(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.replacement": schema_apis_query_v0alpha1_template_replacement(ref), + } +} + +func schema_pkg_apis_query_v0alpha1_DataSourceApiServer(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "The data source resource is a reflection of the individual datasource instances that are exposed in the groups: {datasource}.datasource.grafana.app The status is updated periodically. The name is the plugin id", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "title": { + SchemaProps: spec.SchemaProps{ + Description: "The display name", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "description": { + SchemaProps: spec.SchemaProps{ + Description: "Describe the plugin", + Type: []string{"string"}, + Format: "", + }, + }, + "groupVersion": { + SchemaProps: spec.SchemaProps{ + Description: "The group + preferred version", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "aliasIDs": { + SchemaProps: spec.SchemaProps{ + Description: "Possible alternative plugin IDs", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + }, + Required: []string{"title", "groupVersion"}, + }, + }, + Dependencies: []string{ + "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_query_v0alpha1_DataSourceApiServerList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "List of datasource plugins", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceApiServer"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceApiServer", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_query_v0alpha1_QueryDataRequest(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Generic query request with shared time across all values Copied from: https://github.com/grafana/grafana/blob/main/pkg/api/dtos/models.go#L62", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "from": { + SchemaProps: spec.SchemaProps{ + Description: "From is the start time of the query.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "to": { + SchemaProps: spec.SchemaProps{ + Description: "To is the end time of the query.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "queries": { + SchemaProps: spec.SchemaProps{ + Description: "Datasource queries", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataQuery"), + }, + }, + }, + }, + }, + "debug": { + SchemaProps: spec.SchemaProps{ + Description: "Optionally include debug information in the response", + Type: []string{"boolean"}, + Format: "", + }, + }, + }, + Required: []string{"from", "to", "queries"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataQuery"}, + } +} + +func schema_pkg_apis_query_v0alpha1_QueryDataResponse(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Wraps backend.QueryDataResponse, however it includes TypeMeta and implements runtime.Object", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "results": { + SchemaProps: spec.SchemaProps{ + Description: "Responses is a map of RefIDs (Unique Query ID) to *DataResponse.", + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Allows: true, + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana-plugin-sdk-go/backend.DataResponse"), + }, + }, + }, + }, + }, + }, + Required: []string{"results"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana-plugin-sdk-go/backend.DataResponse"}, + } +} + +func schema_pkg_apis_query_v0alpha1_QueryTypeDefinition(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Generic query request with shared time across all values", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.QueryTypeDefinitionSpec"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.QueryTypeDefinitionSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_query_v0alpha1_QueryTypeDefinitionList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinition"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinition", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_apis_query_v0alpha1_template_Position(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Position is where to do replacement in the targets during render.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "start": { + SchemaProps: spec.SchemaProps{ + Description: "Start is the byte offset within TargetKey's property of the variable. It is the start location for replacements).", + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "end": { + SchemaProps: spec.SchemaProps{ + Description: "End is the byte offset of the end of the variable.", + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + }, + Required: []string{"start", "end"}, + }, + }, + } +} + +func schema_apis_query_v0alpha1_template_QueryTemplate(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "title": { + SchemaProps: spec.SchemaProps{ + Description: "A display name", + Type: []string{"string"}, + Format: "", + }, + }, + "description": { + SchemaProps: spec.SchemaProps{ + Description: "Longer description for why it is interesting", + Type: []string{"string"}, + Format: "", + }, + }, + "vars": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-map-keys": []interface{}{ + "key", + }, + "x-kubernetes-list-type": "map", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "The variables that can be used to render", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.TemplateVariable"), + }, + }, + }, + }, + }, + "targets": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "set", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "Output variables", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.Target"), + }, + }, + }, + }, + }, + }, + Required: []string{"targets"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.Target", "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.TemplateVariable"}, + } +} + +func schema_apis_query_v0alpha1_template_Target(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "dataType": { + SchemaProps: spec.SchemaProps{ + Description: "DataType is the returned Dataplane type from the query.", + Type: []string{"string"}, + Format: "", + }, + }, + "variables": { + SchemaProps: spec.SchemaProps{ + Description: "Variables that will be replaced in the query", + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Allows: true, + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.VariableReplacement"), + }, + }, + }, + }, + }, + }, + }, + }, + "properties": { + SchemaProps: spec.SchemaProps{ + Description: "Query target", + Ref: ref("github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataQuery"), + }, + }, + }, + Required: []string{"variables", "properties"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataQuery", "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.VariableReplacement"}, + } +} + +func schema_apis_query_v0alpha1_template_TemplateVariable(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "TemplateVariable is the definition of a variable that will be interpolated in targets.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "key": { + SchemaProps: spec.SchemaProps{ + Description: "Key is the name of the variable.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "defaultValues": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "DefaultValue is the value to be used when there is no selected value during render.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + "valueListDefinition": { + SchemaProps: spec.SchemaProps{ + Description: "ValueListDefinition is the object definition used by the FE to get a list of possible values to select for render.", + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"), + }, + }, + }, + Required: []string{"key"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"}, + } +} + +func schema_apis_query_v0alpha1_template_VariableReplacement(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "QueryVariable is the definition of a variable that will be interpolated in targets.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "path": { + SchemaProps: spec.SchemaProps{ + Description: "Path is the location of the property within a target. The format for this is not figured out yet (Maybe JSONPath?). Idea: [\"string\", int, \"string\"] where int indicates array offset", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "position": { + SchemaProps: spec.SchemaProps{ + Description: "Positions is a list of where to perform the interpolation within targets during render. The first string is the Idx of the target as a string, since openAPI does not support ints as map keys", + Ref: ref("github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.Position"), + }, + }, + "format": { + SchemaProps: spec.SchemaProps{ + Description: "How values should be interpolated\n\nPossible enum values:\n - `\"csv\"` Formats variables with multiple values as a comma-separated string.\n - `\"doublequote\"` Formats single- and multi-valued variables into a comma-separated string\n - `\"json\"` Formats variables with multiple values as a comma-separated string.\n - `\"pipe\"` Formats variables with multiple values into a pipe-separated string.\n - `\"raw\"` Formats variables with multiple values into comma-separated string. This is the default behavior when no format is specified\n - `\"singlequote\"` Formats single- and multi-valued variables into a comma-separated string", + Type: []string{"string"}, + Format: "", + Enum: []interface{}{"csv", "doublequote", "json", "pipe", "raw", "singlequote"}, + }, + }, + }, + Required: []string{"path"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.Position"}, + } +} + +func schema_apis_query_v0alpha1_template_replacement(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "Position": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.Position"), + }, + }, + "TemplateVariable": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.TemplateVariable"), + }, + }, + "format": { + SchemaProps: spec.SchemaProps{ + Description: "Possible enum values:\n - `\"csv\"` Formats variables with multiple values as a comma-separated string.\n - `\"doublequote\"` Formats single- and multi-valued variables into a comma-separated string\n - `\"json\"` Formats variables with multiple values as a comma-separated string.\n - `\"pipe\"` Formats variables with multiple values into a pipe-separated string.\n - `\"raw\"` Formats variables with multiple values into comma-separated string. This is the default behavior when no format is specified\n - `\"singlequote\"` Formats single- and multi-valued variables into a comma-separated string", + Default: "", + Type: []string{"string"}, + Format: "", + Enum: []interface{}{"csv", "doublequote", "json", "pipe", "raw", "singlequote"}, + }, + }, + }, + Required: []string{"Position", "TemplateVariable", "format"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.Position", "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template.TemplateVariable"}, + } +} diff --git a/pkg/apis/query/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/query/v0alpha1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 0000000000000..ccded35944675 --- /dev/null +++ b/pkg/apis/query/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -0,0 +1,5 @@ +API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/query/v0alpha1,DataSourceApiServer,AliasIDs +API rule violation: names_match,github.com/grafana/grafana/pkg/apis/query/v0alpha1/template,QueryTemplate,Variables +API rule violation: names_match,github.com/grafana/grafana/pkg/apis/query/v0alpha1/template,replacement,Position +API rule violation: names_match,github.com/grafana/grafana/pkg/apis/query/v0alpha1/template,replacement,TemplateVariable +API rule violation: names_match,github.com/grafana/grafana/pkg/apis/query/v0alpha1/template,replacement,format diff --git a/pkg/apis/scope/v0alpha1/doc.go b/pkg/apis/scope/v0alpha1/doc.go new file mode 100644 index 0000000000000..f9f43a0ea3822 --- /dev/null +++ b/pkg/apis/scope/v0alpha1/doc.go @@ -0,0 +1,6 @@ +// +k8s:deepcopy-gen=package +// +k8s:openapi-gen=true +// +k8s:defaulter-gen=TypeMeta +// +groupName=scope.grafana.app + +package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/scope/v0alpha1" diff --git a/pkg/apis/scope/v0alpha1/register.go b/pkg/apis/scope/v0alpha1/register.go new file mode 100644 index 0000000000000..5868562a84a9d --- /dev/null +++ b/pkg/apis/scope/v0alpha1/register.go @@ -0,0 +1,58 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +const ( + GROUP = "scope.grafana.app" + VERSION = "v0alpha1" + APIVERSION = GROUP + "/" + VERSION +) + +var ScopeResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "scopes", "scope", "Scope", + func() runtime.Object { return &Scope{} }, + func() runtime.Object { return &ScopeList{} }, +) + +var ScopeDashboardResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "scopedashboards", "scopedashboard", "ScopeDashboard", + func() runtime.Object { return &ScopeDashboard{} }, + func() runtime.Object { return &ScopeDashboardList{} }, +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} + + // SchemaBuilder is used by standard codegen + SchemeBuilder runtime.SchemeBuilder + localSchemeBuilder = &SchemeBuilder + AddToScheme = localSchemeBuilder.AddToScheme +) + +func init() { + localSchemeBuilder.Register(addKnownTypes) +} + +// Adds the list of known types to the given scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &Scope{}, + &ScopeList{}, + &ScopeDashboard{}, + &ScopeDashboardList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} diff --git a/pkg/apis/scope/v0alpha1/types.go b/pkg/apis/scope/v0alpha1/types.go new file mode 100644 index 0000000000000..9ed2bb0742e9b --- /dev/null +++ b/pkg/apis/scope/v0alpha1/types.go @@ -0,0 +1,56 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type Scope struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ScopeSpec `json:"spec,omitempty"` +} + +type ScopeSpec struct { + Title string `json:"title"` + Type string `json:"type"` + Description string `json:"description"` + Category string `json:"category"` + Filters []ScopeFilter `json:"filters"` +} + +type ScopeFilter struct { + Key string `json:"key"` + Value string `json:"value"` + Operator string `json:"operator"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ScopeList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []Scope `json:"items,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ScopeDashboard struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ScopeDashboardSpec `json:"spec,omitempty"` +} + +type ScopeDashboardSpec struct { + DashboardUIDs []string `json:"dashboardUids"` + ScopeUID string `json:"scopeUid"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ScopeDashboardList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []ScopeDashboard `json:"items,omitempty"` +} diff --git a/pkg/apis/scope/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/scope/v0alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000000000..77bc6b7498e12 --- /dev/null +++ b/pkg/apis/scope/v0alpha1/zz_generated.deepcopy.go @@ -0,0 +1,190 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Scope) DeepCopyInto(out *Scope) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Scope. +func (in *Scope) DeepCopy() *Scope { + if in == nil { + return nil + } + out := new(Scope) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Scope) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScopeDashboard) DeepCopyInto(out *ScopeDashboard) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeDashboard. +func (in *ScopeDashboard) DeepCopy() *ScopeDashboard { + if in == nil { + return nil + } + out := new(ScopeDashboard) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ScopeDashboard) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScopeDashboardList) DeepCopyInto(out *ScopeDashboardList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ScopeDashboard, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeDashboardList. +func (in *ScopeDashboardList) DeepCopy() *ScopeDashboardList { + if in == nil { + return nil + } + out := new(ScopeDashboardList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ScopeDashboardList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScopeDashboardSpec) DeepCopyInto(out *ScopeDashboardSpec) { + *out = *in + if in.DashboardUIDs != nil { + in, out := &in.DashboardUIDs, &out.DashboardUIDs + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeDashboardSpec. +func (in *ScopeDashboardSpec) DeepCopy() *ScopeDashboardSpec { + if in == nil { + return nil + } + out := new(ScopeDashboardSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScopeFilter) DeepCopyInto(out *ScopeFilter) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeFilter. +func (in *ScopeFilter) DeepCopy() *ScopeFilter { + if in == nil { + return nil + } + out := new(ScopeFilter) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScopeList) DeepCopyInto(out *ScopeList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Scope, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeList. +func (in *ScopeList) DeepCopy() *ScopeList { + if in == nil { + return nil + } + out := new(ScopeList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ScopeList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScopeSpec) DeepCopyInto(out *ScopeSpec) { + *out = *in + if in.Filters != nil { + in, out := &in.Filters, &out.Filters + *out = make([]ScopeFilter, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeSpec. +func (in *ScopeSpec) DeepCopy() *ScopeSpec { + if in == nil { + return nil + } + out := new(ScopeSpec) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/apis/scope/v0alpha1/zz_generated.defaults.go b/pkg/apis/scope/v0alpha1/zz_generated.defaults.go new file mode 100644 index 0000000000000..238fc2f4edcfe --- /dev/null +++ b/pkg/apis/scope/v0alpha1/zz_generated.defaults.go @@ -0,0 +1,19 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by defaulter-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// RegisterDefaults adds defaulters functions to the given scheme. +// Public to allow building arbitrary schemes. +// All generated defaulters are covering - they call all nested defaulters. +func RegisterDefaults(scheme *runtime.Scheme) error { + return nil +} diff --git a/pkg/apis/scope/v0alpha1/zz_generated.openapi.go b/pkg/apis/scope/v0alpha1/zz_generated.openapi.go new file mode 100644 index 0000000000000..65f7bfdb5c8af --- /dev/null +++ b/pkg/apis/scope/v0alpha1/zz_generated.openapi.go @@ -0,0 +1,325 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by openapi-gen. DO NOT EDIT. + +// This file was autogenerated by openapi-gen. Do not edit it manually! + +package v0alpha1 + +import ( + common "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" +) + +func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + return map[string]common.OpenAPIDefinition{ + "github.com/grafana/grafana/pkg/apis/scope/v0alpha1.Scope": schema_pkg_apis_scope_v0alpha1_Scope(ref), + "github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeDashboard": schema_pkg_apis_scope_v0alpha1_ScopeDashboard(ref), + "github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeDashboardList": schema_pkg_apis_scope_v0alpha1_ScopeDashboardList(ref), + "github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeDashboardSpec": schema_pkg_apis_scope_v0alpha1_ScopeDashboardSpec(ref), + "github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeFilter": schema_pkg_apis_scope_v0alpha1_ScopeFilter(ref), + "github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeList": schema_pkg_apis_scope_v0alpha1_ScopeList(ref), + "github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeSpec": schema_pkg_apis_scope_v0alpha1_ScopeSpec(ref), + } +} + +func schema_pkg_apis_scope_v0alpha1_Scope(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeSpec"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeDashboard(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeDashboardSpec"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeDashboardSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeDashboardList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeDashboard"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeDashboard", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeDashboardSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "dashboardUids": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + "scopeUid": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"dashboardUids", "scopeUid"}, + }, + }, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeFilter(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "key": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "value": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "operator": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"key", "value", "operator"}, + }, + }, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/scope/v0alpha1.Scope"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/scope/v0alpha1.Scope", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "title": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "type": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "description": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "category": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "filters": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeFilter"), + }, + }, + }, + }, + }, + }, + Required: []string{"title", "type", "description", "category", "filters"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeFilter"}, + } +} diff --git a/pkg/apis/scope/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/scope/v0alpha1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 0000000000000..55e0bf15fe692 --- /dev/null +++ b/pkg/apis/scope/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -0,0 +1,4 @@ +API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/scope/v0alpha1,ScopeDashboardSpec,DashboardUIDs +API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/scope/v0alpha1,ScopeSpec,Filters +API rule violation: names_match,github.com/grafana/grafana/pkg/apis/scope/v0alpha1,ScopeDashboardSpec,DashboardUIDs +API rule violation: names_match,github.com/grafana/grafana/pkg/apis/scope/v0alpha1,ScopeDashboardSpec,ScopeUID diff --git a/pkg/apis/service/v0alpha1/doc.go b/pkg/apis/service/v0alpha1/doc.go new file mode 100644 index 0000000000000..a334e2f5a3bb1 --- /dev/null +++ b/pkg/apis/service/v0alpha1/doc.go @@ -0,0 +1,6 @@ +// +k8s:deepcopy-gen=package +// +k8s:openapi-gen=true +// +k8s:defaulter-gen=TypeMeta +// +groupName=service.grafana.app + +package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/service/v0alpha1" diff --git a/pkg/apis/service/v0alpha1/register.go b/pkg/apis/service/v0alpha1/register.go new file mode 100644 index 0000000000000..530b1914feba5 --- /dev/null +++ b/pkg/apis/service/v0alpha1/register.go @@ -0,0 +1,50 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +const ( + GROUP = "service.grafana.app" + VERSION = "v0alpha1" + APIVERSION = GROUP + "/" + VERSION +) + +var ExternalNameResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "externalnames", "externalname", "ExternalName", + func() runtime.Object { return &ExternalName{} }, + func() runtime.Object { return &ExternalNameList{} }, +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} + + // SchemaBuilder is used by standard codegen + SchemeBuilder runtime.SchemeBuilder + localSchemeBuilder = &SchemeBuilder + AddToScheme = localSchemeBuilder.AddToScheme +) + +func init() { + localSchemeBuilder.Register(addKnownTypes) +} + +// Adds the list of known types to the given scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &ExternalName{}, + &ExternalNameList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} diff --git a/pkg/apis/service/v0alpha1/types.go b/pkg/apis/service/v0alpha1/types.go new file mode 100644 index 0000000000000..ae2e4fd4581e5 --- /dev/null +++ b/pkg/apis/service/v0alpha1/types.go @@ -0,0 +1,27 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ExternalName struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ExternalNameSpec `json:"spec,omitempty"` +} + +type ExternalNameSpec struct { + Host string `json:"host,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ExternalNameList struct { + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ListMeta `json:"metadata,omitempty"` + + Items []ExternalName `json:"items,omitempty"` +} diff --git a/pkg/apis/service/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/service/v0alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000000000..215d8218c151b --- /dev/null +++ b/pkg/apis/service/v0alpha1/zz_generated.deepcopy.go @@ -0,0 +1,88 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalName) DeepCopyInto(out *ExternalName) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalName. +func (in *ExternalName) DeepCopy() *ExternalName { + if in == nil { + return nil + } + out := new(ExternalName) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ExternalName) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalNameList) DeepCopyInto(out *ExternalNameList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ExternalName, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalNameList. +func (in *ExternalNameList) DeepCopy() *ExternalNameList { + if in == nil { + return nil + } + out := new(ExternalNameList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ExternalNameList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalNameSpec) DeepCopyInto(out *ExternalNameSpec) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalNameSpec. +func (in *ExternalNameSpec) DeepCopy() *ExternalNameSpec { + if in == nil { + return nil + } + out := new(ExternalNameSpec) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/apis/service/v0alpha1/zz_generated.defaults.go b/pkg/apis/service/v0alpha1/zz_generated.defaults.go new file mode 100644 index 0000000000000..238fc2f4edcfe --- /dev/null +++ b/pkg/apis/service/v0alpha1/zz_generated.defaults.go @@ -0,0 +1,19 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by defaulter-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// RegisterDefaults adds defaulters functions to the given scheme. +// Public to allow building arbitrary schemes. +// All generated defaulters are covering - they call all nested defaulters. +func RegisterDefaults(scheme *runtime.Scheme) error { + return nil +} diff --git a/pkg/apis/service/v0alpha1/zz_generated.openapi.go b/pkg/apis/service/v0alpha1/zz_generated.openapi.go new file mode 100644 index 0000000000000..bf951cc52c201 --- /dev/null +++ b/pkg/apis/service/v0alpha1/zz_generated.openapi.go @@ -0,0 +1,128 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by openapi-gen. DO NOT EDIT. + +// This file was autogenerated by openapi-gen. Do not edit it manually! + +package v0alpha1 + +import ( + common "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" +) + +func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + return map[string]common.OpenAPIDefinition{ + "github.com/grafana/grafana/pkg/apis/service/v0alpha1.ExternalName": schema_pkg_apis_service_v0alpha1_ExternalName(ref), + "github.com/grafana/grafana/pkg/apis/service/v0alpha1.ExternalNameList": schema_pkg_apis_service_v0alpha1_ExternalNameList(ref), + "github.com/grafana/grafana/pkg/apis/service/v0alpha1.ExternalNameSpec": schema_pkg_apis_service_v0alpha1_ExternalNameSpec(ref), + } +} + +func schema_pkg_apis_service_v0alpha1_ExternalName(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/service/v0alpha1.ExternalNameSpec"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/service/v0alpha1.ExternalNameSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_service_v0alpha1_ExternalNameList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/service/v0alpha1.ExternalName"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/service/v0alpha1.ExternalName", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_service_v0alpha1_ExternalNameSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "host": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} diff --git a/pkg/services/grafana-apiserver/common.go b/pkg/apiserver/builder/common.go similarity index 84% rename from pkg/services/grafana-apiserver/common.go rename to pkg/apiserver/builder/common.go index 44661d9658d63..0f380c1eafe46 100644 --- a/pkg/services/grafana-apiserver/common.go +++ b/pkg/apiserver/builder/common.go @@ -1,4 +1,4 @@ -package grafanaapiserver +package builder import ( "net/http" @@ -25,8 +25,9 @@ type APIGroupBuilder interface { // Build the group+version behavior GetAPIGroupInfo( scheme *runtime.Scheme, - codecs serializer.CodecFactory, // pointer? + codecs serializer.CodecFactory, optsGetter generic.RESTOptionsGetter, + dualWrite bool, ) (*genericapiserver.APIGroupInfo, error) // Get OpenAPI definitions @@ -41,6 +42,11 @@ type APIGroupBuilder interface { GetAuthorizer() authorizer.Authorizer } +// Builders that implement OpenAPIPostProcessor are given a chance to modify the schema directly +type OpenAPIPostProcessor interface { + PostProcessOpenAPI(*spec3.OpenAPI) (*spec3.OpenAPI, error) +} + // This is used to implement dynamic sub-resources like pods/x/logs type APIRouteHandler struct { Path string // added to the appropriate level @@ -57,3 +63,7 @@ type APIRoutes struct { // Namespace handlers are mounted under the namespace Namespace []APIRouteHandler } + +type APIRegistrar interface { + RegisterAPI(builder APIGroupBuilder) +} diff --git a/pkg/apiserver/builder/helper.go b/pkg/apiserver/builder/helper.go new file mode 100644 index 0000000000000..f41626e2e2a88 --- /dev/null +++ b/pkg/apiserver/builder/helper.go @@ -0,0 +1,153 @@ +package builder + +import ( + "fmt" + "net/http" + goruntime "runtime" + "runtime/debug" + "strconv" + "strings" + "time" + + "golang.org/x/mod/semver" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/version" + openapinamer "k8s.io/apiserver/pkg/endpoints/openapi" + "k8s.io/apiserver/pkg/registry/generic" + genericapiserver "k8s.io/apiserver/pkg/server" + "k8s.io/apiserver/pkg/util/openapi" + k8sscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/kube-openapi/pkg/common" + + "github.com/grafana/grafana/pkg/apiserver/endpoints/filters" +) + +func SetupConfig( + scheme *runtime.Scheme, + serverConfig *genericapiserver.RecommendedConfig, + builders []APIGroupBuilder, + buildTimestamp int64, + buildVersion string, + buildCommit string, + buildBranch string, +) error { + defsGetter := GetOpenAPIDefinitions(builders) + serverConfig.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig( + openapi.GetOpenAPIDefinitionsWithoutDisabledFeatures(defsGetter), + openapinamer.NewDefinitionNamer(scheme, k8sscheme.Scheme)) + + serverConfig.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config( + openapi.GetOpenAPIDefinitionsWithoutDisabledFeatures(defsGetter), + openapinamer.NewDefinitionNamer(scheme, k8sscheme.Scheme)) + + // Add the custom routes to service discovery + serverConfig.OpenAPIV3Config.PostProcessSpec = getOpenAPIPostProcessor(buildVersion, builders) + serverConfig.OpenAPIV3Config.GetOperationIDAndTagsFromRoute = func(r common.Route) (string, []string, error) { + tags := []string{} + prop, ok := r.Metadata()["x-kubernetes-group-version-kind"] + if ok { + gvk, ok := prop.(metav1.GroupVersionKind) + if ok && gvk.Kind != "" { + tags = append(tags, gvk.Kind) + } + } + return r.OperationName(), tags, nil + } + + // Set the swagger build versions + serverConfig.OpenAPIConfig.Info.Version = buildVersion + serverConfig.OpenAPIV3Config.Info.Version = buildVersion + + serverConfig.SkipOpenAPIInstallation = false + serverConfig.BuildHandlerChainFunc = func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler { + // Call DefaultBuildHandlerChain on the main entrypoint http.Handler + // See https://github.com/kubernetes/apiserver/blob/v0.28.0/pkg/server/config.go#L906 + // DefaultBuildHandlerChain provides many things, notably CORS, HSTS, cache-control, authz and latency tracking + requestHandler, err := getAPIHandler( + delegateHandler, + c.LoopbackClientConfig, + builders) + if err != nil { + panic(fmt.Sprintf("could not build handler chain func: %s", err.Error())) + } + + handler := genericapiserver.DefaultBuildHandlerChain(requestHandler, c) + handler = filters.WithAcceptHeader(handler) + + return handler + } + + k8sVersion, err := getK8sApiserverVersion() + if err != nil { + return err + } + before, after, _ := strings.Cut(buildVersion, ".") + serverConfig.Version = &version.Info{ + Major: before, + Minor: after, + GoVersion: goruntime.Version(), + Platform: fmt.Sprintf("%s/%s", goruntime.GOOS, goruntime.GOARCH), + Compiler: goruntime.Compiler, + GitTreeState: buildBranch, + GitCommit: buildCommit, + BuildDate: time.Unix(buildTimestamp, 0).UTC().Format(time.DateTime), + GitVersion: k8sVersion, + } + return nil +} + +func InstallAPIs( + scheme *runtime.Scheme, + codecs serializer.CodecFactory, + server *genericapiserver.GenericAPIServer, + optsGetter generic.RESTOptionsGetter, + builders []APIGroupBuilder, + dualWrite bool, +) error { + for _, b := range builders { + g, err := b.GetAPIGroupInfo(scheme, codecs, optsGetter, dualWrite) + if err != nil { + return err + } + if g == nil || len(g.PrioritizedVersions) < 1 { + continue + } + err = server.InstallAPIGroup(g) + if err != nil { + return err + } + } + return nil +} + +// find the k8s version according to build info +func getK8sApiserverVersion() (string, error) { + bi, ok := debug.ReadBuildInfo() + if !ok { + return "", fmt.Errorf("debug.ReadBuildInfo() failed") + } + + if len(bi.Deps) == 0 { + return "v?.?", nil // this is normal while debugging + } + + for _, dep := range bi.Deps { + if dep.Path == "k8s.io/apiserver" { + if !semver.IsValid(dep.Version) { + return "", fmt.Errorf("invalid semantic version for k8s.io/apiserver") + } + // v0 => v1 + majorVersion := strings.TrimPrefix(semver.Major(dep.Version), "v") + majorInt, err := strconv.Atoi(majorVersion) + if err != nil { + return "", fmt.Errorf("could not convert majorVersion to int. majorVersion: %s", majorVersion) + } + newMajor := fmt.Sprintf("v%d", majorInt+1) + return strings.Replace(dep.Version, semver.Major(dep.Version), newMajor, 1), nil + } + } + + return "", fmt.Errorf("could not find k8s.io/apiserver in build info") +} diff --git a/pkg/apiserver/builder/openapi.go b/pkg/apiserver/builder/openapi.go new file mode 100644 index 0000000000000..0790ba733aa07 --- /dev/null +++ b/pkg/apiserver/builder/openapi.go @@ -0,0 +1,115 @@ +package builder + +import ( + "maps" + "strings" + + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + common "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/spec3" + spec "k8s.io/kube-openapi/pkg/validation/spec" + + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +// This should eventually live in grafana-app-sdk +func GetOpenAPIDefinitions(builders []APIGroupBuilder) common.GetOpenAPIDefinitions { + return func(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + defs := v0alpha1.GetOpenAPIDefinitions(ref) // common grafana apis + maps.Copy(defs, data.GetOpenAPIDefinitions(ref)) + for _, b := range builders { + g := b.GetOpenAPIDefinitions() + if g != nil { + out := g(ref) + maps.Copy(defs, out) + } + } + return defs + } +} + +// Modify the OpenAPI spec to include the additional routes. +// Currently this requires: https://github.com/kubernetes/kube-openapi/pull/420 +// In future k8s release, the hook will use Config3 rather than the same hook for both v2 and v3 +func getOpenAPIPostProcessor(version string, builders []APIGroupBuilder) func(*spec3.OpenAPI) (*spec3.OpenAPI, error) { + return func(s *spec3.OpenAPI) (*spec3.OpenAPI, error) { + if s.Paths == nil { + return s, nil + } + for _, b := range builders { + routes := b.GetAPIRoutes() + gv := b.GetGroupVersion() + prefix := "/apis/" + gv.String() + "/" + if s.Paths.Paths[prefix] != nil { + copy := spec3.OpenAPI{ + Version: s.Version, + Info: &spec.Info{ + InfoProps: spec.InfoProps{ + Title: gv.String(), + Version: version, + }, + }, + Components: s.Components, + ExternalDocs: s.ExternalDocs, + Servers: s.Servers, + Paths: s.Paths, + } + + if routes == nil { + routes = &APIRoutes{} + } + + for _, route := range routes.Root { + copy.Paths.Paths[prefix+route.Path] = &spec3.Path{ + PathProps: *route.Spec, + } + } + + for _, route := range routes.Namespace { + copy.Paths.Paths[prefix+"namespaces/{namespace}/"+route.Path] = &spec3.Path{ + PathProps: *route.Spec, + } + } + + // Make the sub-resources (connect) share the same tags as the main resource + for path, spec := range copy.Paths.Paths { + idx := strings.LastIndex(path, "{name}/") + if idx > 0 { + parent := copy.Paths.Paths[path[:idx+6]] + if parent != nil && parent.Get != nil { + for _, op := range GetPathOperations(spec) { + if op != nil && op.Extensions != nil { + action, ok := op.Extensions.GetString("x-kubernetes-action") + if ok && action == "connect" { + op.Tags = parent.Get.Tags + } + } + } + } + } + } + + // Support direct manipulation of API results + processor, ok := b.(OpenAPIPostProcessor) + if ok { + return processor.PostProcessOpenAPI(©) + } + return ©, nil + } + } + return s, nil + } +} + +func GetPathOperations(path *spec3.Path) []*spec3.Operation { + return []*spec3.Operation{ + path.Get, + path.Head, + path.Delete, + path.Patch, + path.Post, + path.Put, + path.Trace, + path.Options, + } +} diff --git a/pkg/services/grafana-apiserver/request_handler.go b/pkg/apiserver/builder/request_handler.go similarity index 62% rename from pkg/services/grafana-apiserver/request_handler.go rename to pkg/apiserver/builder/request_handler.go index 175b7413083f2..38385d7b77828 100644 --- a/pkg/services/grafana-apiserver/request_handler.go +++ b/pkg/apiserver/builder/request_handler.go @@ -1,4 +1,4 @@ -package grafanaapiserver +package builder import ( "fmt" @@ -7,16 +7,13 @@ import ( "github.com/gorilla/mux" restclient "k8s.io/client-go/rest" "k8s.io/kube-openapi/pkg/spec3" - "k8s.io/kube-openapi/pkg/validation/spec" - - "github.com/grafana/grafana/pkg/setting" ) type requestHandler struct { router *mux.Router } -func GetAPIHandler(delegateHandler http.Handler, restConfig *restclient.Config, builders []APIGroupBuilder) (http.Handler, error) { +func getAPIHandler(delegateHandler http.Handler, restConfig *restclient.Config, builders []APIGroupBuilder) (http.Handler, error) { useful := false // only true if any routes exist anywhere router := mux.NewRouter() @@ -116,53 +113,3 @@ type methodNotAllowedHandler struct{} func (h *methodNotAllowedHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { w.WriteHeader(405) // method not allowed } - -// Modify the the OpenAPI spec to include the additional routes. -// Currently this requires: https://github.com/kubernetes/kube-openapi/pull/420 -// In future k8s release, the hook will use Config3 rather than the same hook for both v2 and v3 -func GetOpenAPIPostProcessor(builders []APIGroupBuilder) func(*spec3.OpenAPI) (*spec3.OpenAPI, error) { - return func(s *spec3.OpenAPI) (*spec3.OpenAPI, error) { - if s.Paths == nil { - return s, nil - } - for _, builder := range builders { - routes := builder.GetAPIRoutes() - gv := builder.GetGroupVersion() - prefix := "/apis/" + gv.String() + "/" - if s.Paths.Paths[prefix] != nil { - copy := spec3.OpenAPI{ - Version: s.Version, - Info: &spec.Info{ - InfoProps: spec.InfoProps{ - Title: gv.String(), - Version: setting.BuildVersion, - }, - }, - Components: s.Components, - ExternalDocs: s.ExternalDocs, - Servers: s.Servers, - Paths: s.Paths, - } - - if routes == nil { - routes = &APIRoutes{} - } - - for _, route := range routes.Root { - copy.Paths.Paths[prefix+route.Path] = &spec3.Path{ - PathProps: *route.Spec, - } - } - - for _, route := range routes.Namespace { - copy.Paths.Paths[prefix+"namespaces/{namespace}/"+route.Path] = &spec3.Path{ - PathProps: *route.Spec, - } - } - - return ©, nil - } - } - return s, nil - } -} diff --git a/pkg/apiserver/endpoints/filters/accept.go b/pkg/apiserver/endpoints/filters/accept.go new file mode 100644 index 0000000000000..8fa8f34bef21c --- /dev/null +++ b/pkg/apiserver/endpoints/filters/accept.go @@ -0,0 +1,15 @@ +package filters + +import ( + "net/http" + + "github.com/grafana/grafana/pkg/apiserver/endpoints/request" +) + +// WithAcceptHeader adds the Accept header to the request context. +func WithAcceptHeader(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ctx := request.WithAcceptHeader(req.Context(), req.Header.Get("Accept")) + handler.ServeHTTP(w, req.WithContext(ctx)) + }) +} diff --git a/pkg/apiserver/endpoints/filters/accept_test.go b/pkg/apiserver/endpoints/filters/accept_test.go new file mode 100644 index 0000000000000..e90da56e4c4d3 --- /dev/null +++ b/pkg/apiserver/endpoints/filters/accept_test.go @@ -0,0 +1,46 @@ +package filters + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/grafana/grafana/pkg/apiserver/endpoints/request" + "github.com/stretchr/testify/require" +) + +func TestWithAcceptHeader(t *testing.T) { + t.Run("should not set accept header in context for empty header", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + + rr := httptest.NewRecorder() + handler := &fakeHandler{} + WithAcceptHeader(handler).ServeHTTP(rr, req) + + acceptHeader, ok := request.AcceptHeaderFrom(handler.ctx) + require.False(t, ok) + require.Empty(t, acceptHeader) + }) + + t.Run("should set accept header in context", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept", "application/json") + + rr := httptest.NewRecorder() + handler := &fakeHandler{} + WithAcceptHeader(handler).ServeHTTP(rr, req) + + acceptHeader, ok := request.AcceptHeaderFrom(handler.ctx) + require.True(t, ok) + require.Equal(t, "application/json", acceptHeader) + }) +} + +type fakeHandler struct { + ctx context.Context +} + +func (h *fakeHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + h.ctx = req.Context() +} diff --git a/pkg/apiserver/endpoints/request/accept.go b/pkg/apiserver/endpoints/request/accept.go new file mode 100644 index 0000000000000..535688206743c --- /dev/null +++ b/pkg/apiserver/endpoints/request/accept.go @@ -0,0 +1,22 @@ +package request + +import ( + "context" +) + +type acceptHeaderKey struct{} + +// WithAcceptHeader adds the accept header to the supplied context. +func WithAcceptHeader(ctx context.Context, acceptHeader string) context.Context { + // only add the accept header to ctx if it is not empty + if acceptHeader == "" { + return ctx + } + return context.WithValue(ctx, acceptHeaderKey{}, acceptHeader) +} + +// AcceptHeaderFrom returns the accept header from the supplied context and a boolean indicating if the value was present. +func AcceptHeaderFrom(ctx context.Context) (string, bool) { + acceptHeader, ok := ctx.Value(acceptHeaderKey{}).(string) + return acceptHeader, ok +} diff --git a/pkg/apiserver/endpoints/request/accept_test.go b/pkg/apiserver/endpoints/request/accept_test.go new file mode 100644 index 0000000000000..0a415896a550d --- /dev/null +++ b/pkg/apiserver/endpoints/request/accept_test.go @@ -0,0 +1,26 @@ +package request + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAcceptHeader(t *testing.T) { + ctx := context.Background() + + t.Run("should not set ctx for empty header", func(t *testing.T) { + out := WithAcceptHeader(ctx, "") + acceptHeader, ok := AcceptHeaderFrom(out) + require.False(t, ok) + require.Empty(t, acceptHeader) + }) + + t.Run("should add header to ctx", func(t *testing.T) { + out := WithAcceptHeader(ctx, "application/json") + acceptHeader, ok := AcceptHeaderFrom(out) + require.True(t, ok) + require.Equal(t, "application/json", acceptHeader) + }) +} diff --git a/pkg/apiserver/endpoints/responsewriter/responsewriter.go b/pkg/apiserver/endpoints/responsewriter/responsewriter.go new file mode 100644 index 0000000000000..6e635203cfa9f --- /dev/null +++ b/pkg/apiserver/endpoints/responsewriter/responsewriter.go @@ -0,0 +1,132 @@ +package responsewriter + +import ( + "bufio" + "fmt" + "io" + "net/http" + + "k8s.io/apiserver/pkg/endpoints/responsewriter" + "k8s.io/klog/v2" +) + +var _ responsewriter.CloseNotifierFlusher = (*ResponseAdapter)(nil) +var _ http.ResponseWriter = (*ResponseAdapter)(nil) +var _ io.ReadCloser = (*ResponseAdapter)(nil) + +func WrapHandler(handler http.Handler) func(req *http.Request) (*http.Response, error) { + // ignore the lint error because the response is passed directly to the client, + // so the client will be responsible for closing the response body. + //nolint:bodyclose + return func(req *http.Request) (*http.Response, error) { + w := NewAdapter(req) + resp := w.Response() + go func() { + handler.ServeHTTP(w, req) + if err := w.CloseWriter(); err != nil { + klog.Errorf("error closing writer: %v", err) + } + }() + return resp, nil + } +} + +// ResponseAdapter is an implementation of [http.ResponseWriter] that allows conversion to a [http.Response]. +type ResponseAdapter struct { + req *http.Request + res *http.Response + reader io.ReadCloser + writer io.WriteCloser + buffered *bufio.ReadWriter +} + +// NewAdapter returns an initialized [ResponseAdapter]. +func NewAdapter(req *http.Request) *ResponseAdapter { + r, w := io.Pipe() + writer := bufio.NewWriter(w) + reader := bufio.NewReader(r) + buffered := bufio.NewReadWriter(reader, writer) + return &ResponseAdapter{ + req: req, + res: &http.Response{ + Proto: req.Proto, + ProtoMajor: req.ProtoMajor, + ProtoMinor: req.ProtoMinor, + Header: make(http.Header), + }, + reader: r, + writer: w, + buffered: buffered, + } +} + +// Header implements [http.ResponseWriter]. +// It returns the response headers to mutate within a handler. +func (ra *ResponseAdapter) Header() http.Header { + return ra.res.Header +} + +// Write implements [http.ResponseWriter]. +func (ra *ResponseAdapter) Write(buf []byte) (int, error) { + return ra.buffered.Write(buf) +} + +// Read implements [io.Reader]. +func (ra *ResponseAdapter) Read(buf []byte) (int, error) { + return ra.buffered.Read(buf) +} + +// WriteHeader implements [http.ResponseWriter]. +func (ra *ResponseAdapter) WriteHeader(code int) { + ra.res.StatusCode = code + ra.res.Status = fmt.Sprintf("%03d %s", code, http.StatusText(code)) +} + +// Flush implements [http.Flusher]. +func (ra *ResponseAdapter) Flush() { + if ra.buffered.Writer.Buffered() == 0 { + return + } + + if err := ra.buffered.Writer.Flush(); err != nil { + klog.Error("Error flushing response buffer: ", "error", err) + } +} + +// Response returns the [http.Response] generated by the [http.Handler]. +func (ra *ResponseAdapter) Response() *http.Response { + // make sure to set the status code to 200 if the request is a watch + // this is to ensure that client-go uses a streamwatcher: + // https://github.com/kubernetes/client-go/blob/76174b8af8cfd938018b04198595d65b48a69334/rest/request.go#L737 + if ra.res.StatusCode == 0 && ra.req.URL.Query().Get("watch") == "true" { + ra.WriteHeader(http.StatusOK) + } + ra.res.Body = ra + return ra.res +} + +// Decorate implements [responsewriter.UserProvidedDecorator]. +func (ra *ResponseAdapter) Unwrap() http.ResponseWriter { + return ra +} + +// CloseNotify implements [http.CloseNotifier]. +func (ra *ResponseAdapter) CloseNotify() <-chan bool { + ch := make(chan bool) + go func() { + <-ra.req.Context().Done() + ch <- true + }() + return ch +} + +// Close implements [io.Closer]. +func (ra *ResponseAdapter) Close() error { + return ra.reader.Close() +} + +// CloseWriter should be called after the http.Handler has returned. +func (ra *ResponseAdapter) CloseWriter() error { + ra.Flush() + return ra.writer.Close() +} diff --git a/pkg/apiserver/endpoints/responsewriter/responsewriter_test.go b/pkg/apiserver/endpoints/responsewriter/responsewriter_test.go new file mode 100644 index 0000000000000..4c759ac64859c --- /dev/null +++ b/pkg/apiserver/endpoints/responsewriter/responsewriter_test.go @@ -0,0 +1,137 @@ +package responsewriter_test + +import ( + "io" + "math/rand" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + + grafanaresponsewriter "github.com/grafana/grafana/pkg/apiserver/endpoints/responsewriter" +) + +func TestResponseAdapter(t *testing.T) { + t.Run("should handle synchronous write", func(t *testing.T) { + client := &http.Client{ + Transport: &roundTripperFunc{ + ready: make(chan struct{}), + // ignore the lint error because the response is passed directly to the client, + // so the client will be responsible for closing the response body. + //nolint:bodyclose + fn: grafanaresponsewriter.WrapHandler(http.HandlerFunc(syncHandler)), + }, + } + close(client.Transport.(*roundTripperFunc).ready) + req, err := http.NewRequest("GET", "http://localhost/test", nil) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + + defer func() { + err := resp.Body.Close() + require.NoError(t, err) + }() + + bodyBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "OK", string(bodyBytes)) + }) + + t.Run("should handle synchronous write", func(t *testing.T) { + generateRandomStrings(10) + client := &http.Client{ + Transport: &roundTripperFunc{ + ready: make(chan struct{}), + // ignore the lint error because the response is passed directly to the client, + // so the client will be responsible for closing the response body. + //nolint:bodyclose + fn: grafanaresponsewriter.WrapHandler(http.HandlerFunc(asyncHandler)), + }, + } + close(client.Transport.(*roundTripperFunc).ready) + req, err := http.NewRequest("GET", "http://localhost/test?watch=true", nil) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + + defer func() { + err := resp.Body.Close() + require.NoError(t, err) + }() + + // ensure that watch request is a 200 + require.Equal(t, http.StatusOK, resp.StatusCode) + + // limit to 100 bytes to test the reader buffer + buf := make([]byte, 100) + // holds the read bytes between iterations + cache := []byte{} + + for i := 0; i < 10; { + n, err := resp.Body.Read(buf) + require.NoError(t, err) + if n == 0 { + continue + } + cache = append(cache, buf[:n]...) + + if len(cache) >= len(randomStrings[i]) { + str := cache[:len(randomStrings[i])] + require.Equal(t, randomStrings[i], string(str)) + cache = cache[len(randomStrings[i]):] + i++ + } + } + }) +} + +func syncHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) +} + +func asyncHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + for _, s := range randomStrings { + time.Sleep(100 * time.Millisecond) + // write the current iteration + _, _ = w.Write([]byte(s)) + w.(http.Flusher).Flush() + } +} + +var randomStrings = []string{} + +func generateRandomStrings(n int) { + for i := 0; i < n; i++ { + randomString := generateRandomString(1000 * (i + 1)) + randomStrings = append(randomStrings, randomString) + } +} + +func generateRandomString(n int) string { + gen := rand.New(rand.NewSource(time.Now().UnixNano())) + var chars = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + b := make([]rune, n) + for i := range b { + b[i] = chars[gen.Intn(len(chars))] + } + return string(b) +} + +type roundTripperFunc struct { + ready chan struct{} + fn func(req *http.Request) (*http.Response, error) +} + +func (f *roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + if f.fn == nil { + <-f.ready + } + res, err := f.fn(req) + return res, err +} diff --git a/pkg/apiserver/go.mod b/pkg/apiserver/go.mod new file mode 100644 index 0000000000000..77fab0818a919 --- /dev/null +++ b/pkg/apiserver/go.mod @@ -0,0 +1,150 @@ +module github.com/grafana/grafana/pkg/apiserver + +go 1.21.0 + +require ( + github.com/bwmarrin/snowflake v0.3.0 + github.com/gorilla/mux v1.8.0 + github.com/grafana/grafana-plugin-sdk-go v0.215.0 + github.com/stretchr/testify v1.8.4 + golang.org/x/mod v0.14.0 + k8s.io/apimachinery v0.29.2 + k8s.io/apiserver v0.29.2 + k8s.io/client-go v0.29.2 + k8s.io/klog/v2 v2.120.1 + k8s.io/kube-openapi v0.0.0-20240220201932-37d671a357a5 +) + +require ( + github.com/BurntSushi/toml v1.3.2 // indirect + github.com/NYTimes/gziphandler v1.1.1 // indirect + github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df // indirect + github.com/apache/arrow/go/v15 v15.0.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cheekybits/genny v1.0.0 // indirect + github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/evanphx/json-patch v5.6.0+incompatible // indirect + github.com/fatih/color v1.15.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/getkin/kin-openapi v0.120.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.20.2 // indirect + github.com/go-openapi/jsonreference v0.20.4 // indirect + github.com/go-openapi/swag v0.22.9 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/btree v1.1.2 // indirect + github.com/google/cel-go v0.17.7 // indirect + github.com/google/flatbuffers v23.5.26+incompatible // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/pprof v0.0.0-20231205033806-a5a03c77bf08 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect + github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect + github.com/hashicorp/go-hclog v1.6.2 // indirect + github.com/hashicorp/go-plugin v1.6.0 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/imdario/mergo v0.3.16 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/yaml v0.2.0 // indirect + github.com/jonboulle/clockwork v0.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.4 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect + github.com/magefile/mage v1.15.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattetti/filebuffer v1.0.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pierrec/lz4/v4 v4.1.18 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.18.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.46.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/rivo/uniseg v0.3.4 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/smartystreets/goconvey v1.6.4 // indirect + github.com/spf13/cobra v1.8.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect + github.com/unknwon/com v1.0.1 // indirect + github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 // indirect + github.com/urfave/cli v1.22.14 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + go.etcd.io/etcd/api/v3 v3.5.10 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.5.10 // indirect + go.etcd.io/etcd/client/v3 v3.5.10 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.49.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.22.0 // indirect + go.opentelemetry.io/contrib/samplers/jaegerremote v0.18.0 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/sdk v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect + go.opentelemetry.io/proto/otlp v1.1.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.26.0 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/oauth2 v0.16.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/term v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect + golang.org/x/tools v0.17.0 // indirect + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 // indirect + google.golang.org/grpc v1.62.1 // indirect + google.golang.org/protobuf v1.32.0 // indirect + gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.29.2 // indirect + k8s.io/component-base v0.29.2 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.28.0 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect +) diff --git a/pkg/apiserver/go.sum b/pkg/apiserver/go.sum new file mode 100644 index 0000000000000..3196c458b7420 --- /dev/null +++ b/pkg/apiserver/go.sum @@ -0,0 +1,306 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= +github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df h1:7RFfzj4SSt6nnvCPbCqijJi1nWCd+TqAT3bYCStRC18= +github.com/apache/arrow/go/v15 v15.0.0 h1:1zZACWf85oEZY5/kd9dsQS7i+2G5zVQcbKTHgslqHNA= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= +github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= +github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89 h1:aPflPkRFkVwbW6dmcVqfgwp1i+UWGFH6VgR1Jim5Ygc= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 h1:1L0aalTpPz7YlMxETKpmQoWMBkeiuorElZIXoNmgiPE= +github.com/elazarl/goproxy/ext v0.0.0-20220115173737-adb46da277ac h1:9yrT5tmn9Zc0ytWPASlaPwQfQMQYnRf0RSDe1XvHw0Q= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/getkin/kin-openapi v0.120.0 h1:MqJcNJFrMDFNc07iwE8iFC5eT2k/NPUFDIpNeiZv8Jg= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.2.3 h1:a9vnzlIBPQBBkeaR9IuMUfmVOrQlkoC4YfPoFkX3T7A= +github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= +github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU= +github.com/go-openapi/swag v0.22.9 h1:XX2DssF+mQKM2DHsbgZK74y/zj4mo9I99+89xUmuZCE= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= +github.com/google/cel-go v0.17.7 h1:6ebJFzu1xO2n7TLtN+UBqShGBhlD85bhvglh5DpcfqQ= +github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20231205033806-a5a03c77bf08 h1:PxlBVtIFHR/mtWk2i0gTEdCz+jBnqiuHNSki0epDbVs= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafana/grafana-plugin-sdk-go v0.215.0 h1:02gwVsqYi1I+U48/MQR61eOMxiXE7KNKC8QsiMJ//qA= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No= +github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I= +github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattetti/filebuffer v1.0.1 h1:gG7pyfnSIZCxdoKq+cPa8T0hhYtD9NxCdI4D7PTjRLM= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= +github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= +github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= +github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/common v0.46.0 h1:doXzt5ybi1HBKpsZOL0sSkaNHJJqkyfEWZGGqqScV0Y= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/rivo/uniseg v0.3.4 h1:3Z3Eu6FGHZWSfNKJTOUiPatWwfc7DzJRU04jFUqJODw= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= +github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 h1:aVGB3YnaS/JNfOW3tiHIlmNmTDg618va+eT0mVomgyI= +github.com/unknwon/com v1.0.1 h1:3d1LTxD+Lnf3soQiD4Cp/0BRB+Rsa/+RTvz8GMMzIXs= +github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 h1:4EYQaWAatQokdji3zqZloVIW/Ke1RQjYw2zHULyrHJg= +github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA= +go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= +go.etcd.io/etcd/api/v3 v3.5.10 h1:szRajuUUbLyppkhs9K6BRtjY37l66XQQmw7oZRANE4k= +go.etcd.io/etcd/api/v3 v3.5.10/go.mod h1:TidfmT4Uycad3NM/o25fG3J07odo4GBB9hoxaodFCtI= +go.etcd.io/etcd/client/pkg/v3 v3.5.10 h1:kfYIdQftBnbAq8pUWFXfpuuxFSKzlmM5cSn76JByiT0= +go.etcd.io/etcd/client/pkg/v3 v3.5.10/go.mod h1:DYivfIviIuQ8+/lCq4vcxuseg2P2XbHygkKwFo9fc8U= +go.etcd.io/etcd/client/v2 v2.305.10 h1:MrmRktzv/XF8CvtQt+P6wLUlURaNpSDJHFZhe//2QE4= +go.etcd.io/etcd/client/v2 v2.305.10/go.mod h1:m3CKZi69HzilhVqtPDcjhSGp+kA1OmbNn0qamH80xjA= +go.etcd.io/etcd/client/v3 v3.5.10 h1:W9TXNZ+oB3MCd/8UjxHTWK5J9Nquw9fQBLJd5ne5/Ao= +go.etcd.io/etcd/client/v3 v3.5.10/go.mod h1:RVeBnDz2PUEZqTpgqwAtUd8nAPf5kjyFyND7P1VkOKc= +go.etcd.io/etcd/pkg/v3 v3.5.10 h1:WPR8K0e9kWl1gAhB5A7gEa5ZBTNkT9NdNWrR8Qpo1CM= +go.etcd.io/etcd/pkg/v3 v3.5.10/go.mod h1:TKTuCKKcF1zxmfKWDkfz5qqYaE3JncKKZPFf8c1nFUs= +go.etcd.io/etcd/raft/v3 v3.5.10 h1:cgNAYe7xrsrn/5kXMSaH8kM/Ky8mAdMqGOxyYwpP0LA= +go.etcd.io/etcd/raft/v3 v3.5.10/go.mod h1:odD6kr8XQXTy9oQnyMPBOr0TVe+gT0neQhElQ6jbGRc= +go.etcd.io/etcd/server/v3 v3.5.10 h1:4NOGyOwD5sUZ22PiWYKmfxqoeh72z6EhYjNosKGLmZg= +go.etcd.io/etcd/server/v3 v3.5.10/go.mod h1:gBplPHfs6YI0L+RpGkTQO7buDbHv5HJGG/Bst0/zIPo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.49.0 h1:RtcvQ4iw3w9NBB5yRwgA4sSa82rfId7n4atVpvKx3bY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/propagators/jaeger v1.22.0 h1:bAHX+zN/inu+Rbqk51REmC8oXLl+Dw6pp9ldQf/onaY= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.18.0 h1:Q9PrD94WoMolBx44ef5UWWvufpVSME0MiSymXZfedso= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 h1:Mw5xcxMwlqoJd97vwPxA8isEaIoxsta9/Q51+TTJLGE= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= +gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 h1:KAeGQVN3M9nD0/bQXnr/ClcEMJ968gUXJQ9pwfSynuQ= +google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80 h1:Lj5rbfG876hIAYFjqiJnPHfhXbv+nzTWfm04Fg/XSVU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 h1:AjyfHzEPEFp/NpvfN5g+KDla3EMojjhRVZc1i7cj+oM= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify/fsnotify.v1 v1.4.7 h1:XNNYLJHt73EyYiCZi6+xjupS9CpvmiDgjPTAjrBlQbo= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.29.2 h1:hBC7B9+MU+ptchxEqTNW2DkUosJpp1P+Wn6YncZ474A= +k8s.io/api v0.29.2/go.mod h1:sdIaaKuU7P44aoyyLlikSLayT6Vb7bvJNCX105xZXY0= +k8s.io/apimachinery v0.29.2 h1:EWGpfJ856oj11C52NRCHuU7rFDwxev48z+6DSlGNsV8= +k8s.io/apimachinery v0.29.2/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU= +k8s.io/apiserver v0.29.2 h1:+Z9S0dSNr+CjnVXQePG8TcBWHr3Q7BmAr7NraHvsMiQ= +k8s.io/apiserver v0.29.2/go.mod h1:B0LieKVoyU7ykQvPFm7XSdIHaCHSzCzQWPFa5bqbeMQ= +k8s.io/client-go v0.29.2 h1:FEg85el1TeZp+/vYJM7hkDlSTFZ+c5nnK44DJ4FyoRg= +k8s.io/client-go v0.29.2/go.mod h1:knlvFZE58VpqbQpJNbCbctTVXcd35mMyAAwBdpt4jrA= +k8s.io/component-base v0.29.2 h1:lpiLyuvPA9yV1aQwGLENYyK7n/8t6l3nn3zAtFTJYe8= +k8s.io/component-base v0.29.2/go.mod h1:BfB3SLrefbZXiBfbM+2H1dlat21Uewg/5qtKOl8degM= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240220201932-37d671a357a5 h1:QSpdNrZ9uRlV0VkqLvVO0Rqg8ioKi3oSw7O5P7pJV8M= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.28.0 h1:TgtAeesdhpm2SGwkQasmbeqDo8th5wOBA5h/AjTKA4I= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.28.0/go.mod h1:VHVDI/KrK4fjnV61bE2g3sA7tiETLn8sooImelsCx3Y= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/pkg/services/grafana-apiserver/registry/generic/strategy.go b/pkg/apiserver/registry/generic/strategy.go similarity index 100% rename from pkg/services/grafana-apiserver/registry/generic/strategy.go rename to pkg/apiserver/registry/generic/strategy.go diff --git a/pkg/services/grafana-apiserver/rest/dualwriter.go b/pkg/apiserver/rest/dualwriter.go similarity index 97% rename from pkg/services/grafana-apiserver/rest/dualwriter.go rename to pkg/apiserver/rest/dualwriter.go index 7200172249347..81dcca18bfb20 100644 --- a/pkg/services/grafana-apiserver/rest/dualwriter.go +++ b/pkg/apiserver/rest/dualwriter.go @@ -8,8 +8,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/rest" - - "github.com/grafana/grafana/pkg/infra/log" + "k8s.io/klog/v2" ) var ( @@ -64,7 +63,6 @@ type LegacyStorage interface { type DualWriter struct { Storage legacy LegacyStorage - log log.Logger } // NewDualWriter returns a new DualWriter. @@ -72,7 +70,6 @@ func NewDualWriter(legacy LegacyStorage, storage Storage) *DualWriter { return &DualWriter{ Storage: storage, legacy: legacy, - log: log.New("grafana-apiserver.dualwriter"), } } @@ -93,7 +90,7 @@ func (d *DualWriter) Create(ctx context.Context, obj runtime.Object, createValid rsp, err := d.Storage.Create(ctx, created, createValidation, options) if err != nil { - d.log.Error("unable to create object in duplicate storage", "error", err) + klog.Error("unable to create object in duplicate storage", "error", err) } return rsp, err } diff --git a/pkg/services/grafana-apiserver/storage/file/file.go b/pkg/apiserver/storage/file/file.go similarity index 86% rename from pkg/services/grafana-apiserver/storage/file/file.go rename to pkg/apiserver/storage/file/file.go index 2331277ae2f39..68fb81583d85a 100644 --- a/pkg/services/grafana-apiserver/storage/file/file.go +++ b/pkg/apiserver/storage/file/file.go @@ -12,6 +12,7 @@ import ( "path/filepath" "reflect" "strings" + "sync" "time" "github.com/bwmarrin/snowflake" @@ -37,15 +38,16 @@ var errResourceVersionSetOnCreate = errors.New("resourceVersion should not be se // Storage implements storage.Interface and storage resources as JSON files on disk. type Storage struct { - root string - gr schema.GroupResource - codec runtime.Codec - keyFunc func(obj runtime.Object) (string, error) - newFunc func() runtime.Object - newListFunc func() runtime.Object - getAttrsFunc storage.AttrFunc - trigger storage.IndexerFuncs - indexers *cache.Indexers + root string + resourcePrefix string + gr schema.GroupResource + codec runtime.Codec + keyFunc func(obj runtime.Object) (string, error) + newFunc func() runtime.Object + newListFunc func() runtime.Object + getAttrsFunc storage.AttrFunc + trigger storage.IndexerFuncs + indexers *cache.Indexers watchSet *WatchSet } @@ -56,8 +58,16 @@ var ErrFileNotExists = fmt.Errorf("file doesn't exist") // ErrNamespaceNotExists means the directory for the namespace doesn't actually exist. var ErrNamespaceNotExists = errors.New("namespace does not exist") +var ( + node *snowflake.Node + once sync.Once +) + func getResourceVersion() (*uint64, error) { - node, err := snowflake.NewNode(1) + var err error + once.Do(func() { + node, err = snowflake.NewNode(1) + }) if err != nil { return nil, err } @@ -78,20 +88,22 @@ func NewStorage( trigger storage.IndexerFuncs, indexers *cache.Indexers, ) (storage.Interface, factory.DestroyFunc, error) { - if err := ensureDir(resourcePrefix); err != nil { - return nil, func() {}, fmt.Errorf("could not establish a writable directory at path=%s", resourcePrefix) + root := config.Prefix + if err := ensureDir(root); err != nil { + return nil, func() {}, fmt.Errorf("could not establish a writable directory at path=%s", root) } ws := NewWatchSet() return &Storage{ - root: resourcePrefix, - gr: config.GroupResource, - codec: config.Codec, - keyFunc: keyFunc, - newFunc: newFunc, - newListFunc: newListFunc, - getAttrsFunc: getAttrsFunc, - trigger: trigger, - indexers: indexers, + root: root, + resourcePrefix: resourcePrefix, + gr: config.GroupResource, + codec: config.Codec, + keyFunc: keyFunc, + newFunc: newFunc, + newListFunc: newListFunc, + getAttrsFunc: getAttrsFunc, + trigger: trigger, + indexers: indexers, watchSet: ws, }, func() { @@ -108,12 +120,12 @@ func (s *Storage) Versioner() storage.Versioner { // in seconds (0 means forever). If no error is returned and out is not nil, out will be // set to the read value from database. func (s *Storage) Create(ctx context.Context, key string, obj runtime.Object, out runtime.Object, ttl uint64) error { - filename := s.filePath(key) - if exists(filename) { + fpath := s.filePath(key) + if exists(fpath) { return storage.NewKeyExistsError(key, 0) } - dirname := filepath.Dir(filename) + dirname := filepath.Dir(fpath) if err := ensureDir(dirname); err != nil { return err } @@ -136,7 +148,7 @@ func (s *Storage) Create(ctx context.Context, key string, obj runtime.Object, ou return err } - if err := writeFile(s.codec, filename, obj); err != nil { + if err := writeFile(s.codec, fpath, obj); err != nil { return err } @@ -174,7 +186,7 @@ func (s *Storage) Delete( validateDeletion storage.ValidateObjectFunc, cachedExistingObject runtime.Object, ) error { - filename := s.filePath(key) + fpath := s.filePath(key) var currentState runtime.Object var stateIsCurrent bool if cachedExistingObject != nil { @@ -229,7 +241,7 @@ func (s *Storage) Delete( return err } - if err := deleteFile(filename); err != nil { + if err := deleteFile(fpath); err != nil { return err } @@ -293,8 +305,15 @@ func (s *Storage) Watch(ctx context.Context, key string, opts storage.ListOption // The returned contents may be delayed, but it is guaranteed that they will // match 'opts.ResourceVersion' according 'opts.ResourceVersionMatch'. func (s *Storage) Get(ctx context.Context, key string, opts storage.GetOptions, objPtr runtime.Object) error { - filename := s.filePath(key) - obj, err := readFile(s.codec, filename, func() runtime.Object { + fpath := s.filePath(key) + + // Since it's a get, check if the dir exists and return early as needed + dirname := filepath.Dir(fpath) + if !exists(dirname) { + return apierrors.NewNotFound(s.gr, s.nameFromKey(key)) + } + + obj, err := readFile(s.codec, fpath, func() runtime.Object { return objPtr }) if err != nil { @@ -336,6 +355,11 @@ func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOpti return err } + // Watch is failing when set the list resourceVersion to 0, even though informers provide that in the opts + if opts.ResourceVersion == "0" { + opts.ResourceVersion = "" + } + if opts.ResourceVersion != "" { resourceVersionInt, err := s.Versioner().ParseResourceVersion(opts.ResourceVersion) if err != nil { @@ -346,7 +370,14 @@ func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOpti } } - objs, err := readDirRecursive(s.codec, key, s.newFunc) + dirpath := s.dirPath(key) + // Since it's a get, check if the dir exists and return early as needed + if !exists(dirpath) { + // ensure we return empty list in listObj insted of a not found error + return nil + } + + objs, err := readDirRecursive(s.codec, dirpath, s.newFunc) if err != nil { return err } @@ -404,18 +435,25 @@ func (s *Storage) GuaranteedUpdate( var res storage.ResponseMeta for attempt := 1; attempt <= MaxUpdateAttempts; attempt = attempt + 1 { var ( - filename = s.filePath(key) + fpath = s.filePath(key) + dirpath = filepath.Dir(fpath) obj runtime.Object err error created bool ) - if !exists(filename) && !ignoreNotFound { + if !exists(dirpath) { + if err := ensureDir(dirpath); err != nil { + return err + } + } + + if !exists(fpath) && !ignoreNotFound { return apierrors.NewNotFound(s.gr, s.nameFromKey(key)) } - obj, err = readFile(s.codec, filename, s.newFunc) + obj, err = readFile(s.codec, fpath, s.newFunc) if err != nil { // fallback to new object if the file is not found obj = s.newFunc() @@ -462,7 +500,7 @@ func (s *Storage) GuaranteedUpdate( if err := s.Versioner().UpdateObject(updatedObj, *generatedRV); err != nil { return err } - if err := writeFile(s.codec, filename, updatedObj); err != nil { + if err := writeFile(s.codec, fpath, updatedObj); err != nil { return err } eventType := watch.Modified @@ -518,5 +556,5 @@ func (s *Storage) validateMinimumResourceVersion(minimumResourceVersion string, } func (s *Storage) nameFromKey(key string) string { - return strings.Replace(key, s.root+"/", "", 1) + return strings.Replace(key, s.resourcePrefix+"/", "", 1) } diff --git a/pkg/services/grafana-apiserver/storage/file/restoptions.go b/pkg/apiserver/storage/file/restoptions.go similarity index 57% rename from pkg/services/grafana-apiserver/storage/file/restoptions.go rename to pkg/apiserver/storage/file/restoptions.go index 1b70af47bd65f..52981cef7feb3 100644 --- a/pkg/services/grafana-apiserver/storage/file/restoptions.go +++ b/pkg/apiserver/storage/file/restoptions.go @@ -3,7 +3,8 @@ package file import ( - "path" + "os" + "path/filepath" "time" "k8s.io/apimachinery/pkg/runtime/schema" @@ -19,12 +20,30 @@ type RESTOptionsGetter struct { original storagebackend.Config } -func NewRESTOptionsGetter(path string, originalStorageConfig storagebackend.Config) *RESTOptionsGetter { +// Optionally, this constructor allows specifying directories +// for resources that are required to be read/watched on startup and there +// won't be any write operations that initially bootstrap their directories +func NewRESTOptionsGetter(path string, + originalStorageConfig storagebackend.Config, + createResourceDirs ...string) (*RESTOptionsGetter, error) { if path == "" { - path = "/tmp/grafana-apiserver" + path = filepath.Join(os.TempDir(), "grafana-apiserver") } - return &RESTOptionsGetter{path: path, original: originalStorageConfig} + if err := initializeDirs(path, createResourceDirs); err != nil { + return nil, err + } + + return &RESTOptionsGetter{path: path, original: originalStorageConfig}, nil +} + +func initializeDirs(root string, createResourceDirs []string) error { + for _, dir := range createResourceDirs { + if err := ensureDir(filepath.Join(root, dir)); err != nil { + return err + } + } + return nil } func (r *RESTOptionsGetter) GetRESTOptions(resource schema.GroupResource) (generic.RESTOptions, error) { @@ -47,11 +66,12 @@ func (r *RESTOptionsGetter) GetRESTOptions(resource schema.GroupResource) (gener } ret := generic.RESTOptions{ - StorageConfig: storageConfig, - Decorator: NewStorage, - DeleteCollectionWorkers: 0, - EnableGarbageCollection: false, - ResourcePrefix: path.Join(storageConfig.Prefix, resource.Group, resource.Resource), + StorageConfig: storageConfig, + Decorator: NewStorage, + DeleteCollectionWorkers: 0, + EnableGarbageCollection: false, + // k8s expects forward slashes here, we'll convert them to os path separators in the storage + ResourcePrefix: "/" + resource.Group + "/" + resource.Resource, CountMetricPollPeriod: 1 * time.Second, StorageObjectCountTracker: storageConfig.Config.StorageObjectCountTracker, } diff --git a/pkg/services/grafana-apiserver/storage/file/util.go b/pkg/apiserver/storage/file/util.go similarity index 74% rename from pkg/services/grafana-apiserver/storage/file/util.go rename to pkg/apiserver/storage/file/util.go index f7cbdf08b53fd..67cd5452bca23 100644 --- a/pkg/services/grafana-apiserver/storage/file/util.go +++ b/pkg/apiserver/storage/file/util.go @@ -10,12 +10,23 @@ import ( "errors" "os" "path/filepath" + "strings" "k8s.io/apimachinery/pkg/runtime" ) func (s *Storage) filePath(key string) string { - return key + ".json" + // Replace backslashes with underscores to avoid creating bogus subdirectories + key = strings.Replace(key, "\\", "_", -1) + fileName := filepath.Join(s.root, filepath.Clean(key+".json")) + return fileName +} + +// this is for constructing dirPath in a sanitized way provided you have +// already calculated the key. In order to go in the other direction, from a file path +// key to its dir, use the go standard library: filepath.Dir +func (s *Storage) dirPath(key string) string { + return dirPath(s.root, key) } func writeFile(codec runtime.Codec, path string, obj runtime.Object) error { @@ -73,6 +84,13 @@ func exists(filepath string) bool { return err == nil } +func dirPath(root string, key string) string { + // Replace backslashes with underscores to avoid creating bogus subdirectories + key = strings.Replace(key, "\\", "_", -1) + dirName := filepath.Join(root, filepath.Clean(key)) + return dirName +} + func ensureDir(dirname string) error { if !exists(dirname) { return os.MkdirAll(dirname, 0700) diff --git a/pkg/services/grafana-apiserver/storage/file/watchset.go b/pkg/apiserver/storage/file/watchset.go similarity index 100% rename from pkg/services/grafana-apiserver/storage/file/watchset.go rename to pkg/apiserver/storage/file/watchset.go diff --git a/pkg/build/cmd/publishstorybook.go b/pkg/build/cmd/publishstorybook.go index b147d9a7b4029..e33366d8e4bdb 100644 --- a/pkg/build/cmd/publishstorybook.go +++ b/pkg/build/cmd/publishstorybook.go @@ -39,7 +39,7 @@ func PublishStorybookAction(c *cli.Context) error { return err } - if latest, err := isLatest(cfg); err != nil && latest { + if latest, err := isLatest(cfg); err == nil && latest { log.Printf("Copying storybooks to latest...") if err := gcs.CopyRemoteDir(c.Context, gcs.Bucket(cfg.srcBucket), fmt.Sprintf("artifacts/storybook/v%s", cfg.tag), bucket, "latest"); err != nil { return err diff --git a/pkg/build/config/version.go b/pkg/build/config/version.go index 05b99cf46785c..2e93e0a6a228e 100644 --- a/pkg/build/config/version.go +++ b/pkg/build/config/version.go @@ -57,7 +57,7 @@ func (md *Metadata) GetReleaseMode() (ReleaseMode, error) { return md.ReleaseMode, nil } -// VersionMap is a map of versions. Each key of the Versions map is an event that uses the the config as the value for that key. +// VersionMap is a map of versions. Each key of the Versions map is an event that uses the config as the value for that key. // For example, the 'pull_request' key will have data in it that might cause Grafana to be built differently in a pull request, // than the way it will be built in 'main' type VersionMap map[VersionMode]BuildConfig diff --git a/pkg/build/docker/build.go b/pkg/build/docker/build.go index 29b2263e62bc3..8731ab7a247ca 100644 --- a/pkg/build/docker/build.go +++ b/pkg/build/docker/build.go @@ -70,7 +70,7 @@ func BuildImage(version string, arch config.Architecture, grafanaDir string, use } libc := "-musl" - baseImage := fmt.Sprintf("%salpine:3.18.3", baseArch) + baseImage := fmt.Sprintf("%salpine:3.18.5", baseArch) tagSuffix := "" if useUbuntu { libc = "" diff --git a/pkg/cmd/grafana-cli/commands/commands.go b/pkg/cmd/grafana-cli/commands/commands.go index 30eacb2827c17..ab2293bc353c7 100644 --- a/pkg/cmd/grafana-cli/commands/commands.go +++ b/pkg/cmd/grafana-cli/commands/commands.go @@ -30,7 +30,7 @@ func runRunnerCommand(command func(commandLine utils.CommandLine, runner server. } } -func runDbCommand(command func(commandLine utils.CommandLine, sqlStore db.DB) error) func(context *cli.Context) error { +func runDbCommand(command func(commandLine utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) error) func(context *cli.Context) error { return func(context *cli.Context) error { cmd := &utils.ContextCommandLine{Context: context} runner, err := initializeRunner(cmd) @@ -38,8 +38,9 @@ func runDbCommand(command func(commandLine utils.CommandLine, sqlStore db.DB) er return fmt.Errorf("%v: %w", "failed to initialize runner", err) } + cfg := runner.Cfg sqlStore := runner.SQLStore - if err := command(cmd, sqlStore); err != nil { + if err := command(cmd, cfg, sqlStore); err != nil { return err } diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command.go b/pkg/cmd/grafana-cli/commands/conflict_user_command.go index 8bccb53bdab72..3e607cecca89f 100644 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command.go +++ b/pkg/cmd/grafana-cli/commands/conflict_user_command.go @@ -33,7 +33,7 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -func initConflictCfg(cmd *utils.ContextCommandLine) (*setting.Cfg, error) { +func initConflictCfg(cmd *utils.ContextCommandLine) (*setting.Cfg, *featuremgmt.FeatureManager, error) { configOptions := strings.Split(cmd.String("configOverrides"), " ") configOptions = append(configOptions, cmd.Args().Slice()...) cfg, err := setting.NewCfgFromArgs(setting.CommandLineArgs{ @@ -43,17 +43,19 @@ func initConflictCfg(cmd *utils.ContextCommandLine) (*setting.Cfg, error) { }) if err != nil { - return nil, err + return nil, nil, err } - return cfg, nil + + features, err := featuremgmt.ProvideManagerService(cfg) + return cfg, features, err } func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx *cli.Context) (*ConflictResolver, error) { - cfg, err := initConflictCfg(cmd) + cfg, features, err := initConflictCfg(cmd) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to load configuration", err) } - s, err := getSqlStore(cfg) + s, err := getSqlStore(cfg, features) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get to sql", err) } @@ -67,11 +69,7 @@ func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx return nil, fmt.Errorf("%v: %w", "failed to get user service", err) } routing := routing.ProvideRegister() - featMgmt, err := featuremgmt.ProvideManagerService(cfg, nil) - if err != nil { - return nil, fmt.Errorf("%v: %w", "failed to get feature management service", err) - } - acService, err := acimpl.ProvideService(cfg, s, routing, nil, nil, featMgmt) + acService, err := acimpl.ProvideService(cfg, s, routing, nil, nil, features) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get access control", err) } @@ -80,13 +78,13 @@ func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx return &resolver, nil } -func getSqlStore(cfg *setting.Cfg) (*sqlstore.SQLStore, error) { +func getSqlStore(cfg *setting.Cfg, features featuremgmt.FeatureToggles) (*sqlstore.SQLStore, error) { tracer, err := tracing.ProvideService(cfg) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to initialize tracer service", err) } bus := bus.ProvideBus(tracer) - return sqlstore.ProvideService(cfg, &migrations.OSSMigrations{}, bus, tracer) + return sqlstore.ProvideService(cfg, features, &migrations.OSSMigrations{}, bus, tracer) } func runListConflictUsers() func(context *cli.Context) error { diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go index d15eb50f49549..0da42a55cbf01 100644 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go +++ b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go @@ -23,11 +23,16 @@ import ( "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) // "Skipping conflicting users test for mysql as it does make unique constraint case insensitive by default const ignoredDatabase = migrator.MySQL +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestBuildConflictBlock(t *testing.T) { type testBuildConflictBlock struct { desc string @@ -608,9 +613,10 @@ func TestIntegrationMergeUser(t *testing.T) { t.Run("should be able to merge user", func(t *testing.T) { // Restore after destructive operation sqlStore := db.InitTestDB(t) - teamSvc := teamimpl.ProvideService(sqlStore, setting.NewCfg()) + teamSvc, err := teamimpl.ProvideService(sqlStore, setting.NewCfg()) + require.NoError(t, err) team1, err := teamSvc.CreateTeam("team1 name", "", 1) - require.Nil(t, err) + require.NoError(t, err) usrSvc := setupTestUserService(t, sqlStore) const testOrgID int64 = 1 @@ -635,7 +641,7 @@ func TestIntegrationMergeUser(t *testing.T) { userWithUpperCase, err := usrSvc.Create(context.Background(), &dupUserEmailcmd) require.NoError(t, err) // this is the user we want to update to another team - err = teamSvc.AddTeamMember(userWithUpperCase.ID, testOrgID, team1.ID, false, 0) + err = teamSvc.AddTeamMember(context.Background(), userWithUpperCase.ID, testOrgID, team1.ID, false, 0) require.NoError(t, err) // get users diff --git a/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords.go b/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords.go index 2b244d8beeea9..13519423f8c4b 100644 --- a/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords.go +++ b/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords.go @@ -27,14 +27,14 @@ var ( // EncryptDatasourcePasswords migrates unencrypted secrets on datasources // to the secureJson Column. -func EncryptDatasourcePasswords(c utils.CommandLine, sqlStore db.DB) error { +func EncryptDatasourcePasswords(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) error { return sqlStore.WithDbSession(context.Background(), func(session *db.Session) error { - passwordsUpdated, err := migrateColumn(session, "password") + passwordsUpdated, err := migrateColumn(cfg, session, "password") if err != nil { return err } - basicAuthUpdated, err := migrateColumn(session, "basic_auth_password") + basicAuthUpdated, err := migrateColumn(cfg, session, "basic_auth_password") if err != nil { return err } @@ -61,7 +61,7 @@ func EncryptDatasourcePasswords(c utils.CommandLine, sqlStore db.DB) error { }) } -func migrateColumn(session *db.Session, column string) (int, error) { +func migrateColumn(cfg *setting.Cfg, session *db.Session, column string) (int, error) { var rows []map[string][]byte session.Cols("id", column, "secure_json_data") @@ -74,18 +74,18 @@ func migrateColumn(session *db.Session, column string) (int, error) { return 0, fmt.Errorf("failed to select column: %s: %w", column, err) } - rowsUpdated, err := updateRows(session, rows, column) + rowsUpdated, err := updateRows(cfg, session, rows, column) if err != nil { return rowsUpdated, fmt.Errorf("failed to update column: %s: %w", column, err) } return rowsUpdated, err } -func updateRows(session *db.Session, rows []map[string][]byte, passwordFieldName string) (int, error) { +func updateRows(cfg *setting.Cfg, session *db.Session, rows []map[string][]byte, passwordFieldName string) (int, error) { var rowsUpdated int for _, row := range rows { - newSecureJSONData, err := getUpdatedSecureJSONData(row, passwordFieldName) + newSecureJSONData, err := getUpdatedSecureJSONData(cfg.SecretKey, row, passwordFieldName) if err != nil { return 0, err } @@ -111,8 +111,8 @@ func updateRows(session *db.Session, rows []map[string][]byte, passwordFieldName return rowsUpdated, nil } -func getUpdatedSecureJSONData(row map[string][]byte, passwordFieldName string) (map[string]any, error) { - encryptedPassword, err := util.Encrypt(row[passwordFieldName], setting.SecretKey) +func getUpdatedSecureJSONData(secretKey string, row map[string][]byte, passwordFieldName string) (map[string]any, error) { + encryptedPassword, err := util.Encrypt(row[passwordFieldName], secretKey) if err != nil { return nil, err } diff --git a/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go b/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go index 1ba6516cfc47f..d24e0782cabd7 100644 --- a/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go +++ b/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go @@ -12,9 +12,14 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestPasswordMigrationCommand(t *testing.T) { // setup datasources with password, basic_auth and none store := db.InitTestDB(t) @@ -38,8 +43,10 @@ func passwordMigration(t *testing.T, session *db.Session, sqlstore db.DB) { ds.Created = time.Now() ds.Updated = time.Now() + cfg := setting.NewCfg() + if ds.Name == "elasticsearch" { - key, err := util.Encrypt([]byte("value"), setting.SecretKey) + key, err := util.Encrypt([]byte("value"), cfg.SecretKey) require.NoError(t, err) ds.SecureJsonData = map[string][]byte{"key": key} @@ -58,7 +65,7 @@ func passwordMigration(t *testing.T, session *db.Session, sqlstore db.DB) { // run migration c, err := commandstest.NewCliContext(map[string]string{}) require.Nil(t, err) - err = EncryptDatasourcePasswords(c, sqlstore) + err = EncryptDatasourcePasswords(c, setting.NewCfg(), sqlstore) require.NoError(t, err) // verify that no datasources still have password or basic_auth @@ -68,7 +75,8 @@ func passwordMigration(t *testing.T, session *db.Session, sqlstore db.DB) { assert.Equal(t, len(dss), 4) for _, ds := range dss { - sj, err := DecryptSecureJsonData(ds) + cfg := setting.NewCfg() + sj, err := DecryptSecureJsonData(cfg.SecretKey, ds) require.NoError(t, err) if ds.Name == "influxdb" { @@ -101,10 +109,10 @@ func passwordMigration(t *testing.T, session *db.Session, sqlstore db.DB) { } } -func DecryptSecureJsonData(ds *datasources.DataSource) (map[string]string, error) { +func DecryptSecureJsonData(secretKey string, ds *datasources.DataSource) (map[string]string, error) { decrypted := make(map[string]string) for key, data := range ds.SecureJsonData { - decryptedData, err := util.Decrypt(data, setting.SecretKey) + decryptedData, err := util.Decrypt(data, secretKey) if err != nil { return nil, err } diff --git a/pkg/cmd/grafana-cli/commands/reset_password_command.go b/pkg/cmd/grafana-cli/commands/reset_password_command.go index c63ef80d814a7..08c8b7edb5700 100644 --- a/pkg/cmd/grafana-cli/commands/reset_password_command.go +++ b/pkg/cmd/grafana-cli/commands/reset_password_command.go @@ -18,7 +18,7 @@ import ( const DefaultAdminUserId = 1 func resetPasswordCommand(c utils.CommandLine, runner server.Runner) error { - newPassword := "" + var newPassword user.Password adminId := int64(c.Int("user-id")) if c.Bool("password-from-stdin") { @@ -31,9 +31,13 @@ func resetPasswordCommand(c utils.CommandLine, runner server.Runner) error { } return fmt.Errorf("can't read password from stdin") } - newPassword = scanner.Text() + newPassword = user.Password(scanner.Text()) } else { - newPassword = c.Args().First() + newPassword = user.Password(c.Args().First()) + } + + if err := newPassword.Validate(runner.Cfg); err != nil { + return fmt.Errorf("the new password doesn't meet the password policy criteria") } err := resetPassword(adminId, newPassword, runner.UserService) @@ -44,12 +48,7 @@ func resetPasswordCommand(c utils.CommandLine, runner server.Runner) error { return err } -func resetPassword(adminId int64, newPassword string, userSvc user.Service) error { - password := user.Password(newPassword) - if password.IsWeak() { - return fmt.Errorf("new password is too short") - } - +func resetPassword(adminId int64, newPassword user.Password, userSvc user.Service) error { userQuery := user.GetUserByIDQuery{ID: adminId} usr, err := userSvc.GetByID(context.Background(), &userQuery) if err != nil { @@ -59,14 +58,14 @@ func resetPassword(adminId int64, newPassword string, userSvc user.Service) erro return ErrMustBeAdmin } - passwordHashed, err := util.EncodePassword(newPassword, usr.Salt) + passwordHashed, err := util.EncodePassword(string(newPassword), usr.Salt) if err != nil { return err } cmd := user.ChangeUserPasswordCommand{ UserID: adminId, - NewPassword: passwordHashed, + NewPassword: user.Password(passwordHashed), } if err := userSvc.ChangePassword(context.Background(), &cmd); err != nil { diff --git a/pkg/cmd/grafana-cli/services/services.go b/pkg/cmd/grafana-cli/services/services.go index 53bebf8f552a5..00fffc487e279 100644 --- a/pkg/cmd/grafana-cli/services/services.go +++ b/pkg/cmd/grafana-cli/services/services.go @@ -15,7 +15,6 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/manager/loader/finder" "github.com/grafana/grafana/pkg/plugins/manager/sources" - "github.com/grafana/grafana/pkg/services/featuremgmt" ) var ( @@ -78,7 +77,7 @@ func GetLocalPlugin(pluginDir, pluginID string) (plugins.FoundPlugin, error) { } func GetLocalPlugins(pluginDir string) []*plugins.FoundBundle { - f := finder.NewLocalFinder(true, featuremgmt.WithFeatures()) + f := finder.NewLocalFinder(true) res, err := f.Find(context.Background(), sources.NewLocalSource(plugins.ClassExternal, []string{pluginDir})) if err != nil { diff --git a/pkg/cmd/grafana-server/commands/cli.go b/pkg/cmd/grafana-server/commands/cli.go index ea329ea1b7322..f2d434809d210 100644 --- a/pkg/cmd/grafana-server/commands/cli.go +++ b/pkg/cmd/grafana-server/commands/cli.go @@ -11,6 +11,8 @@ import ( "syscall" "time" + _ "github.com/grafana/pyroscope-go/godeltaprof/http/pprof" + "github.com/urfave/cli/v2" "github.com/grafana/grafana/pkg/api" @@ -19,8 +21,6 @@ import ( "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/process" "github.com/grafana/grafana/pkg/server" - _ "github.com/grafana/grafana/pkg/services/alerting/conditions" - _ "github.com/grafana/grafana/pkg/services/alerting/notifiers" "github.com/grafana/grafana/pkg/setting" ) diff --git a/pkg/cmd/grafana/apiserver/README.md b/pkg/cmd/grafana/apiserver/README.md deleted file mode 100644 index 4e934974317b2..0000000000000 --- a/pkg/cmd/grafana/apiserver/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# grafana apiserver (standalone) - -The example-apiserver closely resembles the -[sample-apiserver](https://github.com/kubernetes/sample-apiserver/tree/master) project in code and thus -allows the same -[CLI flags](https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/) as kube-apiserver. -It is currently used for testing our deployment pipelines for aggregated servers. You can optionally omit the -aggregation path altogether and just run this example apiserver as a standalone process. - -## Standalone Mode - -### Usage - -```shell -go run ./pkg/cmd/grafana apiserver example.grafana.app \ - --secure-port 8443 -``` - -### Verify that all works - -```shell -export KUBECONFIG=./example-apiserver/kubeconfig - -kubectl api-resources -NAME SHORTNAMES APIVERSION NAMESPACED KIND -dummy example.grafana.app/v0alpha1 true DummyResource -runtime example.grafana.app/v0alpha1 false RuntimeInfo -``` - -## Aggregated Mode - -### Prerequisites: -1. kind: you will need kind (or another local K8s setup) if you want to test aggregation. - ``` - go install sigs.k8s.io/kind@v0.20.0 && kind create cluster - ``` - -### Usage - -You can start the example-apiserver with an invocation as shown below. The Authn / Authz flags are set up so that the kind cluster -can be used as a root server for this example-apiserver (in aggregated mode). Here, it's assumed that you have a local -kind cluster and that you can provide its kubeconfig in the parameters to the example-apiserver. - -```shell -go run ./pkg/cmd/grafana apiserver example.grafana.app \ - --authentication-kubeconfig ~/.kube/config \ - --authorization-kubeconfig ~/.kube/config \ - --kubeconfig ~/.kube/config \ - --secure-port 8443 -``` - -Once, the `example-apiserver` is running, you can configure aggregation against your kind cluster -by applying a `APIService` and it's corresponding `Service` object. Sample kustomizations are provided -for local development on [Linux](./deploy/linux/kustomization.yaml) and [macOS](./deploy/darwin/kustomization.yaml). - -```shell -kubectl deploy -k ./deploy/darwin # or /linux -``` - - -### Verify that all works - -With kubectl configured against `kind-kind` context, you can run the following: - -```shell -kubectl get --raw /apis/example.grafana.app/v0alpha1 | jq -r -{ - "kind": "APIResourceList", - "apiVersion": "v1", - "groupVersion": "example.grafana.app/v0alpha1", - "resources": [ - { - "name": "runtime", - "singularName": "runtime", - "namespaced": false, - "kind": "RuntimeInfo", - "verbs": [ - "list" - ] - } - ] -} -``` - -```shell -kubectl get apiservice v0alpha1.example.grafana.app -NAME SERVICE AVAILABLE AGE -v0alpha1.example.grafana.app grafana/example-apiserver True 4h1m -``` diff --git a/pkg/cmd/grafana/apiserver/apiserver.md b/pkg/cmd/grafana/apiserver/apiserver.md new file mode 100644 index 0000000000000..27dce737a7d17 --- /dev/null +++ b/pkg/cmd/grafana/apiserver/apiserver.md @@ -0,0 +1,34 @@ +# grafana apiserver (standalone) + +The example-apiserver closely resembles the +[sample-apiserver](https://github.com/kubernetes/sample-apiserver/tree/master) project in code and thus +allows the same +[CLI flags](https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/) as kube-apiserver. +It is currently used for testing our deployment pipelines for aggregated servers. You can optionally omit the +aggregation path altogether and just run this example apiserver as a standalone process. + +## Standalone Mode + +### Usage + +```shell +go run ./pkg/cmd/grafana apiserver \ + --runtime-config=example.grafana.app/v0alpha1=true \ + --grafana-apiserver-dev-mode \ + --verbosity 10 \ + --secure-port 7443 +``` + +### Verify that all works + +In dev mode, the standalone server's loopback kubeconfig is written to `./data/grafana-apiserver/apiserver.kubeconfig`. + +```shell +export KUBECONFIG=./data/grafana-apiserver/apiserver.kubeconfig + +kubectl api-resources +NAME SHORTNAMES APIVERSION NAMESPACED KIND +dummy example.grafana.app/v0alpha1 true DummyResource +runtime example.grafana.app/v0alpha1 false RuntimeInfo +``` + diff --git a/pkg/cmd/grafana/apiserver/cmd.go b/pkg/cmd/grafana/apiserver/cmd.go index 11cb3e4db9135..dd63ab8a3d4e5 100644 --- a/pkg/cmd/grafana/apiserver/cmd.go +++ b/pkg/cmd/grafana/apiserver/cmd.go @@ -5,22 +5,49 @@ import ( "github.com/spf13/cobra" genericapiserver "k8s.io/apiserver/pkg/server" - "k8s.io/apiserver/pkg/server/options" "k8s.io/component-base/cli" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/server" + "github.com/grafana/grafana/pkg/services/apiserver/standalone" ) func newCommandStartExampleAPIServer(o *APIServerOptions, stopCh <-chan struct{}) *cobra.Command { - devAcknowledgementNotice := "The apiserver command is in heavy development. The entire setup is subject to change without notice" + devAcknowledgementNotice := "The apiserver command is in heavy development. The entire setup is subject to change without notice" + runtimeConfig := "" + + factory, err := server.InitializeAPIServerFactory() + if err != nil { + return nil + } + o.factory = factory cmd := &cobra.Command{ Use: "apiserver [api group(s)]", Short: "Run the grafana apiserver", Long: "Run a standalone kubernetes based apiserver that can be aggregated by a root apiserver. " + devAcknowledgementNotice, - Example: "grafana apiserver example.grafana.app", + Example: "grafana apiserver --runtime-config=example.grafana.app/v0alpha1=true", RunE: func(c *cobra.Command, args []string) error { + if err := log.SetupConsoleLogger("debug"); err != nil { + return nil + } + + if err := o.Validate(); err != nil { + return err + } + + runtime, err := standalone.ReadRuntimeConfig(runtimeConfig) + if err != nil { + return err + } + apis, err := o.factory.GetEnabled(runtime) + if err != nil { + return err + } + // Load each group from the args - if err := o.LoadAPIGroupBuilders(args[1:]); err != nil { + if err := o.loadAPIGroupBuilders(apis); err != nil { return err } @@ -41,11 +68,15 @@ func newCommandStartExampleAPIServer(o *APIServerOptions, stopCh <-chan struct{} }, } + cmd.Flags().StringVar(&runtimeConfig, "runtime-config", "", "A set of key=value pairs that enable or disable built-in APIs.") + + if factoryOptions := o.factory.GetOptions(); factoryOptions != nil { + factoryOptions.AddFlags(cmd.Flags()) + } + + o.ExtraOptions.AddFlags(cmd.Flags()) + // Register standard k8s flags with the command line - o.RecommendedOptions = options.NewRecommendedOptions( - defaultEtcdPathPrefix, - Codecs.LegacyCodec(), // the codec is passed to etcd and not used - ) o.RecommendedOptions.AddFlags(cmd.Flags()) return cmd diff --git a/pkg/cmd/grafana/apiserver/deploy/base/apiservice.yaml b/pkg/cmd/grafana/apiserver/deploy/aggregator-test/apiservice.yaml similarity index 94% rename from pkg/cmd/grafana/apiserver/deploy/base/apiservice.yaml rename to pkg/cmd/grafana/apiserver/deploy/aggregator-test/apiservice.yaml index c86833954b865..65cc2a5884a59 100644 --- a/pkg/cmd/grafana/apiserver/deploy/base/apiservice.yaml +++ b/pkg/cmd/grafana/apiserver/deploy/aggregator-test/apiservice.yaml @@ -1,3 +1,4 @@ +--- apiVersion: apiregistration.k8s.io/v1 kind: APIService metadata: @@ -11,4 +12,4 @@ spec: service: name: example-apiserver namespace: grafana - port: 8443 + port: 7443 diff --git a/pkg/cmd/grafana/apiserver/deploy/aggregator-test/externalname.yaml b/pkg/cmd/grafana/apiserver/deploy/aggregator-test/externalname.yaml new file mode 100644 index 0000000000000..75009779106e2 --- /dev/null +++ b/pkg/cmd/grafana/apiserver/deploy/aggregator-test/externalname.yaml @@ -0,0 +1,8 @@ +apiVersion: service.grafana.app/v0alpha1 +kind: ExternalName +metadata: + name: example-apiserver + namespace: grafana +spec: + host: localhost + diff --git a/pkg/cmd/grafana/apiserver/deploy/base/kustomization.yaml b/pkg/cmd/grafana/apiserver/deploy/aggregator-test/kustomization.yaml similarity index 58% rename from pkg/cmd/grafana/apiserver/deploy/base/kustomization.yaml rename to pkg/cmd/grafana/apiserver/deploy/aggregator-test/kustomization.yaml index cc578fada2d2f..15829bf3c27b7 100644 --- a/pkg/cmd/grafana/apiserver/deploy/base/kustomization.yaml +++ b/pkg/cmd/grafana/apiserver/deploy/aggregator-test/kustomization.yaml @@ -1,3 +1,3 @@ resources: - - namespace.yaml - apiservice.yaml + - externalname.yaml diff --git a/pkg/cmd/grafana/apiserver/deploy/base/namespace.yaml b/pkg/cmd/grafana/apiserver/deploy/base/namespace.yaml deleted file mode 100644 index 201d7d3e55be7..0000000000000 --- a/pkg/cmd/grafana/apiserver/deploy/base/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: grafana diff --git a/pkg/cmd/grafana/apiserver/deploy/darwin/kustomization.yaml b/pkg/cmd/grafana/apiserver/deploy/darwin/kustomization.yaml deleted file mode 100644 index 50deda0eb2edd..0000000000000 --- a/pkg/cmd/grafana/apiserver/deploy/darwin/kustomization.yaml +++ /dev/null @@ -1,5 +0,0 @@ -namespace: grafana - -resources: - - ../base - - service.yaml diff --git a/pkg/cmd/grafana/apiserver/deploy/darwin/service.yaml b/pkg/cmd/grafana/apiserver/deploy/darwin/service.yaml deleted file mode 100644 index 08fad7959f8cb..0000000000000 --- a/pkg/cmd/grafana/apiserver/deploy/darwin/service.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: example-apiserver -spec: - type: ExternalName - externalName: host.docker.internal - ports: - - port: 8443 - name: https diff --git a/pkg/cmd/grafana/apiserver/deploy/linux/kustomization.yaml b/pkg/cmd/grafana/apiserver/deploy/linux/kustomization.yaml deleted file mode 100644 index 50deda0eb2edd..0000000000000 --- a/pkg/cmd/grafana/apiserver/deploy/linux/kustomization.yaml +++ /dev/null @@ -1,5 +0,0 @@ -namespace: grafana - -resources: - - ../base - - service.yaml diff --git a/pkg/cmd/grafana/apiserver/deploy/linux/service.yaml b/pkg/cmd/grafana/apiserver/deploy/linux/service.yaml deleted file mode 100644 index 2f3b9b52eeddb..0000000000000 --- a/pkg/cmd/grafana/apiserver/deploy/linux/service.yaml +++ /dev/null @@ -1,23 +0,0 @@ ---- -apiVersion: v1 -kind: Endpoints -metadata: - name: example-apiserver -subsets: - - addresses: - - ip: 172.17.0.1 # this is the gateway IP in the "bridge" docker network - ports: - - appProtocol: https - port: 8443 - protocol: TCP ---- -apiVersion: v1 -kind: Service -metadata: - name: example-apiserver -spec: - ports: - - protocol: TCP - appProtocol: https - port: 8443 - targetPort: 8443 diff --git a/pkg/cmd/grafana/apiserver/server.go b/pkg/cmd/grafana/apiserver/server.go index f47542dbf9356..28e51181154d1 100644 --- a/pkg/cmd/grafana/apiserver/server.go +++ b/pkg/cmd/grafana/apiserver/server.go @@ -6,22 +6,19 @@ import ( "net" "path" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/runtime/serializer" utilerrors "k8s.io/apimachinery/pkg/util/errors" - openapinamer "k8s.io/apiserver/pkg/endpoints/openapi" genericapiserver "k8s.io/apiserver/pkg/server" "k8s.io/apiserver/pkg/server/options" - "k8s.io/apiserver/pkg/util/openapi" "k8s.io/client-go/tools/clientcmd" netutils "k8s.io/utils/net" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils" - - "github.com/grafana/grafana/pkg/registry/apis/example" - grafanaAPIServer "github.com/grafana/grafana/pkg/services/grafana-apiserver" + "github.com/grafana/grafana/pkg/apiserver/builder" + grafanaAPIServer "github.com/grafana/grafana/pkg/services/apiserver" + grafanaAPIServerOptions "github.com/grafana/grafana/pkg/services/apiserver/options" + "github.com/grafana/grafana/pkg/services/apiserver/standalone" + "github.com/grafana/grafana/pkg/services/apiserver/utils" + "github.com/grafana/grafana/pkg/setting" ) const ( @@ -29,30 +26,11 @@ const ( dataPath = "data/grafana-apiserver" // same as grafana core ) -var ( - Scheme = runtime.NewScheme() - Codecs = serializer.NewCodecFactory(Scheme) - - unversionedVersion = schema.GroupVersion{Group: "", Version: "v1"} - unversionedTypes = []runtime.Object{ - &metav1.Status{}, - &metav1.WatchEvent{}, - &metav1.APIVersions{}, - &metav1.APIGroupList{}, - &metav1.APIGroup{}, - &metav1.APIResourceList{}, - } -) - -func init() { - // we need to add the options to empty v1 - metav1.AddToGroupVersion(Scheme, schema.GroupVersion{Group: "", Version: "v1"}) - Scheme.AddUnversionedTypes(unversionedVersion, unversionedTypes...) -} - // APIServerOptions contains the state for the apiserver type APIServerOptions struct { - builders []grafanaAPIServer.APIGroupBuilder + factory standalone.APIServerFactory + builders []builder.APIGroupBuilder + ExtraOptions *grafanaAPIServerOptions.ExtraOptions RecommendedOptions *options.RecommendedOptions AlternateDNS []string @@ -64,28 +42,31 @@ func newAPIServerOptions(out, errOut io.Writer) *APIServerOptions { return &APIServerOptions{ StdOut: out, StdErr: errOut, + RecommendedOptions: options.NewRecommendedOptions( + defaultEtcdPathPrefix, + grafanaAPIServer.Codecs.LegacyCodec(), // the codec is passed to etcd and not used + ), + ExtraOptions: grafanaAPIServerOptions.NewExtraOptions(), } } -func (o *APIServerOptions) LoadAPIGroupBuilders(args []string) error { - o.builders = []grafanaAPIServer.APIGroupBuilder{} - for _, g := range args { - switch g { - // No dependencies for testing - case "example.grafana.app": - o.builders = append(o.builders, example.NewTestingAPIBuilder()) - default: - return fmt.Errorf("unknown group: %s", g) +func (o *APIServerOptions) loadAPIGroupBuilders(apis []schema.GroupVersion) error { + o.builders = []builder.APIGroupBuilder{} + for _, gv := range apis { + api, err := o.factory.MakeAPIServer(gv) + if err != nil { + return err } + o.builders = append(o.builders, api) } if len(o.builders) < 1 { - return fmt.Errorf("expected group name(s) in the command line arguments") + return fmt.Errorf("no apis matched ") } // Install schemas for _, b := range o.builders { - if err := b.InstallSchema(Scheme); err != nil { + if err := b.InstallSchema(grafanaAPIServer.Scheme); err != nil { return err } } @@ -116,9 +97,18 @@ func (o *APIServerOptions) ModifiedApplyTo(config *genericapiserver.RecommendedC if err := o.RecommendedOptions.Audit.ApplyTo(&config.Config); err != nil { return err } - //if err := o.RecommendedOptions.Features.ApplyTo(&config.Config); err != nil { - // return err - //} + + // TODO: determine whether we need flow control (API priority and fairness) + // We can't assume that a shared informers config was provided in standalone mode and will need a guard + // when enabling below + /* kubeClient, err := kubernetes.NewForConfig(config.ClientConfig) + if err != nil { + return err + } + + if err := o.RecommendedOptions.Features.ApplyTo(&config.Config, kubeClient, config.SharedInformerFactory); err != nil { + return err + } */ if err := o.RecommendedOptions.CoreAPI.ApplyTo(config); err != nil { return err @@ -128,6 +118,7 @@ func (o *APIServerOptions) ModifiedApplyTo(config *genericapiserver.RecommendedC if err != nil { return err } + return nil } @@ -139,7 +130,11 @@ func (o *APIServerOptions) Config() (*genericapiserver.RecommendedConfig, error) } o.RecommendedOptions.Authentication.RemoteKubeConfigFileOptional = true - o.RecommendedOptions.Authorization.RemoteKubeConfigFileOptional = true + + // TODO: determine authorization, currently insecure because Authorization provided by recommended options doesn't work + // reason: an aggregated server won't be able to post subjectaccessreviews (Grafana doesn't have this kind) + // exact error: the server could not find the requested resource (post subjectaccessreviews.authorization.k8s.io) + o.RecommendedOptions.Authorization = nil o.RecommendedOptions.Admission = nil o.RecommendedOptions.Etcd = nil @@ -148,7 +143,7 @@ func (o *APIServerOptions) Config() (*genericapiserver.RecommendedConfig, error) o.RecommendedOptions.CoreAPI = nil } - serverConfig := genericapiserver.NewRecommendedConfig(Codecs) + serverConfig := genericapiserver.NewRecommendedConfig(grafanaAPIServer.Codecs) if o.RecommendedOptions.CoreAPI == nil { if err := o.ModifiedApplyTo(serverConfig); err != nil { @@ -160,25 +155,38 @@ func (o *APIServerOptions) Config() (*genericapiserver.RecommendedConfig, error) } } - // Add OpenAPI specs for each group+version - defsGetter := grafanaAPIServer.GetOpenAPIDefinitions(o.builders) - serverConfig.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig( - openapi.GetOpenAPIDefinitionsWithoutDisabledFeatures(defsGetter), - openapinamer.NewDefinitionNamer(Scheme)) + if o.ExtraOptions != nil { + if err := o.ExtraOptions.ApplyTo(serverConfig); err != nil { + return nil, err + } + } - serverConfig.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config( - openapi.GetOpenAPIDefinitionsWithoutDisabledFeatures(defsGetter), - openapinamer.NewDefinitionNamer(Scheme)) + serverConfig.DisabledPostStartHooks = serverConfig.DisabledPostStartHooks.Insert("generic-apiserver-start-informers") + serverConfig.DisabledPostStartHooks = serverConfig.DisabledPostStartHooks.Insert("priority-and-fairness-config-consumer") - return serverConfig, nil + // Add OpenAPI specs for each group+version + err := builder.SetupConfig( + grafanaAPIServer.Scheme, + serverConfig, + o.builders, + setting.BuildStamp, + setting.BuildVersion, + setting.BuildCommit, + setting.BuildBranch, + ) + return serverConfig, err } // Validate validates APIServerOptions -// NOTE: we don't call validate on the top level recommended options as it doesn't like skipping etcd-servers -// the function is left here for troubleshooting any other config issues -func (o *APIServerOptions) Validate(args []string) error { - errors := []error{} - errors = append(errors, o.RecommendedOptions.Validate()...) +func (o *APIServerOptions) Validate() error { + errors := make([]error, 0) + // NOTE: we don't call validate on the top level recommended options as it doesn't like skipping etcd-servers + // the function is left here for troubleshooting any other config issues + // errors = append(errors, o.RecommendedOptions.Validate()...) + if factoryOptions := o.factory.GetOptions(); factoryOptions != nil { + errors = append(errors, factoryOptions.ValidateOptions()...) + } + return utilerrors.NewAggregate(errors) } @@ -190,31 +198,23 @@ func (o *APIServerOptions) Complete() error { func (o *APIServerOptions) RunAPIServer(config *genericapiserver.RecommendedConfig, stopCh <-chan struct{}) error { delegationTarget := genericapiserver.NewEmptyDelegate() completedConfig := config.Complete() - server, err := completedConfig.New("example-apiserver", delegationTarget) + + server, err := completedConfig.New("standalone-apiserver", delegationTarget) if err != nil { return err } // Install the API Group+version - for _, b := range o.builders { - g, err := b.GetAPIGroupInfo(Scheme, Codecs, completedConfig.RESTOptionsGetter) - if err != nil { - return err - } - if g == nil || len(g.PrioritizedVersions) < 1 { - continue - } - err = server.InstallAPIGroup(g) - if err != nil { - return err - } + err = builder.InstallAPIs(grafanaAPIServer.Scheme, grafanaAPIServer.Codecs, server, config.RESTOptionsGetter, o.builders, true) + if err != nil { + return err } - // in standalone mode, write the local config to disk - if o.RecommendedOptions.CoreAPI == nil { + // write the local config to disk + if o.ExtraOptions.DevMode { if err = clientcmd.WriteToFile( utils.FormatKubeConfig(server.LoopbackClientConfig), - path.Join(dataPath, "grafana.kubeconfig"), + path.Join(dataPath, "apiserver.kubeconfig"), ); err != nil { return err } diff --git a/pkg/cmd/grafana/main.go b/pkg/cmd/grafana/main.go index e543ee0fd10de..696198e40b94b 100644 --- a/pkg/cmd/grafana/main.go +++ b/pkg/cmd/grafana/main.go @@ -45,6 +45,7 @@ func main() { return nil }, }, + gsrv.ServerCommand(version, commit, enterpriseCommit, buildBranch, buildstamp), }, CommandNotFound: cmdNotFound, EnableBashCompletion: true, diff --git a/pkg/codegen/generators.go b/pkg/codegen/generators.go index f4558c48ba7e2..6aadfd45ae879 100644 --- a/pkg/codegen/generators.go +++ b/pkg/codegen/generators.go @@ -5,6 +5,7 @@ import ( "fmt" "path/filepath" + "cuelang.org/go/cue" "github.com/grafana/codejen" "github.com/grafana/kindsys" "github.com/grafana/thema" @@ -15,19 +16,6 @@ type OneToMany codejen.OneToMany[kindsys.Kind] type ManyToOne codejen.ManyToOne[kindsys.Kind] type ManyToMany codejen.ManyToMany[kindsys.Kind] -// ForLatestSchema returns a [SchemaForGen] for the latest schema in the -// provided [kindsys.Kind]'s lineage. -// -// TODO this will be replaced by thema-native constructs -func ForLatestSchema(k kindsys.Kind) SchemaForGen { - comm := k.Props().Common() - return SchemaForGen{ - Name: comm.Name, - Schema: k.Lineage().Latest(), - IsGroup: comm.LineageIsGroup, - } -} - // SlashHeaderMapper produces a FileMapper that injects a comment header onto // a [codejen.File] indicating the main generator that produced it (via the provided // maingen, which should be a path) and the jenny or jennies that constructed the @@ -72,3 +60,8 @@ type SchemaForGen struct { // Whether the schema is grouped. See https://github.com/grafana/thema/issues/62 IsGroup bool } + +type CueSchema struct { + CueFile cue.Value + FilePath string +} diff --git a/pkg/codegen/jenny_basecorereg.go b/pkg/codegen/jenny_basecorereg.go deleted file mode 100644 index 1a19a9aca8c8a..0000000000000 --- a/pkg/codegen/jenny_basecorereg.go +++ /dev/null @@ -1,64 +0,0 @@ -package codegen - -import ( - "bytes" - "fmt" - "path/filepath" - - "github.com/grafana/codejen" - "github.com/grafana/kindsys" -) - -// BaseCoreRegistryJenny generates a static registry for core kinds that -// only initializes their [kindsys.Kind]. No slot kinds are composed. -// -// Path should be the relative path to the directory that will contain the -// generated registry. kindrelroot should be the repo-root-relative path to the -// parent directory to all directories that contain generated kind bindings -// (e.g. pkg/kind). -func BaseCoreRegistryJenny(path, kindrelroot string) ManyToOne { - return &genBaseRegistry{ - path: path, - kindrelroot: kindrelroot, - } -} - -type genBaseRegistry struct { - path string - kindrelroot string -} - -func (gen *genBaseRegistry) JennyName() string { - return "BaseCoreRegistryJenny" -} - -func (gen *genBaseRegistry) Generate(kinds ...kindsys.Kind) (*codejen.File, error) { - cores := make([]kindsys.Core, 0, len(kinds)) - for _, d := range kinds { - if corekind, is := d.(kindsys.Core); is { - cores = append(cores, corekind) - } - } - if len(cores) == 0 { - return nil, nil - } - - buf := new(bytes.Buffer) - if err := tmpls.Lookup("kind_registry.tmpl").Execute(buf, tvars_kind_registry{ - PackageName: filepath.Base(gen.path), - KindPackagePrefix: filepath.ToSlash(filepath.Join("github.com/grafana/grafana", gen.kindrelroot)), - Kinds: cores, - }); err != nil { - return nil, fmt.Errorf("failed executing kind registry template: %w", err) - } - - b, err := postprocessGoFile(genGoFile{ - path: gen.path, - in: buf.Bytes(), - }) - if err != nil { - return nil, err - } - - return codejen.NewFile(filepath.Join(gen.path, "base_gen.go"), b, gen), nil -} diff --git a/pkg/codegen/jenny_core_registry.go b/pkg/codegen/jenny_core_registry.go new file mode 100644 index 0000000000000..2db4702e96d35 --- /dev/null +++ b/pkg/codegen/jenny_core_registry.go @@ -0,0 +1,62 @@ +package codegen + +import ( + "bytes" + "fmt" + "go/format" + "path/filepath" + "strings" + + "cuelang.org/go/cue" + "github.com/grafana/codejen" +) + +var registryPath = filepath.Join("pkg", "registry", "schemas") + +// CoreRegistryJenny generates a registry with all core kinds. +type CoreRegistryJenny struct { +} + +func (jenny *CoreRegistryJenny) JennyName() string { + return "CoreRegistryJenny" +} + +func (jenny *CoreRegistryJenny) Generate(cueFiles []CueSchema) (codejen.Files, error) { + schemas := make([]Schema, len(cueFiles)) + for i, v := range cueFiles { + name, err := getSchemaName(v.CueFile) + if err != nil { + return nil, err + } + + schemas[i] = Schema{ + Name: name, + FilePath: v.FilePath, + } + } + + buf := new(bytes.Buffer) + if err := tmpls.Lookup("core_registry.tmpl").Execute(buf, tvars_registry{ + Schemas: schemas, + }); err != nil { + return nil, fmt.Errorf("failed executing kind registry template: %w", err) + } + + b, err := format.Source(buf.Bytes()) + if err != nil { + return nil, err + } + + file := codejen.NewFile(filepath.Join(registryPath, "core_kind.go"), b, jenny) + return codejen.Files{*file}, nil +} + +func getSchemaName(v cue.Value) (string, error) { + name, err := getPackageName(v) + if err != nil { + return "", err + } + + name = strings.Replace(name, "-", "_", -1) + return strings.ToLower(name), nil +} diff --git a/pkg/codegen/jenny_corekind.go b/pkg/codegen/jenny_corekind.go deleted file mode 100644 index b7deb36cfb501..0000000000000 --- a/pkg/codegen/jenny_corekind.go +++ /dev/null @@ -1,72 +0,0 @@ -package codegen - -import ( - "bytes" - "fmt" - "path/filepath" - - "github.com/grafana/codejen" - "github.com/grafana/kindsys" -) - -// CoreKindJenny generates the implementation of [kindsys.Core] for the provided -// kind declaration. -// -// gokindsdir should be the relative path to the parent directory that contains -// all generated kinds. -// -// This generator only has output for core structured kinds. -func CoreKindJenny(gokindsdir string, cfg *CoreKindJennyConfig) OneToOne { - if cfg == nil { - cfg = new(CoreKindJennyConfig) - } - if cfg.GenDirName == nil { - cfg.GenDirName = func(def kindsys.Kind) string { - return def.Props().Common().MachineName - } - } - - return &coreKindJenny{ - gokindsdir: gokindsdir, - cfg: cfg, - } -} - -// CoreKindJennyConfig holds configuration options for [CoreKindJenny]. -type CoreKindJennyConfig struct { - // GenDirName returns the name of the directory in which the file should be - // generated. Defaults to DefForGen.Lineage().Name() if nil. - GenDirName func(kindsys.Kind) string -} - -type coreKindJenny struct { - gokindsdir string - cfg *CoreKindJennyConfig -} - -var _ OneToOne = &coreKindJenny{} - -func (gen *coreKindJenny) JennyName() string { - return "CoreKindJenny" -} - -func (gen *coreKindJenny) Generate(kind kindsys.Kind) (*codejen.File, error) { - if _, is := kind.(kindsys.Core); !is { - return nil, nil - } - - path := filepath.Join(gen.gokindsdir, gen.cfg.GenDirName(kind), kind.Props().Common().MachineName+"_kind_gen.go") - buf := new(bytes.Buffer) - if err := tmpls.Lookup("kind_core.tmpl").Execute(buf, kind); err != nil { - return nil, fmt.Errorf("failed executing kind_core template for %s: %w", path, err) - } - b, err := postprocessGoFile(genGoFile{ - path: path, - in: buf.Bytes(), - }) - if err != nil { - return nil, err - } - - return codejen.NewFile(path, b, gen), nil -} diff --git a/pkg/codegen/jenny_docs.go b/pkg/codegen/jenny_docs.go deleted file mode 100644 index 1883faff00b09..0000000000000 --- a/pkg/codegen/jenny_docs.go +++ /dev/null @@ -1,793 +0,0 @@ -package codegen - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "path" - "path/filepath" - "reflect" - "sort" - "strings" - "text/template" - - "cuelang.org/go/cue/cuecontext" - "github.com/grafana/codejen" - "github.com/grafana/kindsys" - "github.com/grafana/thema/encoding/jsonschema" - "github.com/olekukonko/tablewriter" - "github.com/xeipuuv/gojsonpointer" - - "github.com/grafana/grafana/pkg/components/simplejson" -) - -func DocsJenny(docsPath string) OneToOne { - return docsJenny{ - docsPath: docsPath, - } -} - -type docsJenny struct { - docsPath string -} - -func (j docsJenny) JennyName() string { - return "DocsJenny" -} - -func (j docsJenny) Generate(kind kindsys.Kind) (*codejen.File, error) { - // TODO remove this once codejen catches nils https://github.com/grafana/codejen/issues/5 - if kind == nil { - return nil, nil - } - - f, err := jsonschema.GenerateSchema(kind.Lineage().Latest()) - if err != nil { - return nil, fmt.Errorf("failed to generate json representation for the schema: %v", err) - } - b, err := cuecontext.New().BuildFile(f).MarshalJSON() - if err != nil { - return nil, fmt.Errorf("failed to marshal schema value to json: %v", err) - } - - // We don't need entire json obj, only the value of components.schemas path - var obj struct { - Info struct { - Title string - } - Components struct { - Schemas json.RawMessage - } - } - dec := json.NewDecoder(bytes.NewReader(b)) - dec.UseNumber() - err = dec.Decode(&obj) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal schema json: %v", err) - } - - // fixes the references between the types within a json after making components.schema.<types> the root of the json - kindJsonStr := strings.Replace(string(obj.Components.Schemas), "#/components/schemas/", "#/", -1) - - kindProps := kind.Props().Common() - data := templateData{ - KindName: kindProps.Name, - KindVersion: kind.Lineage().Latest().Version().String(), - KindMaturity: fmt.Sprintf("[%s](../../../maturity/#%[1]s)", kindProps.Maturity), - KindDescription: kindProps.Description, - Markdown: "{{ .Markdown }}", - } - - tmpl, err := makeTemplate(data, "docs.tmpl") - if err != nil { - return nil, err - } - - doc, err := jsonToMarkdown([]byte(kindJsonStr), string(tmpl), obj.Info.Title) - if err != nil { - return nil, fmt.Errorf("failed to build markdown for kind %s: %v", kindProps.Name, err) - } - - return codejen.NewFile(filepath.Join(j.docsPath, strings.ToLower(kindProps.Name), "schema-reference.md"), doc, j), nil -} - -// makeTemplate pre-populates the template with the kind metadata -func makeTemplate(data templateData, tmpl string) ([]byte, error) { - buf := new(bytes.Buffer) - if err := tmpls.Lookup(tmpl).Execute(buf, data); err != nil { - return []byte{}, fmt.Errorf("failed to populate docs template with the kind metadata") - } - return buf.Bytes(), nil -} - -type templateData struct { - KindName string - KindVersion string - KindMaturity string - KindDescription string - Markdown string -} - -// -------------------- JSON to Markdown conversion -------------------- -// Copied from https://github.com/marcusolsson/json-schema-docs and slightly changed to fit the DocsJenny -type constraints struct { - Pattern string `json:"pattern"` - Maximum json.Number `json:"maximum"` - ExclusiveMinimum bool `json:"exclusiveMinimum"` - Minimum json.Number `json:"minimum"` - ExclusiveMaximum bool `json:"exclusiveMaximum"` - MinLength uint `json:"minLength"` - MaxLength uint `json:"maxLength"` -} - -type schema struct { - constraints - ID string `json:"$id,omitempty"` - Ref string `json:"$ref,omitempty"` - Schema string `json:"$schema,omitempty"` - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` - Required []string `json:"required,omitempty"` - Type PropertyTypes `json:"type,omitempty"` - Properties map[string]*schema `json:"properties,omitempty"` - Items *schema `json:"items,omitempty"` - Definitions map[string]*schema `json:"definitions,omitempty"` - Enum []Any `json:"enum"` - Default any `json:"default"` - AllOf []*schema `json:"allOf"` - OneOf []*schema `json:"oneOf"` - AdditionalProperties *schema `json:"additionalProperties"` - extends []string `json:"-"` - inheritedFrom string `json:"-"` -} - -func renderMapType(props *schema) string { - if props == nil { - return "" - } - - if props.Type.HasType(PropertyTypeObject) { - name, anchor := propNameAndAnchor(props.Title, props.Title) - return fmt.Sprintf("[%s](#%s)", name, anchor) - } - - if props.AdditionalProperties != nil { - return "map[string]" + renderMapType(props.AdditionalProperties) - } - - if props.Items != nil { - return "[]" + renderMapType(props.Items) - } - - var types []string - for _, t := range props.Type { - types = append(types, string(t)) - } - return strings.Join(types, ", ") -} - -func jsonToMarkdown(jsonData []byte, tpl string, kindName string) ([]byte, error) { - sch, err := newSchema(jsonData, kindName) - if err != nil { - return []byte{}, err - } - - t, err := template.New("markdown").Parse(tpl) - if err != nil { - return []byte{}, err - } - - buf := new(bytes.Buffer) - err = t.Execute(buf, sch) - if err != nil { - return []byte{}, err - } - - return buf.Bytes(), nil -} - -func newSchema(b []byte, kindName string) (*schema, error) { - var data map[string]*schema - if err := json.Unmarshal(b, &data); err != nil { - return nil, err - } - - // Needed for resolving in-schema references. - root, err := simplejson.NewJson(b) - if err != nil { - return nil, err - } - - return resolveSchema(data[kindName], root) -} - -// resolveSchema recursively resolves schemas. -func resolveSchema(schem *schema, root *simplejson.Json) (*schema, error) { - for _, prop := range schem.Properties { - if prop.Ref != "" { - tmp, err := resolveReference(prop.Ref, root) - if err != nil { - return nil, err - } - *prop = *tmp - } - foo, err := resolveSchema(prop, root) - if err != nil { - return nil, err - } - *prop = *foo - } - - if schem.Items != nil { - if schem.Items.Ref != "" { - tmp, err := resolveReference(schem.Items.Ref, root) - if err != nil { - return nil, err - } - *schem.Items = *tmp - } - foo, err := resolveSchema(schem.Items, root) - if err != nil { - return nil, err - } - *schem.Items = *foo - } - - if len(schem.AllOf) > 0 { - for idx, child := range schem.AllOf { - tmp, err := resolveSubSchema(schem, child, root) - if err != nil { - return nil, err - } - schem.AllOf[idx] = tmp - - if len(tmp.Title) > 0 { - schem.extends = append(schem.extends, tmp.Title) - } - } - } - - if len(schem.OneOf) > 0 { - for idx, child := range schem.OneOf { - tmp, err := resolveSubSchema(schem, child, root) - if err != nil { - return nil, err - } - schem.OneOf[idx] = tmp - } - } - - if schem.AdditionalProperties != nil { - if schem.AdditionalProperties.Ref != "" { - tmp, err := resolveReference(schem.AdditionalProperties.Ref, root) - if err != nil { - return nil, err - } - *schem.AdditionalProperties = *tmp - } - foo, err := resolveSchema(schem.AdditionalProperties, root) - if err != nil { - return nil, err - } - *schem.AdditionalProperties = *foo - } - - return schem, nil -} - -func resolveSubSchema(parent, child *schema, root *simplejson.Json) (*schema, error) { - if child.Ref != "" { - tmp, err := resolveReference(child.Ref, root) - if err != nil { - return nil, err - } - *child = *tmp - } - - if len(child.Required) > 0 { - parent.Required = append(parent.Required, child.Required...) - } - - child, err := resolveSchema(child, root) - if err != nil { - return nil, err - } - - if parent.Properties == nil { - parent.Properties = make(map[string]*schema) - } - - for k, v := range child.Properties { - prop := *v - prop.inheritedFrom = child.Title - parent.Properties[k] = &prop - } - - return child, err -} - -// resolveReference loads a schema from a $ref. -// If ref contains a hashtag (#), the part after represents a in-schema reference. -func resolveReference(ref string, root *simplejson.Json) (*schema, error) { - i := strings.Index(ref, "#") - - if i != 0 { - return nil, fmt.Errorf("not in-schema reference: %s", ref) - } - return resolveInSchemaReference(ref[i+1:], root) -} - -func resolveInSchemaReference(ref string, root *simplejson.Json) (*schema, error) { - // in-schema reference - pointer, err := gojsonpointer.NewJsonPointer(ref) - if err != nil { - return nil, err - } - - v, _, err := pointer.Get(root.MustMap()) - if err != nil { - return nil, err - } - - var sch schema - b, err := json.Marshal(v) - if err != nil { - return nil, err - } - - if err := json.Unmarshal(b, &sch); err != nil { - return nil, err - } - - // Set the ref name as title - sch.Title = path.Base(ref) - - return &sch, nil -} - -type mdSection struct { - title string - extends string - description string - rows [][]string -} - -func (md mdSection) write(w io.Writer) { - if md.title != "" { - fmt.Fprintf(w, "### %s\n", strings.Title(md.title)) - fmt.Fprintln(w) - } - - if md.description != "" { - fmt.Fprintln(w, md.description) - fmt.Fprintln(w) - } - - if md.extends != "" { - fmt.Fprintln(w, md.extends) - fmt.Fprintln(w) - } - - table := tablewriter.NewWriter(w) - table.SetHeader([]string{"Property", "Type", "Required", "Default", "Description"}) - table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) - table.SetCenterSeparator("|") - table.SetAutoFormatHeaders(false) - table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) - table.SetAutoWrapText(false) - table.AppendBulk(md.rows) - table.Render() - fmt.Fprintln(w) -} - -// Markdown returns the Markdown representation of the schema. -// -// The level argument can be used to offset the heading levels. This can be -// useful if you want to add the schema under a subheading. -func (s *schema) Markdown() string { - buf := new(bytes.Buffer) - - for _, v := range s.sections() { - v.write(buf) - } - - return buf.String() -} - -func (s *schema) sections() []mdSection { - md := mdSection{} - - if s.AdditionalProperties == nil { - md.title = s.Title - } - md.description = s.Description - - if len(s.extends) > 0 { - md.extends = makeExtends(s.extends) - } - md.rows = makeRows(s) - - sections := []mdSection{md} - for _, sch := range findDefinitions(s) { - for _, ss := range sch.sections() { - if !contains(sections, ss) { - sections = append(sections, ss) - } - } - } - - return sections -} - -func contains(sl []mdSection, elem mdSection) bool { - for _, s := range sl { - if reflect.DeepEqual(s, elem) { - return true - } - } - return false -} - -func makeExtends(from []string) string { - fromLinks := make([]string, 0, len(from)) - for _, f := range from { - fromLinks = append(fromLinks, fmt.Sprintf("[%s](#%s)", f, strings.ToLower(f))) - } - - return fmt.Sprintf("It extends %s.", strings.Join(fromLinks, " and ")) -} - -func findDefinitions(s *schema) []*schema { - // Gather all properties of object type so that we can generate the - // properties for them recursively. - var objs []*schema - - definition := func(k string, p *schema) { - if p.Type.HasType(PropertyTypeObject) && p.AdditionalProperties == nil { - // Use the identifier as the title. - if len(p.Title) == 0 { - p.Title = k - } - objs = append(objs, p) - } - - // If the property is an array of objects, use the name of the array - // property as the title. - if p.Type.HasType(PropertyTypeArray) { - if p.Items != nil { - if p.Items.Type.HasType(PropertyTypeObject) { - if len(p.Items.Title) == 0 { - p.Items.Title = k - } - objs = append(objs, p.Items) - } - } - } - } - - for k, p := range s.Properties { - // If a property has AdditionalProperties, then it's a map - if p.AdditionalProperties != nil { - definition(k, p.AdditionalProperties) - } - - definition(k, p) - } - - // This code could probably be unified with the one above - for _, child := range s.AllOf { - if child.Type.HasType(PropertyTypeObject) { - objs = append(objs, child) - } - - if child.Type.HasType(PropertyTypeArray) { - if child.Items != nil { - if child.Items.Type.HasType(PropertyTypeObject) { - objs = append(objs, child.Items) - } - } - } - } - - for _, child := range s.OneOf { - if child.Type.HasType(PropertyTypeObject) { - objs = append(objs, child) - } - - if child.Type.HasType(PropertyTypeArray) { - if child.Items != nil { - if child.Items.Type.HasType(PropertyTypeObject) { - objs = append(objs, child.Items) - } - } - } - } - - // Sort the object schemas. - sort.Slice(objs, func(i, j int) bool { - return objs[i].Title < objs[j].Title - }) - - return objs -} - -func makeRows(s *schema) [][]string { - // Buffer all property rows so that we can sort them before printing them. - rows := make([][]string, 0, len(s.Properties)) - - var typeStr string - if len(s.OneOf) > 0 { - typeStr = enumStr(s) - rows = append(rows, []string{"`object`", typeStr, "", ""}) - return rows - } - - for key, p := range s.Properties { - alias := propTypeAlias(p) - - if alias != "" { - typeStr = alias - } else { - typeStr = propTypeStr(key, p) - } - - // Emphasize required properties. - var required string - if in(s.Required, key) { - required = "**Yes**" - } else { - required = "No" - } - - var desc string - if p.inheritedFrom != "" { - desc = fmt.Sprintf("*(Inherited from [%s](#%s))*", p.inheritedFrom, strings.ToLower(p.inheritedFrom)) - } - - if p.Description != "" { - desc += "\n" + p.Description - } - - if len(p.Enum) > 0 { - vals := make([]string, 0, len(p.Enum)) - for _, e := range p.Enum { - vals = append(vals, e.String()) - } - desc += "\nPossible values are: `" + strings.Join(vals, "`, `") + "`." - } - - var defaultValue string - if p.Default != nil { - defaultValue = fmt.Sprintf("`%v`", p.Default) - } - - // Render a constraint only if it's not a type alias https://cuelang.org/docs/references/spec/#predeclared-identifiers - if alias == "" { - desc += constraintDescr(p) - } - rows = append(rows, []string{fmt.Sprintf("`%s`", key), typeStr, required, defaultValue, formatForTable(desc)}) - } - - // Sort by the required column, then by the name column. - sort.Slice(rows, func(i, j int) bool { - if rows[i][2] < rows[j][2] { - return true - } - if rows[i][2] > rows[j][2] { - return false - } - return rows[i][0] < rows[j][0] - }) - return rows -} - -func propTypeAlias(prop *schema) string { - if prop.Minimum == "" || prop.Maximum == "" { - return "" - } - - min := prop.Minimum - max := prop.Maximum - - switch { - case min == "0" && max == "255": - return "uint8" - case min == "0" && max == "65535": - return "uint16" - case min == "0" && max == "4294967295": - return "uint32" - case min == "0" && max == "18446744073709551615": - return "uint64" - case min == "-128" && max == "127": - return "int8" - case min == "-32768" && max == "32767": - return "int16" - case min == "-2147483648" && max == "2147483647": - return "int32" - case min == "-9223372036854775808" && max == "9223372036854775807": - return "int64" - default: - return "" - } -} - -func constraintDescr(prop *schema) string { - if prop.Minimum != "" && prop.Maximum != "" { - var left, right string - if prop.ExclusiveMinimum { - left = ">" + prop.Minimum.String() - } else { - left = ">=" + prop.Minimum.String() - } - - if prop.ExclusiveMaximum { - right = "<" + prop.Maximum.String() - } else { - right = "<=" + prop.Maximum.String() - } - return fmt.Sprintf("\nConstraint: `%s & %s`.", left, right) - } - - if prop.MinLength > 0 { - left := fmt.Sprintf(">=%v", prop.MinLength) - right := "" - - if prop.MaxLength > 0 { - right = fmt.Sprintf(" && <=%v", prop.MaxLength) - } - return fmt.Sprintf("\nConstraint: `length %s`.", left+right) - } - - if prop.Pattern != "" { - return fmt.Sprintf("\nConstraint: must match `%s`.", prop.Pattern) - } - - return "" -} - -func enumStr(propValue *schema) string { - var vals []string - for _, v := range propValue.OneOf { - vals = append(vals, fmt.Sprintf("[%s](#%s)", v.Title, strings.ToLower(v.Title))) - } - return "Possible types are: " + strings.Join(vals, ", ") + "." -} - -func propTypeStr(propName string, propValue *schema) string { - // If the property has AdditionalProperties, it is most likely a map type - if propValue.AdditionalProperties != nil { - mapValue := renderMapType(propValue.AdditionalProperties) - return "map[string]" + mapValue - } - - propType := make([]string, 0, len(propValue.Type)) - // Generate relative links for objects and arrays of objects. - for _, pt := range propValue.Type { - switch pt { - case PropertyTypeObject: - name, anchor := propNameAndAnchor(propName, propValue.Title) - propType = append(propType, fmt.Sprintf("[%s](#%s)", name, anchor)) - case PropertyTypeArray: - if propValue.Items != nil { - for _, pi := range propValue.Items.Type { - if pi == PropertyTypeObject { - name, anchor := propNameAndAnchor(propName, propValue.Items.Title) - propType = append(propType, fmt.Sprintf("[%s](#%s)[]", name, anchor)) - } else { - propType = append(propType, fmt.Sprintf("%s[]", pi)) - } - } - } else { - propType = append(propType, string(pt)) - } - default: - propType = append(propType, string(pt)) - } - } - - if len(propType) == 0 { - return "" - } - - if len(propType) == 1 { - return propType[0] - } - - if len(propType) == 2 { - return strings.Join(propType, " or ") - } - - return fmt.Sprintf("%s, or %s", strings.Join(propType[:len(propType)-1], ", "), propType[len(propType)-1]) -} - -func propNameAndAnchor(prop, title string) (string, string) { - if len(title) > 0 { - return title, strings.ToLower(title) - } - return string(PropertyTypeObject), strings.ToLower(prop) -} - -// in returns true if a string slice contains a specific string. -func in(strs []string, str string) bool { - for _, s := range strs { - if s == str { - return true - } - } - return false -} - -// formatForTable returns string usable in a Markdown table. -// It trims white spaces, replaces new lines and pipe characters. -func formatForTable(in string) string { - s := strings.TrimSpace(in) - s = strings.ReplaceAll(s, "\n", "<br/>") - s = strings.ReplaceAll(s, "|", "|") - return s -} - -type PropertyTypes []PropertyType - -func (pts *PropertyTypes) HasType(pt PropertyType) bool { - for _, t := range *pts { - if t == pt { - return true - } - } - return false -} - -func (pts *PropertyTypes) UnmarshalJSON(data []byte) error { - var value any - if err := json.Unmarshal(data, &value); err != nil { - return err - } - - switch val := value.(type) { - case string: - *pts = []PropertyType{PropertyType(val)} - return nil - case []any: - var pt []PropertyType - for _, t := range val { - s, ok := t.(string) - if !ok { - return errors.New("unsupported property type") - } - pt = append(pt, PropertyType(s)) - } - *pts = pt - default: - return errors.New("unsupported property type") - } - - return nil -} - -type PropertyType string - -const ( - PropertyTypeString PropertyType = "string" - PropertyTypeNumber PropertyType = "number" - PropertyTypeBoolean PropertyType = "boolean" - PropertyTypeObject PropertyType = "object" - PropertyTypeArray PropertyType = "array" - PropertyTypeNull PropertyType = "null" -) - -type Any struct { - value any -} - -func (u *Any) UnmarshalJSON(data []byte) error { - if err := json.Unmarshal(data, &u.value); err != nil { - return err - } - return nil -} - -func (u *Any) String() string { - return fmt.Sprintf("%v", u.value) -} diff --git a/pkg/codegen/jenny_eachmajor.go b/pkg/codegen/jenny_eachmajor.go index 0bc3d1b8c216a..5fcc83028d2a2 100644 --- a/pkg/codegen/jenny_eachmajor.go +++ b/pkg/codegen/jenny_eachmajor.go @@ -5,28 +5,22 @@ import ( "path/filepath" "github.com/grafana/codejen" + "github.com/grafana/cuetsy/ts" + "github.com/grafana/cuetsy/ts/ast" "github.com/grafana/kindsys" ) // LatestMajorsOrXJenny returns a jenny that repeats the input for the latest in each major version. -// -// TODO remove forceGroup option, it's a temporary hack to accommodate core kinds -func LatestMajorsOrXJenny(parentdir string, forceGroup bool, inner codejen.OneToOne[SchemaForGen]) OneToMany { - if inner == nil { - panic("inner jenny must not be nil") - } - +func LatestMajorsOrXJenny(parentdir string) OneToMany { return &lmox{ - parentdir: parentdir, - inner: inner, - forceGroup: forceGroup, + parentdir: parentdir, + inner: TSTypesJenny{ApplyFuncs: []ApplyFunc{renameSpecNode}}, } } type lmox struct { - parentdir string - inner codejen.OneToOne[SchemaForGen] - forceGroup bool + parentdir string + inner codejen.OneToOne[SchemaForGen] } func (j *lmox) JennyName() string { @@ -42,49 +36,64 @@ func (j *lmox) Generate(kind kindsys.Kind) (codejen.Files, error) { comm := kind.Props().Common() sfg := SchemaForGen{ Name: comm.Name, - IsGroup: comm.LineageIsGroup, + IsGroup: true, + Schema: kind.Lineage().Latest(), } - if j.forceGroup { - sfg.IsGroup = true + f, err := j.inner.Generate(sfg) + if err != nil { + return nil, fmt.Errorf("%s jenny failed on %s schema for %s: %w", j.inner.JennyName(), sfg.Schema.Version(), kind.Props().Common().Name, err) } - - do := func(sfg SchemaForGen, infix string) (codejen.Files, error) { - f, err := j.inner.Generate(sfg) - if err != nil { - return nil, fmt.Errorf("%s jenny failed on %s schema for %s: %w", j.inner.JennyName(), sfg.Schema.Version(), kind.Props().Common().Name, err) - } - if f == nil || !f.Exists() { - return nil, nil - } - - f.RelativePath = filepath.Join(j.parentdir, comm.MachineName, infix, f.RelativePath) - f.From = append(f.From, j) - return codejen.Files{*f}, nil + if f == nil || !f.Exists() { + return nil, nil } - if comm.Maturity.Less(kindsys.MaturityStable) { - sfg.Schema = kind.Lineage().Latest() - return do(sfg, "x") - } + f.RelativePath = filepath.Join(j.parentdir, comm.MachineName, "x", f.RelativePath) + f.From = append(f.From, j) + return codejen.Files{*f}, nil +} - var fl codejen.Files - major := -1 - for sch := kind.Lineage().First(); sch != nil; sch = sch.Successor() { - if int(sch.Version()[0]) == major { - continue +// renameSpecNode rename spec node from the TS file result +func renameSpecNode(sfg SchemaForGen, tf *ast.File) { + specidx, specdefidx := -1, -1 + for idx, def := range tf.Nodes { + // Peer through export keywords + if ex, is := def.(ast.ExportKeyword); is { + def = ex.Decl } - major = int(sch.Version()[0]) - sfg.Schema = sch.LatestInMajor() - files, err := do(sfg, fmt.Sprintf("v%v", sch.Version()[0])) - if err != nil { - return nil, err + switch x := def.(type) { + case ast.TypeDecl: + if x.Name.Name == "spec" { + specidx = idx + x.Name.Name = sfg.Name + tf.Nodes[idx] = x + } + case ast.VarDecl: + // Before: + // export const defaultspec: Partial<spec> = { + // After: + // / export const defaultPlaylist: Partial<Playlist> = { + if x.Names.Idents[0].Name == "defaultspec" { + specdefidx = idx + x.Names.Idents[0].Name = "default" + sfg.Name + tt := x.Type.(ast.TypeTransformExpr) + tt.Expr = ts.Ident(sfg.Name) + x.Type = tt + tf.Nodes[idx] = x + } } - fl = append(fl, files...) } - if fl.Validate() != nil { - return nil, fl.Validate() + + if specidx != -1 { + decl := tf.Nodes[specidx] + tf.Nodes = append(append(tf.Nodes[:specidx], tf.Nodes[specidx+1:]...), decl) + } + if specdefidx != -1 { + if specdefidx > specidx { + specdefidx-- + } + decl := tf.Nodes[specdefidx] + tf.Nodes = append(append(tf.Nodes[:specdefidx], tf.Nodes[specdefidx+1:]...), decl) } - return fl, nil } diff --git a/pkg/codegen/jenny_go_resources.go b/pkg/codegen/jenny_go_resources.go deleted file mode 100644 index 72a3dfa5f6b96..0000000000000 --- a/pkg/codegen/jenny_go_resources.go +++ /dev/null @@ -1,128 +0,0 @@ -package codegen - -import ( - "bytes" - "fmt" - "go/format" - "strings" - - "cuelang.org/go/cue" - "github.com/dave/dst/dstutil" - "github.com/grafana/codejen" - "github.com/grafana/kindsys" - "github.com/grafana/thema/encoding/gocode" - "github.com/grafana/thema/encoding/openapi" -) - -var schPath = cue.MakePath(cue.Hid("_#schema", "github.com/grafana/thema")) - -type ResourceGoTypesJenny struct { - ApplyFuncs []dstutil.ApplyFunc - ExpandReferences bool -} - -func (*ResourceGoTypesJenny) JennyName() string { - return "GoTypesJenny" -} - -func (ag *ResourceGoTypesJenny) Generate(kind kindsys.Kind) (*codejen.File, error) { - comm := kind.Props().Common() - sfg := SchemaForGen{ - Name: comm.Name, - Schema: kind.Lineage().Latest(), - IsGroup: comm.LineageIsGroup, - } - sch := sfg.Schema - - iter, err := sch.Underlying().LookupPath(schPath).Fields() - if err != nil { - return nil, err - } - - var subr []string - for iter.Next() { - subr = append(subr, typeNameFromKey(iter.Selector().String())) - } - - buf := new(bytes.Buffer) - mname := kind.Props().Common().MachineName - if err := tmpls.Lookup("core_resource.tmpl").Execute(buf, tvars_resource{ - PackageName: mname, - KindName: kind.Props().Common().Name, - Version: strings.Replace(sfg.Schema.Version().String(), ".", "-", -1), - SubresourceNames: subr, - }); err != nil { - return nil, fmt.Errorf("failed executing core resource template: %w", err) - } - - if err != nil { - return nil, err - } - - content, err := format.Source(buf.Bytes()) - if err != nil { - return nil, err - } - - return codejen.NewFile(fmt.Sprintf("pkg/kinds/%s/%s_gen.go", mname, mname), content, ag), nil -} - -type SubresourceGoTypesJenny struct { - ApplyFuncs []dstutil.ApplyFunc - ExpandReferences bool -} - -func (*SubresourceGoTypesJenny) JennyName() string { - return "GoResourceTypes" -} - -func (g *SubresourceGoTypesJenny) Generate(kind kindsys.Kind) (codejen.Files, error) { - comm := kind.Props().Common() - sfg := SchemaForGen{ - Name: comm.Name, - Schema: kind.Lineage().Latest(), - IsGroup: comm.LineageIsGroup, - } - sch := sfg.Schema - - // Iterate through all top-level fields and make go types for them - // (this should consist of "spec" and arbitrary subresources) - i, err := sch.Underlying().LookupPath(schPath).Fields() - if err != nil { - return nil, err - } - files := make(codejen.Files, 0) - for i.Next() { - str := i.Selector().String() - - b, err := gocode.GenerateTypesOpenAPI(sch, &gocode.TypeConfigOpenAPI{ - // TODO will need to account for sanitizing e.g. dashes here at some point - Config: &openapi.Config{ - Group: false, // TODO: better - RootName: typeNameFromKey(str), - Subpath: cue.MakePath(cue.Str(str)), - }, - PackageName: sfg.Schema.Lineage().Name(), - ApplyFuncs: append(g.ApplyFuncs, PrefixDropper(sfg.Name)), - }) - if err != nil { - return nil, err - } - - name := sfg.Schema.Lineage().Name() - files = append(files, codejen.File{ - RelativePath: fmt.Sprintf("pkg/kinds/%s/%s_%s_gen.go", name, name, strings.ToLower(str)), - Data: b, - From: []codejen.NamedJenny{g}, - }) - } - - return files, nil -} - -func typeNameFromKey(key string) string { - if len(key) > 0 { - return strings.ToUpper(key[:1]) + key[1:] - } - return strings.ToUpper(key) -} diff --git a/pkg/codegen/jenny_go_spec.go b/pkg/codegen/jenny_go_spec.go new file mode 100644 index 0000000000000..82cab5c383145 --- /dev/null +++ b/pkg/codegen/jenny_go_spec.go @@ -0,0 +1,46 @@ +package codegen + +import ( + "fmt" + + "cuelang.org/go/cue" + "github.com/dave/dst/dstutil" + "github.com/grafana/codejen" + "github.com/grafana/kindsys" + "github.com/grafana/thema/encoding/gocode" + "github.com/grafana/thema/encoding/openapi" +) + +type GoSpecJenny struct { + ApplyFuncs []dstutil.ApplyFunc +} + +func (jenny *GoSpecJenny) JennyName() string { + return "GoResourceTypes" +} + +func (jenny *GoSpecJenny) Generate(kinds ...kindsys.Kind) (codejen.Files, error) { + files := make(codejen.Files, len(kinds)) + for i, v := range kinds { + name := v.Lineage().Name() + b, err := gocode.GenerateTypesOpenAPI(v.Lineage().Latest(), + &gocode.TypeConfigOpenAPI{ + Config: &openapi.Config{ + Group: false, + RootName: "Spec", + Subpath: cue.MakePath(cue.Str("spec")), + }, + PackageName: name, + ApplyFuncs: append(jenny.ApplyFuncs, PrefixDropper(v.Props().Common().Name)), + }, + ) + + if err != nil { + return nil, err + } + + files[i] = *codejen.NewFile(fmt.Sprintf("pkg/kinds/%s/%s_spec_gen.go", name, name), b, jenny) + } + + return files, nil +} diff --git a/pkg/codegen/jenny_go_types.go b/pkg/codegen/jenny_go_types.go deleted file mode 100644 index d1d83364a576a..0000000000000 --- a/pkg/codegen/jenny_go_types.go +++ /dev/null @@ -1,42 +0,0 @@ -package codegen - -import ( - copenapi "cuelang.org/go/encoding/openapi" - "github.com/dave/dst/dstutil" - "github.com/grafana/codejen" - "github.com/grafana/thema/encoding/gocode" - "github.com/grafana/thema/encoding/openapi" -) - -// GoTypesJenny is a [OneToOne] that produces Go types for the provided -// [thema.Schema]. -type GoTypesJenny struct { - ApplyFuncs []dstutil.ApplyFunc - ExpandReferences bool -} - -func (j GoTypesJenny) JennyName() string { - return "GoTypesJenny" -} - -func (j GoTypesJenny) Generate(sfg SchemaForGen) (*codejen.File, error) { - // TODO allow using name instead of machine name in thema generator - b, err := gocode.GenerateTypesOpenAPI(sfg.Schema, &gocode.TypeConfigOpenAPI{ - // TODO will need to account for sanitizing e.g. dashes here at some point - Config: &openapi.Config{ - Group: sfg.IsGroup, - RootName: sfg.Name, - Config: &copenapi.Config{ - ExpandReferences: j.ExpandReferences, - }, - }, - PackageName: sfg.Schema.Lineage().Name(), - ApplyFuncs: append(j.ApplyFuncs, PrefixDropper(sfg.Name)), - }) - - if err != nil { - return nil, err - } - - return codejen.NewFile(sfg.Schema.Lineage().Name()+"_types_gen.go", b, j), nil -} diff --git a/pkg/codegen/jenny_k8_resources.go b/pkg/codegen/jenny_k8_resources.go new file mode 100644 index 0000000000000..547ca2bcc7178 --- /dev/null +++ b/pkg/codegen/jenny_k8_resources.go @@ -0,0 +1,130 @@ +package codegen + +import ( + "bytes" + "fmt" + "go/format" + "strings" + + "cuelang.org/go/cue" + "github.com/grafana/codejen" +) + +// K8ResourcesJenny generates resource, metadata and status for each file. +type K8ResourcesJenny struct { +} + +func (jenny *K8ResourcesJenny) JennyName() string { + return "K8ResourcesJenny" +} + +func (jenny *K8ResourcesJenny) Generate(cueFiles []CueSchema) (codejen.Files, error) { + files := make(codejen.Files, 0) + for _, val := range cueFiles { + pkg, err := getPackageName(val.CueFile) + if err != nil { + return nil, err + } + + resource, err := jenny.genResource(pkg, val.CueFile) + if err != nil { + return nil, err + } + + metadata, err := jenny.genMetadata(pkg) + if err != nil { + return nil, err + } + + status, err := jenny.genStatus(pkg) + if err != nil { + return nil, err + } + + files = append(files, resource) + files = append(files, metadata) + files = append(files, status) + } + + return files, nil +} + +func (jenny *K8ResourcesJenny) genResource(pkg string, val cue.Value) (codejen.File, error) { + version, err := getVersion(val) + if err != nil { + return codejen.File{}, err + } + + pkgName := strings.ToLower(pkg) + + buf := new(bytes.Buffer) + if err := tmpls.Lookup("core_resource.tmpl").Execute(buf, tvars_resource{ + PackageName: pkgName, + KindName: pkg, + Version: version, + }); err != nil { + return codejen.File{}, fmt.Errorf("failed executing core resource template: %w", err) + } + + content, err := format.Source(buf.Bytes()) + if err != nil { + return codejen.File{}, err + } + + return *codejen.NewFile(fmt.Sprintf("pkg/kinds/%s/%s_gen.go", pkgName, pkgName), content, jenny), nil +} + +func (jenny *K8ResourcesJenny) genMetadata(pkg string) (codejen.File, error) { + pkg = strings.ToLower(pkg) + + buf := new(bytes.Buffer) + if err := tmpls.Lookup("core_metadata.tmpl").Execute(buf, tvars_metadata{ + PackageName: pkg, + }); err != nil { + return codejen.File{}, fmt.Errorf("failed executing core resource template: %w", err) + } + + return *codejen.NewFile(fmt.Sprintf("pkg/kinds/%s/%s_metadata_gen.go", pkg, pkg), buf.Bytes(), jenny), nil +} + +func (jenny *K8ResourcesJenny) genStatus(pkg string) (codejen.File, error) { + pkg = strings.ToLower(pkg) + + buf := new(bytes.Buffer) + if err := tmpls.Lookup("core_status.tmpl").Execute(buf, tvars_status{ + PackageName: pkg, + }); err != nil { + return codejen.File{}, fmt.Errorf("failed executing core resource template: %w", err) + } + + return *codejen.NewFile(fmt.Sprintf("pkg/kinds/%s/%s_status_gen.go", pkg, pkg), buf.Bytes(), jenny), nil +} + +func getPackageName(val cue.Value) (string, error) { + name := val.LookupPath(cue.ParsePath("name")) + pkg, err := name.String() + if err != nil { + return "", fmt.Errorf("file doesn't have name field set: %s", err) + } + return pkg, nil +} + +func getVersion(val cue.Value) (string, error) { + val = val.LookupPath(cue.ParsePath("lineage.schemas[0].version")) + versionValues, err := val.List() + if err != nil { + return "", fmt.Errorf("missing version in schema: %s", err) + } + + version := make([]int64, 0) + for versionValues.Next() { + v, err := versionValues.Value().Int64() + if err != nil { + return "", fmt.Errorf("version should be a list of two elements: %s", err) + } + + version = append(version, v) + } + + return fmt.Sprintf("%d-%d", version[0], version[1]), nil +} diff --git a/pkg/codegen/jenny_ts_resources.go b/pkg/codegen/jenny_ts_resources.go deleted file mode 100644 index 9e3391a6276b1..0000000000000 --- a/pkg/codegen/jenny_ts_resources.go +++ /dev/null @@ -1,85 +0,0 @@ -package codegen - -import ( - "github.com/grafana/codejen" - "github.com/grafana/cuetsy" - "github.com/grafana/cuetsy/ts" - "github.com/grafana/cuetsy/ts/ast" - "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/thema/encoding/typescript" -) - -// TSResourceJenny is a [OneToOne] that produces TypeScript types and -// defaults for a Thema schema. -// -// Thema's generic TS jenny will be able to replace this one once -// https://github.com/grafana/thema/issues/89 is complete. -type TSResourceJenny struct{} - -var _ codejen.OneToOne[SchemaForGen] = &TSResourceJenny{} - -func (j TSResourceJenny) JennyName() string { - return "TSResourceJenny" -} - -func (j TSResourceJenny) Generate(sfg SchemaForGen) (*codejen.File, error) { - // TODO allow using name instead of machine name in thema generator - f, err := typescript.GenerateTypes(sfg.Schema, &typescript.TypeConfig{ - RootName: sfg.Name, - Group: sfg.IsGroup, - CuetsyConfig: &cuetsy.Config{ - Export: true, - ImportMapper: cuectx.MapCUEImportToTS, - }, - }) - if err != nil { - return nil, err - } - renameSpecNode(sfg.Name, f) - - return codejen.NewFile(sfg.Schema.Lineage().Name()+"_types.gen.ts", []byte(f.String()), j), nil -} - -func renameSpecNode(name string, tf *ast.File) { - specidx, specdefidx := -1, -1 - for idx, def := range tf.Nodes { - // Peer through export keywords - if ex, is := def.(ast.ExportKeyword); is { - def = ex.Decl - } - - switch x := def.(type) { - case ast.TypeDecl: - if x.Name.Name == "spec" { - specidx = idx - x.Name.Name = name - tf.Nodes[idx] = x - } - case ast.VarDecl: - // Before: - // export const defaultspec: Partial<spec> = { - // After: - /// export const defaultPlaylist: Partial<Playlist> = { - if x.Names.Idents[0].Name == "defaultspec" { - specdefidx = idx - x.Names.Idents[0].Name = "default" + name - tt := x.Type.(ast.TypeTransformExpr) - tt.Expr = ts.Ident(name) - x.Type = tt - tf.Nodes[idx] = x - } - } - } - - if specidx != -1 { - decl := tf.Nodes[specidx] - tf.Nodes = append(append(tf.Nodes[:specidx], tf.Nodes[specidx+1:]...), decl) - } - if specdefidx != -1 { - if specdefidx > specidx { - specdefidx-- - } - decl := tf.Nodes[specdefidx] - tf.Nodes = append(append(tf.Nodes[:specdefidx], tf.Nodes[specdefidx+1:]...), decl) - } -} diff --git a/pkg/codegen/jenny_ts_types.go b/pkg/codegen/jenny_ts_types.go index d16968a45cf20..3eb5b078d6527 100644 --- a/pkg/codegen/jenny_ts_types.go +++ b/pkg/codegen/jenny_ts_types.go @@ -3,16 +3,21 @@ package codegen import ( "github.com/grafana/codejen" "github.com/grafana/cuetsy" + "github.com/grafana/cuetsy/ts/ast" "github.com/grafana/grafana/pkg/cuectx" "github.com/grafana/thema/encoding/typescript" ) +type ApplyFunc func(sfg SchemaForGen, file *ast.File) + // TSTypesJenny is a [OneToOne] that produces TypeScript types and // defaults for a Thema schema. // // Thema's generic TS jenny will be able to replace this one once // https://github.com/grafana/thema/issues/89 is complete. -type TSTypesJenny struct{} +type TSTypesJenny struct { + ApplyFuncs []ApplyFunc +} var _ codejen.OneToOne[SchemaForGen] = &TSTypesJenny{} @@ -30,6 +35,11 @@ func (j TSTypesJenny) Generate(sfg SchemaForGen) (*codejen.File, error) { RootName: sfg.Name, Group: sfg.IsGroup, }) + + for _, renameFunc := range j.ApplyFuncs { + renameFunc(sfg, f) + } + if err != nil { return nil, err } diff --git a/pkg/codegen/jenny_tsveneerindex.go b/pkg/codegen/jenny_tsveneerindex.go index 875301ec815a5..96160bdfdd279 100644 --- a/pkg/codegen/jenny_tsveneerindex.go +++ b/pkg/codegen/jenny_tsveneerindex.go @@ -18,6 +18,8 @@ import ( "github.com/grafana/thema/encoding/typescript" ) +var schPath = cue.MakePath(cue.Hid("_#schema", "github.com/grafana/thema")) + // TSVeneerIndexJenny generates an index.gen.ts file with references to all // generated TS types. Elements with the attribute @grafana(TSVeneer="type") are // exported from a handwritten file, rather than the raw generated types. diff --git a/pkg/codegen/latest_jenny.go b/pkg/codegen/latest_jenny.go deleted file mode 100644 index b8a0a618a0e27..0000000000000 --- a/pkg/codegen/latest_jenny.go +++ /dev/null @@ -1,54 +0,0 @@ -package codegen - -import ( - "fmt" - "path/filepath" - - "github.com/grafana/codejen" - "github.com/grafana/kindsys" -) - -// LatestJenny returns a jenny that runs another jenny for only the latest -// schema in a DefForGen, and prefixes the resulting file with the provided -// parentdir (e.g. "pkg/kinds/") and with a directory based on the kind's -// machine name (e.g. "dashboard/"). -func LatestJenny(parentdir string, inner codejen.OneToOne[SchemaForGen]) OneToOne { - if inner == nil { - panic("inner jenny must not be nil") - } - - return &latestj{ - parentdir: parentdir, - inner: inner, - } -} - -type latestj struct { - parentdir string - inner codejen.OneToOne[SchemaForGen] -} - -func (j *latestj) JennyName() string { - return "LatestJenny" -} - -func (j *latestj) Generate(kind kindsys.Kind) (*codejen.File, error) { - comm := kind.Props().Common() - sfg := SchemaForGen{ - Name: comm.Name, - Schema: kind.Lineage().Latest(), - IsGroup: comm.LineageIsGroup, - } - - f, err := j.inner.Generate(sfg) - if err != nil { - return nil, fmt.Errorf("%s jenny failed on %s schema for %s: %w", j.inner.JennyName(), sfg.Schema.Version(), kind.Props().Common().Name, err) - } - if f == nil || !f.Exists() { - return nil, nil - } - - f.RelativePath = filepath.Join(j.parentdir, comm.MachineName, f.RelativePath) - f.From = append(f.From, j) - return f, nil -} diff --git a/pkg/codegen/tmpl.go b/pkg/codegen/tmpl.go index 0abfdd4511eae..8f8a179cb3293 100644 --- a/pkg/codegen/tmpl.go +++ b/pkg/codegen/tmpl.go @@ -7,7 +7,6 @@ import ( "time" "github.com/grafana/codejen" - "github.com/grafana/kindsys" ) // All the parsed templates in the tmpl subdirectory @@ -33,15 +32,27 @@ type ( From string Leader string } - tvars_kind_registry struct { - PackageName string - KindPackagePrefix string - Kinds []kindsys.Core - } + tvars_resource struct { - PackageName string - KindName string - Version string - SubresourceNames []string + PackageName string + KindName string + Version string + } + + tvars_metadata struct { + PackageName string + } + + tvars_status struct { + PackageName string + } + + tvars_registry struct { + Schemas []Schema + } + + Schema struct { + Name string + FilePath string } ) diff --git a/pkg/codegen/tmpl/addenda.tmpl b/pkg/codegen/tmpl/addenda.tmpl deleted file mode 100644 index dc7533b8ea73c..0000000000000 --- a/pkg/codegen/tmpl/addenda.tmpl +++ /dev/null @@ -1,64 +0,0 @@ - - -//go:embed coremodel.cue -var cueFS embed.FS - -// The current version of the coremodel schema, as declared in coremodel.cue. -// This version determines what schema version is returned from [Coremodel.CurrentSchema], -// and which schema version is used for code generation within the grafana/grafana repository. -// -// The code generator ensures that this is always the latest Thema schema version. -var currentVersion = thema.SV({{ .LatestSeqv }}, {{ .LatestSchv }}) - -// Lineage returns the Thema lineage representing a Grafana {{ .Name }}. -// -// The lineage is the canonical specification of the current {{ .Name }} schema, -// all prior schema versions, and the mappings that allow migration between -// schema versions. -{{- if .IsComposed }}// -// This is the base variant of the schema. It does not include any composed -// plugin schemas.{{ end }} -func Lineage(rt *thema.Runtime, opts ...thema.BindOption) (thema.Lineage, error) { - return cuectx.LoadGrafanaInstancesWithThema(filepath.Join("pkg", "coremodel", "{{ .Name }}"), cueFS, rt, opts...) -} - -var _ thema.LineageFactory = Lineage -var _ coremodel.Interface = &Coremodel{} - -// Coremodel contains the foundational schema declaration for {{ .Name }}s. -// It implements coremodel.Interface. -type Coremodel struct { - lin thema.Lineage -} - -// Lineage returns the canonical {{ .Name }} Lineage. -func (c *Coremodel) Lineage() thema.Lineage { - return c.lin -} - -// CurrentSchema returns the current (latest) {{ .Name }} Thema schema. -func (c *Coremodel) CurrentSchema() thema.Schema { - return thema.SchemaP(c.lin, currentVersion) -} - -// GoType returns a pointer to an empty Go struct that corresponds to -// the current Thema schema. -func (c *Coremodel) GoType() interface{} { - return &Model{} -} - -// New returns a new instance of the {{ .Name }} coremodel. -// -// Note that this function does not cache, and initially loading a Thema lineage -// can be expensive. As such, the Grafana backend should prefer to access this -// coremodel through a registry (pkg/framework/coremodel/registry), which does cache. -func New(rt *thema.Runtime) (*Coremodel, error) { - lin, err := Lineage(rt) - if err != nil { - return nil, err - } - - return &Coremodel{ - lin: lin, - }, nil -} diff --git a/pkg/codegen/tmpl/autogen_header.tmpl b/pkg/codegen/tmpl/autogen_header.tmpl deleted file mode 100644 index c6af378dc5bb7..0000000000000 --- a/pkg/codegen/tmpl/autogen_header.tmpl +++ /dev/null @@ -1,28 +0,0 @@ -{{ if .GenLicense -}} -// Copyright {{ now.Year }} Grafana Labs -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -{{ end -}} -// This file is autogenerated. DO NOT EDIT. -{{- if ne .GeneratorPath "" }} -// -// Generated by {{ .GeneratorPath }} -{{- end }} -{{- if ne .LineagePath "" }} -// -// Derived from the Thema lineage declared in {{ .LineagePath }}{{ if ne .LineageCUEPath "" }} at CUE path "{{ .LineageCUEPath }}"{{ end }} -{{- end }} -// -// Run `make gen-cue` from repository root to regenerate. - diff --git a/pkg/codegen/tmpl/core_metadata.tmpl b/pkg/codegen/tmpl/core_metadata.tmpl new file mode 100644 index 0000000000000..11bf70f342c64 --- /dev/null +++ b/pkg/codegen/tmpl/core_metadata.tmpl @@ -0,0 +1,33 @@ +package {{ .PackageName }} + +import ( + "time" +) + +// Metadata defines model for Metadata. +type Metadata struct { + CreatedBy string `json:"createdBy"` + CreationTimestamp time.Time `json:"creationTimestamp"` + DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` + + // extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata + ExtraFields map[string]any `json:"extraFields"` + Finalizers []string `json:"finalizers"` + Labels map[string]string `json:"labels"` + ResourceVersion string `json:"resourceVersion"` + Uid string `json:"uid"` + UpdateTimestamp time.Time `json:"updateTimestamp"` + UpdatedBy string `json:"updatedBy"` +} + +// _kubeObjectMetadata is metadata found in a kubernetes object's metadata field. +// It is not exhaustive and only includes fields which may be relevant to a kind's implementation, +// As it is also intended to be generic enough to function with any API Server. +type KubeObjectMetadata struct { + CreationTimestamp time.Time `json:"creationTimestamp"` + DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` + Finalizers []string `json:"finalizers"` + Labels map[string]string `json:"labels"` + ResourceVersion string `json:"resourceVersion"` + Uid string `json:"uid"` +} diff --git a/pkg/codegen/tmpl/core_registry.tmpl b/pkg/codegen/tmpl/core_registry.tmpl new file mode 100644 index 0000000000000..8fc8bb9b0928e --- /dev/null +++ b/pkg/codegen/tmpl/core_registry.tmpl @@ -0,0 +1,46 @@ +package schemas + +import ( + "os" + "path/filepath" + "runtime" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" +) + +type CoreKind struct { + Name string + CueFile cue.Value +} + +func GetCoreKinds() ([]CoreKind, error) { + ctx := cuecontext.New() + kinds := make([]CoreKind, 0) + + _, caller, _, _ := runtime.Caller(0) + root := filepath.Join(caller, "../../../..") + + {{- range .Schemas }} + + {{ .Name }}Cue, err := loadCueFile(ctx, filepath.Join(root, "{{ .FilePath }}")) + if err != nil { + return nil, err + } + kinds = append(kinds, CoreKind{ + Name: "{{ .Name }}", + CueFile: {{ .Name }}Cue, + }) + {{- end }} + + return kinds, nil +} + +func loadCueFile(ctx *cue.Context, path string) (cue.Value, error) { + cueFile, err := os.ReadFile(path) + if err != nil { + return cue.Value{}, err + } + + return ctx.CompileBytes(cueFile), nil +} diff --git a/pkg/codegen/tmpl/core_resource.tmpl b/pkg/codegen/tmpl/core_resource.tmpl index 0120753934816..d929505e4a757 100644 --- a/pkg/codegen/tmpl/core_resource.tmpl +++ b/pkg/codegen/tmpl/core_resource.tmpl @@ -1,6 +1,8 @@ package {{ .PackageName }} import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/grafana/grafana/pkg/kinds" ) @@ -10,10 +12,12 @@ type K8sResource = kinds.GrafanaResource[Spec, Status] // NewResource creates a new instance of the resource with a given name (UID) func NewK8sResource(name string, s *Spec) K8sResource { return K8sResource{ - Kind: "{{ .KindName }}", - APIVersion: "v{{ .Version }}-alpha", - Metadata: kinds.GrafanaResourceMetadata{ - Name: name, + TypeMeta: v1.TypeMeta{ + Kind: "{{ .KindName }}", + APIVersion: "v{{ .Version }}-alpha", + }, + ObjectMeta: v1.ObjectMeta{ + Name: name, Annotations: make(map[string]string), Labels: make(map[string]string), }, @@ -24,6 +28,7 @@ func NewK8sResource(name string, s *Spec) K8sResource { // Resource is the wire representation of {{ .KindName }}. // It currently will soon be merged into the k8s flavor (TODO be better) type Resource struct { - {{- range .SubresourceNames }} - {{ . }} {{ . }} `json:"{{ . | ToLower }}"`{{end}} + Metadata Metadata `json:"metadata"` + Spec Spec `json:"spec"` + Status Status `json:"status"` } diff --git a/pkg/codegen/tmpl/core_status.tmpl b/pkg/codegen/tmpl/core_status.tmpl new file mode 100644 index 0000000000000..da2ceb99c8892 --- /dev/null +++ b/pkg/codegen/tmpl/core_status.tmpl @@ -0,0 +1,65 @@ +package {{ .PackageName }} + +// Defines values for OperatorStateState. +const ( + OperatorStateStateFailed OperatorStateState = "failed" + OperatorStateStateInProgress OperatorStateState = "in_progress" + OperatorStateStateSuccess OperatorStateState = "success" +) + +// Defines values for StatusOperatorStateState. +const ( + StatusOperatorStateStateFailed StatusOperatorStateState = "failed" + StatusOperatorStateStateInProgress StatusOperatorStateState = "in_progress" + StatusOperatorStateStateSuccess StatusOperatorStateState = "success" +) + +// OperatorState defines model for OperatorState. +type OperatorState struct { + // descriptiveState is an optional more descriptive state field which has no requirements on format + DescriptiveState *string `json:"descriptiveState,omitempty"` + + // details contains any extra information that is operator-specific + Details map[string]any `json:"details,omitempty"` + + // lastEvaluation is the ResourceVersion last evaluated + LastEvaluation string `json:"lastEvaluation"` + + // state describes the state of the lastEvaluation. + // It is limited to three possible states for machine evaluation. + State OperatorStateState `json:"state"` +} + +// OperatorStateState state describes the state of the lastEvaluation. +// It is limited to three possible states for machine evaluation. +type OperatorStateState string + +// Status defines model for Status. +type Status struct { + // additionalFields is reserved for future use + AdditionalFields map[string]any `json:"additionalFields,omitempty"` + + // operatorStates is a map of operator ID to operator state evaluations. + // Any operator which consumes this kind SHOULD add its state evaluation information to this field. + OperatorStates map[string]StatusOperatorState `json:"operatorStates,omitempty"` +} + +// StatusOperatorState defines model for status.#OperatorState. +type StatusOperatorState struct { + // descriptiveState is an optional more descriptive state field which has no requirements on format + DescriptiveState *string `json:"descriptiveState,omitempty"` + + // details contains any extra information that is operator-specific + Details map[string]any `json:"details,omitempty"` + + // lastEvaluation is the ResourceVersion last evaluated + LastEvaluation string `json:"lastEvaluation"` + + // state describes the state of the lastEvaluation. + // It is limited to three possible states for machine evaluation. + State StatusOperatorStateState `json:"state"` +} + +// StatusOperatorStateState state describes the state of the lastEvaluation. +// It is limited to three possible states for machine evaluation. +type StatusOperatorStateState string diff --git a/pkg/codegen/tmpl/coremodel_imports.tmpl b/pkg/codegen/tmpl/coremodel_imports.tmpl deleted file mode 100644 index 40e4aa452110e..0000000000000 --- a/pkg/codegen/tmpl/coremodel_imports.tmpl +++ /dev/null @@ -1,10 +0,0 @@ -package {{ .PackageName }} - -import ( - "embed" - "path/filepath" - - "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/grafana/pkg/framework/coremodel" - "github.com/grafana/thema" -) diff --git a/pkg/codegen/tmpl/cuetsy_multi.tmpl b/pkg/codegen/tmpl/cuetsy_multi.tmpl deleted file mode 100644 index 686a8e55509d7..0000000000000 --- a/pkg/codegen/tmpl/cuetsy_multi.tmpl +++ /dev/null @@ -1,2 +0,0 @@ -{{ template "autogen_header.tmpl" .Header -}} -{{ .Body }} diff --git a/pkg/codegen/tmpl/docs.tmpl b/pkg/codegen/tmpl/docs.tmpl deleted file mode 100644 index 4fa8df80021c2..0000000000000 --- a/pkg/codegen/tmpl/docs.tmpl +++ /dev/null @@ -1,21 +0,0 @@ ---- -keywords: - - grafana - - schema -labels: - products: - - cloud - - enterprise - - oss -title: {{ .KindName }} kind ---- -> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. - -## {{ .KindName }} - -#### Maturity: {{ .KindMaturity }} -#### Version: {{ .KindVersion }} - -{{ .KindDescription }} - -{{ .Markdown }} diff --git a/pkg/codegen/tmpl/kind_core.tmpl b/pkg/codegen/tmpl/kind_core.tmpl deleted file mode 100644 index a2db88dffaf84..0000000000000 --- a/pkg/codegen/tmpl/kind_core.tmpl +++ /dev/null @@ -1,70 +0,0 @@ -package {{ .Props.MachineName }} - -import ( - "github.com/grafana/kindsys" - "github.com/grafana/thema" - "github.com/grafana/thema/vmux" - - "github.com/grafana/grafana/pkg/cuectx" -) - -// rootrel is the relative path from the grafana repository root to the -// directory containing the .cue files in which this kind is defined. Necessary -// for runtime errors related to the definition and/or lineage to provide -// a real path to the correct .cue file. -const rootrel string = "kinds/{{ .Props.MachineName }}" - -// TODO standard generated docs -type Kind struct { - kindsys.Core - lin thema.ConvergentLineage[*Resource] - jcodec vmux.Codec - valmux vmux.ValueMux[*Resource] -} - -// type guard - ensure generated Kind type satisfies the kindsys.Core interface -var _ kindsys.Core = &Kind{} - -// TODO standard generated docs -func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { - def, err := cuectx.LoadCoreKindDef(rootrel, rt.Context(), nil) - if err != nil { - return nil, err - } - - k := &Kind{} - k.Core, err = kindsys.BindCore(rt, def, opts...) - if err != nil { - return nil, err - } - // Get the thema.Schema that the meta says is in the current version (which - // codegen ensures is always the latest) - cursch := thema.SchemaP(k.Core.Lineage(), def.Properties.CurrentVersion) - tsch, err := thema.BindType(cursch, &Resource{}) - if err != nil { - // Should be unreachable, modulo bugs in the Thema->Go code generator - return nil, err - } - - k.jcodec = vmux.NewJSONCodec("{{ .Props.MachineName }}.json") - k.lin = tsch.ConvergentLineage() - k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) - return k, nil -} - -// ConvergentLineage returns the same [thema.Lineage] as Lineage, but bound (see [thema.BindType]) -// to the the {{ .Props.Name }} [Resource] type generated from the current schema, v{{ .Props.CurrentVersion }}. -func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*Resource] { - return k.lin -} - -// JSONValueMux is a version multiplexer that maps a []byte containing JSON data -// at any schematized dashboard version to an instance of {{ .Props.Name }} [Resource]. -// -// Validation and translation errors emitted from this func will identify the -// input bytes as "dashboard.json". -// -// This is a thin wrapper around Thema's [vmux.ValueMux]. -func (k *Kind) JSONValueMux(b []byte) (*Resource, thema.TranslationLacunas, error) { - return k.valmux(b) -} diff --git a/pkg/codegen/tmpl/kind_registry.tmpl b/pkg/codegen/tmpl/kind_registry.tmpl deleted file mode 100644 index 59cf5fbc0aea3..0000000000000 --- a/pkg/codegen/tmpl/kind_registry.tmpl +++ /dev/null @@ -1,61 +0,0 @@ -package {{ .PackageName }} - -import ( - "fmt" - "sync" - - {{range .Kinds }} - "{{ $.KindPackagePrefix }}/{{ .Props.MachineName }}"{{end}} - "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/kindsys" - "github.com/grafana/thema" -) - -// Base is a registry of all Grafana core kinds. It is designed for use both inside -// of Grafana itself, and for import by external Go programs wanting to work with Grafana's -// kind system. -// -// The registry provides two modes for accessing core kinds: -// * Per-kind methods, which return the kind-specific type, e.g. Dashboard() returns [dashboard.Dashboard]. -// * All(), which returns a slice of [kindsys.Core]. -// -// Prefer the individual named methods for use cases where the particular kind(s) that -// are needed are known to the caller. For example, a dashboard linter can know that it -// specifically wants the dashboard kind. -// -// Prefer All() when performing operations generically across all kinds. For example, -// a generic HTTP middleware for validating request bodies expected to contain some -// kind-schematized type. -type Base struct { - all []kindsys.Core - {{- range .Kinds }} - {{ .Props.MachineName }} *{{ .Props.MachineName }}.Kind{{end}} -} - -// type guards -var ( -{{- range .Kinds }} - _ kindsys.Core = &{{ .Props.MachineName }}.Kind{}{{end}} -) - -{{range .Kinds }} -// {{ .Props.Name }} returns the [kindsys.Interface] implementation for the {{ .Props.MachineName }} kind. -func (b *Base) {{ .Props.Name }}() *{{ .Props.MachineName }}.Kind { - return b.{{ .Props.MachineName }} -} -{{end}} - -func doNewBase(rt *thema.Runtime) *Base { - var err error - reg := &Base{} - -{{range .Kinds }} - reg.{{ .Props.MachineName }}, err = {{ .Props.MachineName }}.NewKind(rt) - if err != nil { - panic(fmt.Sprintf("error while initializing the {{ .Props.MachineName }} Kind: %s", err)) - } - reg.all = append(reg.all, reg.{{ .Props.MachineName }}) -{{end}} - - return reg -} diff --git a/pkg/codegen/tmpl/plugin_lineage_binding.tmpl b/pkg/codegen/tmpl/plugin_lineage_binding.tmpl deleted file mode 100644 index e3d66a9f1acab..0000000000000 --- a/pkg/codegen/tmpl/plugin_lineage_binding.tmpl +++ /dev/null @@ -1,12 +0,0 @@ -// The current version of the coremodel schema, as declared in coremodel.cue. -// This version determines what schema version is returned from [Coremodel.CurrentSchema], -// and which schema version is used for code generation within the grafana/grafana repository. -// -// The code generator ensures that this is always the latest Thema schema version. -var currentVersion{{ .SlotName }} = thema.SV({{ .LatestSeqv }}, {{ .LatestSchv }}) - -// {{ .SlotName }}Lineage returns the Thema lineage for the {{ .PluginID }} {{ .PluginType }} plugin's -// {{ .SlotName }} ["github.com/grafana/grafana/pkg/framework/coremodel".Slot] implementation. -func {{ .SlotName }}Lineage(rt *thema.Runtime, opts ...thema.BindOption) (thema.Lineage, error) { - return cuectx.LoadGrafanaInstancesWithThema(filepath.Join("public", "app", "{{ .Name }}"), cueFS, rt, opts...) -} diff --git a/pkg/codegen/tmpl/plugin_lineage_file.tmpl b/pkg/codegen/tmpl/plugin_lineage_file.tmpl deleted file mode 100644 index a14f4c975be91..0000000000000 --- a/pkg/codegen/tmpl/plugin_lineage_file.tmpl +++ /dev/null @@ -1,59 +0,0 @@ -{{ template "autogen_header.tmpl" .Header -}} -package {{ .PackageName }} - -import ( - "embed" - "fmt" - "path/filepath" - "sync" - - "github.com/grafana/thema" - - "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/grafana/pkg/plugins/pfs" -) - -var parseOnce sync.Once -var ptree *pfs.Tree - -//go:embed plugin.{{ if .RootCUE }}cue{{ else }}json{{ end }}{{ if .HasModels }} models.cue{{ end }} -var plugFS embed.FS - -// PluginTree returns the plugin tree representing the statically analyzable contents of the {{ .PluginID }} plugin. -func PluginTree(rt *thema.Runtime) *pfs.Tree { - var err error - if rt == nil || rt == cuectx.GrafanaThemaRuntime() { - parseOnce.Do(func() { - ptree, err = pfs.ParsePluginFS(plugFS, cuectx.GrafanaThemaRuntime()) - }) - } else { - ptree, err = pfs.ParsePluginFS(plugFS, rt) - } - - if err != nil { - // Even the most rudimentary testing in CI ensures this is unreachable - panic(fmt.Errorf("error parsing plugin fs tree: %w", err)) - } - - return ptree -} - -{{ $pluginfo := . }}{{ range $slot := .SlotImpls }} -// {{ .SlotName }}Lineage returns the Thema lineage for the {{ $pluginfo.PluginID }} {{ $pluginfo.PluginType }} plugin's -// {{ .SlotName }} ["github.com/grafana/grafana/pkg/framework/coremodel".Slot] implementation. -func {{ .SlotName }}Lineage(rt *thema.Runtime, opts ...thema.BindOption) (thema.Lineage, error) { - t := PluginTree(rt) - lin, has := t.RootPlugin().SlotImplementations()["{{ .SlotName }}"] - if !has { - panic("unreachable: lineage for {{ .SlotName }} does not exist, but code is only generated for existing lineages") - } - return lin, nil -} - -// The current schema version of the {{ .SlotName }} slot implementation. -// -// Code generation ensures that this is always the version number for the latest schema -// in the {{ .SlotName }} Thema lineage. -var currentVersion{{ .SlotName }} = thema.SV({{ .LatestMajv }}, {{ .LatestMinv }}) - -{{ end }} diff --git a/pkg/codegen/tmpl/plugin_registry_ref.tmpl b/pkg/codegen/tmpl/plugin_registry_ref.tmpl deleted file mode 100644 index 68ef98cbdedee..0000000000000 --- a/pkg/codegen/tmpl/plugin_registry_ref.tmpl +++ /dev/null @@ -1,16 +0,0 @@ -{{ template "autogen_header.tmpl" .Header -}} -package registry - -import ( - "github.com/grafana/grafana/pkg/plugins/pfs" - "github.com/grafana/thema" - {{ range .Plugins }} - {{ if .NoAlias }}{{ .PkgName }} {{end}}"{{ .Path }}"{{ end }} -) - -func coreTreeLoaders() []func(*thema.Runtime) *pfs.Tree{ - return []func(*thema.Runtime) *pfs.Tree{ - {{- range .Plugins }} - {{ .PkgName }}.PluginTree,{{ end }} - } -} diff --git a/pkg/codegen/util_go.go b/pkg/codegen/util_go.go index 2cd172b5d66d0..faecaae57c730 100644 --- a/pkg/codegen/util_go.go +++ b/pkg/codegen/util_go.go @@ -1,74 +1,14 @@ package codegen import ( - "bytes" "fmt" - "go/format" - "go/parser" - "go/token" - "os" - "path/filepath" "regexp" "strings" "github.com/dave/dst" - "github.com/dave/dst/decorator" "github.com/dave/dst/dstutil" - "golang.org/x/tools/imports" ) -type genGoFile struct { - path string - walker dstutil.ApplyFunc - in []byte -} - -func postprocessGoFile(cfg genGoFile) ([]byte, error) { - fname := filepath.Base(cfg.path) - buf := new(bytes.Buffer) - fset := token.NewFileSet() - gf, err := decorator.ParseFile(fset, fname, string(cfg.in), parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("error parsing generated file: %w", err) - } - - if cfg.walker != nil { - dstutil.Apply(gf, cfg.walker, nil) - - err = format.Node(buf, fset, gf) - if err != nil { - return nil, fmt.Errorf("error formatting Go AST: %w", err) - } - } else { - buf = bytes.NewBuffer(cfg.in) - } - - byt, err := imports.Process(fname, buf.Bytes(), nil) - if err != nil { - return nil, fmt.Errorf("goimports processing failed: %w", err) - } - - // Compare imports before and after; warn about performance if some were added - gfa, _ := parser.ParseFile(fset, fname, string(byt), parser.ParseComments) - imap := make(map[string]bool) - for _, im := range gf.Imports { - imap[im.Path.Value] = true - } - var added []string - for _, im := range gfa.Imports { - if !imap[im.Path.Value] { - added = append(added, im.Path.Value) - } - } - - if len(added) != 0 { - // TODO improve the guidance in this error if/when we better abstract over imports to generate - fmt.Fprintf(os.Stderr, "The following imports were added by goimports while generating %s: \n\t%s\nRelying on goimports to find imports significantly slows down code generation. Consider adding these to the relevant template.\n", cfg.path, strings.Join(added, "\n\t")) - } - - return byt, nil -} - type prefixmod struct { prefix string replace string diff --git a/pkg/components/imguploader/azureblobuploader_test.go b/pkg/components/imguploader/azureblobuploader_test.go index ca7a663ab0ff2..01afb1755dfb9 100644 --- a/pkg/components/imguploader/azureblobuploader_test.go +++ b/pkg/components/imguploader/azureblobuploader_test.go @@ -18,7 +18,7 @@ func TestUploadToAzureBlob(t *testing.T) { }) require.NoError(t, err) - uploader, _ := NewImageUploader() + uploader, _ := NewImageUploader(cfg) path, err := uploader.Upload(context.Background(), "../../../public/img/logo_transparent_400x.png") diff --git a/pkg/components/imguploader/imguploader.go b/pkg/components/imguploader/imguploader.go index 7e0aee0028b6a..7d3999abfeba5 100644 --- a/pkg/components/imguploader/imguploader.go +++ b/pkg/components/imguploader/imguploader.go @@ -32,10 +32,10 @@ var ( logger = log.New("imguploader") ) -func NewImageUploader() (ImageUploader, error) { - switch setting.ImageUploadProvider { +func NewImageUploader(cfg *setting.Cfg) (ImageUploader, error) { + switch cfg.ImageUploadProvider { case "s3": - s3sec, err := setting.Raw.GetSection("external_image_storage.s3") + s3sec, err := cfg.Raw.GetSection("external_image_storage.s3") if err != nil { return nil, err } @@ -64,7 +64,7 @@ func NewImageUploader() (ImageUploader, error) { return NewS3Uploader(endpoint, region, bucket, path, "public-read", accessKey, secretKey, pathStyleAccess), nil case "webdav": - webdavSec, err := setting.Raw.GetSection("external_image_storage.webdav") + webdavSec, err := cfg.Raw.GetSection("external_image_storage.webdav") if err != nil { return nil, err } @@ -80,7 +80,7 @@ func NewImageUploader() (ImageUploader, error) { return NewWebdavImageUploader(url, username, password, public_url) case "gcs": - gcssec, err := setting.Raw.GetSection("external_image_storage.gcs") + gcssec, err := cfg.Raw.GetSection("external_image_storage.gcs") if err != nil { return nil, err } @@ -102,7 +102,7 @@ func NewImageUploader() (ImageUploader, error) { return gcs.NewUploader(keyFile, bucketName, path, enableSignedURLs, suExp) case "azure_blob": - azureBlobSec, err := setting.Raw.GetSection("external_image_storage.azure_blob") + azureBlobSec, err := cfg.Raw.GetSection("external_image_storage.azure_blob") if err != nil { return nil, err } @@ -118,8 +118,8 @@ func NewImageUploader() (ImageUploader, error) { return NewLocalImageUploader() } - if setting.ImageUploadProvider != "" { - logger.Error("The external image storage configuration is invalid", "unsupported provider", setting.ImageUploadProvider) + if cfg.ImageUploadProvider != "" { + logger.Error("The external image storage configuration is invalid", "unsupported provider", cfg.ImageUploadProvider) } return NopImageUploader{}, nil diff --git a/pkg/components/imguploader/imguploader_test.go b/pkg/components/imguploader/imguploader_test.go index a6acb346c342f..db0c277169b8d 100644 --- a/pkg/components/imguploader/imguploader_test.go +++ b/pkg/components/imguploader/imguploader_test.go @@ -17,10 +17,10 @@ func TestImageUploaderFactory(t *testing.T) { }) require.NoError(t, err) - setting.ImageUploadProvider = "s3" + cfg.ImageUploadProvider = "s3" t.Run("with bucket url https://foo.bar.baz.s3-us-east-2.amazonaws.com", func(t *testing.T) { - s3sec, err := setting.Raw.GetSection("external_image_storage.s3") + s3sec, err := cfg.Raw.GetSection("external_image_storage.s3") require.NoError(t, err) _, err = s3sec.NewKey("bucket_url", "https://foo.bar.baz.s3-us-east-2.amazonaws.com") require.NoError(t, err) @@ -29,7 +29,7 @@ func TestImageUploaderFactory(t *testing.T) { _, err = s3sec.NewKey("secret_key", "secret_key") require.NoError(t, err) - uploader, err := NewImageUploader() + uploader, err := NewImageUploader(cfg) require.NoError(t, err) original, ok := uploader.(*S3Uploader) @@ -41,7 +41,7 @@ func TestImageUploaderFactory(t *testing.T) { }) t.Run("with bucket url https://s3.amazonaws.com/mybucket", func(t *testing.T) { - s3sec, err := setting.Raw.GetSection("external_image_storage.s3") + s3sec, err := cfg.Raw.GetSection("external_image_storage.s3") require.NoError(t, err) _, err = s3sec.NewKey("bucket_url", "https://s3.amazonaws.com/my.bucket.com") require.NoError(t, err) @@ -50,7 +50,7 @@ func TestImageUploaderFactory(t *testing.T) { _, err = s3sec.NewKey("secret_key", "secret_key") require.NoError(t, err) - uploader, err := NewImageUploader() + uploader, err := NewImageUploader(cfg) require.NoError(t, err) original, ok := uploader.(*S3Uploader) @@ -62,7 +62,7 @@ func TestImageUploaderFactory(t *testing.T) { }) t.Run("with bucket url https://s3-us-west-2.amazonaws.com/mybucket", func(t *testing.T) { - s3sec, err := setting.Raw.GetSection("external_image_storage.s3") + s3sec, err := cfg.Raw.GetSection("external_image_storage.s3") require.NoError(t, err) _, err = s3sec.NewKey("bucket_url", "https://s3-us-west-2.amazonaws.com/my.bucket.com") require.NoError(t, err) @@ -71,7 +71,7 @@ func TestImageUploaderFactory(t *testing.T) { _, err = s3sec.NewKey("secret_key", "secret_key") require.NoError(t, err) - uploader, err := NewImageUploader() + uploader, err := NewImageUploader(cfg) require.NoError(t, err) original, ok := uploader.(*S3Uploader) @@ -90,7 +90,7 @@ func TestImageUploaderFactory(t *testing.T) { }) require.NoError(t, err) - setting.ImageUploadProvider = "webdav" + cfg.ImageUploadProvider = "webdav" webdavSec, err := cfg.Raw.GetSection("external_image_storage.webdav") require.NoError(t, err) @@ -101,7 +101,7 @@ func TestImageUploaderFactory(t *testing.T) { _, err = webdavSec.NewKey("password", "password") require.NoError(t, err) - uploader, err := NewImageUploader() + uploader, err := NewImageUploader(cfg) require.NoError(t, err) original, ok := uploader.(*WebdavUploader) @@ -118,7 +118,7 @@ func TestImageUploaderFactory(t *testing.T) { }) require.NoError(t, err) - setting.ImageUploadProvider = "gcs" + cfg.ImageUploadProvider = "gcs" gcpSec, err := cfg.Raw.GetSection("external_image_storage.gcs") require.NoError(t, err) @@ -127,7 +127,7 @@ func TestImageUploaderFactory(t *testing.T) { _, err = gcpSec.NewKey("bucket", "project-grafana-east") require.NoError(t, err) - uploader, err := NewImageUploader() + uploader, err := NewImageUploader(cfg) require.NoError(t, err) original, ok := uploader.(*gcs.Uploader) @@ -143,7 +143,7 @@ func TestImageUploaderFactory(t *testing.T) { }) require.NoError(t, err) - setting.ImageUploadProvider = "azure_blob" + cfg.ImageUploadProvider = "azure_blob" t.Run("with container name", func(t *testing.T) { azureBlobSec, err := cfg.Raw.GetSection("external_image_storage.azure_blob") @@ -157,7 +157,7 @@ func TestImageUploaderFactory(t *testing.T) { _, err = azureBlobSec.NewKey("sas_token_expiration_days", "sas_token_expiration_days") require.NoError(t, err) - uploader, err := NewImageUploader() + uploader, err := NewImageUploader(cfg) require.NoError(t, err) original, ok := uploader.(*AzureBlobUploader) @@ -176,9 +176,9 @@ func TestImageUploaderFactory(t *testing.T) { }) require.NoError(t, err) - setting.ImageUploadProvider = "local" + cfg.ImageUploadProvider = "local" - uploader, err := NewImageUploader() + uploader, err := NewImageUploader(cfg) require.NoError(t, err) original, ok := uploader.(*LocalUploader) diff --git a/pkg/components/imguploader/s3uploader_test.go b/pkg/components/imguploader/s3uploader_test.go index 8d5bc4a64e7af..637e5ffe38626 100644 --- a/pkg/components/imguploader/s3uploader_test.go +++ b/pkg/components/imguploader/s3uploader_test.go @@ -17,7 +17,7 @@ func TestUploadToS3(t *testing.T) { }) require.NoError(t, err) - s3Uploader, err := NewImageUploader() + s3Uploader, err := NewImageUploader(cfg) require.NoError(t, err) path, err := s3Uploader.Upload(context.Background(), "../../../public/img/logo_transparent_400x.png") diff --git a/pkg/expr/classic/classic.go b/pkg/expr/classic/classic.go index 01288e0b01d47..bcb99b24d7d4c 100644 --- a/pkg/expr/classic/classic.go +++ b/pkg/expr/classic/classic.go @@ -54,7 +54,7 @@ type condition struct { // Operator is the logical operator to use when there are two conditions in ConditionsCmd. // If there are more than two conditions in ConditionsCmd then operator is used to compare // the outcome of this condition with that of the condition before it. - Operator string + Operator ConditionOperatorType } // NeedsVars returns the variable names (refIds) that are dependencies @@ -216,7 +216,7 @@ func (cmd *ConditionsCmd) executeCond(_ context.Context, _ time.Time, cond condi return isCondFiring, isCondNoData, matches, nil } -func compareWithOperator(b1, b2 bool, operator string) bool { +func compareWithOperator(b1, b2 bool, operator ConditionOperatorType) bool { if operator == "or" { return b1 || b2 } else { @@ -262,8 +262,17 @@ type ConditionEvalJSON struct { Type string `json:"type"` // e.g. "gt" } +// The reducer function +// +enum +type ConditionOperatorType string + +const ( + ConditionOperatorAnd ConditionOperatorType = "and" + ConditionOperatorOr ConditionOperatorType = "or" +) + type ConditionOperatorJSON struct { - Type string `json:"type"` + Type ConditionOperatorType `json:"type"` } type ConditionQueryJSON struct { @@ -275,21 +284,12 @@ type ConditionReducerJSON struct { // Params []any `json:"params"` (Unused) } -// UnmarshalConditionsCmd creates a new ConditionsCmd. -func UnmarshalConditionsCmd(rawQuery map[string]any, refID string) (*ConditionsCmd, error) { - jsonFromM, err := json.Marshal(rawQuery["conditions"]) - if err != nil { - return nil, fmt.Errorf("failed to remarshal classic condition body: %w", err) - } - var ccj []ConditionJSON - if err = json.Unmarshal(jsonFromM, &ccj); err != nil { - return nil, fmt.Errorf("failed to unmarshal remarshaled classic condition body: %w", err) - } - +func NewConditionCmd(refID string, ccj []ConditionJSON) (*ConditionsCmd, error) { c := &ConditionsCmd{ RefID: refID, } + var err error for i, cj := range ccj { cond := condition{} @@ -316,6 +316,18 @@ func UnmarshalConditionsCmd(rawQuery map[string]any, refID string) (*ConditionsC c.Conditions = append(c.Conditions, cond) } - return c, nil } + +// UnmarshalConditionsCmd creates a new ConditionsCmd. +func UnmarshalConditionsCmd(rawQuery map[string]any, refID string) (*ConditionsCmd, error) { + jsonFromM, err := json.Marshal(rawQuery["conditions"]) + if err != nil { + return nil, fmt.Errorf("failed to remarshal classic condition body: %w", err) + } + var ccj []ConditionJSON + if err = json.Unmarshal(jsonFromM, &ccj); err != nil { + return nil, fmt.Errorf("failed to unmarshal remarshaled classic condition body: %w", err) + } + return NewConditionCmd(refID, ccj) +} diff --git a/pkg/expr/commands.go b/pkg/expr/commands.go index d68a6e6d16f19..9e1c495859780 100644 --- a/pkg/expr/commands.go +++ b/pkg/expr/commands.go @@ -77,14 +77,14 @@ func (gm *MathCommand) Execute(ctx context.Context, _ time.Time, vars mathexp.Va // ReduceCommand is an expression command for reduction of a timeseries such as a min, mean, or max. type ReduceCommand struct { - Reducer string + Reducer mathexp.ReducerID VarToReduce string refID string seriesMapper mathexp.ReduceMapper } // NewReduceCommand creates a new ReduceCMD. -func NewReduceCommand(refID, reducer, varToReduce string, mapper mathexp.ReduceMapper) (*ReduceCommand, error) { +func NewReduceCommand(refID string, reducer mathexp.ReducerID, varToReduce string, mapper mathexp.ReduceMapper) (*ReduceCommand, error) { _, err := mathexp.GetReduceFunc(reducer) if err != nil { return nil, err @@ -114,10 +114,11 @@ func UnmarshalReduceCommand(rn *rawNode) (*ReduceCommand, error) { if !ok { return nil, errors.New("no reducer specified") } - redFunc, ok := rawReducer.(string) + redString, ok := rawReducer.(string) if !ok { return nil, fmt.Errorf("expected reducer to be a string, got %T", rawReducer) } + redFunc := mathexp.ReducerID(strings.ToLower(redString)) var mapper mathexp.ReduceMapper = nil settings, ok := rn.Query["settings"] @@ -163,7 +164,7 @@ func (gr *ReduceCommand) Execute(ctx context.Context, _ time.Time, vars mathexp. _, span := tracer.Start(ctx, "SSE.ExecuteReduce") defer span.End() - span.SetAttributes(attribute.String("reducer", gr.Reducer)) + span.SetAttributes(attribute.String("reducer", string(gr.Reducer))) newRes := mathexp.Results{} for i, val := range vars[gr.VarToReduce].Values { @@ -204,14 +205,14 @@ func (gr *ReduceCommand) Execute(ctx context.Context, _ time.Time, vars mathexp. type ResampleCommand struct { Window time.Duration VarToResample string - Downsampler string - Upsampler string + Downsampler mathexp.ReducerID + Upsampler mathexp.Upsampler TimeRange TimeRange refID string } // NewResampleCommand creates a new ResampleCMD. -func NewResampleCommand(refID, rawWindow, varToResample string, downsampler string, upsampler string, tr TimeRange) (*ResampleCommand, error) { +func NewResampleCommand(refID, rawWindow, varToResample string, downsampler mathexp.ReducerID, upsampler mathexp.Upsampler, tr TimeRange) (*ResampleCommand, error) { // TODO: validate reducer here, before execution window, err := gtime.ParseDuration(rawWindow) if err != nil { @@ -270,7 +271,11 @@ func UnmarshalResampleCommand(rn *rawNode) (*ResampleCommand, error) { return nil, fmt.Errorf("expected resample downsampler to be a string, got type %T", upsampler) } - return NewResampleCommand(rn.RefID, window, varToResample, downsampler, upsampler, rn.TimeRange) + return NewResampleCommand(rn.RefID, window, + varToResample, + mathexp.ReducerID(downsampler), + mathexp.Upsampler(upsampler), + rn.TimeRange) } // NeedsVars returns the variable names (refIds) that are dependencies @@ -323,6 +328,8 @@ const ( TypeClassicConditions // TypeThreshold is the CMDType for checking if a threshold has been crossed TypeThreshold + // TypeSQL is the CMDType for running SQL expressions + TypeSQL ) func (gt CommandType) String() string { @@ -335,6 +342,8 @@ func (gt CommandType) String() string { return "resample" case TypeClassicConditions: return "classic_conditions" + case TypeSQL: + return "sql" default: return "unknown" } @@ -353,6 +362,8 @@ func ParseCommandType(s string) (CommandType, error) { return TypeClassicConditions, nil case "threshold": return TypeThreshold, nil + case "sql": + return TypeSQL, nil default: return TypeUnknown, fmt.Errorf("'%v' is not a recognized expression type", s) } diff --git a/pkg/expr/commands_test.go b/pkg/expr/commands_test.go index 6fa837eb48e5d..7fd5832917133 100644 --- a/pkg/expr/commands_test.go +++ b/pkg/expr/commands_test.go @@ -210,7 +210,7 @@ func TestReduceExecute(t *testing.T) { }) } -func randomReduceFunc() string { +func randomReduceFunc() mathexp.ReducerID { res := mathexp.GetSupportedReduceFuncs() return res[rand.Intn(len(res))] } diff --git a/pkg/expr/converter.go b/pkg/expr/converter.go new file mode 100644 index 0000000000000..3b080f42e59e8 --- /dev/null +++ b/pkg/expr/converter.go @@ -0,0 +1,361 @@ +package expr + +import ( + "context" + "fmt" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + + "github.com/grafana/grafana/pkg/expr/mathexp" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +type ResultConverter struct { + Features featuremgmt.FeatureToggles + Tracer tracing.Tracer +} + +func (c *ResultConverter) Convert(ctx context.Context, + datasourceType string, + frames data.Frames, + allowLongFrames bool, +) (string, mathexp.Results, error) { + if len(frames) == 0 { + return "no-data", mathexp.Results{Values: mathexp.Values{mathexp.NewNoData()}}, nil + } + + var dt data.FrameType + dt, useDataplane, _ := shouldUseDataplane(frames, logger, c.Features.IsEnabled(ctx, featuremgmt.FlagDisableSSEDataplane)) + if useDataplane { + logger.Debug("Handling SSE data source query through dataplane", "datatype", dt) + result, err := handleDataplaneFrames(ctx, c.Tracer, dt, frames) + return fmt.Sprintf("dataplane-%s", dt), result, err + } + + if isAllFrameVectors(datasourceType, frames) { // Prometheus Specific Handling + vals, err := framesToNumbers(frames) + if err != nil { + return "", mathexp.Results{}, fmt.Errorf("failed to read frames as numbers: %w", err) + } + return "vector", mathexp.Results{Values: vals}, nil + } + + if len(frames) == 1 { + frame := frames[0] + // Handle Untyped NoData + if len(frame.Fields) == 0 { + return "no-data", mathexp.Results{Values: mathexp.Values{mathexp.NoData{Frame: frame}}}, nil + } + + // Handle Numeric Table + if frame.TimeSeriesSchema().Type == data.TimeSeriesTypeNot && isNumberTable(frame) { + numberSet, err := extractNumberSet(frame) + if err != nil { + return "", mathexp.Results{}, err + } + vals := make([]mathexp.Value, 0, len(numberSet)) + for _, n := range numberSet { + vals = append(vals, n) + } + return "number set", mathexp.Results{ + Values: vals, + }, nil + } + } + + filtered := make([]*data.Frame, 0, len(frames)) + totalLen := 0 + for _, frame := range frames { + schema := frame.TimeSeriesSchema() + // Check for TimeSeriesTypeNot in InfluxDB queries. A data frame of this type will cause + // the WideToMany() function to error out, which results in unhealthy alerts. + // This check should be removed once inconsistencies in data source responses are solved. + if schema.Type == data.TimeSeriesTypeNot && datasourceType == datasources.DS_INFLUXDB { + logger.Warn("Ignoring InfluxDB data frame due to missing numeric fields") + continue + } + + if schema.Type != data.TimeSeriesTypeWide && !allowLongFrames { + return "", mathexp.Results{}, fmt.Errorf("input data must be a wide series but got type %s (input refid)", schema.Type) + } + filtered = append(filtered, frame) + totalLen += len(schema.ValueIndices) + } + + if len(filtered) == 0 { + return "no data", mathexp.Results{Values: mathexp.Values{mathexp.NoData{Frame: frames[0]}}}, nil + } + + maybeFixerFn := checkIfSeriesNeedToBeFixed(filtered, datasourceType) + + dataType := "single frame series" + if len(filtered) > 1 { + dataType = "multi frame series" + } + + vals := make([]mathexp.Value, 0, totalLen) + for _, frame := range filtered { + schema := frame.TimeSeriesSchema() + if schema.Type == data.TimeSeriesTypeWide { + series, err := WideToMany(frame, maybeFixerFn) + if err != nil { + return "", mathexp.Results{}, err + } + for _, ser := range series { + vals = append(vals, ser) + } + } else { + v := mathexp.TableData{Frame: frame} + vals = append(vals, v) + dataType = "single frame" + } + } + + return dataType, mathexp.Results{ + Values: vals, + }, nil +} + +func getResponseFrame(resp *backend.QueryDataResponse, refID string) (data.Frames, error) { + response, ok := resp.Responses[refID] + if !ok { + // This indicates that the RefID of the request was not included to the response, i.e. some problem in the data source plugin + keys := make([]string, 0, len(resp.Responses)) + for refID := range resp.Responses { + keys = append(keys, refID) + } + logger.Warn("Can't find response by refID. Return nodata", "responseRefIds", keys) + return nil, nil + } + + if response.Error != nil { + return nil, response.Error + } + return response.Frames, nil +} + +func isAllFrameVectors(datasourceType string, frames data.Frames) bool { + if datasourceType != datasources.DS_PROMETHEUS { + return false + } + allVector := false + for i, frame := range frames { + if frame.Meta != nil && frame.Meta.Custom != nil { + if sMap, ok := frame.Meta.Custom.(map[string]string); ok { + if sMap != nil { + if sMap["resultType"] == "vector" { + if i != 0 && !allVector { + break + } + allVector = true + } + } + } + } + } + return allVector +} + +func framesToNumbers(frames data.Frames) ([]mathexp.Value, error) { + vals := make([]mathexp.Value, 0, len(frames)) + for _, frame := range frames { + if frame == nil { + continue + } + if len(frame.Fields) == 2 && frame.Fields[0].Len() == 1 { + // Can there be zero Len Field results that are being skipped? + valueField := frame.Fields[1] + if valueField.Type().Numeric() { // should be []float64 + val, err := valueField.FloatAt(0) // FloatAt should not err if numeric + if err != nil { + return nil, fmt.Errorf("failed to read value of frame [%v] (RefID %v) of type [%v] as float: %w", frame.Name, frame.RefID, valueField.Type(), err) + } + n := mathexp.NewNumber(frame.Name, valueField.Labels) + n.SetValue(&val) + vals = append(vals, n) + } + } + } + return vals, nil +} + +func isNumberTable(frame *data.Frame) bool { + if frame == nil || frame.Fields == nil { + return false + } + numericCount := 0 + stringCount := 0 + otherCount := 0 + for _, field := range frame.Fields { + fType := field.Type() + switch { + case fType.Numeric(): + numericCount++ + case fType == data.FieldTypeString || fType == data.FieldTypeNullableString: + stringCount++ + default: + otherCount++ + } + } + return numericCount == 1 && otherCount == 0 +} + +func extractNumberSet(frame *data.Frame) ([]mathexp.Number, error) { + numericField := 0 + stringFieldIdxs := []int{} + stringFieldNames := []string{} + for i, field := range frame.Fields { + fType := field.Type() + switch { + case fType.Numeric(): + numericField = i + case fType == data.FieldTypeString || fType == data.FieldTypeNullableString: + stringFieldIdxs = append(stringFieldIdxs, i) + stringFieldNames = append(stringFieldNames, field.Name) + } + } + numbers := make([]mathexp.Number, frame.Rows()) + + for rowIdx := 0; rowIdx < frame.Rows(); rowIdx++ { + val, _ := frame.FloatAt(numericField, rowIdx) + var labels data.Labels + for i := 0; i < len(stringFieldIdxs); i++ { + if i == 0 { + labels = make(data.Labels) + } + key := stringFieldNames[i] // TODO check for duplicate string column names + val, _ := frame.ConcreteAt(stringFieldIdxs[i], rowIdx) + labels[key] = val.(string) // TODO check assertion / return error + } + + n := mathexp.NewNumber(frame.Fields[numericField].Name, labels) + + // The new value fields' configs gets pointed to the one in the original frame + n.Frame.Fields[0].Config = frame.Fields[numericField].Config + n.SetValue(&val) + + numbers[rowIdx] = n + } + return numbers, nil +} + +// WideToMany converts a data package wide type Frame to one or multiple Series. A series +// is created for each value type column of wide frame. +// +// This might not be a good idea long term, but works now as an adapter/shim. +func WideToMany(frame *data.Frame, fixSeries func(series mathexp.Series, valueField *data.Field)) ([]mathexp.Series, error) { + tsSchema := frame.TimeSeriesSchema() + if tsSchema.Type != data.TimeSeriesTypeWide { + return nil, fmt.Errorf("input data must be a wide series but got type %s", tsSchema.Type) + } + + if len(tsSchema.ValueIndices) == 1 { + s, err := mathexp.SeriesFromFrame(frame) + if err != nil { + return nil, err + } + if fixSeries != nil { + fixSeries(s, frame.Fields[tsSchema.ValueIndices[0]]) + } + return []mathexp.Series{s}, nil + } + + series := make([]mathexp.Series, 0, len(tsSchema.ValueIndices)) + for _, valIdx := range tsSchema.ValueIndices { + l := frame.Rows() + f := data.NewFrameOfFieldTypes(frame.Name, l, frame.Fields[tsSchema.TimeIndex].Type(), frame.Fields[valIdx].Type()) + f.Fields[0].Name = frame.Fields[tsSchema.TimeIndex].Name + f.Fields[1].Name = frame.Fields[valIdx].Name + + // The new value fields' configs gets pointed to the one in the original frame + f.Fields[1].Config = frame.Fields[valIdx].Config + + if frame.Fields[valIdx].Labels != nil { + f.Fields[1].Labels = frame.Fields[valIdx].Labels.Copy() + } + for i := 0; i < l; i++ { + f.SetRow(i, frame.Fields[tsSchema.TimeIndex].CopyAt(i), frame.Fields[valIdx].CopyAt(i)) + } + s, err := mathexp.SeriesFromFrame(f) + if err != nil { + return nil, err + } + if fixSeries != nil { + fixSeries(s, frame.Fields[valIdx]) + } + series = append(series, s) + } + + return series, nil +} + +// checkIfSeriesNeedToBeFixed scans all value fields of all provided frames and determines whether the resulting mathexp.Series +// needs to be updated so each series could be identifiable by labels. +// NOTE: applicable only to only datasources.DS_GRAPHITE and datasources.DS_TESTDATA data sources +// returns a function that patches the mathexp.Series with information from data.Field from which it was created if the all series need to be fixed. Otherwise, returns nil +func checkIfSeriesNeedToBeFixed(frames []*data.Frame, datasourceType string) func(series mathexp.Series, valueField *data.Field) { + if !(datasourceType == datasources.DS_GRAPHITE || datasourceType == datasources.DS_TESTDATA) { + return nil + } + + // get all value fields + var valueFields []*data.Field + for _, frame := range frames { + tsSchema := frame.TimeSeriesSchema() + for _, index := range tsSchema.ValueIndices { + field := frame.Fields[index] + // if at least one value field contains labels, the result does not need to be fixed. + if len(field.Labels) > 0 { + return nil + } + if valueFields == nil { + valueFields = make([]*data.Field, 0, len(frames)*len(tsSchema.ValueIndices)) + } + valueFields = append(valueFields, field) + } + } + + // selectors are in precedence order. + nameSelectors := []func(f *data.Field) string{ + func(f *data.Field) string { + if f == nil || f.Config == nil { + return "" + } + return f.Config.DisplayNameFromDS + }, + func(f *data.Field) string { + if f == nil || f.Config == nil { + return "" + } + return f.Config.DisplayName + }, + func(f *data.Field) string { + return f.Name + }, + } + + // now look for the first selector that would make all value fields be unique + for _, selector := range nameSelectors { + names := make(map[string]struct{}, len(valueFields)) + good := true + for _, field := range valueFields { + name := selector(field) + if _, ok := names[name]; ok || name == "" { + good = false + break + } + names[name] = struct{}{} + } + if good { + return func(series mathexp.Series, valueField *data.Field) { + series.SetLabels(data.Labels{ + nameLabelName: selector(valueField), + }) + } + } + } + return nil +} diff --git a/pkg/expr/converter_test.go b/pkg/expr/converter_test.go new file mode 100644 index 0000000000000..2a484ea96735e --- /dev/null +++ b/pkg/expr/converter_test.go @@ -0,0 +1,121 @@ +package expr + +import ( + "context" + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/expr/mathexp" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" +) + +func TestConvertDataFramesToResults(t *testing.T) { + s := &Service{ + cfg: setting.NewCfg(), + features: &featuremgmt.FeatureManager{}, + tracer: tracing.InitializeTracerForTest(), + metrics: newMetrics(nil), + } + converter := &ResultConverter{Features: s.features, Tracer: s.tracer} + + t.Run("should add name label if no labels and specific data source", func(t *testing.T) { + supported := []string{datasources.DS_GRAPHITE, datasources.DS_TESTDATA} + t.Run("when only field name is specified", func(t *testing.T) { + t.Run("use value field names if one frame - many series", func(t *testing.T) { + supported := []string{datasources.DS_GRAPHITE, datasources.DS_TESTDATA} + + frames := []*data.Frame{ + data.NewFrame("test", + data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), + data.NewField("test-value1", nil, []*float64{fp(2)}), + data.NewField("test-value2", nil, []*float64{fp(2)})), + } + + for _, dtype := range supported { + t.Run(dtype, func(t *testing.T) { + resultType, res, err := converter.Convert(context.Background(), dtype, frames, s.allowLongFrames) + require.NoError(t, err) + assert.Equal(t, "single frame series", resultType) + require.Len(t, res.Values, 2) + + var names []string + for _, value := range res.Values { + require.IsType(t, mathexp.Series{}, value) + lbls := value.GetLabels() + require.Contains(t, lbls, nameLabelName) + names = append(names, lbls[nameLabelName]) + } + require.EqualValues(t, []string{"test-value1", "test-value2"}, names) + }) + } + }) + t.Run("should use frame name if one frame - one series", func(t *testing.T) { + frames := []*data.Frame{ + data.NewFrame("test-frame1", + data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), + data.NewField("test-value1", nil, []*float64{fp(2)})), + data.NewFrame("test-frame2", + data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), + data.NewField("test-value2", nil, []*float64{fp(2)})), + } + + for _, dtype := range supported { + t.Run(dtype, func(t *testing.T) { + resultType, res, err := converter.Convert(context.Background(), dtype, frames, s.allowLongFrames) + require.NoError(t, err) + assert.Equal(t, "multi frame series", resultType) + require.Len(t, res.Values, 2) + + var names []string + for _, value := range res.Values { + require.IsType(t, mathexp.Series{}, value) + lbls := value.GetLabels() + require.Contains(t, lbls, nameLabelName) + names = append(names, lbls[nameLabelName]) + } + require.EqualValues(t, []string{"test-frame1", "test-frame2"}, names) + }) + } + }) + }) + t.Run("should use fields DisplayNameFromDS when it is unique", func(t *testing.T) { + f1 := data.NewField("test-value1", nil, []*float64{fp(2)}) + f1.Config = &data.FieldConfig{DisplayNameFromDS: "test-value1"} + f2 := data.NewField("test-value2", nil, []*float64{fp(2)}) + f2.Config = &data.FieldConfig{DisplayNameFromDS: "test-value2"} + frames := []*data.Frame{ + data.NewFrame("test-frame1", + data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), + f1), + data.NewFrame("test-frame2", + data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), + f2), + } + + for _, dtype := range supported { + t.Run(dtype, func(t *testing.T) { + resultType, res, err := converter.Convert(context.Background(), dtype, frames, s.allowLongFrames) + require.NoError(t, err) + assert.Equal(t, "multi frame series", resultType) + require.Len(t, res.Values, 2) + + var names []string + for _, value := range res.Values { + require.IsType(t, mathexp.Series{}, value) + lbls := value.GetLabels() + require.Contains(t, lbls, nameLabelName) + names = append(names, lbls[nameLabelName]) + } + require.EqualValues(t, []string{"test-value1", "test-value2"}, names) + }) + } + }) + }) +} diff --git a/pkg/expr/dataplane_test.go b/pkg/expr/dataplane_test.go index 3973dd8c86ebe..674d4432f0a10 100644 --- a/pkg/expr/dataplane_test.go +++ b/pkg/expr/dataplane_test.go @@ -14,11 +14,10 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/config" - pluginFakes "github.com/grafana/grafana/pkg/plugins/manager/fakes" "github.com/grafana/grafana/pkg/services/datasources" datafakes "github.com/grafana/grafana/pkg/services/datasources/fakes" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/user" @@ -51,19 +50,25 @@ func framesPassThroughService(t *testing.T, frames data.Frames) (data.Frames, er map[string]backend.DataResponse{"A": {Frames: frames}}, } + features := featuremgmt.WithFeatures() cfg := setting.NewCfg() s := Service{ cfg: cfg, dataService: me, - features: &featuremgmt.FeatureManager{}, + features: features, pCtxProvider: plugincontext.ProvideService(cfg, nil, &pluginstore.FakePluginStore{ PluginList: []pluginstore.Plugin{ {JSONData: plugins.JSONData{ID: "test"}}, }}, - &datafakes.FakeDataSourceService{}, nil, pluginFakes.NewFakeLicensingService(), &config.Cfg{}), + &datafakes.FakeCacheService{}, &datafakes.FakeDataSourceService{}, + nil, pluginconfig.NewFakePluginRequestConfigProvider()), tracer: tracing.InitializeTracerForTest(), metrics: newMetrics(nil), + converter: &ResultConverter{ + Features: features, + Tracer: tracing.InitializeTracerForTest(), + }, } queries := []Query{{ RefID: "A", diff --git a/pkg/expr/graph.go b/pkg/expr/graph.go index 2b09143110b89..49c90b6221b35 100644 --- a/pkg/expr/graph.go +++ b/pkg/expr/graph.go @@ -75,6 +75,8 @@ func (dp *DataPipeline) execute(c context.Context, now time.Time, s *Service) (m executeDSNodesGrouped(c, now, vars, s, dsNodes) } + s.allowLongFrames = hasSqlExpression(*dp) + for _, node := range *dp { if groupByDSFlag && node.NodeType() == TypeDatasourceNode { continue // already executed via executeDSNodesGrouped @@ -266,6 +268,10 @@ func buildGraphEdges(dp *simple.DirectedGraph, registry map[string]Node) error { for _, neededVar := range cmdNode.Command.NeedsVars() { neededNode, ok := registry[neededVar] if !ok { + _, ok := cmdNode.Command.(*SQLCommand) + if ok { + continue + } return fmt.Errorf("unable to find dependent node '%v'", neededVar) } @@ -312,3 +318,37 @@ func GetCommandsFromPipeline[T Command](pipeline DataPipeline) []T { } return results } + +func hasSqlExpression(dp DataPipeline) bool { + for _, node := range dp { + if node.NodeType() == TypeCMDNode { + cmdNode := node.(*CMDNode) + _, ok := cmdNode.Command.(*SQLCommand) + if ok { + return true + } + } + } + return false +} + +// func graphHasSqlExpresssion(dp *simple.DirectedGraph) bool { +// node := dp.Nodes() +// for node.Next() { +// if cmdNode, ok := node.Node().(*CMDNode); ok { +// // res[dpNode.RefID()] = dpNode +// _, ok := cmdNode.Command.(*SQLCommand) +// if ok { +// return true +// } +// } +// // if node.NodeType() == TypeCMDNode { +// // cmdNode := node.(*CMDNode) +// // _, ok := cmdNode.Command.(*SQLCommand) +// // if ok { +// // return true +// // } +// // } +// } +// return false +// } diff --git a/pkg/expr/graph_test.go b/pkg/expr/graph_test.go index 11be8e5a35287..fafca8f68763c 100644 --- a/pkg/expr/graph_test.go +++ b/pkg/expr/graph_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" ) func TestServicebuildPipeLine(t *testing.T) { @@ -231,7 +232,9 @@ func TestServicebuildPipeLine(t *testing.T) { expectedOrder: []string{"B", "A"}, }, } - s := Service{} + s := Service{ + features: featuremgmt.WithFeatures(featuremgmt.FlagExpressionParser), + } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { nodes, err := s.buildPipeline(tt.req) diff --git a/pkg/expr/mathexp/parse/node.go b/pkg/expr/mathexp/parse/node.go index 41feeb702667e..02c297dde968e 100644 --- a/pkg/expr/mathexp/parse/node.go +++ b/pkg/expr/mathexp/parse/node.go @@ -405,6 +405,8 @@ const ( TypeVariantSet // TypeNoData is a no data response without a known data type. TypeNoData + // TypeTableData is a tabular data response. + TypeTableData ) // String returns a string representation of the ReturnType. @@ -422,6 +424,8 @@ func (f ReturnType) String() string { return "variant" case TypeNoData: return "noData" + case TypeTableData: + return "tableData" default: return "unknown" } diff --git a/pkg/expr/mathexp/reduce.go b/pkg/expr/mathexp/reduce.go index b4b6611eb85d7..76d74ca459fca 100644 --- a/pkg/expr/mathexp/reduce.go +++ b/pkg/expr/mathexp/reduce.go @@ -3,13 +3,30 @@ package mathexp import ( "fmt" "math" - "strings" "github.com/grafana/grafana-plugin-sdk-go/data" ) type ReducerFunc = func(fv *Float64Field) *float64 +// The reducer function +// +enum +type ReducerID string + +const ( + ReducerSum ReducerID = "sum" + ReducerMean ReducerID = "mean" + ReducerMin ReducerID = "min" + ReducerMax ReducerID = "max" + ReducerCount ReducerID = "count" + ReducerLast ReducerID = "last" +) + +// GetSupportedReduceFuncs returns collection of supported function names +func GetSupportedReduceFuncs() []ReducerID { + return []ReducerID{ReducerSum, ReducerMean, ReducerMin, ReducerMax, ReducerCount, ReducerLast} +} + func Sum(fv *Float64Field) *float64 { var sum float64 for i := 0; i < fv.Len(); i++ { @@ -81,34 +98,29 @@ func Last(fv *Float64Field) *float64 { return fv.GetValue(fv.Len() - 1) } -func GetReduceFunc(rFunc string) (ReducerFunc, error) { - switch strings.ToLower(rFunc) { - case "sum": +func GetReduceFunc(rFunc ReducerID) (ReducerFunc, error) { + switch rFunc { + case ReducerSum: return Sum, nil - case "mean": + case ReducerMean: return Avg, nil - case "min": + case ReducerMin: return Min, nil - case "max": + case ReducerMax: return Max, nil - case "count": + case ReducerCount: return Count, nil - case "last": + case ReducerLast: return Last, nil default: return nil, fmt.Errorf("reduction %v not implemented", rFunc) } } -// GetSupportedReduceFuncs returns collection of supported function names -func GetSupportedReduceFuncs() []string { - return []string{"sum", "mean", "min", "max", "count", "last"} -} - // Reduce turns the Series into a Number based on the given reduction function // if ReduceMapper is defined it applies it to the provided series and performs reduction of the resulting series. // Otherwise, the reduction operation is done against the original series. -func (s Series) Reduce(refID, rFunc string, mapper ReduceMapper) (Number, error) { +func (s Series) Reduce(refID string, rFunc ReducerID, mapper ReduceMapper) (Number, error) { var l data.Labels if s.GetLabels() != nil { l = s.GetLabels().Copy() diff --git a/pkg/expr/mathexp/reduce_test.go b/pkg/expr/mathexp/reduce_test.go index 2f9c7a2a81f83..c4d786cad8f38 100644 --- a/pkg/expr/mathexp/reduce_test.go +++ b/pkg/expr/mathexp/reduce_test.go @@ -30,7 +30,7 @@ var seriesEmpty = Vars{ func TestSeriesReduce(t *testing.T) { var tests = []struct { name string - red string + red ReducerID vars Vars varToReduce string errIs require.ErrorAssertionFunc @@ -217,7 +217,7 @@ var seriesNonNumbers = Vars{ func TestSeriesReduceDropNN(t *testing.T) { var tests = []struct { name string - red string + red ReducerID vars Vars varToReduce string results Results @@ -304,7 +304,7 @@ func TestSeriesReduceReplaceNN(t *testing.T) { replaceWith := rand.Float64() var tests = []struct { name string - red string + red ReducerID vars Vars varToReduce string results Results diff --git a/pkg/expr/mathexp/resample.go b/pkg/expr/mathexp/resample.go index f6239be59f9b8..69c9a3db548e6 100644 --- a/pkg/expr/mathexp/resample.go +++ b/pkg/expr/mathexp/resample.go @@ -7,8 +7,23 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" ) +// The upsample function +// +enum +type Upsampler string + +const ( + // Use the last seen value + UpsamplerPad Upsampler = "pad" + + // backfill + UpsamplerBackfill Upsampler = "backfilling" + + // Do not fill values (nill) + UpsamplerFillNA Upsampler = "fillna" +) + // Resample turns the Series into a Number based on the given reduction function -func (s Series) Resample(refID string, interval time.Duration, downsampler string, upsampler string, from, to time.Time) (Series, error) { +func (s Series) Resample(refID string, interval time.Duration, downsampler ReducerID, upsampler Upsampler, from, to time.Time) (Series, error) { newSeriesLength := int(float64(to.Sub(from).Nanoseconds()) / float64(interval.Nanoseconds())) if newSeriesLength <= 0 { return s, fmt.Errorf("the series cannot be sampled further; the time range is shorter than the interval") @@ -37,19 +52,19 @@ func (s Series) Resample(refID string, interval time.Duration, downsampler strin var value *float64 if len(vals) == 0 { // upsampling switch upsampler { - case "pad": + case UpsamplerPad: if lastSeen != nil { value = lastSeen } else { value = nil } - case "backfilling": + case UpsamplerBackfill: if sIdx == s.Len() { // no vals left value = nil } else { _, value = s.GetPoint(sIdx) } - case "fillna": + case UpsamplerFillNA: value = nil default: return s, fmt.Errorf("upsampling %v not implemented", upsampler) @@ -61,15 +76,15 @@ func (s Series) Resample(refID string, interval time.Duration, downsampler strin ff := Float64Field(*fVec) var tmp *float64 switch downsampler { - case "sum": + case ReducerSum: tmp = Sum(&ff) - case "mean": + case ReducerMean: tmp = Avg(&ff) - case "min": + case ReducerMin: tmp = Min(&ff) - case "max": + case ReducerMax: tmp = Max(&ff) - case "last": + case ReducerLast: tmp = Last(&ff) default: return s, fmt.Errorf("downsampling %v not implemented", downsampler) diff --git a/pkg/expr/mathexp/resample_test.go b/pkg/expr/mathexp/resample_test.go index a09ae962266e0..afee71428b4ef 100644 --- a/pkg/expr/mathexp/resample_test.go +++ b/pkg/expr/mathexp/resample_test.go @@ -13,8 +13,8 @@ func TestResampleSeries(t *testing.T) { var tests = []struct { name string interval time.Duration - downsampler string - upsampler string + downsampler ReducerID + upsampler Upsampler timeRange backend.TimeRange seriesToResample Series series Series diff --git a/pkg/expr/mathexp/types.go b/pkg/expr/mathexp/types.go index ca5069650b7b0..ba9115c7443a7 100644 --- a/pkg/expr/mathexp/types.go +++ b/pkg/expr/mathexp/types.go @@ -196,7 +196,7 @@ func (ff *Float64Field) GetValue(idx int) *float64 { return &f } -// Len returns the the length of the field. +// Len returns the length of the field. func (ff *Float64Field) Len() int { df := data.Field(*ff) return df.Len() @@ -246,3 +246,48 @@ func (s NoData) New() NoData { func NewNoData() NoData { return NoData{data.NewFrame("no data")} } + +// TableData is an untyped no data response. +type TableData struct{ Frame *data.Frame } + +// Type returns the Value type and allows it to fulfill the Value interface. +func (s TableData) Type() parse.ReturnType { return parse.TypeTableData } + +// Value returns the actual value allows it to fulfill the Value interface. +func (s TableData) Value() any { return s } + +func (s TableData) GetLabels() data.Labels { return nil } + +func (s TableData) SetLabels(ls data.Labels) {} + +func (s TableData) GetMeta() any { + return s.Frame.Meta.Custom +} + +func (s TableData) SetMeta(v any) { + m := s.Frame.Meta + if m == nil { + m = &data.FrameMeta{} + s.Frame.SetMeta(m) + } + m.Custom = v +} + +func (s TableData) AddNotice(notice data.Notice) { + m := s.Frame.Meta + if m == nil { + m = &data.FrameMeta{} + s.Frame.SetMeta(m) + } + m.Notices = append(m.Notices, notice) +} + +func (s TableData) AsDataFrame() *data.Frame { return s.Frame } + +func (s TableData) New() TableData { + return NewTableData() +} + +func NewTableData() TableData { + return TableData{data.NewFrame("")} +} diff --git a/pkg/expr/ml.go b/pkg/expr/ml.go index 185b265474e6c..affdad77a955c 100644 --- a/pkg/expr/ml.go +++ b/pkg/expr/ml.go @@ -130,7 +130,7 @@ func (m *MLNode) Execute(ctx context.Context, now time.Time, _ mathexp.Vars, s * } // process the response the same way DSNode does. Use plugin ID as data source type. Semantically, they are the same. - responseType, result, err = convertDataFramesToResults(ctx, dataFrames, mlPluginID, s, logger) + responseType, result, err = s.converter.Convert(ctx, mlPluginID, dataFrames, s.allowLongFrames) return result, err } diff --git a/pkg/expr/models.go b/pkg/expr/models.go new file mode 100644 index 0000000000000..6fffd7d116cfd --- /dev/null +++ b/pkg/expr/models.go @@ -0,0 +1,102 @@ +package expr + +import ( + "github.com/grafana/grafana/pkg/expr/classic" + "github.com/grafana/grafana/pkg/expr/mathexp" +) + +// Supported expression types +// +enum +type QueryType string + +const ( + // Apply a mathematical expression to results + QueryTypeMath QueryType = "math" + + // Reduce query results + QueryTypeReduce QueryType = "reduce" + + // Resample query results + QueryTypeResample QueryType = "resample" + + // Classic query + QueryTypeClassic QueryType = "classic_conditions" + + // Threshold + QueryTypeThreshold QueryType = "threshold" + + // SQL query via DuckDB + QueryTypeSQL QueryType = "sql" +) + +type MathQuery struct { + // General math expression + Expression string `json:"expression" jsonschema:"minLength=1,example=$A + 1,example=$A/$B"` +} + +type ReduceQuery struct { + // Reference to single query result + Expression string `json:"expression" jsonschema:"minLength=1,example=$A"` + + // The reducer + Reducer mathexp.ReducerID `json:"reducer"` + + // Reducer Options + Settings *ReduceSettings `json:"settings,omitempty"` +} + +// QueryType = resample +type ResampleQuery struct { + // The math expression + Expression string `json:"expression" jsonschema:"minLength=1,example=$A + 1,example=$A"` + + // The time duration + Window string `json:"window" jsonschema:"minLength=1,example=1d,example=10m"` + + // The downsample function + Downsampler mathexp.ReducerID `json:"downsampler"` + + // The upsample function + Upsampler mathexp.Upsampler `json:"upsampler"` +} + +type ThresholdQuery struct { + // Reference to single query result + Expression string `json:"expression" jsonschema:"minLength=1,example=$A"` + + // Threshold Conditions + Conditions []ThresholdConditionJSON `json:"conditions"` +} + +type ClassicQuery struct { + Conditions []classic.ConditionJSON `json:"conditions"` +} + +// SQLQuery requires the sqlExpression feature flag +type SQLExpression struct { + Expression string `json:"expression" jsonschema:"minLength=1,example=SELECT * FROM A LIMIT 1"` +} + +//------------------------------- +// Non-query commands +//------------------------------- + +type ReduceSettings struct { + // Non-number reduce behavior + Mode ReduceMode `json:"mode"` + + // Only valid when mode is replace + ReplaceWithValue *float64 `json:"replaceWithValue,omitempty"` +} + +// Non-Number behavior mode +// +enum +type ReduceMode string + +const ( + // Drop non-numbers + ReduceModeDrop ReduceMode = "dropNN" + + // Replace non-numbers + ReduceModeReplace ReduceMode = "replaceNN" +) diff --git a/pkg/expr/nodes.go b/pkg/expr/nodes.go index 32883d305d6e7..14ac0fbe474a2 100644 --- a/pkg/expr/nodes.go +++ b/pkg/expr/nodes.go @@ -9,7 +9,8 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter" + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "gonum.org/v1/gonum/graph/simple" @@ -46,14 +47,22 @@ type rawNode struct { idx int64 } -func GetExpressionCommandType(rawQuery map[string]any) (c CommandType, err error) { +func getExpressionCommandTypeString(rawQuery map[string]any) (string, error) { rawType, ok := rawQuery["type"] if !ok { - return c, errors.New("no expression command type in query") + return "", errors.New("no expression command type in query") } typeString, ok := rawType.(string) if !ok { - return c, fmt.Errorf("expected expression command type to be a string, got type %T", rawType) + return "", fmt.Errorf("expected expression command type to be a string, got type %T", rawType) + } + return typeString, nil +} + +func GetExpressionCommandType(rawQuery map[string]any) (c CommandType, err error) { + typeString, err := getExpressionCommandTypeString(rawQuery) + if err != nil { + return c, err } return ParseCommandType(typeString) } @@ -111,6 +120,31 @@ func buildCMDNode(rn *rawNode, toggles featuremgmt.FeatureToggles) (*CMDNode, er CMDType: commandType, } + if toggles.IsEnabledGlobally(featuremgmt.FlagExpressionParser) { + rn.QueryType, err = getExpressionCommandTypeString(rn.Query) + if err != nil { + return nil, err // should not happen because the command was parsed first thing + } + + // NOTE: this structure of this is weird now, because it is targeting a structure + // where this is actually run in the root loop, however we want to verify the individual + // node parsing before changing the full tree parser + reader := NewExpressionQueryReader(toggles) + iter, err := jsoniter.ParseBytes(jsoniter.ConfigDefault, rn.QueryRaw) + if err != nil { + return nil, err + } + q, err := reader.ReadQuery(data.NewDataQuery(map[string]any{ + "refId": rn.RefID, + "type": rn.QueryType, + }), iter) + if err != nil { + return nil, err + } + node.Command = q.Command + return node, err + } + switch commandType { case TypeMath: node.Command, err = UnmarshalMathCommand(rn) @@ -122,6 +156,8 @@ func buildCMDNode(rn *rawNode, toggles featuremgmt.FeatureToggles) (*CMDNode, er node.Command, err = classic.UnmarshalConditionsCmd(rn.Query, rn.RefID) case TypeThreshold: node.Command, err = UnmarshalThresholdCommand(rn, toggles) + case TypeSQL: + node.Command, err = UnmarshalSQLCommand(rn) default: return nil, fmt.Errorf("expression command type '%v' in expression '%v' not implemented", commandType, rn.RefID) } @@ -290,7 +326,7 @@ func executeDSNodesGrouped(ctx context.Context, now time.Time, vars mathexp.Vars } var result mathexp.Results - responseType, result, err := convertDataFramesToResults(ctx, dataFrames, dn.datasource.Type, s, logger) + responseType, result, err := s.converter.Convert(ctx, dn.datasource.Type, dataFrames, s.allowLongFrames) if err != nil { result.Error = makeConversionError(dn.RefID(), err) } @@ -358,337 +394,9 @@ func (dn *DSNode) Execute(ctx context.Context, now time.Time, _ mathexp.Vars, s } var result mathexp.Results - responseType, result, err = convertDataFramesToResults(ctx, dataFrames, dn.datasource.Type, s, logger) + responseType, result, err = s.converter.Convert(ctx, dn.datasource.Type, dataFrames, s.allowLongFrames) if err != nil { err = makeConversionError(dn.refID, err) } return result, err } - -func getResponseFrame(resp *backend.QueryDataResponse, refID string) (data.Frames, error) { - response, ok := resp.Responses[refID] - if !ok { - // This indicates that the RefID of the request was not included to the response, i.e. some problem in the data source plugin - keys := make([]string, 0, len(resp.Responses)) - for refID := range resp.Responses { - keys = append(keys, refID) - } - logger.Warn("Can't find response by refID. Return nodata", "responseRefIds", keys) - return nil, nil - } - - if response.Error != nil { - return nil, response.Error - } - return response.Frames, nil -} - -func convertDataFramesToResults(ctx context.Context, frames data.Frames, datasourceType string, s *Service, logger log.Logger) (string, mathexp.Results, error) { - if len(frames) == 0 { - return "no-data", mathexp.Results{Values: mathexp.Values{mathexp.NewNoData()}}, nil - } - - var dt data.FrameType - dt, useDataplane, _ := shouldUseDataplane(frames, logger, s.features.IsEnabled(ctx, featuremgmt.FlagDisableSSEDataplane)) - if useDataplane { - logger.Debug("Handling SSE data source query through dataplane", "datatype", dt) - result, err := handleDataplaneFrames(ctx, s.tracer, dt, frames) - return fmt.Sprintf("dataplane-%s", dt), result, err - } - - if isAllFrameVectors(datasourceType, frames) { // Prometheus Specific Handling - vals, err := framesToNumbers(frames) - if err != nil { - return "", mathexp.Results{}, fmt.Errorf("failed to read frames as numbers: %w", err) - } - return "vector", mathexp.Results{Values: vals}, nil - } - - if len(frames) == 1 { - frame := frames[0] - // Handle Untyped NoData - if len(frame.Fields) == 0 { - return "no-data", mathexp.Results{Values: mathexp.Values{mathexp.NoData{Frame: frame}}}, nil - } - - // Handle Numeric Table - if frame.TimeSeriesSchema().Type == data.TimeSeriesTypeNot && isNumberTable(frame) { - numberSet, err := extractNumberSet(frame) - if err != nil { - return "", mathexp.Results{}, err - } - vals := make([]mathexp.Value, 0, len(numberSet)) - for _, n := range numberSet { - vals = append(vals, n) - } - return "number set", mathexp.Results{ - Values: vals, - }, nil - } - } - - filtered := make([]*data.Frame, 0, len(frames)) - totalLen := 0 - for _, frame := range frames { - schema := frame.TimeSeriesSchema() - // Check for TimeSeriesTypeNot in InfluxDB queries. A data frame of this type will cause - // the WideToMany() function to error out, which results in unhealthy alerts. - // This check should be removed once inconsistencies in data source responses are solved. - if schema.Type == data.TimeSeriesTypeNot && datasourceType == datasources.DS_INFLUXDB { - logger.Warn("Ignoring InfluxDB data frame due to missing numeric fields") - continue - } - if schema.Type != data.TimeSeriesTypeWide { - return "", mathexp.Results{}, fmt.Errorf("input data must be a wide series but got type %s (input refid)", schema.Type) - } - filtered = append(filtered, frame) - totalLen += len(schema.ValueIndices) - } - - if len(filtered) == 0 { - return "no data", mathexp.Results{Values: mathexp.Values{mathexp.NoData{Frame: frames[0]}}}, nil - } - - maybeFixerFn := checkIfSeriesNeedToBeFixed(filtered, datasourceType) - - vals := make([]mathexp.Value, 0, totalLen) - for _, frame := range filtered { - series, err := WideToMany(frame, maybeFixerFn) - if err != nil { - return "", mathexp.Results{}, err - } - for _, ser := range series { - vals = append(vals, ser) - } - } - dataType := "single frame series" - if len(filtered) > 1 { - dataType = "multi frame series" - } - return dataType, mathexp.Results{ - Values: vals, - }, nil -} - -func isAllFrameVectors(datasourceType string, frames data.Frames) bool { - if datasourceType != datasources.DS_PROMETHEUS { - return false - } - allVector := false - for i, frame := range frames { - if frame.Meta != nil && frame.Meta.Custom != nil { - if sMap, ok := frame.Meta.Custom.(map[string]string); ok { - if sMap != nil { - if sMap["resultType"] == "vector" { - if i != 0 && !allVector { - break - } - allVector = true - } - } - } - } - } - return allVector -} - -func framesToNumbers(frames data.Frames) ([]mathexp.Value, error) { - vals := make([]mathexp.Value, 0, len(frames)) - for _, frame := range frames { - if frame == nil { - continue - } - if len(frame.Fields) == 2 && frame.Fields[0].Len() == 1 { - // Can there be zero Len Field results that are being skipped? - valueField := frame.Fields[1] - if valueField.Type().Numeric() { // should be []float64 - val, err := valueField.FloatAt(0) // FloatAt should not err if numeric - if err != nil { - return nil, fmt.Errorf("failed to read value of frame [%v] (RefID %v) of type [%v] as float: %w", frame.Name, frame.RefID, valueField.Type(), err) - } - n := mathexp.NewNumber(frame.Name, valueField.Labels) - n.SetValue(&val) - vals = append(vals, n) - } - } - } - return vals, nil -} - -func isNumberTable(frame *data.Frame) bool { - if frame == nil || frame.Fields == nil { - return false - } - numericCount := 0 - stringCount := 0 - otherCount := 0 - for _, field := range frame.Fields { - fType := field.Type() - switch { - case fType.Numeric(): - numericCount++ - case fType == data.FieldTypeString || fType == data.FieldTypeNullableString: - stringCount++ - default: - otherCount++ - } - } - return numericCount == 1 && otherCount == 0 -} - -func extractNumberSet(frame *data.Frame) ([]mathexp.Number, error) { - numericField := 0 - stringFieldIdxs := []int{} - stringFieldNames := []string{} - for i, field := range frame.Fields { - fType := field.Type() - switch { - case fType.Numeric(): - numericField = i - case fType == data.FieldTypeString || fType == data.FieldTypeNullableString: - stringFieldIdxs = append(stringFieldIdxs, i) - stringFieldNames = append(stringFieldNames, field.Name) - } - } - numbers := make([]mathexp.Number, frame.Rows()) - - for rowIdx := 0; rowIdx < frame.Rows(); rowIdx++ { - val, _ := frame.FloatAt(numericField, rowIdx) - var labels data.Labels - for i := 0; i < len(stringFieldIdxs); i++ { - if i == 0 { - labels = make(data.Labels) - } - key := stringFieldNames[i] // TODO check for duplicate string column names - val, _ := frame.ConcreteAt(stringFieldIdxs[i], rowIdx) - labels[key] = val.(string) // TODO check assertion / return error - } - - n := mathexp.NewNumber(frame.Fields[numericField].Name, labels) - - // The new value fields' configs gets pointed to the one in the original frame - n.Frame.Fields[0].Config = frame.Fields[numericField].Config - n.SetValue(&val) - - numbers[rowIdx] = n - } - return numbers, nil -} - -// WideToMany converts a data package wide type Frame to one or multiple Series. A series -// is created for each value type column of wide frame. -// -// This might not be a good idea long term, but works now as an adapter/shim. -func WideToMany(frame *data.Frame, fixSeries func(series mathexp.Series, valueField *data.Field)) ([]mathexp.Series, error) { - tsSchema := frame.TimeSeriesSchema() - if tsSchema.Type != data.TimeSeriesTypeWide { - return nil, fmt.Errorf("input data must be a wide series but got type %s", tsSchema.Type) - } - - if len(tsSchema.ValueIndices) == 1 { - s, err := mathexp.SeriesFromFrame(frame) - if err != nil { - return nil, err - } - if fixSeries != nil { - fixSeries(s, frame.Fields[tsSchema.ValueIndices[0]]) - } - return []mathexp.Series{s}, nil - } - - series := make([]mathexp.Series, 0, len(tsSchema.ValueIndices)) - for _, valIdx := range tsSchema.ValueIndices { - l := frame.Rows() - f := data.NewFrameOfFieldTypes(frame.Name, l, frame.Fields[tsSchema.TimeIndex].Type(), frame.Fields[valIdx].Type()) - f.Fields[0].Name = frame.Fields[tsSchema.TimeIndex].Name - f.Fields[1].Name = frame.Fields[valIdx].Name - - // The new value fields' configs gets pointed to the one in the original frame - f.Fields[1].Config = frame.Fields[valIdx].Config - - if frame.Fields[valIdx].Labels != nil { - f.Fields[1].Labels = frame.Fields[valIdx].Labels.Copy() - } - for i := 0; i < l; i++ { - f.SetRow(i, frame.Fields[tsSchema.TimeIndex].CopyAt(i), frame.Fields[valIdx].CopyAt(i)) - } - s, err := mathexp.SeriesFromFrame(f) - if err != nil { - return nil, err - } - if fixSeries != nil { - fixSeries(s, frame.Fields[valIdx]) - } - series = append(series, s) - } - - return series, nil -} - -// checkIfSeriesNeedToBeFixed scans all value fields of all provided frames and determines whether the resulting mathexp.Series -// needs to be updated so each series could be identifiable by labels. -// NOTE: applicable only to only datasources.DS_GRAPHITE and datasources.DS_TESTDATA data sources -// returns a function that patches the mathexp.Series with information from data.Field from which it was created if the all series need to be fixed. Otherwise, returns nil -func checkIfSeriesNeedToBeFixed(frames []*data.Frame, datasourceType string) func(series mathexp.Series, valueField *data.Field) { - if !(datasourceType == datasources.DS_GRAPHITE || datasourceType == datasources.DS_TESTDATA) { - return nil - } - - // get all value fields - var valueFields []*data.Field - for _, frame := range frames { - tsSchema := frame.TimeSeriesSchema() - for _, index := range tsSchema.ValueIndices { - field := frame.Fields[index] - // if at least one value field contains labels, the result does not need to be fixed. - if len(field.Labels) > 0 { - return nil - } - if valueFields == nil { - valueFields = make([]*data.Field, 0, len(frames)*len(tsSchema.ValueIndices)) - } - valueFields = append(valueFields, field) - } - } - - // selectors are in precedence order. - nameSelectors := []func(f *data.Field) string{ - func(f *data.Field) string { - if f == nil || f.Config == nil { - return "" - } - return f.Config.DisplayNameFromDS - }, - func(f *data.Field) string { - if f == nil || f.Config == nil { - return "" - } - return f.Config.DisplayName - }, - func(f *data.Field) string { - return f.Name - }, - } - - // now look for the first selector that would make all value fields be unique - for _, selector := range nameSelectors { - names := make(map[string]struct{}, len(valueFields)) - good := true - for _, field := range valueFields { - name := selector(field) - if _, ok := names[name]; ok || name == "" { - good = false - break - } - names[name] = struct{}{} - } - if good { - return func(series mathexp.Series, valueField *data.Field) { - series.SetLabels(data.Labels{ - nameLabelName: selector(valueField), - }) - } - } - } - return nil -} diff --git a/pkg/expr/nodes_test.go b/pkg/expr/nodes_test.go index f889e817c2e25..ada215a8ad912 100644 --- a/pkg/expr/nodes_test.go +++ b/pkg/expr/nodes_test.go @@ -1,7 +1,6 @@ package expr import ( - "context" "errors" "fmt" "testing" @@ -12,11 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/expr/mathexp" - "github.com/grafana/grafana/pkg/infra/log/logtest" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" ) type expectedError struct{} @@ -169,106 +164,3 @@ func TestCheckIfSeriesNeedToBeFixed(t *testing.T) { }) } } - -func TestConvertDataFramesToResults(t *testing.T) { - s := &Service{ - cfg: setting.NewCfg(), - features: &featuremgmt.FeatureManager{}, - tracer: tracing.InitializeTracerForTest(), - metrics: newMetrics(nil), - } - - t.Run("should add name label if no labels and specific data source", func(t *testing.T) { - supported := []string{datasources.DS_GRAPHITE, datasources.DS_TESTDATA} - t.Run("when only field name is specified", func(t *testing.T) { - t.Run("use value field names if one frame - many series", func(t *testing.T) { - supported := []string{datasources.DS_GRAPHITE, datasources.DS_TESTDATA} - - frames := []*data.Frame{ - data.NewFrame("test", - data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), - data.NewField("test-value1", nil, []*float64{fp(2)}), - data.NewField("test-value2", nil, []*float64{fp(2)})), - } - - for _, dtype := range supported { - t.Run(dtype, func(t *testing.T) { - resultType, res, err := convertDataFramesToResults(context.Background(), frames, dtype, s, &logtest.Fake{}) - require.NoError(t, err) - assert.Equal(t, "single frame series", resultType) - require.Len(t, res.Values, 2) - - var names []string - for _, value := range res.Values { - require.IsType(t, mathexp.Series{}, value) - lbls := value.GetLabels() - require.Contains(t, lbls, nameLabelName) - names = append(names, lbls[nameLabelName]) - } - require.EqualValues(t, []string{"test-value1", "test-value2"}, names) - }) - } - }) - t.Run("should use frame name if one frame - one series", func(t *testing.T) { - frames := []*data.Frame{ - data.NewFrame("test-frame1", - data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), - data.NewField("test-value1", nil, []*float64{fp(2)})), - data.NewFrame("test-frame2", - data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), - data.NewField("test-value2", nil, []*float64{fp(2)})), - } - - for _, dtype := range supported { - t.Run(dtype, func(t *testing.T) { - resultType, res, err := convertDataFramesToResults(context.Background(), frames, dtype, s, &logtest.Fake{}) - require.NoError(t, err) - assert.Equal(t, "multi frame series", resultType) - require.Len(t, res.Values, 2) - - var names []string - for _, value := range res.Values { - require.IsType(t, mathexp.Series{}, value) - lbls := value.GetLabels() - require.Contains(t, lbls, nameLabelName) - names = append(names, lbls[nameLabelName]) - } - require.EqualValues(t, []string{"test-frame1", "test-frame2"}, names) - }) - } - }) - }) - t.Run("should use fields DisplayNameFromDS when it is unique", func(t *testing.T) { - f1 := data.NewField("test-value1", nil, []*float64{fp(2)}) - f1.Config = &data.FieldConfig{DisplayNameFromDS: "test-value1"} - f2 := data.NewField("test-value2", nil, []*float64{fp(2)}) - f2.Config = &data.FieldConfig{DisplayNameFromDS: "test-value2"} - frames := []*data.Frame{ - data.NewFrame("test-frame1", - data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), - f1), - data.NewFrame("test-frame2", - data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), - f2), - } - - for _, dtype := range supported { - t.Run(dtype, func(t *testing.T) { - resultType, res, err := convertDataFramesToResults(context.Background(), frames, dtype, s, &logtest.Fake{}) - require.NoError(t, err) - assert.Equal(t, "multi frame series", resultType) - require.Len(t, res.Values, 2) - - var names []string - for _, value := range res.Values { - require.IsType(t, mathexp.Series{}, value) - lbls := value.GetLabels() - require.Contains(t, lbls, nameLabelName) - names = append(names, lbls[nameLabelName]) - } - require.EqualValues(t, []string{"test-value1", "test-value2"}, names) - }) - } - }) - }) -} diff --git a/pkg/expr/reader.go b/pkg/expr/reader.go new file mode 100644 index 0000000000000..71a75c6cb8524 --- /dev/null +++ b/pkg/expr/reader.go @@ -0,0 +1,186 @@ +package expr + +import ( + "fmt" + "strings" + + "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter" + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + + "github.com/grafana/grafana/pkg/expr/classic" + "github.com/grafana/grafana/pkg/expr/mathexp" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/tsdb/legacydata" +) + +// Once we are comfortable with the parsing logic, this struct will +// be merged/replace the existing Query struct in grafana/pkg/expr/transform.go +type ExpressionQuery struct { + GraphID int64 `json:"id,omitempty"` + RefID string `json:"refId"` + QueryType QueryType `json:"type"` + + // The typed query parameters + Properties any `json:"properties"` + + // Hidden in debug JSON + Command Command `json:"-"` +} + +// ID is used to identify nodes in the directed graph +func (q ExpressionQuery) ID() int64 { + return q.GraphID +} + +type ExpressionQueryReader struct { + features featuremgmt.FeatureToggles +} + +func NewExpressionQueryReader(features featuremgmt.FeatureToggles) *ExpressionQueryReader { + return &ExpressionQueryReader{ + features: features, + } +} + +// nolint:gocyclo +func (h *ExpressionQueryReader) ReadQuery( + // Properties that have been parsed off the same node + common data.DataQuery, + // An iterator with context for the full node (include common values) + iter *jsoniter.Iterator, +) (eq ExpressionQuery, err error) { + referenceVar := "" + eq.RefID = common.RefID + eq.QueryType = QueryType(common.GetString("type")) + if eq.QueryType == "" { + return eq, fmt.Errorf("missing type") + } + switch eq.QueryType { + case QueryTypeMath: + q := &MathQuery{} + err = iter.ReadVal(q) + if err == nil { + eq.Command, err = NewMathCommand(common.RefID, q.Expression) + eq.Properties = q + } + + case QueryTypeReduce: + var mapper mathexp.ReduceMapper = nil + q := &ReduceQuery{} + err = iter.ReadVal(q) + if err == nil { + referenceVar, err = getReferenceVar(q.Expression, common.RefID) + eq.Properties = q + } + if err == nil && q.Settings != nil { + switch q.Settings.Mode { + case ReduceModeDrop: + mapper = mathexp.DropNonNumber{} + case ReduceModeReplace: + if q.Settings.ReplaceWithValue == nil { + err = fmt.Errorf("setting replaceWithValue must be specified when mode is '%s'", q.Settings.Mode) + } + mapper = mathexp.ReplaceNonNumberWithValue{Value: *q.Settings.ReplaceWithValue} + default: + err = fmt.Errorf("unsupported reduce mode") + } + } + if err == nil { + eq.Properties = q + eq.Command, err = NewReduceCommand(common.RefID, + q.Reducer, referenceVar, mapper) + } + + case QueryTypeResample: + q := &ResampleQuery{} + err = iter.ReadVal(q) + if err == nil && common.TimeRange == nil { + err = fmt.Errorf("missing time range in query") + } + if err == nil { + referenceVar, err = getReferenceVar(q.Expression, common.RefID) + } + if err == nil { + tr := legacydata.NewDataTimeRange(common.TimeRange.From, common.TimeRange.To) + eq.Properties = q + eq.Command, err = NewResampleCommand(common.RefID, + q.Window, + referenceVar, + q.Downsampler, + q.Upsampler, + AbsoluteTimeRange{ + From: tr.GetFromAsTimeUTC(), + To: tr.GetToAsTimeUTC(), + }, + ) + } + + case QueryTypeClassic: + q := &ClassicQuery{} + err = iter.ReadVal(q) + if err == nil { + eq.Properties = q + eq.Command, err = classic.NewConditionCmd(common.RefID, q.Conditions) + } + + case QueryTypeSQL: + q := &SQLExpression{} + err = iter.ReadVal(q) + if err == nil { + eq.Properties = q + eq.Command, err = NewSQLCommand(common.RefID, q.Expression) + } + + case QueryTypeThreshold: + q := &ThresholdQuery{} + err = iter.ReadVal(q) + if err == nil { + referenceVar, err = getReferenceVar(q.Expression, common.RefID) + } + if err == nil { + // we only support one condition for now, we might want to turn this in to "OR" expressions later + if len(q.Conditions) != 1 { + return eq, fmt.Errorf("threshold expression requires exactly one condition") + } + firstCondition := q.Conditions[0] + + threshold, err := NewThresholdCommand(common.RefID, referenceVar, firstCondition.Evaluator.Type, firstCondition.Evaluator.Params) + if err != nil { + return eq, fmt.Errorf("invalid condition: %w", err) + } + eq.Command = threshold + eq.Properties = q + + if firstCondition.UnloadEvaluator != nil && h.features.IsEnabledGlobally(featuremgmt.FlagRecoveryThreshold) { + unloading, err := NewThresholdCommand(common.RefID, referenceVar, firstCondition.UnloadEvaluator.Type, firstCondition.UnloadEvaluator.Params) + unloading.Invert = true + if err != nil { + return eq, fmt.Errorf("invalid unloadCondition: %w", err) + } + var d Fingerprints + if firstCondition.LoadedDimensions != nil { + d, err = FingerprintsFromFrame(firstCondition.LoadedDimensions) + if err != nil { + return eq, fmt.Errorf("failed to parse loaded dimensions: %w", err) + } + } + eq.Command, err = NewHysteresisCommand(common.RefID, referenceVar, *threshold, *unloading, d) + if err != nil { + return eq, err + } + } + } + + default: + err = fmt.Errorf("unknown query type (%s)", common.QueryType) + } + return eq, err +} + +func getReferenceVar(exp string, refId string) (string, error) { + exp = strings.TrimPrefix(exp, "$") + if exp == "" { + return "", fmt.Errorf("no variable specified to reference for refId %v", refId) + } + return exp, nil +} diff --git a/pkg/expr/service.go b/pkg/expr/service.go index ffd7e3ffd667a..01c88be979b6e 100644 --- a/pkg/expr/service.go +++ b/pkg/expr/service.go @@ -60,11 +60,13 @@ type Service struct { dataService backend.QueryDataHandler pCtxProvider pluginContextProvider features featuremgmt.FeatureToggles + converter *ResultConverter pluginsClient backend.CallResourceHandler - tracer tracing.Tracer - metrics *metrics + tracer tracing.Tracer + metrics *metrics + allowLongFrames bool } type pluginContextProvider interface { @@ -82,6 +84,10 @@ func ProvideService(cfg *setting.Cfg, pluginClient plugins.Client, pCtxProvider tracer: tracer, metrics: newMetrics(registerer), pluginsClient: pluginClient, + converter: &ResultConverter{ + Features: features, + Tracer: tracer, + }, } } diff --git a/pkg/expr/service_test.go b/pkg/expr/service_test.go index 9b0c417a09e58..2ee7ff5d35757 100644 --- a/pkg/expr/service_test.go +++ b/pkg/expr/service_test.go @@ -15,11 +15,10 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/config" - "github.com/grafana/grafana/pkg/plugins/manager/fakes" "github.com/grafana/grafana/pkg/services/datasources" datafakes "github.com/grafana/grafana/pkg/services/datasources/fakes" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/user" @@ -42,15 +41,20 @@ func TestService(t *testing.T) { PluginList: []pluginstore.Plugin{ {JSONData: plugins.JSONData{ID: "test"}}, }, - }, &datafakes.FakeDataSourceService{}, nil, fakes.NewFakeLicensingService(), &config.Cfg{}) + }, &datafakes.FakeCacheService{}, &datafakes.FakeDataSourceService{}, nil, pluginconfig.NewFakePluginRequestConfigProvider()) + features := featuremgmt.WithFeatures() s := Service{ cfg: setting.NewCfg(), dataService: me, pCtxProvider: pCtxProvider, - features: &featuremgmt.FeatureManager{}, + features: features, tracer: tracing.InitializeTracerForTest(), metrics: newMetrics(nil), + converter: &ResultConverter{ + Features: features, + Tracer: tracing.InitializeTracerForTest(), + }, } queries := []Query{ @@ -128,7 +132,7 @@ func TestDSQueryError(t *testing.T) { PluginList: []pluginstore.Plugin{ {JSONData: plugins.JSONData{ID: "test"}}, }, - }, &datafakes.FakeDataSourceService{}, nil, nil, &config.Cfg{}) + }, &datafakes.FakeCacheService{}, &datafakes.FakeDataSourceService{}, nil, pluginconfig.NewFakePluginRequestConfigProvider()) s := Service{ cfg: setting.NewCfg(), diff --git a/pkg/expr/sql/parser.go b/pkg/expr/sql/parser.go new file mode 100644 index 0000000000000..d5ea2d1f6bed3 --- /dev/null +++ b/pkg/expr/sql/parser.go @@ -0,0 +1,99 @@ +package sql + +import ( + "errors" + "strings" + + parser "github.com/krasun/gosqlparser" + "github.com/xwb1989/sqlparser" +) + +// TablesList returns a list of tables for the sql statement +func TablesList(rawSQL string) ([]string, error) { + stmt, err := sqlparser.Parse(rawSQL) + if err != nil { + tables, err := parse(rawSQL) + if err != nil { + return parseTables(rawSQL) + } + return tables, nil + } + + tables := []string{} + switch kind := stmt.(type) { + case *sqlparser.Select: + for _, t := range kind.From { + buf := sqlparser.NewTrackedBuffer(nil) + t.Format(buf) + table := buf.String() + if table != "dual" { + tables = append(tables, buf.String()) + } + } + default: + return nil, errors.New("not a select statement") + } + return tables, nil +} + +// uses a simple tokenizer +func parse(rawSQL string) ([]string, error) { + query, err := parser.Parse(rawSQL) + if err != nil { + return nil, err + } + if query.GetType() == parser.StatementSelect { + sel, ok := query.(*parser.Select) + if ok { + return []string{sel.Table}, nil + } + } + return nil, err +} + +func parseTables(rawSQL string) ([]string, error) { + checkSql := strings.ToUpper(rawSQL) + if strings.HasPrefix(checkSql, "SELECT") || strings.HasPrefix(rawSQL, "WITH") { + tables := []string{} + tokens := strings.Split(rawSQL, " ") + checkNext := false + takeNext := false + for _, t := range tokens { + t = strings.ToUpper(t) + t = strings.TrimSpace(t) + + if takeNext { + tables = append(tables, t) + checkNext = false + takeNext = false + continue + } + if checkNext { + if strings.Contains(t, "(") { + checkNext = false + continue + } + if strings.Contains(t, ",") { + values := strings.Split(t, ",") + for _, v := range values { + v := strings.TrimSpace(v) + if v != "" { + tables = append(tables, v) + } else { + takeNext = true + break + } + } + continue + } + tables = append(tables, t) + checkNext = false + } + if t == "FROM" { + checkNext = true + } + } + return tables, nil + } + return nil, errors.New("not a select statement") +} diff --git a/pkg/expr/sql/parser_test.go b/pkg/expr/sql/parser_test.go new file mode 100644 index 0000000000000..2c8e43681e6cb --- /dev/null +++ b/pkg/expr/sql/parser_test.go @@ -0,0 +1,58 @@ +package sql + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParse(t *testing.T) { + sql := "select * from foo" + tables, err := parseTables((sql)) + assert.Nil(t, err) + + assert.Equal(t, "FOO", tables[0]) +} + +func TestParseWithComma(t *testing.T) { + sql := "select * from foo,bar" + tables, err := parseTables((sql)) + assert.Nil(t, err) + + assert.Equal(t, "FOO", tables[0]) + assert.Equal(t, "BAR", tables[1]) +} + +func TestParseWithCommas(t *testing.T) { + sql := "select * from foo,bar,baz" + tables, err := parseTables((sql)) + assert.Nil(t, err) + + assert.Equal(t, "FOO", tables[0]) + assert.Equal(t, "BAR", tables[1]) + assert.Equal(t, "BAZ", tables[2]) +} + +func TestArray(t *testing.T) { + sql := "SELECT array_value(1, 2, 3)" + tables, err := TablesList((sql)) + assert.Nil(t, err) + + assert.Equal(t, 0, len(tables)) +} + +func TestArray2(t *testing.T) { + sql := "SELECT array_value(1, 2, 3)[2]" + tables, err := TablesList((sql)) + assert.Nil(t, err) + + assert.Equal(t, 0, len(tables)) +} + +func TestXxx(t *testing.T) { + sql := "SELECT [3, 2, 1]::INT[3];" + tables, err := TablesList((sql)) + assert.Nil(t, err) + + assert.Equal(t, 0, len(tables)) +} diff --git a/pkg/expr/sql_command.go b/pkg/expr/sql_command.go new file mode 100644 index 0000000000000..44bbe559862de --- /dev/null +++ b/pkg/expr/sql_command.go @@ -0,0 +1,105 @@ +package expr + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/scottlepp/go-duck/duck" + + "github.com/grafana/grafana/pkg/expr/mathexp" + "github.com/grafana/grafana/pkg/expr/sql" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/util/errutil" +) + +// SQLCommand is an expression to run SQL over results +type SQLCommand struct { + query string + varsToQuery []string + refID string +} + +// NewSQLCommand creates a new SQLCommand. +func NewSQLCommand(refID, rawSQL string) (*SQLCommand, error) { + if rawSQL == "" { + return nil, errutil.BadRequest("sql-missing-query", + errutil.WithPublicMessage("missing SQL query")) + } + tables, err := sql.TablesList(rawSQL) + if err != nil { + logger.Warn("invalid sql query", "sql", rawSQL, "error", err) + return nil, errutil.BadRequest("sql-invalid-sql", + errutil.WithPublicMessage("error reading SQL command"), + ) + } + return &SQLCommand{ + query: rawSQL, + varsToQuery: tables, + refID: refID, + }, nil +} + +// UnmarshalSQLCommand creates a SQLCommand from Grafana's frontend query. +func UnmarshalSQLCommand(rn *rawNode) (*SQLCommand, error) { + if rn.TimeRange == nil { + return nil, fmt.Errorf("time range must be specified for refID %s", rn.RefID) + } + + expressionRaw, ok := rn.Query["expression"] + if !ok { + return nil, errors.New("no expression in the query") + } + expression, ok := expressionRaw.(string) + if !ok { + return nil, fmt.Errorf("expected sql expression to be type string, but got type %T", expressionRaw) + } + + return NewSQLCommand(rn.RefID, expression) +} + +// NeedsVars returns the variable names (refIds) that are dependencies +// to execute the command and allows the command to fulfill the Command interface. +func (gr *SQLCommand) NeedsVars() []string { + return gr.varsToQuery +} + +// Execute runs the command and returns the results or an error if the command +// failed to execute. +func (gr *SQLCommand) Execute(ctx context.Context, now time.Time, vars mathexp.Vars, tracer tracing.Tracer) (mathexp.Results, error) { + _, span := tracer.Start(ctx, "SSE.ExecuteSQL") + defer span.End() + + allFrames := []*data.Frame{} + for _, ref := range gr.varsToQuery { + results := vars[ref] + frames := results.Values.AsDataFrames(ref) + allFrames = append(allFrames, frames...) + } + + rsp := mathexp.Results{} + + duckDB := duck.NewInMemoryDB() + var frame = &data.Frame{} + err := duckDB.QueryFramesInto(gr.refID, gr.query, allFrames, frame) + if err != nil { + rsp.Error = err + return rsp, nil + } + + frame.RefID = gr.refID + + if frame.Rows() == 0 { + rsp.Values = mathexp.Values{ + mathexp.NoData{Frame: frame}, + } + } + + rsp.Values = mathexp.Values{ + mathexp.TableData{Frame: frame}, + } + + return rsp, nil +} diff --git a/pkg/expr/sql_command_test.go b/pkg/expr/sql_command_test.go new file mode 100644 index 0000000000000..7bd9d3c06e20a --- /dev/null +++ b/pkg/expr/sql_command_test.go @@ -0,0 +1,26 @@ +package expr + +import ( + "strings" + "testing" +) + +func TestNewCommand(t *testing.T) { + cmd, err := NewSQLCommand("a", "select a from foo, bar") + if err != nil && strings.Contains(err.Error(), "feature is not enabled") { + return + } + + if err != nil { + t.Fail() + return + } + + for _, v := range cmd.varsToQuery { + if strings.Contains("foo bar", v) { + continue + } + t.Fail() + return + } +} diff --git a/pkg/expr/threshold.go b/pkg/expr/threshold.go index f6fd093a27dbd..8a8f9ea54176e 100644 --- a/pkg/expr/threshold.go +++ b/pkg/expr/threshold.go @@ -18,23 +18,31 @@ import ( type ThresholdCommand struct { ReferenceVar string RefID string - ThresholdFunc string + ThresholdFunc ThresholdType Conditions []float64 Invert bool } +// +enum +type ThresholdType string + const ( - ThresholdIsAbove = "gt" - ThresholdIsBelow = "lt" - ThresholdIsWithinRange = "within_range" - ThresholdIsOutsideRange = "outside_range" + ThresholdIsAbove ThresholdType = "gt" + ThresholdIsBelow ThresholdType = "lt" + ThresholdIsWithinRange ThresholdType = "within_range" + ThresholdIsOutsideRange ThresholdType = "outside_range" ) var ( - supportedThresholdFuncs = []string{ThresholdIsAbove, ThresholdIsBelow, ThresholdIsWithinRange, ThresholdIsOutsideRange} + supportedThresholdFuncs = []string{ + string(ThresholdIsAbove), + string(ThresholdIsBelow), + string(ThresholdIsWithinRange), + string(ThresholdIsOutsideRange), + } ) -func NewThresholdCommand(refID, referenceVar, thresholdFunc string, conditions []float64) (*ThresholdCommand, error) { +func NewThresholdCommand(refID, referenceVar string, thresholdFunc ThresholdType, conditions []float64) (*ThresholdCommand, error) { switch thresholdFunc { case ThresholdIsOutsideRange, ThresholdIsWithinRange: if len(conditions) < 2 { @@ -57,8 +65,8 @@ func NewThresholdCommand(refID, referenceVar, thresholdFunc string, conditions [ } type ConditionEvalJSON struct { - Params []float64 `json:"params"` - Type string `json:"type"` // e.g. "gt" + Params []float64 `json:"params"` + Type ThresholdType `json:"type"` // e.g. "gt" } // UnmarshalResampleCommand creates a ResampleCMD from Grafana's frontend query. @@ -121,7 +129,7 @@ func (tc *ThresholdCommand) Execute(ctx context.Context, now time.Time, vars mat } // createMathExpression converts all the info we have about a "threshold" expression in to a Math expression -func createMathExpression(referenceVar string, thresholdFunc string, args []float64, invert bool) (string, error) { +func createMathExpression(referenceVar string, thresholdFunc ThresholdType, args []float64, invert bool) (string, error) { var exp string switch thresholdFunc { case ThresholdIsAbove: diff --git a/pkg/expr/threshold_test.go b/pkg/expr/threshold_test.go index 18d1a19fb6fb6..c7e1c1f2eb64d 100644 --- a/pkg/expr/threshold_test.go +++ b/pkg/expr/threshold_test.go @@ -14,7 +14,7 @@ import ( func TestNewThresholdCommand(t *testing.T) { type testCase struct { - fn string + fn ThresholdType args []float64 shouldError bool expectedError string @@ -107,7 +107,7 @@ func TestUnmarshalThresholdCommand(t *testing.T) { require.IsType(t, &ThresholdCommand{}, command) cmd := command.(*ThresholdCommand) require.Equal(t, []string{"A"}, cmd.NeedsVars()) - require.Equal(t, "gt", cmd.ThresholdFunc) + require.Equal(t, ThresholdIsAbove, cmd.ThresholdFunc) require.Equal(t, []float64{20.0, 80.0}, cmd.Conditions) }, }, @@ -172,10 +172,10 @@ func TestUnmarshalThresholdCommand(t *testing.T) { cmd := c.(*HysteresisCommand) require.Equal(t, []string{"B"}, cmd.NeedsVars()) require.Equal(t, []string{"B"}, cmd.LoadingThresholdFunc.NeedsVars()) - require.Equal(t, "gt", cmd.LoadingThresholdFunc.ThresholdFunc) + require.Equal(t, ThresholdIsAbove, cmd.LoadingThresholdFunc.ThresholdFunc) require.Equal(t, []float64{100.0}, cmd.LoadingThresholdFunc.Conditions) require.Equal(t, []string{"B"}, cmd.UnloadingThresholdFunc.NeedsVars()) - require.Equal(t, "lt", cmd.UnloadingThresholdFunc.ThresholdFunc) + require.Equal(t, ThresholdIsBelow, cmd.UnloadingThresholdFunc.ThresholdFunc) require.Equal(t, []float64{31.0}, cmd.UnloadingThresholdFunc.Conditions) require.True(t, cmd.UnloadingThresholdFunc.Invert) require.NotNil(t, cmd.LoadedDimensions) @@ -233,7 +233,7 @@ func TestCreateMathExpression(t *testing.T) { expected string ref string - function string + function ThresholdType params []float64 } @@ -297,7 +297,7 @@ func TestCreateMathExpression(t *testing.T) { func TestIsSupportedThresholdFunc(t *testing.T) { type testCase struct { - function string + function ThresholdType supported bool } @@ -325,8 +325,8 @@ func TestIsSupportedThresholdFunc(t *testing.T) { } for _, tc := range cases { - t.Run(tc.function, func(t *testing.T) { - supported := IsSupportedThresholdFunc(tc.function) + t.Run(string(tc.function), func(t *testing.T) { + supported := IsSupportedThresholdFunc(string(tc.function)) require.Equal(t, supported, tc.supported) }) } diff --git a/pkg/generated/applyconfiguration/internal/internal.go b/pkg/generated/applyconfiguration/internal/internal.go new file mode 100644 index 0000000000000..2329c80fe2dd3 --- /dev/null +++ b/pkg/generated/applyconfiguration/internal/internal.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package internal + +import ( + "fmt" + "sync" + + typed "sigs.k8s.io/structured-merge-diff/v4/typed" +) + +func Parser() *typed.Parser { + parserOnce.Do(func() { + var err error + parser, err = typed.NewParser(schemaYAML) + if err != nil { + panic(fmt.Sprintf("Failed to parse schema: %v", err)) + } + }) + return parser +} + +var parserOnce sync.Once +var parser *typed.Parser +var schemaYAML = typed.YAMLObject(`types: +- name: __untyped_atomic_ + scalar: untyped + list: + elementType: + namedType: __untyped_atomic_ + elementRelationship: atomic + map: + elementType: + namedType: __untyped_atomic_ + elementRelationship: atomic +- name: __untyped_deduced_ + scalar: untyped + list: + elementType: + namedType: __untyped_atomic_ + elementRelationship: atomic + map: + elementType: + namedType: __untyped_deduced_ + elementRelationship: separable +`) diff --git a/pkg/generated/applyconfiguration/service/v0alpha1/externalname.go b/pkg/generated/applyconfiguration/service/v0alpha1/externalname.go new file mode 100644 index 0000000000000..7bf2af7bcead4 --- /dev/null +++ b/pkg/generated/applyconfiguration/service/v0alpha1/externalname.go @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// ExternalNameApplyConfiguration represents an declarative configuration of the ExternalName type for use +// with apply. +type ExternalNameApplyConfiguration struct { + v1.TypeMetaApplyConfiguration `json:",inline"` + *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` + Spec *ExternalNameSpecApplyConfiguration `json:"spec,omitempty"` +} + +// ExternalName constructs an declarative configuration of the ExternalName type for use with +// apply. +func ExternalName(name, namespace string) *ExternalNameApplyConfiguration { + b := &ExternalNameApplyConfiguration{} + b.WithName(name) + b.WithNamespace(namespace) + b.WithKind("ExternalName") + b.WithAPIVersion("service.grafana.app/v0alpha1") + return b +} + +// WithKind sets the Kind field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Kind field is set to the value of the last call. +func (b *ExternalNameApplyConfiguration) WithKind(value string) *ExternalNameApplyConfiguration { + b.Kind = &value + return b +} + +// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the APIVersion field is set to the value of the last call. +func (b *ExternalNameApplyConfiguration) WithAPIVersion(value string) *ExternalNameApplyConfiguration { + b.APIVersion = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *ExternalNameApplyConfiguration) WithName(value string) *ExternalNameApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.Name = &value + return b +} + +// WithGenerateName sets the GenerateName field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GenerateName field is set to the value of the last call. +func (b *ExternalNameApplyConfiguration) WithGenerateName(value string) *ExternalNameApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.GenerateName = &value + return b +} + +// WithNamespace sets the Namespace field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Namespace field is set to the value of the last call. +func (b *ExternalNameApplyConfiguration) WithNamespace(value string) *ExternalNameApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.Namespace = &value + return b +} + +// WithUID sets the UID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UID field is set to the value of the last call. +func (b *ExternalNameApplyConfiguration) WithUID(value types.UID) *ExternalNameApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.UID = &value + return b +} + +// WithResourceVersion sets the ResourceVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ResourceVersion field is set to the value of the last call. +func (b *ExternalNameApplyConfiguration) WithResourceVersion(value string) *ExternalNameApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ResourceVersion = &value + return b +} + +// WithGeneration sets the Generation field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Generation field is set to the value of the last call. +func (b *ExternalNameApplyConfiguration) WithGeneration(value int64) *ExternalNameApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.Generation = &value + return b +} + +// WithCreationTimestamp sets the CreationTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CreationTimestamp field is set to the value of the last call. +func (b *ExternalNameApplyConfiguration) WithCreationTimestamp(value metav1.Time) *ExternalNameApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.CreationTimestamp = &value + return b +} + +// WithDeletionTimestamp sets the DeletionTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionTimestamp field is set to the value of the last call. +func (b *ExternalNameApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *ExternalNameApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.DeletionTimestamp = &value + return b +} + +// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionGracePeriodSeconds field is set to the value of the last call. +func (b *ExternalNameApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *ExternalNameApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.DeletionGracePeriodSeconds = &value + return b +} + +// WithLabels puts the entries into the Labels field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Labels field, +// overwriting an existing map entries in Labels field with the same key. +func (b *ExternalNameApplyConfiguration) WithLabels(entries map[string]string) *ExternalNameApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.Labels == nil && len(entries) > 0 { + b.Labels = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.Labels[k] = v + } + return b +} + +// WithAnnotations puts the entries into the Annotations field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Annotations field, +// overwriting an existing map entries in Annotations field with the same key. +func (b *ExternalNameApplyConfiguration) WithAnnotations(entries map[string]string) *ExternalNameApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.Annotations == nil && len(entries) > 0 { + b.Annotations = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.Annotations[k] = v + } + return b +} + +// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the OwnerReferences field. +func (b *ExternalNameApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *ExternalNameApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + if values[i] == nil { + panic("nil value passed to WithOwnerReferences") + } + b.OwnerReferences = append(b.OwnerReferences, *values[i]) + } + return b +} + +// WithFinalizers adds the given value to the Finalizers field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Finalizers field. +func (b *ExternalNameApplyConfiguration) WithFinalizers(values ...string) *ExternalNameApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + b.Finalizers = append(b.Finalizers, values[i]) + } + return b +} + +func (b *ExternalNameApplyConfiguration) ensureObjectMetaApplyConfigurationExists() { + if b.ObjectMetaApplyConfiguration == nil { + b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{} + } +} + +// WithSpec sets the Spec field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Spec field is set to the value of the last call. +func (b *ExternalNameApplyConfiguration) WithSpec(value *ExternalNameSpecApplyConfiguration) *ExternalNameApplyConfiguration { + b.Spec = value + return b +} diff --git a/pkg/generated/applyconfiguration/service/v0alpha1/externalnamespec.go b/pkg/generated/applyconfiguration/service/v0alpha1/externalnamespec.go new file mode 100644 index 0000000000000..d172862ddb17f --- /dev/null +++ b/pkg/generated/applyconfiguration/service/v0alpha1/externalnamespec.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +// ExternalNameSpecApplyConfiguration represents an declarative configuration of the ExternalNameSpec type for use +// with apply. +type ExternalNameSpecApplyConfiguration struct { + Host *string `json:"host,omitempty"` +} + +// ExternalNameSpecApplyConfiguration constructs an declarative configuration of the ExternalNameSpec type for use with +// apply. +func ExternalNameSpec() *ExternalNameSpecApplyConfiguration { + return &ExternalNameSpecApplyConfiguration{} +} + +// WithHost sets the Host field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Host field is set to the value of the last call. +func (b *ExternalNameSpecApplyConfiguration) WithHost(value string) *ExternalNameSpecApplyConfiguration { + b.Host = &value + return b +} diff --git a/pkg/generated/applyconfiguration/utils.go b/pkg/generated/applyconfiguration/utils.go new file mode 100644 index 0000000000000..93ca019455ce4 --- /dev/null +++ b/pkg/generated/applyconfiguration/utils.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package applyconfiguration + +import ( + v0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + servicev0alpha1 "github.com/grafana/grafana/pkg/generated/applyconfiguration/service/v0alpha1" + schema "k8s.io/apimachinery/pkg/runtime/schema" +) + +// ForKind returns an apply configuration type for the given GroupVersionKind, or nil if no +// apply configuration type exists for the given GroupVersionKind. +func ForKind(kind schema.GroupVersionKind) interface{} { + switch kind { + // Group=service.grafana.app, Version=v0alpha1 + case v0alpha1.SchemeGroupVersion.WithKind("ExternalName"): + return &servicev0alpha1.ExternalNameApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("ExternalNameSpec"): + return &servicev0alpha1.ExternalNameSpecApplyConfiguration{} + + } + return nil +} diff --git a/pkg/generated/clientset/versioned/clientset.go b/pkg/generated/clientset/versioned/clientset.go new file mode 100644 index 0000000000000..55fa581701aa1 --- /dev/null +++ b/pkg/generated/clientset/versioned/clientset.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package versioned + +import ( + "fmt" + "net/http" + + servicev0alpha1 "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/service/v0alpha1" + discovery "k8s.io/client-go/discovery" + rest "k8s.io/client-go/rest" + flowcontrol "k8s.io/client-go/util/flowcontrol" +) + +type Interface interface { + Discovery() discovery.DiscoveryInterface + ServiceV0alpha1() servicev0alpha1.ServiceV0alpha1Interface +} + +// Clientset contains the clients for groups. +type Clientset struct { + *discovery.DiscoveryClient + serviceV0alpha1 *servicev0alpha1.ServiceV0alpha1Client +} + +// ServiceV0alpha1 retrieves the ServiceV0alpha1Client +func (c *Clientset) ServiceV0alpha1() servicev0alpha1.ServiceV0alpha1Interface { + return c.serviceV0alpha1 +} + +// Discovery retrieves the DiscoveryClient +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + if c == nil { + return nil + } + return c.DiscoveryClient +} + +// NewForConfig creates a new Clientset for the given config. +// If config's RateLimiter is not set and QPS and Burst are acceptable, +// NewForConfig will generate a rate-limiter in configShallowCopy. +// NewForConfig is equivalent to NewForConfigAndClient(c, httpClient), +// where httpClient was generated with rest.HTTPClientFor(c). +func NewForConfig(c *rest.Config) (*Clientset, error) { + configShallowCopy := *c + + if configShallowCopy.UserAgent == "" { + configShallowCopy.UserAgent = rest.DefaultKubernetesUserAgent() + } + + // share the transport between all clients + httpClient, err := rest.HTTPClientFor(&configShallowCopy) + if err != nil { + return nil, err + } + + return NewForConfigAndClient(&configShallowCopy, httpClient) +} + +// NewForConfigAndClient creates a new Clientset for the given config and http client. +// Note the http client provided takes precedence over the configured transport values. +// If config's RateLimiter is not set and QPS and Burst are acceptable, +// NewForConfigAndClient will generate a rate-limiter in configShallowCopy. +func NewForConfigAndClient(c *rest.Config, httpClient *http.Client) (*Clientset, error) { + configShallowCopy := *c + if configShallowCopy.RateLimiter == nil && configShallowCopy.QPS > 0 { + if configShallowCopy.Burst <= 0 { + return nil, fmt.Errorf("burst is required to be greater than 0 when RateLimiter is not set and QPS is set to greater than 0") + } + configShallowCopy.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(configShallowCopy.QPS, configShallowCopy.Burst) + } + + var cs Clientset + var err error + cs.serviceV0alpha1, err = servicev0alpha1.NewForConfigAndClient(&configShallowCopy, httpClient) + if err != nil { + return nil, err + } + + cs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfigAndClient(&configShallowCopy, httpClient) + if err != nil { + return nil, err + } + return &cs, nil +} + +// NewForConfigOrDie creates a new Clientset for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *Clientset { + cs, err := NewForConfig(c) + if err != nil { + panic(err) + } + return cs +} + +// New creates a new Clientset for the given RESTClient. +func New(c rest.Interface) *Clientset { + var cs Clientset + cs.serviceV0alpha1 = servicev0alpha1.New(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClient(c) + return &cs +} diff --git a/pkg/generated/clientset/versioned/fake/clientset_generated.go b/pkg/generated/clientset/versioned/fake/clientset_generated.go new file mode 100644 index 0000000000000..c7e19bcc91804 --- /dev/null +++ b/pkg/generated/clientset/versioned/fake/clientset_generated.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + clientset "github.com/grafana/grafana/pkg/generated/clientset/versioned" + servicev0alpha1 "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/service/v0alpha1" + fakeservicev0alpha1 "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/service/v0alpha1/fake" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/discovery" + fakediscovery "k8s.io/client-go/discovery/fake" + "k8s.io/client-go/testing" +) + +// NewSimpleClientset returns a clientset that will respond with the provided objects. +// It's backed by a very simple object tracker that processes creates, updates and deletions as-is, +// without applying any validations and/or defaults. It shouldn't be considered a replacement +// for a real clientset and is mostly useful in simple unit tests. +func NewSimpleClientset(objects ...runtime.Object) *Clientset { + o := testing.NewObjectTracker(scheme, codecs.UniversalDecoder()) + for _, obj := range objects { + if err := o.Add(obj); err != nil { + panic(err) + } + } + + cs := &Clientset{tracker: o} + cs.discovery = &fakediscovery.FakeDiscovery{Fake: &cs.Fake} + cs.AddReactor("*", "*", testing.ObjectReaction(o)) + cs.AddWatchReactor("*", func(action testing.Action) (handled bool, ret watch.Interface, err error) { + gvr := action.GetResource() + ns := action.GetNamespace() + watch, err := o.Watch(gvr, ns) + if err != nil { + return false, nil, err + } + return true, watch, nil + }) + + return cs +} + +// Clientset implements clientset.Interface. Meant to be embedded into a +// struct to get a default implementation. This makes faking out just the method +// you want to test easier. +type Clientset struct { + testing.Fake + discovery *fakediscovery.FakeDiscovery + tracker testing.ObjectTracker +} + +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + return c.discovery +} + +func (c *Clientset) Tracker() testing.ObjectTracker { + return c.tracker +} + +var ( + _ clientset.Interface = &Clientset{} + _ testing.FakeClient = &Clientset{} +) + +// ServiceV0alpha1 retrieves the ServiceV0alpha1Client +func (c *Clientset) ServiceV0alpha1() servicev0alpha1.ServiceV0alpha1Interface { + return &fakeservicev0alpha1.FakeServiceV0alpha1{Fake: &c.Fake} +} diff --git a/pkg/generated/clientset/versioned/fake/doc.go b/pkg/generated/clientset/versioned/fake/doc.go new file mode 100644 index 0000000000000..bc6b017db1be6 --- /dev/null +++ b/pkg/generated/clientset/versioned/fake/doc.go @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated fake clientset. +package fake diff --git a/pkg/generated/clientset/versioned/fake/register.go b/pkg/generated/clientset/versioned/fake/register.go new file mode 100644 index 0000000000000..bbf12658e7613 --- /dev/null +++ b/pkg/generated/clientset/versioned/fake/register.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + servicev0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" +) + +var scheme = runtime.NewScheme() +var codecs = serializer.NewCodecFactory(scheme) + +var localSchemeBuilder = runtime.SchemeBuilder{ + servicev0alpha1.AddToScheme, +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// _ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +var AddToScheme = localSchemeBuilder.AddToScheme + +func init() { + v1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) + utilruntime.Must(AddToScheme(scheme)) +} diff --git a/pkg/generated/clientset/versioned/scheme/doc.go b/pkg/generated/clientset/versioned/scheme/doc.go new file mode 100644 index 0000000000000..b69e1aef90632 --- /dev/null +++ b/pkg/generated/clientset/versioned/scheme/doc.go @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +// This package contains the scheme of the automatically generated clientset. +package scheme diff --git a/pkg/generated/clientset/versioned/scheme/register.go b/pkg/generated/clientset/versioned/scheme/register.go new file mode 100644 index 0000000000000..dd6e961916048 --- /dev/null +++ b/pkg/generated/clientset/versioned/scheme/register.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package scheme + +import ( + servicev0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" +) + +var Scheme = runtime.NewScheme() +var Codecs = serializer.NewCodecFactory(Scheme) +var ParameterCodec = runtime.NewParameterCodec(Scheme) +var localSchemeBuilder = runtime.SchemeBuilder{ + servicev0alpha1.AddToScheme, +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// _ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +var AddToScheme = localSchemeBuilder.AddToScheme + +func init() { + v1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"}) + utilruntime.Must(AddToScheme(Scheme)) +} diff --git a/pkg/generated/clientset/versioned/typed/service/v0alpha1/doc.go b/pkg/generated/clientset/versioned/typed/service/v0alpha1/doc.go new file mode 100644 index 0000000000000..1c86744fecc98 --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/service/v0alpha1/doc.go @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v0alpha1 diff --git a/pkg/generated/clientset/versioned/typed/service/v0alpha1/externalname.go b/pkg/generated/clientset/versioned/typed/service/v0alpha1/externalname.go new file mode 100644 index 0000000000000..04c348e53c1c9 --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/service/v0alpha1/externalname.go @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + "context" + json "encoding/json" + "fmt" + "time" + + v0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + servicev0alpha1 "github.com/grafana/grafana/pkg/generated/applyconfiguration/service/v0alpha1" + scheme "github.com/grafana/grafana/pkg/generated/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// ExternalNamesGetter has a method to return a ExternalNameInterface. +// A group's client should implement this interface. +type ExternalNamesGetter interface { + ExternalNames(namespace string) ExternalNameInterface +} + +// ExternalNameInterface has methods to work with ExternalName resources. +type ExternalNameInterface interface { + Create(ctx context.Context, externalName *v0alpha1.ExternalName, opts v1.CreateOptions) (*v0alpha1.ExternalName, error) + Update(ctx context.Context, externalName *v0alpha1.ExternalName, opts v1.UpdateOptions) (*v0alpha1.ExternalName, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*v0alpha1.ExternalName, error) + List(ctx context.Context, opts v1.ListOptions) (*v0alpha1.ExternalNameList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v0alpha1.ExternalName, err error) + Apply(ctx context.Context, externalName *servicev0alpha1.ExternalNameApplyConfiguration, opts v1.ApplyOptions) (result *v0alpha1.ExternalName, err error) + ExternalNameExpansion +} + +// externalNames implements ExternalNameInterface +type externalNames struct { + client rest.Interface + ns string +} + +// newExternalNames returns a ExternalNames +func newExternalNames(c *ServiceV0alpha1Client, namespace string) *externalNames { + return &externalNames{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the externalName, and returns the corresponding externalName object, and an error if there is any. +func (c *externalNames) Get(ctx context.Context, name string, options v1.GetOptions) (result *v0alpha1.ExternalName, err error) { + result = &v0alpha1.ExternalName{} + err = c.client.Get(). + Namespace(c.ns). + Resource("externalnames"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(ctx). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of ExternalNames that match those selectors. +func (c *externalNames) List(ctx context.Context, opts v1.ListOptions) (result *v0alpha1.ExternalNameList, err error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + result = &v0alpha1.ExternalNameList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("externalnames"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Do(ctx). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested externalNames. +func (c *externalNames) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("externalnames"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Watch(ctx) +} + +// Create takes the representation of a externalName and creates it. Returns the server's representation of the externalName, and an error, if there is any. +func (c *externalNames) Create(ctx context.Context, externalName *v0alpha1.ExternalName, opts v1.CreateOptions) (result *v0alpha1.ExternalName, err error) { + result = &v0alpha1.ExternalName{} + err = c.client.Post(). + Namespace(c.ns). + Resource("externalnames"). + VersionedParams(&opts, scheme.ParameterCodec). + Body(externalName). + Do(ctx). + Into(result) + return +} + +// Update takes the representation of a externalName and updates it. Returns the server's representation of the externalName, and an error, if there is any. +func (c *externalNames) Update(ctx context.Context, externalName *v0alpha1.ExternalName, opts v1.UpdateOptions) (result *v0alpha1.ExternalName, err error) { + result = &v0alpha1.ExternalName{} + err = c.client.Put(). + Namespace(c.ns). + Resource("externalnames"). + Name(externalName.Name). + VersionedParams(&opts, scheme.ParameterCodec). + Body(externalName). + Do(ctx). + Into(result) + return +} + +// Delete takes name of the externalName and deletes it. Returns an error if one occurs. +func (c *externalNames) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("externalnames"). + Name(name). + Body(&opts). + Do(ctx). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *externalNames) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + var timeout time.Duration + if listOpts.TimeoutSeconds != nil { + timeout = time.Duration(*listOpts.TimeoutSeconds) * time.Second + } + return c.client.Delete(). + Namespace(c.ns). + Resource("externalnames"). + VersionedParams(&listOpts, scheme.ParameterCodec). + Timeout(timeout). + Body(&opts). + Do(ctx). + Error() +} + +// Patch applies the patch and returns the patched externalName. +func (c *externalNames) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v0alpha1.ExternalName, err error) { + result = &v0alpha1.ExternalName{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("externalnames"). + Name(name). + SubResource(subresources...). + VersionedParams(&opts, scheme.ParameterCodec). + Body(data). + Do(ctx). + Into(result) + return +} + +// Apply takes the given apply declarative configuration, applies it and returns the applied externalName. +func (c *externalNames) Apply(ctx context.Context, externalName *servicev0alpha1.ExternalNameApplyConfiguration, opts v1.ApplyOptions) (result *v0alpha1.ExternalName, err error) { + if externalName == nil { + return nil, fmt.Errorf("externalName provided to Apply must not be nil") + } + patchOpts := opts.ToPatchOptions() + data, err := json.Marshal(externalName) + if err != nil { + return nil, err + } + name := externalName.Name + if name == nil { + return nil, fmt.Errorf("externalName.Name must be provided to Apply") + } + result = &v0alpha1.ExternalName{} + err = c.client.Patch(types.ApplyPatchType). + Namespace(c.ns). + Resource("externalnames"). + Name(*name). + VersionedParams(&patchOpts, scheme.ParameterCodec). + Body(data). + Do(ctx). + Into(result) + return +} diff --git a/pkg/generated/clientset/versioned/typed/service/v0alpha1/fake/doc.go b/pkg/generated/clientset/versioned/typed/service/v0alpha1/fake/doc.go new file mode 100644 index 0000000000000..d96b985b3eae1 --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/service/v0alpha1/fake/doc.go @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +// Package fake has the automatically generated clients. +package fake diff --git a/pkg/generated/clientset/versioned/typed/service/v0alpha1/fake/fake_externalname.go b/pkg/generated/clientset/versioned/typed/service/v0alpha1/fake/fake_externalname.go new file mode 100644 index 0000000000000..b3f0bacb493f0 --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/service/v0alpha1/fake/fake_externalname.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + "context" + json "encoding/json" + "fmt" + + v0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + servicev0alpha1 "github.com/grafana/grafana/pkg/generated/applyconfiguration/service/v0alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeExternalNames implements ExternalNameInterface +type FakeExternalNames struct { + Fake *FakeServiceV0alpha1 + ns string +} + +var externalnamesResource = v0alpha1.SchemeGroupVersion.WithResource("externalnames") + +var externalnamesKind = v0alpha1.SchemeGroupVersion.WithKind("ExternalName") + +// Get takes name of the externalName, and returns the corresponding externalName object, and an error if there is any. +func (c *FakeExternalNames) Get(ctx context.Context, name string, options v1.GetOptions) (result *v0alpha1.ExternalName, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(externalnamesResource, c.ns, name), &v0alpha1.ExternalName{}) + + if obj == nil { + return nil, err + } + return obj.(*v0alpha1.ExternalName), err +} + +// List takes label and field selectors, and returns the list of ExternalNames that match those selectors. +func (c *FakeExternalNames) List(ctx context.Context, opts v1.ListOptions) (result *v0alpha1.ExternalNameList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(externalnamesResource, externalnamesKind, c.ns, opts), &v0alpha1.ExternalNameList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v0alpha1.ExternalNameList{ListMeta: obj.(*v0alpha1.ExternalNameList).ListMeta} + for _, item := range obj.(*v0alpha1.ExternalNameList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested externalNames. +func (c *FakeExternalNames) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(externalnamesResource, c.ns, opts)) + +} + +// Create takes the representation of a externalName and creates it. Returns the server's representation of the externalName, and an error, if there is any. +func (c *FakeExternalNames) Create(ctx context.Context, externalName *v0alpha1.ExternalName, opts v1.CreateOptions) (result *v0alpha1.ExternalName, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(externalnamesResource, c.ns, externalName), &v0alpha1.ExternalName{}) + + if obj == nil { + return nil, err + } + return obj.(*v0alpha1.ExternalName), err +} + +// Update takes the representation of a externalName and updates it. Returns the server's representation of the externalName, and an error, if there is any. +func (c *FakeExternalNames) Update(ctx context.Context, externalName *v0alpha1.ExternalName, opts v1.UpdateOptions) (result *v0alpha1.ExternalName, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(externalnamesResource, c.ns, externalName), &v0alpha1.ExternalName{}) + + if obj == nil { + return nil, err + } + return obj.(*v0alpha1.ExternalName), err +} + +// Delete takes name of the externalName and deletes it. Returns an error if one occurs. +func (c *FakeExternalNames) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteActionWithOptions(externalnamesResource, c.ns, name, opts), &v0alpha1.ExternalName{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeExternalNames) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(externalnamesResource, c.ns, listOpts) + + _, err := c.Fake.Invokes(action, &v0alpha1.ExternalNameList{}) + return err +} + +// Patch applies the patch and returns the patched externalName. +func (c *FakeExternalNames) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v0alpha1.ExternalName, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(externalnamesResource, c.ns, name, pt, data, subresources...), &v0alpha1.ExternalName{}) + + if obj == nil { + return nil, err + } + return obj.(*v0alpha1.ExternalName), err +} + +// Apply takes the given apply declarative configuration, applies it and returns the applied externalName. +func (c *FakeExternalNames) Apply(ctx context.Context, externalName *servicev0alpha1.ExternalNameApplyConfiguration, opts v1.ApplyOptions) (result *v0alpha1.ExternalName, err error) { + if externalName == nil { + return nil, fmt.Errorf("externalName provided to Apply must not be nil") + } + data, err := json.Marshal(externalName) + if err != nil { + return nil, err + } + name := externalName.Name + if name == nil { + return nil, fmt.Errorf("externalName.Name must be provided to Apply") + } + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(externalnamesResource, c.ns, *name, types.ApplyPatchType, data), &v0alpha1.ExternalName{}) + + if obj == nil { + return nil, err + } + return obj.(*v0alpha1.ExternalName), err +} diff --git a/pkg/generated/clientset/versioned/typed/service/v0alpha1/fake/fake_service_client.go b/pkg/generated/clientset/versioned/typed/service/v0alpha1/fake/fake_service_client.go new file mode 100644 index 0000000000000..00969d9eb17db --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/service/v0alpha1/fake/fake_service_client.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v0alpha1 "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/service/v0alpha1" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeServiceV0alpha1 struct { + *testing.Fake +} + +func (c *FakeServiceV0alpha1) ExternalNames(namespace string) v0alpha1.ExternalNameInterface { + return &FakeExternalNames{c, namespace} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeServiceV0alpha1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/pkg/generated/clientset/versioned/typed/service/v0alpha1/generated_expansion.go b/pkg/generated/clientset/versioned/typed/service/v0alpha1/generated_expansion.go new file mode 100644 index 0000000000000..f0a8644fafa74 --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/service/v0alpha1/generated_expansion.go @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package v0alpha1 + +type ExternalNameExpansion interface{} diff --git a/pkg/generated/clientset/versioned/typed/service/v0alpha1/service_client.go b/pkg/generated/clientset/versioned/typed/service/v0alpha1/service_client.go new file mode 100644 index 0000000000000..76683c7106aa3 --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/service/v0alpha1/service_client.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + "net/http" + + v0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + "github.com/grafana/grafana/pkg/generated/clientset/versioned/scheme" + rest "k8s.io/client-go/rest" +) + +type ServiceV0alpha1Interface interface { + RESTClient() rest.Interface + ExternalNamesGetter +} + +// ServiceV0alpha1Client is used to interact with features provided by the service.grafana.app group. +type ServiceV0alpha1Client struct { + restClient rest.Interface +} + +func (c *ServiceV0alpha1Client) ExternalNames(namespace string) ExternalNameInterface { + return newExternalNames(c, namespace) +} + +// NewForConfig creates a new ServiceV0alpha1Client for the given config. +// NewForConfig is equivalent to NewForConfigAndClient(c, httpClient), +// where httpClient was generated with rest.HTTPClientFor(c). +func NewForConfig(c *rest.Config) (*ServiceV0alpha1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + httpClient, err := rest.HTTPClientFor(&config) + if err != nil { + return nil, err + } + return NewForConfigAndClient(&config, httpClient) +} + +// NewForConfigAndClient creates a new ServiceV0alpha1Client for the given config and http client. +// Note the http client provided takes precedence over the configured transport values. +func NewForConfigAndClient(c *rest.Config, h *http.Client) (*ServiceV0alpha1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientForConfigAndClient(&config, h) + if err != nil { + return nil, err + } + return &ServiceV0alpha1Client{client}, nil +} + +// NewForConfigOrDie creates a new ServiceV0alpha1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *ServiceV0alpha1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new ServiceV0alpha1Client for the given RESTClient. +func New(c rest.Interface) *ServiceV0alpha1Client { + return &ServiceV0alpha1Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v0alpha1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = scheme.Codecs.WithoutConversion() + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *ServiceV0alpha1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/pkg/generated/informers/externalversions/factory.go b/pkg/generated/informers/externalversions/factory.go new file mode 100644 index 0000000000000..faca5c61edb49 --- /dev/null +++ b/pkg/generated/informers/externalversions/factory.go @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + reflect "reflect" + sync "sync" + time "time" + + versioned "github.com/grafana/grafana/pkg/generated/clientset/versioned" + internalinterfaces "github.com/grafana/grafana/pkg/generated/informers/externalversions/internalinterfaces" + service "github.com/grafana/grafana/pkg/generated/informers/externalversions/service" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// SharedInformerOption defines the functional option type for SharedInformerFactory. +type SharedInformerOption func(*sharedInformerFactory) *sharedInformerFactory + +type sharedInformerFactory struct { + client versioned.Interface + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc + lock sync.Mutex + defaultResync time.Duration + customResync map[reflect.Type]time.Duration + transform cache.TransformFunc + + informers map[reflect.Type]cache.SharedIndexInformer + // startedInformers is used for tracking which informers have been started. + // This allows Start() to be called multiple times safely. + startedInformers map[reflect.Type]bool + // wg tracks how many goroutines were started. + wg sync.WaitGroup + // shuttingDown is true when Shutdown has been called. It may still be running + // because it needs to wait for goroutines. + shuttingDown bool +} + +// WithCustomResyncConfig sets a custom resync period for the specified informer types. +func WithCustomResyncConfig(resyncConfig map[v1.Object]time.Duration) SharedInformerOption { + return func(factory *sharedInformerFactory) *sharedInformerFactory { + for k, v := range resyncConfig { + factory.customResync[reflect.TypeOf(k)] = v + } + return factory + } +} + +// WithTweakListOptions sets a custom filter on all listers of the configured SharedInformerFactory. +func WithTweakListOptions(tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerOption { + return func(factory *sharedInformerFactory) *sharedInformerFactory { + factory.tweakListOptions = tweakListOptions + return factory + } +} + +// WithNamespace limits the SharedInformerFactory to the specified namespace. +func WithNamespace(namespace string) SharedInformerOption { + return func(factory *sharedInformerFactory) *sharedInformerFactory { + factory.namespace = namespace + return factory + } +} + +// WithTransform sets a transform on all informers. +func WithTransform(transform cache.TransformFunc) SharedInformerOption { + return func(factory *sharedInformerFactory) *sharedInformerFactory { + factory.transform = transform + return factory + } +} + +// NewSharedInformerFactory constructs a new instance of sharedInformerFactory for all namespaces. +func NewSharedInformerFactory(client versioned.Interface, defaultResync time.Duration) SharedInformerFactory { + return NewSharedInformerFactoryWithOptions(client, defaultResync) +} + +// NewFilteredSharedInformerFactory constructs a new instance of sharedInformerFactory. +// Listers obtained via this SharedInformerFactory will be subject to the same filters +// as specified here. +// Deprecated: Please use NewSharedInformerFactoryWithOptions instead +func NewFilteredSharedInformerFactory(client versioned.Interface, defaultResync time.Duration, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerFactory { + return NewSharedInformerFactoryWithOptions(client, defaultResync, WithNamespace(namespace), WithTweakListOptions(tweakListOptions)) +} + +// NewSharedInformerFactoryWithOptions constructs a new instance of a SharedInformerFactory with additional options. +func NewSharedInformerFactoryWithOptions(client versioned.Interface, defaultResync time.Duration, options ...SharedInformerOption) SharedInformerFactory { + factory := &sharedInformerFactory{ + client: client, + namespace: v1.NamespaceAll, + defaultResync: defaultResync, + informers: make(map[reflect.Type]cache.SharedIndexInformer), + startedInformers: make(map[reflect.Type]bool), + customResync: make(map[reflect.Type]time.Duration), + } + + // Apply all options + for _, opt := range options { + factory = opt(factory) + } + + return factory +} + +func (f *sharedInformerFactory) Start(stopCh <-chan struct{}) { + f.lock.Lock() + defer f.lock.Unlock() + + if f.shuttingDown { + return + } + + for informerType, informer := range f.informers { + if !f.startedInformers[informerType] { + f.wg.Add(1) + // We need a new variable in each loop iteration, + // otherwise the goroutine would use the loop variable + // and that keeps changing. + informer := informer + go func() { + defer f.wg.Done() + informer.Run(stopCh) + }() + f.startedInformers[informerType] = true + } + } +} + +func (f *sharedInformerFactory) Shutdown() { + f.lock.Lock() + f.shuttingDown = true + f.lock.Unlock() + + // Will return immediately if there is nothing to wait for. + f.wg.Wait() +} + +func (f *sharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool { + informers := func() map[reflect.Type]cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informers := map[reflect.Type]cache.SharedIndexInformer{} + for informerType, informer := range f.informers { + if f.startedInformers[informerType] { + informers[informerType] = informer + } + } + return informers + }() + + res := map[reflect.Type]bool{} + for informType, informer := range informers { + res[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced) + } + return res +} + +// InformerFor returns the SharedIndexInformer for obj using an internal +// client. +func (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informerType := reflect.TypeOf(obj) + informer, exists := f.informers[informerType] + if exists { + return informer + } + + resyncPeriod, exists := f.customResync[informerType] + if !exists { + resyncPeriod = f.defaultResync + } + + informer = newFunc(f.client, resyncPeriod) + informer.SetTransform(f.transform) + f.informers[informerType] = informer + + return informer +} + +// SharedInformerFactory provides shared informers for resources in all known +// API group versions. +// +// It is typically used like this: +// +// ctx, cancel := context.Background() +// defer cancel() +// factory := NewSharedInformerFactory(client, resyncPeriod) +// defer factory.WaitForStop() // Returns immediately if nothing was started. +// genericInformer := factory.ForResource(resource) +// typedInformer := factory.SomeAPIGroup().V1().SomeType() +// factory.Start(ctx.Done()) // Start processing these informers. +// synced := factory.WaitForCacheSync(ctx.Done()) +// for v, ok := range synced { +// if !ok { +// fmt.Fprintf(os.Stderr, "caches failed to sync: %v", v) +// return +// } +// } +// +// // Creating informers can also be created after Start, but then +// // Start must be called again: +// anotherGenericInformer := factory.ForResource(resource) +// factory.Start(ctx.Done()) +type SharedInformerFactory interface { + internalinterfaces.SharedInformerFactory + + // Start initializes all requested informers. They are handled in goroutines + // which run until the stop channel gets closed. + Start(stopCh <-chan struct{}) + + // Shutdown marks a factory as shutting down. At that point no new + // informers can be started anymore and Start will return without + // doing anything. + // + // In addition, Shutdown blocks until all goroutines have terminated. For that + // to happen, the close channel(s) that they were started with must be closed, + // either before Shutdown gets called or while it is waiting. + // + // Shutdown may be called multiple times, even concurrently. All such calls will + // block until all goroutines have terminated. + Shutdown() + + // WaitForCacheSync blocks until all started informers' caches were synced + // or the stop channel gets closed. + WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool + + // ForResource gives generic access to a shared informer of the matching type. + ForResource(resource schema.GroupVersionResource) (GenericInformer, error) + + // InformerFor returns the SharedIndexInformer for obj using an internal + // client. + InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer + + Service() service.Interface +} + +func (f *sharedInformerFactory) Service() service.Interface { + return service.New(f, f.namespace, f.tweakListOptions) +} diff --git a/pkg/generated/informers/externalversions/generic.go b/pkg/generated/informers/externalversions/generic.go new file mode 100644 index 0000000000000..88cc30aa0f99a --- /dev/null +++ b/pkg/generated/informers/externalversions/generic.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + "fmt" + + v0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// GenericInformer is type of SharedIndexInformer which will locate and delegate to other +// sharedInformers based on type +type GenericInformer interface { + Informer() cache.SharedIndexInformer + Lister() cache.GenericLister +} + +type genericInformer struct { + informer cache.SharedIndexInformer + resource schema.GroupResource +} + +// Informer returns the SharedIndexInformer. +func (f *genericInformer) Informer() cache.SharedIndexInformer { + return f.informer +} + +// Lister returns the GenericLister. +func (f *genericInformer) Lister() cache.GenericLister { + return cache.NewGenericLister(f.Informer().GetIndexer(), f.resource) +} + +// ForResource gives generic access to a shared informer of the matching type +// TODO extend this to unknown resources with a client pool +func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { + switch resource { + // Group=service.grafana.app, Version=v0alpha1 + case v0alpha1.SchemeGroupVersion.WithResource("externalnames"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Service().V0alpha1().ExternalNames().Informer()}, nil + + } + + return nil, fmt.Errorf("no informer found for %v", resource) +} diff --git a/pkg/generated/informers/externalversions/internalinterfaces/factory_interfaces.go b/pkg/generated/informers/externalversions/internalinterfaces/factory_interfaces.go new file mode 100644 index 0000000000000..1d8997bbea3d7 --- /dev/null +++ b/pkg/generated/informers/externalversions/internalinterfaces/factory_interfaces.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by informer-gen. DO NOT EDIT. + +package internalinterfaces + +import ( + time "time" + + versioned "github.com/grafana/grafana/pkg/generated/clientset/versioned" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + cache "k8s.io/client-go/tools/cache" +) + +// NewInformerFunc takes versioned.Interface and time.Duration to return a SharedIndexInformer. +type NewInformerFunc func(versioned.Interface, time.Duration) cache.SharedIndexInformer + +// SharedInformerFactory a small interface to allow for adding an informer without an import cycle +type SharedInformerFactory interface { + Start(stopCh <-chan struct{}) + InformerFor(obj runtime.Object, newFunc NewInformerFunc) cache.SharedIndexInformer +} + +// TweakListOptionsFunc is a function that transforms a v1.ListOptions. +type TweakListOptionsFunc func(*v1.ListOptions) diff --git a/pkg/generated/informers/externalversions/service/interface.go b/pkg/generated/informers/externalversions/service/interface.go new file mode 100644 index 0000000000000..95b68423c3217 --- /dev/null +++ b/pkg/generated/informers/externalversions/service/interface.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by informer-gen. DO NOT EDIT. + +package service + +import ( + internalinterfaces "github.com/grafana/grafana/pkg/generated/informers/externalversions/internalinterfaces" + v0alpha1 "github.com/grafana/grafana/pkg/generated/informers/externalversions/service/v0alpha1" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V0alpha1 provides access to shared informers for resources in V0alpha1. + V0alpha1() v0alpha1.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V0alpha1 returns a new v0alpha1.Interface. +func (g *group) V0alpha1() v0alpha1.Interface { + return v0alpha1.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/pkg/generated/informers/externalversions/service/v0alpha1/externalname.go b/pkg/generated/informers/externalversions/service/v0alpha1/externalname.go new file mode 100644 index 0000000000000..62a5a89d1cea8 --- /dev/null +++ b/pkg/generated/informers/externalversions/service/v0alpha1/externalname.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by informer-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + "context" + time "time" + + servicev0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + versioned "github.com/grafana/grafana/pkg/generated/clientset/versioned" + internalinterfaces "github.com/grafana/grafana/pkg/generated/informers/externalversions/internalinterfaces" + v0alpha1 "github.com/grafana/grafana/pkg/generated/listers/service/v0alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// ExternalNameInformer provides access to a shared informer and lister for +// ExternalNames. +type ExternalNameInformer interface { + Informer() cache.SharedIndexInformer + Lister() v0alpha1.ExternalNameLister +} + +type externalNameInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewExternalNameInformer constructs a new informer for ExternalName type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewExternalNameInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredExternalNameInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredExternalNameInformer constructs a new informer for ExternalName type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredExternalNameInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ServiceV0alpha1().ExternalNames(namespace).List(context.TODO(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ServiceV0alpha1().ExternalNames(namespace).Watch(context.TODO(), options) + }, + }, + &servicev0alpha1.ExternalName{}, + resyncPeriod, + indexers, + ) +} + +func (f *externalNameInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredExternalNameInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *externalNameInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&servicev0alpha1.ExternalName{}, f.defaultInformer) +} + +func (f *externalNameInformer) Lister() v0alpha1.ExternalNameLister { + return v0alpha1.NewExternalNameLister(f.Informer().GetIndexer()) +} diff --git a/pkg/generated/informers/externalversions/service/v0alpha1/interface.go b/pkg/generated/informers/externalversions/service/v0alpha1/interface.go new file mode 100644 index 0000000000000..c073cfb9eb9f4 --- /dev/null +++ b/pkg/generated/informers/externalversions/service/v0alpha1/interface.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by informer-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + internalinterfaces "github.com/grafana/grafana/pkg/generated/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // ExternalNames returns a ExternalNameInformer. + ExternalNames() ExternalNameInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// ExternalNames returns a ExternalNameInformer. +func (v *version) ExternalNames() ExternalNameInformer { + return &externalNameInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} diff --git a/pkg/generated/listers/service/v0alpha1/expansion_generated.go b/pkg/generated/listers/service/v0alpha1/expansion_generated.go new file mode 100644 index 0000000000000..78f522df55960 --- /dev/null +++ b/pkg/generated/listers/service/v0alpha1/expansion_generated.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by lister-gen. DO NOT EDIT. + +package v0alpha1 + +// ExternalNameListerExpansion allows custom methods to be added to +// ExternalNameLister. +type ExternalNameListerExpansion interface{} + +// ExternalNameNamespaceListerExpansion allows custom methods to be added to +// ExternalNameNamespaceLister. +type ExternalNameNamespaceListerExpansion interface{} diff --git a/pkg/generated/listers/service/v0alpha1/externalname.go b/pkg/generated/listers/service/v0alpha1/externalname.go new file mode 100644 index 0000000000000..89cc8e425f441 --- /dev/null +++ b/pkg/generated/listers/service/v0alpha1/externalname.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by lister-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + v0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// ExternalNameLister helps list ExternalNames. +// All objects returned here must be treated as read-only. +type ExternalNameLister interface { + // List lists all ExternalNames in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v0alpha1.ExternalName, err error) + // ExternalNames returns an object that can list and get ExternalNames. + ExternalNames(namespace string) ExternalNameNamespaceLister + ExternalNameListerExpansion +} + +// externalNameLister implements the ExternalNameLister interface. +type externalNameLister struct { + indexer cache.Indexer +} + +// NewExternalNameLister returns a new ExternalNameLister. +func NewExternalNameLister(indexer cache.Indexer) ExternalNameLister { + return &externalNameLister{indexer: indexer} +} + +// List lists all ExternalNames in the indexer. +func (s *externalNameLister) List(selector labels.Selector) (ret []*v0alpha1.ExternalName, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v0alpha1.ExternalName)) + }) + return ret, err +} + +// ExternalNames returns an object that can list and get ExternalNames. +func (s *externalNameLister) ExternalNames(namespace string) ExternalNameNamespaceLister { + return externalNameNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// ExternalNameNamespaceLister helps list and get ExternalNames. +// All objects returned here must be treated as read-only. +type ExternalNameNamespaceLister interface { + // List lists all ExternalNames in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v0alpha1.ExternalName, err error) + // Get retrieves the ExternalName from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*v0alpha1.ExternalName, error) + ExternalNameNamespaceListerExpansion +} + +// externalNameNamespaceLister implements the ExternalNameNamespaceLister +// interface. +type externalNameNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all ExternalNames in the indexer for a given namespace. +func (s externalNameNamespaceLister) List(selector labels.Selector) (ret []*v0alpha1.ExternalName, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v0alpha1.ExternalName)) + }) + return ret, err +} + +// Get retrieves the ExternalName from the indexer for a given namespace and name. +func (s externalNameNamespaceLister) Get(name string) (*v0alpha1.ExternalName, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v0alpha1.Resource("externalname"), name) + } + return obj.(*v0alpha1.ExternalName), nil +} diff --git a/pkg/infra/appcontext/user.go b/pkg/infra/appcontext/user.go index a02edb0a1b0b8..d68071006de01 100644 --- a/pkg/infra/appcontext/user.go +++ b/pkg/infra/appcontext/user.go @@ -61,9 +61,15 @@ func User(ctx context.Context) (*user.SignedInUser, error) { IsGrafanaAdmin: true, Permissions: map[int64]map[string][]string{ orgId: { - "*": {"*"}, - dashboards.ActionFoldersCreate: {"*"}, // all resources, all scopes - dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}, // access to read all folders + "*": {"*"}, // all resources, all scopes + + // Dashboards do not support wildcard action + dashboards.ActionDashboardsRead: {"*"}, + dashboards.ActionDashboardsCreate: {"*"}, + dashboards.ActionDashboardsWrite: {"*"}, + dashboards.ActionDashboardsDelete: {"*"}, + dashboards.ActionFoldersCreate: {"*"}, + dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}, // access to read all folders }, }, }, nil diff --git a/pkg/infra/db/db.go b/pkg/infra/db/db.go index 6cf32ae6606bb..08cea4a761fb9 100644 --- a/pkg/infra/db/db.go +++ b/pkg/infra/db/db.go @@ -10,6 +10,8 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/session" + "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" + "github.com/grafana/grafana/pkg/setting" ) type DB interface { @@ -51,10 +53,16 @@ type DB interface { type Session = sqlstore.DBSession type InitTestDBOpt = sqlstore.InitTestDBOpt +var SetupTestDB = sqlstore.SetupTestDB var InitTestDB = sqlstore.InitTestDB -var InitTestDBwithCfg = sqlstore.InitTestDBWithCfg +var CleanupTestDB = sqlstore.CleanupTestDB var ProvideService = sqlstore.ProvideService +func InitTestDBwithCfg(t sqlutil.ITestDB, opts ...InitTestDBOpt) (*sqlstore.SQLStore, *setting.Cfg) { + store := InitTestDB(t, opts...) + return store, store.Cfg +} + func IsTestDbSQLite() bool { if db, present := os.LookupEnv("GRAFANA_TEST_DB"); !present || db == "sqlite" { return true diff --git a/pkg/infra/filestorage/fs_integration_test.go b/pkg/infra/filestorage/fs_integration_test.go index 9336636ab237f..13d74b5de35df 100644 --- a/pkg/infra/filestorage/fs_integration_test.go +++ b/pkg/infra/filestorage/fs_integration_test.go @@ -12,12 +12,17 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/tests/testsuite" ) const ( pngImageBase64 = "iVBORw0KGgoNAANSUhEUgAAAC4AAAAmCAYAAAC76qlaAAAABHNCSVQICAgIfAhkiAAAABl0RVh0U29mdHdhcmUAZ25vbWUtc2NyZWVuc2hvdO8Dvz4AAABFSURBVFiF7c5BDQAhEACx4/x7XjzwGELSKuiamfke9N8OnBKvidfEa+I18Zp4TbwmXhOvidfEa+I18Zp4TbwmXhOvidc2lcsESD1LGnUAAAAASUVORK5CYII=" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type fsTestCase struct { name string skip *bool diff --git a/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware.go b/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware.go index 3f8c95ac95605..012755732d695 100644 --- a/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware.go +++ b/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware.go @@ -2,6 +2,7 @@ package httpclientprovider import ( "net/http" + "strconv" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana/pkg/infra/httpclient" @@ -18,7 +19,7 @@ var ( Name: "datasource_request_total", Help: "A counter for outgoing requests for a data source", }, - []string{"datasource", "datasource_type", "code", "method"}, + []string{"datasource", "datasource_type", "code", "method", "secure_socks_ds_proxy_enabled"}, ) datasourceRequestHistogram = promauto.NewHistogramVec( @@ -27,7 +28,7 @@ var ( Name: "datasource_request_duration_seconds", Help: "histogram of durations of outgoing data source requests sent from Grafana", Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 25, 50, 100}, - }, []string{"datasource", "datasource_type", "code", "method"}, + }, []string{"datasource", "datasource_type", "code", "method", "secure_socks_ds_proxy_enabled"}, ) datasourceResponseHistogram = promauto.NewHistogramVec( @@ -36,7 +37,7 @@ var ( Name: "datasource_response_size_bytes", Help: "histogram of data source response sizes returned to Grafana", Buckets: []float64{128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072, 262144, 524288, 1048576}, - }, []string{"datasource", "datasource_type"}, + }, []string{"datasource", "datasource_type", "secure_socks_ds_proxy_enabled"}, ) datasourceRequestsInFlight = promauto.NewGaugeVec( @@ -45,7 +46,7 @@ var ( Name: "datasource_request_in_flight", Help: "A gauge of outgoing data source requests currently being sent by Grafana", }, - []string{"datasource", "datasource_type"}, + []string{"datasource", "datasource_type", "secure_socks_ds_proxy_enabled"}, ) ) @@ -82,7 +83,11 @@ func DataSourceMetricsMiddleware() sdkhttpclient.Middleware { return next } - labels := prometheus.Labels{"datasource": datasourceLabelName, "datasource_type": datasourceLabelType} + labels := prometheus.Labels{ + "datasource": datasourceLabelName, + "datasource_type": datasourceLabelType, + "secure_socks_ds_proxy_enabled": strconv.FormatBool(opts.ProxyOptions != nil && opts.ProxyOptions.Enabled), + } return executeMiddlewareFunc(next, labels) }) diff --git a/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware_test.go b/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware_test.go index 37afde4fd964d..28874be941f63 100644 --- a/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware_test.go +++ b/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" ) @@ -103,29 +104,56 @@ func TestDataSourceMetricsMiddleware(t *testing.T) { executeMiddlewareFunc = origExecuteMiddlewareFunc }) - ctx := &testContext{} - finalRoundTripper := ctx.createRoundTripper("finalrt") - mw := DataSourceMetricsMiddleware() - rt := mw.CreateMiddleware(httpclient.Options{Labels: map[string]string{"datasource_name": "My Data Source 123", "datasource_type": "prometheus"}}, finalRoundTripper) - require.NotNil(t, rt) - middlewareName, ok := mw.(httpclient.MiddlewareName) - require.True(t, ok) - require.Equal(t, DataSourceMetricsMiddlewareName, middlewareName.MiddlewareName()) + testCases := []struct { + description string + httpClientOptions httpclient.Options + expectedSecureSocksDSProxyEnabled string + }{ + { + description: "secure socks ds proxy is disabled", + httpClientOptions: httpclient.Options{ + Labels: map[string]string{"datasource_name": "My Data Source 123", "datasource_type": "prometheus"}, + }, + expectedSecureSocksDSProxyEnabled: "false", + }, + { + description: "secure socks ds proxy is enabled", + httpClientOptions: httpclient.Options{ + Labels: map[string]string{"datasource_name": "My Data Source 123", "datasource_type": "prometheus"}, + ProxyOptions: &proxy.Options{Enabled: true}, + }, + expectedSecureSocksDSProxyEnabled: "true", + }, + } - req, err := http.NewRequest(http.MethodGet, "http://", nil) - require.NoError(t, err) - res, err := rt.RoundTrip(req) - require.NoError(t, err) - require.NotNil(t, res) - if res.Body != nil { - require.NoError(t, res.Body.Close()) + for _, tt := range testCases { + t.Run(tt.description, func(t *testing.T) { + ctx := &testContext{} + finalRoundTripper := ctx.createRoundTripper("finalrt") + mw := DataSourceMetricsMiddleware() + rt := mw.CreateMiddleware(tt.httpClientOptions, finalRoundTripper) + require.NotNil(t, rt) + middlewareName, ok := mw.(httpclient.MiddlewareName) + require.True(t, ok) + require.Equal(t, DataSourceMetricsMiddlewareName, middlewareName.MiddlewareName()) + + req, err := http.NewRequest(http.MethodGet, "http://", nil) + require.NoError(t, err) + res, err := rt.RoundTrip(req) + require.NoError(t, err) + require.NotNil(t, res) + if res.Body != nil { + require.NoError(t, res.Body.Close()) + } + require.Len(t, ctx.callChain, 1) + require.ElementsMatch(t, []string{"finalrt"}, ctx.callChain) + require.True(t, executeMiddlewareCalled) + require.Len(t, labels, 3) + require.Equal(t, "My_Data_Source_123", labels["datasource"]) + require.Equal(t, "prometheus", labels["datasource_type"]) + require.Equal(t, tt.expectedSecureSocksDSProxyEnabled, labels["secure_socks_ds_proxy_enabled"]) + require.True(t, middlewareCalled) + }) } - require.Len(t, ctx.callChain, 1) - require.ElementsMatch(t, []string{"finalrt"}, ctx.callChain) - require.True(t, executeMiddlewareCalled) - require.Len(t, labels, 2) - require.Equal(t, "My_Data_Source_123", labels["datasource"]) - require.Equal(t, "prometheus", labels["datasource_type"]) - require.True(t, middlewareCalled) }) } diff --git a/pkg/infra/httpclient/httpclientprovider/grafana_request_id_header_middleware.go b/pkg/infra/httpclient/httpclientprovider/grafana_request_id_header_middleware.go new file mode 100644 index 0000000000000..0075578ae8a52 --- /dev/null +++ b/pkg/infra/httpclient/httpclientprovider/grafana_request_id_header_middleware.go @@ -0,0 +1,34 @@ +package httpclientprovider + +import ( + "net/http" + + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/pluginsintegration/clientmiddleware" + "github.com/grafana/grafana/pkg/setting" +) + +const GrafanaRequestIDHeaderMiddlewareName = "grafana-request-id-header-middleware" + +func GrafanaRequestIDHeaderMiddleware(cfg *setting.Cfg, logger log.Logger) sdkhttpclient.Middleware { + return sdkhttpclient.NamedMiddlewareFunc(GrafanaRequestIDHeaderMiddlewareName, func(opts sdkhttpclient.Options, next http.RoundTripper) http.RoundTripper { + return sdkhttpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + if req.Header.Get(clientmiddleware.GrafanaRequestID) != "" { + logger.Debug("Request already has a Grafana request ID header", "request_id", req.Header.Get(clientmiddleware.GrafanaRequestID)) + return next.RoundTrip(req) + } + + if !clientmiddleware.IsRequestURLInAllowList(req.URL, cfg) { + logger.Debug("Data source URL not among the allow-listed URLs", "url", req.URL.String()) + return next.RoundTrip(req) + } + + for k, v := range clientmiddleware.GetGrafanaRequestIDHeaders(req, cfg, logger) { + req.Header.Set(k, v) + } + + return next.RoundTrip(req) + }) + }) +} diff --git a/pkg/infra/httpclient/httpclientprovider/grafana_request_id_header_middleware_test.go b/pkg/infra/httpclient/httpclientprovider/grafana_request_id_header_middleware_test.go new file mode 100644 index 0000000000000..92c995915b457 --- /dev/null +++ b/pkg/infra/httpclient/httpclientprovider/grafana_request_id_header_middleware_test.go @@ -0,0 +1,113 @@ +package httpclientprovider + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/url" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/pluginsintegration/clientmiddleware" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" +) + +func TestGrafanaRequestIDHeaderMiddleware(t *testing.T) { + testCases := []struct { + description string + allowedURLs []*url.URL + requestURL string + remoteAddress string + expectGrafanaRequestIDHeaders bool + expectPrivateRequestHeader bool + }{ + { + description: "With target URL in the allowed URL list and remote address specified, should add headers to the request but the request should not be marked as private", + allowedURLs: []*url.URL{{ + Scheme: "https", + Host: "grafana.com", + }}, + requestURL: "https://grafana.com/api/some/path", + remoteAddress: "1.2.3.4", + expectGrafanaRequestIDHeaders: true, + expectPrivateRequestHeader: false, + }, + { + description: "With target URL in the allowed URL list and remote address not specified, should add headers to the request and the request should be marked as private", + allowedURLs: []*url.URL{{ + Scheme: "https", + Host: "grafana.com", + }}, + requestURL: "https://grafana.com/api/some/path", + expectGrafanaRequestIDHeaders: true, + expectPrivateRequestHeader: true, + }, + { + description: "With target URL not in the allowed URL list, should not add headers to the request", + allowedURLs: []*url.URL{{ + Scheme: "https", + Host: "grafana.com", + }}, + requestURL: "https://fake-grafana.com/api/some/path", + expectGrafanaRequestIDHeaders: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + ctx := &testContext{} + finalRoundTripper := ctx.createRoundTripper("final") + cfg := setting.NewCfg() + cfg.IPRangeACEnabled = false + cfg.IPRangeACAllowedURLs = tc.allowedURLs + cfg.IPRangeACSecretKey = "secret" + mw := GrafanaRequestIDHeaderMiddleware(cfg, log.New("test")) + rt := mw.CreateMiddleware(httpclient.Options{}, finalRoundTripper) + require.NotNil(t, rt) + middlewareName, ok := mw.(httpclient.MiddlewareName) + require.True(t, ok) + require.Equal(t, GrafanaRequestIDHeaderMiddlewareName, middlewareName.MiddlewareName()) + + req, err := http.NewRequest(http.MethodGet, tc.requestURL, nil) + require.NoError(t, err) + req.RemoteAddr = tc.remoteAddress + res, err := rt.RoundTrip(req) + require.NoError(t, err) + require.NotNil(t, res) + if res.Body != nil { + require.NoError(t, res.Body.Close()) + } + require.Len(t, ctx.callChain, 1) + require.ElementsMatch(t, []string{"final"}, ctx.callChain) + + if !tc.expectGrafanaRequestIDHeaders { + require.Len(t, req.Header.Values(clientmiddleware.GrafanaRequestID), 0) + require.Len(t, req.Header.Values(clientmiddleware.GrafanaSignedRequestID), 0) + } else { + require.Len(t, req.Header.Values(clientmiddleware.GrafanaRequestID), 1) + require.Len(t, req.Header.Values(clientmiddleware.GrafanaSignedRequestID), 1) + requestID := req.Header.Get(clientmiddleware.GrafanaRequestID) + + instance := hmac.New(sha256.New, []byte(cfg.IPRangeACSecretKey)) + _, err = instance.Write([]byte(requestID)) + require.NoError(t, err) + computed := hex.EncodeToString(instance.Sum(nil)) + + require.Equal(t, req.Header.Get(clientmiddleware.GrafanaSignedRequestID), computed) + + if tc.remoteAddress == "" { + require.Equal(t, req.Header.Get(clientmiddleware.GrafanaInternalRequest), "true") + } else { + require.Len(t, req.Header.Values(clientmiddleware.XRealIPHeader), 1) + require.Equal(t, req.Header.Get(clientmiddleware.XRealIPHeader), tc.remoteAddress) + + // Internal header should not be set + require.Len(t, req.Header.Values(clientmiddleware.GrafanaInternalRequest), 0) + } + } + }) + } +} diff --git a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go index 16c7b197049ba..5447999a1fd29 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go +++ b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go @@ -4,6 +4,7 @@ import ( "net/http" "time" + awssdk "github.com/grafana/grafana-aws-sdk/pkg/sigv4" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/mwitkow/go-conntrack" @@ -27,18 +28,22 @@ func New(cfg *setting.Cfg, validator validations.PluginRequestValidator, tracer SetUserAgentMiddleware(cfg.DataProxyUserAgent), sdkhttpclient.BasicAuthenticationMiddleware(), sdkhttpclient.CustomHeadersMiddleware(), - ResponseLimitMiddleware(cfg.ResponseLimit), + sdkhttpclient.ResponseLimitMiddleware(cfg.ResponseLimit), RedirectLimitMiddleware(validator), } if cfg.SigV4AuthEnabled { - middlewares = append(middlewares, SigV4Middleware(cfg.SigV4VerboseLogging)) + middlewares = append(middlewares, awssdk.SigV4Middleware(cfg.SigV4VerboseLogging)) } if httpLoggingEnabled(cfg.PluginSettings) { middlewares = append(middlewares, HTTPLoggerMiddleware(cfg.PluginSettings)) } + if cfg.IPRangeACEnabled { + middlewares = append(middlewares, GrafanaRequestIDHeaderMiddleware(cfg, logger)) + } + setDefaultTimeoutOptions(cfg) return newProviderFunc(sdkhttpclient.ProviderOptions{ diff --git a/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go b/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go index 208c51fff21e3..652372a0de410 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go +++ b/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/services/validations" + awssdk "github.com/grafana/grafana-aws-sdk/pkg/sigv4" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/setting" @@ -33,7 +34,7 @@ func TestHTTPClientProvider(t *testing.T) { require.Equal(t, SetUserAgentMiddlewareName, o.Middlewares[3].(sdkhttpclient.MiddlewareName).MiddlewareName()) require.Equal(t, sdkhttpclient.BasicAuthenticationMiddlewareName, o.Middlewares[4].(sdkhttpclient.MiddlewareName).MiddlewareName()) require.Equal(t, sdkhttpclient.CustomHeadersMiddlewareName, o.Middlewares[5].(sdkhttpclient.MiddlewareName).MiddlewareName()) - require.Equal(t, ResponseLimitMiddlewareName, o.Middlewares[6].(sdkhttpclient.MiddlewareName).MiddlewareName()) + require.Equal(t, sdkhttpclient.ResponseLimitMiddlewareName, o.Middlewares[6].(sdkhttpclient.MiddlewareName).MiddlewareName()) }) t.Run("When creating new provider and SigV4 is enabled should apply expected middleware", func(t *testing.T) { @@ -57,8 +58,8 @@ func TestHTTPClientProvider(t *testing.T) { require.Equal(t, SetUserAgentMiddlewareName, o.Middlewares[3].(sdkhttpclient.MiddlewareName).MiddlewareName()) require.Equal(t, sdkhttpclient.BasicAuthenticationMiddlewareName, o.Middlewares[4].(sdkhttpclient.MiddlewareName).MiddlewareName()) require.Equal(t, sdkhttpclient.CustomHeadersMiddlewareName, o.Middlewares[5].(sdkhttpclient.MiddlewareName).MiddlewareName()) - require.Equal(t, ResponseLimitMiddlewareName, o.Middlewares[6].(sdkhttpclient.MiddlewareName).MiddlewareName()) - require.Equal(t, SigV4MiddlewareName, o.Middlewares[8].(sdkhttpclient.MiddlewareName).MiddlewareName()) + require.Equal(t, sdkhttpclient.ResponseLimitMiddlewareName, o.Middlewares[6].(sdkhttpclient.MiddlewareName).MiddlewareName()) + require.Equal(t, awssdk.SigV4MiddlewareName, o.Middlewares[8].(sdkhttpclient.MiddlewareName).MiddlewareName()) }) t.Run("When creating new provider and http logging is enabled for one plugin, it should apply expected middleware", func(t *testing.T) { @@ -82,7 +83,7 @@ func TestHTTPClientProvider(t *testing.T) { require.Equal(t, SetUserAgentMiddlewareName, o.Middlewares[3].(sdkhttpclient.MiddlewareName).MiddlewareName()) require.Equal(t, sdkhttpclient.BasicAuthenticationMiddlewareName, o.Middlewares[4].(sdkhttpclient.MiddlewareName).MiddlewareName()) require.Equal(t, sdkhttpclient.CustomHeadersMiddlewareName, o.Middlewares[5].(sdkhttpclient.MiddlewareName).MiddlewareName()) - require.Equal(t, ResponseLimitMiddlewareName, o.Middlewares[6].(sdkhttpclient.MiddlewareName).MiddlewareName()) + require.Equal(t, sdkhttpclient.ResponseLimitMiddlewareName, o.Middlewares[6].(sdkhttpclient.MiddlewareName).MiddlewareName()) require.Equal(t, HostRedirectValidationMiddlewareName, o.Middlewares[7].(sdkhttpclient.MiddlewareName).MiddlewareName()) require.Equal(t, HTTPLoggerMiddlewareName, o.Middlewares[8].(sdkhttpclient.MiddlewareName).MiddlewareName()) }) diff --git a/pkg/infra/httpclient/httpclientprovider/response_limit_middleware.go b/pkg/infra/httpclient/httpclientprovider/response_limit_middleware.go deleted file mode 100644 index 97d8285d4cbe1..0000000000000 --- a/pkg/infra/httpclient/httpclientprovider/response_limit_middleware.go +++ /dev/null @@ -1,31 +0,0 @@ -package httpclientprovider - -import ( - "net/http" - - sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana/pkg/infra/httpclient" -) - -// ResponseLimitMiddlewareName is the middleware name used by ResponseLimitMiddleware. -const ResponseLimitMiddlewareName = "response-limit" - -func ResponseLimitMiddleware(limit int64) sdkhttpclient.Middleware { - return sdkhttpclient.NamedMiddlewareFunc(ResponseLimitMiddlewareName, func(opts sdkhttpclient.Options, next http.RoundTripper) http.RoundTripper { - if limit <= 0 { - return next - } - return sdkhttpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { - res, err := next.RoundTrip(req) - if err != nil { - return nil, err - } - - if res != nil && res.StatusCode != http.StatusSwitchingProtocols { - res.Body = httpclient.MaxBytesReader(res.Body, limit) - } - - return res, nil - }) - }) -} diff --git a/pkg/infra/httpclient/httpclientprovider/response_limit_middleware_test.go b/pkg/infra/httpclient/httpclientprovider/response_limit_middleware_test.go deleted file mode 100644 index 88a0d572b86c7..0000000000000 --- a/pkg/infra/httpclient/httpclientprovider/response_limit_middleware_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package httpclientprovider - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "strings" - "testing" - - "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/stretchr/testify/require" -) - -func TestResponseLimitMiddleware(t *testing.T) { - tcs := []struct { - limit int64 - bodyLength int - body string - err error - }{ - {limit: 1, bodyLength: 1, body: "d", err: errors.New("error: http: response body too large, response limit is set to: 1")}, - {limit: 1000000, bodyLength: 5, body: "dummy", err: nil}, - {limit: 0, bodyLength: 5, body: "dummy", err: nil}, - } - for _, tc := range tcs { - t.Run(fmt.Sprintf("Test ResponseLimitMiddleware with limit: %d", tc.limit), func(t *testing.T) { - finalRoundTripper := httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { - return &http.Response{StatusCode: http.StatusOK, Request: req, Body: io.NopCloser(strings.NewReader("dummy"))}, nil - }) - - mw := ResponseLimitMiddleware(tc.limit) - rt := mw.CreateMiddleware(httpclient.Options{}, finalRoundTripper) - require.NotNil(t, rt) - middlewareName, ok := mw.(httpclient.MiddlewareName) - require.True(t, ok) - require.Equal(t, ResponseLimitMiddlewareName, middlewareName.MiddlewareName()) - - ctx := context.Background() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://test.com/query", nil) - require.NoError(t, err) - res, err := rt.RoundTrip(req) - require.NoError(t, err) - require.NotNil(t, res) - require.NotNil(t, res.Body) - require.NoError(t, res.Body.Close()) - - bodyBytes, err := io.ReadAll(res.Body) - if err != nil { - require.EqualError(t, tc.err, err.Error()) - } else { - require.NoError(t, tc.err) - } - - require.Len(t, bodyBytes, tc.bodyLength) - require.Equal(t, string(bodyBytes), tc.body) - }) - } -} diff --git a/pkg/infra/httpclient/httpclientprovider/sigv4_middleware.go b/pkg/infra/httpclient/httpclientprovider/sigv4_middleware.go deleted file mode 100644 index 0a5de35d08ad4..0000000000000 --- a/pkg/infra/httpclient/httpclientprovider/sigv4_middleware.go +++ /dev/null @@ -1,47 +0,0 @@ -package httpclientprovider - -import ( - "fmt" - "net/http" - - "github.com/grafana/grafana-aws-sdk/pkg/sigv4" - "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" -) - -// SigV4MiddlewareName the middleware name used by SigV4Middleware. -const SigV4MiddlewareName = "sigv4" - -var newSigV4Func = sigv4.New - -// SigV4Middleware applies AWS Signature Version 4 request signing for the outgoing request. -func SigV4Middleware(verboseLogging bool) httpclient.Middleware { - return httpclient.NamedMiddlewareFunc(SigV4MiddlewareName, func(opts httpclient.Options, next http.RoundTripper) http.RoundTripper { - if opts.SigV4 == nil { - return next - } - - conf := &sigv4.Config{ - Service: opts.SigV4.Service, - AccessKey: opts.SigV4.AccessKey, - SecretKey: opts.SigV4.SecretKey, - Region: opts.SigV4.Region, - AssumeRoleARN: opts.SigV4.AssumeRoleARN, - AuthType: opts.SigV4.AuthType, - ExternalID: opts.SigV4.ExternalID, - Profile: opts.SigV4.Profile, - } - - rt, err := newSigV4Func(conf, next, sigv4.Opts{VerboseMode: verboseLogging}) - if err != nil { - return invalidSigV4Config(err) - } - - return rt - }) -} - -func invalidSigV4Config(err error) http.RoundTripper { - return httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { - return nil, fmt.Errorf("invalid SigV4 configuration: %w", err) - }) -} diff --git a/pkg/infra/httpclient/httpclientprovider/sigv4_middleware_test.go b/pkg/infra/httpclient/httpclientprovider/sigv4_middleware_test.go deleted file mode 100644 index 1e021d42d7bba..0000000000000 --- a/pkg/infra/httpclient/httpclientprovider/sigv4_middleware_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package httpclientprovider - -import ( - "fmt" - "net/http" - "testing" - - "github.com/grafana/grafana-aws-sdk/pkg/sigv4" - "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/stretchr/testify/require" -) - -func TestSigV4Middleware(t *testing.T) { - t.Run("Without sigv4 options set should return next http.RoundTripper", func(t *testing.T) { - origSigV4Func := newSigV4Func - newSigV4Called := false - middlewareCalled := false - newSigV4Func = func(config *sigv4.Config, next http.RoundTripper, opts ...sigv4.Opts) (http.RoundTripper, error) { - newSigV4Called = true - return httpclient.RoundTripperFunc(func(r *http.Request) (*http.Response, error) { - middlewareCalled = true - return next.RoundTrip(r) - }), nil - } - t.Cleanup(func() { - newSigV4Func = origSigV4Func - }) - - ctx := &testContext{} - finalRoundTripper := ctx.createRoundTripper("finalrt") - mw := SigV4Middleware(false) - rt := mw.CreateMiddleware(httpclient.Options{}, finalRoundTripper) - require.NotNil(t, rt) - middlewareName, ok := mw.(httpclient.MiddlewareName) - require.True(t, ok) - require.Equal(t, SigV4MiddlewareName, middlewareName.MiddlewareName()) - - req, err := http.NewRequest(http.MethodGet, "http://", nil) - require.NoError(t, err) - res, err := rt.RoundTrip(req) - require.NoError(t, err) - require.NotNil(t, res) - if res.Body != nil { - require.NoError(t, res.Body.Close()) - } - require.Len(t, ctx.callChain, 1) - require.ElementsMatch(t, []string{"finalrt"}, ctx.callChain) - require.False(t, newSigV4Called) - require.False(t, middlewareCalled) - }) - - t.Run("With sigv4 options set should call sigv4 http.RoundTripper", func(t *testing.T) { - origSigV4Func := newSigV4Func - newSigV4Called := false - middlewareCalled := false - newSigV4Func = func(config *sigv4.Config, next http.RoundTripper, opts ...sigv4.Opts) (http.RoundTripper, error) { - newSigV4Called = true - return httpclient.RoundTripperFunc(func(r *http.Request) (*http.Response, error) { - middlewareCalled = true - return next.RoundTrip(r) - }), nil - } - t.Cleanup(func() { - newSigV4Func = origSigV4Func - }) - - ctx := &testContext{} - finalRoundTripper := ctx.createRoundTripper("final") - mw := SigV4Middleware(false) - rt := mw.CreateMiddleware(httpclient.Options{SigV4: &httpclient.SigV4Config{}}, finalRoundTripper) - require.NotNil(t, rt) - middlewareName, ok := mw.(httpclient.MiddlewareName) - require.True(t, ok) - require.Equal(t, SigV4MiddlewareName, middlewareName.MiddlewareName()) - - req, err := http.NewRequest(http.MethodGet, "http://", nil) - require.NoError(t, err) - res, err := rt.RoundTrip(req) - require.NoError(t, err) - require.NotNil(t, res) - if res.Body != nil { - require.NoError(t, res.Body.Close()) - } - require.Len(t, ctx.callChain, 1) - require.ElementsMatch(t, []string{"final"}, ctx.callChain) - - require.True(t, newSigV4Called) - require.True(t, middlewareCalled) - }) - - t.Run("With sigv4 error returned", func(t *testing.T) { - origSigV4Func := newSigV4Func - newSigV4Func = func(config *sigv4.Config, next http.RoundTripper, opts ...sigv4.Opts) (http.RoundTripper, error) { - return nil, fmt.Errorf("problem") - } - t.Cleanup(func() { - newSigV4Func = origSigV4Func - }) - - ctx := &testContext{} - finalRoundTripper := ctx.createRoundTripper("final") - mw := SigV4Middleware(false) - rt := mw.CreateMiddleware(httpclient.Options{SigV4: &httpclient.SigV4Config{}}, finalRoundTripper) - require.NotNil(t, rt) - middlewareName, ok := mw.(httpclient.MiddlewareName) - require.True(t, ok) - require.Equal(t, SigV4MiddlewareName, middlewareName.MiddlewareName()) - - req, err := http.NewRequest(http.MethodGet, "http://", nil) - require.NoError(t, err) - // response is nil - // nolint:bodyclose - res, err := rt.RoundTrip(req) - require.Error(t, err) - require.Nil(t, res) - require.Empty(t, ctx.callChain) - }) -} diff --git a/pkg/infra/httpclient/max_bytes_reader.go b/pkg/infra/httpclient/max_bytes_reader.go deleted file mode 100644 index 9bbdce1e3754f..0000000000000 --- a/pkg/infra/httpclient/max_bytes_reader.go +++ /dev/null @@ -1,66 +0,0 @@ -package httpclient - -import ( - "errors" - "fmt" - "io" -) - -// Similar implementation to http/net MaxBytesReader -// https://pkg.go.dev/net/http#MaxBytesReader -// What's happening differently here, is that the field that -// is limited is the response and not the request, thus -// the error handling/message needed to be accurate. - -// ErrResponseBodyTooLarge indicates response body is too large -var ErrResponseBodyTooLarge = errors.New("http: response body too large") - -// MaxBytesReader is similar to io.LimitReader but is intended for -// limiting the size of incoming request bodies. In contrast to -// io.LimitReader, MaxBytesReader's result is a ReadCloser, returns a -// non-EOF error for a Read beyond the limit, and closes the -// underlying reader when its Close method is called. -// -// MaxBytesReader prevents clients from accidentally or maliciously -// sending a large request and wasting server resources. -func MaxBytesReader(r io.ReadCloser, n int64) io.ReadCloser { - return &maxBytesReader{r: r, n: n} -} - -type maxBytesReader struct { - r io.ReadCloser // underlying reader - n int64 // max bytes remaining - err error // sticky error -} - -func (l *maxBytesReader) Read(p []byte) (n int, err error) { - if l.err != nil { - return 0, l.err - } - if len(p) == 0 { - return 0, nil - } - // If they asked for a 32KB byte read but only 5 bytes are - // remaining, no need to read 32KB. 6 bytes will answer the - // question of the whether we hit the limit or go past it. - if int64(len(p)) > l.n+1 { - p = p[:l.n+1] - } - n, err = l.r.Read(p) - - if int64(n) <= l.n { - l.n -= int64(n) - l.err = err - return n, err - } - - n = int(l.n) - l.n = 0 - - l.err = fmt.Errorf("error: %w, response limit is set to: %d", ErrResponseBodyTooLarge, n) - return n, l.err -} - -func (l *maxBytesReader) Close() error { - return l.r.Close() -} diff --git a/pkg/infra/httpclient/max_bytes_reader_test.go b/pkg/infra/httpclient/max_bytes_reader_test.go deleted file mode 100644 index 1f48be0bbc5b2..0000000000000 --- a/pkg/infra/httpclient/max_bytes_reader_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package httpclient - -import ( - "errors" - "fmt" - "io" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestMaxBytesReader(t *testing.T) { - tcs := []struct { - limit int64 - bodyLength int - body string - err error - }{ - {limit: 1, bodyLength: 1, body: "d", err: errors.New("error: http: response body too large, response limit is set to: 1")}, - {limit: 1000000, bodyLength: 5, body: "dummy", err: nil}, - {limit: 0, bodyLength: 0, body: "", err: errors.New("error: http: response body too large, response limit is set to: 0")}, - } - for _, tc := range tcs { - t.Run(fmt.Sprintf("Test MaxBytesReader with limit: %d", tc.limit), func(t *testing.T) { - body := io.NopCloser(strings.NewReader("dummy")) - readCloser := MaxBytesReader(body, tc.limit) - - bodyBytes, err := io.ReadAll(readCloser) - if err != nil { - require.EqualError(t, tc.err, err.Error()) - } else { - require.NoError(t, tc.err) - } - - require.Len(t, bodyBytes, tc.bodyLength) - require.Equal(t, string(bodyBytes), tc.body) - }) - } -} diff --git a/pkg/infra/kvstore/kvstore_test.go b/pkg/infra/kvstore/kvstore_test.go index e3471285e26fa..f94fd7dc0d215 100644 --- a/pkg/infra/kvstore/kvstore_test.go +++ b/pkg/infra/kvstore/kvstore_test.go @@ -10,8 +10,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func createTestableKVStore(t *testing.T) KVStore { t.Helper() diff --git a/pkg/infra/log/log.go b/pkg/infra/log/log.go index 53e80ca07c733..f9c741e1346a2 100644 --- a/pkg/infra/log/log.go +++ b/pkg/infra/log/log.go @@ -499,3 +499,34 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) error { return nil } + +// SetupConsoleLogger setup Grafana console logger with provided level. +func SetupConsoleLogger(level string) error { + iniFile := ini.Empty() + sLog, err := iniFile.NewSection("log") + if err != nil { + return err + } + + _, err = sLog.NewKey("level", level) + if err != nil { + return err + } + + sLogConsole, err := iniFile.NewSection("log.console") + if err != nil { + return err + } + + _, err = sLogConsole.NewKey("format", "console") + if err != nil { + return err + } + + err = ReadLoggingConfig([]string{"console"}, "", iniFile) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/infra/metrics/metrics.go b/pkg/infra/metrics/metrics.go index 4ed18149f8be0..8c44d8e466579 100644 --- a/pkg/infra/metrics/metrics.go +++ b/pkg/infra/metrics/metrics.go @@ -6,6 +6,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/grafana/grafana/pkg/infra/metrics/metricutil" + "github.com/grafana/grafana/pkg/services/accesscontrol" pubdash "github.com/grafana/grafana/pkg/services/publicdashboards/models" "github.com/grafana/grafana/pkg/setting" ) @@ -80,15 +81,6 @@ var ( // MAlertingNotificationSent is a metric counter for how many alert notifications that failed MAlertingNotificationFailed *prometheus.CounterVec - // MAwsCloudWatchGetMetricStatistics is a metric counter for getting metric statistics from aws - MAwsCloudWatchGetMetricStatistics prometheus.Counter - - // MAwsCloudWatchListMetrics is a metric counter for getting list of metrics from aws - MAwsCloudWatchListMetrics prometheus.Counter - - // MAwsCloudWatchGetMetricData is a metric counter for getting metric data time series from aws - MAwsCloudWatchGetMetricData prometheus.Counter - // MDBDataSourceQueryByID is a metric counter for getting datasource by id MDBDataSourceQueryByID prometheus.Counter @@ -104,11 +96,23 @@ var ( // MAccessEvaluationCount is a metric gauge for total number of evaluation requests MAccessEvaluationCount prometheus.Counter + // MAccessPermissionsCacheUsage is a metric counter for cache usage + MAccessPermissionsCacheUsage *prometheus.CounterVec + + // MAccessSearchUserPermissionsCacheUsage is a metric counter for cache usage + MAccessSearchUserPermissionsCacheUsage *prometheus.CounterVec + // MPublicDashboardRequestCount is a metric counter for public dashboards requests MPublicDashboardRequestCount prometheus.Counter // MPublicDashboardDatasourceQuerySuccess is a metric counter for successful queries labelled by datasource MPublicDashboardDatasourceQuerySuccess *prometheus.CounterVec + + // MFolderIDsAPICount is a metric counter for folder ids count in the api package + MFolderIDsAPICount *prometheus.CounterVec + + // MFolderIDsServicesCount is a metric counter for folder ids count in the services package + MFolderIDsServiceCount *prometheus.CounterVec ) // Timers @@ -128,6 +132,9 @@ var ( // MAccessPermissionsSummary is a metric summary for loading permissions request duration when evaluating access MAccessPermissionsSummary prometheus.Histogram + // MSearchPermissionsSummary is a metric summary for searching permissions request duration + MAccessSearchPermissionsSummary prometheus.Histogram + // MAccessEvaluationsSummary is a metric summary for loading permissions request duration when evaluating access MAccessEvaluationsSummary prometheus.Histogram ) @@ -209,9 +216,39 @@ var ( MStatTotalCorrelations prometheus.Gauge ) +const ( + // FolderID API + GetAlerts string = "GetAlerts" + GetDashboard string = "GetDashboard" + RestoreDashboardVersion string = "RestoreDashboardVersion" + GetFolderByID string = "GetFolderByID" + GetFolderDescendantCounts string = "GetFolderDescendantCounts" + SearchFolders string = "searchFolders" + GetFolderPermissionList string = "GetFolderPermissionList" + UpdateFolderPermissions string = "UpdateFolderPermissions" + GetFolderACL string = "getFolderACL" + Search string = "Search" + GetDashboardACL string = "getDashboardACL" + NewToFolderDTO string = "newToFolderDto" + GetFolders string = "GetFolders" + // FolderID services + Folder string = "folder" + Dashboard string = "dashboards" + LibraryElements string = "libraryelements" + LibraryPanels string = "librarypanels" + NGAlerts string = "ngalert" + Provisioning string = "provisioning" + PublicDashboards string = "publicdashboards" + AccessControl string = "accesscontrol" + Guardian string = "guardian" + DashboardImport string = "dashboardimport" +) + func init() { httpStatusCodes := []string{"200", "404", "500", "unknown"} objectiveMap := map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001} + apiFolderIDMethods := []string{GetAlerts, GetDashboard, RestoreDashboardVersion, GetFolderByID, GetFolderDescendantCounts, SearchFolders, GetFolderPermissionList, UpdateFolderPermissions, GetFolderACL, Search, GetDashboardACL, NewToFolderDTO, GetFolders} + folderIDServices := []string{Folder, Dashboard, LibraryElements, LibraryPanels, NGAlerts, Provisioning, PublicDashboards, AccessControl, Guardian, Search, DashboardImport} MInstanceStart = prometheus.NewCounter(prometheus.CounterOpts{ Name: "instance_start_total", @@ -351,24 +388,6 @@ func init() { Namespace: ExporterName, }, []string{"type"}) - MAwsCloudWatchGetMetricStatistics = metricutil.NewCounterStartingAtZero(prometheus.CounterOpts{ - Name: "aws_cloudwatch_get_metric_statistics_total", - Help: "counter for getting metric statistics from aws", - Namespace: ExporterName, - }) - - MAwsCloudWatchListMetrics = metricutil.NewCounterStartingAtZero(prometheus.CounterOpts{ - Name: "aws_cloudwatch_list_metrics_total", - Help: "counter for getting list of metrics from aws", - Namespace: ExporterName, - }) - - MAwsCloudWatchGetMetricData = metricutil.NewCounterStartingAtZero(prometheus.CounterOpts{ - Name: "aws_cloudwatch_get_metric_data_total", - Help: "counter for getting metric data time series from aws", - Namespace: ExporterName, - }) - MDBDataSourceQueryByID = metricutil.NewCounterStartingAtZero(prometheus.CounterOpts{ Name: "db_datasource_query_by_id_total", Help: "counter for getting datasource by id", @@ -449,6 +468,18 @@ func init() { Namespace: ExporterName, }, []string{"datasource", "status"}, map[string][]string{"status": pubdash.QueryResultStatuses}) + MFolderIDsAPICount = metricutil.NewCounterVecStartingAtZero(prometheus.CounterOpts{ + Name: "folder_id_api_count", + Help: "counter for folder id usage in api package", + Namespace: ExporterName, + }, []string{"method"}, map[string][]string{"method": apiFolderIDMethods}) + + MFolderIDsServiceCount = metricutil.NewCounterVecStartingAtZero(prometheus.CounterOpts{ + Name: "folder_id_service_count", + Help: "counter for folder id usage in service package", + Namespace: ExporterName, + }, []string{"service"}, map[string][]string{"service": folderIDServices}) + MStatTotalDashboards = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "stat_totals_dashboard", Help: "total amount of dashboards", @@ -581,6 +612,24 @@ func init() { Namespace: ExporterName, }) + MAccessSearchPermissionsSummary = prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "access_search_permissions_duration", + Help: "Histogram for the runtime of permissions search function", + Buckets: prometheus.ExponentialBuckets(0.001, 10, 6), + }) + + MAccessPermissionsCacheUsage = metricutil.NewCounterVecStartingAtZero(prometheus.CounterOpts{ + Name: "access_permissions_cache_usage", + Help: "access control permissions cache hit/miss", + Namespace: ExporterName, + }, []string{"status"}, map[string][]string{"status": accesscontrol.CacheUsageStatuses}) + + MAccessSearchUserPermissionsCacheUsage = metricutil.NewCounterVecStartingAtZero(prometheus.CounterOpts{ + Name: "access_search_user_permissions_cache_usage", + Help: "access control search user permissions cache hit/miss", + Namespace: ExporterName, + }, []string{"status"}, map[string][]string{"status": accesscontrol.CacheUsageStatuses}) + StatsTotalLibraryPanels = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "stat_totals_library_panels", Help: "total amount of library panels in the database", @@ -687,9 +736,6 @@ func initMetricVars(reg prometheus.Registerer) { MAlertingResultState, MAlertingNotificationSent, MAlertingNotificationFailed, - MAwsCloudWatchGetMetricStatistics, - MAwsCloudWatchListMetrics, - MAwsCloudWatchGetMetricData, MDBDataSourceQueryByID, LDAPUsersSyncExecutionTime, MRenderingRequestTotal, @@ -698,6 +744,10 @@ func initMetricVars(reg prometheus.Registerer) { MRenderingQueue, MAccessPermissionsSummary, MAccessEvaluationsSummary, + MAccessSearchPermissionsSummary, + MAccessEvaluationCount, + MAccessPermissionsCacheUsage, + MAccessSearchUserPermissionsCacheUsage, MAlertingActiveAlerts, MStatTotalDashboards, MStatTotalFolders, @@ -718,7 +768,6 @@ func initMetricVars(reg prometheus.Registerer) { StatsTotalAnnotations, StatsTotalAlertRules, StatsTotalRuleGroups, - MAccessEvaluationCount, StatsTotalLibraryPanels, StatsTotalLibraryVariables, StatsTotalDataKeys, @@ -726,5 +775,7 @@ func initMetricVars(reg prometheus.Registerer) { MPublicDashboardRequestCount, MPublicDashboardDatasourceQuerySuccess, MStatTotalCorrelations, + MFolderIDsAPICount, + MFolderIDsServiceCount, ) } diff --git a/pkg/infra/metrics/service.go b/pkg/infra/metrics/service.go index f62ad23f2a8ac..dd8d57aec5cf7 100644 --- a/pkg/infra/metrics/service.go +++ b/pkg/infra/metrics/service.go @@ -12,7 +12,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics/graphitebridge" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" ) @@ -61,19 +60,12 @@ func (im *InternalMetricsService) Run(ctx context.Context) error { } func ProvideRegisterer(cfg *setting.Cfg) prometheus.Registerer { - if cfg.IsFeatureToggleEnabled(featuremgmt.FlagGrafanaAPIServer) { - return legacyregistry.Registerer() - } - return prometheus.DefaultRegisterer + return legacyregistry.Registerer() } func ProvideGatherer(cfg *setting.Cfg) prometheus.Gatherer { - if cfg.IsFeatureToggleEnabled(featuremgmt.FlagGrafanaAPIServer) { - k8sGatherer := newAddPrefixWrapper(legacyregistry.DefaultGatherer) - return newMultiRegistry(k8sGatherer, prometheus.DefaultGatherer) - } - - return prometheus.DefaultGatherer + k8sGatherer := newAddPrefixWrapper(legacyregistry.DefaultGatherer) + return newMultiRegistry(k8sGatherer, prometheus.DefaultGatherer) } func ProvideRegistererForTest() prometheus.Registerer { diff --git a/pkg/infra/metrics/settings.go b/pkg/infra/metrics/settings.go index e07f5533a0058..54715db249ecf 100644 --- a/pkg/infra/metrics/settings.go +++ b/pkg/infra/metrics/settings.go @@ -8,7 +8,6 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/grafana/grafana/pkg/infra/metrics/graphitebridge" - "github.com/grafana/grafana/pkg/setting" ) func (im *InternalMetricsService) readSettings() error { @@ -48,7 +47,7 @@ func (im *InternalMetricsService) parseGraphiteSettings() error { ErrorHandling: graphitebridge.ContinueOnError, } - safeInstanceName := strings.ReplaceAll(setting.InstanceName, ".", "_") + safeInstanceName := strings.ReplaceAll(im.Cfg.InstanceName, ".", "_") prefix := graphiteSection.Key("prefix").Value() if prefix == "" { diff --git a/pkg/infra/remotecache/redis_storage.go b/pkg/infra/remotecache/redis_storage.go index 1368453052561..e97679c04f65e 100644 --- a/pkg/infra/remotecache/redis_storage.go +++ b/pkg/infra/remotecache/redis_storage.go @@ -3,6 +3,7 @@ package remotecache import ( "context" "crypto/tls" + "errors" "fmt" "strconv" "strings" @@ -93,7 +94,15 @@ func (s *redisStorage) Set(ctx context.Context, key string, data []byte, expires // GetByteArray returns the value as byte array func (s *redisStorage) Get(ctx context.Context, key string) ([]byte, error) { - return s.c.Get(ctx, key).Bytes() + item, err := s.c.Get(ctx, key).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return nil, ErrCacheItemNotFound + } + return nil, err + } + + return item, nil } // Delete delete a key from session. diff --git a/pkg/infra/remotecache/remotecache_test.go b/pkg/infra/remotecache/remotecache_test.go index 0daf3202abcd9..a51a836fa6ce4 100644 --- a/pkg/infra/remotecache/remotecache_test.go +++ b/pkg/infra/remotecache/remotecache_test.go @@ -13,8 +13,13 @@ import ( "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func createTestClient(t *testing.T, opts *setting.RemoteCacheOptions, sqlstore db.DB) CacheStorage { t.Helper() diff --git a/pkg/infra/serverlock/serverlock_test.go b/pkg/infra/serverlock/serverlock_test.go index 79212a02c5767..3c060c0f22ecf 100644 --- a/pkg/infra/serverlock/serverlock_test.go +++ b/pkg/infra/serverlock/serverlock_test.go @@ -11,8 +11,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func createTestableServerLock(t *testing.T) *ServerLockService { t.Helper() diff --git a/pkg/infra/usagestats/service/api.go b/pkg/infra/usagestats/service/api.go index 5bbbb00ce107b..1217b639613a3 100644 --- a/pkg/infra/usagestats/service/api.go +++ b/pkg/infra/usagestats/service/api.go @@ -20,7 +20,10 @@ func (uss *UsageStats) registerAPIEndpoints() { } func (uss *UsageStats) getUsageReportPreview(ctx *contextmodel.ReqContext) response.Response { - usageReport, err := uss.GetUsageReport(ctx.Req.Context()) + ctxTracer, span := uss.tracer.Start(ctx.Req.Context(), "usageStats.getUsageReportPreview") + defer span.End() + + usageReport, err := uss.GetUsageReport(ctxTracer) if err != nil { return response.Error(http.StatusInternalServerError, "failed to get usage report", err) } diff --git a/pkg/infra/usagestats/service/api_test.go b/pkg/infra/usagestats/service/api_test.go index 7f45b7ca97dd0..f2b749f5dff4b 100644 --- a/pkg/infra/usagestats/service/api_test.go +++ b/pkg/infra/usagestats/service/api_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "path" "testing" "github.com/stretchr/testify/require" @@ -15,7 +14,6 @@ import ( contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/stats" "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -80,7 +78,7 @@ func getUsageStats(t *testing.T, server *web.Mux) (*stats.SystemStats, *httptest func setupTestServer(t *testing.T, user *user.SignedInUser, service *UsageStats) *web.Mux { server := web.New() - server.UseMiddleware(web.Renderer(path.Join(setting.StaticRootPath, "views"), "[[", "]]")) + server.UseMiddleware(web.Renderer("views", "[[", "]]")) server.Use(contextProvider(&testContext{user})) service.RouteRegister.Register(server) return server diff --git a/pkg/infra/usagestats/service/usage_stats.go b/pkg/infra/usagestats/service/usage_stats.go index 5f40165e596da..2f19bd22150ad 100644 --- a/pkg/infra/usagestats/service/usage_stats.go +++ b/pkg/infra/usagestats/service/usage_stats.go @@ -9,6 +9,7 @@ import ( "reflect" "runtime" "strings" + "sync" "time" "github.com/google/uuid" @@ -19,6 +20,11 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats" ) +const ( + maxConcurrentCollectors = 5 + collectorTimeoutDuration = 5 * time.Minute +) + var usageStatsURL = "https://stats.grafana.org/grafana-usage-report" func (uss *UsageStats) GetUsageReport(ctx context.Context) (usagestats.Report, error) { @@ -54,21 +60,38 @@ func (uss *UsageStats) GetUsageReport(ctx context.Context) (usagestats.Report, e } func (uss *UsageStats) gatherMetrics(ctx context.Context, metrics map[string]any) { - ctx, span := uss.tracer.Start(ctx, "UsageStats.GatherLoop") + ctxTracer, span := uss.tracer.Start(ctx, "UsageStats.GatherLoop") defer span.End() totC, errC := 0, 0 + + sem := make(chan struct{}, maxConcurrentCollectors) // create a semaphore with a capacity of 5 + var wg sync.WaitGroup + for _, fn := range uss.externalMetrics { - fnMetrics, err := uss.runMetricsFunc(ctx, fn) - totC++ - if err != nil { - errC++ - continue - } + wg.Add(1) + go func(fn func(context.Context) (map[string]any, error)) { + defer wg.Done() - for name, value := range fnMetrics { - metrics[name] = value - } + sem <- struct{}{} // acquire a token + defer func() { <-sem }() // release the token when done + + ctxWithTimeout, cancel := context.WithTimeout(ctxTracer, collectorTimeoutDuration) + defer cancel() + + fnMetrics, err := uss.runMetricsFunc(ctxWithTimeout, fn) + totC++ + if err != nil { + errC++ + return + } + + for name, value := range fnMetrics { + metrics[name] = value + } + }(fn) } + + wg.Wait() metrics["stats.usagestats.debug.collect.total.count"] = totC metrics["stats.usagestats.debug.collect.error.count"] = errC } diff --git a/pkg/infra/usagestats/service/usage_stats_test.go b/pkg/infra/usagestats/service/usage_stats_test.go index fceb924ad35a2..f2e826b47bde2 100644 --- a/pkg/infra/usagestats/service/usage_stats_test.go +++ b/pkg/infra/usagestats/service/usage_stats_test.go @@ -25,8 +25,13 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + // This is to ensure that the interface contract is held by the implementation func Test_InterfaceContractValidity(t *testing.T) { newUsageStats := func() usagestats.Service { @@ -80,7 +85,7 @@ func TestMetrics(t *testing.T) { AnonymousEnabled: true, BasicAuthEnabled: true, LDAPAuthEnabled: true, - AuthProxyEnabled: true, + AuthProxy: setting.AuthProxySettings{Enabled: true}, Packaging: "deb", ReportingDistributor: "hosted-grafana", } diff --git a/pkg/infra/usagestats/statscollector/concurrent_users_test.go b/pkg/infra/usagestats/statscollector/concurrent_users_test.go index 15c006441bddc..33ffcb3244abd 100644 --- a/pkg/infra/usagestats/statscollector/concurrent_users_test.go +++ b/pkg/infra/usagestats/statscollector/concurrent_users_test.go @@ -14,9 +14,15 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/stats/statsimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) +// run tests with cleanup +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestConcurrentUsersMetrics(t *testing.T) { sqlStore, cfg := db.InitTestDBwithCfg(t) statsService := statsimpl.ProvideService(&setting.Cfg{}, sqlStore) diff --git a/pkg/infra/usagestats/statscollector/service.go b/pkg/infra/usagestats/statscollector/service.go index 158cc5f6367c0..20b0d186f9890 100644 --- a/pkg/infra/usagestats/statscollector/service.go +++ b/pkg/infra/usagestats/statscollector/service.go @@ -25,8 +25,8 @@ import ( ) const ( - MIN_DELAY = 30 - MAX_DELAY = 120 + minDelay = 30 + maxDelay = 120 ) type Service struct { @@ -45,7 +45,6 @@ type Service struct { startTime time.Time concurrentUserStatsCache memoConcurrentUserStats promFlavorCache memoPrometheusFlavor - usageStatProviders []registry.ProvidesUsageStats } func ProvideService( @@ -82,8 +81,8 @@ func ProvideService( s.collectDatasourceAccess, s.collectAlertNotifierStats, s.collectPrometheusFlavors, - s.collectAdditionalMetrics, } + for _, c := range collectors { us.RegisterMetricsFunc(c) } @@ -94,12 +93,19 @@ func ProvideService( // RegisterProviders is called only once - during Grafana start up func (s *Service) RegisterProviders(usageStatProviders []registry.ProvidesUsageStats) { s.log.Info("registering usage stat providers", "usageStatsProvidersLen", len(usageStatProviders)) - s.usageStatProviders = usageStatProviders + for _, usageStatProvider := range usageStatProviders { + provider := usageStatProvider.GetUsageStats + collector := func(ctx context.Context) (map[string]interface{}, error) { + return provider(ctx), nil + } + + s.usageStats.RegisterMetricsFunc(collector) + } } func (s *Service) Run(ctx context.Context) error { sendInterval := time.Second * time.Duration(s.cfg.MetricsTotalStatsIntervalSeconds) - nextSendInterval := time.Duration(rand.Intn(MAX_DELAY-MIN_DELAY)+MIN_DELAY) * time.Second + nextSendInterval := time.Duration(rand.Intn(maxDelay-minDelay)+minDelay) * time.Second s.log.Debug("usage stats collector started", "sendInterval", sendInterval, "nextSendInterval", nextSendInterval) updateStatsTicker := time.NewTicker(nextSendInterval) defer updateStatsTicker.Stop() @@ -212,17 +218,6 @@ func (s *Service) collectSystemStats(ctx context.Context) (map[string]any, error return m, nil } -func (s *Service) collectAdditionalMetrics(ctx context.Context) (map[string]any, error) { - m := map[string]any{} - for _, usageStatProvider := range s.usageStatProviders { - stats := usageStatProvider.GetUsageStats(ctx) - for k, v := range stats { - m[k] = v - } - } - return m, nil -} - func (s *Service) collectAlertNotifierStats(ctx context.Context) (map[string]any, error) { m := map[string]any{} // get stats about alert notifier usage diff --git a/pkg/infra/usagestats/statscollector/service_test.go b/pkg/infra/usagestats/statscollector/service_test.go index f3525e1c08d2d..600f39afac24d 100644 --- a/pkg/infra/usagestats/statscollector/service_test.go +++ b/pkg/infra/usagestats/statscollector/service_test.go @@ -93,22 +93,19 @@ func (d dummyUsageStatProvider) GetUsageStats(ctx context.Context) map[string]an } func TestUsageStatsProviders(t *testing.T) { - provider1 := &dummyUsageStatProvider{stats: map[string]any{"my_stat_1": "val1", "my_stat_2": "val2"}} - provider2 := &dummyUsageStatProvider{stats: map[string]any{"my_stat_x": "valx", "my_stat_z": "valz"}} + provider := &dummyUsageStatProvider{stats: map[string]any{"my_stat_x": "valx", "my_stat_z": "valz"}} store := dbtest.NewFakeDB() statsService := statstest.NewFakeService() mockSystemStats(statsService) s := createService(t, setting.NewCfg(), store, statsService) - s.RegisterProviders([]registry.ProvidesUsageStats{provider1, provider2}) + s.RegisterProviders([]registry.ProvidesUsageStats{provider}) - m, err := s.collectAdditionalMetrics(context.Background()) + report, err := s.usageStats.GetUsageReport(context.Background()) require.NoError(t, err, "Expected no error") - assert.Equal(t, "val1", m["my_stat_1"]) - assert.Equal(t, "val2", m["my_stat_2"]) - assert.Equal(t, "valx", m["my_stat_x"]) - assert.Equal(t, "valz", m["my_stat_z"]) + assert.Equal(t, "valx", report.Metrics["my_stat_x"]) + assert.Equal(t, "valz", report.Metrics["my_stat_z"]) } func TestFeatureUsageStats(t *testing.T) { @@ -145,7 +142,7 @@ func TestCollectingUsageStats(t *testing.T) { AnonymousEnabled: true, BasicAuthEnabled: true, LDAPAuthEnabled: true, - AuthProxyEnabled: true, + AuthProxy: setting.AuthProxySettings{Enabled: true}, Packaging: "deb", ReportingDistributor: "hosted-grafana", RemoteCacheOptions: &setting.RemoteCacheOptions{ @@ -382,7 +379,7 @@ func createService(t testing.TB, cfg *setting.Cfg, store db.DB, statsService sta store, &mockSocial{}, &pluginstore.FakePluginStore{}, - featuremgmt.WithFeatures("feature1", "feature2"), + featuremgmt.WithManager("feature1", "feature2"), o.datasources, httpclient.NewProvider(sdkhttpclient.ProviderOptions{Middlewares: []sdkhttpclient.Middleware{}}), ) diff --git a/pkg/kinds/accesspolicy/accesspolicy_gen.go b/pkg/kinds/accesspolicy/accesspolicy_gen.go index 2c84338278854..a528ab8ea36a7 100644 --- a/pkg/kinds/accesspolicy/accesspolicy_gen.go +++ b/pkg/kinds/accesspolicy/accesspolicy_gen.go @@ -3,13 +3,15 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoTypesJenny +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. package accesspolicy import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/grafana/grafana/pkg/kinds" ) @@ -19,9 +21,11 @@ type K8sResource = kinds.GrafanaResource[Spec, Status] // NewResource creates a new instance of the resource with a given name (UID) func NewK8sResource(name string, s *Spec) K8sResource { return K8sResource{ - Kind: "AccessPolicy", - APIVersion: "v0-0-alpha", - Metadata: kinds.GrafanaResourceMetadata{ + TypeMeta: v1.TypeMeta{ + Kind: "AccessPolicy", + APIVersion: "v0-0-alpha", + }, + ObjectMeta: v1.ObjectMeta{ Name: name, Annotations: make(map[string]string), Labels: make(map[string]string), diff --git a/pkg/kinds/accesspolicy/accesspolicy_kind_gen.go b/pkg/kinds/accesspolicy/accesspolicy_kind_gen.go deleted file mode 100644 index 200ad2653d061..0000000000000 --- a/pkg/kinds/accesspolicy/accesspolicy_kind_gen.go +++ /dev/null @@ -1,79 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// CoreKindJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package accesspolicy - -import ( - "github.com/grafana/kindsys" - "github.com/grafana/thema" - "github.com/grafana/thema/vmux" - - "github.com/grafana/grafana/pkg/cuectx" -) - -// rootrel is the relative path from the grafana repository root to the -// directory containing the .cue files in which this kind is defined. Necessary -// for runtime errors related to the definition and/or lineage to provide -// a real path to the correct .cue file. -const rootrel string = "kinds/accesspolicy" - -// TODO standard generated docs -type Kind struct { - kindsys.Core - lin thema.ConvergentLineage[*Resource] - jcodec vmux.Codec - valmux vmux.ValueMux[*Resource] -} - -// type guard - ensure generated Kind type satisfies the kindsys.Core interface -var _ kindsys.Core = &Kind{} - -// TODO standard generated docs -func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { - def, err := cuectx.LoadCoreKindDef(rootrel, rt.Context(), nil) - if err != nil { - return nil, err - } - - k := &Kind{} - k.Core, err = kindsys.BindCore(rt, def, opts...) - if err != nil { - return nil, err - } - // Get the thema.Schema that the meta says is in the current version (which - // codegen ensures is always the latest) - cursch := thema.SchemaP(k.Core.Lineage(), def.Properties.CurrentVersion) - tsch, err := thema.BindType(cursch, &Resource{}) - if err != nil { - // Should be unreachable, modulo bugs in the Thema->Go code generator - return nil, err - } - - k.jcodec = vmux.NewJSONCodec("accesspolicy.json") - k.lin = tsch.ConvergentLineage() - k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) - return k, nil -} - -// ConvergentLineage returns the same [thema.Lineage] as Lineage, but bound (see [thema.BindType]) -// to the the AccessPolicy [Resource] type generated from the current schema, v0.0. -func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*Resource] { - return k.lin -} - -// JSONValueMux is a version multiplexer that maps a []byte containing JSON data -// at any schematized dashboard version to an instance of AccessPolicy [Resource]. -// -// Validation and translation errors emitted from this func will identify the -// input bytes as "dashboard.json". -// -// This is a thin wrapper around Thema's [vmux.ValueMux]. -func (k *Kind) JSONValueMux(b []byte) (*Resource, thema.TranslationLacunas, error) { - return k.valmux(b) -} diff --git a/pkg/kinds/accesspolicy/accesspolicy_metadata_gen.go b/pkg/kinds/accesspolicy/accesspolicy_metadata_gen.go index 46bcec2632fe6..689f54c57d10d 100644 --- a/pkg/kinds/accesspolicy/accesspolicy_metadata_gen.go +++ b/pkg/kinds/accesspolicy/accesspolicy_metadata_gen.go @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoResourceTypes +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/accesspolicy/accesspolicy_status_gen.go b/pkg/kinds/accesspolicy/accesspolicy_status_gen.go index 8f4e90abab173..5101e4177411d 100644 --- a/pkg/kinds/accesspolicy/accesspolicy_status_gen.go +++ b/pkg/kinds/accesspolicy/accesspolicy_status_gen.go @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoResourceTypes +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/dashboard/dashboard_gen.go b/pkg/kinds/dashboard/dashboard_gen.go index 3022cd9365866..28ac37b0b7381 100644 --- a/pkg/kinds/dashboard/dashboard_gen.go +++ b/pkg/kinds/dashboard/dashboard_gen.go @@ -3,13 +3,15 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoTypesJenny +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. package dashboard import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/grafana/grafana/pkg/kinds" ) @@ -19,9 +21,11 @@ type K8sResource = kinds.GrafanaResource[Spec, Status] // NewResource creates a new instance of the resource with a given name (UID) func NewK8sResource(name string, s *Spec) K8sResource { return K8sResource{ - Kind: "Dashboard", - APIVersion: "v0-0-alpha", - Metadata: kinds.GrafanaResourceMetadata{ + TypeMeta: v1.TypeMeta{ + Kind: "Dashboard", + APIVersion: "v0-0-alpha", + }, + ObjectMeta: v1.ObjectMeta{ Name: name, Annotations: make(map[string]string), Labels: make(map[string]string), diff --git a/pkg/kinds/dashboard/dashboard_kind_gen.go b/pkg/kinds/dashboard/dashboard_kind_gen.go deleted file mode 100644 index 4fe311a23a49c..0000000000000 --- a/pkg/kinds/dashboard/dashboard_kind_gen.go +++ /dev/null @@ -1,79 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// CoreKindJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package dashboard - -import ( - "github.com/grafana/kindsys" - "github.com/grafana/thema" - "github.com/grafana/thema/vmux" - - "github.com/grafana/grafana/pkg/cuectx" -) - -// rootrel is the relative path from the grafana repository root to the -// directory containing the .cue files in which this kind is defined. Necessary -// for runtime errors related to the definition and/or lineage to provide -// a real path to the correct .cue file. -const rootrel string = "kinds/dashboard" - -// TODO standard generated docs -type Kind struct { - kindsys.Core - lin thema.ConvergentLineage[*Resource] - jcodec vmux.Codec - valmux vmux.ValueMux[*Resource] -} - -// type guard - ensure generated Kind type satisfies the kindsys.Core interface -var _ kindsys.Core = &Kind{} - -// TODO standard generated docs -func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { - def, err := cuectx.LoadCoreKindDef(rootrel, rt.Context(), nil) - if err != nil { - return nil, err - } - - k := &Kind{} - k.Core, err = kindsys.BindCore(rt, def, opts...) - if err != nil { - return nil, err - } - // Get the thema.Schema that the meta says is in the current version (which - // codegen ensures is always the latest) - cursch := thema.SchemaP(k.Core.Lineage(), def.Properties.CurrentVersion) - tsch, err := thema.BindType(cursch, &Resource{}) - if err != nil { - // Should be unreachable, modulo bugs in the Thema->Go code generator - return nil, err - } - - k.jcodec = vmux.NewJSONCodec("dashboard.json") - k.lin = tsch.ConvergentLineage() - k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) - return k, nil -} - -// ConvergentLineage returns the same [thema.Lineage] as Lineage, but bound (see [thema.BindType]) -// to the the Dashboard [Resource] type generated from the current schema, v0.0. -func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*Resource] { - return k.lin -} - -// JSONValueMux is a version multiplexer that maps a []byte containing JSON data -// at any schematized dashboard version to an instance of Dashboard [Resource]. -// -// Validation and translation errors emitted from this func will identify the -// input bytes as "dashboard.json". -// -// This is a thin wrapper around Thema's [vmux.ValueMux]. -func (k *Kind) JSONValueMux(b []byte) (*Resource, thema.TranslationLacunas, error) { - return k.valmux(b) -} diff --git a/pkg/kinds/dashboard/dashboard_metadata_gen.go b/pkg/kinds/dashboard/dashboard_metadata_gen.go index c628f6037d868..67047c56cc666 100644 --- a/pkg/kinds/dashboard/dashboard_metadata_gen.go +++ b/pkg/kinds/dashboard/dashboard_metadata_gen.go @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoResourceTypes +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/dashboard/dashboard_spec_gen.go b/pkg/kinds/dashboard/dashboard_spec_gen.go index 08c57edfd932d..ab33a1771353c 100644 --- a/pkg/kinds/dashboard/dashboard_spec_gen.go +++ b/pkg/kinds/dashboard/dashboard_spec_gen.go @@ -26,6 +26,13 @@ const ( LinkTypeLink LinkType = "link" ) +// Defines values for DataTransformerConfigTopic. +const ( + DataTransformerConfigTopicAlertStates DataTransformerConfigTopic = "alertStates" + DataTransformerConfigTopicAnnotations DataTransformerConfigTopic = "annotations" + DataTransformerConfigTopicSeries DataTransformerConfigTopic = "series" +) + // Defines values for FieldColorModeId. const ( FieldColorModeIdContinuousBlPu FieldColorModeId = "continuous-BlPu" @@ -152,6 +159,7 @@ const ( VariableTypeConstant VariableType = "constant" VariableTypeCustom VariableType = "custom" VariableTypeDatasource VariableType = "datasource" + VariableTypeGroupby VariableType = "groupby" VariableTypeInterval VariableType = "interval" VariableTypeQuery VariableType = "query" VariableTypeSystem VariableType = "system" @@ -294,8 +302,14 @@ type DataTransformerConfig struct { // Options to be passed to the transformer // Valid options depend on the transformer id Options any `json:"options"` + + // Where to pull DataFrames from as input to transformation + Topic *DataTransformerConfigTopic `json:"topic,omitempty"` } +// Where to pull DataFrames from as input to transformation +type DataTransformerConfigTopic string + // DynamicConfigValue defines model for DynamicConfigValue. type DynamicConfigValue struct { Id string `json:"id"` @@ -491,6 +505,9 @@ type MatcherConfig struct { // Dashboard panels are the basic visualization building blocks. type Panel struct { + // Sets panel queries cache timeout. + CacheTimeout *string `json:"cacheTimeout,omitempty"` + // Ref to a DataSource instance Datasource *DataSourceRef `json:"datasource,omitempty"` @@ -538,6 +555,9 @@ type Panel struct { // The version of the plugin that is used for this panel. This is used to find the plugin to display the panel and to migrate old panel configs. PluginVersion *string `json:"pluginVersion,omitempty"` + // Overrides the data source configured time-to-live for a query cache item in milliseconds + QueryCachingTTL *float32 `json:"queryCachingTTL,omitempty"` + // Name of template variable to repeat for. Repeat *string `json:"repeat,omitempty"` @@ -545,9 +565,6 @@ type Panel struct { // `h` for horizontal, `v` for vertical. RepeatDirection *PanelRepeatDirection `json:"repeatDirection,omitempty"` - // Tags for the panel. - Tags []string `json:"tags,omitempty"` - // Depends on the panel plugin. See the plugin documentation for details. Targets []Target `json:"targets,omitempty"` @@ -683,6 +700,9 @@ type Snapshot struct { // OrgId org id of the snapshot OrgId int `json:"orgId"` + // OriginalUrl original url, url of the dashboard that was snapshotted + OriginalUrl string `json:"originalUrl"` + // Updated last time when the snapshot was updated Updated time.Time `json:"updated"` @@ -734,7 +754,7 @@ type Spec struct { Panels []any `json:"panels,omitempty"` // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". - Refresh *any `json:"refresh,omitempty"` + Refresh *string `json:"refresh,omitempty"` // This property should only be used in dashboards defined by plugins. It is a quick check // to see if the version has changed since the last time. @@ -843,13 +863,16 @@ type ThresholdsMode string // It defines the default config for the time picker and the refresh picker for the specific dashboard. type TimePickerConfig struct { // Whether timepicker is visible or not. - Hidden bool `json:"hidden"` + Hidden *bool `json:"hidden,omitempty"` + + // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. + NowDelay *string `json:"nowDelay,omitempty"` // Interval options available in the refresh picker dropdown. - RefreshIntervals []string `json:"refresh_intervals"` + RefreshIntervals []string `json:"refresh_intervals,omitempty"` // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. - TimeOptions []string `json:"time_options"` + TimeOptions []string `json:"time_options,omitempty"` } // Maps text values to a color or different display text and color. @@ -884,6 +907,9 @@ type VariableHide int // A variable is a placeholder for a value. You can use variables in metric queries and in panel titles. type VariableModel struct { + // Custom all value + AllValue *string `json:"allValue,omitempty"` + // Option to be selected in a variable. Current *VariableOption `json:"current,omitempty"` @@ -897,6 +923,9 @@ type VariableModel struct { // Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing). Hide *VariableHide `json:"hide,omitempty"` + // Whether all value option is available or not + IncludeAll *bool `json:"includeAll,omitempty"` + // Optional display name Label *string `json:"label,omitempty"` @@ -918,6 +947,10 @@ type VariableModel struct { // `2`: Queries the data source when the dashboard time range changes. Refresh *VariableRefresh `json:"refresh,omitempty"` + // Optional field, if you want to extract part of a series name or metric node segment. + // Named capture groups can be used to separate the display text and value. + Regex *string `json:"regex,omitempty"` + // Whether the variable value should be managed by URL query params or not SkipUrlSync *bool `json:"skipUrlSync,omitempty"` diff --git a/pkg/kinds/dashboard/dashboard_status_gen.go b/pkg/kinds/dashboard/dashboard_status_gen.go index 49a6d55b912ce..82c114dff2bd4 100644 --- a/pkg/kinds/dashboard/dashboard_status_gen.go +++ b/pkg/kinds/dashboard/dashboard_status_gen.go @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoResourceTypes +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/general.go b/pkg/kinds/general.go index bc437ce1fb812..61c092036b7e5 100644 --- a/pkg/kinds/general.go +++ b/pkg/kinds/general.go @@ -1,390 +1,18 @@ package kinds import ( - "fmt" - "time" - - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// ResourceOriginInfo is saved in annotations. This is used to identify where the resource came from -// This object can model the same data as our existing provisioning table or a more general git sync -type ResourceOriginInfo struct { - // Name of the origin/provisioning source - Name string `json:"name,omitempty"` - - // The path within the named origin above (external_id in the existing dashboard provisioing) - Path string `json:"path,omitempty"` - - // Verification/identification key (check_sum in existing dashboard provisioning) - Key string `json:"key,omitempty"` - - // Origin modification timestamp when the resource was saved - // This will be before the resource updated time - Timestamp *time.Time `json:"time,omitempty"` - - // Avoid extending - _ any `json:"-"` -} - -// GrafanaResourceMetadata is standard k8s object metadata with helper functions -type GrafanaResourceMetadata v1.ObjectMeta - // GrafanaResource is a generic kubernetes resource with a helper for the common grafana metadata // This is a temporary solution until this object (or similar) can be moved to the app-sdk or kindsys type GrafanaResource[Spec any, Status any] struct { - APIVersion string `json:"apiVersion"` - Kind string `json:"kind"` + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` - Metadata GrafanaResourceMetadata `json:"metadata"` - Spec *Spec `json:"spec,omitempty"` - Status *Status `json:"status,omitempty"` + Spec *Spec `json:"spec,omitempty"` + Status *Status `json:"status,omitempty"` // Avoid extending _ any `json:"-"` } - -// Annotation keys -const annoKeyCreatedBy = "grafana.app/createdBy" -const annoKeyUpdatedTimestamp = "grafana.app/updatedTimestamp" -const annoKeyUpdatedBy = "grafana.app/updatedBy" - -// The folder identifier -const annoKeyFolder = "grafana.app/folder" -const annoKeySlug = "grafana.app/slug" -const annoKeyTitle = "grafana.app/title" - -// Identify where values came from -const annoKeyOriginName = "grafana.app/originName" -const annoKeyOriginPath = "grafana.app/originPath" -const annoKeyOriginKey = "grafana.app/originKey" -const annoKeyOriginTimestamp = "grafana.app/originTimestamp" - -func (m *GrafanaResourceMetadata) set(key string, val string) { - if val == "" { - if m.Annotations != nil { - delete(m.Annotations, key) - } - return - } - if m.Annotations == nil { - m.Annotations = make(map[string]string) - } - m.Annotations[key] = val -} - -func (m *GrafanaResourceMetadata) get(key string) string { - if m.Annotations == nil { - return "" - } - return m.Annotations[key] -} - -func (m *GrafanaResourceMetadata) GetUpdatedTimestamp() (*time.Time, error) { - v, ok := m.Annotations[annoKeyUpdatedTimestamp] - if !ok || v == "" { - return nil, nil - } - t, err := time.Parse(time.RFC3339, v) - if err != nil { - return nil, fmt.Errorf("invalid updated timestamp: %s", err.Error()) - } - return &t, nil -} - -func (m *GrafanaResourceMetadata) SetUpdatedTimestampMillis(v int64) { - if v > 0 { - t := time.UnixMilli(v) - m.SetUpdatedTimestamp(&t) - } else { - m.SetUpdatedTimestamp(nil) - } -} - -func (m *GrafanaResourceMetadata) SetUpdatedTimestamp(v *time.Time) { - txt := "" - if v != nil { - txt = v.UTC().Format(time.RFC3339) - } - m.set(annoKeyUpdatedTimestamp, txt) -} - -func (m *GrafanaResourceMetadata) GetCreatedBy() string { - return m.Annotations[annoKeyCreatedBy] -} - -func (m *GrafanaResourceMetadata) SetCreatedBy(user string) { - m.set(annoKeyCreatedBy, user) -} - -func (m *GrafanaResourceMetadata) GetUpdatedBy() string { - return m.Annotations[annoKeyUpdatedBy] -} - -func (m *GrafanaResourceMetadata) SetUpdatedBy(user string) { - m.set(annoKeyUpdatedBy, user) -} - -func (m *GrafanaResourceMetadata) GetFolder() string { - return m.Annotations[annoKeyFolder] -} - -func (m *GrafanaResourceMetadata) SetFolder(uid string) { - m.set(annoKeyFolder, uid) -} - -func (m *GrafanaResourceMetadata) GetSlug() string { - return m.get(annoKeySlug) -} - -func (m *GrafanaResourceMetadata) SetSlug(v string) { - m.set(annoKeySlug, v) -} - -func (m *GrafanaResourceMetadata) GetTitle() string { - return m.get(annoKeyTitle) -} - -func (m *GrafanaResourceMetadata) SetTitle(v string) { - m.set(annoKeyTitle, v) -} - -func (m *GrafanaResourceMetadata) SetOriginInfo(info *ResourceOriginInfo) { - delete(m.Annotations, annoKeyOriginName) - delete(m.Annotations, annoKeyOriginPath) - delete(m.Annotations, annoKeyOriginKey) - delete(m.Annotations, annoKeyOriginTimestamp) - if info != nil && info.Name != "" { - m.set(annoKeyOriginName, info.Name) - m.set(annoKeyOriginKey, info.Key) - m.set(annoKeyOriginPath, info.Path) - if info.Timestamp != nil { - m.Annotations[annoKeyOriginTimestamp] = info.Timestamp.Format(time.RFC3339) - } - } -} - -// GetOriginInfo returns the origin info stored in k8s metadata annotations -func (m *GrafanaResourceMetadata) GetOriginInfo() (*ResourceOriginInfo, error) { - v, ok := m.Annotations[annoKeyOriginName] - if !ok { - return nil, nil - } - t, err := m.GetOriginTimestamp() - return &ResourceOriginInfo{ - Name: v, - Path: m.GetOriginPath(), - Key: m.GetOriginKey(), - Timestamp: t, - }, err -} - -func (m *GrafanaResourceMetadata) GetOriginName() string { - return m.Annotations[annoKeyOriginName] -} - -func (m *GrafanaResourceMetadata) GetOriginPath() string { - return m.Annotations[annoKeyOriginPath] -} - -func (m *GrafanaResourceMetadata) GetOriginKey() string { - return m.Annotations[annoKeyOriginKey] -} - -func (m *GrafanaResourceMetadata) GetOriginTimestamp() (*time.Time, error) { - v, ok := m.Annotations[annoKeyOriginTimestamp] - if !ok || v == "" { - return nil, nil - } - t, err := time.Parse(time.RFC3339, v) - if err != nil { - return nil, fmt.Errorf("invalid origin timestamp: %s", err.Error()) - } - return &t, nil -} - -// Accessor functions for k8s objects -type GrafanaResourceMetaAccessor interface { - GetUpdatedTimestamp() (*time.Time, error) - SetUpdatedTimestamp(v *time.Time) - GetCreatedBy() string - SetCreatedBy(user string) - GetUpdatedBy() string - SetUpdatedBy(user string) - GetFolder() string - SetFolder(uid string) - GetSlug() string - SetSlug(v string) - GetTitle() string - SetTitle(v string) - GetOriginInfo() (*ResourceOriginInfo, error) - SetOriginInfo(info *ResourceOriginInfo) - GetOriginName() string - GetOriginPath() string - GetOriginKey() string - GetOriginTimestamp() (*time.Time, error) -} - -var _ GrafanaResourceMetaAccessor = (*grafanaResourceMetaAccessor)(nil) -var _ GrafanaResourceMetaAccessor = (*GrafanaResourceMetadata)(nil) - -type grafanaResourceMetaAccessor struct { - obj v1.Object -} - -func MetaAccessor(obj v1.Object) GrafanaResourceMetaAccessor { - return &grafanaResourceMetaAccessor{obj} -} - -func (m *grafanaResourceMetaAccessor) set(key string, val string) { - anno := m.obj.GetAnnotations() - if val == "" { - if anno != nil { - delete(anno, key) - } - } else { - if anno == nil { - anno = make(map[string]string) - } - anno[key] = val - } - m.obj.SetAnnotations(anno) -} - -func (m *grafanaResourceMetaAccessor) get(key string) string { - return m.obj.GetAnnotations()[key] -} - -func (m *grafanaResourceMetaAccessor) GetUpdatedTimestamp() (*time.Time, error) { - v, ok := m.obj.GetAnnotations()[annoKeyUpdatedTimestamp] - if !ok || v == "" { - return nil, nil - } - t, err := time.Parse(time.RFC3339, v) - if err != nil { - return nil, fmt.Errorf("invalid updated timestamp: %s", err.Error()) - } - return &t, nil -} - -func (m *grafanaResourceMetaAccessor) SetUpdatedTimestampMillis(v int64) { - if v > 0 { - t := time.UnixMilli(v) - m.SetUpdatedTimestamp(&t) - } else { - m.SetUpdatedTimestamp(nil) - } -} - -func (m *grafanaResourceMetaAccessor) SetUpdatedTimestamp(v *time.Time) { - txt := "" - if v != nil { - txt = v.UTC().Format(time.RFC3339) - } - m.set(annoKeyUpdatedTimestamp, txt) -} - -func (m *grafanaResourceMetaAccessor) GetCreatedBy() string { - return m.get(annoKeyCreatedBy) -} - -func (m *grafanaResourceMetaAccessor) SetCreatedBy(user string) { - m.set(annoKeyCreatedBy, user) -} - -func (m *grafanaResourceMetaAccessor) GetUpdatedBy() string { - return m.get(annoKeyUpdatedBy) -} - -func (m *grafanaResourceMetaAccessor) SetUpdatedBy(user string) { - m.set(annoKeyUpdatedBy, user) -} - -func (m *grafanaResourceMetaAccessor) GetFolder() string { - return m.get(annoKeyFolder) -} - -func (m *grafanaResourceMetaAccessor) SetFolder(uid string) { - m.set(annoKeyFolder, uid) -} - -func (m *grafanaResourceMetaAccessor) GetSlug() string { - return m.get(annoKeySlug) -} - -func (m *grafanaResourceMetaAccessor) SetSlug(v string) { - m.set(annoKeySlug, v) -} -func (m *grafanaResourceMetaAccessor) GetTitle() string { - return m.get(annoKeyTitle) -} - -func (m *grafanaResourceMetaAccessor) SetTitle(v string) { - m.set(annoKeyTitle, v) -} -func (m *grafanaResourceMetaAccessor) SetOriginInfo(info *ResourceOriginInfo) { - anno := m.obj.GetAnnotations() - if anno == nil { - if info == nil { - return - } - anno = make(map[string]string, 0) - m.obj.SetAnnotations(anno) - } - - delete(anno, annoKeyOriginName) - delete(anno, annoKeyOriginPath) - delete(anno, annoKeyOriginKey) - delete(anno, annoKeyOriginTimestamp) - if info != nil && info.Name != "" { - anno[annoKeyOriginName] = info.Name - if info.Path != "" { - anno[annoKeyOriginPath] = info.Path - } - if info.Key != "" { - anno[annoKeyOriginKey] = info.Key - } - if info.Timestamp != nil { - anno[annoKeyOriginTimestamp] = info.Timestamp.Format(time.RFC3339) - } - } - m.obj.SetAnnotations(anno) -} - -func (m *grafanaResourceMetaAccessor) GetOriginInfo() (*ResourceOriginInfo, error) { - v, ok := m.obj.GetAnnotations()[annoKeyOriginName] - if !ok { - return nil, nil - } - t, err := m.GetOriginTimestamp() - return &ResourceOriginInfo{ - Name: v, - Path: m.GetOriginPath(), - Key: m.GetOriginKey(), - Timestamp: t, - }, err -} - -func (m *grafanaResourceMetaAccessor) GetOriginName() string { - return m.get(annoKeyOriginName) -} - -func (m *grafanaResourceMetaAccessor) GetOriginPath() string { - return m.get(annoKeyOriginPath) -} - -func (m *grafanaResourceMetaAccessor) GetOriginKey() string { - return m.get(annoKeyOriginKey) -} - -func (m *grafanaResourceMetaAccessor) GetOriginTimestamp() (*time.Time, error) { - v, ok := m.obj.GetAnnotations()[annoKeyOriginTimestamp] - if !ok || v == "" { - return nil, nil - } - t, err := time.Parse(time.RFC3339, v) - if err != nil { - return nil, fmt.Errorf("invalid origin timestamp: %s", err.Error()) - } - return &t, nil -} diff --git a/pkg/kinds/general_test.go b/pkg/kinds/general_test.go deleted file mode 100644 index 6f5f74d7bd164..0000000000000 --- a/pkg/kinds/general_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package kinds - -import ( - "testing" - - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -) - -func TestMetaAccessor(t *testing.T) { - originInfo := &ResourceOriginInfo{ - Name: "test", - Path: "a/b/c", - Key: "kkk", - } - - // Verify that you can set annotations when they do not exist - dummy := &GrafanaResourceMetadata{} - dummy.SetOriginInfo(originInfo) - dummy.SetFolder("folderUID") - - // with any k8s object - obj := &unstructured.Unstructured{} - meta := MetaAccessor(obj) - meta.SetOriginInfo(originInfo) - meta.SetFolder("folderUID") - - require.Equal(t, map[string]string{ - "grafana.app/originName": "test", - "grafana.app/originPath": "a/b/c", - "grafana.app/originKey": "kkk", - "grafana.app/folder": "folderUID", - }, dummy.Annotations) - require.Equal(t, dummy.Annotations, obj.GetAnnotations()) -} diff --git a/pkg/kinds/librarypanel/librarypanel_gen.go b/pkg/kinds/librarypanel/librarypanel_gen.go index 051a738b9f1f5..ea25be0ad4ffb 100644 --- a/pkg/kinds/librarypanel/librarypanel_gen.go +++ b/pkg/kinds/librarypanel/librarypanel_gen.go @@ -3,13 +3,15 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoTypesJenny +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. package librarypanel import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/grafana/grafana/pkg/kinds" ) @@ -19,9 +21,11 @@ type K8sResource = kinds.GrafanaResource[Spec, Status] // NewResource creates a new instance of the resource with a given name (UID) func NewK8sResource(name string, s *Spec) K8sResource { return K8sResource{ - Kind: "LibraryPanel", - APIVersion: "v0-0-alpha", - Metadata: kinds.GrafanaResourceMetadata{ + TypeMeta: v1.TypeMeta{ + Kind: "LibraryPanel", + APIVersion: "v0-0-alpha", + }, + ObjectMeta: v1.ObjectMeta{ Name: name, Annotations: make(map[string]string), Labels: make(map[string]string), diff --git a/pkg/kinds/librarypanel/librarypanel_kind_gen.go b/pkg/kinds/librarypanel/librarypanel_kind_gen.go deleted file mode 100644 index 246c8869bc1c7..0000000000000 --- a/pkg/kinds/librarypanel/librarypanel_kind_gen.go +++ /dev/null @@ -1,79 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// CoreKindJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package librarypanel - -import ( - "github.com/grafana/kindsys" - "github.com/grafana/thema" - "github.com/grafana/thema/vmux" - - "github.com/grafana/grafana/pkg/cuectx" -) - -// rootrel is the relative path from the grafana repository root to the -// directory containing the .cue files in which this kind is defined. Necessary -// for runtime errors related to the definition and/or lineage to provide -// a real path to the correct .cue file. -const rootrel string = "kinds/librarypanel" - -// TODO standard generated docs -type Kind struct { - kindsys.Core - lin thema.ConvergentLineage[*Resource] - jcodec vmux.Codec - valmux vmux.ValueMux[*Resource] -} - -// type guard - ensure generated Kind type satisfies the kindsys.Core interface -var _ kindsys.Core = &Kind{} - -// TODO standard generated docs -func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { - def, err := cuectx.LoadCoreKindDef(rootrel, rt.Context(), nil) - if err != nil { - return nil, err - } - - k := &Kind{} - k.Core, err = kindsys.BindCore(rt, def, opts...) - if err != nil { - return nil, err - } - // Get the thema.Schema that the meta says is in the current version (which - // codegen ensures is always the latest) - cursch := thema.SchemaP(k.Core.Lineage(), def.Properties.CurrentVersion) - tsch, err := thema.BindType(cursch, &Resource{}) - if err != nil { - // Should be unreachable, modulo bugs in the Thema->Go code generator - return nil, err - } - - k.jcodec = vmux.NewJSONCodec("librarypanel.json") - k.lin = tsch.ConvergentLineage() - k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) - return k, nil -} - -// ConvergentLineage returns the same [thema.Lineage] as Lineage, but bound (see [thema.BindType]) -// to the the LibraryPanel [Resource] type generated from the current schema, v0.0. -func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*Resource] { - return k.lin -} - -// JSONValueMux is a version multiplexer that maps a []byte containing JSON data -// at any schematized dashboard version to an instance of LibraryPanel [Resource]. -// -// Validation and translation errors emitted from this func will identify the -// input bytes as "dashboard.json". -// -// This is a thin wrapper around Thema's [vmux.ValueMux]. -func (k *Kind) JSONValueMux(b []byte) (*Resource, thema.TranslationLacunas, error) { - return k.valmux(b) -} diff --git a/pkg/kinds/librarypanel/librarypanel_metadata_gen.go b/pkg/kinds/librarypanel/librarypanel_metadata_gen.go index 49a44f5710cd5..e899f28b1ffec 100644 --- a/pkg/kinds/librarypanel/librarypanel_metadata_gen.go +++ b/pkg/kinds/librarypanel/librarypanel_metadata_gen.go @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoResourceTypes +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/librarypanel/librarypanel_status_gen.go b/pkg/kinds/librarypanel/librarypanel_status_gen.go index 8b04861837def..69072c08dff56 100644 --- a/pkg/kinds/librarypanel/librarypanel_status_gen.go +++ b/pkg/kinds/librarypanel/librarypanel_status_gen.go @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoResourceTypes +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/preferences/preferences_gen.go b/pkg/kinds/preferences/preferences_gen.go index aec2fc93fbdf0..4f6861b821544 100644 --- a/pkg/kinds/preferences/preferences_gen.go +++ b/pkg/kinds/preferences/preferences_gen.go @@ -3,13 +3,15 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoTypesJenny +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. package preferences import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/grafana/grafana/pkg/kinds" ) @@ -19,9 +21,11 @@ type K8sResource = kinds.GrafanaResource[Spec, Status] // NewResource creates a new instance of the resource with a given name (UID) func NewK8sResource(name string, s *Spec) K8sResource { return K8sResource{ - Kind: "Preferences", - APIVersion: "v0-0-alpha", - Metadata: kinds.GrafanaResourceMetadata{ + TypeMeta: v1.TypeMeta{ + Kind: "Preferences", + APIVersion: "v0-0-alpha", + }, + ObjectMeta: v1.ObjectMeta{ Name: name, Annotations: make(map[string]string), Labels: make(map[string]string), diff --git a/pkg/kinds/preferences/preferences_kind_gen.go b/pkg/kinds/preferences/preferences_kind_gen.go deleted file mode 100644 index 9efc26fba774e..0000000000000 --- a/pkg/kinds/preferences/preferences_kind_gen.go +++ /dev/null @@ -1,79 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// CoreKindJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package preferences - -import ( - "github.com/grafana/kindsys" - "github.com/grafana/thema" - "github.com/grafana/thema/vmux" - - "github.com/grafana/grafana/pkg/cuectx" -) - -// rootrel is the relative path from the grafana repository root to the -// directory containing the .cue files in which this kind is defined. Necessary -// for runtime errors related to the definition and/or lineage to provide -// a real path to the correct .cue file. -const rootrel string = "kinds/preferences" - -// TODO standard generated docs -type Kind struct { - kindsys.Core - lin thema.ConvergentLineage[*Resource] - jcodec vmux.Codec - valmux vmux.ValueMux[*Resource] -} - -// type guard - ensure generated Kind type satisfies the kindsys.Core interface -var _ kindsys.Core = &Kind{} - -// TODO standard generated docs -func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { - def, err := cuectx.LoadCoreKindDef(rootrel, rt.Context(), nil) - if err != nil { - return nil, err - } - - k := &Kind{} - k.Core, err = kindsys.BindCore(rt, def, opts...) - if err != nil { - return nil, err - } - // Get the thema.Schema that the meta says is in the current version (which - // codegen ensures is always the latest) - cursch := thema.SchemaP(k.Core.Lineage(), def.Properties.CurrentVersion) - tsch, err := thema.BindType(cursch, &Resource{}) - if err != nil { - // Should be unreachable, modulo bugs in the Thema->Go code generator - return nil, err - } - - k.jcodec = vmux.NewJSONCodec("preferences.json") - k.lin = tsch.ConvergentLineage() - k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) - return k, nil -} - -// ConvergentLineage returns the same [thema.Lineage] as Lineage, but bound (see [thema.BindType]) -// to the the Preferences [Resource] type generated from the current schema, v0.0. -func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*Resource] { - return k.lin -} - -// JSONValueMux is a version multiplexer that maps a []byte containing JSON data -// at any schematized dashboard version to an instance of Preferences [Resource]. -// -// Validation and translation errors emitted from this func will identify the -// input bytes as "dashboard.json". -// -// This is a thin wrapper around Thema's [vmux.ValueMux]. -func (k *Kind) JSONValueMux(b []byte) (*Resource, thema.TranslationLacunas, error) { - return k.valmux(b) -} diff --git a/pkg/kinds/preferences/preferences_metadata_gen.go b/pkg/kinds/preferences/preferences_metadata_gen.go index bd33d5d53a8ba..dacb200beb402 100644 --- a/pkg/kinds/preferences/preferences_metadata_gen.go +++ b/pkg/kinds/preferences/preferences_metadata_gen.go @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoResourceTypes +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/preferences/preferences_status_gen.go b/pkg/kinds/preferences/preferences_status_gen.go index e9a7553380708..53fbb8a07c237 100644 --- a/pkg/kinds/preferences/preferences_status_gen.go +++ b/pkg/kinds/preferences/preferences_status_gen.go @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoResourceTypes +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/publicdashboard/publicdashboard_gen.go b/pkg/kinds/publicdashboard/publicdashboard_gen.go index 158fe87cf63d3..68239e64690d9 100644 --- a/pkg/kinds/publicdashboard/publicdashboard_gen.go +++ b/pkg/kinds/publicdashboard/publicdashboard_gen.go @@ -3,13 +3,15 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoTypesJenny +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. package publicdashboard import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/grafana/grafana/pkg/kinds" ) @@ -19,9 +21,11 @@ type K8sResource = kinds.GrafanaResource[Spec, Status] // NewResource creates a new instance of the resource with a given name (UID) func NewK8sResource(name string, s *Spec) K8sResource { return K8sResource{ - Kind: "PublicDashboard", - APIVersion: "v0-0-alpha", - Metadata: kinds.GrafanaResourceMetadata{ + TypeMeta: v1.TypeMeta{ + Kind: "PublicDashboard", + APIVersion: "v0-0-alpha", + }, + ObjectMeta: v1.ObjectMeta{ Name: name, Annotations: make(map[string]string), Labels: make(map[string]string), diff --git a/pkg/kinds/publicdashboard/publicdashboard_kind_gen.go b/pkg/kinds/publicdashboard/publicdashboard_kind_gen.go deleted file mode 100644 index f82475779e815..0000000000000 --- a/pkg/kinds/publicdashboard/publicdashboard_kind_gen.go +++ /dev/null @@ -1,79 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// CoreKindJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package publicdashboard - -import ( - "github.com/grafana/kindsys" - "github.com/grafana/thema" - "github.com/grafana/thema/vmux" - - "github.com/grafana/grafana/pkg/cuectx" -) - -// rootrel is the relative path from the grafana repository root to the -// directory containing the .cue files in which this kind is defined. Necessary -// for runtime errors related to the definition and/or lineage to provide -// a real path to the correct .cue file. -const rootrel string = "kinds/publicdashboard" - -// TODO standard generated docs -type Kind struct { - kindsys.Core - lin thema.ConvergentLineage[*Resource] - jcodec vmux.Codec - valmux vmux.ValueMux[*Resource] -} - -// type guard - ensure generated Kind type satisfies the kindsys.Core interface -var _ kindsys.Core = &Kind{} - -// TODO standard generated docs -func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { - def, err := cuectx.LoadCoreKindDef(rootrel, rt.Context(), nil) - if err != nil { - return nil, err - } - - k := &Kind{} - k.Core, err = kindsys.BindCore(rt, def, opts...) - if err != nil { - return nil, err - } - // Get the thema.Schema that the meta says is in the current version (which - // codegen ensures is always the latest) - cursch := thema.SchemaP(k.Core.Lineage(), def.Properties.CurrentVersion) - tsch, err := thema.BindType(cursch, &Resource{}) - if err != nil { - // Should be unreachable, modulo bugs in the Thema->Go code generator - return nil, err - } - - k.jcodec = vmux.NewJSONCodec("publicdashboard.json") - k.lin = tsch.ConvergentLineage() - k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) - return k, nil -} - -// ConvergentLineage returns the same [thema.Lineage] as Lineage, but bound (see [thema.BindType]) -// to the the PublicDashboard [Resource] type generated from the current schema, v0.0. -func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*Resource] { - return k.lin -} - -// JSONValueMux is a version multiplexer that maps a []byte containing JSON data -// at any schematized dashboard version to an instance of PublicDashboard [Resource]. -// -// Validation and translation errors emitted from this func will identify the -// input bytes as "dashboard.json". -// -// This is a thin wrapper around Thema's [vmux.ValueMux]. -func (k *Kind) JSONValueMux(b []byte) (*Resource, thema.TranslationLacunas, error) { - return k.valmux(b) -} diff --git a/pkg/kinds/publicdashboard/publicdashboard_metadata_gen.go b/pkg/kinds/publicdashboard/publicdashboard_metadata_gen.go index 0f0b0a878ac64..c5b84bf2700cc 100644 --- a/pkg/kinds/publicdashboard/publicdashboard_metadata_gen.go +++ b/pkg/kinds/publicdashboard/publicdashboard_metadata_gen.go @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoResourceTypes +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/publicdashboard/publicdashboard_status_gen.go b/pkg/kinds/publicdashboard/publicdashboard_status_gen.go index 100e34313879d..95de4433cfed4 100644 --- a/pkg/kinds/publicdashboard/publicdashboard_status_gen.go +++ b/pkg/kinds/publicdashboard/publicdashboard_status_gen.go @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoResourceTypes +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/role/role_gen.go b/pkg/kinds/role/role_gen.go index a3bfb86a5028b..c054e8c1773a5 100644 --- a/pkg/kinds/role/role_gen.go +++ b/pkg/kinds/role/role_gen.go @@ -3,13 +3,15 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoTypesJenny +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. package role import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/grafana/grafana/pkg/kinds" ) @@ -19,9 +21,11 @@ type K8sResource = kinds.GrafanaResource[Spec, Status] // NewResource creates a new instance of the resource with a given name (UID) func NewK8sResource(name string, s *Spec) K8sResource { return K8sResource{ - Kind: "Role", - APIVersion: "v0-0-alpha", - Metadata: kinds.GrafanaResourceMetadata{ + TypeMeta: v1.TypeMeta{ + Kind: "Role", + APIVersion: "v0-0-alpha", + }, + ObjectMeta: v1.ObjectMeta{ Name: name, Annotations: make(map[string]string), Labels: make(map[string]string), diff --git a/pkg/kinds/role/role_kind_gen.go b/pkg/kinds/role/role_kind_gen.go deleted file mode 100644 index cc297cb080731..0000000000000 --- a/pkg/kinds/role/role_kind_gen.go +++ /dev/null @@ -1,79 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// CoreKindJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package role - -import ( - "github.com/grafana/kindsys" - "github.com/grafana/thema" - "github.com/grafana/thema/vmux" - - "github.com/grafana/grafana/pkg/cuectx" -) - -// rootrel is the relative path from the grafana repository root to the -// directory containing the .cue files in which this kind is defined. Necessary -// for runtime errors related to the definition and/or lineage to provide -// a real path to the correct .cue file. -const rootrel string = "kinds/role" - -// TODO standard generated docs -type Kind struct { - kindsys.Core - lin thema.ConvergentLineage[*Resource] - jcodec vmux.Codec - valmux vmux.ValueMux[*Resource] -} - -// type guard - ensure generated Kind type satisfies the kindsys.Core interface -var _ kindsys.Core = &Kind{} - -// TODO standard generated docs -func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { - def, err := cuectx.LoadCoreKindDef(rootrel, rt.Context(), nil) - if err != nil { - return nil, err - } - - k := &Kind{} - k.Core, err = kindsys.BindCore(rt, def, opts...) - if err != nil { - return nil, err - } - // Get the thema.Schema that the meta says is in the current version (which - // codegen ensures is always the latest) - cursch := thema.SchemaP(k.Core.Lineage(), def.Properties.CurrentVersion) - tsch, err := thema.BindType(cursch, &Resource{}) - if err != nil { - // Should be unreachable, modulo bugs in the Thema->Go code generator - return nil, err - } - - k.jcodec = vmux.NewJSONCodec("role.json") - k.lin = tsch.ConvergentLineage() - k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) - return k, nil -} - -// ConvergentLineage returns the same [thema.Lineage] as Lineage, but bound (see [thema.BindType]) -// to the the Role [Resource] type generated from the current schema, v0.0. -func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*Resource] { - return k.lin -} - -// JSONValueMux is a version multiplexer that maps a []byte containing JSON data -// at any schematized dashboard version to an instance of Role [Resource]. -// -// Validation and translation errors emitted from this func will identify the -// input bytes as "dashboard.json". -// -// This is a thin wrapper around Thema's [vmux.ValueMux]. -func (k *Kind) JSONValueMux(b []byte) (*Resource, thema.TranslationLacunas, error) { - return k.valmux(b) -} diff --git a/pkg/kinds/role/role_metadata_gen.go b/pkg/kinds/role/role_metadata_gen.go index b64111b964448..21bd45d33623b 100644 --- a/pkg/kinds/role/role_metadata_gen.go +++ b/pkg/kinds/role/role_metadata_gen.go @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoResourceTypes +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/role/role_status_gen.go b/pkg/kinds/role/role_status_gen.go index f5088b2c2dec7..ff9f44bdc5e0d 100644 --- a/pkg/kinds/role/role_status_gen.go +++ b/pkg/kinds/role/role_status_gen.go @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoResourceTypes +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/rolebinding/rolebinding_gen.go b/pkg/kinds/rolebinding/rolebinding_gen.go index c5177cafb9aa8..216bd3a952508 100644 --- a/pkg/kinds/rolebinding/rolebinding_gen.go +++ b/pkg/kinds/rolebinding/rolebinding_gen.go @@ -3,13 +3,15 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoTypesJenny +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. package rolebinding import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/grafana/grafana/pkg/kinds" ) @@ -19,9 +21,11 @@ type K8sResource = kinds.GrafanaResource[Spec, Status] // NewResource creates a new instance of the resource with a given name (UID) func NewK8sResource(name string, s *Spec) K8sResource { return K8sResource{ - Kind: "RoleBinding", - APIVersion: "v0-0-alpha", - Metadata: kinds.GrafanaResourceMetadata{ + TypeMeta: v1.TypeMeta{ + Kind: "RoleBinding", + APIVersion: "v0-0-alpha", + }, + ObjectMeta: v1.ObjectMeta{ Name: name, Annotations: make(map[string]string), Labels: make(map[string]string), diff --git a/pkg/kinds/rolebinding/rolebinding_kind_gen.go b/pkg/kinds/rolebinding/rolebinding_kind_gen.go deleted file mode 100644 index 603b1d9674711..0000000000000 --- a/pkg/kinds/rolebinding/rolebinding_kind_gen.go +++ /dev/null @@ -1,79 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// CoreKindJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package rolebinding - -import ( - "github.com/grafana/kindsys" - "github.com/grafana/thema" - "github.com/grafana/thema/vmux" - - "github.com/grafana/grafana/pkg/cuectx" -) - -// rootrel is the relative path from the grafana repository root to the -// directory containing the .cue files in which this kind is defined. Necessary -// for runtime errors related to the definition and/or lineage to provide -// a real path to the correct .cue file. -const rootrel string = "kinds/rolebinding" - -// TODO standard generated docs -type Kind struct { - kindsys.Core - lin thema.ConvergentLineage[*Resource] - jcodec vmux.Codec - valmux vmux.ValueMux[*Resource] -} - -// type guard - ensure generated Kind type satisfies the kindsys.Core interface -var _ kindsys.Core = &Kind{} - -// TODO standard generated docs -func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { - def, err := cuectx.LoadCoreKindDef(rootrel, rt.Context(), nil) - if err != nil { - return nil, err - } - - k := &Kind{} - k.Core, err = kindsys.BindCore(rt, def, opts...) - if err != nil { - return nil, err - } - // Get the thema.Schema that the meta says is in the current version (which - // codegen ensures is always the latest) - cursch := thema.SchemaP(k.Core.Lineage(), def.Properties.CurrentVersion) - tsch, err := thema.BindType(cursch, &Resource{}) - if err != nil { - // Should be unreachable, modulo bugs in the Thema->Go code generator - return nil, err - } - - k.jcodec = vmux.NewJSONCodec("rolebinding.json") - k.lin = tsch.ConvergentLineage() - k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) - return k, nil -} - -// ConvergentLineage returns the same [thema.Lineage] as Lineage, but bound (see [thema.BindType]) -// to the the RoleBinding [Resource] type generated from the current schema, v0.0. -func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*Resource] { - return k.lin -} - -// JSONValueMux is a version multiplexer that maps a []byte containing JSON data -// at any schematized dashboard version to an instance of RoleBinding [Resource]. -// -// Validation and translation errors emitted from this func will identify the -// input bytes as "dashboard.json". -// -// This is a thin wrapper around Thema's [vmux.ValueMux]. -func (k *Kind) JSONValueMux(b []byte) (*Resource, thema.TranslationLacunas, error) { - return k.valmux(b) -} diff --git a/pkg/kinds/rolebinding/rolebinding_metadata_gen.go b/pkg/kinds/rolebinding/rolebinding_metadata_gen.go index 841dba676228f..2c2f4b28343ee 100644 --- a/pkg/kinds/rolebinding/rolebinding_metadata_gen.go +++ b/pkg/kinds/rolebinding/rolebinding_metadata_gen.go @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoResourceTypes +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/rolebinding/rolebinding_status_gen.go b/pkg/kinds/rolebinding/rolebinding_status_gen.go index 18430702595e9..1b4552df63d14 100644 --- a/pkg/kinds/rolebinding/rolebinding_status_gen.go +++ b/pkg/kinds/rolebinding/rolebinding_status_gen.go @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoResourceTypes +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/team/team_gen.go b/pkg/kinds/team/team_gen.go index c09d04fdf369d..155f91d0a5416 100644 --- a/pkg/kinds/team/team_gen.go +++ b/pkg/kinds/team/team_gen.go @@ -3,13 +3,15 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoTypesJenny +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. package team import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/grafana/grafana/pkg/kinds" ) @@ -19,9 +21,11 @@ type K8sResource = kinds.GrafanaResource[Spec, Status] // NewResource creates a new instance of the resource with a given name (UID) func NewK8sResource(name string, s *Spec) K8sResource { return K8sResource{ - Kind: "Team", - APIVersion: "v0-0-alpha", - Metadata: kinds.GrafanaResourceMetadata{ + TypeMeta: v1.TypeMeta{ + Kind: "Team", + APIVersion: "v0-0-alpha", + }, + ObjectMeta: v1.ObjectMeta{ Name: name, Annotations: make(map[string]string), Labels: make(map[string]string), diff --git a/pkg/kinds/team/team_kind_gen.go b/pkg/kinds/team/team_kind_gen.go deleted file mode 100644 index 8fc6e091565c9..0000000000000 --- a/pkg/kinds/team/team_kind_gen.go +++ /dev/null @@ -1,79 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// CoreKindJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package team - -import ( - "github.com/grafana/kindsys" - "github.com/grafana/thema" - "github.com/grafana/thema/vmux" - - "github.com/grafana/grafana/pkg/cuectx" -) - -// rootrel is the relative path from the grafana repository root to the -// directory containing the .cue files in which this kind is defined. Necessary -// for runtime errors related to the definition and/or lineage to provide -// a real path to the correct .cue file. -const rootrel string = "kinds/team" - -// TODO standard generated docs -type Kind struct { - kindsys.Core - lin thema.ConvergentLineage[*Resource] - jcodec vmux.Codec - valmux vmux.ValueMux[*Resource] -} - -// type guard - ensure generated Kind type satisfies the kindsys.Core interface -var _ kindsys.Core = &Kind{} - -// TODO standard generated docs -func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { - def, err := cuectx.LoadCoreKindDef(rootrel, rt.Context(), nil) - if err != nil { - return nil, err - } - - k := &Kind{} - k.Core, err = kindsys.BindCore(rt, def, opts...) - if err != nil { - return nil, err - } - // Get the thema.Schema that the meta says is in the current version (which - // codegen ensures is always the latest) - cursch := thema.SchemaP(k.Core.Lineage(), def.Properties.CurrentVersion) - tsch, err := thema.BindType(cursch, &Resource{}) - if err != nil { - // Should be unreachable, modulo bugs in the Thema->Go code generator - return nil, err - } - - k.jcodec = vmux.NewJSONCodec("team.json") - k.lin = tsch.ConvergentLineage() - k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) - return k, nil -} - -// ConvergentLineage returns the same [thema.Lineage] as Lineage, but bound (see [thema.BindType]) -// to the the Team [Resource] type generated from the current schema, v0.0. -func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*Resource] { - return k.lin -} - -// JSONValueMux is a version multiplexer that maps a []byte containing JSON data -// at any schematized dashboard version to an instance of Team [Resource]. -// -// Validation and translation errors emitted from this func will identify the -// input bytes as "dashboard.json". -// -// This is a thin wrapper around Thema's [vmux.ValueMux]. -func (k *Kind) JSONValueMux(b []byte) (*Resource, thema.TranslationLacunas, error) { - return k.valmux(b) -} diff --git a/pkg/kinds/team/team_metadata_gen.go b/pkg/kinds/team/team_metadata_gen.go index d4acb2f00d8f8..2709fc43743be 100644 --- a/pkg/kinds/team/team_metadata_gen.go +++ b/pkg/kinds/team/team_metadata_gen.go @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoResourceTypes +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/team/team_status_gen.go b/pkg/kinds/team/team_status_gen.go index 5983d8f260ff4..d9e9c6535fde0 100644 --- a/pkg/kinds/team/team_status_gen.go +++ b/pkg/kinds/team/team_status_gen.go @@ -3,7 +3,7 @@ // Generated by: // kinds/gen.go // Using jennies: -// GoResourceTypes +// K8ResourcesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kindsysreport/attributes.go b/pkg/kindsysreport/attributes.go deleted file mode 100644 index c51a4b3df740a..0000000000000 --- a/pkg/kindsysreport/attributes.go +++ /dev/null @@ -1,74 +0,0 @@ -package kindsysreport - -import ( - "cuelang.org/go/cue" -) - -type AttributeWalker struct { - seen map[cue.Value]bool - count map[string]int -} - -func (w *AttributeWalker) Count(sch cue.Value, attrs ...string) map[string]int { - w.seen = make(map[cue.Value]bool) - w.count = make(map[string]int) - - for _, attr := range attrs { - w.count[attr] = 0 - } - - w.walk(cue.MakePath(), sch) - return w.count -} - -func (w *AttributeWalker) walk(p cue.Path, v cue.Value) { - if w.seen[v] { - return - } - - w.seen[v] = true - - for attr := range w.count { - if found := v.Attribute(attr); found.Err() == nil { - w.count[attr]++ - } - } - - // nolint: exhaustive - switch v.Kind() { - case cue.StructKind: - // If current cue.Value is a reference to another - // definition, we don't want to traverse its fields - // individually, because we'll do so for the actual def. - if v != cue.Dereference(v) { - return - } - - iter, err := v.Fields(cue.All()) - if err != nil { - panic(err) - } - - for iter.Next() { - w.walk(appendPath(p, iter.Selector()), iter.Value()) - } - if lv := v.LookupPath(cue.MakePath(cue.AnyString)); lv.Exists() { - w.walk(appendPath(p, cue.AnyString), lv) - } - case cue.ListKind: - list, err := v.List() - if err != nil { - panic(err) - } - for i := 0; list.Next(); i++ { - w.walk(appendPath(p, cue.Index(i)), list.Value()) - } - if lv := v.LookupPath(cue.MakePath(cue.AnyIndex)); lv.Exists() { - w.walk(appendPath(p, cue.AnyString), lv) - } - } -} - -func appendPath(p cue.Path, sel cue.Selector) cue.Path { - return cue.MakePath(append(p.Selectors(), sel)...) -} diff --git a/pkg/kindsysreport/codegen/report.go b/pkg/kindsysreport/codegen/report.go deleted file mode 100644 index 433c5d43c64db..0000000000000 --- a/pkg/kindsysreport/codegen/report.go +++ /dev/null @@ -1,391 +0,0 @@ -//go:build ignore -// +build ignore - -//go:generate go run report.go - -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/url" - "os" - "path/filepath" - "reflect" - "sort" - "strings" - - "cuelang.org/go/cue" - "github.com/grafana/codejen" - "github.com/grafana/kindsys" - "github.com/grafana/thema" - - "github.com/grafana/grafana/pkg/kindsysreport" - "github.com/grafana/grafana/pkg/plugins/pfs/corelist" - "github.com/grafana/grafana/pkg/plugins/plugindef" - "github.com/grafana/grafana/pkg/registry/corekind" -) - -const ( - // Program's output - reportFileName = "report.json" - - // External references - repoBaseURL = "https://github.com/grafana/grafana/tree/main" - docsBaseURL = "https://grafana.com/docs/grafana/next/developers/kinds" - - // Local references - coreTSPath = "packages/grafana-schema/src/raw/%s/%s/%s_types.gen.ts" - coreGoPath = "pkg/kinds/%s" - coreCUEPath = "kinds/%s/%s_kind.cue" - - composableTSPath = "public/app/plugins/%s/%s/%s.gen.ts" - composableGoPath = "pkg/tsdb/%s/kinds/%s/types_%s_gen.go" - composableCUEPath = "public/app/plugins/%s/%s/%s.cue" -) - -func main() { - report := buildKindStateReport() - reportJSON := elsedie(json.MarshalIndent(report, "", " "))("error generating json output") - - file := codejen.NewFile(reportFileName, reportJSON, reportJenny{}) - filesystem := elsedie(file.ToFS())("error building in-memory file system") - - if _, set := os.LookupEnv("CODEGEN_VERIFY"); set { - if err := filesystem.Verify(context.Background(), ""); err != nil { - die(fmt.Errorf("generated code is out of sync with inputs:\n%s\nrun `make gen-cue` to regenerate", err)) - } - } else if err := filesystem.Write(context.Background(), ""); err != nil { - die(fmt.Errorf("error while writing generated code to disk:\n%s", err)) - } -} - -// static list of planned core kinds so that we can inject ones that -// haven't been started on yet as "planned" -var plannedCoreKinds = []string{ - "Dashboard", - "Playlist", - "Team", - "User", - "Folder", - "DataSource", - "APIKey", - "ServiceAccount", - "Thumb", - "Query", - "QueryHistory", -} - -type KindLinks struct { - Schema string - Go string - Ts string - Docs string -} - -type Kind struct { - kindsys.SomeKindProperties - Category string - Links KindLinks - GrafanaMaturityCount int - CodeOwners []string -} - -// MarshalJSON is overwritten to marshal -// kindsys.SomeKindProperties at root level. -func (k Kind) MarshalJSON() ([]byte, error) { - b, err := json.Marshal(k.SomeKindProperties) - if err != nil { - return nil, err - } - - var m map[string]any - if err = json.Unmarshal(b, &m); err != nil { - return nil, err - } - - m["category"] = k.Category - m["grafanaMaturityCount"] = k.GrafanaMaturityCount - - if len(k.CodeOwners) == 0 { - m["codeowners"] = []string{} - } else { - m["codeowners"] = k.CodeOwners - } - - m["links"] = map[string]string{} - for _, ref := range []string{"Schema", "Go", "Ts", "Docs"} { - refVal := reflect.ValueOf(k.Links).FieldByName(ref).String() - if len(refVal) > 0 { - m["links"].(map[string]string)[toCamelCase(ref)] = refVal - } else { - m["links"].(map[string]string)[toCamelCase(ref)] = "n/a" - } - } - - return json.Marshal(m) -} - -type KindStateReport struct { - Kinds map[string]Kind `json:"kinds"` - Dimensions map[string]Dimension `json:"dimensions"` -} - -func (r *KindStateReport) add(k Kind) { - kName := k.Common().MachineName - - r.Kinds[kName] = k - r.Dimensions["maturity"][k.Common().Maturity.String()].add(kName) - r.Dimensions["category"][k.Category].add(kName) -} - -type Dimension map[string]*DimensionValue - -type DimensionValue struct { - Name string `json:"name"` - Items []string `json:"items"` - Count int `json:"count"` -} - -func (dv *DimensionValue) add(s string) { - dv.Count++ - dv.Items = append(dv.Items, s) -} - -// emptyKindStateReport is used to ensure certain -// dimension values are present (even if empty) in -// the final report. -func emptyKindStateReport() *KindStateReport { - return &KindStateReport{ - Kinds: make(map[string]Kind), - Dimensions: map[string]Dimension{ - "maturity": { - "planned": emptyDimensionValue("planned"), - "merged": emptyDimensionValue("merged"), - "experimental": emptyDimensionValue("experimental"), - "stable": emptyDimensionValue("stable"), - "mature": emptyDimensionValue("mature"), - }, - "category": { - "core": emptyDimensionValue("core"), - "composable": emptyDimensionValue("composable"), - }, - }, - } -} - -func emptyDimensionValue(name string) *DimensionValue { - return &DimensionValue{ - Name: name, - Items: make([]string, 0), - Count: 0, - } -} - -func buildKindStateReport() *KindStateReport { - r := emptyKindStateReport() - b := corekind.NewBase(nil) - - groot := filepath.Join(elsedie(os.Getwd())("cannot get cwd"), "..", "..", "..") - of := elsedie(kindsysreport.NewCodeOwnersFinder(groot))("cannot parse .github/codeowners") - - seen := make(map[string]bool) - for _, k := range b.All() { - seen[k.Props().Common().Name] = true - lin := k.Lineage() - links := buildCoreLinks(lin, k.Def().Properties) - r.add(Kind{ - SomeKindProperties: k.Props(), - Category: "core", - Links: links, - GrafanaMaturityCount: grafanaMaturityAttrCount(lin.Latest().Underlying()), - CodeOwners: findCodeOwners(of, links), - }) - } - - for _, kn := range plannedCoreKinds { - if seen[kn] { - continue - } - - r.add(Kind{ - SomeKindProperties: kindsys.CoreProperties{ - CommonProperties: kindsys.CommonProperties{ - Name: kn, - PluralName: kn + "s", - MachineName: machinize(kn), - PluralMachineName: machinize(kn) + "s", - Maturity: "planned", - }, - }, - Category: "core", - }) - } - - all := kindsys.SchemaInterfaces(nil) - for _, pp := range corelist.New(nil) { - for _, si := range all { - if ck, has := pp.ComposableKinds[si.Name()]; has { - links := buildComposableLinks(pp.Properties, ck.Def().Properties) - r.add(Kind{ - SomeKindProperties: ck.Props(), - Category: "composable", - Links: links, - GrafanaMaturityCount: grafanaMaturityAttrCount(ck.Lineage().Latest().Underlying()), - CodeOwners: findCodeOwners(of, links), - }) - } else if may := si.Should(string(pp.Properties.Type)); may { - n := plugindef.DerivePascalName(pp.Properties) + si.Name() - ck := kindsys.ComposableProperties{ - SchemaInterface: si.Name(), - CommonProperties: kindsys.CommonProperties{ - Name: n, - PluralName: n + "s", - MachineName: machinize(n), - PluralMachineName: machinize(n) + "s", - LineageIsGroup: si.IsGroup(), - Maturity: "planned", - }, - } - r.add(Kind{ - SomeKindProperties: ck, - Category: "composable", - }) - } - } - } - - for _, d := range r.Dimensions { - for _, dv := range d { - sort.Strings(dv.Items) - } - } - - return r -} - -func buildCoreLinks(lin thema.Lineage, cp kindsys.CoreProperties) KindLinks { - const category = "core" - vpath := fmt.Sprintf("v%v", lin.Latest().Version()[0]) - if cp.Maturity.Less(kindsys.MaturityStable) { - vpath = "x" - } - - return KindLinks{ - Schema: elsedie(url.JoinPath(repoBaseURL, fmt.Sprintf(coreCUEPath, cp.MachineName, cp.MachineName)))("cannot build schema link"), - Go: elsedie(url.JoinPath(repoBaseURL, fmt.Sprintf(coreGoPath, cp.MachineName)))("cannot build go link"), - Ts: elsedie(url.JoinPath(repoBaseURL, fmt.Sprintf(coreTSPath, cp.MachineName, vpath, cp.MachineName)))("cannot build ts link"), - Docs: elsedie(url.JoinPath(docsBaseURL, category, cp.MachineName, "schema-reference"))("cannot build docs link"), - } -} - -// used to map names for those plugins that aren't following -// naming conventions, like 'annonlist' which comes from "Annotations list". -var irregularPluginNames = map[string]string{ - // Panel - "alertgroups": "alertGroups", - "annotationslist": "annolist", - "dashboardlist": "dashlist", - "nodegraph": "nodeGraph", - "statetimeline": "state-timeline", - "statushistory": "status-history", - "tableold": "table-old", - // Datasource - "googlecloudmonitoring": "cloud-monitoring", - "azuremonitor": "grafana-azure-monitor-datasource", - "microsoftsqlserver": "mssql", - "postgresql": "postgres", - "testdata": "grafana-testdata-datasource", -} - -func buildComposableLinks(pp plugindef.PluginDef, cp kindsys.ComposableProperties) KindLinks { - const category = "composable" - schemaInterface := strings.ToLower(cp.SchemaInterface) - - pName := strings.Replace(cp.MachineName, schemaInterface, "", 1) - if irr, ok := irregularPluginNames[pName]; ok { - pName = irr - } - - var goLink string - if pp.Backend != nil && *pp.Backend { - goLink = elsedie(url.JoinPath(repoBaseURL, fmt.Sprintf(composableGoPath, pName, schemaInterface, schemaInterface)))("cannot build go link") - } - - return KindLinks{ - Schema: elsedie(url.JoinPath(repoBaseURL, fmt.Sprintf(composableCUEPath, string(pp.Type), pName, schemaInterface)))("cannot build schema link"), - Go: goLink, - Ts: elsedie(url.JoinPath(repoBaseURL, fmt.Sprintf(composableTSPath, string(pp.Type), pName, schemaInterface)))("cannot build ts link"), - Docs: elsedie(url.JoinPath(docsBaseURL, category, cp.MachineName, "schema-reference"))("cannot build docs link"), - } -} - -func grafanaMaturityAttrCount(sch cue.Value) int { - const attr = "grafanamaturity" - aw := new(kindsysreport.AttributeWalker) - return aw.Count(sch, attr)[attr] -} - -func findCodeOwners(of kindsysreport.CodeOwnersFinder, links KindLinks) []string { - owners := elsedie(of.FindFor([]string{ - toLocalPath(links.Schema), - toLocalPath(links.Go), - toLocalPath(links.Ts), - }...))("cannot find code owners") - - sort.Strings(owners) - return owners -} - -func machinize(s string) string { - return strings.Map(func(r rune) rune { - switch { - case r >= 'a' && r <= 'z': - fallthrough - case r >= '0' && r <= '9': - fallthrough - case r == '_': - return r - case r >= 'A' && r <= 'Z': - return r + 32 - case r == '-': - return '_' - default: - return -1 - } - }, s) -} - -func toCamelCase(s string) string { - return strings.ToLower(string(s[0])) + s[1:] -} - -func toLocalPath(s string) string { - return strings.Replace(s, repoBaseURL+"/", "", 1) -} - -type reportJenny struct{} - -func (reportJenny) JennyName() string { - return "ReportJenny" -} - -func elsedie[T any](t T, err error) func(msg string) T { - if err != nil { - return func(msg string) T { - fmt.Fprintf(os.Stderr, "%s: %s\n", msg, err) - os.Exit(1) - return t - } - } - - return func(msg string) T { - return t - } -} - -func die(err error) { - fmt.Fprint(os.Stderr, err, "\n") - os.Exit(1) -} diff --git a/pkg/kindsysreport/codegen/report.json b/pkg/kindsysreport/codegen/report.json deleted file mode 100644 index 70ce9698f4844..0000000000000 --- a/pkg/kindsysreport/codegen/report.json +++ /dev/null @@ -1,2338 +0,0 @@ -{ - "kinds": { - "accesspolicy": { - "category": "core", - "codeowners": [ - "grafana/grafana-as-code", - "grafana/grafana-frontend-platform", - "grafana/plugins-platform-frontend" - ], - "crd": { - "dummySchema": false, - "group": "accesspolicy.core.grafana.com", - "scope": "Namespaced" - }, - "currentVersion": [ - 0, - 0 - ], - "description": "Access rules for a scope+role. NOTE there is a unique constraint on role+scope", - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/core/accesspolicy/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/kinds/accesspolicy", - "schema": "https://github.com/grafana/grafana/tree/main/kinds/accesspolicy/accesspolicy_kind.cue", - "ts": "https://github.com/grafana/grafana/tree/main/packages/grafana-schema/src/raw/accesspolicy/x/accesspolicy_types.gen.ts" - }, - "machineName": "accesspolicy", - "maturity": "merged", - "name": "AccessPolicy", - "pluralMachineName": "accesspolicies", - "pluralName": "AccessPolicies" - }, - "alertgroupspanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/alerting-frontend" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/alertgroupspanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/alertGroups/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/alertGroups/panelcfg.gen.ts" - }, - "machineName": "alertgroupspanelcfg", - "maturity": "merged", - "name": "AlertGroupsPanelCfg", - "pluralMachineName": "alertgroupspanelcfgs", - "pluralName": "AlertGroupsPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "alertlistpanelcfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "alertlistpanelcfg", - "maturity": "planned", - "name": "AlertListPanelCfg", - "pluralMachineName": "alertlistpanelcfgs", - "pluralName": "AlertListPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "alertmanagerdataquery": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "alertmanagerdataquery", - "maturity": "planned", - "name": "AlertmanagerDataQuery", - "pluralMachineName": "alertmanagerdataquerys", - "pluralName": "AlertmanagerDataQuerys", - "schemaInterface": "DataQuery" - }, - "alertmanagerdatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "alertmanagerdatasourcecfg", - "maturity": "planned", - "name": "AlertmanagerDataSourceCfg", - "pluralMachineName": "alertmanagerdatasourcecfgs", - "pluralName": "AlertmanagerDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "annotationslistpanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/grafana-frontend-platform" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/annotationslistpanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/annolist/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/annolist/panelcfg.gen.ts" - }, - "machineName": "annotationslistpanelcfg", - "maturity": "experimental", - "name": "AnnotationsListPanelCfg", - "pluralMachineName": "annotationslistpanelcfgs", - "pluralName": "AnnotationsListPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "apikey": { - "category": "core", - "codeowners": [], - "crd": { - "dummySchema": false, - "group": "", - "scope": "" - }, - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "apikey", - "maturity": "planned", - "name": "APIKey", - "pluralMachineName": "apikeys", - "pluralName": "APIKeys" - }, - "azuremonitordataquery": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/azuremonitordataquery/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/tsdb/grafana-azure-monitor-datasource/kinds/dataquery/types_dataquery_gen.go", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/grafana-azure-monitor-datasource/dataquery.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/grafana-azure-monitor-datasource/dataquery.gen.ts" - }, - "machineName": "azuremonitordataquery", - "maturity": "merged", - "name": "AzureMonitorDataQuery", - "pluralMachineName": "azuremonitordataquerys", - "pluralName": "AzureMonitorDataQuerys", - "schemaInterface": "DataQuery" - }, - "azuremonitordatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "azuremonitordatasourcecfg", - "maturity": "planned", - "name": "AzureMonitorDataSourceCfg", - "pluralMachineName": "azuremonitordatasourcecfgs", - "pluralName": "AzureMonitorDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "barchartpanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/dataviz-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/barchartpanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/barchart/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/barchart/panelcfg.gen.ts" - }, - "machineName": "barchartpanelcfg", - "maturity": "experimental", - "name": "BarChartPanelCfg", - "pluralMachineName": "barchartpanelcfgs", - "pluralName": "BarChartPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "bargaugepanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/dataviz-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/bargaugepanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/bargauge/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/bargauge/panelcfg.gen.ts" - }, - "machineName": "bargaugepanelcfg", - "maturity": "experimental", - "name": "BarGaugePanelCfg", - "pluralMachineName": "bargaugepanelcfgs", - "pluralName": "BarGaugePanelCfgs", - "schemaInterface": "PanelCfg" - }, - "candlestickpanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/dataviz-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/candlestickpanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/candlestick/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/candlestick/panelcfg.gen.ts" - }, - "machineName": "candlestickpanelcfg", - "maturity": "experimental", - "name": "CandlestickPanelCfg", - "pluralMachineName": "candlestickpanelcfgs", - "pluralName": "CandlestickPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "canvaspanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/dataviz-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/canvaspanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/canvas/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/canvas/panelcfg.gen.ts" - }, - "machineName": "canvaspanelcfg", - "maturity": "experimental", - "name": "CanvasPanelCfg", - "pluralMachineName": "canvaspanelcfgs", - "pluralName": "CanvasPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "cloudwatchdataquery": { - "category": "composable", - "codeowners": [ - "grafana/aws-datasources" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/cloudwatchdataquery/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/tsdb/cloudwatch/kinds/dataquery/types_dataquery_gen.go", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/cloudwatch/dataquery.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts" - }, - "machineName": "cloudwatchdataquery", - "maturity": "experimental", - "name": "CloudWatchDataQuery", - "pluralMachineName": "cloudwatchdataquerys", - "pluralName": "CloudWatchDataQuerys", - "schemaInterface": "DataQuery" - }, - "cloudwatchdatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "cloudwatchdatasourcecfg", - "maturity": "planned", - "name": "CloudWatchDataSourceCfg", - "pluralMachineName": "cloudwatchdatasourcecfgs", - "pluralName": "CloudWatchDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "dashboard": { - "category": "core", - "codeowners": [ - "grafana/grafana-as-code", - "grafana/grafana-frontend-platform", - "grafana/plugins-platform-frontend" - ], - "crd": { - "dummySchema": true, - "group": "dashboard.core.grafana.com", - "scope": "Namespaced" - }, - "currentVersion": [ - 0, - 0 - ], - "description": "A Grafana dashboard.", - "grafanaMaturityCount": 103, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/core/dashboard/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/kinds/dashboard", - "schema": "https://github.com/grafana/grafana/tree/main/kinds/dashboard/dashboard_kind.cue", - "ts": "https://github.com/grafana/grafana/tree/main/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts" - }, - "machineName": "dashboard", - "maturity": "experimental", - "name": "Dashboard", - "pluralMachineName": "dashboards", - "pluralName": "Dashboards" - }, - "dashboarddataquery": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "dashboarddataquery", - "maturity": "planned", - "name": "DashboardDataQuery", - "pluralMachineName": "dashboarddataquerys", - "pluralName": "DashboardDataQuerys", - "schemaInterface": "DataQuery" - }, - "dashboarddatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "dashboarddatasourcecfg", - "maturity": "planned", - "name": "DashboardDataSourceCfg", - "pluralMachineName": "dashboarddatasourcecfgs", - "pluralName": "DashboardDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "dashboardlistpanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/grafana-frontend-platform" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/dashboardlistpanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/dashlist/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/dashlist/panelcfg.gen.ts" - }, - "machineName": "dashboardlistpanelcfg", - "maturity": "experimental", - "name": "DashboardListPanelCfg", - "pluralMachineName": "dashboardlistpanelcfgs", - "pluralName": "DashboardListPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "datagridpanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/grafana-bi-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/datagridpanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/datagrid/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/datagrid/panelcfg.gen.ts" - }, - "machineName": "datagridpanelcfg", - "maturity": "experimental", - "name": "DatagridPanelCfg", - "pluralMachineName": "datagridpanelcfgs", - "pluralName": "DatagridPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "datasource": { - "category": "core", - "codeowners": [], - "crd": { - "dummySchema": false, - "group": "", - "scope": "" - }, - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "datasource", - "maturity": "planned", - "name": "DataSource", - "pluralMachineName": "datasources", - "pluralName": "DataSources" - }, - "debugpanelcfg": { - "category": "composable", - "codeowners": [ - "ryantxu" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/debugpanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/debug/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/debug/panelcfg.gen.ts" - }, - "machineName": "debugpanelcfg", - "maturity": "experimental", - "name": "DebugPanelCfg", - "pluralMachineName": "debugpanelcfgs", - "pluralName": "DebugPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "elasticsearchdataquery": { - "category": "composable", - "codeowners": [ - "grafana/observability-logs" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/elasticsearchdataquery/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/tsdb/elasticsearch/kinds/dataquery/types_dataquery_gen.go", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/elasticsearch/dataquery.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/elasticsearch/dataquery.gen.ts" - }, - "machineName": "elasticsearchdataquery", - "maturity": "experimental", - "name": "ElasticsearchDataQuery", - "pluralMachineName": "elasticsearchdataquerys", - "pluralName": "ElasticsearchDataQuerys", - "schemaInterface": "DataQuery" - }, - "elasticsearchdatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "elasticsearchdatasourcecfg", - "maturity": "planned", - "name": "ElasticsearchDataSourceCfg", - "pluralMachineName": "elasticsearchdatasourcecfgs", - "pluralName": "ElasticsearchDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "flamegraphpanelcfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "flamegraphpanelcfg", - "maturity": "planned", - "name": "FlameGraphPanelCfg", - "pluralMachineName": "flamegraphpanelcfgs", - "pluralName": "FlameGraphPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "folder": { - "category": "core", - "codeowners": [], - "crd": { - "dummySchema": false, - "group": "", - "scope": "" - }, - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "folder", - "maturity": "planned", - "name": "Folder", - "pluralMachineName": "folders", - "pluralName": "Folders" - }, - "gaugepanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/dataviz-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/gaugepanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/gauge/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/gauge/panelcfg.gen.ts" - }, - "machineName": "gaugepanelcfg", - "maturity": "experimental", - "name": "GaugePanelCfg", - "pluralMachineName": "gaugepanelcfgs", - "pluralName": "GaugePanelCfgs", - "schemaInterface": "PanelCfg" - }, - "geomappanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/dataviz-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/geomappanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/geomap/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/geomap/panelcfg.gen.ts" - }, - "machineName": "geomappanelcfg", - "maturity": "experimental", - "name": "GeomapPanelCfg", - "pluralMachineName": "geomappanelcfgs", - "pluralName": "GeomapPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "gettingstartedpanelcfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "gettingstartedpanelcfg", - "maturity": "planned", - "name": "GettingStartedPanelCfg", - "pluralMachineName": "gettingstartedpanelcfgs", - "pluralName": "GettingStartedPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "googlecloudmonitoringdataquery": { - "category": "composable", - "codeowners": [ - "grafana/partner-datasources" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/googlecloudmonitoringdataquery/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/tsdb/cloud-monitoring/kinds/dataquery/types_dataquery_gen.go", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/cloud-monitoring/dataquery.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/cloud-monitoring/dataquery.gen.ts" - }, - "machineName": "googlecloudmonitoringdataquery", - "maturity": "merged", - "name": "GoogleCloudMonitoringDataQuery", - "pluralMachineName": "googlecloudmonitoringdataquerys", - "pluralName": "GoogleCloudMonitoringDataQuerys", - "schemaInterface": "DataQuery" - }, - "googlecloudmonitoringdatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "googlecloudmonitoringdatasourcecfg", - "maturity": "planned", - "name": "GoogleCloudMonitoringDataSourceCfg", - "pluralMachineName": "googlecloudmonitoringdatasourcecfgs", - "pluralName": "GoogleCloudMonitoringDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "grafanadataquery": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "grafanadataquery", - "maturity": "planned", - "name": "GrafanaDataQuery", - "pluralMachineName": "grafanadataquerys", - "pluralName": "GrafanaDataQuerys", - "schemaInterface": "DataQuery" - }, - "grafanadatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "grafanadatasourcecfg", - "maturity": "planned", - "name": "GrafanaDataSourceCfg", - "pluralMachineName": "grafanadatasourcecfgs", - "pluralName": "GrafanaDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "grafanapyroscopedataquery": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/grafanapyroscopedataquery/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/tsdb/grafanapyroscope/kinds/dataquery/types_dataquery_gen.go", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/grafanapyroscope/dataquery.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/grafanapyroscope/dataquery.gen.ts" - }, - "machineName": "grafanapyroscopedataquery", - "maturity": "experimental", - "name": "GrafanaPyroscopeDataQuery", - "pluralMachineName": "grafanapyroscopedataquerys", - "pluralName": "GrafanaPyroscopeDataQuerys", - "schemaInterface": "DataQuery" - }, - "grafanapyroscopedatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "grafanapyroscopedatasourcecfg", - "maturity": "planned", - "name": "GrafanaPyroscopeDataSourceCfg", - "pluralMachineName": "grafanapyroscopedatasourcecfgs", - "pluralName": "GrafanaPyroscopeDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "graphitedataquery": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "graphitedataquery", - "maturity": "planned", - "name": "GraphiteDataQuery", - "pluralMachineName": "graphitedataquerys", - "pluralName": "GraphiteDataQuerys", - "schemaInterface": "DataQuery" - }, - "graphitedatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "graphitedatasourcecfg", - "maturity": "planned", - "name": "GraphiteDataSourceCfg", - "pluralMachineName": "graphitedatasourcecfgs", - "pluralName": "GraphiteDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "grapholdpanelcfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "grapholdpanelcfg", - "maturity": "planned", - "name": "GraphOldPanelCfg", - "pluralMachineName": "grapholdpanelcfgs", - "pluralName": "GraphOldPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "heatmappanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/dataviz-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/heatmappanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/heatmap/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/heatmap/panelcfg.gen.ts" - }, - "machineName": "heatmappanelcfg", - "maturity": "merged", - "name": "HeatmapPanelCfg", - "pluralMachineName": "heatmappanelcfgs", - "pluralName": "HeatmapPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "histogrampanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/dataviz-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/histogrampanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/histogram/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/histogram/panelcfg.gen.ts" - }, - "machineName": "histogrampanelcfg", - "maturity": "experimental", - "name": "HistogramPanelCfg", - "pluralMachineName": "histogrampanelcfgs", - "pluralName": "HistogramPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "jaegerdataquery": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "jaegerdataquery", - "maturity": "planned", - "name": "JaegerDataQuery", - "pluralMachineName": "jaegerdataquerys", - "pluralName": "JaegerDataQuerys", - "schemaInterface": "DataQuery" - }, - "jaegerdatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "jaegerdatasourcecfg", - "maturity": "planned", - "name": "JaegerDataSourceCfg", - "pluralMachineName": "jaegerdatasourcecfgs", - "pluralName": "JaegerDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "librarypanel": { - "category": "core", - "codeowners": [ - "grafana/grafana-as-code", - "grafana/grafana-frontend-platform", - "grafana/plugins-platform-frontend" - ], - "crd": { - "dummySchema": false, - "group": "librarypanel.core.grafana.com", - "scope": "Namespaced" - }, - "currentVersion": [ - 0, - 0 - ], - "description": "A standalone panel", - "grafanaMaturityCount": 19, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/core/librarypanel/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/kinds/librarypanel", - "schema": "https://github.com/grafana/grafana/tree/main/kinds/librarypanel/librarypanel_kind.cue", - "ts": "https://github.com/grafana/grafana/tree/main/packages/grafana-schema/src/raw/librarypanel/x/librarypanel_types.gen.ts" - }, - "machineName": "librarypanel", - "maturity": "experimental", - "name": "LibraryPanel", - "pluralMachineName": "librarypanels", - "pluralName": "LibraryPanels" - }, - "livepanelcfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "livepanelcfg", - "maturity": "planned", - "name": "LivePanelCfg", - "pluralMachineName": "livepanelcfgs", - "pluralName": "LivePanelCfgs", - "schemaInterface": "PanelCfg" - }, - "logspanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/observability-logs" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/logspanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/logs/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/logs/panelcfg.gen.ts" - }, - "machineName": "logspanelcfg", - "maturity": "experimental", - "name": "LogsPanelCfg", - "pluralMachineName": "logspanelcfgs", - "pluralName": "LogsPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "lokidataquery": { - "category": "composable", - "codeowners": [ - "grafana/observability-logs" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/lokidataquery/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/tsdb/loki/kinds/dataquery/types_dataquery_gen.go", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/loki/dataquery.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/loki/dataquery.gen.ts" - }, - "machineName": "lokidataquery", - "maturity": "experimental", - "name": "LokiDataQuery", - "pluralMachineName": "lokidataquerys", - "pluralName": "LokiDataQuerys", - "schemaInterface": "DataQuery" - }, - "lokidatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "lokidatasourcecfg", - "maturity": "planned", - "name": "LokiDataSourceCfg", - "pluralMachineName": "lokidatasourcecfgs", - "pluralName": "LokiDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "microsoftsqlserverdataquery": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "microsoftsqlserverdataquery", - "maturity": "planned", - "name": "MicrosoftSQLServerDataQuery", - "pluralMachineName": "microsoftsqlserverdataquerys", - "pluralName": "MicrosoftSQLServerDataQuerys", - "schemaInterface": "DataQuery" - }, - "microsoftsqlserverdatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "microsoftsqlserverdatasourcecfg", - "maturity": "planned", - "name": "MicrosoftSQLServerDataSourceCfg", - "pluralMachineName": "microsoftsqlserverdatasourcecfgs", - "pluralName": "MicrosoftSQLServerDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "mysqldataquery": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "mysqldataquery", - "maturity": "planned", - "name": "MySQLDataQuery", - "pluralMachineName": "mysqldataquerys", - "pluralName": "MySQLDataQuerys", - "schemaInterface": "DataQuery" - }, - "mysqldatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "mysqldatasourcecfg", - "maturity": "planned", - "name": "MySQLDataSourceCfg", - "pluralMachineName": "mysqldatasourcecfgs", - "pluralName": "MySQLDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "newspanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/grafana-frontend-platform" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/newspanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/news/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/news/panelcfg.gen.ts" - }, - "machineName": "newspanelcfg", - "maturity": "experimental", - "name": "NewsPanelCfg", - "pluralMachineName": "newspanelcfgs", - "pluralName": "NewsPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "nodegraphpanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/app-o11y-visualizations", - "grafana/observability-traces-and-profiling" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/nodegraphpanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/nodeGraph/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/nodeGraph/panelcfg.gen.ts" - }, - "machineName": "nodegraphpanelcfg", - "maturity": "experimental", - "name": "NodeGraphPanelCfg", - "pluralMachineName": "nodegraphpanelcfgs", - "pluralName": "NodeGraphPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "parcadataquery": { - "category": "composable", - "codeowners": [ - "grafana/observability-traces-and-profiling" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/parcadataquery/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/tsdb/parca/kinds/dataquery/types_dataquery_gen.go", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/parca/dataquery.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/parca/dataquery.gen.ts" - }, - "machineName": "parcadataquery", - "maturity": "experimental", - "name": "ParcaDataQuery", - "pluralMachineName": "parcadataquerys", - "pluralName": "ParcaDataQuerys", - "schemaInterface": "DataQuery" - }, - "parcadatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "parcadatasourcecfg", - "maturity": "planned", - "name": "ParcaDataSourceCfg", - "pluralMachineName": "parcadatasourcecfgs", - "pluralName": "ParcaDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "piechartpanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/dataviz-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/piechartpanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/piechart/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/piechart/panelcfg.gen.ts" - }, - "machineName": "piechartpanelcfg", - "maturity": "experimental", - "name": "PieChartPanelCfg", - "pluralMachineName": "piechartpanelcfgs", - "pluralName": "PieChartPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "playlist": { - "category": "core", - "codeowners": [], - "crd": { - "dummySchema": false, - "group": "", - "scope": "" - }, - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "playlist", - "maturity": "planned", - "name": "Playlist", - "pluralMachineName": "playlists", - "pluralName": "Playlists" - }, - "postgresqldataquery": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "postgresqldataquery", - "maturity": "planned", - "name": "PostgreSQLDataQuery", - "pluralMachineName": "postgresqldataquerys", - "pluralName": "PostgreSQLDataQuerys", - "schemaInterface": "DataQuery" - }, - "postgresqldatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "postgresqldatasourcecfg", - "maturity": "planned", - "name": "PostgreSQLDataSourceCfg", - "pluralMachineName": "postgresqldatasourcecfgs", - "pluralName": "PostgreSQLDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "preferences": { - "category": "core", - "codeowners": [ - "grafana/grafana-as-code", - "grafana/grafana-frontend-platform", - "grafana/plugins-platform-frontend" - ], - "crd": { - "dummySchema": false, - "group": "preferences.core.grafana.com", - "scope": "Namespaced" - }, - "currentVersion": [ - 0, - 0 - ], - "description": "The user or team frontend preferences", - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/core/preferences/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/kinds/preferences", - "schema": "https://github.com/grafana/grafana/tree/main/kinds/preferences/preferences_kind.cue", - "ts": "https://github.com/grafana/grafana/tree/main/packages/grafana-schema/src/raw/preferences/x/preferences_types.gen.ts" - }, - "machineName": "preferences", - "maturity": "merged", - "name": "Preferences", - "pluralMachineName": "preferences", - "pluralName": "Preferences" - }, - "prometheusdataquery": { - "category": "composable", - "codeowners": [ - "grafana/observability-metrics" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/prometheusdataquery/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/tsdb/prometheus/kinds/dataquery/types_dataquery_gen.go", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/prometheus/dataquery.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/prometheus/dataquery.gen.ts" - }, - "machineName": "prometheusdataquery", - "maturity": "experimental", - "name": "PrometheusDataQuery", - "pluralMachineName": "prometheusdataquerys", - "pluralName": "PrometheusDataQuerys", - "schemaInterface": "DataQuery" - }, - "prometheusdatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "prometheusdatasourcecfg", - "maturity": "planned", - "name": "PrometheusDataSourceCfg", - "pluralMachineName": "prometheusdatasourcecfgs", - "pluralName": "PrometheusDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "publicdashboard": { - "category": "core", - "codeowners": [ - "grafana/grafana-as-code", - "grafana/grafana-frontend-platform", - "grafana/plugins-platform-frontend" - ], - "crd": { - "dummySchema": false, - "group": "publicdashboard.core.grafana.com", - "scope": "Namespaced" - }, - "currentVersion": [ - 0, - 0 - ], - "description": "Public dashboard configuration", - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/core/publicdashboard/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/kinds/publicdashboard", - "schema": "https://github.com/grafana/grafana/tree/main/kinds/publicdashboard/publicdashboard_kind.cue", - "ts": "https://github.com/grafana/grafana/tree/main/packages/grafana-schema/src/raw/publicdashboard/x/publicdashboard_types.gen.ts" - }, - "machineName": "publicdashboard", - "maturity": "merged", - "name": "PublicDashboard", - "pluralMachineName": "publicdashboards", - "pluralName": "PublicDashboards" - }, - "query": { - "category": "core", - "codeowners": [], - "crd": { - "dummySchema": false, - "group": "", - "scope": "" - }, - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "query", - "maturity": "planned", - "name": "Query", - "pluralMachineName": "querys", - "pluralName": "Querys" - }, - "queryhistory": { - "category": "core", - "codeowners": [], - "crd": { - "dummySchema": false, - "group": "", - "scope": "" - }, - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "queryhistory", - "maturity": "planned", - "name": "QueryHistory", - "pluralMachineName": "queryhistorys", - "pluralName": "QueryHistorys" - }, - "role": { - "category": "core", - "codeowners": [ - "grafana/grafana-as-code", - "grafana/grafana-frontend-platform", - "grafana/plugins-platform-frontend" - ], - "crd": { - "dummySchema": false, - "group": "role.core.grafana.com", - "scope": "Namespaced" - }, - "currentVersion": [ - 0, - 0 - ], - "description": "Roles represent a set of users+teams that should share similar access", - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/core/role/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/kinds/role", - "schema": "https://github.com/grafana/grafana/tree/main/kinds/role/role_kind.cue", - "ts": "https://github.com/grafana/grafana/tree/main/packages/grafana-schema/src/raw/role/x/role_types.gen.ts" - }, - "machineName": "role", - "maturity": "merged", - "name": "Role", - "pluralMachineName": "roles", - "pluralName": "Roles" - }, - "rolebinding": { - "category": "core", - "codeowners": [ - "grafana/grafana-as-code", - "grafana/grafana-frontend-platform", - "grafana/plugins-platform-frontend" - ], - "crd": { - "dummySchema": false, - "group": "rolebinding.core.grafana.com", - "scope": "Namespaced" - }, - "currentVersion": [ - 0, - 0 - ], - "description": "Role bindings links a user|team to a configured role", - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/core/rolebinding/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/kinds/rolebinding", - "schema": "https://github.com/grafana/grafana/tree/main/kinds/rolebinding/rolebinding_kind.cue", - "ts": "https://github.com/grafana/grafana/tree/main/packages/grafana-schema/src/raw/rolebinding/x/rolebinding_types.gen.ts" - }, - "machineName": "rolebinding", - "maturity": "merged", - "name": "RoleBinding", - "pluralMachineName": "rolebindings", - "pluralName": "RoleBindings" - }, - "serviceaccount": { - "category": "core", - "codeowners": [], - "crd": { - "dummySchema": false, - "group": "", - "scope": "" - }, - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "serviceaccount", - "maturity": "planned", - "name": "ServiceAccount", - "pluralMachineName": "serviceaccounts", - "pluralName": "ServiceAccounts" - }, - "statetimelinepanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/dataviz-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/statetimelinepanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/state-timeline/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/state-timeline/panelcfg.gen.ts" - }, - "machineName": "statetimelinepanelcfg", - "maturity": "experimental", - "name": "StateTimelinePanelCfg", - "pluralMachineName": "statetimelinepanelcfgs", - "pluralName": "StateTimelinePanelCfgs", - "schemaInterface": "PanelCfg" - }, - "statpanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/dataviz-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/statpanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/stat/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/stat/panelcfg.gen.ts" - }, - "machineName": "statpanelcfg", - "maturity": "experimental", - "name": "StatPanelCfg", - "pluralMachineName": "statpanelcfgs", - "pluralName": "StatPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "statushistorypanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/dataviz-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/statushistorypanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/status-history/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/status-history/panelcfg.gen.ts" - }, - "machineName": "statushistorypanelcfg", - "maturity": "experimental", - "name": "StatusHistoryPanelCfg", - "pluralMachineName": "statushistorypanelcfgs", - "pluralName": "StatusHistoryPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "tableoldpanelcfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "tableoldpanelcfg", - "maturity": "planned", - "name": "TableOldPanelCfg", - "pluralMachineName": "tableoldpanelcfgs", - "pluralName": "TableOldPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "tablepanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/grafana-bi-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/tablepanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/table/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/table/panelcfg.gen.ts" - }, - "machineName": "tablepanelcfg", - "maturity": "experimental", - "name": "TablePanelCfg", - "pluralMachineName": "tablepanelcfgs", - "pluralName": "TablePanelCfgs", - "schemaInterface": "PanelCfg" - }, - "team": { - "category": "core", - "codeowners": [ - "grafana/grafana-as-code", - "grafana/grafana-frontend-platform", - "grafana/plugins-platform-frontend" - ], - "crd": { - "dummySchema": false, - "group": "team.core.grafana.com", - "scope": "Namespaced" - }, - "currentVersion": [ - 0, - 0 - ], - "description": "A team is a named grouping of Grafana users to which access control rules may be assigned.", - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/core/team/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/kinds/team", - "schema": "https://github.com/grafana/grafana/tree/main/kinds/team/team_kind.cue", - "ts": "https://github.com/grafana/grafana/tree/main/packages/grafana-schema/src/raw/team/x/team_types.gen.ts" - }, - "machineName": "team", - "maturity": "merged", - "name": "Team", - "pluralMachineName": "teams", - "pluralName": "Teams" - }, - "tempodataquery": { - "category": "composable", - "codeowners": [ - "grafana/observability-traces-and-profiling" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/tempodataquery/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/tempo/dataquery.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/tempo/dataquery.gen.ts" - }, - "machineName": "tempodataquery", - "maturity": "experimental", - "name": "TempoDataQuery", - "pluralMachineName": "tempodataquerys", - "pluralName": "TempoDataQuerys", - "schemaInterface": "DataQuery" - }, - "tempodatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "tempodatasourcecfg", - "maturity": "planned", - "name": "TempoDataSourceCfg", - "pluralMachineName": "tempodatasourcecfgs", - "pluralName": "TempoDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "testdatadataquery": { - "category": "composable", - "codeowners": [ - "grafana/plugins-platform-backend", - "grafana/plugins-platform-frontend" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/testdatadataquery/schema-reference", - "go": "https://github.com/grafana/grafana/tree/main/pkg/tsdb/grafana-testdata-datasource/kinds/dataquery/types_dataquery_gen.go", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.gen.ts" - }, - "machineName": "testdatadataquery", - "maturity": "experimental", - "name": "TestDataDataQuery", - "pluralMachineName": "testdatadataquerys", - "pluralName": "TestDataDataQuerys", - "schemaInterface": "DataQuery" - }, - "testdatadatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "testdatadatasourcecfg", - "maturity": "planned", - "name": "TestDataDataSourceCfg", - "pluralMachineName": "testdatadatasourcecfgs", - "pluralName": "TestDataDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - }, - "textpanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/grafana-frontend-platform" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/textpanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/text/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/text/panelcfg.gen.ts" - }, - "machineName": "textpanelcfg", - "maturity": "experimental", - "name": "TextPanelCfg", - "pluralMachineName": "textpanelcfgs", - "pluralName": "TextPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "thumb": { - "category": "core", - "codeowners": [], - "crd": { - "dummySchema": false, - "group": "", - "scope": "" - }, - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "thumb", - "maturity": "planned", - "name": "Thumb", - "pluralMachineName": "thumbs", - "pluralName": "Thumbs" - }, - "timeseriespanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/dataviz-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/timeseriespanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/timeseries/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/timeseries/panelcfg.gen.ts" - }, - "machineName": "timeseriespanelcfg", - "maturity": "merged", - "name": "TimeSeriesPanelCfg", - "pluralMachineName": "timeseriespanelcfgs", - "pluralName": "TimeSeriesPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "tracespanelcfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "tracespanelcfg", - "maturity": "planned", - "name": "TracesPanelCfg", - "pluralMachineName": "tracespanelcfgs", - "pluralName": "TracesPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "trendpanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/dataviz-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/trendpanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/trend/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/trend/panelcfg.gen.ts" - }, - "machineName": "trendpanelcfg", - "maturity": "merged", - "name": "TrendPanelCfg", - "pluralMachineName": "trendpanelcfgs", - "pluralName": "TrendPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "user": { - "category": "core", - "codeowners": [], - "crd": { - "dummySchema": false, - "group": "", - "scope": "" - }, - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "user", - "maturity": "planned", - "name": "User", - "pluralMachineName": "users", - "pluralName": "Users" - }, - "welcomepanelcfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "welcomepanelcfg", - "maturity": "planned", - "name": "WelcomePanelCfg", - "pluralMachineName": "welcomepanelcfgs", - "pluralName": "WelcomePanelCfgs", - "schemaInterface": "PanelCfg" - }, - "xychartpanelcfg": { - "category": "composable", - "codeowners": [ - "grafana/dataviz-squad" - ], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/xychartpanelcfg/schema-reference", - "go": "n/a", - "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/xychart/panelcfg.cue", - "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/xychart/panelcfg.gen.ts" - }, - "machineName": "xychartpanelcfg", - "maturity": "experimental", - "name": "XYChartPanelCfg", - "pluralMachineName": "xychartpanelcfgs", - "pluralName": "XYChartPanelCfgs", - "schemaInterface": "PanelCfg" - }, - "zipkindataquery": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": false, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "zipkindataquery", - "maturity": "planned", - "name": "ZipkinDataQuery", - "pluralMachineName": "zipkindataquerys", - "pluralName": "ZipkinDataQuerys", - "schemaInterface": "DataQuery" - }, - "zipkindatasourcecfg": { - "category": "composable", - "codeowners": [], - "currentVersion": [ - 0, - 0 - ], - "grafanaMaturityCount": 0, - "lineageIsGroup": true, - "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" - }, - "machineName": "zipkindatasourcecfg", - "maturity": "planned", - "name": "ZipkinDataSourceCfg", - "pluralMachineName": "zipkindatasourcecfgs", - "pluralName": "ZipkinDataSourceCfgs", - "schemaInterface": "DataSourceCfg" - } - }, - "dimensions": { - "category": { - "composable": { - "name": "composable", - "items": [ - "alertgroupspanelcfg", - "alertlistpanelcfg", - "alertmanagerdataquery", - "alertmanagerdatasourcecfg", - "annotationslistpanelcfg", - "azuremonitordataquery", - "azuremonitordatasourcecfg", - "barchartpanelcfg", - "bargaugepanelcfg", - "candlestickpanelcfg", - "canvaspanelcfg", - "cloudwatchdataquery", - "cloudwatchdatasourcecfg", - "dashboarddataquery", - "dashboarddatasourcecfg", - "dashboardlistpanelcfg", - "datagridpanelcfg", - "debugpanelcfg", - "elasticsearchdataquery", - "elasticsearchdatasourcecfg", - "flamegraphpanelcfg", - "gaugepanelcfg", - "geomappanelcfg", - "gettingstartedpanelcfg", - "googlecloudmonitoringdataquery", - "googlecloudmonitoringdatasourcecfg", - "grafanadataquery", - "grafanadatasourcecfg", - "grafanapyroscopedataquery", - "grafanapyroscopedatasourcecfg", - "graphitedataquery", - "graphitedatasourcecfg", - "grapholdpanelcfg", - "heatmappanelcfg", - "histogrampanelcfg", - "jaegerdataquery", - "jaegerdatasourcecfg", - "livepanelcfg", - "logspanelcfg", - "lokidataquery", - "lokidatasourcecfg", - "microsoftsqlserverdataquery", - "microsoftsqlserverdatasourcecfg", - "mysqldataquery", - "mysqldatasourcecfg", - "newspanelcfg", - "nodegraphpanelcfg", - "parcadataquery", - "parcadatasourcecfg", - "piechartpanelcfg", - "postgresqldataquery", - "postgresqldatasourcecfg", - "prometheusdataquery", - "prometheusdatasourcecfg", - "statetimelinepanelcfg", - "statpanelcfg", - "statushistorypanelcfg", - "tableoldpanelcfg", - "tablepanelcfg", - "tempodataquery", - "tempodatasourcecfg", - "testdatadataquery", - "testdatadatasourcecfg", - "textpanelcfg", - "timeseriespanelcfg", - "tracespanelcfg", - "trendpanelcfg", - "welcomepanelcfg", - "xychartpanelcfg", - "zipkindataquery", - "zipkindatasourcecfg" - ], - "count": 71 - }, - "core": { - "name": "core", - "items": [ - "accesspolicy", - "apikey", - "dashboard", - "datasource", - "folder", - "librarypanel", - "playlist", - "preferences", - "publicdashboard", - "query", - "queryhistory", - "role", - "rolebinding", - "serviceaccount", - "team", - "thumb", - "user" - ], - "count": 17 - } - }, - "maturity": { - "experimental": { - "name": "experimental", - "items": [ - "annotationslistpanelcfg", - "barchartpanelcfg", - "bargaugepanelcfg", - "candlestickpanelcfg", - "canvaspanelcfg", - "cloudwatchdataquery", - "dashboard", - "dashboardlistpanelcfg", - "datagridpanelcfg", - "debugpanelcfg", - "elasticsearchdataquery", - "gaugepanelcfg", - "geomappanelcfg", - "grafanapyroscopedataquery", - "histogrampanelcfg", - "librarypanel", - "logspanelcfg", - "lokidataquery", - "newspanelcfg", - "nodegraphpanelcfg", - "parcadataquery", - "piechartpanelcfg", - "prometheusdataquery", - "statetimelinepanelcfg", - "statpanelcfg", - "statushistorypanelcfg", - "tablepanelcfg", - "tempodataquery", - "testdatadataquery", - "textpanelcfg", - "xychartpanelcfg" - ], - "count": 31 - }, - "mature": { - "name": "mature", - "items": [], - "count": 0 - }, - "merged": { - "name": "merged", - "items": [ - "accesspolicy", - "alertgroupspanelcfg", - "azuremonitordataquery", - "googlecloudmonitoringdataquery", - "heatmappanelcfg", - "preferences", - "publicdashboard", - "role", - "rolebinding", - "team", - "timeseriespanelcfg", - "trendpanelcfg" - ], - "count": 12 - }, - "planned": { - "name": "planned", - "items": [ - "alertlistpanelcfg", - "alertmanagerdataquery", - "alertmanagerdatasourcecfg", - "apikey", - "azuremonitordatasourcecfg", - "cloudwatchdatasourcecfg", - "dashboarddataquery", - "dashboarddatasourcecfg", - "datasource", - "elasticsearchdatasourcecfg", - "flamegraphpanelcfg", - "folder", - "gettingstartedpanelcfg", - "googlecloudmonitoringdatasourcecfg", - "grafanadataquery", - "grafanadatasourcecfg", - "grafanapyroscopedatasourcecfg", - "graphitedataquery", - "graphitedatasourcecfg", - "grapholdpanelcfg", - "jaegerdataquery", - "jaegerdatasourcecfg", - "livepanelcfg", - "lokidatasourcecfg", - "microsoftsqlserverdataquery", - "microsoftsqlserverdatasourcecfg", - "mysqldataquery", - "mysqldatasourcecfg", - "parcadatasourcecfg", - "playlist", - "postgresqldataquery", - "postgresqldatasourcecfg", - "prometheusdatasourcecfg", - "query", - "queryhistory", - "serviceaccount", - "tableoldpanelcfg", - "tempodatasourcecfg", - "testdatadatasourcecfg", - "thumb", - "tracespanelcfg", - "user", - "welcomepanelcfg", - "zipkindataquery", - "zipkindatasourcecfg" - ], - "count": 45 - }, - "stable": { - "name": "stable", - "items": [], - "count": 0 - } - } - } -} \ No newline at end of file diff --git a/pkg/kindsysreport/codeowners.go b/pkg/kindsysreport/codeowners.go deleted file mode 100644 index 5c1a3edcc7adb..0000000000000 --- a/pkg/kindsysreport/codeowners.go +++ /dev/null @@ -1,61 +0,0 @@ -package kindsysreport - -import ( - "os" - "path/filepath" - - "github.com/hmarr/codeowners" -) - -type CodeOwnersFinder struct { - ruleset codeowners.Ruleset -} - -func NewCodeOwnersFinder(groot string) (CodeOwnersFinder, error) { - //nolint:gosec - file, err := os.Open(filepath.Join(groot, ".github", "CODEOWNERS")) - if err != nil { - return CodeOwnersFinder{}, err - } - - ruleset, err := codeowners.ParseFile(file) - if err != nil { - return CodeOwnersFinder{}, err - } - - return CodeOwnersFinder{ - ruleset: ruleset, - }, nil -} - -func (f CodeOwnersFinder) FindFor(pp ...string) ([]string, error) { - if len(f.ruleset) == 0 { - return nil, nil - } - - // Set, to avoid duplicates - m := make(map[string]struct{}) - - for _, p := range pp { - r, err := f.ruleset.Match(p) - if err != nil { - return nil, err - } - - // No rule found for path p - if r == nil { - continue - } - - for _, o := range r.Owners { - m[o.Value] = struct{}{} - } - } - - result := make([]string, 0, len(m)) - for k := range m { - result = append(result, k) - } - - return result, nil -} diff --git a/pkg/login/social/connectors/azuread_oauth.go b/pkg/login/social/connectors/azuread_oauth.go index a3ad4f3b8589a..178cc9a837c40 100644 --- a/pkg/login/social/connectors/azuread_oauth.go +++ b/pkg/login/social/connectors/azuread_oauth.go @@ -12,15 +12,18 @@ import ( jose "github.com/go-jose/go-jose/v3" "github.com/go-jose/go-jose/v3/jwt" + "github.com/google/uuid" "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" + "github.com/grafana/grafana/pkg/services/ssosettings/validation" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -28,7 +31,10 @@ import ( const forceUseGraphAPIKey = "force_use_graph_api" // #nosec G101 not a hardcoded credential var ( - ExtraAzureADSettingKeys = []string{forceUseGraphAPIKey, allowedOrganizationsKey} + ExtraAzureADSettingKeys = map[string]ExtraKeyInfo{ + forceUseGraphAPIKey: {Type: Bool, DefaultValue: false}, + allowedOrganizationsKey: {Type: String}, + } errAzureADMissingGroups = &SocialError{"either the user does not have any group membership or the groups claim is missing from the token."} ) @@ -72,17 +78,16 @@ type keySetJWKS struct { jose.JSONWebKeySet } -func NewAzureADProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features *featuremgmt.FeatureManager, cache remotecache.CacheStorage) *SocialAzureAD { - config := createOAuthConfig(info, cfg, social.AzureADProviderName) +func NewAzureADProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles, cache remotecache.CacheStorage) *SocialAzureAD { provider := &SocialAzureAD{ - SocialBase: newSocialBase(social.AzureADProviderName, config, info, cfg.AutoAssignOrgRole, *features), + SocialBase: newSocialBase(social.AzureADProviderName, info, features, cfg), cache: cache, allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]), - forceUseGraphAPI: MustBool(info.Extra[forceUseGraphAPIKey], false), + forceUseGraphAPI: MustBool(info.Extra[forceUseGraphAPIKey], ExtraAzureADSettingKeys[forceUseGraphAPIKey].DefaultValue.(bool)), } if info.UseRefreshToken { - appendUniqueScope(config, social.OfflineAccessScope) + appendUniqueScope(provider.Config, social.OfflineAccessScope) } if features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsApi) { @@ -93,6 +98,9 @@ func NewAzureADProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ss } func (s *SocialAzureAD) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*social.BasicUserInfo, error) { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + idToken := token.Extra("id_token") if idToken == nil { return nil, ErrIDTokenNotFound @@ -162,16 +170,54 @@ func (s *SocialAzureAD) UserInfo(ctx context.Context, client *http.Client, token }, nil } -func (s *SocialAzureAD) Validate(ctx context.Context, settings ssoModels.SSOSettings) error { +func (s *SocialAzureAD) Reload(ctx context.Context, settings ssoModels.SSOSettings) error { + newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings) + if err != nil { + return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) + } + + s.reloadMutex.Lock() + defer s.reloadMutex.Unlock() + + s.updateInfo(social.AzureADProviderName, newInfo) + + if newInfo.UseRefreshToken { + appendUniqueScope(s.Config, social.OfflineAccessScope) + } + + s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey]) + s.forceUseGraphAPI = MustBool(newInfo.Extra[forceUseGraphAPIKey], false) + return nil } -func (s *SocialAzureAD) Reload(ctx context.Context, settings ssoModels.SSOSettings) error { - return nil +func (s *SocialAzureAD) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error { + info, err := CreateOAuthInfoFromKeyValues(settings.Settings) + if err != nil { + return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) + } + + err = validateInfo(info, requester) + if err != nil { + return err + } + + return validation.Validate(info, requester, + validateAllowedGroups, + // FIXME: uncomment this after the Terraform provider is updated + //validation.MustBeEmptyValidator(info.ApiUrl, "API URL"), + validation.RequiredUrlValidator(info.AuthUrl, "Auth URL"), + validation.RequiredUrlValidator(info.TokenUrl, "Token URL")) } -func (s *SocialAzureAD) GetOAuthInfo() *social.OAuthInfo { - return s.info +func validateAllowedGroups(info *social.OAuthInfo, requester identity.Requester) error { + for _, groupId := range info.AllowedGroups { + _, err := uuid.Parse(groupId) + if err != nil { + return ssosettings.ErrInvalidOAuthConfig("One or more of the Allowed groups are not in the correct format. Allowed groups should be a list of Object Ids.") + } + } + return nil } func (s *SocialAzureAD) validateClaims(ctx context.Context, client *http.Client, parsedToken *jwt.JSONWebToken) (*azureClaims, error) { @@ -388,13 +434,16 @@ func (s *SocialAzureAD) groupsGraphAPIURL(claims *azureClaims, token *oauth2.Tok } func (s *SocialAzureAD) SupportBundleContent(bf *bytes.Buffer) error { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + bf.WriteString("## AzureAD specific configuration\n\n") bf.WriteString("```ini\n") bf.WriteString(fmt.Sprintf("allowed_groups = %v\n", s.info.AllowedGroups)) bf.WriteString(fmt.Sprintf("forceUseGraphAPI = %v\n", s.forceUseGraphAPI)) bf.WriteString("```\n\n") - return s.SocialBase.SupportBundleContent(bf) + return s.SocialBase.getBaseSupportBundleContent(bf) } func (s *SocialAzureAD) isAllowedTenant(tenantID string) bool { diff --git a/pkg/login/social/connectors/azuread_oauth_test.go b/pkg/login/social/connectors/azuread_oauth_test.go index f974d132a4b0c..670426086cdf8 100644 --- a/pkg/login/social/connectors/azuread_oauth_test.go +++ b/pkg/login/social/connectors/azuread_oauth_test.go @@ -18,8 +18,12 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/ssosettings" + ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -987,3 +991,286 @@ func TestSocialAzureAD_InitializeExtraFields(t *testing.T) { }) } } + +func TestSocialAzureAD_Validate(t *testing.T) { + testCases := []struct { + name string + settings ssoModels.SSOSettings + requester identity.Requester + wantErr error + }{ + { + name: "SSOSettings is valid", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allowed_groups": "0bb9c9cc-4945-418f-9b6a-c1d3b81141b0, 6034d328-0e6a-4240-8d03-cb9f2c1f16e4", + "allow_assign_grafana_admin": "true", + "auth_url": "https://example.com/auth", + "token_url": "https://example.com/token", + }, + }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, + }, + { + name: "fails if settings map contains an invalid field", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "invalid_field": []int{1, 2, 3}, + }, + }, + wantErr: ssosettings.ErrInvalidSettings, + }, + { + name: "fails if client id is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if client id does not exist", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{}, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if allowed groups are not uuids", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allowed_groups": "abc, def", + "auth_url": "https://example.com/auth", + "token_url": "https://example.com/token", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if both allow assign grafana admin and skip org role sync are enabled", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "skip_org_role_sync": "true", + "auth_url": "https://example.com/auth", + "token_url": "https://example.com/token", + }, + }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if the user is not allowed to update allow assign grafana admin", + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "auth_url": "https://example.com/auth", + "token_url": "https://example.com/token", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if auth url is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "", + "token_url": "https://example.com/token", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if token url is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "https://example.com/auth", + "token_url": "", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if auth url is invalid", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "invalid_url", + "token_url": "https://example.com/token", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if token url is invalid", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "https://example.com/auth", + "token_url": "/path", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewAzureADProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), nil) + + if tc.requester == nil { + tc.requester = &user.SignedInUser{IsGrafanaAdmin: false} + } + err := s.Validate(context.Background(), tc.settings, tc.requester) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestSocialAzureAD_Reload(t *testing.T) { + testCases := []struct { + name string + info *social.OAuthInfo + settings ssoModels.SSOSettings + expectError bool + expectedInfo *social.OAuthInfo + expectedConfig *oauth2.Config + }{ + { + name: "SSO provider successfully updated", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "new-client-id", + "client_secret": "new-client-secret", + "auth_url": "some-new-url", + }, + }, + expectError: false, + expectedInfo: &social.OAuthInfo{ + ClientId: "new-client-id", + ClientSecret: "new-client-secret", + AuthUrl: "some-new-url", + }, + expectedConfig: &oauth2.Config{ + ClientID: "new-client-id", + ClientSecret: "new-client-secret", + Endpoint: oauth2.Endpoint{ + AuthURL: "some-new-url", + }, + RedirectURL: "/login/azuread", + }, + }, + { + name: "fails if settings contain invalid values", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "new-client-id", + "client_secret": "new-client-secret", + "auth_url": []string{"first", "second"}, + }, + }, + expectError: true, + expectedInfo: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + expectedConfig: &oauth2.Config{ + ClientID: "client-id", + ClientSecret: "client-secret", + RedirectURL: "/login/azuread", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewAzureADProvider(tc.info, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), nil) + + err := s.Reload(context.Background(), tc.settings) + if tc.expectError { + require.Error(t, err) + return + } + require.NoError(t, err) + + require.EqualValues(t, tc.expectedInfo, s.info) + require.EqualValues(t, tc.expectedConfig, s.Config) + }) + } +} + +func TestSocialAzureAD_Reload_ExtraFields(t *testing.T) { + testCases := []struct { + name string + settings ssoModels.SSOSettings + info *social.OAuthInfo + expectError bool + expectedInfo *social.OAuthInfo + expectedAllowedOrganizations []string + expectedForceUseGraphApi bool + }{ + { + name: "successfully reloads the settings", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + Extra: map[string]string{ + "allowed_organizations": "previous", + "force_use_graph_api": "true", + }, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "allowed_organizations": "uuid-1234,uuid-5678", + "force_use_graph_api": "false", + }, + }, + expectedInfo: &social.OAuthInfo{ + ClientId: "new-client-id", + ClientSecret: "new-client-secret", + Name: "a-new-name", + Extra: map[string]string{ + "allowed_organizations": "uuid-1234,uuid-5678", + "force_use_graph_api": "false", + }, + }, + expectedAllowedOrganizations: []string{"uuid-1234", "uuid-5678"}, + expectedForceUseGraphApi: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewAzureADProvider(tc.info, setting.NewCfg(), &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), remotecache.FakeCacheStorage{}) + + err := s.Reload(context.Background(), tc.settings) + require.NoError(t, err) + + require.EqualValues(t, tc.expectedAllowedOrganizations, s.allowedOrganizations) + require.EqualValues(t, tc.expectedForceUseGraphApi, s.forceUseGraphAPI) + }) + } +} diff --git a/pkg/login/social/connectors/common.go b/pkg/login/social/connectors/common.go index d9700dc686003..7e9d62a6c7696 100644 --- a/pkg/login/social/connectors/common.go +++ b/pkg/login/social/connectors/common.go @@ -2,8 +2,6 @@ package connectors import ( "context" - "encoding/json" - "errors" "fmt" "io" "net/http" @@ -12,7 +10,6 @@ import ( "strconv" "strings" - "github.com/jmespath/go-jmespath" "github.com/mitchellh/mapstructure" "golang.org/x/oauth2" @@ -21,6 +18,18 @@ import ( "github.com/grafana/grafana/pkg/util" ) +type ExtraFieldType int + +const ( + String ExtraFieldType = iota + Bool +) + +type ExtraKeyInfo struct { + Type ExtraFieldType + DefaultValue any +} + const ( // consider moving this to OAuthInfo teamIdsKey = "team_ids" @@ -38,10 +47,16 @@ type httpGetResponse struct { } func (s *SocialBase) IsEmailAllowed(email string) bool { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + return isEmailAllowed(email, s.info.AllowedDomains) } func (s *SocialBase) IsSignupAllowed() bool { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + return s.info.AllowSignup } @@ -92,63 +107,6 @@ func (s *SocialBase) httpGet(ctx context.Context, client *http.Client, url strin return response, nil } -func (s *SocialBase) searchJSONForAttr(attributePath string, data []byte) (any, error) { - if attributePath == "" { - return "", errors.New("no attribute path specified") - } - - if len(data) == 0 { - return "", errors.New("empty user info JSON response provided") - } - - var buf any - if err := json.Unmarshal(data, &buf); err != nil { - return "", fmt.Errorf("%v: %w", "failed to unmarshal user info JSON response", err) - } - - val, err := jmespath.Search(attributePath, buf) - if err != nil { - return "", fmt.Errorf("failed to search user info JSON response with provided path: %q: %w", attributePath, err) - } - - return val, nil -} - -func (s *SocialBase) searchJSONForStringAttr(attributePath string, data []byte) (string, error) { - val, err := s.searchJSONForAttr(attributePath, data) - if err != nil { - return "", err - } - - strVal, ok := val.(string) - if ok { - return strVal, nil - } - - return "", nil -} - -func (s *SocialBase) searchJSONForStringArrayAttr(attributePath string, data []byte) ([]string, error) { - val, err := s.searchJSONForAttr(attributePath, data) - if err != nil { - return []string{}, err - } - - ifArr, ok := val.([]any) - if !ok { - return []string{}, nil - } - - result := []string{} - for _, v := range ifArr { - if strVal, ok := v.(string); ok { - result = append(result, strVal) - } - } - - return result, nil -} - func createOAuthConfig(info *social.OAuthInfo, cfg *setting.Cfg, defaultName string) *oauth2.Config { var authStyle oauth2.AuthStyle switch strings.ToLower(info.AuthStyle) { @@ -239,7 +197,7 @@ func CreateOAuthInfoFromKeyValues(settingsKV map[string]any) (*social.OAuthInfo, } func appendUniqueScope(config *oauth2.Config, scope string) { - if !slices.Contains(config.Scopes, social.OfflineAccessScope) { - config.Scopes = append(config.Scopes, social.OfflineAccessScope) + if !slices.Contains(config.Scopes, scope) { + config.Scopes = append(config.Scopes, scope) } } diff --git a/pkg/login/social/connectors/generic_oauth.go b/pkg/login/social/connectors/generic_oauth.go index 6728c54f52ab3..c76e64d8a0190 100644 --- a/pkg/login/social/connectors/generic_oauth.go +++ b/pkg/login/social/connectors/generic_oauth.go @@ -13,9 +13,11 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" + "github.com/grafana/grafana/pkg/services/ssosettings/validation" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -26,7 +28,13 @@ const ( idTokenAttributeNameKey = "id_token_attribute_name" // #nosec G101 not a hardcoded credential ) -var ExtraGenericOAuthSettingKeys = []string{nameAttributePathKey, loginAttributePathKey, idTokenAttributeNameKey, teamIdsKey, allowedOrganizationsKey} +var ExtraGenericOAuthSettingKeys = map[string]ExtraKeyInfo{ + nameAttributePathKey: {Type: String}, + loginAttributePathKey: {Type: String}, + idTokenAttributeNameKey: {Type: String}, + teamIdsKey: {Type: String}, + allowedOrganizationsKey: {Type: String}, +} var _ social.SocialConnector = (*SocialGenericOAuth)(nil) var _ ssosettings.Reloadable = (*SocialGenericOAuth)(nil) @@ -45,10 +53,9 @@ type SocialGenericOAuth struct { teamIds []string } -func NewGenericOAuthProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features *featuremgmt.FeatureManager) *SocialGenericOAuth { - config := createOAuthConfig(info, cfg, social.GenericOAuthProviderName) +func NewGenericOAuthProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGenericOAuth { provider := &SocialGenericOAuth{ - SocialBase: newSocialBase(social.GenericOAuthProviderName, config, info, cfg.AutoAssignOrgRole, *features), + SocialBase: newSocialBase(social.GenericOAuthProviderName, info, features, cfg), teamsUrl: info.TeamsUrl, emailAttributeName: info.EmailAttributeName, emailAttributePath: info.EmailAttributePath, @@ -68,16 +75,71 @@ func NewGenericOAuthProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettin return provider } -func (s *SocialGenericOAuth) Validate(ctx context.Context, settings ssoModels.SSOSettings) error { +func (s *SocialGenericOAuth) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error { + info, err := CreateOAuthInfoFromKeyValues(settings.Settings) + if err != nil { + return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) + } + + err = validateInfo(info, requester) + if err != nil { + return err + } + + err = validation.Validate(info, requester, + validation.UrlValidator(info.AuthUrl, "Auth URL"), + validation.UrlValidator(info.TokenUrl, "Token URL"), + validateTeamsUrlWhenNotEmpty) + + if err != nil { + return err + } + + if info.Extra[teamIdsKey] != "" && (info.TeamIdsAttributePath == "" || info.TeamsUrl == "") { + return ssosettings.ErrInvalidOAuthConfig("If Team Ids are configured then Team Ids attribute path and Teams URL must be configured.") + } + + if info.AllowedGroups != nil && len(info.AllowedGroups) > 0 && info.GroupsAttributePath == "" { + return ssosettings.ErrInvalidOAuthConfig("If Allowed groups is configured then Groups attribute path must be configured.") + } + return nil } +func validateTeamsUrlWhenNotEmpty(info *social.OAuthInfo, requester identity.Requester) error { + if info.TeamsUrl == "" { + return nil + } + return validation.UrlValidator(info.TeamsUrl, "Teams URL")(info, requester) +} + func (s *SocialGenericOAuth) Reload(ctx context.Context, settings ssoModels.SSOSettings) error { + newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings) + if err != nil { + return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) + } + + s.reloadMutex.Lock() + defer s.reloadMutex.Unlock() + + s.updateInfo(social.GenericOAuthProviderName, newInfo) + + s.teamsUrl = newInfo.TeamsUrl + s.emailAttributeName = newInfo.EmailAttributeName + s.emailAttributePath = newInfo.EmailAttributePath + s.nameAttributePath = newInfo.Extra[nameAttributePathKey] + s.groupsAttributePath = newInfo.GroupsAttributePath + s.loginAttributePath = newInfo.Extra[loginAttributePathKey] + s.idTokenAttributeName = newInfo.Extra[idTokenAttributeNameKey] + s.teamIdsAttributePath = newInfo.TeamIdsAttributePath + s.teamIds = util.SplitString(newInfo.Extra[teamIdsKey]) + s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey]) + return nil } // TODOD: remove this in the next PR and use the isGroupMember from social.go -func (s *SocialGenericOAuth) IsGroupMember(groups []string) bool { +func (s *SocialGenericOAuth) isGroupMember(groups []string) bool { if len(s.info.AllowedGroups) == 0 { return true } @@ -93,12 +155,12 @@ func (s *SocialGenericOAuth) IsGroupMember(groups []string) bool { return false } -func (s *SocialGenericOAuth) IsTeamMember(ctx context.Context, client *http.Client) bool { +func (s *SocialGenericOAuth) isTeamMember(ctx context.Context, client *http.Client) bool { if len(s.teamIds) == 0 { return true } - teamMemberships, err := s.FetchTeamMemberships(ctx, client) + teamMemberships, err := s.fetchTeamMemberships(ctx, client) if err != nil { return false } @@ -114,12 +176,12 @@ func (s *SocialGenericOAuth) IsTeamMember(ctx context.Context, client *http.Clie return false } -func (s *SocialGenericOAuth) IsOrganizationMember(ctx context.Context, client *http.Client) bool { +func (s *SocialGenericOAuth) isOrganizationMember(ctx context.Context, client *http.Client) bool { if len(s.allowedOrganizations) == 0 { return true } - organizations, ok := s.FetchOrganizations(ctx, client) + organizations, ok := s.fetchOrganizations(ctx, client) if !ok { return false } @@ -155,6 +217,9 @@ func (info *UserInfoJson) String() string { } func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*social.BasicUserInfo, error) { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + s.log.Debug("Getting user info") toCheck := make([]*UserInfoJson, 0, 2) @@ -224,7 +289,7 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client, if userInfo.Email == "" { var err error - userInfo.Email, err = s.FetchPrivateEmail(ctx, client) + userInfo.Email, err = s.fetchPrivateEmail(ctx, client) if err != nil { return nil, err } @@ -236,15 +301,15 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client, userInfo.Login = userInfo.Email } - if !s.IsTeamMember(ctx, client) { + if !s.isTeamMember(ctx, client) { return nil, errors.New("user not a member of one of the required teams") } - if !s.IsOrganizationMember(ctx, client) { + if !s.isOrganizationMember(ctx, client) { return nil, errors.New("user not a member of one of the required organizations") } - if !s.IsGroupMember(userInfo.Groups) { + if !s.isGroupMember(userInfo.Groups) { return nil, errMissingGroupMembership } @@ -252,10 +317,6 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client, return userInfo, nil } -func (s *SocialGenericOAuth) GetOAuthInfo() *social.OAuthInfo { - return s.info -} - func (s *SocialGenericOAuth) extractFromToken(token *oauth2.Token) *UserInfoJson { s.log.Debug("Extracting user info from OAuth token") @@ -322,7 +383,7 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string { } if s.emailAttributePath != "" { - email, err := s.searchJSONForStringAttr(s.emailAttributePath, data.rawJSON) + email, err := util.SearchJSONForStringAttr(s.emailAttributePath, data.rawJSON) if err != nil { s.log.Error("Failed to search JSON for attribute", "error", err) } else if email != "" { @@ -354,7 +415,7 @@ func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson) string { if s.loginAttributePath != "" { s.log.Debug("Searching for login among JSON", "loginAttributePath", s.loginAttributePath) - login, err := s.searchJSONForStringAttr(s.loginAttributePath, data.rawJSON) + login, err := util.SearchJSONForStringAttr(s.loginAttributePath, data.rawJSON) if err != nil { s.log.Error("Failed to search JSON for login attribute", "error", err) } @@ -374,7 +435,7 @@ func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson) string { func (s *SocialGenericOAuth) extractUserName(data *UserInfoJson) string { if s.nameAttributePath != "" { - name, err := s.searchJSONForStringAttr(s.nameAttributePath, data.rawJSON) + name, err := util.SearchJSONForStringAttr(s.nameAttributePath, data.rawJSON) if err != nil { s.log.Error("Failed to search JSON for attribute", "error", err) } else if name != "" { @@ -402,10 +463,10 @@ func (s *SocialGenericOAuth) extractGroups(data *UserInfoJson) ([]string, error) return []string{}, nil } - return s.searchJSONForStringArrayAttr(s.groupsAttributePath, data.rawJSON) + return util.SearchJSONForStringSliceAttr(s.groupsAttributePath, data.rawJSON) } -func (s *SocialGenericOAuth) FetchPrivateEmail(ctx context.Context, client *http.Client) (string, error) { +func (s *SocialGenericOAuth) fetchPrivateEmail(ctx context.Context, client *http.Client) (string, error) { type Record struct { Email string `json:"email"` Primary bool `json:"primary"` @@ -452,7 +513,7 @@ func (s *SocialGenericOAuth) FetchPrivateEmail(ctx context.Context, client *http return email, nil } -func (s *SocialGenericOAuth) FetchTeamMemberships(ctx context.Context, client *http.Client) ([]string, error) { +func (s *SocialGenericOAuth) fetchTeamMemberships(ctx context.Context, client *http.Client) ([]string, error) { var err error var ids []string @@ -509,10 +570,10 @@ func (s *SocialGenericOAuth) fetchTeamMembershipsFromTeamsUrl(ctx context.Contex return nil, err } - return s.searchJSONForStringArrayAttr(s.teamIdsAttributePath, response.Body) + return util.SearchJSONForStringSliceAttr(s.teamIdsAttributePath, response.Body) } -func (s *SocialGenericOAuth) FetchOrganizations(ctx context.Context, client *http.Client) ([]string, bool) { +func (s *SocialGenericOAuth) fetchOrganizations(ctx context.Context, client *http.Client) ([]string, bool) { type Record struct { Login string `json:"login"` } @@ -542,6 +603,9 @@ func (s *SocialGenericOAuth) FetchOrganizations(ctx context.Context, client *htt } func (s *SocialGenericOAuth) SupportBundleContent(bf *bytes.Buffer) error { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + bf.WriteString("## GenericOAuth specific configuration\n\n") bf.WriteString("```ini\n") bf.WriteString(fmt.Sprintf("name_attribute_path = %s\n", s.nameAttributePath)) @@ -552,5 +616,5 @@ func (s *SocialGenericOAuth) SupportBundleContent(bf *bytes.Buffer) error { bf.WriteString(fmt.Sprintf("allowed_organizations = %v\n", s.allowedOrganizations)) bf.WriteString("```\n\n") - return s.SocialBase.SupportBundleContent(bf) + return s.SocialBase.getBaseSupportBundleContent(bf) } diff --git a/pkg/login/social/connectors/generic_oauth_test.go b/pkg/login/social/connectors/generic_oauth_test.go index dbb5d482c0605..a7b64d7f20e66 100644 --- a/pkg/login/social/connectors/generic_oauth_test.go +++ b/pkg/login/social/connectors/generic_oauth_test.go @@ -13,214 +13,16 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/ssosettings" + ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) -func TestSearchJSONForEmail(t *testing.T) { - t.Run("Given a generic OAuth provider", func(t *testing.T) { - provider := NewGenericOAuthProvider(social.NewOAuthInfo(), &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) - - tests := []struct { - Name string - UserInfoJSONResponse []byte - EmailAttributePath string - ExpectedResult string - ExpectedError string - }{ - { - Name: "Given an invalid user info JSON response", - UserInfoJSONResponse: []byte("{"), - EmailAttributePath: "attributes.email", - ExpectedResult: "", - ExpectedError: "failed to unmarshal user info JSON response: unexpected end of JSON input", - }, - { - Name: "Given an empty user info JSON response and empty JMES path", - UserInfoJSONResponse: []byte{}, - EmailAttributePath: "", - ExpectedResult: "", - ExpectedError: "no attribute path specified", - }, - { - Name: "Given an empty user info JSON response and valid JMES path", - UserInfoJSONResponse: []byte{}, - EmailAttributePath: "attributes.email", - ExpectedResult: "", - ExpectedError: "empty user info JSON response provided", - }, - { - Name: "Given a simple user info JSON response and valid JMES path", - UserInfoJSONResponse: []byte(`{ - "attributes": { - "email": "grafana@localhost" - } -}`), - EmailAttributePath: "attributes.email", - ExpectedResult: "grafana@localhost", - }, - { - Name: "Given a user info JSON response with e-mails array and valid JMES path", - UserInfoJSONResponse: []byte(`{ - "attributes": { - "emails": ["grafana@localhost", "admin@localhost"] - } -}`), - EmailAttributePath: "attributes.emails[0]", - ExpectedResult: "grafana@localhost", - }, - { - Name: "Given a nested user info JSON response and valid JMES path", - UserInfoJSONResponse: []byte(`{ - "identities": [ - { - "userId": "grafana@localhost" - }, - { - "userId": "admin@localhost" - } - ] -}`), - EmailAttributePath: "identities[0].userId", - ExpectedResult: "grafana@localhost", - }, - } - - for _, test := range tests { - provider.emailAttributePath = test.EmailAttributePath - t.Run(test.Name, func(t *testing.T) { - actualResult, err := provider.searchJSONForStringAttr(test.EmailAttributePath, test.UserInfoJSONResponse) - if test.ExpectedError == "" { - require.NoError(t, err, "Testing case %q", test.Name) - } else { - require.EqualError(t, err, test.ExpectedError, "Testing case %q", test.Name) - } - require.Equal(t, test.ExpectedResult, actualResult) - }) - } - }) -} - -func TestSearchJSONForGroups(t *testing.T) { - t.Run("Given a generic OAuth provider", func(t *testing.T) { - provider := NewGenericOAuthProvider(social.NewOAuthInfo(), &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) - - tests := []struct { - Name string - UserInfoJSONResponse []byte - GroupsAttributePath string - ExpectedResult []string - ExpectedError string - }{ - { - Name: "Given an invalid user info JSON response", - UserInfoJSONResponse: []byte("{"), - GroupsAttributePath: "attributes.groups", - ExpectedResult: []string{}, - ExpectedError: "failed to unmarshal user info JSON response: unexpected end of JSON input", - }, - { - Name: "Given an empty user info JSON response and empty JMES path", - UserInfoJSONResponse: []byte{}, - GroupsAttributePath: "", - ExpectedResult: []string{}, - ExpectedError: "no attribute path specified", - }, - { - Name: "Given an empty user info JSON response and valid JMES path", - UserInfoJSONResponse: []byte{}, - GroupsAttributePath: "attributes.groups", - ExpectedResult: []string{}, - ExpectedError: "empty user info JSON response provided", - }, - { - Name: "Given a simple user info JSON response and valid JMES path", - UserInfoJSONResponse: []byte(`{ - "attributes": { - "groups": ["foo", "bar"] - } -}`), - GroupsAttributePath: "attributes.groups[]", - ExpectedResult: []string{"foo", "bar"}, - }, - } - - for _, test := range tests { - provider.groupsAttributePath = test.GroupsAttributePath - t.Run(test.Name, func(t *testing.T) { - actualResult, err := provider.searchJSONForStringArrayAttr(test.GroupsAttributePath, test.UserInfoJSONResponse) - if test.ExpectedError == "" { - require.NoError(t, err, "Testing case %q", test.Name) - } else { - require.EqualError(t, err, test.ExpectedError, "Testing case %q", test.Name) - } - require.Equal(t, test.ExpectedResult, actualResult) - }) - } - }) -} - -func TestSearchJSONForRole(t *testing.T) { - t.Run("Given a generic OAuth provider", func(t *testing.T) { - provider := NewGenericOAuthProvider(social.NewOAuthInfo(), &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) - - tests := []struct { - Name string - UserInfoJSONResponse []byte - RoleAttributePath string - ExpectedResult string - ExpectedError string - }{ - { - Name: "Given an invalid user info JSON response", - UserInfoJSONResponse: []byte("{"), - RoleAttributePath: "attributes.role", - ExpectedResult: "", - ExpectedError: "failed to unmarshal user info JSON response: unexpected end of JSON input", - }, - { - Name: "Given an empty user info JSON response and empty JMES path", - UserInfoJSONResponse: []byte{}, - RoleAttributePath: "", - ExpectedResult: "", - ExpectedError: "no attribute path specified", - }, - { - Name: "Given an empty user info JSON response and valid JMES path", - UserInfoJSONResponse: []byte{}, - RoleAttributePath: "attributes.role", - ExpectedResult: "", - ExpectedError: "empty user info JSON response provided", - }, - { - Name: "Given a simple user info JSON response and valid JMES path", - UserInfoJSONResponse: []byte(`{ - "attributes": { - "role": "admin" - } -}`), - RoleAttributePath: "attributes.role", - ExpectedResult: "admin", - }, - } - - for _, test := range tests { - provider.info.RoleAttributePath = test.RoleAttributePath - t.Run(test.Name, func(t *testing.T) { - actualResult, err := provider.searchJSONForStringAttr(test.RoleAttributePath, test.UserInfoJSONResponse) - if test.ExpectedError == "" { - require.NoError(t, err, "Testing case %q", test.Name) - } else { - require.EqualError(t, err, test.ExpectedError, "Testing case %q", test.Name) - } - require.Equal(t, test.ExpectedResult, actualResult) - }) - } - }) -} - func TestUserInfoSearchesForEmailAndRole(t *testing.T) { provider := NewGenericOAuthProvider(&social.OAuthInfo{ EmailAttributePath: "email", @@ -915,3 +717,354 @@ func TestSocialGenericOAuth_InitializeExtraFields(t *testing.T) { }) } } + +func TestSocialGenericOAuth_Validate(t *testing.T) { + testCases := []struct { + name string + settings ssoModels.SSOSettings + requester identity.Requester + wantErr error + }{ + { + name: "SSOSettings is valid", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "teams_url": "https://example.com/teams", + "auth_url": "https://example.com/auth", + "token_url": "https://example.com/token", + }, + }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, + }, + { + name: "passes when team_url is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "teams_url": "", + "auth_url": "https://example.com/auth", + "token_url": "https://example.com/token", + }, + }, + wantErr: nil, + }, + { + name: "fails if settings map contains an invalid field", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "invalid_field": []int{1, 2, 3}, + }, + }, + wantErr: ssosettings.ErrInvalidSettings, + }, + { + name: "fails if client id is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if client id does not exist", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{}, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if both allow assign grafana admin and skip org role sync are enabled", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "skip_org_role_sync": "true", + "auth_url": "https://example.com/auth", + "token_url": "https://example.com/token", + }, + }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if the user is not allowed to update allow assign grafana admin", + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "skip_org_role_sync": "true", + "auth_url": "https://example.com/auth", + "token_url": "https://example.com/token", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if auth url is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "teams_url": "https://example.com/teams", + "auth_url": "", + "token_url": "https://example.com/token", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if token url is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "teams_url": "https://example.com/teams", + "auth_url": "https://example.com/auth", + "token_url": "", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if auth url is invalid", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "teams_url": "https://example.com/teams", + "auth_url": "invalid_url", + "token_url": "https://example.com/token", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if token url is invalid", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "teams_url": "https://example.com/teams", + "auth_url": "https://example.com/auth", + "token_url": "/path", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if teams url is invalid", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "teams_url": "file://teams", + "auth_url": "https://example.com/auth", + "token_url": "https://example.com/token", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewGenericOAuthProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) + + if tc.requester == nil { + tc.requester = &user.SignedInUser{IsGrafanaAdmin: false} + } + err := s.Validate(context.Background(), tc.settings, tc.requester) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestSocialGenericOAuth_Reload(t *testing.T) { + testCases := []struct { + name string + info *social.OAuthInfo + settings ssoModels.SSOSettings + expectError bool + expectedInfo *social.OAuthInfo + expectedConfig *oauth2.Config + }{ + { + name: "SSO provider successfully updated", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "new-client-id", + "client_secret": "new-client-secret", + "auth_url": "some-new-url", + }, + }, + expectError: false, + expectedInfo: &social.OAuthInfo{ + ClientId: "new-client-id", + ClientSecret: "new-client-secret", + AuthUrl: "some-new-url", + }, + expectedConfig: &oauth2.Config{ + ClientID: "new-client-id", + ClientSecret: "new-client-secret", + Endpoint: oauth2.Endpoint{ + AuthURL: "some-new-url", + }, + RedirectURL: "/login/generic_oauth", + }, + }, + { + name: "fails if settings contain invalid values", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "new-client-id", + "client_secret": "new-client-secret", + "auth_url": []string{"first", "second"}, + }, + }, + expectError: true, + expectedInfo: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + expectedConfig: &oauth2.Config{ + ClientID: "client-id", + ClientSecret: "client-secret", + RedirectURL: "/login/generic_oauth", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewGenericOAuthProvider(tc.info, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) + + err := s.Reload(context.Background(), tc.settings) + if tc.expectError { + require.Error(t, err) + return + } + require.NoError(t, err) + + require.EqualValues(t, tc.expectedInfo, s.info) + require.EqualValues(t, tc.expectedConfig, s.Config) + }) + } +} + +func TestGenericOAuth_Reload_ExtraFields(t *testing.T) { + testCases := []struct { + name string + settings ssoModels.SSOSettings + info *social.OAuthInfo + expectError bool + expectedInfo *social.OAuthInfo + expectedTeamsUrl string + expectedEmailAttributeName string + expectedEmailAttributePath string + expectedNameAttributePath string + expectedGroupsAttributePath string + expectedLoginAttributePath string + expectedIdTokenAttributeName string + expectedTeamIdsAttributePath string + expectedTeamIds []string + expectedAllowedOrganizations []string + }{ + { + name: "successfully reloads the settings", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + TeamsUrl: "https://host/users", + EmailAttributePath: "email-attr-path", + EmailAttributeName: "email-attr-name", + GroupsAttributePath: "groups-attr-path", + TeamIdsAttributePath: "team-ids-attr-path", + Extra: map[string]string{ + teamIdsKey: "team1", + allowedOrganizationsKey: "org1", + loginAttributePathKey: "login-attr-path", + idTokenAttributeNameKey: "id-token-attr-name", + nameAttributePathKey: "name-attr-path", + }, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "new-client-id", + "client_secret": "new-client-secret", + "teams_url": "https://host/v2/users", + "email_attribute_path": "new-email-attr-path", + "email_attribute_name": "new-email-attr-name", + "groups_attribute_path": "new-group-attr-path", + "team_ids_attribute_path": "new-team-ids-attr-path", + teamIdsKey: "team1,team2", + allowedOrganizationsKey: "org1,org2", + loginAttributePathKey: "new-login-attr-path", + idTokenAttributeNameKey: "new-id-token-attr-name", + nameAttributePathKey: "new-name-attr-path", + }, + }, + expectedInfo: &social.OAuthInfo{ + ClientId: "new-client-id", + ClientSecret: "new-client-secret", + TeamsUrl: "https://host/v2/users", + EmailAttributePath: "new-email-attr-path", + EmailAttributeName: "new-email-attr-name", + GroupsAttributePath: "new-group-attr-path", + TeamIdsAttributePath: "new-team-ids-attr-path", + Extra: map[string]string{ + teamIdsKey: "team1,team2", + allowedOrganizationsKey: "org1,org2", + loginAttributePathKey: "new-login-attr-path", + idTokenAttributeNameKey: "new-id-token-attr-name", + nameAttributePathKey: "new-name-attr-path", + }, + }, + expectedTeamsUrl: "https://host/v2/users", + expectedEmailAttributeName: "new-email-attr-name", + expectedEmailAttributePath: "new-email-attr-path", + expectedGroupsAttributePath: "new-group-attr-path", + expectedTeamIdsAttributePath: "new-team-ids-attr-path", + expectedTeamIds: []string{"team1", "team2"}, + expectedAllowedOrganizations: []string{"org1", "org2"}, + expectedLoginAttributePath: "new-login-attr-path", + expectedIdTokenAttributeName: "new-id-token-attr-name", + expectedNameAttributePath: "new-name-attr-path", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewGenericOAuthProvider(tc.info, setting.NewCfg(), &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) + + err := s.Reload(context.Background(), tc.settings) + require.NoError(t, err) + + require.EqualValues(t, tc.expectedInfo, s.info) + + require.EqualValues(t, tc.expectedTeamsUrl, s.teamsUrl) + require.EqualValues(t, tc.expectedEmailAttributeName, s.emailAttributeName) + require.EqualValues(t, tc.expectedEmailAttributePath, s.emailAttributePath) + require.EqualValues(t, tc.expectedGroupsAttributePath, s.groupsAttributePath) + require.EqualValues(t, tc.expectedTeamIdsAttributePath, s.teamIdsAttributePath) + require.EqualValues(t, tc.expectedTeamIds, s.teamIds) + require.EqualValues(t, tc.expectedAllowedOrganizations, s.allowedOrganizations) + require.EqualValues(t, tc.expectedLoginAttributePath, s.loginAttributePath) + require.EqualValues(t, tc.expectedIdTokenAttributeName, s.idTokenAttributeName) + require.EqualValues(t, tc.expectedNameAttributePath, s.nameAttributePath) + }) + } +} diff --git a/pkg/login/social/connectors/github_oauth.go b/pkg/login/social/connectors/github_oauth.go index 7506f21348d79..18e2175bcc1f6 100644 --- a/pkg/login/social/connectors/github_oauth.go +++ b/pkg/login/social/connectors/github_oauth.go @@ -14,15 +14,20 @@ import ( "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" + "github.com/grafana/grafana/pkg/services/ssosettings/validation" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/errutil" ) -var ExtraGithubSettingKeys = []string{allowedOrganizationsKey, teamIdsKey} +var ExtraGithubSettingKeys = map[string]ExtraKeyInfo{ + allowedOrganizationsKey: {Type: String}, + teamIdsKey: {Type: String}, +} var _ social.SocialConnector = (*SocialGithub)(nil) var _ ssosettings.Reloadable = (*SocialGithub)(nil) @@ -53,13 +58,12 @@ var ( "User is not a member of one of the required organizations. Please contact identity provider administrator.")) ) -func NewGitHubProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features *featuremgmt.FeatureManager) *SocialGithub { +func NewGitHubProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGithub { teamIdsSplitted := util.SplitString(info.Extra[teamIdsKey]) teamIds := mustInts(teamIdsSplitted) - config := createOAuthConfig(info, cfg, social.GitHubProviderName) provider := &SocialGithub{ - SocialBase: newSocialBase(social.GitHubProviderName, config, info, cfg.AutoAssignOrgRole, *features), + SocialBase: newSocialBase(social.GitHubProviderName, info, features, cfg), teamIds: teamIds, allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]), } @@ -75,20 +79,65 @@ func NewGitHubProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings sso return provider } -func (s *SocialGithub) Validate(ctx context.Context, settings ssoModels.SSOSettings) error { +func (s *SocialGithub) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error { + info, err := CreateOAuthInfoFromKeyValues(settings.Settings) + if err != nil { + return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) + } + + err = validateInfo(info, requester) + if err != nil { + return err + } + + return validation.Validate(info, requester, + validation.MustBeEmptyValidator(info.AuthUrl, "Auth URL"), + validation.MustBeEmptyValidator(info.TokenUrl, "Token URL"), + validation.MustBeEmptyValidator(info.ApiUrl, "API URL"), + teamIdsNumbersValidator) +} + +func teamIdsNumbersValidator(info *social.OAuthInfo, requester identity.Requester) error { + teamIdsSplitted := util.SplitString(info.Extra[teamIdsKey]) + teamIds := mustInts(teamIdsSplitted) + + if len(teamIdsSplitted) != len(teamIds) { + return ssosettings.ErrInvalidOAuthConfig("Failed to parse Team Ids. Team Ids must be a list of numbers.") + } + return nil } func (s *SocialGithub) Reload(ctx context.Context, settings ssoModels.SSOSettings) error { + newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings) + if err != nil { + return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) + } + + teamIdsSplitted := util.SplitString(newInfo.Extra[teamIdsKey]) + teamIds := mustInts(teamIdsSplitted) + + if len(teamIdsSplitted) != len(teamIds) { + s.log.Warn("Failed to parse team ids. Team ids must be a list of numbers.", "teamIds", teamIdsSplitted) + } + + s.reloadMutex.Lock() + defer s.reloadMutex.Unlock() + + s.updateInfo(social.GitHubProviderName, newInfo) + + s.teamIds = teamIds + s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey]) + return nil } -func (s *SocialGithub) IsTeamMember(ctx context.Context, client *http.Client) bool { +func (s *SocialGithub) isTeamMember(ctx context.Context, client *http.Client) bool { if len(s.teamIds) == 0 { return true } - teamMemberships, err := s.FetchTeamMemberships(ctx, client) + teamMemberships, err := s.fetchTeamMemberships(ctx, client) if err != nil { return false } @@ -104,13 +153,13 @@ func (s *SocialGithub) IsTeamMember(ctx context.Context, client *http.Client) bo return false } -func (s *SocialGithub) IsOrganizationMember(ctx context.Context, +func (s *SocialGithub) isOrganizationMember(ctx context.Context, client *http.Client, organizationsUrl string) bool { if len(s.allowedOrganizations) == 0 { return true } - organizations, err := s.FetchOrganizations(ctx, client, organizationsUrl) + organizations, err := s.fetchOrganizations(ctx, client, organizationsUrl) if err != nil { return false } @@ -126,7 +175,7 @@ func (s *SocialGithub) IsOrganizationMember(ctx context.Context, return false } -func (s *SocialGithub) FetchPrivateEmail(ctx context.Context, client *http.Client) (string, error) { +func (s *SocialGithub) fetchPrivateEmail(ctx context.Context, client *http.Client) (string, error) { type Record struct { Email string `json:"email"` Primary bool `json:"primary"` @@ -155,7 +204,7 @@ func (s *SocialGithub) FetchPrivateEmail(ctx context.Context, client *http.Clien return email, nil } -func (s *SocialGithub) FetchTeamMemberships(ctx context.Context, client *http.Client) ([]GithubTeam, error) { +func (s *SocialGithub) fetchTeamMemberships(ctx context.Context, client *http.Client) ([]GithubTeam, error) { url := fmt.Sprintf(s.info.ApiUrl + "/teams?per_page=100") hasMore := true teams := make([]GithubTeam, 0) @@ -175,13 +224,13 @@ func (s *SocialGithub) FetchTeamMemberships(ctx context.Context, client *http.Cl teams = append(teams, records...) - url, hasMore = s.HasMoreRecords(response.Headers) + url, hasMore = s.hasMoreRecords(response.Headers) } return teams, nil } -func (s *SocialGithub) HasMoreRecords(headers http.Header) (string, bool) { +func (s *SocialGithub) hasMoreRecords(headers http.Header) (string, bool) { value, exists := headers["Link"] if !exists { return "", false @@ -199,7 +248,7 @@ func (s *SocialGithub) HasMoreRecords(headers http.Header) (string, bool) { return url, true } -func (s *SocialGithub) FetchOrganizations(ctx context.Context, client *http.Client, organizationsUrl string) ([]string, error) { +func (s *SocialGithub) fetchOrganizations(ctx context.Context, client *http.Client, organizationsUrl string) ([]string, error) { url := organizationsUrl hasMore := true logins := make([]string, 0) @@ -225,12 +274,15 @@ func (s *SocialGithub) FetchOrganizations(ctx context.Context, client *http.Clie logins = append(logins, record.Login) } - url, hasMore = s.HasMoreRecords(response.Headers) + url, hasMore = s.hasMoreRecords(response.Headers) } return logins, nil } func (s *SocialGithub) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*social.BasicUserInfo, error) { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + var data struct { Id int `json:"id"` Login string `json:"login"` @@ -247,7 +299,7 @@ func (s *SocialGithub) UserInfo(ctx context.Context, client *http.Client, token return nil, fmt.Errorf("error unmarshalling user info: %s", err) } - teamMemberships, err := s.FetchTeamMemberships(ctx, client) + teamMemberships, err := s.fetchTeamMemberships(ctx, client) if err != nil { return nil, fmt.Errorf("error getting user teams: %s", err) } @@ -289,18 +341,18 @@ func (s *SocialGithub) UserInfo(ctx context.Context, client *http.Client, token organizationsUrl := fmt.Sprintf(s.info.ApiUrl + "/orgs?per_page=100") - if !s.IsTeamMember(ctx, client) { + if !s.isTeamMember(ctx, client) { return nil, ErrMissingTeamMembership.Errorf("User is not a member of any of the allowed teams: %v", s.teamIds) } - if !s.IsOrganizationMember(ctx, client, organizationsUrl) { + if !s.isOrganizationMember(ctx, client, organizationsUrl) { return nil, ErrMissingOrganizationMembership.Errorf( "User is not a member of any of the allowed organizations: %v", s.allowedOrganizations) } if userInfo.Email == "" { - userInfo.Email, err = s.FetchPrivateEmail(ctx, client) + userInfo.Email, err = s.fetchPrivateEmail(ctx, client) if err != nil { return nil, err } @@ -316,10 +368,6 @@ func (t *GithubTeam) GetShorthand() (string, error) { return fmt.Sprintf("@%s/%s", t.Organization.Login, t.Slug), nil } -func (s *SocialGithub) GetOAuthInfo() *social.OAuthInfo { - return s.info -} - func convertToGroupList(t []GithubTeam) []string { groups := make([]string, 0) for _, team := range t { diff --git a/pkg/login/social/connectors/github_oauth_test.go b/pkg/login/social/connectors/github_oauth_test.go index 336fee64569a8..e7c8edfa7e452 100644 --- a/pkg/login/social/connectors/github_oauth_test.go +++ b/pkg/login/social/connectors/github_oauth_test.go @@ -12,8 +12,12 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/ssosettings" + ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -341,3 +345,273 @@ func TestSocialGitHub_InitializeExtraFields(t *testing.T) { }) } } + +func TestSocialGitHub_Validate(t *testing.T) { + testCases := []struct { + name string + settings ssoModels.SSOSettings + requester identity.Requester + wantErr error + }{ + { + name: "SSOSettings is valid", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "auth_url": "", + "token_url": "", + "api_url": "", + }, + }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, + }, + { + name: "fails if settings map contains an invalid field", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "invalid_field": []int{1, 2, 3}, + }, + }, + wantErr: ssosettings.ErrInvalidSettings, + }, + { + name: "fails if client id is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if client id does not exist", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{}, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if team ids are not integers", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "team_ids": "abc1234,5678,def", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if both allow assign grafana admin and skip org role sync are enabled", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "skip_org_role_sync": "true", + }, + }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if the user is not allowed to update allow assign grafana admin", + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if token url is not empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "", + "token_url": "https://example.com/token", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if auth url is not empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "https://example.com/auth", + "token_url": "", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if api url is not empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "", + "token_url": "", + "api_url": "http://example.com/api", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewGitHubProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) + + if tc.requester == nil { + tc.requester = &user.SignedInUser{IsGrafanaAdmin: false} + } + + err := s.Validate(context.Background(), tc.settings, tc.requester) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestSocialGitHub_Reload(t *testing.T) { + testCases := []struct { + name string + info *social.OAuthInfo + settings ssoModels.SSOSettings + expectError bool + expectedInfo *social.OAuthInfo + expectedConfig *oauth2.Config + }{ + { + name: "SSO provider successfully updated", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "new-client-id", + "client_secret": "new-client-secret", + "auth_url": "some-new-url", + }, + }, + expectError: false, + expectedInfo: &social.OAuthInfo{ + ClientId: "new-client-id", + ClientSecret: "new-client-secret", + AuthUrl: "some-new-url", + }, + expectedConfig: &oauth2.Config{ + ClientID: "new-client-id", + ClientSecret: "new-client-secret", + Endpoint: oauth2.Endpoint{ + AuthURL: "some-new-url", + }, + RedirectURL: "/login/github", + }, + }, + { + name: "fails if settings contain invalid values", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "new-client-id", + "client_secret": "new-client-secret", + "auth_url": []string{"first", "second"}, + }, + }, + expectError: true, + expectedInfo: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + expectedConfig: &oauth2.Config{ + ClientID: "client-id", + ClientSecret: "client-secret", + RedirectURL: "/login/github", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewGitHubProvider(tc.info, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) + + err := s.Reload(context.Background(), tc.settings) + if tc.expectError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + require.EqualValues(t, tc.expectedInfo, s.info) + require.EqualValues(t, tc.expectedConfig, s.Config) + }) + } +} + +func TestGitHub_Reload_ExtraFields(t *testing.T) { + testCases := []struct { + name string + settings ssoModels.SSOSettings + info *social.OAuthInfo + expectError bool + expectedInfo *social.OAuthInfo + expectedAllowedOrganizations []string + expectedTeamIds []int + }{ + { + name: "successfully reloads the settings", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + Extra: map[string]string{ + "allowed_organizations": "previous", + "team_ids": "", + }, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "allowed_organizations": "uuid-1234,uuid-5678", + "team_ids": "123,456", + }, + }, + expectedInfo: &social.OAuthInfo{ + ClientId: "new-client-id", + ClientSecret: "new-client-secret", + Name: "a-new-name", + AuthStyle: "inheader", + Extra: map[string]string{ + "allowed_organizations": "uuid-1234,uuid-5678", + "force_use_graph_api": "false", + }, + }, + expectedAllowedOrganizations: []string{"uuid-1234", "uuid-5678"}, + expectedTeamIds: []int{123, 456}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewGitHubProvider(tc.info, setting.NewCfg(), &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) + + err := s.Reload(context.Background(), tc.settings) + require.NoError(t, err) + + require.EqualValues(t, tc.expectedAllowedOrganizations, s.allowedOrganizations) + require.EqualValues(t, tc.expectedTeamIds, s.teamIds) + }) + } +} diff --git a/pkg/login/social/connectors/gitlab_oauth.go b/pkg/login/social/connectors/gitlab_oauth.go index 2bce4283bb4de..b588d2c2ebf0f 100644 --- a/pkg/login/social/connectors/gitlab_oauth.go +++ b/pkg/login/social/connectors/gitlab_oauth.go @@ -13,9 +13,11 @@ import ( "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" + "github.com/grafana/grafana/pkg/services/ssosettings/validation" "github.com/grafana/grafana/pkg/setting" ) @@ -52,10 +54,9 @@ type userData struct { IsGrafanaAdmin *bool `json:"-"` } -func NewGitLabProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features *featuremgmt.FeatureManager) *SocialGitlab { - config := createOAuthConfig(info, cfg, social.GitlabProviderName) +func NewGitLabProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGitlab { provider := &SocialGitlab{ - SocialBase: newSocialBase(social.GitlabProviderName, config, info, cfg.AutoAssignOrgRole, *features), + SocialBase: newSocialBase(social.GitlabProviderName, info, features, cfg), } if features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsApi) { @@ -65,11 +66,34 @@ func NewGitLabProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings sso return provider } -func (s *SocialGitlab) Validate(ctx context.Context, settings ssoModels.SSOSettings) error { - return nil +func (s *SocialGitlab) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error { + info, err := CreateOAuthInfoFromKeyValues(settings.Settings) + if err != nil { + return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) + } + + err = validateInfo(info, requester) + if err != nil { + return err + } + + return validation.Validate(info, requester, + validation.MustBeEmptyValidator(info.AuthUrl, "Auth URL"), + validation.MustBeEmptyValidator(info.TokenUrl, "Token URL"), + validation.MustBeEmptyValidator(info.ApiUrl, "API URL")) } func (s *SocialGitlab) Reload(ctx context.Context, settings ssoModels.SSOSettings) error { + newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings) + if err != nil { + return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) + } + + s.reloadMutex.Lock() + defer s.reloadMutex.Unlock() + + s.updateInfo(social.GitlabProviderName, newInfo) + return nil } @@ -153,6 +177,9 @@ func (s *SocialGitlab) getGroupsPage(ctx context.Context, client *http.Client, n } func (s *SocialGitlab) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*social.BasicUserInfo, error) { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + data, err := s.extractFromToken(ctx, client, token) if err != nil { return nil, err @@ -188,10 +215,6 @@ func (s *SocialGitlab) UserInfo(ctx context.Context, client *http.Client, token return userInfo, nil } -func (s *SocialGitlab) GetOAuthInfo() *social.OAuthInfo { - return s.info -} - func (s *SocialGitlab) extractFromAPI(ctx context.Context, client *http.Client, token *oauth2.Token) (*userData, error) { apiResp := &apiData{} response, err := s.httpGet(ctx, client, s.info.ApiUrl+"/user") @@ -234,7 +257,7 @@ func (s *SocialGitlab) extractFromAPI(ctx context.Context, client *http.Client, idData.Role = role } - if setting.Env == setting.Dev { + if s.cfg.Env == setting.Dev { s.log.Debug("Resolved ID", "data", fmt.Sprintf("%+v", idData)) } diff --git a/pkg/login/social/connectors/gitlab_oauth_test.go b/pkg/login/social/connectors/gitlab_oauth_test.go index 3c068cb01b859..a09944d29b3d7 100644 --- a/pkg/login/social/connectors/gitlab_oauth_test.go +++ b/pkg/login/social/connectors/gitlab_oauth_test.go @@ -16,9 +16,13 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/ssosettings" + ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -163,7 +167,7 @@ func TestSocialGitlab_UserInfo(t *testing.T) { for _, test := range tests { provider.info.RoleAttributePath = test.RoleAttributePath provider.info.AllowAssignGrafanaAdmin = test.Cfg.AllowAssignGrafanaAdmin - provider.autoAssignOrgRole = string(test.Cfg.AutoAssignOrgRole) + provider.cfg.AutoAssignOrgRole = string(test.Cfg.AutoAssignOrgRole) provider.info.RoleAttributeStrict = test.Cfg.RoleAttributeStrict provider.info.SkipOrgRoleSync = test.Cfg.SkipOrgRoleSync @@ -459,3 +463,212 @@ func TestSocialGitlab_GetGroupsNextPage(t *testing.T) { assert.Equal(t, expectedGroups, actualGroups) assert.Equal(t, 2, calls) } + +func TestSocialGitlab_Validate(t *testing.T) { + testCases := []struct { + name string + settings ssoModels.SSOSettings + requester identity.Requester + wantErr error + }{ + { + name: "SSOSettings is valid", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "auth_url": "", + "token_url": "", + "api_url": "", + }, + }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, + }, + { + name: "fails if settings map contains an invalid field", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "invalid_field": []int{1, 2, 3}, + }, + }, + wantErr: ssosettings.ErrInvalidSettings, + }, + { + name: "fails if client id is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if client id does not exist", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{}, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if both allow assign grafana admin and skip org role sync are enabled", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "skip_org_role_sync": "true", + "auth_url": "https://example.com/auth", + "token_url": "https://example.com/token", + }, + }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if the user is not allowed to update allow assign grafana admin", + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if api url is not empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "", + "token_url": "", + "api_url": "https://example.com/api", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if auth url is not empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "https://example.com/auth", + "token_url": "", + "api_url": "", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if token url is not empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "", + "token_url": "https://example.com/token", + "api_url": "", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewGitLabProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) + + if tc.requester == nil { + tc.requester = &user.SignedInUser{IsGrafanaAdmin: false} + } + + err := s.Validate(context.Background(), tc.settings, tc.requester) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestSocialGitlab_Reload(t *testing.T) { + testCases := []struct { + name string + info *social.OAuthInfo + settings ssoModels.SSOSettings + expectError bool + expectedInfo *social.OAuthInfo + expectedConfig *oauth2.Config + }{ + { + name: "SSO provider successfully updated", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "new-client-id", + "client_secret": "new-client-secret", + "auth_url": "some-new-url", + }, + }, + expectError: false, + expectedInfo: &social.OAuthInfo{ + ClientId: "new-client-id", + ClientSecret: "new-client-secret", + AuthUrl: "some-new-url", + }, + expectedConfig: &oauth2.Config{ + ClientID: "new-client-id", + ClientSecret: "new-client-secret", + Endpoint: oauth2.Endpoint{ + AuthURL: "some-new-url", + }, + RedirectURL: "/login/gitlab", + }, + }, + { + name: "fails if settings contain invalid values", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "new-client-id", + "client_secret": "new-client-secret", + "auth_url": []string{"first", "second"}, + }, + }, + expectError: true, + expectedInfo: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + expectedConfig: &oauth2.Config{ + ClientID: "client-id", + ClientSecret: "client-secret", + RedirectURL: "/login/gitlab", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewGitLabProvider(tc.info, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) + + err := s.Reload(context.Background(), tc.settings) + if tc.expectError { + require.Error(t, err) + return + } + require.NoError(t, err) + + require.EqualValues(t, tc.expectedInfo, s.info) + require.EqualValues(t, tc.expectedConfig, s.Config) + }) + } +} diff --git a/pkg/login/social/connectors/google_oauth.go b/pkg/login/social/connectors/google_oauth.go index bffc8f91b1cac..0bda73cd62a80 100644 --- a/pkg/login/social/connectors/google_oauth.go +++ b/pkg/login/social/connectors/google_oauth.go @@ -11,23 +11,32 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" + "github.com/grafana/grafana/pkg/services/ssosettings/validation" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util/errutil" ) const ( legacyAPIURL = "https://www.googleapis.com/oauth2/v1/userinfo" googleIAMGroupsEndpoint = "https://content-cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups" googleIAMScope = "https://www.googleapis.com/auth/cloud-identity.groups.readonly" + validateHDKey = "validate_hd" ) +var ExtraGoogleSettingKeys = map[string]ExtraKeyInfo{ + validateHDKey: {Type: Bool, DefaultValue: true}, +} + var _ social.SocialConnector = (*SocialGoogle)(nil) var _ ssosettings.Reloadable = (*SocialGoogle)(nil) type SocialGoogle struct { *SocialBase + validateHD bool } type googleUserData struct { @@ -35,13 +44,14 @@ type googleUserData struct { Email string `json:"email"` Name string `json:"name"` EmailVerified bool `json:"email_verified"` + HD string `json:"hd"` rawJSON []byte `json:"-"` } -func NewGoogleProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features *featuremgmt.FeatureManager) *SocialGoogle { - config := createOAuthConfig(info, cfg, social.GoogleProviderName) +func NewGoogleProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGoogle { provider := &SocialGoogle{ - SocialBase: newSocialBase(social.GoogleProviderName, config, info, cfg.AutoAssignOrgRole, *features), + SocialBase: newSocialBase(social.GoogleProviderName, info, features, cfg), + validateHD: MustBool(info.Extra[validateHDKey], true), } if strings.HasPrefix(info.ApiUrl, legacyAPIURL) { @@ -55,15 +65,46 @@ func NewGoogleProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings sso return provider } -func (s *SocialGoogle) Validate(ctx context.Context, settings ssoModels.SSOSettings) error { - return nil +func (s *SocialGoogle) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error { + info, err := CreateOAuthInfoFromKeyValues(settings.Settings) + if err != nil { + return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) + } + + err = validateInfo(info, requester) + if err != nil { + return err + } + + return validation.Validate(info, requester, + validation.MustBeEmptyValidator(info.AuthUrl, "Auth URL"), + validation.MustBeEmptyValidator(info.TokenUrl, "Token URL"), + validation.MustBeEmptyValidator(info.ApiUrl, "API URL")) } func (s *SocialGoogle) Reload(ctx context.Context, settings ssoModels.SSOSettings) error { + newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings) + if err != nil { + return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) + } + + if strings.HasPrefix(newInfo.ApiUrl, legacyAPIURL) { + s.log.Warn("Using legacy Google API URL, please update your configuration") + } + + s.reloadMutex.Lock() + defer s.reloadMutex.Unlock() + + s.updateInfo(social.GoogleProviderName, newInfo) + s.validateHD = MustBool(newInfo.Extra[validateHDKey], true) + return nil } func (s *SocialGoogle) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*social.BasicUserInfo, error) { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + data, errToken := s.extractFromToken(ctx, client, token) if errToken != nil { return nil, errToken @@ -85,6 +126,10 @@ func (s *SocialGoogle) UserInfo(ctx context.Context, client *http.Client, token return nil, fmt.Errorf("user email is not verified") } + if err := s.isHDAllowed(data.HD); err != nil { + return nil, err + } + groups, errPage := s.retrieveGroups(ctx, client, data) if errPage != nil { s.log.Warn("Error retrieving groups", "error", errPage) @@ -122,15 +167,12 @@ func (s *SocialGoogle) UserInfo(ctx context.Context, client *http.Client, token return userInfo, nil } -func (s *SocialGoogle) GetOAuthInfo() *social.OAuthInfo { - return s.info -} - type googleAPIData struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` EmailVerified bool `json:"verified_email"` + HD string `json:"hd"` } func (s *SocialGoogle) extractFromAPI(ctx context.Context, client *http.Client) (*googleUserData, error) { @@ -150,6 +192,7 @@ func (s *SocialGoogle) extractFromAPI(ctx context.Context, client *http.Client) Name: data.Name, Email: data.Email, EmailVerified: data.EmailVerified, + HD: data.HD, rawJSON: response.Body, }, nil } @@ -168,10 +211,13 @@ func (s *SocialGoogle) extractFromAPI(ctx context.Context, client *http.Client) } func (s *SocialGoogle) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + if s.info.UseRefreshToken { opts = append(opts, oauth2.AccessTypeOffline, oauth2.ApprovalForce) } - return s.SocialBase.AuthCodeURL(state, opts...) + return s.SocialBase.Config.AuthCodeURL(state, opts...) } func (s *SocialGoogle) extractFromToken(ctx context.Context, client *http.Client, token *oauth2.Token) (*googleUserData, error) { @@ -189,7 +235,7 @@ func (s *SocialGoogle) extractFromToken(ctx context.Context, client *http.Client return nil, nil } - if setting.Env == setting.Dev { + if s.cfg.Env == setting.Dev { s.log.Debug("Received id_token", "raw_json", string(rawJSON)) } @@ -215,7 +261,7 @@ type googleGroupResp struct { } func (s *SocialGoogle) retrieveGroups(ctx context.Context, client *http.Client, userData *googleUserData) ([]string, error) { - s.log.Debug("Retrieving groups", "scopes", s.SocialBase.Config.Scopes) + s.log.Debug("Retrieving groups", "scopes", s.Config.Scopes) if !slices.Contains(s.Scopes, googleIAMScope) { return nil, nil } @@ -260,3 +306,21 @@ func (s *SocialGoogle) getGroupsPage(ctx context.Context, client *http.Client, u return &data, nil } + +func (s *SocialGoogle) isHDAllowed(hd string) error { + if s.validateHD { + return nil + } + + if len(s.info.AllowedDomains) == 0 { + return nil + } + + for _, allowedDomain := range s.info.AllowedDomains { + if hd == allowedDomain { + return nil + } + } + + return errutil.Forbidden("the hd claim found in the ID token is not present in the allowed domains", errutil.WithPublicMessage("Invalid domain")) +} diff --git a/pkg/login/social/connectors/google_oauth_test.go b/pkg/login/social/connectors/google_oauth_test.go index dffd3d74bc423..64b40f5913020 100644 --- a/pkg/login/social/connectors/google_oauth_test.go +++ b/pkg/login/social/connectors/google_oauth_test.go @@ -16,8 +16,12 @@ import ( "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/ssosettings" + ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -664,3 +668,277 @@ func TestSocialGoogle_UserInfo(t *testing.T) { }) } } + +func TestSocialGoogle_Validate(t *testing.T) { + testCases := []struct { + name string + settings ssoModels.SSOSettings + requester identity.Requester + wantErr error + }{ + { + name: "SSOSettings is valid", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "auth_url": "", + "token_url": "", + "api_url": "", + }, + }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, + }, + { + name: "fails if settings map contains an invalid field", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "invalid_field": []int{1, 2, 3}, + }, + }, + wantErr: ssosettings.ErrInvalidSettings, + }, + { + name: "fails if client id is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if client id does not exist", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{}, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if both allow assign grafana admin and skip org role sync are enabled", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "skip_org_role_sync": "true", + "auth_url": "https://example.com/auth", + "token_url": "https://example.com/token", + }, + }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if the user is not allowed to update allow assign grafana admin", + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "auth_url": "https://example.com/auth", + "token_url": "https://example.com/token", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if api url is not empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "", + "token_url": "", + "api_url": "https://example.com/api", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if token url is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "https://example.com/auth", + "token_url": "", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if auth url is not empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "https://example.com/auth", + "token_url": "", + "api_url": "", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if api token url is not empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "", + "token_url": "https://example.com/token", + "api_url": "", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewGoogleProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) + + if tc.requester == nil { + tc.requester = &user.SignedInUser{IsGrafanaAdmin: false} + } + + err := s.Validate(context.Background(), tc.settings, tc.requester) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestSocialGoogle_Reload(t *testing.T) { + testCases := []struct { + name string + info *social.OAuthInfo + settings ssoModels.SSOSettings + expectError bool + expectedInfo *social.OAuthInfo + expectedConfig *oauth2.Config + }{ + { + name: "SSO provider successfully updated", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "new-client-id", + "client_secret": "new-client-secret", + "auth_url": "some-new-url", + }, + }, + expectError: false, + expectedInfo: &social.OAuthInfo{ + ClientId: "new-client-id", + ClientSecret: "new-client-secret", + AuthUrl: "some-new-url", + }, + expectedConfig: &oauth2.Config{ + ClientID: "new-client-id", + ClientSecret: "new-client-secret", + Endpoint: oauth2.Endpoint{ + AuthURL: "some-new-url", + }, + RedirectURL: "/login/google", + }, + }, + { + name: "fails if settings contain invalid values", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "new-client-id", + "client_secret": "new-client-secret", + "auth_url": []string{"first", "second"}, + }, + }, + expectError: true, + expectedInfo: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + expectedConfig: &oauth2.Config{ + ClientID: "client-id", + ClientSecret: "client-secret", + RedirectURL: "/login/google", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewGoogleProvider(tc.info, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) + + err := s.Reload(context.Background(), tc.settings) + if tc.expectError { + require.Error(t, err) + return + } + require.NoError(t, err) + + require.EqualValues(t, tc.expectedInfo, s.info) + require.EqualValues(t, tc.expectedConfig, s.Config) + }) + } +} + +func TestIsHDAllowed(t *testing.T) { + testCases := []struct { + name string + email string + allowedDomains []string + expectedErrorMessage string + validateHD bool + }{ + { + name: "should not fail if no allowed domains are set", + email: "mycompany.com", + allowedDomains: []string{}, + expectedErrorMessage: "", + }, + { + name: "should not fail if email is from allowed domain", + email: "mycompany.com", + allowedDomains: []string{"grafana.com", "mycompany.com", "example.com"}, + expectedErrorMessage: "", + }, + { + name: "should fail if email is not from allowed domain", + email: "mycompany.com", + allowedDomains: []string{"grafana.com", "example.com"}, + expectedErrorMessage: "the hd claim found in the ID token is not present in the allowed domains", + }, + { + name: "should not fail if the HD validation is disabled and the email not being from an allowed domain", + email: "mycompany.com", + allowedDomains: []string{"grafana.com", "example.com"}, + validateHD: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + info := &social.OAuthInfo{} + info.AllowedDomains = tc.allowedDomains + s := NewGoogleProvider(info, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) + s.validateHD = tc.validateHD + err := s.isHDAllowed(tc.email) + + if tc.expectedErrorMessage != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErrorMessage) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/login/social/connectors/grafana_com_oauth.go b/pkg/login/social/connectors/grafana_com_oauth.go index d3bda3607efb2..c68ecb2d8e8bf 100644 --- a/pkg/login/social/connectors/grafana_com_oauth.go +++ b/pkg/login/social/connectors/grafana_com_oauth.go @@ -10,15 +10,19 @@ import ( "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" + "github.com/grafana/grafana/pkg/services/ssosettings/validation" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) -var ExtraGrafanaComSettingKeys = []string{allowedOrganizationsKey} +var ExtraGrafanaComSettingKeys = map[string]ExtraKeyInfo{ + allowedOrganizationsKey: {Type: String, DefaultValue: ""}, +} var _ social.SocialConnector = (*SocialGrafanaCom)(nil) var _ ssosettings.Reloadable = (*SocialGrafanaCom)(nil) @@ -33,15 +37,14 @@ type OrgRecord struct { Login string `json:"login"` } -func NewGrafanaComProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features *featuremgmt.FeatureManager) *SocialGrafanaCom { +func NewGrafanaComProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGrafanaCom { // Override necessary settings info.AuthUrl = cfg.GrafanaComURL + "/oauth2/authorize" info.TokenUrl = cfg.GrafanaComURL + "/api/oauth2/token" info.AuthStyle = "inheader" - config := createOAuthConfig(info, cfg, social.GrafanaComProviderName) provider := &SocialGrafanaCom{ - SocialBase: newSocialBase(social.GrafanaComProviderName, config, info, cfg.AutoAssignOrgRole, *features), + SocialBase: newSocialBase(social.GrafanaComProviderName, info, features, cfg), url: cfg.GrafanaComURL, allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]), } @@ -53,11 +56,42 @@ func NewGrafanaComProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings return provider } -func (s *SocialGrafanaCom) Validate(ctx context.Context, settings ssoModels.SSOSettings) error { - return nil +func (s *SocialGrafanaCom) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error { + info, err := CreateOAuthInfoFromKeyValues(settings.Settings) + if err != nil { + return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) + } + + err = validateInfo(info, requester) + if err != nil { + return err + } + + return validation.Validate(info, requester, + validation.MustBeEmptyValidator(info.AuthUrl, "Auth URL"), + validation.MustBeEmptyValidator(info.TokenUrl, "Token URL"), + validation.MustBeEmptyValidator(info.TeamsUrl, "Teams URL")) } func (s *SocialGrafanaCom) Reload(ctx context.Context, settings ssoModels.SSOSettings) error { + newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings) + if err != nil { + return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) + } + + // Override necessary settings + newInfo.AuthUrl = s.cfg.GrafanaComURL + "/oauth2/authorize" + newInfo.TokenUrl = s.cfg.GrafanaComURL + "/api/oauth2/token" + newInfo.AuthStyle = "inheader" + + s.reloadMutex.Lock() + defer s.reloadMutex.Unlock() + + s.updateInfo(social.GrafanaComProviderName, newInfo) + + s.url = s.cfg.GrafanaComURL + s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey]) + return nil } @@ -65,7 +99,7 @@ func (s *SocialGrafanaCom) IsEmailAllowed(email string) bool { return true } -func (s *SocialGrafanaCom) IsOrganizationMember(organizations []OrgRecord) bool { +func (s *SocialGrafanaCom) isOrganizationMember(organizations []OrgRecord) bool { if len(s.allowedOrganizations) == 0 { return true } @@ -83,6 +117,9 @@ func (s *SocialGrafanaCom) IsOrganizationMember(organizations []OrgRecord) bool // UserInfo is used for login credentials for the user func (s *SocialGrafanaCom) UserInfo(ctx context.Context, client *http.Client, _ *oauth2.Token) (*social.BasicUserInfo, error) { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + var data struct { Id int `json:"id"` Name string `json:"name"` @@ -116,7 +153,7 @@ func (s *SocialGrafanaCom) UserInfo(ctx context.Context, client *http.Client, _ Role: role, } - if !s.IsOrganizationMember(data.Orgs) { + if !s.isOrganizationMember(data.Orgs) { return nil, ErrMissingOrganizationMembership.Errorf( "User is not a member of any of the allowed organizations: %v. Returned Organizations: %v", s.allowedOrganizations, data.Orgs) @@ -124,7 +161,3 @@ func (s *SocialGrafanaCom) UserInfo(ctx context.Context, client *http.Client, _ return userInfo, nil } - -func (s *SocialGrafanaCom) GetOAuthInfo() *social.OAuthInfo { - return s.info -} diff --git a/pkg/login/social/connectors/grafana_com_oauth_test.go b/pkg/login/social/connectors/grafana_com_oauth_test.go index 63c0c668b89f7..dc800775f897c 100644 --- a/pkg/login/social/connectors/grafana_com_oauth_test.go +++ b/pkg/login/social/connectors/grafana_com_oauth_test.go @@ -7,10 +7,14 @@ import ( "testing" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" + ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -132,3 +136,236 @@ func TestSocialGrafanaCom_InitializeExtraFields(t *testing.T) { }) } } + +func TestSocialGrafanaCom_Validate(t *testing.T) { + testCases := []struct { + name string + settings ssoModels.SSOSettings + requester identity.Requester + expectError bool + }{ + { + name: "SSOSettings is valid", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + }, + }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, + expectError: false, + }, + { + name: "fails if settings map contains an invalid field", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "invalid_field": []int{1, 2, 3}, + }, + }, + expectError: true, + }, + { + name: "fails if client id is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "", + }, + }, + expectError: true, + }, + { + name: "fails if client id does not exist", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{}, + }, + expectError: true, + }, + { + name: "fails if the user is not allowed to update allow assign grafana admin", + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "skip_org_role_sync": "true", + }, + }, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewGrafanaComProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) + + if tc.requester == nil { + tc.requester = &user.SignedInUser{IsGrafanaAdmin: false} + } + + err := s.Validate(context.Background(), tc.settings, tc.requester) + if tc.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestSocialGrafanaCom_Reload(t *testing.T) { + const GrafanaComURL = "http://localhost:3000" + + testCases := []struct { + name string + info *social.OAuthInfo + settings ssoModels.SSOSettings + expectError bool + expectedInfo *social.OAuthInfo + expectedConfig *oauth2.Config + }{ + { + name: "SSO provider successfully updated", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "new-client-id", + "client_secret": "new-client-secret", + "name": "a-new-name", + }, + }, + expectError: false, + expectedInfo: &social.OAuthInfo{ + ClientId: "new-client-id", + ClientSecret: "new-client-secret", + Name: "a-new-name", + AuthUrl: GrafanaComURL + "/oauth2/authorize", + TokenUrl: GrafanaComURL + "/api/oauth2/token", + AuthStyle: "inheader", + }, + expectedConfig: &oauth2.Config{ + ClientID: "new-client-id", + ClientSecret: "new-client-secret", + Endpoint: oauth2.Endpoint{ + AuthURL: GrafanaComURL + "/oauth2/authorize", + TokenURL: GrafanaComURL + "/api/oauth2/token", + AuthStyle: oauth2.AuthStyleInHeader, + }, + RedirectURL: "/login/grafana_com", + }, + }, + { + name: "fails if settings contain invalid values", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "new-client-id", + "client_secret": "new-client-secret", + "auth_url": []string{"first", "second"}, + }, + }, + expectError: true, + expectedInfo: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + // these are the overwrites from the constructor + AuthUrl: GrafanaComURL + "/oauth2/authorize", + TokenUrl: GrafanaComURL + "/api/oauth2/token", + AuthStyle: "inheader", + }, + expectedConfig: &oauth2.Config{ + ClientID: "client-id", + ClientSecret: "client-secret", + Endpoint: oauth2.Endpoint{ + AuthURL: GrafanaComURL + "/oauth2/authorize", + TokenURL: GrafanaComURL + "/api/oauth2/token", + AuthStyle: oauth2.AuthStyleInHeader, + }, + RedirectURL: "/login/grafana_com", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cfg := &setting.Cfg{ + GrafanaComURL: GrafanaComURL, + } + s := NewGrafanaComProvider(tc.info, cfg, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) + + err := s.Reload(context.Background(), tc.settings) + if tc.expectError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + require.EqualValues(t, tc.expectedInfo, s.info) + require.EqualValues(t, tc.expectedConfig, s.Config) + }) + } +} + +func TestSocialGrafanaCom_Reload_ExtraFields(t *testing.T) { + const GrafanaComURL = "http://localhost:3000" + + testCases := []struct { + name string + settings ssoModels.SSOSettings + info *social.OAuthInfo + expectError bool + expectedInfo *social.OAuthInfo + expectedAllowedOrganizations []string + }{ + { + name: "successfully reloads the allowed organizations when they are set in the settings", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + Extra: map[string]string{ + "allowed_organizations": "previous", + }, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "allowed_organizations": "uuid-1234,uuid-5678", + }, + }, + expectedInfo: &social.OAuthInfo{ + ClientId: "new-client-id", + ClientSecret: "new-client-secret", + Name: "a-new-name", + AuthUrl: GrafanaComURL + "/oauth2/authorize", + TokenUrl: GrafanaComURL + "/api/oauth2/token", + AuthStyle: "inheader", + Extra: map[string]string{ + "allowed_organizations": "uuid-1234,uuid-5678", + }, + }, + expectedAllowedOrganizations: []string{"uuid-1234", "uuid-5678"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cfg := &setting.Cfg{ + GrafanaComURL: GrafanaComURL, + } + s := NewGrafanaComProvider(tc.info, cfg, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) + + err := s.Reload(context.Background(), tc.settings) + require.NoError(t, err) + + require.EqualValues(t, tc.expectedAllowedOrganizations, s.allowedOrganizations) + }) + } +} diff --git a/pkg/login/social/connectors/okta_oauth.go b/pkg/login/social/connectors/okta_oauth.go index 89f043c54c5a0..d548827c83cf7 100644 --- a/pkg/login/social/connectors/okta_oauth.go +++ b/pkg/login/social/connectors/okta_oauth.go @@ -12,9 +12,11 @@ import ( "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" + "github.com/grafana/grafana/pkg/services/ssosettings/validation" "github.com/grafana/grafana/pkg/setting" ) @@ -44,14 +46,13 @@ type OktaClaims struct { Name string `json:"name"` } -func NewOktaProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features *featuremgmt.FeatureManager) *SocialOkta { - config := createOAuthConfig(info, cfg, social.OktaProviderName) +func NewOktaProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialOkta { provider := &SocialOkta{ - SocialBase: newSocialBase(social.OktaProviderName, config, info, cfg.AutoAssignOrgRole, *features), + SocialBase: newSocialBase(social.OktaProviderName, info, features, cfg), } if info.UseRefreshToken { - appendUniqueScope(config, social.OfflineAccessScope) + appendUniqueScope(provider.Config, social.OfflineAccessScope) } if features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsApi) { @@ -61,11 +62,37 @@ func NewOktaProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssose return provider } -func (s *SocialOkta) Validate(ctx context.Context, settings ssoModels.SSOSettings) error { - return nil +func (s *SocialOkta) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error { + info, err := CreateOAuthInfoFromKeyValues(settings.Settings) + if err != nil { + return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) + } + + err = validateInfo(info, requester) + if err != nil { + return err + } + + return validation.Validate(info, requester, + validation.RequiredUrlValidator(info.AuthUrl, "Auth URL"), + validation.RequiredUrlValidator(info.TokenUrl, "Token URL"), + validation.RequiredUrlValidator(info.ApiUrl, "API URL")) } func (s *SocialOkta) Reload(ctx context.Context, settings ssoModels.SSOSettings) error { + newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings) + if err != nil { + return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) + } + + s.reloadMutex.Lock() + defer s.reloadMutex.Unlock() + + s.updateInfo(social.OktaProviderName, newInfo) + if newInfo.UseRefreshToken { + appendUniqueScope(s.Config, social.OfflineAccessScope) + } + return nil } @@ -78,6 +105,9 @@ func (claims *OktaClaims) extractEmail() string { } func (s *SocialOkta) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*social.BasicUserInfo, error) { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + idToken := token.Extra("id_token") if idToken == nil { return nil, fmt.Errorf("no id_token found") @@ -104,8 +134,8 @@ func (s *SocialOkta) UserInfo(ctx context.Context, client *http.Client, token *o return nil, err } - groups := s.GetGroups(&data) - if !s.IsGroupMember(groups) { + groups := s.getGroups(&data) + if !s.isGroupMember(groups) { return nil, errMissingGroupMembership } @@ -137,10 +167,6 @@ func (s *SocialOkta) UserInfo(ctx context.Context, client *http.Client, token *o }, nil } -func (s *SocialOkta) GetOAuthInfo() *social.OAuthInfo { - return s.info -} - func (s *SocialOkta) extractAPI(ctx context.Context, data *OktaUserInfoJson, client *http.Client) error { rawUserInfoResponse, err := s.httpGet(ctx, client, s.info.ApiUrl) if err != nil { @@ -160,7 +186,7 @@ func (s *SocialOkta) extractAPI(ctx context.Context, data *OktaUserInfoJson, cli return nil } -func (s *SocialOkta) GetGroups(data *OktaUserInfoJson) []string { +func (s *SocialOkta) getGroups(data *OktaUserInfoJson) []string { groups := make([]string, 0) if len(data.Groups) > 0 { groups = data.Groups @@ -169,7 +195,7 @@ func (s *SocialOkta) GetGroups(data *OktaUserInfoJson) []string { } // TODO: remove this in a separate PR and use the isGroupMember from the social.go -func (s *SocialOkta) IsGroupMember(groups []string) bool { +func (s *SocialOkta) isGroupMember(groups []string) bool { if len(s.info.AllowedGroups) == 0 { return true } diff --git a/pkg/login/social/connectors/okta_oauth_test.go b/pkg/login/social/connectors/okta_oauth_test.go index 27019a03280e9..d3c443083426d 100644 --- a/pkg/login/social/connectors/okta_oauth_test.go +++ b/pkg/login/social/connectors/okta_oauth_test.go @@ -14,8 +14,12 @@ import ( "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/ssosettings" + ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -132,3 +136,243 @@ func TestSocialOkta_UserInfo(t *testing.T) { }) } } + +func TestSocialOkta_Validate(t *testing.T) { + testCases := []struct { + name string + settings ssoModels.SSOSettings + requester identity.Requester + wantErr error + }{ + { + name: "SSOSettings is valid", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "auth_url": "https://example.com/auth", + "token_url": "https://example.com/token", + "api_url": "https://example.com/api", + }, + }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, + }, + { + name: "fails if settings map contains an invalid field", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "invalid_field": []int{1, 2, 3}, + }, + }, + wantErr: ssosettings.ErrInvalidSettings, + }, + { + name: "fails if client id is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if client id does not exist", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{}, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if both allow assign grafana admin and skip org role sync are enabled", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "skip_org_role_sync": "true", + }, + }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if the user is not allowed to update allow assign grafana admin", + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "auth_url": "https://example.com/auth", + "token_url": "https://example.com/token", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if auth url is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "", + "token_url": "https://example.com/token", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if token url is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "https://example.com/auth", + "token_url": "", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if auth url is invalid", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "invalid_url", + "token_url": "https://example.com/token", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if token url is invalid", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "https://example.com/auth", + "token_url": "/path", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if api url is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "https://example.com/auth", + "token_url": "https://example.com/token", + "api_url": "", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "fails if api url is invalid", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "auth_url": "https://example.com/auth", + "api_url": "/api", + "token_url": "https://example.com/token", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewOktaProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) + + if tc.requester == nil { + tc.requester = &user.SignedInUser{IsGrafanaAdmin: false} + } + err := s.Validate(context.Background(), tc.settings, tc.requester) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestSocialOkta_Reload(t *testing.T) { + testCases := []struct { + name string + info *social.OAuthInfo + settings ssoModels.SSOSettings + expectError bool + expectedInfo *social.OAuthInfo + expectedConfig *oauth2.Config + }{ + { + name: "SSO provider successfully updated", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "new-client-id", + "client_secret": "new-client-secret", + "auth_url": "some-new-url", + }, + }, + expectError: false, + expectedInfo: &social.OAuthInfo{ + ClientId: "new-client-id", + ClientSecret: "new-client-secret", + AuthUrl: "some-new-url", + }, + expectedConfig: &oauth2.Config{ + ClientID: "new-client-id", + ClientSecret: "new-client-secret", + Endpoint: oauth2.Endpoint{ + AuthURL: "some-new-url", + }, + RedirectURL: "/login/okta", + }, + }, + { + name: "fails if settings contain invalid values", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "new-client-id", + "client_secret": "new-client-secret", + "auth_url": []string{"first", "second"}, + }, + }, + expectError: true, + expectedInfo: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + expectedConfig: &oauth2.Config{ + ClientID: "client-id", + ClientSecret: "client-secret", + RedirectURL: "/login/okta", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewOktaProvider(tc.info, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) + + err := s.Reload(context.Background(), tc.settings) + if tc.expectError { + require.Error(t, err) + return + } + require.NoError(t, err) + + require.EqualValues(t, tc.expectedInfo, s.info) + require.EqualValues(t, tc.expectedConfig, s.Config) + }) + } +} diff --git a/pkg/login/social/connectors/social_base.go b/pkg/login/social/connectors/social_base.go index 5ee11e3be2065..6e53c13e7698c 100644 --- a/pkg/login/social/connectors/social_base.go +++ b/pkg/login/social/connectors/social_base.go @@ -3,12 +3,15 @@ package connectors import ( "bytes" "compress/zlib" + "context" "encoding/base64" "encoding/json" "fmt" "io" + "net/http" "regexp" "strings" + "sync" "golang.org/x/oauth2" "golang.org/x/text/cases" @@ -16,46 +19,97 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/ssosettings/validation" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) type SocialBase struct { *oauth2.Config - info *social.OAuthInfo - log log.Logger - autoAssignOrgRole string - features featuremgmt.FeatureManager + info *social.OAuthInfo + cfg *setting.Cfg + reloadMutex sync.RWMutex + log log.Logger + features featuremgmt.FeatureToggles } func newSocialBase(name string, - config *oauth2.Config, info *social.OAuthInfo, - autoAssignOrgRole string, - features featuremgmt.FeatureManager, + features featuremgmt.FeatureToggles, + cfg *setting.Cfg, ) *SocialBase { logger := log.New("oauth." + name) return &SocialBase{ - Config: config, - info: info, - log: logger, - autoAssignOrgRole: autoAssignOrgRole, - features: features, + Config: createOAuthConfig(info, cfg, name), + info: info, + log: logger, + features: features, + cfg: cfg, } } +func (s *SocialBase) updateInfo(name string, info *social.OAuthInfo) { + s.Config = createOAuthConfig(info, s.cfg, name) + s.info = info +} + type groupStruct struct { Groups []string `json:"groups"` } func (s *SocialBase) SupportBundleContent(bf *bytes.Buffer) error { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + + return s.getBaseSupportBundleContent(bf) +} + +func (s *SocialBase) GetOAuthInfo() *social.OAuthInfo { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + + return s.info +} + +func (s *SocialBase) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + + return s.Config.AuthCodeURL(state, opts...) +} + +func (s *SocialBase) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + + return s.Config.Exchange(ctx, code, opts...) +} + +func (s *SocialBase) Client(ctx context.Context, t *oauth2.Token) *http.Client { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + + return s.Config.Client(ctx, t) +} + +func (s *SocialBase) TokenSource(ctx context.Context, t *oauth2.Token) oauth2.TokenSource { + s.reloadMutex.RLock() + defer s.reloadMutex.RUnlock() + + return s.Config.TokenSource(ctx, t) +} + +func (s *SocialBase) getBaseSupportBundleContent(bf *bytes.Buffer) error { bf.WriteString("## Client configuration\n\n") bf.WriteString("```ini\n") bf.WriteString(fmt.Sprintf("allow_assign_grafana_admin = %v\n", s.info.AllowAssignGrafanaAdmin)) bf.WriteString(fmt.Sprintf("allow_sign_up = %v\n", s.info.AllowSignup)) bf.WriteString(fmt.Sprintf("allowed_domains = %v\n", s.info.AllowedDomains)) - bf.WriteString(fmt.Sprintf("auto_assign_org_role = %v\n", s.autoAssignOrgRole)) + bf.WriteString(fmt.Sprintf("auto_assign_org_role = %v\n", s.cfg.AutoAssignOrgRole)) bf.WriteString(fmt.Sprintf("role_attribute_path = %v\n", s.info.RoleAttributePath)) bf.WriteString(fmt.Sprintf("role_attribute_strict = %v\n", s.info.RoleAttributeStrict)) bf.WriteString(fmt.Sprintf("skip_org_role_sync = %v\n", s.info.SkipOrgRoleSync)) @@ -67,6 +121,7 @@ func (s *SocialBase) SupportBundleContent(bf *bytes.Buffer) error { bf.WriteString(fmt.Sprintf("redirect_url = %v\n", s.Config.RedirectURL)) bf.WriteString(fmt.Sprintf("scopes = %v\n", s.Config.Scopes)) bf.WriteString("```\n\n") + return nil } @@ -101,13 +156,13 @@ func (s *SocialBase) extractRoleAndAdmin(rawJSON []byte, groups []string) (org.R } func (s *SocialBase) searchRole(rawJSON []byte, groups []string) (org.RoleType, bool) { - role, err := s.searchJSONForStringAttr(s.info.RoleAttributePath, rawJSON) + role, err := util.SearchJSONForStringAttr(s.info.RoleAttributePath, rawJSON) if err == nil && role != "" { return getRoleFromSearch(role) } if groupBytes, err := json.Marshal(groupStruct{groups}); err == nil { - role, err := s.searchJSONForStringAttr(s.info.RoleAttributePath, groupBytes) + role, err := util.SearchJSONForStringAttr(s.info.RoleAttributePath, groupBytes) if err == nil && role != "" { return getRoleFromSearch(role) } @@ -119,9 +174,9 @@ func (s *SocialBase) searchRole(rawJSON []byte, groups []string) (org.RoleType, // defaultRole returns the default role for the user based on the autoAssignOrgRole setting // if legacy is enabled "" is returned indicating the previous role assignment is used. func (s *SocialBase) defaultRole() org.RoleType { - if s.autoAssignOrgRole != "" { + if s.cfg.AutoAssignOrgRole != "" { s.log.Debug("No role found, returning default.") - return org.RoleType(s.autoAssignOrgRole) + return org.RoleType(s.cfg.AutoAssignOrgRole) } // should never happen @@ -209,3 +264,10 @@ func getRoleFromSearch(role string) (org.RoleType, bool) { return org.RoleType(cases.Title(language.Und).String(role)), false } + +func validateInfo(info *social.OAuthInfo, requester identity.Requester) error { + return validation.Validate(info, requester, + validation.RequiredValidator(info.ClientId, "Client Id"), + validation.AllowAssignGrafanaAdminValidator, + validation.SkipOrgRoleSyncAllowAssignGrafanaAdminValidator) +} diff --git a/pkg/login/social/socialimpl/service.go b/pkg/login/social/socialimpl/service.go index 30893daca07cf..3dc66ff819ef6 100644 --- a/pkg/login/social/socialimpl/service.go +++ b/pkg/login/social/socialimpl/service.go @@ -37,7 +37,7 @@ type SocialService struct { } func ProvideService(cfg *setting.Cfg, - features *featuremgmt.FeatureManager, + features featuremgmt.FeatureToggles, usageStats usagestats.Service, bundleRegistry supportbundles.Service, cache remotecache.CacheStorage, @@ -84,11 +84,6 @@ func ProvideService(cfg *setting.Cfg, continue } - // Workaround for moving the SkipOrgRoleSync setting to the OAuthInfo struct - withOverrides := cfg.SectionWithEnvOverrides("auth." + name) - info.Enabled = withOverrides.Key("enabled").MustBool(false) - info.SkipOrgRoleSync = withOverrides.Key("skip_org_role_sync").MustBool(false) - if !info.Enabled { continue } @@ -228,7 +223,7 @@ func (ss *SocialService) getUsageStats(ctx context.Context) (map[string]any, err return m, nil } -func createOAuthConnector(name string, info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features *featuremgmt.FeatureManager, cache remotecache.CacheStorage) (social.SocialConnector, error) { +func createOAuthConnector(name string, info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles, cache remotecache.CacheStorage) (social.SocialConnector, error) { switch name { case social.AzureADProviderName: return connectors.NewAzureADProvider(info, cfg, ssoSettings, features, cache), nil diff --git a/pkg/login/social/socialimpl/service_test.go b/pkg/login/social/socialimpl/service_test.go index fe3d885fedd52..04fa8b403f0de 100644 --- a/pkg/login/social/socialimpl/service_test.go +++ b/pkg/login/social/socialimpl/service_test.go @@ -18,11 +18,16 @@ import ( "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingsimpl" "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestSocialService_ProvideService(t *testing.T) { type testEnv struct { - features *featuremgmt.FeatureManager + features featuremgmt.FeatureToggles } testCases := []struct { name string @@ -44,15 +49,6 @@ func TestSocialService_ProvideService(t *testing.T) { expectedSocialMapLength: 7, expectedGenericOAuthSkipOrgRoleSync: false, }, - { - name: "should load Enabled and SkipOrgRoleSync parameters from environment variables when ssoSettingsApi is disabled", - setup: func(t *testing.T, env *testEnv) { - t.Setenv("GF_AUTH_GENERIC_OAUTH_ENABLED", "true") - t.Setenv("GF_AUTH_GENERIC_OAUTH_SKIP_ORG_ROLE_SYNC", "true") - }, - expectedSocialMapLength: 2, - expectedGenericOAuthSkipOrgRoleSync: true, - }, } iniContent := ` [auth.azuread] @@ -73,7 +69,7 @@ func TestSocialService_ProvideService(t *testing.T) { accessControl := acimpl.ProvideAccessControl(cfg) sqlStore := db.InitTestDB(t) - ssoSettingsSvc := ssosettingsimpl.ProvideService(cfg, sqlStore, accessControl, routing.NewRouteRegister(), featuremgmt.WithFeatures(), secrets) + ssoSettingsSvc := ssosettingsimpl.ProvideService(cfg, sqlStore, accessControl, routing.NewRouteRegister(), featuremgmt.WithFeatures(), secrets, &usagestats.UsageStatsMock{}, nil) for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { @@ -95,6 +91,101 @@ func TestSocialService_ProvideService(t *testing.T) { } } +func TestSocialService_ProvideService_GrafanaComGrafanaNet(t *testing.T) { + testCases := []struct { + name string + rawIniContent string + expectedGrafanaComOAuthInfo *social.OAuthInfo + }{ + { + name: "should setup the connector using auth.grafana_com section if it is enabled", + rawIniContent: ` + [auth.grafana_com] + enabled = true + client_id = grafanaComClientId + + [auth.grafananet] + enabled = false + client_id = grafanaNetClientId`, + expectedGrafanaComOAuthInfo: &social.OAuthInfo{ + AuthStyle: "inheader", + AuthUrl: "/oauth2/authorize", + TokenUrl: "/api/oauth2/token", + Enabled: true, + ClientId: "grafanaComClientId", + }, + }, + { + name: "should setup the connector using auth.grafananet section if it is enabled", + rawIniContent: ` + [auth.grafana_com] + enabled = false + client_id = grafanaComClientId + + [auth.grafananet] + enabled = true + client_id = grafanaNetClientId`, + expectedGrafanaComOAuthInfo: &social.OAuthInfo{ + AuthStyle: "inheader", + AuthUrl: "/oauth2/authorize", + TokenUrl: "/api/oauth2/token", + Enabled: true, + ClientId: "grafanaNetClientId", + }, + }, + { + name: "should setup the connector using auth.grafana_com section if both are enabled", + rawIniContent: ` + [auth.grafana_com] + enabled = true + client_id = grafanaComClientId + + [auth.grafananet] + enabled = true + client_id = grafanaNetClientId`, + expectedGrafanaComOAuthInfo: &social.OAuthInfo{ + AuthStyle: "inheader", + AuthUrl: "/oauth2/authorize", + TokenUrl: "/api/oauth2/token", + Enabled: true, + ClientId: "grafanaComClientId", + }, + }, + { + name: "should not setup the connector when both are disabled", + rawIniContent: ` + [auth.grafana_com] + enabled = false + client_id = grafanaComClientId + + [auth.grafananet] + enabled = false + client_id = grafanaNetClientId`, + expectedGrafanaComOAuthInfo: nil, + }, + } + + cfg := setting.NewCfg() + secrets := secretsfake.NewMockService(t) + accessControl := acimpl.ProvideAccessControl(cfg) + sqlStore := db.InitTestDB(t) + + ssoSettingsSvc := ssosettingsimpl.ProvideService(cfg, sqlStore, accessControl, routing.NewRouteRegister(), featuremgmt.WithFeatures(), secrets, &usagestats.UsageStatsMock{}, nil) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + iniFile, err := ini.Load([]byte(tc.rawIniContent)) + require.NoError(t, err) + + cfg := setting.NewCfg() + cfg.Raw = iniFile + + socialService := ProvideService(cfg, featuremgmt.WithFeatures(), &usagestats.UsageStatsMock{}, supportbundlestest.NewFakeBundleService(), remotecache.NewFakeStore(t), ssoSettingsSvc) + require.EqualValues(t, tc.expectedGrafanaComOAuthInfo, socialService.GetOAuthInfoProvider("grafana_com")) + }) + } +} + func TestMapping_IniSectionOAuthInfo(t *testing.T) { iniContent := ` [test] diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index b56c473bb6253..ef726a08c2e7a 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -56,7 +56,7 @@ func notAuthorized(c *contextmodel.ReqContext) { func tokenRevoked(c *contextmodel.ReqContext, err *auth.TokenRevokedError) { if c.IsApiRequest() { - c.JSON(401, map[string]any{ + c.JSON(http.StatusUnauthorized, map[string]any{ "message": "Token revoked", "error": map[string]any{ "id": "ERR_TOKEN_REVOKED", diff --git a/pkg/middleware/loggermw/logger.go b/pkg/middleware/loggermw/logger.go index a4bfe1003bcf9..34dcab5066e88 100644 --- a/pkg/middleware/loggermw/logger.go +++ b/pkg/middleware/loggermw/logger.go @@ -74,6 +74,7 @@ func (l *loggerImpl) Middleware() web.Middleware { duration := time.Since(start) timeTaken := duration / time.Millisecond ctx := contexthandler.FromContext(r.Context()) + if ctx != nil && ctx.PerfmonTimer != nil { ctx.PerfmonTimer.Observe(float64(timeTaken)) } @@ -128,10 +129,8 @@ func (l *loggerImpl) prepareLogParams(c *contextmodel.ReqContext, duration time. logParams = append(logParams, "handler", handler) } - if l.flags.IsEnabled(r.Context(), featuremgmt.FlagRequestInstrumentationStatusSource) { - rmd := requestmeta.GetRequestMetaData(c.Req.Context()) - logParams = append(logParams, "status_source", rmd.StatusSource) - } + rmd := requestmeta.GetRequestMetaData(c.Req.Context()) + logParams = append(logParams, "status_source", rmd.StatusSource) logParams = append(logParams, errorLogParams(c.Error)...) diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 3842f6d519d35..722c9f093357c 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -150,9 +150,9 @@ func TestMiddlewareContext(t *testing.T) { Settings: &dtos.FrontendSettingsDTO{}, NavTree: &navtree.NavTreeRoot{}, Assets: &dtos.EntryPointAssets{ - JSFiles: []dtos.EntryPointAsset{}, - CSSDark: "dark.css", - CSSLight: "light.css", + JSFiles: []dtos.EntryPointAsset{}, + Dark: "dark.css", + Light: "light.css", }, } t.Log("Calling HTML", "data", data) diff --git a/pkg/middleware/recovery.go b/pkg/middleware/recovery.go index 3d23c3e5ed329..01e07ee8e3869 100644 --- a/pkg/middleware/recovery.go +++ b/pkg/middleware/recovery.go @@ -27,6 +27,7 @@ import ( "github.com/grafana/grafana/pkg/api/webassets" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -104,7 +105,7 @@ func function(pc uintptr) []byte { // Recovery returns a middleware that recovers from any panics and writes a 500 if there was one. // While Martini is in development mode, Recovery will also output the panic as HTML. -func Recovery(cfg *setting.Cfg) web.Middleware { +func Recovery(cfg *setting.Cfg, license licensing.Licensing) web.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { c := web.FromContext(req.Context()) @@ -137,7 +138,7 @@ func Recovery(cfg *setting.Cfg) web.Middleware { return } - assets, _ := webassets.GetWebAssets(cfg) + assets, _ := webassets.GetWebAssets(req.Context(), cfg, license) if assets == nil { assets = &dtos.EntryPointAssets{JSFiles: []dtos.EntryPointAsset{}} } @@ -146,12 +147,12 @@ func Recovery(cfg *setting.Cfg) web.Middleware { Title string AppTitle string AppSubUrl string - Theme string + ThemeType string ErrorMsg string Assets *dtos.EntryPointAssets }{"Server Error", "Grafana", cfg.AppSubURL, cfg.DefaultTheme, "", assets} - if setting.Env == setting.Dev { + if cfg.Env == setting.Dev { if err, ok := r.(error); ok { data.Title = err.Error() } @@ -169,9 +170,9 @@ func Recovery(cfg *setting.Cfg) web.Middleware { resp["error"] = data.Title } - ctx.JSON(500, resp) + ctx.JSON(http.StatusInternalServerError, resp) } else { - ctx.HTML(500, cfg.ErrTemplateName, data) + ctx.HTML(http.StatusInternalServerError, cfg.ErrTemplateName, data) } } }() diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go index 6b19d9dfdb8b7..85df5feaed867 100644 --- a/pkg/middleware/recovery_test.go +++ b/pkg/middleware/recovery_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn/authntest" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" @@ -62,7 +63,7 @@ func recoveryScenario(t *testing.T, desc string, url string, fn scenarioFunc) { require.NoError(t, err) sc.m = web.New() - sc.m.UseMiddleware(Recovery(cfg)) + sc.m.UseMiddleware(Recovery(cfg, &licensing.OSSLicensingService{})) sc.m.Use(AddDefaultResponseHeaders(cfg)) sc.m.UseMiddleware(web.Renderer(viewsPath, "[[", "]]")) diff --git a/pkg/middleware/request_metrics.go b/pkg/middleware/request_metrics.go index ad614581a0b83..b109a52a56f1a 100644 --- a/pkg/middleware/request_metrics.go +++ b/pkg/middleware/request_metrics.go @@ -35,20 +35,12 @@ func RequestMetrics(features featuremgmt.FeatureToggles, cfg *setting.Cfg, promR }, ) - histogramLabels := []string{"handler", "status_code", "method"} - - if features.IsEnabledGlobally(featuremgmt.FlagRequestInstrumentationStatusSource) { - histogramLabels = append(histogramLabels, "status_source") - } + histogramLabels := []string{"handler", "status_code", "method", "status_source", "slo_group"} if cfg.MetricsIncludeTeamLabel { histogramLabels = append(histogramLabels, "grafana_team") } - if features.IsEnabledGlobally(featuremgmt.FlagHttpSLOLevels) { - histogramLabels = append(histogramLabels, "slo_group") - } - histogramOptions := prometheus.HistogramOpts{ Namespace: "grafana", Name: "http_request_duration_seconds", @@ -104,18 +96,12 @@ func RequestMetrics(features featuremgmt.FeatureToggles, cfg *setting.Cfg, promR labelValues := []string{handler, code, r.Method} rmd := requestmeta.GetRequestMetaData(r.Context()) - if features.IsEnabled(r.Context(), featuremgmt.FlagRequestInstrumentationStatusSource) { - labelValues = append(labelValues, string(rmd.StatusSource)) - } + labelValues = append(labelValues, string(rmd.StatusSource), string(rmd.SLOGroup)) if cfg.MetricsIncludeTeamLabel { labelValues = append(labelValues, rmd.Team) } - if features.IsEnabled(r.Context(), featuremgmt.FlagHttpSLOLevels) { - labelValues = append(labelValues, string(rmd.SLOGroup)) - } - // avoiding the sanitize functions for in the new instrumentation // since they dont make much sense. We should remove them later. histogram := httpRequestDurationHistogram. diff --git a/pkg/middleware/request_test.go b/pkg/middleware/request_test.go index 6e4c7526ccf11..ea9ea4fad662b 100644 --- a/pkg/middleware/request_test.go +++ b/pkg/middleware/request_test.go @@ -42,7 +42,7 @@ func TestCanGetRouteNameFromContext(t *testing.T) { func TestOperationNameCanOnlyBeSetOnce(t *testing.T) { req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://grafana.com", nil) - // set the the initial operation name + // set the initial operation name req = addRouteNameToContext(req, "first") // check that the operation name is set correctly diff --git a/pkg/plugins/apiserver.go b/pkg/plugins/apiserver.go new file mode 100644 index 0000000000000..e72355beb9e40 --- /dev/null +++ b/pkg/plugins/apiserver.go @@ -0,0 +1,27 @@ +package plugins + +import ( + "fmt" + "strings" +) + +// Get the default API group name for from a plugin ID +// NOTE: this is a work in progress, and may change without notice +func GetDatasourceGroupNameFromPluginID(pluginId string) (string, error) { + if pluginId == "" { + return "", fmt.Errorf("bad pluginID (empty)") + } + parts := strings.Split(pluginId, "-") + if len(parts) == 1 { + return fmt.Sprintf("%s.datasource.grafana.app", parts[0]), nil + } + + last := parts[len(parts)-1] + if last != "datasource" { + return "", fmt.Errorf("bad pluginID (%s)", pluginId) + } + if parts[0] == "grafana" { + parts = parts[1:] // strip the first value + } + return fmt.Sprintf("%s.datasource.grafana.app", strings.Join(parts[:len(parts)-1], "-")), nil +} diff --git a/pkg/plugins/apiserver_test.go b/pkg/plugins/apiserver_test.go new file mode 100644 index 0000000000000..24d7102a40e43 --- /dev/null +++ b/pkg/plugins/apiserver_test.go @@ -0,0 +1,32 @@ +package plugins + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUtils(t *testing.T) { + // multiple flavors of the same idea + require.Equal(t, "tempo.datasource.grafana.app", getIDIgnoreError("tempo")) + require.Equal(t, "tempo.datasource.grafana.app", getIDIgnoreError("grafana-tempo-datasource")) + require.Equal(t, "tempo.datasource.grafana.app", getIDIgnoreError("tempo-datasource")) + + // Multiple dashes in the name + require.Equal(t, "org-name.datasource.grafana.app", getIDIgnoreError("org-name-datasource")) + require.Equal(t, "org-name-more.datasource.grafana.app", getIDIgnoreError("org-name-more-datasource")) + require.Equal(t, "org-name-more-more.datasource.grafana.app", getIDIgnoreError("org-name-more-more-datasource")) + + require.Error(t, getErrorIgnoreValue("graph-panel")) + require.Error(t, getErrorIgnoreValue("anything-notdatasource")) +} + +func getIDIgnoreError(id string) string { + v, _ := GetDatasourceGroupNameFromPluginID(id) + return v +} + +func getErrorIgnoreValue(id string) error { + _, err := GetDatasourceGroupNameFromPluginID(id) + return err +} diff --git a/pkg/plugins/auth/models.go b/pkg/plugins/auth/models.go index b236353116f94..93b97f0394c3b 100644 --- a/pkg/plugins/auth/models.go +++ b/pkg/plugins/auth/models.go @@ -3,7 +3,7 @@ package auth import ( "context" - "github.com/grafana/grafana/pkg/plugins/plugindef" + "github.com/grafana/grafana/pkg/plugins/pfs" ) type ExternalService struct { @@ -14,6 +14,6 @@ type ExternalService struct { type ExternalServiceRegistry interface { HasExternalService(ctx context.Context, pluginID string) (bool, error) - RegisterExternalService(ctx context.Context, pluginID string, pType plugindef.Type, svc *plugindef.IAM) (*ExternalService, error) + RegisterExternalService(ctx context.Context, pluginID string, pType pfs.Type, svc *pfs.IAM) (*ExternalService, error) RemoveExternalService(ctx context.Context, pluginID string) error } diff --git a/pkg/plugins/backendplugin/pluginextensionv2/generate.sh b/pkg/plugins/backendplugin/pluginextensionv2/generate.sh index c7e2379cf7994..063b2f3f64bb9 100755 --- a/pkg/plugins/backendplugin/pluginextensionv2/generate.sh +++ b/pkg/plugins/backendplugin/pluginextensionv2/generate.sh @@ -13,4 +13,4 @@ DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" cd "$DIR" -protoc -I ./ *.proto --go_out=plugins=grpc:./ +protoc -I ./ *.proto --go_out=. --go-grpc_out=. diff --git a/pkg/plugins/backendplugin/pluginextensionv2/rendererv2.pb.go b/pkg/plugins/backendplugin/pluginextensionv2/rendererv2.pb.go index b2e563a112dd0..4f235a4f61dbb 100644 --- a/pkg/plugins/backendplugin/pluginextensionv2/rendererv2.pb.go +++ b/pkg/plugins/backendplugin/pluginextensionv2/rendererv2.pb.go @@ -1,16 +1,12 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.30.0 -// protoc v4.23.4 +// protoc-gen-go v1.32.0 +// protoc v4.25.2 // source: rendererv2.proto package pluginextensionv2 import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" @@ -87,6 +83,7 @@ type RenderRequest struct { Timezone string `protobuf:"bytes,9,opt,name=timezone,proto3" json:"timezone,omitempty"` Headers map[string]*StringList `protobuf:"bytes,10,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` AuthToken string `protobuf:"bytes,11,opt,name=authToken,proto3" json:"authToken,omitempty"` + Encoding string `protobuf:"bytes,12,opt,name=encoding,proto3" json:"encoding,omitempty"` } func (x *RenderRequest) Reset() { @@ -198,6 +195,13 @@ func (x *RenderRequest) GetAuthToken() string { return "" } +func (x *RenderRequest) GetEncoding() string { + if x != nil { + return x.Encoding + } + return "" +} + type RenderResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -410,7 +414,7 @@ var file_rendererv2_proto_rawDesc = []byte{ 0x74, 0x6f, 0x12, 0x11, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x76, 0x32, 0x22, 0x24, 0x0a, 0x0a, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, - 0x03, 0x28, 0x09, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0xc7, 0x03, 0x0a, 0x0d, + 0x03, 0x28, 0x09, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0xe3, 0x03, 0x0a, 0x0d, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, @@ -433,56 +437,58 @@ var file_rendererv2_proto_rawDesc = []byte{ 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x09, 0x61, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x1a, 0x59, 0x0a, 0x0c, 0x48, 0x65, - 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x6c, - 0x75, 0x67, 0x69, 0x6e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x76, 0x32, 0x2e, - 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x26, 0x0a, 0x0e, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0xf1, 0x02, - 0x0a, 0x10, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x43, 0x53, 0x56, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x03, 0x75, 0x72, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x50, 0x61, 0x74, 0x68, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x50, 0x61, 0x74, 0x68, - 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x16, - 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, - 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, - 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, - 0x12, 0x1a, 0x0a, 0x08, 0x74, 0x69, 0x6d, 0x65, 0x7a, 0x6f, 0x6e, 0x65, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x08, 0x74, 0x69, 0x6d, 0x65, 0x7a, 0x6f, 0x6e, 0x65, 0x12, 0x4a, 0x0a, 0x07, - 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x30, 0x2e, - 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x76, - 0x32, 0x2e, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x43, 0x53, 0x56, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, - 0x07, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x75, 0x74, 0x68, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x75, 0x74, - 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x1a, 0x59, 0x0a, 0x0c, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, + 0x09, 0x61, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x6e, + 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x65, 0x6e, + 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x1a, 0x59, 0x0a, 0x0c, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x22, 0x45, 0x0a, 0x11, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x43, 0x53, 0x56, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x1a, 0x0a, 0x08, - 0x66, 0x69, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, - 0x66, 0x69, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x32, 0xb1, 0x01, 0x0a, 0x08, 0x52, 0x65, 0x6e, - 0x64, 0x65, 0x72, 0x65, 0x72, 0x12, 0x4d, 0x0a, 0x06, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x12, - 0x20, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, - 0x6e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, - 0x69, 0x6f, 0x6e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x09, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x43, 0x53, - 0x56, 0x12, 0x23, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, - 0x69, 0x6f, 0x6e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x43, 0x53, 0x56, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x65, - 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x6e, 0x64, 0x65, - 0x72, 0x43, 0x53, 0x56, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x16, 0x5a, 0x14, - 0x2e, 0x2f, 0x3b, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, - 0x6f, 0x6e, 0x76, 0x32, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x01, 0x22, 0x26, 0x0a, 0x0e, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0xf1, 0x02, 0x0a, 0x10, 0x52, 0x65, + 0x6e, 0x64, 0x65, 0x72, 0x43, 0x53, 0x56, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, + 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, + 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x50, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x50, 0x61, 0x74, 0x68, 0x12, 0x1c, 0x0a, 0x09, + 0x72, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x72, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x05, 0x52, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, + 0x74, 0x69, 0x6d, 0x65, 0x7a, 0x6f, 0x6e, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x74, 0x69, 0x6d, 0x65, 0x7a, 0x6f, 0x6e, 0x65, 0x12, 0x4a, 0x0a, 0x07, 0x68, 0x65, 0x61, 0x64, + 0x65, 0x72, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x30, 0x2e, 0x70, 0x6c, 0x75, 0x67, + 0x69, 0x6e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x76, 0x32, 0x2e, 0x52, 0x65, + 0x6e, 0x64, 0x65, 0x72, 0x43, 0x53, 0x56, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x48, + 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x68, 0x65, 0x61, + 0x64, 0x65, 0x72, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x1a, 0x59, 0x0a, 0x0c, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x65, 0x78, 0x74, 0x65, + 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x69, + 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x45, 0x0a, + 0x11, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x43, 0x53, 0x56, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, + 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, + 0x4e, 0x61, 0x6d, 0x65, 0x32, 0xb1, 0x01, 0x0a, 0x08, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x65, + 0x72, 0x12, 0x4d, 0x0a, 0x06, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x12, 0x20, 0x2e, 0x70, 0x6c, + 0x75, 0x67, 0x69, 0x6e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x76, 0x32, 0x2e, + 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, + 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x76, + 0x32, 0x2e, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x56, 0x0a, 0x09, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x43, 0x53, 0x56, 0x12, 0x23, 0x2e, + 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x76, + 0x32, 0x2e, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x43, 0x53, 0x56, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x65, 0x78, 0x74, 0x65, 0x6e, + 0x73, 0x69, 0x6f, 0x6e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x43, 0x53, 0x56, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x16, 0x5a, 0x14, 0x2e, 0x2f, 0x3b, 0x70, + 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x76, 0x32, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -609,119 +615,3 @@ func file_rendererv2_proto_init() { file_rendererv2_proto_goTypes = nil file_rendererv2_proto_depIdxs = nil } - -// Reference imports to suppress errors if they are not otherwise used. -var _ context.Context -var _ grpc.ClientConnInterface - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -const _ = grpc.SupportPackageIsVersion6 - -// RendererClient is the client API for Renderer service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. -type RendererClient interface { - Render(ctx context.Context, in *RenderRequest, opts ...grpc.CallOption) (*RenderResponse, error) - RenderCSV(ctx context.Context, in *RenderCSVRequest, opts ...grpc.CallOption) (*RenderCSVResponse, error) -} - -type rendererClient struct { - cc grpc.ClientConnInterface -} - -func NewRendererClient(cc grpc.ClientConnInterface) RendererClient { - return &rendererClient{cc} -} - -func (c *rendererClient) Render(ctx context.Context, in *RenderRequest, opts ...grpc.CallOption) (*RenderResponse, error) { - out := new(RenderResponse) - err := c.cc.Invoke(ctx, "/pluginextensionv2.Renderer/Render", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *rendererClient) RenderCSV(ctx context.Context, in *RenderCSVRequest, opts ...grpc.CallOption) (*RenderCSVResponse, error) { - out := new(RenderCSVResponse) - err := c.cc.Invoke(ctx, "/pluginextensionv2.Renderer/RenderCSV", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -// RendererServer is the server API for Renderer service. -type RendererServer interface { - Render(context.Context, *RenderRequest) (*RenderResponse, error) - RenderCSV(context.Context, *RenderCSVRequest) (*RenderCSVResponse, error) -} - -// UnimplementedRendererServer can be embedded to have forward compatible implementations. -type UnimplementedRendererServer struct { -} - -func (*UnimplementedRendererServer) Render(context.Context, *RenderRequest) (*RenderResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Render not implemented") -} -func (*UnimplementedRendererServer) RenderCSV(context.Context, *RenderCSVRequest) (*RenderCSVResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method RenderCSV not implemented") -} - -func RegisterRendererServer(s *grpc.Server, srv RendererServer) { - s.RegisterService(&_Renderer_serviceDesc, srv) -} - -func _Renderer_Render_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(RenderRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(RendererServer).Render(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/pluginextensionv2.Renderer/Render", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(RendererServer).Render(ctx, req.(*RenderRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _Renderer_RenderCSV_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(RenderCSVRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(RendererServer).RenderCSV(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/pluginextensionv2.Renderer/RenderCSV", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(RendererServer).RenderCSV(ctx, req.(*RenderCSVRequest)) - } - return interceptor(ctx, in, info, handler) -} - -var _Renderer_serviceDesc = grpc.ServiceDesc{ - ServiceName: "pluginextensionv2.Renderer", - HandlerType: (*RendererServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "Render", - Handler: _Renderer_Render_Handler, - }, - { - MethodName: "RenderCSV", - Handler: _Renderer_RenderCSV_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "rendererv2.proto", -} diff --git a/pkg/plugins/backendplugin/pluginextensionv2/rendererv2.proto b/pkg/plugins/backendplugin/pluginextensionv2/rendererv2.proto index 77a1fdce8a036..0d7a0f91ebfff 100644 --- a/pkg/plugins/backendplugin/pluginextensionv2/rendererv2.proto +++ b/pkg/plugins/backendplugin/pluginextensionv2/rendererv2.proto @@ -19,6 +19,7 @@ message RenderRequest { string timezone = 9; map<string, StringList> headers = 10; string authToken = 11; + string encoding = 12; } message RenderResponse { diff --git a/pkg/plugins/backendplugin/pluginextensionv2/rendererv2_grpc.pb.go b/pkg/plugins/backendplugin/pluginextensionv2/rendererv2_grpc.pb.go new file mode 100644 index 0000000000000..7625f9655e2ee --- /dev/null +++ b/pkg/plugins/backendplugin/pluginextensionv2/rendererv2_grpc.pb.go @@ -0,0 +1,146 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v4.25.2 +// source: rendererv2.proto + +package pluginextensionv2 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +const ( + Renderer_Render_FullMethodName = "/pluginextensionv2.Renderer/Render" + Renderer_RenderCSV_FullMethodName = "/pluginextensionv2.Renderer/RenderCSV" +) + +// RendererClient is the client API for Renderer service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type RendererClient interface { + Render(ctx context.Context, in *RenderRequest, opts ...grpc.CallOption) (*RenderResponse, error) + RenderCSV(ctx context.Context, in *RenderCSVRequest, opts ...grpc.CallOption) (*RenderCSVResponse, error) +} + +type rendererClient struct { + cc grpc.ClientConnInterface +} + +func NewRendererClient(cc grpc.ClientConnInterface) RendererClient { + return &rendererClient{cc} +} + +func (c *rendererClient) Render(ctx context.Context, in *RenderRequest, opts ...grpc.CallOption) (*RenderResponse, error) { + out := new(RenderResponse) + err := c.cc.Invoke(ctx, Renderer_Render_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *rendererClient) RenderCSV(ctx context.Context, in *RenderCSVRequest, opts ...grpc.CallOption) (*RenderCSVResponse, error) { + out := new(RenderCSVResponse) + err := c.cc.Invoke(ctx, Renderer_RenderCSV_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// RendererServer is the server API for Renderer service. +// All implementations must embed UnimplementedRendererServer +// for forward compatibility +type RendererServer interface { + Render(context.Context, *RenderRequest) (*RenderResponse, error) + RenderCSV(context.Context, *RenderCSVRequest) (*RenderCSVResponse, error) + mustEmbedUnimplementedRendererServer() +} + +// UnimplementedRendererServer must be embedded to have forward compatible implementations. +type UnimplementedRendererServer struct { +} + +func (UnimplementedRendererServer) Render(context.Context, *RenderRequest) (*RenderResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Render not implemented") +} +func (UnimplementedRendererServer) RenderCSV(context.Context, *RenderCSVRequest) (*RenderCSVResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RenderCSV not implemented") +} +func (UnimplementedRendererServer) mustEmbedUnimplementedRendererServer() {} + +// UnsafeRendererServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to RendererServer will +// result in compilation errors. +type UnsafeRendererServer interface { + mustEmbedUnimplementedRendererServer() +} + +func RegisterRendererServer(s grpc.ServiceRegistrar, srv RendererServer) { + s.RegisterService(&Renderer_ServiceDesc, srv) +} + +func _Renderer_Render_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RenderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RendererServer).Render(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Renderer_Render_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RendererServer).Render(ctx, req.(*RenderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Renderer_RenderCSV_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RenderCSVRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RendererServer).RenderCSV(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Renderer_RenderCSV_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RendererServer).RenderCSV(ctx, req.(*RenderCSVRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// Renderer_ServiceDesc is the grpc.ServiceDesc for Renderer service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Renderer_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "pluginextensionv2.Renderer", + HandlerType: (*RendererServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Render", + Handler: _Renderer_Render_Handler, + }, + { + MethodName: "RenderCSV", + Handler: _Renderer_RenderCSV_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "rendererv2.proto", +} diff --git a/pkg/plugins/backendplugin/pluginextensionv2/sanitizer.pb.go b/pkg/plugins/backendplugin/pluginextensionv2/sanitizer.pb.go index 8fceddd418910..a420647a68788 100644 --- a/pkg/plugins/backendplugin/pluginextensionv2/sanitizer.pb.go +++ b/pkg/plugins/backendplugin/pluginextensionv2/sanitizer.pb.go @@ -1,16 +1,12 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.30.0 -// protoc v4.23.4 +// protoc-gen-go v1.32.0 +// protoc v4.25.2 // source: sanitizer.proto package pluginextensionv2 import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" @@ -255,83 +251,3 @@ func file_sanitizer_proto_init() { file_sanitizer_proto_goTypes = nil file_sanitizer_proto_depIdxs = nil } - -// Reference imports to suppress errors if they are not otherwise used. -var _ context.Context -var _ grpc.ClientConnInterface - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -const _ = grpc.SupportPackageIsVersion6 - -// SanitizerClient is the client API for Sanitizer service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. -type SanitizerClient interface { - Sanitize(ctx context.Context, in *SanitizeRequest, opts ...grpc.CallOption) (*SanitizeResponse, error) -} - -type sanitizerClient struct { - cc grpc.ClientConnInterface -} - -func NewSanitizerClient(cc grpc.ClientConnInterface) SanitizerClient { - return &sanitizerClient{cc} -} - -func (c *sanitizerClient) Sanitize(ctx context.Context, in *SanitizeRequest, opts ...grpc.CallOption) (*SanitizeResponse, error) { - out := new(SanitizeResponse) - err := c.cc.Invoke(ctx, "/pluginextensionv2.Sanitizer/Sanitize", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -// SanitizerServer is the server API for Sanitizer service. -type SanitizerServer interface { - Sanitize(context.Context, *SanitizeRequest) (*SanitizeResponse, error) -} - -// UnimplementedSanitizerServer can be embedded to have forward compatible implementations. -type UnimplementedSanitizerServer struct { -} - -func (*UnimplementedSanitizerServer) Sanitize(context.Context, *SanitizeRequest) (*SanitizeResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Sanitize not implemented") -} - -func RegisterSanitizerServer(s *grpc.Server, srv SanitizerServer) { - s.RegisterService(&_Sanitizer_serviceDesc, srv) -} - -func _Sanitizer_Sanitize_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(SanitizeRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(SanitizerServer).Sanitize(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/pluginextensionv2.Sanitizer/Sanitize", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(SanitizerServer).Sanitize(ctx, req.(*SanitizeRequest)) - } - return interceptor(ctx, in, info, handler) -} - -var _Sanitizer_serviceDesc = grpc.ServiceDesc{ - ServiceName: "pluginextensionv2.Sanitizer", - HandlerType: (*SanitizerServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "Sanitize", - Handler: _Sanitizer_Sanitize_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "sanitizer.proto", -} diff --git a/pkg/plugins/backendplugin/pluginextensionv2/sanitizer_grpc.pb.go b/pkg/plugins/backendplugin/pluginextensionv2/sanitizer_grpc.pb.go new file mode 100644 index 0000000000000..80a3f117df3dc --- /dev/null +++ b/pkg/plugins/backendplugin/pluginextensionv2/sanitizer_grpc.pb.go @@ -0,0 +1,109 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v4.25.2 +// source: sanitizer.proto + +package pluginextensionv2 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +const ( + Sanitizer_Sanitize_FullMethodName = "/pluginextensionv2.Sanitizer/Sanitize" +) + +// SanitizerClient is the client API for Sanitizer service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type SanitizerClient interface { + Sanitize(ctx context.Context, in *SanitizeRequest, opts ...grpc.CallOption) (*SanitizeResponse, error) +} + +type sanitizerClient struct { + cc grpc.ClientConnInterface +} + +func NewSanitizerClient(cc grpc.ClientConnInterface) SanitizerClient { + return &sanitizerClient{cc} +} + +func (c *sanitizerClient) Sanitize(ctx context.Context, in *SanitizeRequest, opts ...grpc.CallOption) (*SanitizeResponse, error) { + out := new(SanitizeResponse) + err := c.cc.Invoke(ctx, Sanitizer_Sanitize_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// SanitizerServer is the server API for Sanitizer service. +// All implementations must embed UnimplementedSanitizerServer +// for forward compatibility +type SanitizerServer interface { + Sanitize(context.Context, *SanitizeRequest) (*SanitizeResponse, error) + mustEmbedUnimplementedSanitizerServer() +} + +// UnimplementedSanitizerServer must be embedded to have forward compatible implementations. +type UnimplementedSanitizerServer struct { +} + +func (UnimplementedSanitizerServer) Sanitize(context.Context, *SanitizeRequest) (*SanitizeResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Sanitize not implemented") +} +func (UnimplementedSanitizerServer) mustEmbedUnimplementedSanitizerServer() {} + +// UnsafeSanitizerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to SanitizerServer will +// result in compilation errors. +type UnsafeSanitizerServer interface { + mustEmbedUnimplementedSanitizerServer() +} + +func RegisterSanitizerServer(s grpc.ServiceRegistrar, srv SanitizerServer) { + s.RegisterService(&Sanitizer_ServiceDesc, srv) +} + +func _Sanitizer_Sanitize_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SanitizeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SanitizerServer).Sanitize(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Sanitizer_Sanitize_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SanitizerServer).Sanitize(ctx, req.(*SanitizeRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// Sanitizer_ServiceDesc is the grpc.ServiceDesc for Sanitizer service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Sanitizer_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "pluginextensionv2.Sanitizer", + HandlerType: (*SanitizerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Sanitize", + Handler: _Sanitizer_Sanitize_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "sanitizer.proto", +} diff --git a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.pb.go b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.pb.go index 79834373e010c..777de3c42f24f 100644 --- a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.pb.go +++ b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.30.0 -// protoc v4.23.4 +// protoc-gen-go v1.32.0 +// protoc v4.25.2 // source: secretsmanager.proto package secretsmanagerplugin diff --git a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grpc.pb.go b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grpc.pb.go index 466b8f9b6a0ee..264020b216e8a 100644 --- a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grpc.pb.go +++ b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.3.0 -// - protoc v4.23.4 +// - protoc v4.25.2 // source: secretsmanager.proto package secretsmanagerplugin diff --git a/pkg/plugins/codegen/jenny_plugin_registry.go b/pkg/plugins/codegen/jenny_plugin_registry.go new file mode 100644 index 0000000000000..e28b3222ee09c --- /dev/null +++ b/pkg/plugins/codegen/jenny_plugin_registry.go @@ -0,0 +1,74 @@ +package codegen + +import ( + "bytes" + "fmt" + "go/format" + "path/filepath" + "strings" + + "github.com/grafana/codejen" +) + +var registryPath = filepath.Join("pkg", "registry", "schemas") + +var renamedPlugins = map[string]string{ + "cloud-monitoring": "googlecloudmonitoring", + "grafana-pyroscope-datasource": "grafanapyroscope", + "annolist": "annotationslist", + "grafanatestdatadatasource": "testdata", + "dashlist": "dashboardlist", +} + +type PluginRegistryJenny struct { +} + +func (jenny *PluginRegistryJenny) JennyName() string { + return "PluginRegistryJenny" +} + +func (jenny *PluginRegistryJenny) Generate(files []string) (*codejen.File, error) { + if len(files) == 0 { + return nil, nil + } + schemas := make([]Schema, len(files)) + for i, file := range files { + name, err := getSchemaName(file) + if err != nil { + return nil, fmt.Errorf("unable to find schema name: %s", err) + } + + schemas[i] = Schema{ + Name: name, + Filename: filepath.Base(file), + FilePath: file, + } + } + + buf := new(bytes.Buffer) + if err := tmpls.Lookup("composable_registry.tmpl").Execute(buf, tmpl_vars_plugin_registry{ + Schemas: schemas, + }); err != nil { + return nil, fmt.Errorf("failed executing kind registry template: %w", err) + } + + b, err := format.Source(buf.Bytes()) + if err != nil { + return nil, err + } + + return codejen.NewFile(filepath.Join(registryPath, "composable_kind.go"), b, jenny), nil +} + +func getSchemaName(path string) (string, error) { + parts := strings.Split(path, "/") + if len(parts) < 2 { + return "", fmt.Errorf("path should contain more than 2 elements") + } + folderName := parts[len(parts)-2] + if renamed, ok := renamedPlugins[folderName]; ok { + folderName = renamed + } + folderName = strings.ReplaceAll(folderName, "-", "") + return strings.ToLower(folderName), nil +} diff --git a/pkg/plugins/codegen/jenny_plugingotypes.go b/pkg/plugins/codegen/jenny_plugingotypes.go index 9547e2d09f043..16062b60e2ad6 100644 --- a/pkg/plugins/codegen/jenny_plugingotypes.go +++ b/pkg/plugins/codegen/jenny_plugingotypes.go @@ -35,10 +35,10 @@ func (j *pgoJenny) Generate(decl *pfs.PluginDecl) (*codejen.File, error) { return nil, nil } - slotname := strings.ToLower(decl.SchemaInterface.Name()) + slotname := strings.ToLower(decl.SchemaInterface.Name) byt, err := gocode.GenerateTypesOpenAPI(decl.Lineage.Latest(), &gocode.TypeConfigOpenAPI{ Config: &openapi.Config{ - Group: decl.SchemaInterface.IsGroup(), + Group: decl.SchemaInterface.IsGroup, Config: &copenapi.Config{ MaxCycleDepth: 10, }, diff --git a/pkg/plugins/codegen/jenny_pluginseachmajor.go b/pkg/plugins/codegen/jenny_pluginseachmajor.go deleted file mode 100644 index 6d3e6d268219d..0000000000000 --- a/pkg/plugins/codegen/jenny_pluginseachmajor.go +++ /dev/null @@ -1,101 +0,0 @@ -package codegen - -import ( - "fmt" - "os" - "path" - "path/filepath" - - "github.com/grafana/codejen" - tsast "github.com/grafana/cuetsy/ts/ast" - "github.com/grafana/grafana/pkg/build" - corecodegen "github.com/grafana/grafana/pkg/codegen" - "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/grafana/pkg/plugins/pfs" - "github.com/grafana/kindsys" - "github.com/grafana/thema" -) - -func PluginTSEachMajor(rt *thema.Runtime) codejen.OneToMany[*pfs.PluginDecl] { - latestMajorsOrX := corecodegen.LatestMajorsOrXJenny(filepath.Join("packages", "grafana-schema", "src", "raw", "composable"), false, corecodegen.TSTypesJenny{}) - return &pleJenny{ - inner: kinds2pd(rt, latestMajorsOrX), - } -} - -type pleJenny struct { - inner codejen.OneToMany[*pfs.PluginDecl] -} - -func (*pleJenny) JennyName() string { - return "PluginEachMajorJenny" -} - -func (j *pleJenny) Generate(decl *pfs.PluginDecl) (codejen.Files, error) { - if !decl.HasSchema() { - return nil, nil - } - - jf, err := j.inner.Generate(decl) - if err != nil { - return nil, err - } - - version := "export const pluginVersion = \"%s\";" - if decl.PluginMeta.Info.Version != nil { - version = fmt.Sprintf(version, *decl.PluginMeta.Info.Version) - } else { - version = fmt.Sprintf(version, getGrafanaVersion()) - } - - files := make(codejen.Files, len(jf)) - for i, file := range jf { - tsf := &tsast.File{} - for _, im := range decl.Imports { - if tsim, err := cuectx.ConvertImport(im); err != nil { - return nil, err - } else if tsim.From.Value != "" { - tsf.Imports = append(tsf.Imports, tsim) - } - } - - tsf.Nodes = append(tsf.Nodes, tsast.Raw{ - Data: version, - }) - - tsf.Nodes = append(tsf.Nodes, tsast.Raw{ - Data: string(file.Data), - }) - - data := []byte(tsf.String()) - data = data[:len(data)-1] // remove the additional line break added by the inner jenny - - files[i] = *codejen.NewFile(file.RelativePath, data, append(file.From, j)...) - } - - return files, nil -} - -func kinds2pd(rt *thema.Runtime, j codejen.OneToMany[kindsys.Kind]) codejen.OneToMany[*pfs.PluginDecl] { - return codejen.AdaptOneToMany(j, func(pd *pfs.PluginDecl) kindsys.Kind { - kd, err := kindsys.BindComposable(rt, pd.KindDecl) - if err != nil { - return nil - } - return kd - }) -} - -func getGrafanaVersion() string { - dir, err := os.Getwd() - if err != nil { - return "" - } - - pkg, err := build.OpenPackageJSON(path.Join(dir, "../../../")) - if err != nil { - return "" - } - - return pkg.Version -} diff --git a/pkg/plugins/codegen/jenny_plugintreelist.go b/pkg/plugins/codegen/jenny_plugintreelist.go deleted file mode 100644 index fe737720434d1..0000000000000 --- a/pkg/plugins/codegen/jenny_plugintreelist.go +++ /dev/null @@ -1,100 +0,0 @@ -package codegen - -import ( - "bytes" - "fmt" - "path" - "path/filepath" - "strings" - - "github.com/grafana/codejen" - "github.com/grafana/grafana/pkg/plugins/pfs" -) - -const prefix = "github.com/grafana/grafana/public/app/plugins" - -// PluginTreeListJenny creates a [codejen.ManyToOne] that produces Go code -// for loading a [pfs.PluginList] given [*kindsys.PluginDecl] as inputs. -func PluginTreeListJenny() codejen.ManyToOne[*pfs.PluginDecl] { - outputFile := filepath.Join("pkg", "plugins", "pfs", "corelist", "corelist_load_gen.go") - - return &ptlJenny{ - outputFile: outputFile, - plugins: make(map[string]bool, 0), - } -} - -type ptlJenny struct { - outputFile string - plugins map[string]bool -} - -func (j *ptlJenny) JennyName() string { - return "PluginTreeListJenny" -} - -func (j *ptlJenny) Generate(decls ...*pfs.PluginDecl) (*codejen.File, error) { - buf := new(bytes.Buffer) - vars := templateVars_plugin_registry{ - Plugins: make([]struct { - PkgName, Path, ImportPath string - NoAlias bool - }, 0, len(decls)), - } - - type tpl struct { - PkgName, Path, ImportPath string - NoAlias bool - } - - for _, decl := range decls { - meta := decl.PluginMeta - - if _, exists := j.plugins[meta.Id]; exists { - continue - } - - pluginId := j.sanitizePluginId(meta.Id) - vars.Plugins = append(vars.Plugins, tpl{ - PkgName: pluginId, - NoAlias: pluginId != filepath.Base(decl.PluginPath), - ImportPath: filepath.ToSlash(filepath.Join(prefix, decl.PluginPath)), - Path: path.Join(append(strings.Split(prefix, "/")[3:], decl.PluginPath)...), - }) - - j.plugins[meta.Id] = true - } - - if err := tmpls.Lookup("plugin_registry.tmpl").Execute(buf, vars); err != nil { - return nil, fmt.Errorf("failed executing plugin registry template: %w", err) - } - - byt, err := postprocessGoFile(genGoFile{ - path: j.outputFile, - in: buf.Bytes(), - }) - if err != nil { - return nil, fmt.Errorf("error postprocessing plugin registry: %w", err) - } - - return codejen.NewFile(j.outputFile, byt, j), nil -} - -func (j *ptlJenny) sanitizePluginId(s string) string { - return strings.Map(func(r rune) rune { - switch { - case r >= 'a' && r <= 'z': - fallthrough - case r >= 'A' && r <= 'Z': - fallthrough - case r >= '0' && r <= '9': - fallthrough - case r == '_': - return r - case r == '-': - return '_' - default: - return -1 - } - }, s) -} diff --git a/pkg/plugins/codegen/jenny_plugintstypes.go b/pkg/plugins/codegen/jenny_plugintstypes.go index 1bedbaea727a4..c82f1be00f70a 100644 --- a/pkg/plugins/codegen/jenny_plugintstypes.go +++ b/pkg/plugins/codegen/jenny_plugintstypes.go @@ -2,19 +2,25 @@ package codegen import ( "fmt" + "os" + "path" "path/filepath" "strings" "github.com/grafana/codejen" tsast "github.com/grafana/cuetsy/ts/ast" + "github.com/grafana/grafana/pkg/build" + "github.com/grafana/grafana/pkg/codegen" "github.com/grafana/grafana/pkg/cuectx" "github.com/grafana/grafana/pkg/plugins/pfs" ) -func PluginTSTypesJenny(root string, inner codejen.OneToOne[*pfs.PluginDecl]) codejen.OneToOne[*pfs.PluginDecl] { +var versionedPluginPath = filepath.Join("packages", "grafana-schema", "src", "raw", "composable") + +func PluginTSTypesJenny(root string) codejen.OneToMany[*pfs.PluginDecl] { return &ptsJenny{ root: root, - inner: inner, + inner: adaptToPipeline(codegen.TSTypesJenny{}), } } @@ -24,21 +30,23 @@ type ptsJenny struct { } func (j *ptsJenny) JennyName() string { - return "PluginTSTypesJenny" + return "PluginTsTypesJenny" } -func (j *ptsJenny) Generate(decl *pfs.PluginDecl) (*codejen.File, error) { +func (j *ptsJenny) Generate(decl *pfs.PluginDecl) (codejen.Files, error) { if !decl.HasSchema() { return nil, nil } - tsf := &tsast.File{} + genFile := &tsast.File{} + versionedFile := &tsast.File{} for _, im := range decl.Imports { if tsim, err := cuectx.ConvertImport(im); err != nil { return nil, err } else if tsim.From.Value != "" { - tsf.Imports = append(tsf.Imports, tsim) + genFile.Imports = append(genFile.Imports, tsim) + versionedFile.Imports = append(versionedFile.Imports, tsim) } } @@ -47,13 +55,67 @@ func (j *ptsJenny) Generate(decl *pfs.PluginDecl) (*codejen.File, error) { return nil, err } - tsf.Nodes = append(tsf.Nodes, tsast.Raw{ - Data: string(jf.Data), - }) + rawData := tsast.Raw{Data: string(jf.Data)} + rawVersion := tsast.Raw{ + Data: getPluginVersion(decl.PluginMeta.Version), + } + + genFile.Nodes = append(genFile.Nodes, rawData) - path := filepath.Join(j.root, decl.PluginPath, fmt.Sprintf("%s.gen.ts", strings.ToLower(decl.SchemaInterface.Name()))) - data := []byte(tsf.String()) + genPath := filepath.Join(j.root, decl.PluginPath, fmt.Sprintf("%s.gen.ts", strings.ToLower(decl.SchemaInterface.Name))) + data := []byte(genFile.String()) data = data[:len(data)-1] // remove the additional line break added by the inner jenny - return codejen.NewFile(path, data, append(jf.From, j)...), nil + files := make(codejen.Files, 2) + files[0] = *codejen.NewFile(genPath, data, append(jf.From, j)...) + + versionedFile.Nodes = append(versionedFile.Nodes, rawVersion, rawData) + + versionedData := []byte(versionedFile.String()) + versionedData = versionedData[:len(versionedData)-1] + + pluginFolder := strings.ReplaceAll(strings.ToLower(decl.PluginMeta.Name), " ", "") + versionedPath := filepath.Join(versionedPluginPath, pluginFolder, strings.ToLower(decl.SchemaInterface.Name), "x", jf.RelativePath) + files[1] = *codejen.NewFile(versionedPath, versionedData, append(jf.From, j)...) + + return files, nil +} + +func getPluginVersion(pluginVersion *string) string { + version := "export const pluginVersion = \"%s\";" + if pluginVersion != nil { + version = fmt.Sprintf(version, *pluginVersion) + } else { + version = fmt.Sprintf(version, getGrafanaVersion()) + } + + return version +} + +func adaptToPipeline(j codejen.OneToOne[codegen.SchemaForGen]) codejen.OneToOne[*pfs.PluginDecl] { + return codejen.AdaptOneToOne(j, func(pd *pfs.PluginDecl) codegen.SchemaForGen { + name := strings.ReplaceAll(pd.PluginMeta.Name, " ", "") + if pd.SchemaInterface.Name == "DataQuery" { + name = name + "DataQuery" + } + return codegen.SchemaForGen{ + Name: name, + Schema: pd.Lineage.Latest(), + IsGroup: pd.SchemaInterface.IsGroup, + } + }) +} + +func getGrafanaVersion() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + + pkg, err := build.OpenPackageJSON(path.Join(dir, "../../../")) + if err != nil { + return "" + } + + return pkg.Version } diff --git a/pkg/plugins/codegen/tmpl.go b/pkg/plugins/codegen/tmpl.go index 71bd93cc1b825..729072a440fbc 100644 --- a/pkg/plugins/codegen/tmpl.go +++ b/pkg/plugins/codegen/tmpl.go @@ -22,12 +22,13 @@ var tmplFS embed.FS // The following group of types, beginning with templateVars_*, all contain the set // of variables expected by the corresponding named template file under tmpl/ type ( - templateVars_plugin_registry struct { - Plugins []struct { - PkgName string - Path string - ImportPath string - NoAlias bool - } + tmpl_vars_plugin_registry struct { + Schemas []Schema + } + + Schema struct { + Name string + Filename string + FilePath string } ) diff --git a/pkg/plugins/codegen/tmpl/composable_registry.tmpl b/pkg/plugins/codegen/tmpl/composable_registry.tmpl new file mode 100644 index 0000000000000..5ad6416a41a78 --- /dev/null +++ b/pkg/plugins/codegen/tmpl/composable_registry.tmpl @@ -0,0 +1,132 @@ +package schemas + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "testing/fstest" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/cue/load" +) + +var cueImportsPath = filepath.Join("packages", "grafana-schema", "src", "common") +var importPath = "github.com/grafana/grafana/packages/grafana-schema/src/common" + +type ComposableKind struct { + Name string + Filename string + CueFile cue.Value +} + +func GetComposableKinds() ([]ComposableKind, error) { + kinds := make([]ComposableKind, 0) + + _, caller, _, _ := runtime.Caller(0) + root := filepath.Join(caller, "../../../..") + + {{- range .Schemas }} + + {{ .Name }}Cue, err := loadCueFileWithCommon(root, filepath.Join(root, "{{ .FilePath }}")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "{{ .Name }}", + Filename: "{{ .Filename }}", + CueFile: {{ .Name }}Cue, + }) + {{- end }} + + return kinds, nil +} + +func loadCueFileWithCommon(root string, entrypoint string) (cue.Value, error) { + commonFS, err := mockCommonFS(root) + if err != nil { + fmt.Printf("cannot load common cue files: %s\n", err) + return cue.Value{}, err + } + + overlay, err := buildOverlay(commonFS) + if err != nil { + fmt.Printf("Cannot build overlay: %s\n", err) + return cue.Value{}, err + } + + bis := load.Instances([]string{entrypoint}, &load.Config{ + ModuleRoot: "/", + Overlay: overlay, + }) + + values, err := cuecontext.New().BuildInstances(bis) + if err != nil { + fmt.Printf("Cannot build instance: %s\n", err) + return cue.Value{}, err + } + + return values[0], nil +} + +func mockCommonFS(root string) (fs.FS, error) { + path := filepath.Join(root, cueImportsPath) + dir, err := os.ReadDir(path) + if err != nil { + return nil, fmt.Errorf("cannot open common cue files directory: %s", err) + } + + prefix := "cue.mod/pkg/" + importPath + + commonFS := fstest.MapFS{} + for _, d := range dir { + if d.IsDir() { + continue + } + + readPath := filepath.Join(path, d.Name()) + b, err := os.ReadFile(filepath.Clean(readPath)) + if err != nil { + return nil, err + } + + commonFS[filepath.Join(prefix, d.Name())] = &fstest.MapFile{Data: b} + } + + return commonFS, nil +} + +// It loads common cue files into the schema to be able to make import works +func buildOverlay(commonFS fs.FS) (map[string]load.Source, error) { + overlay := make(map[string]load.Source) + + err := fs.WalkDir(commonFS, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + f, err := commonFS.Open(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + b, err := io.ReadAll(f) + if err != nil { + return err + } + + overlay[filepath.Join("/", path)] = load.FromBytes(b) + + return nil + }) + + return overlay, err +} diff --git a/pkg/plugins/codegen/tmpl/plugin_registry.tmpl b/pkg/plugins/codegen/tmpl/plugin_registry.tmpl deleted file mode 100644 index 26ef440e02b8f..0000000000000 --- a/pkg/plugins/codegen/tmpl/plugin_registry.tmpl +++ /dev/null @@ -1,30 +0,0 @@ -package corelist - -import ( - "fmt" - "io/fs" - "sync" - "github.com/grafana/grafana" - "github.com/grafana/grafana/pkg/plugins/pfs" - "github.com/grafana/thema" -) - -func parsePluginOrPanic(path string, pkgname string, rt *thema.Runtime) pfs.ParsedPlugin { - sub, err := fs.Sub(grafana.CueSchemaFS, path) - if err != nil { - panic("could not create fs sub to " + path) - } - pp, err := pfs.ParsePluginFS(sub, rt) - if err != nil { - panic(fmt.Sprintf("error parsing plugin metadata for %s: %s", pkgname, err)) - } - return pp -} - -func corePlugins(rt *thema.Runtime) []pfs.ParsedPlugin{ - return []pfs.ParsedPlugin{ - {{- range .Plugins }} - parsePluginOrPanic("{{ .Path }}", "{{ .PkgName }}", rt), - {{- end }} - } -} diff --git a/pkg/plugins/codegen/util_go.go b/pkg/plugins/codegen/util_go.go deleted file mode 100644 index a9caf75ea2ab2..0000000000000 --- a/pkg/plugins/codegen/util_go.go +++ /dev/null @@ -1,68 +0,0 @@ -package codegen - -import ( - "bytes" - "fmt" - "go/format" - "go/parser" - "go/token" - "os" - "path/filepath" - "strings" - - "github.com/dave/dst/decorator" - "github.com/dave/dst/dstutil" - "golang.org/x/tools/imports" -) - -type genGoFile struct { - path string - walker dstutil.ApplyFunc - in []byte -} - -func postprocessGoFile(cfg genGoFile) ([]byte, error) { - fname := filepath.Base(cfg.path) - buf := new(bytes.Buffer) - fset := token.NewFileSet() - gf, err := decorator.ParseFile(fset, fname, string(cfg.in), parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("error parsing generated file: %w", err) - } - - if cfg.walker != nil { - dstutil.Apply(gf, cfg.walker, nil) - - err = format.Node(buf, fset, gf) - if err != nil { - return nil, fmt.Errorf("error formatting Go AST: %w", err) - } - } else { - buf = bytes.NewBuffer(cfg.in) - } - - byt, err := imports.Process(fname, buf.Bytes(), nil) - if err != nil { - return nil, fmt.Errorf("goimports processing failed: %w", err) - } - - // Compare imports before and after; warn about performance if some were added - gfa, _ := parser.ParseFile(fset, fname, string(byt), parser.ParseComments) - imap := make(map[string]bool) - for _, im := range gf.Imports { - imap[im.Path.Value] = true - } - var added []string - for _, im := range gfa.Imports { - if !imap[im.Path.Value] { - added = append(added, im.Path.Value) - } - } - - if len(added) != 0 { - // TODO improve the guidance in this error if/when we better abstract over imports to generate - fmt.Fprintf(os.Stderr, "The following imports were added by goimports while generating %s: \n\t%s\nRelying on goimports to find imports significantly slows down code generation. Consider adding these to the relevant template.\n", cfg.path, strings.Join(added, "\n\t")) - } - - return byt, nil -} diff --git a/pkg/plugins/config/config.go b/pkg/plugins/config/config.go index f96aa769ad8c3..9fcf3bba71ad4 100644 --- a/pkg/plugins/config/config.go +++ b/pkg/plugins/config/config.go @@ -1,16 +1,12 @@ package config import ( - "github.com/grafana/grafana-azure-sdk-go/azsettings" - - "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/setting" ) -type Cfg struct { - log log.Logger - +// PluginManagementCfg is the configuration for the plugin management system. +// It includes settings which are used to configure different components of plugin management. +type PluginManagementCfg struct { DevMode bool PluginsPath string @@ -20,62 +16,41 @@ type Cfg struct { DisablePlugins []string ForwardHostEnvVars []string - // AWS Plugin Auth - AWSAllowedAuthProviders []string - AWSAssumeRoleEnabled bool - AWSExternalId string - - // Azure Cloud settings - Azure *azsettings.AzureSettings - - // Proxy Settings - ProxySettings setting.SecureSocksDSProxySettings - - BuildVersion string // TODO Remove - - LogDatasourceRequests bool - PluginsCDNURLTemplate string - Tracing Tracing - GrafanaComURL string - GrafanaAppURL string - GrafanaAppSubURL string + GrafanaAppURL string - Features plugins.FeatureToggles + Features Features AngularSupportEnabled bool HideAngularDeprecation []string } -func NewCfg(devMode bool, pluginsPath string, pluginSettings setting.PluginSettings, pluginsAllowUnsigned []string, - awsAllowedAuthProviders []string, awsAssumeRoleEnabled bool, awsExternalId string, azure *azsettings.AzureSettings, secureSocksDSProxy setting.SecureSocksDSProxySettings, - grafanaVersion string, logDatasourceRequests bool, pluginsCDNURLTemplate string, appURL string, appSubURL string, tracing Tracing, features plugins.FeatureToggles, angularSupportEnabled bool, - grafanaComURL string, disablePlugins []string, hideAngularDeprecation []string, forwardHostEnvVars []string) *Cfg { - return &Cfg{ - log: log.New("plugin.cfg"), - PluginsPath: pluginsPath, - BuildVersion: grafanaVersion, - DevMode: devMode, - PluginSettings: pluginSettings, - PluginsAllowUnsigned: pluginsAllowUnsigned, - DisablePlugins: disablePlugins, - AWSAllowedAuthProviders: awsAllowedAuthProviders, - AWSAssumeRoleEnabled: awsAssumeRoleEnabled, - AWSExternalId: awsExternalId, - Azure: azure, - ProxySettings: secureSocksDSProxy, - LogDatasourceRequests: logDatasourceRequests, - PluginsCDNURLTemplate: pluginsCDNURLTemplate, - Tracing: tracing, - GrafanaComURL: grafanaComURL, - GrafanaAppURL: appURL, - GrafanaAppSubURL: appSubURL, - Features: features, - AngularSupportEnabled: angularSupportEnabled, - HideAngularDeprecation: hideAngularDeprecation, - ForwardHostEnvVars: forwardHostEnvVars, +// Features contains the feature toggles used for the plugin management system. +type Features struct { + ExternalCorePluginsEnabled bool + SkipHostEnvVarsEnabled bool +} + +// NewPluginManagementCfg returns a new PluginManagementCfg. +func NewPluginManagementCfg(devMode bool, pluginsPath string, pluginSettings setting.PluginSettings, pluginsAllowUnsigned []string, + pluginsCDNURLTemplate string, appURL string, features Features, angularSupportEnabled bool, + grafanaComURL string, disablePlugins []string, hideAngularDeprecation []string, forwardHostEnvVars []string, +) *PluginManagementCfg { + return &PluginManagementCfg{ + PluginsPath: pluginsPath, + DevMode: devMode, + PluginSettings: pluginSettings, + PluginsAllowUnsigned: pluginsAllowUnsigned, + DisablePlugins: disablePlugins, + PluginsCDNURLTemplate: pluginsCDNURLTemplate, + GrafanaComURL: grafanaComURL, + GrafanaAppURL: appURL, + Features: features, + AngularSupportEnabled: angularSupportEnabled, + HideAngularDeprecation: hideAngularDeprecation, + ForwardHostEnvVars: forwardHostEnvVars, } } diff --git a/pkg/plugins/envvars/envvars.go b/pkg/plugins/envvars/envvars.go index 91a8d7926e643..442a350d51260 100644 --- a/pkg/plugins/envvars/envvars.go +++ b/pkg/plugins/envvars/envvars.go @@ -2,31 +2,14 @@ package envvars import ( "context" - "fmt" "os" - "slices" - "sort" - "strconv" - "strings" - - "github.com/grafana/grafana-aws-sdk/pkg/awsds" - "github.com/grafana/grafana-azure-sdk-go/azsettings" - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" - "github.com/grafana/grafana-plugin-sdk-go/experimental/featuretoggles" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/auth" - "github.com/grafana/grafana/pkg/plugins/config" -) - -const ( - customConfigPrefix = "GF_PLUGIN" ) -// allowedHostEnvVarNames is the list of environment variables that can be passed from Grafana's process to the +// permittedHostEnvVarNames is the list of environment variables that can be passed from Grafana's process to the // plugin's process -var allowedHostEnvVarNames = []string{ +var permittedHostEnvVarNames = []string{ // Env vars used by net/http (Go stdlib) for http/https proxy // https://github.com/golang/net/blob/fbaf41277f28102c36926d1368dafbe2b54b4c1d/http/httpproxy/proxy.go#L91-L93 "HTTP_PROXY", @@ -38,252 +21,25 @@ var allowedHostEnvVarNames = []string{ } type Provider interface { - Get(ctx context.Context, p *plugins.Plugin) []string + PluginEnvVars(ctx context.Context, p *plugins.Plugin) []string } -type Service struct { - cfg *config.Cfg - license plugins.Licensing -} +type Service struct{} -func NewProvider(cfg *config.Cfg, license plugins.Licensing) *Service { - return &Service{ - cfg: cfg, - license: license, - } +func DefaultProvider() *Service { + return &Service{} } -func (s *Service) Get(ctx context.Context, p *plugins.Plugin) []string { - hostEnv := []string{ - fmt.Sprintf("GF_VERSION=%s", s.cfg.BuildVersion), - } - - if s.license != nil { - hostEnv = append( - hostEnv, - fmt.Sprintf("GF_EDITION=%s", s.license.Edition()), - fmt.Sprintf("GF_ENTERPRISE_LICENSE_PATH=%s", s.license.Path()), - fmt.Sprintf("GF_ENTERPRISE_APP_URL=%s", s.license.AppURL()), - ) - hostEnv = append(hostEnv, s.license.Environment()...) - } - - if p.ExternalService != nil { - hostEnv = append( - hostEnv, - fmt.Sprintf("GF_APP_URL=%s", s.cfg.GrafanaAppURL), - fmt.Sprintf("GF_PLUGIN_APP_CLIENT_ID=%s", p.ExternalService.ClientID), - fmt.Sprintf("GF_PLUGIN_APP_CLIENT_SECRET=%s", p.ExternalService.ClientSecret), - ) - if p.ExternalService.PrivateKey != "" { - hostEnv = append(hostEnv, fmt.Sprintf("GF_PLUGIN_APP_PRIVATE_KEY=%s", p.ExternalService.PrivateKey)) - } - } - - hostEnv = append(hostEnv, s.featureToggleEnableVar(ctx)...) - hostEnv = append(hostEnv, s.awsEnvVars()...) - hostEnv = append(hostEnv, s.secureSocksProxyEnvVars()...) - hostEnv = append(hostEnv, azsettings.WriteToEnvStr(s.cfg.Azure)...) - hostEnv = append(hostEnv, s.tracingEnvVars(p)...) - - // If SkipHostEnvVars is enabled, get some allowed variables from the current process and pass - // them down to the plugin. If the flag is not set, do not add anything else because ALL env vars - // from the current process (os.Environ()) will be forwarded to the plugin's process by go-plugin - if p.SkipHostEnvVars { - hostEnv = append(hostEnv, s.allowedHostEnvVars()...) - } - - ev := getPluginSettings(p.ID, s.cfg).asEnvVar(customConfigPrefix, hostEnv...) - - return ev +func (s *Service) PluginEnvVars(_ context.Context, _ *plugins.Plugin) []string { + return PermittedHostEnvVars() } -// GetConfigMap returns a map of configuration that should be passed in a plugin request. -func (s *Service) GetConfigMap(ctx context.Context, pluginID string, _ *auth.ExternalService) map[string]string { - m := make(map[string]string) - - if s.cfg.GrafanaAppURL != "" { - m[backend.AppURL] = s.cfg.GrafanaAppURL - } - - // TODO add support via plugin SDK - //if externalService != nil { - // m[oauthtokenretriever.AppURL] = s.cfg.GrafanaAppURL - // m[oauthtokenretriever.AppClientID] = externalService.ClientID - // m[oauthtokenretriever.AppClientSecret] = externalService.ClientSecret - // m[oauthtokenretriever.AppPrivateKey] = externalService.PrivateKey - //} - - if s.cfg.Features != nil { - enabledFeatures := s.cfg.Features.GetEnabled(ctx) - if len(enabledFeatures) > 0 { - features := make([]string, 0, len(enabledFeatures)) - for feat := range enabledFeatures { - features = append(features, feat) - } - sort.Strings(features) - m[featuretoggles.EnabledFeatures] = strings.Join(features, ",") - } - } - // TODO add support via plugin SDK - //if s.cfg.AWSAssumeRoleEnabled { - // m[awsds.AssumeRoleEnabledEnvVarKeyName] = "true" - //} - //if len(s.cfg.AWSAllowedAuthProviders) > 0 { - // m[awsds.AllowedAuthProvidersEnvVarKeyName] = strings.Join(s.cfg.AWSAllowedAuthProviders, ",") - //} - //if s.cfg.AWSExternalId != "" { - // m[awsds.GrafanaAssumeRoleExternalIdKeyName] = s.cfg.AWSExternalId - //} - - if s.cfg.ProxySettings.Enabled { - m[proxy.PluginSecureSocksProxyEnabled] = "true" - m[proxy.PluginSecureSocksProxyClientCert] = s.cfg.ProxySettings.ClientCert - m[proxy.PluginSecureSocksProxyClientKey] = s.cfg.ProxySettings.ClientKey - m[proxy.PluginSecureSocksProxyRootCACert] = s.cfg.ProxySettings.RootCA - m[proxy.PluginSecureSocksProxyProxyAddress] = s.cfg.ProxySettings.ProxyAddress - m[proxy.PluginSecureSocksProxyServerName] = s.cfg.ProxySettings.ServerName - m[proxy.PluginSecureSocksProxyAllowInsecure] = strconv.FormatBool(s.cfg.ProxySettings.AllowInsecure) - } - - // Settings here will be extracted by grafana-azure-sdk-go from the plugin context - azureSettings := s.cfg.Azure - if azureSettings != nil && slices.Contains[[]string, string](azureSettings.ForwardSettingsPlugins, pluginID) { - if azureSettings.Cloud != "" { - m[azsettings.AzureCloud] = azureSettings.Cloud - } - - if azureSettings.ManagedIdentityEnabled { - m[azsettings.ManagedIdentityEnabled] = "true" - - if azureSettings.ManagedIdentityClientId != "" { - m[azsettings.ManagedIdentityClientID] = azureSettings.ManagedIdentityClientId - } - } - - if azureSettings.UserIdentityEnabled { - m[azsettings.UserIdentityEnabled] = "true" - - if azureSettings.UserIdentityTokenEndpoint != nil { - if azureSettings.UserIdentityTokenEndpoint.TokenUrl != "" { - m[azsettings.UserIdentityTokenURL] = azureSettings.UserIdentityTokenEndpoint.TokenUrl - } - if azureSettings.UserIdentityTokenEndpoint.ClientId != "" { - m[azsettings.UserIdentityClientID] = azureSettings.UserIdentityTokenEndpoint.ClientId - } - if azureSettings.UserIdentityTokenEndpoint.ClientSecret != "" { - m[azsettings.UserIdentityClientSecret] = azureSettings.UserIdentityTokenEndpoint.ClientSecret - } - if azureSettings.UserIdentityTokenEndpoint.UsernameAssertion { - m[azsettings.UserIdentityAssertion] = "username" - } - } - } - - if azureSettings.WorkloadIdentityEnabled { - m[azsettings.WorkloadIdentityEnabled] = "true" - - if azureSettings.WorkloadIdentitySettings != nil { - if azureSettings.WorkloadIdentitySettings.ClientId != "" { - m[azsettings.WorkloadIdentityClientID] = azureSettings.WorkloadIdentitySettings.ClientId - } - if azureSettings.WorkloadIdentitySettings.TenantId != "" { - m[azsettings.WorkloadIdentityTenantID] = azureSettings.WorkloadIdentitySettings.TenantId - } - if azureSettings.WorkloadIdentitySettings.TokenFile != "" { - m[azsettings.WorkloadIdentityTokenFile] = azureSettings.WorkloadIdentitySettings.TokenFile - } - } - } - } - - // TODO add support via plugin SDK - //ps := getPluginSettings(pluginID, s.cfg) - //for k, v := range ps { - // m[fmt.Sprintf("%s_%s", customConfigPrefix, strings.ToUpper(k))] = v - //} - - return m -} - -func (s *Service) tracingEnvVars(plugin *plugins.Plugin) []string { - var pluginTracingEnabled bool - if v, exists := s.cfg.PluginSettings[plugin.ID]["tracing"]; exists { - pluginTracingEnabled = v == "true" - } - if !s.cfg.Tracing.IsEnabled() || !pluginTracingEnabled { - return nil - } - - vars := []string{ - fmt.Sprintf("GF_INSTANCE_OTLP_ADDRESS=%s", s.cfg.Tracing.OpenTelemetry.Address), - fmt.Sprintf("GF_INSTANCE_OTLP_PROPAGATION=%s", s.cfg.Tracing.OpenTelemetry.Propagation), - - fmt.Sprintf("GF_INSTANCE_OTLP_SAMPLER_TYPE=%s", s.cfg.Tracing.OpenTelemetry.Sampler), - fmt.Sprintf("GF_INSTANCE_OTLP_SAMPLER_PARAM=%.6f", s.cfg.Tracing.OpenTelemetry.SamplerParam), - fmt.Sprintf("GF_INSTANCE_OTLP_SAMPLER_REMOTE_URL=%s", s.cfg.Tracing.OpenTelemetry.SamplerRemoteURL), - } - if plugin.Info.Version != "" { - vars = append(vars, fmt.Sprintf("GF_PLUGIN_VERSION=%s", plugin.Info.Version)) - } - return vars -} - -func (s *Service) featureToggleEnableVar(ctx context.Context) []string { - var variables []string // an array is used for consistency and keep the logic simpler for no features case - - if s.cfg.Features == nil { - return variables - } - - enabledFeatures := s.cfg.Features.GetEnabled(ctx) - if len(enabledFeatures) > 0 { - features := make([]string, 0, len(enabledFeatures)) - for feat := range enabledFeatures { - features = append(features, feat) - } - variables = append(variables, fmt.Sprintf("GF_INSTANCE_FEATURE_TOGGLES_ENABLE=%s", strings.Join(features, ","))) - } - - return variables -} - -func (s *Service) awsEnvVars() []string { - var variables []string - if s.cfg.AWSAssumeRoleEnabled { - variables = append(variables, awsds.AssumeRoleEnabledEnvVarKeyName+"=true") - } - if len(s.cfg.AWSAllowedAuthProviders) > 0 { - variables = append(variables, awsds.AllowedAuthProvidersEnvVarKeyName+"="+strings.Join(s.cfg.AWSAllowedAuthProviders, ",")) - } - if s.cfg.AWSExternalId != "" { - variables = append(variables, awsds.GrafanaAssumeRoleExternalIdKeyName+"="+s.cfg.AWSExternalId) - } - - return variables -} - -func (s *Service) secureSocksProxyEnvVars() []string { - if s.cfg.ProxySettings.Enabled { - return []string{ - proxy.PluginSecureSocksProxyClientCert + "=" + s.cfg.ProxySettings.ClientCert, - proxy.PluginSecureSocksProxyClientKey + "=" + s.cfg.ProxySettings.ClientKey, - proxy.PluginSecureSocksProxyRootCACert + "=" + s.cfg.ProxySettings.RootCA, - proxy.PluginSecureSocksProxyProxyAddress + "=" + s.cfg.ProxySettings.ProxyAddress, - proxy.PluginSecureSocksProxyServerName + "=" + s.cfg.ProxySettings.ServerName, - proxy.PluginSecureSocksProxyEnabled + "=" + strconv.FormatBool(s.cfg.ProxySettings.Enabled), - proxy.PluginSecureSocksProxyAllowInsecure + "=" + strconv.FormatBool(s.cfg.ProxySettings.AllowInsecure), - } - } - return nil -} - -// allowedHostEnvVars returns the variables that can be passed from Grafana's process +// PermittedHostEnvVars returns the variables that can be passed from Grafana's process // (current process, also known as: "host") to the plugin process. -// A string in format "k=v" is returned for each variable in allowedHostEnvVarNames, if it's set. -func (s *Service) allowedHostEnvVars() []string { +// A string in format "k=v" is returned for each variable in PermittedHostEnvVarNames, if it's set. +func PermittedHostEnvVars() []string { var r []string - for _, envVarName := range allowedHostEnvVarNames { + for _, envVarName := range PermittedHostEnvVarNames() { if envVarValue, ok := os.LookupEnv(envVarName); ok { r = append(r, envVarName+"="+envVarValue) } @@ -291,32 +47,6 @@ func (s *Service) allowedHostEnvVars() []string { return r } -type pluginSettings map[string]string - -func getPluginSettings(pluginID string, cfg *config.Cfg) pluginSettings { - ps := pluginSettings{} - for k, v := range cfg.PluginSettings[pluginID] { - if k == "path" || strings.ToLower(k) == "id" { - continue - } - ps[k] = v - } - - return ps -} - -func (ps pluginSettings) asEnvVar(prefix string, hostEnv ...string) []string { - env := make([]string, 0, len(ps)) - for k, v := range ps { - key := fmt.Sprintf("%s_%s", prefix, strings.ToUpper(k)) - if value := os.Getenv(key); value != "" { - v = value - } - - env = append(env, fmt.Sprintf("%s=%s", key, v)) - } - - env = append(env, hostEnv...) - - return env +func PermittedHostEnvVarNames() []string { + return permittedHostEnvVarNames } diff --git a/pkg/plugins/ifaces.go b/pkg/plugins/ifaces.go index a81e18c409a97..aa3dcd0180b40 100644 --- a/pkg/plugins/ifaces.go +++ b/pkg/plugins/ifaces.go @@ -14,7 +14,7 @@ type Installer interface { // Add adds a new plugin. Add(ctx context.Context, pluginID, version string, opts CompatOpts) error // Remove removes an existing plugin. - Remove(ctx context.Context, pluginID string) error + Remove(ctx context.Context, pluginID, version string) error } type PluginSource interface { @@ -25,7 +25,7 @@ type PluginSource interface { type FileStore interface { // File retrieves a plugin file. - File(ctx context.Context, pluginID, filename string) (*File, error) + File(ctx context.Context, pluginID, pluginVersion, filename string) (*File, error) } type File struct { @@ -149,11 +149,6 @@ func (fn ClientMiddlewareFunc) CreateClientMiddleware(next Client) Client { return fn(next) } -type FeatureToggles interface { - IsEnabledGlobally(flag string) bool - GetEnabled(ctx context.Context) map[string]bool -} - type SignatureCalculator interface { Calculate(ctx context.Context, src PluginSource, plugin FoundPlugin) (Signature, error) } diff --git a/pkg/plugins/log/fake.go b/pkg/plugins/log/fake.go index 2a4f0163ac2af..94fe67bff9bd9 100644 --- a/pkg/plugins/log/fake.go +++ b/pkg/plugins/log/fake.go @@ -1,6 +1,9 @@ package log -import "context" +import ( + "context" + "sync" +) var _ Logger = (*TestLogger)(nil) @@ -20,27 +23,19 @@ func (f *TestLogger) New(_ ...any) Logger { } func (f *TestLogger) Info(msg string, ctx ...any) { - f.InfoLogs.Calls++ - f.InfoLogs.Message = msg - f.InfoLogs.Ctx = ctx + f.InfoLogs.Call(msg, ctx) } func (f *TestLogger) Warn(msg string, ctx ...any) { - f.WarnLogs.Calls++ - f.WarnLogs.Message = msg - f.WarnLogs.Ctx = ctx + f.WarnLogs.Call(msg, ctx) } func (f *TestLogger) Debug(msg string, ctx ...any) { - f.DebugLogs.Calls++ - f.DebugLogs.Message = msg - f.DebugLogs.Ctx = ctx + f.DebugLogs.Call(msg, ctx) } func (f *TestLogger) Error(msg string, ctx ...any) { - f.ErrorLogs.Calls++ - f.ErrorLogs.Message = msg - f.ErrorLogs.Ctx = ctx + f.ErrorLogs.Call(msg, ctx) } func (f *TestLogger) FromContext(_ context.Context) Logger { @@ -51,6 +46,16 @@ type Logs struct { Calls int Message string Ctx []any + + mu sync.Mutex +} + +func (l *Logs) Call(msg string, ctx ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.Calls++ + l.Message = msg + l.Ctx = ctx } var _ PrettyLogger = (*TestPrettyLogger)(nil) diff --git a/pkg/plugins/manager/client/client.go b/pkg/plugins/manager/client/client.go index 9914b615f2ecf..aed1acd6e6514 100644 --- a/pkg/plugins/manager/client/client.go +++ b/pkg/plugins/manager/client/client.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/manager/registry" ) @@ -29,13 +28,11 @@ var ( type Service struct { pluginRegistry registry.Service - cfg *config.Cfg } -func ProvideService(pluginRegistry registry.Service, cfg *config.Cfg) *Service { +func ProvideService(pluginRegistry registry.Service) *Service { return &Service{ pluginRegistry: pluginRegistry, - cfg: cfg, } } @@ -44,7 +41,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return nil, errNilRequest } - p, exists := s.plugin(ctx, req.PluginContext.PluginID) + p, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion) if !exists { return nil, plugins.ErrPluginNotRegistered } @@ -87,7 +84,7 @@ func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceReq return errNilSender } - p, exists := s.plugin(ctx, req.PluginContext.PluginID) + p, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion) if !exists { return plugins.ErrPluginNotRegistered } @@ -130,7 +127,7 @@ func (s *Service) CollectMetrics(ctx context.Context, req *backend.CollectMetric return nil, errNilRequest } - p, exists := s.plugin(ctx, req.PluginContext.PluginID) + p, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion) if !exists { return nil, plugins.ErrPluginNotRegistered } @@ -152,7 +149,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque return nil, errNilRequest } - p, exists := s.plugin(ctx, req.PluginContext.PluginID) + p, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion) if !exists { return nil, plugins.ErrPluginNotRegistered } @@ -182,7 +179,7 @@ func (s *Service) SubscribeStream(ctx context.Context, req *backend.SubscribeStr return nil, errNilRequest } - plugin, exists := s.plugin(ctx, req.PluginContext.PluginID) + plugin, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion) if !exists { return nil, plugins.ErrPluginNotRegistered } @@ -195,7 +192,7 @@ func (s *Service) PublishStream(ctx context.Context, req *backend.PublishStreamR return nil, errNilRequest } - plugin, exists := s.plugin(ctx, req.PluginContext.PluginID) + plugin, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion) if !exists { return nil, plugins.ErrPluginNotRegistered } @@ -212,7 +209,7 @@ func (s *Service) RunStream(ctx context.Context, req *backend.RunStreamRequest, return errNilSender } - plugin, exists := s.plugin(ctx, req.PluginContext.PluginID) + plugin, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion) if !exists { return plugins.ErrPluginNotRegistered } @@ -221,8 +218,8 @@ func (s *Service) RunStream(ctx context.Context, req *backend.RunStreamRequest, } // plugin finds a plugin with `pluginID` from the registry that is not decommissioned -func (s *Service) plugin(ctx context.Context, pluginID string) (*plugins.Plugin, bool) { - p, exists := s.pluginRegistry.Plugin(ctx, pluginID) +func (s *Service) plugin(ctx context.Context, pluginID, pluginVersion string) (*plugins.Plugin, bool) { + p, exists := s.pluginRegistry.Plugin(ctx, pluginID, pluginVersion) if !exists { return nil, false } diff --git a/pkg/plugins/manager/client/client_test.go b/pkg/plugins/manager/client/client_test.go index 7c32f97526c12..c6649b2f9dd7e 100644 --- a/pkg/plugins/manager/client/client_test.go +++ b/pkg/plugins/manager/client/client_test.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" - "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/manager/fakes" "github.com/stretchr/testify/require" ) @@ -18,7 +17,7 @@ import ( func TestQueryData(t *testing.T) { t.Run("Empty registry should return not registered error", func(t *testing.T) { registry := fakes.NewFakePluginRegistry() - client := ProvideService(registry, &config.Cfg{}) + client := ProvideService(registry) _, err := client.QueryData(context.Background(), &backend.QueryDataRequest{}) require.Error(t, err) require.ErrorIs(t, err, plugins.ErrPluginNotRegistered) @@ -63,7 +62,7 @@ func TestQueryData(t *testing.T) { err := registry.Add(context.Background(), p) require.NoError(t, err) - client := ProvideService(registry, &config.Cfg{}) + client := ProvideService(registry) _, err = client.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ PluginID: "grafana", @@ -79,7 +78,7 @@ func TestQueryData(t *testing.T) { func TestCheckHealth(t *testing.T) { t.Run("empty plugin registry should return plugin not registered error", func(t *testing.T) { registry := fakes.NewFakePluginRegistry() - client := ProvideService(registry, &config.Cfg{}) + client := ProvideService(registry) _, err := client.CheckHealth(context.Background(), &backend.CheckHealthRequest{}) require.Error(t, err) require.ErrorIs(t, err, plugins.ErrPluginNotRegistered) @@ -125,7 +124,7 @@ func TestCheckHealth(t *testing.T) { err := registry.Add(context.Background(), p) require.NoError(t, err) - client := ProvideService(registry, &config.Cfg{}) + client := ProvideService(registry) _, err = client.CheckHealth(context.Background(), &backend.CheckHealthRequest{ PluginContext: backend.PluginContext{ PluginID: "grafana", @@ -189,7 +188,7 @@ func TestCallResource(t *testing.T) { err := registry.Add(context.Background(), p) require.NoError(t, err) - client := ProvideService(registry, &config.Cfg{}) + client := ProvideService(registry) err = client.CallResource(context.Background(), req, sender) require.NoError(t, err) @@ -252,7 +251,7 @@ func TestCallResource(t *testing.T) { err := registry.Add(context.Background(), p) require.NoError(t, err) - client := ProvideService(registry, &config.Cfg{}) + client := ProvideService(registry) err = client.CallResource(context.Background(), req, sender) require.NoError(t, err) @@ -298,7 +297,7 @@ func TestCallResource(t *testing.T) { err := registry.Add(context.Background(), p) require.NoError(t, err) - client := ProvideService(registry, &config.Cfg{}) + client := ProvideService(registry) err = client.CallResource(context.Background(), req, sender) require.NoError(t, err) @@ -366,7 +365,7 @@ func TestCallResource(t *testing.T) { err := registry.Add(context.Background(), p) require.NoError(t, err) - client := ProvideService(registry, &config.Cfg{}) + client := ProvideService(registry) err = client.CallResource(context.Background(), req, sender) require.NoError(t, err) diff --git a/pkg/plugins/manager/fakes/fakes.go b/pkg/plugins/manager/fakes/fakes.go index 9b950274da6e7..361d2d90d237a 100644 --- a/pkg/plugins/manager/fakes/fakes.go +++ b/pkg/plugins/manager/fakes/fakes.go @@ -13,7 +13,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/auth" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/log" - "github.com/grafana/grafana/pkg/plugins/plugindef" + "github.com/grafana/grafana/pkg/plugins/pfs" "github.com/grafana/grafana/pkg/plugins/repo" "github.com/grafana/grafana/pkg/plugins/storage" ) @@ -176,7 +176,7 @@ func NewFakePluginRegistry() *FakePluginRegistry { } } -func (f *FakePluginRegistry) Plugin(_ context.Context, id string) (*plugins.Plugin, bool) { +func (f *FakePluginRegistry) Plugin(_ context.Context, id, _ string) (*plugins.Plugin, bool) { p, exists := f.Store[id] return p, exists } @@ -195,7 +195,7 @@ func (f *FakePluginRegistry) Add(_ context.Context, p *plugins.Plugin) error { return nil } -func (f *FakePluginRegistry) Remove(_ context.Context, id string) error { +func (f *FakePluginRegistry) Remove(_ context.Context, id, _ string) error { delete(f.Store, id) return nil } @@ -255,6 +255,21 @@ func (s *FakePluginStorage) Extract(ctx context.Context, pluginID string, dirNam return &storage.ExtractedPluginArchive{}, nil } +type FakePluginEnvProvider struct { + PluginEnvVarsFunc func(ctx context.Context, plugin *plugins.Plugin) []string +} + +func NewFakePluginEnvProvider() *FakePluginEnvProvider { + return &FakePluginEnvProvider{} +} + +func (p *FakePluginEnvProvider) PluginEnvVars(ctx context.Context, plugin *plugins.Plugin) []string { + if p.PluginEnvVarsFunc != nil { + return p.PluginEnvVarsFunc(ctx, plugin) + } + return []string{} +} + type FakeProcessManager struct { StartFunc func(_ context.Context, p *plugins.Plugin) error StopFunc func(_ context.Context, p *plugins.Plugin) error @@ -423,12 +438,12 @@ func (s *FakePluginSource) DefaultSignature(ctx context.Context) (plugins.Signat } type FakePluginFileStore struct { - FileFunc func(ctx context.Context, pluginID, filename string) (*plugins.File, error) + FileFunc func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) } -func (f *FakePluginFileStore) File(ctx context.Context, pluginID, filename string) (*plugins.File, error) { +func (f *FakePluginFileStore) File(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) { if f.FileFunc != nil { - return f.FileFunc(ctx, pluginID, filename) + return f.FileFunc(ctx, pluginID, pluginVersion, filename) } return nil, nil } @@ -441,7 +456,7 @@ func (f *FakeAuthService) HasExternalService(ctx context.Context, pluginID strin return f.Result != nil, nil } -func (f *FakeAuthService) RegisterExternalService(ctx context.Context, pluginID string, pType plugindef.Type, svc *plugindef.IAM) (*auth.ExternalService, error) { +func (f *FakeAuthService) RegisterExternalService(ctx context.Context, pluginID string, pType pfs.Type, svc *pfs.IAM) (*auth.ExternalService, error) { return f.Result, nil } @@ -574,26 +589,3 @@ func (p *FakeBackendPlugin) Kill() { defer p.mutex.Unlock() p.Running = false } - -type FakeFeatureToggles struct { - features map[string]bool -} - -func NewFakeFeatureToggles(features ...string) *FakeFeatureToggles { - m := make(map[string]bool) - for _, f := range features { - m[f] = true - } - - return &FakeFeatureToggles{ - features: m, - } -} - -func (f *FakeFeatureToggles) GetEnabled(_ context.Context) map[string]bool { - return f.features -} - -func (f *FakeFeatureToggles) IsEnabledGlobally(feature string) bool { - return f.features[feature] -} diff --git a/pkg/plugins/manager/filestore/fs.go b/pkg/plugins/manager/filestore/fs.go index 6d133d7bc6dd8..155880eb570f5 100644 --- a/pkg/plugins/manager/filestore/fs.go +++ b/pkg/plugins/manager/filestore/fs.go @@ -21,8 +21,8 @@ func ProvideService(pluginRegistry registry.Service) *Service { } } -func (s *Service) File(ctx context.Context, pluginID, filename string) (*plugins.File, error) { - if p, exists := s.pluginRegistry.Plugin(ctx, pluginID); exists { +func (s *Service) File(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) { + if p, exists := s.pluginRegistry.Plugin(ctx, pluginID, pluginVersion); exists { f, err := p.File(filename) if err != nil { return nil, err diff --git a/pkg/plugins/manager/installer.go b/pkg/plugins/manager/installer.go index e4f26dc54c82e..02aafee67338b 100644 --- a/pkg/plugins/manager/installer.go +++ b/pkg/plugins/manager/installer.go @@ -28,7 +28,7 @@ type PluginInstaller struct { serviceRegistry auth.ExternalServiceRegistry } -func ProvideInstaller(cfg *config.Cfg, pluginRegistry registry.Service, pluginLoader loader.Service, +func ProvideInstaller(cfg *config.PluginManagementCfg, pluginRegistry registry.Service, pluginLoader loader.Service, pluginRepo repo.Service, serviceRegistry auth.ExternalServiceRegistry) *PluginInstaller { return New(pluginRegistry, pluginLoader, pluginRepo, storage.FileSystem(log.NewPrettyLogger("installer.fs"), cfg.PluginsPath), storage.SimpleDirNameGeneratorFunc, serviceRegistry) @@ -55,7 +55,7 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt } var pluginArchive *repo.PluginArchive - if plugin, exists := m.plugin(ctx, pluginID); exists { + if plugin, exists := m.plugin(ctx, pluginID, version); exists { if plugin.IsCorePlugin() || plugin.IsBundledPlugin() { return plugins.ErrInstallCorePlugin } @@ -84,7 +84,7 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt } // remove existing installation of plugin - err = m.Remove(ctx, plugin.ID) + err = m.Remove(ctx, plugin.ID, plugin.Info.Version) if err != nil { return err } @@ -139,8 +139,8 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt return nil } -func (m *PluginInstaller) Remove(ctx context.Context, pluginID string) error { - plugin, exists := m.plugin(ctx, pluginID) +func (m *PluginInstaller) Remove(ctx context.Context, pluginID, version string) error { + plugin, exists := m.plugin(ctx, pluginID, version) if !exists { return plugins.ErrPluginNotInstalled } @@ -168,8 +168,8 @@ func (m *PluginInstaller) Remove(ctx context.Context, pluginID string) error { } // plugin finds a plugin with `pluginID` from the store -func (m *PluginInstaller) plugin(ctx context.Context, pluginID string) (*plugins.Plugin, bool) { - p, exists := m.pluginRegistry.Plugin(ctx, pluginID) +func (m *PluginInstaller) plugin(ctx context.Context, pluginID, pluginVersion string) (*plugins.Plugin, bool) { + p, exists := m.pluginRegistry.Plugin(ctx, pluginID, pluginVersion) if !exists { return nil, false } diff --git a/pkg/plugins/manager/installer_test.go b/pkg/plugins/manager/installer_test.go index 43465cd23ff4f..7bfadece3af9a 100644 --- a/pkg/plugins/manager/installer_test.go +++ b/pkg/plugins/manager/installer_test.go @@ -23,6 +23,8 @@ func TestPluginManager_Add_Remove(t *testing.T) { const ( pluginID, v1 = "test-panel", "1.0.0" zipNameV1 = "test-panel-1.0.0.zip" + v2 = "2.0.0" + zipNameV2 = "test-panel-2.0.0.zip" ) // mock a plugin to be returned automatically by the plugin loader @@ -83,10 +85,6 @@ func TestPluginManager_Add_Remove(t *testing.T) { }) t.Run("Update plugin to different version", func(t *testing.T) { - const ( - v2 = "2.0.0" - zipNameV2 = "test-panel-2.0.0.zip" - ) // mock a plugin to be returned automatically by the plugin loader pluginV2 := createPlugin(t, pluginID, plugins.ClassExternal, true, true, func(plugin *plugins.Plugin) { plugin.Info.Version = v2 @@ -138,7 +136,7 @@ func TestPluginManager_Add_Remove(t *testing.T) { }, } - err = inst.Remove(context.Background(), pluginID) + err = inst.Remove(context.Background(), pluginID, v2) require.NoError(t, err) require.Equal(t, []string{pluginID}, unloadedPlugins) @@ -146,7 +144,7 @@ func TestPluginManager_Add_Remove(t *testing.T) { t.Run("Won't remove if not exists", func(t *testing.T) { inst.pluginRegistry = fakes.NewFakePluginRegistry() - err = inst.Remove(context.Background(), pluginID) + err = inst.Remove(context.Background(), pluginID, v2) require.Equal(t, plugins.ErrPluginNotInstalled, err) }) }) @@ -179,7 +177,7 @@ func TestPluginManager_Add_Remove(t *testing.T) { require.Equal(t, plugins.ErrInstallCorePlugin, err) t.Run(fmt.Sprintf("Can't uninstall %s plugin", tc.class), func(t *testing.T) { - err = pm.Remove(context.Background(), p.ID) + err = pm.Remove(context.Background(), p.ID, p.Info.Version) require.Equal(t, plugins.ErrUninstallCorePlugin, err) }) } diff --git a/pkg/plugins/manager/loader/assetpath/assetpath.go b/pkg/plugins/manager/loader/assetpath/assetpath.go index cc610f8d3401f..eb24b0434a81f 100644 --- a/pkg/plugins/manager/loader/assetpath/assetpath.go +++ b/pkg/plugins/manager/loader/assetpath/assetpath.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/pluginscdn" - "github.com/grafana/grafana/pkg/services/featuremgmt" ) // Service provides methods for constructing asset paths for plugins. @@ -18,10 +17,10 @@ import ( // on the plugins CDN, and it will switch to the correct implementation depending on the plugin and the config. type Service struct { cdn *pluginscdn.Service - cfg *config.Cfg + cfg *config.PluginManagementCfg } -func ProvideService(cfg *config.Cfg, cdn *pluginscdn.Service) *Service { +func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) *Service { return &Service{cfg: cfg, cdn: cdn} } @@ -39,7 +38,7 @@ func NewPluginInfo(pluginJSON plugins.JSONData, class plugins.Class, fs plugins. } } -func DefaultService(cfg *config.Cfg) *Service { +func DefaultService(cfg *config.PluginManagementCfg) *Service { return &Service{cfg: cfg, cdn: pluginscdn.ProvideService(cfg)} } @@ -47,20 +46,18 @@ func DefaultService(cfg *config.Cfg) *Service { func (s *Service) Base(n PluginInfo) (string, error) { if n.class == plugins.ClassCore { baseDir := getBaseDir(n.dir) - return path.Join("/", s.cfg.GrafanaAppSubURL, "/public/app/plugins", string(n.pluginJSON.Type), baseDir), nil + return path.Join("public/app/plugins", string(n.pluginJSON.Type), baseDir), nil } if s.cdn.PluginSupported(n.pluginJSON.ID) { return s.cdn.AssetURL(n.pluginJSON.ID, n.pluginJSON.Info.Version, "") } - return path.Join("/", s.cfg.GrafanaAppSubURL, "/public/plugins", n.pluginJSON.ID), nil + return path.Join("public/plugins", n.pluginJSON.ID), nil } // Module returns the module.js path for the specified plugin. func (s *Service) Module(n PluginInfo) (string, error) { if n.class == plugins.ClassCore { - if s.cfg.Features != nil && - s.cfg.Features.IsEnabledGlobally(featuremgmt.FlagExternalCorePlugins) && - filepath.Base(n.dir) == "dist" { + if filepath.Base(n.dir) == "dist" { // The core plugin has been built externally, use the module from the dist folder } else { baseDir := getBaseDir(n.dir) @@ -70,7 +67,7 @@ func (s *Service) Module(n PluginInfo) (string, error) { if s.cdn.PluginSupported(n.pluginJSON.ID) { return s.cdn.AssetURL(n.pluginJSON.ID, n.pluginJSON.Info.Version, "module.js") } - return path.Join("/", s.cfg.GrafanaAppSubURL, "/public/plugins", n.pluginJSON.ID, "module.js"), nil + return path.Join("public/plugins", n.pluginJSON.ID, "module.js"), nil } // RelativeURL returns the relative URL for an arbitrary plugin asset. @@ -101,7 +98,7 @@ func (s *Service) RelativeURL(n PluginInfo, pathStr string) (string, error) { // DefaultLogoPath returns the default logo path for the specified plugin type. func (s *Service) DefaultLogoPath(pluginType plugins.Type) string { - return path.Join("/", s.cfg.GrafanaAppSubURL, fmt.Sprintf("/public/img/icn-%s.svg", string(pluginType))) + return path.Join("public/img", fmt.Sprintf("icn-%s.svg", string(pluginType))) } func getBaseDir(pluginDir string) string { diff --git a/pkg/plugins/manager/loader/assetpath/assetpath_test.go b/pkg/plugins/manager/loader/assetpath/assetpath_test.go index 78d32ee75d61c..9802dcfe00b0b 100644 --- a/pkg/plugins/manager/loader/assetpath/assetpath_test.go +++ b/pkg/plugins/manager/loader/assetpath/assetpath_test.go @@ -36,7 +36,7 @@ func TestService(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - cfg := &config.Cfg{ + cfg := &config.PluginManagementCfg{ PluginsCDNURLTemplate: tc.cdnBaseURL, PluginSettings: map[string]map[string]string{ "one": {"cdn": "true"}, @@ -69,11 +69,11 @@ func TestService(t *testing.T) { base, err = svc.Base(NewPluginInfo(jsonData["two"], plugins.ClassExternal, extPath("two"))) require.NoError(t, err) - require.Equal(t, "/public/plugins/two", base) + require.Equal(t, "public/plugins/two", base) base, err = svc.Base(NewPluginInfo(jsonData["table-old"], plugins.ClassCore, tableOldFS)) require.NoError(t, err) - require.Equal(t, "/public/app/plugins/table-old", base) + require.Equal(t, "public/app/plugins/table-old", base) }) t.Run("Module", func(t *testing.T) { @@ -86,7 +86,7 @@ func TestService(t *testing.T) { module, err = svc.Module(NewPluginInfo(jsonData["two"], plugins.ClassExternal, extPath("two"))) require.NoError(t, err) - require.Equal(t, "/public/plugins/two/module.js", module) + require.Equal(t, "public/plugins/two/module.js", module) module, err = svc.Module(NewPluginInfo(jsonData["table-old"], plugins.ClassCore, tableOldFS)) require.NoError(t, err) @@ -116,54 +116,12 @@ func TestService(t *testing.T) { u, err = svc.RelativeURL(NewPluginInfo(pluginsMap["two"].JSONData, plugins.ClassExternal, extPath("two")), "path/to/file.txt") require.NoError(t, err) - require.Equal(t, "/public/plugins/two/path/to/file.txt", u) + require.Equal(t, "public/plugins/two/path/to/file.txt", u) u, err = svc.RelativeURL(NewPluginInfo(pluginsMap["two"].JSONData, plugins.ClassExternal, extPath("two")), "default") require.NoError(t, err) - require.Equal(t, "/public/plugins/two/default", u) + require.Equal(t, "public/plugins/two/default", u) }) }) } - - t.Run("With App Sub URL", func(t *testing.T) { - for _, tc := range []struct { - appSubURL string - }{ - { - appSubURL: "grafana", - }, - { - appSubURL: "/grafana", - }, - { - appSubURL: "grafana/", - }, - { - appSubURL: "/grafana/", - }, - } { - cfg := &config.Cfg{GrafanaAppSubURL: tc.appSubURL} - svc := ProvideService(cfg, pluginscdn.ProvideService(cfg)) - - dir := "/plugins/test-datasource" - p := plugins.JSONData{ID: "test-datasource"} - fs := fakes.NewFakePluginFiles(dir) - - base, err := svc.Base(NewPluginInfo(p, plugins.ClassExternal, fs)) - require.NoError(t, err) - require.Equal(t, "/grafana/public/plugins/test-datasource", base) - - mod, err := svc.Module(NewPluginInfo(p, plugins.ClassExternal, fs)) - require.NoError(t, err) - require.Equal(t, "/grafana/public/plugins/test-datasource/module.js", mod) - - base, err = svc.Base(NewPluginInfo(p, plugins.ClassCore, fs)) - require.NoError(t, err) - require.Equal(t, "/grafana/public/app/plugins/test-datasource", base) - - mod, err = svc.Module(NewPluginInfo(p, plugins.ClassCore, fs)) - require.NoError(t, err) - require.Equal(t, "core:plugin/test-datasource", mod) - } - }) } diff --git a/pkg/plugins/manager/loader/finder/local.go b/pkg/plugins/manager/loader/finder/local.go index aab9712f91e83..29f3bacfed145 100644 --- a/pkg/plugins/manager/loader/finder/local.go +++ b/pkg/plugins/manager/loader/finder/local.go @@ -13,7 +13,6 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/util" ) @@ -26,19 +25,17 @@ var ( type Local struct { log log.Logger production bool - features plugins.FeatureToggles } -func NewLocalFinder(devMode bool, features plugins.FeatureToggles) *Local { +func NewLocalFinder(devMode bool) *Local { return &Local{ production: !devMode, log: log.New("local.finder"), - features: features, } } -func ProvideLocalFinder(cfg *config.Cfg) *Local { - return NewLocalFinder(cfg.DevMode, cfg.Features) +func ProvideLocalFinder(cfg *config.PluginManagementCfg) *Local { + return NewLocalFinder(cfg.DevMode) } func (l *Local) Find(ctx context.Context, src plugins.PluginSource) ([]*plugins.FoundBundle, error) { @@ -59,12 +56,7 @@ func (l *Local) Find(ctx context.Context, src plugins.PluginSource) ([]*plugins. continue } - followDistFolder := true - if src.PluginClass(ctx) == plugins.ClassCore && - !l.features.IsEnabledGlobally(featuremgmt.FlagExternalCorePlugins) { - followDistFolder = false - } - paths, err := l.getAbsPluginJSONPaths(path, followDistFolder) + paths, err := l.getAbsPluginJSONPaths(path) if err != nil { return nil, err } @@ -167,7 +159,7 @@ func (l *Local) readPluginJSON(pluginJSONPath string) (plugins.JSONData, error) return plugin, nil } -func (l *Local) getAbsPluginJSONPaths(path string, followDistFolder bool) ([]string, error) { +func (l *Local) getAbsPluginJSONPaths(path string) ([]string, error) { var pluginJSONPaths []string var err error @@ -176,7 +168,7 @@ func (l *Local) getAbsPluginJSONPaths(path string, followDistFolder bool) ([]str return []string{}, err } - if err = walk(path, true, true, followDistFolder, + if err = walk(path, true, true, func(currentPath string, fi os.FileInfo, err error) error { if err != nil { if errors.Is(err, os.ErrNotExist) { diff --git a/pkg/plugins/manager/loader/finder/local_test.go b/pkg/plugins/manager/loader/finder/local_test.go index d7da23e0e1566..c7ccefbeeb9bd 100644 --- a/pkg/plugins/manager/loader/finder/local_test.go +++ b/pkg/plugins/manager/loader/finder/local_test.go @@ -13,7 +13,6 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/manager/fakes" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/util" ) @@ -143,6 +142,7 @@ func TestFinder_Find(t *testing.T) { {Name: "img1", Path: "img/screenshot1.png"}, {Name: "img2", Path: "img/screenshot2.png"}, }, + Keywords: []string{"test"}, }, Dependencies: plugins.Dependencies{ GrafanaVersion: "3.x.x", @@ -176,40 +176,19 @@ func TestFinder_Find(t *testing.T) { { name: "Multiple plugin dirs", pluginDirs: []string{"../../testdata/duplicate-plugins", "../../testdata/invalid-v1-signature"}, - expectedBundles: []*plugins.FoundBundle{{ - Primary: plugins.FoundPlugin{ - JSONData: plugins.JSONData{ - ID: "test-app", - Type: plugins.TypeDataSource, - Name: "Parent", - Info: plugins.Info{ - Author: plugins.InfoLink{ - Name: "Grafana Labs", - URL: "http://grafana.com", - }, - Description: "Parent plugin", - Version: "1.0.0", - Updated: "2020-10-20", - }, - Dependencies: plugins.Dependencies{ - GrafanaVersion: "*", - Plugins: []plugins.Dependency{}, - }, - }, - FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested")), - }, - Children: []*plugins.FoundPlugin{ - { + expectedBundles: []*plugins.FoundBundle{ + { + Primary: plugins.FoundPlugin{ JSONData: plugins.JSONData{ ID: "test-app", Type: plugins.TypeDataSource, - Name: "Child", + Name: "Parent", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Grafana Labs", URL: "http://grafana.com", }, - Description: "Child plugin", + Description: "Parent plugin", Version: "1.0.0", Updated: "2020-10-20", }, @@ -218,10 +197,32 @@ func TestFinder_Find(t *testing.T) { Plugins: []plugins.Dependency{}, }, }, - FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested/nested")), + FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested")), + }, + Children: []*plugins.FoundPlugin{ + { + JSONData: plugins.JSONData{ + ID: "test-app", + Type: plugins.TypeDataSource, + Name: "Child", + Info: plugins.Info{ + Author: plugins.InfoLink{ + Name: "Grafana Labs", + URL: "http://grafana.com", + }, + Description: "Child plugin", + Version: "1.0.0", + Updated: "2020-10-20", + }, + Dependencies: plugins.Dependencies{ + GrafanaVersion: "*", + Plugins: []plugins.Dependency{}, + }, + }, + FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested/nested")), + }, }, }, - }, { Primary: plugins.FoundPlugin{ JSONData: plugins.JSONData{ @@ -282,7 +283,7 @@ func TestFinder_Find(t *testing.T) { } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - f := NewLocalFinder(false, featuremgmt.WithFeatures(featuremgmt.FlagExternalCorePlugins)) + f := NewLocalFinder(false) pluginBundles, err := f.Find(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return tc.pluginClass @@ -311,105 +312,48 @@ func TestFinder_Find(t *testing.T) { func TestFinder_getAbsPluginJSONPaths(t *testing.T) { t.Run("When scanning a folder that doesn't exists shouldn't return an error", func(t *testing.T) { origWalk := walk - walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop, followDistFolder bool, walkFn util.WalkFunc) error { + walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error { return walkFn(path, nil, os.ErrNotExist) } t.Cleanup(func() { walk = origWalk }) - finder := NewLocalFinder(false, featuremgmt.WithFeatures()) - paths, err := finder.getAbsPluginJSONPaths("test", true) + finder := NewLocalFinder(false) + paths, err := finder.getAbsPluginJSONPaths("test") require.NoError(t, err) require.Empty(t, paths) }) t.Run("When scanning a folder that lacks permission shouldn't return an error", func(t *testing.T) { origWalk := walk - walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop, followDistFolder bool, walkFn util.WalkFunc) error { + walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error { return walkFn(path, nil, os.ErrPermission) } t.Cleanup(func() { walk = origWalk }) - finder := NewLocalFinder(false, featuremgmt.WithFeatures()) - paths, err := finder.getAbsPluginJSONPaths("test", true) + finder := NewLocalFinder(false) + paths, err := finder.getAbsPluginJSONPaths("test") require.NoError(t, err) require.Empty(t, paths) }) t.Run("When scanning a folder that returns a non-handled error should return that error", func(t *testing.T) { origWalk := walk - walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop, followDistFolder bool, walkFn util.WalkFunc) error { + walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error { return walkFn(path, nil, errors.New("random error")) } t.Cleanup(func() { walk = origWalk }) - finder := NewLocalFinder(false, featuremgmt.WithFeatures()) - paths, err := finder.getAbsPluginJSONPaths("test", true) + finder := NewLocalFinder(false) + paths, err := finder.getAbsPluginJSONPaths("test") require.Error(t, err) require.Empty(t, paths) }) - - t.Run("should forward if the dist folder should be evaluated", func(t *testing.T) { - origWalk := walk - walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop, followDistFolder bool, walkFn util.WalkFunc) error { - if followDistFolder { - return walkFn(path, nil, errors.New("unexpected followDistFolder")) - } - return walkFn(path, nil, filepath.SkipDir) - } - t.Cleanup(func() { - walk = origWalk - }) - - finder := NewLocalFinder(false, featuremgmt.WithFeatures()) - paths, err := finder.getAbsPluginJSONPaths("test", false) - require.ErrorIs(t, err, filepath.SkipDir) - require.Empty(t, paths) - }) -} - -func TestFinder_getAbsPluginJSONPaths_PluginClass(t *testing.T) { - t.Run("When a dist folder exists as a direct child of the plugins path, it will always be resolved", func(t *testing.T) { - dir, err := filepath.Abs("../../testdata/pluginRootWithDist") - require.NoError(t, err) - - tcs := []struct { - name string - followDist bool - expected []string - }{ - { - name: "When followDistFolder is enabled, a nested dist folder will also be resolved", - followDist: true, - expected: []string{ - filepath.Join(dir, "datasource/plugin.json"), - filepath.Join(dir, "dist/plugin.json"), - filepath.Join(dir, "panel/dist/plugin.json"), - }, - }, - { - name: "When followDistFolder is disabled, a nested dist folder will not be resolved", - followDist: false, - expected: []string{ - filepath.Join(dir, "datasource/plugin.json"), - filepath.Join(dir, "dist/plugin.json"), - filepath.Join(dir, "panel/src/plugin.json"), - }, - }, - } - for _, tc := range tcs { - pluginBundles, err := NewLocalFinder(false, featuremgmt.WithFeatures()).getAbsPluginJSONPaths(dir, tc.followDist) - require.NoError(t, err) - - sort.Strings(pluginBundles) - require.Equal(t, tc.expected, pluginBundles) - } - }) } var fsComparer = cmp.Comparer(func(fs1 plugins.FS, fs2 plugins.FS) bool { diff --git a/pkg/plugins/manager/loader/loader.go b/pkg/plugins/manager/loader/loader.go index c6b85bbb5f4ed..91b2175664e39 100644 --- a/pkg/plugins/manager/loader/loader.go +++ b/pkg/plugins/manager/loader/loader.go @@ -75,6 +75,10 @@ func (l *Loader) instrumentLoad(ctx context.Context, src plugins.PluginSource) f return func(logger log.Logger, start time.Time) func([]*plugins.Plugin) { return func(plugins []*plugins.Plugin) { + if len(plugins) == 0 { + logger.Debug("Plugin source loaded, though no plugins were found") + return + } names := make([]string, len(plugins)) for i, p := range plugins { names[i] = p.ID diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index b152019ec4093..63471d8c53552 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -20,7 +20,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/pipeline/termination" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/validation" "github.com/grafana/grafana/pkg/plugins/manager/sources" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" ) @@ -61,14 +60,14 @@ func TestLoader_Load(t *testing.T) { tests := []struct { name string class plugins.Class - cfg *config.Cfg + cfg *config.PluginManagementCfg pluginPaths []string want []*plugins.Plugin }{ { name: "Load a Core plugin", class: plugins.ClassCore, - cfg: &config.Cfg{Features: featuremgmt.WithFeatures()}, + cfg: &config.PluginManagementCfg{}, pluginPaths: []string{filepath.Join(corePluginDir, "app/plugins/datasource/cloudwatch")}, want: []*plugins.Plugin{ { @@ -83,8 +82,8 @@ func TestLoader_Load(t *testing.T) { }, Description: "Data source for Amazon AWS monitoring service", Logos: plugins.Logos{ - Small: "/public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", - Large: "/public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", + Small: "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", + Large: "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", }, }, Includes: []*plugins.Includes{ @@ -106,9 +105,8 @@ func TestLoader_Load(t *testing.T) { Backend: true, QueryOptions: map[string]bool{"minInterval": true}, }, - Module: "core:plugin/cloudwatch", - BaseURL: "/public/app/plugins/datasource/cloudwatch", - + Module: "core:plugin/cloudwatch", + BaseURL: "public/app/plugins/datasource/cloudwatch", FS: mustNewStaticFSForTests(t, filepath.Join(corePluginDir, "app/plugins/datasource/cloudwatch")), Signature: plugins.SignatureStatusInternal, Class: plugins.ClassCore, @@ -118,7 +116,7 @@ func TestLoader_Load(t *testing.T) { { name: "Load a Bundled plugin", class: plugins.ClassBundled, - cfg: &config.Cfg{Features: featuremgmt.WithFeatures()}, + cfg: &config.PluginManagementCfg{}, pluginPaths: []string{"../testdata/valid-v2-signature"}, want: []*plugins.Plugin{ { @@ -133,8 +131,8 @@ func TestLoader_Load(t *testing.T) { }, Version: "1.0.0", Logos: plugins.Logos{ - Small: "/public/img/icn-datasource.svg", - Large: "/public/img/icn-datasource.svg", + Small: "public/img/icn-datasource.svg", + Large: "public/img/icn-datasource.svg", }, Description: "Test", }, @@ -146,8 +144,8 @@ func TestLoader_Load(t *testing.T) { Backend: true, State: "alpha", }, - Module: "/public/plugins/test-datasource/module.js", - BaseURL: "/public/plugins/test-datasource", + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/valid-v2-signature/plugin/")), Signature: "valid", SignatureType: plugins.SignatureTypeGrafana, @@ -155,10 +153,11 @@ func TestLoader_Load(t *testing.T) { Class: plugins.ClassBundled, }, }, - }, { + }, + { name: "Load plugin with symbolic links", class: plugins.ClassExternal, - cfg: &config.Cfg{Features: featuremgmt.WithFeatures()}, + cfg: &config.PluginManagementCfg{}, pluginPaths: []string{"../testdata/symbolic-plugin-dirs"}, want: []*plugins.Plugin{ { @@ -172,8 +171,8 @@ func TestLoader_Load(t *testing.T) { URL: "http://test.com", }, Logos: plugins.Logos{ - Small: "/public/plugins/test-app/img/logo_small.png", - Large: "/public/plugins/test-app/img/logo_large.png", + Small: "public/plugins/test-app/img/logo_small.png", + Large: "public/plugins/test-app/img/logo_large.png", }, Links: []plugins.InfoLink{ {Name: "Project site", URL: "http://project.com"}, @@ -181,11 +180,12 @@ func TestLoader_Load(t *testing.T) { }, Description: "Official Grafana Test App & Dashboard bundle", Screenshots: []plugins.Screenshots{ - {Path: "/public/plugins/test-app/img/screenshot1.png", Name: "img1"}, - {Path: "/public/plugins/test-app/img/screenshot2.png", Name: "img2"}, + {Path: "public/plugins/test-app/img/screenshot1.png", Name: "img1"}, + {Path: "public/plugins/test-app/img/screenshot2.png", Name: "img2"}, }, - Version: "1.0.0", - Updated: "2015-02-10", + Version: "1.0.0", + Updated: "2015-02-10", + Keywords: []string{"test"}, }, Dependencies: plugins.Dependencies{ GrafanaVersion: "3.x.x", @@ -213,7 +213,8 @@ func TestLoader_Load(t *testing.T) { Name: "Nginx Panel", Type: string(plugins.TypePanel), Role: org.RoleViewer, - Slug: "nginx-panel"}, + Slug: "nginx-panel", + }, { Name: "Nginx Datasource", Type: string(plugins.TypeDataSource), @@ -223,20 +224,20 @@ func TestLoader_Load(t *testing.T) { }, }, Class: plugins.ClassExternal, - Module: "/public/plugins/test-app/module.js", - BaseURL: "/public/plugins/test-app", + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")), Signature: "valid", SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", }, }, - }, { + }, + { name: "Load an unsigned plugin (development)", class: plugins.ClassExternal, - cfg: &config.Cfg{ - DevMode: true, - Features: featuremgmt.WithFeatures(), + cfg: &config.PluginManagementCfg{ + DevMode: true, }, pluginPaths: []string{"../testdata/unsigned-datasource"}, want: []*plugins.Plugin{ @@ -251,8 +252,8 @@ func TestLoader_Load(t *testing.T) { URL: "https://grafana.com", }, Logos: plugins.Logos{ - Small: "/public/img/icn-datasource.svg", - Large: "/public/img/icn-datasource.svg", + Small: "public/img/icn-datasource.svg", + Large: "public/img/icn-datasource.svg", }, Description: "Test", }, @@ -264,8 +265,8 @@ func TestLoader_Load(t *testing.T) { State: plugins.ReleaseStateAlpha, }, Class: plugins.ClassExternal, - Module: "/public/plugins/test-datasource/module.js", - BaseURL: "/public/plugins/test-datasource", + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")), Signature: "unsigned", }, @@ -274,16 +275,15 @@ func TestLoader_Load(t *testing.T) { { name: "Load an unsigned plugin (production)", class: plugins.ClassExternal, - cfg: &config.Cfg{Features: featuremgmt.WithFeatures()}, + cfg: &config.PluginManagementCfg{}, pluginPaths: []string{"../testdata/unsigned-datasource"}, want: []*plugins.Plugin{}, }, { name: "Load an unsigned plugin using PluginsAllowUnsigned config (production)", class: plugins.ClassExternal, - cfg: &config.Cfg{ + cfg: &config.PluginManagementCfg{ PluginsAllowUnsigned: []string{"test-datasource"}, - Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{"../testdata/unsigned-datasource"}, want: []*plugins.Plugin{ @@ -298,8 +298,8 @@ func TestLoader_Load(t *testing.T) { URL: "https://grafana.com", }, Logos: plugins.Logos{ - Small: "/public/img/icn-datasource.svg", - Large: "/public/img/icn-datasource.svg", + Small: "public/img/icn-datasource.svg", + Large: "public/img/icn-datasource.svg", }, Description: "Test", }, @@ -311,8 +311,8 @@ func TestLoader_Load(t *testing.T) { State: plugins.ReleaseStateAlpha, }, Class: plugins.ClassExternal, - Module: "/public/plugins/test-datasource/module.js", - BaseURL: "/public/plugins/test-datasource", + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")), Signature: plugins.SignatureStatusUnsigned, }, @@ -321,16 +321,15 @@ func TestLoader_Load(t *testing.T) { { name: "Load a plugin with v1 manifest should return signatureInvalid", class: plugins.ClassExternal, - cfg: &config.Cfg{Features: featuremgmt.WithFeatures()}, + cfg: &config.PluginManagementCfg{}, pluginPaths: []string{"../testdata/lacking-files"}, want: []*plugins.Plugin{}, }, { name: "Load a plugin with v1 manifest using PluginsAllowUnsigned config (production) should return signatureInvali", class: plugins.ClassExternal, - cfg: &config.Cfg{ + cfg: &config.PluginManagementCfg{ PluginsAllowUnsigned: []string{"test-datasource"}, - Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{"../testdata/lacking-files"}, want: []*plugins.Plugin{}, @@ -338,9 +337,8 @@ func TestLoader_Load(t *testing.T) { { name: "Load a plugin with manifest which has a file not found in plugin folder", class: plugins.ClassExternal, - cfg: &config.Cfg{ + cfg: &config.PluginManagementCfg{ PluginsAllowUnsigned: []string{"test-datasource"}, - Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{"../testdata/invalid-v2-missing-file"}, want: []*plugins.Plugin{}, @@ -348,9 +346,8 @@ func TestLoader_Load(t *testing.T) { { name: "Load a plugin with file which is missing from the manifest", class: plugins.ClassExternal, - cfg: &config.Cfg{ + cfg: &config.PluginManagementCfg{ PluginsAllowUnsigned: []string{"test-datasource"}, - Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{"../testdata/invalid-v2-extra-file"}, want: []*plugins.Plugin{}, @@ -358,9 +355,8 @@ func TestLoader_Load(t *testing.T) { { name: "Load an app with includes", class: plugins.ClassExternal, - cfg: &config.Cfg{ + cfg: &config.PluginManagementCfg{ PluginsAllowUnsigned: []string{"test-app"}, - Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{"../testdata/test-app-with-includes"}, want: []*plugins.Plugin{ @@ -381,10 +377,11 @@ func TestLoader_Load(t *testing.T) { {Name: "License & Terms", URL: "http://license.com"}, }, Logos: plugins.Logos{ - Small: "/public/img/icn-app.svg", - Large: "/public/img/icn-app.svg", + Small: "public/img/icn-app.svg", + Large: "public/img/icn-app.svg", }, - Updated: "2015-02-10", + Updated: "2015-02-10", + Keywords: []string{"test"}, }, Dependencies: plugins.Dependencies{ GrafanaDependency: ">=8.0.0", @@ -401,49 +398,8 @@ func TestLoader_Load(t *testing.T) { FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/test-app-with-includes")), Class: plugins.ClassExternal, Signature: plugins.SignatureStatusUnsigned, - Module: "/public/plugins/test-app/module.js", - BaseURL: "/public/plugins/test-app", - }, - }, - }, - { - name: "Load a plugin with app sub url set", - class: plugins.ClassExternal, - cfg: &config.Cfg{ - DevMode: true, - GrafanaAppSubURL: "grafana", - Features: featuremgmt.WithFeatures(), - }, - pluginPaths: []string{"../testdata/unsigned-datasource"}, - want: []*plugins.Plugin{ - { - JSONData: plugins.JSONData{ - ID: "test-datasource", - Type: plugins.TypeDataSource, - Name: "Test", - Info: plugins.Info{ - Author: plugins.InfoLink{ - Name: "Grafana Labs", - URL: "https://grafana.com", - }, - Logos: plugins.Logos{ - Small: "/grafana/public/img/icn-datasource.svg", - Large: "/grafana/public/img/icn-datasource.svg", - }, - Description: "Test", - }, - Dependencies: plugins.Dependencies{ - GrafanaVersion: "*", - Plugins: []plugins.Dependency{}, - }, - Backend: true, - State: plugins.ReleaseStateAlpha, - }, - Class: plugins.ClassExternal, - Module: "/grafana/public/plugins/test-datasource/module.js", - BaseURL: "/grafana/public/plugins/test-datasource", - FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")), - Signature: plugins.SignatureStatusUnsigned, + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", }, }, }, diff --git a/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go b/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go index 19300c4e2474f..df9e16692b19a 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go +++ b/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go @@ -42,7 +42,7 @@ type Opts struct { } // New returns a new Bootstrap stage. -func New(cfg *config.Cfg, opts Opts) *Bootstrap { +func New(cfg *config.PluginManagementCfg, opts Opts) *Bootstrap { if opts.ConstructFunc == nil { opts.ConstructFunc = DefaultConstructFunc(signature.DefaultCalculator(cfg), assetpath.DefaultService(cfg)) } diff --git a/pkg/plugins/manager/pipeline/bootstrap/steps.go b/pkg/plugins/manager/pipeline/bootstrap/steps.go index 83e025b18cf50..9c14796830b3c 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/steps.go +++ b/pkg/plugins/manager/pipeline/bootstrap/steps.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath" - "github.com/grafana/grafana/pkg/services/featuremgmt" ) // DefaultConstructor implements the default ConstructFunc used for the Construct step of the Bootstrap stage. @@ -29,11 +28,11 @@ func DefaultConstructFunc(signatureCalculator plugins.SignatureCalculator, asset } // DefaultDecorateFuncs are the default DecorateFuncs used for the Decorate step of the Bootstrap stage. -func DefaultDecorateFuncs(cfg *config.Cfg) []DecorateFunc { +func DefaultDecorateFuncs(cfg *config.PluginManagementCfg) []DecorateFunc { return []DecorateFunc{ AppDefaultNavURLDecorateFunc, TemplateDecorateFunc, - AppChildDecorateFunc(cfg), + AppChildDecorateFunc(), SkipHostEnvVarsDecorateFunc(cfg), } } @@ -133,37 +132,37 @@ func setDefaultNavURL(p *plugins.Plugin) { } // AppChildDecorateFunc is a DecorateFunc that configures child plugins of app plugins. -func AppChildDecorateFunc(cfg *config.Cfg) DecorateFunc { +func AppChildDecorateFunc() DecorateFunc { return func(_ context.Context, p *plugins.Plugin) (*plugins.Plugin, error) { if p.Parent != nil && p.Parent.IsApp() { - configureAppChildPlugin(cfg, p.Parent, p) + configureAppChildPlugin(p.Parent, p) } return p, nil } } -func configureAppChildPlugin(cfg *config.Cfg, parent *plugins.Plugin, child *plugins.Plugin) { +func configureAppChildPlugin(parent *plugins.Plugin, child *plugins.Plugin) { if !parent.IsApp() { return } child.IncludedInAppID = parent.ID child.BaseURL = parent.BaseURL + // TODO move this logic within assetpath package appSubPath := strings.ReplaceAll(strings.Replace(child.FS.Base(), parent.FS.Base(), "", 1), "\\", "/") if parent.IsCorePlugin() { child.Module = path.Join("core:plugin", parent.ID, appSubPath) } else { - child.Module = path.Join("/", cfg.GrafanaAppSubURL, "/public/plugins", parent.ID, appSubPath, "module.js") + child.Module = path.Join("public/plugins", parent.ID, appSubPath, "module.js") } } // SkipHostEnvVarsDecorateFunc returns a DecorateFunc that configures the SkipHostEnvVars field of the plugin. // It will be set to true if the FlagPluginsSkipHostEnvVars feature flag is set, and the plugin is not present in the // ForwardHostEnvVars plugin ids list. -func SkipHostEnvVarsDecorateFunc(cfg *config.Cfg) DecorateFunc { +func SkipHostEnvVarsDecorateFunc(cfg *config.PluginManagementCfg) DecorateFunc { return func(_ context.Context, p *plugins.Plugin) (*plugins.Plugin, error) { - p.SkipHostEnvVars = cfg.Features.IsEnabledGlobally(featuremgmt.FlagPluginsSkipHostEnvVars) && - !slices.Contains(cfg.ForwardHostEnvVars, p.ID) + p.SkipHostEnvVars = cfg.Features.SkipHostEnvVarsEnabled && !slices.Contains(cfg.ForwardHostEnvVars, p.ID) return p, nil } } diff --git a/pkg/plugins/manager/pipeline/bootstrap/steps_test.go b/pkg/plugins/manager/pipeline/bootstrap/steps_test.go index 53302890df08b..1fa03bac60e6a 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/steps_test.go +++ b/pkg/plugins/manager/pipeline/bootstrap/steps_test.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/fakes" - "github.com/grafana/grafana/pkg/services/featuremgmt" ) func TestSetDefaultNavURL(t *testing.T) { @@ -108,25 +107,17 @@ func Test_configureAppChildPlugin(t *testing.T) { }, Class: plugins.ClassCore, FS: fakes.NewFakePluginFiles("c:\\grafana\\public\\app\\plugins\\app\\testdata-app"), - BaseURL: "/public/app/plugins/app/testdata-app", + BaseURL: "public/app/plugins/app/testdata-app", } - configureAppChildPlugin(&config.Cfg{}, parent, child) + configureAppChildPlugin(parent, child) require.Equal(t, "core:plugin/testdata-app/datasources/datasource", child.Module) require.Equal(t, "testdata-app", child.IncludedInAppID) - require.Equal(t, "/public/app/plugins/app/testdata-app", child.BaseURL) - - t.Run("App sub URL has no effect on Core plugins", func(t *testing.T) { - configureAppChildPlugin(&config.Cfg{GrafanaAppSubURL: "/grafana"}, parent, child) - - require.Equal(t, "core:plugin/testdata-app/datasources/datasource", child.Module) - require.Equal(t, "testdata-app", child.IncludedInAppID) - require.Equal(t, "/public/app/plugins/app/testdata-app", child.BaseURL) - }) + require.Equal(t, "public/app/plugins/app/testdata-app", child.BaseURL) }) - t.Run("When setting paths based on external plugin with app sub URL", func(t *testing.T) { + t.Run("When setting paths based on external plugin", func(t *testing.T) { child := &plugins.Plugin{ FS: fakes.NewFakePluginFiles("/plugins/parent-app/child-panel"), } @@ -137,31 +128,33 @@ func Test_configureAppChildPlugin(t *testing.T) { }, Class: plugins.ClassExternal, FS: fakes.NewFakePluginFiles("/plugins/parent-app"), - BaseURL: "/grafana/plugins/parent-app", + BaseURL: "plugins/parent-app", } - configureAppChildPlugin(&config.Cfg{GrafanaAppSubURL: "/grafana"}, parent, child) + configureAppChildPlugin(parent, child) - require.Equal(t, "/grafana/public/plugins/testdata-app/child-panel/module.js", child.Module) + require.Equal(t, "public/plugins/testdata-app/child-panel/module.js", child.Module) require.Equal(t, "testdata-app", child.IncludedInAppID) - require.Equal(t, "/grafana/plugins/parent-app", child.BaseURL) + require.Equal(t, "plugins/parent-app", child.BaseURL) }) } func TestSkipEnvVarsDecorateFunc(t *testing.T) { const pluginID = "plugin-id" - t.Run("feature flag is not present", func(t *testing.T) { - f := SkipHostEnvVarsDecorateFunc(&config.Cfg{Features: featuremgmt.WithFeatures()}) + t.Run("config field is false", func(t *testing.T) { + f := SkipHostEnvVarsDecorateFunc(&config.PluginManagementCfg{ + Features: config.Features{SkipHostEnvVarsEnabled: false}, + }) p, err := f(context.Background(), &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID}}) require.NoError(t, err) require.False(t, p.SkipHostEnvVars) }) - t.Run("feature flag is present", func(t *testing.T) { + t.Run("config field is true", func(t *testing.T) { t.Run("no plugin settings should set SkipHostEnvVars to true", func(t *testing.T) { - f := SkipHostEnvVarsDecorateFunc(&config.Cfg{ - Features: featuremgmt.WithFeatures(featuremgmt.FlagPluginsSkipHostEnvVars), + f := SkipHostEnvVarsDecorateFunc(&config.PluginManagementCfg{ + Features: config.Features{SkipHostEnvVarsEnabled: true}, }) p, err := f(context.Background(), &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID}}) require.NoError(t, err) @@ -196,8 +189,10 @@ func TestSkipEnvVarsDecorateFunc(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - f := SkipHostEnvVarsDecorateFunc(&config.Cfg{ - Features: featuremgmt.WithFeatures(featuremgmt.FlagPluginsSkipHostEnvVars), + f := SkipHostEnvVarsDecorateFunc(&config.PluginManagementCfg{ + Features: config.Features{ + SkipHostEnvVarsEnabled: true, + }, ForwardHostEnvVars: tc.forwardHostEnvVars, }) p, err := f(context.Background(), &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID}}) diff --git a/pkg/plugins/manager/pipeline/discovery/discovery.go b/pkg/plugins/manager/pipeline/discovery/discovery.go index 8d7c2b4f71c9e..f05f2582e2360 100644 --- a/pkg/plugins/manager/pipeline/discovery/discovery.go +++ b/pkg/plugins/manager/pipeline/discovery/discovery.go @@ -40,7 +40,7 @@ type Opts struct { } // New returns a new Discovery stage. -func New(cfg *config.Cfg, opts Opts) *Discovery { +func New(cfg *config.PluginManagementCfg, opts Opts) *Discovery { if opts.FindFunc == nil { opts.FindFunc = DefaultFindFunc(cfg) } diff --git a/pkg/plugins/manager/pipeline/discovery/steps.go b/pkg/plugins/manager/pipeline/discovery/steps.go index 179eca4c12aac..6c5a1d0743715 100644 --- a/pkg/plugins/manager/pipeline/discovery/steps.go +++ b/pkg/plugins/manager/pipeline/discovery/steps.go @@ -6,53 +6,13 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" - "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/loader/finder" - "github.com/grafana/grafana/pkg/plugins/manager/registry" ) // DefaultFindFunc is the default function used for the Find step of the Discovery stage. It will scan the local // filesystem for plugins. -func DefaultFindFunc(cfg *config.Cfg) FindFunc { - return finder.NewLocalFinder(cfg.DevMode, cfg.Features).Find -} - -// DuplicatePluginValidation is a filter step that will filter out any plugins that are already registered with the -// registry. This includes both the primary plugin and any child plugins, which are matched using the plugin ID field. -type DuplicatePluginValidation struct { - registry registry.Service - log log.Logger -} - -// NewDuplicatePluginFilterStep returns a new DuplicatePluginValidation. -func NewDuplicatePluginFilterStep(registry registry.Service) *DuplicatePluginValidation { - return &DuplicatePluginValidation{ - registry: registry, - log: log.New("plugins.dedupe"), - } -} - -// Filter will filter out any plugins that are already registered with the registry. -func (d *DuplicatePluginValidation) Filter(ctx context.Context, bundles []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) { - res := make([]*plugins.FoundBundle, 0, len(bundles)) - for _, b := range bundles { - _, exists := d.registry.Plugin(ctx, b.Primary.JSONData.ID) - if exists { - d.log.Warn("Skipping loading of plugin as it's a duplicate", "pluginId", b.Primary.JSONData.ID) - continue - } - - for _, child := range b.Children { - _, exists = d.registry.Plugin(ctx, child.JSONData.ID) - if exists { - d.log.Warn("Skipping loading of child plugin as it's a duplicate", "pluginId", child.JSONData.ID) - continue - } - } - res = append(res, b) - } - - return res, nil +func DefaultFindFunc(cfg *config.PluginManagementCfg) FindFunc { + return finder.NewLocalFinder(cfg.DevMode).Find } // PermittedPluginTypesFilter is a filter step that will filter out any plugins that are not of a permitted type. diff --git a/pkg/plugins/manager/pipeline/initialization/initialization.go b/pkg/plugins/manager/pipeline/initialization/initialization.go index f4ef2df8574af..03b7d9375b075 100644 --- a/pkg/plugins/manager/pipeline/initialization/initialization.go +++ b/pkg/plugins/manager/pipeline/initialization/initialization.go @@ -17,7 +17,7 @@ type Initializer interface { type InitializeFunc func(ctx context.Context, p *plugins.Plugin) (*plugins.Plugin, error) type Initialize struct { - cfg *config.Cfg + cfg *config.PluginManagementCfg initializeSteps []InitializeFunc log log.Logger } @@ -27,7 +27,7 @@ type Opts struct { } // New returns a new Initialization stage. -func New(cfg *config.Cfg, opts Opts) *Initialize { +func New(cfg *config.PluginManagementCfg, opts Opts) *Initialize { if opts.InitializeFuncs == nil { opts.InitializeFuncs = []InitializeFunc{} } diff --git a/pkg/plugins/manager/pipeline/initialization/steps.go b/pkg/plugins/manager/pipeline/initialization/steps.go index db06e6f72ee27..c27cf667602d7 100644 --- a/pkg/plugins/manager/pipeline/initialization/steps.go +++ b/pkg/plugins/manager/pipeline/initialization/steps.go @@ -47,7 +47,7 @@ func (b *BackendClientInit) Initialize(ctx context.Context, p *plugins.Plugin) ( } // this will ensure that the env variables are calculated every time a plugin is started - envFunc := func() []string { return b.envVarProvider.Get(ctx, p) } + envFunc := func() []string { return b.envVarProvider.PluginEnvVars(ctx, p) } if backendClient, err := backendFactory(p.ID, p.Logger(), envFunc); err != nil { return nil, err diff --git a/pkg/plugins/manager/pipeline/initialization/steps_test.go b/pkg/plugins/manager/pipeline/initialization/steps_test.go index fa387ec6f814b..b5e43195e7122 100644 --- a/pkg/plugins/manager/pipeline/initialization/steps_test.go +++ b/pkg/plugins/manager/pipeline/initialization/steps_test.go @@ -121,12 +121,12 @@ func (f *fakeBackendProvider) BackendFactory(_ context.Context, _ *plugins.Plugi } type fakeEnvVarsProvider struct { - GetFunc func(ctx context.Context, p *plugins.Plugin) []string + PluginEnvVarsFunc func(ctx context.Context, p *plugins.Plugin) []string } -func (f *fakeEnvVarsProvider) Get(ctx context.Context, p *plugins.Plugin) []string { - if f.GetFunc != nil { - return f.GetFunc(ctx, p) +func (f *fakeEnvVarsProvider) PluginEnvVars(ctx context.Context, p *plugins.Plugin) []string { + if f.PluginEnvVarsFunc != nil { + return f.PluginEnvVars(ctx, p) } return nil } diff --git a/pkg/plugins/manager/pipeline/termination/steps.go b/pkg/plugins/manager/pipeline/termination/steps.go index e2fdcc3bed85a..0d5e2144d401e 100644 --- a/pkg/plugins/manager/pipeline/termination/steps.go +++ b/pkg/plugins/manager/pipeline/termination/steps.go @@ -54,7 +54,7 @@ func newDeregister(pluginRegistry registry.Service) *Deregister { // Deregister removes a plugin from the plugin registry. func (d *Deregister) Deregister(ctx context.Context, p *plugins.Plugin) error { - if err := d.pluginRegistry.Remove(ctx, p.ID); err != nil { + if err := d.pluginRegistry.Remove(ctx, p.ID, p.Info.Version); err != nil { return err } d.log.Debug("Plugin unregistered", "pluginId", p.ID) diff --git a/pkg/plugins/manager/pipeline/termination/termination.go b/pkg/plugins/manager/pipeline/termination/termination.go index 85c31de9100dd..66e76d8a54800 100644 --- a/pkg/plugins/manager/pipeline/termination/termination.go +++ b/pkg/plugins/manager/pipeline/termination/termination.go @@ -17,7 +17,7 @@ type Terminator interface { type TerminateFunc func(ctx context.Context, p *plugins.Plugin) error type Terminate struct { - cfg *config.Cfg + cfg *config.PluginManagementCfg terminateSteps []TerminateFunc log log.Logger } @@ -27,7 +27,7 @@ type Opts struct { } // New returns a new Termination stage. -func New(cfg *config.Cfg, opts Opts) (*Terminate, error) { +func New(cfg *config.PluginManagementCfg, opts Opts) (*Terminate, error) { if opts.TerminateFuncs == nil { opts.TerminateFuncs = []TerminateFunc{} } diff --git a/pkg/plugins/manager/pipeline/validation/steps.go b/pkg/plugins/manager/pipeline/validation/steps.go index 5efe0b4bb2938..20dee23f7800e 100644 --- a/pkg/plugins/manager/pipeline/validation/steps.go +++ b/pkg/plugins/manager/pipeline/validation/steps.go @@ -14,7 +14,7 @@ import ( ) // DefaultValidateFuncs are the default ValidateFunc used for the Validate step of the Validation stage. -func DefaultValidateFuncs(cfg *config.Cfg) []ValidateFunc { +func DefaultValidateFuncs(cfg *config.PluginManagementCfg) []ValidateFunc { return []ValidateFunc{ SignatureValidationStep(signature.NewValidator(signature.NewUnsignedAuthorizer(cfg))), ModuleJSValidationStep(), @@ -74,16 +74,16 @@ func (v *ModuleJSValidator) Validate(_ context.Context, p *plugins.Plugin) error } type AngularDetector struct { - cfg *config.Cfg + cfg *config.PluginManagementCfg angularInspector angularinspector.Inspector log log.Logger } -func AngularDetectionStep(cfg *config.Cfg, angularInspector angularinspector.Inspector) ValidateFunc { +func AngularDetectionStep(cfg *config.PluginManagementCfg, angularInspector angularinspector.Inspector) ValidateFunc { return newAngularDetector(cfg, angularInspector).Validate } -func newAngularDetector(cfg *config.Cfg, angularInspector angularinspector.Inspector) *AngularDetector { +func newAngularDetector(cfg *config.PluginManagementCfg, angularInspector angularinspector.Inspector) *AngularDetector { return &AngularDetector{ cfg: cfg, angularInspector: angularInspector, diff --git a/pkg/plugins/manager/pipeline/validation/validation.go b/pkg/plugins/manager/pipeline/validation/validation.go index 05c48f59f11a5..6c3ab23dc18a7 100644 --- a/pkg/plugins/manager/pipeline/validation/validation.go +++ b/pkg/plugins/manager/pipeline/validation/validation.go @@ -17,7 +17,7 @@ type Validator interface { type ValidateFunc func(ctx context.Context, p *plugins.Plugin) error type Validate struct { - cfg *config.Cfg + cfg *config.PluginManagementCfg validateSteps []ValidateFunc log log.Logger } @@ -27,7 +27,7 @@ type Opts struct { } // New returns a new Validation stage. -func New(cfg *config.Cfg, opts Opts) *Validate { +func New(cfg *config.PluginManagementCfg, opts Opts) *Validate { if opts.ValidateFuncs == nil { opts.ValidateFuncs = DefaultValidateFuncs(cfg) } diff --git a/pkg/plugins/manager/process/process.go b/pkg/plugins/manager/process/process.go index a663690bc4d58..2219e5f2180f2 100644 --- a/pkg/plugins/manager/process/process.go +++ b/pkg/plugins/manager/process/process.go @@ -7,22 +7,24 @@ import ( "github.com/grafana/grafana/pkg/plugins" ) -var ( - keepPluginAliveTickerDuration = time.Second * 1 -) +const defaultKeepPluginAliveTickerDuration = time.Second -type Service struct{} +type Service struct { + keepPluginAliveTickerDuration time.Duration +} func ProvideService() *Service { - return &Service{} + return &Service{ + keepPluginAliveTickerDuration: defaultKeepPluginAliveTickerDuration, + } } -func (*Service) Start(ctx context.Context, p *plugins.Plugin) error { +func (s *Service) Start(ctx context.Context, p *plugins.Plugin) error { if !p.IsManaged() || !p.Backend || p.SignatureError != nil { return nil } - if err := startPluginAndKeepItAlive(ctx, p); err != nil { + if err := s.startPluginAndKeepItAlive(ctx, p); err != nil { return err } @@ -43,7 +45,7 @@ func (*Service) Stop(ctx context.Context, p *plugins.Plugin) error { return nil } -func startPluginAndKeepItAlive(ctx context.Context, p *plugins.Plugin) error { +func (s *Service) startPluginAndKeepItAlive(ctx context.Context, p *plugins.Plugin) error { if err := p.Start(ctx); err != nil { return err } @@ -53,7 +55,7 @@ func startPluginAndKeepItAlive(ctx context.Context, p *plugins.Plugin) error { } go func(p *plugins.Plugin) { - if err := keepPluginAlive(p); err != nil { + if err := s.keepPluginAlive(p); err != nil { p.Logger().Error("Attempt to restart killed plugin process failed", "error", err) } }(p) @@ -62,8 +64,8 @@ func startPluginAndKeepItAlive(ctx context.Context, p *plugins.Plugin) error { } // keepPluginAlive will restart the plugin if the process is killed or exits -func keepPluginAlive(p *plugins.Plugin) error { - ticker := time.NewTicker(keepPluginAliveTickerDuration) +func (s *Service) keepPluginAlive(p *plugins.Plugin) error { + ticker := time.NewTicker(s.keepPluginAliveTickerDuration) for { <-ticker.C diff --git a/pkg/plugins/manager/process/process_test.go b/pkg/plugins/manager/process/process_test.go index db9641dcc25e3..4654832f65a35 100644 --- a/pkg/plugins/manager/process/process_test.go +++ b/pkg/plugins/manager/process/process_test.go @@ -4,7 +4,6 @@ import ( "context" "sync" "testing" - "time" "github.com/stretchr/testify/require" @@ -15,7 +14,11 @@ import ( ) func TestProcessManager_Start(t *testing.T) { + t.Parallel() + t.Run("Plugin state determines process start", func(t *testing.T) { + t.Parallel() + tcs := []struct { name string managed bool @@ -52,14 +55,20 @@ func TestProcessManager_Start(t *testing.T) { }, } for _, tc := range tcs { + // create a local copy of "tc" to allow concurrent access within tests to the different items of testCases, + // otherwise it would be like a moving pointer while tests run in parallel + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + bp := fakes.NewFakeBackendPlugin(tc.managed) p := createPlugin(t, bp, func(plugin *plugins.Plugin) { plugin.Backend = tc.backend plugin.SignatureError = tc.signatureError }) - m := &Service{} + m := ProvideService() err := m.Start(context.Background(), p) require.NoError(t, err) require.Equal(t, tc.expectedStartCount, bp.StartCount) @@ -74,18 +83,15 @@ func TestProcessManager_Start(t *testing.T) { }) t.Run("Won't stop the plugin if the context is cancelled", func(t *testing.T) { + t.Parallel() + bp := fakes.NewFakeBackendPlugin(true) p := createPlugin(t, bp, func(plugin *plugins.Plugin) { plugin.Backend = true }) - tickerDuration := keepPluginAliveTickerDuration - keepPluginAliveTickerDuration = 1 * time.Millisecond - defer func() { - keepPluginAliveTickerDuration = tickerDuration - }() - - m := &Service{} + m := ProvideService() + m.keepPluginAliveTickerDuration = 1 ctx := context.Background() ctx, cancel := context.WithCancel(ctx) err := m.Start(ctx, p) @@ -100,7 +106,11 @@ func TestProcessManager_Start(t *testing.T) { } func TestProcessManager_Stop(t *testing.T) { + t.Parallel() + t.Run("Can stop a running plugin", func(t *testing.T) { + t.Parallel() + pluginID := "test-datasource" bp := fakes.NewFakeBackendPlugin(true) @@ -109,7 +119,7 @@ func TestProcessManager_Stop(t *testing.T) { plugin.Backend = true }) - m := &Service{} + m := ProvideService() err := m.Stop(context.Background(), p) require.NoError(t, err) @@ -120,18 +130,21 @@ func TestProcessManager_Stop(t *testing.T) { } func TestProcessManager_ManagedBackendPluginLifecycle(t *testing.T) { - bp := fakes.NewFakeBackendPlugin(true) - p := createPlugin(t, bp, func(plugin *plugins.Plugin) { - plugin.Backend = true - }) + t.Parallel() - m := &Service{} + t.Run("When plugin process is killed, the process is restarted", func(t *testing.T) { + t.Parallel() + bp := fakes.NewFakeBackendPlugin(true) + p := createPlugin(t, bp, func(plugin *plugins.Plugin) { + plugin.Backend = true + }) - err := m.Start(context.Background(), p) - require.NoError(t, err) - require.Equal(t, 1, bp.StartCount) + m := ProvideService() + + err := m.Start(context.Background(), p) + require.NoError(t, err) + require.Equal(t, 1, bp.StartCount) - t.Run("When plugin process is killed, the process is restarted", func(t *testing.T) { var wgKill sync.WaitGroup wgKill.Add(1) go func() { diff --git a/pkg/plugins/manager/registry/ifaces.go b/pkg/plugins/manager/registry/ifaces.go index 94c7696eac859..680dcdab5bfd2 100644 --- a/pkg/plugins/manager/registry/ifaces.go +++ b/pkg/plugins/manager/registry/ifaces.go @@ -8,12 +8,12 @@ import ( // Service is responsible for the internal storing and retrieval of plugins. type Service interface { - // Plugin finds a plugin by its ID. - Plugin(ctx context.Context, id string) (*plugins.Plugin, bool) + // Plugin finds a plugin by its ID and version. + Plugin(ctx context.Context, id, version string) (*plugins.Plugin, bool) // Plugins returns all plugins. Plugins(ctx context.Context) []*plugins.Plugin // Add adds the provided plugin to the registry. Add(ctx context.Context, plugin *plugins.Plugin) error // Remove deletes the requested plugin from the registry. - Remove(ctx context.Context, id string) error + Remove(ctx context.Context, id, version string) error } diff --git a/pkg/plugins/manager/registry/in_memory.go b/pkg/plugins/manager/registry/in_memory.go index 8b533b985d88a..af3652e5d6cd2 100644 --- a/pkg/plugins/manager/registry/in_memory.go +++ b/pkg/plugins/manager/registry/in_memory.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/plugins" ) +// InMemory is a registry that only allows a single version of a plugin to be registered at a time. type InMemory struct { store map[string]*plugins.Plugin alias map[string]*plugins.Plugin @@ -25,7 +26,7 @@ func NewInMemory() *InMemory { } } -func (i *InMemory) Plugin(_ context.Context, pluginID string) (*plugins.Plugin, bool) { +func (i *InMemory) Plugin(_ context.Context, pluginID, _ string) (*plugins.Plugin, bool) { return i.plugin(pluginID) } @@ -56,7 +57,7 @@ func (i *InMemory) Add(_ context.Context, p *plugins.Plugin) error { return nil } -func (i *InMemory) Remove(_ context.Context, pluginID string) error { +func (i *InMemory) Remove(_ context.Context, pluginID, _ string) error { p, ok := i.plugin(pluginID) if !ok { return fmt.Errorf("plugin %s is not registered", pluginID) diff --git a/pkg/plugins/manager/registry/in_memory_test.go b/pkg/plugins/manager/registry/in_memory_test.go index 25d3f42342abf..31d6a21a2dfb2 100644 --- a/pkg/plugins/manager/registry/in_memory_test.go +++ b/pkg/plugins/manager/registry/in_memory_test.go @@ -11,29 +11,41 @@ import ( "github.com/grafana/grafana/pkg/plugins" ) -const pluginID = "test-ds" +const ( + pluginID = "test-ds" + v1 = "1.0.0" + v2 = "2.0.0" +) func TestInMemory(t *testing.T) { t.Run("Test mix of registry operations", func(t *testing.T) { i := NewInMemory() ctx := context.Background() - p, exists := i.Plugin(ctx, pluginID) + p, exists := i.Plugin(ctx, pluginID, v1) require.False(t, exists) require.Nil(t, p) - err := i.Remove(ctx, pluginID) + err := i.Remove(ctx, pluginID, v1) require.EqualError(t, err, fmt.Errorf("plugin %s is not registered", pluginID).Error()) - p = &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID}} - err = i.Add(ctx, p) + pv1 := &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID, Info: plugins.Info{Version: v1}}} + err = i.Add(ctx, pv1) require.NoError(t, err) - existingP, exists := i.Plugin(ctx, pluginID) + pv2 := &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID, Info: plugins.Info{Version: v2}}} + err = i.Add(ctx, pv2) + require.Errorf(t, err, fmt.Sprintf("plugin %s is already registered", pluginID)) + + existingP, exists := i.Plugin(ctx, pluginID, v1) require.True(t, exists) - require.Equal(t, p, existingP) + require.Equal(t, pv1, existingP) + + p = &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID}} + err = i.Add(ctx, p) + require.Errorf(t, err, fmt.Sprintf("plugin %s is already registered", pluginID)) - err = i.Remove(ctx, pluginID) + err = i.Remove(ctx, pluginID, v1) require.NoError(t, err) existingPlugins := i.Plugins(ctx) @@ -87,6 +99,28 @@ func TestInMemory_Add(t *testing.T) { }, err: fmt.Errorf("plugin %s is already registered", pluginID), }, + { + name: "Cannot add a plugin to the registry even if it has a different version", + mocks: mocks{ + store: map[string]*plugins.Plugin{ + pluginID: { + JSONData: plugins.JSONData{ + ID: pluginID, + Info: plugins.Info{Version: v1}, + }, + }, + }, + }, + args: args{ + p: &plugins.Plugin{ + JSONData: plugins.JSONData{ + ID: pluginID, + Info: plugins.Info{Version: v2}, + }, + }, + }, + err: fmt.Errorf("plugin %s is already registered", pluginID), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -151,7 +185,7 @@ func TestInMemory_Plugin(t *testing.T) { i := &InMemory{ store: tt.mocks.store, } - p, exists := i.Plugin(context.Background(), tt.args.pluginID) + p, exists := i.Plugin(context.Background(), tt.args.pluginID, "") if exists != tt.exists { t.Errorf("Plugin() got1 = %v, expected %v", exists, tt.exists) } @@ -265,7 +299,7 @@ func TestInMemory_Remove(t *testing.T) { i := &InMemory{ store: tt.mocks.store, } - err := i.Remove(context.Background(), tt.args.pluginID) + err := i.Remove(context.Background(), tt.args.pluginID, "") require.Equal(t, tt.err, err) }) } @@ -280,7 +314,7 @@ func TestAliasSupport(t *testing.T) { pluginIdOld := "plugin-old" pluginIdOld2 := "plugin-old2" - p, exists := i.Plugin(ctx, pluginIdNew) + p, exists := i.Plugin(ctx, pluginIdNew, "") require.False(t, exists) require.Nil(t, p) @@ -294,17 +328,17 @@ func TestAliasSupport(t *testing.T) { require.NoError(t, err) // Can lookup by the new ID - found, exists := i.Plugin(ctx, pluginIdNew) + found, exists := i.Plugin(ctx, pluginIdNew, "") require.True(t, exists) require.Equal(t, pluginNew, found) // Can lookup by the old ID - found, exists = i.Plugin(ctx, pluginIdOld) + found, exists = i.Plugin(ctx, pluginIdOld, "") require.True(t, exists) require.Equal(t, pluginNew, found) // Can lookup by the other old ID - found, exists = i.Plugin(ctx, pluginIdOld2) + found, exists = i.Plugin(ctx, pluginIdOld2, "") require.True(t, exists) require.Equal(t, pluginNew, found) @@ -313,7 +347,7 @@ func TestAliasSupport(t *testing.T) { ID: pluginIdOld, }} require.NoError(t, i.Add(ctx, pluginOld)) - found, exists = i.Plugin(ctx, pluginIdOld) + found, exists = i.Plugin(ctx, pluginIdOld, "") require.True(t, exists) require.Equal(t, pluginOld, found) }) diff --git a/pkg/plugins/manager/signature/authorizer.go b/pkg/plugins/manager/signature/authorizer.go index a96aa6f16b943..5dc0f1845f5b9 100644 --- a/pkg/plugins/manager/signature/authorizer.go +++ b/pkg/plugins/manager/signature/authorizer.go @@ -5,18 +5,18 @@ import ( "github.com/grafana/grafana/pkg/plugins/config" ) -func ProvideOSSAuthorizer(cfg *config.Cfg) *UnsignedPluginAuthorizer { +func ProvideOSSAuthorizer(cfg *config.PluginManagementCfg) *UnsignedPluginAuthorizer { return NewUnsignedAuthorizer(cfg) } -func NewUnsignedAuthorizer(cfg *config.Cfg) *UnsignedPluginAuthorizer { +func NewUnsignedAuthorizer(cfg *config.PluginManagementCfg) *UnsignedPluginAuthorizer { return &UnsignedPluginAuthorizer{ cfg: cfg, } } type UnsignedPluginAuthorizer struct { - cfg *config.Cfg + cfg *config.PluginManagementCfg } func (u *UnsignedPluginAuthorizer) CanLoadPlugin(p *plugins.Plugin) bool { diff --git a/pkg/plugins/manager/signature/manifest.go b/pkg/plugins/manager/signature/manifest.go index 5313d9c7e3343..f2c64917f3136 100644 --- a/pkg/plugins/manager/signature/manifest.go +++ b/pkg/plugins/manager/signature/manifest.go @@ -59,17 +59,17 @@ func (m *PluginManifest) isV2() bool { type Signature struct { kr plugins.KeyRetriever - cfg *config.Cfg + cfg *config.PluginManagementCfg log log.Logger } var _ plugins.SignatureCalculator = &Signature{} -func ProvideService(cfg *config.Cfg, kr plugins.KeyRetriever) *Signature { +func ProvideService(cfg *config.PluginManagementCfg, kr plugins.KeyRetriever) *Signature { return NewCalculator(cfg, kr) } -func NewCalculator(cfg *config.Cfg, kr plugins.KeyRetriever) *Signature { +func NewCalculator(cfg *config.PluginManagementCfg, kr plugins.KeyRetriever) *Signature { return &Signature{ kr: kr, cfg: cfg, @@ -77,7 +77,7 @@ func NewCalculator(cfg *config.Cfg, kr plugins.KeyRetriever) *Signature { } } -func DefaultCalculator(cfg *config.Cfg) *Signature { +func DefaultCalculator(cfg *config.PluginManagementCfg) *Signature { return &Signature{ kr: statickey.New(), cfg: cfg, diff --git a/pkg/plugins/manager/signature/manifest_test.go b/pkg/plugins/manager/signature/manifest_test.go index 6f950e54ddeff..72425785889c3 100644 --- a/pkg/plugins/manager/signature/manifest_test.go +++ b/pkg/plugins/manager/signature/manifest_test.go @@ -52,7 +52,7 @@ NR7DnB0CCQHO+4FlSPtXFTzNepoc+CytQyDAeOLMLmf2Tqhk2YShk+G/YlVX -----END PGP SIGNATURE-----` t.Run("valid manifest", func(t *testing.T) { - s := ProvideService(&config.Cfg{}, statickey.New()) + s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) manifest, err := s.readPluginManifest(context.Background(), []byte(txt)) require.NoError(t, err) @@ -69,7 +69,7 @@ NR7DnB0CCQHO+4FlSPtXFTzNepoc+CytQyDAeOLMLmf2Tqhk2YShk+G/YlVX t.Run("invalid manifest", func(t *testing.T) { modified := strings.ReplaceAll(txt, "README.md", "xxxxxxxxxx") - s := ProvideService(&config.Cfg{}, statickey.New()) + s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) _, err := s.readPluginManifest(context.Background(), []byte(modified)) require.Error(t, err) }) @@ -107,7 +107,7 @@ khdr/tZ1PDgRxMqB/u+Vtbpl0xSxgblnrDOYMSI= -----END PGP SIGNATURE-----` t.Run("valid manifest", func(t *testing.T) { - s := ProvideService(&config.Cfg{}, statickey.New()) + s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) manifest, err := s.readPluginManifest(context.Background(), []byte(txt)) require.NoError(t, err) @@ -155,7 +155,7 @@ func TestCalculate(t *testing.T) { for _, tc := range tcs { basePath := filepath.Join(parentDir, "testdata/non-pvt-with-root-url/plugin") - s := ProvideService(&config.Cfg{GrafanaAppURL: tc.appURL}, statickey.New()) + s := ProvideService(&config.PluginManagementCfg{GrafanaAppURL: tc.appURL}, statickey.New()) sig, err := s.Calculate(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal @@ -183,7 +183,7 @@ func TestCalculate(t *testing.T) { basePath := "../testdata/renderer-added-file/plugin" runningWindows = true - s := ProvideService(&config.Cfg{}, statickey.New()) + s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) sig, err := s.Calculate(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal @@ -247,7 +247,7 @@ func TestCalculate(t *testing.T) { toSlash = tc.platform.toSlashFunc() fromSlash = tc.platform.fromSlashFunc() - s := ProvideService(&config.Cfg{}, statickey.New()) + s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) pfs, err := tc.fsFactory() require.NoError(t, err) pfs, err = newPathSeparatorOverrideFS(string(tc.platform.separator), pfs) @@ -715,7 +715,7 @@ func Test_validateManifest(t *testing.T) { } for _, tc := range tcs { t.Run(tc.name, func(t *testing.T) { - s := ProvideService(&config.Cfg{}, statickey.New()) + s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) err := s.validateManifest(context.Background(), *tc.manifest, nil) require.Errorf(t, err, tc.expectedErr) }) @@ -811,7 +811,7 @@ pHo= } func Test_VerifyRevokedKey(t *testing.T) { - s := ProvideService(&config.Cfg{}, &revokedKeyProvider{}) + s := ProvideService(&config.PluginManagementCfg{}, &revokedKeyProvider{}) m := createV2Manifest(t) txt := `-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA512 diff --git a/pkg/plugins/manager/sources/source_local_disk.go b/pkg/plugins/manager/sources/source_local_disk.go index 07ab84d41c241..5f6ab1c3dd169 100644 --- a/pkg/plugins/manager/sources/source_local_disk.go +++ b/pkg/plugins/manager/sources/source_local_disk.go @@ -2,6 +2,10 @@ package sources import ( "context" + "errors" + "os" + "path/filepath" + "slices" "github.com/grafana/grafana/pkg/plugins" ) @@ -36,3 +40,31 @@ func (s *LocalSource) DefaultSignature(_ context.Context) (plugins.Signature, bo return plugins.Signature{}, false } } + +func DirAsLocalSources(pluginsPath string, class plugins.Class) ([]*LocalSource, error) { + if pluginsPath == "" { + return []*LocalSource{}, errors.New("plugins path not configured") + } + + // It's safe to ignore gosec warning G304 since the variable part of the file path comes from a configuration + // variable. + // nolint:gosec + d, err := os.ReadDir(pluginsPath) + if err != nil { + return []*LocalSource{}, errors.New("failed to open plugins path") + } + + var pluginDirs []string + for _, dir := range d { + if dir.IsDir() || dir.Type()&os.ModeSymlink == os.ModeSymlink { + pluginDirs = append(pluginDirs, filepath.Join(pluginsPath, dir.Name())) + } + } + slices.Sort(pluginDirs) + + var sources []*LocalSource + for _, dir := range pluginDirs { + sources = append(sources, NewLocalSource(class, []string{dir})) + } + return sources, nil +} diff --git a/pkg/plugins/manager/sources/source_local_disk_test.go b/pkg/plugins/manager/sources/source_local_disk_test.go new file mode 100644 index 0000000000000..e380b5d5388cf --- /dev/null +++ b/pkg/plugins/manager/sources/source_local_disk_test.go @@ -0,0 +1,71 @@ +package sources + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/plugins" +) + +func TestDirAsLocalSources(t *testing.T) { + testdataDir := "../testdata" + + tests := []struct { + name string + pluginsPath string + expected []*LocalSource + err error + }{ + { + name: "Empty path returns an error", + pluginsPath: "", + expected: []*LocalSource{}, + err: errors.New("plugins path not configured"), + }, + { + name: "Directory with subdirectories", + pluginsPath: filepath.Join(testdataDir, "pluginRootWithDist"), + expected: []*LocalSource{ + { + paths: []string{filepath.Join(testdataDir, "pluginRootWithDist", "datasource")}, + class: plugins.ClassExternal, + }, + { + paths: []string{filepath.Join(testdataDir, "pluginRootWithDist", "dist")}, + class: plugins.ClassExternal, + }, + { + paths: []string{filepath.Join(testdataDir, "pluginRootWithDist", "panel")}, + class: plugins.ClassExternal, + }, + }, + }, + { + name: "Directory with no subdirectories", + pluginsPath: filepath.Join(testdataDir, "pluginRootWithDist", "datasource"), + expected: nil, + }, + { + name: "Directory with a symlink to a directory", + pluginsPath: filepath.Join(testdataDir, "symbolic-plugin-dirs"), + expected: []*LocalSource{ + { + paths: []string{filepath.Join(testdataDir, "symbolic-plugin-dirs", "plugin")}, + class: plugins.ClassExternal, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DirAsLocalSources(tt.pluginsPath, plugins.ClassExternal) + if tt.err != nil { + require.Errorf(t, err, tt.err.Error()) + } + require.Equal(t, tt.expected, got) + }) + } +} diff --git a/pkg/plugins/manager/sources/sources.go b/pkg/plugins/manager/sources/sources.go index 4bf907cba2fa6..a3bfe1d4112f5 100644 --- a/pkg/plugins/manager/sources/sources.go +++ b/pkg/plugins/manager/sources/sources.go @@ -5,49 +5,61 @@ import ( "path/filepath" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/setting" ) type Service struct { - gCfg *setting.Cfg - cfg *config.Cfg - log log.Logger + cfg *setting.Cfg + log log.Logger } -func ProvideService(gCfg *setting.Cfg, cfg *config.Cfg) *Service { +func ProvideService(cfg *setting.Cfg) *Service { return &Service{ - gCfg: gCfg, - cfg: cfg, - log: log.New("plugin.sources"), + cfg: cfg, + log: log.New("plugin.sources"), } } func (s *Service) List(_ context.Context) []plugins.PluginSource { - return []plugins.PluginSource{ - NewLocalSource(plugins.ClassCore, corePluginPaths(s.gCfg.StaticRootPath)), - NewLocalSource(plugins.ClassBundled, []string{s.gCfg.BundledPluginsPath}), - NewLocalSource(plugins.ClassExternal, append([]string{s.cfg.PluginsPath}, pluginFSPaths(s.cfg.PluginSettings)...)), + r := []plugins.PluginSource{ + NewLocalSource(plugins.ClassCore, corePluginPaths(s.cfg.StaticRootPath)), + NewLocalSource(plugins.ClassBundled, []string{s.cfg.BundledPluginsPath}), } + r = append(r, s.externalPluginSources()...) + r = append(r, s.pluginSettingSources()...) + return r } -// corePluginPaths provides a list of the Core plugin file system paths -func corePluginPaths(staticRootPath string) []string { - datasourcePaths := filepath.Join(staticRootPath, "app/plugins/datasource") - panelsPath := filepath.Join(staticRootPath, "app/plugins/panel") - return []string{datasourcePaths, panelsPath} +func (s *Service) externalPluginSources() []plugins.PluginSource { + localSrcs, err := DirAsLocalSources(s.cfg.PluginsPath, plugins.ClassExternal) + if err != nil { + s.log.Error("Failed to load external plugins", "error", err) + return []plugins.PluginSource{} + } + + var srcs []plugins.PluginSource + for _, src := range localSrcs { + srcs = append(srcs, src) + } + return srcs } -// pluginSettingPaths provides plugin file system paths defined in cfg.PluginSettings -func pluginFSPaths(ps map[string]map[string]string) []string { - var pluginSettingDirs []string - for _, s := range ps { - path, exists := s["path"] +func (s *Service) pluginSettingSources() []plugins.PluginSource { + var sources []plugins.PluginSource + for _, ps := range s.cfg.PluginSettings { + path, exists := ps["path"] if !exists || path == "" { continue } - pluginSettingDirs = append(pluginSettingDirs, path) + sources = append(sources, NewLocalSource(plugins.ClassExternal, []string{path})) } - return pluginSettingDirs + return sources +} + +// corePluginPaths provides a list of the Core plugin file system paths +func corePluginPaths(staticRootPath string) []string { + datasourcePaths := filepath.Join(staticRootPath, "app/plugins/datasource") + panelsPath := filepath.Join(staticRootPath, "app/plugins/panel") + return []string{datasourcePaths, panelsPath} } diff --git a/pkg/plugins/manager/sources/sources_test.go b/pkg/plugins/manager/sources/sources_test.go index 9bffb3e7245d6..f4a941ddf0b29 100644 --- a/pkg/plugins/manager/sources/sources_test.go +++ b/pkg/plugins/manager/sources/sources_test.go @@ -8,20 +8,21 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/setting" ) func TestSources_List(t *testing.T) { t.Run("Plugin sources are populated by default and listed in specific order", func(t *testing.T) { + testdata, err := filepath.Abs("../testdata") + require.NoError(t, err) + cfg := &setting.Cfg{ - BundledPluginsPath: "path1", - } - pCfg := &config.Cfg{ - PluginsPath: "path2", + StaticRootPath: testdata, + PluginsPath: filepath.Join(testdata, "pluginRootWithDist"), + BundledPluginsPath: filepath.Join(testdata, "unsigned-panel"), PluginSettings: setting.PluginSettings{ "foo": map[string]string{ - "path": "path3", + "path": filepath.Join(testdata, "test-app"), }, "bar": map[string]string{ "url": "https://grafana.plugin", @@ -29,15 +30,18 @@ func TestSources_List(t *testing.T) { }, } - s := ProvideService(cfg, pCfg) + s := ProvideService(cfg) srcs := s.List(context.Background()) ctx := context.Background() - require.Len(t, srcs, 3) + require.Len(t, srcs, 6) require.Equal(t, srcs[0].PluginClass(ctx), plugins.ClassCore) - require.Equal(t, srcs[0].PluginURIs(ctx), []string{filepath.Join("app", "plugins", "datasource"), filepath.Join("app", "plugins", "panel")}) + require.Equal(t, srcs[0].PluginURIs(ctx), []string{ + filepath.Join(testdata, "app", "plugins", "datasource"), + filepath.Join(testdata, "app", "plugins", "panel"), + }) sig, exists := srcs[0].DefaultSignature(ctx) require.True(t, exists) require.Equal(t, plugins.SignatureStatusInternal, sig.Status) @@ -45,15 +49,70 @@ func TestSources_List(t *testing.T) { require.Equal(t, "", sig.SigningOrg) require.Equal(t, srcs[1].PluginClass(ctx), plugins.ClassBundled) - require.Equal(t, srcs[1].PluginURIs(ctx), []string{"path1"}) + require.Equal(t, srcs[1].PluginURIs(ctx), []string{filepath.Join(testdata, "unsigned-panel")}) sig, exists = srcs[1].DefaultSignature(ctx) require.False(t, exists) require.Equal(t, plugins.Signature{}, sig) require.Equal(t, srcs[2].PluginClass(ctx), plugins.ClassExternal) - require.Equal(t, srcs[2].PluginURIs(ctx), []string{"path2", "path3"}) + require.Equal(t, srcs[2].PluginURIs(ctx), []string{ + filepath.Join(testdata, "pluginRootWithDist", "datasource"), + }) sig, exists = srcs[2].DefaultSignature(ctx) require.False(t, exists) require.Equal(t, plugins.Signature{}, sig) + + require.Equal(t, srcs[3].PluginClass(ctx), plugins.ClassExternal) + require.Equal(t, srcs[3].PluginURIs(ctx), []string{ + filepath.Join(testdata, "pluginRootWithDist", "dist"), + }) + sig, exists = srcs[3].DefaultSignature(ctx) + require.False(t, exists) + require.Equal(t, plugins.Signature{}, sig) + + require.Equal(t, srcs[4].PluginClass(ctx), plugins.ClassExternal) + require.Equal(t, srcs[4].PluginURIs(ctx), []string{ + filepath.Join(testdata, "pluginRootWithDist", "panel"), + }) + sig, exists = srcs[4].DefaultSignature(ctx) + require.False(t, exists) + require.Equal(t, plugins.Signature{}, sig) + }) + + t.Run("Plugin sources are populated with symbolic links", func(t *testing.T) { + testdata, err := filepath.Abs("../testdata") + require.NoError(t, err) + + cfg := &setting.Cfg{ + StaticRootPath: testdata, + PluginsPath: filepath.Join(testdata, "symbolic-plugin-dirs"), + BundledPluginsPath: filepath.Join(testdata, "unsigned-panel"), + } + s := ProvideService(cfg) + ctx := context.Background() + srcs := s.List(ctx) + uris := map[plugins.Class]map[string]struct{}{} + for _, s := range srcs { + class := s.PluginClass(ctx) + if _, exists := uris[class]; !exists { + uris[class] = map[string]struct{}{} + } + for _, uri := range s.PluginURIs(ctx) { + uris[class][uri] = struct{}{} + } + } + + require.Equal(t, uris[plugins.ClassCore], map[string]struct{}{ + filepath.Join(testdata, "app", "plugins", "datasource"): {}, + filepath.Join(testdata, "app", "plugins", "panel"): {}, + }, "should include core plugins") + + require.Equal(t, uris[plugins.ClassBundled], map[string]struct{}{ + filepath.Join(testdata, "unsigned-panel"): {}, + }, "should include bundle plugin") + + require.Equal(t, uris[plugins.ClassExternal], map[string]struct{}{ + filepath.Join(testdata, "symbolic-plugin-dirs", "plugin"): {}, + }, "should include external symlinked plugin") }) } diff --git a/pkg/plugins/manager/testdata/oauth-external-registration/plugin.json b/pkg/plugins/manager/testdata/oauth-external-registration/plugin.json deleted file mode 100644 index 1f51c5ef91d7f..0000000000000 --- a/pkg/plugins/manager/testdata/oauth-external-registration/plugin.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "id": "grafana-test-datasource", - "type": "datasource", - "name": "Test", - "backend": true, - "executable": "gpx_test_datasource", - "info": { - "author": { - "name": "Grafana Labs", - "url": "https://grafana.com" - }, - "logos": { - "large": "img/ds.svg", - "small": "img/ds.svg" - }, - "screenshots": [], - "updated": "2023-08-03", - "version": "1.0.0" - }, - "iam": { - "impersonation": { - "groups" : true, - "permissions" : [ - { - "action": "read", - "scope": "datasource" - } - ] - }, - "permissions" : [ - { - "action": "read", - "scope": "datasource" - } - ] - } -} diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 0305cdf0aecfd..5553ad331a592 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -130,6 +130,7 @@ type Info struct { Screenshots []Screenshots `json:"screenshots"` Version string `json:"version"` Updated string `json:"updated"` + Keywords []string `json:"keywords"` } type InfoLink struct { diff --git a/pkg/plugins/pfs/corelist/corelist_load_gen.go b/pkg/plugins/pfs/corelist/corelist_load_gen.go index 3394d9fe03dea..dbdaf7b1e8530 100644 --- a/pkg/plugins/pfs/corelist/corelist_load_gen.go +++ b/pkg/plugins/pfs/corelist/corelist_load_gen.go @@ -51,7 +51,6 @@ func corePlugins(rt *thema.Runtime) []pfs.ParsedPlugin { parsePluginOrPanic("public/app/plugins/datasource/prometheus", "prometheus", rt), parsePluginOrPanic("public/app/plugins/datasource/tempo", "tempo", rt), parsePluginOrPanic("public/app/plugins/datasource/zipkin", "zipkin", rt), - parsePluginOrPanic("public/app/plugins/panel/alertGroups", "alertGroups", rt), parsePluginOrPanic("public/app/plugins/panel/alertlist", "alertlist", rt), parsePluginOrPanic("public/app/plugins/panel/annolist", "annolist", rt), parsePluginOrPanic("public/app/plugins/panel/barchart", "barchart", rt), diff --git a/pkg/plugins/pfs/decl.go b/pkg/plugins/pfs/decl.go index 37a3916d9045d..406275d3c85d5 100644 --- a/pkg/plugins/pfs/decl.go +++ b/pkg/plugins/pfs/decl.go @@ -4,20 +4,30 @@ import ( "cuelang.org/go/cue/ast" "github.com/grafana/kindsys" "github.com/grafana/thema" - - "github.com/grafana/grafana/pkg/plugins/plugindef" ) type PluginDecl struct { - SchemaInterface *kindsys.SchemaInterface + SchemaInterface *SchemaInterface Lineage thema.Lineage Imports []*ast.ImportSpec PluginPath string - PluginMeta plugindef.PluginDef + PluginMeta Metadata KindDecl kindsys.Def[kindsys.ComposableProperties] } -func EmptyPluginDecl(path string, meta plugindef.PluginDef) *PluginDecl { +type SchemaInterface struct { + Name string + IsGroup bool +} + +type Metadata struct { + Id string + Name string + Backend *bool + Version *string +} + +func EmptyPluginDecl(path string, meta Metadata) *PluginDecl { return &PluginDecl{ PluginPath: path, PluginMeta: meta, diff --git a/pkg/plugins/pfs/decl_parser.go b/pkg/plugins/pfs/decl_parser.go index 04833a425d54f..4e909b16ab0ed 100644 --- a/pkg/plugins/pfs/decl_parser.go +++ b/pkg/plugins/pfs/decl_parser.go @@ -6,7 +6,6 @@ import ( "path/filepath" "sort" - "github.com/grafana/kindsys" "github.com/grafana/thema" ) @@ -15,6 +14,18 @@ type declParser struct { skip map[string]bool } +// Extracted from kindsys repository +var schemaInterfaces = map[string]*SchemaInterface{ + "PanelCfg": { + Name: "PanelCfg", + IsGroup: true, + }, + "DataQuery": { + Name: "DataQuery", + IsGroup: false, + }, +} + func NewDeclParser(rt *thema.Runtime, skip map[string]bool) *declParser { return &declParser{ rt: rt, @@ -50,12 +61,11 @@ func (psr *declParser) Parse(root fs.FS) ([]*PluginDecl, error) { } for slotName, kind := range pp.ComposableKinds { - slot, err := kindsys.FindSchemaInterface(slotName) if err != nil { return nil, fmt.Errorf("parsing plugin failed for %s: %s", dir, err) } decls = append(decls, &PluginDecl{ - SchemaInterface: &slot, + SchemaInterface: schemaInterfaces[slotName], Lineage: kind.Lineage(), Imports: pp.CUEImports, PluginMeta: pp.Properties, diff --git a/pkg/plugins/pfs/errors.go b/pkg/plugins/pfs/errors.go index a65c588a0d73e..6bbd1cc093cc6 100644 --- a/pkg/plugins/pfs/errors.go +++ b/pkg/plugins/pfs/errors.go @@ -11,16 +11,6 @@ var ErrNoRootFile = errors.New("no plugin.json at root of fs.fS") // ErrInvalidRootFile indicates that the root plugin.json file is invalid. var ErrInvalidRootFile = errors.New("plugin.json is invalid") -// ErrComposableNotExpected indicates that a plugin has a composable kind for a -// schema interface that is not expected, given the type of the plugin. (For -// example, a datasource plugin has a panelcfg composable kind) -var ErrComposableNotExpected = errors.New("plugin type should not produce composable kind for schema interface") - -// ErrExpectedComposable indicates that a plugin lacks a composable kind -// implementation for a schema interface that is expected for that plugin's -// type. (For example, a datasource plugin lacks a queries composable kind) -var ErrExpectedComposable = errors.New("plugin type should produce composable kind for schema interface") - // ErrInvalidGrafanaPluginInstance indicates a plugin's set of .cue // grafanaplugin package files are invalid with respect to the GrafanaPlugin // spec. diff --git a/pkg/plugins/pfs/grafanaplugin.cue b/pkg/plugins/pfs/grafanaplugin.cue deleted file mode 100644 index 6adb68555a1a2..0000000000000 --- a/pkg/plugins/pfs/grafanaplugin.cue +++ /dev/null @@ -1,31 +0,0 @@ -package pfs - -import ( - "github.com/grafana/kindsys" -) - -// GrafanaPlugin specifies what plugins may declare in .cue files in a -// `grafanaplugin` CUE package in the plugin root directory (adjacent to plugin.json). -GrafanaPlugin: { - // id and pascalName are injected from plugin.json. Plugin authors can write - // values for them in .cue files, but the only valid values will be the ones - // given in plugin.json. - id: string - pascalName: string - - // A plugin defines its Composable kinds under this key. - // - // This struct is open for forwards compatibility - older versions of Grafana (or - // dependent tooling) should not break if new versions introduce additional schema interfaces. - composableKinds?: [Iface=string]: kindsys.Composable & { - name: pascalName + Iface - schemaInterface: Iface - lineage: name: pascalName + Iface - } - - // A plugin defines its Custom kinds under this key. - customKinds?: [Name=string]: kindsys.Custom & { - name: Name - } - ... -} diff --git a/pkg/plugins/pfs/pfs.go b/pkg/plugins/pfs/pfs.go index 73cb4a3f33c64..b56f5fdf65259 100644 --- a/pkg/plugins/pfs/pfs.go +++ b/pkg/plugins/pfs/pfs.go @@ -1,56 +1,29 @@ package pfs import ( + "encoding/json" "fmt" "io/fs" - "path/filepath" "sort" "strings" - "sync" "testing/fstest" "cuelang.org/go/cue" - "cuelang.org/go/cue/build" "cuelang.org/go/cue/cuecontext" "cuelang.org/go/cue/errors" - "cuelang.org/go/cue/parser" "cuelang.org/go/cue/token" "github.com/grafana/kindsys" "github.com/grafana/thema" "github.com/grafana/thema/load" - "github.com/grafana/thema/vmux" "github.com/yalue/merged_fs" "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/grafana/pkg/plugins/plugindef" ) // PackageName is the name of the CUE package that Grafana will load when // looking for a Grafana plugin's kind declarations. const PackageName = "grafanaplugin" -var onceGP sync.Once -var defaultGP cue.Value - -func doLoadGP(ctx *cue.Context) cue.Value { - v, err := cuectx.BuildGrafanaInstance(ctx, filepath.Join("pkg", "plugins", "pfs"), "pfs", nil) - if err != nil { - // should be unreachable - panic(err) - } - return v.LookupPath(cue.MakePath(cue.Str("GrafanaPlugin"))) -} - -func loadGP(ctx *cue.Context) cue.Value { - if ctx == nil || ctx == cuectx.GrafanaCUEContext() { - onceGP.Do(func() { - defaultGP = doLoadGP(ctx) - }) - return defaultGP - } - return doLoadGP(ctx) -} - // PermittedCUEImports returns the list of import paths that may be used in a // plugin's grafanaplugin cue package. var PermittedCUEImports = cuectx.PermittedCUEImports @@ -109,35 +82,14 @@ func ParsePluginFS(fsys fs.FS, rt *thema.Runtime) (ParsedPlugin, error) { rt = cuectx.GrafanaThemaRuntime() } - lin, err := plugindef.Lineage(rt) + metadata, err := getPluginMetadata(fsys) if err != nil { - panic(fmt.Sprintf("plugindef lineage is invalid or broken, needs dev attention: %s", err)) - } - ctx := rt.Context() - - b, err := fs.ReadFile(fsys, "plugin.json") - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return ParsedPlugin{}, ErrNoRootFile - } - return ParsedPlugin{}, fmt.Errorf("error reading plugin.json: %w", err) + return ParsedPlugin{}, err } pp := ParsedPlugin{ ComposableKinds: make(map[string]kindsys.Composable), - // CustomKinds: make(map[string]kindsys.Custom), - } - - // Pass the raw bytes into the muxer, get the populated PluginDef type out that we want. - // TODO stop ignoring second return. (for now, lacunas are a WIP and can't occur until there's >1 schema in the plugindef lineage) - pinst, _, err := vmux.NewTypedMux(lin.TypedSchema(), vmux.NewJSONCodec("plugin.json"))(b) - if err != nil { - return ParsedPlugin{}, errors.Wrap(errors.Promote(err, ""), ErrInvalidRootFile) - } - pp.Properties = *(pinst.ValueP()) - // FIXME remove this once it's being correctly populated coming out of lineage - if pp.Properties.PascalName == "" { - pp.Properties.PascalName = plugindef.DerivePascalName(pp.Properties) + Properties: metadata, } if cuefiles, err := fs.Glob(fsys, "*.cue"); err != nil { @@ -146,8 +98,6 @@ func ParsePluginFS(fsys fs.FS, rt *thema.Runtime) (ParsedPlugin, error) { return pp, nil } - gpv := loadGP(rt.Context()) - fsys, err = ensureCueMod(fsys, pp.Properties) if err != nil { return ParsedPlugin{}, fmt.Errorf("%s has invalid cue.mod: %w", pp.Properties.Id, err) @@ -161,11 +111,6 @@ func ParsePluginFS(fsys fs.FS, rt *thema.Runtime) (ParsedPlugin, error) { return ParsedPlugin{}, errors.Wrap(errors.Newf(token.NoPos, "%s did not load", pp.Properties.Id), err) } - f, _ := parser.ParseFile("plugin.json", fmt.Sprintf(`{ - "id": %q, - "pascalName": %q - }`, pp.Properties.Id, pp.Properties.PascalName)) - for _, f := range bi.Files { for _, im := range f.Imports { ip := strings.Trim(im.Path.Value, "\"") @@ -187,16 +132,7 @@ func ParsePluginFS(fsys fs.FS, rt *thema.Runtime) (ParsedPlugin, error) { panic("Refactor required - upstream CUE implementation changed, bi.Files is no longer populated") } - // Inject the JSON directly into the build so it gets loaded together - bi.BuildFiles = append(bi.BuildFiles, &build.File{ - Filename: "plugin.json", - Encoding: build.JSON, - Form: build.Data, - Source: b, - }) - bi.Files = append(bi.Files, f) - - gpi := ctx.BuildInstance(bi).Unify(gpv) + gpi := rt.Context().BuildInstance(bi) if gpi.Err() != nil { return ParsedPlugin{}, errors.Wrap(errors.Promote(ErrInvalidGrafanaPluginInstance, pp.Properties.Id), gpi.Err()) } @@ -207,6 +143,18 @@ func ParsePluginFS(fsys fs.FS, rt *thema.Runtime) (ParsedPlugin, error) { continue } + iv = iv.FillPath(cue.MakePath(cue.Str("schemaInterface")), si.Name()) + iv = iv.FillPath(cue.MakePath(cue.Str("name")), derivePascalName(pp.Properties.Id, pp.Properties.Name)+si.Name()) + lineageNamePath := iv.LookupPath(cue.MakePath(cue.Str("lineage"), cue.Str("name"))) + if !lineageNamePath.Exists() { + iv = iv.FillPath(cue.MakePath(cue.Str("lineage"), cue.Str("name")), derivePascalName(pp.Properties.Id, pp.Properties.Name)+si.Name()) + } + + validSchema := iv.LookupPath(cue.ParsePath("lineage.schemas[0].schema")) + if !validSchema.Exists() { + return ParsedPlugin{}, errors.Wrap(errors.Promote(ErrInvalidGrafanaPluginInstance, pp.Properties.Id), validSchema.Err()) + } + props, err := kindsys.ToKindProps[kindsys.ComposableProperties](iv) if err != nil { return ParsedPlugin{}, err @@ -222,7 +170,6 @@ func ParsePluginFS(fsys fs.FS, rt *thema.Runtime) (ParsedPlugin, error) { pp.ComposableKinds[si.Name()] = compo } - // TODO custom kinds return pp, nil } @@ -237,7 +184,7 @@ func ParsePluginFS(fsys fs.FS, rt *thema.Runtime) (ParsedPlugin, error) { func LoadComposableKindDef(fsys fs.FS, rt *thema.Runtime, defpath string) (kindsys.Def[kindsys.ComposableProperties], error) { pp := ParsedPlugin{ ComposableKinds: make(map[string]kindsys.Composable), - Properties: plugindef.PluginDef{ + Properties: Metadata{ Id: defpath, }, } @@ -269,13 +216,13 @@ func LoadComposableKindDef(fsys fs.FS, rt *thema.Runtime, defpath string) (kinds }, nil } -func ensureCueMod(fsys fs.FS, pdef plugindef.PluginDef) (fs.FS, error) { +func ensureCueMod(fsys fs.FS, metadata Metadata) (fs.FS, error) { if modf, err := fs.ReadFile(fsys, "cue.mod/module.cue"); err != nil { if !errors.Is(err, fs.ErrNotExist) { return nil, err } return merged_fs.NewMergedFS(fsys, fstest.MapFS{ - "cue.mod/module.cue": &fstest.MapFile{Data: []byte(fmt.Sprintf(`module: "grafana.com/grafana/plugins/%s"`, pdef.Id))}, + "cue.mod/module.cue": &fstest.MapFile{Data: []byte(fmt.Sprintf(`module: "grafana.com/grafana/plugins/%s"`, metadata.Id))}, }), nil } else if _, err := cuecontext.New().CompileBytes(modf).LookupPath(cue.MakePath(cue.Str("module"))).String(); err != nil { return nil, fmt.Errorf("error reading cue module name: %w", err) @@ -283,3 +230,61 @@ func ensureCueMod(fsys fs.FS, pdef plugindef.PluginDef) (fs.FS, error) { return fsys, nil } + +func getPluginMetadata(fsys fs.FS) (Metadata, error) { + b, err := fs.ReadFile(fsys, "plugin.json") + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return Metadata{}, ErrNoRootFile + } + return Metadata{}, fmt.Errorf("error reading plugin.json: %w", err) + } + + var metadata PluginDef + if err := json.Unmarshal(b, &metadata); err != nil { + return Metadata{}, fmt.Errorf("error unmarshalling plugin.json: %s", err) + } + + if err := metadata.Validate(); err != nil { + return Metadata{}, err + } + + return Metadata{ + Id: metadata.Id, + Name: metadata.Name, + Backend: metadata.Backend, + Version: metadata.Info.Version, + }, nil +} + +func derivePascalName(id string, name string) string { + sani := func(s string) string { + ret := strings.Title(strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z': + return r + case r >= 'A' && r <= 'Z': + return r + default: + return -1 + } + }, strings.Title(strings.Map(func(r rune) rune { + switch r { + case '-', '_': + return ' ' + default: + return r + } + }, s)))) + if len(ret) > 63 { + return ret[:63] + } + return ret + } + + fromname := sani(name) + if len(fromname) != 0 { + return fromname + } + return sani(strings.Split(id, "-")[1]) +} diff --git a/pkg/plugins/pfs/pfs_test.go b/pkg/plugins/pfs/pfs_test.go index e98fd66b83e1c..8980e593455ca 100644 --- a/pkg/plugins/pfs/pfs_test.go +++ b/pkg/plugins/pfs/pfs_test.go @@ -142,9 +142,6 @@ func TestParsePluginTestdata(t *testing.T) { "external-registration": { rootid: "grafana-test-datasource", }, - "oauth-external-registration": { - rootid: "grafana-test-datasource", - }, } staticRootPath, err := filepath.Abs(filepath.Join("..", "manager", "testdata")) diff --git a/pkg/plugins/pfs/plugin.go b/pkg/plugins/pfs/plugin.go index e5b95dae3988d..2829a81046d9f 100644 --- a/pkg/plugins/pfs/plugin.go +++ b/pkg/plugins/pfs/plugin.go @@ -3,8 +3,6 @@ package pfs import ( "cuelang.org/go/cue/ast" "github.com/grafana/kindsys" - - "github.com/grafana/grafana/pkg/plugins/plugindef" ) // ParsedPlugin represents everything knowable about a single plugin from static @@ -14,7 +12,7 @@ import ( // struct returned from [ParsePluginFS]. type ParsedPlugin struct { // Properties contains the plugin's definition, as declared in plugin.json. - Properties plugindef.PluginDef + Properties Metadata // ComposableKinds is a map of all the composable kinds declared in this plugin. // Keys are the name of the [kindsys.SchemaInterface] implemented by the value. diff --git a/pkg/plugins/pfs/plugindef_types.go b/pkg/plugins/pfs/plugindef_types.go new file mode 100644 index 0000000000000..841200428a0a2 --- /dev/null +++ b/pkg/plugins/pfs/plugindef_types.go @@ -0,0 +1,42 @@ +package pfs + +type Type string + +// Defines values for Type. +const ( + TypeApp Type = "app" + TypeDatasource Type = "datasource" + TypePanel Type = "panel" + TypeRenderer Type = "renderer" + TypeSecretsmanager Type = "secretsmanager" +) + +type PluginDef struct { + Id string + Name string + Backend *bool + Type Type + Info Info + IAM IAM +} + +type Info struct { + Version *string +} + +type IAM struct { + Permissions []Permission `json:"permissions,omitempty"` +} + +type Permission struct { + Action string `json:"action"` + Scope *string `json:"scope,omitempty"` +} + +func (pd PluginDef) Validate() error { + if pd.Id == "" || pd.Name == "" || pd.Type == "" { + return ErrInvalidRootFile + } + + return nil +} diff --git a/pkg/plugins/plugindef/gen.go b/pkg/plugins/plugindef/gen.go deleted file mode 100644 index 710a6734fa8f3..0000000000000 --- a/pkg/plugins/plugindef/gen.go +++ /dev/null @@ -1,130 +0,0 @@ -//go:build ignore -// +build ignore - -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - - "cuelang.org/go/cue/cuecontext" - "github.com/dave/dst" - "github.com/grafana/codejen" - "github.com/grafana/grafana/pkg/codegen" - "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/thema" - "github.com/grafana/thema/encoding/gocode" - "github.com/grafana/thema/encoding/jsonschema" -) - -var dirPlugindef = filepath.Join("pkg", "plugins", "plugindef") - -// main generator for plugindef. plugindef isn't a kind, so it has its own -// one-off main generator. -func main() { - v := elsedie(cuectx.BuildGrafanaInstance(nil, dirPlugindef, "", nil))("could not load plugindef cue package") - - lin := elsedie(thema.BindLineage(v, cuectx.GrafanaThemaRuntime()))("plugindef lineage is invalid") - - jl := &codejen.JennyList[thema.Lineage]{} - jl.AppendOneToOne(&jennytypego{}, &jennybindgo{}) - jl.AddPostprocessors(codegen.SlashHeaderMapper(filepath.Join(dirPlugindef, "gen.go"))) - - cwd, err := os.Getwd() - if err != nil { - fmt.Fprintf(os.Stderr, "could not get working directory: %s", err) - os.Exit(1) - } - - groot := filepath.Clean(filepath.Join(cwd, "../../..")) - - jfs := elsedie(jl.GenerateFS(lin))("plugindef jenny pipeline failed") - if _, set := os.LookupEnv("CODEGEN_VERIFY"); set { - if err := jfs.Verify(context.Background(), groot); err != nil { - die(fmt.Errorf("generated code is out of sync with inputs:\n%s\nrun `make gen-cue` to regenerate", err)) - } - } else if err := jfs.Write(context.Background(), groot); err != nil { - die(fmt.Errorf("error while writing generated code to disk:\n%s", err)) - } -} - -// one-off jenny for plugindef go types -type jennytypego struct{} - -func (j *jennytypego) JennyName() string { - return "PluginGoTypes" -} - -func (j *jennytypego) Generate(lin thema.Lineage) (*codejen.File, error) { - f, err := codegen.GoTypesJenny{}.Generate(codegen.SchemaForGen{ - Name: "PluginDef", - Schema: lin.Latest(), - IsGroup: false, - }) - if f != nil { - f.RelativePath = filepath.Join(dirPlugindef, f.RelativePath) - } - return f, err -} - -// one-off jenny for plugindef go bindings -type jennybindgo struct{} - -func (j *jennybindgo) JennyName() string { - return "PluginGoBindings" -} - -func (j *jennybindgo) Generate(lin thema.Lineage) (*codejen.File, error) { - b, err := gocode.GenerateLineageBinding(lin, &gocode.BindingConfig{ - TitleName: "PluginDef", - Assignee: dst.NewIdent("*PluginDef"), - PrivateFactory: true, - }) - if err != nil { - return nil, err - } - return codejen.NewFile(filepath.Join(dirPlugindef, "plugindef_bindings_gen.go"), b, j), nil -} - -// one-off jenny for plugindef json schema generator -type jennyjschema struct{} - -func (j *jennyjschema) JennyName() string { - return "PluginJSONSchema" -} - -func (j *jennyjschema) Generate(lin thema.Lineage) (*codejen.File, error) { - f, err := jsonschema.GenerateSchema(lin.Latest()) - if err != nil { - return nil, err - } - - b, _ := cuecontext.New().BuildFile(f).MarshalJSON() - nb := new(bytes.Buffer) - die(json.Indent(nb, b, "", " ")) - return codejen.NewFile(filepath.FromSlash("docs/sources/developers/plugins/plugin.schema.json"), nb.Bytes(), j), nil -} - -func elsedie[T any](t T, err error) func(msg string) T { - if err != nil { - return func(msg string) T { - fmt.Fprintf(os.Stderr, "%s: %s\n", msg, err) - os.Exit(1) - return t - } - } - return func(msg string) T { - return t - } -} - -func die(err error) { - if err != nil { - fmt.Fprint(os.Stderr, err, "\n") - os.Exit(1) - } -} diff --git a/pkg/plugins/plugindef/pascal_test.go b/pkg/plugins/plugindef/pascal_test.go deleted file mode 100644 index 095384c5d03f6..0000000000000 --- a/pkg/plugins/plugindef/pascal_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package plugindef - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestDerivePascal(t *testing.T) { - table := []struct { - id, name, out string - }{ - { - name: "-- Grafana --", - out: "Grafana", - }, - { - name: "A weird/Thing", - out: "AWeirdThing", - }, - { - name: "/", - out: "Empty", - }, - { - name: "some really Long thing WHY would38883 anyone do this i don't know but hey It seems like it this is just going on and", - out: "SomeReallyLongThingWHYWouldAnyoneDoThisIDonTKnowButHeyItSeemsLi", - }, - } - - for _, row := range table { - if row.id == "" { - row.id = "default-empty-panel" - } - - pd := PluginDef{ - Id: row.id, - Name: row.name, - } - - require.Equal(t, row.out, DerivePascalName(pd)) - } -} diff --git a/pkg/plugins/plugindef/plugindef.cue b/pkg/plugins/plugindef/plugindef.cue deleted file mode 100644 index b2d05e658e9f7..0000000000000 --- a/pkg/plugins/plugindef/plugindef.cue +++ /dev/null @@ -1,439 +0,0 @@ -package plugindef - -import ( - "regexp" - "strings" - - "github.com/grafana/thema" -) - -thema.#Lineage -name: "plugindef" -schemas: [{ - version: [0, 0] - schema: { - // Unique name of the plugin. If the plugin is published on - // grafana.com, then the plugin `id` has to follow the naming - // conventions. - id: string & strings.MinRunes(1) - id: =~"^([0-9a-z]+\\-([0-9a-z]+\\-)?(\(strings.Join([ for t in _types {t}], "|"))))|(alertGroups|alertlist|annolist|barchart|bargauge|candlestick|canvas|dashlist|debug|datagrid|gauge|geomap|gettingstarted|graph|heatmap|histogram|icon|live|logs|news|nodeGraph|piechart|pluginlist|stat|state-timeline|status-history|table|table-old|text|timeseries|trend|traces|welcome|xychart|alertmanager|cloudwatch|dashboard|elasticsearch|grafana|grafana-azure-monitor-datasource|graphite|influxdb|jaeger|loki|mixed|mssql|mysql|opentsdb|postgres|prometheus|stackdriver|tempo|grafana-testdata-datasource|zipkin|phlare|parca)$" - - // An alias is useful when migrating from one plugin id to another (rebranding etc) - // This should be used sparingly, and is currently only supported though a hardcoded checklist - aliasIDs?: [...string] - - // Human-readable name of the plugin that is shown to the user in - // the UI. - name: string - - // The set of all plugin types. This hidden field exists solely - // so that the set can be string-interpolated into other fields. - _types: ["app", "datasource", "panel", "renderer", "secretsmanager"] - - // type indicates which type of Grafana plugin this is, of the defined - // set of Grafana plugin types. - type: or(_types) - - // IncludeType is a string identifier of a plugin include type, which is - // a superset of plugin types. - #IncludeType: type | "dashboard" | "page" - - // Metadata about the plugin - info: #Info - - // Metadata about a Grafana plugin. Some fields are used on the plugins - // page in Grafana and others on grafana.com, if the plugin is published. - #Info: { - // Information about the plugin author - author?: { - // Author's name - name?: string - - // Author's name - email?: string - - // Link to author's website - url?: string - } - - // Build information - build?: #BuildInfo - - // Description of plugin. Used on the plugins page in Grafana and - // for search on grafana.com. - description?: string - - // Array of plugin keywords. Used for search on grafana.com. - keywords: [...string] - // should be this, but CUE to openapi converter screws this up - // by inserting a non-concrete default. - // keywords: [string, ...string] - - // An array of link objects to be displayed on this plugin's - // project page in the form `{name: 'foo', url: - // 'http://example.com'}` - links?: [...{ - name?: string - url?: string - }] - - // SVG images that are used as plugin icons - logos?: { - // Link to the "small" version of the plugin logo, which must be - // an SVG image. "Large" and "small" logos can be the same image. - small: string - - // Link to the "large" version of the plugin logo, which must be - // an SVG image. "Large" and "small" logos can be the same image. - large: string - } - - // An array of screenshot objects in the form `{name: 'bar', path: - // 'img/screenshot.png'}` - screenshots?: [...{ - name?: string - path?: string - }] - - // Date when this plugin was built - updated?: =~"^(\\d{4}-\\d{2}-\\d{2}|\\%TODAY\\%)$" - - // Project version of this commit, e.g. `6.7.x` - version?: =~"^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)|(\\%VERSION\\%)$" - } - - #BuildInfo: { - // Time when the plugin was built, as a Unix timestamp - time?: int64 - repo?: string - - // Git branch the plugin was built from - branch?: string - - // Git hash of the commit the plugin was built from - hash?: string - number?: int64 - - // GitHub pull request the plugin was built from - pr?: int32 - } - - // Dependency information related to Grafana and other plugins - dependencies: #Dependencies - - #Dependencies: { - // (Deprecated) Required Grafana version for this plugin, e.g. - // `6.x.x 7.x.x` to denote plugin requires Grafana v6.x.x or - // v7.x.x. - grafanaVersion?: =~"^([0-9]+)(\\.[0-9x]+)?(\\.[0-9x])?$" - - // Required Grafana version for this plugin. Validated using - // https://github.com/npm/node-semver. - grafanaDependency?: =~"^(<=|>=|<|>|=|~|\\^)?([0-9]+)(\\.[0-9x\\*]+)(\\.[0-9x\\*]+)?(\\s(<=|>=|<|=>)?([0-9]+)(\\.[0-9x]+)(\\.[0-9x]+))?(\\-[0-9]+)?$" - - // An array of required plugins on which this plugin depends - plugins?: [...#Dependency] - } - - // Dependency describes another plugin on which a plugin depends. - // The id refers to the plugin package identifier, as given on - // the grafana.com plugin marketplace. - #Dependency: { - id: =~"^[0-9a-z]+\\-([0-9a-z]+\\-)?(app|panel|datasource)$" - type: "app" | "datasource" | "panel" - name: string - version: string - ... - } - - // Schema definition for the plugin.json file. Used primarily for schema validation. - $schema?: string - - // For data source plugins, if the plugin supports alerting. Requires `backend` to be set to `true`. - alerting?: bool - - // For data source plugins, if the plugin supports annotation - // queries. - annotations?: bool - - // Set to true for app plugins that should be enabled and pinned to the navigation bar in all orgs. - autoEnabled?: bool - - // If the plugin has a backend component. - backend?: bool - - // [internal only] Indicates whether the plugin is developed and shipped as part - // of Grafana. Also known as a 'core plugin'. - builtIn: bool | *false - - // Plugin category used on the Add data source page. - category?: "tsdb" | "logging" | "cloud" | "tracing" | "profiling" | "sql" | "enterprise" | "iot" | "other" - - // Grafana Enterprise specific features. - enterpriseFeatures?: { - // Enable/Disable health diagnostics errors. Requires Grafana - // >=7.5.5. - healthDiagnosticsErrors?: bool | *false - ... - } - - // The first part of the file name of the backend component - // executable. There can be multiple executables built for - // different operating system and architecture. Grafana will - // check for executables named `<executable>_<$GOOS>_<lower case - // $GOARCH><.exe for Windows>`, e.g. `plugin_linux_amd64`. - // Combination of $GOOS and $GOARCH can be found here: - // https://golang.org/doc/install/source#environment. - executable?: string - - // [internal only] Excludes the plugin from listings in Grafana's UI. Only - // allowed for `builtIn` plugins. - hideFromList: bool | *false - - // Resources to include in plugin. - includes?: [...#Include] - - // A resource to be included in a plugin. - #Include: { - // Unique identifier of the included resource - uid?: string - type: #IncludeType - name?: string - - // (Legacy) The Angular component to use for a page. - component?: string - - // The minimum role a user must have to see this page in the navigation menu. - role?: "Admin" | "Editor" | "Viewer" - - // RBAC action the user must have to access the route - action?: string - - // Used for app plugins. - path?: string - - // Add the include to the navigation menu. - addToNav?: bool - - // Page or dashboard when user clicks the icon in the side menu. - defaultNav?: bool - - // Icon to use in the side menu. For information on available - // icon, refer to [Icons - // Overview](https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview). - icon?: string - ... - } - - // For data source plugins, if the plugin supports logs. It may be used to filter logs only features. - logs?: bool - - // For data source plugins, if the plugin supports metric queries. - // Used to enable the plugin in the panel editor. - metrics?: bool - - // FIXME there appears to be a bug in thema that prevents this from working. Maybe it'd - // help to refer to it with an alias, but thema can't support using current list syntax. - // syntax (fixed by grafana/thema#82). Either way, for now, pascalName gets populated in Go. - let sani = (strings.ToTitle(regexp.ReplaceAllLiteral("[^a-zA-Z]+", name, ""))) - - // [internal only] The PascalCase name for the plugin. Used for creating machine-friendly - // identifiers, typically in code generation. - // - // If not provided, defaults to name, but title-cased and sanitized (only - // alphabetical characters allowed). - pascalName: string & =~"^([A-Z][a-zA-Z]{1,62})$" | *sani - - // Initialize plugin on startup. By default, the plugin - // initializes on first use. - preload?: bool - - // For data source plugins. There is a query options section in - // the plugin's query editor and these options can be turned on - // if needed. - queryOptions?: { - // For data source plugins. If the `max data points` option should - // be shown in the query options section in the query editor. - maxDataPoints?: bool - - // For data source plugins. If the `min interval` option should be - // shown in the query options section in the query editor. - minInterval?: bool - - // For data source plugins. If the `cache timeout` option should - // be shown in the query options section in the query editor. - cacheTimeout?: bool - } - - // Routes is a list of proxy routes, if any. For datasource plugins only. - routes?: [...#Route] - - // For panel plugins. Hides the query editor. - skipDataQuery?: bool - - // Marks a plugin as a pre-release. - state?: #ReleaseState - - // ReleaseState indicates release maturity state of a plugin. - #ReleaseState: "alpha" | "beta" | "deprecated" | *"stable" - - // For data source plugins, if the plugin supports streaming. Used in Explore to start live streaming. - streaming?: bool - - // For data source plugins, if the plugin supports tracing. Used for example to link logs (e.g. Loki logs) with tracing plugins. - tracing?: bool - - // Optional list of RBAC RoleRegistrations. - // Describes and organizes the default permissions associated with any of the Grafana basic roles, - // which characterizes what viewers, editors, admins, or grafana admins can do on the plugin. - // The Admin basic role inherits its default permissions from the Editor basic role which in turn - // inherits them from the Viewer basic role. - roles?: [...#RoleRegistration] - - // RoleRegistration describes an RBAC role and its assignments to basic roles. - // It organizes related RBAC permissions on the plugin into a role and defines which basic roles - // will get them by default. - // Example: the role 'Schedules Reader' bundles permissions to view all schedules of the plugin - // which will be granted to Admins by default. - #RoleRegistration: { - // RBAC role definition to bundle related RBAC permissions on the plugin. - role: #Role - - // Default assignment of the role to Grafana basic roles (Viewer, Editor, Admin, Grafana Admin) - // The Admin basic role inherits its default permissions from the Editor basic role which in turn - // inherits them from the Viewer basic role. - grants: [...#BasicRole] - } - - // Role describes an RBAC role which allows grouping multiple related permissions on the plugin, - // each of which has an action and an optional scope. - // Example: the role 'Schedules Reader' bundles permissions to view all schedules of the plugin. - #Role: { - name: string - name: =~"^([A-Z][0-9A-Za-z ]+)$" - description: string - permissions: [...#Permission] - } - - // Permission describes an RBAC permission on the plugin. A permission has an action and an optional - // scope. - // Example: action: 'test-app.schedules:read', scope: 'test-app.schedules:*' - #Permission: { - action: string - scope?: string - } - - // BasicRole is a Grafana basic role, which can be 'Viewer', 'Editor', 'Admin' or 'Grafana Admin'. - // With RBAC, the Admin basic role inherits its default permissions from the Editor basic role which - // in turn inherits them from the Viewer basic role. - #BasicRole: "Grafana Admin" | "Admin" | "Editor" | "Viewer" - - // Header describes an HTTP header that is forwarded with a proxied request for - // a plugin route. - #Header: { - name: string - content: string - } - - // URLParam describes query string parameters for - // a url in a plugin route - #URLParam: { - name: string - content: string - } - - // A proxy route used in datasource plugins for plugin authentication - // and adding headers to HTTP requests made by the plugin. - // For more information, refer to [Authentication for data source - // plugins](https://grafana.com/developers/plugin-tools/create-a-plugin/extend-a-plugin/add-authentication-for-data-source-plugins). - #Route: { - // For data source plugins. The route path that is replaced by the - // route URL field when proxying the call. - path?: string - - // For data source plugins. Route method matches the HTTP verb - // like GET or POST. Multiple methods can be provided as a - // comma-separated list. - method?: string - - // For data source plugins. Route URL is where the request is - // proxied to. - url?: string - - urlParams?: [...#URLParam] - reqSignedIn?: bool - reqRole?: string - - // For data source plugins. Route headers adds HTTP headers to the - // proxied request. - headers?: [...#Header] - - // For data source plugins. Route headers set the body content and - // length to the proxied request. - body?: { - ... - } - - // For data source plugins. Token authentication section used with - // an OAuth API. - tokenAuth?: #TokenAuth - - // For data source plugins. Token authentication section used with - // an JWT OAuth API. - jwtTokenAuth?: #JWTTokenAuth - } - - // TODO docs - #TokenAuth: { - // URL to fetch the authentication token. - url?: string - - // The list of scopes that your application should be granted - // access to. - scopes?: [...string] - - // Parameters for the token authentication request. - params: [string]: string - } - - // TODO docs - // TODO should this really be separate from TokenAuth? - #JWTTokenAuth: { - // URL to fetch the JWT token. - url: string - - // The list of scopes that your application should be granted - // access to. - scopes: [...string] - - // Parameters for the JWT token authentication request. - params: [string]: string - } - - // Identity and Access Management information. - // Allows the plugin to define the permissions it requires to have on Grafana. - iam: #IAM - - // IAM allows the plugin to get a service account with tailored permissions and a token - // (or to use the client_credentials grant if the token provider is the OAuth2 Server) - #IAM: { - // Permissions are the permissions that the external service needs its associated service account to have. - permissions?: [...#Permission] - - // Impersonation describes the permissions that the external service will have on behalf of the user - // This is only available with the OAuth2 Server - impersonation?: #Impersonation - } - - #Impersonation: { - // Groups allows the service to list the impersonated user's teams. - // Defaults to true. - groups?: bool - // Permissions are the permissions that the external service needs when impersonating a user. - // The intersection of this set with the impersonated user's permission guarantees that the client will not - // gain more privileges than the impersonated user has. - permissions?: [...#Permission] - } - } -}] -lenses: [] diff --git a/pkg/plugins/plugindef/plugindef.go b/pkg/plugins/plugindef/plugindef.go deleted file mode 100644 index f49a73637cf55..0000000000000 --- a/pkg/plugins/plugindef/plugindef.go +++ /dev/null @@ -1,73 +0,0 @@ -package plugindef - -import ( - "strings" - "sync" - - "cuelang.org/go/cue/build" - "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/thema" -) - -//go:generate go run gen.go - -func loadInstanceForplugindef() (*build.Instance, error) { - return cuectx.LoadGrafanaInstance("pkg/plugins/plugindef", "", nil) -} - -var linonce sync.Once -var pdlin thema.ConvergentLineage[*PluginDef] -var pdlinerr error - -// Lineage returns the [thema.ConvergentLineage] for plugindef, the canonical -// specification for Grafana plugin.json files. -// -// Unless a custom thema.Runtime is specifically needed, prefer calling this with -// nil, as a cached lineage will be returned. -func Lineage(rt *thema.Runtime, opts ...thema.BindOption) (thema.ConvergentLineage[*PluginDef], error) { - if len(opts) == 0 && (rt == nil || rt == cuectx.GrafanaThemaRuntime()) { - linonce.Do(func() { - pdlin, pdlinerr = doLineage(rt) - }) - return pdlin, pdlinerr - } - return doLineage(rt, opts...) -} - -// DerivePascalName derives a PascalCase name from a PluginDef. -// -// This function does not mutate the input PluginDef; as such, it ignores -// whether there exists any value for PluginDef.PascalName. -// -// FIXME this should be removable once CUE logic for it works/unmarshals correctly. -func DerivePascalName(pd PluginDef) string { - sani := func(s string) string { - ret := strings.Title(strings.Map(func(r rune) rune { - switch { - case r >= 'a' && r <= 'z': - return r - case r >= 'A' && r <= 'Z': - return r - default: - return -1 - } - }, strings.Title(strings.Map(func(r rune) rune { - switch r { - case '-', '_': - return ' ' - default: - return r - } - }, s)))) - if len(ret) > 63 { - return ret[:63] - } - return ret - } - - fromname := sani(pd.Name) - if len(fromname) != 0 { - return fromname - } - return sani(strings.Split(pd.Id, "-")[1]) -} diff --git a/pkg/plugins/plugindef/plugindef_bindings_gen.go b/pkg/plugins/plugindef/plugindef_bindings_gen.go deleted file mode 100644 index 3438f6896a33d..0000000000000 --- a/pkg/plugins/plugindef/plugindef_bindings_gen.go +++ /dev/null @@ -1,84 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// pkg/plugins/plugindef/gen.go -// Using jennies: -// PluginGoBindings -// -// Run 'make gen-cue' from repository root to regenerate. - -package plugindef - -import ( - "cuelang.org/go/cue/build" - "github.com/grafana/thema" -) - -// doLineage returns a [thema.ConvergentLineage] for the 'plugindef' Thema lineage. -// -// The lineage is the canonical specification of plugindef. It contains all -// schema versions that have ever existed for plugindef, and the lenses that -// allow valid instances of one schema in the lineage to be translated to -// another schema in the lineage. -// -// As a [thema.ConvergentLineage], the returned lineage has one primary schema, 0.0, -// which is [thema.AssignableTo] [*PluginDef], the lineage's parameterized type. -// -// This function will return an error if the [Thema invariants] are not met by -// the underlying lineage declaration in CUE, or if [*PluginDef] is not -// [thema.AssignableTo] the 0.0 schema. -// -// [Thema's general invariants]: https://github.com/grafana/thema/blob/main/docs/invariants.md -func doLineage(rt *thema.Runtime, opts ...thema.BindOption) (thema.ConvergentLineage[*PluginDef], error) { - lin, err := baseLineage(rt, opts...) - if err != nil { - return nil, err - } - - sch := thema.SchemaP(lin, thema.SV(0, 0)) - typ := new(PluginDef) - tsch, err := thema.BindType(sch, typ) - if err != nil { - // This will error out if the 0.0 schema isn't assignable to - // *PluginDef. If Thema also generates that type, this should be unreachable, - // barring a critical bug in Thema's Go generator. - return nil, err - } - return tsch.ConvergentLineage(), nil -} -func baseLineage(rt *thema.Runtime, opts ...thema.BindOption) (thema.Lineage, error) { - // First, we must get the bytes of the .cue file(s) in which the "plugindef" lineage - // is declared, and load them into a - // "cuelang.org/go/cue/build".Instance. - // - // For most Thema-based development workflows, these bytes should come from an embed.FS. - // This ensures Go is always compiled with the current state of the .cue files. - var inst *build.Instance - var err error - - // loadInstanceForplugindef must be manually implemented in another file in this - // Go package. - inst, err = loadInstanceForplugindef() - if err != nil { - // Errors at this point indicate a problem with basic loading of .cue file bytes, - // which typically means the code generator was misconfigured and a path input - // is incorrect. - return nil, err - } - - raw := rt.Context().BuildInstance(inst) - - // An error returned from thema.BindLineage indicates one of the following: - // - The parsed path does not exist in the loaded CUE file (["github.com/grafana/thema/errors".ErrValueNotExist]) - // - The value at the parsed path exists, but does not appear to be a Thema - // lineage (["github.com/grafana/thema/errors".ErrValueNotALineage]) - // - The value at the parsed path exists and is a lineage (["github.com/grafana/thema/errors".ErrInvalidLineage]), - // but is invalid due to the violation of some general Thema invariant - - // for example, declared schemas don't follow backwards compatibility rules, - // lenses are incomplete. - return thema.BindLineage(raw, rt, opts...) -} - -// type guards -var _ thema.ConvergentLineageFactory[*PluginDef] = doLineage -var _ thema.LineageFactory = baseLineage diff --git a/pkg/plugins/plugindef/plugindef_types_gen.go b/pkg/plugins/plugindef/plugindef_types_gen.go deleted file mode 100644 index ef0c80d25f93b..0000000000000 --- a/pkg/plugins/plugindef/plugindef_types_gen.go +++ /dev/null @@ -1,495 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// pkg/plugins/plugindef/gen.go -// Using jennies: -// GoTypesJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package plugindef - -// Defines values for BasicRole. -const ( - BasicRoleAdmin BasicRole = "Admin" - BasicRoleEditor BasicRole = "Editor" - BasicRoleGrafanaAdmin BasicRole = "Grafana Admin" - BasicRoleViewer BasicRole = "Viewer" -) - -// Defines values for DependencyType. -const ( - DependencyTypeApp DependencyType = "app" - DependencyTypeDatasource DependencyType = "datasource" - DependencyTypePanel DependencyType = "panel" -) - -// Defines values for IncludeRole. -const ( - IncludeRoleAdmin IncludeRole = "Admin" - IncludeRoleEditor IncludeRole = "Editor" - IncludeRoleViewer IncludeRole = "Viewer" -) - -// Defines values for IncludeType. -const ( - IncludeTypeApp IncludeType = "app" - IncludeTypeDashboard IncludeType = "dashboard" - IncludeTypeDatasource IncludeType = "datasource" - IncludeTypePage IncludeType = "page" - IncludeTypePanel IncludeType = "panel" - IncludeTypeRenderer IncludeType = "renderer" - IncludeTypeSecretsmanager IncludeType = "secretsmanager" -) - -// Defines values for Category. -const ( - CategoryCloud Category = "cloud" - CategoryEnterprise Category = "enterprise" - CategoryIot Category = "iot" - CategoryLogging Category = "logging" - CategoryOther Category = "other" - CategoryProfiling Category = "profiling" - CategorySql Category = "sql" - CategoryTracing Category = "tracing" - CategoryTsdb Category = "tsdb" -) - -// Defines values for Type. -const ( - TypeApp Type = "app" - TypeDatasource Type = "datasource" - TypePanel Type = "panel" - TypeRenderer Type = "renderer" - TypeSecretsmanager Type = "secretsmanager" -) - -// Defines values for ReleaseState. -const ( - ReleaseStateAlpha ReleaseState = "alpha" - ReleaseStateBeta ReleaseState = "beta" - ReleaseStateDeprecated ReleaseState = "deprecated" - ReleaseStateStable ReleaseState = "stable" -) - -// BasicRole is a Grafana basic role, which can be 'Viewer', 'Editor', 'Admin' or 'Grafana Admin'. -// With RBAC, the Admin basic role inherits its default permissions from the Editor basic role which -// in turn inherits them from the Viewer basic role. -type BasicRole string - -// BuildInfo defines model for BuildInfo. -type BuildInfo struct { - // Git branch the plugin was built from - Branch *string `json:"branch,omitempty"` - - // Git hash of the commit the plugin was built from - Hash *string `json:"hash,omitempty"` - Number *int64 `json:"number,omitempty"` - - // GitHub pull request the plugin was built from - Pr *int32 `json:"pr,omitempty"` - Repo *string `json:"repo,omitempty"` - - // Time when the plugin was built, as a Unix timestamp - Time *int64 `json:"time,omitempty"` -} - -// Dependencies defines model for Dependencies. -type Dependencies struct { - // Required Grafana version for this plugin. Validated using - // https://github.com/npm/node-semver. - GrafanaDependency *string `json:"grafanaDependency,omitempty"` - - // (Deprecated) Required Grafana version for this plugin, e.g. - // `6.x.x 7.x.x` to denote plugin requires Grafana v6.x.x or - // v7.x.x. - GrafanaVersion *string `json:"grafanaVersion,omitempty"` - - // An array of required plugins on which this plugin depends - Plugins []Dependency `json:"plugins,omitempty"` -} - -// Dependency describes another plugin on which a plugin depends. -// The id refers to the plugin package identifier, as given on -// the grafana.com plugin marketplace. -type Dependency struct { - Id string `json:"id"` - Name string `json:"name"` - Type DependencyType `json:"type"` - Version string `json:"version"` -} - -// DependencyType defines model for Dependency.Type. -type DependencyType string - -// Header describes an HTTP header that is forwarded with a proxied request for -// a plugin route. -type Header struct { - Content string `json:"content"` - Name string `json:"name"` -} - -// IAM allows the plugin to get a service account with tailored permissions and a token -// (or to use the client_credentials grant if the token provider is the OAuth2 Server) -type IAM struct { - Impersonation *Impersonation `json:"impersonation,omitempty"` - - // Permissions are the permissions that the external service needs its associated service account to have. - Permissions []Permission `json:"permissions,omitempty"` -} - -// Impersonation defines model for Impersonation. -type Impersonation struct { - // Groups allows the service to list the impersonated user's teams. - // Defaults to true. - Groups *bool `json:"groups,omitempty"` - - // Permissions are the permissions that the external service needs when impersonating a user. - // The intersection of this set with the impersonated user's permission guarantees that the client will not - // gain more privileges than the impersonated user has. - Permissions []Permission `json:"permissions,omitempty"` -} - -// A resource to be included in a plugin. -type Include struct { - // RBAC action the user must have to access the route - Action *string `json:"action,omitempty"` - - // Add the include to the navigation menu. - AddToNav *bool `json:"addToNav,omitempty"` - - // (Legacy) The Angular component to use for a page. - Component *string `json:"component,omitempty"` - - // Page or dashboard when user clicks the icon in the side menu. - DefaultNav *bool `json:"defaultNav,omitempty"` - - // Icon to use in the side menu. For information on available - // icon, refer to [Icons - // Overview](https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview). - Icon *string `json:"icon,omitempty"` - Name *string `json:"name,omitempty"` - - // Used for app plugins. - Path *string `json:"path,omitempty"` - - // The minimum role a user must have to see this page in the navigation menu. - Role *IncludeRole `json:"role,omitempty"` - - // IncludeType is a string identifier of a plugin include type, which is - // a superset of plugin types. - Type IncludeType `json:"type"` - - // Unique identifier of the included resource - Uid *string `json:"uid,omitempty"` -} - -// The minimum role a user must have to see this page in the navigation menu. -type IncludeRole string - -// IncludeType is a string identifier of a plugin include type, which is -// a superset of plugin types. -type IncludeType string - -// Metadata about a Grafana plugin. Some fields are used on the plugins -// page in Grafana and others on grafana.com, if the plugin is published. -type Info struct { - // Information about the plugin author - Author *struct { - // Author's name - Email *string `json:"email,omitempty"` - - // Author's name - Name *string `json:"name,omitempty"` - - // Link to author's website - Url *string `json:"url,omitempty"` - } `json:"author,omitempty"` - Build *BuildInfo `json:"build,omitempty"` - - // Description of plugin. Used on the plugins page in Grafana and - // for search on grafana.com. - Description *string `json:"description,omitempty"` - - // Array of plugin keywords. Used for search on grafana.com. - Keywords []string `json:"keywords"` - - // An array of link objects to be displayed on this plugin's - // project page in the form `{name: 'foo', url: - // 'http://example.com'}` - Links []struct { - Name *string `json:"name,omitempty"` - Url *string `json:"url,omitempty"` - } `json:"links,omitempty"` - - // SVG images that are used as plugin icons - Logos *struct { - // Link to the "large" version of the plugin logo, which must be - // an SVG image. "Large" and "small" logos can be the same image. - Large string `json:"large"` - - // Link to the "small" version of the plugin logo, which must be - // an SVG image. "Large" and "small" logos can be the same image. - Small string `json:"small"` - } `json:"logos,omitempty"` - - // An array of screenshot objects in the form `{name: 'bar', path: - // 'img/screenshot.png'}` - Screenshots []struct { - Name *string `json:"name,omitempty"` - Path *string `json:"path,omitempty"` - } `json:"screenshots,omitempty"` - - // Date when this plugin was built - Updated *string `json:"updated,omitempty"` - - // Project version of this commit, e.g. `6.7.x` - Version *string `json:"version,omitempty"` -} - -// TODO docs -// TODO should this really be separate from TokenAuth? -type JWTTokenAuth struct { - // Parameters for the JWT token authentication request. - Params map[string]string `json:"params"` - - // The list of scopes that your application should be granted - // access to. - Scopes []string `json:"scopes"` - - // URL to fetch the JWT token. - Url string `json:"url"` -} - -// Permission describes an RBAC permission on the plugin. A permission has an action and an optional -// scope. -// Example: action: 'test-app.schedules:read', scope: 'test-app.schedules:*' -type Permission struct { - Action string `json:"action"` - Scope *string `json:"scope,omitempty"` -} - -// PluginDef defines model for PluginDef. -type PluginDef struct { - // Schema definition for the plugin.json file. Used primarily for schema validation. - Schema *string `json:"$schema,omitempty"` - - // For data source plugins, if the plugin supports alerting. Requires `backend` to be set to `true`. - Alerting *bool `json:"alerting,omitempty"` - - // An alias is useful when migrating from one plugin id to another (rebranding etc) - // This should be used sparingly, and is currently only supported though a hardcoded checklist - AliasIDs []string `json:"aliasIDs,omitempty"` - - // For data source plugins, if the plugin supports annotation - // queries. - Annotations *bool `json:"annotations,omitempty"` - - // Set to true for app plugins that should be enabled and pinned to the navigation bar in all orgs. - AutoEnabled *bool `json:"autoEnabled,omitempty"` - - // If the plugin has a backend component. - Backend *bool `json:"backend,omitempty"` - - // [internal only] Indicates whether the plugin is developed and shipped as part - // of Grafana. Also known as a 'core plugin'. - BuiltIn bool `json:"builtIn"` - - // Plugin category used on the Add data source page. - Category *Category `json:"category,omitempty"` - Dependencies Dependencies `json:"dependencies"` - - // Grafana Enterprise specific features. - EnterpriseFeatures *struct { - // Enable/Disable health diagnostics errors. Requires Grafana - // >=7.5.5. - HealthDiagnosticsErrors *bool `json:"healthDiagnosticsErrors,omitempty"` - } `json:"enterpriseFeatures,omitempty"` - - // The first part of the file name of the backend component - // executable. There can be multiple executables built for - // different operating system and architecture. Grafana will - // check for executables named `<executable>_<$GOOS>_<lower case - // $GOARCH><.exe for Windows>`, e.g. `plugin_linux_amd64`. - // Combination of $GOOS and $GOARCH can be found here: - // https://golang.org/doc/install/source#environment. - Executable *string `json:"executable,omitempty"` - - // [internal only] Excludes the plugin from listings in Grafana's UI. Only - // allowed for `builtIn` plugins. - HideFromList bool `json:"hideFromList"` - - // IAM allows the plugin to get a service account with tailored permissions and a token - // (or to use the client_credentials grant if the token provider is the OAuth2 Server) - Iam IAM `json:"iam"` - - // Unique name of the plugin. If the plugin is published on - // grafana.com, then the plugin `id` has to follow the naming - // conventions. - Id string `json:"id"` - - // Resources to include in plugin. - Includes []Include `json:"includes,omitempty"` - - // Metadata about a Grafana plugin. Some fields are used on the plugins - // page in Grafana and others on grafana.com, if the plugin is published. - Info Info `json:"info"` - - // For data source plugins, if the plugin supports logs. It may be used to filter logs only features. - Logs *bool `json:"logs,omitempty"` - - // For data source plugins, if the plugin supports metric queries. - // Used to enable the plugin in the panel editor. - Metrics *bool `json:"metrics,omitempty"` - - // Human-readable name of the plugin that is shown to the user in - // the UI. - Name string `json:"name"` - - // [internal only] The PascalCase name for the plugin. Used for creating machine-friendly - // identifiers, typically in code generation. - // - // If not provided, defaults to name, but title-cased and sanitized (only - // alphabetical characters allowed). - PascalName string `json:"pascalName"` - - // Initialize plugin on startup. By default, the plugin - // initializes on first use. - Preload *bool `json:"preload,omitempty"` - - // For data source plugins. There is a query options section in - // the plugin's query editor and these options can be turned on - // if needed. - QueryOptions *struct { - // For data source plugins. If the `cache timeout` option should - // be shown in the query options section in the query editor. - CacheTimeout *bool `json:"cacheTimeout,omitempty"` - - // For data source plugins. If the `max data points` option should - // be shown in the query options section in the query editor. - MaxDataPoints *bool `json:"maxDataPoints,omitempty"` - - // For data source plugins. If the `min interval` option should be - // shown in the query options section in the query editor. - MinInterval *bool `json:"minInterval,omitempty"` - } `json:"queryOptions,omitempty"` - - // Optional list of RBAC RoleRegistrations. - // Describes and organizes the default permissions associated with any of the Grafana basic roles, - // which characterizes what viewers, editors, admins, or grafana admins can do on the plugin. - // The Admin basic role inherits its default permissions from the Editor basic role which in turn - // inherits them from the Viewer basic role. - Roles []RoleRegistration `json:"roles,omitempty"` - - // Routes is a list of proxy routes, if any. For datasource plugins only. - Routes []Route `json:"routes,omitempty"` - - // For panel plugins. Hides the query editor. - SkipDataQuery *bool `json:"skipDataQuery,omitempty"` - - // ReleaseState indicates release maturity state of a plugin. - State *ReleaseState `json:"state,omitempty"` - - // For data source plugins, if the plugin supports streaming. Used in Explore to start live streaming. - Streaming *bool `json:"streaming,omitempty"` - - // For data source plugins, if the plugin supports tracing. Used for example to link logs (e.g. Loki logs) with tracing plugins. - Tracing *bool `json:"tracing,omitempty"` - - // type indicates which type of Grafana plugin this is, of the defined - // set of Grafana plugin types. - Type Type `json:"type"` -} - -// Plugin category used on the Add data source page. -type Category string - -// Type type indicates which type of Grafana plugin this is, of the defined -// set of Grafana plugin types. -type Type string - -// ReleaseState indicates release maturity state of a plugin. -type ReleaseState string - -// Role describes an RBAC role which allows grouping multiple related permissions on the plugin, -// each of which has an action and an optional scope. -// Example: the role 'Schedules Reader' bundles permissions to view all schedules of the plugin. -type Role struct { - Description string `json:"description"` - Name string `json:"name"` - Permissions []Permission `json:"permissions"` -} - -// RoleRegistration describes an RBAC role and its assignments to basic roles. -// It organizes related RBAC permissions on the plugin into a role and defines which basic roles -// will get them by default. -// Example: the role 'Schedules Reader' bundles permissions to view all schedules of the plugin -// which will be granted to Admins by default. -type RoleRegistration struct { - // Default assignment of the role to Grafana basic roles (Viewer, Editor, Admin, Grafana Admin) - // The Admin basic role inherits its default permissions from the Editor basic role which in turn - // inherits them from the Viewer basic role. - Grants []BasicRole `json:"grants"` - - // Role describes an RBAC role which allows grouping multiple related permissions on the plugin, - // each of which has an action and an optional scope. - // Example: the role 'Schedules Reader' bundles permissions to view all schedules of the plugin. - Role Role `json:"role"` -} - -// A proxy route used in datasource plugins for plugin authentication -// and adding headers to HTTP requests made by the plugin. -// For more information, refer to [Authentication for data source -// plugins](https://grafana.com/developers/plugin-tools/create-a-plugin/extend-a-plugin/add-authentication-for-data-source-plugins). -type Route struct { - // For data source plugins. Route headers set the body content and - // length to the proxied request. - Body map[string]any `json:"body,omitempty"` - - // For data source plugins. Route headers adds HTTP headers to the - // proxied request. - Headers []Header `json:"headers,omitempty"` - - // TODO docs - // TODO should this really be separate from TokenAuth? - JwtTokenAuth *JWTTokenAuth `json:"jwtTokenAuth,omitempty"` - - // For data source plugins. Route method matches the HTTP verb - // like GET or POST. Multiple methods can be provided as a - // comma-separated list. - Method *string `json:"method,omitempty"` - - // For data source plugins. The route path that is replaced by the - // route URL field when proxying the call. - Path *string `json:"path,omitempty"` - ReqRole *string `json:"reqRole,omitempty"` - ReqSignedIn *bool `json:"reqSignedIn,omitempty"` - - // TODO docs - TokenAuth *TokenAuth `json:"tokenAuth,omitempty"` - - // For data source plugins. Route URL is where the request is - // proxied to. - Url *string `json:"url,omitempty"` - UrlParams []URLParam `json:"urlParams,omitempty"` -} - -// TODO docs -type TokenAuth struct { - // Parameters for the token authentication request. - Params map[string]string `json:"params"` - - // The list of scopes that your application should be granted - // access to. - Scopes []string `json:"scopes,omitempty"` - - // URL to fetch the authentication token. - Url *string `json:"url,omitempty"` -} - -// URLParam describes query string parameters for -// a url in a plugin route -type URLParam struct { - Content string `json:"content"` - Name string `json:"name"` -} diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index acb46eec62b18..15e43ad07d9e2 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -19,7 +19,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2" "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin" "github.com/grafana/grafana/pkg/plugins/log" - "github.com/grafana/grafana/pkg/plugins/plugindef" + "github.com/grafana/grafana/pkg/plugins/pfs" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/util" ) @@ -118,7 +118,7 @@ type JSONData struct { Executable string `json:"executable,omitempty"` // App Service Auth Registration - IAM *plugindef.IAM `json:"iam,omitempty"` + IAM *pfs.IAM `json:"iam,omitempty"` } func ReadPluginJSON(reader io.Reader) (JSONData, error) { @@ -194,6 +194,7 @@ type Route struct { Path string `json:"path"` Method string `json:"method"` ReqRole org.RoleType `json:"reqRole"` + ReqAction string `json:"reqAction"` URL string `json:"url"` URLParams []URLParam `json:"urlParams"` Headers []Header `json:"headers"` @@ -203,6 +204,10 @@ type Route struct { Body json.RawMessage `json:"body"` } +func (r *Route) RequiresRBACAction() bool { + return r.ReqAction != "" +} + // Header describes an HTTP header that is forwarded with // the proxied request for a plugin route type Header struct { diff --git a/pkg/plugins/plugins_test.go b/pkg/plugins/plugins_test.go index 96eef9f0b593d..5554c86d1846f 100644 --- a/pkg/plugins/plugins_test.go +++ b/pkg/plugins/plugins_test.go @@ -49,7 +49,8 @@ func Test_ReadPluginJSON(t *testing.T) { {Path: "img/screenshot1.png", Name: "img1"}, {Path: "img/screenshot2.png", Name: "img2"}, }, - Updated: "2015-02-10", + Updated: "2015-02-10", + Keywords: []string{"test"}, }, Dependencies: Dependencies{ GrafanaVersion: "3.x.x", @@ -107,7 +108,7 @@ func Test_ReadPluginJSON(t *testing.T) { pluginJSON: func(t *testing.T) io.ReadCloser { pJSON := `{ "id": "grafana-pyroscope-datasource", - "type": "datasource", + "type": "datasource", "aliasIDs": ["phlare"] }` return io.NopCloser(strings.NewReader(pJSON)) diff --git a/pkg/plugins/pluginscdn/pluginscdn.go b/pkg/plugins/pluginscdn/pluginscdn.go index 5f93f4825987b..44445d449fde4 100644 --- a/pkg/plugins/pluginscdn/pluginscdn.go +++ b/pkg/plugins/pluginscdn/pluginscdn.go @@ -16,10 +16,10 @@ var ErrPluginNotCDN = errors.New("plugin is not a cdn plugin") // Service provides methods for the plugins CDN. type Service struct { - cfg *config.Cfg + cfg *config.PluginManagementCfg } -func ProvideService(cfg *config.Cfg) *Service { +func ProvideService(cfg *config.PluginManagementCfg) *Service { return &Service{cfg: cfg} } diff --git a/pkg/plugins/pluginscdn/pluginscdn_test.go b/pkg/plugins/pluginscdn/pluginscdn_test.go index 08cae194aae69..f18285b0d68de 100644 --- a/pkg/plugins/pluginscdn/pluginscdn_test.go +++ b/pkg/plugins/pluginscdn/pluginscdn_test.go @@ -8,7 +8,7 @@ import ( ) func TestService(t *testing.T) { - svc := ProvideService(&config.Cfg{ + svc := ProvideService(&config.PluginManagementCfg{ PluginsCDNURLTemplate: "https://cdn.example.com", PluginSettings: map[string]map[string]string{ "one": {"cdn": "true"}, @@ -40,7 +40,7 @@ func TestService(t *testing.T) { }, } { t.Run(c.name, func(t *testing.T) { - u, err := ProvideService(&config.Cfg{PluginsCDNURLTemplate: c.cfgURL}).BaseURL() + u, err := ProvideService(&config.PluginManagementCfg{PluginsCDNURLTemplate: c.cfgURL}).BaseURL() require.NoError(t, err) require.Equal(t, c.expBaseURL, u) }) diff --git a/pkg/plugins/repo/service.go b/pkg/plugins/repo/service.go index 8b1a7a1654d27..b55d2d582f909 100644 --- a/pkg/plugins/repo/service.go +++ b/pkg/plugins/repo/service.go @@ -21,7 +21,7 @@ type Manager struct { log log.PrettyLogger } -func ProvideService(cfg *config.Cfg) (*Manager, error) { +func ProvideService(cfg *config.PluginManagementCfg) (*Manager, error) { baseURL, err := url.JoinPath(cfg.GrafanaComURL, "/api/plugins") if err != nil { return nil, err diff --git a/pkg/promlib/README.md b/pkg/promlib/README.md new file mode 100644 index 0000000000000..02b92080aa63d --- /dev/null +++ b/pkg/promlib/README.md @@ -0,0 +1,13 @@ +# promlib + +Prometheus Library (a.k.a. promlib) is the foundation of the Grafana Prometheus data source backend. + +### How to tag/version? + +- Checkout the commit you want to tag (`git checkout <COMMIT_SHA>`) +- Run `git tag <VERSION>` (For example v0.0.12) + - NOTE: We're using Lightweight Tags, so no other options are required +- Run `git push origin <VERSION>` +- Verify that the tag was created successfully [here](https://github.com/grafana/grafana/tags) +- DO NOT RELEASE anything! Tagging is enough. +- After tagging and waiting 5-10 minutes for go module registry to catch up just bump the `promlib` version on grafana/grafana diff --git a/pkg/tsdb/prometheus/client/client.go b/pkg/promlib/client/client.go similarity index 98% rename from pkg/tsdb/prometheus/client/client.go rename to pkg/promlib/client/client.go index 47a5969d37391..b23196b6f5ba0 100644 --- a/pkg/tsdb/prometheus/client/client.go +++ b/pkg/promlib/client/client.go @@ -13,7 +13,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/tsdb/prometheus/models" + "github.com/grafana/grafana/pkg/promlib/models" ) type doer interface { diff --git a/pkg/tsdb/prometheus/client/client_test.go b/pkg/promlib/client/client_test.go similarity index 94% rename from pkg/tsdb/prometheus/client/client_test.go rename to pkg/promlib/client/client_test.go index 920baf81dfb8a..16dc76fb27063 100644 --- a/pkg/tsdb/prometheus/client/client_test.go +++ b/pkg/promlib/client/client_test.go @@ -2,6 +2,7 @@ package client import ( "context" + "fmt" "io" "net/http" "testing" @@ -10,8 +11,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" - "github.com/grafana/grafana/pkg/tsdb/prometheus/models" + "github.com/grafana/grafana/pkg/promlib/models" ) type MockDoer struct { @@ -44,7 +44,7 @@ func TestClient(t *testing.T) { defer func() { if res != nil && res.Body != nil { if err := res.Body.Close(); err != nil { - logger.Warn("Error", "err", err) + fmt.Println("Error", "err", err) } } }() @@ -68,7 +68,7 @@ func TestClient(t *testing.T) { defer func() { if res != nil && res.Body != nil { if err := res.Body.Close(); err != nil { - logger.Warn("Error", "err", err) + fmt.Println("Error", "err", err) } } }() @@ -98,7 +98,7 @@ func TestClient(t *testing.T) { defer func() { if res != nil && res.Body != nil { if err := res.Body.Close(); err != nil { - logger.Warn("Error", "err", err) + fmt.Println("Error", "err", err) } } }() @@ -125,7 +125,7 @@ func TestClient(t *testing.T) { defer func() { if res != nil && res.Body != nil { if err := res.Body.Close(); err != nil { - logger.Warn("Error", "err", err) + fmt.Println("Error", "err", err) } } }() diff --git a/pkg/tsdb/prometheus/client/transport.go b/pkg/promlib/client/transport.go similarity index 59% rename from pkg/tsdb/prometheus/client/transport.go rename to pkg/promlib/client/transport.go index 1776e5ef370fd..429befa15270a 100644 --- a/pkg/tsdb/prometheus/client/transport.go +++ b/pkg/promlib/client/transport.go @@ -7,18 +7,16 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana-plugin-sdk-go/data/utils/maputil" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/prometheus/azureauth" - "github.com/grafana/grafana/pkg/tsdb/prometheus/middleware" - "github.com/grafana/grafana/pkg/tsdb/prometheus/utils" - "github.com/grafana/grafana/pkg/util/maputil" + + "github.com/grafana/grafana/pkg/promlib/middleware" + "github.com/grafana/grafana/pkg/promlib/utils" ) -// CreateTransportOptions creates options for the http client. Probably should be shared and should not live in the -// buffered package. -func CreateTransportOptions(ctx context.Context, settings backend.DataSourceInstanceSettings, cfg *setting.Cfg, logger log.Logger) (*sdkhttpclient.Options, error) { +// CreateTransportOptions creates options for the http client. +func CreateTransportOptions(ctx context.Context, settings backend.DataSourceInstanceSettings, logger log.Logger) (*sdkhttpclient.Options, error) { opts, err := settings.HTTPClientOptions(ctx) if err != nil { return nil, fmt.Errorf("error getting HTTP options: %w", err) @@ -32,19 +30,6 @@ func CreateTransportOptions(ctx context.Context, settings backend.DataSourceInst opts.Middlewares = middlewares(logger, httpMethod) - // Set SigV4 service namespace - if opts.SigV4 != nil { - opts.SigV4.Service = "aps" - } - - // Set Azure authentication - if cfg.AzureAuthEnabled { - err = azureauth.ConfigureAzureAuthentication(settings, cfg.Azure, &opts) - if err != nil { - return nil, fmt.Errorf("error configuring Azure auth: %v", err) - } - } - return &opts, nil } diff --git a/pkg/promlib/client/transport_test.go b/pkg/promlib/client/transport_test.go new file mode 100644 index 0000000000000..a0d23aa2f2f3c --- /dev/null +++ b/pkg/promlib/client/transport_test.go @@ -0,0 +1,27 @@ +package client + +import ( + "context" + "net/http" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/stretchr/testify/require" +) + +func TestCreateTransportOptions(t *testing.T) { + t.Run("creates correct options object", func(t *testing.T) { + settings := backend.DataSourceInstanceSettings{ + BasicAuthEnabled: false, + BasicAuthUser: "", + JSONData: []byte(`{"httpHeaderName1": "foo"}`), + DecryptedSecureJSONData: map[string]string{ + "httpHeaderValue1": "bar", + }, + } + opts, err := CreateTransportOptions(context.Background(), settings, backend.NewLoggerWith("logger", "test")) + require.NoError(t, err) + require.Equal(t, http.Header{"Foo": []string{"bar"}}, opts.Header) + require.Equal(t, 2, len(opts.Middlewares)) + }) +} diff --git a/pkg/util/converter/prom.go b/pkg/promlib/converter/prom.go similarity index 91% rename from pkg/util/converter/prom.go rename to pkg/promlib/converter/prom.go index c74d726559292..7b5cd6b95f61f 100644 --- a/pkg/util/converter/prom.go +++ b/pkg/promlib/converter/prom.go @@ -8,15 +8,15 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + sdkjsoniter "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter" jsoniter "github.com/json-iterator/go" - "golang.org/x/exp/slices" - "github.com/grafana/grafana/pkg/util/converter/jsonitere" + "golang.org/x/exp/slices" ) // helpful while debugging all the options that may appear func logf(format string, a ...any) { - //fmt.Printf(format, a...) + // fmt.Printf(format, a...) } type Options struct { @@ -29,7 +29,7 @@ func rspErr(e error) backend.DataResponse { // ReadPrometheusStyleResult will read results from a prometheus or loki server and return data frames func ReadPrometheusStyleResult(jIter *jsoniter.Iterator, opt Options) backend.DataResponse { - iter := jsonitere.NewIterator(jIter) + iter := sdkjsoniter.NewIterator(jIter) var rsp backend.DataResponse status := "unknown" errorType := "" @@ -102,14 +102,14 @@ l1Fields: return rsp } -func readWarnings(iter *jsonitere.Iterator) ([]data.Notice, error) { +func readWarnings(iter *sdkjsoniter.Iterator) ([]data.Notice, error) { warnings := []data.Notice{} next, err := iter.WhatIsNext() if err != nil { return nil, err } - if next != jsoniter.ArrayValue { + if next != sdkjsoniter.ArrayValue { return warnings, nil } @@ -121,7 +121,7 @@ func readWarnings(iter *jsonitere.Iterator) ([]data.Notice, error) { if err != nil { return nil, err } - if next == jsoniter.StringValue { + if next == sdkjsoniter.StringValue { s, err := iter.ReadString() if err != nil { return nil, err @@ -137,18 +137,18 @@ func readWarnings(iter *jsonitere.Iterator) ([]data.Notice, error) { return warnings, nil } -func readPrometheusData(iter *jsonitere.Iterator, opt Options) backend.DataResponse { +func readPrometheusData(iter *sdkjsoniter.Iterator, opt Options) backend.DataResponse { var rsp backend.DataResponse t, err := iter.WhatIsNext() if err != nil { return rspErr(err) } - if t == jsoniter.ArrayValue { + if t == sdkjsoniter.ArrayValue { return readArrayData(iter) } - if t != jsoniter.ObjectValue { + if t != sdkjsoniter.ObjectValue { return backend.DataResponse{ Error: fmt.Errorf("expected object type"), } @@ -190,7 +190,7 @@ l1Fields: // if we have saved resultBytes we will parse them here // we saved them because when we had them we don't know the resultType if len(resultBytes) > 0 { - ji := jsonitere.NewIterator(jsoniter.ParseBytes(jsoniter.ConfigDefault, resultBytes)) + ji := sdkjsoniter.NewIterator(jsoniter.ParseBytes(sdkjsoniter.ConfigDefault, resultBytes)) rsp = readResult(resultType, rsp, ji, opt, encodingFlags) } case "result": @@ -200,7 +200,7 @@ l1Fields: if resultTypeFound { rsp = readResult(resultType, rsp, iter, opt, encodingFlags) } else { - resultBytes = iter.SkipAndReturnBytes() + resultBytes, _ = iter.SkipAndReturnBytes() } case "stats": @@ -241,7 +241,7 @@ l1Fields: } // will read the result object based on the resultType and return a DataResponse -func readResult(resultType string, rsp backend.DataResponse, iter *jsonitere.Iterator, opt Options, encodingFlags []string) backend.DataResponse { +func readResult(resultType string, rsp backend.DataResponse, iter *sdkjsoniter.Iterator, opt Options, encodingFlags []string) backend.DataResponse { switch resultType { case "matrix", "vector": rsp = readMatrixOrVectorMulti(iter, resultType, opt) @@ -263,7 +263,7 @@ func readResult(resultType string, rsp backend.DataResponse, iter *jsonitere.Ite return rsp } case "scalar": - rsp = readScalar(iter) + rsp = readScalar(iter, opt.Dataplane) if rsp.Error != nil { return rsp } @@ -279,7 +279,7 @@ func readResult(resultType string, rsp backend.DataResponse, iter *jsonitere.Ite } // will return strings or exemplars -func readArrayData(iter *jsonitere.Iterator) backend.DataResponse { +func readArrayData(iter *sdkjsoniter.Iterator) backend.DataResponse { lookup := make(map[string]*data.Field) var labelFrame *data.Frame @@ -298,7 +298,7 @@ func readArrayData(iter *jsonitere.Iterator) backend.DataResponse { } switch next { - case jsoniter.StringValue: + case sdkjsoniter.StringValue: s, err := iter.ReadString() if err != nil { return rspErr(err) @@ -306,7 +306,7 @@ func readArrayData(iter *jsonitere.Iterator) backend.DataResponse { stringField.Append(s) // Either label or exemplars - case jsoniter.ObjectValue: + case sdkjsoniter.ObjectValue: exemplar, labelPairs, err := readLabelsOrExemplars(iter) if err != nil { rspErr(err) @@ -365,7 +365,7 @@ func readArrayData(iter *jsonitere.Iterator) backend.DataResponse { } // For consistent ordering read values to an array not a map -func readLabelsAsPairs(iter *jsonitere.Iterator) ([][2]string, error) { +func readLabelsAsPairs(iter *sdkjsoniter.Iterator) ([][2]string, error) { pairs := make([][2]string, 0, 10) for k, err := iter.ReadObject(); k != ""; k, err = iter.ReadObject() { if err != nil { @@ -380,7 +380,7 @@ func readLabelsAsPairs(iter *jsonitere.Iterator) ([][2]string, error) { return pairs, nil } -func readLabelsOrExemplars(iter *jsonitere.Iterator) (*data.Frame, [][2]string, error) { +func readLabelsOrExemplars(iter *sdkjsoniter.Iterator) (*data.Frame, [][2]string, error) { pairs := make([][2]string, 0, 10) labels := data.Labels{} var frame *data.Frame @@ -496,7 +496,7 @@ l1Fields: return frame, pairs, nil } -func readString(iter *jsonitere.Iterator) backend.DataResponse { +func readString(iter *sdkjsoniter.Iterator) backend.DataResponse { timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 0) timeField.Name = data.TimeSeriesTimeFieldName valueField := data.NewFieldFromFieldType(data.FieldTypeString, 0) @@ -541,7 +541,7 @@ func readString(iter *jsonitere.Iterator) backend.DataResponse { } } -func readScalar(iter *jsonitere.Iterator) backend.DataResponse { +func readScalar(iter *sdkjsoniter.Iterator, dataPlane bool) backend.DataResponse { rsp := backend.DataResponse{} timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 0) @@ -564,12 +564,16 @@ func readScalar(iter *jsonitere.Iterator) backend.DataResponse { Custom: resultTypeToCustomMeta("scalar"), } + if dataPlane { + frame.Meta.TypeVersion = data.FrameTypeVersion{0, 1} + } + return backend.DataResponse{ Frames: []*data.Frame{frame}, } } -func readMatrixOrVectorMulti(iter *jsonitere.Iterator, resultType string, opt Options) backend.DataResponse { +func readMatrixOrVectorMulti(iter *sdkjsoniter.Iterator, resultType string, opt Options) backend.DataResponse { rsp := backend.DataResponse{} for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { @@ -675,7 +679,7 @@ func readMatrixOrVectorMulti(iter *jsonitere.Iterator, resultType string, opt Op return rsp } -func readTimeValuePair(iter *jsonitere.Iterator) (time.Time, float64, error) { +func readTimeValuePair(iter *sdkjsoniter.Iterator) (time.Time, float64, error) { if _, err := iter.ReadArray(); err != nil { return time.Time{}, 0, err } @@ -704,7 +708,7 @@ func readTimeValuePair(iter *jsonitere.Iterator) (time.Time, float64, error) { } type histogramInfo struct { - //XMax (time) YMin Ymax Count YLayout + // XMax (time) YMin Ymax Count YLayout time *data.Field yMin *data.Field // will have labels? yMax *data.Field @@ -730,7 +734,7 @@ func newHistogramInfo() *histogramInfo { // This will read a single sparse histogram // [ time, { count, sum, buckets: [...] }] -func readHistogram(iter *jsonitere.Iterator, hist *histogramInfo) error { +func readHistogram(iter *sdkjsoniter.Iterator, hist *histogramInfo) error { // first element if _, err := iter.ReadArray(); err != nil { return err @@ -830,7 +834,7 @@ func readHistogram(iter *jsonitere.Iterator, hist *histogramInfo) error { return nil } -func appendValueFromString(iter *jsonitere.Iterator, field *data.Field) error { +func appendValueFromString(iter *sdkjsoniter.Iterator, field *data.Field) error { var err error var s string if s, err = iter.ReadString(); err != nil { @@ -846,7 +850,7 @@ func appendValueFromString(iter *jsonitere.Iterator, field *data.Field) error { return nil } -func readStream(iter *jsonitere.Iterator) backend.DataResponse { +func readStream(iter *sdkjsoniter.Iterator) backend.DataResponse { rsp := backend.DataResponse{} labelsField := data.NewFieldFromFieldType(data.FieldTypeJSON, 0) @@ -946,7 +950,7 @@ func readStream(iter *jsonitere.Iterator) backend.DataResponse { return rsp } -func readCategorizedStream(iter *jsonitere.Iterator) backend.DataResponse { +func readCategorizedStream(iter *sdkjsoniter.Iterator) backend.DataResponse { rsp := backend.DataResponse{} labelsField := data.NewFieldFromFieldType(data.FieldTypeJSON, 0) @@ -965,7 +969,7 @@ func readCategorizedStream(iter *jsonitere.Iterator) backend.DataResponse { tsField := data.NewFieldFromFieldType(data.FieldTypeString, 0) tsField.Name = "TS" - labels := data.Labels{} + indexedLabels := data.Labels{} for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { if err != nil { @@ -981,8 +985,8 @@ func readCategorizedStream(iter *jsonitere.Iterator) backend.DataResponse { case "stream": // we need to clear `labels`, because `iter.ReadVal` // only appends to it - labels = data.Labels{} - if err = iter.ReadVal(&labels); err != nil { + indexedLabels = data.Labels{} + if err = iter.ReadVal(&indexedLabels); err != nil { return rspErr(err) } @@ -1030,23 +1034,25 @@ func readCategorizedStream(iter *jsonitere.Iterator) backend.DataResponse { } typeMap := data.Labels{} + clonedLabels := data.Labels{} - for k := range labels { + for k := range indexedLabels { typeMap[k] = "I" + clonedLabels[k] = indexedLabels[k] } // merge all labels (indexed, parsed, structuredMetadata) into one dataframe field for k, v := range structuredMetadataMap { - labels[k] = fmt.Sprintf("%s", v) + clonedLabels[k] = fmt.Sprintf("%s", v) typeMap[k] = "S" } for k, v := range parsedLabelsMap { - labels[k] = fmt.Sprintf("%s", v) + clonedLabels[k] = fmt.Sprintf("%s", v) typeMap[k] = "P" } - labelJson, err := labelsToRawJson(labels) + labelJson, err := labelsToRawJson(clonedLabels) if err != nil { return rspErr(err) } @@ -1078,7 +1084,7 @@ func readCategorizedStream(iter *jsonitere.Iterator) backend.DataResponse { return rsp } -func readCategorizedStreamField(iter *jsonitere.Iterator) (map[string]interface{}, map[string]interface{}, error) { +func readCategorizedStreamField(iter *sdkjsoniter.Iterator) (map[string]interface{}, map[string]interface{}, error) { parsedLabels := data.Labels{} structuredMetadata := data.Labels{} var parsedLabelsMap map[string]interface{} diff --git a/pkg/util/converter/prom_test.go b/pkg/promlib/converter/prom_test.go similarity index 71% rename from pkg/util/converter/prom_test.go rename to pkg/promlib/converter/prom_test.go index 18908c9a0cd2d..76dd73a2be35b 100644 --- a/pkg/util/converter/prom_test.go +++ b/pkg/promlib/converter/prom_test.go @@ -1,15 +1,14 @@ package converter import ( - "fmt" "os" "path" "strings" "testing" "time" + sdkjsoniter "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter" "github.com/grafana/grafana-plugin-sdk-go/experimental" - "github.com/grafana/grafana/pkg/infra/httpclient" jsoniter "github.com/json-iterator/go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -44,29 +43,6 @@ func TestReadPromFrames(t *testing.T) { } } -func TestReadLimited(t *testing.T) { - for _, name := range files { - p := path.Join("testdata", name+".json") - stat, err := os.Stat(p) - require.NoError(t, err) - size := stat.Size() - - for i := int64(10); i < size-1; i += size / 10 { - t.Run(fmt.Sprintf("%v_%v", name, i), func(t *testing.T) { - //nolint:gosec - f, err := os.Open(p) - require.NoError(t, err) - mbr := httpclient.MaxBytesReader(f, i) - - iter := jsoniter.Parse(jsoniter.ConfigDefault, mbr, 1024) - rsp := ReadPrometheusStyleResult(iter, Options{}) - - require.ErrorContains(t, rsp.Error, "response body too large") - }) - } - } -} - func runScenario(name string, opts Options) func(t *testing.T) { return func(t *testing.T) { // Safe to disable, this is a test. @@ -74,7 +50,7 @@ func runScenario(name string, opts Options) func(t *testing.T) { f, err := os.Open(path.Join("testdata", name+".json")) require.NoError(t, err) - iter := jsoniter.Parse(jsoniter.ConfigDefault, f, 1024) + iter := jsoniter.Parse(sdkjsoniter.ConfigDefault, f, 1024) rsp := ReadPrometheusStyleResult(iter, opts) if strings.Contains(name, "error") { diff --git a/pkg/util/converter/testdata/loki-streams-a-frame.jsonc b/pkg/promlib/converter/testdata/loki-streams-a-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/loki-streams-a-frame.jsonc rename to pkg/promlib/converter/testdata/loki-streams-a-frame.jsonc diff --git a/pkg/util/converter/testdata/loki-streams-a.json b/pkg/promlib/converter/testdata/loki-streams-a.json similarity index 100% rename from pkg/util/converter/testdata/loki-streams-a.json rename to pkg/promlib/converter/testdata/loki-streams-a.json diff --git a/pkg/util/converter/testdata/loki-streams-b-frame.jsonc b/pkg/promlib/converter/testdata/loki-streams-b-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/loki-streams-b-frame.jsonc rename to pkg/promlib/converter/testdata/loki-streams-b-frame.jsonc diff --git a/pkg/util/converter/testdata/loki-streams-b.json b/pkg/promlib/converter/testdata/loki-streams-b.json similarity index 100% rename from pkg/util/converter/testdata/loki-streams-b.json rename to pkg/promlib/converter/testdata/loki-streams-b.json diff --git a/pkg/util/converter/testdata/loki-streams-c-frame.jsonc b/pkg/promlib/converter/testdata/loki-streams-c-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/loki-streams-c-frame.jsonc rename to pkg/promlib/converter/testdata/loki-streams-c-frame.jsonc diff --git a/pkg/util/converter/testdata/loki-streams-c.json b/pkg/promlib/converter/testdata/loki-streams-c.json similarity index 100% rename from pkg/util/converter/testdata/loki-streams-c.json rename to pkg/promlib/converter/testdata/loki-streams-c.json diff --git a/pkg/util/converter/testdata/loki-streams-structured-metadata-frame.jsonc b/pkg/promlib/converter/testdata/loki-streams-structured-metadata-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/loki-streams-structured-metadata-frame.jsonc rename to pkg/promlib/converter/testdata/loki-streams-structured-metadata-frame.jsonc diff --git a/pkg/util/converter/testdata/loki-streams-structured-metadata.json b/pkg/promlib/converter/testdata/loki-streams-structured-metadata.json similarity index 100% rename from pkg/util/converter/testdata/loki-streams-structured-metadata.json rename to pkg/promlib/converter/testdata/loki-streams-structured-metadata.json diff --git a/pkg/util/converter/testdata/prom-error-frame.jsonc b/pkg/promlib/converter/testdata/prom-error-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/prom-error-frame.jsonc rename to pkg/promlib/converter/testdata/prom-error-frame.jsonc diff --git a/pkg/util/converter/testdata/prom-error.json b/pkg/promlib/converter/testdata/prom-error.json similarity index 100% rename from pkg/util/converter/testdata/prom-error.json rename to pkg/promlib/converter/testdata/prom-error.json diff --git a/pkg/util/converter/testdata/prom-exemplars-a-frame.json b/pkg/promlib/converter/testdata/prom-exemplars-a-frame.json similarity index 100% rename from pkg/util/converter/testdata/prom-exemplars-a-frame.json rename to pkg/promlib/converter/testdata/prom-exemplars-a-frame.json diff --git a/pkg/util/converter/testdata/prom-exemplars-a-frame.jsonc b/pkg/promlib/converter/testdata/prom-exemplars-a-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/prom-exemplars-a-frame.jsonc rename to pkg/promlib/converter/testdata/prom-exemplars-a-frame.jsonc diff --git a/pkg/util/converter/testdata/prom-exemplars-a-golden.txt b/pkg/promlib/converter/testdata/prom-exemplars-a-golden.txt similarity index 100% rename from pkg/util/converter/testdata/prom-exemplars-a-golden.txt rename to pkg/promlib/converter/testdata/prom-exemplars-a-golden.txt diff --git a/pkg/util/converter/testdata/prom-exemplars-a.json b/pkg/promlib/converter/testdata/prom-exemplars-a.json similarity index 100% rename from pkg/util/converter/testdata/prom-exemplars-a.json rename to pkg/promlib/converter/testdata/prom-exemplars-a.json diff --git a/pkg/util/converter/testdata/prom-exemplars-b-frame.json b/pkg/promlib/converter/testdata/prom-exemplars-b-frame.json similarity index 100% rename from pkg/util/converter/testdata/prom-exemplars-b-frame.json rename to pkg/promlib/converter/testdata/prom-exemplars-b-frame.json diff --git a/pkg/util/converter/testdata/prom-exemplars-b-frame.jsonc b/pkg/promlib/converter/testdata/prom-exemplars-b-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/prom-exemplars-b-frame.jsonc rename to pkg/promlib/converter/testdata/prom-exemplars-b-frame.jsonc diff --git a/pkg/util/converter/testdata/prom-exemplars-b-golden.txt b/pkg/promlib/converter/testdata/prom-exemplars-b-golden.txt similarity index 100% rename from pkg/util/converter/testdata/prom-exemplars-b-golden.txt rename to pkg/promlib/converter/testdata/prom-exemplars-b-golden.txt diff --git a/pkg/util/converter/testdata/prom-exemplars-b.json b/pkg/promlib/converter/testdata/prom-exemplars-b.json similarity index 100% rename from pkg/util/converter/testdata/prom-exemplars-b.json rename to pkg/promlib/converter/testdata/prom-exemplars-b.json diff --git a/pkg/util/converter/testdata/prom-exemplars-diff-labels-frame.jsonc b/pkg/promlib/converter/testdata/prom-exemplars-diff-labels-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/prom-exemplars-diff-labels-frame.jsonc rename to pkg/promlib/converter/testdata/prom-exemplars-diff-labels-frame.jsonc diff --git a/pkg/util/converter/testdata/prom-exemplars-diff-labels.json b/pkg/promlib/converter/testdata/prom-exemplars-diff-labels.json similarity index 100% rename from pkg/util/converter/testdata/prom-exemplars-diff-labels.json rename to pkg/promlib/converter/testdata/prom-exemplars-diff-labels.json diff --git a/pkg/util/converter/testdata/prom-exemplars-frame.jsonc b/pkg/promlib/converter/testdata/prom-exemplars-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/prom-exemplars-frame.jsonc rename to pkg/promlib/converter/testdata/prom-exemplars-frame.jsonc diff --git a/pkg/util/converter/testdata/prom-exemplars.json b/pkg/promlib/converter/testdata/prom-exemplars.json similarity index 100% rename from pkg/util/converter/testdata/prom-exemplars.json rename to pkg/promlib/converter/testdata/prom-exemplars.json diff --git a/pkg/util/converter/testdata/prom-labels-frame.jsonc b/pkg/promlib/converter/testdata/prom-labels-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/prom-labels-frame.jsonc rename to pkg/promlib/converter/testdata/prom-labels-frame.jsonc diff --git a/pkg/util/converter/testdata/prom-labels.json b/pkg/promlib/converter/testdata/prom-labels.json similarity index 100% rename from pkg/util/converter/testdata/prom-labels.json rename to pkg/promlib/converter/testdata/prom-labels.json diff --git a/pkg/util/converter/testdata/prom-matrix-frame.jsonc b/pkg/promlib/converter/testdata/prom-matrix-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/prom-matrix-frame.jsonc rename to pkg/promlib/converter/testdata/prom-matrix-frame.jsonc diff --git a/pkg/util/converter/testdata/prom-matrix-histogram-no-labels-frame.jsonc b/pkg/promlib/converter/testdata/prom-matrix-histogram-no-labels-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/prom-matrix-histogram-no-labels-frame.jsonc rename to pkg/promlib/converter/testdata/prom-matrix-histogram-no-labels-frame.jsonc diff --git a/pkg/util/converter/testdata/prom-matrix-histogram-no-labels.json b/pkg/promlib/converter/testdata/prom-matrix-histogram-no-labels.json similarity index 100% rename from pkg/util/converter/testdata/prom-matrix-histogram-no-labels.json rename to pkg/promlib/converter/testdata/prom-matrix-histogram-no-labels.json diff --git a/pkg/util/converter/testdata/prom-matrix-histogram-partitioned-frame.jsonc b/pkg/promlib/converter/testdata/prom-matrix-histogram-partitioned-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/prom-matrix-histogram-partitioned-frame.jsonc rename to pkg/promlib/converter/testdata/prom-matrix-histogram-partitioned-frame.jsonc diff --git a/pkg/util/converter/testdata/prom-matrix-histogram-partitioned.json b/pkg/promlib/converter/testdata/prom-matrix-histogram-partitioned.json similarity index 100% rename from pkg/util/converter/testdata/prom-matrix-histogram-partitioned.json rename to pkg/promlib/converter/testdata/prom-matrix-histogram-partitioned.json diff --git a/pkg/util/converter/testdata/prom-matrix-with-nans-frame.jsonc b/pkg/promlib/converter/testdata/prom-matrix-with-nans-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/prom-matrix-with-nans-frame.jsonc rename to pkg/promlib/converter/testdata/prom-matrix-with-nans-frame.jsonc diff --git a/pkg/util/converter/testdata/prom-matrix-with-nans.json b/pkg/promlib/converter/testdata/prom-matrix-with-nans.json similarity index 100% rename from pkg/util/converter/testdata/prom-matrix-with-nans.json rename to pkg/promlib/converter/testdata/prom-matrix-with-nans.json diff --git a/pkg/util/converter/testdata/prom-matrix.json b/pkg/promlib/converter/testdata/prom-matrix.json similarity index 100% rename from pkg/util/converter/testdata/prom-matrix.json rename to pkg/promlib/converter/testdata/prom-matrix.json diff --git a/pkg/util/converter/testdata/prom-scalar-frame.jsonc b/pkg/promlib/converter/testdata/prom-scalar-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/prom-scalar-frame.jsonc rename to pkg/promlib/converter/testdata/prom-scalar-frame.jsonc diff --git a/pkg/util/converter/testdata/prom-scalar.json b/pkg/promlib/converter/testdata/prom-scalar.json similarity index 100% rename from pkg/util/converter/testdata/prom-scalar.json rename to pkg/promlib/converter/testdata/prom-scalar.json diff --git a/pkg/util/converter/testdata/prom-series-frame.jsonc b/pkg/promlib/converter/testdata/prom-series-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/prom-series-frame.jsonc rename to pkg/promlib/converter/testdata/prom-series-frame.jsonc diff --git a/pkg/util/converter/testdata/prom-series.json b/pkg/promlib/converter/testdata/prom-series.json similarity index 100% rename from pkg/util/converter/testdata/prom-series.json rename to pkg/promlib/converter/testdata/prom-series.json diff --git a/pkg/util/converter/testdata/prom-string-frame.jsonc b/pkg/promlib/converter/testdata/prom-string-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/prom-string-frame.jsonc rename to pkg/promlib/converter/testdata/prom-string-frame.jsonc diff --git a/pkg/util/converter/testdata/prom-string.json b/pkg/promlib/converter/testdata/prom-string.json similarity index 100% rename from pkg/util/converter/testdata/prom-string.json rename to pkg/promlib/converter/testdata/prom-string.json diff --git a/pkg/util/converter/testdata/prom-vector-frame.jsonc b/pkg/promlib/converter/testdata/prom-vector-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/prom-vector-frame.jsonc rename to pkg/promlib/converter/testdata/prom-vector-frame.jsonc diff --git a/pkg/util/converter/testdata/prom-vector-histogram-no-labels-frame.jsonc b/pkg/promlib/converter/testdata/prom-vector-histogram-no-labels-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/prom-vector-histogram-no-labels-frame.jsonc rename to pkg/promlib/converter/testdata/prom-vector-histogram-no-labels-frame.jsonc diff --git a/pkg/util/converter/testdata/prom-vector-histogram-no-labels.json b/pkg/promlib/converter/testdata/prom-vector-histogram-no-labels.json similarity index 100% rename from pkg/util/converter/testdata/prom-vector-histogram-no-labels.json rename to pkg/promlib/converter/testdata/prom-vector-histogram-no-labels.json diff --git a/pkg/util/converter/testdata/prom-vector.json b/pkg/promlib/converter/testdata/prom-vector.json similarity index 100% rename from pkg/util/converter/testdata/prom-vector.json rename to pkg/promlib/converter/testdata/prom-vector.json diff --git a/pkg/util/converter/testdata/prom-warnings-frame.jsonc b/pkg/promlib/converter/testdata/prom-warnings-frame.jsonc similarity index 100% rename from pkg/util/converter/testdata/prom-warnings-frame.jsonc rename to pkg/promlib/converter/testdata/prom-warnings-frame.jsonc diff --git a/pkg/util/converter/testdata/prom-warnings.json b/pkg/promlib/converter/testdata/prom-warnings.json similarity index 100% rename from pkg/util/converter/testdata/prom-warnings.json rename to pkg/promlib/converter/testdata/prom-warnings.json diff --git a/pkg/promlib/go.mod b/pkg/promlib/go.mod new file mode 100644 index 0000000000000..49c4ea67244bd --- /dev/null +++ b/pkg/promlib/go.mod @@ -0,0 +1,109 @@ +module github.com/grafana/grafana/pkg/promlib + +go 1.21.0 + +require ( + github.com/grafana/grafana-plugin-sdk-go v0.215.0 + github.com/json-iterator/go v1.1.12 + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/prometheus/client_golang v1.18.0 + github.com/prometheus/common v0.46.0 + github.com/prometheus/prometheus v1.8.2-0.20221021121301-51a44e6657c3 + github.com/stretchr/testify v1.8.4 + go.opentelemetry.io/otel v1.24.0 + go.opentelemetry.io/otel/trace v1.24.0 + golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb +) + +require ( + github.com/BurntSushi/toml v1.3.2 // indirect + github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 // indirect + github.com/apache/arrow/go/v15 v15.0.0 // indirect + github.com/aws/aws-sdk-go v1.50.8 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cheekybits/genny v1.0.0 // indirect + github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dennwc/varint v1.0.0 // indirect + github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 // indirect + github.com/fatih/color v1.15.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/getkin/kin-openapi v0.120.0 // indirect + github.com/go-kit/log v0.2.1 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.20.2 // indirect + github.com/go-openapi/swag v0.22.9 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/flatbuffers v23.5.26+incompatible // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/mux v1.8.0 // indirect + github.com/grafana/regexp v0.0.0-20221123153739-15dc172cd2db // indirect + github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect + github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect + github.com/hashicorp/go-hclog v1.6.2 // indirect + github.com/hashicorp/go-plugin v1.6.0 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/invopop/yaml v0.2.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.17.4 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect + github.com/magefile/mage v1.15.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattetti/filebuffer v1.0.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pierrec/lz4/v4 v4.1.18 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/rivo/uniseg v0.3.4 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/smartystreets/goconvey v1.6.4 // indirect + github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect + github.com/unknwon/com v1.0.1 // indirect + github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 // indirect + github.com/urfave/cli v1.22.14 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.49.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.22.0 // indirect + go.opentelemetry.io/contrib/samplers/jaegerremote v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/sdk v1.24.0 // indirect + go.opentelemetry.io/proto/otlp v1.1.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/goleak v1.3.0 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/tools v0.17.0 // indirect + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect + google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 // indirect + google.golang.org/grpc v1.62.1 // indirect + google.golang.org/protobuf v1.32.0 // indirect + gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/pkg/promlib/go.sum b/pkg/promlib/go.sum new file mode 100644 index 0000000000000..9d05b2cf1b486 --- /dev/null +++ b/pkg/promlib/go.sum @@ -0,0 +1,129 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 h1:ez/4by2iGztzR4L0zgAOR8lTQK9VlyBVVd7G4omaOQs= +github.com/apache/arrow/go/v15 v15.0.0 h1:1zZACWf85oEZY5/kd9dsQS7i+2G5zVQcbKTHgslqHNA= +github.com/aws/aws-sdk-go v1.50.8 h1:gY0WoOW+/Wz6XmYSgDH9ge3wnAevYDSQWPxxJvqAkP4= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= +github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89 h1:aPflPkRFkVwbW6dmcVqfgwp1i+UWGFH6VgR1Jim5Ygc= +github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/dennwc/varint v1.0.0 h1:kGNFFSSw8ToIy3obO/kKr8U9GZYUAxQEVuix4zfDWzE= +github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 h1:1L0aalTpPz7YlMxETKpmQoWMBkeiuorElZIXoNmgiPE= +github.com/elazarl/goproxy/ext v0.0.0-20220115173737-adb46da277ac h1:9yrT5tmn9Zc0ytWPASlaPwQfQMQYnRf0RSDe1XvHw0Q= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/getkin/kin-openapi v0.120.0 h1:MqJcNJFrMDFNc07iwE8iFC5eT2k/NPUFDIpNeiZv8Jg= +github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= +github.com/go-openapi/swag v0.22.9 h1:XX2DssF+mQKM2DHsbgZK74y/zj4mo9I99+89xUmuZCE= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/grafana/grafana-plugin-sdk-go v0.215.0 h1:02gwVsqYi1I+U48/MQR61eOMxiXE7KNKC8QsiMJ//qA= +github.com/grafana/regexp v0.0.0-20221123153739-15dc172cd2db h1:7aN5cccjIqCLTzedH7MZzRZt5/lsAHch6Z3L2ZGn5FA= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No= +github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I= +github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mattetti/filebuffer v1.0.1 h1:gG7pyfnSIZCxdoKq+cPa8T0hhYtD9NxCdI4D7PTjRLM= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/common v0.46.0 h1:doXzt5ybi1HBKpsZOL0sSkaNHJJqkyfEWZGGqqScV0Y= +github.com/prometheus/common/sigv4 v0.1.0 h1:qoVebwtwwEhS85Czm2dSROY5fTo2PAPEVdDeppTwGX4= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/prometheus v1.8.2-0.20221021121301-51a44e6657c3 h1:etRZv4bJf9YAuyPWbyFufjkijfeoPSmyA5xNcd4DoyI= +github.com/prometheus/prometheus v1.8.2-0.20221021121301-51a44e6657c3/go.mod h1:plwr4+63Q1xL8oIdBDeU854um7Cct0Av8dhP44lutMw= +github.com/rivo/uniseg v0.3.4 h1:3Z3Eu6FGHZWSfNKJTOUiPatWwfc7DzJRU04jFUqJODw= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= +github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 h1:aVGB3YnaS/JNfOW3tiHIlmNmTDg618va+eT0mVomgyI= +github.com/unknwon/com v1.0.1 h1:3d1LTxD+Lnf3soQiD4Cp/0BRB+Rsa/+RTvz8GMMzIXs= +github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 h1:4EYQaWAatQokdji3zqZloVIW/Ke1RQjYw2zHULyrHJg= +github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.49.0 h1:RtcvQ4iw3w9NBB5yRwgA4sSa82rfId7n4atVpvKx3bY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/propagators/jaeger v1.22.0 h1:bAHX+zN/inu+Rbqk51REmC8oXLl+Dw6pp9ldQf/onaY= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.18.0 h1:Q9PrD94WoMolBx44ef5UWWvufpVSME0MiSymXZfedso= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 h1:Mw5xcxMwlqoJd97vwPxA8isEaIoxsta9/Q51+TTJLGE= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= +gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 h1:KAeGQVN3M9nD0/bQXnr/ClcEMJ968gUXJQ9pwfSynuQ= +google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80 h1:Lj5rbfG876hIAYFjqiJnPHfhXbv+nzTWfm04Fg/XSVU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 h1:AjyfHzEPEFp/NpvfN5g+KDla3EMojjhRVZc1i7cj+oM= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/fsnotify/fsnotify.v1 v1.4.7 h1:XNNYLJHt73EyYiCZi6+xjupS9CpvmiDgjPTAjrBlQbo= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/pkg/tsdb/prometheus/healthcheck.go b/pkg/promlib/healthcheck.go similarity index 82% rename from pkg/tsdb/prometheus/healthcheck.go rename to pkg/promlib/healthcheck.go index 873b8ac06fd43..c384939868440 100644 --- a/pkg/tsdb/prometheus/healthcheck.go +++ b/pkg/promlib/healthcheck.go @@ -1,4 +1,4 @@ -package prometheus +package promlib import ( "context" @@ -7,23 +7,17 @@ import ( "fmt" "time" - "github.com/grafana/kindsys" - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/tsdb/prometheus/kinds/dataquery" - "github.com/grafana/grafana/pkg/tsdb/prometheus/models" + + "github.com/grafana/grafana/pkg/promlib/models" ) const ( refID = "__healthcheck__" ) -var logger log.Logger = backend.NewLoggerWith("logger", "tsdb.prometheus") - func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { - logger := logger.FromContext(ctx) ds, err := s.getInstance(ctx, req.PluginContext) // check that the datasource exists @@ -35,13 +29,15 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque return getHealthCheckMessage("", errors.New("invalid datasource info received")) } + logger := s.logger.FromContext(ctx) + hc, err := healthcheck(ctx, req, ds) if err != nil { logger.Warn("Error performing prometheus healthcheck", "err", err.Error()) return nil, err } - heuristics, err := getHeuristics(ctx, ds) + heuristics, err := getHeuristics(ctx, ds, logger) if err != nil { logger.Warn("Failed to get prometheus heuristics", "err", err.Error()) } else { @@ -58,12 +54,13 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque func healthcheck(ctx context.Context, req *backend.CheckHealthRequest, i *instance) (*backend.CheckHealthResult, error) { qm := models.QueryModel{ - LegendFormat: "", UtcOffsetSec: 0, - PrometheusDataQuery: dataquery.PrometheusDataQuery{ + CommonQueryProperties: models.CommonQueryProperties{ + RefId: refID, + }, + PrometheusQueryProperties: models.PrometheusQueryProperties{ Expr: "1+1", - Instant: kindsys.Ptr(true), - RefId: refID, + Instant: true, }, } b, _ := json.Marshal(&qm) diff --git a/pkg/tsdb/prometheus/healthcheck_test.go b/pkg/promlib/healthcheck_test.go similarity index 73% rename from pkg/tsdb/prometheus/healthcheck_test.go rename to pkg/promlib/healthcheck_test.go index eaf38c317e79d..c60fc074726d5 100644 --- a/pkg/tsdb/prometheus/healthcheck_test.go +++ b/pkg/promlib/healthcheck_test.go @@ -1,4 +1,4 @@ -package prometheus +package promlib import ( "context" @@ -10,19 +10,18 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" - "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/stretchr/testify/assert" ) type healthCheckProvider[T http.RoundTripper] struct { - httpclient.Provider + sdkhttpclient.Provider RoundTripper *T } type healthCheckSuccessRoundTripper struct { } + type healthCheckFailRoundTripper struct { } @@ -57,34 +56,36 @@ func (rt *healthCheckFailRoundTripper) RoundTrip(req *http.Request) (*http.Respo }, nil } -func (provider *healthCheckProvider[T]) New(opts ...httpclient.Options) (*http.Client, error) { +func (provider *healthCheckProvider[T]) New(opts ...sdkhttpclient.Options) (*http.Client, error) { client := &http.Client{} provider.RoundTripper = new(T) client.Transport = *provider.RoundTripper return client, nil } -func (provider *healthCheckProvider[T]) GetTransport(opts ...httpclient.Options) (http.RoundTripper, error) { +func (provider *healthCheckProvider[T]) GetTransport(opts ...sdkhttpclient.Options) (http.RoundTripper, error) { return *new(T), nil } -func getMockProvider[T http.RoundTripper]() *httpclient.Provider { +func getMockProvider[T http.RoundTripper]() *sdkhttpclient.Provider { p := &healthCheckProvider[T]{ RoundTripper: new(T), } - anotherFN := func(o httpclient.Options, next http.RoundTripper) http.RoundTripper { + anotherFN := func(o sdkhttpclient.Options, next http.RoundTripper) http.RoundTripper { return *p.RoundTripper } - fn := httpclient.MiddlewareFunc(anotherFN) - mid := httpclient.NamedMiddlewareFunc("mock", fn) - return httpclient.NewProvider(httpclient.ProviderOptions{Middlewares: []httpclient.Middleware{mid}}) + fn := sdkhttpclient.MiddlewareFunc(anotherFN) + mid := sdkhttpclient.NamedMiddlewareFunc("mock", fn) + return sdkhttpclient.NewProvider(sdkhttpclient.ProviderOptions{Middlewares: []sdkhttpclient.Middleware{mid}}) } func Test_healthcheck(t *testing.T) { t.Run("should do a successful health check", func(t *testing.T) { httpProvider := getMockProvider[*healthCheckSuccessRoundTripper]() + logger := backend.NewLoggerWith("logger", "test") s := &Service{ - im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, &setting.Cfg{}, &featuremgmt.FeatureManager{}, backend.NewLoggerWith("logger", "test"))), + im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, logger, mockExtendClientOpts)), + logger: logger, } req := &backend.CheckHealthRequest{ @@ -99,8 +100,10 @@ func Test_healthcheck(t *testing.T) { t.Run("should return an error for an unsuccessful health check", func(t *testing.T) { httpProvider := getMockProvider[*healthCheckFailRoundTripper]() + logger := backend.NewLoggerWith("logger", "test") s := &Service{ - im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, &setting.Cfg{}, &featuremgmt.FeatureManager{}, backend.NewLoggerWith("logger", "test"))), + im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, logger, mockExtendClientOpts)), + logger: logger, } req := &backend.CheckHealthRequest{ diff --git a/pkg/tsdb/prometheus/heuristics.go b/pkg/promlib/heuristics.go similarity index 91% rename from pkg/tsdb/prometheus/heuristics.go rename to pkg/promlib/heuristics.go index 78e38c027eea3..e97a1cd7e5d10 100644 --- a/pkg/tsdb/prometheus/heuristics.go +++ b/pkg/promlib/heuristics.go @@ -1,4 +1,4 @@ -package prometheus +package promlib import ( "context" @@ -8,6 +8,7 @@ import ( "net/http" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" ) const ( @@ -85,10 +86,11 @@ func (s *Service) GetHeuristics(ctx context.Context, req HeuristicsRequest) (*He if err != nil { return nil, err } - return getHeuristics(ctx, ds) + logger := s.logger.FromContext(ctx) + return getHeuristics(ctx, ds, logger) } -func getHeuristics(ctx context.Context, i *instance) (*Heuristics, error) { +func getHeuristics(ctx context.Context, i *instance, logger log.Logger) (*Heuristics, error) { heuristics := Heuristics{ Application: "unknown", Features: Features{ diff --git a/pkg/tsdb/prometheus/heuristics_test.go b/pkg/promlib/heuristics_test.go similarity index 68% rename from pkg/tsdb/prometheus/heuristics_test.go rename to pkg/promlib/heuristics_test.go index 70de2738dfca4..48b6f380ad8c3 100644 --- a/pkg/tsdb/prometheus/heuristics_test.go +++ b/pkg/promlib/heuristics_test.go @@ -1,4 +1,4 @@ -package prometheus +package promlib import ( "context" @@ -13,9 +13,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" - "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" ) type heuristicsSuccessRoundTripper struct { @@ -34,13 +32,17 @@ func (rt *heuristicsSuccessRoundTripper) RoundTrip(req *http.Request) (*http.Res }, nil } -func newHeuristicsSDKProvider(hrt heuristicsSuccessRoundTripper) *httpclient.Provider { - anotherFN := func(o httpclient.Options, next http.RoundTripper) http.RoundTripper { +func newHeuristicsSDKProvider(hrt heuristicsSuccessRoundTripper) *sdkhttpclient.Provider { + anotherFN := func(o sdkhttpclient.Options, next http.RoundTripper) http.RoundTripper { return &hrt } - fn := httpclient.MiddlewareFunc(anotherFN) - mid := httpclient.NamedMiddlewareFunc("mock", fn) - return httpclient.NewProvider(httpclient.ProviderOptions{Middlewares: []httpclient.Middleware{mid}}) + fn := sdkhttpclient.MiddlewareFunc(anotherFN) + mid := sdkhttpclient.NamedMiddlewareFunc("mock", fn) + return sdkhttpclient.NewProvider(sdkhttpclient.ProviderOptions{Middlewares: []sdkhttpclient.Middleware{mid}}) +} + +func mockExtendClientOpts(ctx context.Context, settings backend.DataSourceInstanceSettings, clientOpts *sdkhttpclient.Options) error { + return nil } func Test_GetHeuristics(t *testing.T) { @@ -49,10 +51,11 @@ func Test_GetHeuristics(t *testing.T) { res: io.NopCloser(strings.NewReader("{\"status\":\"success\",\"data\":{\"version\":\"1.0\"}}")), status: http.StatusOK, } - //httpProvider := getHeuristicsMockProvider(&rt) httpProvider := newHeuristicsSDKProvider(rt) + logger := backend.NewLoggerWith("logger", "test") s := &Service{ - im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, &setting.Cfg{}, &featuremgmt.FeatureManager{}, backend.NewLoggerWith("logger", "test"))), + im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, logger, mockExtendClientOpts)), + logger: logger, } req := HeuristicsRequest{ @@ -71,8 +74,10 @@ func Test_GetHeuristics(t *testing.T) { status: http.StatusOK, } httpProvider := newHeuristicsSDKProvider(rt) + logger := backend.NewLoggerWith("logger", "test") s := &Service{ - im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, &setting.Cfg{}, &featuremgmt.FeatureManager{}, backend.NewLoggerWith("logger", "test"))), + im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, logger, mockExtendClientOpts)), + logger: logger, } req := HeuristicsRequest{ diff --git a/pkg/tsdb/prometheus/instrumentation/instrumentation.go b/pkg/promlib/instrumentation/instrumentation.go similarity index 100% rename from pkg/tsdb/prometheus/instrumentation/instrumentation.go rename to pkg/promlib/instrumentation/instrumentation.go diff --git a/pkg/tsdb/prometheus/instrumentation/instrumentation_test.go b/pkg/promlib/instrumentation/instrumentation_test.go similarity index 100% rename from pkg/tsdb/prometheus/instrumentation/instrumentation_test.go rename to pkg/promlib/instrumentation/instrumentation_test.go diff --git a/pkg/promlib/intervalv2/intervalv2.go b/pkg/promlib/intervalv2/intervalv2.go new file mode 100644 index 0000000000000..cf1d943cbd765 --- /dev/null +++ b/pkg/promlib/intervalv2/intervalv2.go @@ -0,0 +1,74 @@ +// Package intervalv2 partially copied from https://github.com/grafana/grafana/blob/main/pkg/tsdb/intervalv2/intervalv2.go +package intervalv2 + +import ( + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" +) + +var ( + DefaultRes int64 = 1500 + defaultMinInterval = time.Millisecond * 1 +) + +type Interval struct { + Text string + Value time.Duration +} + +type intervalCalculator struct { + minInterval time.Duration +} + +type Calculator interface { + Calculate(timerange backend.TimeRange, minInterval time.Duration, maxDataPoints int64) Interval + CalculateSafeInterval(timerange backend.TimeRange, resolution int64) Interval +} + +type CalculatorOptions struct { + MinInterval time.Duration +} + +func NewCalculator(opts ...CalculatorOptions) *intervalCalculator { + calc := &intervalCalculator{} + + for _, o := range opts { + if o.MinInterval == 0 { + calc.minInterval = defaultMinInterval + } else { + calc.minInterval = o.MinInterval + } + } + + return calc +} + +func (ic *intervalCalculator) Calculate(timerange backend.TimeRange, minInterval time.Duration, maxDataPoints int64) Interval { + to := timerange.To.UnixNano() + from := timerange.From.UnixNano() + resolution := maxDataPoints + if resolution == 0 { + resolution = DefaultRes + } + + calculatedInterval := time.Duration((to - from) / resolution) + + if calculatedInterval < minInterval { + return Interval{Text: gtime.FormatInterval(minInterval), Value: minInterval} + } + + rounded := gtime.RoundInterval(calculatedInterval) + + return Interval{Text: gtime.FormatInterval(rounded), Value: rounded} +} + +func (ic *intervalCalculator) CalculateSafeInterval(timerange backend.TimeRange, safeRes int64) Interval { + to := timerange.To.UnixNano() + from := timerange.From.UnixNano() + safeInterval := time.Duration((to - from) / safeRes) + + rounded := gtime.RoundInterval(safeInterval) + return Interval{Text: gtime.FormatInterval(rounded), Value: rounded} +} diff --git a/pkg/promlib/intervalv2/intervalv2_test.go b/pkg/promlib/intervalv2/intervalv2_test.go new file mode 100644 index 0000000000000..8e604f168f117 --- /dev/null +++ b/pkg/promlib/intervalv2/intervalv2_test.go @@ -0,0 +1,63 @@ +package intervalv2 + +import ( + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/stretchr/testify/assert" +) + +func TestIntervalCalculator_Calculate(t *testing.T) { + calculator := NewCalculator(CalculatorOptions{}) + + timeNow := time.Now() + + testCases := []struct { + name string + timeRange backend.TimeRange + resolution int64 + expected string + }{ + {"from 5m to now and default resolution", backend.TimeRange{From: timeNow, To: timeNow.Add(5 * time.Minute)}, 0, "200ms"}, + {"from 5m to now and 500 resolution", backend.TimeRange{From: timeNow, To: timeNow.Add(5 * time.Minute)}, 500, "500ms"}, + {"from 15m to now and default resolution", backend.TimeRange{From: timeNow, To: timeNow.Add(15 * time.Minute)}, 0, "500ms"}, + {"from 15m to now and 100 resolution", backend.TimeRange{From: timeNow, To: timeNow.Add(15 * time.Minute)}, 100, "10s"}, + {"from 30m to now and default resolution", backend.TimeRange{From: timeNow, To: timeNow.Add(30 * time.Minute)}, 0, "1s"}, + {"from 30m to now and 3000 resolution", backend.TimeRange{From: timeNow, To: timeNow.Add(30 * time.Minute)}, 3000, "500ms"}, + {"from 1h to now and default resolution", backend.TimeRange{From: timeNow, To: timeNow.Add(time.Hour)}, 0, "2s"}, + {"from 1h to now and 1000 resolution", backend.TimeRange{From: timeNow, To: timeNow.Add(time.Hour)}, 1000, "5s"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + interval := calculator.Calculate(tc.timeRange, time.Millisecond*1, tc.resolution) + assert.Equal(t, tc.expected, interval.Text) + }) + } +} + +func TestIntervalCalculator_CalculateSafeInterval(t *testing.T) { + calculator := NewCalculator(CalculatorOptions{}) + + timeNow := time.Now() + + testCases := []struct { + name string + timeRange backend.TimeRange + safeResolution int64 + expected string + }{ + {"from 5m to now", backend.TimeRange{From: timeNow, To: timeNow.Add(5 * time.Minute)}, 11000, "20ms"}, + {"from 15m to now", backend.TimeRange{From: timeNow, To: timeNow.Add(15 * time.Minute)}, 11000, "100ms"}, + {"from 30m to now", backend.TimeRange{From: timeNow, To: timeNow.Add(30 * time.Minute)}, 11000, "200ms"}, + {"from 24h to now", backend.TimeRange{From: timeNow, To: timeNow.Add(1440 * time.Minute)}, 11000, "10s"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + interval := calculator.CalculateSafeInterval(tc.timeRange, tc.safeResolution) + assert.Equal(t, tc.expected, interval.Text) + }) + } +} diff --git a/pkg/promlib/library.go b/pkg/promlib/library.go new file mode 100644 index 0000000000000..1a441e9305ee9 --- /dev/null +++ b/pkg/promlib/library.go @@ -0,0 +1,156 @@ +package promlib + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/patrickmn/go-cache" + apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" + + "github.com/grafana/grafana/pkg/promlib/client" + "github.com/grafana/grafana/pkg/promlib/instrumentation" + "github.com/grafana/grafana/pkg/promlib/querydata" + "github.com/grafana/grafana/pkg/promlib/resource" +) + +type Service struct { + im instancemgmt.InstanceManager + logger log.Logger +} + +type instance struct { + queryData *querydata.QueryData + resource *resource.Resource + versionCache *cache.Cache +} + +type ExtendOptions func(ctx context.Context, settings backend.DataSourceInstanceSettings, clientOpts *sdkhttpclient.Options) error + +func NewService(httpClientProvider *sdkhttpclient.Provider, plog log.Logger, extendOptions ExtendOptions) *Service { + if httpClientProvider == nil { + httpClientProvider = sdkhttpclient.NewProvider() + } + return &Service{ + im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider, plog, extendOptions)), + logger: plog, + } +} + +func newInstanceSettings(httpClientProvider *sdkhttpclient.Provider, log log.Logger, extendOptions ExtendOptions) datasource.InstanceFactoryFunc { + return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + // Creates a http roundTripper. + opts, err := client.CreateTransportOptions(ctx, settings, log) + if err != nil { + return nil, fmt.Errorf("error creating transport options: %v", err) + } + + if extendOptions != nil { + err = extendOptions(ctx, settings, opts) + if err != nil { + return nil, fmt.Errorf("error extending transport options: %v", err) + } + } + + httpClient, err := httpClientProvider.New(*opts) + if err != nil { + return nil, fmt.Errorf("error creating http client: %v", err) + } + + // New version using custom client and better response parsing + qd, err := querydata.New(httpClient, settings, log) + if err != nil { + return nil, err + } + + // Resource call management using new custom client same as querydata + r, err := resource.New(httpClient, settings, log) + if err != nil { + return nil, err + } + + return instance{ + queryData: qd, + resource: r, + versionCache: cache.New(time.Minute*1, time.Minute*5), + }, nil + } +} + +func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + if len(req.Queries) == 0 { + err := fmt.Errorf("query contains no queries") + instrumentation.UpdateQueryDataMetrics(err, nil) + return &backend.QueryDataResponse{}, err + } + + i, err := s.getInstance(ctx, req.PluginContext) + if err != nil { + instrumentation.UpdateQueryDataMetrics(err, nil) + return nil, err + } + + qd, err := i.queryData.Execute(ctx, req) + instrumentation.UpdateQueryDataMetrics(err, qd) + + return qd, err +} + +func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + i, err := s.getInstance(ctx, req.PluginContext) + if err != nil { + return err + } + + if strings.EqualFold(req.Path, "version-detect") { + versionObj, found := i.versionCache.Get("version") + if found { + return sender.Send(versionObj.(*backend.CallResourceResponse)) + } + + vResp, err := i.resource.DetectVersion(ctx, req) + if err != nil { + return err + } + i.versionCache.Set("version", vResp, cache.DefaultExpiration) + return sender.Send(vResp) + } + + resp, err := i.resource.Execute(ctx, req) + if err != nil { + return err + } + + return sender.Send(resp) +} + +func (s *Service) getInstance(ctx context.Context, pluginCtx backend.PluginContext) (*instance, error) { + i, err := s.im.Get(ctx, pluginCtx) + if err != nil { + return nil, err + } + in := i.(instance) + return &in, nil +} + +// IsAPIError returns whether err is or wraps a Prometheus error. +func IsAPIError(err error) bool { + // Check if the right error type is in err's chain. + var e *apiv1.Error + return errors.As(err, &e) +} + +func ConvertAPIError(err error) error { + var e *apiv1.Error + if errors.As(err, &e) { + return fmt.Errorf("%s: %s", e.Msg, e.Detail) + } + return err +} diff --git a/pkg/promlib/library_test.go b/pkg/promlib/library_test.go new file mode 100644 index 0000000000000..04529d608d0b2 --- /dev/null +++ b/pkg/promlib/library_test.go @@ -0,0 +1,147 @@ +package promlib + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/stretchr/testify/require" +) + +type fakeSender struct{} + +func (sender *fakeSender) Send(resp *backend.CallResourceResponse) error { + return nil +} + +type fakeRoundtripper struct { + Req *http.Request +} + +func (rt *fakeRoundtripper) RoundTrip(req *http.Request) (*http.Response, error) { + rt.Req = req + return &http.Response{ + Status: "200", + StatusCode: 200, + Header: nil, + Body: nil, + ContentLength: 0, + }, nil +} + +type fakeHTTPClientProvider struct { + sdkhttpclient.Provider + Roundtripper *fakeRoundtripper +} + +func (provider *fakeHTTPClientProvider) New(opts ...sdkhttpclient.Options) (*http.Client, error) { + client := &http.Client{} + provider.Roundtripper = &fakeRoundtripper{} + client.Transport = provider.Roundtripper + return client, nil +} + +func (provider *fakeHTTPClientProvider) GetTransport(opts ...sdkhttpclient.Options) (http.RoundTripper, error) { + return &fakeRoundtripper{}, nil +} + +func getMockPromTestSDKProvider(f *fakeHTTPClientProvider) *sdkhttpclient.Provider { + anotherFN := func(o sdkhttpclient.Options, next http.RoundTripper) http.RoundTripper { + _, _ = f.New() + return f.Roundtripper + } + fn := sdkhttpclient.MiddlewareFunc(anotherFN) + mid := sdkhttpclient.NamedMiddlewareFunc("mock", fn) + return sdkhttpclient.NewProvider(sdkhttpclient.ProviderOptions{Middlewares: []sdkhttpclient.Middleware{mid}}) +} + +func mockExtendTransportOptions(ctx context.Context, settings backend.DataSourceInstanceSettings, clientOpts *sdkhttpclient.Options) error { + return nil +} + +func TestService(t *testing.T) { + t.Run("Service", func(t *testing.T) { + t.Run("CallResource", func(t *testing.T) { + t.Run("creates correct request", func(t *testing.T) { + f := &fakeHTTPClientProvider{} + httpProvider := getMockPromTestSDKProvider(f) + service := NewService(httpProvider, backend.NewLoggerWith("logger", "test"), mockExtendTransportOptions) + + req := mockRequest() + sender := &fakeSender{} + err := service.CallResource(context.Background(), req, sender) + require.NoError(t, err) + require.Equal( + t, + http.Header{ + "Content-Type": {"application/x-www-form-urlencoded"}, + "Idempotency-Key": []string(nil), + }, + f.Roundtripper.Req.Header) + require.Equal(t, http.MethodPost, f.Roundtripper.Req.Method) + body, err := io.ReadAll(f.Roundtripper.Req.Body) + require.NoError(t, err) + require.Equal(t, []byte("match%5B%5D: ALERTS\nstart: 1655271408\nend: 1655293008"), body) + require.Equal(t, "http://localhost:9090/api/v1/series", f.Roundtripper.Req.URL.String()) + }) + }) + }) + + t.Run("no extendOptions function provided", func(t *testing.T) { + f := &fakeHTTPClientProvider{} + httpProvider := getMockPromTestSDKProvider(f) + service := NewService(httpProvider, backend.NewLoggerWith("logger", "test"), nil) + require.NotNil(t, service) + require.NotNil(t, service.im) + }) + + t.Run("extendOptions function provided", func(t *testing.T) { + f := &fakeHTTPClientProvider{} + httpProvider := getMockPromTestSDKProvider(f) + service := NewService(httpProvider, backend.NewLoggerWith("logger", "test"), func(ctx context.Context, settings backend.DataSourceInstanceSettings, clientOpts *sdkhttpclient.Options) error { + fmt.Println(ctx, settings, clientOpts) + require.NotNil(t, ctx) + require.NotNil(t, settings) + require.Equal(t, "test-prom", settings.Name) + return nil + }) + + req := mockRequest() + sender := &fakeSender{} + err := service.CallResource(context.Background(), req, sender) + require.NoError(t, err) + }) +} + +func mockRequest() *backend.CallResourceRequest { + return &backend.CallResourceRequest{ + PluginContext: backend.PluginContext{ + OrgID: 0, + PluginID: "prometheus", + User: nil, + AppInstanceSettings: nil, + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + ID: 0, + UID: "", + Type: "prometheus", + Name: "test-prom", + URL: "http://localhost:9090", + User: "", + Database: "", + BasicAuthEnabled: true, + BasicAuthUser: "admin", + Updated: time.Time{}, + JSONData: []byte("{}"), + }, + }, + Path: "/api/v1/series", + Method: http.MethodPost, + URL: "/api/v1/series", + Body: []byte("match%5B%5D: ALERTS\nstart: 1655271408\nend: 1655293008"), + } +} diff --git a/pkg/tsdb/prometheus/middleware/custom_query_params.go b/pkg/promlib/middleware/custom_query_params.go similarity index 100% rename from pkg/tsdb/prometheus/middleware/custom_query_params.go rename to pkg/promlib/middleware/custom_query_params.go diff --git a/pkg/tsdb/prometheus/middleware/custom_query_params_test.go b/pkg/promlib/middleware/custom_query_params_test.go similarity index 100% rename from pkg/tsdb/prometheus/middleware/custom_query_params_test.go rename to pkg/promlib/middleware/custom_query_params_test.go diff --git a/pkg/tsdb/prometheus/middleware/force_http_get.go b/pkg/promlib/middleware/force_http_get.go similarity index 100% rename from pkg/tsdb/prometheus/middleware/force_http_get.go rename to pkg/promlib/middleware/force_http_get.go diff --git a/pkg/tsdb/prometheus/middleware/force_http_get_test.go b/pkg/promlib/middleware/force_http_get_test.go similarity index 100% rename from pkg/tsdb/prometheus/middleware/force_http_get_test.go rename to pkg/promlib/middleware/force_http_get_test.go diff --git a/pkg/tsdb/prometheus/models/query.go b/pkg/promlib/models/query.go similarity index 65% rename from pkg/tsdb/prometheus/models/query.go rename to pkg/promlib/models/query.go index 03cb044aea65d..a22eb5a2bab2a 100644 --- a/pkg/tsdb/prometheus/models/query.go +++ b/pkg/promlib/models/query.go @@ -8,11 +8,64 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" + "github.com/prometheus/prometheus/model/labels" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" - "github.com/grafana/grafana/pkg/tsdb/prometheus/kinds/dataquery" + "github.com/grafana/grafana/pkg/apis/scope/v0alpha1" + "github.com/grafana/grafana/pkg/promlib/intervalv2" ) +// PromQueryFormat defines model for PromQueryFormat. +// +enum +type PromQueryFormat string + +const ( + PromQueryFormatTimeSeries PromQueryFormat = "time_series" + PromQueryFormatTable PromQueryFormat = "table" + PromQueryFormatHeatmap PromQueryFormat = "heatmap" +) + +// QueryEditorMode defines model for QueryEditorMode. +// +enum +type QueryEditorMode string + +const ( + QueryEditorModeBuilder QueryEditorMode = "builder" + QueryEditorModeCode QueryEditorMode = "code" +) + +// PrometheusQueryProperties defines the specific properties used for prometheus +type PrometheusQueryProperties struct { + // The response format + Format PromQueryFormat `json:"format,omitempty"` + + // The actual expression/query that will be evaluated by Prometheus + Expr string `json:"expr"` + + // Returns a Range vector, comprised of a set of time series containing a range of data points over time for each time series + Range bool `json:"range,omitempty"` + + // Returns only the latest value that Prometheus has scraped for the requested time series + Instant bool `json:"instant,omitempty"` + + // Execute an additional query to identify interesting raw samples relevant for the given expr + Exemplar bool `json:"exemplar,omitempty"` + + // what we should show in the editor + EditorMode QueryEditorMode `json:"editorMode,omitempty"` + + // Used to specify how many times to divide max data points by. We use max data points under query options + // See https://github.com/grafana/grafana/issues/48081 + // Deprecated: use interval + IntervalFactor int64 `json:"intervalFactor,omitempty"` + + // Series name override or template. Ex. {{hostname}} will be replaced with label value for hostname + LegendFormat string `json:"legendFormat,omitempty"` + + // ??? + Scope *v0alpha1.ScopeSpec `json:"scope,omitempty"` +} + // Internal interval and range variables const ( varInterval = "$__interval" @@ -47,15 +100,22 @@ const ( var safeResolution = 11000 +// QueryModel includes both the common and specific values type QueryModel struct { - dataquery.PrometheusDataQuery + PrometheusQueryProperties `json:",inline"` + CommonQueryProperties `json:",inline"` + // The following properties may be part of the request payload, however they are not saved in panel JSON // Timezone offset to align start & end time on backend - UtcOffsetSec int64 `json:"utcOffsetSec,omitempty"` - LegendFormat string `json:"legendFormat,omitempty"` - Interval string `json:"interval,omitempty"` - IntervalMs int64 `json:"intervalMs,omitempty"` - IntervalFactor int64 `json:"intervalFactor,omitempty"` + UtcOffsetSec int64 `json:"utcOffsetSec,omitempty"` + Interval string `json:"interval,omitempty"` +} + +// CommonQueryProperties is properties applied to all queries +// NOTE: this will soon be replaced with a struct from the SDK +type CommonQueryProperties struct { + RefId string `json:"refId,omitempty"` + IntervalMs int64 `json:"intervalMs,omitempty"` } type TimeRange struct { @@ -64,6 +124,7 @@ type TimeRange struct { Step time.Duration } +// The internal query object type Query struct { Expr string Step time.Duration @@ -75,16 +136,21 @@ type Query struct { RangeQuery bool ExemplarQuery bool UtcOffsetSec int64 + Scope *v0alpha1.ScopeSpec } -func Parse(query backend.DataQuery, dsScrapeInterval string, intervalCalculator intervalv2.Calculator, fromAlert bool) (*Query, error) { +type Scope struct { + Matchers []*labels.Matcher +} + +func Parse(query backend.DataQuery, dsScrapeInterval string, intervalCalculator intervalv2.Calculator, fromAlert bool, enableScope bool) (*Query, error) { model := &QueryModel{} if err := json.Unmarshal(query.JSON, model); err != nil { return nil, err } // Final step value for prometheus - calculatedMinStep, err := calculatePrometheusInterval(model.Interval, dsScrapeInterval, model.IntervalMs, model.IntervalFactor, query, intervalCalculator) + calculatedStep, err := calculatePrometheusInterval(model.Interval, dsScrapeInterval, model.IntervalMs, model.IntervalFactor, query, intervalCalculator) if err != nil { return nil, err } @@ -94,46 +160,37 @@ func Parse(query backend.DataQuery, dsScrapeInterval string, intervalCalculator expr := interpolateVariables( model.Expr, query.Interval, - calculatedMinStep, + calculatedStep, model.Interval, dsScrapeInterval, timeRange, ) - var rangeQuery, instantQuery bool - if model.Instant == nil { - instantQuery = false - } else { - instantQuery = *model.Instant - } - if model.Range == nil { - rangeQuery = false - } else { - rangeQuery = *model.Range + if enableScope && model.Scope != nil && len(model.Scope.Filters) > 0 { + expr, err = ApplyQueryScope(expr, *model.Scope) + if err != nil { + return nil, err + } } - if !instantQuery && !rangeQuery { + if !model.Instant && !model.Range { // In older dashboards, we were not setting range query param and !range && !instant was run as range query - rangeQuery = true + model.Range = true } // We never want to run exemplar query for alerting - exemplarQuery := false - if model.Exemplar != nil { - exemplarQuery = *model.Exemplar - } if fromAlert { - exemplarQuery = false + model.Exemplar = false } return &Query{ Expr: expr, - Step: calculatedMinStep, + Step: calculatedStep, LegendFormat: model.LegendFormat, Start: query.TimeRange.From, End: query.TimeRange.To, RefId: query.RefID, - InstantQuery: instantQuery, - RangeQuery: rangeQuery, - ExemplarQuery: exemplarQuery, + InstantQuery: model.Instant, + RangeQuery: model.Range, + ExemplarQuery: model.Exemplar, UtcOffsetSec: model.UtcOffsetSec, }, nil } @@ -175,7 +232,7 @@ func calculatePrometheusInterval( queryInterval = "" } - minInterval, err := intervalv2.GetIntervalFrom(dsScrapeInterval, queryInterval, intervalMs, 15*time.Second) + minInterval, err := gtime.GetIntervalFrom(dsScrapeInterval, queryInterval, intervalMs, 15*time.Second) if err != nil { return time.Duration(0), err } @@ -214,7 +271,7 @@ func calculateRateInterval( scrape = "15s" } - scrapeIntervalDuration, err := intervalv2.ParseIntervalStringToTimeDuration(scrape) + scrapeIntervalDuration, err := gtime.ParseIntervalStringToTimeDuration(scrape) if err != nil { return time.Duration(0) } @@ -226,14 +283,14 @@ func calculateRateInterval( // interpolateVariables interpolates built-in variables // expr PromQL query // queryInterval Requested interval in milliseconds. This value may be overridden by MinStep in query options -// calculatedMinStep Calculated final step value. It was calculated in calculatePrometheusInterval +// calculatedStep Calculated final step value. It was calculated in calculatePrometheusInterval // requestedMinStep Requested minimum step value. QueryModel.interval // dsScrapeInterval Data source scrape interval in the config // timeRange Requested time range for query func interpolateVariables( expr string, queryInterval time.Duration, - calculatedMinStep time.Duration, + calculatedStep time.Duration, requestedMinStep string, dsScrapeInterval string, timeRange time.Duration, @@ -243,10 +300,10 @@ func interpolateVariables( var rateInterval time.Duration if requestedMinStep == varRateInterval || requestedMinStep == varRateIntervalAlt { - rateInterval = calculatedMinStep + rateInterval = calculatedStep } else { if requestedMinStep == varInterval || requestedMinStep == varIntervalAlt { - requestedMinStep = calculatedMinStep.String() + requestedMinStep = calculatedStep.String() } if requestedMinStep == "" { requestedMinStep = dsScrapeInterval @@ -254,8 +311,8 @@ func interpolateVariables( rateInterval = calculateRateInterval(queryInterval, requestedMinStep) } - expr = strings.ReplaceAll(expr, varIntervalMs, strconv.FormatInt(int64(queryInterval/time.Millisecond), 10)) - expr = strings.ReplaceAll(expr, varInterval, intervalv2.FormatDuration(queryInterval)) + expr = strings.ReplaceAll(expr, varIntervalMs, strconv.FormatInt(int64(calculatedStep/time.Millisecond), 10)) + expr = strings.ReplaceAll(expr, varInterval, gtime.FormatInterval(calculatedStep)) expr = strings.ReplaceAll(expr, varRangeMs, strconv.FormatInt(rangeMs, 10)) expr = strings.ReplaceAll(expr, varRangeS, strconv.FormatInt(rangeSRounded, 10)) expr = strings.ReplaceAll(expr, varRange, strconv.FormatInt(rangeSRounded, 10)+"s") @@ -263,8 +320,8 @@ func interpolateVariables( expr = strings.ReplaceAll(expr, varRateInterval, rateInterval.String()) // Repetitive code, we should have functionality to unify these - expr = strings.ReplaceAll(expr, varIntervalMsAlt, strconv.FormatInt(int64(queryInterval/time.Millisecond), 10)) - expr = strings.ReplaceAll(expr, varIntervalAlt, intervalv2.FormatDuration(queryInterval)) + expr = strings.ReplaceAll(expr, varIntervalMsAlt, strconv.FormatInt(int64(calculatedStep/time.Millisecond), 10)) + expr = strings.ReplaceAll(expr, varIntervalAlt, gtime.FormatInterval(calculatedStep)) expr = strings.ReplaceAll(expr, varRangeMsAlt, strconv.FormatInt(rangeMs, 10)) expr = strings.ReplaceAll(expr, varRangeSAlt, strconv.FormatInt(rangeSRounded, 10)) expr = strings.ReplaceAll(expr, varRangeAlt, strconv.FormatInt(rangeSRounded, 10)+"s") @@ -284,6 +341,11 @@ func isVariableInterval(interval string) bool { return false } +// AlignTimeRange aligns query range to step and handles the time offset. +// It rounds start and end down to a multiple of step. +// Prometheus caching is dependent on the range being aligned with the step. +// Rounding to the step can significantly change the start and end of the range for larger steps, i.e. a week. +// In rounding the range to a 1w step the range will always start on a Thursday. func AlignTimeRange(t time.Time, step time.Duration, offset int64) time.Time { offsetNano := float64(offset * 1e9) stepNano := float64(step.Nanoseconds()) diff --git a/pkg/tsdb/prometheus/models/query_test.go b/pkg/promlib/models/query_test.go similarity index 85% rename from pkg/tsdb/prometheus/models/query_test.go rename to pkg/promlib/models/query_test.go index 6627f09271545..085352f3f1a87 100644 --- a/pkg/tsdb/prometheus/models/query_test.go +++ b/pkg/promlib/models/query_test.go @@ -9,8 +9,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" - "github.com/grafana/grafana/pkg/tsdb/prometheus/models" + "github.com/grafana/grafana/pkg/promlib/intervalv2" + "github.com/grafana/grafana/pkg/promlib/models" ) var ( @@ -37,7 +37,7 @@ func TestParse(t *testing.T) { RefID: "A", } - res, err := models.Parse(q, "15s", intervalCalculator, true) + res, err := models.Parse(q, "15s", intervalCalculator, true, false) require.NoError(t, err) require.Equal(t, false, res.ExemplarQuery) }) @@ -54,7 +54,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, time.Second*30, res.Step) }) @@ -72,7 +72,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, time.Second*15, res.Step) }) @@ -90,7 +90,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, time.Minute*20, res.Step) }) @@ -108,7 +108,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, time.Minute*2, res.Step) }) @@ -126,7 +126,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "240s", intervalCalculator, false) + res, err := models.Parse(q, "240s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, time.Minute*4, res.Step) }) @@ -145,9 +145,9 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) - require.Equal(t, "rate(ALERTS{job=\"test\" [1m]})", res.Expr) + require.Equal(t, "rate(ALERTS{job=\"test\" [2m]})", res.Expr) require.Equal(t, 120*time.Second, res.Step) }) @@ -166,9 +166,9 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) - require.Equal(t, "rate(ALERTS{job=\"test\" [1m]})", res.Expr) + require.Equal(t, "rate(ALERTS{job=\"test\" [2m]})", res.Expr) }) t.Run("parsing query model with $__interval_ms variable", func(t *testing.T) { @@ -185,9 +185,9 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) - require.Equal(t, "rate(ALERTS{job=\"test\" [60000]})", res.Expr) + require.Equal(t, "rate(ALERTS{job=\"test\" [120000]})", res.Expr) }) t.Run("parsing query model with $__interval_ms and $__interval variable", func(t *testing.T) { @@ -204,9 +204,9 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) - require.Equal(t, "rate(ALERTS{job=\"test\" [60000]}) + rate(ALERTS{job=\"test\" [1m]})", res.Expr) + require.Equal(t, "rate(ALERTS{job=\"test\" [120000]}) + rate(ALERTS{job=\"test\" [2m]})", res.Expr) }) t.Run("parsing query model with ${__interval_ms} and ${__interval} variable", func(t *testing.T) { @@ -223,9 +223,9 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) - require.Equal(t, "rate(ALERTS{job=\"test\" [60000]}) + rate(ALERTS{job=\"test\" [1m]})", res.Expr) + require.Equal(t, "rate(ALERTS{job=\"test\" [120000]}) + rate(ALERTS{job=\"test\" [2m]})", res.Expr) }) t.Run("parsing query model with $__range variable", func(t *testing.T) { @@ -241,7 +241,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [172800s]})", res.Expr) }) @@ -259,7 +259,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [172800]})", res.Expr) }) @@ -277,7 +277,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [172800s]})", res.Expr) }) @@ -295,7 +295,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [0]})", res.Expr) }) @@ -313,7 +313,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [1]})", res.Expr) }) @@ -331,7 +331,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [172800000]})", res.Expr) }) @@ -349,7 +349,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [20]})", res.Expr) }) @@ -368,7 +368,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [20m0s]})", res.Expr) }) @@ -387,7 +387,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, 1*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [1m0s]})", res.Expr) require.Equal(t, 1*time.Minute, res.Step) @@ -406,7 +406,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, 2*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [135000]})", res.Expr) }) @@ -424,7 +424,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, 2*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [135000]}) + rate(ALERTS{job=\"test\" [2m15s]})", res.Expr) }) @@ -442,7 +442,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, 2*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [135000]}) + rate(ALERTS{job=\"test\" [2m15s]})", res.Expr) }) @@ -461,7 +461,7 @@ func TestParse(t *testing.T) { "range": true }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, true, res.RangeQuery) }) @@ -481,7 +481,7 @@ func TestParse(t *testing.T) { "instant": true }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, true, res.RangeQuery) require.Equal(t, true, res.InstantQuery) @@ -500,7 +500,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(q, "15s", intervalCalculator, false) + res, err := models.Parse(q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, true, res.RangeQuery) }) @@ -631,7 +631,7 @@ func TestRateInterval(t *testing.T) { t.Run(tt.name, func(t *testing.T) { q := mockQuery(tt.args.expr, tt.args.interval, tt.args.intervalMs, tt.args.timeRange) q.MaxDataPoints = 12384 - res, err := models.Parse(q, tt.args.dsScrapeInterval, intervalCalculator, false) + res, err := models.Parse(q, tt.args.dsScrapeInterval, intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, tt.want.Expr, res.Expr) require.Equal(t, tt.want.Step, res.Step) @@ -666,7 +666,7 @@ func TestRateInterval(t *testing.T) { "utcOffsetSec":3600 }`), } - res, err := models.Parse(query, "30s", intervalCalculator, false) + res, err := models.Parse(query, "30s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "sum(rate(process_cpu_seconds_total[2m0s]))", res.Expr) require.Equal(t, 30*time.Second, res.Step) @@ -701,7 +701,7 @@ func TestRateInterval(t *testing.T) { "maxDataPoints": 1055 }`), } - res, err := models.Parse(query, "15s", intervalCalculator, false) + res, err := models.Parse(query, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "sum(rate(cache_requests_total[1m0s]))", res.Expr) require.Equal(t, 15*time.Second, res.Step) @@ -739,20 +739,40 @@ func queryContext(json string, timeRange backend.TimeRange, queryInterval time.D } } +// AlignTimeRange aligns query range to step and handles the time offset. +// It rounds start and end down to a multiple of step. +// Prometheus caching is dependent on the range being aligned with the step. +// Rounding to the step can significantly change the start and end of the range for larger steps, i.e. a week. +// In rounding the range to a 1w step the range will always start on a Thursday. func TestAlignTimeRange(t *testing.T) { type args struct { t time.Time step time.Duration offset int64 } + + var monday int64 = 1704672000 + var thursday int64 = 1704326400 + var one_week_min_step = 604800 * time.Second + tests := []struct { name string args args want time.Time }{ - {name: "second step", args: args{t: time.Unix(1664816826, 0), step: 10 * time.Second, offset: 0}, want: time.Unix(1664816820, 0).UTC()}, + { + name: "second step", + args: args{t: time.Unix(1664816826, 0), step: 10 * time.Second, offset: 0}, + want: time.Unix(1664816820, 0).UTC(), + }, {name: "millisecond step", args: args{t: time.Unix(1664816825, 5*int64(time.Millisecond)), step: 10 * time.Millisecond, offset: 0}, want: time.Unix(1664816825, 0).UTC()}, {name: "second step with offset", args: args{t: time.Unix(1664816825, 5*int64(time.Millisecond)), step: 2 * time.Second, offset: -3}, want: time.Unix(1664816825, 0).UTC()}, + // we may not want this functionality in the future but if we change this we break Prometheus caching. + { + name: "1w step with range date of Monday that changes the range to a Thursday.", + args: args{t: time.Unix(monday, 0), step: one_week_min_step, offset: 0}, + want: time.Unix(thursday, 0).UTC(), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/tsdb/prometheus/models/result.go b/pkg/promlib/models/result.go similarity index 99% rename from pkg/tsdb/prometheus/models/result.go rename to pkg/promlib/models/result.go index ea5e5c8934fc7..3cefc7fb3d6bd 100644 --- a/pkg/tsdb/prometheus/models/result.go +++ b/pkg/promlib/models/result.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" ) +// +enum type ResultType string const ( diff --git a/pkg/promlib/models/scope.go b/pkg/promlib/models/scope.go new file mode 100644 index 0000000000000..16e588001bbaa --- /dev/null +++ b/pkg/promlib/models/scope.go @@ -0,0 +1,85 @@ +package models + +import ( + "fmt" + + "github.com/grafana/grafana/pkg/apis/scope/v0alpha1" + "github.com/prometheus/prometheus/model/labels" + "github.com/prometheus/prometheus/promql/parser" +) + +func ApplyQueryScope(rawExpr string, scope v0alpha1.ScopeSpec) (string, error) { + expr, err := parser.ParseExpr(rawExpr) + if err != nil { + return "", err + } + + matchers, err := scopeFiltersToMatchers(scope.Filters) + if err != nil { + return "", err + } + + matcherNamesToIdx := make(map[string]int, len(matchers)) + for i, matcher := range matchers { + if matcher == nil { + continue + } + matcherNamesToIdx[matcher.Name] = i + } + + parser.Inspect(expr, func(node parser.Node, nodes []parser.Node) error { + switch v := node.(type) { + case *parser.VectorSelector: + found := make([]bool, len(matchers)) + for _, matcher := range v.LabelMatchers { + if matcher == nil || matcher.Name == "__name__" { // const prob + continue + } + if _, ok := matcherNamesToIdx[matcher.Name]; ok { + found[matcherNamesToIdx[matcher.Name]] = true + newM := matchers[matcherNamesToIdx[matcher.Name]] + matcher.Name = newM.Name + matcher.Type = newM.Type + matcher.Value = newM.Value + } + } + for i, f := range found { + if f { + continue + } + v.LabelMatchers = append(v.LabelMatchers, matchers[i]) + } + + return nil + + default: + return nil + } + }) + return expr.String(), nil +} + +func scopeFiltersToMatchers(filters []v0alpha1.ScopeFilter) ([]*labels.Matcher, error) { + matchers := make([]*labels.Matcher, 0, len(filters)) + for _, f := range filters { + var mt labels.MatchType + switch f.Operator { + case "=": + mt = labels.MatchEqual + case "!=": + mt = labels.MatchNotEqual + case "=~": + mt = labels.MatchRegexp + case "!~": + mt = labels.MatchNotRegexp + default: + return nil, fmt.Errorf("unknown operator %q", f.Operator) + } + m, err := labels.NewMatcher(mt, f.Key, f.Value) + if err != nil { + return nil, err + } + matchers = append(matchers, m) + } + return matchers, nil +} diff --git a/pkg/tsdb/prometheus/querydata/exemplar/framer.go b/pkg/promlib/querydata/exemplar/framer.go similarity index 100% rename from pkg/tsdb/prometheus/querydata/exemplar/framer.go rename to pkg/promlib/querydata/exemplar/framer.go diff --git a/pkg/tsdb/prometheus/querydata/exemplar/labels.go b/pkg/promlib/querydata/exemplar/labels.go similarity index 100% rename from pkg/tsdb/prometheus/querydata/exemplar/labels.go rename to pkg/promlib/querydata/exemplar/labels.go diff --git a/pkg/tsdb/prometheus/querydata/exemplar/sampler.go b/pkg/promlib/querydata/exemplar/sampler.go similarity index 93% rename from pkg/tsdb/prometheus/querydata/exemplar/sampler.go rename to pkg/promlib/querydata/exemplar/sampler.go index be7f518cb898d..f0b3e0eed3aae 100644 --- a/pkg/tsdb/prometheus/querydata/exemplar/sampler.go +++ b/pkg/promlib/querydata/exemplar/sampler.go @@ -4,7 +4,7 @@ import ( "sort" "time" - "github.com/grafana/grafana/pkg/tsdb/prometheus/models" + "github.com/grafana/grafana/pkg/promlib/models" ) type Sampler interface { diff --git a/pkg/tsdb/prometheus/querydata/exemplar/sampler_stddev.go b/pkg/promlib/querydata/exemplar/sampler_stddev.go similarity index 97% rename from pkg/tsdb/prometheus/querydata/exemplar/sampler_stddev.go rename to pkg/promlib/querydata/exemplar/sampler_stddev.go index 9028e22cff594..92ebf0ef510c0 100644 --- a/pkg/tsdb/prometheus/querydata/exemplar/sampler_stddev.go +++ b/pkg/promlib/querydata/exemplar/sampler_stddev.go @@ -5,7 +5,7 @@ import ( "sort" "time" - "github.com/grafana/grafana/pkg/tsdb/prometheus/models" + "github.com/grafana/grafana/pkg/promlib/models" ) type StandardDeviationSampler struct { diff --git a/pkg/tsdb/prometheus/querydata/exemplar/sampler_stddev_test.go b/pkg/promlib/querydata/exemplar/sampler_stddev_test.go similarity index 84% rename from pkg/tsdb/prometheus/querydata/exemplar/sampler_stddev_test.go rename to pkg/promlib/querydata/exemplar/sampler_stddev_test.go index b96f6d63b285e..c7a4ff0aa7a3f 100644 --- a/pkg/tsdb/prometheus/querydata/exemplar/sampler_stddev_test.go +++ b/pkg/promlib/querydata/exemplar/sampler_stddev_test.go @@ -6,8 +6,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/experimental" - "github.com/grafana/grafana/pkg/tsdb/prometheus/models" - "github.com/grafana/grafana/pkg/tsdb/prometheus/querydata/exemplar" + "github.com/grafana/grafana/pkg/promlib/models" + "github.com/grafana/grafana/pkg/promlib/querydata/exemplar" ) func TestStdDevSampler(t *testing.T) { diff --git a/pkg/tsdb/prometheus/querydata/exemplar/sampler_test.go b/pkg/promlib/querydata/exemplar/sampler_test.go similarity index 88% rename from pkg/tsdb/prometheus/querydata/exemplar/sampler_test.go rename to pkg/promlib/querydata/exemplar/sampler_test.go index f071f1b902549..26cdc5c2a8d5d 100644 --- a/pkg/tsdb/prometheus/querydata/exemplar/sampler_test.go +++ b/pkg/promlib/querydata/exemplar/sampler_test.go @@ -6,8 +6,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/experimental" - "github.com/grafana/grafana/pkg/tsdb/prometheus/models" - "github.com/grafana/grafana/pkg/tsdb/prometheus/querydata/exemplar" + "github.com/grafana/grafana/pkg/promlib/models" + "github.com/grafana/grafana/pkg/promlib/querydata/exemplar" ) const update = true diff --git a/pkg/tsdb/prometheus/querydata/exemplar/testdata/noop_sampler.jsonc b/pkg/promlib/querydata/exemplar/testdata/noop_sampler.jsonc similarity index 100% rename from pkg/tsdb/prometheus/querydata/exemplar/testdata/noop_sampler.jsonc rename to pkg/promlib/querydata/exemplar/testdata/noop_sampler.jsonc diff --git a/pkg/tsdb/prometheus/querydata/exemplar/testdata/stddev_sampler.jsonc b/pkg/promlib/querydata/exemplar/testdata/stddev_sampler.jsonc similarity index 100% rename from pkg/tsdb/prometheus/querydata/exemplar/testdata/stddev_sampler.jsonc rename to pkg/promlib/querydata/exemplar/testdata/stddev_sampler.jsonc diff --git a/pkg/tsdb/prometheus/querydata/framing_bench_test.go b/pkg/promlib/querydata/framing_bench_test.go similarity index 90% rename from pkg/tsdb/prometheus/querydata/framing_bench_test.go rename to pkg/promlib/querydata/framing_bench_test.go index 295810ed9f607..b769df2b47d13 100644 --- a/pkg/tsdb/prometheus/querydata/framing_bench_test.go +++ b/pkg/promlib/querydata/framing_bench_test.go @@ -15,16 +15,13 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/kindsys" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/tsdb/prometheus/kinds/dataquery" - - "github.com/grafana/grafana/pkg/tsdb/prometheus/models" + "github.com/grafana/grafana/pkg/promlib/models" ) // when memory-profiling this benchmark, these commands are recommended: -// - go test -benchmem -run=^$ -bench ^BenchmarkExemplarJson$ github.com/grafana/grafana/pkg/tsdb/prometheus/querydata -memprofile memprofile.out -count 6 | tee old.txt +// - go test -benchmem -run=^$ -bench ^BenchmarkExemplarJson$ github.com/grafana/grafana/pkg/promlib/querydata -memprofile memprofile.out -count 6 | tee old.txt // - go tool pprof -http=localhost:6061 memprofile.out func BenchmarkExemplarJson(b *testing.B) { queryFileName := filepath.Join("../testdata", "exemplar.query.json") @@ -58,7 +55,7 @@ func BenchmarkExemplarJson(b *testing.B) { var resp *backend.QueryDataResponse // when memory-profiling this benchmark, these commands are recommended: -// - go test -benchmem -run=^$ -bench ^BenchmarkRangeJson$ github.com/grafana/grafana/pkg/tsdb/prometheus/querydata -memprofile memprofile.out -count 6 | tee old.txt +// - go test -benchmem -run=^$ -bench ^BenchmarkRangeJson$ github.com/grafana/grafana/pkg/promlib/querydata -memprofile memprofile.out -count 6 | tee old.txt // - go tool pprof -http=localhost:6061 memprofile.out // - benchstat old.txt new.txt func BenchmarkRangeJson(b *testing.B) { @@ -127,8 +124,8 @@ func createJsonTestData(start int64, step int64, timestampCount int, seriesCount bytes := []byte(fmt.Sprintf(`{"status":"success","data":{"resultType":"matrix","result":[%v]}}`, strings.Join(allSeries, ","))) qm := models.QueryModel{ - PrometheusDataQuery: dataquery.PrometheusDataQuery{ - Range: kindsys.Ptr(true), + PrometheusQueryProperties: models.PrometheusQueryProperties{ + Range: true, Expr: "test", }, } diff --git a/pkg/tsdb/prometheus/querydata/framing_test.go b/pkg/promlib/querydata/framing_test.go similarity index 91% rename from pkg/tsdb/prometheus/querydata/framing_test.go rename to pkg/promlib/querydata/framing_test.go index feadc406619fb..0d194f189ffa9 100644 --- a/pkg/tsdb/prometheus/querydata/framing_test.go +++ b/pkg/promlib/querydata/framing_test.go @@ -14,10 +14,9 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/experimental" - "github.com/grafana/grafana/pkg/tsdb/prometheus/kinds/dataquery" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/tsdb/prometheus/models" + "github.com/grafana/grafana/pkg/promlib/models" ) var update = true @@ -108,14 +107,16 @@ func loadStoredQuery(fileName string) (*backend.QueryDataRequest, error) { } qm := models.QueryModel{ - PrometheusDataQuery: dataquery.PrometheusDataQuery{ - Range: &sq.RangeQuery, - Exemplar: &sq.ExemplarQuery, - Expr: sq.Expr, + PrometheusQueryProperties: models.PrometheusQueryProperties{ + Range: sq.RangeQuery, + Exemplar: sq.ExemplarQuery, + Expr: sq.Expr, + LegendFormat: sq.LegendFormat, }, - Interval: fmt.Sprintf("%ds", sq.Step), - IntervalMs: sq.Step * 1000, - LegendFormat: sq.LegendFormat, + CommonQueryProperties: models.CommonQueryProperties{ + IntervalMs: sq.Step * 1000, + }, + Interval: fmt.Sprintf("%ds", sq.Step), } data, err := json.Marshal(&qm) diff --git a/pkg/tsdb/prometheus/querydata/request.go b/pkg/promlib/querydata/request.go similarity index 73% rename from pkg/tsdb/prometheus/querydata/request.go rename to pkg/promlib/querydata/request.go index 762cf371cd4f0..aabd347b9ae73 100644 --- a/pkg/tsdb/prometheus/querydata/request.go +++ b/pkg/promlib/querydata/request.go @@ -9,19 +9,18 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana-plugin-sdk-go/data/utils/maputil" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" - "github.com/grafana/grafana/pkg/tsdb/prometheus/client" - "github.com/grafana/grafana/pkg/tsdb/prometheus/models" - "github.com/grafana/grafana/pkg/tsdb/prometheus/querydata/exemplar" - "github.com/grafana/grafana/pkg/tsdb/prometheus/utils" - "github.com/grafana/grafana/pkg/util/maputil" + "github.com/grafana/grafana/pkg/promlib/client" + "github.com/grafana/grafana/pkg/promlib/intervalv2" + "github.com/grafana/grafana/pkg/promlib/models" + "github.com/grafana/grafana/pkg/promlib/querydata/exemplar" + "github.com/grafana/grafana/pkg/promlib/utils" ) const legendFormatAuto = "__auto" @@ -44,13 +43,11 @@ type QueryData struct { ID int64 URL string TimeInterval string - enableDataplane bool exemplarSampler func() exemplar.Sampler } func New( httpClient *http.Client, - features featuremgmt.FeatureToggles, settings backend.DataSourceInstanceSettings, plog log.Logger, ) (*QueryData, error) { @@ -74,10 +71,6 @@ func New( // standard deviation sampler is the default for backwards compatibility exemplarSampler := exemplar.NewStandardDeviationSampler - if features.IsEnabledGlobally(featuremgmt.FlagDisablePrometheusExemplarSampling) { - exemplarSampler = exemplar.NewNoOpSampler - } - return &QueryData{ intervalCalculator: intervalv2.NewCalculator(), tracer: tracing.DefaultTracer(), @@ -86,7 +79,6 @@ func New( TimeInterval: timeInterval, ID: settings.ID, URL: settings.URL, - enableDataplane: features.IsEnabledGlobally(featuremgmt.FlagPrometheusDataplane), exemplarSampler: exemplarSampler, }, nil } @@ -97,13 +89,17 @@ func (s *QueryData) Execute(ctx context.Context, req *backend.QueryDataRequest) Responses: backend.Responses{}, } + cfg := backend.GrafanaConfigFromContext(ctx) + hasPromQLScopeFeatureFlag := cfg.FeatureToggles().IsEnabled("promQLScope") + hasPrometheusDataplaneFeatureFlag := cfg.FeatureToggles().IsEnabled("prometheusDataplane") + for _, q := range req.Queries { - query, err := models.Parse(q, s.TimeInterval, s.intervalCalculator, fromAlert) + query, err := models.Parse(q, s.TimeInterval, s.intervalCalculator, fromAlert, hasPromQLScopeFeatureFlag) if err != nil { return &result, err } - r := s.fetch(ctx, s.client, query, req.Headers) + r := s.fetch(ctx, s.client, query, hasPrometheusDataplaneFeatureFlag) if r == nil { s.log.FromContext(ctx).Debug("Received nil response from runQuery", "query", query.Expr) continue @@ -114,7 +110,7 @@ func (s *QueryData) Execute(ctx context.Context, req *backend.QueryDataRequest) return &result, nil } -func (s *QueryData) fetch(ctx context.Context, client *client.Client, q *models.Query, headers map[string]string) *backend.DataResponse { +func (s *QueryData) fetch(ctx context.Context, client *client.Client, q *models.Query, enablePrometheusDataplane bool) *backend.DataResponse { traceCtx, end := s.trace(ctx, q) defer end() @@ -127,25 +123,29 @@ func (s *QueryData) fetch(ctx context.Context, client *client.Client, q *models. } if q.InstantQuery { - res := s.instantQuery(traceCtx, client, q, headers) + res := s.instantQuery(traceCtx, client, q, enablePrometheusDataplane) dr.Error = res.Error dr.Frames = res.Frames + dr.Status = res.Status } if q.RangeQuery { - res := s.rangeQuery(traceCtx, client, q, headers) + res := s.rangeQuery(traceCtx, client, q, enablePrometheusDataplane) if res.Error != nil { if dr.Error == nil { dr.Error = res.Error } else { dr.Error = fmt.Errorf("%v %w", dr.Error, res.Error) } + // When both instant and range are true, we may overwrite the status code. + // To fix this (and other things) they should come in separate http requests. + dr.Status = res.Status } dr.Frames = append(dr.Frames, res.Frames...) } if q.ExemplarQuery { - res := s.exemplarQuery(traceCtx, client, q, headers) + res := s.exemplarQuery(traceCtx, client, q, enablePrometheusDataplane) if res.Error != nil { // If exemplar query returns error, we want to only log it and // continue with other results processing @@ -157,11 +157,12 @@ func (s *QueryData) fetch(ctx context.Context, client *client.Client, q *models. return dr } -func (s *QueryData) rangeQuery(ctx context.Context, c *client.Client, q *models.Query, headers map[string]string) backend.DataResponse { +func (s *QueryData) rangeQuery(ctx context.Context, c *client.Client, q *models.Query, enablePrometheusDataplaneFlag bool) backend.DataResponse { res, err := c.QueryRange(ctx, q) if err != nil { return backend.DataResponse{ - Error: err, + Error: err, + Status: backend.StatusBadGateway, } } @@ -172,14 +173,15 @@ func (s *QueryData) rangeQuery(ctx context.Context, c *client.Client, q *models. } }() - return s.parseResponse(ctx, q, res) + return s.parseResponse(ctx, q, res, enablePrometheusDataplaneFlag) } -func (s *QueryData) instantQuery(ctx context.Context, c *client.Client, q *models.Query, headers map[string]string) backend.DataResponse { +func (s *QueryData) instantQuery(ctx context.Context, c *client.Client, q *models.Query, enablePrometheusDataplaneFlag bool) backend.DataResponse { res, err := c.QueryInstant(ctx, q) if err != nil { return backend.DataResponse{ - Error: err, + Error: err, + Status: backend.StatusBadGateway, } } @@ -197,10 +199,10 @@ func (s *QueryData) instantQuery(ctx context.Context, c *client.Client, q *model } }() - return s.parseResponse(ctx, q, res) + return s.parseResponse(ctx, q, res, enablePrometheusDataplaneFlag) } -func (s *QueryData) exemplarQuery(ctx context.Context, c *client.Client, q *models.Query, headers map[string]string) backend.DataResponse { +func (s *QueryData) exemplarQuery(ctx context.Context, c *client.Client, q *models.Query, enablePrometheusDataplaneFlag bool) backend.DataResponse { res, err := c.QueryExemplars(ctx, q) if err != nil { return backend.DataResponse{ @@ -214,7 +216,7 @@ func (s *QueryData) exemplarQuery(ctx context.Context, c *client.Client, q *mode s.log.Warn("Failed to close response body", "error", err) } }() - return s.parseResponse(ctx, q, res) + return s.parseResponse(ctx, q, res, enablePrometheusDataplaneFlag) } func (s *QueryData) trace(ctx context.Context, q *models.Query) (context.Context, func()) { diff --git a/pkg/tsdb/prometheus/querydata/request_test.go b/pkg/promlib/querydata/request_test.go similarity index 89% rename from pkg/tsdb/prometheus/querydata/request_test.go rename to pkg/promlib/querydata/request_test.go index 40d3d783b7630..3a1ac8986f953 100644 --- a/pkg/tsdb/prometheus/querydata/request_test.go +++ b/pkg/promlib/querydata/request_test.go @@ -15,20 +15,14 @@ import ( p "github.com/prometheus/common/model" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/tsdb/prometheus/kinds/dataquery" - - "github.com/grafana/kindsys" - "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/prometheus/client" - "github.com/grafana/grafana/pkg/tsdb/prometheus/models" - "github.com/grafana/grafana/pkg/tsdb/prometheus/querydata" + "github.com/grafana/grafana/pkg/promlib/client" + "github.com/grafana/grafana/pkg/promlib/models" + "github.com/grafana/grafana/pkg/promlib/querydata" ) func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { @@ -70,10 +64,10 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { require.NoError(t, err) qm := models.QueryModel{ - LegendFormat: "legend {{app}}", UtcOffsetSec: 0, - PrometheusDataQuery: dataquery.PrometheusDataQuery{ - Exemplar: kindsys.Ptr(true), + PrometheusQueryProperties: models.PrometheusQueryProperties{ + LegendFormat: "legend {{app}}", + Exemplar: true, }, } b, err := json.Marshal(&qm) @@ -117,10 +111,10 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { } qm := models.QueryModel{ - LegendFormat: "legend {{app}}", UtcOffsetSec: 0, - PrometheusDataQuery: dataquery.PrometheusDataQuery{ - Range: kindsys.Ptr(true), + PrometheusQueryProperties: models.PrometheusQueryProperties{ + Range: true, + LegendFormat: "legend {{app}}", }, } b, err := json.Marshal(&qm) @@ -166,10 +160,10 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { } qm := models.QueryModel{ - LegendFormat: "", UtcOffsetSec: 0, - PrometheusDataQuery: dataquery.PrometheusDataQuery{ - Range: kindsys.Ptr(true), + PrometheusQueryProperties: models.PrometheusQueryProperties{ + Range: true, + LegendFormat: "", }, } b, err := json.Marshal(&qm) @@ -211,10 +205,10 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { } qm := models.QueryModel{ - LegendFormat: "", UtcOffsetSec: 0, - PrometheusDataQuery: dataquery.PrometheusDataQuery{ - Range: kindsys.Ptr(true), + PrometheusQueryProperties: models.PrometheusQueryProperties{ + Range: true, + LegendFormat: "", }, } b, err := json.Marshal(&qm) @@ -254,10 +248,10 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { } qm := models.QueryModel{ - LegendFormat: "", UtcOffsetSec: 0, - PrometheusDataQuery: dataquery.PrometheusDataQuery{ - Range: kindsys.Ptr(true), + PrometheusQueryProperties: models.PrometheusQueryProperties{ + Range: true, + LegendFormat: "", }, } b, err := json.Marshal(&qm) @@ -291,10 +285,10 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { }, } qm := models.QueryModel{ - LegendFormat: "legend {{app}}", UtcOffsetSec: 0, - PrometheusDataQuery: dataquery.PrometheusDataQuery{ - Instant: kindsys.Ptr(true), + PrometheusQueryProperties: models.PrometheusQueryProperties{ + Instant: true, + LegendFormat: "legend {{app}}", }, } b, err := json.Marshal(&qm) @@ -332,10 +326,10 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { }, } qm := models.QueryModel{ - LegendFormat: "", UtcOffsetSec: 0, - PrometheusDataQuery: dataquery.PrometheusDataQuery{ - Instant: kindsys.Ptr(true), + PrometheusQueryProperties: models.PrometheusQueryProperties{ + Instant: true, + LegendFormat: "", }, } b, err := json.Marshal(&qm) @@ -442,8 +436,7 @@ func setup() (*testContext, error) { JSONData: json.RawMessage(`{"timeInterval": "15s"}`), } - features := featuremgmt.WithFeatures() - opts, err := client.CreateTransportOptions(context.Background(), settings, &setting.Cfg{}, log.New()) + opts, err := client.CreateTransportOptions(context.Background(), settings, log.New()) if err != nil { return nil, err } @@ -453,7 +446,7 @@ func setup() (*testContext, error) { return nil, err } - queryData, _ := querydata.New(httpClient, features, settings, log.New()) + queryData, _ := querydata.New(httpClient, settings, log.New()) return &testContext{ httpProvider: httpProvider, diff --git a/pkg/tsdb/prometheus/querydata/response.go b/pkg/promlib/querydata/response.go similarity index 91% rename from pkg/tsdb/prometheus/querydata/response.go rename to pkg/promlib/querydata/response.go index 18b0890b04747..7181400df1680 100644 --- a/pkg/tsdb/prometheus/querydata/response.go +++ b/pkg/promlib/querydata/response.go @@ -12,13 +12,13 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" jsoniter "github.com/json-iterator/go" - "github.com/grafana/grafana/pkg/tsdb/prometheus/models" - "github.com/grafana/grafana/pkg/tsdb/prometheus/querydata/exemplar" - "github.com/grafana/grafana/pkg/tsdb/prometheus/utils" - "github.com/grafana/grafana/pkg/util/converter" + "github.com/grafana/grafana/pkg/promlib/converter" + "github.com/grafana/grafana/pkg/promlib/models" + "github.com/grafana/grafana/pkg/promlib/querydata/exemplar" + "github.com/grafana/grafana/pkg/promlib/utils" ) -func (s *QueryData) parseResponse(ctx context.Context, q *models.Query, res *http.Response) backend.DataResponse { +func (s *QueryData) parseResponse(ctx context.Context, q *models.Query, res *http.Response, enablePrometheusDataplaneFlag bool) backend.DataResponse { defer func() { if err := res.Body.Close(); err != nil { s.log.FromContext(ctx).Error("Failed to close response body", "err", err) @@ -30,8 +30,9 @@ func (s *QueryData) parseResponse(ctx context.Context, q *models.Query, res *htt iter := jsoniter.Parse(jsoniter.ConfigDefault, res.Body, 1024) r := converter.ReadPrometheusStyleResult(iter, converter.Options{ - Dataplane: s.enableDataplane, + Dataplane: enablePrometheusDataplaneFlag, }) + r.Status = backend.Status(res.StatusCode) // Add frame to attach metadata if len(r.Frames) == 0 && !q.ExemplarQuery { @@ -40,7 +41,7 @@ func (s *QueryData) parseResponse(ctx context.Context, q *models.Query, res *htt // The ExecutedQueryString can be viewed in QueryInspector in UI for i, frame := range r.Frames { - addMetadataToMultiFrame(q, frame, s.enableDataplane) + addMetadataToMultiFrame(q, frame, enablePrometheusDataplaneFlag) if i == 0 { frame.Meta.ExecutedQueryString = executedQueryString(q) } diff --git a/pkg/tsdb/prometheus/querydata/response_test.go b/pkg/promlib/querydata/response_test.go similarity index 94% rename from pkg/tsdb/prometheus/querydata/response_test.go rename to pkg/promlib/querydata/response_test.go index e449f3610d8d1..8cec7ba460aab 100644 --- a/pkg/tsdb/prometheus/querydata/response_test.go +++ b/pkg/promlib/querydata/response_test.go @@ -9,8 +9,8 @@ import ( "github.com/stretchr/testify/assert" - "github.com/grafana/grafana/pkg/tsdb/prometheus/models" - "github.com/grafana/grafana/pkg/tsdb/prometheus/querydata/exemplar" + "github.com/grafana/grafana/pkg/promlib/models" + "github.com/grafana/grafana/pkg/promlib/querydata/exemplar" ) func TestQueryData_parseResponse(t *testing.T) { @@ -19,7 +19,7 @@ func TestQueryData_parseResponse(t *testing.T) { t.Run("resultType is before result the field must parsed normally", func(t *testing.T) { resBody := `{"data":{"resultType":"vector", "result":[{"metric":{"__name__":"some_name","environment":"some_env","id":"some_id","instance":"some_instance:1234","job":"some_job","name":"another_name","region":"some_region"},"value":[1.1,"2"]}]},"status":"success"}` res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody))} - result := qd.parseResponse(context.Background(), &models.Query{}, res) + result := qd.parseResponse(context.Background(), &models.Query{}, res, false) assert.Nil(t, result.Error) assert.Len(t, result.Frames, 1) }) @@ -27,7 +27,7 @@ func TestQueryData_parseResponse(t *testing.T) { t.Run("resultType is after the result field must parsed normally", func(t *testing.T) { resBody := `{"data":{"result":[{"metric":{"__name__":"some_name","environment":"some_env","id":"some_id","instance":"some_instance:1234","job":"some_job","name":"another_name","region":"some_region"},"value":[1.1,"2"]}],"resultType":"vector"},"status":"success"}` res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody))} - result := qd.parseResponse(context.Background(), &models.Query{}, res) + result := qd.parseResponse(context.Background(), &models.Query{}, res, false) assert.Nil(t, result.Error) assert.Len(t, result.Frames, 1) }) @@ -35,7 +35,7 @@ func TestQueryData_parseResponse(t *testing.T) { t.Run("no resultType is existed in the data", func(t *testing.T) { resBody := `{"data":{"result":[{"metric":{"__name__":"some_name","environment":"some_env","id":"some_id","instance":"some_instance:1234","job":"some_job","name":"another_name","region":"some_region"},"value":[1.1,"2"]}]},"status":"success"}` res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody))} - result := qd.parseResponse(context.Background(), &models.Query{}, res) + result := qd.parseResponse(context.Background(), &models.Query{}, res, false) assert.Error(t, result.Error) assert.Equal(t, result.Error.Error(), "no resultType found") }) @@ -43,7 +43,7 @@ func TestQueryData_parseResponse(t *testing.T) { t.Run("resultType is set as empty string before result", func(t *testing.T) { resBody := `{"data":{"resultType":"", "result":[{"metric":{"__name__":"some_name","environment":"some_env","id":"some_id","instance":"some_instance:1234","job":"some_job","name":"another_name","region":"some_region"},"value":[1.1,"2"]}]},"status":"success"}` res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody))} - result := qd.parseResponse(context.Background(), &models.Query{}, res) + result := qd.parseResponse(context.Background(), &models.Query{}, res, false) assert.Error(t, result.Error) assert.Equal(t, result.Error.Error(), "unknown result type: ") }) @@ -51,7 +51,7 @@ func TestQueryData_parseResponse(t *testing.T) { t.Run("resultType is set as empty string after result", func(t *testing.T) { resBody := `{"data":{"result":[{"metric":{"__name__":"some_name","environment":"some_env","id":"some_id","instance":"some_instance:1234","job":"some_job","name":"another_name","region":"some_region"},"value":[1.1,"2"]}],"resultType":""},"status":"success"}` res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody))} - result := qd.parseResponse(context.Background(), &models.Query{}, res) + result := qd.parseResponse(context.Background(), &models.Query{}, res, false) assert.Error(t, result.Error) assert.Equal(t, result.Error.Error(), "unknown result type: ") }) diff --git a/pkg/tsdb/prometheus/resource/resource.go b/pkg/promlib/resource/resource.go similarity index 92% rename from pkg/tsdb/prometheus/resource/resource.go rename to pkg/promlib/resource/resource.go index 1c5223c361731..888267d413834 100644 --- a/pkg/tsdb/prometheus/resource/resource.go +++ b/pkg/promlib/resource/resource.go @@ -8,9 +8,10 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/tsdb/prometheus/client" - "github.com/grafana/grafana/pkg/tsdb/prometheus/utils" - "github.com/grafana/grafana/pkg/util/maputil" + "github.com/grafana/grafana-plugin-sdk-go/data/utils/maputil" + + "github.com/grafana/grafana/pkg/promlib/client" + "github.com/grafana/grafana/pkg/promlib/utils" ) type Resource struct { diff --git a/pkg/tsdb/prometheus/testdata/exemplar.query.json b/pkg/promlib/testdata/exemplar.query.json similarity index 100% rename from pkg/tsdb/prometheus/testdata/exemplar.query.json rename to pkg/promlib/testdata/exemplar.query.json diff --git a/pkg/tsdb/prometheus/testdata/exemplar.result.golden.jsonc b/pkg/promlib/testdata/exemplar.result.golden.jsonc similarity index 100% rename from pkg/tsdb/prometheus/testdata/exemplar.result.golden.jsonc rename to pkg/promlib/testdata/exemplar.result.golden.jsonc diff --git a/pkg/tsdb/prometheus/testdata/exemplar.result.json b/pkg/promlib/testdata/exemplar.result.json similarity index 100% rename from pkg/tsdb/prometheus/testdata/exemplar.result.json rename to pkg/promlib/testdata/exemplar.result.json diff --git a/pkg/tsdb/prometheus/testdata/range_auto.query.json b/pkg/promlib/testdata/range_auto.query.json similarity index 100% rename from pkg/tsdb/prometheus/testdata/range_auto.query.json rename to pkg/promlib/testdata/range_auto.query.json diff --git a/pkg/tsdb/prometheus/testdata/range_auto.result.golden.jsonc b/pkg/promlib/testdata/range_auto.result.golden.jsonc similarity index 100% rename from pkg/tsdb/prometheus/testdata/range_auto.result.golden.jsonc rename to pkg/promlib/testdata/range_auto.result.golden.jsonc diff --git a/pkg/tsdb/prometheus/testdata/range_auto.result.json b/pkg/promlib/testdata/range_auto.result.json similarity index 100% rename from pkg/tsdb/prometheus/testdata/range_auto.result.json rename to pkg/promlib/testdata/range_auto.result.json diff --git a/pkg/tsdb/prometheus/testdata/range_infinity.query.json b/pkg/promlib/testdata/range_infinity.query.json similarity index 100% rename from pkg/tsdb/prometheus/testdata/range_infinity.query.json rename to pkg/promlib/testdata/range_infinity.query.json diff --git a/pkg/tsdb/prometheus/testdata/range_infinity.result.golden.jsonc b/pkg/promlib/testdata/range_infinity.result.golden.jsonc similarity index 100% rename from pkg/tsdb/prometheus/testdata/range_infinity.result.golden.jsonc rename to pkg/promlib/testdata/range_infinity.result.golden.jsonc diff --git a/pkg/tsdb/prometheus/testdata/range_infinity.result.json b/pkg/promlib/testdata/range_infinity.result.json similarity index 100% rename from pkg/tsdb/prometheus/testdata/range_infinity.result.json rename to pkg/promlib/testdata/range_infinity.result.json diff --git a/pkg/tsdb/prometheus/testdata/range_missing.query.json b/pkg/promlib/testdata/range_missing.query.json similarity index 100% rename from pkg/tsdb/prometheus/testdata/range_missing.query.json rename to pkg/promlib/testdata/range_missing.query.json diff --git a/pkg/tsdb/prometheus/testdata/range_missing.result.golden.jsonc b/pkg/promlib/testdata/range_missing.result.golden.jsonc similarity index 100% rename from pkg/tsdb/prometheus/testdata/range_missing.result.golden.jsonc rename to pkg/promlib/testdata/range_missing.result.golden.jsonc diff --git a/pkg/tsdb/prometheus/testdata/range_missing.result.json b/pkg/promlib/testdata/range_missing.result.json similarity index 100% rename from pkg/tsdb/prometheus/testdata/range_missing.result.json rename to pkg/promlib/testdata/range_missing.result.json diff --git a/pkg/tsdb/prometheus/testdata/range_nan.query.json b/pkg/promlib/testdata/range_nan.query.json similarity index 100% rename from pkg/tsdb/prometheus/testdata/range_nan.query.json rename to pkg/promlib/testdata/range_nan.query.json diff --git a/pkg/tsdb/prometheus/testdata/range_nan.result.golden.jsonc b/pkg/promlib/testdata/range_nan.result.golden.jsonc similarity index 100% rename from pkg/tsdb/prometheus/testdata/range_nan.result.golden.jsonc rename to pkg/promlib/testdata/range_nan.result.golden.jsonc diff --git a/pkg/tsdb/prometheus/testdata/range_nan.result.json b/pkg/promlib/testdata/range_nan.result.json similarity index 100% rename from pkg/tsdb/prometheus/testdata/range_nan.result.json rename to pkg/promlib/testdata/range_nan.result.json diff --git a/pkg/tsdb/prometheus/testdata/range_simple.query.json b/pkg/promlib/testdata/range_simple.query.json similarity index 100% rename from pkg/tsdb/prometheus/testdata/range_simple.query.json rename to pkg/promlib/testdata/range_simple.query.json diff --git a/pkg/tsdb/prometheus/testdata/range_simple.result.golden.jsonc b/pkg/promlib/testdata/range_simple.result.golden.jsonc similarity index 100% rename from pkg/tsdb/prometheus/testdata/range_simple.result.golden.jsonc rename to pkg/promlib/testdata/range_simple.result.golden.jsonc diff --git a/pkg/tsdb/prometheus/testdata/range_simple.result.json b/pkg/promlib/testdata/range_simple.result.json similarity index 100% rename from pkg/tsdb/prometheus/testdata/range_simple.result.json rename to pkg/promlib/testdata/range_simple.result.json diff --git a/pkg/tsdb/prometheus/utils/utils.go b/pkg/promlib/utils/utils.go similarity index 100% rename from pkg/tsdb/prometheus/utils/utils.go rename to pkg/promlib/utils/utils.go diff --git a/pkg/registry/apis/apis.go b/pkg/registry/apis/apis.go index 82b15a4f11229..90b7de087550b 100644 --- a/pkg/registry/apis/apis.go +++ b/pkg/registry/apis/apis.go @@ -4,9 +4,16 @@ import ( "context" "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/registry/apis/dashboard" + "github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot" + "github.com/grafana/grafana/pkg/registry/apis/datasource" "github.com/grafana/grafana/pkg/registry/apis/example" + "github.com/grafana/grafana/pkg/registry/apis/featuretoggle" "github.com/grafana/grafana/pkg/registry/apis/folders" + "github.com/grafana/grafana/pkg/registry/apis/peakq" "github.com/grafana/grafana/pkg/registry/apis/playlist" + "github.com/grafana/grafana/pkg/registry/apis/query" + "github.com/grafana/grafana/pkg/registry/apis/scope" ) var ( @@ -18,9 +25,16 @@ type Service struct{} // ProvideRegistryServiceSink is an entry point for each service that will force initialization // and give each builder the chance to register itself with the main server func ProvideRegistryServiceSink( + _ *dashboard.DashboardsAPIBuilder, _ *playlist.PlaylistAPIBuilder, _ *example.TestingAPIBuilder, + _ *dashboardsnapshot.SnapshotsAPIBuilder, + _ *featuretoggle.FeatureFlagAPIBuilder, + _ *datasource.DataSourceAPIBuilder, _ *folders.FolderAPIBuilder, + _ *peakq.PeakQAPIBuilder, + _ *scope.ScopeAPIBuilder, + _ *query.QueryAPIBuilder, ) *Service { return &Service{} } diff --git a/pkg/registry/apis/dashboard/access/sql_dashboards.go b/pkg/registry/apis/dashboard/access/sql_dashboards.go new file mode 100644 index 0000000000000..4064a1fa7a3ea --- /dev/null +++ b/pkg/registry/apis/dashboard/access/sql_dashboards.go @@ -0,0 +1,427 @@ +package access + +import ( + "context" + "database/sql" + "fmt" + "path/filepath" + "time" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + dashboardsV0 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/utils" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/provisioning" + "github.com/grafana/grafana/pkg/services/sqlstore/session" +) + +var ( + _ DashboardAccess = (*dashboardSqlAccess)(nil) +) + +type dashboardRow struct { + // Dashboard resource + Dash *dashboardsV0.Dashboard + + // Title -- this may come from saved metadata rather than the body + Title string + + // The folder UID (needed for access control checks) + FolderUID string + + // Needed for fast summary access + Tags []string + + // Size (in bytes) of the dashboard payload + Bytes int + + // The token we can use that will start a new connection that includes + // this same dashboard + token *continueToken +} + +type dashboardSqlAccess struct { + sql db.DB + sess *session.SessionDB + namespacer request.NamespaceMapper + dashStore dashboards.Store + provisioning provisioning.ProvisioningService +} + +func NewDashboardAccess(sql db.DB, namespacer request.NamespaceMapper, dashStore dashboards.Store, provisioning provisioning.ProvisioningService) DashboardAccess { + return &dashboardSqlAccess{ + sql: sql, + sess: sql.GetSqlxSession(), + namespacer: namespacer, + dashStore: dashStore, + provisioning: provisioning, + } +} + +const selector = `SELECT + dashboard.org_id, dashboard.id, + dashboard.uid,slug, + dashboard.folder_uid, + dashboard.created,dashboard.created_by,CreatedUSER.login, + dashboard.updated,dashboard.updated_by,UpdatedUSER.login, + plugin_id, + dashboard_provisioning.name as origin_name, + dashboard_provisioning.external_id as origin_path, + dashboard_provisioning.check_sum as origin_key, + dashboard_provisioning.updated as origin_ts, + dashboard.version, + title, + dashboard.data + FROM dashboard + LEFT OUTER JOIN dashboard_provisioning ON dashboard.id = dashboard_provisioning.dashboard_id + LEFT OUTER JOIN user AS CreatedUSER ON dashboard.created_by = CreatedUSER.id + LEFT OUTER JOIN user AS UpdatedUSER ON dashboard.created_by = UpdatedUSER.id + WHERE is_folder = false` + +// GetDashboards implements DashboardAccess. +func (a *dashboardSqlAccess) GetDashboards(ctx context.Context, query *DashboardQuery) (*dashboardsV0.DashboardList, error) { + token, err := readContinueToken(query) + if err != nil { + return nil, err + } + + limit := query.Limit + if limit < 1 { + limit = 15 // + } + + rows, err := a.doQuery(ctx, selector+` + AND dashboard.org_id=$1 + AND dashboard.id>=$2 + ORDER BY dashboard.id asc + LIMIT $3 + `, query.OrgID, token.id, (limit + 2)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + totalSize := 0 + list := &dashboardsV0.DashboardList{} + if err != nil { + return nil, err + } + for { + row, err := rows.Next() + if err != nil || row == nil { + return list, err + } + + totalSize += row.Bytes + if len(list.Items) > 0 && (totalSize > query.MaxBytes || len(list.Items) >= limit) { + row.token.folder = query.FolderUID + list.Continue = row.token.String() // will skip this one but start here next time + return list, err + } + list.Items = append(list.Items, *row.Dash) + } +} + +func (a *dashboardSqlAccess) GetDashboard(ctx context.Context, orgId int64, uid string) (*dashboardsV0.Dashboard, error) { + r, err := a.GetDashboards(ctx, &DashboardQuery{ + OrgID: orgId, + UID: uid, + }) + if err != nil { + return nil, err + } + if len(r.Items) > 0 { + return &r.Items[0], nil + } + return nil, fmt.Errorf("not found") +} + +// GetDashboards implements DashboardAccess. +func (a *dashboardSqlAccess) GetDashboardSummaries(ctx context.Context, query *DashboardQuery) (*dashboardsV0.DashboardSummaryList, error) { + token, err := readContinueToken(query) + if err != nil { + return nil, err + } + limit := query.Limit + if limit < 1 { + limit = 15 // + } + rows, err := a.doQuery(ctx, selector+` + AND dashboard.org_id=$1 + AND dashboard.id>=$2 + ORDER BY dashboard.id asc + LIMIT $3 + `, query.OrgID, token.id, (limit + 2)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + totalSize := 0 + list := &dashboardsV0.DashboardSummaryList{} + if err != nil { + return nil, err + } + for { + row, err := rows.Next() + if err != nil || row == nil { + return list, err + } + + totalSize += row.Bytes + if len(list.Items) > 0 && (totalSize > query.MaxBytes || len(list.Items) >= limit) { + row.token.folder = query.FolderUID + list.Continue = row.token.String() // will skip this one but start here next time + return list, err + } + list.Items = append(list.Items, toSummary(row)) + } +} + +func (a *dashboardSqlAccess) GetDashboardSummary(ctx context.Context, orgId int64, uid string) (*dashboardsV0.DashboardSummary, error) { + r, err := a.GetDashboardSummaries(ctx, &DashboardQuery{ + OrgID: orgId, + UID: uid, + }) + if err != nil { + return nil, err + } + if len(r.Items) > 0 { + return &r.Items[0], nil + } + return nil, fmt.Errorf("not found") +} + +func toSummary(row *dashboardRow) dashboardsV0.DashboardSummary { + return dashboardsV0.DashboardSummary{ + ObjectMeta: row.Dash.ObjectMeta, + Spec: dashboardsV0.DashboardSummarySpec{ + Title: row.Title, + Tags: row.Tags, + }, + } +} + +func (a *dashboardSqlAccess) doQuery(ctx context.Context, query string, args ...any) (*rowsWrapper, error) { + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } + rows, err := a.sess.Query(ctx, query, args...) + return &rowsWrapper{ + rows: rows, + a: a, + // This looks up rules from the permissions on a user + canReadDashboard: accesscontrol.Checker(user, dashboards.ActionDashboardsRead), + }, err +} + +type rowsWrapper struct { + a *dashboardSqlAccess + rows *sql.Rows + idx int + total int64 + + canReadDashboard func(scopes ...string) bool +} + +func (r *rowsWrapper) Close() error { + return r.rows.Close() +} + +func (r *rowsWrapper) Next() (*dashboardRow, error) { + // breaks after first readable value + for r.rows.Next() { + r.idx++ + d, err := r.a.scanRow(r.rows) + if d != nil { + // Access control checker + scopes := []string{dashboards.ScopeDashboardsProvider.GetResourceScopeUID(d.Dash.Name)} + if d.FolderUID != "" { // Copied from searchV2... not sure the logic is right + scopes = append(scopes, dashboards.ScopeFoldersProvider.GetResourceScopeUID(d.FolderUID)) + } + if !r.canReadDashboard(scopes...) { + continue + } + d.token.size = r.total // size before next! + r.total += int64(d.Bytes) + } + + // returns the first folder it can + return d, err + } + return nil, nil +} + +func (a *dashboardSqlAccess) scanRow(rows *sql.Rows) (*dashboardRow, error) { + dash := &dashboardsV0.Dashboard{ + TypeMeta: dashboardsV0.DashboardResourceInfo.TypeMeta(), + ObjectMeta: v1.ObjectMeta{Annotations: make(map[string]string)}, + } + row := &dashboardRow{Dash: dash} + + var dashboard_id int64 + var orgId int64 + var slug string + var folder_uid sql.NullString + var updated time.Time + var updatedByID int64 + var updatedByName sql.NullString + + var created time.Time + var createdByID int64 + var createdByName sql.NullString + + var plugin_id string + var origin_name sql.NullString + var origin_path sql.NullString + var origin_ts sql.NullInt64 + var origin_key sql.NullString + var data []byte // the dashboard JSON + var version int64 + + err := rows.Scan(&orgId, &dashboard_id, &dash.Name, + &slug, &folder_uid, + &created, &createdByID, &createdByName, + &updated, &updatedByID, &updatedByName, + &plugin_id, + &origin_name, &origin_path, &origin_key, &origin_ts, + &version, + &row.Title, &data, + ) + + row.token = &continueToken{orgId: orgId, id: dashboard_id} + if err == nil { + dash.ResourceVersion = fmt.Sprintf("%d", created.UnixMilli()) + dash.Namespace = a.namespacer(orgId) + dash.UID = utils.CalculateClusterWideUID(dash) + dash.SetCreationTimestamp(v1.NewTime(created)) + meta, err := utils.MetaAccessor(dash) + if err != nil { + return nil, err + } + meta.SetUpdatedTimestamp(&updated) + meta.SetSlug(slug) + if createdByID > 0 { + meta.SetCreatedBy(fmt.Sprintf("user:%d/%s", createdByID, createdByName.String)) + } + if updatedByID > 0 { + meta.SetUpdatedBy(fmt.Sprintf("user:%d/%s", updatedByID, updatedByName.String)) + } + if folder_uid.Valid { + meta.SetFolder(folder_uid.String) + row.FolderUID = folder_uid.String + } + + if origin_name.Valid { + ts := time.Unix(origin_ts.Int64, 0) + + originPath, err := filepath.Rel( + a.provisioning.GetDashboardProvisionerResolvedPath(origin_name.String), + origin_path.String, + ) + if err != nil { + return nil, err + } + + meta.SetOriginInfo(&utils.ResourceOriginInfo{ + Name: origin_name.String, + Path: originPath, + Key: origin_key.String, + Timestamp: &ts, + }) + } else if plugin_id != "" { + meta.SetOriginInfo(&utils.ResourceOriginInfo{ + Name: "plugin", + Path: plugin_id, + }) + } + + row.Bytes = len(data) + if row.Bytes > 0 { + err = dash.Spec.UnmarshalJSON(data) + if err != nil { + return row, err + } + dash.Spec.Set("id", dashboard_id) // add it so we can get it from the body later + row.Title = dash.Spec.GetNestedString("title") + row.Tags = dash.Spec.GetNestedStringSlice("tags") + } + } + return row, err +} + +// DeleteDashboard implements DashboardAccess. +func (a *dashboardSqlAccess) DeleteDashboard(ctx context.Context, orgId int64, uid string) (*dashboardsV0.Dashboard, bool, error) { + dash, err := a.GetDashboard(ctx, orgId, uid) + if err != nil { + return nil, false, err + } + + id := dash.Spec.GetNestedInt64("id") + if id == 0 { + return nil, false, fmt.Errorf("could not find id in saved body") + } + + err = a.dashStore.DeleteDashboard(ctx, &dashboards.DeleteDashboardCommand{ + OrgID: orgId, + ID: id, + }) + if err != nil { + return nil, false, err + } + return dash, true, nil +} + +// SaveDashboard implements DashboardAccess. +func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, dash *dashboardsV0.Dashboard) (*dashboardsV0.Dashboard, bool, error) { + created := false + user, err := appcontext.User(ctx) + if err != nil { + return nil, created, err + } + if dash.Name != "" { + dash.Spec.Set("uid", dash.Name) + + // Get the previous version to set the internal ID + old, _ := a.dashStore.GetDashboard(ctx, &dashboards.GetDashboardQuery{ + OrgID: orgId, + UID: dash.Name, + }) + if old != nil { + dash.Spec.Set("id", old.ID) + } else { + dash.Spec.Remove("id") // existing of "id" makes it an update + created = true + } + } else { + dash.Spec.Remove("id") + dash.Spec.Remove("uid") + } + + meta, err := utils.MetaAccessor(dash) + if err != nil { + return nil, false, err + } + out, err := a.dashStore.SaveDashboard(ctx, dashboards.SaveDashboardCommand{ + OrgID: orgId, + Dashboard: simplejson.NewFromAny(dash.Spec.UnstructuredContent()), + FolderUID: meta.GetFolder(), + Overwrite: true, // already passed the revisionVersion checks! + UserID: user.UserID, + }) + if err != nil { + return nil, false, err + } + if out != nil { + created = (out.Created.Unix() == out.Updated.Unix()) // and now? + } + dash, err = a.GetDashboard(ctx, orgId, out.UID) + return dash, created, err +} diff --git a/pkg/registry/apis/dashboard/access/token.go b/pkg/registry/apis/dashboard/access/token.go new file mode 100644 index 0000000000000..0805adb89132a --- /dev/null +++ b/pkg/registry/apis/dashboard/access/token.go @@ -0,0 +1,63 @@ +package access + +import ( + "fmt" + "strconv" + "strings" + + "github.com/grafana/grafana/pkg/util" +) + +type continueToken struct { + orgId int64 + id int64 // the internal id (sort by!) + folder string // from the query + size int64 +} + +func readContinueToken(q *DashboardQuery) (continueToken, error) { + var err error + token := continueToken{} + if q.ContinueToken == "" { + return token, nil + } + parts := strings.Split(q.ContinueToken, "/") + if len(parts) < 3 { + return token, fmt.Errorf("invalid continue token (too few parts)") + } + sub := strings.Split(parts[0], ":") + if sub[0] != "org" { + return token, fmt.Errorf("expected org in first slug") + } + token.orgId, err = strconv.ParseInt(sub[1], 10, 64) + if err != nil { + return token, fmt.Errorf("error parsing orgid") + } + + sub = strings.Split(parts[1], ":") + if sub[0] != "start" { + return token, fmt.Errorf("expected internal ID in second slug") + } + token.id, err = strconv.ParseInt(sub[1], 10, 64) + if err != nil { + return token, fmt.Errorf("error parsing updated") + } + + sub = strings.Split(parts[2], ":") + if sub[0] != "folder" { + return token, fmt.Errorf("expected folder UID in third slug") + } + token.folder = sub[1] + + // Check if the folder filter is the same from the previous query + if token.folder != q.FolderUID { + return token, fmt.Errorf("invalid token, the folder must match previous query") + } + + return token, err +} + +func (r *continueToken) String() string { + return fmt.Sprintf("org:%d/start:%d/folder:%s/%s", + r.orgId, r.id, r.folder, util.ByteCountSI(r.size)) +} diff --git a/pkg/registry/apis/dashboard/access/types.go b/pkg/registry/apis/dashboard/access/types.go new file mode 100644 index 0000000000000..1df421caf6d7b --- /dev/null +++ b/pkg/registry/apis/dashboard/access/types.go @@ -0,0 +1,31 @@ +package access + +import ( + "context" + + dashboardsV0 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" +) + +// This does not check if you have permissions! + +type DashboardQuery struct { + OrgID int64 + UID string // to select a single dashboard + FolderUID string + Limit int + MaxBytes int + + // The token from previous query + ContinueToken string +} + +type DashboardAccess interface { + GetDashboard(ctx context.Context, orgId int64, uid string) (*dashboardsV0.Dashboard, error) + GetDashboards(ctx context.Context, query *DashboardQuery) (*dashboardsV0.DashboardList, error) + + GetDashboardSummary(ctx context.Context, orgId int64, uid string) (*dashboardsV0.DashboardSummary, error) + GetDashboardSummaries(ctx context.Context, query *DashboardQuery) (*dashboardsV0.DashboardSummaryList, error) + + SaveDashboard(ctx context.Context, orgId int64, dash *dashboardsV0.Dashboard) (*dashboardsV0.Dashboard, bool, error) + DeleteDashboard(ctx context.Context, orgId int64, uid string) (*dashboardsV0.Dashboard, bool, error) +} diff --git a/pkg/registry/apis/dashboard/authorizer.go b/pkg/registry/apis/dashboard/authorizer.go new file mode 100644 index 0000000000000..54a772591d5f1 --- /dev/null +++ b/pkg/registry/apis/dashboard/authorizer.go @@ -0,0 +1,95 @@ +package dashboard + +import ( + "context" + + "k8s.io/apiserver/pkg/authorization/authorizer" + + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/guardian" +) + +func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer { + return authorizer.AuthorizerFunc( + func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { + if !attr.IsResourceRequest() { + return authorizer.DecisionNoOpinion, "", nil + } + + user, err := appcontext.User(ctx) + if err != nil { + return authorizer.DecisionDeny, "", err + } + + if attr.GetName() == "" { + // Discourage use of the "list" command for non super admin users + if attr.GetVerb() == "list" && attr.GetResource() == v0alpha1.DashboardResourceInfo.GroupResource().Resource { + if !user.IsGrafanaAdmin { + return authorizer.DecisionDeny, "list summary objects (or connect as GrafanaAdmin)", err + } + } + return authorizer.DecisionNoOpinion, "", nil + } + + ns := attr.GetNamespace() + if ns == "" { + return authorizer.DecisionDeny, "expected namespace", nil + } + + info, err := request.ParseNamespace(attr.GetNamespace()) + if err != nil { + return authorizer.DecisionDeny, "error reading org from namespace", err + } + + // expensive path to lookup permissions for a single dashboard + dto, err := b.dashboardService.GetDashboard(ctx, &dashboards.GetDashboardQuery{ + UID: attr.GetName(), + OrgID: info.OrgID, + }) + if err != nil { + return authorizer.DecisionDeny, "error loading dashboard", err + } + + ok := false + guardian, err := guardian.NewByDashboard(ctx, dto, info.OrgID, user) + if err != nil { + return authorizer.DecisionDeny, "", err + } + + switch attr.GetVerb() { + case "get": + ok, err = guardian.CanView() + if !ok || err != nil { + return authorizer.DecisionDeny, "can not view dashboard", err + } + case "create": + fallthrough + case "post": + ok, err = guardian.CanSave() // vs Edit? + if !ok || err != nil { + return authorizer.DecisionDeny, "can not save dashboard", err + } + case "update": + fallthrough + case "patch": + fallthrough + case "put": + ok, err = guardian.CanEdit() // vs Save + if !ok || err != nil { + return authorizer.DecisionDeny, "can not edit dashboard", err + } + case "delete": + ok, err = guardian.CanDelete() + if !ok || err != nil { + return authorizer.DecisionDeny, "can not delete dashboard", err + } + default: + b.log.Info("unknown verb", "verb", attr.GetVerb()) + return authorizer.DecisionNoOpinion, "unsupported verb", nil // Unknown verb + } + return authorizer.DecisionAllow, "", nil + }) +} diff --git a/pkg/registry/apis/dashboard/legacy_storage.go b/pkg/registry/apis/dashboard/legacy_storage.go new file mode 100644 index 0000000000000..20e5193b240a3 --- /dev/null +++ b/pkg/registry/apis/dashboard/legacy_storage.go @@ -0,0 +1,155 @@ +package dashboard + +import ( + "context" + "fmt" + "strings" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/access" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" +) + +var ( + _ rest.Storage = (*dashboardStorage)(nil) + _ rest.Scoper = (*dashboardStorage)(nil) + _ rest.SingularNameProvider = (*dashboardStorage)(nil) + _ rest.Getter = (*dashboardStorage)(nil) + _ rest.Lister = (*dashboardStorage)(nil) + _ rest.Creater = (*dashboardStorage)(nil) + _ rest.Updater = (*dashboardStorage)(nil) + _ rest.GracefulDeleter = (*dashboardStorage)(nil) +) + +type dashboardStorage struct { + resource common.ResourceInfo + access access.DashboardAccess + tableConverter rest.TableConvertor +} + +func (s *dashboardStorage) New() runtime.Object { + return s.resource.NewFunc() +} + +func (s *dashboardStorage) Destroy() {} + +func (s *dashboardStorage) NamespaceScoped() bool { + return true +} + +func (s *dashboardStorage) GetSingularName() string { + return s.resource.GetSingularName() +} + +func (s *dashboardStorage) NewList() runtime.Object { + return s.resource.NewListFunc() +} + +func (s *dashboardStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return s.tableConverter.ConvertToTable(ctx, object, tableOptions) +} + +func (s *dashboardStorage) Create(ctx context.Context, + obj runtime.Object, + createValidation rest.ValidateObjectFunc, + options *metav1.CreateOptions, +) (runtime.Object, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + p, ok := obj.(*v0alpha1.Dashboard) + if !ok { + return nil, fmt.Errorf("expected dashboard?") + } + + // HACK to simplify unique name testing from kubectl + t := p.Spec.GetNestedString("title") + if strings.Contains(t, "${NOW}") { + t = strings.ReplaceAll(t, "${NOW}", fmt.Sprintf("%d", time.Now().Unix())) + p.Spec.Set("title", t) + } + + dash, _, err := s.access.SaveDashboard(ctx, info.OrgID, p) + return dash, err +} + +func (s *dashboardStorage) Update(ctx context.Context, + name string, + objInfo rest.UpdatedObjectInfo, + createValidation rest.ValidateObjectFunc, + updateValidation rest.ValidateObjectUpdateFunc, + forceAllowCreate bool, + options *metav1.UpdateOptions, +) (runtime.Object, bool, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, false, err + } + + created := false + old, err := s.Get(ctx, name, nil) + if err != nil { + return old, created, err + } + + obj, err := objInfo.UpdatedObject(ctx, old) + if err != nil { + return old, created, err + } + p, ok := obj.(*v0alpha1.Dashboard) + if !ok { + return nil, created, fmt.Errorf("expected dashboard after update") + } + + _, created, err = s.access.SaveDashboard(ctx, info.OrgID, p) + if err == nil { + r, err := s.Get(ctx, name, nil) + return r, created, err + } + return nil, created, err +} + +// GracefulDeleter +func (s *dashboardStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, false, err + } + + return s.access.DeleteDashboard(ctx, info.OrgID, name) +} + +func (s *dashboardStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + orgId, err := request.OrgIDForList(ctx) + if err != nil { + return nil, err + } + + // fmt.Printf("LIST: %s\n", options.Continue) + + query := &access.DashboardQuery{ + OrgID: orgId, + Limit: int(options.Limit), + MaxBytes: 2 * 1024 * 1024, // 2MB, + ContinueToken: options.Continue, + } + return s.access.GetDashboards(ctx, query) +} + +func (s *dashboardStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + return s.access.GetDashboard(ctx, info.OrgID, name) +} diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go new file mode 100644 index 0000000000000..98b928a560f31 --- /dev/null +++ b/pkg/registry/apis/dashboard/register.go @@ -0,0 +1,222 @@ +package dashboard + +import ( + "fmt" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apiserver/pkg/registry/generic" + genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" + "k8s.io/apiserver/pkg/registry/rest" + genericapiserver "k8s.io/apiserver/pkg/server" + common "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/spec3" + + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" + "github.com/grafana/grafana/pkg/apiserver/builder" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/access" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/utils" + "github.com/grafana/grafana/pkg/services/dashboards" + dashver "github.com/grafana/grafana/pkg/services/dashboardversion" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/provisioning" + "github.com/grafana/grafana/pkg/setting" +) + +var _ builder.APIGroupBuilder = (*DashboardsAPIBuilder)(nil) + +// This is used just so wire has something unique to return +type DashboardsAPIBuilder struct { + dashboardService dashboards.DashboardService + + dashboardVersionService dashver.Service + accessControl accesscontrol.AccessControl + namespacer request.NamespaceMapper + access access.DashboardAccess + dashStore dashboards.Store + + log log.Logger +} + +func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, + apiregistration builder.APIRegistrar, + dashboardService dashboards.DashboardService, + dashboardVersionService dashver.Service, + accessControl accesscontrol.AccessControl, + provisioning provisioning.ProvisioningService, + dashStore dashboards.Store, + sql db.DB, +) *DashboardsAPIBuilder { + if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { + return nil // skip registration unless opting into experimental apis + } + + namespacer := request.GetNamespaceMapper(cfg) + builder := &DashboardsAPIBuilder{ + dashboardService: dashboardService, + dashboardVersionService: dashboardVersionService, + dashStore: dashStore, + accessControl: accessControl, + namespacer: namespacer, + access: access.NewDashboardAccess(sql, namespacer, dashStore, provisioning), + log: log.New("grafana-apiserver.dashboards"), + } + apiregistration.RegisterAPI(builder) + return builder +} + +func (b *DashboardsAPIBuilder) GetGroupVersion() schema.GroupVersion { + return v0alpha1.DashboardResourceInfo.GroupVersion() +} + +func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { + scheme.AddKnownTypes(gv, + &v0alpha1.Dashboard{}, + &v0alpha1.DashboardList{}, + &v0alpha1.DashboardAccessInfo{}, + &v0alpha1.DashboardVersionList{}, + &v0alpha1.DashboardSummary{}, + &v0alpha1.DashboardSummaryList{}, + &v0alpha1.VersionsQueryOptions{}, + ) +} + +func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { + resourceInfo := v0alpha1.DashboardResourceInfo + addKnownTypes(scheme, resourceInfo.GroupVersion()) + + // Link this version to the internal representation. + // This is used for server-side-apply (PATCH), and avoids the error: + // "no kind is registered for the type" + addKnownTypes(scheme, schema.GroupVersion{ + Group: resourceInfo.GroupVersion().Group, + Version: runtime.APIVersionInternal, + }) + + // If multiple versions exist, then register conversions from zz_generated.conversion.go + // if err := playlist.RegisterConversions(scheme); err != nil { + // return err + // } + metav1.AddToGroupVersion(scheme, resourceInfo.GroupVersion()) + return scheme.SetVersionPriority(resourceInfo.GroupVersion()) +} + +func (b *DashboardsAPIBuilder) GetAPIGroupInfo( + scheme *runtime.Scheme, + codecs serializer.CodecFactory, // pointer? + optsGetter generic.RESTOptionsGetter, + dualWrite bool, +) (*genericapiserver.APIGroupInfo, error) { + apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(v0alpha1.GROUP, scheme, metav1.ParameterCodec, codecs) + + resourceInfo := v0alpha1.DashboardResourceInfo + strategy := grafanaregistry.NewStrategy(scheme) + store := &genericregistry.Store{ + NewFunc: resourceInfo.NewFunc, + NewListFunc: resourceInfo.NewListFunc, + PredicateFunc: grafanaregistry.Matcher, + DefaultQualifiedResource: resourceInfo.GroupResource(), + SingularQualifiedResource: resourceInfo.SingularGroupResource(), + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + } + store.TableConvertor = utils.NewTableConverter( + store.DefaultQualifiedResource, + []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Title", Type: "string", Format: "string", Description: "The dashboard name"}, + {Name: "Created At", Type: "date"}, + }, + func(obj any) ([]interface{}, error) { + dash, ok := obj.(*v0alpha1.Dashboard) + if ok { + return []interface{}{ + dash.Name, + dash.Spec.GetNestedString("title"), + dash.CreationTimestamp.UTC().Format(time.RFC3339), + }, nil + } + summary, ok := obj.(*v0alpha1.DashboardSummary) + if ok { + return []interface{}{ + dash.Name, + summary.Spec.Title, + dash.CreationTimestamp.UTC().Format(time.RFC3339), + }, nil + } + return nil, fmt.Errorf("expected dashboard or summary") + }) + + legacyStore := &dashboardStorage{ + resource: resourceInfo, + access: b.access, + tableConverter: store.TableConvertor, + } + + storage := map[string]rest.Storage{} + storage[resourceInfo.StoragePath()] = legacyStore + storage[resourceInfo.StoragePath("access")] = &AccessREST{ + builder: b, + } + storage[resourceInfo.StoragePath("versions")] = &VersionsREST{ + builder: b, + } + + // Dual writes if a RESTOptionsGetter is provided + if dualWrite && optsGetter != nil { + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} + if err := store.CompleteWithOptions(options); err != nil { + return nil, err + } + storage[resourceInfo.StoragePath()] = grafanarest.NewDualWriter(legacyStore, store) + } + + // Summary + resourceInfo2 := v0alpha1.DashboardSummaryResourceInfo + storage[resourceInfo2.StoragePath()] = &summaryStorage{ + resource: resourceInfo2, + access: b.access, + tableConverter: store.TableConvertor, + } + + apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage + return &apiGroupInfo, nil +} + +func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { + return v0alpha1.GetOpenAPIDefinitions +} + +func (b *DashboardsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) { + // The plugin description + oas.Info.Description = "Grafana dashboards as resources" + + // The root api URL + root := "/apis/" + b.GetGroupVersion().String() + "/" + + // Hide the ability to list or watch across all tenants + delete(oas.Paths.Paths, root+v0alpha1.DashboardResourceInfo.GroupResource().Resource) + delete(oas.Paths.Paths, root+"watch/"+v0alpha1.DashboardResourceInfo.GroupResource().Resource) + delete(oas.Paths.Paths, root+v0alpha1.DashboardSummaryResourceInfo.GroupResource().Resource) + + // The root API discovery list + sub := oas.Paths.Paths[root] + if sub != nil && sub.Get != nil { + sub.Get.Tags = []string{"API Discovery"} // sorts first in the list + } + return oas, nil +} + +func (b *DashboardsAPIBuilder) GetAPIRoutes() *builder.APIRoutes { + return nil // no custom API routes +} diff --git a/pkg/registry/apis/dashboard/sub_access.go b/pkg/registry/apis/dashboard/sub_access.go new file mode 100644 index 0000000000000..3436a3f1fc802 --- /dev/null +++ b/pkg/registry/apis/dashboard/sub_access.go @@ -0,0 +1,114 @@ +package dashboard + +import ( + "context" + "fmt" + "net/http" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + dashboard "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/auth/identity" + dashboardssvc "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/guardian" +) + +type AccessREST struct { + builder *DashboardsAPIBuilder +} + +var _ = rest.Connecter(&AccessREST{}) +var _ = rest.StorageMetadata(&AccessREST{}) + +func (r *AccessREST) New() runtime.Object { + return &dashboard.DashboardAccessInfo{} +} + +func (r *AccessREST) Destroy() { +} + +func (r *AccessREST) ConnectMethods() []string { + return []string{"GET"} +} + +func (r *AccessREST) NewConnectOptions() (runtime.Object, bool, string) { + return &dashboard.VersionsQueryOptions{}, false, "" +} + +func (r *AccessREST) ProducesMIMETypes(verb string) []string { + return nil +} + +func (r *AccessREST) ProducesObject(verb string) interface{} { + return &dashboard.DashboardAccessInfo{} +} + +func (r *AccessREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } + + dto, err := r.builder.dashboardService.GetDashboard(ctx, &dashboardssvc.GetDashboardQuery{ + UID: name, + OrgID: info.OrgID, + }) + if err != nil { + return nil, err + } + + guardian, err := guardian.NewByDashboard(ctx, dto, info.OrgID, user) + if err != nil { + return nil, err + } + canView, err := guardian.CanView() + if err != nil || !canView { + return nil, fmt.Errorf("not allowed to view") + } + + access := &dashboard.DashboardAccessInfo{} + access.CanEdit, _ = guardian.CanEdit() + access.CanSave, _ = guardian.CanSave() + access.CanAdmin, _ = guardian.CanAdmin() + access.CanDelete, _ = guardian.CanDelete() + access.CanStar = user.IsRealUser() && !user.IsAnonymous + + access.AnnotationsPermissions = &dashboard.AnnotationPermission{} + r.getAnnotationPermissionsByScope(ctx, user, &access.AnnotationsPermissions.Dashboard, accesscontrol.ScopeAnnotationsTypeDashboard) + r.getAnnotationPermissionsByScope(ctx, user, &access.AnnotationsPermissions.Organization, accesscontrol.ScopeAnnotationsTypeOrganization) + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + responder.Object(http.StatusOK, access) + }), nil +} + +func (r *AccessREST) getAnnotationPermissionsByScope(ctx context.Context, user identity.Requester, actions *dashboard.AnnotationActions, scope string) { + var err error + + evaluate := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsCreate, scope) + actions.CanAdd, err = r.builder.accessControl.Evaluate(ctx, user, evaluate) + if err != nil { + r.builder.log.Warn("Failed to evaluate permission", "err", err, "action", accesscontrol.ActionAnnotationsCreate, "scope", scope) + } + + evaluate = accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsDelete, scope) + actions.CanDelete, err = r.builder.accessControl.Evaluate(ctx, user, evaluate) + if err != nil { + r.builder.log.Warn("Failed to evaluate permission", "err", err, "action", accesscontrol.ActionAnnotationsDelete, "scope", scope) + } + + evaluate = accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsWrite, scope) + actions.CanEdit, err = r.builder.accessControl.Evaluate(ctx, user, evaluate) + if err != nil { + r.builder.log.Warn("Failed to evaluate permission", "err", err, "action", accesscontrol.ActionAnnotationsWrite, "scope", scope) + } +} diff --git a/pkg/registry/apis/dashboard/sub_versions.go b/pkg/registry/apis/dashboard/sub_versions.go new file mode 100644 index 0000000000000..4787c5387df9f --- /dev/null +++ b/pkg/registry/apis/dashboard/sub_versions.go @@ -0,0 +1,117 @@ +package dashboard + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + dashboard "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + dashver "github.com/grafana/grafana/pkg/services/dashboardversion" +) + +type VersionsREST struct { + builder *DashboardsAPIBuilder +} + +var _ = rest.Connecter(&VersionsREST{}) +var _ = rest.StorageMetadata(&VersionsREST{}) + +func (r *VersionsREST) New() runtime.Object { + return &dashboard.DashboardVersionList{} +} + +func (r *VersionsREST) Destroy() { +} + +func (r *VersionsREST) ConnectMethods() []string { + return []string{"GET"} +} + +func (r *VersionsREST) ProducesMIMETypes(verb string) []string { + return nil +} + +func (r *VersionsREST) ProducesObject(verb string) interface{} { + return &dashboard.DashboardVersionList{} +} + +func (r *VersionsREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, true, "" +} + +func (r *VersionsREST) Connect(ctx context.Context, uid string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + path := req.URL.Path + idx := strings.LastIndex(path, "/versions/") + if idx > 0 { + key := path[strings.LastIndex(path, "/")+1:] + version, err := strconv.Atoi(key) + if err != nil { + responder.Error(err) + return + } + + dto, err := r.builder.dashboardVersionService.Get(ctx, &dashver.GetDashboardVersionQuery{ + DashboardUID: uid, + OrgID: info.OrgID, + Version: version, + }) + if err != nil { + responder.Error(err) + return + } + + data, _ := dto.Data.Map() + + // Convert the version to a regular dashboard + dash := &dashboard.Dashboard{ + ObjectMeta: metav1.ObjectMeta{ + Name: uid, + CreationTimestamp: metav1.NewTime(dto.Created), + }, + Spec: common.Unstructured{Object: data}, + } + responder.Object(100, dash) + return + } + + // Or list versions + rsp, err := r.builder.dashboardVersionService.List(ctx, &dashver.ListDashboardVersionsQuery{ + DashboardUID: uid, + OrgID: info.OrgID, + }) + if err != nil { + responder.Error(err) + return + } + versions := &dashboard.DashboardVersionList{} + for _, v := range rsp { + info := dashboard.DashboardVersionInfo{ + Version: v.Version, + Created: v.Created.UnixMilli(), + Message: v.Message, + } + if v.ParentVersion != v.Version { + info.ParentVersion = v.ParentVersion + } + if v.CreatedBy > 0 { + info.CreatedBy = fmt.Sprintf("%d", v.CreatedBy) + } + versions.Items = append(versions.Items, info) + } + responder.Object(http.StatusOK, versions) + }), nil +} diff --git a/pkg/registry/apis/dashboard/summary_storage.go b/pkg/registry/apis/dashboard/summary_storage.go new file mode 100644 index 0000000000000..9f8966e012eff --- /dev/null +++ b/pkg/registry/apis/dashboard/summary_storage.go @@ -0,0 +1,74 @@ +package dashboard + +import ( + "context" + + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/access" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" +) + +var ( + _ rest.Storage = (*summaryStorage)(nil) + _ rest.Scoper = (*summaryStorage)(nil) + _ rest.SingularNameProvider = (*summaryStorage)(nil) + _ rest.Getter = (*summaryStorage)(nil) + _ rest.Lister = (*summaryStorage)(nil) +) + +type summaryStorage struct { + resource common.ResourceInfo + access access.DashboardAccess + tableConverter rest.TableConvertor +} + +func (s *summaryStorage) New() runtime.Object { + return s.resource.NewFunc() +} + +func (s *summaryStorage) Destroy() {} + +func (s *summaryStorage) NamespaceScoped() bool { + return true +} + +func (s *summaryStorage) GetSingularName() string { + return s.resource.GetSingularName() +} + +func (s *summaryStorage) NewList() runtime.Object { + return s.resource.NewListFunc() +} + +func (s *summaryStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return s.tableConverter.ConvertToTable(ctx, object, tableOptions) +} + +func (s *summaryStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + orgId, err := request.OrgIDForList(ctx) + if err != nil { + return nil, err + } + + query := &access.DashboardQuery{ + OrgID: orgId, + Limit: int(options.Limit), + MaxBytes: 2 * 1024 * 1024, // 2MB, + ContinueToken: options.Continue, + } + return s.access.GetDashboardSummaries(ctx, query) +} + +func (s *summaryStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + return s.access.GetDashboardSummary(ctx, info.OrgID, name) +} diff --git a/pkg/registry/apis/dashboardsnapshot/conversions.go b/pkg/registry/apis/dashboardsnapshot/conversions.go new file mode 100644 index 0000000000000..8071799de5afa --- /dev/null +++ b/pkg/registry/apis/dashboardsnapshot/conversions.go @@ -0,0 +1,71 @@ +package dashboardsnapshot + +import ( + "fmt" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/utils" + "github.com/grafana/grafana/pkg/services/dashboardsnapshots" +) + +func convertDTOToSnapshot(v *dashboardsnapshots.DashboardSnapshotDTO, namespacer request.NamespaceMapper) *dashboardsnapshot.DashboardSnapshot { + expires := v.Expires.UnixMilli() + if v.Expires.After(time.Date(2070, time.January, 0, 0, 0, 0, 0, time.UTC)) { + expires = 0 // ignore things expiring long into the future + } + snap := &dashboardsnapshot.DashboardSnapshot{ + TypeMeta: resourceInfo.TypeMeta(), + ObjectMeta: metav1.ObjectMeta{ + Name: v.Key, + ResourceVersion: fmt.Sprintf("%d", v.Updated.UnixMilli()), + CreationTimestamp: metav1.NewTime(v.Created), + Namespace: namespacer(v.OrgID), + }, + Spec: dashboardsnapshot.SnapshotInfo{ + Title: v.Name, + ExternalURL: v.ExternalURL, + Expires: expires, + }, + } + if v.Updated != v.Created { + meta, _ := utils.MetaAccessor(snap) + meta.SetUpdatedTimestamp(&v.Updated) + } + return snap +} + +func convertSnapshotToK8sResource(v *dashboardsnapshots.DashboardSnapshot, namespacer request.NamespaceMapper) *dashboardsnapshot.DashboardSnapshot { + expires := v.Expires.UnixMilli() + if v.Expires.After(time.Date(2070, time.January, 0, 0, 0, 0, 0, time.UTC)) { + expires = 0 // ignore things expiring long into the future + } + + info := dashboardsnapshot.SnapshotInfo{ + Title: v.Name, + ExternalURL: v.ExternalURL, + Expires: expires, + } + s := v.Dashboard.Get("snapshot") + if s != nil { + info.OriginalUrl, _ = s.Get("originalUrl").String() + info.Timestamp, _ = s.Get("timestamp").String() + } + snap := &dashboardsnapshot.DashboardSnapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: v.Key, + ResourceVersion: fmt.Sprintf("%d", v.Updated.UnixMilli()), + CreationTimestamp: metav1.NewTime(v.Created), + Namespace: namespacer(v.OrgID), + }, + Spec: info, + } + if v.Updated != v.Created { + meta, _ := utils.MetaAccessor(snap) + meta.SetUpdatedTimestamp(&v.Updated) + } + return snap +} diff --git a/pkg/registry/apis/dashboardsnapshot/exporter.go b/pkg/registry/apis/dashboardsnapshot/exporter.go new file mode 100644 index 0000000000000..18dbd203eaf3b --- /dev/null +++ b/pkg/registry/apis/dashboardsnapshot/exporter.go @@ -0,0 +1,130 @@ +package dashboardsnapshot + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "gocloud.dev/blob" + "k8s.io/kube-openapi/pkg/spec3" + + "github.com/grafana/grafana/pkg/apiserver/builder" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/dashboardsnapshots" +) + +type dashExportStatus struct { + Count int + Index int + Started int64 + Updated int64 + Finished int64 + Error string +} + +type dashExporter struct { + status dashExportStatus + + service dashboardsnapshots.Service + sql db.DB +} + +func (d *dashExporter) getAPIRouteHandler() builder.APIRouteHandler { + return builder.APIRouteHandler{ + Path: "admin/export", + Spec: &spec3.PathProps{ + Summary: "an example at the root level", + Description: "longer description here?", + Post: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + Tags: []string{"export"}, + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + StatusCodeResponses: map[int]*spec3.Response{ + 200: { + ResponseProps: spec3.ResponseProps{ + Content: map[string]*spec3.MediaType{ + "application/json": {}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Handler: func(w http.ResponseWriter, r *http.Request) { + // Only let it start once + if d.status.Started == 0 { + go d.doExport() + } + time.Sleep(time.Second) + _ = json.NewEncoder(w).Encode(d.status) + }, + } +} + +// NO way to stop!!!!!! +func (d *dashExporter) doExport() { + defer func() { + d.status.Finished = time.Now().UnixMilli() + }() + d.status = dashExportStatus{ + Started: time.Now().UnixMilli(), + } + if d.sql == nil { + d.status.Error = "missing dependencies" + return + } + + ctx := context.Background() + keys := []string{} + err := d.sql.GetSqlxSession().Select(ctx, + &keys, "SELECT key FROM dashboard_snapshot ORDER BY id asc") + if err != nil { + d.status.Error = err.Error() + return + } + d.status.Count = len(keys) + + bucket, err := blob.OpenBucket(ctx, "mem://?key=foo.txt&prefix=a/subfolder/") + if err != nil { + d.status.Error = err.Error() + return + } + defer func() { + _ = bucket.Close() + }() + + for idx, key := range keys { + d.status.Index = idx + snap, err := d.service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{ + Key: key, + }) + if err != nil { + d.status.Error = err.Error() + return + } + + dash, err := snap.Dashboard.ToDB() + if err != nil { + d.status.Error = err.Error() + return + } + + fmt.Printf("TODO, export: %s (len: %d)\n", snap.Key, len(dash)) + + // w, err := bucket.NewWriter(ctx, "foo.txt", nil) + // if err != nil { + // d.status.Error = err.Error() + // return + // } + + time.Sleep(time.Second * 1) + d.status.Updated = time.Now().UnixMilli() + } + fmt.Printf("done!\n") +} diff --git a/pkg/registry/apis/dashboardsnapshot/options_storage.go b/pkg/registry/apis/dashboardsnapshot/options_storage.go new file mode 100644 index 0000000000000..571010b9f5c02 --- /dev/null +++ b/pkg/registry/apis/dashboardsnapshot/options_storage.go @@ -0,0 +1,91 @@ +package dashboardsnapshot + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/setting" +) + +var ( + _ rest.Scoper = (*optionsStorage)(nil) + _ rest.SingularNameProvider = (*optionsStorage)(nil) + _ rest.Getter = (*optionsStorage)(nil) + _ rest.Lister = (*optionsStorage)(nil) + _ rest.Storage = (*optionsStorage)(nil) +) + +type sharingOptionsGetter = func(namespace string) (*dashboardsnapshot.SharingOptions, error) + +func newSharingOptionsGetter(cfg *setting.Cfg) sharingOptionsGetter { + s := &dashboardsnapshot.SharingOptions{ + ObjectMeta: metav1.ObjectMeta{ + CreationTimestamp: metav1.Now(), + }, + Spec: dashboardsnapshot.SnapshotSharingOptions{ + SnapshotsEnabled: cfg.SnapshotEnabled, + ExternalSnapshotURL: cfg.ExternalSnapshotUrl, + ExternalSnapshotName: cfg.ExternalSnapshotName, + ExternalEnabled: cfg.ExternalEnabled, + }, + } + return func(namespace string) (*dashboardsnapshot.SharingOptions, error) { + return s, nil + } +} + +type optionsStorage struct { + getter sharingOptionsGetter + tableConverter rest.TableConvertor +} + +func (s *optionsStorage) New() runtime.Object { + return &dashboardsnapshot.SharingOptions{} +} + +func (s *optionsStorage) Destroy() {} + +func (s *optionsStorage) NamespaceScoped() bool { + return true +} + +func (s *optionsStorage) GetSingularName() string { + return "options" +} + +func (s *optionsStorage) NewList() runtime.Object { + return &dashboardsnapshot.SharingOptionsList{} +} + +func (s *optionsStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return s.tableConverter.ConvertToTable(ctx, object, tableOptions) +} + +func (s *optionsStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + if info.OrgID < 0 { + return nil, fmt.Errorf("missing namespace") + } + v, err := s.getter(info.Value) + if err != nil { + return nil, err + } + list := &dashboardsnapshot.SharingOptionsList{ + Items: []dashboardsnapshot.SharingOptions{*v}, + } + return list, nil +} + +func (s *optionsStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + return s.getter(name) +} diff --git a/pkg/registry/apis/dashboardsnapshot/register.go b/pkg/registry/apis/dashboardsnapshot/register.go new file mode 100644 index 0000000000000..0034bb2b9ed54 --- /dev/null +++ b/pkg/registry/apis/dashboardsnapshot/register.go @@ -0,0 +1,377 @@ +package dashboardsnapshot + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/gorilla/mux" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/rest" + genericapiserver "k8s.io/apiserver/pkg/server" + common "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + + dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" + "github.com/grafana/grafana/pkg/apiserver/builder" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/utils" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/dashboardsnapshots" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/util/errutil/errhttp" + "github.com/grafana/grafana/pkg/web" +) + +var _ builder.APIGroupBuilder = (*SnapshotsAPIBuilder)(nil) +var _ builder.OpenAPIPostProcessor = (*SnapshotsAPIBuilder)(nil) + +var resourceInfo = dashboardsnapshot.DashboardSnapshotResourceInfo + +// This is used just so wire has something unique to return +type SnapshotsAPIBuilder struct { + service dashboardsnapshots.Service + namespacer request.NamespaceMapper + options sharingOptionsGetter + exporter *dashExporter + logger log.Logger +} + +func NewSnapshotsAPIBuilder( + p dashboardsnapshots.Service, + cfg *setting.Cfg, + exporter *dashExporter, +) *SnapshotsAPIBuilder { + return &SnapshotsAPIBuilder{ + service: p, + options: newSharingOptionsGetter(cfg), + namespacer: request.GetNamespaceMapper(cfg), + exporter: exporter, + logger: log.New("snapshots::RawHandlers"), + } +} + +func RegisterAPIService( + service dashboardsnapshots.Service, + apiregistration builder.APIRegistrar, + cfg *setting.Cfg, + features featuremgmt.FeatureToggles, + sql db.DB, +) *SnapshotsAPIBuilder { + if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { + return nil // skip registration unless opting into experimental apis + } + builder := NewSnapshotsAPIBuilder(service, cfg, &dashExporter{ + service: service, + sql: sql, + }) + apiregistration.RegisterAPI(builder) + return builder +} + +func (b *SnapshotsAPIBuilder) GetGroupVersion() schema.GroupVersion { + return resourceInfo.GroupVersion() +} + +func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { + scheme.AddKnownTypes(gv, + &dashboardsnapshot.DashboardSnapshot{}, + &dashboardsnapshot.DashboardSnapshotList{}, + &dashboardsnapshot.SharingOptions{}, + &dashboardsnapshot.SharingOptionsList{}, + &dashboardsnapshot.FullDashboardSnapshot{}, + &dashboardsnapshot.DashboardSnapshotWithDeleteKey{}, + &metav1.Status{}, + ) +} + +func (b *SnapshotsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { + gv := resourceInfo.GroupVersion() + addKnownTypes(scheme, gv) + + // Link this version to the internal representation. + // This is used for server-side-apply (PATCH), and avoids the error: + // "no kind is registered for the type" + addKnownTypes(scheme, schema.GroupVersion{ + Group: gv.Group, + Version: runtime.APIVersionInternal, + }) + + // If multiple versions exist, then register conversions from zz_generated.conversion.go + // if err := playlist.RegisterConversions(scheme); err != nil { + // return err + // } + metav1.AddToGroupVersion(scheme, gv) + return scheme.SetVersionPriority(gv) +} + +func (b *SnapshotsAPIBuilder) GetAPIGroupInfo( + scheme *runtime.Scheme, + codecs serializer.CodecFactory, // pointer? + optsGetter generic.RESTOptionsGetter, + dualWrite bool, +) (*genericapiserver.APIGroupInfo, error) { + apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(dashboardsnapshot.GROUP, scheme, metav1.ParameterCodec, codecs) + storage := map[string]rest.Storage{} + + legacyStore := &legacyStorage{ + service: b.service, + namespacer: b.namespacer, + options: b.options, + } + legacyStore.tableConverter = utils.NewTableConverter( + resourceInfo.GroupResource(), + []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Title", Type: "string", Format: "string", Description: "The snapshot name"}, + {Name: "Created At", Type: "date"}, + }, + func(obj any) ([]interface{}, error) { + m, ok := obj.(*dashboardsnapshot.DashboardSnapshot) + if ok { + return []interface{}{ + m.Name, + m.Spec.Title, + m.CreationTimestamp.UTC().Format(time.RFC3339), + }, nil + } + return nil, fmt.Errorf("expected snapshot") + }, + ) + storage[resourceInfo.StoragePath()] = legacyStore + storage[resourceInfo.StoragePath("body")] = &subBodyREST{ + service: b.service, + namespacer: b.namespacer, + } + + storage["options"] = &optionsStorage{ + getter: b.options, + tableConverter: legacyStore.tableConverter, + } + + apiGroupInfo.VersionedResourcesStorageMap[dashboardsnapshot.VERSION] = storage + return &apiGroupInfo, nil +} + +func (b *SnapshotsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { + return dashboardsnapshot.GetOpenAPIDefinitions +} + +// Register additional routes with the server +func (b *SnapshotsAPIBuilder) GetAPIRoutes() *builder.APIRoutes { + prefix := dashboardsnapshot.DashboardSnapshotResourceInfo.GroupResource().Resource + defs := dashboardsnapshot.GetOpenAPIDefinitions(func(path string) spec.Ref { return spec.Ref{} }) + createCmd := defs["github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardCreateCommand"].Schema + createExample := `{"dashboard":{"annotations":{"list":[{"name":"Annotations & Alerts","enable":true,"iconColor":"rgba(0, 211, 255, 1)","snapshotData":[],"type":"dashboard","builtIn":1,"hide":true}]},"editable":true,"fiscalYearStartMonth":0,"graphTooltip":0,"id":203,"links":[],"liveNow":false,"panels":[{"datasource":null,"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":0},"id":1,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"10.4.0-pre","snapshotData":[{"fields":[{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"showPoints":"auto","thresholdsStyle":{"mode":"off"}},"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"time","type":"time","values":[1706030536378,1706034856378,1706039176378,1706043496378,1706047816378,1706052136378]},{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"A-series","type":"number","values":[1,20,90,30,50,0]}],"refId":"A"}],"targets":[],"title":"Simple example","type":"timeseries","links":[]}],"refresh":"","schemaVersion":39,"snapshot":{"timestamp":"2024-01-23T23:22:16.377Z"},"tags":[],"templating":{"list":[]},"time":{"from":"2024-01-23T17:22:20.380Z","to":"2024-01-23T23:22:20.380Z","raw":{"from":"now-6h","to":"now"}},"timepicker":{},"timezone":"","title":"simple and small","uid":"b22ec8db-399b-403b-b6c7-b0fb30ccb2a5","version":1,"weekStart":""},"name":"simple and small","expires":86400}` + createRsp := defs["github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardCreateResponse"].Schema + + tags := []string{dashboardsnapshot.DashboardSnapshotResourceInfo.GroupVersionKind().Kind} + routes := &builder.APIRoutes{ + Namespace: []builder.APIRouteHandler{ + { + Path: prefix + "/create", + Spec: &spec3.PathProps{ + Post: &spec3.Operation{ + VendorExtensible: spec.VendorExtensible{ + Extensions: map[string]any{ + "x-grafana-action": "create", + "x-kubernetes-group-version-kind": metav1.GroupVersionKind{ + Group: dashboardsnapshot.GROUP, + Version: dashboardsnapshot.VERSION, + Kind: "DashboardCreateResponse", + }, + }, + }, + OperationProps: spec3.OperationProps{ + Tags: tags, + Summary: "Full dashboard", + Description: "longer description here?", + Parameters: []*spec3.Parameter{ + { + ParameterProps: spec3.ParameterProps{ + Name: "namespace", + In: "path", + Required: true, + Example: "default", + Description: "workspace", + Schema: spec.StringProperty(), + }, + }, + }, + RequestBody: &spec3.RequestBody{ + RequestBodyProps: spec3.RequestBodyProps{ + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &createCmd, + Example: createExample, // raw JSON body + }, + }, + }, + }, + }, + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + StatusCodeResponses: map[int]*spec3.Response{ + 200: { + ResponseProps: spec3.ResponseProps{ + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &createRsp, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Handler: func(w http.ResponseWriter, r *http.Request) { + user, err := appcontext.User(r.Context()) + if err != nil { + errhttp.Write(r.Context(), err, w) + return + } + wrap := &contextmodel.ReqContext{ + Logger: b.logger, + Context: &web.Context{ + Req: r, + Resp: web.NewResponseWriter(r.Method, w), + }, + SignedInUser: user, + } + + vars := mux.Vars(r) + info, err := request.ParseNamespace(vars["namespace"]) + if err != nil { + wrap.JsonApiErr(http.StatusBadRequest, "expected namespace", nil) + return + } + if info.OrgID != user.OrgID { + wrap.JsonApiErr(http.StatusBadRequest, + fmt.Sprintf("user orgId does not match namespace (%d != %d)", info.OrgID, user.OrgID), nil) + return + } + opts, err := b.options(info.Value) + if err != nil { + wrap.JsonApiErr(http.StatusBadRequest, "error getting options", err) + return + } + + // Use the existing snapshot service + dashboardsnapshots.CreateDashboardSnapshot(wrap, opts.Spec, b.service) + }, + }, + { + Path: prefix + "/delete/{deleteKey}", + Spec: &spec3.PathProps{ + Summary: "an example at the root level", + Description: "longer description here?", + Delete: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + Tags: tags, + Parameters: []*spec3.Parameter{ + { + ParameterProps: spec3.ParameterProps{ + Name: "deleteKey", + In: "path", + Required: true, + Description: "unique key returned in create", + Schema: spec.StringProperty(), + }, + }, + }, + }, + }, + }, + Handler: func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + key := vars["deleteKey"] + + err := dashboardsnapshots.DeleteWithKey(ctx, key, b.service) + if err != nil { + errhttp.Write(ctx, fmt.Errorf("failed to delete external dashboard (%w)", err), w) + return + } + _ = json.NewEncoder(w).Encode(&util.DynMap{ + "message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.", + }) + }, + }, + }, + } + + // dev environment to export all snapshots to a blob store + if b.exporter != nil && false { + routes.Root = append(routes.Root, b.exporter.getAPIRouteHandler()) + } + return routes +} + +func (b *SnapshotsAPIBuilder) GetAuthorizer() authorizer.Authorizer { + // TODO: this behavior must match the existing logic (it is currently more restrictive) + // + // https://github.com/grafana/grafana/blob/f63e43c113ac0cf8f78ed96ee2953874139bd2dc/pkg/middleware/auth.go#L203 + // func SnapshotPublicModeOrSignedIn(cfg *setting.Cfg) web.Handler { + // return func(c *contextmodel.ReqContext) { + // if cfg.SnapshotPublicMode { + // return + // } + + // if !c.IsSignedIn { + // notAuthorized(c) + // return + // } + // } + // } + + return authorizer.AuthorizerFunc( + func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { + // Everyone can view dashsnaps + if attr.GetVerb() == "get" && attr.GetResource() == dashboardsnapshot.DashboardSnapshotResourceInfo.GroupResource().Resource { + return authorizer.DecisionAllow, "", err + } + + // Fallback to the default behaviors (namespace matches org) + return authorizer.DecisionNoOpinion, "", err + }) +} + +func (b *SnapshotsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) { + oas.Info.Description = "A dashboard snapshot shares an interactive dashboard publicly." + + // Set a description on the + sub := oas.Paths.Paths["/apis/dashboardsnapshot.grafana.app/v0alpha1/namespaces/{namespace}/dashboardsnapshots/{name}/body"] + if sub != nil && sub.Get != nil { + sub.Get.Summary = "Full dashboard" + sub.Get.Description = "Read the full dashboard body" + } + + // Hide the invalid endpoint to list all snapshots for all orgs + delete(oas.Paths.Paths, "/apis/dashboardsnapshot.grafana.app/v0alpha1/dashboardsnapshots") + + // The root API discovery list + sub = oas.Paths.Paths["/apis/dashboardsnapshot.grafana.app/v0alpha1/"] + if sub != nil && sub.Get != nil { + sub.Get.Tags = []string{"API Discovery"} // sorts first in the list + } + return oas, nil +} diff --git a/pkg/registry/apis/dashboardsnapshot/sql_storage.go b/pkg/registry/apis/dashboardsnapshot/sql_storage.go new file mode 100644 index 0000000000000..b21231a861362 --- /dev/null +++ b/pkg/registry/apis/dashboardsnapshot/sql_storage.go @@ -0,0 +1,147 @@ +package dashboardsnapshot + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/dashboardsnapshots" +) + +var ( + _ rest.Scoper = (*legacyStorage)(nil) + _ rest.SingularNameProvider = (*legacyStorage)(nil) + _ rest.Getter = (*legacyStorage)(nil) + _ rest.Lister = (*legacyStorage)(nil) + _ rest.Storage = (*legacyStorage)(nil) + _ rest.GracefulDeleter = (*legacyStorage)(nil) +) + +type legacyStorage struct { + service dashboardsnapshots.Service + namespacer request.NamespaceMapper + tableConverter rest.TableConvertor + options sharingOptionsGetter +} + +func (s *legacyStorage) New() runtime.Object { + return resourceInfo.NewFunc() +} + +func (s *legacyStorage) Destroy() {} + +func (s *legacyStorage) NamespaceScoped() bool { + return true // namespace == org +} + +func (s *legacyStorage) GetSingularName() string { + return resourceInfo.GetSingularName() +} + +func (s *legacyStorage) NewList() runtime.Object { + return resourceInfo.NewListFunc() +} + +func (s *legacyStorage) checkEnabled(ns string) error { + opts, err := s.options(ns) + if err != nil { + return err + } + if !opts.Spec.SnapshotsEnabled { + return fmt.Errorf("snapshots not enabled") + } + return nil +} + +func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return s.tableConverter.ConvertToTable(ctx, object, tableOptions) +} + +func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err == nil { + err = s.checkEnabled(info.Value) + } + if err != nil { + return nil, err + } + + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } + + limit := 5000 + if options.Limit > 0 { + limit = int(options.Limit) + } + res, err := s.service.SearchDashboardSnapshots(ctx, &dashboardsnapshots.GetDashboardSnapshotsQuery{ + OrgID: info.OrgID, + SignedInUser: user, + Limit: limit, + }) + if err != nil { + return nil, err + } + + list := &dashboardsnapshot.DashboardSnapshotList{} + for _, v := range res { + list.Items = append(list.Items, *convertDTOToSnapshot(v, s.namespacer)) + } + return list, nil +} + +func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err == nil { + err = s.checkEnabled(info.Value) + } + if err != nil { + return nil, err + } + + v, err := s.service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{ + Key: name, + }) + if err != nil || v == nil { + // if errors.Is(err, playlistsvc.ErrPlaylistNotFound) || err == nil { + // err = k8serrors.NewNotFound(s.SingularQualifiedResource, name) + // } + return nil, err + } + + return convertSnapshotToK8sResource(v, s.namespacer), nil +} + +// GracefulDeleter +func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + snap, err := s.service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{ + Key: name, + }) + if err != nil || snap == nil { + return nil, false, err + } + + // Delete the external one first + if snap.ExternalDeleteURL != "" { + err := dashboardsnapshots.DeleteExternalDashboardSnapshot(snap.ExternalDeleteURL) + if err != nil { + return nil, false, err + } + } + + err = s.service.DeleteDashboardSnapshot(ctx, &dashboardsnapshots.DeleteDashboardSnapshotCommand{ + DeleteKey: snap.DeleteKey, + }) + if err != nil { + return nil, false, err + } + return nil, true, nil +} diff --git a/pkg/registry/apis/dashboardsnapshot/sub_body.go b/pkg/registry/apis/dashboardsnapshot/sub_body.go new file mode 100644 index 0000000000000..9d0a7349e4388 --- /dev/null +++ b/pkg/registry/apis/dashboardsnapshot/sub_body.go @@ -0,0 +1,60 @@ +package dashboardsnapshot + +import ( + "context" + "net/http" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/dashboardsnapshots" +) + +type subBodyREST struct { + service dashboardsnapshots.Service + namespacer request.NamespaceMapper +} + +var _ = rest.Connecter(&subBodyREST{}) + +func (r *subBodyREST) New() runtime.Object { + return &dashboardsnapshot.FullDashboardSnapshot{} +} + +func (r *subBodyREST) Destroy() {} + +func (r *subBodyREST) ConnectMethods() []string { + return []string{"GET"} +} + +func (r *subBodyREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, false, "" +} + +func (r *subBodyREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + snap, err := r.service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{ + Key: name, + }) + if err != nil { + responder.Error(err) + return + } + + data, err := snap.Dashboard.Map() + if err != nil { + responder.Error(err) + return + } + + r := convertSnapshotToK8sResource(snap, r.namespacer) + responder.Object(200, &dashboardsnapshot.FullDashboardSnapshot{ + ObjectMeta: r.ObjectMeta, + Info: r.Spec, + Dashboard: common.Unstructured{Object: data}, + }) + }), nil +} diff --git a/pkg/registry/apis/datasource/README.md b/pkg/registry/apis/datasource/README.md new file mode 100644 index 0000000000000..2e08c86215a01 --- /dev/null +++ b/pkg/registry/apis/datasource/README.md @@ -0,0 +1,8 @@ +Experimental! + +This is exploring how to expose any datasource as a k8s aggregated API server. + +Unlike the other services, this will register datasources as: + +> {plugin}.datasource.grafana.app + diff --git a/pkg/registry/apis/datasource/authorizer.go b/pkg/registry/apis/datasource/authorizer.go new file mode 100644 index 0000000000000..354325a65b1f4 --- /dev/null +++ b/pkg/registry/apis/datasource/authorizer.go @@ -0,0 +1,82 @@ +package datasource + +import ( + "context" + "fmt" + + "k8s.io/apiserver/pkg/authorization/authorizer" + + "github.com/grafana/grafana/pkg/infra/appcontext" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/datasources" +) + +func (b *DataSourceAPIBuilder) GetAuthorizer() authorizer.Authorizer { + return authorizer.AuthorizerFunc( + func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { + if !attr.IsResourceRequest() { + return authorizer.DecisionNoOpinion, "", nil + } + user, err := appcontext.User(ctx) + if err != nil { + return authorizer.DecisionDeny, "valid user is required", err + } + + uidScope := datasources.ScopeProvider.GetResourceScopeUID(attr.GetName()) + + // Must have query access to see a connection + if attr.GetResource() == b.connectionResourceInfo.GroupResource().Resource { + scopes := []string{} + if attr.GetName() != "" { + scopes = []string{uidScope} + } + ok, err := b.accessControl.Evaluate(ctx, user, ac.EvalPermission(datasources.ActionQuery, scopes...)) + if !ok || err != nil { + return authorizer.DecisionDeny, "unable to query", err + } + + if attr.GetSubresource() == "proxy" { + return authorizer.DecisionDeny, "TODO: map the plugin settings to access rules", err + } + + return authorizer.DecisionAllow, "", nil + } + + // Must have query access to see a connection + action := "" // invalid + + switch attr.GetVerb() { + case "list": + ok, err := b.accessControl.Evaluate(ctx, user, + ac.EvalPermission(datasources.ActionRead)) // Can see any datasource values + if !ok || err != nil { + return authorizer.DecisionDeny, "unable to read", err + } + return authorizer.DecisionAllow, "", nil + + case "get": + action = datasources.ActionRead + case "create": + action = datasources.ActionWrite + case "post": + fallthrough + case "update": + fallthrough + case "patch": + fallthrough + case "put": + action = datasources.ActionWrite + case "delete": + action = datasources.ActionDelete + default: + //b.log.Info("unknown verb", "verb", attr.GetVerb()) + return authorizer.DecisionDeny, "unsupported verb", nil // Unknown verb + } + ok, err := b.accessControl.Evaluate(ctx, user, + ac.EvalPermission(action, uidScope)) + if !ok || err != nil { + return authorizer.DecisionDeny, fmt.Sprintf("unable to %s", action), nil + } + return authorizer.DecisionAllow, "", nil + }) +} diff --git a/pkg/registry/apis/datasource/connections.go b/pkg/registry/apis/datasource/connections.go new file mode 100644 index 0000000000000..3e701f59d5a73 --- /dev/null +++ b/pkg/registry/apis/datasource/connections.go @@ -0,0 +1,61 @@ +package datasource + +import ( + "context" + + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +var ( + _ rest.Scoper = (*connectionAccess)(nil) + _ rest.SingularNameProvider = (*connectionAccess)(nil) + _ rest.Getter = (*connectionAccess)(nil) + _ rest.Lister = (*connectionAccess)(nil) + _ rest.Storage = (*connectionAccess)(nil) +) + +type connectionAccess struct { + pluginID string + resourceInfo common.ResourceInfo + tableConverter rest.TableConvertor + datasources PluginDatasourceProvider +} + +func (s *connectionAccess) New() runtime.Object { + return s.resourceInfo.NewFunc() +} + +func (s *connectionAccess) Destroy() {} + +func (s *connectionAccess) NamespaceScoped() bool { + return true +} + +func (s *connectionAccess) GetSingularName() string { + return s.resourceInfo.GetSingularName() +} + +func (s *connectionAccess) ShortNames() []string { + return s.resourceInfo.GetShortNames() +} + +func (s *connectionAccess) NewList() runtime.Object { + return s.resourceInfo.NewListFunc() +} + +func (s *connectionAccess) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return s.tableConverter.ConvertToTable(ctx, object, tableOptions) +} + +func (s *connectionAccess) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + return s.datasources.Get(ctx, s.pluginID, name) +} + +func (s *connectionAccess) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + return s.datasources.List(ctx, s.pluginID) +} diff --git a/pkg/registry/apis/datasource/middleware.go b/pkg/registry/apis/datasource/middleware.go new file mode 100644 index 0000000000000..95d8082320ae3 --- /dev/null +++ b/pkg/registry/apis/datasource/middleware.go @@ -0,0 +1,23 @@ +package datasource + +import ( + "context" + + "github.com/grafana/grafana-aws-sdk/pkg/awsds" + "github.com/grafana/grafana-aws-sdk/pkg/sigv4" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" +) + +func contextualMiddlewares(ctx context.Context) context.Context { + cfg := backend.GrafanaConfigFromContext(ctx) + responseLimitMiddleware := httpclient.ResponseLimitMiddleware(cfg.ResponseLimit()) + ctx = httpclient.WithContextualMiddleware(ctx, responseLimitMiddleware) + + sigv4Settings := awsds.ReadSigV4Settings(ctx) + if sigv4Settings.Enabled { + ctx = httpclient.WithContextualMiddleware(ctx, sigv4.SigV4Middleware(sigv4Settings.VerboseLogging)) + } + + return ctx +} diff --git a/pkg/registry/apis/datasource/plugincontext.go b/pkg/registry/apis/datasource/plugincontext.go new file mode 100644 index 0000000000000..1c39aaa9e269d --- /dev/null +++ b/pkg/registry/apis/datasource/plugincontext.go @@ -0,0 +1,123 @@ +package datasource + +import ( + "context" + "fmt" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/utils" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" +) + +// This provides access to settings saved in the database. +// Authorization checks will happen within each function, and the user in ctx will +// limit which namespace/tenant/org we are talking to +type PluginDatasourceProvider interface { + // Get gets a specific datasource (that the user in context can see) + Get(ctx context.Context, pluginID, uid string) (*v0alpha1.DataSourceConnection, error) + + // List lists all data sources the user in context can see + List(ctx context.Context, pluginID string) (*v0alpha1.DataSourceConnectionList, error) + + // Return settings (decrypted!) for a specific plugin + // This will require "query" permission for the user in context + GetInstanceSettings(ctx context.Context, pluginID, uid string) (*backend.DataSourceInstanceSettings, error) +} + +// PluginContext requires adding system settings (feature flags, etc) to the datasource config +type PluginContextWrapper interface { + PluginContextForDataSource(ctx context.Context, datasourceSettings *backend.DataSourceInstanceSettings) (backend.PluginContext, error) +} + +func ProvideDefaultPluginConfigs( + dsService datasources.DataSourceService, + dsCache datasources.CacheService, + contextProvider *plugincontext.Provider) PluginDatasourceProvider { + return &defaultPluginDatasourceProvider{ + dsService: dsService, + dsCache: dsCache, + contextProvider: contextProvider, + } +} + +type defaultPluginDatasourceProvider struct { + dsService datasources.DataSourceService + dsCache datasources.CacheService + contextProvider *plugincontext.Provider +} + +var ( + _ PluginDatasourceProvider = (*defaultPluginDatasourceProvider)(nil) +) + +func (q *defaultPluginDatasourceProvider) Get(ctx context.Context, pluginID, uid string) (*v0alpha1.DataSourceConnection, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } + ds, err := q.dsCache.GetDatasourceByUID(ctx, uid, user, false) + if err != nil { + return nil, err + } + return asConnection(ds, info.Value) +} + +func (q *defaultPluginDatasourceProvider) List(ctx context.Context, pluginID string) (*v0alpha1.DataSourceConnectionList, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + dss, err := q.dsService.GetDataSourcesByType(ctx, &datasources.GetDataSourcesByTypeQuery{ + OrgID: info.OrgID, + Type: pluginID, + }) + if err != nil { + return nil, err + } + result := &v0alpha1.DataSourceConnectionList{ + Items: []v0alpha1.DataSourceConnection{}, + } + for _, ds := range dss { + v, _ := asConnection(ds, info.Value) + result.Items = append(result.Items, *v) + } + return result, nil +} + +func (q *defaultPluginDatasourceProvider) GetInstanceSettings(ctx context.Context, pluginID, uid string) (*backend.DataSourceInstanceSettings, error) { + if q.contextProvider == nil { + // NOTE!!! this is only here for the standalone example + // if we cleanup imports this can throw an error + return nil, nil + } + return q.contextProvider.GetDataSourceInstanceSettings(ctx, uid) +} + +func asConnection(ds *datasources.DataSource, ns string) (*v0alpha1.DataSourceConnection, error) { + v := &v0alpha1.DataSourceConnection{ + ObjectMeta: metav1.ObjectMeta{ + Name: ds.UID, + Namespace: ns, + CreationTimestamp: metav1.NewTime(ds.Created), + ResourceVersion: fmt.Sprintf("%d", ds.Updated.UnixMilli()), + }, + Title: ds.Name, + } + v.UID = utils.CalculateClusterWideUID(v) // indicates if the value changed on the server + meta, err := utils.MetaAccessor(v) + if err != nil { + meta.SetUpdatedTimestamp(&ds.Updated) + } + return v, err +} diff --git a/pkg/registry/apis/datasource/querier.go b/pkg/registry/apis/datasource/querier.go new file mode 100644 index 0000000000000..5811dbb8378eb --- /dev/null +++ b/pkg/registry/apis/datasource/querier.go @@ -0,0 +1,148 @@ +package datasource + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/datasources" +) + +type QuerierFactoryFunc func(ctx context.Context, ri common.ResourceInfo, pj plugins.JSONData) (Querier, error) + +type QuerierProvider interface { + Querier(ctx context.Context, ri common.ResourceInfo, pj plugins.JSONData) (Querier, error) +} + +type DefaultQuerierProvider struct { + factory QuerierFactoryFunc +} + +func ProvideDefaultQuerierProvider(pluginClient plugins.Client, dsService datasources.DataSourceService, + dsCache datasources.CacheService) *DefaultQuerierProvider { + return NewQuerierProvider(func(ctx context.Context, ri common.ResourceInfo, pj plugins.JSONData) (Querier, error) { + return NewDefaultQuerier(ri, pj, pluginClient, dsService, dsCache), nil + }) +} + +func NewQuerierProvider(factory QuerierFactoryFunc) *DefaultQuerierProvider { + return &DefaultQuerierProvider{ + factory: factory, + } +} + +func (p *DefaultQuerierProvider) Querier(ctx context.Context, ri common.ResourceInfo, pj plugins.JSONData) (Querier, error) { + return p.factory(ctx, ri, pj) +} + +// Querier is the interface that wraps the Query method. +type Querier interface { + // Query runs the query on behalf of the user in context. + Query(ctx context.Context, query *backend.QueryDataRequest) (*backend.QueryDataResponse, error) + // Health checks the health of the plugin. + Health(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) + // Resource gets a resource plugin. + Resource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error + // Datasource gets all data source plugins (with elevated permissions). + Datasource(ctx context.Context, name string) (*v0alpha1.DataSourceConnection, error) + // Datasources lists all data sources (with elevated permissions). + Datasources(ctx context.Context) (*v0alpha1.DataSourceConnectionList, error) +} + +type DefaultQuerier struct { + connectionResourceInfo common.ResourceInfo + pluginJSON plugins.JSONData + pluginClient plugins.Client + dsService datasources.DataSourceService + dsCache datasources.CacheService +} + +func NewDefaultQuerier( + connectionResourceInfo common.ResourceInfo, + pluginJSON plugins.JSONData, + pluginClient plugins.Client, + dsService datasources.DataSourceService, + dsCache datasources.CacheService, +) *DefaultQuerier { + return &DefaultQuerier{ + connectionResourceInfo: connectionResourceInfo, + pluginJSON: pluginJSON, + pluginClient: pluginClient, + dsService: dsService, + dsCache: dsCache, + } +} + +func (q *DefaultQuerier) Query(ctx context.Context, query *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + _, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + return q.pluginClient.QueryData(ctx, query) +} + +func (q *DefaultQuerier) Resource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + _, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return err + } + return q.pluginClient.CallResource(ctx, req, sender) +} + +func (q *DefaultQuerier) Health(ctx context.Context, query *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + _, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + return q.pluginClient.CheckHealth(ctx, query) +} + +func (q *DefaultQuerier) Datasource(ctx context.Context, name string) (*v0alpha1.DataSourceConnection, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } + ds, err := q.dsCache.GetDatasourceByUID(ctx, name, user, false) + if err != nil { + return nil, err + } + return asConnection(ds, info.Value) +} + +func (q *DefaultQuerier) Datasources(ctx context.Context) (*v0alpha1.DataSourceConnectionList, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + ds, err := q.dsService.GetDataSourcesByType(ctx, &datasources.GetDataSourcesByTypeQuery{ + OrgID: info.OrgID, + Type: q.pluginJSON.ID, + }) + if err != nil { + return nil, err + } + return asConnectionList(q.connectionResourceInfo.TypeMeta(), ds, info.Value) +} + +func asConnectionList(typeMeta metav1.TypeMeta, dss []*datasources.DataSource, ns string) (*v0alpha1.DataSourceConnectionList, error) { + result := &v0alpha1.DataSourceConnectionList{ + Items: []v0alpha1.DataSourceConnection{}, + } + for _, ds := range dss { + v, _ := asConnection(ds, ns) + result.Items = append(result.Items, *v) + } + + return result, nil +} diff --git a/pkg/registry/apis/datasource/register.go b/pkg/registry/apis/datasource/register.go new file mode 100644 index 0000000000000..066e552dd6fdd --- /dev/null +++ b/pkg/registry/apis/datasource/register.go @@ -0,0 +1,354 @@ +package datasource + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "github.com/grafana/grafana-plugin-sdk-go/experimental/schemabuilder" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/rest" + genericapiserver "k8s.io/apiserver/pkg/server" + openapi "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + "k8s.io/utils/strings/slices" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + datasource "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" + query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" + "github.com/grafana/grafana/pkg/apiserver/builder" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver/utils" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" +) + +const QueryRequestSchemaKey = "QueryRequestSchema" +const QueryPayloadSchemaKey = "QueryPayloadSchema" + +var _ builder.APIGroupBuilder = (*DataSourceAPIBuilder)(nil) + +// DataSourceAPIBuilder is used just so wire has something unique to return +type DataSourceAPIBuilder struct { + connectionResourceInfo common.ResourceInfo + + pluginJSON plugins.JSONData + client PluginClient // will only ever be called with the same pluginid! + datasources PluginDatasourceProvider + contextProvider PluginContextWrapper + accessControl accesscontrol.AccessControl + queryTypes *query.QueryTypeDefinitionList +} + +func RegisterAPIService( + features featuremgmt.FeatureToggles, + apiRegistrar builder.APIRegistrar, + pluginClient plugins.Client, // access to everything + datasources PluginDatasourceProvider, + contextProvider PluginContextWrapper, + pluginStore pluginstore.Store, + accessControl accesscontrol.AccessControl, +) (*DataSourceAPIBuilder, error) { + // This requires devmode! + if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { + return nil, nil // skip registration unless opting into experimental apis + } + + var err error + var builder *DataSourceAPIBuilder + all := pluginStore.Plugins(context.Background(), plugins.TypeDataSource) + ids := []string{ + "grafana-testdata-datasource", + // "prometheus", + } + + for _, ds := range all { + if !slices.Contains(ids, ds.ID) { + continue // skip this one + } + + builder, err = NewDataSourceAPIBuilder(ds.JSONData, + pluginClient, + datasources, + contextProvider, + accessControl, + ) + if err != nil { + return nil, err + } + apiRegistrar.RegisterAPI(builder) + } + return builder, nil // only used for wire +} + +// PluginClient is a subset of the plugins.Client interface with only the +// functions supported (yet) by the datasource API +type PluginClient interface { + backend.QueryDataHandler + backend.CheckHealthHandler + backend.CallResourceHandler +} + +func NewDataSourceAPIBuilder( + plugin plugins.JSONData, + client PluginClient, + datasources PluginDatasourceProvider, + contextProvider PluginContextWrapper, + accessControl accesscontrol.AccessControl) (*DataSourceAPIBuilder, error) { + ri, err := resourceFromPluginID(plugin.ID) + if err != nil { + return nil, err + } + + return &DataSourceAPIBuilder{ + connectionResourceInfo: ri, + pluginJSON: plugin, + client: client, + datasources: datasources, + contextProvider: contextProvider, + accessControl: accessControl, + }, nil +} + +func (b *DataSourceAPIBuilder) GetGroupVersion() schema.GroupVersion { + return b.connectionResourceInfo.GroupVersion() +} + +func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { + scheme.AddKnownTypes(gv, + &datasource.DataSourceConnection{}, + &datasource.DataSourceConnectionList{}, + &datasource.HealthCheckResult{}, + &unstructured.Unstructured{}, + // Query handler + &query.QueryDataRequest{}, + &query.QueryDataResponse{}, + &metav1.Status{}, + ) +} + +func (b *DataSourceAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { + gv := b.connectionResourceInfo.GroupVersion() + addKnownTypes(scheme, gv) + + // Link this version to the internal representation. + // This is used for server-side-apply (PATCH), and avoids the error: + // "no kind is registered for the type" + addKnownTypes(scheme, schema.GroupVersion{ + Group: gv.Group, + Version: runtime.APIVersionInternal, + }) + + // If multiple versions exist, then register conversions from zz_generated.conversion.go + // if err := playlist.RegisterConversions(scheme); err != nil { + // return err + // } + metav1.AddToGroupVersion(scheme, gv) + return scheme.SetVersionPriority(gv) +} + +func resourceFromPluginID(pluginID string) (common.ResourceInfo, error) { + group, err := plugins.GetDatasourceGroupNameFromPluginID(pluginID) + if err != nil { + return common.ResourceInfo{}, err + } + return datasource.GenericConnectionResourceInfo.WithGroupAndShortName(group, pluginID+"-connection"), nil +} + +func (b *DataSourceAPIBuilder) GetAPIGroupInfo( + scheme *runtime.Scheme, + codecs serializer.CodecFactory, // pointer? + _ generic.RESTOptionsGetter, + _ bool, +) (*genericapiserver.APIGroupInfo, error) { + storage := map[string]rest.Storage{} + + conn := b.connectionResourceInfo + storage[conn.StoragePath()] = &connectionAccess{ + pluginID: b.pluginJSON.ID, + datasources: b.datasources, + resourceInfo: conn, + tableConverter: utils.NewTableConverter( + conn.GroupResource(), + []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Title", Type: "string", Format: "string", Description: "The datasource title"}, + {Name: "APIVersion", Type: "string", Format: "string", Description: "API Version"}, + {Name: "Created At", Type: "date"}, + }, + func(obj any) ([]interface{}, error) { + m, ok := obj.(*datasource.DataSourceConnection) + if !ok { + return nil, fmt.Errorf("expected connection") + } + return []interface{}{ + m.Name, + m.Title, + m.APIVersion, + m.CreationTimestamp.UTC().Format(time.RFC3339), + }, nil + }, + ), + } + storage[conn.StoragePath("query")] = &subQueryREST{builder: b} + storage[conn.StoragePath("health")] = &subHealthREST{builder: b} + + // TODO! only setup this endpoint if it is implemented + storage[conn.StoragePath("resource")] = &subResourceREST{builder: b} + + // Frontend proxy + if len(b.pluginJSON.Routes) > 0 { + storage[conn.StoragePath("proxy")] = &subProxyREST{pluginJSON: b.pluginJSON} + } + + apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo( + conn.GroupResource().Group, scheme, + metav1.ParameterCodec, codecs) + + apiGroupInfo.VersionedResourcesStorageMap[conn.GroupVersion().Version] = storage + return &apiGroupInfo, nil +} + +func (b *DataSourceAPIBuilder) getPluginContext(ctx context.Context, uid string) (backend.PluginContext, error) { + instance, err := b.datasources.GetInstanceSettings(ctx, b.pluginJSON.ID, uid) + if err != nil { + return backend.PluginContext{}, err + } + return b.contextProvider.PluginContextForDataSource(ctx, instance) +} + +func (b *DataSourceAPIBuilder) GetOpenAPIDefinitions() openapi.GetOpenAPIDefinitions { + return func(ref openapi.ReferenceCallback) map[string]openapi.OpenAPIDefinition { + defs := query.GetOpenAPIDefinitions(ref) // required when running standalone + for k, v := range datasource.GetOpenAPIDefinitions(ref) { + defs[k] = v + } + return defs + } +} + +func (b *DataSourceAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) { + // The plugin description + oas.Info.Description = b.pluginJSON.Info.Description + + // The root api URL + root := "/apis/" + b.connectionResourceInfo.GroupVersion().String() + "/" + + // Hide the ability to list all connections across tenants + delete(oas.Paths.Paths, root+b.connectionResourceInfo.GroupResource().Resource) + + var err error + opts := schemabuilder.QuerySchemaOptions{ + PluginID: []string{b.pluginJSON.ID}, + QueryTypes: []data.QueryTypeDefinition{}, + Mode: schemabuilder.SchemaTypeQueryPayload, + } + if b.pluginJSON.AliasIDs != nil { + opts.PluginID = append(opts.PluginID, b.pluginJSON.AliasIDs...) + } + if b.queryTypes != nil { + for _, qt := range b.queryTypes.Items { + // The SDK type and api type are not the same so we recreate it here + opts.QueryTypes = append(opts.QueryTypes, data.QueryTypeDefinition{ + ObjectMeta: data.ObjectMeta{ + Name: qt.Name, + }, + Spec: qt.Spec, + }) + } + } + oas.Components.Schemas[QueryPayloadSchemaKey], err = schemabuilder.GetQuerySchema(opts) + if err != nil { + return oas, err + } + opts.Mode = schemabuilder.SchemaTypeQueryRequest + oas.Components.Schemas[QueryRequestSchemaKey], err = schemabuilder.GetQuerySchema(opts) + if err != nil { + return oas, err + } + + // Update the request object + sub := oas.Paths.Paths[root+"namespaces/{namespace}/connections/{name}/query"] + if sub != nil && sub.Post != nil { + sub.Post.Description = "Execute queries" + sub.Post.RequestBody = &spec3.RequestBody{ + RequestBodyProps: spec3.RequestBodyProps{ + Required: true, + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: spec.RefSchema("#/components/schemas/" + QueryRequestSchemaKey), + Examples: getExamples(b.queryTypes), + }, + }, + }, + }, + } + okrsp, ok := sub.Post.Responses.StatusCodeResponses[200] + if ok { + sub.Post.Responses.StatusCodeResponses[http.StatusMultiStatus] = &spec3.Response{ + ResponseProps: spec3.ResponseProps{ + Description: "Query executed, but errors may exist in the datasource. See the payload for more details.", + Content: okrsp.Content, + }, + } + } + } + + // The root API discovery list + sub = oas.Paths.Paths[root] + if sub != nil && sub.Get != nil { + sub.Get.Tags = []string{"API Discovery"} // sorts first in the list + } + return oas, err +} + +// Register additional routes with the server +func (b *DataSourceAPIBuilder) GetAPIRoutes() *builder.APIRoutes { + return nil +} + +func getExamples(queryTypes *query.QueryTypeDefinitionList) map[string]*spec3.Example { + if queryTypes == nil { + return nil + } + + tr := data.TimeRange{From: "now-1h", To: "now"} + examples := map[string]*spec3.Example{} + for _, queryType := range queryTypes.Items { + for idx, example := range queryType.Spec.Examples { + q := data.NewDataQuery(example.SaveModel.Object) + q.RefID = "A" + for _, dis := range queryType.Spec.Discriminators { + _ = q.Set(dis.Field, dis.Value) + } + if q.MaxDataPoints < 1 { + q.MaxDataPoints = 1000 + } + if q.IntervalMS < 1 { + q.IntervalMS = 5000 // 5s + } + examples[fmt.Sprintf("%s-%d", example.Name, idx)] = &spec3.Example{ + ExampleProps: spec3.ExampleProps{ + Summary: example.Name, + Description: example.Description, + Value: data.QueryDataRequest{ + TimeRange: tr, + Queries: []data.DataQuery{q}, + }, + }, + } + } + } + return examples +} diff --git a/pkg/registry/apis/datasource/sub_health.go b/pkg/registry/apis/datasource/sub_health.go new file mode 100644 index 0000000000000..01a8ea82d9e3a --- /dev/null +++ b/pkg/registry/apis/datasource/sub_health.go @@ -0,0 +1,82 @@ +package datasource + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + datasource "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" +) + +type subHealthREST struct { + builder *DataSourceAPIBuilder +} + +var ( + _ = rest.Connecter(&subHealthREST{}) + _ = rest.StorageMetadata(&subHealthREST{}) +) + +func (r *subHealthREST) New() runtime.Object { + return &datasource.HealthCheckResult{} +} + +func (r *subHealthREST) Destroy() { +} + +func (r *subHealthREST) ConnectMethods() []string { + return []string{"GET"} +} + +func (r *subHealthREST) ProducesMIMETypes(verb string) []string { + return nil +} + +func (r *subHealthREST) ProducesObject(verb string) interface{} { + return &datasource.HealthCheckResult{} +} + +func (r *subHealthREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, false, "" +} + +func (r *subHealthREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + pluginCtx, err := r.builder.getPluginContext(ctx, name) + if err != nil { + return nil, err + } + ctx = backend.WithGrafanaConfig(ctx, pluginCtx.GrafanaConfig) + ctx = contextualMiddlewares(ctx) + + healthResponse, err := r.builder.client.CheckHealth(ctx, &backend.CheckHealthRequest{ + PluginContext: pluginCtx, + }) + if err != nil { + return nil, err + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + rsp := &datasource.HealthCheckResult{} + rsp.Code = int(healthResponse.Status) + rsp.Status = healthResponse.Status.String() + rsp.Message = healthResponse.Message + + if len(healthResponse.JSONDetails) > 0 { + err = json.Unmarshal(healthResponse.JSONDetails, &rsp.Details) + if err != nil { + responder.Error(err) + return + } + } + + statusCode := http.StatusOK + if healthResponse.Status != backend.HealthStatusOk { + statusCode = http.StatusBadRequest + } + responder.Object(statusCode, rsp) + }), nil +} diff --git a/pkg/registry/apis/datasource/sub_proxy.go b/pkg/registry/apis/datasource/sub_proxy.go new file mode 100644 index 0000000000000..5c082fc4a92c5 --- /dev/null +++ b/pkg/registry/apis/datasource/sub_proxy.go @@ -0,0 +1,48 @@ +package datasource + +import ( + "context" + "fmt" + "net/http" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + "github.com/grafana/grafana/pkg/plugins" +) + +type subProxyREST struct { + pluginJSON plugins.JSONData +} + +var _ = rest.Connecter(&subProxyREST{}) + +func (r *subProxyREST) New() runtime.Object { + return &metav1.Status{} +} + +func (r *subProxyREST) Destroy() {} + +func (r *subProxyREST) ConnectMethods() []string { + unique := map[string]bool{} + methods := []string{} + for _, r := range r.pluginJSON.Routes { + if unique[r.Method] { + continue + } + unique[r.Method] = true + methods = append(methods, r.Method) + } + return methods +} + +func (r *subProxyREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, true, "" +} + +func (r *subProxyREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + responder.Error(fmt.Errorf("TODO, proxy: " + r.pluginJSON.ID)) + }), nil +} diff --git a/pkg/registry/apis/datasource/sub_query.go b/pkg/registry/apis/datasource/sub_query.go new file mode 100644 index 0000000000000..1fdd151014e57 --- /dev/null +++ b/pkg/registry/apis/datasource/sub_query.go @@ -0,0 +1,88 @@ +package datasource + +import ( + "context" + "fmt" + "net/http" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" + "github.com/grafana/grafana/pkg/tsdb/legacydata" + "github.com/grafana/grafana/pkg/web" +) + +type subQueryREST struct { + builder *DataSourceAPIBuilder +} + +var ( + _ rest.Storage = (*subQueryREST)(nil) + _ rest.Connecter = (*subQueryREST)(nil) + _ rest.StorageMetadata = (*subQueryREST)(nil) +) + +func (r *subQueryREST) New() runtime.Object { + // This is added as the "ResponseType" regarless what ProducesObject() says :) + return &query.QueryDataResponse{} +} + +func (r *subQueryREST) Destroy() {} + +func (r *subQueryREST) ProducesMIMETypes(verb string) []string { + return []string{"application/json"} // and parquet! +} + +func (r *subQueryREST) ProducesObject(verb string) interface{} { + return &query.QueryDataResponse{} +} + +func (r *subQueryREST) ConnectMethods() []string { + return []string{"POST"} +} + +func (r *subQueryREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, false, "" // true means you can use the trailing path as a variable +} + +func (r *subQueryREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + pluginCtx, err := r.builder.getPluginContext(ctx, name) + if err != nil { + return nil, err + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + dqr := data.QueryDataRequest{} + err := web.Bind(req, &dqr) + if err != nil { + responder.Error(err) + return + } + + queries, dsRef, err := legacydata.ToDataSourceQueries(dqr) + if err != nil { + responder.Error(err) + return + } + if dsRef != nil && dsRef.UID != name { + responder.Error(fmt.Errorf("expected query body datasource and request to match")) + } + + ctx = backend.WithGrafanaConfig(ctx, pluginCtx.GrafanaConfig) + ctx = contextualMiddlewares(ctx) + rsp, err := r.builder.client.QueryData(ctx, &backend.QueryDataRequest{ + Queries: queries, + PluginContext: pluginCtx, + }) + if err != nil { + responder.Error(err) + return + } + responder.Object(query.GetResponseCode(rsp), + &query.QueryDataResponse{QueryDataResponse: *rsp}, + ) + }), nil +} diff --git a/pkg/registry/apis/datasource/sub_resource.go b/pkg/registry/apis/datasource/sub_resource.go new file mode 100644 index 0000000000000..f8f56baf92ee2 --- /dev/null +++ b/pkg/registry/apis/datasource/sub_resource.go @@ -0,0 +1,81 @@ +package datasource + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + "github.com/grafana/grafana/pkg/plugins/httpresponsesender" +) + +type subResourceREST struct { + builder *DataSourceAPIBuilder +} + +var _ = rest.Connecter(&subResourceREST{}) + +func (r *subResourceREST) New() runtime.Object { + return &metav1.Status{} +} + +func (r *subResourceREST) Destroy() { +} + +func (r *subResourceREST) ConnectMethods() []string { + // All for now??? ideally we have a schema for resource and limit this + return []string{ + http.MethodGet, + http.MethodHead, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + http.MethodOptions, + } +} + +func (r *subResourceREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, true, "" +} + +func (r *subResourceREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + pluginCtx, err := r.builder.getPluginContext(ctx, name) + if err != nil { + return nil, err + } + ctx = backend.WithGrafanaConfig(ctx, pluginCtx.GrafanaConfig) + ctx = contextualMiddlewares(ctx) + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, err := io.ReadAll(req.Body) + if err != nil { + responder.Error(err) + return + } + + idx := strings.LastIndex(req.URL.Path, "/resource") + if idx < 0 { + responder.Error(fmt.Errorf("expected resource path")) // 400? + return + } + + path := req.URL.Path[idx+len("/resource"):] + err = r.builder.client.CallResource(ctx, &backend.CallResourceRequest{ + PluginContext: pluginCtx, + Path: path, + Method: req.Method, + Body: body, + }, httpresponsesender.New(w)) + + if err != nil { + responder.Error(err) + } + }), nil +} diff --git a/pkg/registry/apis/example/dummy_storage.go b/pkg/registry/apis/example/dummy_storage.go index 290623771cb32..574a87dd527c2 100644 --- a/pkg/registry/apis/example/dummy_storage.go +++ b/pkg/registry/apis/example/dummy_storage.go @@ -12,9 +12,10 @@ import ( genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" "k8s.io/apiserver/pkg/registry/rest" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" example "github.com/grafana/grafana/pkg/apis/example/v0alpha1" - grafanarequest "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" - grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + grafanarequest "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" ) var ( @@ -93,7 +94,11 @@ func (s *dummyStorage) Get(ctx context.Context, name string, options *metav1.Get CreationTimestamp: s.creationTimestamp, ResourceVersion: "1", }, - Spec: fmt.Sprintf("dummy: %s", name), + Spec: common.Unstructured{ + Object: map[string]any{ + "Dummy": name, + }, + }, }, nil } @@ -112,7 +117,11 @@ func (s *dummyStorage) List(ctx context.Context, options *internalversion.ListOp CreationTimestamp: s.creationTimestamp, ResourceVersion: "1", }, - Spec: fmt.Sprintf("dummy: %s", name), + Spec: common.Unstructured{ + Object: map[string]any{ + "Dummy": name, + }, + }, }) } return res, nil diff --git a/pkg/registry/apis/example/register.go b/pkg/registry/apis/example/register.go index ef6cccc4b99b4..c18d5d4c4e096 100644 --- a/pkg/registry/apis/example/register.go +++ b/pkg/registry/apis/example/register.go @@ -21,12 +21,12 @@ import ( "k8s.io/kube-openapi/pkg/validation/spec" example "github.com/grafana/grafana/pkg/apis/example/v0alpha1" + "github.com/grafana/grafana/pkg/apiserver/builder" "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/services/featuremgmt" - grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver" ) -var _ grafanaapiserver.APIGroupBuilder = (*TestingAPIBuilder)(nil) +var _ builder.APIGroupBuilder = (*TestingAPIBuilder)(nil) // This is used just so wire has something unique to return type TestingAPIBuilder struct { @@ -40,12 +40,12 @@ func NewTestingAPIBuilder() *TestingAPIBuilder { } } -func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration grafanaapiserver.APIRegistrar) *TestingAPIBuilder { +func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar) *TestingAPIBuilder { if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { return nil // skip registration unless opting into experimental apis } builder := NewTestingAPIBuilder() - apiregistration.RegisterAPI(NewTestingAPIBuilder()) + apiregistration.RegisterAPI(builder) return builder } @@ -84,7 +84,8 @@ func (b *TestingAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { func (b *TestingAPIBuilder) GetAPIGroupInfo( scheme *runtime.Scheme, codecs serializer.CodecFactory, // pointer? - optsGetter generic.RESTOptionsGetter, + _ generic.RESTOptionsGetter, + _ bool, ) (*genericapiserver.APIGroupInfo, error) { b.codecs = codecs apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(b.gv.Group, scheme, metav1.ParameterCodec, codecs) @@ -102,9 +103,9 @@ func (b *TestingAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions } // Register additional routes with the server -func (b *TestingAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes { - return &grafanaapiserver.APIRoutes{ - Root: []grafanaapiserver.APIRouteHandler{ +func (b *TestingAPIBuilder) GetAPIRoutes() *builder.APIRoutes { + return &builder.APIRoutes{ + Root: []builder.APIRouteHandler{ { Path: "aaa", Spec: &spec3.PathProps{ @@ -166,7 +167,7 @@ func (b *TestingAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes { }, }, }, - Namespace: []grafanaapiserver.APIRouteHandler{ + Namespace: []builder.APIRouteHandler{ { Path: "ccc", Spec: &spec3.PathProps{ diff --git a/pkg/registry/apis/example/storage.go b/pkg/registry/apis/example/storage.go index fbec2ad7316bd..4e2c1858837f8 100644 --- a/pkg/registry/apis/example/storage.go +++ b/pkg/registry/apis/example/storage.go @@ -12,7 +12,7 @@ import ( "k8s.io/apiserver/pkg/registry/rest" example "github.com/grafana/grafana/pkg/apis/example/v0alpha1" - grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" "github.com/grafana/grafana/pkg/setting" ) diff --git a/pkg/registry/apis/example/subresource.go b/pkg/registry/apis/example/subresource.go index d7d41186b545b..63fdcab5f02d4 100644 --- a/pkg/registry/apis/example/subresource.go +++ b/pkg/registry/apis/example/subresource.go @@ -10,7 +10,7 @@ import ( example "github.com/grafana/grafana/pkg/apis/example/v0alpha1" "github.com/grafana/grafana/pkg/infra/appcontext" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" ) type dummySubresourceREST struct{} diff --git a/pkg/registry/apis/featuretoggle/README.md b/pkg/registry/apis/featuretoggle/README.md new file mode 100644 index 0000000000000..7267e2e281951 --- /dev/null +++ b/pkg/registry/apis/featuretoggle/README.md @@ -0,0 +1,5 @@ +This package supports the [Feature toggle admin page](https://grafana.com/docs/grafana/latest/administration/feature-toggles/) feature. + +In order to update feature toggles through the app, the PATCH handler calls a webhook that should update Grafana's configuration and restarts the instance. + +For local development, set the app mode to `development` by adding `app_mode = development` to the top level of your Grafana .ini file. \ No newline at end of file diff --git a/pkg/registry/apis/featuretoggle/current.go b/pkg/registry/apis/featuretoggle/current.go new file mode 100644 index 0000000000000..97d59a76a1e3a --- /dev/null +++ b/pkg/registry/apis/featuretoggle/current.go @@ -0,0 +1,238 @@ +package featuretoggle + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" + "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" + "github.com/grafana/grafana/pkg/infra/appcontext" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util/errutil" + "github.com/grafana/grafana/pkg/util/errutil/errhttp" + "github.com/grafana/grafana/pkg/web" +) + +func (b *FeatureFlagAPIBuilder) getResolvedToggleState(ctx context.Context) v0alpha1.ResolvedToggleState { + state := v0alpha1.ResolvedToggleState{ + TypeMeta: v1.TypeMeta{ + APIVersion: v0alpha1.APIVERSION, + Kind: "ResolvedToggleState", + }, + Enabled: b.features.GetEnabled(ctx), + RestartRequired: b.features.IsRestartRequired(), + } + + // Reference to the object that defined the values + startupRef := &common.ObjectReference{ + Namespace: "system", + Name: "startup", + } + + startup := b.features.GetStartupFlags() + warnings := b.features.GetWarning() + for _, f := range b.features.GetFlags() { + name := f.Name + if b.features.IsHiddenFromAdminPage(name, false) { + continue + } + + toggle := v0alpha1.ToggleStatus{ + Name: name, + Description: f.Description, // simplify the UI changes + Stage: f.Stage.String(), + Enabled: state.Enabled[name], + Writeable: b.features.IsEditableFromAdminPage(name), + Source: startupRef, + Warning: warnings[name], + } + if f.Expression == "true" && toggle.Enabled { + toggle.Source = nil + } + _, inStartup := startup[name] + if toggle.Enabled || toggle.Writeable || toggle.Warning != "" || inStartup { + state.Toggles = append(state.Toggles, toggle) + } + + if toggle.Writeable { + state.AllowEditing = true + } + } + + // Make sure the user can actually write values + if state.AllowEditing { + state.AllowEditing = b.features.IsFeatureEditingAllowed() && b.userCanWrite(ctx, nil) + } + return state +} + +func (b *FeatureFlagAPIBuilder) userCanRead(ctx context.Context, u *user.SignedInUser) bool { + if u == nil { + u, _ = appcontext.User(ctx) + if u == nil { + return false + } + } + ok, err := b.accessControl.Evaluate(ctx, u, ac.EvalPermission(ac.ActionFeatureManagementRead)) + return ok && err == nil +} + +func (b *FeatureFlagAPIBuilder) userCanWrite(ctx context.Context, u *user.SignedInUser) bool { + if u == nil { + u, _ = appcontext.User(ctx) + if u == nil { + return false + } + } + ok, err := b.accessControl.Evaluate(ctx, u, ac.EvalPermission(ac.ActionFeatureManagementWrite)) + return ok && err == nil +} + +func (b *FeatureFlagAPIBuilder) handleCurrentStatus(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPatch { + b.handlePatchCurrent(w, r) + return + } + + // Check if the user can access toggle info + ctx := r.Context() + user, err := appcontext.User(ctx) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + if !b.userCanRead(ctx, user) { + err = errutil.Unauthorized("featuretoggle.canNotRead", + errutil.WithPublicMessage("missing read permission")).Errorf("user %s does not have read permissions", user.Login) + errhttp.Write(ctx, err, w) + return + } + + // Write the state to the response body + state := b.getResolvedToggleState(r.Context()) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(state) +} + +// NOTE: authz is already handled by the authorizer +func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !b.features.IsFeatureEditingAllowed() { + err := errutil.Forbidden("featuretoggle.disabled", + errutil.WithPublicMessage("feature toggles are read-only")).Errorf("feature toggles are not writeable due to missing configuration") + errhttp.Write(ctx, err, w) + return + } + + user, err := appcontext.User(ctx) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + if !b.userCanWrite(ctx, user) { + err = errutil.Unauthorized("featuretoggle.canNotWrite", + errutil.WithPublicMessage("missing write permission")).Errorf("user %s does not have write permissions", user.Login) + errhttp.Write(ctx, err, w) + return + } + + request := v0alpha1.ResolvedToggleState{} + err = web.Bind(r, &request) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + if len(request.Toggles) > 0 { + err = errutil.BadRequest("featuretoggle.badRequest", + errutil.WithPublicMessage("can only patch the enabled section")).Errorf("request payload included properties in the read-only Toggles section") + errhttp.Write(ctx, err, w) + return + } + + changes := map[string]string{} // TODO would be nice to have this be a bool on the HG side + for k, v := range request.Enabled { + current := b.features.IsEnabled(ctx, k) + if current != v { + if !b.features.IsEditableFromAdminPage(k) { + err = errutil.BadRequest("featuretoggle.badRequest", + errutil.WithPublicMessage("invalid toggle passed in")).Errorf("can not edit toggle %s", k) + errhttp.Write(ctx, err, w) + w.WriteHeader(http.StatusBadRequest) + return + } + changes[k] = strconv.FormatBool(v) + } + } + + if len(changes) == 0 { + w.WriteHeader(http.StatusNotModified) + return + } + + payload := featuremgmt.FeatureToggleWebhookPayload{ + FeatureToggles: changes, + User: user.Email, + } + + err = sendWebhookUpdate(b.features.Settings, payload) + if err != nil && b.cfg.Env != setting.Dev { + err = errutil.Internal("featuretoggle.webhookFailure", errutil.WithPublicMessage("an error occurred while updating feeature toggles")).Errorf("webhook error: %w", err) + errhttp.Write(ctx, err, w) + return + } + + b.features.SetRestartRequired() + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("feature toggles updated successfully")) +} + +func sendWebhookUpdate(cfg setting.FeatureMgmtSettings, payload featuremgmt.FeatureToggleWebhookPayload) error { + data, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, cfg.UpdateWebhook, bytes.NewBuffer(data)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+cfg.UpdateWebhookToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + logger.Warn("Failed to close response body", "err", err) + } + }() + + if resp.StatusCode >= http.StatusBadRequest { + if body, err := io.ReadAll(resp.Body); err != nil { + return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %s", resp.StatusCode, string(body)) + } else { + return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %w", resp.StatusCode, err) + } + } + + return nil +} diff --git a/pkg/registry/apis/featuretoggle/current_test.go b/pkg/registry/apis/featuretoggle/current_test.go new file mode 100644 index 0000000000000..7b1b24c52bb6b --- /dev/null +++ b/pkg/registry/apis/featuretoggle/current_test.go @@ -0,0 +1,460 @@ +package featuretoggle + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" +) + +func TestGetFeatureToggles(t *testing.T) { + t.Run("fails without adequate permissions", func(t *testing.T) { + features := featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, []*featuremgmt.FeatureFlag{{ + // Add this here to ensure the feature works as expected during tests + Name: featuremgmt.FlagFeatureToggleAdminPage, + Stage: featuremgmt.FeatureStageGeneralAvailability, + }}) + + b := NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: false}, &setting.Cfg{}) + + callGetWith(t, b, http.StatusUnauthorized) + }) + + t.Run("should be able to get feature toggles", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: "toggle1", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, + } + disabled := []string{"toggle2"} + + b := newTestAPIBuilder(t, features, disabled, setting.FeatureMgmtSettings{}) + result := callGetWith(t, b, http.StatusOK) + assert.Len(t, result.Toggles, 2) + t1, _ := findResult(t, result, "toggle1") + assert.True(t, t1.Enabled) + t2, _ := findResult(t, result, "toggle2") + assert.False(t, t2.Enabled) + }) + + t.Run("toggles hidden by config are not present in the response", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: "toggle1", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, + } + settings := setting.FeatureMgmtSettings{ + HiddenToggles: map[string]struct{}{"toggle1": {}}, + } + + b := newTestAPIBuilder(t, features, []string{}, settings) + result := callGetWith(t, b, http.StatusOK) + + assert.Len(t, result.Toggles, 1) + assert.Equal(t, "toggle2", result.Toggles[0].Name) + }) + + t.Run("toggles that are read-only by config have the readOnly field set", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: "toggle1", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, + } + disabled := []string{"toggle2"} + settings := setting.FeatureMgmtSettings{ + HiddenToggles: map[string]struct{}{"toggle1": {}}, + ReadOnlyToggles: map[string]struct{}{"toggle2": {}}, + AllowEditing: true, + UpdateWebhook: "bogus", + } + + b := newTestAPIBuilder(t, features, disabled, settings) + result := callGetWith(t, b, http.StatusOK) + + assert.Len(t, result.Toggles, 1) + assert.Equal(t, "toggle2", result.Toggles[0].Name) + assert.False(t, result.Toggles[0].Writeable) + }) + + t.Run("feature toggle defailts", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: "toggle1", + Stage: featuremgmt.FeatureStageUnknown, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStageExperimental, + }, { + Name: "toggle3", + Stage: featuremgmt.FeatureStagePrivatePreview, + }, { + Name: "toggle4", + Stage: featuremgmt.FeatureStagePublicPreview, + AllowSelfServe: true, + }, { + Name: "toggle5", + Stage: featuremgmt.FeatureStageGeneralAvailability, + AllowSelfServe: true, + }, { + Name: "toggle6", + Stage: featuremgmt.FeatureStageDeprecated, + AllowSelfServe: true, + }, { + Name: "toggle7", + Stage: featuremgmt.FeatureStageGeneralAvailability, + AllowSelfServe: false, + }, + } + + t.Run("unknown, experimental, and private preview toggles are hidden by default", func(t *testing.T) { + b := newTestAPIBuilder(t, features, []string{}, setting.FeatureMgmtSettings{}) + result := callGetWith(t, b, http.StatusOK) + + assert.Len(t, result.Toggles, 4) + + _, ok := findResult(t, result, "toggle1") + assert.False(t, ok) + _, ok = findResult(t, result, "toggle2") + assert.False(t, ok) + _, ok = findResult(t, result, "toggle3") + assert.False(t, ok) + }) + + t.Run("only public preview and GA with AllowSelfServe are writeable", func(t *testing.T) { + settings := setting.FeatureMgmtSettings{ + AllowEditing: true, + UpdateWebhook: "bogus", + } + + b := newTestAPIBuilder(t, features, []string{}, settings) + result := callGetWith(t, b, http.StatusOK) + + t4, ok := findResult(t, result, "toggle4") + assert.True(t, ok) + assert.True(t, t4.Writeable) + t5, ok := findResult(t, result, "toggle5") + assert.True(t, ok) + assert.True(t, t5.Writeable) + t6, ok := findResult(t, result, "toggle6") + assert.True(t, ok) + assert.True(t, t6.Writeable) + }) + + t.Run("all toggles are read-only when server is misconfigured", func(t *testing.T) { + settings := setting.FeatureMgmtSettings{ + AllowEditing: false, + UpdateWebhook: "", + } + b := newTestAPIBuilder(t, features, []string{}, settings) + result := callGetWith(t, b, http.StatusOK) + + assert.Len(t, result.Toggles, 4) + + t4, ok := findResult(t, result, "toggle4") + assert.True(t, ok) + assert.False(t, t4.Writeable) + t5, ok := findResult(t, result, "toggle5") + assert.True(t, ok) + assert.False(t, t5.Writeable) + t6, ok := findResult(t, result, "toggle6") + assert.True(t, ok) + assert.False(t, t6.Writeable) + }) + }) +} + +func TestSetFeatureToggles(t *testing.T) { + t.Run("fails when the user doesn't have write permissions", func(t *testing.T) { + s := setting.FeatureMgmtSettings{ + AllowEditing: true, + UpdateWebhook: "random", + } + features := featuremgmt.WithFeatureManager(s, []*featuremgmt.FeatureFlag{{ + // Add this here to ensure the feature works as expected during tests + Name: featuremgmt.FlagFeatureToggleAdminPage, + Stage: featuremgmt.FeatureStageGeneralAvailability, + }}) + + b := NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: false}, &setting.Cfg{}) + msg := callPatchWith(t, b, v0alpha1.ResolvedToggleState{}, http.StatusUnauthorized) + assert.Equal(t, "missing write permission", msg) + }) + + t.Run("fails when update toggle url is not set", func(t *testing.T) { + s := setting.FeatureMgmtSettings{ + AllowEditing: true, + } + b := newTestAPIBuilder(t, nil, []string{}, s) + msg := callPatchWith(t, b, v0alpha1.ResolvedToggleState{}, http.StatusForbidden) + assert.Equal(t, "feature toggles are read-only", msg) + }) + + t.Run("fails with non-existent toggle", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: "toggle1", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, + } + disabled := []string{"toggle2"} + update := v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + "toggle3": true, + }, + } + + s := setting.FeatureMgmtSettings{ + AllowEditing: true, + UpdateWebhook: "random", + } + b := newTestAPIBuilder(t, features, disabled, s) + msg := callPatchWith(t, b, update, http.StatusBadRequest) + assert.Equal(t, "invalid toggle passed in", msg) + }) + + t.Run("fails with read-only toggles", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: featuremgmt.FlagFeatureToggleAdminPage, + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStagePublicPreview, + }, { + Name: "toggle3", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, + } + disabled := []string{"toggle2", "toggle3"} + + s := setting.FeatureMgmtSettings{ + AllowEditing: true, + UpdateWebhook: "random", + ReadOnlyToggles: map[string]struct{}{ + "toggle3": {}, + }, + } + + t.Run("because it is the feature toggle admin page toggle", func(t *testing.T) { + update := v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + featuremgmt.FlagFeatureToggleAdminPage: true, + }, + } + b := newTestAPIBuilder(t, features, disabled, s) + callPatchWith(t, b, update, http.StatusNotModified) + }) + + t.Run("because it is not GA or Deprecated", func(t *testing.T) { + update := v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + "toggle2": true, + }, + } + b := newTestAPIBuilder(t, features, disabled, s) + msg := callPatchWith(t, b, update, http.StatusBadRequest) + assert.Equal(t, "invalid toggle passed in", msg) + }) + + t.Run("because it is configured to be read-only", func(t *testing.T) { + update := v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + "toggle2": true, + }, + } + b := newTestAPIBuilder(t, features, disabled, s) + msg := callPatchWith(t, b, update, http.StatusBadRequest) + assert.Equal(t, "invalid toggle passed in", msg) + }) + }) + + t.Run("when all conditions met", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: featuremgmt.FlagFeatureToggleAdminPage, + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStagePublicPreview, + }, { + Name: "toggle3", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle4", + Stage: featuremgmt.FeatureStageGeneralAvailability, + AllowSelfServe: true, + }, { + Name: "toggle5", + Stage: featuremgmt.FeatureStageDeprecated, + AllowSelfServe: true, + }, + } + disabled := []string{"toggle2", "toggle3", "toggle4"} + + s := setting.FeatureMgmtSettings{ + AllowEditing: true, + UpdateWebhook: "random", + UpdateWebhookToken: "token", + ReadOnlyToggles: map[string]struct{}{ + "toggle3": {}, + }, + } + + update := v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + "toggle4": true, + "toggle5": false, + }, + } + t.Run("fail when webhook request is not successful", func(t *testing.T) { + webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer webhookServer.Close() + s.UpdateWebhook = webhookServer.URL + + b := newTestAPIBuilder(t, features, disabled, s) + msg := callPatchWith(t, b, update, http.StatusInternalServerError) + assert.Equal(t, "an error occurred while updating feeature toggles", msg) + }) + + t.Run("succeed when webhook request is not successful but app is in dev mode", func(t *testing.T) { + webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer webhookServer.Close() + s.UpdateWebhook = webhookServer.URL + + b := newTestAPIBuilder(t, features, disabled, s) + b.cfg.Env = setting.Dev + callPatchWith(t, b, update, http.StatusOK) + }) + + t.Run("succeed when webhook request is successful", func(t *testing.T) { + webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer "+s.UpdateWebhookToken, r.Header.Get("Authorization")) + + var req featuremgmt.FeatureToggleWebhookPayload + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + + assert.Equal(t, "true", req.FeatureToggles["toggle4"]) + assert.Equal(t, "false", req.FeatureToggles["toggle5"]) + w.WriteHeader(http.StatusOK) + })) + defer webhookServer.Close() + s.UpdateWebhook = webhookServer.URL + + b := newTestAPIBuilder(t, features, disabled, s) + msg := callPatchWith(t, b, update, http.StatusOK) + assert.Equal(t, "feature toggles updated successfully", msg) + }) + }) +} + +func findResult(t *testing.T, result v0alpha1.ResolvedToggleState, name string) (v0alpha1.ToggleStatus, bool) { + t.Helper() + + for _, t := range result.Toggles { + if t.Name == name { + return t, true + } + } + return v0alpha1.ToggleStatus{}, false +} + +func callGetWith(t *testing.T, b *FeatureFlagAPIBuilder, expectedCode int) v0alpha1.ResolvedToggleState { + w := response.CreateNormalResponse(http.Header{}, []byte{}, 0) + req := &http.Request{ + Method: "GET", + Header: http.Header{}, + } + req.Header.Add("content-type", "application/json") + req = req.WithContext(appcontext.WithUser(req.Context(), &user.SignedInUser{})) + b.handleCurrentStatus(w, req) + + rts := v0alpha1.ResolvedToggleState{} + require.NoError(t, json.Unmarshal(w.Body(), &rts)) + require.Equal(t, expectedCode, w.Status()) + + // Tests don't expect the feature toggle admin page feature to be present, so remove them from the resolved toggle state + for i, t := range rts.Toggles { + if t.Name == "featureToggleAdminPage" { + rts.Toggles = append(rts.Toggles[0:i], rts.Toggles[i+1:]...) + } + } + + return rts +} + +func callPatchWith(t *testing.T, b *FeatureFlagAPIBuilder, update v0alpha1.ResolvedToggleState, expectedCode int) string { + w := response.CreateNormalResponse(http.Header{}, []byte{}, 0) + + body, err := json.Marshal(update) + require.NoError(t, err) + + req := &http.Request{ + Method: "PATCH", + Body: io.NopCloser(bytes.NewReader(body)), + Header: http.Header{}, + } + req.Header.Add("content-type", "application/json") + req = req.WithContext(appcontext.WithUser(req.Context(), &user.SignedInUser{})) + b.handleCurrentStatus(w, req) + + require.NotNil(t, w.Body()) + require.Equal(t, expectedCode, w.Status()) + + // Extract the public facing message if this is an error + if w.Status() > 399 { + res := map[string]any{} + require.NoError(t, json.Unmarshal(w.Body(), &res)) + + return res["message"].(string) + } + + return string(w.Body()) +} + +func newTestAPIBuilder( + t *testing.T, + serverFeatures []*featuremgmt.FeatureFlag, + disabled []string, // the flags that are disabled + settings setting.FeatureMgmtSettings, +) *FeatureFlagAPIBuilder { + t.Helper() + features := featuremgmt.WithFeatureManager(settings, append([]*featuremgmt.FeatureFlag{{ + // Add this here to ensure the feature works as expected during tests + Name: featuremgmt.FlagFeatureToggleAdminPage, + Stage: featuremgmt.FeatureStageGeneralAvailability, + }}, serverFeatures...), disabled...) + + return NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: true}, &setting.Cfg{}) +} diff --git a/pkg/registry/apis/featuretoggle/features.go b/pkg/registry/apis/featuretoggle/features.go new file mode 100644 index 0000000000000..6902d8bb3c8ed --- /dev/null +++ b/pkg/registry/apis/featuretoggle/features.go @@ -0,0 +1,114 @@ +package featuretoggle + +import ( + "context" + "fmt" + "sync" + + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" + "github.com/grafana/grafana/pkg/services/apiserver/utils" + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +var ( + _ rest.Storage = (*featuresStorage)(nil) + _ rest.Scoper = (*featuresStorage)(nil) + _ rest.SingularNameProvider = (*featuresStorage)(nil) + _ rest.Lister = (*featuresStorage)(nil) + _ rest.Getter = (*featuresStorage)(nil) +) + +type featuresStorage struct { + resource *common.ResourceInfo + tableConverter rest.TableConvertor + features *v0alpha1.FeatureList + featuresOnce sync.Once +} + +// NOTE! this does not depend on config or any system state! +// In the future, the existence of features (and their properties) can be defined dynamically +func NewFeaturesStorage() *featuresStorage { + resourceInfo := v0alpha1.FeatureResourceInfo + return &featuresStorage{ + resource: &resourceInfo, + tableConverter: utils.NewTableConverter( + resourceInfo.GroupResource(), + []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Stage", Type: "string", Format: "string", Description: "Where is the flag in the dev cycle"}, + {Name: "Owner", Type: "string", Format: "string", Description: "Which team owns the feature"}, + }, + func(obj any) ([]interface{}, error) { + r, ok := obj.(*v0alpha1.Feature) + if ok { + return []interface{}{ + r.Name, + r.Spec.Stage, + r.Spec.Owner, + }, nil + } + return nil, fmt.Errorf("expected resource or info") + }), + } +} + +func (s *featuresStorage) New() runtime.Object { + return s.resource.NewFunc() +} + +func (s *featuresStorage) Destroy() {} + +func (s *featuresStorage) NamespaceScoped() bool { + return false +} + +func (s *featuresStorage) GetSingularName() string { + return s.resource.GetSingularName() +} + +func (s *featuresStorage) NewList() runtime.Object { + return s.resource.NewListFunc() +} + +func (s *featuresStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return s.tableConverter.ConvertToTable(ctx, object, tableOptions) +} + +func (s *featuresStorage) init() { + s.featuresOnce.Do(func() { + rv := "1" + features, _ := featuremgmt.GetEmbeddedFeatureList() + for _, feature := range features.Items { + if feature.ResourceVersion > rv { + rv = feature.ResourceVersion + } + } + features.ResourceVersion = rv + s.features = &features + }) +} + +func (s *featuresStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + s.init() + if s.features == nil { + return nil, fmt.Errorf("error loading embedded features") + } + return s.features, nil +} + +func (s *featuresStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + s.init() + + for idx, flag := range s.features.Items { + if flag.Name == name { + return &s.features.Items[idx], nil + } + } + return nil, fmt.Errorf("not found") +} diff --git a/pkg/registry/apis/featuretoggle/register.go b/pkg/registry/apis/featuretoggle/register.go new file mode 100644 index 0000000000000..e93b396d55a4e --- /dev/null +++ b/pkg/registry/apis/featuretoggle/register.go @@ -0,0 +1,213 @@ +package featuretoggle + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/rest" + genericapiserver "k8s.io/apiserver/pkg/server" + common "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" + "github.com/grafana/grafana/pkg/apiserver/builder" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" +) + +var _ builder.APIGroupBuilder = (*FeatureFlagAPIBuilder)(nil) + +var gv = v0alpha1.SchemeGroupVersion + +// This is used just so wire has something unique to return +type FeatureFlagAPIBuilder struct { + features *featuremgmt.FeatureManager + accessControl accesscontrol.AccessControl + cfg *setting.Cfg +} + +func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager, accessControl accesscontrol.AccessControl, cfg *setting.Cfg) *FeatureFlagAPIBuilder { + return &FeatureFlagAPIBuilder{features, accessControl, cfg} +} + +func RegisterAPIService(features *featuremgmt.FeatureManager, + accessControl accesscontrol.AccessControl, + apiregistration builder.APIRegistrar, + cfg *setting.Cfg, +) *FeatureFlagAPIBuilder { + builder := NewFeatureFlagAPIBuilder(features, accessControl, cfg) + apiregistration.RegisterAPI(builder) + return builder +} + +func (b *FeatureFlagAPIBuilder) GetGroupVersion() schema.GroupVersion { + return gv +} + +func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { + scheme.AddKnownTypes(gv, + &v0alpha1.Feature{}, + &v0alpha1.FeatureList{}, + &v0alpha1.FeatureToggles{}, + &v0alpha1.FeatureTogglesList{}, + &v0alpha1.ResolvedToggleState{}, + ) +} + +func (b *FeatureFlagAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { + addKnownTypes(scheme, gv) + + // Link this version to the internal representation. + // This is used for server-side-apply (PATCH), and avoids the error: + // "no kind is registered for the type" + addKnownTypes(scheme, schema.GroupVersion{ + Group: gv.Group, + Version: runtime.APIVersionInternal, + }) + + // If multiple versions exist, then register conversions from zz_generated.conversion.go + // if err := playlist.RegisterConversions(scheme); err != nil { + // return err + // } + metav1.AddToGroupVersion(scheme, gv) + return scheme.SetVersionPriority(gv) +} + +func (b *FeatureFlagAPIBuilder) GetAPIGroupInfo( + scheme *runtime.Scheme, + codecs serializer.CodecFactory, // pointer? + _ generic.RESTOptionsGetter, + _ bool, +) (*genericapiserver.APIGroupInfo, error) { + apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(v0alpha1.GROUP, scheme, metav1.ParameterCodec, codecs) + + featureStore := NewFeaturesStorage() + toggleStore := NewTogglesStorage(b.features) + + storage := map[string]rest.Storage{} + storage[featureStore.resource.StoragePath()] = featureStore + storage[toggleStore.resource.StoragePath()] = toggleStore + + apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage + return &apiGroupInfo, nil +} + +func (b *FeatureFlagAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { + return v0alpha1.GetOpenAPIDefinitions +} + +func (b *FeatureFlagAPIBuilder) GetAuthorizer() authorizer.Authorizer { + return nil // default authorizer is fine +} + +// Register additional routes with the server +func (b *FeatureFlagAPIBuilder) GetAPIRoutes() *builder.APIRoutes { + defs := v0alpha1.GetOpenAPIDefinitions(func(path string) spec.Ref { return spec.Ref{} }) + stateSchema := defs["github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.ResolvedToggleState"].Schema + + tags := []string{"Editor"} + return &builder.APIRoutes{ + Root: []builder.APIRouteHandler{ + { + Path: "current", + Spec: &spec3.PathProps{ + Get: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + Tags: tags, + Summary: "Current configuration with details", + Description: "Show details about the current flags and where they come from", + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + StatusCodeResponses: map[int]*spec3.Response{ + 200: { + ResponseProps: spec3.ResponseProps{ + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &stateSchema, + }, + }, + }, + Description: "OK", + }, + }, + }, + }, + }, + }, + }, + Patch: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + Tags: tags, + Summary: "Update individual toggles", + Description: "Patch some of the toggles (keyed by the toggle name)", + RequestBody: &spec3.RequestBody{ + RequestBodyProps: spec3.RequestBodyProps{ + Required: true, + Description: "flags to change", + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &stateSchema, + Example: &v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + featuremgmt.FlagAutoMigrateOldPanels: true, + featuremgmt.FlagAngularDeprecationUI: false, + }, + }, + Examples: map[string]*spec3.Example{ + "enable-auto-migrate": { + ExampleProps: spec3.ExampleProps{ + Summary: "enable auto-migrate panels", + Description: "enable description", + Value: &v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + featuremgmt.FlagAutoMigrateOldPanels: true, + }, + }, + }, + }, + "disable-auto-migrate": { + ExampleProps: spec3.ExampleProps{ + Summary: "disable auto-migrate panels", + Description: "disable description", + Value: &v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + featuremgmt.FlagAutoMigrateOldPanels: false, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + StatusCodeResponses: map[int]*spec3.Response{ + 200: { + ResponseProps: spec3.ResponseProps{ + Content: map[string]*spec3.MediaType{ + "application/json": {}, + }, + Description: "OK", + }, + }, + }, + }, + }, + }, + }, + }, + Handler: b.handleCurrentStatus, + }, + }, + } +} diff --git a/pkg/registry/apis/featuretoggle/toggles.go b/pkg/registry/apis/featuretoggle/toggles.go new file mode 100644 index 0000000000000..9c920c5a0ba93 --- /dev/null +++ b/pkg/registry/apis/featuretoggle/toggles.go @@ -0,0 +1,92 @@ +package featuretoggle + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +var ( + _ rest.Storage = (*togglesStorage)(nil) + _ rest.Scoper = (*togglesStorage)(nil) + _ rest.SingularNameProvider = (*togglesStorage)(nil) + _ rest.Lister = (*togglesStorage)(nil) + _ rest.Getter = (*togglesStorage)(nil) +) + +type togglesStorage struct { + resource *common.ResourceInfo + tableConverter rest.TableConvertor + + // The startup toggles + startup *v0alpha1.FeatureToggles +} + +func NewTogglesStorage(features *featuremgmt.FeatureManager) *togglesStorage { + resourceInfo := v0alpha1.TogglesResourceInfo + return &togglesStorage{ + resource: &resourceInfo, + startup: &v0alpha1.FeatureToggles{ + TypeMeta: resourceInfo.TypeMeta(), + ObjectMeta: metav1.ObjectMeta{ + Name: "startup", + Namespace: "system", + CreationTimestamp: metav1.Now(), + }, + Spec: features.GetStartupFlags(), + }, + tableConverter: rest.NewDefaultTableConvertor(resourceInfo.GroupResource()), + } +} + +func (s *togglesStorage) New() runtime.Object { + return s.resource.NewFunc() +} + +func (s *togglesStorage) Destroy() {} + +func (s *togglesStorage) NamespaceScoped() bool { + return true +} + +func (s *togglesStorage) GetSingularName() string { + return s.resource.GetSingularName() +} + +func (s *togglesStorage) NewList() runtime.Object { + return s.resource.NewListFunc() +} + +func (s *togglesStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return s.tableConverter.ConvertToTable(ctx, object, tableOptions) +} + +func (s *togglesStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + flags := &v0alpha1.FeatureTogglesList{ + Items: []v0alpha1.FeatureToggles{*s.startup}, + } + return flags, nil +} + +func (s *togglesStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + info, err := request.NamespaceInfoFrom(ctx, false) // allow system + if err != nil { + return nil, err + } + if info.Value != "" && info.Value != "system" { + return nil, fmt.Errorf("only system namespace is currently supported") + } + if name != "startup" { + return nil, fmt.Errorf("only system/startup is currently supported") + } + return s.startup, nil +} diff --git a/pkg/registry/apis/folders/conversions.go b/pkg/registry/apis/folders/conversions.go index 21f0d977d24a1..9c9678a490906 100644 --- a/pkg/registry/apis/folders/conversions.go +++ b/pkg/registry/apis/folders/conversions.go @@ -5,28 +5,13 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/grafana/grafana/pkg/apis/folders/v0alpha1" - "github.com/grafana/grafana/pkg/kinds" + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/utils" "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils" ) func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) *v0alpha1.Folder { - meta := kinds.GrafanaResourceMetadata{} - meta.SetUpdatedTimestampMillis(v.Updated.UnixMilli()) - if v.ID > 0 { // nolint:staticcheck - meta.SetOriginInfo(&kinds.ResourceOriginInfo{ - Name: "SQL", - Key: fmt.Sprintf("%d", v.ID), // nolint:staticcheck - }) - } - if v.CreatedBy > 0 { - meta.SetCreatedBy(fmt.Sprintf("user:%d", v.CreatedBy)) - } - if v.UpdatedBy > 0 { - meta.SetUpdatedBy(fmt.Sprintf("user:%d", v.UpdatedBy)) - } f := &v0alpha1.Folder{ TypeMeta: v0alpha1.FolderResourceInfo.TypeMeta(), ObjectMeta: metav1.ObjectMeta{ @@ -34,13 +19,32 @@ func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) ResourceVersion: fmt.Sprintf("%d", v.Updated.UnixMilli()), CreationTimestamp: metav1.NewTime(v.Created), Namespace: namespacer(v.OrgID), - Annotations: meta.Annotations, }, Spec: v0alpha1.Spec{ Title: v.Title, Description: v.Description, }, } + + meta, err := utils.MetaAccessor(f) + if err == nil { + meta.SetUpdatedTimestamp(&v.Updated) + if v.ID > 0 { // nolint:staticcheck + meta.SetOriginInfo(&utils.ResourceOriginInfo{ + Name: "SQL", + Key: fmt.Sprintf("%d", v.ID), // nolint:staticcheck + }) + } + if v.CreatedBy > 0 { + meta.SetCreatedBy(fmt.Sprintf("user:%d", v.CreatedBy)) + } + if v.UpdatedBy > 0 { + meta.SetUpdatedBy(fmt.Sprintf("user:%d", v.UpdatedBy)) + } + } + if v.ParentUID != "" { + meta.SetFolder(v.ParentUID) + } f.UID = utils.CalculateClusterWideUID(f) return f } diff --git a/pkg/registry/apis/folders/legacy_storage.go b/pkg/registry/apis/folders/legacy_storage.go index 402850a582edf..82a4f097a3297 100644 --- a/pkg/registry/apis/folders/legacy_storage.go +++ b/pkg/registry/apis/folders/legacy_storage.go @@ -11,12 +11,13 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/rest" - "github.com/grafana/grafana/pkg/apis/folders/v0alpha1" + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" "github.com/grafana/grafana/pkg/infra/appcontext" - "github.com/grafana/grafana/pkg/kinds" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/storage/entity" + "github.com/grafana/grafana/pkg/services/apiserver/utils" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" "github.com/grafana/grafana/pkg/util" ) @@ -65,6 +66,18 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO return nil, err } + parentUID := "" + // translate grafana.app/* label selectors into field requirements + requirements, newSelector, err := entity.ReadLabelSelectors(options.LabelSelector) + if err != nil { + return nil, err + } + if requirements.Folder != nil { + parentUID = *requirements.Folder + } + // Update the selector to remove the unneeded requirements + options.LabelSelector = newSelector + paging, err := readContinueToken(options) if err != nil { return nil, err @@ -77,6 +90,7 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO // When nested folders are not enabled, all folders are root folders hits, err := s.service.GetChildren(ctx, &folder.GetChildrenQuery{ + UID: parentUID, // NOTE! we should do a different query when nested folders are enabled! SignedInUser: user, Limit: paging.page, OrgID: orgId, @@ -148,7 +162,10 @@ func (s *legacyStorage) Create(ctx context.Context, p.Spec.Title = strings.ReplaceAll(p.Spec.Title, "${RAND}", rand) } - accessor := kinds.MetaAccessor(p) + accessor, err := utils.MetaAccessor(p) + if err != nil { + return nil, err + } parent := accessor.GetFolder() out, err := s.service.Create(ctx, &folder.CreateFolderCommand{ @@ -202,8 +219,10 @@ func (s *legacyStorage) Update(ctx context.Context, return nil, created, fmt.Errorf("expected old object to be a folder also") } - oldParent := kinds.MetaAccessor(old).GetFolder() - newParent := kinds.MetaAccessor(f).GetFolder() + mOld, _ := utils.MetaAccessor(old) + mNew, _ := utils.MetaAccessor(f) + oldParent := mOld.GetFolder() + newParent := mNew.GetFolder() if oldParent != newParent { _, err = s.service.Move(ctx, &folder.MoveFolderCommand{ SignedInUser: user, diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index 381dcb0cf2230..19cb2d71f48d5 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -1,6 +1,7 @@ package folders import ( + "context" "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -9,49 +10,53 @@ import ( "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/registry/generic" - genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" common "k8s.io/kube-openapi/pkg/common" - - "github.com/grafana/grafana/pkg/apis/folders/v0alpha1" - "github.com/grafana/grafana/pkg/kinds" + "k8s.io/kube-openapi/pkg/spec3" + + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + "github.com/grafana/grafana/pkg/apiserver/builder" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/utils" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" - grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" - grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils" "github.com/grafana/grafana/pkg/setting" ) -var _ grafanaapiserver.APIGroupBuilder = (*FolderAPIBuilder)(nil) +var _ builder.APIGroupBuilder = (*FolderAPIBuilder)(nil) var resourceInfo = v0alpha1.FolderResourceInfo // This is used just so wire has something unique to return type FolderAPIBuilder struct { - gv schema.GroupVersion - features *featuremgmt.FeatureManager - namespacer request.NamespaceMapper - folderSvc folder.Service + gv schema.GroupVersion + features *featuremgmt.FeatureManager + namespacer request.NamespaceMapper + folderSvc folder.Service + accessControl accesscontrol.AccessControl } func RegisterAPIService(cfg *setting.Cfg, features *featuremgmt.FeatureManager, - apiregistration grafanaapiserver.APIRegistrar, + apiregistration builder.APIRegistrar, folderSvc folder.Service, + accessControl accesscontrol.AccessControl, ) *FolderAPIBuilder { if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { return nil // skip registration unless opting into experimental apis } builder := &FolderAPIBuilder{ - gv: resourceInfo.GroupVersion(), - features: features, - namespacer: request.GetNamespaceMapper(cfg), - folderSvc: folderSvc, + gv: resourceInfo.GroupVersion(), + features: features, + namespacer: request.GetNamespaceMapper(cfg), + folderSvc: folderSvc, + accessControl: accessControl, } apiregistration.RegisterAPI(builder) return builder @@ -65,7 +70,9 @@ func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { scheme.AddKnownTypes(gv, &v0alpha1.Folder{}, &v0alpha1.FolderList{}, - &v0alpha1.FolderInfo{}, + &v0alpha1.FolderInfoList{}, + &v0alpha1.DescendantCounts{}, + &v0alpha1.FolderAccessInfo{}, ) } @@ -92,52 +99,42 @@ func (b *FolderAPIBuilder) GetAPIGroupInfo( scheme *runtime.Scheme, codecs serializer.CodecFactory, // pointer? optsGetter generic.RESTOptionsGetter, + dualWrite bool, ) (*genericapiserver.APIGroupInfo, error) { apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(v0alpha1.GROUP, scheme, metav1.ParameterCodec, codecs) - strategy := grafanaregistry.NewStrategy(scheme) - store := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - PredicateFunc: grafanaregistry.Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - } - store.TableConvertor = utils.NewTableConverter( - store.DefaultQualifiedResource, - []metav1.TableColumnDefinition{ - {Name: "Name", Type: "string", Format: "name"}, - {Name: "Title", Type: "string", Format: "string", Description: "The display name"}, - {Name: "Parent", Type: "string", Format: "string", Description: "Parent folder UID"}, - }, - func(obj any) ([]interface{}, error) { - r, ok := obj.(*v0alpha1.Folder) - if ok { - accessor := kinds.MetaAccessor(r) - return []interface{}{ - r.Name, - r.Spec.Title, - accessor.GetFolder(), - }, nil - } - return nil, fmt.Errorf("expected resource or info") - }) legacyStore := &legacyStorage{ - service: b.folderSvc, - namespacer: b.namespacer, - tableConverter: store.TableConvertor, + service: b.folderSvc, + namespacer: b.namespacer, + tableConverter: utils.NewTableConverter( + resourceInfo.GroupResource(), + []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Title", Type: "string", Format: "string", Description: "The display name"}, + {Name: "Parent", Type: "string", Format: "string", Description: "Parent folder UID"}, + }, + func(obj any) ([]interface{}, error) { + r, ok := obj.(*v0alpha1.Folder) + if ok { + accessor, _ := utils.MetaAccessor(r) + return []interface{}{ + r.Name, + r.Spec.Title, + accessor.GetFolder(), + }, nil + } + return nil, fmt.Errorf("expected resource or info") + }), } storage := map[string]rest.Storage{} storage[resourceInfo.StoragePath()] = legacyStore storage[resourceInfo.StoragePath("parents")] = &subParentsREST{b.folderSvc} - storage[resourceInfo.StoragePath("children")] = &subChildrenREST{b.folderSvc} + storage[resourceInfo.StoragePath("count")] = &subCountREST{b.folderSvc} + storage[resourceInfo.StoragePath("access")] = &subAccessREST{b.folderSvc} // enable dual writes if a RESTOptionsGetter is provided - if optsGetter != nil { + if dualWrite && optsGetter != nil { store, err := newStorage(scheme, optsGetter, legacyStore) if err != nil { return nil, err @@ -153,10 +150,63 @@ func (b *FolderAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions return v0alpha1.GetOpenAPIDefinitions } -func (b *FolderAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes { +func (b *FolderAPIBuilder) GetAPIRoutes() *builder.APIRoutes { return nil // no custom API routes } +func (b *FolderAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) { + // The plugin description + oas.Info.Description = "Grafana folders" + + // The root api URL + root := "/apis/" + b.GetGroupVersion().String() + "/" + + // Hide the ability to list or watch across all tenants + delete(oas.Paths.Paths, root+v0alpha1.FolderResourceInfo.GroupResource().Resource) + delete(oas.Paths.Paths, root+"watch/"+v0alpha1.FolderResourceInfo.GroupResource().Resource) + + // The root API discovery list + sub := oas.Paths.Paths[root] + if sub != nil && sub.Get != nil { + sub.Get.Tags = []string{"API Discovery"} // sorts first in the list + } + return oas, nil +} + func (b *FolderAPIBuilder) GetAuthorizer() authorizer.Authorizer { - return nil // TODO: the FGAC rules encoded in the service can be moved here + return authorizer.AuthorizerFunc( + func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { + if !attr.IsResourceRequest() || attr.GetName() == "" { + return authorizer.DecisionNoOpinion, "", nil + } + + // require a user + user, err := appcontext.User(ctx) + if err != nil { + return authorizer.DecisionDeny, "valid user is required", err + } + + action := dashboards.ActionFoldersRead + scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(attr.GetName()) + + // "get" is used for sub-resources with GET http (parents, access, count) + switch attr.GetVerb() { + case "patch": + fallthrough + case "create": + fallthrough + case "update": + action = dashboards.ActionFoldersWrite + case "deletecollection": + fallthrough + case "delete": + action = dashboards.ActionFoldersDelete + } + + ok, err := b.accessControl.Evaluate(ctx, user, accesscontrol.EvalPermission(action, scope)) + if ok { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "folder", err + }) } diff --git a/pkg/registry/apis/folders/storage.go b/pkg/registry/apis/folders/storage.go index 97c1d056c9266..1466a703f5d8d 100644 --- a/pkg/registry/apis/folders/storage.go +++ b/pkg/registry/apis/folders/storage.go @@ -5,9 +5,9 @@ import ( "k8s.io/apiserver/pkg/registry/generic" genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" - "github.com/grafana/grafana/pkg/apis/folders/v0alpha1" - grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest" + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" ) var _ grafanarest.Storage = (*storage)(nil) diff --git a/pkg/registry/apis/folders/sub_access.go b/pkg/registry/apis/folders/sub_access.go new file mode 100644 index 0000000000000..be506be9ece08 --- /dev/null +++ b/pkg/registry/apis/folders/sub_access.go @@ -0,0 +1,78 @@ +package folders + +import ( + "context" + "net/http" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/guardian" +) + +type subAccessREST struct { + service folder.Service +} + +var _ = rest.Connecter(&subAccessREST{}) +var _ = rest.StorageMetadata(&subAccessREST{}) + +func (r *subAccessREST) New() runtime.Object { + return &v0alpha1.FolderAccessInfo{} +} + +func (r *subAccessREST) Destroy() { +} + +func (r *subAccessREST) ConnectMethods() []string { + return []string{"GET"} +} + +func (r *subAccessREST) ProducesMIMETypes(verb string) []string { + return nil +} + +func (r *subAccessREST) ProducesObject(verb string) interface{} { + return &v0alpha1.FolderAccessInfo{} +} + +func (r *subAccessREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, false, "" // true means you can use the trailing path as a variable +} + +func (r *subAccessREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + ns, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } + // Can view is managed here (and in the Authorizer) + f, err := r.service.Get(ctx, &folder.GetFolderQuery{ + UID: &name, + OrgID: ns.OrgID, + SignedInUser: user, + }) + if err != nil { + return nil, err + } + guardian, err := guardian.NewByFolder(ctx, f, ns.OrgID, user) + if err != nil { + return nil, err + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + access := &v0alpha1.FolderAccessInfo{} + access.CanEdit, _ = guardian.CanEdit() + access.CanSave, _ = guardian.CanSave() + access.CanAdmin, _ = guardian.CanAdmin() + access.CanDelete, _ = guardian.CanDelete() + responder.Object(http.StatusOK, access) + }), nil +} diff --git a/pkg/registry/apis/folders/sub_children.go b/pkg/registry/apis/folders/sub_children.go deleted file mode 100644 index 4a86b033f28bb..0000000000000 --- a/pkg/registry/apis/folders/sub_children.go +++ /dev/null @@ -1,72 +0,0 @@ -package folders - -import ( - "context" - "net/http" - - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/rest" - - "github.com/grafana/grafana/pkg/apis/folders/v0alpha1" - "github.com/grafana/grafana/pkg/infra/appcontext" - "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" -) - -type subChildrenREST struct { - service folder.Service -} - -var _ = rest.Connecter(&subChildrenREST{}) - -func (r *subChildrenREST) New() runtime.Object { - return &v0alpha1.FolderInfo{} -} - -func (r *subChildrenREST) Destroy() { -} - -func (r *subChildrenREST) ConnectMethods() []string { - return []string{"GET"} -} - -func (r *subChildrenREST) NewConnectOptions() (runtime.Object, bool, string) { - return nil, false, "" // true means you can use the trailing path as a variable -} - -func (r *subChildrenREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - ns, err := request.NamespaceInfoFrom(ctx, true) - if err != nil { - responder.Error(err) - return - } - - user, err := appcontext.User(ctx) - if err != nil { - responder.Error(err) - return - } - - children, err := r.service.GetChildren(ctx, &folder.GetChildrenQuery{ - SignedInUser: user, - UID: name, - OrgID: ns.OrgID, - }) - if err != nil { - responder.Error(err) - return - } - - info := &v0alpha1.FolderInfo{ - Items: make([]v0alpha1.FolderItem, 0), - } - for _, parent := range children { - info.Items = append(info.Items, v0alpha1.FolderItem{ - Name: parent.UID, - Title: parent.Title, - }) - } - responder.Object(http.StatusOK, info) - }), nil -} diff --git a/pkg/registry/apis/folders/sub_count.go b/pkg/registry/apis/folders/sub_count.go new file mode 100644 index 0000000000000..f3b7d7e61b1b1 --- /dev/null +++ b/pkg/registry/apis/folders/sub_count.go @@ -0,0 +1,75 @@ +package folders + +import ( + "context" + "net/http" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/folder" +) + +type subCountREST struct { + service folder.Service +} + +var ( + _ = rest.Connecter(&subCountREST{}) + _ = rest.StorageMetadata(&subCountREST{}) +) + +func (r *subCountREST) New() runtime.Object { + return &v0alpha1.DescendantCounts{} +} + +func (r *subCountREST) Destroy() { +} + +func (r *subCountREST) ConnectMethods() []string { + return []string{"GET"} +} + +func (r *subCountREST) ProducesMIMETypes(verb string) []string { + return nil +} + +func (r *subCountREST) ProducesObject(verb string) interface{} { + return &v0alpha1.DescendantCounts{} +} + +func (r *subCountREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, false, "" // true means you can use the trailing path as a variable +} + +func (r *subCountREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ns, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + responder.Error(err) + return + } + + counts, err := r.service.GetDescendantCounts(ctx, &folder.GetDescendantCountsQuery{ + UID: &name, + OrgID: ns.OrgID, + SignedInUser: user, + }) + if err != nil { + responder.Error(err) + return + } + + responder.Object(http.StatusOK, &v0alpha1.DescendantCounts{ + Counts: counts, + }) + }), nil +} diff --git a/pkg/registry/apis/folders/sub_parents.go b/pkg/registry/apis/folders/sub_parents.go index 618c41d199a94..f7761d075b1e6 100644 --- a/pkg/registry/apis/folders/sub_parents.go +++ b/pkg/registry/apis/folders/sub_parents.go @@ -7,9 +7,9 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/rest" - "github.com/grafana/grafana/pkg/apis/folders/v0alpha1" + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" ) type subParentsREST struct { @@ -17,9 +17,10 @@ type subParentsREST struct { } var _ = rest.Connecter(&subParentsREST{}) +var _ = rest.StorageMetadata(&subParentsREST{}) func (r *subParentsREST) New() runtime.Object { - return &v0alpha1.FolderInfo{} + return &v0alpha1.FolderInfoList{} } func (r *subParentsREST) Destroy() { @@ -29,6 +30,14 @@ func (r *subParentsREST) ConnectMethods() []string { return []string{"GET"} } +func (r *subParentsREST) ProducesMIMETypes(verb string) []string { + return nil +} + +func (r *subParentsREST) ProducesObject(verb string) interface{} { + return &v0alpha1.FolderInfoList{} +} + func (r *subParentsREST) NewConnectOptions() (runtime.Object, bool, string) { return nil, false, "" // true means you can use the trailing path as a variable } @@ -50,13 +59,14 @@ func (r *subParentsREST) Connect(ctx context.Context, name string, opts runtime. return } - info := &v0alpha1.FolderInfo{ - Items: make([]v0alpha1.FolderItem, 0), + info := &v0alpha1.FolderInfoList{ + Items: make([]v0alpha1.FolderInfo, 0), } for _, parent := range parents { - info.Items = append(info.Items, v0alpha1.FolderItem{ - Name: parent.UID, - Title: parent.Title, + info.Items = append(info.Items, v0alpha1.FolderInfo{ + UID: parent.UID, + Title: parent.Title, + Parent: parent.ParentUID, }) } responder.Object(http.StatusOK, info) diff --git a/pkg/registry/apis/peakq/register.go b/pkg/registry/apis/peakq/register.go new file mode 100644 index 0000000000000..076b3b427a0fc --- /dev/null +++ b/pkg/registry/apis/peakq/register.go @@ -0,0 +1,177 @@ +package peakq + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/rest" + genericapiserver "k8s.io/apiserver/pkg/server" + "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + + peakq "github.com/grafana/grafana/pkg/apis/peakq/v0alpha1" + "github.com/grafana/grafana/pkg/apiserver/builder" + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +var _ builder.APIGroupBuilder = (*PeakQAPIBuilder)(nil) + +// This is used just so wire has something unique to return +type PeakQAPIBuilder struct{} + +func NewPeakQAPIBuilder() *PeakQAPIBuilder { + return &PeakQAPIBuilder{} +} + +func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar) *PeakQAPIBuilder { + if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { + return nil // skip registration unless opting into experimental apis + } + builder := NewPeakQAPIBuilder() + apiregistration.RegisterAPI(NewPeakQAPIBuilder()) + return builder +} + +func (b *PeakQAPIBuilder) GetAuthorizer() authorizer.Authorizer { + return nil // default authorizer is fine +} + +func (b *PeakQAPIBuilder) GetGroupVersion() schema.GroupVersion { + return peakq.SchemeGroupVersion +} + +func (b *PeakQAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { + gv := peakq.SchemeGroupVersion + err := peakq.AddToScheme(scheme) + if err != nil { + return err + } + + // Link this version to the internal representation. + // This is used for server-side-apply (PATCH), and avoids the error: + // "no kind is registered for the type" + // addKnownTypes(scheme, schema.GroupVersion{ + // Group: peakq.GROUP, + // Version: runtime.APIVersionInternal, + // }) + metav1.AddToGroupVersion(scheme, gv) + return scheme.SetVersionPriority(gv) +} + +func (b *PeakQAPIBuilder) GetAPIGroupInfo( + scheme *runtime.Scheme, + codecs serializer.CodecFactory, + optsGetter generic.RESTOptionsGetter, + _ bool, // dual write (not relevant) +) (*genericapiserver.APIGroupInfo, error) { + apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(peakq.GROUP, scheme, metav1.ParameterCodec, codecs) + + resourceInfo := peakq.QueryTemplateResourceInfo + storage := map[string]rest.Storage{} + peakqStorage, err := newStorage(scheme, optsGetter) + if err != nil { + return nil, err + } + storage[resourceInfo.StoragePath()] = peakqStorage + storage[resourceInfo.StoragePath("render")] = &renderREST{ + getter: peakqStorage, + } + + apiGroupInfo.VersionedResourcesStorageMap[peakq.VERSION] = storage + return &apiGroupInfo, nil +} + +func (b *PeakQAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { + return peakq.GetOpenAPIDefinitions +} + +// NOT A GREAT APPROACH... BUT will make a UI for statically defined +func (b *PeakQAPIBuilder) GetAPIRoutes() *builder.APIRoutes { + defs := peakq.GetOpenAPIDefinitions(func(path string) spec.Ref { return spec.Ref{} }) + renderedQuerySchema := defs["github.com/grafana/grafana/pkg/apis/peakq/v0alpha1.RenderedQuery"].Schema + queryTemplateSpecSchema := defs["github.com/grafana/grafana/pkg/apis/peakq/v0alpha1.QueryTemplateSpec"].Schema + + params := []*spec3.Parameter{ + { + ParameterProps: spec3.ParameterProps{ + // Arbitrary name. It won't appear in the request URL, + // but will be used in code generated from this OAS spec + Name: "variables", + In: "query", + Schema: spec.MapProperty(spec.ArrayProperty(spec.StringProperty())), + Style: "form", + Explode: true, + Description: "Each variable is prefixed with var-{variable}={value}", + Example: map[string][]string{ + "var-metricName": {"up"}, + "var-another": {"first", "second"}, + }, + }, + }, + } + return &builder.APIRoutes{ + Root: []builder.APIRouteHandler{ + { + Path: "render", + Spec: &spec3.PathProps{ + Summary: "an example at the root level", + Description: "longer description here?", + Post: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + Parameters: params, + RequestBody: &spec3.RequestBody{ + RequestBodyProps: spec3.RequestBodyProps{ + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &queryTemplateSpecSchema, + // Example: basicTemplateSpec, + Examples: map[string]*spec3.Example{ + "test": { + ExampleProps: spec3.ExampleProps{ + Summary: "hello", + Value: basicTemplateSpec, + }, + }, + "test2": { + ExampleProps: spec3.ExampleProps{ + Summary: "hello2", + Value: basicTemplateSpec, + }, + }, + }, + }, + }, + }, + }, + }, + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + StatusCodeResponses: map[int]*spec3.Response{ + 200: { + ResponseProps: spec3.ResponseProps{ + Description: "OK", + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &renderedQuerySchema, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Handler: renderPOSTHandler, + }, + }, + } +} diff --git a/pkg/registry/apis/peakq/render.go b/pkg/registry/apis/peakq/render.go new file mode 100644 index 0000000000000..fd0791b49057e --- /dev/null +++ b/pkg/registry/apis/peakq/render.go @@ -0,0 +1,107 @@ +package peakq + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + peakq "github.com/grafana/grafana/pkg/apis/peakq/v0alpha1" + "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template" +) + +type renderREST struct { + getter rest.Getter +} + +var _ = rest.Connecter(&renderREST{}) + +func (r *renderREST) New() runtime.Object { + return &peakq.RenderedQuery{} +} + +func (r *renderREST) Destroy() { +} + +func (r *renderREST) ConnectMethods() []string { + return []string{"GET"} +} + +func (r *renderREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, false, "" // true means you can use the trailing path as a variable +} + +func (r *renderREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + obj, err := r.getter.Get(ctx, name, &v1.GetOptions{}) + if err != nil { + return nil, err + } + t, ok := obj.(*peakq.QueryTemplate) + if !ok { + return nil, fmt.Errorf("expected template") + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + input, err := makeVarMapFromParams(req.URL.Query()) + if err != nil { + responder.Error(err) + return + } + out, err := template.RenderTemplate(t.Spec, input) + if err != nil { + responder.Error(fmt.Errorf("failed to render: %w", err)) + return + } + responder.Object(http.StatusOK, &peakq.RenderedQuery{ + Targets: out, + }) + }), nil +} + +func renderPOSTHandler(w http.ResponseWriter, req *http.Request) { + input, err := makeVarMapFromParams(req.URL.Query()) + if err != nil { + _, _ = w.Write([]byte("ERROR: " + err.Error())) + w.WriteHeader(500) + return + } + + var qT peakq.QueryTemplate + err = json.NewDecoder(req.Body).Decode(&qT.Spec) + if err != nil { + _, _ = w.Write([]byte("ERROR: " + err.Error())) + w.WriteHeader(500) + return + } + results, err := template.RenderTemplate(qT.Spec, input) + if err != nil { + _, _ = w.Write([]byte("ERROR: " + err.Error())) + w.WriteHeader(500) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(peakq.RenderedQuery{ + Targets: results, + }) +} + +// Replicate the grafana dashboard URL syntax +// &var-abc=1&var=abc=2&var-xyz=3... +func makeVarMapFromParams(v url.Values) (map[string][]string, error) { + input := make(map[string][]string, len(v)) + for key, vals := range v { + if !strings.HasPrefix(key, "var-") { + continue + } + input[key[4:]] = vals + } + return input, nil +} diff --git a/pkg/registry/apis/peakq/render_examples.go b/pkg/registry/apis/peakq/render_examples.go new file mode 100644 index 0000000000000..0d78ec007eb55 --- /dev/null +++ b/pkg/registry/apis/peakq/render_examples.go @@ -0,0 +1,74 @@ +package peakq + +import ( + "github.com/grafana/grafana-plugin-sdk-go/data" + apidata "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + + "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template" +) + +var basicTemplateSpec = template.QueryTemplate{ + Title: "Test", + Variables: []template.TemplateVariable{ + { + Key: "metricName", + DefaultValues: []string{`down`}, + }, + }, + Targets: []template.Target{ + { + DataType: data.FrameTypeUnknown, + //DataTypeVersion: data.FrameTypeVersion{0, 0}, + Variables: map[string][]template.VariableReplacement{ + "metricName": { + { + Path: "$.expr", + Position: &template.Position{ + Start: 0, + End: 10, + }, + }, + { + Path: "$.expr", + Position: &template.Position{ + Start: 13, + End: 23, + }, + }, + }, + }, + + Properties: apidata.NewDataQuery(map[string]any{ + "refId": "A", // TODO: Set when Where? + "datasource": map[string]any{ + "type": "prometheus", + "uid": "foo", // TODO: Probably a default templating thing to set this. + }, + "editorMode": "builder", + "expr": "metricName + metricName + 42", + "instant": true, + "range": false, + "exemplar": false, + }), + }, + }, +} + +var basicTemplateRenderedTargets = []template.Target{ + { + DataType: data.FrameTypeUnknown, + //DataTypeVersion: data.FrameTypeVersion{0, 0}, + Properties: apidata.NewDataQuery(map[string]any{ + "refId": "A", // TODO: Set when Where? + "datasource": map[string]any{ + "type": "prometheus", + "uid": "foo", // TODO: Probably a default templating thing to set this. + }, + "editorMode": "builder", + "expr": "up + up + 42", + "instant": true, + "range": false, + "exemplar": false, + }), + }, +} diff --git a/pkg/registry/apis/peakq/render_examples_test.go b/pkg/registry/apis/peakq/render_examples_test.go new file mode 100644 index 0000000000000..1b4b2d53a8f9f --- /dev/null +++ b/pkg/registry/apis/peakq/render_examples_test.go @@ -0,0 +1,21 @@ +package peakq + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/apis/query/v0alpha1/template" +) + +func TestRender(t *testing.T) { + rT, err := template.RenderTemplate(basicTemplateSpec, map[string][]string{"metricName": {"up"}}) + require.NoError(t, err) + require.Equal(t, + basicTemplateRenderedTargets[0].Properties.GetString("expr"), + rT[0].Properties.GetString("expr")) + b, _ := json.MarshalIndent(basicTemplateSpec, "", " ") + fmt.Println(string(b)) +} diff --git a/pkg/registry/apis/peakq/storage.go b/pkg/registry/apis/peakq/storage.go new file mode 100644 index 0000000000000..c3bd23aac3678 --- /dev/null +++ b/pkg/registry/apis/peakq/storage.go @@ -0,0 +1,62 @@ +package peakq + +import ( + "fmt" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" + + peakq "github.com/grafana/grafana/pkg/apis/peakq/v0alpha1" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/apiserver/utils" +) + +var _ grafanarest.Storage = (*storage)(nil) + +type storage struct { + *genericregistry.Store +} + +func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter) (*storage, error) { + strategy := grafanaregistry.NewStrategy(scheme) + + resourceInfo := peakq.QueryTemplateResourceInfo + store := &genericregistry.Store{ + NewFunc: resourceInfo.NewFunc, + NewListFunc: resourceInfo.NewListFunc, + PredicateFunc: grafanaregistry.Matcher, + DefaultQualifiedResource: resourceInfo.GroupResource(), + SingularQualifiedResource: resourceInfo.SingularGroupResource(), + TableConvertor: utils.NewTableConverter( + resourceInfo.GroupResource(), + []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Title", Type: "string"}, + {Name: "Created At", Type: "date"}, + }, + func(obj any) ([]interface{}, error) { + m, ok := obj.(*peakq.QueryTemplate) + if !ok { + return nil, fmt.Errorf("expected query template") + } + return []interface{}{ + m.Name, + m.Spec.Title, + m.CreationTimestamp.UTC().Format(time.RFC3339), + }, nil + }, + ), + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + } + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} + if err := store.CompleteWithOptions(options); err != nil { + return nil, err + } + return &storage{Store: store}, nil +} diff --git a/pkg/registry/apis/playlist/conversions.go b/pkg/registry/apis/playlist/conversions.go index 88b67c69cbaf3..c301cf9c04f21 100644 --- a/pkg/registry/apis/playlist/conversions.go +++ b/pkg/registry/apis/playlist/conversions.go @@ -11,9 +11,8 @@ import ( "k8s.io/apimachinery/pkg/types" playlist "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1" - "github.com/grafana/grafana/pkg/kinds" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/utils" playlistsvc "github.com/grafana/grafana/pkg/services/playlist" ) @@ -78,14 +77,6 @@ func convertToK8sResource(v *playlistsvc.PlaylistDTO, namespacer request.Namespa }) } - meta := kinds.GrafanaResourceMetadata{} - meta.SetUpdatedTimestampMillis(v.UpdatedAt) - if v.Id > 0 { - meta.SetOriginInfo(&kinds.ResourceOriginInfo{ - Name: "SQL", - Key: fmt.Sprintf("%d", v.Id), - }) - } p := &playlist.Playlist{ ObjectMeta: metav1.ObjectMeta{ Name: v.Uid, @@ -93,10 +84,20 @@ func convertToK8sResource(v *playlistsvc.PlaylistDTO, namespacer request.Namespa ResourceVersion: fmt.Sprintf("%d", v.UpdatedAt), CreationTimestamp: metav1.NewTime(time.UnixMilli(v.CreatedAt)), Namespace: namespacer(v.OrgID), - Annotations: meta.Annotations, }, Spec: spec, } + meta, err := utils.MetaAccessor(p) + if err == nil { + meta.SetUpdatedTimestampMillis(v.UpdatedAt) + if v.Id > 0 { + meta.SetOriginInfo(&utils.ResourceOriginInfo{ + Name: "SQL", + Key: fmt.Sprintf("%d", v.Id), + }) + } + } + p.UID = utils.CalculateClusterWideUID(p) return p } @@ -123,8 +124,9 @@ func convertToLegacyUpdateCommand(p *playlist.Playlist, orgId int64) (*playlists // Read legacy ID from metadata annotations func getLegacyID(item *unstructured.Unstructured) int64 { - meta := kinds.GrafanaResourceMetadata{ - Annotations: item.GetAnnotations(), + meta, err := utils.MetaAccessor(item) + if err != nil { + return 0 } info, _ := meta.GetOriginInfo() if info != nil && info.Name == "SQL" { diff --git a/pkg/registry/apis/playlist/conversions_test.go b/pkg/registry/apis/playlist/conversions_test.go index 3bb8284bb8e94..831885720309f 100644 --- a/pkg/registry/apis/playlist/conversions_test.go +++ b/pkg/registry/apis/playlist/conversions_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/playlist" ) diff --git a/pkg/registry/apis/playlist/legacy_storage.go b/pkg/registry/apis/playlist/legacy_storage.go index 699d6e5371cdb..316c0f5246296 100644 --- a/pkg/registry/apis/playlist/legacy_storage.go +++ b/pkg/registry/apis/playlist/legacy_storage.go @@ -11,7 +11,7 @@ import ( "k8s.io/apiserver/pkg/registry/rest" playlist "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" playlistsvc "github.com/grafana/grafana/pkg/services/playlist" ) diff --git a/pkg/registry/apis/playlist/register.go b/pkg/registry/apis/playlist/register.go index 11a8aaf0a9c6a..59b10b2ccae52 100644 --- a/pkg/registry/apis/playlist/register.go +++ b/pkg/registry/apis/playlist/register.go @@ -2,7 +2,6 @@ package playlist import ( "fmt" - "strings" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -16,15 +15,15 @@ import ( common "k8s.io/kube-openapi/pkg/common" playlist "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1" - grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" - grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils" + "github.com/grafana/grafana/pkg/apiserver/builder" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/utils" playlistsvc "github.com/grafana/grafana/pkg/services/playlist" "github.com/grafana/grafana/pkg/setting" ) -var _ grafanaapiserver.APIGroupBuilder = (*PlaylistAPIBuilder)(nil) +var _ builder.APIGroupBuilder = (*PlaylistAPIBuilder)(nil) // This is used just so wire has something unique to return type PlaylistAPIBuilder struct { @@ -34,7 +33,7 @@ type PlaylistAPIBuilder struct { } func RegisterAPIService(p playlistsvc.Service, - apiregistration grafanaapiserver.APIRegistrar, + apiregistration builder.APIRegistrar, cfg *setting.Cfg, ) *PlaylistAPIBuilder { builder := &PlaylistAPIBuilder{ @@ -68,28 +67,6 @@ func (b *PlaylistAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { Version: runtime.APIVersionInternal, }) - gvk := playlist.PlaylistResourceInfo.GroupVersionKind() - - // Add playlist thing - _ = scheme.AddFieldLabelConversionFunc(gvk, - runtime.FieldLabelConversionFunc( - func(label, value string) (string, string, error) { - if strings.HasPrefix(label, "grafana.app/") { - return label, value, nil - } - - switch label { - case "metadata.name": - return label, value, nil - case "metadata.namespace": - return label, value, nil - default: - return "", "", fmt.Errorf("%q is not a known field selector: only %q, %q", label, "metadata.name", "metadata.namespace") - } - }, - ), - ) - // If multiple versions exist, then register conversions from zz_generated.conversion.go // if err := playlist.RegisterConversions(scheme); err != nil { // return err @@ -102,6 +79,7 @@ func (b *PlaylistAPIBuilder) GetAPIGroupInfo( scheme *runtime.Scheme, codecs serializer.CodecFactory, // pointer? optsGetter generic.RESTOptionsGetter, + dualWrite bool, ) (*genericapiserver.APIGroupInfo, error) { apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(playlist.GROUP, scheme, metav1.ParameterCodec, codecs) storage := map[string]rest.Storage{} @@ -135,7 +113,7 @@ func (b *PlaylistAPIBuilder) GetAPIGroupInfo( storage[resource.StoragePath()] = legacyStore // enable dual writes if a RESTOptionsGetter is provided - if optsGetter != nil { + if optsGetter != nil && dualWrite { store, err := newStorage(scheme, optsGetter, legacyStore) if err != nil { return nil, err @@ -151,7 +129,7 @@ func (b *PlaylistAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinition return playlist.GetOpenAPIDefinitions } -func (b *PlaylistAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes { +func (b *PlaylistAPIBuilder) GetAPIRoutes() *builder.APIRoutes { return nil // no custom API routes } diff --git a/pkg/registry/apis/playlist/storage.go b/pkg/registry/apis/playlist/storage.go index b0f848cc11ffb..0c537e3127062 100644 --- a/pkg/registry/apis/playlist/storage.go +++ b/pkg/registry/apis/playlist/storage.go @@ -6,8 +6,8 @@ import ( genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" playlist "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1" - grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" ) var _ grafanarest.Storage = (*storage)(nil) diff --git a/pkg/registry/apis/query/client.go b/pkg/registry/apis/query/client.go new file mode 100644 index 0000000000000..50f7a16434a17 --- /dev/null +++ b/pkg/registry/apis/query/client.go @@ -0,0 +1,22 @@ +package query + +import ( + "context" + + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" +) + +// The query runner interface +type DataSourceClientSupplier interface { + // Get a client for a given datasource + // NOTE: authorization headers are not yet added and the client may be shared across multiple users + GetDataSourceClient(ctx context.Context, ref data.DataSourceRef) (data.QueryDataClient, error) +} + +type CommonDataSourceClientSupplier struct { + Client data.QueryDataClient +} + +func (s *CommonDataSourceClientSupplier) GetDataSourceClient(ctx context.Context, ref data.DataSourceRef) (data.QueryDataClient, error) { + return s.Client, nil +} diff --git a/pkg/registry/apis/query/client/plugin.go b/pkg/registry/apis/query/client/plugin.go new file mode 100644 index 0000000000000..a5354e35ecf12 --- /dev/null +++ b/pkg/registry/apis/query/client/plugin.go @@ -0,0 +1,179 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "sync" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb/legacydata" +) + +type pluginClient struct { + pluginClient plugins.Client + pCtxProvider *plugincontext.Provider +} + +type pluginRegistry struct { + pluginsMu sync.Mutex + plugins *query.DataSourceApiServerList + apis map[string]schema.GroupVersion + groupToPlugin map[string]string + pluginStore pluginstore.Store + + // called on demand + dataSourcesService datasources.DataSourceService +} + +var _ data.QueryDataClient = (*pluginClient)(nil) +var _ query.DataSourceApiServerRegistry = (*pluginRegistry)(nil) + +// NewDummyTestRunner creates a runner that only works with testdata +func NewQueryClientForPluginClient(p plugins.Client, ctx *plugincontext.Provider) data.QueryDataClient { + return &pluginClient{ + pluginClient: p, + pCtxProvider: ctx, + } +} + +func NewDataSourceRegistryFromStore(pluginStore pluginstore.Store, + dataSourcesService datasources.DataSourceService, +) query.DataSourceApiServerRegistry { + return &pluginRegistry{ + pluginStore: pluginStore, + dataSourcesService: dataSourcesService, + } +} + +// ExecuteQueryData implements QueryHelper. +func (d *pluginClient) QueryData(ctx context.Context, req data.QueryDataRequest) (int, *backend.QueryDataResponse, error) { + queries, dsRef, err := legacydata.ToDataSourceQueries(req) + if err != nil { + return http.StatusBadRequest, nil, err + } + if dsRef == nil { + return http.StatusBadRequest, nil, fmt.Errorf("expected single datasource request") + } + + // NOTE: this depends on uid unique across datasources + settings, err := d.pCtxProvider.GetDataSourceInstanceSettings(ctx, dsRef.UID) + if err != nil { + return http.StatusBadRequest, nil, err + } + + qdr := &backend.QueryDataRequest{ + Queries: queries, + } + qdr.PluginContext, err = d.pCtxProvider.PluginContextForDataSource(ctx, settings) + if err != nil { + return http.StatusBadRequest, nil, err + } + + code := http.StatusOK + rsp, err := d.pluginClient.QueryData(ctx, qdr) + if err == nil { + for _, v := range rsp.Responses { + if v.Error != nil { + code = http.StatusMultiStatus + break + } + } + } else { + code = http.StatusInternalServerError + } + return code, rsp, err +} + +// GetDatasourceAPI implements DataSourceRegistry. +func (d *pluginRegistry) GetDatasourceGroupVersion(pluginId string) (schema.GroupVersion, error) { + d.pluginsMu.Lock() + defer d.pluginsMu.Unlock() + + if d.plugins == nil { + err := d.updatePlugins() + if err != nil { + return schema.GroupVersion{}, err + } + } + + var err error + gv, ok := d.apis[pluginId] + if !ok { + err = fmt.Errorf("no API found for id: " + pluginId) + } + return gv, err +} + +// GetDatasourcePlugins no namespace? everything that is available +func (d *pluginRegistry) GetDatasourceApiServers(ctx context.Context) (*query.DataSourceApiServerList, error) { + d.pluginsMu.Lock() + defer d.pluginsMu.Unlock() + + if d.plugins == nil { + err := d.updatePlugins() + if err != nil { + return nil, err + } + } + + return d.plugins, nil +} + +// This should be called when plugins change +func (d *pluginRegistry) updatePlugins() error { + groupToPlugin := map[string]string{} + apis := map[string]schema.GroupVersion{} + result := &query.DataSourceApiServerList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: fmt.Sprintf("%d", time.Now().UnixMilli()), + }, + } + + // TODO? only backend plugins + for _, dsp := range d.pluginStore.Plugins(context.Background(), plugins.TypeDataSource) { + ts := setting.BuildStamp * 1000 + if dsp.Info.Build.Time > 0 { + ts = dsp.Info.Build.Time + } + + group, err := plugins.GetDatasourceGroupNameFromPluginID(dsp.ID) + if err != nil { + return err + } + gv := schema.GroupVersion{Group: group, Version: "v0alpha1"} // default version + apis[dsp.ID] = gv + for _, alias := range dsp.AliasIDs { + apis[alias] = gv + } + groupToPlugin[group] = dsp.ID + + ds := query.DataSourceApiServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: dsp.ID, + CreationTimestamp: metav1.NewTime(time.UnixMilli(ts)), + }, + Title: dsp.Name, + AliasIDs: dsp.AliasIDs, + GroupVersion: gv.String(), + Description: dsp.Info.Description, + } + result.Items = append(result.Items, ds) + } + + d.plugins = result + d.apis = apis + d.groupToPlugin = groupToPlugin + return nil +} diff --git a/pkg/registry/apis/query/client/testdata.go b/pkg/registry/apis/query/client/testdata.go new file mode 100644 index 0000000000000..b64169e4e074c --- /dev/null +++ b/pkg/registry/apis/query/client/testdata.go @@ -0,0 +1,75 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" + testdata "github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource" + "github.com/grafana/grafana/pkg/tsdb/legacydata" +) + +type testdataDummy struct{} + +var _ data.QueryDataClient = (*testdataDummy)(nil) +var _ query.DataSourceApiServerRegistry = (*testdataDummy)(nil) + +// NewTestDataClient creates a runner that only works with testdata +func NewTestDataClient() data.QueryDataClient { + return &testdataDummy{} +} + +// NewTestDataRegistry returns a registry that only knows about testdata +func NewTestDataRegistry() query.DataSourceApiServerRegistry { + return &testdataDummy{} +} + +// ExecuteQueryData implements QueryHelper. +func (d *testdataDummy) QueryData(ctx context.Context, req data.QueryDataRequest) (int, *backend.QueryDataResponse, error) { + queries, _, err := legacydata.ToDataSourceQueries(req) + if err != nil { + return http.StatusBadRequest, nil, err + } + + qdr := &backend.QueryDataRequest{Queries: queries} + rsp, err := testdata.ProvideService().QueryData(ctx, qdr) + return query.GetResponseCode(rsp), rsp, err +} + +// GetDatasourceAPI implements DataSourceRegistry. +func (*testdataDummy) GetDatasourceGroupVersion(pluginId string) (schema.GroupVersion, error) { + if pluginId == "testdata" || pluginId == "grafana-testdata-datasource" { + return schema.GroupVersion{ + Group: "testdata.datasource.grafana.app", + Version: "v0alpha1", + }, nil + } + return schema.GroupVersion{}, fmt.Errorf("unsupported plugin (only testdata for now)") +} + +// GetDatasourcePlugins implements QueryHelper. +func (d *testdataDummy) GetDatasourceApiServers(ctx context.Context) (*query.DataSourceApiServerList, error) { + return &query.DataSourceApiServerList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: fmt.Sprintf("%d", time.Now().UnixMilli()), + }, + Items: []query.DataSourceApiServer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "grafana-testdata-datasource", + CreationTimestamp: metav1.Now(), + }, + Title: "Test Data", + GroupVersion: "testdata.datasource.grafana.app/v0alpha1", + AliasIDs: []string{"testdata"}, + }, + }, + }, nil +} diff --git a/pkg/registry/apis/query/metrics.go b/pkg/registry/apis/query/metrics.go new file mode 100644 index 0000000000000..e13525917e2d6 --- /dev/null +++ b/pkg/registry/apis/query/metrics.go @@ -0,0 +1,48 @@ +package query + +import ( + "github.com/prometheus/client_golang/prometheus" +) + +const ( + metricsSubSystem = "queryservice" + metricsNamespace = "grafana" +) + +type metrics struct { + dsRequests *prometheus.CounterVec + + // older metric + expressionsQuerySummary *prometheus.SummaryVec +} + +func newMetrics(reg prometheus.Registerer) *metrics { + m := &metrics{ + dsRequests: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + Name: "ds_queries_total", + Help: "Number of datasource queries made from the query service", + }, []string{"error", "dataplane", "datasource_type"}), + + expressionsQuerySummary: prometheus.NewSummaryVec( + prometheus.SummaryOpts{ + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + Name: "expressions_queries_duration_milliseconds", + Help: "Expressions query summary", + Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, + }, + []string{"status"}, + ), + } + + if reg != nil { + reg.MustRegister( + m.dsRequests, + m.expressionsQuerySummary, + ) + } + + return m +} diff --git a/pkg/registry/apis/query/parser.go b/pkg/registry/apis/query/parser.go new file mode 100644 index 0000000000000..613da016908a2 --- /dev/null +++ b/pkg/registry/apis/query/parser.go @@ -0,0 +1,216 @@ +package query + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter" + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "gonum.org/v1/gonum/graph/simple" + "gonum.org/v1/gonum/graph/topo" + + query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" + "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/datasources/service" +) + +type datasourceRequest struct { + // The type + PluginId string `json:"pluginId"` + + // The UID + UID string `json:"uid"` + + // Optionally show the additional query properties + Request *data.QueryDataRequest `json:"request"` + + // Headers that should be forwarded to the next request + Headers map[string]string `json:"headers,omitempty"` +} + +type parsedRequestInfo struct { + // Datasource queries, one for each datasource + Requests []datasourceRequest `json:"requests,omitempty"` + + // Expressions in required execution order + Expressions []expr.ExpressionQuery `json:"expressions,omitempty"` + + // Expressions include explicit hacks for influx+prometheus + RefIDTypes map[string]string `json:"types,omitempty"` + + // Hidden queries used as dependencies + HideBeforeReturn []string `json:"hide,omitempty"` +} + +type queryParser struct { + legacy service.LegacyDataSourceLookup + reader *expr.ExpressionQueryReader + tracer tracing.Tracer +} + +func newQueryParser(reader *expr.ExpressionQueryReader, legacy service.LegacyDataSourceLookup, tracer tracing.Tracer) *queryParser { + return &queryParser{ + reader: reader, + legacy: legacy, + tracer: tracer, + } +} + +// Split the main query into multiple +func (p *queryParser) parseRequest(ctx context.Context, input *query.QueryDataRequest) (parsedRequestInfo, error) { + ctx, span := p.tracer.Start(ctx, "QueryService.parseRequest") + defer span.End() + + queryRefIDs := make(map[string]*data.DataQuery, len(input.Queries)) + expressions := make(map[string]*expr.ExpressionQuery) + index := make(map[string]int) // index lookup + rsp := parsedRequestInfo{ + RefIDTypes: make(map[string]string, len(input.Queries)), + } + + // Ensure a valid time range + if input.From == "" { + input.From = "now-6h" + } + if input.To == "" { + input.To = "now" + } + + for _, q := range input.Queries { + _, found := queryRefIDs[q.RefID] + if found { + return rsp, fmt.Errorf("multiple queries found for refId: %s", q.RefID) + } + _, found = expressions[q.RefID] + if found { + return rsp, fmt.Errorf("multiple queries found for refId: %s", q.RefID) + } + + ds, err := p.getValidDataSourceRef(ctx, q.Datasource, q.DatasourceID) + if err != nil { + return rsp, err + } + + // Process each query + if expr.IsDataSource(ds.UID) { + // In order to process the query as a typed expression query, we + // are writing it back to JSON and parsing again. Alternatively we + // could construct it from the untyped map[string]any additional properties + // but this approach lets us focus on well typed behavior first + raw, err := json.Marshal(q) + if err != nil { + return rsp, err + } + iter, err := jsoniter.ParseBytes(jsoniter.ConfigDefault, raw) + if err != nil { + return rsp, err + } + exp, err := p.reader.ReadQuery(q, iter) + if err != nil { + return rsp, err + } + exp.GraphID = int64(len(expressions) + 1) + expressions[q.RefID] = &exp + } else { + key := fmt.Sprintf("%s/%s", ds.Type, ds.UID) + idx, ok := index[key] + if !ok { + idx = len(index) + index[key] = idx + rsp.Requests = append(rsp.Requests, datasourceRequest{ + PluginId: ds.Type, + UID: ds.UID, + Request: &data.QueryDataRequest{ + TimeRange: input.TimeRange, + Debug: input.Debug, + // no queries + }, + }) + } + + req := rsp.Requests[idx].Request + req.Queries = append(req.Queries, q) + queryRefIDs[q.RefID] = &req.Queries[len(req.Queries)-1] + } + + // Mark all the queries that should be hidden () + if q.Hide { + rsp.HideBeforeReturn = append(rsp.HideBeforeReturn, q.RefID) + } + } + + // Make sure all referenced variables exist and the expression order is stable + if len(expressions) > 0 { + queryNode := &expr.ExpressionQuery{ + GraphID: -1, + } + + // Build the graph for a request + dg := simple.NewDirectedGraph() + dg.AddNode(queryNode) + for _, exp := range expressions { + dg.AddNode(exp) + } + for _, exp := range expressions { + vars := exp.Command.NeedsVars() + for _, refId := range vars { + target := queryNode + q, ok := queryRefIDs[refId] + if !ok { + target, ok = expressions[refId] + if !ok { + return rsp, fmt.Errorf("expression [%s] is missing variable [%s]", exp.RefID, refId) + } + } + // Do not hide queries used in variables + if q != nil && q.Hide { + q.Hide = false + } + if target.ID() == exp.ID() { + return rsp, fmt.Errorf("expression [%s] can not depend on itself", exp.RefID) + } + dg.SetEdge(dg.NewEdge(target, exp)) + } + } + + // Add the sorted expressions + sortedNodes, err := topo.SortStabilized(dg, nil) + if err != nil { + return rsp, fmt.Errorf("cyclic references in query") + } + for _, v := range sortedNodes { + if v.ID() > 0 { + rsp.Expressions = append(rsp.Expressions, *v.(*expr.ExpressionQuery)) + } + } + } + + return rsp, nil +} + +func (p *queryParser) getValidDataSourceRef(ctx context.Context, ds *data.DataSourceRef, id int64) (*data.DataSourceRef, error) { + if ds == nil { + if id == 0 { + return nil, fmt.Errorf("missing datasource reference or id") + } + if p.legacy == nil { + return nil, fmt.Errorf("legacy datasource lookup unsupported (id:%d)", id) + } + return p.legacy.GetDataSourceFromDeprecatedFields(ctx, "", id) + } + if ds.Type == "" { + if ds.UID == "" { + return nil, fmt.Errorf("missing name/uid in data source reference") + } + if ds.UID == expr.DatasourceType { + return ds, nil + } + if p.legacy == nil { + return nil, fmt.Errorf("legacy datasource lookup unsupported (name:%s)", ds.UID) + } + return p.legacy.GetDataSourceFromDeprecatedFields(ctx, ds.UID, 0) + } + return ds, nil +} diff --git a/pkg/registry/apis/query/parser_test.go b/pkg/registry/apis/query/parser_test.go new file mode 100644 index 0000000000000..938b8ff1e9c02 --- /dev/null +++ b/pkg/registry/apis/query/parser_test.go @@ -0,0 +1,131 @@ +package query + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path" + "strings" + "testing" + + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" + "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +type parserTestObject struct { + Description string `json:"description,omitempty"` + Request query.QueryDataRequest `json:"input"` + Expect parsedRequestInfo `json:"expect"` + Error string `json:"error,omitempty"` +} + +func TestQuerySplitting(t *testing.T) { + ctx := context.Background() + parser := newQueryParser(expr.NewExpressionQueryReader(featuremgmt.WithFeatures()), + &legacyDataSourceRetriever{}, tracing.InitializeTracerForTest()) + + t.Run("missing datasource flavors", func(t *testing.T) { + split, err := parser.parseRequest(ctx, &query.QueryDataRequest{ + QueryDataRequest: data.QueryDataRequest{ + Queries: []data.DataQuery{{ + CommonQueryProperties: data.CommonQueryProperties{ + RefID: "A", + }, + }}, + }, + }) + require.Error(t, err) // Missing datasource + require.Empty(t, split.Requests) + }) + + t.Run("applies default time range", func(t *testing.T) { + split, err := parser.parseRequest(ctx, &query.QueryDataRequest{ + QueryDataRequest: data.QueryDataRequest{ + TimeRange: data.TimeRange{}, // missing + Queries: []data.DataQuery{{ + CommonQueryProperties: data.CommonQueryProperties{ + RefID: "A", + Datasource: &data.DataSourceRef{ + Type: "x", + UID: "abc", + }, + }, + }}, + }, + }) + require.NoError(t, err) + require.Len(t, split.Requests, 1) + require.Equal(t, "now-6h", split.Requests[0].Request.From) + require.Equal(t, "now", split.Requests[0].Request.To) + }) + + t.Run("verify tests", func(t *testing.T) { + files, err := os.ReadDir("testdata") + require.NoError(t, err) + + for _, file := range files { + if !strings.HasSuffix(file.Name(), ".json") { + continue + } + + fpath := path.Join("testdata", file.Name()) + // nolint:gosec + body, err := os.ReadFile(fpath) + require.NoError(t, err) + harness := &parserTestObject{} + err = json.Unmarshal(body, harness) + require.NoError(t, err) + + changed := false + parsed, err := parser.parseRequest(ctx, &harness.Request) + if err != nil { + if !assert.Equal(t, harness.Error, err.Error(), "File %s", file) { + changed = true + } + } else { + x, _ := json.Marshal(parsed) + y, _ := json.Marshal(harness.Expect) + if !assert.JSONEq(t, string(y), string(x), "File %s", file) { + changed = true + } + } + + if changed { + harness.Error = "" + harness.Expect = parsed + if err != nil { + harness.Error = err.Error() + } + jj, err := json.MarshalIndent(harness, "", " ") + require.NoError(t, err) + err = os.WriteFile(fpath, jj, 0600) + require.NoError(t, err) + } + } + }) +} + +type legacyDataSourceRetriever struct{} + +func (s *legacyDataSourceRetriever) GetDataSourceFromDeprecatedFields(ctx context.Context, name string, id int64) (*data.DataSourceRef, error) { + if id == 100 { + return &data.DataSourceRef{ + Type: "plugin-aaaa", + UID: "AAA", + }, nil + } + if name != "" { + return &data.DataSourceRef{ + Type: "plugin-bbb", + UID: name, + }, nil + } + return nil, fmt.Errorf("missing parameter") +} diff --git a/pkg/registry/apis/query/plugins.go b/pkg/registry/apis/query/plugins.go new file mode 100644 index 0000000000000..1836602d4de51 --- /dev/null +++ b/pkg/registry/apis/query/plugins.go @@ -0,0 +1,62 @@ +package query + +import ( + "context" + + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + example "github.com/grafana/grafana/pkg/apis/example/v0alpha1" + query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" +) + +var ( + _ rest.Storage = (*pluginsStorage)(nil) + _ rest.Scoper = (*pluginsStorage)(nil) + _ rest.SingularNameProvider = (*pluginsStorage)(nil) + _ rest.Lister = (*pluginsStorage)(nil) +) + +type pluginsStorage struct { + resourceInfo *common.ResourceInfo + tableConverter rest.TableConvertor + registry query.DataSourceApiServerRegistry +} + +func newPluginsStorage(reg query.DataSourceApiServerRegistry) *pluginsStorage { + var resourceInfo = query.DataSourceApiServerResourceInfo + return &pluginsStorage{ + resourceInfo: &resourceInfo, + tableConverter: rest.NewDefaultTableConvertor(resourceInfo.GroupResource()), + registry: reg, + } +} + +func (s *pluginsStorage) New() runtime.Object { + return s.resourceInfo.NewFunc() +} + +func (s *pluginsStorage) Destroy() {} + +func (s *pluginsStorage) NamespaceScoped() bool { + return false +} + +func (s *pluginsStorage) GetSingularName() string { + return example.DummyResourceInfo.GetSingularName() +} + +func (s *pluginsStorage) NewList() runtime.Object { + return s.resourceInfo.NewListFunc() +} + +func (s *pluginsStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return s.tableConverter.ConvertToTable(ctx, object, tableOptions) +} + +func (s *pluginsStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + return s.registry.GetDatasourceApiServers(ctx) +} diff --git a/pkg/registry/apis/query/query.go b/pkg/registry/apis/query/query.go new file mode 100644 index 0000000000000..c3a14dac30965 --- /dev/null +++ b/pkg/registry/apis/query/query.go @@ -0,0 +1,272 @@ +package query + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "go.opentelemetry.io/otel/attribute" + "golang.org/x/sync/errgroup" + + query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" + "github.com/grafana/grafana/pkg/expr/mathexp" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/util/errutil" + "github.com/grafana/grafana/pkg/util/errutil/errhttp" + "github.com/grafana/grafana/pkg/web" +) + +// The query method (not really a create) +func (b *QueryAPIBuilder) doQuery(w http.ResponseWriter, r *http.Request) { + ctx, span := b.tracer.Start(r.Context(), "QueryService.Query") + defer span.End() + + raw := &query.QueryDataRequest{} + err := web.Bind(r, raw) + if err != nil { + errhttp.Write(ctx, errutil.BadRequest( + "query.bind", + errutil.WithPublicMessage("Error reading query")). + Errorf("error reading: %w", err), w) + return + } + + // Parses the request and splits it into multiple sub queries (if necessary) + req, err := b.parser.parseRequest(ctx, raw) + if err != nil { + if errors.Is(err, datasources.ErrDataSourceNotFound) { + errhttp.Write(ctx, errutil.BadRequest( + "query.datasource.notfound", + errutil.WithPublicMessage(err.Error())), w) + return + } + errhttp.Write(ctx, errutil.BadRequest( + "query.parse", + errutil.WithPublicMessage("Error parsing query")). + Errorf("error parsing: %w", err), w) + return + } + + // Actually run the query + rsp, err := b.execute(ctx, req) + if err != nil { + errhttp.Write(ctx, errutil.Internal( + "query.execution", + errutil.WithPublicMessage("Error executing query")). + Errorf("execution error: %w", err), w) + return + } + + w.WriteHeader(query.GetResponseCode(rsp)) + _ = json.NewEncoder(w).Encode(rsp) +} + +func (b *QueryAPIBuilder) execute(ctx context.Context, req parsedRequestInfo) (qdr *backend.QueryDataResponse, err error) { + switch len(req.Requests) { + case 0: + break // nothing to do + case 1: + qdr, err = b.handleQuerySingleDatasource(ctx, req.Requests[0]) + default: + qdr, err = b.executeConcurrentQueries(ctx, req.Requests) + } + + if len(req.Expressions) > 0 { + qdr, err = b.handleExpressions(ctx, req, qdr) + } + + // Remove hidden results + for _, refId := range req.HideBeforeReturn { + r, ok := qdr.Responses[refId] + if ok && r.Error == nil { + delete(qdr.Responses, refId) + } + } + return +} + +// Process a single request +// See: https://github.com/grafana/grafana/blob/v10.2.3/pkg/services/query/query.go#L242 +func (b *QueryAPIBuilder) handleQuerySingleDatasource(ctx context.Context, req datasourceRequest) (*backend.QueryDataResponse, error) { + ctx, span := b.tracer.Start(ctx, "Query.handleQuerySingleDatasource") + defer span.End() + span.SetAttributes( + attribute.String("datasource.type", req.PluginId), + attribute.String("datasource.uid", req.UID), + ) + + allHidden := true + for idx := range req.Request.Queries { + if !req.Request.Queries[idx].Hide { + allHidden = false + break + } + } + if allHidden { + return &backend.QueryDataResponse{}, nil + } + + // headers? + client, err := b.client.GetDataSourceClient(ctx, v0alpha1.DataSourceRef{ + Type: req.PluginId, + UID: req.UID, + }) + if err != nil { + return nil, err + } + + // headers? + _, rsp, err := client.QueryData(ctx, *req.Request) + if err == nil { + for _, q := range req.Request.Queries { + if q.ResultAssertions != nil { + result, ok := rsp.Responses[q.RefID] + if ok && result.Error == nil { + err = q.ResultAssertions.Validate(result.Frames) + if err != nil { + result.Error = err + result.ErrorSource = backend.ErrorSourceDownstream + rsp.Responses[q.RefID] = result + } + } + } + } + } + return rsp, err +} + +// buildErrorResponses applies the provided error to each query response in the list. These queries should all belong to the same datasource. +func buildErrorResponse(err error, req datasourceRequest) *backend.QueryDataResponse { + rsp := backend.NewQueryDataResponse() + for _, query := range req.Request.Queries { + rsp.Responses[query.RefID] = backend.DataResponse{ + Error: err, + } + } + return rsp +} + +// executeConcurrentQueries executes queries to multiple datasources concurrently and returns the aggregate result. +func (b *QueryAPIBuilder) executeConcurrentQueries(ctx context.Context, requests []datasourceRequest) (*backend.QueryDataResponse, error) { + ctx, span := b.tracer.Start(ctx, "Query.executeConcurrentQueries") + defer span.End() + + g, ctx := errgroup.WithContext(ctx) + g.SetLimit(b.concurrentQueryLimit) // prevent too many concurrent requests + rchan := make(chan *backend.QueryDataResponse, len(requests)) + + // Create panic recovery function for loop below + recoveryFn := func(req datasourceRequest) { + if r := recover(); r != nil { + var err error + b.log.Error("query datasource panic", "error", r, "stack", log.Stack(1)) + if theErr, ok := r.(error); ok { + err = theErr + } else if theErrString, ok := r.(string); ok { + err = fmt.Errorf(theErrString) + } else { + err = fmt.Errorf("unexpected error - %s", b.userFacingDefaultError) + } + // Due to the panic, there is no valid response for any query for this datasource. Append an error for each one. + rchan <- buildErrorResponse(err, req) + } + } + + // Query each datasource concurrently + for idx := range requests { + req := requests[idx] + g.Go(func() error { + defer recoveryFn(req) + + dqr, err := b.handleQuerySingleDatasource(ctx, req) + if err == nil { + rchan <- dqr + } else { + rchan <- buildErrorResponse(err, req) + } + return nil + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + close(rchan) + + // Merge the results from each response + resp := backend.NewQueryDataResponse() + for result := range rchan { + for refId, dataResponse := range result.Responses { + resp.Responses[refId] = dataResponse + } + } + + return resp, nil +} + +// Unlike the implementation in expr/node.go, all datasource queries have been processed first +func (b *QueryAPIBuilder) handleExpressions(ctx context.Context, req parsedRequestInfo, data *backend.QueryDataResponse) (qdr *backend.QueryDataResponse, err error) { + start := time.Now() + ctx, span := b.tracer.Start(ctx, "SSE.handleExpressions") + defer func() { + var respStatus string + switch { + case err == nil: + respStatus = "success" + default: + respStatus = "failure" + } + duration := float64(time.Since(start).Nanoseconds()) / float64(time.Millisecond) + b.metrics.expressionsQuerySummary.WithLabelValues(respStatus).Observe(duration) + + span.End() + }() + + qdr = data + if qdr == nil { + qdr = &backend.QueryDataResponse{} + } + now := start // <<< this should come from the original query parser + vars := make(mathexp.Vars) + for _, expression := range req.Expressions { + // Setup the variables + for _, refId := range expression.Command.NeedsVars() { + _, ok := vars[refId] + if !ok { + dr, ok := qdr.Responses[refId] + if ok { + allowLongFrames := false // TODO -- depends on input type and only if SQL? + _, res, err := b.converter.Convert(ctx, req.RefIDTypes[refId], dr.Frames, allowLongFrames) + if err != nil { + res.Error = err + } + vars[refId] = res + } else { + // This should error in the parsing phase + err := fmt.Errorf("missing variable %s for %s", refId, expression.RefID) + qdr.Responses[refId] = backend.DataResponse{ + Error: err, + } + return qdr, err + } + } + } + + refId := expression.RefID + results, err := expression.Command.Execute(ctx, now, vars, b.tracer) + if err != nil { + results.Error = err + } + qdr.Responses[refId] = backend.DataResponse{ + Error: results.Error, + Frames: results.Values.AsDataFrames(refId), + } + } + return qdr, nil +} diff --git a/pkg/registry/apis/query/register.go b/pkg/registry/apis/query/register.go new file mode 100644 index 0000000000000..1b6460df3e8f4 --- /dev/null +++ b/pkg/registry/apis/query/register.go @@ -0,0 +1,304 @@ +package query + +import ( + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "github.com/grafana/grafana-plugin-sdk-go/experimental/schemabuilder" + "github.com/prometheus/client_golang/prometheus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/rest" + genericapiserver "k8s.io/apiserver/pkg/server" + common "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + + example "github.com/grafana/grafana/pkg/apis/example/v0alpha1" + "github.com/grafana/grafana/pkg/apis/query/v0alpha1" + "github.com/grafana/grafana/pkg/apiserver/builder" + "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/registry/apis/query/client" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/datasources/service" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" +) + +var _ builder.APIGroupBuilder = (*QueryAPIBuilder)(nil) + +type QueryAPIBuilder struct { + log log.Logger + concurrentQueryLimit int + userFacingDefaultError string + returnMultiStatus bool // from feature toggle + features featuremgmt.FeatureToggles + + tracer tracing.Tracer + metrics *metrics + parser *queryParser + client DataSourceClientSupplier + registry v0alpha1.DataSourceApiServerRegistry + converter *expr.ResultConverter +} + +func NewQueryAPIBuilder(features featuremgmt.FeatureToggles, + client DataSourceClientSupplier, + registry v0alpha1.DataSourceApiServerRegistry, + legacy service.LegacyDataSourceLookup, + registerer prometheus.Registerer, + tracer tracing.Tracer, +) (*QueryAPIBuilder, error) { + reader := expr.NewExpressionQueryReader(features) + return &QueryAPIBuilder{ + concurrentQueryLimit: 4, + log: log.New("query_apiserver"), + returnMultiStatus: features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryMultiStatus), + client: client, + registry: registry, + parser: newQueryParser(reader, legacy, tracer), + metrics: newMetrics(registerer), + tracer: tracer, + features: features, + converter: &expr.ResultConverter{ + Features: features, + Tracer: tracer, + }, + }, nil +} + +func RegisterAPIService(features featuremgmt.FeatureToggles, + apiregistration builder.APIRegistrar, + dataSourcesService datasources.DataSourceService, + pluginStore pluginstore.Store, + accessControl accesscontrol.AccessControl, + pluginClient plugins.Client, + pCtxProvider *plugincontext.Provider, + registerer prometheus.Registerer, + tracer tracing.Tracer, + legacy service.LegacyDataSourceLookup, +) (*QueryAPIBuilder, error) { + if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { + return nil, nil // skip registration unless opting into experimental apis + } + + builder, err := NewQueryAPIBuilder( + features, + &CommonDataSourceClientSupplier{ + Client: client.NewQueryClientForPluginClient(pluginClient, pCtxProvider), + }, + client.NewDataSourceRegistryFromStore(pluginStore, dataSourcesService), + legacy, registerer, tracer, + ) + apiregistration.RegisterAPI(builder) + return builder, err +} + +func (b *QueryAPIBuilder) GetGroupVersion() schema.GroupVersion { + return v0alpha1.SchemeGroupVersion +} + +func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { + scheme.AddKnownTypes(gv, + &v0alpha1.DataSourceApiServer{}, + &v0alpha1.DataSourceApiServerList{}, + &v0alpha1.QueryDataRequest{}, + &v0alpha1.QueryDataResponse{}, + &v0alpha1.QueryTypeDefinition{}, + &v0alpha1.QueryTypeDefinitionList{}, + &example.DummySubresource{}, + ) +} + +func (b *QueryAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { + addKnownTypes(scheme, v0alpha1.SchemeGroupVersion) + metav1.AddToGroupVersion(scheme, v0alpha1.SchemeGroupVersion) + return scheme.SetVersionPriority(v0alpha1.SchemeGroupVersion) +} + +func (b *QueryAPIBuilder) GetAPIGroupInfo( + scheme *runtime.Scheme, + codecs serializer.CodecFactory, // pointer? + optsGetter generic.RESTOptionsGetter, + _ bool, +) (*genericapiserver.APIGroupInfo, error) { + gv := v0alpha1.SchemeGroupVersion + apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(gv.Group, scheme, metav1.ParameterCodec, codecs) + + plugins := newPluginsStorage(b.registry) + + storage := map[string]rest.Storage{} + storage[plugins.resourceInfo.StoragePath()] = plugins + + apiGroupInfo.VersionedResourcesStorageMap[gv.Version] = storage + return &apiGroupInfo, nil +} + +func (b *QueryAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { + return v0alpha1.GetOpenAPIDefinitions +} + +// Register additional routes with the server +func (b *QueryAPIBuilder) GetAPIRoutes() *builder.APIRoutes { + routes := &builder.APIRoutes{ + Namespace: []builder.APIRouteHandler{ + { + Path: "query", + Spec: &spec3.PathProps{ + Post: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + Tags: []string{"query"}, + Summary: "Query", + Description: "longer description here?", + Parameters: []*spec3.Parameter{ + { + ParameterProps: spec3.ParameterProps{ + Name: "namespace", + In: "path", + Required: true, + Example: "default", + Description: "workspace", + Schema: spec.StringProperty(), + }, + }, + }, + RequestBody: &spec3.RequestBody{ + RequestBodyProps: spec3.RequestBodyProps{ + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: spec.RefSchema("#/components/schemas/" + QueryRequestSchemaKey), + Examples: map[string]*spec3.Example{ + "A": { + ExampleProps: spec3.ExampleProps{ + Summary: "Random walk (testdata)", + Description: "Use testdata to execute a random walk query", + Value: `{ + "queries": [ + { + "refId": "A", + "scenarioId": "random_walk_table", + "seriesCount": 1, + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "intervalMs": 60000, + "maxDataPoints": 20 + } + ], + "from": "now-6h", + "to": "now" + }`, + }, + }, + "B": { + ExampleProps: spec3.ExampleProps{ + Summary: "With deprecated datasource name", + Description: "Includes an old style string for datasource reference", + Value: `{ + "queries": [ + { + "refId": "A", + "datasource": { + "type": "grafana-googlesheets-datasource", + "uid": "b1808c48-9fc9-4045-82d7-081781f8a553" + }, + "cacheDurationSeconds": 300, + "spreadsheet": "spreadsheetID", + "datasourceId": 4, + "intervalMs": 30000, + "maxDataPoints": 794 + }, + { + "refId": "Z", + "datasource": "old", + "maxDataPoints": 10, + "timeRange": { + "from": "100", + "to": "200" + } + } + ], + "from": "now-6h", + "to": "now" + }`, + }, + }, + }, + }, + }, + }, + }, + }, + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + StatusCodeResponses: map[int]*spec3.Response{ + 200: { + ResponseProps: spec3.ResponseProps{ + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: spec.StringProperty(), // TODO!!! + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Handler: b.doQuery, + }, + }, + } + return routes +} + +func (b *QueryAPIBuilder) GetAuthorizer() authorizer.Authorizer { + return nil // default is OK +} + +const QueryRequestSchemaKey = "QueryRequestSchema" +const QueryPayloadSchemaKey = "QueryPayloadSchema" + +func (b *QueryAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) { + // The plugin description + oas.Info.Description = "Query service" + + // The root api URL + root := "/apis/" + b.GetGroupVersion().String() + "/" + + var err error + opts := schemabuilder.QuerySchemaOptions{ + PluginID: []string{""}, + QueryTypes: []data.QueryTypeDefinition{}, + Mode: schemabuilder.SchemaTypeQueryPayload, + } + oas.Components.Schemas[QueryPayloadSchemaKey], err = schemabuilder.GetQuerySchema(opts) + if err != nil { + return oas, err + } + opts.Mode = schemabuilder.SchemaTypeQueryRequest + oas.Components.Schemas[QueryRequestSchemaKey], err = schemabuilder.GetQuerySchema(opts) + if err != nil { + return oas, err + } + + // The root API discovery list + sub := oas.Paths.Paths[root] + if sub != nil && sub.Get != nil { + sub.Get.Tags = []string{"API Discovery"} // sorts first in the list + } + return oas, nil +} diff --git a/pkg/registry/apis/query/testdata/cyclic-references.json b/pkg/registry/apis/query/testdata/cyclic-references.json new file mode 100644 index 0000000000000..bdbc9c96440f0 --- /dev/null +++ b/pkg/registry/apis/query/testdata/cyclic-references.json @@ -0,0 +1,29 @@ +{ + "description": "self dependencies", + "input": { + "from": "now-6", + "to": "now", + "queries": [ + { + "refId": "A", + "datasource": { + "type": "", + "uid": "__expr__" + }, + "expression": "$B", + "type": "math" + }, + { + "refId": "B", + "datasource": { + "type": "", + "uid": "__expr__" + }, + "type": "math", + "expression": "$A" + } + ] + }, + "expect": {}, + "error": "cyclic references in query" +} \ No newline at end of file diff --git a/pkg/registry/apis/query/testdata/multiple-uids-same-plugin.json b/pkg/registry/apis/query/testdata/multiple-uids-same-plugin.json new file mode 100644 index 0000000000000..68a6705f094ae --- /dev/null +++ b/pkg/registry/apis/query/testdata/multiple-uids-same-plugin.json @@ -0,0 +1,60 @@ +{ + "input": { + "from": "now-6", + "to": "now", + "queries": [ + { + "refId": "A", + "datasource": { + "type": "plugin-x", + "uid": "123" + } + }, + { + "refId": "B", + "datasource": { + "type": "plugin-x", + "uid": "456" + } + } + ] + }, + "expect": { + "requests": [ + { + "pluginId": "plugin-x", + "uid": "123", + "request": { + "from": "now-6", + "to": "now", + "queries": [ + { + "refId": "A", + "datasource": { + "type": "plugin-x", + "uid": "123" + } + } + ] + } + }, + { + "pluginId": "plugin-x", + "uid": "456", + "request": { + "from": "now-6", + "to": "now", + "queries": [ + { + "refId": "B", + "datasource": { + "type": "plugin-x", + "uid": "456" + } + } + ] + } + } + ] + } +} \ No newline at end of file diff --git a/pkg/registry/apis/query/testdata/self-reference.json b/pkg/registry/apis/query/testdata/self-reference.json new file mode 100644 index 0000000000000..4248f70d58800 --- /dev/null +++ b/pkg/registry/apis/query/testdata/self-reference.json @@ -0,0 +1,20 @@ +{ + "description": "self dependencies", + "input": { + "from": "now-6", + "to": "now", + "queries": [ + { + "refId": "A", + "datasource": { + "type": "", + "uid": "__expr__" + }, + "type": "math", + "expression": "$A" + } + ] + }, + "expect": {}, + "error": "expression [A] can not depend on itself" +} \ No newline at end of file diff --git a/pkg/registry/apis/query/testdata/with-expressions.json b/pkg/registry/apis/query/testdata/with-expressions.json new file mode 100644 index 0000000000000..a1cbca89994ac --- /dev/null +++ b/pkg/registry/apis/query/testdata/with-expressions.json @@ -0,0 +1,79 @@ +{ + "description": "one hidden query with two expressions that start out-of-order", + "input": { + "from": "now-6", + "to": "now", + "queries": [ + { + "refId": "C", + "datasource": { + "type": "", + "uid": "__expr__" + }, + "type": "reduce", + "expression": "$B", + "reducer": "last" + }, + { + "refId": "A", + "datasource": { + "type": "sql", + "uid": "123" + }, + "hide": true + }, + { + "refId": "B", + "datasource": { + "type": "", + "uid": "-100" + }, + "type": "math", + "expression": "$A + 10" + } + ] + }, + "expect": { + "requests": [ + { + "pluginId": "sql", + "uid": "123", + "request": { + "from": "now-6", + "to": "now", + "queries": [ + { + "refId": "A", + "datasource": { + "type": "sql", + "uid": "123" + } + } + ] + } + } + ], + "expressions": [ + { + "id": 2, + "refId": "B", + "type": "math", + "properties": { + "expression": "$A + 10" + } + }, + { + "id": 1, + "refId": "C", + "type": "reduce", + "properties": { + "expression": "$B", + "reducer": "last" + } + } + ], + "hide": [ + "A" + ] + } +} \ No newline at end of file diff --git a/pkg/registry/apis/scope/register.go b/pkg/registry/apis/scope/register.go new file mode 100644 index 0000000000000..f55454a11ed74 --- /dev/null +++ b/pkg/registry/apis/scope/register.go @@ -0,0 +1,99 @@ +package scope + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/rest" + genericapiserver "k8s.io/apiserver/pkg/server" + "k8s.io/kube-openapi/pkg/common" + + scope "github.com/grafana/grafana/pkg/apis/scope/v0alpha1" + "github.com/grafana/grafana/pkg/apiserver/builder" + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +var _ builder.APIGroupBuilder = (*ScopeAPIBuilder)(nil) + +// This is used just so wire has something unique to return +type ScopeAPIBuilder struct{} + +func NewScopeAPIBuilder() *ScopeAPIBuilder { + return &ScopeAPIBuilder{} +} + +func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar) *ScopeAPIBuilder { + if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { + return nil // skip registration unless opting into experimental apis + } + builder := NewScopeAPIBuilder() + apiregistration.RegisterAPI(builder) + return builder +} + +func (b *ScopeAPIBuilder) GetAuthorizer() authorizer.Authorizer { + return nil // default authorizer is fine +} + +func (b *ScopeAPIBuilder) GetGroupVersion() schema.GroupVersion { + return scope.SchemeGroupVersion +} + +func (b *ScopeAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { + gv := scope.SchemeGroupVersion + err := scope.AddToScheme(scheme) + if err != nil { + return err + } + + // Link this version to the internal representation. + // This is used for server-side-apply (PATCH), and avoids the error: + // "no kind is registered for the type" + // addKnownTypes(scheme, schema.GroupVersion{ + // Group: scope.GROUP, + // Version: runtime.APIVersionInternal, + // }) + metav1.AddToGroupVersion(scheme, gv) + return scheme.SetVersionPriority(gv) +} + +func (b *ScopeAPIBuilder) GetAPIGroupInfo( + scheme *runtime.Scheme, + codecs serializer.CodecFactory, + optsGetter generic.RESTOptionsGetter, + _ bool, // dual write (not relevant) +) (*genericapiserver.APIGroupInfo, error) { + apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(scope.GROUP, scheme, metav1.ParameterCodec, codecs) + + scopeResourceInfo := scope.ScopeResourceInfo + scopeDashboardResourceInfo := scope.ScopeDashboardResourceInfo + + storage := map[string]rest.Storage{} + + scopeStorage, err := newScopeStorage(scheme, optsGetter) + if err != nil { + return nil, err + } + storage[scopeResourceInfo.StoragePath()] = scopeStorage + + scopeDashboardStorage, err := newScopeDashboardStorage(scheme, optsGetter) + if err != nil { + return nil, err + } + storage[scopeDashboardResourceInfo.StoragePath()] = scopeDashboardStorage + + apiGroupInfo.VersionedResourcesStorageMap[scope.VERSION] = storage + return &apiGroupInfo, nil +} + +func (b *ScopeAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { + return scope.GetOpenAPIDefinitions +} + +// Register additional routes with the server +func (b *ScopeAPIBuilder) GetAPIRoutes() *builder.APIRoutes { + return nil +} diff --git a/pkg/registry/apis/scope/storage.go b/pkg/registry/apis/scope/storage.go new file mode 100644 index 0000000000000..abd88de717a9d --- /dev/null +++ b/pkg/registry/apis/scope/storage.go @@ -0,0 +1,98 @@ +package scope + +import ( + "fmt" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" + + scope "github.com/grafana/grafana/pkg/apis/scope/v0alpha1" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/apiserver/utils" +) + +var _ grafanarest.Storage = (*storage)(nil) + +type storage struct { + *genericregistry.Store +} + +func newScopeStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter) (*storage, error) { + strategy := grafanaregistry.NewStrategy(scheme) + + resourceInfo := scope.ScopeResourceInfo + store := &genericregistry.Store{ + NewFunc: resourceInfo.NewFunc, + NewListFunc: resourceInfo.NewListFunc, + PredicateFunc: grafanaregistry.Matcher, + DefaultQualifiedResource: resourceInfo.GroupResource(), + SingularQualifiedResource: resourceInfo.SingularGroupResource(), + TableConvertor: utils.NewTableConverter( + resourceInfo.GroupResource(), + []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Created At", Type: "date"}, + }, + func(obj any) ([]interface{}, error) { + m, ok := obj.(*scope.Scope) + if !ok { + return nil, fmt.Errorf("expected scope") + } + return []interface{}{ + m.Name, + m.CreationTimestamp.UTC().Format(time.RFC3339), + }, nil + }, + ), + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + } + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} + if err := store.CompleteWithOptions(options); err != nil { + return nil, err + } + return &storage{Store: store}, nil +} + +func newScopeDashboardStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter) (*storage, error) { + strategy := grafanaregistry.NewStrategy(scheme) + + resourceInfo := scope.ScopeDashboardResourceInfo + store := &genericregistry.Store{ + NewFunc: resourceInfo.NewFunc, + NewListFunc: resourceInfo.NewListFunc, + PredicateFunc: grafanaregistry.Matcher, + DefaultQualifiedResource: resourceInfo.GroupResource(), + SingularQualifiedResource: resourceInfo.SingularGroupResource(), + TableConvertor: utils.NewTableConverter( + resourceInfo.GroupResource(), + []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Created At", Type: "date"}, + }, + func(obj any) ([]interface{}, error) { + m, ok := obj.(*scope.Scope) + if !ok { + return nil, fmt.Errorf("expected scope") + } + return []interface{}{ + m.Name, + m.CreationTimestamp.UTC().Format(time.RFC3339), + }, nil + }, + ), + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + } + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} + if err := store.CompleteWithOptions(options); err != nil { + return nil, err + } + return &storage{Store: store}, nil +} diff --git a/pkg/registry/apis/service/register.go b/pkg/registry/apis/service/register.go new file mode 100644 index 0000000000000..17dbc3b07aa37 --- /dev/null +++ b/pkg/registry/apis/service/register.go @@ -0,0 +1,97 @@ +package service + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/rest" + genericapiserver "k8s.io/apiserver/pkg/server" + "k8s.io/kube-openapi/pkg/common" + + service "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + "github.com/grafana/grafana/pkg/apiserver/builder" + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +var _ builder.APIGroupBuilder = (*ServiceAPIBuilder)(nil) + +// This is used just so wire has something unique to return +type ServiceAPIBuilder struct{} + +func NewServiceAPIBuilder() *ServiceAPIBuilder { + return &ServiceAPIBuilder{} +} + +func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar) *ServiceAPIBuilder { + if !features.IsEnabledGlobally(featuremgmt.FlagKubernetesAggregator) { + return nil // skip registration unless opting into aggregator mode + } + + builder := NewServiceAPIBuilder() + apiregistration.RegisterAPI(NewServiceAPIBuilder()) + return builder +} + +func (b *ServiceAPIBuilder) GetAuthorizer() authorizer.Authorizer { + return nil // default authorizer is fine +} + +func (b *ServiceAPIBuilder) GetGroupVersion() schema.GroupVersion { + return service.SchemeGroupVersion +} + +func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { + scheme.AddKnownTypes(gv, + &service.ExternalName{}, + &service.ExternalNameList{}, + ) +} + +func (b *ServiceAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { + gv := service.SchemeGroupVersion + err := service.AddToScheme(scheme) + if err != nil { + return err + } + + // Link this version to the internal representation. + // This is used for server-side-apply (PATCH), and avoids the error: + // "no kind is registered for the type" + addKnownTypes(scheme, schema.GroupVersion{ + Group: service.GROUP, + Version: runtime.APIVersionInternal, + }) + metav1.AddToGroupVersion(scheme, gv) + return scheme.SetVersionPriority(gv) +} + +func (b *ServiceAPIBuilder) GetAPIGroupInfo( + scheme *runtime.Scheme, + codecs serializer.CodecFactory, + optsGetter generic.RESTOptionsGetter, + _ bool, +) (*genericapiserver.APIGroupInfo, error) { + apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(service.GROUP, scheme, metav1.ParameterCodec, codecs) + + resourceInfo := service.ExternalNameResourceInfo + storage := map[string]rest.Storage{} + serviceStorage, err := newStorage(scheme, optsGetter) + if err != nil { + return nil, err + } + storage[resourceInfo.StoragePath()] = serviceStorage + apiGroupInfo.VersionedResourcesStorageMap[service.VERSION] = storage + return &apiGroupInfo, nil +} + +func (b *ServiceAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { + return service.GetOpenAPIDefinitions +} + +// Register additional routes with the server +func (b *ServiceAPIBuilder) GetAPIRoutes() *builder.APIRoutes { + return nil +} diff --git a/pkg/registry/apis/service/storage.go b/pkg/registry/apis/service/storage.go new file mode 100644 index 0000000000000..a21023996dcb6 --- /dev/null +++ b/pkg/registry/apis/service/storage.go @@ -0,0 +1,62 @@ +package service + +import ( + "fmt" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" + + service "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/apiserver/utils" +) + +var _ grafanarest.Storage = (*storage)(nil) + +type storage struct { + *genericregistry.Store +} + +func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter) (*storage, error) { + strategy := grafanaregistry.NewStrategy(scheme) + + resourceInfo := service.ExternalNameResourceInfo + store := &genericregistry.Store{ + NewFunc: resourceInfo.NewFunc, + NewListFunc: resourceInfo.NewListFunc, + PredicateFunc: grafanaregistry.Matcher, + DefaultQualifiedResource: resourceInfo.GroupResource(), + SingularQualifiedResource: resourceInfo.SingularGroupResource(), + TableConvertor: utils.NewTableConverter( + resourceInfo.GroupResource(), + []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Host", Type: "string", Format: "string", Description: "The service host"}, + {Name: "Created At", Type: "date"}, + }, + func(obj any) ([]interface{}, error) { + m, ok := obj.(*service.ExternalName) + if !ok { + return nil, fmt.Errorf("expected playlist") + } + return []interface{}{ + m.Name, + m.Spec.Host, + m.CreationTimestamp.UTC().Format(time.RFC3339), + }, nil + }, + ), + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + } + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} + if err := store.CompleteWithOptions(options); err != nil { + return nil, err + } + return &storage{Store: store}, nil +} diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go index 7906512fa7733..e2bd94e9e7c25 100644 --- a/pkg/registry/apis/wireset.go +++ b/pkg/registry/apis/wireset.go @@ -3,17 +3,38 @@ package apiregistry import ( "github.com/google/wire" + "github.com/grafana/grafana/pkg/registry/apis/dashboard" + "github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot" + "github.com/grafana/grafana/pkg/registry/apis/datasource" "github.com/grafana/grafana/pkg/registry/apis/example" + "github.com/grafana/grafana/pkg/registry/apis/featuretoggle" "github.com/grafana/grafana/pkg/registry/apis/folders" + "github.com/grafana/grafana/pkg/registry/apis/peakq" "github.com/grafana/grafana/pkg/registry/apis/playlist" + "github.com/grafana/grafana/pkg/registry/apis/query" + "github.com/grafana/grafana/pkg/registry/apis/scope" + "github.com/grafana/grafana/pkg/registry/apis/service" + "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" ) var WireSet = wire.NewSet( ProvideRegistryServiceSink, // dummy background service that forces registration + // read-only datasource abstractions + plugincontext.ProvideService, + wire.Bind(new(datasource.PluginContextWrapper), new(*plugincontext.Provider)), + datasource.ProvideDefaultPluginConfigs, + // Each must be added here *and* in the ServiceSink above - // playlistV0.RegisterAPIService, playlist.RegisterAPIService, + dashboard.RegisterAPIService, example.RegisterAPIService, + dashboardsnapshot.RegisterAPIService, + featuretoggle.RegisterAPIService, + datasource.RegisterAPIService, folders.RegisterAPIService, + peakq.RegisterAPIService, + service.RegisterAPIService, + query.RegisterAPIService, + scope.RegisterAPIService, ) diff --git a/pkg/registry/backgroundsvcs/background_services.go b/pkg/registry/backgroundsvcs/background_services.go index c2b0078087c21..43dd35bcd25ba 100644 --- a/pkg/registry/backgroundsvcs/background_services.go +++ b/pkg/registry/backgroundsvcs/background_services.go @@ -9,12 +9,12 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats/statscollector" "github.com/grafana/grafana/pkg/registry" apiregistry "github.com/grafana/grafana/pkg/registry/apis" - "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/anonymous/anonimpl" + grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/cleanup" + "github.com/grafana/grafana/pkg/services/cloudmigration" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" - grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver" "github.com/grafana/grafana/pkg/services/grpcserver" "github.com/grafana/grafana/pkg/services/guardian" ldapapi "github.com/grafana/grafana/pkg/services/ldap/api" @@ -26,6 +26,7 @@ import ( plugindashboardsservice "github.com/grafana/grafana/pkg/services/plugindashboards/service" "github.com/grafana/grafana/pkg/services/pluginsintegration/angulardetectorsprovider" "github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever/dynamic" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginexternal" pluginStore "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/provisioning" publicdashboardsmetric "github.com/grafana/grafana/pkg/services/publicdashboards/metric" @@ -36,6 +37,7 @@ import ( "github.com/grafana/grafana/pkg/services/serviceaccounts" samanager "github.com/grafana/grafana/pkg/services/serviceaccounts/manager" "github.com/grafana/grafana/pkg/services/ssosettings" + "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingsimpl" "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/store/sanitizer" @@ -48,7 +50,7 @@ func ProvideBackgroundServiceRegistry( httpServer *api.HTTPServer, ng *ngalert.AlertNG, cleanup *cleanup.CleanUpService, live *live.GrafanaLive, pushGateway *pushhttp.Gateway, notifications *notifications.NotificationService, pluginStore *pluginStore.Service, rendering *rendering.RenderingService, tokenService auth.UserTokenBackgroundService, tracing *tracing.TracingService, - provisioning *provisioning.ProvisioningServiceImpl, alerting *alerting.AlertEngine, usageStats *uss.UsageStats, + provisioning *provisioning.ProvisioningServiceImpl, usageStats *uss.UsageStats, statsCollector *statscollector.Service, grafanaUpdateChecker *updatechecker.GrafanaService, pluginsUpdateChecker *updatechecker.PluginsService, metrics *metrics.InternalMetricsService, secretsService *secretsManager.SecretsService, remoteCache *remotecache.RemoteCache, StorageService store.StorageService, searchService searchV2.SearchService, entityEventsService store.EntityEventsService, @@ -58,12 +60,15 @@ func ProvideBackgroundServiceRegistry( keyRetriever *dynamic.KeyRetriever, dynamicAngularDetectorsProvider *angulardetectorsprovider.Dynamic, grafanaAPIServer grafanaapiserver.Service, anon *anonimpl.AnonDeviceService, + ssoSettings *ssosettingsimpl.Service, + pluginExternal *pluginexternal.Service, // Need to make sure these are initialized, is there a better place to put them? - _ dashboardsnapshots.Service, _ *alerting.AlertNotificationService, + _ dashboardsnapshots.Service, _ serviceaccounts.Service, _ *guardian.Provider, _ *plugindashboardsservice.DashboardUpdater, _ *sanitizer.Provider, _ *grpcserver.HealthService, _ entity.EntityStoreServer, _ *grpcserver.ReflectionService, _ *ldapapi.Service, _ *apiregistry.Service, _ auth.IDService, _ *teamapi.TeamAPI, _ ssosettings.Service, + _ cloudmigration.Service, ) *BackgroundServiceRegistry { return NewBackgroundServiceRegistry( httpServer, @@ -75,7 +80,6 @@ func ProvideBackgroundServiceRegistry( rendering, tokenService, provisioning, - alerting, grafanaUpdateChecker, pluginsUpdateChecker, metrics, @@ -98,6 +102,8 @@ func ProvideBackgroundServiceRegistry( dynamicAngularDetectorsProvider, grafanaAPIServer, anon, + ssoSettings, + pluginExternal, ) } diff --git a/pkg/registry/corekind/base.go b/pkg/registry/corekind/base.go deleted file mode 100644 index 148bbb72f406f..0000000000000 --- a/pkg/registry/corekind/base.go +++ /dev/null @@ -1,42 +0,0 @@ -package corekind - -import ( - "sync" - - "github.com/grafana/kindsys" - "github.com/grafana/thema" - - "github.com/grafana/grafana/pkg/cuectx" -) - -var ( - baseOnce sync.Once - defaultBase *Base -) - -// NewBase provides a registry of all core raw and structured kinds, without any -// composition of slot kinds. -// -// All calling code within grafana/grafana is expected to use Grafana's -// singleton [thema.Runtime], returned from [cuectx.GrafanaThemaRuntime]. If nil -// is passed, the singleton will be used. -func NewBase(rt *thema.Runtime) *Base { - allrt := cuectx.GrafanaThemaRuntime() - if rt == nil || rt == allrt { - baseOnce.Do(func() { - defaultBase = doNewBase(allrt) - }) - return defaultBase - } - - return doNewBase(rt) -} - -// All returns a slice of [kindsys.Core] containing all core Grafana kinds. -// -// The returned slice is sorted lexicographically by kind machine name. -func (b *Base) All() []kindsys.Core { - ret := make([]kindsys.Core, len(b.all)) - copy(ret, b.all) - return ret -} diff --git a/pkg/registry/corekind/base_gen.go b/pkg/registry/corekind/base_gen.go deleted file mode 100644 index 658078498b5e5..0000000000000 --- a/pkg/registry/corekind/base_gen.go +++ /dev/null @@ -1,159 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// BaseCoreRegistryJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package corekind - -import ( - "fmt" - - "github.com/grafana/grafana/pkg/kinds/accesspolicy" - "github.com/grafana/grafana/pkg/kinds/dashboard" - "github.com/grafana/grafana/pkg/kinds/librarypanel" - "github.com/grafana/grafana/pkg/kinds/preferences" - "github.com/grafana/grafana/pkg/kinds/publicdashboard" - "github.com/grafana/grafana/pkg/kinds/role" - "github.com/grafana/grafana/pkg/kinds/rolebinding" - "github.com/grafana/grafana/pkg/kinds/team" - "github.com/grafana/kindsys" - "github.com/grafana/thema" -) - -// Base is a registry of all Grafana core kinds. It is designed for use both inside -// of Grafana itself, and for import by external Go programs wanting to work with Grafana's -// kind system. -// -// The registry provides two modes for accessing core kinds: -// - Per-kind methods, which return the kind-specific type, e.g. Dashboard() returns [dashboard.Dashboard]. -// - All(), which returns a slice of [kindsys.Core]. -// -// Prefer the individual named methods for use cases where the particular kind(s) that -// are needed are known to the caller. For example, a dashboard linter can know that it -// specifically wants the dashboard kind. -// -// Prefer All() when performing operations generically across all kinds. For example, -// a generic HTTP middleware for validating request bodies expected to contain some -// kind-schematized type. -type Base struct { - all []kindsys.Core - accesspolicy *accesspolicy.Kind - dashboard *dashboard.Kind - librarypanel *librarypanel.Kind - preferences *preferences.Kind - publicdashboard *publicdashboard.Kind - role *role.Kind - rolebinding *rolebinding.Kind - team *team.Kind -} - -// type guards -var ( - _ kindsys.Core = &accesspolicy.Kind{} - _ kindsys.Core = &dashboard.Kind{} - _ kindsys.Core = &librarypanel.Kind{} - _ kindsys.Core = &preferences.Kind{} - _ kindsys.Core = &publicdashboard.Kind{} - _ kindsys.Core = &role.Kind{} - _ kindsys.Core = &rolebinding.Kind{} - _ kindsys.Core = &team.Kind{} -) - -// AccessPolicy returns the [kindsys.Interface] implementation for the accesspolicy kind. -func (b *Base) AccessPolicy() *accesspolicy.Kind { - return b.accesspolicy -} - -// Dashboard returns the [kindsys.Interface] implementation for the dashboard kind. -func (b *Base) Dashboard() *dashboard.Kind { - return b.dashboard -} - -// LibraryPanel returns the [kindsys.Interface] implementation for the librarypanel kind. -func (b *Base) LibraryPanel() *librarypanel.Kind { - return b.librarypanel -} - -// Preferences returns the [kindsys.Interface] implementation for the preferences kind. -func (b *Base) Preferences() *preferences.Kind { - return b.preferences -} - -// PublicDashboard returns the [kindsys.Interface] implementation for the publicdashboard kind. -func (b *Base) PublicDashboard() *publicdashboard.Kind { - return b.publicdashboard -} - -// Role returns the [kindsys.Interface] implementation for the role kind. -func (b *Base) Role() *role.Kind { - return b.role -} - -// RoleBinding returns the [kindsys.Interface] implementation for the rolebinding kind. -func (b *Base) RoleBinding() *rolebinding.Kind { - return b.rolebinding -} - -// Team returns the [kindsys.Interface] implementation for the team kind. -func (b *Base) Team() *team.Kind { - return b.team -} - -func doNewBase(rt *thema.Runtime) *Base { - var err error - reg := &Base{} - - reg.accesspolicy, err = accesspolicy.NewKind(rt) - if err != nil { - panic(fmt.Sprintf("error while initializing the accesspolicy Kind: %s", err)) - } - reg.all = append(reg.all, reg.accesspolicy) - - reg.dashboard, err = dashboard.NewKind(rt) - if err != nil { - panic(fmt.Sprintf("error while initializing the dashboard Kind: %s", err)) - } - reg.all = append(reg.all, reg.dashboard) - - reg.librarypanel, err = librarypanel.NewKind(rt) - if err != nil { - panic(fmt.Sprintf("error while initializing the librarypanel Kind: %s", err)) - } - reg.all = append(reg.all, reg.librarypanel) - - reg.preferences, err = preferences.NewKind(rt) - if err != nil { - panic(fmt.Sprintf("error while initializing the preferences Kind: %s", err)) - } - reg.all = append(reg.all, reg.preferences) - - reg.publicdashboard, err = publicdashboard.NewKind(rt) - if err != nil { - panic(fmt.Sprintf("error while initializing the publicdashboard Kind: %s", err)) - } - reg.all = append(reg.all, reg.publicdashboard) - - reg.role, err = role.NewKind(rt) - if err != nil { - panic(fmt.Sprintf("error while initializing the role Kind: %s", err)) - } - reg.all = append(reg.all, reg.role) - - reg.rolebinding, err = rolebinding.NewKind(rt) - if err != nil { - panic(fmt.Sprintf("error while initializing the rolebinding Kind: %s", err)) - } - reg.all = append(reg.all, reg.rolebinding) - - reg.team, err = team.NewKind(rt) - if err != nil { - panic(fmt.Sprintf("error while initializing the team Kind: %s", err)) - } - reg.all = append(reg.all, reg.team) - - return reg -} diff --git a/pkg/registry/schemas/composable_kind.go b/pkg/registry/schemas/composable_kind.go new file mode 100644 index 0000000000000..dd5bdd5f8c949 --- /dev/null +++ b/pkg/registry/schemas/composable_kind.go @@ -0,0 +1,458 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. +// +// Generated by: +// public/app/plugins/gen.go +// Using jennies: +// PluginRegistryJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +package schemas + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "testing/fstest" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/cue/load" +) + +var cueImportsPath = filepath.Join("packages", "grafana-schema", "src", "common") +var importPath = "github.com/grafana/grafana/packages/grafana-schema/src/common" + +type ComposableKind struct { + Name string + Filename string + CueFile cue.Value +} + +func GetComposableKinds() ([]ComposableKind, error) { + kinds := make([]ComposableKind, 0) + + _, caller, _, _ := runtime.Caller(0) + root := filepath.Join(caller, "../../../..") + + azuremonitorCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/datasource/azuremonitor/dataquery.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "azuremonitor", + Filename: "dataquery.cue", + CueFile: azuremonitorCue, + }) + + googlecloudmonitoringCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/datasource/cloud-monitoring/dataquery.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "googlecloudmonitoring", + Filename: "dataquery.cue", + CueFile: googlecloudmonitoringCue, + }) + + cloudwatchCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/datasource/cloudwatch/dataquery.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "cloudwatch", + Filename: "dataquery.cue", + CueFile: cloudwatchCue, + }) + + elasticsearchCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/datasource/elasticsearch/dataquery.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "elasticsearch", + Filename: "dataquery.cue", + CueFile: elasticsearchCue, + }) + + grafanapyroscopeCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "grafanapyroscope", + Filename: "dataquery.cue", + CueFile: grafanapyroscopeCue, + }) + + grafanatestdatadatasourceCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/datasource/grafana-testdata-datasource/dataquery.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "grafanatestdatadatasource", + Filename: "dataquery.cue", + CueFile: grafanatestdatadatasourceCue, + }) + + lokiCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/datasource/loki/dataquery.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "loki", + Filename: "dataquery.cue", + CueFile: lokiCue, + }) + + parcaCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/datasource/parca/dataquery.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "parca", + Filename: "dataquery.cue", + CueFile: parcaCue, + }) + + tempoCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/datasource/tempo/dataquery.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "tempo", + Filename: "dataquery.cue", + CueFile: tempoCue, + }) + + annotationslistCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/annolist/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "annotationslist", + Filename: "panelcfg.cue", + CueFile: annotationslistCue, + }) + + barchartCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/barchart/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "barchart", + Filename: "panelcfg.cue", + CueFile: barchartCue, + }) + + bargaugeCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/bargauge/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "bargauge", + Filename: "panelcfg.cue", + CueFile: bargaugeCue, + }) + + candlestickCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/candlestick/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "candlestick", + Filename: "panelcfg.cue", + CueFile: candlestickCue, + }) + + canvasCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/canvas/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "canvas", + Filename: "panelcfg.cue", + CueFile: canvasCue, + }) + + dashboardlistCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/dashlist/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "dashboardlist", + Filename: "panelcfg.cue", + CueFile: dashboardlistCue, + }) + + datagridCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/datagrid/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "datagrid", + Filename: "panelcfg.cue", + CueFile: datagridCue, + }) + + debugCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/debug/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "debug", + Filename: "panelcfg.cue", + CueFile: debugCue, + }) + + gaugeCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/gauge/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "gauge", + Filename: "panelcfg.cue", + CueFile: gaugeCue, + }) + + geomapCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/geomap/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "geomap", + Filename: "panelcfg.cue", + CueFile: geomapCue, + }) + + heatmapCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/heatmap/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "heatmap", + Filename: "panelcfg.cue", + CueFile: heatmapCue, + }) + + histogramCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/histogram/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "histogram", + Filename: "panelcfg.cue", + CueFile: histogramCue, + }) + + logsCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/logs/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "logs", + Filename: "panelcfg.cue", + CueFile: logsCue, + }) + + newsCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/news/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "news", + Filename: "panelcfg.cue", + CueFile: newsCue, + }) + + nodegraphCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/nodeGraph/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "nodegraph", + Filename: "panelcfg.cue", + CueFile: nodegraphCue, + }) + + piechartCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/piechart/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "piechart", + Filename: "panelcfg.cue", + CueFile: piechartCue, + }) + + statCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/stat/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "stat", + Filename: "panelcfg.cue", + CueFile: statCue, + }) + + statetimelineCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/state-timeline/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "statetimeline", + Filename: "panelcfg.cue", + CueFile: statetimelineCue, + }) + + statushistoryCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/status-history/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "statushistory", + Filename: "panelcfg.cue", + CueFile: statushistoryCue, + }) + + tableCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/table/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "table", + Filename: "panelcfg.cue", + CueFile: tableCue, + }) + + textCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/text/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "text", + Filename: "panelcfg.cue", + CueFile: textCue, + }) + + timeseriesCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/timeseries/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "timeseries", + Filename: "panelcfg.cue", + CueFile: timeseriesCue, + }) + + trendCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/trend/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "trend", + Filename: "panelcfg.cue", + CueFile: trendCue, + }) + + xychartCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/xychart/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "xychart", + Filename: "panelcfg.cue", + CueFile: xychartCue, + }) + + return kinds, nil +} + +func loadCueFileWithCommon(root string, entrypoint string) (cue.Value, error) { + commonFS, err := mockCommonFS(root) + if err != nil { + fmt.Printf("cannot load common cue files: %s\n", err) + return cue.Value{}, err + } + + overlay, err := buildOverlay(commonFS) + if err != nil { + fmt.Printf("Cannot build overlay: %s\n", err) + return cue.Value{}, err + } + + bis := load.Instances([]string{entrypoint}, &load.Config{ + ModuleRoot: "/", + Overlay: overlay, + }) + + values, err := cuecontext.New().BuildInstances(bis) + if err != nil { + fmt.Printf("Cannot build instance: %s\n", err) + return cue.Value{}, err + } + + return values[0], nil +} + +func mockCommonFS(root string) (fs.FS, error) { + path := filepath.Join(root, cueImportsPath) + dir, err := os.ReadDir(path) + if err != nil { + return nil, fmt.Errorf("cannot open common cue files directory: %s", err) + } + + prefix := "cue.mod/pkg/" + importPath + + commonFS := fstest.MapFS{} + for _, d := range dir { + if d.IsDir() { + continue + } + + readPath := filepath.Join(path, d.Name()) + b, err := os.ReadFile(filepath.Clean(readPath)) + if err != nil { + return nil, err + } + + commonFS[filepath.Join(prefix, d.Name())] = &fstest.MapFile{Data: b} + } + + return commonFS, nil +} + +// It loads common cue files into the schema to be able to make import works +func buildOverlay(commonFS fs.FS) (map[string]load.Source, error) { + overlay := make(map[string]load.Source) + + err := fs.WalkDir(commonFS, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + f, err := commonFS.Open(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + b, err := io.ReadAll(f) + if err != nil { + return err + } + + overlay[filepath.Join("/", path)] = load.FromBytes(b) + + return nil + }) + + return overlay, err +} diff --git a/pkg/registry/schemas/core_kind.go b/pkg/registry/schemas/core_kind.go new file mode 100644 index 0000000000000..596eeedf04330 --- /dev/null +++ b/pkg/registry/schemas/core_kind.go @@ -0,0 +1,115 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. +// +// Generated by: +// kinds/gen.go +// Using jennies: +// CoreRegistryJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +package schemas + +import ( + "os" + "path/filepath" + "runtime" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" +) + +type CoreKind struct { + Name string + CueFile cue.Value +} + +func GetCoreKinds() ([]CoreKind, error) { + ctx := cuecontext.New() + kinds := make([]CoreKind, 0) + + _, caller, _, _ := runtime.Caller(0) + root := filepath.Join(caller, "../../../..") + + accesspolicyCue, err := loadCueFile(ctx, filepath.Join(root, "./kinds/accesspolicy/access_policy_kind.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, CoreKind{ + Name: "accesspolicy", + CueFile: accesspolicyCue, + }) + + dashboardCue, err := loadCueFile(ctx, filepath.Join(root, "./kinds/dashboard/dashboard_kind.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, CoreKind{ + Name: "dashboard", + CueFile: dashboardCue, + }) + + librarypanelCue, err := loadCueFile(ctx, filepath.Join(root, "./kinds/librarypanel/librarypanel_kind.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, CoreKind{ + Name: "librarypanel", + CueFile: librarypanelCue, + }) + + preferencesCue, err := loadCueFile(ctx, filepath.Join(root, "./kinds/preferences/preferences_kind.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, CoreKind{ + Name: "preferences", + CueFile: preferencesCue, + }) + + publicdashboardCue, err := loadCueFile(ctx, filepath.Join(root, "./kinds/publicdashboard/public_dashboard_kind.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, CoreKind{ + Name: "publicdashboard", + CueFile: publicdashboardCue, + }) + + roleCue, err := loadCueFile(ctx, filepath.Join(root, "./kinds/role/role_kind.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, CoreKind{ + Name: "role", + CueFile: roleCue, + }) + + rolebindingCue, err := loadCueFile(ctx, filepath.Join(root, "./kinds/rolebinding/role_binding_kind.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, CoreKind{ + Name: "rolebinding", + CueFile: rolebindingCue, + }) + + teamCue, err := loadCueFile(ctx, filepath.Join(root, "./kinds/team/team_kind.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, CoreKind{ + Name: "team", + CueFile: teamCue, + }) + + return kinds, nil +} + +func loadCueFile(ctx *cue.Context, path string) (cue.Value, error) { + cueFile, err := os.ReadFile(path) + if err != nil { + return cue.Value{}, err + } + + return ctx.CompileBytes(cueFile), nil +} diff --git a/pkg/server/test_env.go b/pkg/server/test_env.go index 2d54760fd5a90..6420fd63bb08a 100644 --- a/pkg/server/test_env.go +++ b/pkg/server/test_env.go @@ -3,6 +3,7 @@ package server import ( "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/plugins/manager/registry" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/grpcserver" "github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/oauthtoken/oauthtokentest" @@ -18,6 +19,7 @@ func ProvideTestEnv( pluginRegistry registry.Service, httpClientProvider httpclient.Provider, oAuthTokenService *oauthtokentest.Service, + featureMgmt featuremgmt.FeatureToggles, ) (*TestEnv, error) { return &TestEnv{ Server: server, @@ -27,6 +29,7 @@ func ProvideTestEnv( PluginRegistry: pluginRegistry, HTTPClientProvider: httpClientProvider, OAuthTokenService: oAuthTokenService, + FeatureToggles: featureMgmt, }, nil } @@ -39,4 +42,5 @@ type TestEnv struct { HTTPClientProvider httpclient.Provider OAuthTokenService *oauthtokentest.Service RequestMiddleware web.Middleware + FeatureToggles featuremgmt.FeatureToggles } diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 64909565597d1..e2548922582e7 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -38,16 +38,18 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" - "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/annotations/annotationsimpl" "github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" + grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver" + "github.com/grafana/grafana/pkg/services/apiserver/standalone" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth/idimpl" "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/authn/authnimpl" "github.com/grafana/grafana/pkg/services/cleanup" + "github.com/grafana/grafana/pkg/services/cloudmigration/cloudmigrationimpl" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/correlations" "github.com/grafana/grafana/pkg/services/dashboardimport" @@ -64,13 +66,10 @@ import ( "github.com/grafana/grafana/pkg/services/encryption" encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" "github.com/grafana/grafana/pkg/services/extsvcauth" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/oasimpl" extsvcreg "github.com/grafana/grafana/pkg/services/extsvcauth/registry" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/folderimpl" - grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver" "github.com/grafana/grafana/pkg/services/grpcserver" grpccontext "github.com/grafana/grafana/pkg/services/grpcserver/context" "github.com/grafana/grafana/pkg/services/grpcserver/interceptors" @@ -90,8 +89,6 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert" ngimage "github.com/grafana/grafana/pkg/services/ngalert/image" ngmetrics "github.com/grafana/grafana/pkg/services/ngalert/metrics" - ngmigration "github.com/grafana/grafana/pkg/services/ngalert/migration" - migrationStore "github.com/grafana/grafana/pkg/services/ngalert/migration/store" ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/oauthtoken" @@ -129,6 +126,7 @@ import ( "github.com/grafana/grafana/pkg/services/signingkeys" "github.com/grafana/grafana/pkg/services/signingkeys/signingkeysimpl" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" "github.com/grafana/grafana/pkg/services/ssosettings" ssoSettingsImpl "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingsimpl" starApi "github.com/grafana/grafana/pkg/services/star/api" @@ -136,6 +134,7 @@ import ( "github.com/grafana/grafana/pkg/services/stats/statsimpl" "github.com/grafana/grafana/pkg/services/store" entityDB "github.com/grafana/grafana/pkg/services/store/entity/db" + "github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl" "github.com/grafana/grafana/pkg/services/store/entity/sqlstash" "github.com/grafana/grafana/pkg/services/store/resolver" "github.com/grafana/grafana/pkg/services/store/sanitizer" @@ -149,6 +148,7 @@ import ( tempuser "github.com/grafana/grafana/pkg/services/temp_user" "github.com/grafana/grafana/pkg/services/temp_user/tempuserimpl" "github.com/grafana/grafana/pkg/services/updatechecker" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" @@ -177,9 +177,6 @@ var wireBasicSet = wire.NewSet( wire.Bind(new(legacydata.RequestHandler), new(*legacydataservice.Service)), annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), - alerting.ProvideAlertStore, - alerting.ProvideAlertEngine, - wire.Bind(new(alerting.UsageStatsQuerier), new(*alerting.AlertEngine)), New, api.ProvideHTTPServer, query.ProvideService, @@ -243,8 +240,6 @@ var wireBasicSet = wire.NewSet( wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), ngstore.ProvideDBStore, ngimage.ProvideDeleteExpiredService, - ngmigration.ProvideService, - migrationStore.ProvideMigrationStore, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), @@ -282,7 +277,7 @@ var wireBasicSet = wire.NewSet( dashsnapsvc.ProvideService, datasourceservice.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*datasourceservice.Service)), - alerting.ProvideService, + datasourceservice.ProvideLegacyDataSourceLookup, serviceaccountsretriever.ProvideService, wire.Bind(new(serviceaccountsretriever.ServiceAccountRetriever), new(*serviceaccountsretriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, @@ -306,8 +301,6 @@ var wireBasicSet = wire.NewSet( plugindashboardsservice.ProvideService, wire.Bind(new(plugindashboards.Service), new(*plugindashboardsservice.Service)), plugindashboardsservice.ProvideDashboardUpdater, - alerting.ProvideDashAlertExtractorService, - wire.Bind(new(alerting.DashAlertExtractor), new(*alerting.DashAlertExtractorService)), guardian.ProvideService, sanitizer.ProvideService, secretsStore.ProvideService, @@ -342,8 +335,8 @@ var wireBasicSet = wire.NewSet( grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, interceptors.ProvideAuthenticator, - entityDB.ProvideEntityDB, - wire.Bind(new(sqlstash.EntityDB), new(*entityDB.EntityDB)), + dbimpl.ProvideEntityDB, + wire.Bind(new(entityDB.EntityDBInterface), new(*dbimpl.EntityDB)), sqlstash.ProvideSQLEntityServer, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, @@ -368,8 +361,6 @@ var wireBasicSet = wire.NewSet( supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), - oasimpl.ProvideService, - wire.Bind(new(oauthserver.OAuth2Server), new(*oasimpl.OAuth2ServiceImpl)), extsvcreg.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*extsvcreg.Registry)), anonstore.ProvideAnonDBStore, @@ -378,9 +369,13 @@ var wireBasicSet = wire.NewSet( signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssoSettingsImpl.ProvideService, - wire.Bind(new(ssosettings.Service), new(*ssoSettingsImpl.SSOSettingsService)), + wire.Bind(new(ssosettings.Service), new(*ssoSettingsImpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), + cloudmigrationimpl.ProvideService, + userimpl.ProvideVerifier, + wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), + // Kubernetes API server grafanaapiserver.WireSet, apiregistry.WireSet, ) @@ -436,7 +431,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser return &Server{}, nil } -func InitializeForTest(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*TestEnv, error) { +func InitializeForTest(t sqlutil.ITestDB, cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*TestEnv, error) { wire.Build(wireExtsTestSet) return &TestEnv{Server: &Server{}, SQLStore: &sqlstore.SQLStore{}}, nil } @@ -459,3 +454,9 @@ func InitializeModuleServer(cfg *setting.Cfg, opts Options, apiOpts api.ServerOp wire.Build(wireExtsModuleServerSet) return &ModuleServer{}, nil } + +// Initialize the standalone APIServer factory +func InitializeAPIServerFactory() (standalone.APIServerFactory, error) { + wire.Build(wireExtsStandaloneAPIServerSet) + return &standalone.DummyAPIFactory{}, nil // Wire will replace this with a real interface +} diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index 5efd58cd50e97..6b9cb1407b125 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" "github.com/grafana/grafana/pkg/services/anonymous" "github.com/grafana/grafana/pkg/services/anonymous/anonimpl" + "github.com/grafana/grafana/pkg/services/apiserver/standalone" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth/authimpl" "github.com/grafana/grafana/pkg/services/auth/idimpl" @@ -137,3 +138,7 @@ var wireExtsModuleServerSet = wire.NewSet( NewModule, wireExtsBaseCLISet, ) + +var wireExtsStandaloneAPIServerSet = wire.NewSet( + standalone.GetDummyAPIFactory, +) diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index a07053c003dde..616d876a205ee 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -2,7 +2,9 @@ package accesscontrol import ( "context" + "errors" "fmt" + "strconv" "strings" "github.com/grafana/grafana/pkg/registry" @@ -24,6 +26,8 @@ type Service interface { registry.ProvidesUsageStats // GetUserPermissions returns user permissions with only action and scope fields set. GetUserPermissions(ctx context.Context, user identity.Requester, options Options) ([]Permission, error) + // GetUserPermissionsInOrg return user permission in a specific organization + GetUserPermissionsInOrg(ctx context.Context, user identity.Requester, orgID int64) ([]Permission, error) // SearchUsersPermissions returns all users' permissions filtered by an action prefix SearchUsersPermissions(ctx context.Context, user identity.Requester, options SearchOptions) (map[int64][]Permission, error) // ClearUserPermissionCache removes the permission cache entry for the given user @@ -33,6 +37,9 @@ type Service interface { // DeleteUserPermissions removes all permissions user has in org and all permission to that user // If orgID is set to 0 remove permissions from all orgs DeleteUserPermissions(ctx context.Context, orgID, userID int64) error + // DeleteTeamPermissions removes all role assignments and permissions granted to a team + // and removes permissions scoped to the team. + DeleteTeamPermissions(ctx context.Context, orgID, teamID int64) error // DeclareFixedRoles allows the caller to declare, to the service, fixed roles and their // assignments to organization roles ("Viewer", "Editor", "Admin") or "Grafana Admin" DeclareFixedRoles(registrations ...RoleRegistration) error @@ -40,6 +47,19 @@ type Service interface { SaveExternalServiceRole(ctx context.Context, cmd SaveExternalServiceRoleCommand) error // DeleteExternalServiceRole removes an external service's role and its assignment. DeleteExternalServiceRole(ctx context.Context, externalServiceID string) error + // SyncUserRoles adds provided roles to user + SyncUserRoles(ctx context.Context, orgID int64, cmd SyncUserRolesCommand) error +} + +//go:generate mockery --name Store --structname MockStore --outpkg actest --filename store_mock.go --output ./actest/ +type Store interface { + GetUserPermissions(ctx context.Context, query GetUserPermissionsQuery) ([]Permission, error) + SearchUsersPermissions(ctx context.Context, orgID int64, options SearchOptions) (map[int64][]Permission, error) + GetUsersBasicRoles(ctx context.Context, userFilter []int64, orgID int64) (map[int64][]string, error) + DeleteUserPermissions(ctx context.Context, orgID, userID int64) error + DeleteTeamPermissions(ctx context.Context, orgID, teamID int64) error + SaveExternalServiceRole(ctx context.Context, cmd SaveExternalServiceRoleCommand) error + DeleteExternalServiceRole(ctx context.Context, externalServiceID string) error } type RoleRegistry interface { @@ -55,7 +75,54 @@ type SearchOptions struct { ActionPrefix string // Needed for the PoC v1, it's probably going to be removed. Action string Scope string - UserID int64 // ID for the user for which to return information, if none is specified information is returned for all users. + NamespacedID string // ID of the identity (ex: user:3, service-account:4) + wildcards Wildcards // private field computed based on the Scope + RolePrefixes []string +} + +// Wildcards computes the wildcard scopes that include the scope +func (s *SearchOptions) Wildcards() []string { + if s.wildcards != nil { + return s.wildcards + } + + if s.Scope == "" { + s.wildcards = []string{} + return s.wildcards + } + + s.wildcards = WildcardsFromPrefix(ScopePrefix(s.Scope)) + return s.wildcards +} + +func (s *SearchOptions) ComputeUserID() (int64, error) { + if s.NamespacedID == "" { + return 0, errors.New("namespacedID must be set") + } + // Split namespaceID into namespace and ID + parts := strings.Split(s.NamespacedID, ":") + // Validate namespace ID format + if len(parts) != 2 { + return 0, fmt.Errorf("invalid namespaced ID: %s", s.NamespacedID) + } + // Validate namespace type is user or service account + if parts[0] != identity.NamespaceUser && parts[0] != identity.NamespaceServiceAccount { + return 0, fmt.Errorf("invalid namespace: %s", parts[0]) + } + // Validate namespace ID is a number + id, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid namespaced ID: %s", s.NamespacedID) + } + return id, nil +} + +type SyncUserRolesCommand struct { + UserID int64 + // name of roles the user should have + RolesToAdd []string + // name of roles the user should not have + RolesToRemove []string } type TeamPermissionsService interface { @@ -232,110 +299,6 @@ func Reduce(ps []Permission) map[string][]string { return reduced } -// intersectScopes computes the minimal list of scopes common to two slices. -func intersectScopes(s1, s2 []string) []string { - if len(s1) == 0 || len(s2) == 0 { - return []string{} - } - - // helpers - splitScopes := func(s []string) (map[string]bool, map[string]bool) { - scopes := make(map[string]bool) - wildcards := make(map[string]bool) - for _, s := range s { - if isWildcard(s) { - wildcards[s] = true - } else { - scopes[s] = true - } - } - return scopes, wildcards - } - includes := func(wildcardsSet map[string]bool, scope string) bool { - for wildcard := range wildcardsSet { - if wildcard == "*" || strings.HasPrefix(scope, wildcard[:len(wildcard)-1]) { - return true - } - } - return false - } - - res := make([]string, 0) - - // split input into scopes and wildcards - s1Scopes, s1Wildcards := splitScopes(s1) - s2Scopes, s2Wildcards := splitScopes(s2) - - // intersect wildcards - wildcards := make(map[string]bool) - for s := range s1Wildcards { - // if s1 wildcard is included in s2 wildcards - // then it is included in the intersection - if includes(s2Wildcards, s) { - wildcards[s] = true - continue - } - } - for s := range s2Wildcards { - // if s2 wildcard is included in s1 wildcards - // then it is included in the intersection - if includes(s1Wildcards, s) { - wildcards[s] = true - } - } - - // intersect scopes - scopes := make(map[string]bool) - for s := range s1Scopes { - // if s1 scope is included in s2 wilcards or s2 scopes - // then it is included in the intersection - if includes(s2Wildcards, s) || s2Scopes[s] { - scopes[s] = true - } - } - for s := range s2Scopes { - // if s2 scope is included in s1 wilcards - // then it is included in the intersection - if includes(s1Wildcards, s) { - scopes[s] = true - } - } - - // merge wildcards and scopes - for w := range wildcards { - res = append(res, w) - } - for s := range scopes { - res = append(res, s) - } - - return res -} - -// Intersect returns the intersection of two slices of permissions, grouping scopes by action. -func Intersect(p1, p2 []Permission) map[string][]string { - if len(p1) == 0 || len(p2) == 0 { - return map[string][]string{} - } - - res := make(map[string][]string) - p1m := Reduce(p1) - p2m := Reduce(p2) - - // Loop over the smallest map - if len(p1m) > len(p2m) { - p1m, p2m = p2m, p1m - } - - for a1, s1 := range p1m { - if s2, ok := p2m[a1]; ok { - res[a1] = intersectScopes(s1, s2) - } - } - - return res -} - func ValidateScope(scope string) bool { prefix, last := scope[:len(scope)-1], scope[len(scope)-1] // verify that last char is either ':' or '/' if last character of scope is '*' diff --git a/pkg/services/accesscontrol/accesscontrol_test.go b/pkg/services/accesscontrol/accesscontrol_test.go index 1b13136cb78a9..b39ca3b8a8042 100644 --- a/pkg/services/accesscontrol/accesscontrol_test.go +++ b/pkg/services/accesscontrol/accesscontrol_test.go @@ -1,7 +1,6 @@ package accesscontrol import ( - "fmt" "testing" "github.com/stretchr/testify/require" @@ -126,210 +125,3 @@ func TestReduce(t *testing.T) { }) } } - -func TestIntersect(t *testing.T) { - tests := []struct { - name string - p1 []Permission - p2 []Permission - want map[string][]string - }{ - { - name: "no permission", - p1: []Permission{}, - p2: []Permission{}, - want: map[string][]string{}, - }, - { - name: "no intersection", - p1: []Permission{{Action: "orgs:read"}}, - p2: []Permission{{Action: "orgs:write"}}, - want: map[string][]string{}, - }, - { - name: "intersection no scopes", - p1: []Permission{{Action: "orgs:read"}}, - p2: []Permission{{Action: "orgs:read"}}, - want: map[string][]string{"orgs:read": {}}, - }, - { - name: "unbalanced intersection", - p1: []Permission{{Action: "teams:read", Scope: "teams:id:1"}}, - p2: []Permission{{Action: "teams:read"}}, - want: map[string][]string{"teams:read": {}}, - }, - { - name: "intersection", - p1: []Permission{ - {Action: "teams:read", Scope: "teams:id:1"}, - {Action: "teams:read", Scope: "teams:id:2"}, - {Action: "teams:write", Scope: "teams:id:1"}, - }, - p2: []Permission{ - {Action: "teams:read", Scope: "teams:id:1"}, - {Action: "teams:read", Scope: "teams:id:3"}, - {Action: "teams:write", Scope: "teams:id:1"}, - }, - want: map[string][]string{ - "teams:read": {"teams:id:1"}, - "teams:write": {"teams:id:1"}, - }, - }, - { - name: "intersection with wildcards", - p1: []Permission{ - {Action: "teams:read", Scope: "teams:id:1"}, - {Action: "teams:read", Scope: "teams:id:2"}, - {Action: "teams:write", Scope: "teams:id:1"}, - }, - p2: []Permission{ - {Action: "teams:read", Scope: "*"}, - {Action: "teams:write", Scope: "*"}, - }, - want: map[string][]string{ - "teams:read": {"teams:id:1", "teams:id:2"}, - "teams:write": {"teams:id:1"}, - }, - }, - { - name: "intersection with wildcards on both sides", - p1: []Permission{ - {Action: "dashboards:read", Scope: "dashboards:uid:1"}, - {Action: "dashboards:read", Scope: "folders:uid:1"}, - {Action: "dashboards:read", Scope: "dashboards:uid:*"}, - {Action: "folders:read", Scope: "folders:uid:1"}, - }, - p2: []Permission{ - {Action: "dashboards:read", Scope: "folders:uid:*"}, - {Action: "dashboards:read", Scope: "dashboards:uid:*"}, - {Action: "folders:read", Scope: "folders:uid:*"}, - }, - want: map[string][]string{ - "dashboards:read": {"dashboards:uid:*", "folders:uid:1"}, - "folders:read": {"folders:uid:1"}, - }, - }, - { - name: "intersection with wildcards of different sizes", - p1: []Permission{ - {Action: "dashboards:read", Scope: "folders:uid:1"}, - {Action: "dashboards:read", Scope: "dashboards:*"}, - {Action: "folders:read", Scope: "folders:*"}, - {Action: "teams:read", Scope: "teams:id:1"}, - }, - p2: []Permission{ - {Action: "dashboards:read", Scope: "folders:uid:*"}, - {Action: "dashboards:read", Scope: "dashboards:uid:*"}, - {Action: "folders:read", Scope: "folders:uid:*"}, - {Action: "teams:read", Scope: "*"}, - }, - want: map[string][]string{ - "dashboards:read": {"dashboards:uid:*", "folders:uid:1"}, - "folders:read": {"folders:uid:*"}, - "teams:read": {"teams:id:1"}, - }, - }, - } - check := func(t *testing.T, want map[string][]string, p1, p2 []Permission) { - intersect := Intersect(p1, p2) - for action, scopes := range intersect { - want, ok := want[action] - require.True(t, ok) - require.ElementsMatch(t, scopes, want, fmt.Sprintf("scopes for %v differs from expected", action)) - } - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Intersect is commutative - check(t, tt.want, tt.p1, tt.p2) - check(t, tt.want, tt.p2, tt.p1) - }) - } -} - -func Test_intersectScopes(t *testing.T) { - tests := []struct { - name string - s1 []string - s2 []string - want []string - }{ - { - name: "no values", - s1: []string{}, - s2: []string{}, - want: []string{}, - }, - { - name: "no values on one side", - s1: []string{}, - s2: []string{"teams:id:1"}, - want: []string{}, - }, - { - name: "empty values on one side", - s1: []string{""}, - s2: []string{"team:id:1"}, - want: []string{}, - }, - { - name: "no intersection", - s1: []string{"teams:id:1"}, - s2: []string{"teams:id:2"}, - want: []string{}, - }, - { - name: "intersection", - s1: []string{"teams:id:1"}, - s2: []string{"teams:id:1"}, - want: []string{"teams:id:1"}, - }, - { - name: "intersection with wildcard", - s1: []string{"teams:id:1", "teams:id:2"}, - s2: []string{"teams:id:*"}, - want: []string{"teams:id:1", "teams:id:2"}, - }, - { - name: "intersection of wildcards", - s1: []string{"teams:id:*"}, - s2: []string{"teams:id:*"}, - want: []string{"teams:id:*"}, - }, - { - name: "intersection with a bigger wildcards", - s1: []string{"teams:id:*"}, - s2: []string{"teams:*"}, - want: []string{"teams:id:*"}, - }, - { - name: "intersection of different wildcards with a bigger one", - s1: []string{"dashboards:uid:*", "folders:uid:*"}, - s2: []string{"*"}, - want: []string{"dashboards:uid:*", "folders:uid:*"}, - }, - { - name: "intersection with wildcards and scopes on both sides", - s1: []string{"dashboards:uid:*", "folders:uid:1"}, - s2: []string{"folders:uid:*", "dashboards:uid:1"}, - want: []string{"dashboards:uid:1", "folders:uid:1"}, - }, - { - name: "intersection of non reduced list of scopes", - s1: []string{"dashboards:uid:*", "dashboards:*", "dashboards:uid:1"}, - s2: []string{"dashboards:uid:*", "dashboards:*", "dashboards:uid:2"}, - want: []string{"dashboards:uid:*", "dashboards:*", "dashboards:uid:1", "dashboards:uid:2"}, - }, - } - check := func(t *testing.T, want []string, s1, s2 []string) { - intersect := intersectScopes(s1, s2) - require.ElementsMatch(t, want, intersect) - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Intersect is commutative - check(t, tt.want, tt.s1, tt.s2) - check(t, tt.want, tt.s2, tt.s1) - }) - } -} diff --git a/pkg/services/accesscontrol/acimpl/accesscontrol.go b/pkg/services/accesscontrol/acimpl/accesscontrol.go index f99e4edbe8284..b26900ca0b94f 100644 --- a/pkg/services/accesscontrol/acimpl/accesscontrol.go +++ b/pkg/services/accesscontrol/acimpl/accesscontrol.go @@ -38,14 +38,19 @@ func (a *AccessControl) Evaluate(ctx context.Context, user identity.Requester, e return false, nil } - namespace, identifier := user.GetNamespacedID() - if len(user.GetPermissions()) == 0 { - a.log.Debug("No permissions set for entity", "namespace", namespace, "id", identifier, "orgID", user.GetOrgID(), "login", user.GetLogin()) + // If the user is in no organization, then the evaluation must happen based on the user's global permissions + permissions := user.GetPermissions() + if user.GetOrgID() == accesscontrol.NoOrgID { + permissions = user.GetGlobalPermissions() + } + if len(permissions) == 0 { + a.debug(ctx, user, "No permissions set", evaluator) return false, nil } + a.debug(ctx, user, "Evaluating permissions", evaluator) // Test evaluation without scope resolver first, this will prevent 403 for wildcard scopes when resource does not exist - if evaluator.Evaluate(user.GetPermissions()) { + if evaluator.Evaluate(permissions) { return true, nil } @@ -57,9 +62,15 @@ func (a *AccessControl) Evaluate(ctx context.Context, user identity.Requester, e return false, err } - return resolvedEvaluator.Evaluate(user.GetPermissions()), nil + a.debug(ctx, user, "Evaluating resolved permissions", resolvedEvaluator) + return resolvedEvaluator.Evaluate(permissions), nil } func (a *AccessControl) RegisterScopeAttributeResolver(prefix string, resolver accesscontrol.ScopeAttributeResolver) { a.resolvers.AddScopeAttributeResolver(prefix, resolver) } + +func (a *AccessControl) debug(ctx context.Context, ident identity.Requester, msg string, eval accesscontrol.Evaluator) { + namespace, id := ident.GetNamespacedID() + a.log.FromContext(ctx).Debug(msg, "namespace", namespace, "id", id, "orgID", ident.GetOrgID(), "permissions", eval.GoString()) +} diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index ac51ecc0f8bff..5cd05fd094175 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -3,6 +3,7 @@ package acimpl import ( "context" "fmt" + "slices" "strconv" "strings" "time" @@ -22,6 +23,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/migrator" "github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils" "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -40,8 +42,10 @@ var SharedWithMeFolderPermission = accesscontrol.Permission{ Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder.SharedWithMeFolderUID), } +var OSSRolesPrefixes = []string{accesscontrol.ManagedRolePrefix, accesscontrol.ExternalServiceRolePrefix} + func ProvideService(cfg *setting.Cfg, db db.DB, routeRegister routing.RouteRegister, cache *localcache.CacheService, - accessControl accesscontrol.AccessControl, features *featuremgmt.FeatureManager) (*Service, error) { + accessControl accesscontrol.AccessControl, features featuremgmt.FeatureToggles) (*Service, error) { service := ProvideOSSService(cfg, database.ProvideService(db), cache, features) api.NewAccessControlAPI(routeRegister, accessControl, service, features).RegisterAPIEndpoints() @@ -49,51 +53,39 @@ func ProvideService(cfg *setting.Cfg, db db.DB, routeRegister routing.RouteRegis return nil, err } - if features.IsEnabledGlobally(featuremgmt.FlagSplitScopes) { - // Migrating scopes that haven't been split yet to have kind, attribute and identifier in the DB - // This will be removed once we've: - // 1) removed the feature toggle and - // 2) have released enough versions not to support a version without split scopes - if err := migrator.MigrateScopeSplit(db, service.log); err != nil { - return nil, err - } + // Migrating scopes that haven't been split yet to have kind, attribute and identifier in the DB + // This will be removed once we've: + // 1) removed the feature toggle and + // 2) have released enough versions not to support a version without split scopes + if err := migrator.MigrateScopeSplit(db, service.log); err != nil { + return nil, err } return service, nil } -func ProvideOSSService(cfg *setting.Cfg, store store, cache *localcache.CacheService, features *featuremgmt.FeatureManager) *Service { +func ProvideOSSService(cfg *setting.Cfg, store accesscontrol.Store, cache *localcache.CacheService, features featuremgmt.FeatureToggles) *Service { s := &Service{ + cache: cache, cfg: cfg, - store: store, + features: features, log: log.New("accesscontrol.service"), - cache: cache, roles: accesscontrol.BuildBasicRoleDefinitions(), - features: features, + store: store, } return s } -//go:generate mockery --name store --structname MockStore --outpkg actest --filename store_mock.go --output ../actest/ -type store interface { - GetUserPermissions(ctx context.Context, query accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error) - SearchUsersPermissions(ctx context.Context, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) - GetUsersBasicRoles(ctx context.Context, userFilter []int64, orgID int64) (map[int64][]string, error) - DeleteUserPermissions(ctx context.Context, orgID, userID int64) error - SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error - DeleteExternalServiceRole(ctx context.Context, externalServiceID string) error -} - // Service is the service implementing role based access control. type Service struct { - log log.Logger - cfg *setting.Cfg - store store cache *localcache.CacheService + cfg *setting.Cfg + features featuremgmt.FeatureToggles + log log.Logger registrations accesscontrol.RegistrationList roles map[string]*accesscontrol.RoleDTO - features *featuremgmt.FeatureManager + store accesscontrol.Store } func (s *Service) GetUsageStats(_ context.Context) map[string]any { @@ -136,7 +128,7 @@ func (s *Service) getUserPermissions(ctx context.Context, user identity.Requeste UserID: userID, Roles: accesscontrol.GetOrgRoles(user), TeamIDs: user.GetTeams(), - RolePrefixes: []string{accesscontrol.ManagedRolePrefix, accesscontrol.ExternalServiceRolePrefix}, + RolePrefixes: OSSRolesPrefixes, }) if err != nil { return nil, err @@ -150,11 +142,13 @@ func (s *Service) getCachedUserPermissions(ctx context.Context, user identity.Re if !options.ReloadCache { permissions, ok := s.cache.Get(key) if ok { + metrics.MAccessPermissionsCacheUsage.WithLabelValues(accesscontrol.CacheHit).Inc() s.log.Debug("Using cached permissions", "key", key) return permissions.([]accesscontrol.Permission), nil } } + metrics.MAccessPermissionsCacheUsage.WithLabelValues(accesscontrol.CacheMiss).Inc() s.log.Debug("Fetch permissions from store", "key", key) permissions, err := s.getUserPermissions(ctx, user, options) if err != nil { @@ -167,6 +161,48 @@ func (s *Service) getCachedUserPermissions(ctx context.Context, user identity.Re return permissions, nil } +func (s *Service) GetUserPermissionsInOrg(ctx context.Context, user identity.Requester, orgID int64) ([]accesscontrol.Permission, error) { + permissions := make([]accesscontrol.Permission, 0) + + if s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) { + permissions = append(permissions, SharedWithMeFolderPermission) + } + + namespace, id := user.GetNamespacedID() + userID, err := identity.UserIdentifier(namespace, id) + if err != nil { + return nil, err + } + + // Get permissions for user's basic roles from RAM + roleList, err := s.store.GetUsersBasicRoles(ctx, []int64{userID}, orgID) + if err != nil { + return nil, fmt.Errorf("could not fetch basic roles for the user: %w", err) + } + var roles []string + var ok bool + if roles, ok = roleList[userID]; !ok { + return nil, fmt.Errorf("found no basic roles for user %d in organisation %d", userID, orgID) + } + for _, builtin := range roles { + if basicRole, ok := s.roles[builtin]; ok { + permissions = append(permissions, basicRole.Permissions...) + } + } + + dbPermissions, err := s.store.SearchUsersPermissions(ctx, orgID, accesscontrol.SearchOptions{ + NamespacedID: authn.NamespacedID(namespace, userID), + // Query only basic, managed and plugin roles in OSS + RolePrefixes: OSSRolesPrefixes, + }) + if err != nil { + return nil, err + } + + userPermissions := dbPermissions[userID] + return append(permissions, userPermissions...), nil +} + func (s *Service) ClearUserPermissionCache(user identity.Requester) { s.cache.Delete(permissionCacheKey(user)) } @@ -175,6 +211,10 @@ func (s *Service) DeleteUserPermissions(ctx context.Context, orgID int64, userID return s.store.DeleteUserPermissions(ctx, orgID, userID) } +func (s *Service) DeleteTeamPermissions(ctx context.Context, orgID int64, teamID int64) error { + return s.store.DeleteTeamPermissions(ctx, orgID, teamID) +} + // DeclareFixedRoles allow the caller to declare, to the service, fixed roles and their assignments // to organization roles ("Viewer", "Editor", "Admin") or "Grafana Admin" func (s *Service) DeclareFixedRoles(registrations ...accesscontrol.RoleRegistration) error { @@ -240,32 +280,53 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs } // SearchUsersPermissions returns all users' permissions filtered by action prefixes -func (s *Service) SearchUsersPermissions(ctx context.Context, user identity.Requester, +func (s *Service) SearchUsersPermissions(ctx context.Context, usr identity.Requester, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { + // Limit roles to available in OSS + options.RolePrefixes = OSSRolesPrefixes + if options.NamespacedID != "" { + userID, err := options.ComputeUserID() + if err != nil { + s.log.Error("Failed to resolve user ID", "error", err) + return nil, err + } + + // Reroute to the user specific implementation of search permissions + // because it leverages the user permission cache. + userPerms, err := s.SearchUserPermissions(ctx, usr.GetOrgID(), options) + if err != nil { + return nil, err + } + return map[int64][]accesscontrol.Permission{userID: userPerms}, nil + } + + timer := prometheus.NewTimer(metrics.MAccessSearchPermissionsSummary) + defer timer.ObserveDuration() + // Filter ram permissions basicPermissions := map[string][]accesscontrol.Permission{} for role, basicRole := range s.roles { for i := range basicRole.Permissions { - if PermissionMatchesSearchOptions(basicRole.Permissions[i], options) { + if PermissionMatchesSearchOptions(basicRole.Permissions[i], &options) { basicPermissions[role] = append(basicPermissions[role], basicRole.Permissions[i]) } } } - usersRoles, err := s.store.GetUsersBasicRoles(ctx, nil, user.GetOrgID()) + usersRoles, err := s.store.GetUsersBasicRoles(ctx, nil, usr.GetOrgID()) if err != nil { return nil, err } // Get managed permissions (DB) - usersPermissions, err := s.store.SearchUsersPermissions(ctx, user.GetOrgID(), options) + usersPermissions, err := s.store.SearchUsersPermissions(ctx, usr.GetOrgID(), options) if err != nil { return nil, err } // helper to filter out permissions the signed in users cannot see canView := func() func(userID int64) bool { - siuPermissions := user.GetPermissions() + siuPermissions := usr.GetPermissions() if len(siuPermissions) == 0 { return func(_ int64) bool { return false } } @@ -323,8 +384,8 @@ func (s *Service) SearchUserPermissions(ctx context.Context, orgID int64, search timer := prometheus.NewTimer(metrics.MAccessPermissionsSummary) defer timer.ObserveDuration() - if searchOptions.UserID == 0 { - return nil, fmt.Errorf("expected user ID to be specified") + if searchOptions.NamespacedID == "" { + return nil, fmt.Errorf("expected namespaced ID to be specified") } if permissions, success := s.searchUserPermissionsFromCache(orgID, searchOptions); success { @@ -334,21 +395,26 @@ func (s *Service) SearchUserPermissions(ctx context.Context, orgID int64, search } func (s *Service) searchUserPermissions(ctx context.Context, orgID int64, searchOptions accesscontrol.SearchOptions) ([]accesscontrol.Permission, error) { + userID, err := searchOptions.ComputeUserID() + if err != nil { + return nil, err + } + // Get permissions for user's basic roles from RAM - roleList, err := s.store.GetUsersBasicRoles(ctx, []int64{searchOptions.UserID}, orgID) + roleList, err := s.store.GetUsersBasicRoles(ctx, []int64{userID}, orgID) if err != nil { return nil, fmt.Errorf("could not fetch basic roles for the user: %w", err) } var roles []string var ok bool - if roles, ok = roleList[searchOptions.UserID]; !ok { - return nil, fmt.Errorf("found no basic roles for user %d in organisation %d", searchOptions.UserID, orgID) + if roles, ok = roleList[userID]; !ok { + return nil, fmt.Errorf("found no basic roles for user %d in organisation %d", userID, orgID) } permissions := make([]accesscontrol.Permission, 0) for _, builtin := range roles { if basicRole, ok := s.roles[builtin]; ok { for _, permission := range basicRole.Permissions { - if PermissionMatchesSearchOptions(permission, searchOptions) { + if PermissionMatchesSearchOptions(permission, &searchOptions) { permissions = append(permissions, permission) } } @@ -360,28 +426,36 @@ func (s *Service) searchUserPermissions(ctx context.Context, orgID int64, search if err != nil { return nil, err } - permissions = append(permissions, dbPermissions[searchOptions.UserID]...) + permissions = append(permissions, dbPermissions[userID]...) return permissions, nil } func (s *Service) searchUserPermissionsFromCache(orgID int64, searchOptions accesscontrol.SearchOptions) ([]accesscontrol.Permission, bool) { + userID, err := searchOptions.ComputeUserID() + if err != nil { + return nil, false + } + // Create a temp signed in user object to retrieve cache key tempUser := &user.SignedInUser{ - UserID: searchOptions.UserID, + UserID: userID, OrgID: orgID, } key := permissionCacheKey(tempUser) permissions, ok := s.cache.Get((key)) if !ok { + metrics.MAccessSearchUserPermissionsCacheUsage.WithLabelValues(accesscontrol.CacheMiss).Inc() return nil, false } + metrics.MAccessSearchUserPermissionsCacheUsage.WithLabelValues(accesscontrol.CacheHit).Inc() + s.log.Debug("Using cached permissions", "key", key) filteredPermissions := make([]accesscontrol.Permission, 0) for _, permission := range permissions.([]accesscontrol.Permission) { - if PermissionMatchesSearchOptions(permission, searchOptions) { + if PermissionMatchesSearchOptions(permission, &searchOptions) { filteredPermissions = append(filteredPermissions, permission) } } @@ -389,9 +463,13 @@ func (s *Service) searchUserPermissionsFromCache(orgID int64, searchOptions acce return filteredPermissions, true } -func PermissionMatchesSearchOptions(permission accesscontrol.Permission, searchOptions accesscontrol.SearchOptions) bool { - if searchOptions.Scope != "" && permission.Scope != searchOptions.Scope { - return false +func PermissionMatchesSearchOptions(permission accesscontrol.Permission, searchOptions *accesscontrol.SearchOptions) bool { + if searchOptions.Scope != "" { + // Permissions including the scope should also match + scopes := append(searchOptions.Wildcards(), searchOptions.Scope) + if !slices.Contains[[]string, string](scopes, permission.Scope) { + return false + } } if searchOptions.Action != "" { return permission.Action == searchOptions.Action @@ -400,7 +478,7 @@ func PermissionMatchesSearchOptions(permission accesscontrol.Permission, searchO } func (s *Service) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error { - if !(s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) || s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts)) { + if !s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) { s.log.Debug("Registering an external service role is behind a feature flag, enable it to use this feature.") return nil } @@ -413,7 +491,7 @@ func (s *Service) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol } func (s *Service) DeleteExternalServiceRole(ctx context.Context, externalServiceID string) error { - if !(s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) || s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts)) { + if !s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) { s.log.Debug("Deleting an external service role is behind a feature flag, enable it to use this feature.") return nil } @@ -422,3 +500,7 @@ func (s *Service) DeleteExternalServiceRole(ctx context.Context, externalService return s.store.DeleteExternalServiceRole(ctx, slug) } + +func (*Service) SyncUserRoles(ctx context.Context, orgID int64, cmd accesscontrol.SyncUserRolesCommand) error { + return nil +} diff --git a/pkg/services/accesscontrol/acimpl/service_test.go b/pkg/services/accesscontrol/acimpl/service_test.go index eed3f3bcbc809..88ea293cf7268 100644 --- a/pkg/services/accesscontrol/acimpl/service_test.go +++ b/pkg/services/accesscontrol/acimpl/service_test.go @@ -15,24 +15,31 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/accesscontrol/database" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func setupTestEnv(t testing.TB) *Service { t.Helper() cfg := setting.NewCfg() ac := &Service{ + cache: localcache.ProvideService(), cfg: cfg, + features: featuremgmt.WithFeatures(), log: log.New("accesscontrol"), registrations: accesscontrol.RegistrationList{}, - store: database.ProvideService(db.InitTestDB(t)), roles: accesscontrol.BuildBasicRoleDefinitions(), - features: featuremgmt.WithFeatures(), + store: database.ProvideService(db.InitTestDB(t)), } require.NoError(t, ac.RegisterFixedRoles(context.Background())) return ac @@ -448,6 +455,23 @@ func TestService_SearchUsersPermissions(t *testing.T) { {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, }, }, + { + name: "ram only search on scope", + siuPermissions: listAllPerms, + searchOption: accesscontrol.SearchOptions{Scope: "teams:id:2"}, + ramRoles: map[string]*accesscontrol.RoleDTO{ + string(roletype.RoleAdmin): {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + }}, + }, + storedRoles: map[int64][]string{ + 1: {string(roletype.RoleEditor)}, + 2: {string(roletype.RoleAdmin), accesscontrol.RoleGrafanaAdmin}, + }, + want: map[int64][]accesscontrol.Permission{ + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}}, + }, + }, { name: "view permission on subset of users only", siuPermissions: listSomePerms, @@ -508,6 +532,36 @@ func TestService_SearchUsersPermissions(t *testing.T) { }, }, }, + { + // This test is not exactly representative as normally the store would return + // only the user's basic roles and the user's stored permissions + name: "check namespacedId filter works correctly", + siuPermissions: listAllPerms, + searchOption: accesscontrol.SearchOptions{NamespacedID: identity.NamespaceServiceAccount + ":1"}, + ramRoles: map[string]*accesscontrol.RoleDTO{ + string(roletype.RoleEditor): {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + }}, + string(roletype.RoleAdmin): {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsWrite, Scope: "teams:*"}, + }}, + accesscontrol.RoleGrafanaAdmin: {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}, + }}, + }, + storedPerms: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:id:1"}}, + }, + storedRoles: map[int64][]string{ + 1: {string(roletype.RoleEditor)}, + 2: {string(roletype.RoleAdmin), accesscontrol.RoleGrafanaAdmin}, + }, + want: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}, {Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}}, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -553,7 +607,7 @@ func TestService_SearchUserPermissions(t *testing.T) { name: "ram only", searchOption: accesscontrol.SearchOptions{ ActionPrefix: "teams", - UserID: 2, + NamespacedID: identity.NamespaceUser + ":2", }, ramRoles: map[string]*accesscontrol.RoleDTO{ string(roletype.RoleEditor): {Permissions: []accesscontrol.Permission{ @@ -578,7 +632,7 @@ func TestService_SearchUserPermissions(t *testing.T) { name: "stored only", searchOption: accesscontrol.SearchOptions{ ActionPrefix: "teams", - UserID: 2, + NamespacedID: identity.NamespaceUser + ":2", }, storedPerms: map[int64][]accesscontrol.Permission{ 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, @@ -598,7 +652,7 @@ func TestService_SearchUserPermissions(t *testing.T) { name: "ram and stored", searchOption: accesscontrol.SearchOptions{ ActionPrefix: "teams", - UserID: 2, + NamespacedID: identity.NamespaceUser + ":2", }, ramRoles: map[string]*accesscontrol.RoleDTO{ string(roletype.RoleAdmin): {Permissions: []accesscontrol.Permission{ @@ -628,7 +682,7 @@ func TestService_SearchUserPermissions(t *testing.T) { name: "check action prefix filter works correctly", searchOption: accesscontrol.SearchOptions{ ActionPrefix: "teams", - UserID: 1, + NamespacedID: identity.NamespaceUser + ":1", }, ramRoles: map[string]*accesscontrol.RoleDTO{ string(roletype.RoleEditor): {Permissions: []accesscontrol.Permission{ @@ -649,8 +703,8 @@ func TestService_SearchUserPermissions(t *testing.T) { { name: "check action filter works correctly", searchOption: accesscontrol.SearchOptions{ - Action: accesscontrol.ActionTeamsRead, - UserID: 1, + Action: accesscontrol.ActionTeamsRead, + NamespacedID: identity.NamespaceUser + ":1", }, ramRoles: map[string]*accesscontrol.RoleDTO{ string(roletype.RoleEditor): {Permissions: []accesscontrol.Permission{ @@ -815,7 +869,7 @@ func TestService_SaveExternalServiceRole(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() ac := setupTestEnv(t) - ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth, featuremgmt.FlagExternalServiceAccounts) + ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts) for _, r := range tt.runs { err := ac.SaveExternalServiceRole(ctx, r.cmd) if r.wantErr { @@ -861,7 +915,7 @@ func TestService_DeleteExternalServiceRole(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() ac := setupTestEnv(t) - ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth, featuremgmt.FlagExternalServiceAccounts) + ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts) if tt.initCmd != nil { err := ac.SaveExternalServiceRole(ctx, *tt.initCmd) @@ -884,3 +938,59 @@ func TestService_DeleteExternalServiceRole(t *testing.T) { }) } } + +func TestService_GetUserPermissionsInOrg(t *testing.T) { + tests := []struct { + name string + orgID int64 + ramRoles map[string]*accesscontrol.RoleDTO // BasicRole => RBAC BasicRole + storedPerms map[int64][]accesscontrol.Permission // UserID => Permissions + storedRoles map[int64][]string // UserID => Roles + want []accesscontrol.Permission + }{ + { + name: "should get correct permissions from another org", + orgID: 2, + ramRoles: map[string]*accesscontrol.RoleDTO{ + string(roletype.RoleEditor): {Permissions: []accesscontrol.Permission{}}, + string(roletype.RoleAdmin): {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + }}, + }, + storedPerms: map[int64][]accesscontrol.Permission{ + 1: { + {Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:id:1"}, + }, + 2: { + {Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:2"}, + }, + }, + storedRoles: map[int64][]string{ + 1: {string(roletype.RoleAdmin)}, + 2: {string(roletype.RoleEditor)}, + }, + want: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:2"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + ac := setupTestEnv(t) + + ac.roles = tt.ramRoles + ac.store = actest.FakeStore{ + ExpectedUsersPermissions: tt.storedPerms, + ExpectedUsersRoles: tt.storedRoles, + } + user := &user.SignedInUser{OrgID: 1, UserID: 2} + + got, err := ac.GetUserPermissionsInOrg(ctx, user, 2) + require.Nil(t, err) + + assert.ElementsMatch(t, got, tt.want) + }) + } +} diff --git a/pkg/services/accesscontrol/actest/common.go b/pkg/services/accesscontrol/actest/common.go index 6be44a6911968..c5c95462f5187 100644 --- a/pkg/services/accesscontrol/actest/common.go +++ b/pkg/services/accesscontrol/actest/common.go @@ -116,7 +116,7 @@ func AddUserPermissionToDB(t testing.TB, db db.DB, user *user.SignedInUser) { p := accesscontrol.Permission{ RoleID: role.ID, Action: action, Scope: scope, Created: time.Now(), Updated: time.Now(), } - //p.Kind, p.Attribute, p.Identifier = p.SplitScope() + p.Kind, p.Attribute, p.Identifier = p.SplitScope() permissions = append(permissions, p) } diff --git a/pkg/services/accesscontrol/actest/fake.go b/pkg/services/accesscontrol/actest/fake.go index 87113fb6e3fe0..21698bd410a68 100644 --- a/pkg/services/accesscontrol/actest/fake.go +++ b/pkg/services/accesscontrol/actest/fake.go @@ -11,6 +11,7 @@ var _ accesscontrol.Service = new(FakeService) var _ accesscontrol.RoleRegistry = new(FakeService) type FakeService struct { + accesscontrol.Service ExpectedErr error ExpectedCachedPermissions bool ExpectedPermissions []accesscontrol.Permission @@ -26,6 +27,10 @@ func (f FakeService) GetUserPermissions(ctx context.Context, user identity.Reque return f.ExpectedPermissions, f.ExpectedErr } +func (f FakeService) GetUserPermissionsInOrg(ctx context.Context, user identity.Requester, orgID int64) ([]accesscontrol.Permission, error) { + return f.ExpectedPermissions, f.ExpectedErr +} + func (f FakeService) SearchUsersPermissions(ctx context.Context, user identity.Requester, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { return f.ExpectedUsersPermissions, f.ExpectedErr } @@ -40,6 +45,10 @@ func (f FakeService) DeleteUserPermissions(ctx context.Context, orgID, userID in return f.ExpectedErr } +func (f FakeService) DeleteTeamPermissions(ctx context.Context, orgID, teamID int64) error { + return f.ExpectedErr +} + func (f FakeService) DeclareFixedRoles(registrations ...accesscontrol.RoleRegistration) error { return f.ExpectedErr } @@ -93,6 +102,10 @@ func (f FakeStore) DeleteUserPermissions(ctx context.Context, orgID, userID int6 return f.ExpectedErr } +func (f FakeStore) DeleteTeamPermissions(ctx context.Context, orgID, teamID int64) error { + return f.ExpectedErr +} + func (f FakeStore) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error { return f.ExpectedErr } diff --git a/pkg/services/accesscontrol/actest/store_mock.go b/pkg/services/accesscontrol/actest/store_mock.go index 6f38eea198a1d..70509a8842bf8 100644 --- a/pkg/services/accesscontrol/actest/store_mock.go +++ b/pkg/services/accesscontrol/actest/store_mock.go @@ -1,16 +1,16 @@ -// Code generated by mockery v2.20.0. DO NOT EDIT. +// Code generated by mockery v2.42.0. DO NOT EDIT. package actest import ( - accesscontrol "github.com/grafana/grafana/pkg/services/accesscontrol" - context "context" + accesscontrol "github.com/grafana/grafana/pkg/services/accesscontrol" + mock "github.com/stretchr/testify/mock" ) -// MockStore is an autogenerated mock type for the store type +// MockStore is an autogenerated mock type for the Store type type MockStore struct { mock.Mock } @@ -19,6 +19,10 @@ type MockStore struct { func (_m *MockStore) DeleteExternalServiceRole(ctx context.Context, externalServiceID string) error { ret := _m.Called(ctx, externalServiceID) + if len(ret) == 0 { + panic("no return value specified for DeleteExternalServiceRole") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { r0 = rf(ctx, externalServiceID) @@ -33,6 +37,10 @@ func (_m *MockStore) DeleteExternalServiceRole(ctx context.Context, externalServ func (_m *MockStore) DeleteUserPermissions(ctx context.Context, orgID int64, userID int64) error { ret := _m.Called(ctx, orgID, userID) + if len(ret) == 0 { + panic("no return value specified for DeleteUserPermissions") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, int64, int64) error); ok { r0 = rf(ctx, orgID, userID) @@ -43,10 +51,32 @@ func (_m *MockStore) DeleteUserPermissions(ctx context.Context, orgID int64, use return r0 } +// DeleteTeamPermissions provides a mock function with given fields: ctx, orgID, teamID +func (_m *MockStore) DeleteTeamPermissions(ctx context.Context, orgID int64, teamID int64) error { + ret := _m.Called(ctx, orgID, teamID) + + if len(ret) == 0 { + panic("no return value specified for DeleteTeamPermissions") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64, int64) error); ok { + r0 = rf(ctx, orgID, teamID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // GetUserPermissions provides a mock function with given fields: ctx, query func (_m *MockStore) GetUserPermissions(ctx context.Context, query accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error) { ret := _m.Called(ctx, query) + if len(ret) == 0 { + panic("no return value specified for GetUserPermissions") + } + var r0 []accesscontrol.Permission var r1 error if rf, ok := ret.Get(0).(func(context.Context, accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error)); ok { @@ -73,6 +103,10 @@ func (_m *MockStore) GetUserPermissions(ctx context.Context, query accesscontrol func (_m *MockStore) GetUsersBasicRoles(ctx context.Context, userFilter []int64, orgID int64) (map[int64][]string, error) { ret := _m.Called(ctx, userFilter, orgID) + if len(ret) == 0 { + panic("no return value specified for GetUsersBasicRoles") + } + var r0 map[int64][]string var r1 error if rf, ok := ret.Get(0).(func(context.Context, []int64, int64) (map[int64][]string, error)); ok { @@ -99,6 +133,10 @@ func (_m *MockStore) GetUsersBasicRoles(ctx context.Context, userFilter []int64, func (_m *MockStore) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error { ret := _m.Called(ctx, cmd) + if len(ret) == 0 { + panic("no return value specified for SaveExternalServiceRole") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, accesscontrol.SaveExternalServiceRoleCommand) error); ok { r0 = rf(ctx, cmd) @@ -113,6 +151,10 @@ func (_m *MockStore) SaveExternalServiceRole(ctx context.Context, cmd accesscont func (_m *MockStore) SearchUsersPermissions(ctx context.Context, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { ret := _m.Called(ctx, orgID, options) + if len(ret) == 0 { + panic("no return value specified for SearchUsersPermissions") + } + var r0 map[int64][]accesscontrol.Permission var r1 error if rf, ok := ret.Get(0).(func(context.Context, int64, accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error)); ok { @@ -135,13 +177,12 @@ func (_m *MockStore) SearchUsersPermissions(ctx context.Context, orgID int64, op return r0, r1 } -type mockConstructorTestingTNewMockStore interface { +// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockStore(t interface { mock.TestingT Cleanup(func()) -} - -// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewMockStore(t mockConstructorTestingTNewMockStore) *MockStore { +}) *MockStore { mock := &MockStore{} mock.Mock.Test(t) diff --git a/pkg/services/accesscontrol/api/api.go b/pkg/services/accesscontrol/api/api.go index 241d367c88a90..67e7951267b22 100644 --- a/pkg/services/accesscontrol/api/api.go +++ b/pkg/services/accesscontrol/api/api.go @@ -2,7 +2,6 @@ package api import ( "net/http" - "strconv" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" @@ -11,11 +10,10 @@ import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/web" ) func NewAccessControlAPI(router routing.RouteRegister, accesscontrol ac.AccessControl, service ac.Service, - features *featuremgmt.FeatureManager) *AccessControlAPI { + features featuremgmt.FeatureToggles) *AccessControlAPI { return &AccessControlAPI{ RouteRegister: router, Service: service, @@ -28,7 +26,7 @@ type AccessControlAPI struct { Service ac.Service AccessControl ac.AccessControl RouteRegister routing.RouteRegister - features *featuremgmt.FeatureManager + features featuremgmt.FeatureToggles } func (api *AccessControlAPI) RegisterAPIEndpoints() { @@ -38,9 +36,7 @@ func (api *AccessControlAPI) RegisterAPIEndpoints() { rr.Get("/user/actions", middleware.ReqSignedIn, routing.Wrap(api.getUserActions)) rr.Get("/user/permissions", middleware.ReqSignedIn, routing.Wrap(api.getUserPermissions)) if api.features.IsEnabledGlobally(featuremgmt.FlagAccessControlOnCall) { - userIDScope := ac.Scope("users", "id", ac.Parameter(":userID")) rr.Get("/users/permissions/search", authorize(ac.EvalPermission(ac.ActionUsersPermissionsRead)), routing.Wrap(api.searchUsersPermissions)) - rr.Get("/user/:userID/permissions/search", authorize(ac.EvalPermission(ac.ActionUsersPermissionsRead, userIDScope)), routing.Wrap(api.searchUserPermissions)) } }, requestmeta.SetOwner(requestmeta.TeamAuth)) } @@ -51,7 +47,7 @@ func (api *AccessControlAPI) getUserActions(c *contextmodel.ReqContext) response permissions, err := api.Service.GetUserPermissions(c.Req.Context(), c.SignedInUser, ac.Options{ReloadCache: reloadCache}) if err != nil { - response.JSON(http.StatusInternalServerError, err) + return response.JSON(http.StatusInternalServerError, err) } return response.JSON(http.StatusOK, ac.BuildPermissionsMap(permissions)) @@ -63,23 +59,27 @@ func (api *AccessControlAPI) getUserPermissions(c *contextmodel.ReqContext) resp permissions, err := api.Service.GetUserPermissions(c.Req.Context(), c.SignedInUser, ac.Options{ReloadCache: reloadCache}) if err != nil { - response.JSON(http.StatusInternalServerError, err) + return response.JSON(http.StatusInternalServerError, err) } return response.JSON(http.StatusOK, ac.GroupScopesByAction(permissions)) } -// GET /api/access-control/users/permissions +// GET /api/access-control/users/permissions/search func (api *AccessControlAPI) searchUsersPermissions(c *contextmodel.ReqContext) response.Response { searchOptions := ac.SearchOptions{ ActionPrefix: c.Query("actionPrefix"), Action: c.Query("action"), Scope: c.Query("scope"), + NamespacedID: c.Query("namespacedId"), } // Validate inputs - if (searchOptions.ActionPrefix != "") == (searchOptions.Action != "") { - return response.JSON(http.StatusBadRequest, "provide one of 'action' or 'actionPrefix'") + if searchOptions.ActionPrefix != "" && searchOptions.Action != "" { + return response.JSON(http.StatusBadRequest, "'action' and 'actionPrefix' are mutually exclusive") + } + if searchOptions.NamespacedID == "" && searchOptions.ActionPrefix == "" && searchOptions.Action == "" { + return response.JSON(http.StatusBadRequest, "at least one search option must be provided") } // Compute metadata @@ -95,31 +95,3 @@ func (api *AccessControlAPI) searchUsersPermissions(c *contextmodel.ReqContext) return response.JSON(http.StatusOK, permsByAction) } - -// GET /api/access-control/user/:userID/permissions/search -func (api *AccessControlAPI) searchUserPermissions(c *contextmodel.ReqContext) response.Response { - userIDString := web.Params(c.Req)[":userID"] - userID, err := strconv.ParseInt(userIDString, 10, 64) - if err != nil { - response.Error(http.StatusBadRequest, "user ID is invalid", err) - } - - searchOptions := ac.SearchOptions{ - ActionPrefix: c.Query("actionPrefix"), - Action: c.Query("action"), - Scope: c.Query("scope"), - UserID: userID, - } - // Validate inputs - if (searchOptions.ActionPrefix != "") == (searchOptions.Action != "") { - return response.JSON(http.StatusBadRequest, "provide one of 'action' or 'actionPrefix'") - } - - permissions, err := api.Service.SearchUserPermissions(c.Req.Context(), - c.SignedInUser.GetOrgID(), searchOptions) - if err != nil { - response.Error(http.StatusInternalServerError, "could not search user permissions", err) - } - - return response.JSON(http.StatusOK, ac.Reduce(permissions)) -} diff --git a/pkg/services/accesscontrol/api/api_test.go b/pkg/services/accesscontrol/api/api_test.go index 602602bab0204..0502504a373b0 100644 --- a/pkg/services/accesscontrol/api/api_test.go +++ b/pkg/services/accesscontrol/api/api_test.go @@ -118,3 +118,80 @@ func TestAPI_getUserPermissions(t *testing.T) { }) } } + +func TestAccessControlAPI_searchUsersPermissions(t *testing.T) { + type testCase struct { + desc string + permissions map[int64][]ac.Permission + filters string + expectedOutput map[int64]map[string][]string + expectedCode int + } + + tests := []testCase{ + { + desc: "Should reject if no filter is provided", + expectedCode: http.StatusBadRequest, + }, + { + desc: "Should reject if conflicting action filters are provided", + filters: "?actionPrefix=grafana-test-app&action=grafana-test-app.projects:read", + expectedCode: http.StatusBadRequest, + }, + { + desc: "Should work with valid namespacedId filter provided", + filters: "?namespacedId=service-account:2", + permissions: map[int64][]ac.Permission{2: {{Action: "users:read", Scope: "users:*"}}}, + expectedCode: http.StatusOK, + expectedOutput: map[int64]map[string][]string{2: {"users:read": {"users:*"}}}, + }, + { + desc: "Should reduce permissions", + filters: "?namespacedId=service-account:2", + permissions: map[int64][]ac.Permission{2: {{Action: "users:read", Scope: "users:id:1"}, {Action: "users:read", Scope: "users:*"}}}, + expectedCode: http.StatusOK, + expectedOutput: map[int64]map[string][]string{2: {"users:read": {"users:*"}}}, + }, + { + desc: "Should work with valid action prefix filter", + filters: "?actionPrefix=users:", + permissions: map[int64][]ac.Permission{ + 1: {{Action: "users:write", Scope: "users:id:1"}}, + 2: {{Action: "users:read", Scope: "users:id:2"}}, + }, + expectedCode: http.StatusOK, + expectedOutput: map[int64]map[string][]string{ + 1: {"users:write": {"users:id:1"}}, + 2: {"users:read": {"users:id:2"}}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + acSvc := actest.FakeService{ExpectedUsersPermissions: tt.permissions} + accessControl := actest.FakeAccessControl{ExpectedEvaluate: true} // Always allow access to the endpoint + api := NewAccessControlAPI(routing.NewRouteRegister(), accessControl, acSvc, featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall)) + api.RegisterAPIEndpoints() + + server := webtest.NewServer(t, api.RouteRegister) + url := "/api/access-control/users/permissions/search" + tt.filters + + req := server.NewGetRequest(url) + webtest.RequestWithSignedInUser(req, &user.SignedInUser{ + OrgID: 1, + Permissions: map[int64]map[string][]string{}, + }) + res, err := server.Send(req) + defer func() { require.NoError(t, res.Body.Close()) }() + require.NoError(t, err) + require.Equal(t, tt.expectedCode, res.StatusCode) + + if tt.expectedCode == http.StatusOK { + var output map[int64]map[string][]string + err := json.NewDecoder(res.Body).Decode(&output) + require.NoError(t, err) + require.Equal(t, tt.expectedOutput, output) + } + }) + } +} diff --git a/pkg/services/accesscontrol/authorize_in_org_test.go b/pkg/services/accesscontrol/authorize_in_org_test.go index 812f56634fa42..8a9f6d2b814c5 100644 --- a/pkg/services/accesscontrol/authorize_in_org_test.go +++ b/pkg/services/accesscontrol/authorize_in_org_test.go @@ -202,6 +202,24 @@ func TestAuthorizeInOrgMiddleware(t *testing.T) { teamService: &teamtest.FakeService{}, expectedStatus: http.StatusForbidden, }, + { + name: "should fetch global user permissions when user is not a member of the target org", + orgIDGetter: func(c *contextmodel.ReqContext) (int64, error) { + return 2, nil + }, + evaluator: accesscontrol.EvalPermission("users:read", "users:*"), + accessControl: ac, + acService: &actest.FakeService{ + ExpectedPermissions: []accesscontrol.Permission{{Action: "users:read", Scope: "users:*"}}, + }, + userCache: &usertest.FakeUserService{ + GetSignedInUserFn: func(ctx context.Context, query *user.GetSignedInUserQuery) (*user.SignedInUser, error) { + return &user.SignedInUser{UserID: 1, OrgID: -1, Permissions: map[int64]map[string][]string{}}, nil + }, + }, + ctxSignedInUser: &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{1: {"users:write": {"users:*"}}}}, + expectedStatus: http.StatusOK, + }, } // Run test cases diff --git a/pkg/services/accesscontrol/database/database.go b/pkg/services/accesscontrol/database/database.go index 0f6ca5c0bfcd2..fa91e954908ee 100644 --- a/pkg/services/accesscontrol/database/database.go +++ b/pkg/services/accesscontrol/database/database.go @@ -36,8 +36,8 @@ func (s *AccessControlStore) GetUserPermissions(ctx context.Context, query acces ` + filter if len(query.RolePrefixes) > 0 { - q += " WHERE ( " + strings.Repeat("role.name LIKE ? OR ", len(query.RolePrefixes)) - q = q[:len(q)-4] + " )" // remove last " OR " + q += " WHERE ( " + strings.Repeat("role.name LIKE ? OR ", len(query.RolePrefixes)-1) + q += "role.name LIKE ? )" for i := range query.RolePrefixes { params = append(params, query.RolePrefixes[i]+"%") } @@ -53,7 +53,7 @@ func (s *AccessControlStore) GetUserPermissions(ctx context.Context, query acces return result, err } -// SearchUsersPermissions returns the list of user permissions indexed by UserID +// SearchUsersPermissions returns the list of user permissions in specific organization indexed by UserID func (s *AccessControlStore) SearchUsersPermissions(ctx context.Context, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { type UserRBACPermission struct { UserID int64 `xorm:"user_id"` @@ -61,7 +61,13 @@ func (s *AccessControlStore) SearchUsersPermissions(ctx context.Context, orgID i Scope string `xorm:"scope"` } dbPerms := make([]UserRBACPermission, 0) + if err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + roleNameFilterJoin := "" + if len(options.RolePrefixes) > 0 { + roleNameFilterJoin = "INNER JOIN role AS r on up.role_id = r.id" + } + // Find permissions q := ` SELECT @@ -69,21 +75,21 @@ func (s *AccessControlStore) SearchUsersPermissions(ctx context.Context, orgID i action, scope FROM ( - SELECT ur.user_id, ur.org_id, p.action, p.scope + SELECT ur.user_id, ur.org_id, p.action, p.scope, ur.role_id FROM permission AS p INNER JOIN user_role AS ur on ur.role_id = p.role_id UNION ALL - SELECT tm.user_id, tr.org_id, p.action, p.scope + SELECT tm.user_id, tr.org_id, p.action, p.scope, tr.role_id FROM permission AS p INNER JOIN team_role AS tr ON tr.role_id = p.role_id INNER JOIN team_member AS tm ON tm.team_id = tr.team_id UNION ALL - SELECT ou.user_id, ou.org_id, p.action, p.scope + SELECT ou.user_id, ou.org_id, p.action, p.scope, br.role_id FROM permission AS p INNER JOIN builtin_role AS br ON br.role_id = p.role_id INNER JOIN org_user AS ou ON ou.role = br.role UNION ALL - SELECT sa.user_id, br.org_id, p.action, p.scope + SELECT sa.user_id, br.org_id, p.action, p.scope, br.role_id FROM permission AS p INNER JOIN builtin_role AS br ON br.role_id = p.role_id INNER JOIN ( @@ -91,8 +97,8 @@ func (s *AccessControlStore) SearchUsersPermissions(ctx context.Context, orgID i FROM ` + s.sql.GetDialect().Quote("user") + ` AS u WHERE u.is_admin ) AS sa ON 1 = 1 WHERE br.role = ? - ) AS up - WHERE (org_id = ? OR org_id = ?) + ) AS up ` + roleNameFilterJoin + ` + WHERE (up.org_id = ? OR up.org_id = ?) ` params := []any{accesscontrol.RoleGrafanaAdmin, accesscontrol.GlobalOrgID, orgID} @@ -106,17 +112,30 @@ func (s *AccessControlStore) SearchUsersPermissions(ctx context.Context, orgID i params = append(params, options.Action) } if options.Scope != "" { - q += ` AND scope = ?` - params = append(params, options.Scope) + // Search for scope and wildcard that include the scope + scopes := append(options.Wildcards(), options.Scope) + q += ` AND scope IN ( ? ` + strings.Repeat(", ?", len(scopes)-1) + ")" + for i := range scopes { + params = append(params, scopes[i]) + } } - - if options.UserID != 0 { + if options.NamespacedID != "" { + userID, err := options.ComputeUserID() + if err != nil { + return err + } q += ` AND user_id = ?` - params = append(params, options.UserID) + params = append(params, userID) + } + if len(options.RolePrefixes) > 0 { + q += " AND ( " + strings.Repeat("r.name LIKE ? OR ", len(options.RolePrefixes)-1) + q += "r.name LIKE ? )" + for _, prefix := range options.RolePrefixes { + params = append(params, prefix+"%") + } } - return sess.SQL(q, params...). - Find(&dbPerms) + return sess.SQL(q, params...).Find(&dbPerms) }); err != nil { return nil, err } @@ -234,3 +253,58 @@ func (s *AccessControlStore) DeleteUserPermissions(ctx context.Context, orgID, u }) return err } + +func (s *AccessControlStore) DeleteTeamPermissions(ctx context.Context, orgID, teamID int64) error { + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + roleDeleteQuery := "DELETE FROM team_role WHERE team_id = ? AND org_id = ?" + roleDeleteParams := []any{roleDeleteQuery, teamID, orgID} + + // Delete team role assignments + if _, err := sess.Exec(roleDeleteParams...); err != nil { + return err + } + + // Delete permissions that are scoped to the team + if _, err := sess.Exec("DELETE FROM permission WHERE scope = ?", accesscontrol.Scope("teams", "id", strconv.FormatInt(teamID, 10))); err != nil { + return err + } + + // Delete the team managed role + roleQuery := "SELECT id FROM role WHERE name = ? AND org_id = ?" + roleParams := []any{accesscontrol.ManagedTeamRoleName(teamID), orgID} + + var roleIDs []int64 + if err := sess.SQL(roleQuery, roleParams...).Find(&roleIDs); err != nil { + return err + } + + if len(roleIDs) == 0 { + return nil + } + + permissionDeleteQuery := "DELETE FROM permission WHERE role_id IN(? " + strings.Repeat(",?", len(roleIDs)-1) + ")" + permissionDeleteParams := make([]any, 0, len(roleIDs)+1) + permissionDeleteParams = append(permissionDeleteParams, permissionDeleteQuery) + for _, id := range roleIDs { + permissionDeleteParams = append(permissionDeleteParams, id) + } + + // Delete managed team permissions + if _, err := sess.Exec(permissionDeleteParams...); err != nil { + return err + } + + managedRoleDeleteQuery := "DELETE FROM role WHERE id IN(? " + strings.Repeat(",?", len(roleIDs)-1) + ")" + managedRoleDeleteParams := []any{managedRoleDeleteQuery} + for _, id := range roleIDs { + managedRoleDeleteParams = append(managedRoleDeleteParams, id) + } + // Delete managed team role + if _, err := sess.Exec(managedRoleDeleteParams...); err != nil { + return err + } + + return nil + }) + return err +} diff --git a/pkg/services/accesscontrol/database/database_test.go b/pkg/services/accesscontrol/database/database_test.go index e21794867ab67..5cbfae4633d4d 100644 --- a/pkg/services/accesscontrol/database/database_test.go +++ b/pkg/services/accesscontrol/database/database_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/services/accesscontrol" rs "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" @@ -23,8 +24,14 @@ import ( "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +// run tests with cleanup +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type getUserPermissionsTestCase struct { desc string anonymousUser bool @@ -236,6 +243,77 @@ func TestAccessControlStore_DeleteUserPermissions(t *testing.T) { }) } +func TestAccessControlStore_DeleteTeamPermissions(t *testing.T) { + t.Run("expect permissions related to team to be deleted", func(t *testing.T) { + store, permissionsStore, sql, teamSvc, _ := setupTestEnv(t) + user, team := createUserAndTeam(t, sql, teamSvc, 1) + + // grant permission to the team + _, err := permissionsStore.SetTeamResourcePermission(context.Background(), 1, team.ID, rs.SetResourcePermissionCommand{ + Actions: []string{"dashboards:write"}, + Resource: "dashboards", + ResourceAttribute: "uid", + ResourceID: "xxYYzz", + }, nil) + require.NoError(t, err) + + // generate permissions scoped to the team + _, err = permissionsStore.SetUserResourcePermission(context.Background(), 1, accesscontrol.User{ID: user.ID}, rs.SetResourcePermissionCommand{ + Actions: []string{"team:read"}, + Resource: "teams", + ResourceAttribute: "id", + ResourceID: fmt.Sprintf("%d", team.ID), + }, nil) + require.NoError(t, err) + + err = store.DeleteTeamPermissions(context.Background(), 1, team.ID) + require.NoError(t, err) + + permissions, err := store.GetUserPermissions(context.Background(), accesscontrol.GetUserPermissionsQuery{ + OrgID: 1, + UserID: user.ID, + Roles: []string{"Admin"}, + TeamIDs: []int64{team.ID}, + }) + require.NoError(t, err) + assert.Len(t, permissions, 0) + }) + t.Run("expect permissions not related to team to be kept", func(t *testing.T) { + store, permissionsStore, sql, teamSvc, _ := setupTestEnv(t) + user, team := createUserAndTeam(t, sql, teamSvc, 1) + + // grant permission to the team + _, err := permissionsStore.SetTeamResourcePermission(context.Background(), 1, team.ID, rs.SetResourcePermissionCommand{ + Actions: []string{"dashboards:write"}, + Resource: "dashboards", + ResourceAttribute: "uid", + ResourceID: "xxYYzz", + }, nil) + require.NoError(t, err) + + // generate permissions scoped to another team + _, err = permissionsStore.SetUserResourcePermission(context.Background(), 1, accesscontrol.User{ID: user.ID}, rs.SetResourcePermissionCommand{ + Actions: []string{"team:read"}, + Resource: "teams", + ResourceAttribute: "id", + ResourceID: fmt.Sprintf("%d", team.ID+1), + }, nil) + require.NoError(t, err) + + err = store.DeleteTeamPermissions(context.Background(), 1, team.ID) + require.NoError(t, err) + + permissions, err := store.GetUserPermissions(context.Background(), accesscontrol.GetUserPermissionsQuery{ + OrgID: 1, + UserID: user.ID, + Roles: []string{"Admin"}, + TeamIDs: []int64{team.ID}, + }) + require.NoError(t, err) + assert.Len(t, permissions, 1) + }) +} + func createUserAndTeam(t *testing.T, userSrv user.Service, teamSvc team.Service, orgID int64) (*user.User, team.Team) { t.Helper() @@ -248,7 +326,7 @@ func createUserAndTeam(t *testing.T, userSrv user.Service, teamSvc team.Service, team, err := teamSvc.CreateTeam("team", "", orgID) require.NoError(t, err) - err = teamSvc.AddTeamMember(user.ID, orgID, team.ID, false, dashboardaccess.PERMISSION_VIEW) + err = teamSvc.AddTeamMember(context.Background(), user.ID, orgID, team.ID, false, dashboardaccess.PERMISSION_VIEW) require.NoError(t, err) return user, team @@ -296,7 +374,7 @@ func createUsersAndTeams(t *testing.T, svcs helperServices, orgID int64, users [ team, err := svcs.teamSvc.CreateTeam(fmt.Sprintf("team%v", i+1), "", orgID) require.NoError(t, err) - err = svcs.teamSvc.AddTeamMember(user.ID, orgID, team.ID, false, dashboardaccess.PERMISSION_VIEW) + err = svcs.teamSvc.AddTeamMember(context.Background(), user.ID, orgID, team.ID, false, dashboardaccess.PERMISSION_VIEW) require.NoError(t, err) err = svcs.orgSvc.UpdateOrgUser(context.Background(), @@ -316,7 +394,8 @@ func setupTestEnv(t testing.TB) (*AccessControlStore, rs.Store, user.Service, te cfg.AutoAssignOrgId = 1 acstore := ProvideService(sql) permissionStore := rs.NewStore(sql, featuremgmt.WithFeatures()) - teamService := teamimpl.ProvideService(sql, cfg) + teamService, err := teamimpl.ProvideService(sql, cfg) + require.NoError(t, err) orgService, err := orgimpl.ProvideService(sql, cfg, quotatest.New(false, nil)) require.NoError(t, err) @@ -461,7 +540,7 @@ func TestIntegrationAccessControlStore_SearchUsersPermissions(t *testing.T) { }, options: accesscontrol.SearchOptions{ ActionPrefix: "teams:", - UserID: 1, + NamespacedID: identity.NamespaceUser + ":1", }, wantPerm: map[int64][]accesscontrol.Permission{ 1: {{Action: "teams:read", Scope: "teams:id:1"}, {Action: "teams:read", Scope: "teams:id:10"}, @@ -526,6 +605,20 @@ func TestIntegrationAccessControlStore_SearchUsersPermissions(t *testing.T) { {Action: "teams:write", Scope: "teams:id:1"}, }}, }, + { + name: "user assignment by scope", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("*")}, // hack to have a global permission + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: writeTeamPerm("1")}, + }, + options: accesscontrol.SearchOptions{Scope: "teams:id:1"}, + wantPerm: map[int64][]accesscontrol.Permission{1: { + {Action: "teams:read", Scope: "teams:id:*"}, + {Action: "teams:read", Scope: "teams:id:1"}, + {Action: "teams:write", Scope: "teams:id:1"}, + }}, + }, { name: "user assignment by action and scope", users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, @@ -536,6 +629,24 @@ func TestIntegrationAccessControlStore_SearchUsersPermissions(t *testing.T) { options: accesscontrol.SearchOptions{Action: "teams:read", Scope: "teams:id:1"}, wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, }, + { + name: "user assignment by role prefixes", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + }, + options: accesscontrol.SearchOptions{RolePrefixes: []string{accesscontrol.ManagedRolePrefix}}, + wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, + }, + { + name: "filter out permissions by role prefix", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + }, + options: accesscontrol.SearchOptions{RolePrefixes: []string{accesscontrol.BasicRolePrefix}}, + wantPerm: map[int64][]accesscontrol.Permission{}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/services/accesscontrol/errors.go b/pkg/services/accesscontrol/errors.go index 73394a67f51e2..175f501532931 100644 --- a/pkg/services/accesscontrol/errors.go +++ b/pkg/services/accesscontrol/errors.go @@ -21,6 +21,16 @@ func (e *ErrorInvalidRole) Error() string { return "role is invalid" } +type ErrorRoleNameMissing struct{} + +func (e *ErrorRoleNameMissing) Error() string { + return "role has been defined without a name" +} + +func (e *ErrorRoleNameMissing) Unwrap() error { + return &ErrorInvalidRole{} +} + type ErrorRolePrefixMissing struct { Role string Prefixes []string diff --git a/pkg/services/accesscontrol/filter_test.go b/pkg/services/accesscontrol/filter_test.go index a68d2a6b4f7a5..480bee31345ee 100644 --- a/pkg/services/accesscontrol/filter_test.go +++ b/pkg/services/accesscontrol/filter_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/datasources" dsService "github.com/grafana/grafana/pkg/services/datasources/service" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" ) type filterDatasourcesTestCase struct { @@ -27,6 +28,10 @@ type filterDatasourcesTestCase struct { expectErr bool } +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestFilter_Datasources(t *testing.T) { tests := []filterDatasourcesTestCase{ { diff --git a/pkg/services/accesscontrol/middleware.go b/pkg/services/accesscontrol/middleware.go index 6c76060542d4a..ed79b13c604a5 100644 --- a/pkg/services/accesscontrol/middleware.go +++ b/pkg/services/accesscontrol/middleware.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io" "net/http" "net/url" "regexp" @@ -37,7 +38,7 @@ func Middleware(ac AccessControl) func(Evaluator) web.Handler { } if !c.IsSignedIn && forceLogin { - unauthorized(c, nil) + unauthorized(c) return } } @@ -49,7 +50,7 @@ func Middleware(ac AccessControl) func(Evaluator) web.Handler { return } - unauthorized(c, c.LookupTokenErr) + unauthorized(c) return } @@ -113,7 +114,7 @@ func deny(c *contextmodel.ReqContext, evaluator Evaluator, err error) { }) } -func unauthorized(c *contextmodel.ReqContext, err error) { +func unauthorized(c *contextmodel.ReqContext) { if c.IsApiRequest() { c.WriteErrOrFallback(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized), c.LookupTokenErr) return @@ -260,23 +261,32 @@ func makeTmpUser(ctx context.Context, service Service, cache userCache, tmpUser.OrgName = queryResult.OrgName tmpUser.OrgRole = queryResult.OrgRole - if teamService != nil { - teamIDs, err := teamService.GetTeamIDsByUser(ctx, &team.GetTeamIDsByUserQuery{OrgID: targetOrgID, UserID: tmpUser.UserID}) - if err != nil { - return nil, err + // Only fetch the team membership is the user is a member of the organization + if queryResult.OrgID == targetOrgID { + if teamService != nil { + teamIDs, err := teamService.GetTeamIDsByUser(ctx, &team.GetTeamIDsByUserQuery{OrgID: targetOrgID, UserID: tmpUser.UserID}) + if err != nil { + return nil, err + } + tmpUser.Teams = teamIDs } - tmpUser.Teams = teamIDs } } } - if tmpUser.Permissions[targetOrgID] == nil || len(tmpUser.Permissions[targetOrgID]) == 0 { + // If the user is not a member of the organization + // evaluation must happen based on global permissions. + evaluationOrg := targetOrgID + if tmpUser.OrgID == NoOrgID { + evaluationOrg = GlobalOrgID + } + if tmpUser.Permissions[evaluationOrg] == nil || len(tmpUser.Permissions[evaluationOrg]) == 0 { permissions, err := service.GetUserPermissions(ctx, tmpUser, Options{}) if err != nil { return nil, err } - tmpUser.Permissions[targetOrgID] = GroupScopesByAction(permissions) + tmpUser.Permissions[evaluationOrg] = GroupScopesByAction(permissions) } return tmpUser, nil @@ -311,6 +321,92 @@ func UseGlobalOrSingleOrg(cfg *setting.Cfg) OrgIDGetter { } } +// UseOrgFromRequestData returns the organization from the request data. +// If no org is specified, then the org where user is logged in is returned. +func UseOrgFromRequestData(c *contextmodel.ReqContext) (int64, error) { + query, err := getOrgQueryFromRequest(c) + if err != nil { + // Special case of macaron handling invalid params + return NoOrgID, org.ErrOrgNotFound.Errorf("failed to get organization from context: %w", err) + } + + if query.OrgId == nil { + return c.SignedInUser.GetOrgID(), nil + } + + return *query.OrgId, nil +} + +// UseGlobalOrgFromRequestData returns global org if `global` flag is set or the org where user is logged in. +func UseGlobalOrgFromRequestData(c *contextmodel.ReqContext) (int64, error) { + query, err := getOrgQueryFromRequest(c) + if err != nil { + // Special case of macaron handling invalid params + return NoOrgID, org.ErrOrgNotFound.Errorf("failed to get organization from context: %w", err) + } + + if query.Global { + return GlobalOrgID, nil + } + + return c.SignedInUser.GetOrgID(), nil +} + +// UseGlobalOrgFromRequestParams returns global org if `global` flag is set or the org where user is logged in. +func UseGlobalOrgFromRequestParams(c *contextmodel.ReqContext) (int64, error) { + if c.QueryBool("global") { + return GlobalOrgID, nil + } + + return c.SignedInUser.GetOrgID(), nil +} + +func getOrgQueryFromRequest(c *contextmodel.ReqContext) (*QueryWithOrg, error) { + query := &QueryWithOrg{} + + req, err := CloneRequest(c.Req) + if err != nil { + return nil, err + } + + if err := web.Bind(req, query); err != nil { + // Special case of macaron handling invalid params + return nil, err + } + + return query, nil +} + +// CloneRequest creates request copy including request body +func CloneRequest(req *http.Request) (*http.Request, error) { + // Get copy of body to prevent error when reading closed body in request handler + bodyCopy, err := CopyRequestBody(req) + if err != nil { + return nil, err + } + reqCopy := req.Clone(req.Context()) + reqCopy.Body = bodyCopy + return reqCopy, nil +} + +// CopyRequestBody returns copy of request body and keeps the original one to prevent error when reading closed body +func CopyRequestBody(req *http.Request) (io.ReadCloser, error) { + if req.Body == nil { + return nil, nil + } + + body := req.Body + var buf bytes.Buffer + if _, err := buf.ReadFrom(body); err != nil { + return nil, err + } + if err := body.Close(); err != nil { + return nil, err + } + req.Body = io.NopCloser(&buf) + return io.NopCloser(bytes.NewReader(buf.Bytes())), nil +} + // scopeParams holds the parameters used to fill in scope templates type scopeParams struct { OrgID int64 diff --git a/pkg/services/accesscontrol/migrator/migrator_test.go b/pkg/services/accesscontrol/migrator/migrator_test.go index 779bc399fa32f..bfa35f17efcff 100644 --- a/pkg/services/accesscontrol/migrator/migrator_test.go +++ b/pkg/services/accesscontrol/migrator/migrator_test.go @@ -14,8 +14,13 @@ import ( "github.com/grafana/grafana/pkg/infra/log" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func batchInsertPermissions(cnt int, sqlStore db.DB) error { now := time.Now() diff --git a/pkg/services/accesscontrol/mock/mock.go b/pkg/services/accesscontrol/mock/mock.go index 90a9cda8bfe64..48fd43fc992aa 100644 --- a/pkg/services/accesscontrol/mock/mock.go +++ b/pkg/services/accesscontrol/mock/mock.go @@ -21,6 +21,7 @@ type fullAccessControl interface { type Calls struct { Evaluate []interface{} GetUserPermissions []interface{} + GetUserPermissionsInOrg []interface{} ClearUserPermissionCache []interface{} DeclareFixedRoles []interface{} DeclarePluginRoles []interface{} @@ -28,6 +29,7 @@ type Calls struct { RegisterFixedRoles []interface{} RegisterAttributeScopeResolver []interface{} DeleteUserPermissions []interface{} + DeleteTeamPermissions []interface{} SearchUsersPermissions []interface{} SearchUserPermissions []interface{} SaveExternalServiceRole []interface{} @@ -46,6 +48,7 @@ type Mock struct { // Override functions EvaluateFunc func(context.Context, identity.Requester, accesscontrol.Evaluator) (bool, error) GetUserPermissionsFunc func(context.Context, identity.Requester, accesscontrol.Options) ([]accesscontrol.Permission, error) + GetUserPermissionsInOrgFunc func(context.Context, identity.Requester, int64) ([]accesscontrol.Permission, error) ClearUserPermissionCacheFunc func(identity.Requester) DeclareFixedRolesFunc func(...accesscontrol.RoleRegistration) error DeclarePluginRolesFunc func(context.Context, string, string, []plugins.RoleRegistration) error @@ -53,10 +56,12 @@ type Mock struct { RegisterFixedRolesFunc func() error RegisterScopeAttributeResolverFunc func(string, accesscontrol.ScopeAttributeResolver) DeleteUserPermissionsFunc func(context.Context, int64) error + DeleteTeamPermissionsFunc func(context.Context, int64) error SearchUsersPermissionsFunc func(context.Context, identity.Requester, int64, accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) SearchUserPermissionsFunc func(ctx context.Context, orgID int64, searchOptions accesscontrol.SearchOptions) ([]accesscontrol.Permission, error) SaveExternalServiceRoleFunc func(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error DeleteExternalServiceRoleFunc func(ctx context.Context, externalServiceID string) error + SyncUserRolesFunc func(ctx context.Context, orgID int64, cmd accesscontrol.SyncUserRolesCommand) error scopeResolvers accesscontrol.Resolvers } @@ -137,6 +142,16 @@ func (m *Mock) GetUserPermissions(ctx context.Context, user identity.Requester, return m.permissions, nil } +func (m *Mock) GetUserPermissionsInOrg(ctx context.Context, user identity.Requester, orgID int64) ([]accesscontrol.Permission, error) { + m.Calls.GetUserPermissionsInOrg = append(m.Calls.GetUserPermissionsInOrg, []interface{}{ctx, user, orgID}) + // Use override if provided + if m.GetUserPermissionsInOrgFunc != nil { + return m.GetUserPermissionsInOrgFunc(ctx, user, orgID) + } + // Otherwise return the Permissions list + return m.permissions, nil +} + func (m *Mock) ClearUserPermissionCache(user identity.Requester) { m.Calls.ClearUserPermissionCache = append(m.Calls.ClearUserPermissionCache, []interface{}{user}) // Use override if provided @@ -198,6 +213,15 @@ func (m *Mock) DeleteUserPermissions(ctx context.Context, orgID, userID int64) e return nil } +func (m *Mock) DeleteTeamPermissions(ctx context.Context, orgID, teamID int64) error { + m.Calls.DeleteTeamPermissions = append(m.Calls.DeleteTeamPermissions, []interface{}{ctx, orgID, teamID}) + // Use override if provided + if m.DeleteTeamPermissionsFunc != nil { + return m.DeleteTeamPermissionsFunc(ctx, teamID) + } + return nil +} + // SearchUsersPermissions returns all users' permissions filtered by an action prefix func (m *Mock) SearchUsersPermissions(ctx context.Context, usr identity.Requester, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { user := usr.(*user.SignedInUser) @@ -235,3 +259,10 @@ func (m *Mock) DeleteExternalServiceRole(ctx context.Context, externalServiceID } return nil } + +func (m *Mock) SyncUserRoles(ctx context.Context, orgID int64, cmd accesscontrol.SyncUserRolesCommand) error { + if m.SyncUserRolesFunc != nil { + return m.SyncUserRolesFunc(ctx, orgID, cmd) + } + return nil +} diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 2e848e2ab37d7..a85a0047438d7 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -13,7 +13,15 @@ import ( "github.com/grafana/grafana/pkg/util/errutil" ) -var ErrInternal = errutil.Internal("accesscontrol.internal") +const ( + CacheHit = "hit" + CacheMiss = "miss" +) + +var ( + ErrInternal = errutil.Internal("accesscontrol.internal") + CacheUsageStatuses = []string{CacheHit, CacheMiss} +) // RoleRegistration stores a role and its assignments to built-in roles // (Viewer, Editor, Admin, Grafana Admin) @@ -322,6 +330,7 @@ func (cmd *SaveExternalServiceRoleCommand) Validate() error { const ( GlobalOrgID = 0 + NoOrgID = int64(-1) GeneralFolderUID = "general" RoleGrafanaAdmin = "Grafana Admin" @@ -332,9 +341,8 @@ const ( ActionAPIKeyDelete = "apikeys:delete" // Users actions - ActionUsersRead = "users:read" - ActionUsersWrite = "users:write" - ActionUsersImpersonate = "users:impersonate" + ActionUsersRead = "users:read" + ActionUsersWrite = "users:write" // We can ignore gosec G101 since this does not contain any credentials. // nolint:gosec @@ -436,6 +444,15 @@ const ( ActionAlertingNotificationsRead = "alert.notifications:read" ActionAlertingNotificationsWrite = "alert.notifications:write" + // Alerting notifications time interval actions + ActionAlertingNotificationsTimeIntervalsRead = "alert.notifications.time-intervals:read" + ActionAlertingNotificationsTimeIntervalsWrite = "alert.notifications.time-intervals:write" + + // Alerting receiver actions + ActionAlertingReceiversList = "alert.notifications.receivers:list" + ActionAlertingReceiversRead = "alert.notifications.receivers:read" + ActionAlertingReceiversReadSecrets = "alert.notifications.receivers.secrets:read" + // External alerting rule actions. We can only narrow it down to writes or reads, as we don't control the atomicity in the external system. ActionAlertingRuleExternalWrite = "alert.rules.external:write" ActionAlertingRuleExternalRead = "alert.rules.external:read" @@ -507,6 +524,7 @@ var TeamsAccessEvaluator = EvalAny( EvalAny( EvalPermission(ActionTeamsWrite), EvalPermission(ActionTeamsPermissionsWrite), + EvalPermission(ActionTeamsPermissionsRead), ), ), ) @@ -545,3 +563,8 @@ var OrgsCreateAccessEvaluator = EvalAll( // ApiKeyAccessEvaluator is used to protect the "Configuration > API keys" page access var ApiKeyAccessEvaluator = EvalPermission(ActionAPIKeyRead) + +type QueryWithOrg struct { + OrgId *int64 `json:"orgId"` + Global bool `json:"global"` +} diff --git a/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go index 5c5725ac55f04..1645bc0983682 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/auth/identity" @@ -22,6 +23,7 @@ import ( "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" ) type TeamPermissionsService struct { @@ -43,7 +45,7 @@ var ( ) func ProvideTeamPermissions( - features featuremgmt.FeatureToggles, router routing.RouteRegister, sql db.DB, + cfg *setting.Cfg, features featuremgmt.FeatureToggles, router routing.RouteRegister, sql db.DB, ac accesscontrol.AccessControl, license licensing.Licensing, service accesscontrol.Service, teamService team.Service, userService user.Service, ) (*TeamPermissionsService, error) { @@ -101,7 +103,7 @@ func ProvideTeamPermissions( }, } - srv, err := resourcepermissions.New(options, features, router, license, ac, service, sql, teamService, userService) + srv, err := resourcepermissions.New(cfg, options, features, router, license, ac, service, sql, teamService, userService) if err != nil { return nil, err } @@ -116,8 +118,29 @@ var DashboardViewActions = []string{dashboards.ActionDashboardsRead} var DashboardEditActions = append(DashboardViewActions, []string{dashboards.ActionDashboardsWrite, dashboards.ActionDashboardsDelete}...) var DashboardAdminActions = append(DashboardEditActions, []string{dashboards.ActionDashboardsPermissionsRead, dashboards.ActionDashboardsPermissionsWrite}...) +func getDashboardViewActions(features featuremgmt.FeatureToggles) []string { + if features.IsEnabled(context.Background(), featuremgmt.FlagAnnotationPermissionUpdate) { + return append(DashboardViewActions, accesscontrol.ActionAnnotationsRead) + } + return DashboardViewActions +} + +func getDashboardEditActions(features featuremgmt.FeatureToggles) []string { + if features.IsEnabled(context.Background(), featuremgmt.FlagAnnotationPermissionUpdate) { + return append(DashboardEditActions, []string{accesscontrol.ActionAnnotationsRead, accesscontrol.ActionAnnotationsWrite, accesscontrol.ActionAnnotationsDelete, accesscontrol.ActionAnnotationsCreate}...) + } + return DashboardEditActions +} + +func getDashboardAdminActions(features featuremgmt.FeatureToggles) []string { + if features.IsEnabled(context.Background(), featuremgmt.FlagAnnotationPermissionUpdate) { + return append(DashboardAdminActions, []string{accesscontrol.ActionAnnotationsRead, accesscontrol.ActionAnnotationsWrite, accesscontrol.ActionAnnotationsDelete, accesscontrol.ActionAnnotationsCreate}...) + } + return DashboardAdminActions +} + func ProvideDashboardPermissions( - features featuremgmt.FeatureToggles, router routing.RouteRegister, sql db.DB, ac accesscontrol.AccessControl, + cfg *setting.Cfg, features featuremgmt.FeatureToggles, router routing.RouteRegister, sql db.DB, ac accesscontrol.AccessControl, license licensing.Licensing, dashboardStore dashboards.Store, folderService folder.Service, service accesscontrol.Service, teamService team.Service, userService user.Service, ) (*DashboardPermissionsService, error) { @@ -150,6 +173,7 @@ func ProvideDashboardPermissions( if err != nil { return nil, err } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.AccessControl).Inc() // nolint:staticcheck if dashboard.FolderID > 0 { query := &dashboards.GetDashboardQuery{ID: dashboard.FolderID, OrgID: orgID} @@ -174,16 +198,16 @@ func ProvideDashboardPermissions( ServiceAccounts: true, }, PermissionsToActions: map[string][]string{ - "View": DashboardViewActions, - "Edit": DashboardEditActions, - "Admin": DashboardAdminActions, + "View": getDashboardViewActions(features), + "Edit": getDashboardEditActions(features), + "Admin": getDashboardAdminActions(features), }, ReaderRoleName: "Dashboard permission reader", WriterRoleName: "Dashboard permission writer", RoleGroup: "Dashboards", } - srv, err := resourcepermissions.New(options, features, router, license, ac, service, sql, teamService, userService) + srv, err := resourcepermissions.New(cfg, options, features, router, license, ac, service, sql, teamService, userService) if err != nil { return nil, err } @@ -209,7 +233,7 @@ var FolderEditActions = append(FolderViewActions, []string{ var FolderAdminActions = append(FolderEditActions, []string{dashboards.ActionFoldersPermissionsRead, dashboards.ActionFoldersPermissionsWrite}...) func ProvideFolderPermissions( - features featuremgmt.FeatureToggles, router routing.RouteRegister, sql db.DB, accesscontrol accesscontrol.AccessControl, + cfg *setting.Cfg, features featuremgmt.FeatureToggles, router routing.RouteRegister, sql db.DB, accesscontrol accesscontrol.AccessControl, license licensing.Licensing, dashboardStore dashboards.Store, folderService folder.Service, service accesscontrol.Service, teamService team.Service, userService user.Service, ) (*FolderPermissionsService, error) { @@ -239,15 +263,15 @@ func ProvideFolderPermissions( ServiceAccounts: true, }, PermissionsToActions: map[string][]string{ - "View": append(DashboardViewActions, FolderViewActions...), - "Edit": append(DashboardEditActions, FolderEditActions...), - "Admin": append(DashboardAdminActions, FolderAdminActions...), + "View": append(getDashboardViewActions(features), FolderViewActions...), + "Edit": append(getDashboardEditActions(features), FolderEditActions...), + "Admin": append(getDashboardAdminActions(features), FolderAdminActions...), }, ReaderRoleName: "Folder permission reader", WriterRoleName: "Folder permission writer", RoleGroup: "Folders", } - srv, err := resourcepermissions.New(options, features, router, license, accesscontrol, service, sql, teamService, userService) + srv, err := resourcepermissions.New(cfg, options, features, router, license, accesscontrol, service, sql, teamService, userService) if err != nil { return nil, err } @@ -283,7 +307,6 @@ func (e DatasourcePermissionsService) SetPermissions(ctx context.Context, orgID } func (e DatasourcePermissionsService) DeleteResourcePermissions(ctx context.Context, orgID int64, resourceID string) error { - // TODO: implement return nil } @@ -310,7 +333,7 @@ type ServiceAccountPermissionsService struct { } func ProvideServiceAccountPermissions( - features featuremgmt.FeatureToggles, router routing.RouteRegister, sql db.DB, ac accesscontrol.AccessControl, + cfg *setting.Cfg, features featuremgmt.FeatureToggles, router routing.RouteRegister, sql db.DB, ac accesscontrol.AccessControl, license licensing.Licensing, serviceAccountRetrieverService *retriever.Service, service accesscontrol.Service, teamService team.Service, userService user.Service, ) (*ServiceAccountPermissionsService, error) { @@ -339,7 +362,7 @@ func ProvideServiceAccountPermissions( RoleGroup: "Service accounts", } - srv, err := resourcepermissions.New(options, features, router, license, ac, service, sql, teamService, userService) + srv, err := resourcepermissions.New(cfg, options, features, router, license, ac, service, sql, teamService, userService) if err != nil { return nil, err } diff --git a/pkg/services/accesscontrol/pluginutils/utils.go b/pkg/services/accesscontrol/pluginutils/utils.go index 118902177481d..ffbde39295f08 100644 --- a/pkg/services/accesscontrol/pluginutils/utils.go +++ b/pkg/services/accesscontrol/pluginutils/utils.go @@ -34,6 +34,9 @@ func ValidatePluginRole(pluginID string, role ac.RoleDTO) error { if pluginID == "" { return ac.ErrPluginIDRequired } + if role.DisplayName == "" { + return &ac.ErrorRoleNameMissing{} + } if !strings.HasPrefix(role.Name, ac.PluginRolePrefix+pluginID+":") { return &ac.ErrorRolePrefixMissing{Role: role.Name, Prefixes: []string{ac.PluginRolePrefix + pluginID + ":"}} } @@ -60,6 +63,22 @@ func ToRegistrations(pluginID, pluginName string, regs []plugins.RoleRegistratio return res } +// PluginIDFromName extracts the plugin ID from the role name +func PluginIDFromName(roleName string) string { + if !strings.HasPrefix(roleName, ac.PluginRolePrefix) { + return "" + } + + pluginID := strings.Builder{} + for _, c := range roleName[len(ac.PluginRolePrefix):] { + if c == ':' { + break + } + pluginID.WriteRune(c) + } + return pluginID.String() +} + func roleName(pluginID, roleName string) string { return fmt.Sprintf("%v%v:%v", ac.PluginRolePrefix, pluginID, strings.Replace(strings.ToLower(roleName), " ", "-", -1)) } diff --git a/pkg/services/accesscontrol/pluginutils/utils_test.go b/pkg/services/accesscontrol/pluginutils/utils_test.go index c6432a355ab25..38ae6f4765c5b 100644 --- a/pkg/services/accesscontrol/pluginutils/utils_test.go +++ b/pkg/services/accesscontrol/pluginutils/utils_test.go @@ -85,34 +85,41 @@ func TestValidatePluginRole(t *testing.T) { role ac.RoleDTO wantErr error }{ + { + name: "empty display name", + pluginID: "test-app", + role: ac.RoleDTO{DisplayName: ""}, + wantErr: &ac.ErrorInvalidRole{}, + }, { name: "empty", pluginID: "", - role: ac.RoleDTO{Name: "plugins::"}, + role: ac.RoleDTO{Name: "plugins::reader", DisplayName: "Reader"}, wantErr: ac.ErrPluginIDRequired, }, { name: "invalid name", pluginID: "test-app", - role: ac.RoleDTO{Name: "test-app:reader"}, + role: ac.RoleDTO{Name: "test-app:reader", DisplayName: "Reader"}, wantErr: &ac.ErrorInvalidRole{}, }, { name: "invalid id in name", pluginID: "test-app", - role: ac.RoleDTO{Name: "plugins:test-app2:reader"}, + role: ac.RoleDTO{Name: "plugins:test-app2:reader", DisplayName: "Reader"}, wantErr: &ac.ErrorInvalidRole{}, }, { name: "valid name", pluginID: "test-app", - role: ac.RoleDTO{Name: "plugins:test-app:reader"}, + role: ac.RoleDTO{Name: "plugins:test-app:reader", DisplayName: "Reader"}, }, { name: "invalid permission", pluginID: "test-app", role: ac.RoleDTO{ Name: "plugins:test-app:reader", + DisplayName: "Reader", Permissions: []ac.Permission{{Action: "invalidtest-app:read"}}, }, wantErr: &ac.ErrorInvalidRole{}, @@ -121,7 +128,8 @@ func TestValidatePluginRole(t *testing.T) { name: "valid permissions", pluginID: "test-app", role: ac.RoleDTO{ - Name: "plugins:test-app:reader", + Name: "plugins:test-app:reader", + DisplayName: "Reader", Permissions: []ac.Permission{ {Action: "plugins.app:access", Scope: "plugins:id:test-app"}, {Action: "test-app:read"}, @@ -133,7 +141,8 @@ func TestValidatePluginRole(t *testing.T) { name: "invalid permission targets other plugin", pluginID: "test-app", role: ac.RoleDTO{ - Name: "plugins:test-app:reader", + Name: "plugins:test-app:reader", + DisplayName: "Reader", Permissions: []ac.Permission{ {Action: "plugins.app:access", Scope: "plugins:id:other-app"}, }, diff --git a/pkg/services/accesscontrol/resourcepermissions/api.go b/pkg/services/accesscontrol/resourcepermissions/api.go index e79a06a02bdd5..95db0372e8a44 100644 --- a/pkg/services/accesscontrol/resourcepermissions/api.go +++ b/pkg/services/accesscontrol/resourcepermissions/api.go @@ -11,23 +11,25 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) type api struct { + cfg *setting.Cfg ac accesscontrol.AccessControl router routing.RouteRegister service *Service permissions []string } -func newApi(ac accesscontrol.AccessControl, router routing.RouteRegister, manager *Service) *api { +func newApi(cfg *setting.Cfg, ac accesscontrol.AccessControl, router routing.RouteRegister, manager *Service) *api { permissions := make([]string, 0, len(manager.permissions)) // reverse the permissions order for display for i := len(manager.permissions) - 1; i >= 0; i-- { permissions = append(permissions, manager.permissions[i]) } - return &api{ac, router, manager, permissions} + return &api{cfg, ac, router, manager, permissions} } func (a *api) registerEndpoints() { @@ -63,6 +65,13 @@ type Assignments struct { BuiltInRoles bool `json:"builtInRoles"` } +// swagger:parameters getResourceDescription +type GetResourceDescriptionParams struct { + // in:path + // required:true + Resource string `json:"resource"` +} + // swagger:response resourcePermissionsDescription type DescriptionResponse struct { // in:body @@ -75,7 +84,7 @@ type Description struct { Permissions []string `json:"permissions"` } -// swagger:route POST /access-control/:resource/description enterprise,access_control getResourceDescription +// swagger:route GET /access-control/{resource}/description access_control getResourceDescription // // Get a description of a resource's access control properties. // @@ -107,23 +116,35 @@ type resourcePermissionDTO struct { Permission string `json:"permission"` } +// swagger:parameters getResourcePermissions +type GetResourcePermissionsParams struct { + // in:path + // required:true + Resource string `json:"resource"` + + // in:path + // required:true + ResourceID string `json:"resourceID"` +} + // swagger:response getResourcePermissionsResponse type getResourcePermissionsResponse []resourcePermissionDTO -// swagger:route POST /access-control/:resource/:resourceID enterprise,access_control getResourcePermissions +// swagger:route GET /access-control/{resource}/{resourceID} access_control getResourcePermissions // // Get permissions for a resource. // // Responses: // 200: getResourcePermissionsResponse // 403: forbiddenError +// 404: notFoundError // 500: internalServerError func (a *api) getPermissions(c *contextmodel.ReqContext) response.Response { resourceID := web.Params(c.Req)[":resourceID"] permissions, err := a.service.GetPermissions(c.Req.Context(), c.SignedInUser, resourceID) if err != nil { - return response.Error(http.StatusInternalServerError, "failed to get permissions", err) + return response.ErrOrFallback(http.StatusInternalServerError, "failed to get permissions", err) } if a.service.options.Assignments.BuiltInRoles && !a.service.license.FeatureEnabled("accesscontrol.enforcement") { @@ -139,7 +160,7 @@ func (a *api) getPermissions(c *contextmodel.ReqContext) response.Response { if permission := a.service.MapActions(p); permission != "" { teamAvatarUrl := "" if p.TeamId != 0 { - teamAvatarUrl = dtos.GetGravatarUrlWithDefault(p.TeamEmail, p.Team) + teamAvatarUrl = dtos.GetGravatarUrlWithDefault(a.cfg, p.TeamEmail, p.Team) } dto = append(dto, resourcePermissionDTO{ @@ -147,7 +168,7 @@ func (a *api) getPermissions(c *contextmodel.ReqContext) response.Response { RoleName: p.RoleName, UserID: p.UserId, UserLogin: p.UserLogin, - UserAvatarUrl: dtos.GetGravatarUrl(p.UserEmail), + UserAvatarUrl: dtos.GetGravatarUrl(a.cfg, p.UserEmail), Team: p.Team, TeamID: p.TeamId, TeamAvatarUrl: teamAvatarUrl, @@ -172,18 +193,38 @@ type setPermissionsCommand struct { Permissions []accesscontrol.SetResourcePermissionCommand `json:"permissions"` } -// swagger:route POST /access-control/:resource/:resourceID/users/:userID enterprise,access_control setResourcePermissionsForUser +// swagger:parameters setResourcePermissionsForUser +type SetResourcePermissionsForUserParams struct { + // in:path + // required:true + Resource string `json:"resource"` + + // in:path + // required:true + ResourceID string `json:"resourceID"` + + // in:path + // required:true + UserID int64 `json:"userID"` + + // in:body + // required:true + Body setPermissionCommand +} + +// swagger:route POST /access-control/{resource}/{resourceID}/users/{userID} access_control setResourcePermissionsForUser // // Set resource permissions for a user. // // Assigns permissions for a resource by a given type (`:resource`) and `:resourceID` to a user or a service account. // Allowed resources are `datasources`, `teams`, `dashboards`, `folders`, and `serviceaccounts`. -// Refer to the `/access-control/:resource/description` endpoint for allowed Permissions. +// Refer to the `/access-control/{resource}/description` endpoint for allowed Permissions. // // Responses: -// 200: okRespoonse +// 200: okResponse // 400: badRequestError // 403: forbiddenError +// 404: notFoundError // 500: internalServerError func (a *api) setUserPermission(c *contextmodel.ReqContext) response.Response { userID, err := strconv.ParseInt(web.Params(c.Req)[":userID"], 10, 64) @@ -199,24 +240,44 @@ func (a *api) setUserPermission(c *contextmodel.ReqContext) response.Response { _, err = a.service.SetUserPermission(c.Req.Context(), c.SignedInUser.GetOrgID(), accesscontrol.User{ID: userID}, resourceID, cmd.Permission) if err != nil { - return response.Error(http.StatusBadRequest, "failed to set user permission", err) + return response.ErrOrFallback(http.StatusBadRequest, "failed to set user permission", err) } return permissionSetResponse(cmd) } -// swagger:route POST /access-control/:resource/:resourceID/teams/:teamID enterprise,access_control setResourcePermissionsForTeam +// swagger:parameters setResourcePermissionsForTeam +type SetResourcePermissionsForTeamParams struct { + // in:path + // required:true + Resource string `json:"resource"` + + // in:path + // required:true + ResourceID string `json:"resourceID"` + + // in:path + // required:true + TeamID int64 `json:"teamID"` + + // in:body + // required:true + Body setPermissionCommand +} + +// swagger:route POST /access-control/{resource}/{resourceID}/teams/{teamID} access_control setResourcePermissionsForTeam // // Set resource permissions for a team. // // Assigns permissions for a resource by a given type (`:resource`) and `:resourceID` to a team. // Allowed resources are `datasources`, `teams`, `dashboards`, `folders`, and `serviceaccounts`. -// Refer to the `/access-control/:resource/description` endpoint for allowed Permissions. +// Refer to the `/access-control/{resource}/description` endpoint for allowed Permissions. // // Responses: -// 200: okRespoonse +// 200: okResponse // 400: badRequestError // 403: forbiddenError +// 404: notFoundError // 500: internalServerError func (a *api) setTeamPermission(c *contextmodel.ReqContext) response.Response { teamID, err := strconv.ParseInt(web.Params(c.Req)[":teamID"], 10, 64) @@ -232,24 +293,44 @@ func (a *api) setTeamPermission(c *contextmodel.ReqContext) response.Response { _, err = a.service.SetTeamPermission(c.Req.Context(), c.SignedInUser.GetOrgID(), teamID, resourceID, cmd.Permission) if err != nil { - return response.Error(http.StatusBadRequest, "failed to set team permission", err) + return response.ErrOrFallback(http.StatusBadRequest, "failed to set team permission", err) } return permissionSetResponse(cmd) } -// swagger:route POST /access-control/:resource/:resourceID/builtInRoles/:builtInRole enterprise,access_control setResourcePermissionsForBuiltInRole +// swagger:parameters setResourcePermissionsForBuiltInRole +type SetResourcePermissionsForBuiltInRoleParams struct { + // in:path + // required:true + Resource string `json:"resource"` + + // in:path + // required:true + ResourceID string `json:"resourceID"` + + // in:path + // required:true + BuiltInRole string `json:"builtInRole"` + + // in:body + // required:true + Body setPermissionCommand +} + +// swagger:route POST /access-control/{resource}/{resourceID}/builtInRoles/{builtInRole} access_control setResourcePermissionsForBuiltInRole // // Set resource permissions for a built-in role. // // Assigns permissions for a resource by a given type (`:resource`) and `:resourceID` to a built-in role. // Allowed resources are `datasources`, `teams`, `dashboards`, `folders`, and `serviceaccounts`. -// Refer to the `/access-control/:resource/description` endpoint for allowed Permissions. +// Refer to the `/access-control/{resource}/description` endpoint for allowed Permissions. // // Responses: -// 200: okRespoonse +// 200: okResponse // 400: badRequestError // 403: forbiddenError +// 404: notFoundError // 500: internalServerError func (a *api) setBuiltinRolePermission(c *contextmodel.ReqContext) response.Response { builtInRole := web.Params(c.Req)[":builtInRole"] @@ -262,24 +343,40 @@ func (a *api) setBuiltinRolePermission(c *contextmodel.ReqContext) response.Resp _, err := a.service.SetBuiltInRolePermission(c.Req.Context(), c.SignedInUser.GetOrgID(), builtInRole, resourceID, cmd.Permission) if err != nil { - return response.Error(http.StatusBadRequest, "failed to set role permission", err) + return response.ErrOrFallback(http.StatusBadRequest, "failed to set role permission", err) } return permissionSetResponse(cmd) } -// swagger:route POST /access-control/:resource/:resourceID enterprise,access_control setResourcePermissions +// swagger:parameters setResourcePermissions +type SetResourcePermissionsParams struct { + // in:path + // required:true + Resource string `json:"resource"` + + // in:path + // required:true + ResourceID string `json:"resourceID"` + + // in:body + // required:true + Body setPermissionsCommand +} + +// swagger:route POST /access-control/{resource}/{resourceID} access_control setResourcePermissions // // Set resource permissions. // // Assigns permissions for a resource by a given type (`:resource`) and `:resourceID` to one or many // assignment types. Allowed resources are `datasources`, `teams`, `dashboards`, `folders`, and `serviceaccounts`. -// Refer to the `/access-control/:resource/description` endpoint for allowed Permissions. +// Refer to the `/access-control/{resource}/description` endpoint for allowed Permissions. // // Responses: -// 200: okRespoonse +// 200: okResponse // 400: badRequestError // 403: forbiddenError +// 404: notFoundError // 500: internalServerError func (a *api) setPermissions(c *contextmodel.ReqContext) response.Response { resourceID := web.Params(c.Req)[":resourceID"] @@ -291,7 +388,7 @@ func (a *api) setPermissions(c *contextmodel.ReqContext) response.Response { _, err := a.service.SetPermissions(c.Req.Context(), c.SignedInUser.GetOrgID(), resourceID, cmd.Permissions...) if err != nil { - return response.Error(http.StatusBadRequest, "failed to set permissions", err) + return response.ErrOrFallback(http.StatusBadRequest, "failed to set permission", err) } return response.Success("Permissions updated") diff --git a/pkg/services/accesscontrol/resourcepermissions/api_test.go b/pkg/services/accesscontrol/resourcepermissions/api_test.go index 797e51de7379b..5e1b9032f49d6 100644 --- a/pkg/services/accesscontrol/resourcepermissions/api_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/api_test.go @@ -6,7 +6,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "path" "strconv" "strings" "testing" @@ -25,7 +24,6 @@ import ( "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -432,7 +430,7 @@ func TestApi_setUserPermission(t *testing.T) { func setupTestServer(t *testing.T, user *user.SignedInUser, service *Service) *web.Mux { server := web.New() - server.UseMiddleware(web.Renderer(path.Join(setting.StaticRootPath, "views"), "[[", "]]")) + server.UseMiddleware(web.Renderer("views", "[[", "]]")) server.Use(contextProvider(&testContext{user})) service.api.router.Register(server) return server @@ -510,7 +508,8 @@ func checkSeededPermissions(t *testing.T, permissions []resourcePermissionDTO) { func seedPermissions(t *testing.T, resourceID string, sql *sqlstore.SQLStore, service *Service) { t.Helper() // seed team 1 with "Edit" permission on dashboard 1 - teamSvc := teamimpl.ProvideService(sql, sql.Cfg) + teamSvc, err := teamimpl.ProvideService(sql, sql.Cfg) + require.NoError(t, err) team, err := teamSvc.CreateTeam("test", "test@test.com", 1) require.NoError(t, err) _, err = service.SetTeamPermission(context.Background(), team.OrgID, team.ID, resourceID, "Edit") diff --git a/pkg/services/accesscontrol/resourcepermissions/service.go b/pkg/services/accesscontrol/resourcepermissions/service.go index 235e2240dd02d..cf2e638275f26 100644 --- a/pkg/services/accesscontrol/resourcepermissions/service.go +++ b/pkg/services/accesscontrol/resourcepermissions/service.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" ) type Store interface { @@ -52,7 +53,7 @@ type Store interface { DeleteResourcePermissions(ctx context.Context, orgID int64, cmd *DeleteResourcePermissionsCmd) error } -func New( +func New(cfg *setting.Cfg, options Options, features featuremgmt.FeatureToggles, router routing.RouteRegister, license licensing.Licensing, ac accesscontrol.AccessControl, service accesscontrol.Service, sqlStore db.DB, teamService team.Service, userService user.Service, @@ -89,7 +90,7 @@ func New( userService: userService, } - s.api = newApi(ac, router, s) + s.api = newApi(cfg, ac, router, s) if err := s.declareFixedRoles(); err != nil { return nil, err diff --git a/pkg/services/accesscontrol/resourcepermissions/service_test.go b/pkg/services/accesscontrol/resourcepermissions/service_test.go index dbf040b4cb65b..95287de0404f9 100644 --- a/pkg/services/accesscontrol/resourcepermissions/service_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/service_test.go @@ -237,15 +237,16 @@ func setupTestEnvironment(t *testing.T, ops Options) (*Service, *sqlstore.SQLSto sql := db.InitTestDB(t) cfg := setting.NewCfg() - teamSvc := teamimpl.ProvideService(sql, cfg) - userSvc, err := userimpl.ProvideService(sql, nil, cfg, teamimpl.ProvideService(sql, cfg), nil, quotatest.New(false, nil), supportbundlestest.NewFakeBundleService()) + teamSvc, err := teamimpl.ProvideService(sql, cfg) + require.NoError(t, err) + userSvc, err := userimpl.ProvideService(sql, nil, cfg, teamSvc, nil, quotatest.New(false, nil), supportbundlestest.NewFakeBundleService()) require.NoError(t, err) license := licensingtest.NewFakeLicensing() license.On("FeatureEnabled", "accesscontrol.enforcement").Return(true).Maybe() ac := acimpl.ProvideAccessControl(cfg) acService := &actest.FakeService{} service, err := New( - ops, featuremgmt.WithFeatures(), routing.NewRouteRegister(), license, + cfg, ops, featuremgmt.WithFeatures(), routing.NewRouteRegister(), license, ac, acService, sql, teamSvc, userSvc, ) require.NoError(t, err) diff --git a/pkg/services/accesscontrol/resourcepermissions/store.go b/pkg/services/accesscontrol/resourcepermissions/store.go index 5722cbc2a00ff..33359fdfa4b78 100644 --- a/pkg/services/accesscontrol/resourcepermissions/store.go +++ b/pkg/services/accesscontrol/resourcepermissions/store.go @@ -667,9 +667,7 @@ func (s *store) createPermissions(sess *db.Session, roleID int64, resource, reso p.RoleID = roleID p.Created = time.Now() p.Updated = time.Now() - if s.features.IsEnabledGlobally(featuremgmt.FlagSplitScopes) { - p.Kind, p.Attribute, p.Identifier = p.SplitScope() - } + p.Kind, p.Attribute, p.Identifier = p.SplitScope() permissions = append(permissions, p) } diff --git a/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go b/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go index a1a7e4c11d42d..f85a457573f66 100644 --- a/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go @@ -138,7 +138,8 @@ func GenerateDatasourcePermissions(b *testing.B, db *sqlstore.SQLStore, ac *stor } func generateTeamsAndUsers(b *testing.B, db *sqlstore.SQLStore, users int) ([]int64, []int64) { - teamSvc := teamimpl.ProvideService(db, db.Cfg) + teamSvc, err := teamimpl.ProvideService(db, db.Cfg) + require.NoError(b, err) numberOfTeams := int(math.Ceil(float64(users) / UsersPerTeam)) globalUserId := 0 qs := quotatest.New(false, nil) @@ -169,7 +170,7 @@ func generateTeamsAndUsers(b *testing.B, db *sqlstore.SQLStore, users int) ([]in globalUserId++ userIds = append(userIds, userId) - err = teamSvc.AddTeamMember(userId, 1, teamId, false, 1) + err = teamSvc.AddTeamMember(context.Background(), userId, 1, teamId, false, 1) require.NoError(b, err) } } diff --git a/pkg/services/accesscontrol/resourcepermissions/store_test.go b/pkg/services/accesscontrol/resourcepermissions/store_test.go index d83d41a4fad52..1af01485939ca 100644 --- a/pkg/services/accesscontrol/resourcepermissions/store_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/store_test.go @@ -21,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" + "github.com/grafana/grafana/pkg/tests/testsuite" ) type setUserResourcePermissionTest struct { @@ -34,6 +35,10 @@ type setUserResourcePermissionTest struct { seeds []SetResourcePermissionCommand } +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationStore_SetUserResourcePermission(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/accesscontrol/roles.go b/pkg/services/accesscontrol/roles.go index 8aaeec91e466d..5c82eefb8f400 100644 --- a/pkg/services/accesscontrol/roles.go +++ b/pkg/services/accesscontrol/roles.go @@ -28,6 +28,11 @@ const ( BasicRoleNoneUID = "basic_none" BasicRoleNoneName = "basic:none" + + FixedCloudRolePrefix = "fixed:cloud:" + FixedCloudViewerRole = "fixed:cloud:viewer" + FixedCloudEditorRole = "fixed:cloud:editor" + FixedCloudAdminRole = "fixed:cloud:admin" ) // Roles definition @@ -258,6 +263,23 @@ var ( }, }, } + + generalAuthConfigWriterRole = RoleDTO{ + Name: "fixed:general.auth.config:writer", + DisplayName: "General authentication config writer", + Description: "Read and update the Grafana instance's general authentication configuration.", + Group: "Settings", + Permissions: []Permission{ + { + Action: ActionSettingsRead, + Scope: "settings:auth:oauth_allow_insecure_email_lookup", + }, + { + Action: ActionSettingsWrite, + Scope: "settings:auth:oauth_allow_insecure_email_lookup", + }, + }, + } ) // Declare OSS roles to the accesscontrol service @@ -294,6 +316,10 @@ func DeclareFixedRoles(service Service, cfg *setting.Cfg) error { Role: usersWriterRole, Grants: []string{RoleGrafanaAdmin}, } + generalAuthConfigWriter := RoleRegistration{ + Role: generalAuthConfigWriterRole, + Grants: []string{RoleGrafanaAdmin}, + } // TODO: Move to own service when implemented authenticationConfigWriter := RoleRegistration{ @@ -306,7 +332,7 @@ func DeclareFixedRoles(service Service, cfg *setting.Cfg) error { } return service.DeclareFixedRoles(ldapReader, ldapWriter, orgUsersReader, orgUsersWriter, - settingsReader, statsReader, usersReader, usersWriter, authenticationConfigWriter) + settingsReader, statsReader, usersReader, usersWriter, authenticationConfigWriter, generalAuthConfigWriter) } func ConcatPermissions(permissions ...[]Permission) []Permission { @@ -376,6 +402,14 @@ func (m *RegistrationList) Range(f func(registration RoleRegistration) bool) { } } +func (m *RegistrationList) Slice() []RoleRegistration { + m.mx.RLock() + defer m.mx.RUnlock() + out := make([]RoleRegistration, len(m.registrations)) + copy(out, m.registrations) + return out +} + func BuildBasicRoleDefinitions() map[string]*RoleDTO { return map[string]*RoleDTO{ string(org.RoleAdmin): { diff --git a/pkg/services/accesscontrol/ssoutils/utils.go b/pkg/services/accesscontrol/ssoutils/utils.go new file mode 100644 index 0000000000000..857a2532af943 --- /dev/null +++ b/pkg/services/accesscontrol/ssoutils/utils.go @@ -0,0 +1,24 @@ +package ssoutils + +import ( + ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/setting" +) + +func EvalAuthenticationSettings(cfg *setting.Cfg) ac.Evaluator { + return ac.EvalAny( + ac.EvalAll( + ac.EvalPermission(ac.ActionSettingsWrite, ac.ScopeSettingsSAML), + ac.EvalPermission(ac.ActionSettingsRead, ac.ScopeSettingsSAML), + ), + ac.EvalPermission(ac.ActionLDAPStatusRead)) +} + +func OauthSettingsEvaluator(cfg *setting.Cfg) ac.Evaluator { + result := make([]ac.Evaluator, 0, len(cfg.SSOSettingsConfigurableProviders)) + for provider := range cfg.SSOSettingsConfigurableProviders { + result = append(result, ac.EvalPermission(ac.ActionSettingsRead, ac.ScopeSettingsOAuth(provider))) + result = append(result, ac.EvalPermission(ac.ActionSettingsWrite, ac.ScopeSettingsOAuth(provider))) + } + return ac.EvalAny(result...) +} diff --git a/pkg/services/alerting/alerting_usage.go b/pkg/services/alerting/alerting_usage.go deleted file mode 100644 index 71f0c2fb1eb4c..0000000000000 --- a/pkg/services/alerting/alerting_usage.go +++ /dev/null @@ -1,114 +0,0 @@ -package alerting - -import ( - "context" - "encoding/json" - - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/datasources" -) - -// DatasourceAlertUsage is a hash where the key represents the -// Datasource type and the value represents how many alerts -// that use the datasources. -type DatasourceAlertUsage map[string]int - -// UsageStats contains stats about alert rules configured in -// Grafana. -type UsageStats struct { - DatasourceUsage DatasourceAlertUsage -} - -// UsageStatsQuerier returns usage stats about alert rules -// configured in Grafana. -type UsageStatsQuerier interface { - QueryUsageStats(context.Context) (*UsageStats, error) -} - -// QueryUsageStats returns usage stats about alert rules -// configured in Grafana. -func (e *AlertEngine) QueryUsageStats(ctx context.Context) (*UsageStats, error) { - cmd := &models.GetAllAlertsQuery{} - res, err := e.AlertStore.GetAllAlertQueryHandler(ctx, cmd) - if err != nil { - return nil, err - } - - dsUsage, err := e.mapRulesToUsageStats(ctx, res) - if err != nil { - return nil, err - } - - return &UsageStats{ - DatasourceUsage: dsUsage, - }, nil -} - -func (e *AlertEngine) mapRulesToUsageStats(ctx context.Context, rules []*models.Alert) (DatasourceAlertUsage, error) { - // map of datasourceId type and frequency - typeCount := map[int64]int{} - for _, a := range rules { - dss, err := e.parseAlertRuleModel(a.Settings) - if err != nil { - e.log.Debug("Could not parse settings for alert rule", "id", a.ID) - continue - } - - for _, d := range dss { - // aggregated datasource usage based on datasource id - typeCount[d]++ - } - } - - // map of datsource types and frequency - result := map[string]int{} - for k, v := range typeCount { - query := &datasources.GetDataSourceQuery{ID: k} - dataSource, err := e.datasourceService.GetDataSource(ctx, query) - if err != nil { - return map[string]int{}, nil - } - - // aggregate datasource usages based on datasource type - result[dataSource.Type] += v - } - - return result, nil -} - -func (e *AlertEngine) parseAlertRuleModel(settings json.Marshaler) ([]int64, error) { - datasourceIDs := []int64{} - model := alertJSONModel{} - - if settings == nil { - return datasourceIDs, nil - } - - bytes, err := settings.MarshalJSON() - if err != nil { - return nil, err - } - - err = json.Unmarshal(bytes, &model) - if err != nil { - return datasourceIDs, err - } - - for _, condition := range model.Conditions { - datasourceIDs = append(datasourceIDs, condition.Query.DatasourceID) - } - - return datasourceIDs, nil -} - -type alertCondition struct { - Query *conditionQuery `json:"query"` -} - -type conditionQuery struct { - DatasourceID int64 `json:"datasourceId"` -} - -type alertJSONModel struct { - Conditions []*alertCondition `json:"conditions"` -} diff --git a/pkg/services/alerting/alerting_usage_test.go b/pkg/services/alerting/alerting_usage_test.go deleted file mode 100644 index 04b821ac961f1..0000000000000 --- a/pkg/services/alerting/alerting_usage_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package alerting - -import ( - "context" - "encoding/json" - "os" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/datasources" - fd "github.com/grafana/grafana/pkg/services/datasources/fakes" -) - -func TestAlertingUsageStats(t *testing.T) { - store := &AlertStoreMock{} - dsMock := &fd.FakeDataSourceService{ - DataSources: []*datasources.DataSource{ - {ID: 1, Type: datasources.DS_INFLUXDB}, - {ID: 2, Type: datasources.DS_GRAPHITE}, - {ID: 3, Type: datasources.DS_PROMETHEUS}, - {ID: 4, Type: datasources.DS_PROMETHEUS}, - }, - } - ae := &AlertEngine{ - AlertStore: store, - datasourceService: dsMock, - } - - store.getAllAlerts = func(ctx context.Context, query *models.GetAllAlertsQuery) (res []*models.Alert, err error) { - var createFake = func(file string) *simplejson.Json { - // Ignore gosec warning G304 since it's a test - // nolint:gosec - content, err := os.ReadFile(file) - require.NoError(t, err, "expected to be able to read file") - - j, err := simplejson.NewJson(content) - require.NoError(t, err) - return j - } - - return []*models.Alert{ - {ID: 1, Settings: createFake("testdata/settings/one_condition.json")}, - {ID: 2, Settings: createFake("testdata/settings/two_conditions.json")}, - {ID: 2, Settings: createFake("testdata/settings/three_conditions.json")}, - {ID: 3, Settings: createFake("testdata/settings/empty.json")}, - }, nil - } - - result, err := ae.QueryUsageStats(context.Background()) - require.NoError(t, err, "getAlertingUsage should not return error") - - expected := map[string]int{ - "prometheus": 4, - "graphite": 2, - } - - for k := range expected { - if expected[k] != result.DatasourceUsage[k] { - t.Errorf("result mismatch for %s. got %v expected %v", k, result.DatasourceUsage[k], expected[k]) - } - } -} - -func TestParsingAlertRuleSettings(t *testing.T) { - tcs := []struct { - name string - file string - expected []int64 - shouldErr require.ErrorAssertionFunc - }{ - { - name: "can parse single condition", - file: "testdata/settings/one_condition.json", - expected: []int64{3}, - shouldErr: require.NoError, - }, - { - name: "can parse multiple conditions", - file: "testdata/settings/two_conditions.json", - expected: []int64{3, 2}, - shouldErr: require.NoError, - }, - { - name: "can parse empty json", - file: "testdata/settings/empty.json", - expected: []int64{}, - shouldErr: require.NoError, - }, - { - name: "can handle nil content", - expected: []int64{}, - shouldErr: require.NoError, - }, - } - - ae := &AlertEngine{} - - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - var settings json.Marshaler - if tc.file != "" { - content, err := os.ReadFile(tc.file) - require.NoError(t, err, "expected to be able to read file") - - settings, err = simplejson.NewJson(content) - require.NoError(t, err) - } - - result, err := ae.parseAlertRuleModel(settings) - - tc.shouldErr(t, err) - diff := cmp.Diff(tc.expected, result) - if diff != "" { - t.Errorf("result mismatch (-want +got) %s\n", diff) - } - }) - } -} diff --git a/pkg/services/alerting/conditions/evaluator.go b/pkg/services/alerting/conditions/evaluator.go deleted file mode 100644 index 76afe8919fbe3..0000000000000 --- a/pkg/services/alerting/conditions/evaluator.go +++ /dev/null @@ -1,157 +0,0 @@ -package conditions - -import ( - "encoding/json" - "fmt" - - "github.com/grafana/grafana/pkg/components/null" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting" -) - -var ( - defaultTypes = []string{"gt", "lt"} - rangedTypes = []string{"within_range", "outside_range"} -) - -// AlertEvaluator evaluates the reduced value of a timeseries. -// Returning true if a timeseries is violating the condition -// ex: ThresholdEvaluator, NoValueEvaluator, RangeEvaluator -type AlertEvaluator interface { - Eval(reducedValue null.Float) bool -} - -type noValueEvaluator struct{} - -func (e *noValueEvaluator) Eval(reducedValue null.Float) bool { - return !reducedValue.Valid -} - -type thresholdEvaluator struct { - Type string - Threshold float64 -} - -func newThresholdEvaluator(typ string, model *simplejson.Json) (*thresholdEvaluator, error) { - params := model.Get("params").MustArray() - if len(params) == 0 || params[0] == nil { - return nil, fmt.Errorf("evaluator '%v' is missing the threshold parameter", HumanThresholdType(typ)) - } - - firstParam, ok := params[0].(json.Number) - if !ok { - return nil, fmt.Errorf("evaluator has invalid parameter") - } - - defaultEval := &thresholdEvaluator{Type: typ} - defaultEval.Threshold, _ = firstParam.Float64() - return defaultEval, nil -} - -func (e *thresholdEvaluator) Eval(reducedValue null.Float) bool { - if !reducedValue.Valid { - return false - } - - switch e.Type { - case "gt": - return reducedValue.Float64 > e.Threshold - case "lt": - return reducedValue.Float64 < e.Threshold - } - - return false -} - -type rangedEvaluator struct { - Type string - Lower float64 - Upper float64 -} - -func newRangedEvaluator(typ string, model *simplejson.Json) (*rangedEvaluator, error) { - params := model.Get("params").MustArray() - if len(params) == 0 { - return nil, alerting.ValidationError{Reason: "Evaluator missing threshold parameter"} - } - - firstParam, ok := params[0].(json.Number) - if !ok { - return nil, alerting.ValidationError{Reason: "Evaluator has invalid parameter"} - } - - secondParam, ok := params[1].(json.Number) - if !ok { - return nil, alerting.ValidationError{Reason: "Evaluator has invalid second parameter"} - } - - rangedEval := &rangedEvaluator{Type: typ} - rangedEval.Lower, _ = firstParam.Float64() - rangedEval.Upper, _ = secondParam.Float64() - return rangedEval, nil -} - -func (e *rangedEvaluator) Eval(reducedValue null.Float) bool { - if !reducedValue.Valid { - return false - } - - floatValue := reducedValue.Float64 - - switch e.Type { - case "within_range": - return (e.Lower < floatValue && e.Upper > floatValue) || (e.Upper < floatValue && e.Lower > floatValue) - case "outside_range": - return (e.Upper < floatValue && e.Lower < floatValue) || (e.Upper > floatValue && e.Lower > floatValue) - } - - return false -} - -// NewAlertEvaluator is a factory function for returning -// an `AlertEvaluator` depending on the json model. -func NewAlertEvaluator(model *simplejson.Json) (AlertEvaluator, error) { - typ := model.Get("type").MustString() - if typ == "" { - return nil, fmt.Errorf("evaluator missing type property") - } - - if inSlice(typ, defaultTypes) { - return newThresholdEvaluator(typ, model) - } - - if inSlice(typ, rangedTypes) { - return newRangedEvaluator(typ, model) - } - - if typ == "no_value" { - return &noValueEvaluator{}, nil - } - - return nil, fmt.Errorf("evaluator invalid evaluator type: %s", typ) -} - -func inSlice(a string, list []string) bool { - for _, b := range list { - if b == a { - return true - } - } - return false -} - -// HumanThresholdType converts a threshold "type" string to a string that matches the UI -// so errors are less confusing. -func HumanThresholdType(typ string) string { - switch typ { - case "gt": - return "IS ABOVE" - case "lt": - return "IS BELOW" - case "within_range": - return "IS WITHIN RANGE" - case "outside_range": - return "IS OUTSIDE RANGE" - } - return "" -} diff --git a/pkg/services/alerting/conditions/evaluator_test.go b/pkg/services/alerting/conditions/evaluator_test.go deleted file mode 100644 index 937c052752547..0000000000000 --- a/pkg/services/alerting/conditions/evaluator_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package conditions - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/null" - "github.com/grafana/grafana/pkg/components/simplejson" -) - -func evaluatorScenario(t *testing.T, json string, reducedValue float64, datapoints ...float64) bool { - jsonModel, err := simplejson.NewJson([]byte(json)) - require.NoError(t, err) - - evaluator, err := NewAlertEvaluator(jsonModel) - require.NoError(t, err) - - return evaluator.Eval(null.FloatFrom(reducedValue)) -} - -func TestEvaluators(t *testing.T) { - t.Run("greater then", func(t *testing.T) { - require.True(t, evaluatorScenario(t, `{"type": "gt", "params": [1] }`, 3)) - require.False(t, evaluatorScenario(t, `{"type": "gt", "params": [3] }`, 1)) - }) - - t.Run("less then", func(t *testing.T) { - require.False(t, evaluatorScenario(t, `{"type": "lt", "params": [1] }`, 3)) - require.True(t, evaluatorScenario(t, `{"type": "lt", "params": [3] }`, 1)) - }) - - t.Run("within_range", func(t *testing.T) { - require.True(t, evaluatorScenario(t, `{"type": "within_range", "params": [1, 100] }`, 3)) - require.False(t, evaluatorScenario(t, `{"type": "within_range", "params": [1, 100] }`, 300)) - require.True(t, evaluatorScenario(t, `{"type": "within_range", "params": [100, 1] }`, 3)) - require.False(t, evaluatorScenario(t, `{"type": "within_range", "params": [100, 1] }`, 300)) - }) - - t.Run("outside_range", func(t *testing.T) { - require.True(t, evaluatorScenario(t, `{"type": "outside_range", "params": [1, 100] }`, 1000)) - require.False(t, evaluatorScenario(t, `{"type": "outside_range", "params": [1, 100] }`, 50)) - require.True(t, evaluatorScenario(t, `{"type": "outside_range", "params": [100, 1] }`, 1000)) - require.False(t, evaluatorScenario(t, `{"type": "outside_range", "params": [100, 1] }`, 50)) - }) - - t.Run("no_value", func(t *testing.T) { - t.Run("should be false if series have values", func(t *testing.T) { - require.False(t, evaluatorScenario(t, `{"type": "no_value", "params": [] }`, 50)) - }) - - t.Run("should be true when the series have no value", func(t *testing.T) { - jsonModel, err := simplejson.NewJson([]byte(`{"type": "no_value", "params": [] }`)) - require.NoError(t, err) - - evaluator, err := NewAlertEvaluator(jsonModel) - require.NoError(t, err) - - require.True(t, evaluator.Eval(null.FloatFromPtr(nil))) - }) - }) -} diff --git a/pkg/services/alerting/conditions/query.go b/pkg/services/alerting/conditions/query.go deleted file mode 100644 index ea090f1403d0e..0000000000000 --- a/pkg/services/alerting/conditions/query.go +++ /dev/null @@ -1,430 +0,0 @@ -package conditions - -import ( - gocontext "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/grafana/grafana-plugin-sdk-go/data" - - "github.com/grafana/grafana/pkg/components/null" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/datasources" - ngalertmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/tsdb/legacydata" - "github.com/grafana/grafana/pkg/tsdb/legacydata/interval" - "github.com/grafana/grafana/pkg/tsdb/prometheus" -) - -func init() { - alerting.RegisterCondition("query", func(model *simplejson.Json, index int) (alerting.Condition, error) { - return newQueryCondition(model, index) - }) -} - -// QueryCondition is responsible for issue and query, reduce the -// timeseries into single values and evaluate if they are firing or not. -type QueryCondition struct { - Index int - Query AlertQuery - Reducer *queryReducer - Evaluator AlertEvaluator - Operator string -} - -// AlertQuery contains information about what datasource a query -// should be sent to and the query object. -type AlertQuery struct { - Model *simplejson.Json - DatasourceID int64 - From string - To string -} - -// Eval evaluates the `QueryCondition`. -func (c *QueryCondition) Eval(context *alerting.EvalContext, requestHandler legacydata.RequestHandler) (*alerting.ConditionResult, error) { - timeRange := legacydata.NewDataTimeRange(c.Query.From, c.Query.To) - - seriesList, err := c.executeQuery(context, timeRange, requestHandler) - if err != nil { - return nil, err - } - - emptySeriesCount := 0 - evalMatchCount := 0 - - // matches represents all the series that violate the alert condition - var matches []*alerting.EvalMatch - // allMatches capture all evaluation matches irregardless on whether the condition is met or not - allMatches := make([]*alerting.EvalMatch, 0, len(seriesList)) - - for _, series := range seriesList { - reducedValue := c.Reducer.Reduce(series) - evalMatch := c.Evaluator.Eval(reducedValue) - - if !reducedValue.Valid { - emptySeriesCount++ - } - - if context.IsTestRun { - context.Logs = append(context.Logs, &alerting.ResultLogEntry{ - Message: fmt.Sprintf("Condition[%d]: Eval: %v, Metric: %s, Value: %s", c.Index, evalMatch, series.Name, reducedValue), - }) - } - - em := alerting.EvalMatch{ - Metric: series.Name, - Value: reducedValue, - Tags: series.Tags, - } - - allMatches = append(allMatches, &em) - - if evalMatch { - evalMatchCount++ - matches = append(matches, &em) - } - } - - // handle no series special case - if len(seriesList) == 0 { - // eval condition for null value - evalMatch := c.Evaluator.Eval(null.FloatFromPtr(nil)) - - if context.IsTestRun { - context.Logs = append(context.Logs, &alerting.ResultLogEntry{ - Message: fmt.Sprintf("Condition: Eval: %v, Query Returned No Series (reduced to null/no value)", evalMatch), - }) - } - - if evalMatch { - evalMatchCount++ - matches = append(matches, &alerting.EvalMatch{Metric: "NoData", Value: null.FloatFromPtr(nil)}) - } - } - - return &alerting.ConditionResult{ - Firing: evalMatchCount > 0, - NoDataFound: emptySeriesCount == len(seriesList), - Operator: c.Operator, - EvalMatches: matches, - AllMatches: allMatches, - }, nil -} - -func calculateInterval(timeRange legacydata.DataTimeRange, model *simplejson.Json, dsInfo *datasources.DataSource) (time.Duration, error) { - // if there is no min-interval specified in the datasource or in the dashboard-panel, - // the value of 1ms is used (this is how it is done in the dashboard-interval-calculation too, - // see https://github.com/grafana/grafana/blob/9a0040c0aeaae8357c650cec2ee644a571dddf3d/packages/grafana-data/src/datetime/rangeutil.ts#L264) - defaultMinInterval := time.Millisecond * 1 - - // interval.GetIntervalFrom has two problems (but they do not affect us here): - // - it returns the min-interval, so it should be called interval.GetMinIntervalFrom - // - it falls back to model.intervalMs. it should not, because that one is the real final - // interval-value calculated by the browser. but, in this specific case (old-alert), - // that value is not set, so the fallback never happens. - minInterval, err := interval.GetIntervalFrom(dsInfo, model, defaultMinInterval) - - if err != nil { - return time.Duration(0), err - } - - calc := interval.NewCalculator() - - interval := calc.Calculate(timeRange, minInterval) - - return interval.Value, nil -} - -func (c *QueryCondition) executeQuery(context *alerting.EvalContext, timeRange legacydata.DataTimeRange, - requestHandler legacydata.RequestHandler) (legacydata.DataTimeSeriesSlice, error) { - getDsInfo := &datasources.GetDataSourceQuery{ - ID: c.Query.DatasourceID, - OrgID: context.Rule.OrgID, - } - - dataSource, err := context.GetDataSource(context.Ctx, getDsInfo) - if err != nil { - return nil, fmt.Errorf("could not find datasource: %w", err) - } - - err = context.RequestValidator.Validate(dataSource.URL, nil) - if err != nil { - return nil, fmt.Errorf("access denied: %w", err) - } - - req, err := c.getRequestForAlertRule(dataSource, timeRange, context.IsDebug) - if err != nil { - return nil, fmt.Errorf("interval calculation failed: %w", err) - } - result := make(legacydata.DataTimeSeriesSlice, 0) - - if context.IsDebug { - data := simplejson.New() - if req.TimeRange != nil { - data.Set("from", req.TimeRange.GetFromAsMsEpoch()) - data.Set("to", req.TimeRange.GetToAsMsEpoch()) - } - - type queryDto struct { - RefID string `json:"refId"` - Model *simplejson.Json `json:"model"` - Datasource *simplejson.Json `json:"datasource"` - MaxDataPoints int64 `json:"maxDataPoints"` - IntervalMS int64 `json:"intervalMs"` - } - - queries := []*queryDto{} - for _, q := range req.Queries { - queries = append(queries, &queryDto{ - RefID: q.RefID, - Model: q.Model, - Datasource: simplejson.NewFromAny(map[string]any{ - "id": q.DataSource.ID, - "name": q.DataSource.Name, - }), - MaxDataPoints: q.MaxDataPoints, - IntervalMS: q.IntervalMS, - }) - } - - data.Set("queries", queries) - - context.Logs = append(context.Logs, &alerting.ResultLogEntry{ - Message: fmt.Sprintf("Condition[%d]: Query", c.Index), - Data: data, - }) - } - - resp, err := requestHandler.HandleRequest(context.Ctx, dataSource, req) - if err != nil { - return nil, toCustomError(err) - } - - for _, v := range resp.Results { - if v.Error != nil { - return nil, fmt.Errorf("request handler response error %v", v) - } - - // If there are dataframes but no series on the result - useDataframes := v.Dataframes != nil && (v.Series == nil || len(v.Series) == 0) - - if useDataframes { // convert the dataframes to plugins.DataTimeSeries - frames, err := v.Dataframes.Decoded() - if err != nil { - return nil, fmt.Errorf("%v: %w", "request handler failed to unmarshal arrow dataframes from bytes", err) - } - - for _, frame := range frames { - ss, err := FrameToSeriesSlice(frame) - if err != nil { - return nil, fmt.Errorf( - `request handler failed to convert dataframe "%v" to plugins.DataTimeSeriesSlice: %w`, frame.Name, err) - } - result = append(result, ss...) - } - } else { - result = append(result, v.Series...) - } - - queryResultData := map[string]any{} - - if context.IsTestRun { - queryResultData["series"] = result - } - - if context.IsDebug && v.Meta != nil { - queryResultData["meta"] = v.Meta - } - - if context.IsTestRun || context.IsDebug { - if useDataframes { - queryResultData["fromDataframe"] = true - } - context.Logs = append(context.Logs, &alerting.ResultLogEntry{ - Message: fmt.Sprintf("Condition[%d]: Query Result", c.Index), - Data: simplejson.NewFromAny(queryResultData), - }) - } - } - - return result, nil -} - -func (c *QueryCondition) getRequestForAlertRule(datasource *datasources.DataSource, timeRange legacydata.DataTimeRange, - debug bool) (legacydata.DataQuery, error) { - queryModel := c.Query.Model - - calculatedInterval, err := calculateInterval(timeRange, queryModel, datasource) - if err != nil { - return legacydata.DataQuery{}, err - } - - req := legacydata.DataQuery{ - TimeRange: &timeRange, - Queries: []legacydata.DataSubQuery{ - { - RefID: "A", - Model: queryModel, - DataSource: datasource, - QueryType: queryModel.Get("queryType").MustString(""), - MaxDataPoints: interval.DefaultRes, - IntervalMS: calculatedInterval.Milliseconds(), - }, - }, - Headers: map[string]string{ - ngalertmodels.FromAlertHeaderName: "true", - ngalertmodels.CacheSkipHeaderName: "true", - }, - Debug: debug, - } - - return req, nil -} - -func newQueryCondition(model *simplejson.Json, index int) (*QueryCondition, error) { - condition := QueryCondition{} - condition.Index = index - - queryJSON := model.Get("query") - - condition.Query.Model = queryJSON.Get("model") - condition.Query.From = queryJSON.Get("params").MustArray()[1].(string) - condition.Query.To = queryJSON.Get("params").MustArray()[2].(string) - - if err := validateFromValue(condition.Query.From); err != nil { - return nil, err - } - - if err := validateToValue(condition.Query.To); err != nil { - return nil, err - } - - condition.Query.DatasourceID = queryJSON.Get("datasourceId").MustInt64() - - reducerJSON := model.Get("reducer") - condition.Reducer = newSimpleReducer(reducerJSON.Get("type").MustString()) - - evaluatorJSON := model.Get("evaluator") - evaluator, err := NewAlertEvaluator(evaluatorJSON) - if err != nil { - return nil, fmt.Errorf("error in condition %v: %v", index, err) - } - condition.Evaluator = evaluator - - operatorJSON := model.Get("operator") - operator := operatorJSON.Get("type").MustString("and") - condition.Operator = operator - - return &condition, nil -} - -func validateFromValue(from string) error { - fromRaw := strings.Replace(from, "now-", "", 1) - - _, err := time.ParseDuration("-" + fromRaw) - return err -} - -func validateToValue(to string) error { - if to == "now" { - return nil - } else if strings.HasPrefix(to, "now-") { - withoutNow := strings.Replace(to, "now-", "", 1) - - _, err := time.ParseDuration("-" + withoutNow) - if err == nil { - return nil - } - } - - _, err := time.ParseDuration(to) - return err -} - -// FrameToSeriesSlice converts a frame that is a valid time series as per data.TimeSeriesSchema() -// to a DataTimeSeriesSlice. -func FrameToSeriesSlice(frame *data.Frame) (legacydata.DataTimeSeriesSlice, error) { - tsSchema := frame.TimeSeriesSchema() - if tsSchema.Type == data.TimeSeriesTypeNot { - // If no fields, or only a time field, create an empty plugins.DataTimeSeriesSlice with a single - // time series in order to trigger "no data" in alerting. - if frame.Rows() == 0 || (len(frame.Fields) == 1 && frame.Fields[0].Type().Time()) { - return legacydata.DataTimeSeriesSlice{{ - Name: frame.Name, - Points: make(legacydata.DataTimeSeriesPoints, 0), - }}, nil - } - return nil, fmt.Errorf("input frame is not recognized as a time series") - } - seriesCount := len(tsSchema.ValueIndices) - seriesSlice := make(legacydata.DataTimeSeriesSlice, 0, seriesCount) - timeField := frame.Fields[tsSchema.TimeIndex] - timeNullFloatSlice := make([]null.Float, timeField.Len()) - - for i := 0; i < timeField.Len(); i++ { // built slice of time as epoch ms in null floats - tStamp, err := timeField.FloatAt(i) - if err != nil { - return nil, err - } - timeNullFloatSlice[i] = null.FloatFrom(tStamp) - } - - for _, fieldIdx := range tsSchema.ValueIndices { // create a TimeSeries for each value Field - field := frame.Fields[fieldIdx] - ts := legacydata.DataTimeSeries{ - Points: make(legacydata.DataTimeSeriesPoints, field.Len()), - } - - if len(field.Labels) > 0 { - ts.Tags = field.Labels.Copy() - } - - switch { - case field.Config != nil && field.Config.DisplayName != "": - ts.Name = field.Config.DisplayName - case field.Config != nil && field.Config.DisplayNameFromDS != "": - ts.Name = field.Config.DisplayNameFromDS - case len(field.Labels) > 0: - // Tags are appended to the name so they are eventually included in EvalMatch's Metric property - // for display in notifications. - ts.Name = fmt.Sprintf("%v {%v}", field.Name, field.Labels.String()) - default: - ts.Name = field.Name - } - - for rowIdx := 0; rowIdx < field.Len(); rowIdx++ { // for each value in the field, make a TimePoint - val, err := field.FloatAt(rowIdx) - if err != nil { - return nil, fmt.Errorf( - "failed to convert frame to DataTimeSeriesSlice, can not convert value %v to float: %w", field.At(rowIdx), err) - } - ts.Points[rowIdx] = legacydata.DataTimePoint{ - null.FloatFrom(val), - timeNullFloatSlice[rowIdx], - } - } - - seriesSlice = append(seriesSlice, ts) - } - - return seriesSlice, nil -} - -func toCustomError(err error) error { - // is context timeout - if errors.Is(err, gocontext.DeadlineExceeded) { - return fmt.Errorf("alert execution exceeded the timeout") - } - - // is Prometheus error - if prometheus.IsAPIError(err) { - return prometheus.ConvertAPIError(err) - } - - // generic fallback - return fmt.Errorf("request handler error: %w", err) -} diff --git a/pkg/services/alerting/conditions/query_interval_test.go b/pkg/services/alerting/conditions/query_interval_test.go deleted file mode 100644 index 443b0f74d83f2..0000000000000 --- a/pkg/services/alerting/conditions/query_interval_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package conditions - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db/dbtest" - "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/datasources" - fd "github.com/grafana/grafana/pkg/services/datasources/fakes" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/validations" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" - "github.com/grafana/grafana/pkg/tsdb/legacydata" -) - -func TestQueryInterval(t *testing.T) { - t.Run("When evaluating query condition, regarding the interval value", func(t *testing.T) { - t.Run("Can handle interval-calculation with no panel-min-interval and no datasource-min-interval", func(t *testing.T) { - // no panel-min-interval in the queryModel - queryModel := `{"target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}` - - // no datasource-min-interval - var dataSourceJson *simplejson.Json = nil - - timeRange := "5m" - - verifier := func(query legacydata.DataSubQuery) { - // 5minutes timerange = 300000milliseconds; default-resolution is 1500pixels, - // so we should have 300000/1500 = 200milliseconds here - require.Equal(t, int64(200), query.IntervalMS) - require.Equal(t, intervalv2.DefaultRes, query.MaxDataPoints) - } - - applyScenario(t, timeRange, dataSourceJson, queryModel, verifier) - }) - t.Run("Can handle interval-calculation with panel-min-interval and no datasource-min-interval", func(t *testing.T) { - // panel-min-interval in the queryModel - queryModel := `{"interval":"123s", "target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}` - - // no datasource-min-interval - var dataSourceJson *simplejson.Json = nil - - timeRange := "5m" - - verifier := func(query legacydata.DataSubQuery) { - require.Equal(t, int64(123000), query.IntervalMS) - require.Equal(t, intervalv2.DefaultRes, query.MaxDataPoints) - } - - applyScenario(t, timeRange, dataSourceJson, queryModel, verifier) - }) - t.Run("Can handle interval-calculation with no panel-min-interval and datasource-min-interval", func(t *testing.T) { - // no panel-min-interval in the queryModel - queryModel := `{"target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}` - - // min-interval in datasource-json - dataSourceJson, err := simplejson.NewJson([]byte(`{ - "timeInterval": "71s" - }`)) - require.Nil(t, err) - - timeRange := "5m" - - verifier := func(query legacydata.DataSubQuery) { - require.Equal(t, int64(71000), query.IntervalMS) - require.Equal(t, intervalv2.DefaultRes, query.MaxDataPoints) - } - - applyScenario(t, timeRange, dataSourceJson, queryModel, verifier) - }) - t.Run("Can handle interval-calculation with both panel-min-interval and datasource-min-interval", func(t *testing.T) { - // panel-min-interval in the queryModel - queryModel := `{"interval":"19s", "target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}` - - // min-interval in datasource-json - dataSourceJson, err := simplejson.NewJson([]byte(`{ - "timeInterval": "71s" - }`)) - require.Nil(t, err) - - timeRange := "5m" - - verifier := func(query legacydata.DataSubQuery) { - // when both panel-min-interval and datasource-min-interval exists, - // panel-min-interval is used - require.Equal(t, int64(19000), query.IntervalMS) - require.Equal(t, intervalv2.DefaultRes, query.MaxDataPoints) - } - - applyScenario(t, timeRange, dataSourceJson, queryModel, verifier) - }) - - t.Run("Can handle no min-interval, and very small time-ranges, where the default-min-interval=1ms applies", func(t *testing.T) { - // no panel-min-interval in the queryModel - queryModel := `{"target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}` - - // no datasource-min-interval - var dataSourceJson *simplejson.Json = nil - - timeRange := "1s" - - verifier := func(query legacydata.DataSubQuery) { - // no min-interval exists, the default-min-interval will be used, - // and for such a short time-range this will cause the value to be 1millisecond. - require.Equal(t, int64(1), query.IntervalMS) - require.Equal(t, intervalv2.DefaultRes, query.MaxDataPoints) - } - - applyScenario(t, timeRange, dataSourceJson, queryModel, verifier) - }) - }) -} - -type queryIntervalTestContext struct { - result *alerting.EvalContext - condition *QueryCondition -} - -type queryIntervalVerifier func(query legacydata.DataSubQuery) - -type fakeIntervalTestReqHandler struct { - //nolint: staticcheck // legacydata.DataResponse deprecated - response legacydata.DataResponse - verifier queryIntervalVerifier -} - -//nolint:staticcheck // legacydata.DataResponse deprecated -func (rh fakeIntervalTestReqHandler) HandleRequest(ctx context.Context, dsInfo *datasources.DataSource, query legacydata.DataQuery) ( - legacydata.DataResponse, error) { - q := query.Queries[0] - rh.verifier(q) - return rh.response, nil -} - -//nolint:staticcheck // legacydata.DataResponse deprecated -func applyScenario(t *testing.T, timeRange string, dataSourceJsonData *simplejson.Json, queryModel string, verifier func(query legacydata.DataSubQuery)) { - t.Run("desc", func(t *testing.T) { - db := dbtest.NewFakeDB() - store := alerting.ProvideAlertStore(db, localcache.ProvideService(), &setting.Cfg{}, nil, featuremgmt.WithFeatures()) - - ctx := &queryIntervalTestContext{} - ctx.result = &alerting.EvalContext{ - Ctx: context.Background(), - Rule: &alerting.Rule{}, - RequestValidator: &validations.OSSPluginRequestValidator{}, - Store: store, - DatasourceService: &fd.FakeDataSourceService{ - DataSources: []*datasources.DataSource{ - {ID: 1, Type: datasources.DS_GRAPHITE, JsonData: dataSourceJsonData}, - }, - }, - } - - jsonModel, err := simplejson.NewJson([]byte(`{ - "type": "query", - "query": { - "params": ["A", "` + timeRange + `", "now"], - "datasourceId": 1, - "model": ` + queryModel + ` - }, - "reducer":{"type": "avg"}, - "evaluator":{"type": "gt", "params": [100]} - }`)) - require.Nil(t, err) - - condition, err := newQueryCondition(jsonModel, 0) - require.Nil(t, err) - - ctx.condition = condition - - qr := legacydata.DataQueryResult{} - - reqHandler := fakeIntervalTestReqHandler{ - response: legacydata.DataResponse{ - Results: map[string]legacydata.DataQueryResult{ - "A": qr, - }, - }, - verifier: verifier, - } - _, err = condition.Eval(ctx.result, reqHandler) - - require.Nil(t, err) - }) -} diff --git a/pkg/services/alerting/conditions/query_test.go b/pkg/services/alerting/conditions/query_test.go deleted file mode 100644 index 11e98da1acac4..0000000000000 --- a/pkg/services/alerting/conditions/query_test.go +++ /dev/null @@ -1,386 +0,0 @@ -package conditions - -import ( - "context" - "math" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/null" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db/dbtest" - "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/datasources" - fd "github.com/grafana/grafana/pkg/services/datasources/fakes" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/validations" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/legacydata" - "github.com/grafana/grafana/pkg/util" -) - -func newTimeSeriesPointsFromArgs(values ...float64) legacydata.DataTimeSeriesPoints { - points := make(legacydata.DataTimeSeriesPoints, 0) - - for i := 0; i < len(values); i += 2 { - points = append(points, legacydata.DataTimePoint{null.FloatFrom(values[i]), null.FloatFrom(values[i+1])}) - } - - return points -} - -func TestQueryCondition(t *testing.T) { - setup := func() *queryConditionTestContext { - ctx := &queryConditionTestContext{} - db := dbtest.NewFakeDB() - store := alerting.ProvideAlertStore(db, localcache.ProvideService(), &setting.Cfg{}, nil, featuremgmt.WithFeatures()) - ctx.reducer = `{"type":"avg"}` - ctx.evaluator = `{"type":"gt","params":[100]}` - ctx.result = &alerting.EvalContext{ - Ctx: context.Background(), - Rule: &alerting.Rule{}, - RequestValidator: &validations.OSSPluginRequestValidator{}, - Store: store, - DatasourceService: &fd.FakeDataSourceService{ - DataSources: []*datasources.DataSource{ - {ID: 1, Type: datasources.DS_GRAPHITE}, - }, - }, - } - return ctx - } - - t.Run("Can read query condition from json model", func(t *testing.T) { - ctx := setup() - _, err := ctx.exec(t) - require.Nil(t, err) - - require.Equal(t, "5m", ctx.condition.Query.From) - require.Equal(t, "now", ctx.condition.Query.To) - require.Equal(t, int64(1), ctx.condition.Query.DatasourceID) - - t.Run("Can read query reducer", func(t *testing.T) { - reducer := ctx.condition.Reducer - require.Equal(t, "avg", reducer.Type) - }) - - t.Run("Can read evaluator", func(t *testing.T) { - evaluator, ok := ctx.condition.Evaluator.(*thresholdEvaluator) - require.True(t, ok) - require.Equal(t, "gt", evaluator.Type) - }) - }) - - t.Run("should fire when avg is above 100", func(t *testing.T) { - ctx := setup() - points := newTimeSeriesPointsFromArgs(120, 0) - ctx.series = legacydata.DataTimeSeriesSlice{legacydata.DataTimeSeries{Name: "test1", Points: points}} - cr, err := ctx.exec(t) - - require.Nil(t, err) - require.True(t, cr.Firing) - }) - - t.Run("should fire when avg is above 100 on dataframe", func(t *testing.T) { - ctx := setup() - ctx.frame = data.NewFrame("", - data.NewField("time", nil, []time.Time{time.Now(), time.Now()}), - data.NewField("val", nil, []int64{120, 150}), - ) - cr, err := ctx.exec(t) - - require.Nil(t, err) - require.True(t, cr.Firing) - }) - - t.Run("Should not fire when avg is below 100", func(t *testing.T) { - ctx := setup() - points := newTimeSeriesPointsFromArgs(90, 0) - ctx.series = legacydata.DataTimeSeriesSlice{legacydata.DataTimeSeries{Name: "test1", Points: points}} - cr, err := ctx.exec(t) - - require.Nil(t, err) - require.False(t, cr.Firing) - }) - - t.Run("Should not fire when avg is below 100 on dataframe", func(t *testing.T) { - ctx := setup() - ctx.frame = data.NewFrame("", - data.NewField("time", nil, []time.Time{time.Now(), time.Now()}), - data.NewField("val", nil, []int64{12, 47}), - ) - cr, err := ctx.exec(t) - - require.Nil(t, err) - require.False(t, cr.Firing) - }) - - t.Run("Should fire if only first series matches", func(t *testing.T) { - ctx := setup() - ctx.series = legacydata.DataTimeSeriesSlice{ - legacydata.DataTimeSeries{Name: "test1", Points: newTimeSeriesPointsFromArgs(120, 0)}, - legacydata.DataTimeSeries{Name: "test2", Points: newTimeSeriesPointsFromArgs(0, 0)}, - } - cr, err := ctx.exec(t) - - require.Nil(t, err) - require.True(t, cr.Firing) - }) - - t.Run("No series", func(t *testing.T) { - ctx := setup() - t.Run("Should set NoDataFound when condition is gt", func(t *testing.T) { - ctx.series = legacydata.DataTimeSeriesSlice{} - cr, err := ctx.exec(t) - - require.Nil(t, err) - require.False(t, cr.Firing) - require.True(t, cr.NoDataFound) - }) - - t.Run("Should be firing when condition is no_value", func(t *testing.T) { - ctx.evaluator = `{"type": "no_value", "params": []}` - ctx.series = legacydata.DataTimeSeriesSlice{} - cr, err := ctx.exec(t) - - require.Nil(t, err) - require.True(t, cr.Firing) - }) - }) - - t.Run("Empty series", func(t *testing.T) { - ctx := setup() - t.Run("Should set Firing if eval match", func(t *testing.T) { - ctx.evaluator = `{"type": "no_value", "params": []}` - ctx.series = legacydata.DataTimeSeriesSlice{ - legacydata.DataTimeSeries{Name: "test1", Points: newTimeSeriesPointsFromArgs()}, - } - cr, err := ctx.exec(t) - - require.Nil(t, err) - require.True(t, cr.Firing) - }) - - t.Run("Should set NoDataFound both series are empty", func(t *testing.T) { - ctx.series = legacydata.DataTimeSeriesSlice{ - legacydata.DataTimeSeries{Name: "test1", Points: newTimeSeriesPointsFromArgs()}, - legacydata.DataTimeSeries{Name: "test2", Points: newTimeSeriesPointsFromArgs()}, - } - cr, err := ctx.exec(t) - - require.Nil(t, err) - require.True(t, cr.NoDataFound) - }) - - t.Run("Should set NoDataFound both series contains null", func(t *testing.T) { - ctx.series = legacydata.DataTimeSeriesSlice{ - legacydata.DataTimeSeries{Name: "test1", Points: legacydata.DataTimeSeriesPoints{legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(0)}}}, - legacydata.DataTimeSeries{Name: "test2", Points: legacydata.DataTimeSeriesPoints{legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(0)}}}, - } - cr, err := ctx.exec(t) - - require.Nil(t, err) - require.True(t, cr.NoDataFound) - }) - - t.Run("Should not set NoDataFound if one series is empty", func(t *testing.T) { - ctx.series = legacydata.DataTimeSeriesSlice{ - legacydata.DataTimeSeries{Name: "test1", Points: newTimeSeriesPointsFromArgs()}, - legacydata.DataTimeSeries{Name: "test2", Points: newTimeSeriesPointsFromArgs(120, 0)}, - } - cr, err := ctx.exec(t) - - require.Nil(t, err) - require.False(t, cr.NoDataFound) - }) - }) -} - -type queryConditionTestContext struct { - reducer string - evaluator string - series legacydata.DataTimeSeriesSlice - frame *data.Frame - result *alerting.EvalContext - condition *QueryCondition -} - -//nolint:staticcheck // legacydata.DataPlugin deprecated -func (ctx *queryConditionTestContext) exec(t *testing.T) (*alerting.ConditionResult, error) { - jsonModel, err := simplejson.NewJson([]byte(`{ - "type": "query", - "query": { - "params": ["A", "5m", "now"], - "datasourceId": 1, - "model": {"target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"} - }, - "reducer":` + ctx.reducer + `, - "evaluator":` + ctx.evaluator + ` - }`)) - require.Nil(t, err) - - condition, err := newQueryCondition(jsonModel, 0) - require.Nil(t, err) - - ctx.condition = condition - - qr := legacydata.DataQueryResult{ - Series: ctx.series, - } - - if ctx.frame != nil { - qr = legacydata.DataQueryResult{ - Dataframes: legacydata.NewDecodedDataFrames(data.Frames{ctx.frame}), - } - } - reqHandler := fakeReqHandler{ - response: legacydata.DataResponse{ - Results: map[string]legacydata.DataQueryResult{ - "A": qr, - }, - }, - } - - return condition.Eval(ctx.result, reqHandler) -} - -type fakeReqHandler struct { - //nolint: staticcheck // legacydata.DataPlugin deprecated - response legacydata.DataResponse -} - -//nolint:staticcheck // legacydata.DataPlugin deprecated -func (rh fakeReqHandler) HandleRequest(context.Context, *datasources.DataSource, legacydata.DataQuery) ( - legacydata.DataResponse, error) { - return rh.response, nil -} - -func TestFrameToSeriesSlice(t *testing.T) { - tests := []struct { - name string - frame *data.Frame - seriesSlice legacydata.DataTimeSeriesSlice - Err require.ErrorAssertionFunc - }{ - { - name: "a wide series", - frame: data.NewFrame("", - data.NewField("Time", nil, []time.Time{ - time.Date(2020, 1, 2, 3, 4, 0, 0, time.UTC), - time.Date(2020, 1, 2, 3, 4, 30, 0, time.UTC), - }), - data.NewField(`Values Int64s`, data.Labels{"Animal Factor": "cat"}, []*int64{ - nil, - util.Pointer(int64(3)), - }), - data.NewField(`Values Floats`, data.Labels{"Animal Factor": "sloth"}, []float64{ - 2.0, - 4.0, - })), - - seriesSlice: legacydata.DataTimeSeriesSlice{ - legacydata.DataTimeSeries{ - Name: "Values Int64s {Animal Factor=cat}", - Tags: map[string]string{"Animal Factor": "cat"}, - Points: legacydata.DataTimeSeriesPoints{ - legacydata.DataTimePoint{null.FloatFrom(math.NaN()), null.FloatFrom(1577934240000)}, - legacydata.DataTimePoint{null.FloatFrom(3), null.FloatFrom(1577934270000)}, - }, - }, - legacydata.DataTimeSeries{ - Name: "Values Floats {Animal Factor=sloth}", - Tags: map[string]string{"Animal Factor": "sloth"}, - Points: legacydata.DataTimeSeriesPoints{ - legacydata.DataTimePoint{null.FloatFrom(2), null.FloatFrom(1577934240000)}, - legacydata.DataTimePoint{null.FloatFrom(4), null.FloatFrom(1577934270000)}, - }, - }, - }, - Err: require.NoError, - }, - { - name: "empty wide series", - frame: data.NewFrame("", - data.NewField("Time", nil, []time.Time{}), - data.NewField(`Values Int64s`, data.Labels{"Animal Factor": "cat"}, []*int64{}), - data.NewField(`Values Floats`, data.Labels{"Animal Factor": "sloth"}, []float64{})), - - seriesSlice: legacydata.DataTimeSeriesSlice{ - legacydata.DataTimeSeries{ - Name: "Values Int64s {Animal Factor=cat}", - Tags: map[string]string{"Animal Factor": "cat"}, - Points: legacydata.DataTimeSeriesPoints{}, - }, - legacydata.DataTimeSeries{ - Name: "Values Floats {Animal Factor=sloth}", - Tags: map[string]string{"Animal Factor": "sloth"}, - Points: legacydata.DataTimeSeriesPoints{}, - }, - }, - Err: require.NoError, - }, - { - name: "empty labels", - frame: data.NewFrame("", - data.NewField("Time", data.Labels{}, []time.Time{}), - data.NewField(`Values`, data.Labels{}, []float64{})), - - seriesSlice: legacydata.DataTimeSeriesSlice{ - legacydata.DataTimeSeries{ - Name: "Values", - Points: legacydata.DataTimeSeriesPoints{}, - }, - }, - Err: require.NoError, - }, - { - name: "display name from data source", - frame: data.NewFrame("", - data.NewField("Time", data.Labels{}, []time.Time{}), - data.NewField(`Values`, data.Labels{"Rating": "10"}, []*int64{}).SetConfig(&data.FieldConfig{ - DisplayNameFromDS: "sloth", - })), - - seriesSlice: legacydata.DataTimeSeriesSlice{ - legacydata.DataTimeSeries{ - Name: "sloth", - Points: legacydata.DataTimeSeriesPoints{}, - Tags: map[string]string{"Rating": "10"}, - }, - }, - Err: require.NoError, - }, - { - name: "prefer display name over data source display name", - frame: data.NewFrame("", - data.NewField("Time", data.Labels{}, []time.Time{}), - data.NewField(`Values`, data.Labels{}, []*int64{}).SetConfig(&data.FieldConfig{ - DisplayName: "sloth #1", - DisplayNameFromDS: "sloth #2", - })), - - seriesSlice: legacydata.DataTimeSeriesSlice{ - legacydata.DataTimeSeries{ - Name: "sloth #1", - Points: legacydata.DataTimeSeriesPoints{}, - }, - }, - Err: require.NoError, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - seriesSlice, err := FrameToSeriesSlice(tt.frame) - tt.Err(t, err) - if diff := cmp.Diff(tt.seriesSlice, seriesSlice, cmpopts.EquateNaNs()); diff != "" { - t.Errorf("Result mismatch (-want +got):\n%s", diff) - } - }) - } -} diff --git a/pkg/services/alerting/conditions/reducer.go b/pkg/services/alerting/conditions/reducer.go deleted file mode 100644 index 6fb986cb5aa3a..0000000000000 --- a/pkg/services/alerting/conditions/reducer.go +++ /dev/null @@ -1,174 +0,0 @@ -package conditions - -import ( - "math" - "sort" - - "github.com/grafana/grafana/pkg/components/null" - "github.com/grafana/grafana/pkg/tsdb/legacydata" -) - -// queryReducer reduces a timeseries to a nullable float -type queryReducer struct { - - // Type is how the timeseries should be reduced. - // Ex avg, sum, max, min, count - Type string -} - -//nolint:gocyclo -func (s *queryReducer) Reduce(series legacydata.DataTimeSeries) null.Float { - if len(series.Points) == 0 { - return null.FloatFromPtr(nil) - } - - value := float64(0) - allNull := true - - switch s.Type { - case "avg": - validPointsCount := 0 - for _, point := range series.Points { - if isValid(point[0]) { - value += point[0].Float64 - validPointsCount++ - allNull = false - } - } - if validPointsCount > 0 { - value /= float64(validPointsCount) - } - case "sum": - for _, point := range series.Points { - if isValid(point[0]) { - value += point[0].Float64 - allNull = false - } - } - case "min": - value = math.MaxFloat64 - for _, point := range series.Points { - if isValid(point[0]) { - allNull = false - if value > point[0].Float64 { - value = point[0].Float64 - } - } - } - case "max": - value = -math.MaxFloat64 - for _, point := range series.Points { - if isValid(point[0]) { - allNull = false - if value < point[0].Float64 { - value = point[0].Float64 - } - } - } - case "count": - value = float64(len(series.Points)) - allNull = false - case "last": - points := series.Points - for i := len(points) - 1; i >= 0; i-- { - if isValid(points[i][0]) { - value = points[i][0].Float64 - allNull = false - break - } - } - case "median": - var values []float64 - for _, v := range series.Points { - if isValid(v[0]) { - allNull = false - values = append(values, v[0].Float64) - } - } - if len(values) >= 1 { - sort.Float64s(values) - length := len(values) - if length%2 == 1 { - value = values[(length-1)/2] - } else { - value = (values[(length/2)-1] + values[length/2]) / 2 - } - } - case "diff": - allNull, value = calculateDiff(series, allNull, value, diff) - case "diff_abs": - allNull, value = calculateDiff(series, allNull, value, diffAbs) - case "percent_diff": - allNull, value = calculateDiff(series, allNull, value, percentDiff) - case "percent_diff_abs": - allNull, value = calculateDiff(series, allNull, value, percentDiffAbs) - case "count_non_null": - for _, v := range series.Points { - if isValid(v[0]) { - value++ - } - } - - if value > 0 { - allNull = false - } - } - - if allNull { - return null.FloatFromPtr(nil) - } - - return null.FloatFrom(value) -} - -func newSimpleReducer(t string) *queryReducer { - return &queryReducer{Type: t} -} - -func calculateDiff(series legacydata.DataTimeSeries, allNull bool, value float64, fn func(float64, float64) float64) (bool, float64) { - var ( - points = series.Points - first float64 - i int - ) - // get the newest point - for i = len(points) - 1; i >= 0; i-- { - if isValid(points[i][0]) { - allNull = false - first = points[i][0].Float64 - break - } - } - if i >= 1 { - // get the oldest point - points = points[0:i] - for i := 0; i < len(points); i++ { - if isValid(points[i][0]) { - allNull = false - value = fn(first, points[i][0].Float64) - break - } - } - } - return allNull, value -} - -func isValid(f null.Float) bool { - return f.Valid && !math.IsNaN(f.Float64) -} - -var diff = func(newest, oldest float64) float64 { - return newest - oldest -} - -var diffAbs = func(newest, oldest float64) float64 { - return math.Abs(newest - oldest) -} - -var percentDiff = func(newest, oldest float64) float64 { - return (newest - oldest) / math.Abs(oldest) * 100 -} - -var percentDiffAbs = func(newest, oldest float64) float64 { - return math.Abs((newest - oldest) / oldest * 100) -} diff --git a/pkg/services/alerting/conditions/reducer_test.go b/pkg/services/alerting/conditions/reducer_test.go deleted file mode 100644 index c357a2e9c4bf9..0000000000000 --- a/pkg/services/alerting/conditions/reducer_test.go +++ /dev/null @@ -1,409 +0,0 @@ -package conditions - -import ( - "math" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/null" - "github.com/grafana/grafana/pkg/tsdb/legacydata" -) - -func TestSimpleReducer(t *testing.T) { - t.Run("sum", func(t *testing.T) { - result := testReducer("sum", 1, 2, 3) - require.Equal(t, float64(6), result) - }) - - t.Run("min", func(t *testing.T) { - result := testReducer("min", 3, 2, 1) - require.Equal(t, float64(1), result) - }) - - t.Run("max", func(t *testing.T) { - result := testReducer("max", 1, 2, 3) - require.Equal(t, float64(3), result) - }) - - t.Run("count", func(t *testing.T) { - result := testReducer("count", 1, 2, 3000) - require.Equal(t, float64(3), result) - }) - - t.Run("last", func(t *testing.T) { - result := testReducer("last", 1, 2, 3000) - require.Equal(t, float64(3000), result) - }) - - t.Run("median odd amount of numbers", func(t *testing.T) { - result := testReducer("median", 1, 2, 3000) - require.Equal(t, float64(2), result) - }) - - t.Run("median even amount of numbers", func(t *testing.T) { - result := testReducer("median", 1, 2, 4, 3000) - require.Equal(t, float64(3), result) - }) - - t.Run("median with one values", func(t *testing.T) { - result := testReducer("median", 1) - require.Equal(t, float64(1), result) - }) - - t.Run("median should ignore null values", func(t *testing.T) { - reducer := newSimpleReducer("median") - series := legacydata.DataTimeSeries{ - Name: "test time series", - } - - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(3)}) - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFrom(float64(1)), null.FloatFrom(4)}) - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFrom(float64(2)), null.FloatFrom(5)}) - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFrom(float64(3)), null.FloatFrom(6)}) - - result := reducer.Reduce(series) - require.Equal(t, true, result.Valid) - require.Equal(t, float64(2), result.Float64) - }) - - t.Run("avg", func(t *testing.T) { - result := testReducer("avg", 1, 2, 3) - require.Equal(t, float64(2), result) - }) - - t.Run("avg with only nulls", func(t *testing.T) { - reducer := newSimpleReducer("avg") - series := legacydata.DataTimeSeries{ - Name: "test time series", - } - - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) - require.Equal(t, false, reducer.Reduce(series).Valid) - }) - - t.Run("count_non_null", func(t *testing.T) { - t.Run("with null values and real values", func(t *testing.T) { - reducer := newSimpleReducer("count_non_null") - series := legacydata.DataTimeSeries{ - Name: "test time series", - } - - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFrom(3), null.FloatFrom(3)}) - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFrom(3), null.FloatFrom(4)}) - - require.Equal(t, true, reducer.Reduce(series).Valid) - require.Equal(t, 2.0, reducer.Reduce(series).Float64) - }) - - t.Run("with null values", func(t *testing.T) { - reducer := newSimpleReducer("count_non_null") - series := legacydata.DataTimeSeries{ - Name: "test time series", - } - - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) - - require.Equal(t, false, reducer.Reduce(series).Valid) - }) - }) - - t.Run("avg of number values and null values should ignore nulls", func(t *testing.T) { - reducer := newSimpleReducer("avg") - series := legacydata.DataTimeSeries{ - Name: "test time series", - } - - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFrom(3), null.FloatFrom(1)}) - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(3)}) - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFrom(3), null.FloatFrom(4)}) - - require.Equal(t, float64(3), reducer.Reduce(series).Float64) - }) - - // diff function Test Suite - t.Run("diff of one positive point", func(t *testing.T) { - result := testReducer("diff", 30) - require.Equal(t, float64(0), result) - }) - - t.Run("diff of one negative point", func(t *testing.T) { - result := testReducer("diff", -30) - require.Equal(t, float64(0), result) - }) - - t.Run("diff of two positive points[1]", func(t *testing.T) { - result := testReducer("diff", 30, 40) - require.Equal(t, float64(10), result) - }) - - t.Run("diff of two positive points[2]", func(t *testing.T) { - result := testReducer("diff", 30, 20) - require.Equal(t, float64(-10), result) - }) - - t.Run("diff of two negative points[1]", func(t *testing.T) { - result := testReducer("diff", -30, -40) - require.Equal(t, float64(-10), result) - }) - - t.Run("diff of two negative points[2]", func(t *testing.T) { - result := testReducer("diff", -30, -10) - require.Equal(t, float64(20), result) - }) - - t.Run("diff of one positive and one negative point", func(t *testing.T) { - result := testReducer("diff", 30, -40) - require.Equal(t, float64(-70), result) - }) - - t.Run("diff of one negative and one positive point", func(t *testing.T) { - result := testReducer("diff", -30, 40) - require.Equal(t, float64(70), result) - }) - - t.Run("diff of three positive points", func(t *testing.T) { - result := testReducer("diff", 30, 40, 50) - require.Equal(t, float64(20), result) - }) - - t.Run("diff of three negative points", func(t *testing.T) { - result := testReducer("diff", -30, -40, -50) - require.Equal(t, float64(-20), result) - }) - - t.Run("diff with only nulls", func(t *testing.T) { - reducer := newSimpleReducer("diff") - series := legacydata.DataTimeSeries{ - Name: "test time series", - } - - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) - - require.Equal(t, false, reducer.Reduce(series).Valid) - }) - - // diff_abs function Test Suite - t.Run("diff_abs of one positive point", func(t *testing.T) { - result := testReducer("diff_abs", 30) - require.Equal(t, float64(0), result) - }) - - t.Run("diff_abs of one negative point", func(t *testing.T) { - result := testReducer("diff_abs", -30) - require.Equal(t, float64(0), result) - }) - - t.Run("diff_abs of two positive points[1]", func(t *testing.T) { - result := testReducer("diff_abs", 30, 40) - require.Equal(t, float64(10), result) - }) - - t.Run("diff_abs of two positive points[2]", func(t *testing.T) { - result := testReducer("diff_abs", 30, 20) - require.Equal(t, float64(10), result) - }) - - t.Run("diff_abs of two negative points[1]", func(t *testing.T) { - result := testReducer("diff_abs", -30, -40) - require.Equal(t, float64(10), result) - }) - - t.Run("diff_abs of two negative points[2]", func(t *testing.T) { - result := testReducer("diff_abs", -30, -10) - require.Equal(t, float64(20), result) - }) - - t.Run("diff_abs of one positive and one negative point", func(t *testing.T) { - result := testReducer("diff_abs", 30, -40) - require.Equal(t, float64(70), result) - }) - - t.Run("diff_abs of one negative and one positive point", func(t *testing.T) { - result := testReducer("diff_abs", -30, 40) - require.Equal(t, float64(70), result) - }) - - t.Run("diff_abs of three positive points", func(t *testing.T) { - result := testReducer("diff_abs", 30, 40, 50) - require.Equal(t, float64(20), result) - }) - - t.Run("diff_abs of three negative points", func(t *testing.T) { - result := testReducer("diff_abs", -30, -40, -50) - require.Equal(t, float64(20), result) - }) - - t.Run("diff_abs with only nulls", func(t *testing.T) { - reducer := newSimpleReducer("diff_abs") - series := legacydata.DataTimeSeries{ - Name: "test time series", - } - - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) - - require.Equal(t, false, reducer.Reduce(series).Valid) - }) - - // percent_diff function Test Suite - t.Run("percent_diff of one positive point", func(t *testing.T) { - result := testReducer("percent_diff", 30) - require.Equal(t, float64(0), result) - }) - - t.Run("percent_diff of one negative point", func(t *testing.T) { - result := testReducer("percent_diff", -30) - require.Equal(t, float64(0), result) - }) - - t.Run("percent_diff of two positive points[1]", func(t *testing.T) { - result := testReducer("percent_diff", 30, 40) - require.Equal(t, float64(33.33333333333333), result) - }) - - t.Run("percent_diff of two positive points[2]", func(t *testing.T) { - result := testReducer("percent_diff", 30, 20) - require.Equal(t, float64(-33.33333333333333), result) - }) - - t.Run("percent_diff of two negative points[1]", func(t *testing.T) { - result := testReducer("percent_diff", -30, -40) - require.Equal(t, float64(-33.33333333333333), result) - }) - - t.Run("percent_diff of two negative points[2]", func(t *testing.T) { - result := testReducer("percent_diff", -30, -10) - require.Equal(t, float64(66.66666666666666), result) - }) - - t.Run("percent_diff of one positive and one negative point", func(t *testing.T) { - result := testReducer("percent_diff", 30, -40) - require.Equal(t, float64(-233.33333333333334), result) - }) - - t.Run("percent_diff of one negative and one positive point", func(t *testing.T) { - result := testReducer("percent_diff", -30, 40) - require.Equal(t, float64(233.33333333333334), result) - }) - - t.Run("percent_diff of three positive points", func(t *testing.T) { - result := testReducer("percent_diff", 30, 40, 50) - require.Equal(t, float64(66.66666666666666), result) - }) - - t.Run("percent_diff of three negative points", func(t *testing.T) { - result := testReducer("percent_diff", -30, -40, -50) - require.Equal(t, float64(-66.66666666666666), result) - }) - - t.Run("percent_diff with only nulls", func(t *testing.T) { - reducer := newSimpleReducer("percent_diff") - series := legacydata.DataTimeSeries{ - Name: "test time series", - } - - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) - - require.Equal(t, false, reducer.Reduce(series).Valid) - }) - - // percent_diff_abs function Test Suite - t.Run("percent_diff_abs_abs of one positive point", func(t *testing.T) { - result := testReducer("percent_diff_abs", 30) - require.Equal(t, float64(0), result) - }) - - t.Run("percent_diff_abs of one negative point", func(t *testing.T) { - result := testReducer("percent_diff_abs", -30) - require.Equal(t, float64(0), result) - }) - - t.Run("percent_diff_abs of two positive points[1]", func(t *testing.T) { - result := testReducer("percent_diff_abs", 30, 40) - require.Equal(t, float64(33.33333333333333), result) - }) - - t.Run("percent_diff_abs of two positive points[2]", func(t *testing.T) { - result := testReducer("percent_diff_abs", 30, 20) - require.Equal(t, float64(33.33333333333333), result) - }) - - t.Run("percent_diff_abs of two negative points[1]", func(t *testing.T) { - result := testReducer("percent_diff_abs", -30, -40) - require.Equal(t, float64(33.33333333333333), result) - }) - - t.Run("percent_diff_abs of two negative points[2]", func(t *testing.T) { - result := testReducer("percent_diff_abs", -30, -10) - require.Equal(t, float64(66.66666666666666), result) - }) - - t.Run("percent_diff_abs of one positive and one negative point", func(t *testing.T) { - result := testReducer("percent_diff_abs", 30, -40) - require.Equal(t, float64(233.33333333333334), result) - }) - - t.Run("percent_diff_abs of one negative and one positive point", func(t *testing.T) { - result := testReducer("percent_diff_abs", -30, 40) - require.Equal(t, float64(233.33333333333334), result) - }) - - t.Run("percent_diff_abs of three positive points", func(t *testing.T) { - result := testReducer("percent_diff_abs", 30, 40, 50) - require.Equal(t, float64(66.66666666666666), result) - }) - - t.Run("percent_diff_abs of three negative points", func(t *testing.T) { - result := testReducer("percent_diff_abs", -30, -40, -50) - require.Equal(t, float64(66.66666666666666), result) - }) - - t.Run("percent_diff_abs with only nulls", func(t *testing.T) { - reducer := newSimpleReducer("percent_diff_abs") - series := legacydata.DataTimeSeries{ - Name: "test time series", - } - - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) - - require.Equal(t, false, reducer.Reduce(series).Valid) - }) - - t.Run("min should work with NaNs", func(t *testing.T) { - result := testReducer("min", math.NaN(), math.NaN(), math.NaN()) - require.Equal(t, float64(0), result) - }) - - t.Run("isValid should treat NaN as invalid", func(t *testing.T) { - result := isValid(null.FloatFrom(math.NaN())) - require.False(t, result) - }) - - t.Run("isValid should treat invalid null.Float as invalid", func(t *testing.T) { - result := isValid(null.FloatFromPtr(nil)) - require.False(t, result) - }) -} - -func testReducer(reducerType string, datapoints ...float64) float64 { - reducer := newSimpleReducer(reducerType) - series := legacydata.DataTimeSeries{ - Name: "test time series", - } - - for idx := range datapoints { - series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFrom(datapoints[idx]), null.FloatFrom(1234134)}) - } - - return reducer.Reduce(series).Float64 -} diff --git a/pkg/services/alerting/engine.go b/pkg/services/alerting/engine.go deleted file mode 100644 index e81e7fadf1d71..0000000000000 --- a/pkg/services/alerting/engine.go +++ /dev/null @@ -1,288 +0,0 @@ -package alerting - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/benbjohnson/clock" - "github.com/prometheus/client_golang/prometheus" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "golang.org/x/sync/errgroup" - - "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/infra/usagestats" - "github.com/grafana/grafana/pkg/infra/usagestats/validator" - "github.com/grafana/grafana/pkg/services/annotations" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/encryption" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/services/rendering" - "github.com/grafana/grafana/pkg/services/validations" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/legacydata" - "github.com/grafana/grafana/pkg/util/ticker" -) - -// AlertEngine is the background process that -// schedules alert evaluations and makes sure notifications -// are sent. -type AlertEngine struct { - RenderService rendering.Service - RequestValidator validations.PluginRequestValidator - DataService legacydata.RequestHandler - Cfg *setting.Cfg - - execQueue chan *Job - ticker *ticker.T - scheduler scheduler - evalHandler evalHandler - ruleReader ruleReader - log log.Logger - resultHandler resultHandler - usageStatsService usagestats.Service - validator validator.Service - tracer tracing.Tracer - AlertStore AlertStore - dashAlertExtractor DashAlertExtractor - dashboardService dashboards.DashboardService - datasourceService datasources.DataSourceService - annotationsRepo annotations.Repository -} - -// IsDisabled returns true if the alerting service is disabled for this instance. -func (e *AlertEngine) IsDisabled() bool { - return setting.AlertingEnabled == nil || !*setting.AlertingEnabled || !setting.ExecuteAlerts || e.Cfg.UnifiedAlerting.IsEnabled() -} - -// ProvideAlertEngine returns a new AlertEngine. -func ProvideAlertEngine(renderer rendering.Service, requestValidator validations.PluginRequestValidator, - dataService legacydata.RequestHandler, usageStatsService usagestats.Service, validator validator.Service, encryptionService encryption.Internal, - notificationService *notifications.NotificationService, tracer tracing.Tracer, store AlertStore, cfg *setting.Cfg, - dashAlertExtractor DashAlertExtractor, dashboardService dashboards.DashboardService, cacheService *localcache.CacheService, dsService datasources.DataSourceService, annotationsRepo annotations.Repository) *AlertEngine { - e := &AlertEngine{ - Cfg: cfg, - RenderService: renderer, - RequestValidator: requestValidator, - DataService: dataService, - usageStatsService: usageStatsService, - validator: validator, - tracer: tracer, - AlertStore: store, - dashAlertExtractor: dashAlertExtractor, - dashboardService: dashboardService, - datasourceService: dsService, - annotationsRepo: annotationsRepo, - } - e.execQueue = make(chan *Job, 1000) - e.scheduler = newScheduler() - e.evalHandler = NewEvalHandler(e.DataService) - e.ruleReader = newRuleReader(store) - e.log = log.New("alerting.engine") - e.resultHandler = newResultHandler(e.RenderService, store, notificationService, encryptionService.GetDecryptedValue) - - e.registerUsageMetrics() - - return e -} - -// Run starts the alerting service background process. -func (e *AlertEngine) Run(ctx context.Context) error { - reg := prometheus.WrapRegistererWithPrefix("legacy_", prometheus.DefaultRegisterer) - e.ticker = ticker.New(clock.New(), 1*time.Second, ticker.NewMetrics(reg, "alerting")) - defer e.ticker.Stop() - alertGroup, ctx := errgroup.WithContext(ctx) - alertGroup.Go(func() error { return e.alertingTicker(ctx) }) - alertGroup.Go(func() error { return e.runJobDispatcher(ctx) }) - - err := alertGroup.Wait() - return err -} - -func (e *AlertEngine) alertingTicker(grafanaCtx context.Context) error { - defer func() { - if err := recover(); err != nil { - e.log.Error("Scheduler Panic: stopping alertingTicker", "error", err, "stack", log.Stack(1)) - } - }() - - tickIndex := 0 - - for { - select { - case <-grafanaCtx.Done(): - return grafanaCtx.Err() - case tick := <-e.ticker.C: - // TEMP SOLUTION update rules ever tenth tick - if tickIndex%10 == 0 { - e.scheduler.Update(e.ruleReader.fetch(grafanaCtx)) - } - - e.scheduler.Tick(tick, e.execQueue) - tickIndex++ - } - } -} - -func (e *AlertEngine) runJobDispatcher(grafanaCtx context.Context) error { - dispatcherGroup, alertCtx := errgroup.WithContext(grafanaCtx) - - for { - select { - case <-grafanaCtx.Done(): - return dispatcherGroup.Wait() - case job := <-e.execQueue: - dispatcherGroup.Go(func() error { return e.processJobWithRetry(alertCtx, job) }) - } - } -} - -var ( - unfinishedWorkTimeout = time.Second * 5 -) - -func (e *AlertEngine) processJobWithRetry(grafanaCtx context.Context, job *Job) error { - defer func() { - if err := recover(); err != nil { - e.log.Error("Alert Panic", "error", err, "stack", log.Stack(1)) - } - }() - - cancelChan := make(chan context.CancelFunc, setting.AlertingMaxAttempts*2) - attemptChan := make(chan int, 1) - - // Initialize with first attemptID=1 - attemptChan <- 1 - job.SetRunning(true) - - for { - select { - case <-grafanaCtx.Done(): - // In case grafana server context is cancel, let a chance to job processing - // to finish gracefully - by waiting a timeout duration - before forcing its end. - unfinishedWorkTimer := time.NewTimer(unfinishedWorkTimeout) - select { - case <-unfinishedWorkTimer.C: - return e.endJob(grafanaCtx.Err(), cancelChan, job) - case <-attemptChan: - return e.endJob(nil, cancelChan, job) - } - case attemptID, more := <-attemptChan: - if !more { - return e.endJob(nil, cancelChan, job) - } - go e.processJob(attemptID, attemptChan, cancelChan, job) - } - } -} - -func (e *AlertEngine) endJob(err error, cancelChan chan context.CancelFunc, job *Job) error { - job.SetRunning(false) - close(cancelChan) - for cancelFn := range cancelChan { - cancelFn() - } - return err -} - -func (e *AlertEngine) processJob(attemptID int, attemptChan chan int, cancelChan chan context.CancelFunc, job *Job) { - defer func() { - if err := recover(); err != nil { - e.log.Error("Alert Panic", "error", err, "stack", log.Stack(1)) - } - }() - - alertCtx, cancelFn := context.WithTimeout(context.Background(), setting.AlertingEvaluationTimeout) - cancelChan <- cancelFn - alertCtx, span := e.tracer.Start(alertCtx, "alert execution") - evalContext := NewEvalContext(alertCtx, job.Rule, e.RequestValidator, e.AlertStore, e.dashboardService, e.datasourceService, e.annotationsRepo) - evalContext.Ctx = alertCtx - - go func() { - defer func() { - if err := recover(); err != nil { - e.log.Error("Alert Panic", "error", err, "stack", log.Stack(1)) - span.SetStatus(codes.Error, "failed to execute alert rule. panic was recovered.") - span.RecordError(fmt.Errorf("%v", err)) - span.End() - close(attemptChan) - } - }() - - e.evalHandler.Eval(evalContext) - - span.SetAttributes( - attribute.Int64("alertId", evalContext.Rule.ID), - attribute.Int64("dashboardId", evalContext.Rule.DashboardID), - attribute.Bool("firing", evalContext.Firing), - attribute.Bool("nodatapoints", evalContext.NoDataFound), - attribute.Int("attemptID", attemptID), - ) - - if evalContext.Error != nil { - span.SetStatus(codes.Error, "alerting execution attempt failed") - span.RecordError(evalContext.Error) - - if attemptID < setting.AlertingMaxAttempts { - span.End() - e.log.Debug("Job Execution attempt triggered retry", "timeMs", evalContext.GetDurationMs(), "alertId", evalContext.Rule.ID, "name", evalContext.Rule.Name, "firing", evalContext.Firing, "attemptID", attemptID) - attemptChan <- (attemptID + 1) - return - } - } - - // create new context with timeout for notifications - resultHandleCtx, resultHandleCancelFn := context.WithTimeout(context.Background(), setting.AlertingNotificationTimeout) - cancelChan <- resultHandleCancelFn - - // override the context used for evaluation with a new context for notifications. - // This makes it possible for notifiers to execute when datasources - // don't respond within the timeout limit. We should rewrite this so notifications - // don't reuse the evalContext and get its own context. - evalContext.Ctx = resultHandleCtx - evalContext.Rule.State = evalContext.GetNewState() - if err := e.resultHandler.handle(evalContext); err != nil { - switch { - case errors.Is(err, context.Canceled): - e.log.Debug("Result handler returned context.Canceled") - case errors.Is(err, context.DeadlineExceeded): - e.log.Debug("Result handler returned context.DeadlineExceeded") - default: - e.log.Error("Failed to handle result", "err", err) - } - } - - span.End() - e.log.Debug("Job Execution completed", "timeMs", evalContext.GetDurationMs(), "alertId", evalContext.Rule.ID, "name", evalContext.Rule.Name, "firing", evalContext.Firing, "attemptID", attemptID) - close(attemptChan) - }() -} - -func (e *AlertEngine) registerUsageMetrics() { - e.usageStatsService.RegisterMetricsFunc(func(ctx context.Context) (map[string]interface{}, error) { - alertingUsageStats, err := e.QueryUsageStats(ctx) - if err != nil { - return nil, err - } - - alertingOtherCount := 0 - metrics := map[string]interface{}{} - - for dsType, usageCount := range alertingUsageStats.DatasourceUsage { - if e.validator.ShouldBeReported(ctx, dsType) { - metrics[fmt.Sprintf("stats.alerting.ds.%s.count", dsType)] = usageCount - } else { - alertingOtherCount += usageCount - } - } - - metrics["stats.alerting.ds.other.count"] = alertingOtherCount - - return metrics, nil - }) -} diff --git a/pkg/services/alerting/engine_integration_test.go b/pkg/services/alerting/engine_integration_test.go deleted file mode 100644 index bc114528e7b36..0000000000000 --- a/pkg/services/alerting/engine_integration_test.go +++ /dev/null @@ -1,178 +0,0 @@ -package alerting - -import ( - "context" - "errors" - "net" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/infra/usagestats" - "github.com/grafana/grafana/pkg/infra/usagestats/validator" - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - datasources "github.com/grafana/grafana/pkg/services/datasources/fakes" - encryptionprovider "github.com/grafana/grafana/pkg/services/encryption/provider" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" - "github.com/grafana/grafana/pkg/setting" -) - -func TestIntegrationEngineTimeouts(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - usMock := &usagestats.UsageStatsMock{T: t} - usValidatorMock := &validator.FakeUsageStatsValidator{} - - encProvider := encryptionprovider.ProvideEncryptionProvider() - encService, err := encryptionservice.ProvideEncryptionService(encProvider, usMock, setting.NewCfg()) - require.NoError(t, err) - - tracer := tracing.InitializeTracerForTest() - dsMock := &datasources.FakeDataSourceService{} - annotationsRepo := annotationstest.NewFakeAnnotationsRepo() - engine := ProvideAlertEngine(nil, nil, nil, usMock, usValidatorMock, encService, nil, tracer, nil, setting.NewCfg(), nil, nil, localcache.New(time.Minute, time.Minute), dsMock, annotationsRepo) - setting.AlertingNotificationTimeout = 30 * time.Second - setting.AlertingMaxAttempts = 3 - engine.resultHandler = &FakeResultHandler{} - job := &Job{running: true, Rule: &Rule{}} - - t.Run("Should trigger as many retries as needed", func(t *testing.T) { - t.Run("pended alert for datasource -> result handler should be worked", func(t *testing.T) { - // reduce alert timeout to test quickly - setting.AlertingEvaluationTimeout = 30 * time.Second - transportTimeoutInterval := 2 * time.Second - serverBusySleepDuration := 1 * time.Second - - evalHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration) - resultHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration) - engine.evalHandler = evalHandler - engine.resultHandler = resultHandler - - err := engine.processJobWithRetry(context.Background(), job) - require.Nil(t, err) - - require.Equal(t, true, evalHandler.EvalSucceed) - require.Equal(t, true, resultHandler.ResultHandleSucceed) - - // initialize for other tests. - setting.AlertingEvaluationTimeout = 2 * time.Second - engine.resultHandler = &FakeResultHandler{} - }) - }) -} - -type FakeCommonTimeoutHandler struct { - TransportTimeoutDuration time.Duration - ServerBusySleepDuration time.Duration - EvalSucceed bool - ResultHandleSucceed bool -} - -func NewFakeCommonTimeoutHandler(transportTimeoutDuration time.Duration, serverBusySleepDuration time.Duration) *FakeCommonTimeoutHandler { - return &FakeCommonTimeoutHandler{ - TransportTimeoutDuration: transportTimeoutDuration, - ServerBusySleepDuration: serverBusySleepDuration, - EvalSucceed: false, - ResultHandleSucceed: false, - } -} - -func (handler *FakeCommonTimeoutHandler) Eval(evalContext *EvalContext) { - // 1. prepare mock server - path := "/evaltimeout" - srv := runBusyServer(path, handler.ServerBusySleepDuration) - defer srv.Close() - - // 2. send requests - url := srv.URL + path - res, err := sendRequest(evalContext.Ctx, url, handler.TransportTimeoutDuration) - if res != nil { - defer func() { - if err := res.Body.Close(); err != nil { - logger.Warn("Error", "err", err) - } - }() - } - - if err != nil { - evalContext.Error = errors.New("Fake evaluation timeout test failure") - return - } - - if res.StatusCode == 200 { - handler.EvalSucceed = true - } - - evalContext.Error = errors.New("Fake evaluation timeout test failure; wrong response") -} - -func (handler *FakeCommonTimeoutHandler) handle(evalContext *EvalContext) error { - // 1. prepare mock server - path := "/resulthandle" - srv := runBusyServer(path, handler.ServerBusySleepDuration) - defer srv.Close() - - // 2. send requests - url := srv.URL + path - res, err := sendRequest(evalContext.Ctx, url, handler.TransportTimeoutDuration) - if res != nil { - defer func() { - if err := res.Body.Close(); err != nil { - logger.Warn("Error", "err", err) - } - }() - } - - if err != nil { - evalContext.Error = errors.New("Fake result handle timeout test failure") - return evalContext.Error - } - - if res.StatusCode == 200 { - handler.ResultHandleSucceed = true - return nil - } - - evalContext.Error = errors.New("Fake result handle timeout test failure; wrong response") - - return evalContext.Error -} - -func runBusyServer(path string, serverBusySleepDuration time.Duration) *httptest.Server { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - - mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { - time.Sleep(serverBusySleepDuration) - }) - - return server -} - -func sendRequest(context context.Context, url string, transportTimeoutInterval time.Duration) (resp *http.Response, err error) { - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - req = req.WithContext(context) - - transport := http.Transport{ - Dial: (&net.Dialer{ - Timeout: transportTimeoutInterval, - KeepAlive: transportTimeoutInterval, - }).Dial, - } - client := http.Client{ - Transport: &transport, - } - - return client.Do(req) -} diff --git a/pkg/services/alerting/engine_test.go b/pkg/services/alerting/engine_test.go deleted file mode 100644 index bcdd30ed15791..0000000000000 --- a/pkg/services/alerting/engine_test.go +++ /dev/null @@ -1,231 +0,0 @@ -package alerting - -import ( - "context" - "errors" - "math" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/infra/usagestats" - "github.com/grafana/grafana/pkg/infra/usagestats/validator" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/datasources" - fd "github.com/grafana/grafana/pkg/services/datasources/fakes" - encryptionprovider "github.com/grafana/grafana/pkg/services/encryption/provider" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" - "github.com/grafana/grafana/pkg/setting" -) - -type FakeEvalHandler struct { - SuccessCallID int // 0 means never success - CallNb int -} - -func NewFakeEvalHandler(successCallID int) *FakeEvalHandler { - return &FakeEvalHandler{ - SuccessCallID: successCallID, - CallNb: 0, - } -} - -func (handler *FakeEvalHandler) Eval(evalContext *EvalContext) { - handler.CallNb++ - if handler.CallNb != handler.SuccessCallID { - evalContext.Error = errors.New("Fake evaluation failure") - } -} - -type FakeResultHandler struct{} - -func (handler *FakeResultHandler) handle(evalContext *EvalContext) error { - return nil -} - -// A mock implementation of the AlertStore interface, allowing to override certain methods individually -type AlertStoreMock struct { - getAllAlerts func(context.Context, *models.GetAllAlertsQuery) ([]*models.Alert, error) - getAlertNotificationsWithUidToSend func(ctx context.Context, query *models.GetAlertNotificationsWithUidToSendQuery) ([]*models.AlertNotification, error) - getOrCreateNotificationState func(ctx context.Context, query *models.GetOrCreateNotificationStateQuery) (*models.AlertNotificationState, error) -} - -func (a *AlertStoreMock) GetAlertById(c context.Context, cmd *models.GetAlertByIdQuery) (res *models.Alert, err error) { - return nil, nil -} - -func (a *AlertStoreMock) GetAllAlertQueryHandler(c context.Context, cmd *models.GetAllAlertsQuery) (res []*models.Alert, err error) { - if a.getAllAlerts != nil { - return a.getAllAlerts(c, cmd) - } - return nil, nil -} - -func (a *AlertStoreMock) GetAlertNotificationUidWithId(c context.Context, query *models.GetAlertNotificationUidQuery) (res string, err error) { - return "", nil -} - -func (a *AlertStoreMock) GetAlertNotificationsWithUidToSend(c context.Context, cmd *models.GetAlertNotificationsWithUidToSendQuery) (res []*models.AlertNotification, err error) { - if a.getAlertNotificationsWithUidToSend != nil { - return a.getAlertNotificationsWithUidToSend(c, cmd) - } - return nil, nil -} - -func (a *AlertStoreMock) GetOrCreateAlertNotificationState(c context.Context, cmd *models.GetOrCreateNotificationStateQuery) (res *models.AlertNotificationState, err error) { - if a.getOrCreateNotificationState != nil { - return a.getOrCreateNotificationState(c, cmd) - } - return nil, nil -} - -func (a *AlertStoreMock) GetDashboardUIDById(_ context.Context, _ *dashboards.GetDashboardRefByIDQuery) error { - return nil -} - -func (a *AlertStoreMock) SetAlertNotificationStateToCompleteCommand(_ context.Context, _ *models.SetAlertNotificationStateToCompleteCommand) error { - return nil -} - -func (a *AlertStoreMock) SetAlertNotificationStateToPendingCommand(_ context.Context, _ *models.SetAlertNotificationStateToPendingCommand) error { - return nil -} - -func (a *AlertStoreMock) SetAlertState(_ context.Context, _ *models.SetAlertStateCommand) (res models.Alert, err error) { - return models.Alert{}, nil -} - -func (a *AlertStoreMock) GetAlertStatesForDashboard(_ context.Context, _ *models.GetAlertStatesForDashboardQuery) (res []*models.AlertStateInfoDTO, err error) { - return nil, nil -} - -func (a *AlertStoreMock) HandleAlertsQuery(context.Context, *models.GetAlertsQuery) (res []*models.AlertListItemDTO, err error) { - return nil, nil -} - -func (a *AlertStoreMock) PauseAlert(context.Context, *models.PauseAlertCommand) error { - return nil -} - -func (a *AlertStoreMock) PauseAllAlerts(context.Context, *models.PauseAllAlertCommand) error { - return nil -} - -func TestEngineProcessJob(t *testing.T) { - usMock := &usagestats.UsageStatsMock{T: t} - usValidatorMock := &validator.FakeUsageStatsValidator{} - - encProvider := encryptionprovider.ProvideEncryptionProvider() - encService, err := encryptionservice.ProvideEncryptionService(encProvider, usMock, setting.NewCfg()) - require.NoError(t, err) - tracer := tracing.InitializeTracerForTest() - - store := &AlertStoreMock{} - dsMock := &fd.FakeDataSourceService{ - DataSources: []*datasources.DataSource{{ID: 1, Type: datasources.DS_PROMETHEUS}}, - } - engine := ProvideAlertEngine(nil, nil, nil, usMock, usValidatorMock, encService, nil, tracer, store, setting.NewCfg(), nil, nil, localcache.New(time.Minute, time.Minute), dsMock, annotationstest.NewFakeAnnotationsRepo()) - setting.AlertingEvaluationTimeout = 30 * time.Second - setting.AlertingNotificationTimeout = 30 * time.Second - setting.AlertingMaxAttempts = 3 - engine.resultHandler = &FakeResultHandler{} - job := &Job{running: true, Rule: &Rule{}} - - t.Run("Should register usage metrics func", func(t *testing.T) { - store.getAllAlerts = func(ctx context.Context, q *models.GetAllAlertsQuery) (res []*models.Alert, err error) { - settings, err := simplejson.NewJson([]byte(`{"conditions": [{"query": { "datasourceId": 1}}]}`)) - if err != nil { - return nil, err - } - return []*models.Alert{{Settings: settings}}, nil - } - - report, err := usMock.GetUsageReport(context.Background()) - require.Nil(t, err) - - require.Equal(t, 1, report.Metrics["stats.alerting.ds.prometheus.count"]) - require.Equal(t, 0, report.Metrics["stats.alerting.ds.other.count"]) - }) - - t.Run("Should trigger retry if needed", func(t *testing.T) { - t.Run("error + not last attempt -> retry", func(t *testing.T) { - engine.evalHandler = NewFakeEvalHandler(0) - - for i := 1; i < setting.AlertingMaxAttempts; i++ { - attemptChan := make(chan int, 1) - cancelChan := make(chan context.CancelFunc, setting.AlertingMaxAttempts) - - engine.processJob(i, attemptChan, cancelChan, job) - nextAttemptID, more := <-attemptChan - - require.Equal(t, i+1, nextAttemptID) - require.Equal(t, true, more) - require.NotNil(t, <-cancelChan) - } - }) - - t.Run("error + last attempt -> no retry", func(t *testing.T) { - engine.evalHandler = NewFakeEvalHandler(0) - attemptChan := make(chan int, 1) - cancelChan := make(chan context.CancelFunc, setting.AlertingMaxAttempts) - - engine.processJob(setting.AlertingMaxAttempts, attemptChan, cancelChan, job) - nextAttemptID, more := <-attemptChan - - require.Equal(t, 0, nextAttemptID) - require.Equal(t, false, more) - require.NotNil(t, <-cancelChan) - }) - - t.Run("no error -> no retry", func(t *testing.T) { - engine.evalHandler = NewFakeEvalHandler(1) - attemptChan := make(chan int, 1) - cancelChan := make(chan context.CancelFunc, setting.AlertingMaxAttempts) - - engine.processJob(1, attemptChan, cancelChan, job) - nextAttemptID, more := <-attemptChan - - require.Equal(t, 0, nextAttemptID) - require.Equal(t, false, more) - require.NotNil(t, <-cancelChan) - }) - }) - - t.Run("Should trigger as many retries as needed", func(t *testing.T) { - t.Run("never success -> max retries number", func(t *testing.T) { - expectedAttempts := setting.AlertingMaxAttempts - evalHandler := NewFakeEvalHandler(0) - engine.evalHandler = evalHandler - - err := engine.processJobWithRetry(context.Background(), job) - require.Nil(t, err) - require.Equal(t, expectedAttempts, evalHandler.CallNb) - }) - - t.Run("always success -> never retry", func(t *testing.T) { - expectedAttempts := 1 - evalHandler := NewFakeEvalHandler(1) - engine.evalHandler = evalHandler - - err := engine.processJobWithRetry(context.Background(), job) - require.Nil(t, err) - require.Equal(t, expectedAttempts, evalHandler.CallNb) - }) - - t.Run("some errors before success -> some retries", func(t *testing.T) { - expectedAttempts := int(math.Ceil(float64(setting.AlertingMaxAttempts) / 2)) - evalHandler := NewFakeEvalHandler(expectedAttempts) - engine.evalHandler = evalHandler - - err := engine.processJobWithRetry(context.Background(), job) - require.Nil(t, err) - require.Equal(t, expectedAttempts, evalHandler.CallNb) - }) - }) -} diff --git a/pkg/services/alerting/eval_context.go b/pkg/services/alerting/eval_context.go deleted file mode 100644 index 452a1e0eb3861..0000000000000 --- a/pkg/services/alerting/eval_context.go +++ /dev/null @@ -1,281 +0,0 @@ -package alerting - -import ( - "context" - "fmt" - "regexp" - "time" - - "github.com/grafana/grafana/pkg/infra/log" - alertmodels "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/annotations" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/validations" - "github.com/grafana/grafana/pkg/setting" -) - -// EvalContext is the context object for an alert evaluation. -type EvalContext struct { - Firing bool - IsTestRun bool - IsDebug bool - EvalMatches []*EvalMatch - AllMatches []*EvalMatch - Logs []*ResultLogEntry - Error error - ConditionEvals string - StartTime time.Time - EndTime time.Time - Rule *Rule - Log log.Logger - - dashboardRef *dashboards.DashboardRef - - ImagePublicURL string - ImageOnDiskPath string - NoDataFound bool - PrevAlertState alertmodels.AlertStateType - - RequestValidator validations.PluginRequestValidator - - Ctx context.Context - - Store AlertStore - dashboardService dashboards.DashboardService - DatasourceService datasources.DataSourceService - annotationRepo annotations.Repository -} - -// NewEvalContext is the EvalContext constructor. -func NewEvalContext(alertCtx context.Context, rule *Rule, requestValidator validations.PluginRequestValidator, - alertStore AlertStore, dashboardService dashboards.DashboardService, dsService datasources.DataSourceService, annotationRepo annotations.Repository) *EvalContext { - return &EvalContext{ - Ctx: alertCtx, - StartTime: time.Now(), - Rule: rule, - Logs: make([]*ResultLogEntry, 0), - EvalMatches: make([]*EvalMatch, 0), - AllMatches: make([]*EvalMatch, 0), - Log: log.New("alerting.evalContext"), - PrevAlertState: rule.State, - RequestValidator: requestValidator, - Store: alertStore, - dashboardService: dashboardService, - DatasourceService: dsService, - annotationRepo: annotationRepo, - } -} - -// StateDescription contains visual information about the alert state. -type StateDescription struct { - Color string - Text string - Data string -} - -// GetStateModel returns the `StateDescription` based on current state. -func (c *EvalContext) GetStateModel() *StateDescription { - switch c.Rule.State { - case alertmodels.AlertStateOK: - return &StateDescription{ - Color: "#36a64f", - Text: "OK", - } - case alertmodels.AlertStateNoData: - return &StateDescription{ - Color: "#888888", - Text: "No Data", - } - case alertmodels.AlertStateAlerting: - return &StateDescription{ - Color: "#D63232", - Text: "Alerting", - } - case alertmodels.AlertStateUnknown: - return &StateDescription{ - Color: "#888888", - Text: "Unknown", - } - default: - panic("Unknown rule state for alert " + c.Rule.State) - } -} - -func (c *EvalContext) shouldUpdateAlertState() bool { - return c.Rule.State != c.PrevAlertState -} - -// GetDurationMs returns the duration of the alert evaluation. -func (c *EvalContext) GetDurationMs() float64 { - return float64(c.EndTime.Sub(c.StartTime).Nanoseconds()) / float64(time.Millisecond) -} - -// GetNotificationTitle returns the title of the alert rule including alert state. -func (c *EvalContext) GetNotificationTitle() string { - return "[" + c.GetStateModel().Text + "] " + c.Rule.Name -} - -// GetDashboardUID returns the dashboard uid for the alert rule. -func (c *EvalContext) GetDashboardUID() (*dashboards.DashboardRef, error) { - if c.dashboardRef != nil { - return c.dashboardRef, nil - } - - uidQuery := &dashboards.GetDashboardRefByIDQuery{ID: c.Rule.DashboardID} - uidQueryResult, err := c.dashboardService.GetDashboardUIDByID(c.Ctx, uidQuery) - if err != nil { - return nil, err - } - - c.dashboardRef = uidQueryResult - return c.dashboardRef, nil -} - -const urlFormat = "%s?tab=alert&viewPanel=%d&orgId=%d" - -// GetRuleURL returns the url to the dashboard containing the alert. -func (c *EvalContext) GetRuleURL() (string, error) { - if c.IsTestRun { - return setting.AppUrl, nil - } - - ref, err := c.GetDashboardUID() - if err != nil { - return "", err - } - return fmt.Sprintf(urlFormat, dashboards.GetFullDashboardURL(ref.UID, ref.Slug), c.Rule.PanelID, c.Rule.OrgID), nil -} - -// GetNewState returns the new state from the alert rule evaluation. -func (c *EvalContext) GetNewState() alertmodels.AlertStateType { - ns := getNewStateInternal(c) - if ns != alertmodels.AlertStateAlerting || c.Rule.For == 0 { - return ns - } - - since := time.Since(c.Rule.LastStateChange) - if c.PrevAlertState == alertmodels.AlertStatePending && since > c.Rule.For { - return alertmodels.AlertStateAlerting - } - - if c.PrevAlertState == alertmodels.AlertStateAlerting { - return alertmodels.AlertStateAlerting - } - - return alertmodels.AlertStatePending -} - -func getNewStateInternal(c *EvalContext) alertmodels.AlertStateType { - if c.Error != nil { - c.Log.Error("Alert Rule Result Error", - "ruleId", c.Rule.ID, - "name", c.Rule.Name, - "error", c.Error, - "changing state to", c.Rule.ExecutionErrorState.ToAlertState()) - - if c.Rule.ExecutionErrorState == alertmodels.ExecutionErrorKeepState { - return c.PrevAlertState - } - return c.Rule.ExecutionErrorState.ToAlertState() - } - - if c.Firing { - return alertmodels.AlertStateAlerting - } - - if c.NoDataFound { - c.Log.Info("Alert Rule returned no data", - "ruleId", c.Rule.ID, - "name", c.Rule.Name, - "changing state to", c.Rule.NoDataState.ToAlertState()) - - if c.Rule.NoDataState == alertmodels.NoDataKeepState { - return c.PrevAlertState - } - return c.Rule.NoDataState.ToAlertState() - } - - return alertmodels.AlertStateOK -} - -// evaluateNotificationTemplateFields will treat the alert evaluation rule's name and message fields as -// templates, and evaluate the templates using data from the alert evaluation's tags -func (c *EvalContext) evaluateNotificationTemplateFields() error { - matches := c.getTemplateMatches() - if len(matches) < 1 { - // if there are no series to parse the templates with, return - return nil - } - - templateDataMap, err := buildTemplateDataMap(matches) - if err != nil { - return err - } - - ruleMsg, err := evaluateTemplate(c.Rule.Message, templateDataMap) - if err != nil { - return err - } - c.Rule.Message = ruleMsg - - ruleName, err := evaluateTemplate(c.Rule.Name, templateDataMap) - if err != nil { - return err - } - c.Rule.Name = ruleName - - return nil -} - -func (c *EvalContext) GetDataSource(ctx context.Context, q *datasources.GetDataSourceQuery) (*datasources.DataSource, error) { - return c.DatasourceService.GetDataSource(ctx, q) -} - -// getTemplateMatches returns the values we should use to parse the templates -func (c *EvalContext) getTemplateMatches() []*EvalMatch { - // EvalMatches represent series violating the rule threshold, - // if we have any, this means the alert is firing and we should use this data to parse the templates. - if len(c.EvalMatches) > 0 { - return c.EvalMatches - } - - // If we don't have any alerting values, use all values to parse the templates. - return c.AllMatches -} - -func evaluateTemplate(s string, m map[string]string) (string, error) { - for k, v := range m { - re, err := regexp.Compile(fmt.Sprintf(`\${%s}`, regexp.QuoteMeta(k))) - if err != nil { - return "", err - } - s = re.ReplaceAllString(s, v) - } - - return s, nil -} - -// buildTemplateDataMap builds a map of alert evaluation tag names to a set of associated values (comma separated) -func buildTemplateDataMap(evalMatches []*EvalMatch) (map[string]string, error) { - var result = map[string]string{} - for _, match := range evalMatches { - for tagName, tagValue := range match.Tags { - // skip duplicate values - rVal, err := regexp.Compile(fmt.Sprintf(`\b%s\b`, regexp.QuoteMeta(tagValue))) - if err != nil { - return nil, err - } - rMatch := rVal.FindString(result[tagName]) - if len(rMatch) > 0 { - continue - } - if _, exists := result[tagName]; exists { - result[tagName] = fmt.Sprintf("%s, %s", result[tagName], tagValue) - } else { - result[tagName] = tagValue - } - } - } - return result, nil -} diff --git a/pkg/services/alerting/eval_context_test.go b/pkg/services/alerting/eval_context_test.go deleted file mode 100644 index d717dc5c4b4da..0000000000000 --- a/pkg/services/alerting/eval_context_test.go +++ /dev/null @@ -1,421 +0,0 @@ -package alerting - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - "github.com/grafana/grafana/pkg/services/validations" -) - -func TestStateIsUpdatedWhenNeeded(t *testing.T) { - ctx := NewEvalContext(context.Background(), &Rule{Conditions: []Condition{&conditionStub{firing: true}}}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - t.Run("ok -> alerting", func(t *testing.T) { - ctx.PrevAlertState = models.AlertStateOK - ctx.Rule.State = models.AlertStateAlerting - - if !ctx.shouldUpdateAlertState() { - t.Fatalf("expected should updated to be true") - } - }) - - t.Run("ok -> ok", func(t *testing.T) { - ctx.PrevAlertState = models.AlertStateOK - ctx.Rule.State = models.AlertStateOK - - if ctx.shouldUpdateAlertState() { - t.Fatalf("expected should updated to be false") - } - }) -} - -func TestGetStateFromEvalContext(t *testing.T) { - tcs := []struct { - name string - expected models.AlertStateType - applyFn func(ec *EvalContext) - }{ - { - name: "ok -> alerting", - expected: models.AlertStateAlerting, - applyFn: func(ec *EvalContext) { - ec.Firing = true - ec.PrevAlertState = models.AlertStateOK - }, - }, - { - name: "ok -> error(alerting)", - expected: models.AlertStateAlerting, - applyFn: func(ec *EvalContext) { - ec.PrevAlertState = models.AlertStateOK - ec.Error = errors.New("test error") - ec.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting - }, - }, - { - name: "ok -> pending. since its been firing for less than FOR", - expected: models.AlertStatePending, - applyFn: func(ec *EvalContext) { - ec.PrevAlertState = models.AlertStateOK - ec.Firing = true - ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 2) - ec.Rule.For = time.Minute * 5 - }, - }, - { - name: "ok -> pending. since it has to be pending longer than FOR and prev state is ok", - expected: models.AlertStatePending, - applyFn: func(ec *EvalContext) { - ec.PrevAlertState = models.AlertStateOK - ec.Firing = true - ec.Rule.LastStateChange = time.Now().Add(-(time.Hour * 5)) - ec.Rule.For = time.Minute * 2 - }, - }, - { - name: "pending -> alerting. since its been firing for more than FOR and prev state is pending", - expected: models.AlertStateAlerting, - applyFn: func(ec *EvalContext) { - ec.PrevAlertState = models.AlertStatePending - ec.Firing = true - ec.Rule.LastStateChange = time.Now().Add(-(time.Hour * 5)) - ec.Rule.For = time.Minute * 2 - }, - }, - { - name: "alerting -> alerting. should not update regardless of FOR", - expected: models.AlertStateAlerting, - applyFn: func(ec *EvalContext) { - ec.PrevAlertState = models.AlertStatePending - ec.Firing = true - ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 5) - ec.Rule.For = time.Minute * 2 - }, - }, - { - name: "ok -> ok. should not update regardless of FOR", - expected: models.AlertStateOK, - applyFn: func(ec *EvalContext) { - ec.PrevAlertState = models.AlertStateOK - ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 5) - ec.Rule.For = time.Minute * 2 - }, - }, - { - name: "ok -> error(keep_last)", - expected: models.AlertStateOK, - applyFn: func(ec *EvalContext) { - ec.PrevAlertState = models.AlertStateOK - ec.Error = errors.New("test error") - ec.Rule.ExecutionErrorState = models.ExecutionErrorKeepState - }, - }, - { - name: "pending -> error(keep_last)", - expected: models.AlertStatePending, - applyFn: func(ec *EvalContext) { - ec.PrevAlertState = models.AlertStatePending - ec.Error = errors.New("test error") - ec.Rule.ExecutionErrorState = models.ExecutionErrorKeepState - }, - }, - { - name: "ok -> no_data(alerting)", - expected: models.AlertStateAlerting, - applyFn: func(ec *EvalContext) { - ec.PrevAlertState = models.AlertStateOK - ec.Rule.NoDataState = models.NoDataSetAlerting - ec.NoDataFound = true - }, - }, - { - name: "ok -> no_data(keep_last)", - expected: models.AlertStateOK, - applyFn: func(ec *EvalContext) { - ec.PrevAlertState = models.AlertStateOK - ec.Rule.NoDataState = models.NoDataKeepState - ec.NoDataFound = true - }, - }, - { - name: "pending -> no_data(keep_last)", - expected: models.AlertStatePending, - applyFn: func(ec *EvalContext) { - ec.PrevAlertState = models.AlertStatePending - ec.Rule.NoDataState = models.NoDataKeepState - ec.NoDataFound = true - }, - }, - { - name: "pending -> no_data(alerting) with for duration have not passed", - expected: models.AlertStatePending, - applyFn: func(ec *EvalContext) { - ec.PrevAlertState = models.AlertStatePending - ec.Rule.NoDataState = models.NoDataSetAlerting - ec.NoDataFound = true - ec.Rule.For = time.Minute * 5 - ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 2) - }, - }, - { - name: "pending -> no_data(alerting) should set alerting since time passed FOR", - expected: models.AlertStateAlerting, - applyFn: func(ec *EvalContext) { - ec.PrevAlertState = models.AlertStatePending - ec.Rule.NoDataState = models.NoDataSetAlerting - ec.NoDataFound = true - ec.Rule.For = time.Minute * 2 - ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 5) - }, - }, - { - name: "pending -> error(alerting) with for duration have not passed ", - expected: models.AlertStatePending, - applyFn: func(ec *EvalContext) { - ec.PrevAlertState = models.AlertStatePending - ec.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting - ec.Error = errors.New("test error") - ec.Rule.For = time.Minute * 5 - ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 2) - }, - }, - { - name: "pending -> error(alerting) should set alerting since time passed FOR", - expected: models.AlertStateAlerting, - applyFn: func(ec *EvalContext) { - ec.PrevAlertState = models.AlertStatePending - ec.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting - ec.Error = errors.New("test error") - ec.Rule.For = time.Minute * 2 - ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 5) - }, - }, - } - - for _, tc := range tcs { - evalContext := NewEvalContext(context.Background(), &Rule{Conditions: []Condition{&conditionStub{firing: true}}}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - tc.applyFn(evalContext) - newState := evalContext.GetNewState() - assert.Equal(t, tc.expected, newState, "failed: %s \n expected '%s' have '%s'\n", tc.name, tc.expected, string(newState)) - } -} - -func TestBuildTemplateDataMap(t *testing.T) { - tcs := []struct { - name string - matches []*EvalMatch - expected map[string]string - }{ - { - name: "single match", - matches: []*EvalMatch{ - { - Tags: map[string]string{ - "InstanceId": "i-123456789", - "Percentile": "0.999", - }, - }, - }, - expected: map[string]string{ - "InstanceId": "i-123456789", - "Percentile": "0.999", - }, - }, - { - name: "matches with duplicate keys", - matches: []*EvalMatch{ - { - Tags: map[string]string{ - "InstanceId": "i-123456789", - }, - }, - { - Tags: map[string]string{ - "InstanceId": "i-987654321", - "Percentile": "0.999", - }, - }, - }, - expected: map[string]string{ - "InstanceId": "i-123456789, i-987654321", - "Percentile": "0.999", - }, - }, - { - name: "matches with duplicate keys and values", - matches: []*EvalMatch{ - { - Tags: map[string]string{ - "InstanceId": "i-123456789", - "Percentile": "0.999", - }, - }, - { - Tags: map[string]string{ - "InstanceId": "i-987654321", - "Percentile": "0.995", - }, - }, - { - Tags: map[string]string{ - "InstanceId": "i-987654321", - "Percentile": "0.999", - }, - }, - }, - expected: map[string]string{ - "InstanceId": "i-123456789, i-987654321", - "Percentile": "0.999, 0.995", - }, - }, - { - name: "a value and its substring for same key", - matches: []*EvalMatch{ - { - Tags: map[string]string{ - "Percentile": "0.9990", - }, - }, - { - Tags: map[string]string{ - "Percentile": "0.999", - }, - }, - }, - expected: map[string]string{ - "Percentile": "0.9990, 0.999", - }, - }, - } - - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - result, err := buildTemplateDataMap(tc.matches) - require.NoError(t, err) - assert.Equal(t, tc.expected, result, "failed: %s \n expected '%s' have '%s'\n", tc.name, tc.expected, result) - }) - } -} - -func TestEvaluateTemplate(t *testing.T) { - tcs := []struct { - name string - message string - data map[string]string - expected string - }{ - { - name: "matching terms", - message: "Degraded ${percentile} latency on ${instance}", - data: map[string]string{ - "instance": "i-123456789", - "percentile": "0.95", - }, - expected: "Degraded 0.95 latency on i-123456789", - }, - { - name: "non-matching terms", - message: "Degraded $percentile latency for endpoint ${ endpoint } on ${instance}", - data: map[string]string{ - "INSTANCE": "i-123456789", - "percentile": "0.95", - "endpoint": "/api/dashboard/123", - }, - expected: "Degraded $percentile latency for endpoint ${ endpoint } on ${instance}", - }, - } - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - result, err := evaluateTemplate(tc.message, tc.data) - require.NoError(t, err) - assert.Equal(t, tc.expected, result, "failed: %s \n expected '%s' have '%s'\n", tc.name, tc.expected, result) - }) - } -} - -func TestEvaluateNotificationTemplateFields(t *testing.T) { - tests := []struct { - name string - evalMatches []*EvalMatch - allMatches []*EvalMatch - expectedName string - expectedMessage string - }{ - { - "with evaluation matches", - []*EvalMatch{{ - Tags: map[string]string{"value1": "test1", "value2": "test2"}, - }}, - []*EvalMatch{{ - Tags: map[string]string{"value1": "test1", "value2": "test2"}, - }}, - "Rule name: test1", - "Rule message: test2", - }, - { - "missing key", - []*EvalMatch{{ - Tags: map[string]string{"value1": "test1", "value3": "test2"}, - }}, - []*EvalMatch{{ - Tags: map[string]string{"value1": "test1", "value3": "test2"}, - }}, - "Rule name: test1", - "Rule message: ${value2}", - }, - { - "no evaluation matches, with series", - []*EvalMatch{}, - []*EvalMatch{{ - Tags: map[string]string{"value1": "test1", "value2": "test2"}, - }}, - "Rule name: test1", - "Rule message: test2", - }, - { - "no evaluation matches, no series", - []*EvalMatch{}, - []*EvalMatch{}, - "Rule name: ${value1}", - "Rule message: ${value2}", - }, - } - - for _, test := range tests { - t.Run(test.name, func(tt *testing.T) { - evalContext := NewEvalContext(context.Background(), &Rule{Name: "Rule name: ${value1}", Message: "Rule message: ${value2}", - Conditions: []Condition{&conditionStub{firing: true}}}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - evalContext.EvalMatches = test.evalMatches - evalContext.AllMatches = test.allMatches - - err := evalContext.evaluateNotificationTemplateFields() - - require.NoError(tt, err) - require.Equal(tt, test.expectedName, evalContext.Rule.Name) - require.Equal(tt, test.expectedMessage, evalContext.Rule.Message) - }) - } -} - -func TestGetDurationFromEvalContext(t *testing.T) { - startTime, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", "2022-10-03 11:33:14.438803 +0200 CEST") - require.NoError(t, err) - - endTime, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", "2022-10-03 11:33:15.291075 +0200 CEST") - require.NoError(t, err) - - evalContext := EvalContext{ - StartTime: startTime, - EndTime: endTime, - } - - assert.Equal(t, float64(852.272), evalContext.GetDurationMs()) -} diff --git a/pkg/services/alerting/eval_handler.go b/pkg/services/alerting/eval_handler.go deleted file mode 100644 index f516d0718e67e..0000000000000 --- a/pkg/services/alerting/eval_handler.go +++ /dev/null @@ -1,81 +0,0 @@ -package alerting - -import ( - "strconv" - "strings" - "time" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/tsdb/legacydata" -) - -// DefaultEvalHandler is responsible for evaluating the alert rule. -type DefaultEvalHandler struct { - log log.Logger - alertJobTimeout time.Duration - requestHandler legacydata.RequestHandler -} - -// NewEvalHandler is the `DefaultEvalHandler` constructor. -func NewEvalHandler(requestHandler legacydata.RequestHandler) *DefaultEvalHandler { - return &DefaultEvalHandler{ - log: log.New("alerting.evalHandler"), - alertJobTimeout: time.Second * 5, - requestHandler: requestHandler, - } -} - -// Eval evaluated the alert rule. -func (e *DefaultEvalHandler) Eval(context *EvalContext) { - firing := true - noDataFound := true - conditionEvals := "" - - for i := 0; i < len(context.Rule.Conditions); i++ { - condition := context.Rule.Conditions[i] - cr, err := condition.Eval(context, e.requestHandler) - if err != nil { - context.Error = err - } - - // break if condition could not be evaluated - if context.Error != nil { - break - } - - if i == 0 { - firing = cr.Firing - noDataFound = cr.NoDataFound - } - - // calculating Firing based on operator - if cr.Operator == "or" { - firing = firing || cr.Firing - } else { - firing = firing && cr.Firing - } - - // We cannot evaluate the expression when one or more conditions are missing data - // and so noDataFound should be true if at least one condition returns no data, - // irrespective of the operator. - noDataFound = noDataFound || cr.NoDataFound - - if i > 0 { - conditionEvals = "[" + conditionEvals + " " + strings.ToUpper(cr.Operator) + " " + strconv.FormatBool(cr.Firing) + "]" - } else { - conditionEvals = strconv.FormatBool(firing) - } - - context.EvalMatches = append(context.EvalMatches, cr.EvalMatches...) - context.AllMatches = append(context.AllMatches, cr.AllMatches...) - } - - context.ConditionEvals = conditionEvals + " = " + strconv.FormatBool(firing) - context.Firing = firing - context.NoDataFound = noDataFound - context.EndTime = time.Now() - - elapsedTime := context.EndTime.Sub(context.StartTime).Nanoseconds() / int64(time.Millisecond) - metrics.MAlertingExecutionTime.Observe(float64(elapsedTime)) -} diff --git a/pkg/services/alerting/eval_handler_test.go b/pkg/services/alerting/eval_handler_test.go deleted file mode 100644 index df12cd335e291..0000000000000 --- a/pkg/services/alerting/eval_handler_test.go +++ /dev/null @@ -1,208 +0,0 @@ -package alerting - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - "github.com/grafana/grafana/pkg/services/validations" - "github.com/grafana/grafana/pkg/tsdb/legacydata" -) - -type conditionStub struct { - firing bool - operator string - matches []*EvalMatch - noData bool -} - -func (c *conditionStub) Eval(context *EvalContext, reqHandler legacydata.RequestHandler) (*ConditionResult, error) { - return &ConditionResult{Firing: c.firing, EvalMatches: c.matches, Operator: c.operator, NoDataFound: c.noData}, nil -} - -func TestAlertingEvaluationHandler(t *testing.T) { - handler := NewEvalHandler(nil) - - t.Run("Show return triggered with single passing condition", func(t *testing.T) { - context := NewEvalContext(context.Background(), &Rule{ - Conditions: []Condition{&conditionStub{ - firing: true, - }}, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - handler.Eval(context) - require.Equal(t, true, context.Firing) - require.Equal(t, "true = true", context.ConditionEvals) - }) - - t.Run("Show return triggered with single passing condition2", func(t *testing.T) { - context := NewEvalContext(context.Background(), &Rule{ - Conditions: []Condition{&conditionStub{firing: true, operator: "and"}}, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - handler.Eval(context) - require.Equal(t, true, context.Firing) - require.Equal(t, "true = true", context.ConditionEvals) - }) - - t.Run("Show return false with not passing asdf", func(t *testing.T) { - context := NewEvalContext(context.Background(), &Rule{ - Conditions: []Condition{ - &conditionStub{firing: true, operator: "and", matches: []*EvalMatch{{}, {}}}, - &conditionStub{firing: false, operator: "and"}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - handler.Eval(context) - require.Equal(t, false, context.Firing) - require.Equal(t, "[true AND false] = false", context.ConditionEvals) - }) - - t.Run("Show return true if any of the condition is passing with OR operator", func(t *testing.T) { - context := NewEvalContext(context.Background(), &Rule{ - Conditions: []Condition{ - &conditionStub{firing: true, operator: "and"}, - &conditionStub{firing: false, operator: "or"}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - handler.Eval(context) - require.Equal(t, true, context.Firing) - require.Equal(t, "[true OR false] = true", context.ConditionEvals) - }) - - t.Run("Show return false if any of the condition is failing with AND operator", func(t *testing.T) { - context := NewEvalContext(context.Background(), &Rule{ - Conditions: []Condition{ - &conditionStub{firing: true, operator: "and"}, - &conditionStub{firing: false, operator: "and"}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - handler.Eval(context) - require.Equal(t, false, context.Firing) - require.Equal(t, "[true AND false] = false", context.ConditionEvals) - }) - - t.Run("Show return true if one condition is failing with nested OR operator", func(t *testing.T) { - context := NewEvalContext(context.Background(), &Rule{ - Conditions: []Condition{ - &conditionStub{firing: true, operator: "and"}, - &conditionStub{firing: true, operator: "and"}, - &conditionStub{firing: false, operator: "or"}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - handler.Eval(context) - require.Equal(t, true, context.Firing) - require.Equal(t, "[[true AND true] OR false] = true", context.ConditionEvals) - }) - - t.Run("Show return false if one condition is passing with nested OR operator", func(t *testing.T) { - context := NewEvalContext(context.Background(), &Rule{ - Conditions: []Condition{ - &conditionStub{firing: true, operator: "and"}, - &conditionStub{firing: false, operator: "and"}, - &conditionStub{firing: false, operator: "or"}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - handler.Eval(context) - require.Equal(t, false, context.Firing) - require.Equal(t, "[[true AND false] OR false] = false", context.ConditionEvals) - }) - - t.Run("Show return false if a condition is failing with nested AND operator", func(t *testing.T) { - context := NewEvalContext(context.Background(), &Rule{ - Conditions: []Condition{ - &conditionStub{firing: true, operator: "and"}, - &conditionStub{firing: false, operator: "and"}, - &conditionStub{firing: true, operator: "and"}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - handler.Eval(context) - require.Equal(t, false, context.Firing) - require.Equal(t, "[[true AND false] AND true] = false", context.ConditionEvals) - }) - - t.Run("Show return true if a condition is passing with nested OR operator", func(t *testing.T) { - context := NewEvalContext(context.Background(), &Rule{ - Conditions: []Condition{ - &conditionStub{firing: true, operator: "and"}, - &conditionStub{firing: false, operator: "or"}, - &conditionStub{firing: true, operator: "or"}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - handler.Eval(context) - require.Equal(t, true, context.Firing) - require.Equal(t, "[[true OR false] OR true] = true", context.ConditionEvals) - }) - - t.Run("Should return false if no condition is firing using OR operator", func(t *testing.T) { - context := NewEvalContext(context.Background(), &Rule{ - Conditions: []Condition{ - &conditionStub{firing: false, operator: "or"}, - &conditionStub{firing: false, operator: "or"}, - &conditionStub{firing: false, operator: "or"}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - handler.Eval(context) - require.Equal(t, false, context.Firing) - require.Equal(t, "[[false OR false] OR false] = false", context.ConditionEvals) - }) - - // FIXME: What should the actual test case name be here? - t.Run("Should not return NoDataFound if all conditions have data and using OR", func(t *testing.T) { - context := NewEvalContext(context.Background(), &Rule{ - Conditions: []Condition{ - &conditionStub{operator: "or", noData: false}, - &conditionStub{operator: "or", noData: false}, - &conditionStub{operator: "or", noData: false}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - handler.Eval(context) - require.False(t, context.NoDataFound) - }) - - t.Run("Should return NoDataFound if one condition has no data", func(t *testing.T) { - context := NewEvalContext(context.Background(), &Rule{ - Conditions: []Condition{ - &conditionStub{operator: "and", noData: true}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - handler.Eval(context) - require.Equal(t, false, context.Firing) - require.True(t, context.NoDataFound) - }) - - t.Run("Should return no data if at least one condition has no data and using AND", func(t *testing.T) { - context := NewEvalContext(context.Background(), &Rule{ - Conditions: []Condition{ - &conditionStub{operator: "and", noData: true}, - &conditionStub{operator: "and", noData: false}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - handler.Eval(context) - require.True(t, context.NoDataFound) - }) - - t.Run("Should return no data if at least one condition has no data and using OR", func(t *testing.T) { - context := NewEvalContext(context.Background(), &Rule{ - Conditions: []Condition{ - &conditionStub{operator: "or", noData: true}, - &conditionStub{operator: "or", noData: false}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - handler.Eval(context) - require.True(t, context.NoDataFound) - }) -} diff --git a/pkg/services/alerting/extractor.go b/pkg/services/alerting/extractor.go deleted file mode 100644 index a5e4510a29a95..0000000000000 --- a/pkg/services/alerting/extractor.go +++ /dev/null @@ -1,306 +0,0 @@ -package alerting - -import ( - "context" - "encoding/json" - "errors" - "fmt" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/datasources/guardian" -) - -type DashAlertExtractor interface { - GetAlerts(ctx context.Context, dashAlertInfo DashAlertInfo) ([]*models.Alert, error) - ValidateAlerts(ctx context.Context, dashAlertInfo DashAlertInfo) error -} - -// DashAlertExtractorService extracts alerts from the dashboard json. -type DashAlertExtractorService struct { - dsGuardian guardian.DatasourceGuardianProvider - datasourceService datasources.DataSourceService - alertStore AlertStore - log log.Logger -} - -func ProvideDashAlertExtractorService(dsGuardian guardian.DatasourceGuardianProvider, datasourceService datasources.DataSourceService, store AlertStore) *DashAlertExtractorService { - return &DashAlertExtractorService{ - dsGuardian: dsGuardian, - datasourceService: datasourceService, - alertStore: store, - log: log.New("alerting.extractor"), - } -} - -func (e *DashAlertExtractorService) lookupQueryDataSource(ctx context.Context, panel *simplejson.Json, panelQuery *simplejson.Json, orgID int64) (*datasources.DataSource, error) { - dsName := "" - dsUid := "" - - ds, ok := panelQuery.CheckGet("datasource") - - if !ok { - ds = panel.Get("datasource") - } - - if name, err := ds.String(); err == nil { - dsName = name - } else if uid, ok := ds.CheckGet("uid"); ok { - dsUid = uid.MustString() - } - - if dsName == "" && dsUid == "" { - query := &datasources.GetDefaultDataSourceQuery{OrgID: orgID} - dataSource, err := e.datasourceService.GetDefaultDataSource(ctx, query) - if err != nil { - return nil, err - } - return dataSource, nil - } - - query := &datasources.GetDataSourceQuery{Name: dsName, UID: dsUid, OrgID: orgID} - dataSource, err := e.datasourceService.GetDataSource(ctx, query) - if err != nil { - return nil, err - } - - return dataSource, nil -} - -func findPanelQueryByRefID(panel *simplejson.Json, refID string) *simplejson.Json { - for _, targetsObj := range panel.Get("targets").MustArray() { - target := simplejson.NewFromAny(targetsObj) - - if target.Get("refId").MustString() == refID { - return target - } - } - return nil -} - -func copyJSON(in json.Marshaler) (*simplejson.Json, error) { - rawJSON, err := in.MarshalJSON() - if err != nil { - return nil, fmt.Errorf("JSON marshaling failed: %w", err) - } - - return simplejson.NewJson(rawJSON) -} - -// UAEnabled takes a context and returns true if Unified Alerting is enabled -// and false if it is disabled or the setting is not present in the context -type uaEnabledKeyType string - -const uaEnabledKey uaEnabledKeyType = "unified_alerting_enabled" - -func WithUAEnabled(ctx context.Context, enabled bool) context.Context { - retCtx := context.WithValue(ctx, uaEnabledKey, enabled) - return retCtx -} - -func UAEnabled(ctx context.Context) bool { - enabled, ok := ctx.Value(uaEnabledKey).(bool) - if !ok { - return false - } - return enabled -} - -func (e *DashAlertExtractorService) getAlertFromPanels(ctx context.Context, jsonWithPanels *simplejson.Json, validateAlertFunc func(*models.Alert) error, logTranslationFailures bool, dashAlertInfo DashAlertInfo) ([]*models.Alert, error) { - ret := make([]*models.Alert, 0) - - for _, panelObj := range jsonWithPanels.Get("panels").MustArray() { - panel := simplejson.NewFromAny(panelObj) - - collapsedJSON, collapsed := panel.CheckGet("collapsed") - // check if the panel is collapsed - if collapsed && collapsedJSON.MustBool() { - // extract alerts from sub panels for collapsed panels - alertSlice, err := e.getAlertFromPanels(ctx, panel, validateAlertFunc, logTranslationFailures, dashAlertInfo) - if err != nil { - return nil, err - } - - ret = append(ret, alertSlice...) - continue - } - - jsonAlert, hasAlert := panel.CheckGet("alert") - - if !hasAlert { - continue - } - - panelID, err := panel.Get("id").Int64() - if err != nil { - return nil, ValidationError{Reason: "A numeric panel id property is missing"} - } - - addIdentifiersToValidationError := func(err error) error { - if err == nil { - return nil - } - - var validationErr ValidationError - if ok := errors.As(err, &validationErr); ok { - ve := ValidationError{ - Reason: validationErr.Reason, - Err: validationErr.Err, - PanelID: panelID, - } - if dashAlertInfo.Dash != nil { - ve.DashboardID = dashAlertInfo.Dash.ID - } - return ve - } - return err - } - - // backward compatibility check, can be removed later - enabled, hasEnabled := jsonAlert.CheckGet("enabled") - if hasEnabled && !enabled.MustBool() { - continue - } - - frequency, err := getTimeDurationStringToSeconds(jsonAlert.Get("frequency").MustString()) - if err != nil { - return nil, addIdentifiersToValidationError(ValidationError{Reason: err.Error()}) - } - - rawFor := jsonAlert.Get("for").MustString() - - forValue, err := getForValue(rawFor) - if err != nil { - return nil, addIdentifiersToValidationError(err) - } - - alert := &models.Alert{ - DashboardID: dashAlertInfo.Dash.ID, - OrgID: dashAlertInfo.OrgID, - PanelID: panelID, - ID: jsonAlert.Get("id").MustInt64(), - Name: jsonAlert.Get("name").MustString(), - Handler: jsonAlert.Get("handler").MustInt64(), - Message: jsonAlert.Get("message").MustString(), - Frequency: frequency, - For: forValue, - } - - for _, condition := range jsonAlert.Get("conditions").MustArray() { - jsonCondition := simplejson.NewFromAny(condition) - - jsonQuery := jsonCondition.Get("query") - queryRefID := jsonQuery.Get("params").MustArray()[0].(string) - panelQuery := findPanelQueryByRefID(panel, queryRefID) - - if panelQuery == nil { - var reason string - if UAEnabled(ctx) { - reason = fmt.Sprintf("Alert on PanelId: %v refers to query(%s) that cannot be found. Legacy alerting queries are not able to be removed at this time in order to preserve the ability to rollback to previous versions of Grafana", alert.PanelID, queryRefID) - } else { - reason = fmt.Sprintf("Alert on PanelId: %v refers to query(%s) that cannot be found", alert.PanelID, queryRefID) - } - return nil, ValidationError{Reason: reason} - } - - datasource, err := e.lookupQueryDataSource(ctx, panel, panelQuery, dashAlertInfo.OrgID) - if err != nil { - return nil, err - } - - canQuery, err := e.dsGuardian.New(dashAlertInfo.OrgID, dashAlertInfo.User, *datasource).CanQuery(datasource.ID) - if err != nil { - return nil, err - } else if !canQuery { - return nil, datasources.ErrDataSourceAccessDenied - } - - jsonQuery.SetPath([]string{"datasourceId"}, datasource.ID) - - if interval, err := panel.Get("interval").String(); err == nil { - panelQuery.Set("interval", interval) - } - - jsonQuery.Set("model", panelQuery.Interface()) - } - - alert.Settings = jsonAlert - - // validate - _, err = NewRuleFromDBAlert(ctx, e.alertStore, alert, logTranslationFailures) - if err != nil { - return nil, err - } - - if err := validateAlertFunc(alert); err != nil { - return nil, err - } - - ret = append(ret, alert) - } - - return ret, nil -} - -func validateAlertRule(alert *models.Alert) error { - if !alert.ValidDashboardPanel() { - return ValidationError{Reason: fmt.Sprintf("Panel id is not correct, alertName=%v, panelId=%v", alert.Name, alert.PanelID)} - } - if !alert.ValidTags() { - return ValidationError{Reason: "Invalid tags, must be less than 100 characters"} - } - return nil -} - -// GetAlerts extracts alerts from the dashboard json and does full validation on the alert json data. -func (e *DashAlertExtractorService) GetAlerts(ctx context.Context, dashAlertInfo DashAlertInfo) ([]*models.Alert, error) { - return e.extractAlerts(ctx, validateAlertRule, true, dashAlertInfo) -} - -func (e *DashAlertExtractorService) extractAlerts(ctx context.Context, validateFunc func(alert *models.Alert) error, logTranslationFailures bool, dashAlertInfo DashAlertInfo) ([]*models.Alert, error) { - dashboardJSON, err := copyJSON(dashAlertInfo.Dash.Data) - if err != nil { - return nil, err - } - - alerts := make([]*models.Alert, 0) - - // We extract alerts from rows to be backwards compatible - // with the old dashboard json model. - rows := dashboardJSON.Get("rows").MustArray() - if len(rows) > 0 { - for _, rowObj := range rows { - row := simplejson.NewFromAny(rowObj) - a, err := e.getAlertFromPanels(ctx, row, validateFunc, logTranslationFailures, dashAlertInfo) - if err != nil { - return nil, err - } - - alerts = append(alerts, a...) - } - } else { - a, err := e.getAlertFromPanels(ctx, dashboardJSON, validateFunc, logTranslationFailures, dashAlertInfo) - if err != nil { - return nil, err - } - - alerts = append(alerts, a...) - } - - e.log.Debug("Extracted alerts from dashboard", "alertCount", len(alerts)) - return alerts, nil -} - -// ValidateAlerts validates alerts in the dashboard json but does not require a valid dashboard id -// in the first validation pass. -func (e *DashAlertExtractorService) ValidateAlerts(ctx context.Context, dashAlertInfo DashAlertInfo) error { - _, err := e.extractAlerts(ctx, func(alert *models.Alert) error { - if alert.OrgID == 0 || alert.PanelID == 0 { - return errors.New("missing OrgId, PanelId or both") - } - return nil - }, false, dashAlertInfo) - return err -} diff --git a/pkg/services/alerting/extractor_test.go b/pkg/services/alerting/extractor_test.go deleted file mode 100644 index df1f5489aae34..0000000000000 --- a/pkg/services/alerting/extractor_test.go +++ /dev/null @@ -1,313 +0,0 @@ -package alerting - -import ( - "context" - "os" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db/dbtest" - "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/datasources/guardian" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/setting" -) - -func TestAlertRuleExtraction(t *testing.T) { - RegisterCondition("query", func(model *simplejson.Json, index int) (Condition, error) { - return &FakeCondition{}, nil - }) - - // mock data - defaultDs := &datasources.DataSource{ID: 12, OrgID: 1, Name: "I am default", IsDefault: true, UID: "def-uid"} - graphite2Ds := &datasources.DataSource{ID: 15, OrgID: 1, Name: "graphite2", UID: "graphite2-uid"} - - json, err := os.ReadFile("./testdata/graphite-alert.json") - require.Nil(t, err) - - dsGuardian := guardian.ProvideGuardian() - - dsService := &fakeDatasourceService{ExpectedDatasource: defaultDs} - db := dbtest.NewFakeDB() - cfg := &setting.Cfg{} - store := ProvideAlertStore(db, localcache.ProvideService(), cfg, nil, featuremgmt.WithFeatures()) - extractor := ProvideDashAlertExtractorService(dsGuardian, dsService, store) - - t.Run("Parsing alert rules from dashboard json", func(t *testing.T) { - dashJSON, err := simplejson.NewJson(json) - require.Nil(t, err) - - getTarget := func(j *simplejson.Json) string { - rowObj := j.Get("rows").MustArray()[0] - row := simplejson.NewFromAny(rowObj) - panelObj := row.Get("panels").MustArray()[0] - panel := simplejson.NewFromAny(panelObj) - conditionObj := panel.Get("alert").Get("conditions").MustArray()[0] - condition := simplejson.NewFromAny(conditionObj) - return condition.Get("query").Get("model").Get("target").MustString() - } - - require.Equal(t, getTarget(dashJSON), "") - - _, _ = extractor.GetAlerts(context.Background(), DashAlertInfo{ - User: nil, - Dash: dashboards.NewDashboardFromJson(dashJSON), - OrgID: 1, - }) - - require.Equal(t, getTarget(dashJSON), "") - }) - - t.Run("Parsing and validating dashboard containing graphite alerts", func(t *testing.T) { - dashJSON, err := simplejson.NewJson(json) - require.Nil(t, err) - - dsService.ExpectedDatasource = &datasources.DataSource{ID: 12} - alerts, err := extractor.GetAlerts(context.Background(), DashAlertInfo{ - User: nil, - Dash: dashboards.NewDashboardFromJson(dashJSON), - OrgID: 1, - }) - - require.Nil(t, err) - - require.Len(t, alerts, 2) - - for _, v := range alerts { - require.EqualValues(t, v.DashboardID, 57) - require.NotEmpty(t, v.Name) - require.NotEmpty(t, v.Message) - - settings := simplejson.NewFromAny(v.Settings) - require.Equal(t, settings.Get("interval").MustString(""), "") - } - - require.EqualValues(t, alerts[0].Handler, 1) - require.EqualValues(t, alerts[1].Handler, 0) - - require.EqualValues(t, alerts[0].Frequency, 60) - require.EqualValues(t, alerts[1].Frequency, 60) - - require.EqualValues(t, alerts[0].PanelID, 3) - require.EqualValues(t, alerts[1].PanelID, 4) - - require.Equal(t, alerts[0].For, time.Minute*2) - require.Equal(t, alerts[1].For, time.Duration(0)) - - require.Equal(t, alerts[0].Name, "name1") - require.Equal(t, alerts[0].Message, "desc1") - require.Equal(t, alerts[1].Name, "name2") - require.Equal(t, alerts[1].Message, "desc2") - - condition := simplejson.NewFromAny(alerts[0].Settings.Get("conditions").MustArray()[0]) - query := condition.Get("query") - require.EqualValues(t, query.Get("datasourceId").MustInt64(), 12) - - condition = simplejson.NewFromAny(alerts[0].Settings.Get("conditions").MustArray()[0]) - model := condition.Get("query").Get("model") - require.Equal(t, model.Get("target").MustString(), "aliasByNode(statsd.fakesite.counters.session_start.desktop.count, 4)") - }) - - t.Run("Panels missing id should return error", func(t *testing.T) { - panelWithoutID, err := os.ReadFile("./testdata/panels-missing-id.json") - require.Nil(t, err) - - dashJSON, err := simplejson.NewJson(panelWithoutID) - require.Nil(t, err) - - _, err = extractor.GetAlerts(context.Background(), DashAlertInfo{ - User: nil, - Dash: dashboards.NewDashboardFromJson(dashJSON), - OrgID: 1, - }) - - require.NotNil(t, err) - }) - - t.Run("Panels missing id should return error", func(t *testing.T) { - panelWithIDZero, err := os.ReadFile("./testdata/panel-with-id-0.json") - require.Nil(t, err) - - dashJSON, err := simplejson.NewJson(panelWithIDZero) - require.Nil(t, err) - - _, err = extractor.GetAlerts(context.Background(), DashAlertInfo{ - User: nil, - Dash: dashboards.NewDashboardFromJson(dashJSON), - OrgID: 1, - }) - - require.NotNil(t, err) - }) - - t.Run("Cannot save panel with query that is referenced by legacy alerting", func(t *testing.T) { - panelWithQuery, err := os.ReadFile("./testdata/panel-with-bad-query-id.json") - require.Nil(t, err) - dashJSON, err := simplejson.NewJson(panelWithQuery) - require.Nil(t, err) - - _, err = extractor.GetAlerts(WithUAEnabled(context.Background(), true), DashAlertInfo{ - User: nil, - Dash: dashboards.NewDashboardFromJson(dashJSON), - OrgID: 1, - }) - require.Equal(t, "alert validation error: Alert on PanelId: 2 refers to query(B) that cannot be found. Legacy alerting queries are not able to be removed at this time in order to preserve the ability to rollback to previous versions of Grafana", err.Error()) - }) - - t.Run("Panel does not have datasource configured, use the default datasource", func(t *testing.T) { - panelWithoutSpecifiedDatasource, err := os.ReadFile("./testdata/panel-without-specified-datasource.json") - require.Nil(t, err) - - dashJSON, err := simplejson.NewJson(panelWithoutSpecifiedDatasource) - require.Nil(t, err) - - dsService.ExpectedDatasource = &datasources.DataSource{ID: 12} - alerts, err := extractor.GetAlerts(context.Background(), DashAlertInfo{ - User: nil, - Dash: dashboards.NewDashboardFromJson(dashJSON), - OrgID: 1, - }) - require.Nil(t, err) - - condition := simplejson.NewFromAny(alerts[0].Settings.Get("conditions").MustArray()[0]) - query := condition.Get("query") - require.EqualValues(t, query.Get("datasourceId").MustInt64(), 12) - }) - - t.Run("Parse alerts from dashboard without rows", func(t *testing.T) { - json, err := os.ReadFile("./testdata/v5-dashboard.json") - require.Nil(t, err) - - dashJSON, err := simplejson.NewJson(json) - require.Nil(t, err) - - alerts, err := extractor.GetAlerts(context.Background(), DashAlertInfo{ - User: nil, - Dash: dashboards.NewDashboardFromJson(dashJSON), - OrgID: 1, - }) - require.Nil(t, err) - - require.Len(t, alerts, 2) - }) - - t.Run("Alert notifications are in DB", func(t *testing.T) { - sqlStore := sqlStore{db: sqlstore.InitTestDB(t)} - - firstNotification := models.CreateAlertNotificationCommand{UID: "notifier1", OrgID: 1, Name: "1"} - _, err = sqlStore.CreateAlertNotificationCommand(context.Background(), &firstNotification) - require.Nil(t, err) - - secondNotification := models.CreateAlertNotificationCommand{UID: "notifier2", OrgID: 1, Name: "2"} - _, err = sqlStore.CreateAlertNotificationCommand(context.Background(), &secondNotification) - require.Nil(t, err) - - json, err := os.ReadFile("./testdata/influxdb-alert.json") - require.Nil(t, err) - - dashJSON, err := simplejson.NewJson(json) - require.Nil(t, err) - - alerts, err := extractor.GetAlerts(context.Background(), DashAlertInfo{ - User: nil, - Dash: dashboards.NewDashboardFromJson(dashJSON), - OrgID: 1, - }) - require.Nil(t, err) - - require.Len(t, alerts, 1) - - for _, alert := range alerts { - require.EqualValues(t, alert.DashboardID, 4) - - conditions := alert.Settings.Get("conditions").MustArray() - cond := simplejson.NewFromAny(conditions[0]) - - require.Equal(t, cond.Get("query").Get("model").Get("interval").MustString(), ">10s") - } - }) - - t.Run("Should be able to extract collapsed panels", func(t *testing.T) { - json, err := os.ReadFile("./testdata/collapsed-panels.json") - require.Nil(t, err) - - dashJSON, err := simplejson.NewJson(json) - require.Nil(t, err) - - dash := dashboards.NewDashboardFromJson(dashJSON) - - alerts, err := extractor.GetAlerts(context.Background(), DashAlertInfo{ - User: nil, - Dash: dash, - OrgID: 1, - }) - require.Nil(t, err) - - require.Len(t, alerts, 4) - }) - - t.Run("Parse and validate dashboard without id and containing an alert", func(t *testing.T) { - json, err := os.ReadFile("./testdata/dash-without-id.json") - require.Nil(t, err) - - dashJSON, err := simplejson.NewJson(json) - require.Nil(t, err) - - dashAlertInfo := DashAlertInfo{ - User: nil, - Dash: dashboards.NewDashboardFromJson(dashJSON), - OrgID: 1, - } - - err = extractor.ValidateAlerts(context.Background(), dashAlertInfo) - require.Nil(t, err) - - _, err = extractor.GetAlerts(context.Background(), dashAlertInfo) - require.Equal(t, err.Error(), "alert validation error: Panel id is not correct, alertName=Influxdb, panelId=1") - }) - - t.Run("Extract data source given new DataSourceRef object model", func(t *testing.T) { - json, err := os.ReadFile("./testdata/panel-with-datasource-ref.json") - require.Nil(t, err) - - dashJSON, err := simplejson.NewJson(json) - require.Nil(t, err) - - dsService.ExpectedDatasource = graphite2Ds - dashAlertInfo := DashAlertInfo{ - User: nil, - Dash: dashboards.NewDashboardFromJson(dashJSON), - OrgID: 1, - } - - err = extractor.ValidateAlerts(context.Background(), dashAlertInfo) - require.Nil(t, err) - - alerts, err := extractor.GetAlerts(context.Background(), dashAlertInfo) - require.Nil(t, err) - - condition := simplejson.NewFromAny(alerts[0].Settings.Get("conditions").MustArray()[0]) - query := condition.Get("query") - require.EqualValues(t, 15, query.Get("datasourceId").MustInt64()) - }) -} - -type fakeDatasourceService struct { - ExpectedDatasource *datasources.DataSource - datasources.DataSourceService -} - -func (f *fakeDatasourceService) GetDefaultDataSource(ctx context.Context, query *datasources.GetDefaultDataSourceQuery) (*datasources.DataSource, error) { - return f.ExpectedDatasource, nil -} - -func (f *fakeDatasourceService) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) { - return f.ExpectedDatasource, nil -} diff --git a/pkg/services/alerting/interfaces.go b/pkg/services/alerting/interfaces.go deleted file mode 100644 index 5aeb5077cd85b..0000000000000 --- a/pkg/services/alerting/interfaces.go +++ /dev/null @@ -1,65 +0,0 @@ -package alerting - -import ( - "context" - "time" - - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/tsdb/legacydata" -) - -type evalHandler interface { - Eval(evalContext *EvalContext) -} - -type scheduler interface { - Tick(time time.Time, execQueue chan *Job) - Update(rules []*Rule) -} - -// Notifier is responsible for sending alert notifications. -type Notifier interface { - Notify(evalContext *EvalContext) error - GetType() string - NeedsImage() bool - - // ShouldNotify checks this evaluation should send an alert notification - ShouldNotify(ctx context.Context, evalContext *EvalContext, notificationState *models.AlertNotificationState) bool - - GetNotifierUID() string - GetIsDefault() bool - GetSendReminder() bool - GetDisableResolveMessage() bool - GetFrequency() time.Duration -} - -type notifierState struct { - notifier Notifier - state *models.AlertNotificationState -} - -type notifierStateSlice []*notifierState - -func (notifiers notifierStateSlice) ShouldUploadImage() bool { - for _, ns := range notifiers { - if ns.notifier.NeedsImage() { - return true - } - } - - return false -} - -// ConditionResult is the result of a condition evaluation. -type ConditionResult struct { - Firing bool - NoDataFound bool - Operator string - EvalMatches []*EvalMatch - AllMatches []*EvalMatch -} - -// Condition is responsible for evaluating an alert condition. -type Condition interface { - Eval(result *EvalContext, requestHandler legacydata.RequestHandler) (*ConditionResult, error) -} diff --git a/pkg/services/alerting/models.go b/pkg/services/alerting/models.go deleted file mode 100644 index 391ca1f9e4b3f..0000000000000 --- a/pkg/services/alerting/models.go +++ /dev/null @@ -1,52 +0,0 @@ -package alerting - -import ( - "sync" - - "github.com/grafana/grafana/pkg/components/null" - "github.com/grafana/grafana/pkg/services/auth/identity" - "github.com/grafana/grafana/pkg/services/dashboards" -) - -// Job holds state about when the alert rule should be evaluated. -type Job struct { - Offset int64 - OffsetWait bool - Delay bool - running bool - Rule *Rule - runningLock sync.Mutex // Lock for running property which is used in the Scheduler and AlertEngine execution -} - -// GetRunning returns true if the job is running. A lock is taken and released on the Job to ensure atomicity. -func (j *Job) GetRunning() bool { - defer j.runningLock.Unlock() - j.runningLock.Lock() - return j.running -} - -// SetRunning sets the running property on the Job. A lock is taken and released on the Job to ensure atomicity. -func (j *Job) SetRunning(b bool) { - j.runningLock.Lock() - j.running = b - j.runningLock.Unlock() -} - -// ResultLogEntry represents log data for the alert evaluation. -type ResultLogEntry struct { - Message string - Data any -} - -// EvalMatch represents the series violating the threshold. -type EvalMatch struct { - Value null.Float `json:"value"` - Metric string `json:"metric"` - Tags map[string]string `json:"tags"` -} - -type DashAlertInfo struct { - User identity.Requester - Dash *dashboards.Dashboard - OrgID int64 -} diff --git a/pkg/services/alerting/models/alert.go b/pkg/services/alerting/models/alert.go deleted file mode 100644 index 7ea358f4bc086..0000000000000 --- a/pkg/services/alerting/models/alert.go +++ /dev/null @@ -1,206 +0,0 @@ -package models - -import ( - "fmt" - "time" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/tag" - "github.com/grafana/grafana/pkg/services/user" -) - -type AlertStateType string -type NoDataOption string -type ExecutionErrorOption string - -const ( - AlertStateNoData AlertStateType = "no_data" - AlertStatePaused AlertStateType = "paused" - AlertStateAlerting AlertStateType = "alerting" - AlertStateOK AlertStateType = "ok" - AlertStatePending AlertStateType = "pending" - AlertStateUnknown AlertStateType = "unknown" -) - -const ( - NoDataSetOK NoDataOption = "ok" - NoDataSetNoData NoDataOption = "no_data" - NoDataKeepState NoDataOption = "keep_state" - NoDataSetAlerting NoDataOption = "alerting" -) - -const ( - ExecutionErrorSetOk ExecutionErrorOption = "ok" - ExecutionErrorSetAlerting ExecutionErrorOption = "alerting" - ExecutionErrorKeepState ExecutionErrorOption = "keep_state" -) - -var ( - ErrCannotChangeStateOnPausedAlert = fmt.Errorf("cannot change state on pause alert") - ErrRequiresNewState = fmt.Errorf("update alert state requires a new state") -) - -func (s AlertStateType) IsValid() bool { - return s == AlertStateOK || - s == AlertStateNoData || - s == AlertStatePaused || - s == AlertStatePending || - s == AlertStateAlerting || - s == AlertStateUnknown -} - -func (s NoDataOption) IsValid() bool { - return s == NoDataSetNoData || s == NoDataSetAlerting || s == NoDataKeepState || s == NoDataSetOK -} - -func (s NoDataOption) ToAlertState() AlertStateType { - return AlertStateType(s) -} - -func (s ExecutionErrorOption) IsValid() bool { - return s == ExecutionErrorSetAlerting || s == ExecutionErrorKeepState || s == ExecutionErrorSetOk -} - -func (s ExecutionErrorOption) ToAlertState() AlertStateType { - return AlertStateType(s) -} - -// swagger:model LegacyAlert -type Alert struct { - ID int64 `xorm:"pk autoincr 'id'"` - Version int64 - OrgID int64 `xorm:"org_id"` - DashboardID int64 `xorm:"dashboard_id"` - PanelID int64 `xorm:"panel_id"` - Name string - Message string - Severity string // Unused - State AlertStateType - Handler int64 // Unused - Silenced bool - ExecutionError string - Frequency int64 - For time.Duration - - EvalData *simplejson.Json - NewStateDate time.Time - StateChanges int64 - - Created time.Time - Updated time.Time - - Settings *simplejson.Json -} - -func (a *Alert) ValidDashboardPanel() bool { - return a.OrgID != 0 && a.DashboardID != 0 && a.PanelID != 0 -} - -func (a *Alert) ValidTags() bool { - for _, tag := range a.GetTagsFromSettings() { - if len(tag.Key) > 100 || len(tag.Value) > 100 { - return false - } - } - return true -} - -func (a *Alert) ContainsUpdates(other *Alert) bool { - result := false - result = result || a.Name != other.Name - result = result || a.Message != other.Message - - if a.Settings != nil && other.Settings != nil { - json1, err1 := a.Settings.Encode() - json2, err2 := other.Settings.Encode() - - if err1 != nil || err2 != nil { - return false - } - - result = result || string(json1) != string(json2) - } - - // don't compare .State! That would be insane. - return result -} - -func (a *Alert) GetTagsFromSettings() []*tag.Tag { - tags := []*tag.Tag{} - if a.Settings != nil { - if data, ok := a.Settings.CheckGet("alertRuleTags"); ok { - for tagNameString, tagValue := range data.MustMap() { - // MustMap() already guarantees the return of a `map[string]any`. - // Therefore we only need to verify that tagValue is a String. - tagValueString := simplejson.NewFromAny(tagValue).MustString() - tags = append(tags, &tag.Tag{Key: tagNameString, Value: tagValueString}) - } - } - } - return tags -} - -type PauseAlertCommand struct { - OrgID int64 `xorm:"org_id"` - AlertIDs []int64 `xorm:"alert_ids"` - ResultCount int64 - Paused bool -} - -type PauseAllAlertCommand struct { - ResultCount int64 - Paused bool -} - -type SetAlertStateCommand struct { - AlertID int64 `xorm:"alert_id"` - OrgID int64 `xorm:"org_id"` - State AlertStateType - Error string - EvalData *simplejson.Json -} - -// Queries -type GetAlertsQuery struct { - OrgID int64 `xorm:"org_id"` - State []string - DashboardIDs []int64 `xorm:"dashboard_ids"` - PanelID int64 `xorm:"panel_id"` - Limit int64 - Query string - User *user.SignedInUser -} - -type GetAllAlertsQuery struct{} - -type GetAlertByIdQuery struct { - ID int64 `xorm:"id"` -} - -type GetAlertStatesForDashboardQuery struct { - OrgID int64 `xorm:"org_id"` - DashboardID int64 `xorm:"dashboard_id"` -} - -type AlertListItemDTO struct { - ID int64 `json:"id" xorm:"id"` - DashboardID int64 `json:"dashboardId" xorm:"dashboard_id"` - DashboardUID string `json:"dashboardUid" xorm:"dashboard_uid"` - DashboardSlug string `json:"dashboardSlug"` - PanelID int64 `json:"panelId" xorm:"panel_id"` - Name string `json:"name"` - State AlertStateType `json:"state"` - NewStateDate time.Time `json:"newStateDate"` - EvalDate time.Time `json:"evalDate"` - EvalData *simplejson.Json `json:"evalData"` - ExecutionError string `json:"executionError"` - URL string `json:"url" xorm:"url"` -} - -type AlertStateInfoDTO struct { - ID int64 `json:"id" xorm:"id"` - DashboardID int64 `json:"dashboardId" xorm:"dashboard_id"` - PanelID int64 `json:"panelId" xorm:"panel_id"` - State AlertStateType `json:"state"` - NewStateDate time.Time `json:"newStateDate"` -} diff --git a/pkg/services/alerting/models/alert_notification.go b/pkg/services/alerting/models/alert_notification.go deleted file mode 100644 index 57f165238df16..0000000000000 --- a/pkg/services/alerting/models/alert_notification.go +++ /dev/null @@ -1,154 +0,0 @@ -package models - -import ( - "errors" - "time" - - "github.com/grafana/grafana/pkg/components/simplejson" -) - -var ( - ErrAlertNotificationNotFound = errors.New("alert notification not found") - ErrNotificationFrequencyNotFound = errors.New("notification frequency not specified") - ErrAlertNotificationStateVersionConflict = errors.New("alert notification state update version conflict") - ErrAlertNotificationFailedGenerateUniqueUid = errors.New("failed to generate unique alert notification uid") - ErrAlertNotificationFailedTranslateUniqueID = errors.New("failed to translate Notification Id to Uid") - ErrAlertNotificationWithSameNameExists = errors.New("alert notification with same name already exists") - ErrAlertNotificationWithSameUIDExists = errors.New("alert notification with same uid already exists") -) - -type AlertNotificationStateType string - -var ( - AlertNotificationStatePending = AlertNotificationStateType("pending") - AlertNotificationStateCompleted = AlertNotificationStateType("completed") - AlertNotificationStateUnknown = AlertNotificationStateType("unknown") -) - -type AlertNotification struct { - ID int64 `json:"id" xorm:"pk autoincr 'id'"` - UID string `json:"-" xorm:"uid"` - OrgID int64 `json:"-" xorm:"org_id"` - Name string `json:"name"` - Type string `json:"type"` - SendReminder bool `json:"sendReminder"` - DisableResolveMessage bool `json:"disableResolveMessage"` - Frequency time.Duration `json:"frequency"` - IsDefault bool `json:"isDefault"` - Settings *simplejson.Json `json:"settings"` - SecureSettings map[string][]byte `json:"secureSettings"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` -} - -type CreateAlertNotificationCommand struct { - UID string `json:"uid"` - Name string `json:"name" binding:"Required"` - Type string `json:"type" binding:"Required"` - SendReminder bool `json:"sendReminder"` - DisableResolveMessage bool `json:"disableResolveMessage"` - Frequency string `json:"frequency"` - IsDefault bool `json:"isDefault"` - Settings *simplejson.Json `json:"settings"` - SecureSettings map[string]string `json:"secureSettings"` - - OrgID int64 `json:"-"` - EncryptedSecureSettings map[string][]byte `json:"-"` -} - -type UpdateAlertNotificationCommand struct { - ID int64 `json:"id" binding:"Required"` - UID string `json:"uid"` - Name string `json:"name" binding:"Required"` - Type string `json:"type" binding:"Required"` - SendReminder bool `json:"sendReminder"` - DisableResolveMessage bool `json:"disableResolveMessage"` - Frequency string `json:"frequency"` - IsDefault bool `json:"isDefault"` - Settings *simplejson.Json `json:"settings" binding:"Required"` - SecureSettings map[string]string `json:"secureSettings"` - - OrgID int64 `json:"-"` - EncryptedSecureSettings map[string][]byte `json:"-"` -} - -type UpdateAlertNotificationWithUidCommand struct { - UID string `json:"-"` - NewUID string `json:"uid"` - Name string `json:"name" binding:"Required"` - Type string `json:"type" binding:"Required"` - SendReminder bool `json:"sendReminder"` - DisableResolveMessage bool `json:"disableResolveMessage"` - Frequency string `json:"frequency"` - IsDefault bool `json:"isDefault"` - Settings *simplejson.Json `json:"settings" binding:"Required"` - SecureSettings map[string]string `json:"secureSettings"` - - OrgID int64 `json:"-"` -} - -type DeleteAlertNotificationCommand struct { - ID int64 - OrgID int64 -} -type DeleteAlertNotificationWithUidCommand struct { - UID string - OrgID int64 - - DeletedAlertNotificationID int64 -} - -type GetAlertNotificationUidQuery struct { - ID int64 - OrgID int64 -} - -type GetAlertNotificationsQuery struct { - Name string - ID int64 - OrgID int64 -} - -type GetAlertNotificationsWithUidQuery struct { - UID string - OrgID int64 -} - -type GetAlertNotificationsWithUidToSendQuery struct { - UIDs []string - OrgID int64 -} - -type GetAllAlertNotificationsQuery struct { - OrgID int64 -} - -type AlertNotificationState struct { - ID int64 `xorm:"pk autoincr 'id'"` - OrgID int64 `xorm:"org_id"` - AlertID int64 `xorm:"alert_id"` - NotifierID int64 `xorm:"notifier_id"` - State AlertNotificationStateType - Version int64 - UpdatedAt int64 - AlertRuleStateUpdatedVersion int64 -} - -type SetAlertNotificationStateToPendingCommand struct { - ID int64 - AlertRuleStateUpdatedVersion int64 - Version int64 - - ResultVersion int64 -} - -type SetAlertNotificationStateToCompleteCommand struct { - ID int64 - Version int64 -} - -type GetOrCreateNotificationStateQuery struct { - OrgID int64 - AlertID int64 - NotifierID int64 -} diff --git a/pkg/services/alerting/models/alert_test.go b/pkg/services/alerting/models/alert_test.go deleted file mode 100644 index cceac2296f443..0000000000000 --- a/pkg/services/alerting/models/alert_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package models - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/tag" -) - -func TestAlert_ContainsUpdates(t *testing.T) { - settings, err := simplejson.NewJson([]byte(`{ "field": "value" }`)) - require.NoError(t, err) - - alert1 := &Alert{ - Settings: settings, - Name: "Name", - Message: "Message", - } - - alert2 := &Alert{ - Settings: settings, - Name: "Name", - Message: "Message", - } - - assert.False(t, alert1.ContainsUpdates(alert2)) - - settingsUpdated, err := simplejson.NewJson([]byte(`{ "field": "newValue" }`)) - require.NoError(t, err) - - alert2.Settings = settingsUpdated - - assert.True(t, alert1.ContainsUpdates(alert2)) -} - -func TestAlert_GetTagsFromSettings(t *testing.T) { - settings, err := simplejson.NewJson([]byte(`{ - "field": "value", - "alertRuleTags": { - "foo": "bar", - "waldo": "fred", - "tagMap": { "mapValue": "value" } - } - }`)) - require.NoError(t, err) - - alert := &Alert{ - Settings: settings, - Name: "Name", - Message: "Message", - } - - expectedTags := []*tag.Tag{ - {Id: 0, Key: "foo", Value: "bar"}, - {Id: 0, Key: "waldo", Value: "fred"}, - {Id: 0, Key: "tagMap", Value: ""}, - } - actualTags := alert.GetTagsFromSettings() - - assert.ElementsMatch(t, actualTags, expectedTags) -} diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go deleted file mode 100644 index 593d4610512c4..0000000000000 --- a/pkg/services/alerting/notifier.go +++ /dev/null @@ -1,331 +0,0 @@ -package alerting - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/grafana/grafana/pkg/components/imguploader" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/models" - alertmodels "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/rendering" - "github.com/grafana/grafana/pkg/setting" -) - -// for stubbing in tests -// -//nolint:gocritic -var newImageUploaderProvider = func() (imguploader.ImageUploader, error) { - return imguploader.NewImageUploader() -} - -// NotifierPlugin holds meta information about a notifier. -type NotifierPlugin struct { - Type string `json:"type"` - Name string `json:"name"` - Heading string `json:"heading"` - Description string `json:"description"` - Info string `json:"info"` - Factory NotifierFactory `json:"-"` - Options []NotifierOption `json:"options"` -} - -// NotifierOption holds information about options specific for the NotifierPlugin. -type NotifierOption struct { - Element ElementType `json:"element"` - InputType InputType `json:"inputType"` - Label string `json:"label"` - Description string `json:"description"` - Placeholder string `json:"placeholder"` - PropertyName string `json:"propertyName"` - SelectOptions []SelectOption `json:"selectOptions"` - ShowWhen ShowWhen `json:"showWhen"` - Required bool `json:"required"` - ValidationRule string `json:"validationRule"` - Secure bool `json:"secure"` - DependsOn string `json:"dependsOn"` -} - -// InputType is the type of input that can be rendered in the frontend. -type InputType string - -const ( - // InputTypeText will render a text field in the frontend - InputTypeText = "text" - // InputTypePassword will render a password field in the frontend - InputTypePassword = "password" -) - -// ElementType is the type of element that can be rendered in the frontend. -type ElementType string - -const ( - // ElementTypeInput will render an input - ElementTypeInput = "input" - // ElementTypeSelect will render a select - ElementTypeSelect = "select" - // ElementTypeCheckbox will render a checkbox - ElementTypeCheckbox = "checkbox" - // ElementTypeTextArea will render a textarea - ElementTypeTextArea = "textarea" -) - -// SelectOption is a simple type for Options that have dropdown options. Should be used when Element is ElementTypeSelect. -type SelectOption struct { - Value string `json:"value"` - Label string `json:"label"` -} - -// ShowWhen holds information about when options are dependant on other options. -type ShowWhen struct { - Field string `json:"field"` - Is string `json:"is"` -} - -func newNotificationService(renderService rendering.Service, sqlStore AlertStore, notificationSvc *notifications.NotificationService, decryptFn GetDecryptedValueFn) *notificationService { - return ¬ificationService{ - log: log.New("alerting.notifier"), - renderService: renderService, - sqlStore: sqlStore, - notificationService: notificationSvc, - decryptFn: decryptFn, - } -} - -type notificationService struct { - log log.Logger - renderService rendering.Service - sqlStore AlertStore - notificationService *notifications.NotificationService - decryptFn GetDecryptedValueFn -} - -func (n *notificationService) SendIfNeeded(evalCtx *EvalContext) error { - notifierStates, err := n.getNeededNotifiers(evalCtx.Rule.OrgID, evalCtx.Rule.Notifications, evalCtx) - if err != nil { - n.log.Error("Failed to get alert notifiers", "error", err) - return err - } - - if len(notifierStates) == 0 { - return nil - } - - if notifierStates.ShouldUploadImage() { - // Create a copy of EvalContext and give it a new, shorter, timeout context to upload the image - uploadEvalCtx := *evalCtx - timeout := setting.AlertingNotificationTimeout / 2 - var uploadCtxCancel func() - uploadEvalCtx.Ctx, uploadCtxCancel = context.WithTimeout(evalCtx.Ctx, timeout) - - // Try to upload the image without consuming all the time allocated for EvalContext - if err = n.renderAndUploadImage(&uploadEvalCtx, timeout); err != nil { - n.log.Error("Failed to render and upload alert panel image.", "ruleId", uploadEvalCtx.Rule.ID, "error", err) - } - uploadCtxCancel() - evalCtx.ImageOnDiskPath = uploadEvalCtx.ImageOnDiskPath - evalCtx.ImagePublicURL = uploadEvalCtx.ImagePublicURL - } - - return n.sendNotifications(evalCtx, notifierStates) -} - -func (n *notificationService) sendAndMarkAsComplete(evalContext *EvalContext, notifierState *notifierState) error { - notifier := notifierState.notifier - - n.log.Debug("Sending notification", "type", notifier.GetType(), "uid", notifier.GetNotifierUID(), "isDefault", notifier.GetIsDefault()) - metrics.MAlertingNotificationSent.WithLabelValues(notifier.GetType()).Inc() - - if err := evalContext.evaluateNotificationTemplateFields(); err != nil { - n.log.Error("Failed trying to evaluate notification template fields", "uid", notifier.GetNotifierUID(), "error", err) - } - - if err := notifier.Notify(evalContext); err != nil { - n.log.Error("Failed to send notification", "uid", notifier.GetNotifierUID(), "error", err) - metrics.MAlertingNotificationFailed.WithLabelValues(notifier.GetType()).Inc() - return err - } - - if evalContext.IsTestRun { - return nil - } - - cmd := &alertmodels.SetAlertNotificationStateToCompleteCommand{ - ID: notifierState.state.ID, - Version: notifierState.state.Version, - } - - return n.sqlStore.SetAlertNotificationStateToCompleteCommand(evalContext.Ctx, cmd) -} - -func (n *notificationService) sendNotification(evalContext *EvalContext, notifierState *notifierState) error { - if !evalContext.IsTestRun { - setPendingCmd := &alertmodels.SetAlertNotificationStateToPendingCommand{ - ID: notifierState.state.ID, - Version: notifierState.state.Version, - AlertRuleStateUpdatedVersion: evalContext.Rule.StateChanges, - } - - err := n.sqlStore.SetAlertNotificationStateToPendingCommand(evalContext.Ctx, setPendingCmd) - if err != nil { - if errors.Is(err, alertmodels.ErrAlertNotificationStateVersionConflict) { - return nil - } - - return err - } - - // We need to update state version to be able to log - // unexpected version conflicts when marking notifications as ok - notifierState.state.Version = setPendingCmd.ResultVersion - } - - return n.sendAndMarkAsComplete(evalContext, notifierState) -} - -func (n *notificationService) sendNotifications(evalContext *EvalContext, notifierStates notifierStateSlice) error { - for _, notifierState := range notifierStates { - err := n.sendNotification(evalContext, notifierState) - if err != nil { - n.log.Error("Failed to send notification", "uid", notifierState.notifier.GetNotifierUID(), "error", err) - if evalContext.IsTestRun { - return err - } - } - } - return nil -} - -func (n *notificationService) renderAndUploadImage(evalCtx *EvalContext, timeout time.Duration) (err error) { - uploader, err := newImageUploaderProvider() - if err != nil { - return err - } - - renderOpts := rendering.Opts{ - TimeoutOpts: rendering.TimeoutOpts{ - Timeout: timeout, - }, - AuthOpts: rendering.AuthOpts{ - OrgID: evalCtx.Rule.OrgID, - OrgRole: org.RoleAdmin, - }, - Width: 1000, - Height: 500, - ConcurrentLimit: setting.AlertingRenderLimit, - Theme: models.ThemeDark, - } - - ref, err := evalCtx.GetDashboardUID() - if err != nil { - return err - } - - renderOpts.Path = fmt.Sprintf("d-solo/%s/%s?orgId=%d&panelId=%d", ref.UID, ref.Slug, evalCtx.Rule.OrgID, evalCtx.Rule.PanelID) - - n.log.Debug("Rendering alert panel image", "ruleId", evalCtx.Rule.ID, "urlPath", renderOpts.Path) - start := time.Now() - result, err := n.renderService.Render(evalCtx.Ctx, renderOpts, nil) - if err != nil { - return err - } - took := time.Since(start) - - n.log.Debug("Rendered alert panel image", "ruleId", evalCtx.Rule.ID, "path", result.FilePath, "took", took) - - evalCtx.ImageOnDiskPath = result.FilePath - - n.log.Debug("Uploading alert panel image to external image store", "ruleId", evalCtx.Rule.ID, "path", evalCtx.ImageOnDiskPath) - - start = time.Now() - evalCtx.ImagePublicURL, err = uploader.Upload(evalCtx.Ctx, evalCtx.ImageOnDiskPath) - if err != nil { - return err - } - took = time.Since(start) - - if evalCtx.ImagePublicURL != "" { - n.log.Debug("Uploaded alert panel image to external image store", "ruleId", evalCtx.Rule.ID, "url", evalCtx.ImagePublicURL, "took", took) - } - - return nil -} - -func (n *notificationService) getNeededNotifiers(orgID int64, notificationUids []string, evalContext *EvalContext) (notifierStateSlice, error) { - query := &alertmodels.GetAlertNotificationsWithUidToSendQuery{OrgID: orgID, UIDs: notificationUids} - - res, err := n.sqlStore.GetAlertNotificationsWithUidToSend(evalContext.Ctx, query) - if err != nil { - return nil, err - } - - var result notifierStateSlice - for _, notification := range res { - not, err := InitNotifier(notification, n.decryptFn, n.notificationService) - if err != nil { - n.log.Error("Could not create notifier", "notifier", notification.UID, "error", err) - continue - } - - query := &alertmodels.GetOrCreateNotificationStateQuery{ - NotifierID: notification.ID, - AlertID: evalContext.Rule.ID, - OrgID: evalContext.Rule.OrgID, - } - - state, err := n.sqlStore.GetOrCreateAlertNotificationState(evalContext.Ctx, query) - if err != nil { - n.log.Error("Could not get notification state.", "notifier", notification.ID, "error", err) - continue - } - - if not.ShouldNotify(evalContext.Ctx, evalContext, state) { - result = append(result, ¬ifierState{ - notifier: not, - state: state, - }) - } - } - - return result, nil -} - -// InitNotifier instantiate a new notifier based on the model. -func InitNotifier(model *alertmodels.AlertNotification, fn GetDecryptedValueFn, notificationService *notifications.NotificationService) (Notifier, error) { - notifierPlugin, found := notifierFactories[model.Type] - if !found { - return nil, fmt.Errorf("unsupported notification type %q", model.Type) - } - - return notifierPlugin.Factory(model, fn, notificationService) -} - -// GetDecryptedValueFn is a function that returns the decrypted value of -// the given key. If the key is not present, then it returns the fallback value. -type GetDecryptedValueFn func(ctx context.Context, sjd map[string][]byte, key string, fallback string, secret string) string - -// NotifierFactory is a signature for creating notifiers. -type NotifierFactory func(*alertmodels.AlertNotification, GetDecryptedValueFn, notifications.Service) (Notifier, error) - -var notifierFactories = make(map[string]*NotifierPlugin) - -// RegisterNotifier registers a notifier. -func RegisterNotifier(plugin *NotifierPlugin) { - notifierFactories[plugin.Type] = plugin -} - -// GetNotifiers returns a list of metadata about available notifiers. -func GetNotifiers() []*NotifierPlugin { - list := make([]*NotifierPlugin, 0) - - for _, value := range notifierFactories { - list = append(list, value) - } - - return list -} diff --git a/pkg/services/alerting/notifier_test.go b/pkg/services/alerting/notifier_test.go deleted file mode 100644 index 93342526f51cc..0000000000000 --- a/pkg/services/alerting/notifier_test.go +++ /dev/null @@ -1,405 +0,0 @@ -package alerting - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/imguploader" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/models" - alertmodels "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/services/rendering" - "github.com/grafana/grafana/pkg/services/validations" - "github.com/grafana/grafana/pkg/setting" -) - -func TestNotificationService(t *testing.T) { - testRule := &Rule{Name: "Test", Message: "Something is bad"} - store := &AlertStoreMock{} - evalCtx := NewEvalContext(context.Background(), testRule, &validations.OSSPluginRequestValidator{}, store, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - testRuleTemplated := &Rule{Name: "Test latency ${quantile}", Message: "Something is bad on instance ${instance}"} - - evalCtxWithMatch := NewEvalContext(context.Background(), testRuleTemplated, &validations.OSSPluginRequestValidator{}, store, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - evalCtxWithMatch.EvalMatches = []*EvalMatch{{ - Tags: map[string]string{ - "instance": "localhost:3000", - "quantile": "0.99", - }, - }} - evalCtxWithoutMatch := NewEvalContext(context.Background(), testRuleTemplated, &validations.OSSPluginRequestValidator{}, store, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - notificationServiceScenario(t, "Given alert rule with upload image enabled should render and upload image and send notification", - evalCtx, true, func(sc *scenarioContext) { - err := sc.notificationService.SendIfNeeded(evalCtx) - require.NoError(sc.t, err) - - require.Equalf(sc.t, 1, sc.renderCount, "expected render to be called, but wasn't") - require.Equalf(sc.t, 1, sc.imageUploadCount, "expected image to be uploaded, but wasn't") - require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") - }) - - notificationServiceScenario(t, - "Given alert rule with upload image enabled but no renderer available should render and upload unavailable image and send notification", - evalCtx, true, func(sc *scenarioContext) { - sc.rendererAvailable = false - err := sc.notificationService.SendIfNeeded(evalCtx) - require.NoError(sc.t, err) - - require.Equalf(sc.t, 1, sc.renderCount, "expected render to be called, but it wasn't") - require.Equalf(sc.t, 1, sc.imageUploadCount, "expected image to be uploaded, but it wasn't") - require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") - }) - - notificationServiceScenario( - t, "Given alert rule with upload image disabled should not render and upload image, but send notification", - evalCtx, false, func(sc *scenarioContext) { - err := sc.notificationService.SendIfNeeded(evalCtx) - require.NoError(t, err) - - require.Equalf(sc.t, 0, sc.renderCount, "expected render not to be called, but it was") - require.Equalf(sc.t, 0, sc.imageUploadCount, "expected image not to be uploaded, but it was") - require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") - }) - - notificationServiceScenario(t, "Given alert rule with upload image enabled and render times out should send notification", - evalCtx, true, func(sc *scenarioContext) { - setting.AlertingNotificationTimeout = 200 * time.Millisecond - sc.renderProvider = func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error) { - wait := make(chan bool) - - go func() { - time.Sleep(1 * time.Second) - wait <- true - }() - - select { - case <-ctx.Done(): - if err := ctx.Err(); err != nil { - return nil, err - } - break - case <-wait: - } - - return nil, nil - } - err := sc.notificationService.SendIfNeeded(evalCtx) - require.NoError(sc.t, err) - - require.Equalf(sc.t, 0, sc.renderCount, "expected render not to be called, but it was") - require.Equalf(sc.t, 0, sc.imageUploadCount, "expected image not to be uploaded, but it was") - require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") - }) - - notificationServiceScenario(t, "Given alert rule with upload image enabled and upload times out should send notification", - evalCtx, true, func(sc *scenarioContext) { - setting.AlertingNotificationTimeout = 200 * time.Millisecond - sc.uploadProvider = func(ctx context.Context, path string) (string, error) { - wait := make(chan bool) - - go func() { - time.Sleep(1 * time.Second) - wait <- true - }() - - select { - case <-ctx.Done(): - if err := ctx.Err(); err != nil { - return "", err - } - break - case <-wait: - } - - return "", nil - } - err := sc.notificationService.SendIfNeeded(evalCtx) - require.NoError(sc.t, err) - - require.Equalf(sc.t, 1, sc.renderCount, "expected render to be called, but wasn't") - require.Equalf(sc.t, 0, sc.imageUploadCount, "expected image not to be uploaded, but it was") - require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") - }) - - notificationServiceScenario(t, "Given matched alert rule with templated notification fields", - evalCtxWithMatch, true, func(sc *scenarioContext) { - err := sc.notificationService.SendIfNeeded(evalCtxWithMatch) - require.NoError(sc.t, err) - - ctx := evalCtxWithMatch - require.Equalf(sc.t, 1, sc.renderCount, "expected render to be called, but wasn't") - require.Equalf(sc.t, 1, sc.imageUploadCount, "expected image to be uploaded, but wasn't") - require.Truef(sc.t, ctx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") - assert.Equal(t, "Test latency 0.99", ctx.Rule.Name) - assert.Equal(t, "Something is bad on instance localhost:3000", ctx.Rule.Message) - }) - - notificationServiceScenario(t, "Given unmatched alert rule with templated notification fields", - evalCtxWithoutMatch, true, func(sc *scenarioContext) { - err := sc.notificationService.SendIfNeeded(evalCtxWithMatch) - require.NoError(sc.t, err) - - ctx := evalCtxWithMatch - require.Equalf(sc.t, 1, sc.renderCount, "expected render to be called, but wasn't") - require.Equalf(sc.t, 1, sc.imageUploadCount, "expected image to be uploaded, but wasn't") - require.Truef(sc.t, ctx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't") - assert.Equal(t, evalCtxWithoutMatch.Rule.Name, ctx.Rule.Name) - assert.Equal(t, evalCtxWithoutMatch.Rule.Message, ctx.Rule.Message) - }) -} - -type scenarioContext struct { - t *testing.T - evalCtx *EvalContext - notificationService *notificationService - imageUploadCount int - renderCount int - uploadProvider func(ctx context.Context, path string) (string, error) - renderProvider func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error) - rendererAvailable bool -} - -type scenarioFunc func(c *scenarioContext) - -func notificationServiceScenario(t *testing.T, name string, evalCtx *EvalContext, uploadImage bool, fn scenarioFunc) { - t.Run(name, func(t *testing.T) { - RegisterNotifier(&NotifierPlugin{ - Type: "test", - Name: "Test", - Description: "Test notifier", - Factory: newTestNotifier, - }) - - evalCtx.dashboardRef = &dashboards.DashboardRef{UID: "db-uid"} - - store := evalCtx.Store.(*AlertStoreMock) - - store.getAlertNotificationsWithUidToSend = func(ctx context.Context, query *alertmodels.GetAlertNotificationsWithUidToSendQuery) (res []*alertmodels.AlertNotification, err error) { - return []*alertmodels.AlertNotification{ - { - ID: 1, - Type: "test", - Settings: simplejson.NewFromAny(map[string]any{ - "uploadImage": uploadImage, - }), - }, - }, nil - } - - store.getOrCreateNotificationState = func(ctx context.Context, query *alertmodels.GetOrCreateNotificationStateQuery) (res *alertmodels.AlertNotificationState, err error) { - return &alertmodels.AlertNotificationState{ - AlertID: evalCtx.Rule.ID, - AlertRuleStateUpdatedVersion: 1, - ID: 1, - OrgID: evalCtx.Rule.OrgID, - State: alertmodels.AlertNotificationStateUnknown, - }, nil - } - - setting.AlertingNotificationTimeout = 30 * time.Second - - scenarioCtx := &scenarioContext{ - t: t, - evalCtx: evalCtx, - } - - uploadProvider := func(ctx context.Context, path string) (string, error) { - scenarioCtx.imageUploadCount++ - return "", nil - } - - imageUploader := &testImageUploader{ - uploadProvider: func(ctx context.Context, path string) (string, error) { - if scenarioCtx.uploadProvider != nil { - if _, err := scenarioCtx.uploadProvider(ctx, path); err != nil { - return "", err - } - } - - return uploadProvider(ctx, path) - }, - } - - origNewImageUploaderProvider := newImageUploaderProvider - newImageUploaderProvider = func() (imguploader.ImageUploader, error) { - return imageUploader, nil - } - defer func() { - newImageUploaderProvider = origNewImageUploaderProvider - }() - - renderProvider := func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error) { - scenarioCtx.renderCount++ - return &rendering.RenderResult{FilePath: "image.png"}, nil - } - - scenarioCtx.rendererAvailable = true - - renderService := &testRenderService{ - isAvailableProvider: func(ctx context.Context) bool { - return scenarioCtx.rendererAvailable - }, - renderProvider: func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error) { - if scenarioCtx.renderProvider != nil { - if _, err := scenarioCtx.renderProvider(ctx, opts); err != nil { - return nil, err - } - } - - return renderProvider(ctx, opts) - }, - } - - scenarioCtx.notificationService = newNotificationService(renderService, store, nil, nil) - fn(scenarioCtx) - }) -} - -type testNotifier struct { - Name string - Type string - UID string - IsDefault bool - UploadImage bool - SendReminder bool - DisableResolveMessage bool - Frequency time.Duration -} - -func newTestNotifier(model *alertmodels.AlertNotification, _ GetDecryptedValueFn, ns notifications.Service) (Notifier, error) { - uploadImage := true - value, exist := model.Settings.CheckGet("uploadImage") - if exist { - uploadImage = value.MustBool() - } - - return &testNotifier{ - UID: model.UID, - Name: model.Name, - IsDefault: model.IsDefault, - Type: model.Type, - UploadImage: uploadImage, - SendReminder: model.SendReminder, - DisableResolveMessage: model.DisableResolveMessage, - Frequency: model.Frequency, - }, nil -} - -type notificationSent struct{} - -func (n *testNotifier) Notify(evalCtx *EvalContext) error { - evalCtx.Ctx = context.WithValue(evalCtx.Ctx, notificationSent{}, true) - return nil -} - -func (n *testNotifier) ShouldNotify(ctx context.Context, evalCtx *EvalContext, notifierState *alertmodels.AlertNotificationState) bool { - return true -} - -func (n *testNotifier) GetType() string { - return n.Type -} - -func (n *testNotifier) NeedsImage() bool { - return n.UploadImage -} - -func (n *testNotifier) GetNotifierUID() string { - return n.UID -} - -func (n *testNotifier) GetIsDefault() bool { - return n.IsDefault -} - -func (n *testNotifier) GetSendReminder() bool { - return n.SendReminder -} - -func (n *testNotifier) GetDisableResolveMessage() bool { - return n.DisableResolveMessage -} - -func (n *testNotifier) GetFrequency() time.Duration { - return n.Frequency -} - -var _ Notifier = &testNotifier{} - -type testRenderService struct { - isAvailableProvider func(ctx context.Context) bool - renderProvider func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error) - renderErrorImageProvider func(error error) (*rendering.RenderResult, error) -} - -func (s *testRenderService) SanitizeSVG(ctx context.Context, req *rendering.SanitizeSVGRequest) (*rendering.SanitizeSVGResponse, error) { - return &rendering.SanitizeSVGResponse{Sanitized: req.Content}, nil -} - -func (s *testRenderService) HasCapability(_ context.Context, feature rendering.CapabilityName) (rendering.CapabilitySupportRequestResult, error) { - return rendering.CapabilitySupportRequestResult{}, nil -} - -func (s *testRenderService) IsAvailable(ctx context.Context) bool { - if s.isAvailableProvider != nil { - return s.isAvailableProvider(ctx) - } - - return true -} - -func (s *testRenderService) Render(ctx context.Context, opts rendering.Opts, session rendering.Session) (*rendering.RenderResult, error) { - if s.renderProvider != nil { - return s.renderProvider(ctx, opts) - } - - return &rendering.RenderResult{FilePath: "image.png"}, nil -} - -func (s *testRenderService) RenderCSV(ctx context.Context, opts rendering.CSVOpts, session rendering.Session) (*rendering.RenderCSVResult, error) { - return nil, nil -} - -func (s *testRenderService) RenderErrorImage(theme models.Theme, err error) (*rendering.RenderResult, error) { - if s.renderErrorImageProvider != nil { - return s.renderErrorImageProvider(err) - } - - return &rendering.RenderResult{FilePath: "image.png"}, nil -} - -func (s *testRenderService) GetRenderUser(ctx context.Context, key string) (*rendering.RenderUser, bool) { - return nil, false -} - -func (s *testRenderService) Version() string { - return "" -} - -func (s *testRenderService) CreateRenderingSession(ctx context.Context, authOpts rendering.AuthOpts, sessionOpts rendering.SessionOpts) (rendering.Session, error) { - return nil, nil -} - -var _ rendering.Service = &testRenderService{} - -type testImageUploader struct { - uploadProvider func(ctx context.Context, path string) (string, error) -} - -func (u *testImageUploader) Upload(ctx context.Context, path string) (string, error) { - if u.uploadProvider != nil { - return u.uploadProvider(ctx, path) - } - - return "", nil -} - -var _ imguploader.ImageUploader = &testImageUploader{} diff --git a/pkg/services/alerting/notifiers/alertmanager.go b/pkg/services/alerting/notifiers/alertmanager.go deleted file mode 100644 index 4936dee2cdb2d..0000000000000 --- a/pkg/services/alerting/notifiers/alertmanager.go +++ /dev/null @@ -1,206 +0,0 @@ -package notifiers - -import ( - "context" - "fmt" - "regexp" - "strings" - "time" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" -) - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "prometheus-alertmanager", - Name: "Prometheus Alertmanager", - Description: "Sends alert to Prometheus Alertmanager", - Heading: "Alertmanager settings", - Factory: NewAlertmanagerNotifier, - Options: []alerting.NotifierOption{ - { - Label: "Url", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Description: "As specified in Alertmanager documentation, do not specify a load balancer here. Enter all your Alertmanager URLs comma-separated.", - Placeholder: "http://localhost:9093", - PropertyName: "url", - Required: true, - }, - { - Label: "Basic Auth User", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - PropertyName: "basicAuthUser", - }, - { - Label: "Basic Auth Password", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypePassword, - PropertyName: "basicAuthPassword", - Secure: true, - }, - }, - }) -} - -// NewAlertmanagerNotifier returns a new Alertmanager notifier -func NewAlertmanagerNotifier(model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - urlString := model.Settings.Get("url").MustString() - if urlString == "" { - return nil, alerting.ValidationError{Reason: "Could not find url property in settings"} - } - - var url []string - for _, u := range strings.Split(urlString, ",") { - u = strings.TrimSpace(u) - if u != "" { - url = append(url, u) - } - } - basicAuthUser := model.Settings.Get("basicAuthUser").MustString() - basicAuthPassword := fn(context.Background(), model.SecureSettings, "basicAuthPassword", model.Settings.Get("basicAuthPassword").MustString(), setting.SecretKey) - - return &AlertmanagerNotifier{ - NotifierBase: NewNotifierBase(model, ns), - URL: url, - BasicAuthUser: basicAuthUser, - BasicAuthPassword: basicAuthPassword, - log: log.New("alerting.notifier.prometheus-alertmanager"), - }, nil -} - -// AlertmanagerNotifier sends alert notifications to the alert manager -type AlertmanagerNotifier struct { - NotifierBase - URL []string - BasicAuthUser string - BasicAuthPassword string - log log.Logger -} - -// ShouldNotify returns true if the notifiers should be used depending on state -func (am *AlertmanagerNotifier) ShouldNotify(ctx context.Context, evalContext *alerting.EvalContext, notificationState *models.AlertNotificationState) bool { - am.log.Debug("Should notify", "ruleId", evalContext.Rule.ID, "state", evalContext.Rule.State, "previousState", evalContext.PrevAlertState) - - // Do not notify when we become OK for the first time. - if (evalContext.PrevAlertState == models.AlertStatePending) && (evalContext.Rule.State == models.AlertStateOK) { - return false - } - - // Notify on Alerting -> OK to resolve before alertmanager timeout.models.AlertStateOK - if (evalContext.PrevAlertState == models.AlertStateAlerting) && (evalContext.Rule.State == models.AlertStateOK) { - return true - } - - return evalContext.Rule.State == models.AlertStateAlerting -} - -func (am *AlertmanagerNotifier) createAlert(evalContext *alerting.EvalContext, match *alerting.EvalMatch, ruleURL string) *simplejson.Json { - alertJSON := simplejson.New() - alertJSON.Set("startsAt", evalContext.StartTime.UTC().Format(time.RFC3339)) - if evalContext.Rule.State == models.AlertStateOK { - alertJSON.Set("endsAt", time.Now().UTC().Format(time.RFC3339)) - } - alertJSON.Set("generatorURL", ruleURL) - - // Annotations (summary and description are very commonly used). - alertJSON.SetPath([]string{"annotations", "summary"}, evalContext.Rule.Name) - description := "" - if evalContext.Rule.Message != "" { - description += evalContext.Rule.Message - } - if evalContext.Error != nil { - if description != "" { - description += "\n" - } - description += "Error: " + evalContext.Error.Error() - } - if description != "" { - alertJSON.SetPath([]string{"annotations", "description"}, description) - } - if evalContext.ImagePublicURL != "" { - alertJSON.SetPath([]string{"annotations", "image"}, evalContext.ImagePublicURL) - } - - // Labels (from metrics tags + AlertRuleTags + mandatory alertname). - tags := make(map[string]string) - if match != nil { - if len(match.Tags) == 0 { - tags["metric"] = match.Metric - } else { - for k, v := range match.Tags { - tags[replaceIllegalCharsInLabelname(k)] = v - } - } - } - for _, tag := range evalContext.Rule.AlertRuleTags { - tags[tag.Key] = tag.Value - } - tags["alertname"] = evalContext.Rule.Name - alertJSON.Set("labels", tags) - return alertJSON -} - -// Notify sends alert notifications to the alert manager -func (am *AlertmanagerNotifier) Notify(evalContext *alerting.EvalContext) error { - am.log.Info("Sending Alertmanager alert", "ruleId", evalContext.Rule.ID, "notification", am.Name) - - ruleURL, err := evalContext.GetRuleURL() - if err != nil { - am.log.Error("Failed get rule link", "error", err) - return err - } - - // Send one alert per matching series. - alerts := make([]any, 0) - for _, match := range evalContext.EvalMatches { - alert := am.createAlert(evalContext, match, ruleURL) - alerts = append(alerts, alert) - } - - // This happens on ExecutionError or NoData - if len(alerts) == 0 { - alert := am.createAlert(evalContext, nil, ruleURL) - alerts = append(alerts, alert) - } - - bodyJSON := simplejson.NewFromAny(alerts) - body, _ := bodyJSON.MarshalJSON() - errCnt := 0 - - for _, url := range am.URL { - cmd := ¬ifications.SendWebhookSync{ - Url: strings.TrimSuffix(url, "/") + "/api/v1/alerts", - User: am.BasicAuthUser, - Password: am.BasicAuthPassword, - HttpMethod: "POST", - Body: string(body), - } - - if err := am.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - am.log.Error("Failed to send alertmanager", "error", err, "alertmanager", am.Name, "url", url) - errCnt++ - } - } - - // This happens when every dispatch return error - if errCnt == len(am.URL) { - return fmt.Errorf("failed to send alert to alertmanager") - } - - return nil -} - -// regexp that matches all none valid label name characters -// https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels -var labelNamePattern = regexp.MustCompile(`[^a-zA-Z0-9_]`) - -func replaceIllegalCharsInLabelname(input string) string { - return labelNamePattern.ReplaceAllString(input, "_") -} diff --git a/pkg/services/alerting/notifiers/alertmanager_test.go b/pkg/services/alerting/notifiers/alertmanager_test.go deleted file mode 100644 index 4f3b8e762bdf0..0000000000000 --- a/pkg/services/alerting/notifiers/alertmanager_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package notifiers - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" - "github.com/grafana/grafana/pkg/services/validations" -) - -func TestReplaceIllegalCharswithUnderscore(t *testing.T) { - cases := []struct { - input string - expected string - }{ - { - input: "foobar", - expected: "foobar", - }, - { - input: `foo.,\][!?#="~*^&+|<>\'bar09_09`, - expected: "foo____________________bar09_09", - }, - } - - for _, c := range cases { - assert.Equal(t, replaceIllegalCharsInLabelname(c.input), c.expected) - } -} - -func TestWhenAlertManagerShouldNotify(t *testing.T) { - tcs := []struct { - prevState models.AlertStateType - newState models.AlertStateType - - expect bool - }{ - { - prevState: models.AlertStatePending, - newState: models.AlertStateOK, - expect: false, - }, - { - prevState: models.AlertStateAlerting, - newState: models.AlertStateOK, - expect: true, - }, - { - prevState: models.AlertStateOK, - newState: models.AlertStatePending, - expect: false, - }, - { - prevState: models.AlertStateUnknown, - newState: models.AlertStatePending, - expect: false, - }, - } - - for _, tc := range tcs { - am := &AlertmanagerNotifier{log: log.New("test.logger")} - evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{ - State: tc.prevState, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - evalContext.Rule.State = tc.newState - - res := am.ShouldNotify(context.Background(), evalContext, &models.AlertNotificationState{}) - if res != tc.expect { - t.Errorf("got %v expected %v", res, tc.expect) - } - } -} - -//nolint:goconst -func TestAlertmanagerNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("Parsing alert notification from settings", func(t *testing.T) { - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "alertmanager", - Type: "alertmanager", - Settings: settingsJSON, - } - - _, err := NewAlertmanagerNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - - t.Run("from settings", func(t *testing.T) { - json := `{ "url": "http://127.0.0.1:9093/", "basicAuthUser": "user", "basicAuthPassword": "password" }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "alertmanager", - Type: "alertmanager", - Settings: settingsJSON, - } - - not, err := NewAlertmanagerNotifier(model, encryptionService.GetDecryptedValue, nil) - alertmanagerNotifier := not.(*AlertmanagerNotifier) - - require.NoError(t, err) - require.Equal(t, alertmanagerNotifier.BasicAuthUser, "user") - require.Equal(t, alertmanagerNotifier.BasicAuthPassword, "password") - require.Equal(t, alertmanagerNotifier.URL, []string{"http://127.0.0.1:9093/"}) - }) - - t.Run("from settings with multiple alertmanager", func(t *testing.T) { - json := `{ "url": "http://alertmanager1:9093,http://alertmanager2:9093" }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "alertmanager", - Type: "alertmanager", - Settings: settingsJSON, - } - - not, err := NewAlertmanagerNotifier(model, encryptionService.GetDecryptedValue, nil) - alertmanagerNotifier := not.(*AlertmanagerNotifier) - - require.NoError(t, err) - require.Equal(t, alertmanagerNotifier.URL, []string{"http://alertmanager1:9093", "http://alertmanager2:9093"}) - }) - }) -} diff --git a/pkg/services/alerting/notifiers/base.go b/pkg/services/alerting/notifiers/base.go deleted file mode 100644 index c4730cbac8082..0000000000000 --- a/pkg/services/alerting/notifiers/base.go +++ /dev/null @@ -1,151 +0,0 @@ -package notifiers - -import ( - "context" - "time" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" -) - -const ( - triggMetrString = "Triggered metrics:\n\n" -) - -// NotifierBase is the base implementation of a notifier. -type NotifierBase struct { - Name string - Type string - UID string - IsDefault bool - UploadImage bool - SendReminder bool - DisableResolveMessage bool - Frequency time.Duration - - NotificationService notifications.Service - - log log.Logger -} - -// NewNotifierBase returns a new `NotifierBase`. -func NewNotifierBase(model *models.AlertNotification, notificationService notifications.Service) NotifierBase { - uploadImage := true - if value, exists := model.Settings.CheckGet("uploadImage"); exists { - uploadImage = value.MustBool() - } - - return NotifierBase{ - UID: model.UID, - Name: model.Name, - IsDefault: model.IsDefault, - Type: model.Type, - UploadImage: uploadImage, - SendReminder: model.SendReminder, - DisableResolveMessage: model.DisableResolveMessage, - Frequency: model.Frequency, - NotificationService: notificationService, - log: log.New("alerting.notifier." + model.Name), - } -} - -// ShouldNotify checks this evaluation should send an alert notification -func (n *NotifierBase) ShouldNotify(ctx context.Context, context *alerting.EvalContext, notifierState *models.AlertNotificationState) bool { - prevState := context.PrevAlertState - newState := context.Rule.State - - // Only notify on state change. - if prevState == newState && !n.SendReminder { - return false - } - - if prevState == newState && n.SendReminder { - // Do not notify if interval has not elapsed - lastNotify := time.Unix(notifierState.UpdatedAt, 0) - if notifierState.UpdatedAt != 0 && lastNotify.Add(n.Frequency).After(time.Now()) { - return false - } - - // Do not notify if alert state is OK or pending even on repeated notify - if newState == models.AlertStateOK || newState == models.AlertStatePending { - return false - } - } - - okOrPending := newState == models.AlertStatePending || newState == models.AlertStateOK - - // Do not notify when new state is ok/pending when previous is unknown - if prevState == models.AlertStateUnknown && okOrPending { - return false - } - - // Do not notify when we become Pending for the first - if prevState == models.AlertStateNoData && newState == models.AlertStatePending { - return false - } - - // Do not notify when we become OK from pending - if prevState == models.AlertStatePending && newState == models.AlertStateOK { - return false - } - - // Do not notify when we OK -> Pending - if prevState == models.AlertStateOK && newState == models.AlertStatePending { - return false - } - - // Do not notify if state pending and it have been updated last minute - if notifierState.State == models.AlertNotificationStatePending { - lastUpdated := time.Unix(notifierState.UpdatedAt, 0) - if lastUpdated.Add(1 * time.Minute).After(time.Now()) { - return false - } - } - - // Do not notify when state is OK if DisableResolveMessage is set to true - if newState == models.AlertStateOK && n.DisableResolveMessage { - return false - } - - return true -} - -// GetType returns the notifier type. -func (n *NotifierBase) GetType() string { - return n.Type -} - -// NeedsImage returns true if an image is expected in the notification. -func (n *NotifierBase) NeedsImage() bool { - return n.UploadImage -} - -// GetNotifierUID returns the notifier `uid`. -func (n *NotifierBase) GetNotifierUID() string { - return n.UID -} - -// GetIsDefault returns true if the notifiers should -// be used for all alerts. -func (n *NotifierBase) GetIsDefault() bool { - return n.IsDefault -} - -// GetSendReminder returns true if reminders should be sent. -func (n *NotifierBase) GetSendReminder() bool { - return n.SendReminder -} - -// GetDisableResolveMessage returns true if ok alert notifications -// should be skipped. -func (n *NotifierBase) GetDisableResolveMessage() bool { - return n.DisableResolveMessage -} - -// GetFrequency returns the frequency for how often -// alerts should be evaluated. -func (n *NotifierBase) GetFrequency() time.Duration { - return n.Frequency -} diff --git a/pkg/services/alerting/notifiers/base_test.go b/pkg/services/alerting/notifiers/base_test.go deleted file mode 100644 index e95fca0652a1f..0000000000000 --- a/pkg/services/alerting/notifiers/base_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package notifiers - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - "github.com/grafana/grafana/pkg/services/validations" -) - -func TestShouldSendAlertNotification(t *testing.T) { - tnow := time.Now() - - tcs := []struct { - name string - prevState models.AlertStateType - newState models.AlertStateType - sendReminder bool - frequency time.Duration - state *models.AlertNotificationState - - expect bool - }{ - { - name: "pending -> ok should not trigger an notification", - newState: models.AlertStateOK, - prevState: models.AlertStatePending, - sendReminder: false, - - expect: false, - }, - { - name: "ok -> alerting should trigger an notification", - newState: models.AlertStateAlerting, - prevState: models.AlertStateOK, - sendReminder: false, - - expect: true, - }, - { - name: "ok -> pending should not trigger an notification", - newState: models.AlertStatePending, - prevState: models.AlertStateOK, - sendReminder: false, - - expect: false, - }, - { - name: "ok -> ok should not trigger an notification", - newState: models.AlertStateOK, - prevState: models.AlertStateOK, - sendReminder: false, - - expect: false, - }, - { - name: "ok -> ok with reminder should not trigger an notification", - newState: models.AlertStateOK, - prevState: models.AlertStateOK, - sendReminder: true, - - expect: false, - }, - { - name: "alerting -> ok should trigger an notification", - newState: models.AlertStateOK, - prevState: models.AlertStateAlerting, - sendReminder: false, - - expect: true, - }, - { - name: "alerting -> ok should trigger an notification when reminders enabled", - newState: models.AlertStateOK, - prevState: models.AlertStateAlerting, - frequency: time.Minute * 10, - sendReminder: true, - state: &models.AlertNotificationState{UpdatedAt: tnow.Add(-time.Minute).Unix()}, - - expect: true, - }, - { - name: "alerting -> alerting with reminder and no state should trigger", - newState: models.AlertStateAlerting, - prevState: models.AlertStateAlerting, - frequency: time.Minute * 10, - sendReminder: true, - - expect: true, - }, - { - name: "alerting -> alerting with reminder and last notification sent 1 minute ago should not trigger", - newState: models.AlertStateAlerting, - prevState: models.AlertStateAlerting, - frequency: time.Minute * 10, - sendReminder: true, - state: &models.AlertNotificationState{UpdatedAt: tnow.Add(-time.Minute).Unix()}, - - expect: false, - }, - { - name: "alerting -> alerting with reminder and last notification sent 11 minutes ago should trigger", - newState: models.AlertStateAlerting, - prevState: models.AlertStateAlerting, - frequency: time.Minute * 10, - sendReminder: true, - state: &models.AlertNotificationState{UpdatedAt: tnow.Add(-11 * time.Minute).Unix()}, - - expect: true, - }, - { - name: "OK -> alerting with notification state pending and updated 30 seconds ago should not trigger", - newState: models.AlertStateAlerting, - prevState: models.AlertStateOK, - state: &models.AlertNotificationState{State: models.AlertNotificationStatePending, UpdatedAt: tnow.Add(-30 * time.Second).Unix()}, - - expect: false, - }, - { - name: "OK -> alerting with notification state pending and updated 2 minutes ago should trigger", - newState: models.AlertStateAlerting, - prevState: models.AlertStateOK, - state: &models.AlertNotificationState{State: models.AlertNotificationStatePending, UpdatedAt: tnow.Add(-2 * time.Minute).Unix()}, - - expect: true, - }, - { - name: "unknown -> ok", - prevState: models.AlertStateUnknown, - newState: models.AlertStateOK, - - expect: false, - }, - { - name: "unknown -> pending", - prevState: models.AlertStateUnknown, - newState: models.AlertStatePending, - - expect: false, - }, - { - name: "unknown -> alerting", - prevState: models.AlertStateUnknown, - newState: models.AlertStateAlerting, - - expect: true, - }, - { - name: "no_data -> pending", - prevState: models.AlertStateNoData, - newState: models.AlertStatePending, - - expect: false, - }, - { - name: "no_data -> ok", - prevState: models.AlertStateNoData, - newState: models.AlertStateOK, - - expect: true, - }, - } - - for _, tc := range tcs { - evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{ - State: tc.prevState, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - if tc.state == nil { - tc.state = &models.AlertNotificationState{} - } - - evalContext.Rule.State = tc.newState - nb := &NotifierBase{SendReminder: tc.sendReminder, Frequency: tc.frequency} - - r := nb.ShouldNotify(evalContext.Ctx, evalContext, tc.state) - assert.Equal(t, r, tc.expect, "failed test %s. expected %+v to return: %v", tc.name, tc, tc.expect) - } -} - -func TestBaseNotifier(t *testing.T) { - bJSON := simplejson.New() - - model := &models.AlertNotification{ - UID: "1", - Name: "name", - Type: "email", - Settings: bJSON, - } - - t.Run("can parse false value", func(t *testing.T) { - bJSON.Set("uploadImage", false) - - base := NewNotifierBase(model, nil) - require.False(t, base.UploadImage) - }) - - t.Run("can parse true value", func(t *testing.T) { - bJSON.Set("uploadImage", true) - - base := NewNotifierBase(model, nil) - require.True(t, base.UploadImage) - }) - - t.Run("default value should be true for backwards compatibility", func(t *testing.T) { - base := NewNotifierBase(model, nil) - require.True(t, base.UploadImage) - }) - - t.Run("default value should be false for backwards compatibility", func(t *testing.T) { - base := NewNotifierBase(model, nil) - require.False(t, base.DisableResolveMessage) - }) -} diff --git a/pkg/services/alerting/notifiers/dingding.go b/pkg/services/alerting/notifiers/dingding.go deleted file mode 100644 index ab4f452e988aa..0000000000000 --- a/pkg/services/alerting/notifiers/dingding.go +++ /dev/null @@ -1,158 +0,0 @@ -package notifiers - -import ( - "encoding/json" - "fmt" - "net/url" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" -) - -const defaultDingdingMsgType = "link" - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "dingding", - Name: "DingDing", - Description: "Sends HTTP POST request to DingDing", - Heading: "DingDing settings", - Factory: newDingDingNotifier, - Options: []alerting.NotifierOption{ - { - Label: "Url", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx", - PropertyName: "url", - Required: true, - }, - { - Label: "Message Type", - Element: alerting.ElementTypeSelect, - PropertyName: "msgType", - SelectOptions: []alerting.SelectOption{ - { - Value: "link", - Label: "Link"}, - { - Value: "actionCard", - Label: "ActionCard", - }, - }, - }, - }, - }) -} - -func newDingDingNotifier(model *models.AlertNotification, _ alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - url := model.Settings.Get("url").MustString() - if url == "" { - return nil, alerting.ValidationError{Reason: "Could not find url property in settings"} - } - - msgType := model.Settings.Get("msgType").MustString(defaultDingdingMsgType) - - return &DingDingNotifier{ - NotifierBase: NewNotifierBase(model, ns), - MsgType: msgType, - URL: url, - log: log.New("alerting.notifier.dingding"), - }, nil -} - -// DingDingNotifier is responsible for sending alert notifications to ding ding. -type DingDingNotifier struct { - NotifierBase - MsgType string - URL string - log log.Logger -} - -// Notify sends the alert notification to dingding. -func (dd *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error { - dd.log.Info("Sending dingding") - - messageURL, err := evalContext.GetRuleURL() - if err != nil { - dd.log.Error("Failed to get messageUrl", "error", err, "dingding", dd.Name) - messageURL = "" - } - - body, err := dd.genBody(evalContext, messageURL) - if err != nil { - return err - } - - cmd := ¬ifications.SendWebhookSync{ - Url: dd.URL, - Body: string(body), - } - - if err := dd.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - dd.log.Error("Failed to send DingDing", "error", err, "dingding", dd.Name) - return err - } - - return nil -} - -func (dd *DingDingNotifier) genBody(evalContext *alerting.EvalContext, messageURL string) ([]byte, error) { - q := url.Values{ - "pc_slide": {"false"}, - "url": {messageURL}, - } - - // Use special link to auto open the message url outside of Dingding - // Refer: https://open-doc.dingtalk.com/docs/doc.htm?treeId=385&articleId=104972&docType=1#s9 - messageURL = "dingtalk://dingtalkclient/page/link?" + q.Encode() - - dd.log.Info("MessageUrl:" + messageURL) - - message := evalContext.Rule.Message - picURL := evalContext.ImagePublicURL - title := evalContext.GetNotificationTitle() - if message == "" { - message = title - } - - for i, match := range evalContext.EvalMatches { - message += fmt.Sprintf("\n%2d. %s: %s", i+1, match.Metric, match.Value) - } - - var bodyMsg map[string]any - if dd.MsgType == "actionCard" { - // Embed the pic into the markdown directly because actionCard doesn't have a picUrl field - if dd.NeedsImage() && picURL != "" { - message = "![](" + picURL + ")\n\n" + message - } - - bodyMsg = map[string]any{ - "msgtype": "actionCard", - "actionCard": map[string]string{ - "text": message, - "title": title, - "singleTitle": "More", - "singleURL": messageURL, - }, - } - } else { - link := map[string]string{ - "text": message, - "title": title, - "messageUrl": messageURL, - } - - if dd.NeedsImage() { - link["picUrl"] = picURL - } - - bodyMsg = map[string]any{ - "msgtype": "link", - "link": link, - } - } - return json.Marshal(bodyMsg) -} diff --git a/pkg/services/alerting/notifiers/dingding_test.go b/pkg/services/alerting/notifiers/dingding_test.go deleted file mode 100644 index 0ff465837c1e3..0000000000000 --- a/pkg/services/alerting/notifiers/dingding_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package notifiers - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" - "github.com/grafana/grafana/pkg/services/validations" -) - -func TestDingDingNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "dingding_testing", - Type: "dingding", - Settings: settingsJSON, - } - - _, err := newDingDingNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - t.Run("settings should trigger incident", func(t *testing.T) { - json := `{ "url": "https://www.google.com" }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "dingding_testing", - Type: "dingding", - Settings: settingsJSON, - } - - not, err := newDingDingNotifier(model, encryptionService.GetDecryptedValue, nil) - notifier := not.(*DingDingNotifier) - - require.Nil(t, err) - require.Equal(t, "dingding_testing", notifier.Name) - require.Equal(t, "dingding", notifier.Type) - require.Equal(t, "https://www.google.com", notifier.URL) - - t.Run("genBody should not panic", func(t *testing.T) { - evalContext := alerting.NewEvalContext(context.Background(), - &alerting.Rule{ - State: models.AlertStateAlerting, - Message: `{host="localhost"}`, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - _, err = notifier.genBody(evalContext, "") - require.Nil(t, err) - }) - }) -} diff --git a/pkg/services/alerting/notifiers/discord.go b/pkg/services/alerting/notifiers/discord.go deleted file mode 100644 index b27336c3fa6e7..0000000000000 --- a/pkg/services/alerting/notifiers/discord.go +++ /dev/null @@ -1,250 +0,0 @@ -package notifiers - -import ( - "bytes" - "fmt" - "io" - "mime/multipart" - "os" - "strconv" - "strings" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" -) - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "discord", - Name: "Discord", - Description: "Sends notifications to Discord", - Factory: newDiscordNotifier, - Heading: "Discord settings", - Options: []alerting.NotifierOption{ - { - Label: "Avatar URL", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Description: "Provide a URL to an image to use as the avatar for the bot's message", - PropertyName: "avatar_url", - }, - { - Label: "Message Content", - Description: "Mention a group using <@&ID> or a user using <@ID> when notifying in a channel", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - PropertyName: "content", - }, - { - Label: "Webhook URL", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "Discord webhook URL", - PropertyName: "url", - Required: true, - }, - { - Label: "Use Discord's Webhook Username", - Description: "Use the username configured in Discord's webhook settings. Otherwise, the username will be 'Grafana'", - Element: alerting.ElementTypeCheckbox, - PropertyName: "use_discord_username", - }, - }, - }) -} - -func newDiscordNotifier(model *models.AlertNotification, _ alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - avatar := model.Settings.Get("avatar_url").MustString() - content := model.Settings.Get("content").MustString() - url := model.Settings.Get("url").MustString() - if url == "" { - return nil, alerting.ValidationError{Reason: "Could not find webhook url property in settings"} - } - useDiscordUsername := model.Settings.Get("use_discord_username").MustBool(false) - - return &DiscordNotifier{ - NotifierBase: NewNotifierBase(model, ns), - Content: content, - AvatarURL: avatar, - WebhookURL: url, - log: log.New("alerting.notifier.discord"), - UseDiscordUsername: useDiscordUsername, - }, nil -} - -// DiscordNotifier is responsible for sending alert -// notifications to discord. -type DiscordNotifier struct { - NotifierBase - Content string - AvatarURL string - WebhookURL string - log log.Logger - UseDiscordUsername bool -} - -// Notify send an alert notification to Discord. -func (dn *DiscordNotifier) Notify(evalContext *alerting.EvalContext) error { - dn.log.Info("Sending alert notification to", "webhook_url", dn.WebhookURL) - - ruleURL, err := evalContext.GetRuleURL() - if err != nil { - dn.log.Error("Failed get rule link", "error", err) - return err - } - - bodyJSON := simplejson.New() - if !dn.UseDiscordUsername { - bodyJSON.Set("username", "Grafana") - } - - if dn.Content != "" { - bodyJSON.Set("content", dn.Content) - } - - if dn.AvatarURL != "" { - bodyJSON.Set("avatar_url", dn.AvatarURL) - } - - fields := make([]map[string]any, 0) - - for _, evt := range evalContext.EvalMatches { - fields = append(fields, map[string]any{ - // Discord uniquely does not send the alert if the metric field is empty, - // which it can be in some cases - "name": notEmpty(evt.Metric), - "value": evt.Value.FullString(), - "inline": true, - }) - } - - footer := map[string]any{ - "text": "Grafana v" + setting.BuildVersion, - "icon_url": "https://grafana.com/static/assets/img/fav32.png", - } - - color, _ := strconv.ParseInt(strings.TrimLeft(evalContext.GetStateModel().Color, "#"), 16, 0) - - embed := simplejson.New() - embed.Set("title", evalContext.GetNotificationTitle()) - // Discord takes integer for color - embed.Set("color", color) - embed.Set("url", ruleURL) - embed.Set("description", evalContext.Rule.Message) - embed.Set("type", "rich") - embed.Set("fields", fields) - embed.Set("footer", footer) - - var image map[string]any - var embeddedImage = false - - if dn.NeedsImage() { - if evalContext.ImagePublicURL != "" { - image = map[string]any{ - "url": evalContext.ImagePublicURL, - } - embed.Set("image", image) - } else { - image = map[string]any{ - "url": "attachment://graph.png", - } - embed.Set("image", image) - embeddedImage = true - } - } - - bodyJSON.Set("embeds", []any{embed}) - - json, _ := bodyJSON.MarshalJSON() - - cmd := ¬ifications.SendWebhookSync{ - Url: dn.WebhookURL, - HttpMethod: "POST", - ContentType: "application/json", - } - - if !embeddedImage { - cmd.Body = string(json) - } else { - err := dn.embedImage(cmd, evalContext.ImageOnDiskPath, json) - if err != nil { - dn.log.Error("Failed to embed image", "error", err) - return err - } - } - - if err := dn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - dn.log.Error("Failed to send notification to Discord", "error", err) - return err - } - - return nil -} - -func (dn *DiscordNotifier) embedImage(cmd *notifications.SendWebhookSync, imagePath string, existingJSONBody []byte) error { - // nolint:gosec - // We can ignore the gosec G304 warning on this one because `imagePath` comes - // from the alert `evalContext` that generates the images. - f, err := os.Open(imagePath) - if err != nil { - if os.IsNotExist(err) { - cmd.Body = string(existingJSONBody) - return nil - } - if !os.IsNotExist(err) { - return err - } - } - defer func() { - if err := f.Close(); err != nil { - dn.log.Warn("Failed to close file", "path", imagePath, "err", err) - } - }() - - var b bytes.Buffer - w := multipart.NewWriter(&b) - defer func() { - if err := w.Close(); err != nil { - // Should be OK since we already close it on non-error path - dn.log.Warn("Failed to close multipart writer", "err", err) - } - }() - fw, err := w.CreateFormField("payload_json") - if err != nil { - return err - } - - if _, err = fw.Write([]byte(string(existingJSONBody))); err != nil { - return err - } - - fw, err = w.CreateFormFile("file", "graph.png") - if err != nil { - return err - } - - if _, err = io.Copy(fw, f); err != nil { - return err - } - - if err := w.Close(); err != nil { - return fmt.Errorf("failed to close multipart writer: %w", err) - } - - cmd.Body = b.String() - cmd.ContentType = w.FormDataContentType() - - return nil -} - -func notEmpty(metric string) string { - if metric == "" { - return "<NO_METRIC_NAME>" - } - - return metric -} diff --git a/pkg/services/alerting/notifiers/discord_test.go b/pkg/services/alerting/notifiers/discord_test.go deleted file mode 100644 index d173a85d21581..0000000000000 --- a/pkg/services/alerting/notifiers/discord_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package notifiers - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting/models" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" -) - -func TestDiscordNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("Parsing alert notification from settings", func(t *testing.T) { - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "discord_testing", - Type: "discord", - Settings: settingsJSON, - } - - _, err := newDiscordNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - - t.Run("settings should trigger incident", func(t *testing.T) { - json := ` - { - "avatar_url": "https://grafana.com/img/fav32.png", - "content": "@everyone Please check this notification", - "url": "https://web.hook/" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "discord_testing", - Type: "discord", - Settings: settingsJSON, - } - - not, err := newDiscordNotifier(model, encryptionService.GetDecryptedValue, nil) - discordNotifier := not.(*DiscordNotifier) - - require.Nil(t, err) - require.Equal(t, "discord_testing", discordNotifier.Name) - require.Equal(t, "discord", discordNotifier.Type) - require.Equal(t, "https://grafana.com/img/fav32.png", discordNotifier.AvatarURL) - require.Equal(t, "@everyone Please check this notification", discordNotifier.Content) - require.Equal(t, "https://web.hook/", discordNotifier.WebhookURL) - }) - }) -} diff --git a/pkg/services/alerting/notifiers/email.go b/pkg/services/alerting/notifiers/email.go deleted file mode 100644 index 08bfe23871973..0000000000000 --- a/pkg/services/alerting/notifiers/email.go +++ /dev/null @@ -1,125 +0,0 @@ -package notifiers - -import ( - "os" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" -) - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "email", - Name: "Email", - Description: "Sends notifications using Grafana server configured SMTP settings", - Factory: NewEmailNotifier, - Heading: "Email settings", - Options: []alerting.NotifierOption{ - { - Label: "Single email", - Description: "Send a single email to all recipients", - Element: alerting.ElementTypeCheckbox, - PropertyName: "singleEmail", - }, - { - Label: "Addresses", - Description: "You can enter multiple email addresses using a \";\" separator", - Element: alerting.ElementTypeTextArea, - PropertyName: "addresses", - Required: true, - }, - }, - }) -} - -// EmailNotifier is responsible for sending -// alert notifications over email. -type EmailNotifier struct { - NotifierBase - Addresses []string - SingleEmail bool - log log.Logger -} - -// NewEmailNotifier is the constructor function -// for the EmailNotifier. -func NewEmailNotifier(model *models.AlertNotification, _ alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - addressesString := model.Settings.Get("addresses").MustString() - singleEmail := model.Settings.Get("singleEmail").MustBool(false) - - if addressesString == "" { - return nil, alerting.ValidationError{Reason: "Could not find addresses in settings"} - } - - // split addresses with a few different ways - addresses := util.SplitEmails(addressesString) - - return &EmailNotifier{ - NotifierBase: NewNotifierBase(model, ns), - Addresses: addresses, - SingleEmail: singleEmail, - log: log.New("alerting.notifier.email"), - }, nil -} - -// Notify sends the alert notification. -func (en *EmailNotifier) Notify(evalContext *alerting.EvalContext) error { - en.log.Info("Sending alert notification to", "addresses", en.Addresses, "singleEmail", en.SingleEmail) - - ruleURL, err := evalContext.GetRuleURL() - if err != nil { - en.log.Error("Failed get rule link", "error", err) - return err - } - - error := "" - if evalContext.Error != nil { - error = evalContext.Error.Error() - } - - cmd := ¬ifications.SendEmailCommandSync{ - SendEmailCommand: notifications.SendEmailCommand{ - Subject: evalContext.GetNotificationTitle(), - Data: map[string]any{ - "Title": evalContext.GetNotificationTitle(), - "State": evalContext.Rule.State, - "Name": evalContext.Rule.Name, - "StateModel": evalContext.GetStateModel(), - "Message": evalContext.Rule.Message, - "Error": error, - "RuleUrl": ruleURL, - "ImageLink": "", - "EmbeddedImage": "", - "AlertPageUrl": setting.AppUrl + "alerting", - "EvalMatches": evalContext.EvalMatches, - }, - To: en.Addresses, - SingleEmail: en.SingleEmail, - Template: "alert_notification", - EmbeddedFiles: []string{}, - }, - } - - if en.NeedsImage() { - if evalContext.ImagePublicURL != "" { - cmd.Data["ImageLink"] = evalContext.ImagePublicURL - } else { - file, err := os.Stat(evalContext.ImageOnDiskPath) - if err == nil { - cmd.EmbeddedFiles = []string{evalContext.ImageOnDiskPath} - cmd.Data["EmbeddedImage"] = file.Name() - } - } - } - - if err := en.NotificationService.SendEmailCommandHandlerSync(evalContext.Ctx, cmd); err != nil { - en.log.Error("Failed to send alert notification email", "error", err) - return err - } - - return nil -} diff --git a/pkg/services/alerting/notifiers/email_test.go b/pkg/services/alerting/notifiers/email_test.go deleted file mode 100644 index 69ca1bffd21a7..0000000000000 --- a/pkg/services/alerting/notifiers/email_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package notifiers - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting/models" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" -) - -func TestEmailNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("Parsing alert notification from settings", func(t *testing.T) { - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "ops", - Type: "email", - Settings: settingsJSON, - } - - _, err := NewEmailNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - - t.Run("from settings", func(t *testing.T) { - json := ` - { - "addresses": "ops@grafana.org" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "ops", - Type: "email", - Settings: settingsJSON, - } - - not, err := NewEmailNotifier(model, encryptionService.GetDecryptedValue, nil) - emailNotifier := not.(*EmailNotifier) - - require.Nil(t, err) - require.Equal(t, "ops", emailNotifier.Name) - require.Equal(t, "email", emailNotifier.Type) - require.Equal(t, "ops@grafana.org", emailNotifier.Addresses[0]) - }) - - t.Run("from settings with two emails", func(t *testing.T) { - json := ` - { - "addresses": "ops@grafana.org;dev@grafana.org" - }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.Nil(t, err) - - model := &models.AlertNotification{ - Name: "ops", - Type: "email", - Settings: settingsJSON, - } - - not, err := NewEmailNotifier(model, encryptionService.GetDecryptedValue, nil) - emailNotifier := not.(*EmailNotifier) - - require.Nil(t, err) - require.Equal(t, "ops", emailNotifier.Name) - require.Equal(t, "email", emailNotifier.Type) - require.Equal(t, 2, len(emailNotifier.Addresses)) - - require.Equal(t, "ops@grafana.org", emailNotifier.Addresses[0]) - require.Equal(t, "dev@grafana.org", emailNotifier.Addresses[1]) - }) - }) -} diff --git a/pkg/services/alerting/notifiers/googlechat.go b/pkg/services/alerting/notifiers/googlechat.go deleted file mode 100644 index 7af742e64037b..0000000000000 --- a/pkg/services/alerting/notifiers/googlechat.go +++ /dev/null @@ -1,246 +0,0 @@ -package notifiers - -import ( - "encoding/json" - "fmt" - "net/url" - "time" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" -) - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "googlechat", - Name: "Google Hangouts Chat", - Description: "Sends notifications to Google Hangouts Chat via webhooks based on the official JSON message format", - Factory: newGoogleChatNotifier, - Heading: "Google Hangouts Chat settings", - Options: []alerting.NotifierOption{ - { - Label: "Url", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "Google Hangouts Chat incoming webhook url", - PropertyName: "url", - Required: true, - }, - }, - }) -} - -func newGoogleChatNotifier(model *models.AlertNotification, _ alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - url := model.Settings.Get("url").MustString() - if url == "" { - return nil, alerting.ValidationError{Reason: "Could not find url property in settings"} - } - - return &GoogleChatNotifier{ - NotifierBase: NewNotifierBase(model, ns), - URL: url, - log: log.New("alerting.notifier.googlechat"), - }, nil -} - -// GoogleChatNotifier is responsible for sending -// alert notifications to Google chat. -type GoogleChatNotifier struct { - NotifierBase - URL string - log log.Logger -} - -/* -* -Structs used to build a custom Google Hangouts Chat message card. -See: https://developers.google.com/hangouts/chat/reference/message-formats/cards -*/ -type outerStruct struct { - PreviewText string `json:"previewText"` - FallbackText string `json:"fallbackText"` - Cards []card `json:"cards"` -} - -type card struct { - Header header `json:"header"` - Sections []section `json:"sections"` -} - -type header struct { - Title string `json:"title"` -} - -type section struct { - Widgets []widget `json:"widgets"` -} - -// "generic" widget used to add different types of widgets (buttonWidget, textParagraphWidget, imageWidget) -type widget interface { -} - -type buttonWidget struct { - Buttons []button `json:"buttons"` -} - -type textParagraphWidget struct { - Text text `json:"textParagraph"` -} - -type text struct { - Text string `json:"text"` -} - -type imageWidget struct { - Image image `json:"image"` -} - -type image struct { - ImageURL string `json:"imageUrl"` -} - -type button struct { - TextButton textButton `json:"textButton"` -} - -type textButton struct { - Text string `json:"text"` - OnClick onClick `json:"onClick"` -} - -type onClick struct { - OpenLink openLink `json:"openLink"` -} - -type openLink struct { - URL string `json:"url"` -} - -// Notify send an alert notification to Google Chat. -func (gcn *GoogleChatNotifier) Notify(evalContext *alerting.EvalContext) error { - gcn.log.Info("Executing Google Chat notification") - - headers := map[string]string{ - "Content-Type": "application/json; charset=UTF-8", - } - - ruleURL, err := evalContext.GetRuleURL() - if err != nil { - gcn.log.Error("EvalContext returned an invalid rule URL") - } - - widgets := []widget{} - if len(evalContext.Rule.Message) > 0 { - // add a text paragraph widget for the message if there is a message - // Google Chat API doesn't accept an empty text property - widgets = append(widgets, textParagraphWidget{ - Text: text{ - Text: evalContext.Rule.Message, - }, - }) - } - - // add a text paragraph widget for the fields - //nolint:prealloc // break block - var fields []textParagraphWidget - fieldLimitCount := 4 - for index, evt := range evalContext.EvalMatches { - fields = append(fields, - textParagraphWidget{ - Text: text{ - Text: "<i>" + evt.Metric + ": " + fmt.Sprint(evt.Value) + "</i>", - }, - }, - ) - if index > fieldLimitCount { - break - } - } - widgets = append(widgets, fields) - - if gcn.NeedsImage() { - // if an image exists, add it as an image widget - if evalContext.ImagePublicURL != "" { - widgets = append(widgets, imageWidget{ - Image: image{ - ImageURL: evalContext.ImagePublicURL, - }, - }) - } else { - gcn.log.Info("Could not retrieve a public image URL.") - } - } - - if gcn.isUrlAbsolute(ruleURL) { - // add a button widget (link to Grafana) - widgets = append(widgets, buttonWidget{ - Buttons: []button{ - { - TextButton: textButton{ - Text: "OPEN IN GRAFANA", - OnClick: onClick{ - OpenLink: openLink{ - URL: ruleURL, - }, - }, - }, - }, - }, - }) - } else { - gcn.log.Warn("Grafana External URL setting is missing or invalid. Skipping 'open in grafana' button to prevent google from displaying empty alerts.", "ruleURL", ruleURL) - } - - // add text paragraph widget for the build version and timestamp - widgets = append(widgets, textParagraphWidget{ - Text: text{ - Text: "Grafana v" + setting.BuildVersion + " | " + (time.Now()).Format(time.RFC822), - }, - }) - - // nest the required structs - res1D := &outerStruct{ - PreviewText: evalContext.GetNotificationTitle(), - FallbackText: evalContext.GetNotificationTitle(), - Cards: []card{ - { - Header: header{ - Title: evalContext.GetNotificationTitle(), - }, - Sections: []section{ - { - Widgets: widgets, - }, - }, - }, - }, - } - body, _ := json.Marshal(res1D) - - cmd := ¬ifications.SendWebhookSync{ - Url: gcn.URL, - HttpMethod: "POST", - HttpHeader: headers, - Body: string(body), - } - - if err := gcn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - gcn.log.Error("Failed to send Google Hangouts Chat alert", "error", err, "webhook", gcn.Name) - return err - } - - return nil -} - -func (gcn *GoogleChatNotifier) isUrlAbsolute(urlToCheck string) bool { - parsed, err := url.Parse(urlToCheck) - if err != nil { - gcn.log.Warn("Could not parse URL", "urlToCheck", urlToCheck) - return false - } - - return parsed.IsAbs() -} diff --git a/pkg/services/alerting/notifiers/googlechat_test.go b/pkg/services/alerting/notifiers/googlechat_test.go deleted file mode 100644 index 48c102a1fe7f8..0000000000000 --- a/pkg/services/alerting/notifiers/googlechat_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package notifiers - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting/models" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" -) - -func TestGoogleChatNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("Parsing alert notification from settings", func(t *testing.T) { - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "ops", - Type: "googlechat", - Settings: settingsJSON, - } - - _, err := newGoogleChatNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - - t.Run("from settings", func(t *testing.T) { - json := ` - { - "url": "http://google.com" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "ops", - Type: "googlechat", - Settings: settingsJSON, - } - - not, err := newGoogleChatNotifier(model, encryptionService.GetDecryptedValue, nil) - webhookNotifier := not.(*GoogleChatNotifier) - - require.Nil(t, err) - require.Equal(t, "ops", webhookNotifier.Name) - require.Equal(t, "googlechat", webhookNotifier.Type) - require.Equal(t, "http://google.com", webhookNotifier.URL) - }) - }) -} diff --git a/pkg/services/alerting/notifiers/hipchat.go b/pkg/services/alerting/notifiers/hipchat.go deleted file mode 100644 index 8581773107769..0000000000000 --- a/pkg/services/alerting/notifiers/hipchat.go +++ /dev/null @@ -1,183 +0,0 @@ -package notifiers - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" -) - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "hipchat", - Name: "HipChat", - Description: "Sends notifications uto a HipChat Room", - Heading: "HipChat settings", - Factory: NewHipChatNotifier, - Options: []alerting.NotifierOption{ - { - Label: "Hip Chat Url", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "HipChat URL (ex https://grafana.hipchat.com)", - PropertyName: "url", - Required: true, - }, - { - Label: "API Key", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "HipChat API Key", - PropertyName: "apiKey", - Required: true, - }, - { - Label: "Room ID", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - PropertyName: "roomid", - }, - }, - }) -} - -const ( - maxFieldCount int = 4 -) - -// NewHipChatNotifier is the constructor functions -// for the HipChatNotifier -func NewHipChatNotifier(model *models.AlertNotification, _ alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - url := model.Settings.Get("url").MustString() - url = strings.TrimSuffix(url, "/") - if url == "" { - return nil, alerting.ValidationError{Reason: "Could not find url property in settings"} - } - - apikey := model.Settings.Get("apikey").MustString() - roomID := model.Settings.Get("roomid").MustString() - - return &HipChatNotifier{ - NotifierBase: NewNotifierBase(model, ns), - URL: url, - APIKey: apikey, - RoomID: roomID, - log: log.New("alerting.notifier.hipchat"), - }, nil -} - -// HipChatNotifier is responsible for sending -// alert notifications to Hipchat. -type HipChatNotifier struct { - NotifierBase - URL string - APIKey string - RoomID string - log log.Logger -} - -// Notify sends an alert notification to HipChat -func (hc *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error { - hc.log.Info("Executing hipchat notification", "ruleId", evalContext.Rule.ID, "notification", hc.Name) - - ruleURL, err := evalContext.GetRuleURL() - if err != nil { - hc.log.Error("Failed get rule link", "error", err) - return err - } - - attributes := make([]map[string]any, 0) - for index, evt := range evalContext.EvalMatches { - metricName := evt.Metric - if len(metricName) > 50 { - metricName = metricName[:50] - } - attributes = append(attributes, map[string]any{ - "label": metricName, - "value": map[string]any{ - "label": strconv.FormatFloat(evt.Value.Float64, 'f', -1, 64), - }, - }) - if index > maxFieldCount { - break - } - } - - if evalContext.Error != nil { - attributes = append(attributes, map[string]any{ - "label": "Error message", - "value": map[string]any{ - "label": evalContext.Error.Error(), - }, - }) - } - - message := "" - if evalContext.Rule.State != models.AlertStateOK { // don't add message when going back to alert state ok. - message += " " + evalContext.Rule.Message - } - - if message == "" { - message = evalContext.GetNotificationTitle() + " in state " + evalContext.GetStateModel().Text - } - - // HipChat has a set list of colors - var color string - switch evalContext.Rule.State { - case models.AlertStateOK: - color = "green" - case models.AlertStateNoData: - color = "gray" - case models.AlertStateAlerting: - color = "red" - default: - // Handle other cases? - } - - // Add a card with link to the dashboard - card := map[string]any{ - "style": "application", - "url": ruleURL, - "id": "1", - "title": evalContext.GetNotificationTitle(), - "description": message, - "icon": map[string]any{ - "url": "https://grafana.com/static/assets/img/fav32.png", - }, - "date": evalContext.EndTime.Unix(), - "attributes": attributes, - } - if hc.NeedsImage() && evalContext.ImagePublicURL != "" { - card["thumbnail"] = map[string]any{ - "url": evalContext.ImagePublicURL, - "url@2x": evalContext.ImagePublicURL, - "width": 1193, - "height": 564, - } - } - - body := map[string]any{ - "message": message, - "notify": "true", - "message_format": "html", - "color": color, - "card": card, - } - - hipURL := fmt.Sprintf("%s/v2/room/%s/notification?auth_token=%s", hc.URL, hc.RoomID, hc.APIKey) - data, _ := json.Marshal(&body) - hc.log.Info("Request payload", "json", string(data)) - cmd := ¬ifications.SendWebhookSync{Url: hipURL, Body: string(data)} - - if err := hc.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - hc.log.Error("Failed to send hipchat notification", "error", err, "webhook", hc.Name) - return err - } - - return nil -} diff --git a/pkg/services/alerting/notifiers/hipchat_test.go b/pkg/services/alerting/notifiers/hipchat_test.go deleted file mode 100644 index 625eb14da8c1f..0000000000000 --- a/pkg/services/alerting/notifiers/hipchat_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package notifiers - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting/models" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" -) - -//nolint:goconst -func TestHipChatNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("Parsing alert notification from settings", func(t *testing.T) { - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "ops", - Type: "hipchat", - Settings: settingsJSON, - } - - _, err := NewHipChatNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - - t.Run("from settings", func(t *testing.T) { - json := ` - { - "url": "http://google.com" - }` - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "ops", - Type: "hipchat", - Settings: settingsJSON, - } - - not, err := NewHipChatNotifier(model, encryptionService.GetDecryptedValue, nil) - hipchatNotifier := not.(*HipChatNotifier) - - require.Nil(t, err) - require.Equal(t, "ops", hipchatNotifier.Name) - require.Equal(t, "hipchat", hipchatNotifier.Type) - require.Equal(t, "http://google.com", hipchatNotifier.URL) - require.Equal(t, "", hipchatNotifier.APIKey) - require.Equal(t, "", hipchatNotifier.RoomID) - }) - - t.Run("from settings with Recipient and Mention", func(t *testing.T) { - json := ` - { - "url": "http://www.hipchat.com", - "apikey": "1234", - "roomid": "1234" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "ops", - Type: "hipchat", - Settings: settingsJSON, - } - - not, err := NewHipChatNotifier(model, encryptionService.GetDecryptedValue, nil) - hipchatNotifier := not.(*HipChatNotifier) - - require.Nil(t, err) - require.Equal(t, "ops", hipchatNotifier.Name) - require.Equal(t, "hipchat", hipchatNotifier.Type) - require.Equal(t, "http://www.hipchat.com", hipchatNotifier.URL) - require.Equal(t, "1234", hipchatNotifier.APIKey) - require.Equal(t, "1234", hipchatNotifier.RoomID) - }) - }) -} diff --git a/pkg/services/alerting/notifiers/kafka.go b/pkg/services/alerting/notifiers/kafka.go deleted file mode 100644 index 601e64351ca7f..0000000000000 --- a/pkg/services/alerting/notifiers/kafka.go +++ /dev/null @@ -1,132 +0,0 @@ -package notifiers - -import ( - "fmt" - "strconv" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" -) - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "kafka", - Name: "Kafka REST Proxy", - Description: "Sends notifications to Kafka Rest Proxy", - Heading: "Kafka settings", - Factory: NewKafkaNotifier, - Options: []alerting.NotifierOption{ - { - Label: "Kafka REST Proxy", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "http://localhost:8082", - PropertyName: "kafkaRestProxy", - Required: true, - }, - { - Label: "Topic", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "topic1", - PropertyName: "kafkaTopic", - Required: true, - }, - }, - }) -} - -// NewKafkaNotifier is the constructor function for the Kafka notifier. -func NewKafkaNotifier(model *models.AlertNotification, _ alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - endpoint := model.Settings.Get("kafkaRestProxy").MustString() - if endpoint == "" { - return nil, alerting.ValidationError{Reason: "Could not find kafka rest proxy endpoint property in settings"} - } - topic := model.Settings.Get("kafkaTopic").MustString() - if topic == "" { - return nil, alerting.ValidationError{Reason: "Could not find kafka topic property in settings"} - } - - return &KafkaNotifier{ - NotifierBase: NewNotifierBase(model, ns), - Endpoint: endpoint, - Topic: topic, - log: log.New("alerting.notifier.kafka"), - }, nil -} - -// KafkaNotifier is responsible for sending -// alert notifications to Kafka. -type KafkaNotifier struct { - NotifierBase - Endpoint string - Topic string - log log.Logger -} - -// Notify sends the alert notification. -func (kn *KafkaNotifier) Notify(evalContext *alerting.EvalContext) error { - state := evalContext.Rule.State - - customData := triggMetrString - for _, evt := range evalContext.EvalMatches { - customData += fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value) - } - - kn.log.Info("Notifying Kafka", "alert_state", state) - - recordJSON := simplejson.New() - records := make([]any, 1) - - bodyJSON := simplejson.New() - // get alert state in the kafka output issue #11401 - bodyJSON.Set("alert_state", state) - bodyJSON.Set("description", evalContext.Rule.Name+" - "+evalContext.Rule.Message) - bodyJSON.Set("client", "Grafana") - bodyJSON.Set("details", customData) - bodyJSON.Set("incident_key", "alertId-"+strconv.FormatInt(evalContext.Rule.ID, 10)) - - ruleURL, err := evalContext.GetRuleURL() - if err != nil { - kn.log.Error("Failed get rule link", "error", err) - return err - } - bodyJSON.Set("client_url", ruleURL) - - if kn.NeedsImage() && evalContext.ImagePublicURL != "" { - contexts := make([]any, 1) - imageJSON := simplejson.New() - imageJSON.Set("type", "image") - imageJSON.Set("src", evalContext.ImagePublicURL) - contexts[0] = imageJSON - bodyJSON.Set("contexts", contexts) - } - - valueJSON := simplejson.New() - valueJSON.Set("value", bodyJSON) - records[0] = valueJSON - recordJSON.Set("records", records) - body, _ := recordJSON.MarshalJSON() - - topicURL := kn.Endpoint + "/topics/" + kn.Topic - - cmd := ¬ifications.SendWebhookSync{ - Url: topicURL, - Body: string(body), - HttpMethod: "POST", - HttpHeader: map[string]string{ - "Content-Type": "application/vnd.kafka.json.v2+json", - "Accept": "application/vnd.kafka.v2+json", - }, - } - - if err := kn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - kn.log.Error("Failed to send notification to Kafka", "error", err, "body", string(body)) - return err - } - - return nil -} diff --git a/pkg/services/alerting/notifiers/kafka_test.go b/pkg/services/alerting/notifiers/kafka_test.go deleted file mode 100644 index 95dbaedf57d1d..0000000000000 --- a/pkg/services/alerting/notifiers/kafka_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package notifiers - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting/models" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" -) - -func TestKafkaNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("Parsing alert notification from settings", func(t *testing.T) { - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "kafka_testing", - Type: "kafka", - Settings: settingsJSON, - } - - _, err := NewKafkaNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - - t.Run("settings should send an event to kafka", func(t *testing.T) { - json := ` - { - "kafkaRestProxy": "http://localhost:8082", - "kafkaTopic": "topic1" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "kafka_testing", - Type: "kafka", - Settings: settingsJSON, - } - - not, err := NewKafkaNotifier(model, encryptionService.GetDecryptedValue, nil) - kafkaNotifier := not.(*KafkaNotifier) - - require.Nil(t, err) - require.Equal(t, "kafka_testing", kafkaNotifier.Name) - require.Equal(t, "kafka", kafkaNotifier.Type) - require.Equal(t, "http://localhost:8082", kafkaNotifier.Endpoint) - require.Equal(t, "topic1", kafkaNotifier.Topic) - }) - }) -} diff --git a/pkg/services/alerting/notifiers/line.go b/pkg/services/alerting/notifiers/line.go deleted file mode 100644 index 3bf4d09831ed6..0000000000000 --- a/pkg/services/alerting/notifiers/line.go +++ /dev/null @@ -1,101 +0,0 @@ -package notifiers - -import ( - "context" - "fmt" - "net/url" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" -) - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "LINE", - Name: "LINE", - Description: "Send notifications to LINE notify", - Heading: "LINE notify settings", - Factory: NewLINENotifier, - Options: []alerting.NotifierOption{ - { - Label: "Token", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "LINE notify token key", - PropertyName: "token", - Required: true, - Secure: true, - }}, - }) -} - -const ( - lineNotifyURL string = "https://notify-api.line.me/api/notify" -) - -// NewLINENotifier is the constructor for the LINE notifier -func NewLINENotifier(model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - token := fn(context.Background(), model.SecureSettings, "token", model.Settings.Get("token").MustString(), setting.SecretKey) - if token == "" { - return nil, alerting.ValidationError{Reason: "Could not find token in settings"} - } - - return &LineNotifier{ - NotifierBase: NewNotifierBase(model, ns), - Token: token, - log: log.New("alerting.notifier.line"), - }, nil -} - -// LineNotifier is responsible for sending -// alert notifications to LINE. -type LineNotifier struct { - NotifierBase - Token string - log log.Logger -} - -// Notify send an alert notification to LINE -func (ln *LineNotifier) Notify(evalContext *alerting.EvalContext) error { - ln.log.Info("Executing line notification", "ruleId", evalContext.Rule.ID, "notification", ln.Name) - - return ln.createAlert(evalContext) -} - -func (ln *LineNotifier) createAlert(evalContext *alerting.EvalContext) error { - ln.log.Info("Creating Line notify", "ruleId", evalContext.Rule.ID, "notification", ln.Name) - ruleURL, err := evalContext.GetRuleURL() - if err != nil { - ln.log.Error("Failed get rule link", "error", err) - return err - } - - form := url.Values{} - body := fmt.Sprintf("%s - %s\n%s", evalContext.GetNotificationTitle(), ruleURL, evalContext.Rule.Message) - form.Add("message", body) - - if ln.NeedsImage() && evalContext.ImagePublicURL != "" { - form.Add("imageThumbnail", evalContext.ImagePublicURL) - form.Add("imageFullsize", evalContext.ImagePublicURL) - } - - cmd := ¬ifications.SendWebhookSync{ - Url: lineNotifyURL, - HttpMethod: "POST", - HttpHeader: map[string]string{ - "Authorization": fmt.Sprintf("Bearer %s", ln.Token), - "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", - }, - Body: form.Encode(), - } - - if err := ln.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - ln.log.Error("Failed to send notification to LINE", "error", err, "body", body) - return err - } - - return nil -} diff --git a/pkg/services/alerting/notifiers/line_test.go b/pkg/services/alerting/notifiers/line_test.go deleted file mode 100644 index c6086e09c3b61..0000000000000 --- a/pkg/services/alerting/notifiers/line_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package notifiers - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting/models" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" -) - -func TestLineNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "line_testing", - Type: "line", - Settings: settingsJSON, - } - - _, err := NewLINENotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - t.Run("settings should trigger incident", func(t *testing.T) { - json := ` - { - "token": "abcdefgh0123456789" - }` - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "line_testing", - Type: "line", - Settings: settingsJSON, - } - - not, err := NewLINENotifier(model, encryptionService.GetDecryptedValue, nil) - lineNotifier := not.(*LineNotifier) - - require.Nil(t, err) - require.Equal(t, "line_testing", lineNotifier.Name) - require.Equal(t, "line", lineNotifier.Type) - require.Equal(t, "abcdefgh0123456789", lineNotifier.Token) - }) -} diff --git a/pkg/services/alerting/notifiers/opsgenie.go b/pkg/services/alerting/notifiers/opsgenie.go deleted file mode 100644 index dcf6b7b35ee63..0000000000000 --- a/pkg/services/alerting/notifiers/opsgenie.go +++ /dev/null @@ -1,246 +0,0 @@ -package notifiers - -import ( - "context" - "fmt" - "strconv" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" -) - -const ( - sendTags = "tags" - sendDetails = "details" - sendBoth = "both" -) - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "opsgenie", - Name: "OpsGenie", - Description: "Sends notifications to OpsGenie", - Heading: "OpsGenie settings", - Factory: NewOpsGenieNotifier, - Options: []alerting.NotifierOption{ - { - Label: "API Key", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "OpsGenie API Key", - PropertyName: "apiKey", - Required: true, - Secure: true, - }, - { - Label: "Alert API Url", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "https://api.opsgenie.com/v2/alerts", - PropertyName: "apiUrl", - Required: true, - }, - { - Label: "Auto close incidents", - Element: alerting.ElementTypeCheckbox, - Description: "Automatically close alerts in OpsGenie once the alert goes back to ok.", - PropertyName: "autoClose", - }, { - Label: "Override priority", - Element: alerting.ElementTypeCheckbox, - Description: "Allow the alert priority to be set using the og_priority tag", - PropertyName: "overridePriority", - }, - { - Label: "Send notification tags as", - Element: alerting.ElementTypeSelect, - SelectOptions: []alerting.SelectOption{ - { - Value: sendTags, - Label: "Tags", - }, - { - Value: sendDetails, - Label: "Extra Properties", - }, - { - Value: sendBoth, - Label: "Tags & Extra Properties", - }, - }, - Description: "Send the notification tags to Opsgenie as either Extra Properties, Tags or both", - PropertyName: "sendTagsAs", - }, - }, - }) -} - -const ( - opsgenieAlertURL = "https://api.opsgenie.com/v2/alerts" -) - -// NewOpsGenieNotifier is the constructor for OpsGenie. -func NewOpsGenieNotifier(model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - autoClose := model.Settings.Get("autoClose").MustBool(true) - overridePriority := model.Settings.Get("overridePriority").MustBool(true) - apiKey := fn(context.Background(), model.SecureSettings, "apiKey", model.Settings.Get("apiKey").MustString(), setting.SecretKey) - apiURL := model.Settings.Get("apiUrl").MustString() - if apiKey == "" { - return nil, alerting.ValidationError{Reason: "Could not find api key property in settings"} - } - if apiURL == "" { - apiURL = opsgenieAlertURL - } - - sendTagsAs := model.Settings.Get("sendTagsAs").MustString(sendTags) - if sendTagsAs != sendTags && sendTagsAs != sendDetails && sendTagsAs != sendBoth { - return nil, alerting.ValidationError{ - Reason: fmt.Sprintf("Invalid value for sendTagsAs: %q", sendTagsAs), - } - } - - return &OpsGenieNotifier{ - NotifierBase: NewNotifierBase(model, ns), - APIKey: apiKey, - APIUrl: apiURL, - AutoClose: autoClose, - OverridePriority: overridePriority, - SendTagsAs: sendTagsAs, - log: log.New("alerting.notifier.opsgenie"), - }, nil -} - -// OpsGenieNotifier is responsible for sending -// alert notifications to OpsGenie -type OpsGenieNotifier struct { - NotifierBase - APIKey string - APIUrl string - AutoClose bool - OverridePriority bool - SendTagsAs string - log log.Logger -} - -// Notify sends an alert notification to OpsGenie. -func (on *OpsGenieNotifier) Notify(evalContext *alerting.EvalContext) error { - var err error - switch evalContext.Rule.State { - case models.AlertStateOK: - if on.AutoClose { - err = on.closeAlert(evalContext) - } - case models.AlertStateAlerting: - err = on.createAlert(evalContext) - default: - // Handle other cases? - } - return err -} - -func (on *OpsGenieNotifier) createAlert(evalContext *alerting.EvalContext) error { - on.log.Info("Creating OpsGenie alert", "ruleId", evalContext.Rule.ID, "notification", on.Name) - - ruleURL, err := evalContext.GetRuleURL() - if err != nil { - on.log.Error("Failed get rule link", "error", err) - return err - } - - customData := triggMetrString - for _, evt := range evalContext.EvalMatches { - customData += fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value) - } - - bodyJSON := simplejson.New() - bodyJSON.Set("message", evalContext.Rule.Name) - bodyJSON.Set("source", "Grafana") - bodyJSON.Set("alias", "alertId-"+strconv.FormatInt(evalContext.Rule.ID, 10)) - bodyJSON.Set("description", fmt.Sprintf("%s - %s\n%s\n%s", evalContext.Rule.Name, ruleURL, evalContext.Rule.Message, customData)) - - details := simplejson.New() - details.Set("url", ruleURL) - if on.NeedsImage() && evalContext.ImagePublicURL != "" { - details.Set("image", evalContext.ImagePublicURL) - } - - tags := make([]string, 0) - for _, tag := range evalContext.Rule.AlertRuleTags { - if on.sendDetails() { - details.Set(tag.Key, tag.Value) - } - - if on.sendTags() { - if len(tag.Value) > 0 { - tags = append(tags, fmt.Sprintf("%s:%s", tag.Key, tag.Value)) - } else { - tags = append(tags, tag.Key) - } - } - if tag.Key == "og_priority" { - if on.OverridePriority { - validPriorities := map[string]bool{"P1": true, "P2": true, "P3": true, "P4": true, "P5": true} - if validPriorities[tag.Value] { - bodyJSON.Set("priority", tag.Value) - } - } - } - } - bodyJSON.Set("tags", tags) - bodyJSON.Set("details", details) - - body, _ := bodyJSON.MarshalJSON() - - cmd := ¬ifications.SendWebhookSync{ - Url: on.APIUrl, - Body: string(body), - HttpMethod: "POST", - HttpHeader: map[string]string{ - "Content-Type": "application/json", - "Authorization": fmt.Sprintf("GenieKey %s", on.APIKey), - }, - } - - if err := on.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - on.log.Error("Failed to send notification to OpsGenie", "error", err, "body", string(body)) - } - - return nil -} - -func (on *OpsGenieNotifier) closeAlert(evalContext *alerting.EvalContext) error { - on.log.Info("Closing OpsGenie alert", "ruleId", evalContext.Rule.ID, "notification", on.Name) - - bodyJSON := simplejson.New() - bodyJSON.Set("source", "Grafana") - body, _ := bodyJSON.MarshalJSON() - - cmd := ¬ifications.SendWebhookSync{ - Url: fmt.Sprintf("%s/alertId-%d/close?identifierType=alias", on.APIUrl, evalContext.Rule.ID), - Body: string(body), - HttpMethod: "POST", - HttpHeader: map[string]string{ - "Content-Type": "application/json", - "Authorization": fmt.Sprintf("GenieKey %s", on.APIKey), - }, - } - - if err := on.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - on.log.Error("Failed to send notification to OpsGenie", "error", err, "body", string(body)) - return err - } - - return nil -} - -func (on *OpsGenieNotifier) sendDetails() bool { - return on.SendTagsAs == sendDetails || on.SendTagsAs == sendBoth -} - -func (on *OpsGenieNotifier) sendTags() bool { - return on.SendTagsAs == sendTags || on.SendTagsAs == sendBoth -} diff --git a/pkg/services/alerting/notifiers/opsgenie_test.go b/pkg/services/alerting/notifiers/opsgenie_test.go deleted file mode 100644 index 1ff97eef0745a..0000000000000 --- a/pkg/services/alerting/notifiers/opsgenie_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package notifiers - -import ( - "context" - "reflect" - "strings" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/services/tag" - "github.com/grafana/grafana/pkg/services/validations" -) - -func TestOpsGenieNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("Parsing alert notification from settings", func(t *testing.T) { - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "opsgenie_testing", - Type: "opsgenie", - Settings: settingsJSON, - } - - _, err := NewOpsGenieNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - - t.Run("settings should trigger incident", func(t *testing.T) { - json := ` - { - "apiKey": "abcdefgh0123456789" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "opsgenie_testing", - Type: "opsgenie", - Settings: settingsJSON, - } - - not, err := NewOpsGenieNotifier(model, encryptionService.GetDecryptedValue, nil) - opsgenieNotifier := not.(*OpsGenieNotifier) - - require.Nil(t, err) - require.Equal(t, "opsgenie_testing", opsgenieNotifier.Name) - require.Equal(t, "opsgenie", opsgenieNotifier.Type) - require.Equal(t, "abcdefgh0123456789", opsgenieNotifier.APIKey) - }) - }) - - t.Run("Handling notification tags", func(t *testing.T) { - t.Run("invalid sendTagsAs value should return error", func(t *testing.T) { - json := `{ - "apiKey": "abcdefgh0123456789", - "sendTagsAs": "not_a_valid_value" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "opsgenie_testing", - Type: "opsgenie", - Settings: settingsJSON, - } - - _, err := NewOpsGenieNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - require.Equal(t, reflect.TypeOf(err), reflect.TypeOf(alerting.ValidationError{})) - require.True(t, strings.HasSuffix(err.Error(), "Invalid value for sendTagsAs: \"not_a_valid_value\"")) - }) - - t.Run("alert payload should include tag pairs only as an array in the tags key when sendAsTags is not set", func(t *testing.T) { - json := `{ - "apiKey": "abcdefgh0123456789" - }` - - tagPairs := []*tag.Tag{ - {Key: "keyOnly"}, - {Key: "aKey", Value: "aValue"}, - } - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "opsgenie_testing", - Type: "opsgenie", - Settings: settingsJSON, - } - - notificationService := notifications.MockNotificationService() - notifier, notifierErr := NewOpsGenieNotifier(model, encryptionService.GetDecryptedValue, notificationService) // unhandled error - - opsgenieNotifier := notifier.(*OpsGenieNotifier) - - evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{ - ID: 0, - Name: "someRule", - Message: "someMessage", - State: models.AlertStateAlerting, - AlertRuleTags: tagPairs, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - evalContext.IsTestRun = true - - tags := make([]string, 0) - details := make(map[string]any) - - alertErr := opsgenieNotifier.createAlert(evalContext) - - bodyJSON, err := simplejson.NewJson([]byte(notificationService.Webhook.Body)) - if err == nil { - tags = bodyJSON.Get("tags").MustStringArray([]string{}) - details = bodyJSON.Get("details").MustMap(map[string]any{}) - } - - require.Nil(t, notifierErr) - require.Nil(t, alertErr) - require.Equal(t, tags, []string{"keyOnly", "aKey:aValue"}) - require.Equal(t, details, map[string]any{"url": ""}) - }) - - t.Run("alert payload should include tag pairs only as a map in the details key when sendAsTags=details", func(t *testing.T) { - json := `{ - "apiKey": "abcdefgh0123456789", - "sendTagsAs": "details" - }` - - tagPairs := []*tag.Tag{ - {Key: "keyOnly"}, - {Key: "aKey", Value: "aValue"}, - } - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "opsgenie_testing", - Type: "opsgenie", - Settings: settingsJSON, - } - - notificationService := notifications.MockNotificationService() - notifier, notifierErr := NewOpsGenieNotifier(model, encryptionService.GetDecryptedValue, notificationService) // unhandled error - - opsgenieNotifier := notifier.(*OpsGenieNotifier) - - evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{ - ID: 0, - Name: "someRule", - Message: "someMessage", - State: models.AlertStateAlerting, - AlertRuleTags: tagPairs, - }, nil, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - evalContext.IsTestRun = true - - tags := make([]string, 0) - details := make(map[string]any) - - alertErr := opsgenieNotifier.createAlert(evalContext) - - bodyJSON, err := simplejson.NewJson([]byte(notificationService.Webhook.Body)) - if err == nil { - tags = bodyJSON.Get("tags").MustStringArray([]string{}) - details = bodyJSON.Get("details").MustMap(map[string]any{}) - } - - require.Nil(t, notifierErr) - require.Nil(t, alertErr) - require.Equal(t, tags, []string{}) - require.Equal(t, details, map[string]any{"keyOnly": "", "aKey": "aValue", "url": ""}) - }) - - t.Run("alert payload should include tag pairs as both a map in the details key and an array in the tags key when sendAsTags=both", func(t *testing.T) { - json := `{ - "apiKey": "abcdefgh0123456789", - "sendTagsAs": "both" - }` - - tagPairs := []*tag.Tag{ - {Key: "keyOnly"}, - {Key: "aKey", Value: "aValue"}, - } - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "opsgenie_testing", - Type: "opsgenie", - Settings: settingsJSON, - } - - notificationService := notifications.MockNotificationService() - notifier, notifierErr := NewOpsGenieNotifier(model, encryptionService.GetDecryptedValue, notificationService) // unhandled error - - opsgenieNotifier := notifier.(*OpsGenieNotifier) - - evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{ - ID: 0, - Name: "someRule", - Message: "someMessage", - State: models.AlertStateAlerting, - AlertRuleTags: tagPairs, - }, nil, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - evalContext.IsTestRun = true - - tags := make([]string, 0) - details := make(map[string]any) - - alertErr := opsgenieNotifier.createAlert(evalContext) - - bodyJSON, err := simplejson.NewJson([]byte(notificationService.Webhook.Body)) - if err == nil { - tags = bodyJSON.Get("tags").MustStringArray([]string{}) - details = bodyJSON.Get("details").MustMap(map[string]any{}) - } - - require.Nil(t, notifierErr) - require.Nil(t, alertErr) - require.Equal(t, tags, []string{"keyOnly", "aKey:aValue"}) - require.Equal(t, details, map[string]any{"keyOnly": "", "aKey": "aValue", "url": ""}) - }) - }) -} diff --git a/pkg/services/alerting/notifiers/pagerduty.go b/pkg/services/alerting/notifiers/pagerduty.go deleted file mode 100644 index 2ecf083328556..0000000000000 --- a/pkg/services/alerting/notifiers/pagerduty.go +++ /dev/null @@ -1,248 +0,0 @@ -package notifiers - -import ( - "context" - "os" - "strconv" - "strings" - "time" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" -) - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "pagerduty", - Name: "PagerDuty", - Description: "Sends notifications to PagerDuty", - Heading: "PagerDuty settings", - Factory: NewPagerdutyNotifier, - Options: []alerting.NotifierOption{ - { - Label: "Integration Key", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "Pagerduty Integration Key", - PropertyName: "integrationKey", - Required: true, - Secure: true, - }, - { - Label: "Severity", - Element: alerting.ElementTypeSelect, - SelectOptions: []alerting.SelectOption{ - { - Value: "critical", - Label: "Critical", - }, - { - Value: "error", - Label: "Error", - }, - { - Value: "warning", - Label: "Warning", - }, - { - Value: "info", - Label: "Info", - }, - }, - PropertyName: "severity", - }, - { - Label: "Auto resolve incidents", - Element: alerting.ElementTypeCheckbox, - Description: "Resolve incidents in pagerduty once the alert goes back to ok.", - PropertyName: "autoResolve", - }, - { - Label: "Include message in details", - Element: alerting.ElementTypeCheckbox, - Description: "Move the alert message from the PD summary into the custom details. This changes the custom details object and may break event rules you have configured", - PropertyName: "messageInDetails", - }, - }, - }) -} - -var ( - pagerdutyEventAPIURL = "https://events.pagerduty.com/v2/enqueue" -) - -// NewPagerdutyNotifier is the constructor for the PagerDuty notifier -func NewPagerdutyNotifier(model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - severity := model.Settings.Get("severity").MustString("critical") - autoResolve := model.Settings.Get("autoResolve").MustBool(false) - key := fn(context.Background(), model.SecureSettings, "integrationKey", model.Settings.Get("integrationKey").MustString(), setting.SecretKey) - messageInDetails := model.Settings.Get("messageInDetails").MustBool(false) - if key == "" { - return nil, alerting.ValidationError{Reason: "Could not find integration key property in settings"} - } - - return &PagerdutyNotifier{ - NotifierBase: NewNotifierBase(model, ns), - Key: key, - Severity: severity, - AutoResolve: autoResolve, - MessageInDetails: messageInDetails, - log: log.New("alerting.notifier.pagerduty"), - }, nil -} - -// PagerdutyNotifier is responsible for sending -// alert notifications to pagerduty -type PagerdutyNotifier struct { - NotifierBase - Key string - Severity string - AutoResolve bool - MessageInDetails bool - log log.Logger -} - -// buildEventPayload is responsible for building the event payload body for sending to Pagerduty v2 API -func (pn *PagerdutyNotifier) buildEventPayload(evalContext *alerting.EvalContext) ([]byte, error) { - eventType := "trigger" - if evalContext.Rule.State == models.AlertStateOK { - eventType = "resolve" - } - customData := simplejson.New() - customData.Set("state", evalContext.Rule.State) - if pn.MessageInDetails { - queries := make(map[string]interface{}) - for _, evt := range evalContext.EvalMatches { - queries[evt.Metric] = evt.Value - } - customData.Set("queries", queries) - customData.Set("message", evalContext.Rule.Message) - } else { - for _, evt := range evalContext.EvalMatches { - customData.Set(evt.Metric, evt.Value) - } - } - - pn.log.Info("Notifying Pagerduty", "event_type", eventType) - - payloadJSON := simplejson.New() - - // set default, override in following case switch if defined - payloadJSON.Set("component", "Grafana") - payloadJSON.Set("severity", pn.Severity) - dedupKey := "alertId-" + strconv.FormatInt(evalContext.Rule.ID, 10) - - for _, tag := range evalContext.Rule.AlertRuleTags { - // Override tags appropriately if they are in the PagerDuty v2 API - switch strings.ToLower(tag.Key) { - case "group": - payloadJSON.Set("group", tag.Value) - case "class": - payloadJSON.Set("class", tag.Value) - case "component": - payloadJSON.Set("component", tag.Value) - case "dedup_key": - if len(tag.Value) > 254 { - tag.Value = tag.Value[0:254] - } - dedupKey = tag.Value - case "severity": - // Only set severity if it's one of the PD supported enum values - // Info, Warning, Error, or Critical (case insensitive) - switch sev := strings.ToLower(tag.Value); sev { - case "info": - fallthrough - case "warning": - fallthrough - case "error": - fallthrough - case "critical": - payloadJSON.Set("severity", sev) - default: - pn.log.Warn("Ignoring invalid severity tag", "severity", sev) - } - } - customData.Set(tag.Key, tag.Value) - } - - var summary string - if pn.MessageInDetails || evalContext.Rule.Message == "" { - summary = evalContext.Rule.Name - } else { - summary = evalContext.Rule.Name + " - " + evalContext.Rule.Message - } - if len(summary) > 1024 { - summary = summary[0:1024] - } - payloadJSON.Set("summary", summary) - - if hostname, err := os.Hostname(); err == nil { - payloadJSON.Set("source", hostname) - } - payloadJSON.Set("timestamp", time.Now()) - payloadJSON.Set("custom_details", customData) - bodyJSON := simplejson.New() - bodyJSON.Set("routing_key", pn.Key) - bodyJSON.Set("event_action", eventType) - bodyJSON.Set("dedup_key", dedupKey) - bodyJSON.Set("payload", payloadJSON) - - ruleURL, err := evalContext.GetRuleURL() - if err != nil { - pn.log.Error("Failed get rule link", "error", err) - return []byte{}, err - } - links := make([]interface{}, 1) - linkJSON := simplejson.New() - linkJSON.Set("href", ruleURL) - bodyJSON.Set("client_url", ruleURL) - bodyJSON.Set("client", "Grafana") - - links[0] = linkJSON - bodyJSON.Set("links", links) - - if pn.NeedsImage() && evalContext.ImagePublicURL != "" { - contexts := make([]interface{}, 1) - imageJSON := simplejson.New() - imageJSON.Set("src", evalContext.ImagePublicURL) - contexts[0] = imageJSON - bodyJSON.Set("images", contexts) - } - - body, _ := bodyJSON.MarshalJSON() - - return body, nil -} - -// Notify sends an alert notification to PagerDuty -func (pn *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error { - if evalContext.Rule.State == models.AlertStateOK && !pn.AutoResolve { - pn.log.Info("Not sending a trigger to Pagerduty", "state", evalContext.Rule.State, "auto resolve", pn.AutoResolve) - return nil - } - - body, err := pn.buildEventPayload(evalContext) - if err != nil { - pn.log.Error("Unable to build PagerDuty event payload", "error", err) - return err - } - - cmd := ¬ifications.SendWebhookSync{ - Url: pagerdutyEventAPIURL, - Body: string(body), - HttpMethod: "POST", - HttpHeader: map[string]string{ - "Content-Type": "application/json", - }, - } - - if err := pn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - pn.log.Error("Failed to send notification to Pagerduty", "error", err, "body", string(body)) - return err - } - return nil -} diff --git a/pkg/services/alerting/notifiers/pagerduty_test.go b/pkg/services/alerting/notifiers/pagerduty_test.go deleted file mode 100644 index c2b200ff69c06..0000000000000 --- a/pkg/services/alerting/notifiers/pagerduty_test.go +++ /dev/null @@ -1,542 +0,0 @@ -package notifiers - -import ( - "context" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/null" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" - "github.com/grafana/grafana/pkg/services/tag" - "github.com/grafana/grafana/pkg/services/validations" -) - -func presenceComparer(a, b string) bool { - if a == "<<PRESENCE>>" { - return b != "" - } - if b == "<<PRESENCE>>" { - return a != "" - } - return a == b -} - -func TestPagerdutyNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.Nil(t, err) - - model := &models.AlertNotification{ - Name: "pageduty_testing", - Type: "pagerduty", - Settings: settingsJSON, - } - - _, err = NewPagerdutyNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - - t.Run("severity should override default", func(t *testing.T) { - json := `{ "integrationKey": "abcdefgh0123456789", "severity": "info", "tags": ["foo"]}` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.Nil(t, err) - - model := &models.AlertNotification{ - Name: "pagerduty_testing", - Type: "pagerduty", - Settings: settingsJSON, - } - - not, err := NewPagerdutyNotifier(model, encryptionService.GetDecryptedValue, nil) - pagerdutyNotifier := not.(*PagerdutyNotifier) - - require.Nil(t, err) - require.Equal(t, "pagerduty_testing", pagerdutyNotifier.Name) - require.Equal(t, "pagerduty", pagerdutyNotifier.Type) - require.Equal(t, "abcdefgh0123456789", pagerdutyNotifier.Key) - require.Equal(t, "info", pagerdutyNotifier.Severity) - require.False(t, pagerdutyNotifier.AutoResolve) - }) - - t.Run("auto resolve and severity should have expected defaults", func(t *testing.T) { - json := `{ "integrationKey": "abcdefgh0123456789" }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.Nil(t, err) - - model := &models.AlertNotification{ - Name: "pagerduty_testing", - Type: "pagerduty", - Settings: settingsJSON, - } - - not, err := NewPagerdutyNotifier(model, encryptionService.GetDecryptedValue, nil) - pagerdutyNotifier := not.(*PagerdutyNotifier) - - require.Nil(t, err) - require.Equal(t, "pagerduty_testing", pagerdutyNotifier.Name) - require.Equal(t, "pagerduty", pagerdutyNotifier.Type) - require.Equal(t, "abcdefgh0123456789", pagerdutyNotifier.Key) - require.Equal(t, "critical", pagerdutyNotifier.Severity) - require.False(t, pagerdutyNotifier.AutoResolve) - }) - - t.Run("settings should trigger incident", func(t *testing.T) { - json := ` - { - "integrationKey": "abcdefgh0123456789", - "autoResolve": false - }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.Nil(t, err) - - model := &models.AlertNotification{ - Name: "pagerduty_testing", - Type: "pagerduty", - Settings: settingsJSON, - } - - not, err := NewPagerdutyNotifier(model, encryptionService.GetDecryptedValue, nil) - pagerdutyNotifier := not.(*PagerdutyNotifier) - - require.Nil(t, err) - require.Equal(t, "pagerduty_testing", pagerdutyNotifier.Name) - require.Equal(t, "pagerduty", pagerdutyNotifier.Type) - require.Equal(t, "abcdefgh0123456789", pagerdutyNotifier.Key) - require.False(t, pagerdutyNotifier.AutoResolve) - }) - - t.Run("should return properly formatted default v2 event payload", func(t *testing.T) { - json := `{ - "integrationKey": "abcdefgh0123456789", - "autoResolve": false - }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.Nil(t, err) - - model := &models.AlertNotification{ - Name: "pagerduty_testing", - Type: "pagerduty", - Settings: settingsJSON, - } - - not, err := NewPagerdutyNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Nil(t, err) - - pagerdutyNotifier := not.(*PagerdutyNotifier) - evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{ - ID: 0, - Name: "someRule", - Message: "someMessage", - State: models.AlertStateAlerting, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - evalContext.IsTestRun = true - - payloadJSON, err := pagerdutyNotifier.buildEventPayload(evalContext) - require.Nil(t, err) - payload, err := simplejson.NewJson(payloadJSON) - require.Nil(t, err) - - diff := cmp.Diff(map[string]any{ - "client": "Grafana", - "client_url": "", - "dedup_key": "alertId-0", - "event_action": "trigger", - "links": []any{ - map[string]any{ - "href": "", - }, - }, - "payload": map[string]any{ - "component": "Grafana", - "source": "<<PRESENCE>>", - "custom_details": map[string]any{ - "state": "alerting", - }, - "severity": "critical", - "summary": "someRule - someMessage", - "timestamp": "<<PRESENCE>>", - }, - "routing_key": "abcdefgh0123456789", - }, payload.Interface(), cmp.Comparer(presenceComparer)) - require.Empty(t, diff) - }) - - t.Run("should return properly formatted default v2 event payload with empty message", func(t *testing.T) { - json := `{ - "integrationKey": "abcdefgh0123456789", - "autoResolve": false - }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.Nil(t, err) - - model := &models.AlertNotification{ - Name: "pagerduty_testing", - Type: "pagerduty", - Settings: settingsJSON, - } - - not, err := NewPagerdutyNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Nil(t, err) - - pagerdutyNotifier := not.(*PagerdutyNotifier) - evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{ - ID: 0, - Name: "someRule", - State: models.AlertStateAlerting, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - evalContext.IsTestRun = true - - payloadJSON, err := pagerdutyNotifier.buildEventPayload(evalContext) - require.Nil(t, err) - payload, err := simplejson.NewJson(payloadJSON) - require.Nil(t, err) - - diff := cmp.Diff(map[string]any{ - "client": "Grafana", - "client_url": "", - "dedup_key": "alertId-0", - "event_action": "trigger", - "links": []any{ - map[string]any{ - "href": "", - }, - }, - "payload": map[string]any{ - "component": "Grafana", - "source": "<<PRESENCE>>", - "custom_details": map[string]any{ - "state": "alerting", - }, - "severity": "critical", - "summary": "someRule", - "timestamp": "<<PRESENCE>>", - }, - "routing_key": "abcdefgh0123456789", - }, payload.Interface(), cmp.Comparer(presenceComparer)) - require.Empty(t, diff) - }) - - t.Run("should return properly formatted payload with message moved to details", func(t *testing.T) { - json := `{ - "integrationKey": "abcdefgh0123456789", - "autoResolve": false, - "messageInDetails": true - }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.Nil(t, err) - - model := &models.AlertNotification{ - Name: "pagerduty_testing", - Type: "pagerduty", - Settings: settingsJSON, - } - - not, err := NewPagerdutyNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Nil(t, err) - - pagerdutyNotifier := not.(*PagerdutyNotifier) - evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{ - ID: 0, - Name: "someRule", - Message: "someMessage", - State: models.AlertStateAlerting, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - evalContext.IsTestRun = true - evalContext.EvalMatches = []*alerting.EvalMatch{ - { - // nil is a terrible value to test with, but the cmp.Diff doesn't - // like comparing actual floats. So this is roughly the equivalent - // of <<PRESENCE>> - Value: null.FloatFromPtr(nil), - Metric: "someMetric", - }, - } - - payloadJSON, err := pagerdutyNotifier.buildEventPayload(evalContext) - require.NoError(t, err) - payload, err := simplejson.NewJson(payloadJSON) - require.NoError(t, err) - - diff := cmp.Diff(map[string]any{ - "client": "Grafana", - "client_url": "", - "dedup_key": "alertId-0", - "event_action": "trigger", - "links": []any{ - map[string]any{ - "href": "", - }, - }, - "payload": map[string]any{ - "component": "Grafana", - "source": "<<PRESENCE>>", - "custom_details": map[string]any{ - "message": "someMessage", - "queries": map[string]any{ - "someMetric": nil, - }, - "state": "alerting", - }, - "severity": "critical", - "summary": "someRule", - "timestamp": "<<PRESENCE>>", - }, - "routing_key": "abcdefgh0123456789", - }, payload.Interface(), cmp.Comparer(presenceComparer)) - require.Empty(t, diff) - }) - - t.Run("should return properly formatted v2 event payload when using override tags", func(t *testing.T) { - json := `{ - "integrationKey": "abcdefgh0123456789", - "autoResolve": false - }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.NoError(t, err) - - model := &models.AlertNotification{ - Name: "pagerduty_testing", - Type: "pagerduty", - Settings: settingsJSON, - } - - not, err := NewPagerdutyNotifier(model, encryptionService.GetDecryptedValue, nil) - require.NoError(t, err) - - pagerdutyNotifier := not.(*PagerdutyNotifier) - - evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{ - ID: 0, - Name: "someRule", - Message: "someMessage", - State: models.AlertStateAlerting, - AlertRuleTags: []*tag.Tag{ - {Key: "keyOnly"}, - {Key: "group", Value: "aGroup"}, - {Key: "class", Value: "aClass"}, - {Key: "component", Value: "aComponent"}, - {Key: "severity", Value: "warning"}, - {Key: "dedup_key", Value: "key-" + strings.Repeat("x", 260)}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - evalContext.ImagePublicURL = "http://somewhere.com/omg_dont_panic.png" - evalContext.IsTestRun = true - - payloadJSON, err := pagerdutyNotifier.buildEventPayload(evalContext) - require.NoError(t, err) - payload, err := simplejson.NewJson(payloadJSON) - require.NoError(t, err) - - diff := cmp.Diff(map[string]any{ - "client": "Grafana", - "client_url": "", - "dedup_key": "key-" + strings.Repeat("x", 250), - "event_action": "trigger", - "links": []any{ - map[string]any{ - "href": "", - }, - }, - "payload": map[string]any{ - "source": "<<PRESENCE>>", - "component": "aComponent", - "custom_details": map[string]any{ - "group": "aGroup", - "class": "aClass", - "component": "aComponent", - "severity": "warning", - "dedup_key": "key-" + strings.Repeat("x", 250), - "keyOnly": "", - "state": "alerting", - }, - "severity": "warning", - "summary": "someRule - someMessage", - "timestamp": "<<PRESENCE>>", - "class": "aClass", - "group": "aGroup", - }, - "images": []any{ - map[string]any{ - "src": "http://somewhere.com/omg_dont_panic.png", - }, - }, - "routing_key": "abcdefgh0123456789", - }, payload.Interface(), cmp.Comparer(presenceComparer)) - require.Empty(t, diff) - }) - - t.Run("should support multiple levels of severity", func(t *testing.T) { - json := `{ - "integrationKey": "abcdefgh0123456789", - "autoResolve": false - }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.NoError(t, err) - - model := &models.AlertNotification{ - Name: "pagerduty_testing", - Type: "pagerduty", - Settings: settingsJSON, - } - - not, err := NewPagerdutyNotifier(model, encryptionService.GetDecryptedValue, nil) - require.NoError(t, err) - - pagerdutyNotifier := not.(*PagerdutyNotifier) - - evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{ - ID: 0, - Name: "someRule", - Message: "someMessage", - State: models.AlertStateAlerting, - AlertRuleTags: []*tag.Tag{ - {Key: "keyOnly"}, - {Key: "group", Value: "aGroup"}, - {Key: "class", Value: "aClass"}, - {Key: "component", Value: "aComponent"}, - {Key: "severity", Value: "info"}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - evalContext.ImagePublicURL = "http://somewhere.com/omg_dont_panic.png" - evalContext.IsTestRun = true - - payloadJSON, err := pagerdutyNotifier.buildEventPayload(evalContext) - require.NoError(t, err) - payload, err := simplejson.NewJson(payloadJSON) - require.NoError(t, err) - - diff := cmp.Diff(map[string]any{ - "client": "Grafana", - "client_url": "", - "dedup_key": "alertId-0", - "event_action": "trigger", - "links": []any{ - map[string]any{ - "href": "", - }, - }, - "payload": map[string]any{ - "source": "<<PRESENCE>>", - "component": "aComponent", - "custom_details": map[string]any{ - "group": "aGroup", - "class": "aClass", - "component": "aComponent", - "severity": "info", - "keyOnly": "", - "state": "alerting", - }, - "severity": "info", - "summary": "someRule - someMessage", - "timestamp": "<<PRESENCE>>", - "class": "aClass", - "group": "aGroup", - }, - "images": []any{ - map[string]any{ - "src": "http://somewhere.com/omg_dont_panic.png", - }, - }, - "routing_key": "abcdefgh0123456789", - }, payload.Interface(), cmp.Comparer(presenceComparer)) - require.Empty(t, diff) - }) - - t.Run("should ignore invalid severity for PD but keep the tag", func(t *testing.T) { - json := `{ - "integrationKey": "abcdefgh0123456789", - "autoResolve": false, - "severity": "critical" - }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.NoError(t, err) - - model := &models.AlertNotification{ - Name: "pagerduty_testing", - Type: "pagerduty", - Settings: settingsJSON, - } - - not, err := NewPagerdutyNotifier(model, encryptionService.GetDecryptedValue, nil) - require.NoError(t, err) - - pagerdutyNotifier := not.(*PagerdutyNotifier) - - evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{ - ID: 0, - Name: "someRule", - Message: "someMessage", - State: models.AlertStateAlerting, - AlertRuleTags: []*tag.Tag{ - {Key: "keyOnly"}, - {Key: "group", Value: "aGroup"}, - {Key: "class", Value: "aClass"}, - {Key: "component", Value: "aComponent"}, - {Key: "severity", Value: "llama"}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - evalContext.ImagePublicURL = "http://somewhere.com/omg_dont_panic.png" - evalContext.IsTestRun = true - - payloadJSON, err := pagerdutyNotifier.buildEventPayload(evalContext) - require.NoError(t, err) - payload, err := simplejson.NewJson(payloadJSON) - require.NoError(t, err) - - diff := cmp.Diff(map[string]any{ - "client": "Grafana", - "client_url": "", - "dedup_key": "alertId-0", - "event_action": "trigger", - "links": []any{ - map[string]any{ - "href": "", - }, - }, - "payload": map[string]any{ - "source": "<<PRESENCE>>", - "component": "aComponent", - "custom_details": map[string]any{ - "group": "aGroup", - "class": "aClass", - "component": "aComponent", - "severity": "llama", - "keyOnly": "", - "state": "alerting", - }, - "severity": "critical", - "summary": "someRule - someMessage", - "timestamp": "<<PRESENCE>>", - "class": "aClass", - "group": "aGroup", - }, - "images": []any{ - map[string]any{ - "src": "http://somewhere.com/omg_dont_panic.png", - }, - }, - "routing_key": "abcdefgh0123456789", - }, payload.Interface(), cmp.Comparer(presenceComparer)) - require.Empty(t, diff) - }) -} diff --git a/pkg/services/alerting/notifiers/pushover.go b/pkg/services/alerting/notifiers/pushover.go deleted file mode 100644 index dcfd187d6834e..0000000000000 --- a/pkg/services/alerting/notifiers/pushover.go +++ /dev/null @@ -1,415 +0,0 @@ -package notifiers - -import ( - "bytes" - "context" - "fmt" - "io" - "mime/multipart" - "os" - "strconv" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" -) - -const pushoverEndpoint = "https://api.pushover.net/1/messages.json" - -func init() { - soundOptions := []alerting.SelectOption{ - { - Value: "default", - Label: "Default", - }, - { - Value: "pushover", - Label: "Pushover", - }, { - Value: "bike", - Label: "Bike", - }, { - Value: "bugle", - Label: "Bugle", - }, { - Value: "cashregister", - Label: "Cashregister", - }, { - Value: "classical", - Label: "Classical", - }, { - Value: "cosmic", - Label: "Cosmic", - }, { - Value: "falling", - Label: "Falling", - }, { - Value: "gamelan", - Label: "Gamelan", - }, { - Value: "incoming", - Label: "Incoming", - }, { - Value: "intermission", - Label: "Intermission", - }, { - Value: "magic", - Label: "Magic", - }, { - Value: "mechanical", - Label: "Mechanical", - }, { - Value: "pianobar", - Label: "Pianobar", - }, { - Value: "siren", - Label: "Siren", - }, { - Value: "spacealarm", - Label: "Spacealarm", - }, { - Value: "tugboat", - Label: "Tugboat", - }, { - Value: "alien", - Label: "Alien", - }, { - Value: "climb", - Label: "Climb", - }, { - Value: "persistent", - Label: "Persistent", - }, { - Value: "echo", - Label: "Echo", - }, { - Value: "updown", - Label: "Updown", - }, { - Value: "none", - Label: "None", - }, - } - - priorityOptions := []alerting.SelectOption{ - { - Value: "2", - Label: "Emergency", - }, - { - Value: "1", - Label: "High", - }, - { - Value: "0", - Label: "Normal", - }, - { - Value: "-1", - Label: "Low", - }, - { - Value: "-2", - Label: "Lowest", - }, - } - - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "pushover", - Name: "Pushover", - Description: "Sends HTTP POST request to the Pushover API", - Heading: "Pushover settings", - Factory: NewPushoverNotifier, - Options: []alerting.NotifierOption{ - { - Label: "API Token", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "Application token", - PropertyName: "apiToken", - Required: true, - Secure: true, - }, - { - Label: "User key(s)", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "comma-separated list", - PropertyName: "userKey", - Required: true, - Secure: true, - }, - { - Label: "Device(s) (optional)", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "comma-separated list; leave empty to send to all devices", - PropertyName: "device", - }, - { - Label: "Alerting priority", - Element: alerting.ElementTypeSelect, - SelectOptions: priorityOptions, - PropertyName: "priority", - }, - { - Label: "OK priority", - Element: alerting.ElementTypeSelect, - SelectOptions: priorityOptions, - PropertyName: "okPriority", - }, - { - Description: "How often (in seconds) the Pushover servers will send the same alerting or OK notification to the user.", - Label: "Retry (Only used for Emergency Priority)", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "minimum 30 seconds", - PropertyName: "retry", - }, - { - Description: "How many seconds the alerting or OK notification will continue to be retried.", - Label: "Expire (Only used for Emergency Priority)", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "maximum 86400 seconds", - PropertyName: "expire", - }, - { - Label: "Alerting sound", - Element: alerting.ElementTypeSelect, - SelectOptions: soundOptions, - PropertyName: "sound", - }, - { - Label: "OK sound", - Element: alerting.ElementTypeSelect, - SelectOptions: soundOptions, - PropertyName: "okSound", - }, - }, - }) -} - -// NewPushoverNotifier is the constructor for the Pushover Notifier -func NewPushoverNotifier(model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - userKey := fn(context.Background(), model.SecureSettings, "userKey", model.Settings.Get("userKey").MustString(), setting.SecretKey) - APIToken := fn(context.Background(), model.SecureSettings, "apiToken", model.Settings.Get("apiToken").MustString(), setting.SecretKey) - device := model.Settings.Get("device").MustString() - alertingPriority, err := strconv.Atoi(model.Settings.Get("priority").MustString("0")) // default Normal - if err != nil { - return nil, fmt.Errorf("failed to convert alerting priority to integer: %w", err) - } - okPriority, err := strconv.Atoi(model.Settings.Get("okPriority").MustString("0")) // default Normal - if err != nil { - return nil, fmt.Errorf("failed to convert OK priority to integer: %w", err) - } - retry, _ := strconv.Atoi(model.Settings.Get("retry").MustString()) - expire, _ := strconv.Atoi(model.Settings.Get("expire").MustString()) - alertingSound := model.Settings.Get("sound").MustString() - okSound := model.Settings.Get("okSound").MustString() - uploadImage := model.Settings.Get("uploadImage").MustBool(true) - - if userKey == "" { - return nil, alerting.ValidationError{Reason: "User key not given"} - } - if APIToken == "" { - return nil, alerting.ValidationError{Reason: "API token not given"} - } - return &PushoverNotifier{ - NotifierBase: NewNotifierBase(model, ns), - UserKey: userKey, - APIToken: APIToken, - AlertingPriority: alertingPriority, - OKPriority: okPriority, - Retry: retry, - Expire: expire, - Device: device, - AlertingSound: alertingSound, - OKSound: okSound, - Upload: uploadImage, - log: log.New("alerting.notifier.pushover"), - }, nil -} - -// PushoverNotifier is responsible for sending -// alert notifications to Pushover -type PushoverNotifier struct { - NotifierBase - UserKey string - APIToken string - AlertingPriority int - OKPriority int - Retry int - Expire int - Device string - AlertingSound string - OKSound string - Upload bool - log log.Logger -} - -// Notify sends a alert notification to Pushover -func (pn *PushoverNotifier) Notify(evalContext *alerting.EvalContext) error { - ruleURL, err := evalContext.GetRuleURL() - if err != nil { - pn.log.Error("Failed get rule link", "error", err) - return err - } - - message := evalContext.Rule.Message - for idx, evt := range evalContext.EvalMatches { - message += fmt.Sprintf("\n<b>%s</b>: %v", evt.Metric, evt.Value) - if idx > 4 { - break - } - } - if evalContext.Error != nil { - message += fmt.Sprintf("\n<b>Error message:</b> %s", evalContext.Error.Error()) - } - - if message == "" { - message = "Notification message missing (Set a notification message to replace this text.)" - } - - headers, uploadBody, err := pn.genPushoverBody(evalContext, message, ruleURL) - if err != nil { - pn.log.Error("Failed to generate body for pushover", "error", err) - return err - } - - cmd := ¬ifications.SendWebhookSync{ - Url: pushoverEndpoint, - HttpMethod: "POST", - HttpHeader: headers, - Body: uploadBody.String(), - } - - if err := pn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - pn.log.Error("Failed to send pushover notification", "error", err, "webhook", pn.Name) - return err - } - - return nil -} - -func (pn *PushoverNotifier) genPushoverBody(evalContext *alerting.EvalContext, message string, ruleURL string) (map[string]string, bytes.Buffer, error) { - var b bytes.Buffer - var err error - w := multipart.NewWriter(&b) - - // Add image only if requested and available - if pn.Upload && evalContext.ImageOnDiskPath != "" { - f, err := os.Open(evalContext.ImageOnDiskPath) - if err != nil { - return nil, b, err - } - defer func() { - if err := f.Close(); err != nil { - pn.log.Warn("Failed to close file", "path", evalContext.ImageOnDiskPath, "err", err) - } - }() - - fw, err := w.CreateFormFile("attachment", evalContext.ImageOnDiskPath) - if err != nil { - return nil, b, err - } - - _, err = io.Copy(fw, f) - if err != nil { - return nil, b, err - } - } - - // Add the user token - err = w.WriteField("user", pn.UserKey) - if err != nil { - return nil, b, err - } - - // Add the api token - err = w.WriteField("token", pn.APIToken) - if err != nil { - return nil, b, err - } - - // Add priority - priority := pn.AlertingPriority - if evalContext.Rule.State == models.AlertStateOK { - priority = pn.OKPriority - } - err = w.WriteField("priority", strconv.Itoa(priority)) - if err != nil { - return nil, b, err - } - - if priority == 2 { - err = w.WriteField("retry", strconv.Itoa(pn.Retry)) - if err != nil { - return nil, b, err - } - - err = w.WriteField("expire", strconv.Itoa(pn.Expire)) - if err != nil { - return nil, b, err - } - } - - // Add device - if pn.Device != "" { - err = w.WriteField("device", pn.Device) - if err != nil { - return nil, b, err - } - } - - // Add sound - sound := pn.AlertingSound - if evalContext.Rule.State == models.AlertStateOK { - sound = pn.OKSound - } - if sound != "default" { - err = w.WriteField("sound", sound) - if err != nil { - return nil, b, err - } - } - - // Add title - err = w.WriteField("title", evalContext.GetNotificationTitle()) - if err != nil { - return nil, b, err - } - - // Add URL - err = w.WriteField("url", ruleURL) - if err != nil { - return nil, b, err - } - // Add URL title - err = w.WriteField("url_title", "Show dashboard with alert") - if err != nil { - return nil, b, err - } - - // Add message - err = w.WriteField("message", message) - if err != nil { - return nil, b, err - } - - // Mark as html message - err = w.WriteField("html", "1") - if err != nil { - return nil, b, err - } - if err := w.Close(); err != nil { - return nil, b, err - } - - headers := map[string]string{ - "Content-Type": w.FormDataContentType(), - } - return headers, b, nil -} diff --git a/pkg/services/alerting/notifiers/pushover_test.go b/pkg/services/alerting/notifiers/pushover_test.go deleted file mode 100644 index 371e78fcd7147..0000000000000 --- a/pkg/services/alerting/notifiers/pushover_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package notifiers - -import ( - "context" - "strings" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" - "github.com/grafana/grafana/pkg/services/validations" -) - -func TestPushoverNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("Parsing alert notification from settings", func(t *testing.T) { - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "Pushover", - Type: "pushover", - Settings: settingsJSON, - } - - _, err := NewPushoverNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - - t.Run("from settings", func(t *testing.T) { - json := ` - { - "apiToken": "4SrUFQL4A5V5TQ1z5Pg9nxHXPXSTve", - "userKey": "tzNZYf36y0ohWwXo4XoUrB61rz1A4o", - "priority": "1", - "okPriority": "2", - "sound": "pushover", - "okSound": "magic" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "Pushover", - Type: "pushover", - Settings: settingsJSON, - } - - not, err := NewPushoverNotifier(model, encryptionService.GetDecryptedValue, nil) - pushoverNotifier := not.(*PushoverNotifier) - - require.Nil(t, err) - require.Equal(t, "Pushover", pushoverNotifier.Name) - require.Equal(t, "pushover", pushoverNotifier.Type) - require.Equal(t, "4SrUFQL4A5V5TQ1z5Pg9nxHXPXSTve", pushoverNotifier.APIToken) - require.Equal(t, "tzNZYf36y0ohWwXo4XoUrB61rz1A4o", pushoverNotifier.UserKey) - require.Equal(t, 1, pushoverNotifier.AlertingPriority) - require.Equal(t, 2, pushoverNotifier.OKPriority) - require.Equal(t, "pushover", pushoverNotifier.AlertingSound) - require.Equal(t, "magic", pushoverNotifier.OKSound) - }) - }) -} - -func TestGenPushoverBody(t *testing.T) { - t.Run("Given common sounds", func(t *testing.T) { - sirenSound := "siren_sound_tst" - successSound := "success_sound_tst" - notifier := &PushoverNotifier{AlertingSound: sirenSound, OKSound: successSound} - - t.Run("When alert is firing - should use siren sound", func(t *testing.T) { - evalContext := alerting.NewEvalContext(context.Background(), - &alerting.Rule{ - State: models.AlertStateAlerting, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - _, pushoverBody, err := notifier.genPushoverBody(evalContext, "", "") - - require.Nil(t, err) - require.True(t, strings.Contains(pushoverBody.String(), sirenSound)) - }) - - t.Run("When alert is ok - should use success sound", func(t *testing.T) { - evalContext := alerting.NewEvalContext(context.Background(), - &alerting.Rule{ - State: models.AlertStateOK, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - _, pushoverBody, err := notifier.genPushoverBody(evalContext, "", "") - - require.Nil(t, err) - require.True(t, strings.Contains(pushoverBody.String(), successSound)) - }) - }) -} diff --git a/pkg/services/alerting/notifiers/sensu.go b/pkg/services/alerting/notifiers/sensu.go deleted file mode 100644 index c00dad176dcaf..0000000000000 --- a/pkg/services/alerting/notifiers/sensu.go +++ /dev/null @@ -1,155 +0,0 @@ -package notifiers - -import ( - "context" - "strconv" - "strings" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" -) - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "sensu", - Name: "Sensu", - Description: "Sends HTTP POST request to a Sensu API", - Heading: "Sensu settings", - Factory: NewSensuNotifier, - Options: []alerting.NotifierOption{ - { - Label: "Url", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "http://sensu-api.local:4567/results", - PropertyName: "url", - Required: true, - }, - { - Label: "Source", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Description: "If empty rule id will be used", - PropertyName: "source", - }, - { - Label: "Handler", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "default", - PropertyName: "handler", - }, - { - Label: "Username", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - PropertyName: "username", - }, - { - Label: "Password", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypePassword, - PropertyName: "passsword ", - Secure: true, - }, - }, - }) -} - -// NewSensuNotifier is the constructor for the Sensu Notifier. -func NewSensuNotifier(model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - url := model.Settings.Get("url").MustString() - if url == "" { - return nil, alerting.ValidationError{Reason: "Could not find url property in settings"} - } - - return &SensuNotifier{ - NotifierBase: NewNotifierBase(model, ns), - URL: url, - User: model.Settings.Get("username").MustString(), - Source: model.Settings.Get("source").MustString(), - Password: fn(context.Background(), model.SecureSettings, "password", model.Settings.Get("password").MustString(), setting.SecretKey), - Handler: model.Settings.Get("handler").MustString(), - log: log.New("alerting.notifier.sensu"), - }, nil -} - -// SensuNotifier is responsible for sending -// alert notifications to Sensu. -type SensuNotifier struct { - NotifierBase - URL string - Source string - User string - Password string - Handler string - log log.Logger -} - -// Notify send alert notification to Sensu -func (sn *SensuNotifier) Notify(evalContext *alerting.EvalContext) error { - sn.log.Info("Sending sensu result") - - bodyJSON := simplejson.New() - bodyJSON.Set("ruleId", evalContext.Rule.ID) - // Sensu alerts cannot have spaces in them - bodyJSON.Set("name", strings.ReplaceAll(evalContext.Rule.Name, " ", "_")) - // Sensu alerts require a source. We set it to the user-specified value (optional), - // else we fallback and use the grafana ruleID. - if sn.Source != "" { - bodyJSON.Set("source", sn.Source) - } else { - bodyJSON.Set("source", "grafana_rule_"+strconv.FormatInt(evalContext.Rule.ID, 10)) - } - // Finally, sensu expects an output - // We set it to a default output - bodyJSON.Set("output", "Grafana Metric Condition Met") - bodyJSON.Set("evalMatches", evalContext.EvalMatches) - - switch evalContext.Rule.State { - case "alerting": - bodyJSON.Set("status", 2) - case "no_data": - bodyJSON.Set("status", 1) - default: - bodyJSON.Set("status", 0) - } - - if sn.Handler != "" { - bodyJSON.Set("handler", sn.Handler) - } - - ruleURL, err := evalContext.GetRuleURL() - if err == nil { - bodyJSON.Set("ruleUrl", ruleURL) - } - - if sn.NeedsImage() && evalContext.ImagePublicURL != "" { - bodyJSON.Set("imageUrl", evalContext.ImagePublicURL) - } - - if evalContext.Rule.Message != "" { - bodyJSON.Set("output", evalContext.Rule.Message) - } - - body, _ := bodyJSON.MarshalJSON() - - cmd := ¬ifications.SendWebhookSync{ - Url: sn.URL, - User: sn.User, - Password: sn.Password, - Body: string(body), - HttpMethod: "POST", - } - - if err := sn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - sn.log.Error("Failed to send sensu event", "error", err, "sensu", sn.Name) - return err - } - - return nil -} diff --git a/pkg/services/alerting/notifiers/sensu_test.go b/pkg/services/alerting/notifiers/sensu_test.go deleted file mode 100644 index 5b6503f798fb1..0000000000000 --- a/pkg/services/alerting/notifiers/sensu_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package notifiers - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting/models" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" -) - -func TestSensuNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("Parsing alert notification from settings", func(t *testing.T) { - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "sensu", - Type: "sensu", - Settings: settingsJSON, - } - - _, err := NewSensuNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - - t.Run("from settings", func(t *testing.T) { - json := ` - { - "url": "http://sensu-api.example.com:4567/results", - "source": "grafana_instance_01", - "handler": "myhandler" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "sensu", - Type: "sensu", - Settings: settingsJSON, - } - - not, err := NewSensuNotifier(model, encryptionService.GetDecryptedValue, nil) - sensuNotifier := not.(*SensuNotifier) - - require.Nil(t, err) - require.Equal(t, "sensu", sensuNotifier.Name) - require.Equal(t, "sensu", sensuNotifier.Type) - require.Equal(t, "http://sensu-api.example.com:4567/results", sensuNotifier.URL) - require.Equal(t, "grafana_instance_01", sensuNotifier.Source) - require.Equal(t, "myhandler", sensuNotifier.Handler) - }) - }) -} diff --git a/pkg/services/alerting/notifiers/sensugo.go b/pkg/services/alerting/notifiers/sensugo.go deleted file mode 100644 index d5f6a4dbfaf75..0000000000000 --- a/pkg/services/alerting/notifiers/sensugo.go +++ /dev/null @@ -1,206 +0,0 @@ -package notifiers - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" -) - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "sensugo", - Name: "Sensu Go", - Description: "Sends HTTP POST request to a Sensu Go API", - Heading: "Sensu Go Settings", - Factory: NewSensuGoNotifier, - Options: []alerting.NotifierOption{ - { - Label: "Backend URL", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "http://sensu-api.local:8080", - PropertyName: "url", - Required: true, - }, - { - Label: "API Key", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypePassword, - Description: "API Key to auth to Sensu Go backend", - PropertyName: "apikey", - Required: true, - Secure: true, - }, - { - Label: "Proxy entity name", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Description: "If empty, rule name will be used", - PropertyName: "entity", - }, - { - Label: "Check name", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Description: "If empty, rule id will be used", - PropertyName: "check", - }, - { - Label: "Handler", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - PropertyName: "handler", - }, - { - Label: "Namespace", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "default", - PropertyName: "namespace", - }, - }, - }) -} - -// NewSensuGoNotifier is the constructor for the Sensu Go Notifier. -func NewSensuGoNotifier(model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - url := model.Settings.Get("url").MustString() - apikey := fn(context.Background(), model.SecureSettings, "apikey", model.Settings.Get("apikey").MustString(), setting.SecretKey) - - if url == "" { - return nil, alerting.ValidationError{Reason: "Could not find URL property in settings"} - } - if apikey == "" { - return nil, alerting.ValidationError{Reason: "Could not find the API Key property in settings"} - } - - return &SensuGoNotifier{ - NotifierBase: NewNotifierBase(model, ns), - URL: url, - Entity: model.Settings.Get("entity").MustString(), - Check: model.Settings.Get("check").MustString(), - Namespace: model.Settings.Get("namespace").MustString(), - Handler: model.Settings.Get("handler").MustString(), - APIKey: apikey, - log: log.New("alerting.notifier.sensugo"), - }, nil -} - -// SensuGoNotifier is responsible for sending -// alert notifications to Sensu Go. -type SensuGoNotifier struct { - NotifierBase - URL string - Entity string - Check string - Namespace string - Handler string - APIKey string - log log.Logger -} - -// Notify send alert notification to Sensu Go -func (sn *SensuGoNotifier) Notify(evalContext *alerting.EvalContext) error { - sn.log.Info("Sending Sensu Go result") - - var namespace string - - customData := triggMetrString - for _, evt := range evalContext.EvalMatches { - customData += fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value) - } - - bodyJSON := simplejson.New() - // Sensu Go alerts require an entity and a check. We set it to the user-specified - // value (optional), else we fallback and use the grafana rule anme and ruleID. - if sn.Entity != "" { - bodyJSON.SetPath([]string{"entity", "metadata", "name"}, sn.Entity) - } else { - // Sensu Go alerts cannot have spaces in them - bodyJSON.SetPath([]string{"entity", "metadata", "name"}, strings.ReplaceAll(evalContext.Rule.Name, " ", "_")) - } - if sn.Check != "" { - bodyJSON.SetPath([]string{"check", "metadata", "name"}, sn.Check) - } else { - bodyJSON.SetPath([]string{"check", "metadata", "name"}, "grafana_rule_"+strconv.FormatInt(evalContext.Rule.ID, 10)) - } - // Sensu Go requires the entity in an event specify its namespace. We set it to - // the user-specified value (optional), else we fallback and use default - if sn.Namespace != "" { - bodyJSON.SetPath([]string{"entity", "metadata", "namespace"}, sn.Namespace) - namespace = sn.Namespace - } else { - bodyJSON.SetPath([]string{"entity", "metadata", "namespace"}, "default") - namespace = "default" - } - // Sensu Go needs check output, triggered metrics as default value - if evalContext.Rule.Message != "" { - bodyJSON.SetPath([]string{"check", "output"}, evalContext.Rule.Message) - } else { - bodyJSON.SetPath([]string{"check", "output"}, customData) - } - // Add timestamp detail in event - bodyJSON.SetPath([]string{"check", "issued"}, time.Now().Unix()) - // Sensu GO requires that the check portion of the event have an interval - bodyJSON.SetPath([]string{"check", "interval"}, 86400) - - switch evalContext.Rule.State { - case "alerting": - bodyJSON.SetPath([]string{"check", "status"}, 2) - case "no_data": - bodyJSON.SetPath([]string{"check", "status"}, 1) - default: - bodyJSON.SetPath([]string{"check", "status"}, 0) - } - - if sn.Handler != "" { - bodyJSON.SetPath([]string{"check", "handlers"}, []string{sn.Handler}) - } - - ruleURL, err := evalContext.GetRuleURL() - if err == nil { - bodyJSON.Set("ruleUrl", ruleURL) - } - - labels := map[string]string{ - "ruleName": evalContext.Rule.Name, - "ruleId": strconv.FormatInt(evalContext.Rule.ID, 10), - "ruleURL": ruleURL, - } - - if sn.NeedsImage() && evalContext.ImagePublicURL != "" { - labels["imageUrl"] = evalContext.ImagePublicURL - } - - bodyJSON.SetPath([]string{"check", "metadata", "labels"}, labels) - - body, err := bodyJSON.MarshalJSON() - if err != nil { - return err - } - - cmd := ¬ifications.SendWebhookSync{ - Url: fmt.Sprintf("%s/api/core/v2/namespaces/%s/events", strings.TrimSuffix(sn.URL, "/"), namespace), - Body: string(body), - HttpMethod: "POST", - HttpHeader: map[string]string{ - "Content-Type": "application/json", - "Authorization": fmt.Sprintf("Key %s", sn.APIKey), - }, - } - if err := sn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - sn.log.Error("Failed to send Sensu Go event", "error", err, "sensugo", sn.Name) - return err - } - - return nil -} diff --git a/pkg/services/alerting/notifiers/sensugo_test.go b/pkg/services/alerting/notifiers/sensugo_test.go deleted file mode 100644 index 73d30ecf73bcd..0000000000000 --- a/pkg/services/alerting/notifiers/sensugo_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package notifiers - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting/models" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" -) - -func TestSensuGoNotifier(t *testing.T) { - json := `{ }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.NoError(t, err) - model := &models.AlertNotification{ - Name: "Sensu Go", - Type: "sensugo", - Settings: settingsJSON, - } - - encryptionService := encryptionservice.SetupTestService(t) - - _, err = NewSensuGoNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - - json = ` - { - "url": "http://sensu-api.example.com:8080", - "entity": "grafana_instance_01", - "check": "grafana_rule_0", - "namespace": "default", - "handler": "myhandler", - "apikey": "abcdef0123456789abcdef" - }` - - settingsJSON, err = simplejson.NewJson([]byte(json)) - require.NoError(t, err) - model = &models.AlertNotification{ - Name: "Sensu Go", - Type: "sensugo", - Settings: settingsJSON, - } - - not, err := NewSensuGoNotifier(model, encryptionService.GetDecryptedValue, nil) - require.NoError(t, err) - sensuGoNotifier := not.(*SensuGoNotifier) - - assert.Equal(t, "Sensu Go", sensuGoNotifier.Name) - assert.Equal(t, "sensugo", sensuGoNotifier.Type) - assert.Equal(t, "http://sensu-api.example.com:8080", sensuGoNotifier.URL) - assert.Equal(t, "grafana_instance_01", sensuGoNotifier.Entity) - assert.Equal(t, "grafana_rule_0", sensuGoNotifier.Check) - assert.Equal(t, "default", sensuGoNotifier.Namespace) - assert.Equal(t, "myhandler", sensuGoNotifier.Handler) - assert.Equal(t, "abcdef0123456789abcdef", sensuGoNotifier.APIKey) -} diff --git a/pkg/services/alerting/notifiers/slack.go b/pkg/services/alerting/notifiers/slack.go deleted file mode 100644 index 7a673b99dd22d..0000000000000 --- a/pkg/services/alerting/notifiers/slack.go +++ /dev/null @@ -1,478 +0,0 @@ -package notifiers - -import ( - "bytes" - "context" - "crypto/tls" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" - "time" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" -) - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "slack", - Name: "Slack", - Description: "Sends notifications to Slack", - Heading: "Slack settings", - Factory: NewSlackNotifier, - Options: []alerting.NotifierOption{ - { - Label: "Recipient", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Description: "Specify channel, private group, or IM channel (can be an encoded ID or a name) - required unless you provide a webhook", - PropertyName: "recipient", - }, - // Logically, this field should be required when not using a webhook, since the Slack API needs a token. - // However, since the UI doesn't allow to say that a field is required or not depending on another field, - // we've gone with the compromise of making this field optional and instead return a validation error - // if it's necessary and missing. - { - Label: "Token", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Description: "Provide a Slack API token (starts with \"xoxb\") - required unless you provide a webhook", - PropertyName: "token", - Secure: true, - }, - { - Label: "Username", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Description: "Set the username for the bot's message", - PropertyName: "username", - }, - { - Label: "Icon emoji", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Description: "Provide an emoji to use as the icon for the bot's message. Overrides the icon URL.", - PropertyName: "icon_emoji", - }, - { - Label: "Icon URL", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Description: "Provide a URL to an image to use as the icon for the bot's message", - PropertyName: "icon_url", - }, - { - Label: "Mention Users", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Description: "Mention one or more users (comma separated) when notifying in a channel, by ID (you can copy this from the user's Slack profile)", - PropertyName: "mentionUsers", - }, - { - Label: "Mention Groups", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Description: "Mention one or more groups (comma separated) when notifying in a channel (you can copy this from the group's Slack profile URL)", - PropertyName: "mentionGroups", - }, - { - Label: "Mention Channel", - Element: alerting.ElementTypeSelect, - SelectOptions: []alerting.SelectOption{ - { - Value: "", - Label: "Disabled", - }, - { - Value: "here", - Label: "Every active channel member", - }, - { - Value: "channel", - Label: "Every channel member", - }, - }, - Description: "Mention whole channel or just active members when notifying", - PropertyName: "mentionChannel", - }, - { - Label: "Webhook URL", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Description: "Optionally provide a Slack incoming webhook URL for sending messages, in this case the token isn't necessary", - Placeholder: "Slack incoming webhook URL", - PropertyName: "url", - Secure: true, - }, - }, - }) -} - -const slackAPIEndpoint = "https://slack.com/api/chat.postMessage" - -// NewSlackNotifier is the constructor for the Slack notifier. -func NewSlackNotifier(model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - urlStr := fn(context.Background(), model.SecureSettings, "url", model.Settings.Get("url").MustString(), setting.SecretKey) - if urlStr == "" { - urlStr = slackAPIEndpoint - } - apiURL, err := url.Parse(urlStr) - if err != nil { - return nil, fmt.Errorf("invalid URL %q: %w", urlStr, err) - } - - recipient := strings.TrimSpace(model.Settings.Get("recipient").MustString()) - if recipient == "" && apiURL.String() == slackAPIEndpoint { - return nil, alerting.ValidationError{ - Reason: "recipient must be specified when using the Slack chat API", - } - } - username := model.Settings.Get("username").MustString() - iconEmoji := model.Settings.Get("icon_emoji").MustString() - iconURL := model.Settings.Get("icon_url").MustString() - mentionUsersStr := model.Settings.Get("mentionUsers").MustString() - mentionGroupsStr := model.Settings.Get("mentionGroups").MustString() - mentionChannel := model.Settings.Get("mentionChannel").MustString() - token := fn(context.Background(), model.SecureSettings, "token", model.Settings.Get("token").MustString(), setting.SecretKey) - if token == "" && apiURL.String() == slackAPIEndpoint { - return nil, alerting.ValidationError{ - Reason: "token must be specified when using the Slack chat API", - } - } - - uploadImage := model.Settings.Get("uploadImage").MustBool(true) - - if mentionChannel != "" && mentionChannel != "here" && mentionChannel != "channel" { - return nil, alerting.ValidationError{ - Reason: fmt.Sprintf("Invalid value for mentionChannel: %q", mentionChannel), - } - } - mentionUsers := []string{} - for _, u := range strings.Split(mentionUsersStr, ",") { - u = strings.TrimSpace(u) - if u != "" { - mentionUsers = append(mentionUsers, u) - } - } - mentionGroups := []string{} - for _, g := range strings.Split(mentionGroupsStr, ",") { - g = strings.TrimSpace(g) - if g != "" { - mentionGroups = append(mentionGroups, g) - } - } - - return &SlackNotifier{ - url: apiURL, - NotifierBase: NewNotifierBase(model, ns), - recipient: recipient, - username: username, - iconEmoji: iconEmoji, - iconURL: iconURL, - mentionUsers: mentionUsers, - mentionGroups: mentionGroups, - mentionChannel: mentionChannel, - token: token, - upload: uploadImage, - log: log.New("alerting.notifier.slack"), - }, nil -} - -// SlackNotifier is responsible for sending -// alert notification to Slack. -type SlackNotifier struct { - NotifierBase - url *url.URL - recipient string - username string - iconEmoji string - iconURL string - mentionUsers []string - mentionGroups []string - mentionChannel string - token string - upload bool - log log.Logger -} - -// Notify sends an alert notification to Slack. -func (sn *SlackNotifier) Notify(evalContext *alerting.EvalContext) error { - sn.log.Info("Executing slack notification", "ruleId", evalContext.Rule.ID, "notification", sn.Name) - - ruleURL, err := evalContext.GetRuleURL() - if err != nil { - sn.log.Error("Failed to get rule link", "error", err) - return err - } - - fields := make([]map[string]interface{}, 0) - for _, evt := range evalContext.EvalMatches { - fields = append(fields, map[string]interface{}{ - "title": evt.Metric, - "value": evt.Value, - "short": true, - }) - } - - if evalContext.Error != nil { - fields = append(fields, map[string]interface{}{ - "title": "Error message", - "value": evalContext.Error.Error(), - "short": false, - }) - } - - mentionsBuilder := strings.Builder{} - appendSpace := func() { - if mentionsBuilder.Len() > 0 { - mentionsBuilder.WriteString(" ") - } - } - mentionChannel := strings.TrimSpace(sn.mentionChannel) - if mentionChannel != "" { - mentionsBuilder.WriteString(fmt.Sprintf("<!%s|%s>", mentionChannel, mentionChannel)) - } - if len(sn.mentionGroups) > 0 { - appendSpace() - for _, g := range sn.mentionGroups { - mentionsBuilder.WriteString(fmt.Sprintf("<!subteam^%s>", g)) - } - } - if len(sn.mentionUsers) > 0 { - appendSpace() - for _, u := range sn.mentionUsers { - mentionsBuilder.WriteString(fmt.Sprintf("<@%s>", u)) - } - } - msg := "" - if evalContext.Rule.State != models.AlertStateOK { // don't add message when going back to alert state ok. - msg = evalContext.Rule.Message - } - imageURL := "" - // default to file.upload API method if a token is provided - if sn.token == "" { - imageURL = evalContext.ImagePublicURL - } - - var blocks []map[string]interface{} - if mentionsBuilder.Len() > 0 { - blocks = []map[string]interface{}{ - { - "type": "section", - "text": map[string]interface{}{ - "type": "mrkdwn", - "text": mentionsBuilder.String(), - }, - }, - } - } - attachment := map[string]interface{}{ - "color": evalContext.GetStateModel().Color, - "title": evalContext.GetNotificationTitle(), - "title_link": ruleURL, - "text": msg, - "fallback": evalContext.GetNotificationTitle(), - "fields": fields, - "footer": "Grafana v" + setting.BuildVersion, - "footer_icon": "https://grafana.com/static/assets/img/fav32.png", - "ts": time.Now().Unix(), - } - if sn.NeedsImage() && imageURL != "" { - attachment["image_url"] = imageURL - } - body := map[string]interface{}{ - "channel": sn.recipient, - "attachments": []map[string]interface{}{ - attachment, - }, - } - if len(blocks) > 0 { - body["blocks"] = blocks - } - - if sn.username != "" { - body["username"] = sn.username - } - if sn.iconEmoji != "" { - body["icon_emoji"] = sn.iconEmoji - } - if sn.iconURL != "" { - body["icon_url"] = sn.iconURL - } - data, err := json.Marshal(&body) - if err != nil { - return err - } - - if err := sn.sendRequest(evalContext.Ctx, data); err != nil { - return err - } - - if sn.token != "" && sn.UploadImage { - err := sn.slackFileUpload(evalContext, sn.log, sn.recipient, sn.token) - if err != nil { - return err - } - } - - return nil -} - -func (sn *SlackNotifier) sendRequest(ctx context.Context, data []byte) error { - sn.log.Debug("Sending Slack API request", "url", sn.url.String(), "data", string(data)) - request, err := http.NewRequestWithContext(ctx, http.MethodPost, sn.url.String(), bytes.NewReader(data)) - if err != nil { - return fmt.Errorf("failed to create HTTP request: %w", err) - } - - request.Header.Set("Content-Type", "application/json") - request.Header.Set("User-Agent", "Grafana") - if sn.token == "" { - if sn.url.String() == slackAPIEndpoint { - panic("Token should be set when using the Slack chat API") - } - } else { - sn.log.Debug("Adding authorization header to HTTP request") - request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", sn.token)) - } - - netTransport := &http.Transport{ - TLSClientConfig: &tls.Config{ - Renegotiation: tls.RenegotiateFreelyAsClient, - }, - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - }).DialContext, - TLSHandshakeTimeout: 5 * time.Second, - } - netClient := &http.Client{ - Timeout: time.Second * 30, - Transport: netTransport, - } - resp, err := netClient.Do(request) - if err != nil { - return err - } - defer func() { - if err := resp.Body.Close(); err != nil { - sn.log.Warn("Failed to close response body", "err", err) - } - }() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - // Slack responds to some requests with a JSON document, that might contain an error. - rslt := struct { - Ok bool `json:"ok"` - Err string `json:"error"` - }{} - - // Marshaling can fail if Slack's response body is plain text (e.g. "ok"). - if err := json.Unmarshal(body, &rslt); err != nil && json.Valid(body) { - sn.log.Error("Failed to unmarshal Slack API response", "url", sn.url.String(), "statusCode", resp.Status, - "err", err) - return fmt.Errorf("failed to unmarshal Slack API response with status code %d: %s", resp.StatusCode, err) - } - - if !rslt.Ok && rslt.Err != "" { - sn.log.Error("Sending Slack API request failed", "url", sn.url.String(), "statusCode", resp.Status, - "err", rslt.Err) - return fmt.Errorf("failed to make Slack API request: %s", rslt.Err) - } - - sn.log.Debug("Sending Slack API request succeeded", "url", sn.url.String(), "statusCode", resp.Status) - - return nil - } - - sn.log.Error("Slack API request failed", "url", sn.url.String(), "statusCode", resp.Status, "body", string(body)) - return fmt.Errorf("request to Slack API failed with status code %d", resp.StatusCode) -} - -func (sn *SlackNotifier) slackFileUpload(evalContext *alerting.EvalContext, log log.Logger, recipient, token string) error { - if evalContext.ImageOnDiskPath == "" { - // nolint:gosec - // We can ignore the gosec G304 warning on this one because `setting.HomePath` comes from Grafana's configuration file. - evalContext.ImageOnDiskPath = filepath.Join(setting.HomePath, "public/img/mixed_styles.png") - } - log.Info("Uploading to slack via file.upload API") - headers, uploadBody, err := sn.generateSlackBody(evalContext.ImageOnDiskPath, token, recipient) - if err != nil { - return err - } - cmd := ¬ifications.SendWebhookSync{ - Url: "https://slack.com/api/files.upload", Body: uploadBody.String(), HttpHeader: headers, HttpMethod: "POST", - } - if err := sn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - log.Error("Failed to upload slack image", "error", err, "webhook", "file.upload") - return err - } - return nil -} - -func (sn *SlackNotifier) generateSlackBody(path string, token string, recipient string) (map[string]string, bytes.Buffer, error) { - // Slack requires all POSTs to files.upload to present - // an "application/x-www-form-urlencoded" encoded querystring - // See https://api.slack.com/methods/files.upload - var b bytes.Buffer - w := multipart.NewWriter(&b) - defer func() { - if err := w.Close(); err != nil { - // Shouldn't matter since we already close w explicitly on the non-error path - sn.log.Warn("Failed to close multipart writer", "err", err) - } - }() - - // Add the generated image file - // We can ignore the gosec G304 warning on this one because `imagePath` comes - // from the alert `evalContext` that generates the images. `evalContext` in turn derives the root of the file - // path from configuration variables. - // nolint:gosec - f, err := os.Open(path) - if err != nil { - return nil, b, err - } - defer func() { - if err := f.Close(); err != nil { - sn.log.Warn("Failed to close file", "path", path, "err", err) - } - }() - fw, err := w.CreateFormFile("file", path) - if err != nil { - return nil, b, err - } - if _, err := io.Copy(fw, f); err != nil { - return nil, b, err - } - // Add the authorization token - if err := w.WriteField("token", token); err != nil { - return nil, b, err - } - // Add the channel(s) to POST to - if err := w.WriteField("channels", recipient); err != nil { - return nil, b, err - } - if err := w.Close(); err != nil { - return nil, b, fmt.Errorf("failed to close multipart writer: %w", err) - } - headers := map[string]string{ - "Content-Type": w.FormDataContentType(), - "Authorization": "auth_token=\"" + token + "\"", - } - return headers, b, nil -} diff --git a/pkg/services/alerting/notifiers/slack_test.go b/pkg/services/alerting/notifiers/slack_test.go deleted file mode 100644 index 909257c741a25..0000000000000 --- a/pkg/services/alerting/notifiers/slack_test.go +++ /dev/null @@ -1,273 +0,0 @@ -package notifiers - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting/models" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" - "github.com/grafana/grafana/pkg/setting" -) - -func TestSlackNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.NoError(t, err) - model := &models.AlertNotification{ - Name: "ops", - Type: "slack", - Settings: settingsJSON, - } - - _, err = NewSlackNotifier(model, encryptionService.GetDecryptedValue, nil) - assert.EqualError(t, err, "alert validation error: recipient must be specified when using the Slack chat API") - }) - - t.Run("from settings", func(t *testing.T) { - json := ` - { - "url": "http://google.com" - }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.NoError(t, err) - model := &models.AlertNotification{ - Name: "ops", - Type: "slack", - Settings: settingsJSON, - } - - not, err := NewSlackNotifier(model, encryptionService.GetDecryptedValue, nil) - require.NoError(t, err) - slackNotifier := not.(*SlackNotifier) - assert.Equal(t, "ops", slackNotifier.Name) - assert.Equal(t, "slack", slackNotifier.Type) - assert.Equal(t, "http://google.com", slackNotifier.url.String()) - assert.Empty(t, slackNotifier.recipient) - assert.Empty(t, slackNotifier.username) - assert.Empty(t, slackNotifier.iconEmoji) - assert.Empty(t, slackNotifier.iconURL) - assert.Empty(t, slackNotifier.mentionUsers) - assert.Empty(t, slackNotifier.mentionGroups) - assert.Empty(t, slackNotifier.mentionChannel) - assert.Empty(t, slackNotifier.token) - }) - - t.Run("from settings with Recipient, Username, IconEmoji, IconUrl, MentionUsers, MentionGroups, MentionChannel, and Token", func(t *testing.T) { - json := ` - { - "url": "http://google.com", - "recipient": "#ds-opentsdb", - "username": "Grafana Alerts", - "icon_emoji": ":smile:", - "icon_url": "https://grafana.com/img/fav32.png", - "mentionUsers": "user1, user2", - "mentionGroups": "group1, group2", - "mentionChannel": "here", - "token": "xoxb-XXXXXXXX-XXXXXXXX-XXXXXXXXXX" - }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.NoError(t, err) - model := &models.AlertNotification{ - Name: "ops", - Type: "slack", - Settings: settingsJSON, - } - - not, err := NewSlackNotifier(model, encryptionService.GetDecryptedValue, nil) - require.NoError(t, err) - slackNotifier := not.(*SlackNotifier) - assert.Equal(t, "ops", slackNotifier.Name) - assert.Equal(t, "slack", slackNotifier.Type) - assert.Equal(t, "http://google.com", slackNotifier.url.String()) - assert.Equal(t, "#ds-opentsdb", slackNotifier.recipient) - assert.Equal(t, "Grafana Alerts", slackNotifier.username) - assert.Equal(t, ":smile:", slackNotifier.iconEmoji) - assert.Equal(t, "https://grafana.com/img/fav32.png", slackNotifier.iconURL) - assert.Equal(t, []string{"user1", "user2"}, slackNotifier.mentionUsers) - assert.Equal(t, []string{"group1", "group2"}, slackNotifier.mentionGroups) - assert.Equal(t, "here", slackNotifier.mentionChannel) - assert.Equal(t, "xoxb-XXXXXXXX-XXXXXXXX-XXXXXXXXXX", slackNotifier.token) - }) - - t.Run("from settings with Recipient, Username, IconEmoji, IconUrl, MentionUsers, MentionGroups, MentionChannel, and Secured Token", func(t *testing.T) { - json := ` - { - "url": "http://google.com", - "recipient": "#ds-opentsdb", - "username": "Grafana Alerts", - "icon_emoji": ":smile:", - "icon_url": "https://grafana.com/img/fav32.png", - "mentionUsers": "user1, user2", - "mentionGroups": "group1, group2", - "mentionChannel": "here", - "token": "uenc-XXXXXXXX-XXXXXXXX-XXXXXXXXXX" - }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.NoError(t, err) - - encryptionService := encryptionService - securedSettingsJSON, err := encryptionService.EncryptJsonData( - context.Background(), - map[string]string{ - "token": "xenc-XXXXXXXX-XXXXXXXX-XXXXXXXXXX", - }, setting.SecretKey) - require.NoError(t, err) - - model := &models.AlertNotification{ - Name: "ops", - Type: "slack", - Settings: settingsJSON, - SecureSettings: securedSettingsJSON, - } - - not, err := NewSlackNotifier(model, encryptionService.GetDecryptedValue, nil) - require.NoError(t, err) - slackNotifier := not.(*SlackNotifier) - assert.Equal(t, "ops", slackNotifier.Name) - assert.Equal(t, "slack", slackNotifier.Type) - assert.Equal(t, "http://google.com", slackNotifier.url.String()) - assert.Equal(t, "#ds-opentsdb", slackNotifier.recipient) - assert.Equal(t, "Grafana Alerts", slackNotifier.username) - assert.Equal(t, ":smile:", slackNotifier.iconEmoji) - assert.Equal(t, "https://grafana.com/img/fav32.png", slackNotifier.iconURL) - assert.Equal(t, []string{"user1", "user2"}, slackNotifier.mentionUsers) - assert.Equal(t, []string{"group1", "group2"}, slackNotifier.mentionGroups) - assert.Equal(t, "here", slackNotifier.mentionChannel) - assert.Equal(t, "xenc-XXXXXXXX-XXXXXXXX-XXXXXXXXXX", slackNotifier.token) - }) - - t.Run("with Slack ID for recipient should work", func(t *testing.T) { - json := ` - { - "url": "http://google.com", - "recipient": "1ABCDE" - }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.NoError(t, err) - model := &models.AlertNotification{ - Name: "ops", - Type: "slack", - Settings: settingsJSON, - } - - not, err := NewSlackNotifier(model, encryptionService.GetDecryptedValue, nil) - require.NoError(t, err) - slackNotifier := not.(*SlackNotifier) - assert.Equal(t, "1ABCDE", slackNotifier.recipient) - }) -} - -func TestSendSlackRequest(t *testing.T) { - tests := []struct { - name string - slackResponse string - statusCode int - expectError bool - }{ - { - name: "Example error", - slackResponse: `{ - "ok": false, - "error": "too_many_attachments" - }`, - statusCode: http.StatusBadRequest, - expectError: true, - }, - { - name: "Non 200 status code, no response body", - statusCode: http.StatusMovedPermanently, - expectError: true, - }, - { - name: "Success case, normal response body", - slackResponse: `{ - "ok": true, - "channel": "C1H9RESGL", - "ts": "1503435956.000247", - "message": { - "text": "Here's a message for you", - "username": "ecto1", - "bot_id": "B19LU7CSY", - "attachments": [ - { - "text": "This is an attachment", - "id": 1, - "fallback": "This is an attachment's fallback" - } - ], - "type": "message", - "subtype": "bot_message", - "ts": "1503435956.000247" - } - }`, - statusCode: http.StatusOK, - expectError: false, - }, - { - name: "No response body", - statusCode: http.StatusOK, - }, - { - name: "Success case, unexpected response body", - statusCode: http.StatusOK, - slackResponse: `{"test": true}`, - expectError: false, - }, - { - name: "Success case, ok: true", - statusCode: http.StatusOK, - slackResponse: `{"ok": true}`, - expectError: false, - }, - { - name: "200 status code, error in body", - statusCode: http.StatusOK, - slackResponse: `{"ok": false, "error": "test error"}`, - expectError: true, - }, - } - - for _, test := range tests { - t.Run(test.name, func(tt *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(test.statusCode) - _, err := w.Write([]byte(test.slackResponse)) - require.NoError(tt, err) - })) - - settingsJSON, err := simplejson.NewJson([]byte(fmt.Sprintf(`{"url": %q}`, server.URL))) - require.NoError(t, err) - model := &models.AlertNotification{ - Settings: settingsJSON, - } - - encryptionService := encryptionservice.SetupTestService(t) - - not, err := NewSlackNotifier(model, encryptionService.GetDecryptedValue, nil) - require.NoError(t, err) - slackNotifier := not.(*SlackNotifier) - - err = slackNotifier.sendRequest(context.Background(), []byte("test")) - if !test.expectError { - require.NoError(tt, err) - } else { - require.Error(tt, err) - } - }) - } -} diff --git a/pkg/services/alerting/notifiers/teams.go b/pkg/services/alerting/notifiers/teams.go deleted file mode 100644 index a2a10a706c5ce..0000000000000 --- a/pkg/services/alerting/notifiers/teams.go +++ /dev/null @@ -1,144 +0,0 @@ -package notifiers - -import ( - "encoding/json" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" -) - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "teams", - Name: "Microsoft Teams", - Description: "Sends notifications using Incoming Webhook connector to Microsoft Teams", - Heading: "Teams settings", - Factory: NewTeamsNotifier, - Options: []alerting.NotifierOption{ - { - Label: "URL", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "Teams incoming webhook url", - PropertyName: "url", - Required: true, - }, - }, - }) -} - -// NewTeamsNotifier is the constructor for Teams notifier. -func NewTeamsNotifier(model *models.AlertNotification, _ alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - url := model.Settings.Get("url").MustString() - if url == "" { - return nil, alerting.ValidationError{Reason: "Could not find url property in settings"} - } - - return &TeamsNotifier{ - NotifierBase: NewNotifierBase(model, ns), - URL: url, - log: log.New("alerting.notifier.teams"), - }, nil -} - -// TeamsNotifier is responsible for sending -// alert notifications to Microsoft teams. -type TeamsNotifier struct { - NotifierBase - URL string - log log.Logger -} - -// Notify send an alert notification to Microsoft teams. -func (tn *TeamsNotifier) Notify(evalContext *alerting.EvalContext) error { - tn.log.Info("Executing teams notification", "ruleId", evalContext.Rule.ID, "notification", tn.Name) - - ruleURL, err := evalContext.GetRuleURL() - if err != nil { - tn.log.Error("Failed get rule link", "error", err) - return err - } - - fields := make([]map[string]any, 0) - fieldLimitCount := 4 - for index, evt := range evalContext.EvalMatches { - fields = append(fields, map[string]any{ - "name": evt.Metric, - "value": evt.Value, - }) - if index > fieldLimitCount { - break - } - } - - if evalContext.Error != nil { - fields = append(fields, map[string]any{ - "name": "Error message", - "value": evalContext.Error.Error(), - }) - } - - message := "" - if evalContext.Rule.State != models.AlertStateOK { // don't add message when going back to alert state ok. - message = evalContext.Rule.Message - } - - images := make([]map[string]any, 0) - if tn.NeedsImage() && evalContext.ImagePublicURL != "" { - images = append(images, map[string]any{ - "image": evalContext.ImagePublicURL, - }) - } - - body := map[string]any{ - "@type": "MessageCard", - "@context": "http://schema.org/extensions", - // summary MUST not be empty or the webhook request fails - // summary SHOULD contain some meaningful information, since it is used for mobile notifications - "summary": evalContext.GetNotificationTitle(), - "title": evalContext.GetNotificationTitle(), - "themeColor": evalContext.GetStateModel().Color, - "sections": []map[string]any{ - { - "title": "Details", - "facts": fields, - "images": images, - "text": message, - }, - }, - "potentialAction": []map[string]any{ - { - "@context": "http://schema.org", - "@type": "OpenUri", - "name": "View Rule", - "targets": []map[string]any{ - { - "os": "default", "uri": ruleURL, - }, - }, - }, - { - "@context": "http://schema.org", - "@type": "OpenUri", - "name": "View Graph", - "targets": []map[string]any{ - { - "os": "default", "uri": evalContext.ImagePublicURL, - }, - }, - }, - }, - } - - data, _ := json.Marshal(&body) - cmd := ¬ifications.SendWebhookSync{Url: tn.URL, Body: string(data)} - - if err := tn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - tn.log.Error("Failed to send teams notification", "error", err, "webhook", tn.Name) - return err - } - - return nil -} diff --git a/pkg/services/alerting/notifiers/teams_test.go b/pkg/services/alerting/notifiers/teams_test.go deleted file mode 100644 index ce10015888290..0000000000000 --- a/pkg/services/alerting/notifiers/teams_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package notifiers - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting/models" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" -) - -func TestTeamsNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("Parsing alert notification from settings", func(t *testing.T) { - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "ops", - Type: "teams", - Settings: settingsJSON, - } - - _, err := NewTeamsNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - - t.Run("from settings", func(t *testing.T) { - json := ` - { - "url": "http://google.com" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "ops", - Type: "teams", - Settings: settingsJSON, - } - - not, err := NewTeamsNotifier(model, encryptionService.GetDecryptedValue, nil) - teamsNotifier := not.(*TeamsNotifier) - - require.Nil(t, err) - require.Equal(t, "ops", teamsNotifier.Name) - require.Equal(t, "teams", teamsNotifier.Type) - require.Equal(t, "http://google.com", teamsNotifier.URL) - }) - - t.Run("from settings with Recipient and Mention", func(t *testing.T) { - json := ` - { - "url": "http://google.com" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "ops", - Type: "teams", - Settings: settingsJSON, - } - - not, err := NewTeamsNotifier(model, encryptionService.GetDecryptedValue, nil) - teamsNotifier := not.(*TeamsNotifier) - - require.Nil(t, err) - require.Equal(t, "ops", teamsNotifier.Name) - require.Equal(t, "teams", teamsNotifier.Type) - require.Equal(t, "http://google.com", teamsNotifier.URL) - }) - }) -} diff --git a/pkg/services/alerting/notifiers/telegram.go b/pkg/services/alerting/notifiers/telegram.go deleted file mode 100644 index 0dcf34afb67b6..0000000000000 --- a/pkg/services/alerting/notifiers/telegram.go +++ /dev/null @@ -1,280 +0,0 @@ -package notifiers - -import ( - "bytes" - "context" - "fmt" - "io" - "mime/multipart" - "os" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" -) - -const ( - captionLengthLimit = 1024 -) - -var ( - telegramAPIURL = "https://api.telegram.org/bot%s/%s" -) - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "telegram", - Name: "Telegram", - Description: "Sends notifications to Telegram", - Heading: "Telegram API settings", - Factory: NewTelegramNotifier, - Options: []alerting.NotifierOption{ - { - Label: "BOT API Token", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "Telegram BOT API Token", - PropertyName: "bottoken", - Required: true, - Secure: true, - }, - { - Label: "Chat ID", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Description: "Integer Telegram Chat Identifier", - PropertyName: "chatid", - Required: true, - }, - }, - }) -} - -// TelegramNotifier is responsible for sending -// alert notifications to Telegram. -type TelegramNotifier struct { - NotifierBase - BotToken string - ChatID string - UploadImage bool - log log.Logger -} - -// NewTelegramNotifier is the constructor for the Telegram notifier -func NewTelegramNotifier(model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - if model.Settings == nil { - return nil, alerting.ValidationError{Reason: "No Settings Supplied"} - } - - botToken := fn(context.Background(), model.SecureSettings, "bottoken", model.Settings.Get("bottoken").MustString(), setting.SecretKey) - chatID := model.Settings.Get("chatid").MustString() - uploadImage := model.Settings.Get("uploadImage").MustBool() - - if botToken == "" { - return nil, alerting.ValidationError{Reason: "Could not find Bot Token in settings"} - } - - if chatID == "" { - return nil, alerting.ValidationError{Reason: "Could not find Chat Id in settings"} - } - - return &TelegramNotifier{ - NotifierBase: NewNotifierBase(model, ns), - BotToken: botToken, - ChatID: chatID, - UploadImage: uploadImage, - log: log.New("alerting.notifier.telegram"), - }, nil -} - -func (tn *TelegramNotifier) buildMessage(evalContext *alerting.EvalContext, sendImageInline bool) (*notifications.SendWebhookSync, error) { - if sendImageInline { - cmd, err := tn.buildMessageInlineImage(evalContext) - if err == nil { - return cmd, nil - } - - tn.log.Error("Could not generate Telegram message with inline image.", "err", err) - } - - return tn.buildMessageLinkedImage(evalContext) -} - -func (tn *TelegramNotifier) buildMessageLinkedImage(evalContext *alerting.EvalContext) (*notifications.SendWebhookSync, error) { - message := fmt.Sprintf("<b>%s</b>\nState: %s\nMessage: %s\n", evalContext.GetNotificationTitle(), evalContext.Rule.Name, evalContext.Rule.Message) - - ruleURL, err := evalContext.GetRuleURL() - if err == nil { - message += fmt.Sprintf("URL: %s\n", ruleURL) - } - - if evalContext.ImagePublicURL != "" { - message += fmt.Sprintf("Image: %s\n", evalContext.ImagePublicURL) - } - - metrics := generateMetricsMessage(evalContext) - if metrics != "" { - message += fmt.Sprintf("\n<i>Metrics:</i>%s", metrics) - } - - return tn.generateTelegramCmd(message, "text", "sendMessage", func(w *multipart.Writer) { - fw, err := w.CreateFormField("parse_mode") - if err != nil { - tn.log.Error("Failed to create form file", "err", err) - return - } - - if _, err := fw.Write([]byte("html")); err != nil { - tn.log.Error("Failed to write to form field", "err", err) - } - }) -} - -func (tn *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.EvalContext) (*notifications.SendWebhookSync, error) { - var imageFile *os.File - var err error - - imageFile, err = os.Open(evalContext.ImageOnDiskPath) - if err != nil { - return nil, err - } - - defer func() { - err := imageFile.Close() - if err != nil { - tn.log.Error("Could not close Telegram inline image.", "err", err) - } - }() - - ruleURL, err := evalContext.GetRuleURL() - if err != nil { - return nil, err - } - - metrics := generateMetricsMessage(evalContext) - message := generateImageCaption(evalContext, ruleURL, metrics) - - return tn.generateTelegramCmd(message, "caption", "sendPhoto", func(w *multipart.Writer) { - fw, err := w.CreateFormFile("photo", evalContext.ImageOnDiskPath) - if err != nil { - tn.log.Error("Failed to create form file", "err", err) - return - } - - if _, err := io.Copy(fw, imageFile); err != nil { - tn.log.Error("Failed to write to form file", "err", err) - } - }) -} - -func (tn *TelegramNotifier) generateTelegramCmd(message string, messageField string, apiAction string, extraConf func(writer *multipart.Writer)) (*notifications.SendWebhookSync, error) { - var body bytes.Buffer - w := multipart.NewWriter(&body) - defer func() { - if err := w.Close(); err != nil { - tn.log.Warn("Failed to close writer", "err", err) - } - }() - - fw, err := w.CreateFormField("chat_id") - if err != nil { - return nil, err - } - if _, err := fw.Write([]byte(tn.ChatID)); err != nil { - return nil, err - } - - fw, err = w.CreateFormField(messageField) - if err != nil { - return nil, err - } - if _, err := fw.Write([]byte(message)); err != nil { - return nil, err - } - - extraConf(w) - - if err := w.Close(); err != nil { - return nil, err - } - - tn.log.Info("Sending telegram notification", "chat_id", tn.ChatID, "bot_token", tn.BotToken, "apiAction", apiAction) - url := fmt.Sprintf(telegramAPIURL, tn.BotToken, apiAction) - - cmd := ¬ifications.SendWebhookSync{ - Url: url, - Body: body.String(), - HttpMethod: "POST", - HttpHeader: map[string]string{ - "Content-Type": w.FormDataContentType(), - }, - } - return cmd, nil -} - -func generateMetricsMessage(evalContext *alerting.EvalContext) string { - metrics := "" - fieldLimitCount := 4 - for index, evt := range evalContext.EvalMatches { - metrics += fmt.Sprintf("\n%s: %s", evt.Metric, evt.Value) - if index > fieldLimitCount { - break - } - } - return metrics -} - -func generateImageCaption(evalContext *alerting.EvalContext, ruleURL string, metrics string) string { - message := evalContext.GetNotificationTitle() - - if len(evalContext.Rule.Message) > 0 { - message = fmt.Sprintf("%s\nMessage: %s", message, evalContext.Rule.Message) - } - - if len(message) > captionLengthLimit { - message = message[0:captionLengthLimit] - } - - if len(ruleURL) > 0 { - urlLine := fmt.Sprintf("\nURL: %s", ruleURL) - message = appendIfPossible(evalContext.Log, message, urlLine, captionLengthLimit) - } - - if metrics != "" { - metricsLines := fmt.Sprintf("\n\nMetrics:%s", metrics) - message = appendIfPossible(evalContext.Log, message, metricsLines, captionLengthLimit) - } - - return message -} - -func appendIfPossible(tlog log.Logger, message string, extra string, sizeLimit int) string { - if len(extra)+len(message) <= sizeLimit { - return message + extra - } - tlog.Debug("Line too long for image caption.", "value", extra) - return message -} - -// Notify send an alert notification to Telegram. -func (tn *TelegramNotifier) Notify(evalContext *alerting.EvalContext) error { - var cmd *notifications.SendWebhookSync - var err error - if evalContext.ImagePublicURL == "" && tn.UploadImage { - cmd, err = tn.buildMessage(evalContext, true) - } else { - cmd, err = tn.buildMessage(evalContext, false) - } - if err != nil { - return err - } - - if err := tn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - tn.log.Error("Failed to send webhook", "error", err, "webhook", tn.Name) - return err - } - - return nil -} diff --git a/pkg/services/alerting/notifiers/telegram_test.go b/pkg/services/alerting/notifiers/telegram_test.go deleted file mode 100644 index f9c1650edb443..0000000000000 --- a/pkg/services/alerting/notifiers/telegram_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package notifiers - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" - "github.com/grafana/grafana/pkg/services/validations" -) - -func TestTelegramNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("Parsing alert notification from settings", func(t *testing.T) { - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "telegram_testing", - Type: "telegram", - Settings: settingsJSON, - } - - _, err := NewTelegramNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - - t.Run("settings should trigger incident", func(t *testing.T) { - json := ` - { - "bottoken": "abcdefgh0123456789", - "chatid": "-1234567890" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "telegram_testing", - Type: "telegram", - Settings: settingsJSON, - } - - not, err := NewTelegramNotifier(model, encryptionService.GetDecryptedValue, nil) - telegramNotifier := not.(*TelegramNotifier) - - require.Nil(t, err) - require.Equal(t, "telegram_testing", telegramNotifier.Name) - require.Equal(t, "telegram", telegramNotifier.Type) - require.Equal(t, "abcdefgh0123456789", telegramNotifier.BotToken) - require.Equal(t, "-1234567890", telegramNotifier.ChatID) - }) - - t.Run("generateCaption should generate a message with all pertinent details", func(t *testing.T) { - evalContext := alerting.NewEvalContext(context.Background(), - &alerting.Rule{ - Name: "This is an alarm", - Message: "Some kind of message.", - State: models.AlertStateOK, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - caption := generateImageCaption(evalContext, "http://grafa.url/abcdef", "") - require.LessOrEqual(t, len(caption), 1024) - require.Contains(t, caption, "Some kind of message.") - require.Contains(t, caption, "[OK] This is an alarm") - require.Contains(t, caption, "http://grafa.url/abcdef") - }) - - t.Run("When generating a message", func(t *testing.T) { - t.Run("URL should be skipped if it's too long", func(t *testing.T) { - evalContext := alerting.NewEvalContext(context.Background(), - &alerting.Rule{ - Name: "This is an alarm", - Message: "Some kind of message.", - State: models.AlertStateOK, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - caption := generateImageCaption(evalContext, - "http://grafa.url/abcdefaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "foo bar") - require.LessOrEqual(t, len(caption), 1024) - require.Contains(t, caption, "Some kind of message.") - require.Contains(t, caption, "[OK] This is an alarm") - require.Contains(t, caption, "foo bar") - require.NotContains(t, caption, "http") - }) - - t.Run("Message should be trimmed if it's too long", func(t *testing.T) { - evalContext := alerting.NewEvalContext(context.Background(), - &alerting.Rule{ - Name: "This is an alarm", - Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it. But suddenly Telegram increased the length so now we need some lorem ipsum to fix this test. Here we go: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus consectetur molestie cursus. Donec suscipit egestas nisi. Proin ut efficitur ex. Mauris mi augue, volutpat a nisi vel, euismod dictum arcu. Sed quis tempor eros, sed malesuada dolor. Ut orci augue, viverra sit amet blandit quis, faucibus sit amet ex. Duis condimentum efficitur lectus, id dignissim quam tempor id. Morbi sollicitudin rhoncus diam, id tincidunt lectus scelerisque vitae. Etiam imperdiet semper sem, vel eleifend ligula mollis eget. Etiam ultrices fringilla lacus, sit amet pharetra ex blandit quis. Suspendisse in egestas neque, et posuere lectus. Vestibulum eu ex dui. Sed molestie nulla a lobortis scelerisque. Nulla ipsum ex, iaculis vitae vehicula sit amet, fermentum eu eros.", - State: models.AlertStateOK, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - caption := generateImageCaption(evalContext, - "http://grafa.url/foo", - "") - require.LessOrEqual(t, len(caption), 1024) - require.Contains(t, caption, "[OK] This is an alarm") - require.NotContains(t, caption, "http") - require.Contains(t, caption, "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it. But suddenly Telegram increased the length so now we need some lorem ipsum to fix this test. Here we go: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus consectetur molestie cursus. Donec suscipit egestas nisi. Proin ut efficitur ex. Mauris mi augue, volutpat a nisi vel, euismod dictum arcu. Sed quis tempor eros, sed malesuada dolor. Ut orci augue, viverra sit amet blandit quis, faucibus sit amet ex. Duis condimentum efficitur lectus, id dignissim quam tempor id. Morbi sollicitudin rhoncus diam, id tincidunt lectus scelerisque vitae. Etiam imperdiet semper sem, vel eleifend ligula mollis eget. Etiam ultrices fringilla lacus, sit amet pharetra ex blandit quis. Suspendisse in egestas neque, et posuere lectus. Vestibulum eu ex dui. Sed molestie nulla a lobortis sceleri") - }) - - t.Run("Metrics should be skipped if they don't fit", func(t *testing.T) { - evalContext := alerting.NewEvalContext(context.Background(), - &alerting.Rule{ - Name: "This is an alarm", - Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it. But suddenly Telegram increased the length so now we need some lorem ipsum to fix this test. Here we go: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus consectetur molestie cursus. Donec suscipit egestas nisi. Proin ut efficitur ex. Mauris mi augue, volutpat a nisi vel, euismod dictum arcu. Sed quis tempor eros, sed malesuada dolor. Ut orci augue, viverra sit amet blandit quis, faucibus sit amet ex. Duis condimentum efficitur lectus, id dignissim quam tempor id. Morbi sollicitudin rhoncus diam, id tincidunt lectus scelerisque vitae. Etiam imperdiet semper sem, vel eleifend ligula mollis eget. Etiam ultrices fringilla lacus, sit amet pharetra ex blandit quis. Suspendisse in egestas neque, et posuere lectus. Vestibulum eu ex dui. Sed molestie nulla a lobortis sceleri", - State: models.AlertStateOK, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - - caption := generateImageCaption(evalContext, - "http://grafa.url/foo", - "foo bar long song") - require.LessOrEqual(t, len(caption), 1024) - require.Contains(t, caption, "[OK] This is an alarm") - require.NotContains(t, caption, "http") - require.NotContains(t, caption, "foo bar") - }) - }) - }) -} diff --git a/pkg/services/alerting/notifiers/threema.go b/pkg/services/alerting/notifiers/threema.go deleted file mode 100644 index 6fc3589e099e1..0000000000000 --- a/pkg/services/alerting/notifiers/threema.go +++ /dev/null @@ -1,167 +0,0 @@ -package notifiers - -import ( - "context" - "fmt" - "net/url" - "strings" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" -) - -var ( - threemaGwBaseURL = "https://msgapi.threema.ch/%s" -) - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "threema", - Name: "Threema Gateway", - Description: "Sends notifications to Threema using Threema Gateway (Basic IDs)", - Heading: "Threema Gateway settings", - Info: "Notifications can be configured for any Threema Gateway ID of type \"Basic\". End-to-End IDs are not currently supported." + - "The Threema Gateway ID can be set up at https://gateway.threema.ch/.", - Factory: NewThreemaNotifier, - Options: []alerting.NotifierOption{ - { - Label: "Gateway ID", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "*3MAGWID", - Description: "Your 8 character Threema Gateway Basic ID (starting with a *).", - PropertyName: "gateway_id", - Required: true, - ValidationRule: "\\*[0-9A-Z]{7}", - }, - { - Label: "Recipient ID", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "YOUR3MID", - Description: "The 8 character Threema ID that should receive the alerts.", - PropertyName: "recipient_id", - Required: true, - ValidationRule: "[0-9A-Z]{8}", - }, - { - Label: "API Secret", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Description: "Your Threema Gateway API secret.", - PropertyName: "api_secret", - Required: true, - Secure: true, - }, - }, - }) -} - -// ThreemaNotifier is responsible for sending -// alert notifications to Threema. -type ThreemaNotifier struct { - NotifierBase - GatewayID string - RecipientID string - APISecret string - log log.Logger -} - -// NewThreemaNotifier is the constructor for the Threema notifier -func NewThreemaNotifier(model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - if model.Settings == nil { - return nil, alerting.ValidationError{Reason: "No Settings Supplied"} - } - - gatewayID := model.Settings.Get("gateway_id").MustString() - recipientID := model.Settings.Get("recipient_id").MustString() - apiSecret := fn(context.Background(), model.SecureSettings, "api_secret", model.Settings.Get("api_secret").MustString(), setting.SecretKey) - - // Validation - if gatewayID == "" { - return nil, alerting.ValidationError{Reason: "Could not find Threema Gateway ID in settings"} - } - if !strings.HasPrefix(gatewayID, "*") { - return nil, alerting.ValidationError{Reason: "Invalid Threema Gateway ID: Must start with a *"} - } - if len(gatewayID) != 8 { - return nil, alerting.ValidationError{Reason: "Invalid Threema Gateway ID: Must be 8 characters long"} - } - if recipientID == "" { - return nil, alerting.ValidationError{Reason: "Could not find Threema Recipient ID in settings"} - } - if len(recipientID) != 8 { - return nil, alerting.ValidationError{Reason: "Invalid Threema Recipient ID: Must be 8 characters long"} - } - if apiSecret == "" { - return nil, alerting.ValidationError{Reason: "Could not find Threema API secret in settings"} - } - - return &ThreemaNotifier{ - NotifierBase: NewNotifierBase(model, ns), - GatewayID: gatewayID, - RecipientID: recipientID, - APISecret: apiSecret, - log: log.New("alerting.notifier.threema"), - }, nil -} - -// Notify send an alert notification to Threema -func (notifier *ThreemaNotifier) Notify(evalContext *alerting.EvalContext) error { - notifier.log.Info("Sending alert notification from", "threema_id", notifier.GatewayID) - notifier.log.Info("Sending alert notification to", "threema_id", notifier.RecipientID) - - // Set up basic API request data - data := url.Values{} - data.Set("from", notifier.GatewayID) - data.Set("to", notifier.RecipientID) - data.Set("secret", notifier.APISecret) - - // Determine emoji - stateEmoji := "" - switch evalContext.Rule.State { - case models.AlertStateOK: - stateEmoji = "\u2705 " // Check Mark Button - case models.AlertStateNoData: - stateEmoji = "\u2753\uFE0F " // Question Mark - case models.AlertStateAlerting: - stateEmoji = "\u26A0\uFE0F " // Warning sign - default: - // Handle other cases? - } - - // Build message - message := fmt.Sprintf("%s%s\n\n*State:* %s\n*Message:* %s\n", - stateEmoji, evalContext.GetNotificationTitle(), - evalContext.Rule.Name, evalContext.Rule.Message) - ruleURL, err := evalContext.GetRuleURL() - if err == nil { - message += fmt.Sprintf("*URL:* %s\n", ruleURL) - } - if notifier.NeedsImage() && evalContext.ImagePublicURL != "" { - message += fmt.Sprintf("*Image:* %s\n", evalContext.ImagePublicURL) - } - data.Set("text", message) - - // Prepare and send request - url := fmt.Sprintf(threemaGwBaseURL, "send_simple") - body := data.Encode() - headers := map[string]string{ - "Content-Type": "application/x-www-form-urlencoded", - } - cmd := ¬ifications.SendWebhookSync{ - Url: url, - Body: body, - HttpMethod: "POST", - HttpHeader: headers, - } - if err := notifier.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - notifier.log.Error("Failed to send webhook", "error", err, "webhook", notifier.Name) - return err - } - - return nil -} diff --git a/pkg/services/alerting/notifiers/threema_test.go b/pkg/services/alerting/notifiers/threema_test.go deleted file mode 100644 index 42b1aea6c8f5c..0000000000000 --- a/pkg/services/alerting/notifiers/threema_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package notifiers - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" -) - -func TestThreemaNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("Parsing alert notification from settings", func(t *testing.T) { - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "threema_testing", - Type: "threema", - Settings: settingsJSON, - } - - _, err := NewThreemaNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - - t.Run("valid settings should be parsed successfully", func(t *testing.T) { - json := ` - { - "gateway_id": "*3MAGWID", - "recipient_id": "ECHOECHO", - "api_secret": "1234" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "threema_testing", - Type: "threema", - Settings: settingsJSON, - } - - not, err := NewThreemaNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Nil(t, err) - threemaNotifier := not.(*ThreemaNotifier) - - require.Nil(t, err) - require.Equal(t, "threema_testing", threemaNotifier.Name) - require.Equal(t, "threema", threemaNotifier.Type) - require.Equal(t, "*3MAGWID", threemaNotifier.GatewayID) - require.Equal(t, "ECHOECHO", threemaNotifier.RecipientID) - require.Equal(t, "1234", threemaNotifier.APISecret) - }) - - t.Run("invalid Threema Gateway IDs should be rejected (prefix)", func(t *testing.T) { - json := ` - { - "gateway_id": "ECHOECHO", - "recipient_id": "ECHOECHO", - "api_secret": "1234" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "threema_testing", - Type: "threema", - Settings: settingsJSON, - } - - not, err := NewThreemaNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Nil(t, not) - var valErr alerting.ValidationError - require.True(t, errors.As(err, &valErr)) - require.Equal(t, "Invalid Threema Gateway ID: Must start with a *", valErr.Reason) - }) - - t.Run("invalid Threema Gateway IDs should be rejected (length)", func(t *testing.T) { - json := ` - { - "gateway_id": "*ECHOECHO", - "recipient_id": "ECHOECHO", - "api_secret": "1234" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "threema_testing", - Type: "threema", - Settings: settingsJSON, - } - - not, err := NewThreemaNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Nil(t, not) - var valErr alerting.ValidationError - require.True(t, errors.As(err, &valErr)) - require.Equal(t, "Invalid Threema Gateway ID: Must be 8 characters long", valErr.Reason) - }) - - t.Run("invalid Threema Recipient IDs should be rejected (length)", func(t *testing.T) { - json := ` - { - "gateway_id": "*3MAGWID", - "recipient_id": "ECHOECH", - "api_secret": "1234" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "threema_testing", - Type: "threema", - Settings: settingsJSON, - } - - not, err := NewThreemaNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Nil(t, not) - var valErr alerting.ValidationError - require.True(t, errors.As(err, &valErr)) - require.Equal(t, "Invalid Threema Recipient ID: Must be 8 characters long", valErr.Reason) - }) - }) -} diff --git a/pkg/services/alerting/notifiers/victorops.go b/pkg/services/alerting/notifiers/victorops.go deleted file mode 100644 index 4da542562d785..0000000000000 --- a/pkg/services/alerting/notifiers/victorops.go +++ /dev/null @@ -1,165 +0,0 @@ -package notifiers - -import ( - "strings" - "time" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" -) - -// AlertStateCritical - Victorops uses "CRITICAL" string to indicate "Alerting" state -const AlertStateCritical = "CRITICAL" - -// AlertStateWarning - VictorOps "WARNING" message type -const AlertStateWarning = "WARNING" -const alertStateRecovery = "RECOVERY" - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "victorops", - Name: "VictorOps", - Description: "Sends notifications to VictorOps", - Heading: "VictorOps settings", - Factory: NewVictoropsNotifier, - Options: []alerting.NotifierOption{ - { - Label: "Url", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - Placeholder: "VictorOps url", - PropertyName: "url", - Required: true, - }, - { - Label: "Auto resolve incidents", - Description: "Resolve incidents in VictorOps once the alert goes back to ok.", - Element: alerting.ElementTypeCheckbox, - PropertyName: "autoResolve", - }, - }, - }) -} - -// NewVictoropsNotifier creates an instance of VictoropsNotifier that -// handles posting notifications to Victorops REST API -func NewVictoropsNotifier(model *models.AlertNotification, _ alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - autoResolve := model.Settings.Get("autoResolve").MustBool(true) - url := model.Settings.Get("url").MustString() - if url == "" { - return nil, alerting.ValidationError{Reason: "Could not find victorops url property in settings"} - } - noDataAlertType := model.Settings.Get("noDataAlertType").MustString(AlertStateWarning) - - return &VictoropsNotifier{ - NotifierBase: NewNotifierBase(model, ns), - URL: url, - NoDataAlertType: noDataAlertType, - AutoResolve: autoResolve, - log: log.New("alerting.notifier.victorops"), - }, nil -} - -// VictoropsNotifier defines URL property for Victorops REST API -// and handles notification process by formatting POST body according to -// Victorops specifications (http://victorops.force.com/knowledgebase/articles/Integration/Alert-Ingestion-API-Documentation/) -type VictoropsNotifier struct { - NotifierBase - URL string - NoDataAlertType string - AutoResolve bool - log log.Logger -} - -func (vn *VictoropsNotifier) buildEventPayload(evalContext *alerting.EvalContext) (*simplejson.Json, error) { - ruleURL, err := evalContext.GetRuleURL() - if err != nil { - vn.log.Error("Failed get rule link", "error", err) - return nil, err - } - - if evalContext.Rule.State == models.AlertStateOK && !vn.AutoResolve { - vn.log.Info("Not alerting VictorOps", "state", evalContext.Rule.State, "auto resolve", vn.AutoResolve) - return nil, nil - } - - messageType := AlertStateCritical // Default to alerting and change based on state checks (Ensures string type) - for _, tag := range evalContext.Rule.AlertRuleTags { - if strings.ToLower(tag.Key) == "severity" { - // Only set severity if it's one of the PD supported enum values - // Info, Warning, Error, or Critical (case insensitive) - switch sev := strings.ToUpper(tag.Value); sev { - case "INFO": - fallthrough - case "WARNING": - fallthrough - case "CRITICAL": - messageType = sev - default: - vn.log.Warn("Ignoring invalid severity tag", "severity", sev) - } - } - } - - if evalContext.Rule.State == models.AlertStateNoData { // translate 'NODATA' to set alert - messageType = vn.NoDataAlertType - } - - if evalContext.Rule.State == models.AlertStateOK { - messageType = alertStateRecovery - } - - fields := make(map[string]any) - fieldLimitCount := 4 - for index, evt := range evalContext.EvalMatches { - fields[evt.Metric] = evt.Value - if index > fieldLimitCount { - break - } - } - - bodyJSON := simplejson.New() - bodyJSON.Set("message_type", messageType) - bodyJSON.Set("entity_id", evalContext.Rule.Name) - bodyJSON.Set("entity_display_name", evalContext.GetNotificationTitle()) - bodyJSON.Set("timestamp", time.Now().Unix()) - bodyJSON.Set("state_start_time", evalContext.StartTime.Unix()) - bodyJSON.Set("state_message", evalContext.Rule.Message) - bodyJSON.Set("monitoring_tool", "Grafana v"+setting.BuildVersion) - bodyJSON.Set("alert_url", ruleURL) - bodyJSON.Set("metrics", fields) - - if evalContext.Error != nil { - bodyJSON.Set("error_message", evalContext.Error.Error()) - } - - if vn.NeedsImage() && evalContext.ImagePublicURL != "" { - bodyJSON.Set("image_url", evalContext.ImagePublicURL) - } - - return bodyJSON, nil -} - -// Notify sends notification to Victorops via POST to URL endpoint -func (vn *VictoropsNotifier) Notify(evalContext *alerting.EvalContext) error { - vn.log.Info("Executing victorops notification", "ruleId", evalContext.Rule.ID, "notification", vn.Name) - - bodyJSON, err := vn.buildEventPayload(evalContext) - if err != nil { - return err - } - - data, _ := bodyJSON.MarshalJSON() - cmd := ¬ifications.SendWebhookSync{Url: vn.URL, Body: string(data)} - - if err := vn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - vn.log.Error("Failed to send Victorops notification", "error", err, "webhook", vn.Name) - return err - } - - return nil -} diff --git a/pkg/services/alerting/notifiers/victorops_test.go b/pkg/services/alerting/notifiers/victorops_test.go deleted file mode 100644 index 39134ab2c0c5e..0000000000000 --- a/pkg/services/alerting/notifiers/victorops_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package notifiers - -import ( - "context" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" - "github.com/grafana/grafana/pkg/services/tag" - "github.com/grafana/grafana/pkg/services/validations" -) - -func presenceComparerInt(a, b int64) bool { - if a == -1 { - return b != 0 - } - if b == -1 { - return a != 0 - } - return a == b -} -func TestVictoropsNotifier(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("Parsing alert notification from settings", func(t *testing.T) { - t.Run("empty settings should return error", func(t *testing.T) { - json := `{ }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "victorops_testing", - Type: "victorops", - Settings: settingsJSON, - } - - _, err := NewVictoropsNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - - t.Run("from settings", func(t *testing.T) { - json := ` - { - "url": "http://google.com" - }` - - settingsJSON, _ := simplejson.NewJson([]byte(json)) - model := &models.AlertNotification{ - Name: "victorops_testing", - Type: "victorops", - Settings: settingsJSON, - } - - not, err := NewVictoropsNotifier(model, encryptionService.GetDecryptedValue, nil) - victoropsNotifier := not.(*VictoropsNotifier) - - require.Nil(t, err) - require.Equal(t, "victorops_testing", victoropsNotifier.Name) - require.Equal(t, "victorops", victoropsNotifier.Type) - require.Equal(t, "http://google.com", victoropsNotifier.URL) - }) - - t.Run("should return properly formatted event payload when using severity override tag", func(t *testing.T) { - json := ` - { - "url": "http://google.com" - }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.Nil(t, err) - - model := &models.AlertNotification{ - Name: "victorops_testing", - Type: "victorops", - Settings: settingsJSON, - } - - not, err := NewVictoropsNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Nil(t, err) - - victoropsNotifier := not.(*VictoropsNotifier) - - evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{ - ID: 0, - Name: "someRule", - Message: "someMessage", - State: models.AlertStateAlerting, - AlertRuleTags: []*tag.Tag{ - {Key: "keyOnly"}, - {Key: "severity", Value: "warning"}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - evalContext.IsTestRun = true - - payload, err := victoropsNotifier.buildEventPayload(evalContext) - require.Nil(t, err) - - diff := cmp.Diff(map[string]any{ - "alert_url": "", - "entity_display_name": "[Alerting] someRule", - "entity_id": "someRule", - "message_type": "WARNING", - "metrics": map[string]any{}, - "monitoring_tool": "Grafana v", - "state_message": "someMessage", - "state_start_time": int64(-1), - "timestamp": int64(-1), - }, payload.Interface(), cmp.Comparer(presenceComparerInt)) - require.Empty(t, diff) - }) - t.Run("resolving with severity works properly", func(t *testing.T) { - json := ` - { - "url": "http://google.com" - }` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.Nil(t, err) - - model := &models.AlertNotification{ - Name: "victorops_testing", - Type: "victorops", - Settings: settingsJSON, - } - - not, err := NewVictoropsNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Nil(t, err) - - victoropsNotifier := not.(*VictoropsNotifier) - - evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{ - ID: 0, - Name: "someRule", - Message: "someMessage", - State: models.AlertStateOK, - AlertRuleTags: []*tag.Tag{ - {Key: "keyOnly"}, - {Key: "severity", Value: "warning"}, - }, - }, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - evalContext.IsTestRun = true - - payload, err := victoropsNotifier.buildEventPayload(evalContext) - require.Nil(t, err) - - diff := cmp.Diff(map[string]any{ - "alert_url": "", - "entity_display_name": "[OK] someRule", - "entity_id": "someRule", - "message_type": "RECOVERY", - "metrics": map[string]any{}, - "monitoring_tool": "Grafana v", - "state_message": "someMessage", - "state_start_time": int64(-1), - "timestamp": int64(-1), - }, payload.Interface(), cmp.Comparer(presenceComparerInt)) - require.Empty(t, diff) - }) - }) -} diff --git a/pkg/services/alerting/notifiers/webhook.go b/pkg/services/alerting/notifiers/webhook.go deleted file mode 100644 index 4342ee46b2a4c..0000000000000 --- a/pkg/services/alerting/notifiers/webhook.go +++ /dev/null @@ -1,162 +0,0 @@ -package notifiers - -import ( - "context" - "encoding/json" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" -) - -func init() { - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "webhook", - Name: "webhook", - Description: "Sends HTTP POST request to a URL", - Heading: "Webhook settings", - Factory: NewWebHookNotifier, - Options: []alerting.NotifierOption{ - { - Label: "Url", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - PropertyName: "url", - Required: true, - }, - { - Label: "Http Method", - Element: alerting.ElementTypeSelect, - SelectOptions: []alerting.SelectOption{ - { - Value: "POST", - Label: "POST", - }, - { - Value: "PUT", - Label: "PUT", - }, - }, - PropertyName: "httpMethod", - }, - { - Label: "Username", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypeText, - PropertyName: "username", - }, - { - Label: "Password", - Element: alerting.ElementTypeInput, - InputType: alerting.InputTypePassword, - PropertyName: "password", - Secure: true, - }, - }, - }) -} - -// NewWebHookNotifier is the constructor for -// the WebHook notifier. -func NewWebHookNotifier(model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { - url := model.Settings.Get("url").MustString() - if url == "" { - return nil, alerting.ValidationError{Reason: "Could not find url property in settings"} - } - - password := fn(context.Background(), model.SecureSettings, "password", model.Settings.Get("password").MustString(), setting.SecretKey) - - return &WebhookNotifier{ - NotifierBase: NewNotifierBase(model, ns), - URL: url, - User: model.Settings.Get("username").MustString(), - Password: password, - HTTPMethod: model.Settings.Get("httpMethod").MustString("POST"), - log: log.New("alerting.notifier.webhook"), - }, nil -} - -// WebhookNotifier is responsible for sending -// alert notifications as webhooks. -type WebhookNotifier struct { - NotifierBase - URL string - User string - Password string - HTTPMethod string - log log.Logger -} - -// WebhookNotifierBody is the body of webhook -// notification channel -type WebhookNotifierBody struct { - Title string `json:"title"` - RuleID int64 `json:"ruleId"` - RuleName string `json:"ruleName"` - State models.AlertStateType `json:"state"` - EvalMatches []*alerting.EvalMatch `json:"evalMatches"` - OrgID int64 `json:"orgId"` - DashboardID int64 `json:"dashboardId"` - PanelID int64 `json:"panelId"` - Tags map[string]string `json:"tags"` - RuleURL string `json:"ruleUrl,omitempty"` - ImageURL string `json:"imageUrl,omitempty"` - Message string `json:"message,omitempty"` -} - -// Notify send alert notifications as -// webhook as http requests. -func (wn *WebhookNotifier) Notify(evalContext *alerting.EvalContext) error { - wn.log.Info("Sending webhook") - - body := WebhookNotifierBody{ - Title: evalContext.GetNotificationTitle(), - RuleID: evalContext.Rule.ID, - RuleName: evalContext.Rule.Name, - State: evalContext.Rule.State, - EvalMatches: evalContext.EvalMatches, - OrgID: evalContext.Rule.OrgID, - DashboardID: evalContext.Rule.DashboardID, - PanelID: evalContext.Rule.PanelID, - } - - tags := make(map[string]string) - - for _, tag := range evalContext.Rule.AlertRuleTags { - tags[tag.Key] = tag.Value - } - - body.Tags = tags - - ruleURL, err := evalContext.GetRuleURL() - if err == nil { - body.RuleURL = ruleURL - } - - if wn.NeedsImage() && evalContext.ImagePublicURL != "" { - body.ImageURL = evalContext.ImagePublicURL - } - - if evalContext.Rule.Message != "" { - body.Message = evalContext.Rule.Message - } - - bodyJSON, _ := json.Marshal(body) - - cmd := ¬ifications.SendWebhookSync{ - Url: wn.URL, - User: wn.User, - Password: wn.Password, - Body: string(bodyJSON), - HttpMethod: wn.HTTPMethod, - } - - if err := wn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil { - wn.log.Error("Failed to send webhook", "error", err, "webhook", wn.Name) - return err - } - - return nil -} diff --git a/pkg/services/alerting/notifiers/webhook_test.go b/pkg/services/alerting/notifiers/webhook_test.go deleted file mode 100644 index 05c489f61ecd0..0000000000000 --- a/pkg/services/alerting/notifiers/webhook_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package notifiers - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting/models" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" -) - -func TestWebhookNotifier_parsingFromSettings(t *testing.T) { - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("Empty settings should cause error", func(t *testing.T) { - const json = `{}` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.NoError(t, err) - model := &models.AlertNotification{ - Name: "ops", - Type: "webhook", - Settings: settingsJSON, - } - - _, err = NewWebHookNotifier(model, encryptionService.GetDecryptedValue, nil) - require.Error(t, err) - }) - - t.Run("Valid settings should result in a valid notifier", func(t *testing.T) { - const json = `{"url": "http://google.com"}` - - settingsJSON, err := simplejson.NewJson([]byte(json)) - require.NoError(t, err) - model := &models.AlertNotification{ - Name: "ops", - Type: "webhook", - Settings: settingsJSON, - } - - not, err := NewWebHookNotifier(model, encryptionService.GetDecryptedValue, nil) - require.NoError(t, err) - webhookNotifier := not.(*WebhookNotifier) - - assert.Equal(t, "ops", webhookNotifier.Name) - assert.Equal(t, "webhook", webhookNotifier.Type) - assert.Equal(t, "http://google.com", webhookNotifier.URL) - }) -} diff --git a/pkg/services/alerting/reader.go b/pkg/services/alerting/reader.go deleted file mode 100644 index eaf84b15b94ce..0000000000000 --- a/pkg/services/alerting/reader.go +++ /dev/null @@ -1,51 +0,0 @@ -package alerting - -import ( - "context" - "sync" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/services/alerting/models" -) - -type ruleReader interface { - fetch(context.Context) []*Rule -} - -type defaultRuleReader struct { - sync.RWMutex - sqlStore AlertStore - log log.Logger -} - -func newRuleReader(sqlStore AlertStore) *defaultRuleReader { - ruleReader := &defaultRuleReader{ - sqlStore: sqlStore, - log: log.New("alerting.ruleReader"), - } - - return ruleReader -} - -func (arr *defaultRuleReader) fetch(ctx context.Context) []*Rule { - cmd := &models.GetAllAlertsQuery{} - - alerts, err := arr.sqlStore.GetAllAlertQueryHandler(ctx, cmd) - if err != nil { - arr.log.Error("Could not load alerts", "error", err) - return []*Rule{} - } - - res := make([]*Rule, 0) - for _, ruleDef := range alerts { - if model, err := NewRuleFromDBAlert(ctx, arr.sqlStore, ruleDef, false); err != nil { - arr.log.Error("Could not build alert model for rule", "ruleId", ruleDef.ID, "error", err) - } else { - res = append(res, model) - } - } - - metrics.MAlertingActiveAlerts.Set(float64(len(res))) - return res -} diff --git a/pkg/services/alerting/result_handler.go b/pkg/services/alerting/result_handler.go deleted file mode 100644 index c6c3d2c507f4a..0000000000000 --- a/pkg/services/alerting/result_handler.go +++ /dev/null @@ -1,115 +0,0 @@ -package alerting - -import ( - "context" - "errors" - "time" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/annotations" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/services/rendering" -) - -type resultHandler interface { - handle(evalContext *EvalContext) error -} - -type defaultResultHandler struct { - notifier *notificationService - sqlStore AlertStore - log log.Logger -} - -func newResultHandler(renderService rendering.Service, sqlStore AlertStore, notificationService *notifications.NotificationService, decryptFn GetDecryptedValueFn) *defaultResultHandler { - return &defaultResultHandler{ - log: log.New("alerting.resultHandler"), - sqlStore: sqlStore, - notifier: newNotificationService(renderService, sqlStore, notificationService, decryptFn), - } -} - -func (handler *defaultResultHandler) handle(evalContext *EvalContext) error { - executionError := "" - annotationData := simplejson.New() - - if len(evalContext.EvalMatches) > 0 { - annotationData.Set("evalMatches", simplejson.NewFromAny(evalContext.EvalMatches)) - } - - if evalContext.Error != nil { - executionError = evalContext.Error.Error() - annotationData.Set("error", executionError) - } else if evalContext.NoDataFound { - annotationData.Set("noData", true) - } - - metrics.MAlertingResultState.WithLabelValues(string(evalContext.Rule.State)).Inc() - if evalContext.shouldUpdateAlertState() { - handler.log.Info("New state change", "ruleId", evalContext.Rule.ID, "newState", evalContext.Rule.State, "prev state", evalContext.PrevAlertState) - - cmd := &models.SetAlertStateCommand{ - AlertID: evalContext.Rule.ID, - OrgID: evalContext.Rule.OrgID, - State: evalContext.Rule.State, - Error: executionError, - EvalData: annotationData, - } - - alert, err := handler.sqlStore.SetAlertState(evalContext.Ctx, cmd) - if err != nil { - if errors.Is(err, models.ErrCannotChangeStateOnPausedAlert) { - handler.log.Error("Cannot change state on alert that's paused", "error", err) - return err - } - - if errors.Is(err, models.ErrRequiresNewState) { - handler.log.Info("Alert already updated") - return nil - } - - handler.log.Error("Failed to save state", "error", err) - } else { - // StateChanges is used for de duping alert notifications - // when two servers are raising. This makes sure that the server - // with the last state change always sends a notification. - evalContext.Rule.StateChanges = alert.StateChanges - - // Update the last state change of the alert rule in memory - evalContext.Rule.LastStateChange = time.Now() - } - - // save annotation - item := annotations.Item{ - OrgID: evalContext.Rule.OrgID, - DashboardID: evalContext.Rule.DashboardID, - PanelID: evalContext.Rule.PanelID, - AlertID: evalContext.Rule.ID, - Text: "", - NewState: string(evalContext.Rule.State), - PrevState: string(evalContext.PrevAlertState), - Epoch: time.Now().UnixNano() / int64(time.Millisecond), - Data: annotationData, - } - - if err := evalContext.annotationRepo.Save(evalContext.Ctx, &item); err != nil { - handler.log.Error("Failed to save annotation for new alert state", "error", err) - } - } - - if err := handler.notifier.SendIfNeeded(evalContext); err != nil { - switch { - case errors.Is(err, context.Canceled): - handler.log.Debug("Handler.notifier.SendIfNeeded returned context.Canceled") - case errors.Is(err, context.DeadlineExceeded): - handler.log.Debug("Handler.notifier.SendIfNeeded returned context.DeadlineExceeded") - default: - handler.log.Error("Handler.notifier.SendIfNeeded failed", "err", err) - } - } - - return nil -} diff --git a/pkg/services/alerting/rule.go b/pkg/services/alerting/rule.go deleted file mode 100644 index 301274c560130..0000000000000 --- a/pkg/services/alerting/rule.go +++ /dev/null @@ -1,245 +0,0 @@ -package alerting - -import ( - "context" - "errors" - "fmt" - "reflect" - "regexp" - "strconv" - "time" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/tag" -) - -var unitMultiplier = map[string]int{ - "s": 1, - "m": 60, - "h": 3600, - "d": 86400, -} - -var ( - valueFormatRegex = regexp.MustCompile(`^\d+`) - isDigitRegex = regexp.MustCompile(`^[0-9]+$`) - unitFormatRegex = regexp.MustCompile(`[a-z]+`) -) - -var ( - // ErrFrequencyCannotBeZeroOrLess frequency cannot be below zero - ErrFrequencyCannotBeZeroOrLess = errors.New(`"evaluate every" cannot be zero or below`) - - // ErrFrequencyCouldNotBeParsed frequency cannot be parsed - ErrFrequencyCouldNotBeParsed = errors.New(`"evaluate every" field could not be parsed`) - - // ErrWrongUnitFormat wrong unit format - ErrWrongUnitFormat = fmt.Errorf(`time unit not supported. supported units: %s`, reflect.ValueOf(unitMultiplier).MapKeys()) -) - -// Rule is the in-memory version of an alert rule. -type Rule struct { - ID int64 - OrgID int64 - DashboardID int64 - PanelID int64 - Frequency int64 - Name string - Message string - LastStateChange time.Time - For time.Duration - NoDataState models.NoDataOption - ExecutionErrorState models.ExecutionErrorOption - State models.AlertStateType - Conditions []Condition - Notifications []string - AlertRuleTags []*tag.Tag - - StateChanges int64 -} - -// ValidationError is a typed error with meta data -// about the validation error. -type ValidationError struct { - Reason string - Err error - AlertID int64 - DashboardID int64 - PanelID int64 -} - -func (e ValidationError) Error() string { - extraInfo := e.Reason - if e.AlertID != 0 { - extraInfo = fmt.Sprintf("%s AlertId: %v", extraInfo, e.AlertID) - } - - if e.PanelID != 0 { - extraInfo = fmt.Sprintf("%s PanelId: %v", extraInfo, e.PanelID) - } - - if e.DashboardID != 0 { - extraInfo = fmt.Sprintf("%s DashboardId: %v", extraInfo, e.DashboardID) - } - - if e.Err != nil { - return fmt.Sprintf("alert validation error: %s%s", e.Err.Error(), extraInfo) - } - - return fmt.Sprintf("alert validation error: %s", extraInfo) -} - -func getTimeDurationStringToSeconds(str string) (int64, error) { - // Check if frequency lacks unit - if isDigitRegex.MatchString(str) || str == "" { - return 0, ErrFrequencyCouldNotBeParsed - } - - unit := unitFormatRegex.FindAllString(str, 1)[0] - if _, ok := unitMultiplier[unit]; !ok { - return 0, ErrWrongUnitFormat - } - - multiplier := unitMultiplier[unit] - - matches := valueFormatRegex.FindAllString(str, 1) - - if len(matches) == 0 { - return 0, ErrFrequencyCouldNotBeParsed - } - - value, err := strconv.Atoi(matches[0]) - if err != nil { - return 0, err - } - - if value == 0 { - return 0, ErrFrequencyCannotBeZeroOrLess - } - - return int64(value * multiplier), nil -} - -func getForValue(rawFor string) (time.Duration, error) { - var forValue time.Duration - var err error - - if rawFor != "" { - if rawFor != "0" { - strings := unitFormatRegex.FindAllString(rawFor, 1) - if strings == nil { - return 0, ValidationError{Reason: fmt.Sprintf("no specified unit, error: %s", ErrWrongUnitFormat.Error())} - } - if _, ok := unitMultiplier[strings[0]]; !ok { - return 0, ValidationError{Reason: fmt.Sprintf("could not parse for field, error: %s", ErrWrongUnitFormat.Error())} - } - } - forValue, err = time.ParseDuration(rawFor) - if err != nil { - return 0, ValidationError{Reason: "Could not parse for field"} - } - } - return forValue, nil -} - -// NewRuleFromDBAlert maps a db version of -// alert to an in-memory version. -func NewRuleFromDBAlert(ctx context.Context, store AlertStore, ruleDef *models.Alert, logTranslationFailures bool) (*Rule, error) { - model := &Rule{} - model.ID = ruleDef.ID - model.OrgID = ruleDef.OrgID - model.DashboardID = ruleDef.DashboardID - model.PanelID = ruleDef.PanelID - model.Name = ruleDef.Name - model.Message = ruleDef.Message - model.State = ruleDef.State - model.LastStateChange = ruleDef.NewStateDate - model.For = ruleDef.For - model.NoDataState = models.NoDataOption(ruleDef.Settings.Get("noDataState").MustString("no_data")) - model.ExecutionErrorState = models.ExecutionErrorOption(ruleDef.Settings.Get("executionErrorState").MustString("alerting")) - model.StateChanges = ruleDef.StateChanges - - model.Frequency = ruleDef.Frequency - // frequency cannot be zero since that would not execute the alert rule. - // so we fallback to 60 seconds if `Frequency` is missing - if model.Frequency == 0 { - model.Frequency = 60 - } - - for _, v := range ruleDef.Settings.Get("notifications").MustArray() { - jsonModel := simplejson.NewFromAny(v) - if id, err := jsonModel.Get("id").Int64(); err == nil { - uid, err := translateNotificationIDToUID(ctx, store, id, ruleDef.OrgID) - if err != nil { - if !errors.Is(err, models.ErrAlertNotificationFailedTranslateUniqueID) { - logger.Error("Failed to translate notification id to uid", "error", err.Error(), "dashboardId", model.DashboardID, "alert", model.Name, "panelId", model.PanelID, "notificationId", id) - } - - if logTranslationFailures { - logger.Warn("Unable to translate notification id to uid", "dashboardId", model.DashboardID, "alert", model.Name, "panelId", model.PanelID, "notificationId", id) - } - } else { - model.Notifications = append(model.Notifications, uid) - } - } else if uid, err := jsonModel.Get("uid").String(); err == nil { - model.Notifications = append(model.Notifications, uid) - } else { - return nil, ValidationError{Reason: "Neither id nor uid is specified in 'notifications' block, " + err.Error(), DashboardID: model.DashboardID, AlertID: model.ID, PanelID: model.PanelID} - } - } - model.AlertRuleTags = ruleDef.GetTagsFromSettings() - - for index, condition := range ruleDef.Settings.Get("conditions").MustArray() { - conditionModel := simplejson.NewFromAny(condition) - conditionType := conditionModel.Get("type").MustString() - factory, exist := conditionFactories[conditionType] - if !exist { - return nil, ValidationError{Reason: "Unknown alert condition: " + conditionType, DashboardID: model.DashboardID, AlertID: model.ID, PanelID: model.PanelID} - } - queryCondition, err := factory(conditionModel, index) - if err != nil { - return nil, ValidationError{Err: err, DashboardID: model.DashboardID, AlertID: model.ID, PanelID: model.PanelID} - } - model.Conditions = append(model.Conditions, queryCondition) - } - - if len(model.Conditions) == 0 { - return nil, ValidationError{Reason: "Alert is missing conditions"} - } - - return model, nil -} - -func translateNotificationIDToUID(ctx context.Context, store AlertStore, id int64, orgID int64) (string, error) { - notificationUID, err := getAlertNotificationUIDByIDAndOrgID(ctx, store, id, orgID) - if err != nil { - return "", err - } - - return notificationUID, nil -} - -func getAlertNotificationUIDByIDAndOrgID(ctx context.Context, store AlertStore, notificationID int64, orgID int64) (string, error) { - query := &models.GetAlertNotificationUidQuery{ - OrgID: orgID, - ID: notificationID, - } - - uid, err := store.GetAlertNotificationUidWithId(ctx, query) - if err != nil { - return "", err - } - - return uid, nil -} - -// ConditionFactory is the function signature for creating `Conditions`. -type ConditionFactory func(model *simplejson.Json, index int) (Condition, error) - -var conditionFactories = make(map[string]ConditionFactory) - -// RegisterCondition adds support for alerting conditions. -func RegisterCondition(typeName string, factory ConditionFactory) { - conditionFactories[typeName] = factory -} diff --git a/pkg/services/alerting/rule_test.go b/pkg/services/alerting/rule_test.go deleted file mode 100644 index 5f4987c7b3bac..0000000000000 --- a/pkg/services/alerting/rule_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package alerting - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/tsdb/legacydata" -) - -type FakeCondition struct{} - -func (f *FakeCondition) Eval(context *EvalContext, reqHandler legacydata.RequestHandler) (*ConditionResult, error) { - return &ConditionResult{}, nil -} - -func TestAlertRuleFrequencyParsing(t *testing.T) { - tcs := []struct { - input string - err error - result int64 - }{ - {input: "10s", result: 10}, - {input: "10m", result: 600}, - {input: "1h", result: 3600}, - {input: "1d", result: 86400}, - {input: "1o", err: ErrWrongUnitFormat}, - {input: "0s", err: ErrFrequencyCannotBeZeroOrLess}, - {input: "0m", err: ErrFrequencyCannotBeZeroOrLess}, - {input: "0h", err: ErrFrequencyCannotBeZeroOrLess}, - {input: "0", err: ErrFrequencyCouldNotBeParsed}, - {input: "", err: ErrFrequencyCouldNotBeParsed}, - {input: "-1s", err: ErrFrequencyCouldNotBeParsed}, - } - - for _, tc := range tcs { - t.Run(tc.input, func(t *testing.T) { - r, err := getTimeDurationStringToSeconds(tc.input) - if tc.err == nil { - require.NoError(t, err) - } else { - require.EqualError(t, err, tc.err.Error()) - } - assert.Equal(t, tc.result, r) - }) - } -} - -func TestAlertRuleForParsing(t *testing.T) { - tcs := []struct { - input string - err error - result time.Duration - }{ - {input: "10s", result: time.Duration(10000000000)}, - {input: "10m", result: time.Duration(600000000000)}, - {input: "1h", result: time.Duration(3600000000000)}, - {input: "1o", err: fmt.Errorf("alert validation error: could not parse for field, error: %s", ErrWrongUnitFormat)}, - {input: "1", err: fmt.Errorf("alert validation error: no specified unit, error: %s", ErrWrongUnitFormat)}, - {input: "0s", result: time.Duration(0)}, - {input: "0m", result: time.Duration(0)}, - {input: "0h", result: time.Duration(0)}, - {input: "0", result: time.Duration(0)}, - {input: "", result: time.Duration(0)}, - } - - for _, tc := range tcs { - t.Run(tc.input, func(t *testing.T) { - r, err := getForValue(tc.input) - if tc.err == nil { - require.NoError(t, err) - } else { - require.EqualError(t, err, tc.err.Error()) - } - assert.Equal(t, tc.result, r) - }) - } -} - -func TestAlertRuleModel(t *testing.T) { - sqlStore := &sqlStore{db: db.InitTestDB(t), cache: localcache.New(time.Minute, time.Minute)} - RegisterCondition("test", func(model *simplejson.Json, index int) (Condition, error) { - return &FakeCondition{}, nil - }) - - firstNotification := models.CreateAlertNotificationCommand{UID: "notifier1", OrgID: 1, Name: "1"} - _, err := sqlStore.CreateAlertNotificationCommand(context.Background(), &firstNotification) - require.Nil(t, err) - secondNotification := models.CreateAlertNotificationCommand{UID: "notifier2", OrgID: 1, Name: "2"} - _, err = sqlStore.CreateAlertNotificationCommand(context.Background(), &secondNotification) - require.Nil(t, err) - - t.Run("Testing alert rule with notification id and uid", func(t *testing.T) { - json := ` - { - "name": "name2", - "description": "desc2", - "handler": 0, - "noDataMode": "critical", - "enabled": true, - "frequency": "60s", - "conditions": [ - { - "type": "test", - "prop": 123 - } - ], - "notifications": [ - {"id": 1}, - {"uid": "notifier2"} - ] - } - ` - - alertJSON, jsonErr := simplejson.NewJson([]byte(json)) - require.Nil(t, jsonErr) - - alert := &models.Alert{ - ID: 1, - OrgID: 1, - DashboardID: 1, - PanelID: 1, - - Settings: alertJSON, - } - - alertRule, err := NewRuleFromDBAlert(context.Background(), sqlStore, alert, false) - require.Nil(t, err) - - require.Len(t, alertRule.Conditions, 1) - require.Len(t, alertRule.Notifications, 2) - - require.Contains(t, alertRule.Notifications, "notifier2") - require.Contains(t, alertRule.Notifications, "notifier1") - }) - - t.Run("Testing alert rule with non existing notification id", func(t *testing.T) { - json := ` - { - "name": "name3", - "description": "desc3", - "handler": 0, - "noDataMode": "critical", - "enabled": true, - "frequency": "60s", - "conditions": [{"type": "test", "prop": 123 }], - "notifications": [ - {"id": 999}, - {"uid": "notifier2"} - ] - } - ` - - alertJSON, jsonErr := simplejson.NewJson([]byte(json)) - require.Nil(t, jsonErr) - - alert := &models.Alert{ - ID: 1, - OrgID: 1, - DashboardID: 1, - PanelID: 1, - - Settings: alertJSON, - } - - alertRule, err := NewRuleFromDBAlert(context.Background(), sqlStore, alert, false) - require.Nil(t, err) - require.NotContains(t, alertRule.Notifications, "999") - require.Contains(t, alertRule.Notifications, "notifier2") - }) - - t.Run("Testing alert rule which can construct alert rule model with invalid frequency", func(t *testing.T) { - json := ` - { - "name": "name2", - "description": "desc2", - "noDataMode": "critical", - "enabled": true, - "frequency": "0s", - "conditions": [ { "type": "test", "prop": 123 } ], - "notifications": [] - }` - - alertJSON, jsonErr := simplejson.NewJson([]byte(json)) - require.Nil(t, jsonErr) - - alert := &models.Alert{ - ID: 1, - OrgID: 1, - DashboardID: 1, - PanelID: 1, - Frequency: 0, - - Settings: alertJSON, - } - - alertRule, err := NewRuleFromDBAlert(context.Background(), sqlStore, alert, false) - require.Nil(t, err) - require.EqualValues(t, alertRule.Frequency, 60) - }) - - t.Run("Testing alert rule which will raise error in case of missing notification id and uid", func(t *testing.T) { - json := ` - { - "name": "name2", - "description": "desc2", - "noDataMode": "critical", - "enabled": true, - "frequency": "60s", - "conditions": [ - { - "type": "test", - "prop": 123 - } - ], - "notifications": [ - {"not_id_uid": "1134"} - ] - } - ` - - alertJSON, jsonErr := simplejson.NewJson([]byte(json)) - require.Nil(t, jsonErr) - - alert := &models.Alert{ - ID: 1, - OrgID: 1, - DashboardID: 1, - PanelID: 1, - Frequency: 0, - - Settings: alertJSON, - } - - _, err := NewRuleFromDBAlert(context.Background(), sqlStore, alert, false) - require.NotNil(t, err) - require.EqualValues(t, err.Error(), "alert validation error: Neither id nor uid is specified in 'notifications' block, type assertion to string failed AlertId: 1 PanelId: 1 DashboardId: 1") - }) -} diff --git a/pkg/services/alerting/scheduler.go b/pkg/services/alerting/scheduler.go deleted file mode 100644 index 4e9286c04bd7c..0000000000000 --- a/pkg/services/alerting/scheduler.go +++ /dev/null @@ -1,84 +0,0 @@ -package alerting - -import ( - "math" - "time" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/setting" -) - -type schedulerImpl struct { - jobs map[int64]*Job - log log.Logger -} - -func newScheduler() scheduler { - return &schedulerImpl{ - jobs: make(map[int64]*Job), - log: log.New("alerting.scheduler"), - } -} - -func (s *schedulerImpl) Update(rules []*Rule) { - s.log.Debug("Scheduling update", "ruleCount", len(rules)) - - jobs := make(map[int64]*Job) - - for i, rule := range rules { - var job *Job - if s.jobs[rule.ID] != nil { - job = s.jobs[rule.ID] - } else { - job = &Job{} - job.SetRunning(false) - } - - job.Rule = rule - - offset := ((rule.Frequency * 1000) / int64(len(rules))) * int64(i) - job.Offset = int64(math.Floor(float64(offset) / 1000)) - if job.Offset == 0 { // zero offset causes division with 0 panics. - job.Offset = 1 - } - jobs[rule.ID] = job - } - - s.jobs = jobs -} - -func (s *schedulerImpl) Tick(tickTime time.Time, execQueue chan *Job) { - now := tickTime.Unix() - - for _, job := range s.jobs { - if job.GetRunning() || job.Rule.State == models.AlertStatePaused { - continue - } - - if job.OffsetWait && now%job.Offset == 0 { - job.OffsetWait = false - s.enqueue(job, execQueue) - continue - } - - // Check the job frequency against the minimum interval required - interval := job.Rule.Frequency - if interval < setting.AlertingMinInterval { - interval = setting.AlertingMinInterval - } - - if now%interval == 0 { - if job.Offset > 0 { - job.OffsetWait = true - } else { - s.enqueue(job, execQueue) - } - } - } -} - -func (s *schedulerImpl) enqueue(job *Job, execQueue chan *Job) { - s.log.Debug("Scheduler: Putting job on to exec queue", "name", job.Rule.Name, "id", job.Rule.ID) - execQueue <- job -} diff --git a/pkg/services/alerting/service.go b/pkg/services/alerting/service.go deleted file mode 100644 index df8730820e1a4..0000000000000 --- a/pkg/services/alerting/service.go +++ /dev/null @@ -1,172 +0,0 @@ -package alerting - -import ( - "context" - "fmt" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/encryption" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" -) - -type AlertNotificationService struct { - SQLStore AlertNotificationStore - EncryptionService encryption.Internal - NotificationService *notifications.NotificationService -} - -func ProvideService(store db.DB, encryptionService encryption.Internal, - notificationService *notifications.NotificationService) *AlertNotificationService { - s := &AlertNotificationService{ - SQLStore: &sqlStore{db: store}, - EncryptionService: encryptionService, - NotificationService: notificationService, - } - - return s -} - -func (s *AlertNotificationService) GetAlertNotifications(ctx context.Context, query *models.GetAlertNotificationsQuery) (res *models.AlertNotification, err error) { - return s.SQLStore.GetAlertNotifications(ctx, query) -} - -func (s *AlertNotificationService) CreateAlertNotificationCommand(ctx context.Context, cmd *models.CreateAlertNotificationCommand) (res *models.AlertNotification, err error) { - if util.IsShortUIDTooLong(cmd.UID) { - return nil, ValidationError{Reason: "Invalid UID: Must be 40 characters or less"} - } - - cmd.EncryptedSecureSettings, err = s.EncryptionService.EncryptJsonData(ctx, cmd.SecureSettings, setting.SecretKey) - if err != nil { - return nil, err - } - - model := models.AlertNotification{ - Name: cmd.Name, - Type: cmd.Type, - Settings: cmd.Settings, - } - - if err := s.validateAlertNotification(ctx, &model, cmd.SecureSettings); err != nil { - return nil, err - } - - return s.SQLStore.CreateAlertNotificationCommand(ctx, cmd) -} - -func (s *AlertNotificationService) UpdateAlertNotification(ctx context.Context, cmd *models.UpdateAlertNotificationCommand) (res *models.AlertNotification, err error) { - if util.IsShortUIDTooLong(cmd.UID) { - return nil, ValidationError{Reason: "Invalid UID: Must be 40 characters or less"} - } - - cmd.EncryptedSecureSettings, err = s.EncryptionService.EncryptJsonData(ctx, cmd.SecureSettings, setting.SecretKey) - if err != nil { - return nil, err - } - - model := models.AlertNotification{ - ID: cmd.ID, - OrgID: cmd.OrgID, - Name: cmd.Name, - Type: cmd.Type, - Settings: cmd.Settings, - } - - if err := s.validateAlertNotification(ctx, &model, cmd.SecureSettings); err != nil { - return nil, err - } - - return s.SQLStore.UpdateAlertNotification(ctx, cmd) -} - -func (s *AlertNotificationService) DeleteAlertNotification(ctx context.Context, cmd *models.DeleteAlertNotificationCommand) error { - return s.SQLStore.DeleteAlertNotification(ctx, cmd) -} - -func (s *AlertNotificationService) GetAllAlertNotifications(ctx context.Context, query *models.GetAllAlertNotificationsQuery) (res []*models.AlertNotification, err error) { - return s.SQLStore.GetAllAlertNotifications(ctx, query) -} - -func (s *AlertNotificationService) GetOrCreateAlertNotificationState(ctx context.Context, cmd *models.GetOrCreateNotificationStateQuery) (res *models.AlertNotificationState, err error) { - return s.SQLStore.GetOrCreateAlertNotificationState(ctx, cmd) -} - -func (s *AlertNotificationService) SetAlertNotificationStateToCompleteCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToCompleteCommand) error { - return s.SQLStore.SetAlertNotificationStateToCompleteCommand(ctx, cmd) -} - -func (s *AlertNotificationService) SetAlertNotificationStateToPendingCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToPendingCommand) error { - return s.SQLStore.SetAlertNotificationStateToPendingCommand(ctx, cmd) -} - -func (s *AlertNotificationService) GetAlertNotificationsWithUid(ctx context.Context, query *models.GetAlertNotificationsWithUidQuery) (res *models.AlertNotification, err error) { - return s.SQLStore.GetAlertNotificationsWithUid(ctx, query) -} - -func (s *AlertNotificationService) UpdateAlertNotificationWithUid(ctx context.Context, cmd *models.UpdateAlertNotificationWithUidCommand) (res *models.AlertNotification, err error) { - if util.IsShortUIDTooLong(cmd.UID) || util.IsShortUIDTooLong(cmd.NewUID) { - return nil, ValidationError{Reason: "Invalid UID: Must be 40 characters or less"} - } - - return s.SQLStore.UpdateAlertNotificationWithUid(ctx, cmd) -} - -func (s *AlertNotificationService) DeleteAlertNotificationWithUid(ctx context.Context, cmd *models.DeleteAlertNotificationWithUidCommand) error { - return s.SQLStore.DeleteAlertNotificationWithUid(ctx, cmd) -} - -func (s *AlertNotificationService) GetAlertNotificationsWithUidToSend(ctx context.Context, query *models.GetAlertNotificationsWithUidToSendQuery) (res []*models.AlertNotification, err error) { - return s.SQLStore.GetAlertNotificationsWithUidToSend(ctx, query) -} - -func (s *AlertNotificationService) createNotifier(ctx context.Context, model *models.AlertNotification, secureSettings map[string]string) (Notifier, error) { - secureSettingsMap := map[string]string{} - - if model.ID > 0 { - query := &models.GetAlertNotificationsQuery{ - OrgID: model.OrgID, - ID: model.ID, - } - res, err := s.SQLStore.GetAlertNotifications(ctx, query) - if err != nil { - return nil, err - } - - if res == nil { - return nil, fmt.Errorf("unable to find the alert notification") - } - - if res.SecureSettings != nil { - var err error - secureSettingsMap, err = s.EncryptionService.DecryptJsonData(ctx, res.SecureSettings, setting.SecretKey) - if err != nil { - return nil, err - } - } - } - - for k, v := range secureSettings { - secureSettingsMap[k] = v - } - - var err error - model.SecureSettings, err = s.EncryptionService.EncryptJsonData(ctx, secureSettingsMap, setting.SecretKey) - if err != nil { - return nil, err - } - - notifier, err := InitNotifier(model, s.EncryptionService.GetDecryptedValue, s.NotificationService) - if err != nil { - logger.Error("Failed to create notifier", "error", err.Error()) - return nil, err - } - - return notifier, nil -} - -func (s *AlertNotificationService) validateAlertNotification(ctx context.Context, model *models.AlertNotification, secureSettings map[string]string) error { - _, err := s.createNotifier(ctx, model, secureSettings) - return err -} diff --git a/pkg/services/alerting/service_test.go b/pkg/services/alerting/service_test.go deleted file mode 100644 index 0cea01be6d391..0000000000000 --- a/pkg/services/alerting/service_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package alerting - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/usagestats" - "github.com/grafana/grafana/pkg/services/alerting/models" - encryptionprovider "github.com/grafana/grafana/pkg/services/encryption/provider" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/setting" -) - -func TestService(t *testing.T) { - sqlStore := &sqlStore{ - db: db.InitTestDB(t), - log: &log.ConcreteLogger{}, - cache: localcache.New(time.Minute, time.Minute), - } - - nType := "test" - registerTestNotifier(nType) - - usMock := &usagestats.UsageStatsMock{T: t} - - encProvider := encryptionprovider.ProvideEncryptionProvider() - encService, err := encryptionservice.ProvideEncryptionService(encProvider, usMock, setting.NewCfg()) - require.NoError(t, err) - - s := ProvideService(sqlStore.db, encService, nil) - - origSecret := setting.SecretKey - setting.SecretKey = "alert_notification_service_test" - - t.Cleanup(func() { - setting.SecretKey = origSecret - }) - - t.Run("create alert notification should reject an invalid command", func(t *testing.T) { - ctx := context.Background() - - ss := map[string]string{"password": "12345"} - cmd := models.CreateAlertNotificationCommand{SecureSettings: ss} - - _, err := s.CreateAlertNotificationCommand(ctx, &cmd) - require.Error(t, err) - }) - - t.Run("create alert notification should encrypt the secure json data", func(t *testing.T) { - ctx := context.Background() - - ss := map[string]string{"password": "12345"} - cmd := models.CreateAlertNotificationCommand{SecureSettings: ss, Type: nType} - - an, err := s.CreateAlertNotificationCommand(ctx, &cmd) - require.NoError(t, err) - - decrypted, err := s.EncryptionService.DecryptJsonData(ctx, an.SecureSettings, setting.SecretKey) - require.NoError(t, err) - require.Equal(t, ss, decrypted) - - // Delete the created alert notification - delCmd := models.DeleteAlertNotificationCommand{ - ID: an.ID, - OrgID: an.OrgID, - } - err = s.DeleteAlertNotification(context.Background(), &delCmd) - require.NoError(t, err) - }) - - t.Run("update alert notification should reject an invalid command", func(t *testing.T) { - ctx := context.Background() - - // Save test notification. - ss := map[string]string{"password": "12345"} - createCmd := models.CreateAlertNotificationCommand{SecureSettings: ss, Type: nType} - - n, err := s.CreateAlertNotificationCommand(ctx, &createCmd) - require.NoError(t, err) - - // Try to update it with an invalid type. - updateCmd := models.UpdateAlertNotificationCommand{ID: n.ID, Settings: simplejson.New(), SecureSettings: ss, Type: "invalid"} - _, err = s.UpdateAlertNotification(ctx, &updateCmd) - require.Error(t, err) - - // Delete the created alert notification. - delCmd := models.DeleteAlertNotificationCommand{ - ID: n.ID, - OrgID: n.OrgID, - } - err = s.DeleteAlertNotification(context.Background(), &delCmd) - require.NoError(t, err) - }) - - t.Run("update alert notification should encrypt the secure json data", func(t *testing.T) { - ctx := context.Background() - - // Save test notification. - ss := map[string]string{"password": "12345"} - createCmd := models.CreateAlertNotificationCommand{SecureSettings: ss, Type: nType} - - n, err := s.CreateAlertNotificationCommand(ctx, &createCmd) - require.NoError(t, err) - - // Update test notification. - updateCmd := models.UpdateAlertNotificationCommand{ID: n.ID, Settings: simplejson.New(), SecureSettings: ss, Type: nType} - n2, err := s.UpdateAlertNotification(ctx, &updateCmd) - require.NoError(t, err) - - decrypted, err := s.EncryptionService.DecryptJsonData(ctx, n2.SecureSettings, setting.SecretKey) - require.NoError(t, err) - require.Equal(t, ss, decrypted) - - // Delete the created alert notification. - delCmd := models.DeleteAlertNotificationCommand{ - ID: n.ID, - OrgID: n.OrgID, - } - err = s.DeleteAlertNotification(context.Background(), &delCmd) - require.NoError(t, err) - }) - - t.Run("create alert notification should reject an invalid command", func(t *testing.T) { - uid := strings.Repeat("A", 41) - - _, err := s.CreateAlertNotificationCommand(context.Background(), &models.CreateAlertNotificationCommand{UID: uid}) - require.ErrorIs(t, err, ValidationError{Reason: "Invalid UID: Must be 40 characters or less"}) - }) - - t.Run("update alert notification should reject an invalid command", func(t *testing.T) { - ctx := context.Background() - - uid := strings.Repeat("A", 41) - expectedErr := ValidationError{Reason: "Invalid UID: Must be 40 characters or less"} - - _, err := s.UpdateAlertNotification(ctx, &models.UpdateAlertNotificationCommand{UID: uid}) - require.ErrorIs(t, err, expectedErr) - - _, err = s.UpdateAlertNotificationWithUid(ctx, &models.UpdateAlertNotificationWithUidCommand{NewUID: uid}) - require.ErrorIs(t, err, expectedErr) - }) -} - -func registerTestNotifier(notifierType string) { - RegisterNotifier(&NotifierPlugin{ - Type: notifierType, - Factory: func(*models.AlertNotification, GetDecryptedValueFn, notifications.Service) (Notifier, error) { - return nil, nil - }, - }) -} diff --git a/pkg/services/alerting/store.go b/pkg/services/alerting/store.go deleted file mode 100644 index 762c110fbc0cb..0000000000000 --- a/pkg/services/alerting/store.go +++ /dev/null @@ -1,415 +0,0 @@ -package alerting - -import ( - "bytes" - "context" - "fmt" - "strings" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/infra/log" - alertmodels "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/tag" - "github.com/grafana/grafana/pkg/setting" -) - -// AlertStore is a subset of SQLStore API to satisfy the needs of the alerting service. -// A subset is needed to make it easier to mock during the tests. -type AlertStore interface { - GetAlertById(context.Context, *alertmodels.GetAlertByIdQuery) (*alertmodels.Alert, error) - GetAllAlertQueryHandler(context.Context, *alertmodels.GetAllAlertsQuery) ([]*alertmodels.Alert, error) - GetAlertStatesForDashboard(context.Context, *alertmodels.GetAlertStatesForDashboardQuery) ([]*alertmodels.AlertStateInfoDTO, error) - HandleAlertsQuery(context.Context, *alertmodels.GetAlertsQuery) ([]*alertmodels.AlertListItemDTO, error) - SetAlertNotificationStateToCompleteCommand(context.Context, *alertmodels.SetAlertNotificationStateToCompleteCommand) error - SetAlertNotificationStateToPendingCommand(context.Context, *alertmodels.SetAlertNotificationStateToPendingCommand) error - GetAlertNotificationUidWithId(context.Context, *alertmodels.GetAlertNotificationUidQuery) (string, error) - GetAlertNotificationsWithUidToSend(context.Context, *alertmodels.GetAlertNotificationsWithUidToSendQuery) ([]*alertmodels.AlertNotification, error) - GetOrCreateAlertNotificationState(context.Context, *alertmodels.GetOrCreateNotificationStateQuery) (*alertmodels.AlertNotificationState, error) - SetAlertState(context.Context, *alertmodels.SetAlertStateCommand) (alertmodels.Alert, error) - PauseAlert(context.Context, *alertmodels.PauseAlertCommand) error - PauseAllAlerts(context.Context, *alertmodels.PauseAllAlertCommand) error -} - -type sqlStore struct { - db db.DB - cache *localcache.CacheService - log *log.ConcreteLogger - cfg *setting.Cfg - tagService tag.Service - features featuremgmt.FeatureToggles -} - -func ProvideAlertStore( - db db.DB, - cacheService *localcache.CacheService, cfg *setting.Cfg, tagService tag.Service, features featuremgmt.FeatureToggles) AlertStore { - return &sqlStore{ - db: db, - cache: cacheService, - log: log.New("alerting.store"), - cfg: cfg, - tagService: tagService, - features: features, - } -} - -func (ss *sqlStore) GetAlertById(ctx context.Context, query *alertmodels.GetAlertByIdQuery) (res *alertmodels.Alert, err error) { - err = ss.db.WithDbSession(ctx, func(sess *db.Session) error { - alert := alertmodels.Alert{} - has, err := sess.ID(query.ID).Get(&alert) - if !has { - return fmt.Errorf("could not find alert") - } - if err != nil { - return err - } - - res = &alert - return nil - }) - return res, err -} - -func (ss *sqlStore) GetAllAlertQueryHandler(ctx context.Context, query *alertmodels.GetAllAlertsQuery) (res []*alertmodels.Alert, err error) { - err = ss.db.WithDbSession(ctx, func(sess *db.Session) error { - var alerts []*alertmodels.Alert - err := sess.SQL("select * from alert").Find(&alerts) - if err != nil { - return err - } - - res = alerts - return nil - }) - return res, err -} - -func deleteAlertByIdInternal(alertId int64, reason string, sess *db.Session, log *log.ConcreteLogger) error { - log.Debug("Deleting alert", "id", alertId, "reason", reason) - - if _, err := sess.Exec("DELETE FROM alert WHERE id = ?", alertId); err != nil { - return err - } - - if _, err := sess.Exec("DELETE FROM annotation WHERE alert_id = ?", alertId); err != nil { - return err - } - - if _, err := sess.Exec("DELETE FROM alert_notification_state WHERE alert_id = ?", alertId); err != nil { - return err - } - - if _, err := sess.Exec("DELETE FROM alert_rule_tag WHERE alert_id = ?", alertId); err != nil { - return err - } - - return nil -} - -func (ss *sqlStore) HandleAlertsQuery(ctx context.Context, query *alertmodels.GetAlertsQuery) (res []*alertmodels.AlertListItemDTO, err error) { - recursiveQueriesAreSupported, err := ss.db.RecursiveQueriesAreSupported() - if err != nil { - return res, err - } - - err = ss.db.WithDbSession(ctx, func(sess *db.Session) error { - builder := db.NewSqlBuilder(ss.cfg, ss.features, ss.db.GetDialect(), recursiveQueriesAreSupported) - - builder.Write(`SELECT - alert.id, - alert.dashboard_id, - alert.panel_id, - alert.name, - alert.state, - alert.new_state_date, - alert.eval_data, - alert.eval_date, - alert.execution_error, - dashboard.uid as dashboard_uid, - dashboard.slug as dashboard_slug - FROM alert - INNER JOIN dashboard on dashboard.id = alert.dashboard_id `) - - builder.Write(`WHERE alert.org_id = ?`, query.OrgID) - - if len(strings.TrimSpace(query.Query)) > 0 { - builder.Write(" AND alert.name "+ss.db.GetDialect().LikeStr()+" ?", "%"+query.Query+"%") - } - - if len(query.DashboardIDs) > 0 { - builder.Write(` AND alert.dashboard_id IN (?` + strings.Repeat(",?", len(query.DashboardIDs)-1) + `) `) - - for _, dbID := range query.DashboardIDs { - builder.AddParams(dbID) - } - } - - if query.PanelID != 0 { - builder.Write(` AND alert.panel_id = ?`, query.PanelID) - } - - if len(query.State) > 0 && query.State[0] != "all" { - builder.Write(` AND (`) - for i, v := range query.State { - if i > 0 { - builder.Write(" OR ") - } - if strings.HasPrefix(v, "not_") { - builder.Write("state <> ? ") - v = strings.TrimPrefix(v, "not_") - } else { - builder.Write("state = ? ") - } - builder.AddParams(v) - } - builder.Write(")") - } - - builder.WriteDashboardPermissionFilter(query.User, dashboardaccess.PERMISSION_VIEW, "") - - builder.Write(" ORDER BY name ASC") - - if query.Limit != 0 { - builder.Write(ss.db.GetDialect().Limit(query.Limit)) - } - - alerts := make([]*alertmodels.AlertListItemDTO, 0) - if err := sess.SQL(builder.GetSQLString(), builder.GetParams()...).Find(&alerts); err != nil { - return err - } - - for i := range alerts { - if alerts[i].ExecutionError == " " { - alerts[i].ExecutionError = "" - } - } - - res = alerts - return nil - }) - return res, err -} - -func (ss *sqlStore) SaveAlerts(ctx context.Context, dashID int64, alerts []*alertmodels.Alert) error { - return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - existingAlerts, err := GetAlertsByDashboardId2(dashID, sess) - if err != nil { - return err - } - - if err := ss.UpdateAlerts(ctx, existingAlerts, alerts, sess, ss.log); err != nil { - return err - } - - if err := deleteMissingAlerts(existingAlerts, alerts, sess, ss.log); err != nil { - return err - } - - return nil - }) -} - -func (ss *sqlStore) UpdateAlerts(ctx context.Context, existingAlerts []*alertmodels.Alert, alerts []*alertmodels.Alert, sess *db.Session, log *log.ConcreteLogger) error { - for _, alert := range alerts { - update := false - var alertToUpdate *alertmodels.Alert - - for _, k := range existingAlerts { - if alert.PanelID == k.PanelID { - update = true - alert.ID = k.ID - alertToUpdate = k - break - } - } - - if update { - if alertToUpdate.ContainsUpdates(alert) { - alert.Updated = timeNow() - alert.State = alertToUpdate.State - sess.MustCols("message", "for") - - _, err := sess.ID(alert.ID).Update(alert) - if err != nil { - return err - } - - log.Debug("Alert updated", "name", alert.Name, "id", alert.ID) - } - } else { - alert.Updated = timeNow() - alert.Created = timeNow() - alert.State = alertmodels.AlertStateUnknown - alert.NewStateDate = timeNow() - - _, err := sess.Insert(alert) - if err != nil { - return err - } - - log.Debug("Alert inserted", "name", alert.Name, "id", alert.ID) - } - tags := alert.GetTagsFromSettings() - if _, err := sess.Exec("DELETE FROM alert_rule_tag WHERE alert_id = ?", alert.ID); err != nil { - return err - } - if tags != nil { - tags, err := ss.tagService.EnsureTagsExist(ctx, tags) - if err != nil { - return err - } - for _, tag := range tags { - if _, err := sess.Exec("INSERT INTO alert_rule_tag (alert_id, tag_id) VALUES(?,?)", alert.ID, tag.Id); err != nil { - return err - } - } - } - } - - return nil -} - -func deleteMissingAlerts(alerts []*alertmodels.Alert, existingAlerts []*alertmodels.Alert, sess *db.Session, log *log.ConcreteLogger) error { - for _, missingAlert := range alerts { - missing := true - - for _, k := range existingAlerts { - if missingAlert.PanelID == k.PanelID { - missing = false - break - } - } - - if missing { - if err := deleteAlertByIdInternal(missingAlert.ID, "Removed from dashboard", sess, log); err != nil { - // No use trying to delete more, since we're in a transaction and it will be - // rolled back on error. - return err - } - } - } - - return nil -} - -func GetAlertsByDashboardId2(dashboardId int64, sess *db.Session) ([]*alertmodels.Alert, error) { - alerts := make([]*alertmodels.Alert, 0) - err := sess.Where("dashboard_id = ?", dashboardId).Find(&alerts) - - if err != nil { - return []*alertmodels.Alert{}, err - } - - return alerts, nil -} - -func (ss *sqlStore) SetAlertState(ctx context.Context, cmd *alertmodels.SetAlertStateCommand) (res alertmodels.Alert, err error) { - err = ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - alert := alertmodels.Alert{} - - if has, err := sess.ID(cmd.AlertID).Get(&alert); err != nil { - return err - } else if !has { - return fmt.Errorf("could not find alert") - } - - if alert.State == alertmodels.AlertStatePaused { - return alertmodels.ErrCannotChangeStateOnPausedAlert - } - - if alert.State == cmd.State { - return alertmodels.ErrRequiresNewState - } - - alert.State = cmd.State - alert.StateChanges++ - alert.NewStateDate = timeNow() - alert.EvalData = cmd.EvalData - - if cmd.Error == "" { - alert.ExecutionError = " " // without this space, xorm skips updating this field - } else { - alert.ExecutionError = cmd.Error - } - - _, err := sess.ID(alert.ID).Update(&alert) - if err != nil { - return err - } - - res = alert - return nil - }) - return res, err -} - -func (ss *sqlStore) PauseAlert(ctx context.Context, cmd *alertmodels.PauseAlertCommand) error { - return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - if len(cmd.AlertIDs) == 0 { - return fmt.Errorf("command contains no alertids") - } - - var buffer bytes.Buffer - params := make([]any, 0) - - buffer.WriteString(`UPDATE alert SET state = ?, new_state_date = ?`) - if cmd.Paused { - params = append(params, string(alertmodels.AlertStatePaused)) - params = append(params, timeNow().UTC()) - } else { - params = append(params, string(alertmodels.AlertStateUnknown)) - params = append(params, timeNow().UTC()) - } - - buffer.WriteString(` WHERE id IN (?` + strings.Repeat(",?", len(cmd.AlertIDs)-1) + `)`) - for _, v := range cmd.AlertIDs { - params = append(params, v) - } - - sqlOrArgs := append([]any{buffer.String()}, params...) - - res, err := sess.Exec(sqlOrArgs...) - if err != nil { - return err - } - cmd.ResultCount, _ = res.RowsAffected() - return nil - }) -} - -func (ss *sqlStore) PauseAllAlerts(ctx context.Context, cmd *alertmodels.PauseAllAlertCommand) error { - return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - var newState string - if cmd.Paused { - newState = string(alertmodels.AlertStatePaused) - } else { - newState = string(alertmodels.AlertStateUnknown) - } - - res, err := sess.Exec(`UPDATE alert SET state = ?, new_state_date = ?`, newState, timeNow().UTC()) - if err != nil { - return err - } - cmd.ResultCount, _ = res.RowsAffected() - return nil - }) -} - -func (ss *sqlStore) GetAlertStatesForDashboard(ctx context.Context, query *alertmodels.GetAlertStatesForDashboardQuery) (res []*alertmodels.AlertStateInfoDTO, err error) { - err = ss.db.WithDbSession(ctx, func(sess *db.Session) error { - var rawSQL = `SELECT - id, - dashboard_id, - panel_id, - state, - new_state_date - FROM alert - WHERE org_id = ? AND dashboard_id = ?` - - res = make([]*alertmodels.AlertStateInfoDTO, 0) - return sess.SQL(rawSQL, query.OrgID, query.DashboardID).Find(&res) - }) - return res, err -} diff --git a/pkg/services/alerting/store_notification.go b/pkg/services/alerting/store_notification.go deleted file mode 100644 index 09d07a8530edc..0000000000000 --- a/pkg/services/alerting/store_notification.go +++ /dev/null @@ -1,594 +0,0 @@ -package alerting - -import ( - "bytes" - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/util" -) - -type AlertNotificationStore interface { - DeleteAlertNotification(ctx context.Context, cmd *models.DeleteAlertNotificationCommand) error - DeleteAlertNotificationWithUid(ctx context.Context, cmd *models.DeleteAlertNotificationWithUidCommand) error - GetAlertNotifications(ctx context.Context, query *models.GetAlertNotificationsQuery) (*models.AlertNotification, error) - GetAlertNotificationUidWithId(ctx context.Context, query *models.GetAlertNotificationUidQuery) (string, error) - GetAlertNotificationsWithUid(ctx context.Context, query *models.GetAlertNotificationsWithUidQuery) (*models.AlertNotification, error) - GetAllAlertNotifications(ctx context.Context, query *models.GetAllAlertNotificationsQuery) ([]*models.AlertNotification, error) - GetAlertNotificationsWithUidToSend(ctx context.Context, query *models.GetAlertNotificationsWithUidToSendQuery) ([]*models.AlertNotification, error) - CreateAlertNotificationCommand(ctx context.Context, cmd *models.CreateAlertNotificationCommand) (*models.AlertNotification, error) - UpdateAlertNotification(ctx context.Context, cmd *models.UpdateAlertNotificationCommand) (*models.AlertNotification, error) - UpdateAlertNotificationWithUid(ctx context.Context, cmd *models.UpdateAlertNotificationWithUidCommand) (*models.AlertNotification, error) - SetAlertNotificationStateToCompleteCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToCompleteCommand) error - SetAlertNotificationStateToPendingCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToPendingCommand) error - GetOrCreateAlertNotificationState(ctx context.Context, cmd *models.GetOrCreateNotificationStateQuery) (*models.AlertNotificationState, error) -} - -// timeNow makes it possible to test usage of time -var timeNow = time.Now - -func (ss *sqlStore) DeleteAlertNotification(ctx context.Context, cmd *models.DeleteAlertNotificationCommand) error { - return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - sql := "DELETE FROM alert_notification WHERE alert_notification.org_id = ? AND alert_notification.id = ?" - res, err := sess.Exec(sql, cmd.OrgID, cmd.ID) - if err != nil { - return err - } - rowsAffected, err := res.RowsAffected() - if err != nil { - return err - } - - if rowsAffected == 0 { - return models.ErrAlertNotificationNotFound - } - - if _, err := sess.Exec("DELETE FROM alert_notification_state WHERE alert_notification_state.org_id = ? AND alert_notification_state.notifier_id = ?", cmd.OrgID, cmd.ID); err != nil { - return err - } - - return nil - }) -} - -func (ss *sqlStore) DeleteAlertNotificationWithUid(ctx context.Context, cmd *models.DeleteAlertNotificationWithUidCommand) (err error) { - var res *models.AlertNotification - if err = ss.db.WithDbSession(ctx, func(sess *db.Session) error { - existingNotification := &models.GetAlertNotificationsWithUidQuery{OrgID: cmd.OrgID, UID: cmd.UID} - res, err = getAlertNotificationWithUidInternal(ctx, existingNotification, sess) - return err - }); err != nil { - return err - } - - if res == nil { - return models.ErrAlertNotificationNotFound - } - - cmd.DeletedAlertNotificationID = res.ID - deleteCommand := &models.DeleteAlertNotificationCommand{ - ID: res.ID, - OrgID: res.OrgID, - } - return ss.DeleteAlertNotification(ctx, deleteCommand) -} - -func (ss *sqlStore) GetAlertNotifications(ctx context.Context, query *models.GetAlertNotificationsQuery) (res *models.AlertNotification, err error) { - err = ss.db.WithDbSession(ctx, func(sess *db.Session) error { - res, err = getAlertNotificationInternal(ctx, query, sess) - return err - }) - return res, err -} - -func (ss *sqlStore) GetAlertNotificationUidWithId(ctx context.Context, query *models.GetAlertNotificationUidQuery) (res string, err error) { - cacheKey := newAlertNotificationUidCacheKey(query.OrgID, query.ID) - - if cached, found := ss.cache.Get(cacheKey); found { - return cached.(string), nil - } - - if err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { - res, err = getAlertNotificationUidInternal(ctx, query, sess) - return err - }); err != nil { - return "", err - } - - ss.cache.Set(cacheKey, res, -1) // Infinite, never changes - return res, nil -} - -func newAlertNotificationUidCacheKey(orgID, notificationId int64) string { - return fmt.Sprintf("notification-uid-by-org-%d-and-id-%d", orgID, notificationId) -} - -func (ss *sqlStore) GetAlertNotificationsWithUid(ctx context.Context, query *models.GetAlertNotificationsWithUidQuery) (res *models.AlertNotification, err error) { - err = ss.db.WithDbSession(ctx, func(sess *db.Session) error { - res, err = getAlertNotificationWithUidInternal(ctx, query, sess) - return err - }) - return res, err -} - -func (ss *sqlStore) GetAllAlertNotifications(ctx context.Context, query *models.GetAllAlertNotificationsQuery) (res []*models.AlertNotification, err error) { - res = make([]*models.AlertNotification, 0) - err = ss.db.WithDbSession(ctx, func(sess *db.Session) error { - if err := sess.Where("org_id = ?", query.OrgID).Asc("name").Find(&res); err != nil { - return err - } - return nil - }) - return res, err -} - -func (ss *sqlStore) GetAlertNotificationsWithUidToSend(ctx context.Context, query *models.GetAlertNotificationsWithUidToSendQuery) (res []*models.AlertNotification, err error) { - res = make([]*models.AlertNotification, 0) - err = ss.db.WithDbSession(ctx, func(sess *db.Session) error { - var sql bytes.Buffer - params := make([]any, 0) - - sql.WriteString(`SELECT - alert_notification.id, - alert_notification.uid, - alert_notification.org_id, - alert_notification.name, - alert_notification.type, - alert_notification.created, - alert_notification.updated, - alert_notification.settings, - alert_notification.secure_settings, - alert_notification.is_default, - alert_notification.disable_resolve_message, - alert_notification.send_reminder, - alert_notification.frequency - FROM alert_notification - `) - - sql.WriteString(` WHERE alert_notification.org_id = ?`) - params = append(params, query.OrgID) - - sql.WriteString(` AND ((alert_notification.is_default = ?)`) - params = append(params, ss.db.GetDialect().BooleanStr(true)) - - if len(query.UIDs) > 0 { - sql.WriteString(` OR alert_notification.uid IN (?` + strings.Repeat(",?", len(query.UIDs)-1) + ")") - for _, v := range query.UIDs { - params = append(params, v) - } - } - sql.WriteString(`)`) - - return sess.SQL(sql.String(), params...).Find(&res) - }) - return res, err -} - -func getAlertNotificationUidInternal(ctx context.Context, query *models.GetAlertNotificationUidQuery, sess *db.Session) (res string, err error) { - var sql bytes.Buffer - params := make([]any, 0) - - sql.WriteString(`SELECT - alert_notification.uid - FROM alert_notification - `) - - sql.WriteString(` WHERE alert_notification.org_id = ?`) - params = append(params, query.OrgID) - - sql.WriteString(` AND alert_notification.id = ?`) - params = append(params, query.ID) - - results := make([]string, 0) - if err := sess.SQL(sql.String(), params...).Find(&results); err != nil { - return "", err - } - - if len(results) == 0 { - return "", models.ErrAlertNotificationFailedTranslateUniqueID - } - - res = results[0] - return res, nil -} - -func getAlertNotificationInternal(ctx context.Context, query *models.GetAlertNotificationsQuery, sess *db.Session) (res *models.AlertNotification, err error) { - var sql bytes.Buffer - params := make([]any, 0) - - sql.WriteString(`SELECT - alert_notification.id, - alert_notification.uid, - alert_notification.org_id, - alert_notification.name, - alert_notification.type, - alert_notification.created, - alert_notification.updated, - alert_notification.settings, - alert_notification.secure_settings, - alert_notification.is_default, - alert_notification.disable_resolve_message, - alert_notification.send_reminder, - alert_notification.frequency - FROM alert_notification - `) - - sql.WriteString(` WHERE alert_notification.org_id = ?`) - params = append(params, query.OrgID) - - if query.Name != "" || query.ID != 0 { - if query.Name != "" { - sql.WriteString(` AND alert_notification.name = ?`) - params = append(params, query.Name) - } - - if query.ID != 0 { - sql.WriteString(` AND alert_notification.id = ?`) - params = append(params, query.ID) - } - } - - results := make([]*models.AlertNotification, 0) - if err := sess.SQL(sql.String(), params...).Find(&results); err != nil { - return nil, err - } - - if len(results) == 0 { - return nil, nil - } - return results[0], nil -} - -func getAlertNotificationWithUidInternal(ctx context.Context, query *models.GetAlertNotificationsWithUidQuery, sess *db.Session) (res *models.AlertNotification, err error) { - var sql bytes.Buffer - params := make([]any, 0) - - sql.WriteString(`SELECT - alert_notification.id, - alert_notification.uid, - alert_notification.org_id, - alert_notification.name, - alert_notification.type, - alert_notification.created, - alert_notification.updated, - alert_notification.settings, - alert_notification.secure_settings, - alert_notification.is_default, - alert_notification.disable_resolve_message, - alert_notification.send_reminder, - alert_notification.frequency - FROM alert_notification - `) - - sql.WriteString(` WHERE alert_notification.org_id = ? AND alert_notification.uid = ?`) - params = append(params, query.OrgID, query.UID) - - results := make([]*models.AlertNotification, 0) - if err := sess.SQL(sql.String(), params...).Find(&results); err != nil { - return nil, err - } - - if len(results) == 0 { - return nil, nil - } - return results[0], nil -} - -func (ss *sqlStore) CreateAlertNotificationCommand(ctx context.Context, cmd *models.CreateAlertNotificationCommand) (res *models.AlertNotification, err error) { - err = ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - if cmd.UID == "" { - uid, uidGenerationErr := generateNewAlertNotificationUid(ctx, sess, cmd.OrgID) - if uidGenerationErr != nil { - return uidGenerationErr - } - - cmd.UID = uid - } - existingQuery := &models.GetAlertNotificationsWithUidQuery{OrgID: cmd.OrgID, UID: cmd.UID} - if notification, err := getAlertNotificationWithUidInternal(ctx, existingQuery, sess); err != nil { - return err - } else if notification != nil { - return models.ErrAlertNotificationWithSameUIDExists - } - - // check if name exists - sameNameQuery := &models.GetAlertNotificationsQuery{OrgID: cmd.OrgID, Name: cmd.Name} - if notification, err := getAlertNotificationInternal(ctx, sameNameQuery, sess); err != nil { - return err - } else if notification != nil { - return models.ErrAlertNotificationWithSameNameExists - } - - var frequency time.Duration - if cmd.SendReminder { - if cmd.Frequency == "" { - return models.ErrNotificationFrequencyNotFound - } - - frequency, err = time.ParseDuration(cmd.Frequency) - if err != nil { - return err - } - } - - // delete empty keys - for k, v := range cmd.SecureSettings { - if v == "" { - delete(cmd.SecureSettings, k) - } - } - - alertNotification := &models.AlertNotification{ - UID: cmd.UID, - OrgID: cmd.OrgID, - Name: cmd.Name, - Type: cmd.Type, - Settings: cmd.Settings, - SecureSettings: cmd.EncryptedSecureSettings, - SendReminder: cmd.SendReminder, - DisableResolveMessage: cmd.DisableResolveMessage, - Frequency: frequency, - Created: time.Now(), - Updated: time.Now(), - IsDefault: cmd.IsDefault, - } - - if _, err = sess.MustCols("send_reminder").Insert(alertNotification); err != nil { - return err - } - - res = alertNotification - return nil - }) - return res, err -} - -func generateNewAlertNotificationUid(ctx context.Context, sess *db.Session, orgId int64) (string, error) { - for i := 0; i < 3; i++ { - uid := util.GenerateShortUID() - exists, err := sess.Where("org_id=? AND uid=?", orgId, uid).Get(&models.AlertNotification{}) - if err != nil { - return "", err - } - - if !exists { - return uid, nil - } - } - - return "", models.ErrAlertNotificationFailedGenerateUniqueUid -} - -func (ss *sqlStore) UpdateAlertNotification(ctx context.Context, cmd *models.UpdateAlertNotificationCommand) (res *models.AlertNotification, err error) { - err = ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) (err error) { - current := models.AlertNotification{} - - if _, err = sess.ID(cmd.ID).Get(¤t); err != nil { - return err - } - - if current.ID == 0 { - return models.ErrAlertNotificationNotFound - } - - // check if name exists - sameNameQuery := &models.GetAlertNotificationsQuery{OrgID: cmd.OrgID, Name: cmd.Name} - notification, err := getAlertNotificationInternal(ctx, sameNameQuery, sess) - if err != nil { - return err - } - - if notification != nil && notification.ID != current.ID { - return fmt.Errorf("alert notification name %q already exists", cmd.Name) - } - - // delete empty keys - for k, v := range cmd.SecureSettings { - if v == "" { - delete(cmd.SecureSettings, k) - } - } - - current.Updated = time.Now() - current.Settings = cmd.Settings - current.SecureSettings = cmd.EncryptedSecureSettings - current.Name = cmd.Name - current.Type = cmd.Type - current.IsDefault = cmd.IsDefault - current.SendReminder = cmd.SendReminder - current.DisableResolveMessage = cmd.DisableResolveMessage - - if cmd.UID != "" { - current.UID = cmd.UID - } - - if current.SendReminder { - if cmd.Frequency == "" { - return models.ErrNotificationFrequencyNotFound - } - - frequency, err := time.ParseDuration(cmd.Frequency) - if err != nil { - return err - } - - current.Frequency = frequency - } - - sess.UseBool("is_default", "send_reminder", "disable_resolve_message") - - if affected, err := sess.ID(cmd.ID).Update(current); err != nil { - return err - } else if affected == 0 { - return fmt.Errorf("could not update alert notification") - } - - res = ¤t - return nil - }) - return res, err -} - -func (ss *sqlStore) UpdateAlertNotificationWithUid(ctx context.Context, cmd *models.UpdateAlertNotificationWithUidCommand) (res *models.AlertNotification, err error) { - getAlertNotificationWithUidQuery := &models.GetAlertNotificationsWithUidQuery{OrgID: cmd.OrgID, UID: cmd.UID} - - if err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { - res, err = getAlertNotificationWithUidInternal(ctx, getAlertNotificationWithUidQuery, sess) - return err - }); err != nil { - return nil, err - } - - current := res - if current == nil { - return nil, models.ErrAlertNotificationNotFound - } - - if cmd.NewUID == "" { - cmd.NewUID = cmd.UID - } - - updateNotification := &models.UpdateAlertNotificationCommand{ - ID: current.ID, - UID: cmd.NewUID, - Name: cmd.Name, - Type: cmd.Type, - SendReminder: cmd.SendReminder, - DisableResolveMessage: cmd.DisableResolveMessage, - Frequency: cmd.Frequency, - IsDefault: cmd.IsDefault, - Settings: cmd.Settings, - SecureSettings: cmd.SecureSettings, - - OrgID: cmd.OrgID, - } - - return ss.UpdateAlertNotification(ctx, updateNotification) -} - -func (ss *sqlStore) SetAlertNotificationStateToCompleteCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToCompleteCommand) error { - return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - version := cmd.Version - var current models.AlertNotificationState - if _, err := sess.ID(cmd.ID).Get(¤t); err != nil { - return err - } - - newVersion := cmd.Version + 1 - sql := `UPDATE alert_notification_state SET - state = ?, - version = ?, - updated_at = ? - WHERE - id = ?` - - _, err := sess.Exec(sql, models.AlertNotificationStateCompleted, newVersion, timeNow().Unix(), cmd.ID) - if err != nil { - return err - } - - if current.Version != version { - ss.log.Error("Notification state out of sync. the notification is marked as complete but has been modified between set as pending and completion.", "notifierId", current.NotifierID) - } - - return nil - }) -} - -func (ss *sqlStore) SetAlertNotificationStateToPendingCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToPendingCommand) error { - return ss.db.WithDbSession(ctx, func(sess *db.Session) error { - newVersion := cmd.Version + 1 - sql := `UPDATE alert_notification_state SET - state = ?, - version = ?, - updated_at = ?, - alert_rule_state_updated_version = ? - WHERE - id = ? AND - (version = ? OR alert_rule_state_updated_version < ?)` - - res, err := sess.Exec(sql, - models.AlertNotificationStatePending, - newVersion, - timeNow().Unix(), - cmd.AlertRuleStateUpdatedVersion, - cmd.ID, - cmd.Version, - cmd.AlertRuleStateUpdatedVersion) - - if err != nil { - return err - } - - affected, _ := res.RowsAffected() - if affected == 0 { - return models.ErrAlertNotificationStateVersionConflict - } - - cmd.ResultVersion = newVersion - - return nil - }) -} - -func (ss *sqlStore) GetOrCreateAlertNotificationState(ctx context.Context, cmd *models.GetOrCreateNotificationStateQuery) (res *models.AlertNotificationState, err error) { - err = ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - nj := &models.AlertNotificationState{} - - exist, err := getAlertNotificationState(ctx, sess, cmd, nj) - - // if exists, return it, otherwise create it with default values - if err != nil { - return err - } - - if exist { - res = nj - return nil - } - - notificationState := &models.AlertNotificationState{ - OrgID: cmd.OrgID, - AlertID: cmd.AlertID, - NotifierID: cmd.NotifierID, - State: models.AlertNotificationStateUnknown, - UpdatedAt: timeNow().Unix(), - } - - if _, err := sess.Insert(notificationState); err != nil { - if ss.db.GetDialect().IsUniqueConstraintViolation(err) { - exist, err = getAlertNotificationState(ctx, sess, cmd, nj) - - if err != nil { - return err - } - - if !exist { - return errors.New("should not happen") - } - - res = nj - return nil - } - - return err - } - - res = notificationState - return nil - }) - return res, err -} - -func getAlertNotificationState(ctx context.Context, sess *db.Session, cmd *models.GetOrCreateNotificationStateQuery, nj *models.AlertNotificationState) (bool, error) { - return sess. - Where("alert_notification_state.org_id = ?", cmd.OrgID). - Where("alert_notification_state.alert_id = ?", cmd.AlertID). - Where("alert_notification_state.notifier_id = ?", cmd.NotifierID). - Get(nj) -} diff --git a/pkg/services/alerting/store_notification_test.go b/pkg/services/alerting/store_notification_test.go deleted file mode 100644 index 3050db3605d4d..0000000000000 --- a/pkg/services/alerting/store_notification_test.go +++ /dev/null @@ -1,504 +0,0 @@ -package alerting - -import ( - "context" - "errors" - "regexp" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting/models" -) - -func TestIntegrationAlertNotificationSQLAccess(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - var store *sqlStore - setup := func() { - store = &sqlStore{ - db: db.InitTestDB(t), - log: log.New(), - cache: localcache.New(time.Minute, time.Minute)} - } - - t.Run("Alert notification state", func(t *testing.T) { - setup() - var alertID int64 = 7 - var orgID int64 = 5 - var notifierID int64 = 10 - oldTimeNow := timeNow - now := time.Date(2018, 9, 30, 0, 0, 0, 0, time.UTC) - timeNow = func() time.Time { return now } - - defer func() { timeNow = oldTimeNow }() - - t.Run("Get no existing state should create a new state", func(t *testing.T) { - query := &models.GetOrCreateNotificationStateQuery{AlertID: alertID, OrgID: orgID, NotifierID: notifierID} - res, err := store.GetOrCreateAlertNotificationState(context.Background(), query) - require.Nil(t, err) - require.NotNil(t, res) - require.Equal(t, models.AlertNotificationStateUnknown, res.State) - require.Equal(t, int64(0), res.Version) - require.Equal(t, now.Unix(), res.UpdatedAt) - - t.Run("Get existing state should not create a new state", func(t *testing.T) { - query2 := &models.GetOrCreateNotificationStateQuery{AlertID: alertID, OrgID: orgID, NotifierID: notifierID} - res2, err := store.GetOrCreateAlertNotificationState(context.Background(), query2) - require.Nil(t, err) - require.NotNil(t, res2) - require.Equal(t, res.ID, res2.ID) - require.Equal(t, now.Unix(), res2.UpdatedAt) - }) - - t.Run("Update existing state to pending with correct version should update database", func(t *testing.T) { - s := *res - - cmd := models.SetAlertNotificationStateToPendingCommand{ - ID: s.ID, - Version: s.Version, - AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion, - } - - err := store.SetAlertNotificationStateToPendingCommand(context.Background(), &cmd) - require.Nil(t, err) - require.Equal(t, int64(1), cmd.ResultVersion) - - query2 := &models.GetOrCreateNotificationStateQuery{AlertID: alertID, OrgID: orgID, NotifierID: notifierID} - res2, err := store.GetOrCreateAlertNotificationState(context.Background(), query2) - require.Nil(t, err) - require.Equal(t, int64(1), res2.Version) - require.Equal(t, models.AlertNotificationStatePending, res2.State) - require.Equal(t, now.Unix(), res2.UpdatedAt) - - t.Run("Update existing state to completed should update database", func(t *testing.T) { - s := *res - setStateCmd := models.SetAlertNotificationStateToCompleteCommand{ - ID: s.ID, - Version: cmd.ResultVersion, - } - err := store.SetAlertNotificationStateToCompleteCommand(context.Background(), &setStateCmd) - require.Nil(t, err) - - query3 := &models.GetOrCreateNotificationStateQuery{AlertID: alertID, OrgID: orgID, NotifierID: notifierID} - res3, err := store.GetOrCreateAlertNotificationState(context.Background(), query3) - require.Nil(t, err) - require.Equal(t, int64(2), res3.Version) - require.Equal(t, models.AlertNotificationStateCompleted, res3.State) - require.Equal(t, now.Unix(), res3.UpdatedAt) - }) - - t.Run("Update existing state to completed should update database. regardless of version", func(t *testing.T) { - s := *res - unknownVersion := int64(1000) - cmd := models.SetAlertNotificationStateToCompleteCommand{ - ID: s.ID, - Version: unknownVersion, - } - err := store.SetAlertNotificationStateToCompleteCommand(context.Background(), &cmd) - require.Nil(t, err) - - query3 := &models.GetOrCreateNotificationStateQuery{AlertID: alertID, OrgID: orgID, NotifierID: notifierID} - res3, err := store.GetOrCreateAlertNotificationState(context.Background(), query3) - require.Nil(t, err) - require.Equal(t, unknownVersion+1, res3.Version) - require.Equal(t, models.AlertNotificationStateCompleted, res3.State) - require.Equal(t, now.Unix(), res3.UpdatedAt) - }) - }) - - t.Run("Update existing state to pending with incorrect version should return version mismatch error", func(t *testing.T) { - s := *res - s.Version = 1000 - cmd := models.SetAlertNotificationStateToPendingCommand{ - ID: s.NotifierID, - Version: s.Version, - AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion, - } - err := store.SetAlertNotificationStateToPendingCommand(context.Background(), &cmd) - require.Equal(t, models.ErrAlertNotificationStateVersionConflict, err) - }) - - t.Run("Updating existing state to pending with incorrect version since alert rule state update version is higher", func(t *testing.T) { - s := *res - cmd := models.SetAlertNotificationStateToPendingCommand{ - ID: s.ID, - Version: s.Version, - AlertRuleStateUpdatedVersion: 1000, - } - err := store.SetAlertNotificationStateToPendingCommand(context.Background(), &cmd) - require.Nil(t, err) - - require.Equal(t, int64(1), cmd.ResultVersion) - }) - - t.Run("different version and same alert state change version should return error", func(t *testing.T) { - s := *res - s.Version = 1000 - cmd := models.SetAlertNotificationStateToPendingCommand{ - ID: s.ID, - Version: s.Version, - AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion, - } - err := store.SetAlertNotificationStateToPendingCommand(context.Background(), &cmd) - require.Error(t, err) - }) - }) - }) - - t.Run("Alert notifications should be empty", func(t *testing.T) { - setup() - cmd := &models.GetAlertNotificationsQuery{ - OrgID: 2, - Name: "email", - } - - res, err := store.GetAlertNotifications(context.Background(), cmd) - require.Nil(t, err) - require.Nil(t, res) - }) - - t.Run("Cannot save alert notifier with send reminder = true", func(t *testing.T) { - setup() - cmd := &models.CreateAlertNotificationCommand{ - Name: "ops", - Type: "email", - OrgID: 1, - SendReminder: true, - Settings: simplejson.New(), - } - - t.Run("and missing frequency", func(t *testing.T) { - _, err := store.CreateAlertNotificationCommand(context.Background(), cmd) - require.Equal(t, models.ErrNotificationFrequencyNotFound, err) - }) - - t.Run("invalid frequency", func(t *testing.T) { - cmd.Frequency = "invalid duration" - _, err := store.CreateAlertNotificationCommand(context.Background(), cmd) - require.True(t, regexp.MustCompile(`^time: invalid duration "?invalid duration"?$`).MatchString( - err.Error())) - }) - }) - - t.Run("Cannot update alert notifier with send reminder = false", func(t *testing.T) { - setup() - cmd := &models.CreateAlertNotificationCommand{ - Name: "ops update", - Type: "email", - OrgID: 1, - SendReminder: false, - Settings: simplejson.New(), - } - - res, err := store.CreateAlertNotificationCommand(context.Background(), cmd) - require.Nil(t, err) - - updateCmd := &models.UpdateAlertNotificationCommand{ - ID: res.ID, - SendReminder: true, - } - - t.Run("and missing frequency", func(t *testing.T) { - _, err := store.UpdateAlertNotification(context.Background(), updateCmd) - require.Equal(t, models.ErrNotificationFrequencyNotFound, err) - }) - - t.Run("invalid frequency", func(t *testing.T) { - updateCmd.Frequency = "invalid duration" - - _, err := store.UpdateAlertNotification(context.Background(), updateCmd) - require.Error(t, err) - require.True(t, regexp.MustCompile(`^time: invalid duration "?invalid duration"?$`).MatchString( - err.Error())) - }) - }) - - t.Run("Can save Alert Notification", func(t *testing.T) { - setup() - cmd := &models.CreateAlertNotificationCommand{ - Name: "ops", - Type: "email", - OrgID: 1, - SendReminder: true, - Frequency: "10s", - Settings: simplejson.New(), - } - - res, err := store.CreateAlertNotificationCommand(context.Background(), cmd) - require.Nil(t, err) - require.NotEqual(t, 0, res.ID) - require.NotEqual(t, 0, res.OrgID) - require.Equal(t, "email", res.Type) - require.Equal(t, 10*time.Second, res.Frequency) - require.False(t, res.DisableResolveMessage) - require.NotEmpty(t, res.UID) - - t.Run("Cannot save Alert Notification with the same name", func(t *testing.T) { - _, err = store.CreateAlertNotificationCommand(context.Background(), cmd) - require.Error(t, err) - }) - t.Run("Cannot save Alert Notification with the same name and another uid", func(t *testing.T) { - anotherUidCmd := &models.CreateAlertNotificationCommand{ - Name: cmd.Name, - Type: cmd.Type, - OrgID: 1, - SendReminder: cmd.SendReminder, - Frequency: cmd.Frequency, - Settings: cmd.Settings, - UID: "notifier1", - } - _, err = store.CreateAlertNotificationCommand(context.Background(), anotherUidCmd) - require.Error(t, err) - }) - t.Run("Can save Alert Notification with another name and another uid", func(t *testing.T) { - anotherUidCmd := &models.CreateAlertNotificationCommand{ - Name: "another ops", - Type: cmd.Type, - OrgID: 1, - SendReminder: cmd.SendReminder, - Frequency: cmd.Frequency, - Settings: cmd.Settings, - UID: "notifier2", - } - _, err = store.CreateAlertNotificationCommand(context.Background(), anotherUidCmd) - require.Nil(t, err) - }) - - t.Run("Can update alert notification", func(t *testing.T) { - newCmd := &models.UpdateAlertNotificationCommand{ - Name: "NewName", - Type: "webhook", - OrgID: res.OrgID, - SendReminder: true, - DisableResolveMessage: true, - Frequency: "60s", - Settings: simplejson.New(), - ID: res.ID, - } - newres, err := store.UpdateAlertNotification(context.Background(), newCmd) - require.Nil(t, err) - require.Equal(t, "NewName", newres.Name) - require.Equal(t, time.Minute, newres.Frequency) - require.True(t, newres.DisableResolveMessage) - }) - - t.Run("Can update alert notification to disable sending of reminders", func(t *testing.T) { - newCmd := &models.UpdateAlertNotificationCommand{ - Name: "NewName", - Type: "webhook", - OrgID: res.OrgID, - SendReminder: false, - Settings: simplejson.New(), - ID: res.ID, - } - newres, err := store.UpdateAlertNotification(context.Background(), newCmd) - require.Nil(t, err) - require.False(t, newres.SendReminder) - }) - }) - - t.Run("Can search using an array of ids", func(t *testing.T) { - setup() - cmd1 := models.CreateAlertNotificationCommand{Name: "nagios", Type: "webhook", OrgID: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()} - cmd2 := models.CreateAlertNotificationCommand{Name: "slack", Type: "webhook", OrgID: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()} - cmd3 := models.CreateAlertNotificationCommand{Name: "ops2", Type: "email", OrgID: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()} - cmd4 := models.CreateAlertNotificationCommand{IsDefault: true, Name: "default", Type: "email", OrgID: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()} - - otherOrg := models.CreateAlertNotificationCommand{Name: "default", Type: "email", OrgID: 2, SendReminder: true, Frequency: "10s", Settings: simplejson.New()} - - res1, err := store.CreateAlertNotificationCommand(context.Background(), &cmd1) - require.NoError(t, err) - res2, err := store.CreateAlertNotificationCommand(context.Background(), &cmd2) - require.NoError(t, err) - _, err = store.CreateAlertNotificationCommand(context.Background(), &cmd3) - require.NoError(t, err) - _, err = store.CreateAlertNotificationCommand(context.Background(), &cmd4) - require.NoError(t, err) - _, err = store.CreateAlertNotificationCommand(context.Background(), &otherOrg) - require.NoError(t, err) - - t.Run("search", func(t *testing.T) { - query := &models.GetAlertNotificationsWithUidToSendQuery{ - UIDs: []string{res1.UID, res2.UID, "112341231"}, - OrgID: 1, - } - - res, err := store.GetAlertNotificationsWithUidToSend(context.Background(), query) - require.Nil(t, err) - require.Equal(t, 3, len(res)) - }) - - t.Run("all", func(t *testing.T) { - query := &models.GetAllAlertNotificationsQuery{ - OrgID: 1, - } - - res, err := store.GetAllAlertNotifications(context.Background(), query) - require.Nil(t, err) - require.Equal(t, 4, len(res)) - require.Equal(t, cmd4.Name, res[0].Name) - require.Equal(t, cmd1.Name, res[1].Name) - require.Equal(t, cmd3.Name, res[2].Name) - require.Equal(t, cmd2.Name, res[3].Name) - }) - }) - - t.Run("Notification UID by ID Caching", func(t *testing.T) { - setup() - - notification := &models.CreateAlertNotificationCommand{UID: "aNotificationUid", OrgID: 1, Name: "aNotificationUid"} - _, err := store.CreateAlertNotificationCommand(context.Background(), notification) - require.Nil(t, err) - - byUidQuery := &models.GetAlertNotificationsWithUidQuery{ - UID: notification.UID, - OrgID: notification.OrgID, - } - - res, notificationByUidErr := store.GetAlertNotificationsWithUid(context.Background(), byUidQuery) - require.Nil(t, notificationByUidErr) - - t.Run("Can cache notification UID", func(t *testing.T) { - byIdQuery := &models.GetAlertNotificationUidQuery{ - ID: res.ID, - OrgID: res.OrgID, - } - - cacheKey := newAlertNotificationUidCacheKey(byIdQuery.OrgID, byIdQuery.ID) - - resultBeforeCaching, foundBeforeCaching := store.cache.Get(cacheKey) - require.False(t, foundBeforeCaching) - require.Nil(t, resultBeforeCaching) - - _, notificationByIdErr := store.GetAlertNotificationUidWithId(context.Background(), byIdQuery) - require.Nil(t, notificationByIdErr) - - resultAfterCaching, foundAfterCaching := store.cache.Get(cacheKey) - require.True(t, foundAfterCaching) - require.Equal(t, notification.UID, resultAfterCaching) - }) - - t.Run("Retrieves from cache when exists", func(t *testing.T) { - query := &models.GetAlertNotificationUidQuery{ - ID: 999, - OrgID: 100, - } - cacheKey := newAlertNotificationUidCacheKey(query.OrgID, query.ID) - store.cache.Set(cacheKey, "a-cached-uid", -1) - - res, err := store.GetAlertNotificationUidWithId(context.Background(), query) - require.Nil(t, err) - require.Equal(t, "a-cached-uid", res) - }) - - t.Run("Returns an error without populating cache when the notification doesn't exist in the database", func(t *testing.T) { - query := &models.GetAlertNotificationUidQuery{ - ID: -1, - OrgID: 100, - } - - res, err := store.GetAlertNotificationUidWithId(context.Background(), query) - require.Equal(t, "", res) - require.Error(t, err) - require.True(t, errors.Is(err, models.ErrAlertNotificationFailedTranslateUniqueID)) - - cacheKey := newAlertNotificationUidCacheKey(query.OrgID, query.ID) - result, found := store.cache.Get(cacheKey) - require.False(t, found) - require.Nil(t, result) - }) - }) - - t.Run("Cannot update non-existing Alert Notification", func(t *testing.T) { - setup() - updateCmd := &models.UpdateAlertNotificationCommand{ - Name: "NewName", - Type: "webhook", - OrgID: 1, - SendReminder: true, - DisableResolveMessage: true, - Frequency: "60s", - Settings: simplejson.New(), - ID: 1, - } - _, err := store.UpdateAlertNotification(context.Background(), updateCmd) - require.Equal(t, models.ErrAlertNotificationNotFound, err) - - t.Run("using UID", func(t *testing.T) { - updateWithUidCmd := &models.UpdateAlertNotificationWithUidCommand{ - Name: "NewName", - Type: "webhook", - OrgID: 1, - SendReminder: true, - DisableResolveMessage: true, - Frequency: "60s", - Settings: simplejson.New(), - UID: "uid", - NewUID: "newUid", - } - _, err := store.UpdateAlertNotificationWithUid(context.Background(), updateWithUidCmd) - require.Equal(t, models.ErrAlertNotificationNotFound, err) - }) - }) - - t.Run("Can delete Alert Notification", func(t *testing.T) { - setup() - cmd := &models.CreateAlertNotificationCommand{ - Name: "ops update", - Type: "email", - OrgID: 1, - SendReminder: false, - Settings: simplejson.New(), - } - - res, err := store.CreateAlertNotificationCommand(context.Background(), cmd) - require.Nil(t, err) - - deleteCmd := &models.DeleteAlertNotificationCommand{ - ID: res.ID, - OrgID: 1, - } - err = store.DeleteAlertNotification(context.Background(), deleteCmd) - require.Nil(t, err) - - t.Run("using UID", func(t *testing.T) { - res, err := store.CreateAlertNotificationCommand(context.Background(), cmd) - require.Nil(t, err) - - deleteWithUidCmd := &models.DeleteAlertNotificationWithUidCommand{ - UID: res.UID, - OrgID: 1, - } - - err = store.DeleteAlertNotificationWithUid(context.Background(), deleteWithUidCmd) - require.Nil(t, err) - require.Equal(t, res.ID, deleteWithUidCmd.DeletedAlertNotificationID) - }) - }) - - t.Run("Cannot delete non-existing Alert Notification", func(t *testing.T) { - setup() - deleteCmd := &models.DeleteAlertNotificationCommand{ - ID: 1, - OrgID: 1, - } - err := store.DeleteAlertNotification(context.Background(), deleteCmd) - require.Equal(t, models.ErrAlertNotificationNotFound, err) - - t.Run("using UID", func(t *testing.T) { - deleteWithUidCmd := &models.DeleteAlertNotificationWithUidCommand{ - UID: "uid", - OrgID: 1, - } - err = store.DeleteAlertNotificationWithUid(context.Background(), deleteWithUidCmd) - require.Equal(t, models.ErrAlertNotificationNotFound, err) - }) - }) -} diff --git a/pkg/services/alerting/store_test.go b/pkg/services/alerting/store_test.go deleted file mode 100644 index 23745c1ad84d5..0000000000000 --- a/pkg/services/alerting/store_test.go +++ /dev/null @@ -1,492 +0,0 @@ -package alerting - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/dashboards" - dashver "github.com/grafana/grafana/pkg/services/dashboardversion" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/tag/tagimpl" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" -) - -func mockTimeNow() { - var timeSeed int64 - timeNow = func() time.Time { - loc := time.FixedZone("MockZoneUTC-5", -5*60*60) - fakeNow := time.Unix(timeSeed, 0).In(loc) - timeSeed++ - return fakeNow - } -} - -func resetTimeNow() { - timeNow = time.Now -} - -func TestIntegrationAlertingDataAccess(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - mockTimeNow() - defer resetTimeNow() - - var store *sqlStore - var testDash *dashboards.Dashboard - var items []*models.Alert - - setup := func(t *testing.T) { - ss := db.InitTestDB(t) - tagService := tagimpl.ProvideService(ss) - cfg := setting.NewCfg() - store = &sqlStore{ - db: ss, - log: log.New(), - cfg: cfg, - tagService: tagService, - features: featuremgmt.WithFeatures(), - } - - testDash = insertTestDashboard(t, store.db, "dashboard with alerts", 1, 0, "", false, "alert") - evalData, err := simplejson.NewJson([]byte(`{"test": "test"}`)) - require.Nil(t, err) - items = []*models.Alert{ - { - PanelID: 1, - DashboardID: testDash.ID, - OrgID: testDash.OrgID, - Name: "Alerting title", - Message: "Alerting message", - Settings: simplejson.New(), - Frequency: 1, - EvalData: evalData, - }, - } - - err = store.SaveAlerts(context.Background(), testDash.ID, items) - require.Nil(t, err) - } - - t.Run("Can set new states", func(t *testing.T) { - setup(t) - - // Get alert so we can use its ID in tests - signedInUser := &user.SignedInUser{ - OrgRole: org.RoleAdmin, - OrgID: 1, - Permissions: map[int64]map[string][]string{ - 1: { - dashboards.ActionFoldersRead: {dashboards.ScopeDashboardsAll, dashboards.ScopeFoldersAll}, - dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll, dashboards.ScopeFoldersAll}, - }, - }, - } - alertQuery := models.GetAlertsQuery{DashboardIDs: []int64{testDash.ID}, PanelID: 1, OrgID: 1, User: signedInUser} - result, err2 := store.HandleAlertsQuery(context.Background(), &alertQuery) - require.Nil(t, err2) - - insertedAlert := result[0] - - t.Run("new state ok", func(t *testing.T) { - cmd := &models.SetAlertStateCommand{ - AlertID: insertedAlert.ID, - State: models.AlertStateOK, - } - - _, err := store.SetAlertState(context.Background(), cmd) - require.Nil(t, err) - }) - - alert, _ := getAlertById(t, insertedAlert.ID, store) - stateDateBeforePause := alert.NewStateDate - - t.Run("can pause all alerts", func(t *testing.T) { - err := store.pauseAllAlerts(t, true) - require.Nil(t, err) - - t.Run("cannot updated paused alert", func(t *testing.T) { - cmd := &models.SetAlertStateCommand{ - AlertID: insertedAlert.ID, - State: models.AlertStateOK, - } - - _, err = store.SetAlertState(context.Background(), cmd) - require.Error(t, err) - }) - - t.Run("alert is paused", func(t *testing.T) { - alert, _ = getAlertById(t, insertedAlert.ID, store) - currentState := alert.State - require.Equal(t, models.AlertStatePaused, currentState) - }) - - t.Run("pausing alerts should update their NewStateDate", func(t *testing.T) { - alert, _ = getAlertById(t, insertedAlert.ID, store) - stateDateAfterPause := alert.NewStateDate - require.True(t, stateDateBeforePause.Before(stateDateAfterPause)) - }) - - t.Run("unpausing alerts should update their NewStateDate again", func(t *testing.T) { - err := store.pauseAllAlerts(t, false) - require.Nil(t, err) - alert, _ = getAlertById(t, insertedAlert.ID, store) - stateDateAfterUnpause := alert.NewStateDate - require.True(t, stateDateBeforePause.Before(stateDateAfterUnpause)) - }) - }) - }) - - t.Run("Can read properties", func(t *testing.T) { - setup(t) - signedInUser := &user.SignedInUser{ - OrgRole: org.RoleAdmin, - OrgID: 1, - Permissions: map[int64]map[string][]string{ - 1: { - dashboards.ActionFoldersRead: {dashboards.ScopeDashboardsAll, dashboards.ScopeFoldersAll}, - dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll, dashboards.ScopeFoldersAll}, - }, - }} - alertQuery := models.GetAlertsQuery{DashboardIDs: []int64{testDash.ID}, PanelID: 1, OrgID: 1, User: signedInUser} - result, err2 := store.HandleAlertsQuery(context.Background(), &alertQuery) - - alert := result[0] - require.Nil(t, err2) - require.Greater(t, alert.ID, int64(0)) - require.Equal(t, testDash.ID, alert.DashboardID) - require.Equal(t, int64(1), alert.PanelID) - require.Equal(t, "Alerting title", alert.Name) - require.Equal(t, models.AlertStateUnknown, alert.State) - require.NotNil(t, alert.NewStateDate) - require.NotNil(t, alert.EvalData) - require.Equal(t, "test", alert.EvalData.Get("test").MustString()) - require.NotNil(t, alert.EvalDate) - require.Equal(t, "", alert.ExecutionError) - require.NotNil(t, alert.DashboardUID) - require.Equal(t, "dashboard-with-alerts", alert.DashboardSlug) - }) - - t.Run("Viewer can read alerts", func(t *testing.T) { - setup(t) - viewerUser := &user.SignedInUser{ - OrgRole: org.RoleViewer, - OrgID: 1, - Permissions: map[int64]map[string][]string{ - 1: {dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}, dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}}, - }, - } - alertQuery := models.GetAlertsQuery{DashboardIDs: []int64{testDash.ID}, PanelID: 1, OrgID: 1, User: viewerUser} - res, err2 := store.HandleAlertsQuery(context.Background(), &alertQuery) - - require.Nil(t, err2) - require.Equal(t, 1, len(res)) - }) - - t.Run("Alerts with same dashboard id and panel id should update", func(t *testing.T) { - setup(t) - modifiedItems := items - modifiedItems[0].Name = "Name" - - err := store.SaveAlerts(context.Background(), testDash.ID, items) - - t.Run("Can save alerts with same dashboard and panel id", func(t *testing.T) { - require.Nil(t, err) - }) - - t.Run("Alerts should be updated", func(t *testing.T) { - signedInUser := &user.SignedInUser{ - OrgRole: org.RoleAdmin, - OrgID: 1, - Permissions: map[int64]map[string][]string{ - 1: { - dashboards.ActionFoldersRead: {dashboards.ScopeDashboardsAll, dashboards.ScopeFoldersAll}, - dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll, dashboards.ScopeFoldersAll}, - }, - }} - query := models.GetAlertsQuery{DashboardIDs: []int64{testDash.ID}, OrgID: 1, User: signedInUser} - res, err2 := store.HandleAlertsQuery(context.Background(), &query) - - require.Nil(t, err2) - require.Equal(t, 1, len(res)) - require.Equal(t, "Name", res[0].Name) - - t.Run("Alert state should not be updated", func(t *testing.T) { - require.Equal(t, models.AlertStateUnknown, res[0].State) - }) - }) - - t.Run("Updates without changes should be ignored", func(t *testing.T) { - err3 := store.SaveAlerts(context.Background(), testDash.ID, items) - require.Nil(t, err3) - }) - }) - - t.Run("Multiple alerts per dashboard", func(t *testing.T) { - setup(t) - signedInUser := &user.SignedInUser{ - OrgRole: org.RoleAdmin, - OrgID: 1, - Permissions: map[int64]map[string][]string{ - 1: { - dashboards.ActionFoldersRead: {dashboards.ScopeDashboardsAll, dashboards.ScopeFoldersAll}, - dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll, dashboards.ScopeFoldersAll}, - }, - }, - } - multipleItems := []*models.Alert{ - { - DashboardID: testDash.ID, - PanelID: 1, - Name: "1", - OrgID: 1, - Settings: simplejson.New(), - }, - { - DashboardID: testDash.ID, - PanelID: 2, - Name: "2", - OrgID: 1, - Settings: simplejson.New(), - }, - { - DashboardID: testDash.ID, - PanelID: 3, - Name: "3", - OrgID: 1, - Settings: simplejson.New(), - }, - } - - err := store.SaveAlerts(context.Background(), testDash.ID, multipleItems) - - t.Run("Should save 3 dashboards", func(t *testing.T) { - require.Nil(t, err) - - queryForDashboard := models.GetAlertsQuery{DashboardIDs: []int64{testDash.ID}, OrgID: 1, User: signedInUser} - res, err2 := store.HandleAlertsQuery(context.Background(), &queryForDashboard) - - require.Nil(t, err2) - require.Equal(t, 3, len(res)) - }) - - t.Run("should updated two dashboards and delete one", func(t *testing.T) { - missingOneAlert := multipleItems[:2] - - err = store.SaveAlerts(context.Background(), testDash.ID, missingOneAlert) - - t.Run("should delete the missing alert", func(t *testing.T) { - query := models.GetAlertsQuery{DashboardIDs: []int64{testDash.ID}, OrgID: 1, User: signedInUser} - res, err2 := store.HandleAlertsQuery(context.Background(), &query) - require.Nil(t, err2) - require.Equal(t, 2, len(res)) - }) - }) - }) - - t.Run("When dashboard is removed", func(t *testing.T) { - setup(t) - items := []*models.Alert{ - { - PanelID: 1, - DashboardID: testDash.ID, - Name: "Alerting title", - Message: "Alerting message", - }, - } - - err := store.SaveAlerts(context.Background(), testDash.ID, items) - require.Nil(t, err) - - err = store.db.WithDbSession(context.Background(), func(sess *db.Session) error { - dash := dashboards.Dashboard{ID: testDash.ID, OrgID: 1} - _, err := sess.Delete(dash) - return err - }) - require.Nil(t, err) - - t.Run("Alerts should be removed", func(t *testing.T) { - query := models.GetAlertsQuery{DashboardIDs: []int64{testDash.ID}, OrgID: 1, User: &user.SignedInUser{OrgRole: org.RoleAdmin}} - res, err2 := store.HandleAlertsQuery(context.Background(), &query) - - require.Nil(t, err2) - require.Equal(t, 0, len(res)) - }) - }) -} - -func TestIntegrationPausingAlerts(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - mockTimeNow() - defer resetTimeNow() - - t.Run("Given an alert", func(t *testing.T) { - ss := db.InitTestDB(t) - cfg := setting.NewCfg() - sqlStore := sqlStore{db: ss, cfg: cfg, log: log.New(), tagService: tagimpl.ProvideService(ss), features: featuremgmt.WithFeatures()} - - testDash := insertTestDashboard(t, sqlStore.db, "dashboard with alerts", 1, 0, "", false, "alert") - alert, err := insertTestAlert("Alerting title", "Alerting message", testDash.OrgID, testDash.ID, simplejson.New(), sqlStore) - require.Nil(t, err) - - stateDateBeforePause := alert.NewStateDate - stateDateAfterPause := stateDateBeforePause - signedInUser := &user.SignedInUser{ - OrgRole: org.RoleAdmin, - OrgID: testDash.OrgID, - Permissions: map[int64]map[string][]string{ - testDash.OrgID: { - dashboards.ActionFoldersRead: {dashboards.ScopeDashboardsAll, dashboards.ScopeFoldersAll}, - dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll, dashboards.ScopeFoldersAll}, - }, - }, - } - // Get alert so we can use its ID in tests - alertQuery := models.GetAlertsQuery{DashboardIDs: []int64{testDash.ID}, PanelID: 1, OrgID: 1, User: signedInUser} - res, err2 := sqlStore.HandleAlertsQuery(context.Background(), &alertQuery) - require.Nil(t, err2) - - insertedAlert := res[0] - - t.Run("when paused", func(t *testing.T) { - _, err := sqlStore.pauseAlert(t, testDash.OrgID, insertedAlert.ID, true) - require.Nil(t, err) - - t.Run("the NewStateDate should be updated", func(t *testing.T) { - alert, err := getAlertById(t, insertedAlert.ID, &sqlStore) - require.Nil(t, err) - - stateDateAfterPause = alert.NewStateDate - require.True(t, stateDateBeforePause.Before(stateDateAfterPause)) - }) - }) - - t.Run("when unpaused", func(t *testing.T) { - _, err := sqlStore.pauseAlert(t, testDash.OrgID, insertedAlert.ID, false) - require.Nil(t, err) - - t.Run("the NewStateDate should be updated again", func(t *testing.T) { - alert, err := getAlertById(t, insertedAlert.ID, &sqlStore) - require.Nil(t, err) - - stateDateAfterUnpause := alert.NewStateDate - require.True(t, stateDateAfterPause.Before(stateDateAfterUnpause)) - }) - }) - }) -} - -func (ss *sqlStore) pauseAlert(t *testing.T, orgId int64, alertId int64, pauseState bool) (int64, error) { - cmd := &models.PauseAlertCommand{ - OrgID: orgId, - AlertIDs: []int64{alertId}, - Paused: pauseState, - } - err := ss.PauseAlert(context.Background(), cmd) - require.Nil(t, err) - return cmd.ResultCount, err -} - -func insertTestAlert(title string, message string, orgId int64, dashId int64, settings *simplejson.Json, ss sqlStore) (*models.Alert, error) { - items := []*models.Alert{ - { - PanelID: 1, - DashboardID: dashId, - OrgID: orgId, - Name: title, - Message: message, - Settings: settings, - Frequency: 1, - }, - } - - err := ss.SaveAlerts(context.Background(), dashId, items) - return items[0], err -} - -func getAlertById(t *testing.T, id int64, ss *sqlStore) (*models.Alert, error) { - q := &models.GetAlertByIdQuery{ - ID: id, - } - res, err := ss.GetAlertById(context.Background(), q) - require.Nil(t, err) - return res, err -} - -func (ss *sqlStore) pauseAllAlerts(t *testing.T, pauseState bool) error { - cmd := &models.PauseAllAlertCommand{ - Paused: pauseState, - } - err := ss.PauseAllAlerts(context.Background(), cmd) - require.Nil(t, err) - return err -} - -func insertTestDashboard(t *testing.T, store db.DB, title string, orgId int64, - folderId int64, folderUID string, isFolder bool, tags ...any) *dashboards.Dashboard { - t.Helper() - cmd := dashboards.SaveDashboardCommand{ - OrgID: orgId, - FolderID: folderId, // nolint:staticcheck - FolderUID: folderUID, - IsFolder: isFolder, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": title, - "tags": tags, - }), - } - - var dash *dashboards.Dashboard - err := store.WithDbSession(context.Background(), func(sess *db.Session) error { - dash = cmd.GetDashboardModel() - dash.SetVersion(1) - dash.Created = time.Now() - dash.Updated = time.Now() - dash.UID = util.GenerateShortUID() - _, err := sess.Insert(dash) - return err - }) - - require.NoError(t, err) - require.NotNil(t, dash) - dash.Data.Set("id", dash.ID) - dash.Data.Set("uid", dash.UID) - - err = store.WithDbSession(context.Background(), func(sess *db.Session) error { - dashVersion := &dashver.DashboardVersion{ - DashboardID: dash.ID, - ParentVersion: dash.Version, - RestoredFrom: cmd.RestoredFrom, - Version: dash.Version, - Created: time.Now(), - CreatedBy: dash.UpdatedBy, - Message: cmd.Message, - Data: dash.Data, - } - require.NoError(t, err) - - if affectedRows, err := sess.Insert(dashVersion); err != nil { - return err - } else if affectedRows == 0 { - return dashboards.ErrDashboardNotFound - } - - return nil - }) - require.NoError(t, err) - - return dash -} diff --git a/pkg/services/alerting/test_notification.go b/pkg/services/alerting/test_notification.go deleted file mode 100644 index e844b2d1a57d6..0000000000000 --- a/pkg/services/alerting/test_notification.go +++ /dev/null @@ -1,92 +0,0 @@ -package alerting - -import ( - "context" - "fmt" - "math/rand" - "net/http" - - "github.com/grafana/grafana/pkg/components/null" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" -) - -// NotificationTestCommand initiates an test -// execution of an alert notification. -type NotificationTestCommand struct { - OrgID int64 - ID int64 - State models.AlertStateType - Name string - Type string - Settings *simplejson.Json - SecureSettings map[string]string -} - -var ( - logger = log.New("alerting.testnotification") -) - -func (s *AlertNotificationService) HandleNotificationTestCommand(ctx context.Context, cmd *NotificationTestCommand) error { - notificationSvc := newNotificationService(nil, nil, nil, nil) - - model := models.AlertNotification{ - ID: cmd.ID, - OrgID: cmd.OrgID, - Name: cmd.Name, - Type: cmd.Type, - Settings: cmd.Settings, - } - - notifier, err := s.createNotifier(ctx, &model, cmd.SecureSettings) - if err != nil { - return err - } - - return notificationSvc.sendNotifications(createTestEvalContext(cmd), notifierStateSlice{{notifier: notifier}}) -} - -func createTestEvalContext(cmd *NotificationTestCommand) *EvalContext { - testRule := &Rule{ - DashboardID: 1, - PanelID: 1, - Name: "Test notification", - Message: "Someone is testing the alert notification within Grafana.", - State: models.AlertStateAlerting, - ID: rand.Int63(), - } - - ctx := NewEvalContext(context.Background(), testRule, fakeRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) - if cmd.Settings.Get("uploadImage").MustBool(true) { - ctx.ImagePublicURL = "https://grafana.com/static/assets/img/blog/mixed_styles.png" - } - ctx.IsTestRun = true - ctx.Firing = true - ctx.Error = fmt.Errorf("this is only a test") - ctx.EvalMatches = evalMatchesBasedOnState() - - return ctx -} - -func evalMatchesBasedOnState() []*EvalMatch { - matches := make([]*EvalMatch, 0) - matches = append(matches, &EvalMatch{ - Metric: "High value", - Value: null.FloatFrom(100), - }) - - matches = append(matches, &EvalMatch{ - Metric: "Higher Value", - Value: null.FloatFrom(200), - }) - - return matches -} - -type fakeRequestValidator struct{} - -func (fakeRequestValidator) Validate(_ string, _ *http.Request) error { - return nil -} diff --git a/pkg/services/alerting/test_rule.go b/pkg/services/alerting/test_rule.go deleted file mode 100644 index c73f9d7b39742..0000000000000 --- a/pkg/services/alerting/test_rule.go +++ /dev/null @@ -1,47 +0,0 @@ -package alerting - -import ( - "context" - "fmt" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/user" -) - -// AlertTest makes a test alert. -func (e *AlertEngine) AlertTest(orgID int64, dashboard *simplejson.Json, panelID int64, user *user.SignedInUser) (*EvalContext, error) { - dash := dashboards.NewDashboardFromJson(dashboard) - dashInfo := DashAlertInfo{ - User: user, - Dash: dash, - OrgID: orgID, - } - alerts, err := e.dashAlertExtractor.GetAlerts(context.Background(), dashInfo) - if err != nil { - return nil, err - } - - for _, alert := range alerts { - if alert.PanelID != panelID { - continue - } - rule, err := NewRuleFromDBAlert(context.Background(), e.AlertStore, alert, true) - if err != nil { - return nil, err - } - - handler := NewEvalHandler(e.DataService) - - context := NewEvalContext(context.Background(), rule, fakeRequestValidator{}, e.AlertStore, nil, e.datasourceService, annotationstest.NewFakeAnnotationsRepo()) - context.IsTestRun = true - context.IsDebug = true - - handler.Eval(context) - context.Rule.State = context.GetNewState() - return context, nil - } - - return nil, fmt.Errorf("could not find alert with panel ID %d", panelID) -} diff --git a/pkg/services/alerting/testdata/collapsed-panels.json b/pkg/services/alerting/testdata/collapsed-panels.json deleted file mode 100644 index cf0df1f04979e..0000000000000 --- a/pkg/services/alerting/testdata/collapsed-panels.json +++ /dev/null @@ -1,596 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": "-- Grafana --", - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "gnetId": null, - "graphTooltip": 0, - "id": 127, - "links": [], - "panels": [ - { - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 9, - "title": "Row title", - "type": "row" - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 200 - ], - "type": "gt" - }, - "operator": { - "type": "and" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "executionErrorState": "alerting", - "frequency": "10s", - "handler": 1, - "name": "Panel Title alert", - "noDataState": "no_data", - "notifications": [] - }, - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 9, - "w": 12, - "x": 0, - "y": 1 - }, - "id": 10, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "nullPointMode": "null", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "go_goroutines", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{job}}", - "refId": "A" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 200 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Panel Title", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ] - }, - { - "gridPos": { - "h": 9, - "w": 12, - "x": 12, - "y": 1 - }, - "id": 14, - "limit": 10, - "links": [], - "onlyAlertsOnDashboard": true, - "show": "current", - "sortOrder": 1, - "stateFilter": [], - "title": "Panel Title", - "type": "alertlist" - }, - { - "collapsed": true, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 10 - }, - "id": 6, - "panels": [ - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 200 - ], - "type": "gt" - }, - "operator": { - "type": "and" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "executionErrorState": "alerting", - "frequency": "10s", - "handler": 1, - "name": "Panel 2 alert", - "noDataState": "no_data", - "notifications": [] - }, - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 9, - "w": 12, - "x": 0, - "y": 11 - }, - "id": 11, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "links": [], - "nullPointMode": "null", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "go_goroutines", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{job}}", - "refId": "A" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 200 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Panel 2", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ] - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 200 - ], - "type": "gt" - }, - "operator": { - "type": "and" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "executionErrorState": "alerting", - "frequency": "10s", - "handler": 1, - "name": "Panel 4 alert", - "noDataState": "no_data", - "notifications": [] - }, - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 9, - "w": 12, - "x": 12, - "y": 11 - }, - "id": 15, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "links": [], - "nullPointMode": "null", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "go_goroutines", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{job}}", - "refId": "A" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 200 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Panel 4", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ] - } - ], - "title": "Row title", - "type": "row" - }, - { - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 11 - }, - "id": 4, - "title": "Row title", - "type": "row" - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 200 - ], - "type": "gt" - }, - "operator": { - "type": "and" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "executionErrorState": "alerting", - "frequency": "10s", - "handler": 1, - "name": "Panel 3 alert", - "noDataState": "no_data", - "notifications": [] - }, - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 9, - "w": 12, - "x": 0, - "y": 12 - }, - "id": 12, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "links": [], - "nullPointMode": "null", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "go_goroutines", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{job}}", - "refId": "A" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 200 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Panel 3", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ] - } - ], - "schemaVersion": 16, - "tags": [], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": { - "refresh_intervals": [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ] - }, - "timezone": "", - "title": "New dashboard Copy", - "uid": "6v5pg36zk", - "version": 17 -} diff --git a/pkg/services/alerting/testdata/dash-without-id.json b/pkg/services/alerting/testdata/dash-without-id.json deleted file mode 100644 index afea526b7afb5..0000000000000 --- a/pkg/services/alerting/testdata/dash-without-id.json +++ /dev/null @@ -1,282 +0,0 @@ -{ - "title": "Influxdb", - "tags": [ - "apa" - ], - "timezone": "browser", - "editable": true, - "sharedCrosshair": false, - "rows": [ - { - "collapse": false, - "editable": true, - "height": "450px", - "panels": [ - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 10 - ], - "type": "gt" - }, - "query": { - "params": [ - "B", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "frequency": "3s", - "handler": 1, - "name": "Influxdb", - "noDataState": "no_data", - "notifications": [ - { - "uid": "notifier1" - }, - { - "id": 2 - } - ] - }, - "alerting": {}, - "aliasColors": { - "logins.count.count": "#890F02" - }, - "bars": false, - "datasource": "InfluxDB", - "editable": true, - "error": false, - "fill": 1, - "grid": {}, - "id": 1, - "interval": ">10s", - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "groupBy": [ - { - "params": [ - "$interval" - ], - "type": "time" - }, - { - "params": [ - "datacenter" - ], - "type": "tag" - }, - { - "params": [ - "none" - ], - "type": "fill" - } - ], - "hide": false, - "measurement": "logins.count", - "policy": "default", - "query": "SELECT 8 * count(\"value\") FROM \"logins.count\" WHERE $timeFilter GROUP BY time($interval), \"datacenter\" fill(none)", - "rawQuery": true, - "refId": "B", - "resultFormat": "time_series", - "select": [ - [ - { - "params": [ - "value" - ], - "type": "field" - }, - { - "params": [], - "type": "count" - } - ] - ], - "tags": [] - }, - { - "groupBy": [ - { - "params": [ - "$interval" - ], - "type": "time" - }, - { - "params": [ - "null" - ], - "type": "fill" - } - ], - "hide": true, - "measurement": "cpu", - "policy": "default", - "refId": "A", - "resultFormat": "time_series", - "select": [ - [ - { - "params": [ - "value" - ], - "type": "field" - }, - { - "params": [], - "type": "mean" - } - ], - [ - { - "params": [ - "value" - ], - "type": "field" - }, - { - "params": [], - "type": "sum" - } - ] - ], - "tags": [] - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 10 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Panel Title", - "tooltip": { - "msResolution": false, - "ordering": "alphabetical", - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ] - }, - { - "editable": true, - "error": false, - "id": 2, - "isNew": true, - "limit": 10, - "links": [], - "show": "current", - "span": 2, - "stateFilter": [ - "alerting" - ], - "title": "Alert status", - "type": "alertlist" - } - ], - "title": "Row" - } - ], - "time": { - "from": "now-5m", - "to": "now" - }, - "timepicker": { - "now": true, - "refresh_intervals": [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ] - }, - "templating": { - "list": [] - }, - "annotations": { - "list": [] - }, - "schemaVersion": 13, - "version": 120, - "links": [], - "gnetId": null - } diff --git a/pkg/services/alerting/testdata/graphite-alert.json b/pkg/services/alerting/testdata/graphite-alert.json deleted file mode 100644 index 3cb4ae1dd2236..0000000000000 --- a/pkg/services/alerting/testdata/graphite-alert.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "id": 57, - "title": "Graphite 4", - "originalTitle": "Graphite 4", - "tags": ["graphite"], - "rows": [ - { - "panels": [ - { - "title": "Active desktop users", - "editable": true, - "type": "graph", - "id": 3, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.desktop.count, 4)" - } - ], - "datasource": null, - "alert": { - "name": "name1", - "message": "desc1", - "handler": 1, - "frequency": "60s", - "for": "2m", - "conditions": [ - { - "type": "query", - "query": {"params": ["A", "5m", "now"]}, - "reducer": {"type": "avg", "params": []}, - "evaluator": {"type": ">", "params": [100]} - } - ] - } - }, - { - "title": "Active mobile users", - "id": 4, - "targets": [ - {"refId": "A", "target": ""}, - {"refId": "B", "target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"} - ], - "datasource": "graphite2", - "alert": { - "name": "name2", - "message": "desc2", - "handler": 0, - "frequency": "60s", - "severity": "warning", - "conditions": [ - { - "type": "query", - "query": {"params": ["B", "5m", "now"]}, - "reducer": {"type": "avg", "params": []}, - "evaluator": {"type": ">", "params": [100]} - } - ] - } - } - ] - } - ] - } \ No newline at end of file diff --git a/pkg/services/alerting/testdata/influxdb-alert.json b/pkg/services/alerting/testdata/influxdb-alert.json deleted file mode 100644 index f835778ab47df..0000000000000 --- a/pkg/services/alerting/testdata/influxdb-alert.json +++ /dev/null @@ -1,283 +0,0 @@ -{ - "id": 4, - "title": "Influxdb", - "tags": [ - "apa" - ], - "timezone": "browser", - "editable": true, - "sharedCrosshair": false, - "rows": [ - { - "collapse": false, - "editable": true, - "height": "450px", - "panels": [ - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 10 - ], - "type": "gt" - }, - "query": { - "params": [ - "B", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "frequency": "3s", - "handler": 1, - "name": "Influxdb", - "noDataState": "no_data", - "notifications": [ - { - "id": 1 - }, - { - "uid": "notifier2" - } - ] - }, - "alerting": {}, - "aliasColors": { - "logins.count.count": "#890F02" - }, - "bars": false, - "datasource": "InfluxDB", - "editable": true, - "error": false, - "fill": 1, - "grid": {}, - "id": 1, - "interval": ">10s", - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "groupBy": [ - { - "params": [ - "$interval" - ], - "type": "time" - }, - { - "params": [ - "datacenter" - ], - "type": "tag" - }, - { - "params": [ - "none" - ], - "type": "fill" - } - ], - "hide": false, - "measurement": "logins.count", - "policy": "default", - "query": "SELECT 8 * count(\"value\") FROM \"logins.count\" WHERE $timeFilter GROUP BY time($interval), \"datacenter\" fill(none)", - "rawQuery": true, - "refId": "B", - "resultFormat": "time_series", - "select": [ - [ - { - "params": [ - "value" - ], - "type": "field" - }, - { - "params": [], - "type": "count" - } - ] - ], - "tags": [] - }, - { - "groupBy": [ - { - "params": [ - "$interval" - ], - "type": "time" - }, - { - "params": [ - "null" - ], - "type": "fill" - } - ], - "hide": true, - "measurement": "cpu", - "policy": "default", - "refId": "A", - "resultFormat": "time_series", - "select": [ - [ - { - "params": [ - "value" - ], - "type": "field" - }, - { - "params": [], - "type": "mean" - } - ], - [ - { - "params": [ - "value" - ], - "type": "field" - }, - { - "params": [], - "type": "sum" - } - ] - ], - "tags": [] - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 10 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Panel Title", - "tooltip": { - "msResolution": false, - "ordering": "alphabetical", - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ] - }, - { - "editable": true, - "error": false, - "id": 2, - "isNew": true, - "limit": 10, - "links": [], - "show": "current", - "span": 2, - "stateFilter": [ - "alerting" - ], - "title": "Alert status", - "type": "alertlist" - } - ], - "title": "Row" - } - ], - "time": { - "from": "now-5m", - "to": "now" - }, - "timepicker": { - "now": true, - "refresh_intervals": [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ] - }, - "templating": { - "list": [] - }, - "annotations": { - "list": [] - }, - "schemaVersion": 13, - "version": 120, - "links": [], - "gnetId": null - } diff --git a/pkg/services/alerting/testdata/panel-with-bad-query-id.json b/pkg/services/alerting/testdata/panel-with-bad-query-id.json deleted file mode 100644 index 2907ed4d94acf..0000000000000 --- a/pkg/services/alerting/testdata/panel-with-bad-query-id.json +++ /dev/null @@ -1,184 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": "-- Grafana --", - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "gnetId": null, - "graphTooltip": 0, - "id": 436, - "links": [], - "panels": [ - { - "alert": { - "alertRuleTags": {}, - "conditions": [ - { - "evaluator": { - "params": [ - 3 - ], - "type": "gt" - }, - "operator": { - "type": "and" - }, - "query": { - "params": [ - "B", - "15s", - "now" - ] - }, - "reducer": { - "params": [], - "type": "last" - }, - "type": "query" - } - ], - "executionErrorState": "alerting", - "for": "0m", - "frequency": "10s", - "handler": 1, - "message": "A message", - "name": "Ok / Alerting Cycle test default datasource", - "noDataState": "no_data", - "notifications": [] - }, - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": null, - "fieldConfig": { - "defaults": { - "custom": {}, - "links": [] - }, - "overrides": [] - }, - "fill": 1, - "fillGradient": 0, - "gridPos": { - "h": 6, - "w": 12, - "x": 0, - "y": 0 - }, - "hiddenSeries": false, - "id": 2, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "nullPointMode": "null", - "options": { - "alertThreshold": true - }, - "percentage": false, - "pluginVersion": "7.4.0-pre", - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "pulseWave": { - "offCount": 6, - "offValue": "2", - "onCount": 6, - "onValue": "4", - "timeStep": 10 - }, - "refId": "A", - "scenarioId": "predictable_pulse", - "stringInput": "" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 3 - } - ], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, - "title": "Alerting / Ok", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "$$hashKey": "object:536", - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "$$hashKey": "object:537", - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - } - ], - "schemaVersion": 27, - "tags": [], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "extractor test default datasource", - "uid": "bqQguKaMz", - "version": 7 -} diff --git a/pkg/services/alerting/testdata/panel-with-datasource-ref.json b/pkg/services/alerting/testdata/panel-with-datasource-ref.json deleted file mode 100644 index 225f60b0fa0fe..0000000000000 --- a/pkg/services/alerting/testdata/panel-with-datasource-ref.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "id": 57, - "title": "Graphite 4", - "originalTitle": "Graphite 4", - "tags": ["graphite"], - "panels": [ - { - "title": "Active desktop users", - "id": 2, - "editable": true, - "type": "graph", - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.desktop.count, 4)" - } - ], - "datasource": { - "uid": "graphite2-uid", - "type": "graphite" - }, - "alert": { - "name": "name1", - "message": "desc1", - "handler": 1, - "frequency": "60s", - "conditions": [ - { - "type": "query", - "query": { "params": ["A", "5m", "now"] }, - "reducer": { "type": "avg", "params": [] }, - "evaluator": { "type": ">", "params": [100] } - } - ] - } - } - ] -} diff --git a/pkg/services/alerting/testdata/panel-with-id-0.json b/pkg/services/alerting/testdata/panel-with-id-0.json deleted file mode 100644 index d1f314a4f559b..0000000000000 --- a/pkg/services/alerting/testdata/panel-with-id-0.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "id": 57, - "title": "Graphite 4", - "originalTitle": "Graphite 4", - "tags": ["graphite"], - "rows": [ - { - "panels": [ - { - "title": "Active desktop users", - "id": 0, - "editable": true, - "type": "graph", - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.desktop.count, 4)" - } - ], - "datasource": null, - "alert": { - "name": "name1", - "message": "desc1", - "handler": 1, - "frequency": "60s", - "conditions": [ - { - "type": "query", - "query": {"params": ["A", "5m", "now"]}, - "reducer": {"type": "avg", "params": []}, - "evaluator": {"type": ">", "params": [100]} - } - ] - } - }, - { - "title": "Active mobile users", - "id": 4, - "targets": [ - {"refId": "A", "target": ""}, - {"refId": "B", "target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"} - ], - "datasource": "graphite2", - "alert": { - "name": "name2", - "message": "desc2", - "handler": 0, - "frequency": "60s", - "severity": "warning", - "conditions": [ - { - "type": "query", - "query": {"params": ["B", "5m", "now"]}, - "reducer": {"type": "avg", "params": []}, - "evaluator": {"type": ">", "params": [100]} - } - ] - } - } - ] - } -] - } diff --git a/pkg/services/alerting/testdata/panel-without-specified-datasource.json b/pkg/services/alerting/testdata/panel-without-specified-datasource.json deleted file mode 100644 index dfd814d20ee79..0000000000000 --- a/pkg/services/alerting/testdata/panel-without-specified-datasource.json +++ /dev/null @@ -1,184 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": "-- Grafana --", - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "gnetId": null, - "graphTooltip": 0, - "id": 436, - "links": [], - "panels": [ - { - "alert": { - "alertRuleTags": {}, - "conditions": [ - { - "evaluator": { - "params": [ - 3 - ], - "type": "gt" - }, - "operator": { - "type": "and" - }, - "query": { - "params": [ - "A", - "15s", - "now" - ] - }, - "reducer": { - "params": [], - "type": "last" - }, - "type": "query" - } - ], - "executionErrorState": "alerting", - "for": "0m", - "frequency": "10s", - "handler": 1, - "message": "A message", - "name": "Ok / Alerting Cycle test default datasource", - "noDataState": "no_data", - "notifications": [] - }, - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": null, - "fieldConfig": { - "defaults": { - "custom": {}, - "links": [] - }, - "overrides": [] - }, - "fill": 1, - "fillGradient": 0, - "gridPos": { - "h": 6, - "w": 12, - "x": 0, - "y": 0 - }, - "hiddenSeries": false, - "id": 2, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "nullPointMode": "null", - "options": { - "alertThreshold": true - }, - "percentage": false, - "pluginVersion": "7.4.0-pre", - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "pulseWave": { - "offCount": 6, - "offValue": "2", - "onCount": 6, - "onValue": "4", - "timeStep": 10 - }, - "refId": "A", - "scenarioId": "predictable_pulse", - "stringInput": "" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 3 - } - ], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, - "title": "Alerting / Ok", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "$$hashKey": "object:536", - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "$$hashKey": "object:537", - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - } - ], - "schemaVersion": 27, - "tags": [], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "extractor test default datasource", - "uid": "bqQguKaMz", - "version": 7 -} \ No newline at end of file diff --git a/pkg/services/alerting/testdata/panels-missing-id.json b/pkg/services/alerting/testdata/panels-missing-id.json deleted file mode 100644 index dad96a18dc135..0000000000000 --- a/pkg/services/alerting/testdata/panels-missing-id.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "id": 57, - "title": "Graphite 4", - "originalTitle": "Graphite 4", - "tags": ["graphite"], - "rows": [ - { - "panels": [ - { - "title": "Active desktop users", - "editable": true, - "type": "graph", - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.desktop.count, 4)" - } - ], - "datasource": null, - "alert": { - "name": "name1", - "message": "desc1", - "handler": 1, - "frequency": "60s", - "conditions": [ - { - "type": "query", - "query": {"params": ["A", "5m", "now"]}, - "reducer": {"type": "avg", "params": []}, - "evaluator": {"type": ">", "params": [100]} - } - ] - } - }, - { - "title": "Active mobile users", - "id": 4, - "targets": [ - {"refId": "A", "target": ""}, - {"refId": "B", "target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"} - ], - "datasource": "graphite2", - "alert": { - "name": "name2", - "message": "desc2", - "handler": 0, - "frequency": "60s", - "severity": "warning", - "conditions": [ - { - "type": "query", - "query": {"params": ["B", "5m", "now"]}, - "reducer": {"type": "avg", "params": []}, - "evaluator": {"type": ">", "params": [100]} - } - ] - } - } - ] - } - ] - } \ No newline at end of file diff --git a/pkg/services/alerting/testdata/settings/empty.json b/pkg/services/alerting/testdata/settings/empty.json deleted file mode 100644 index 9e26dfeeb6e64..0000000000000 --- a/pkg/services/alerting/testdata/settings/empty.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/pkg/services/alerting/testdata/settings/one_condition.json b/pkg/services/alerting/testdata/settings/one_condition.json deleted file mode 100644 index 04c2d9b3da4fc..0000000000000 --- a/pkg/services/alerting/testdata/settings/one_condition.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "conditions": [ - { - "evaluator": { - "params": [ - 60 - ], - "type": "gt" - }, - "query": { - "datasourceId": 3, - "model": { - "refId": "A", - "scenario": "random_walk", - "scenarioId": "csv_metric_values", - "stringInput": "1,20,90,30,5,0", - "target": "" - }, - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "TestData - Always OK", - "noDataState": "no_data", - "notifications": [] -} \ No newline at end of file diff --git a/pkg/services/alerting/testdata/settings/three_conditions.json b/pkg/services/alerting/testdata/settings/three_conditions.json deleted file mode 100644 index a10f27ce1dd66..0000000000000 --- a/pkg/services/alerting/testdata/settings/three_conditions.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "conditions": [ - { - "evaluator": { - "params": [ - 60 - ], - "type": "gt" - }, - "query": { - "datasourceId": 3, - "model": { - "refId": "A", - "scenario": "random_walk", - "scenarioId": "csv_metric_values", - "stringInput": "1,20,90,30,5,0", - "target": "" - }, - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - }, - { - "evaluator": { - "params": [ - 60 - ], - "type": "gt" - }, - "query": { - "datasourceId": 2, - "model": { - "refId": "A", - "scenario": "random_walk", - "scenarioId": "csv_metric_values", - "stringInput": "1,20,90,30,5,0", - "target": "" - }, - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - }, - { - "evaluator": { - "params": [ - 60 - ], - "type": "gt" - }, - "query": { - "datasourceId": 4, - "model": { - "refId": "A", - "scenario": "random_walk", - "scenarioId": "csv_metric_values", - "stringInput": "1,20,90,30,5,0", - "target": "" - }, - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "TestData - Always OK", - "noDataState": "no_data", - "notifications": [] -} \ No newline at end of file diff --git a/pkg/services/alerting/testdata/settings/two_conditions.json b/pkg/services/alerting/testdata/settings/two_conditions.json deleted file mode 100644 index 54ae3952be373..0000000000000 --- a/pkg/services/alerting/testdata/settings/two_conditions.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "conditions": [ - { - "evaluator": { - "params": [ - 60 - ], - "type": "gt" - }, - "query": { - "datasourceId": 3, - "model": { - "refId": "A", - "scenario": "random_walk", - "scenarioId": "csv_metric_values", - "stringInput": "1,20,90,30,5,0", - "target": "" - }, - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - }, - { - "evaluator": { - "params": [ - 60 - ], - "type": "gt" - }, - "query": { - "datasourceId": 2, - "model": { - "refId": "A", - "scenario": "random_walk", - "scenarioId": "csv_metric_values", - "stringInput": "1,20,90,30,5,0", - "target": "" - }, - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "TestData - Always OK", - "noDataState": "no_data", - "notifications": [] -} \ No newline at end of file diff --git a/pkg/services/alerting/testdata/v5-dashboard.json b/pkg/services/alerting/testdata/v5-dashboard.json deleted file mode 100644 index da7bbd8d04856..0000000000000 --- a/pkg/services/alerting/testdata/v5-dashboard.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "id": 57, - "title": "Graphite 4", - "originalTitle": "Graphite 4", - "tags": ["graphite"], - "panels": [ - { - "title": "Active desktop users", - "editable": true, - "type": "graph", - "id": 3, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.desktop.count, 4)" - } - ], - "datasource": null, - "alert": { - "name": "name1", - "message": "desc1", - "handler": 1, - "frequency": "60s", - "conditions": [ - { - "type": "query", - "query": {"params": ["A", "5m", "now"]}, - "reducer": {"type": "avg", "params": []}, - "evaluator": {"type": ">", "params": [100]} - } - ] - } - }, - { - "title": "Active mobile users", - "id": 4, - "targets": [ - {"refId": "A", "target": ""}, - {"refId": "B", "target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"} - ], - "datasource": "graphite2", - "alert": { - "name": "name2", - "message": "desc2", - "handler": 0, - "frequency": "60s", - "severity": "warning", - "conditions": [ - { - "type": "query", - "query": {"params": ["B", "5m", "now"]}, - "reducer": {"type": "avg", "params": []}, - "evaluator": {"type": ">", "params": [100]} - } - ] - } - - } - ] - } \ No newline at end of file diff --git a/pkg/services/annotations/accesscontrol/accesscontrol.go b/pkg/services/annotations/accesscontrol/accesscontrol.go index 071cf21e87074..9102e62b9fa46 100644 --- a/pkg/services/annotations/accesscontrol/accesscontrol.go +++ b/pkg/services/annotations/accesscontrol/accesscontrol.go @@ -6,7 +6,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/annotations" - "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" @@ -40,7 +39,8 @@ func NewAuthService(db db.DB, features featuremgmt.FeatureToggles) *AuthService } // Authorize checks if the user has permission to read annotations, then returns a struct containing dashboards and scope types that the user has access to. -func (authz *AuthService) Authorize(ctx context.Context, orgID int64, user identity.Requester) (*AccessResources, error) { +func (authz *AuthService) Authorize(ctx context.Context, orgID int64, query *annotations.ItemQuery) (*AccessResources, error) { + user := query.SignedInUser if user == nil || user.IsNil() { return nil, ErrReadForbidden.Errorf("missing user") } @@ -59,7 +59,7 @@ func (authz *AuthService) Authorize(ctx context.Context, orgID int64, user ident var visibleDashboards map[string]int64 var err error if canAccessDashAnnotations { - visibleDashboards, err = authz.dashboardsWithVisibleAnnotations(ctx, user, orgID) + visibleDashboards, err = authz.dashboardsWithVisibleAnnotations(ctx, query, orgID) if err != nil { return nil, ErrAccessControlInternal.Errorf("failed to fetch dashboards: %w", err) } @@ -72,7 +72,7 @@ func (authz *AuthService) Authorize(ctx context.Context, orgID int64, user ident }, nil } -func (authz *AuthService) dashboardsWithVisibleAnnotations(ctx context.Context, user identity.Requester, orgID int64) (map[string]int64, error) { +func (authz *AuthService) dashboardsWithVisibleAnnotations(ctx context.Context, query *annotations.ItemQuery, orgID int64) (map[string]int64, error) { recursiveQueriesSupported, err := authz.db.RecursiveQueriesAreSupported() if err != nil { return nil, err @@ -84,10 +84,21 @@ func (authz *AuthService) dashboardsWithVisibleAnnotations(ctx context.Context, } filters := []any{ - permissions.NewAccessControlDashboardPermissionFilter(user, dashboardaccess.PERMISSION_VIEW, filterType, authz.features, recursiveQueriesSupported), + permissions.NewAccessControlDashboardPermissionFilter(query.SignedInUser, dashboardaccess.PERMISSION_VIEW, filterType, authz.features, recursiveQueriesSupported), searchstore.OrgFilter{OrgId: orgID}, } + if query.DashboardUID != "" { + filters = append(filters, searchstore.DashboardFilter{ + UIDs: []string{query.DashboardUID}, + }) + } + if query.DashboardID != 0 { + filters = append(filters, searchstore.DashboardIDFilter{ + IDs: []int64{query.DashboardID}, + }) + } + sb := &searchstore.Builder{Dialect: authz.db.GetDialect(), Filters: filters, Features: authz.features} visibleDashboards := make(map[string]int64) diff --git a/pkg/services/annotations/accesscontrol/accesscontrol_test.go b/pkg/services/annotations/accesscontrol/accesscontrol_test.go index 7bb4d74f2a605..02674e42b2468 100644 --- a/pkg/services/annotations/accesscontrol/accesscontrol_test.go +++ b/pkg/services/annotations/accesscontrol/accesscontrol_test.go @@ -5,16 +5,23 @@ import ( "fmt" "testing" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/annotations/testutil" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/user" - "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationAuthorize(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -168,7 +175,8 @@ func TestIntegrationAuthorize(t *testing.T) { authz := NewAuthService(sql, featuremgmt.WithFeatures(tc.featureToggle)) - resources, err := authz.Authorize(context.Background(), 1, u) + query := &annotations.ItemQuery{SignedInUser: u} + resources, err := authz.Authorize(context.Background(), 1, query) require.NoError(t, err) if tc.expectedResources.Dashboards != nil { diff --git a/pkg/services/annotations/annotationsimpl/annotations.go b/pkg/services/annotations/annotationsimpl/annotations.go index 42ff9d20cf85a..3cf88288d270b 100644 --- a/pkg/services/annotations/annotationsimpl/annotations.go +++ b/pkg/services/annotations/annotationsimpl/annotations.go @@ -38,7 +38,7 @@ func ProvideService( historianStore := loki.NewLokiHistorianStore(cfg.UnifiedAlerting.StateHistory, features, db, log.New("annotations.loki")) if historianStore != nil { l.Debug("Using composite read store") - read = NewCompositeStore(xormStore, historianStore) + read = NewCompositeStore(log.New("annotations.composite"), xormStore, historianStore) } else { l.Debug("Using xorm read store") read = write @@ -68,7 +68,7 @@ func (r *RepositoryImpl) Update(ctx context.Context, item *annotations.Item) err } func (r *RepositoryImpl) Find(ctx context.Context, query *annotations.ItemQuery) ([]*annotations.ItemDTO, error) { - resources, err := r.authZ.Authorize(ctx, query.OrgID, query.SignedInUser) + resources, err := r.authZ.Authorize(ctx, query.OrgID, query) if err != nil { return make([]*annotations.ItemDTO, 0), err } diff --git a/pkg/services/annotations/annotationsimpl/annotations_test.go b/pkg/services/annotations/annotationsimpl/annotations_test.go index 9b6a824e42335..a3053d5d12ced 100644 --- a/pkg/services/annotations/annotationsimpl/annotations_test.go +++ b/pkg/services/annotations/annotationsimpl/annotations_test.go @@ -26,11 +26,17 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -221,7 +227,7 @@ func TestIntegrationAnnotationListingWithInheritedRBAC(t *testing.T) { }) ac := acimpl.ProvideAccessControl(sql.Cfg) - folderSvc := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), sql.Cfg, dashStore, folderimpl.ProvideDashboardFolderStore(sql), sql, features, nil) + folderSvc := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), sql.Cfg, dashStore, folderimpl.ProvideDashboardFolderStore(sql), sql, features, supportbundlestest.NewFakeBundleService(), nil) cfg := setting.NewCfg() cfg.AnnotationMaximumTagsLength = 60 diff --git a/pkg/services/annotations/annotationsimpl/cleanup_test.go b/pkg/services/annotations/annotationsimpl/cleanup_test.go index 3b94ef8fdd5ba..278bb48e365ec 100644 --- a/pkg/services/annotations/annotationsimpl/cleanup_test.go +++ b/pkg/services/annotations/annotationsimpl/cleanup_test.go @@ -2,6 +2,7 @@ package annotationsimpl import ( "context" + "errors" "testing" "time" @@ -14,31 +15,30 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -func TestAnnotationCleanUp(t *testing.T) { - fakeSQL := db.InitTestDB(t) - - t.Cleanup(func() { - err := fakeSQL.WithDbSession(context.Background(), func(session *db.Session) error { - _, err := session.Exec("DELETE FROM annotation") - return err - }) - assert.NoError(t, err) - }) +func TestIntegrationAnnotationCleanUp(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } - createTestAnnotations(t, fakeSQL, 21, 6) - assertAnnotationCount(t, fakeSQL, "", 21) - assertAnnotationTagCount(t, fakeSQL, 42) + fakeSQL := db.InitTestDB(t) tests := []struct { - name string - cfg *setting.Cfg - alertAnnotationCount int64 - dashboardAnnotationCount int64 - APIAnnotationCount int64 - affectedAnnotations int64 + name string + createAnnotationsNum int + createOldAnnotationsNum int + + cfg *setting.Cfg + alertAnnotationCount int64 + annotationCleanupJobBatchSize int + dashboardAnnotationCount int64 + APIAnnotationCount int64 + affectedAnnotations int64 }{ { - name: "default settings should not delete any annotations", + name: "default settings should not delete any annotations", + createAnnotationsNum: 21, + createOldAnnotationsNum: 6, + annotationCleanupJobBatchSize: 1, cfg: &setting.Cfg{ AlertingAnnotationCleanupSetting: settingsFn(0, 0), DashboardAnnotationCleanupSettings: settingsFn(0, 0), @@ -50,7 +50,10 @@ func TestAnnotationCleanUp(t *testing.T) { affectedAnnotations: 0, }, { - name: "should remove annotations created before cut off point", + name: "should remove annotations created before cut off point", + createAnnotationsNum: 21, + createOldAnnotationsNum: 6, + annotationCleanupJobBatchSize: 1, cfg: &setting.Cfg{ AlertingAnnotationCleanupSetting: settingsFn(time.Hour*48, 0), DashboardAnnotationCleanupSettings: settingsFn(time.Hour*48, 0), @@ -62,7 +65,10 @@ func TestAnnotationCleanUp(t *testing.T) { affectedAnnotations: 6, }, { - name: "should only keep three annotations", + name: "should only keep three annotations", + createAnnotationsNum: 15, + createOldAnnotationsNum: 6, + annotationCleanupJobBatchSize: 1, cfg: &setting.Cfg{ AlertingAnnotationCleanupSetting: settingsFn(0, 3), DashboardAnnotationCleanupSettings: settingsFn(0, 3), @@ -74,7 +80,10 @@ func TestAnnotationCleanUp(t *testing.T) { affectedAnnotations: 6, }, { - name: "running the max count delete again should not remove any annotations", + name: "running the max count delete again should not remove any annotations", + createAnnotationsNum: 9, + createOldAnnotationsNum: 6, + annotationCleanupJobBatchSize: 1, cfg: &setting.Cfg{ AlertingAnnotationCleanupSetting: settingsFn(0, 3), DashboardAnnotationCleanupSettings: settingsFn(0, 3), @@ -85,12 +94,40 @@ func TestAnnotationCleanUp(t *testing.T) { APIAnnotationCount: 3, affectedAnnotations: 0, }, + { + name: "should not fail if batch size is larger than SQLITE_MAX_VARIABLE_NUMBER for SQLite >= 3.32.0", + createAnnotationsNum: 40003, + createOldAnnotationsNum: 0, + annotationCleanupJobBatchSize: 32767, + cfg: &setting.Cfg{ + AlertingAnnotationCleanupSetting: settingsFn(0, 1), + DashboardAnnotationCleanupSettings: settingsFn(0, 1), + APIAnnotationCleanupSettings: settingsFn(0, 1), + }, + alertAnnotationCount: 1, + dashboardAnnotationCount: 1, + APIAnnotationCount: 1, + affectedAnnotations: 40000, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { + createTestAnnotations(t, fakeSQL, test.createAnnotationsNum, test.createOldAnnotationsNum) + assertAnnotationCount(t, fakeSQL, "", int64(test.createAnnotationsNum)) + assertAnnotationTagCount(t, fakeSQL, 2*int64(test.createAnnotationsNum)) + + t.Cleanup(func() { + err := fakeSQL.WithDbSession(context.Background(), func(session *db.Session) error { + _, deleteAnnotationErr := session.Exec("DELETE FROM annotation") + _, deleteAnnotationTagErr := session.Exec("DELETE FROM annotation_tag") + return errors.Join(deleteAnnotationErr, deleteAnnotationTagErr) + }) + assert.NoError(t, err) + }) + cfg := setting.NewCfg() - cfg.AnnotationCleanupJobBatchSize = 1 + cfg.AnnotationCleanupJobBatchSize = int64(test.annotationCleanupJobBatchSize) cleaner := ProvideCleanupService(fakeSQL, cfg) affectedAnnotations, affectedAnnotationTags, err := cleaner.Run(context.Background(), test.cfg) require.NoError(t, err) @@ -111,7 +148,11 @@ func TestAnnotationCleanUp(t *testing.T) { } } -func TestOldAnnotationsAreDeletedFirst(t *testing.T) { +func TestIntegrationOldAnnotationsAreDeletedFirst(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + fakeSQL := db.InitTestDB(t) t.Cleanup(func() { @@ -193,8 +234,11 @@ func createTestAnnotations(t *testing.T, store db.DB, expectedCount int, oldAnno cutoffDate := time.Now() + newAnnotations := make([]*annotations.Item, 0, expectedCount) + newAnnotationTags := make([]*annotationTag, 0, 2*expectedCount) for i := 0; i < expectedCount; i++ { a := &annotations.Item{ + ID: int64(i + 1), DashboardID: 1, OrgID: 1, UserID: 1, @@ -222,20 +266,29 @@ func createTestAnnotations(t *testing.T, store db.DB, expectedCount int, oldAnno a.Created = cutoffDate.AddDate(-10, 0, -10).UnixNano() / int64(time.Millisecond) } - err := store.WithDbSession(context.Background(), func(sess *db.Session) error { - _, err := sess.Insert(a) - require.NoError(t, err, "should be able to save annotation", err) - - // mimick the SQL annotation Save logic by writing records to the annotation_tag table - // we need to ensure they get deleted when we clean up annotations - for tagID := range []int{1, 2} { - _, err = sess.Exec("INSERT INTO annotation_tag (annotation_id, tag_id) VALUES(?,?)", a.ID, tagID) - require.NoError(t, err, "should be able to save annotation tag ID", err) - } - return err - }) - require.NoError(t, err) + newAnnotations = append(newAnnotations, a) + newAnnotationTags = append(newAnnotationTags, &annotationTag{AnnotationID: a.ID, TagID: 1}, &annotationTag{AnnotationID: a.ID, TagID: 2}) } + + err := store.WithDbSession(context.Background(), func(sess *db.Session) error { + batchsize := 500 + for i := 0; i < len(newAnnotations); i += batchsize { + _, err := sess.InsertMulti(newAnnotations[i:min(i+batchsize, len(newAnnotations))]) + require.NoError(t, err) + } + return nil + }) + require.NoError(t, err) + + err = store.WithDbSession(context.Background(), func(sess *db.Session) error { + batchsize := 500 + for i := 0; i < len(newAnnotationTags); i += batchsize { + _, err := sess.InsertMulti(newAnnotationTags[i:min(i+batchsize, len(newAnnotationTags))]) + require.NoError(t, err) + } + return nil + }) + require.NoError(t, err) } func settingsFn(maxAge time.Duration, maxCount int64) setting.AnnotationCleanupSettings { diff --git a/pkg/services/annotations/annotationsimpl/composite_store.go b/pkg/services/annotations/annotationsimpl/composite_store.go index 4db6e1c9ece22..3bcf0724b52d5 100644 --- a/pkg/services/annotations/annotationsimpl/composite_store.go +++ b/pkg/services/annotations/annotationsimpl/composite_store.go @@ -2,8 +2,11 @@ package annotationsimpl import ( "context" + "fmt" "sort" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/dskit/concurrency" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/annotations/accesscontrol" @@ -11,20 +14,29 @@ import ( // CompositeStore is a read store that combines two or more read stores, and queries all stores in parallel. type CompositeStore struct { + logger log.Logger readers []readStore } -func NewCompositeStore(readers ...readStore) *CompositeStore { +func NewCompositeStore(logger log.Logger, readers ...readStore) *CompositeStore { return &CompositeStore{ + logger: logger, readers: readers, } } +// Satisfy the commonStore interface, in practice this is not used. +func (c *CompositeStore) Type() string { + return "composite" +} + // Get returns annotations from all stores, and combines the results. func (c *CompositeStore) Get(ctx context.Context, query *annotations.ItemQuery, accessResources *accesscontrol.AccessResources) ([]*annotations.ItemDTO, error) { itemCh := make(chan []*annotations.ItemDTO, len(c.readers)) - err := concurrency.ForEachJob(ctx, len(c.readers), len(c.readers), func(ctx context.Context, i int) error { + err := concurrency.ForEachJob(ctx, len(c.readers), len(c.readers), func(ctx context.Context, i int) (err error) { + defer handleJobPanic(c.logger, c.readers[i].Type(), &err) + items, err := c.readers[i].Get(ctx, query, accessResources) itemCh <- items return err @@ -47,7 +59,9 @@ func (c *CompositeStore) Get(ctx context.Context, query *annotations.ItemQuery, func (c *CompositeStore) GetTags(ctx context.Context, query *annotations.TagsQuery) (annotations.FindTagsResult, error) { resCh := make(chan annotations.FindTagsResult, len(c.readers)) - err := concurrency.ForEachJob(ctx, len(c.readers), len(c.readers), func(ctx context.Context, i int) error { + err := concurrency.ForEachJob(ctx, len(c.readers), len(c.readers), func(ctx context.Context, i int) (err error) { + defer handleJobPanic(c.logger, c.readers[i].Type(), &err) + res, err := c.readers[i].GetTags(ctx, query) resCh <- res return err @@ -65,3 +79,20 @@ func (c *CompositeStore) GetTags(ctx context.Context, query *annotations.TagsQue return annotations.FindTagsResult{Tags: res}, nil } + +// handleJobPanic is a helper function that recovers from a panic in a concurrent job., +// It will log the error and set the job error if it is not nil. +func handleJobPanic(logger log.Logger, storeType string, jobErr *error) { + if r := recover(); r != nil { + logger.Error("Annotation store panic", "error", r, "store", storeType, "stack", log.Stack(1)) + errMsg := "concurrent job panic" + + if jobErr != nil { + err := fmt.Errorf(errMsg) + if panicErr, ok := r.(error); ok { + err = fmt.Errorf("%s: %w", errMsg, panicErr) + } + *jobErr = err + } + } +} diff --git a/pkg/services/annotations/annotationsimpl/composite_store_test.go b/pkg/services/annotations/annotationsimpl/composite_store_test.go index e7800d5dbdc3a..b5aa1d22d8c21 100644 --- a/pkg/services/annotations/annotationsimpl/composite_store_test.go +++ b/pkg/services/annotations/annotationsimpl/composite_store_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/annotations/accesscontrol" "github.com/stretchr/testify/require" @@ -18,6 +19,22 @@ var ( ) func TestCompositeStore(t *testing.T) { + t.Run("should handle panic", func(t *testing.T) { + r1 := newFakeReader() + getPanic := func(context.Context, *annotations.ItemQuery, *accesscontrol.AccessResources) ([]*annotations.ItemDTO, error) { + panic("ohno") + } + r2 := newFakeReader(withGetFn(getPanic)) + store := &CompositeStore{ + log.NewNopLogger(), + []readStore{r1, r2}, + } + + _, err := store.Get(context.Background(), nil, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "concurrent job panic") + }) + t.Run("should return first error", func(t *testing.T) { err1 := errors.New("error 1") r1 := newFakeReader(withError(err1)) @@ -25,6 +42,7 @@ func TestCompositeStore(t *testing.T) { r2 := newFakeReader(withError(err2), withWait(10*time.Millisecond)) store := &CompositeStore{ + log.NewNopLogger(), []readStore{r1, r2}, } @@ -64,6 +82,7 @@ func TestCompositeStore(t *testing.T) { r2 := newFakeReader(withItems(items2)) store := &CompositeStore{ + log.NewNopLogger(), []readStore{r1, r2}, } @@ -92,6 +111,7 @@ func TestCompositeStore(t *testing.T) { r2 := newFakeReader(withTags(tags2)) store := &CompositeStore{ + log.NewNopLogger(), []readStore{r1, r2}, } @@ -108,13 +128,23 @@ func TestCompositeStore(t *testing.T) { } type fakeReader struct { - items []*annotations.ItemDTO - tagRes annotations.FindTagsResult - wait time.Duration - err error + items []*annotations.ItemDTO + tagRes annotations.FindTagsResult + getFn func(context.Context, *annotations.ItemQuery, *accesscontrol.AccessResources) ([]*annotations.ItemDTO, error) + getTagFn func(context.Context, *annotations.TagsQuery) (annotations.FindTagsResult, error) + wait time.Duration + err error +} + +func (f *fakeReader) Type() string { + return "fake" } func (f *fakeReader) Get(ctx context.Context, query *annotations.ItemQuery, accessResources *accesscontrol.AccessResources) ([]*annotations.ItemDTO, error) { + if f.getFn != nil { + return f.getFn(ctx, query, accessResources) + } + if f.wait > 0 { time.Sleep(f.wait) } @@ -128,6 +158,10 @@ func (f *fakeReader) Get(ctx context.Context, query *annotations.ItemQuery, acce } func (f *fakeReader) GetTags(ctx context.Context, query *annotations.TagsQuery) (annotations.FindTagsResult, error) { + if f.getTagFn != nil { + return f.getTagFn(ctx, query) + } + if f.wait > 0 { time.Sleep(f.wait) } @@ -164,6 +198,12 @@ func withTags(tags []*annotations.TagsDTO) func(*fakeReader) { } } +func withGetFn(fn func(context.Context, *annotations.ItemQuery, *accesscontrol.AccessResources) ([]*annotations.ItemDTO, error)) func(*fakeReader) { + return func(f *fakeReader) { + f.getFn = fn + } +} + func newFakeReader(opts ...func(*fakeReader)) *fakeReader { f := &fakeReader{} for _, opt := range opts { diff --git a/pkg/services/annotations/annotationsimpl/loki/historian_store.go b/pkg/services/annotations/annotationsimpl/loki/historian_store.go index 773a3f53a722c..3d12623bd025c 100644 --- a/pkg/services/annotations/annotationsimpl/loki/historian_store.go +++ b/pkg/services/annotations/annotationsimpl/loki/historian_store.go @@ -2,21 +2,43 @@ package loki import ( "context" + "encoding/json" + "errors" + "fmt" + "sort" + "time" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/services/annotations/accesscontrol" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert" + "golang.org/x/exp/constraints" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" ngmetrics "github.com/grafana/grafana/pkg/services/ngalert/metrics" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/ngalert/state/historian" - "github.com/grafana/grafana/pkg/setting" + historymodel "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model" + "github.com/prometheus/client_golang/prometheus" + + "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util/errutil" ) const ( - subsystem = "annotations" + subsystem = "annotations" + defaultQueryRange = 6 * time.Hour // from grafana/pkg/services/ngalert/state/historian/loki.go +) + +var ( + ErrLokiStoreInternal = errutil.Internal("annotations.loki.internal") + ErrLokiStoreNotFound = errutil.NotFound("annotations.loki.notFound") + + errMissingRule = errors.New("rule not found") ) type lokiQueryClient interface { @@ -47,14 +69,225 @@ func NewLokiHistorianStore(cfg setting.UnifiedAlertingStateHistorySettings, ft f } } +func (r *LokiHistorianStore) Type() string { + return "loki" +} + func (r *LokiHistorianStore) Get(ctx context.Context, query *annotations.ItemQuery, accessResources *accesscontrol.AccessResources) ([]*annotations.ItemDTO, error) { - return []*annotations.ItemDTO{}, nil + if query.Type == "annotation" { + return make([]*annotations.ItemDTO, 0), nil + } + + rule := &ngmodels.AlertRule{} + if query.AlertID != 0 { + var err error + rule, err = getRule(ctx, r.db, query.OrgID, query.AlertID) + if err != nil { + if errors.Is(err, errMissingRule) { + return make([]*annotations.ItemDTO, 0), ErrLokiStoreNotFound.Errorf("rule with ID %d does not exist", query.AlertID) + } + return make([]*annotations.ItemDTO, 0), ErrLokiStoreInternal.Errorf("failed to query rule: %w", err) + } + } + + logQL, err := historian.BuildLogQuery(buildHistoryQuery(query, accessResources.Dashboards, rule.UID)) + if err != nil { + return make([]*annotations.ItemDTO, 0), ErrLokiStoreInternal.Errorf("failed to build loki query: %w", err) + } + + now := time.Now().UTC() + if query.To == 0 { + query.To = now.UnixMilli() + } + if query.From == 0 { + query.From = now.Add(-defaultQueryRange).UnixMilli() + } + + // query.From and query.To are always in milliseconds, convert them to nanoseconds for loki + from := query.From * 1e6 + to := query.To * 1e6 + + res, err := r.client.RangeQuery(ctx, logQL, from, to, query.Limit) + if err != nil { + return make([]*annotations.ItemDTO, 0), ErrLokiStoreInternal.Errorf("failed to query loki: %w", err) + } + + items := make([]*annotations.ItemDTO, 0) + for _, stream := range res.Data.Result { + items = append(items, r.annotationsFromStream(stream, *accessResources)...) + } + sort.Sort(annotations.SortedItems(items)) + + return items, err +} + +func (r *LokiHistorianStore) annotationsFromStream(stream historian.Stream, ac accesscontrol.AccessResources) []*annotations.ItemDTO { + items := make([]*annotations.ItemDTO, 0, len(stream.Values)) + for _, sample := range stream.Values { + entry := historian.LokiEntry{} + err := json.Unmarshal([]byte(sample.V), &entry) + if err != nil { + // bad data, skip + r.log.Debug("failed to unmarshal loki entry", "error", err, "entry", sample.V) + continue + } + + if !hasAccess(entry, ac) { + // no access to this annotation, skip + continue + } + + transition, err := buildTransition(entry) + if err != nil { + // bad data, skip + r.log.Debug("failed to build transition", "error", err, "entry", entry) + continue + } + + if !historian.ShouldRecordAnnotation(*transition) { + // skip non-annotation transition + continue + } + + annotationText, annotationData := historian.BuildAnnotationTextAndData( + historymodel.RuleMeta{ + Title: entry.RuleTitle, + }, + transition.State, + ) + + items = append(items, &annotations.ItemDTO{ + AlertID: entry.RuleID, + DashboardID: ac.Dashboards[entry.DashboardUID], + DashboardUID: &entry.DashboardUID, + PanelID: entry.PanelID, + NewState: entry.Current, + PrevState: entry.Previous, + Time: sample.T.UnixMilli(), + Text: annotationText, + Data: annotationData, + }) + } + + return items } func (r *LokiHistorianStore) GetTags(ctx context.Context, query *annotations.TagsQuery) (annotations.FindTagsResult, error) { return annotations.FindTagsResult{}, nil } +// util + +func getRule(ctx context.Context, sql db.DB, orgID int64, ruleID int64) (*ngmodels.AlertRule, error) { + rule := &ngmodels.AlertRule{OrgID: orgID, ID: ruleID} + err := sql.WithDbSession(ctx, func(sess *db.Session) error { + exists, err := sess.Get(rule) + if err != nil { + return err + } + if !exists { + return errMissingRule + } + return nil + }) + + return rule, err +} + +func hasAccess(entry historian.LokiEntry, resources accesscontrol.AccessResources) bool { + orgFilter := resources.CanAccessOrgAnnotations && entry.DashboardUID == "" + dashFilter := func() bool { + if !resources.CanAccessDashAnnotations { + return false + } + _, canAccess := resources.Dashboards[entry.DashboardUID] + return canAccess + } + + return orgFilter || dashFilter() +} + +type number interface { + constraints.Integer | constraints.Float +} + +// numericMap converts a simplejson map[string]any to a map[string]N, where N is numeric (int or float). +func numericMap[N number](j *simplejson.Json) (map[string]N, error) { + if j == nil { + return nil, fmt.Errorf("unexpected nil value") + } + + m, err := j.Map() + if err != nil { + return nil, err + } + + values := make(map[string]N) + for k, v := range m { + a, ok := (v).(json.Number) + if !ok { + return nil, fmt.Errorf("unexpected value type %T", v) + } + + f, err := a.Float64() + if err != nil { + return nil, err + } + + values[k] = N(f) + } + + return values, nil +} + +func buildTransition(entry historian.LokiEntry) (*state.StateTransition, error) { + curState, curStateReason, err := state.ParseFormattedState(entry.Current) + if err != nil { + return nil, fmt.Errorf("parsing current state: %w", err) + } + + prevState, prevReason, err := state.ParseFormattedState(entry.Previous) + if err != nil { + return nil, fmt.Errorf("parsing previous state: %w", err) + } + + v, err := numericMap[float64](entry.Values) + if err != nil { + return nil, fmt.Errorf("parsing entry values: %w", err) + } + + return &state.StateTransition{ + State: &state.State{ + State: curState, + StateReason: curStateReason, + Values: v, + Labels: entry.InstanceLabels, + }, + PreviousState: prevState, + PreviousStateReason: prevReason, + }, nil +} + +func buildHistoryQuery(query *annotations.ItemQuery, dashboards map[string]int64, ruleUID string) ngmodels.HistoryQuery { + historyQuery := ngmodels.HistoryQuery{ + OrgID: query.OrgID, + DashboardUID: query.DashboardUID, + PanelID: query.PanelID, + RuleUID: ruleUID, + } + + if historyQuery.DashboardUID == "" && query.DashboardID != 0 { + for uid, id := range dashboards { + if query.DashboardID == id { + historyQuery.DashboardUID = uid + break + } + } + } + + return historyQuery +} + func useStore(cfg setting.UnifiedAlertingStateHistorySettings, ft featuremgmt.FeatureToggles) bool { if !cfg.Enabled { return false @@ -62,7 +295,7 @@ func useStore(cfg setting.UnifiedAlertingStateHistorySettings, ft featuremgmt.Fe // Override config based on feature toggles. // We pass in a no-op logger here since this function is also called during ngalert init, - // and we don't want to log the same problem twice. + // and we don't want to log the same info twice. ngalert.ApplyStateHistoryFeatureToggles(&cfg, ft, log.NewNopLogger()) backend, err := historian.ParseBackendType(cfg.Backend) @@ -70,6 +303,7 @@ func useStore(cfg setting.UnifiedAlertingStateHistorySettings, ft featuremgmt.Fe return false } - // We should only query Loki if annotations do no exist in the database. - return backend == historian.BackendTypeLoki + // We should only query Loki if annotations do not exist in the database. + // To be doubly sure, ensure that the feature toggle to only use Loki is enabled. + return backend == historian.BackendTypeLoki && ft.IsEnabledGlobally(featuremgmt.FlagAlertStateHistoryLokiOnly) } diff --git a/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go b/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go index 8587f5f32d23b..56b999da54f0c 100644 --- a/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go +++ b/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go @@ -1,13 +1,807 @@ package loki import ( + "context" + "encoding/json" + "errors" + "math/rand" + "net/url" + "strconv" + "sync" "testing" + "time" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/annotations" + annotation_ac "github.com/grafana/grafana/pkg/services/annotations/accesscontrol" + "github.com/grafana/grafana/pkg/services/annotations/testutil" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/ngalert/client" + "github.com/grafana/grafana/pkg/services/ngalert/eval" + "github.com/grafana/grafana/pkg/services/ngalert/metrics" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/state" + "github.com/grafana/grafana/pkg/services/ngalert/state/historian" + historymodel "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func TestIntegrationAlertStateHistoryStore(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + sql := db.InitTestDB(t) + + dashboard1 := testutil.CreateDashboard(t, sql, featuremgmt.WithFeatures(), dashboards.SaveDashboardCommand{ + UserID: 1, + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]any{ + "title": "Dashboard 1", + }), + }) + + dashboard2 := testutil.CreateDashboard(t, sql, featuremgmt.WithFeatures(), dashboards.SaveDashboardCommand{ + UserID: 1, + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]any{ + "title": "Dashboard 2", + }), + }) + + knownUIDs := &sync.Map{} + generator := ngmodels.AlertRuleGen( + ngmodels.WithUniqueUID(knownUIDs), + ngmodels.WithUniqueID(), + ngmodels.WithOrgID(1), + ) + + dashboardRules := map[string][]*ngmodels.AlertRule{ + dashboard1.UID: { + createAlertRuleFromDashboard(t, sql, "Test Rule 1", *dashboard1, generator), + createAlertRuleFromDashboard(t, sql, "Test Rule 2", *dashboard1, generator), + }, + dashboard2.UID: { + createAlertRuleFromDashboard(t, sql, "Test Rule 3", *dashboard2, generator), + }, + } + + t.Run("Testing Loki state history read", func(t *testing.T) { + start := time.Now() + numTransitions := 2 + transitions := genStateTransitions(t, numTransitions, start) + + fakeLokiClient := NewFakeLokiClient() + store := createTestLokiStore(t, sql, fakeLokiClient) + + t.Run("can query history by alert id", func(t *testing.T) { + rule := dashboardRules[dashboard1.UID][0] + + fakeLokiClient.rangeQueryRes = []historian.Stream{ + historian.StatesToStream(ruleMetaFromRule(t, rule), transitions, map[string]string{}, log.NewNopLogger()), + } + + query := annotations.ItemQuery{ + OrgID: 1, + AlertID: rule.ID, + From: start.UnixMilli(), + To: start.Add(time.Second * time.Duration(numTransitions+1)).UnixMilli(), + } + res, err := store.Get( + context.Background(), + &query, + &annotation_ac.AccessResources{ + Dashboards: map[string]int64{ + dashboard1.UID: dashboard1.ID, + }, + CanAccessDashAnnotations: true, + }, + ) + require.NoError(t, err) + require.Len(t, res, numTransitions) + }) + + t.Run("can query history by dashboard id", func(t *testing.T) { + fakeLokiClient.rangeQueryRes = []historian.Stream{ + historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][0]), transitions, map[string]string{}, log.NewNopLogger()), + historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][1]), transitions, map[string]string{}, log.NewNopLogger()), + } + + query := annotations.ItemQuery{ + OrgID: 1, + DashboardID: dashboard1.ID, + From: start.UnixMilli(), + To: start.Add(time.Second * time.Duration(numTransitions+1)).UnixMilli(), + } + res, err := store.Get( + context.Background(), + &query, + &annotation_ac.AccessResources{ + Dashboards: map[string]int64{ + dashboard1.UID: dashboard1.ID, + }, + CanAccessDashAnnotations: true, + }, + ) + require.NoError(t, err) + require.Len(t, res, 2*numTransitions) + }) + + t.Run("should return empty results when type is annotation", func(t *testing.T) { + fakeLokiClient.rangeQueryRes = []historian.Stream{ + historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][0]), transitions, map[string]string{}, log.NewNopLogger()), + historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][1]), transitions, map[string]string{}, log.NewNopLogger()), + } + + query := annotations.ItemQuery{ + OrgID: 1, + Type: "annotation", + } + res, err := store.Get( + context.Background(), + &query, + &annotation_ac.AccessResources{ + Dashboards: map[string]int64{ + dashboard1.UID: dashboard1.ID, + }, + CanAccessDashAnnotations: true, + }, + ) + require.NoError(t, err) + require.Empty(t, res) + }) + + t.Run("should return empty results when history is outside time range", func(t *testing.T) { + fakeLokiClient.rangeQueryRes = []historian.Stream{ + historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][0]), transitions, map[string]string{}, log.NewNopLogger()), + historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][1]), transitions, map[string]string{}, log.NewNopLogger()), + } + + query := annotations.ItemQuery{ + OrgID: 1, + DashboardID: dashboard1.ID, + From: start.Add(-2 * time.Second).UnixMilli(), + To: start.Add(-1 * time.Second).UnixMilli(), + } + res, err := store.Get( + context.Background(), + &query, + &annotation_ac.AccessResources{ + Dashboards: map[string]int64{ + dashboard1.UID: dashboard1.ID, + }, + CanAccessDashAnnotations: true, + }, + ) + require.NoError(t, err) + require.Len(t, res, 0) + }) + + t.Run("should return partial results when history is partly outside clamped time range", func(t *testing.T) { + fakeLokiClient.rangeQueryRes = []historian.Stream{ + historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][0]), transitions, map[string]string{}, log.NewNopLogger()), + historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][1]), transitions, map[string]string{}, log.NewNopLogger()), + } + + // clamp time range to 1 second + oldMax := fakeLokiClient.cfg.MaxQueryLength + fakeLokiClient.cfg.MaxQueryLength = 1 * time.Second + + query := annotations.ItemQuery{ + OrgID: 1, + DashboardID: dashboard1.ID, + From: start.Add(-1 * time.Second).UnixMilli(), // should clamp to start + To: start.Add(1 * time.Second).UnixMilli(), + } + res, err := store.Get( + context.Background(), + &query, + &annotation_ac.AccessResources{ + Dashboards: map[string]int64{ + dashboard1.UID: dashboard1.ID, + }, + CanAccessDashAnnotations: true, + }, + ) + require.NoError(t, err) + require.Len(t, res, 2) + + // restore original max query length + fakeLokiClient.cfg.MaxQueryLength = oldMax + }) + + t.Run("should sort history by time", func(t *testing.T) { + fakeLokiClient.rangeQueryRes = []historian.Stream{ + historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][0]), transitions, map[string]string{}, log.NewNopLogger()), + historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][1]), transitions, map[string]string{}, log.NewNopLogger()), + } + + query := annotations.ItemQuery{ + OrgID: 1, + DashboardID: dashboard1.ID, + From: start.UnixMilli(), + To: start.Add(time.Second * time.Duration(numTransitions+1)).UnixMilli(), + } + res, err := store.Get( + context.Background(), + &query, + &annotation_ac.AccessResources{ + Dashboards: map[string]int64{ + dashboard1.UID: dashboard1.ID, + }, + CanAccessDashAnnotations: true, + }, + ) + require.NoError(t, err) + require.Len(t, res, 2*numTransitions) + + var lastTime int64 + for _, item := range res { + if lastTime != 0 { + require.True(t, item.Time <= lastTime) + } + lastTime = item.Time + } + }) + }) + + t.Run("Testing items from Loki stream", func(t *testing.T) { + fakeLokiClient := NewFakeLokiClient() + store := createTestLokiStore(t, sql, fakeLokiClient) + + t.Run("should return empty list when no streams", func(t *testing.T) { + items := store.annotationsFromStream(historian.Stream{}, annotation_ac.AccessResources{}) + require.Empty(t, items) + }) + + t.Run("should return empty list when no entries", func(t *testing.T) { + items := store.annotationsFromStream(historian.Stream{ + Values: []historian.Sample{}, + }, annotation_ac.AccessResources{}) + require.Empty(t, items) + }) + + t.Run("should return one annotation per sample", func(t *testing.T) { + rule := dashboardRules[dashboard1.UID][0] + start := time.Now() + numTransitions := 2 + transitions := genStateTransitions(t, numTransitions, start) + + stream := historian.StatesToStream(ruleMetaFromRule(t, rule), transitions, map[string]string{}, log.NewNopLogger()) + + items := store.annotationsFromStream(stream, annotation_ac.AccessResources{ + Dashboards: map[string]int64{ + dashboard1.UID: dashboard1.ID, + }, + CanAccessDashAnnotations: true, + }) + require.Len(t, items, numTransitions) + + for i := 0; i < numTransitions; i++ { + item := items[i] + transition := transitions[i] + + expected := &annotations.ItemDTO{ + AlertID: rule.ID, + DashboardID: dashboard1.ID, + DashboardUID: &dashboard1.UID, + PanelID: *rule.PanelID, + Time: transition.State.LastEvaluationTime.UnixMilli(), + NewState: transition.Formatted(), + } + if i > 0 { + prevTransition := transitions[i-1] + expected.PrevState = prevTransition.Formatted() + } + + compareAnnotationItem(t, expected, item) + } + }) + + t.Run("should filter out annotations from dashboards not in scope", func(t *testing.T) { + start := time.Now() + numTransitions := 2 + transitions := genStateTransitions(t, numTransitions, start) + + rule := dashboardRules[dashboard1.UID][0] + stream1 := historian.StatesToStream(ruleMetaFromRule(t, rule), transitions, map[string]string{}, log.NewNopLogger()) + + rule = createAlertRule(t, sql, "Test rule", generator) + stream2 := historian.StatesToStream(ruleMetaFromRule(t, rule), transitions, map[string]string{}, log.NewNopLogger()) + + stream := historian.Stream{ + Values: append(stream1.Values, stream2.Values...), + Stream: stream1.Stream, + } + + items := store.annotationsFromStream(stream, annotation_ac.AccessResources{ + Dashboards: map[string]int64{ + dashboard1.UID: dashboard1.ID, + }, + CanAccessDashAnnotations: true, + }) + require.Len(t, items, numTransitions) + + for _, item := range items { + require.Equal(t, dashboard1.ID, item.DashboardID) + require.Equal(t, dashboard1.UID, *item.DashboardUID) + } + }) + + t.Run("should include only annotations without linked dashboard on org scope", func(t *testing.T) { + start := time.Now() + numTransitions := 2 + transitions := genStateTransitions(t, numTransitions, start) + + rule := dashboardRules[dashboard1.UID][0] + stream1 := historian.StatesToStream(ruleMetaFromRule(t, rule), transitions, map[string]string{}, log.NewNopLogger()) + + rule.DashboardUID = nil + stream2 := historian.StatesToStream(ruleMetaFromRule(t, rule), transitions, map[string]string{}, log.NewNopLogger()) + + stream := historian.Stream{ + Values: append(stream1.Values, stream2.Values...), + Stream: stream1.Stream, + } + + items := store.annotationsFromStream(stream, annotation_ac.AccessResources{ + Dashboards: map[string]int64{ + dashboard1.UID: dashboard1.ID, + }, + CanAccessOrgAnnotations: true, + }) + require.Len(t, items, numTransitions) + + for _, item := range items { + require.Zero(t, *item.DashboardUID) + require.Zero(t, item.DashboardID) + } + }) + }) +} + +func TestHasAccess(t *testing.T) { + entry := historian.LokiEntry{ + DashboardUID: "dashboard-uid", + } + + t.Run("should return false when scope is organization and entry has dashboard UID", func(t *testing.T) { + require.False(t, hasAccess(entry, annotation_ac.AccessResources{ + CanAccessOrgAnnotations: true, + })) + }) + + t.Run("should return false when scope is dashboard and dashboard UID is not in resources", func(t *testing.T) { + require.False(t, hasAccess(entry, annotation_ac.AccessResources{ + CanAccessDashAnnotations: true, + Dashboards: map[string]int64{ + "other-dashboard-uid": 1, + }, + })) + }) + + t.Run("should return true when scope is organization and entry has no dashboard UID", func(t *testing.T) { + require.True(t, hasAccess(historian.LokiEntry{}, annotation_ac.AccessResources{ + CanAccessOrgAnnotations: true, + })) + }) + + t.Run("should return true when scope is dashboard and dashboard UID is in resources", func(t *testing.T) { + require.True(t, hasAccess(entry, annotation_ac.AccessResources{ + CanAccessDashAnnotations: true, + Dashboards: map[string]int64{ + "dashboard-uid": 1, + }, + })) + }) +} + +func TestNumericMap(t *testing.T) { + t.Run("should return error for nil value", func(t *testing.T) { + var jsonMap *simplejson.Json + _, err := numericMap[float64](jsonMap) + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected nil value") + }) + + t.Run("should return error for nil interface value", func(t *testing.T) { + jsonMap := simplejson.NewFromAny(map[string]any{ + "key1": nil, + }) + _, err := numericMap[float64](jsonMap) + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected value type") + }) + + t.Run(`should convert json string:float kv to Golang map[string]float64`, func(t *testing.T) { + jsonMap := simplejson.NewFromAny(map[string]any{ + "key1": json.Number("1.0"), + "key2": json.Number("2.0"), + }) + + golangMap, err := numericMap[float64](jsonMap) + require.NoError(t, err) + + require.Equal(t, map[string]float64{ + "key1": 1.0, + "key2": 2.0, + }, golangMap) + }) + + t.Run("should return error when json map contains non-float values", func(t *testing.T) { + jsonMap := simplejson.NewFromAny(map[string]any{ + "key1": json.Number("1.0"), + "key2": "not a float", + }) + + _, err := numericMap[float64](jsonMap) + require.Error(t, err) + }) +} + +func TestBuildHistoryQuery(t *testing.T) { + t.Run("should set dashboard UID from dashboard ID if query does not contain UID", func(t *testing.T) { + query := buildHistoryQuery( + &annotations.ItemQuery{ + DashboardID: 1, + }, + map[string]int64{ + "dashboard-uid": 1, + }, + "rule-uid", + ) + require.Equal(t, "dashboard-uid", query.DashboardUID) + }) + + t.Run("should skip dashboard UID if missing from query and dashboard map", func(t *testing.T) { + query := buildHistoryQuery( + &annotations.ItemQuery{ + DashboardID: 1, + }, + map[string]int64{ + "other-dashboard-uid": 2, + }, + "rule-uid", + ) + require.Zero(t, query.DashboardUID) + }) + + t.Run("should skip dashboard UID when not in query", func(t *testing.T) { + query := buildHistoryQuery( + &annotations.ItemQuery{}, + map[string]int64{ + "dashboard-uid": 1, + }, + "rule-uid", + ) + require.Zero(t, query.DashboardUID) + }) +} + +func TestBuildTransition(t *testing.T) { + t.Run("should return error when entry contains invalid state strings", func(t *testing.T) { + _, err := buildTransition(historian.LokiEntry{ + Current: "Invalid", + }) + require.Error(t, err) + + _, err = buildTransition(historian.LokiEntry{ + Current: "Normal", + Previous: "Invalid", + }) + require.Error(t, err) + }) + + t.Run("should return error when values are not numbers", func(t *testing.T) { + _, err := buildTransition(historian.LokiEntry{ + Current: "Normal", + Values: simplejson.NewFromAny(map[string]any{"key1": "not a float"}), + }) + require.Error(t, err) + }) + + t.Run("should build transition correctly", func(t *testing.T) { + values := map[string]float64{ + "key1": 1.0, + "key2": 2.0, + } + + labels := map[string]string{ + "key1": "value1", + "key2": "value2", + } + + jsonValues := simplejson.New() + for k, v := range values { + jsonValues.Set(k, json.Number(strconv.FormatFloat(v, 'f', -1, 64))) + } + + entry := historian.LokiEntry{ + Current: "Normal", + Previous: "Error (NoData)", + Values: jsonValues, + InstanceLabels: labels, + } + + expected := &state.StateTransition{ + State: &state.State{ + State: eval.Normal, + StateReason: "", + LastEvaluationTime: time.Time{}, + Values: values, + Labels: labels, + }, + PreviousState: eval.Error, + PreviousStateReason: eval.NoData.String(), + } + + stub, err := buildTransition(entry) + + require.NoError(t, err) + require.Equal(t, expected, stub) + }) +} + +func createTestLokiStore(t *testing.T, sql db.DB, client lokiQueryClient) *LokiHistorianStore { + t.Helper() + + return &LokiHistorianStore{ + client: client, + db: sql, + log: log.NewNopLogger(), + } +} + +// createAlertRule creates an alert rule in the database and returns it. +// If a generator is not specified, uniqueness of primary key is not guaranteed. +func createAlertRule(t *testing.T, sql db.DB, title string, generator func() *ngmodels.AlertRule) *ngmodels.AlertRule { + t.Helper() + + if generator == nil { + generator = ngmodels.AlertRuleGen(ngmodels.WithTitle(title), withDashboardUID(nil), withPanelID(nil), ngmodels.WithOrgID(1)) + } + + rule := generator() + // ensure rule has correct values + if rule.Title != title { + rule.Title = title + } + // rule should not have linked dashboard or panel + rule.DashboardUID = nil + rule.PanelID = nil + + err := sql.WithDbSession(context.Background(), func(sess *db.Session) error { + _, err := sess.Table(ngmodels.AlertRule{}).InsertOne(rule) + if err != nil { + return err + } + + dbRule := &ngmodels.AlertRule{} + exist, err := sess.Table(ngmodels.AlertRule{}).ID(rule.ID).Get(dbRule) + if err != nil { + return err + } + if !exist { + return errors.New("cannot read inserted record") + } + rule = dbRule + + return nil + }) + require.NoError(t, err) + + return rule +} + +// createAlertRuleFromDashboard creates an alert rule with a linked dashboard and panel in the database and returns it. +// If a generator is not specified, uniqueness of primary key is not guaranteed. +func createAlertRuleFromDashboard(t *testing.T, sql db.DB, title string, dashboard dashboards.Dashboard, generator func() *ngmodels.AlertRule) *ngmodels.AlertRule { + t.Helper() + + panelID := new(int64) + *panelID = 123 + + if generator == nil { + generator = ngmodels.AlertRuleGen(ngmodels.WithTitle(title), ngmodels.WithOrgID(1), withDashboardUID(&dashboard.UID), withPanelID(panelID)) + } + + rule := generator() + // ensure rule has correct values + if rule.Title != title { + rule.Title = title + } + if rule.DashboardUID == nil || (rule.DashboardUID != nil && *rule.DashboardUID != dashboard.UID) { + rule.DashboardUID = &dashboard.UID + } + if rule.PanelID == nil || (rule.PanelID != nil && *rule.PanelID != *panelID) { + rule.PanelID = panelID + } + + err := sql.WithDbSession(context.Background(), func(sess *db.Session) error { + _, err := sess.Table(ngmodels.AlertRule{}).InsertOne(rule) + if err != nil { + return err + } + + dbRule := &ngmodels.AlertRule{} + exist, err := sess.Table(ngmodels.AlertRule{}).ID(rule.ID).Get(dbRule) + if err != nil { + return err + } + if !exist { + return errors.New("cannot read inserted record") + } + rule = dbRule + + return nil + }) + require.NoError(t, err) + + return rule +} + +func ruleMetaFromRule(t *testing.T, rule *ngmodels.AlertRule) historymodel.RuleMeta { + t.Helper() + + meta := historymodel.RuleMeta{ + OrgID: rule.OrgID, + UID: rule.UID, + ID: rule.ID, + } + + if rule.DashboardUID != nil { + meta.DashboardUID = *rule.DashboardUID + } + + if rule.PanelID != nil { + meta.PanelID = *rule.PanelID + } + + return meta +} + +func genStateTransitions(t *testing.T, num int, start time.Time) []state.StateTransition { + t.Helper() + + transitions := make([]state.StateTransition, 0, num) + lastState := state.State{ + State: eval.Normal, + StateReason: "", + LastEvaluationTime: start, + Values: map[string]float64{ + "key1": 1.0, + "key2": 2.0, + }, + Labels: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + } + + for i := 0; i < num; i++ { + stateVal := rand.Intn(4) + if stateVal == int(lastState.State) { + stateVal = (stateVal + 1) % 4 + } + + newState := state.State{ + State: eval.State(stateVal), + StateReason: "", + LastEvaluationTime: lastState.LastEvaluationTime.Add(time.Second * time.Duration(i)), + Values: lastState.Values, + Labels: lastState.Labels, + } + + transitions = append(transitions, state.StateTransition{ + PreviousState: lastState.State, + PreviousStateReason: lastState.StateReason, + State: &newState, + }) + + lastState = newState + } + + return transitions +} + +func withDashboardUID(dashboardUID *string) ngmodels.AlertRuleMutator { + return func(rule *ngmodels.AlertRule) { + rule.DashboardUID = dashboardUID + } +} + +func withPanelID(panelID *int64) ngmodels.AlertRuleMutator { + return func(rule *ngmodels.AlertRule) { + rule.PanelID = panelID + } +} + +func compareAnnotationItem(t *testing.T, expected, actual *annotations.ItemDTO) { + require.Equal(t, expected.AlertID, actual.AlertID) + require.Equal(t, expected.AlertName, actual.AlertName) + if expected.PanelID != 0 { + require.Equal(t, expected.PanelID, actual.PanelID) + } + if expected.DashboardUID != nil { + require.Equal(t, expected.DashboardID, actual.DashboardID) + require.Equal(t, *expected.DashboardUID, *actual.DashboardUID) + } + require.Equal(t, expected.NewState, actual.NewState) + if expected.PrevState != "" { + require.Equal(t, expected.PrevState, actual.PrevState) + } + require.Equal(t, expected.Time, actual.Time) + if expected.Text != "" && expected.Data != nil { + require.Equal(t, expected.Text, actual.Text) + require.Equal(t, expected.Data, actual.Data) + } +} + +type FakeLokiClient struct { + client client.Requester + cfg historian.LokiConfig + metrics *metrics.Historian + log log.Logger + rangeQueryRes []historian.Stream +} + +func NewFakeLokiClient() *FakeLokiClient { + url, _ := url.Parse("http://some.url") + req := historian.NewFakeRequester() + metrics := metrics.NewHistorianMetrics(prometheus.NewRegistry(), "annotations_test") + + return &FakeLokiClient{ + client: client.NewTimedClient(req, metrics.WriteDuration), + cfg: historian.LokiConfig{ + WritePathURL: url, + ReadPathURL: url, + Encoder: historian.JsonEncoder{}, + MaxQueryLength: 721 * time.Hour, + }, + metrics: metrics, + log: log.New("ngalert.state.historian", "backend", "loki"), + } +} + +func (c *FakeLokiClient) RangeQuery(ctx context.Context, query string, from, to, limit int64) (historian.QueryRes, error) { + streams := make([]historian.Stream, len(c.rangeQueryRes)) + + // clamp time range using logic from historian + from, to = historian.ClampRange(from, to, c.cfg.MaxQueryLength.Nanoseconds()) + + for n, stream := range c.rangeQueryRes { + streams[n].Stream = stream.Stream + streams[n].Values = []historian.Sample{} + for _, sample := range stream.Values { + if sample.T.UnixNano() < from || sample.T.UnixNano() >= to { // matches Loki behavior + continue + } + streams[n].Values = append(streams[n].Values, sample) + } + } + + res := historian.QueryRes{ + Data: historian.QueryData{ + Result: streams, + }, + } + + // reset expected streams on read + c.rangeQueryRes = []historian.Stream{} + return res, nil +} + func TestUseStore(t *testing.T) { t.Run("false if state history disabled", func(t *testing.T) { cfg := setting.UnifiedAlertingStateHistorySettings{ diff --git a/pkg/services/annotations/annotationsimpl/store.go b/pkg/services/annotations/annotationsimpl/store.go index 1a3d399141617..b1c3643a9c35d 100644 --- a/pkg/services/annotations/annotationsimpl/store.go +++ b/pkg/services/annotations/annotationsimpl/store.go @@ -14,12 +14,18 @@ type store interface { writeStore } +type commonStore interface { + Type() string +} + type readStore interface { + commonStore Get(ctx context.Context, query *annotations.ItemQuery, accessResources *accesscontrol.AccessResources) ([]*annotations.ItemDTO, error) GetTags(ctx context.Context, query *annotations.TagsQuery) (annotations.FindTagsResult, error) } type writeStore interface { + commonStore Add(ctx context.Context, items *annotations.Item) error AddMany(ctx context.Context, items []annotations.Item) error Update(ctx context.Context, item *annotations.Item) error diff --git a/pkg/services/annotations/annotationsimpl/xorm_store.go b/pkg/services/annotations/annotationsimpl/xorm_store.go index e7072f2f0520b..6ae9ddc3ce1bd 100644 --- a/pkg/services/annotations/annotationsimpl/xorm_store.go +++ b/pkg/services/annotations/annotationsimpl/xorm_store.go @@ -10,6 +10,7 @@ import ( "time" "github.com/grafana/grafana/pkg/services/annotations/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" @@ -53,6 +54,10 @@ func NewXormStore(cfg *setting.Cfg, l log.Logger, db db.DB, tagService tag.Servi } } +func (r *xormRepositoryImpl) Type() string { + return "sql" +} + func (r *xormRepositoryImpl) Add(ctx context.Context, item *annotations.Item) error { tags := tag.ParseTagPairs(item.Tags) item.Tags = tag.JoinTagPairs(tags) @@ -519,10 +524,20 @@ func (r *xormRepositoryImpl) CleanAnnotations(ctx context.Context, cfg setting.A var totalAffected int64 if cfg.MaxAge > 0 { cutoffDate := timeNow().Add(-cfg.MaxAge).UnixNano() / int64(time.Millisecond) - deleteQuery := `DELETE FROM annotation WHERE id IN (SELECT id FROM (SELECT id FROM annotation WHERE %s AND created < %v ORDER BY id DESC %s) a)` - sql := fmt.Sprintf(deleteQuery, annotationType, cutoffDate, r.db.GetDialect().Limit(r.cfg.AnnotationCleanupJobBatchSize)) + // Single-statement approaches, specifically ones using batched sub-queries, seem to deadlock with concurrent inserts on MySQL. + // We have a bounded batch size, so work around this by first loading the IDs into memory and allowing any locks to flush inside each batch. + // This may under-delete when concurrent inserts happen, but any such annotations will simply be cleaned on the next cycle. + // + // We execute the following batched operation repeatedly until either we run out of objects, the context is cancelled, or there is an error. + affected, err := untilDoneOrCancelled(ctx, func() (int64, error) { + cond := fmt.Sprintf(`%s AND created < %v ORDER BY id DESC %s`, annotationType, cutoffDate, r.db.GetDialect().Limit(r.cfg.AnnotationCleanupJobBatchSize)) + ids, err := r.fetchIDs(ctx, "annotation", cond) + if err != nil { + return 0, err + } - affected, err := r.executeUntilDoneOrCancelled(ctx, sql) + return r.deleteByIDs(ctx, "annotation", ids) + }) totalAffected += affected if err != nil { return totalAffected, err @@ -530,41 +545,105 @@ func (r *xormRepositoryImpl) CleanAnnotations(ctx context.Context, cfg setting.A } if cfg.MaxCount > 0 { - deleteQuery := `DELETE FROM annotation WHERE id IN (SELECT id FROM (SELECT id FROM annotation WHERE %s ORDER BY id DESC %s) a)` - sql := fmt.Sprintf(deleteQuery, annotationType, r.db.GetDialect().LimitOffset(r.cfg.AnnotationCleanupJobBatchSize, cfg.MaxCount)) - affected, err := r.executeUntilDoneOrCancelled(ctx, sql) + // Similar strategy as the above cleanup process, to avoid deadlocks. + affected, err := untilDoneOrCancelled(ctx, func() (int64, error) { + cond := fmt.Sprintf(`%s ORDER BY id DESC %s`, annotationType, r.db.GetDialect().LimitOffset(r.cfg.AnnotationCleanupJobBatchSize, cfg.MaxCount)) + ids, err := r.fetchIDs(ctx, "annotation", cond) + if err != nil { + return 0, err + } + + return r.deleteByIDs(ctx, "annotation", ids) + }) totalAffected += affected - return totalAffected, err + if err != nil { + return totalAffected, err + } } return totalAffected, nil } func (r *xormRepositoryImpl) CleanOrphanedAnnotationTags(ctx context.Context) (int64, error) { - deleteQuery := `DELETE FROM annotation_tag WHERE id IN ( SELECT id FROM (SELECT id FROM annotation_tag WHERE NOT EXISTS (SELECT 1 FROM annotation a WHERE annotation_id = a.id) %s) a)` - sql := fmt.Sprintf(deleteQuery, r.db.GetDialect().Limit(r.cfg.AnnotationCleanupJobBatchSize)) - return r.executeUntilDoneOrCancelled(ctx, sql) + return untilDoneOrCancelled(ctx, func() (int64, error) { + cond := fmt.Sprintf(`NOT EXISTS (SELECT 1 FROM annotation a WHERE annotation_id = a.id) %s`, r.db.GetDialect().Limit(r.cfg.AnnotationCleanupJobBatchSize)) + ids, err := r.fetchIDs(ctx, "annotation_tag", cond) + if err != nil { + return 0, err + } + + return r.deleteByIDs(ctx, "annotation_tag", ids) + }) +} + +func (r *xormRepositoryImpl) fetchIDs(ctx context.Context, table, condition string) ([]int64, error) { + sql := fmt.Sprintf(`SELECT id FROM %s`, table) + if condition == "" { + return nil, fmt.Errorf("condition must be supplied; cannot fetch IDs from entire table") + } + sql += fmt.Sprintf(` WHERE %s`, condition) + ids := make([]int64, 0) + err := r.db.WithDbSession(ctx, func(session *db.Session) error { + return session.SQL(sql).Find(&ids) + }) + return ids, err } -func (r *xormRepositoryImpl) executeUntilDoneOrCancelled(ctx context.Context, sql string) (int64, error) { +func (r *xormRepositoryImpl) deleteByIDs(ctx context.Context, table string, ids []int64) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + + sql := "" + args := make([]any, 0) + + // SQLite has a parameter limit of 999. + // If the batch size is bigger than that, and we're on SQLite, we have to put the IDs directly into the statement. + const sqliteParameterLimit = 999 + if r.db.GetDBType() == migrator.SQLite && r.cfg.AnnotationCleanupJobBatchSize > sqliteParameterLimit { + values := fmt.Sprint(ids[0]) + for _, v := range ids[1:] { + values = fmt.Sprintf("%s, %d", values, v) + } + sql = fmt.Sprintf(`DELETE FROM %s WHERE id IN (%s)`, table, values) + } else { + placeholders := "?" + strings.Repeat(",?", len(ids)-1) + sql = fmt.Sprintf(`DELETE FROM %s WHERE id IN (%s)`, table, placeholders) + args = asAny(ids) + } + + var affected int64 + err := r.db.WithDbSession(ctx, func(session *db.Session) error { + res, err := session.Exec(append([]any{sql}, args...)...) + if err != nil { + return err + } + affected, err = res.RowsAffected() + return err + }) + return affected, err +} + +func asAny(vs []int64) []any { + r := make([]any, len(vs)) + for i, v := range vs { + r[i] = v + } + return r +} + +// untilDoneOrCancelled repeatedly executes batched work until that work is either done (i.e., returns zero affected objects), +// a batch produces an error, or the provided context is cancelled. +// The work to be done is given as a callback that returns the number of affected objects for each batch, plus that batch's errors. +func untilDoneOrCancelled(ctx context.Context, batchWork func() (int64, error)) (int64, error) { var totalAffected int64 for { select { case <-ctx.Done(): return totalAffected, ctx.Err() default: - var affected int64 - err := r.db.WithDbSession(ctx, func(session *db.Session) error { - res, err := session.Exec(sql) - if err != nil { - return err - } - - affected, err = res.RowsAffected() - totalAffected += affected - - return err - }) + affected, err := batchWork() + totalAffected += affected if err != nil { return totalAffected, err } diff --git a/pkg/services/annotations/testutil/testutil.go b/pkg/services/annotations/testutil/testutil.go index aa340cb4f1b2c..2e24e3a2c9b13 100644 --- a/pkg/services/annotations/testutil/testutil.go +++ b/pkg/services/annotations/testutil/testutil.go @@ -60,9 +60,9 @@ func SetupRBACPermission(t *testing.T, db *sqlstore.SQLStore, role *accesscontro var acPermission []accesscontrol.Permission for action, scopes := range user.Permissions[user.OrgID] { for _, scope := range scopes { - acPermission = append(acPermission, accesscontrol.Permission{ - RoleID: role.ID, Action: action, Scope: scope, Created: time.Now(), Updated: time.Now(), - }) + p := accesscontrol.Permission{RoleID: role.ID, Action: action, Scope: scope, Created: time.Now(), Updated: time.Now()} + p.Kind, p.Attribute, p.Identifier = p.SplitScope() + acPermission = append(acPermission, p) } } @@ -90,6 +90,7 @@ func CreateDashboard(t *testing.T, sql *sqlstore.SQLStore, features featuremgmt. dash, err := dashboardStore.SaveDashboard(context.Background(), cmd) require.NoError(t, err) + require.NotNil(t, dash) return dash } diff --git a/pkg/services/anonymous/anonimpl/anonstore/database.go b/pkg/services/anonymous/anonimpl/anonstore/database.go index 8c5ed04db2734..34470936217d0 100644 --- a/pkg/services/anonymous/anonimpl/anonstore/database.go +++ b/pkg/services/anonymous/anonimpl/anonstore/database.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/search/model" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" ) @@ -32,6 +33,30 @@ type Device struct { UpdatedAt time.Time `json:"updatedAt" xorm:"updated_at" db:"updated_at"` } +type DeviceSearchHitDTO struct { + DeviceID string `json:"deviceId" xorm:"device_id" db:"device_id"` + ClientIP string `json:"clientIp" xorm:"client_ip" db:"client_ip"` + UserAgent string `json:"userAgent" xorm:"user_agent" db:"user_agent"` + CreatedAt time.Time `json:"createdAt" xorm:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" xorm:"updated_at" db:"updated_at"` + LastSeenAt time.Time `json:"lastSeenAt"` +} + +type SearchDeviceQueryResult struct { + TotalCount int64 `json:"totalCount"` + Devices []*DeviceSearchHitDTO `json:"devices"` + Page int `json:"page"` + PerPage int `json:"perPage"` +} +type SearchDeviceQuery struct { + Query string + Page int + Limit int + From time.Time + To time.Time + SortOpts []model.SortOption +} + func (a *Device) CacheKey() string { return strings.Join([]string{cacheKeyPrefix, a.DeviceID}, ":") } @@ -47,6 +72,8 @@ type AnonStore interface { DeleteDevice(ctx context.Context, deviceID string) error // DeleteDevicesOlderThan deletes all devices that have no been updated since the given time. DeleteDevicesOlderThan(ctx context.Context, olderThan time.Time) error + // SearchDevices searches for devices within the 30 days active. + SearchDevices(ctx context.Context, query *SearchDeviceQuery) (*SearchDeviceQueryResult, error) } func ProvideAnonDBStore(sqlStore db.DB, deviceLimit int64) *AnonDBStore { @@ -183,3 +210,64 @@ func (s *AnonDBStore) DeleteDevicesOlderThan(ctx context.Context, olderThan time return err } + +func (s *AnonDBStore) SearchDevices(ctx context.Context, query *SearchDeviceQuery) (*SearchDeviceQueryResult, error) { + result := SearchDeviceQueryResult{ + Devices: make([]*DeviceSearchHitDTO, 0), + } + err := s.sqlStore.WithDbSession(ctx, func(dbSess *db.Session) error { + if query.From.IsZero() && !query.To.IsZero() { + return fmt.Errorf("from date must be set if to date is set") + } + if !query.From.IsZero() && query.To.IsZero() { + return fmt.Errorf("to date must be set if from date is set") + } + + // restricted only to last 30 days, if noting else specified + if query.From.IsZero() && query.To.IsZero() { + query.From = time.Now().Add(-anonymousDeviceExpiration) + query.To = time.Now() + } + + sess := dbSess.Table("anon_device").Alias("d") + + if query.Limit > 0 { + offset := query.Limit * (query.Page - 1) + sess.Limit(query.Limit, offset) + } + sess.Cols("d.id", "d.device_id", "d.client_ip", "d.user_agent", "d.updated_at") + + if len(query.SortOpts) > 0 { + for i := range query.SortOpts { + for j := range query.SortOpts[i].Filter { + sess.OrderBy(query.SortOpts[i].Filter[j].OrderBy()) + } + } + } else { + sess.Asc("d.user_agent") + } + + // add to query about from and to session + sess.Where("d.updated_at BETWEEN ? AND ?", query.From.UTC(), query.To.UTC()) + + if query.Query != "" { + queryWithWildcards := "%" + strings.Replace(query.Query, "\\", "", -1) + "%" + sess.Where("d.client_ip "+s.sqlStore.GetDialect().LikeStr()+" ?", queryWithWildcards) + } + + // get total + devices, err := s.ListDevices(ctx, &query.From, &query.To) + if err != nil { + return err + } + // cast to int64 + result.TotalCount = int64(len(devices)) + if err := sess.Find(&result.Devices); err != nil { + return err + } + result.Page = query.Page + result.PerPage = query.Limit + return nil + }) + return &result, err +} diff --git a/pkg/services/anonymous/anonimpl/anonstore/database_test.go b/pkg/services/anonymous/anonimpl/anonstore/database_test.go index 0da29d5f6b701..54e625662ede8 100644 --- a/pkg/services/anonymous/anonimpl/anonstore/database_test.go +++ b/pkg/services/anonymous/anonimpl/anonstore/database_test.go @@ -9,8 +9,13 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationAnonStore_DeleteDevicesOlderThan(t *testing.T) { store := db.InitTestDB(t) anonDBStore := ProvideAnonDBStore(store, 0) diff --git a/pkg/services/anonymous/anonimpl/anonstore/fake.go b/pkg/services/anonymous/anonimpl/anonstore/fake.go index 9c7249d37bf65..8db978d309e1d 100644 --- a/pkg/services/anonymous/anonimpl/anonstore/fake.go +++ b/pkg/services/anonymous/anonimpl/anonstore/fake.go @@ -19,3 +19,7 @@ func (s *FakeAnonStore) CreateOrUpdateDevice(ctx context.Context, device *Device func (s *FakeAnonStore) CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) { return 0, nil } + +func (s *FakeAnonStore) SearchDevices(ctx context.Context, query SearchDeviceQuery) (*SearchDeviceQueryResult, error) { + return nil, nil +} diff --git a/pkg/services/anonymous/anonimpl/api/api.go b/pkg/services/anonymous/anonimpl/api/api.go index defdf28d17aa0..95774e1682b4d 100644 --- a/pkg/services/anonymous/anonimpl/api/api.go +++ b/pkg/services/anonymous/anonimpl/api/api.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore" + "github.com/grafana/grafana/pkg/services/anonymous/sortopts" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -50,6 +51,7 @@ func (api *AnonDeviceServiceAPI) RegisterAPIEndpoints() { auth := accesscontrol.Middleware(api.accesscontrol) api.RouterRegister.Group("/api/anonymous", func(anonRoutes routing.RouteRegister) { anonRoutes.Get("/devices", auth(accesscontrol.EvalPermission(accesscontrol.ActionUsersRead)), routing.Wrap(api.ListDevices)) + anonRoutes.Get("/search", auth(accesscontrol.EvalPermission(accesscontrol.ActionUsersRead)), routing.Wrap(api.SearchDevices)) }) } @@ -82,15 +84,67 @@ func (api *AnonDeviceServiceAPI) ListDevices(c *contextmodel.ReqContext) respons resDevices = append(resDevices, &deviceDTO{ Device: *device, LastSeenAt: util.GetAgeString(device.UpdatedAt), - AvatarUrl: dtos.GetGravatarUrl(device.DeviceID), + AvatarUrl: dtos.GetGravatarUrl(api.cfg, device.DeviceID), }) } return response.JSON(http.StatusOK, resDevices) } +// swagger:route POST /search devices SearchDevices +// +// # Lists all devices within the last 30 days +// +// Produces: +// - application/json +// +// Responses: +// +// 200: devicesSearchResponse +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError +// 500: internalServerError +func (api *AnonDeviceServiceAPI) SearchDevices(c *contextmodel.ReqContext) response.Response { + perPage := c.QueryInt("perpage") + if perPage <= 0 { + perPage = 100 + } + page := c.QueryInt("page") + + if page < 1 { + page = 1 + } + + searchQuery := c.Query("query") + + sortOpts, err := sortopts.ParseSortQueryParam(c.Query("sort")) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to list devices", err) + } + + // TODO: potential add from and to time to query + query := &anonstore.SearchDeviceQuery{ + Query: searchQuery, + Page: page, + Limit: perPage, + SortOpts: sortOpts, + } + results, err := api.store.SearchDevices(c.Req.Context(), query) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to list devices", err) + } + return response.JSON(http.StatusOK, results) +} + // swagger:response devicesResponse type DevicesResponse struct { // in:body Body []deviceDTO `json:"body"` } + +// swagger:response devicesSearchResponse +type DevicesSearchResponse struct { + // in:body + Body anonstore.SearchDeviceQueryResult `json:"body"` +} diff --git a/pkg/services/anonymous/anonimpl/impl.go b/pkg/services/anonymous/anonimpl/impl.go index 826be4d0a90ee..e1cc21415f72f 100644 --- a/pkg/services/anonymous/anonimpl/impl.go +++ b/pkg/services/anonymous/anonimpl/impl.go @@ -88,7 +88,7 @@ func (a *AnonDeviceService) tagDeviceUI(ctx context.Context, httpReq *http.Reque a.localCache.SetDefault(key, struct{}{}) - if setting.Env == setting.Dev { + if a.cfg.Env == setting.Dev { a.log.Debug("Tagging device for UI", "deviceID", device.DeviceID, "device", device, "key", key) } @@ -154,6 +154,7 @@ func (a *AnonDeviceService) TagDevice(ctx context.Context, httpReq *http.Request // ListDevices returns all devices that have been updated between the given times. func (a *AnonDeviceService) ListDevices(ctx context.Context, from *time.Time, to *time.Time) ([]*anonstore.Device, error) { if !a.cfg.AnonymousEnabled { + a.log.Debug("Anonymous access is disabled, returning empty result") return []*anonstore.Device{}, nil } @@ -163,12 +164,21 @@ func (a *AnonDeviceService) ListDevices(ctx context.Context, from *time.Time, to // CountDevices returns the number of devices that have been updated between the given times. func (a *AnonDeviceService) CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) { if !a.cfg.AnonymousEnabled { + a.log.Debug("Anonymous access is disabled, returning empty result") return 0, nil } return a.anonStore.CountDevices(ctx, from, to) } +func (a *AnonDeviceService) SearchDevices(ctx context.Context, query *anonstore.SearchDeviceQuery) (*anonstore.SearchDeviceQueryResult, error) { + if !a.cfg.AnonymousEnabled { + a.log.Debug("Anonymous access is disabled, returning empty result") + return nil, nil + } + return a.anonStore.SearchDevices(ctx, query) +} + func (a *AnonDeviceService) Run(ctx context.Context) error { ticker := time.NewTicker(2 * time.Hour) diff --git a/pkg/services/anonymous/anonimpl/impl_test.go b/pkg/services/anonymous/anonimpl/impl_test.go index 295654f93bc84..a84e913f3b1ae 100644 --- a/pkg/services/anonymous/anonimpl/impl_test.go +++ b/pkg/services/anonymous/anonimpl/impl_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/authn/authntest" "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationDeviceService_tag(t *testing.T) { type tagReq struct { httpReq *http.Request @@ -177,3 +182,92 @@ func TestIntegrationAnonDeviceService_localCacheSafety(t *testing.T) { assert.Equal(t, int64(0), stats["stats.anonymous.device.ui.count"].(int64)) } + +func TestIntegrationDeviceService_SearchDevice(t *testing.T) { + fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) // Fixed timestamp for testing + + testCases := []struct { + name string + insertDevices []*anonstore.Device + searchQuery anonstore.SearchDeviceQuery + expectedCount int + expectedDevice *anonstore.Device + }{ + { + name: "two devices and limit set to 1", + insertDevices: []*anonstore.Device{ + { + DeviceID: "32mdo31deeqwes", + ClientIP: "", + UserAgent: "test", + }, + { + DeviceID: "32mdo31deeqwes2", + ClientIP: "", + UserAgent: "test2", + }, + }, + searchQuery: anonstore.SearchDeviceQuery{ + Query: "", + Page: 1, + Limit: 1, + From: fixedTime, + To: fixedTime.Add(1 * time.Hour), + }, + expectedCount: 1, + }, + { + name: "two devices and search for client ip 192.1", + insertDevices: []*anonstore.Device{ + { + DeviceID: "32mdo31deeqwes", + ClientIP: "192.168.0.2:10", + UserAgent: "", + }, + { + DeviceID: "32mdo31deeqwes2", + ClientIP: "192.268.1.3:200", + UserAgent: "", + }, + }, + searchQuery: anonstore.SearchDeviceQuery{ + Query: "192.1", + Page: 1, + Limit: 50, + From: fixedTime, + To: fixedTime.Add(1 * time.Hour), + }, + expectedCount: 1, + expectedDevice: &anonstore.Device{ + DeviceID: "32mdo31deeqwes", + ClientIP: "192.168.0.2:10", + UserAgent: "", + }, + }, + } + store := db.InitTestDB(t) + cfg := setting.NewCfg() + cfg.AnonymousEnabled = true + anonService := ProvideAnonymousDeviceService(&usagestats.UsageStatsMock{}, &authntest.FakeService{}, store, cfg, orgtest.NewOrgServiceFake(), nil, actest.FakeAccessControl{}, &routing.RouteRegisterImpl{}) + + for _, tc := range testCases { + err := store.Reset() + assert.NoError(t, err) + t.Run(tc.name, func(t *testing.T) { + for _, device := range tc.insertDevices { + device.CreatedAt = fixedTime.Add(-10 * time.Hour) // Use fixed time + device.UpdatedAt = fixedTime + err := anonService.anonStore.CreateOrUpdateDevice(context.Background(), device) + require.NoError(t, err) + } + + devices, err := anonService.anonStore.SearchDevices(context.Background(), &tc.searchQuery) + require.NoError(t, err) + require.Len(t, devices.Devices, tc.expectedCount) + if tc.expectedDevice != nil { + device := devices.Devices[0] + require.Equal(t, tc.expectedDevice.UserAgent, device.UserAgent) + } + }) + } +} diff --git a/pkg/services/anonymous/sortopts/sortopts.go b/pkg/services/anonymous/sortopts/sortopts.go new file mode 100644 index 0000000000000..4ebb16e4929c0 --- /dev/null +++ b/pkg/services/anonymous/sortopts/sortopts.go @@ -0,0 +1,97 @@ +package sortopts + +import ( + "fmt" + "sort" + "strings" + + "github.com/grafana/grafana/pkg/services/search/model" + "github.com/grafana/grafana/pkg/util/errutil" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var ( + // SortOptionsByQueryParam is a map to translate the "sort" query param values to SortOption(s) + SortOptionsByQueryParam = map[string]model.SortOption{ + "userAgent-asc": newSortOption("user_agent", false, 0), + "userAgent-desc": newSortOption("user_agent", true, 0), + "updatedAt-asc": newTimeSortOption("updated_at", false, 1), + "updatedAt-desc": newTimeSortOption("updated_at", true, 2), + } + + ErrorUnknownSortingOption = errutil.BadRequest("unknown sorting option") +) + +type Sorter struct { + Field string + LowerCase bool + Descending bool + WithTableName bool +} + +func (s Sorter) OrderBy() string { + orderBy := "anon_device." + if !s.WithTableName { + orderBy = "" + } + orderBy += s.Field + if s.LowerCase { + orderBy = fmt.Sprintf("LOWER(%v)", orderBy) + } + if s.Descending { + return orderBy + " DESC" + } + return orderBy + " ASC" +} + +func newSortOption(field string, desc bool, index int) model.SortOption { + direction := "asc" + description := ("A-Z") + if desc { + direction = "desc" + description = ("Z-A") + } + return model.SortOption{ + Name: fmt.Sprintf("%v-%v", field, direction), + DisplayName: fmt.Sprintf("%v (%v)", cases.Title(language.Und).String(field), description), + Description: fmt.Sprintf("Sort %v in an alphabetically %vending order", field, direction), + Index: index, + Filter: []model.SortOptionFilter{Sorter{Field: field, Descending: desc}}, + } +} + +func newTimeSortOption(field string, desc bool, index int) model.SortOption { + direction := "asc" + description := ("Oldest-Newest") + if desc { + direction = "desc" + description = ("Newest-Oldest") + } + return model.SortOption{ + Name: fmt.Sprintf("%v-%v", field, direction), + DisplayName: fmt.Sprintf("%v (%v)", cases.Title(language.Und).String(field), description), + Description: fmt.Sprintf("Sort %v by time in an %vending order", field, direction), + Index: index, + Filter: []model.SortOptionFilter{Sorter{Field: field, Descending: desc}}, + } +} + +// ParseSortQueryParam parses the "sort" query param and returns an ordered list of SortOption(s) +func ParseSortQueryParam(param string) ([]model.SortOption, error) { + opts := []model.SortOption{} + if param != "" { + optsStr := strings.Split(param, ",") + for i := range optsStr { + if opt, ok := SortOptionsByQueryParam[optsStr[i]]; !ok { + return nil, ErrorUnknownSortingOption.Errorf("%v option unknown", optsStr[i]) + } else { + opts = append(opts, opt) + } + } + sort.Slice(opts, func(i, j int) bool { + return opts[i].Index < opts[j].Index || (opts[i].Index == opts[j].Index && opts[i].Name < opts[j].Name) + }) + } + return opts, nil +} diff --git a/pkg/services/apikey/apikeyimpl/store_test.go b/pkg/services/apikey/apikeyimpl/store_test.go index 925e418d404d3..3b5089526cdaa 100644 --- a/pkg/services/apikey/apikeyimpl/store_test.go +++ b/pkg/services/apikey/apikeyimpl/store_test.go @@ -14,8 +14,13 @@ import ( "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type getStore func(db.DB) store type getApiKeysTestCase struct { diff --git a/pkg/services/grafana-apiserver/README.md b/pkg/services/apiserver/README.md similarity index 94% rename from pkg/services/grafana-apiserver/README.md rename to pkg/services/apiserver/README.md index 2a4ead58fd9e9..98df14b6dc1be 100644 --- a/pkg/services/grafana-apiserver/README.md +++ b/pkg/services/apiserver/README.md @@ -4,7 +4,6 @@ ```ini [feature_toggles] -grafanaAPIServer = true kubernetesPlaylists = true ``` @@ -51,6 +50,10 @@ data/grafana-apiserver └── hi.json ``` +## Enable aggregation + +See [aggregator/README.md](./aggregator/README.md) for more information. + ### `kubectl` access For kubectl to work, grafana needs to run over https. To simplify development, you can use: @@ -59,7 +62,6 @@ For kubectl to work, grafana needs to run over https. To simplify development, app_mode = development [feature_toggles] -grafanaAPIServer = true grafanaAPIServerEnsureKubectlAccess = true kubernetesPlaylists = true ``` diff --git a/pkg/services/apiserver/aggregator/README.md b/pkg/services/apiserver/aggregator/README.md new file mode 100644 index 0000000000000..5216b23873c89 --- /dev/null +++ b/pkg/services/apiserver/aggregator/README.md @@ -0,0 +1,127 @@ +# aggregator + +This is a package that is intended to power the aggregation of microservices within Grafana. The concept +as well as implementation is largely borrowed from [kube-aggregator](https://github.com/kubernetes/kube-aggregator). + +## Why aggregate services? + +Grafana's future architecture will entail the same API Server design as that of Kubernetes API Servers. API Servers +provide a standard way of stitching together API Groups through discovery and shared routing patterns that allows +them to aggregate to a parent API Server in a seamless manner. Since we desire to break Grafana monolith up into +more functionally divided microservices, aggregation does the job of still being able to provide these services +under a single address. Other benefits of aggregation include free health checks and being able to independently +roll out features for each service without downtime. + +To read more about the concept, see +[here](https://kubernetes.io/docs/tasks/extend-kubernetes/setup-extension-api-server/). + +Note that this aggregation will be a totally internal detail to Grafana. External fully functional API Servers that +may themselves act as parent API Servers to Grafana will never be made aware of internal Grafana API Servers. +Thus, any `APIService` objects corresponding to Grafana's API groups will take the address of +Grafana's main API Server (the one that bundles grafana-aggregator). + +Also, note that the single binary OSS offering of Grafana doesn't make use of the aggregator component at all, instead +opting for local installation of all the Grafana API groups. + +### kube-aggregator versus grafana-aggregator + +The `grafana-aggregator` component will work similarly to how `kube-aggregator` works for `kube-apiserver`, the major +difference being that it doesn't require core V1 APIs such as `Service`. Early on, we decided to not have core V1 +APIs in the root Grafana API Server. In order to still be able to implement aggregation, we do the following in this Go +package: + +1. We do not start the core shared informer factories as well as any default controllers that utilize them. +This is achieved using `DisabledPostStartHooks` facility under the GenericAPIServer's RecommendedConfig. +2. We provide an `externalname` Kind API implementation under `service.grafana.app` group which works functionally +equivalent to the idea with the same name under `core/v1/Service`. +3. Lastly, we swap the default available condition controller with the custom one written by us. This one is based on +our `externalname` (`service.grafana.app`) implementation. We register separate `PostStartHooks` +using `AddPostStartHookOrDie` on the GenericAPIServer to start the corresponding custom controller as well as +requisite informer factories for our own `externalname` Kind. +4. For now, we bundle apiextensions-apiserver under our aggregator component. This is slightly different from K8s +where kube-apiserver is called the top-level component and controlplane, aggregator and apiextensions-apiserver +live under that instead. + +### Gotchas (Pay Attention) + +1. `grafana-aggregator` uses file storage under `data/grafana-apiserver` (`apiregistration.k8s.io`, +`service.grafana.app`). Thus, any restarts will still have any prior configured aggregation in effect. +2. During local development, ensure you start the aggregated service after launching the aggregator. This is +so you have TLS and kubeconfig available for use with example aggregated api servers. +3. Ensure you have `grafanaAPIServerWithExperimentalAPIs = false` in your custom.ini. Otherwise, the example +service the following guide uses for the aggregation test is bundled as a `Local` `APIService` and will cause +configuration overwrites on startup. + +## Testing aggregation locally + +1. Generate the PKI using `openssl` (for development purposes, we will use the CN of `system:masters`): + ```shell + ./hack/make-aggregator-pki.sh + ``` +2. Configure the aggregator: + ```ini + [feature_toggles] + grafanaAPIServerEnsureKubectlAccess = true + ; disable the experimental APIs flag to disable bundling of the example service locally + grafanaAPIServerWithExperimentalAPIs = false + kubernetesAggregator = true + + [grafana-apiserver] + proxy_client_cert_file = ./data/grafana-aggregator/client.crt + proxy_client_key_file = ./data/grafana-aggregator/client.key + ``` +3. Start the server + ```shell + make run + ``` +4. In another tab, apply the manifests: + ```shell + export KUBECONFIG=$PWD/data/grafana-apiserver/grafana.kubeconfig + kubectl apply -f ./pkg/services/apiserver/aggregator/examples/manual-test/ + # SAMPLE OUTPUT + # apiservice.apiregistration.k8s.io/v0alpha1.example.grafana.app created + # externalname.service.grafana.app/example-apiserver created + + kubectl get apiservice + # SAMPLE OUTPUT + # NAME SERVICE AVAILABLE AGE + # v0alpha1.example.grafana.app grafana/example-apiserver False (FailedDiscoveryCheck) 29m + ``` +5. In another tab, start the example microservice that will be aggregated by the parent apiserver: + ```shell + go run ./pkg/cmd/grafana apiserver \ + --runtime-config=example.grafana.app/v0alpha1=true \ + --secure-port 7443 \ + --tls-cert-file $PWD/data/grafana-aggregator/server.crt \ + --tls-private-key-file $PWD/data/grafana-aggregator/server.key \ + --requestheader-client-ca-file=$PWD/data/grafana-aggregator/ca.crt \ + --requestheader-extra-headers-prefix=X-Remote-Extra- \ + --requestheader-group-headers=X-Remote-Group \ + --requestheader-username-headers=X-Remote-User \ + -v 10 + ``` +6. After 10 seconds, check `APIService` again. It should report as available. + ```shell + export KUBECONFIG=$PWD/data/grafana-apiserver/grafana.kubeconfig + kubectl get apiservice + # SAMPLE OUTPUT + # NAME SERVICE AVAILABLE AGE + # v0alpha1.example.grafana.app grafana/example-apiserver True 30m + ``` +7. For tear down of the above test: + ```shell + kubectl delete -f ./pkg/services/apiserver/aggregator/examples/ + ``` + +## Testing auto-registration of remote services locally + +A sample aggregation config for remote services is provided under [conf](../../../../conf/aggregation/apiservices.yaml). Provided, you have the following setup in your custom.ini, the apiserver will +register your remotely running services on startup. + +```ini +; in custom.ini +; the bundle is only used when not in dev mode +apiservice_ca_bundle_file = ./data/grafana-aggregator/ca.crt + +remote_services_file = ./pkg/services/apiserver/aggregator/examples/autoregister/apiservices.yaml +``` diff --git a/pkg/services/apiserver/aggregator/aggregator.go b/pkg/services/apiserver/aggregator/aggregator.go new file mode 100644 index 0000000000000..55dc9ab78f837 --- /dev/null +++ b/pkg/services/apiserver/aggregator/aggregator.go @@ -0,0 +1,458 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Provenance-includes-location: https://github.com/kubernetes/kubernetes/blob/master/cmd/kube-apiserver/app/aggregator.go +// Provenance-includes-license: Apache-2.0 +// Provenance-includes-copyright: The Kubernetes Authors. +// Provenance-includes-location: https://github.com/kubernetes/kubernetes/blob/master/cmd/kube-apiserver/app/server.go +// Provenance-includes-license: Apache-2.0 +// Provenance-includes-copyright: The Kubernetes Authors. +// Provenance-includes-location: https://github.com/kubernetes/kubernetes/blob/master/pkg/controlplane/apiserver/apiextensions.go +// Provenance-includes-license: Apache-2.0 +// Provenance-includes-copyright: The Kubernetes Authors. + +package aggregator + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "time" + + servicev0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/service" + "gopkg.in/yaml.v3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + utilnet "k8s.io/apimachinery/pkg/util/net" + "k8s.io/apimachinery/pkg/util/sets" + genericapiserver "k8s.io/apiserver/pkg/server" + "k8s.io/apiserver/pkg/server/healthz" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/tools/cache" + "k8s.io/klog/v2" + v1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1" + v1helper "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1/helper" + aggregatorapiserver "k8s.io/kube-aggregator/pkg/apiserver" + aggregatorscheme "k8s.io/kube-aggregator/pkg/apiserver/scheme" + apiregistrationclientset "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset" + apiregistrationclient "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/typed/apiregistration/v1" + apiregistrationInformers "k8s.io/kube-aggregator/pkg/client/informers/externalversions/apiregistration/v1" + "k8s.io/kube-aggregator/pkg/controllers/autoregister" + + "github.com/grafana/grafana/pkg/apiserver/builder" + servicev0alpha1applyconfiguration "github.com/grafana/grafana/pkg/generated/applyconfiguration/service/v0alpha1" + serviceclientset "github.com/grafana/grafana/pkg/generated/clientset/versioned" + informersv0alpha1 "github.com/grafana/grafana/pkg/generated/informers/externalversions" + "github.com/grafana/grafana/pkg/services/apiserver/options" +) + +func readCABundlePEM(path string, devMode bool) ([]byte, error) { + if devMode { + return nil, nil + } + + // We can ignore the gosec G304 warning on this one because `path` comes + // from Grafana configuration (commandOptions.AggregatorOptions.APIServiceCABundleFile) + //nolint:gosec + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { + if err := f.Close(); err != nil { + klog.Errorf("error closing remote services file: %s", err) + } + }() + + return io.ReadAll(f) +} + +func readRemoteServices(path string) ([]RemoteService, error) { + // We can ignore the gosec G304 warning on this one because `path` comes + // from Grafana configuration (commandOptions.AggregatorOptions.RemoteServicesFile) + //nolint:gosec + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { + if err := f.Close(); err != nil { + klog.Errorf("error closing remote services file: %s", err) + } + }() + + rawRemoteServices, err := io.ReadAll(f) + if err != nil { + return nil, err + } + + remoteServices := make([]RemoteService, 0) + if err := yaml.Unmarshal(rawRemoteServices, &remoteServices); err != nil { + return nil, err + } + + return remoteServices, nil +} + +func CreateAggregatorConfig(commandOptions *options.Options, sharedConfig genericapiserver.RecommendedConfig, externalNamesNamespace string) (*Config, error) { + // Create a fake clientset and informers for the k8s v1 API group. + // These are not used in grafana's aggregator because v1 APIs are not available. + fakev1Informers := informers.NewSharedInformerFactory(fake.NewSimpleClientset(), 10*time.Minute) + + serviceClient, err := serviceclientset.NewForConfig(sharedConfig.LoopbackClientConfig) + if err != nil { + return nil, err + } + sharedInformerFactory := informersv0alpha1.NewSharedInformerFactory( + serviceClient, + 5*time.Minute, // this is effectively used as a refresh interval right now. Might want to do something nicer later on. + ) + serviceResolver := NewExternalNameResolver(sharedInformerFactory.Service().V0alpha1().ExternalNames().Lister()) + + aggregatorConfig := &aggregatorapiserver.Config{ + GenericConfig: &genericapiserver.RecommendedConfig{ + Config: sharedConfig.Config, + SharedInformerFactory: fakev1Informers, + ClientConfig: sharedConfig.LoopbackClientConfig, + }, + ExtraConfig: aggregatorapiserver.ExtraConfig{ + ProxyClientCertFile: commandOptions.AggregatorOptions.ProxyClientCertFile, + ProxyClientKeyFile: commandOptions.AggregatorOptions.ProxyClientKeyFile, + // NOTE: while ProxyTransport can be skipped in the configuration, it allows honoring + // DISABLE_HTTP2, HTTPS_PROXY and NO_PROXY env vars as needed + ProxyTransport: createProxyTransport(), + ServiceResolver: serviceResolver, + }, + } + + if err := commandOptions.AggregatorOptions.ApplyTo(aggregatorConfig, commandOptions.RecommendedOptions.Etcd, commandOptions.StorageOptions.DataPath); err != nil { + return nil, err + } + + serviceAPIBuilder := service.NewServiceAPIBuilder() + if err := serviceAPIBuilder.InstallSchema(aggregatorscheme.Scheme); err != nil { + return nil, err + } + APIVersionPriorities[serviceAPIBuilder.GetGroupVersion()] = Priority{Group: 15000, Version: int32(1)} + + // Exit early, if no remote services file is configured + if commandOptions.AggregatorOptions.RemoteServicesFile == "" { + return NewConfig(aggregatorConfig, sharedInformerFactory, []builder.APIGroupBuilder{serviceAPIBuilder}, nil), nil + } + + _, err = readCABundlePEM(commandOptions.AggregatorOptions.APIServiceCABundleFile, commandOptions.ExtraOptions.DevMode) + if err != nil { + return nil, err + } + remoteServices, err := readRemoteServices(commandOptions.AggregatorOptions.RemoteServicesFile) + if err != nil { + return nil, err + } + + remoteServicesConfig := &RemoteServicesConfig{ + // TODO: in practice, we should only use the insecure flag when commandOptions.ExtraOptions.DevMode == true + // But given the bug in K8s, we are forced to set it to true until the below PR is merged and available + // https://github.com/kubernetes/kubernetes/pull/123808 + InsecureSkipTLSVerify: true, + ExternalNamesNamespace: externalNamesNamespace, + // TODO: CABundle can't be set when insecure is true + // CABundle: caBundlePEM, + Services: remoteServices, + serviceClientSet: serviceClient, + } + + return NewConfig(aggregatorConfig, sharedInformerFactory, []builder.APIGroupBuilder{serviceAPIBuilder}, remoteServicesConfig), nil +} + +func CreateAggregatorServer(config *Config, delegateAPIServer genericapiserver.DelegationTarget) (*aggregatorapiserver.APIAggregator, error) { + aggregatorConfig := config.KubeAggregatorConfig + sharedInformerFactory := config.Informers + remoteServicesConfig := config.RemoteServicesConfig + + completedConfig := aggregatorConfig.Complete() + + aggregatorServer, err := completedConfig.NewWithDelegate(delegateAPIServer) + if err != nil { + return nil, err + } + + // create controllers for auto-registration + apiRegistrationClient, err := apiregistrationclient.NewForConfig(completedConfig.GenericConfig.LoopbackClientConfig) + if err != nil { + return nil, err + } + + autoRegistrationController := autoregister.NewAutoRegisterController(aggregatorServer.APIRegistrationInformers.Apiregistration().V1().APIServices(), apiRegistrationClient) + + apiServices := apiServicesToRegister(delegateAPIServer, autoRegistrationController) + + // Imbue all builtin group-priorities onto the aggregated discovery + if completedConfig.GenericConfig.AggregatedDiscoveryGroupManager != nil { + for gv, entry := range APIVersionPriorities { + completedConfig.GenericConfig.AggregatedDiscoveryGroupManager.SetGroupVersionPriority(metav1.GroupVersion(gv), int(entry.Group), int(entry.Version)) + } + } + + err = aggregatorServer.GenericAPIServer.AddPostStartHook("grafana-apiserver-autoregistration", func(context genericapiserver.PostStartHookContext) error { + go autoRegistrationController.Run(5, context.StopCh) + return nil + }) + if err != nil { + return nil, err + } + + if remoteServicesConfig != nil { + addRemoteAPIServicesToRegister(remoteServicesConfig, autoRegistrationController) + externalNames := getRemoteExternalNamesToRegister(remoteServicesConfig) + err = aggregatorServer.GenericAPIServer.AddPostStartHook("grafana-apiserver-remote-autoregistration", func(_ genericapiserver.PostStartHookContext) error { + namespacedClient := remoteServicesConfig.serviceClientSet.ServiceV0alpha1().ExternalNames(remoteServicesConfig.ExternalNamesNamespace) + for _, externalName := range externalNames { + _, err := namespacedClient.Apply(context.Background(), externalName, metav1.ApplyOptions{ + FieldManager: "grafana-aggregator", + Force: true, + }) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + } + + err = aggregatorServer.GenericAPIServer.AddBootSequenceHealthChecks( + makeAPIServiceAvailableHealthCheck( + "autoregister-completion", + apiServices, + aggregatorServer.APIRegistrationInformers.Apiregistration().V1().APIServices(), + ), + ) + if err != nil { + return nil, err + } + + apiregistrationClient, err := apiregistrationclientset.NewForConfig(completedConfig.GenericConfig.LoopbackClientConfig) + if err != nil { + return nil, err + } + + availableController, err := NewAvailableConditionController( + aggregatorServer.APIRegistrationInformers.Apiregistration().V1().APIServices(), + sharedInformerFactory.Service().V0alpha1().ExternalNames(), + apiregistrationClient.ApiregistrationV1(), + nil, + (func() ([]byte, []byte))(nil), + completedConfig.ExtraConfig.ServiceResolver, + ) + if err != nil { + return nil, err + } + + aggregatorServer.GenericAPIServer.AddPostStartHookOrDie("apiservice-status-override-available-controller", func(context genericapiserver.PostStartHookContext) error { + // if we end up blocking for long periods of time, we may need to increase workers. + go availableController.Run(5, context.StopCh) + return nil + }) + + aggregatorServer.GenericAPIServer.AddPostStartHookOrDie("start-grafana-aggregator-informers", func(context genericapiserver.PostStartHookContext) error { + sharedInformerFactory.Start(context.StopCh) + aggregatorServer.APIRegistrationInformers.Start(context.StopCh) + return nil + }) + + for _, b := range config.Builders { + serviceAPIGroupInfo, err := b.GetAPIGroupInfo(aggregatorscheme.Scheme, aggregatorscheme.Codecs, aggregatorConfig.GenericConfig.RESTOptionsGetter, false) + if err != nil { + return nil, err + } + if err := aggregatorServer.GenericAPIServer.InstallAPIGroup(serviceAPIGroupInfo); err != nil { + return nil, err + } + } + + return aggregatorServer, nil +} + +func makeAPIService(gv schema.GroupVersion) *v1.APIService { + apiServicePriority, ok := APIVersionPriorities[gv] + if !ok { + // if we aren't found, then we shouldn't register ourselves because it could result in a CRD group version + // being permanently stuck in the APIServices list. + klog.Infof("Skipping APIService creation for %v", gv) + return nil + } + return &v1.APIService{ + ObjectMeta: metav1.ObjectMeta{Name: gv.Version + "." + gv.Group}, + Spec: v1.APIServiceSpec{ + Group: gv.Group, + Version: gv.Version, + GroupPriorityMinimum: apiServicePriority.Group, + VersionPriority: apiServicePriority.Version, + }, + } +} + +// makeAPIServiceAvailableHealthCheck returns a healthz check that returns healthy +// once all of the specified services have been observed to be available at least once. +func makeAPIServiceAvailableHealthCheck(name string, apiServices []*v1.APIService, apiServiceInformer apiregistrationInformers.APIServiceInformer) healthz.HealthChecker { + // Track the auto-registered API services that have not been observed to be available yet + pendingServiceNamesLock := &sync.RWMutex{} + pendingServiceNames := sets.NewString() + for _, service := range apiServices { + pendingServiceNames.Insert(service.Name) + } + + // When an APIService in the list is seen as available, remove it from the pending list + handleAPIServiceChange := func(service *v1.APIService) { + pendingServiceNamesLock.Lock() + defer pendingServiceNamesLock.Unlock() + if !pendingServiceNames.Has(service.Name) { + return + } + if v1helper.IsAPIServiceConditionTrue(service, v1.Available) { + pendingServiceNames.Delete(service.Name) + } + } + + // Watch add/update events for APIServices + _, err := apiServiceInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { handleAPIServiceChange(obj.(*v1.APIService)) }, + UpdateFunc: func(old, new interface{}) { handleAPIServiceChange(new.(*v1.APIService)) }, + }) + if err != nil { + klog.Errorf("Failed to watch APIServices for health check: %v", err) + } + + // Don't return healthy until the pending list is empty + return healthz.NamedCheck(name, func(r *http.Request) error { + pendingServiceNamesLock.RLock() + defer pendingServiceNamesLock.RUnlock() + if pendingServiceNames.Len() > 0 { + klog.Error("APIServices not yet available", "services", pendingServiceNames.List()) + return fmt.Errorf("missing APIService: %v", pendingServiceNames.List()) + } + return nil + }) +} + +// Priority defines group Priority that is used in discovery. This controls +// group position in the kubectl output. +type Priority struct { + // Group indicates the order of the Group relative to other groups. + Group int32 + // Version indicates the relative order of the Version inside of its group. + Version int32 +} + +// APIVersionPriorities are the proper way to resolve this letting the aggregator know the desired group and version-within-group order of the underlying servers +// is to refactor the genericapiserver.DelegationTarget to include a list of priorities based on which APIs were installed. +// This requires the APIGroupInfo struct to evolve and include the concept of priorities and to avoid mistakes, the core storage map there needs to be updated. +// That ripples out every bit as far as you'd expect, so for 1.7 we'll include the list here instead of being built up during storage. +var APIVersionPriorities = map[schema.GroupVersion]Priority{ + {Group: "", Version: "v1"}: {Group: 18000, Version: 1}, + // to my knowledge, nothing below here collides + {Group: "admissionregistration.k8s.io", Version: "v1"}: {Group: 16700, Version: 15}, + {Group: "admissionregistration.k8s.io", Version: "v1beta1"}: {Group: 16700, Version: 12}, + {Group: "admissionregistration.k8s.io", Version: "v1alpha1"}: {Group: 16700, Version: 9}, + // Append a new group to the end of the list if unsure. + // You can use min(existing group)-100 as the initial value for a group. + // Version can be set to 9 (to have space around) for a new group. +} + +func addRemoteAPIServicesToRegister(config *RemoteServicesConfig, registration autoregister.AutoAPIServiceRegistration) { + for i, service := range config.Services { + port := service.Port + apiService := &v1.APIService{ + ObjectMeta: metav1.ObjectMeta{Name: service.Version + "." + service.Group}, + Spec: v1.APIServiceSpec{ + Group: service.Group, + Version: service.Version, + InsecureSkipTLSVerify: config.InsecureSkipTLSVerify, + CABundle: config.CABundle, + // TODO: Group priority minimum of 1000 more than for local services, figure out a better story + // when we have multiple versions, potentially running in heterogeneous ways (local and remote) + GroupPriorityMinimum: 16000, + VersionPriority: 1 + int32(i), + Service: &v1.ServiceReference{ + Name: service.Version + "." + service.Group, + Namespace: config.ExternalNamesNamespace, + Port: &port, + }, + }, + } + + registration.AddAPIServiceToSyncOnStart(apiService) + } +} + +func getRemoteExternalNamesToRegister(config *RemoteServicesConfig) []*servicev0alpha1applyconfiguration.ExternalNameApplyConfiguration { + externalNames := make([]*servicev0alpha1applyconfiguration.ExternalNameApplyConfiguration, 0) + + for _, service := range config.Services { + host := service.Host + name := service.Version + "." + service.Group + externalName := &servicev0alpha1applyconfiguration.ExternalNameApplyConfiguration{} + externalName.WithAPIVersion(servicev0alpha1.SchemeGroupVersion.String()) + externalName.WithKind("ExternalName") + externalName.WithName(name) + externalName.WithSpec(&servicev0alpha1applyconfiguration.ExternalNameSpecApplyConfiguration{ + Host: &host, + }) + externalNames = append(externalNames, externalName) + } + + return externalNames +} + +func apiServicesToRegister(delegateAPIServer genericapiserver.DelegationTarget, registration autoregister.AutoAPIServiceRegistration) []*v1.APIService { + apiServices := []*v1.APIService{} + + for _, curr := range delegateAPIServer.ListedPaths() { + if curr == "/api/v1" { + apiService := makeAPIService(schema.GroupVersion{Group: "", Version: "v1"}) + registration.AddAPIServiceToSyncOnStart(apiService) + apiServices = append(apiServices, apiService) + continue + } + + if !strings.HasPrefix(curr, "/apis/") { + continue + } + // this comes back in a list that looks like /apis/rbac.authorization.k8s.io/v1alpha1 + tokens := strings.Split(curr, "/") + if len(tokens) != 4 { + continue + } + + apiService := makeAPIService(schema.GroupVersion{Group: tokens[2], Version: tokens[3]}) + if apiService == nil { + continue + } + registration.AddAPIServiceToSyncOnStart(apiService) + apiServices = append(apiServices, apiService) + } + + return apiServices +} + +// NOTE: below function imported from https://github.com/kubernetes/kubernetes/blob/master/cmd/kube-apiserver/app/server.go#L197 +// createProxyTransport creates the dialer infrastructure to connect to the api servers. +func createProxyTransport() *http.Transport { + // NOTE: We don't set proxyDialerFn but the below SetTransportDefaults will + // See https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apimachinery/pkg/util/net/http.go#L109 + var proxyDialerFn utilnet.DialFunc + // Proxying to services is IP-based... don't expect to be able to verify the hostname + proxyTLSClientConfig := &tls.Config{InsecureSkipVerify: true} + proxyTransport := utilnet.SetTransportDefaults(&http.Transport{ + DialContext: proxyDialerFn, + TLSClientConfig: proxyTLSClientConfig, + }) + return proxyTransport +} diff --git a/pkg/services/apiserver/aggregator/availableController.go b/pkg/services/apiserver/aggregator/availableController.go new file mode 100644 index 0000000000000..adc534982e5e5 --- /dev/null +++ b/pkg/services/apiserver/aggregator/availableController.go @@ -0,0 +1,466 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Provenance-includes-location: https://github.com/kubernetes/kube-aggregator/blob/master/pkg/controllers/status/available_controller.go +// Provenance-includes-license: Apache-2.0 +// Provenance-includes-copyright: The Kubernetes Authors. + +package aggregator + +import ( + "context" + "fmt" + "net/http" + "net/url" + "reflect" + "sync" + "time" + + "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + informersservicev0alpha1 "github.com/grafana/grafana/pkg/generated/informers/externalversions/service/v0alpha1" + listersservicev0alpha1 "github.com/grafana/grafana/pkg/generated/listers/service/v0alpha1" + + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/transport" + "k8s.io/client-go/util/workqueue" + "k8s.io/klog/v2" + apiregistrationv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1" + apiregistrationv1apihelper "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1/helper" + apiregistrationclient "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/typed/apiregistration/v1" + informers "k8s.io/kube-aggregator/pkg/client/informers/externalversions/apiregistration/v1" + listers "k8s.io/kube-aggregator/pkg/client/listers/apiregistration/v1" + "k8s.io/kube-aggregator/pkg/controllers" +) + +type certKeyFunc func() ([]byte, []byte) + +// ServiceResolver knows how to convert a service reference into an actual location. +type ServiceResolver interface { + ResolveEndpoint(namespace, name string, port int32) (*url.URL, error) +} + +// AvailableConditionController handles checking the availability of registered API services. +type AvailableConditionController struct { + apiServiceClient apiregistrationclient.APIServicesGetter + + apiServiceLister listers.APIServiceLister + apiServiceSynced cache.InformerSynced + + // externalNameLister is used to get the IP to create the transport for + externalNameLister listersservicev0alpha1.ExternalNameLister + servicesSynced cache.InformerSynced + + // proxyTransportDial specifies the dial function for creating unencrypted TCP connections. + proxyTransportDial *transport.DialHolder + proxyCurrentCertKeyContent certKeyFunc + serviceResolver ServiceResolver + + // To allow injection for testing. + syncFn func(key string) error + + queue workqueue.RateLimitingInterface + // map from service-namespace -> service-name -> apiservice names + cache map[string]map[string][]string + // this lock protects operations on the above cache + cacheLock sync.RWMutex +} + +// NewAvailableConditionController returns a new AvailableConditionController. +func NewAvailableConditionController( + apiServiceInformer informers.APIServiceInformer, + externalNameInformer informersservicev0alpha1.ExternalNameInformer, + apiServiceClient apiregistrationclient.APIServicesGetter, + proxyTransportDial *transport.DialHolder, + proxyCurrentCertKeyContent certKeyFunc, + serviceResolver ServiceResolver, +) (*AvailableConditionController, error) { + c := &AvailableConditionController{ + apiServiceClient: apiServiceClient, + apiServiceLister: apiServiceInformer.Lister(), + externalNameLister: externalNameInformer.Lister(), + serviceResolver: serviceResolver, + queue: workqueue.NewNamedRateLimitingQueue( + // We want a fairly tight requeue time. The controller listens to the API, but because it relies on the routability of the + // service network, it is possible for an external, non-watchable factor to affect availability. This keeps + // the maximum disruption time to a minimum, but it does prevent hot loops. + workqueue.NewItemExponentialFailureRateLimiter(5*time.Millisecond, 30*time.Second), + "AvailableConditionController"), + proxyTransportDial: proxyTransportDial, + proxyCurrentCertKeyContent: proxyCurrentCertKeyContent, + } + + // resync on this one because it is low cardinality and rechecking the actual discovery + // allows us to detect health in a more timely fashion when network connectivity to + // nodes is snipped, but the network still attempts to route there. See + // https://github.com/openshift/origin/issues/17159#issuecomment-341798063 + apiServiceHandler, _ := apiServiceInformer.Informer().AddEventHandlerWithResyncPeriod( + cache.ResourceEventHandlerFuncs{ + AddFunc: c.addAPIService, + UpdateFunc: c.updateAPIService, + DeleteFunc: c.deleteAPIService, + }, + 30*time.Second) + c.apiServiceSynced = apiServiceHandler.HasSynced + + serviceHandler, _ := externalNameInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: c.addService, + UpdateFunc: c.updateService, + DeleteFunc: c.deleteService, + }) + c.servicesSynced = serviceHandler.HasSynced + + c.syncFn = c.sync + + return c, nil +} + +func (c *AvailableConditionController) sync(key string) error { + originalAPIService, err := c.apiServiceLister.Get(key) + if apierrors.IsNotFound(err) { + return nil + } + if err != nil { + return err + } + + // if a particular transport was specified, use that otherwise build one + // construct an http client that will ignore TLS verification (if someone owns the network and messes with your status + // that's not so bad) and sets a very short timeout. This is a best effort GET that provides no additional information + transportConfig := &transport.Config{ + TLS: transport.TLSConfig{ + Insecure: true, + }, + DialHolder: c.proxyTransportDial, + } + + if c.proxyCurrentCertKeyContent != nil { + proxyClientCert, proxyClientKey := c.proxyCurrentCertKeyContent() + + transportConfig.TLS.CertData = proxyClientCert + transportConfig.TLS.KeyData = proxyClientKey + } + restTransport, err := transport.New(transportConfig) + if err != nil { + return err + } + discoveryClient := &http.Client{ + Transport: restTransport, + // the request should happen quickly. + Timeout: 5 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + apiService := originalAPIService.DeepCopy() + + availableCondition := apiregistrationv1.APIServiceCondition{ + Type: apiregistrationv1.Available, + Status: apiregistrationv1.ConditionTrue, + LastTransitionTime: metav1.Now(), + } + + // local API services are always considered available + if apiService.Spec.Service == nil { + apiregistrationv1apihelper.SetAPIServiceCondition(apiService, apiregistrationv1apihelper.NewLocalAvailableAPIServiceCondition()) + _, err := c.updateAPIServiceStatus(originalAPIService, apiService) + return err + } + + _, err = c.externalNameLister.ExternalNames(apiService.Spec.Service.Namespace).Get(apiService.Spec.Service.Name) + if apierrors.IsNotFound(err) { + availableCondition.Status = apiregistrationv1.ConditionFalse + availableCondition.Reason = "ServiceNotFound" + availableCondition.Message = fmt.Sprintf("service/%s in %q is not present", apiService.Spec.Service.Name, apiService.Spec.Service.Namespace) + apiregistrationv1apihelper.SetAPIServiceCondition(apiService, availableCondition) + _, err := c.updateAPIServiceStatus(originalAPIService, apiService) + return err + } else if err != nil { + availableCondition.Status = apiregistrationv1.ConditionUnknown + availableCondition.Reason = "ServiceAccessError" + availableCondition.Message = fmt.Sprintf("service/%s in %q cannot be checked due to: %v", apiService.Spec.Service.Name, apiService.Spec.Service.Namespace, err) + apiregistrationv1apihelper.SetAPIServiceCondition(apiService, availableCondition) + _, err := c.updateAPIServiceStatus(originalAPIService, apiService) + return err + } + + // actually try to hit the discovery endpoint when it isn't local and when we're routing as a service. + if apiService.Spec.Service != nil && c.serviceResolver != nil { + attempts := 5 + results := make(chan error, attempts) + for i := 0; i < attempts; i++ { + go func() { + discoveryURL, err := c.serviceResolver.ResolveEndpoint(apiService.Spec.Service.Namespace, apiService.Spec.Service.Name, *apiService.Spec.Service.Port) + if err != nil { + results <- err + return + } + // render legacyAPIService health check path when it is delegated to a service + if apiService.Name == "v1." { + discoveryURL.Path = "/api/" + apiService.Spec.Version + } else { + discoveryURL.Path = "/apis/" + apiService.Spec.Group + "/" + apiService.Spec.Version + } + + errCh := make(chan error, 1) + go func() { + // be sure to check a URL that the aggregated API server is required to serve + newReq, err := http.NewRequest("GET", discoveryURL.String(), nil) + if err != nil { + errCh <- err + return + } + + // setting the system-masters identity ensures that we will always have access rights + transport.SetAuthProxyHeaders(newReq, "system:kube-aggregator", []string{"system:masters"}, nil) + resp, err := discoveryClient.Do(newReq) + if resp != nil { + _ = resp.Body.Close() + // we should always been in the 200s or 300s + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + errCh <- fmt.Errorf("bad status from %v: %v", discoveryURL, resp.StatusCode) + return + } + } + + errCh <- err + }() + + select { + case err = <-errCh: + if err != nil { + results <- fmt.Errorf("failing or missing response from %v: %v", discoveryURL, err) + return + } + + // we had trouble with slow dial and DNS responses causing us to wait too long. + // we added this as insurance + case <-time.After(6 * time.Second): + results <- fmt.Errorf("timed out waiting for %v", discoveryURL) + return + } + + results <- nil + }() + } + + var lastError error + for i := 0; i < attempts; i++ { + lastError = <-results + // if we had at least one success, we are successful overall and we can return now + if lastError == nil { + break + } + } + + if lastError != nil { + availableCondition.Status = apiregistrationv1.ConditionFalse + availableCondition.Reason = "FailedDiscoveryCheck" + availableCondition.Message = lastError.Error() + apiregistrationv1apihelper.SetAPIServiceCondition(apiService, availableCondition) + _, updateErr := c.updateAPIServiceStatus(originalAPIService, apiService) + if updateErr != nil { + return updateErr + } + // force a requeue to make it very obvious that this will be retried at some point in the future + // along with other requeues done via service change, endpoint change, and resync + return lastError + } + } + + availableCondition.Reason = "Passed" + availableCondition.Message = "all checks passed" + apiregistrationv1apihelper.SetAPIServiceCondition(apiService, availableCondition) + _, err = c.updateAPIServiceStatus(originalAPIService, apiService) + return err +} + +// updateAPIServiceStatus only issues an update if a change is detected. We have a tight resync loop to quickly detect dead +// apiservices. Doing that means we don't want to quickly issue no-op updates. +func (c *AvailableConditionController) updateAPIServiceStatus(originalAPIService, newAPIService *apiregistrationv1.APIService) (*apiregistrationv1.APIService, error) { + if equality.Semantic.DeepEqual(originalAPIService.Status, newAPIService.Status) { + return newAPIService, nil + } + + orig := apiregistrationv1apihelper.GetAPIServiceConditionByType(originalAPIService, apiregistrationv1.Available) + now := apiregistrationv1apihelper.GetAPIServiceConditionByType(newAPIService, apiregistrationv1.Available) + unknown := apiregistrationv1.APIServiceCondition{ + Type: apiregistrationv1.Available, + Status: apiregistrationv1.ConditionUnknown, + } + if orig == nil { + orig = &unknown + } + if now == nil { + now = &unknown + } + if *orig != *now { + klog.V(2).InfoS("changing APIService availability", "name", newAPIService.Name, "oldStatus", orig.Status, "newStatus", now.Status, "message", now.Message, "reason", now.Reason) + } + + newAPIService, err := c.apiServiceClient.APIServices().UpdateStatus(context.TODO(), newAPIService, metav1.UpdateOptions{}) + if err != nil { + return nil, err + } + + return newAPIService, nil +} + +// Run starts the AvailableConditionController loop which manages the availability condition of API services. +func (c *AvailableConditionController) Run(workers int, stopCh <-chan struct{}) { + defer utilruntime.HandleCrash() + defer c.queue.ShutDown() + + klog.Info("Starting AvailableConditionController") + defer klog.Info("Shutting down AvailableConditionController") + + // This waits not just for the informers to sync, but for our handlers + // to be called; since the handlers are three different ways of + // enqueueing the same thing, waiting for this permits the queue to + // maximally de-duplicate the entries. + if !controllers.WaitForCacheSync("AvailableConditionCOverrideController", stopCh, c.apiServiceSynced, c.servicesSynced) { + return + } + + for i := 0; i < workers; i++ { + go wait.Until(c.runWorker, time.Second, stopCh) + } + + <-stopCh +} + +func (c *AvailableConditionController) runWorker() { + for c.processNextWorkItem() { + } +} + +// processNextWorkItem deals with one key off the queue. It returns false when it's time to quit. +func (c *AvailableConditionController) processNextWorkItem() bool { + key, quit := c.queue.Get() + if quit { + return false + } + defer c.queue.Done(key) + + err := c.syncFn(key.(string)) + if err == nil { + c.queue.Forget(key) + return true + } + + utilruntime.HandleError(fmt.Errorf("%v failed with: %v", key, err)) + c.queue.AddRateLimited(key) + + return true +} + +func (c *AvailableConditionController) addAPIService(obj interface{}) { + castObj := obj.(*apiregistrationv1.APIService) + klog.V(4).Infof("Adding %s", castObj.Name) + if castObj.Spec.Service != nil { + c.rebuildAPIServiceCache() + } + c.queue.Add(castObj.Name) +} + +func (c *AvailableConditionController) updateAPIService(oldObj, newObj interface{}) { + castObj := newObj.(*apiregistrationv1.APIService) + oldCastObj := oldObj.(*apiregistrationv1.APIService) + klog.V(4).Infof("Updating %s", oldCastObj.Name) + if !reflect.DeepEqual(castObj.Spec.Service, oldCastObj.Spec.Service) { + c.rebuildAPIServiceCache() + } + c.queue.Add(oldCastObj.Name) +} + +func (c *AvailableConditionController) deleteAPIService(obj interface{}) { + castObj, ok := obj.(*apiregistrationv1.APIService) + if !ok { + tombstone, ok := obj.(cache.DeletedFinalStateUnknown) + if !ok { + klog.Errorf("Couldn't get object from tombstone %#v", obj) + return + } + castObj, ok = tombstone.Obj.(*apiregistrationv1.APIService) + if !ok { + klog.Errorf("Tombstone contained object that is not expected %#v", obj) + return + } + } + klog.V(4).Infof("Deleting %q", castObj.Name) + if castObj.Spec.Service != nil { + c.rebuildAPIServiceCache() + } + c.queue.Add(castObj.Name) +} + +func (c *AvailableConditionController) getAPIServicesFor(obj runtime.Object) []string { + metadata, err := meta.Accessor(obj) + if err != nil { + utilruntime.HandleError(err) + return nil + } + c.cacheLock.RLock() + defer c.cacheLock.RUnlock() + return c.cache[metadata.GetNamespace()][metadata.GetName()] +} + +// if the service/endpoint handler wins the race against the cache rebuilding, it may queue a no-longer-relevant apiservice +// (which will get processed an extra time - this doesn't matter), +// and miss a newly relevant apiservice (which will get queued by the apiservice handler) +func (c *AvailableConditionController) rebuildAPIServiceCache() { + apiServiceList, _ := c.apiServiceLister.List(labels.Everything()) + newCache := map[string]map[string][]string{} + for _, apiService := range apiServiceList { + if apiService.Spec.Service == nil { + continue + } + if newCache[apiService.Spec.Service.Namespace] == nil { + newCache[apiService.Spec.Service.Namespace] = map[string][]string{} + } + newCache[apiService.Spec.Service.Namespace][apiService.Spec.Service.Name] = append(newCache[apiService.Spec.Service.Namespace][apiService.Spec.Service.Name], apiService.Name) + } + + c.cacheLock.Lock() + defer c.cacheLock.Unlock() + c.cache = newCache +} + +// TODO, think of a way to avoid checking on every service manipulation + +func (c *AvailableConditionController) addService(obj interface{}) { + for _, apiService := range c.getAPIServicesFor(obj.(*v0alpha1.ExternalName)) { + c.queue.Add(apiService) + } +} + +func (c *AvailableConditionController) updateService(obj, _ interface{}) { + for _, apiService := range c.getAPIServicesFor(obj.(*v0alpha1.ExternalName)) { + c.queue.Add(apiService) + } +} + +func (c *AvailableConditionController) deleteService(obj interface{}) { + castObj, ok := obj.(*v0alpha1.ExternalName) + if !ok { + tombstone, ok := obj.(cache.DeletedFinalStateUnknown) + if !ok { + klog.Errorf("Couldn't get object from tombstone %#v", obj) + return + } + castObj, ok = tombstone.Obj.(*v0alpha1.ExternalName) + if !ok { + klog.Errorf("Tombstone contained object that is not expected %#v", obj) + return + } + } + for _, apiService := range c.getAPIServicesFor(castObj) { + c.queue.Add(apiService) + } +} diff --git a/pkg/services/apiserver/aggregator/config.go b/pkg/services/apiserver/aggregator/config.go new file mode 100644 index 0000000000000..e50f938316573 --- /dev/null +++ b/pkg/services/apiserver/aggregator/config.go @@ -0,0 +1,68 @@ +package aggregator + +import ( + openapinamer "k8s.io/apiserver/pkg/endpoints/openapi" + genericapiserver "k8s.io/apiserver/pkg/server" + aggregatorapiserver "k8s.io/kube-aggregator/pkg/apiserver" + aggregatorscheme "k8s.io/kube-aggregator/pkg/apiserver/scheme" + aggregatoropenapi "k8s.io/kube-aggregator/pkg/generated/openapi" + "k8s.io/kube-openapi/pkg/common" + + "github.com/grafana/grafana/pkg/apiserver/builder" + serviceclientset "github.com/grafana/grafana/pkg/generated/clientset/versioned" + informersv0alpha1 "github.com/grafana/grafana/pkg/generated/informers/externalversions" +) + +type RemoteService struct { + Group string `yaml:"group"` + Version string `yaml:"version"` + Host string `yaml:"host"` + Port int32 `yaml:"port"` +} + +type RemoteServicesConfig struct { + ExternalNamesNamespace string + InsecureSkipTLSVerify bool + CABundle []byte + Services []RemoteService + serviceClientSet *serviceclientset.Clientset +} + +type Config struct { + KubeAggregatorConfig *aggregatorapiserver.Config + Informers informersv0alpha1.SharedInformerFactory + RemoteServicesConfig *RemoteServicesConfig + // Builders contain prerequisite api groups for aggregator to function correctly e.g. ExternalName + // Since the main APIServer delegate supports storage implementations that intend to be multi-tenant + // Aggregator builders that we don't intend to use multi-tenant storage are kept in aggregator's + // Delegate, one which is configured explicitly to use file storage only + Builders []builder.APIGroupBuilder +} + +// remoteServices may be nil when not using aggregation +func NewConfig(aggregator *aggregatorapiserver.Config, informers informersv0alpha1.SharedInformerFactory, builders []builder.APIGroupBuilder, remoteServices *RemoteServicesConfig) *Config { + getMergedOpenAPIDefinitions := func(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + aggregatorAPIs := aggregatoropenapi.GetOpenAPIDefinitions(ref) + builderAPIs := builder.GetOpenAPIDefinitions(builders)(ref) + + for k, v := range builderAPIs { + aggregatorAPIs[k] = v + } + + return aggregatorAPIs + } + + // Add OpenAPI config, which depends on builders + namer := openapinamer.NewDefinitionNamer(aggregatorscheme.Scheme) + aggregator.GenericConfig.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config(getMergedOpenAPIDefinitions, namer) + aggregator.GenericConfig.OpenAPIV3Config.Info.Title = "Kubernetes" + aggregator.GenericConfig.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig(getMergedOpenAPIDefinitions, namer) + aggregator.GenericConfig.OpenAPIConfig.Info.Title = "Kubernetes" + + return &Config{ + aggregator, + informers, + remoteServices, + builders, + } +} diff --git a/pkg/services/apiserver/aggregator/examples/autoregister/apiservices.yaml b/pkg/services/apiserver/aggregator/examples/autoregister/apiservices.yaml new file mode 100644 index 0000000000000..183afb542b24a --- /dev/null +++ b/pkg/services/apiserver/aggregator/examples/autoregister/apiservices.yaml @@ -0,0 +1,14 @@ +# NOTE: dev-mode only and governed by presence of non-empty value for cfg["grafana-apiserver"]["remote_services_file"] +# List of sample multi-tenant services to aggregate on startup +- group: example.grafana.app + version: v0alpha1 + host: localhost + port: 7443 +- group: query.grafana.app + version: v0alpha1 + host: localhost + port: 7444 +- group: testdata.datasource.grafana.app + version: v0alpha1 + host: localhost + port: 7445 diff --git a/pkg/services/apiserver/aggregator/examples/manual-test/apiservice.yaml b/pkg/services/apiserver/aggregator/examples/manual-test/apiservice.yaml new file mode 100644 index 0000000000000..65cc2a5884a59 --- /dev/null +++ b/pkg/services/apiserver/aggregator/examples/manual-test/apiservice.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: apiregistration.k8s.io/v1 +kind: APIService +metadata: + name: v0alpha1.example.grafana.app +spec: + version: v0alpha1 + insecureSkipTLSVerify: true + group: example.grafana.app + groupPriorityMinimum: 1000 + versionPriority: 15 + service: + name: example-apiserver + namespace: grafana + port: 7443 diff --git a/pkg/services/apiserver/aggregator/examples/manual-test/externalname.yaml b/pkg/services/apiserver/aggregator/examples/manual-test/externalname.yaml new file mode 100644 index 0000000000000..731782611d51c --- /dev/null +++ b/pkg/services/apiserver/aggregator/examples/manual-test/externalname.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: service.grafana.app/v0alpha1 +kind: ExternalName +metadata: + name: example-apiserver + namespace: grafana +spec: + host: localhost diff --git a/pkg/services/apiserver/aggregator/resolver.go b/pkg/services/apiserver/aggregator/resolver.go new file mode 100644 index 0000000000000..c0970ab726c57 --- /dev/null +++ b/pkg/services/apiserver/aggregator/resolver.go @@ -0,0 +1,32 @@ +package aggregator + +import ( + "fmt" + "net" + "net/url" + + "k8s.io/kube-aggregator/pkg/apiserver" + + servicelistersv0alpha1 "github.com/grafana/grafana/pkg/generated/listers/service/v0alpha1" +) + +func NewExternalNameResolver(externalNames servicelistersv0alpha1.ExternalNameLister) apiserver.ServiceResolver { + return &externalNameResolver{ + externalNames: externalNames, + } +} + +type externalNameResolver struct { + externalNames servicelistersv0alpha1.ExternalNameLister +} + +func (r *externalNameResolver) ResolveEndpoint(namespace, name string, port int32) (*url.URL, error) { + extName, err := r.externalNames.ExternalNames(namespace).Get(name) + if err != nil { + return nil, err + } + return &url.URL{ + Scheme: "https", + Host: net.JoinHostPort(extName.Spec.Host, fmt.Sprintf("%d", port)), + }, nil +} diff --git a/pkg/services/apiserver/auth/authenticator/provider.go b/pkg/services/apiserver/auth/authenticator/provider.go new file mode 100644 index 0000000000000..389c808d52835 --- /dev/null +++ b/pkg/services/apiserver/auth/authenticator/provider.go @@ -0,0 +1,11 @@ +package authenticator + +import ( + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authentication/request/union" +) + +func NewAuthenticator(authRequestHandlers ...authenticator.Request) authenticator.Request { + handlers := append([]authenticator.Request{authenticator.RequestFunc(signedInUserAuthenticator)}, authRequestHandlers...) + return union.New(handlers...) +} diff --git a/pkg/services/apiserver/auth/authenticator/signedinuser.go b/pkg/services/apiserver/auth/authenticator/signedinuser.go new file mode 100644 index 0000000000000..76dc5289bb61b --- /dev/null +++ b/pkg/services/apiserver/auth/authenticator/signedinuser.go @@ -0,0 +1,45 @@ +package authenticator + +import ( + "net/http" + "strconv" + + "k8s.io/apiserver/pkg/authentication/authenticator" + k8suser "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/klog/v2" + + "github.com/grafana/grafana/pkg/infra/appcontext" +) + +var _ authenticator.RequestFunc = signedInUserAuthenticator + +func signedInUserAuthenticator(req *http.Request) (*authenticator.Response, bool, error) { + ctx := req.Context() + signedInUser, err := appcontext.User(ctx) + if err != nil { + klog.V(5).Info("failed to get signed in user", "err", err) + return nil, false, nil + } + + userInfo := &k8suser.DefaultInfo{ + Name: signedInUser.Login, + UID: signedInUser.UserUID, + Groups: []string{}, + // In order to faithfully round-trip through an impersonation flow, Extra keys MUST be lowercase. + // see: https://pkg.go.dev/k8s.io/apiserver@v0.27.1/pkg/authentication/user#Info + Extra: map[string][]string{}, + } + + for _, v := range signedInUser.Teams { + userInfo.Groups = append(userInfo.Groups, strconv.FormatInt(v, 10)) + } + + // + if signedInUser.IDToken != "" { + userInfo.Extra["id-token"] = []string{signedInUser.IDToken} + } + + return &authenticator.Response{ + User: userInfo, + }, true, nil +} diff --git a/pkg/services/apiserver/auth/authenticator/signedinuser_test.go b/pkg/services/apiserver/auth/authenticator/signedinuser_test.go new file mode 100644 index 0000000000000..a06abe06aaf91 --- /dev/null +++ b/pkg/services/apiserver/auth/authenticator/signedinuser_test.go @@ -0,0 +1,88 @@ +package authenticator + +import ( + "context" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/user" + "github.com/stretchr/testify/require" + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authentication/request/union" +) + +func TestSignedInUser(t *testing.T) { + t.Run("should call next authenticator if SignedInUser is not set", func(t *testing.T) { + req, err := http.NewRequest("GET", "http://localhost:3000/apis", nil) + require.NoError(t, err) + mockAuthenticator := &mockAuthenticator{} + all := union.New(authenticator.RequestFunc(signedInUserAuthenticator), mockAuthenticator) + res, ok, err := all.AuthenticateRequest(req) + require.NoError(t, err) + require.False(t, ok) + require.Nil(t, res) + require.True(t, mockAuthenticator.called) + }) + + t.Run("should set user and group", func(t *testing.T) { + u := &user.SignedInUser{ + Login: "admin", + UserID: 1, + UserUID: uuid.New().String(), + Teams: []int64{1, 2}, + } + ctx := appcontext.WithUser(context.Background(), u) + req, err := http.NewRequest("GET", "http://localhost:3000/apis", nil) + require.NoError(t, err) + req = req.WithContext(ctx) + mockAuthenticator := &mockAuthenticator{} + all := union.New(authenticator.RequestFunc(signedInUserAuthenticator), mockAuthenticator) + res, ok, err := all.AuthenticateRequest(req) + require.NoError(t, err) + require.True(t, ok) + require.False(t, mockAuthenticator.called) + + require.Equal(t, u.Login, res.User.GetName()) + require.Equal(t, u.UserUID, res.User.GetUID()) + require.Equal(t, []string{"1", "2"}, res.User.GetGroups()) + require.Empty(t, res.User.GetExtra()["id-token"]) + }) + + t.Run("should set ID token when available", func(t *testing.T) { + u := &user.SignedInUser{ + Login: "admin", + UserID: 1, + UserUID: uuid.New().String(), + Teams: []int64{1, 2}, + IDToken: "test-id-token", + } + ctx := appcontext.WithUser(context.Background(), u) + req, err := http.NewRequest("GET", "http://localhost:3000/apis", nil) + require.NoError(t, err) + req = req.WithContext(ctx) + mockAuthenticator := &mockAuthenticator{} + all := union.New(authenticator.RequestFunc(signedInUserAuthenticator), mockAuthenticator) + res, ok, err := all.AuthenticateRequest(req) + require.NoError(t, err) + require.True(t, ok) + + require.False(t, mockAuthenticator.called) + require.Equal(t, u.Login, res.User.GetName()) + require.Equal(t, u.UserUID, res.User.GetUID()) + require.Equal(t, []string{"1", "2"}, res.User.GetGroups()) + require.Equal(t, "test-id-token", res.User.GetExtra()["id-token"][0]) + }) +} + +var _ authenticator.Request = (*mockAuthenticator)(nil) + +type mockAuthenticator struct { + called bool +} + +func (a *mockAuthenticator) AuthenticateRequest(req *http.Request) (*authenticator.Response, bool, error) { + a.called = true + return nil, false, nil +} diff --git a/pkg/services/grafana-apiserver/auth/authorizer/impersonation.go b/pkg/services/apiserver/auth/authorizer/impersonation.go similarity index 100% rename from pkg/services/grafana-apiserver/auth/authorizer/impersonation.go rename to pkg/services/apiserver/auth/authorizer/impersonation.go diff --git a/pkg/services/grafana-apiserver/auth/authorizer/impersonation_test.go b/pkg/services/apiserver/auth/authorizer/impersonation_test.go similarity index 100% rename from pkg/services/grafana-apiserver/auth/authorizer/impersonation_test.go rename to pkg/services/apiserver/auth/authorizer/impersonation_test.go diff --git a/pkg/services/grafana-apiserver/auth/authorizer/org_id.go b/pkg/services/apiserver/auth/authorizer/org_id.go similarity index 95% rename from pkg/services/grafana-apiserver/auth/authorizer/org_id.go rename to pkg/services/apiserver/auth/authorizer/org_id.go index 4d6efdfc88a83..e9d4a6786a8ed 100644 --- a/pkg/services/grafana-apiserver/auth/authorizer/org_id.go +++ b/pkg/services/apiserver/auth/authorizer/org_id.go @@ -8,7 +8,7 @@ import ( "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/log" - grafanarequest "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" + grafanarequest "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/org" ) diff --git a/pkg/services/grafana-apiserver/auth/authorizer/org_role.go b/pkg/services/apiserver/auth/authorizer/org_role.go similarity index 100% rename from pkg/services/grafana-apiserver/auth/authorizer/org_role.go rename to pkg/services/apiserver/auth/authorizer/org_role.go diff --git a/pkg/services/grafana-apiserver/auth/authorizer/provider.go b/pkg/services/apiserver/auth/authorizer/provider.go similarity index 92% rename from pkg/services/grafana-apiserver/auth/authorizer/provider.go rename to pkg/services/apiserver/auth/authorizer/provider.go index f8ab2a79d0f19..5cd92982e4061 100644 --- a/pkg/services/grafana-apiserver/auth/authorizer/provider.go +++ b/pkg/services/apiserver/auth/authorizer/provider.go @@ -4,7 +4,9 @@ import ( "context" "k8s.io/apimachinery/pkg/runtime/schema" + k8suser "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/authorization/authorizerfactory" "k8s.io/apiserver/pkg/authorization/union" orgsvc "github.com/grafana/grafana/pkg/services/org" @@ -21,6 +23,7 @@ type GrafanaAuthorizer struct { func NewGrafanaAuthorizer(cfg *setting.Cfg, orgService orgsvc.Service) *GrafanaAuthorizer { authorizers := []authorizer.Authorizer{ &impersonationAuthorizer{}, + authorizerfactory.NewPrivilegedGroups(k8suser.SystemPrivilegedGroup), } // In Hosted grafana, the StackID replaces the orgID as a valid namespace diff --git a/pkg/services/grafana-apiserver/auth/authorizer/stack_id.go b/pkg/services/apiserver/auth/authorizer/stack_id.go similarity index 94% rename from pkg/services/grafana-apiserver/auth/authorizer/stack_id.go rename to pkg/services/apiserver/auth/authorizer/stack_id.go index 32887dd814231..0098c1dfd2185 100644 --- a/pkg/services/grafana-apiserver/auth/authorizer/stack_id.go +++ b/pkg/services/apiserver/auth/authorizer/stack_id.go @@ -8,7 +8,7 @@ import ( "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/log" - grafanarequest "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" + grafanarequest "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/setting" ) diff --git a/pkg/services/apiserver/config.go b/pkg/services/apiserver/config.go new file mode 100644 index 0000000000000..5436b97be4dd1 --- /dev/null +++ b/pkg/services/apiserver/config.go @@ -0,0 +1,57 @@ +package apiserver + +import ( + "fmt" + "net" + "path/filepath" + "strconv" + + "github.com/grafana/grafana/pkg/services/apiserver/options" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" +) + +func applyGrafanaConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles, o *options.Options) { + defaultLogLevel := 0 + ip := net.ParseIP(cfg.HTTPAddr) + apiURL := cfg.AppURL + port, err := strconv.Atoi(cfg.HTTPPort) + if err != nil { + port = 3000 + } + + if cfg.Env == setting.Dev { + defaultLogLevel = 10 + port = 6443 + ip = net.ParseIP("127.0.0.1") + apiURL = fmt.Sprintf("https://%s:%d", ip, port) + } + + host := fmt.Sprintf("%s:%d", ip, port) + + apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver") + + o.RecommendedOptions.Etcd.StorageConfig.Transport.ServerList = apiserverCfg.Key("etcd_servers").Strings(",") + + o.RecommendedOptions.SecureServing.BindAddress = ip + o.RecommendedOptions.SecureServing.BindPort = port + o.RecommendedOptions.Authentication.RemoteKubeConfigFileOptional = true + o.RecommendedOptions.Authorization.RemoteKubeConfigFileOptional = true + + o.AggregatorOptions.ProxyClientCertFile = apiserverCfg.Key("proxy_client_cert_file").MustString("") + o.AggregatorOptions.ProxyClientKeyFile = apiserverCfg.Key("proxy_client_key_file").MustString("") + + o.AggregatorOptions.APIServiceCABundleFile = apiserverCfg.Key("apiservice_ca_bundle_file").MustString("") + o.AggregatorOptions.RemoteServicesFile = apiserverCfg.Key("remote_services_file").MustString("") + + o.RecommendedOptions.Admission = nil + o.RecommendedOptions.CoreAPI = nil + + o.StorageOptions.StorageType = options.StorageType(apiserverCfg.Key("storage_type").MustString(string(options.StorageTypeLegacy))) + o.StorageOptions.DataPath = apiserverCfg.Key("storage_path").MustString(filepath.Join(cfg.DataPath, "grafana-apiserver")) + o.StorageOptions.Address = apiserverCfg.Key("address").MustString(o.StorageOptions.Address) + o.ExtraOptions.DevMode = features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerEnsureKubectlAccess) + o.ExtraOptions.ExternalAddress = host + o.ExtraOptions.APIURL = apiURL + o.ExtraOptions.Verbosity = apiserverCfg.Key("log_level").MustInt(defaultLogLevel) +} diff --git a/pkg/services/grafana-apiserver/endpoints/request/namespace.go b/pkg/services/apiserver/endpoints/request/namespace.go similarity index 94% rename from pkg/services/grafana-apiserver/endpoints/request/namespace.go rename to pkg/services/apiserver/endpoints/request/namespace.go index ccec8680f816a..899e342dc9f2f 100644 --- a/pkg/services/grafana-apiserver/endpoints/request/namespace.go +++ b/pkg/services/apiserver/endpoints/request/namespace.go @@ -67,10 +67,12 @@ func ParseNamespace(ns string) (NamespaceInfo, error) { } if strings.HasPrefix(ns, "stack-") { - info.StackID = ns[6:] - if len(info.StackID) < 2 { + stackIDStr := ns[6:] + stackID, err := strconv.Atoi(stackIDStr) + if err != nil || stackID < 1 { return info, fmt.Errorf("invalid stack id") } + info.StackID = stackIDStr info.OrgID = 1 return info, nil } diff --git a/pkg/services/grafana-apiserver/endpoints/request/namespace_test.go b/pkg/services/apiserver/endpoints/request/namespace_test.go similarity index 90% rename from pkg/services/grafana-apiserver/endpoints/request/namespace_test.go rename to pkg/services/apiserver/endpoints/request/namespace_test.go index 4f9dc7e928181..f5665ff2f5b89 100644 --- a/pkg/services/grafana-apiserver/endpoints/request/namespace_test.go +++ b/pkg/services/apiserver/endpoints/request/namespace_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/setting" ) @@ -77,15 +77,15 @@ func TestParseNamespace(t *testing.T) { }, }, { - name: "valid stack", + name: "invalid stack id (must be an int)", + expectErr: true, namespace: "stack-abcdef", expected: request.NamespaceInfo{ - OrgID: 1, - StackID: "abcdef", + OrgID: -1, }, }, { - name: "invalid stack id", + name: "invalid stack id (must be provided)", namespace: "stack-", expectErr: true, expected: request.NamespaceInfo{ @@ -93,11 +93,18 @@ func TestParseNamespace(t *testing.T) { }, }, { - name: "invalid stack id (too short)", - namespace: "stack-1", + name: "invalid stack id (cannot be 0)", + namespace: "stack-0", expectErr: true, expected: request.NamespaceInfo{ - OrgID: -1, + OrgID: -1, + }, + }, + { + name: "valid stack", + namespace: "stack-1", + expected: request.NamespaceInfo{ + OrgID: 1, StackID: "1", }, }, diff --git a/pkg/services/apiserver/options/aggregator.go b/pkg/services/apiserver/options/aggregator.go new file mode 100644 index 0000000000000..911b6c9c951e7 --- /dev/null +++ b/pkg/services/apiserver/options/aggregator.go @@ -0,0 +1,110 @@ +package options + +import ( + servicev0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + filestorage "github.com/grafana/grafana/pkg/apiserver/storage/file" + "github.com/spf13/pflag" + v1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + genericfeatures "k8s.io/apiserver/pkg/features" + genericapiserver "k8s.io/apiserver/pkg/server" + "k8s.io/apiserver/pkg/server/options" + "k8s.io/apiserver/pkg/server/resourceconfig" + utilfeature "k8s.io/apiserver/pkg/util/feature" + apiregistrationv1beta1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1beta1" + aggregatorapiserver "k8s.io/kube-aggregator/pkg/apiserver" + aggregatorscheme "k8s.io/kube-aggregator/pkg/apiserver/scheme" +) + +// AggregatorServerOptions contains the state for the aggregator apiserver +type AggregatorServerOptions struct { + AlternateDNS []string + ProxyClientCertFile string + ProxyClientKeyFile string + RemoteServicesFile string + APIServiceCABundleFile string +} + +func NewAggregatorServerOptions() *AggregatorServerOptions { + return &AggregatorServerOptions{} +} + +func (o *AggregatorServerOptions) AddFlags(fs *pflag.FlagSet) { + if o == nil { + return + } + + fs.StringVar(&o.ProxyClientCertFile, "proxy-client-cert-file", o.ProxyClientCertFile, + "path to proxy client cert file") + + fs.StringVar(&o.ProxyClientKeyFile, "proxy-client-key-file", o.ProxyClientKeyFile, + "path to proxy client cert file") +} + +func (o *AggregatorServerOptions) Validate() []error { + if o == nil { + return nil + } + + // TODO: do we need to validate anything here? + return nil +} + +func (o *AggregatorServerOptions) ApplyTo(aggregatorConfig *aggregatorapiserver.Config, etcdOpts *options.EtcdOptions, dataPath string) error { + genericConfig := aggregatorConfig.GenericConfig + + genericConfig.PostStartHooks = map[string]genericapiserver.PostStartHookConfigEntry{} + genericConfig.RESTOptionsGetter = nil + + if utilfeature.DefaultFeatureGate.Enabled(genericfeatures.StorageVersionAPI) && + utilfeature.DefaultFeatureGate.Enabled(genericfeatures.APIServerIdentity) { + // Add StorageVersionPrecondition handler to aggregator-apiserver. + // The handler will block write requests to built-in resources until the + // target resources' storage versions are up-to-date. + genericConfig.BuildHandlerChainFunc = genericapiserver.BuildHandlerChainWithStorageVersionPrecondition + } + + // copy the etcd options so we don't mutate originals. + // we assume that the etcd options have been completed already. avoid messing with anything outside + // of changes to StorageConfig as that may lead to unexpected behavior when the options are applied. + etcdOptions := *etcdOpts + etcdOptions.StorageConfig.Codec = aggregatorscheme.Codecs.LegacyCodec(v1.SchemeGroupVersion, + apiregistrationv1beta1.SchemeGroupVersion, + servicev0alpha1.SchemeGroupVersion) + etcdOptions.StorageConfig.EncodeVersioner = runtime.NewMultiGroupVersioner(v1.SchemeGroupVersion, + schema.GroupKind{Group: apiregistrationv1beta1.GroupName}, + schema.GroupKind{Group: servicev0alpha1.GROUP}) + etcdOptions.SkipHealthEndpoints = true // avoid double wiring of health checks + if err := etcdOptions.ApplyTo(&genericConfig.Config); err != nil { + return err + } + // override the RESTOptionsGetter to use the file storage options getter + restOptionsGetter, err := filestorage.NewRESTOptionsGetter(dataPath, etcdOptions.StorageConfig, + "apiregistration.k8s.io/apiservices", + "service.grafana.app/externalnames", + ) + if err != nil { + return err + } + aggregatorConfig.GenericConfig.RESTOptionsGetter = restOptionsGetter + + // prevent generic API server from installing the OpenAPI handler. Aggregator server has its own customized OpenAPI handler. + genericConfig.SkipOpenAPIInstallation = true + mergedResourceConfig, err := resourceconfig.MergeAPIResourceConfigs(aggregatorapiserver.DefaultAPIResourceConfigSource(), nil, aggregatorscheme.Scheme) + if err != nil { + return err + } + genericConfig.MergedResourceConfig = mergedResourceConfig + + aggregatorConfig.ExtraConfig.ProxyClientCertFile = o.ProxyClientCertFile + aggregatorConfig.ExtraConfig.ProxyClientKeyFile = o.ProxyClientKeyFile + + genericConfig.PostStartHooks = map[string]genericapiserver.PostStartHookConfigEntry{} + + // These hooks use v1 informers, which are not available in the grafana aggregator. + genericConfig.DisabledPostStartHooks = genericConfig.DisabledPostStartHooks.Insert("apiservice-status-available-controller") + genericConfig.DisabledPostStartHooks = genericConfig.DisabledPostStartHooks.Insert("start-kube-aggregator-informers") + + return nil +} diff --git a/pkg/services/apiserver/options/extra.go b/pkg/services/apiserver/options/extra.go new file mode 100644 index 0000000000000..fdb394d4d2879 --- /dev/null +++ b/pkg/services/apiserver/options/extra.go @@ -0,0 +1,46 @@ +package options + +import ( + "strconv" + + "github.com/go-logr/logr" + "github.com/spf13/pflag" + genericapiserver "k8s.io/apiserver/pkg/server" + "k8s.io/component-base/logs" + "k8s.io/klog/v2" +) + +type ExtraOptions struct { + DevMode bool + ExternalAddress string + APIURL string + Verbosity int +} + +func NewExtraOptions() *ExtraOptions { + return &ExtraOptions{ + DevMode: false, + Verbosity: 0, + } +} + +func (o *ExtraOptions) AddFlags(fs *pflag.FlagSet) { + fs.BoolVar(&o.DevMode, "grafana-apiserver-dev-mode", o.DevMode, "Enable dev mode") + fs.StringVar(&o.ExternalAddress, "grafana-apiserver-host", o.ExternalAddress, "Host") + fs.StringVar(&o.APIURL, "grafana-apiserver-api-url", o.APIURL, "API URL") + fs.IntVar(&o.Verbosity, "verbosity", o.Verbosity, "Verbosity") +} + +func (o *ExtraOptions) Validate() []error { + return nil +} + +func (o *ExtraOptions) ApplyTo(c *genericapiserver.RecommendedConfig) error { + logger := logr.New(newLogAdapter(o.Verbosity)) + klog.SetLoggerWithOptions(logger, klog.ContextualLogger(true)) + if _, err := logs.GlogSetter(strconv.Itoa(o.Verbosity)); err != nil { + logger.Error(err, "failed to set log level") + } + c.ExternalAddress = o.ExternalAddress + return nil +} diff --git a/pkg/services/grafana-apiserver/log.go b/pkg/services/apiserver/options/log.go similarity index 97% rename from pkg/services/grafana-apiserver/log.go rename to pkg/services/apiserver/options/log.go index edf10c0d3d3b6..9e32f66e3b300 100644 --- a/pkg/services/grafana-apiserver/log.go +++ b/pkg/services/apiserver/options/log.go @@ -1,4 +1,4 @@ -package grafanaapiserver +package options import ( "strings" diff --git a/pkg/services/apiserver/options/options.go b/pkg/services/apiserver/options/options.go new file mode 100644 index 0000000000000..42cb655703626 --- /dev/null +++ b/pkg/services/apiserver/options/options.go @@ -0,0 +1,147 @@ +package options + +import ( + "net" + + "github.com/spf13/pflag" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/endpoints/discovery/aggregated" + genericapiserver "k8s.io/apiserver/pkg/server" + genericoptions "k8s.io/apiserver/pkg/server/options" +) + +type OptionsProvider interface { + AddFlags(fs *pflag.FlagSet) + ValidateOptions() []error +} + +const defaultEtcdPathPrefix = "/registry/grafana.app" + +type Options struct { + RecommendedOptions *genericoptions.RecommendedOptions + AggregatorOptions *AggregatorServerOptions + StorageOptions *StorageOptions + ExtraOptions *ExtraOptions + APIOptions []OptionsProvider +} + +func NewOptions(codec runtime.Codec) *Options { + return &Options{ + RecommendedOptions: genericoptions.NewRecommendedOptions( + defaultEtcdPathPrefix, + codec, + ), + AggregatorOptions: NewAggregatorServerOptions(), + StorageOptions: NewStorageOptions(), + ExtraOptions: NewExtraOptions(), + } +} + +func (o *Options) AddFlags(fs *pflag.FlagSet) { + o.RecommendedOptions.AddFlags(fs) + o.AggregatorOptions.AddFlags(fs) + o.StorageOptions.AddFlags(fs) + o.ExtraOptions.AddFlags(fs) + + for _, api := range o.APIOptions { + api.AddFlags(fs) + } +} + +func (o *Options) Validate() []error { + if errs := o.ExtraOptions.Validate(); len(errs) != 0 { + return errs + } + + if errs := o.StorageOptions.Validate(); len(errs) != 0 { + return errs + } + + if errs := o.AggregatorOptions.Validate(); len(errs) != 0 { + return errs + } + + if errs := o.RecommendedOptions.SecureServing.Validate(); len(errs) != 0 { + return errs + } + + if o.ExtraOptions.DevMode { + // NOTE: Only consider authn for dev mode - resolves the failure due to missing extension apiserver auth-config + // in parent k8s + if errs := o.RecommendedOptions.Authentication.Validate(); len(errs) != 0 { + return errs + } + } + + if o.StorageOptions.StorageType == StorageTypeEtcd { + if errs := o.RecommendedOptions.Etcd.Validate(); len(errs) != 0 { + return errs + } + } + + for _, api := range o.APIOptions { + if errs := api.ValidateOptions(); len(errs) != 0 { + return errs + } + } + return nil +} + +func (o *Options) ApplyTo(serverConfig *genericapiserver.RecommendedConfig) error { + serverConfig.AggregatedDiscoveryGroupManager = aggregated.NewResourceManager("apis") + + // avoid picking up an in-cluster service account token + o.RecommendedOptions.Authentication.SkipInClusterLookup = true + + if err := o.ExtraOptions.ApplyTo(serverConfig); err != nil { + return err + } + + if !o.ExtraOptions.DevMode { + o.RecommendedOptions.SecureServing.Listener = newFakeListener() + } + + if err := o.RecommendedOptions.SecureServing.ApplyTo(&serverConfig.SecureServing, &serverConfig.LoopbackClientConfig); err != nil { + return err + } + + if err := o.RecommendedOptions.Authentication.ApplyTo(&serverConfig.Authentication, serverConfig.SecureServing, serverConfig.OpenAPIConfig); err != nil { + return err + } + + if !o.ExtraOptions.DevMode { + if err := serverConfig.SecureServing.Listener.Close(); err != nil { + return err + } + serverConfig.SecureServing = nil + } + return nil +} + +type fakeListener struct { + server net.Conn + client net.Conn +} + +func newFakeListener() *fakeListener { + server, client := net.Pipe() + return &fakeListener{ + server: server, + client: client, + } +} + +func (f *fakeListener) Accept() (net.Conn, error) { + return f.server, nil +} + +func (f *fakeListener) Close() error { + if err := f.client.Close(); err != nil { + return err + } + return f.server.Close() +} + +func (f *fakeListener) Addr() net.Addr { + return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 3000, Zone: ""} +} diff --git a/pkg/services/apiserver/options/storage.go b/pkg/services/apiserver/options/storage.go new file mode 100644 index 0000000000000..dc99d98e9b40b --- /dev/null +++ b/pkg/services/apiserver/options/storage.go @@ -0,0 +1,59 @@ +package options + +import ( + "fmt" + "net" + + "github.com/spf13/pflag" + genericapiserver "k8s.io/apiserver/pkg/server" + "k8s.io/apiserver/pkg/server/options" +) + +type StorageType string + +const ( + StorageTypeFile StorageType = "file" + StorageTypeEtcd StorageType = "etcd" + StorageTypeLegacy StorageType = "legacy" + StorageTypeUnified StorageType = "unified" + StorageTypeUnifiedGrpc StorageType = "unified-grpc" +) + +type StorageOptions struct { + StorageType StorageType + DataPath string + Address string +} + +func NewStorageOptions() *StorageOptions { + return &StorageOptions{ + StorageType: StorageTypeLegacy, + Address: "localhost:10000", + } +} + +func (o *StorageOptions) AddFlags(fs *pflag.FlagSet) { + fs.StringVar((*string)(&o.StorageType), "grafana-apiserver-storage-type", string(o.StorageType), "Storage type") + fs.StringVar(&o.DataPath, "grafana-apiserver-storage-path", o.DataPath, "Storage path for file storage") + fs.StringVar(&o.Address, "grafana-apiserver-storage-address", o.Address, "Remote grpc address endpoint") +} + +func (o *StorageOptions) Validate() []error { + errs := []error{} + switch o.StorageType { + case StorageTypeFile, StorageTypeEtcd, StorageTypeLegacy, StorageTypeUnified, StorageTypeUnifiedGrpc: + // no-op + default: + errs = append(errs, fmt.Errorf("--grafana-apiserver-storage-type must be one of %s, %s, %s, %s, %s", StorageTypeFile, StorageTypeEtcd, StorageTypeLegacy, StorageTypeUnified, StorageTypeUnifiedGrpc)) + } + + if _, _, err := net.SplitHostPort(o.Address); err != nil { + errs = append(errs, fmt.Errorf("--grafana-apiserver-storage-address must be a valid network address: %v", err)) + } + return errs +} + +func (o *StorageOptions) ApplyTo(serverConfig *genericapiserver.RecommendedConfig, etcdOptions *options.EtcdOptions) error { + // TODO: move storage setup here + return nil +} diff --git a/pkg/services/apiserver/service.go b/pkg/services/apiserver/service.go new file mode 100644 index 0000000000000..3315c62fabb12 --- /dev/null +++ b/pkg/services/apiserver/service.go @@ -0,0 +1,451 @@ +package apiserver + +import ( + "context" + "fmt" + "net/http" + "path" + + "github.com/grafana/dskit/services" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apiserver/pkg/endpoints/responsewriter" + genericapiserver "k8s.io/apiserver/pkg/server" + clientrest "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/apiserver/builder" + grafanaresponsewriter "github.com/grafana/grafana/pkg/apiserver/endpoints/responsewriter" + filestorage "github.com/grafana/grafana/pkg/apiserver/storage/file" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/modules" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/apiserver/aggregator" + "github.com/grafana/grafana/pkg/services/apiserver/auth/authenticator" + "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + grafanaapiserveroptions "github.com/grafana/grafana/pkg/services/apiserver/options" + entitystorage "github.com/grafana/grafana/pkg/services/apiserver/storage/entity" + "github.com/grafana/grafana/pkg/services/apiserver/utils" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/store/entity" + "github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl" + "github.com/grafana/grafana/pkg/services/store/entity/sqlstash" + "github.com/grafana/grafana/pkg/setting" +) + +var ( + _ Service = (*service)(nil) + _ RestConfigProvider = (*service)(nil) + _ registry.BackgroundService = (*service)(nil) + _ registry.CanBeDisabled = (*service)(nil) + + Scheme = runtime.NewScheme() + Codecs = serializer.NewCodecFactory(Scheme) + + unversionedVersion = schema.GroupVersion{Group: "", Version: "v1"} + unversionedTypes = []runtime.Object{ + &metav1.Status{}, + &metav1.WatchEvent{}, + &metav1.APIVersions{}, + &metav1.APIGroupList{}, + &metav1.APIGroup{}, + &metav1.APIResourceList{}, + } +) + +func init() { + // we need to add the options to empty v1 + metav1.AddToGroupVersion(Scheme, schema.GroupVersion{Group: "", Version: "v1"}) + Scheme.AddUnversionedTypes(unversionedVersion, unversionedTypes...) +} + +type Service interface { + services.NamedService + registry.BackgroundService + registry.CanBeDisabled +} + +type RestConfigProvider interface { + GetRestConfig() *clientrest.Config +} + +type DirectRestConfigProvider interface { + // GetDirectRestConfig returns a k8s client configuration that will use the same + // logged logged in user as the current request context. This is useful when + // creating clients that map legacy API handlers to k8s backed services + GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Config + + // This can be used to rewrite incoming requests to path now supported under /apis + DirectlyServeHTTP(w http.ResponseWriter, r *http.Request) +} + +type service struct { + *services.BasicService + + options *grafanaapiserveroptions.Options + restConfig *clientrest.Config + + cfg *setting.Cfg + features featuremgmt.FeatureToggles + + stopCh chan struct{} + stoppedCh chan error + + db db.DB + rr routing.RouteRegister + handler http.Handler + builders []builder.APIGroupBuilder + + tracing *tracing.TracingService + + authorizer *authorizer.GrafanaAuthorizer +} + +func ProvideService( + cfg *setting.Cfg, + features featuremgmt.FeatureToggles, + rr routing.RouteRegister, + orgService org.Service, + tracing *tracing.TracingService, + db db.DB, +) (*service, error) { + s := &service{ + cfg: cfg, + features: features, + rr: rr, + stopCh: make(chan struct{}), + builders: []builder.APIGroupBuilder{}, + authorizer: authorizer.NewGrafanaAuthorizer(cfg, orgService), + tracing: tracing, + db: db, // For Unified storage + } + + // This will be used when running as a dskit service + s.BasicService = services.NewBasicService(s.start, s.running, nil).WithName(modules.GrafanaAPIServer) + + // TODO: this is very hacky + // We need to register the routes in ProvideService to make sure + // the routes are registered before the Grafana HTTP server starts. + proxyHandler := func(k8sRoute routing.RouteRegister) { + handler := func(c *contextmodel.ReqContext) { + if s.handler == nil { + c.Resp.WriteHeader(404) + _, _ = c.Resp.Write([]byte("Not found")) + return + } + + req := c.Req + if req.URL.Path == "" { + req.URL.Path = "/" + } + + resp := responsewriter.WrapForHTTP1Or2(c.Resp) + s.handler.ServeHTTP(resp, req) + } + k8sRoute.Any("/", middleware.ReqSignedIn, handler) + k8sRoute.Any("/*", middleware.ReqSignedIn, handler) + } + + s.rr.Group("/apis", proxyHandler) + s.rr.Group("/livez", proxyHandler) + s.rr.Group("/readyz", proxyHandler) + s.rr.Group("/healthz", proxyHandler) + s.rr.Group("/openapi", proxyHandler) + s.rr.Group("/version", proxyHandler) + + return s, nil +} + +func (s *service) GetRestConfig() *clientrest.Config { + return s.restConfig +} + +func (s *service) IsDisabled() bool { + return false +} + +// Run is an adapter for the BackgroundService interface. +func (s *service) Run(ctx context.Context) error { + if err := s.start(ctx); err != nil { + return err + } + return s.running(ctx) +} + +func (s *service) RegisterAPI(b builder.APIGroupBuilder) { + s.builders = append(s.builders, b) +} + +func (s *service) start(ctx context.Context) error { + // Get the list of groups the server will support + builders := s.builders + + groupVersions := make([]schema.GroupVersion, 0, len(builders)) + // Install schemas + initialSize := len(aggregator.APIVersionPriorities) + for i, b := range builders { + groupVersions = append(groupVersions, b.GetGroupVersion()) + if err := b.InstallSchema(Scheme); err != nil { + return err + } + + if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAggregator) { + // set the priority for the group+version + aggregator.APIVersionPriorities[b.GetGroupVersion()] = aggregator.Priority{Group: 15000, Version: int32(i + initialSize)} + } + + auth := b.GetAuthorizer() + if auth != nil { + s.authorizer.Register(b.GetGroupVersion(), auth) + } + } + + o := grafanaapiserveroptions.NewOptions(Codecs.LegacyCodec(groupVersions...)) + applyGrafanaConfig(s.cfg, s.features, o) + + if errs := o.Validate(); len(errs) != 0 { + // TODO: handle multiple errors + return errs[0] + } + + serverConfig := genericapiserver.NewRecommendedConfig(Codecs) + if err := o.ApplyTo(serverConfig); err != nil { + return err + } + serverConfig.Authorization.Authorizer = s.authorizer + serverConfig.Authentication.Authenticator = authenticator.NewAuthenticator(serverConfig.Authentication.Authenticator) + serverConfig.TracerProvider = s.tracing.GetTracerProvider() + + // setup loopback transport for the aggregator server + transport := &roundTripperFunc{ready: make(chan struct{})} + serverConfig.LoopbackClientConfig.Transport = transport + serverConfig.LoopbackClientConfig.TLSClientConfig = clientrest.TLSClientConfig{} + + switch o.StorageOptions.StorageType { + case grafanaapiserveroptions.StorageTypeEtcd: + if err := o.RecommendedOptions.Etcd.Validate(); len(err) > 0 { + return err[0] + } + if err := o.RecommendedOptions.Etcd.ApplyTo(&serverConfig.Config); err != nil { + return err + } + + case grafanaapiserveroptions.StorageTypeUnified: + if !s.features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorage) { + return fmt.Errorf("unified storage requires the unifiedStorage feature flag (and app_mode = development)") + } + + eDB, err := dbimpl.ProvideEntityDB(s.db, s.cfg, s.features) + if err != nil { + return err + } + + storeServer, err := sqlstash.ProvideSQLEntityServer(eDB) + if err != nil { + return err + } + + store := entity.NewEntityStoreClientLocal(storeServer) + + serverConfig.Config.RESTOptionsGetter = entitystorage.NewRESTOptionsGetter(s.cfg, store, o.RecommendedOptions.Etcd.StorageConfig.Codec) + + case grafanaapiserveroptions.StorageTypeUnifiedGrpc: + // Create a connection to the gRPC server + conn, err := grpc.Dial(o.StorageOptions.Address, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return err + } + + // TODO: determine when to close the connection, we cannot defer it here + // defer conn.Close() + + // Create a client instance + store := entity.NewEntityStoreClientGRPC(conn) + + serverConfig.Config.RESTOptionsGetter = entitystorage.NewRESTOptionsGetter(s.cfg, store, o.RecommendedOptions.Etcd.StorageConfig.Codec) + + case grafanaapiserveroptions.StorageTypeLegacy: + fallthrough + case grafanaapiserveroptions.StorageTypeFile: + restOptionsGetter, err := filestorage.NewRESTOptionsGetter(o.StorageOptions.DataPath, o.RecommendedOptions.Etcd.StorageConfig) + if err != nil { + return err + } + serverConfig.RESTOptionsGetter = restOptionsGetter + } + + // Add OpenAPI specs for each group+version + err := builder.SetupConfig( + Scheme, + serverConfig, + builders, + s.cfg.BuildStamp, + s.cfg.BuildVersion, + s.cfg.BuildCommit, + s.cfg.BuildBranch, + ) + if err != nil { + return err + } + + // Create the server + server, err := serverConfig.Complete().New("grafana-apiserver", genericapiserver.NewEmptyDelegate()) + if err != nil { + return err + } + + // dual writing is only enabled when the storage type is not legacy. + // this is needed to support setting a default RESTOptionsGetter for new APIs that don't + // support the legacy storage type. + dualWriteEnabled := o.StorageOptions.StorageType != grafanaapiserveroptions.StorageTypeLegacy + + // Install the API group+version + err = builder.InstallAPIs(Scheme, Codecs, server, serverConfig.RESTOptionsGetter, builders, dualWriteEnabled) + if err != nil { + return err + } + + // stash the options for later use + s.options = o + + var runningServer *genericapiserver.GenericAPIServer + if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAggregator) { + runningServer, err = s.startAggregator(transport, serverConfig, server) + if err != nil { + return err + } + } else { + runningServer, err = s.startCoreServer(transport, serverConfig, server) + if err != nil { + return err + } + } + + // only write kubeconfig in dev mode + if o.ExtraOptions.DevMode { + if err := ensureKubeConfig(runningServer.LoopbackClientConfig, o.StorageOptions.DataPath); err != nil { + return err + } + } + + // used by the proxy wrapper registered in ProvideService + s.handler = runningServer.Handler + // used by local clients to make requests to the server + s.restConfig = runningServer.LoopbackClientConfig + + return nil +} + +func (s *service) startCoreServer( + transport *roundTripperFunc, + serverConfig *genericapiserver.RecommendedConfig, + server *genericapiserver.GenericAPIServer, +) (*genericapiserver.GenericAPIServer, error) { + // setup the loopback transport and signal that it's ready. + // ignore the lint error because the response is passed directly to the client, + // so the client will be responsible for closing the response body. + // nolint:bodyclose + transport.fn = grafanaresponsewriter.WrapHandler(server.Handler) + close(transport.ready) + + prepared := server.PrepareRun() + go func() { + s.stoppedCh <- prepared.Run(s.stopCh) + }() + + return server, nil +} + +func (s *service) startAggregator( + transport *roundTripperFunc, + serverConfig *genericapiserver.RecommendedConfig, + server *genericapiserver.GenericAPIServer, +) (*genericapiserver.GenericAPIServer, error) { + namespaceMapper := request.GetNamespaceMapper(s.cfg) + + aggregatorConfig, err := aggregator.CreateAggregatorConfig(s.options, *serverConfig, namespaceMapper(1)) + if err != nil { + return nil, err + } + + aggregatorServer, err := aggregator.CreateAggregatorServer(aggregatorConfig, server) + if err != nil { + return nil, err + } + + // setup the loopback transport for the aggregator server and signal that it's ready + // ignore the lint error because the response is passed directly to the client, + // so the client will be responsible for closing the response body. + // nolint:bodyclose + transport.fn = grafanaresponsewriter.WrapHandler(aggregatorServer.GenericAPIServer.Handler) + close(transport.ready) + + prepared, err := aggregatorServer.PrepareRun() + if err != nil { + return nil, err + } + + go func() { + s.stoppedCh <- prepared.Run(s.stopCh) + }() + + return aggregatorServer.GenericAPIServer, nil +} + +func (s *service) GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Config { + return &clientrest.Config{ + Transport: &roundTripperFunc{ + fn: func(req *http.Request) (*http.Response, error) { + ctx := appcontext.WithUser(req.Context(), c.SignedInUser) + wrapped := grafanaresponsewriter.WrapHandler(s.handler) + return wrapped(req.WithContext(ctx)) + }, + }, + } +} + +func (s *service) DirectlyServeHTTP(w http.ResponseWriter, r *http.Request) { + s.handler.ServeHTTP(w, r) +} + +func (s *service) running(ctx context.Context) error { + select { + case err := <-s.stoppedCh: + if err != nil { + return err + } + case <-ctx.Done(): + close(s.stopCh) + } + return nil +} + +func ensureKubeConfig(restConfig *clientrest.Config, dir string) error { + return clientcmd.WriteToFile( + utils.FormatKubeConfig(restConfig), + path.Join(dir, "grafana.kubeconfig"), + ) +} + +type roundTripperFunc struct { + ready chan struct{} + fn func(req *http.Request) (*http.Response, error) +} + +func (f *roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + if f.fn == nil { + <-f.ready + } + return f.fn(req) +} diff --git a/pkg/services/apiserver/standalone/factory.go b/pkg/services/apiserver/standalone/factory.go new file mode 100644 index 0000000000000..0ebfb2c3b9fa7 --- /dev/null +++ b/pkg/services/apiserver/standalone/factory.go @@ -0,0 +1,164 @@ +package standalone + +import ( + "context" + "fmt" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/prometheus/client_golang/prometheus" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" + "github.com/grafana/grafana/pkg/apiserver/builder" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/registry/apis/datasource" + "github.com/grafana/grafana/pkg/registry/apis/example" + "github.com/grafana/grafana/pkg/registry/apis/featuretoggle" + "github.com/grafana/grafana/pkg/registry/apis/query" + "github.com/grafana/grafana/pkg/registry/apis/query/client" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/options" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" + testdatasource "github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource" +) + +type APIServerFactory interface { + // Called before the groups are loaded so any custom command can be registered + GetOptions() options.OptionsProvider + + // Given the flags, what can we produce + GetEnabled(runtime []RuntimeConfig) ([]schema.GroupVersion, error) + + // Make an API server for a given group+version + MakeAPIServer(gv schema.GroupVersion) (builder.APIGroupBuilder, error) +} + +// Zero dependency provider for testing +func GetDummyAPIFactory() APIServerFactory { + return &DummyAPIFactory{} +} + +type DummyAPIFactory struct{} + +func (p *DummyAPIFactory) GetOptions() options.OptionsProvider { + return nil +} + +func (p *DummyAPIFactory) GetEnabled(runtime []RuntimeConfig) ([]schema.GroupVersion, error) { + gv := []schema.GroupVersion{} + for _, cfg := range runtime { + if !cfg.Enabled { + return nil, fmt.Errorf("only enabled supported now") + } + if cfg.Group == "all" { + return nil, fmt.Errorf("all not yet supported") + } + gv = append(gv, schema.GroupVersion{Group: cfg.Group, Version: cfg.Version}) + } + return gv, nil +} + +func (p *DummyAPIFactory) MakeAPIServer(gv schema.GroupVersion) (builder.APIGroupBuilder, error) { + if gv.Version != "v0alpha1" { + return nil, fmt.Errorf("only alpha supported now") + } + + switch gv.Group { + case "example.grafana.app": + return example.NewTestingAPIBuilder(), nil + + // Only works with testdata + case "query.grafana.app": + return query.NewQueryAPIBuilder( + featuremgmt.WithFeatures(), + &query.CommonDataSourceClientSupplier{ + Client: client.NewTestDataClient(), + }, + client.NewTestDataRegistry(), + nil, // legacy lookup + prometheus.NewRegistry(), // ??? + tracing.InitializeTracerForTest(), // ??? + ) + + case "featuretoggle.grafana.app": + return featuretoggle.NewFeatureFlagAPIBuilder( + featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, nil), // none... for now + &actest.FakeAccessControl{ExpectedEvaluate: false}, + &setting.Cfg{}, + ), nil + + case "testdata.datasource.grafana.app": + return datasource.NewDataSourceAPIBuilder( + plugins.JSONData{ + ID: "grafana-testdata-datasource", + }, + testdatasource.ProvideService(), // the client + &pluginDatasourceImpl{ + startup: v1.Now(), + }, + &pluginDatasourceImpl{}, // stub + &actest.FakeAccessControl{ExpectedEvaluate: true}, + ) + } + + return nil, fmt.Errorf("unsupported group") +} + +// Simple stub for standalone datasource testing +type pluginDatasourceImpl struct { + startup v1.Time +} + +var ( + _ datasource.PluginDatasourceProvider = (*pluginDatasourceImpl)(nil) +) + +// Get implements PluginDatasourceProvider. +func (p *pluginDatasourceImpl) Get(ctx context.Context, pluginID string, uid string) (*v0alpha1.DataSourceConnection, error) { + all, err := p.List(ctx, pluginID) + if err != nil { + return nil, err + } + for idx, v := range all.Items { + if v.Name == uid { + return &all.Items[idx], nil + } + } + return nil, fmt.Errorf("not found") +} + +// List implements PluginConfigProvider. +func (p *pluginDatasourceImpl) List(ctx context.Context, pluginID string) (*v0alpha1.DataSourceConnectionList, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + return &v0alpha1.DataSourceConnectionList{ + TypeMeta: v0alpha1.GenericConnectionResourceInfo.TypeMeta(), + Items: []v0alpha1.DataSourceConnection{ + { + ObjectMeta: v1.ObjectMeta{ + Name: "PD8C576611E62080A", + Namespace: info.Value, // the raw namespace value + CreationTimestamp: p.startup, + }, + Title: "gdev-testdata", + }, + }, + }, nil +} + +// PluginContextForDataSource implements PluginConfigProvider. +func (*pluginDatasourceImpl) GetInstanceSettings(ctx context.Context, pluginID, uid string) (*backend.DataSourceInstanceSettings, error) { + return &backend.DataSourceInstanceSettings{}, nil +} + +// PluginContextWrapper +func (*pluginDatasourceImpl) PluginContextForDataSource(ctx context.Context, datasourceSettings *backend.DataSourceInstanceSettings) (backend.PluginContext, error) { + return backend.PluginContext{DataSourceInstanceSettings: datasourceSettings}, nil +} diff --git a/pkg/services/apiserver/standalone/runtime.go b/pkg/services/apiserver/standalone/runtime.go new file mode 100644 index 0000000000000..7b8abcb6d9304 --- /dev/null +++ b/pkg/services/apiserver/standalone/runtime.go @@ -0,0 +1,46 @@ +package standalone + +import ( + "fmt" + "strings" +) + +type RuntimeConfig struct { + Group string + Version string + Enabled bool +} + +func (a RuntimeConfig) String() string { + return fmt.Sprintf("%s/%s=%v", a.Group, a.Version, a.Enabled) +} + +// Supported options are: +// +// <group>/<version>=true|false for a specific API group and version (e.g. dashboards.grafana.app/v0alpha1=true) +// api/all=true|false controls all API versions +// api/ga=true|false controls all API versions of the form v[0-9]+ +// api/beta=true|false controls all API versions of the form v[0-9]+beta[0-9]+ +// api/alpha=true|false controls all API versions of the form v[0-9]+alpha[0-9]+`) +// +// See: https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/ +func ReadRuntimeConfig(cfg string) ([]RuntimeConfig, error) { + if cfg == "" { + return nil, fmt.Errorf("missing --runtime-config={apiservers}") + } + parts := strings.Split(cfg, ",") + apis := make([]RuntimeConfig, len(parts)) + for i, part := range parts { + idx0 := strings.Index(part, "/") + idx1 := strings.LastIndex(part, "=") + if idx1 < idx0 || idx0 < 0 { + return nil, fmt.Errorf("expected values in the form: group/version=true") + } + apis[i] = RuntimeConfig{ + Group: part[:idx0], + Version: part[idx0+1 : idx1], + Enabled: part[idx1+1:] == "true", + } + } + return apis, nil +} diff --git a/pkg/services/apiserver/standalone/runtime_test.go b/pkg/services/apiserver/standalone/runtime_test.go new file mode 100644 index 0000000000000..374920cfd0e2a --- /dev/null +++ b/pkg/services/apiserver/standalone/runtime_test.go @@ -0,0 +1,22 @@ +package standalone + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReadRuntimeCOnfig(t *testing.T) { + out, err := ReadRuntimeConfig("all/all=true,dashboards.grafana.app/v0alpha1=false") + require.NoError(t, err) + require.Equal(t, []RuntimeConfig{ + {Group: "all", Version: "all", Enabled: true}, + {Group: "dashboards.grafana.app", Version: "v0alpha1", Enabled: false}, + }, out) + require.Equal(t, "all/all=true", fmt.Sprintf("%v", out[0])) + + // Empty is an error + _, err = ReadRuntimeConfig("") + require.Error(t, err) +} diff --git a/pkg/services/grafana-apiserver/storage/entity/restoptions.go b/pkg/services/apiserver/storage/entity/restoptions.go similarity index 96% rename from pkg/services/grafana-apiserver/storage/entity/restoptions.go rename to pkg/services/apiserver/storage/entity/restoptions.go index 78bc96984f841..58119cc87acab 100644 --- a/pkg/services/grafana-apiserver/storage/entity/restoptions.go +++ b/pkg/services/apiserver/storage/entity/restoptions.go @@ -24,11 +24,11 @@ var _ generic.RESTOptionsGetter = (*RESTOptionsGetter)(nil) type RESTOptionsGetter struct { cfg *setting.Cfg - store entityStore.EntityStoreServer + store entityStore.EntityStoreClient Codec runtime.Codec } -func NewRESTOptionsGetter(cfg *setting.Cfg, store entityStore.EntityStoreServer, codec runtime.Codec) *RESTOptionsGetter { +func NewRESTOptionsGetter(cfg *setting.Cfg, store entityStore.EntityStoreClient, codec runtime.Codec) *RESTOptionsGetter { return &RESTOptionsGetter{ cfg: cfg, store: store, diff --git a/pkg/services/apiserver/storage/entity/selector.go b/pkg/services/apiserver/storage/entity/selector.go new file mode 100644 index 0000000000000..14ad0619cea3c --- /dev/null +++ b/pkg/services/apiserver/storage/entity/selector.go @@ -0,0 +1,81 @@ +package entity + +import ( + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" +) + +const FolderAnnoKey = "grafana.app/folder" +const SortByKey = "grafana.app/sortBy" +const ListDeletedKey = "grafana.app/listDeleted" +const ListHistoryKey = "grafana.app/listHistory" + +type Requirements struct { + // Equals folder + Folder *string + // SortBy is a list of fields to sort by + SortBy []string + // ListDeleted is a flag to list deleted entities + ListDeleted bool + // ListHistory is a resource name to list the history of + ListHistory string +} + +func ReadLabelSelectors(selector labels.Selector) (Requirements, labels.Selector, error) { + requirements := Requirements{} + newSelector := labels.NewSelector() + + if selector == nil { + return requirements, newSelector, nil + } + + labelSelectors, _ := selector.Requirements() + + for _, r := range labelSelectors { + switch r.Key() { + case FolderAnnoKey: + if (r.Operator() != selection.Equals) && (r.Operator() != selection.DoubleEquals) { + return requirements, newSelector, apierrors.NewBadRequest(FolderAnnoKey + " label selector only supports equality") + } + folder := r.Values().List()[0] + requirements.Folder = &folder + case SortByKey: + if r.Operator() != selection.In { + return requirements, newSelector, apierrors.NewBadRequest(SortByKey + " label selector only supports in") + } + requirements.SortBy = r.Values().List() + case ListDeletedKey: + if r.Operator() != selection.Equals { + return requirements, newSelector, apierrors.NewBadRequest(ListDeletedKey + " label selector only supports equality") + } + if len(r.Values().List()) != 1 { + return requirements, newSelector, apierrors.NewBadRequest(ListDeletedKey + " label selector only supports one value") + } + if r.Values().List()[0] != "true" && r.Values().List()[0] != "false" { + return requirements, newSelector, apierrors.NewBadRequest(ListDeletedKey + " label selector only supports true or false") + } + requirements.ListDeleted = r.Values().List()[0] == "true" + case ListHistoryKey: + if r.Operator() != selection.Equals { + return requirements, newSelector, apierrors.NewBadRequest(ListHistoryKey + " label selector only supports equality") + } + if len(r.Values().List()) != 1 { + return requirements, newSelector, apierrors.NewBadRequest(ListHistoryKey + " label selector only supports one value") + } + if r.Values().List()[0] == "" { + return requirements, newSelector, apierrors.NewBadRequest(ListHistoryKey + " label selector must not be empty") + } + requirements.ListHistory = r.Values().List()[0] + // add all unregonized label selectors to the new selector list, these will be processed by the entity store + default: + newSelector = newSelector.Add(r) + } + } + + if requirements.ListDeleted && requirements.ListHistory != "" { + return requirements, newSelector, apierrors.NewBadRequest("cannot list deleted and history at the same time") + } + + return requirements, newSelector, nil +} diff --git a/pkg/services/grafana-apiserver/storage/entity/storage.go b/pkg/services/apiserver/storage/entity/storage.go similarity index 55% rename from pkg/services/grafana-apiserver/storage/entity/storage.go rename to pkg/services/apiserver/storage/entity/storage.go index 298f636427612..1c76c719e3ea3 100644 --- a/pkg/services/grafana-apiserver/storage/entity/storage.go +++ b/pkg/services/apiserver/storage/entity/storage.go @@ -9,9 +9,13 @@ import ( "context" "errors" "fmt" + "io" + "log" "reflect" "strconv" + grpcCodes "google.golang.org/grpc/codes" + grpcStatus "google.golang.org/grpc/status" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -26,17 +30,14 @@ import ( "k8s.io/apiserver/pkg/storage/storagebackend/factory" entityStore "github.com/grafana/grafana/pkg/services/store/entity" - "github.com/grafana/grafana/pkg/util" ) var _ storage.Interface = (*Storage)(nil) -const MaxUpdateAttempts = 1 - -// Storage implements storage.Interface and storage resources as JSON files on disk. +// Storage implements storage.Interface and stores resources in unified storage type Storage struct { config *storagebackend.ConfigForResource - store entityStore.EntityStoreServer + store entityStore.EntityStoreClient gr schema.GroupResource codec runtime.Codec keyFunc func(obj runtime.Object) (string, error) @@ -45,14 +46,12 @@ type Storage struct { getAttrsFunc storage.AttrFunc // trigger storage.IndexerFuncs // indexers *cache.Indexers - - // watchSet *WatchSet } func NewStorage( config *storagebackend.ConfigForResource, gr schema.GroupResource, - store entityStore.EntityStoreServer, + store entityStore.EntityStoreClient, codec runtime.Codec, keyFunc func(obj runtime.Object) (string, error), newFunc func() runtime.Object, @@ -84,25 +83,7 @@ func (s *Storage) Create(ctx context.Context, key string, obj runtime.Object, ou return err } - metaAccessor, err := meta.Accessor(obj) - if err != nil { - return err - } - - // Replace the default name generation strategy - if metaAccessor.GetGenerateName() != "" { - k, err := entityStore.ParseKey(key) - if err != nil { - return err - } - k.Name = util.GenerateShortUID() - key = k.String() - - metaAccessor.SetName(k.Name) - metaAccessor.SetGenerateName("") - } - - e, err := resourceToEntity(key, obj, requestInfo, s.codec) + e, err := resourceToEntity(obj, requestInfo, s.codec) if err != nil { return err } @@ -124,13 +105,6 @@ func (s *Storage) Create(ctx context.Context, key string, obj runtime.Object, ou return apierrors.NewInternalError(err) } - /* - s.watchSet.notifyWatchers(watch.Event{ - Object: out.DeepCopyObject(), - Type: watch.Added, - }) - */ - return nil } @@ -140,13 +114,26 @@ func (s *Storage) Create(ctx context.Context, key string, obj runtime.Object, ou // current version of the object to avoid read operation from storage to get it. // However, the implementations have to retry in case suggestion is stale. func (s *Storage) Delete(ctx context.Context, key string, out runtime.Object, preconditions *storage.Preconditions, validateDeletion storage.ValidateObjectFunc, cachedExistingObject runtime.Object) error { + requestInfo, ok := request.RequestInfoFrom(ctx) + if !ok { + return apierrors.NewInternalError(fmt.Errorf("could not get request info")) + } + + k := &entityStore.Key{ + Group: requestInfo.APIGroup, + Resource: requestInfo.Resource, + Namespace: requestInfo.Namespace, + Name: requestInfo.Name, + Subresource: requestInfo.Subresource, + } + previousVersion := int64(0) if preconditions != nil && preconditions.ResourceVersion != nil { previousVersion, _ = strconv.ParseInt(*preconditions.ResourceVersion, 10, 64) } rsp, err := s.store.Delete(ctx, &entityStore.DeleteEntityRequest{ - Key: key, + Key: k.String(), PreviousVersion: previousVersion, }) if err != nil { @@ -169,7 +156,109 @@ func (s *Storage) Delete(ctx context.Context, key string, out runtime.Object, pr // If resource version is "0", this interface will get current object at given key // and send it in an "ADDED" event, before watch starts. func (s *Storage) Watch(ctx context.Context, key string, opts storage.ListOptions) (watch.Interface, error) { - return nil, apierrors.NewMethodNotSupported(schema.GroupResource{}, "watch") + requestInfo, ok := request.RequestInfoFrom(ctx) + if !ok { + return nil, apierrors.NewInternalError(fmt.Errorf("could not get request info")) + } + + k := &entityStore.Key{ + Group: requestInfo.APIGroup, + Resource: requestInfo.Resource, + Namespace: requestInfo.Namespace, + Name: requestInfo.Name, + Subresource: requestInfo.Subresource, + } + + if opts.Predicate.Field != nil { + // check for metadata.name field selector + if v, ok := opts.Predicate.Field.RequiresExactMatch("metadata.name"); ok { + if k.Name != "" && k.Name != v { + return nil, apierrors.NewBadRequest("name field selector does not match key") + } + + // just watch the specific key if we have a name field selector + k.Name = v + } + + // check for metadata.namespace field selector + if v, ok := opts.Predicate.Field.RequiresExactMatch("metadata.namespace"); ok { + if k.Namespace != "" && k.Namespace != v { + return nil, apierrors.NewBadRequest("namespace field selector does not match key") + } + + // just watch the specific namespace if we have a namespace field selector + k.Namespace = v + } + } + + // translate grafana.app/* label selectors into field requirements + requirements, newSelector, err := ReadLabelSelectors(opts.Predicate.Label) + if err != nil { + return nil, err + } + + // Update the selector to remove the unneeded requirements + opts.Predicate.Label = newSelector + + // if we got a listHistory label selector, watch the specified resource + if requirements.ListHistory != "" { + if k.Name != "" && k.Name != requirements.ListHistory { + return nil, apierrors.NewBadRequest("name field selector does not match listHistory") + } + k.Name = requirements.ListHistory + } + + req := &entityStore.EntityWatchRequest{ + Key: []string{ + k.String(), + }, + Labels: map[string]string{}, + WithBody: true, + WithStatus: true, + } + + if requirements.Folder != nil { + req.Folder = *requirements.Folder + } + + // translate "equals" label selectors to storage label conditions + labelRequirements, selectable := opts.Predicate.Label.Requirements() + if !selectable { + return nil, apierrors.NewBadRequest("label selector is not selectable") + } + + for _, r := range labelRequirements { + if r.Operator() == selection.Equals { + req.Labels[r.Key()] = r.Values().List()[0] + } + } + + if opts.ResourceVersion != "" { + rv, err := strconv.ParseInt(opts.ResourceVersion, 10, 64) + if err != nil { + return nil, apierrors.NewBadRequest(fmt.Sprintf("invalid resource version: %s", opts.ResourceVersion)) + } + + req.Since = rv + } + + result, err := s.store.Watch(ctx, req) + if err != nil { + return nil, err + } + + reporter := apierrors.NewClientErrorReporter(500, "WATCH", "") + + decoder := &Decoder{ + client: result, + newFunc: s.newFunc, + opts: opts, + codec: s.codec, + } + + w := watch.NewStreamWatcher(decoder, reporter) + + return w, nil } // Get unmarshals object found at key into objPtr. On a not found error, will either @@ -178,10 +267,33 @@ func (s *Storage) Watch(ctx context.Context, key string, opts storage.ListOption // The returned contents may be delayed, but it is guaranteed that they will // match 'opts.ResourceVersion' according 'opts.ResourceVersionMatch'. func (s *Storage) Get(ctx context.Context, key string, opts storage.GetOptions, objPtr runtime.Object) error { + requestInfo, ok := request.RequestInfoFrom(ctx) + if !ok { + return apierrors.NewInternalError(fmt.Errorf("could not get request info")) + } + + k := &entityStore.Key{ + Group: requestInfo.APIGroup, + Resource: requestInfo.Resource, + Namespace: requestInfo.Namespace, + Name: requestInfo.Name, + Subresource: requestInfo.Subresource, + } + + resourceVersion := int64(0) + var err error + if opts.ResourceVersion != "" { + resourceVersion, err = strconv.ParseInt(opts.ResourceVersion, 10, 64) + if err != nil { + return apierrors.NewBadRequest(fmt.Sprintf("invalid resource version: %s", opts.ResourceVersion)) + } + } + rsp, err := s.store.Read(ctx, &entityStore.ReadEntityRequest{ - Key: key, - WithBody: true, - WithStatus: true, + Key: k.String(), + WithBody: true, + WithStatus: true, + ResourceVersion: resourceVersion, }) if err != nil { return err @@ -192,7 +304,7 @@ func (s *Storage) Get(ctx context.Context, key string, opts storage.GetOptions, return nil } - return apierrors.NewNotFound(s.gr, key) + return apierrors.NewNotFound(s.gr, k.Name) } err = entityToResource(rsp, objPtr, s.codec) @@ -210,6 +322,19 @@ func (s *Storage) Get(ctx context.Context, key string, opts storage.GetOptions, // The returned contents may be delayed, but it is guaranteed that they will // match 'opts.ResourceVersion' according 'opts.ResourceVersionMatch'. func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOptions, listObj runtime.Object) error { + requestInfo, ok := request.RequestInfoFrom(ctx) + if !ok { + return apierrors.NewInternalError(fmt.Errorf("could not get request info")) + } + + k := &entityStore.Key{ + Group: requestInfo.APIGroup, + Resource: requestInfo.Resource, + Namespace: requestInfo.Namespace, + Name: requestInfo.Name, + Subresource: requestInfo.Subresource, + } + listPtr, err := meta.GetItemsPtr(listObj) if err != nil { return err @@ -219,52 +344,99 @@ func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOpti return err } + // translate grafana.app/* label selectors into field requirements + requirements, newSelector, err := ReadLabelSelectors(opts.Predicate.Label) + if err != nil { + return err + } + + // Update the selector to remove the unneeded requirements + opts.Predicate.Label = newSelector + + if requirements.ListHistory != "" { + k.Name = requirements.ListHistory + + req := &entityStore.EntityHistoryRequest{ + Key: k.String(), + WithBody: true, + WithStatus: true, + NextPageToken: opts.Predicate.Continue, + Limit: opts.Predicate.Limit, + Sort: requirements.SortBy, + } + + rsp, err := s.store.History(ctx, req) + if err != nil { + return apierrors.NewInternalError(err) + } + + for _, r := range rsp.Versions { + res := s.newFunc() + + err := entityToResource(r, res, s.codec) + if err != nil { + return apierrors.NewInternalError(err) + } + + // apply any predicates not handled in storage + matches, err := opts.Predicate.Matches(res) + if err != nil { + return apierrors.NewInternalError(err) + } + if !matches { + continue + } + + v.Set(reflect.Append(v, reflect.ValueOf(res).Elem())) + } + + listAccessor, err := meta.ListAccessor(listObj) + if err != nil { + return err + } + + if rsp.NextPageToken != "" { + listAccessor.SetContinue(rsp.NextPageToken) + } + + listAccessor.SetResourceVersion(strconv.FormatInt(rsp.ResourceVersion, 10)) + + return nil + } + req := &entityStore.EntityListRequest{ - Key: []string{key}, + Key: []string{ + k.String(), + }, WithBody: true, WithStatus: true, NextPageToken: opts.Predicate.Continue, Limit: opts.Predicate.Limit, Labels: map[string]string{}, - // TODO push label/field matching down to storage + } + + if requirements.Folder != nil { + req.Folder = *requirements.Folder + } + if len(requirements.SortBy) > 0 { + req.Sort = requirements.SortBy + } + if requirements.ListDeleted { + req.Deleted = true } // translate "equals" label selectors to storage label conditions - requirements, selectable := opts.Predicate.Label.Requirements() + labelRequirements, selectable := opts.Predicate.Label.Requirements() if !selectable { return apierrors.NewBadRequest("label selector is not selectable") } - for _, r := range requirements { + for _, r := range labelRequirements { if r.Operator() == selection.Equals { req.Labels[r.Key()] = r.Values().List()[0] } } - // translate grafana.app/folder field selector to folder condition - fields := opts.Predicate.Field.Requirements() - for _, f := range fields { - if f.Field == "grafana.app/folder" { - if f.Operator != selection.Equals { - return apierrors.NewBadRequest("grafana.app/folder field selector only supports equality") - } - - // select items in the spcified folder - req.Folder = f.Value - } - } - - // use Transform function to remove grafana.app/folder field selector - opts.Predicate.Field, err = opts.Predicate.Field.Transform(func(field, value string) (string, string, error) { - if field == "grafana.app/folder" { - return "", "", nil - } - return field, value, nil - }) - if err != nil { - return err - } - rsp, err := s.store.List(ctx, req) if err != nil { return apierrors.NewInternalError(err) @@ -278,7 +450,7 @@ func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOpti return apierrors.NewInternalError(err) } - // TODO filter in storage + // apply any predicates not handled in storage matches, err := opts.Predicate.Matches(res) if err != nil { return apierrors.NewInternalError(err) @@ -299,6 +471,8 @@ func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOpti listAccessor.SetContinue(rsp.NextPageToken) } + listAccessor.SetResourceVersion(strconv.FormatInt(rsp.ResourceVersion, 10)) + return nil } @@ -323,33 +497,21 @@ func (s *Storage) GuaranteedUpdate( preconditions *storage.Preconditions, tryUpdate storage.UpdateFunc, cachedExistingObject runtime.Object, -) error { - var err error - for attempt := 1; attempt <= MaxUpdateAttempts; attempt = attempt + 1 { - err = s.guaranteedUpdate(ctx, key, destination, ignoreNotFound, preconditions, tryUpdate, cachedExistingObject) - if err == nil { - return nil - } - } - - return err -} - -func (s *Storage) guaranteedUpdate( - ctx context.Context, - key string, - destination runtime.Object, - ignoreNotFound bool, - preconditions *storage.Preconditions, - tryUpdate storage.UpdateFunc, - cachedExistingObject runtime.Object, ) error { requestInfo, ok := request.RequestInfoFrom(ctx) if !ok { return apierrors.NewInternalError(fmt.Errorf("could not get request info")) } - err := s.Get(ctx, key, storage.GetOptions{}, destination) + k := &entityStore.Key{ + Group: requestInfo.APIGroup, + Resource: requestInfo.Resource, + Namespace: requestInfo.Namespace, + Name: requestInfo.Name, + Subresource: requestInfo.Subresource, + } + + err := s.Get(ctx, k.String(), storage.GetOptions{}, destination) if err != nil { return err } @@ -374,10 +536,10 @@ func (s *Storage) guaranteedUpdate( } } - return apierrors.NewInternalError(fmt.Errorf("could not successfully update object. key=%s, err=%s", key, err.Error())) + return apierrors.NewInternalError(fmt.Errorf("could not successfully update object. key=%s, err=%s", k.String(), err.Error())) } - e, err := resourceToEntity(key, updatedObj, requestInfo, s.codec) + e, err := resourceToEntity(updatedObj, requestInfo, s.codec) if err != nil { return err } @@ -401,13 +563,6 @@ func (s *Storage) guaranteedUpdate( return apierrors.NewInternalError(err) } - /* - s.watchSet.notifyWatchers(watch.Event{ - Object: destination.DeepCopyObject(), - Type: watch.Modified, - }) - */ - return nil } @@ -423,3 +578,68 @@ func (s *Storage) Versioner() storage.Versioner { func (s *Storage) RequestWatchProgress(ctx context.Context) error { return nil } + +type Decoder struct { + client entityStore.EntityStore_WatchClient + newFunc func() runtime.Object + opts storage.ListOptions + codec runtime.Codec +} + +func (d *Decoder) Decode() (action watch.EventType, object runtime.Object, err error) { + for { + resp, err := d.client.Recv() + if errors.Is(err, io.EOF) { + log.Printf("watch is done") + return watch.Error, nil, err + } + + if grpcStatus.Code(err) == grpcCodes.Canceled { + log.Printf("watch was canceled") + return watch.Error, nil, err + } + + if err != nil { + log.Printf("error receiving result: %s", err) + return watch.Error, nil, err + } + + obj := d.newFunc() + + err = entityToResource(resp.Entity, obj, d.codec) + if err != nil { + log.Printf("error decoding entity: %s", err) + return watch.Error, nil, err + } + + // apply any predicates not handled in storage + matches, err := d.opts.Predicate.Matches(obj) + if err != nil { + log.Printf("error matching object: %s", err) + return watch.Error, nil, err + } + if !matches { + continue + } + + var watchAction watch.EventType + switch resp.Entity.Action { + case entityStore.Entity_CREATED: + watchAction = watch.Added + case entityStore.Entity_UPDATED: + watchAction = watch.Modified + case entityStore.Entity_DELETED: + watchAction = watch.Deleted + default: + watchAction = watch.Error + } + + return watchAction, obj, nil + } +} + +func (d *Decoder) Close() { + _ = d.client.CloseSend() +} + +var _ watch.Decoder = (*Decoder)(nil) diff --git a/pkg/services/grafana-apiserver/storage/entity/utils.go b/pkg/services/apiserver/storage/entity/utils.go similarity index 79% rename from pkg/services/grafana-apiserver/storage/entity/utils.go rename to pkg/services/apiserver/storage/entity/utils.go index 724d931c9725c..51cd46d339718 100644 --- a/pkg/services/grafana-apiserver/storage/entity/utils.go +++ b/pkg/services/apiserver/storage/entity/utils.go @@ -15,11 +15,10 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apiserver/pkg/endpoints/request" - "github.com/grafana/grafana/pkg/kinds" + "github.com/grafana/grafana/pkg/services/apiserver/utils" entityStore "github.com/grafana/grafana/pkg/services/store/entity" ) -// this is terrible... but just making it work!!!! func entityToResource(rsp *entityStore.Entity, res runtime.Object, codec runtime.Codec) error { var err error @@ -50,7 +49,10 @@ func entityToResource(rsp *entityStore.Entity, res runtime.Object, codec runtime metaAccessor.SetResourceVersion(fmt.Sprintf("%d", rsp.ResourceVersion)) metaAccessor.SetCreationTimestamp(metav1.Unix(rsp.CreatedAt/1000, rsp.CreatedAt%1000*1000000)) - grafanaAccessor := kinds.MetaAccessor(metaAccessor) + grafanaAccessor, err := utils.MetaAccessor(metaAccessor) + if err != nil { + return err + } if rsp.Folder != "" { grafanaAccessor.SetFolder(rsp.Folder) @@ -66,11 +68,11 @@ func entityToResource(rsp *entityStore.Entity, res runtime.Object, codec runtime grafanaAccessor.SetUpdatedTimestamp(&updatedAt) } grafanaAccessor.SetSlug(rsp.Slug) - grafanaAccessor.SetTitle(rsp.Title) + grafanaAccessor.SetAction(rsp.Action.String()) if rsp.Origin != nil { originTime := time.UnixMilli(rsp.Origin.Time).UTC() - grafanaAccessor.SetOriginInfo(&kinds.ResourceOriginInfo{ + grafanaAccessor.SetOriginInfo(&utils.ResourceOriginInfo{ Name: rsp.Origin.Source, Key: rsp.Origin.Key, // Path: rsp.Origin.Path, @@ -97,23 +99,34 @@ func entityToResource(rsp *entityStore.Entity, res runtime.Object, codec runtime return nil } -func resourceToEntity(key string, res runtime.Object, requestInfo *request.RequestInfo, codec runtime.Codec) (*entityStore.Entity, error) { +func resourceToEntity(res runtime.Object, requestInfo *request.RequestInfo, codec runtime.Codec) (*entityStore.Entity, error) { metaAccessor, err := meta.Accessor(res) if err != nil { return nil, err } - grafanaAccessor := kinds.MetaAccessor(metaAccessor) + grafanaAccessor, err := utils.MetaAccessor(metaAccessor) + if err != nil { + return nil, err + } rv, _ := strconv.ParseInt(metaAccessor.GetResourceVersion(), 10, 64) + k := &entityStore.Key{ + Group: requestInfo.APIGroup, + Resource: requestInfo.Resource, + Namespace: requestInfo.Namespace, + Name: metaAccessor.GetName(), + Subresource: requestInfo.Subresource, + } + rsp := &entityStore.Entity{ - Group: requestInfo.APIGroup, + Group: k.Group, GroupVersion: requestInfo.APIVersion, - Resource: requestInfo.Resource, - Subresource: requestInfo.Subresource, - Namespace: metaAccessor.GetNamespace(), - Key: key, - Name: metaAccessor.GetName(), + Resource: k.Resource, + Subresource: k.Subresource, + Namespace: k.Namespace, + Key: k.String(), + Name: k.Name, Guid: string(metaAccessor.GetUID()), ResourceVersion: rv, Folder: grafanaAccessor.GetFolder(), @@ -121,7 +134,7 @@ func resourceToEntity(key string, res runtime.Object, requestInfo *request.Reque CreatedBy: grafanaAccessor.GetCreatedBy(), UpdatedBy: grafanaAccessor.GetUpdatedBy(), Slug: grafanaAccessor.GetSlug(), - Title: grafanaAccessor.GetTitle(), + Title: grafanaAccessor.FindTitle(metaAccessor.GetName()), Origin: &entityStore.EntityOriginInfo{ Source: grafanaAccessor.GetOriginName(), Key: grafanaAccessor.GetOriginKey(), diff --git a/pkg/services/grafana-apiserver/storage/entity/utils_test.go b/pkg/services/apiserver/storage/entity/utils_test.go similarity index 91% rename from pkg/services/grafana-apiserver/storage/entity/utils_test.go rename to pkg/services/apiserver/storage/entity/utils_test.go index 88828707aea34..c5e470bdbca51 100644 --- a/pkg/services/grafana-apiserver/storage/entity/utils_test.go +++ b/pkg/services/apiserver/storage/entity/utils_test.go @@ -24,22 +24,18 @@ func TestResourceToEntity(t *testing.T) { updatedAt := createdAt.Add(time.Hour).Truncate(time.Second) updatedAtStr := updatedAt.UTC().Format(time.RFC3339) - apiVersion := "v0alpha1" - requestInfo := &request.RequestInfo{ - APIVersion: apiVersion, - } - Scheme := runtime.NewScheme() Scheme.AddKnownTypes(v0alpha1.PlaylistResourceInfo.GroupVersion(), &v0alpha1.Playlist{}) Codecs := serializer.NewCodecFactory(Scheme) testCases := []struct { - key string + requestInfo *request.RequestInfo resource runtime.Object codec runtime.Codec expectedKey string expectedGroupVersion string expectedName string + expectedNamespace string expectedTitle string expectedGuid string expectedVersion string @@ -55,11 +51,14 @@ func TestResourceToEntity(t *testing.T) { expectedBody []byte }{ { - key: "/playlist.grafana.app/playlists/default/test-uid", + requestInfo: &request.RequestInfo{ + APIGroup: "playlist.grafana.app", + APIVersion: "v0alpha1", + Resource: "playlists", + Namespace: "default", + Name: "test-name", + }, resource: &v0alpha1.Playlist{ - TypeMeta: metav1.TypeMeta{ - APIVersion: apiVersion, - }, ObjectMeta: metav1.ObjectMeta{ CreationTimestamp: createdAt, Labels: map[string]string{"label1": "value1", "label2": "value2"}, @@ -83,9 +82,11 @@ func TestResourceToEntity(t *testing.T) { }, }, }, - expectedKey: "/playlist.grafana.app/playlists/default/test-uid", - expectedGroupVersion: apiVersion, + expectedKey: "/playlist.grafana.app/playlists/namespaces/default/test-name", + expectedGroupVersion: "v0alpha1", expectedName: "test-name", + expectedNamespace: "default", + expectedTitle: "A playlist", expectedGuid: "test-uid", expectedVersion: "1", expectedFolder: "test-folder", @@ -103,10 +104,11 @@ func TestResourceToEntity(t *testing.T) { for _, tc := range testCases { t.Run(tc.resource.GetObjectKind().GroupVersionKind().Kind+" to entity conversion should succeed", func(t *testing.T) { - entity, err := resourceToEntity(tc.key, tc.resource, requestInfo, Codecs.LegacyCodec(v0alpha1.PlaylistResourceInfo.GroupVersion())) + entity, err := resourceToEntity(tc.resource, tc.requestInfo, Codecs.LegacyCodec(v0alpha1.PlaylistResourceInfo.GroupVersion())) require.NoError(t, err) assert.Equal(t, tc.expectedKey, entity.Key) assert.Equal(t, tc.expectedName, entity.Name) + assert.Equal(t, tc.expectedNamespace, entity.Namespace) assert.Equal(t, tc.expectedTitle, entity.Title) assert.Equal(t, tc.expectedGroupVersion, entity.GroupVersion) assert.Equal(t, tc.expectedName, entity.Name) @@ -151,10 +153,10 @@ func TestEntityToResource(t *testing.T) { }{ { entity: &entityStore.Entity{ - Key: "/playlist.grafana.app/playlists/default/test-uid", + Key: "/playlist.grafana.app/playlists/namespaces/default/test-uid", GroupVersion: "v0alpha1", Name: "test-uid", - Title: "test-name", + Title: "A playlist", Guid: "test-guid", Folder: "test-folder", CreatedBy: "test-created-by", @@ -167,6 +169,7 @@ func TestEntityToResource(t *testing.T) { Meta: []byte(fmt.Sprintf(`{"metadata":{"name":"test-name","uid":"test-uid","resourceVersion":"1","creationTimestamp":%q,"labels":{"label1":"value1","label2":"value2"},"annotations":{"grafana.app/createdBy":"test-created-by","grafana.app/folder":"test-folder","grafana.app/slug":"test-slug","grafana.app/updatedTimestamp":%q,"grafana.app/updatedBy":"test-updated-by"}}}`, createdAtStr, updatedAtStr)), Body: []byte(fmt.Sprintf(`{"kind":"Playlist","apiVersion":"playlist.grafana.app/v0alpha1","metadata":{"name":"test-name","uid":"test-uid","resourceVersion":"1","creationTimestamp":%q,"labels":{"label1":"value1","label2":"value2"},"annotations":{"grafana.app/createdBy":"test-created-by","grafana.app/folder":"test-folder","grafana.app/slug":"test-slug","grafana.app/updatedBy":"test-updated-by","grafana.app/updatedTimestamp":%q}},"spec":{"title":"A playlist","interval":"5m","items":[{"type":"dashboard_by_tag","value":"panel-tests"},{"type":"dashboard_by_uid","value":"vmie2cmWz"}]}}`, createdAtStr, updatedAtStr)), ResourceVersion: 1, + Action: entityStore.Entity_CREATED, }, codec: runtime.Codec(nil), expectedApiVersion: "playlist.grafana.app/v0alpha1", @@ -177,10 +180,10 @@ func TestEntityToResource(t *testing.T) { expectedResourceVersion: "1", expectedUid: "test-guid", expectedAnnotations: map[string]string{ + "grafana.app/action": "CREATED", "grafana.app/createdBy": "test-created-by", "grafana.app/folder": "test-folder", "grafana.app/slug": "test-slug", - "grafana.app/title": "test-name", "grafana.app/updatedBy": "test-updated-by", "grafana.app/updatedTimestamp": updatedAtStr, }, diff --git a/pkg/services/grafana-apiserver/utils/clientConfig.go b/pkg/services/apiserver/utils/clientConfig.go similarity index 100% rename from pkg/services/grafana-apiserver/utils/clientConfig.go rename to pkg/services/apiserver/utils/clientConfig.go diff --git a/pkg/services/apiserver/utils/meta.go b/pkg/services/apiserver/utils/meta.go new file mode 100644 index 0000000000000..e2b7a6f8f2979 --- /dev/null +++ b/pkg/services/apiserver/utils/meta.go @@ -0,0 +1,278 @@ +package utils + +import ( + "fmt" + "reflect" + "time" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Annotation keys + +const AnnoKeyCreatedBy = "grafana.app/createdBy" +const AnnoKeyUpdatedTimestamp = "grafana.app/updatedTimestamp" +const AnnoKeyUpdatedBy = "grafana.app/updatedBy" +const AnnoKeyFolder = "grafana.app/folder" +const AnnoKeySlug = "grafana.app/slug" +const AnnoKeyAction = "grafana.app/action" + +// Identify where values came from + +const AnnoKeyOriginName = "grafana.app/originName" +const AnnoKeyOriginPath = "grafana.app/originPath" +const AnnoKeyOriginKey = "grafana.app/originKey" +const AnnoKeyOriginTimestamp = "grafana.app/originTimestamp" + +// ResourceOriginInfo is saved in annotations. This is used to identify where the resource came from +// This object can model the same data as our existing provisioning table or a more general git sync +type ResourceOriginInfo struct { + // Name of the origin/provisioning source + Name string `json:"name,omitempty"` + + // The path within the named origin above (external_id in the existing dashboard provisioing) + Path string `json:"path,omitempty"` + + // Verification/identification key (check_sum in existing dashboard provisioning) + Key string `json:"key,omitempty"` + + // Origin modification timestamp when the resource was saved + // This will be before the resource updated time + Timestamp *time.Time `json:"time,omitempty"` + + // Avoid extending + _ any `json:"-"` +} + +// Accessor functions for k8s objects +type GrafanaResourceMetaAccessor interface { + GetUpdatedTimestamp() (*time.Time, error) + SetUpdatedTimestamp(v *time.Time) + SetUpdatedTimestampMillis(unix int64) + GetCreatedBy() string + SetCreatedBy(user string) + GetUpdatedBy() string + SetUpdatedBy(user string) + GetFolder() string + SetFolder(uid string) + GetSlug() string + SetSlug(v string) + GetAction() string + SetAction(v string) + + GetOriginInfo() (*ResourceOriginInfo, error) + SetOriginInfo(info *ResourceOriginInfo) + GetOriginName() string + GetOriginPath() string + GetOriginKey() string + GetOriginTimestamp() (*time.Time, error) + + // Find a title in the object + // This will reflect the object and try to get: + // * spec.title + // * spec.name + // * title + // and return an empty string if nothing was found + FindTitle(defaultTitle string) string +} + +var _ GrafanaResourceMetaAccessor = (*grafanaResourceMetaAccessor)(nil) + +type grafanaResourceMetaAccessor struct { + raw interface{} // the original object (it implements metav1.Object) + obj metav1.Object +} + +// Accessor takes an arbitrary object pointer and returns meta.Interface. +// obj must be a pointer to an API type. An error is returned if the minimum +// required fields are missing. Fields that are not required return the default +// value and are a no-op if set. +func MetaAccessor(raw interface{}) (GrafanaResourceMetaAccessor, error) { + obj, err := meta.Accessor(raw) + if err != nil { + return nil, err + } + return &grafanaResourceMetaAccessor{raw, obj}, nil +} + +func (m *grafanaResourceMetaAccessor) set(key string, val string) { + anno := m.obj.GetAnnotations() + if val == "" { + if anno != nil { + delete(anno, key) + } + } else { + if anno == nil { + anno = make(map[string]string) + } + anno[key] = val + } + m.obj.SetAnnotations(anno) +} + +func (m *grafanaResourceMetaAccessor) get(key string) string { + return m.obj.GetAnnotations()[key] +} + +func (m *grafanaResourceMetaAccessor) GetUpdatedTimestamp() (*time.Time, error) { + v, ok := m.obj.GetAnnotations()[AnnoKeyUpdatedTimestamp] + if !ok || v == "" { + return nil, nil + } + t, err := time.Parse(time.RFC3339, v) + if err != nil { + return nil, fmt.Errorf("invalid updated timestamp: %s", err.Error()) + } + return &t, nil +} + +func (m *grafanaResourceMetaAccessor) SetUpdatedTimestampMillis(v int64) { + if v > 0 { + t := time.UnixMilli(v) + m.SetUpdatedTimestamp(&t) + } else { + m.set(AnnoKeyUpdatedTimestamp, "") // will clear the annotation + } +} + +func (m *grafanaResourceMetaAccessor) SetUpdatedTimestamp(v *time.Time) { + txt := "" + if v != nil && v.Unix() != 0 { + txt = v.UTC().Format(time.RFC3339) + } + m.set(AnnoKeyUpdatedTimestamp, txt) +} + +func (m *grafanaResourceMetaAccessor) GetCreatedBy() string { + return m.get(AnnoKeyCreatedBy) +} + +func (m *grafanaResourceMetaAccessor) SetCreatedBy(user string) { + m.set(AnnoKeyCreatedBy, user) +} + +func (m *grafanaResourceMetaAccessor) GetUpdatedBy() string { + return m.get(AnnoKeyUpdatedBy) +} + +func (m *grafanaResourceMetaAccessor) SetUpdatedBy(user string) { + m.set(AnnoKeyUpdatedBy, user) +} + +func (m *grafanaResourceMetaAccessor) GetFolder() string { + return m.get(AnnoKeyFolder) +} + +func (m *grafanaResourceMetaAccessor) SetFolder(uid string) { + m.set(AnnoKeyFolder, uid) +} + +func (m *grafanaResourceMetaAccessor) GetSlug() string { + return m.get(AnnoKeySlug) +} + +func (m *grafanaResourceMetaAccessor) SetSlug(v string) { + m.set(AnnoKeySlug, v) +} + +func (m *grafanaResourceMetaAccessor) GetAction() string { + return m.get(AnnoKeyAction) +} + +func (m *grafanaResourceMetaAccessor) SetAction(v string) { + m.set(AnnoKeyAction, v) +} + +func (m *grafanaResourceMetaAccessor) SetOriginInfo(info *ResourceOriginInfo) { + anno := m.obj.GetAnnotations() + if anno == nil { + if info == nil { + return + } + anno = make(map[string]string, 0) + } + + delete(anno, AnnoKeyOriginName) + delete(anno, AnnoKeyOriginPath) + delete(anno, AnnoKeyOriginKey) + delete(anno, AnnoKeyOriginTimestamp) + if info != nil && info.Name != "" { + anno[AnnoKeyOriginName] = info.Name + if info.Path != "" { + anno[AnnoKeyOriginPath] = info.Path + } + if info.Key != "" { + anno[AnnoKeyOriginKey] = info.Key + } + if info.Timestamp != nil { + anno[AnnoKeyOriginTimestamp] = info.Timestamp.Format(time.RFC3339) + } + } + m.obj.SetAnnotations(anno) +} + +func (m *grafanaResourceMetaAccessor) GetOriginInfo() (*ResourceOriginInfo, error) { + v, ok := m.obj.GetAnnotations()[AnnoKeyOriginName] + if !ok { + return nil, nil + } + t, err := m.GetOriginTimestamp() + return &ResourceOriginInfo{ + Name: v, + Path: m.GetOriginPath(), + Key: m.GetOriginKey(), + Timestamp: t, + }, err +} + +func (m *grafanaResourceMetaAccessor) GetOriginName() string { + return m.get(AnnoKeyOriginName) +} + +func (m *grafanaResourceMetaAccessor) GetOriginPath() string { + return m.get(AnnoKeyOriginPath) +} + +func (m *grafanaResourceMetaAccessor) GetOriginKey() string { + return m.get(AnnoKeyOriginKey) +} + +func (m *grafanaResourceMetaAccessor) GetOriginTimestamp() (*time.Time, error) { + v, ok := m.obj.GetAnnotations()[AnnoKeyOriginTimestamp] + if !ok || v == "" { + return nil, nil + } + t, err := time.Parse(time.RFC3339, v) + if err != nil { + return nil, fmt.Errorf("invalid origin timestamp: %s", err.Error()) + } + return &t, nil +} + +func (m *grafanaResourceMetaAccessor) FindTitle(defaultTitle string) string { + // look for Spec.Title or Spec.Name + r := reflect.ValueOf(m.raw) + if r.Kind() == reflect.Ptr || r.Kind() == reflect.Interface { + r = r.Elem() + } + if r.Kind() == reflect.Struct { + spec := r.FieldByName("Spec") + if spec.Kind() == reflect.Struct { + title := spec.FieldByName("Title") + if title.IsValid() && title.Kind() == reflect.String { + return title.String() + } + name := spec.FieldByName("Name") + if name.IsValid() && name.Kind() == reflect.String { + return name.String() + } + } + + title := r.FieldByName("Title") + if title.IsValid() && title.Kind() == reflect.String { + return title.String() + } + } + return defaultTitle +} diff --git a/pkg/services/apiserver/utils/meta_test.go b/pkg/services/apiserver/utils/meta_test.go new file mode 100644 index 0000000000000..e8555bdff6ec7 --- /dev/null +++ b/pkg/services/apiserver/utils/meta_test.go @@ -0,0 +1,208 @@ +package utils_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/grafana/grafana/pkg/services/apiserver/utils" +) + +type TestResource struct { + metav1.TypeMeta `json:",inline"` + // Standard object's metadata + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec Spec `json:"spec,omitempty"` +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TestResource) DeepCopyInto(out *TestResource) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Playlist. +func (in *TestResource) DeepCopy() *TestResource { + if in == nil { + return nil + } + out := new(TestResource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TestResource) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// Spec defines model for Spec. +type Spec struct { + // Name of the object. + Title string `json:"title"` +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Spec) DeepCopyInto(out *Spec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Spec. +func (in *Spec) DeepCopy() *Spec { + if in == nil { + return nil + } + out := new(Spec) + in.DeepCopyInto(out) + return out +} + +type TestResource2 struct { + metav1.TypeMeta `json:",inline"` + // Standard object's metadata + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec Spec2 `json:"spec,omitempty"` +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TestResource2) DeepCopyInto(out *TestResource2) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Playlist. +func (in *TestResource2) DeepCopy() *TestResource2 { + if in == nil { + return nil + } + out := new(TestResource2) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TestResource2) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// Spec defines model for Spec. +type Spec2 struct{} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Spec2) DeepCopyInto(out *Spec2) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Spec. +func (in *Spec2) DeepCopy() *Spec2 { + if in == nil { + return nil + } + out := new(Spec2) + in.DeepCopyInto(out) + return out +} + +func TestMetaAccessor(t *testing.T) { + originInfo := &utils.ResourceOriginInfo{ + Name: "test", + Path: "a/b/c", + Key: "kkk", + } + + t.Run("fails for non resource objects", func(t *testing.T) { + _, err := utils.MetaAccessor("hello") + require.Error(t, err) + + _, err = utils.MetaAccessor(unstructured.Unstructured{}) + require.Error(t, err) // Not a pointer! + + _, err = utils.MetaAccessor(&unstructured.Unstructured{}) + require.NoError(t, err) // Must be a pointer + + _, err = utils.MetaAccessor(&TestResource{ + Spec: Spec{ + Title: "HELLO", + }, + }) + require.NoError(t, err) // Must be a pointer + }) + + t.Run("get and set grafana metadata", func(t *testing.T) { + res := &unstructured.Unstructured{} + meta, err := utils.MetaAccessor(res) + require.NoError(t, err) + + meta.SetOriginInfo(originInfo) + meta.SetFolder("folderUID") + + require.Equal(t, map[string]string{ + "grafana.app/originName": "test", + "grafana.app/originPath": "a/b/c", + "grafana.app/originKey": "kkk", + "grafana.app/folder": "folderUID", + }, res.GetAnnotations()) + }) + + t.Run("find titles", func(t *testing.T) { + // with a k8s object that has Spec.Title + obj := &TestResource{ + Spec: Spec{ + Title: "HELLO", + }, + } + + meta, err := utils.MetaAccessor(obj) + require.NoError(t, err) + meta.SetOriginInfo(originInfo) + meta.SetFolder("folderUID") + + require.Equal(t, map[string]string{ + "grafana.app/originName": "test", + "grafana.app/originPath": "a/b/c", + "grafana.app/originKey": "kkk", + "grafana.app/folder": "folderUID", + }, obj.GetAnnotations()) + + require.Equal(t, "HELLO", obj.Spec.Title) + require.Equal(t, "HELLO", meta.FindTitle("")) + obj.Spec.Title = "" + require.Equal(t, "", meta.FindTitle("xxx")) + + // with a k8s object without Spec.Title + obj2 := &TestResource2{} + + meta, err = utils.MetaAccessor(obj2) + require.NoError(t, err) + meta.SetOriginInfo(originInfo) + meta.SetFolder("folderUID") + + require.Equal(t, map[string]string{ + "grafana.app/originName": "test", + "grafana.app/originPath": "a/b/c", + "grafana.app/originKey": "kkk", + "grafana.app/folder": "folderUID", + }, obj2.GetAnnotations()) + + require.Equal(t, "xxx", meta.FindTitle("xxx")) + }) +} diff --git a/pkg/services/grafana-apiserver/utils/tableConverter.go b/pkg/services/apiserver/utils/tableConverter.go similarity index 100% rename from pkg/services/grafana-apiserver/utils/tableConverter.go rename to pkg/services/apiserver/utils/tableConverter.go diff --git a/pkg/services/grafana-apiserver/utils/tableConverter_test.go b/pkg/services/apiserver/utils/tableConverter_test.go similarity index 98% rename from pkg/services/grafana-apiserver/utils/tableConverter_test.go rename to pkg/services/apiserver/utils/tableConverter_test.go index ec1310475c957..7c7618d4f0d1b 100644 --- a/pkg/services/grafana-apiserver/utils/tableConverter_test.go +++ b/pkg/services/apiserver/utils/tableConverter_test.go @@ -11,7 +11,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils" + "github.com/grafana/grafana/pkg/services/apiserver/utils" ) func TestTableConverter(t *testing.T) { diff --git a/pkg/services/grafana-apiserver/utils/uids.go b/pkg/services/apiserver/utils/uids.go similarity index 100% rename from pkg/services/grafana-apiserver/utils/uids.go rename to pkg/services/apiserver/utils/uids.go diff --git a/pkg/services/grafana-apiserver/wireset.go b/pkg/services/apiserver/wireset.go similarity index 65% rename from pkg/services/grafana-apiserver/wireset.go rename to pkg/services/apiserver/wireset.go index 25a02e898ef10..f8e840e424bc6 100644 --- a/pkg/services/grafana-apiserver/wireset.go +++ b/pkg/services/apiserver/wireset.go @@ -1,13 +1,15 @@ -package grafanaapiserver +package apiserver import ( "github.com/google/wire" + + "github.com/grafana/grafana/pkg/apiserver/builder" ) var WireSet = wire.NewSet( ProvideService, wire.Bind(new(RestConfigProvider), new(*service)), wire.Bind(new(Service), new(*service)), - wire.Bind(new(APIRegistrar), new(*service)), wire.Bind(new(DirectRestConfigProvider), new(*service)), + wire.Bind(new(builder.APIRegistrar), new(*service)), ) diff --git a/pkg/services/auth/authimpl/auth_token.go b/pkg/services/auth/authimpl/auth_token.go index c4879c7f3efd9..2b28327e6d546 100644 --- a/pkg/services/auth/authimpl/auth_token.go +++ b/pkg/services/auth/authimpl/auth_token.go @@ -66,7 +66,7 @@ type UserAuthTokenService struct { } func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) { - token, hashedToken, err := generateAndHashToken() + token, hashedToken, err := generateAndHashToken(s.cfg.SecretKey) if err != nil { return nil, err } @@ -112,7 +112,7 @@ func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, } func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { - hashedToken := hashToken(unhashedToken) + hashedToken := hashToken(s.cfg.SecretKey, unhashedToken) var model userAuthToken var exists bool var err error @@ -249,7 +249,7 @@ func (s *UserAuthTokenService) rotateToken(ctx context.Context, token *auth.User clientIPStr = clientIP.String() } - newToken, hashedToken, err := generateAndHashToken() + newToken, hashedToken, err := generateAndHashToken(s.cfg.SecretKey) if err != nil { return nil, err } @@ -338,7 +338,7 @@ func (s *UserAuthTokenService) TryRotateToken(ctx context.Context, token *auth.U if err != nil { return nil, err } - hashedToken := hashToken(newToken) + hashedToken := hashToken(s.cfg.SecretKey, newToken) // very important that auth_token_seen is set after the prev_auth_token = case when ... for mysql to function correctly sql := ` @@ -627,18 +627,18 @@ func createToken() (string, error) { return token, nil } -func hashToken(token string) string { - hashBytes := sha256.Sum256([]byte(token + setting.SecretKey)) +func hashToken(secretKey string, token string) string { + hashBytes := sha256.Sum256([]byte(token + secretKey)) return hex.EncodeToString(hashBytes[:]) } -func generateAndHashToken() (string, string, error) { +func generateAndHashToken(secretKey string) (string, string, error) { token, err := createToken() if err != nil { return "", "", err } - return token, hashToken(token), nil + return token, hashToken(secretKey, token), nil } func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { diff --git a/pkg/services/auth/authimpl/auth_token_test.go b/pkg/services/auth/authimpl/auth_token_test.go index 6c3db6c8bc492..b0a8beb94f151 100644 --- a/pkg/services/auth/authimpl/auth_token_test.go +++ b/pkg/services/auth/authimpl/auth_token_test.go @@ -19,8 +19,13 @@ import ( "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationUserAuthToken(t *testing.T) { ctx := createTestContext(t) usr := &user.User{ID: int64(10)} @@ -479,14 +484,14 @@ func TestIntegrationUserAuthToken(t *testing.T) { token, err = ctx.tokenService.RotateToken(context.Background(), auth.RotateCommand{UnHashedToken: token.UnhashedToken}) require.NoError(t, err) assert.True(t, token.UnhashedToken != prev) - assert.True(t, token.PrevAuthToken == hashToken(prev)) + assert.True(t, token.PrevAuthToken == hashToken("", prev)) }) t.Run("should rotate token when called with previous", func(t *testing.T) { newPrev := token.UnhashedToken token, err = ctx.tokenService.RotateToken(context.Background(), auth.RotateCommand{UnHashedToken: prev}) require.NoError(t, err) - assert.True(t, token.PrevAuthToken == hashToken(newPrev)) + assert.True(t, token.PrevAuthToken == hashToken("", newPrev)) }) t.Run("should not rotate token when called with old previous", func(t *testing.T) { diff --git a/pkg/services/auth/id.go b/pkg/services/auth/id.go index 82aa7905a5b00..06d302d0f1567 100644 --- a/pkg/services/auth/id.go +++ b/pkg/services/auth/id.go @@ -6,7 +6,6 @@ import ( "github.com/go-jose/go-jose/v3/jwt" "github.com/grafana/grafana/pkg/services/auth/identity" - "github.com/grafana/grafana/pkg/services/datasources" ) type IDService interface { @@ -20,10 +19,5 @@ type IDSigner interface { type IDClaims struct { jwt.Claims -} - -const settingsKey = "forwardGrafanaIdToken" - -func IsIDForwardingEnabledForDataSource(ds *datasources.DataSource) bool { - return ds.JsonData != nil && ds.JsonData.Get(settingsKey).MustBool() + AuthenticatedBy string `json:"authenticatedBy,omitempty"` } diff --git a/pkg/services/auth/identity/requester.go b/pkg/services/auth/identity/requester.go index b88120e88f25b..dadc5e5f9fabe 100644 --- a/pkg/services/auth/identity/requester.go +++ b/pkg/services/auth/identity/requester.go @@ -20,6 +20,11 @@ var ErrNotIntIdentifier = errors.New("identifier is not an int64") var ErrIdentifierNotInitialized = errors.New("identifier is not initialized") type Requester interface { + // GetID returns namespaced id for the entity + GetID() string + // GetNamespacedID returns the namespace and ID of the active entity. + // The namespace is one of the constants defined in pkg/services/auth/identity. + GetNamespacedID() (namespace string, identifier string) // GetDisplayName returns the display name of the active entity. // The display name is the name if it is set, otherwise the login or email. GetDisplayName() string @@ -31,15 +36,14 @@ type Requester interface { // GetLogin returns the login of the active entity // Can be empty. GetLogin() string - // GetNamespacedID returns the namespace and ID of the active entity. - // The namespace is one of the constants defined in pkg/services/auth/identity. - GetNamespacedID() (namespace string, identifier string) // GetOrgID returns the ID of the active organization GetOrgID() int64 // GetOrgRole returns the role of the active entity in the active organization. GetOrgRole() roletype.RoleType // GetPermissions returns the permissions of the active entity. GetPermissions() map[string][]string + // GetGlobalPermissions returns the permissions of the active entity that are available across all organizations. + GetGlobalPermissions() map[string][]string // DEPRECATED: GetTeams returns the teams the entity is a member of. // Retrieve the teams from the team service instead of using this method. GetTeams() []int64 diff --git a/pkg/services/auth/idimpl/service.go b/pkg/services/auth/idimpl/service.go index 566ad620838f0..07c5cc5991dff 100644 --- a/pkg/services/auth/idimpl/service.go +++ b/pkg/services/auth/idimpl/service.go @@ -2,7 +2,9 @@ package idimpl import ( "context" + "errors" "fmt" + "strconv" "time" "github.com/go-jose/go-jose/v3/jwt" @@ -15,22 +17,29 @@ import ( "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) const ( cachePrefix = "id-token" - tokenTTL = 1 * time.Hour - cacheTTL = 58 * time.Minute + tokenTTL = 10 * time.Minute + cacheLeeway = 30 * time.Second ) var _ auth.IDService = (*Service)(nil) func ProvideService( cfg *setting.Cfg, signer auth.IDSigner, cache remotecache.CacheStorage, - features featuremgmt.FeatureToggles, authnService authn.Service, reg prometheus.Registerer, + features featuremgmt.FeatureToggles, authnService authn.Service, + authInfoService login.AuthInfoService, reg prometheus.Registerer, ) *Service { - s := &Service{cfg: cfg, logger: log.New("id-service"), signer: signer, cache: cache, metrics: newMetrics(reg)} + s := &Service{ + cfg: cfg, logger: log.New("id-service"), + signer: signer, cache: cache, + authInfoService: authInfoService, metrics: newMetrics(reg), + } if features.IsEnabledGlobally(featuremgmt.FlagIdForwarding) { authnService.RegisterPostAuthHook(s.hook, 140) @@ -40,12 +49,13 @@ func ProvideService( } type Service struct { - cfg *setting.Cfg - logger log.Logger - signer auth.IDSigner - cache remotecache.CacheStorage - si singleflight.Group - metrics *metrics + cfg *setting.Cfg + logger log.Logger + signer auth.IDSigner + cache remotecache.CacheStorage + authInfoService login.AuthInfoService + si singleflight.Group + metrics *metrics } func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (string, error) { @@ -61,15 +71,15 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri cachedToken, err := s.cache.Get(ctx, cacheKey) if err == nil { s.metrics.tokenSigningFromCacheCounter.Inc() - s.logger.Debug("Cached token found", "namespace", namespace, "id", identifier) + s.logger.FromContext(ctx).Debug("Cached token found", "namespace", namespace, "id", identifier) return string(cachedToken), nil } s.metrics.tokenSigningCounter.Inc() - s.logger.Debug("Sign new id token", "namespace", namespace, "id", identifier) + s.logger.FromContext(ctx).Debug("Sign new id token", "namespace", namespace, "id", identifier) now := time.Now() - token, err := s.signer.SignIDToken(ctx, &auth.IDClaims{ + claims := &auth.IDClaims{ Claims: jwt.Claims{ Issuer: s.cfg.AppURL, Audience: getAudience(id.GetOrgID()), @@ -77,15 +87,37 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri Expiry: jwt.NewNumericDate(now.Add(tokenTTL)), IssuedAt: jwt.NewNumericDate(now), }, - }) + } + + if identity.IsNamespace(namespace, identity.NamespaceUser) { + if err := s.setUserClaims(ctx, identifier, claims); err != nil { + return "", err + } + } + token, err := s.signer.SignIDToken(ctx, claims) if err != nil { s.metrics.failedTokenSigningCounter.Inc() return "", err } - if err := s.cache.Set(ctx, cacheKey, []byte(token), cacheTTL); err != nil { - s.logger.Error("Failed to add id token to cache", "error", err) + parsed, err := jwt.ParseSigned(token) + if err != nil { + s.metrics.failedTokenSigningCounter.Inc() + return "", err + } + + extracted := auth.IDClaims{} + // We don't need to verify the signature here, we are only intrested in checking + // when the token expires. + if err := parsed.UnsafeClaimsWithoutVerification(&extracted); err != nil { + s.metrics.failedTokenSigningCounter.Inc() + return "", err + } + + expires := time.Until(extracted.Expiry.Time()) + if err := s.cache.Set(ctx, cacheKey, []byte(token), expires-cacheLeeway); err != nil { + s.logger.FromContext(ctx).Error("Failed to add id token to cache", "error", err) } return token, nil @@ -98,12 +130,37 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri return result.(string), nil } +func (s *Service) setUserClaims(ctx context.Context, identifier string, claims *auth.IDClaims) error { + id, err := strconv.ParseInt(identifier, 10, 64) + if err != nil { + return err + } + + if id == 0 { + return nil + } + + info, err := s.authInfoService.GetAuthInfo(ctx, &login.GetAuthInfoQuery{UserId: id}) + if err != nil { + // we ignore errors when a user don't have external user auth + if !errors.Is(err, user.ErrUserNotFound) { + s.logger.FromContext(ctx).Error("Failed to fetch auth info", "userId", id, "error", err) + } + + return nil + } + + claims.AuthenticatedBy = info.AuthModule + + return nil +} + func (s *Service) hook(ctx context.Context, identity *authn.Identity, _ *authn.Request) error { // FIXME(kalleep): we should probably lazy load this token, err := s.SignIdentity(ctx, identity) if err != nil { namespace, id := identity.GetNamespacedID() - s.logger.Error("Failed to sign id token", "err", err, "namespace", namespace, "id", id) + s.logger.FromContext(ctx).Error("Failed to sign id token", "err", err, "namespace", namespace, "id", id) // for now don't return error so we don't break authentication from this hook return nil } diff --git a/pkg/services/auth/idimpl/service_test.go b/pkg/services/auth/idimpl/service_test.go index 14a7fb36d5ea8..a5e746904b5f8 100644 --- a/pkg/services/auth/idimpl/service_test.go +++ b/pkg/services/auth/idimpl/service_test.go @@ -1,13 +1,23 @@ package idimpl import ( + "context" "testing" + "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v3/jwt" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/remotecache" + "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/idtest" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn/authntest" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/login/authinfotest" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -22,7 +32,7 @@ func Test_ProvideService(t *testing.T) { }, } - _ = ProvideService(setting.NewCfg(), nil, nil, features, authnService, nil) + _ = ProvideService(setting.NewCfg(), nil, nil, features, authnService, nil, nil) assert.True(t, hookRegistered) }) @@ -36,7 +46,50 @@ func Test_ProvideService(t *testing.T) { }, } - _ = ProvideService(setting.NewCfg(), nil, nil, features, authnService, nil) + _ = ProvideService(setting.NewCfg(), nil, nil, features, authnService, nil, nil) assert.False(t, hookRegistered) }) } + +func TestService_SignIdentity(t *testing.T) { + signer := &idtest.MockSigner{ + SignIDTokenFn: func(_ context.Context, claims *auth.IDClaims) (string, error) { + key := []byte("key") + s, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: key}, nil) + require.NoError(t, err) + + token, err := jwt.Signed(s).Claims(claims).CompactSerialize() + require.NoError(t, err) + + return token, nil + }, + } + + t.Run("should sing identity", func(t *testing.T) { + s := ProvideService( + setting.NewCfg(), signer, remotecache.NewFakeCacheStorage(), + featuremgmt.WithFeatures(featuremgmt.FlagIdForwarding), + &authntest.FakeService{}, &authinfotest.FakeService{ExpectedError: user.ErrUserNotFound}, nil, + ) + token, err := s.SignIdentity(context.Background(), &authn.Identity{ID: "user:1"}) + require.NoError(t, err) + require.NotEmpty(t, token) + }) + + t.Run("should sing identity with authenticated by if user is externally authenticated", func(t *testing.T) { + s := ProvideService( + setting.NewCfg(), signer, remotecache.NewFakeCacheStorage(), + featuremgmt.WithFeatures(featuremgmt.FlagIdForwarding), + &authntest.FakeService{}, &authinfotest.FakeService{ExpectedUserAuth: &login.UserAuth{AuthModule: login.AzureADAuthModule}}, nil, + ) + token, err := s.SignIdentity(context.Background(), &authn.Identity{ID: "user:1"}) + require.NoError(t, err) + + parsed, err := jwt.ParseSigned(token) + require.NoError(t, err) + + claims := &auth.IDClaims{} + require.NoError(t, parsed.UnsafeClaimsWithoutVerification(&claims)) + assert.Equal(t, login.AzureADAuthModule, claims.AuthenticatedBy) + }) +} diff --git a/pkg/services/auth/idimpl/signer.go b/pkg/services/auth/idimpl/signer.go index 5bb004deb1167..e69a8422b4808 100644 --- a/pkg/services/auth/idimpl/signer.go +++ b/pkg/services/auth/idimpl/signer.go @@ -37,7 +37,7 @@ func (s *LocalSigner) SignIDToken(ctx context.Context, claims *auth.IDClaims) (s return "", err } - builder := jwt.Signed(signer).Claims(claims.Claims) + builder := jwt.Signed(signer).Claims(claims) token, err := builder.CompactSerialize() if err != nil { diff --git a/pkg/services/auth/idtest/mock.go b/pkg/services/auth/idtest/mock.go new file mode 100644 index 0000000000000..eafc244ec5b53 --- /dev/null +++ b/pkg/services/auth/idtest/mock.go @@ -0,0 +1,18 @@ +package idtest + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/auth" +) + +type MockSigner struct { + SignIDTokenFn func(ctx context.Context, claims *auth.IDClaims) (string, error) +} + +func (s *MockSigner) SignIDToken(ctx context.Context, claims *auth.IDClaims) (string, error) { + if s.SignIDTokenFn != nil { + return s.SignIDTokenFn(ctx, claims) + } + return "", nil +} diff --git a/pkg/services/auth/jwt/auth.go b/pkg/services/auth/jwt/auth.go index c2d317a056e02..37e0a7fa37395 100644 --- a/pkg/services/auth/jwt/auth.go +++ b/pkg/services/auth/jwt/auth.go @@ -33,7 +33,7 @@ func newService(cfg *setting.Cfg, remoteCache *remotecache.RemoteCache) *AuthSer } func (s *AuthService) init() error { - if !s.Cfg.JWTAuthEnabled { + if !s.Cfg.JWTAuth.Enabled { return nil } diff --git a/pkg/services/auth/jwt/auth_test.go b/pkg/services/auth/jwt/auth_test.go index 3cc93a5f912d6..73d2106b72dd9 100644 --- a/pkg/services/auth/jwt/auth_test.go +++ b/pkg/services/auth/jwt/auth_test.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) type scenarioContext struct { @@ -39,6 +40,10 @@ type cachingScenarioFunc func(*testing.T, cachingScenarioContext) const subject = "foo-subj" +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestVerifyUsingPKIXPublicKeyFile(t *testing.T) { key := rsaKeys[0] unknownKey := rsaKeys[1] @@ -70,7 +75,7 @@ func TestVerifyUsingPKIXPublicKeyFile(t *testing.T) { assert.Equal(t, verifiedClaims["sub"], subject) }, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) { t.Helper() - cfg.JWTAuthKeyID = publicKeyID + cfg.JWTAuth.KeyID = publicKeyID }) } @@ -89,7 +94,7 @@ func TestVerifyUsingJWKSetFile(t *testing.T) { require.NoError(t, json.NewEncoder(file).Encode(jwksPublic)) require.NoError(t, file.Close()) - cfg.JWTAuthJWKSetFile = file.Name() + cfg.JWTAuth.JWKSetFile = file.Name() } scenario(t, "verifies a token signed with a key from the set", func(t *testing.T, sc scenarioContext) { @@ -118,22 +123,18 @@ func TestVerifyUsingJWKSetURL(t *testing.T) { var err error _, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthJWKSetURL = "https://example.com/.well-known/jwks.json" + cfg.JWTAuth.JWKSetURL = "https://example.com/.well-known/jwks.json" }) require.NoError(t, err) _, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthJWKSetURL = "http://example.com/.well-known/jwks.json" + cfg.JWTAuth.JWKSetURL = "http://example.com/.well-known/jwks.json" }) require.NoError(t, err) - oldEnv := setting.Env - setting.Env = setting.Prod - defer func() { - setting.Env = oldEnv - }() _, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthJWKSetURL = "http://example.com/.well-known/jwks.json" + cfg.Env = setting.Prod + cfg.JWTAuth.JWKSetURL = "http://example.com/.well-known/jwks.json" }) require.Error(t, err) }) @@ -184,7 +185,7 @@ func TestCachingJWKHTTPResponse(t *testing.T) { assert.Equal(t, 1, *sc.reqCount) }, func(t *testing.T, cfg *setting.Cfg) { // Arbitrary high value, several times what the test should take. - cfg.JWTAuthCacheTTL = time.Minute + cfg.JWTAuth.CacheTTL = time.Minute }) jwkCachingScenario(t, "does not cache the response when TTL is zero", func(t *testing.T, sc cachingScenarioContext) { @@ -195,7 +196,7 @@ func TestCachingJWKHTTPResponse(t *testing.T) { assert.Equal(t, 2, *sc.reqCount) }, func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthCacheTTL = 0 + cfg.JWTAuth.CacheTTL = 0 }) } @@ -220,7 +221,7 @@ func TestClaimValidation(t *testing.T) { _, err = sc.authJWTSvc.Verify(sc.ctx, tokenInvalid) require.Error(t, err) }, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthExpectClaims = `{"iss": "http://foo"}` + cfg.JWTAuth.ExpectClaims = `{"iss": "http://foo"}` }) scenario(t, "validates sub field for equality", func(t *testing.T, sc scenarioContext) { @@ -235,7 +236,7 @@ func TestClaimValidation(t *testing.T) { _, err = sc.authJWTSvc.Verify(sc.ctx, tokenInvalid) require.Error(t, err) }, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthExpectClaims = `{"sub": "foo"}` + cfg.JWTAuth.ExpectClaims = `{"sub": "foo"}` }) scenario(t, "validates aud field for inclusion", func(t *testing.T, sc scenarioContext) { @@ -256,7 +257,7 @@ func TestClaimValidation(t *testing.T) { _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Audience: []string{"baz"}}, nil)) require.Error(t, err) }, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthExpectClaims = `{"aud": ["foo", "bar"]}` + cfg.JWTAuth.ExpectClaims = `{"aud": ["foo", "bar"]}` }) scenario(t, "validates non-registered (custom) claims for equality", func(t *testing.T, sc scenarioContext) { @@ -277,7 +278,7 @@ func TestClaimValidation(t *testing.T) { _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, map[string]any{"my-number": 123}, nil)) require.Error(t, err) }, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthExpectClaims = `{"my-str": "foo", "my-number": 123}` + cfg.JWTAuth.ExpectClaims = `{"my-str": "foo", "my-number": 123}` }) scenario(t, "validates exp claim of the token", func(t *testing.T, sc scenarioContext) { @@ -322,7 +323,7 @@ func jwkHTTPScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...configur t.Cleanup(ts.Close) configure := func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthJWKSetURL = ts.URL + cfg.JWTAuth.JWKSetURL = ts.URL } runner := scenarioRunner(func(t *testing.T, sc scenarioContext) { keySet := sc.authJWTSvc.keySet.(*keySetHTTP) @@ -354,8 +355,8 @@ func jwkCachingScenario(t *testing.T, desc string, fn cachingScenarioFunc, cbs . t.Cleanup(ts.Close) configure := func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthJWKSetURL = ts.URL - cfg.JWTAuthCacheTTL = time.Hour + cfg.JWTAuth.JWKSetURL = ts.URL + cfg.JWTAuth.CacheTTL = time.Hour } runner := scenarioRunner(func(t *testing.T, sc scenarioContext) { keySet := sc.authJWTSvc.keySet.(*keySetHTTP) @@ -396,8 +397,8 @@ func initAuthService(t *testing.T, cbs ...configureFunc) (*AuthService, error) { t.Helper() cfg := setting.NewCfg() - cfg.JWTAuthEnabled = true - cfg.JWTAuthExpectClaims = "{}" + cfg.JWTAuth.Enabled = true + cfg.JWTAuth.ExpectClaims = "{}" for _, cb := range cbs { cb(t, cfg) @@ -441,5 +442,5 @@ func configurePKIXPublicKeyFile(t *testing.T, cfg *setting.Cfg) { })) require.NoError(t, file.Close()) - cfg.JWTAuthKeyFile = file.Name() + cfg.JWTAuth.KeyFile = file.Name() } diff --git a/pkg/services/auth/jwt/jwt.go b/pkg/services/auth/jwt/jwt.go index c0c7ddd42920b..c1175b5c72dc0 100644 --- a/pkg/services/auth/jwt/jwt.go +++ b/pkg/services/auth/jwt/jwt.go @@ -2,9 +2,11 @@ package jwt import ( "context" + + "github.com/grafana/grafana/pkg/util" ) -type JWTClaims map[string]any +type JWTClaims util.DynMap type JWTService interface { Verify(ctx context.Context, strToken string) (JWTClaims, error) diff --git a/pkg/services/auth/jwt/key_sets.go b/pkg/services/auth/jwt/key_sets.go index 6c9597c895bd0..5361f3b3fbb03 100644 --- a/pkg/services/auth/jwt/key_sets.go +++ b/pkg/services/auth/jwt/key_sets.go @@ -49,13 +49,13 @@ type keySetHTTP struct { func (s *AuthService) checkKeySetConfiguration() error { var count int - if s.Cfg.JWTAuthKeyFile != "" { + if s.Cfg.JWTAuth.KeyFile != "" { count++ } - if s.Cfg.JWTAuthJWKSetFile != "" { + if s.Cfg.JWTAuth.JWKSetFile != "" { count++ } - if s.Cfg.JWTAuthJWKSetURL != "" { + if s.Cfg.JWTAuth.JWKSetURL != "" { count++ } @@ -75,7 +75,7 @@ func (s *AuthService) initKeySet() error { return err } - if keyFilePath := s.Cfg.JWTAuthKeyFile; keyFilePath != "" { + if keyFilePath := s.Cfg.JWTAuth.KeyFile; keyFilePath != "" { // nolint:gosec // We can ignore the gosec G304 warning on this one because `fileName` comes from grafana configuration file file, err := os.Open(keyFilePath) @@ -125,10 +125,10 @@ func (s *AuthService) initKeySet() error { s.keySet = &keySetJWKS{ jose.JSONWebKeySet{ - Keys: []jose.JSONWebKey{{Key: key, KeyID: s.Cfg.JWTAuthKeyID}}, + Keys: []jose.JSONWebKey{{Key: key, KeyID: s.Cfg.JWTAuth.KeyID}}, }, } - } else if keyFilePath := s.Cfg.JWTAuthJWKSetFile; keyFilePath != "" { + } else if keyFilePath := s.Cfg.JWTAuth.JWKSetFile; keyFilePath != "" { // nolint:gosec // We can ignore the gosec G304 warning on this one because `fileName` comes from grafana configuration file file, err := os.Open(keyFilePath) @@ -147,12 +147,12 @@ func (s *AuthService) initKeySet() error { } s.keySet = &keySetJWKS{jwks} - } else if urlStr := s.Cfg.JWTAuthJWKSetURL; urlStr != "" { + } else if urlStr := s.Cfg.JWTAuth.JWKSetURL; urlStr != "" { urlParsed, err := url.Parse(urlStr) if err != nil { return err } - if urlParsed.Scheme != "https" && setting.Env != setting.Dev { + if urlParsed.Scheme != "https" && s.Cfg.Env != setting.Dev { return ErrJWTSetURLMustHaveHTTPSScheme } s.keySet = &keySetHTTP{ @@ -176,7 +176,7 @@ func (s *AuthService) initKeySet() error { Timeout: time.Second * 30, }, cacheKey: fmt.Sprintf("auth-jwt:jwk-%s", urlStr), - cacheExpiration: s.Cfg.JWTAuthCacheTTL, + cacheExpiration: s.Cfg.JWTAuth.CacheTTL, cache: s.RemoteCache, } } @@ -194,7 +194,11 @@ func (ks *keySetHTTP) getJWKS(ctx context.Context) (keySetJWKS, error) { if ks.cacheExpiration > 0 { if val, err := ks.cache.Get(ctx, ks.cacheKey); err == nil { err := json.Unmarshal(val, &jwks) - return jwks, err + if err != nil { + ks.log.Warn("Failed to unmarshal key set from cache", "err", err) + } else { + return jwks, err + } } } diff --git a/pkg/services/auth/jwt/validation.go b/pkg/services/auth/jwt/validation.go index 260aa964f29cd..4fe5b88119ff2 100644 --- a/pkg/services/auth/jwt/validation.go +++ b/pkg/services/auth/jwt/validation.go @@ -10,7 +10,7 @@ import ( ) func (s *AuthService) initClaimExpectations() error { - if err := json.Unmarshal([]byte(s.Cfg.JWTAuthExpectClaims), &s.expect); err != nil { + if err := json.Unmarshal([]byte(s.Cfg.JWTAuth.ExpectClaims), &s.expect); err != nil { return err } diff --git a/pkg/services/authn/authn.go b/pkg/services/authn/authn.go index 12ac3b826c1a2..27658cecb44d1 100644 --- a/pkg/services/authn/authn.go +++ b/pkg/services/authn/authn.go @@ -77,6 +77,10 @@ type Service interface { RedirectURL(ctx context.Context, client string, r *Request) (*Redirect, error) // Logout revokes session token and does additional clean up if client used to authenticate supports it Logout(ctx context.Context, user identity.Requester, sessionToken *usertoken.UserToken) (*Redirect, error) + + // ResolveIdentity resolves an identity from org and namespace id. + ResolveIdentity(ctx context.Context, orgID int64, namespaceID string) (*Identity, error) + // RegisterClient will register a new authn.Client that can be used for authentication RegisterClient(c Client) } diff --git a/pkg/services/authn/authnimpl/service.go b/pkg/services/authn/authnimpl/service.go index 661b03aed4284..6573ef3dd85b8 100644 --- a/pkg/services/authn/authnimpl/service.go +++ b/pkg/services/authn/authnimpl/service.go @@ -24,7 +24,6 @@ import ( "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn/authnimpl/sync" "github.com/grafana/grafana/pkg/services/authn/clients" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ldap/service" "github.com/grafana/grafana/pkg/services/login" @@ -72,7 +71,8 @@ func ProvideService( features *featuremgmt.FeatureManager, oauthTokenService oauthtoken.OAuthTokenService, socialService social.Service, cache *remotecache.RemoteCache, ldapService service.LDAP, registerer prometheus.Registerer, - signingKeysService signingkeys.Service, oauthServer oauthserver.OAuth2Server, + signingKeysService signingkeys.Service, + settingsProviderService setting.Provider, ) *Service { s := &Service{ log: log.New("authn.service"), @@ -89,11 +89,11 @@ func ProvideService( usageStats.RegisterMetricsFunc(s.getUsageStats) - s.RegisterClient(clients.ProvideRender(userService, renderService)) - s.RegisterClient(clients.ProvideAPIKey(apikeyService, userService)) + s.RegisterClient(clients.ProvideRender(renderService)) + s.RegisterClient(clients.ProvideAPIKey(apikeyService)) if cfg.LoginCookieName != "" { - s.RegisterClient(clients.ProvideSession(cfg, sessionService, features)) + s.RegisterClient(clients.ProvideSession(cfg, sessionService)) } var proxyClients []authn.ProxyClient @@ -122,8 +122,8 @@ func ProvideService( } } - if s.cfg.AuthProxyEnabled && len(proxyClients) > 0 { - proxy, err := clients.ProvideProxy(cfg, cache, userService, proxyClients...) + if s.cfg.AuthProxy.Enabled && len(proxyClients) > 0 { + proxy, err := clients.ProvideProxy(cfg, cache, proxyClients...) if err != nil { s.log.Error("Failed to configure auth proxy", "err", err) } else { @@ -131,39 +131,38 @@ func ProvideService( } } - if s.cfg.JWTAuthEnabled { + if s.cfg.JWTAuth.Enabled { s.RegisterClient(clients.ProvideJWT(jwtService, cfg)) } - if s.cfg.ExtendedJWTAuthEnabled && features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) { - s.RegisterClient(clients.ProvideExtendedJWT(userService, cfg, signingKeysService, oauthServer)) - } + // FIXME (gamab): Commenting that out for now as we want to re-use the client for external service auth + // if s.cfg.ExtendedJWTAuthEnabled && features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) { + // s.RegisterClient(clients.ProvideExtendedJWT(userService, cfg, signingKeysService, oauthServer)) + // } for name := range socialService.GetOAuthProviders() { - oauthCfg := socialService.GetOAuthInfoProvider(name) - if oauthCfg != nil && oauthCfg.Enabled { - clientName := authn.ClientWithPrefix(name) - - connector, errConnector := socialService.GetConnector(name) - httpClient, errHTTPClient := socialService.GetOAuthHttpClient(name) - if errConnector != nil || errHTTPClient != nil { - s.log.Error("Failed to configure oauth client", "client", clientName, "err", errors.Join(errConnector, errHTTPClient)) - } else { - s.RegisterClient(clients.ProvideOAuth(clientName, cfg, oauthCfg, connector, httpClient, oauthTokenService)) - } - } + clientName := authn.ClientWithPrefix(name) + s.RegisterClient(clients.ProvideOAuth(clientName, cfg, oauthTokenService, socialService, settingsProviderService)) } // FIXME (jguer): move to User package userSyncService := sync.ProvideUserSync(userService, userProtectionService, authInfoService, quotaService) - orgUserSyncService := sync.ProvideOrgSync(userService, orgService, accessControlService) + orgUserSyncService := sync.ProvideOrgSync(userService, orgService, accessControlService, cfg) s.RegisterPostAuthHook(userSyncService.SyncUserHook, 10) s.RegisterPostAuthHook(userSyncService.EnableUserHook, 20) s.RegisterPostAuthHook(orgUserSyncService.SyncOrgRolesHook, 30) - s.RegisterPostAuthHook(userSyncService.SyncLastSeenHook, 120) + s.RegisterPostAuthHook(userSyncService.SyncLastSeenHook, 130) s.RegisterPostAuthHook(sync.ProvideOAuthTokenSync(oauthTokenService, sessionService, socialService).SyncOauthTokenHook, 60) s.RegisterPostAuthHook(userSyncService.FetchSyncedUserHook, 100) - s.RegisterPostAuthHook(sync.ProvidePermissionsSync(accessControlService).SyncPermissionsHook, 110) + + rbacSync := sync.ProvideRBACSync(accessControlService) + if features.IsEnabledGlobally(featuremgmt.FlagCloudRBACRoles) { + s.RegisterPostAuthHook(rbacSync.SyncCloudRoles, 110) + } + + s.RegisterPostAuthHook(rbacSync.SyncPermissionsHook, 120) + + s.RegisterPostAuthHook(orgUserSyncService.SetDefaultOrgHook, 130) return s } @@ -392,6 +391,20 @@ Default: return redirect, nil } +func (s *Service) ResolveIdentity(ctx context.Context, orgID int64, namespaceID string) (*authn.Identity, error) { + r := &authn.Request{} + r.OrgID = orgID + // hack to not update last seen + r.SetMeta(authn.MetaKeyIsLogin, "true") + + identity, err := s.authenticate(ctx, clients.ProvideIdentity(namespaceID), r) + if err != nil { + return nil, err + } + + return identity, nil +} + func (s *Service) RegisterClient(c authn.Client) { s.clients[c.Name()] = c if cac, ok := c.(authn.ContextAwareClient); ok { diff --git a/pkg/services/authn/authnimpl/sync/oauth_token_sync.go b/pkg/services/authn/authnimpl/sync/oauth_token_sync.go index c1a852b696bc8..8756ce603cba8 100644 --- a/pkg/services/authn/authnimpl/sync/oauth_token_sync.go +++ b/pkg/services/authn/authnimpl/sync/oauth_token_sync.go @@ -3,26 +3,20 @@ package sync import ( "context" "errors" - "fmt" - "strings" "time" - "github.com/go-jose/go-jose/v3/jwt" "golang.org/x/sync/singleflight" - "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/authn" - "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/oauthtoken" ) func ProvideOAuthTokenSync(service oauthtoken.OAuthTokenService, sessionService auth.UserTokenService, socialService social.Service) *OAuthTokenSync { return &OAuthTokenSync{ log.New("oauth_token.sync"), - localcache.New(maxOAuthTokenCacheTTL, 15*time.Minute), service, sessionService, socialService, @@ -31,12 +25,11 @@ func ProvideOAuthTokenSync(service oauthtoken.OAuthTokenService, sessionService } type OAuthTokenSync struct { - log log.Logger - cache *localcache.CacheService - service oauthtoken.OAuthTokenService - sessionService auth.UserTokenService - socialService social.Service - sf *singleflight.Group + log log.Logger + service oauthtoken.OAuthTokenService + sessionService auth.UserTokenService + socialService social.Service + singleflightGroup *singleflight.Group } func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, identity *authn.Identity, _ *authn.Request) error { @@ -51,71 +44,14 @@ func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, identity *authn return nil } - // if we recently have performed this it would be cached, so we can skip the hook - if _, ok := s.cache.Get(identity.ID); ok { - s.log.FromContext(ctx).Debug("OAuth token check is cached", "id", identity.ID) - return nil - } - - token, exists, err := s.service.HasOAuthEntry(ctx, identity) - // user is not authenticated through oauth so skip further checks - if !exists { - if err != nil { - s.log.FromContext(ctx).Error("Failed to fetch oauth entry", "id", identity.ID, "error", err) - } - return nil - } - - idTokenExpiry, err := getIDTokenExpiry(token) - if err != nil { - s.log.FromContext(ctx).Error("Failed to extract expiry of ID token", "id", identity.ID, "error", err) - } - - // token has no expire time configured, so we don't have to refresh it - if token.OAuthExpiry.IsZero() { - s.log.FromContext(ctx).Debug("Access token without expiry", "id", identity.ID) - // cache the token check, so we don't perform it on every request - s.cache.Set(identity.ID, struct{}{}, getOAuthTokenCacheTTL(token.OAuthExpiry, idTokenExpiry)) - return nil - } - - // get the token's auth provider (f.e. azuread) - provider := strings.TrimPrefix(token.AuthModule, "oauth_") - currentOAuthInfo := s.socialService.GetOAuthInfoProvider(provider) - if currentOAuthInfo == nil { - s.log.Warn("OAuth provider not found", "provider", provider) - return nil - } - - // if refresh token handling is disabled for this provider, we can skip the hook - if !currentOAuthInfo.UseRefreshToken { - return nil - } - - accessTokenExpires, hasAccessTokenExpired := getExpiryWithSkew(token.OAuthExpiry) - - hasIdTokenExpired := false - idTokenExpires := time.Time{} - - if !idTokenExpiry.IsZero() { - idTokenExpires, hasIdTokenExpired = getExpiryWithSkew(idTokenExpiry) - } - // token has not expired, so we don't have to refresh it - if !hasAccessTokenExpired && !hasIdTokenExpired { - s.log.FromContext(ctx).Debug("Access and id token has not expired yet", "id", identity.ID) - // cache the token check, so we don't perform it on every request - s.cache.Set(identity.ID, struct{}{}, getOAuthTokenCacheTTL(accessTokenExpires, idTokenExpires)) - return nil - } - - _, err, _ = s.sf.Do(identity.ID, func() (interface{}, error) { + _, err, _ := s.singleflightGroup.Do(identity.ID, func() (interface{}, error) { s.log.Debug("Singleflight request for OAuth token sync", "key", identity.ID) // FIXME: Consider using context.WithoutCancel instead of context.Background after Go 1.21 update updateCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - if refreshErr := s.service.TryTokenRefresh(updateCtx, token); refreshErr != nil { + if refreshErr := s.service.TryTokenRefresh(updateCtx, identity); refreshErr != nil { if errors.Is(refreshErr, context.Canceled) { return nil, nil } @@ -153,56 +89,3 @@ func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, identity *authn return nil } - -const maxOAuthTokenCacheTTL = 10 * time.Minute - -func getOAuthTokenCacheTTL(accessTokenExpiry, idTokenExpiry time.Time) time.Duration { - if accessTokenExpiry.IsZero() && idTokenExpiry.IsZero() { - return maxOAuthTokenCacheTTL - } - - min := func(a, b time.Duration) time.Duration { - if a <= b { - return a - } - return b - } - - if accessTokenExpiry.IsZero() && !idTokenExpiry.IsZero() { - return min(time.Until(idTokenExpiry), maxOAuthTokenCacheTTL) - } - - if !accessTokenExpiry.IsZero() && idTokenExpiry.IsZero() { - return min(time.Until(accessTokenExpiry), maxOAuthTokenCacheTTL) - } - - return min(min(time.Until(accessTokenExpiry), time.Until(idTokenExpiry)), maxOAuthTokenCacheTTL) -} - -// getIDTokenExpiry extracts the expiry time from the ID token -func getIDTokenExpiry(token *login.UserAuth) (time.Time, error) { - if token.OAuthIdToken == "" { - return time.Time{}, nil - } - - parsedToken, err := jwt.ParseSigned(token.OAuthIdToken) - if err != nil { - return time.Time{}, fmt.Errorf("error parsing id token: %w", err) - } - - type Claims struct { - Exp int64 `json:"exp"` - } - var claims Claims - if err := parsedToken.UnsafeClaimsWithoutVerification(&claims); err != nil { - return time.Time{}, fmt.Errorf("error getting claims from id token: %w", err) - } - - return time.Unix(claims.Exp, 0), nil -} - -func getExpiryWithSkew(expiry time.Time) (adjustedExpiry time.Time, hasTokenExpired bool) { - adjustedExpiry = expiry.Round(0).Add(-oauthtoken.ExpiryDelta) - hasTokenExpired = adjustedExpiry.Before(time.Now()) - return -} diff --git a/pkg/services/authn/authnimpl/sync/oauth_token_sync_test.go b/pkg/services/authn/authnimpl/sync/oauth_token_sync_test.go index 3632deb563382..9bd0863c82c85 100644 --- a/pkg/services/authn/authnimpl/sync/oauth_token_sync_test.go +++ b/pkg/services/authn/authnimpl/sync/oauth_token_sync_test.go @@ -2,18 +2,13 @@ package sync import ( "context" - "encoding/base64" - "encoding/json" "errors" - "fmt" "testing" "time" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "golang.org/x/sync/singleflight" - "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/login/social/socialtest" @@ -45,45 +40,17 @@ func TestOAuthTokenSync_SyncOAuthTokenHook(t *testing.T) { tests := []testCase{ { - desc: "should skip sync when identity is not a user", - identity: &authn.Identity{ID: "service-account:1"}, - }, - { - desc: "should skip sync when identity is a user but is not authenticated with session token", - identity: &authn.Identity{ID: "user:1"}, - }, - { - desc: "should skip sync when user has session but is not authenticated with oauth", - identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}}, - expectHasEntryCalled: true, - }, - { - desc: "should skip sync for when access token don't have expire time", - identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}}, - expectHasEntryCalled: true, - expectedHasEntryToken: &login.UserAuth{}, - }, - { - desc: "should skip sync when access token has no expired yet", - identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}}, - expectHasEntryCalled: true, - expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(10 * time.Minute)}, - }, - { - desc: "should skip sync when access token has no expired yet", - identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}}, - expectHasEntryCalled: true, - expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(10 * time.Minute)}, + desc: "should skip sync when identity is not a user", + identity: &authn.Identity{ID: "service-account:1"}, + expectTryRefreshTokenCalled: false, }, { - desc: "should refresh access token when it has expired", - identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}}, - expectHasEntryCalled: true, - expectTryRefreshTokenCalled: true, - expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(-10 * time.Minute)}, + desc: "should skip sync when identity is a user but is not authenticated with session token", + identity: &authn.Identity{ID: "user:1"}, + expectTryRefreshTokenCalled: false, }, { - desc: "should invalidate access token and session token if access token can't be refreshed", + desc: "should invalidate access token and session token if token refresh fails", identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}}, expectHasEntryCalled: true, expectedTryRefreshErr: errors.New("some err"), @@ -92,21 +59,27 @@ func TestOAuthTokenSync_SyncOAuthTokenHook(t *testing.T) { expectRevokeTokenCalled: true, expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(-10 * time.Minute)}, expectedErr: authn.ErrExpiredAccessToken, - }, { - desc: "should skip sync when use_refresh_token is disabled", - identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}, AuthenticatedBy: login.GitLabAuthModule}, - expectHasEntryCalled: true, - expectTryRefreshTokenCalled: false, - expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(-10 * time.Minute)}, - oauthInfo: &social.OAuthInfo{UseRefreshToken: false}, }, { - desc: "should refresh access token when ID token has expired", - identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}}, - expectHasEntryCalled: true, - expectTryRefreshTokenCalled: true, - expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(10 * time.Minute), OAuthIdToken: fakeIDToken(t, time.Now().Add(-10*time.Minute))}, + desc: "should refresh the token successfully", + identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}}, + expectHasEntryCalled: false, + expectTryRefreshTokenCalled: true, + expectInvalidateOauthTokensCalled: false, + expectRevokeTokenCalled: false, + }, + { + desc: "should not invalidate the token if the token has already been refreshed by another request (singleflight)", + identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}}, + expectHasEntryCalled: true, + expectTryRefreshTokenCalled: true, + expectInvalidateOauthTokensCalled: false, + expectRevokeTokenCalled: false, + expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(10 * time.Minute)}, + expectedTryRefreshErr: errors.New("some err"), }, + + // TODO: address coverage of oauthtoken sync } for _, tt := range tests { @@ -127,7 +100,7 @@ func TestOAuthTokenSync_SyncOAuthTokenHook(t *testing.T) { invalidateTokensCalled = true return nil }, - TryTokenRefreshFunc: func(ctx context.Context, usr *login.UserAuth) error { + TryTokenRefreshFunc: func(ctx context.Context, usr identity.Requester) error { tryRefreshCalled = true return tt.expectedTryRefreshErr }, @@ -151,12 +124,11 @@ func TestOAuthTokenSync_SyncOAuthTokenHook(t *testing.T) { } sync := &OAuthTokenSync{ - log: log.NewNopLogger(), - cache: localcache.New(0, 0), - service: service, - sessionService: sessionService, - socialService: socialService, - sf: new(singleflight.Group), + log: log.NewNopLogger(), + service: service, + sessionService: sessionService, + socialService: socialService, + singleflightGroup: new(singleflight.Group), } err := sync.SyncOauthTokenHook(context.Background(), tt.identity, nil) @@ -168,93 +140,3 @@ func TestOAuthTokenSync_SyncOAuthTokenHook(t *testing.T) { }) } } - -// fakeIDToken is used to create a fake invalid token to verify expiry logic -func fakeIDToken(t *testing.T, expiryDate time.Time) string { - type Header struct { - Kid string `json:"kid"` - Alg string `json:"alg"` - } - type Payload struct { - Iss string `json:"iss"` - Sub string `json:"sub"` - Exp int64 `json:"exp"` - } - - header, err := json.Marshal(Header{Kid: "123", Alg: "none"}) - require.NoError(t, err) - u := expiryDate.UTC().Unix() - payload, err := json.Marshal(Payload{Iss: "fake", Sub: "a-sub", Exp: u}) - require.NoError(t, err) - - fakeSignature := []byte("6ICJm") - return fmt.Sprintf("%s.%s.%s", base64.RawURLEncoding.EncodeToString(header), base64.RawURLEncoding.EncodeToString(payload), base64.RawURLEncoding.EncodeToString(fakeSignature)) -} - -func TestOAuthTokenSync_getOAuthTokenCacheTTL(t *testing.T) { - defaultTime := time.Now() - tests := []struct { - name string - accessTokenExpiry time.Time - idTokenExpiry time.Time - want time.Duration - }{ - { - name: "should return maxOAuthTokenCacheTTL when no expiry is given", - accessTokenExpiry: time.Time{}, - idTokenExpiry: time.Time{}, - - want: maxOAuthTokenCacheTTL, - }, - { - name: "should return maxOAuthTokenCacheTTL when access token is not given and id token expiry is greater than max cache ttl", - accessTokenExpiry: time.Time{}, - idTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL), - - want: maxOAuthTokenCacheTTL, - }, - { - name: "should return idTokenExpiry when access token is not given and id token expiry is less than max cache ttl", - accessTokenExpiry: time.Time{}, - idTokenExpiry: defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL), - want: time.Until(defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL)), - }, - { - name: "should return maxOAuthTokenCacheTTL when access token expiry is greater than max cache ttl and id token is not given", - accessTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL), - idTokenExpiry: time.Time{}, - want: maxOAuthTokenCacheTTL, - }, - { - name: "should return accessTokenExpiry when access token expiry is less than max cache ttl and id token is not given", - accessTokenExpiry: defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL), - idTokenExpiry: time.Time{}, - want: time.Until(defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL)), - }, - { - name: "should return accessTokenExpiry when access token expiry is less than max cache ttl and less than id token expiry", - accessTokenExpiry: defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL), - idTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL), - want: time.Until(defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL)), - }, - { - name: "should return idTokenExpiry when id token expiry is less than max cache ttl and less than access token expiry", - accessTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL), - idTokenExpiry: defaultTime.Add(-3*time.Minute + maxOAuthTokenCacheTTL), - want: time.Until(defaultTime.Add(-3*time.Minute + maxOAuthTokenCacheTTL)), - }, - { - name: "should return maxOAuthTokenCacheTTL when access token expiry is greater than max cache ttl and id token expiry is greater than max cache ttl", - accessTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL), - idTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL), - want: maxOAuthTokenCacheTTL, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := getOAuthTokenCacheTTL(tt.accessTokenExpiry, tt.idTokenExpiry) - - assert.Equal(t, tt.want.Round(time.Second), got.Round(time.Second)) - }) - } -} diff --git a/pkg/services/authn/authnimpl/sync/org_sync.go b/pkg/services/authn/authnimpl/sync/org_sync.go index 43e1bbf0249ee..fcc5526463971 100644 --- a/pkg/services/authn/authnimpl/sync/org_sync.go +++ b/pkg/services/authn/authnimpl/sync/org_sync.go @@ -3,6 +3,7 @@ package sync import ( "context" "errors" + "fmt" "sort" "github.com/grafana/grafana/pkg/infra/log" @@ -11,16 +12,18 @@ import ( "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" ) -func ProvideOrgSync(userService user.Service, orgService org.Service, accessControl accesscontrol.Service) *OrgSync { - return &OrgSync{userService, orgService, accessControl, log.New("org.sync")} +func ProvideOrgSync(userService user.Service, orgService org.Service, accessControl accesscontrol.Service, cfg *setting.Cfg) *OrgSync { + return &OrgSync{userService, orgService, accessControl, cfg, log.New("org.sync")} } type OrgSync struct { userService user.Service orgService org.Service accessControl accesscontrol.Service + cfg *setting.Cfg log log.Logger } @@ -72,7 +75,7 @@ func (s *OrgSync) SyncOrgRolesHook(ctx context.Context, id *authn.Identity, _ *a // update role cmd := &org.UpdateOrgUserCommand{OrgID: orga.OrgID, UserID: userID, Role: extRole} if err := s.orgService.UpdateOrgUser(ctx, cmd); err != nil { - s.log.FromContext(ctx).Error("Failed to update active org user", "id", id.ID, "error", err) + ctxLogger.Error("Failed to update active org user", "id", id.ID, "error", err) return err } } @@ -90,7 +93,7 @@ func (s *OrgSync) SyncOrgRolesHook(ctx context.Context, id *authn.Identity, _ *a cmd := &org.AddOrgUserCommand{UserID: userID, Role: orgRole, OrgID: orgId} err := s.orgService.AddOrgUser(ctx, cmd) if err != nil && !errors.Is(err, org.ErrOrgNotFound) { - s.log.FromContext(ctx).Error("Failed to update active org for user", "id", id.ID, "error", err) + ctxLogger.Error("Failed to update active org for user", "id", id.ID, "error", err) return err } } @@ -100,7 +103,7 @@ func (s *OrgSync) SyncOrgRolesHook(ctx context.Context, id *authn.Identity, _ *a ctxLogger.Debug("Removing user's organization membership as part of syncing with OAuth login", "id", id.ID, "orgId", orgID) cmd := &org.RemoveOrgUserCommand{OrgID: orgID, UserID: userID} if err := s.orgService.RemoveOrgUser(ctx, cmd); err != nil { - s.log.FromContext(ctx).Error("Failed to remove user from org", "id", id.ID, "orgId", orgID, "error", err) + ctxLogger.Error("Failed to remove user from org", "id", id.ID, "orgId", orgID, "error", err) if errors.Is(err, org.ErrLastOrgAdmin) { continue } @@ -128,3 +131,59 @@ func (s *OrgSync) SyncOrgRolesHook(ctx context.Context, id *authn.Identity, _ *a return nil } + +func (s *OrgSync) SetDefaultOrgHook(ctx context.Context, currentIdentity *authn.Identity, r *authn.Request) error { + if s.cfg.LoginDefaultOrgId < 1 || currentIdentity == nil { + return nil + } + + ctxLogger := s.log.FromContext(ctx) + + namespace, identifier := currentIdentity.GetNamespacedID() + if namespace != identity.NamespaceUser { + ctxLogger.Debug("Skipping default org sync, not a user", "namespace", namespace) + return nil + } + + userID, err := identity.IntIdentifier(namespace, identifier) + if err != nil { + ctxLogger.Debug("Skipping default org sync, invalid ID for identity", "id", currentIdentity.ID, "namespace", namespace, "err", err) + return nil + } + + hasAssignedToOrg, err := s.validateUsingOrg(ctx, userID, s.cfg.LoginDefaultOrgId) + if err != nil { + ctxLogger.Error("Skipping default org sync, failed to validate user's organizations", "id", currentIdentity.ID, "err", err) + return nil + } + + if !hasAssignedToOrg { + ctxLogger.Debug("Skipping default org sync, user is not assigned to org", "id", currentIdentity.ID, "org", s.cfg.LoginDefaultOrgId) + return nil + } + + cmd := user.SetUsingOrgCommand{UserID: userID, OrgID: s.cfg.LoginDefaultOrgId} + if err := s.userService.SetUsingOrg(ctx, &cmd); err != nil { + ctxLogger.Error("Failed to set default org", "id", currentIdentity.ID, "err", err) + return err + } + + return nil +} + +func (s *OrgSync) validateUsingOrg(ctx context.Context, userID int64, orgID int64) (bool, error) { + query := org.GetUserOrgListQuery{UserID: userID} + + result, err := s.orgService.GetUserOrgList(ctx, &query) + if err != nil { + return false, fmt.Errorf("failed to get user's organizations: %w", err) + } + + // validate that the org id in the list + for _, other := range result { + if other.OrgID == orgID { + return true, nil + } + } + return false, nil +} diff --git a/pkg/services/authn/authnimpl/sync/org_sync_test.go b/pkg/services/authn/authnimpl/sync/org_sync_test.go index 00bbbf5b1a312..9288f4c1958a2 100644 --- a/pkg/services/authn/authnimpl/sync/org_sync_test.go +++ b/pkg/services/authn/authnimpl/sync/org_sync_test.go @@ -2,9 +2,11 @@ package sync import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models/roletype" @@ -16,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" + "github.com/grafana/grafana/pkg/setting" ) func TestOrgSync_SyncOrgRolesHook(t *testing.T) { @@ -124,3 +127,96 @@ func TestOrgSync_SyncOrgRolesHook(t *testing.T) { }) } } + +func TestOrgSync_SetDefaultOrgHook(t *testing.T) { + testCases := []struct { + name string + defaultOrgSetting int64 + identity *authn.Identity + setupMock func(*usertest.MockService, *orgtest.FakeOrgService) + + wantErr bool + }{ + { + name: "should set default org", + defaultOrgSetting: 2, + identity: &authn.Identity{ID: "user:1"}, + setupMock: func(userService *usertest.MockService, orgService *orgtest.FakeOrgService) { + userService.On("SetUsingOrg", mock.Anything, mock.MatchedBy(func(cmd *user.SetUsingOrgCommand) bool { + return cmd.UserID == 1 && cmd.OrgID == 2 + })).Return(nil) + }, + }, + { + name: "should skip setting the default org when default org is not set", + defaultOrgSetting: -1, + identity: &authn.Identity{ID: "user:1"}, + }, + { + name: "should skip setting the default org when identity is nil", + defaultOrgSetting: -1, + identity: nil, + }, + { + name: "should skip setting the default org when identity is not a user", + defaultOrgSetting: 2, + identity: &authn.Identity{ID: "service-account:1"}, + }, + { + name: "should skip setting the default org when user id is not valid", + defaultOrgSetting: 2, + identity: &authn.Identity{ID: "user:invalid"}, + }, + { + name: "should skip setting the default org when user is not allowed to use the configured default org", + defaultOrgSetting: 3, + identity: &authn.Identity{ID: "user:1"}, + }, + { + name: "should skip setting the default org when validateUsingOrg returns error", + defaultOrgSetting: 2, + identity: &authn.Identity{ID: "user:1"}, + setupMock: func(userService *usertest.MockService, orgService *orgtest.FakeOrgService) { + orgService.ExpectedError = fmt.Errorf("error") + }, + }, + { + name: "should return error when the user org update was unsuccessful", + defaultOrgSetting: 2, + identity: &authn.Identity{ID: "user:1"}, + setupMock: func(userService *usertest.MockService, orgService *orgtest.FakeOrgService) { + userService.On("SetUsingOrg", mock.Anything, mock.Anything).Return(fmt.Errorf("error")) + }, + wantErr: true, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + cfg := setting.NewCfg() + cfg.LoginDefaultOrgId = tt.defaultOrgSetting + + userService := &usertest.MockService{} + defer userService.AssertExpectations(t) + + orgService := &orgtest.FakeOrgService{ + ExpectedUserOrgDTO: []*org.UserOrgDTO{{OrgID: 2}}, + } + + if tt.setupMock != nil { + tt.setupMock(userService, orgService) + } + + s := &OrgSync{ + userService: userService, + orgService: orgService, + accessControl: actest.FakeService{}, + log: log.NewNopLogger(), + cfg: cfg, + } + + if err := s.SetDefaultOrgHook(context.Background(), tt.identity, nil); (err != nil) != tt.wantErr { + t.Errorf("OrgSync.SetDefaultOrgHook() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/pkg/services/authn/authnimpl/sync/permission_sync.go b/pkg/services/authn/authnimpl/sync/permission_sync.go deleted file mode 100644 index c9c9d67f742ec..0000000000000 --- a/pkg/services/authn/authnimpl/sync/permission_sync.go +++ /dev/null @@ -1,44 +0,0 @@ -package sync - -import ( - "context" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/authn" - "github.com/grafana/grafana/pkg/util/errutil" -) - -var ( - errSyncPermissionsForbidden = errutil.Forbidden("permissions.sync.forbidden") -) - -func ProvidePermissionsSync(acService accesscontrol.Service) *PermissionsSync { - return &PermissionsSync{ - ac: acService, - log: log.New("permissions.sync"), - } -} - -type PermissionsSync struct { - ac accesscontrol.Service - log log.Logger -} - -func (s *PermissionsSync) SyncPermissionsHook(ctx context.Context, identity *authn.Identity, _ *authn.Request) error { - if !identity.ClientParams.SyncPermissions { - return nil - } - - permissions, err := s.ac.GetUserPermissions(ctx, identity, accesscontrol.Options{ReloadCache: false}) - if err != nil { - s.log.FromContext(ctx).Error("Failed to fetch permissions from db", "error", err, "user_id", identity.ID) - return errSyncPermissionsForbidden - } - - if identity.Permissions == nil { - identity.Permissions = make(map[int64]map[string][]string) - } - identity.Permissions[identity.OrgID] = accesscontrol.GroupScopesByAction(permissions) - return nil -} diff --git a/pkg/services/authn/authnimpl/sync/permission_sync_test.go b/pkg/services/authn/authnimpl/sync/permission_sync_test.go deleted file mode 100644 index db4445536b480..0000000000000 --- a/pkg/services/authn/authnimpl/sync/permission_sync_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package sync - -import ( - "context" - "testing" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/accesscontrol" - acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" - "github.com/grafana/grafana/pkg/services/auth/identity" - "github.com/grafana/grafana/pkg/services/authn" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestPermissionsSync_SyncPermission(t *testing.T) { - type testCase struct { - name string - identity *authn.Identity - expectedPermissions []accesscontrol.Permission - } - testCases := []testCase{ - { - name: "enriches the identity successfully when SyncPermissions is true", - identity: &authn.Identity{ID: "user:2", OrgID: 1, ClientParams: authn.ClientParams{SyncPermissions: true}}, - expectedPermissions: []accesscontrol.Permission{ - {Action: accesscontrol.ActionUsersRead}, - }, - }, - { - name: "does not load the permissions when SyncPermissions is false", - identity: &authn.Identity{ID: "user:2", OrgID: 1, ClientParams: authn.ClientParams{SyncPermissions: true}}, - expectedPermissions: []accesscontrol.Permission{ - {Action: accesscontrol.ActionUsersRead}, - }, - }, - } - - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - s := setupTestEnv() - - err := s.SyncPermissionsHook(context.Background(), tt.identity, &authn.Request{}) - require.NoError(t, err) - - assert.Equal(t, 1, len(tt.identity.Permissions)) - assert.Equal(t, accesscontrol.GroupScopesByAction(tt.expectedPermissions), tt.identity.Permissions[tt.identity.OrgID]) - }) - } -} - -func setupTestEnv() *PermissionsSync { - acMock := &acmock.Mock{ - GetUserPermissionsFunc: func(ctx context.Context, siu identity.Requester, o accesscontrol.Options) ([]accesscontrol.Permission, error) { - return []accesscontrol.Permission{ - {Action: accesscontrol.ActionUsersRead}, - }, nil - }, - } - s := &PermissionsSync{ - ac: acMock, - log: log.NewNopLogger(), - } - return s -} diff --git a/pkg/services/authn/authnimpl/sync/rbac_sync.go b/pkg/services/authn/authnimpl/sync/rbac_sync.go new file mode 100644 index 0000000000000..98135f5b1945f --- /dev/null +++ b/pkg/services/authn/authnimpl/sync/rbac_sync.go @@ -0,0 +1,93 @@ +package sync + +import ( + "context" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/authn" + "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/util/errutil" +) + +var ( + errInvalidCloudRole = errutil.BadRequest("rbac.sync.invalid-cloud-role") + errSyncPermissionsForbidden = errutil.Forbidden("permissions.sync.forbidden") +) + +func ProvideRBACSync(acService accesscontrol.Service) *RBACSync { + return &RBACSync{ + ac: acService, + log: log.New("permissions.sync"), + } +} + +type RBACSync struct { + ac accesscontrol.Service + log log.Logger +} + +func (s *RBACSync) SyncPermissionsHook(ctx context.Context, ident *authn.Identity, _ *authn.Request) error { + if !ident.ClientParams.SyncPermissions { + return nil + } + + permissions, err := s.ac.GetUserPermissions(ctx, ident, accesscontrol.Options{ReloadCache: false}) + if err != nil { + s.log.FromContext(ctx).Error("Failed to fetch permissions from db", "error", err, "id", ident.ID) + return errSyncPermissionsForbidden + } + + if ident.Permissions == nil { + ident.Permissions = make(map[int64]map[string][]string) + } + ident.Permissions[ident.OrgID] = accesscontrol.GroupScopesByAction(permissions) + return nil +} + +var fixedCloudRoles = map[org.RoleType]string{ + org.RoleViewer: accesscontrol.FixedCloudViewerRole, + org.RoleEditor: accesscontrol.FixedCloudEditorRole, + org.RoleAdmin: accesscontrol.FixedCloudAdminRole, +} + +func (s *RBACSync) SyncCloudRoles(ctx context.Context, ident *authn.Identity, r *authn.Request) error { + // we only want to run this hook during login and if the module used is grafana com + if r.GetMeta(authn.MetaKeyAuthModule) != login.GrafanaComAuthModule { + return nil + } + + namespace, id := ident.GetNamespacedID() + if namespace != authn.NamespaceUser { + s.log.FromContext(ctx).Debug("Skip syncing cloud role", "id", ident.ID) + return nil + } + + userID, err := identity.IntIdentifier(namespace, id) + if err != nil { + return err + } + + rolesToAdd := make([]string, 0, 1) + rolesToRemove := make([]string, 0, 2) + + for role, fixedRole := range fixedCloudRoles { + if role == ident.GetOrgRole() { + rolesToAdd = append(rolesToAdd, fixedRole) + } else { + rolesToRemove = append(rolesToRemove, fixedRole) + } + } + + if len(rolesToAdd) != 1 { + return errInvalidCloudRole.Errorf("invalid role: %s", ident.GetOrgRole()) + } + + return s.ac.SyncUserRoles(ctx, ident.GetOrgID(), accesscontrol.SyncUserRolesCommand{ + UserID: userID, + RolesToAdd: rolesToAdd, + RolesToRemove: rolesToRemove, + }) +} diff --git a/pkg/services/authn/authnimpl/sync/rbac_sync_test.go b/pkg/services/authn/authnimpl/sync/rbac_sync_test.go new file mode 100644 index 0000000000000..27436998f4d61 --- /dev/null +++ b/pkg/services/authn/authnimpl/sync/rbac_sync_test.go @@ -0,0 +1,157 @@ +package sync + +import ( + "context" + "testing" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/authn" + "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/org" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRBACSync_SyncPermission(t *testing.T) { + type testCase struct { + name string + identity *authn.Identity + expectedPermissions []accesscontrol.Permission + } + testCases := []testCase{ + { + name: "enriches the identity successfully when SyncPermissions is true", + identity: &authn.Identity{ID: "user:2", OrgID: 1, ClientParams: authn.ClientParams{SyncPermissions: true}}, + expectedPermissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionUsersRead}, + }, + }, + { + name: "does not load the permissions when SyncPermissions is false", + identity: &authn.Identity{ID: "user:2", OrgID: 1, ClientParams: authn.ClientParams{SyncPermissions: true}}, + expectedPermissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionUsersRead}, + }, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + s := setupTestEnv() + + err := s.SyncPermissionsHook(context.Background(), tt.identity, &authn.Request{}) + require.NoError(t, err) + + assert.Equal(t, 1, len(tt.identity.Permissions)) + assert.Equal(t, accesscontrol.GroupScopesByAction(tt.expectedPermissions), tt.identity.Permissions[tt.identity.OrgID]) + }) + } +} + +func TestRBACSync_SyncCloudRoles(t *testing.T) { + type testCase struct { + desc string + module string + identity *authn.Identity + expectedErr error + expectedCalled bool + } + + tests := []testCase{ + { + desc: "should call sync when authenticated with grafana com and has viewer role", + module: login.GrafanaComAuthModule, + identity: &authn.Identity{ + ID: authn.NamespacedID(authn.NamespaceUser, 1), + OrgID: 1, + OrgRoles: map[int64]org.RoleType{1: org.RoleViewer}, + }, + expectedErr: nil, + expectedCalled: true, + }, + { + desc: "should call sync when authenticated with grafana com and has editor role", + module: login.GrafanaComAuthModule, + identity: &authn.Identity{ + ID: authn.NamespacedID(authn.NamespaceUser, 1), + OrgID: 1, + OrgRoles: map[int64]org.RoleType{1: org.RoleEditor}, + }, + expectedErr: nil, + expectedCalled: true, + }, + { + desc: "should call sync when authenticated with grafana com and has admin role", + module: login.GrafanaComAuthModule, + identity: &authn.Identity{ + ID: authn.NamespacedID(authn.NamespaceUser, 1), + OrgID: 1, + OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin}, + }, + expectedErr: nil, + expectedCalled: true, + }, + { + desc: "should not call sync when authenticated with grafana com and has invalid role", + module: login.GrafanaComAuthModule, + identity: &authn.Identity{ + ID: authn.NamespacedID(authn.NamespaceUser, 1), + OrgID: 1, + OrgRoles: map[int64]org.RoleType{1: org.RoleType("something else")}, + }, + expectedErr: errInvalidCloudRole, + expectedCalled: false, + }, + { + desc: "should not call sync when not authenticated with grafana com", + module: login.LDAPAuthModule, + identity: &authn.Identity{ + ID: authn.NamespacedID(authn.NamespaceUser, 1), + OrgID: 1, + OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin}, + }, + expectedErr: nil, + expectedCalled: false, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + var called bool + s := &RBACSync{ + ac: &acmock.Mock{ + SyncUserRolesFunc: func(_ context.Context, _ int64, _ accesscontrol.SyncUserRolesCommand) error { + called = true + return nil + }, + }, + log: log.NewNopLogger(), + } + + req := &authn.Request{} + req.SetMeta(authn.MetaKeyAuthModule, tt.module) + + err := s.SyncCloudRoles(context.Background(), tt.identity, req) + assert.ErrorIs(t, err, tt.expectedErr) + assert.Equal(t, tt.expectedCalled, called) + }) + } +} + +func setupTestEnv() *RBACSync { + acMock := &acmock.Mock{ + GetUserPermissionsFunc: func(ctx context.Context, siu identity.Requester, o accesscontrol.Options) ([]accesscontrol.Permission, error) { + return []accesscontrol.Permission{ + {Action: accesscontrol.ActionUsersRead}, + }, nil + }, + } + s := &RBACSync{ + ac: acMock, + log: log.NewNopLogger(), + } + return s +} diff --git a/pkg/services/authn/authnimpl/sync/user_sync.go b/pkg/services/authn/authnimpl/sync/user_sync.go index 71a96c18124d2..6ba757b561527 100644 --- a/pkg/services/authn/authnimpl/sync/user_sync.go +++ b/pkg/services/authn/authnimpl/sync/user_sync.go @@ -111,7 +111,7 @@ func (s *UserSync) FetchSyncedUserHook(ctx context.Context, identity *authn.Iden return nil } namespace, id := identity.GetNamespacedID() - if namespace != authn.NamespaceUser { + if namespace != authn.NamespaceUser && namespace != authn.NamespaceServiceAccount { return nil } diff --git a/pkg/services/authn/authnimpl/usage_stats.go b/pkg/services/authn/authnimpl/usage_stats.go index e2b15cf1ab4de..52f224c7326de 100644 --- a/pkg/services/authn/authnimpl/usage_stats.go +++ b/pkg/services/authn/authnimpl/usage_stats.go @@ -13,9 +13,9 @@ func (s *Service) getUsageStats(ctx context.Context) (map[string]any, error) { authTypes := map[string]bool{} authTypes["basic_auth"] = s.cfg.BasicAuthEnabled authTypes["ldap"] = s.cfg.LDAPAuthEnabled - authTypes["auth_proxy"] = s.cfg.AuthProxyEnabled + authTypes["auth_proxy"] = s.cfg.AuthProxy.Enabled authTypes["anonymous"] = s.cfg.AnonymousEnabled - authTypes["jwt"] = s.cfg.JWTAuthEnabled + authTypes["jwt"] = s.cfg.JWTAuth.Enabled authTypes["grafana_password"] = !s.cfg.DisableLogin authTypes["login_form"] = !s.cfg.DisableLoginForm diff --git a/pkg/services/authn/authnimpl/usage_stats_test.go b/pkg/services/authn/authnimpl/usage_stats_test.go index ba59c1f1818fe..04d9776ce3174 100644 --- a/pkg/services/authn/authnimpl/usage_stats_test.go +++ b/pkg/services/authn/authnimpl/usage_stats_test.go @@ -20,8 +20,8 @@ func TestService_getUsageStats(t *testing.T) { svc.cfg.DisableLoginForm = false svc.cfg.DisableLogin = false svc.cfg.BasicAuthEnabled = true - svc.cfg.AuthProxyEnabled = true - svc.cfg.JWTAuthEnabled = true + svc.cfg.AuthProxy.Enabled = true + svc.cfg.JWTAuth.Enabled = true svc.cfg.LDAPAuthEnabled = true svc.cfg.EditorsCanAdmin = true svc.cfg.ViewersCanEdit = true diff --git a/pkg/services/authn/authntest/fake.go b/pkg/services/authn/authntest/fake.go index 81a93636585e0..c83154a9d8cfb 100644 --- a/pkg/services/authn/authntest/fake.go +++ b/pkg/services/authn/authntest/fake.go @@ -68,7 +68,11 @@ func (f *FakeService) RedirectURL(ctx context.Context, client string, r *authn.R return f.ExpectedRedirect, f.ExpectedErr } -func (*FakeService) Logout(_ context.Context, _ identity.Requester, _ *usertoken.UserToken) (*authn.Redirect, error) { +func (f *FakeService) Logout(_ context.Context, _ identity.Requester, _ *usertoken.UserToken) (*authn.Redirect, error) { + panic("unimplemented") +} + +func (f *FakeService) ResolveIdentity(ctx context.Context, orgID int64, namespaceID string) (*authn.Identity, error) { panic("unimplemented") } diff --git a/pkg/services/authn/authntest/mock.go b/pkg/services/authn/authntest/mock.go index 2a46f97a5c816..193200e549883 100644 --- a/pkg/services/authn/authntest/mock.go +++ b/pkg/services/authn/authntest/mock.go @@ -47,6 +47,10 @@ func (*MockService) Logout(_ context.Context, _ identity.Requester, _ *usertoken panic("unimplemented") } +func (m *MockService) ResolveIdentity(ctx context.Context, orgID int64, namespaceID string) (*authn.Identity, error) { + panic("unimplemented") +} + func (m *MockService) SyncIdentity(ctx context.Context, identity *authn.Identity) error { if m.SyncIdentityFunc != nil { return m.SyncIdentityFunc(ctx, identity) diff --git a/pkg/services/authn/clients/api_key.go b/pkg/services/authn/clients/api_key.go index 34c3fc180fef1..4005005631185 100644 --- a/pkg/services/authn/clients/api_key.go +++ b/pkg/services/authn/clients/api_key.go @@ -14,7 +14,6 @@ import ( "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/errutil" ) @@ -29,17 +28,15 @@ var ( var _ authn.HookClient = new(APIKey) var _ authn.ContextAwareClient = new(APIKey) -func ProvideAPIKey(apiKeyService apikey.Service, userService user.Service) *APIKey { +func ProvideAPIKey(apiKeyService apikey.Service) *APIKey { return &APIKey{ log: log.New(authn.ClientAPIKey), - userService: userService, apiKeyService: apiKeyService, } } type APIKey struct { log log.Logger - userService user.Service apiKeyService apikey.Service } @@ -81,16 +78,12 @@ func (s *APIKey) Authenticate(ctx context.Context, r *authn.Request) (*authn.Ide }, nil } - usr, err := s.userService.GetSignedInUserWithCacheCtx(ctx, &user.GetSignedInUserQuery{ - UserID: *apiKey.ServiceAccountId, - OrgID: apiKey.OrgID, - }) - - if err != nil { - return nil, err - } - - return authn.IdentityFromSignedInUser(authn.NamespacedID(authn.NamespaceServiceAccount, usr.UserID), usr, authn.ClientParams{SyncPermissions: true}, login.APIKeyAuthModule), nil + return &authn.Identity{ + ID: authn.NamespacedID(authn.NamespaceServiceAccount, *apiKey.ServiceAccountId), + OrgID: apiKey.OrgID, + AuthenticatedBy: login.APIKeyAuthModule, + ClientParams: authn.ClientParams{FetchSyncedUser: true, SyncPermissions: true}, + }, nil } func (s *APIKey) getAPIKey(ctx context.Context, token string) (*apikey.APIKey, error) { diff --git a/pkg/services/authn/clients/api_key_test.go b/pkg/services/authn/clients/api_key_test.go index f4dce265cf020..5cb8ae6311887 100644 --- a/pkg/services/authn/clients/api_key_test.go +++ b/pkg/services/authn/clients/api_key_test.go @@ -16,8 +16,6 @@ import ( "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/services/user/usertest" ) var ( @@ -30,7 +28,6 @@ func TestAPIKey_Authenticate(t *testing.T) { desc string req *authn.Request expectedKey *apikey.APIKey - expectedUser *user.SignedInUser expectedErr error expectedIdentity *authn.Identity } @@ -72,20 +69,11 @@ func TestAPIKey_Authenticate(t *testing.T) { Key: hash, ServiceAccountId: intPtr(1), }, - expectedUser: &user.SignedInUser{ - UserID: 1, - OrgID: 1, - IsServiceAccount: true, - OrgRole: org.RoleViewer, - Name: "test", - }, expectedIdentity: &authn.Identity{ - ID: "service-account:1", - OrgID: 1, - Name: "test", - OrgRoles: map[int64]org.RoleType{1: org.RoleViewer}, - IsGrafanaAdmin: boolPtr(false), + ID: "service-account:1", + OrgID: 1, ClientParams: authn.ClientParams{ + FetchSyncedUser: true, SyncPermissions: true, }, AuthenticatedBy: login.APIKeyAuthModule, @@ -124,11 +112,7 @@ func TestAPIKey_Authenticate(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - c := ProvideAPIKey(&apikeytest.Service{ - ExpectedAPIKey: tt.expectedKey, - }, &usertest.FakeUserService{ - ExpectedSignedInUser: tt.expectedUser, - }) + c := ProvideAPIKey(&apikeytest.Service{ExpectedAPIKey: tt.expectedKey}) identity, err := c.Authenticate(context.Background(), tt.req) if tt.expectedErr != nil { @@ -195,7 +179,7 @@ func TestAPIKey_Test(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - c := ProvideAPIKey(&apikeytest.Service{}, usertest.NewUserServiceFake()) + c := ProvideAPIKey(&apikeytest.Service{}) assert.Equal(t, tt.expected, c.Test(context.Background(), tt.req)) }) } @@ -286,19 +270,11 @@ func TestAPIKey_GetAPIKeyIDFromIdentity(t *testing.T) { }, }} - signedInUser := &user.SignedInUser{ - UserID: 1, - OrgID: 1, - Name: "test", - } - for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { c := ProvideAPIKey(&apikeytest.Service{ ExpectedError: tt.expectedError, ExpectedAPIKey: tt.expectedKey, - }, &usertest.FakeUserService{ - ExpectedSignedInUser: signedInUser, }) id, exists := c.getAPIKeyID(context.Background(), tt.expectedIdentity, req) assert.Equal(t, tt.expectedExists, exists) diff --git a/pkg/services/authn/clients/basic.go b/pkg/services/authn/clients/basic.go index bda9f3c1ed56a..d0c7050f5bf55 100644 --- a/pkg/services/authn/clients/basic.go +++ b/pkg/services/authn/clients/basic.go @@ -2,7 +2,6 @@ package clients import ( "context" - "strings" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/util/errutil" @@ -43,10 +42,6 @@ func (c *Basic) Test(ctx context.Context, r *authn.Request) bool { if r.HTTPRequest == nil { return false } - // The OAuth2 introspection endpoint uses basic auth but is handled by the oauthserver package. - if strings.EqualFold(r.HTTPRequest.RequestURI, "/oauth2/introspect") { - return false - } return looksLikeBasicAuthRequest(r) } diff --git a/pkg/services/authn/clients/basic_test.go b/pkg/services/authn/clients/basic_test.go index 93fbd7d4165cf..fbf2a96a2d7d0 100644 --- a/pkg/services/authn/clients/basic_test.go +++ b/pkg/services/authn/clients/basic_test.go @@ -85,12 +85,6 @@ func TestBasic_Test(t *testing.T) { HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {"something"}}}, }, }, - { - desc: "should fail when the URL ends with /oauth2/introspect", - req: &authn.Request{ - HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {encodeBasicAuth("user", "password")}}, RequestURI: "/oauth2/introspect"}, - }, - }, } for _, tt := range tests { diff --git a/pkg/services/authn/clients/ext_jwt.go b/pkg/services/authn/clients/ext_jwt.go index 01d8a524bb660..1fd3ef8b358c1 100644 --- a/pkg/services/authn/clients/ext_jwt.go +++ b/pkg/services/authn/clients/ext_jwt.go @@ -14,7 +14,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/authn" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/signingkeys" "github.com/grafana/grafana/pkg/services/user" @@ -33,13 +32,12 @@ const ( rfc9068MediaType = "application/at+jwt" ) -func ProvideExtendedJWT(userService user.Service, cfg *setting.Cfg, signingKeys signingkeys.Service, oauthServer oauthserver.OAuth2Server) *ExtendedJWT { +func ProvideExtendedJWT(userService user.Service, cfg *setting.Cfg, signingKeys signingkeys.Service) *ExtendedJWT { return &ExtendedJWT{ cfg: cfg, log: log.New(authn.ClientExtendedJWT), userService: userService, signingKeys: signingKeys, - oauthServer: oauthServer, } } @@ -48,7 +46,6 @@ type ExtendedJWT struct { log log.Logger userService user.Service signingKeys signingkeys.Service - oauthServer oauthserver.OAuth2Server } type ExtendedJWTClaims struct { @@ -222,10 +219,6 @@ func (s *ExtendedJWT) validateClientIdClaim(ctx context.Context, claims Extended return fmt.Errorf("missing 'client_id' claim") } - if _, err := s.oauthServer.GetExternalService(ctx, claims.ClientID); err != nil { - return fmt.Errorf("invalid 'client_id' claim: %s", claims.ClientID) - } - return nil } diff --git a/pkg/services/authn/clients/ext_jwt_test.go b/pkg/services/authn/clients/ext_jwt_test.go index 5325baf3e3f38..64ff1c5095cdd 100644 --- a/pkg/services/authn/clients/ext_jwt_test.go +++ b/pkg/services/authn/clients/ext_jwt_test.go @@ -17,8 +17,6 @@ import ( "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/services/authn" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/oastest" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/signingkeys" "github.com/grafana/grafana/pkg/services/signingkeys/signingkeystest" @@ -268,27 +266,6 @@ func TestExtendedJWT_Authenticate(t *testing.T) { want: nil, wantErr: true, }, - { - name: "should return error when the client was not found", - payload: ExtendedJWTClaims{ - Claims: jwt.Claims{ - Issuer: "http://localhost:3000", - Subject: "user:id:2", - Audience: jwt.Audience{"http://localhost:3000"}, - ID: "1234567890", - Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), - IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), - }, - ClientID: "unknown-client-id", - Scopes: []string{"profile", "groups"}, - }, - initTestEnv: func(env *testEnv) { - env.oauthSvc.ExpectedErr = oauthserver.ErrClientNotFoundFn("unknown-client-id") - }, - orgID: 1, - want: nil, - wantErr: true, - }, } for _, tc := range testCases { @@ -521,21 +498,18 @@ func setupTestCtx(t *testing.T, cfg *setting.Cfg) *testEnv { } userSvc := &usertest.FakeUserService{} - oauthSvc := &oastest.FakeService{} - extJwtClient := ProvideExtendedJWT(userSvc, cfg, signingKeysSvc, oauthSvc) + extJwtClient := ProvideExtendedJWT(userSvc, cfg, signingKeysSvc) return &testEnv{ - oauthSvc: oauthSvc, - userSvc: userSvc, - s: extJwtClient, + userSvc: userSvc, + s: extJwtClient, } } type testEnv struct { - oauthSvc *oastest.FakeService - userSvc *usertest.FakeUserService - s *ExtendedJWT + userSvc *usertest.FakeUserService + s *ExtendedJWT } func generateToken(payload ExtendedJWTClaims, signingKey any, alg jose.SignatureAlgorithm) string { diff --git a/pkg/services/authn/clients/grafana.go b/pkg/services/authn/clients/grafana.go index 31a94f2720561..10e330086004b 100644 --- a/pkg/services/authn/clients/grafana.go +++ b/pkg/services/authn/clients/grafana.go @@ -40,11 +40,11 @@ func (c *Grafana) AuthenticateProxy(ctx context.Context, r *authn.Request, usern FetchSyncedUser: true, SyncOrgRoles: true, SyncPermissions: true, - AllowSignUp: c.cfg.AuthProxyAutoSignUp, + AllowSignUp: c.cfg.AuthProxy.AutoSignUp, }, } - switch c.cfg.AuthProxyHeaderProperty { + switch c.cfg.AuthProxy.HeaderProperty { case "username": identity.Login = username addr, err := mail.ParseAddress(username) @@ -55,7 +55,7 @@ func (c *Grafana) AuthenticateProxy(ctx context.Context, r *authn.Request, usern identity.Login = username identity.Email = username default: - return nil, errInvalidProxyHeader.Errorf("invalid auth proxy header property, expected username or email but got: %s", c.cfg.AuthProxyHeaderProperty) + return nil, errInvalidProxyHeader.Errorf("invalid auth proxy header property, expected username or email but got: %s", c.cfg.AuthProxy.HeaderProperty) } if v, ok := additional[proxyFieldName]; ok { @@ -100,7 +100,7 @@ func (c *Grafana) AuthenticatePassword(ctx context.Context, r *authn.Request, us // user was found so set auth module in req metadata r.SetMeta(authn.MetaKeyAuthModule, "grafana") - if ok := comparePassword(password, usr.Salt, usr.Password); !ok { + if ok := comparePassword(password, usr.Salt, string(usr.Password)); !ok { return nil, errInvalidPassword.Errorf("invalid password") } diff --git a/pkg/services/authn/clients/grafana_test.go b/pkg/services/authn/clients/grafana_test.go index 53dab4486e4f6..f77d233744fad 100644 --- a/pkg/services/authn/clients/grafana_test.go +++ b/pkg/services/authn/clients/grafana_test.go @@ -94,8 +94,8 @@ func TestGrafana_AuthenticateProxy(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { cfg := setting.NewCfg() - cfg.AuthProxyAutoSignUp = true - cfg.AuthProxyHeaderProperty = tt.proxyProperty + cfg.AuthProxy.AutoSignUp = true + cfg.AuthProxy.HeaderProperty = tt.proxyProperty c := ProvideGrafana(cfg, usertest.NewUserServiceFake()) identity, err := c.AuthenticateProxy(context.Background(), tt.req, tt.username, tt.additional) @@ -171,7 +171,7 @@ func TestGrafana_AuthenticatePassword(t *testing.T) { hashed, _ := util.EncodePassword("password", "salt") userService := &usertest.FakeUserService{ ExpectedSignedInUser: tt.expectedSignedInUser, - ExpectedUser: &user.User{Password: hashed, Salt: "salt"}, + ExpectedUser: &user.User{Password: user.Password(hashed), Salt: "salt"}, } if !tt.findUser { diff --git a/pkg/services/authn/clients/identity.go b/pkg/services/authn/clients/identity.go new file mode 100644 index 0000000000000..557f04db9d50f --- /dev/null +++ b/pkg/services/authn/clients/identity.go @@ -0,0 +1,33 @@ +package clients + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/authn" +) + +var _ authn.Client = (*IdentityClient)(nil) + +func ProvideIdentity(namespaceID string) *IdentityClient { + return &IdentityClient{namespaceID} +} + +type IdentityClient struct { + namespaceID string +} + +func (i *IdentityClient) Name() string { + return "identity" +} + +// Authenticate implements authn.Client. +func (i *IdentityClient) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { + return &authn.Identity{ + OrgID: r.OrgID, + ID: i.namespaceID, + ClientParams: authn.ClientParams{ + FetchSyncedUser: true, + SyncPermissions: true, + }, + }, nil +} diff --git a/pkg/services/authn/clients/jwt.go b/pkg/services/authn/clients/jwt.go index 9c628906bafae..e789f9153a0e1 100644 --- a/pkg/services/authn/clients/jwt.go +++ b/pkg/services/authn/clients/jwt.go @@ -2,13 +2,9 @@ package clients import ( "context" - "errors" - "fmt" "net/http" "strings" - "github.com/jmespath/go-jmespath" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/auth" authJWT "github.com/grafana/grafana/pkg/services/auth/jwt" @@ -16,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/errutil" ) @@ -73,15 +70,16 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi SyncUser: true, FetchSyncedUser: true, SyncPermissions: true, - SyncOrgRoles: !s.cfg.JWTAuthSkipOrgRoleSync, - AllowSignUp: s.cfg.JWTAuthAutoSignUp, + SyncOrgRoles: !s.cfg.JWTAuth.SkipOrgRoleSync, + AllowSignUp: s.cfg.JWTAuth.AutoSignUp, + SyncTeams: s.cfg.JWTAuth.GroupsAttributePath != "", }} - if key := s.cfg.JWTAuthUsernameClaim; key != "" { + if key := s.cfg.JWTAuth.UsernameClaim; key != "" { id.Login, _ = claims[key].(string) id.ClientParams.LookUpParams.Login = &id.Login } - if key := s.cfg.JWTAuthEmailClaim; key != "" { + if key := s.cfg.JWTAuth.EmailClaim; key != "" { id.Email, _ = claims[key].(string) id.ClientParams.LookUpParams.Email = &id.Email } @@ -91,16 +89,16 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi } orgRoles, isGrafanaAdmin, err := getRoles(s.cfg, func() (org.RoleType, *bool, error) { - if s.cfg.JWTAuthSkipOrgRoleSync { + if s.cfg.JWTAuth.SkipOrgRoleSync { return "", nil, nil } role, grafanaAdmin := s.extractRoleAndAdmin(claims) - if s.cfg.JWTAuthRoleAttributeStrict && !role.IsValid() { + if s.cfg.JWTAuth.RoleAttributeStrict && !role.IsValid() { return "", nil, errJWTInvalidRole.Errorf("invalid role claim in JWT: %s", role) } - if !s.cfg.JWTAuthAllowAssignGrafanaAdmin { + if !s.cfg.JWTAuth.AllowAssignGrafanaAdmin { return role, nil, nil } @@ -114,6 +112,11 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi id.OrgRoles = orgRoles id.IsGrafanaAdmin = isGrafanaAdmin + id.Groups, err = s.extractGroups(claims) + if err != nil { + return nil, err + } + if id.Login == "" && id.Email == "" { s.log.FromContext(ctx).Debug("Failed to get an authentication claim from JWT", "login", id.Login, "email", id.Email) @@ -126,7 +129,7 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi // remove sensitive query param // avoid JWT URL login passing auth_token in URL func (s *JWT) stripSensitiveParam(httpRequest *http.Request) { - if s.cfg.JWTAuthURLLogin { + if s.cfg.JWTAuth.URLLogin { params := httpRequest.URL.Query() if params.Has(authQueryParamName) { params.Del(authQueryParamName) @@ -137,8 +140,8 @@ func (s *JWT) stripSensitiveParam(httpRequest *http.Request) { // retrieveToken retrieves the JWT token from the request. func (s *JWT) retrieveToken(httpRequest *http.Request) string { - jwtToken := httpRequest.Header.Get(s.cfg.JWTAuthHeaderName) - if jwtToken == "" && s.cfg.JWTAuthURLLogin { + jwtToken := httpRequest.Header.Get(s.cfg.JWTAuth.HeaderName) + if jwtToken == "" && s.cfg.JWTAuth.URLLogin { jwtToken = httpRequest.URL.Query().Get("auth_token") } // Strip the 'Bearer' prefix if it exists. @@ -146,7 +149,7 @@ func (s *JWT) retrieveToken(httpRequest *http.Request) string { } func (s *JWT) Test(ctx context.Context, r *authn.Request) bool { - if !s.cfg.JWTAuthEnabled || s.cfg.JWTAuthHeaderName == "" { + if !s.cfg.JWTAuth.Enabled || s.cfg.JWTAuth.HeaderName == "" { return false } @@ -171,11 +174,11 @@ func (s *JWT) Priority() uint { const roleGrafanaAdmin = "GrafanaAdmin" func (s *JWT) extractRoleAndAdmin(claims map[string]any) (org.RoleType, bool) { - if s.cfg.JWTAuthRoleAttributePath == "" { + if s.cfg.JWTAuth.RoleAttributePath == "" { return "", false } - role, err := searchClaimsForStringAttr(s.cfg.JWTAuthRoleAttributePath, claims) + role, err := util.SearchJSONForStringAttr(s.cfg.JWTAuth.RoleAttributePath, claims) if err != nil || role == "" { return "", false } @@ -186,33 +189,10 @@ func (s *JWT) extractRoleAndAdmin(claims map[string]any) (org.RoleType, bool) { return org.RoleType(role), false } -func searchClaimsForStringAttr(attributePath string, claims map[string]any) (string, error) { - val, err := searchClaimsForAttr(attributePath, claims) - if err != nil { - return "", err - } - - strVal, ok := val.(string) - if ok { - return strVal, nil - } - - return "", nil -} - -func searchClaimsForAttr(attributePath string, claims map[string]any) (any, error) { - if attributePath == "" { - return "", errors.New("no attribute path specified") - } - - if len(claims) == 0 { - return "", errors.New("empty claims provided") - } - - val, err := jmespath.Search(attributePath, claims) - if err != nil { - return "", fmt.Errorf("failed to search claims with provided path: %q: %w", attributePath, err) +func (s *JWT) extractGroups(claims map[string]any) ([]string, error) { + if s.cfg.JWTAuth.GroupsAttributePath == "" { + return []string{}, nil } - return val, nil + return util.SearchJSONForStringSliceAttr(s.cfg.JWTAuth.GroupsAttributePath, claims) } diff --git a/pkg/services/authn/clients/jwt_test.go b/pkg/services/authn/clients/jwt_test.go index 95ce08467a16b..f704a691189d5 100644 --- a/pkg/services/authn/clients/jwt_test.go +++ b/pkg/services/authn/clients/jwt_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) func stringPtr(s string) *string { @@ -22,72 +23,153 @@ func stringPtr(s string) *string { } func TestAuthenticateJWT(t *testing.T) { - jwtService := &jwt.FakeJWTService{ - VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { - return jwt.JWTClaims{ - "sub": "1234567890", - "email": "eai.doe@cor.po", - "preferred_username": "eai-doe", - "name": "Eai Doe", - "roles": "Admin", - }, nil - }, - } + t.Parallel() + jwtHeaderName := "X-Forwarded-User" - wantID := &authn.Identity{ - OrgID: 0, - OrgName: "", - OrgRoles: map[int64]roletype.RoleType{1: roletype.RoleAdmin}, - ID: "", - Login: "eai-doe", - Name: "Eai Doe", - Email: "eai.doe@cor.po", - IsGrafanaAdmin: boolPtr(false), - AuthenticatedBy: login.JWTModule, - AuthID: "1234567890", - IsDisabled: false, - HelpFlags1: 0, - ClientParams: authn.ClientParams{ - SyncUser: true, - AllowSignUp: true, - FetchSyncedUser: true, - SyncOrgRoles: true, - SyncPermissions: true, - LookUpParams: login.UserLookupParams{ - UserID: nil, - Email: stringPtr("eai.doe@cor.po"), - Login: stringPtr("eai-doe"), + + testCases := []struct { + name string + wantID *authn.Identity + verifyProvider func(context.Context, string) (jwt.JWTClaims, error) + cfg *setting.Cfg + }{ + { + name: "Valid Use case with group path", + wantID: &authn.Identity{ + OrgID: 0, + OrgName: "", + OrgRoles: map[int64]roletype.RoleType{1: roletype.RoleAdmin}, + Groups: []string{"foo", "bar"}, + ID: "", + Login: "eai-doe", + Name: "Eai Doe", + Email: "eai.doe@cor.po", + IsGrafanaAdmin: boolPtr(false), + AuthenticatedBy: login.JWTModule, + AuthID: "1234567890", + IsDisabled: false, + HelpFlags1: 0, + ClientParams: authn.ClientParams{ + SyncUser: true, + AllowSignUp: true, + FetchSyncedUser: true, + SyncOrgRoles: true, + SyncPermissions: true, + SyncTeams: true, + LookUpParams: login.UserLookupParams{ + UserID: nil, + Email: stringPtr("eai.doe@cor.po"), + Login: stringPtr("eai-doe"), + }, + }, + }, + verifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { + return jwt.JWTClaims{ + "sub": "1234567890", + "email": "eai.doe@cor.po", + "preferred_username": "eai-doe", + "name": "Eai Doe", + "roles": "Admin", + "groups": []string{"foo", "bar"}, + }, nil + }, + cfg: &setting.Cfg{ + JWTAuth: setting.AuthJWTSettings{ + Enabled: true, + HeaderName: jwtHeaderName, + EmailClaim: "email", + UsernameClaim: "preferred_username", + AutoSignUp: true, + AllowAssignGrafanaAdmin: true, + RoleAttributeStrict: true, + RoleAttributePath: "roles", + GroupsAttributePath: "groups[]", + }, + }, + }, + { + name: "Valid Use case without group path", + wantID: &authn.Identity{ + OrgID: 0, + OrgName: "", + OrgRoles: map[int64]roletype.RoleType{1: roletype.RoleAdmin}, + ID: "", + Login: "eai-doe", + Groups: []string{}, + Name: "Eai Doe", + Email: "eai.doe@cor.po", + IsGrafanaAdmin: boolPtr(false), + AuthenticatedBy: login.JWTModule, + AuthID: "1234567890", + IsDisabled: false, + HelpFlags1: 0, + ClientParams: authn.ClientParams{ + SyncUser: true, + AllowSignUp: true, + FetchSyncedUser: true, + SyncOrgRoles: true, + SyncPermissions: true, + SyncTeams: false, + LookUpParams: login.UserLookupParams{ + UserID: nil, + Email: stringPtr("eai.doe@cor.po"), + Login: stringPtr("eai-doe"), + }, + }, + }, + verifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { + return jwt.JWTClaims{ + "sub": "1234567890", + "email": "eai.doe@cor.po", + "preferred_username": "eai-doe", + "name": "Eai Doe", + "roles": "Admin", + "groups": []string{"foo", "bar"}, + }, nil + }, + cfg: &setting.Cfg{ + JWTAuth: setting.AuthJWTSettings{ + Enabled: true, + HeaderName: jwtHeaderName, + EmailClaim: "email", + UsernameClaim: "preferred_username", + AutoSignUp: true, + AllowAssignGrafanaAdmin: true, + RoleAttributeStrict: true, + RoleAttributePath: "roles", + }, }, }, } - cfg := &setting.Cfg{ - JWTAuthEnabled: true, - JWTAuthHeaderName: jwtHeaderName, - JWTAuthEmailClaim: "email", - JWTAuthUsernameClaim: "preferred_username", - JWTAuthAutoSignUp: true, - JWTAuthAllowAssignGrafanaAdmin: true, - JWTAuthRoleAttributeStrict: true, - JWTAuthRoleAttributePath: "roles", - } - jwtClient := ProvideJWT(jwtService, cfg) - validHTTPReq := &http.Request{ - Header: map[string][]string{ - jwtHeaderName: {"sample-token"}}, - } + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + jwtService := &jwt.FakeJWTService{ + VerifyProvider: tc.verifyProvider, + } - id, err := jwtClient.Authenticate(context.Background(), &authn.Request{ - OrgID: 1, - HTTPRequest: validHTTPReq, - Resp: nil, - }) - require.NoError(t, err) + jwtClient := ProvideJWT(jwtService, tc.cfg) + validHTTPReq := &http.Request{ + Header: map[string][]string{ + jwtHeaderName: {"sample-token"}}, + } + + id, err := jwtClient.Authenticate(context.Background(), &authn.Request{ + OrgID: 1, + HTTPRequest: validHTTPReq, + Resp: nil, + }) + require.NoError(t, err) - assert.EqualValues(t, wantID, id, fmt.Sprintf("%+v", id)) + assert.EqualValues(t, tc.wantID, id, fmt.Sprintf("%+v", id)) + }) + } } func TestJWTClaimConfig(t *testing.T) { + t.Parallel() jwtService := &jwt.FakeJWTService{ VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { return jwt.JWTClaims{ @@ -102,30 +184,19 @@ func TestJWTClaimConfig(t *testing.T) { jwtHeaderName := "X-Forwarded-User" - cfg := &setting.Cfg{ - JWTAuthEnabled: true, - JWTAuthHeaderName: jwtHeaderName, - JWTAuthAutoSignUp: true, - JWTAuthAllowAssignGrafanaAdmin: true, - JWTAuthRoleAttributeStrict: true, - JWTAuthRoleAttributePath: "roles", - } - // #nosec G101 -- This is a dummy/test token token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o" - type Dictionary map[string]any - type testCase struct { desc string - claimsConfigurations []Dictionary + claimsConfigurations []util.DynMap valid bool } testCases := []testCase{ { desc: "JWT configuration with email and username claims", - claimsConfigurations: []Dictionary{ + claimsConfigurations: []util.DynMap{ { "JWTAuthEmailClaim": true, "JWTAuthUsernameClaim": true, @@ -135,7 +206,7 @@ func TestJWTClaimConfig(t *testing.T) { }, { desc: "JWT configuration with email claim", - claimsConfigurations: []Dictionary{ + claimsConfigurations: []util.DynMap{ { "JWTAuthEmailClaim": true, "JWTAuthUsernameClaim": false, @@ -145,7 +216,7 @@ func TestJWTClaimConfig(t *testing.T) { }, { desc: "JWT configuration with username claim", - claimsConfigurations: []Dictionary{ + claimsConfigurations: []util.DynMap{ { "JWTAuthEmailClaim": false, "JWTAuthUsernameClaim": true, @@ -155,7 +226,7 @@ func TestJWTClaimConfig(t *testing.T) { }, { desc: "JWT configuration without email and username claims", - claimsConfigurations: []Dictionary{ + claimsConfigurations: []util.DynMap{ { "JWTAuthEmailClaim": false, "JWTAuthUsernameClaim": false, @@ -166,39 +237,53 @@ func TestJWTClaimConfig(t *testing.T) { } for _, tc := range testCases { + tc := tc t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + cfg := &setting.Cfg{ + JWTAuth: setting.AuthJWTSettings{ + Enabled: true, + HeaderName: jwtHeaderName, + AutoSignUp: true, + AllowAssignGrafanaAdmin: true, + RoleAttributeStrict: true, + RoleAttributePath: "roles", + }, + } for _, claims := range tc.claimsConfigurations { - cfg.JWTAuthEmailClaim = "" - cfg.JWTAuthUsernameClaim = "" + cfg.JWTAuth.EmailClaim = "" + cfg.JWTAuth.UsernameClaim = "" if claims["JWTAuthEmailClaim"] == true { - cfg.JWTAuthEmailClaim = "email" + cfg.JWTAuth.EmailClaim = "email" } if claims["JWTAuthUsernameClaim"] == true { - cfg.JWTAuthUsernameClaim = "preferred_username" + cfg.JWTAuth.UsernameClaim = "preferred_username" } } + + httpReq := &http.Request{ + URL: &url.URL{RawQuery: "auth_token=" + token}, + Header: map[string][]string{ + jwtHeaderName: {token}}, + } + jwtClient := ProvideJWT(jwtService, cfg) + _, err := jwtClient.Authenticate(context.Background(), &authn.Request{ + OrgID: 1, + HTTPRequest: httpReq, + Resp: nil, + }) + if tc.valid { + require.NoError(t, err) + } else { + require.Error(t, err) + } }) - httpReq := &http.Request{ - URL: &url.URL{RawQuery: "auth_token=" + token}, - Header: map[string][]string{ - jwtHeaderName: {token}}, - } - jwtClient := ProvideJWT(jwtService, cfg) - _, err := jwtClient.Authenticate(context.Background(), &authn.Request{ - OrgID: 1, - HTTPRequest: httpReq, - Resp: nil, - }) - if tc.valid { - require.NoError(t, err) - } else { - require.Error(t, err) - } } } func TestJWTTest(t *testing.T) { + t.Parallel() jwtService := &jwt.FakeJWTService{} jwtHeaderName := "X-Forwarded-User" // #nosec G101 -- This is dummy/test token @@ -280,14 +365,18 @@ func TestJWTTest(t *testing.T) { } for _, tc := range testCases { + tc := tc t.Run(tc.desc, func(t *testing.T) { + t.Parallel() cfg := &setting.Cfg{ - JWTAuthEnabled: true, - JWTAuthURLLogin: tc.urlLogin, - JWTAuthHeaderName: tc.cfgHeaderName, - JWTAuthAutoSignUp: true, - JWTAuthAllowAssignGrafanaAdmin: true, - JWTAuthRoleAttributeStrict: true, + JWTAuth: setting.AuthJWTSettings{ + Enabled: true, + URLLogin: tc.urlLogin, + HeaderName: tc.cfgHeaderName, + AutoSignUp: true, + AllowAssignGrafanaAdmin: true, + RoleAttributeStrict: true, + }, } jwtClient := ProvideJWT(jwtService, cfg) httpReq := &http.Request{ @@ -308,6 +397,7 @@ func TestJWTTest(t *testing.T) { } func TestJWTStripParam(t *testing.T) { + t.Parallel() jwtService := &jwt.FakeJWTService{ VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { return jwt.JWTClaims{ @@ -323,15 +413,17 @@ func TestJWTStripParam(t *testing.T) { jwtHeaderName := "X-Forwarded-User" cfg := &setting.Cfg{ - JWTAuthEnabled: true, - JWTAuthHeaderName: jwtHeaderName, - JWTAuthAutoSignUp: true, - JWTAuthAllowAssignGrafanaAdmin: true, - JWTAuthURLLogin: true, - JWTAuthRoleAttributeStrict: false, - JWTAuthRoleAttributePath: "roles", - JWTAuthEmailClaim: "email", - JWTAuthUsernameClaim: "preferred_username", + JWTAuth: setting.AuthJWTSettings{ + Enabled: true, + HeaderName: jwtHeaderName, + AutoSignUp: true, + AllowAssignGrafanaAdmin: true, + URLLogin: true, + RoleAttributeStrict: false, + RoleAttributePath: "roles", + EmailClaim: "email", + UsernameClaim: "preferred_username", + }, } // #nosec G101 -- This is a dummy/test token diff --git a/pkg/services/authn/clients/oauth.go b/pkg/services/authn/clients/oauth.go index c27a63b1d030a..7fd04b1fddf67 100644 --- a/pkg/services/authn/clients/oauth.go +++ b/pkg/services/authn/clients/oauth.go @@ -8,7 +8,6 @@ import ( "encoding/hex" "errors" "fmt" - "net/http" "net/url" "strings" @@ -40,6 +39,9 @@ const ( ) var ( + errOAuthClientDisabled = errutil.BadRequest("auth.oauth.disabled", errutil.WithPublicMessage("OAuth client is disabled")) + errOAuthInternal = errutil.Internal("auth.oauth.internal", errutil.WithPublicMessage("An internal error occurred in the OAuth client")) + errOAuthGenPKCE = errutil.Internal("auth.oauth.pkce.internal", errutil.WithPublicMessage("An internal error occurred")) errOAuthMissingPKCE = errutil.BadRequest("auth.oauth.pkce.missing", errutil.WithPublicMessage("Missing required pkce cookie")) @@ -62,24 +64,26 @@ var _ authn.LogoutClient = new(OAuth) var _ authn.RedirectClient = new(OAuth) func ProvideOAuth( - name string, cfg *setting.Cfg, oauthCfg *social.OAuthInfo, - connector social.SocialConnector, httpClient *http.Client, oauthService oauthtoken.OAuthTokenService, + name string, cfg *setting.Cfg, oauthService oauthtoken.OAuthTokenService, + socialService social.Service, settingsProviderService setting.Provider, ) *OAuth { + providerName := strings.TrimPrefix(name, "auth.client.") return &OAuth{ - name, fmt.Sprintf("oauth_%s", strings.TrimPrefix(name, "auth.client.")), - log.New(name), cfg, oauthCfg, connector, httpClient, oauthService, + name, fmt.Sprintf("oauth_%s", providerName), providerName, + log.New(name), cfg, settingsProviderService, oauthService, socialService, } } type OAuth struct { name string moduleName string + providerName string log log.Logger cfg *setting.Cfg - oauthCfg *social.OAuthInfo - connector social.SocialConnector - httpClient *http.Client - oauthService oauthtoken.OAuthTokenService + + settingsProviderSvc setting.Provider + oauthService oauthtoken.OAuthTokenService + socialService social.Service } func (c *OAuth) Name() string { @@ -88,6 +92,12 @@ func (c *OAuth) Name() string { func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { r.SetMeta(authn.MetaKeyAuthModule, c.moduleName) + + oauthCfg := c.socialService.GetOAuthInfoProvider(c.providerName) + if !oauthCfg.Enabled { + return nil, errOAuthClientDisabled.Errorf("oauth client is disabled: %s", c.providerName) + } + // get hashed state stored in cookie stateCookie, err := r.HTTPRequest.Cookie(oauthStateCookieName) if err != nil { @@ -99,7 +109,7 @@ func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden } // get state returned by the idp and hash it - stateQuery := hashOAuthState(r.HTTPRequest.URL.Query().Get(oauthStateQueryName), c.cfg.SecretKey, c.oauthCfg.ClientSecret) + stateQuery := hashOAuthState(r.HTTPRequest.URL.Query().Get(oauthStateQueryName), c.cfg.SecretKey, oauthCfg.ClientSecret) // compare the state returned by idp against the one we stored in cookie if stateQuery != stateCookie.Value { return nil, errOAuthInvalidState.Errorf("provided state did not match stored state") @@ -107,23 +117,29 @@ func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden var opts []oauth2.AuthCodeOption // if pkce is enabled for client validate we have the cookie and set it as url param - if c.oauthCfg.UsePKCE { + if oauthCfg.UsePKCE { pkceCookie, err := r.HTTPRequest.Cookie(oauthPKCECookieName) if err != nil { return nil, errOAuthMissingPKCE.Errorf("no pkce cookie found: %w", err) } - opts = append(opts, oauth2.SetAuthURLParam(codeVerifierParamName, pkceCookie.Value)) + opts = append(opts, oauth2.VerifierOption(pkceCookie.Value)) } - clientCtx := context.WithValue(ctx, oauth2.HTTPClient, c.httpClient) + connector, errConnector := c.socialService.GetConnector(c.providerName) + httpClient, errHTTPClient := c.socialService.GetOAuthHttpClient(c.providerName) + if errConnector != nil || errHTTPClient != nil { + return nil, errOAuthInternal.Errorf("failed to get %s oauth client: %w", c.name, errors.Join(errConnector, errHTTPClient)) + } + + clientCtx := context.WithValue(ctx, oauth2.HTTPClient, httpClient) // exchange auth code to a valid token - token, err := c.connector.Exchange(clientCtx, r.HTTPRequest.URL.Query().Get("code"), opts...) + token, err := connector.Exchange(clientCtx, r.HTTPRequest.URL.Query().Get("code"), opts...) if err != nil { return nil, errOAuthTokenExchange.Errorf("failed to exchange code to token: %w", err) } token.TokenType = "Bearer" - userInfo, err := c.connector.UserInfo(ctx, c.connector.Client(clientCtx, token), token) + userInfo, err := connector.UserInfo(ctx, connector.Client(clientCtx, token), token) if err != nil { var sErr *connectors.SocialError if errors.As(err, &sErr) { @@ -132,11 +148,16 @@ func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden return nil, errOAuthUserInfo.Errorf("failed to get user info: %w", err) } + // Implement in Grafana 11 + // if userInfo.Id == "" { + // return nil, errors.New("idP did not return a user id") + // } + if userInfo.Email == "" { return nil, errOAuthMissingRequiredEmail.Errorf("required attribute email was not provided") } - if !c.connector.IsEmailAllowed(userInfo.Email) { + if !connector.IsEmailAllowed(userInfo.Email) { return nil, errOAuthEmailNotAllowed.Errorf("provided email is not allowed") } @@ -148,7 +169,8 @@ func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden }) lookupParams := login.UserLookupParams{} - if c.cfg.OAuthAllowInsecureEmailLookup { + allowInsecureEmailLookup := c.settingsProviderSvc.KeyValue("auth", "oauth_allow_insecure_email_lookup").MustBool(false) + if allowInsecureEmailLookup { lookupParams.Email = &userInfo.Email } @@ -167,7 +189,7 @@ func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden SyncTeams: true, FetchSyncedUser: true, SyncPermissions: true, - AllowSignUp: c.connector.IsSignupAllowed(), + AllowSignUp: connector.IsSignupAllowed(), // skip org role flag is checked and handled in the connector. For now we can skip the hook if no roles are passed SyncOrgRoles: len(orgRoles) > 0, LookUpParams: lookupParams, @@ -178,31 +200,38 @@ func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden func (c *OAuth) RedirectURL(ctx context.Context, r *authn.Request) (*authn.Redirect, error) { var opts []oauth2.AuthCodeOption - if c.oauthCfg.HostedDomain != "" { - opts = append(opts, oauth2.SetAuthURLParam(hostedDomainParamName, c.oauthCfg.HostedDomain)) + oauthCfg := c.socialService.GetOAuthInfoProvider(c.providerName) + if !oauthCfg.Enabled { + return nil, errOAuthClientDisabled.Errorf("oauth client is disabled: %s", c.providerName) + } + + if oauthCfg.HostedDomain != "" { + opts = append(opts, oauth2.SetAuthURLParam(hostedDomainParamName, oauthCfg.HostedDomain)) } var plainPKCE string - if c.oauthCfg.UsePKCE { - pkce, hashedPKCE, err := genPKCECode() + if oauthCfg.UsePKCE { + verifier, err := genPKCECodeVerifier() if err != nil { return nil, errOAuthGenPKCE.Errorf("failed to generate pkce: %w", err) } - plainPKCE = pkce - opts = append(opts, - oauth2.SetAuthURLParam(codeChallengeParamName, hashedPKCE), - oauth2.SetAuthURLParam(codeChallengeMethodParamName, codeChallengeMethod), - ) + plainPKCE = verifier + opts = append(opts, oauth2.S256ChallengeOption(plainPKCE)) } - state, hashedSate, err := genOAuthState(c.cfg.SecretKey, c.oauthCfg.ClientSecret) + state, hashedSate, err := genOAuthState(c.cfg.SecretKey, oauthCfg.ClientSecret) if err != nil { return nil, errOAuthGenState.Errorf("failed to generate state: %w", err) } + connector, err := c.socialService.GetConnector(c.providerName) + if err != nil { + return nil, errOAuthInternal.Errorf("failed to get %s oauth connector: %w", c.name, err) + } + return &authn.Redirect{ - URL: c.connector.AuthCodeURL(state, opts...), + URL: connector.AuthCodeURL(state, opts...), Extra: map[string]string{ authn.KeyOAuthState: hashedSate, authn.KeyOAuthPKCE: plainPKCE, @@ -218,23 +247,29 @@ func (c *OAuth) Logout(ctx context.Context, user identity.Requester, info *login c.log.FromContext(ctx).Error("Failed to invalidate tokens", "namespace", namespace, "id", id, "error", err) } - redirctURL := getOAuthSignoutRedirectURL(c.cfg, c.oauthCfg) - if redirctURL == "" { + oauthCfg := c.socialService.GetOAuthInfoProvider(c.providerName) + if !oauthCfg.Enabled { + c.log.FromContext(ctx).Debug("OAuth client is disabled") + return nil, false + } + + redirectURL := getOAuthSignoutRedirectURL(c.cfg, oauthCfg) + if redirectURL == "" { c.log.FromContext(ctx).Debug("No signout redirect url configured") return nil, false } - if isOICDLogout(redirctURL) && token != nil && token.Valid() { + if isOICDLogout(redirectURL) && token != nil && token.Valid() { if idToken, ok := token.Extra("id_token").(string); ok { - redirctURL = withIDTokenHint(redirctURL, idToken) + redirectURL = withIDTokenHint(redirectURL, idToken) } } - return &authn.Redirect{URL: redirctURL}, true + return &authn.Redirect{URL: redirectURL}, true } -// genPKCECode returns a random URL-friendly string and it's base64 URL encoded SHA256 digest. -func genPKCECode() (string, string, error) { +// genPKCECodeVerifier returns code verifier that 128 characters random URL-friendly string. +func genPKCECodeVerifier() (string, error) { // IETF RFC 7636 specifies that the code verifier should be 43-128 // characters from a set of unreserved URI characters which is // almost the same as the set of characters in base64url. @@ -249,14 +284,12 @@ func genPKCECode() (string, string, error) { raw := make([]byte, 96) _, err := rand.Read(raw) if err != nil { - return "", "", err + return "", err } ascii := make([]byte, 128) base64.RawURLEncoding.Encode(ascii, raw) - shasum := sha256.Sum256(ascii) - pkce := base64.RawURLEncoding.EncodeToString(shasum[:]) - return string(ascii), pkce, nil + return string(ascii), nil } func genOAuthState(secret, seed string) (string, string, error) { diff --git a/pkg/services/authn/clients/oauth_test.go b/pkg/services/authn/clients/oauth_test.go index 893bc6934f992..b1007594c80cb 100644 --- a/pkg/services/authn/clients/oauth_test.go +++ b/pkg/services/authn/clients/oauth_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/url" + "strconv" "strings" "testing" "time" @@ -14,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/login/social/socialtest" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/login" @@ -46,17 +48,23 @@ func TestOAuth_Authenticate(t *testing.T) { { desc: "should return error when missing state cookie", req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{}}}, - oauthCfg: &social.OAuthInfo{}, + oauthCfg: &social.OAuthInfo{Enabled: true}, expectedErr: errOAuthMissingState, }, { desc: "should return error when state cookie is present but don't have a value", req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{}}}, - oauthCfg: &social.OAuthInfo{}, + oauthCfg: &social.OAuthInfo{Enabled: true}, addStateCookie: true, stateCookieValue: "", expectedErr: errOAuthMissingState, }, + { + desc: "should return error when the client is not enabled", + req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{}}}, + oauthCfg: &social.OAuthInfo{Enabled: false}, + expectedErr: errOAuthClientDisabled, + }, { desc: "should return error when state from ipd does not match stored state", req: &authn.Request{HTTPRequest: &http.Request{ @@ -64,7 +72,7 @@ func TestOAuth_Authenticate(t *testing.T) { URL: mustParseURL("http://grafana.com/?state=some-other-state"), }, }, - oauthCfg: &social.OAuthInfo{UsePKCE: true}, + oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true}, addStateCookie: true, stateCookieValue: "some-state", expectedErr: errOAuthInvalidState, @@ -76,7 +84,7 @@ func TestOAuth_Authenticate(t *testing.T) { URL: mustParseURL("http://grafana.com/?state=some-state"), }, }, - oauthCfg: &social.OAuthInfo{UsePKCE: true}, + oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true}, addStateCookie: true, stateCookieValue: "some-state", expectedErr: errOAuthMissingPKCE, @@ -88,7 +96,7 @@ func TestOAuth_Authenticate(t *testing.T) { URL: mustParseURL("http://grafana.com/?state=some-state"), }, }, - oauthCfg: &social.OAuthInfo{UsePKCE: true}, + oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true}, addStateCookie: true, stateCookieValue: "some-state", addPKCECookie: true, @@ -103,7 +111,7 @@ func TestOAuth_Authenticate(t *testing.T) { URL: mustParseURL("http://grafana.com/?state=some-state"), }, }, - oauthCfg: &social.OAuthInfo{UsePKCE: true}, + oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true}, addStateCookie: true, stateCookieValue: "some-state", addPKCECookie: true, @@ -119,7 +127,7 @@ func TestOAuth_Authenticate(t *testing.T) { URL: mustParseURL("http://grafana.com/?state=some-state"), }, }, - oauthCfg: &social.OAuthInfo{UsePKCE: true}, + oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true}, addStateCookie: true, stateCookieValue: "some-state", addPKCECookie: true, @@ -157,7 +165,7 @@ func TestOAuth_Authenticate(t *testing.T) { URL: mustParseURL("http://grafana.com/?state=some-state"), }, }, - oauthCfg: &social.OAuthInfo{UsePKCE: true}, + oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true}, allowInsecureTakeover: true, addStateCookie: true, stateCookieValue: "some-state", @@ -194,10 +202,12 @@ func TestOAuth_Authenticate(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { cfg := setting.NewCfg() + auth, err := cfg.Raw.NewSection("auth") + assert.NoError(t, err) + _, err = auth.NewKey("oauth_allow_insecure_email_lookup", strconv.FormatBool(tt.allowInsecureTakeover)) + assert.NoError(t, err) - if tt.allowInsecureTakeover { - cfg.OAuthAllowInsecureEmailLookup = true - } + settingsProvider := &setting.OSSImpl{Cfg: cfg} if tt.addStateCookie { v := tt.stateCookieValue @@ -211,12 +221,18 @@ func TestOAuth_Authenticate(t *testing.T) { tt.req.HTTPRequest.AddCookie(&http.Cookie{Name: oauthPKCECookieName, Value: tt.pkceCookieValue}) } - c := ProvideOAuth(authn.ClientWithPrefix("azuread"), cfg, tt.oauthCfg, fakeConnector{ - ExpectedUserInfo: tt.userInfo, - ExpectedToken: &oauth2.Token{}, - ExpectedIsSignupAllowed: true, - ExpectedIsEmailAllowed: tt.isEmailAllowed, - }, nil, nil) + fakeSocialSvc := &socialtest.FakeSocialService{ + ExpectedAuthInfoProvider: tt.oauthCfg, + ExpectedConnector: fakeConnector{ + ExpectedUserInfo: tt.userInfo, + ExpectedToken: &oauth2.Token{}, + ExpectedIsSignupAllowed: true, + ExpectedIsEmailAllowed: tt.isEmailAllowed, + }, + } + + c := ProvideOAuth(authn.ClientWithPrefix("azuread"), cfg, nil, fakeSocialSvc, settingsProvider) + identity, err := c.Authenticate(context.Background(), tt.req) assert.ErrorIs(t, err, tt.expectedErr) @@ -256,21 +272,27 @@ func TestOAuth_RedirectURL(t *testing.T) { tests := []testCase{ { desc: "should generate redirect url and state", - oauthCfg: &social.OAuthInfo{}, + oauthCfg: &social.OAuthInfo{Enabled: true}, authCodeUrlCalled: true, }, { desc: "should generate redirect url with hosted domain option if configured", - oauthCfg: &social.OAuthInfo{HostedDomain: "grafana.com"}, + oauthCfg: &social.OAuthInfo{HostedDomain: "grafana.com", Enabled: true}, numCallOptions: 1, authCodeUrlCalled: true, }, { desc: "should generate redirect url with pkce if configured", - oauthCfg: &social.OAuthInfo{UsePKCE: true}, - numCallOptions: 2, + oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true}, + numCallOptions: 1, authCodeUrlCalled: true, }, + { + desc: "should return error if the client is not enabled", + oauthCfg: &social.OAuthInfo{Enabled: false}, + authCodeUrlCalled: false, + expectedErr: errOAuthClientDisabled, + }, } for _, tt := range tests { @@ -279,13 +301,20 @@ func TestOAuth_RedirectURL(t *testing.T) { authCodeUrlCalled = false ) - c := ProvideOAuth(authn.ClientWithPrefix("azuread"), setting.NewCfg(), tt.oauthCfg, mockConnector{ - AuthCodeURLFunc: func(state string, opts ...oauth2.AuthCodeOption) string { - authCodeUrlCalled = true - require.Len(t, opts, tt.numCallOptions) - return "" + fakeSocialSvc := &socialtest.FakeSocialService{ + ExpectedAuthInfoProvider: tt.oauthCfg, + ExpectedConnector: mockConnector{ + AuthCodeURLFunc: func(state string, opts ...oauth2.AuthCodeOption) string { + authCodeUrlCalled = true + require.Len(t, opts, tt.numCallOptions) + return "" + }, }, - }, nil, nil) + } + + cfg := setting.NewCfg() + + c := ProvideOAuth(authn.ClientWithPrefix("azuread"), cfg, nil, fakeSocialSvc, &setting.OSSImpl{Cfg: cfg}) redirect, err := c.RedirectURL(context.Background(), nil) assert.ErrorIs(t, err, tt.expectedErr) @@ -321,12 +350,17 @@ func TestOAuth_Logout(t *testing.T) { cfg: &setting.Cfg{}, oauthCfg: &social.OAuthInfo{}, }, + { + desc: "should not return redirect url when client is not enabled", + cfg: &setting.Cfg{}, + oauthCfg: &social.OAuthInfo{Enabled: false}, + }, { desc: "should return redirect url for globably configured redirect url", cfg: &setting.Cfg{ SignoutRedirectUrl: "http://idp.com/logout", }, - oauthCfg: &social.OAuthInfo{}, + oauthCfg: &social.OAuthInfo{Enabled: true}, expectedURL: "http://idp.com/logout", expectedOK: true, }, @@ -334,6 +368,7 @@ func TestOAuth_Logout(t *testing.T) { desc: "should return redirect url for client configured redirect url", cfg: &setting.Cfg{}, oauthCfg: &social.OAuthInfo{ + Enabled: true, SignoutRedirectUrl: "http://idp.com/logout", }, expectedURL: "http://idp.com/logout", @@ -345,6 +380,7 @@ func TestOAuth_Logout(t *testing.T) { SignoutRedirectUrl: "http://idp.com/logout", }, oauthCfg: &social.OAuthInfo{ + Enabled: true, SignoutRedirectUrl: "http://idp-2.com/logout", }, expectedURL: "http://idp-2.com/logout", @@ -354,6 +390,7 @@ func TestOAuth_Logout(t *testing.T) { desc: "should add id token hint if oicd logout is configured and token is valid", cfg: &setting.Cfg{}, oauthCfg: &social.OAuthInfo{ + Enabled: true, SignoutRedirectUrl: "http://idp.com/logout?post_logout_redirect_uri=http%3A%3A%2F%2Ftest.com%2Flogin", }, expectedURL: "http://idp.com/logout", @@ -387,7 +424,10 @@ func TestOAuth_Logout(t *testing.T) { }, } - c := ProvideOAuth(authn.ClientWithPrefix("azuread"), tt.cfg, tt.oauthCfg, mockConnector{}, nil, mockService) + fakeSocialSvc := &socialtest.FakeSocialService{ + ExpectedAuthInfoProvider: tt.oauthCfg, + } + c := ProvideOAuth(authn.ClientWithPrefix("azuread"), tt.cfg, mockService, fakeSocialSvc, &setting.OSSImpl{Cfg: tt.cfg}) redirect, ok := c.Logout(context.Background(), &authn.Identity{}, &login.UserAuth{}) @@ -404,6 +444,12 @@ func TestOAuth_Logout(t *testing.T) { } } +func TestGenPKCECodeVerifier(t *testing.T) { + verifier, err := genPKCECodeVerifier() + assert.NoError(t, err) + assert.Len(t, verifier, 128) +} + type mockConnector struct { AuthCodeURLFunc func(state string, opts ...oauth2.AuthCodeOption) string social.SocialConnector diff --git a/pkg/services/authn/clients/proxy.go b/pkg/services/authn/clients/proxy.go index fd19789274d2b..cd6a890761577 100644 --- a/pkg/services/authn/clients/proxy.go +++ b/pkg/services/authn/clients/proxy.go @@ -3,6 +3,7 @@ package clients import ( "context" "encoding/hex" + "errors" "fmt" "hash/fnv" "net" @@ -12,10 +13,10 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/remotecache" authidentity "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/login" - "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/errutil" @@ -43,24 +44,24 @@ var ( _ authn.ContextAwareClient = new(Proxy) ) -func ProvideProxy(cfg *setting.Cfg, cache proxyCache, userSrv user.Service, clients ...authn.ProxyClient) (*Proxy, error) { - list, err := parseAcceptList(cfg.AuthProxyWhitelist) +func ProvideProxy(cfg *setting.Cfg, cache proxyCache, clients ...authn.ProxyClient) (*Proxy, error) { + list, err := parseAcceptList(cfg.AuthProxy.Whitelist) if err != nil { return nil, err } - return &Proxy{log.New(authn.ClientProxy), cfg, cache, userSrv, clients, list}, nil + return &Proxy{log.New(authn.ClientProxy), cfg, cache, clients, list}, nil } type proxyCache interface { Get(ctx context.Context, key string) ([]byte, error) Set(ctx context.Context, key string, value []byte, expire time.Duration) error + Delete(ctx context.Context, key string) error } type Proxy struct { log log.Logger cfg *setting.Cfg cache proxyCache - userSrv user.Service clients []authn.ProxyClient acceptedIPs []*net.IPNet } @@ -74,38 +75,22 @@ func (c *Proxy) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden return nil, errNotAcceptedIP.Errorf("request ip is not in the configured accept list") } - username := getProxyHeader(r, c.cfg.AuthProxyHeaderName, c.cfg.AuthProxyHeadersEncoded) + username := getProxyHeader(r, c.cfg.AuthProxy.HeaderName, c.cfg.AuthProxy.HeadersEncoded) if len(username) == 0 { return nil, errEmptyProxyHeader.Errorf("no username provided in auth proxy header") } additional := getAdditionalProxyHeaders(r, c.cfg) - cacheKey, ok := getProxyCacheKey(username, additional) - if ok { - // See if we have cached the user id, in that case we can fetch the signed-in user and skip sync. - // Error here means that we could not find anything in cache, so we can proceed as usual - if entry, err := c.cache.Get(ctx, cacheKey); err == nil { - uid, err := strconv.ParseInt(string(entry), 10, 64) - if err != nil { - c.log.FromContext(ctx).Warn("Failed to parse user id from cache", "error", err, "userId", string(entry)) - } else { - usr, err := c.userSrv.GetSignedInUserWithCacheCtx(ctx, &user.GetSignedInUserQuery{ - UserID: uid, - OrgID: r.OrgID, - }) - - if err != nil { - c.log.FromContext(ctx).Warn("Could not resolved cached user", "error", err, "userId", string(entry)) - } - - // if we for some reason cannot find the user we proceed with the normal flow, authenticate with ProxyClient - // and perform syncs - if usr != nil { - c.log.FromContext(ctx).Debug("User was loaded from cache, skip syncs", "userId", usr.UserID) - return authn.IdentityFromSignedInUser(authn.NamespacedID(authn.NamespaceUser, usr.UserID), usr, authn.ClientParams{SyncPermissions: true}, login.AuthProxyAuthModule), nil - } - } + + if c.cfg.AuthProxy.SyncTTL != 0 && ok { + identity, errCache := c.retrieveIDFromCache(ctx, cacheKey, r) + if errCache == nil { + return identity, nil + } + + if !errors.Is(errCache, remotecache.ErrCacheItemNotFound) { + c.log.FromContext(ctx).Warn("Failed to fetch auth proxy info from cache", "error", errCache) } } @@ -115,7 +100,6 @@ func (c *Proxy) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden identity, clientErr = proxyClient.AuthenticateProxy(ctx, r, username, additional) if identity != nil { identity.ClientParams.CacheAuthProxyKey = cacheKey - identity.AuthenticatedBy = login.AuthProxyAuthModule return identity, nil } } @@ -123,8 +107,34 @@ func (c *Proxy) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden return nil, clientErr } +// See if we have cached the user id, in that case we can fetch the signed-in user and skip sync. +// Error here means that we could not find anything in cache, so we can proceed as usual +func (c *Proxy) retrieveIDFromCache(ctx context.Context, cacheKey string, r *authn.Request) (*authn.Identity, error) { + entry, err := c.cache.Get(ctx, cacheKey) + if err != nil { + return nil, err + } + + uid, err := strconv.ParseInt(string(entry), 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse user id from cache: %w - entry: %s", err, string(entry)) + } + + return &authn.Identity{ + ID: authn.NamespacedID(authn.NamespaceUser, uid), + OrgID: r.OrgID, + // FIXME: This does not match the actual auth module used, but should not have any impact + // Maybe caching the auth module used with the user ID would be a good idea + AuthenticatedBy: login.AuthProxyAuthModule, + ClientParams: authn.ClientParams{ + FetchSyncedUser: true, + SyncPermissions: true, + }, + }, nil +} + func (c *Proxy) Test(ctx context.Context, r *authn.Request) bool { - return len(getProxyHeader(r, c.cfg.AuthProxyHeaderName, c.cfg.AuthProxyHeadersEncoded)) != 0 + return len(getProxyHeader(r, c.cfg.AuthProxy.HeaderName, c.cfg.AuthProxy.HeadersEncoded)) != 0 } func (c *Proxy) Priority() uint { @@ -146,13 +156,32 @@ func (c *Proxy) Hook(ctx context.Context, identity *authn.Identity, r *authn.Req c.log.Warn("Failed to cache proxy user", "error", err, "userId", identifier, "err", err) return nil } + + // User's role would not be updated if the cache hit. If requests arrive in the following order: + // 1. Name = x; Role = Admin # cache missed, new user created and cached with key Name=x;Role=Admin + // 2. Name = x; Role = Editor # cache missed, the user got updated and cached with key Name=x;Role=Editor + // 3. Name = x; Role = Admin # cache hit with key Name=x;Role=Admin, no update, the user stays with Role=Editor + // To avoid such a problem we also cache the key used using `prefix:[username]`. + // Then whenever we get a cache miss due to changes in any header we use it to invalidate the previous item. + username := getProxyHeader(r, c.cfg.AuthProxy.HeaderName, c.cfg.AuthProxy.HeadersEncoded) + userKey := fmt.Sprintf("%s:%s", proxyCachePrefix, username) + + // invalidate previously cached user id + if prevCacheKey, err := c.cache.Get(ctx, userKey); err == nil && len(prevCacheKey) > 0 { + if err := c.cache.Delete(ctx, string(prevCacheKey)); err != nil { + return err + } + } + c.log.FromContext(ctx).Debug("Cache proxy user", "userId", id) bytes := []byte(strconv.FormatInt(id, 10)) - if err := c.cache.Set(ctx, identity.ClientParams.CacheAuthProxyKey, bytes, time.Duration(c.cfg.AuthProxySyncTTL)*time.Minute); err != nil { + duration := time.Duration(c.cfg.AuthProxy.SyncTTL) * time.Minute + if err := c.cache.Set(ctx, identity.ClientParams.CacheAuthProxyKey, bytes, duration); err != nil { c.log.Warn("Failed to cache proxy user", "error", err, "userId", id) } - return nil + // store current cacheKey for the user + return c.cache.Set(ctx, userKey, []byte(identity.ClientParams.CacheAuthProxyKey), duration) } func (c *Proxy) isAllowedIP(r *authn.Request) bool { @@ -219,7 +248,7 @@ func getProxyHeader(r *authn.Request, headerName string, encoded bool) string { func getAdditionalProxyHeaders(r *authn.Request, cfg *setting.Cfg) map[string]string { additional := make(map[string]string, len(proxyFields)) for _, k := range proxyFields { - if v := getProxyHeader(r, cfg.AuthProxyHeaders[k], cfg.AuthProxyHeadersEncoded); v != "" { + if v := getProxyHeader(r, cfg.AuthProxy.Headers[k], cfg.AuthProxy.HeadersEncoded); v != "" { additional[k] = v } } diff --git a/pkg/services/authn/clients/proxy_test.go b/pkg/services/authn/clients/proxy_test.go index a0b19dc1a0d93..1f9801224766c 100644 --- a/pkg/services/authn/clients/proxy_test.go +++ b/pkg/services/authn/clients/proxy_test.go @@ -3,6 +3,7 @@ package clients import ( "context" "errors" + "fmt" "net/http" "testing" "time" @@ -12,7 +13,6 @@ import ( "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn/authntest" - "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" ) @@ -100,9 +100,9 @@ func TestProxy_Authenticate(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { cfg := setting.NewCfg() - cfg.AuthProxyHeaderName = "X-Username" - cfg.AuthProxyHeaders = tt.proxyHeaders - cfg.AuthProxyWhitelist = tt.ips + cfg.AuthProxy.HeaderName = "X-Username" + cfg.AuthProxy.Headers = tt.proxyHeaders + cfg.AuthProxy.Whitelist = tt.ips calledUsername := "" var calledAdditional map[string]string @@ -112,7 +112,7 @@ func TestProxy_Authenticate(t *testing.T) { calledAdditional = additional return nil, nil }} - c, err := ProvideProxy(cfg, fakeCache{expectedErr: errors.New("")}, usertest.NewUserServiceFake(), proxyClient) + c, err := ProvideProxy(cfg, &fakeCache{expectedErr: errors.New("")}, proxyClient) require.NoError(t, err) _, err = c.Authenticate(context.Background(), tt.req) @@ -166,7 +166,7 @@ func TestProxy_Test(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { cfg := setting.NewCfg() - cfg.AuthProxyHeaderName = "Proxy-Header" + cfg.AuthProxy.HeaderName = "Proxy-Header" c, _ := ProvideProxy(cfg, nil, nil, nil) assert.Equal(t, tt.expectedOK, c.Test(context.Background(), tt.req)) @@ -177,14 +177,65 @@ func TestProxy_Test(t *testing.T) { var _ proxyCache = new(fakeCache) type fakeCache struct { - expectedErr error - expectedItem []byte + data map[string][]byte + expectedErr error } -func (f fakeCache) Get(ctx context.Context, key string) ([]byte, error) { - return f.expectedItem, f.expectedErr +func (f *fakeCache) Get(ctx context.Context, key string) ([]byte, error) { + return f.data[key], f.expectedErr } -func (f fakeCache) Set(ctx context.Context, key string, value []byte, expire time.Duration) error { +func (f *fakeCache) Set(ctx context.Context, key string, value []byte, expire time.Duration) error { + f.data[key] = value return f.expectedErr } + +func (f fakeCache) Delete(ctx context.Context, key string) error { + delete(f.data, key) + return f.expectedErr +} + +func TestProxy_Hook(t *testing.T) { + cfg := setting.NewCfg() + cfg.AuthProxy.HeaderName = "X-Username" + cfg.AuthProxy.Headers = map[string]string{ + proxyFieldRole: "X-Role", + } + cache := &fakeCache{data: make(map[string][]byte)} + userId := 1 + userID := fmt.Sprintf("%s:%d", authn.NamespaceUser, userId) + + // withRole creates a test case for a user with a specific role. + withRole := func(role string) func(t *testing.T) { + cacheKey := fmt.Sprintf("users:johndoe-%s", role) + return func(t *testing.T) { + c, err := ProvideProxy(cfg, cache, authntest.MockProxyClient{}) + require.NoError(t, err) + userIdentity := &authn.Identity{ + ID: userID, + ClientParams: authn.ClientParams{ + CacheAuthProxyKey: cacheKey, + }, + } + userReq := &authn.Request{ + HTTPRequest: &http.Request{ + Header: map[string][]string{ + "X-Username": {"johndoe"}, + "X-Role": {role}, + }, + }, + } + err = c.Hook(context.Background(), userIdentity, userReq) + assert.NoError(t, err) + expectedCache := map[string][]byte{ + cacheKey: []byte(fmt.Sprintf("%d", userId)), + fmt.Sprintf("%s:%s", proxyCachePrefix, "johndoe"): []byte(fmt.Sprintf("users:johndoe-%s", role)), + } + assert.Equal(t, expectedCache, cache.data) + } + } + + t.Run("step 1: new user with role Admin", withRole("Admin")) + t.Run("step 2: cached user with new Role Viewer", withRole("Viewer")) + t.Run("step 3: cached user get changed back to Admin", withRole("Admin")) +} diff --git a/pkg/services/authn/clients/render.go b/pkg/services/authn/clients/render.go index 5386240822875..e5a1b6970a40c 100644 --- a/pkg/services/authn/clients/render.go +++ b/pkg/services/authn/clients/render.go @@ -8,7 +8,6 @@ import ( "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/rendering" - "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util/errutil" ) @@ -22,12 +21,11 @@ const ( var _ authn.ContextAwareClient = new(Render) -func ProvideRender(userService user.Service, renderService rendering.Service) *Render { - return &Render{userService, renderService} +func ProvideRender(renderService rendering.Service) *Render { + return &Render{renderService} } type Render struct { - userService user.Service renderService rendering.Service } @@ -42,26 +40,23 @@ func (c *Render) Authenticate(ctx context.Context, r *authn.Request) (*authn.Ide return nil, errInvalidRenderKey.Errorf("found no render user for key: %s", key) } - var identity *authn.Identity if renderUsr.UserID <= 0 { - identity = &authn.Identity{ - ID: authn.NamespacedID(authn.NamespaceRenderService, 0), - OrgID: renderUsr.OrgID, - OrgRoles: map[int64]org.RoleType{renderUsr.OrgID: org.RoleType(renderUsr.OrgRole)}, - ClientParams: authn.ClientParams{SyncPermissions: true}, - } - } else { - usr, err := c.userService.GetSignedInUserWithCacheCtx(ctx, &user.GetSignedInUserQuery{UserID: renderUsr.UserID, OrgID: renderUsr.OrgID}) - if err != nil { - return nil, err - } - - identity = authn.IdentityFromSignedInUser(authn.NamespacedID(authn.NamespaceUser, usr.UserID), usr, authn.ClientParams{SyncPermissions: true}, login.RenderModule) + return &authn.Identity{ + ID: authn.NamespacedID(authn.NamespaceRenderService, 0), + OrgID: renderUsr.OrgID, + OrgRoles: map[int64]org.RoleType{renderUsr.OrgID: org.RoleType(renderUsr.OrgRole)}, + ClientParams: authn.ClientParams{SyncPermissions: true}, + LastSeenAt: time.Now(), + AuthenticatedBy: login.RenderModule, + }, nil } - identity.LastSeenAt = time.Now() - identity.AuthenticatedBy = login.RenderModule - return identity, nil + return &authn.Identity{ + ID: authn.NamespacedID(authn.NamespaceUser, renderUsr.UserID), + LastSeenAt: time.Now(), + AuthenticatedBy: login.RenderModule, + ClientParams: authn.ClientParams{FetchSyncedUser: true, SyncPermissions: true}, + }, nil } func (c *Render) Test(ctx context.Context, r *authn.Request) bool { diff --git a/pkg/services/authn/clients/render_test.go b/pkg/services/authn/clients/render_test.go index 19e18dbb33665..24051db326f6f 100644 --- a/pkg/services/authn/clients/render_test.go +++ b/pkg/services/authn/clients/render_test.go @@ -13,8 +13,6 @@ import ( "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/rendering" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/services/user/usertest" ) func TestRender_Authenticate(t *testing.T) { @@ -23,7 +21,6 @@ func TestRender_Authenticate(t *testing.T) { renderKey string req *authn.Request expectedErr error - expectedUsr *user.SignedInUser expectedIdentity *authn.Identity expectedRenderUsr *rendering.RenderUser } @@ -60,23 +57,13 @@ func TestRender_Authenticate(t *testing.T) { }, expectedIdentity: &authn.Identity{ ID: "user:1", - OrgID: 1, - OrgName: "test", - OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin}, - IsGrafanaAdmin: boolPtr(false), AuthenticatedBy: login.RenderModule, - ClientParams: authn.ClientParams{SyncPermissions: true}, + ClientParams: authn.ClientParams{FetchSyncedUser: true, SyncPermissions: true}, }, expectedRenderUsr: &rendering.RenderUser{ OrgID: 1, UserID: 1, }, - expectedUsr: &user.SignedInUser{ - UserID: 1, - OrgID: 1, - OrgName: "test", - OrgRole: "Admin", - }, }, { desc: "expect error when render key is invalid", @@ -97,7 +84,7 @@ func TestRender_Authenticate(t *testing.T) { renderService := rendering.NewMockService(ctrl) renderService.EXPECT().GetRenderUser(gomock.Any(), tt.renderKey).Return(tt.expectedRenderUsr, tt.expectedRenderUsr != nil) - c := ProvideRender(&usertest.FakeUserService{ExpectedSignedInUser: tt.expectedUsr}, renderService) + c := ProvideRender(renderService) identity, err := c.Authenticate(context.Background(), tt.req) if tt.expectedErr != nil { assert.ErrorIs(t, tt.expectedErr, err) @@ -141,7 +128,7 @@ func TestRender_Test(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - c := ProvideRender(&usertest.FakeUserService{}, &rendering.MockService{}) + c := ProvideRender(&rendering.MockService{}) assert.Equal(t, tt.expected, c.Test(context.Background(), tt.req)) }) } diff --git a/pkg/services/authn/clients/session.go b/pkg/services/authn/clients/session.go index dc8219c024035..1af47b16449d6 100644 --- a/pkg/services/authn/clients/session.go +++ b/pkg/services/authn/clients/session.go @@ -2,27 +2,20 @@ package clients import ( "context" - "errors" "net/url" "time" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/network" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/authn" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/web" ) -var _ authn.HookClient = new(Session) var _ authn.ContextAwareClient = new(Session) -func ProvideSession(cfg *setting.Cfg, sessionService auth.UserTokenService, - features *featuremgmt.FeatureManager) *Session { +func ProvideSession(cfg *setting.Cfg, sessionService auth.UserTokenService) *Session { return &Session{ cfg: cfg, - features: features, sessionService: sessionService, log: log.New(authn.ClientSession), } @@ -30,7 +23,6 @@ func ProvideSession(cfg *setting.Cfg, sessionService auth.UserTokenService, type Session struct { cfg *setting.Cfg - features *featuremgmt.FeatureManager sessionService auth.UserTokenService log log.Logger } @@ -55,10 +47,8 @@ func (s *Session) Authenticate(ctx context.Context, r *authn.Request) (*authn.Id return nil, err } - if s.features.IsEnabled(ctx, featuremgmt.FlagClientTokenRotation) { - if token.NeedsRotation(time.Duration(s.cfg.TokenRotationIntervalMinutes) * time.Minute) { - return nil, authn.ErrTokenNeedsRotation.Errorf("token needs to be rotated") - } + if token.NeedsRotation(time.Duration(s.cfg.TokenRotationIntervalMinutes) * time.Minute) { + return nil, authn.ErrTokenNeedsRotation.Errorf("token needs to be rotated") } return &authn.Identity{ @@ -86,40 +76,3 @@ func (s *Session) Test(ctx context.Context, r *authn.Request) bool { func (s *Session) Priority() uint { return 60 } - -func (s *Session) Hook(ctx context.Context, identity *authn.Identity, r *authn.Request) error { - if identity.SessionToken == nil || s.features.IsEnabled(ctx, featuremgmt.FlagClientTokenRotation) { - return nil - } - - r.Resp.Before(func(w web.ResponseWriter) { - if w.Written() || errors.Is(ctx.Err(), context.Canceled) { - return - } - - // FIXME (jguer): get real values - addr := web.RemoteAddr(r.HTTPRequest) - userAgent := r.HTTPRequest.UserAgent() - - // addr := reqContext.RemoteAddr() - ip, err := network.GetIPFromAddress(addr) - if err != nil { - s.log.Debug("Failed to get client IP address", "addr", addr, "err", err) - ip = nil - } - rotated, newToken, err := s.sessionService.TryRotateToken(ctx, identity.SessionToken, ip, userAgent) - if err != nil { - s.log.Error("Failed to rotate token", "error", err) - return - } - - if rotated { - identity.SessionToken = newToken - s.log.Debug("Rotated session token", "user", identity.ID) - - authn.WriteSessionCookie(w, s.cfg, identity.SessionToken) - } - }) - - return nil -} diff --git a/pkg/services/authn/clients/session_test.go b/pkg/services/authn/clients/session_test.go index 8dd7555cd269b..41fc4e5f7aea0 100644 --- a/pkg/services/authn/clients/session_test.go +++ b/pkg/services/authn/clients/session_test.go @@ -2,7 +2,6 @@ package clients import ( "context" - "net" "net/http" "testing" "time" @@ -14,9 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/authn" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/web" ) func TestSession_Test(t *testing.T) { @@ -29,7 +26,7 @@ func TestSession_Test(t *testing.T) { cfg := setting.NewCfg() cfg.LoginCookieName = "" cfg.LoginMaxLifetime = 20 * time.Second - s := ProvideSession(cfg, &authtest.FakeUserAuthTokenService{}, featuremgmt.WithFeatures()) + s := ProvideSession(cfg, &authtest.FakeUserAuthTokenService{}) disabled := s.Test(context.Background(), &authn.Request{HTTPRequest: validHTTPReq}) assert.False(t, disabled) @@ -64,7 +61,6 @@ func TestSession_Authenticate(t *testing.T) { type fields struct { sessionService auth.UserTokenService - features *featuremgmt.FeatureManager } type args struct { r *authn.Request @@ -80,7 +76,6 @@ func TestSession_Authenticate(t *testing.T) { name: "cookie not found", fields: fields{ sessionService: &authtest.FakeUserAuthTokenService{}, - features: featuremgmt.WithFeatures(), }, args: args{r: &authn.Request{HTTPRequest: &http.Request{}}}, wantID: nil, @@ -92,7 +87,6 @@ func TestSession_Authenticate(t *testing.T) { sessionService: &authtest.FakeUserAuthTokenService{LookupTokenProvider: func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { return validToken, nil }}, - features: featuremgmt.WithFeatures(), }, args: args{r: &authn.Request{HTTPRequest: validHTTPReq}}, wantID: &authn.Identity{ @@ -106,7 +100,7 @@ func TestSession_Authenticate(t *testing.T) { wantErr: false, }, { - name: "should return error for token that needs rotation if ClientTokenRotation is enabled", + name: "should return error for token that needs rotation", fields: fields{ sessionService: &authtest.FakeUserAuthTokenService{LookupTokenProvider: func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { return &auth.UserToken{ @@ -114,18 +108,16 @@ func TestSession_Authenticate(t *testing.T) { RotatedAt: time.Now().Add(-11 * time.Minute).Unix(), }, nil }}, - features: featuremgmt.WithFeatures(featuremgmt.FlagClientTokenRotation), }, args: args{r: &authn.Request{HTTPRequest: validHTTPReq}}, wantErr: true, }, { - name: "should return identity for token that don't need rotation if ClientTokenRotation is enabled", + name: "should return identity for token that don't need rotation", fields: fields{ sessionService: &authtest.FakeUserAuthTokenService{LookupTokenProvider: func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { return validToken, nil }}, - features: featuremgmt.WithFeatures(featuremgmt.FlagClientTokenRotation), }, args: args{r: &authn.Request{HTTPRequest: validHTTPReq}}, wantID: &authn.Identity{ @@ -145,7 +137,7 @@ func TestSession_Authenticate(t *testing.T) { cfg.LoginCookieName = cookieName cfg.TokenRotationIntervalMinutes = 10 cfg.LoginMaxLifetime = 20 * time.Second - s := ProvideSession(cfg, tt.fields.sessionService, tt.fields.features) + s := ProvideSession(cfg, tt.fields.sessionService) got, err := s.Authenticate(context.Background(), tt.args.r) require.True(t, (err != nil) == tt.wantErr, err) @@ -157,73 +149,3 @@ func TestSession_Authenticate(t *testing.T) { }) } } - -type fakeResponseWriter struct { - Status int - HeaderStore http.Header -} - -func (f *fakeResponseWriter) Header() http.Header { - return f.HeaderStore -} - -func (f *fakeResponseWriter) Write([]byte) (int, error) { - return 0, nil -} - -func (f *fakeResponseWriter) WriteHeader(statusCode int) { - f.Status = statusCode -} - -func TestSession_Hook(t *testing.T) { - t.Run("should rotate token", func(t *testing.T) { - cfg := setting.NewCfg() - cfg.LoginCookieName = "grafana-session" - cfg.LoginMaxLifetime = 20 * time.Second - s := ProvideSession(cfg, &authtest.FakeUserAuthTokenService{ - TryRotateTokenProvider: func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, *auth.UserToken, error) { - token.UnhashedToken = "new-token" - return true, token, nil - }, - }, featuremgmt.WithFeatures()) - - sampleID := &authn.Identity{ - SessionToken: &auth.UserToken{ - Id: 1, - UserId: 1, - }, - } - - mockResponseWriter := &fakeResponseWriter{ - Status: 0, - HeaderStore: map[string][]string{}, - } - - resp := &authn.Request{ - HTTPRequest: &http.Request{ - Header: map[string][]string{}, - }, - Resp: web.NewResponseWriter(http.MethodConnect, mockResponseWriter), - } - - err := s.Hook(context.Background(), sampleID, resp) - require.NoError(t, err) - - resp.Resp.WriteHeader(201) - require.Equal(t, 201, mockResponseWriter.Status) - - assert.Equal(t, "new-token", sampleID.SessionToken.UnhashedToken) - require.Len(t, mockResponseWriter.HeaderStore, 1) - assert.Equal(t, "grafana-session=new-token; Path=/; Max-Age=20; HttpOnly", - mockResponseWriter.HeaderStore.Get("set-cookie"), mockResponseWriter.HeaderStore) - }) - - t.Run("should not rotate token with feature flag", func(t *testing.T) { - s := ProvideSession(setting.NewCfg(), nil, featuremgmt.WithFeatures(featuremgmt.FlagClientTokenRotation)) - - req := &authn.Request{} - identity := &authn.Identity{} - err := s.Hook(context.Background(), identity, req) - require.NoError(t, err) - }) -} diff --git a/pkg/services/authn/identity.go b/pkg/services/authn/identity.go index dc8f8cce04cbd..5cd067fc65fc1 100644 --- a/pkg/services/authn/identity.go +++ b/pkg/services/authn/identity.go @@ -31,6 +31,7 @@ const ( const ( AnonymousNamespaceID = NamespaceAnonymous + ":0" + GlobalOrgID = int64(0) ) var _ identity.Requester = (*Identity)(nil) @@ -86,6 +87,18 @@ type Identity struct { IDToken string } +func (i *Identity) GetID() string { + return i.ID +} + +func (i *Identity) GetNamespacedID() (namespace string, identifier string) { + split := strings.Split(i.GetID(), ":") + if len(split) != 2 { + return "", "" + } + return split[0], split[1] +} + func (i *Identity) GetAuthenticatedBy() string { return i.AuthenticatedBy } @@ -121,16 +134,6 @@ func (i *Identity) GetLogin() string { return i.Login } -func (i *Identity) GetNamespacedID() (namespace string, identifier string) { - split := strings.Split(i.ID, ":") - - if len(split) != 2 { - return "", "" - } - - return split[0], split[1] -} - // GetOrgID implements identity.Requester. func (i *Identity) GetOrgID() int64 { return i.OrgID @@ -164,6 +167,19 @@ func (i *Identity) GetPermissions() map[string][]string { return i.Permissions[i.GetOrgID()] } +// GetGlobalPermissions returns the permissions of the active entity that are available across all organizations +func (i *Identity) GetGlobalPermissions() map[string][]string { + if i.Permissions == nil { + return make(map[string][]string) + } + + if i.Permissions[GlobalOrgID] == nil { + return make(map[string][]string) + } + + return i.Permissions[GlobalOrgID] +} + func (i *Identity) GetTeams() []int64 { return i.Teams } diff --git a/pkg/services/cleanup/cleanup.go b/pkg/services/cleanup/cleanup.go index a96d5f5a702f3..3b1a2c5e201dd 100644 --- a/pkg/services/cleanup/cleanup.go +++ b/pkg/services/cleanup/cleanup.go @@ -102,6 +102,7 @@ func (srv *CleanUpService) clean(ctx context.Context) { {"expire old user invites", srv.expireOldUserInvites}, {"delete stale short URLs", srv.deleteStaleShortURLs}, {"delete stale query history", srv.deleteStaleQueryHistory}, + {"expire old email verifications", srv.expireOldVerifications}, } logger := srv.log.FromContext(ctx) @@ -134,6 +135,7 @@ func (srv *CleanUpService) cleanUpTmpFiles(ctx context.Context) { folders := []string{ srv.Cfg.ImagesDir, srv.Cfg.CSVsDir, + srv.Cfg.PDFsDir, } for _, f := range folders { @@ -237,6 +239,21 @@ func (srv *CleanUpService) expireOldUserInvites(ctx context.Context) { } } +func (srv *CleanUpService) expireOldVerifications(ctx context.Context) { + logger := srv.log.FromContext(ctx) + maxVerificationLifetime := srv.Cfg.VerificationEmailMaxLifetime + + cmd := tempuser.ExpireTempUsersCommand{ + OlderThan: time.Now().Add(-maxVerificationLifetime), + } + + if err := srv.tempUserService.ExpireOldVerifications(ctx, &cmd); err != nil { + logger.Error("Problem expiring email verifications", "error", err.Error()) + } else { + logger.Debug("Expired email verifications", "rows affected", cmd.NumExpired) + } +} + func (srv *CleanUpService) deleteStaleShortURLs(ctx context.Context) { logger := srv.log.FromContext(ctx) cmd := shorturls.DeleteShortUrlCommand{ diff --git a/pkg/services/cloudmigration/api/api.go b/pkg/services/cloudmigration/api/api.go new file mode 100644 index 0000000000000..fb408f09cadec --- /dev/null +++ b/pkg/services/cloudmigration/api/api.go @@ -0,0 +1,56 @@ +package api + +import ( + "net/http" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/services/cloudmigration" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/web" +) + +type MigrationAPI struct { + cloudMigrationsService cloudmigration.Service + routeRegister routing.RouteRegister + log log.Logger +} + +func RegisterApi( + rr routing.RouteRegister, + cms cloudmigration.Service, +) *MigrationAPI { + api := &MigrationAPI{ + log: log.New("cloudmigrations.api"), + routeRegister: rr, + cloudMigrationsService: cms, + } + api.registerEndpoints() + return api +} + +// RegisterAPIEndpoints Registers Endpoints on Grafana Router +func (api *MigrationAPI) registerEndpoints() { + api.routeRegister.Group("/api/cloudmigrations", func(apiRoute routing.RouteRegister) { + apiRoute.Post( + "/migrate_datasources", + routing.Wrap(api.MigrateDatasources), + ) + }, middleware.ReqGrafanaAdmin) +} + +func (api *MigrationAPI) MigrateDatasources(c *contextmodel.ReqContext) response.Response { + var req cloudmigration.MigrateDatasourcesRequestDTO + if err := web.Bind(c.Req, &req); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + + resp, err := api.cloudMigrationsService.MigrateDatasources(c.Req.Context(), &cloudmigration.MigrateDatasourcesRequest{MigrateToPDC: req.MigrateToPDC, MigrateCredentials: req.MigrateCredentials}) + if err != nil { + return response.Error(http.StatusInternalServerError, "data source migrations error", err) + } + + return response.JSON(http.StatusOK, cloudmigration.MigrateDatasourcesResponseDTO{DatasourcesMigrated: resp.DatasourcesMigrated}) +} diff --git a/pkg/services/cloudmigration/api/api_test.go b/pkg/services/cloudmigration/api/api_test.go new file mode 100644 index 0000000000000..3a5fe5a122738 --- /dev/null +++ b/pkg/services/cloudmigration/api/api_test.go @@ -0,0 +1,12 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_OnlyEnabledForGrafanaAdmi(t *testing.T) { + // TODO: implement + assert.True(t, true) +} diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go new file mode 100644 index 0000000000000..c1e58e8f397f2 --- /dev/null +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go @@ -0,0 +1,67 @@ +package cloudmigrationimpl + +import ( + "context" + + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/cloudmigration" + "github.com/grafana/grafana/pkg/services/cloudmigration/api" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" + "github.com/prometheus/client_golang/prometheus" +) + +// CloudMigrationsServiceImpl Define the Service Implementation. +type Service struct { + store store + + log log.Logger + cfg *setting.Cfg + + features featuremgmt.FeatureToggles + dsService datasources.DataSourceService + + api *api.MigrationAPI + // metrics *Metrics +} + +var LogPrefix = "cloudmigration.service" + +var _ cloudmigration.Service = (*Service)(nil) + +// ProvideService Factory for method used by wire to inject dependencies. +// builds the service, and api, and configures routes +func ProvideService( + cfg *setting.Cfg, + features featuremgmt.FeatureToggles, + db db.DB, + dsService datasources.DataSourceService, + routeRegister routing.RouteRegister, + prom prometheus.Registerer, +) cloudmigration.Service { + if !features.IsEnabledGlobally(featuremgmt.FlagOnPremToCloudMigrations) { + return &NoopServiceImpl{} + } + + s := &Service{ + store: &sqlStore{db: db}, + log: log.New(LogPrefix), + cfg: cfg, + features: features, + dsService: dsService, + } + s.api = api.RegisterApi(routeRegister, s) + + if err := s.registerMetrics(prom); err != nil { + s.log.Warn("error registering prom metrics", "error", err.Error()) + } + + return s +} + +func (s *Service) MigrateDatasources(ctx context.Context, request *cloudmigration.MigrateDatasourcesRequest) (*cloudmigration.MigrateDatasourcesResponse, error) { + return s.store.MigrateDatasources(ctx, request) +} diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go new file mode 100644 index 0000000000000..3f22422b7c785 --- /dev/null +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go @@ -0,0 +1,16 @@ +package cloudmigrationimpl + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/cloudmigration" +) + +// CloudMigrationsServiceImpl Define the Service Implementation. +type NoopServiceImpl struct{} + +var _ cloudmigration.Service = (*NoopServiceImpl)(nil) + +func (s *NoopServiceImpl) MigrateDatasources(ctx context.Context, request *cloudmigration.MigrateDatasourcesRequest) (*cloudmigration.MigrateDatasourcesResponse, error) { + return nil, cloudmigration.ErrFeatureDisabledError +} diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go new file mode 100644 index 0000000000000..008a9b66add51 --- /dev/null +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go @@ -0,0 +1,15 @@ +package cloudmigrationimpl + +import ( + "context" + "testing" + + "github.com/grafana/grafana/pkg/services/cloudmigration" + "github.com/stretchr/testify/assert" +) + +func Test_NoopServiceDoesNothing(t *testing.T) { + s := &NoopServiceImpl{} + _, e := s.MigrateDatasources(context.Background(), &cloudmigration.MigrateDatasourcesRequest{}) + assert.ErrorIs(t, e, cloudmigration.ErrFeatureDisabledError) +} diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/metric.go b/pkg/services/cloudmigration/cloudmigrationimpl/metric.go new file mode 100644 index 0000000000000..0c33a7adfb984 --- /dev/null +++ b/pkg/services/cloudmigration/cloudmigrationimpl/metric.go @@ -0,0 +1,26 @@ +package cloudmigrationimpl + +import ( + "errors" + + "github.com/grafana/grafana/pkg/services/cloudmigration" + "github.com/prometheus/client_golang/prometheus" +) + +// type Metrics struct { +// log log.Logger +// } + +func (s *Service) registerMetrics(prom prometheus.Registerer) error { + for _, m := range cloudmigration.PromMetrics { + if err := prom.Register(m); err != nil { + var alreadyRegisterErr prometheus.AlreadyRegisteredError + if errors.As(err, &alreadyRegisterErr) { + s.log.Warn("metric already registered", "metric", m) + continue + } + return err + } + } + return nil +} diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/store.go b/pkg/services/cloudmigration/cloudmigrationimpl/store.go new file mode 100644 index 0000000000000..161b575b75acf --- /dev/null +++ b/pkg/services/cloudmigration/cloudmigrationimpl/store.go @@ -0,0 +1,11 @@ +package cloudmigrationimpl + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/cloudmigration" +) + +type store interface { + MigrateDatasources(context.Context, *cloudmigration.MigrateDatasourcesRequest) (*cloudmigration.MigrateDatasourcesResponse, error) +} diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store.go b/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store.go new file mode 100644 index 0000000000000..7407a9192699b --- /dev/null +++ b/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store.go @@ -0,0 +1,16 @@ +package cloudmigrationimpl + +import ( + "context" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/cloudmigration" +) + +type sqlStore struct { + db db.DB +} + +func (ss *sqlStore) MigrateDatasources(ctx context.Context, request *cloudmigration.MigrateDatasourcesRequest) (*cloudmigration.MigrateDatasourcesResponse, error) { + return nil, cloudmigration.ErrInternalNotImplementedError +} diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store_test.go b/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store_test.go new file mode 100644 index 0000000000000..a2915e782fd5b --- /dev/null +++ b/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store_test.go @@ -0,0 +1,9 @@ +package cloudmigrationimpl + +import ( + "testing" +) + +func TestMigrateDatasources(t *testing.T) { + // TODO: Write this test +} diff --git a/pkg/services/cloudmigration/cloudmigrations.go b/pkg/services/cloudmigration/cloudmigrations.go new file mode 100644 index 0000000000000..34bb99c11d3a6 --- /dev/null +++ b/pkg/services/cloudmigration/cloudmigrations.go @@ -0,0 +1,9 @@ +package cloudmigration + +import ( + "context" +) + +type Service interface { + MigrateDatasources(context.Context, *MigrateDatasourcesRequest) (*MigrateDatasourcesResponse, error) +} diff --git a/pkg/services/cloudmigration/cloudmigrationtest/fake.go b/pkg/services/cloudmigration/cloudmigrationtest/fake.go new file mode 100644 index 0000000000000..7f410d3a27dc0 --- /dev/null +++ b/pkg/services/cloudmigration/cloudmigrationtest/fake.go @@ -0,0 +1,15 @@ +package cloudmigrationtest + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/cloudmigration" +) + +type Service struct { + ExpectedError error +} + +func (s *Service) MigrateDatasources(ctx context.Context, request *cloudmigration.MigrateDatasourcesRequest) (*cloudmigration.MigrateDatasourcesResponse, error) { + return nil, cloudmigration.ErrInternalNotImplementedError +} diff --git a/pkg/services/cloudmigration/model.go b/pkg/services/cloudmigration/model.go new file mode 100644 index 0000000000000..e118abbd5a3b2 --- /dev/null +++ b/pkg/services/cloudmigration/model.go @@ -0,0 +1,43 @@ +package cloudmigration + +import ( + "github.com/grafana/grafana/pkg/util/errutil" + "github.com/prometheus/client_golang/prometheus" +) + +var ( + ErrInternalNotImplementedError = errutil.Internal("cloudmigrations.notImplemented", errutil.WithPublicMessage("Internal server error")) + ErrFeatureDisabledError = errutil.Internal("cloudmigrations.disabled", errutil.WithPublicMessage("Cloud migrations are disabled on this instance")) +) + +type MigrateDatasourcesRequest struct { + MigrateToPDC bool + MigrateCredentials bool +} + +type MigrateDatasourcesResponse struct { + DatasourcesMigrated int +} + +type MigrateDatasourcesRequestDTO struct { + MigrateToPDC bool `json:"migrateToPDC"` + MigrateCredentials bool `json:"migrateCredentials"` +} + +type MigrateDatasourcesResponseDTO struct { + DatasourcesMigrated int `json:"datasourcesMigrated"` +} + +const ( + namespace = "grafana" + subsystem = "cloudmigrations" +) + +var PromMetrics = []prometheus.Collector{ + prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "datasources_migrated", + Help: "Total amount of data sources migrated", + }, []string{"pdc_converted"}), +} diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index 49b91f45f4f9d..02ff630fc6664 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -3,7 +3,6 @@ package contexthandler import ( "context" - "errors" "fmt" "net/http" @@ -13,7 +12,6 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" @@ -25,7 +23,7 @@ import ( "github.com/grafana/grafana/pkg/web" ) -func ProvideService(cfg *setting.Cfg, tracer tracing.Tracer, features *featuremgmt.FeatureManager, authnService authn.Service, +func ProvideService(cfg *setting.Cfg, tracer tracing.Tracer, features featuremgmt.FeatureToggles, authnService authn.Service, ) *ContextHandler { return &ContextHandler{ Cfg: cfg, @@ -39,7 +37,7 @@ func ProvideService(cfg *setting.Cfg, tracer tracing.Tracer, features *featuremg type ContextHandler struct { Cfg *setting.Cfg tracer tracing.Tracer - features *featuremgmt.FeatureManager + features featuremgmt.FeatureToggles authnService authn.Service } @@ -115,11 +113,6 @@ func (h *ContextHandler) Middleware(next http.Handler) http.Handler { identity, err := h.authnService.Authenticate(reqContext.Req.Context(), &authn.Request{HTTPRequest: reqContext.Req, Resp: reqContext.Resp}) if err != nil { - if errors.Is(err, auth.ErrInvalidSessionToken) || errors.Is(err, authn.ErrExpiredAccessToken) { - // Burn the cookie in case of invalid, expired or missing token - reqContext.Resp.Before(h.deleteInvalidCookieEndOfRequestFunc(reqContext)) - } - // Hack: set all errors on LookupTokenErr, so we can check it in auth middlewares reqContext.LookupTokenErr = err } else { @@ -127,7 +120,7 @@ func (h *ContextHandler) Middleware(next http.Handler) http.Handler { reqContext.UserToken = identity.SessionToken reqContext.IsSignedIn = !reqContext.SignedInUser.IsAnonymous reqContext.AllowAnonymous = reqContext.SignedInUser.IsAnonymous - reqContext.IsRenderCall = identity.AuthenticatedBy == login.RenderModule + reqContext.IsRenderCall = identity.GetAuthenticatedBy() == login.RenderModule } reqContext.Logger = reqContext.Logger.New("userId", reqContext.UserID, "orgId", reqContext.OrgID, "uname", reqContext.Login) @@ -170,22 +163,6 @@ func (h *ContextHandler) addIDHeaderEndOfRequestFunc(ident identity.Requester) w } } -func (h *ContextHandler) deleteInvalidCookieEndOfRequestFunc(reqContext *contextmodel.ReqContext) web.BeforeFunc { - return func(w web.ResponseWriter) { - if h.features.IsEnabled(reqContext.Req.Context(), featuremgmt.FlagClientTokenRotation) { - return - } - - if w.Written() { - reqContext.Logger.Debug("Response written, skipping invalid cookie delete") - return - } - - reqContext.Logger.Debug("Expiring invalid cookie") - authn.DeleteSessionCookie(reqContext.Resp, h.Cfg) - } -} - type authHTTPHeaderListContextKey struct{} var authHTTPHeaderListKey = authHTTPHeaderListContextKey{} @@ -209,15 +186,18 @@ func WithAuthHTTPHeaders(ctx context.Context, cfg *setting.Cfg) context.Context // used by basic auth, api keys and potentially jwt auth list.Items = append(list.Items, "Authorization") + // remove X-Grafana-Device-Id as it is only used for auth in authn clients. + list.Items = append(list.Items, "X-Grafana-Device-Id") + // if jwt is enabled we add it to the list. We can ignore in case it is set to Authorization - if cfg.JWTAuthEnabled && cfg.JWTAuthHeaderName != "" && cfg.JWTAuthHeaderName != "Authorization" { - list.Items = append(list.Items, cfg.JWTAuthHeaderName) + if cfg.JWTAuth.Enabled && cfg.JWTAuth.HeaderName != "" && cfg.JWTAuth.HeaderName != "Authorization" { + list.Items = append(list.Items, cfg.JWTAuth.HeaderName) } // if auth proxy is enabled add the main proxy header and all configured headers - if cfg.AuthProxyEnabled { - list.Items = append(list.Items, cfg.AuthProxyHeaderName) - for _, header := range cfg.AuthProxyHeaders { + if cfg.AuthProxy.Enabled { + list.Items = append(list.Items, cfg.AuthProxy.HeaderName) + for _, header := range cfg.AuthProxy.Headers { if header != "" { list.Items = append(list.Items, header) } diff --git a/pkg/services/contexthandler/contexthandler_test.go b/pkg/services/contexthandler/contexthandler_test.go index 47f5adc4816a7..f7c46f7208c47 100644 --- a/pkg/services/contexthandler/contexthandler_test.go +++ b/pkg/services/contexthandler/contexthandler_test.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn/authntest" "github.com/grafana/grafana/pkg/services/contexthandler" @@ -111,53 +110,13 @@ func TestContextHandler(t *testing.T) { require.NoError(t, res.Body.Close()) }) - t.Run("should delete session cookie on invalid session", func(t *testing.T) { - handler := contexthandler.ProvideService( - setting.NewCfg(), - tracing.InitializeTracerForTest(), - featuremgmt.WithFeatures(), - &authntest.FakeService{ExpectedErr: auth.ErrInvalidSessionToken}, - ) - - server := webtest.NewServer(t, routing.NewRouteRegister()) - server.Mux.Use(handler.Middleware) - server.Mux.Get("/api/handler", func(c *contextmodel.ReqContext) {}) - - res, err := server.Send(server.NewGetRequest("/api/handler")) - require.NoError(t, err) - cookies := res.Cookies() - require.Len(t, cookies, 1) - require.Equal(t, cookies[0].String(), "grafana_session_expiry=; Path=/; Max-Age=0") - require.NoError(t, res.Body.Close()) - }) - - t.Run("should delete session cookie when oauth token refresh failed", func(t *testing.T) { - handler := contexthandler.ProvideService( - setting.NewCfg(), - tracing.InitializeTracerForTest(), - featuremgmt.WithFeatures(), - &authntest.FakeService{ExpectedErr: authn.ErrExpiredAccessToken.Errorf("")}, - ) - - server := webtest.NewServer(t, routing.NewRouteRegister()) - server.Mux.Use(handler.Middleware) - server.Mux.Get("/api/handler", func(c *contextmodel.ReqContext) {}) - - res, err := server.Send(server.NewGetRequest("/api/handler")) - require.NoError(t, err) - cookies := res.Cookies() - require.Len(t, cookies, 1) - require.Equal(t, cookies[0].String(), "grafana_session_expiry=; Path=/; Max-Age=0") - require.NoError(t, res.Body.Close()) - }) - t.Run("should store auth header in context", func(t *testing.T) { cfg := setting.NewCfg() - cfg.JWTAuthEnabled = true - cfg.JWTAuthHeaderName = "jwt-header" - cfg.AuthProxyEnabled = true - cfg.AuthProxyHeaderName = "proxy-header" - cfg.AuthProxyHeaders = map[string]string{ + cfg.JWTAuth.Enabled = true + cfg.JWTAuth.HeaderName = "jwt-header" + cfg.AuthProxy.Enabled = true + cfg.AuthProxy.HeaderName = "proxy-header" + cfg.AuthProxy.Headers = map[string]string{ "name": "proxy-header-name", } diff --git a/pkg/services/contexthandler/model/model.go b/pkg/services/contexthandler/model/model.go index 3f8248f836e1d..e7b9ebc8c78e2 100644 --- a/pkg/services/contexthandler/model/model.go +++ b/pkg/services/contexthandler/model/model.go @@ -43,7 +43,7 @@ func (ctx *ReqContext) Handle(cfg *setting.Cfg, status int, title string, err er Title string AppTitle string AppSubUrl string - Theme string + ThemeType string ErrorMsg error }{title, "Grafana", cfg.AppSubURL, "dark", nil} diff --git a/pkg/services/dashboardimport/api/api.go b/pkg/services/dashboardimport/api/api.go index 819aa30ab030e..8134b190de96e 100644 --- a/pkg/services/dashboardimport/api/api.go +++ b/pkg/services/dashboardimport/api/api.go @@ -1,6 +1,7 @@ package api import ( + "errors" "net/http" "github.com/grafana/grafana/pkg/api/apierrors" @@ -10,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboardimport" + "github.com/grafana/grafana/pkg/services/dashboardimport/utils" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/quota" @@ -71,12 +73,15 @@ func (api *ImportDashboardAPI) ImportDashboard(c *contextmodel.ReqContext) respo } if limitReached { - return response.Error(403, "Quota reached", nil) + return response.Error(http.StatusForbidden, "Quota reached", nil) } req.User = c.SignedInUser resp, err := api.dashboardImportService.ImportDashboard(c.Req.Context(), &req) if err != nil { + if errors.Is(err, utils.ErrDashboardInputMissing) { + return response.Error(http.StatusBadRequest, err.Error(), err) + } return apierrors.ToDashboardErrorResponse(c.Req.Context(), api.pluginStore, err) } diff --git a/pkg/services/dashboardimport/service/service.go b/pkg/services/dashboardimport/service/service.go index c1eb1b4482d17..842651e275984 100644 --- a/pkg/services/dashboardimport/service/service.go +++ b/pkg/services/dashboardimport/service/service.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/dashboardimport" @@ -83,6 +84,7 @@ func (s *ImportDashboardService) ImportDashboard(ctx context.Context, req *dashb generatedDash.Del("__inputs") generatedDash.Del("__requires") + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.DashboardImport).Inc() // here we need to get FolderId from FolderUID if it present in the request, if both exist, FolderUID would overwrite FolderID if req.FolderUid != "" { folder, err := s.folderService.Get(ctx, &folder.GetFolderQuery{ @@ -137,6 +139,7 @@ func (s *ImportDashboardService) ImportDashboard(ctx context.Context, req *dashb return nil, err } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.DashboardImport).Inc() // nolint:staticcheck err = s.libraryPanelService.ImportLibraryPanelsForDashboard(ctx, req.User, libraryElements, generatedDash.Get("panels").MustArray(), req.FolderId) if err != nil { @@ -149,6 +152,7 @@ func (s *ImportDashboardService) ImportDashboard(ctx context.Context, req *dashb } revision := savedDashboard.Data.Get("revision").MustInt64(0) + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.DashboardImport).Inc() return &dashboardimport.ImportDashboardResponse{ UID: savedDashboard.UID, PluginId: req.PluginId, diff --git a/pkg/services/dashboardimport/service/service_test.go b/pkg/services/dashboardimport/service/service_test.go index c9b0ae8a11359..19e0dba89372b 100644 --- a/pkg/services/dashboardimport/service/service_test.go +++ b/pkg/services/dashboardimport/service/service_test.go @@ -31,15 +31,15 @@ func TestImportDashboardService(t *testing.T) { importDashboardFunc: func(ctx context.Context, dto *dashboards.SaveDashboardDTO) (*dashboards.Dashboard, error) { importDashboardArg = dto return &dashboards.Dashboard{ - ID: 4, - UID: dto.Dashboard.UID, - Slug: dto.Dashboard.Slug, - OrgID: 3, - Version: dto.Dashboard.Version, - PluginID: "prometheus", - FolderID: dto.Dashboard.FolderID, // nolint:staticcheck - Title: dto.Dashboard.Title, - Data: dto.Dashboard.Data, + ID: 4, + UID: dto.Dashboard.UID, + Slug: dto.Dashboard.Slug, + OrgID: 3, + Version: dto.Dashboard.Version, + PluginID: "prometheus", + FolderUID: dto.Dashboard.FolderUID, + Title: dto.Dashboard.Title, + Data: dto.Dashboard.Data, }, nil }, } @@ -58,7 +58,6 @@ func TestImportDashboardService(t *testing.T) { } folderService := &foldertest.FakeService{ ExpectedFolder: &folder.Folder{ - ID: 5, // nolint:staticcheck UID: "123", }, } @@ -76,8 +75,9 @@ func TestImportDashboardService(t *testing.T) { Inputs: []dashboardimport.ImportDashboardInput{ {Name: "*", Type: "datasource", Value: "prom"}, }, - User: &user.SignedInUser{UserID: 2, OrgRole: org.RoleAdmin, OrgID: 3}, - FolderId: 5, // nolint:staticcheck + User: &user.SignedInUser{UserID: 2, OrgRole: org.RoleAdmin, OrgID: 3}, + // FolderId: 5, + FolderUid: "123", } resp, err := s.ImportDashboard(context.Background(), req) require.NoError(t, err) @@ -91,8 +91,7 @@ func TestImportDashboardService(t *testing.T) { require.Equal(t, int64(3), importDashboardArg.OrgID) require.Equal(t, int64(2), userID) require.Equal(t, "prometheus", importDashboardArg.Dashboard.PluginID) - // nolint:staticcheck - require.Equal(t, int64(5), importDashboardArg.Dashboard.FolderID) + require.Equal(t, "123", importDashboardArg.Dashboard.FolderUID) panel := importDashboardArg.Dashboard.Data.Get("panels").GetIndex(0) require.Equal(t, "prom", panel.Get("datasource").MustString()) @@ -107,22 +106,21 @@ func TestImportDashboardService(t *testing.T) { importDashboardFunc: func(ctx context.Context, dto *dashboards.SaveDashboardDTO) (*dashboards.Dashboard, error) { importDashboardArg = dto return &dashboards.Dashboard{ - ID: 4, - UID: dto.Dashboard.UID, - Slug: dto.Dashboard.Slug, - OrgID: 3, - Version: dto.Dashboard.Version, - PluginID: "prometheus", - FolderID: dto.Dashboard.FolderID, // nolint:staticcheck - Title: dto.Dashboard.Title, - Data: dto.Dashboard.Data, + ID: 4, + UID: dto.Dashboard.UID, + Slug: dto.Dashboard.Slug, + OrgID: 3, + Version: dto.Dashboard.Version, + PluginID: "prometheus", + FolderUID: dto.Dashboard.FolderUID, + Title: dto.Dashboard.Title, + Data: dto.Dashboard.Data, }, nil }, } libraryPanelService := &libraryPanelServiceMock{} folderService := &foldertest.FakeService{ ExpectedFolder: &folder.Folder{ - ID: 5, // nolint:staticcheck UID: "123", }, } @@ -144,8 +142,8 @@ func TestImportDashboardService(t *testing.T) { Inputs: []dashboardimport.ImportDashboardInput{ {Name: "*", Type: "datasource", Value: "prom"}, }, - User: &user.SignedInUser{UserID: 2, OrgRole: org.RoleAdmin, OrgID: 3}, - FolderId: 5, // nolint:staticcheck + User: &user.SignedInUser{UserID: 2, OrgRole: org.RoleAdmin, OrgID: 3}, + FolderUid: "123", } resp, err := s.ImportDashboard(context.Background(), req) require.NoError(t, err) @@ -159,8 +157,7 @@ func TestImportDashboardService(t *testing.T) { require.Equal(t, int64(3), importDashboardArg.OrgID) require.Equal(t, int64(2), userID) require.Equal(t, "", importDashboardArg.Dashboard.PluginID) - // nolint:staticcheck - require.Equal(t, int64(5), importDashboardArg.Dashboard.FolderID) + require.Equal(t, "123", importDashboardArg.Dashboard.FolderUID) panel := importDashboardArg.Dashboard.Data.Get("panels").GetIndex(0) require.Equal(t, "prom", panel.Get("datasource").MustString()) diff --git a/pkg/services/dashboardimport/utils/dash_template_evaluator.go b/pkg/services/dashboardimport/utils/dash_template_evaluator.go index c21b1ce62f008..29c8f76ad6523 100644 --- a/pkg/services/dashboardimport/utils/dash_template_evaluator.go +++ b/pkg/services/dashboardimport/utils/dash_template_evaluator.go @@ -2,6 +2,7 @@ package utils import ( "encoding/json" + "errors" "fmt" "regexp" @@ -11,14 +12,7 @@ import ( ) var varRegex = regexp.MustCompile(`(\$\{.+?\})`) - -type DashboardInputMissingError struct { - VariableName string -} - -func (e DashboardInputMissingError) Error() string { - return fmt.Sprintf("Dashboard input variable: %v missing from import command", e.VariableName) -} +var ErrDashboardInputMissing = errors.New("missing dashboard input variable") type DashTemplateEvaluator struct { template *simplejson.Json @@ -63,7 +57,7 @@ func (e *DashTemplateEvaluator) Eval() (*simplejson.Json, error) { } if input == nil { - return nil, &DashboardInputMissingError{VariableName: inputName} + return nil, fmt.Errorf("dashboard import failed: %w %s", ErrDashboardInputMissing, inputName) } e.variables["${"+inputName+"}"] = input.Value diff --git a/pkg/services/dashboards/accesscontrol.go b/pkg/services/dashboards/accesscontrol.go index 61c19267e53a5..e8ec7661b35a8 100644 --- a/pkg/services/dashboards/accesscontrol.go +++ b/pkg/services/dashboards/accesscontrol.go @@ -2,8 +2,10 @@ package dashboards import ( "context" + "errors" "strings" + "github.com/grafana/grafana/pkg/infra/metrics" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/folder" ) @@ -49,7 +51,9 @@ func NewFolderNameScopeResolver(folderDB folder.FolderStore, folderSvc folder.Se if len(nsName) == 0 { return nil, ac.ErrInvalidScope } - folder, err := folderDB.GetFolderByTitle(ctx, orgID, nsName) + // this will fetch only root folders + // this is legacy code so most probably it is not used + folder, err := folderDB.GetFolderByTitle(ctx, orgID, nsName, nil) if err != nil { return nil, err } @@ -166,11 +170,13 @@ func NewDashboardUIDScopeResolver(folderDB folder.FolderStore, ds DashboardServi func resolveDashboardScope(ctx context.Context, folderDB folder.FolderStore, orgID int64, dashboard *Dashboard, folderSvc folder.Service) ([]string, error) { var folderUID string + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck if dashboard.FolderID < 0 { return []string{ScopeDashboardsProvider.GetResourceScopeUID(dashboard.UID)}, nil } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck if dashboard.FolderID == 0 { folderUID = ac.GeneralFolderUID @@ -207,6 +213,9 @@ func GetInheritedScopes(ctx context.Context, orgID int64, folderUID string, fold }) if err != nil { + if errors.Is(err, folder.ErrFolderNotFound) { + return nil, err + } return nil, ac.ErrInternal.Errorf("could not retrieve folder parents: %w", err) } diff --git a/pkg/services/dashboards/accesscontrol_test.go b/pkg/services/dashboards/accesscontrol_test.go index d20cb8c2c7396..57e1d8e80e0aa 100644 --- a/pkg/services/dashboards/accesscontrol_test.go +++ b/pkg/services/dashboards/accesscontrol_test.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "math/rand" - "strconv" "testing" "github.com/google/go-cmp/cmp" @@ -26,10 +25,9 @@ func TestNewFolderNameScopeResolver(t *testing.T) { t.Run("resolver should convert to uid scope", func(t *testing.T) { orgId := rand.Int63() title := "Very complex :title with: and /" + util.GenerateShortUID() - // nolint:staticcheck - db := &folder.Folder{Title: title, ID: rand.Int63(), UID: util.GenerateShortUID()} + db := &folder.Folder{Title: title, UID: util.GenerateShortUID()} folderStore := foldertest.NewFakeFolderStore(t) - folderStore.On("GetFolderByTitle", mock.Anything, mock.Anything, mock.Anything).Return(db, nil).Once() + folderStore.On("GetFolderByTitle", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(db, nil).Once() scope := "folders:name:" + title @@ -38,17 +36,16 @@ func TestNewFolderNameScopeResolver(t *testing.T) { require.NoError(t, err) require.Len(t, resolvedScopes, 1) require.Equal(t, fmt.Sprintf("folders:uid:%v", db.UID), resolvedScopes[0]) - folderStore.AssertCalled(t, "GetFolderByTitle", mock.Anything, orgId, title) + folderStore.AssertCalled(t, "GetFolderByTitle", mock.Anything, orgId, title, mock.Anything) }) t.Run("resolver should include inherited scopes if any", func(t *testing.T) { orgId := rand.Int63() title := "Very complex :title with: and /" + util.GenerateShortUID() - // nolint:staticcheck - db := &folder.Folder{Title: title, ID: rand.Int63(), UID: util.GenerateShortUID()} + db := &folder.Folder{Title: title, UID: util.GenerateShortUID()} folderStore := foldertest.NewFakeFolderStore(t) - folderStore.On("GetFolderByTitle", mock.Anything, mock.Anything, mock.Anything).Return(db, nil).Once() + folderStore.On("GetFolderByTitle", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(db, nil).Once() scope := "folders:name:" + title @@ -75,7 +72,7 @@ func TestNewFolderNameScopeResolver(t *testing.T) { t.Errorf("Result mismatch (-want +got):\n%s", diff) } - folderStore.AssertCalled(t, "GetFolderByTitle", mock.Anything, orgId, title) + folderStore.AssertCalled(t, "GetFolderByTitle", mock.Anything, orgId, title, mock.Anything) }) t.Run("resolver should fail if input scope is not expected", func(t *testing.T) { _, resolver := NewFolderNameScopeResolver(foldertest.NewFakeFolderStore(t), foldertest.NewFakeService()) @@ -94,7 +91,7 @@ func TestNewFolderNameScopeResolver(t *testing.T) { _, resolver := NewFolderNameScopeResolver(folderStore, foldertest.NewFakeService()) orgId := rand.Int63() - folderStore.On("GetFolderByTitle", mock.Anything, mock.Anything, mock.Anything).Return(nil, ErrDashboardNotFound).Once() + folderStore.On("GetFolderByTitle", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, ErrDashboardNotFound).Once() scope := "folders:name:" + util.GenerateShortUID() @@ -110,63 +107,6 @@ func TestNewFolderIDScopeResolver(t *testing.T) { require.Equal(t, "folders:id:", prefix) }) - t.Run("resolver should convert to uid scope", func(t *testing.T) { - folderStore := foldertest.NewFakeFolderStore(t) - _, resolver := NewFolderIDScopeResolver(folderStore, foldertest.NewFakeService()) - - orgId := rand.Int63() - uid := util.GenerateShortUID() - // nolint:staticcheck - db := &folder.Folder{ID: rand.Int63(), UID: uid} - folderStore.On("GetFolderByID", mock.Anything, mock.Anything, mock.Anything).Return(db, nil).Once() - - // nolint:staticcheck - scope := "folders:id:" + strconv.FormatInt(db.ID, 10) - resolvedScopes, err := resolver.Resolve(context.Background(), orgId, scope) - require.NoError(t, err) - require.Len(t, resolvedScopes, 1) - require.Equal(t, fmt.Sprintf("folders:uid:%v", db.UID), resolvedScopes[0]) - - // nolint:staticcheck - folderStore.AssertCalled(t, "GetFolderByID", mock.Anything, orgId, db.ID) - }) - t.Run("resolver should should include inherited scopes if any", func(t *testing.T) { - folderStore := foldertest.NewFakeFolderStore(t) - folderSvc := foldertest.NewFakeService() - folderSvc.ExpectedFolders = []*folder.Folder{ - { - UID: "parent", - }, - { - UID: "grandparent", - }, - } - _, resolver := NewFolderIDScopeResolver(folderStore, folderSvc) - - orgId := rand.Int63() - uid := util.GenerateShortUID() - // nolint:staticcheck - db := &folder.Folder{ID: rand.Int63(), UID: uid} - folderStore.On("GetFolderByID", mock.Anything, mock.Anything, mock.Anything).Return(db, nil).Once() - - // nolint:staticcheck - scope := "folders:id:" + strconv.FormatInt(db.ID, 10) - - resolvedScopes, err := resolver.Resolve(context.Background(), orgId, scope) - require.NoError(t, err) - require.Len(t, resolvedScopes, 3) - - if diff := cmp.Diff([]string{ - fmt.Sprintf("folders:uid:%v", db.UID), - "folders:uid:parent", - "folders:uid:grandparent", - }, resolvedScopes); diff != "" { - t.Errorf("Result mismatch (-want +got):\n%s", diff) - } - - // nolint:staticcheck - folderStore.AssertCalled(t, "GetFolderByID", mock.Anything, orgId, db.ID) - }) t.Run("resolver should fail if input scope is not expected", func(t *testing.T) { _, resolver := NewFolderIDScopeResolver(foldertest.NewFakeFolderStore(t), foldertest.NewFakeService()) @@ -213,87 +153,11 @@ func TestNewDashboardIDScopeResolver(t *testing.T) { require.Equal(t, "dashboards:id:", prefix) }) - t.Run("resolver should convert to uid dashboard and folder scope", func(t *testing.T) { - folderStore := foldertest.NewFakeFolderStore(t) - dashSvc := &FakeDashboardService{} - _, resolver := NewDashboardIDScopeResolver(folderStore, dashSvc, foldertest.NewFakeService()) - - orgID := rand.Int63() - // nolint:staticcheck - folder := &folder.Folder{ID: 2, UID: "2"} - // nolint:staticcheck - dashboard := &Dashboard{ID: 1, FolderID: folder.ID, UID: "1"} - dashSvc.On("G") - // nolint:staticcheck - folderStore.On("GetFolderByID", mock.Anything, orgID, folder.ID).Return(folder, nil).Once() - dashSvc.On("GetDashboard", mock.Anything, mock.Anything).Return(dashboard, nil).Once() - scope := ac.Scope("dashboards", "id", strconv.FormatInt(dashboard.ID, 10)) - resolvedScopes, err := resolver.Resolve(context.Background(), orgID, scope) - require.NoError(t, err) - require.Len(t, resolvedScopes, 2) - require.Equal(t, fmt.Sprintf("dashboards:uid:%s", dashboard.UID), resolvedScopes[0]) - require.Equal(t, fmt.Sprintf("folders:uid:%s", folder.UID), resolvedScopes[1]) - }) - - t.Run("resolver should inlude inherited scopes if any", func(t *testing.T) { - dashSvc := &FakeDashboardService{} - folderStore := foldertest.NewFakeFolderStore(t) - folderSvc := foldertest.NewFakeService() - folderSvc.ExpectedFolders = []*folder.Folder{ - { - UID: "parent", - }, - { - UID: "grandparent", - }, - } - _, resolver := NewDashboardIDScopeResolver(folderStore, dashSvc, folderSvc) - - orgID := rand.Int63() - // nolint:staticcheck - folder := &folder.Folder{ID: 2, UID: "2"} - // nolint:staticcheck - dashboard := &Dashboard{ID: 1, FolderID: folder.ID, UID: "1"} - - dashSvc.On("GetDashboard", mock.Anything, mock.Anything).Return(dashboard, nil).Once() - // nolint:staticcheck - folderStore.On("GetFolderByID", mock.Anything, orgID, folder.ID).Return(folder, nil).Once() - - scope := ac.Scope("dashboards", "id", strconv.FormatInt(dashboard.ID, 10)) - resolvedScopes, err := resolver.Resolve(context.Background(), orgID, scope) - require.NoError(t, err) - require.Len(t, resolvedScopes, 4) - - if diff := cmp.Diff([]string{ - fmt.Sprintf("dashboards:uid:%s", dashboard.UID), - fmt.Sprintf("folders:uid:%s", folder.UID), - "folders:uid:parent", - "folders:uid:grandparent", - }, resolvedScopes); diff != "" { - t.Errorf("Result mismatch (-want +got):\n%s", diff) - } - }) - t.Run("resolver should fail if input scope is not expected", func(t *testing.T) { _, resolver := NewDashboardIDScopeResolver(foldertest.NewFakeFolderStore(t), &FakeDashboardService{}, foldertest.NewFakeService()) _, err := resolver.Resolve(context.Background(), rand.Int63(), "dashboards:uid:123") require.ErrorIs(t, err, ac.ErrInvalidScope) }) - - t.Run("resolver should convert folderID 0 to general uid scope for the folder scope", func(t *testing.T) { - dashSvc := &FakeDashboardService{} - _, resolver := NewDashboardIDScopeResolver(foldertest.NewFakeFolderStore(t), dashSvc, foldertest.NewFakeService()) - - // nolint:staticcheck - dashboard := &Dashboard{ID: 1, FolderID: 0, UID: "1"} - dashSvc.On("GetDashboard", mock.Anything, mock.Anything).Return(dashboard, nil) - resolved, err := resolver.Resolve(context.Background(), 1, ac.Scope("dashboards", "id", "1")) - require.NoError(t, err) - - require.Len(t, resolved, 2) - require.Equal(t, "dashboards:uid:1", resolved[0]) - require.Equal(t, "folders:uid:general", resolved[1]) - }) } func TestNewDashboardUIDScopeResolver(t *testing.T) { @@ -302,83 +166,9 @@ func TestNewDashboardUIDScopeResolver(t *testing.T) { require.Equal(t, "dashboards:uid:", prefix) }) - t.Run("resolver should convert to uid dashboard and folder scope", func(t *testing.T) { - folderStore := foldertest.NewFakeFolderStore(t) - dashSvc := &FakeDashboardService{} - _, resolver := NewDashboardUIDScopeResolver(folderStore, dashSvc, foldertest.NewFakeService()) - - orgID := rand.Int63() - // nolint:staticcheck - folder := &folder.Folder{ID: 2, UID: "2"} - // nolint:staticcheck - dashboard := &Dashboard{ID: 1, FolderID: folder.ID, UID: "1"} - - dashSvc.On("GetDashboard", mock.Anything, mock.Anything).Return(dashboard, nil).Once() - // nolint:staticcheck - folderStore.On("GetFolderByID", mock.Anything, orgID, folder.ID).Return(folder, nil).Once() - scope := ac.Scope("dashboards", "uid", dashboard.UID) - resolvedScopes, err := resolver.Resolve(context.Background(), orgID, scope) - require.NoError(t, err) - require.Len(t, resolvedScopes, 2) - require.Equal(t, fmt.Sprintf("dashboards:uid:%s", dashboard.UID), resolvedScopes[0]) - require.Equal(t, fmt.Sprintf("folders:uid:%s", folder.UID), resolvedScopes[1]) - }) - - t.Run("resolver should include inherited scopes if any", func(t *testing.T) { - folderStore := foldertest.NewFakeFolderStore(t) - folderSvc := foldertest.NewFakeService() - folderSvc.ExpectedFolders = []*folder.Folder{ - { - UID: "parent", - }, - { - UID: "grandparent", - }, - } - dashSvc := &FakeDashboardService{} - _, resolver := NewDashboardUIDScopeResolver(folderStore, dashSvc, folderSvc) - - orgID := rand.Int63() - // nolint:staticcheck - folder := &folder.Folder{ID: 2, UID: "2"} - // nolint:staticcheck - dashboard := &Dashboard{ID: 1, FolderID: folder.ID, UID: "1"} - dashSvc.On("GetDashboard", mock.Anything, mock.Anything).Return(dashboard, nil).Once() - folderStore.On("GetFolderByID", mock.Anything, mock.Anything, mock.Anything).Return(folder, nil).Once() - - scope := ac.Scope("dashboards", "uid", dashboard.UID) - resolvedScopes, err := resolver.Resolve(context.Background(), orgID, scope) - require.NoError(t, err) - require.Len(t, resolvedScopes, 4) - - if diff := cmp.Diff([]string{ - fmt.Sprintf("dashboards:uid:%s", dashboard.UID), - fmt.Sprintf("folders:uid:%s", folder.UID), - "folders:uid:parent", - "folders:uid:grandparent", - }, resolvedScopes); diff != "" { - t.Errorf("Result mismatch (-want +got):\n%s", diff) - } - }) - t.Run("resolver should fail if input scope is not expected", func(t *testing.T) { _, resolver := NewDashboardUIDScopeResolver(foldertest.NewFakeFolderStore(t), &FakeDashboardService{}, foldertest.NewFakeService()) _, err := resolver.Resolve(context.Background(), rand.Int63(), "dashboards:id:123") require.ErrorIs(t, err, ac.ErrInvalidScope) }) - - t.Run("resolver should convert folderID 0 to general uid scope for the folder scope", func(t *testing.T) { - service := &FakeDashboardService{} - _, resolver := NewDashboardUIDScopeResolver(foldertest.NewFakeFolderStore(t), service, foldertest.NewFakeService()) - - // nolint:staticcheck - dashboard := &Dashboard{ID: 1, FolderID: 0, UID: "1"} - service.On("GetDashboard", mock.Anything, mock.Anything).Return(dashboard, nil) - resolved, err := resolver.Resolve(context.Background(), 1, ac.Scope("dashboards", "uid", "1")) - require.NoError(t, err) - - require.Len(t, resolved, 2) - require.Equal(t, "dashboards:uid:1", resolved[0]) - require.Equal(t, "folders:uid:general", resolved[1]) - }) } diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index cbb17fa594566..9958fbfe3bd40 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -3,8 +3,8 @@ package dashboards import ( "context" - alertmodels "github.com/grafana/grafana/pkg/services/alerting/models" "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/search/model" ) @@ -13,9 +13,12 @@ import ( // //go:generate mockery --name DashboardService --structname FakeDashboardService --inpackage --filename dashboard_service_mock.go type DashboardService interface { - BuildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, shouldValidateAlerts bool, validateProvisionedDashboard bool) (*SaveDashboardCommand, error) + BuildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, validateProvisionedDashboard bool) (*SaveDashboardCommand, error) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error FindDashboards(ctx context.Context, query *FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) + // GetDashboard fetches a dashboard. + // To fetch a dashboard under root by title should set the folder UID to point to an empty string + // eg. util.Pointer("") GetDashboard(ctx context.Context, query *GetDashboardQuery) (*Dashboard, error) GetDashboards(ctx context.Context, query *GetDashboardsQuery) ([]*Dashboard, error) GetDashboardTags(ctx context.Context, query *GetDashboardTagsQuery) ([]*DashboardTagCloudItem, error) @@ -23,7 +26,7 @@ type DashboardService interface { ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*Dashboard, error) SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*Dashboard, error) SearchDashboards(ctx context.Context, query *FindPersistedDashboardsQuery) (model.HitList, error) - CountInFolder(ctx context.Context, orgID int64, folderUID string, user identity.Requester) (int64, error) + CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, user identity.Requester) (int64, error) GetDashboardsSharedWithUser(ctx context.Context, user identity.Requester) ([]*Dashboard, error) } @@ -41,7 +44,7 @@ type DashboardProvisioningService interface { GetProvisionedDashboardData(ctx context.Context, name string) ([]*DashboardProvisioning, error) GetProvisionedDashboardDataByDashboardID(ctx context.Context, dashboardID int64) (*DashboardProvisioning, error) GetProvisionedDashboardDataByDashboardUID(ctx context.Context, orgID int64, dashboardUID string) (*DashboardProvisioning, error) - SaveFolderForProvisionedDashboards(context.Context, *SaveDashboardDTO) (*Dashboard, error) + SaveFolderForProvisionedDashboards(context.Context, *folder.CreateFolderCommand) (*folder.Folder, error) SaveProvisionedDashboard(ctx context.Context, dto *SaveDashboardDTO, provisioning *DashboardProvisioning) (*Dashboard, error) UnprovisionDashboard(ctx context.Context, dashboardID int64) error } @@ -62,8 +65,6 @@ type Store interface { GetProvisionedDashboardData(ctx context.Context, name string) ([]*DashboardProvisioning, error) GetProvisionedDataByDashboardID(ctx context.Context, dashboardID int64) (*DashboardProvisioning, error) GetProvisionedDataByDashboardUID(ctx context.Context, orgID int64, dashboardUID string) (*DashboardProvisioning, error) - // SaveAlerts saves dashboard alerts. - SaveAlerts(ctx context.Context, dashID int64, alerts []*alertmodels.Alert) error SaveDashboard(ctx context.Context, cmd SaveDashboardCommand) (*Dashboard, error) SaveProvisionedDashboard(ctx context.Context, cmd SaveDashboardCommand, provisioning *DashboardProvisioning) (*Dashboard, error) UnprovisionDashboard(ctx context.Context, id int64) error @@ -73,6 +74,6 @@ type Store interface { Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) // CountDashboardsInFolder returns the number of dashboards associated with // the given parent folder ID. - CountDashboardsInFolder(ctx context.Context, request *CountDashboardsInFolderRequest) (int64, error) - DeleteDashboardsInFolder(ctx context.Context, request *DeleteDashboardsInFolderRequest) error + CountDashboardsInFolders(ctx context.Context, request *CountDashboardsInFolderRequest) (int64, error) + DeleteDashboardsInFolders(ctx context.Context, request *DeleteDashboardsInFolderRequest) error } diff --git a/pkg/services/dashboards/dashboard_provisioning_mock.go b/pkg/services/dashboards/dashboard_provisioning_mock.go index 7b6a111992499..a743752fcd57e 100644 --- a/pkg/services/dashboards/dashboard_provisioning_mock.go +++ b/pkg/services/dashboards/dashboard_provisioning_mock.go @@ -1,10 +1,11 @@ -// Code generated by mockery v2.28.0. DO NOT EDIT. +// Code generated by mockery v2.32.0. DO NOT EDIT. package dashboards import ( context "context" + folder "github.com/grafana/grafana/pkg/services/folder" mock "github.com/stretchr/testify/mock" ) @@ -120,23 +121,23 @@ func (_m *FakeDashboardProvisioning) GetProvisionedDashboardDataByDashboardUID(c } // SaveFolderForProvisionedDashboards provides a mock function with given fields: _a0, _a1 -func (_m *FakeDashboardProvisioning) SaveFolderForProvisionedDashboards(_a0 context.Context, _a1 *SaveDashboardDTO) (*Dashboard, error) { +func (_m *FakeDashboardProvisioning) SaveFolderForProvisionedDashboards(_a0 context.Context, _a1 *folder.CreateFolderCommand) (*folder.Folder, error) { ret := _m.Called(_a0, _a1) - var r0 *Dashboard + var r0 *folder.Folder var r1 error - if rf, ok := ret.Get(0).(func(context.Context, *SaveDashboardDTO) (*Dashboard, error)); ok { + if rf, ok := ret.Get(0).(func(context.Context, *folder.CreateFolderCommand) (*folder.Folder, error)); ok { return rf(_a0, _a1) } - if rf, ok := ret.Get(0).(func(context.Context, *SaveDashboardDTO) *Dashboard); ok { + if rf, ok := ret.Get(0).(func(context.Context, *folder.CreateFolderCommand) *folder.Folder); ok { r0 = rf(_a0, _a1) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*Dashboard) + r0 = ret.Get(0).(*folder.Folder) } } - if rf, ok := ret.Get(1).(func(context.Context, *SaveDashboardDTO) error); ok { + if rf, ok := ret.Get(1).(func(context.Context, *folder.CreateFolderCommand) error); ok { r1 = rf(_a0, _a1) } else { r1 = ret.Error(1) @@ -185,13 +186,12 @@ func (_m *FakeDashboardProvisioning) UnprovisionDashboard(ctx context.Context, d return r0 } -type mockConstructorTestingTNewFakeDashboardProvisioning interface { +// NewFakeDashboardProvisioning creates a new instance of FakeDashboardProvisioning. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewFakeDashboardProvisioning(t interface { mock.TestingT Cleanup(func()) -} - -// NewFakeDashboardProvisioning creates a new instance of FakeDashboardProvisioning. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewFakeDashboardProvisioning(t mockConstructorTestingTNewFakeDashboardProvisioning) *FakeDashboardProvisioning { +}) *FakeDashboardProvisioning { mock := &FakeDashboardProvisioning{} mock.Mock.Test(t) diff --git a/pkg/services/dashboards/dashboard_service_mock.go b/pkg/services/dashboards/dashboard_service_mock.go index 239468e14f1b7..f8be5a2e900a8 100644 --- a/pkg/services/dashboards/dashboard_service_mock.go +++ b/pkg/services/dashboards/dashboard_service_mock.go @@ -1,11 +1,13 @@ +// Code generated by mockery v2.34.2. DO NOT EDIT. + package dashboards import ( context "context" + identity "github.com/grafana/grafana/pkg/services/auth/identity" mock "github.com/stretchr/testify/mock" - "github.com/grafana/grafana/pkg/services/auth/identity" model "github.com/grafana/grafana/pkg/services/search/model" ) @@ -14,25 +16,25 @@ type FakeDashboardService struct { mock.Mock } -// BuildSaveDashboardCommand provides a mock function with given fields: ctx, dto, shouldValidateAlerts, validateProvisionedDashboard -func (_m *FakeDashboardService) BuildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, shouldValidateAlerts bool, validateProvisionedDashboard bool) (*SaveDashboardCommand, error) { - ret := _m.Called(ctx, dto, shouldValidateAlerts, validateProvisionedDashboard) +// BuildSaveDashboardCommand provides a mock function with given fields: ctx, dto, validateProvisionedDashboard +func (_m *FakeDashboardService) BuildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, validateProvisionedDashboard bool) (*SaveDashboardCommand, error) { + ret := _m.Called(ctx, dto, validateProvisionedDashboard) var r0 *SaveDashboardCommand var r1 error - if rf, ok := ret.Get(0).(func(context.Context, *SaveDashboardDTO, bool, bool) (*SaveDashboardCommand, error)); ok { - return rf(ctx, dto, shouldValidateAlerts, validateProvisionedDashboard) + if rf, ok := ret.Get(0).(func(context.Context, *SaveDashboardDTO, bool) (*SaveDashboardCommand, error)); ok { + return rf(ctx, dto, validateProvisionedDashboard) } - if rf, ok := ret.Get(0).(func(context.Context, *SaveDashboardDTO, bool, bool) *SaveDashboardCommand); ok { - r0 = rf(ctx, dto, shouldValidateAlerts, validateProvisionedDashboard) + if rf, ok := ret.Get(0).(func(context.Context, *SaveDashboardDTO, bool) *SaveDashboardCommand); ok { + r0 = rf(ctx, dto, validateProvisionedDashboard) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*SaveDashboardCommand) } } - if rf, ok := ret.Get(1).(func(context.Context, *SaveDashboardDTO, bool, bool) error); ok { - r1 = rf(ctx, dto, shouldValidateAlerts, validateProvisionedDashboard) + if rf, ok := ret.Get(1).(func(context.Context, *SaveDashboardDTO, bool) error); ok { + r1 = rf(ctx, dto, validateProvisionedDashboard) } else { r1 = ret.Error(1) } @@ -40,23 +42,23 @@ func (_m *FakeDashboardService) BuildSaveDashboardCommand(ctx context.Context, d return r0, r1 } -// CountInFolder provides a mock function with given fields: ctx, orgID, folderUID, _a3 -func (_m *FakeDashboardService) CountInFolder(ctx context.Context, orgID int64, folderUID string, _a3 identity.Requester) (int64, error) { - ret := _m.Called(ctx, orgID, folderUID, _a3) +// CountInFolders provides a mock function with given fields: ctx, orgID, folderUIDs, user +func (_m *FakeDashboardService) CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, user identity.Requester) (int64, error) { + ret := _m.Called(ctx, orgID, folderUIDs, user) var r0 int64 var r1 error - if rf, ok := ret.Get(0).(func(context.Context, int64, string, identity.Requester) (int64, error)); ok { - return rf(ctx, orgID, folderUID, _a3) + if rf, ok := ret.Get(0).(func(context.Context, int64, []string, identity.Requester) (int64, error)); ok { + return rf(ctx, orgID, folderUIDs, user) } - if rf, ok := ret.Get(0).(func(context.Context, int64, string, identity.Requester) int64); ok { - r0 = rf(ctx, orgID, folderUID, _a3) + if rf, ok := ret.Get(0).(func(context.Context, int64, []string, identity.Requester) int64); ok { + r0 = rf(ctx, orgID, folderUIDs, user) } else { r0 = ret.Get(0).(int64) } - if rf, ok := ret.Get(1).(func(context.Context, int64, string, identity.Requester) error); ok { - r1 = rf(ctx, orgID, folderUID, _a3) + if rf, ok := ret.Get(1).(func(context.Context, int64, []string, identity.Requester) error); ok { + r1 = rf(ctx, orgID, folderUIDs, user) } else { r1 = ret.Error(1) } @@ -208,6 +210,32 @@ func (_m *FakeDashboardService) GetDashboards(ctx context.Context, query *GetDas return r0, r1 } +// GetDashboardsSharedWithUser provides a mock function with given fields: ctx, user +func (_m *FakeDashboardService) GetDashboardsSharedWithUser(ctx context.Context, user identity.Requester) ([]*Dashboard, error) { + ret := _m.Called(ctx, user) + + var r0 []*Dashboard + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, identity.Requester) ([]*Dashboard, error)); ok { + return rf(ctx, user) + } + if rf, ok := ret.Get(0).(func(context.Context, identity.Requester) []*Dashboard); ok { + r0 = rf(ctx, user) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*Dashboard) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, identity.Requester) error); ok { + r1 = rf(ctx, user) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // ImportDashboard provides a mock function with given fields: ctx, dto func (_m *FakeDashboardService) ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*Dashboard, error) { ret := _m.Called(ctx, dto) @@ -286,38 +314,12 @@ func (_m *FakeDashboardService) SearchDashboards(ctx context.Context, query *Fin return r0, r1 } -func (_m *FakeDashboardService) GetDashboardsSharedWithUser(ctx context.Context, user identity.Requester) ([]*Dashboard, error) { - ret := _m.Called(ctx, user) - - var r0 []*Dashboard - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, identity.Requester) ([]*Dashboard, error)); ok { - return rf(ctx, user) - } - if rf, ok := ret.Get(0).(func(context.Context, identity.Requester) []*Dashboard); ok { - r0 = rf(ctx, user) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*Dashboard) - } - } - - if rf, ok := ret.Get(1).(func(context.Context, identity.Requester) error); ok { - r1 = rf(ctx, user) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -type mockConstructorTestingTNewFakeDashboardService interface { +// NewFakeDashboardService creates a new instance of FakeDashboardService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewFakeDashboardService(t interface { mock.TestingT Cleanup(func()) -} - -// NewFakeDashboardService creates a new instance of FakeDashboardService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewFakeDashboardService(t mockConstructorTestingTNewFakeDashboardService) *FakeDashboardService { +}) *FakeDashboardService { mock := &FakeDashboardService{} mock.Mock.Test(t) diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index fe5bf4b714d74..04b4354922c0e 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "xorm.io/xorm" @@ -12,7 +13,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" ac "github.com/grafana/grafana/pkg/services/accesscontrol" - alertmodels "github.com/grafana/grafana/pkg/services/alerting/models" "github.com/grafana/grafana/pkg/services/dashboards" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -171,25 +171,6 @@ func (d *dashboardStore) SaveDashboard(ctx context.Context, cmd dashboards.SaveD return result, err } -func (d *dashboardStore) SaveAlerts(ctx context.Context, dashID int64, alerts []*alertmodels.Alert) error { - return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - existingAlerts, err := GetAlertsByDashboardId2(dashID, sess) - if err != nil { - return err - } - - if err := d.updateAlerts(ctx, existingAlerts, alerts, d.log); err != nil { - return err - } - - if err := d.deleteMissingAlerts(existingAlerts, alerts, sess); err != nil { - return err - } - - return nil - }) -} - // UnprovisionDashboard removes row in dashboard_provisioning for the dashboard making it seem as if manually created. // The dashboard will still have `created_by = -1` to see it was not created by any particular user. func (d *dashboardStore) UnprovisionDashboard(ctx context.Context, id int64) error { @@ -346,9 +327,15 @@ func getExistingDashboardByIDOrUIDForUpdate(sess *db.Session, dash *dashboards.D func getExistingDashboardByTitleAndFolder(sess *db.Session, dash *dashboards.Dashboard, dialect migrator.Dialect, overwrite, isParentFolderChanged bool) (bool, error) { var existing dashboards.Dashboard - // nolint:staticcheck - exists, err := sess.Where("org_id=? AND title=? AND (is_folder=? OR folder_id=?)", dash.OrgID, dash.Title, - dialect.BooleanStr(true), dash.FolderID).Get(&existing) + condition := "org_id=? AND title=?" + args := []any{dash.OrgID, dash.Title} + if dash.FolderUID != "" { + condition += " AND folder_uid=?" + args = append(args, dash.FolderUID) + } else { + condition += " AND folder_uid IS NULL" + } + exists, err := sess.Where(condition, args...).Get(&existing) if err != nil { return isParentFolderChanged, fmt.Errorf("SQL query for existing dashboard by org ID or folder ID failed: %w", err) } @@ -361,6 +348,7 @@ func getExistingDashboardByTitleAndFolder(sess *db.Session, dash *dashboards.Das return isParentFolderChanged, dashboards.ErrDashboardFolderWithSameNameAsDashboard } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck if !dash.IsFolder && (dash.FolderID != existing.FolderID || dash.ID == 0) { isParentFolderChanged = true @@ -512,123 +500,6 @@ func saveProvisionedData(sess *db.Session, provisioning *dashboards.DashboardPro return err } -func GetAlertsByDashboardId2(dashboardId int64, sess *db.Session) ([]*alertmodels.Alert, error) { - alerts := make([]*alertmodels.Alert, 0) - err := sess.Where("dashboard_id = ?", dashboardId).Find(&alerts) - - if err != nil { - return []*alertmodels.Alert{}, err - } - - return alerts, nil -} - -func (d *dashboardStore) updateAlerts(ctx context.Context, existingAlerts []*alertmodels.Alert, alertsIn []*alertmodels.Alert, log log.Logger) error { - return d.store.WithDbSession(ctx, func(sess *db.Session) error { - for _, alert := range alertsIn { - update := false - var alertToUpdate *alertmodels.Alert - - for _, k := range existingAlerts { - if alert.PanelID == k.PanelID { - update = true - alert.ID = k.ID - alertToUpdate = k - break - } - } - - if update { - if alertToUpdate.ContainsUpdates(alert) { - alert.Updated = time.Now() - alert.State = alertToUpdate.State - sess.MustCols("message", "for") - - _, err := sess.ID(alert.ID).Update(alert) - if err != nil { - return err - } - - log.Debug("Alert updated", "name", alert.Name, "id", alert.ID) - } - } else { - alert.Updated = time.Now() - alert.Created = time.Now() - alert.State = alertmodels.AlertStateUnknown - alert.NewStateDate = time.Now() - - _, err := sess.Insert(alert) - if err != nil { - return err - } - - log.Debug("Alert inserted", "name", alert.Name, "id", alert.ID) - } - tags := alert.GetTagsFromSettings() - if _, err := sess.Exec("DELETE FROM alert_rule_tag WHERE alert_id = ?", alert.ID); err != nil { - return err - } - if tags != nil { - tags, err := d.tagService.EnsureTagsExist(ctx, tags) - if err != nil { - return err - } - for _, tag := range tags { - if _, err := sess.Exec("INSERT INTO alert_rule_tag (alert_id, tag_id) VALUES(?,?)", alert.ID, tag.Id); err != nil { - return err - } - } - } - } - return nil - }) -} - -func (d *dashboardStore) deleteMissingAlerts(alerts []*alertmodels.Alert, existingAlerts []*alertmodels.Alert, sess *db.Session) error { - for _, missingAlert := range alerts { - missing := true - - for _, k := range existingAlerts { - if missingAlert.PanelID == k.PanelID { - missing = false - break - } - } - - if missing { - if err := d.deleteAlertByIdInternal(missingAlert.ID, "Removed from dashboard", sess); err != nil { - // No use trying to delete more, since we're in a transaction and it will be - // rolled back on error. - return err - } - } - } - - return nil -} - -func (d *dashboardStore) deleteAlertByIdInternal(alertId int64, reason string, sess *db.Session) error { - d.log.Debug("Deleting alert", "id", alertId, "reason", reason) - - if _, err := sess.Exec("DELETE FROM alert WHERE id = ?", alertId); err != nil { - return err - } - - if _, err := sess.Exec("DELETE FROM annotation WHERE alert_id = ?", alertId); err != nil { - return err - } - - if _, err := sess.Exec("DELETE FROM alert_notification_state WHERE alert_id = ?", alertId); err != nil { - return err - } - - if _, err := sess.Exec("DELETE FROM alert_rule_tag WHERE alert_id = ?", alertId); err != nil { - return err - } - - return nil -} - func (d *dashboardStore) GetDashboardsByPluginID(ctx context.Context, query *dashboards.GetDashboardsByPluginIDQuery) ([]*dashboards.Dashboard, error) { var dashboards = make([]*dashboards.Dashboard, 0) err := d.store.WithDbSession(ctx, func(dbSession *db.Session) error { @@ -650,7 +521,12 @@ func (d *dashboardStore) DeleteDashboard(ctx context.Context, cmd *dashboards.De } func (d *dashboardStore) deleteDashboard(cmd *dashboards.DeleteDashboardCommand, sess *db.Session, emitEntityEvent bool) error { - dashboard := dashboards.Dashboard{ID: cmd.ID, OrgID: cmd.OrgID} + dashboard := dashboards.Dashboard{OrgID: cmd.OrgID} + if cmd.UID != "" { + dashboard.UID = cmd.UID + } else { + dashboard.ID = cmd.ID + } has, err := sess.Get(&dashboard) if err != nil { return err @@ -686,10 +562,6 @@ func (d *dashboardStore) deleteDashboard(cmd *dashboards.DeleteDashboardCommand, } } - if err := d.deleteAlertDefinition(dashboard.ID, sess); err != nil { - return err - } - _, err = sess.Exec("DELETE FROM annotation WHERE dashboard_id = ? AND org_id = ?", dashboard.ID, dashboard.OrgID) if err != nil { return err @@ -741,10 +613,6 @@ func (d *dashboardStore) deleteChildrenDashboardAssociations(sess *db.Session, d if len(dashIds) > 0 { for _, dash := range dashIds { - if err := d.deleteAlertDefinition(dash.Id, sess); err != nil { - return err - } - // remove all access control permission with child dashboard scopes if err := d.deleteResourcePermissions(sess, dashboard.OrgID, ac.GetResourceScopeUID("dashboards", dash.Uid)); err != nil { return err @@ -792,28 +660,12 @@ func createEntityEvent(dashboard *dashboards.Dashboard, eventType store.EntityEv return entityEvent } -func (d *dashboardStore) deleteAlertDefinition(dashboardId int64, sess *db.Session) error { - alerts := make([]*alertmodels.Alert, 0) - if err := sess.Where("dashboard_id = ?", dashboardId).Find(&alerts); err != nil { - return err - } - - for _, alert := range alerts { - if err := d.deleteAlertByIdInternal(alert.ID, "Dashboard deleted", sess); err != nil { - // If we return an error, the current transaction gets rolled back, so no use - // trying to delete more - return err - } - } - - return nil -} - func (d *dashboardStore) GetDashboard(ctx context.Context, query *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) { var queryResult *dashboards.Dashboard err := d.store.WithDbSession(ctx, func(sess *db.Session) error { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck - if query.ID == 0 && len(query.UID) == 0 && (query.Title == nil || query.FolderID == nil) { + if query.ID == 0 && len(query.UID) == 0 && (query.Title == nil || (query.FolderID == nil && query.FolderUID == nil)) { return dashboards.ErrDashboardIdentifierNotSet } @@ -823,15 +675,18 @@ func (d *dashboardStore) GetDashboard(ctx context.Context, query *dashboards.Get dashboard.Title = *query.Title mustCols = append(mustCols, "title") } - // nolint:staticcheck - if query.FolderID != nil { + + if query.FolderUID != nil { + dashboard.FolderUID = *query.FolderUID + mustCols = append(mustCols, "folder_uid") + } else if query.FolderID != nil { // nolint:staticcheck // nolint:staticcheck dashboard.FolderID = *query.FolderID mustCols = append(mustCols, "folder_id") + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() } - has, err := sess.MustCols(mustCols...).Get(&dashboard) - + has, err := sess.MustCols(mustCols...).Nullable("folder_uid").Get(&dashboard) if err != nil { return err } else if !has { @@ -932,7 +787,7 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F if len(query.Type) > 0 { filters = append(filters, searchstore.TypeFilter{Dialect: d.store.GetDialect(), Type: query.Type}) } - + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck if len(query.FolderIds) > 0 { filters = append(filters, searchstore.FolderFilter{IDs: query.FolderIds}) @@ -997,41 +852,58 @@ func (d *dashboardStore) GetDashboardTags(ctx context.Context, query *dashboards // CountDashboardsInFolder returns a count of all dashboards associated with the // given parent folder ID. -// -// This will be updated to take CountDashboardsInFolderQuery as an argument and -// lookup dashboards using the ParentFolderUID when dashboards are associated with a parent folder UID instead of ID. -func (d *dashboardStore) CountDashboardsInFolder( +func (d *dashboardStore) CountDashboardsInFolders( ctx context.Context, req *dashboards.CountDashboardsInFolderRequest) (int64, error) { + if len(req.FolderUIDs) == 0 { + return 0, nil + } var count int64 - var err error - err = d.store.WithDbSession(ctx, func(sess *db.Session) error { - // nolint:staticcheck - session := sess.In("folder_id", req.FolderID).In("org_id", req.OrgID). - In("is_folder", d.store.GetDialect().BooleanStr(false)) - count, err = session.Count(&dashboards.Dashboard{}) + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() + s := strings.Builder{} + args := make([]any, 0, 3) + s.WriteString("SELECT COUNT(*) FROM dashboard WHERE ") + if len(req.FolderUIDs) == 1 && req.FolderUIDs[0] == "" { + s.WriteString("folder_uid IS NULL") + } else { + s.WriteString(fmt.Sprintf("folder_uid IN (%s)", strings.Repeat("?,", len(req.FolderUIDs)-1)+"?")) + for _, folderUID := range req.FolderUIDs { + args = append(args, folderUID) + } + } + s.WriteString(" AND org_id = ? AND is_folder = ?") + args = append(args, req.OrgID, d.store.GetDialect().BooleanStr(false)) + sql := s.String() + _, err := sess.SQL(sql, args...).Get(&count) return err }) return count, err } -func (d *dashboardStore) DeleteDashboardsInFolder( +func (d *dashboardStore) DeleteDashboardsInFolders( ctx context.Context, req *dashboards.DeleteDashboardsInFolderRequest) error { return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - dashboard := dashboards.Dashboard{OrgID: req.OrgID} - has, err := sess.Where("uid = ? AND org_id = ?", req.FolderUID, req.OrgID).Get(&dashboard) - if err != nil { - return err - } - if !has { - return dashboards.ErrFolderNotFound - } + // TODO delete all dashboards in the folder in a bulk query + for _, folderUID := range req.FolderUIDs { + dashboard := dashboards.Dashboard{OrgID: req.OrgID} + has, err := sess.Where("org_id = ? AND uid = ?", req.OrgID, folderUID).Get(&dashboard) + if err != nil { + return err + } + if !has { + return dashboards.ErrFolderNotFound + } - if err := d.deleteChildrenDashboardAssociations(sess, &dashboard); err != nil { - return err - } + if err := d.deleteChildrenDashboardAssociations(sess, &dashboard); err != nil { + return err + } - _, err = sess.Where("folder_id = ? AND org_id = ? AND is_folder = ?", dashboard.ID, dashboard.OrgID, false).Delete(&dashboards.Dashboard{}) - return err + _, err = sess.Where("folder_id = ? AND org_id = ? AND is_folder = ?", dashboard.ID, dashboard.OrgID, false).Delete(&dashboards.Dashboard{}) + if err != nil { + return err + } + } + return nil }) } diff --git a/pkg/services/dashboards/database/database_folder_test.go b/pkg/services/dashboards/database/database_folder_test.go index 86a8d52f65a0f..250f926d5701d 100644 --- a/pkg/services/dashboards/database/database_folder_test.go +++ b/pkg/services/dashboards/database/database_folder_test.go @@ -194,9 +194,10 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("should not return folder with acl or its children", func(t *testing.T) { query := &dashboards.FindPersistedDashboardsQuery{ - SignedInUser: currentUser, - OrgId: 1, - DashboardIds: []int64{folder1.ID, childDash1.ID, childDash2.ID, dashInRoot.ID}, + SignedInUser: currentUser, + OrgId: 1, + DashboardIds: []int64{folder1.ID, childDash1.ID, childDash2.ID, dashInRoot.ID}, + DashboardUIDs: []string{folder1.UID, childDash1.UID, childDash2.UID, dashInRoot.UID}, } hits, err := testSearchDashboards(dashboardStore, query) require.NoError(t, err) @@ -206,7 +207,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { }) t.Run("and a dashboard is moved from folder with acl to the folder without an acl", func(t *testing.T) { setup2() - moveDashboard(t, dashboardStore, 1, childDash1.Data, folder2.ID, folder2.UID) + moveDashboard(t, dashboardStore, 1, childDash1.Data, folder2.ID, childDash2.FolderUID) currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dashInRoot.UID), dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2.UID)}, dashboards.ActionFoldersRead: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2.UID)}}} actest.AddUserPermissionToDB(t, sqlStore, currentUser) @@ -218,11 +219,11 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { } hits, err := testSearchDashboards(dashboardStore, query) require.NoError(t, err) - require.Equal(t, len(hits), 4) - require.Equal(t, hits[0].ID, folder2.ID) - require.Equal(t, hits[1].ID, childDash1.ID) - require.Equal(t, hits[2].ID, childDash2.ID) - require.Equal(t, hits[3].ID, dashInRoot.ID) + assert.Equal(t, len(hits), 4) + assert.Equal(t, hits[0].ID, folder2.ID) + assert.Equal(t, hits[1].ID, childDash1.ID) + assert.Equal(t, hits[2].ID, childDash2.ID) + assert.Equal(t, hits[3].ID, dashInRoot.ID) }) }) }) @@ -294,7 +295,7 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { guardian.New = origNewGuardian }) - folderSvc := folderimpl.ProvideService(mock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), sqlStore.Cfg, dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(sqlStore), sqlStore, features, nil) + folderSvc := folderimpl.ProvideService(mock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), sqlStore.Cfg, dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(sqlStore), sqlStore, features, supportbundlestest.NewFakeBundleService(), nil) parentUID := "" for i := 0; ; i++ { @@ -339,7 +340,6 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { Dashboard: simplejson.NewFromAny(map[string]any{ "title": dashInParentTitle, }), - FolderID: nestedFolders[0].ID, // nolint:staticcheck FolderUID: nestedFolders[0].UID, } _, err = dashboardWriteStore.SaveDashboard(context.Background(), saveDashboardCmd) @@ -352,7 +352,6 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { Dashboard: simplejson.NewFromAny(map[string]any{ "title": dashInSubfolderTitle, }), - FolderID: nestedFolders[1].ID, // nolint:staticcheck FolderUID: nestedFolders[1].UID, } _, err = dashboardWriteStore.SaveDashboard(context.Background(), saveDashboardCmd) @@ -378,22 +377,6 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { permissions: nil, expectedTitles: nil, }, - { - desc: "it should not return dashboard in subfolder if nested folders are disabled and the user has permission to read dashboards under parent folder", - features: featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch), - permissions: map[string][]string{ - dashboards.ActionDashboardsRead: {fmt.Sprintf("folders:uid:%s", nestedFolders[0].UID)}, - }, - expectedTitles: []string{dashInParentTitle}, - }, - { - desc: "it should return dashboard in subfolder if nested folders are enabled and the user has permission to read dashboards under parent folder", - features: featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch, featuremgmt.FlagNestedFolders), - permissions: map[string][]string{ - dashboards.ActionDashboardsRead: {fmt.Sprintf("folders:uid:%s", nestedFolders[0].UID)}, - }, - expectedTitles: []string{dashInParentTitle, dashInSubfolderTitle}, - }, { desc: "it should not return subfolder if nested folders are disabled and the user has permission to read folders under parent folder", features: featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch), diff --git a/pkg/services/dashboards/database/database_provisioning_test.go b/pkg/services/dashboards/database/database_provisioning_test.go index 6578eafa5004f..4415cd77bec24 100644 --- a/pkg/services/dashboards/database/database_provisioning_test.go +++ b/pkg/services/dashboards/database/database_provisioning_test.go @@ -25,7 +25,6 @@ func TestIntegrationDashboardProvisioningTest(t *testing.T) { folderCmd := dashboards.SaveDashboardCommand{ OrgID: 1, - FolderID: 0, // nolint:staticcheck FolderUID: "", IsFolder: true, Dashboard: simplejson.NewFromAny(map[string]any{ @@ -40,11 +39,10 @@ func TestIntegrationDashboardProvisioningTest(t *testing.T) { saveDashboardCmd := dashboards.SaveDashboardCommand{ OrgID: 1, IsFolder: false, - FolderID: dash.ID, // nolint:staticcheck FolderUID: dash.UID, Dashboard: simplejson.NewFromAny(map[string]any{ "id": nil, - "title": "test dashboard", + "title": "test dashboard 2", }), } @@ -67,7 +65,6 @@ func TestIntegrationDashboardProvisioningTest(t *testing.T) { saveCmd := dashboards.SaveDashboardCommand{ OrgID: 1, IsFolder: false, - FolderID: dash.ID, // nolint:staticcheck FolderUID: dash.UID, Dashboard: simplejson.NewFromAny(map[string]any{ "id": nil, diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go index febcdb22d1400..2bf380956f320 100644 --- a/pkg/services/dashboards/database/database_test.go +++ b/pkg/services/dashboards/database/database_test.go @@ -26,12 +26,19 @@ import ( "github.com/grafana/grafana/pkg/services/search/model" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" + "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) +// run tests with cleanup +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationDashboardDataAccess(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -47,6 +54,11 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { var err error dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) + // insertTestDashboard creates the following hierarchy: + // 1 test dash folder + // test dash 23 + // test dash 45 + // test dash 67 savedFolder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, "", true, "prod", "webapp") savedDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, savedFolder.ID, savedFolder.UID, false, "prod", "webapp") insertTestDashboard(t, dashboardStore, "test dash 45", 1, savedFolder.ID, savedFolder.UID, false, "prod") @@ -60,16 +72,14 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { require.Equal(t, savedDash.Slug, "test-dash-23") require.NotEqual(t, savedDash.ID, 0) require.False(t, savedDash.IsFolder) - // nolint:staticcheck - require.Positive(t, savedDash.FolderID) + require.NotEmpty(t, savedDash.FolderUID) require.Positive(t, len(savedDash.UID)) require.Equal(t, savedFolder.Title, "1 test dash folder") require.Equal(t, savedFolder.Slug, "1-test-dash-folder") require.NotEqual(t, savedFolder.ID, 0) require.True(t, savedFolder.IsFolder) - // nolint:staticcheck - require.EqualValues(t, savedFolder.FolderID, 0) + require.Empty(t, savedFolder.FolderUID) require.Positive(t, len(savedFolder.UID)) }) @@ -90,45 +100,56 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { require.False(t, queryResult.IsFolder) }) - t.Run("Should be able to get dashboard by title and folderID", func(t *testing.T) { + t.Run("Should not be able to get dashboard by title alone", func(t *testing.T) { setup() query := dashboards.GetDashboardQuery{ - Title: util.Pointer("test dash 23"), - FolderID: &savedFolder.ID, // nolint:staticcheck - OrgID: 1, + Title: util.Pointer("test dash 23"), + OrgID: 1, } - queryResult, err := dashboardStore.GetDashboard(context.Background(), &query) - require.NoError(t, err) - - require.Equal(t, queryResult.Title, "test dash 23") - require.Equal(t, queryResult.Slug, "test-dash-23") - require.Equal(t, queryResult.ID, savedDash.ID) - require.Equal(t, queryResult.UID, savedDash.UID) - require.False(t, queryResult.IsFolder) + _, err := dashboardStore.GetDashboard(context.Background(), &query) + require.ErrorIs(t, err, dashboards.ErrDashboardIdentifierNotSet) }) - t.Run("Should not be able to get dashboard by title alone", func(t *testing.T) { + t.Run("Should be able to get root dashboard by title", func(t *testing.T) { setup() query := dashboards.GetDashboardQuery{ - Title: util.Pointer("test dash 23"), - OrgID: 1, + Title: util.Pointer("test dash 67"), + FolderUID: util.Pointer(""), + OrgID: 1, } _, err := dashboardStore.GetDashboard(context.Background(), &query) - require.ErrorIs(t, err, dashboards.ErrDashboardIdentifierNotSet) + require.Error(t, err) }) - t.Run("Folder=0 should not be able to get a dashboard in a folder", func(t *testing.T) { + t.Run("Should be able to get dashboard by title and folderID", func(t *testing.T) { setup() query := dashboards.GetDashboardQuery{ Title: util.Pointer("test dash 23"), - FolderID: util.Pointer(int64(0)), // nolint:staticcheck + FolderID: &savedDash.ID, OrgID: 1, } _, err := dashboardStore.GetDashboard(context.Background(), &query) - require.ErrorIs(t, err, dashboards.ErrDashboardNotFound) + require.Error(t, err) + }) + + t.Run("Should be able to get dashboard by title and folderUID", func(t *testing.T) { + setup() + query := dashboards.GetDashboardQuery{ + Title: util.Pointer("test dash 23"), + FolderUID: util.Pointer(savedFolder.UID), + OrgID: 1, + } + queryResult, err := dashboardStore.GetDashboard(context.Background(), &query) + require.NoError(t, err) + + require.Equal(t, queryResult.Title, "test dash 23") + require.Equal(t, queryResult.Slug, "test-dash-23") + require.Equal(t, queryResult.ID, savedDash.ID) + require.Equal(t, queryResult.UID, savedDash.UID) + require.False(t, queryResult.IsFolder) }) t.Run("Should be able to get dashboard by uid", func(t *testing.T) { @@ -166,6 +187,18 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { require.Equal(t, err, dashboards.ErrDashboardIdentifierNotSet) }) + t.Run("Folder=0 should not be able to get a dashboard in a folder", func(t *testing.T) { + setup() + query := dashboards.GetDashboardQuery{ + Title: util.Pointer("test dash 23"), + FolderUID: util.Pointer(""), + OrgID: 1, + } + + _, err := dashboardStore.GetDashboard(context.Background(), &query) + require.Error(t, err, dashboards.ErrDashboardNotFound) + }) + t.Run("Should be able to get dashboards by IDs & UIDs", func(t *testing.T) { setup() query := dashboards.GetDashboardsQuery{DashboardIDs: []int64{savedDash.ID, savedDash2.ID}} @@ -218,13 +251,12 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { "tags": []interface{}{}, }), Overwrite: true, - FolderID: 2, // nolint:staticcheck + FolderUID: "2", UserID: 100, } dash, err := dashboardStore.SaveDashboard(context.Background(), cmd) require.NoError(t, err) - // nolint:staticcheck - require.EqualValues(t, dash.FolderID, 2) + require.EqualValues(t, dash.FolderUID, "2") cmd = dashboards.SaveDashboardCommand{ OrgID: 1, @@ -233,7 +265,7 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { "title": "folderId", "tags": []interface{}{}, }), - FolderID: 0, // nolint:staticcheck + FolderUID: "", Overwrite: true, UserID: 100, } @@ -247,8 +279,7 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { queryResult, err := dashboardStore.GetDashboard(context.Background(), &query) require.NoError(t, err) - // nolint:staticcheck - require.Equal(t, queryResult.FolderID, int64(0)) + require.Equal(t, queryResult.FolderUID, "") require.Equal(t, queryResult.CreatedBy, savedDash.CreatedBy) require.WithinDuration(t, queryResult.Created, savedDash.Created, 3*time.Second) require.Equal(t, queryResult.UpdatedBy, int64(100)) @@ -398,8 +429,8 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { t.Run("Should be able to find a dashboard folder's children", func(t *testing.T) { setup() query := dashboards.FindPersistedDashboardsQuery{ - OrgId: 1, - FolderIds: []int64{savedFolder.ID}, // nolint:staticcheck + OrgId: 1, + FolderUIDs: []string{savedFolder.UID}, SignedInUser: &user.SignedInUser{ OrgID: 1, OrgRole: org.RoleEditor, @@ -416,8 +447,6 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { hit := hits[0] require.Equal(t, hit.ID, savedDash.ID) require.Equal(t, hit.URL, fmt.Sprintf("/d/%s/%s", savedDash.UID, savedDash.Slug)) - // nolint:staticcheck - require.Equal(t, hit.FolderID, savedFolder.ID) require.Equal(t, hit.FolderUID, savedFolder.UID) require.Equal(t, hit.FolderTitle, savedFolder.Title) require.Equal(t, hit.FolderURL, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.UID, savedFolder.Slug)) @@ -444,8 +473,6 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { hit := hits[0] require.Equal(t, hit.ID, savedDash.ID) require.Equal(t, hit.URL, fmt.Sprintf("/d/%s/%s", savedDash.UID, savedDash.Slug)) - // nolint:staticcheck - require.Equal(t, hit.FolderID, savedFolder.ID) require.Equal(t, hit.FolderUID, savedFolder.UID) require.Equal(t, hit.FolderTitle, savedFolder.Title) require.Equal(t, hit.FolderURL, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.UID, savedFolder.Slug)) @@ -479,17 +506,15 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { t.Run("Can count dashboards by parent folder", func(t *testing.T) { setup() // setup() saves one dashboard in the general folder and two in the "savedFolder". - count, err := dashboardStore.CountDashboardsInFolder( + count, err := dashboardStore.CountDashboardsInFolders( context.Background(), - // nolint:staticcheck - &dashboards.CountDashboardsInFolderRequest{FolderID: 0, OrgID: 1}) + &dashboards.CountDashboardsInFolderRequest{FolderUIDs: []string{""}, OrgID: 1}) require.NoError(t, err) require.Equal(t, int64(1), count) - count, err = dashboardStore.CountDashboardsInFolder( + count, err = dashboardStore.CountDashboardsInFolders( context.Background(), - // nolint:staticcheck - &dashboards.CountDashboardsInFolderRequest{FolderID: savedFolder.ID, OrgID: 1}) + &dashboards.CountDashboardsInFolderRequest{FolderUIDs: []string{savedFolder.UID}, OrgID: 1}) require.NoError(t, err) require.Equal(t, int64(2), count) }) @@ -500,16 +525,15 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { _ = insertTestDashboard(t, dashboardStore, "delete me 1", 1, folder.ID, folder.UID, false, "delete this 1") _ = insertTestDashboard(t, dashboardStore, "delete me 2", 1, folder.ID, folder.UID, false, "delete this 2") - err := dashboardStore.DeleteDashboardsInFolder( + err := dashboardStore.DeleteDashboardsInFolders( context.Background(), &dashboards.DeleteDashboardsInFolderRequest{ - FolderUID: folder.UID, - OrgID: 1, + FolderUIDs: []string{folder.UID}, + OrgID: 1, }) require.NoError(t, err) - // nolint:staticcheck - count, err := dashboardStore.CountDashboardsInFolder(context.Background(), &dashboards.CountDashboardsInFolderRequest{FolderID: 2, OrgID: 1}) + count, err := dashboardStore.CountDashboardsInFolders(context.Background(), &dashboards.CountDashboardsInFolderRequest{FolderUIDs: []string{folder.UID}, OrgID: 1}) require.NoError(t, err) require.Equal(t, count, int64(0)) }) @@ -525,9 +549,10 @@ func TestIntegrationDashboardDataAccessGivenPluginWithImportedDashboards(t *test require.NoError(t, err) pluginId := "test-app" - appFolder := insertTestDashboardForPlugin(t, dashboardStore, "app-test", 1, 0, true, pluginId) - insertTestDashboardForPlugin(t, dashboardStore, "app-dash1", 1, appFolder.ID, false, pluginId) - insertTestDashboardForPlugin(t, dashboardStore, "app-dash2", 1, appFolder.ID, false, pluginId) + insertTestDashboardForPlugin(t, dashboardStore, "app-test", 1, "", true, pluginId) + insertTestDashboardForPlugin(t, dashboardStore, "app-test", 1, "", true, pluginId) + insertTestDashboardForPlugin(t, dashboardStore, "app-dash1", 1, "", false, pluginId) + insertTestDashboardForPlugin(t, dashboardStore, "app-dash2", 1, "", false, pluginId) query := dashboards.GetDashboardsByPluginIDQuery{ PluginID: pluginId, @@ -664,8 +689,7 @@ func TestGetExistingDashboardByTitleAndFolder(t *testing.T) { savedFolder := insertTestDashboard(t, dashboardStore, "test dash folder", 1, 0, "", true, "prod", "webapp") savedDash := insertTestDashboard(t, dashboardStore, "test dash", 1, savedFolder.ID, savedFolder.UID, false, "prod", "webapp") err = sqlStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { - // nolint:staticcheck - _, err = getExistingDashboardByTitleAndFolder(sess, &dashboards.Dashboard{Title: savedDash.Title, FolderID: savedFolder.ID, OrgID: 1}, sqlStore.GetDialect(), false, false) + _, err = getExistingDashboardByTitleAndFolder(sess, &dashboards.Dashboard{Title: savedDash.Title, FolderUID: savedFolder.UID, OrgID: 1}, sqlStore.GetDialect(), false, false) return err }) require.ErrorIs(t, err, dashboards.ErrDashboardWithSameNameInFolderExists) @@ -689,7 +713,7 @@ func TestIntegrationFindDashboardsByTitle(t *testing.T) { ac := acimpl.ProvideAccessControl(sqlStore.Cfg) folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - folderServiceWithFlagOn := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), sqlStore.Cfg, dashboardStore, folderStore, sqlStore, features, nil) + folderServiceWithFlagOn := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), sqlStore.Cfg, dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil) user := &user.SignedInUser{ OrgID: 1, @@ -719,8 +743,8 @@ func TestIntegrationFindDashboardsByTitle(t *testing.T) { SignedInUser: user, }) require.NoError(t, err) - // nolint:staticcheck - insertTestDashboard(t, dashboardStore, "dashboard under f0", orgID, f0.ID, f0.UID, false) + + insertTestDashboard(t, dashboardStore, "dashboard under f0", orgID, 0, f0.UID, false) subfolder, err := folderServiceWithFlagOn.Create(context.Background(), &folder.CreateFolderCommand{ OrgID: orgID, @@ -729,8 +753,8 @@ func TestIntegrationFindDashboardsByTitle(t *testing.T) { SignedInUser: user, }) require.NoError(t, err) - // nolint:staticcheck - insertTestDashboard(t, dashboardStore, "dashboard under subfolder", orgID, subfolder.ID, subfolder.UID, false) + + insertTestDashboard(t, dashboardStore, "dashboard under subfolder", orgID, 0, subfolder.UID, false) type res struct { title string @@ -806,7 +830,7 @@ func TestIntegrationFindDashboardsByFolder(t *testing.T) { ac := acimpl.ProvideAccessControl(sqlStore.Cfg) folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - folderServiceWithFlagOn := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), sqlStore.Cfg, dashboardStore, folderStore, sqlStore, features, nil) + folderServiceWithFlagOn := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), sqlStore.Cfg, dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil) user := &user.SignedInUser{ OrgID: 1, @@ -871,43 +895,6 @@ func TestIntegrationFindDashboardsByFolder(t *testing.T) { expectedResult map[string][]res typ string }{ - { - desc: "find dashboard under general using folder id", - folderIDs: []int64{0}, // nolint:staticcheck - typ: searchstore.TypeDashboard, - expectedResult: map[string][]res{ - "": {{title: "dashboard under general"}}, - featuremgmt.FlagNestedFolders: {{title: "dashboard under general"}}, - }, - }, - { - desc: "find dashboard under general using folder id", - folderIDs: []int64{0}, // nolint:staticcheck - typ: searchstore.TypeDashboard, - expectedResult: map[string][]res{ - "": {{title: "dashboard under general"}}, - featuremgmt.FlagNestedFolders: {{title: "dashboard under general"}}, - }, - }, - { - desc: "find dashboard under f0 using folder id", - folderIDs: []int64{f0.ID}, // nolint:staticcheck - typ: searchstore.TypeDashboard, - expectedResult: map[string][]res{ - "": {{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title}}, - }, - }, - { - desc: "find dashboard under f0 or f1 using folder id", - folderIDs: []int64{f0.ID, f1.ID}, // nolint:staticcheck - typ: searchstore.TypeDashboard, - expectedResult: map[string][]res{ - "": {{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title}, - {title: "dashboard under f1", folderUID: f1.UID, folderTitle: f1.Title}}, - featuremgmt.FlagNestedFolders: {{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title}, - {title: "dashboard under f1", folderUID: f1.UID, folderTitle: f1.Title}}, - }, - }, { desc: "find dashboard under general using folder UID", folderUIDs: []string{folder.GeneralFolderUID}, @@ -946,30 +933,6 @@ func TestIntegrationFindDashboardsByFolder(t *testing.T) { {title: "dashboard under f1", folderUID: f1.UID, folderTitle: f1.Title}}, }, }, - { - desc: "find dashboard under general or f0 using folder id", - folderIDs: []int64{0, f0.ID}, // nolint:staticcheck - typ: searchstore.TypeDashboard, - expectedResult: map[string][]res{ - "": {{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title}, - {title: "dashboard under general"}}, - featuremgmt.FlagNestedFolders: {{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title}, - {title: "dashboard under general"}}, - }, - }, - { - desc: "find dashboard under general or f0 or f1 using folder id", - folderIDs: []int64{0, f0.ID, f1.ID}, // nolint:staticcheck - typ: searchstore.TypeDashboard, - expectedResult: map[string][]res{ - "": {{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title}, - {title: "dashboard under f1", folderUID: f1.UID, folderTitle: f1.Title}, - {title: "dashboard under general"}}, - featuremgmt.FlagNestedFolders: {{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title}, - {title: "dashboard under f1", folderUID: f1.UID, folderTitle: f1.Title}, - {title: "dashboard under general"}}, - }, - }, { desc: "find dashboard under general or f0 using folder UID", folderUIDs: []string{folder.GeneralFolderUID, f0.UID}, @@ -1013,7 +976,6 @@ func TestIntegrationFindDashboardsByFolder(t *testing.T) { res, err := dashboardStore.FindDashboards(context.Background(), &dashboards.FindPersistedDashboardsQuery{ SignedInUser: user, Type: tc.typ, - FolderIds: tc.folderIDs, // nolint:staticcheck FolderUIDs: tc.folderUIDs, }) require.NoError(t, err) @@ -1133,12 +1095,12 @@ func insertTestDashboard(t *testing.T, dashboardStore dashboards.Store, title st } func insertTestDashboardForPlugin(t *testing.T, dashboardStore dashboards.Store, title string, orgId int64, - folderId int64, isFolder bool, pluginId string) *dashboards.Dashboard { + folderUID string, isFolder bool, pluginId string) *dashboards.Dashboard { t.Helper() cmd := dashboards.SaveDashboardCommand{ - OrgID: orgId, - FolderID: folderId, // nolint:staticcheck - IsFolder: isFolder, + OrgID: orgId, + IsFolder: isFolder, + FolderUID: folderUID, Dashboard: simplejson.NewFromAny(map[string]interface{}{ "id": nil, "title": title, @@ -1182,14 +1144,12 @@ func makeQueryResult(query *dashboards.FindPersistedDashboardsQuery, res []dashb URI: "db/" + item.Slug, URL: dashboards.GetDashboardFolderURL(item.IsFolder, item.UID, item.Slug), Type: hitType, - FolderID: item.FolderID, // nolint:staticcheck FolderUID: item.FolderUID, FolderTitle: item.FolderTitle, Tags: []string{}, } - // nolint:staticcheck - if item.FolderID > 0 { + if item.FolderUID != "" { hit.FolderURL = dashboards.GetFolderURL(item.FolderUID, item.FolderSlug) } diff --git a/pkg/services/dashboards/database/migrations/folder_uid_migrator.go b/pkg/services/dashboards/database/migrations/folder_uid_migrator.go index 672279e15a4c9..4db022cfd8197 100644 --- a/pkg/services/dashboards/database/migrations/folder_uid_migrator.go +++ b/pkg/services/dashboards/database/migrations/folder_uid_migrator.go @@ -39,11 +39,14 @@ func (m *FolderUIDMigration) Exec(sess *xorm.Session, mgrtr *migrator.Migrator) } // for folders the source of truth is the folder table + // covered by UQE_folder_org_id_uid q = `UPDATE dashboard SET folder_uid = folder.parent_uid FROM folder WHERE dashboard.uid = folder.uid AND dashboard.org_id = folder.org_id AND dashboard.is_folder = ?` + + // covered by UQE_folder_org_id_uid if mgrtr.Dialect.DriverName() == migrator.MySQL { q = `UPDATE dashboard SET folder_uid = ( @@ -78,4 +81,21 @@ func AddDashboardFolderMigrations(mg *migrator.Migrator) { mg.AddMigration("Add unique index for dashboard_org_id_folder_uid_title", migrator.NewAddIndexMigration(migrator.Table{Name: "dashboard"}, &migrator.Index{ Cols: []string{"org_id", "folder_uid", "title"}, Type: migrator.UniqueIndex, })) + + mg.AddMigration("Delete unique index for dashboard_org_id_folder_id_title", migrator.NewDropIndexMigration(migrator.Table{Name: "dashboard"}, &migrator.Index{ + Cols: []string{"org_id", "folder_id", "title"}, Type: migrator.UniqueIndex, + })) + + mg.AddMigration("Delete unique index for dashboard_org_id_folder_uid_title", migrator.NewDropIndexMigration(migrator.Table{Name: "dashboard"}, &migrator.Index{ + Cols: []string{"org_id", "folder_uid", "title"}, Type: migrator.UniqueIndex, + })) + + mg.AddMigration("Add unique index for dashboard_org_id_folder_uid_title_is_folder", migrator.NewAddIndexMigration(migrator.Table{Name: "dashboard"}, &migrator.Index{ + Cols: []string{"org_id", "folder_uid", "title", "is_folder"}, Type: migrator.UniqueIndex, + })) + + // Temporary index until decommisioning of folder_id in query + mg.AddMigration("Restore index for dashboard_org_id_folder_id_title", migrator.NewAddIndexMigration(migrator.Table{Name: "dashboard"}, &migrator.Index{ + Cols: []string{"org_id", "folder_id", "title"}, + })) } diff --git a/pkg/services/dashboards/errors.go b/pkg/services/dashboards/errors.go index 6cbc6d9d3335f..307f18fc50e86 100644 --- a/pkg/services/dashboards/errors.go +++ b/pkg/services/dashboards/errors.go @@ -122,7 +122,7 @@ var ( ErrFolderTitleEmpty = errors.New("folder title cannot be empty") ErrFolderWithSameUIDExists = errors.New("a folder/dashboard with the same uid already exists") ErrFolderInvalidUID = errors.New("invalid uid for folder provided") - ErrFolderSameNameExists = errors.New("a folder or dashboard in the general folder with the same name already exists") + ErrFolderSameNameExists = errors.New("a folder with the same name already exists in the current location") ErrFolderAccessDenied = errors.New("access denied to folder") ) diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 021a753cac662..4df6dc95153ba 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -4,12 +4,9 @@ import ( "fmt" "time" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/slugify" - "github.com/grafana/grafana/pkg/kinds" - "github.com/grafana/grafana/pkg/kinds/dashboard" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" @@ -67,55 +64,6 @@ func (d *Dashboard) SetVersion(version int) { d.Data.Set("version", version) } -func (d *Dashboard) ToResource() kinds.GrafanaResource[simplejson.Json, any] { - parent := dashboard.NewK8sResource(d.UID, nil) - res := kinds.GrafanaResource[simplejson.Json, any]{ - Kind: parent.Kind, - APIVersion: parent.APIVersion, - Metadata: kinds.GrafanaResourceMetadata{ - Name: d.UID, - Annotations: make(map[string]string), - Labels: make(map[string]string), - CreationTimestamp: v1.NewTime(d.Created), - ResourceVersion: fmt.Sprintf("%d", d.Version), - }, - } - if d.Data != nil { - copy := &simplejson.Json{} - db, _ := d.Data.ToDB() - _ = copy.FromDB(db) - - copy.Del("id") - copy.Del("version") // ??? - copy.Del("uid") // duplicated to name - res.Spec = copy - } - - d.UpdateSlug() - res.Metadata.SetUpdatedTimestamp(&d.Updated) - res.Metadata.SetSlug(d.Slug) - if d.CreatedBy > 0 { - res.Metadata.SetCreatedBy(fmt.Sprintf("user:%d", d.CreatedBy)) - } - if d.UpdatedBy > 0 { - res.Metadata.SetUpdatedBy(fmt.Sprintf("user:%d", d.UpdatedBy)) - } - if d.PluginID != "" { - res.Metadata.SetOriginInfo(&kinds.ResourceOriginInfo{ - Name: "plugin", - Key: d.PluginID, - }) - } - // nolint:staticcheck - if d.FolderID > 0 { - res.Metadata.SetFolder(fmt.Sprintf("folder:%d", d.FolderID)) - } - if d.IsFolder { - res.Kind = "Folder" - } - return res -} - // NewDashboard creates a new dashboard func NewDashboard(title string) *Dashboard { dash := &Dashboard{} @@ -189,6 +137,7 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard { dash.OrgID = cmd.OrgID dash.PluginID = cmd.PluginID dash.IsFolder = cmd.IsFolder + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck dash.FolderID = cmd.FolderID dash.FolderUID = cmd.FolderUID @@ -271,6 +220,7 @@ type DashboardProvisioning struct { type DeleteDashboardCommand struct { ID int64 + UID string OrgID int64 ForceDeleteFolderRules bool } @@ -301,8 +251,9 @@ type GetDashboardQuery struct { UID string Title *string // Deprecated: use FolderUID instead - FolderID *int64 - OrgID int64 + FolderID *int64 + FolderUID *string + OrgID int64 } type DashboardTagCloudItem struct { @@ -372,12 +323,12 @@ type CountDashboardsInFolderQuery struct { // to the store layer. The FolderID will be replaced with FolderUID when // dashboards are updated with parent folder UIDs. type CountDashboardsInFolderRequest struct { - // Deprecated: use FolderUID instead - FolderID int64 - OrgID int64 + FolderUIDs []string + OrgID int64 } func FromDashboard(dash *Dashboard) *folder.Folder { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() return &folder.Folder{ ID: dash.ID, // nolint:staticcheck UID: dash.UID, @@ -394,8 +345,8 @@ func FromDashboard(dash *Dashboard) *folder.Folder { } type DeleteDashboardsInFolderRequest struct { - FolderUID string - OrgID int64 + FolderUIDs []string + OrgID int64 } // @@ -448,27 +399,6 @@ type DashboardACLInfoDTO struct { Inherited bool `json:"inherited"` } -func (dto *DashboardACLInfoDTO) hasSameRoleAs(other *DashboardACLInfoDTO) bool { - if dto.Role == nil || other.Role == nil { - return false - } - - return dto.UserID <= 0 && dto.TeamID <= 0 && dto.UserID == other.UserID && dto.TeamID == other.TeamID && *dto.Role == *other.Role -} - -func (dto *DashboardACLInfoDTO) hasSameUserAs(other *DashboardACLInfoDTO) bool { - return dto.UserID > 0 && dto.UserID == other.UserID -} - -func (dto *DashboardACLInfoDTO) hasSameTeamAs(other *DashboardACLInfoDTO) bool { - return dto.TeamID > 0 && dto.TeamID == other.TeamID -} - -// IsDuplicateOf returns true if other item has same role, same user or same team -func (dto *DashboardACLInfoDTO) IsDuplicateOf(other *DashboardACLInfoDTO) bool { - return dto.hasSameRoleAs(other) || dto.hasSameUserAs(other) || dto.hasSameTeamAs(other) -} - type FindPersistedDashboardsQuery struct { Title string OrgId int64 diff --git a/pkg/services/dashboards/models_test.go b/pkg/services/dashboards/models_test.go index 0e6768ce23acc..06aeb1eba6094 100644 --- a/pkg/services/dashboards/models_test.go +++ b/pkg/services/dashboards/models_test.go @@ -1,10 +1,7 @@ package dashboards import ( - "encoding/json" - "fmt" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -66,12 +63,10 @@ func TestSaveDashboardCommand_GetDashboardModel(t *testing.T) { json := simplejson.New() json.Set("title", "test dash") - // nolint:staticcheck - cmd := &SaveDashboardCommand{Dashboard: json, FolderID: 1, FolderUID: "1"} + cmd := &SaveDashboardCommand{Dashboard: json, FolderUID: "1"} dash := cmd.GetDashboardModel() - // nolint:staticcheck - assert.Equal(t, int64(1), dash.FolderID) + assert.Equal(t, "1", dash.FolderUID) }) } @@ -91,54 +86,3 @@ func TestSlugifyTitle(t *testing.T) { }) } } - -func TestResourceConversion(t *testing.T) { - body := simplejson.New() - body.Set("title", "test dash") - body.Set("tags", []string{"hello", "world"}) - - dash := NewDashboardFromJson(body) - dash.SetUID("TheUID") - dash.SetVersion(10) - dash.Created = time.UnixMilli(946713600000).UTC() // 2000-01-01 - dash.Updated = time.UnixMilli(1262332800000).UTC() // 2010-01-01 - dash.CreatedBy = 10 - dash.UpdatedBy = 11 - dash.PluginID = "plugin-xyz" - // nolint:staticcheck - dash.FolderID = 1234 - dash.SetID(12345) // should be removed in resource version - - dst := dash.ToResource() - require.Equal(t, int64(12345), dash.ID) - require.Equal(t, int64(12345), dash.Data.Get("id").MustInt64(0)) - - out, err := json.MarshalIndent(dst, "", " ") - require.NoError(t, err) - fmt.Printf("%s", string(out)) - require.JSONEq(t, `{ - "apiVersion": "v0-0-alpha", - "kind": "Dashboard", - "metadata": { - "name": "TheUID", - "resourceVersion": "10", - "creationTimestamp": "2000-01-01T08:00:00Z", - "annotations": { - "grafana.app/createdBy": "user:10", - "grafana.app/folder": "folder:1234", - "grafana.app/originKey": "plugin-xyz", - "grafana.app/originName": "plugin", - "grafana.app/slug": "test-dash", - "grafana.app/updatedBy": "user:11", - "grafana.app/updatedTimestamp": "2010-01-01T08:00:00Z" - } - }, - "spec": { - "tags": [ - "hello", - "world" - ], - "title": "test dash" - } - }`, string(out)) -} diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 49ee617d39a3f..3836df0f20282 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -13,8 +13,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" @@ -49,7 +49,6 @@ type DashboardServiceImpl struct { dashboardStore dashboards.Store folderStore folder.FolderStore folderService folder.Service - dashAlertExtractor alerting.DashAlertExtractor features featuremgmt.FeatureToggles folderPermissions accesscontrol.FolderPermissionsService dashboardPermissions accesscontrol.DashboardPermissionsService @@ -59,7 +58,7 @@ type DashboardServiceImpl struct { // This is the uber service that implements a three smaller services func ProvideDashboardServiceImpl( - cfg *setting.Cfg, dashboardStore dashboards.Store, folderStore folder.FolderStore, dashAlertExtractor alerting.DashAlertExtractor, + cfg *setting.Cfg, dashboardStore dashboards.Store, folderStore folder.FolderStore, features featuremgmt.FeatureToggles, folderPermissionsService accesscontrol.FolderPermissionsService, dashboardPermissionsService accesscontrol.DashboardPermissionsService, ac accesscontrol.AccessControl, folderSvc folder.Service, r prometheus.Registerer, @@ -68,7 +67,6 @@ func ProvideDashboardServiceImpl( cfg: cfg, log: log.New("dashboard-service"), dashboardStore: dashboardStore, - dashAlertExtractor: dashAlertExtractor, features: features, folderPermissions: folderPermissionsService, dashboardPermissions: dashboardPermissionsService, @@ -101,7 +99,7 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(ctx co } //nolint:gocyclo -func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, dto *dashboards.SaveDashboardDTO, shouldValidateAlerts bool, +func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, dto *dashboards.SaveDashboardDTO, validateProvisionedDashboard bool) (*dashboards.SaveDashboardCommand, error) { dash := dto.Dashboard @@ -114,6 +112,7 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d return nil, dashboards.ErrDashboardTitleEmpty } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck if dash.IsFolder && dash.FolderID > 0 { return nil, dashboards.ErrDashboardFolderCannotHaveParent @@ -129,26 +128,21 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d return nil, dashboards.ErrDashboardUidTooLong } - if err := validateDashboardRefreshInterval(dash); err != nil { + if err := validateDashboardRefreshInterval(dr.cfg.MinRefreshInterval, dash); err != nil { return nil, err } - if shouldValidateAlerts { - dashAlertInfo := alerting.DashAlertInfo{Dash: dash, User: dto.User, OrgID: dash.OrgID} - if err := dr.dashAlertExtractor.ValidateAlerts(ctx, dashAlertInfo); err != nil { - return nil, err - } - } - // Validate folder if dash.FolderUID != "" { folder, err := dr.folderStore.GetFolderByUID(ctx, dash.OrgID, dash.FolderUID) if err != nil { return nil, err } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck dash.FolderID = folder.ID } else if dash.FolderID != 0 { // nolint:staticcheck + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck folder, err := dr.folderStore.GetFolderByID(ctx, dash.OrgID, dash.FolderID) if err != nil { @@ -168,6 +162,7 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d if err != nil { return nil, err } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck if canSave, err := guardian.CanCreate(dash.FolderID, dash.IsFolder); err != nil || !canSave { if err != nil { @@ -194,6 +189,7 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d } if dash.ID == 0 { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck if canCreate, err := guard.CanCreate(dash.FolderID, dash.IsFolder); err != nil || !canCreate { if err != nil { @@ -215,6 +211,7 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d return nil, err } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() cmd := &dashboards.SaveDashboardCommand{ Dashboard: dash.Data, Message: dto.Message, @@ -260,6 +257,7 @@ func getGuardianForSavePermissionCheck(ctx context.Context, d *dashboards.Dashbo if newDashboard { // if it's a new dashboard/folder check the parent folder permissions + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck guard, err := guardian.New(ctx, d.FolderID, d.OrgID, user) if err != nil { @@ -274,8 +272,8 @@ func getGuardianForSavePermissionCheck(ctx context.Context, d *dashboards.Dashbo return guard, nil } -func validateDashboardRefreshInterval(dash *dashboards.Dashboard) error { - if setting.MinRefreshInterval == "" { +func validateDashboardRefreshInterval(minRefreshInterval string, dash *dashboards.Dashboard) error { + if minRefreshInterval == "" { return nil } @@ -285,16 +283,16 @@ func validateDashboardRefreshInterval(dash *dashboards.Dashboard) error { return nil } - minRefreshInterval, err := gtime.ParseDuration(setting.MinRefreshInterval) + minRefreshIntervalDur, err := gtime.ParseDuration(minRefreshInterval) if err != nil { - return fmt.Errorf("parsing min refresh interval %q failed: %w", setting.MinRefreshInterval, err) + return fmt.Errorf("parsing min refresh interval %q failed: %w", minRefreshInterval, err) } d, err := gtime.ParseDuration(refresh) if err != nil { return fmt.Errorf("parsing refresh duration %q failed: %w", refresh, err) } - if d < minRefreshInterval { + if d < minRefreshIntervalDur { return dashboards.ErrDashboardRefreshIntervalTooShort } @@ -303,15 +301,15 @@ func validateDashboardRefreshInterval(dash *dashboards.Dashboard) error { func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dto *dashboards.SaveDashboardDTO, provisioning *dashboards.DashboardProvisioning) (*dashboards.Dashboard, error) { - if err := validateDashboardRefreshInterval(dto.Dashboard); err != nil { + if err := validateDashboardRefreshInterval(dr.cfg.MinRefreshInterval, dto.Dashboard); err != nil { dr.log.Warn("Changing refresh interval for provisioned dashboard to minimum refresh interval", "dashboardUid", - dto.Dashboard.UID, "dashboardTitle", dto.Dashboard.Title, "minRefreshInterval", setting.MinRefreshInterval) - dto.Dashboard.Data.Set("refresh", setting.MinRefreshInterval) + dto.Dashboard.UID, "dashboardTitle", dto.Dashboard.Title, "minRefreshInterval", dr.cfg.MinRefreshInterval) + dto.Dashboard.Data.Set("refresh", dr.cfg.MinRefreshInterval) } dto.User = accesscontrol.BackgroundUser("dashboard_provisioning", dto.OrgID, org.RoleAdmin, provisionerPermissions) - cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, setting.IsLegacyAlertingEnabled(), false) + cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, false) if err != nil { return nil, err } @@ -322,26 +320,6 @@ func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dt return nil, err } - // alerts - dashAlertInfo := alerting.DashAlertInfo{ - User: dto.User, - Dash: dash, - OrgID: dto.OrgID, - } - - // extract/save legacy alerts only if legacy alerting is enabled - if setting.IsLegacyAlertingEnabled() { - alerts, err := dr.dashAlertExtractor.GetAlerts(ctx, dashAlertInfo) - if err != nil { - return nil, err - } - - err = dr.dashboardStore.SaveAlerts(ctx, dash.ID, alerts) - if err != nil { - return nil, err - } - } - if dto.Dashboard.ID == 0 { dr.setDefaultPermissions(ctx, dto, dash, true) } @@ -349,54 +327,29 @@ func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dt return dash, nil } -func (dr *DashboardServiceImpl) SaveFolderForProvisionedDashboards(ctx context.Context, dto *dashboards.SaveDashboardDTO) (*dashboards.Dashboard, error) { - dto.User = accesscontrol.BackgroundUser("dashboard_provisioning", dto.OrgID, org.RoleAdmin, provisionerPermissions) - cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, false, false) - if err != nil { - return nil, err - } +func (dr *DashboardServiceImpl) SaveFolderForProvisionedDashboards(ctx context.Context, dto *folder.CreateFolderCommand) (*folder.Folder, error) { + dto.SignedInUser = accesscontrol.BackgroundUser("dashboard_provisioning", dto.OrgID, org.RoleAdmin, provisionerPermissions) - dash, err := dr.dashboardStore.SaveDashboard(ctx, *cmd) + f, err := dr.folderService.Create(ctx, dto) if err != nil { + dr.log.Error("failed to create folder for provisioned dashboards", "folder", dto.Title, "org", dto.OrgID, "err", err) return nil, err } - dashAlertInfo := alerting.DashAlertInfo{ - User: dto.User, - Dash: dash, - OrgID: dto.OrgID, - } - - // extract/save legacy alerts only if legacy alerting is enabled - if setting.IsLegacyAlertingEnabled() { - alerts, err := dr.dashAlertExtractor.GetAlerts(ctx, dashAlertInfo) - if err != nil { - return nil, err - } - - err = dr.dashboardStore.SaveAlerts(ctx, dash.ID, alerts) - if err != nil { - return nil, err - } - } - - if dto.Dashboard.ID == 0 { - dr.setDefaultPermissions(ctx, dto, dash, true) - } - - return dash, nil + dr.setDefaultFolderPermissions(ctx, dto, f, true) + return f, nil } func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *dashboards.SaveDashboardDTO, allowUiUpdate bool) (*dashboards.Dashboard, error) { - if err := validateDashboardRefreshInterval(dto.Dashboard); err != nil { + if err := validateDashboardRefreshInterval(dr.cfg.MinRefreshInterval, dto.Dashboard); err != nil { dr.log.Warn("Changing refresh interval for imported dashboard to minimum refresh interval", "dashboardUid", dto.Dashboard.UID, "dashboardTitle", dto.Dashboard.Title, "minRefreshInterval", - setting.MinRefreshInterval) - dto.Dashboard.Data.Set("refresh", setting.MinRefreshInterval) + dr.cfg.MinRefreshInterval) + dto.Dashboard.Data.Set("refresh", dr.cfg.MinRefreshInterval) } - cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, setting.IsLegacyAlertingEnabled(), !allowUiUpdate) + cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, !allowUiUpdate) if err != nil { return nil, err } @@ -406,25 +359,6 @@ func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *dashboar return nil, fmt.Errorf("saving dashboard failed: %w", err) } - dashAlertInfo := alerting.DashAlertInfo{ - User: dto.User, - Dash: dash, - OrgID: dto.OrgID, - } - - // extract/save legacy alerts only if legacy alerting is enabled - if setting.IsLegacyAlertingEnabled() { - alerts, err := dr.dashAlertExtractor.GetAlerts(ctx, dashAlertInfo) - if err != nil { - return nil, err - } - - err = dr.dashboardStore.SaveAlerts(ctx, dash.ID, alerts) - if err != nil { - return nil, err - } - } - // new dashboard created if dto.Dashboard.ID == 0 { dr.setDefaultPermissions(ctx, dto, dash, false) @@ -465,14 +399,14 @@ func (dr *DashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId func (dr *DashboardServiceImpl) ImportDashboard(ctx context.Context, dto *dashboards.SaveDashboardDTO) ( *dashboards.Dashboard, error) { - if err := validateDashboardRefreshInterval(dto.Dashboard); err != nil { + if err := validateDashboardRefreshInterval(dr.cfg.MinRefreshInterval, dto.Dashboard); err != nil { dr.log.Warn("Changing refresh interval for imported dashboard to minimum refresh interval", "dashboardUid", dto.Dashboard.UID, "dashboardTitle", dto.Dashboard.Title, - "minRefreshInterval", setting.MinRefreshInterval) - dto.Dashboard.Data.Set("refresh", setting.MinRefreshInterval) + "minRefreshInterval", dr.cfg.MinRefreshInterval) + dto.Dashboard.Data.Set("refresh", dr.cfg.MinRefreshInterval) } - cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, false, true) + cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, true) if err != nil { return nil, err } @@ -498,6 +432,7 @@ func (dr *DashboardServiceImpl) GetDashboardsByPluginID(ctx context.Context, que } func (dr *DashboardServiceImpl) setDefaultPermissions(ctx context.Context, dto *dashboards.SaveDashboardDTO, dash *dashboards.Dashboard, provisioned bool) { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck inFolder := dash.FolderID > 0 var permissions []accesscontrol.SetResourcePermissionCommand @@ -532,6 +467,35 @@ func (dr *DashboardServiceImpl) setDefaultPermissions(ctx context.Context, dto * } } +func (dr *DashboardServiceImpl) setDefaultFolderPermissions(ctx context.Context, cmd *folder.CreateFolderCommand, f *folder.Folder, provisioned bool) { + inFolder := f.ParentUID != "" + var permissions []accesscontrol.SetResourcePermissionCommand + + if !provisioned { + namespaceID, userIDstr := cmd.SignedInUser.GetNamespacedID() + userID, err := identity.IntIdentifier(namespaceID, userIDstr) + + if err != nil { + dr.log.Error("Could not make user admin", "folder", cmd.Title, "namespaceID", namespaceID, "userID", userID, "error", err) + } else if namespaceID == identity.NamespaceUser && userID > 0 { + permissions = append(permissions, accesscontrol.SetResourcePermissionCommand{ + UserID: userID, Permission: dashboardaccess.PERMISSION_ADMIN.String(), + }) + } + } + + if !inFolder { + permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{ + {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, + {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()}, + }...) + } + + if _, err := dr.folderPermissions.SetPermissions(ctx, cmd.OrgID, f.UID, permissions...); err != nil { + dr.log.Error("Could not set default folder permissions", "folder", f.Title, "error", err) + } +} + func (dr *DashboardServiceImpl) GetDashboard(ctx context.Context, query *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) { return dr.dashboardStore.GetDashboard(ctx, query) } @@ -585,35 +549,30 @@ func (dr *DashboardServiceImpl) filterUserSharedDashboards(ctx context.Context, folderUIDs = append(folderUIDs, dashboard.FolderUID) } - dashFolders, err := dr.folderStore.GetFolders(ctx, user.GetOrgID(), folderUIDs) + // GetFolders return only folders available to user. So we can use is to check access. + userDashFolders, err := dr.folderService.GetFolders(ctx, folder.GetFoldersQuery{ + UIDs: folderUIDs, + OrgID: user.GetOrgID(), + OrderByTitle: true, + SignedInUser: user, + }) if err != nil { return nil, folder.ErrInternal.Errorf("failed to fetch parent folders from store: %w", err) } + dashFoldersMap := make(map[string]*folder.Folder, 0) + for _, f := range userDashFolders { + dashFoldersMap[f.UID] = f + } + for _, dashboard := range userDashboards { // Filter out dashboards if user has access to parent folder if dashboard.FolderUID == "" { continue } - dashFolder, ok := dashFolders[dashboard.FolderUID] - if !ok { - dr.log.Error("failed to fetch folder by UID from store", "uid", dashboard.FolderUID) - continue - } - - g, err := guardian.NewByFolder(ctx, dashFolder, user.GetOrgID(), user) - if err != nil { - dr.log.Error("failed to check folder permissions", "folder uid", dashboard.FolderUID, "error", err) - continue - } - - canView, err := g.CanView() - if err != nil { - dr.log.Error("failed to fetch dashboard", "uid", dashboard.UID, "error", err) - continue - } - if !canView { + _, hasAccess := dashFoldersMap[dashboard.FolderUID] + if !hasAccess { filteredDashboards = append(filteredDashboards, dashboard) } } @@ -682,6 +641,7 @@ func makeQueryResult(query *dashboards.FindPersistedDashboardsQuery, res []dashb for _, item := range res { hit, exists := hits[item.ID] if !exists { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() hit = &model.Hit{ ID: item.ID, UID: item.UID, @@ -719,18 +679,12 @@ func (dr *DashboardServiceImpl) GetDashboardTags(ctx context.Context, query *das return dr.dashboardStore.GetDashboardTags(ctx, query) } -func (dr DashboardServiceImpl) CountInFolder(ctx context.Context, orgID int64, folderUID string, u identity.Requester) (int64, error) { - folder, err := dr.folderService.Get(ctx, &folder.GetFolderQuery{UID: &folderUID, OrgID: orgID, SignedInUser: u}) - if err != nil { - return 0, err - } - - // nolint:staticcheck - return dr.dashboardStore.CountDashboardsInFolder(ctx, &dashboards.CountDashboardsInFolderRequest{FolderID: folder.ID, OrgID: orgID}) +func (dr DashboardServiceImpl) CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, u identity.Requester) (int64, error) { + return dr.dashboardStore.CountDashboardsInFolders(ctx, &dashboards.CountDashboardsInFolderRequest{FolderUIDs: folderUIDs, OrgID: orgID}) } -func (dr *DashboardServiceImpl) DeleteInFolder(ctx context.Context, orgID int64, folderUID string, u identity.Requester) error { - return dr.dashboardStore.DeleteDashboardsInFolder(ctx, &dashboards.DeleteDashboardsInFolderRequest{FolderUID: folderUID, OrgID: orgID}) +func (dr *DashboardServiceImpl) DeleteInFolders(ctx context.Context, orgID int64, folderUIDs []string, u identity.Requester) error { + return dr.dashboardStore.DeleteDashboardsInFolders(ctx, &dashboards.DeleteDashboardsInFolderRequest{FolderUIDs: folderUIDs, OrgID: orgID}) } func (dr *DashboardServiceImpl) Kind() string { return entity.StandardKindDashboard } diff --git a/pkg/services/dashboards/service/dashboard_service_integration_test.go b/pkg/services/dashboards/service/dashboard_service_integration_test.go index cc445995917d9..448dad895b615 100644 --- a/pkg/services/dashboards/service/dashboard_service_integration_test.go +++ b/pkg/services/dashboards/service/dashboard_service_integration_test.go @@ -13,8 +13,6 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/database" @@ -27,10 +25,15 @@ import ( "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) const testOrgID int64 = 1 +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationIntegratedDashboardService(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -128,7 +131,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Dashboard: simplejson.NewFromAny(map[string]any{ "title": "Dash", }), - FolderID: sc.otherSavedFolder.ID, // nolint:staticcheck FolderUID: sc.otherSavedFolder.UID, UserID: 10000, Overwrite: true, @@ -152,7 +154,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Dashboard: simplejson.NewFromAny(map[string]any{ "title": sc.savedDashInFolder.Title, }), - FolderID: sc.savedFolder.ID, // nolint:staticcheck FolderUID: sc.savedFolder.UID, UserID: 10000, Overwrite: true, @@ -177,7 +178,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "uid": sc.savedDashInFolder.UID, "title": "New dash", }), - FolderID: sc.savedFolder.ID, // nolint:staticcheck FolderUID: sc.savedFolder.UID, UserID: 10000, Overwrite: true, @@ -202,7 +202,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "id": sc.savedDashInGeneralFolder.ID, "title": "Dash", }), - FolderID: sc.savedDashInGeneralFolder.FolderID, // nolint:staticcheck FolderUID: sc.savedDashInGeneralFolder.FolderUID, UserID: 10000, Overwrite: true, @@ -227,7 +226,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "id": sc.savedDashInFolder.ID, "title": "Dash", }), - FolderID: sc.savedDashInFolder.FolderID, // nolint:staticcheck FolderUID: sc.savedDashInFolder.FolderUID, UserID: 10000, Overwrite: true, @@ -252,7 +250,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "id": sc.savedDashInGeneralFolder.ID, "title": "Dash", }), - FolderID: sc.otherSavedFolder.ID, // nolint:staticcheck FolderUID: sc.otherSavedFolder.UID, UserID: 10000, Overwrite: true, @@ -277,7 +274,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "id": sc.savedDashInFolder.ID, "title": "Dash", }), - FolderID: 0, // nolint:staticcheck FolderUID: "", UserID: 10000, Overwrite: true, @@ -302,7 +298,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "uid": sc.savedDashInGeneralFolder.UID, "title": "Dash", }), - FolderID: sc.otherSavedFolder.ID, // nolint:staticcheck FolderUID: sc.otherSavedFolder.UID, UserID: 10000, Overwrite: true, @@ -327,7 +322,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "uid": sc.savedDashInFolder.UID, "title": "Dash", }), - FolderID: 0, // nolint:staticcheck FolderUID: "", UserID: 10000, Overwrite: true, @@ -359,7 +353,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "id": nil, "title": sc.savedDashInFolder.Title, }), - FolderID: 0, // nolint:staticcheck FolderUID: "", Overwrite: shouldOverwrite, } @@ -383,7 +376,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "id": nil, "title": sc.savedDashInGeneralFolder.Title, }), - FolderID: sc.savedFolder.ID, // nolint:staticcheck FolderUID: sc.savedFolder.UID, Overwrite: shouldOverwrite, } @@ -475,7 +467,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Dashboard: simplejson.NewFromAny(map[string]any{ "title": "Expect error", }), - FolderID: 123412321, // nolint:staticcheck FolderUID: "123412321", Overwrite: shouldOverwrite, } @@ -492,7 +483,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "id": sc.savedDashInGeneralFolder.ID, "title": "test dash 23", }), - FolderID: sc.savedFolder.ID, // nolint:staticcheck FolderUID: sc.savedFolder.UID, Overwrite: shouldOverwrite, } @@ -510,7 +500,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "title": "Updated title", "version": sc.savedDashInGeneralFolder.Version, }), - FolderID: sc.savedFolder.ID, // nolint:staticcheck FolderUID: sc.savedFolder.UID, Overwrite: shouldOverwrite, } @@ -534,7 +523,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "uid": sc.savedDashInFolder.UID, "title": "test dash 23", }), - FolderID: 0, // nolint:staticcheck FolderUID: "", Overwrite: shouldOverwrite, } @@ -552,7 +540,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "title": "Updated title", "version": sc.savedDashInFolder.Version, }), - FolderID: 0, // nolint:staticcheck FolderUID: "", Overwrite: shouldOverwrite, } @@ -575,7 +562,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "id": nil, "title": sc.savedDashInFolder.Title, }), - FolderID: sc.savedDashInFolder.FolderID, // nolint:staticcheck FolderUID: sc.savedDashInFolder.FolderUID, Overwrite: shouldOverwrite, } @@ -592,7 +578,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "id": nil, "title": sc.savedDashInGeneralFolder.Title, }), - FolderID: sc.savedDashInGeneralFolder.FolderID, // nolint:staticcheck FolderUID: sc.savedDashInGeneralFolder.FolderUID, Overwrite: shouldOverwrite, } @@ -629,7 +614,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "id": sc.savedDashInGeneralFolder.ID, "title": "Updated title", }), - FolderID: sc.savedFolder.ID, // nolint:staticcheck FolderUID: sc.savedFolder.UID, Overwrite: shouldOverwrite, } @@ -652,7 +636,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "uid": sc.savedDashInFolder.UID, "title": "Updated title", }), - FolderID: 0, // nolint:staticcheck FolderUID: "", Overwrite: shouldOverwrite, } @@ -715,7 +698,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "id": nil, "title": sc.savedDashInFolder.Title, }), - FolderID: sc.savedDashInFolder.FolderID, // nolint:staticcheck FolderUID: sc.savedDashInFolder.FolderUID, Overwrite: shouldOverwrite, } @@ -740,7 +722,6 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { "id": nil, "title": sc.savedDashInGeneralFolder.Title, }), - FolderID: sc.savedDashInGeneralFolder.FolderID, // nolint:staticcheck FolderUID: sc.savedDashInGeneralFolder.FolderUID, Overwrite: shouldOverwrite, } @@ -887,7 +868,7 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc folderPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) dashboardPermissions := accesscontrolmock.NewMockedPermissionsService() dashboardService, err := ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, &dummyDashAlertExtractor{}, + cfg, dashboardStore, folderStore, featuremgmt.WithFeatures(), folderPermissions, dashboardPermissions, @@ -899,25 +880,21 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc guardian.InitAccessControlGuardian(cfg, ac, dashboardService) savedFolder := saveTestFolder(t, "Saved folder", testOrgID, sqlStore) - savedDashInFolder := saveTestDashboard(t, "Saved dash in folder", testOrgID, savedFolder.ID, savedFolder.UID, sqlStore) - saveTestDashboard(t, "Other saved dash in folder", testOrgID, savedFolder.ID, savedFolder.UID, sqlStore) - savedDashInGeneralFolder := saveTestDashboard(t, "Saved dashboard in general folder", testOrgID, 0, "", sqlStore) + savedDashInFolder := saveTestDashboard(t, "Saved dash in folder", testOrgID, savedFolder.UID, sqlStore) + saveTestDashboard(t, "Other saved dash in folder", testOrgID, savedFolder.UID, sqlStore) + savedDashInGeneralFolder := saveTestDashboard(t, "Saved dashboard in general folder", testOrgID, "", sqlStore) otherSavedFolder := saveTestFolder(t, "Other saved folder", testOrgID, sqlStore) require.Equal(t, "Saved folder", savedFolder.Title) require.Equal(t, "saved-folder", savedFolder.Slug) require.NotEqual(t, int64(0), savedFolder.ID) require.True(t, savedFolder.IsFolder) - // nolint:staticcheck - require.Equal(t, int64(0), savedFolder.FolderID) require.NotEmpty(t, savedFolder.UID) require.Equal(t, "Saved dash in folder", savedDashInFolder.Title) require.Equal(t, "saved-dash-in-folder", savedDashInFolder.Slug) require.NotEqual(t, int64(0), savedDashInFolder.ID) require.False(t, savedDashInFolder.IsFolder) - // nolint:staticcheck - require.Equal(t, savedFolder.ID, savedDashInFolder.FolderID) require.NotEmpty(t, savedDashInFolder.UID) origNewDashboardGuardian := guardian.New @@ -956,7 +933,7 @@ func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSt dashboardPermissions := accesscontrolmock.NewMockedPermissionsService() dashboardPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) service, err := ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, &dummyDashAlertExtractor{}, + cfg, dashboardStore, folderStore, featuremgmt.WithFeatures(), folderPermissions, dashboardPermissions, @@ -980,7 +957,7 @@ func callSaveWithError(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSto require.NoError(t, err) folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) service, err := ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, &dummyDashAlertExtractor{}, + cfg, dashboardStore, folderStore, featuremgmt.WithFeatures(), accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.NewMockedPermissionsService(), @@ -993,12 +970,11 @@ func callSaveWithError(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSto return err } -func saveTestDashboard(t *testing.T, title string, orgID, folderID int64, folderUID string, sqlStore db.DB) *dashboards.Dashboard { +func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string, sqlStore db.DB) *dashboards.Dashboard { t.Helper() cmd := dashboards.SaveDashboardCommand{ OrgID: orgID, - FolderID: folderID, // nolint:staticcheck FolderUID: folderUID, IsFolder: false, Dashboard: simplejson.NewFromAny(map[string]any{ @@ -1024,7 +1000,7 @@ func saveTestDashboard(t *testing.T, title string, orgID, folderID int64, folder dashboardPermissions := accesscontrolmock.NewMockedPermissionsService() dashboardPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) service, err := ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, &dummyDashAlertExtractor{}, + cfg, dashboardStore, folderStore, features, accesscontrolmock.NewMockedPermissionsService(), dashboardPermissions, @@ -1044,7 +1020,6 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *da t.Helper() cmd := dashboards.SaveDashboardCommand{ OrgID: orgID, - FolderID: 0, // nolint:staticcheck FolderUID: "", IsFolder: true, Dashboard: simplejson.NewFromAny(map[string]any{ @@ -1075,7 +1050,7 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *da folderPermissions := accesscontrolmock.NewMockedPermissionsService() folderPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) service, err := ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, &dummyDashAlertExtractor{}, + cfg, dashboardStore, folderStore, featuremgmt.WithFeatures(), folderPermissions, accesscontrolmock.NewMockedPermissionsService(), @@ -1101,14 +1076,3 @@ func toSaveDashboardDto(cmd dashboards.SaveDashboardCommand) dashboards.SaveDash Overwrite: cmd.Overwrite, } } - -type dummyDashAlertExtractor struct { -} - -func (d *dummyDashAlertExtractor) GetAlerts(ctx context.Context, dashAlertInfo alerting.DashAlertInfo) ([]*models.Alert, error) { - return nil, nil -} - -func (d *dummyDashAlertExtractor) ValidateAlerts(ctx context.Context, dashAlertInfo alerting.DashAlertInfo) error { - return nil -} diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 8291f8474ea28..b13f0bbb666b6 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -2,7 +2,6 @@ package service import ( "context" - "errors" "testing" "github.com/stretchr/testify/mock" @@ -17,7 +16,6 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" ) func TestDashboardService(t *testing.T) { @@ -28,11 +26,10 @@ func TestDashboardService(t *testing.T) { folderSvc := foldertest.NewFakeService() service := &DashboardServiceImpl{ - cfg: setting.NewCfg(), - log: log.New("test.logger"), - dashboardStore: &fakeStore, - folderService: folderSvc, - dashAlertExtractor: &dummyDashAlertExtractor{}, + cfg: setting.NewCfg(), + log: log.New("test.logger"), + dashboardStore: &fakeStore, + folderService: folderSvc, } origNewDashboardGuardian := guardian.New @@ -52,14 +49,6 @@ func TestDashboardService(t *testing.T) { } }) - t.Run("Should return validation error if it's a folder and have a folder id", func(t *testing.T) { - dto.Dashboard = dashboards.NewDashboardFolder("Folder") - // nolint:staticcheck - dto.Dashboard.FolderID = 1 - _, err := service.SaveDashboard(context.Background(), dto, false) - require.Equal(t, err, dashboards.ErrDashboardFolderCannotHaveParent) - }) - t.Run("Should return validation error if folder is named General", func(t *testing.T) { dto.Dashboard = dashboards.NewDashboardFolder("General") _, err := service.SaveDashboard(context.Background(), dto, false) @@ -88,7 +77,7 @@ func TestDashboardService(t *testing.T) { if tc.Error == nil { fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything, mock.AnythingOfType("bool")).Return(true, nil).Once() } - _, err := service.BuildSaveDashboardCommand(context.Background(), dto, true, false) + _, err := service.BuildSaveDashboardCommand(context.Background(), dto, false) require.Equal(t, err, tc.Error) } }) @@ -124,33 +113,6 @@ func TestDashboardService(t *testing.T) { _, err := service.SaveDashboard(context.Background(), dto, true) require.NoError(t, err) }) - - t.Run("Should return validation error if alert data is invalid", func(t *testing.T) { - origAlertingEnabledSet := setting.AlertingEnabled != nil - origAlertingEnabledVal := false - if origAlertingEnabledSet { - origAlertingEnabledVal = *setting.AlertingEnabled - } - setting.AlertingEnabled = util.Pointer(true) - t.Cleanup(func() { - if !origAlertingEnabledSet { - setting.AlertingEnabled = nil - } else { - setting.AlertingEnabled = &origAlertingEnabledVal - } - }) - - fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything, mock.AnythingOfType("bool")).Return(true, nil).Once() - fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.AnythingOfType("int64")).Return(nil, nil).Once() - fakeStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("dashboards.SaveDashboardCommand")).Return(&dashboards.Dashboard{Data: simplejson.New()}, nil).Once() - fakeStore.On("SaveAlerts", mock.Anything, mock.Anything, mock.Anything).Return(errors.New("alert validation error")).Once() - - dto.Dashboard = dashboards.NewDashboard("Dash") - dto.User = &user.SignedInUser{UserID: 1} - _, err := service.SaveDashboard(context.Background(), dto, false) - require.Error(t, err) - require.Equal(t, err.Error(), "alert validation error") - }) }) t.Run("Save provisioned dashboard validation", func(t *testing.T) { @@ -171,9 +133,9 @@ func TestDashboardService(t *testing.T) { fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything, mock.AnythingOfType("bool")).Return(true, nil).Once() fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.AnythingOfType("dashboards.SaveDashboardCommand"), mock.AnythingOfType("*dashboards.DashboardProvisioning")).Return(&dashboards.Dashboard{Data: simplejson.New()}, nil).Once() - oldRefreshInterval := setting.MinRefreshInterval - setting.MinRefreshInterval = "5m" - defer func() { setting.MinRefreshInterval = oldRefreshInterval }() + oldRefreshInterval := service.cfg.MinRefreshInterval + service.cfg.MinRefreshInterval = "5m" + defer func() { service.cfg.MinRefreshInterval = oldRefreshInterval }() dto.Dashboard = dashboards.NewDashboard("Dash") dto.Dashboard.SetID(3) @@ -233,22 +195,21 @@ func TestDashboardService(t *testing.T) { }) t.Run("Count dashboards in folder", func(t *testing.T) { - fakeStore.On("CountDashboardsInFolder", mock.Anything, mock.AnythingOfType("*dashboards.CountDashboardsInFolderRequest")).Return(int64(3), nil) - // nolint:staticcheck - folderSvc.ExpectedFolder = &folder.Folder{ID: 1} + fakeStore.On("CountDashboardsInFolders", mock.Anything, mock.AnythingOfType("*dashboards.CountDashboardsInFolderRequest")).Return(int64(3), nil) + folderSvc.ExpectedFolder = &folder.Folder{UID: "i am a folder"} // set up a ctx with signed in user usr := &user.SignedInUser{UserID: 1} ctx := appcontext.WithUser(context.Background(), usr) - count, err := service.CountInFolder(ctx, 1, "i am a folder", usr) + count, err := service.CountInFolders(ctx, 1, []string{"i am a folder"}, usr) require.NoError(t, err) require.Equal(t, int64(3), count) }) t.Run("Delete dashboards in folder", func(t *testing.T) { - args := &dashboards.DeleteDashboardsInFolderRequest{OrgID: 1, FolderUID: "uid"} - fakeStore.On("DeleteDashboardsInFolder", mock.Anything, args).Return(nil).Once() - err := service.DeleteInFolder(context.Background(), 1, "uid", nil) + args := &dashboards.DeleteDashboardsInFolderRequest{OrgID: 1, FolderUIDs: []string{"uid"}} + fakeStore.On("DeleteDashboardsInFolders", mock.Anything, args).Return(nil).Once() + err := service.DeleteInFolders(context.Background(), 1, []string{"uid"}, nil) require.NoError(t, err) }) }) diff --git a/pkg/services/dashboards/store_mock.go b/pkg/services/dashboards/store_mock.go index d4a696d6ae06b..d22013833ac49 100644 --- a/pkg/services/dashboards/store_mock.go +++ b/pkg/services/dashboards/store_mock.go @@ -1,14 +1,12 @@ -// Code generated by mockery v2.28.0. DO NOT EDIT. +// Code generated by mockery v2.34.2. DO NOT EDIT. package dashboards import ( context "context" - models "github.com/grafana/grafana/pkg/services/alerting/models" - mock "github.com/stretchr/testify/mock" - quota "github.com/grafana/grafana/pkg/services/quota" + mock "github.com/stretchr/testify/mock" ) // FakeDashboardStore is an autogenerated mock type for the Store type @@ -42,8 +40,8 @@ func (_m *FakeDashboardStore) Count(_a0 context.Context, _a1 *quota.ScopeParamet return r0, r1 } -// CountDashboardsInFolder provides a mock function with given fields: ctx, request -func (_m *FakeDashboardStore) CountDashboardsInFolder(ctx context.Context, request *CountDashboardsInFolderRequest) (int64, error) { +// CountDashboardsInFolders provides a mock function with given fields: ctx, request +func (_m *FakeDashboardStore) CountDashboardsInFolders(ctx context.Context, request *CountDashboardsInFolderRequest) (int64, error) { ret := _m.Called(ctx, request) var r0 int64 @@ -80,8 +78,8 @@ func (_m *FakeDashboardStore) DeleteDashboard(ctx context.Context, cmd *DeleteDa return r0 } -// DeleteDashboardsInFolder provides a mock function with given fields: ctx, request -func (_m *FakeDashboardStore) DeleteDashboardsInFolder(ctx context.Context, request *DeleteDashboardsInFolderRequest) error { +// DeleteDashboardsInFolders provides a mock function with given fields: ctx, request +func (_m *FakeDashboardStore) DeleteDashboardsInFolders(ctx context.Context, request *DeleteDashboardsInFolderRequest) error { ret := _m.Called(ctx, request) var r0 error @@ -342,20 +340,6 @@ func (_m *FakeDashboardStore) GetProvisionedDataByDashboardUID(ctx context.Conte return r0, r1 } -// SaveAlerts provides a mock function with given fields: ctx, dashID, alerts -func (_m *FakeDashboardStore) SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error { - ret := _m.Called(ctx, dashID, alerts) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, int64, []*models.Alert) error); ok { - r0 = rf(ctx, dashID, alerts) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // SaveDashboard provides a mock function with given fields: ctx, cmd func (_m *FakeDashboardStore) SaveDashboard(ctx context.Context, cmd SaveDashboardCommand) (*Dashboard, error) { ret := _m.Called(ctx, cmd) @@ -446,13 +430,12 @@ func (_m *FakeDashboardStore) ValidateDashboardBeforeSave(ctx context.Context, d return r0, r1 } -type mockConstructorTestingTNewFakeDashboardStore interface { +// NewFakeDashboardStore creates a new instance of FakeDashboardStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewFakeDashboardStore(t interface { mock.TestingT Cleanup(func()) -} - -// NewFakeDashboardStore creates a new instance of FakeDashboardStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewFakeDashboardStore(t mockConstructorTestingTNewFakeDashboardStore) *FakeDashboardStore { +}) *FakeDashboardStore { mock := &FakeDashboardStore{} mock.Mock.Test(t) diff --git a/pkg/services/dashboardsnapshots/database/database.go b/pkg/services/dashboardsnapshots/database/database.go index 4d1bb798df990..0acd68838bf9c 100644 --- a/pkg/services/dashboardsnapshots/database/database.go +++ b/pkg/services/dashboardsnapshots/database/database.go @@ -16,26 +16,36 @@ import ( type DashboardSnapshotStore struct { store db.DB log log.Logger - cfg *setting.Cfg + + // deprecated behavior + skipDeleteExpired bool } // DashboardStore implements the Store interface var _ dashboardsnapshots.Store = (*DashboardSnapshotStore)(nil) func ProvideStore(db db.DB, cfg *setting.Cfg) *DashboardSnapshotStore { - return &DashboardSnapshotStore{store: db, log: log.New("dashboardsnapshot.store"), cfg: cfg} + // nolint:staticcheck + return NewStore(db, !cfg.SnapShotRemoveExpired) +} + +func NewStore(db db.DB, skipDeleteExpired bool) *DashboardSnapshotStore { + log := log.New("dashboardsnapshot.store") + if skipDeleteExpired { + log.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.") + } + return &DashboardSnapshotStore{store: db, skipDeleteExpired: skipDeleteExpired} } // DeleteExpiredSnapshots removes snapshots with old expiry dates. // SnapShotRemoveExpired is deprecated and should be removed in the future. // Snapshot expiry is decided by the user when they share the snapshot. func (d *DashboardSnapshotStore) DeleteExpiredSnapshots(ctx context.Context, cmd *dashboardsnapshots.DeleteExpiredSnapshotsCommand) error { + if d.skipDeleteExpired { + d.log.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.") + return nil + } return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - if !d.cfg.SnapShotRemoveExpired { - d.log.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.") - return nil - } - deleteExpiredSQL := "DELETE FROM dashboard_snapshot WHERE expires < ?" expiredResponse, err := sess.Exec(deleteExpiredSQL, time.Now()) if err != nil { diff --git a/pkg/services/dashboardsnapshots/database/database_test.go b/pkg/services/dashboardsnapshots/database/database_test.go index 7646a02e4ef1f..45e53d3ef6149 100644 --- a/pkg/services/dashboardsnapshots/database/database_test.go +++ b/pkg/services/dashboardsnapshots/database/database_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" @@ -16,19 +18,25 @@ import ( "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationDashboardSnapshotDBAccess(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } sqlstore := db.InitTestDB(t) - dashStore := ProvideStore(sqlstore, setting.NewCfg()) + cfg := setting.NewCfg() + dashStore := ProvideStore(sqlstore, cfg) - origSecret := setting.SecretKey - setting.SecretKey = "dashboard_snapshot_testing" + origSecret := cfg.SecretKey + cfg.SecretKey = "dashboard_snapshot_testing" t.Cleanup(func() { - setting.SecretKey = origSecret + cfg.SecretKey = origSecret }) secretsService := fakes.NewFakeSecretsService() dashboard := simplejson.NewFromAny(map[string]any{"hello": "mupp"}) @@ -115,9 +123,11 @@ func TestIntegrationDashboardSnapshotDBAccess(t *testing.T) { cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{ Key: "strangesnapshotwithuserid0", DeleteKey: "adeletekey", - Dashboard: simplejson.NewFromAny(map[string]any{ - "hello": "mupp", - }), + DashboardCreateCommand: dashboardsnapshot.DashboardCreateCommand{ + Dashboard: &common.Unstructured{Object: map[string]any{ + "hello": "mupp", + }}, + }, UserID: 0, OrgID: 1, } @@ -154,11 +164,9 @@ func TestIntegrationDeleteExpiredSnapshots(t *testing.T) { t.Skip("skipping integration test") } sqlstore := db.InitTestDB(t) - dashStore := ProvideStore(sqlstore, setting.NewCfg()) + dashStore := NewStore(sqlstore, false) t.Run("Testing dashboard snapshots clean up", func(t *testing.T) { - dashStore.cfg.SnapShotRemoveExpired = true - nonExpiredSnapshot := createTestSnapshot(t, dashStore, "key1", 48000) createTestSnapshot(t, dashStore, "key2", -1200) createTestSnapshot(t, dashStore, "key3", -1200) @@ -195,12 +203,14 @@ func createTestSnapshot(t *testing.T, dashStore *DashboardSnapshotStore, key str cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{ Key: key, DeleteKey: "delete" + key, - Dashboard: simplejson.NewFromAny(map[string]any{ - "hello": "mupp", - }), - UserID: 1000, - OrgID: 1, - Expires: expires, + DashboardCreateCommand: dashboardsnapshot.DashboardCreateCommand{ + Expires: expires, + Dashboard: &common.Unstructured{Object: map[string]any{ + "hello": "mupp", + }}, + }, + UserID: 1000, + OrgID: 1, } result, err := dashStore.CreateDashboardSnapshot(context.Background(), &cmd) require.NoError(t, err) diff --git a/pkg/services/dashboardsnapshots/models.go b/pkg/services/dashboardsnapshots/models.go index 36c155b52ead3..7e4becec46baa 100644 --- a/pkg/services/dashboardsnapshots/models.go +++ b/pkg/services/dashboardsnapshots/models.go @@ -3,6 +3,7 @@ package dashboardsnapshots import ( "time" + dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/services/auth/identity" ) @@ -47,28 +48,17 @@ type DashboardSnapshotDTO struct { // swagger:model type CreateDashboardSnapshotCommand struct { - // The complete dashboard model. - // required:true - Dashboard *simplejson.Json `json:"dashboard" binding:"Required"` - // Snapshot name - // required:false - Name string `json:"name"` - // When the snapshot should expire in seconds in seconds. Default is never to expire. - // required:false - // default:0 - Expires int64 `json:"expires"` + // The "public" fields are defined in this struct while the private/SQL/response params are + // defied in the rest of this command + dashboardsnapshot.DashboardCreateCommand - // these are passed when storing an external snapshot ref - // Save the snapshot on an external server rather than locally. - // required:false - // default: false - External bool `json:"external"` ExternalURL string `json:"-"` ExternalDeleteURL string `json:"-"` // Define the unique key. Required if `external` is `true`. // required:false Key string `json:"key"` + // Unique key used to delete the snapshot. It is different from the `key` so that only the creator can delete the snapshot. Required if `external` is `true`. // required:false DeleteKey string `json:"deleteKey"` @@ -100,3 +90,10 @@ type GetDashboardSnapshotsQuery struct { OrgID int64 SignedInUser identity.Requester } + +type CreateExternalSnapshotResponse struct { + Key string `json:"key"` + DeleteKey string `json:"deleteKey"` + Url string `json:"url"` + DeleteUrl string `json:"deleteUrl"` +} diff --git a/pkg/services/dashboardsnapshots/service.go b/pkg/services/dashboardsnapshots/service.go index ae229d27fed1c..07ae3423c6a13 100644 --- a/pkg/services/dashboardsnapshots/service.go +++ b/pkg/services/dashboardsnapshots/service.go @@ -1,7 +1,23 @@ package dashboardsnapshots import ( + "bytes" "context" + "encoding/json" + "fmt" + "net/http" + "time" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/metrics" + "github.com/grafana/grafana/pkg/services/auth/identity" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/web" ) //go:generate mockery --name Service --structname MockService --inpackage --filename service_mock.go @@ -12,3 +28,198 @@ type Service interface { GetDashboardSnapshot(context.Context, *GetDashboardSnapshotQuery) (*DashboardSnapshot, error) SearchDashboardSnapshots(context.Context, *GetDashboardSnapshotsQuery) (DashboardSnapshotsList, error) } + +var client = &http.Client{ + Timeout: time.Second * 5, + Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, +} + +func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg dashboardsnapshot.SnapshotSharingOptions, svc Service) { + if !cfg.SnapshotsEnabled { + c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) + return + } + + cmd := CreateDashboardSnapshotCommand{} + if err := web.Bind(c.Req, &cmd); err != nil { + c.JsonApiErr(http.StatusBadRequest, "bad request data", err) + return + } + if cmd.Name == "" { + cmd.Name = "Unnamed snapshot" + } + + userID, err := identity.UserIdentifier(c.SignedInUser.GetNamespacedID()) + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, + "Failed to create external snapshot", err) + return + } + + var snapshotUrl string + cmd.ExternalURL = "" + cmd.OrgID = c.SignedInUser.GetOrgID() + cmd.UserID = userID + originalDashboardURL, err := createOriginalDashboardURL(&cmd) + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Invalid app URL", err) + return + } + + if cmd.External { + if !cfg.ExternalEnabled { + c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil) + return + } + + resp, err := createExternalDashboardSnapshot(cmd, cfg.ExternalSnapshotURL) + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Failed to create external snapshot", err) + return + } + + snapshotUrl = resp.Url + cmd.Key = resp.Key + cmd.DeleteKey = resp.DeleteKey + cmd.ExternalURL = resp.Url + cmd.ExternalDeleteURL = resp.DeleteUrl + cmd.Dashboard = &common.Unstructured{} + + metrics.MApiDashboardSnapshotExternal.Inc() + } else { + cmd.Dashboard.SetNestedField(originalDashboardURL, "snapshot", "originalUrl") + + if cmd.Key == "" { + var err error + cmd.Key, err = util.GetRandomString(32) + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err) + return + } + } + + if cmd.DeleteKey == "" { + var err error + cmd.DeleteKey, err = util.GetRandomString(32) + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err) + return + } + } + + snapshotUrl = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key) + + metrics.MApiDashboardSnapshotCreate.Inc() + } + + result, err := svc.CreateDashboardSnapshot(c.Req.Context(), &cmd) + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Failed to create snapshot", err) + return + } + + c.JSON(http.StatusOK, dashboardsnapshot.DashboardCreateResponse{ + Key: result.Key, + DeleteKey: result.DeleteKey, + URL: snapshotUrl, + DeleteURL: setting.ToAbsUrl("api/snapshots-delete/" + result.DeleteKey), + }) +} + +var plog = log.New("external-snapshot") + +func DeleteExternalDashboardSnapshot(externalUrl string) error { + resp, err := client.Get(externalUrl) + if err != nil { + return err + } + + defer func() { + if err := resp.Body.Close(); err != nil { + plog.Warn("Failed to close response body", "err", err) + } + }() + + if resp.StatusCode == 200 { + return nil + } + + // Gracefully ignore "snapshot not found" errors as they could have already + // been removed either via the cleanup script or by request. + if resp.StatusCode == 500 { + var respJson map[string]any + if err := json.NewDecoder(resp.Body).Decode(&respJson); err != nil { + return err + } + + if respJson["message"] == "Failed to get dashboard snapshot" { + return nil + } + } + + return fmt.Errorf("unexpected response when deleting external snapshot, status code: %d", resp.StatusCode) +} + +func createExternalDashboardSnapshot(cmd CreateDashboardSnapshotCommand, externalSnapshotUrl string) (*CreateExternalSnapshotResponse, error) { + var createSnapshotResponse CreateExternalSnapshotResponse + message := map[string]any{ + "name": cmd.Name, + "expires": cmd.Expires, + "dashboard": cmd.Dashboard, + "key": cmd.Key, + "deleteKey": cmd.DeleteKey, + } + + messageBytes, err := simplejson.NewFromAny(message).Encode() + if err != nil { + return nil, err + } + + resp, err := client.Post(externalSnapshotUrl+"/api/snapshots", "application/json", bytes.NewBuffer(messageBytes)) + if err != nil { + return nil, err + } + defer func() { + if err := resp.Body.Close(); err != nil { + plog.Warn("Failed to close response body", "err", err) + } + }() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("create external snapshot response status code %d", resp.StatusCode) + } + + if err := json.NewDecoder(resp.Body).Decode(&createSnapshotResponse); err != nil { + return nil, err + } + + return &createSnapshotResponse, nil +} + +func createOriginalDashboardURL(cmd *CreateDashboardSnapshotCommand) (string, error) { + dashUID := cmd.Dashboard.GetNestedString("uid") + if ok := util.IsValidShortUID(dashUID); !ok { + return "", fmt.Errorf("invalid dashboard UID") + } + + return fmt.Sprintf("/d/%v", dashUID), nil +} + +func DeleteWithKey(ctx context.Context, key string, svc Service) error { + query := &GetDashboardSnapshotQuery{DeleteKey: key} + queryResult, err := svc.GetDashboardSnapshot(ctx, query) + if err != nil { + return err + } + + if queryResult.External { + err := DeleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL) + if err != nil { + return err + } + } + + cmd := &DeleteDashboardSnapshotCommand{DeleteKey: queryResult.DeleteKey} + + return svc.DeleteDashboardSnapshot(ctx, cmd) +} diff --git a/pkg/services/dashboardsnapshots/service/service.go b/pkg/services/dashboardsnapshots/service/service.go index 247f0e7aec9c3..c26f00e7d2329 100644 --- a/pkg/services/dashboardsnapshots/service/service.go +++ b/pkg/services/dashboardsnapshots/service/service.go @@ -26,7 +26,7 @@ func ProvideService(store dashboardsnapshots.Store, secretsService secrets.Servi } func (s *ServiceImpl) CreateDashboardSnapshot(ctx context.Context, cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) (*dashboardsnapshots.DashboardSnapshot, error) { - marshalledData, err := cmd.Dashboard.Encode() + marshalledData, err := cmd.Dashboard.MarshalJSON() if err != nil { return nil, err } diff --git a/pkg/services/dashboardsnapshots/service/service_test.go b/pkg/services/dashboardsnapshots/service/service_test.go index d18e77cea67a4..f71de4d176829 100644 --- a/pkg/services/dashboardsnapshots/service/service_test.go +++ b/pkg/services/dashboardsnapshots/service/service_test.go @@ -2,35 +2,44 @@ package service import ( "context" + "encoding/json" "testing" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/components/simplejson" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" dashsnapdb "github.com/grafana/grafana/pkg/services/dashboardsnapshots/database" "github.com/grafana/grafana/pkg/services/secrets/database" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestDashboardSnapshotsService(t *testing.T) { sqlStore := db.InitTestDB(t) - dsStore := dashsnapdb.ProvideStore(sqlStore, setting.NewCfg()) + cfg := setting.NewCfg() + dsStore := dashsnapdb.ProvideStore(sqlStore, cfg) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) s := ProvideService(dsStore, secretsService) - origSecret := setting.SecretKey - setting.SecretKey = "dashboard_snapshot_service_test" + origSecret := cfg.SecretKey + cfg.SecretKey = "dashboard_snapshot_service_test" t.Cleanup(func() { - setting.SecretKey = origSecret + cfg.SecretKey = origSecret }) dashboardKey := "12345" + dashboard := &common.Unstructured{} rawDashboard := []byte(`{"id":123}`) - dashboard, err := simplejson.NewJson(rawDashboard) + err := json.Unmarshal(rawDashboard, dashboard) require.NoError(t, err) t.Run("create dashboard snapshot should encrypt the dashboard", func(t *testing.T) { @@ -39,7 +48,9 @@ func TestDashboardSnapshotsService(t *testing.T) { cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{ Key: dashboardKey, DeleteKey: dashboardKey, - Dashboard: dashboard, + DashboardCreateCommand: dashboardsnapshot.DashboardCreateCommand{ + Dashboard: dashboard, + }, } result, err := s.CreateDashboardSnapshot(ctx, &cmd) diff --git a/pkg/services/dashboardversion/dashverimpl/dashver.go b/pkg/services/dashboardversion/dashverimpl/dashver.go index f8b090560ba9d..04fed84c9bd6d 100644 --- a/pkg/services/dashboardversion/dashverimpl/dashver.go +++ b/pkg/services/dashboardversion/dashverimpl/dashver.go @@ -17,13 +17,15 @@ const ( ) type Service struct { + cfg *setting.Cfg store store dashSvc dashboards.DashboardService log log.Logger } -func ProvideService(db db.DB, dashboardService dashboards.DashboardService) dashver.Service { +func ProvideService(cfg *setting.Cfg, db db.DB, dashboardService dashboards.DashboardService) dashver.Service { return &Service{ + cfg: cfg, store: &sqlStore{ db: db, dialect: db.GetDialect(), @@ -63,7 +65,7 @@ func (s *Service) Get(ctx context.Context, query *dashver.GetDashboardVersionQue } func (s *Service) DeleteExpired(ctx context.Context, cmd *dashver.DeleteExpiredVersionsCommand) error { - versionsToKeep := setting.DashboardVersionsToKeep + versionsToKeep := s.cfg.DashboardVersionsToKeep if versionsToKeep < 1 { versionsToKeep = 1 } diff --git a/pkg/services/dashboardversion/dashverimpl/dashver_test.go b/pkg/services/dashboardversion/dashverimpl/dashver_test.go index 6f672f62714cc..7ec8fc4ef64bf 100644 --- a/pkg/services/dashboardversion/dashverimpl/dashver_test.go +++ b/pkg/services/dashboardversion/dashverimpl/dashver_test.go @@ -36,11 +36,13 @@ func TestDashboardVersionService(t *testing.T) { func TestDeleteExpiredVersions(t *testing.T) { versionsToKeep := 5 - setting.DashboardVersionsToKeep = versionsToKeep + cfg := setting.NewCfg() + cfg.DashboardVersionsToKeep = versionsToKeep dashboardVersionStore := newDashboardVersionStoreFake() dashboardService := dashboards.NewFakeDashboardService(t) - dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService} + dashboardVersionService := Service{ + cfg: cfg, store: dashboardVersionStore, dashSvc: dashboardService} t.Run("Don't delete anything if there are no expired versions", func(t *testing.T) { err := dashboardVersionService.DeleteExpired(context.Background(), &dashver.DeleteExpiredVersionsCommand{DeletedRows: 4}) diff --git a/pkg/services/dashboardversion/dashverimpl/store_test.go b/pkg/services/dashboardversion/dashverimpl/store_test.go index 538e557aeb0ca..cccd1d220ce52 100644 --- a/pkg/services/dashboardversion/dashverimpl/store_test.go +++ b/pkg/services/dashboardversion/dashverimpl/store_test.go @@ -13,9 +13,14 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/dashboards" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type getStore func(db.DB) store func testIntegrationGetDashboardVersion(t *testing.T, fn getStore) { @@ -25,7 +30,7 @@ func testIntegrationGetDashboardVersion(t *testing.T, fn getStore) { dashVerStore := fn(ss) t.Run("Get a Dashboard ID and version ID", func(t *testing.T) { - savedDash := insertTestDashboard(t, ss, "test dash 26", 1, 0, "", false, "diff") + savedDash := insertTestDashboard(t, ss, "test dash 26", 1, "", false, "diff") query := dashver.GetDashboardVersionQuery{ DashboardID: savedDash.ID, @@ -67,7 +72,7 @@ func testIntegrationGetDashboardVersion(t *testing.T, fn getStore) { t.Run("Clean up old dashboard versions", func(t *testing.T) { versionsToWrite := 10 for i := 0; i < versionsToWrite-1; i++ { - insertTestDashboard(t, ss, "test dash 53", 1, int64(i), strconv.Itoa(i), false, "diff-all") + insertTestDashboard(t, ss, "test dash 53"+strconv.Itoa(i), 1, strconv.Itoa(i), false, "diff-all") } versionIDsToDelete := []any{1, 2, 3, 4} res, err := dashVerStore.DeleteBatch( @@ -79,7 +84,7 @@ func testIntegrationGetDashboardVersion(t *testing.T, fn getStore) { assert.EqualValues(t, 4, res) }) - savedDash := insertTestDashboard(t, ss, "test dash 43", 1, 0, "", false, "diff-all") + savedDash := insertTestDashboard(t, ss, "test dash 43", 1, "", false, "diff-all") t.Run("Get all versions for a given Dashboard ID", func(t *testing.T) { query := dashver.ListDashboardVersionsQuery{ DashboardID: savedDash.ID, @@ -135,11 +140,10 @@ var ( ) func insertTestDashboard(t *testing.T, sqlStore db.DB, title string, orgId int64, - folderId int64, folderUID string, isFolder bool, tags ...any) *dashboards.Dashboard { + folderUID string, isFolder bool, tags ...any) *dashboards.Dashboard { t.Helper() cmd := dashboards.SaveDashboardCommand{ OrgID: orgId, - FolderID: folderId, // nolint:staticcheck FolderUID: folderUID, IsFolder: isFolder, Dashboard: simplejson.NewFromAny(map[string]any{ diff --git a/pkg/services/datasources/datasources.go b/pkg/services/datasources/datasources.go index accc207588736..cc4573fb21c40 100644 --- a/pkg/services/datasources/datasources.go +++ b/pkg/services/datasources/datasources.go @@ -58,7 +58,7 @@ type DataSourceService interface { // CustomHeaders returns a map of custom headers the user might have // configured for this Datasource. Not every datasource can has the option // to configure those. - CustomHeaders(ctx context.Context, ds *DataSource) (map[string]string, error) + CustomHeaders(ctx context.Context, ds *DataSource) (http.Header, error) } // CacheService interface for retrieving a cached datasource. diff --git a/pkg/services/datasources/fakes/fake_datasource_service.go b/pkg/services/datasources/fakes/fake_datasource_service.go index 69959cc6d5739..3f9fcf4d5602a 100644 --- a/pkg/services/datasources/fakes/fake_datasource_service.go +++ b/pkg/services/datasources/fakes/fake_datasource_service.go @@ -134,6 +134,6 @@ func (s *FakeDataSourceService) DecryptedPassword(ctx context.Context, ds *datas return "", nil } -func (s *FakeDataSourceService) CustomHeaders(ctx context.Context, ds *datasources.DataSource) (map[string]string, error) { +func (s *FakeDataSourceService) CustomHeaders(ctx context.Context, ds *datasources.DataSource) (http.Header, error) { return nil, nil } diff --git a/pkg/services/datasources/service/datasource.go b/pkg/services/datasources/service/datasource.go index be9dc343378af..d110042d5a54f 100644 --- a/pkg/services/datasources/service/datasource.go +++ b/pkg/services/datasources/service/datasource.go @@ -273,7 +273,11 @@ func (s *Service) DeleteDataSource(ctx context.Context, cmd *datasources.DeleteD return s.SecretsStore.Del(ctx, cmd.OrgID, cmd.Name, kvstore.DataSourceSecretType) } - return s.SQLStore.DeleteDataSource(ctx, cmd) + if err := s.SQLStore.DeleteDataSource(ctx, cmd); err != nil { + return err + } + + return s.permissionsService.DeleteResourcePermissions(ctx, cmd.OrgID, cmd.UID) }) } @@ -480,7 +484,7 @@ func (s *Service) httpClientOptions(ctx context.Context, ds *datasources.DataSou opts := &sdkhttpclient.Options{ Timeouts: timeouts, - Headers: s.getCustomHeaders(ds.JsonData, decryptedValues), + Header: s.getCustomHeaders(ds.JsonData, decryptedValues), Labels: map[string]string{ "datasource_type": ds.Type, "datasource_name": ds.Name, @@ -550,7 +554,7 @@ func (s *Service) httpClientOptions(ctx context.Context, ds *datasources.DataSou opts.ProxyOptions = proxyOpts } - if ds.JsonData != nil && ds.JsonData.Get("sigV4Auth").MustBool(false) && setting.SigV4AuthEnabled { + if ds.JsonData != nil && ds.JsonData.Get("sigV4Auth").MustBool(false) && s.cfg.SigV4AuthEnabled { opts.SigV4 = &sdkhttpclient.SigV4Config{ Service: awsServiceNamespace(ds.Type, ds.JsonData), Region: ds.JsonData.Get("sigV4Region").MustString(), @@ -650,8 +654,8 @@ func (s *Service) getTimeout(ds *datasources.DataSource) time.Duration { // getCustomHeaders returns a map with all the to be set headers // The map key represents the HeaderName and the value represents this header's value -func (s *Service) getCustomHeaders(jsonData *simplejson.Json, decryptedValues map[string]string) map[string]string { - headers := make(map[string]string) +func (s *Service) getCustomHeaders(jsonData *simplejson.Json, decryptedValues map[string]string) http.Header { + headers := make(http.Header) if jsonData == nil { return headers } @@ -671,12 +675,12 @@ func (s *Service) getCustomHeaders(jsonData *simplejson.Json, decryptedValues ma // skip a header with name that corresponds to auth proxy header's name // to make sure that data source proxy isn't used to circumvent auth proxy. // For more context take a look at CVE-2022-35957 - if s.cfg.AuthProxyEnabled && http.CanonicalHeaderKey(key) == http.CanonicalHeaderKey(s.cfg.AuthProxyHeaderName) { + if s.cfg.AuthProxy.Enabled && http.CanonicalHeaderKey(key) == http.CanonicalHeaderKey(s.cfg.AuthProxy.HeaderName) { continue } if val, ok := decryptedValues[headerValueSuffix]; ok { - headers[key] = val + headers.Add(key, val) } } @@ -765,7 +769,7 @@ func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { } // CustomerHeaders returns the custom headers specified in the datasource. The context is used for the decryption operation that might use the store, so consider setting an acceptable timeout for your use case. -func (s *Service) CustomHeaders(ctx context.Context, ds *datasources.DataSource) (map[string]string, error) { +func (s *Service) CustomHeaders(ctx context.Context, ds *datasources.DataSource) (http.Header, error) { values, err := s.SecretsService.DecryptJsonData(ctx, ds.SecureJsonData) if err != nil { return nil, fmt.Errorf("failed to get custom headers: %w", err) diff --git a/pkg/services/datasources/service/datasource_test.go b/pkg/services/datasources/service/datasource_test.go index 86c6632ab4611..fab5b2b47d77e 100644 --- a/pkg/services/datasources/service/datasource_test.go +++ b/pkg/services/datasources/service/datasource_test.go @@ -31,8 +31,13 @@ import ( secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type dataSourceMockRetriever struct { res []*datasources.DataSource } @@ -608,7 +613,7 @@ func TestService_GetHttpTransport(t *testing.T) { }, }) - setting.SecretKey = "password" + cfg.SecretKey = "password" sjson := simplejson.New() sjson.Set("tlsAuthWithCACert", true) @@ -659,7 +664,7 @@ func TestService_GetHttpTransport(t *testing.T) { }, }) - setting.SecretKey = "password" + cfg.SecretKey = "password" sjson := simplejson.New() sjson.Set("tlsAuth", true) @@ -706,7 +711,7 @@ func TestService_GetHttpTransport(t *testing.T) { }, }) - setting.SecretKey = "password" + cfg.SecretKey = "password" sjson := simplejson.New() sjson.Set("tlsAuthWithCACert", true) @@ -828,7 +833,7 @@ func TestService_GetHttpTransport(t *testing.T) { require.NoError(t, err) headers := dsService.getCustomHeaders(sjson, map[string]string{"httpHeaderValue1": "Bearer xf5yhfkpsnmgo"}) - require.Equal(t, "Bearer xf5yhfkpsnmgo", headers["Authorization"]) + require.Equal(t, "Bearer xf5yhfkpsnmgo", headers.Get("Authorization")) // 1. Start HTTP test server which checks the request headers backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -865,6 +870,75 @@ func TestService_GetHttpTransport(t *testing.T) { require.Equal(t, "Ok", bodyStr) }) + t.Run("Should set request Host if it is configured in custom headers within JsonData", func(t *testing.T) { + provider := httpclient.NewProvider() + + sjson := simplejson.NewFromAny(map[string]any{ + "httpHeaderName1": "Host", + }) + + sqlStore := db.InitTestDB(t) + secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) + secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}) + require.NoError(t, err) + + ds := datasources.DataSource{ + ID: 1, + OrgID: 1, + Name: "kubernetes", + URL: "http://k8s:8001", + Type: "Kubernetes", + JsonData: sjson, + } + + secureJsonData, err := json.Marshal(map[string]string{ + "httpHeaderValue1": "example.com", + }) + require.NoError(t, err) + + err = secretsStore.Set(context.Background(), ds.OrgID, ds.Name, secretskvs.DataSourceSecretType, string(secureJsonData)) + require.NoError(t, err) + + headers := dsService.getCustomHeaders(sjson, map[string]string{"httpHeaderValue1": "example.com"}) + require.Equal(t, "example.com", headers.Get("Host")) + + // 1. Start HTTP test server which checks the request headers + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Host == "example.com" { + w.WriteHeader(200) + _, err := w.Write([]byte("Ok")) + require.NoError(t, err) + return + } + + w.WriteHeader(503) + _, err := w.Write([]byte("Server name mismatch")) + require.NoError(t, err) + })) + defer backend.Close() + + // 2. Get HTTP transport from datasource which uses the test server as backend + ds.URL = backend.URL + rt, err := dsService.GetHTTPTransport(context.Background(), &ds, provider) + require.NoError(t, err) + require.NotNil(t, rt) + + // 3. Send test request which should have the Authorization header set + req := httptest.NewRequest("GET", backend.URL+"/test-host", nil) + res, err := rt.RoundTrip(req) + require.NoError(t, err) + t.Cleanup(func() { + err := res.Body.Close() + require.NoError(t, err) + }) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + bodyStr := string(body) + require.Equal(t, "Ok", bodyStr) + }) + t.Run("Should use request timeout if configured in JsonData", func(t *testing.T) { provider := httpclient.NewProvider() @@ -899,10 +973,10 @@ func TestService_GetHttpTransport(t *testing.T) { }, }) - origSigV4Enabled := setting.SigV4AuthEnabled - setting.SigV4AuthEnabled = true + origSigV4Enabled := cfg.SigV4AuthEnabled + cfg.SigV4AuthEnabled = true t.Cleanup(func() { - setting.SigV4AuthEnabled = origSigV4Enabled + cfg.SigV4AuthEnabled = origSigV4Enabled }) sjson, err := simplejson.NewJson([]byte(`{ "sigV4Auth": true }`)) @@ -1118,7 +1192,7 @@ func TestDataSource_CustomHeaders(t *testing.T) { name string jsonData *simplejson.Json secureJsonData map[string][]byte - expectedHeaders map[string]string + expectedHeaders http.Header expectedErrorMsg string }{ { @@ -1129,8 +1203,8 @@ func TestDataSource_CustomHeaders(t *testing.T) { secureJsonData: map[string][]byte{ "httpHeaderValue1": encryptedValue, }, - expectedHeaders: map[string]string{ - "X-Test-Header1": testValue, + expectedHeaders: http.Header{ + "X-Test-Header1": []string{testValue}, }, }, { @@ -1139,7 +1213,7 @@ func TestDataSource_CustomHeaders(t *testing.T) { "httpHeaderName1": "X-Test-Header1", }), secureJsonData: map[string][]byte{}, - expectedHeaders: map[string]string{}, + expectedHeaders: http.Header{}, }, { name: "non customer header value", @@ -1147,7 +1221,21 @@ func TestDataSource_CustomHeaders(t *testing.T) { "someotherheader": "X-Test-Header1", }), secureJsonData: map[string][]byte{}, - expectedHeaders: map[string]string{}, + expectedHeaders: http.Header{}, + }, + { + name: "add multiple header value", + jsonData: simplejson.NewFromAny(map[string]any{ + "httpHeaderName1": "X-Test-Header1", + "httpHeaderName2": "X-Test-Header1", + }), + secureJsonData: map[string][]byte{ + "httpHeaderValue1": encryptedValue, + "httpHeaderValue2": encryptedValue, + }, + expectedHeaders: http.Header{ + "X-Test-Header1": []string{testValue, testValue}, + }, }, } diff --git a/pkg/services/datasources/service/legacy.go b/pkg/services/datasources/service/legacy.go new file mode 100644 index 0000000000000..db5b008b13ea9 --- /dev/null +++ b/pkg/services/datasources/service/legacy.go @@ -0,0 +1,90 @@ +package service + +import ( + "context" + "errors" + "fmt" + "sync" + + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/datasources" +) + +// LegacyDataSourceRetriever supports finding a reference to datasources using the name or internal ID +type LegacyDataSourceLookup interface { + // Find the UID from either the name or internal id + // NOTE the orgID will be fetched from the context + GetDataSourceFromDeprecatedFields(ctx context.Context, name string, id int64) (*data.DataSourceRef, error) +} + +var ( + _ DataSourceRetriever = (*Service)(nil) + _ LegacyDataSourceLookup = (*cachingLegacyDataSourceLookup)(nil) + _ LegacyDataSourceLookup = (*NoopLegacyDataSourcLookup)(nil) +) + +// NoopLegacyDataSourceRetriever does not even try to lookup, it returns a raw reference +type NoopLegacyDataSourcLookup struct { + Ref *data.DataSourceRef +} + +func (s *NoopLegacyDataSourcLookup) GetDataSourceFromDeprecatedFields(ctx context.Context, name string, id int64) (*data.DataSourceRef, error) { + return s.Ref, nil +} + +type cachingLegacyDataSourceLookup struct { + retriever DataSourceRetriever + cache map[string]cachedValue + cacheMu sync.Mutex +} + +type cachedValue struct { + ref *data.DataSourceRef + err error +} + +func ProvideLegacyDataSourceLookup(p *Service) LegacyDataSourceLookup { + return &cachingLegacyDataSourceLookup{ + retriever: p, + cache: make(map[string]cachedValue), + } +} + +func (s *cachingLegacyDataSourceLookup) GetDataSourceFromDeprecatedFields(ctx context.Context, name string, id int64) (*data.DataSourceRef, error) { + if id == 0 && name == "" { + return nil, fmt.Errorf("either name or ID must be set") + } + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } + key := fmt.Sprintf("%d/%s/%d", user.OrgID, name, id) + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + + v, ok := s.cache[key] + if ok { + return v.ref, v.err + } + + ds, err := s.retriever.GetDataSource(ctx, &datasources.GetDataSourceQuery{ + OrgID: user.OrgID, + Name: name, + ID: id, + }) + if errors.Is(err, datasources.ErrDataSourceNotFound) && name != "" { + ds, err = s.retriever.GetDataSource(ctx, &datasources.GetDataSourceQuery{ + OrgID: user.OrgID, + UID: name, // Sometimes name is actually the UID :( + }) + } + v = cachedValue{ + err: err, + } + if ds != nil { + v.ref = &data.DataSourceRef{Type: ds.Type, UID: ds.UID} + } + return v.ref, v.err +} diff --git a/pkg/services/datasources/service/store.go b/pkg/services/datasources/service/store.go index 23ba2ceb66e1a..b8afc57dbad45 100644 --- a/pkg/services/datasources/service/store.go +++ b/pkg/services/datasources/service/store.go @@ -253,8 +253,8 @@ func (ss *SqlStore) AddDataSource(ctx context.Context, cmd *datasources.AddDataS return fmt.Errorf("failed to generate UID for datasource %q: %w", cmd.Name, err) } cmd.UID = uid - } else if !util.IsValidShortUID(cmd.UID) { - logDeprecatedInvalidDsUid(ss.logger, cmd.UID, cmd.Name) + } else if err := util.ValidateUID(cmd.UID); err != nil { + logDeprecatedInvalidDsUid(ss.logger, cmd.UID, cmd.Name, err) } ds = &datasources.DataSource{ @@ -388,8 +388,10 @@ func (ss *SqlStore) UpdateDataSource(ctx context.Context, cmd *datasources.Updat } } - if !util.IsValidShortUID(cmd.UID) { - logDeprecatedInvalidDsUid(ss.logger, cmd.UID, cmd.Name) + if cmd.UID != "" { + if err := util.ValidateUID(cmd.UID); err != nil { + logDeprecatedInvalidDsUid(ss.logger, cmd.UID, cmd.Name, err) + } } return err @@ -415,11 +417,11 @@ func generateNewDatasourceUid(sess *db.Session, orgId int64) (string, error) { var generateNewUid func() string = util.GenerateShortUID -func logDeprecatedInvalidDsUid(logger log.Logger, uid, name string) { +func logDeprecatedInvalidDsUid(logger log.Logger, uid string, name string, err error) { logger.Warn( "Invalid datasource uid. The use of invalid uids is deprecated and this operation will fail in a future "+ "version of Grafana. A valid uid is a combination of a-z, A-Z, 0-9 (alphanumeric), - (dash) and _ "+ "(underscore) characters, maximum length 40", - "uid", uid, "name", name, + "uid", uid, "name", name, "error", err, ) } diff --git a/pkg/services/extsvcauth/models.go b/pkg/services/extsvcauth/models.go index 717eb42f67eb1..4891ef930a9a4 100644 --- a/pkg/services/extsvcauth/models.go +++ b/pkg/services/extsvcauth/models.go @@ -7,11 +7,11 @@ import ( ) const ( - OAuth2Server AuthProvider = "OAuth2Server" ServiceAccounts AuthProvider = "ServiceAccounts" // TmpOrgID is the orgID we use while global service accounts are not supported. - TmpOrgID int64 = 1 + TmpOrgIDStr string = "1" + TmpOrgID int64 = 1 ) type AuthProvider string @@ -40,23 +40,9 @@ type SelfCfg struct { Permissions []accesscontrol.Permission } -type ImpersonationCfg struct { - // Enabled allows the service to request access tokens to impersonate users - Enabled bool - // Groups allows the service to list the impersonated user's teams - Groups bool - // Permissions are the permissions that the external service needs when impersonating a user. - // The intersection of this set with the impersonated user's permission guarantees that the client will not - // gain more privileges than the impersonated user has and vice versa. - Permissions []accesscontrol.Permission -} - // ExternalServiceRegistration represents the registration form to save new client. type ExternalServiceRegistration struct { Name string - // Impersonation access configuration - // (this is not available on all auth providers) - Impersonation ImpersonationCfg // Self access configuration Self SelfCfg // Auth Provider that the client will use to connect to Grafana diff --git a/pkg/services/extsvcauth/oauthserver/api/api.go b/pkg/services/extsvcauth/oauthserver/api/api.go deleted file mode 100644 index 921a01cb1d604..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/api/api.go +++ /dev/null @@ -1,37 +0,0 @@ -package api - -import ( - "github.com/grafana/grafana/pkg/api/routing" - contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" -) - -type api struct { - router routing.RouteRegister - oauthServer oauthserver.OAuth2Server -} - -func NewAPI( - router routing.RouteRegister, - oauthServer oauthserver.OAuth2Server, -) *api { - return &api{ - router: router, - oauthServer: oauthServer, - } -} - -func (a *api) RegisterAPIEndpoints() { - a.router.Group("/oauth2", func(oauthRouter routing.RouteRegister) { - oauthRouter.Post("/introspect", a.handleIntrospectionRequest) - oauthRouter.Post("/token", a.handleTokenRequest) - }) -} - -func (a *api) handleTokenRequest(c *contextmodel.ReqContext) { - a.oauthServer.HandleTokenRequest(c.Resp, c.Req) -} - -func (a *api) handleIntrospectionRequest(c *contextmodel.ReqContext) { - a.oauthServer.HandleIntrospectionRequest(c.Resp, c.Req) -} diff --git a/pkg/services/extsvcauth/oauthserver/errors.go b/pkg/services/extsvcauth/oauthserver/errors.go deleted file mode 100644 index 942245b362e8a..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/errors.go +++ /dev/null @@ -1,25 +0,0 @@ -package oauthserver - -import ( - "github.com/grafana/grafana/pkg/util/errutil" -) - -var ( - ErrClientNotFoundMessageID = "oauthserver.client-not-found" -) - -var ( - ErrClientRequiredID = errutil.BadRequest( - "oauthserver.required-client-id", - errutil.WithPublicMessage("client ID is required")).Errorf("Client ID is required") - ErrClientRequiredName = errutil.BadRequest( - "oauthserver.required-client-name", - errutil.WithPublicMessage("client name is required")).Errorf("Client name is required") - ErrClientNotFound = errutil.NotFound( - ErrClientNotFoundMessageID, - errutil.WithPublicMessage("Requested client has not been found")) -) - -func ErrClientNotFoundFn(clientID string) error { - return ErrClientNotFound.Errorf("client '%s' not found", clientID) -} diff --git a/pkg/services/extsvcauth/oauthserver/external_service.go b/pkg/services/extsvcauth/oauthserver/external_service.go deleted file mode 100644 index bff2bf93466ae..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/external_service.go +++ /dev/null @@ -1,153 +0,0 @@ -package oauthserver - -import ( - "context" - "strconv" - "strings" - - "github.com/ory/fosite" - - ac "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/extsvcauth" - "github.com/grafana/grafana/pkg/services/user" -) - -type OAuthExternalService struct { - ID int64 `xorm:"id pk autoincr"` - Name string `xorm:"name"` - ClientID string `xorm:"client_id"` - Secret string `xorm:"secret"` - RedirectURI string `xorm:"redirect_uri"` // Not used yet (code flow) - GrantTypes string `xorm:"grant_types"` // CSV value - Audiences string `xorm:"audiences"` // CSV value - PublicPem []byte `xorm:"public_pem"` - ServiceAccountID int64 `xorm:"service_account_id"` - // SelfPermissions are the registered service account permissions (registered and managed permissions) - SelfPermissions []ac.Permission - // ImpersonatePermissions is the restriction set of permissions while impersonating - ImpersonatePermissions []ac.Permission - - // SignedInUser refers to the current Service Account identity/user - SignedInUser *user.SignedInUser - Scopes []string - ImpersonateScopes []string -} - -// ToExternalService converts the ExternalService (used internally by the oauthserver) to extsvcauth.ExternalService (used outside the package) -// If object must contain Key pairs, pass them as parameters, otherwise only the client PublicPem will be added. -func (c *OAuthExternalService) ToExternalService(keys *extsvcauth.KeyResult) *extsvcauth.ExternalService { - c2 := &extsvcauth.ExternalService{ - ID: c.ClientID, - Name: c.Name, - Secret: c.Secret, - OAuthExtra: &extsvcauth.OAuthExtra{ - GrantTypes: c.GrantTypes, - Audiences: c.Audiences, - RedirectURI: c.RedirectURI, - KeyResult: keys, - }, - } - - // Fallback to only display the public pem - if keys == nil && len(c.PublicPem) > 0 { - c2.OAuthExtra.KeyResult = &extsvcauth.KeyResult{PublicPem: string(c.PublicPem)} - } - - return c2 -} - -func (c *OAuthExternalService) LogID() string { - return "{name: " + c.Name + ", clientID: " + c.ClientID + "}" -} - -// GetID returns the client ID. -func (c *OAuthExternalService) GetID() string { return c.ClientID } - -// GetHashedSecret returns the hashed secret as it is stored in the store. -func (c *OAuthExternalService) GetHashedSecret() []byte { - // Hashed version is stored in the secret field - return []byte(c.Secret) -} - -// GetRedirectURIs returns the client's allowed redirect URIs. -func (c *OAuthExternalService) GetRedirectURIs() []string { - return []string{c.RedirectURI} -} - -// GetGrantTypes returns the client's allowed grant types. -func (c *OAuthExternalService) GetGrantTypes() fosite.Arguments { - return strings.Split(c.GrantTypes, ",") -} - -// GetResponseTypes returns the client's allowed response types. -// All allowed combinations of response types have to be listed, each combination having -// response types of the combination separated by a space. -func (c *OAuthExternalService) GetResponseTypes() fosite.Arguments { - return fosite.Arguments{"code"} -} - -// GetScopes returns the scopes this client is allowed to request on its own behalf. -func (c *OAuthExternalService) GetScopes() fosite.Arguments { - if c.Scopes != nil { - return c.Scopes - } - - ret := []string{"profile", "email", "groups", "entitlements"} - if c.SignedInUser != nil && c.SignedInUser.Permissions != nil { - perms := c.SignedInUser.Permissions[TmpOrgID] - for action := range perms { - // Add all actions that the plugin is allowed to request - ret = append(ret, action) - } - } - - c.Scopes = ret - return ret -} - -// GetScopes returns the scopes this client is allowed to request on a specific user. -func (c *OAuthExternalService) GetScopesOnUser(ctx context.Context, accessControl ac.AccessControl, userID int64) []string { - ev := ac.EvalPermission(ac.ActionUsersImpersonate, ac.Scope("users", "id", strconv.FormatInt(userID, 10))) - hasAccess, errAccess := accessControl.Evaluate(ctx, c.SignedInUser, ev) - if errAccess != nil || !hasAccess { - return nil - } - - if c.ImpersonateScopes != nil { - return c.ImpersonateScopes - } - - ret := []string{} - if c.ImpersonatePermissions != nil { - perms := c.ImpersonatePermissions - for i := range perms { - if perms[i].Action == ac.ActionUsersRead && perms[i].Scope == ScopeGlobalUsersSelf { - ret = append(ret, "profile", "email", ac.ActionUsersRead) - continue - } - if perms[i].Action == ac.ActionUsersPermissionsRead && perms[i].Scope == ScopeUsersSelf { - ret = append(ret, "entitlements", ac.ActionUsersPermissionsRead) - continue - } - if perms[i].Action == ac.ActionTeamsRead && perms[i].Scope == ScopeTeamsSelf { - ret = append(ret, "groups", ac.ActionTeamsRead) - continue - } - // Add all actions that the plugin is allowed to request - ret = append(ret, perms[i].Action) - } - } - - c.ImpersonateScopes = ret - return ret -} - -// IsPublic returns true, if this client is marked as public. -func (c *OAuthExternalService) IsPublic() bool { - return false -} - -// GetAudience returns the allowed audience(s) for this client. -func (c *OAuthExternalService) GetAudience() fosite.Arguments { - return strings.Split(c.Audiences, ",") -} diff --git a/pkg/services/extsvcauth/oauthserver/external_service_test.go b/pkg/services/extsvcauth/oauthserver/external_service_test.go deleted file mode 100644 index 67e4011738768..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/external_service_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package oauthserver - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" - - ac "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" -) - -func setupTestEnv(t *testing.T) *OAuthExternalService { - t.Helper() - - client := &OAuthExternalService{ - Name: "my-ext-service", - ClientID: "RANDOMID", - Secret: "RANDOMSECRET", - GrantTypes: "client_credentials,urn:ietf:params:oauth:grant-type:jwt-bearer", - ServiceAccountID: 2, - SelfPermissions: []ac.Permission{ - {Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll}, - }, - SignedInUser: &user.SignedInUser{ - UserID: 2, - OrgID: 1, - }, - } - return client -} - -func TestExternalService_GetScopesOnUser(t *testing.T) { - testCases := []struct { - name string - impersonatePermissions []ac.Permission - initTestEnv func(*OAuthExternalService) - expectedScopes []string - }{ - { - name: "should return nil when the service account has no impersonate permissions", - expectedScopes: nil, - }, - { - name: "should return the 'profile', 'email' and associated RBAC action", - initTestEnv: func(c *OAuthExternalService) { - c.SignedInUser.Permissions = map[int64]map[string][]string{ - 1: { - ac.ActionUsersImpersonate: {ac.ScopeUsersAll}, - }, - } - c.ImpersonatePermissions = []ac.Permission{ - {Action: ac.ActionUsersRead, Scope: ScopeGlobalUsersSelf}, - } - }, - expectedScopes: []string{"profile", "email", ac.ActionUsersRead}, - }, - { - name: "should return 'entitlements' and associated RBAC action scopes", - initTestEnv: func(c *OAuthExternalService) { - c.SignedInUser.Permissions = map[int64]map[string][]string{ - 1: { - ac.ActionUsersImpersonate: {ac.ScopeUsersAll}, - }, - } - c.ImpersonatePermissions = []ac.Permission{ - {Action: ac.ActionUsersPermissionsRead, Scope: ScopeUsersSelf}, - } - }, - expectedScopes: []string{"entitlements", ac.ActionUsersPermissionsRead}, - }, - { - name: "should return 'groups' and associated RBAC action scopes", - initTestEnv: func(c *OAuthExternalService) { - c.SignedInUser.Permissions = map[int64]map[string][]string{ - 1: { - ac.ActionUsersImpersonate: {ac.ScopeUsersAll}, - }, - } - c.ImpersonatePermissions = []ac.Permission{ - {Action: ac.ActionTeamsRead, Scope: ScopeTeamsSelf}, - } - }, - expectedScopes: []string{"groups", ac.ActionTeamsRead}, - }, - { - name: "should return all scopes", - initTestEnv: func(c *OAuthExternalService) { - c.SignedInUser.Permissions = map[int64]map[string][]string{ - 1: { - ac.ActionUsersImpersonate: {ac.ScopeUsersAll}, - }, - } - c.ImpersonatePermissions = []ac.Permission{ - {Action: ac.ActionUsersRead, Scope: ScopeGlobalUsersSelf}, - {Action: ac.ActionUsersPermissionsRead, Scope: ScopeUsersSelf}, - {Action: ac.ActionTeamsRead, Scope: ScopeTeamsSelf}, - {Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeDashboardsAll}, - } - }, - expectedScopes: []string{"profile", "email", ac.ActionUsersRead, - "entitlements", ac.ActionUsersPermissionsRead, - "groups", ac.ActionTeamsRead, - "dashboards:read"}, - }, - { - name: "should return stored scopes when the client's impersonate scopes has already been set", - initTestEnv: func(c *OAuthExternalService) { - c.SignedInUser.Permissions = map[int64]map[string][]string{ - 1: { - ac.ActionUsersImpersonate: {ac.ScopeUsersAll}, - }, - } - c.ImpersonateScopes = []string{"dashboard:create", "profile", "email", "entitlements", "groups"} - }, - expectedScopes: []string{"profile", "email", "entitlements", "groups", "dashboard:create"}, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - c := setupTestEnv(t) - if tc.initTestEnv != nil { - tc.initTestEnv(c) - } - scopes := c.GetScopesOnUser(context.Background(), acimpl.ProvideAccessControl(setting.NewCfg()), 3) - require.ElementsMatch(t, tc.expectedScopes, scopes) - }) - } -} - -func TestExternalService_GetScopes(t *testing.T) { - testCases := []struct { - name string - impersonatePermissions []ac.Permission - initTestEnv func(*OAuthExternalService) - expectedScopes []string - }{ - { - name: "should return default scopes when the signed in user is nil", - initTestEnv: func(c *OAuthExternalService) { - c.SignedInUser = nil - }, - expectedScopes: []string{"profile", "email", "entitlements", "groups"}, - }, - { - name: "should return default scopes when the signed in user has no permissions", - initTestEnv: func(c *OAuthExternalService) { - c.SignedInUser.Permissions = map[int64]map[string][]string{} - }, - expectedScopes: []string{"profile", "email", "entitlements", "groups"}, - }, - { - name: "should return additional scopes from signed in user's permissions", - initTestEnv: func(c *OAuthExternalService) { - c.SignedInUser.Permissions = map[int64]map[string][]string{ - 1: { - dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}, - }, - } - }, - expectedScopes: []string{"profile", "email", "entitlements", "groups", "dashboards:read"}, - }, - { - name: "should return stored scopes when the client's scopes has already been set", - initTestEnv: func(c *OAuthExternalService) { - c.Scopes = []string{"profile", "email", "entitlements", "groups"} - }, - expectedScopes: []string{"profile", "email", "entitlements", "groups"}, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - c := setupTestEnv(t) - if tc.initTestEnv != nil { - tc.initTestEnv(c) - } - scopes := c.GetScopes() - require.ElementsMatch(t, tc.expectedScopes, scopes) - }) - } -} - -func TestExternalService_ToDTO(t *testing.T) { - client := &OAuthExternalService{ - ID: 1, - Name: "my-ext-service", - ClientID: "test", - Secret: "testsecret", - RedirectURI: "http://localhost:3000", - GrantTypes: "client_credentials,urn:ietf:params:oauth:grant-type:jwt-bearer", - Audiences: "https://example.org,https://second.example.org", - PublicPem: []byte("pem_encoded_public_key"), - } - - dto := client.ToExternalService(nil) - - require.Equal(t, client.ClientID, dto.ID) - require.Equal(t, client.Name, dto.Name) - require.Equal(t, client.Secret, dto.Secret) - - require.NotNil(t, dto.OAuthExtra) - - require.Equal(t, client.RedirectURI, dto.OAuthExtra.RedirectURI) - require.Equal(t, client.GrantTypes, dto.OAuthExtra.GrantTypes) - require.Equal(t, client.Audiences, dto.OAuthExtra.Audiences) - require.Equal(t, client.PublicPem, []byte(dto.OAuthExtra.KeyResult.PublicPem)) - require.Empty(t, dto.OAuthExtra.KeyResult.PrivatePem) - require.Empty(t, dto.OAuthExtra.KeyResult.URL) - require.False(t, dto.OAuthExtra.KeyResult.Generated) -} diff --git a/pkg/services/extsvcauth/oauthserver/models.go b/pkg/services/extsvcauth/oauthserver/models.go deleted file mode 100644 index 70ba02ed31d29..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/models.go +++ /dev/null @@ -1,58 +0,0 @@ -package oauthserver - -import ( - "context" - "net/http" - - "github.com/grafana/grafana/pkg/services/extsvcauth" - "gopkg.in/square/go-jose.v2" -) - -const ( - // TmpOrgID is the orgID we use while global service accounts are not supported. - TmpOrgID int64 = 1 - // NoServiceAccountID is the ID we use for client that have no service account associated. - NoServiceAccountID int64 = 0 - - // List of scopes used to identify the impersonated user. - ScopeUsersSelf = "users:self" - ScopeGlobalUsersSelf = "global.users:self" - ScopeTeamsSelf = "teams:self" - - // Supported encryptions - RS256 = "RS256" - ES256 = "ES256" -) - -// OAuth2Server represents a service in charge of managing OAuth2 clients -// and handling OAuth2 requests (token, introspection). -type OAuth2Server interface { - // SaveExternalService creates or updates an external service in the database, it generates client_id and secrets and - // it ensures that the associated service account has the correct permissions. - SaveExternalService(ctx context.Context, cmd *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) - // GetExternalService retrieves an external service from store by client_id. It populates the SelfPermissions and - // SignedInUser from the associated service account. - GetExternalService(ctx context.Context, id string) (*OAuthExternalService, error) - // RemoveExternalService removes an external service and its associated resources from the store. - RemoveExternalService(ctx context.Context, name string) error - - // HandleTokenRequest handles the client's OAuth2 query to obtain an access_token by presenting its authorization - // grant (ex: client_credentials, jwtbearer). - HandleTokenRequest(rw http.ResponseWriter, req *http.Request) - // HandleIntrospectionRequest handles the OAuth2 query to determine the active state of an OAuth 2.0 token and - // to determine meta-information about this token. - HandleIntrospectionRequest(rw http.ResponseWriter, req *http.Request) -} - -//go:generate mockery --name Store --structname MockStore --outpkg oastest --filename store_mock.go --output ./oastest/ - -type Store interface { - DeleteExternalService(ctx context.Context, id string) error - GetExternalService(ctx context.Context, id string) (*OAuthExternalService, error) - GetExternalServiceNames(ctx context.Context) ([]string, error) - GetExternalServiceByName(ctx context.Context, name string) (*OAuthExternalService, error) - GetExternalServicePublicKey(ctx context.Context, clientID string) (*jose.JSONWebKey, error) - RegisterExternalService(ctx context.Context, client *OAuthExternalService) error - SaveExternalService(ctx context.Context, client *OAuthExternalService) error - UpdateExternalServiceGrantTypes(ctx context.Context, clientID, grantTypes string) error -} diff --git a/pkg/services/extsvcauth/oauthserver/oasimpl/aggregate_store.go b/pkg/services/extsvcauth/oauthserver/oasimpl/aggregate_store.go deleted file mode 100644 index bd2bb90371c8c..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/oasimpl/aggregate_store.go +++ /dev/null @@ -1,162 +0,0 @@ -package oasimpl - -import ( - "context" - "time" - - "github.com/ory/fosite" - "github.com/ory/fosite/handler/oauth2" - "github.com/ory/fosite/handler/rfc7523" - "gopkg.in/square/go-jose.v2" - - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/utils" -) - -var _ fosite.ClientManager = &OAuth2ServiceImpl{} -var _ oauth2.AuthorizeCodeStorage = &OAuth2ServiceImpl{} -var _ oauth2.AccessTokenStorage = &OAuth2ServiceImpl{} -var _ oauth2.RefreshTokenStorage = &OAuth2ServiceImpl{} -var _ rfc7523.RFC7523KeyStorage = &OAuth2ServiceImpl{} -var _ oauth2.TokenRevocationStorage = &OAuth2ServiceImpl{} - -// GetClient loads the client by its ID or returns an error -// if the client does not exist or another error occurred. -func (s *OAuth2ServiceImpl) GetClient(ctx context.Context, id string) (fosite.Client, error) { - return s.GetExternalService(ctx, id) -} - -// ClientAssertionJWTValid returns an error if the JTI is -// known or the DB check failed and nil if the JTI is not known. -func (s *OAuth2ServiceImpl) ClientAssertionJWTValid(ctx context.Context, jti string) error { - return s.memstore.ClientAssertionJWTValid(ctx, jti) -} - -// SetClientAssertionJWT marks a JTI as known for the given -// expiry time. Before inserting the new JTI, it will clean -// up any existing JTIs that have expired as those tokens can -// not be replayed due to the expiry. -func (s *OAuth2ServiceImpl) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error { - return s.memstore.SetClientAssertionJWT(ctx, jti, exp) -} - -// GetAuthorizeCodeSession stores the authorization request for a given authorization code. -func (s *OAuth2ServiceImpl) CreateAuthorizeCodeSession(ctx context.Context, code string, request fosite.Requester) (err error) { - return s.memstore.CreateAuthorizeCodeSession(ctx, code, request) -} - -// GetAuthorizeCodeSession hydrates the session based on the given code and returns the authorization request. -// If the authorization code has been invalidated with `InvalidateAuthorizeCodeSession`, this -// method should return the ErrInvalidatedAuthorizeCode error. -// -// Make sure to also return the fosite.Requester value when returning the fosite.ErrInvalidatedAuthorizeCode error! -func (s *OAuth2ServiceImpl) GetAuthorizeCodeSession(ctx context.Context, code string, session fosite.Session) (request fosite.Requester, err error) { - return s.memstore.GetAuthorizeCodeSession(ctx, code, session) -} - -// InvalidateAuthorizeCodeSession is called when an authorize code is being used. The state of the authorization -// code should be set to invalid and consecutive requests to GetAuthorizeCodeSession should return the -// ErrInvalidatedAuthorizeCode error. -func (s *OAuth2ServiceImpl) InvalidateAuthorizeCodeSession(ctx context.Context, code string) (err error) { - return s.memstore.InvalidateAuthorizeCodeSession(ctx, code) -} - -func (s *OAuth2ServiceImpl) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) (err error) { - return s.memstore.CreateAccessTokenSession(ctx, signature, request) -} - -func (s *OAuth2ServiceImpl) GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error) { - return s.memstore.GetAccessTokenSession(ctx, signature, session) -} - -func (s *OAuth2ServiceImpl) DeleteAccessTokenSession(ctx context.Context, signature string) (err error) { - return s.memstore.DeleteAccessTokenSession(ctx, signature) -} - -func (s *OAuth2ServiceImpl) CreateRefreshTokenSession(ctx context.Context, signature string, request fosite.Requester) (err error) { - return s.memstore.CreateRefreshTokenSession(ctx, signature, request) -} - -func (s *OAuth2ServiceImpl) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error) { - return s.memstore.GetRefreshTokenSession(ctx, signature, session) -} - -func (s *OAuth2ServiceImpl) DeleteRefreshTokenSession(ctx context.Context, signature string) (err error) { - return s.memstore.DeleteRefreshTokenSession(ctx, signature) -} - -// RevokeRefreshToken revokes a refresh token as specified in: -// https://tools.ietf.org/html/rfc7009#section-2.1 -// If the particular -// token is a refresh token and the authorization server supports the -// revocation of access tokens, then the authorization server SHOULD -// also invalidate all access tokens based on the same authorization -// grant (see Implementation Note). -func (s *OAuth2ServiceImpl) RevokeRefreshToken(ctx context.Context, requestID string) error { - return s.memstore.RevokeRefreshToken(ctx, requestID) -} - -// RevokeRefreshTokenMaybeGracePeriod revokes a refresh token as specified in: -// https://tools.ietf.org/html/rfc7009#section-2.1 -// If the particular -// token is a refresh token and the authorization server supports the -// revocation of access tokens, then the authorization server SHOULD -// also invalidate all access tokens based on the same authorization -// grant (see Implementation Note). -// -// If the Refresh Token grace period is greater than zero in configuration the token -// will have its expiration time set as UTCNow + GracePeriod. -func (s *OAuth2ServiceImpl) RevokeRefreshTokenMaybeGracePeriod(ctx context.Context, requestID string, signature string) error { - return s.memstore.RevokeRefreshTokenMaybeGracePeriod(ctx, requestID, signature) -} - -// RevokeAccessToken revokes an access token as specified in: -// https://tools.ietf.org/html/rfc7009#section-2.1 -// If the token passed to the request -// is an access token, the server MAY revoke the respective refresh -// token as well. -func (s *OAuth2ServiceImpl) RevokeAccessToken(ctx context.Context, requestID string) error { - return s.memstore.RevokeAccessToken(ctx, requestID) -} - -// GetPublicKey returns public key, issued by 'issuer', and assigned for subject. Public key is used to check -// signature of jwt assertion in authorization grants. -func (s *OAuth2ServiceImpl) GetPublicKey(ctx context.Context, issuer string, subject string, kid string) (*jose.JSONWebKey, error) { - return s.sqlstore.GetExternalServicePublicKey(ctx, issuer) -} - -// GetPublicKeys returns public key, set issued by 'issuer', and assigned for subject. -func (s *OAuth2ServiceImpl) GetPublicKeys(ctx context.Context, issuer string, subject string) (*jose.JSONWebKeySet, error) { - jwk, err := s.sqlstore.GetExternalServicePublicKey(ctx, issuer) - if err != nil { - return nil, err - } - return &jose.JSONWebKeySet{ - Keys: []jose.JSONWebKey{*jwk}, - }, nil -} - -// GetPublicKeyScopes returns assigned scope for assertion, identified by public key, issued by 'issuer'. -func (s *OAuth2ServiceImpl) GetPublicKeyScopes(ctx context.Context, issuer string, subject string, kid string) ([]string, error) { - client, err := s.GetExternalService(ctx, issuer) - if err != nil { - return nil, err - } - userID, err := utils.ParseUserIDFromSubject(subject) - if err != nil { - return nil, err - } - return client.GetScopesOnUser(ctx, s.accessControl, userID), nil -} - -// IsJWTUsed returns true, if JWT is not known yet or it can not be considered valid, because it must be already -// expired. -func (s *OAuth2ServiceImpl) IsJWTUsed(ctx context.Context, jti string) (bool, error) { - return s.memstore.IsJWTUsed(ctx, jti) -} - -// MarkJWTUsedForTime marks JWT as used for a time passed in exp parameter. This helps ensure that JWTs are not -// replayed by maintaining the set of used "jti" values for the length of time for which the JWT would be -// considered valid based on the applicable "exp" instant. (https://tools.ietf.org/html/rfc7523#section-3) -func (s *OAuth2ServiceImpl) MarkJWTUsedForTime(ctx context.Context, jti string, exp time.Time) error { - return s.memstore.MarkJWTUsedForTime(ctx, jti, exp) -} diff --git a/pkg/services/extsvcauth/oauthserver/oasimpl/aggregate_store_test.go b/pkg/services/extsvcauth/oauthserver/oasimpl/aggregate_store_test.go deleted file mode 100644 index 1501239cce334..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/oasimpl/aggregate_store_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package oasimpl - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - ac "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" - "github.com/grafana/grafana/pkg/services/user" -) - -var cachedExternalService = func() *oauthserver.OAuthExternalService { - return &oauthserver.OAuthExternalService{ - Name: "my-ext-service", - ClientID: "RANDOMID", - Secret: "RANDOMSECRET", - GrantTypes: "client_credentials", - PublicPem: []byte("-----BEGIN PUBLIC KEY-----"), - ServiceAccountID: 1, - SelfPermissions: []ac.Permission{{Action: "users:impersonate", Scope: "users:*"}}, - SignedInUser: &user.SignedInUser{ - UserID: 2, - OrgID: 1, - Permissions: map[int64]map[string][]string{ - 1: { - "users:impersonate": {"users:*"}, - }, - }, - }, - } -} - -func TestOAuth2ServiceImpl_GetPublicKeyScopes(t *testing.T) { - testCases := []struct { - name string - initTestEnv func(*TestEnv) - impersonatePermissions []ac.Permission - userID string - expectedScopes []string - wantErr bool - }{ - { - name: "should error out when GetExternalService returns error", - initTestEnv: func(env *TestEnv) { - env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(nil, oauthserver.ErrClientNotFoundFn("my-ext-service")) - }, - wantErr: true, - }, - { - name: "should error out when the user id cannot be parsed", - initTestEnv: func(env *TestEnv) { - env.S.cache.Set("my-ext-service", *cachedExternalService(), time.Minute) - }, - userID: "user:3", - wantErr: true, - }, - { - name: "should return no scope when the external service is not allowed to impersonate the user", - initTestEnv: func(env *TestEnv) { - client := cachedExternalService() - client.SignedInUser.Permissions = map[int64]map[string][]string{} - env.S.cache.Set("my-ext-service", *client, time.Minute) - }, - userID: "user:id:3", - expectedScopes: nil, - wantErr: false, - }, - { - name: "should return no scope when the external service has an no impersonate permission", - initTestEnv: func(env *TestEnv) { - client := cachedExternalService() - client.ImpersonatePermissions = []ac.Permission{} - env.S.cache.Set("my-ext-service", *client, time.Minute) - }, - userID: "user:id:3", - expectedScopes: []string{}, - wantErr: false, - }, - { - name: "should return the scopes when the external service has impersonate permissions", - initTestEnv: func(env *TestEnv) { - env.S.cache.Set("my-ext-service", *cachedExternalService(), time.Minute) - client := cachedExternalService() - client.ImpersonatePermissions = []ac.Permission{ - {Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll}, - {Action: ac.ActionUsersRead, Scope: oauthserver.ScopeGlobalUsersSelf}, - {Action: ac.ActionUsersPermissionsRead, Scope: oauthserver.ScopeUsersSelf}, - {Action: ac.ActionTeamsRead, Scope: oauthserver.ScopeTeamsSelf}} - env.S.cache.Set("my-ext-service", *client, time.Minute) - }, - userID: "user:id:3", - expectedScopes: []string{"users:impersonate", - "profile", "email", ac.ActionUsersRead, - "entitlements", ac.ActionUsersPermissionsRead, - "groups", ac.ActionTeamsRead}, - wantErr: false, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - env := setupTestEnv(t) - if tc.initTestEnv != nil { - tc.initTestEnv(env) - } - - scopes, err := env.S.GetPublicKeyScopes(context.Background(), "my-ext-service", tc.userID, "") - if tc.wantErr { - require.Error(t, err) - return - } - - require.ElementsMatch(t, tc.expectedScopes, scopes) - }) - } -} diff --git a/pkg/services/extsvcauth/oauthserver/oasimpl/introspection.go b/pkg/services/extsvcauth/oauthserver/oasimpl/introspection.go deleted file mode 100644 index 3182efbf3ac1a..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/oasimpl/introspection.go +++ /dev/null @@ -1,21 +0,0 @@ -package oasimpl - -import ( - "log" - "net/http" -) - -// HandleIntrospectionRequest handles the OAuth2 query to determine the active state of an OAuth 2.0 token and -// to determine meta-information about this token -func (s *OAuth2ServiceImpl) HandleIntrospectionRequest(rw http.ResponseWriter, req *http.Request) { - ctx := req.Context() - currentOAuthSessionData := NewAuthSession() - ir, err := s.oauthProvider.NewIntrospectionRequest(ctx, req, currentOAuthSessionData) - if err != nil { - log.Printf("Error occurred in NewIntrospectionRequest: %+v", err) - s.oauthProvider.WriteIntrospectionError(ctx, rw, err) - return - } - - s.oauthProvider.WriteIntrospectionResponse(ctx, rw, ir) -} diff --git a/pkg/services/extsvcauth/oauthserver/oasimpl/service.go b/pkg/services/extsvcauth/oauthserver/oasimpl/service.go deleted file mode 100644 index 8c5a90248d250..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/oasimpl/service.go +++ /dev/null @@ -1,500 +0,0 @@ -package oasimpl - -import ( - "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/base64" - "encoding/pem" - "errors" - "fmt" - "strings" - "time" - - "github.com/go-jose/go-jose/v3" - "github.com/ory/fosite" - "github.com/ory/fosite/compose" - "github.com/ory/fosite/storage" - "github.com/ory/fosite/token/jwt" - "golang.org/x/crypto/bcrypt" - - "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/slugify" - ac "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/extsvcauth" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/api" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/store" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/utils" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" - "github.com/grafana/grafana/pkg/services/serviceaccounts" - "github.com/grafana/grafana/pkg/services/signingkeys" - "github.com/grafana/grafana/pkg/services/team" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" -) - -const ( - cacheExpirationTime = 5 * time.Minute - cacheCleanupInterval = 5 * time.Minute -) - -type OAuth2ServiceImpl struct { - cache *localcache.CacheService - memstore *storage.MemoryStore - cfg *setting.Cfg - sqlstore oauthserver.Store - oauthProvider fosite.OAuth2Provider - logger log.Logger - accessControl ac.AccessControl - acService ac.Service - saService serviceaccounts.ExtSvcAccountsService - userService user.Service - teamService team.Service - publicKey any -} - -func ProvideService(router routing.RouteRegister, bus bus.Bus, db db.DB, cfg *setting.Cfg, - extSvcAccSvc serviceaccounts.ExtSvcAccountsService, accessControl ac.AccessControl, acSvc ac.Service, userSvc user.Service, - teamSvc team.Service, keySvc signingkeys.Service, fmgmt *featuremgmt.FeatureManager) (*OAuth2ServiceImpl, error) { - if !fmgmt.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) { - return nil, nil - } - config := &fosite.Config{ - AccessTokenLifespan: cfg.OAuth2ServerAccessTokenLifespan, - TokenURL: fmt.Sprintf("%voauth2/token", cfg.AppURL), - AccessTokenIssuer: cfg.AppURL, - IDTokenIssuer: cfg.AppURL, - ScopeStrategy: fosite.WildcardScopeStrategy, - } - - s := &OAuth2ServiceImpl{ - cache: localcache.New(cacheExpirationTime, cacheCleanupInterval), - cfg: cfg, - accessControl: accessControl, - acService: acSvc, - memstore: storage.NewMemoryStore(), - sqlstore: store.NewStore(db), - logger: log.New("oauthserver"), - userService: userSvc, - saService: extSvcAccSvc, - teamService: teamSvc, - } - - api := api.NewAPI(router, s) - api.RegisterAPIEndpoints() - - bus.AddEventListener(s.handlePluginStateChanged) - - s.oauthProvider = newProvider(config, s, keySvc) - - return s, nil -} - -func newProvider(config *fosite.Config, storage any, signingKeyService signingkeys.Service) fosite.OAuth2Provider { - keyGetter := func(ctx context.Context) (any, error) { - _, key, err := signingKeyService.GetOrCreatePrivateKey(ctx, signingkeys.ServerPrivateKeyID, jose.ES256) - return key, err - } - return compose.Compose( - config, - storage, - &compose.CommonStrategy{ - CoreStrategy: compose.NewOAuth2JWTStrategy(keyGetter, compose.NewOAuth2HMACStrategy(config), config), - Signer: &jwt.DefaultSigner{GetPrivateKey: keyGetter}, - }, - compose.OAuth2ClientCredentialsGrantFactory, - compose.RFC7523AssertionGrantFactory, - - compose.OAuth2TokenIntrospectionFactory, - compose.OAuth2TokenRevocationFactory, - ) -} - -// HasExternalService returns whether an external service has been saved with that name. -func (s *OAuth2ServiceImpl) HasExternalService(ctx context.Context, name string) (bool, error) { - client, errRetrieve := s.sqlstore.GetExternalServiceByName(ctx, name) - if errRetrieve != nil && !errors.Is(errRetrieve, oauthserver.ErrClientNotFound) { - return false, errRetrieve - } - - return client != nil, nil -} - -// GetExternalService retrieves an external service from store by client_id. It populates the SelfPermissions and -// SignedInUser from the associated service account. -// For performance reason, the service uses caching. -func (s *OAuth2ServiceImpl) GetExternalService(ctx context.Context, id string) (*oauthserver.OAuthExternalService, error) { - entry, ok := s.cache.Get(id) - if ok { - client, ok := entry.(oauthserver.OAuthExternalService) - if ok { - s.logger.Debug("GetExternalService: cache hit", "id", id) - return &client, nil - } - } - - client, err := s.sqlstore.GetExternalService(ctx, id) - if err != nil { - return nil, err - } - - if err := s.setClientUser(ctx, client); err != nil { - return nil, err - } - - s.cache.Set(id, *client, cacheExpirationTime) - return client, nil -} - -// setClientUser sets the SignedInUser and SelfPermissions fields of the client -func (s *OAuth2ServiceImpl) setClientUser(ctx context.Context, client *oauthserver.OAuthExternalService) error { - if client.ServiceAccountID == oauthserver.NoServiceAccountID { - s.logger.Debug("GetExternalService: service has no service account, hence no permission", "client_id", client.ClientID, "name", client.Name) - - // Create a signed in user with no role and no permission - client.SignedInUser = &user.SignedInUser{ - UserID: oauthserver.NoServiceAccountID, - OrgID: oauthserver.TmpOrgID, - Name: client.Name, - Permissions: map[int64]map[string][]string{oauthserver.TmpOrgID: {}}, - } - return nil - } - - s.logger.Debug("GetExternalService: fetch permissions", "client_id", client.ClientID) - sa, err := s.saService.RetrieveExtSvcAccount(ctx, oauthserver.TmpOrgID, client.ServiceAccountID) - if err != nil { - s.logger.Error("GetExternalService: error fetching service account", "id", client.ClientID, "error", err) - return err - } - client.SignedInUser = &user.SignedInUser{ - UserID: sa.ID, - OrgID: oauthserver.TmpOrgID, - OrgRole: sa.Role, - Login: sa.Login, - Name: sa.Name, - Permissions: map[int64]map[string][]string{}, - } - client.SelfPermissions, err = s.acService.GetUserPermissions(ctx, client.SignedInUser, ac.Options{}) - if err != nil { - s.logger.Error("GetExternalService: error fetching permissions", "client_id", client.ClientID, "error", err) - return err - } - client.SignedInUser.Permissions[oauthserver.TmpOrgID] = ac.GroupScopesByAction(client.SelfPermissions) - return nil -} - -// GetExternalServiceNames get the names of External Service in store -func (s *OAuth2ServiceImpl) GetExternalServiceNames(ctx context.Context) ([]string, error) { - s.logger.Debug("Get external service names from store") - res, err := s.sqlstore.GetExternalServiceNames(ctx) - if err != nil { - s.logger.Error("Could not fetch clients from store", "error", err.Error()) - return nil, err - } - return res, nil -} - -func (s *OAuth2ServiceImpl) RemoveExternalService(ctx context.Context, name string) error { - s.logger.Info("Remove external service", "service", name) - - client, err := s.sqlstore.GetExternalServiceByName(ctx, name) - if err != nil { - if errors.Is(err, oauthserver.ErrClientNotFound) { - s.logger.Debug("No external service linked to this name", "name", name) - return nil - } - s.logger.Error("Error fetching external service", "name", name, "error", err.Error()) - return err - } - - // Since we will delete the service, clear cache entry - s.cache.Delete(client.ClientID) - - // Delete the OAuth client info in store - if err := s.sqlstore.DeleteExternalService(ctx, client.ClientID); err != nil { - s.logger.Error("Error deleting external service", "name", name, "error", err.Error()) - return err - } - s.logger.Debug("Deleted external service", "name", name, "client_id", client.ClientID) - - // Remove the associated service account - return s.saService.RemoveExtSvcAccount(ctx, oauthserver.TmpOrgID, slugify.Slugify(name)) -} - -// SaveExternalService creates or updates an external service in the database, it generates client_id and secrets and -// it ensures that the associated service account has the correct permissions. -// Database consistency is not guaranteed, consider changing this in the future. -func (s *OAuth2ServiceImpl) SaveExternalService(ctx context.Context, registration *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) { - if registration == nil { - s.logger.Warn("RegisterExternalService called without registration") - return nil, nil - } - slug := registration.Name - s.logger.Info("Registering external service", "external service", slug) - - // Check if the client already exists in store - client, errFetchExtSvc := s.sqlstore.GetExternalServiceByName(ctx, slug) - if errFetchExtSvc != nil && !errors.Is(errFetchExtSvc, oauthserver.ErrClientNotFound) { - s.logger.Error("Error fetching service", "external service", slug, "error", errFetchExtSvc) - return nil, errFetchExtSvc - } - // Otherwise, create a new client - if client == nil { - s.logger.Debug("External service does not yet exist", "external service", slug) - client = &oauthserver.OAuthExternalService{ - Name: slug, - ServiceAccountID: oauthserver.NoServiceAccountID, - Audiences: s.cfg.AppURL, - } - } - - // Parse registration form to compute required permissions for the client - client.SelfPermissions, client.ImpersonatePermissions = s.handleRegistrationPermissions(registration) - - if registration.OAuthProviderCfg == nil { - return nil, errors.New("missing oauth provider configuration") - } - - if registration.OAuthProviderCfg.RedirectURI != nil { - client.RedirectURI = *registration.OAuthProviderCfg.RedirectURI - } - - var errGenCred error - client.ClientID, client.Secret, errGenCred = s.genCredentials() - if errGenCred != nil { - s.logger.Error("Error generating credentials", "client", client.LogID(), "error", errGenCred) - return nil, errGenCred - } - - grantTypes := s.computeGrantTypes(registration.Self.Enabled, registration.Impersonation.Enabled) - client.GrantTypes = strings.Join(grantTypes, ",") - - // Handle key options - s.logger.Debug("Handle key options") - keys, err := s.handleKeyOptions(ctx, registration.OAuthProviderCfg.Key) - if err != nil { - s.logger.Error("Error handling key options", "client", client.LogID(), "error", err) - return nil, err - } - if keys != nil { - client.PublicPem = []byte(keys.PublicPem) - } - dto := client.ToExternalService(keys) - - hashedSecret, err := bcrypt.GenerateFromPassword([]byte(client.Secret), bcrypt.DefaultCost) - if err != nil { - s.logger.Error("Error hashing secret", "client", client.LogID(), "error", err) - return nil, err - } - client.Secret = string(hashedSecret) - - s.logger.Debug("Save service account") - saID, errSaveServiceAccount := s.saService.ManageExtSvcAccount(ctx, &serviceaccounts.ManageExtSvcAccountCmd{ - ExtSvcSlug: slugify.Slugify(client.Name), - Enabled: registration.Self.Enabled, - OrgID: oauthserver.TmpOrgID, - Permissions: client.SelfPermissions, - }) - if errSaveServiceAccount != nil { - return nil, errSaveServiceAccount - } - client.ServiceAccountID = saID - - err = s.sqlstore.SaveExternalService(ctx, client) - if err != nil { - s.logger.Error("Error saving external service", "client", client.LogID(), "error", err) - return nil, err - } - s.logger.Debug("Registered", "client", client.LogID()) - return dto, nil -} - -// randString generates a a cryptographically secure random string of n bytes -func (s *OAuth2ServiceImpl) randString(n int) (string, error) { - res := make([]byte, n) - if _, err := rand.Read(res); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(res), nil -} - -func (s *OAuth2ServiceImpl) genCredentials() (string, string, error) { - id, err := s.randString(20) - if err != nil { - return "", "", err - } - // client_secret must be at least 32 bytes long - secret, err := s.randString(32) - if err != nil { - return "", "", err - } - return id, secret, err -} - -func (s *OAuth2ServiceImpl) computeGrantTypes(selfAccessEnabled, impersonationEnabled bool) []string { - grantTypes := []string{} - - if selfAccessEnabled { - grantTypes = append(grantTypes, string(fosite.GrantTypeClientCredentials)) - } - - if impersonationEnabled { - grantTypes = append(grantTypes, string(fosite.GrantTypeJWTBearer)) - } - - return grantTypes -} - -func (s *OAuth2ServiceImpl) handleKeyOptions(ctx context.Context, keyOption *extsvcauth.KeyOption) (*extsvcauth.KeyResult, error) { - if keyOption == nil { - return nil, fmt.Errorf("keyOption is nil") - } - - var publicPem, privatePem string - - if keyOption.Generate { - switch s.cfg.OAuth2ServerGeneratedKeyTypeForClient { - case "RSA": - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return nil, err - } - publicPem = string(pem.EncodeToMemory(&pem.Block{ - Type: "RSA PUBLIC KEY", - Bytes: x509.MarshalPKCS1PublicKey(&privateKey.PublicKey), - })) - privatePem = string(pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(privateKey), - })) - s.logger.Debug("RSA key has been generated") - default: // default to ECDSA - privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, err - } - publicDer, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) - if err != nil { - return nil, err - } - - privateDer, err := x509.MarshalPKCS8PrivateKey(privateKey) - if err != nil { - return nil, err - } - - publicPem = string(pem.EncodeToMemory(&pem.Block{ - Type: "PUBLIC KEY", - Bytes: publicDer, - })) - privatePem = string(pem.EncodeToMemory(&pem.Block{ - Type: "PRIVATE KEY", - Bytes: privateDer, - })) - s.logger.Debug("ECDSA key has been generated") - } - - return &extsvcauth.KeyResult{ - PrivatePem: privatePem, - PublicPem: publicPem, - Generated: true, - }, nil - } - - // TODO MVP allow specifying a URL to get the public key - // if registration.Key.URL != "" { - // return &oauthserver.KeyResult{ - // URL: registration.Key.URL, - // }, nil - // } - - if keyOption.PublicPEM != "" { - pemEncoded, err := base64.StdEncoding.DecodeString(keyOption.PublicPEM) - if err != nil { - s.logger.Error("Cannot decode base64 encoded PEM string", "error", err) - } - _, err = utils.ParsePublicKeyPem(pemEncoded) - if err != nil { - s.logger.Error("Cannot parse PEM encoded string", "error", err) - return nil, err - } - return &extsvcauth.KeyResult{ - PublicPem: string(pemEncoded), - }, nil - } - - return nil, fmt.Errorf("at least one key option must be specified") -} - -// handleRegistrationPermissions parses the registration form to retrieve requested permissions and adds default -// permissions when impersonation is requested -func (*OAuth2ServiceImpl) handleRegistrationPermissions(registration *extsvcauth.ExternalServiceRegistration) ([]ac.Permission, []ac.Permission) { - selfPermissions := registration.Self.Permissions - impersonatePermissions := []ac.Permission{} - - if len(registration.Impersonation.Permissions) > 0 { - requiredForToken := []ac.Permission{ - {Action: ac.ActionUsersRead, Scope: oauthserver.ScopeGlobalUsersSelf}, - {Action: ac.ActionUsersPermissionsRead, Scope: oauthserver.ScopeUsersSelf}, - } - if registration.Impersonation.Groups { - requiredForToken = append(requiredForToken, ac.Permission{Action: ac.ActionTeamsRead, Scope: oauthserver.ScopeTeamsSelf}) - } - impersonatePermissions = append(requiredForToken, registration.Impersonation.Permissions...) - selfPermissions = append(selfPermissions, ac.Permission{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll}) - } - return selfPermissions, impersonatePermissions -} - -// handlePluginStateChanged reset the client authorized grant_types according to the plugin state -func (s *OAuth2ServiceImpl) handlePluginStateChanged(ctx context.Context, event *pluginsettings.PluginStateChangedEvent) error { - s.logger.Debug("Plugin state changed", "pluginId", event.PluginId, "enabled", event.Enabled) - - if event.OrgId != extsvcauth.TmpOrgID { - s.logger.Debug("External Service not tied to this organization", "OrgId", event.OrgId) - return nil - } - - // Retrieve client associated to the plugin - client, err := s.sqlstore.GetExternalServiceByName(ctx, event.PluginId) - if err != nil { - if errors.Is(err, oauthserver.ErrClientNotFound) { - s.logger.Debug("No external service linked to this plugin", "pluginId", event.PluginId) - return nil - } - s.logger.Error("Error fetching service", "pluginId", event.PluginId, "error", err.Error()) - return err - } - - // Since we will change the grants, clear cache entry - s.cache.Delete(client.ClientID) - - if !event.Enabled { - // Plugin is disabled => remove all grant_types - return s.sqlstore.UpdateExternalServiceGrantTypes(ctx, client.ClientID, "") - } - - if err := s.setClientUser(ctx, client); err != nil { - return err - } - - // The plugin has self permissions (not only impersonate) - canOnlyImpersonate := len(client.SelfPermissions) == 1 && (client.SelfPermissions[0].Action == ac.ActionUsersImpersonate) - selfEnabled := len(client.SelfPermissions) > 0 && !canOnlyImpersonate - // The plugin declared impersonate permissions - impersonateEnabled := len(client.ImpersonatePermissions) > 0 - - grantTypes := s.computeGrantTypes(selfEnabled, impersonateEnabled) - - return s.sqlstore.UpdateExternalServiceGrantTypes(ctx, client.ClientID, strings.Join(grantTypes, ",")) -} diff --git a/pkg/services/extsvcauth/oauthserver/oasimpl/service_test.go b/pkg/services/extsvcauth/oauthserver/oasimpl/service_test.go deleted file mode 100644 index 42a77a24e13fd..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/oasimpl/service_test.go +++ /dev/null @@ -1,625 +0,0 @@ -package oasimpl - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "encoding/base64" - "fmt" - "slices" - "testing" - "time" - - "github.com/ory/fosite" - "github.com/ory/fosite/storage" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/infra/log" - ac "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" - "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - "github.com/grafana/grafana/pkg/services/extsvcauth" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/oastest" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" - sa "github.com/grafana/grafana/pkg/services/serviceaccounts" - saTests "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" - "github.com/grafana/grafana/pkg/services/signingkeys/signingkeystest" - "github.com/grafana/grafana/pkg/services/team/teamtest" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/services/user/usertest" - "github.com/grafana/grafana/pkg/setting" -) - -const ( - AppURL = "https://oauth.test/" - TokenURL = AppURL + "oauth2/token" -) - -var ( - pk, _ = rsa.GenerateKey(rand.Reader, 4096) - Client1Key, _ = rsa.GenerateKey(rand.Reader, 4096) -) - -type TestEnv struct { - S *OAuth2ServiceImpl - Cfg *setting.Cfg - AcStore *actest.MockStore - OAuthStore *oastest.MockStore - UserService *usertest.FakeUserService - TeamService *teamtest.FakeService - SAService *saTests.MockExtSvcAccountsService -} - -func setupTestEnv(t *testing.T) *TestEnv { - t.Helper() - - cfg := setting.NewCfg() - cfg.AppURL = AppURL - - config := &fosite.Config{ - AccessTokenLifespan: time.Hour, - TokenURL: TokenURL, - AccessTokenIssuer: AppURL, - IDTokenIssuer: AppURL, - ScopeStrategy: fosite.WildcardScopeStrategy, - } - - fmgt := featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth) - - env := &TestEnv{ - Cfg: cfg, - AcStore: &actest.MockStore{}, - OAuthStore: &oastest.MockStore{}, - UserService: usertest.NewUserServiceFake(), - TeamService: teamtest.NewFakeService(), - SAService: saTests.NewMockExtSvcAccountsService(t), - } - env.S = &OAuth2ServiceImpl{ - cache: localcache.New(cacheExpirationTime, cacheCleanupInterval), - cfg: cfg, - accessControl: acimpl.ProvideAccessControl(cfg), - acService: acimpl.ProvideOSSService(cfg, env.AcStore, localcache.New(0, 0), fmgt), - memstore: storage.NewMemoryStore(), - sqlstore: env.OAuthStore, - logger: log.New("oauthserver.test"), - userService: env.UserService, - saService: env.SAService, - teamService: env.TeamService, - publicKey: &pk.PublicKey, - } - - env.S.oauthProvider = newProvider(config, env.S, &signingkeystest.FakeSigningKeysService{ - ExpectedSinger: pk, - ExpectedKeyID: "default", - ExpectedError: nil, - }) - - return env -} - -func TestOAuth2ServiceImpl_SaveExternalService(t *testing.T) { - const serviceName = "my-ext-service" - - tests := []struct { - name string - init func(*TestEnv) - cmd *extsvcauth.ExternalServiceRegistration - mockChecks func(*testing.T, *TestEnv) - wantErr bool - }{ - { - name: "should create a new client without permissions", - init: func(env *TestEnv) { - // No client at the beginning - env.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(nil, oauthserver.ErrClientNotFoundFn(serviceName)) - env.OAuthStore.On("SaveExternalService", mock.Anything, mock.Anything).Return(nil) - - // Return a service account ID - env.SAService.On("ManageExtSvcAccount", mock.Anything, mock.Anything).Return(int64(0), nil) - }, - cmd: &extsvcauth.ExternalServiceRegistration{ - Name: serviceName, - OAuthProviderCfg: &extsvcauth.OAuthProviderCfg{Key: &extsvcauth.KeyOption{Generate: true}}, - }, - mockChecks: func(t *testing.T, env *TestEnv) { - env.OAuthStore.AssertCalled(t, "GetExternalServiceByName", mock.Anything, mock.MatchedBy(func(name string) bool { - return name == serviceName - })) - env.OAuthStore.AssertCalled(t, "SaveExternalService", mock.Anything, mock.MatchedBy(func(client *oauthserver.OAuthExternalService) bool { - return client.Name == serviceName && client.ClientID != "" && client.Secret != "" && - len(client.GrantTypes) == 0 && len(client.PublicPem) > 0 && client.ServiceAccountID == 0 && - len(client.ImpersonatePermissions) == 0 - })) - }, - }, - { - name: "should allow client credentials grant with correct permissions", - init: func(env *TestEnv) { - // No client at the beginning - env.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(nil, oauthserver.ErrClientNotFoundFn(serviceName)) - env.OAuthStore.On("SaveExternalService", mock.Anything, mock.Anything).Return(nil) - - // Return a service account ID - env.SAService.On("ManageExtSvcAccount", mock.Anything, mock.Anything).Return(int64(10), nil) - }, - cmd: &extsvcauth.ExternalServiceRegistration{ - Name: serviceName, - Self: extsvcauth.SelfCfg{ - Enabled: true, - Permissions: []ac.Permission{{Action: ac.ActionUsersRead, Scope: ac.ScopeUsersAll}}, - }, - OAuthProviderCfg: &extsvcauth.OAuthProviderCfg{Key: &extsvcauth.KeyOption{Generate: true}}, - }, - mockChecks: func(t *testing.T, env *TestEnv) { - env.OAuthStore.AssertCalled(t, "GetExternalServiceByName", mock.Anything, mock.MatchedBy(func(name string) bool { - return name == serviceName - })) - env.OAuthStore.AssertCalled(t, "SaveExternalService", mock.Anything, mock.MatchedBy(func(client *oauthserver.OAuthExternalService) bool { - return client.Name == serviceName && len(client.ClientID) > 0 && len(client.Secret) > 0 && - client.GrantTypes == string(fosite.GrantTypeClientCredentials) && - len(client.PublicPem) > 0 && client.ServiceAccountID == 10 && - len(client.ImpersonatePermissions) == 0 && - len(client.SelfPermissions) > 0 - })) - // Check that despite no credential_grants the service account still has a permission to impersonate users - env.SAService.AssertCalled(t, "ManageExtSvcAccount", mock.Anything, - mock.MatchedBy(func(cmd *sa.ManageExtSvcAccountCmd) bool { - return len(cmd.Permissions) == 1 && cmd.Permissions[0] == ac.Permission{Action: ac.ActionUsersRead, Scope: ac.ScopeUsersAll} - })) - }, - }, - { - name: "should allow jwt bearer grant and set default permissions", - init: func(env *TestEnv) { - // No client at the beginning - env.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(nil, oauthserver.ErrClientNotFoundFn(serviceName)) - env.OAuthStore.On("SaveExternalService", mock.Anything, mock.Anything).Return(nil) - // The service account needs to be created with a permission to impersonate users - env.SAService.On("ManageExtSvcAccount", mock.Anything, mock.Anything).Return(int64(10), nil) - }, - cmd: &extsvcauth.ExternalServiceRegistration{ - Name: serviceName, - OAuthProviderCfg: &extsvcauth.OAuthProviderCfg{Key: &extsvcauth.KeyOption{Generate: true}}, - Impersonation: extsvcauth.ImpersonationCfg{ - Enabled: true, - Groups: true, - Permissions: []ac.Permission{{Action: "dashboards:read", Scope: "dashboards:*"}}, - }, - }, - mockChecks: func(t *testing.T, env *TestEnv) { - // Check that the external service impersonate permissions contains the default permissions required to populate the access token - env.OAuthStore.AssertCalled(t, "SaveExternalService", mock.Anything, mock.MatchedBy(func(client *oauthserver.OAuthExternalService) bool { - impPerm := client.ImpersonatePermissions - return slices.Contains(impPerm, ac.Permission{Action: "dashboards:read", Scope: "dashboards:*"}) && - slices.Contains(impPerm, ac.Permission{Action: ac.ActionUsersRead, Scope: oauthserver.ScopeGlobalUsersSelf}) && - slices.Contains(impPerm, ac.Permission{Action: ac.ActionUsersPermissionsRead, Scope: oauthserver.ScopeUsersSelf}) && - slices.Contains(impPerm, ac.Permission{Action: ac.ActionTeamsRead, Scope: oauthserver.ScopeTeamsSelf}) - })) - // Check that despite no credential_grants the service account still has a permission to impersonate users - env.SAService.AssertCalled(t, "ManageExtSvcAccount", mock.Anything, - mock.MatchedBy(func(cmd *sa.ManageExtSvcAccountCmd) bool { - return len(cmd.Permissions) == 1 && cmd.Permissions[0] == ac.Permission{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll} - })) - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - env := setupTestEnv(t) - if tt.init != nil { - tt.init(env) - } - - dto, err := env.S.SaveExternalService(context.Background(), tt.cmd) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - // Check that we generated client ID and secret - require.NotEmpty(t, dto.ID) - require.NotEmpty(t, dto.Secret) - - // Check that we have generated keys and that we correctly return them - if tt.cmd.OAuthProviderCfg.Key != nil && tt.cmd.OAuthProviderCfg.Key.Generate { - require.NotNil(t, dto.OAuthExtra.KeyResult) - require.True(t, dto.OAuthExtra.KeyResult.Generated) - require.NotEmpty(t, dto.OAuthExtra.KeyResult.PublicPem) - require.NotEmpty(t, dto.OAuthExtra.KeyResult.PrivatePem) - } - - // Check that we computed grant types and created or updated the service account - if tt.cmd.Self.Enabled { - require.NotNil(t, dto.OAuthExtra.GrantTypes) - require.Contains(t, dto.OAuthExtra.GrantTypes, fosite.GrantTypeClientCredentials, "grant types should contain client_credentials") - } else { - require.NotContains(t, dto.OAuthExtra.GrantTypes, fosite.GrantTypeClientCredentials, "grant types should not contain client_credentials") - } - // Check that we updated grant types - if tt.cmd.Impersonation.Enabled { - require.NotNil(t, dto.OAuthExtra.GrantTypes) - require.Contains(t, dto.OAuthExtra.GrantTypes, fosite.GrantTypeJWTBearer, "grant types should contain JWT Bearer grant") - } else { - require.NotContains(t, dto.OAuthExtra.GrantTypes, fosite.GrantTypeJWTBearer, "grant types should not contain JWT Bearer grant") - } - - // Check that mocks were called as expected - env.OAuthStore.AssertExpectations(t) - env.SAService.AssertExpectations(t) - env.AcStore.AssertExpectations(t) - - // Additional checks performed - if tt.mockChecks != nil { - tt.mockChecks(t, env) - } - }) - } -} - -func TestOAuth2ServiceImpl_GetExternalService(t *testing.T) { - const serviceName = "my-ext-service" - - dummyClient := func() *oauthserver.OAuthExternalService { - return &oauthserver.OAuthExternalService{ - Name: serviceName, - ClientID: "RANDOMID", - Secret: "RANDOMSECRET", - GrantTypes: "client_credentials", - PublicPem: []byte("-----BEGIN PUBLIC KEY-----"), - ServiceAccountID: 1, - } - } - cachedClient := &oauthserver.OAuthExternalService{ - Name: serviceName, - ClientID: "RANDOMID", - Secret: "RANDOMSECRET", - GrantTypes: "client_credentials", - PublicPem: []byte("-----BEGIN PUBLIC KEY-----"), - ServiceAccountID: 1, - SelfPermissions: []ac.Permission{{Action: "users:impersonate", Scope: "users:*"}}, - SignedInUser: &user.SignedInUser{ - UserID: 1, - Permissions: map[int64]map[string][]string{ - 1: { - "users:impersonate": {"users:*"}, - }, - }, - }, - } - testCases := []struct { - name string - init func(*TestEnv) - mockChecks func(*testing.T, *TestEnv) - wantPerm []ac.Permission - wantErr bool - }{ - { - name: "should hit the cache", - init: func(env *TestEnv) { - env.S.cache.Set(serviceName, *cachedClient, time.Minute) - }, - mockChecks: func(t *testing.T, env *TestEnv) { - env.OAuthStore.AssertNotCalled(t, "GetExternalService", mock.Anything, mock.Anything) - }, - wantPerm: []ac.Permission{{Action: "users:impersonate", Scope: "users:*"}}, - }, - { - name: "should return error when the client was not found", - init: func(env *TestEnv) { - env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(nil, oauthserver.ErrClientNotFoundFn(serviceName)) - }, - wantErr: true, - }, - { - name: "should return error when the service account was not found", - init: func(env *TestEnv) { - env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(dummyClient(), nil) - env.SAService.On("RetrieveExtSvcAccount", mock.Anything, int64(1), int64(1)).Return(&sa.ExtSvcAccount{}, sa.ErrServiceAccountNotFound) - }, - mockChecks: func(t *testing.T, env *TestEnv) { - env.OAuthStore.AssertCalled(t, "GetExternalService", mock.Anything, mock.Anything) - env.SAService.AssertCalled(t, "RetrieveExtSvcAccount", mock.Anything, 1, 1) - }, - wantErr: true, - }, - { - name: "should return error when the service account has no permissions", - init: func(env *TestEnv) { - env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(dummyClient(), nil) - env.SAService.On("RetrieveExtSvcAccount", mock.Anything, int64(1), int64(1)).Return(&sa.ExtSvcAccount{}, nil) - env.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything).Return(nil, fmt.Errorf("some error")) - }, - mockChecks: func(t *testing.T, env *TestEnv) { - env.OAuthStore.AssertCalled(t, "GetExternalService", mock.Anything, mock.Anything) - env.SAService.AssertCalled(t, "RetrieveExtSvcAccount", mock.Anything, 1, 1) - }, - wantErr: true, - }, - { - name: "should return correctly", - init: func(env *TestEnv) { - env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(dummyClient(), nil) - env.SAService.On("RetrieveExtSvcAccount", mock.Anything, int64(1), int64(1)).Return(&sa.ExtSvcAccount{ID: 1}, nil) - env.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything).Return([]ac.Permission{{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll}}, nil) - }, - mockChecks: func(t *testing.T, env *TestEnv) { - env.OAuthStore.AssertCalled(t, "GetExternalService", mock.Anything, mock.Anything) - env.SAService.AssertCalled(t, "RetrieveExtSvcAccount", mock.Anything, int64(1), int64(1)) - }, - wantPerm: []ac.Permission{{Action: "users:impersonate", Scope: "users:*"}}, - }, - { - name: "should return correctly when the client has no service account", - init: func(env *TestEnv) { - client := &oauthserver.OAuthExternalService{ - Name: serviceName, - ClientID: "RANDOMID", - Secret: "RANDOMSECRET", - GrantTypes: "client_credentials", - PublicPem: []byte("-----BEGIN PUBLIC KEY-----"), - ServiceAccountID: oauthserver.NoServiceAccountID, - } - env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(client, nil) - }, - mockChecks: func(t *testing.T, env *TestEnv) { - env.OAuthStore.AssertCalled(t, "GetExternalService", mock.Anything, mock.Anything) - }, - wantPerm: []ac.Permission{}, - }, - } - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - env := setupTestEnv(t) - if tt.init != nil { - tt.init(env) - } - - client, err := env.S.GetExternalService(context.Background(), serviceName) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - if tt.mockChecks != nil { - tt.mockChecks(t, env) - } - - require.Equal(t, serviceName, client.Name) - require.ElementsMatch(t, client.SelfPermissions, tt.wantPerm) - assertArrayInMap(t, client.SignedInUser.Permissions[1], ac.GroupScopesByAction(tt.wantPerm)) - - env.OAuthStore.AssertExpectations(t) - env.SAService.AssertExpectations(t) - }) - } -} - -func assertArrayInMap[K comparable, V string](t *testing.T, m1 map[K][]V, m2 map[K][]V) { - for k, v := range m1 { - require.Contains(t, m2, k) - require.ElementsMatch(t, v, m2[k]) - } -} - -func TestOAuth2ServiceImpl_RemoveExternalService(t *testing.T) { - const serviceName = "my-ext-service" - const clientID = "RANDOMID" - - dummyClient := &oauthserver.OAuthExternalService{ - Name: serviceName, - ClientID: clientID, - ServiceAccountID: 1, - } - - testCases := []struct { - name string - init func(*TestEnv) - }{ - { - name: "should do nothing on not found", - init: func(env *TestEnv) { - env.OAuthStore.On("GetExternalServiceByName", mock.Anything, serviceName).Return(nil, oauthserver.ErrClientNotFoundFn(serviceName)) - }, - }, - { - name: "should remove the external service and its associated service account", - init: func(env *TestEnv) { - env.OAuthStore.On("GetExternalServiceByName", mock.Anything, serviceName).Return(dummyClient, nil) - env.OAuthStore.On("DeleteExternalService", mock.Anything, clientID).Return(nil) - env.SAService.On("RemoveExtSvcAccount", mock.Anything, oauthserver.TmpOrgID, serviceName).Return(nil) - }, - }, - } - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - env := setupTestEnv(t) - if tt.init != nil { - tt.init(env) - } - - err := env.S.RemoveExternalService(context.Background(), serviceName) - require.NoError(t, err) - - env.OAuthStore.AssertExpectations(t) - env.SAService.AssertExpectations(t) - }) - } -} - -func TestTestOAuth2ServiceImpl_handleKeyOptions(t *testing.T) { - testCases := []struct { - name string - keyOption *extsvcauth.KeyOption - expectedResult *extsvcauth.KeyResult - wantErr bool - }{ - { - name: "should return error when the key option is nil", - wantErr: true, - }, - { - name: "should return error when the key option is empty", - keyOption: &extsvcauth.KeyOption{}, - wantErr: true, - }, - { - name: "should return successfully when PublicPEM is specified", - keyOption: &extsvcauth.KeyOption{ - PublicPEM: base64.StdEncoding.EncodeToString([]byte(`-----BEGIN PUBLIC KEY----- -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbsGtoGJTopAIbhqy49/vyCJuDot+ -mgGaC8vUIigFQVsVB+v/HZ4yG1Rcvysig+tyNk1dZQpozpFc2dGmzHlGhw== ------END PUBLIC KEY-----`)), - }, - wantErr: false, - expectedResult: &extsvcauth.KeyResult{ - PublicPem: `-----BEGIN PUBLIC KEY----- -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbsGtoGJTopAIbhqy49/vyCJuDot+ -mgGaC8vUIigFQVsVB+v/HZ4yG1Rcvysig+tyNk1dZQpozpFc2dGmzHlGhw== ------END PUBLIC KEY-----`, - Generated: false, - PrivatePem: "", - URL: "", - }, - }, - } - env := setupTestEnv(t) - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - result, err := env.S.handleKeyOptions(context.Background(), tc.keyOption) - if tc.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - require.Equal(t, tc.expectedResult, result) - }) - } - - t.Run("should generate an ECDSA key pair (default) when generate key option is specified", func(t *testing.T) { - result, err := env.S.handleKeyOptions(context.Background(), &extsvcauth.KeyOption{Generate: true}) - - require.NoError(t, err) - require.NotNil(t, result.PrivatePem) - require.NotNil(t, result.PublicPem) - require.True(t, result.Generated) - }) - - t.Run("should generate an RSA key pair when generate key option is specified", func(t *testing.T) { - env.S.cfg.OAuth2ServerGeneratedKeyTypeForClient = "RSA" - result, err := env.S.handleKeyOptions(context.Background(), &extsvcauth.KeyOption{Generate: true}) - - require.NoError(t, err) - require.NotNil(t, result.PrivatePem) - require.NotNil(t, result.PublicPem) - require.True(t, result.Generated) - }) -} - -func TestOAuth2ServiceImpl_handlePluginStateChanged(t *testing.T) { - pluginID := "my-app" - clientID := "RANDOMID" - impersonatePermission := []ac.Permission{{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll}} - selfPermission := append(impersonatePermission, ac.Permission{Action: ac.ActionUsersRead, Scope: ac.ScopeUsersAll}) - saID := int64(101) - client := &oauthserver.OAuthExternalService{ - ID: 11, - Name: pluginID, - ClientID: clientID, - Secret: "SECRET", - ServiceAccountID: saID, - } - clientWithImpersonate := &oauthserver.OAuthExternalService{ - ID: 11, - Name: pluginID, - ClientID: clientID, - Secret: "SECRET", - ImpersonatePermissions: []ac.Permission{ - {Action: ac.ActionUsersRead, Scope: ac.ScopeUsersAll}, - }, - ServiceAccountID: saID, - } - extSvcAcc := &sa.ExtSvcAccount{ - ID: saID, - Login: "sa-my-app", - Name: pluginID, - OrgID: extsvcauth.TmpOrgID, - IsDisabled: false, - Role: org.RoleNone, - } - - tests := []struct { - name string - init func(*TestEnv) - cmd *pluginsettings.PluginStateChangedEvent - }{ - { - name: "should do nothing with not found", - init: func(te *TestEnv) { - te.OAuthStore.On("GetExternalServiceByName", mock.Anything, "unknown").Return(nil, oauthserver.ErrClientNotFoundFn("unknown")) - }, - cmd: &pluginsettings.PluginStateChangedEvent{PluginId: "unknown", OrgId: 1, Enabled: false}, - }, - { - name: "should remove grants", - init: func(te *TestEnv) { - te.OAuthStore.On("GetExternalServiceByName", mock.Anything, pluginID).Return(clientWithImpersonate, nil) - te.OAuthStore.On("UpdateExternalServiceGrantTypes", mock.Anything, clientID, "").Return(nil) - }, - cmd: &pluginsettings.PluginStateChangedEvent{PluginId: pluginID, OrgId: 1, Enabled: false}, - }, - { - name: "should set both grants", - init: func(te *TestEnv) { - te.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(clientWithImpersonate, nil) - te.SAService.On("RetrieveExtSvcAccount", mock.Anything, extsvcauth.TmpOrgID, saID).Return(extSvcAcc, nil) - te.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything, mock.Anything).Return(selfPermission, nil) - te.OAuthStore.On("UpdateExternalServiceGrantTypes", mock.Anything, clientID, - string(fosite.GrantTypeClientCredentials)+","+string(fosite.GrantTypeJWTBearer)).Return(nil) - }, - cmd: &pluginsettings.PluginStateChangedEvent{PluginId: pluginID, OrgId: 1, Enabled: true}, - }, - { - name: "should set impersonate grant", - init: func(te *TestEnv) { - te.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(clientWithImpersonate, nil) - te.SAService.On("RetrieveExtSvcAccount", mock.Anything, extsvcauth.TmpOrgID, saID).Return(extSvcAcc, nil) - te.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything, mock.Anything).Return(impersonatePermission, nil) - te.OAuthStore.On("UpdateExternalServiceGrantTypes", mock.Anything, clientID, string(fosite.GrantTypeJWTBearer)).Return(nil) - }, - cmd: &pluginsettings.PluginStateChangedEvent{PluginId: pluginID, OrgId: 1, Enabled: true}, - }, - { - name: "should set client_credentials grant", - init: func(te *TestEnv) { - te.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(client, nil) - te.SAService.On("RetrieveExtSvcAccount", mock.Anything, extsvcauth.TmpOrgID, saID).Return(extSvcAcc, nil) - te.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything, mock.Anything).Return(selfPermission, nil) - te.OAuthStore.On("UpdateExternalServiceGrantTypes", mock.Anything, clientID, string(fosite.GrantTypeClientCredentials)).Return(nil) - }, - cmd: &pluginsettings.PluginStateChangedEvent{PluginId: pluginID, OrgId: 1, Enabled: true}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - env := setupTestEnv(t) - if tt.init != nil { - tt.init(env) - } - - err := env.S.handlePluginStateChanged(context.Background(), tt.cmd) - require.NoError(t, err) - - // Check that mocks were called as expected - env.OAuthStore.AssertExpectations(t) - env.SAService.AssertExpectations(t) - env.AcStore.AssertExpectations(t) - }) - } -} diff --git a/pkg/services/extsvcauth/oauthserver/oasimpl/session.go b/pkg/services/extsvcauth/oauthserver/oasimpl/session.go deleted file mode 100644 index 6d184c8c6d880..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/oasimpl/session.go +++ /dev/null @@ -1,16 +0,0 @@ -package oasimpl - -import ( - "github.com/ory/fosite/handler/oauth2" - "github.com/ory/fosite/token/jwt" -) - -func NewAuthSession() *oauth2.JWTSession { - sess := &oauth2.JWTSession{ - JWTClaims: new(jwt.JWTClaims), - JWTHeader: new(jwt.Headers), - } - // Our tokens will follow the RFC9068 - sess.JWTHeader.Add("typ", "at+jwt") - return sess -} diff --git a/pkg/services/extsvcauth/oauthserver/oasimpl/token.go b/pkg/services/extsvcauth/oauthserver/oasimpl/token.go deleted file mode 100644 index 2dc18d6370dd1..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/oasimpl/token.go +++ /dev/null @@ -1,351 +0,0 @@ -package oasimpl - -import ( - "context" - "errors" - "fmt" - "net/http" - "strconv" - - "github.com/ory/fosite" - "github.com/ory/fosite/handler/oauth2" - - ac "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/utils" - "github.com/grafana/grafana/pkg/services/team" - "github.com/grafana/grafana/pkg/services/user" -) - -// HandleTokenRequest handles the client's OAuth2 query to obtain an access_token by presenting its authorization -// grant (ex: client_credentials, jwtbearer) -func (s *OAuth2ServiceImpl) HandleTokenRequest(rw http.ResponseWriter, req *http.Request) { - // This context will be passed to all methods. - ctx := req.Context() - - // Create an empty session object which will be passed to the request handlers - oauthSession := NewAuthSession() - - // This will create an access request object and iterate through the registered TokenEndpointHandlers to validate the request. - accessRequest, err := s.oauthProvider.NewAccessRequest(ctx, req, oauthSession) - if err != nil { - s.writeAccessError(ctx, rw, accessRequest, err) - return - } - - client, err := s.GetExternalService(ctx, accessRequest.GetClient().GetID()) - if err != nil || client == nil { - s.oauthProvider.WriteAccessError(ctx, rw, accessRequest, &fosite.RFC6749Error{ - DescriptionField: "Could not find the requested subject.", - ErrorField: "not_found", - CodeField: http.StatusBadRequest, - }) - return - } - oauthSession.JWTClaims.Add("client_id", client.ClientID) - - errClientCred := s.handleClientCredentials(ctx, accessRequest, oauthSession, client) - if errClientCred != nil { - s.writeAccessError(ctx, rw, accessRequest, errClientCred) - return - } - - errJWTBearer := s.handleJWTBearer(ctx, accessRequest, oauthSession, client) - if errJWTBearer != nil { - s.writeAccessError(ctx, rw, accessRequest, errJWTBearer) - return - } - - // All tokens we generate in this service should target Grafana's API. - accessRequest.GrantAudience(s.cfg.AppURL) - - // Prepare response, fosite handlers will populate the token. - response, err := s.oauthProvider.NewAccessResponse(ctx, accessRequest) - if err != nil { - s.writeAccessError(ctx, rw, accessRequest, err) - return - } - s.oauthProvider.WriteAccessResponse(ctx, rw, accessRequest, response) -} - -// writeAccessError logs the error then uses fosite to write the error back to the user. -func (s *OAuth2ServiceImpl) writeAccessError(ctx context.Context, rw http.ResponseWriter, accessRequest fosite.AccessRequester, err error) { - var fositeErr *fosite.RFC6749Error - if errors.As(err, &fositeErr) { - s.logger.Error("Description", fositeErr.DescriptionField, "hint", fositeErr.HintField, "error", fositeErr.ErrorField) - } else { - s.logger.Error("Error", err) - } - s.oauthProvider.WriteAccessError(ctx, rw, accessRequest, err) -} - -// splitOAuthScopes sort scopes that are generic (profile, email, groups, entitlements) from scopes -// that are RBAC actions (used to further restrict the entitlements embedded in the access_token) -func splitOAuthScopes(requestedScopes fosite.Arguments) (map[string]bool, map[string]bool) { - actionsFilter := map[string]bool{} - claimsFilter := map[string]bool{} - for _, scope := range requestedScopes { - switch scope { - case "profile", "email", "groups", "entitlements": - claimsFilter[scope] = true - default: - actionsFilter[scope] = true - } - } - return actionsFilter, claimsFilter -} - -// handleJWTBearer populates the "impersonation" access_token generated by fosite to match the rfc9068 specifications (entitlements, groups). -// It ensures that the user can be impersonated, that the generated token audiences only contain Grafana's AppURL (and token endpoint) -// and that entitlements solely contain the user's permissions that the client is allowed to have. -func (s *OAuth2ServiceImpl) handleJWTBearer(ctx context.Context, accessRequest fosite.AccessRequester, oauthSession *oauth2.JWTSession, client *oauthserver.OAuthExternalService) error { - if !accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeJWTBearer)) { - return nil - } - - userID, err := utils.ParseUserIDFromSubject(oauthSession.Subject) - if err != nil { - return &fosite.RFC6749Error{ - DescriptionField: "Could not find the requested subject.", - ErrorField: "not_found", - CodeField: http.StatusBadRequest, - } - } - - // Check audiences list only contains the AppURL and the token endpoint - for _, aud := range accessRequest.GetGrantedAudience() { - if aud != fmt.Sprintf("%voauth2/token", s.cfg.AppURL) && aud != s.cfg.AppURL { - return &fosite.RFC6749Error{ - DescriptionField: "Client is not allowed to target this Audience.", - HintField: "The audience must be the AppURL or the token endpoint.", - ErrorField: "invalid_request", - CodeField: http.StatusForbidden, - } - } - } - - // If the client was not allowed to impersonate the user we would not have reached this point given allowed scopes would have been empty - // But just in case we check again - ev := ac.EvalPermission(ac.ActionUsersImpersonate, ac.Scope("users", "id", strconv.FormatInt(userID, 10))) - hasAccess, errAccess := s.accessControl.Evaluate(ctx, client.SignedInUser, ev) - if errAccess != nil || !hasAccess { - return &fosite.RFC6749Error{ - DescriptionField: "Client is not allowed to impersonate subject.", - ErrorField: "restricted_access", - CodeField: http.StatusForbidden, - } - } - - // Populate claims' suject from the session subject - oauthSession.JWTClaims.Subject = oauthSession.Subject - - // Get the user - query := user.GetUserByIDQuery{ID: userID} - dbUser, err := s.userService.GetByID(ctx, &query) - if err != nil { - if errors.Is(err, user.ErrUserNotFound) { - return &fosite.RFC6749Error{ - DescriptionField: "Could not find the requested subject.", - ErrorField: "not_found", - CodeField: http.StatusBadRequest, - } - } - return &fosite.RFC6749Error{ - DescriptionField: "The request subject could not be processed.", - ErrorField: "server_error", - CodeField: http.StatusInternalServerError, - } - } - oauthSession.Username = dbUser.Login - - // Split scopes into actions and claims - actionsFilter, claimsFilter := splitOAuthScopes(accessRequest.GetGrantedScopes()) - - teams := []*team.TeamDTO{} - // Fetch teams if the groups scope is requested or if we need to populate it in the entitlements - if claimsFilter["groups"] || - (claimsFilter["entitlements"] && (len(actionsFilter) == 0 || actionsFilter["teams:read"])) { - var errGetTeams error - teams, errGetTeams = s.teamService.GetTeamsByUser(ctx, &team.GetTeamsByUserQuery{ - OrgID: oauthserver.TmpOrgID, - UserID: dbUser.ID, - // Fetch teams without restriction on permissions - SignedInUser: &user.SignedInUser{ - OrgID: oauthserver.TmpOrgID, - Permissions: map[int64]map[string][]string{ - oauthserver.TmpOrgID: { - ac.ActionTeamsRead: {ac.ScopeTeamsAll}, - }, - }, - }, - }) - if errGetTeams != nil { - return &fosite.RFC6749Error{ - DescriptionField: "The teams scope could not be processed.", - ErrorField: "server_error", - CodeField: http.StatusInternalServerError, - } - } - } - if claimsFilter["profile"] { - oauthSession.JWTClaims.Add("name", dbUser.Name) - oauthSession.JWTClaims.Add("login", dbUser.Login) - oauthSession.JWTClaims.Add("updated_at", dbUser.Updated.Unix()) - } - if claimsFilter["email"] { - oauthSession.JWTClaims.Add("email", dbUser.Email) - } - if claimsFilter["groups"] { - teamNames := make([]string, 0, len(teams)) - for _, team := range teams { - teamNames = append(teamNames, team.Name) - } - oauthSession.JWTClaims.Add("groups", teamNames) - } - - if claimsFilter["entitlements"] { - // Get the user permissions (apply the actions filter) - permissions, errGetPermission := s.filteredUserPermissions(ctx, userID, actionsFilter) - if errGetPermission != nil { - return errGetPermission - } - - // Compute the impersonated permissions (apply the actions filter, replace the scope self with the user id) - impPerms := s.filteredImpersonatePermissions(client.ImpersonatePermissions, userID, teams, actionsFilter) - - // Intersect the permissions with the client permissions - intesect := ac.Intersect(permissions, impPerms) - - oauthSession.JWTClaims.Add("entitlements", intesect) - } - - return nil -} - -// filteredUserPermissions gets the user permissions and applies the actions filter -func (s *OAuth2ServiceImpl) filteredUserPermissions(ctx context.Context, userID int64, actionsFilter map[string]bool) ([]ac.Permission, error) { - permissions, err := s.acService.SearchUserPermissions(ctx, oauthserver.TmpOrgID, ac.SearchOptions{UserID: userID}) - if err != nil { - return nil, &fosite.RFC6749Error{ - DescriptionField: "The permissions scope could not be processed.", - ErrorField: "server_error", - CodeField: http.StatusInternalServerError, - } - } - - // Apply the actions filter - if len(actionsFilter) > 0 { - filtered := []ac.Permission{} - for i := range permissions { - if actionsFilter[permissions[i].Action] { - filtered = append(filtered, permissions[i]) - } - } - permissions = filtered - } - return permissions, nil -} - -// filteredImpersonatePermissions computes the impersonated permissions. -// It applies the actions filter and replaces the "self RBAC scopes" (~ scope templates) by the correct user id/team id. -func (*OAuth2ServiceImpl) filteredImpersonatePermissions(impersonatePermissions []ac.Permission, userID int64, teams []*team.TeamDTO, actionsFilter map[string]bool) []ac.Permission { - // Compute the impersonated permissions - impPerms := impersonatePermissions - // Apply the actions filter - if len(actionsFilter) > 0 { - filtered := []ac.Permission{} - for i := range impPerms { - if actionsFilter[impPerms[i].Action] { - filtered = append(filtered, impPerms[i]) - } - } - impPerms = filtered - } - - // Replace the scope self with the user id - correctScopes := []ac.Permission{} - for i := range impPerms { - switch impPerms[i].Scope { - case oauthserver.ScopeGlobalUsersSelf: - correctScopes = append(correctScopes, ac.Permission{ - Action: impPerms[i].Action, - Scope: ac.Scope("global.users", "id", strconv.FormatInt(userID, 10)), - }) - case oauthserver.ScopeUsersSelf: - correctScopes = append(correctScopes, ac.Permission{ - Action: impPerms[i].Action, - Scope: ac.Scope("users", "id", strconv.FormatInt(userID, 10)), - }) - case oauthserver.ScopeTeamsSelf: - for t := range teams { - correctScopes = append(correctScopes, ac.Permission{ - Action: impPerms[i].Action, - Scope: ac.Scope("teams", "id", strconv.FormatInt(teams[t].ID, 10)), - }) - } - default: - correctScopes = append(correctScopes, impPerms[i]) - } - continue - } - return correctScopes -} - -// handleClientCredentials populates the client's access_token generated by fosite to match the rfc9068 specifications (entitlements, groups) -func (s *OAuth2ServiceImpl) handleClientCredentials(ctx context.Context, accessRequest fosite.AccessRequester, oauthSession *oauth2.JWTSession, client *oauthserver.OAuthExternalService) error { - if !accessRequest.GetGrantTypes().ExactOne("client_credentials") { - return nil - } - // Set the subject to the service account associated to the client - oauthSession.JWTClaims.Subject = fmt.Sprintf("user:id:%d", client.ServiceAccountID) - - sa := client.SignedInUser - if sa == nil { - return &fosite.RFC6749Error{ - DescriptionField: "Could not find the service account of the client", - ErrorField: "not_found", - CodeField: http.StatusNotFound, - } - } - oauthSession.Username = sa.Login - - // For client credentials, scopes are not marked as granted by fosite but the request would have been rejected - // already if the client was not allowed to request them - for _, scope := range accessRequest.GetRequestedScopes() { - accessRequest.GrantScope(scope) - } - - // Split scopes into actions and claims - actionsFilter, claimsFilter := splitOAuthScopes(accessRequest.GetGrantedScopes()) - - if claimsFilter["profile"] { - oauthSession.JWTClaims.Add("name", sa.Name) - oauthSession.JWTClaims.Add("login", sa.Login) - } - if claimsFilter["email"] { - s.logger.Debug("Service accounts have no emails") - } - if claimsFilter["groups"] { - s.logger.Debug("Service accounts have no groups") - } - if claimsFilter["entitlements"] { - s.logger.Debug("Processing client entitlements") - if sa.Permissions != nil && sa.Permissions[oauthserver.TmpOrgID] != nil { - perms := sa.Permissions[oauthserver.TmpOrgID] - if len(actionsFilter) > 0 { - filtered := map[string][]string{} - for action := range actionsFilter { - if _, ok := perms[action]; ok { - filtered[action] = perms[action] - } - } - perms = filtered - } - oauthSession.JWTClaims.Add("entitlements", perms) - } else { - s.logger.Debug("Client has no permissions") - } - } - - return nil -} diff --git a/pkg/services/extsvcauth/oauthserver/oasimpl/token_test.go b/pkg/services/extsvcauth/oauthserver/oasimpl/token_test.go deleted file mode 100644 index ac49dfddb5d57..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/oasimpl/token_test.go +++ /dev/null @@ -1,745 +0,0 @@ -package oasimpl - -import ( - "context" - "crypto/rsa" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" - - "github.com/google/uuid" - "github.com/ory/fosite" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - "golang.org/x/crypto/bcrypt" - "golang.org/x/exp/maps" - "gopkg.in/square/go-jose.v2" - "gopkg.in/square/go-jose.v2/jwt" - - "github.com/grafana/grafana/pkg/models/roletype" - ac "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" - sa "github.com/grafana/grafana/pkg/services/serviceaccounts" - "github.com/grafana/grafana/pkg/services/team" - "github.com/grafana/grafana/pkg/services/user" -) - -func TestOAuth2ServiceImpl_handleClientCredentials(t *testing.T) { - client1 := &oauthserver.OAuthExternalService{ - Name: "testapp", - ClientID: "RANDOMID", - GrantTypes: string(fosite.GrantTypeClientCredentials), - ServiceAccountID: 2, - SignedInUser: &user.SignedInUser{ - UserID: 2, - Name: "Test App", - Login: "testapp", - OrgRole: roletype.RoleViewer, - Permissions: map[int64]map[string][]string{ - oauthserver.TmpOrgID: { - "dashboards:read": {"dashboards:*", "folders:*"}, - "dashboards:write": {"dashboards:uid:1"}, - }, - }, - }, - } - - tests := []struct { - name string - scopes []string - client *oauthserver.OAuthExternalService - expectedClaims map[string]any - wantErr bool - }{ - { - name: "no claim without client_credentials grant type", - client: &oauthserver.OAuthExternalService{ - Name: "testapp", - ClientID: "RANDOMID", - GrantTypes: string(fosite.GrantTypeJWTBearer), - ServiceAccountID: 2, - SignedInUser: &user.SignedInUser{}, - }, - }, - { - name: "no claims without scopes", - client: client1, - }, - { - name: "profile claims", - client: client1, - scopes: []string{"profile"}, - expectedClaims: map[string]any{"name": "Test App", "login": "testapp"}, - }, - { - name: "email claims should be empty", - client: client1, - scopes: []string{"email"}, - }, - { - name: "groups claims should be empty", - client: client1, - scopes: []string{"groups"}, - }, - { - name: "entitlements claims", - client: client1, - scopes: []string{"entitlements"}, - expectedClaims: map[string]any{"entitlements": map[string][]string{ - "dashboards:read": {"dashboards:*", "folders:*"}, - "dashboards:write": {"dashboards:uid:1"}, - }}, - }, - { - name: "scoped entitlements claims", - client: client1, - scopes: []string{"entitlements", "dashboards:write"}, - expectedClaims: map[string]any{"entitlements": map[string][]string{ - "dashboards:write": {"dashboards:uid:1"}, - }}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := context.Background() - env := setupTestEnv(t) - session := &fosite.DefaultSession{} - requester := fosite.NewAccessRequest(session) - requester.GrantTypes = fosite.Arguments(strings.Split(tt.client.GrantTypes, ",")) - requester.RequestedScope = fosite.Arguments(tt.scopes) - sessionData := NewAuthSession() - err := env.S.handleClientCredentials(ctx, requester, sessionData, tt.client) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - if tt.expectedClaims == nil { - require.Empty(t, sessionData.JWTClaims.Extra) - return - } - require.Len(t, sessionData.JWTClaims.Extra, len(tt.expectedClaims)) - for claimsKey, claimsValue := range tt.expectedClaims { - switch expected := claimsValue.(type) { - case []string: - require.ElementsMatch(t, claimsValue, sessionData.JWTClaims.Extra[claimsKey]) - case map[string][]string: - actual, ok := sessionData.JWTClaims.Extra[claimsKey].(map[string][]string) - require.True(t, ok, "expected map[string][]string") - - require.ElementsMatch(t, maps.Keys(expected), maps.Keys(actual)) - for expKey, expValue := range expected { - require.ElementsMatch(t, expValue, actual[expKey]) - } - default: - require.Equal(t, claimsValue, sessionData.JWTClaims.Extra[claimsKey]) - } - } - }) - } -} - -func TestOAuth2ServiceImpl_handleJWTBearer(t *testing.T) { - now := time.Now() - client1 := &oauthserver.OAuthExternalService{ - Name: "testapp", - ClientID: "RANDOMID", - GrantTypes: string(fosite.GrantTypeJWTBearer), - ServiceAccountID: 2, - SignedInUser: &user.SignedInUser{ - UserID: 2, - OrgID: oauthserver.TmpOrgID, - Name: "Test App", - Login: "testapp", - OrgRole: roletype.RoleViewer, - Permissions: map[int64]map[string][]string{ - oauthserver.TmpOrgID: { - "users:impersonate": {"users:*"}, - }, - }, - }, - } - user56 := &user.User{ - ID: 56, - Email: "user56@example.org", - Login: "user56", - Name: "User 56", - Updated: now, - } - teams := []*team.TeamDTO{ - {ID: 1, Name: "Team 1", OrgID: 1}, - {ID: 2, Name: "Team 2", OrgID: 1}, - } - client1WithPerm := func(perms []ac.Permission) *oauthserver.OAuthExternalService { - client := *client1 - client.ImpersonatePermissions = perms - return &client - } - - tests := []struct { - name string - initEnv func(*TestEnv) - scopes []string - client *oauthserver.OAuthExternalService - subject string - expectedClaims map[string]any - wantErr bool - }{ - { - name: "no claim without jwtbearer grant type", - client: &oauthserver.OAuthExternalService{ - Name: "testapp", - ClientID: "RANDOMID", - GrantTypes: string(fosite.GrantTypeClientCredentials), - ServiceAccountID: 2, - }, - }, - { - name: "err invalid subject", - client: client1, - subject: "invalid_subject", - wantErr: true, - }, - { - name: "err client is not allowed to impersonate", - client: &oauthserver.OAuthExternalService{ - Name: "testapp", - ClientID: "RANDOMID", - GrantTypes: string(fosite.GrantTypeJWTBearer), - ServiceAccountID: 2, - SignedInUser: &user.SignedInUser{ - UserID: 2, - Name: "Test App", - Login: "testapp", - OrgRole: roletype.RoleViewer, - Permissions: map[int64]map[string][]string{oauthserver.TmpOrgID: {}}, - }, - }, - subject: "user:id:56", - wantErr: true, - }, - { - name: "err subject not found", - initEnv: func(env *TestEnv) { - env.UserService.ExpectedError = user.ErrUserNotFound - }, - client: client1, - subject: "user:id:56", - wantErr: true, - }, - { - name: "no claim without scope", - initEnv: func(env *TestEnv) { - env.UserService.ExpectedUser = user56 - }, - client: client1, - subject: "user:id:56", - }, - { - name: "profile claims", - initEnv: func(env *TestEnv) { - env.UserService.ExpectedUser = user56 - }, - client: client1, - subject: "user:id:56", - scopes: []string{"profile"}, - expectedClaims: map[string]any{ - "name": "User 56", - "login": "user56", - "updated_at": now.Unix(), - }, - }, - { - name: "email claim", - initEnv: func(env *TestEnv) { - env.UserService.ExpectedUser = user56 - }, - client: client1, - subject: "user:id:56", - scopes: []string{"email"}, - expectedClaims: map[string]any{ - "email": "user56@example.org", - }, - }, - { - name: "groups claim", - initEnv: func(env *TestEnv) { - env.UserService.ExpectedUser = user56 - env.TeamService.ExpectedTeamsByUser = teams - }, - client: client1, - subject: "user:id:56", - scopes: []string{"groups"}, - expectedClaims: map[string]any{ - "groups": []string{"Team 1", "Team 2"}, - }, - }, - { - name: "no entitlement without permission intersection", - initEnv: func(env *TestEnv) { - env.UserService.ExpectedUser = user56 - env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{ - 56: {"Viewer"}}, nil) - env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{ - 56: {{Action: "dashboards:read", Scope: "dashboards:uid:1"}}, - }, nil) - }, - client: client1WithPerm([]ac.Permission{ - {Action: "datasources:read", Scope: "datasources:*"}, - }), - subject: "user:id:56", - expectedClaims: map[string]any{ - "entitlements": map[string][]string{}, - }, - scopes: []string{"entitlements"}, - }, - { - name: "entitlements contains only the intersection of permissions", - initEnv: func(env *TestEnv) { - env.UserService.ExpectedUser = user56 - env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{ - 56: {"Viewer"}}, nil) - env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{ - 56: { - {Action: "dashboards:read", Scope: "dashboards:uid:1"}, - {Action: "datasources:read", Scope: "datasources:uid:1"}, - }, - }, nil) - }, - client: client1WithPerm([]ac.Permission{ - {Action: "datasources:read", Scope: "datasources:*"}, - }), - subject: "user:id:56", - expectedClaims: map[string]any{ - "entitlements": map[string][]string{ - "datasources:read": {"datasources:uid:1"}, - }, - }, - scopes: []string{"entitlements"}, - }, - { - name: "entitlements have correctly translated users:self permissions", - initEnv: func(env *TestEnv) { - env.UserService.ExpectedUser = user56 - env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{ - 56: {"Viewer"}}, nil) - env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{ - 56: { - {Action: "users:read", Scope: "global.users:id:*"}, - {Action: "users.permissions:read", Scope: "users:id:*"}, - }}, nil) - }, - client: client1WithPerm([]ac.Permission{ - {Action: "users:read", Scope: "global.users:self"}, - {Action: "users.permissions:read", Scope: "users:self"}, - }), - subject: "user:id:56", - expectedClaims: map[string]any{ - "entitlements": map[string][]string{ - "users:read": {"global.users:id:56"}, - "users.permissions:read": {"users:id:56"}, - }, - }, - scopes: []string{"entitlements"}, - }, - { - name: "entitlements have correctly translated teams:self permissions", - initEnv: func(env *TestEnv) { - env.UserService.ExpectedUser = user56 - env.TeamService.ExpectedTeamsByUser = teams - env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{ - 56: {"Viewer"}}, nil) - env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{ - 56: {{Action: "teams:read", Scope: "teams:*"}}}, nil) - }, - client: client1WithPerm([]ac.Permission{ - {Action: "teams:read", Scope: "teams:self"}, - }), - subject: "user:id:56", - expectedClaims: map[string]any{ - "entitlements": map[string][]string{"teams:read": {"teams:id:1", "teams:id:2"}}, - }, - scopes: []string{"entitlements"}, - }, - { - name: "entitlements are correctly filtered based on scopes", - initEnv: func(env *TestEnv) { - env.UserService.ExpectedUser = user56 - env.TeamService.ExpectedTeamsByUser = teams - env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{ - 56: {"Viewer"}}, nil) - env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{ - 56: { - {Action: "users:read", Scope: "global.users:id:*"}, - {Action: "datasources:read", Scope: "datasources:uid:1"}, - }}, nil) - }, - client: client1WithPerm([]ac.Permission{ - {Action: "users:read", Scope: "global.users:*"}, - {Action: "datasources:read", Scope: "datasources:*"}, - }), - subject: "user:id:56", - expectedClaims: map[string]any{ - "entitlements": map[string][]string{"users:read": {"global.users:id:*"}}, - }, - scopes: []string{"entitlements", "users:read"}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := context.Background() - env := setupTestEnv(t) - session := &fosite.DefaultSession{} - requester := fosite.NewAccessRequest(session) - requester.GrantTypes = fosite.Arguments(strings.Split(tt.client.GrantTypes, ",")) - requester.RequestedScope = fosite.Arguments(tt.scopes) - requester.GrantedScope = fosite.Arguments(tt.scopes) - sessionData := NewAuthSession() - sessionData.Subject = tt.subject - - if tt.initEnv != nil { - tt.initEnv(env) - } - err := env.S.handleJWTBearer(ctx, requester, sessionData, tt.client) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - if tt.expectedClaims == nil { - require.Empty(t, sessionData.JWTClaims.Extra) - return - } - require.Len(t, sessionData.JWTClaims.Extra, len(tt.expectedClaims)) - - for claimsKey, claimsValue := range tt.expectedClaims { - switch expected := claimsValue.(type) { - case []string: - require.ElementsMatch(t, claimsValue, sessionData.JWTClaims.Extra[claimsKey]) - case map[string][]string: - actual, ok := sessionData.JWTClaims.Extra[claimsKey].(map[string][]string) - require.True(t, ok, "expected map[string][]string") - - require.ElementsMatch(t, maps.Keys(expected), maps.Keys(actual)) - for expKey, expValue := range expected { - require.ElementsMatch(t, expValue, actual[expKey]) - } - default: - require.Equal(t, claimsValue, sessionData.JWTClaims.Extra[claimsKey]) - } - } - - env.AcStore.AssertExpectations(t) - }) - } -} - -type tokenResponse struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - Scope string `json:"scope"` - TokenType string `json:"token_type"` -} - -type claims struct { - jwt.Claims - ClientID string `json:"client_id"` - Groups []string `json:"groups"` - Email string `json:"email"` - Name string `json:"name"` - Login string `json:"login"` - Scopes []string `json:"scope"` - Entitlements map[string][]string `json:"entitlements"` -} - -func TestOAuth2ServiceImpl_HandleTokenRequest(t *testing.T) { - tests := []struct { - name string - tweakTestClient func(*oauthserver.OAuthExternalService) - reqParams url.Values - wantCode int - wantScope []string - wantClaims *claims - }{ - { - name: "should allow client credentials grant", - reqParams: url.Values{ - "grant_type": {string(fosite.GrantTypeClientCredentials)}, - "client_id": {"CLIENT1ID"}, - "client_secret": {"CLIENT1SECRET"}, - "scope": {"profile email groups entitlements"}, - "audience": {AppURL}, - }, - wantCode: http.StatusOK, - wantScope: []string{"profile", "email", "groups", "entitlements"}, - wantClaims: &claims{ - Claims: jwt.Claims{ - Subject: "user:id:2", // From client1.ServiceAccountID - Issuer: AppURL, // From env.S.Config.Issuer - Audience: jwt.Audience{AppURL}, - }, - ClientID: "CLIENT1ID", - Name: "client-1", - Login: "client-1", - Entitlements: map[string][]string{ - "users:impersonate": {"users:*"}, - }, - }, - }, - { - name: "should allow jwt-bearer grant", - reqParams: url.Values{ - "grant_type": {string(fosite.GrantTypeJWTBearer)}, - "client_id": {"CLIENT1ID"}, - "client_secret": {"CLIENT1SECRET"}, - "assertion": { - genAssertion(t, Client1Key, "CLIENT1ID", "user:id:56", TokenURL, AppURL), - }, - "scope": {"profile email groups entitlements"}, - }, - wantCode: http.StatusOK, - wantScope: []string{"profile", "email", "groups", "entitlements"}, - wantClaims: &claims{ - Claims: jwt.Claims{ - Subject: "user:id:56", // To match the assertion - Issuer: AppURL, // From env.S.Config.Issuer - Audience: jwt.Audience{TokenURL, AppURL}, - }, - ClientID: "CLIENT1ID", - Email: "user56@example.org", - Name: "User 56", - Login: "user56", - Groups: []string{"Team 1", "Team 2"}, - Entitlements: map[string][]string{ - "dashboards:read": {"folders:uid:UID1"}, - "folders:read": {"folders:uid:UID1"}, - "users:read": {"global.users:id:56"}, - }, - }, - }, - { - name: "should deny jwt-bearer grant with wrong audience", - reqParams: url.Values{ - "grant_type": {string(fosite.GrantTypeJWTBearer)}, - "client_id": {"CLIENT1ID"}, - "client_secret": {"CLIENT1SECRET"}, - "assertion": { - genAssertion(t, Client1Key, "CLIENT1ID", "user:id:56", TokenURL, "invalid audience"), - }, - "scope": {"profile email groups entitlements"}, - }, - wantCode: http.StatusForbidden, - }, - { - name: "should deny jwt-bearer grant for clients without the grant", - reqParams: url.Values{ - "grant_type": {string(fosite.GrantTypeJWTBearer)}, - "client_id": {"CLIENT1ID"}, - "client_secret": {"CLIENT1SECRET"}, - "assertion": { - genAssertion(t, Client1Key, "CLIENT1ID", "user:id:56", TokenURL, AppURL), - }, - "scope": {"profile email groups entitlements"}, - }, - tweakTestClient: func(es *oauthserver.OAuthExternalService) { - es.GrantTypes = string(fosite.GrantTypeClientCredentials) - }, - wantCode: http.StatusBadRequest, - }, - { - name: "should deny client_credentials grant for clients without the grant", - reqParams: url.Values{ - "grant_type": {string(fosite.GrantTypeClientCredentials)}, - "client_id": {"CLIENT1ID"}, - "client_secret": {"CLIENT1SECRET"}, - "scope": {"profile email groups entitlements"}, - "audience": {AppURL}, - }, - tweakTestClient: func(es *oauthserver.OAuthExternalService) { - es.GrantTypes = string(fosite.GrantTypeJWTBearer) - }, - wantCode: http.StatusBadRequest, - }, - { - name: "should deny client_credentials grant with wrong secret", - reqParams: url.Values{ - "grant_type": {string(fosite.GrantTypeClientCredentials)}, - "client_id": {"CLIENT1ID"}, - "client_secret": {"WRONG_SECRET"}, - "scope": {"profile email groups entitlements"}, - "audience": {AppURL}, - }, - tweakTestClient: func(es *oauthserver.OAuthExternalService) { - es.GrantTypes = string(fosite.GrantTypeClientCredentials) - }, - wantCode: http.StatusUnauthorized, - }, - { - name: "should deny jwt-bearer grant with wrong secret", - reqParams: url.Values{ - "grant_type": {string(fosite.GrantTypeJWTBearer)}, - "client_id": {"CLIENT1ID"}, - "client_secret": {"WRONG_SECRET"}, - "assertion": { - genAssertion(t, Client1Key, "CLIENT1ID", "user:id:56", TokenURL, AppURL), - }, - "scope": {"profile email groups entitlements"}, - }, - tweakTestClient: func(es *oauthserver.OAuthExternalService) { - es.GrantTypes = string(fosite.GrantTypeJWTBearer) - }, - wantCode: http.StatusUnauthorized, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - env := setupTestEnv(t) - setupHandleTokenRequestEnv(t, env, tt.tweakTestClient) - - req := httptest.NewRequest("POST", "/oauth2/token", strings.NewReader(tt.reqParams.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp := httptest.NewRecorder() - - env.S.HandleTokenRequest(resp, req) - - require.Equal(t, tt.wantCode, resp.Code, resp.Body.String()) - if tt.wantCode != http.StatusOK { - return - } - - body := resp.Body.Bytes() - require.NotEmpty(t, body) - - var tokenResp tokenResponse - require.NoError(t, json.Unmarshal(body, &tokenResp)) - - // Check token response - require.NotEmpty(t, tokenResp.Scope) - require.ElementsMatch(t, tt.wantScope, strings.Split(tokenResp.Scope, " ")) - require.Positive(t, tokenResp.ExpiresIn) - require.Equal(t, "bearer", tokenResp.TokenType) - require.NotEmpty(t, tokenResp.AccessToken) - - // Check access token - parsedToken, err := jwt.ParseSigned(tokenResp.AccessToken) - require.NoError(t, err) - require.Len(t, parsedToken.Headers, 1) - typeHeader := parsedToken.Headers[0].ExtraHeaders["typ"] - require.Equal(t, "at+jwt", strings.ToLower(typeHeader.(string))) - require.Equal(t, "RS256", parsedToken.Headers[0].Algorithm) - // Check access token claims - var claims claims - require.NoError(t, parsedToken.Claims(pk.Public(), &claims)) - // Check times and remove them - require.Positive(t, claims.IssuedAt.Time()) - require.Positive(t, claims.Expiry.Time()) - claims.IssuedAt = jwt.NewNumericDate(time.Time{}) - claims.Expiry = jwt.NewNumericDate(time.Time{}) - // Check the ID and remove it - require.NotEmpty(t, claims.ID) - claims.ID = "" - // Compare the rest - require.Equal(t, tt.wantClaims, &claims) - }) - } -} - -func genAssertion(t *testing.T, signKey *rsa.PrivateKey, clientID, sub string, audience ...string) string { - key := jose.SigningKey{Algorithm: jose.RS256, Key: signKey} - assertion := jwt.Claims{ - ID: uuid.New().String(), - Issuer: clientID, - Subject: sub, - Audience: audience, - Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - } - - var signerOpts = jose.SignerOptions{} - signerOpts.WithType("JWT") - rsaSigner, errSigner := jose.NewSigner(key, &signerOpts) - require.NoError(t, errSigner) - builder := jwt.Signed(rsaSigner) - rawJWT, errSign := builder.Claims(assertion).CompactSerialize() - require.NoError(t, errSign) - return rawJWT -} - -// setupHandleTokenRequestEnv creates a client and a user and sets all Mocks call for the handleTokenRequest test cases -func setupHandleTokenRequestEnv(t *testing.T, env *TestEnv, opt func(*oauthserver.OAuthExternalService)) { - now := time.Now() - hashedSecret, err := bcrypt.GenerateFromPassword([]byte("CLIENT1SECRET"), bcrypt.DefaultCost) - require.NoError(t, err) - client1 := &oauthserver.OAuthExternalService{ - Name: "client-1", - ClientID: "CLIENT1ID", - Secret: string(hashedSecret), - GrantTypes: string(fosite.GrantTypeClientCredentials + "," + fosite.GrantTypeJWTBearer), - ServiceAccountID: 2, - ImpersonatePermissions: []ac.Permission{ - {Action: "users:read", Scope: oauthserver.ScopeGlobalUsersSelf}, - {Action: "users.permissions:read", Scope: oauthserver.ScopeUsersSelf}, - {Action: "teams:read", Scope: oauthserver.ScopeTeamsSelf}, - - {Action: "folders:read", Scope: "folders:*"}, - {Action: "dashboards:read", Scope: "folders:*"}, - {Action: "dashboards:read", Scope: "dashboards:*"}, - }, - SelfPermissions: []ac.Permission{ - {Action: "users:impersonate", Scope: "users:*"}, - }, - Audiences: AppURL, - } - - // Apply any option the test case might need - if opt != nil { - opt(client1) - } - - sa1 := &sa.ExtSvcAccount{ - ID: client1.ServiceAccountID, - Name: client1.Name, - Login: client1.Name, - OrgID: oauthserver.TmpOrgID, - IsDisabled: false, - Role: roletype.RoleNone, - } - - user56 := &user.User{ - ID: 56, - Email: "user56@example.org", - Login: "user56", - Name: "User 56", - Updated: now, - } - user56Permissions := []ac.Permission{ - {Action: "users:read", Scope: "global.users:id:56"}, - {Action: "folders:read", Scope: "folders:uid:UID1"}, - {Action: "dashboards:read", Scope: "folders:uid:UID1"}, - {Action: "datasources:read", Scope: "datasources:uid:DS_UID2"}, // This one should be ignored when impersonating - } - user56Teams := []*team.TeamDTO{ - {ID: 1, Name: "Team 1", OrgID: 1}, - {ID: 2, Name: "Team 2", OrgID: 1}, - } - - // To retrieve the Client, its publicKey and its permissions - env.OAuthStore.On("GetExternalService", mock.Anything, client1.ClientID).Return(client1, nil) - env.OAuthStore.On("GetExternalServicePublicKey", mock.Anything, client1.ClientID).Return(&jose.JSONWebKey{Key: Client1Key.Public(), Algorithm: "RS256"}, nil) - env.SAService.On("RetrieveExtSvcAccount", mock.Anything, oauthserver.TmpOrgID, client1.ServiceAccountID).Return(sa1, nil) - env.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything).Return(client1.SelfPermissions, nil) - // To retrieve the user to impersonate, its permissions and its teams - env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{ - user56.ID: user56Permissions}, nil) - env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{ - user56.ID: {"Viewer"}}, nil) - env.TeamService.ExpectedTeamsByUser = user56Teams - env.UserService.ExpectedUser = user56 -} diff --git a/pkg/services/extsvcauth/oauthserver/oastest/fakes.go b/pkg/services/extsvcauth/oauthserver/oastest/fakes.go deleted file mode 100644 index 35a80fdab35e4..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/oastest/fakes.go +++ /dev/null @@ -1,38 +0,0 @@ -package oastest - -import ( - "context" - "net/http" - - "github.com/grafana/grafana/pkg/services/extsvcauth" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" - "gopkg.in/square/go-jose.v2" -) - -type FakeService struct { - ExpectedClient *oauthserver.OAuthExternalService - ExpectedKey *jose.JSONWebKey - ExpectedErr error -} - -var _ oauthserver.OAuth2Server = &FakeService{} - -func (s *FakeService) SaveExternalService(ctx context.Context, cmd *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) { - return s.ExpectedClient.ToExternalService(nil), s.ExpectedErr -} - -func (s *FakeService) GetExternalService(ctx context.Context, id string) (*oauthserver.OAuthExternalService, error) { - return s.ExpectedClient, s.ExpectedErr -} - -func (s *FakeService) GetExternalServiceNames(ctx context.Context) ([]string, error) { - return nil, nil -} - -func (s *FakeService) RemoveExternalService(ctx context.Context, name string) error { - return s.ExpectedErr -} - -func (s *FakeService) HandleTokenRequest(rw http.ResponseWriter, req *http.Request) {} - -func (s *FakeService) HandleIntrospectionRequest(rw http.ResponseWriter, req *http.Request) {} diff --git a/pkg/services/extsvcauth/oauthserver/oastest/store_mock.go b/pkg/services/extsvcauth/oauthserver/oastest/store_mock.go deleted file mode 100644 index 68d6aeab5c4ac..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/oastest/store_mock.go +++ /dev/null @@ -1,191 +0,0 @@ -// Code generated by mockery v2.35.2. DO NOT EDIT. - -package oastest - -import ( - context "context" - - mock "github.com/stretchr/testify/mock" - jose "gopkg.in/square/go-jose.v2" - - oauthserver "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" -) - -// MockStore is an autogenerated mock type for the Store type -type MockStore struct { - mock.Mock -} - -// DeleteExternalService provides a mock function with given fields: ctx, id -func (_m *MockStore) DeleteExternalService(ctx context.Context, id string) error { - ret := _m.Called(ctx, id) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { - r0 = rf(ctx, id) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// GetExternalService provides a mock function with given fields: ctx, id -func (_m *MockStore) GetExternalService(ctx context.Context, id string) (*oauthserver.OAuthExternalService, error) { - ret := _m.Called(ctx, id) - - var r0 *oauthserver.OAuthExternalService - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, string) (*oauthserver.OAuthExternalService, error)); ok { - return rf(ctx, id) - } - if rf, ok := ret.Get(0).(func(context.Context, string) *oauthserver.OAuthExternalService); ok { - r0 = rf(ctx, id) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*oauthserver.OAuthExternalService) - } - } - - if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = rf(ctx, id) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// GetExternalServiceByName provides a mock function with given fields: ctx, name -func (_m *MockStore) GetExternalServiceByName(ctx context.Context, name string) (*oauthserver.OAuthExternalService, error) { - ret := _m.Called(ctx, name) - - var r0 *oauthserver.OAuthExternalService - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, string) (*oauthserver.OAuthExternalService, error)); ok { - return rf(ctx, name) - } - if rf, ok := ret.Get(0).(func(context.Context, string) *oauthserver.OAuthExternalService); ok { - r0 = rf(ctx, name) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*oauthserver.OAuthExternalService) - } - } - - if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = rf(ctx, name) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// GetExternalServiceNames provides a mock function with given fields: ctx -func (_m *MockStore) GetExternalServiceNames(ctx context.Context) ([]string, error) { - ret := _m.Called(ctx) - - var r0 []string - var r1 error - if rf, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok { - return rf(ctx) - } - if rf, ok := ret.Get(0).(func(context.Context) []string); ok { - r0 = rf(ctx) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]string) - } - } - - if rf, ok := ret.Get(1).(func(context.Context) error); ok { - r1 = rf(ctx) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// GetExternalServicePublicKey provides a mock function with given fields: ctx, clientID -func (_m *MockStore) GetExternalServicePublicKey(ctx context.Context, clientID string) (*jose.JSONWebKey, error) { - ret := _m.Called(ctx, clientID) - - var r0 *jose.JSONWebKey - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, string) (*jose.JSONWebKey, error)); ok { - return rf(ctx, clientID) - } - if rf, ok := ret.Get(0).(func(context.Context, string) *jose.JSONWebKey); ok { - r0 = rf(ctx, clientID) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*jose.JSONWebKey) - } - } - - if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = rf(ctx, clientID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// RegisterExternalService provides a mock function with given fields: ctx, client -func (_m *MockStore) RegisterExternalService(ctx context.Context, client *oauthserver.OAuthExternalService) error { - ret := _m.Called(ctx, client) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *oauthserver.OAuthExternalService) error); ok { - r0 = rf(ctx, client) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// SaveExternalService provides a mock function with given fields: ctx, client -func (_m *MockStore) SaveExternalService(ctx context.Context, client *oauthserver.OAuthExternalService) error { - ret := _m.Called(ctx, client) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *oauthserver.OAuthExternalService) error); ok { - r0 = rf(ctx, client) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// UpdateExternalServiceGrantTypes provides a mock function with given fields: ctx, clientID, grantTypes -func (_m *MockStore) UpdateExternalServiceGrantTypes(ctx context.Context, clientID string, grantTypes string) error { - ret := _m.Called(ctx, clientID, grantTypes) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok { - r0 = rf(ctx, clientID, grantTypes) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockStore(t interface { - mock.TestingT - Cleanup(func()) -}) *MockStore { - mock := &MockStore{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/pkg/services/extsvcauth/oauthserver/store/database.go b/pkg/services/extsvcauth/oauthserver/store/database.go deleted file mode 100644 index bd0978b098098..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/store/database.go +++ /dev/null @@ -1,252 +0,0 @@ -package store - -import ( - "context" - "crypto/ecdsa" - "crypto/rsa" - "errors" - - "gopkg.in/square/go-jose.v2" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/utils" -) - -type store struct { - db db.DB -} - -func NewStore(db db.DB) oauthserver.Store { - return &store{db: db} -} - -func createImpersonatePermissions(sess *db.Session, client *oauthserver.OAuthExternalService) error { - if len(client.ImpersonatePermissions) == 0 { - return nil - } - - insertPermQuery := make([]any, 1, len(client.ImpersonatePermissions)*3+1) - insertPermStmt := `INSERT INTO oauth_impersonate_permission (client_id, action, scope) VALUES ` - for _, perm := range client.ImpersonatePermissions { - insertPermStmt += "(?, ?, ?)," - insertPermQuery = append(insertPermQuery, client.ClientID, perm.Action, perm.Scope) - } - insertPermQuery[0] = insertPermStmt[:len(insertPermStmt)-1] - _, err := sess.Exec(insertPermQuery...) - return err -} - -func registerExternalService(sess *db.Session, client *oauthserver.OAuthExternalService) error { - insertQuery := []any{ - `INSERT INTO oauth_client (name, client_id, secret, grant_types, audiences, service_account_id, public_pem, redirect_uri) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - client.Name, - client.ClientID, - client.Secret, - client.GrantTypes, - client.Audiences, - client.ServiceAccountID, - client.PublicPem, - client.RedirectURI, - } - if _, err := sess.Exec(insertQuery...); err != nil { - return err - } - - return createImpersonatePermissions(sess, client) -} - -func (s *store) RegisterExternalService(ctx context.Context, client *oauthserver.OAuthExternalService) error { - return s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - return registerExternalService(sess, client) - }) -} - -func recreateImpersonatePermissions(sess *db.Session, client *oauthserver.OAuthExternalService, prevClientID string) error { - deletePermQuery := `DELETE FROM oauth_impersonate_permission WHERE client_id = ?` - if _, errDelPerm := sess.Exec(deletePermQuery, prevClientID); errDelPerm != nil { - return errDelPerm - } - - if len(client.ImpersonatePermissions) == 0 { - return nil - } - - return createImpersonatePermissions(sess, client) -} - -func updateExternalService(sess *db.Session, client *oauthserver.OAuthExternalService, prevClientID string) error { - updateQuery := []any{ - `UPDATE oauth_client SET client_id = ?, secret = ?, grant_types = ?, audiences = ?, service_account_id = ?, public_pem = ?, redirect_uri = ? WHERE name = ?`, - client.ClientID, - client.Secret, - client.GrantTypes, - client.Audiences, - client.ServiceAccountID, - client.PublicPem, - client.RedirectURI, - client.Name, - } - if _, err := sess.Exec(updateQuery...); err != nil { - return err - } - - return recreateImpersonatePermissions(sess, client, prevClientID) -} - -func (s *store) SaveExternalService(ctx context.Context, client *oauthserver.OAuthExternalService) error { - if client.Name == "" { - return oauthserver.ErrClientRequiredName - } - return s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - previous, errFetchExtSvc := getExternalServiceByName(sess, client.Name) - if errFetchExtSvc != nil && !errors.Is(errFetchExtSvc, oauthserver.ErrClientNotFound) { - return errFetchExtSvc - } - if previous == nil { - return registerExternalService(sess, client) - } - return updateExternalService(sess, client, previous.ClientID) - }) -} - -func (s *store) GetExternalService(ctx context.Context, id string) (*oauthserver.OAuthExternalService, error) { - res := &oauthserver.OAuthExternalService{} - if id == "" { - return nil, oauthserver.ErrClientRequiredID - } - - err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - getClientQuery := `SELECT - id, name, client_id, secret, grant_types, audiences, service_account_id, public_pem, redirect_uri - FROM oauth_client - WHERE client_id = ?` - found, err := sess.SQL(getClientQuery, id).Get(res) - if err != nil { - return err - } - if !found { - res = nil - return oauthserver.ErrClientNotFoundFn(id) - } - - impersonatePermQuery := `SELECT action, scope FROM oauth_impersonate_permission WHERE client_id = ?` - return sess.SQL(impersonatePermQuery, id).Find(&res.ImpersonatePermissions) - }) - - return res, err -} - -// GetPublicKey returns public key, issued by 'issuer', and assigned for subject. Public key is used to check -// signature of jwt assertion in authorization grants. -func (s *store) GetExternalServicePublicKey(ctx context.Context, clientID string) (*jose.JSONWebKey, error) { - res := &oauthserver.OAuthExternalService{} - if clientID == "" { - return nil, oauthserver.ErrClientRequiredID - } - - if err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - getKeyQuery := `SELECT public_pem FROM oauth_client WHERE client_id = ?` - found, err := sess.SQL(getKeyQuery, clientID).Get(res) - if err != nil { - return err - } - if !found { - return oauthserver.ErrClientNotFoundFn(clientID) - } - return nil - }); err != nil { - return nil, err - } - - key, errParseKey := utils.ParsePublicKeyPem(res.PublicPem) - if errParseKey != nil { - return nil, errParseKey - } - - var alg string - switch key.(type) { - case *rsa.PublicKey: - alg = oauthserver.RS256 - case *ecdsa.PublicKey: - alg = oauthserver.ES256 - } - - return &jose.JSONWebKey{ - Algorithm: alg, - Key: key, - }, nil -} - -func (s *store) GetExternalServiceByName(ctx context.Context, name string) (*oauthserver.OAuthExternalService, error) { - res := &oauthserver.OAuthExternalService{} - if name == "" { - return nil, oauthserver.ErrClientRequiredName - } - - err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - var errGetByName error - res, errGetByName = getExternalServiceByName(sess, name) - return errGetByName - }) - - return res, err -} - -func getExternalServiceByName(sess *db.Session, name string) (*oauthserver.OAuthExternalService, error) { - res := &oauthserver.OAuthExternalService{} - getClientQuery := `SELECT - id, name, client_id, secret, grant_types, audiences, service_account_id, public_pem, redirect_uri - FROM oauth_client - WHERE name = ?` - found, err := sess.SQL(getClientQuery, name).Get(res) - if err != nil { - return nil, err - } - if !found { - return nil, oauthserver.ErrClientNotFoundFn(name) - } - - impersonatePermQuery := `SELECT action, scope FROM oauth_impersonate_permission WHERE client_id = ?` - errPerm := sess.SQL(impersonatePermQuery, res.ClientID).Find(&res.ImpersonatePermissions) - - return res, errPerm -} - -// FIXME: If we ever do a search method remove that method -func (s *store) GetExternalServiceNames(ctx context.Context) ([]string, error) { - res := []string{} - - err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - return sess.SQL(`SELECT name FROM oauth_client`).Find(&res) - }) - - return res, err -} - -func (s *store) UpdateExternalServiceGrantTypes(ctx context.Context, clientID, grantTypes string) error { - if clientID == "" { - return oauthserver.ErrClientRequiredID - } - - return s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - query := `UPDATE oauth_client SET grant_types = ? WHERE client_id = ?` - _, err := sess.Exec(query, grantTypes, clientID) - return err - }) -} - -func (s *store) DeleteExternalService(ctx context.Context, id string) error { - if id == "" { - return oauthserver.ErrClientRequiredID - } - - return s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - if _, err := sess.Exec(`DELETE FROM oauth_client WHERE client_id = ?`, id); err != nil { - return err - } - - _, err := sess.Exec(`DELETE FROM oauth_impersonate_permission WHERE client_id = ?`, id) - return err - }) -} diff --git a/pkg/services/extsvcauth/oauthserver/store/database_test.go b/pkg/services/extsvcauth/oauthserver/store/database_test.go deleted file mode 100644 index 69b50ade31746..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/store/database_test.go +++ /dev/null @@ -1,485 +0,0 @@ -package store - -import ( - "context" - "errors" - "testing" - - "github.com/go-jose/go-jose/v3" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" - "github.com/grafana/grafana/pkg/services/featuremgmt" -) - -func TestStore_RegisterAndGetClient(t *testing.T) { - s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})} - tests := []struct { - name string - client oauthserver.OAuthExternalService - wantErr bool - }{ - { - name: "register and get", - client: oauthserver.OAuthExternalService{ - Name: "The Worst App Ever", - ClientID: "ANonRandomClientID", - Secret: "ICouldKeepSecrets", - GrantTypes: "clients_credentials", - PublicPem: []byte(`------BEGIN FAKE PUBLIC KEY----- -VGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBO -b3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNB -IEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhp -cyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3Qg -QW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtl -eS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJ -cyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4g -UlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4g -VGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBO -b3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNB -IEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhp -cyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3Qg -QW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtl -eS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJ -cyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4g -UlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4g -VGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBO -b3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNB -IEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhp -cyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4uLi4gSXQgSXMgSnVz -dCBBIFJlZ3VsYXIgQmFzZTY0IEVuY29kZWQgU3RyaW5nLi4uCg== -------END FAKE PUBLIC KEY-----`), - ServiceAccountID: 2, - SelfPermissions: nil, - ImpersonatePermissions: nil, - RedirectURI: "/whereto", - }, - wantErr: false, - }, - { - name: "register with impersonate permissions and get", - client: oauthserver.OAuthExternalService{ - Name: "The Best App Ever", - ClientID: "AnAlmostRandomClientID", - Secret: "ICannotKeepSecrets", - GrantTypes: "clients_credentials", - PublicPem: []byte(`test`), - ServiceAccountID: 2, - SelfPermissions: nil, - ImpersonatePermissions: []accesscontrol.Permission{ - {Action: "dashboards:create", Scope: "folders:*"}, - {Action: "dashboards:read", Scope: "folders:*"}, - {Action: "dashboards:read", Scope: "dashboards:*"}, - {Action: "dashboards:write", Scope: "folders:*"}, - {Action: "dashboards:write", Scope: "dashboards:*"}, - }, - RedirectURI: "/whereto", - }, - wantErr: false, - }, - { - name: "register with audiences and get", - client: oauthserver.OAuthExternalService{ - Name: "The Most Normal App Ever", - ClientID: "AnAlmostRandomClientIDAgain", - Secret: "ICanKeepSecretsEventually", - GrantTypes: "clients_credentials", - PublicPem: []byte(`test`), - ServiceAccountID: 2, - SelfPermissions: nil, - Audiences: "https://oauth.test/,https://sub.oauth.test/", - RedirectURI: "/whereto", - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := context.Background() - err := s.RegisterExternalService(ctx, &tt.client) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - // Compare results - compareClientToStored(t, s, &tt.client) - }) - } -} - -func TestStore_SaveExternalService(t *testing.T) { - client1 := oauthserver.OAuthExternalService{ - Name: "my-external-service", - ClientID: "ClientID", - Secret: "Secret", - GrantTypes: "client_credentials", - PublicPem: []byte("test"), - ServiceAccountID: 2, - ImpersonatePermissions: []accesscontrol.Permission{}, - RedirectURI: "/whereto", - } - client1WithPerm := client1 - client1WithPerm.ImpersonatePermissions = []accesscontrol.Permission{ - {Action: "dashboards:read", Scope: "folders:*"}, - {Action: "dashboards:read", Scope: "dashboards:*"}, - } - client1WithNewSecrets := client1 - client1WithNewSecrets.ClientID = "NewClientID" - client1WithNewSecrets.Secret = "NewSecret" - client1WithNewSecrets.PublicPem = []byte("newtest") - - client1WithAud := client1 - client1WithAud.Audiences = "https://oauth.test/,https://sub.oauth.test/" - - tests := []struct { - name string - runs []oauthserver.OAuthExternalService - wantErr bool - }{ - { - name: "error no name", - runs: []oauthserver.OAuthExternalService{{}}, - wantErr: true, - }, - { - name: "simple register", - runs: []oauthserver.OAuthExternalService{client1}, - wantErr: false, - }, - { - name: "no update", - runs: []oauthserver.OAuthExternalService{client1, client1}, - wantErr: false, - }, - { - name: "add permissions", - runs: []oauthserver.OAuthExternalService{client1, client1WithPerm}, - wantErr: false, - }, - { - name: "remove permissions", - runs: []oauthserver.OAuthExternalService{client1WithPerm, client1}, - wantErr: false, - }, - { - name: "update id and secrets", - runs: []oauthserver.OAuthExternalService{client1, client1WithNewSecrets}, - wantErr: false, - }, - { - name: "update audience", - runs: []oauthserver.OAuthExternalService{client1, client1WithAud}, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})} - for i := range tt.runs { - err := s.SaveExternalService(context.Background(), &tt.runs[i]) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - compareClientToStored(t, s, &tt.runs[i]) - } - }) - } -} - -func TestStore_GetExternalServiceByName(t *testing.T) { - client1 := oauthserver.OAuthExternalService{ - Name: "my-external-service", - ClientID: "ClientID", - Secret: "Secret", - GrantTypes: "client_credentials", - PublicPem: []byte("test"), - ServiceAccountID: 2, - ImpersonatePermissions: []accesscontrol.Permission{}, - RedirectURI: "/whereto", - } - client2 := oauthserver.OAuthExternalService{ - Name: "my-external-service-2", - ClientID: "ClientID2", - Secret: "Secret2", - GrantTypes: "client_credentials,urn:ietf:params:grant-type:jwt-bearer", - PublicPem: []byte("test2"), - ServiceAccountID: 3, - Audiences: "https://oauth.test/,https://sub.oauth.test/", - ImpersonatePermissions: []accesscontrol.Permission{ - {Action: "dashboards:read", Scope: "folders:*"}, - {Action: "dashboards:read", Scope: "dashboards:*"}, - }, - RedirectURI: "/whereto", - } - s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})} - require.NoError(t, s.SaveExternalService(context.Background(), &client1)) - require.NoError(t, s.SaveExternalService(context.Background(), &client2)) - - tests := []struct { - name string - search string - want *oauthserver.OAuthExternalService - wantErr bool - }{ - { - name: "no name provided", - search: "", - want: nil, - wantErr: true, - }, - { - name: "not found", - search: "unknown-external-service", - want: nil, - wantErr: true, - }, - { - name: "search client 1 by name", - search: "my-external-service", - want: &client1, - wantErr: false, - }, - { - name: "search client 2 by name", - search: "my-external-service-2", - want: &client2, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - stored, err := s.GetExternalServiceByName(context.Background(), tt.search) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - compareClients(t, stored, tt.want) - }) - } -} - -func TestStore_GetExternalServicePublicKey(t *testing.T) { - clientID := "ClientID" - createClient := func(clientID string, publicPem string) *oauthserver.OAuthExternalService { - return &oauthserver.OAuthExternalService{ - Name: "my-external-service", - ClientID: clientID, - Secret: "Secret", - GrantTypes: "client_credentials", - PublicPem: []byte(publicPem), - ServiceAccountID: 2, - ImpersonatePermissions: []accesscontrol.Permission{}, - RedirectURI: "/whereto", - } - } - - testCases := []struct { - name string - client *oauthserver.OAuthExternalService - clientID string - want *jose.JSONWebKey - wantKeyType string - wantErr bool - }{ - { - name: "should return an error when clientID is empty", - clientID: "", - client: createClient(clientID, ""), - want: nil, - wantErr: true, - }, - { - name: "should return an error when the client was not found", - clientID: "random", - client: createClient(clientID, ""), - want: nil, - wantErr: true, - }, - { - name: "should return an error when PublicPem is not valid", - clientID: clientID, - client: createClient(clientID, ""), - want: nil, - wantErr: true, - }, - { - name: "should return the JSON Web Key ES256", - clientID: clientID, - client: createClient(clientID, `-----BEGIN PUBLIC KEY----- -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbsGtoGJTopAIbhqy49/vyCJuDot+ -mgGaC8vUIigFQVsVB+v/HZ4yG1Rcvysig+tyNk1dZQpozpFc2dGmzHlGhw== ------END PUBLIC KEY-----`), - wantKeyType: oauthserver.ES256, - wantErr: false, - }, - { - name: "should return the JSON Web Key RS256", - clientID: clientID, - client: createClient(clientID, `-----BEGIN RSA PUBLIC KEY----- -MIIBCgKCAQEAxkly/cHvsxd6EcShGUlFAB5lIMlIbGRocCVWbIM26f6pnGr+gCNv -s365DQdQ/jUjF8bSEQM+EtjGlv2Y7Jm7dQROpPzX/1M+53Us/Gl138UtAEgL5ZKe -SKN5J/f9Nx4wkgb99v2Bt0nz6xv+kSJwgR0o8zi8shDR5n7a5mTdlQe2NOixzWlT -vnpp6Tm+IE+XyXXcrCr01I9Rf+dKuYOPSJ1K3PDgFmmGvsLcjRCCK9EftfY0keU+ -IP+sh8ewNxc6KcaLBXm3Tadb1c/HyuMi6FyYw7s9m8tyAvI1CMBAcXqLIEaRgNrc -vuO8AU0bVoUmYMKhozkcCYHudkeS08hEjQIDAQAB ------END RSA PUBLIC KEY-----`), - wantKeyType: oauthserver.RS256, - wantErr: false, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})} - require.NoError(t, s.SaveExternalService(context.Background(), tc.client)) - - webKey, err := s.GetExternalServicePublicKey(context.Background(), tc.clientID) - if tc.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - require.Equal(t, tc.wantKeyType, webKey.Algorithm) - }) - } -} - -func TestStore_RemoveExternalService(t *testing.T) { - ctx := context.Background() - client1 := oauthserver.OAuthExternalService{ - Name: "my-external-service", - ClientID: "ClientID", - ImpersonatePermissions: []accesscontrol.Permission{}, - } - client2 := oauthserver.OAuthExternalService{ - Name: "my-external-service-2", - ClientID: "ClientID2", - ImpersonatePermissions: []accesscontrol.Permission{ - {Action: "dashboards:read", Scope: "folders:*"}, - {Action: "dashboards:read", Scope: "dashboards:*"}, - }, - } - - // Init store - s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})} - require.NoError(t, s.SaveExternalService(context.Background(), &client1)) - require.NoError(t, s.SaveExternalService(context.Background(), &client2)) - - // Check presence of clients in store - getState := func(t *testing.T) map[string]bool { - client, err := s.GetExternalService(ctx, "ClientID") - if err != nil && !errors.Is(err, oauthserver.ErrClientNotFound) { - require.Fail(t, "error fetching client") - } - - client2, err := s.GetExternalService(ctx, "ClientID2") - if err != nil && !errors.Is(err, oauthserver.ErrClientNotFound) { - require.Fail(t, "error fetching client") - } - - return map[string]bool{ - "ClientID": client != nil, - "ClientID2": client2 != nil, - } - } - - tests := []struct { - name string - id string - state map[string]bool - wantErr bool - }{ - { - name: "no id provided", - state: map[string]bool{"ClientID": true, "ClientID2": true}, - wantErr: true, - }, - { - name: "not found", - id: "ClientID3", - state: map[string]bool{"ClientID": true, "ClientID2": true}, - wantErr: false, - }, - { - name: "remove client 2", - id: "ClientID2", - state: map[string]bool{"ClientID": true, "ClientID2": false}, - wantErr: false, - }, - { - name: "remove client 1", - id: "ClientID", - state: map[string]bool{"ClientID": false, "ClientID2": false}, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := s.DeleteExternalService(ctx, tt.id) - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } - require.EqualValues(t, tt.state, getState(t)) - }) - } -} - -func Test_store_GetExternalServiceNames(t *testing.T) { - ctx := context.Background() - client1 := oauthserver.OAuthExternalService{ - Name: "my-external-service", - ClientID: "ClientID", - ImpersonatePermissions: []accesscontrol.Permission{}, - } - client2 := oauthserver.OAuthExternalService{ - Name: "my-external-service-2", - ClientID: "ClientID2", - ImpersonatePermissions: []accesscontrol.Permission{ - {Action: "dashboards:read", Scope: "folders:*"}, - {Action: "dashboards:read", Scope: "dashboards:*"}, - }, - } - - // Init store - s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})} - require.NoError(t, s.SaveExternalService(context.Background(), &client1)) - require.NoError(t, s.SaveExternalService(context.Background(), &client2)) - - got, err := s.GetExternalServiceNames(ctx) - require.NoError(t, err) - require.ElementsMatch(t, []string{"my-external-service", "my-external-service-2"}, got) -} - -func compareClientToStored(t *testing.T, s *store, wanted *oauthserver.OAuthExternalService) { - ctx := context.Background() - stored, err := s.GetExternalService(ctx, wanted.ClientID) - require.NoError(t, err) - require.NotNil(t, stored) - - compareClients(t, stored, wanted) -} - -func compareClients(t *testing.T, stored *oauthserver.OAuthExternalService, wanted *oauthserver.OAuthExternalService) { - // Reset ID so we can compare - require.NotZero(t, stored.ID) - stored.ID = 0 - - // Compare permissions separately - wantedPerms := wanted.ImpersonatePermissions - storedPerms := stored.ImpersonatePermissions - wanted.ImpersonatePermissions = nil - stored.ImpersonatePermissions = nil - require.EqualValues(t, *wanted, *stored) - require.ElementsMatch(t, wantedPerms, storedPerms) -} diff --git a/pkg/services/extsvcauth/oauthserver/utils/utils.go b/pkg/services/extsvcauth/oauthserver/utils/utils.go deleted file mode 100644 index 83f79d1973bc7..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/utils/utils.go +++ /dev/null @@ -1,35 +0,0 @@ -package utils - -import ( - "crypto/x509" - "encoding/pem" - "errors" - "fmt" - "strconv" - "strings" - - "github.com/grafana/grafana/pkg/services/authn" -) - -// ParseUserIDFromSubject parses the user ID from format "user:id:<id>". -func ParseUserIDFromSubject(subject string) (int64, error) { - trimmed := strings.TrimPrefix(subject, fmt.Sprintf("%s:id:", authn.NamespaceUser)) - return strconv.ParseInt(trimmed, 10, 64) -} - -// ParsePublicKeyPem parses the public key from the PEM encoded public key. -func ParsePublicKeyPem(publicPem []byte) (any, error) { - block, _ := pem.Decode(publicPem) - if block == nil { - return nil, errors.New("could not decode PEM block") - } - - switch block.Type { - case "PUBLIC KEY": - return x509.ParsePKIXPublicKey(block.Bytes) - case "RSA PUBLIC KEY": - return x509.ParsePKCS1PublicKey(block.Bytes) - default: - return nil, fmt.Errorf("unknown key type %q", block.Type) - } -} diff --git a/pkg/services/extsvcauth/oauthserver/utils/utils_test.go b/pkg/services/extsvcauth/oauthserver/utils/utils_test.go deleted file mode 100644 index 8da34460e6d3f..0000000000000 --- a/pkg/services/extsvcauth/oauthserver/utils/utils_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package utils - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestParsePublicKeyPem(t *testing.T) { - testCases := []struct { - name string - publicKeyPem string - wantErr bool - }{ - { - name: "should return error when the public key pem is empty", - publicKeyPem: "", - wantErr: true, - }, - { - name: "should return error when the public key pem is invalid", - publicKeyPem: `-----BEGIN RSA PRIVATE KEY----- -MIIEowIBAAKCAQEAxP72NEnQF3o3eFFMtFqyloW9oLhTydxXS2dA2NolMvXewO77 -UvJw54wkOdrJrJO2BIw+XBrrb+13+koRUnwa2DNsh+SWG0PEe/31mt0zJrCmNM37 -iJYIu3KZR2aRlierVY5gyrIniBIZ9blQspI6SRY9xmo6Wdh0VCRnsCV5sMlaqerI -snLpYOjGtMmL0rFuW2jKrAzpbq7L99IDgPbiH7tluaQkGIxoc29S4wjwg0NgQONT -GkfJEeXQIkxOHNM5WGb8mvjX4U0jMdXvC4WUWcS+KpcIV7ee8uEs2xDz++N4HYAS -ty37sY8wwW22QUW9I7XlSC4rsC88Ft5ar8yLsQIDAQABAoIBAAQ1yTv+mFmKGYGj -JiskFZVBNDdpPRQvNvfj8+c2iU08ozc3HEyuZQKT1InefsknCoCwIRyNkDrPBc2F -8/cR8y5r8e25EUqxoPM/7xXxVIinBZRTEyU9BKCB71vu6Z1eiWs9jNzEIDNopKCj -ZmG8nY2Gkckp58eYCEtskEE72c0RBPg8ZTBdc1cLqbNVUjkLvR5e98ruDz6b+wyH -FnztZ0k48zM047Ior69OwFRBg+S7d6cgMMmcq4X2pg3xgQMs0Se/4+pmvBf9JPSB -kl3qpVAkzM1IFdrmpFtBzeaqYNj3uU6Bm7NxEiqjAoeDxO231ziSdzIPtXIy5eRl -9WMZCqkCgYEA1ZOaT77aa54zgjAwjNB2Poo3yoUtYJz+yNCR0CPM4MzCas3PR4XI -WUXo+RNofWvRJF88aAVX7+J0UTnRr25rN12NDbo3aZhX2YNDGBe3hgB/FOAI5UAh -9SaU070PFeGzqlu/xWdx5GFk/kiNUNLX/X4xgUGPTiwY4LQeI9lffzkCgYEA7CA7 -VHaNPGVsaNKMJVjrZeYOxNBsrH99IEgaP76DC+EVR2JYVzrNxmN6ZlRxD4CRTcyd -oquTFoFFw26gJIJAYF8MtusOD3PArnpdCRSoELezYdtVhS0yx8TSHGVC9MWSSt7O -IdjzEFpA99HPkYFjXUiWXjfCTK7ofI0RXC6a+DkCgYEAoQb8nYuEGwfYRhwXPtQd -kuGbVvI6WFGGN9opVgjn+8Xl/6jU01QmzkhLcyAS9B1KPmYfoT4GIzNWB7fURLS3 -2bKLGwJ/rPnTooe5Gn0nPb06E38mtdI4yCEirNIqgZD+aT9rw2ZPFKXqA16oTXvq -pZFzucS4S3Qr/Z9P6i+GNOECgYBkvPuS9WEcO0kdD3arGFyVhKkYXrN+hIWlmB1a -xLS0BLtHUTXPQU85LIez0KLLslZLkthN5lVCbLSOxEueR9OfSe3qvC2ref7icWHv -1dg+CaGGRkUeJEJd6CKb6re+Jexb9OKMnjpU56yADgs4ULNLwQQl/jPu81BMkwKt -CVUkQQKBgFvbuUmYtP3aqV/Kt036Q6aB6Xwg29u2XFTe4BgW7c55teebtVmGA/zc -GMwRsF4rWCcScmHGcSKlW9L6S6OxmkYjDDRhimKyHgoiQ9tawWag2XCeOlyJ+hkc -/qwwKxScuFIi2xwT+aAmR70Xk11qXTft+DaEcHdxOOZD8gA0Gxr3 ------END RSA PRIVATE KEY-----`, - wantErr: true, - }, - { - name: "should parse the public key if it is in PKCS1 format", - publicKeyPem: `-----BEGIN RSA PUBLIC KEY----- -MIIBCgKCAQEAy06MeS06Ea7zGKfOM8kosxuUBMNhrWKWMvW4Jq1IXG+lyTfann2+ -kI1rKeWAQ9YbxNzLynahoKN47EQ6mqM1Yj5v9iKWtSvCMKHWBuqrG5ksaEQaAVsA -PDg8aOQrI1MSW9Hoc1CummcWX+HKNPVwIzG3sCboENFzEG8GrJgoNHZgmyOYEMMD -2WCdfY0I9Dm0/uuNMAcyMuVhRhOtT3j91zCXvDju2+M2EejApMkV9r7FqGmNH5Hw -8u43nWXnWc4UYXEItE8nPxuqsZia2mdB5MSIdKu8a7ytFcQ+tiK6vempnxHZytEL -6NDX8DLydHbEsLUn6hc76ODVkr/wRiuYdQIDAQAB ------END RSA PUBLIC KEY-----`, - wantErr: false, - }, - { - name: "should parse the public key if it is in PKIX/X.509 format", - publicKeyPem: `-----BEGIN PUBLIC KEY----- -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbsGtoGJTopAIbhqy49/vyCJuDot+ -mgGaC8vUIigFQVsVB+v/HZ4yG1Rcvysig+tyNk1dZQpozpFc2dGmzHlGhw== ------END PUBLIC KEY-----`, - wantErr: false, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - _, err := ParsePublicKeyPem([]byte(tc.publicKeyPem)) - if tc.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} diff --git a/pkg/services/extsvcauth/registry/service.go b/pkg/services/extsvcauth/registry/service.go index 7348f607c6eb6..9a7ec4a8cc240 100644 --- a/pkg/services/extsvcauth/registry/service.go +++ b/pkg/services/extsvcauth/registry/service.go @@ -1,5 +1,7 @@ package registry +// FIXME (gamab): we can eventually remove this package + import ( "context" "sync" @@ -9,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/infra/serverlock" "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/services/extsvcauth" - "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/oasimpl" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/serviceaccounts/extsvcaccounts" ) @@ -29,21 +30,20 @@ type serverLocker interface { type Registry struct { features featuremgmt.FeatureToggles logger log.Logger - oauthReg extsvcauth.ExternalServiceRegistry saReg extsvcauth.ExternalServiceRegistry + // FIXME (gamab): we can remove this field and use the saReg.GetExternalServiceNames directly extSvcProviders map[string]extsvcauth.AuthProvider lock sync.Mutex serverLock serverLocker } -func ProvideExtSvcRegistry(oauthServer *oasimpl.OAuth2ServiceImpl, saSvc *extsvcaccounts.ExtSvcAccountsService, serverLock *serverlock.ServerLockService, features featuremgmt.FeatureToggles) *Registry { +func ProvideExtSvcRegistry(saSvc *extsvcaccounts.ExtSvcAccountsService, serverLock *serverlock.ServerLockService, features featuremgmt.FeatureToggles) *Registry { return &Registry{ extSvcProviders: map[string]extsvcauth.AuthProvider{}, features: features, lock: sync.Mutex{}, logger: log.New("extsvcauth.registry"), - oauthReg: oauthServer, saReg: saSvc, serverLock: serverLock, } @@ -70,11 +70,6 @@ func (r *Registry) CleanUpOrphanedExternalServices(ctx context.Context) error { errCleanUp = err return } - case extsvcauth.OAuth2Server: - if err := r.oauthReg.RemoveExternalService(ctx, name); err != nil { - errCleanUp = err - return - } } } } @@ -121,13 +116,6 @@ func (r *Registry) RemoveExternalService(ctx context.Context, name string) error } r.logger.Debug("Routing External Service removal to the External Service Account service", "service", name) return r.saReg.RemoveExternalService(ctx, name) - case extsvcauth.OAuth2Server: - if !r.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) { - r.logger.Debug("Skipping External Service removal, flag disabled", "service", name, "flag", featuremgmt.FlagExternalServiceAccounts) - return nil - } - r.logger.Debug("Routing External Service removal to the OAuth2Server", "service", name) - return r.oauthReg.RemoveExternalService(ctx, name) default: return extsvcauth.ErrUnknownProvider.Errorf("unknown provider '%v'", provider) } @@ -157,13 +145,6 @@ func (r *Registry) SaveExternalService(ctx context.Context, cmd *extsvcauth.Exte } r.logger.Debug("Routing the External Service registration to the External Service Account service", "service", cmd.Name) extSvc, errSave = r.saReg.SaveExternalService(ctx, cmd) - case extsvcauth.OAuth2Server: - if !r.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) { - r.logger.Warn("Skipping External Service authentication, flag disabled", "service", cmd.Name, "flag", featuremgmt.FlagExternalServiceAuth) - return - } - r.logger.Debug("Routing the External Service registration to the OAuth2Server", "service", cmd.Name) - extSvc, errSave = r.oauthReg.SaveExternalService(ctx, cmd) default: errSave = extsvcauth.ErrUnknownProvider.Errorf("unknown provider '%v'", cmd.AuthProvider) } @@ -187,16 +168,7 @@ func (r *Registry) retrieveExtSvcProviders(ctx context.Context) (map[string]exts extsvcs[names[i]] = extsvcauth.ServiceAccounts } } - // Important to run this second as the OAuth server uses External Service Accounts as well. - if r.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) { - names, err := r.oauthReg.GetExternalServiceNames(ctx) - if err != nil { - return nil, err - } - for i := range names { - extsvcs[names[i]] = extsvcauth.OAuth2Server - } - } + return extsvcs, nil } diff --git a/pkg/services/extsvcauth/registry/service_test.go b/pkg/services/extsvcauth/registry/service_test.go index f43f0b327f017..40c046f4ceb54 100644 --- a/pkg/services/extsvcauth/registry/service_test.go +++ b/pkg/services/extsvcauth/registry/service_test.go @@ -14,9 +14,8 @@ import ( ) type TestEnv struct { - r *Registry - oauthReg *tests.ExternalServiceRegistryMock - saReg *tests.ExternalServiceRegistryMock + r *Registry + saReg *tests.ExternalServiceRegistryMock } // Never lock in tests @@ -29,12 +28,10 @@ func (f *fakeServerLock) LockExecuteAndReleaseWithRetries(ctx context.Context, a func setupTestEnv(t *testing.T) *TestEnv { env := TestEnv{} - env.oauthReg = tests.NewExternalServiceRegistryMock(t) env.saReg = tests.NewExternalServiceRegistryMock(t) env.r = &Registry{ - features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth, featuremgmt.FlagExternalServiceAccounts), + features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts), logger: log.New("extsvcauth.registry.test"), - oauthReg: env.oauthReg, saReg: env.saReg, extSvcProviders: map[string]extsvcauth.AuthProvider{}, serverLock: &fakeServerLock{}, @@ -51,39 +48,24 @@ func TestRegistry_CleanUpOrphanedExternalServices(t *testing.T) { name: "should not clean up when every service registered", init: func(te *TestEnv) { // Have registered two services one requested a service account, the other requested to be an oauth client - te.r.extSvcProviders = map[string]extsvcauth.AuthProvider{"sa-svc": extsvcauth.ServiceAccounts, "oauth-svc": extsvcauth.OAuth2Server} + te.r.extSvcProviders = map[string]extsvcauth.AuthProvider{"sa-svc": extsvcauth.ServiceAccounts} - te.oauthReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"oauth-svc"}, nil) // Also return the external service account attached to the OAuth Server - te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc", "oauth-svc"}, nil) + te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc"}, nil) }, }, { name: "should clean up an orphaned service account", init: func(te *TestEnv) { // Have registered two services one requested a service account, the other requested to be an oauth client - te.r.extSvcProviders = map[string]extsvcauth.AuthProvider{"sa-svc": extsvcauth.ServiceAccounts, "oauth-svc": extsvcauth.OAuth2Server} + te.r.extSvcProviders = map[string]extsvcauth.AuthProvider{"sa-svc": extsvcauth.ServiceAccounts} - te.oauthReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"oauth-svc"}, nil) // Also return the external service account attached to the OAuth Server - te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc", "orphaned-sa-svc", "oauth-svc"}, nil) + te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc", "orphaned-sa-svc"}, nil) te.saReg.On("RemoveExternalService", mock.Anything, "orphaned-sa-svc").Return(nil) }, }, - { - name: "should clean up an orphaned OAuth Client", - init: func(te *TestEnv) { - // Have registered two services one requested a service account, the other requested to be an oauth client - te.r.extSvcProviders = map[string]extsvcauth.AuthProvider{"sa-svc": extsvcauth.ServiceAccounts, "oauth-svc": extsvcauth.OAuth2Server} - - te.oauthReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"oauth-svc", "orphaned-oauth-svc"}, nil) - // Also return the external service account attached to the OAuth Server - te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc", "orphaned-oauth-svc", "oauth-svc"}, nil) - - te.oauthReg.On("RemoveExternalService", mock.Anything, "orphaned-oauth-svc").Return(nil) - }, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -93,37 +75,6 @@ func TestRegistry_CleanUpOrphanedExternalServices(t *testing.T) { err := env.r.CleanUpOrphanedExternalServices(context.Background()) require.NoError(t, err) - env.oauthReg.AssertExpectations(t) - env.saReg.AssertExpectations(t) - }) - } -} - -func TestRegistry_GetExternalServiceNames(t *testing.T) { - tests := []struct { - name string - init func(*TestEnv) - want []string - }{ - { - name: "should deduplicate names", - init: func(te *TestEnv) { - te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc", "oauth-svc"}, nil) - te.oauthReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"oauth-svc"}, nil) - }, - want: []string{"sa-svc", "oauth-svc"}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - env := setupTestEnv(t) - tt.init(env) - - names, err := env.r.GetExternalServiceNames(context.Background()) - require.NoError(t, err) - require.ElementsMatch(t, tt.want, names) - - env.oauthReg.AssertExpectations(t) env.saReg.AssertExpectations(t) }) } diff --git a/pkg/services/featuremgmt/codeowners.go b/pkg/services/featuremgmt/codeowners.go index 42e0c13962345..a303f74f9b2b4 100644 --- a/pkg/services/featuremgmt/codeowners.go +++ b/pkg/services/featuremgmt/codeowners.go @@ -8,7 +8,6 @@ const ( grafanaAppPlatformSquad codeowner = "@grafana/grafana-app-platform-squad" grafanaDashboardsSquad codeowner = "@grafana/dashboards-squad" grafanaExploreSquad codeowner = "@grafana/explore-squad" - grafanaBiSquad codeowner = "@grafana/grafana-bi-squad" grafanaDatavizSquad codeowner = "@grafana/dataviz-squad" grafanaFrontendPlatformSquad codeowner = "@grafana/grafana-frontend-platform" grafanaBackendPlatformSquad codeowner = "@grafana/backend-platform" diff --git a/pkg/services/featuremgmt/manager.go b/pkg/services/featuremgmt/manager.go index b2c962dc1c2d1..457436790a5b4 100644 --- a/pkg/services/featuremgmt/manager.go +++ b/pkg/services/featuremgmt/manager.go @@ -6,7 +6,7 @@ import ( "reflect" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/licensing" + "github.com/grafana/grafana/pkg/setting" ) var ( @@ -16,13 +16,14 @@ var ( type FeatureManager struct { isDevMod bool restartRequired bool - allowEditing bool - licensing licensing.Licensing - flags map[string]*FeatureFlag - enabled map[string]bool // only the "on" values - config string // path to config file - vars map[string]any - log log.Logger + + Settings setting.FeatureMgmtSettings + + flags map[string]*FeatureFlag + enabled map[string]bool // only the "on" values + startup map[string]bool // the explicit values registered at startup + warnings map[string]string // potential warnings about the flag + log log.Logger } // This will merge the flags with the current configuration @@ -42,9 +43,6 @@ func (fm *FeatureManager) registerFlags(flags ...FeatureFlag) { if add.Description != "" { flag.Description = add.Description } - if add.DocsURL != "" { - flag.DocsURL = add.DocsURL - } if add.Expression != "" { flag.Expression = add.Expression } @@ -59,10 +57,6 @@ func (fm *FeatureManager) registerFlags(flags ...FeatureFlag) { flag.RequiresDevMode = true } - if add.RequiresLicense { - flag.RequiresLicense = true - } - if add.RequiresRestart { flag.RequiresRestart = true } @@ -73,16 +67,12 @@ func (fm *FeatureManager) registerFlags(flags ...FeatureFlag) { } // meetsRequirements checks if grafana is able to run the given feature due to dev mode or licensing requirements -func (fm *FeatureManager) meetsRequirements(ff *FeatureFlag) bool { +func (fm *FeatureManager) meetsRequirements(ff *FeatureFlag) (bool, string) { if ff.RequiresDevMode && !fm.isDevMod { - return false + return false, "requires dev mode" } - if ff.RequiresLicense && (fm.licensing == nil || !fm.licensing.FeatureEnabled(ff.Name)) { - return false - } - - return true + return true, "" } // Update @@ -90,14 +80,17 @@ func (fm *FeatureManager) update() { enabled := make(map[string]bool) for _, flag := range fm.flags { // if grafana cannot run the feature, omit metrics around it - if !fm.meetsRequirements(flag) { + ok, reason := fm.meetsRequirements(flag) + if !ok { + fm.warnings[flag.Name] = reason continue } // Update the registry track := 0.0 - // TODO: CEL - expression - if flag.Expression == "true" { + + startup, ok := fm.startup[flag.Name] + if startup || (!ok && flag.Expression == "true") { track = 1 enabled[flag.Name] = true } @@ -108,23 +101,6 @@ func (fm *FeatureManager) update() { fm.enabled = enabled } -// Run is called by background services -func (fm *FeatureManager) readFile() error { - if fm.config == "" { - return nil // not configured - } - - cfg, err := readConfigFile(fm.config) - if err != nil { - return err - } - - fm.registerFlags(cfg.Flags...) - fm.vars = cfg.Vars - - return nil -} - // IsEnabled checks if a feature is enabled func (fm *FeatureManager) IsEnabled(ctx context.Context, flag string) bool { return fm.enabled[flag] @@ -155,29 +131,76 @@ func (fm *FeatureManager) GetFlags() []FeatureFlag { return v } -func (fm *FeatureManager) GetState() *FeatureManagerState { - return &FeatureManagerState{RestartRequired: fm.restartRequired, AllowEditing: fm.allowEditing} +// isFeatureEditingAllowed checks if the backend is properly configured to allow feature toggle changes from the UI +func (fm *FeatureManager) IsFeatureEditingAllowed() bool { + return fm.Settings.AllowEditing && fm.Settings.UpdateWebhook != "" } -func (fm *FeatureManager) SetRestartRequired() { - fm.restartRequired = true +// indicate if a change has been made (not that accurate, but better than nothing) +func (fm *FeatureManager) IsRestartRequired() bool { + return fm.restartRequired +} + +// Flags that can be edited +func (fm *FeatureManager) IsEditableFromAdminPage(key string) bool { + flag, ok := fm.flags[key] + if !ok || + !fm.IsFeatureEditingAllowed() || + !flag.AllowSelfServe || + flag.Name == FlagFeatureToggleAdminPage { + return false + } + return flag.Stage == FeatureStageGeneralAvailability || + flag.Stage == FeatureStagePublicPreview || + flag.Stage == FeatureStageDeprecated } -// Check to see if a feature toggle exists by name -func (fm *FeatureManager) LookupFlag(name string) (FeatureFlag, bool) { - f, ok := fm.flags[name] - if !ok { - return FeatureFlag{}, false +// Flags that should not be shown in the UI (regardless of their state) +func (fm *FeatureManager) IsHiddenFromAdminPage(key string, lenient bool) bool { + _, hide := fm.Settings.HiddenToggles[key] + flag, ok := fm.flags[key] + if !ok || flag.HideFromAdminPage || hide { + return true // unknown flag (should we show it as a warning!) } - return *f, true + + // Explicitly hidden from configs + _, found := fm.Settings.HiddenToggles[key] + if found { + return true + } + if lenient { + return false + } + + return flag.Stage == FeatureStageUnknown || + flag.Stage == FeatureStageExperimental || + flag.Stage == FeatureStagePrivatePreview +} + +// Get the flags that were explicitly set on startup +func (fm *FeatureManager) GetStartupFlags() map[string]bool { + return fm.startup +} + +// Perhaps expose the flag warnings +func (fm *FeatureManager) GetWarning() map[string]string { + return fm.warnings +} + +func (fm *FeatureManager) SetRestartRequired() { + fm.restartRequired = true } // ############# Test Functions ############# +func WithFeatures(spec ...any) FeatureToggles { + return WithManager(spec...) +} + // WithFeatures is used to define feature toggles for testing. // The arguments are a list of strings that are optionally followed by a boolean value for example: // WithFeatures([]any{"my_feature", "other_feature"}) or WithFeatures([]any{"my_feature", true}) -func WithFeatures(spec ...any) *FeatureManager { +func WithManager(spec ...any) *FeatureManager { count := len(spec) features := make(map[string]*FeatureFlag, count) enabled := make(map[string]bool, count) @@ -192,30 +215,41 @@ func WithFeatures(spec ...any) *FeatureManager { idx++ } - features[key] = &FeatureFlag{Name: key, Enabled: val} + features[key] = &FeatureFlag{Name: key} if val { enabled[key] = true } } - return &FeatureManager{enabled: enabled, flags: features} + return &FeatureManager{enabled: enabled, flags: features, startup: enabled, warnings: map[string]string{}} } -// WithFeatureFlags is used to define feature toggles for testing. +// WithFeatureManager is used to define feature toggle manager for testing. // It should be used when your test feature toggles require metadata beyond `Name` and `Enabled`. // You should provide a feature toggle Name at a minimum. -func WithFeatureFlags(flags []*FeatureFlag) *FeatureManager { +func WithFeatureManager(cfg setting.FeatureMgmtSettings, flags []*FeatureFlag, disabled ...string) *FeatureManager { count := len(flags) features := make(map[string]*FeatureFlag, count) enabled := make(map[string]bool, count) + dis := make(map[string]bool) + for _, v := range disabled { + dis[v] = true + } + for _, f := range flags { if f.Name == "" { continue } features[f.Name] = f - enabled[f.Name] = f.Enabled + enabled[f.Name] = !dis[f.Name] } - return &FeatureManager{enabled: enabled, flags: features} + return &FeatureManager{ + Settings: cfg, + enabled: enabled, + flags: features, + startup: enabled, + warnings: map[string]string{}, + } } diff --git a/pkg/services/featuremgmt/manager_test.go b/pkg/services/featuremgmt/manager_test.go index 83bdd73995d54..449a46ca05500 100644 --- a/pkg/services/featuremgmt/manager_test.go +++ b/pkg/services/featuremgmt/manager_test.go @@ -9,7 +9,7 @@ import ( func TestFeatureManager(t *testing.T) { t.Run("check testing stubs", func(t *testing.T) { - ft := WithFeatures("a", "b", "c") + ft := WithManager("a", "b", "c") require.True(t, ft.IsEnabledGlobally("a")) require.True(t, ft.IsEnabledGlobally("b")) require.True(t, ft.IsEnabledGlobally("c")) @@ -18,60 +18,51 @@ func TestFeatureManager(t *testing.T) { require.Equal(t, map[string]bool{"a": true, "b": true, "c": true}, ft.GetEnabled(context.Background())) // Explicit values - ft = WithFeatures("a", true, "b", false) + ft = WithManager("a", true, "b", false) require.True(t, ft.IsEnabledGlobally("a")) require.False(t, ft.IsEnabledGlobally("b")) require.Equal(t, map[string]bool{"a": true}, ft.GetEnabled(context.Background())) }) - t.Run("check license validation", func(t *testing.T) { + t.Run("check description and stage configs", func(t *testing.T) { ft := FeatureManager{ flags: map[string]*FeatureFlag{}, } ft.registerFlags(FeatureFlag{ - Name: "a", - RequiresLicense: true, - RequiresDevMode: true, - Expression: "true", + Name: "a", + Description: "first", }, FeatureFlag{ - Name: "b", - Expression: "true", - }) - require.False(t, ft.IsEnabledGlobally("a")) - require.True(t, ft.IsEnabledGlobally("b")) - require.False(t, ft.IsEnabledGlobally("c")) // uknown flag - - // Try changing "requires license" - ft.registerFlags(FeatureFlag{ - Name: "a", - RequiresLicense: false, // shuld still require license! + Name: "a", + Description: "second", }, FeatureFlag{ - Name: "b", - RequiresLicense: true, // expression is still "true" + Name: "a", + Stage: FeatureStagePrivatePreview, + }, FeatureFlag{ + Name: "a", }) - require.False(t, ft.IsEnabledGlobally("a")) - require.False(t, ft.IsEnabledGlobally("b")) - require.False(t, ft.IsEnabledGlobally("c")) + flag := ft.flags["a"] + require.Equal(t, "second", flag.Description) + require.Equal(t, FeatureStagePrivatePreview, flag.Stage) }) - t.Run("check description and docs configs", func(t *testing.T) { + t.Run("check startup false flags", func(t *testing.T) { ft := FeatureManager{ flags: map[string]*FeatureFlag{}, + startup: map[string]bool{ + "a": true, + "b": false, // but default true + }, } ft.registerFlags(FeatureFlag{ - Name: "a", - Description: "first", - }, FeatureFlag{ - Name: "a", - Description: "second", + Name: "a", }, FeatureFlag{ - Name: "a", - DocsURL: "http://something", + Name: "b", + Expression: "true", }, FeatureFlag{ - Name: "a", + Name: "c", }) - flag := ft.flags["a"] - require.Equal(t, "second", flag.Description) - require.Equal(t, "http://something", flag.DocsURL) + require.True(t, ft.IsEnabledGlobally("a")) + require.False(t, ft.IsEnabledGlobally("b")) + require.False(t, ft.IsEnabledGlobally("c")) }) } diff --git a/pkg/services/featuremgmt/models.go b/pkg/services/featuremgmt/models.go index 7c950f1e8201c..6260547585ba3 100644 --- a/pkg/services/featuremgmt/models.go +++ b/pkg/services/featuremgmt/models.go @@ -4,20 +4,23 @@ import ( "bytes" "context" "encoding/json" - "time" ) type FeatureToggles interface { - // Check if a feature is enabled for a given context. + // IsEnabled checks if a feature is enabled for a given context. // The settings may be per user, tenant, or globally set in the cloud IsEnabled(ctx context.Context, flag string) bool - // Check if a flag is configured globally. For now, this is the same + // IsEnabledGlobally checks if a flag is configured globally. For now, this is the same // as the function above, however it will move to only checking flags that // are configured by the operator and shared across all tenants. // Use of global feature flags should be limited and careful as they require // a full server restart for a change to take place. IsEnabledGlobally(flag string) bool + + // Get the enabled flags -- this *may* also include disabled flags (with value false) + // but it is guaranteed to have the enabled ones listed + GetEnabled(ctx context.Context) map[string]bool } // FeatureFlagStage indicates the quality level @@ -106,16 +109,15 @@ func (s *FeatureFlagStage) UnmarshalJSON(b []byte) error { return nil } +// These are properties about the feature, but not the current state or value for it type FeatureFlag struct { - // Required properties Name string `json:"name" yaml:"name"` // Unique name Description string `json:"description"` Stage FeatureFlagStage `json:"stage,omitempty"` - Created time.Time `json:"created,omitempty"` // when the flag was introduced - Owner codeowner `json:"-"` // Owner person or team that owns this feature flag + Owner codeowner `json:"-"` // Owner person or team that owns this feature flag // Recommended properties - control behavior of the feature toggle management page in the UI - AllowSelfServe bool `json:"allowSelfServe,omitempty"` // allow users with the right privileges to toggle this from the UI (GeneralAvailability and Deprecated toggles only) + AllowSelfServe bool `json:"allowSelfServe,omitempty"` // allow users with the right privileges to toggle this from the UI (GeneralAvailability, PublicPreview, and Deprecated toggles only) HideFromAdminPage bool `json:"hideFromAdminPage,omitempty"` // GA, Deprecated, and PublicPreview toggles only: don't display this feature in the UI; if this is a GA toggle, add a comment with the reasoning // CEL-GO expression. Using the value "true" will mean this is on by default @@ -123,30 +125,14 @@ type FeatureFlag struct { // Special behavior properties RequiresDevMode bool `json:"requiresDevMode,omitempty"` // can not be enabled in production - RequiresLicense bool `json:"requiresLicense,omitempty"` // Must be enabled in the license FrontendOnly bool `json:"frontend,omitempty"` // change is only seen in the frontend HideFromDocs bool `json:"hideFromDocs,omitempty"` // don't add the values to docs - // This field is only for the feature management API. To enable your feature toggle by default, use `Expression`. - Enabled bool `json:"enabled,omitempty"` - - // These are currently unused - DocsURL string `json:"docsURL,omitempty"` - RequiresRestart bool `json:"requiresRestart,omitempty"` // The server must be initialized with the value -} - -type UpdateFeatureTogglesCommand struct { - FeatureToggles []FeatureToggleDTO `json:"featureToggles"` -} - -type FeatureToggleDTO struct { - Name string `json:"name" binding:"Required"` - Description string `json:"description"` - Enabled bool `json:"enabled"` - ReadOnly bool `json:"readOnly,omitempty"` + // The server must be initialized with the value + RequiresRestart bool `json:"requiresRestart,omitempty"` } -type FeatureManagerState struct { - RestartRequired bool `json:"restartRequired"` - AllowEditing bool `json:"allowEditing"` +type FeatureToggleWebhookPayload struct { + FeatureToggles map[string]string `json:"feature_toggles"` + User string `json:"user"` } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 38a743f6bb7ad..a6e1858bca9cc 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -7,7 +7,10 @@ package featuremgmt import ( - "time" + "embed" + "encoding/json" + + featuretoggle "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" ) var ( @@ -20,7 +23,6 @@ var ( Owner: grafanaAsCodeSquad, HideFromAdminPage: true, AllowSelfServe: false, - Created: time.Date(2022, time.May, 24, 12, 0, 0, 0, time.UTC), }, { Name: "live-service-web-worker", @@ -28,7 +30,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaAppPlatformSquad, - Created: time.Date(2021, time.November, 9, 12, 0, 0, 0, time.UTC), }, { Name: "queryOverLive", @@ -36,7 +37,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaAppPlatformSquad, - Created: time.Date(2022, time.January, 5, 12, 0, 0, 0, time.UTC), }, { Name: "panelTitleSearch", @@ -44,7 +44,6 @@ var ( Stage: FeatureStagePublicPreview, Owner: grafanaAppPlatformSquad, HideFromAdminPage: true, - Created: time.Date(2022, time.February, 15, 12, 0, 0, 0, time.UTC), }, { Name: "publicDashboards", @@ -53,24 +52,20 @@ var ( Owner: grafanaSharingSquad, Expression: "true", // enabled by default AllowSelfServe: true, - Created: time.Date(2022, time.April, 7, 12, 0, 0, 0, time.UTC), }, { Name: "publicDashboardsEmailSharing", Description: "Enables public dashboard sharing to be restricted to only allowed emails", Stage: FeatureStagePublicPreview, - RequiresLicense: true, Owner: grafanaSharingSquad, HideFromDocs: true, HideFromAdminPage: true, - Created: time.Date(2022, time.December, 21, 12, 0, 0, 0, time.UTC), }, { Name: "lokiExperimentalStreaming", Description: "Support new streaming approach for loki (prototype, needs special loki build)", Stage: FeatureStageExperimental, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.June, 19, 12, 0, 0, 0, time.UTC), }, { Name: "featureHighlights", @@ -78,28 +73,25 @@ var ( Stage: FeatureStageGeneralAvailability, Owner: grafanaAsCodeSquad, AllowSelfServe: true, - Created: time.Date(2022, time.February, 3, 12, 0, 0, 0, time.UTC), }, { Name: "migrationLocking", Description: "Lock database during migrations", Stage: FeatureStagePublicPreview, Owner: grafanaBackendPlatformSquad, - Created: time.Date(2022, time.February, 15, 12, 0, 0, 0, time.UTC), }, { Name: "storage", Description: "Configurable storage for dashboards, datasources, and resources", Stage: FeatureStageExperimental, Owner: grafanaAppPlatformSquad, - Created: time.Date(2022, time.March, 17, 12, 0, 0, 0, time.UTC), }, { - Name: "correlations", - Description: "Correlations page", - Stage: FeatureStagePublicPreview, - Owner: grafanaExploreSquad, - Created: time.Date(2022, time.September, 16, 12, 0, 0, 0, time.UTC), + Name: "correlations", + Description: "Correlations page", + Stage: FeatureStageGeneralAvailability, + Owner: grafanaExploreSquad, + AllowSelfServe: true, }, { Name: "exploreContentOutline", @@ -109,30 +101,54 @@ var ( Expression: "true", // enabled by default FrontendOnly: true, AllowSelfServe: true, - Created: time.Date(2023, time.November, 3, 12, 0, 0, 0, time.UTC), }, { Name: "datasourceQueryMultiStatus", Description: "Introduce HTTP 207 Multi Status for api/ds/query", Stage: FeatureStageExperimental, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2022, time.May, 3, 12, 0, 0, 0, time.UTC), }, { - Name: "traceToMetrics", - Description: "Enable trace to metrics links", - Stage: FeatureStageExperimental, + Name: "autoMigrateOldPanels", + Description: "Migrate old angular panels to supported versions (graph, table-old, worldmap, etc)", + Stage: FeatureStagePublicPreview, FrontendOnly: true, - Owner: grafanaObservabilityTracesAndProfilingSquad, - Created: time.Date(2022, time.March, 7, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { - Name: "autoMigrateOldPanels", - Description: "Migrate old angular panels to supported versions (graph, table-old, worldmap, etc)", + Name: "autoMigrateGraphPanel", + Description: "Migrate old graph panel to supported time series panel - broken out from autoMigrateOldPanels to enable granular tracking", + Stage: FeatureStagePublicPreview, + FrontendOnly: true, + Owner: grafanaDatavizSquad, + }, + { + Name: "autoMigrateTablePanel", + Description: "Migrate old table panel to supported table panel - broken out from autoMigrateOldPanels to enable granular tracking", + Stage: FeatureStagePublicPreview, + FrontendOnly: true, + Owner: grafanaDatavizSquad, + }, + { + Name: "autoMigratePiechartPanel", + Description: "Migrate old piechart panel to supported piechart panel - broken out from autoMigrateOldPanels to enable granular tracking", + Stage: FeatureStagePublicPreview, + FrontendOnly: true, + Owner: grafanaDatavizSquad, + }, + { + Name: "autoMigrateWorldmapPanel", + Description: "Migrate old worldmap panel to supported geomap panel - broken out from autoMigrateOldPanels to enable granular tracking", + Stage: FeatureStagePublicPreview, + FrontendOnly: true, + Owner: grafanaDatavizSquad, + }, + { + Name: "autoMigrateStatPanel", + Description: "Migrate old stat panel to supported stat panel - broken out from autoMigrateOldPanels to enable granular tracking", Stage: FeatureStagePublicPreview, FrontendOnly: true, Owner: grafanaDatavizSquad, - Created: time.Date(2022, time.June, 11, 12, 0, 0, 0, time.UTC), }, { Name: "disableAngular", @@ -141,7 +157,6 @@ var ( FrontendOnly: true, Owner: grafanaDatavizSquad, HideFromAdminPage: true, - Created: time.Date(2023, time.March, 23, 12, 0, 0, 0, time.UTC), }, { Name: "canvasPanelNesting", @@ -150,16 +165,14 @@ var ( FrontendOnly: true, Owner: grafanaDatavizSquad, HideFromAdminPage: true, - Created: time.Date(2022, time.May, 31, 12, 0, 0, 0, time.UTC), }, { Name: "newVizTooltips", Description: "New visualizations tooltips UX", - Stage: FeatureStageGeneralAvailability, + Stage: FeatureStagePublicPreview, FrontendOnly: true, Owner: grafanaDatavizSquad, - AllowSelfServe: true, - Created: time.Date(2023, time.November, 3, 12, 0, 0, 0, time.UTC), + AllowSelfServe: false, }, { Name: "scenes", @@ -167,7 +180,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaDashboardsSquad, - Created: time.Date(2022, time.July, 7, 12, 0, 0, 0, time.UTC), }, { Name: "disableSecretsCompatibility", @@ -175,23 +187,12 @@ var ( Stage: FeatureStageExperimental, RequiresRestart: true, Owner: hostedGrafanaTeam, - Created: time.Date(2022, time.July, 13, 12, 0, 0, 0, time.UTC), }, { Name: "logRequestsInstrumentedAsUnknown", Description: "Logs the path for requests that are instrumented as unknown", Stage: FeatureStageExperimental, Owner: hostedGrafanaTeam, - Created: time.Date(2022, time.June, 10, 12, 0, 0, 0, time.UTC), - }, - { - Name: "dataConnectionsConsole", - Description: "Enables a new top-level page called Connections. This page is an experiment that provides a better experience when you install and configure data sources and other plugins.", - Stage: FeatureStageGeneralAvailability, - Expression: "true", // turned on by default - Owner: grafanaPluginsPlatformSquad, - AllowSelfServe: true, - Created: time.Date(2022, time.June, 1, 12, 0, 0, 0, time.UTC), }, { // Some plugins rely on topnav feature flag being enabled, so we cannot remove this until we @@ -201,15 +202,13 @@ var ( Stage: FeatureStageDeprecated, Expression: "true", // enabled by default Owner: grafanaFrontendPlatformSquad, - Created: time.Date(2022, time.June, 20, 12, 0, 0, 0, time.UTC), }, { - Name: "dockedMegaMenu", - Description: "Enable support for a persistent (docked) navigation menu", - Stage: FeatureStageExperimental, + Name: "returnToPrevious", + Description: "Enables the return to previous context functionality", + Stage: FeatureStagePublicPreview, FrontendOnly: true, Owner: grafanaFrontendPlatformSquad, - Created: time.Date(2023, time.September, 18, 12, 0, 0, 0, time.UTC), }, { Name: "grpcServer", @@ -217,7 +216,6 @@ var ( Stage: FeatureStagePublicPreview, Owner: grafanaAppPlatformSquad, HideFromAdminPage: true, - Created: time.Date(2022, time.September, 27, 12, 0, 0, 0, time.UTC), }, { Name: "unifiedStorage", @@ -226,7 +224,6 @@ var ( RequiresDevMode: true, RequiresRestart: true, // new SQL tables created Owner: grafanaAppPlatformSquad, - Created: time.Date(2022, time.December, 1, 12, 0, 0, 0, time.UTC), }, { Name: "cloudWatchCrossAccountQuerying", @@ -235,7 +232,6 @@ var ( Expression: "true", // enabled by default Owner: awsDatasourcesSquad, AllowSelfServe: true, - Created: time.Date(2022, time.November, 28, 12, 0, 0, 0, time.UTC), }, { Name: "redshiftAsyncQueryDataSupport", @@ -244,7 +240,6 @@ var ( Expression: "true", // enabled by default Owner: awsDatasourcesSquad, AllowSelfServe: false, - Created: time.Date(2022, time.August, 27, 12, 0, 0, 0, time.UTC), }, { Name: "athenaAsyncQueryDataSupport", @@ -254,30 +249,18 @@ var ( FrontendOnly: true, Owner: awsDatasourcesSquad, AllowSelfServe: false, - Created: time.Date(2022, time.August, 27, 12, 0, 0, 0, time.UTC), - }, - { - Name: "cloudwatchNewRegionsHandler", - Description: "Refactor of /regions endpoint, no user-facing changes", - Stage: FeatureStageGeneralAvailability, - Expression: "true", // enabled by default - Owner: awsDatasourcesSquad, - AllowSelfServe: true, - Created: time.Date(2023, time.September, 25, 12, 0, 0, 0, time.UTC), }, { Name: "showDashboardValidationWarnings", Description: "Show warnings when dashboards do not validate against the schema", Stage: FeatureStageExperimental, Owner: grafanaDashboardsSquad, - Created: time.Date(2022, time.October, 14, 12, 0, 0, 0, time.UTC), }, { Name: "mysqlAnsiQuotes", Description: "Use double quotes to escape keyword in a MySQL query", Stage: FeatureStageExperimental, Owner: grafanaBackendPlatformSquad, - Created: time.Date(2022, time.October, 12, 12, 0, 0, 0, time.UTC), }, { Name: "accessControlOnCall", @@ -285,14 +268,12 @@ var ( Stage: FeatureStagePublicPreview, Owner: identityAccessTeam, HideFromAdminPage: true, - Created: time.Date(2022, time.October, 19, 12, 0, 0, 0, time.UTC), }, { Name: "nestedFolders", Description: "Enable folder nesting", Stage: FeatureStagePublicPreview, Owner: grafanaBackendPlatformSquad, - Created: time.Date(2022, time.October, 22, 12, 0, 0, 0, time.UTC), }, { Name: "nestedFolderPicker", @@ -302,41 +283,19 @@ var ( FrontendOnly: true, Expression: "true", // enabled by default AllowSelfServe: true, - Created: time.Date(2023, time.July, 24, 12, 0, 0, 0, time.UTC), - }, - { - Name: "emptyDashboardPage", - Description: "Enable the redesigned user interface of a dashboard page that includes no panels", - Stage: FeatureStageGeneralAvailability, - FrontendOnly: true, - Expression: "true", // enabled by default - Owner: grafanaDashboardsSquad, - AllowSelfServe: false, - HideFromAdminPage: true, - Created: time.Date(2023, time.March, 28, 12, 0, 0, 0, time.UTC), - }, - { - Name: "disablePrometheusExemplarSampling", - Description: "Disable Prometheus exemplar sampling", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaObservabilityMetricsSquad, - AllowSelfServe: true, - Created: time.Date(2022, time.December, 19, 12, 0, 0, 0, time.UTC), }, { Name: "alertingBacktesting", Description: "Rule backtesting API for alerting", Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, - Created: time.Date(2022, time.October, 20, 12, 0, 0, 0, time.UTC), }, { Name: "editPanelCSVDragAndDrop", Description: "Enables drag and drop for CSV and Excel files", FrontendOnly: true, Stage: FeatureStageExperimental, - Owner: grafanaBiSquad, - Created: time.Date(2022, time.December, 20, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { Name: "alertingNoNormalState", @@ -345,7 +304,6 @@ var ( RequiresRestart: false, Owner: grafanaAlertingSquad, HideFromAdminPage: true, - Created: time.Date(2023, time.January, 14, 12, 0, 0, 0, time.UTC), }, { Name: "logsContextDatasourceUi", @@ -355,7 +313,6 @@ var ( Owner: grafanaObservabilityLogsSquad, Expression: "true", // turned on by default AllowSelfServe: true, - Created: time.Date(2023, time.January, 27, 12, 0, 0, 0, time.UTC), }, { Name: "lokiQuerySplitting", @@ -365,7 +322,6 @@ var ( Owner: grafanaObservabilityLogsSquad, Expression: "true", // turned on by default AllowSelfServe: true, - Created: time.Date(2023, time.February, 9, 12, 0, 0, 0, time.UTC), }, { Name: "lokiQuerySplittingConfig", @@ -373,14 +329,12 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.March, 20, 12, 0, 0, 0, time.UTC), }, { Name: "individualCookiePreferences", Description: "Support overriding cookie preferences per user", Stage: FeatureStageExperimental, Owner: grafanaBackendPlatformSquad, - Created: time.Date(2023, time.February, 23, 12, 0, 0, 0, time.UTC), }, { Name: "prometheusMetricEncyclopedia", @@ -390,7 +344,6 @@ var ( FrontendOnly: true, Owner: grafanaObservabilityMetricsSquad, AllowSelfServe: true, - Created: time.Date(2023, time.March, 7, 12, 0, 0, 0, time.UTC), }, { Name: "influxdbBackendMigration", @@ -400,23 +353,19 @@ var ( Owner: grafanaObservabilityMetricsSquad, Expression: "true", // enabled by default AllowSelfServe: false, - Created: time.Date(2023, time.March, 15, 12, 0, 0, 0, time.UTC), }, { Name: "influxqlStreamingParser", Description: "Enable streaming JSON parser for InfluxDB datasource InfluxQL query language", Stage: FeatureStageExperimental, Owner: grafanaObservabilityMetricsSquad, - Created: time.Date(2023, time.November, 29, 12, 0, 0, 0, time.UTC), }, { - Name: "clientTokenRotation", - Description: "Replaces the current in-request token rotation so that the client initiates the rotation", - Stage: FeatureStageGeneralAvailability, - Expression: "true", - Owner: identityAccessTeam, - AllowSelfServe: false, - Created: time.Date(2023, time.March, 23, 12, 0, 0, 0, time.UTC), + Name: "influxdbRunQueriesInParallel", + Description: "Enables running InfluxDB Influxql queries in parallel", + Stage: FeatureStagePrivatePreview, + FrontendOnly: false, + Owner: grafanaObservabilityMetricsSquad, }, { Name: "prometheusDataplane", @@ -425,7 +374,6 @@ var ( Stage: FeatureStageGeneralAvailability, Owner: grafanaObservabilityMetricsSquad, AllowSelfServe: true, - Created: time.Date(2023, time.March, 29, 12, 0, 0, 0, time.UTC), }, { Name: "lokiMetricDataplane", @@ -434,14 +382,12 @@ var ( Expression: "true", Owner: grafanaObservabilityLogsSquad, AllowSelfServe: true, - Created: time.Date(2023, time.April, 13, 12, 0, 0, 0, time.UTC), }, { Name: "lokiLogsDataplane", Description: "Changes logs responses from Loki to be compliant with the dataplane specification.", Stage: FeatureStageExperimental, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.July, 13, 12, 0, 0, 0, time.UTC), }, { Name: "dataplaneFrontendFallback", @@ -451,42 +397,36 @@ var ( Expression: "true", Owner: grafanaObservabilityMetricsSquad, AllowSelfServe: true, - Created: time.Date(2023, time.April, 24, 12, 0, 0, 0, time.UTC), }, { Name: "disableSSEDataplane", Description: "Disables dataplane specific processing in server side expressions.", Stage: FeatureStageExperimental, Owner: grafanaObservabilityMetricsSquad, - Created: time.Date(2023, time.April, 24, 12, 0, 0, 0, time.UTC), }, { Name: "alertStateHistoryLokiSecondary", Description: "Enable Grafana to write alert state history to an external Loki instance in addition to Grafana annotations.", Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, - Created: time.Date(2023, time.March, 30, 12, 0, 0, 0, time.UTC), }, { Name: "alertStateHistoryLokiPrimary", Description: "Enable a remote Loki instance as the primary source for state history reads.", Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, - Created: time.Date(2023, time.March, 30, 12, 0, 0, 0, time.UTC), }, { Name: "alertStateHistoryLokiOnly", Description: "Disable Grafana alerts from emitting annotations when a remote Loki instance is available.", Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, - Created: time.Date(2023, time.March, 30, 12, 0, 0, 0, time.UTC), }, { Name: "unifiedRequestLog", Description: "Writes error logs to the request logger", Stage: FeatureStageExperimental, Owner: grafanaBackendPlatformSquad, - Created: time.Date(2023, time.March, 31, 12, 0, 0, 0, time.UTC), }, { Name: "renderAuthJWT", @@ -494,15 +434,6 @@ var ( Stage: FeatureStagePublicPreview, Owner: grafanaAsCodeSquad, HideFromAdminPage: true, - Created: time.Date(2023, time.April, 3, 12, 0, 0, 0, time.UTC), - }, - { - Name: "externalServiceAuth", - Description: "Starts an OAuth2 authentication provider for external services", - Stage: FeatureStageExperimental, - RequiresDevMode: true, - Owner: identityAccessTeam, - Created: time.Date(2023, time.April, 11, 12, 0, 0, 0, time.UTC), }, { Name: "refactorVariablesTimeRange", @@ -510,17 +441,6 @@ var ( Stage: FeatureStagePublicPreview, Owner: grafanaDashboardsSquad, HideFromAdminPage: true, // Non-feature, used to test out a bug fix that impacts the performance of template variables. - Created: time.Date(2023, time.June, 6, 12, 0, 0, 0, time.UTC), - }, - { - Name: "useCachingService", - Description: "When active, the new query and resource caching implementation using a wire service inject replaces the previous middleware implementation.", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaOperatorExperienceSquad, - RequiresRestart: true, - Expression: "true", // enabled by default - AllowSelfServe: false, - Created: time.Date(2023, time.April, 12, 12, 0, 0, 0, time.UTC), }, { Name: "enableElasticsearchBackendQuerying", @@ -529,18 +449,6 @@ var ( Owner: grafanaObservabilityLogsSquad, Expression: "true", // enabled by default AllowSelfServe: true, - Created: time.Date(2023, time.April, 14, 12, 0, 0, 0, time.UTC), - }, - { - Name: "advancedDataSourcePicker", - Description: "Enable a new data source picker with contextual information, recently used order and advanced mode", - Stage: FeatureStageGeneralAvailability, - FrontendOnly: true, - Expression: "true", // enabled by default - Owner: grafanaDashboardsSquad, - AllowSelfServe: false, - HideFromAdminPage: true, - Created: time.Date(2023, time.April, 14, 12, 0, 0, 0, time.UTC), }, { Name: "faroDatasourceSelector", @@ -548,15 +456,13 @@ var ( Stage: FeatureStagePublicPreview, FrontendOnly: true, Owner: appO11ySquad, - Created: time.Date(2023, time.May, 4, 12, 0, 0, 0, time.UTC), }, { Name: "enableDatagridEditing", Description: "Enables the edit functionality in the datagrid panel", FrontendOnly: true, Stage: FeatureStagePublicPreview, - Owner: grafanaBiSquad, - Created: time.Date(2023, time.April, 24, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { Name: "extraThemes", @@ -564,7 +470,6 @@ var ( FrontendOnly: true, Stage: FeatureStageExperimental, Owner: grafanaFrontendPlatformSquad, - Created: time.Date(2023, time.May, 10, 12, 0, 0, 0, time.UTC), }, { Name: "lokiPredefinedOperations", @@ -572,7 +477,6 @@ var ( FrontendOnly: true, Stage: FeatureStageExperimental, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.June, 2, 12, 0, 0, 0, time.UTC), }, { Name: "pluginsFrontendSandbox", @@ -580,7 +484,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.June, 5, 12, 0, 0, 0, time.UTC), }, { Name: "dashboardEmbed", @@ -588,7 +491,6 @@ var ( FrontendOnly: true, Stage: FeatureStageExperimental, Owner: grafanaAsCodeSquad, - Created: time.Date(2023, time.July, 6, 12, 0, 0, 0, time.UTC), }, { Name: "frontendSandboxMonitorOnly", @@ -596,16 +498,14 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.July, 5, 12, 0, 0, 0, time.UTC), }, { Name: "sqlDatasourceDatabaseSelection", Description: "Enables previous SQL data source dataset dropdown behavior", FrontendOnly: true, Stage: FeatureStagePublicPreview, - Owner: grafanaBiSquad, + Owner: grafanaDatavizSquad, HideFromAdminPage: true, - Created: time.Date(2023, time.June, 6, 12, 0, 0, 0, time.UTC), }, { Name: "lokiFormatQuery", @@ -613,25 +513,6 @@ var ( FrontendOnly: true, Stage: FeatureStageExperimental, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.June, 21, 12, 0, 0, 0, time.UTC), - }, - { - Name: "cloudWatchLogsMonacoEditor", - Description: "Enables the Monaco editor for CloudWatch Logs queries", - Stage: FeatureStageGeneralAvailability, - FrontendOnly: true, - Expression: "true", // enabled by default - Owner: awsDatasourcesSquad, - AllowSelfServe: true, - Created: time.Date(2023, time.June, 12, 12, 0, 0, 0, time.UTC), - }, - { - Name: "exploreScrollableLogsContainer", - Description: "Improves the scrolling behavior of logs in Explore", - Stage: FeatureStageExperimental, - FrontendOnly: true, - Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.June, 15, 12, 0, 0, 0, time.UTC), }, { Name: "recordedQueriesMulti", @@ -640,7 +521,6 @@ var ( Expression: "true", Owner: grafanaObservabilityMetricsSquad, AllowSelfServe: false, - Created: time.Date(2023, time.June, 14, 12, 0, 0, 0, time.UTC), }, { Name: "pluginsDynamicAngularDetectionPatterns", @@ -648,7 +528,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: false, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.June, 26, 12, 0, 0, 0, time.UTC), }, { Name: "vizAndWidgetSplit", @@ -656,7 +535,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaDashboardsSquad, - Created: time.Date(2023, time.June, 27, 12, 0, 0, 0, time.UTC), }, { Name: "prometheusIncrementalQueryInstrumentation", @@ -664,22 +542,20 @@ var ( FrontendOnly: true, Stage: FeatureStageExperimental, Owner: grafanaObservabilityMetricsSquad, - Created: time.Date(2023, time.July, 5, 12, 0, 0, 0, time.UTC), }, { Name: "logsExploreTableVisualisation", Description: "A table visualisation for logs in Explore", - Stage: FeatureStageExperimental, + Stage: FeatureStageGeneralAvailability, + Expression: "true", // enabled by default, FrontendOnly: true, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.July, 12, 12, 0, 0, 0, time.UTC), }, { Name: "awsDatasourcesTempCredentials", Description: "Support temporary security credentials in AWS plugins for Grafana Cloud customers", Stage: FeatureStageExperimental, Owner: awsDatasourcesSquad, - Created: time.Date(2023, time.July, 6, 12, 0, 0, 0, time.UTC), }, { Name: "transformationsRedesign", @@ -689,7 +565,6 @@ var ( Expression: "true", // enabled by default Owner: grafanaObservabilityMetricsSquad, AllowSelfServe: true, - Created: time.Date(2023, time.July, 12, 12, 0, 0, 0, time.UTC), }, { Name: "mlExpressions", @@ -697,15 +572,13 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: false, Owner: grafanaAlertingSquad, - Created: time.Date(2023, time.July, 13, 12, 0, 0, 0, time.UTC), }, { Name: "traceQLStreaming", Description: "Enables response streaming of TraceQL queries of the Tempo data source", - Stage: FeatureStageExperimental, + Stage: FeatureStageGeneralAvailability, FrontendOnly: true, Owner: grafanaObservabilityTracesAndProfilingSquad, - Created: time.Date(2023, time.July, 26, 12, 0, 0, 0, time.UTC), }, { Name: "metricsSummary", @@ -713,16 +586,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaObservabilityTracesAndProfilingSquad, - Created: time.Date(2023, time.August, 28, 12, 0, 0, 0, time.UTC), - }, - { - Name: "grafanaAPIServer", - Description: "Enable Kubernetes API Server for Grafana resources", - Stage: FeatureStageGeneralAvailability, - Expression: "true", // enabled by default - RequiresRestart: true, - Owner: grafanaAppPlatformSquad, - Created: time.Date(2023, time.July, 14, 12, 0, 0, 0, time.UTC), }, { Name: "grafanaAPIServerWithExperimentalAPIs", @@ -731,7 +594,6 @@ var ( RequiresRestart: true, RequiresDevMode: true, Owner: grafanaAppPlatformSquad, - Created: time.Date(2023, time.October, 6, 12, 0, 0, 0, time.UTC), }, { Name: "grafanaAPIServerEnsureKubectlAccess", @@ -740,7 +602,6 @@ var ( RequiresDevMode: true, RequiresRestart: true, Owner: grafanaAppPlatformSquad, - Created: time.Date(2023, time.December, 6, 12, 0, 0, 0, time.UTC), }, { Name: "featureToggleAdminPage", @@ -749,48 +610,19 @@ var ( FrontendOnly: false, Owner: grafanaOperatorExperienceSquad, RequiresRestart: true, - Created: time.Date(2023, time.July, 18, 12, 0, 0, 0, time.UTC), }, { Name: "awsAsyncQueryCaching", - Description: "Enable caching for async queries for Redshift and Athena. Requires that the `useCachingService` feature toggle is enabled and the datasource has caching and async query support enabled", - Stage: FeatureStagePublicPreview, + Description: "Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled", + Stage: FeatureStageGeneralAvailability, + Expression: "true", // enabled by default Owner: awsDatasourcesSquad, - Created: time.Date(2023, time.July, 21, 12, 0, 0, 0, time.UTC), - }, - { - Name: "splitScopes", - Description: "Support faster dashboard and folder search by splitting permission scopes into parts", - Stage: FeatureStageGeneralAvailability, - FrontendOnly: false, - Expression: "true", // enabled by default - Owner: identityAccessTeam, - RequiresRestart: true, - HideFromAdminPage: true, // This is internal work to speed up dashboard search, and is not ready for wider use - Created: time.Date(2023, time.July, 21, 12, 0, 0, 0, time.UTC), - }, - { - Name: "traceToProfiles", - Description: "Enables linking between traces and profiles", - Stage: FeatureStageExperimental, - FrontendOnly: true, - Owner: grafanaObservabilityTracesAndProfilingSquad, - Created: time.Date(2023, time.November, 1, 12, 0, 0, 0, time.UTC), - }, - { - Name: "tracesEmbeddedFlameGraph", - Description: "Enables embedding a flame graph in traces", - Stage: FeatureStageExperimental, - FrontendOnly: true, - Owner: grafanaObservabilityTracesAndProfilingSquad, - Created: time.Date(2023, time.November, 2, 12, 0, 0, 0, time.UTC), }, { Name: "permissionsFilterRemoveSubquery", Description: "Alternative permission filter implementation that does not use subqueries for fetching the dashboard folder", Stage: FeatureStageExperimental, Owner: grafanaBackendPlatformSquad, - Created: time.Date(2023, time.August, 2, 12, 0, 0, 0, time.UTC), }, { Name: "prometheusConfigOverhaulAuth", @@ -799,7 +631,6 @@ var ( Stage: FeatureStageGeneralAvailability, Expression: "true", // on by default AllowSelfServe: false, - Created: time.Date(2023, time.July, 21, 12, 0, 0, 0, time.UTC), }, { Name: "configurableSchedulerTick", @@ -809,7 +640,6 @@ var ( Owner: grafanaAlertingSquad, RequiresRestart: true, HideFromDocs: true, - Created: time.Date(2023, time.July, 26, 12, 0, 0, 0, time.UTC), }, { Name: "influxdbSqlSupport", @@ -820,25 +650,23 @@ var ( RequiresRestart: true, AllowSelfServe: true, Expression: "true", // enabled by default - Created: time.Date(2023, time.August, 2, 12, 0, 0, 0, time.UTC), }, { Name: "alertingNoDataErrorExecution", Description: "Changes how Alerting state manager handles execution of NoData/Error", - Stage: FeatureStagePrivatePreview, + Stage: FeatureStageGeneralAvailability, FrontendOnly: false, Owner: grafanaAlertingSquad, RequiresRestart: true, - Enabled: true, - Created: time.Date(2023, time.August, 15, 12, 0, 0, 0, time.UTC), + Expression: "true", // enabled by default }, { Name: "angularDeprecationUI", - Description: "Display new Angular deprecation-related UI features", - Stage: FeatureStageExperimental, + Description: "Display Angular warnings in dashboards and panels", + Stage: FeatureStageGeneralAvailability, FrontendOnly: true, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.August, 29, 12, 0, 0, 0, time.UTC), + Expression: "true", // Enabled by default }, { Name: "dashgpt", @@ -846,7 +674,13 @@ var ( Stage: FeatureStagePublicPreview, FrontendOnly: true, Owner: grafanaDashboardsSquad, - Created: time.Date(2023, time.November, 17, 12, 0, 0, 0, time.UTC), + }, + { + Name: "aiGeneratedDashboardChanges", + Description: "Enable AI powered features for dashboards to auto-summary changes when saving", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDashboardsSquad, }, { Name: "reportingRetries", @@ -855,22 +689,12 @@ var ( FrontendOnly: false, Owner: grafanaSharingSquad, RequiresRestart: true, - Created: time.Date(2023, time.August, 31, 12, 0, 0, 0, time.UTC), }, { Name: "sseGroupByDatasource", - Description: "Send query to the same datasource in a single request when using server side expressions", + Description: "Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch.", Stage: FeatureStageExperimental, Owner: grafanaObservabilityMetricsSquad, - Created: time.Date(2023, time.September, 7, 12, 0, 0, 0, time.UTC), - }, - { - Name: "requestInstrumentationStatusSource", - Description: "Include a status source label for request metrics and logs", - Stage: FeatureStageExperimental, - FrontendOnly: false, - Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.September, 11, 12, 0, 0, 0, time.UTC), }, { Name: "libraryPanelRBAC", @@ -879,7 +703,6 @@ var ( FrontendOnly: false, Owner: grafanaDashboardsSquad, RequiresRestart: true, - Created: time.Date(2023, time.October, 11, 12, 0, 0, 0, time.UTC), }, { Name: "lokiRunQueriesInParallel", @@ -887,7 +710,6 @@ var ( Stage: FeatureStagePrivatePreview, FrontendOnly: false, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.September, 19, 12, 0, 0, 0, time.UTC), }, { Name: "wargamesTesting", @@ -895,7 +717,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: false, Owner: hostedGrafanaTeam, - Created: time.Date(2023, time.September, 13, 12, 0, 0, 0, time.UTC), }, { Name: "alertingInsights", @@ -906,14 +727,12 @@ var ( Expression: "true", // enabled by default AllowSelfServe: false, HideFromAdminPage: true, // This is moving away from being a feature toggle. - Created: time.Date(2023, time.September, 14, 12, 0, 0, 0, time.UTC), }, { Name: "externalCorePlugins", Description: "Allow core plugins to be loaded as external", Stage: FeatureStageExperimental, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.September, 22, 12, 0, 0, 0, time.UTC), }, { Name: "pluginsAPIMetrics", @@ -921,47 +740,27 @@ var ( FrontendOnly: true, Stage: FeatureStageExperimental, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.September, 21, 12, 0, 0, 0, time.UTC), - }, - { - Name: "httpSLOLevels", - Description: "Adds SLO level to http request metrics", - Stage: FeatureStageExperimental, - FrontendOnly: false, - Owner: hostedGrafanaTeam, - RequiresRestart: true, - Created: time.Date(2023, time.September, 22, 12, 0, 0, 0, time.UTC), }, { Name: "idForwarding", Description: "Generate signed id token for identity that can be forwarded to plugins and external services", Stage: FeatureStageExperimental, Owner: identityAccessTeam, - Created: time.Date(2023, time.September, 25, 12, 0, 0, 0, time.UTC), }, { - Name: "cloudWatchWildCardDimensionValues", - Description: "Fetches dimension values from CloudWatch to correctly label wildcard dimensions", - Stage: FeatureStageGeneralAvailability, - Expression: "true", // enabled by default - Owner: awsDatasourcesSquad, - AllowSelfServe: true, - Created: time.Date(2023, time.September, 27, 12, 0, 0, 0, time.UTC), - }, - { - Name: "externalServiceAccounts", - Description: "Automatic service account and token setup for plugins", - Stage: FeatureStagePrivatePreview, - Owner: identityAccessTeam, - Created: time.Date(2023, time.September, 28, 12, 0, 0, 0, time.UTC), + Name: "externalServiceAccounts", + Description: "Automatic service account and token setup for plugins", + HideFromAdminPage: true, + Stage: FeatureStagePublicPreview, + Owner: identityAccessTeam, }, { Name: "panelMonitoring", Description: "Enables panel monitoring through logs and measurements", - Stage: FeatureStageExperimental, + Stage: FeatureStageGeneralAvailability, + Expression: "true", // enabled by default Owner: grafanaDatavizSquad, FrontendOnly: true, - Created: time.Date(2023, time.October, 8, 12, 0, 0, 0, time.UTC), }, { Name: "enableNativeHTTPHistogram", @@ -969,23 +768,20 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: false, Owner: hostedGrafanaTeam, - Created: time.Date(2023, time.October, 3, 12, 0, 0, 0, time.UTC), }, { Name: "formatString", Description: "Enable format string transformer", - Stage: FeatureStagePrivatePreview, + Stage: FeatureStagePublicPreview, FrontendOnly: true, - Owner: grafanaBiSquad, - Created: time.Date(2023, time.October, 13, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { Name: "transformationsVariableSupport", Description: "Allows using variables in transformations", FrontendOnly: true, Stage: FeatureStagePublicPreview, - Owner: grafanaBiSquad, - Created: time.Date(2023, time.October, 4, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { Name: "kubernetesPlaylists", @@ -993,55 +789,58 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaAppPlatformSquad, RequiresRestart: true, // changes the API routing - Created: time.Date(2023, time.November, 8, 12, 0, 0, 0, time.UTC), }, { Name: "kubernetesSnapshots", - Description: "Use the kubernetes API in the frontend to support playlists", + Description: "Routes snapshot requests from /api to the /apis endpoint", + Stage: FeatureStageExperimental, + Owner: grafanaAppPlatformSquad, + RequiresRestart: true, // changes the API routing + }, + { + Name: "kubernetesQueryServiceRewrite", + Description: "Rewrite requests targeting /ds/query to the query service", Stage: FeatureStageExperimental, Owner: grafanaAppPlatformSquad, RequiresRestart: true, // changes the API routing - Created: time.Date(2023, time.December, 4, 12, 0, 0, 0, time.UTC), + RequiresDevMode: true, }, { Name: "cloudWatchBatchQueries", Description: "Runs CloudWatch metrics queries as separate batches", Stage: FeatureStagePublicPreview, Owner: awsDatasourcesSquad, - Created: time.Date(2023, time.October, 20, 12, 0, 0, 0, time.UTC), }, { Name: "recoveryThreshold", Description: "Enables feature recovery threshold (aka hysteresis) for threshold server-side expression", - Stage: FeatureStageExperimental, + Stage: FeatureStageGeneralAvailability, FrontendOnly: false, Owner: grafanaAlertingSquad, RequiresRestart: true, - Created: time.Date(2023, time.October, 10, 12, 0, 0, 0, time.UTC), + Expression: "true", }, { Name: "lokiStructuredMetadata", Description: "Enables the loki data source to request structured metadata from the Loki server", - Stage: FeatureStageExperimental, + Stage: FeatureStageGeneralAvailability, FrontendOnly: false, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.November, 16, 12, 0, 0, 0, time.UTC), + Expression: "true", }, { Name: "teamHttpHeaders", - Description: "Enables datasources to apply team headers to the client requests", - Stage: FeatureStageExperimental, + Description: "Enables Team LBAC for datasources to apply team headers to the client requests", + Stage: FeatureStagePublicPreview, FrontendOnly: false, Owner: identityAccessTeam, - Created: time.Date(2023, time.October, 17, 12, 0, 0, 0, time.UTC), }, { Name: "awsDatasourcesNewFormStyling", Description: "Applies new form styling for configuration and query editors in AWS plugins", - Stage: FeatureStageExperimental, + Stage: FeatureStagePublicPreview, FrontendOnly: true, Owner: awsDatasourcesSquad, - Created: time.Date(2023, time.October, 12, 12, 0, 0, 0, time.UTC), }, { Name: "cachingOptimizeSerializationMemoryUsage", @@ -1049,7 +848,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaOperatorExperienceSquad, FrontendOnly: false, - Created: time.Date(2023, time.October, 12, 12, 0, 0, 0, time.UTC), }, { Name: "panelTitleSearchInV1", @@ -1057,31 +855,13 @@ var ( RequiresDevMode: true, Stage: FeatureStageExperimental, Owner: grafanaBackendPlatformSquad, - Created: time.Date(2023, time.October, 13, 12, 0, 0, 0, time.UTC), - }, - { - Name: "pluginsInstrumentationStatusSource", - Description: "Include a status source label for plugin request metrics and logs", - FrontendOnly: false, - Stage: FeatureStageExperimental, - Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.October, 17, 12, 0, 0, 0, time.UTC), - }, - { - Name: "costManagementUi", - Description: "Toggles the display of the cost management ui plugin", - Stage: FeatureStageExperimental, - FrontendOnly: false, - Owner: grafanaDatabasesFrontend, - Created: time.Date(2023, time.October, 17, 12, 0, 0, 0, time.UTC), }, { Name: "managedPluginsInstall", Description: "Install managed plugins directly from plugins catalog", - Stage: FeatureStageExperimental, + Stage: FeatureStagePublicPreview, RequiresDevMode: false, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.October, 18, 12, 0, 0, 0, time.UTC), }, { Name: "prometheusPromQAIL", @@ -1089,36 +869,31 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaObservabilityMetricsSquad, - Created: time.Date(2023, time.October, 19, 12, 0, 0, 0, time.UTC), }, { Name: "addFieldFromCalculationStatFunctions", Description: "Add cumulative and window functions to the add field from calculation transformation", - Stage: FeatureStagePrivatePreview, + Stage: FeatureStagePublicPreview, FrontendOnly: true, - Owner: grafanaBiSquad, - Created: time.Date(2023, time.November, 3, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { Name: "alertmanagerRemoteSecondary", Description: "Enable Grafana to sync configuration and state with a remote Alertmanager.", Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, - Created: time.Date(2023, time.October, 30, 12, 0, 0, 0, time.UTC), }, { Name: "alertmanagerRemotePrimary", Description: "Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.", Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, - Created: time.Date(2023, time.October, 30, 12, 0, 0, 0, time.UTC), }, { Name: "alertmanagerRemoteOnly", Description: "Disable the internal Alertmanager and only use the external one defined.", Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, - Created: time.Date(2023, time.October, 30, 12, 0, 0, 0, time.UTC), }, { Name: "annotationPermissionUpdate", @@ -1126,15 +901,13 @@ var ( Stage: FeatureStageExperimental, RequiresDevMode: false, Owner: identityAccessTeam, - Created: time.Date(2023, time.October, 31, 12, 0, 0, 0, time.UTC), }, { Name: "extractFieldsNameDeduplication", Description: "Make sure extracted field names are unique in the dataframe", Stage: FeatureStageExperimental, FrontendOnly: true, - Owner: grafanaBiSquad, - Created: time.Date(2023, time.November, 2, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { Name: "dashboardSceneForViewers", @@ -1142,7 +915,13 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaDashboardsSquad, - Created: time.Date(2023, time.November, 2, 12, 0, 0, 0, time.UTC), + }, + { + Name: "dashboardSceneSolo", + Description: "Enables rendering dashboards using scenes for solo panels", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDashboardsSquad, }, { Name: "dashboardScene", @@ -1150,7 +929,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaDashboardsSquad, - Created: time.Date(2023, time.November, 13, 12, 0, 0, 0, time.UTC), }, { Name: "panelFilterVariable", @@ -1159,32 +937,28 @@ var ( FrontendOnly: true, Owner: grafanaDashboardsSquad, HideFromDocs: true, - Created: time.Date(2023, time.November, 3, 12, 0, 0, 0, time.UTC), }, { Name: "pdfTables", Description: "Enables generating table data as PDF in reporting", - Stage: FeatureStagePrivatePreview, + Stage: FeatureStagePublicPreview, FrontendOnly: false, Owner: grafanaSharingSquad, - Created: time.Date(2023, time.November, 6, 12, 0, 0, 0, time.UTC), }, { - Name: "ssoSettingsApi", - Description: "Enables the SSO settings API", - RequiresDevMode: true, - Stage: FeatureStageExperimental, - FrontendOnly: false, - Owner: identityAccessTeam, - Created: time.Date(2023, time.November, 8, 12, 0, 0, 0, time.UTC), + Name: "ssoSettingsApi", + Description: "Enables the SSO settings API and the OAuth configuration UIs in Grafana", + Stage: FeatureStagePublicPreview, + AllowSelfServe: true, + FrontendOnly: false, + Owner: identityAccessTeam, }, { Name: "canvasPanelPanZoom", Description: "Allow pan and zoom in canvas panel", - Stage: FeatureStageExperimental, + Stage: FeatureStagePublicPreview, FrontendOnly: true, Owner: grafanaDatavizSquad, - Created: time.Date(2023, time.December, 27, 12, 0, 0, 0, time.UTC), }, { Name: "logsInfiniteScrolling", @@ -1192,7 +966,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.November, 9, 12, 0, 0, 0, time.UTC), }, { Name: "flameGraphItemCollapsing", @@ -1200,16 +973,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaObservabilityTracesAndProfilingSquad, - Created: time.Date(2023, time.November, 9, 12, 0, 0, 0, time.UTC), - }, - { - Name: "alertingDetailsViewV2", - Description: "Enables the preview of the new alert details view", - Stage: FeatureStageExperimental, - FrontendOnly: true, - Owner: grafanaAlertingSquad, - HideFromDocs: true, - Created: time.Date(2023, time.November, 9, 12, 0, 0, 0, time.UTC), }, { Name: "datatrails", @@ -1218,24 +981,21 @@ var ( FrontendOnly: true, Owner: grafanaDashboardsSquad, HideFromDocs: true, - Created: time.Date(2023, time.November, 15, 12, 0, 0, 0, time.UTC), }, { Name: "alertingSimplifiedRouting", - Description: "Enables the simplified routing for alerting", - Stage: FeatureStageExperimental, + Description: "Enables users to easily configure alert notifications by specifying a contact point directly when editing or creating an alert rule", + Stage: FeatureStagePublicPreview, FrontendOnly: false, Owner: grafanaAlertingSquad, - HideFromDocs: true, - Created: time.Date(2023, time.November, 10, 12, 0, 0, 0, time.UTC), }, { Name: "logRowsPopoverMenu", Description: "Enable filtering menu displayed when text of a log line is selected", - Stage: FeatureStageExperimental, + Stage: FeatureStageGeneralAvailability, FrontendOnly: true, + Expression: "true", Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.November, 16, 12, 0, 0, 0, time.UTC), }, { Name: "pluginsSkipHostEnvVars", @@ -1243,54 +1003,197 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: false, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.November, 15, 12, 0, 0, 0, time.UTC), }, { Name: "tableSharedCrosshair", Description: "Enables shared crosshair in table panel", FrontendOnly: true, Stage: FeatureStageExperimental, - Owner: grafanaBiSquad, - Created: time.Date(2023, time.December, 12, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { Name: "regressionTransformation", Description: "Enables regression analysis transformation", - Stage: FeatureStagePrivatePreview, + Stage: FeatureStagePublicPreview, FrontendOnly: true, - Owner: grafanaBiSquad, - Created: time.Date(2023, time.November, 24, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { - Name: "displayAnonymousStats", - Description: "Enables anonymous stats to be shown in the UI for Grafana", + // this is mainly used a a way to quickly disable query hints as a safe guard for our infrastructure + Name: "lokiQueryHints", + Description: "Enables query hints for Loki", Stage: FeatureStageGeneralAvailability, FrontendOnly: true, - Owner: identityAccessTeam, - Created: time.Date(2023, time.November, 29, 12, 0, 0, 0, time.UTC), + Expression: "true", + Owner: grafanaObservabilityLogsSquad, AllowSelfServe: false, - Expression: "true", // enabled by default }, { - Name: "alertStateHistoryAnnotationsFromLoki", - Description: "Enable using Loki as the source for panel annotations generated by alert rules", + Name: "kubernetesFeatureToggles", + Description: "Use the kubernetes API for feature toggle management in the frontend", Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, + FrontendOnly: true, + Owner: grafanaOperatorExperienceSquad, + AllowSelfServe: false, HideFromAdminPage: true, - HideFromDocs: true, - RequiresRestart: true, - Created: time.Date(2023, time.November, 30, 12, 0, 0, 0, time.UTC), }, { - // this is mainly used a a way to quickly disable query hints as a safe guard for our infrastructure - Name: "lokiQueryHints", - Description: "Enables query hints for Loki", + Name: "enablePluginsTracingByDefault", + Description: "Enable plugin tracing for all external plugins", + FrontendOnly: false, + Stage: FeatureStageExperimental, + Owner: grafanaPluginsPlatformSquad, + RequiresRestart: true, + }, + { + Name: "cloudRBACRoles", + Description: "Enabled grafana cloud specific RBAC roles", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + RequiresRestart: true, + }, + { + Name: "alertingQueryOptimization", + Description: "Optimizes eligible queries in order to reduce load on datasources", Stage: FeatureStageGeneralAvailability, - FrontendOnly: true, - Expression: "true", - Owner: grafanaObservabilityLogsSquad, + Owner: grafanaAlertingSquad, AllowSelfServe: false, - Created: time.Date(2023, time.December, 18, 12, 0, 0, 0, time.UTC), + }, + { + Name: "newFolderPicker", + Description: "Enables the nested folder picker without having nested folders enabled", + Stage: FeatureStageExperimental, + Owner: grafanaFrontendPlatformSquad, + FrontendOnly: true, + }, + { + Name: "jitterAlertRulesWithinGroups", + Description: "Distributes alert rule evaluations more evenly over time, including spreading out rules within the same group", + FrontendOnly: false, + Stage: FeatureStagePublicPreview, + Owner: grafanaAlertingSquad, + AllowSelfServe: false, + HideFromDocs: true, + HideFromAdminPage: false, + RequiresRestart: true, + }, + { + Name: "onPremToCloudMigrations", + Description: "In-development feature that will allow users to easily migrate their on-prem Grafana instances to Grafana Cloud.", + Stage: FeatureStageExperimental, + Owner: grafanaOperatorExperienceSquad, + }, + { + Name: "alertingSaveStatePeriodic", + Description: "Writes the state periodically to the database, asynchronous to rule evaluation", + Stage: FeatureStagePrivatePreview, + FrontendOnly: false, + Owner: grafanaAlertingSquad, + }, + { + Name: "promQLScope", + Description: "In-development feature that will allow injection of labels into prometheus queries.", + Stage: FeatureStageExperimental, + Owner: grafanaObservabilityMetricsSquad, + }, + { + Name: "sqlExpressions", + Description: "Enables using SQL and DuckDB functions as Expressions.", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: grafanaAppPlatformSquad, + }, + { + Name: "nodeGraphDotLayout", + Description: "Changed the layout algorithm for the node graph", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaObservabilityTracesAndProfilingSquad, + }, + { + Name: "groupToNestedTableTransformation", + Description: "Enables the group to nested table transformation", + Stage: FeatureStagePublicPreview, + FrontendOnly: true, + Owner: grafanaDatavizSquad, + }, + { + Name: "newPDFRendering", + Description: "New implementation for the dashboard to PDF rendering", + Stage: FeatureStageExperimental, + Owner: grafanaSharingSquad, + }, + { + Name: "kubernetesAggregator", + Description: "Enable grafana aggregator", + Stage: FeatureStageExperimental, + Owner: grafanaAppPlatformSquad, + RequiresRestart: true, + }, + { + Name: "expressionParser", + Description: "Enable new expression parser", + Stage: FeatureStageExperimental, + Owner: grafanaAppPlatformSquad, + RequiresRestart: true, + }, + { + Name: "groupByVariable", + Description: "Enable groupBy variable support in scenes dashboards", + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + AllowSelfServe: false, + HideFromDocs: true, + HideFromAdminPage: true, + }, + { + Name: "betterPageScrolling", + Description: "Removes CustomScrollbar from the UI, relying on native browser scrollbars", + Stage: FeatureStageGeneralAvailability, + FrontendOnly: true, + Owner: grafanaFrontendPlatformSquad, + Expression: "true", // enabled by default + }, + { + Name: "scopeFilters", + Description: "Enables the use of scope filters in Grafana", + FrontendOnly: false, + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + RequiresRestart: false, + AllowSelfServe: false, + HideFromDocs: true, + HideFromAdminPage: true, + }, + { + Name: "emailVerificationEnforcement", + Description: "Force email verification for users, even when authenticating through sso.", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + HideFromAdminPage: true, + }, + { + Name: "ssoSettingsSAML", + Description: "Use the new SSO Settings API to configure the SAML connector", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + HideFromAdminPage: true, }, } ) + +//go:embed toggles_gen.json +var f embed.FS + +// Get the cached feature list (exposed as a k8s resource) +func GetEmbeddedFeatureList() (featuretoggle.FeatureList, error) { + features := featuretoggle.FeatureList{} + body, err := f.ReadFile("toggles_gen.json") + if err == nil { + err = json.Unmarshal(body, &features) + } + return features, err +} diff --git a/pkg/services/featuremgmt/service.go b/pkg/services/featuremgmt/service.go index 88e93fda851dc..137b8e174b4af 100644 --- a/pkg/services/featuremgmt/service.go +++ b/pkg/services/featuremgmt/service.go @@ -1,15 +1,10 @@ package featuremgmt import ( - "fmt" - "os" - "path/filepath" - "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/setting" ) @@ -22,14 +17,15 @@ var ( }, []string{"name"}) ) -func ProvideManagerService(cfg *setting.Cfg, licensing licensing.Licensing) (*FeatureManager, error) { +func ProvideManagerService(cfg *setting.Cfg) (*FeatureManager, error) { mgmt := &FeatureManager{ - isDevMod: setting.Env != setting.Prod, - licensing: licensing, - flags: make(map[string]*FeatureFlag, 30), - enabled: make(map[string]bool), - allowEditing: cfg.FeatureManagement.AllowEditing && cfg.FeatureManagement.UpdateWebhook != "", - log: log.New("featuremgmt"), + isDevMod: cfg.Env != setting.Prod, + flags: make(map[string]*FeatureFlag, 30), + enabled: make(map[string]bool), + startup: make(map[string]bool), + warnings: make(map[string]string), + Settings: cfg.FeatureManagement, + log: log.New("featuremgmt"), } // Register the standard flags @@ -41,32 +37,21 @@ func ProvideManagerService(cfg *setting.Cfg, licensing licensing.Licensing) (*Fe return mgmt, err } for key, val := range flags { - flag, ok := mgmt.flags[key] + _, ok := mgmt.flags[key] if !ok { switch key { // renamed the flag so it supports more panels case "autoMigrateGraphPanels": - flag = mgmt.flags[FlagAutoMigrateOldPanels] + key = FlagAutoMigrateOldPanels default: - flag = &FeatureFlag{ + mgmt.flags[key] = &FeatureFlag{ Name: key, Stage: FeatureStageUnknown, } - mgmt.flags[key] = flag + mgmt.warnings[key] = "unknown flag in config" } } - flag.Expression = fmt.Sprintf("%t", val) // true | false - } - - // Load config settings - configfile := filepath.Join(cfg.HomePath, "conf", "features.yaml") - if _, err := os.Stat(configfile); err == nil { - mgmt.log.Info("[experimental] loading features from config file", "path", configfile) - mgmt.config = configfile - err = mgmt.readFile() - if err != nil { - return mgmt, err - } + mgmt.startup[key] = val } // update the values diff --git a/pkg/services/featuremgmt/service_test.go b/pkg/services/featuremgmt/service_test.go index b69753fa362b2..49d85e98e0680 100644 --- a/pkg/services/featuremgmt/service_test.go +++ b/pkg/services/featuremgmt/service_test.go @@ -5,40 +5,12 @@ import ( "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/setting" ) func TestFeatureService(t *testing.T) { - license := stubLicenseServier{ - flags: []FeatureFlag{ - { - Name: "a.yes.default", - RequiresLicense: true, - Expression: "true", - }, - { - Name: "a.yes", - RequiresLicense: true, - Expression: "", - }, - { - Name: "b.no", - RequiresLicense: true, - }, - }, - enabled: map[string]bool{ - "a.yes.default": true, - "a.yes": true, - }, - } - require.False(t, license.FeatureEnabled("unknown")) - require.False(t, license.FeatureEnabled("b.no")) - require.True(t, license.FeatureEnabled("a.yes")) - require.True(t, license.FeatureEnabled("a.yes.default")) - cfg := setting.NewCfg() - mgmt, err := ProvideManagerService(cfg, license) + mgmt, err := ProvideManagerService(cfg) require.NoError(t, err) require.NotNil(t, mgmt) @@ -46,40 +18,3 @@ func TestFeatureService(t *testing.T) { require.False(t, mgmt.IsEnabledGlobally("a.yes.default")) require.False(t, mgmt.IsEnabledGlobally("a.yes")) // licensed, but not enabled } - -var ( - _ licensing.Licensing = (*stubLicenseServier)(nil) -) - -type stubLicenseServier struct { - flags []FeatureFlag - enabled map[string]bool -} - -func (s stubLicenseServier) Expiry() int64 { - return 100 -} - -func (s stubLicenseServier) Edition() string { - return "test" -} - -func (s stubLicenseServier) ContentDeliveryPrefix() string { - return "" -} - -func (s stubLicenseServier) LicenseURL(showAdminLicensingPage bool) string { - return "http://??" -} - -func (s stubLicenseServier) StateInfo() string { - return "ok" -} - -func (s stubLicenseServier) EnabledFeatures() map[string]bool { - return map[string]bool{} -} - -func (s stubLicenseServier) FeatureEnabled(feature string) bool { - return s.enabled[feature] -} diff --git a/pkg/services/featuremgmt/settings.go b/pkg/services/featuremgmt/settings.go deleted file mode 100644 index 3c33979e2d7f1..0000000000000 --- a/pkg/services/featuremgmt/settings.go +++ /dev/null @@ -1,34 +0,0 @@ -package featuremgmt - -import ( - "os" - - "gopkg.in/yaml.v3" -) - -type configBody struct { - // define variables that can be used in expressions - Vars map[string]any `yaml:"vars"` - - // Define and override feature flag properties - Flags []FeatureFlag `yaml:"flags"` - - // keep track of where the fie was loaded from - filename string -} - -// will read a single configfile -func readConfigFile(filename string) (*configBody, error) { - cfg := &configBody{} - - // Can ignore gosec G304 because the file path is forced within config subfolder - //nolint:gosec - yamlFile, err := os.ReadFile(filename) - if err != nil { - return cfg, err - } - - err = yaml.Unmarshal(yamlFile, cfg) - cfg.filename = filename - return cfg, err -} diff --git a/pkg/services/featuremgmt/settings_test.go b/pkg/services/featuremgmt/settings_test.go deleted file mode 100644 index 7b8e6c83b42ff..0000000000000 --- a/pkg/services/featuremgmt/settings_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package featuremgmt - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" -) - -func TestReadingFeatureSettings(t *testing.T) { - config, err := readConfigFile("testdata/features.yaml") - require.NoError(t, err, "No error when reading feature configs") - - assert.Equal(t, map[string]any{ - "level": "free", - "stack": "something", - "valA": "value from features.yaml", - }, config.Vars) - - out, err := yaml.Marshal(config) - require.NoError(t, err) - fmt.Printf("%s", string(out)) -} diff --git a/pkg/services/featuremgmt/testdata/features.yaml b/pkg/services/featuremgmt/testdata/features.yaml deleted file mode 100644 index dd73749458047..0000000000000 --- a/pkg/services/featuremgmt/testdata/features.yaml +++ /dev/null @@ -1,33 +0,0 @@ -include: - - included.yaml # not yet supported - -vars: - stack: something - level: free - valA: value from features.yaml - -flags: - - name: feature1 - description: feature1 - expression: "false" - - - name: feature3 - description: feature3 - expression: "true" - - - name: feature3 - description: feature3 - expression: env.level == 'free' - - - name: displaySwedishTheme - description: enable swedish background theme - expression: | - // restrict to users allowing swedish language - req.locale.contains("sv") - - name: displayFrenchFlag - description: sho background theme - expression: | - // only admins - user.id == 1 - // show to users allowing french language - && req.locale.contains("fr") \ No newline at end of file diff --git a/pkg/services/featuremgmt/testdata/included.yaml b/pkg/services/featuremgmt/testdata/included.yaml deleted file mode 100644 index 322b5c7972f1c..0000000000000 --- a/pkg/services/featuremgmt/testdata/included.yaml +++ /dev/null @@ -1,13 +0,0 @@ -include: - - features.yaml # make sure we avoid recusion! - -# variables that can be used in expressions -vars: - stack: something - deep: 1 - valA: value from included.yaml - -flags: - - name: featureFromIncludedFile - description: an inlcuded file - expression: invalid expression string here diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index b803c9d5e309c..6954a9c389113 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -1,154 +1,160 @@ -Name,Stage,Owner,Created,requiresDevMode,RequiresLicense,RequiresRestart,FrontendOnly -disableEnvelopeEncryption,GA,@grafana/grafana-as-code,2022-05-24,false,false,false,false -live-service-web-worker,experimental,@grafana/grafana-app-platform-squad,2021-11-09,false,false,false,true -queryOverLive,experimental,@grafana/grafana-app-platform-squad,2022-01-05,false,false,false,true -panelTitleSearch,preview,@grafana/grafana-app-platform-squad,2022-02-15,false,false,false,false -publicDashboards,GA,@grafana/sharing-squad,2022-04-07,false,false,false,false -publicDashboardsEmailSharing,preview,@grafana/sharing-squad,2022-12-21,false,true,false,false -lokiExperimentalStreaming,experimental,@grafana/observability-logs,2023-06-19,false,false,false,false -featureHighlights,GA,@grafana/grafana-as-code,2022-02-03,false,false,false,false -migrationLocking,preview,@grafana/backend-platform,2022-02-15,false,false,false,false -storage,experimental,@grafana/grafana-app-platform-squad,2022-03-17,false,false,false,false -correlations,preview,@grafana/explore-squad,2022-09-16,false,false,false,false -exploreContentOutline,GA,@grafana/explore-squad,2023-11-03,false,false,false,true -datasourceQueryMultiStatus,experimental,@grafana/plugins-platform-backend,2022-05-03,false,false,false,false -traceToMetrics,experimental,@grafana/observability-traces-and-profiling,2022-03-07,false,false,false,true -autoMigrateOldPanels,preview,@grafana/dataviz-squad,2022-06-11,false,false,false,true -disableAngular,preview,@grafana/dataviz-squad,2023-03-23,false,false,false,true -canvasPanelNesting,experimental,@grafana/dataviz-squad,2022-05-31,false,false,false,true -newVizTooltips,GA,@grafana/dataviz-squad,2023-11-03,false,false,false,true -scenes,experimental,@grafana/dashboards-squad,2022-07-07,false,false,false,true -disableSecretsCompatibility,experimental,@grafana/hosted-grafana-team,2022-07-13,false,false,true,false -logRequestsInstrumentedAsUnknown,experimental,@grafana/hosted-grafana-team,2022-06-10,false,false,false,false -dataConnectionsConsole,GA,@grafana/plugins-platform-backend,2022-06-01,false,false,false,false -topnav,deprecated,@grafana/grafana-frontend-platform,2022-06-20,false,false,false,false -dockedMegaMenu,experimental,@grafana/grafana-frontend-platform,2023-09-18,false,false,false,true -grpcServer,preview,@grafana/grafana-app-platform-squad,2022-09-27,false,false,false,false -unifiedStorage,experimental,@grafana/grafana-app-platform-squad,2022-12-01,true,false,true,false -cloudWatchCrossAccountQuerying,GA,@grafana/aws-datasources,2022-11-28,false,false,false,false -redshiftAsyncQueryDataSupport,GA,@grafana/aws-datasources,2022-08-27,false,false,false,false -athenaAsyncQueryDataSupport,GA,@grafana/aws-datasources,2022-08-27,false,false,false,true -cloudwatchNewRegionsHandler,GA,@grafana/aws-datasources,2023-09-25,false,false,false,false -showDashboardValidationWarnings,experimental,@grafana/dashboards-squad,2022-10-14,false,false,false,false -mysqlAnsiQuotes,experimental,@grafana/backend-platform,2022-10-12,false,false,false,false -accessControlOnCall,preview,@grafana/identity-access-team,2022-10-19,false,false,false,false -nestedFolders,preview,@grafana/backend-platform,2022-10-22,false,false,false,false -nestedFolderPicker,GA,@grafana/grafana-frontend-platform,2023-07-24,false,false,false,true -emptyDashboardPage,GA,@grafana/dashboards-squad,2023-03-28,false,false,false,true -disablePrometheusExemplarSampling,GA,@grafana/observability-metrics,2022-12-19,false,false,false,false -alertingBacktesting,experimental,@grafana/alerting-squad,2022-10-20,false,false,false,false -editPanelCSVDragAndDrop,experimental,@grafana/grafana-bi-squad,2022-12-20,false,false,false,true -alertingNoNormalState,preview,@grafana/alerting-squad,2023-01-14,false,false,false,false -logsContextDatasourceUi,GA,@grafana/observability-logs,2023-01-27,false,false,false,true -lokiQuerySplitting,GA,@grafana/observability-logs,2023-02-09,false,false,false,true -lokiQuerySplittingConfig,experimental,@grafana/observability-logs,2023-03-20,false,false,false,true -individualCookiePreferences,experimental,@grafana/backend-platform,2023-02-23,false,false,false,false -prometheusMetricEncyclopedia,GA,@grafana/observability-metrics,2023-03-07,false,false,false,true -influxdbBackendMigration,GA,@grafana/observability-metrics,2023-03-15,false,false,false,true -influxqlStreamingParser,experimental,@grafana/observability-metrics,2023-11-29,false,false,false,false -clientTokenRotation,GA,@grafana/identity-access-team,2023-03-23,false,false,false,false -prometheusDataplane,GA,@grafana/observability-metrics,2023-03-29,false,false,false,false -lokiMetricDataplane,GA,@grafana/observability-logs,2023-04-13,false,false,false,false -lokiLogsDataplane,experimental,@grafana/observability-logs,2023-07-13,false,false,false,false -dataplaneFrontendFallback,GA,@grafana/observability-metrics,2023-04-24,false,false,false,true -disableSSEDataplane,experimental,@grafana/observability-metrics,2023-04-24,false,false,false,false -alertStateHistoryLokiSecondary,experimental,@grafana/alerting-squad,2023-03-30,false,false,false,false -alertStateHistoryLokiPrimary,experimental,@grafana/alerting-squad,2023-03-30,false,false,false,false -alertStateHistoryLokiOnly,experimental,@grafana/alerting-squad,2023-03-30,false,false,false,false -unifiedRequestLog,experimental,@grafana/backend-platform,2023-03-31,false,false,false,false -renderAuthJWT,preview,@grafana/grafana-as-code,2023-04-03,false,false,false,false -externalServiceAuth,experimental,@grafana/identity-access-team,2023-04-11,true,false,false,false -refactorVariablesTimeRange,preview,@grafana/dashboards-squad,2023-06-06,false,false,false,false -useCachingService,GA,@grafana/grafana-operator-experience-squad,2023-04-12,false,false,true,false -enableElasticsearchBackendQuerying,GA,@grafana/observability-logs,2023-04-14,false,false,false,false -advancedDataSourcePicker,GA,@grafana/dashboards-squad,2023-04-14,false,false,false,true -faroDatasourceSelector,preview,@grafana/app-o11y,2023-05-04,false,false,false,true -enableDatagridEditing,preview,@grafana/grafana-bi-squad,2023-04-24,false,false,false,true -extraThemes,experimental,@grafana/grafana-frontend-platform,2023-05-10,false,false,false,true -lokiPredefinedOperations,experimental,@grafana/observability-logs,2023-06-02,false,false,false,true -pluginsFrontendSandbox,experimental,@grafana/plugins-platform-backend,2023-06-05,false,false,false,true -dashboardEmbed,experimental,@grafana/grafana-as-code,2023-07-06,false,false,false,true -frontendSandboxMonitorOnly,experimental,@grafana/plugins-platform-backend,2023-07-05,false,false,false,true -sqlDatasourceDatabaseSelection,preview,@grafana/grafana-bi-squad,2023-06-06,false,false,false,true -lokiFormatQuery,experimental,@grafana/observability-logs,2023-06-21,false,false,false,true -cloudWatchLogsMonacoEditor,GA,@grafana/aws-datasources,2023-06-12,false,false,false,true -exploreScrollableLogsContainer,experimental,@grafana/observability-logs,2023-06-15,false,false,false,true -recordedQueriesMulti,GA,@grafana/observability-metrics,2023-06-14,false,false,false,false -pluginsDynamicAngularDetectionPatterns,experimental,@grafana/plugins-platform-backend,2023-06-26,false,false,false,false -vizAndWidgetSplit,experimental,@grafana/dashboards-squad,2023-06-27,false,false,false,true -prometheusIncrementalQueryInstrumentation,experimental,@grafana/observability-metrics,2023-07-05,false,false,false,true -logsExploreTableVisualisation,experimental,@grafana/observability-logs,2023-07-12,false,false,false,true -awsDatasourcesTempCredentials,experimental,@grafana/aws-datasources,2023-07-06,false,false,false,false -transformationsRedesign,GA,@grafana/observability-metrics,2023-07-12,false,false,false,true -mlExpressions,experimental,@grafana/alerting-squad,2023-07-13,false,false,false,false -traceQLStreaming,experimental,@grafana/observability-traces-and-profiling,2023-07-26,false,false,false,true -metricsSummary,experimental,@grafana/observability-traces-and-profiling,2023-08-28,false,false,false,true -grafanaAPIServer,GA,@grafana/grafana-app-platform-squad,2023-07-14,false,false,true,false -grafanaAPIServerWithExperimentalAPIs,experimental,@grafana/grafana-app-platform-squad,2023-10-06,true,false,true,false -grafanaAPIServerEnsureKubectlAccess,experimental,@grafana/grafana-app-platform-squad,2023-12-06,true,false,true,false -featureToggleAdminPage,experimental,@grafana/grafana-operator-experience-squad,2023-07-18,false,false,true,false -awsAsyncQueryCaching,preview,@grafana/aws-datasources,2023-07-21,false,false,false,false -splitScopes,GA,@grafana/identity-access-team,2023-07-21,false,false,true,false -traceToProfiles,experimental,@grafana/observability-traces-and-profiling,2023-11-01,false,false,false,true -tracesEmbeddedFlameGraph,experimental,@grafana/observability-traces-and-profiling,2023-11-02,false,false,false,true -permissionsFilterRemoveSubquery,experimental,@grafana/backend-platform,2023-08-02,false,false,false,false -prometheusConfigOverhaulAuth,GA,@grafana/observability-metrics,2023-07-21,false,false,false,false -configurableSchedulerTick,experimental,@grafana/alerting-squad,2023-07-26,false,false,true,false -influxdbSqlSupport,GA,@grafana/observability-metrics,2023-08-02,false,false,true,false -alertingNoDataErrorExecution,privatePreview,@grafana/alerting-squad,2023-08-15,false,false,true,false -angularDeprecationUI,experimental,@grafana/plugins-platform-backend,2023-08-29,false,false,false,true -dashgpt,preview,@grafana/dashboards-squad,2023-11-17,false,false,false,true -reportingRetries,preview,@grafana/sharing-squad,2023-08-31,false,false,true,false -sseGroupByDatasource,experimental,@grafana/observability-metrics,2023-09-07,false,false,false,false -requestInstrumentationStatusSource,experimental,@grafana/plugins-platform-backend,2023-09-11,false,false,false,false -libraryPanelRBAC,experimental,@grafana/dashboards-squad,2023-10-11,false,false,true,false -lokiRunQueriesInParallel,privatePreview,@grafana/observability-logs,2023-09-19,false,false,false,false -wargamesTesting,experimental,@grafana/hosted-grafana-team,2023-09-13,false,false,false,false -alertingInsights,GA,@grafana/alerting-squad,2023-09-14,false,false,false,true -externalCorePlugins,experimental,@grafana/plugins-platform-backend,2023-09-22,false,false,false,false -pluginsAPIMetrics,experimental,@grafana/plugins-platform-backend,2023-09-21,false,false,false,true -httpSLOLevels,experimental,@grafana/hosted-grafana-team,2023-09-22,false,false,true,false -idForwarding,experimental,@grafana/identity-access-team,2023-09-25,false,false,false,false -cloudWatchWildCardDimensionValues,GA,@grafana/aws-datasources,2023-09-27,false,false,false,false -externalServiceAccounts,privatePreview,@grafana/identity-access-team,2023-09-28,false,false,false,false -panelMonitoring,experimental,@grafana/dataviz-squad,2023-10-08,false,false,false,true -enableNativeHTTPHistogram,experimental,@grafana/hosted-grafana-team,2023-10-03,false,false,false,false -formatString,privatePreview,@grafana/grafana-bi-squad,2023-10-13,false,false,false,true -transformationsVariableSupport,preview,@grafana/grafana-bi-squad,2023-10-04,false,false,false,true -kubernetesPlaylists,experimental,@grafana/grafana-app-platform-squad,2023-11-08,false,false,true,false -kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,2023-12-04,false,false,true,false -cloudWatchBatchQueries,preview,@grafana/aws-datasources,2023-10-20,false,false,false,false -recoveryThreshold,experimental,@grafana/alerting-squad,2023-10-10,false,false,true,false -lokiStructuredMetadata,experimental,@grafana/observability-logs,2023-11-16,false,false,false,false -teamHttpHeaders,experimental,@grafana/identity-access-team,2023-10-17,false,false,false,false -awsDatasourcesNewFormStyling,experimental,@grafana/aws-datasources,2023-10-12,false,false,false,true -cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-experience-squad,2023-10-12,false,false,false,false -panelTitleSearchInV1,experimental,@grafana/backend-platform,2023-10-13,true,false,false,false -pluginsInstrumentationStatusSource,experimental,@grafana/plugins-platform-backend,2023-10-17,false,false,false,false -costManagementUi,experimental,@grafana/databases-frontend,2023-10-17,false,false,false,false -managedPluginsInstall,experimental,@grafana/plugins-platform-backend,2023-10-18,false,false,false,false -prometheusPromQAIL,experimental,@grafana/observability-metrics,2023-10-19,false,false,false,true -addFieldFromCalculationStatFunctions,privatePreview,@grafana/grafana-bi-squad,2023-11-03,false,false,false,true -alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,2023-10-30,false,false,false,false -alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,2023-10-30,false,false,false,false -alertmanagerRemoteOnly,experimental,@grafana/alerting-squad,2023-10-30,false,false,false,false -annotationPermissionUpdate,experimental,@grafana/identity-access-team,2023-10-31,false,false,false,false -extractFieldsNameDeduplication,experimental,@grafana/grafana-bi-squad,2023-11-02,false,false,false,true -dashboardSceneForViewers,experimental,@grafana/dashboards-squad,2023-11-02,false,false,false,true -dashboardScene,experimental,@grafana/dashboards-squad,2023-11-13,false,false,false,true -panelFilterVariable,experimental,@grafana/dashboards-squad,2023-11-03,false,false,false,true -pdfTables,privatePreview,@grafana/sharing-squad,2023-11-06,false,false,false,false -ssoSettingsApi,experimental,@grafana/identity-access-team,2023-11-08,true,false,false,false -canvasPanelPanZoom,experimental,@grafana/dataviz-squad,2023-12-27,false,false,false,true -logsInfiniteScrolling,experimental,@grafana/observability-logs,2023-11-09,false,false,false,true -flameGraphItemCollapsing,experimental,@grafana/observability-traces-and-profiling,2023-11-09,false,false,false,true -alertingDetailsViewV2,experimental,@grafana/alerting-squad,2023-11-09,false,false,false,true -datatrails,experimental,@grafana/dashboards-squad,2023-11-15,false,false,false,true -alertingSimplifiedRouting,experimental,@grafana/alerting-squad,2023-11-10,false,false,false,false -logRowsPopoverMenu,experimental,@grafana/observability-logs,2023-11-16,false,false,false,true -pluginsSkipHostEnvVars,experimental,@grafana/plugins-platform-backend,2023-11-15,false,false,false,false -tableSharedCrosshair,experimental,@grafana/grafana-bi-squad,2023-12-12,false,false,false,true -regressionTransformation,privatePreview,@grafana/grafana-bi-squad,2023-11-24,false,false,false,true -displayAnonymousStats,GA,@grafana/identity-access-team,2023-11-29,false,false,false,true -alertStateHistoryAnnotationsFromLoki,experimental,@grafana/alerting-squad,2023-11-30,false,false,true,false -lokiQueryHints,GA,@grafana/observability-logs,2023-12-18,false,false,false,true +Name,Stage,Owner,requiresDevMode,RequiresRestart,FrontendOnly +disableEnvelopeEncryption,GA,@grafana/grafana-as-code,false,false,false +live-service-web-worker,experimental,@grafana/grafana-app-platform-squad,false,false,true +queryOverLive,experimental,@grafana/grafana-app-platform-squad,false,false,true +panelTitleSearch,preview,@grafana/grafana-app-platform-squad,false,false,false +publicDashboards,GA,@grafana/sharing-squad,false,false,false +publicDashboardsEmailSharing,preview,@grafana/sharing-squad,false,false,false +lokiExperimentalStreaming,experimental,@grafana/observability-logs,false,false,false +featureHighlights,GA,@grafana/grafana-as-code,false,false,false +migrationLocking,preview,@grafana/backend-platform,false,false,false +storage,experimental,@grafana/grafana-app-platform-squad,false,false,false +correlations,GA,@grafana/explore-squad,false,false,false +exploreContentOutline,GA,@grafana/explore-squad,false,false,true +datasourceQueryMultiStatus,experimental,@grafana/plugins-platform-backend,false,false,false +autoMigrateOldPanels,preview,@grafana/dataviz-squad,false,false,true +autoMigrateGraphPanel,preview,@grafana/dataviz-squad,false,false,true +autoMigrateTablePanel,preview,@grafana/dataviz-squad,false,false,true +autoMigratePiechartPanel,preview,@grafana/dataviz-squad,false,false,true +autoMigrateWorldmapPanel,preview,@grafana/dataviz-squad,false,false,true +autoMigrateStatPanel,preview,@grafana/dataviz-squad,false,false,true +disableAngular,preview,@grafana/dataviz-squad,false,false,true +canvasPanelNesting,experimental,@grafana/dataviz-squad,false,false,true +newVizTooltips,preview,@grafana/dataviz-squad,false,false,true +scenes,experimental,@grafana/dashboards-squad,false,false,true +disableSecretsCompatibility,experimental,@grafana/hosted-grafana-team,false,true,false +logRequestsInstrumentedAsUnknown,experimental,@grafana/hosted-grafana-team,false,false,false +topnav,deprecated,@grafana/grafana-frontend-platform,false,false,false +returnToPrevious,preview,@grafana/grafana-frontend-platform,false,false,true +grpcServer,preview,@grafana/grafana-app-platform-squad,false,false,false +unifiedStorage,experimental,@grafana/grafana-app-platform-squad,true,true,false +cloudWatchCrossAccountQuerying,GA,@grafana/aws-datasources,false,false,false +redshiftAsyncQueryDataSupport,GA,@grafana/aws-datasources,false,false,false +athenaAsyncQueryDataSupport,GA,@grafana/aws-datasources,false,false,true +showDashboardValidationWarnings,experimental,@grafana/dashboards-squad,false,false,false +mysqlAnsiQuotes,experimental,@grafana/backend-platform,false,false,false +accessControlOnCall,preview,@grafana/identity-access-team,false,false,false +nestedFolders,preview,@grafana/backend-platform,false,false,false +nestedFolderPicker,GA,@grafana/grafana-frontend-platform,false,false,true +alertingBacktesting,experimental,@grafana/alerting-squad,false,false,false +editPanelCSVDragAndDrop,experimental,@grafana/dataviz-squad,false,false,true +alertingNoNormalState,preview,@grafana/alerting-squad,false,false,false +logsContextDatasourceUi,GA,@grafana/observability-logs,false,false,true +lokiQuerySplitting,GA,@grafana/observability-logs,false,false,true +lokiQuerySplittingConfig,experimental,@grafana/observability-logs,false,false,true +individualCookiePreferences,experimental,@grafana/backend-platform,false,false,false +prometheusMetricEncyclopedia,GA,@grafana/observability-metrics,false,false,true +influxdbBackendMigration,GA,@grafana/observability-metrics,false,false,true +influxqlStreamingParser,experimental,@grafana/observability-metrics,false,false,false +influxdbRunQueriesInParallel,privatePreview,@grafana/observability-metrics,false,false,false +prometheusDataplane,GA,@grafana/observability-metrics,false,false,false +lokiMetricDataplane,GA,@grafana/observability-logs,false,false,false +lokiLogsDataplane,experimental,@grafana/observability-logs,false,false,false +dataplaneFrontendFallback,GA,@grafana/observability-metrics,false,false,true +disableSSEDataplane,experimental,@grafana/observability-metrics,false,false,false +alertStateHistoryLokiSecondary,experimental,@grafana/alerting-squad,false,false,false +alertStateHistoryLokiPrimary,experimental,@grafana/alerting-squad,false,false,false +alertStateHistoryLokiOnly,experimental,@grafana/alerting-squad,false,false,false +unifiedRequestLog,experimental,@grafana/backend-platform,false,false,false +renderAuthJWT,preview,@grafana/grafana-as-code,false,false,false +refactorVariablesTimeRange,preview,@grafana/dashboards-squad,false,false,false +enableElasticsearchBackendQuerying,GA,@grafana/observability-logs,false,false,false +faroDatasourceSelector,preview,@grafana/app-o11y,false,false,true +enableDatagridEditing,preview,@grafana/dataviz-squad,false,false,true +extraThemes,experimental,@grafana/grafana-frontend-platform,false,false,true +lokiPredefinedOperations,experimental,@grafana/observability-logs,false,false,true +pluginsFrontendSandbox,experimental,@grafana/plugins-platform-backend,false,false,true +dashboardEmbed,experimental,@grafana/grafana-as-code,false,false,true +frontendSandboxMonitorOnly,experimental,@grafana/plugins-platform-backend,false,false,true +sqlDatasourceDatabaseSelection,preview,@grafana/dataviz-squad,false,false,true +lokiFormatQuery,experimental,@grafana/observability-logs,false,false,true +recordedQueriesMulti,GA,@grafana/observability-metrics,false,false,false +pluginsDynamicAngularDetectionPatterns,experimental,@grafana/plugins-platform-backend,false,false,false +vizAndWidgetSplit,experimental,@grafana/dashboards-squad,false,false,true +prometheusIncrementalQueryInstrumentation,experimental,@grafana/observability-metrics,false,false,true +logsExploreTableVisualisation,GA,@grafana/observability-logs,false,false,true +awsDatasourcesTempCredentials,experimental,@grafana/aws-datasources,false,false,false +transformationsRedesign,GA,@grafana/observability-metrics,false,false,true +mlExpressions,experimental,@grafana/alerting-squad,false,false,false +traceQLStreaming,GA,@grafana/observability-traces-and-profiling,false,false,true +metricsSummary,experimental,@grafana/observability-traces-and-profiling,false,false,true +grafanaAPIServerWithExperimentalAPIs,experimental,@grafana/grafana-app-platform-squad,true,true,false +grafanaAPIServerEnsureKubectlAccess,experimental,@grafana/grafana-app-platform-squad,true,true,false +featureToggleAdminPage,experimental,@grafana/grafana-operator-experience-squad,false,true,false +awsAsyncQueryCaching,GA,@grafana/aws-datasources,false,false,false +permissionsFilterRemoveSubquery,experimental,@grafana/backend-platform,false,false,false +prometheusConfigOverhaulAuth,GA,@grafana/observability-metrics,false,false,false +configurableSchedulerTick,experimental,@grafana/alerting-squad,false,true,false +influxdbSqlSupport,GA,@grafana/observability-metrics,false,true,false +alertingNoDataErrorExecution,GA,@grafana/alerting-squad,false,true,false +angularDeprecationUI,GA,@grafana/plugins-platform-backend,false,false,true +dashgpt,preview,@grafana/dashboards-squad,false,false,true +aiGeneratedDashboardChanges,experimental,@grafana/dashboards-squad,false,false,true +reportingRetries,preview,@grafana/sharing-squad,false,true,false +sseGroupByDatasource,experimental,@grafana/observability-metrics,false,false,false +libraryPanelRBAC,experimental,@grafana/dashboards-squad,false,true,false +lokiRunQueriesInParallel,privatePreview,@grafana/observability-logs,false,false,false +wargamesTesting,experimental,@grafana/hosted-grafana-team,false,false,false +alertingInsights,GA,@grafana/alerting-squad,false,false,true +externalCorePlugins,experimental,@grafana/plugins-platform-backend,false,false,false +pluginsAPIMetrics,experimental,@grafana/plugins-platform-backend,false,false,true +idForwarding,experimental,@grafana/identity-access-team,false,false,false +externalServiceAccounts,preview,@grafana/identity-access-team,false,false,false +panelMonitoring,GA,@grafana/dataviz-squad,false,false,true +enableNativeHTTPHistogram,experimental,@grafana/hosted-grafana-team,false,false,false +formatString,preview,@grafana/dataviz-squad,false,false,true +transformationsVariableSupport,preview,@grafana/dataviz-squad,false,false,true +kubernetesPlaylists,experimental,@grafana/grafana-app-platform-squad,false,true,false +kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,false +kubernetesQueryServiceRewrite,experimental,@grafana/grafana-app-platform-squad,true,true,false +cloudWatchBatchQueries,preview,@grafana/aws-datasources,false,false,false +recoveryThreshold,GA,@grafana/alerting-squad,false,true,false +lokiStructuredMetadata,GA,@grafana/observability-logs,false,false,false +teamHttpHeaders,preview,@grafana/identity-access-team,false,false,false +awsDatasourcesNewFormStyling,preview,@grafana/aws-datasources,false,false,true +cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-experience-squad,false,false,false +panelTitleSearchInV1,experimental,@grafana/backend-platform,true,false,false +managedPluginsInstall,preview,@grafana/plugins-platform-backend,false,false,false +prometheusPromQAIL,experimental,@grafana/observability-metrics,false,false,true +addFieldFromCalculationStatFunctions,preview,@grafana/dataviz-squad,false,false,true +alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,false +alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false +alertmanagerRemoteOnly,experimental,@grafana/alerting-squad,false,false,false +annotationPermissionUpdate,experimental,@grafana/identity-access-team,false,false,false +extractFieldsNameDeduplication,experimental,@grafana/dataviz-squad,false,false,true +dashboardSceneForViewers,experimental,@grafana/dashboards-squad,false,false,true +dashboardSceneSolo,experimental,@grafana/dashboards-squad,false,false,true +dashboardScene,experimental,@grafana/dashboards-squad,false,false,true +panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true +pdfTables,preview,@grafana/sharing-squad,false,false,false +ssoSettingsApi,preview,@grafana/identity-access-team,false,false,false +canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true +logsInfiniteScrolling,experimental,@grafana/observability-logs,false,false,true +flameGraphItemCollapsing,experimental,@grafana/observability-traces-and-profiling,false,false,true +datatrails,experimental,@grafana/dashboards-squad,false,false,true +alertingSimplifiedRouting,preview,@grafana/alerting-squad,false,false,false +logRowsPopoverMenu,GA,@grafana/observability-logs,false,false,true +pluginsSkipHostEnvVars,experimental,@grafana/plugins-platform-backend,false,false,false +tableSharedCrosshair,experimental,@grafana/dataviz-squad,false,false,true +regressionTransformation,preview,@grafana/dataviz-squad,false,false,true +lokiQueryHints,GA,@grafana/observability-logs,false,false,true +kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,false,false,true +enablePluginsTracingByDefault,experimental,@grafana/plugins-platform-backend,false,true,false +cloudRBACRoles,experimental,@grafana/identity-access-team,false,true,false +alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false +newFolderPicker,experimental,@grafana/grafana-frontend-platform,false,false,true +jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false +onPremToCloudMigrations,experimental,@grafana/grafana-operator-experience-squad,false,false,false +alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false +promQLScope,experimental,@grafana/observability-metrics,false,false,false +sqlExpressions,experimental,@grafana/grafana-app-platform-squad,false,false,false +nodeGraphDotLayout,experimental,@grafana/observability-traces-and-profiling,false,false,true +groupToNestedTableTransformation,preview,@grafana/dataviz-squad,false,false,true +newPDFRendering,experimental,@grafana/sharing-squad,false,false,false +kubernetesAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false +expressionParser,experimental,@grafana/grafana-app-platform-squad,false,true,false +groupByVariable,experimental,@grafana/dashboards-squad,false,false,false +betterPageScrolling,GA,@grafana/grafana-frontend-platform,false,false,true +scopeFilters,experimental,@grafana/dashboards-squad,false,false,false +emailVerificationEnforcement,experimental,@grafana/identity-access-team,false,false,false +ssoSettingsSAML,experimental,@grafana/identity-access-team,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index c40afd54e5ed0..40581da464b06 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -59,14 +59,30 @@ const ( // Introduce HTTP 207 Multi Status for api/ds/query FlagDatasourceQueryMultiStatus = "datasourceQueryMultiStatus" - // FlagTraceToMetrics - // Enable trace to metrics links - FlagTraceToMetrics = "traceToMetrics" - // FlagAutoMigrateOldPanels // Migrate old angular panels to supported versions (graph, table-old, worldmap, etc) FlagAutoMigrateOldPanels = "autoMigrateOldPanels" + // FlagAutoMigrateGraphPanel + // Migrate old graph panel to supported time series panel - broken out from autoMigrateOldPanels to enable granular tracking + FlagAutoMigrateGraphPanel = "autoMigrateGraphPanel" + + // FlagAutoMigrateTablePanel + // Migrate old table panel to supported table panel - broken out from autoMigrateOldPanels to enable granular tracking + FlagAutoMigrateTablePanel = "autoMigrateTablePanel" + + // FlagAutoMigratePiechartPanel + // Migrate old piechart panel to supported piechart panel - broken out from autoMigrateOldPanels to enable granular tracking + FlagAutoMigratePiechartPanel = "autoMigratePiechartPanel" + + // FlagAutoMigrateWorldmapPanel + // Migrate old worldmap panel to supported geomap panel - broken out from autoMigrateOldPanels to enable granular tracking + FlagAutoMigrateWorldmapPanel = "autoMigrateWorldmapPanel" + + // FlagAutoMigrateStatPanel + // Migrate old stat panel to supported stat panel - broken out from autoMigrateOldPanels to enable granular tracking + FlagAutoMigrateStatPanel = "autoMigrateStatPanel" + // FlagDisableAngular // Dynamic flag to disable angular at runtime. The preferred method is to set `angular_support_enabled` to `false` in the [security] settings, which allows you to change the state at runtime. FlagDisableAngular = "disableAngular" @@ -91,17 +107,13 @@ const ( // Logs the path for requests that are instrumented as unknown FlagLogRequestsInstrumentedAsUnknown = "logRequestsInstrumentedAsUnknown" - // FlagDataConnectionsConsole - // Enables a new top-level page called Connections. This page is an experiment that provides a better experience when you install and configure data sources and other plugins. - FlagDataConnectionsConsole = "dataConnectionsConsole" - // FlagTopnav // Enables topnav support in external plugins. The new Grafana navigation cannot be disabled. FlagTopnav = "topnav" - // FlagDockedMegaMenu - // Enable support for a persistent (docked) navigation menu - FlagDockedMegaMenu = "dockedMegaMenu" + // FlagReturnToPrevious + // Enables the return to previous context functionality + FlagReturnToPrevious = "returnToPrevious" // FlagGrpcServer // Run the GRPC server @@ -123,10 +135,6 @@ const ( // Enable async query data support for Athena FlagAthenaAsyncQueryDataSupport = "athenaAsyncQueryDataSupport" - // FlagCloudwatchNewRegionsHandler - // Refactor of /regions endpoint, no user-facing changes - FlagCloudwatchNewRegionsHandler = "cloudwatchNewRegionsHandler" - // FlagShowDashboardValidationWarnings // Show warnings when dashboards do not validate against the schema FlagShowDashboardValidationWarnings = "showDashboardValidationWarnings" @@ -147,14 +155,6 @@ const ( // Enables the new folder picker to work with nested folders. Requires the nestedFolders feature toggle FlagNestedFolderPicker = "nestedFolderPicker" - // FlagEmptyDashboardPage - // Enable the redesigned user interface of a dashboard page that includes no panels - FlagEmptyDashboardPage = "emptyDashboardPage" - - // FlagDisablePrometheusExemplarSampling - // Disable Prometheus exemplar sampling - FlagDisablePrometheusExemplarSampling = "disablePrometheusExemplarSampling" - // FlagAlertingBacktesting // Rule backtesting API for alerting FlagAlertingBacktesting = "alertingBacktesting" @@ -195,9 +195,9 @@ const ( // Enable streaming JSON parser for InfluxDB datasource InfluxQL query language FlagInfluxqlStreamingParser = "influxqlStreamingParser" - // FlagClientTokenRotation - // Replaces the current in-request token rotation so that the client initiates the rotation - FlagClientTokenRotation = "clientTokenRotation" + // FlagInfluxdbRunQueriesInParallel + // Enables running InfluxDB Influxql queries in parallel + FlagInfluxdbRunQueriesInParallel = "influxdbRunQueriesInParallel" // FlagPrometheusDataplane // Changes responses to from Prometheus to be compliant with the dataplane specification. In particular, when this feature toggle is active, the numeric `Field.Name` is set from 'Value' to the value of the `__name__` label. @@ -239,26 +239,14 @@ const ( // Uses JWT-based auth for rendering instead of relying on remote cache FlagRenderAuthJWT = "renderAuthJWT" - // FlagExternalServiceAuth - // Starts an OAuth2 authentication provider for external services - FlagExternalServiceAuth = "externalServiceAuth" - // FlagRefactorVariablesTimeRange // Refactor time range variables flow to reduce number of API calls made when query variables are chained FlagRefactorVariablesTimeRange = "refactorVariablesTimeRange" - // FlagUseCachingService - // When active, the new query and resource caching implementation using a wire service inject replaces the previous middleware implementation. - FlagUseCachingService = "useCachingService" - // FlagEnableElasticsearchBackendQuerying // Enable the processing of queries and responses in the Elasticsearch data source through backend FlagEnableElasticsearchBackendQuerying = "enableElasticsearchBackendQuerying" - // FlagAdvancedDataSourcePicker - // Enable a new data source picker with contextual information, recently used order and advanced mode - FlagAdvancedDataSourcePicker = "advancedDataSourcePicker" - // FlagFaroDatasourceSelector // Enable the data source selector within the Frontend Apps section of the Frontend Observability FlagFaroDatasourceSelector = "faroDatasourceSelector" @@ -295,14 +283,6 @@ const ( // Enables the ability to format Loki queries FlagLokiFormatQuery = "lokiFormatQuery" - // FlagCloudWatchLogsMonacoEditor - // Enables the Monaco editor for CloudWatch Logs queries - FlagCloudWatchLogsMonacoEditor = "cloudWatchLogsMonacoEditor" - - // FlagExploreScrollableLogsContainer - // Improves the scrolling behavior of logs in Explore - FlagExploreScrollableLogsContainer = "exploreScrollableLogsContainer" - // FlagRecordedQueriesMulti // Enables writing multiple items from a single query within Recorded Queries FlagRecordedQueriesMulti = "recordedQueriesMulti" @@ -343,10 +323,6 @@ const ( // Enables metrics summary queries in the Tempo data source FlagMetricsSummary = "metricsSummary" - // FlagGrafanaAPIServer - // Enable Kubernetes API Server for Grafana resources - FlagGrafanaAPIServer = "grafanaAPIServer" - // FlagGrafanaAPIServerWithExperimentalAPIs // Register experimental APIs with the k8s API server FlagGrafanaAPIServerWithExperimentalAPIs = "grafanaAPIServerWithExperimentalAPIs" @@ -360,21 +336,9 @@ const ( FlagFeatureToggleAdminPage = "featureToggleAdminPage" // FlagAwsAsyncQueryCaching - // Enable caching for async queries for Redshift and Athena. Requires that the `useCachingService` feature toggle is enabled and the datasource has caching and async query support enabled + // Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled FlagAwsAsyncQueryCaching = "awsAsyncQueryCaching" - // FlagSplitScopes - // Support faster dashboard and folder search by splitting permission scopes into parts - FlagSplitScopes = "splitScopes" - - // FlagTraceToProfiles - // Enables linking between traces and profiles - FlagTraceToProfiles = "traceToProfiles" - - // FlagTracesEmbeddedFlameGraph - // Enables embedding a flame graph in traces - FlagTracesEmbeddedFlameGraph = "tracesEmbeddedFlameGraph" - // FlagPermissionsFilterRemoveSubquery // Alternative permission filter implementation that does not use subqueries for fetching the dashboard folder FlagPermissionsFilterRemoveSubquery = "permissionsFilterRemoveSubquery" @@ -396,25 +360,25 @@ const ( FlagAlertingNoDataErrorExecution = "alertingNoDataErrorExecution" // FlagAngularDeprecationUI - // Display new Angular deprecation-related UI features + // Display Angular warnings in dashboards and panels FlagAngularDeprecationUI = "angularDeprecationUI" // FlagDashgpt // Enable AI powered features in dashboards FlagDashgpt = "dashgpt" + // FlagAiGeneratedDashboardChanges + // Enable AI powered features for dashboards to auto-summary changes when saving + FlagAiGeneratedDashboardChanges = "aiGeneratedDashboardChanges" + // FlagReportingRetries // Enables rendering retries for the reporting feature FlagReportingRetries = "reportingRetries" // FlagSseGroupByDatasource - // Send query to the same datasource in a single request when using server side expressions + // Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch. FlagSseGroupByDatasource = "sseGroupByDatasource" - // FlagRequestInstrumentationStatusSource - // Include a status source label for request metrics and logs - FlagRequestInstrumentationStatusSource = "requestInstrumentationStatusSource" - // FlagLibraryPanelRBAC // Enables RBAC support for library panels FlagLibraryPanelRBAC = "libraryPanelRBAC" @@ -439,18 +403,10 @@ const ( // Sends metrics of public grafana packages usage by plugins FlagPluginsAPIMetrics = "pluginsAPIMetrics" - // FlagHttpSLOLevels - // Adds SLO level to http request metrics - FlagHttpSLOLevels = "httpSLOLevels" - // FlagIdForwarding // Generate signed id token for identity that can be forwarded to plugins and external services FlagIdForwarding = "idForwarding" - // FlagCloudWatchWildCardDimensionValues - // Fetches dimension values from CloudWatch to correctly label wildcard dimensions - FlagCloudWatchWildCardDimensionValues = "cloudWatchWildCardDimensionValues" - // FlagExternalServiceAccounts // Automatic service account and token setup for plugins FlagExternalServiceAccounts = "externalServiceAccounts" @@ -476,9 +432,13 @@ const ( FlagKubernetesPlaylists = "kubernetesPlaylists" // FlagKubernetesSnapshots - // Use the kubernetes API in the frontend to support playlists + // Routes snapshot requests from /api to the /apis endpoint FlagKubernetesSnapshots = "kubernetesSnapshots" + // FlagKubernetesQueryServiceRewrite + // Rewrite requests targeting /ds/query to the query service + FlagKubernetesQueryServiceRewrite = "kubernetesQueryServiceRewrite" + // FlagCloudWatchBatchQueries // Runs CloudWatch metrics queries as separate batches FlagCloudWatchBatchQueries = "cloudWatchBatchQueries" @@ -492,7 +452,7 @@ const ( FlagLokiStructuredMetadata = "lokiStructuredMetadata" // FlagTeamHttpHeaders - // Enables datasources to apply team headers to the client requests + // Enables Team LBAC for datasources to apply team headers to the client requests FlagTeamHttpHeaders = "teamHttpHeaders" // FlagAwsDatasourcesNewFormStyling @@ -507,14 +467,6 @@ const ( // Enable searching for dashboards using panel title in search v1 FlagPanelTitleSearchInV1 = "panelTitleSearchInV1" - // FlagPluginsInstrumentationStatusSource - // Include a status source label for plugin request metrics and logs - FlagPluginsInstrumentationStatusSource = "pluginsInstrumentationStatusSource" - - // FlagCostManagementUi - // Toggles the display of the cost management ui plugin - FlagCostManagementUi = "costManagementUi" - // FlagManagedPluginsInstall // Install managed plugins directly from plugins catalog FlagManagedPluginsInstall = "managedPluginsInstall" @@ -551,6 +503,10 @@ const ( // Enables dashboard rendering using Scenes for viewer roles FlagDashboardSceneForViewers = "dashboardSceneForViewers" + // FlagDashboardSceneSolo + // Enables rendering dashboards using scenes for solo panels + FlagDashboardSceneSolo = "dashboardSceneSolo" + // FlagDashboardScene // Enables dashboard rendering using scenes for all roles FlagDashboardScene = "dashboardScene" @@ -564,7 +520,7 @@ const ( FlagPdfTables = "pdfTables" // FlagSsoSettingsApi - // Enables the SSO settings API + // Enables the SSO settings API and the OAuth configuration UIs in Grafana FlagSsoSettingsApi = "ssoSettingsApi" // FlagCanvasPanelPanZoom @@ -579,16 +535,12 @@ const ( // Allow collapsing of flame graph items FlagFlameGraphItemCollapsing = "flameGraphItemCollapsing" - // FlagAlertingDetailsViewV2 - // Enables the preview of the new alert details view - FlagAlertingDetailsViewV2 = "alertingDetailsViewV2" - // FlagDatatrails // Enables the new core app datatrails FlagDatatrails = "datatrails" // FlagAlertingSimplifiedRouting - // Enables the simplified routing for alerting + // Enables users to easily configure alert notifications by specifying a contact point directly when editing or creating an alert rule FlagAlertingSimplifiedRouting = "alertingSimplifiedRouting" // FlagLogRowsPopoverMenu @@ -607,15 +559,87 @@ const ( // Enables regression analysis transformation FlagRegressionTransformation = "regressionTransformation" - // FlagDisplayAnonymousStats - // Enables anonymous stats to be shown in the UI for Grafana - FlagDisplayAnonymousStats = "displayAnonymousStats" - - // FlagAlertStateHistoryAnnotationsFromLoki - // Enable using Loki as the source for panel annotations generated by alert rules - FlagAlertStateHistoryAnnotationsFromLoki = "alertStateHistoryAnnotationsFromLoki" - // FlagLokiQueryHints // Enables query hints for Loki FlagLokiQueryHints = "lokiQueryHints" + + // FlagKubernetesFeatureToggles + // Use the kubernetes API for feature toggle management in the frontend + FlagKubernetesFeatureToggles = "kubernetesFeatureToggles" + + // FlagEnablePluginsTracingByDefault + // Enable plugin tracing for all external plugins + FlagEnablePluginsTracingByDefault = "enablePluginsTracingByDefault" + + // FlagCloudRBACRoles + // Enabled grafana cloud specific RBAC roles + FlagCloudRBACRoles = "cloudRBACRoles" + + // FlagAlertingQueryOptimization + // Optimizes eligible queries in order to reduce load on datasources + FlagAlertingQueryOptimization = "alertingQueryOptimization" + + // FlagNewFolderPicker + // Enables the nested folder picker without having nested folders enabled + FlagNewFolderPicker = "newFolderPicker" + + // FlagJitterAlertRulesWithinGroups + // Distributes alert rule evaluations more evenly over time, including spreading out rules within the same group + FlagJitterAlertRulesWithinGroups = "jitterAlertRulesWithinGroups" + + // FlagOnPremToCloudMigrations + // In-development feature that will allow users to easily migrate their on-prem Grafana instances to Grafana Cloud. + FlagOnPremToCloudMigrations = "onPremToCloudMigrations" + + // FlagAlertingSaveStatePeriodic + // Writes the state periodically to the database, asynchronous to rule evaluation + FlagAlertingSaveStatePeriodic = "alertingSaveStatePeriodic" + + // FlagPromQLScope + // In-development feature that will allow injection of labels into prometheus queries. + FlagPromQLScope = "promQLScope" + + // FlagSqlExpressions + // Enables using SQL and DuckDB functions as Expressions. + FlagSqlExpressions = "sqlExpressions" + + // FlagNodeGraphDotLayout + // Changed the layout algorithm for the node graph + FlagNodeGraphDotLayout = "nodeGraphDotLayout" + + // FlagGroupToNestedTableTransformation + // Enables the group to nested table transformation + FlagGroupToNestedTableTransformation = "groupToNestedTableTransformation" + + // FlagNewPDFRendering + // New implementation for the dashboard to PDF rendering + FlagNewPDFRendering = "newPDFRendering" + + // FlagKubernetesAggregator + // Enable grafana aggregator + FlagKubernetesAggregator = "kubernetesAggregator" + + // FlagExpressionParser + // Enable new expression parser + FlagExpressionParser = "expressionParser" + + // FlagGroupByVariable + // Enable groupBy variable support in scenes dashboards + FlagGroupByVariable = "groupByVariable" + + // FlagBetterPageScrolling + // Removes CustomScrollbar from the UI, relying on native browser scrollbars + FlagBetterPageScrolling = "betterPageScrolling" + + // FlagScopeFilters + // Enables the use of scope filters in Grafana + FlagScopeFilters = "scopeFilters" + + // FlagEmailVerificationEnforcement + // Force email verification for users, even when authenticating through sso. + FlagEmailVerificationEnforcement = "emailVerificationEnforcement" + + // FlagSsoSettingsSAML + // Use the new SSO Settings API to configure the SAML connector + FlagSsoSettingsSAML = "ssoSettingsSAML" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json new file mode 100644 index 0000000000000..0878000aae497 --- /dev/null +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -0,0 +1,2145 @@ +{ + "kind": "FeatureList", + "apiVersion": "featuretoggle.grafana.app/v0alpha1", + "metadata": {}, + "items": [ + { + "metadata": { + "name": "nestedFolderPicker", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables the new folder picker to work with nested folders. Requires the nestedFolders feature toggle", + "stage": "GA", + "codeowner": "@grafana/grafana-frontend-platform", + "frontend": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "alertingBacktesting", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Rule backtesting API for alerting", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "featureToggleAdminPage", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable admin page for managing feature toggles from the Grafana front-end", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "logsInfiniteScrolling", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables infinite scrolling for the Logs panel in Explore and Dashboards", + "stage": "experimental", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, + { + "metadata": { + "name": "alertingPreviewUpgrade", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z", + "deletionTimestamp": "2024-03-11T16:22:16Z" + }, + "spec": { + "description": "Show Unified Alerting preview and upgrade page in legacy alerting", + "stage": "GA", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "cloudRBACRoles", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enabled grafana cloud specific RBAC roles", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "requiresRestart": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "awsDatasourcesTempCredentials", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Support temporary security credentials in AWS plugins for Grafana Cloud customers", + "stage": "experimental", + "codeowner": "@grafana/aws-datasources" + } + }, + { + "metadata": { + "name": "externalServiceAccounts", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Automatic service account and token setup for plugins", + "stage": "preview", + "codeowner": "@grafana/identity-access-team", + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "transformationsVariableSupport", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Allows using variables in transformations", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "panelTitleSearch", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Search for dashboards using panel title", + "stage": "preview", + "codeowner": "@grafana/grafana-app-platform-squad", + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "enableDatagridEditing", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables the edit functionality in the datagrid panel", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "traceQLStreaming", + "resourceVersion": "1710172794391", + "creationTimestamp": "2024-03-05T14:17:16Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-03-11 15:59:54.391734 +0000 UTC" + } + }, + "spec": { + "description": "Enables response streaming of TraceQL queries of the Tempo data source", + "stage": "GA", + "codeowner": "@grafana/observability-traces-and-profiling", + "frontend": true + } + }, + { + "metadata": { + "name": "metricsSummary", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables metrics summary queries in the Tempo data source", + "stage": "experimental", + "codeowner": "@grafana/observability-traces-and-profiling", + "frontend": true + } + }, + { + "metadata": { + "name": "sseGroupByDatasource", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch.", + "stage": "experimental", + "codeowner": "@grafana/observability-metrics" + } + }, + { + "metadata": { + "name": "live-service-web-worker", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "This will use a webworker thread to processes events rather than the main thread", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "canvasPanelNesting", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Allow elements nesting", + "stage": "experimental", + "codeowner": "@grafana/dataviz-squad", + "frontend": true, + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "enableElasticsearchBackendQuerying", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable the processing of queries and responses in the Elasticsearch data source through backend", + "stage": "GA", + "codeowner": "@grafana/observability-logs", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "extractFieldsNameDeduplication", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Make sure extracted field names are unique in the dataframe", + "stage": "experimental", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "alertStateHistoryLokiPrimary", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable a remote Loki instance as the primary source for state history reads.", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "dashboardEmbed", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Allow embedding dashboard for external use in Code editors", + "stage": "experimental", + "codeowner": "@grafana/grafana-as-code", + "frontend": true + } + }, + { + "metadata": { + "name": "sqlDatasourceDatabaseSelection", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables previous SQL data source dataset dropdown behavior", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true, + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "permissionsFilterRemoveSubquery", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Alternative permission filter implementation that does not use subqueries for fetching the dashboard folder", + "stage": "experimental", + "codeowner": "@grafana/backend-platform" + } + }, + { + "metadata": { + "name": "awsDatasourcesNewFormStyling", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Applies new form styling for configuration and query editors in AWS plugins", + "stage": "preview", + "codeowner": "@grafana/aws-datasources", + "frontend": true + } + }, + { + "metadata": { + "name": "prometheusPromQAIL", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Prometheus and AI/ML to assist users in creating a query", + "stage": "experimental", + "codeowner": "@grafana/observability-metrics", + "frontend": true + } + }, + { + "metadata": { + "name": "scopeFilters", + "resourceVersion": "1709648534592", + "creationTimestamp": "2024-03-05T14:17:16Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-03-05 14:22:14.592841 +0000 UTC" + } + }, + "spec": { + "description": "Enables the use of scope filters in Grafana", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "renderAuthJWT", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Uses JWT-based auth for rendering instead of relying on remote cache", + "stage": "preview", + "codeowner": "@grafana/grafana-as-code", + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "groupToNestedTableTransformation", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables the group to nested table transformation", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "autoMigrateOldPanels", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Migrate old angular panels to supported versions (graph, table-old, worldmap, etc)", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "showDashboardValidationWarnings", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Show warnings when dashboards do not validate against the schema", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad" + } + }, + { + "metadata": { + "name": "alertmanagerRemoteSecondary", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable Grafana to sync configuration and state with a remote Alertmanager.", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "disableAngular", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Dynamic flag to disable angular at runtime. The preferred method is to set `angular_support_enabled` to `false` in the [security] settings, which allows you to change the state at runtime.", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true, + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "disableSecretsCompatibility", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Disable duplicated secret storage in legacy tables", + "stage": "experimental", + "codeowner": "@grafana/hosted-grafana-team", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "alertingNoNormalState", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Stop maintaining state of alerts that are not firing", + "stage": "preview", + "codeowner": "@grafana/alerting-squad", + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "pluginsDynamicAngularDetectionPatterns", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables fetching Angular detection patterns for plugins from GCOM and fallback to hardcoded ones", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend" + } + }, + { + "metadata": { + "name": "jitterAlertRulesWithinGroups", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Distributes alert rule evaluations more evenly over time, including spreading out rules within the same group", + "stage": "preview", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "storage", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Configurable storage for dashboards, datasources, and resources", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad" + } + }, + { + "metadata": { + "name": "autoMigratePiechartPanel", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Migrate old piechart panel to supported piechart panel - broken out from autoMigrateOldPanels to enable granular tracking", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "autoMigrateWorldmapPanel", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Migrate old worldmap panel to supported geomap panel - broken out from autoMigrateOldPanels to enable granular tracking", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "redshiftAsyncQueryDataSupport", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable async query data support for Redshift", + "stage": "GA", + "codeowner": "@grafana/aws-datasources" + } + }, + { + "metadata": { + "name": "influxdbBackendMigration", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Query InfluxDB InfluxQL without the proxy", + "stage": "GA", + "codeowner": "@grafana/observability-metrics", + "frontend": true + } + }, + { + "metadata": { + "name": "lokiLogsDataplane", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Changes logs responses from Loki to be compliant with the dataplane specification.", + "stage": "experimental", + "codeowner": "@grafana/observability-logs" + } + }, + { + "metadata": { + "name": "pluginsFrontendSandbox", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables the plugins frontend sandbox", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true + } + }, + { + "metadata": { + "name": "alertingNoDataErrorExecution", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Changes how Alerting state manager handles execution of NoData/Error", + "stage": "GA", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "nodeGraphDotLayout", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Changed the layout algorithm for the node graph", + "stage": "experimental", + "codeowner": "@grafana/observability-traces-and-profiling", + "frontend": true + } + }, + { + "metadata": { + "name": "accessControlOnCall", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Access control primitives for OnCall", + "stage": "preview", + "codeowner": "@grafana/identity-access-team", + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "prometheusDataplane", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Changes responses to from Prometheus to be compliant with the dataplane specification. In particular, when this feature toggle is active, the numeric `Field.Name` is set from 'Value' to the value of the `__name__` label.", + "stage": "GA", + "codeowner": "@grafana/observability-metrics", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "vizAndWidgetSplit", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Split panels between visualizations and widgets", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "reportingRetries", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables rendering retries for the reporting feature", + "stage": "preview", + "codeowner": "@grafana/sharing-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "panelTitleSearchInV1", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable searching for dashboards using panel title in search v1", + "stage": "experimental", + "codeowner": "@grafana/backend-platform", + "requiresDevMode": true + } + }, + { + "metadata": { + "name": "alertingUpgradeDryrunOnStart", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z", + "deletionTimestamp": "2024-03-11T16:22:16Z" + }, + "spec": { + "description": "When activated in legacy alerting mode, this initiates a dry-run of the Unified Alerting upgrade during each startup. It logs any issues detected without implementing any actual changes.", + "stage": "GA", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "topnav", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables topnav support in external plugins. The new Grafana navigation cannot be disabled.", + "stage": "deprecated", + "codeowner": "@grafana/grafana-frontend-platform" + } + }, + { + "metadata": { + "name": "grpcServer", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Run the GRPC server", + "stage": "preview", + "codeowner": "@grafana/grafana-app-platform-squad", + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "influxdbRunQueriesInParallel", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables running InfluxDB Influxql queries in parallel", + "stage": "privatePreview", + "codeowner": "@grafana/observability-metrics" + } + }, + { + "metadata": { + "name": "lokiPredefinedOperations", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Adds predefined query operations to Loki query editor", + "stage": "experimental", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, + { + "metadata": { + "name": "kubernetesQueryServiceRewrite", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Rewrite requests targeting /ds/query to the query service", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "requiresDevMode": true, + "requiresRestart": true + } + }, + { + "metadata": { + "name": "alertmanagerRemoteOnly", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Disable the internal Alertmanager and only use the external one defined.", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "cloudWatchCrossAccountQuerying", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables cross-account querying in CloudWatch datasources", + "stage": "GA", + "codeowner": "@grafana/aws-datasources", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "prometheusMetricEncyclopedia", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Adds the metrics explorer component to the Prometheus query builder as an option in metric select", + "stage": "GA", + "codeowner": "@grafana/observability-metrics", + "frontend": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "addFieldFromCalculationStatFunctions", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Add cumulative and window functions to the add field from calculation transformation", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "annotationPermissionUpdate", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Separate annotation permissions from dashboard permissions to allow for more granular control.", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team" + } + }, + { + "metadata": { + "name": "scenes", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Experimental framework to build interactive dashboards", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "influxqlStreamingParser", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable streaming JSON parser for InfluxDB datasource InfluxQL query language", + "stage": "experimental", + "codeowner": "@grafana/observability-metrics" + } + }, + { + "metadata": { + "name": "unifiedRequestLog", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Writes error logs to the request logger", + "stage": "experimental", + "codeowner": "@grafana/backend-platform" + } + }, + { + "metadata": { + "name": "faroDatasourceSelector", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable the data source selector within the Frontend Apps section of the Frontend Observability", + "stage": "preview", + "codeowner": "@grafana/app-o11y", + "frontend": true + } + }, + { + "metadata": { + "name": "mlExpressions", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable support for Machine Learning in server-side expressions", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "prometheusConfigOverhaulAuth", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Update the Prometheus configuration page with the new auth component", + "stage": "GA", + "codeowner": "@grafana/observability-metrics" + } + }, + { + "metadata": { + "name": "panelMonitoring", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables panel monitoring through logs and measurements", + "stage": "GA", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "cachingOptimizeSerializationMemoryUsage", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses.", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad" + } + }, + { + "metadata": { + "name": "kubernetesAggregator", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable grafana aggregator", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "refactorVariablesTimeRange", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Refactor time range variables flow to reduce number of API calls made when query variables are chained", + "stage": "preview", + "codeowner": "@grafana/dashboards-squad", + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "recordedQueriesMulti", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables writing multiple items from a single query within Recorded Queries", + "stage": "GA", + "codeowner": "@grafana/observability-metrics" + } + }, + { + "metadata": { + "name": "logsExploreTableVisualisation", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "A table visualisation for logs in Explore", + "stage": "GA", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, + { + "metadata": { + "name": "dashgpt", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable AI powered features in dashboards", + "stage": "preview", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "wargamesTesting", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Placeholder feature flag for internal testing", + "stage": "experimental", + "codeowner": "@grafana/hosted-grafana-team" + } + }, + { + "metadata": { + "name": "enablePluginsTracingByDefault", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable plugin tracing for all external plugins", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "mysqlAnsiQuotes", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Use double quotes to escape keyword in a MySQL query", + "stage": "experimental", + "codeowner": "@grafana/backend-platform" + } + }, + { + "metadata": { + "name": "lokiStructuredMetadata", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables the loki data source to request structured metadata from the Loki server", + "stage": "GA", + "codeowner": "@grafana/observability-logs" + } + }, + { + "metadata": { + "name": "alertingDetailsViewV2", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z", + "deletionTimestamp": "2024-03-12T12:33:03Z" + }, + "spec": { + "description": "Enables the preview of the new alert details view", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "frontend": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "regressionTransformation", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables regression analysis transformation", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "expressionParser", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable new expression parser", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "disableEnvelopeEncryption", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Disable envelope encryption (emergency only)", + "stage": "GA", + "codeowner": "@grafana/grafana-as-code", + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "athenaAsyncQueryDataSupport", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable async query data support for Athena", + "stage": "GA", + "codeowner": "@grafana/aws-datasources", + "frontend": true + } + }, + { + "metadata": { + "name": "logRowsPopoverMenu", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable filtering menu displayed when text of a log line is selected", + "stage": "GA", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, + { + "metadata": { + "name": "nestedFolders", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable folder nesting", + "stage": "preview", + "codeowner": "@grafana/backend-platform" + } + }, + { + "metadata": { + "name": "ssoSettingsApi", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables the SSO settings API and the OAuth configuration UIs in Grafana", + "stage": "preview", + "codeowner": "@grafana/identity-access-team", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "alertingSaveStatePeriodic", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Writes the state periodically to the database, asynchronous to rule evaluation", + "stage": "privatePreview", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "unifiedStorage", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "SQL-based k8s storage", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "requiresDevMode": true, + "requiresRestart": true + } + }, + { + "metadata": { + "name": "frontendSandboxMonitorOnly", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables monitor only in the plugin frontend sandbox (if enabled)", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true + } + }, + { + "metadata": { + "name": "lokiRunQueriesInParallel", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables running Loki queries in parallel", + "stage": "privatePreview", + "codeowner": "@grafana/observability-logs" + } + }, + { + "metadata": { + "name": "queryOverLive", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Use Grafana Live WebSocket to execute backend queries", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "logRequestsInstrumentedAsUnknown", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Logs the path for requests that are instrumented as unknown", + "stage": "experimental", + "codeowner": "@grafana/hosted-grafana-team" + } + }, + { + "metadata": { + "name": "alertStateHistoryLokiOnly", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Disable Grafana alerts from emitting annotations when a remote Loki instance is available.", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "prometheusIncrementalQueryInstrumentation", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Adds RudderStack events to incremental queries", + "stage": "experimental", + "codeowner": "@grafana/observability-metrics", + "frontend": true + } + }, + { + "metadata": { + "name": "angularDeprecationUI", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Display Angular warnings in dashboards and panels", + "stage": "GA", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true + } + }, + { + "metadata": { + "name": "aiGeneratedDashboardChanges", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable AI powered features for dashboards to auto-summary changes when saving", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "kubernetesPlaylists", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Use the kubernetes API in the frontend for playlists, and route /api/playlist requests to k8s", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "pdfTables", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables generating table data as PDF in reporting", + "stage": "preview", + "codeowner": "@grafana/sharing-squad" + } + }, + { + "metadata": { + "name": "tableSharedCrosshair", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables shared crosshair in table panel", + "stage": "experimental", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "featureHighlights", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Highlight Grafana Enterprise features", + "stage": "GA", + "codeowner": "@grafana/grafana-as-code", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "alertStateHistoryLokiSecondary", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable Grafana to write alert state history to an external Loki instance in addition to Grafana annotations.", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "transformationsRedesign", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables the transformations redesign", + "stage": "GA", + "codeowner": "@grafana/observability-metrics", + "frontend": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "kubernetesSnapshots", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Routes snapshot requests from /api to the /apis endpoint", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "pluginsSkipHostEnvVars", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Disables passing host environment variable to plugin processes", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend" + } + }, + { + "metadata": { + "name": "promQLScope", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "In-development feature that will allow injection of labels into prometheus queries.", + "stage": "experimental", + "codeowner": "@grafana/observability-metrics" + } + }, + { + "metadata": { + "name": "autoMigrateStatPanel", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Migrate old stat panel to supported stat panel - broken out from autoMigrateOldPanels to enable granular tracking", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "newVizTooltips", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "New visualizations tooltips UX", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "editPanelCSVDragAndDrop", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables drag and drop for CSV and Excel files", + "stage": "experimental", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "awsAsyncQueryCaching", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled", + "stage": "GA", + "codeowner": "@grafana/aws-datasources" + } + }, + { + "metadata": { + "name": "canvasPanelPanZoom", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Allow pan and zoom in canvas panel", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "migrationLocking", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Lock database during migrations", + "stage": "preview", + "codeowner": "@grafana/backend-platform" + } + }, + { + "metadata": { + "name": "exploreScrollableLogsContainer", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z", + "deletionTimestamp": "2024-03-12T14:02:07Z" + }, + "spec": { + "description": "Improves the scrolling behavior of logs in Explore", + "stage": "experimental", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, + { + "metadata": { + "name": "libraryPanelRBAC", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables RBAC support for library panels", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "newFolderPicker", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables the nested folder picker without having nested folders enabled", + "stage": "experimental", + "codeowner": "@grafana/grafana-frontend-platform", + "frontend": true + } + }, + { + "metadata": { + "name": "publicDashboardsEmailSharing", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables public dashboard sharing to be restricted to only allowed emails", + "stage": "preview", + "codeowner": "@grafana/sharing-squad", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "lokiExperimentalStreaming", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Support new streaming approach for loki (prototype, needs special loki build)", + "stage": "experimental", + "codeowner": "@grafana/observability-logs" + } + }, + { + "metadata": { + "name": "alertingInsights", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Show the new alerting insights landing page", + "stage": "GA", + "codeowner": "@grafana/alerting-squad", + "frontend": true, + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "cloudWatchWildCardDimensionValues", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z", + "deletionTimestamp": "2024-03-12T20:13:32Z" + }, + "spec": { + "description": "Fetches dimension values from CloudWatch to correctly label wildcard dimensions", + "stage": "GA", + "codeowner": "@grafana/aws-datasources", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "dashboardScene", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables dashboard rendering using scenes for all roles", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "alertingSimplifiedRouting", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables users to easily configure alert notifications by specifying a contact point directly when editing or creating an alert rule", + "stage": "preview", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "returnToPrevious", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables the return to previous context functionality", + "stage": "preview", + "codeowner": "@grafana/grafana-frontend-platform", + "frontend": true + } + }, + { + "metadata": { + "name": "cloudWatchLogsMonacoEditor", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z", + "deletionTimestamp": "2024-03-13T18:21:47Z" + }, + "spec": { + "description": "Enables the Monaco editor for CloudWatch Logs queries", + "stage": "GA", + "codeowner": "@grafana/aws-datasources", + "frontend": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "cloudWatchBatchQueries", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Runs CloudWatch metrics queries as separate batches", + "stage": "preview", + "codeowner": "@grafana/aws-datasources" + } + }, + { + "metadata": { + "name": "lokiQueryHints", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables query hints for Loki", + "stage": "GA", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, + { + "metadata": { + "name": "newPDFRendering", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "New implementation for the dashboard to PDF rendering", + "stage": "experimental", + "codeowner": "@grafana/sharing-squad" + } + }, + { + "metadata": { + "name": "logsContextDatasourceUi", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Allow datasource to provide custom UI for context view", + "stage": "GA", + "codeowner": "@grafana/observability-logs", + "frontend": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "dataplaneFrontendFallback", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Support dataplane contract field name change for transformations and field name matchers where the name is different", + "stage": "GA", + "codeowner": "@grafana/observability-metrics", + "frontend": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "grafanaAPIServerEnsureKubectlAccess", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Start an additional https handler and write kubectl options", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "requiresDevMode": true, + "requiresRestart": true + } + }, + { + "metadata": { + "name": "configurableSchedulerTick", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable changing the scheduler base interval via configuration option unified_alerting.scheduler_tick_interval", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "enableNativeHTTPHistogram", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables native HTTP Histograms", + "stage": "experimental", + "codeowner": "@grafana/hosted-grafana-team" + } + }, + { + "metadata": { + "name": "formatString", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable format string transformer", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "flameGraphItemCollapsing", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Allow collapsing of flame graph items", + "stage": "experimental", + "codeowner": "@grafana/observability-traces-and-profiling", + "frontend": true + } + }, + { + "metadata": { + "name": "publicDashboards", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "[Deprecated] Public dashboards are now enabled by default; to disable them, use the configuration setting. This feature toggle will be removed in the next major version.", + "stage": "GA", + "codeowner": "@grafana/sharing-squad", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "exploreContentOutline", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Content outline sidebar", + "stage": "GA", + "codeowner": "@grafana/explore-squad", + "frontend": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "groupByVariable", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable groupBy variable support in scenes dashboards", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "extraThemes", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables extra themes", + "stage": "experimental", + "codeowner": "@grafana/grafana-frontend-platform", + "frontend": true + } + }, + { + "metadata": { + "name": "dashboardSceneForViewers", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables dashboard rendering using Scenes for viewer roles", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "onPremToCloudMigrations", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "In-development feature that will allow users to easily migrate their on-prem Grafana instances to Grafana Cloud.", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad" + } + }, + { + "metadata": { + "name": "lokiQuerySplittingConfig", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Give users the option to configure split durations for Loki queries", + "stage": "experimental", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, + { + "metadata": { + "name": "lokiFormatQuery", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables the ability to format Loki queries", + "stage": "experimental", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, + { + "metadata": { + "name": "grafanaAPIServerWithExperimentalAPIs", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Register experimental APIs with the k8s API server", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "requiresDevMode": true, + "requiresRestart": true + } + }, + { + "metadata": { + "name": "influxdbSqlSupport", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable InfluxDB SQL query language support with new querying UI", + "stage": "GA", + "codeowner": "@grafana/observability-metrics", + "requiresRestart": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "recoveryThreshold", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables feature recovery threshold (aka hysteresis) for threshold server-side expression", + "stage": "GA", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "panelFilterVariable", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "lokiQuerySplitting", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Split large interval queries into subqueries with smaller time intervals", + "stage": "GA", + "codeowner": "@grafana/observability-logs", + "frontend": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "lokiMetricDataplane", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Changes metric responses from Loki to be compliant with the dataplane specification.", + "stage": "GA", + "codeowner": "@grafana/observability-logs", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "idForwarding", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Generate signed id token for identity that can be forwarded to plugins and external services", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team" + } + }, + { + "metadata": { + "name": "teamHttpHeaders", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables Team LBAC for datasources to apply team headers to the client requests", + "stage": "preview", + "codeowner": "@grafana/identity-access-team" + } + }, + { + "metadata": { + "name": "managedPluginsInstall", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Install managed plugins directly from plugins catalog", + "stage": "preview", + "codeowner": "@grafana/plugins-platform-backend" + } + }, + { + "metadata": { + "name": "alertmanagerRemotePrimary", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "alertingQueryOptimization", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Optimizes eligible queries in order to reduce load on datasources", + "stage": "GA", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "correlations", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Correlations page", + "stage": "GA", + "codeowner": "@grafana/explore-squad", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "autoMigrateGraphPanel", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Migrate old graph panel to supported time series panel - broken out from autoMigrateOldPanels to enable granular tracking", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "autoMigrateTablePanel", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Migrate old table panel to supported table panel - broken out from autoMigrateOldPanels to enable granular tracking", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "individualCookiePreferences", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Support overriding cookie preferences per user", + "stage": "experimental", + "codeowner": "@grafana/backend-platform" + } + }, + { + "metadata": { + "name": "disableSSEDataplane", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Disables dataplane specific processing in server side expressions.", + "stage": "experimental", + "codeowner": "@grafana/observability-metrics" + } + }, + { + "metadata": { + "name": "externalCorePlugins", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Allow core plugins to be loaded as external", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend" + } + }, + { + "metadata": { + "name": "pluginsAPIMetrics", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Sends metrics of public grafana packages usage by plugins", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true + } + }, + { + "metadata": { + "name": "dashboardSceneSolo", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables rendering dashboards using scenes for solo panels", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "datasourceQueryMultiStatus", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Introduce HTTP 207 Multi Status for api/ds/query", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend" + } + }, + { + "metadata": { + "name": "datatrails", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables the new core app datatrails", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "kubernetesFeatureToggles", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Use the kubernetes API for feature toggle management in the frontend", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad", + "frontend": true, + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "sqlExpressions", + "resourceVersion": "1709648236447", + "creationTimestamp": "2024-03-05T14:17:16Z" + }, + "spec": { + "description": "Enables using SQL and DuckDB functions as Expressions.", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad" + } + }, + { + "metadata": { + "name": "betterPageScrolling", + "resourceVersion": "1709583501630", + "creationTimestamp": "2024-03-04T20:18:21Z" + }, + "spec": { + "description": "Removes CustomScrollbar from the UI, relying on native browser scrollbars", + "stage": "GA", + "codeowner": "@grafana/grafana-frontend-platform", + "frontend": true + } + }, + { + "metadata": { + "name": "emailVerificationEnforcement", + "resourceVersion": "1710164083965", + "creationTimestamp": "2024-03-11T13:34:43Z" + }, + "spec": { + "description": "Force email verification for users, even when authenticating through sso.", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "ssoSettingsSAML", + "resourceVersion": "1710411764296", + "creationTimestamp": "2024-03-14T09:41:17Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-03-14 10:22:44.296694 +0000 UTC" + } + }, + "spec": { + "description": "Use the new SSO Settings API to configure the SAML connector", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "hideFromAdminPage": true, + "hideFromDocs": true + } + } + ] +} \ No newline at end of file diff --git a/pkg/services/featuremgmt/toggles_gen_test.go b/pkg/services/featuremgmt/toggles_gen_test.go index c144cb8a46c1c..6881e21d07bb0 100644 --- a/pkg/services/featuremgmt/toggles_gen_test.go +++ b/pkg/services/featuremgmt/toggles_gen_test.go @@ -3,6 +3,7 @@ package featuremgmt import ( "bytes" "encoding/csv" + "encoding/json" "fmt" "html/template" "log" @@ -16,7 +17,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/olekukonko/tablewriter" "github.com/stretchr/testify/require" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + featuretoggleapi "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" + "github.com/grafana/grafana/pkg/services/apiserver/utils" "github.com/grafana/grafana/pkg/services/featuremgmt/strcase" ) @@ -26,6 +30,9 @@ func TestFeatureToggleFiles(t *testing.T) { } t.Run("check registry constraints", func(t *testing.T) { + invalidNames := make([]string, 0) + + // Check that all flags set in code are valid for _, flag := range standardFeatureFlags { if flag.Expression == "true" && !(flag.Stage == FeatureStageGeneralAvailability || flag.Stage == FeatureStageDeprecated) { t.Errorf("only FeatureStageGeneralAvailability or FeatureStageDeprecated features can be enabled by default. See: %s", flag.Name) @@ -42,22 +49,108 @@ func TestFeatureToggleFiles(t *testing.T) { if flag.Name != strings.TrimSpace(flag.Name) { t.Errorf("flag Name should not start/end with spaces. See: %s", flag.Name) } - if flag.AllowSelfServe && flag.Stage != FeatureStageGeneralAvailability { - t.Errorf("only allow self-serving GA toggles") - } - if flag.Created.Year() < 2021 { - t.Errorf("flag requires a reasonable created date. See: %s (%s)", - flag.Name, flag.Created.Format(time.DateOnly)) + if flag.AllowSelfServe && !(flag.Stage == FeatureStageGeneralAvailability || flag.Stage == FeatureStagePublicPreview || flag.Stage == FeatureStageDeprecated) { + t.Errorf("only allow self-serving GA, PublicPreview and Deprecated toggles") } - } - }) - - t.Run("all new features should have an owner", func(t *testing.T) { - for _, flag := range standardFeatureFlags { if flag.Owner == "" { t.Errorf("feature %s does not have an owner. please fill the FeatureFlag.Owner property", flag.Name) } + // Check camel case names + if flag.Name != strcase.ToLowerCamel(flag.Name) && !legacyNames[flag.Name] { + invalidNames = append(invalidNames, flag.Name) + } } + + // Make sure the names are valid + require.Empty(t, invalidNames, "%s feature names should be camel cased", invalidNames) + // acronyms can be configured as needed via `ConfigureAcronym` function from `./strcase/camel.go` + + // Now that we know they are valid, update the json database + t.Run("update k8s resource list", func(t *testing.T) { + created := v1.NewTime(time.Now().UTC()) + resourceVersion := fmt.Sprintf("%d", created.UnixMilli()) + + featuresFile := "toggles_gen.json" + current := featuretoggleapi.FeatureList{ + TypeMeta: v1.TypeMeta{ + Kind: "FeatureList", + APIVersion: featuretoggleapi.APIVERSION, + }, + } + existing := featuretoggleapi.FeatureList{} + body, err := os.ReadFile(featuresFile) + if err == nil { + _ = json.Unmarshal(body, &existing) + current.ListMeta = existing.ListMeta + } + + lookup := map[string]featuretoggleapi.FeatureSpec{} + for _, flag := range standardFeatureFlags { + lookup[flag.Name] = featuretoggleapi.FeatureSpec{ + Description: flag.Description, + Stage: flag.Stage.String(), + Owner: string(flag.Owner), + RequiresDevMode: flag.RequiresDevMode, + FrontendOnly: flag.FrontendOnly, + RequiresRestart: flag.RequiresRestart, + AllowSelfServe: flag.AllowSelfServe, + HideFromAdminPage: flag.HideFromAdminPage, + HideFromDocs: flag.HideFromDocs, + // EnabledVersion: ???, + } + + // Replace them all + // current.Items = append(current.Items, featuretoggleapi.Feature{ + // ObjectMeta: v1.ObjectMeta{ + // Name: flag.Name, + // CreationTimestamp: v1.NewTime(flag.Created), + // ResourceVersion: fmt.Sprintf("%d", flag.Created.UnixMilli()), + // }, + // Spec: lookup[flag.Name], + // }) + // current.ListMeta.ResourceVersion = resourceVersion + } + + // Check for changes in any existing values + for _, item := range existing.Items { + v, ok := lookup[item.Name] + if ok { + delete(lookup, item.Name) + a, e1 := json.Marshal(v) + b, e2 := json.Marshal(item.Spec) + if e1 != nil || e2 != nil || !bytes.Equal(a, b) { + item.ResourceVersion = resourceVersion + if item.Annotations == nil { + item.Annotations = make(map[string]string) + } + item.Annotations[utils.AnnoKeyUpdatedTimestamp] = created.String() + item.Spec = v // the current value + } + } else if item.DeletionTimestamp == nil { + item.DeletionTimestamp = &created + fmt.Printf("mark feature as deleted") + } + current.Items = append(current.Items, item) + } + + // New flags not in the existing list + for k, v := range lookup { + current.Items = append(current.Items, featuretoggleapi.Feature{ + ObjectMeta: v1.ObjectMeta{ + Name: k, + CreationTimestamp: created, + ResourceVersion: fmt.Sprintf("%d", created.UnixMilli()), + }, + Spec: v, + }) + } + + out, err := json.MarshalIndent(current, "", " ") + require.NoError(t, err) + + err = os.WriteFile(featuresFile, out, 0644) + require.NoError(t, err, "error writing file") + }) }) t.Run("verify files", func(t *testing.T) { @@ -85,22 +178,6 @@ func TestFeatureToggleFiles(t *testing.T) { generateCSV(), ) }) - - t.Run("check feature naming convention", func(t *testing.T) { - invalidNames := make([]string, 0) - for _, f := range standardFeatureFlags { - if legacyNames[f.Name] { - continue - } - - if f.Name != strcase.ToLowerCamel(f.Name) { - invalidNames = append(invalidNames, f.Name) - } - } - - require.Empty(t, invalidNames, "%s feature names should be camel cased", invalidNames) - // acronyms can be configured as needed via `ConfigureAcronym` function from `./strcase/camel.go` - }) } func verifyAndGenerateFile(t *testing.T, fpath string, gen string) { @@ -214,32 +291,21 @@ func generateCSV() string { w := csv.NewWriter(&buf) if err := w.Write([]string{ "Name", - "Stage", //flag.Stage.String(), - "Owner", //string(flag.Owner), - "Created", + "Stage", //flag.Stage.String(), + "Owner", //string(flag.Owner), "requiresDevMode", //strconv.FormatBool(flag.RequiresDevMode), - "RequiresLicense", //strconv.FormatBool(flag.RequiresLicense), "RequiresRestart", //strconv.FormatBool(flag.RequiresRestart), "FrontendOnly", //strconv.FormatBool(flag.FrontendOnly), }); err != nil { log.Fatalln("error writing record to csv:", err) } - dateFormatter := func(t time.Time) string { - if t.Year() < 2020 { // fake year - return "" - } - return t.Format(time.DateOnly) - } - for _, flag := range standardFeatureFlags { if err := w.Write([]string{ flag.Name, flag.Stage.String(), string(flag.Owner), - dateFormatter(flag.Created), strconv.FormatBool(flag.RequiresDevMode), - strconv.FormatBool(flag.RequiresLicense), strconv.FormatBool(flag.RequiresRestart), strconv.FormatBool(flag.FrontendOnly), }); err != nil { diff --git a/pkg/services/featuremgmt/usage_stats_test.go b/pkg/services/featuremgmt/usage_stats_test.go index 03c7fe0c2dfef..a24b86cbef6b6 100644 --- a/pkg/services/featuremgmt/usage_stats_test.go +++ b/pkg/services/featuremgmt/usage_stats_test.go @@ -8,7 +8,7 @@ import ( ) func TestFeatureUsageStats(t *testing.T) { - featureManagerWithAllFeatures := WithFeatures( + featureManagerWithAllFeatures := WithManager( "database_metrics", "live-config", "UPPER_SNAKE_CASE", diff --git a/pkg/services/folder/folderimpl/dashboard_folder_store.go b/pkg/services/folder/folderimpl/dashboard_folder_store.go index aef13d84b0a1f..65ff76e05700c 100644 --- a/pkg/services/folder/folderimpl/dashboard_folder_store.go +++ b/pkg/services/folder/folderimpl/dashboard_folder_store.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" ) @@ -19,17 +20,24 @@ func ProvideDashboardFolderStore(sqlStore db.DB) *DashboardFolderStoreImpl { return &DashboardFolderStoreImpl{store: sqlStore} } -func (d *DashboardFolderStoreImpl) GetFolderByTitle(ctx context.Context, orgID int64, title string) (*folder.Folder, error) { +func (d *DashboardFolderStoreImpl) GetFolderByTitle(ctx context.Context, orgID int64, title string, folderUID *string) (*folder.Folder, error) { if title == "" { return nil, dashboards.ErrFolderTitleEmpty } - // there is a unique constraint on org_id, folder_id, title + // there is a unique constraint on org_id, folder_uid, title // there are no nested folders so the parent folder id is always 0 + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() // nolint:staticcheck dashboard := dashboards.Dashboard{OrgID: orgID, FolderID: 0, Title: title} err := d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - has, err := sess.Table(&dashboards.Dashboard{}).Where("is_folder = " + d.store.GetDialect().BooleanStr(true)).Where("folder_id=0").Get(&dashboard) + s := sess.Table(&dashboards.Dashboard{}).Where("is_folder = " + d.store.GetDialect().BooleanStr(true)) + if folderUID != nil { + s = s.Where("folder_uid = ?", *folderUID) + } else { + s = s.Where("folder_uid IS NULL") + } + has, err := s.Get(&dashboard) if err != nil { return err } @@ -44,10 +52,11 @@ func (d *DashboardFolderStoreImpl) GetFolderByTitle(ctx context.Context, orgID i } func (d *DashboardFolderStoreImpl) GetFolderByID(ctx context.Context, orgID int64, id int64) (*folder.Folder, error) { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() // nolint:staticcheck dashboard := dashboards.Dashboard{OrgID: orgID, FolderID: 0, ID: id} err := d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - has, err := sess.Table(&dashboards.Dashboard{}).Where("is_folder = " + d.store.GetDialect().BooleanStr(true)).Where("folder_id=0").Get(&dashboard) + has, err := sess.Table(&dashboards.Dashboard{}).Where("is_folder = " + d.store.GetDialect().BooleanStr(true)).Get(&dashboard) if err != nil { return err } @@ -68,11 +77,11 @@ func (d *DashboardFolderStoreImpl) GetFolderByUID(ctx context.Context, orgID int if uid == "" { return nil, dashboards.ErrDashboardIdentifierNotSet } - + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() // nolint:staticcheck dashboard := dashboards.Dashboard{OrgID: orgID, FolderID: 0, UID: uid} err := d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - has, err := sess.Table(&dashboards.Dashboard{}).Where("is_folder = " + d.store.GetDialect().BooleanStr(true)).Where("folder_id=0").Get(&dashboard) + has, err := sess.Table(&dashboards.Dashboard{}).Where("is_folder = " + d.store.GetDialect().BooleanStr(true)).Get(&dashboard) if err != nil { return err } @@ -100,7 +109,7 @@ func (d *DashboardFolderStoreImpl) GetFolders(ctx context.Context, orgID int64, b := strings.Builder{} args := make([]any, 0, len(uids)+1) - b.WriteString("SELECT * FROM dashboard WHERE org_id=?") + b.WriteString("SELECT * FROM dashboard WHERE org_id=? AND is_folder = " + d.store.GetDialect().BooleanStr(true)) args = append(args, orgID) if len(uids) == 1 { diff --git a/pkg/services/folder/folderimpl/dashboard_folder_store_test.go b/pkg/services/folder/folderimpl/dashboard_folder_store_test.go index 997714c1a23fb..12d8c65c1b43e 100644 --- a/pkg/services/folder/folderimpl/dashboard_folder_store_test.go +++ b/pkg/services/folder/folderimpl/dashboard_folder_store_test.go @@ -15,8 +15,14 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +// run tests with cleanup +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationDashboardFolderStore(t *testing.T) { var sqlStore *sqlstore.SQLStore var cfg *setting.Cfg @@ -37,29 +43,35 @@ func TestIntegrationDashboardFolderStore(t *testing.T) { var folder1, folder2 *dashboards.Dashboard sqlStore = db.InitTestDB(t) folderStore := ProvideDashboardFolderStore(sqlStore) - folder2 = insertTestFolder(t, dashboardStore, "TEST", orgId, 0, "prod") + folder2 = insertTestFolder(t, dashboardStore, "TEST", orgId, "", "prod") _ = insertTestDashboard(t, dashboardStore, title, orgId, folder2.ID, folder2.UID, "prod") - folder1 = insertTestFolder(t, dashboardStore, title, orgId, 0, "prod") + folder1 = insertTestFolder(t, dashboardStore, title, orgId, "", "prod") t.Run("GetFolderByTitle should find the folder", func(t *testing.T) { - result, err := folderStore.GetFolderByTitle(context.Background(), orgId, title) + result, err := folderStore.GetFolderByTitle(context.Background(), orgId, title, nil) + require.NoError(t, err) + require.Equal(t, folder1.UID, result.UID) + }) + + t.Run("GetFolderByTitle should find the folder by folderUID", func(t *testing.T) { + folder3 := insertTestFolder(t, dashboardStore, title, orgId, folder2.UID, "prod") + result, err := folderStore.GetFolderByTitle(context.Background(), orgId, title, &folder2.UID) require.NoError(t, err) - // nolint:staticcheck - require.Equal(t, folder1.ID, result.ID) + require.Equal(t, folder3.UID, result.UID) }) }) t.Run("GetFolderByUID", func(t *testing.T) { + setup() var orgId int64 = 1 sqlStore := db.InitTestDB(t) folderStore := ProvideDashboardFolderStore(sqlStore) - folder := insertTestFolder(t, dashboardStore, "TEST", orgId, 0, "prod") + folder := insertTestFolder(t, dashboardStore, "TEST", orgId, "", "prod") dash := insertTestDashboard(t, dashboardStore, "Very Unique Name", orgId, folder.ID, folder.UID, "prod") t.Run("should return folder by UID", func(t *testing.T) { d, err := folderStore.GetFolderByUID(context.Background(), orgId, folder.UID) - // nolint:staticcheck - require.Equal(t, folder.ID, d.ID) + require.Equal(t, folder.UID, d.UID) require.NoError(t, err) }) t.Run("should not find dashboard", func(t *testing.T) { @@ -75,16 +87,16 @@ func TestIntegrationDashboardFolderStore(t *testing.T) { }) t.Run("GetFolderByID", func(t *testing.T) { + setup() var orgId int64 = 1 sqlStore := db.InitTestDB(t) folderStore := ProvideDashboardFolderStore(sqlStore) - folder := insertTestFolder(t, dashboardStore, "TEST", orgId, 0, "prod") + folder := insertTestFolder(t, dashboardStore, "TEST", orgId, "", "prod") dash := insertTestDashboard(t, dashboardStore, "Very Unique Name", orgId, folder.ID, folder.UID, "prod") t.Run("should return folder by ID", func(t *testing.T) { d, err := folderStore.GetFolderByID(context.Background(), orgId, folder.ID) - // nolint:staticcheck - require.Equal(t, folder.ID, d.ID) + require.Equal(t, folder.UID, d.UID) require.NoError(t, err) }) t.Run("should not find dashboard", func(t *testing.T) { @@ -100,12 +112,12 @@ func TestIntegrationDashboardFolderStore(t *testing.T) { }) } -func insertTestDashboard(t *testing.T, dashboardStore dashboards.Store, title string, orgId int64, folderID int64, folderUID string, tags ...any) *dashboards.Dashboard { +func insertTestDashboard(t *testing.T, dashboardStore dashboards.Store, title string, orgId, folderID int64, folderUID string, tags ...any) *dashboards.Dashboard { t.Helper() cmd := dashboards.SaveDashboardCommand{ OrgID: orgId, - FolderID: folderID, // nolint:staticcheck FolderUID: folderUID, + FolderID: folderID, // nolint:staticcheck IsFolder: false, Dashboard: simplejson.NewFromAny(map[string]any{ "id": nil, @@ -113,6 +125,9 @@ func insertTestDashboard(t *testing.T, dashboardStore dashboards.Store, title st "tags": tags, }), } + if folderUID != "" { + cmd.FolderUID = folderUID + } dash, err := dashboardStore.SaveDashboard(context.Background(), cmd) require.NoError(t, err) require.NotNil(t, dash) @@ -121,11 +136,11 @@ func insertTestDashboard(t *testing.T, dashboardStore dashboards.Store, title st return dash } -func insertTestFolder(t *testing.T, dashboardStore dashboards.Store, title string, orgId int64, folderId int64, folderUID string, tags ...any) *dashboards.Dashboard { +func insertTestFolder(t *testing.T, dashboardStore dashboards.Store, title string, orgId int64, folderUID string, tags ...any) *dashboards.Dashboard { t.Helper() cmd := dashboards.SaveDashboardCommand{ - OrgID: orgId, - FolderID: folderId, // nolint:staticcheck + OrgID: orgId, + // FolderID: folderId, // nolint:staticcheck FolderUID: folderUID, IsFolder: true, Dashboard: simplejson.NewFromAny(map[string]any{ @@ -134,6 +149,9 @@ func insertTestFolder(t *testing.T, dashboardStore dashboards.Store, title strin "tags": tags, }), } + if folderUID != "" { + cmd.FolderUID = folderUID + } dash, err := dashboardStore.SaveDashboard(context.Background(), cmd) require.NoError(t, err) require.NotNil(t, dash) diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index 5ac459669d9b5..c7a74ff7d2b9e 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -2,6 +2,7 @@ package folderimpl import ( "context" + "encoding/json" "errors" "fmt" "runtime" @@ -9,21 +10,27 @@ import ( "sync" "time" + "github.com/grafana/dskit/concurrency" "github.com/prometheus/client_golang/prometheus" "golang.org/x/exp/slices" - "github.com/grafana/dskit/concurrency" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/store/entity" + "github.com/grafana/grafana/pkg/services/supportbundles" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -37,7 +44,6 @@ type Service struct { dashboardFolderStore folder.FolderStore features featuremgmt.FeatureToggles accessControl accesscontrol.AccessControl - // bus is currently used to publish event in case of title change bus bus.Bus @@ -54,9 +60,10 @@ func ProvideService( folderStore folder.FolderStore, db db.DB, // DB for the (new) nested folder store features featuremgmt.FeatureToggles, + supportBundles supportbundles.Service, r prometheus.Registerer, ) folder.Service { - store := ProvideStore(db, cfg, features) + store := ProvideStore(db, cfg) srv := &Service{ cfg: cfg, log: log.New("folder-service"), @@ -70,6 +77,9 @@ func ProvideService( registry: make(map[string]folder.RegistryService), metrics: newFoldersMetrics(r), } + srv.DBMigration(db) + + supportBundles.RegisterSupportItemCollector(srv.supportBundleCollector()) ac.RegisterScopeAttributeResolver(dashboards.NewFolderNameScopeResolver(folderStore, srv)) ac.RegisterScopeAttributeResolver(dashboards.NewFolderIDScopeResolver(folderStore, srv)) @@ -77,31 +87,134 @@ func ProvideService( return srv } -func (s *Service) Get(ctx context.Context, cmd *folder.GetFolderQuery) (*folder.Folder, error) { - if cmd.SignedInUser == nil { +func (s *Service) DBMigration(db db.DB) { + s.log.Debug("syncing dashboard and folder tables started") + + ctx := context.Background() + err := db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + var err error + if db.GetDialect().DriverName() == migrator.SQLite { + // covered by UQE_folder_org_id_uid + _, err = sess.Exec(` + INSERT INTO folder (uid, org_id, title, created, updated) + SELECT uid, org_id, title, created, updated FROM dashboard WHERE is_folder = 1 + ON CONFLICT DO UPDATE SET title=excluded.title, updated=excluded.updated + `) + } else if db.GetDialect().DriverName() == migrator.Postgres { + // covered by UQE_folder_org_id_uid + _, err = sess.Exec(` + INSERT INTO folder (uid, org_id, title, created, updated) + SELECT uid, org_id, title, created, updated FROM dashboard WHERE is_folder = true + ON CONFLICT(uid, org_id) DO UPDATE SET title=excluded.title, updated=excluded.updated + `) + } else { + // covered by UQE_folder_org_id_uid + _, err = sess.Exec(` + INSERT INTO folder (uid, org_id, title, created, updated) + SELECT * FROM (SELECT uid, org_id, title, created, updated FROM dashboard WHERE is_folder = 1) AS derived + ON DUPLICATE KEY UPDATE title=derived.title, updated=derived.updated + `) + } + if err != nil { + return err + } + + // covered by UQE_folder_org_id_uid + _, err = sess.Exec(` + DELETE FROM folder WHERE NOT EXISTS + (SELECT 1 FROM dashboard WHERE dashboard.uid = folder.uid AND dashboard.org_id = folder.org_id AND dashboard.is_folder = true) + `) + return err + }) + if err != nil { + s.log.Error("DB migration on folder service start failed.", "err", err) + } + + s.log.Debug("syncing dashboard and folder tables finished") +} + +func (s *Service) GetFolders(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) { + if q.SignedInUser == nil { + return nil, folder.ErrBadRequest.Errorf("missing signed in user") + } + + qry := NewGetFoldersQuery(q) + permissions := q.SignedInUser.GetPermissions() + folderPermissions := permissions[dashboards.ActionFoldersRead] + qry.ancestorUIDs = make([]string, 0, len(folderPermissions)) + if len(folderPermissions) == 0 && !q.SignedInUser.GetIsGrafanaAdmin() { + return nil, nil + } + for _, p := range folderPermissions { + if p == dashboards.ScopeFoldersAll { + // no need to query for folders with permissions + // the user has permission to access all folders + qry.ancestorUIDs = nil + break + } + if folderUid, found := strings.CutPrefix(p, dashboards.ScopeFoldersPrefix); found { + if !slices.Contains(qry.ancestorUIDs, folderUid) { + qry.ancestorUIDs = append(qry.ancestorUIDs, folderUid) + } + } + } + + if !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) { + qry.WithFullpath = false // do not request full path if nested folders are disabled + qry.WithFullpathUIDs = false + } + + dashFolders, err := s.store.GetFolders(ctx, qry) + if err != nil { + return nil, folder.ErrInternal.Errorf("failed to fetch subfolders: %w", err) + } + + if !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) { + if q.WithFullpathUIDs || q.WithFullpath { + for _, f := range dashFolders { // and fix the full path with folder title (unescaped) + if q.WithFullpath { + f.Fullpath = f.Title + } + if q.WithFullpathUIDs { + f.FullpathUIDs = f.UID + } + } + } + } + + return dashFolders, nil +} + +func (s *Service) Get(ctx context.Context, q *folder.GetFolderQuery) (*folder.Folder, error) { + if q.SignedInUser == nil { return nil, folder.ErrBadRequest.Errorf("missing signed in user") } - if s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) && cmd.UID != nil && *cmd.UID == folder.SharedWithMeFolderUID { + if q.UID != nil && *q.UID == accesscontrol.GeneralFolderUID { + return folder.RootFolder, nil + } + + if s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) && q.UID != nil && *q.UID == folder.SharedWithMeFolderUID { return folder.SharedWithMeFolder.WithURL(), nil } var dashFolder *folder.Folder var err error switch { - case cmd.UID != nil && *cmd.UID != "": - dashFolder, err = s.getFolderByUID(ctx, cmd.OrgID, *cmd.UID) + case q.UID != nil && *q.UID != "": + dashFolder, err = s.getFolderByUID(ctx, q.OrgID, *q.UID) if err != nil { return nil, err } // nolint:staticcheck - case cmd.ID != nil: - dashFolder, err = s.getFolderByID(ctx, *cmd.ID, cmd.OrgID) + case q.ID != nil: + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() + dashFolder, err = s.getFolderByID(ctx, *q.ID, q.OrgID) if err != nil { return nil, err } - case cmd.Title != nil: - dashFolder, err = s.getFolderByTitle(ctx, cmd.OrgID, *cmd.Title) + case q.Title != nil: + dashFolder, err = s.getFolderByTitle(ctx, q.OrgID, *q.Title, q.ParentUID) if err != nil { return nil, err } @@ -116,7 +229,7 @@ func (s *Service) Get(ctx context.Context, cmd *folder.GetFolderQuery) (*folder. // do not get guardian by the folder ID because it differs from the nested folder ID // and the legacy folder ID has been associated with the permissions: // use the folde UID instead that is the same for both - g, err := guardian.NewByFolder(ctx, dashFolder, dashFolder.OrgID, cmd.SignedInUser) + g, err := guardian.NewByFolder(ctx, dashFolder, dashFolder.OrgID, q.SignedInUser) if err != nil { return nil, err } @@ -129,29 +242,44 @@ func (s *Service) Get(ctx context.Context, cmd *folder.GetFolderQuery) (*folder. } if !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) { + dashFolder.Fullpath = dashFolder.Title return dashFolder, nil } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() // nolint:staticcheck - if cmd.ID != nil { - cmd.ID = nil - cmd.UID = &dashFolder.UID + if q.ID != nil { + q.ID = nil + q.UID = &dashFolder.UID } - f, err := s.store.Get(ctx, *cmd) + f, err := s.store.Get(ctx, *q) if err != nil { return nil, err } // always expose the dashboard store sequential ID + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() // nolint:staticcheck f.ID = dashFolder.ID f.Version = dashFolder.Version + if !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) { + f.Fullpath = f.Title // set full path to the folder title (unescaped) + } + return f, err } func (s *Service) GetChildren(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.Folder, error) { + defer func(t time.Time) { + parent := q.UID + if q.UID != folder.SharedWithMeFolderUID { + parent = "folder" + } + s.metrics.foldersGetChildrenRequestsDuration.WithLabelValues(parent).Observe(time.Since(t).Seconds()) + }(time.Now()) + if q.SignedInUser == nil { return nil, folder.ErrBadRequest.Errorf("missing signed in user") } @@ -171,12 +299,16 @@ func (s *Service) GetChildren(ctx context.Context, q *folder.GetChildrenQuery) ( return nil, err } - canView, err := g.CanView() + guardianFunc := g.CanView + if q.Permission == dashboardaccess.PERMISSION_EDIT { + guardianFunc = g.CanEdit + } + + hasAccess, err := guardianFunc() if err != nil { return nil, err } - - if !canView { + if !hasAccess { return nil, dashboards.ErrFolderAccessDenied } @@ -204,6 +336,7 @@ func (s *Service) GetChildren(ctx context.Context, q *folder.GetChildrenQuery) ( } // always expose the dashboard store sequential ID + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() // nolint:staticcheck f.ID = dashFolder.ID } @@ -213,8 +346,19 @@ func (s *Service) GetChildren(ctx context.Context, q *folder.GetChildrenQuery) ( func (s *Service) getRootFolders(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.Folder, error) { permissions := q.SignedInUser.GetPermissions() - folderPermissions := permissions[dashboards.ActionFoldersRead] - folderPermissions = append(folderPermissions, permissions[dashboards.ActionDashboardsRead]...) + var folderPermissions []string + if q.Permission == dashboardaccess.PERMISSION_EDIT { + folderPermissions = permissions[dashboards.ActionFoldersWrite] + folderPermissions = append(folderPermissions, permissions[dashboards.ActionDashboardsWrite]...) + } else { + folderPermissions = permissions[dashboards.ActionFoldersRead] + folderPermissions = append(folderPermissions, permissions[dashboards.ActionDashboardsRead]...) + } + + if len(folderPermissions) == 0 && !q.SignedInUser.GetIsGrafanaAdmin() { + return nil, nil + } + q.FolderUIDs = make([]string, 0, len(folderPermissions)) for _, p := range folderPermissions { if p == dashboards.ScopeFoldersAll { @@ -253,6 +397,7 @@ func (s *Service) getRootFolders(ctx context.Context, q *folder.GetChildrenQuery s.log.Error("failed to fetch folder by UID from dashboard store", "orgID", f.OrgID, "uid", f.UID) } // always expose the dashboard store sequential ID + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() // nolint:staticcheck f.ID = dashFolder.ID @@ -270,27 +415,39 @@ func (s *Service) getRootFolders(ctx context.Context, q *folder.GetChildrenQuery } // GetSharedWithMe returns folders available to user, which cannot be accessed from the root folders -func (s *Service) GetSharedWithMe(ctx context.Context, cmd *folder.GetChildrenQuery) ([]*folder.Folder, error) { +func (s *Service) GetSharedWithMe(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.Folder, error) { start := time.Now() - availableNonRootFolders, err := s.getAvailableNonRootFolders(ctx, cmd.OrgID, cmd.SignedInUser) + availableNonRootFolders, err := s.getAvailableNonRootFolders(ctx, q) if err != nil { s.metrics.sharedWithMeFetchFoldersRequestsDuration.WithLabelValues("failure").Observe(time.Since(start).Seconds()) return nil, folder.ErrInternal.Errorf("failed to fetch subfolders to which the user has explicit access: %w", err) } - rootFolders, err := s.GetChildren(ctx, &folder.GetChildrenQuery{UID: "", OrgID: cmd.OrgID, SignedInUser: cmd.SignedInUser}) + rootFolders, err := s.GetChildren(ctx, &folder.GetChildrenQuery{UID: "", OrgID: q.OrgID, SignedInUser: q.SignedInUser, Permission: q.Permission}) if err != nil { s.metrics.sharedWithMeFetchFoldersRequestsDuration.WithLabelValues("failure").Observe(time.Since(start).Seconds()) return nil, folder.ErrInternal.Errorf("failed to fetch root folders to which the user has access: %w", err) } - availableNonRootFolders = s.deduplicateAvailableFolders(ctx, availableNonRootFolders, rootFolders) + + availableNonRootFolders = s.deduplicateAvailableFolders(ctx, availableNonRootFolders, rootFolders, q.OrgID) s.metrics.sharedWithMeFetchFoldersRequestsDuration.WithLabelValues("success").Observe(time.Since(start).Seconds()) return availableNonRootFolders, nil } -func (s *Service) getAvailableNonRootFolders(ctx context.Context, orgID int64, user identity.Requester) ([]*folder.Folder, error) { - permissions := user.GetPermissions() - folderPermissions := permissions[dashboards.ActionFoldersRead] - folderPermissions = append(folderPermissions, permissions[dashboards.ActionDashboardsRead]...) +func (s *Service) getAvailableNonRootFolders(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.Folder, error) { + permissions := q.SignedInUser.GetPermissions() + var folderPermissions []string + if q.Permission == dashboardaccess.PERMISSION_EDIT { + folderPermissions = permissions[dashboards.ActionFoldersWrite] + folderPermissions = append(folderPermissions, permissions[dashboards.ActionDashboardsWrite]...) + } else { + folderPermissions = permissions[dashboards.ActionFoldersRead] + folderPermissions = append(folderPermissions, permissions[dashboards.ActionDashboardsRead]...) + } + + if len(folderPermissions) == 0 { + return nil, nil + } + nonRootFolders := make([]*folder.Folder, 0) folderUids := make([]string, 0, len(folderPermissions)) for _, p := range folderPermissions { @@ -305,7 +462,13 @@ func (s *Service) getAvailableNonRootFolders(ctx context.Context, orgID int64, u return nonRootFolders, nil } - dashFolders, err := s.store.GetFolders(ctx, orgID, folderUids) + dashFolders, err := s.GetFolders(ctx, folder.GetFoldersQuery{ + UIDs: folderUids, + OrgID: q.OrgID, + SignedInUser: q.SignedInUser, + OrderByTitle: true, + WithFullpathUIDs: true, + }) if err != nil { return nil, folder.ErrInternal.Errorf("failed to fetch subfolders: %w", err) } @@ -319,24 +482,28 @@ func (s *Service) getAvailableNonRootFolders(ctx context.Context, orgID int64, u return nonRootFolders, nil } -func (s *Service) deduplicateAvailableFolders(ctx context.Context, folders []*folder.Folder, rootFolders []*folder.Folder) []*folder.Folder { +func (s *Service) deduplicateAvailableFolders(ctx context.Context, folders []*folder.Folder, rootFolders []*folder.Folder, orgID int64) []*folder.Folder { allFolders := append(folders, rootFolders...) foldersDedup := make([]*folder.Folder, 0) + for _, f := range folders { isSubfolder := slices.ContainsFunc(allFolders, func(folder *folder.Folder) bool { return f.ParentUID == folder.UID }) if !isSubfolder { - parents, err := s.GetParents(ctx, folder.GetParentsQuery{UID: f.UID, OrgID: f.OrgID}) - if err != nil { - s.log.Error("failed to fetch folder parents", "uid", f.UID, "error", err) - continue + // Get parents UIDs + parentUIDs := make([]string, 0) + pathUIDs := strings.Split(f.FullpathUIDs, "/") + for _, p := range pathUIDs { + if p != "" && p != f.UID { + parentUIDs = append(parentUIDs, p) + } } - for _, parent := range parents { + for _, parentUID := range parentUIDs { contains := slices.ContainsFunc(allFolders, func(f *folder.Folder) bool { - return f.UID == parent.UID + return f.UID == parentUID }) if contains { isSubfolder = true @@ -353,7 +520,7 @@ func (s *Service) deduplicateAvailableFolders(ctx context.Context, folders []*fo } func (s *Service) GetParents(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) { - if !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) { + if !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) || q.UID == accesscontrol.GeneralFolderUID { return nil, nil } if q.UID == folder.SharedWithMeFolderUID { @@ -374,8 +541,8 @@ func (s *Service) getFolderByUID(ctx context.Context, orgID int64, uid string) ( return s.dashboardFolderStore.GetFolderByUID(ctx, orgID, uid) } -func (s *Service) getFolderByTitle(ctx context.Context, orgID int64, title string) (*folder.Folder, error) { - return s.dashboardFolderStore.GetFolderByTitle(ctx, orgID, title) +func (s *Service) getFolderByTitle(ctx context.Context, orgID int64, title string, parentUID *string) (*folder.Folder, error) { + return s.dashboardFolderStore.GetFolderByTitle(ctx, orgID, title, parentUID) } func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { @@ -457,7 +624,7 @@ func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) ( // well, but for now we take the UID from the newly created folder. UID: dash.UID, OrgID: cmd.OrgID, - Title: cmd.Title, + Title: dashFolder.Title, Description: cmd.Description, ParentUID: cmd.ParentUID, } @@ -498,7 +665,7 @@ func (s *Service) Update(ctx context.Context, cmd *folder.UpdateFolderCommand) ( if foldr, err = s.store.Update(ctx, folder.UpdateFolderCommand{ UID: cmd.UID, OrgID: cmd.OrgID, - NewTitle: cmd.NewTitle, + NewTitle: &dashFolder.Title, NewDescription: cmd.NewDescription, SignedInUser: user, }); err != nil { @@ -508,7 +675,8 @@ func (s *Service) Update(ctx context.Context, cmd *folder.UpdateFolderCommand) ( if cmd.NewTitle != nil { namespace, id := cmd.SignedInUser.GetNamespacedID() - if err := s.bus.Publish(context.Background(), &events.FolderTitleUpdated{ + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() + if err := s.bus.Publish(ctx, &events.FolderTitleUpdated{ Timestamp: foldr.Updated, Title: foldr.Title, ID: dashFolder.ID, // nolint:staticcheck @@ -533,6 +701,7 @@ func (s *Service) Update(ctx context.Context, cmd *folder.UpdateFolderCommand) ( } // always expose the dashboard store sequential ID + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() // nolint:staticcheck foldr.ID = dashFolder.ID foldr.Version = dashFolder.Version @@ -645,71 +814,62 @@ func (s *Service) Delete(ctx context.Context, cmd *folder.DeleteFolderCommand) e return dashboards.ErrFolderAccessDenied } - result := []string{cmd.UID} + folders := []string{cmd.UID} err = s.db.InTransaction(ctx, func(ctx context.Context) error { - subfolders, err := s.nestedFolderDelete(ctx, cmd) + descendants, err := s.nestedFolderDelete(ctx, cmd) if err != nil { logger.Error("the delete folder on folder table failed with err: ", "error", err) return err } - result = append(result, subfolders...) + folders = append(folders, descendants...) - dashFolders, err := s.dashboardFolderStore.GetFolders(ctx, cmd.OrgID, result) - if err != nil { - return folder.ErrInternal.Errorf("failed to fetch subfolders from dashboard store: %w", err) - } - - for _, foldr := range result { - dashFolder, ok := dashFolders[foldr] - if !ok { - return folder.ErrInternal.Errorf("folder does not exist in dashboard store") + if cmd.ForceDeleteRules { + if err := s.deleteChildrenInFolder(ctx, cmd.OrgID, folders, cmd.SignedInUser); err != nil { + return err } - - if cmd.ForceDeleteRules { - if err := s.deleteChildrenInFolder(ctx, dashFolder.OrgID, dashFolder.UID, cmd.SignedInUser); err != nil { - return err - } - } else { - alertRuleSrv, ok := s.registry[entity.StandardKindAlertRule] - if !ok { - return folder.ErrInternal.Errorf("no alert rule service found in registry") - } - alertRulesInFolder, err := alertRuleSrv.CountInFolder(ctx, dashFolder.OrgID, dashFolder.UID, cmd.SignedInUser) - if err != nil { - s.log.Error("failed to count alert rules in folder", "error", err) - return err - } - if alertRulesInFolder > 0 { - return folder.ErrFolderNotEmpty.Errorf("folder contains %d alert rules", alertRulesInFolder) - } + } else { + alertRuleSrv, ok := s.registry[entity.StandardKindAlertRule] + if !ok { + return folder.ErrInternal.Errorf("no alert rule service found in registry") } - - if err = s.legacyDelete(ctx, cmd, dashFolder); err != nil { + alertRulesInFolder, err := alertRuleSrv.CountInFolders(ctx, cmd.OrgID, folders, cmd.SignedInUser) + if err != nil { + s.log.Error("failed to count alert rules in folder", "error", err) return err } + if alertRulesInFolder > 0 { + return folder.ErrFolderNotEmpty.Errorf("folder contains %d alert rules", alertRulesInFolder) + } + } + + if err = s.legacyDelete(ctx, cmd, folders); err != nil { + return err } + return nil }) return err } -func (s *Service) deleteChildrenInFolder(ctx context.Context, orgID int64, folderUID string, user identity.Requester) error { +func (s *Service) deleteChildrenInFolder(ctx context.Context, orgID int64, folderUIDs []string, user identity.Requester) error { for _, v := range s.registry { - if err := v.DeleteInFolder(ctx, orgID, folderUID, user); err != nil { + if err := v.DeleteInFolders(ctx, orgID, folderUIDs, user); err != nil { return err } } return nil } -func (s *Service) legacyDelete(ctx context.Context, cmd *folder.DeleteFolderCommand, dashFolder *folder.Folder) error { - // nolint:staticcheck - deleteCmd := dashboards.DeleteDashboardCommand{OrgID: cmd.OrgID, ID: dashFolder.ID, ForceDeleteFolderRules: cmd.ForceDeleteRules} +func (s *Service) legacyDelete(ctx context.Context, cmd *folder.DeleteFolderCommand, folderUIDs []string) error { + // TODO use bulk delete + for _, folderUID := range folderUIDs { + deleteCmd := dashboards.DeleteDashboardCommand{OrgID: cmd.OrgID, UID: folderUID, ForceDeleteFolderRules: cmd.ForceDeleteRules} - if err := s.dashboardStore.DeleteDashboard(ctx, &deleteCmd); err != nil { - return toFolderError(err) + if err := s.dashboardStore.DeleteDashboard(ctx, &deleteCmd); err != nil { + return toFolderError(err) + } } return nil } @@ -771,6 +931,9 @@ func (s *Service) Move(ctx context.Context, cmd *folder.MoveFolderCommand) (*fol NewParentUID: &newParentUID, SignedInUser: cmd.SignedInUser, }); err != nil { + if s.db.GetDialect().IsUniqueConstraintViolation(err) { + return folder.ErrConflict.Errorf("%w", dashboards.ErrFolderSameNameExists) + } return folder.ErrInternal.Errorf("failed to move folder: %w", err) } @@ -782,6 +945,9 @@ func (s *Service) Move(ctx context.Context, cmd *folder.MoveFolderCommand) (*fol // bypass optimistic locking used for dashboards Overwrite: true, }); err != nil { + if s.db.GetDialect().IsUniqueConstraintViolation(err) { + return folder.ErrConflict.Errorf("%w", dashboards.ErrFolderSameNameExists) + } return folder.ErrInternal.Errorf("failed to move legacy folder: %w", err) } @@ -797,9 +963,9 @@ func (s *Service) Move(ctx context.Context, cmd *folder.MoveFolderCommand) (*fol // the folder store and returns the UIDs for all its descendants. func (s *Service) nestedFolderDelete(ctx context.Context, cmd *folder.DeleteFolderCommand) ([]string, error) { logger := s.log.FromContext(ctx) - result := []string{} + descendantUIDs := []string{} if cmd.SignedInUser == nil { - return result, folder.ErrBadRequest.Errorf("missing signed in user") + return descendantUIDs, folder.ErrBadRequest.Errorf("missing signed in user") } _, err := s.Get(ctx, &folder.GetFolderQuery{ @@ -808,86 +974,63 @@ func (s *Service) nestedFolderDelete(ctx context.Context, cmd *folder.DeleteFold SignedInUser: cmd.SignedInUser, }) if err != nil { - return result, err + return descendantUIDs, err } - folders, err := s.store.GetChildren(ctx, folder.GetChildrenQuery{UID: cmd.UID, OrgID: cmd.OrgID}) + descendants, err := s.store.GetDescendants(ctx, cmd.OrgID, cmd.UID) if err != nil { - return result, err - } - for _, f := range folders { - result = append(result, f.UID) - logger.Info("deleting subfolder", "org_id", f.OrgID, "uid", f.UID) - subfolders, err := s.nestedFolderDelete(ctx, &folder.DeleteFolderCommand{UID: f.UID, OrgID: f.OrgID, ForceDeleteRules: cmd.ForceDeleteRules, SignedInUser: cmd.SignedInUser}) - if err != nil { - logger.Error("failed deleting subfolder", "org_id", f.OrgID, "uid", f.UID, "error", err) - return result, err - } - result = append(result, subfolders...) + logger.Error("failed to get descendant folders", "error", err) + return descendantUIDs, err } - logger.Info("deleting folder and its contents", "org_id", cmd.OrgID, "uid", cmd.UID) - err = s.store.Delete(ctx, cmd.UID, cmd.OrgID) + for _, f := range descendants { + descendantUIDs = append(descendantUIDs, f.UID) + } + logger.Info("deleting folder and its descendants", "org_id", cmd.OrgID, "uid", cmd.UID) + toDelete := append(descendantUIDs, cmd.UID) + err = s.store.Delete(ctx, toDelete, cmd.OrgID) if err != nil { logger.Info("failed deleting folder", "org_id", cmd.OrgID, "uid", cmd.UID, "err", err) - return result, err + return descendantUIDs, err } - return result, nil + return descendantUIDs, nil } -func (s *Service) GetDescendantCounts(ctx context.Context, cmd *folder.GetDescendantCountsQuery) (folder.DescendantCounts, error) { +func (s *Service) GetDescendantCounts(ctx context.Context, q *folder.GetDescendantCountsQuery) (folder.DescendantCounts, error) { logger := s.log.FromContext(ctx) - if cmd.SignedInUser == nil { + if q.SignedInUser == nil { return nil, folder.ErrBadRequest.Errorf("missing signed-in user") } - if *cmd.UID == "" { + if q.UID == nil || *q.UID == "" { return nil, folder.ErrBadRequest.Errorf("missing UID") } - if cmd.OrgID < 1 { + if q.OrgID < 1 { return nil, folder.ErrBadRequest.Errorf("invalid orgID") } - result := []string{*cmd.UID} + folders := []string{*q.UID} countsMap := make(folder.DescendantCounts, len(s.registry)+1) if s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) { - subfolders, err := s.getNestedFolders(ctx, cmd.OrgID, *cmd.UID) + descendantFolders, err := s.store.GetDescendants(ctx, q.OrgID, *q.UID) if err != nil { - logger.Error("failed to get subfolders", "error", err) + logger.Error("failed to get descendant folders", "error", err) return nil, err } - result = append(result, subfolders...) - countsMap[entity.StandardKindFolder] = int64(len(subfolders)) - } - - for _, v := range s.registry { - for _, folder := range result { - c, err := v.CountInFolder(ctx, cmd.OrgID, folder, cmd.SignedInUser) - if err != nil { - logger.Error("failed to count folder descendants", "error", err) - return nil, err - } - countsMap[v.Kind()] += c + for _, f := range descendantFolders { + folders = append(folders, f.UID) } + countsMap[entity.StandardKindFolder] = int64(len(descendantFolders)) } - return countsMap, nil -} -func (s *Service) getNestedFolders(ctx context.Context, orgID int64, uid string) ([]string, error) { - result := []string{} - folders, err := s.store.GetChildren(ctx, folder.GetChildrenQuery{UID: uid, OrgID: orgID}) - if err != nil { - return nil, err - } - - for _, f := range folders { - result = append(result, f.UID) - subfolders, err := s.getNestedFolders(ctx, f.OrgID, f.UID) + for _, v := range s.registry { + c, err := v.CountInFolders(ctx, q.OrgID, folders, q.SignedInUser) if err != nil { + logger.Error("failed to count folder descendants", "error", err) return nil, err } - result = append(result, subfolders...) + countsMap[v.Kind()] = c } - return result, nil + return countsMap, nil } // buildSaveDashboardCommand is a simplified version on DashboardServiceImpl.buildSaveDashboardCommand @@ -931,6 +1074,7 @@ func (s *Service) buildSaveDashboardCommand(ctx context.Context, dto *dashboards } if dash.ID == 0 { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() // nolint:staticcheck if canCreate, err := guard.CanCreate(dash.FolderID, dash.IsFolder); err != nil || !canCreate { if err != nil { @@ -958,6 +1102,7 @@ func (s *Service) buildSaveDashboardCommand(ctx context.Context, dto *dashboards } } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() cmd := &dashboards.SaveDashboardCommand{ Dashboard: dash.Data, Message: dto.Message, @@ -984,6 +1129,7 @@ func getGuardianForSavePermissionCheck(ctx context.Context, d *dashboards.Dashbo if newDashboard { // if it's a new dashboard/folder check the parent folder permissions + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() // nolint:staticcheck guard, err := guardian.New(ctx, d.FolderID, d.OrgID, user) if err != nil { @@ -1068,3 +1214,66 @@ func (s *Service) RegisterService(r folder.RegistryService) error { return nil } + +func (s *Service) supportBundleCollector() supportbundles.Collector { + collector := supportbundles.Collector{ + UID: "folder-stats", + DisplayName: "Folder information", + Description: "Folder information for the Grafana instance", + IncludedByDefault: false, + Default: true, + Fn: func(ctx context.Context) (*supportbundles.SupportItem, error) { + s.log.Info("Generating folder support bundle") + folders, err := s.GetFolders(ctx, folder.GetFoldersQuery{ + OrgID: 0, + SignedInUser: &user.SignedInUser{ + Login: "sa-supportbundle", + OrgRole: "Admin", + IsGrafanaAdmin: true, + IsServiceAccount: true, + Permissions: map[int64]map[string][]string{accesscontrol.GlobalOrgID: {dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}}}, + }, + }) + if err != nil { + return nil, err + } + return s.supportItemFromFolders(folders) + }, + } + return collector +} + +func (s *Service) supportItemFromFolders(folders []*folder.Folder) (*supportbundles.SupportItem, error) { + stats := struct { + Total int `json:"total"` // how many folders? + Depths map[int]int `json:"depths"` // how deep they are? + Children map[int]int `json:"children"` // how many child folders they have? + Folders []*folder.Folder `json:"folders"` // what are they? + }{Total: len(folders), Folders: folders, Children: map[int]int{}, Depths: map[int]int{}} + + // Build parent-child mapping + parents := map[string]string{} + children := map[string][]string{} + for _, f := range folders { + parents[f.UID] = f.ParentUID + children[f.ParentUID] = append(children[f.ParentUID], f.UID) + } + // Find depths of each folder + for _, f := range folders { + depth := 0 + for uid := f.UID; uid != ""; uid = parents[uid] { + depth++ + } + stats.Depths[depth] += 1 + stats.Children[len(children[f.UID])] += 1 + } + + b, err := json.MarshalIndent(stats, "", " ") + if err != nil { + return nil, err + } + return &supportbundles.SupportItem{ + Filename: "folders.json", + FileBytes: b, + }, nil +} diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index e0e68da3b6fb1..1f1c6753b4f8d 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -2,12 +2,15 @@ package folderimpl import ( "context" + "encoding/json" "errors" "fmt" "math/rand" + "strings" "testing" "time" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -22,9 +25,8 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" - "github.com/grafana/grafana/pkg/services/alerting" - alertmodels "github.com/grafana/grafana/pkg/services/alerting/models" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" "github.com/grafana/grafana/pkg/services/dashboards/database" dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -39,6 +41,7 @@ import ( "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/store/entity" + "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -56,7 +59,7 @@ func TestIntegrationProvideFolderService(t *testing.T) { cfg := setting.NewCfg() ac := acmock.New() db := sqlstore.InitTestDB(t) - ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, nil, nil, db, &featuremgmt.FeatureManager{}, nil) + ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, nil, nil, db, &featuremgmt.FeatureManager{}, supportbundlestest.NewFakeBundleService(), nil) require.Len(t, ac.Calls.RegisterAttributeScopeResolver, 3) }) @@ -69,7 +72,7 @@ func TestIntegrationFolderService(t *testing.T) { t.Run("Folder service tests", func(t *testing.T) { dashStore := &dashboards.FakeDashboardStore{} db := sqlstore.InitTestDB(t) - nestedFolderStore := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures([]interface{}{"nestedFolders"})) + nestedFolderStore := ProvideStore(db, db.Cfg) folderStore := foldertest.NewFakeFolderStore(t) @@ -106,38 +109,22 @@ func TestIntegrationFolderService(t *testing.T) { origNewGuardian := guardian.New guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{}) - folderId := rand.Int63() folderUID := util.GenerateShortUID() f := folder.NewFolder("Folder", "") - // nolint:staticcheck - f.ID = folderId f.UID = folderUID - folderStore.On("GetFolderByID", mock.Anything, orgID, folderId).Return(f, nil) folderStore.On("GetFolderByUID", mock.Anything, orgID, folderUID).Return(f, nil) t.Run("When get folder by id should return access denied error", func(t *testing.T) { _, err := service.Get(context.Background(), &folder.GetFolderQuery{ - ID: &folderId, // nolint:staticcheck + UID: &folderUID, OrgID: orgID, SignedInUser: usr, }) require.Equal(t, err, dashboards.ErrFolderAccessDenied) }) - var zeroInt int64 = 0 - t.Run("When get folder by id, with id = 0 should return default folder", func(t *testing.T) { - foldr, err := service.Get(context.Background(), &folder.GetFolderQuery{ - ID: &zeroInt, // nolint:staticcheck - OrgID: orgID, - SignedInUser: usr, - }) - require.NoError(t, err) - // nolint:staticcheck - require.Equal(t, foldr, &folder.Folder{ID: 0, Title: "General"}) - }) - t.Run("When get folder by uid should return access denied error", func(t *testing.T) { _, err := service.Get(context.Background(), &folder.GetFolderQuery{ UID: &folderUID, @@ -176,7 +163,6 @@ func TestIntegrationFolderService(t *testing.T) { newFolder := folder.NewFolder("Folder", "") newFolder.UID = folderUID - folderStore.On("GetFolderByID", mock.Anything, orgID, folderId).Return(newFolder, nil) folderStore.On("GetFolderByUID", mock.Anything, orgID, folderUID).Return(newFolder, nil) err := service.Delete(context.Background(), &folder.DeleteFolderCommand{ @@ -236,23 +222,30 @@ func TestIntegrationFolderService(t *testing.T) { dashboardFolder.ID = rand.Int63() dashboardFolder.UID = util.GenerateShortUID() dashboardFolder.OrgID = orgID - f := dashboards.FromDashboard(dashboardFolder) - _, err := service.store.Create(context.Background(), folder.CreateFolderCommand{ + f, err := service.store.Create(context.Background(), folder.CreateFolderCommand{ OrgID: orgID, Title: dashboardFolder.Title, UID: dashboardFolder.UID, SignedInUser: usr, }) require.NoError(t, err) + assert.Equal(t, "Folder", f.Title) dashStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.AnythingOfType("*dashboards.Dashboard"), mock.AnythingOfType("bool")).Return(true, nil) - dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("dashboards.SaveDashboardCommand")).Return(dashboardFolder, nil) - dashStore.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(dashboardFolder, nil) - - folderStore.On("GetFolderByID", mock.Anything, orgID, dashboardFolder.ID).Return(f, nil) - title := "TEST-Folder" + updatedDashboardFolder := *dashboardFolder + updatedDashboardFolder.Title = title + dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("dashboards.SaveDashboardCommand")).Return(&updatedDashboardFolder, nil) + dashStore.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(&updatedDashboardFolder, nil) + + folderStore.On("GetFolderByID", mock.Anything, orgID, dashboardFolder.ID).Return(&folder.Folder{ + OrgID: orgID, + ID: dashboardFolder.ID, + UID: dashboardFolder.UID, + Title: title, + }, nil) + req := &folder.UpdateFolderCommand{ UID: dashboardFolder.UID, OrgID: orgID, @@ -262,15 +255,12 @@ func TestIntegrationFolderService(t *testing.T) { reqResult, err := service.Update(context.Background(), req) require.NoError(t, err) - require.Equal(t, f, reqResult) + assert.Equal(t, title, reqResult.Title) }) t.Run("When deleting folder by uid should not return access denied error", func(t *testing.T) { f := folder.NewFolder(util.GenerateShortUID(), "") - // nolint:staticcheck - f.ID = rand.Int63() f.UID = util.GenerateShortUID() - folderStore.On("GetFolders", mock.Anything, orgID, []string{f.UID}).Return(map[string]*folder.Folder{f.UID: f}, nil) folderStore.On("GetFolderByUID", mock.Anything, orgID, f.UID).Return(f, nil) var actualCmd *dashboards.DeleteDashboardCommand @@ -287,8 +277,6 @@ func TestIntegrationFolderService(t *testing.T) { }) require.NoError(t, err) require.NotNil(t, actualCmd) - // nolint:staticcheck - require.Equal(t, f.ID, actualCmd.ID) require.Equal(t, orgID, actualCmd.OrgID) require.Equal(t, expectedForceDeleteRules, actualCmd.ForceDeleteFolderRules) }) @@ -302,20 +290,6 @@ func TestIntegrationFolderService(t *testing.T) { origNewGuardian := guardian.New guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true}) - t.Run("When get folder by id should return folder", func(t *testing.T) { - expected := folder.NewFolder(util.GenerateShortUID(), "") - // nolint:staticcheck - expected.ID = rand.Int63() - - // nolint:staticcheck - folderStore.On("GetFolderByID", mock.Anything, orgID, expected.ID).Return(expected, nil) - - // nolint:staticcheck - actual, err := service.getFolderByID(context.Background(), expected.ID, orgID) - require.Equal(t, expected, actual) - require.NoError(t, err) - }) - t.Run("When get folder by uid should return folder", func(t *testing.T) { expected := folder.NewFolder(util.GenerateShortUID(), "") expected.UID = util.GenerateShortUID() @@ -327,12 +301,23 @@ func TestIntegrationFolderService(t *testing.T) { require.NoError(t, err) }) + t.Run("When get folder by uid and uid is general should return the root folder object", func(t *testing.T) { + uid := accesscontrol.GeneralFolderUID + query := &folder.GetFolderQuery{ + UID: &uid, + SignedInUser: usr, + } + actual, err := service.Get(context.Background(), query) + require.Equal(t, folder.RootFolder, actual) + require.NoError(t, err) + }) + t.Run("When get folder by title should return folder", func(t *testing.T) { expected := folder.NewFolder("TEST-"+util.GenerateShortUID(), "") - folderStore.On("GetFolderByTitle", mock.Anything, orgID, expected.Title).Return(expected, nil) + folderStore.On("GetFolderByTitle", mock.Anything, orgID, expected.Title, mock.Anything).Return(expected, nil) - actual, err := service.getFolderByTitle(context.Background(), orgID, expected.Title) + actual, err := service.getFolderByTitle(context.Background(), orgID, expected.Title, nil) require.Equal(t, expected, actual) require.NoError(t, err) }) @@ -378,7 +363,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { featuresFlagOn := featuremgmt.WithFeatures("nestedFolders") dashStore, err := database.ProvideDashboardStore(db, db.Cfg, featuresFlagOn, tagimpl.ProvideService(db), quotaService) require.NoError(t, err) - nestedFolderStore := ProvideStore(db, db.Cfg, featuresFlagOn) + nestedFolderStore := ProvideStore(db, db.Cfg) b := bus.ProvideBus(tracing.InitializeTracerForTest()) ac := acimpl.ProvideAccessControl(cfg) @@ -438,7 +423,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { CanEditValue: true, }) - dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, nil, featuresFlagOn, folderPermissions, dashboardPermissions, ac, serviceWithFlagOn, nil) + dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuresFlagOn, folderPermissions, dashboardPermissions, ac, serviceWithFlagOn, nil) require.NoError(t, err) alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOn, db, serviceWithFlagOn, dashSrv, ac) @@ -448,11 +433,11 @@ func TestIntegrationNestedFolderService(t *testing.T) { lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, serviceWithFlagOn) require.NoError(t, err) - ancestorUIDs := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "getDescendantCountsOn", createCmd) + ancestors := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "getDescendantCountsOn", createCmd) - parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) + parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestors[0].UID) require.NoError(t, err) - subfolder, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[1]) + subfolder, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestors[1].UID) require.NoError(t, err) // nolint:staticcheck _ = insertTestDashboard(t, serviceWithFlagOn.dashboardStore, "dashboard in parent", orgID, parent.ID, parent.UID, "prod") @@ -471,7 +456,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { require.NoError(t, err) countCmd := folder.GetDescendantCountsQuery{ - UID: &ancestorUIDs[0], + UID: &ancestors[0].UID, OrgID: orgID, SignedInUser: &signedInUser, } @@ -484,8 +469,8 @@ func TestIntegrationNestedFolderService(t *testing.T) { t.Cleanup(func() { guardian.New = origNewGuardian - for _, uid := range ancestorUIDs { - err := serviceWithFlagOn.store.Delete(context.Background(), uid, orgID) + for _, ancestor := range ancestors { + err := serviceWithFlagOn.store.Delete(context.Background(), []string{ancestor.UID}, orgID) assert.NoError(t, err) } }) @@ -494,7 +479,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { featuresFlagOff := featuremgmt.WithFeatures() dashStore, err := database.ProvideDashboardStore(db, db.Cfg, featuresFlagOff, tagimpl.ProvideService(db), quotaService) require.NoError(t, err) - nestedFolderStore := ProvideStore(db, db.Cfg, featuresFlagOff) + nestedFolderStore := ProvideStore(db, db.Cfg) serviceWithFlagOff := &Service{ cfg: cfg, @@ -517,7 +502,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { CanEditValue: true, }) - dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, nil, featuresFlagOff, + dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuresFlagOff, folderPermissions, dashboardPermissions, ac, serviceWithFlagOff, nil) require.NoError(t, err) @@ -528,11 +513,11 @@ func TestIntegrationNestedFolderService(t *testing.T) { lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, serviceWithFlagOff) require.NoError(t, err) - ancestorUIDs := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "getDescendantCountsOff", createCmd) + ancestors := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "getDescendantCountsOff", createCmd) - parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) + parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestors[0].UID) require.NoError(t, err) - subfolder, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[1]) + subfolder, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestors[1].UID) require.NoError(t, err) // nolint:staticcheck _ = insertTestDashboard(t, serviceWithFlagOn.dashboardStore, "dashboard in parent", orgID, parent.ID, parent.UID, "prod") @@ -551,7 +536,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { require.NoError(t, err) countCmd := folder.GetDescendantCountsQuery{ - UID: &ancestorUIDs[0], + UID: &ancestors[0].UID, OrgID: orgID, SignedInUser: &signedInUser, } @@ -564,8 +549,8 @@ func TestIntegrationNestedFolderService(t *testing.T) { t.Cleanup(func() { guardian.New = origNewGuardian - for _, uid := range ancestorUIDs { - err := serviceWithFlagOn.store.Delete(context.Background(), uid, orgID) + for _, ancestor := range ancestors { + err := serviceWithFlagOn.store.Delete(context.Background(), []string{ancestor.UID}, orgID) assert.NoError(t, err) } }) @@ -587,7 +572,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { testCases := []struct { service *Service - featuresFlag *featuremgmt.FeatureManager + featuresFlag featuremgmt.FeatureToggles prefix string depth int forceDelete bool @@ -657,18 +642,18 @@ func TestIntegrationNestedFolderService(t *testing.T) { dashStore, err := database.ProvideDashboardStore(db, db.Cfg, tc.featuresFlag, tagimpl.ProvideService(db), quotaService) require.NoError(t, err) - nestedFolderStore := ProvideStore(db, db.Cfg, tc.featuresFlag) + nestedFolderStore := ProvideStore(db, db.Cfg) tc.service.dashboardStore = dashStore tc.service.store = nestedFolderStore - dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, nil, tc.featuresFlag, folderPermissions, dashboardPermissions, ac, tc.service, nil) + dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, tc.featuresFlag, folderPermissions, dashboardPermissions, ac, tc.service, nil) require.NoError(t, err) alertStore, err := ngstore.ProvideDBStore(cfg, tc.featuresFlag, db, tc.service, dashSrv, ac) require.NoError(t, err) - ancestorUIDs := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, tc.depth, tc.prefix, createCmd) + ancestors := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, tc.depth, tc.prefix, createCmd) - parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) + parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestors[0].UID) require.NoError(t, err) _ = createRule(t, alertStore, parent.UID, "parent alert") @@ -677,7 +662,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { subPanel model.LibraryElementDTO ) if tc.depth > 1 { - subfolder, err = serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[1]) + subfolder, err = serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestors[1].UID) require.NoError(t, err) _ = createRule(t, alertStore, subfolder.UID, "sub alert") // nolint:staticcheck @@ -691,7 +676,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { require.NoError(t, err) deleteCmd := folder.DeleteFolderCommand{ - UID: ancestorUIDs[0], + UID: ancestors[0].UID, OrgID: orgID, SignedInUser: &signedInUser, ForceDeleteRules: tc.forceDelete, @@ -700,12 +685,12 @@ func TestIntegrationNestedFolderService(t *testing.T) { err = tc.service.Delete(context.Background(), &deleteCmd) require.ErrorIs(t, err, tc.deletionErr) - for i, uid := range ancestorUIDs { + for i, ancestor := range ancestors { // dashboard table - _, err := tc.service.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, uid) + _, err := tc.service.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestor.UID) require.ErrorIs(t, err, tc.dashboardErr) // folder table - _, err = tc.service.store.Get(context.Background(), folder.GetFolderQuery{UID: &ancestorUIDs[i], OrgID: orgID}) + _, err = tc.service.store.Get(context.Background(), folder.GetFolderQuery{UID: &ancestors[i].UID, OrgID: orgID}) require.ErrorIs(t, err, tc.folderErr) } @@ -767,6 +752,70 @@ func TestNestedFolderServiceFeatureToggle(t *testing.T) { }) } +func TestFolderServiceDualWrite(t *testing.T) { + g := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) + t.Cleanup(func() { + guardian.New = g + }) + + db := sqlstore.InitTestDB(t) + cfg := setting.NewCfg() + features := featuremgmt.WithFeatures() + nestedFolderStore := ProvideStore(db, cfg) + + dashStore, err := database.ProvideDashboardStore(db, cfg, features, tagimpl.ProvideService(db), "atest.FakeQuotaService{}) + require.NoError(t, err) + + dashboardFolderStore := ProvideDashboardFolderStore(db) + + folderService := &Service{ + cfg: setting.NewCfg(), + store: nestedFolderStore, + db: sqlstore.InitTestDB(t), + dashboardStore: dashStore, + dashboardFolderStore: dashboardFolderStore, + features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders), + log: log.New("test-folder-service"), + accessControl: acimpl.ProvideAccessControl(cfg), + metrics: newFoldersMetrics(nil), + bus: bus.ProvideBus(tracing.InitializeTracerForTest()), + } + + t.Run("When creating a folder it should trim leading and trailing spaces in both dashboard and folder tables", func(t *testing.T) { + f, err := folderService.Create(context.Background(), &folder.CreateFolderCommand{SignedInUser: usr, OrgID: orgID, Title: " my folder "}) + require.NoError(t, err) + + assert.Equal(t, "my folder", f.Title) + + dashFolder, err := dashboardFolderStore.GetFolderByUID(context.Background(), orgID, f.UID) + require.NoError(t, err) + + nestedFolder, err := nestedFolderStore.Get(context.Background(), folder.GetFolderQuery{UID: &f.UID, OrgID: orgID}) + require.NoError(t, err) + + assert.Equal(t, dashFolder.Title, nestedFolder.Title) + }) + + t.Run("When updating a folder it should trim leading and trailing spaces in both dashboard and folder tables", func(t *testing.T) { + f, err := folderService.Create(context.Background(), &folder.CreateFolderCommand{SignedInUser: usr, OrgID: orgID, Title: "my folder 2"}) + require.NoError(t, err) + + f, err = folderService.Update(context.Background(), &folder.UpdateFolderCommand{SignedInUser: usr, OrgID: orgID, UID: f.UID, NewTitle: util.Pointer(" my updated folder 2 "), Version: f.Version}) + require.NoError(t, err) + + assert.Equal(t, "my updated folder 2", f.Title) + + dashFolder, err := dashboardFolderStore.GetFolderByUID(context.Background(), orgID, f.UID) + require.NoError(t, err) + + nestedFolder, err := nestedFolderStore.Get(context.Background(), folder.GetFolderQuery{UID: &f.UID, OrgID: orgID}) + require.NoError(t, err) + + assert.Equal(t, dashFolder.Title, nestedFolder.Title) + }) +} + func TestNestedFolderService(t *testing.T) { t.Run("with feature flag unset", func(t *testing.T) { t.Run("Should create a folder in both dashboard and folders tables", func(t *testing.T) { @@ -1231,7 +1280,7 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { featuresFlagOn := featuremgmt.WithFeatures("nestedFolders") dashStore, err := database.ProvideDashboardStore(db, db.Cfg, featuresFlagOn, tagimpl.ProvideService(db), quotaService) require.NoError(t, err) - nestedFolderStore := ProvideStore(db, db.Cfg, featuresFlagOn) + nestedFolderStore := ProvideStore(db, db.Cfg) b := bus.ProvideBus(tracing.InitializeTracerForTest()) ac := acimpl.ProvideAccessControl(cfg) @@ -1252,12 +1301,12 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { dashboardPermissions := acmock.NewMockedPermissionsService() dashboardService, err := dashboardservice.ProvideDashboardServiceImpl( - cfg, dashStore, folderStore, &dummyDashAlertExtractor{}, + cfg, dashStore, folderStore, featuresFlagOn, acmock.NewMockedPermissionsService(), dashboardPermissions, actest.FakeAccessControl{}, - foldertest.NewFakeService(), + serviceWithFlagOn, nil, ) require.NoError(t, err) @@ -1272,6 +1321,7 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { orgID: { dashboards.ActionFoldersCreate: {}, dashboards.ActionFoldersWrite: {dashboards.ScopeFoldersAll}, + dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}, }, }} @@ -1281,43 +1331,32 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { SignedInUser: &signedInAdminUser, } + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ + CanSaveValue: true, + CanViewValue: true, + }) + t.Run("Should get folders shared with given user", func(t *testing.T) { depth := 3 - origNewGuardian := guardian.New - guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ - CanSaveValue: true, - CanViewValue: true, - }) - ancestorUIDsFolderWithPermissions := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "withPermissions", createCmd) - ancestorUIDsFolderWithoutPermissions := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "withoutPermissions", createCmd) + ancestorFoldersWithPermissions := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "withPermissions", createCmd) + ancestorFoldersWithoutPermissions := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "withoutPermissions", createCmd) - parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDsFolderWithoutPermissions[0]) + parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorFoldersWithoutPermissions[0].UID) require.NoError(t, err) - subfolder, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDsFolderWithoutPermissions[1]) + subfolder, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorFoldersWithoutPermissions[1].UID) require.NoError(t, err) // nolint:staticcheck dash1 := insertTestDashboard(t, serviceWithFlagOn.dashboardStore, "dashboard in parent", orgID, parent.ID, parent.UID, "prod") // nolint:staticcheck dash2 := insertTestDashboard(t, serviceWithFlagOn.dashboardStore, "dashboard in subfolder", orgID, subfolder.ID, subfolder.UID, "prod") - guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ - CanSaveValue: true, - CanViewValue: true, - CanViewUIDs: []string{ - ancestorUIDsFolderWithPermissions[0], - ancestorUIDsFolderWithPermissions[1], - ancestorUIDsFolderWithoutPermissions[1], - dash1.UID, - dash2.UID, - }, - }) signedInUser.Permissions[orgID][dashboards.ActionFoldersRead] = []string{ - dashboards.ScopeFoldersProvider.GetResourceScopeUID(ancestorUIDsFolderWithPermissions[0]), + dashboards.ScopeFoldersProvider.GetResourceScopeUID(ancestorFoldersWithPermissions[0].UID), // Add permission to the subfolder of folder with permission (to check deduplication) - dashboards.ScopeFoldersProvider.GetResourceScopeUID(ancestorUIDsFolderWithPermissions[1]), + dashboards.ScopeFoldersProvider.GetResourceScopeUID(ancestorFoldersWithPermissions[1].UID), // Add permission to the subfolder of folder without permission - dashboards.ScopeFoldersProvider.GetResourceScopeUID(ancestorUIDsFolderWithoutPermissions[1]), + dashboards.ScopeFoldersProvider.GetResourceScopeUID(ancestorFoldersWithoutPermissions[1].UID), } signedInUser.Permissions[orgID][dashboards.ActionDashboardsRead] = []string{ dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dash1.UID), @@ -1338,8 +1377,8 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { require.NoError(t, err) require.Len(t, sharedFolders, 1) - require.Contains(t, sharedFoldersUIDs, ancestorUIDsFolderWithoutPermissions[1]) - require.NotContains(t, sharedFoldersUIDs, ancestorUIDsFolderWithPermissions[1]) + require.Contains(t, sharedFoldersUIDs, ancestorFoldersWithoutPermissions[1].UID) + require.NotContains(t, sharedFoldersUIDs, ancestorFoldersWithPermissions[1].UID) sharedDashboards, err := dashboardService.GetDashboardsSharedWithUser(context.Background(), &signedInUser) sharedDashboardsUIDs := make([]string, 0) @@ -1353,29 +1392,723 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { require.NotContains(t, sharedDashboardsUIDs, dash2.UID) t.Cleanup(func() { - guardian.New = origNewGuardian - for _, uid := range ancestorUIDsFolderWithPermissions { - err := serviceWithFlagOn.store.Delete(context.Background(), uid, orgID) - assert.NoError(t, err) + //guardian.New = origNewGuardian + toDelete := make([]string, 0, len(ancestorFoldersWithPermissions)+len(ancestorFoldersWithoutPermissions)) + for _, ancestor := range append(ancestorFoldersWithPermissions, ancestorFoldersWithoutPermissions...) { + toDelete = append(toDelete, ancestor.UID) } + err := serviceWithFlagOn.store.Delete(context.Background(), toDelete, orgID) + assert.NoError(t, err) }) + }) + + t.Run("Should get org folders visible", func(t *testing.T) { + depth := 3 + + // create folder sctructure like this: + // tree1-folder-0 + // └──tree1-folder-1 + // └──tree1-folder-2 + // tree2-folder-0 + // └──tree2-folder-1 + // └──tree2-folder-2 + tree1 := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "tree1-", createCmd) + tree2 := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "tree2-", createCmd) + + signedInUser.Permissions[orgID][dashboards.ActionFoldersRead] = []string{ + // Add permission to tree1-folder-0 + dashboards.ScopeFoldersProvider.GetResourceScopeUID(tree1[0].UID), + // Add permission to the subfolder of folder with permission (tree1-folder-1) to check deduplication + dashboards.ScopeFoldersProvider.GetResourceScopeUID(tree1[1].UID), + // Add permission to the subfolder of folder without permission (tree2-folder-1) + dashboards.ScopeFoldersProvider.GetResourceScopeUID(tree2[1].UID), + } + t.Cleanup(func() { - guardian.New = origNewGuardian - for _, uid := range ancestorUIDsFolderWithoutPermissions { - err := serviceWithFlagOn.store.Delete(context.Background(), uid, orgID) - assert.NoError(t, err) + toDelete := make([]string, 0, len(tree1)+len(tree2)) + for _, f := range append(tree1, tree2...) { + toDelete = append(toDelete, f.UID) } + err := serviceWithFlagOn.store.Delete(context.Background(), toDelete, orgID) + assert.NoError(t, err) }) + + testCases := []struct { + name string + cmd folder.GetFoldersQuery + expected []*folder.Folder + }{ + { + name: "Should get all org folders visible to the user", + cmd: folder.GetFoldersQuery{ + OrgID: orgID, + SignedInUser: &signedInUser, + }, + expected: []*folder.Folder{ + { + UID: tree1[0].UID, + }, + { + UID: tree1[1].UID, + }, + { + UID: tree1[2].UID, + }, + { + UID: tree2[1].UID, + }, + { + UID: tree2[2].UID, + }, + }, + }, + { + name: "Should get all org folders visible to the user with fullpath", + cmd: folder.GetFoldersQuery{ + OrgID: orgID, + WithFullpath: true, + SignedInUser: &signedInUser, + }, + expected: []*folder.Folder{ + { + UID: tree1[0].UID, + Fullpath: "tree1-folder-0", + }, + { + UID: tree1[1].UID, + Fullpath: "tree1-folder-0/tree1-folder-1", + }, + { + UID: tree1[2].UID, + Fullpath: "tree1-folder-0/tree1-folder-1/tree1-folder-2", + }, + { + UID: tree2[1].UID, + Fullpath: "tree2-folder-0/tree2-folder-1", + }, + { + UID: tree2[2].UID, + Fullpath: "tree2-folder-0/tree2-folder-1/tree2-folder-2", + }, + }, + }, + { + name: "Should get all org folders visible to the user with fullpath UIDs", + cmd: folder.GetFoldersQuery{ + OrgID: orgID, + WithFullpathUIDs: true, + SignedInUser: &signedInUser, + }, + expected: []*folder.Folder{ + { + UID: tree1[0].UID, + FullpathUIDs: strings.Join([]string{tree1[0].UID}, "/"), + }, + { + UID: tree1[1].UID, + FullpathUIDs: strings.Join([]string{tree1[0].UID, tree1[1].UID}, "/"), + }, + { + UID: tree1[2].UID, + FullpathUIDs: strings.Join([]string{tree1[0].UID, tree1[1].UID, tree1[2].UID}, "/"), + }, + { + UID: tree2[1].UID, + FullpathUIDs: strings.Join([]string{tree2[0].UID, tree2[1].UID}, "/"), + }, + { + UID: tree2[2].UID, + FullpathUIDs: strings.Join([]string{tree2[0].UID, tree2[1].UID, tree2[2].UID}, "/"), + }, + }, + }, + { + name: "Should get specific org folders visible to the user", + cmd: folder.GetFoldersQuery{ + OrgID: orgID, + UIDs: []string{tree1[0].UID, tree2[0].UID, tree2[1].UID}, + SignedInUser: &signedInUser, + }, + expected: []*folder.Folder{ + { + UID: tree1[0].UID, + }, + { + UID: tree2[1].UID, + }, + }, + }, + { + name: "Should get all org folders visible to the user with admin permissions", + cmd: folder.GetFoldersQuery{ + OrgID: orgID, + SignedInUser: &signedInAdminUser, + }, + expected: []*folder.Folder{ + { + UID: tree1[0].UID, + Fullpath: "tree1-folder-0", + FullpathUIDs: strings.Join([]string{tree1[0].UID}, "/"), + }, + { + UID: tree1[1].UID, + Fullpath: "tree1-folder-0/tree1-folder-1", + FullpathUIDs: strings.Join([]string{tree1[0].UID, tree1[1].UID}, "/"), + }, + { + UID: tree1[2].UID, + Fullpath: "tree1-folder-0/tree1-folder-1/tree1-folder-2", + }, + { + UID: tree2[0].UID, + Fullpath: "tree2-folder-0", + FullpathUIDs: strings.Join([]string{tree2[0].UID}, "/"), + }, + { + UID: tree2[1].UID, + Fullpath: "tree2-folder-0/tree2-folder-1", + FullpathUIDs: strings.Join([]string{tree2[0].UID, tree2[1].UID}, "/"), + }, + { + UID: tree2[2].UID, + Fullpath: "tree2-folder-0/tree2-folder-1/tree2-folder-2", + FullpathUIDs: strings.Join([]string{tree2[0].UID, tree2[1].UID, tree2[2].UID}, "/"), + }, + }, + }, + { + name: "Should not get any folders if user has no permissions", + cmd: folder.GetFoldersQuery{ + OrgID: orgID, + SignedInUser: &user.SignedInUser{UserID: 999, OrgID: orgID, Permissions: map[int64]map[string][]string{ + orgID: {}, + }}, + }, + expected: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actualFolders, err := serviceWithFlagOn.GetFolders(context.Background(), tc.cmd) + require.NoError(t, err) + + require.NoError(t, err) + require.Len(t, actualFolders, len(tc.expected)) + + for _, expected := range tc.expected { + var actualFolder *folder.Folder + for _, f := range actualFolders { + if f.UID == expected.UID { + actualFolder = f + break + } + } + if actualFolder == nil { + t.Fatalf("expected folder with UID %s not found", expected.UID) + } + if tc.cmd.WithFullpath { + require.Equal(t, expected.Fullpath, actualFolder.Fullpath) + } else { + require.Empty(t, actualFolder.Fullpath) + } + + if tc.cmd.WithFullpathUIDs { + require.Equal(t, expected.FullpathUIDs, actualFolder.FullpathUIDs) + } else { + require.Empty(t, actualFolder.FullpathUIDs) + } + } + }) + } }) } -func CreateSubtreeInStore(t *testing.T, store *sqlStore, service *Service, depth int, prefix string, cmd folder.CreateFolderCommand) []string { - t.Helper() +func TestFolderServiceGetFolder(t *testing.T) { + db := sqlstore.InitTestDB(t) + + signedInAdminUser := user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{ + orgID: { + dashboards.ActionFoldersCreate: {}, + dashboards.ActionFoldersWrite: {dashboards.ScopeFoldersAll}, + dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}, + }, + }} + + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ + CanSaveValue: true, + CanViewValue: true, + }) + + getSvc := func(features featuremgmt.FeatureToggles) Service { + quotaService := quotatest.New(false, nil) + folderStore := ProvideDashboardFolderStore(db) + + cfg := setting.NewCfg() + + featuresFlagOff := featuremgmt.WithFeatures() + dashStore, err := database.ProvideDashboardStore(db, db.Cfg, featuresFlagOff, tagimpl.ProvideService(db), quotaService) + require.NoError(t, err) + nestedFolderStore := ProvideStore(db, db.Cfg) + + b := bus.ProvideBus(tracing.InitializeTracerForTest()) + ac := acimpl.ProvideAccessControl(cfg) + + return Service{ + cfg: cfg, + log: log.New("test-folder-service"), + dashboardStore: dashStore, + dashboardFolderStore: folderStore, + store: nestedFolderStore, + features: features, + bus: b, + db: db, + accessControl: ac, + registry: make(map[string]folder.RegistryService), + metrics: newFoldersMetrics(nil), + } + } + + folderSvcOn := getSvc(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) + folderSvcOff := getSvc(featuremgmt.WithFeatures()) + + createCmd := folder.CreateFolderCommand{ + OrgID: orgID, + ParentUID: "", + SignedInUser: &signedInAdminUser, + } + + depth := 3 + folders := CreateSubtreeInStore(t, folderSvcOn.store, &folderSvcOn, depth, "get/folder-", createCmd) + f := folders[1] + + testCases := []struct { + name string + svc *Service + WithFullpath bool + expectedFullpath string + }{ + { + name: "when flag is off", + svc: &folderSvcOff, + expectedFullpath: f.Title, + }, + { + name: "when flag is on and WithFullpath is false", + svc: &folderSvcOn, + WithFullpath: false, + expectedFullpath: "", + }, + { + name: "when flag is on and WithFullpath is true", + svc: &folderSvcOn, + WithFullpath: true, + expectedFullpath: "get\\/folder-folder-0/get\\/folder-folder-1", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + q := folder.GetFolderQuery{ + OrgID: orgID, + UID: &f.UID, + WithFullpath: tc.WithFullpath, + SignedInUser: &signedInAdminUser, + } + fldr, err := tc.svc.Get(context.Background(), &q) + require.NoError(t, err) + require.Equal(t, f.UID, fldr.UID) + + require.Equal(t, tc.expectedFullpath, fldr.Fullpath) + }) + } +} + +func TestFolderServiceGetFolders(t *testing.T) { + db := sqlstore.InitTestDB(t) + quotaService := quotatest.New(false, nil) + folderStore := ProvideDashboardFolderStore(db) + + cfg := setting.NewCfg() + + featuresFlagOff := featuremgmt.WithFeatures() + dashStore, err := database.ProvideDashboardStore(db, db.Cfg, featuresFlagOff, tagimpl.ProvideService(db), quotaService) + require.NoError(t, err) + nestedFolderStore := ProvideStore(db, db.Cfg) + + b := bus.ProvideBus(tracing.InitializeTracerForTest()) + ac := acimpl.ProvideAccessControl(cfg) + + serviceWithFlagOff := &Service{ + cfg: cfg, + log: log.New("test-folder-service"), + dashboardStore: dashStore, + dashboardFolderStore: folderStore, + store: nestedFolderStore, + features: featuresFlagOff, + bus: b, + db: db, + accessControl: ac, + registry: make(map[string]folder.RegistryService), + metrics: newFoldersMetrics(nil), + } + + signedInAdminUser := user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{ + orgID: { + dashboards.ActionFoldersCreate: {}, + dashboards.ActionFoldersWrite: {dashboards.ScopeFoldersAll}, + dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}, + }, + }} + + createCmd := folder.CreateFolderCommand{ + OrgID: orgID, + ParentUID: "", + SignedInUser: &signedInAdminUser, + } + + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ + CanSaveValue: true, + CanViewValue: true, + }) + + prefix := "getfolders/ff/off" + folders := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOff, 5, prefix, createCmd) + f := folders[rand.Intn(len(folders))] + + t.Run("when flag is off", func(t *testing.T) { + t.Run("full path should be a title", func(t *testing.T) { + q := folder.GetFoldersQuery{ + OrgID: orgID, + WithFullpath: true, + WithFullpathUIDs: true, + SignedInUser: &signedInAdminUser, + UIDs: []string{f.UID}, + } + fldrs, err := serviceWithFlagOff.GetFolders(context.Background(), q) + require.NoError(t, err) + require.Len(t, fldrs, 1) + require.Equal(t, f.UID, fldrs[0].UID) + require.Equal(t, f.Title, fldrs[0].Title) + require.Equal(t, f.Title, fldrs[0].Fullpath) + + t.Run("path should not be escaped", func(t *testing.T) { + require.Contains(t, fldrs[0].Fullpath, prefix) + require.Contains(t, fldrs[0].Title, prefix) + }) + }) + }) +} + +// TODO replace it with an API test under /pkg/tests/api/folders +// whenever the golang client with get updated to allow filtering child folders by permission +func TestGetChildrenFilterByPermission(t *testing.T) { + db := sqlstore.InitTestDB(t) + + signedInAdminUser := user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{ + orgID: { + dashboards.ActionFoldersCreate: {}, + dashboards.ActionFoldersWrite: {dashboards.ScopeFoldersAll}, + dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}, + }, + }} + + quotaService := quotatest.New(false, nil) + folderStore := ProvideDashboardFolderStore(db) + + cfg := setting.NewCfg() + + featuresFlagOff := featuremgmt.WithFeatures() + dashStore, err := database.ProvideDashboardStore(db, db.Cfg, featuresFlagOff, tagimpl.ProvideService(db), quotaService) + require.NoError(t, err) + nestedFolderStore := ProvideStore(db, db.Cfg) + + b := bus.ProvideBus(tracing.InitializeTracerForTest()) + ac := acimpl.ProvideAccessControl(cfg) - ancestorUIDs := []string{} - if cmd.ParentUID != "" { - ancestorUIDs = append(ancestorUIDs, cmd.ParentUID) + features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders) + + folderSvcOn := &Service{ + cfg: cfg, + log: log.New("test-folder-service"), + dashboardStore: dashStore, + dashboardFolderStore: folderStore, + store: nestedFolderStore, + features: features, + bus: b, + db: db, + accessControl: ac, + registry: make(map[string]folder.RegistryService), + metrics: newFoldersMetrics(nil), } + + origGuardian := guardian.New + fakeGuardian := &guardian.FakeDashboardGuardian{ + CanSaveValue: true, + CanEditUIDs: []string{}, + CanViewUIDs: []string{}, + } + guardian.MockDashboardGuardian(fakeGuardian) + t.Cleanup(func() { + guardian.New = origGuardian + }) + + viewer := user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{ + orgID: { + dashboards.ActionFoldersRead: {}, + dashboards.ActionFoldersWrite: {}, + }, + }} + + // no view permission + // |_ subfolder under no view permission with view permission + // |_ subfolder under no view permission with view permissionn and with edit permission + // with edit permission + // |_ subfolder under with edit permission + // no edit permission + // |_ subfolder under no edit permission + // |_ subfolder under no edit permission with edit permission + noViewPermission, err := folderSvcOn.Create(context.Background(), &folder.CreateFolderCommand{ + OrgID: orgID, + ParentUID: "", + Title: "no view permission", + SignedInUser: &signedInAdminUser, + }) + require.NoError(t, err) + + f, err := folderSvcOn.Create(context.Background(), &folder.CreateFolderCommand{ + OrgID: orgID, + ParentUID: noViewPermission.UID, + Title: "subfolder under no view permission with view permission", + SignedInUser: &signedInAdminUser, + }) + viewer.Permissions[orgID][dashboards.ActionFoldersRead] = append(viewer.Permissions[orgID][dashboards.ActionFoldersRead], dashboards.ScopeFoldersProvider.GetResourceScopeUID(f.UID)) + fakeGuardian.CanViewUIDs = append(fakeGuardian.CanViewUIDs, f.UID) + + require.NoError(t, err) + f, err = folderSvcOn.Create(context.Background(), &folder.CreateFolderCommand{ + OrgID: orgID, + ParentUID: noViewPermission.UID, + Title: "subfolder under no view permission with view permission and with edit permission", + SignedInUser: &signedInAdminUser, + }) + require.NoError(t, err) + viewer.Permissions[orgID][dashboards.ActionFoldersRead] = append(viewer.Permissions[orgID][dashboards.ActionFoldersRead], dashboards.ScopeFoldersProvider.GetResourceScopeUID(f.UID)) + fakeGuardian.CanViewUIDs = append(fakeGuardian.CanViewUIDs, f.UID) + viewer.Permissions[orgID][dashboards.ActionFoldersWrite] = append(viewer.Permissions[orgID][dashboards.ActionFoldersWrite], dashboards.ScopeFoldersProvider.GetResourceScopeUID(f.UID)) + fakeGuardian.CanEditUIDs = append(fakeGuardian.CanEditUIDs, f.UID) + + withEditPermission, err := folderSvcOn.Create(context.Background(), &folder.CreateFolderCommand{ + OrgID: orgID, + ParentUID: "", + Title: "with edit permission", + SignedInUser: &signedInAdminUser, + }) + require.NoError(t, err) + viewer.Permissions[orgID][dashboards.ActionFoldersRead] = append(viewer.Permissions[orgID][dashboards.ActionFoldersRead], dashboards.ScopeFoldersProvider.GetResourceScopeUID(withEditPermission.UID)) + fakeGuardian.CanViewUIDs = append(fakeGuardian.CanViewUIDs, withEditPermission.UID) + viewer.Permissions[orgID][dashboards.ActionFoldersWrite] = append(viewer.Permissions[orgID][dashboards.ActionFoldersWrite], dashboards.ScopeFoldersProvider.GetResourceScopeUID(withEditPermission.UID)) + fakeGuardian.CanEditUIDs = append(fakeGuardian.CanEditUIDs, withEditPermission.UID) + + _, err = folderSvcOn.Create(context.Background(), &folder.CreateFolderCommand{ + OrgID: orgID, + ParentUID: withEditPermission.UID, + Title: "subfolder under with edit permission", + SignedInUser: &signedInAdminUser, + }) + require.NoError(t, err) + + noEditPermission, err := folderSvcOn.Create(context.Background(), &folder.CreateFolderCommand{ + OrgID: orgID, + ParentUID: "", + Title: "no edit permission", + SignedInUser: &signedInAdminUser, + }) + require.NoError(t, err) + viewer.Permissions[orgID][dashboards.ActionFoldersRead] = append(viewer.Permissions[orgID][dashboards.ActionFoldersRead], dashboards.ScopeFoldersProvider.GetResourceScopeUID(noEditPermission.UID)) + fakeGuardian.CanViewUIDs = append(fakeGuardian.CanViewUIDs, noEditPermission.UID) + + _, err = folderSvcOn.Create(context.Background(), &folder.CreateFolderCommand{ + OrgID: orgID, + ParentUID: noEditPermission.UID, + Title: "subfolder under no edit permission", + SignedInUser: &signedInAdminUser, + }) + require.NoError(t, err) + + f, err = folderSvcOn.Create(context.Background(), &folder.CreateFolderCommand{ + OrgID: orgID, + ParentUID: noEditPermission.UID, + Title: "subfolder under no edit permission with edit permission", + SignedInUser: &signedInAdminUser, + }) + require.NoError(t, err) + viewer.Permissions[orgID][dashboards.ActionFoldersWrite] = append(viewer.Permissions[orgID][dashboards.ActionFoldersWrite], dashboards.ScopeFoldersProvider.GetResourceScopeUID(f.UID)) + fakeGuardian.CanEditUIDs = append(fakeGuardian.CanEditUIDs, f.UID) + + testCases := []struct { + name string + q folder.GetChildrenQuery + expectedErr error + expectedFolders []string + }{ + { + name: "should return root folders with view permission", + q: folder.GetChildrenQuery{ + OrgID: orgID, + SignedInUser: &viewer, + }, + expectedFolders: []string{ + "Shared with me", + "no edit permission", + "with edit permission"}, + }, + { + name: "should return subfolders with view permission", + q: folder.GetChildrenQuery{ + OrgID: orgID, + SignedInUser: &viewer, + UID: noEditPermission.UID, + }, + expectedFolders: []string{ + "subfolder under no edit permission", + "subfolder under no edit permission with edit permission"}, + }, + { + name: "should return shared with me folders with view permission", + q: folder.GetChildrenQuery{ + OrgID: orgID, + SignedInUser: &viewer, + UID: folder.SharedWithMeFolderUID, + }, + expectedFolders: []string{ + "subfolder under no view permission with view permission", + "subfolder under no view permission with view permission and with edit permission"}, + }, + { + name: "should return root folders with edit permission", + q: folder.GetChildrenQuery{ + OrgID: orgID, + SignedInUser: &viewer, + Permission: dashboardaccess.PERMISSION_EDIT, + }, + expectedFolders: []string{ + "Shared with me", + "with edit permission"}, + }, + { + name: "should fail returning subfolders with edit permission when parent folder has no edit permission", + q: folder.GetChildrenQuery{ + OrgID: orgID, + SignedInUser: &viewer, + Permission: dashboardaccess.PERMISSION_EDIT, + UID: noEditPermission.UID, + }, + expectedErr: dashboards.ErrFolderAccessDenied, + }, + { + name: "should return shared with me folders with edit permission", + q: folder.GetChildrenQuery{ + OrgID: orgID, + SignedInUser: &viewer, + Permission: dashboardaccess.PERMISSION_EDIT, + UID: folder.SharedWithMeFolderUID, + }, + expectedFolders: []string{ + "subfolder under no edit permission with edit permission", + "subfolder under no view permission with view permission and with edit permission", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + folders, err := folderSvcOn.GetChildren(context.Background(), &tc.q) + if tc.expectedErr != nil { + require.Error(t, err) + require.Equal(t, tc.expectedErr, err) + } else { + require.NoError(t, err) + actual := make([]string, 0, len(folders)) + for _, f := range folders { + actual = append(actual, f.Title) + } + if cmp.Diff(tc.expectedFolders, actual) != "" { + t.Fatalf("unexpected folders: %s", cmp.Diff(tc.expectedFolders, actual)) + } + } + }) + } +} + +func TestSupportBundle(t *testing.T) { + f := func(uid, parent string) *folder.Folder { return &folder.Folder{UID: uid, ParentUID: parent} } + for _, tc := range []struct { + Folders []*folder.Folder + ExpectedTotal int + ExpectedDepths map[int]int + ExpectedChildren map[int]int + }{ + // Empty folder list + { + Folders: []*folder.Folder{}, + ExpectedTotal: 0, + ExpectedDepths: map[int]int{}, + ExpectedChildren: map[int]int{}, + }, + // Single folder + { + Folders: []*folder.Folder{f("a", "")}, + ExpectedTotal: 1, + ExpectedDepths: map[int]int{1: 1}, + ExpectedChildren: map[int]int{0: 1}, + }, + // Flat folders + { + Folders: []*folder.Folder{f("a", ""), f("b", ""), f("c", "")}, + ExpectedTotal: 3, + ExpectedDepths: map[int]int{1: 3}, + ExpectedChildren: map[int]int{0: 3}, + }, + // Nested folders + { + Folders: []*folder.Folder{f("a", ""), f("ab", "a"), f("ac", "a"), f("x", ""), f("xy", "x"), f("xyz", "xy")}, + ExpectedTotal: 6, + ExpectedDepths: map[int]int{1: 2, 2: 3, 3: 1}, + ExpectedChildren: map[int]int{0: 3, 1: 2, 2: 1}, + }, + } { + svc := &Service{} + supportItem, err := svc.supportItemFromFolders(tc.Folders) + if err != nil { + t.Fatal(err) + } + + stats := struct { + Total int `json:"total"` + Depths map[int]int `json:"depths"` + Children map[int]int `json:"children"` + }{} + if err := json.Unmarshal(supportItem.FileBytes, &stats); err != nil { + t.Fatal(err) + } + + if stats.Total != tc.ExpectedTotal { + t.Error("Total mismatch", stats, tc) + } + if fmt.Sprint(stats.Depths) != fmt.Sprint(tc.ExpectedDepths) { + t.Error("Depths mismatch", stats, tc.ExpectedDepths) + } + if fmt.Sprint(stats.Children) != fmt.Sprint(tc.ExpectedChildren) { + t.Error("Depths mismatch", stats, tc.ExpectedChildren) + } + } +} + +func CreateSubtreeInStore(t *testing.T, store store, service *Service, depth int, prefix string, cmd folder.CreateFolderCommand) []*folder.Folder { + t.Helper() + + folders := make([]*folder.Folder, 0, depth) for i := 0; i < depth; i++ { title := fmt.Sprintf("%sfolder-%d", prefix, i) cmd.Title = title @@ -1384,27 +2117,14 @@ func CreateSubtreeInStore(t *testing.T, store *sqlStore, service *Service, depth f, err := service.Create(context.Background(), &cmd) require.NoError(t, err) require.Equal(t, title, f.Title) - // nolint:staticcheck - require.NotEmpty(t, f.ID) require.NotEmpty(t, f.UID) - parents, err := store.GetParents(context.Background(), folder.GetParentsQuery{ - UID: f.UID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) - parentUIDs := []string{} - for _, p := range parents { - parentUIDs = append(parentUIDs, p.UID) - } - require.Equal(t, ancestorUIDs, parentUIDs) - - ancestorUIDs = append(ancestorUIDs, f.UID) + folders = append(folders, f) cmd.ParentUID = f.UID } - return ancestorUIDs + return folders } func setup(t *testing.T, dashStore dashboards.Store, dashboardFolderStore folder.FolderStore, nestedFolderStore store, features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, db db.DB) folder.Service { @@ -1446,14 +2166,3 @@ func createRule(t *testing.T, store *ngstore.DBstore, folderUID, title string) * return &rule } - -type dummyDashAlertExtractor struct { -} - -func (d *dummyDashAlertExtractor) GetAlerts(ctx context.Context, dashAlertInfo alerting.DashAlertInfo) ([]*alertmodels.Alert, error) { - return nil, nil -} - -func (d *dummyDashAlertExtractor) ValidateAlerts(ctx context.Context, dashAlertInfo alerting.DashAlertInfo) error { - return nil -} diff --git a/pkg/services/folder/folderimpl/metrics.go b/pkg/services/folder/folderimpl/metrics.go index 7e569e84ed81d..2f72c1ac64331 100644 --- a/pkg/services/folder/folderimpl/metrics.go +++ b/pkg/services/folder/folderimpl/metrics.go @@ -12,6 +12,7 @@ const ( type foldersMetrics struct { sharedWithMeFetchFoldersRequestsDuration *prometheus.HistogramVec + foldersGetChildrenRequestsDuration *prometheus.HistogramVec } func newFoldersMetrics(r prometheus.Registerer) *foldersMetrics { @@ -26,5 +27,15 @@ func newFoldersMetrics(r prometheus.Registerer) *foldersMetrics { }, []string{"status"}, ), + foldersGetChildrenRequestsDuration: promauto.With(r).NewHistogramVec( + prometheus.HistogramOpts{ + Name: "get_children_duration_seconds", + Help: "Duration of listing subfolders in specific folder", + Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 25, 50, 100}, + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + }, + []string{"parent"}, + ), } } diff --git a/pkg/services/folder/folderimpl/sqlstore.go b/pkg/services/folder/folderimpl/sqlstore.go index 75aa5e8f794fe..d77ef2bcd44e6 100644 --- a/pkg/services/folder/folderimpl/sqlstore.go +++ b/pkg/services/folder/folderimpl/sqlstore.go @@ -2,6 +2,7 @@ package folderimpl import ( "context" + "fmt" "runtime" "strings" "time" @@ -10,24 +11,27 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/infra/metrics" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) +const DEFAULT_BATCH_SIZE = 999 + type sqlStore struct { db db.DB log log.Logger cfg *setting.Cfg - fm featuremgmt.FeatureToggles } // sqlStore implements the store interface. var _ store = (*sqlStore)(nil) -func ProvideStore(db db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles) *sqlStore { - return &sqlStore{db: db, log: log.New("folder-store"), cfg: cfg, fm: features} +func ProvideStore(db db.DB, cfg *setting.Cfg) *sqlStore { + return &sqlStore{db: db, log: log.New("folder-store"), cfg: cfg} } func (ss *sqlStore) Create(ctx context.Context, cmd folder.CreateFolderCommand) (*folder.Folder, error) { @@ -67,6 +71,7 @@ func (ss *sqlStore) Create(ctx context.Context, cmd folder.CreateFolderCommand) return err } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() foldr, err = ss.Get(ctx, folder.GetFolderQuery{ ID: &lastInsertedID, // nolint:staticcheck }) @@ -78,11 +83,21 @@ func (ss *sqlStore) Create(ctx context.Context, cmd folder.CreateFolderCommand) return foldr.WithURL(), err } -func (ss *sqlStore) Delete(ctx context.Context, uid string, orgID int64) error { +func (ss *sqlStore) Delete(ctx context.Context, UIDs []string, orgID int64) error { + if len(UIDs) == 0 { + return nil + } return ss.db.WithDbSession(ctx, func(sess *db.Session) error { - _, err := sess.Exec("DELETE FROM folder WHERE uid=? AND org_id=?", uid, orgID) + // covered by UQE_folder_org_id_uid + s := fmt.Sprintf("DELETE FROM folder WHERE org_id=? AND uid IN (%s)", strings.Repeat("?, ", len(UIDs)-1)+"?") + sqlArgs := make([]any, 0, len(UIDs)+2) + sqlArgs = append(sqlArgs, s, orgID) + for _, uid := range UIDs { + sqlArgs = append(sqlArgs, uid) + } + _, err := sess.Exec(sqlArgs...) if err != nil { - return folder.ErrDatabaseError.Errorf("failed to delete folder: %w", err) + return folder.ErrDatabaseError.Errorf("failed to delete folders: %w", err) } return nil }) @@ -126,6 +141,7 @@ func (ss *sqlStore) Update(ctx context.Context, cmd folder.UpdateFolderCommand) } sql.WriteString(strings.Join(columnsToUpdate, ", ")) + // covered by UQE_folder_org_id_uid sql.WriteString(" WHERE uid = ? AND org_id = ?") args = append(args, cmd.UID, cmd.OrgID) @@ -157,19 +173,60 @@ func (ss *sqlStore) Update(ctx context.Context, cmd folder.UpdateFolderCommand) return foldr.WithURL(), err } +// If WithFullpath is true it computes also the full path of a folder. +// The full path is a string that contains the titles of all parent folders separated by a slash. +// For example, if the folder structure is: +// +// A +// └── B +// └── C +// +// The full path of C is "A/B/C". +// The full path of B is "A/B". +// The full path of A is "A". +// If a folder contains a slash in its title, it is escaped with a backslash. +// For example, if the folder structure is: +// +// A +// └── B/C +// +// The full path of C is "A/B\/C". func (ss *sqlStore) Get(ctx context.Context, q folder.GetFolderQuery) (*folder.Folder, error) { foldr := &folder.Folder{} err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { exists := false var err error + s := strings.Builder{} + s.WriteString("SELECT *") + if q.WithFullpath { + s.WriteString(fmt.Sprintf(`, %s AS fullpath`, getFullpathSQL(ss.db.GetDialect()))) + } + s.WriteString(" FROM folder f0") + if q.WithFullpath { + s.WriteString(getFullpathJoinsSQL()) + } switch { case q.UID != nil: - exists, err = sess.SQL("SELECT * FROM folder WHERE uid = ? AND org_id = ?", q.UID, q.OrgID).Get(foldr) + // covered UQE_folder_uid_org_id + s.WriteString(" WHERE f0.uid = ? AND f0.org_id = ?") + exists, err = sess.SQL(s.String(), q.UID, q.OrgID).Get(foldr) // nolint:staticcheck case q.ID != nil: - exists, err = sess.SQL("SELECT * FROM folder WHERE id = ?", q.ID).Get(foldr) + s.WriteString(" WHERE f0.id = ?") + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() + // covered by primary key + exists, err = sess.SQL(s.String(), q.ID).Get(foldr) case q.Title != nil: - exists, err = sess.SQL("SELECT * FROM folder WHERE title = ? AND org_id = ?", q.Title, q.OrgID).Get(foldr) + // covered by UQE_folder_org_id_parent_uid_title + s.WriteString(" WHERE f0.title = ? AND f0.org_id = ?") + args := []any{*q.Title, q.OrgID} + if q.ParentUID != nil { + s.WriteString(" AND f0.parent_uid = ?") + args = append(args, *q.ParentUID) + } else { + s.WriteString(" AND f0.parent_uid IS NULL") + } + exists, err = sess.SQL(s.String(), args...).Get(foldr) default: return folder.ErrBadRequest.Errorf("one of ID, UID, or Title must be included in the command") } @@ -177,10 +234,13 @@ func (ss *sqlStore) Get(ctx context.Context, q folder.GetFolderQuery) (*folder.F return folder.ErrDatabaseError.Errorf("failed to get folder: %w", err) } if !exists { - return folder.ErrFolderNotFound.Errorf("folder not found") + // embed dashboards.ErrFolderNotFound + return folder.ErrFolderNotFound.Errorf("%w", dashboards.ErrFolderNotFound) } return nil }) + + foldr.Fullpath = strings.TrimLeft(foldr.Fullpath, "/") return foldr.WithURL(), err } @@ -190,6 +250,7 @@ func (ss *sqlStore) GetParents(ctx context.Context, q folder.GetParentsQuery) ([ } var folders []*folder.Folder + // covered by UQE_folder_org_id_uid recQuery := ` WITH RECURSIVE RecQry AS ( SELECT * FROM folder WHERE uid = ? AND org_id = ? @@ -228,7 +289,7 @@ func (ss *sqlStore) GetParents(ctx context.Context, q folder.GetParentsQuery) ([ if len(folders) < 1 { // the query is expected to return at least the same folder // if it's empty it means that the folder does not exist - return nil, folder.ErrFolderNotFound + return nil, folder.ErrFolderNotFound.Errorf("folder not found") } return util.Reverse(folders[1:]), nil @@ -240,6 +301,7 @@ func (ss *sqlStore) GetChildren(ctx context.Context, q folder.GetChildrenQuery) err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { sql := strings.Builder{} args := make([]any, 0, 2) + // covered by UQE_folder_org_id_parent_uid_title if q.UID == "" { sql.WriteString("SELECT * FROM folder WHERE parent_uid IS NULL AND org_id=?") args = append(args, q.OrgID) @@ -248,15 +310,16 @@ func (ss *sqlStore) GetChildren(ctx context.Context, q folder.GetChildrenQuery) args = append(args, q.UID, q.OrgID) } - if q.FolderUIDs != nil { - sql.WriteString(" AND uid IN (?") - for range q.FolderUIDs[1:] { - sql.WriteString(", ?") - } - sql.WriteString(")") - for _, uid := range q.FolderUIDs { + if len(q.FolderUIDs) > 0 { + sql.WriteString(" AND uid IN (") + for i, uid := range q.FolderUIDs { + if i > 0 { + sql.WriteString(", ") + } + sql.WriteString("?") args = append(args, uid) } + sql.WriteString(")") } sql.WriteString(" ORDER BY title ASC") @@ -283,19 +346,21 @@ func (ss *sqlStore) GetChildren(ctx context.Context, q folder.GetChildrenQuery) return folders, err } -func (ss *sqlStore) getParentsMySQL(ctx context.Context, cmd folder.GetParentsQuery) (folders []*folder.Folder, err error) { +func (ss *sqlStore) getParentsMySQL(ctx context.Context, q folder.GetParentsQuery) (folders []*folder.Folder, err error) { err = ss.db.WithDbSession(ctx, func(sess *db.Session) error { uid := "" - ok, err := sess.SQL("SELECT parent_uid FROM folder WHERE org_id=? AND uid=?", cmd.OrgID, cmd.UID).Get(&uid) + // covered by UQE_folder_org_id_uid + ok, err := sess.SQL("SELECT parent_uid FROM folder WHERE org_id=? AND uid=?", q.OrgID, q.UID).Get(&uid) if err != nil { return err } if !ok { - return folder.ErrFolderNotFound + return folder.ErrFolderNotFound.Errorf("folder not found") } for { f := &folder.Folder{} - ok, err := sess.SQL("SELECT * FROM folder WHERE org_id=? AND uid=?", cmd.OrgID, uid).Get(f) + // covered by UQE_folder_org_id_uid + ok, err := sess.SQL("SELECT * FROM folder WHERE org_id=? AND uid=?", q.OrgID, uid).Get(f) if err != nil { return err } @@ -314,6 +379,7 @@ func (ss *sqlStore) getParentsMySQL(ctx context.Context, cmd folder.GetParentsQu return util.Reverse(folders), err } +// TODO use a single query to get the height of a folder func (ss *sqlStore) GetHeight(ctx context.Context, foldrUID string, orgID int64, parentUID *string) (int, error) { height := -1 queue := []string{foldrUID} @@ -341,27 +407,253 @@ func (ss *sqlStore) GetHeight(ctx context.Context, foldrUID string, orgID int64, return height, nil } -func (ss *sqlStore) GetFolders(ctx context.Context, orgID int64, uids []string) ([]*folder.Folder, error) { - if len(uids) == 0 { - return []*folder.Folder{}, nil +// GetFolders returns org folders by their UIDs. +// If UIDs is empty, it returns all folders in the org. +// If WithFullpath is true it computes also the full path of a folder. +// The full path is a string that contains the titles of all parent folders separated by a slash. +// For example, if the folder structure is: +// +// A +// └── B +// └── C +// +// The full path of C is "A/B/C". +// The full path of B is "A/B". +// The full path of A is "A". +// If a folder contains a slash in its title, it is escaped with a backslash. +// For example, if the folder structure is: +// +// A +// └── B/C +// +// The full path of C is "A/B\/C". +// +// If FullpathUIDs is true it computes a string that contains the UIDs of all parent folders separated by slash. +// For example, if the folder structure is: +// +// A (uid: "uid1") +// └── B (uid: "uid2") +// └── C (uid: "uid3") +// +// The full path UIDs of C is "uid1/uid2/uid3". +// The full path UIDs of B is "uid1/uid2". +// The full path UIDs of A is "uid1". +func (ss *sqlStore) GetFolders(ctx context.Context, q getFoldersQuery) ([]*folder.Folder, error) { + if q.BatchSize == 0 { + q.BatchSize = DEFAULT_BATCH_SIZE } + var folders []*folder.Folder if err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { - b := strings.Builder{} - b.WriteString(`SELECT * FROM folder WHERE org_id=? AND uid IN (?` + strings.Repeat(", ?", len(uids)-1) + `)`) - args := []any{orgID} - for _, uid := range uids { - args = append(args, uid) - } - return sess.SQL(b.String(), args...).Find(&folders) + return batch(len(q.UIDs), int(q.BatchSize), func(start, end int) error { + partialFolders := make([]*folder.Folder, 0, q.BatchSize) + partialUIDs := q.UIDs[start:min(end, len(q.UIDs))] + s := strings.Builder{} + s.WriteString(`SELECT f0.id, f0.org_id, f0.uid, f0.parent_uid, f0.title, f0.description, f0.created, f0.updated`) + // compute full path column if requested + if q.WithFullpath { + s.WriteString(fmt.Sprintf(`, %s AS fullpath`, getFullpathSQL(ss.db.GetDialect()))) + } + // compute full path UIDs column if requested + if q.WithFullpathUIDs { + s.WriteString(fmt.Sprintf(`, %s AS fullpath_uids`, getFullapathUIDsSQL(ss.db.GetDialect()))) + } + s.WriteString(` FROM folder f0`) + // join the same table multiple times to compute the full path of a folder + if q.WithFullpath || q.WithFullpathUIDs || len(q.ancestorUIDs) > 0 { + s.WriteString(getFullpathJoinsSQL()) + } + // covered by UQE_folder_org_id_uid + args := []any{} + if q.OrgID > 0 { + s.WriteString(` WHERE f0.org_id=?`) + args = []any{q.OrgID} + } + if len(partialUIDs) > 0 { + s.WriteString(` AND f0.uid IN (?` + strings.Repeat(", ?", len(partialUIDs)-1) + `)`) + for _, uid := range partialUIDs { + args = append(args, uid) + } + } + + if len(q.ancestorUIDs) == 0 { + if q.OrderByTitle { + s.WriteString(` ORDER BY f0.title ASC`) + } + + err := sess.SQL(s.String(), args...).Find(&partialFolders) + if err != nil { + return err + } + folders = append(folders, partialFolders...) + return nil + } + + // filter out folders if they are not in the subtree of the given ancestor folders + if err := batch(len(q.ancestorUIDs), int(q.BatchSize), func(start2, end2 int) error { + s2, args2 := getAncestorsSQL(ss.db.GetDialect(), q.ancestorUIDs, start2, end2, s.String(), args) + if q.OrderByTitle { + s2 += " ORDER BY f0.title ASC" + } + err := sess.SQL(s2, args2...).Find(&partialFolders) + if err != nil { + return err + } + folders = append(folders, partialFolders...) + return nil + }); err != nil { + return err + } + return nil + }) }); err != nil { return nil, err } // Add URLs for i, f := range folders { + f.Fullpath = strings.TrimLeft(f.Fullpath, "/") + f.FullpathUIDs = strings.TrimLeft(f.FullpathUIDs, "/") folders[i] = f.WithURL() } return folders, nil } + +func (ss *sqlStore) GetDescendants(ctx context.Context, orgID int64, ancestor_uid string) ([]*folder.Folder, error) { + var folders []*folder.Folder + + recursiveQueriesAreSupported, err := ss.db.RecursiveQueriesAreSupported() + if err != nil { + return nil, err + } + switch recursiveQueriesAreSupported { + case true: + // covered by UQE_folder_org_id_parent_uid_title + recQuery := ` + WITH RECURSIVE RecQry AS ( + SELECT * FROM folder WHERE parent_uid = ? AND org_id = ? + UNION ALL SELECT f.* FROM folder f INNER JOIN RecQry r ON f.parent_uid = r.uid and f.org_id = r.org_id + ) + SELECT * FROM RecQry; + ` + if err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { + err := sess.SQL(recQuery, ancestor_uid, orgID).Find(&folders) + if err != nil { + return folder.ErrDatabaseError.Errorf("failed to get folder descendants: %w", err) + } + return nil + }); err != nil { + return nil, err + } + default: + // this is suboptimal because results is full table scan on f0 + // but it's the best we can do without recursive CTE + if err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { + s := strings.Builder{} + args := make([]any, 0, 1+folder.MaxNestedFolderDepth) + args = append(args, orgID) + // covered by UQE_folder_org_id_uid + s.WriteString(`SELECT f0.id, f0.org_id, f0.uid, f0.parent_uid, f0.title, f0.description, f0.created, f0.updated`) + s.WriteString(` FROM folder f0`) + s.WriteString(getFullpathJoinsSQL()) + s.WriteString(` WHERE f0.org_id=?`) + s.WriteString(` AND (`) + for i := 1; i <= folder.MaxNestedFolderDepth; i++ { + if i > 1 { + s.WriteString(` OR `) + } + s.WriteString(fmt.Sprintf(`f%d.uid=?`, i)) + args = append(args, ancestor_uid) + } + s.WriteString(`)`) + return sess.SQL(s.String(), args...).Find(&folders) + }); err != nil { + return nil, err + } + } + + // Add URLs + for i, f := range folders { + folders[i] = f.WithURL() + } + + return folders, nil +} + +func getFullpathSQL(dialect migrator.Dialect) string { + escaped := "\\/" + if dialect.DriverName() == migrator.MySQL { + escaped = "\\\\/" + } + concatCols := make([]string, 0, folder.MaxNestedFolderDepth) + concatCols = append(concatCols, fmt.Sprintf("COALESCE(REPLACE(f0.title, '/', '%s'), '')", escaped)) + for i := 1; i <= folder.MaxNestedFolderDepth; i++ { + concatCols = append([]string{fmt.Sprintf("COALESCE(REPLACE(f%d.title, '/', '%s'), '')", i, escaped), "'/'"}, concatCols...) + } + return dialect.Concat(concatCols...) +} + +func getFullapathUIDsSQL(dialect migrator.Dialect) string { + concatCols := make([]string, 0, folder.MaxNestedFolderDepth) + concatCols = append(concatCols, "COALESCE(f0.uid, '')") + for i := 1; i <= folder.MaxNestedFolderDepth; i++ { + concatCols = append([]string{fmt.Sprintf("COALESCE(f%d.uid, '')", i), "'/'"}, concatCols...) + } + return dialect.Concat(concatCols...) +} + +// getFullpathJoinsSQL returns a SQL fragment that joins the same table multiple times to get the full path of a folder. +func getFullpathJoinsSQL() string { + joins := make([]string, 0, folder.MaxNestedFolderDepth) + for i := 1; i <= folder.MaxNestedFolderDepth; i++ { + // covered by UQE_folder_org_id_uid + joins = append(joins, fmt.Sprintf(` LEFT JOIN folder f%d ON f%d.org_id = f%d.org_id AND f%d.uid = f%d.parent_uid`, i, i, i-1, i, i-1)) + } + return strings.Join(joins, "\n") +} + +func getAncestorsSQL(dialect migrator.Dialect, ancestorUIDs []string, start int, end int, origSQL string, origArgs []any) (string, []any) { + s2 := strings.Builder{} + s2.WriteString(origSQL) + args2 := make([]any, 0, len(ancestorUIDs)*folder.MaxNestedFolderDepth) + args2 = append(args2, origArgs...) + + partialAncestorUIDs := ancestorUIDs[start:min(end, len(ancestorUIDs))] + partialArgs := make([]any, 0, len(partialAncestorUIDs)) + for _, uid := range partialAncestorUIDs { + partialArgs = append(partialArgs, uid) + } + s2.WriteString(` AND ( f0.uid IN (?` + strings.Repeat(", ?", len(partialAncestorUIDs)-1) + `)`) + args2 = append(args2, partialArgs...) + for i := 1; i <= folder.MaxNestedFolderDepth; i++ { + s2.WriteString(fmt.Sprintf(` OR f%d.uid IN (?`+strings.Repeat(", ?", len(partialAncestorUIDs)-1)+`)`, i)) + args2 = append(args2, partialArgs...) + } + s2.WriteString(` )`) + return s2.String(), args2 +} + +func batch(count, batchSize int, eachFn func(start, end int) error) error { + if count == 0 { + if err := eachFn(0, 0); err != nil { + return err + } + return nil + } + + for i := 0; i < count; { + end := i + batchSize + if end > count { + end = count + } + + if err := eachFn(i, end); err != nil { + return err + } + + i = end + } + + return nil +} diff --git a/pkg/services/folder/folderimpl/sqlstore_test.go b/pkg/services/folder/folderimpl/sqlstore_test.go index bd711630f78d0..08df34d0568df 100644 --- a/pkg/services/folder/folderimpl/sqlstore_test.go +++ b/pkg/services/folder/folderimpl/sqlstore_test.go @@ -3,15 +3,16 @@ package folderimpl import ( "context" "fmt" + "path" "slices" "sort" "testing" "github.com/google/go-cmp/cmp" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" @@ -29,7 +30,7 @@ func TestIntegrationCreate(t *testing.T) { } db := sqlstore.InitTestDB(t) - folderStore := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) + folderStore := ProvideStore(db, db.Cfg) orgID := CreateOrg(t, db) @@ -64,14 +65,13 @@ func TestIntegrationCreate(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { - err := folderStore.Delete(context.Background(), f.UID, orgID) + err := folderStore.Delete(context.Background(), []string{f.UID}, orgID) require.NoError(t, err) }) assert.Equal(t, folderTitle, f.Title) assert.Equal(t, folderDsc, f.Description) - // nolint:staticcheck - assert.NotEmpty(t, f.ID) + assert.NotEmpty(t, f.UID) assert.Equal(t, uid, f.UID) assert.Empty(t, f.ParentUID) assert.NotEmpty(t, f.URL) @@ -98,13 +98,12 @@ func TestIntegrationCreate(t *testing.T) { }) require.NoError(t, err) require.Equal(t, "parent", parent.Title) - // nolint:staticcheck - require.NotEmpty(t, parent.ID) + require.NotEmpty(t, parent.UID) assert.Equal(t, parentUID, parent.UID) assert.NotEmpty(t, parent.URL) t.Cleanup(func() { - err := folderStore.Delete(context.Background(), parent.UID, orgID) + err := folderStore.Delete(context.Background(), []string{parent.UID}, orgID) require.NoError(t, err) }) assertAncestorUIDs(t, folderStore, parent, []string{folder.GeneralFolderUID}) @@ -119,14 +118,13 @@ func TestIntegrationCreate(t *testing.T) { }) require.NoError(t, err) t.Cleanup(func() { - err := folderStore.Delete(context.Background(), f.UID, orgID) + err := folderStore.Delete(context.Background(), []string{f.UID}, orgID) require.NoError(t, err) }) assert.Equal(t, folderTitle, f.Title) assert.Equal(t, folderDsc, f.Description) - // nolint:staticcheck - assert.NotEmpty(t, f.ID) + assert.NotEmpty(t, f.UID) assert.Equal(t, uid, f.UID) assert.Equal(t, parentUID, f.ParentUID) assert.NotEmpty(t, f.URL) @@ -152,7 +150,7 @@ func TestIntegrationDelete(t *testing.T) { } db := sqlstore.InitTestDB(t) - folderStore := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) + folderStore := ProvideStore(db, db.Cfg) orgID := CreateOrg(t, db) @@ -168,7 +166,7 @@ func TestIntegrationDelete(t *testing.T) { t.Cleanup(func() { for _, uid := range ancestorUIDs[1:] { - err := folderStore.Delete(context.Background(), uid, orgID) + err := folderStore.Delete(context.Background(), []string{uid}, orgID) require.NoError(t, err) } }) @@ -181,7 +179,7 @@ func TestIntegrationDelete(t *testing.T) { */ t.Run("deleting a leaf folder should succeed", func(t *testing.T) { - err := folderStore.Delete(context.Background(), ancestorUIDs[len(ancestorUIDs)-1], orgID) + err := folderStore.Delete(context.Background(), []string{ancestorUIDs[len(ancestorUIDs)-1]}, orgID) require.NoError(t, err) children, err := folderStore.GetChildren(context.Background(), folder.GetChildrenQuery{ @@ -199,7 +197,7 @@ func TestIntegrationUpdate(t *testing.T) { } db := sqlstore.InitTestDB(t) - folderStore := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) + folderStore := ProvideStore(db, db.Cfg) orgID := CreateOrg(t, db) @@ -224,7 +222,7 @@ func TestIntegrationUpdate(t *testing.T) { require.NoError(t, err) require.Equal(t, f.ParentUID, parent.UID) t.Cleanup(func() { - err := folderStore.Delete(context.Background(), f.UID, orgID) + err := folderStore.Delete(context.Background(), []string{f.UID}, orgID) require.NoError(t, err) }) @@ -374,7 +372,7 @@ func TestIntegrationGet(t *testing.T) { } db := sqlstore.InitTestDB(t) - folderStore := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) + folderStore := ProvideStore(db, db.Cfg) orgID := CreateOrg(t, db) @@ -387,11 +385,14 @@ func TestIntegrationGet(t *testing.T) { UID: uid1, }) require.NoError(t, err) - - t.Cleanup(func() { - err := folderStore.Delete(context.Background(), f.UID, orgID) - require.NoError(t, err) + subfolderWithSameName, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + Title: folderTitle, + Description: folderDsc, + OrgID: orgID, + UID: util.GenerateShortUID(), + ParentUID: f.UID, }) + require.NoError(t, err) t.Run("should gently fail in case of bad request", func(t *testing.T) { _, err = folderStore.Get(context.Background(), folder.GetFolderQuery{}) @@ -404,8 +405,6 @@ func TestIntegrationGet(t *testing.T) { OrgID: orgID, }) require.NoError(t, err) - // nolint:staticcheck - assert.Equal(t, f.ID, ff.ID) assert.Equal(t, f.UID, ff.UID) assert.Equal(t, f.OrgID, ff.OrgID) assert.Equal(t, f.Title, ff.Title) @@ -422,8 +421,6 @@ func TestIntegrationGet(t *testing.T) { OrgID: orgID, }) require.NoError(t, err) - // nolint:staticcheck - assert.Equal(t, f.ID, ff.ID) assert.Equal(t, f.UID, ff.UID) assert.Equal(t, f.OrgID, ff.OrgID) assert.Equal(t, f.Title, ff.Title) @@ -434,13 +431,29 @@ func TestIntegrationGet(t *testing.T) { assert.NotEmpty(t, ff.URL) }) + t.Run("get folder by title and parent UID should succeed", func(t *testing.T) { + ff, err := folderStore.Get(context.Background(), folder.GetFolderQuery{ + Title: &f.Title, + OrgID: orgID, + ParentUID: &uid1, + }) + require.NoError(t, err) + assert.Equal(t, subfolderWithSameName.UID, ff.UID) + assert.Equal(t, subfolderWithSameName.OrgID, ff.OrgID) + assert.Equal(t, subfolderWithSameName.Title, ff.Title) + assert.Equal(t, subfolderWithSameName.Description, ff.Description) + assert.Equal(t, subfolderWithSameName.ParentUID, ff.ParentUID) + assert.NotEmpty(t, ff.Created) + assert.NotEmpty(t, ff.Updated) + assert.NotEmpty(t, ff.URL) + }) + t.Run("get folder by title should succeed", func(t *testing.T) { ff, err := folderStore.Get(context.Background(), folder.GetFolderQuery{ - ID: &f.ID, // nolint:staticcheck + UID: &f.UID, + OrgID: orgID, }) require.NoError(t, err) - // nolint:staticcheck - assert.Equal(t, f.ID, ff.ID) assert.Equal(t, f.UID, ff.UID) assert.Equal(t, f.OrgID, ff.OrgID) assert.Equal(t, f.Title, ff.Title) @@ -450,6 +463,24 @@ func TestIntegrationGet(t *testing.T) { assert.NotEmpty(t, ff.Updated) assert.NotEmpty(t, ff.URL) }) + + t.Run("get folder with fullpath should set fullpath as expected", func(t *testing.T) { + ff, err := folderStore.Get(context.Background(), folder.GetFolderQuery{ + UID: &subfolderWithSameName.UID, + OrgID: orgID, + WithFullpath: true, + }) + require.NoError(t, err) + assert.Equal(t, subfolderWithSameName.UID, ff.UID) + assert.Equal(t, subfolderWithSameName.OrgID, ff.OrgID) + assert.Equal(t, subfolderWithSameName.Title, ff.Title) + assert.Equal(t, subfolderWithSameName.Description, ff.Description) + assert.Equal(t, path.Join(f.Title, subfolderWithSameName.Title), ff.Fullpath) + assert.Equal(t, f.UID, ff.ParentUID) + assert.NotEmpty(t, ff.Created) + assert.NotEmpty(t, ff.Updated) + assert.NotEmpty(t, ff.URL) + }) } func TestIntegrationGetParents(t *testing.T) { @@ -458,7 +489,7 @@ func TestIntegrationGetParents(t *testing.T) { } db := sqlstore.InitTestDB(t) - folderStore := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) + folderStore := ProvideStore(db, db.Cfg) orgID := CreateOrg(t, db) @@ -473,7 +504,7 @@ func TestIntegrationGetParents(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { - err := folderStore.Delete(context.Background(), f.UID, orgID) + err := folderStore.Delete(context.Background(), []string{f.UID}, orgID) require.NoError(t, err) }) @@ -526,7 +557,7 @@ func TestIntegrationGetChildren(t *testing.T) { } db := sqlstore.InitTestDB(t) - folderStore := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) + folderStore := ProvideStore(db, db.Cfg) orgID := CreateOrg(t, db) @@ -545,7 +576,7 @@ func TestIntegrationGetChildren(t *testing.T) { t.Cleanup(func() { for _, uid := range treeLeaves { - err := folderStore.Delete(context.Background(), uid, orgID) + err := folderStore.Delete(context.Background(), []string{uid}, orgID) require.NoError(t, err) } }) @@ -706,7 +737,7 @@ func TestIntegrationGetHeight(t *testing.T) { } db := sqlstore.InitTestDB(t) - folderStore := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) + folderStore := ProvideStore(db, db.Cfg) orgID := CreateOrg(t, db) @@ -739,7 +770,7 @@ func TestIntegrationGetFolders(t *testing.T) { foldersNum := 10 db := sqlstore.InitTestDB(t) - folderStore := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) + folderStore := ProvideStore(db, db.Cfg) orgID := CreateOrg(t, db) @@ -762,31 +793,97 @@ func TestIntegrationGetFolders(t *testing.T) { t.Cleanup(func() { for _, uid := range uids { - err := folderStore.Delete(context.Background(), uid, orgID) + err := folderStore.Delete(context.Background(), []string{uid}, orgID) require.NoError(t, err) } }) t.Run("get folders by UIDs should succeed", func(t *testing.T) { - ff, err := folderStore.GetFolders(context.Background(), orgID, uids) + actualFolders, err := folderStore.GetFolders(context.Background(), NewGetFoldersQuery(folder.GetFoldersQuery{OrgID: orgID, UIDs: uids[1:]})) + require.NoError(t, err) + assert.Equal(t, len(uids[1:]), len(actualFolders)) + for _, f := range folders[1:] { + folderInResponseIdx := slices.IndexFunc(actualFolders, func(rf *folder.Folder) bool { + return rf.UID == f.UID + }) + assert.NotEqual(t, -1, folderInResponseIdx) + actualFolder := actualFolders[folderInResponseIdx] + assert.Equal(t, f.UID, actualFolder.UID) + assert.Equal(t, f.OrgID, actualFolder.OrgID) + assert.Equal(t, f.Title, actualFolder.Title) + assert.Equal(t, f.Description, actualFolder.Description) + assert.NotEmpty(t, actualFolder.Created) + assert.NotEmpty(t, actualFolder.Updated) + assert.NotEmpty(t, actualFolder.URL) + } + }) + + t.Run("get folders by UIDs batching should work as expected", func(t *testing.T) { + q := NewGetFoldersQuery(folder.GetFoldersQuery{OrgID: orgID, UIDs: uids[1:], BatchSize: 3}) + actualFolders, err := folderStore.GetFolders(context.Background(), q) require.NoError(t, err) - assert.Equal(t, len(uids), len(ff)) - for _, f := range folders { - folderInResponseIdx := slices.IndexFunc(ff, func(rf *folder.Folder) bool { + assert.Equal(t, len(uids[1:]), len(actualFolders)) + for _, f := range folders[1:] { + folderInResponseIdx := slices.IndexFunc(actualFolders, func(rf *folder.Folder) bool { return rf.UID == f.UID }) assert.NotEqual(t, -1, folderInResponseIdx) - rf := ff[folderInResponseIdx] - // nolint:staticcheck - assert.Equal(t, f.ID, rf.ID) - assert.Equal(t, f.OrgID, rf.OrgID) - assert.Equal(t, f.Title, rf.Title) - assert.Equal(t, f.Description, rf.Description) - assert.NotEmpty(t, rf.Created) - assert.NotEmpty(t, rf.Updated) - assert.NotEmpty(t, rf.URL) + actualFolder := actualFolders[folderInResponseIdx] + assert.Equal(t, f.UID, actualFolder.UID) + assert.Equal(t, f.OrgID, actualFolder.OrgID) + assert.Equal(t, f.Title, actualFolder.Title) + assert.Equal(t, f.Description, actualFolder.Description) + assert.NotEmpty(t, actualFolder.Created) + assert.NotEmpty(t, actualFolder.Updated) + assert.NotEmpty(t, actualFolder.URL) } }) + + t.Run("get folders by UIDs with fullpath should succeed", func(t *testing.T) { + q := NewGetFoldersQuery(folder.GetFoldersQuery{OrgID: orgID, UIDs: uids[1:], WithFullpath: true}) + q.BatchSize = 3 + actualFolders, err := folderStore.GetFolders(context.Background(), q) + require.NoError(t, err) + assert.Equal(t, len(uids[1:]), len(actualFolders)) + for _, f := range folders[1:] { + folderInResponseIdx := slices.IndexFunc(actualFolders, func(rf *folder.Folder) bool { + return rf.UID == f.UID + }) + assert.NotEqual(t, -1, folderInResponseIdx) + actualFolder := actualFolders[folderInResponseIdx] + assert.Equal(t, f.UID, actualFolder.UID) + assert.Equal(t, f.OrgID, actualFolder.OrgID) + assert.Equal(t, f.Title, actualFolder.Title) + assert.Equal(t, f.Description, actualFolder.Description) + assert.NotEmpty(t, actualFolder.Created) + assert.NotEmpty(t, actualFolder.Updated) + assert.NotEmpty(t, actualFolder.URL) + assert.NotEmpty(t, actualFolder.Fullpath) + } + }) + + t.Run("get folders by UIDs and ancestor UIDs should work as expected", func(t *testing.T) { + q := NewGetFoldersQuery(folder.GetFoldersQuery{OrgID: orgID, UIDs: uids[1:], BatchSize: 3}) + q.ancestorUIDs = make([]string, 0, int(q.BatchSize)+1) + for i := 0; i < int(q.BatchSize); i++ { + q.ancestorUIDs = append(q.ancestorUIDs, uuid.New().String()) + } + q.ancestorUIDs = append(q.ancestorUIDs, folders[len(folders)-1].UID) + + actualFolders, err := folderStore.GetFolders(context.Background(), q) + require.NoError(t, err) + assert.Equal(t, 1, len(actualFolders)) + + f := folders[len(folders)-1] + actualFolder := actualFolders[0] + assert.Equal(t, f.UID, actualFolder.UID) + assert.Equal(t, f.OrgID, actualFolder.OrgID) + assert.Equal(t, f.Title, actualFolder.Title) + assert.Equal(t, f.Description, actualFolder.Description) + assert.NotEmpty(t, actualFolder.Created) + assert.NotEmpty(t, actualFolder.Updated) + assert.NotEmpty(t, actualFolder.URL) + }) } func CreateOrg(t *testing.T, db *sqlstore.SQLStore) int64 { @@ -822,8 +919,6 @@ func CreateSubtree(t *testing.T, store *sqlStore, orgID int64, parentUID string, f, err := store.Create(context.Background(), cmd) require.NoError(t, err) require.Equal(t, title, f.Title) - // nolint:staticcheck - require.NotEmpty(t, f.ID) require.NotEmpty(t, f.UID) parents, err := store.GetParents(context.Background(), folder.GetParentsQuery{ diff --git a/pkg/services/folder/folderimpl/store.go b/pkg/services/folder/folderimpl/store.go index b6dca0ef4b57d..d2177ae4bfb97 100644 --- a/pkg/services/folder/folderimpl/store.go +++ b/pkg/services/folder/folderimpl/store.go @@ -6,13 +6,25 @@ import ( "github.com/grafana/grafana/pkg/services/folder" ) +type getFoldersQuery struct { + folder.GetFoldersQuery + ancestorUIDs []string +} + +func NewGetFoldersQuery(q folder.GetFoldersQuery) getFoldersQuery { + return getFoldersQuery{ + GetFoldersQuery: q, + ancestorUIDs: []string{}, + } +} + // store is the interface which a folder store must implement. type store interface { // Create creates a folder and returns the newly-created folder. Create(ctx context.Context, cmd folder.CreateFolderCommand) (*folder.Folder, error) - // Delete deletes a folder from the folder store. - Delete(ctx context.Context, uid string, orgID int64) error + // Delete folders with the specified UIDs and orgID from the folder store. + Delete(ctx context.Context, UIDs []string, orgID int64) error // Update updates the given folder's UID, Title, and Description (update mode). // If the NewParentUID field is not nil, it updates also the parent UID (move mode). @@ -21,19 +33,21 @@ type store interface { Update(ctx context.Context, cmd folder.UpdateFolderCommand) (*folder.Folder, error) // Get returns a folder. - Get(ctx context.Context, cmd folder.GetFolderQuery) (*folder.Folder, error) + Get(ctx context.Context, q folder.GetFolderQuery) (*folder.Folder, error) // GetParents returns an ordered list of parent folder of the given folder. - GetParents(ctx context.Context, cmd folder.GetParentsQuery) ([]*folder.Folder, error) + GetParents(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) // GetChildren returns the set of immediate children folders (depth=1) of the // given folder. - GetChildren(ctx context.Context, cmd folder.GetChildrenQuery) ([]*folder.Folder, error) + GetChildren(ctx context.Context, q folder.GetChildrenQuery) ([]*folder.Folder, error) // GetHeight returns the height of the folder tree. When parentUID is set, the function would // verify in the meanwhile that parentUID is not present in the subtree of the folder with the given UID. GetHeight(ctx context.Context, foldrUID string, orgID int64, parentUID *string) (int, error) // GetFolders returns folders with given uids - GetFolders(ctx context.Context, orgID int64, uids []string) ([]*folder.Folder, error) + GetFolders(ctx context.Context, q getFoldersQuery) ([]*folder.Folder, error) + // GetDescendants returns all descendants of a folder + GetDescendants(ctx context.Context, orgID int64, anchestor_uid string) ([]*folder.Folder, error) } diff --git a/pkg/services/folder/folderimpl/store_fake.go b/pkg/services/folder/folderimpl/store_fake.go index 7c6a63457ccf6..33af4ebed0936 100644 --- a/pkg/services/folder/folderimpl/store_fake.go +++ b/pkg/services/folder/folderimpl/store_fake.go @@ -28,7 +28,7 @@ func (f *fakeStore) Create(ctx context.Context, cmd folder.CreateFolderCommand) return f.ExpectedFolder, f.ExpectedError } -func (f *fakeStore) Delete(ctx context.Context, uid string, orgID int64) error { +func (f *fakeStore) Delete(ctx context.Context, UIDs []string, orgID int64) error { f.DeleteCalled = true return f.ExpectedError } @@ -45,7 +45,7 @@ func (f *fakeStore) Get(ctx context.Context, cmd folder.GetFolderQuery) (*folder return f.ExpectedFolder, f.ExpectedError } -func (f *fakeStore) GetParents(ctx context.Context, cmd folder.GetParentsQuery) ([]*folder.Folder, error) { +func (f *fakeStore) GetParents(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) { return f.ExpectedParentFolders, f.ExpectedError } @@ -57,6 +57,10 @@ func (f *fakeStore) GetHeight(ctx context.Context, folderUID string, orgID int64 return f.ExpectedFolderHeight, f.ExpectedError } -func (f *fakeStore) GetFolders(ctx context.Context, orgID int64, uids []string) ([]*folder.Folder, error) { +func (f *fakeStore) GetFolders(ctx context.Context, q getFoldersQuery) ([]*folder.Folder, error) { + return f.ExpectedFolders, f.ExpectedError +} + +func (f *fakeStore) GetDescendants(ctx context.Context, orgID int64, ancestor_uid string) ([]*folder.Folder, error) { return f.ExpectedFolders, f.ExpectedError } diff --git a/pkg/services/folder/foldertest/folder_store_mock.go b/pkg/services/folder/foldertest/folder_store_mock.go index aa5b782a90324..93de1422257bb 100644 --- a/pkg/services/folder/foldertest/folder_store_mock.go +++ b/pkg/services/folder/foldertest/folder_store_mock.go @@ -40,25 +40,25 @@ func (_m *FakeFolderStore) GetFolderByID(ctx context.Context, orgID int64, id in return r0, r1 } -// GetFolderByTitle provides a mock function with given fields: ctx, orgID, title -func (_m *FakeFolderStore) GetFolderByTitle(ctx context.Context, orgID int64, title string) (*folder.Folder, error) { - ret := _m.Called(ctx, orgID, title) +// GetFolderByTitle provides a mock function with given fields: ctx, orgID, title, folderUID +func (_m *FakeFolderStore) GetFolderByTitle(ctx context.Context, orgID int64, title string, folderUID *string) (*folder.Folder, error) { + ret := _m.Called(ctx, orgID, title, folderUID) var r0 *folder.Folder var r1 error - if rf, ok := ret.Get(0).(func(context.Context, int64, string) (*folder.Folder, error)); ok { - return rf(ctx, orgID, title) + if rf, ok := ret.Get(0).(func(context.Context, int64, string, *string) (*folder.Folder, error)); ok { + return rf(ctx, orgID, title, folderUID) } - if rf, ok := ret.Get(0).(func(context.Context, int64, string) *folder.Folder); ok { - r0 = rf(ctx, orgID, title) + if rf, ok := ret.Get(0).(func(context.Context, int64, string, *string) *folder.Folder); ok { + r0 = rf(ctx, orgID, title, folderUID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*folder.Folder) } } - if rf, ok := ret.Get(1).(func(context.Context, int64, string) error); ok { - r1 = rf(ctx, orgID, title) + if rf, ok := ret.Get(1).(func(context.Context, int64, string, *string) error); ok { + r1 = rf(ctx, orgID, title, folderUID) } else { r1 = ret.Error(1) } diff --git a/pkg/services/folder/foldertest/foldertest.go b/pkg/services/folder/foldertest/foldertest.go index a1fbf4368bbff..7cfccb3b5c25f 100644 --- a/pkg/services/folder/foldertest/foldertest.go +++ b/pkg/services/folder/foldertest/foldertest.go @@ -19,7 +19,7 @@ func NewFakeService() *FakeService { var _ folder.Service = (*FakeService)(nil) -func (s *FakeService) GetChildren(ctx context.Context, cmd *folder.GetChildrenQuery) ([]*folder.Folder, error) { +func (s *FakeService) GetChildren(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.Folder, error) { return s.ExpectedFolders, s.ExpectedError } @@ -30,7 +30,7 @@ func (s *FakeService) GetParents(ctx context.Context, q folder.GetParentsQuery) func (s *FakeService) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { return s.ExpectedFolder, s.ExpectedError } -func (s *FakeService) Get(ctx context.Context, cmd *folder.GetFolderQuery) (*folder.Folder, error) { +func (s *FakeService) Get(ctx context.Context, q *folder.GetFolderQuery) (*folder.Folder, error) { return s.ExpectedFolder, s.ExpectedError } func (s *FakeService) Update(ctx context.Context, cmd *folder.UpdateFolderCommand) (*folder.Folder, error) { @@ -48,6 +48,10 @@ func (s *FakeService) RegisterService(service folder.RegistryService) error { return s.ExpectedError } -func (s *FakeService) GetDescendantCounts(ctx context.Context, cmd *folder.GetDescendantCountsQuery) (folder.DescendantCounts, error) { +func (s *FakeService) GetDescendantCounts(ctx context.Context, q *folder.GetDescendantCountsQuery) (folder.DescendantCounts, error) { return s.ExpectedDescendantCounts, s.ExpectedError } + +func (s *FakeService) GetFolders(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) { + return s.ExpectedFolders, s.ExpectedError +} diff --git a/pkg/services/folder/model.go b/pkg/services/folder/model.go index a28f24fe8f444..892c0e6ebed1d 100644 --- a/pkg/services/folder/model.go +++ b/pkg/services/folder/model.go @@ -4,8 +4,10 @@ import ( "fmt" "time" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util/errutil" ) @@ -13,6 +15,7 @@ import ( var ErrMaximumDepthReached = errutil.BadRequest("folder.maximum-depth-reached", errutil.WithPublicMessage("Maximum nested folder depth reached")) var ErrBadRequest = errutil.BadRequest("folder.bad-request") var ErrDatabaseError = errutil.Internal("folder.database-error") +var ErrConflict = errutil.Conflict("folder.conflict") var ErrInternal = errutil.Internal("folder.internal") var ErrCircularReference = errutil.BadRequest("folder.circular-reference", errutil.WithPublicMessage("Circular reference detected")) var ErrTargetRegistrySrvConflict = errutil.Internal("folder.target-registry-srv-conflict") @@ -41,15 +44,17 @@ type Folder struct { // TODO: validate if this field is required/relevant to folders. // currently there is no such column - Version int - URL string - UpdatedBy int64 - CreatedBy int64 - HasACL bool + Version int + URL string + UpdatedBy int64 + CreatedBy int64 + HasACL bool + Fullpath string `xorm:"fullpath"` + FullpathUIDs string `xorm:"fullpath_uids"` } var GeneralFolder = Folder{ID: 0, Title: "General"} - +var RootFolder = &Folder{ID: 0, Title: "Root", UID: GeneralFolderUID, ParentUID: ""} var SharedWithMeFolder = Folder{ Title: "Shared with me", Description: "Dashboards and folders shared with me", @@ -59,6 +64,7 @@ var SharedWithMeFolder = Folder{ } func (f *Folder) IsGeneral() bool { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() // nolint:staticcheck return f.ID == GeneralFolder.ID && f.Title == GeneralFolder.Title } @@ -142,10 +148,26 @@ type DeleteFolderCommand struct { type GetFolderQuery struct { UID *string // Deprecated: use FolderUID instead - ID *int64 - Title *string - OrgID int64 + ID *int64 + Title *string + ParentUID *string + OrgID int64 + WithFullpath bool + + SignedInUser identity.Requester `json:"-"` +} +type GetFoldersQuery struct { + OrgID int64 + UIDs []string + WithFullpath bool + WithFullpathUIDs bool + BatchSize uint64 + + // OrderByTitle is used to sort the folders by title + // Set to true when ordering is meaningful (used for listing folders) + // otherwise better to keep it false since ordering can have a performance impact + OrderByTitle bool SignedInUser identity.Requester `json:"-"` } @@ -168,6 +190,9 @@ type GetChildrenQuery struct { Limit int64 Page int64 + // Permission to filter by + Permission dashboardaccess.PermissionType + SignedInUser identity.Requester `json:"-"` // array of folder uids to filter by diff --git a/pkg/services/folder/registry.go b/pkg/services/folder/registry.go index 7a6ff865b0e72..d87b494576163 100644 --- a/pkg/services/folder/registry.go +++ b/pkg/services/folder/registry.go @@ -7,7 +7,7 @@ import ( ) type RegistryService interface { - DeleteInFolder(ctx context.Context, orgID int64, folderUID string, user identity.Requester) error - CountInFolder(ctx context.Context, orgID int64, folderUID string, user identity.Requester) (int64, error) + DeleteInFolders(ctx context.Context, orgID int64, folderUIDs []string, user identity.Requester) error + CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, user identity.Requester) (int64, error) Kind() string } diff --git a/pkg/services/folder/service.go b/pkg/services/folder/service.go index 03d2bc3a77741..50a1bec339185 100644 --- a/pkg/services/folder/service.go +++ b/pkg/services/folder/service.go @@ -6,7 +6,7 @@ import ( type Service interface { // GetChildren returns an array containing all child folders. - GetChildren(ctx context.Context, cmd *GetChildrenQuery) ([]*Folder, error) + GetChildren(ctx context.Context, q *GetChildrenQuery) ([]*Folder, error) // GetParents returns an array containing add parent folders if nested folders are enabled // otherwise it returns an empty array GetParents(ctx context.Context, q GetParentsQuery) ([]*Folder, error) @@ -16,7 +16,10 @@ type Service interface { // request. One of UID, ID or Title must be included. If multiple values // are included in the request, Grafana will select one in order of // specificity (UID, ID, Title). - Get(ctx context.Context, cmd *GetFolderQuery) (*Folder, error) + // When fetching a folder by Title, callers can optionally define a ParentUID. + // If ParentUID is not set then the folder will be fetched from the root level. + // If WithFullpath is true it computes also the full path of a folder. + Get(ctx context.Context, q *GetFolderQuery) (*Folder, error) // Update is used to update a folder's UID, Title and Description. To change // a folder's parent folder, use Move. @@ -25,7 +28,13 @@ type Service interface { // Move changes a folder's parent folder to the requested new parent. Move(ctx context.Context, cmd *MoveFolderCommand) (*Folder, error) RegisterService(service RegistryService) error - GetDescendantCounts(ctx context.Context, cmd *GetDescendantCountsQuery) (DescendantCounts, error) + // GetFolders returns org folders that are accessible by the signed in user by their UIDs. + // If WithFullpath is true it computes also the full path of a folder. + // The full path is a string that contains the titles of all parent folders separated by a slash. + // If a folder contains a slash in its title, it is escaped with a backslash. + // If FullpathUIDs is true it computes a string that contains the UIDs of all parent folders separated by slash. + GetFolders(ctx context.Context, q GetFoldersQuery) ([]*Folder, error) + GetDescendantCounts(ctx context.Context, q *GetDescendantCountsQuery) (DescendantCounts, error) } // FolderStore is a folder store. @@ -33,7 +42,10 @@ type Service interface { //go:generate mockery --name FolderStore --structname FakeFolderStore --outpkg foldertest --output foldertest --filename folder_store_mock.go type FolderStore interface { // GetFolderByTitle retrieves a folder by its title - GetFolderByTitle(ctx context.Context, orgID int64, title string) (*Folder, error) + // It expects a parentUID as last argument. + // If parentUID is empty then the folder will be fetched from the root level + // otherwise it will be fetched from the subfolder under the folder with the given UID. + GetFolderByTitle(ctx context.Context, orgID int64, title string, parentUID *string) (*Folder, error) // GetFolderByUID retrieves a folder by its UID GetFolderByUID(ctx context.Context, orgID int64, uid string) (*Folder, error) // GetFolderByID retrieves a folder by its ID diff --git a/pkg/services/grafana-apiserver/config.go b/pkg/services/grafana-apiserver/config.go deleted file mode 100644 index 70a0d1fd4295c..0000000000000 --- a/pkg/services/grafana-apiserver/config.go +++ /dev/null @@ -1,60 +0,0 @@ -package grafanaapiserver - -import ( - "fmt" - "net" - "path/filepath" - "strconv" - - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" -) - -type config struct { - enabled bool - devMode bool - - ip net.IP - port int - host string - apiURL string - - storageType StorageType - - etcdServers []string - dataPath string - - logLevel int -} - -func newConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles) *config { - defaultLogLevel := 0 - ip := net.ParseIP(cfg.HTTPAddr) - apiURL := cfg.AppURL - port, err := strconv.Atoi(cfg.HTTPPort) - if err != nil { - port = 3000 - } - - if cfg.Env == setting.Dev { - defaultLogLevel = 10 - port = 6443 - ip = net.ParseIP("127.0.0.1") - apiURL = fmt.Sprintf("https://%s:%d", ip, port) - } - - host := fmt.Sprintf("%s:%d", ip, port) - - return &config{ - enabled: features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServer), - devMode: features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerEnsureKubectlAccess), - dataPath: filepath.Join(cfg.DataPath, "grafana-apiserver"), - ip: ip, - port: port, - host: host, - logLevel: cfg.SectionWithEnvOverrides("grafana-apiserver").Key("log_level").MustInt(defaultLogLevel), - etcdServers: cfg.SectionWithEnvOverrides("grafana-apiserver").Key("etcd_servers").Strings(","), - storageType: StorageType(cfg.SectionWithEnvOverrides("grafana-apiserver").Key("storage_type").MustString(string(StorageTypeLegacy))), - apiURL: apiURL, - } -} diff --git a/pkg/services/grafana-apiserver/config_test.go b/pkg/services/grafana-apiserver/config_test.go deleted file mode 100644 index 9726277ee9003..0000000000000 --- a/pkg/services/grafana-apiserver/config_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package grafanaapiserver - -import ( - "net" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" -) - -func TestNewConfig(t *testing.T) { - cfg := setting.NewCfg() - cfg.Env = setting.Prod - cfg.DataPath = "/tmp/grafana" - cfg.HTTPAddr = "10.0.0.1" - cfg.HTTPPort = "4000" - cfg.AppURL = "http://test:4000" - - section := cfg.Raw.Section("grafana-apiserver") - section.Key("log_level").SetValue("5") - section.Key("etcd_servers").SetValue("http://localhost:2379") - - actual := newConfig(cfg, featuremgmt.WithFeatures(featuremgmt.FlagGrafanaAPIServer)) - - expected := &config{ - enabled: true, - devMode: false, - storageType: StorageTypeLegacy, - etcdServers: []string{"http://localhost:2379"}, - apiURL: "http://test:4000", - ip: net.ParseIP("10.0.0.1"), - port: 4000, - host: "10.0.0.1:4000", - dataPath: "/tmp/grafana/grafana-apiserver", - logLevel: 5, - } - require.Equal(t, expected, actual) -} diff --git a/pkg/services/grafana-apiserver/service.go b/pkg/services/grafana-apiserver/service.go deleted file mode 100644 index a0c8e64ddeede..0000000000000 --- a/pkg/services/grafana-apiserver/service.go +++ /dev/null @@ -1,493 +0,0 @@ -package grafanaapiserver - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "path" - goruntime "runtime" - "runtime/debug" - "strconv" - "strings" - "time" - - "github.com/go-logr/logr" - "github.com/grafana/dskit/services" - "golang.org/x/mod/semver" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/runtime/serializer" - "k8s.io/apimachinery/pkg/version" - openapinamer "k8s.io/apiserver/pkg/endpoints/openapi" - "k8s.io/apiserver/pkg/endpoints/responsewriter" - genericapiserver "k8s.io/apiserver/pkg/server" - "k8s.io/apiserver/pkg/server/options" - "k8s.io/apiserver/pkg/util/openapi" - "k8s.io/client-go/kubernetes/scheme" - clientrest "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" - "k8s.io/component-base/logs" - "k8s.io/klog/v2" - - "github.com/grafana/grafana/pkg/services/grafana-apiserver/auth/authorizer" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/utils" - "github.com/grafana/grafana/pkg/services/org" - - "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/infra/appcontext" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/modules" - "github.com/grafana/grafana/pkg/registry" - contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" - "github.com/grafana/grafana/pkg/services/featuremgmt" - entitystorage "github.com/grafana/grafana/pkg/services/grafana-apiserver/storage/entity" - filestorage "github.com/grafana/grafana/pkg/services/grafana-apiserver/storage/file" - "github.com/grafana/grafana/pkg/services/store/entity" - entityDB "github.com/grafana/grafana/pkg/services/store/entity/db" - "github.com/grafana/grafana/pkg/services/store/entity/sqlstash" - "github.com/grafana/grafana/pkg/setting" -) - -type StorageType string - -const ( - StorageTypeFile StorageType = "file" - StorageTypeEtcd StorageType = "etcd" - StorageTypeLegacy StorageType = "legacy" - StorageTypeUnified StorageType = "unified" - StorageTypeUnifiedGrpc StorageType = "unified-grpc" -) - -var ( - _ Service = (*service)(nil) - _ RestConfigProvider = (*service)(nil) - _ registry.BackgroundService = (*service)(nil) - _ registry.CanBeDisabled = (*service)(nil) - - Scheme = runtime.NewScheme() - Codecs = serializer.NewCodecFactory(Scheme) - - unversionedVersion = schema.GroupVersion{Group: "", Version: "v1"} - unversionedTypes = []runtime.Object{ - &metav1.Status{}, - &metav1.WatchEvent{}, - &metav1.APIVersions{}, - &metav1.APIGroupList{}, - &metav1.APIGroup{}, - &metav1.APIResourceList{}, - } -) - -func init() { - // we need to add the options to empty v1 - metav1.AddToGroupVersion(Scheme, schema.GroupVersion{Group: "", Version: "v1"}) - Scheme.AddUnversionedTypes(unversionedVersion, unversionedTypes...) -} - -type Service interface { - services.NamedService - registry.BackgroundService - registry.CanBeDisabled -} - -type APIRegistrar interface { - RegisterAPI(builder APIGroupBuilder) -} - -type RestConfigProvider interface { - GetRestConfig() *clientrest.Config -} - -type DirectRestConfigProvider interface { - // GetDirectRestConfig returns a k8s client configuration that will use the same - // logged logged in user as the current request context. This is useful when - // creating clients that map legacy API handlers to k8s backed services - GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Config -} - -type service struct { - *services.BasicService - - config *config - restConfig *clientrest.Config - - cfg *setting.Cfg - features featuremgmt.FeatureToggles - - stopCh chan struct{} - stoppedCh chan error - - db db.DB - rr routing.RouteRegister - handler http.Handler - builders []APIGroupBuilder - - tracing *tracing.TracingService - - authorizer *authorizer.GrafanaAuthorizer -} - -func ProvideService( - cfg *setting.Cfg, - features featuremgmt.FeatureToggles, - rr routing.RouteRegister, - orgService org.Service, - tracing *tracing.TracingService, - db db.DB, -) (*service, error) { - s := &service{ - config: newConfig(cfg, features), - cfg: cfg, - features: features, - rr: rr, - stopCh: make(chan struct{}), - builders: []APIGroupBuilder{}, - authorizer: authorizer.NewGrafanaAuthorizer(cfg, orgService), - tracing: tracing, - db: db, // For Unified storage - } - - // This will be used when running as a dskit service - s.BasicService = services.NewBasicService(s.start, s.running, nil).WithName(modules.GrafanaAPIServer) - - // TODO: this is very hacky - // We need to register the routes in ProvideService to make sure - // the routes are registered before the Grafana HTTP server starts. - proxyHandler := func(k8sRoute routing.RouteRegister) { - handler := func(c *contextmodel.ReqContext) { - if s.handler == nil { - c.Resp.WriteHeader(404) - _, _ = c.Resp.Write([]byte("Not found")) - return - } - - req := c.Req - if req.URL.Path == "" { - req.URL.Path = "/" - } - - // TODO: add support for the existing MetricsEndpointBasicAuth config option - if req.URL.Path == "/apiserver-metrics" { - req.URL.Path = "/metrics" - } - - resp := responsewriter.WrapForHTTP1Or2(c.Resp) - s.handler.ServeHTTP(resp, req) - } - k8sRoute.Any("/", middleware.ReqSignedIn, handler) - k8sRoute.Any("/*", middleware.ReqSignedIn, handler) - } - - s.rr.Group("/apis", proxyHandler) - s.rr.Group("/apiserver-metrics", proxyHandler) - s.rr.Group("/openapi", proxyHandler) - - return s, nil -} - -func (s *service) GetRestConfig() *clientrest.Config { - return s.restConfig -} - -func (s *service) IsDisabled() bool { - return !s.config.enabled -} - -// Run is an adapter for the BackgroundService interface. -func (s *service) Run(ctx context.Context) error { - if err := s.start(ctx); err != nil { - return err - } - return s.running(ctx) -} - -func (s *service) RegisterAPI(builder APIGroupBuilder) { - s.builders = append(s.builders, builder) -} - -func (s *service) start(ctx context.Context) error { - logger := logr.New(newLogAdapter(s.config.logLevel)) - klog.SetLoggerWithOptions(logger, klog.ContextualLogger(true)) - if _, err := logs.GlogSetter(strconv.Itoa(s.config.logLevel)); err != nil { - logger.Error(err, "failed to set log level") - } - - // Get the list of groups the server will support - builders := s.builders - - groupVersions := make([]schema.GroupVersion, 0, len(builders)) - // Install schemas - for _, b := range builders { - groupVersions = append(groupVersions, b.GetGroupVersion()) - if err := b.InstallSchema(Scheme); err != nil { - return err - } - - // Optionally register a custom authorizer - auth := b.GetAuthorizer() - if auth != nil { - s.authorizer.Register(b.GetGroupVersion(), auth) - } - } - - o := options.NewRecommendedOptions("/registry/grafana.app", Codecs.LegacyCodec(groupVersions...)) - o.SecureServing.BindAddress = s.config.ip - o.SecureServing.BindPort = s.config.port - o.Authentication.RemoteKubeConfigFileOptional = true - o.Authorization.RemoteKubeConfigFileOptional = true - - o.Admission = nil - o.CoreAPI = nil - - serverConfig := genericapiserver.NewRecommendedConfig(Codecs) - serverConfig.ExternalAddress = s.config.host - - if s.config.devMode { - // SecureServingOptions is used when the apiserver needs it's own listener. - // this is not needed in production, but it's useful for development kubectl access. - if err := o.SecureServing.ApplyTo(&serverConfig.SecureServing, &serverConfig.LoopbackClientConfig); err != nil { - return err - } - // AuthenticationOptions is needed to authenticate requests from kubectl in dev mode. - if err := o.Authentication.ApplyTo(&serverConfig.Authentication, serverConfig.SecureServing, serverConfig.OpenAPIConfig); err != nil { - return err - } - } else { - // In production mode, override ExternalAddress and LoopbackClientConfig. - // In dev mode we want to use the loopback client config - // and address provided by SecureServingOptions. - serverConfig.ExternalAddress = s.config.host - serverConfig.LoopbackClientConfig = &clientrest.Config{ - Host: s.config.apiURL, - TLSClientConfig: clientrest.TLSClientConfig{ - Insecure: true, - }, - } - } - - switch s.config.storageType { - case StorageTypeEtcd: - o.Etcd.StorageConfig.Transport.ServerList = s.config.etcdServers - if err := o.Etcd.Validate(); len(err) > 0 { - return err[0] - } - if err := o.Etcd.ApplyTo(&serverConfig.Config); err != nil { - return err - } - - case StorageTypeUnified: - if !s.features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorage) { - return fmt.Errorf("unified storage requires the unifiedStorage feature flag (and app_mode = development)") - } - - eDB, err := entityDB.ProvideEntityDB(s.db, s.cfg, s.features) - if err != nil { - return err - } - - store, err := sqlstash.ProvideSQLEntityServer(eDB) - if err != nil { - return err - } - - serverConfig.Config.RESTOptionsGetter = entitystorage.NewRESTOptionsGetter(s.cfg, store, o.Etcd.StorageConfig.Codec) - - case StorageTypeUnifiedGrpc: - // Create a connection to the gRPC server - // TODO: support configuring the gRPC server address - conn, err := grpc.Dial("localhost:10000", grpc.WithTransportCredentials(insecure.NewCredentials())) - if err != nil { - return err - } - - // TODO: determine when to close the connection, we cannot defer it here - // defer conn.Close() - - // Create a client instance - store := entity.NewEntityStoreClientWrapper(conn) - - serverConfig.Config.RESTOptionsGetter = entitystorage.NewRESTOptionsGetter(s.cfg, store, o.Etcd.StorageConfig.Codec) - - case StorageTypeFile: - serverConfig.RESTOptionsGetter = filestorage.NewRESTOptionsGetter(s.config.dataPath, o.Etcd.StorageConfig) - - case StorageTypeLegacy: - // do nothing? - } - - serverConfig.Authorization.Authorizer = s.authorizer - serverConfig.TracerProvider = s.tracing.GetTracerProvider() - - // Add OpenAPI specs for each group+version - defsGetter := GetOpenAPIDefinitions(builders) - serverConfig.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig( - openapi.GetOpenAPIDefinitionsWithoutDisabledFeatures(defsGetter), - openapinamer.NewDefinitionNamer(Scheme, scheme.Scheme)) - - serverConfig.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config( - openapi.GetOpenAPIDefinitionsWithoutDisabledFeatures(defsGetter), - openapinamer.NewDefinitionNamer(Scheme, scheme.Scheme)) - - // Add the custom routes to service discovery - serverConfig.OpenAPIV3Config.PostProcessSpec = GetOpenAPIPostProcessor(builders) - - // Set the swagger build versions - serverConfig.OpenAPIConfig.Info.Version = setting.BuildVersion - serverConfig.OpenAPIV3Config.Info.Version = setting.BuildVersion - - serverConfig.SkipOpenAPIInstallation = false - serverConfig.BuildHandlerChainFunc = func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler { - // Call DefaultBuildHandlerChain on the main entrypoint http.Handler - // See https://github.com/kubernetes/apiserver/blob/v0.28.0/pkg/server/config.go#L906 - // DefaultBuildHandlerChain provides many things, notably CORS, HSTS, cache-control, authz and latency tracking - requestHandler, err := GetAPIHandler( - delegateHandler, - c.LoopbackClientConfig, - builders) - if err != nil { - panic(fmt.Sprintf("could not build handler chain func: %s", err.Error())) - } - return genericapiserver.DefaultBuildHandlerChain(requestHandler, c) - } - - k8sVersion, err := getK8sApiserverVersion() - if err != nil { - return err - } - before, after, _ := strings.Cut(setting.BuildVersion, ".") - serverConfig.Version = &version.Info{ - Major: before, - Minor: after, - GoVersion: goruntime.Version(), - Platform: fmt.Sprintf("%s/%s", goruntime.GOOS, goruntime.GOARCH), - Compiler: goruntime.Compiler, - GitTreeState: setting.BuildBranch, - GitCommit: setting.BuildCommit, - BuildDate: time.Unix(setting.BuildStamp, 0).UTC().Format(time.DateTime), - GitVersion: k8sVersion, - } - - // Create the server - server, err := serverConfig.Complete().New("grafana-apiserver", genericapiserver.NewEmptyDelegate()) - if err != nil { - return err - } - - // Install the API Group+version - for _, b := range builders { - g, err := b.GetAPIGroupInfo(Scheme, Codecs, serverConfig.RESTOptionsGetter) - if err != nil { - return err - } - if g == nil || len(g.PrioritizedVersions) < 1 { - continue - } - err = server.InstallAPIGroup(g) - if err != nil { - return err - } - } - - // Used by the proxy wrapper registered in ProvideService - s.handler = server.Handler - s.restConfig = server.LoopbackClientConfig - - prepared := server.PrepareRun() - - // When running in production, do not start a standalone https server - if !s.config.devMode { - return nil - } - - // only write kubeconfig in dev mode - if err := s.ensureKubeConfig(); err != nil { - return err - } - - go func() { - s.stoppedCh <- prepared.Run(s.stopCh) - }() - return nil -} - -func (s *service) GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Config { - return &clientrest.Config{ - Transport: &roundTripperFunc{ - fn: func(req *http.Request) (*http.Response, error) { - ctx := appcontext.WithUser(req.Context(), c.SignedInUser) - w := httptest.NewRecorder() - s.handler.ServeHTTP(w, req.WithContext(ctx)) - return w.Result(), nil - }, - }, - } -} - -func (s *service) running(ctx context.Context) error { - // skip waiting for the server in prod mode - if !s.config.devMode { - <-ctx.Done() - return nil - } - - select { - case err := <-s.stoppedCh: - if err != nil { - return err - } - case <-ctx.Done(): - close(s.stopCh) - } - return nil -} - -func (s *service) ensureKubeConfig() error { - return clientcmd.WriteToFile( - utils.FormatKubeConfig(s.restConfig), - path.Join(s.config.dataPath, "grafana.kubeconfig"), - ) -} - -type roundTripperFunc struct { - fn func(req *http.Request) (*http.Response, error) -} - -func (f *roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f.fn(req) -} - -// find the k8s version according to build info -func getK8sApiserverVersion() (string, error) { - bi, ok := debug.ReadBuildInfo() - if !ok { - return "", fmt.Errorf("debug.ReadBuildInfo() failed") - } - - if len(bi.Deps) == 0 { - return "v?.?", nil // this is normal while debugging - } - - for _, dep := range bi.Deps { - if dep.Path == "k8s.io/apiserver" { - if !semver.IsValid(dep.Version) { - return "", fmt.Errorf("invalid semantic version for k8s.io/apiserver") - } - // v0 => v1 - majorVersion := strings.TrimPrefix(semver.Major(dep.Version), "v") - majorInt, err := strconv.Atoi(majorVersion) - if err != nil { - return "", fmt.Errorf("could not convert majorVersion to int. majorVersion: %s", majorVersion) - } - newMajor := fmt.Sprintf("v%d", majorInt+1) - return strings.Replace(dep.Version, semver.Major(dep.Version), newMajor, 1), nil - } - } - - return "", fmt.Errorf("could not find k8s.io/apiserver in build info") -} diff --git a/pkg/services/grpcserver/service.go b/pkg/services/grpcserver/service.go index b820662f5d1db..456a26932df8c 100644 --- a/pkg/services/grpcserver/service.go +++ b/pkg/services/grpcserver/service.go @@ -5,12 +5,12 @@ import ( "fmt" "net" + "github.com/grafana/dskit/instrument" + "github.com/grafana/dskit/middleware" "github.com/grafana/grafana-plugin-sdk-go/backend" grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware" grpcAuth "github.com/grpc-ecosystem/go-grpc-middleware/auth" "github.com/prometheus/client_golang/prometheus" - "github.com/weaveworks/common/instrument" - "github.com/weaveworks/common/middleware" "google.golang.org/grpc" "google.golang.org/grpc/credentials" diff --git a/pkg/services/guardian/accesscontrol_guardian_test.go b/pkg/services/guardian/accesscontrol_guardian_test.go index 380c924723b1d..ef8980bd54d5f 100644 --- a/pkg/services/guardian/accesscontrol_guardian_test.go +++ b/pkg/services/guardian/accesscontrol_guardian_test.go @@ -12,7 +12,6 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/licensing/licensingtest" "github.com/grafana/grafana/pkg/services/user" @@ -30,9 +29,8 @@ const ( var ( folderUIDScope = fmt.Sprintf("folders:uid:%s", folderUID) invalidFolderUIDScope = fmt.Sprintf("folders:uid:%s", invalidFolderUID) - // nolint:staticcheck - dashboard = &dashboards.Dashboard{OrgID: orgID, UID: dashUID, IsFolder: false, FolderID: folderID} - fldr = &dashboards.Dashboard{OrgID: orgID, UID: folderUID, IsFolder: true} + dashboard = &dashboards.Dashboard{OrgID: orgID, UID: dashUID, IsFolder: false, FolderUID: folderUID} + fldr = &dashboards.Dashboard{OrgID: orgID, UID: folderUID, IsFolder: true} ) type accessControlGuardianTestCase struct { @@ -80,7 +78,7 @@ func TestAccessControlDashboardGuardian_CanSave(t *testing.T) { }, { desc: "should be able to save dashboard under root with general folder scope", - dashboard: &dashboards.Dashboard{OrgID: orgID, UID: dashUID, IsFolder: false, FolderID: 0}, // nolint:staticcheck + dashboard: &dashboards.Dashboard{OrgID: orgID, UID: dashUID, IsFolder: false}, permissions: []accesscontrol.Permission{ { Action: dashboards.ActionDashboardsWrite, @@ -89,17 +87,6 @@ func TestAccessControlDashboardGuardian_CanSave(t *testing.T) { }, expected: true, }, - { - desc: "should be able to save dashboard with folder scope", - dashboard: dashboard, - permissions: []accesscontrol.Permission{ - { - Action: dashboards.ActionDashboardsWrite, - Scope: folderUIDScope, - }, - }, - expected: true, - }, { desc: "should not be able to save dashboard with incorrect dashboard scope", dashboard: dashboard, @@ -237,7 +224,7 @@ func TestAccessControlDashboardGuardian_CanEdit(t *testing.T) { }, { desc: "should be able to edit dashboard under root with general folder scope", - dashboard: &dashboards.Dashboard{OrgID: orgID, UID: dashUID, IsFolder: false, FolderID: 0}, // nolint:staticcheck + dashboard: &dashboards.Dashboard{OrgID: orgID, UID: dashUID, IsFolder: false}, permissions: []accesscontrol.Permission{ { Action: dashboards.ActionDashboardsWrite, @@ -246,17 +233,6 @@ func TestAccessControlDashboardGuardian_CanEdit(t *testing.T) { }, expected: true, }, - { - desc: "should be able to edit dashboard with folder scope", - dashboard: dashboard, - permissions: []accesscontrol.Permission{ - { - Action: dashboards.ActionDashboardsWrite, - Scope: folderUIDScope, - }, - }, - expected: true, - }, { desc: "should not be able to edit dashboard with incorrect dashboard scope", dashboard: dashboard, @@ -410,7 +386,7 @@ func TestAccessControlDashboardGuardian_CanView(t *testing.T) { }, { desc: "should be able to view dashboard under root with general folder scope", - dashboard: &dashboards.Dashboard{OrgID: orgID, UID: dashUID, IsFolder: false, FolderID: 0}, // nolint:staticcheck + dashboard: &dashboards.Dashboard{OrgID: orgID, UID: dashUID, IsFolder: false}, permissions: []accesscontrol.Permission{ { Action: dashboards.ActionDashboardsRead, @@ -419,17 +395,6 @@ func TestAccessControlDashboardGuardian_CanView(t *testing.T) { }, expected: true, }, - { - desc: "should be able to view dashboard with folder scope", - dashboard: dashboard, - permissions: []accesscontrol.Permission{ - { - Action: dashboards.ActionDashboardsRead, - Scope: folderUIDScope, - }, - }, - expected: true, - }, { desc: "should not be able to view dashboard with incorrect dashboard scope", dashboard: dashboard, @@ -530,6 +495,7 @@ func TestAccessControlDashboardGuardian_CanView(t *testing.T) { }) } } + func TestAccessControlDashboardGuardian_CanAdmin(t *testing.T) { tests := []accessControlGuardianTestCase{ { @@ -579,7 +545,7 @@ func TestAccessControlDashboardGuardian_CanAdmin(t *testing.T) { }, { desc: "should be able to admin dashboard under root with general folder scope", - dashboard: &dashboards.Dashboard{OrgID: orgID, UID: dashUID, IsFolder: false, FolderID: 0}, // nolint:staticcheck + dashboard: &dashboards.Dashboard{OrgID: orgID, UID: dashUID, IsFolder: false}, permissions: []accesscontrol.Permission{ { Action: dashboards.ActionDashboardsPermissionsRead, @@ -592,21 +558,6 @@ func TestAccessControlDashboardGuardian_CanAdmin(t *testing.T) { }, expected: true, }, - { - desc: "should be able to admin dashboard with folder scope", - dashboard: dashboard, - permissions: []accesscontrol.Permission{ - { - Action: dashboards.ActionDashboardsPermissionsRead, - Scope: folderUIDScope, - }, - { - Action: dashboards.ActionDashboardsPermissionsWrite, - Scope: folderUIDScope, - }, - }, - expected: true, - }, { desc: "should not be able to admin dashboard with incorrect dashboard scope", dashboard: dashboard, @@ -821,7 +772,7 @@ func TestAccessControlDashboardGuardian_CanDelete(t *testing.T) { }, { desc: "should be able to delete dashboard under root with general folder scope", - dashboard: &dashboards.Dashboard{OrgID: orgID, UID: dashUID, IsFolder: false, FolderID: 0}, // nolint:staticcheck + dashboard: &dashboards.Dashboard{OrgID: orgID, UID: dashUID, IsFolder: false}, permissions: []accesscontrol.Permission{ { Action: dashboards.ActionDashboardsDelete, @@ -830,17 +781,6 @@ func TestAccessControlDashboardGuardian_CanDelete(t *testing.T) { }, expected: true, }, - { - desc: "should be able to delete dashboard with folder scope", - dashboard: dashboard, - permissions: []accesscontrol.Permission{ - { - Action: dashboards.ActionDashboardsDelete, - Scope: folderUIDScope, - }, - }, - expected: true, - }, { desc: "should not be able to delete dashboard with incorrect dashboard scope", dashboard: dashboard, @@ -1019,8 +959,6 @@ func setupAccessControlGuardianTest( folderSvc := foldertest.NewFakeService() folderStore := foldertest.NewFakeFolderStore(t) - // nolint:staticcheck - folderStore.On("GetFolderByID", mock.Anything, mock.Anything, mock.Anything).Maybe().Return(&folder.Folder{ID: folderID, UID: folderUID, OrgID: orgID}, nil) ac.RegisterScopeAttributeResolver(dashboards.NewDashboardUIDScopeResolver(folderStore, fakeDashboardService, folderSvc)) ac.RegisterScopeAttributeResolver(dashboards.NewFolderUIDScopeResolver(folderSvc)) diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go index 992c470394071..0d393820eeb96 100644 --- a/pkg/services/guardian/guardian.go +++ b/pkg/services/guardian/guardian.go @@ -4,6 +4,7 @@ import ( "context" "slices" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" @@ -61,13 +62,21 @@ type FakeDashboardGuardian struct { CanViewValue bool CanAdminValue bool CanViewUIDs []string + CanEditUIDs []string + CanSaveUIDs []string } func (g *FakeDashboardGuardian) CanSave() (bool, error) { + if g.CanSaveUIDs != nil { + return slices.Contains(g.CanSaveUIDs, g.DashUID), nil + } return g.CanSaveValue, nil } func (g *FakeDashboardGuardian) CanEdit() (bool, error) { + if g.CanEditUIDs != nil { + return slices.Contains(g.CanEditUIDs, g.DashUID), nil + } return g.CanEditValue, nil } @@ -117,6 +126,7 @@ func MockDashboardGuardian(mock *FakeDashboardGuardian) { NewByFolder = func(_ context.Context, f *folder.Folder, orgId int64, user identity.Requester) (DashboardGuardian, error) { mock.OrgID = orgId mock.DashUID = f.UID + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Guardian).Inc() // nolint:staticcheck mock.DashID = f.ID mock.User = user diff --git a/pkg/services/ldap/api/service.go b/pkg/services/ldap/api/service.go index 2c288c60fb182..2d5de2a1da276 100644 --- a/pkg/services/ldap/api/service.go +++ b/pkg/services/ldap/api/service.go @@ -197,10 +197,10 @@ func (s *Service) PostSyncUserWithLDAP(c *contextmodel.ReqContext) response.Resp authModuleQuery := &login.GetAuthInfoQuery{UserId: usr.ID, AuthModule: login.LDAPAuthModule} if _, err := s.authInfoService.GetAuthInfo(c.Req.Context(), authModuleQuery); err != nil { // validate the userId comes from LDAP if errors.Is(err, user.ErrUserNotFound) { - return response.Error(404, user.ErrUserNotFound.Error(), nil) + return response.Error(http.StatusNotFound, user.ErrUserNotFound.Error(), nil) } - return response.Error(500, "Failed to get user", err) + return response.Error(http.StatusInternalServerError, "Failed to get user", err) } userInfo, _, err := ldapClient.User(usr.Login) diff --git a/pkg/services/libraryelements/api.go b/pkg/services/libraryelements/api.go index 1cf3bd012a777..970fd31212f3b 100644 --- a/pkg/services/libraryelements/api.go +++ b/pkg/services/libraryelements/api.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/infra/metrics" ac "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" @@ -62,6 +63,7 @@ func (l *LibraryElementService) createHandler(c *contextmodel.ReqContext) respon if cmd.FolderUID != nil { if *cmd.FolderUID == "" { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() // nolint:staticcheck cmd.FolderID = 0 generalFolderUID := ac.GeneralFolderUID @@ -71,6 +73,7 @@ func (l *LibraryElementService) createHandler(c *contextmodel.ReqContext) respon if err != nil || folder == nil { return response.ErrOrFallback(http.StatusBadRequest, "failed to get folder", err) } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() // nolint:staticcheck cmd.FolderID = folder.ID } @@ -81,8 +84,10 @@ func (l *LibraryElementService) createHandler(c *contextmodel.ReqContext) respon return toLibraryElementError(err, "Failed to create library element") } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() // nolint:staticcheck if element.FolderID != 0 { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() // nolint:staticcheck folder, err := l.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.SignedInUser.GetOrgID(), ID: &element.FolderID, SignedInUser: c.SignedInUser}) if err != nil { @@ -210,6 +215,7 @@ func (l *LibraryElementService) patchHandler(c *contextmodel.ReqContext) respons if cmd.FolderUID != nil { if *cmd.FolderUID == "" { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() // nolint:staticcheck cmd.FolderID = 0 } else { @@ -217,6 +223,7 @@ func (l *LibraryElementService) patchHandler(c *contextmodel.ReqContext) respons if err != nil || folder == nil { return response.Error(http.StatusBadRequest, "failed to get folder", err) } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() // nolint:staticcheck cmd.FolderID = folder.ID } @@ -227,8 +234,10 @@ func (l *LibraryElementService) patchHandler(c *contextmodel.ReqContext) respons return toLibraryElementError(err, "Failed to update library element") } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() // nolint:staticcheck if element.FolderID != 0 { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() // nolint:staticcheck folder, err := l.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.SignedInUser.GetOrgID(), ID: &element.FolderID, SignedInUser: c.SignedInUser}) if err != nil { @@ -309,31 +318,31 @@ func (l *LibraryElementService) filterLibraryPanelsByPermission(c *contextmodel. func toLibraryElementError(err error, message string) response.Response { if errors.Is(err, model.ErrLibraryElementAlreadyExists) { - return response.Error(400, model.ErrLibraryElementAlreadyExists.Error(), err) + return response.Error(http.StatusBadRequest, model.ErrLibraryElementAlreadyExists.Error(), err) } if errors.Is(err, model.ErrLibraryElementNotFound) { - return response.Error(404, model.ErrLibraryElementNotFound.Error(), err) + return response.Error(http.StatusNotFound, model.ErrLibraryElementNotFound.Error(), err) } if errors.Is(err, model.ErrLibraryElementDashboardNotFound) { - return response.Error(404, model.ErrLibraryElementDashboardNotFound.Error(), err) + return response.Error(http.StatusNotFound, model.ErrLibraryElementDashboardNotFound.Error(), err) } if errors.Is(err, model.ErrLibraryElementVersionMismatch) { - return response.Error(412, model.ErrLibraryElementVersionMismatch.Error(), err) + return response.Error(http.StatusPreconditionFailed, model.ErrLibraryElementVersionMismatch.Error(), err) } if errors.Is(err, dashboards.ErrFolderNotFound) { - return response.Error(404, dashboards.ErrFolderNotFound.Error(), err) + return response.Error(http.StatusNotFound, dashboards.ErrFolderNotFound.Error(), err) } if errors.Is(err, dashboards.ErrFolderAccessDenied) { - return response.Error(403, dashboards.ErrFolderAccessDenied.Error(), err) + return response.Error(http.StatusForbidden, dashboards.ErrFolderAccessDenied.Error(), err) } if errors.Is(err, model.ErrLibraryElementHasConnections) { - return response.Error(403, model.ErrLibraryElementHasConnections.Error(), err) + return response.Error(http.StatusForbidden, model.ErrLibraryElementHasConnections.Error(), err) } if errors.Is(err, model.ErrLibraryElementInvalidUID) { - return response.Error(400, model.ErrLibraryElementInvalidUID.Error(), err) + return response.Error(http.StatusBadRequest, model.ErrLibraryElementInvalidUID.Error(), err) } if errors.Is(err, model.ErrLibraryElementUIDTooLong) { - return response.Error(400, model.ErrLibraryElementUIDTooLong.Error(), err) + return response.Error(http.StatusBadRequest, model.ErrLibraryElementUIDTooLong.Error(), err) } return response.ErrOrFallback(http.StatusInternalServerError, message, err) } diff --git a/pkg/services/libraryelements/database.go b/pkg/services/libraryelements/database.go index 3622bbd59a950..b8c09c1281605 100644 --- a/pkg/services/libraryelements/database.go +++ b/pkg/services/libraryelements/database.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/kinds/librarypanel" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/auth/identity" @@ -20,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -146,6 +148,7 @@ func (l *LibraryElementService) createLibraryElement(c context.Context, signedIn } } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() element := model.LibraryElement{ OrgID: signedInUser.GetOrgID(), FolderID: cmd.FolderID, // nolint:staticcheck @@ -176,6 +179,7 @@ func (l *LibraryElementService) createLibraryElement(c context.Context, signedIn return err } } else { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() // nolint:staticcheck if err := l.requireEditPermissionsOnFolder(c, signedInUser, cmd.FolderID); err != nil { return err @@ -190,6 +194,7 @@ func (l *LibraryElementService) createLibraryElement(c context.Context, signedIn return nil }) + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() dto := model.LibraryElementDTO{ ID: element.ID, OrgID: element.OrgID, @@ -208,12 +213,12 @@ func (l *LibraryElementService) createLibraryElement(c context.Context, signedIn CreatedBy: librarypanel.LibraryElementDTOMetaUser{ Id: element.CreatedBy, Name: signedInUser.GetLogin(), - AvatarUrl: dtos.GetGravatarUrl(signedInUser.GetEmail()), + AvatarUrl: dtos.GetGravatarUrl(l.Cfg, signedInUser.GetEmail()), }, UpdatedBy: librarypanel.LibraryElementDTOMetaUser{ Id: element.UpdatedBy, Name: signedInUser.GetLogin(), - AvatarUrl: dtos.GetGravatarUrl(signedInUser.GetEmail()), + AvatarUrl: dtos.GetGravatarUrl(l.Cfg, signedInUser.GetEmail()), }, }, } @@ -229,6 +234,7 @@ func (l *LibraryElementService) deleteLibraryElement(c context.Context, signedIn if err != nil { return err } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() // nolint:staticcheck if err := l.requireEditPermissionsOnFolder(c, signedInUser, element.FolderID); err != nil { return err @@ -278,8 +284,9 @@ func (l *LibraryElementService) getLibraryElements(c context.Context, store db.D builder := db.NewSqlBuilder(cfg, features, store.GetDialect(), recursiveQueriesAreSupported) builder.Write(selectLibraryElementDTOWithMeta) builder.Write(", ? as folder_name ", cmd.FolderName) - builder.Write(", '' as folder_uid ") + builder.Write(", COALESCE((SELECT folder.uid FROM folder WHERE folder.id = le.folder_id), '') as folder_uid ") builder.Write(getFromLibraryElementDTOWithMeta(store.GetDialect())) + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() // nolint:staticcheck writeParamSelectorSQL(&builder, append(params, Pair{"folder_id", cmd.FolderID})...) builder.Write(" UNION ") @@ -289,7 +296,7 @@ func (l *LibraryElementService) getLibraryElements(c context.Context, store db.D builder.Write(getFromLibraryElementDTOWithMeta(store.GetDialect())) builder.Write(" INNER JOIN dashboard AS dashboard on le.folder_id = dashboard.id AND le.folder_id <> 0") writeParamSelectorSQL(&builder, params...) - builder.WriteDashboardPermissionFilter(signedInUser, dashboardaccess.PERMISSION_VIEW, "") + builder.WriteDashboardPermissionFilter(signedInUser, dashboardaccess.PERMISSION_VIEW, searchstore.TypeFolder) builder.Write(` OR dashboard.id=0`) if err := session.SQL(builder.GetSQLString(), builder.GetParams()...).Find(&libraryElements); err != nil { return err @@ -314,11 +321,16 @@ func (l *LibraryElementService) getLibraryElements(c context.Context, store db.D } } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() + folderUID := libraryElement.FolderUID + if libraryElement.FolderID == 0 { // nolint:staticcheck + folderUID = ac.GeneralFolderUID + } leDtos[i] = model.LibraryElementDTO{ ID: libraryElement.ID, OrgID: libraryElement.OrgID, FolderID: libraryElement.FolderID, // nolint:staticcheck - FolderUID: libraryElement.FolderUID, + FolderUID: folderUID, UID: libraryElement.UID, Name: libraryElement.Name, Kind: libraryElement.Kind, @@ -335,12 +347,12 @@ func (l *LibraryElementService) getLibraryElements(c context.Context, store db.D CreatedBy: librarypanel.LibraryElementDTOMetaUser{ Id: libraryElement.CreatedBy, Name: libraryElement.CreatedByName, - AvatarUrl: dtos.GetGravatarUrl(libraryElement.CreatedByEmail), + AvatarUrl: dtos.GetGravatarUrl(l.Cfg, libraryElement.CreatedByEmail), }, UpdatedBy: librarypanel.LibraryElementDTOMetaUser{ Id: libraryElement.UpdatedBy, Name: libraryElement.UpdatedByName, - AvatarUrl: dtos.GetGravatarUrl(libraryElement.UpdatedByEmail), + AvatarUrl: dtos.GetGravatarUrl(l.Cfg, libraryElement.UpdatedByEmail), }, }, } @@ -434,6 +446,7 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI return err } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() retDTOs := make([]model.LibraryElementDTO, 0) for _, element := range elements { retDTOs = append(retDTOs, model.LibraryElementDTO{ @@ -457,12 +470,12 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI CreatedBy: librarypanel.LibraryElementDTOMetaUser{ Id: element.CreatedBy, Name: element.CreatedByName, - AvatarUrl: dtos.GetGravatarUrl(element.CreatedByEmail), + AvatarUrl: dtos.GetGravatarUrl(l.Cfg, element.CreatedByEmail), }, UpdatedBy: librarypanel.LibraryElementDTOMetaUser{ Id: element.UpdatedBy, Name: element.UpdatedByName, - AvatarUrl: dtos.GetGravatarUrl(element.UpdatedByEmail), + AvatarUrl: dtos.GetGravatarUrl(l.Cfg, element.UpdatedByEmail), }, }, }) @@ -526,6 +539,7 @@ func (l *LibraryElementService) handleFolderIDPatches(ctx context.Context, eleme if err := l.requireEditPermissionsOnFolder(ctx, user, fromFolderID); err != nil { return err } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() // nolint:staticcheck elementToPatch.FolderID = toFolderID @@ -573,6 +587,7 @@ func (l *LibraryElementService) patchLibraryElement(c context.Context, signedInU } } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() var libraryElement = model.LibraryElement{ ID: elementInDB.ID, OrgID: signedInUser.GetOrgID(), @@ -596,6 +611,7 @@ func (l *LibraryElementService) patchLibraryElement(c context.Context, signedInU if cmd.Model == nil { libraryElement.Model = elementInDB.Model } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() // nolint:staticcheck if err := l.handleFolderIDPatches(c, &libraryElement, elementInDB.FolderID, cmd.FolderID, signedInUser); err != nil { return err @@ -612,6 +628,7 @@ func (l *LibraryElementService) patchLibraryElement(c context.Context, signedInU return model.ErrLibraryElementNotFound } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() dto = model.LibraryElementDTO{ ID: libraryElement.ID, OrgID: libraryElement.OrgID, @@ -630,12 +647,12 @@ func (l *LibraryElementService) patchLibraryElement(c context.Context, signedInU CreatedBy: librarypanel.LibraryElementDTOMetaUser{ Id: elementInDB.CreatedBy, Name: elementInDB.CreatedByName, - AvatarUrl: dtos.GetGravatarUrl(elementInDB.CreatedByEmail), + AvatarUrl: dtos.GetGravatarUrl(l.Cfg, elementInDB.CreatedByEmail), }, UpdatedBy: librarypanel.LibraryElementDTOMetaUser{ Id: libraryElement.UpdatedBy, Name: signedInUser.GetLogin(), - AvatarUrl: dtos.GetGravatarUrl(signedInUser.GetEmail()), + AvatarUrl: dtos.GetGravatarUrl(l.Cfg, signedInUser.GetEmail()), }, }, } @@ -683,7 +700,7 @@ func (l *LibraryElementService) getConnections(c context.Context, signedInUser i CreatedBy: librarypanel.LibraryElementDTOMetaUser{ Id: connection.CreatedBy, Name: connection.CreatedByName, - AvatarUrl: dtos.GetGravatarUrl(connection.CreatedByEmail), + AvatarUrl: dtos.GetGravatarUrl(l.Cfg, connection.CreatedByEmail), }, }) } @@ -711,6 +728,7 @@ func (l *LibraryElementService) getElementsForDashboardID(c context.Context, das return err } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() for _, element := range libraryElements { libraryElementMap[element.UID] = model.LibraryElementDTO{ ID: element.ID, @@ -732,12 +750,12 @@ func (l *LibraryElementService) getElementsForDashboardID(c context.Context, das CreatedBy: librarypanel.LibraryElementDTOMetaUser{ Id: element.CreatedBy, Name: element.CreatedByName, - AvatarUrl: dtos.GetGravatarUrl(element.CreatedByEmail), + AvatarUrl: dtos.GetGravatarUrl(l.Cfg, element.CreatedByEmail), }, UpdatedBy: librarypanel.LibraryElementDTOMetaUser{ Id: element.UpdatedBy, Name: element.UpdatedByName, - AvatarUrl: dtos.GetGravatarUrl(element.UpdatedByEmail), + AvatarUrl: dtos.GetGravatarUrl(l.Cfg, element.UpdatedByEmail), }, }, } @@ -761,6 +779,7 @@ func (l *LibraryElementService) connectElementsToDashboardID(c context.Context, if err != nil { return err } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() // nolint:staticcheck if err := l.requireViewPermissionsOnFolder(c, signedInUser, element.FolderID); err != nil { return err diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index a2078ce200d26..588bf8cce3c25 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -25,7 +25,6 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" - "github.com/grafana/grafana/pkg/services/alerting" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/database" @@ -44,12 +43,17 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/web" ) const userInDbName = "user_in_db" const userInDbAvatar = "/avatar/402d08de060496d6b6874495fe20f5ad" +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestDeleteLibraryPanelsInFolder(t *testing.T) { scenarioWithPanel(t, "When an admin tries to delete a folder that contains connected library elements, it should fail", func(t *testing.T, sc scenarioContext) { @@ -292,14 +296,13 @@ func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash quotaService := quotatest.New(false, nil) dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - dashAlertExtractor := alerting.ProvideDashAlertExtractorService(nil, nil, nil) ac := actest.FakeAccessControl{ExpectedEvaluate: true} folderPermissions := acmock.NewMockedPermissionsService() dashboardPermissions := acmock.NewMockedPermissionsService() dashboardPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) service, err := dashboardservice.ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, dashAlertExtractor, + cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions, ac, foldertest.NewFakeService(), nil, @@ -322,7 +325,7 @@ func createFolder(t *testing.T, sc scenarioContext, title string) *folder.Folder require.NoError(t, err) folderStore := folderimpl.ProvideDashboardFolderStore(sc.sqlStore) - s := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, dashboardStore, folderStore, sc.sqlStore, features, nil) + s := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, dashboardStore, folderStore, sc.sqlStore, features, supportbundlestest.NewFakeBundleService(), nil) t.Logf("Creating folder with title and UID %q", title) ctx := appcontext.WithUser(context.Background(), &sc.user) folder, err := s.Create(ctx, &folder.CreateFolderCommand{ @@ -383,7 +386,7 @@ func scenarioWithPanel(t *testing.T, desc string, fn func(t *testing.T, sc scena dashboardPermissions := acmock.NewMockedPermissionsService() folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) dashboardService, svcErr := dashboardservice.ProvideDashboardServiceImpl( - sqlStore.Cfg, dashboardStore, folderStore, nil, + sqlStore.Cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions, ac, foldertest.NewFakeService(), nil, @@ -444,7 +447,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo dashboardPermissions := acmock.NewMockedPermissionsService() folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) dashService, dashSvcErr := dashboardservice.ProvideDashboardServiceImpl( - sqlStore.Cfg, dashboardStore, folderStore, nil, + sqlStore.Cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions, ac, foldertest.NewFakeService(), nil, @@ -455,7 +458,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo Cfg: sqlStore.Cfg, features: featuremgmt.WithFeatures(), SQLStore: sqlStore, - folderService: folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), sqlStore.Cfg, dashboardStore, folderStore, sqlStore, features, nil), + folderService: folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), sqlStore.Cfg, dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil), } // deliberate difference between signed in user and user in db to make it crystal clear diff --git a/pkg/services/libraryelements/model/model.go b/pkg/services/libraryelements/model/model.go index 88a2dcfa54540..df8c1abd7a44a 100644 --- a/pkg/services/libraryelements/model/model.go +++ b/pkg/services/libraryelements/model/model.go @@ -3,13 +3,9 @@ package model import ( "encoding/json" "errors" - "fmt" "time" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/kinds" "github.com/grafana/grafana/pkg/kinds/librarypanel" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type LibraryConnectionKind int @@ -85,32 +81,6 @@ type LibraryElementDTO struct { SchemaVersion int64 `json:"schemaVersion,omitempty"` } -func (dto *LibraryElementDTO) ToResource() kinds.GrafanaResource[simplejson.Json, simplejson.Json] { - body := &simplejson.Json{} - _ = body.FromDB(dto.Model) - parent := librarypanel.NewK8sResource(dto.UID, nil) - res := kinds.GrafanaResource[simplejson.Json, simplejson.Json]{ - Kind: parent.Kind, - APIVersion: parent.APIVersion, - Metadata: kinds.GrafanaResourceMetadata{ - Name: dto.UID, - Annotations: make(map[string]string), - Labels: make(map[string]string), - ResourceVersion: fmt.Sprintf("%d", dto.Version), - CreationTimestamp: v1.NewTime(dto.Meta.Created), - }, - Spec: body, - } - - if dto.FolderUID != "" { - res.Metadata.SetFolder(dto.FolderUID) - } - res.Metadata.SetCreatedBy(fmt.Sprintf("user:%d", dto.Meta.CreatedBy.Id)) - res.Metadata.SetUpdatedBy(fmt.Sprintf("user:%d", dto.Meta.UpdatedBy.Id)) - res.Metadata.SetUpdatedTimestamp(&dto.Meta.Updated) - return res -} - // LibraryElementSearchResult is the search result for entities. type LibraryElementSearchResult struct { TotalCount int64 `json:"totalCount"` diff --git a/pkg/services/libraryelements/model/model_test.go b/pkg/services/libraryelements/model/model_test.go deleted file mode 100644 index 5bdbb068b882c..0000000000000 --- a/pkg/services/libraryelements/model/model_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package model - -import ( - "encoding/json" - "fmt" - "testing" - "time" - - "github.com/grafana/grafana/pkg/kinds/librarypanel" - "github.com/stretchr/testify/require" -) - -func TestLibaryPanelConversion(t *testing.T) { - body := `{}` - - src := LibraryElementDTO{ - Kind: 0, // always library panel - FolderUID: "TheFolderUID", - UID: "TheUID", - Version: 10, - Model: json.RawMessage(body), - Meta: LibraryElementDTOMeta{ - Created: time.UnixMilli(946713600000).UTC(), // 2000-01-01 - Updated: time.UnixMilli(1262332800000).UTC(), // 2010-01-01, - CreatedBy: librarypanel.LibraryElementDTOMetaUser{ - Id: 11, - }, - UpdatedBy: librarypanel.LibraryElementDTOMetaUser{ - Id: 12, - }, - }, - } - - dst := src.ToResource() - - require.Equal(t, src.UID, dst.Metadata.Name) - - out, err := json.MarshalIndent(dst, "", " ") - require.NoError(t, err) - fmt.Printf("%s", string(out)) - require.JSONEq(t, `{ - "apiVersion": "v0-0-alpha", - "kind": "LibraryPanel", - "metadata": { - "name": "TheUID", - "resourceVersion": "10", - "creationTimestamp": "2000-01-01T08:00:00Z", - "annotations": { - "grafana.app/createdBy": "user:11", - "grafana.app/folder": "TheFolderUID", - "grafana.app/updatedBy": "user:12", - "grafana.app/updatedTimestamp": "2010-01-01T08:00:00Z" - } - }, - "spec": {} - }`, string(out)) -} diff --git a/pkg/services/libraryelements/writers.go b/pkg/services/libraryelements/writers.go index 73b8744f63421..e1d470f6cdc66 100644 --- a/pkg/services/libraryelements/writers.go +++ b/pkg/services/libraryelements/writers.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/libraryelements/model" ) @@ -84,6 +85,7 @@ func parseFolderFilter(query model.SearchLibraryElementsQuery) FolderFilter { hasFolderFilter := len(strings.TrimSpace(query.FolderFilter)) > 0 hasFolderFilterUID := len(strings.TrimSpace(query.FolderFilterUIDs)) > 0 + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() result := FolderFilter{ includeGeneralFolder: true, folderIDs: folderIDs, // nolint:staticcheck @@ -99,6 +101,7 @@ func parseFolderFilter(query model.SearchLibraryElementsQuery) FolderFilter { if hasFolderFilter { result.includeGeneralFolder = false folderIDs = strings.Split(query.FolderFilter, ",") + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() // nolint:staticcheck result.folderIDs = folderIDs for _, filter := range folderIDs { @@ -132,6 +135,7 @@ func parseFolderFilter(query model.SearchLibraryElementsQuery) FolderFilter { func (f *FolderFilter) writeFolderFilterSQL(includeGeneral bool, builder *db.SQLBuilder) error { var sql bytes.Buffer params := make([]any, 0) + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc() // nolint:staticcheck for _, filter := range f.folderIDs { folderID, err := strconv.ParseInt(filter, 10, 64) diff --git a/pkg/services/librarypanels/librarypanels.go b/pkg/services/librarypanels/librarypanels.go index d75030b3e374e..244f925ddeb9b 100644 --- a/pkg/services/librarypanels/librarypanels.go +++ b/pkg/services/librarypanels/librarypanels.go @@ -4,11 +4,14 @@ import ( "context" "encoding/json" "errors" + "fmt" + "strings" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" @@ -163,6 +166,7 @@ func importLibraryPanelsRecursively(c context.Context, service libraryelements.S return err } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryPanels).Inc() var cmd = model.CreateLibraryElementCommand{ FolderID: folderID, // nolint:staticcheck Name: name, @@ -186,24 +190,41 @@ func importLibraryPanelsRecursively(c context.Context, service libraryelements.S // CountInFolder is a handler for retrieving the number of library panels contained // within a given folder and for a specific organisation. -func (lps LibraryPanelService) CountInFolder(ctx context.Context, orgID int64, folderUID string, u identity.Requester) (int64, error) { +func (lps LibraryPanelService) CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, u identity.Requester) (int64, error) { + if len(folderUIDs) == 0 { + return 0, nil + } var count int64 return count, lps.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { - folder, err := lps.FolderService.Get(ctx, &folder.GetFolderQuery{UID: &folderUID, OrgID: orgID, SignedInUser: u}) + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryPanels).Inc() + // the sequential IDs for the respective entries of dashboard and folder tables are different, + // so we need to get the folder ID from the dashboard table + // TODO: In the future, we should consider adding a folder UID column to the library_element table + // and use that instead of the folder ID. + s := fmt.Sprintf(`SELECT COUNT(*) FROM library_element + WHERE org_id = ? AND folder_id IN (SELECT id FROM dashboard WHERE org_id = ? AND uid IN (%s)) AND kind = ?`, strings.Repeat("?,", len(folderUIDs)-1)+"?") + args := make([]interface{}, 0, len(folderUIDs)+2) + args = append(args, orgID, orgID) + for _, folderUID := range folderUIDs { + args = append(args, folderUID) + } + args = append(args, int64(model.PanelElement)) + _, err := sess.SQL(s, args...).Get(&count) if err != nil { return err } - // nolint:staticcheck - q := sess.Table("library_element").Where("org_id = ?", u.GetOrgID()). - Where("folder_id = ?", folder.ID).Where("kind = ?", int64(model.PanelElement)) - count, err = q.Count() return err }) } // DeleteInFolder deletes the library panels contained in a given folder. -func (lps LibraryPanelService) DeleteInFolder(ctx context.Context, orgID int64, folderUID string, user identity.Requester) error { - return lps.LibraryElementService.DeleteLibraryElementsInFolder(ctx, user, folderUID) +func (lps LibraryPanelService) DeleteInFolders(ctx context.Context, orgID int64, folderUIDs []string, user identity.Requester) error { + for _, folderUID := range folderUIDs { + if err := lps.LibraryElementService.DeleteLibraryElementsInFolder(ctx, user, folderUID); err != nil { + return err + } + } + return nil } // Kind returns the name of the library panel type of entity. diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 42ed6ec6273d2..0474ac609fbda 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -21,7 +21,6 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" - "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/database" dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/service" @@ -40,11 +39,17 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) const userInDbName = "user_in_db" const userInDbAvatar = "/avatar/402d08de060496d6b6874495fe20f5ad" +// run tests with cleanup +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestConnectLibraryPanelsForDashboard(t *testing.T) { scenarioWithLibraryPanel(t, "When an admin tries to store a dashboard with a library panel, it should connect the two", func(t *testing.T, sc scenarioContext) { @@ -326,14 +331,14 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { scenarioWithLibraryPanel(t, "It should return the correct count of library panels in a folder", func(t *testing.T, sc scenarioContext) { - count, err := sc.lps.CountInFolder(context.Background(), sc.user.OrgID, sc.folder.UID, sc.user) + count, err := sc.lps.CountInFolders(context.Background(), sc.user.OrgID, []string{sc.folder.UID}, sc.user) require.NoError(t, err) require.Equal(t, int64(1), count) }) scenarioWithLibraryPanel(t, "It should delete library panels in a folder", func(t *testing.T, sc scenarioContext) { - err := sc.lps.DeleteInFolder(context.Background(), sc.user.OrgID, sc.folder.UID, sc.user) + err := sc.lps.DeleteInFolders(context.Background(), sc.user.OrgID, []string{sc.folder.UID}, sc.user) require.NoError(t, err) _, err = sc.elementService.GetElement(sc.ctx, sc.user, @@ -725,13 +730,12 @@ func createDashboard(t *testing.T, sqlStore db.DB, user *user.SignedInUser, dash quotaService := quotatest.New(false, nil) dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - dashAlertService := alerting.ProvideDashAlertExtractorService(nil, nil, nil) ac := actest.FakeAccessControl{ExpectedEvaluate: true} folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) dashPermissionService := acmock.NewMockedPermissionsService() dashPermissionService.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) service, err := dashboardservice.ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, dashAlertService, + cfg, dashboardStore, folderStore, featuremgmt.WithFeatures(), acmock.NewMockedPermissionsService(), dashPermissionService, ac, foldertest.NewFakeService(), nil, @@ -753,7 +757,7 @@ func createFolder(t *testing.T, sc scenarioContext, title string) *folder.Folder dashboardStore, err := database.ProvideDashboardStore(sc.sqlStore, cfg, features, tagimpl.ProvideService(sc.sqlStore), quotaService) require.NoError(t, err) folderStore := folderimpl.ProvideDashboardFolderStore(sc.sqlStore) - s := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, dashboardStore, folderStore, sc.sqlStore, features, nil) + s := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, dashboardStore, folderStore, sc.sqlStore, features, supportbundlestest.NewFakeBundleService(), nil) t.Logf("Creating folder with title and UID %q", title) ctx := appcontext.WithUser(context.Background(), sc.user) @@ -821,10 +825,9 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo dashStore := &dashboards.FakeDashboardStore{} dashStore.On("GetDashboard", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{ID: 1}, nil) folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - dashAlertService := alerting.ProvideDashAlertExtractorService(nil, nil, nil) dashPermissionService := acmock.NewMockedPermissionsService() dashService, err := dashboardservice.ProvideDashboardServiceImpl( - setting.NewCfg(), dashStore, folderStore, dashAlertService, + setting.NewCfg(), dashStore, folderStore, featuremgmt.WithFeatures(), acmock.NewMockedPermissionsService(), dashPermissionService, ac, foldertest.NewFakeService(), nil, @@ -835,7 +838,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) features := featuremgmt.WithFeatures() - folderService := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, dashboardStore, folderStore, sqlStore, features, nil) + folderService := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil) elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService, featuremgmt.WithFeatures(), ac) service := LibraryPanelService{ diff --git a/pkg/services/live/database/tests/storage_test.go b/pkg/services/live/database/tests/storage_test.go index cd3a483e39076..b9ce2d2bd2c1c 100644 --- a/pkg/services/live/database/tests/storage_test.go +++ b/pkg/services/live/database/tests/storage_test.go @@ -7,8 +7,13 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/services/live/model" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationLiveMessage(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index d81f7a999674c..f580caf6a3b34 100644 --- a/pkg/services/live/live.go +++ b/pkg/services/live/live.go @@ -1043,7 +1043,7 @@ func (g *GrafanaLive) HandleInfoHTTP(ctx *contextmodel.ReqContext) response.Resp "active": g.GrafanaScope.Dashboards.HasGitOpsObserver(ctx.SignedInUser.GetOrgID()), }) } - return response.JSONStreaming(404, util.DynMap{ + return response.JSONStreaming(http.StatusNotFound, util.DynMap{ "message": "Info is not supported for this channel", }) } diff --git a/pkg/services/live/live_test.go b/pkg/services/live/live_test.go index f19f1a8e30b92..f37266acb1657 100644 --- a/pkg/services/live/live_test.go +++ b/pkg/services/live/live_test.go @@ -17,8 +17,13 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func Test_provideLiveService_RedisUnavailable(t *testing.T) { cfg := setting.NewCfg() diff --git a/pkg/services/login/authinfo.go b/pkg/services/login/authinfo.go index f70dae8c8692e..4f3d95bd931b2 100644 --- a/pkg/services/login/authinfo.go +++ b/pkg/services/login/authinfo.go @@ -76,7 +76,7 @@ func IsExternallySynced(cfg *setting.Cfg, authModule string, oauthInfo *social.O case LDAPAuthModule: return !cfg.LDAPSkipOrgRoleSync case JWTModule: - return !cfg.JWTAuthSkipOrgRoleSync + return !cfg.JWTAuth.SkipOrgRoleSync } // then check the rest of the oauth providers // FIXME: remove this once we remove the setting @@ -104,7 +104,7 @@ func IsGrafanaAdminExternallySynced(cfg *setting.Cfg, oauthInfo *social.OAuthInf switch authModule { case JWTModule: - return cfg.JWTAuthAllowAssignGrafanaAdmin + return cfg.JWTAuth.AllowAssignGrafanaAdmin case SAMLAuthModule: return cfg.SAMLRoleValuesGrafanaAdmin != "" case LDAPAuthModule: @@ -121,7 +121,7 @@ func IsProviderEnabled(cfg *setting.Cfg, authModule string, oauthInfo *social.OA case LDAPAuthModule: return cfg.LDAPAuthEnabled case JWTModule: - return cfg.JWTAuthEnabled + return cfg.JWTAuth.Enabled case GoogleAuthModule, OktaAuthModule, AzureADAuthModule, GitLabAuthModule, GithubAuthModule, GrafanaComAuthModule, GenericOAuthModule: if oauthInfo == nil { return false diff --git a/pkg/services/login/authinfo_test.go b/pkg/services/login/authinfo_test.go index bd84e471e8324..39746ef87455c 100644 --- a/pkg/services/login/authinfo_test.go +++ b/pkg/services/login/authinfo_test.go @@ -3,9 +3,10 @@ package login import ( "testing" + "github.com/stretchr/testify/assert" + "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/assert" ) func TestIsExternallySynced(t *testing.T) { @@ -82,20 +83,20 @@ func TestIsExternallySynced(t *testing.T) { // jwt { name: "JWT synced user should return that it is externally synced", - cfg: &setting.Cfg{JWTAuthEnabled: true, JWTAuthSkipOrgRoleSync: false}, + cfg: &setting.Cfg{JWTAuth: setting.AuthJWTSettings{Enabled: true, SkipOrgRoleSync: false}}, provider: JWTModule, expected: true, }, { name: "JWT synced user should return that it is not externally synced when org role sync is set", - cfg: &setting.Cfg{JWTAuthEnabled: true, JWTAuthSkipOrgRoleSync: true}, + cfg: &setting.Cfg{JWTAuth: setting.AuthJWTSettings{Enabled: true, SkipOrgRoleSync: true}}, provider: JWTModule, expected: false, }, // IsProvider test { name: "If no provider enabled should return false", - cfg: &setting.Cfg{JWTAuthSkipOrgRoleSync: true}, + cfg: &setting.Cfg{JWTAuth: setting.AuthJWTSettings{Enabled: false, SkipOrgRoleSync: true}}, provider: JWTModule, expected: false, }, diff --git a/pkg/services/login/authinfoimpl/service.go b/pkg/services/login/authinfoimpl/service.go index bb4759febe086..4bd5814302b16 100644 --- a/pkg/services/login/authinfoimpl/service.go +++ b/pkg/services/login/authinfoimpl/service.go @@ -2,27 +2,69 @@ package authinfoimpl import ( "context" + "encoding/json" + "errors" + "strconv" + "time" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/secrets" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/util/errutil" ) type Service struct { authInfoStore login.Store logger log.Logger + remoteCache remotecache.CacheStorage + secretService secrets.Service } -func ProvideService(authInfoStore login.Store) *Service { +const remoteCachePrefix = "authinfo-" +const remoteCacheTTL = 60 * time.Hour + +var errMissingParameters = errutil.NewBase(errutil.StatusBadRequest, "auth-missing-parameters", errutil.WithPublicMessage("Missing parameters for auth info")) + +func ProvideService(authInfoStore login.Store, + remoteCache remotecache.CacheStorage, + secretService secrets.Service) *Service { s := &Service{ authInfoStore: authInfoStore, logger: log.New("login.authinfo"), + remoteCache: remoteCache, + secretService: secretService, } return s } func (s *Service) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQuery) (*login.UserAuth, error) { - return s.authInfoStore.GetAuthInfo(ctx, query) + if query.UserId == 0 && query.AuthId == "" { + return nil, user.ErrUserNotFound + } + + authInfo, err := s.getAuthInfoFromCache(ctx, query) + if err != nil && !errors.Is(err, remotecache.ErrCacheItemNotFound) { + s.logger.Error("failed to retrieve auth info from cache", "error", err) + } else if authInfo != nil { + return authInfo, nil + } + + authInfo, err = s.authInfoStore.GetAuthInfo(ctx, query) + if err != nil { + return nil, err + } + + err = s.setAuthInfoInCache(ctx, query, authInfo) + if err != nil { + s.logger.Error("failed to set auth info in cache", "error", err) + } else { + s.logger.Debug("auth info set in cache", "cacheKey", generateCacheKey(query)) + } + + return authInfo, nil } func (s *Service) GetUserLabels(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error) { @@ -32,14 +74,131 @@ func (s *Service) GetUserLabels(ctx context.Context, query login.GetUserLabelsQu return s.authInfoStore.GetUserLabels(ctx, query) } +func (s *Service) setAuthInfoInCache(ctx context.Context, query *login.GetAuthInfoQuery, info *login.UserAuth) error { + cacheKey := generateCacheKey(query) + infoJSON, err := json.Marshal(info) + if err != nil { + return err + } + + encryptedInfo, err := s.secretService.Encrypt(ctx, infoJSON, secrets.WithoutScope()) + if err != nil { + return err + } + + return s.remoteCache.Set(ctx, cacheKey, encryptedInfo, remoteCacheTTL) +} + +func (s *Service) getAuthInfoFromCache(ctx context.Context, query *login.GetAuthInfoQuery) (*login.UserAuth, error) { + // check if we have the auth info in the remote cache + cacheKey := generateCacheKey(query) + item, err := s.remoteCache.Get(ctx, cacheKey) + if err != nil { + return nil, err + } + + info := &login.UserAuth{} + itemJSON, err := s.secretService.Decrypt(ctx, item) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(itemJSON, info); err != nil { + return nil, err + } + + s.logger.Debug("auth info retrieved from cache", "cacheKey", cacheKey) + + return info, nil +} + +func generateCacheKey(query *login.GetAuthInfoQuery) string { + cacheKey := remoteCachePrefix + strconv.FormatInt(query.UserId, 10) + "-" + + query.AuthModule + "-" + query.AuthId + return cacheKey +} + func (s *Service) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error { - return s.authInfoStore.UpdateAuthInfo(ctx, cmd) + // Only update auth info if we have an (user id + auth module) + if cmd.UserId == 0 || cmd.AuthModule == "" { + return errMissingParameters.Errorf("missing parameters for auth info %v", cmd) + } + + if err := s.authInfoStore.UpdateAuthInfo(ctx, cmd); err != nil { + return err + } + + s.deleteUserAuthInfoInCache(ctx, &login.GetAuthInfoQuery{ + AuthModule: cmd.AuthModule, + AuthId: cmd.AuthId, + UserId: cmd.UserId, + }) + + return nil } func (s *Service) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error { - return s.authInfoStore.SetAuthInfo(ctx, cmd) + // Only set auth info if we have an (user id + auth module) + if cmd.UserId == 0 || cmd.AuthModule == "" { + return errMissingParameters.Errorf("missing parameters for auth info %v", cmd) + } + + if err := s.authInfoStore.SetAuthInfo(ctx, cmd); err != nil { + return err + } + + s.deleteUserAuthInfoInCache(ctx, &login.GetAuthInfoQuery{ + AuthModule: cmd.AuthModule, + AuthId: cmd.AuthId, + UserId: cmd.UserId, + }) + + return nil } func (s *Service) DeleteUserAuthInfo(ctx context.Context, userID int64) error { - return s.authInfoStore.DeleteUserAuthInfo(ctx, userID) + err := s.authInfoStore.DeleteUserAuthInfo(ctx, userID) + if err != nil { + return err + } + + err = s.remoteCache.Delete(ctx, generateCacheKey(&login.GetAuthInfoQuery{ + UserId: userID, + })) + if err != nil { + s.logger.Error("failed to delete auth info from cache", "error", err) + } + + return nil +} + +func (s *Service) deleteUserAuthInfoInCache(ctx context.Context, query *login.GetAuthInfoQuery) { + if query.AuthId != "" { + err := s.remoteCache.Delete(ctx, generateCacheKey(&login.GetAuthInfoQuery{ + AuthModule: query.AuthModule, + AuthId: query.AuthId, + })) + if err != nil { + s.logger.Warn("failed to delete auth info from cache", "error", err) + } + } + + if query.UserId != 0 { + errN := s.remoteCache.Delete(ctx, generateCacheKey( + &login.GetAuthInfoQuery{ + UserId: query.UserId, + })) + if errN != nil { + s.logger.Warn("failed to delete user auth info from cache", "error", errN) + } + + errA := s.remoteCache.Delete(ctx, generateCacheKey( + &login.GetAuthInfoQuery{ + UserId: query.UserId, + AuthModule: query.AuthModule, + })) + if errA != nil { + s.logger.Warn("failed to delete user module auth info from cache", "error", errA) + } + } } diff --git a/pkg/services/login/authinfoimpl/store_test.go b/pkg/services/login/authinfoimpl/store_test.go index 147673b3b5c29..9fad32337e8d4 100644 --- a/pkg/services/login/authinfoimpl/store_test.go +++ b/pkg/services/login/authinfoimpl/store_test.go @@ -13,8 +13,13 @@ import ( "github.com/grafana/grafana/pkg/services/login" secretstest "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationAuthInfoStore(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/loginattempt/loginattemptimpl/store_test.go b/pkg/services/loginattempt/loginattemptimpl/store_test.go index 45c27c0c90fb4..194e64ff572b4 100644 --- a/pkg/services/loginattempt/loginattemptimpl/store_test.go +++ b/pkg/services/loginattempt/loginattemptimpl/store_test.go @@ -8,8 +8,13 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationLoginAttemptsQuery(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go index 17ec7ed67faf1..76d718f4c10b2 100644 --- a/pkg/services/navtree/models.go +++ b/pkg/services/navtree/models.go @@ -22,6 +22,7 @@ const ( WeightInfrastructure WeightApplication WeightFrontend + WeightAsserts WeightDataConnections WeightApps WeightPlugin @@ -38,7 +39,6 @@ const ( NavIDAlertsAndIncidents = "alerts-and-incidents" NavIDTestingAndSynthetics = "testing-and-synthetics" NavIDAlerting = "alerting" - NavIDAlertingLegacy = "alerting-legacy" NavIDMonitoring = "monitoring" NavIDInfrastructure = "infrastructure" NavIDFrontend = "frontend" @@ -67,6 +67,7 @@ type NavLink struct { EmptyMessageId string `json:"emptyMessageId,omitempty"` PluginID string `json:"pluginId,omitempty"` // (Optional) The ID of the plugin that registered nav link (e.g. as a standalone plugin page) IsCreateAction bool `json:"isCreateAction,omitempty"` + Keywords []string `json:"keywords,omitempty"` } func (node *NavLink) Sort() { @@ -126,6 +127,14 @@ func Sort(nodes []*NavLink) { } } +func (root *NavTreeRoot) ApplyHelpVersion(version string) { + helpNode := root.FindById("help") + + if helpNode != nil { + helpNode.SubTitle = version + } +} + func (root *NavTreeRoot) ApplyAdminIA() { orgAdminNode := root.FindById(NavIDCfg) @@ -140,6 +149,7 @@ func (root *NavTreeRoot) ApplyAdminIA() { generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("global-orgs")) generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("feature-toggles")) generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("storage")) + generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("migrate-to-cloud")) generalNode := &NavLink{ Text: "General", diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index b50e171f8fddf..235590b89312e 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -2,10 +2,12 @@ package navtreeimpl import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/ssoutils" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/correlations" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/navtree" + "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/services/serviceaccounts" ) @@ -80,7 +82,8 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink }) } - if (authConfigUIAvailable && hasAccess(evalAuthenticationSettings())) || s.features.IsEnabled(ctx, featuremgmt.FlagSsoSettingsApi) { + if authConfigUIAvailable && hasAccess(ssoutils.EvalAuthenticationSettings(s.cfg)) || + (hasAccess(ssoutils.OauthSettingsEvaluator(s.cfg)) && s.features.IsEnabled(ctx, featuremgmt.FlagSsoSettingsApi)) { configNodes = append(configNodes, &navtree.NavLink{ Text: "Authentication", Id: "authentication", @@ -133,6 +136,16 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink configNodes = append(configNodes, storage) } + if s.features.IsEnabled(ctx, featuremgmt.FlagOnPremToCloudMigrations) && c.SignedInUser.HasRole(org.RoleAdmin) { + migrateToCloud := &navtree.NavLink{ + Text: "Migrate to Grafana Cloud", + Id: "migrate-to-cloud", + SubTitle: "Copy configuration from your self-managed installation to a cloud stack", + Url: s.cfg.AppSubURL + "/admin/migrate-to-cloud", + } + configNodes = append(configNodes, migrateToCloud) + } + configNode := &navtree.NavLink{ Id: navtree.NavIDCfg, Text: "Administration", @@ -150,10 +163,3 @@ func enableServiceAccount(s *ServiceImpl, c *contextmodel.ReqContext) bool { hasAccess := ac.HasAccess(s.accessControl, c) return hasAccess(serviceaccounts.AccessEvaluator) } - -func evalAuthenticationSettings() ac.Evaluator { - return ac.EvalAny(ac.EvalAll( - ac.EvalPermission(ac.ActionSettingsWrite, ac.ScopeSettingsSAML), - ac.EvalPermission(ac.ActionSettingsRead, ac.ScopeSettingsSAML), - ), ac.EvalPermission(ac.ActionLDAPStatusRead)) -} diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index 3244a41271d74..7db535d90afa8 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -168,10 +168,10 @@ func (s *ServiceImpl) processAppPlugin(plugin pluginstore.Plugin, c *contextmode func (s *ServiceImpl) addPluginToSection(c *contextmodel.ReqContext, treeRoot *navtree.NavTreeRoot, plugin pluginstore.Plugin, appLink *navtree.NavLink) { // Handle moving apps into specific navtree sections + var alertingNodes []*navtree.NavLink alertingNode := treeRoot.FindById(navtree.NavIDAlerting) - if alertingNode == nil { - // Search for legacy alerting node just in case - alertingNode = treeRoot.FindById(navtree.NavIDAlertingLegacy) + if alertingNode != nil { + alertingNodes = append(alertingNodes, alertingNode) } sectionID := navtree.NavIDApps @@ -235,7 +235,7 @@ func (s *ServiceImpl) addPluginToSection(c *contextmodel.ReqContext, treeRoot *n }) case navtree.NavIDAlertsAndIncidents: alertsAndIncidentsChildren := []*navtree.NavLink{} - if alertingNode != nil { + for _, alertingNode := range alertingNodes { alertsAndIncidentsChildren = append(alertsAndIncidentsChildren, alertingNode) treeRoot.RemoveSection(alertingNode) } @@ -282,55 +282,24 @@ func (s *ServiceImpl) hasAccessToInclude(c *contextmodel.ReqContext, pluginID st } func (s *ServiceImpl) readNavigationSettings() { - k8sCfg := NavigationAppConfig{SectionID: navtree.NavIDMonitoring, SortWeight: 1, Text: "Kubernetes"} - appO11yCfg := NavigationAppConfig{SectionID: navtree.NavIDMonitoring, SortWeight: 2, Text: "Application"} - profilesCfg := NavigationAppConfig{SectionID: navtree.NavIDMonitoring, SortWeight: 3, Text: "Profiles"} - frontendCfg := NavigationAppConfig{SectionID: navtree.NavIDMonitoring, SortWeight: 4, Text: "Frontend"} - k6Cfg := NavigationAppConfig{SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightAlertsAndIncidents + 1, Text: "Performance testing", Icon: "k6"} - syntheticsCfg := NavigationAppConfig{SectionID: navtree.NavIDMonitoring, SortWeight: 5, Text: "Synthetics"} - - if s.features.IsEnabledGlobally(featuremgmt.FlagDockedMegaMenu) { - k8sCfg.SectionID = navtree.NavIDInfrastructure - - appO11yCfg.SectionID = navtree.NavIDRoot - appO11yCfg.SortWeight = navtree.WeightApplication - appO11yCfg.Icon = "graph-bar" - - profilesCfg.SectionID = navtree.NavIDExplore - profilesCfg.SortWeight = 1 - - frontendCfg.SectionID = navtree.NavIDRoot - frontendCfg.SortWeight = navtree.WeightFrontend - frontendCfg.Icon = "frontend-observability" - - k6Cfg.SectionID = navtree.NavIDTestingAndSynthetics - k6Cfg.SortWeight = 1 - k6Cfg.Text = "Performance" - - syntheticsCfg.SectionID = navtree.NavIDTestingAndSynthetics - syntheticsCfg.SortWeight = 2 - } - s.navigationAppConfig = map[string]NavigationAppConfig{ - "grafana-k8s-app": k8sCfg, - "grafana-app-observability-app": appO11yCfg, - "grafana-pyroscope-app": profilesCfg, - "grafana-kowalski-app": frontendCfg, - "grafana-synthetic-monitoring-app": syntheticsCfg, + "grafana-k8s-app": {SectionID: navtree.NavIDInfrastructure, SortWeight: 1, Text: "Kubernetes"}, + "grafana-aws-app": {SectionID: navtree.NavIDInfrastructure, SortWeight: 2}, + "grafana-app-observability-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightApplication, Text: "Application", Icon: "graph-bar"}, + "grafana-pyroscope-app": {SectionID: navtree.NavIDExplore, SortWeight: 1, Text: "Profiles"}, + "grafana-kowalski-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightFrontend, Text: "Frontend", Icon: "frontend-observability"}, + "grafana-synthetic-monitoring-app": {SectionID: navtree.NavIDTestingAndSynthetics, SortWeight: 2, Text: "Synthetics"}, "grafana-oncall-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 1, Text: "OnCall"}, "grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2, Text: "Incidents"}, "grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3, Text: "Machine Learning"}, + "grafana-slo-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 4}, "grafana-cloud-link-app": {SectionID: navtree.NavIDCfg}, "grafana-costmanagementui-app": {SectionID: navtree.NavIDCfg, Text: "Cost management"}, + "grafana-adaptive-metrics-app": {SectionID: navtree.NavIDCfg, Text: "Adaptive Metrics"}, + "grafana-logvolumeexplorer-app": {SectionID: navtree.NavIDCfg, Text: "Log Volume Explorer"}, "grafana-easystart-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightApps + 1, Text: "Connections", Icon: "adjust-circle"}, - "k6-app": k6Cfg, - } - - if s.features.IsEnabledGlobally(featuremgmt.FlagCostManagementUi) { - // if cost management is enabled we want to nest adaptive metrics and log volume explorer under that plugin - // in the admin section - s.navigationAppConfig["grafana-adaptive-metrics-app"] = NavigationAppConfig{SectionID: navtree.NavIDCfg} - s.navigationAppConfig["grafana-logvolumeexplorer-app"] = NavigationAppConfig{SectionID: navtree.NavIDCfg} + "k6-app": {SectionID: navtree.NavIDTestingAndSynthetics, SortWeight: 1, Text: "Performance"}, + "grafana-asserts-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightAsserts, Icon: "asserts"}, } s.navigationAppPathConfig = map[string]NavigationAppConfig{ diff --git a/pkg/services/navtree/navtreeimpl/applinks_test.go b/pkg/services/navtree/navtreeimpl/applinks_test.go index a877638fd6b1b..a526caef8997a 100644 --- a/pkg/services/navtree/navtreeimpl/applinks_test.go +++ b/pkg/services/navtree/navtreeimpl/applinks_test.go @@ -367,7 +367,7 @@ func TestReadingNavigationSettings(t *testing.T) { _, _ = service.cfg.Raw.NewSection("navigation.app_sections") service.readNavigationSettings() - require.Equal(t, "monitoring", service.navigationAppConfig["grafana-k8s-app"].SectionID) + require.Equal(t, "infrastructure", service.navigationAppConfig["grafana-k8s-app"].SectionID) }) t.Run("Can add additional overrides via ini system", func(t *testing.T) { diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 6015c1ff5b422..3b241ca8957a6 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -1,13 +1,11 @@ package navtreeimpl import ( - "fmt" "sort" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models/roletype" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/auth/identity" @@ -33,7 +31,7 @@ type ServiceImpl struct { pluginStore pluginstore.Store pluginSettings pluginsettings.Service starService star.Service - features *featuremgmt.FeatureManager + features featuremgmt.FeatureToggles dashboardService dashboards.DashboardService accesscontrolService ac.Service kvStore kvstore.KVStore @@ -52,7 +50,7 @@ type NavigationAppConfig struct { Icon string } -func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStore pluginstore.Store, pluginSettings pluginsettings.Service, starService star.Service, features *featuremgmt.FeatureManager, dashboardService dashboards.DashboardService, accesscontrolService ac.Service, kvStore kvstore.KVStore, apiKeyService apikey.Service, license licensing.Licensing) navtree.Service { +func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStore pluginstore.Store, pluginSettings pluginsettings.Service, starService star.Service, features featuremgmt.FeatureToggles, dashboardService dashboards.DashboardService, accesscontrolService ac.Service, kvStore kvstore.KVStore, apiKeyService apikey.Service, license licensing.Licensing) navtree.Service { service := &ServiceImpl{ cfg: cfg, log: log.New("navtree service"), @@ -116,7 +114,8 @@ func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Prefere treeRoot.AddSection(dashboardLink) } - if setting.ExploreEnabled && hasAccess(ac.EvalPermission(ac.ActionDatasourcesExplore)) { + if s.cfg.ExploreEnabled && hasAccess(ac.EvalPermission(ac.ActionDatasourcesExplore)) { + exploreChildNavLinks := s.buildExploreNavLinks(c) treeRoot.AddSection(&navtree.NavLink{ Text: "Explore", Id: navtree.NavIDExplore, @@ -124,21 +123,18 @@ func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Prefere Icon: "compass", SortWeight: navtree.WeightExplore, Url: s.cfg.AppSubURL + "/explore", + Children: exploreChildNavLinks, }) } - if setting.ProfileEnabled && c.IsSignedIn { + if s.cfg.ProfileEnabled && c.IsSignedIn { treeRoot.AddSection(s.getProfileNode(c)) } _, uaIsDisabledForOrg := s.cfg.UnifiedAlerting.DisabledOrgs[c.SignedInUser.GetOrgID()] uaVisibleForOrg := s.cfg.UnifiedAlerting.IsEnabled() && !uaIsDisabledForOrg - if setting.AlertingEnabled != nil && *setting.AlertingEnabled { - if legacyAlertSection := s.buildLegacyAlertNavLinks(c); legacyAlertSection != nil { - treeRoot.AddSection(legacyAlertSection) - } - } else if uaVisibleForOrg { + if uaVisibleForOrg { if alertingSection := s.buildAlertNavLinks(c); alertingSection != nil { treeRoot.AddSection(alertingSection) } @@ -191,25 +187,11 @@ func isSupportBundlesEnabled(s *ServiceImpl) bool { return s.cfg.SectionWithEnvOverrides("support_bundles").Key("enabled").MustBool(true) } -// don't need to show the full commit hash in the UI -// let's substring to 10 chars like local git does automatically -func getShortCommitHash(commitHash string, maxLength int) string { - if len(commitHash) > maxLength { - return commitHash[:maxLength] - } - return commitHash -} - func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *contextmodel.ReqContext) { - if setting.HelpEnabled { - helpVersion := fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, getShortCommitHash(setting.BuildCommit, 10)) - if s.cfg.AnonymousHideVersion && !c.IsSignedIn { - helpVersion = setting.ApplicationName - } - + if s.cfg.HelpEnabled { + // The version subtitle is set later by NavTree.ApplyHelpVersion helpNode := &navtree.NavLink{ Text: "Help", - SubTitle: helpVersion, Id: "help", Url: "#", Icon: "question-circle", @@ -245,7 +227,7 @@ func (s *ServiceImpl) getProfileNode(c *contextmodel.ReqContext) *navtree.NavLin if c.SignedInUser.GetLogin() != c.SignedInUser.GetDisplayName() { login = c.SignedInUser.GetLogin() } - gravatarURL := dtos.GetGravatarUrl(c.SignedInUser.GetEmail()) + gravatarURL := dtos.GetGravatarUrl(s.cfg, c.SignedInUser.GetEmail()) children := []*navtree.NavLink{ { @@ -264,7 +246,7 @@ func (s *ServiceImpl) getProfileNode(c *contextmodel.ReqContext) *navtree.NavLin }) } - if !setting.DisableSignoutMenu { + if !s.cfg.DisableSignoutMenu { // add sign out first children = append(children, &navtree.NavLink{ Text: "Sign out", @@ -368,24 +350,6 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt } } - if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagScenes) { - dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ - Text: "Scenes", - Id: "scenes", - Url: s.cfg.AppSubURL + "/scenes", - Icon: "apps", - }) - } - - if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagDatatrails) { - dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ - Text: "Data trails", - Id: "data-trails", - Url: s.cfg.AppSubURL + "/data-trails", - Icon: "code-branch", - }) - } - if hasAccess(ac.EvalPermission(dashboards.ActionDashboardsCreate)) { dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ Text: "New dashboard", Icon: "plus", Url: s.cfg.AppSubURL + "/dashboard/new", HideFromTabs: true, Id: "dashboards/new", IsCreateAction: true, @@ -400,32 +364,6 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt return dashboardChildNavs } -func (s *ServiceImpl) buildLegacyAlertNavLinks(c *contextmodel.ReqContext) *navtree.NavLink { - var alertChildNavs []*navtree.NavLink - alertChildNavs = append(alertChildNavs, &navtree.NavLink{ - Text: "Alert rules", Id: "alert-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul", - }) - - if c.SignedInUser.HasRole(roletype.RoleEditor) { - alertChildNavs = append(alertChildNavs, &navtree.NavLink{ - Text: "Notification channels", Id: "channels", Url: s.cfg.AppSubURL + "/alerting/notifications", - Icon: "comment-alt-share", - }) - } - - var alertNav = navtree.NavLink{ - Text: "Alerting", - SubTitle: "Learn about problems in your systems moments after they occur", - Id: "alerting-legacy", - Icon: "bell", - Children: alertChildNavs, - SortWeight: navtree.WeightAlerting, - Url: s.cfg.AppSubURL + "/alerting", - } - - return &alertNav -} - func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.NavLink { hasAccess := ac.HasAccess(s.accessControl, c) var alertChildNavs []*navtree.NavLink @@ -496,6 +434,7 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *contextmodel.ReqContext) *n SubTitle: "Browse and create new connections", Url: baseUrl + "/add-new-connection", Children: []*navtree.NavLink{}, + Keywords: []string{"csv", "graphite", "json", "loki", "prometheus", "sql", "tempo"}, }) // Data sources @@ -523,3 +462,17 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *contextmodel.ReqContext) *n } return nil } + +func (s *ServiceImpl) buildExploreNavLinks(c *contextmodel.ReqContext) []*navtree.NavLink { + exploreChildNavs := []*navtree.NavLink{} + if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagDatatrails) { + exploreChildNavs = append(exploreChildNavs, &navtree.NavLink{ + Text: "Metrics", + SubTitle: "Queryless exploration of your metrics", + Id: "explore/metrics", + Url: s.cfg.AppSubURL + "/explore/metrics", + Icon: "code-branch", + }) + } + return exploreChildNavs +} diff --git a/pkg/services/ngalert/README.md b/pkg/services/ngalert/README.md index da0b978302594..78e2e59644c14 100644 --- a/pkg/services/ngalert/README.md +++ b/pkg/services/ngalert/README.md @@ -30,7 +30,7 @@ The scheduler runs at a fixed interval, called its heartbeat, in which it does a 3. Send an `*evaluation` event to the goroutine for each alert rule if its interval has elapsed 4. Stop the goroutines for all alert rules that have been deleted since the last heartbeat -The function that evaluates each alert rule is called `ruleRoutine`. It waits for an `*evaluation` event (sent each +The function that evaluates each alert rule is called `run`. It waits for an `*evaluation` event (sent each interval seconds elapsed and is configurable per alert rule) and then evaluates the alert rule. To ensure that the scheduler is evaluating the latest version of the alert rule it compares its local version of the alert rule with that in the `*evaluation` event, fetching the latest version of the alert rule from the database if the version numbers diff --git a/pkg/services/ngalert/accesscontrol.go b/pkg/services/ngalert/accesscontrol.go index 3e9ce4a3f73a6..753810c109912 100644 --- a/pkg/services/ngalert/accesscontrol.go +++ b/pkg/services/ngalert/accesscontrol.go @@ -25,6 +25,13 @@ var ( Action: accesscontrol.ActionAlertingRuleExternalRead, Scope: datasources.ScopeAll, }, + // Following are needed for simplified notification policies + { + Action: accesscontrol.ActionAlertingNotificationsTimeIntervalsRead, + }, + { + Action: accesscontrol.ActionAlertingReceiversList, + }, }, }, } @@ -109,6 +116,12 @@ var ( Action: accesscontrol.ActionAlertingNotificationsExternalRead, Scope: datasources.ScopeAll, }, + { + Action: accesscontrol.ActionAlertingNotificationsTimeIntervalsRead, + }, + { + Action: accesscontrol.ActionAlertingReceiversRead, + }, }, }, } @@ -166,6 +179,10 @@ var ( { Action: accesscontrol.ActionAlertingProvisioningWrite, // organization scope }, + { + Action: dashboards.ActionFoldersRead, + Scope: dashboards.ScopeFoldersAll, + }, }, }, Grants: []string{string(org.RoleAdmin)}, diff --git a/pkg/services/ngalert/accesscontrol/fakes/rules.go b/pkg/services/ngalert/accesscontrol/fakes/rules.go new file mode 100644 index 0000000000000..059f08e915762 --- /dev/null +++ b/pkg/services/ngalert/accesscontrol/fakes/rules.go @@ -0,0 +1,74 @@ +package fakes + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/store" +) + +type Call struct { + MethodName string + Arguments []interface{} +} + +type FakeRuleService struct { + HasAccessFunc func(context.Context, identity.Requester, accesscontrol.Evaluator) (bool, error) + HasAccessOrErrorFunc func(context.Context, identity.Requester, accesscontrol.Evaluator, func() string) error + AuthorizeDatasourceAccessForRuleFunc func(context.Context, identity.Requester, *models.AlertRule) error + HasAccessToRuleGroupFunc func(context.Context, identity.Requester, models.RulesGroup) (bool, error) + AuthorizeAccessToRuleGroupFunc func(context.Context, identity.Requester, models.RulesGroup) error + AuthorizeRuleChangesFunc func(context.Context, identity.Requester, *store.GroupDelta) error + + Calls []Call +} + +func (s *FakeRuleService) HasAccess(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) { + s.Calls = append(s.Calls, Call{"HasAccess", []interface{}{ctx, user, evaluator}}) + if s.HasAccessFunc != nil { + return s.HasAccessFunc(ctx, user, evaluator) + } + return false, nil +} + +func (s *FakeRuleService) HasAccessOrError(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator, action func() string) error { + s.Calls = append(s.Calls, Call{"HasAccessOrError", []interface{}{ctx, user, evaluator, action}}) + if s.HasAccessOrErrorFunc != nil { + return s.HasAccessOrErrorFunc(ctx, user, evaluator, action) + } + return nil +} + +func (s *FakeRuleService) AuthorizeDatasourceAccessForRule(ctx context.Context, user identity.Requester, rule *models.AlertRule) error { + s.Calls = append(s.Calls, Call{"AuthorizeDatasourceAccessForRule", []interface{}{ctx, user, rule}}) + if s.AuthorizeDatasourceAccessForRuleFunc != nil { + return s.AuthorizeDatasourceAccessForRuleFunc(ctx, user, rule) + } + return nil +} + +func (s *FakeRuleService) HasAccessToRuleGroup(ctx context.Context, user identity.Requester, rules models.RulesGroup) (bool, error) { + s.Calls = append(s.Calls, Call{"HasAccessToRuleGroup", []interface{}{ctx, user, rules}}) + if s.HasAccessToRuleGroupFunc != nil { + return s.HasAccessToRuleGroupFunc(ctx, user, rules) + } + return false, nil +} + +func (s *FakeRuleService) AuthorizeAccessToRuleGroup(ctx context.Context, user identity.Requester, rules models.RulesGroup) error { + s.Calls = append(s.Calls, Call{"AuthorizeAccessToRuleGroup", []interface{}{ctx, user, rules}}) + if s.AuthorizeAccessToRuleGroupFunc != nil { + return s.AuthorizeAccessToRuleGroupFunc(ctx, user, rules) + } + return nil +} + +func (s *FakeRuleService) AuthorizeRuleChanges(ctx context.Context, user identity.Requester, change *store.GroupDelta) error { + s.Calls = append(s.Calls, Call{"AuthorizeRuleChanges", []interface{}{ctx, user, change}}) + if s.AuthorizeRuleChangesFunc != nil { + return s.AuthorizeRuleChangesFunc(ctx, user, change) + } + return nil +} diff --git a/pkg/services/ngalert/accesscontrol/models.go b/pkg/services/ngalert/accesscontrol/models.go index f3c93aaf2a75a..49188277da4ce 100644 --- a/pkg/services/ngalert/accesscontrol/models.go +++ b/pkg/services/ngalert/accesscontrol/models.go @@ -8,12 +8,12 @@ import ( ) var ( - errAuthorizationGeneric = errutil.Forbidden("alerting.unauthorized") + ErrAuthorizationBase = errutil.Forbidden("alerting.unauthorized") ) func NewAuthorizationErrorWithPermissions(action string, eval accesscontrol.Evaluator) error { msg := fmt.Sprintf("user is not authorized to %s", action) - err := errAuthorizationGeneric.Errorf(msg) + err := ErrAuthorizationBase.Errorf(msg) err.PublicMessage = msg if eval != nil { err.PublicPayload = map[string]any{ diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index 0d54b6234d006..fcbe4e498eab1 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -56,13 +56,14 @@ type API struct { TransactionManager provisioning.TransactionManager ProvenanceStore provisioning.ProvisioningStore RuleStore RuleStore - AlertingStore AlertingStore + AlertingStore store.AlertingStore AdminConfigStore store.AdminConfigurationStore DataProxy *datasourceproxy.DataSourceProxyService MultiOrgAlertmanager *notifier.MultiOrgAlertmanager StateManager *state.Manager AccessControl ac.AccessControl Policies *provisioning.NotificationPolicyService + ReceiverService *notifier.ReceiverService ContactPointService *provisioning.ContactPointService Templates *provisioning.TemplateService MuteTimings *provisioning.MuteTimingService @@ -112,6 +113,9 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { log: logger, cfg: &api.Cfg.UnifiedAlerting, authz: ruleAuthzService, + amConfigStore: api.AlertingStore, + amRefresher: api.MultiOrgAlertmanager, + featureManager: api.FeatureManager, }, ), m) api.RegisterTestingApiEndpoints(NewTestingApi( @@ -126,6 +130,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { featureManager: api.FeatureManager, appUrl: api.AppUrl, tracer: api.Tracer, + folderService: api.RuleStore, }), m) api.RegisterConfigurationApiEndpoints(NewConfiguration( &ConfigSrv{ @@ -149,4 +154,10 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { logger: logger, hist: api.Historian, }), m) + + api.RegisterNotificationsApiEndpoints(NewNotificationsApi(&NotificationSrv{ + logger: logger, + receiverService: api.ReceiverService, + muteTimingService: api.MuteTimings, + }), m) } diff --git a/pkg/services/ngalert/api/api_alertmanager.go b/pkg/services/ngalert/api/api_alertmanager.go index 9856b1032f660..51398e7295130 100644 --- a/pkg/services/ngalert/api/api_alertmanager.go +++ b/pkg/services/ngalert/api/api_alertmanager.go @@ -20,6 +20,7 @@ import ( apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/util" ) @@ -124,7 +125,8 @@ func (srv AlertmanagerSrv) RouteDeleteSilence(c *contextmodel.ReqContext, silenc } func (srv AlertmanagerSrv) RouteGetAlertingConfig(c *contextmodel.ReqContext) response.Response { - config, err := srv.mam.GetAlertmanagerConfiguration(c.Req.Context(), c.SignedInUser.GetOrgID()) + canSeeAutogen := c.SignedInUser.HasRole(org.RoleAdmin) + config, err := srv.mam.GetAlertmanagerConfiguration(c.Req.Context(), c.SignedInUser.GetOrgID(), canSeeAutogen) if err != nil { if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { return ErrResp(http.StatusNotFound, err, "") @@ -264,7 +266,10 @@ func (srv AlertmanagerSrv) RoutePostGrafanaAlertingConfigHistoryActivate(c *cont } func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *contextmodel.ReqContext, body apimodels.PostableUserConfig) response.Response { - currentConfig, err := srv.mam.GetAlertmanagerConfiguration(c.Req.Context(), c.SignedInUser.GetOrgID()) + // Remove autogenerated config from the user config before checking provenance guard and eventually saving it. + // TODO: This and provenance guard should be moved to the notifier package. + notifier.RemoveAutogenConfigIfExists(body.AlertmanagerConfig.Route) + currentConfig, err := srv.mam.GetAlertmanagerConfiguration(c.Req.Context(), c.SignedInUser.GetOrgID(), false) // If a config is present and valid we proceed with the guard, otherwise we // just bypass the guard which is okay as we are anyway in an invalid state. if err == nil { @@ -272,7 +277,7 @@ func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *contextmodel.ReqContext, b return ErrResp(http.StatusBadRequest, err, "") } } - err = srv.mam.ApplyAlertmanagerConfiguration(c.Req.Context(), c.SignedInUser.GetOrgID(), body) + err = srv.mam.SaveAndApplyAlertmanagerConfiguration(c.Req.Context(), c.SignedInUser.GetOrgID(), body) if err == nil { return response.JSON(http.StatusAccepted, util.DynMap{"message": "configuration created"}) } diff --git a/pkg/services/ngalert/api/api_alertmanager_guards.go b/pkg/services/ngalert/api/api_alertmanager_guards.go index 1b1f7b438d67b..90b6dd9225461 100644 --- a/pkg/services/ngalert/api/api_alertmanager_guards.go +++ b/pkg/services/ngalert/api/api_alertmanager_guards.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "fmt" + "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -137,7 +138,14 @@ func checkMuteTimes(currentConfig apimodels.GettableUserConfig, newConfig apimod return fmt.Errorf("cannot delete provisioned mute time '%s'", muteTime.Name) } reporter := cmputil.DiffReporter{} - options := []cmp.Option{cmp.Reporter(&reporter), cmpopts.EquateEmpty()} + options := []cmp.Option{ + cmp.Reporter(&reporter), + cmp.Comparer(func(a, b *time.Location) bool { + // Check if both are nil or both have the same string representation + return (a == nil && b == nil) || (a != nil && b != nil && a.String() == b.String()) + }), + cmpopts.EquateEmpty(), + } timesEqual := cmp.Equal(muteTime.TimeIntervals, postedMT.TimeIntervals, options...) if !timesEqual { return fmt.Errorf("cannot save provisioned mute time '%s'", muteTime.Name) diff --git a/pkg/services/ngalert/api/api_alertmanager_guards_test.go b/pkg/services/ngalert/api/api_alertmanager_guards_test.go index d0219ed02f02e..93f0d8dcf630f 100644 --- a/pkg/services/ngalert/api/api_alertmanager_guards_test.go +++ b/pkg/services/ngalert/api/api_alertmanager_guards_test.go @@ -2,6 +2,7 @@ package api import ( "testing" + "time" amConfig "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/pkg/labels" @@ -563,6 +564,7 @@ func defaultInterval(t *testing.T) []timeinterval.TimeInterval { t.Helper() return []timeinterval.TimeInterval{ { + Location: &timeinterval.Location{Location: time.Local}, Years: []timeinterval.YearRange{ { InclusiveRange: timeinterval.InclusiveRange{ diff --git a/pkg/services/ngalert/api/api_alertmanager_test.go b/pkg/services/ngalert/api/api_alertmanager_test.go index a2445dcd2feb0..c6e8deadb8ac2 100644 --- a/pkg/services/ngalert/api/api_alertmanager_test.go +++ b/pkg/services/ngalert/api/api_alertmanager_test.go @@ -10,8 +10,11 @@ import ( "time" "github.com/go-openapi/strfmt" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" alertingNotify "github.com/grafana/alerting/notify" amv2 "github.com/prometheus/alertmanager/api/v2/models" + "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" @@ -20,6 +23,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/featuremgmt" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -325,6 +329,84 @@ func TestAlertmanagerConfig(t *testing.T) { }) } +func TestAlertmanagerAutogenConfig(t *testing.T) { + createSutForAutogen := func(t *testing.T) (AlertmanagerSrv, map[int64]*ngmodels.AlertConfiguration) { + sut := createSut(t) + configs := map[int64]*ngmodels.AlertConfiguration{ + 1: {AlertmanagerConfiguration: validConfig, OrgID: 1}, + 2: {AlertmanagerConfiguration: validConfigWithoutAutogen, OrgID: 2}, + } + sut.mam = createMultiOrgAlertmanager(t, configs) + return sut, configs + } + + compare := func(t *testing.T, expectedAm string, testAm string) { + test, err := notifier.Load([]byte(testAm)) + require.NoError(t, err) + + exp, err := notifier.Load([]byte(expectedAm)) + require.NoError(t, err) + + cOpt := []cmp.Option{ + cmpopts.IgnoreUnexported(apimodels.PostableUserConfig{}, apimodels.Route{}, labels.Matcher{}), + cmpopts.IgnoreFields(apimodels.PostableGrafanaReceiver{}, "UID", "Settings"), + } + if !cmp.Equal(test, exp, cOpt...) { + t.Errorf("Unexpected AM Config: %v", cmp.Diff(test, exp, cOpt...)) + } + } + + t.Run("route POST config", func(t *testing.T) { + t.Run("does not save autogen routes", func(t *testing.T) { + sut, configs := createSutForAutogen(t) + rc := createRequestCtxInOrg(1) + request := createAmConfigRequest(t, validConfigWithAutogen) + response := sut.RoutePostAlertingConfig(rc, request) + require.Equal(t, 202, response.Status()) + + compare(t, validConfigWithoutAutogen, configs[1].AlertmanagerConfiguration) + }) + + t.Run("provenance guard ignores autogen routes", func(t *testing.T) { + sut := createSut(t) + rc := createRequestCtxInOrg(1) + request := createAmConfigRequest(t, validConfigWithoutAutogen) + _ = sut.RoutePostAlertingConfig(rc, request) + + setRouteProvenance(t, 1, sut.mam.ProvStore) + request = createAmConfigRequest(t, validConfigWithAutogen) + request.AlertmanagerConfig.Route.Provenance = apimodels.Provenance(ngmodels.ProvenanceAPI) + response := sut.RoutePostAlertingConfig(rc, request) + require.Equal(t, 202, response.Status()) + }) + }) + + t.Run("route GET config", func(t *testing.T) { + t.Run("when admin return autogen routes", func(t *testing.T) { + sut, _ := createSutForAutogen(t) + + rc := createRequestCtxInOrg(2) + rc.SignedInUser.OrgRole = org.RoleAdmin + + response := sut.RouteGetAlertingConfig(rc) + require.Equal(t, 200, response.Status()) + + compare(t, validConfigWithAutogen, string(response.Body())) + }) + + t.Run("when not admin return no autogen routes", func(t *testing.T) { + sut, _ := createSutForAutogen(t) + + rc := createRequestCtxInOrg(2) + + response := sut.RouteGetAlertingConfig(rc) + require.Equal(t, 200, response.Status()) + + compare(t, validConfigWithoutAutogen, string(response.Body())) + }) + }) +} + func TestRouteGetAlertingConfigHistory(t *testing.T) { sut := createSut(t) @@ -633,7 +715,12 @@ func TestRouteCreateSilence(t *testing.T) { func createSut(t *testing.T) AlertmanagerSrv { t.Helper() - mam := createMultiOrgAlertmanager(t) + configs := map[int64]*ngmodels.AlertConfiguration{ + 1: {AlertmanagerConfiguration: validConfig, OrgID: 1}, + 2: {AlertmanagerConfiguration: validConfig, OrgID: 2}, + 3: {AlertmanagerConfiguration: brokenConfig, OrgID: 3}, + } + mam := createMultiOrgAlertmanager(t, configs) log := log.NewNopLogger() return AlertmanagerSrv{ mam: mam, @@ -653,17 +740,12 @@ func createAmConfigRequest(t *testing.T, config string) apimodels.PostableUserCo return request } -func createMultiOrgAlertmanager(t *testing.T) *notifier.MultiOrgAlertmanager { +func createMultiOrgAlertmanager(t *testing.T, configs map[int64]*ngmodels.AlertConfiguration) *notifier.MultiOrgAlertmanager { t.Helper() - configs := map[int64]*ngmodels.AlertConfiguration{ - 1: {AlertmanagerConfiguration: validConfig, OrgID: 1}, - 2: {AlertmanagerConfiguration: validConfig, OrgID: 2}, - 3: {AlertmanagerConfiguration: brokenConfig, OrgID: 3}, - } configStore := notifier.NewFakeConfigStore(t, configs) orgStore := notifier.NewFakeOrgStore(t, []int64{1, 2, 3}) - provStore := provisioning.NewFakeProvisioningStore() + provStore := ngfakes.NewFakeProvisioningStore() tmpDir := t.TempDir() kvStore := ngfakes.NewFakeKVStore(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) @@ -679,7 +761,7 @@ func createMultiOrgAlertmanager(t *testing.T) *notifier.MultiOrgAlertmanager { }, // do not poll in tests. } - mam, err := notifier.NewMultiOrgAlertmanager(cfg, configStore, &orgStore, kvStore, provStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService) + mam, err := notifier.NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, provStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService, featuremgmt.WithManager(featuremgmt.FlagAlertingSimplifiedRouting)) require.NoError(t, err) err = mam.LoadAndSyncAlertmanagersForOrgs(context.Background()) require.NoError(t, err) @@ -710,6 +792,90 @@ var validConfig = `{ } ` +var validConfigWithoutAutogen = `{ + "template_files": { + "a": "template" + }, + "alertmanager_config": { + "route": { + "receiver": "some email", + "routes": [{ + "receiver": "other email", + "object_matchers": [["a", "=", "b"]] + }] + }, + "receivers": [{ + "name": "some email", + "grafana_managed_receiver_configs": [{ + "name": "some email", + "type": "email", + "settings": { + "addresses": "<some@email.com>" + } + }] + },{ + "name": "other email", + "grafana_managed_receiver_configs": [{ + "name": "other email", + "type": "email", + "settings": { + "addresses": "<other@email.com>" + } + }] + }] + } +} +` + +var validConfigWithAutogen = `{ + "template_files": { + "a": "template" + }, + "alertmanager_config": { + "route": { + "receiver": "some email", + "routes": [{ + "receiver": "some email", + "object_matchers": [["__grafana_autogenerated__", "=", "true"]], + "routes": [{ + "receiver": "some email", + "group_by": ["grafana_folder", "alertname"], + "object_matchers": [["__grafana_receiver__", "=", "some email"]], + "continue": false + },{ + "receiver": "other email", + "group_by": ["grafana_folder", "alertname"], + "object_matchers": [["__grafana_receiver__", "=", "other email"]], + "continue": false + }] + },{ + "receiver": "other email", + "object_matchers": [["a", "=", "b"]] + }] + }, + "receivers": [{ + "name": "some email", + "grafana_managed_receiver_configs": [{ + "name": "some email", + "type": "email", + "settings": { + "addresses": "<some@email.com>" + } + }] + },{ + "name": "other email", + "grafana_managed_receiver_configs": [{ + "name": "other email", + "type": "email", + "settings": { + "addresses": "<other@email.com>" + } + }] + }] + } +} +` + var validConfigWithSecureSetting = `{ "template_files": { "a": "template" diff --git a/pkg/services/ngalert/api/api_configuration.go b/pkg/services/ngalert/api/api_configuration.go index dd309988257eb..e1c4fd3376f2d 100644 --- a/pkg/services/ngalert/api/api_configuration.go +++ b/pkg/services/ngalert/api/api_configuration.go @@ -72,16 +72,16 @@ func (srv ConfigSrv) RoutePostNGalertConfig(c *contextmodel.ReqContext, body api sendAlertsTo, err := ngmodels.StringToAlertmanagersChoice(string(body.AlertmanagersChoice)) if err != nil { - return response.Error(400, "Invalid alertmanager choice specified", err) + return response.Error(http.StatusBadRequest, "Invalid alertmanager choice specified", err) } externalAlertmanagers, err := srv.externalAlertmanagers(c.Req.Context(), c.SignedInUser.GetOrgID()) if err != nil { - return response.Error(500, "Couldn't fetch the external Alertmanagers from datasources", err) + return response.Error(http.StatusInternalServerError, "Couldn't fetch the external Alertmanagers from datasources", err) } if sendAlertsTo == ngmodels.ExternalAlertmanagers && len(externalAlertmanagers) < 1 { - return response.Error(400, "At least one Alertmanager must be provided or configured as a datasource that handles alerts to choose this option", nil) + return response.Error(http.StatusBadRequest, "At least one Alertmanager must be provided or configured as a datasource that handles alerts to choose this option", nil) } cfg := &ngmodels.AdminConfiguration{ diff --git a/pkg/services/ngalert/api/api_notifications.go b/pkg/services/ngalert/api/api_notifications.go new file mode 100644 index 0000000000000..1fd4bafec307c --- /dev/null +++ b/pkg/services/ngalert/api/api_notifications.go @@ -0,0 +1,83 @@ +package api + +import ( + "context" + "errors" + "net/http" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/auth/identity" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" +) + +type NotificationSrv struct { + logger log.Logger + receiverService ReceiverService + muteTimingService MuteTimingService // defined in api_provisioning.go +} + +type ReceiverService interface { + GetReceiver(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) + GetReceivers(ctx context.Context, q models.GetReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) +} + +func (srv *NotificationSrv) RouteGetTimeInterval(c *contextmodel.ReqContext, name string) response.Response { + muteTimeInterval, err := srv.muteTimingService.GetMuteTiming(c.Req.Context(), name, c.OrgID) + if err != nil { + return errorToResponse(err) + } + return response.JSON(http.StatusOK, muteTimeInterval) // TODO convert to timing interval +} + +func (srv *NotificationSrv) RouteGetTimeIntervals(c *contextmodel.ReqContext) response.Response { + muteTimeIntervals, err := srv.muteTimingService.GetMuteTimings(c.Req.Context(), c.OrgID) + if err != nil { + return errorToResponse(err) + } + return response.JSON(http.StatusOK, muteTimeIntervals) // TODO convert to timing interval +} + +func (srv *NotificationSrv) RouteGetReceiver(c *contextmodel.ReqContext, name string) response.Response { + q := models.GetReceiverQuery{ + OrgID: c.SignedInUser.OrgID, + Name: name, + Decrypt: c.QueryBool("decrypt"), + } + + receiver, err := srv.receiverService.GetReceiver(c.Req.Context(), q, c.SignedInUser) + if err != nil { + if errors.Is(err, notifier.ErrNotFound) { + return ErrResp(http.StatusNotFound, err, "receiver not found") + } + if errors.Is(err, notifier.ErrPermissionDenied) { + return ErrResp(http.StatusForbidden, err, "permission denied") + } + return ErrResp(http.StatusInternalServerError, err, "failed to get receiver") + } + + return response.JSON(http.StatusOK, receiver) +} + +func (srv *NotificationSrv) RouteGetReceivers(c *contextmodel.ReqContext) response.Response { + q := models.GetReceiversQuery{ + OrgID: c.SignedInUser.OrgID, + Names: c.QueryStrings("names"), + Limit: c.QueryInt("limit"), + Offset: c.QueryInt("offset"), + Decrypt: c.QueryBool("decrypt"), + } + + receivers, err := srv.receiverService.GetReceivers(c.Req.Context(), q, c.SignedInUser) + if err != nil { + if errors.Is(err, notifier.ErrPermissionDenied) { + return ErrResp(http.StatusForbidden, err, "permission denied") + } + return ErrResp(http.StatusInternalServerError, err, "failed to get receiver groups") + } + + return response.JSON(http.StatusOK, receivers) +} diff --git a/pkg/services/ngalert/api/api_notifications_test.go b/pkg/services/ngalert/api/api_notifications_test.go new file mode 100644 index 0000000000000..1af5cbac066c5 --- /dev/null +++ b/pkg/services/ngalert/api/api_notifications_test.go @@ -0,0 +1,188 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/log/logtest" + "github.com/grafana/grafana/pkg/services/auth/identity" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" + "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/web" + + am_config "github.com/prometheus/alertmanager/config" + "github.com/stretchr/testify/require" +) + +func TestRouteGetReceiver(t *testing.T) { + fakeReceiverSvc := fakes.NewFakeReceiverService() + + t.Run("returns expected model", func(t *testing.T) { + expected := definitions.GettableApiReceiver{ + Receiver: am_config.Receiver{ + Name: "receiver1", + }, + GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{ + GrafanaManagedReceivers: []*definitions.GettableGrafanaReceiver{ + { + UID: "uid1", + Name: "receiver1", + Type: "slack", + }, + }, + }, + } + fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) { + return expected, nil + } + handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) + rc := testReqCtx("GET") + resp := handler.handleRouteGetReceiver(&rc, "receiver1") + require.Equal(t, http.StatusOK, resp.Status()) + json, err := json.Marshal(expected) + require.NoError(t, err) + require.Equal(t, json, resp.Body()) + }) + + t.Run("builds query from request context and url param", func(t *testing.T) { + fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) { + return definitions.GettableApiReceiver{}, nil + } + handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) + rc := testReqCtx("GET") + rc.Context.Req.Form.Set("decrypt", "true") + resp := handler.handleRouteGetReceiver(&rc, "receiver1") + require.Equal(t, http.StatusOK, resp.Status()) + + call := fakeReceiverSvc.PopMethodCall() + require.Equal(t, "GetReceiver", call.Method) + expectedQ := models.GetReceiverQuery{ + Name: "receiver1", + Decrypt: true, + OrgID: 1, + } + require.Equal(t, expectedQ, call.Args[1]) + }) + + t.Run("should pass along not found response", func(t *testing.T) { + fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) { + return definitions.GettableApiReceiver{}, notifier.ErrNotFound + } + handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) + rc := testReqCtx("GET") + resp := handler.handleRouteGetReceiver(&rc, "receiver1") + require.Equal(t, http.StatusNotFound, resp.Status()) + }) + + t.Run("should pass along permission denied response", func(t *testing.T) { + fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) { + return definitions.GettableApiReceiver{}, notifier.ErrPermissionDenied + } + handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) + rc := testReqCtx("GET") + resp := handler.handleRouteGetReceiver(&rc, "receiver1") + require.Equal(t, http.StatusForbidden, resp.Status()) + }) +} + +func TestRouteGetReceivers(t *testing.T) { + fakeReceiverSvc := fakes.NewFakeReceiverService() + + t.Run("returns expected model", func(t *testing.T) { + expected := []definitions.GettableApiReceiver{ + { + Receiver: am_config.Receiver{ + Name: "receiver1", + }, + GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{ + GrafanaManagedReceivers: []*definitions.GettableGrafanaReceiver{ + { + UID: "uid1", + Name: "receiver1", + Type: "slack", + }, + }, + }, + }, + } + fakeReceiverSvc.GetReceiversFn = func(ctx context.Context, q models.GetReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) { + return expected, nil + } + handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) + rc := testReqCtx("GET") + rc.Context.Req.Form.Set("names", "receiver1") + resp := handler.handleRouteGetReceivers(&rc) + require.Equal(t, http.StatusOK, resp.Status()) + json, err := json.Marshal(expected) + require.NoError(t, err) + require.Equal(t, json, resp.Body()) + }) + + t.Run("builds query from request context", func(t *testing.T) { + fakeReceiverSvc.GetReceiversFn = func(ctx context.Context, q models.GetReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) { + return []definitions.GettableApiReceiver{}, nil + } + handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) + rc := testReqCtx("GET") + rc.Context.Req.Form.Set("names", "receiver1") + rc.Context.Req.Form.Add("names", "receiver2") + rc.Context.Req.Form.Set("limit", "1") + rc.Context.Req.Form.Set("offset", "2") + rc.Context.Req.Form.Set("decrypt", "true") + resp := handler.handleRouteGetReceivers(&rc) + require.Equal(t, http.StatusOK, resp.Status()) + + call := fakeReceiverSvc.PopMethodCall() + require.Equal(t, "GetReceivers", call.Method) + expectedQ := models.GetReceiversQuery{ + Names: []string{"receiver1", "receiver2"}, + Limit: 1, + Offset: 2, + Decrypt: true, + OrgID: 1, + } + require.Equal(t, expectedQ, call.Args[1]) + }) + + t.Run("should pass along permission denied response", func(t *testing.T) { + fakeReceiverSvc.GetReceiversFn = func(ctx context.Context, q models.GetReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) { + return nil, notifier.ErrPermissionDenied + } + handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) + rc := testReqCtx("GET") + resp := handler.handleRouteGetReceivers(&rc) + require.Equal(t, http.StatusForbidden, resp.Status()) + }) +} + +func newNotificationSrv(receiverService ReceiverService) *NotificationSrv { + return &NotificationSrv{ + logger: log.NewNopLogger(), + receiverService: receiverService, + } +} + +func testReqCtx(method string) contextmodel.ReqContext { + return contextmodel.ReqContext{ + Context: &web.Context{ + Req: &http.Request{ + Header: make(http.Header), + Form: make(url.Values), + }, + Resp: web.NewResponseWriter(method, httptest.NewRecorder()), + }, + SignedInUser: &user.SignedInUser{ + OrgID: 1, + }, + Logger: &logtest.Fake{}, + } +} diff --git a/pkg/services/ngalert/api/api_prometheus.go b/pkg/services/ngalert/api/api_prometheus.go index db539626d8c3f..883dcf577248b 100644 --- a/pkg/services/ngalert/api/api_prometheus.go +++ b/pkg/services/ngalert/api/api_prometheus.go @@ -304,7 +304,7 @@ func (srv PrometheusSrv) toRuleGroup(groupKey ngmodels.AlertRuleGroupKey, folder newGroup := &apimodels.RuleGroup{ Name: groupKey.RuleGroup, // file is what Prometheus uses for provisioning, we replace it with namespace which is the folder in Grafana. - File: folder.Title, + File: folder.Fullpath, } rulesTotals := make(map[string]int64, len(rules)) diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go index c8e67e7cf057e..d4425cf678ae0 100644 --- a/pkg/services/ngalert/api/api_provisioning.go +++ b/pkg/services/ngalert/api/api_provisioning.go @@ -38,7 +38,7 @@ type ContactPointService interface { } type TemplateService interface { - GetTemplates(ctx context.Context, orgID int64) (map[string]string, error) + GetTemplates(ctx context.Context, orgID int64) ([]definitions.NotificationTemplate, error) SetTemplate(ctx context.Context, orgID int64, tmpl definitions.NotificationTemplate) (definitions.NotificationTemplate, error) DeleteTemplate(ctx context.Context, orgID int64, name string) error } @@ -51,22 +51,24 @@ type NotificationPolicyService interface { type MuteTimingService interface { GetMuteTimings(ctx context.Context, orgID int64) ([]definitions.MuteTimeInterval, error) - CreateMuteTiming(ctx context.Context, mt definitions.MuteTimeInterval, orgID int64) (*definitions.MuteTimeInterval, error) - UpdateMuteTiming(ctx context.Context, mt definitions.MuteTimeInterval, orgID int64) (*definitions.MuteTimeInterval, error) + GetMuteTiming(ctx context.Context, name string, orgID int64) (definitions.MuteTimeInterval, error) + CreateMuteTiming(ctx context.Context, mt definitions.MuteTimeInterval, orgID int64) (definitions.MuteTimeInterval, error) + UpdateMuteTiming(ctx context.Context, mt definitions.MuteTimeInterval, orgID int64) (definitions.MuteTimeInterval, error) DeleteMuteTiming(ctx context.Context, name string, orgID int64) error } type AlertRuleService interface { - GetAlertRules(ctx context.Context, orgID int64) ([]*alerting_models.AlertRule, map[string]alerting_models.Provenance, error) - GetAlertRule(ctx context.Context, orgID int64, ruleUID string) (alerting_models.AlertRule, alerting_models.Provenance, error) - CreateAlertRule(ctx context.Context, rule alerting_models.AlertRule, provenance alerting_models.Provenance, userID int64) (alerting_models.AlertRule, error) - UpdateAlertRule(ctx context.Context, rule alerting_models.AlertRule, provenance alerting_models.Provenance) (alerting_models.AlertRule, error) - DeleteAlertRule(ctx context.Context, orgID int64, ruleUID string, provenance alerting_models.Provenance) error - GetRuleGroup(ctx context.Context, orgID int64, folder, group string) (alerting_models.AlertRuleGroup, error) - ReplaceRuleGroup(ctx context.Context, orgID int64, group alerting_models.AlertRuleGroup, userID int64, provenance alerting_models.Provenance) error - GetAlertRuleWithFolderTitle(ctx context.Context, orgID int64, ruleUID string) (provisioning.AlertRuleWithFolderTitle, error) - GetAlertRuleGroupWithFolderTitle(ctx context.Context, orgID int64, folder, group string) (alerting_models.AlertRuleGroupWithFolderTitle, error) - GetAlertGroupsWithFolderTitle(ctx context.Context, orgID int64, folderUIDs []string) ([]alerting_models.AlertRuleGroupWithFolderTitle, error) + GetAlertRules(ctx context.Context, user identity.Requester) ([]*alerting_models.AlertRule, map[string]alerting_models.Provenance, error) + GetAlertRule(ctx context.Context, user identity.Requester, ruleUID string) (alerting_models.AlertRule, alerting_models.Provenance, error) + CreateAlertRule(ctx context.Context, user identity.Requester, rule alerting_models.AlertRule, provenance alerting_models.Provenance) (alerting_models.AlertRule, error) + UpdateAlertRule(ctx context.Context, user identity.Requester, rule alerting_models.AlertRule, provenance alerting_models.Provenance) (alerting_models.AlertRule, error) + DeleteAlertRule(ctx context.Context, user identity.Requester, ruleUID string, provenance alerting_models.Provenance) error + GetRuleGroup(ctx context.Context, user identity.Requester, folder, group string) (alerting_models.AlertRuleGroup, error) + ReplaceRuleGroup(ctx context.Context, user identity.Requester, group alerting_models.AlertRuleGroup, provenance alerting_models.Provenance) error + DeleteRuleGroup(ctx context.Context, user identity.Requester, folder, group string, provenance alerting_models.Provenance) error + GetAlertRuleWithFolderTitle(ctx context.Context, user identity.Requester, ruleUID string) (provisioning.AlertRuleWithFolderTitle, error) + GetAlertRuleGroupWithFolderTitle(ctx context.Context, user identity.Requester, folder, group string) (alerting_models.AlertRuleGroupWithFolderTitle, error) + GetAlertGroupsWithFolderTitle(ctx context.Context, user identity.Requester, folderUIDs []string) ([]alerting_models.AlertRuleGroupWithFolderTitle, error) } func (srv *ProvisioningSrv) RouteGetPolicyTree(c *contextmodel.ReqContext) response.Response { @@ -200,11 +202,7 @@ func (srv *ProvisioningSrv) RouteGetTemplates(c *contextmodel.ReqContext) respon if err != nil { return ErrResp(http.StatusInternalServerError, err, "") } - result := make([]definitions.NotificationTemplate, 0, len(templates)) - for k, v := range templates { - result = append(result, definitions.NotificationTemplate{Name: k, Template: v}) - } - return response.JSON(http.StatusOK, result) + return response.JSON(http.StatusOK, templates) } func (srv *ProvisioningSrv) RouteGetTemplate(c *contextmodel.ReqContext, name string) response.Response { @@ -212,8 +210,10 @@ func (srv *ProvisioningSrv) RouteGetTemplate(c *contextmodel.ReqContext, name st if err != nil { return ErrResp(http.StatusInternalServerError, err, "") } - if tmpl, ok := templates[name]; ok { - return response.JSON(http.StatusOK, definitions.NotificationTemplate{Name: name, Template: tmpl}) + for _, tmpl := range templates { + if tmpl.Name == name { + return response.JSON(http.StatusOK, tmpl) + } } return response.Empty(http.StatusNotFound) } @@ -243,22 +243,17 @@ func (srv *ProvisioningSrv) RouteDeleteTemplate(c *contextmodel.ReqContext, name } func (srv *ProvisioningSrv) RouteGetMuteTiming(c *contextmodel.ReqContext, name string) response.Response { - timings, err := srv.muteTimings.GetMuteTimings(c.Req.Context(), c.SignedInUser.GetOrgID()) + timing, err := srv.muteTimings.GetMuteTiming(c.Req.Context(), name, c.SignedInUser.GetOrgID()) if err != nil { - return ErrResp(http.StatusInternalServerError, err, "") + return response.ErrOrFallback(http.StatusInternalServerError, "failed to get mute timing by name", err) } - for _, timing := range timings { - if name == timing.Name { - return response.JSON(http.StatusOK, timing) - } - } - return response.Empty(http.StatusNotFound) + return response.JSON(http.StatusOK, timing) } func (srv *ProvisioningSrv) RouteGetMuteTimingExport(c *contextmodel.ReqContext, name string) response.Response { timings, err := srv.muteTimings.GetMuteTimings(c.Req.Context(), c.SignedInUser.GetOrgID()) if err != nil { - return ErrResp(http.StatusInternalServerError, err, "") + return response.ErrOrFallback(http.StatusInternalServerError, "failed to get mute timings", err) } for _, timing := range timings { if name == timing.Name { @@ -272,7 +267,7 @@ func (srv *ProvisioningSrv) RouteGetMuteTimingExport(c *contextmodel.ReqContext, func (srv *ProvisioningSrv) RouteGetMuteTimings(c *contextmodel.ReqContext) response.Response { timings, err := srv.muteTimings.GetMuteTimings(c.Req.Context(), c.SignedInUser.GetOrgID()) if err != nil { - return ErrResp(http.StatusInternalServerError, err, "") + return response.ErrOrFallback(http.StatusInternalServerError, "failed to get mute timings", err) } return response.JSON(http.StatusOK, timings) } @@ -280,7 +275,7 @@ func (srv *ProvisioningSrv) RouteGetMuteTimings(c *contextmodel.ReqContext) resp func (srv *ProvisioningSrv) RouteGetMuteTimingsExport(c *contextmodel.ReqContext) response.Response { timings, err := srv.muteTimings.GetMuteTimings(c.Req.Context(), c.SignedInUser.GetOrgID()) if err != nil { - return ErrResp(http.StatusInternalServerError, err, "") + return response.ErrOrFallback(http.StatusInternalServerError, "failed to get mute timings", err) } e := AlertingFileExportFromMuteTimings(c.SignedInUser.GetOrgID(), timings) return exportResponse(c, e) @@ -290,10 +285,7 @@ func (srv *ProvisioningSrv) RoutePostMuteTiming(c *contextmodel.ReqContext, mt d mt.Provenance = determineProvenance(c) created, err := srv.muteTimings.CreateMuteTiming(c.Req.Context(), mt, c.SignedInUser.GetOrgID()) if err != nil { - if errors.Is(err, provisioning.ErrValidation) { - return ErrResp(http.StatusBadRequest, err, "") - } - return ErrResp(http.StatusInternalServerError, err, "") + return response.ErrOrFallback(http.StatusInternalServerError, "failed to create mute timing", err) } return response.JSON(http.StatusCreated, created) } @@ -303,13 +295,7 @@ func (srv *ProvisioningSrv) RoutePutMuteTiming(c *contextmodel.ReqContext, mt de mt.Provenance = determineProvenance(c) updated, err := srv.muteTimings.UpdateMuteTiming(c.Req.Context(), mt, c.SignedInUser.GetOrgID()) if err != nil { - if errors.Is(err, provisioning.ErrValidation) { - return ErrResp(http.StatusBadRequest, err, "") - } - return ErrResp(http.StatusInternalServerError, err, "") - } - if updated == nil { - return response.Empty(http.StatusNotFound) + return response.ErrOrFallback(http.StatusInternalServerError, "failed to update mute timing", err) } return response.JSON(http.StatusAccepted, updated) } @@ -317,13 +303,13 @@ func (srv *ProvisioningSrv) RoutePutMuteTiming(c *contextmodel.ReqContext, mt de func (srv *ProvisioningSrv) RouteDeleteMuteTiming(c *contextmodel.ReqContext, name string) response.Response { err := srv.muteTimings.DeleteMuteTiming(c.Req.Context(), name, c.SignedInUser.GetOrgID()) if err != nil { - return ErrResp(http.StatusInternalServerError, err, "") + return response.ErrOrFallback(http.StatusInternalServerError, "failed to delete mute timing", err) } return response.JSON(http.StatusNoContent, nil) } func (srv *ProvisioningSrv) RouteGetAlertRules(c *contextmodel.ReqContext) response.Response { - rules, provenances, err := srv.alertRules.GetAlertRules(c.Req.Context(), c.SignedInUser.GetOrgID()) + rules, provenances, err := srv.alertRules.GetAlertRules(c.Req.Context(), c.SignedInUser) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") } @@ -331,7 +317,7 @@ func (srv *ProvisioningSrv) RouteGetAlertRules(c *contextmodel.ReqContext) respo } func (srv *ProvisioningSrv) RouteRouteGetAlertRule(c *contextmodel.ReqContext, UID string) response.Response { - rule, provenace, err := srv.alertRules.GetAlertRule(c.Req.Context(), c.SignedInUser.GetOrgID(), UID) + rule, provenace, err := srv.alertRules.GetAlertRule(c.Req.Context(), c.SignedInUser, UID) if err != nil { if errors.Is(err, alerting_models.ErrAlertRuleNotFound) { return response.Empty(http.StatusNotFound) @@ -348,8 +334,7 @@ func (srv *ProvisioningSrv) RoutePostAlertRule(c *contextmodel.ReqContext, ar de return ErrResp(http.StatusBadRequest, err, "") } provenance := determineProvenance(c) - userID, _ := identity.UserIdentifier(c.SignedInUser.GetNamespacedID()) - createdAlertRule, err := srv.alertRules.CreateAlertRule(c.Req.Context(), upstreamModel, alerting_models.Provenance(provenance), userID) + createdAlertRule, err := srv.alertRules.CreateAlertRule(c.Req.Context(), c.SignedInUser, upstreamModel, alerting_models.Provenance(provenance)) if errors.Is(err, alerting_models.ErrAlertRuleFailedValidation) { return ErrResp(http.StatusBadRequest, err, "") } @@ -378,7 +363,7 @@ func (srv *ProvisioningSrv) RoutePutAlertRule(c *contextmodel.ReqContext, ar def updated.OrgID = c.SignedInUser.GetOrgID() updated.UID = UID provenance := determineProvenance(c) - updatedAlertRule, err := srv.alertRules.UpdateAlertRule(c.Req.Context(), updated, alerting_models.Provenance(provenance)) + updatedAlertRule, err := srv.alertRules.UpdateAlertRule(c.Req.Context(), c.SignedInUser, updated, alerting_models.Provenance(provenance)) if errors.Is(err, alerting_models.ErrAlertRuleUniqueConstraintViolation) { return ErrResp(http.StatusBadRequest, err, "") } @@ -401,7 +386,7 @@ func (srv *ProvisioningSrv) RoutePutAlertRule(c *contextmodel.ReqContext, ar def func (srv *ProvisioningSrv) RouteDeleteAlertRule(c *contextmodel.ReqContext, UID string) response.Response { provenance := determineProvenance(c) - err := srv.alertRules.DeleteAlertRule(c.Req.Context(), c.SignedInUser.GetOrgID(), UID, alerting_models.Provenance(provenance)) + err := srv.alertRules.DeleteAlertRule(c.Req.Context(), c.SignedInUser, UID, alerting_models.Provenance(provenance)) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") } @@ -409,12 +394,9 @@ func (srv *ProvisioningSrv) RouteDeleteAlertRule(c *contextmodel.ReqContext, UID } func (srv *ProvisioningSrv) RouteGetAlertRuleGroup(c *contextmodel.ReqContext, folder string, group string) response.Response { - g, err := srv.alertRules.GetRuleGroup(c.Req.Context(), c.SignedInUser.GetOrgID(), folder, group) + g, err := srv.alertRules.GetRuleGroup(c.Req.Context(), c.SignedInUser, folder, group) if err != nil { - if errors.Is(err, store.ErrAlertRuleGroupNotFound) { - return ErrResp(http.StatusNotFound, err, "") - } - return ErrResp(http.StatusInternalServerError, err, "") + return response.ErrOrFallback(http.StatusInternalServerError, "", err) } return response.JSON(http.StatusOK, ApiAlertRuleGroupFromAlertRuleGroup(g)) } @@ -440,7 +422,7 @@ func (srv *ProvisioningSrv) RouteGetAlertRulesExport(c *contextmodel.ReqContext) return srv.RouteGetAlertRuleGroupExport(c, folderUIDs[0], group) } - groupsWithTitle, err := srv.alertRules.GetAlertGroupsWithFolderTitle(c.Req.Context(), c.SignedInUser.GetOrgID(), folderUIDs) + groupsWithTitle, err := srv.alertRules.GetAlertGroupsWithFolderTitle(c.Req.Context(), c.SignedInUser, folderUIDs) if err != nil { return ErrResp(http.StatusInternalServerError, err, "failed to get alert rules") } @@ -458,12 +440,9 @@ func (srv *ProvisioningSrv) RouteGetAlertRulesExport(c *contextmodel.ReqContext) // RouteGetAlertRuleGroupExport retrieves the given alert rule group in a format compatible with file provisioning. func (srv *ProvisioningSrv) RouteGetAlertRuleGroupExport(c *contextmodel.ReqContext, folder string, group string) response.Response { - g, err := srv.alertRules.GetAlertRuleGroupWithFolderTitle(c.Req.Context(), c.SignedInUser.GetOrgID(), folder, group) + g, err := srv.alertRules.GetAlertRuleGroupWithFolderTitle(c.Req.Context(), c.SignedInUser, folder, group) if err != nil { - if errors.Is(err, store.ErrAlertRuleGroupNotFound) { - return ErrResp(http.StatusNotFound, err, "") - } - return ErrResp(http.StatusInternalServerError, err, "failed to get alert rule group") + return response.ErrOrFallback(http.StatusInternalServerError, "failed to get alert rule group", err) } e, err := AlertingFileExportFromAlertRuleGroupWithFolderTitle([]alerting_models.AlertRuleGroupWithFolderTitle{g}) @@ -476,7 +455,7 @@ func (srv *ProvisioningSrv) RouteGetAlertRuleGroupExport(c *contextmodel.ReqCont // RouteGetAlertRuleExport retrieves the given alert rule in a format compatible with file provisioning. func (srv *ProvisioningSrv) RouteGetAlertRuleExport(c *contextmodel.ReqContext, UID string) response.Response { - rule, err := srv.alertRules.GetAlertRuleWithFolderTitle(c.Req.Context(), c.SignedInUser.GetOrgID(), UID) + rule, err := srv.alertRules.GetAlertRuleWithFolderTitle(c.Req.Context(), c.SignedInUser, UID) if err != nil { if errors.Is(err, alerting_models.ErrAlertRuleNotFound) { return ErrResp(http.StatusNotFound, err, "") @@ -502,9 +481,7 @@ func (srv *ProvisioningSrv) RoutePutAlertRuleGroup(c *contextmodel.ReqContext, a ErrResp(http.StatusBadRequest, err, "") } provenance := determineProvenance(c) - - userID, _ := identity.UserIdentifier(c.SignedInUser.GetNamespacedID()) - err = srv.alertRules.ReplaceRuleGroup(c.Req.Context(), c.SignedInUser.GetOrgID(), groupModel, userID, alerting_models.Provenance(provenance)) + err = srv.alertRules.ReplaceRuleGroup(c.Req.Context(), c.SignedInUser, groupModel, alerting_models.Provenance(provenance)) if errors.Is(err, alerting_models.ErrAlertRuleUniqueConstraintViolation) { return ErrResp(http.StatusBadRequest, err, "") } @@ -520,6 +497,15 @@ func (srv *ProvisioningSrv) RoutePutAlertRuleGroup(c *contextmodel.ReqContext, a return response.JSON(http.StatusOK, ag) } +func (srv *ProvisioningSrv) RouteDeleteAlertRuleGroup(c *contextmodel.ReqContext, folderUID string, group string) response.Response { + provenance := determineProvenance(c) + err := srv.alertRules.DeleteRuleGroup(c.Req.Context(), c.SignedInUser, folderUID, group, alerting_models.Provenance(provenance)) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "", err) + } + return response.JSON(http.StatusNoContent, "") +} + func determineProvenance(ctx *contextmodel.ReqContext) definitions.Provenance { if _, disabled := ctx.Req.Header[disableProvenanceHeaderName]; disabled { return definitions.Provenance(alerting_models.ProvenanceNone) @@ -619,11 +605,11 @@ func exportHcl(download bool, body definitions.AlertingFileExport) response.Resp return nil } if err := convertToResources(); err != nil { - return response.Error(500, "failed to convert to HCL resources", err) + return response.Error(http.StatusInternalServerError, "failed to convert to HCL resources", err) } hclBody, err := hcl.Encode(resources...) if err != nil { - return response.Error(500, "body hcl encode", err) + return response.Error(http.StatusInternalServerError, "body hcl encode", err) } resp := response.Respond(http.StatusOK, hclBody) if download { diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index ded0b5196d6c6..361d3a651a9c7 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "net/url" "path" + "strings" "testing" "time" @@ -25,6 +26,8 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier" @@ -34,10 +37,15 @@ import ( secrets_fakes "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestProvisioningApi(t *testing.T) { t.Run("policies", func(t *testing.T) { t.Run("successful GET returns 200", func(t *testing.T) { @@ -265,6 +273,39 @@ func TestProvisioningApi(t *testing.T) { require.NotEmpty(t, response.Body()) require.Contains(t, string(response.Body()), "invalid alert rule") }) + + t.Run("POST returns 400 when folderUID not set", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + rule := createTestAlertRule("rule", 1) + rule.FolderUID = "" + + response := sut.RoutePostAlertRule(&rc, rule) + + require.Equal(t, 400, response.Status()) + require.NotEmpty(t, response.Body()) + require.Contains(t, string(response.Body()), "invalid alert rule") + require.Contains(t, string(response.Body()), "folderUID must be set") + }) + + t.Run("POST returns 400 if folder does not exist", func(t *testing.T) { + testEnv := createTestEnv(t, testConfig) + // Create a fake folder service that will return an error when trying to get a folder. + folderService := foldertest.NewFakeService() + folderService.ExpectedError = dashboards.ErrFolderNotFound + testEnv.folderService = folderService + sut := createProvisioningSrvSutFromEnv(t, &testEnv) + + rc := createTestRequestCtx() + rule := createTestAlertRule("rule", 1) + + response := sut.RoutePostAlertRule(&rc, rule) + + require.Equal(t, 400, response.Status()) + require.NotEmpty(t, response.Body()) + require.Contains(t, string(response.Body()), "invalid alert rule") + require.Contains(t, string(response.Body()), "folder does not exist") + }) }) t.Run("exist in non-default orgs", func(t *testing.T) { @@ -337,24 +378,40 @@ func TestProvisioningApi(t *testing.T) { }) t.Run("alert rule groups", func(t *testing.T) { - t.Run("are present, GET returns 200", func(t *testing.T) { + t.Run("are present", func(t *testing.T) { sut := createProvisioningSrvSut(t) rc := createTestRequestCtx() insertRule(t, sut, createTestAlertRule("rule", 1)) - response := sut.RouteGetAlertRuleGroup(&rc, "folder-uid", "my-cool-group") + t.Run("GET returns 200", func(t *testing.T) { + response := sut.RouteGetAlertRuleGroup(&rc, "folder-uid", "my-cool-group") - require.Equal(t, 200, response.Status()) + require.Equal(t, 200, response.Status()) + }) + + t.Run("DELETE returns 204", func(t *testing.T) { + response := sut.RouteDeleteAlertRuleGroup(&rc, "folder-uid", "my-cool-group") + + require.Equal(t, 204, response.Status()) + }) }) - t.Run("are missing, GET returns 404", func(t *testing.T) { + t.Run("are missing", func(t *testing.T) { sut := createProvisioningSrvSut(t) rc := createTestRequestCtx() insertRule(t, sut, createTestAlertRule("rule", 1)) - response := sut.RouteGetAlertRuleGroup(&rc, "folder-uid", "does not exist") + t.Run("GET returns 404", func(t *testing.T) { + response := sut.RouteGetAlertRuleGroup(&rc, "folder-uid", "does not exist") - require.Equal(t, 404, response.Status()) + require.Equal(t, 404, response.Status()) + }) + + t.Run("DELETE returns 404", func(t *testing.T) { + response := sut.RouteDeleteAlertRuleGroup(&rc, "folder-uid", "does not exist") + + require.Equal(t, 404, response.Status()) + }) }) t.Run("are invalid at group level", func(t *testing.T) { @@ -519,17 +576,14 @@ func TestProvisioningApi(t *testing.T) { t.Run("yaml body content is the default", func(t *testing.T) { sut := createProvisioningSrvSut(t) rc := createTestRequestCtx() - insertRule(t, sut, createTestAlertRule("rule1", 1)) + rule1 := createTestAlertRule("rule1", 1) + rule1.NotificationSettings = nil + insertRule(t, sut, rule1) insertRule(t, sut, createTestAlertRule("rule2", 1)) - expectedResponse := "apiVersion: 1\ngroups:\n - orgId: 1\n name: my-cool-group\n folder" + - ": Folder Title\n interval: 1m\n rules:\n - uid: rule1\n title: rule1\n" + - " condition: A\n data:\n - refId: A\n datasourceUid" + - ": \"\"\n model:\n conditions:\n - evaluator:\n" + - " params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n - uid: rule2\n title: rule2\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n" + expectedResponse := "apiVersion: 1\ngroups:\n - orgId: 1\n name: my-cool-group\n folder: Folder Title\n interval: 1m\n rules:\n - uid: rule1\n title: rule1\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n - uid: rule2\n title: rule2\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n notification_settings:\n receiver: Test-Receiver\n group_by:\n - alertname\n - grafana_folder\n - test\n group_wait: 1s\n group_interval: 5s\n repeat_interval: 5m\n mute_time_intervals:\n - test-mute\n" response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group") - require.Equal(t, 200, response.Status()) require.Equal(t, expectedResponse, string(response.Body())) }) @@ -537,14 +591,15 @@ func TestProvisioningApi(t *testing.T) { t.Run("json body content is as expected", func(t *testing.T) { sut := createProvisioningSrvSut(t) rc := createTestRequestCtx() - insertRule(t, sut, createTestAlertRule("rule1", 1)) + rule1 := createTestAlertRule("rule1", 1) + rule1.NotificationSettings = nil + insertRule(t, sut, rule1) insertRule(t, sut, createTestAlertRule("rule2", 1)) rc.Context.Req.Header.Add("Accept", "application/json") - expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"my-cool-group","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false},{"uid":"rule2","title":"rule2","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]}]}` + expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"my-cool-group","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false},{"uid":"rule2","title":"rule2","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false,"notification_settings":{"receiver":"Test-Receiver","group_by":["alertname","grafana_folder","test"],"group_wait":"1s","group_interval":"5s","repeat_interval":"5m","mute_time_intervals":["test-mute"]}}]}]}` response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group") - require.Equal(t, 200, response.Status()) require.Equal(t, expectedResponse, string(response.Body())) }) @@ -560,7 +615,7 @@ func TestProvisioningApi(t *testing.T) { ": Folder Title\n interval: 1m\n rules:\n - uid: rule1\n title: rule1\n" + " condition: A\n data:\n - refId: A\n datasourceUid" + ": \"\"\n model:\n conditions:\n - evaluator:\n" + - " params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n - uid: rule2\n title: rule2\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n" + " params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n notification_settings:\n receiver: Test-Receiver\n group_by:\n - alertname\n - grafana_folder\n - test\n group_wait: 1s\n group_interval: 5s\n repeat_interval: 5m\n mute_time_intervals:\n - test-mute\n - uid: rule2\n title: rule2\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n notification_settings:\n receiver: Test-Receiver\n group_by:\n - alertname\n - grafana_folder\n - test\n group_wait: 1s\n group_interval: 5s\n repeat_interval: 5m\n mute_time_intervals:\n - test-mute\n" response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group") @@ -579,6 +634,7 @@ func TestProvisioningApi(t *testing.T) { } rule1.NoDataState = definitions.Alerting rule1.ExecErrState = definitions.ErrorErrState + rule1.NotificationSettings = nil insertRule(t, sut, rule1) insertRule(t, sut, createTestAlertRule("rule2", 1)) @@ -635,6 +691,15 @@ func TestProvisioningApi(t *testing.T) { exec_err_state = "OK" for = "0s" is_paused = false + + notification_settings { + receiver = "Test-Receiver" + group_by = ["alertname", "grafana_folder", "test"] + group_wait = "1s" + group_interval = "5s" + repeat_interval = "5m" + mute_time_intervals = ["test-mute"] + } } } ` @@ -768,7 +833,7 @@ func TestProvisioningApi(t *testing.T) { rc := createTestRequestCtx() insertRule(t, sut, createTestAlertRule("rule1", 1)) - expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"my-cool-group","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]}]}` + expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"my-cool-group","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false,"notification_settings":{"receiver":"Test-Receiver","group_by":["alertname","grafana_folder","test"],"group_wait":"1s","group_interval":"5s","repeat_interval":"5m","mute_time_intervals":["test-mute"]}}]}]}` rc.Context.Req.Header.Add("Accept", "application/json") response := sut.RouteGetAlertRuleExport(&rc, "rule1") @@ -783,7 +848,7 @@ func TestProvisioningApi(t *testing.T) { insertRule(t, sut, createTestAlertRule("rule1", 1)) rc.Context.Req.Header.Add("Accept", "application/yaml") - expectedResponse := "apiVersion: 1\ngroups:\n - orgId: 1\n name: my-cool-group\n folder: Folder Title\n interval: 1m\n rules:\n - uid: rule1\n title: rule1\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n" + expectedResponse := "apiVersion: 1\ngroups:\n - orgId: 1\n name: my-cool-group\n folder: Folder Title\n interval: 1m\n rules:\n - uid: rule1\n title: rule1\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n notification_settings:\n receiver: Test-Receiver\n group_by:\n - alertname\n - grafana_folder\n - test\n group_wait: 1s\n group_interval: 5s\n repeat_interval: 5m\n mute_time_intervals:\n - test-mute\n" response := sut.RouteGetAlertRuleExport(&rc, "rule1") @@ -883,15 +948,19 @@ func TestProvisioningApi(t *testing.T) { t.Run("json body content is as expected", func(t *testing.T) { sut := createProvisioningSrvSut(t) rc := createTestRequestCtx() - insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule1", 1, "folder-uid", "groupa")) - insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule2", 1, "folder-uid", "groupb")) - insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule3", 1, "folder-uid2", "groupb")) + rule1 := createTestAlertRuleWithFolderAndGroup("rule1", 1, "folder-uid", "groupa") + rule1.NotificationSettings = nil + rule2 := createTestAlertRuleWithFolderAndGroup("rule2", 1, "folder-uid", "groupb") + rule1.NotificationSettings = &definitions.AlertRuleNotificationSettings{Receiver: "Email"} + rule3 := createTestAlertRuleWithFolderAndGroup("rule3", 1, "folder-uid2", "groupb") + insertRule(t, sut, rule1) + insertRule(t, sut, rule2) + insertRule(t, sut, rule3) rc.Context.Req.Header.Add("Accept", "application/json") - expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"groupa","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]},{"orgId":1,"name":"groupb","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule2","title":"rule2","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]},{"orgId":1,"name":"groupb","folder":"Folder Title2","interval":"1m","rules":[{"uid":"rule3","title":"rule3","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]}]}` + expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"groupa","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false,"notification_settings":{"receiver":"Email"}}]},{"orgId":1,"name":"groupb","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule2","title":"rule2","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false,"notification_settings":{"receiver":"Test-Receiver","group_by":["alertname","grafana_folder","test"],"group_wait":"1s","group_interval":"5s","repeat_interval":"5m","mute_time_intervals":["test-mute"]}}]},{"orgId":1,"name":"groupb","folder":"Folder Title2","interval":"1m","rules":[{"uid":"rule3","title":"rule3","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false,"notification_settings":{"receiver":"Test-Receiver","group_by":["alertname","grafana_folder","test"],"group_wait":"1s","group_interval":"5s","repeat_interval":"5m","mute_time_intervals":["test-mute"]}}]}]}` response := sut.RouteGetAlertRulesExport(&rc) - require.Equal(t, 200, response.Status()) require.Equal(t, expectedResponse, string(response.Body())) }) @@ -899,15 +968,19 @@ func TestProvisioningApi(t *testing.T) { t.Run("yaml body content is as expected", func(t *testing.T) { sut := createProvisioningSrvSut(t) rc := createTestRequestCtx() - insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule1", 1, "folder-uid", "groupa")) - insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule2", 1, "folder-uid", "groupb")) - insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule3", 1, "folder-uid2", "groupb")) + rule1 := createTestAlertRuleWithFolderAndGroup("rule1", 1, "folder-uid", "groupa") + rule1.NotificationSettings = nil + rule2 := createTestAlertRuleWithFolderAndGroup("rule2", 1, "folder-uid", "groupb") + rule1.NotificationSettings = &definitions.AlertRuleNotificationSettings{Receiver: "Email"} + rule3 := createTestAlertRuleWithFolderAndGroup("rule3", 1, "folder-uid2", "groupb") + insertRule(t, sut, rule1) + insertRule(t, sut, rule2) + insertRule(t, sut, rule3) rc.Context.Req.Header.Add("Accept", "application/yaml") - expectedResponse := "apiVersion: 1\ngroups:\n - orgId: 1\n name: groupa\n folder: Folder Title\n interval: 1m\n rules:\n - uid: rule1\n title: rule1\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n - orgId: 1\n name: groupb\n folder: Folder Title\n interval: 1m\n rules:\n - uid: rule2\n title: rule2\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n - orgId: 1\n name: groupb\n folder: Folder Title2\n interval: 1m\n rules:\n - uid: rule3\n title: rule3\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n" + expectedResponse := "apiVersion: 1\ngroups:\n - orgId: 1\n name: groupa\n folder: Folder Title\n interval: 1m\n rules:\n - uid: rule1\n title: rule1\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n notification_settings:\n receiver: Email\n - orgId: 1\n name: groupb\n folder: Folder Title\n interval: 1m\n rules:\n - uid: rule2\n title: rule2\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n notification_settings:\n receiver: Test-Receiver\n group_by:\n - alertname\n - grafana_folder\n - test\n group_wait: 1s\n group_interval: 5s\n repeat_interval: 5m\n mute_time_intervals:\n - test-mute\n - orgId: 1\n name: groupb\n folder: Folder Title2\n interval: 1m\n rules:\n - uid: rule3\n title: rule3\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n notification_settings:\n receiver: Test-Receiver\n group_by:\n - alertname\n - grafana_folder\n - test\n group_wait: 1s\n group_interval: 5s\n repeat_interval: 5m\n mute_time_intervals:\n - test-mute\n" response := sut.RouteGetAlertRulesExport(&rc) - require.Equal(t, 200, response.Status()) require.Equal(t, expectedResponse, string(response.Body())) }) @@ -921,10 +994,9 @@ func TestProvisioningApi(t *testing.T) { rc.Context.Req.Header.Add("Accept", "application/json") rc.Context.Req.Form.Set("folderUid", "folder-uid") - expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"groupa","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]},{"orgId":1,"name":"groupb","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule2","title":"rule2","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]}]}` + expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"groupa","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false,"notification_settings":{"receiver":"Test-Receiver","group_by":["alertname","grafana_folder","test"],"group_wait":"1s","group_interval":"5s","repeat_interval":"5m","mute_time_intervals":["test-mute"]}}]},{"orgId":1,"name":"groupb","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule2","title":"rule2","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false,"notification_settings":{"receiver":"Test-Receiver","group_by":["alertname","grafana_folder","test"],"group_wait":"1s","group_interval":"5s","repeat_interval":"5m","mute_time_intervals":["test-mute"]}}]}]}` response := sut.RouteGetAlertRulesExport(&rc) - require.Equal(t, 200, response.Status()) require.Equal(t, expectedResponse, string(response.Body())) }) @@ -939,10 +1011,9 @@ func TestProvisioningApi(t *testing.T) { rc.Context.Req.Header.Add("Accept", "application/json") rc.Context.Req.Form.Set("folder_uid", "folder-uid") rc.Context.Req.Form.Add("folder_uid", "folder-uid2") - expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"groupa","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]},{"orgId":1,"name":"groupb","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule2","title":"rule2","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]},{"orgId":1,"name":"groupb","folder":"Folder Title2","interval":"1m","rules":[{"uid":"rule3","title":"rule3","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]}]}` + expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"groupa","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false,"notification_settings":{"receiver":"Test-Receiver","group_by":["alertname","grafana_folder","test"],"group_wait":"1s","group_interval":"5s","repeat_interval":"5m","mute_time_intervals":["test-mute"]}}]},{"orgId":1,"name":"groupb","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule2","title":"rule2","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false,"notification_settings":{"receiver":"Test-Receiver","group_by":["alertname","grafana_folder","test"],"group_wait":"1s","group_interval":"5s","repeat_interval":"5m","mute_time_intervals":["test-mute"]}}]},{"orgId":1,"name":"groupb","folder":"Folder Title2","interval":"1m","rules":[{"uid":"rule3","title":"rule3","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false,"notification_settings":{"receiver":"Test-Receiver","group_by":["alertname","grafana_folder","test"],"group_wait":"1s","group_interval":"5s","repeat_interval":"5m","mute_time_intervals":["test-mute"]}}]}]}` response := sut.RouteGetAlertRulesExport(&rc) - require.Equal(t, 200, response.Status()) require.Equal(t, expectedResponse, string(response.Body())) }) @@ -958,10 +1029,9 @@ func TestProvisioningApi(t *testing.T) { rc.Context.Req.Form.Set("folderUid", "folder-uid") rc.Context.Req.Form.Set("group", "groupa") - expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"groupa","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]}]}` + expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"groupa","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false,"notification_settings":{"receiver":"Test-Receiver","group_by":["alertname","grafana_folder","test"],"group_wait":"1s","group_interval":"5s","repeat_interval":"5m","mute_time_intervals":["test-mute"]}}]}]}` response := sut.RouteGetAlertRulesExport(&rc) - require.Equal(t, 200, response.Status()) require.Equal(t, expectedResponse, string(response.Body())) @@ -997,7 +1067,7 @@ func TestProvisioningApi(t *testing.T) { rc.Context.Req.Header.Add("Accept", "application/json") rc.Context.Req.Form.Set("ruleUid", "rule1") - expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"groupa","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]}]}` + expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"groupa","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false,"notification_settings":{"receiver":"Test-Receiver","group_by":["alertname","grafana_folder","test"],"group_wait":"1s","group_interval":"5s","repeat_interval":"5m","mute_time_intervals":["test-mute"]}}]}]}` response := sut.RouteGetAlertRulesExport(&rc) @@ -1363,9 +1433,13 @@ func TestProvisioningApiContactPointExport(t *testing.T) { }) t.Run("decrypt true without alert.provisioning.secrets:read permissions returns 403", func(t *testing.T) { + recPermCheck := false env := createTestEnv(t, testConfig) env.ac = &recordingAccessControlFake{ Callback: func(user *user.SignedInUser, evaluator accesscontrol.Evaluator) (bool, error) { + if strings.Contains(evaluator.String(), accesscontrol.ActionAlertingProvisioningReadSecrets) { + recPermCheck = true + } return false, nil }, } @@ -1377,16 +1451,18 @@ func TestProvisioningApiContactPointExport(t *testing.T) { response := sut.RouteGetContactPointsExport(&rc) + require.True(t, recPermCheck) require.Equal(t, 403, response.Status()) - require.Len(t, env.ac.EvaluateRecordings, 1) - require.Equal(t, accesscontrol.ActionAlertingProvisioningReadSecrets, env.ac.EvaluateRecordings[0].Evaluator.String()) }) t.Run("decrypt true with admin returns 200", func(t *testing.T) { + recPermCheck := false env := createTestEnv(t, testConfig) env.ac = &recordingAccessControlFake{ Callback: func(user *user.SignedInUser, evaluator accesscontrol.Evaluator) (bool, error) { - require.Equal(t, accesscontrol.ActionAlertingProvisioningReadSecrets, evaluator.String()) + if strings.Contains(evaluator.String(), accesscontrol.ActionAlertingProvisioningReadSecrets) { + recPermCheck = true + } return true, nil }, } @@ -1399,9 +1475,8 @@ func TestProvisioningApiContactPointExport(t *testing.T) { response := sut.RouteGetContactPointsExport(&rc) response.WriteTo(&rc) + require.True(t, recPermCheck) require.Equal(t, 200, response.Status()) - require.Len(t, env.ac.EvaluateRecordings, 1) - require.Equal(t, accesscontrol.ActionAlertingProvisioningReadSecrets, env.ac.EvaluateRecordings[0].Evaluator.String()) }) t.Run("json body content is as expected", func(t *testing.T) { @@ -1531,6 +1606,7 @@ type testEnvironment struct { secrets secrets.Service log log.Logger store store.DBstore + folderService folder.Service dashboardService dashboards.DashboardService configs provisioning.AMConfigStore xact provisioning.TransactionManager @@ -1562,11 +1638,18 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment { AlertmanagerConfiguration: string(raw), }) sqlStore := db.InitTestDB(t) + + // init folder service with default folder + folderService := foldertest.NewFakeService() + folderService.ExpectedFolder = &folder.Folder{} + store := store.DBstore{ + Logger: log, SQLStore: sqlStore, Cfg: setting.UnifiedAlertingSettings{ BaseInterval: time.Second * 10, }, + FolderService: folderService, } quotas := &provisioning.MockQuotaChecker{} quotas.EXPECT().LimitOK() @@ -1596,6 +1679,7 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment { log: log, configs: configs, store: store, + folderService: folderService, dashboardService: dashboardService, xact: xact, prov: prov, @@ -1614,13 +1698,14 @@ func createProvisioningSrvSut(t *testing.T) ProvisioningSrv { func createProvisioningSrvSutFromEnv(t *testing.T, env *testEnvironment) ProvisioningSrv { t.Helper() + receiverSvc := notifier.NewReceiverService(env.ac, env.configs, env.prov, env.secrets, env.xact, env.log) return ProvisioningSrv{ log: env.log, policies: newFakeNotificationPolicyService(), - contactPointService: provisioning.NewContactPointService(env.configs, env.secrets, env.prov, env.xact, env.log, env.ac), + contactPointService: provisioning.NewContactPointService(env.configs, env.secrets, env.prov, env.xact, receiverSvc, env.log, env.store), templates: provisioning.NewTemplateService(env.configs, env.prov, env.xact, env.log), muteTimings: provisioning.NewMuteTimingService(env.configs, env.prov, env.xact, env.log), - alertRules: provisioning.NewAlertRuleService(env.store, env.prov, env.dashboardService, env.quotas, env.xact, 60, 10, env.log), + alertRules: provisioning.NewAlertRuleService(env.store, env.prov, env.folderService, env.dashboardService, env.quotas, env.xact, 60, 10, 100, env.log, &provisioning.NotificationSettingsValidatorProviderFake{}), } } @@ -1807,6 +1892,14 @@ func createTestAlertRule(title string, orgID int64) definitions.ProvisionedAlert For: model.Duration(60), NoDataState: definitions.OK, ExecErrState: definitions.OkErrState, + NotificationSettings: &definitions.AlertRuleNotificationSettings{ + Receiver: "Test-Receiver", + GroupBy: []string{"alertname", "grafana_folder", "test"}, + GroupWait: util.Pointer(model.Duration(1 * time.Second)), + GroupInterval: util.Pointer(model.Duration(5 * time.Second)), + RepeatInterval: util.Pointer(model.Duration(5 * time.Minute)), + MuteTimeIntervals: []string{"test-mute"}, + }, } } diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 769c2974e52a6..8a77b72a9cfeb 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "slices" "strings" "time" @@ -16,10 +17,12 @@ import ( "github.com/grafana/grafana/pkg/services/auth/identity" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/quota" @@ -33,6 +36,14 @@ type ConditionValidator interface { Validate(ctx eval.EvaluationContext, condition ngmodels.Condition) error } +type AMConfigStore interface { + GetLatestAlertmanagerConfiguration(ctx context.Context, orgID int64) (*ngmodels.AlertConfiguration, error) +} + +type AMRefresher interface { + ApplyConfig(ctx context.Context, orgId int64, dbConfig *ngmodels.AlertConfiguration) error +} + type RulerSrv struct { xactManager provisioning.TransactionManager provenanceStore provisioning.ProvisioningStore @@ -42,18 +53,25 @@ type RulerSrv struct { cfg *setting.UnifiedAlertingSettings conditionValidator ConditionValidator authz RuleAccessControlService + + amConfigStore AMConfigStore + amRefresher AMRefresher + featureManager featuremgmt.FeatureToggles } var ( errProvisionedResource = errors.New("request affects resources created via provisioning API") ) +// ignore fields that are not part of the rule definition +var ignoreFieldsForValidate = [...]string{"RuleGroupIndex"} + // RouteDeleteAlertRules deletes all alert rules the user is authorized to access in the given namespace // or, if non-empty, a specific group of rules in the namespace. // Returns http.StatusForbidden if user does not have access to any of the rules that match the filter. // Returns http.StatusBadRequest if all rules that match the filter and the user is authorized to delete are provisioned. -func (srv RulerSrv) RouteDeleteAlertRules(c *contextmodel.ReqContext, namespaceTitle string, group string) response.Response { - namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser) +func (srv RulerSrv) RouteDeleteAlertRules(c *contextmodel.ReqContext, namespaceUID string, group string) response.Response { + namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser) if err != nil { return toNamespaceErrorResponse(err) } @@ -144,8 +162,8 @@ func (srv RulerSrv) RouteDeleteAlertRules(c *contextmodel.ReqContext, namespaceT } // RouteGetNamespaceRulesConfig returns all rules in a specific folder that user has access to -func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *contextmodel.ReqContext, namespaceTitle string) response.Response { - namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser) +func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *contextmodel.ReqContext, namespaceUID string) response.Response { + namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser) if err != nil { return toNamespaceErrorResponse(err) } @@ -162,8 +180,7 @@ func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *contextmodel.ReqContext, nam result := apimodels.NamespaceConfigResponse{} for groupKey, rules := range ruleGroups { - // nolint:staticcheck - result[namespaceTitle] = append(result[namespaceTitle], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords)) + result[namespace.Fullpath] = append(result[namespace.Fullpath], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords)) } return response.JSON(http.StatusAccepted, result) @@ -171,8 +188,8 @@ func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *contextmodel.ReqContext, nam // RouteGetRulesGroupConfig returns rules that belong to a specific group in a specific namespace (folder). // If user does not have access to at least one of the rule in the group, returns status 403 Forbidden -func (srv RulerSrv) RouteGetRulesGroupConfig(c *contextmodel.ReqContext, namespaceTitle string, ruleGroup string) response.Response { - namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser) +func (srv RulerSrv) RouteGetRulesGroupConfig(c *contextmodel.ReqContext, namespaceUID string, ruleGroup string) response.Response { + namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser) if err != nil { return toNamespaceErrorResponse(err) } @@ -241,20 +258,22 @@ func (srv RulerSrv) RouteGetRulesConfig(c *contextmodel.ReqContext) response.Res srv.log.Error("Namespace not visible to the user", "user", id, "userNamespace", userNamespace, "namespace", groupKey.NamespaceUID) continue } - namespace := folder.Title - // nolint:staticcheck - result[namespace] = append(result[namespace], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords)) + result[folder.Fullpath] = append(result[folder.Fullpath], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords)) } return response.JSON(http.StatusOK, result) } -func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceTitle string) response.Response { - namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser) +func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceUID string) response.Response { + namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser) if err != nil { return toNamespaceErrorResponse(err) } - rules, err := validateRuleGroup(&ruleGroupConfig, c.SignedInUser.GetOrgID(), namespace, srv.cfg) + if err := srv.checkGroupLimits(ruleGroupConfig); err != nil { + return ErrResp(http.StatusBadRequest, err, "") + } + + rules, err := ValidateRuleGroup(&ruleGroupConfig, c.SignedInUser.GetOrgID(), namespace.UID, RuleLimitsFromConfig(srv.cfg)) if err != nil { return ErrResp(http.StatusBadRequest, err, "") } @@ -268,10 +287,25 @@ func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGro return srv.updateAlertRulesInGroup(c, groupKey, rules) } +func (srv RulerSrv) checkGroupLimits(group apimodels.PostableRuleGroupConfig) error { + if srv.cfg.RulesPerRuleGroupLimit > 0 && int64(len(group.Rules)) > srv.cfg.RulesPerRuleGroupLimit { + srv.log.Warn("Large rule group was edited. Large groups are discouraged and may be rejected in the future.", + "limit", srv.cfg.RulesPerRuleGroupLimit, + "actual", len(group.Rules), + "group", group.Name, + ) + } + + return nil +} + // updateAlertRulesInGroup calculates changes (rules to add,update,delete), verifies that the user is authorized to do the calculated changes and updates database. // All operations are performed in a single transaction +// +//nolint:gocyclo func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey ngmodels.AlertRuleGroupKey, rules []*ngmodels.AlertRuleWithOptionals) response.Response { var finalChanges *store.GroupDelta + var dbConfig *ngmodels.AlertConfiguration err := srv.xactManager.InTransaction(c.Req.Context(), func(tranCtx context.Context) error { userNamespace, id := c.SignedInUser.GetNamespacedID() logger := srv.log.New("namespace_uid", groupKey.NamespaceUID, "group", @@ -296,6 +330,24 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey return err } + newOrUpdatedNotificationSettings := groupChanges.NewOrUpdatedNotificationSettings() + if len(newOrUpdatedNotificationSettings) > 0 { + dbConfig, err = srv.amConfigStore.GetLatestAlertmanagerConfiguration(c.Req.Context(), groupChanges.GroupKey.OrgID) + if err != nil { + return fmt.Errorf("failed to get latest configuration: %w", err) + } + cfg, err := notifier.Load([]byte(dbConfig.AlertmanagerConfiguration)) + if err != nil { + return fmt.Errorf("failed to parse configuration: %w", err) + } + validator := notifier.NewNotificationSettingsValidator(&cfg.AlertmanagerConfig) + for _, s := range newOrUpdatedNotificationSettings { + if err := validator.Validate(s); err != nil { + return errors.Join(ngmodels.ErrAlertRuleFailedValidation, err) + } + } + } + if err := verifyProvisionedRulesNotAffected(c.Req.Context(), srv.provenanceStore, c.SignedInUser.GetOrgID(), groupChanges); err != nil { return err } @@ -379,6 +431,15 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey } return ErrResp(http.StatusInternalServerError, err, "failed to update rule group") } + + if srv.featureManager.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingSimplifiedRouting) && dbConfig != nil { + // This isn't strictly necessary since the alertmanager config is periodically synced. + err := srv.amRefresher.ApplyConfig(c.Req.Context(), groupKey.OrgID, dbConfig) + if err != nil { + srv.log.Warn("Failed to refresh Alertmanager config for org after change in notification settings", "org", c.SignedInUser.GetOrgID(), "error", err) + } + } + return changesToResponse(finalChanges) } @@ -427,23 +488,25 @@ func toGettableExtendedRuleNode(r ngmodels.AlertRule, provenanceRecords map[stri if prov, exists := provenanceRecords[r.ResourceID()]; exists { provenance = prov } + gettableExtendedRuleNode := apimodels.GettableExtendedRuleNode{ GrafanaManagedAlert: &apimodels.GettableGrafanaRule{ - ID: r.ID, - OrgID: r.OrgID, - Title: r.Title, - Condition: r.Condition, - Data: ApiAlertQueriesFromAlertQueries(r.Data), - Updated: r.Updated, - IntervalSeconds: r.IntervalSeconds, - Version: r.Version, - UID: r.UID, - NamespaceUID: r.NamespaceUID, - RuleGroup: r.RuleGroup, - NoDataState: apimodels.NoDataState(r.NoDataState), - ExecErrState: apimodels.ExecutionErrorState(r.ExecErrState), - Provenance: apimodels.Provenance(provenance), - IsPaused: r.IsPaused, + ID: r.ID, + OrgID: r.OrgID, + Title: r.Title, + Condition: r.Condition, + Data: ApiAlertQueriesFromAlertQueries(r.Data), + Updated: r.Updated, + IntervalSeconds: r.IntervalSeconds, + Version: r.Version, + UID: r.UID, + NamespaceUID: r.NamespaceUID, + RuleGroup: r.RuleGroup, + NoDataState: apimodels.NoDataState(r.NoDataState), + ExecErrState: apimodels.ExecutionErrorState(r.ExecErrState), + Provenance: apimodels.Provenance(provenance), + IsPaused: r.IsPaused, + NotificationSettings: AlertRuleNotificationSettingsFromNotificationSettings(r.NotificationSettings), }, } forDuration := model.Duration(r.For) @@ -499,6 +562,9 @@ func validateQueries(ctx context.Context, groupChanges *store.GroupDelta, valida } if len(groupChanges.Update) > 0 { for _, upd := range groupChanges.Update { + if !shouldValidate(upd) { + continue + } err := validator.Validate(eval.NewContext(ctx, user), upd.New.GetEvalCondition()) if err != nil { return fmt.Errorf("%w '%s' (UID: %s): %s", ngmodels.ErrAlertRuleFailedValidation, upd.New.Title, upd.New.UID, err.Error()) @@ -508,6 +574,18 @@ func validateQueries(ctx context.Context, groupChanges *store.GroupDelta, valida return nil } +// shouldValidate returns true if the rule is not paused and there are changes in the rule that are not ignored +func shouldValidate(delta store.RuleDelta) bool { + for _, diff := range delta.Diff { + if !slices.Contains(ignoreFieldsForValidate[:], diff.Path) { + return true + } + } + + // TODO: consider also checking if rule will be paused after the update + return false +} + // getAuthorizedRuleByUid fetches all rules in group to which the specified rule belongs, and checks whether the user is authorized to access the group. // A user is authorized to access a group of rules only when it has permission to query all data sources used by all rules in this group. // Returns rule identified by provided UID or ErrAuthorization if user is not authorized to access the rule. diff --git a/pkg/services/ngalert/api/api_ruler_export.go b/pkg/services/ngalert/api/api_ruler_export.go index 2f030d89a8710..f925a4d4a8afc 100644 --- a/pkg/services/ngalert/api/api_ruler_export.go +++ b/pkg/services/ngalert/api/api_ruler_export.go @@ -13,14 +13,14 @@ import ( ) // ExportFromPayload converts the rule groups from the argument `ruleGroupConfig` to export format. All rules are expected to be fully specified. The access to data sources mentioned in the rules is not enforced. -// Can return 403 StatusForbidden if user is not authorized to read folder `namespaceTitle` -func (srv RulerSrv) ExportFromPayload(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceTitle string) response.Response { - namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser) +// Can return 403 StatusForbidden if user is not authorized to read folder `namespaceUID` +func (srv RulerSrv) ExportFromPayload(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceUID string) response.Response { + namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser) if err != nil { return toNamespaceErrorResponse(err) } - rulesWithOptionals, err := validateRuleGroup(&ruleGroupConfig, c.SignedInUser.GetOrgID(), namespace, srv.cfg) + rulesWithOptionals, err := ValidateRuleGroup(&ruleGroupConfig, c.SignedInUser.GetOrgID(), namespace.UID, RuleLimitsFromConfig(srv.cfg)) if err != nil { return ErrResp(http.StatusBadRequest, err, "") } diff --git a/pkg/services/ngalert/api/api_ruler_export_test.go b/pkg/services/ngalert/api/api_ruler_export_test.go index bce90fe191530..73fc33dadfb5e 100644 --- a/pkg/services/ngalert/api/api_ruler_export_test.go +++ b/pkg/services/ngalert/api/api_ruler_export_test.go @@ -52,7 +52,7 @@ func TestExportFromPayload(t *testing.T) { rc := createRequest() rc.Context.Req.Header.Add("Accept", "application/yaml") - response := srv.ExportFromPayload(rc, body, folder.Title) + response := srv.ExportFromPayload(rc, body, folder.UID) response.WriteTo(rc) @@ -64,7 +64,7 @@ func TestExportFromPayload(t *testing.T) { rc := createRequest() rc.Context.Req.Form.Set("format", "yaml") - response := srv.ExportFromPayload(rc, body, folder.Title) + response := srv.ExportFromPayload(rc, body, folder.UID) response.WriteTo(rc) require.Equal(t, 200, response.Status()) @@ -75,7 +75,7 @@ func TestExportFromPayload(t *testing.T) { rc := createRequest() rc.Context.Req.Form.Set("format", "foo") - response := srv.ExportFromPayload(rc, body, folder.Title) + response := srv.ExportFromPayload(rc, body, folder.UID) response.WriteTo(rc) require.Equal(t, 200, response.Status()) @@ -86,7 +86,7 @@ func TestExportFromPayload(t *testing.T) { rc := createRequest() rc.Context.Req.Header.Add("Accept", "application/json") - response := srv.ExportFromPayload(rc, body, folder.Title) + response := srv.ExportFromPayload(rc, body, folder.UID) response.WriteTo(rc) require.Equal(t, 200, response.Status()) @@ -97,7 +97,7 @@ func TestExportFromPayload(t *testing.T) { rc := createRequest() rc.Context.Req.Header.Add("Accept", "application/json, application/yaml") - response := srv.ExportFromPayload(rc, body, folder.Title) + response := srv.ExportFromPayload(rc, body, folder.UID) response.WriteTo(rc) require.Equal(t, 200, response.Status()) @@ -108,7 +108,7 @@ func TestExportFromPayload(t *testing.T) { rc := createRequest() rc.Context.Req.Form.Set("download", "true") - response := srv.ExportFromPayload(rc, body, folder.Title) + response := srv.ExportFromPayload(rc, body, folder.UID) response.WriteTo(rc) require.Equal(t, 200, response.Status()) @@ -119,7 +119,7 @@ func TestExportFromPayload(t *testing.T) { rc := createRequest() rc.Context.Req.Form.Set("download", "false") - response := srv.ExportFromPayload(rc, body, folder.Title) + response := srv.ExportFromPayload(rc, body, folder.UID) response.WriteTo(rc) require.Equal(t, 200, response.Status()) @@ -129,7 +129,7 @@ func TestExportFromPayload(t *testing.T) { t.Run("query param download not set, GET returns empty content disposition", func(t *testing.T) { rc := createRequest() - response := srv.ExportFromPayload(rc, body, folder.Title) + response := srv.ExportFromPayload(rc, body, folder.UID) response.WriteTo(rc) require.Equal(t, 200, response.Status()) @@ -143,7 +143,7 @@ func TestExportFromPayload(t *testing.T) { rc := createRequest() rc.Context.Req.Header.Add("Accept", "application/json") - response := srv.ExportFromPayload(rc, body, folder.Title) + response := srv.ExportFromPayload(rc, body, folder.UID) response.WriteTo(rc) t.Log(string(response.Body())) @@ -158,7 +158,7 @@ func TestExportFromPayload(t *testing.T) { rc := createRequest() rc.Context.Req.Header.Add("Accept", "application/yaml") - response := srv.ExportFromPayload(rc, body, folder.Title) + response := srv.ExportFromPayload(rc, body, folder.UID) response.WriteTo(rc) require.Equal(t, 200, response.Status()) require.Equal(t, string(expectedResponse), string(response.Body())) @@ -172,7 +172,7 @@ func TestExportFromPayload(t *testing.T) { rc.Context.Req.Form.Set("format", "hcl") rc.Context.Req.Form.Set("download", "false") - response := srv.ExportFromPayload(rc, body, folder.Title) + response := srv.ExportFromPayload(rc, body, folder.UID) response.WriteTo(rc) require.Equal(t, 200, response.Status()) @@ -184,7 +184,7 @@ func TestExportFromPayload(t *testing.T) { rc.Context.Req.Form.Set("format", "hcl") rc.Context.Req.Form.Set("download", "true") - response := srv.ExportFromPayload(rc, body, folder.Title) + response := srv.ExportFromPayload(rc, body, folder.UID) response.WriteTo(rc) require.Equal(t, 200, response.Status()) diff --git a/pkg/services/ngalert/api/api_ruler_test.go b/pkg/services/ngalert/api/api_ruler_test.go index 2b133ebb227f5..7069a20e4acde 100644 --- a/pkg/services/ngalert/api/api_ruler_test.go +++ b/pkg/services/ngalert/api/api_ruler_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "slices" "testing" "time" @@ -20,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" @@ -30,6 +32,7 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/util/cmputil" "github.com/grafana/grafana/pkg/web" ) @@ -79,14 +82,14 @@ func TestRouteDeleteAlertRules(t *testing.T) { request := createRequestContextWithPerms(orgID, map[int64]map[string][]string{}, nil) - response := createService(ruleStore).RouteDeleteAlertRules(request, folder.Title, "") + response := createService(ruleStore).RouteDeleteAlertRules(request, folder.UID, "") require.Equalf(t, http.StatusForbidden, response.Status(), "Expected 403 but got %d: %v", response.Status(), string(response.Body())) require.Empty(t, getRecordedCommand(ruleStore)) }) t.Run("delete only non-provisioned groups that user is authorized", func(t *testing.T) { ruleStore := initFakeRuleStore(t) - provisioningStore := provisioning.NewFakeProvisioningStore() + provisioningStore := fakes.NewFakeProvisioningStore() authorizedRulesInFolder := models.GenerateAlertRulesSmallNonEmpty(models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup("authz_"+util.GenerateShortUID()))) @@ -102,14 +105,14 @@ func TestRouteDeleteAlertRules(t *testing.T) { permissions := createPermissionsForRules(append(authorizedRulesInFolder, provisionedRulesInFolder...), orgID) requestCtx := createRequestContextWithPerms(orgID, permissions, nil) - response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.Title, "") + response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.UID, "") require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) assertRulesDeleted(t, authorizedRulesInFolder, ruleStore) }) t.Run("return 400 if all rules user can access are provisioned", func(t *testing.T) { ruleStore := initFakeRuleStore(t) - provisioningStore := provisioning.NewFakeProvisioningStore() + provisioningStore := fakes.NewFakeProvisioningStore() provisionedRulesInFolder := models.GenerateAlertRulesSmallNonEmpty(models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup(util.GenerateShortUID()))) err := provisioningStore.SetProvenance(context.Background(), provisionedRulesInFolder[0], orgID, models.ProvenanceAPI) @@ -122,7 +125,7 @@ func TestRouteDeleteAlertRules(t *testing.T) { permissions := createPermissionsForRules(provisionedRulesInFolder, orgID) requestCtx := createRequestContextWithPerms(orgID, permissions, nil) - response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.Title, "") + response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.UID, "") require.Equalf(t, 400, response.Status(), "Expected 400 but got %d: %v", response.Status(), string(response.Body())) require.Empty(t, getRecordedCommand(ruleStore)) @@ -131,7 +134,7 @@ func TestRouteDeleteAlertRules(t *testing.T) { ruleStore := initFakeRuleStore(t) requestCtx := createRequestContext(orgID, nil) - response := createService(ruleStore).RouteDeleteAlertRules(requestCtx, folder.Title, "") + response := createService(ruleStore).RouteDeleteAlertRules(requestCtx, folder.UID, "") require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) require.Empty(t, getRecordedCommand(ruleStore)) @@ -150,7 +153,7 @@ func TestRouteDeleteAlertRules(t *testing.T) { permissions := createPermissionsForRules(authorizedRulesInGroup, orgID) requestCtx := createRequestContextWithPerms(orgID, permissions, nil) - response := createService(ruleStore).RouteDeleteAlertRules(requestCtx, folder.Title, groupName) + response := createService(ruleStore).RouteDeleteAlertRules(requestCtx, folder.UID, groupName) require.Equalf(t, http.StatusForbidden, response.Status(), "Expected 403 but got %d: %v", response.Status(), string(response.Body())) deleteCommands := getRecordedCommand(ruleStore) @@ -158,7 +161,7 @@ func TestRouteDeleteAlertRules(t *testing.T) { }) t.Run("return 400 if group is provisioned", func(t *testing.T) { ruleStore := initFakeRuleStore(t) - provisioningStore := provisioning.NewFakeProvisioningStore() + provisioningStore := fakes.NewFakeProvisioningStore() provisionedRulesInFolder := models.GenerateAlertRulesSmallNonEmpty(models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup(groupName))) err := provisioningStore.SetProvenance(context.Background(), provisionedRulesInFolder[0], orgID, models.ProvenanceAPI) @@ -169,7 +172,7 @@ func TestRouteDeleteAlertRules(t *testing.T) { permissions := createPermissionsForRules(provisionedRulesInFolder, orgID) requestCtx := createRequestContextWithPerms(orgID, permissions, nil) - response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.Title, groupName) + response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.UID, groupName) require.Equalf(t, 400, response.Status(), "Expected 400 but got %d: %v", response.Status(), string(response.Body())) deleteCommands := getRecordedCommand(ruleStore) @@ -193,14 +196,14 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) { permissions := createPermissionsForRules(expectedRules, orgID) req := createRequestContextWithPerms(orgID, permissions, nil) - response := createService(ruleStore).RouteGetNamespaceRulesConfig(req, folder.Title) + response := createService(ruleStore).RouteGetNamespaceRulesConfig(req, folder.UID) require.Equal(t, http.StatusAccepted, response.Status()) result := &apimodels.NamespaceConfigResponse{} require.NoError(t, json.Unmarshal(response.Body(), result)) require.NotNil(t, result) for namespace, groups := range *result { - require.Equal(t, folder.Title, namespace) + require.Equal(t, folder.Fullpath, namespace) for _, group := range groups { grouploop: for _, actualRule := range group.Rules { @@ -235,7 +238,7 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) { require.NoError(t, err) req := createRequestContext(orgID, nil) - response := svc.RouteGetNamespaceRulesConfig(req, folder.Title) + response := svc.RouteGetNamespaceRulesConfig(req, folder.UID) require.Equal(t, http.StatusAccepted, response.Status()) result := &apimodels.NamespaceConfigResponse{} @@ -243,7 +246,7 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) { require.NotNil(t, result) found := false for namespace, groups := range *result { - require.Equal(t, folder.Title, namespace) + require.Equal(t, folder.Fullpath, namespace) for _, group := range groups { for _, actualRule := range group.Rules { if actualRule.GrafanaManagedAlert.UID == expectedRules[0].UID { @@ -269,7 +272,7 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) { ruleStore.PutRule(context.Background(), expectedRules...) req := createRequestContext(orgID, nil) - response := createService(ruleStore).RouteGetNamespaceRulesConfig(req, folder.Title) + response := createService(ruleStore).RouteGetNamespaceRulesConfig(req, folder.UID) require.Equal(t, http.StatusAccepted, response.Status()) result := &apimodels.NamespaceConfigResponse{} @@ -278,8 +281,8 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) { models.RulesGroup(expectedRules).SortByGroupIndex() - require.Contains(t, *result, folder.Title) - groups := (*result)[folder.Title] + groups, ok := (*result)[folder.Fullpath] + require.True(t, ok) require.Len(t, groups, 1) group := groups[0] require.Equal(t, groupKey.RuleGroup, group.Name) @@ -329,10 +332,10 @@ func TestRouteGetRulesConfig(t *testing.T) { require.NoError(t, json.Unmarshal(response.Body(), result)) require.NotNil(t, result) - require.Contains(t, *result, folder1.Title) - require.NotContains(t, *result, folder2.Title) + require.Contains(t, *result, folder1.Fullpath) + require.NotContains(t, *result, folder2.UID) - groups := (*result)[folder1.Title] + groups := (*result)[folder1.Fullpath] require.Len(t, groups, 1) require.Equal(t, group1Key.RuleGroup, groups[0].Name) require.Len(t, groups[0].Rules, len(group1)) @@ -361,8 +364,8 @@ func TestRouteGetRulesConfig(t *testing.T) { models.RulesGroup(expectedRules).SortByGroupIndex() - require.Contains(t, *result, folder.Title) - groups := (*result)[folder.Title] + groups, ok := (*result)[folder.Fullpath] + require.True(t, ok) require.Len(t, groups, 1) group := groups[0] require.Equal(t, groupKey.RuleGroup, group.Name) @@ -399,20 +402,20 @@ func TestRouteGetRulesGroupConfig(t *testing.T) { t.Run("and return Forbidden if user does not have access one of rules", func(t *testing.T) { permissions := createPermissionsForRules(expectedRules[1:], orgID) request := createRequestContextWithPerms(orgID, permissions, map[string]string{ - ":Namespace": folder.Title, + ":Namespace": folder.UID, ":Groupname": groupKey.RuleGroup, }) - response := createService(ruleStore).RouteGetRulesGroupConfig(request, folder.Title, groupKey.RuleGroup) + response := createService(ruleStore).RouteGetRulesGroupConfig(request, folder.UID, groupKey.RuleGroup) require.Equal(t, http.StatusForbidden, response.Status()) }) t.Run("and return rules if user has access to all of them", func(t *testing.T) { permissions := createPermissionsForRules(expectedRules, orgID) request := createRequestContextWithPerms(orgID, permissions, map[string]string{ - ":Namespace": folder.Title, + ":Namespace": folder.UID, ":Groupname": groupKey.RuleGroup, }) - response := createService(ruleStore).RouteGetRulesGroupConfig(request, folder.Title, groupKey.RuleGroup) + response := createService(ruleStore).RouteGetRulesGroupConfig(request, folder.UID, groupKey.RuleGroup) require.Equal(t, http.StatusAccepted, response.Status()) result := &apimodels.RuleGroupConfigResponse{} @@ -435,7 +438,7 @@ func TestRouteGetRulesGroupConfig(t *testing.T) { ruleStore.PutRule(context.Background(), expectedRules...) req := createRequestContext(orgID, nil) - response := createService(ruleStore).RouteGetRulesGroupConfig(req, folder.Title, groupKey.RuleGroup) + response := createService(ruleStore).RouteGetRulesGroupConfig(req, folder.UID, groupKey.RuleGroup) require.Equal(t, http.StatusAccepted, response.Status()) result := &apimodels.RuleGroupConfigResponse{} @@ -537,7 +540,24 @@ func TestValidateQueries(t *testing.T) { New: models.AlertRuleGen(func(rule *models.AlertRule) { rule.Condition = "Update_New" })(), - Diff: nil, + Diff: cmputil.DiffReport{ + cmputil.Diff{ + Path: "SomeField", + }, + }, + }, + { + Existing: models.AlertRuleGen(func(rule *models.AlertRule) { + rule.Condition = "Update_Index_Existing" + })(), + New: models.AlertRuleGen(func(rule *models.AlertRule) { + rule.Condition = "Update_Index_New" + })(), + Diff: cmputil.DiffReport{ + cmputil.Diff{ + Path: "RuleGroupIndex", + }, + }, }, }, Delete: []*models.AlertRule{ @@ -547,12 +567,13 @@ func TestValidateQueries(t *testing.T) { }, } - t.Run("should validate New and Updated only", func(t *testing.T) { + t.Run("should not validate deleted rules or updated rules with ignored fields", func(t *testing.T) { validator := &recordingConditionValidator{} err := validateQueries(context.Background(), &delta, validator, nil) require.NoError(t, err) + noValidate := []string{"Deleted", "Update_Index_New"} for _, condition := range validator.recorded { - if condition.Condition == "New" || condition.Condition == "Update_New" { + if !slices.Contains(noValidate, condition.Condition) { continue } assert.Failf(t, "validated unexpected condition", "condition '%s' was validated but should not", condition.Condition) @@ -598,15 +619,29 @@ func createService(store *fakes.RuleStore) *RulerSrv { xactManager: store, store: store, QuotaService: nil, - provenanceStore: provisioning.NewFakeProvisioningStore(), + provenanceStore: fakes.NewFakeProvisioningStore(), log: log.New("test"), cfg: &setting.UnifiedAlertingSettings{ BaseInterval: 10 * time.Second, }, - authz: accesscontrol.NewRuleService(acimpl.ProvideAccessControl(setting.NewCfg())), + authz: accesscontrol.NewRuleService(acimpl.ProvideAccessControl(setting.NewCfg())), + amConfigStore: &fakeAMRefresher{}, + amRefresher: &fakeAMRefresher{}, + featureManager: &featuremgmt.FeatureManager{}, } } +type fakeAMRefresher struct { +} + +func (f *fakeAMRefresher) ApplyConfig(ctx context.Context, orgId int64, dbConfig *models.AlertConfiguration) error { + return nil +} + +func (f *fakeAMRefresher) GetLatestAlertmanagerConfiguration(ctx context.Context, orgID int64) (*models.AlertConfiguration, error) { + return nil, nil +} + func createRequestContext(orgID int64, params map[string]string) *contextmodel.ReqContext { defaultPerms := map[int64]map[string][]string{orgID: {datasources.ActionQuery: []string{datasources.ScopeAll}}} return createRequestContextWithPerms(orgID, defaultPerms, params) diff --git a/pkg/services/ngalert/api/api_ruler_validation.go b/pkg/services/ngalert/api/api_ruler_validation.go index 583a39718bcfb..91a012b0b1e04 100644 --- a/pkg/services/ngalert/api/api_ruler_validation.go +++ b/pkg/services/ngalert/api/api_ruler_validation.go @@ -7,22 +7,35 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/services/folder" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/setting" ) +type RuleLimits struct { + // The default interval if not specified. + DefaultRuleEvaluationInterval time.Duration + // All intervals must be an integer multiple of this duration. + BaseInterval time.Duration +} + +func RuleLimitsFromConfig(cfg *setting.UnifiedAlertingSettings) RuleLimits { + return RuleLimits{ + DefaultRuleEvaluationInterval: cfg.DefaultRuleEvaluationInterval, + BaseInterval: cfg.BaseInterval, + } +} + // validateRuleNode validates API model (definitions.PostableExtendedRuleNode) and converts it to models.AlertRule func validateRuleNode( ruleNode *apimodels.PostableExtendedRuleNode, groupName string, interval time.Duration, orgId int64, - namespace *folder.Folder, - cfg *setting.UnifiedAlertingSettings) (*ngmodels.AlertRule, error) { - intervalSeconds, err := validateInterval(cfg, interval) + namespaceUID string, + limits RuleLimits) (*ngmodels.AlertRule, error) { + intervalSeconds, err := validateInterval(interval, limits.BaseInterval) if err != nil { return nil, err } @@ -91,12 +104,19 @@ func validateRuleNode( Data: queries, UID: ruleNode.GrafanaManagedAlert.UID, IntervalSeconds: intervalSeconds, - NamespaceUID: namespace.UID, + NamespaceUID: namespaceUID, RuleGroup: groupName, NoDataState: noDataState, ExecErrState: errorState, } + if ruleNode.GrafanaManagedAlert.NotificationSettings != nil { + newAlertRule.NotificationSettings, err = validateNotificationSettings(ruleNode.GrafanaManagedAlert.NotificationSettings) + if err != nil { + return nil, err + } + } + newAlertRule.For, err = validateForInterval(ruleNode) if err != nil { return nil, err @@ -104,6 +124,10 @@ func validateRuleNode( if ruleNode.ApiRuleNode != nil { newAlertRule.Annotations = ruleNode.ApiRuleNode.Annotations + err = validateLabels(ruleNode.Labels) + if err != nil { + return nil, err + } newAlertRule.Labels = ruleNode.ApiRuleNode.Labels err = newAlertRule.SetDashboardAndPanelFromAnnotations() @@ -114,6 +138,15 @@ func validateRuleNode( return &newAlertRule, nil } +func validateLabels(l map[string]string) error { + for key := range l { + if _, ok := ngmodels.LabelsUserCannotSpecify[key]; ok { + return fmt.Errorf("system reserved labels cannot be defined in the rule. Label %s is the reserved", key) + } + } + return nil +} + func validateCondition(condition string, queries []apimodels.AlertQuery) error { if condition == "" { return errors.New("condition cannot be empty") @@ -142,10 +175,10 @@ func validateCondition(condition string, queries []apimodels.AlertQuery) error { return nil } -func validateInterval(cfg *setting.UnifiedAlertingSettings, interval time.Duration) (int64, error) { +func validateInterval(interval, baseInterval time.Duration) (int64, error) { intervalSeconds := int64(interval.Seconds()) - baseIntervalSeconds := int64(cfg.BaseInterval.Seconds()) + baseIntervalSeconds := int64(baseInterval.Seconds()) if interval <= 0 { return 0, fmt.Errorf("rule evaluation interval must be positive duration that is multiple of the base interval %d seconds", baseIntervalSeconds) @@ -173,14 +206,14 @@ func validateForInterval(ruleNode *apimodels.PostableExtendedRuleNode) (time.Dur return duration, nil } -// validateRuleGroup validates API model (definitions.PostableRuleGroupConfig) and converts it to a collection of models.AlertRule. +// ValidateRuleGroup validates API model (definitions.PostableRuleGroupConfig) and converts it to a collection of models.AlertRule. // Returns a slice that contains all rules described by API model or error if either group specification or an alert definition is not valid. // It also returns a map containing current existing alerts that don't contain the is_paused field in the body of the call. -func validateRuleGroup( +func ValidateRuleGroup( ruleGroupConfig *apimodels.PostableRuleGroupConfig, orgId int64, - namespace *folder.Folder, - cfg *setting.UnifiedAlertingSettings) ([]*ngmodels.AlertRuleWithOptionals, error) { + namespaceUID string, + limits RuleLimits) ([]*ngmodels.AlertRuleWithOptionals, error) { if ruleGroupConfig.Name == "" { return nil, errors.New("rule group name cannot be empty") } @@ -192,11 +225,11 @@ func validateRuleGroup( interval := time.Duration(ruleGroupConfig.Interval) if interval == 0 { // if group interval is 0 (undefined) then we automatically fall back to the default interval - interval = cfg.DefaultRuleEvaluationInterval + interval = limits.DefaultRuleEvaluationInterval } - if interval < 0 || int64(interval.Seconds())%int64(cfg.BaseInterval.Seconds()) != 0 { - return nil, fmt.Errorf("rule evaluation interval (%d second) should be positive number that is multiple of the base interval of %d seconds", int64(interval.Seconds()), int64(cfg.BaseInterval.Seconds())) + if interval < 0 || int64(interval.Seconds())%int64(limits.BaseInterval.Seconds()) != 0 { + return nil, fmt.Errorf("rule evaluation interval (%d second) should be positive number that is multiple of the base interval of %d seconds", int64(interval.Seconds()), int64(limits.BaseInterval.Seconds())) } // TODO should we validate that interval is >= cfg.MinInterval? Currently, we allow to save but fix the specified interval if it is < cfg.MinInterval @@ -204,7 +237,7 @@ func validateRuleGroup( result := make([]*ngmodels.AlertRuleWithOptionals, 0, len(ruleGroupConfig.Rules)) uids := make(map[string]int, cap(result)) for idx := range ruleGroupConfig.Rules { - rule, err := validateRuleNode(&ruleGroupConfig.Rules[idx], ruleGroupConfig.Name, interval, orgId, namespace, cfg) + rule, err := validateRuleNode(&ruleGroupConfig.Rules[idx], ruleGroupConfig.Name, interval, orgId, namespaceUID, limits) // TODO do not stop on the first failure but return all failures if err != nil { return nil, fmt.Errorf("invalid rule specification at index [%d]: %w", idx, err) @@ -235,3 +268,21 @@ func validateRuleGroup( } return result, nil } + +func validateNotificationSettings(n *apimodels.AlertRuleNotificationSettings) ([]ngmodels.NotificationSettings, error) { + s := ngmodels.NotificationSettings{ + Receiver: n.Receiver, + GroupBy: n.GroupBy, + GroupWait: n.GroupWait, + GroupInterval: n.GroupInterval, + RepeatInterval: n.RepeatInterval, + MuteTimeIntervals: n.MuteTimeIntervals, + } + + if err := s.Validate(); err != nil { + return nil, fmt.Errorf("invalid notification settings: %w", err) + } + return []ngmodels.NotificationSettings{ + s, + }, nil +} diff --git a/pkg/services/ngalert/api/api_ruler_validation_test.go b/pkg/services/ngalert/api/api_ruler_validation_test.go index 5c7a1fefa60f2..0530e2378e4d5 100644 --- a/pkg/services/ngalert/api/api_ruler_validation_test.go +++ b/pkg/services/ngalert/api/api_ruler_validation_test.go @@ -2,6 +2,7 @@ package api import ( "fmt" + "path" "strconv" "testing" "time" @@ -84,10 +85,10 @@ func validGroup(cfg *setting.UnifiedAlertingSettings, rules ...apimodels.Postabl } func randFolder() *folder.Folder { + title := "TEST-FOLDER-" + util.GenerateShortUID() return &folder.Folder{ - ID: rand.Int63(), // nolint:staticcheck UID: util.GenerateShortUID(), - Title: "TEST-FOLDER-" + util.GenerateShortUID(), + Title: title, // URL: "", // Version: 0, Created: time.Time{}, @@ -95,6 +96,8 @@ func randFolder() *folder.Folder { // UpdatedBy: 0, // CreatedBy: 0, // HasACL: false, + ParentUID: uuid.NewString(), + Fullpath: path.Join("parent-folder", title), } } @@ -194,7 +197,7 @@ func TestValidateRuleGroup(t *testing.T) { t.Run("should validate struct and rules", func(t *testing.T) { g := validGroup(cfg, rules...) - alerts, err := validateRuleGroup(&g, orgId, folder, cfg) + alerts, err := ValidateRuleGroup(&g, orgId, folder.UID, RuleLimitsFromConfig(cfg)) require.NoError(t, err) require.Len(t, alerts, len(rules)) }) @@ -202,7 +205,7 @@ func TestValidateRuleGroup(t *testing.T) { t.Run("should default to default interval from config if group interval is 0", func(t *testing.T) { g := validGroup(cfg, rules...) g.Interval = 0 - alerts, err := validateRuleGroup(&g, orgId, folder, cfg) + alerts, err := ValidateRuleGroup(&g, orgId, folder.UID, RuleLimitsFromConfig(cfg)) require.NoError(t, err) for _, alert := range alerts { require.Equal(t, int64(cfg.DefaultRuleEvaluationInterval.Seconds()), alert.IntervalSeconds) @@ -217,7 +220,7 @@ func TestValidateRuleGroup(t *testing.T) { isPaused = !(isPaused) } g := validGroup(cfg, rules...) - alerts, err := validateRuleGroup(&g, orgId, folder, cfg) + alerts, err := ValidateRuleGroup(&g, orgId, folder.UID, RuleLimitsFromConfig(cfg)) require.NoError(t, err) for _, alert := range alerts { require.True(t, alert.HasPause) @@ -289,7 +292,7 @@ func TestValidateRuleGroupFailures(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { g := testCase.group() - _, err := validateRuleGroup(g, orgId, folder, cfg) + _, err := ValidateRuleGroup(g, orgId, folder.UID, RuleLimitsFromConfig(cfg)) require.Error(t, err) if testCase.assert != nil { testCase.assert(t, g, err) @@ -396,7 +399,7 @@ func TestValidateRuleNode_NoUID(t *testing.T) { r := testCase.rule() r.GrafanaManagedAlert.UID = "" - alert, err := validateRuleNode(r, name, interval, orgId, folder, cfg) + alert, err := validateRuleNode(r, name, interval, orgId, folder.UID, RuleLimitsFromConfig(cfg)) require.NoError(t, err) testCase.assert(t, r, alert) }) @@ -404,7 +407,7 @@ func TestValidateRuleNode_NoUID(t *testing.T) { t.Run("accepts empty group name", func(t *testing.T) { r := validRule() - alert, err := validateRuleNode(&r, "", interval, orgId, folder, cfg) + alert, err := validateRuleNode(&r, "", interval, orgId, folder.UID, RuleLimitsFromConfig(cfg)) require.NoError(t, err) require.Equal(t, "", alert.RuleGroup) }) @@ -557,7 +560,7 @@ func TestValidateRuleNodeFailures_NoUID(t *testing.T) { interval = *testCase.interval } - _, err := validateRuleNode(r, "", interval, orgId, folder, cfg) + _, err := validateRuleNode(r, "", interval, orgId, folder.UID, RuleLimitsFromConfig(cfg)) require.Error(t, err) if testCase.assert != nil { testCase.assert(t, r, err) @@ -649,7 +652,7 @@ func TestValidateRuleNode_UID(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { r := testCase.rule() - alert, err := validateRuleNode(r, name, interval, orgId, folder, cfg) + alert, err := validateRuleNode(r, name, interval, orgId, folder.UID, RuleLimitsFromConfig(cfg)) require.NoError(t, err) testCase.assert(t, r, alert) }) @@ -657,7 +660,7 @@ func TestValidateRuleNode_UID(t *testing.T) { t.Run("accepts empty group name", func(t *testing.T) { r := validRule() - alert, err := validateRuleNode(&r, "", interval, orgId, folder, cfg) + alert, err := validateRuleNode(&r, "", interval, orgId, folder.UID, RuleLimitsFromConfig(cfg)) require.NoError(t, err) require.Equal(t, "", alert.RuleGroup) }) @@ -752,7 +755,7 @@ func TestValidateRuleNodeFailures_UID(t *testing.T) { interval = *testCase.interval } - _, err := validateRuleNode(r, "", interval, orgId, folder, cfg) + _, err := validateRuleNode(r, "", interval, orgId, folder.UID, RuleLimitsFromConfig(cfg)) require.Error(t, err) if testCase.assert != nil { testCase.assert(t, r, err) @@ -785,8 +788,122 @@ func TestValidateRuleNodeIntervalFailures(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { r := validRule() - _, err := validateRuleNode(&r, util.GenerateShortUID(), testCase.interval, rand.Int63(), randFolder(), cfg) + _, err := validateRuleNode(&r, util.GenerateShortUID(), testCase.interval, rand.Int63(), randFolder().UID, RuleLimitsFromConfig(cfg)) require.Error(t, err) }) } } + +func TestValidateRuleNodeNotificationSettings(t *testing.T) { + cfg := config(t) + + validNotificationSettings := models.NotificationSettingsGen(models.NSMuts.WithGroupBy(model.AlertNameLabel, models.FolderTitleLabel)) + + testCases := []struct { + name string + notificationSettings models.NotificationSettings + expErrorContains string + }{ + { + name: "valid notification settings", + notificationSettings: validNotificationSettings(), + }, + { + name: "missing receiver is invalid", + notificationSettings: models.CopyNotificationSettings(validNotificationSettings(), models.NSMuts.WithReceiver("")), + expErrorContains: "receiver", + }, + { + name: "group by empty is valid", + notificationSettings: models.CopyNotificationSettings(validNotificationSettings(), models.NSMuts.WithGroupBy()), + }, + { + name: "group by ... is valid", + notificationSettings: models.CopyNotificationSettings(validNotificationSettings(), models.NSMuts.WithGroupBy("...")), + }, + { + name: "group by with alert name and folder name labels is valid", + notificationSettings: models.CopyNotificationSettings(validNotificationSettings(), models.NSMuts.WithGroupBy(model.AlertNameLabel, models.FolderTitleLabel)), + }, + { + name: "group by missing alert name label is invalid", + notificationSettings: models.CopyNotificationSettings(validNotificationSettings(), models.NSMuts.WithGroupBy(models.FolderTitleLabel)), + expErrorContains: model.AlertNameLabel, + }, + { + name: "group by missing folder name label is invalid", + notificationSettings: models.CopyNotificationSettings(validNotificationSettings(), models.NSMuts.WithGroupBy(model.AlertNameLabel)), + expErrorContains: models.FolderTitleLabel, + }, + { + name: "group wait empty is valid", + notificationSettings: models.CopyNotificationSettings(validNotificationSettings(), models.NSMuts.WithGroupWait(nil)), + }, + { + name: "group wait positive is valid", + notificationSettings: models.CopyNotificationSettings(validNotificationSettings(), models.NSMuts.WithGroupWait(util.Pointer(1*time.Second))), + }, + { + name: "group wait negative is invalid", + notificationSettings: models.CopyNotificationSettings(validNotificationSettings(), models.NSMuts.WithGroupWait(util.Pointer(-1*time.Second))), + expErrorContains: "group wait", + }, + { + name: "group interval empty is valid", + notificationSettings: models.CopyNotificationSettings(validNotificationSettings(), models.NSMuts.WithGroupInterval(nil)), + }, + { + name: "group interval positive is valid", + notificationSettings: models.CopyNotificationSettings(validNotificationSettings(), models.NSMuts.WithGroupInterval(util.Pointer(1*time.Second))), + }, + { + name: "group interval negative is invalid", + notificationSettings: models.CopyNotificationSettings(validNotificationSettings(), models.NSMuts.WithGroupInterval(util.Pointer(-1*time.Second))), + expErrorContains: "group interval", + }, + { + name: "repeat interval empty is valid", + notificationSettings: models.CopyNotificationSettings(validNotificationSettings(), models.NSMuts.WithRepeatInterval(nil)), + }, + { + name: "repeat interval positive is valid", + notificationSettings: models.CopyNotificationSettings(validNotificationSettings(), models.NSMuts.WithRepeatInterval(util.Pointer(1*time.Second))), + }, + { + name: "repeat interval negative is invalid", + notificationSettings: models.CopyNotificationSettings(validNotificationSettings(), models.NSMuts.WithRepeatInterval(util.Pointer(-1*time.Second))), + expErrorContains: "repeat interval", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + r := validRule() + r.GrafanaManagedAlert.NotificationSettings = AlertRuleNotificationSettingsFromNotificationSettings([]models.NotificationSettings{tt.notificationSettings}) + _, err := validateRuleNode(&r, util.GenerateShortUID(), cfg.BaseInterval*time.Duration(rand.Int63n(10)+1), rand.Int63(), randFolder().UID, RuleLimitsFromConfig(cfg)) + + if tt.expErrorContains != "" { + require.Error(t, err) + require.ErrorContains(t, err, tt.expErrorContains) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateRuleNodeReservedLabels(t *testing.T) { + cfg := config(t) + + for label := range models.LabelsUserCannotSpecify { + t.Run(label, func(t *testing.T) { + r := validRule() + r.ApiRuleNode.Labels = map[string]string{ + label: "true", + } + _, err := validateRuleNode(&r, util.GenerateShortUID(), cfg.BaseInterval*time.Duration(rand.Int63n(10)+1), rand.Int63(), randFolder().UID, RuleLimitsFromConfig(cfg)) + require.Error(t, err) + require.ErrorContains(t, err, label) + }) + } +} diff --git a/pkg/services/ngalert/api/api_testing.go b/pkg/services/ngalert/api/api_testing.go index 0207a16c2f917..9d715ddd96a57 100644 --- a/pkg/services/ngalert/api/api_testing.go +++ b/pkg/services/ngalert/api/api_testing.go @@ -1,6 +1,7 @@ package api import ( + "context" "errors" "net/http" "net/url" @@ -8,15 +9,19 @@ import ( "time" "github.com/benbjohnson/clock" - "github.com/grafana/alerting/models" amv2 "github.com/prometheus/alertmanager/api/v2/models" + "github.com/grafana/alerting/models" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/auth/identity" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -30,6 +35,10 @@ import ( "github.com/grafana/grafana/pkg/util" ) +type folderService interface { + GetNamespaceByUID(ctx context.Context, uid string, orgID int64, user identity.Requester) (*folder.Folder, error) +} + type TestingApiSrv struct { *AlertingProxy DatasourceCache datasources.CacheService @@ -41,23 +50,24 @@ type TestingApiSrv struct { featureManager featuremgmt.FeatureToggles appUrl *url.URL tracer tracing.Tracer + folderService folderService } // RouteTestGrafanaRuleConfig returns a list of potential alerts for a given rule configuration. This is intended to be // as true as possible to what would be generated by the ruler except that the resulting alerts are not filtered to // only Resolved / Firing and ready to send. func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext, body apimodels.PostableExtendedRuleNodeExtended) response.Response { + folder, err := srv.folderService.GetNamespaceByUID(c.Req.Context(), body.NamespaceUID, c.OrgID, c.SignedInUser) + if err != nil { + return toNamespaceErrorResponse(dashboards.ErrFolderAccessDenied) + } rule, err := validateRuleNode( &body.Rule, body.RuleGroup, srv.cfg.BaseInterval, c.SignedInUser.GetOrgID(), - &folder.Folder{ - OrgID: c.SignedInUser.GetOrgID(), - UID: body.NamespaceUID, - Title: body.NamespaceTitle, - }, - srv.cfg, + folder.UID, + RuleLimitsFromConfig(srv.cfg), ) if err != nil { return ErrResp(http.StatusBadRequest, err, "") @@ -67,8 +77,10 @@ func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext, return response.ErrOrFallback(http.StatusInternalServerError, "failed to authorize access to rule group", err) } - if _, err := store.OptimizeAlertQueries(rule.Data); err != nil { - return ErrResp(http.StatusInternalServerError, err, "Failed to optimize query") + if srv.featureManager.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingQueryOptimization) { + if _, err := store.OptimizeAlertQueries(rule.Data); err != nil { + return ErrResp(http.StatusInternalServerError, err, "Failed to optimize query") + } } evaluator, err := srv.evaluator.Create(eval.NewContext(c.Req.Context(), c.SignedInUser), rule.GetEvalCondition()) @@ -83,29 +95,28 @@ func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext, } cfg := state.ManagerCfg{ - Metrics: nil, - ExternalURL: srv.appUrl, - InstanceStore: nil, - Images: &backtesting.NoopImageService{}, - Clock: clock.New(), - Historian: nil, - MaxStateSaveConcurrency: 1, - Tracer: srv.tracer, - Log: log.New("ngalert.state.manager"), + Metrics: nil, + ExternalURL: srv.appUrl, + InstanceStore: nil, + Images: &backtesting.NoopImageService{}, + Clock: clock.New(), + Historian: nil, + Tracer: srv.tracer, + Log: log.New("ngalert.state.manager"), } - manager := state.NewManager(cfg) + manager := state.NewManager(cfg, state.NewNoopPersister()) includeFolder := !srv.cfg.ReservedLabels.IsReservedLabelDisabled(models.FolderTitleLabel) transitions := manager.ProcessEvalResults( c.Req.Context(), now, rule, results, - state.GetRuleExtraLabels(rule, body.NamespaceTitle, includeFolder), + state.GetRuleExtraLabels(log.New("testing"), rule, folder.Fullpath, includeFolder), ) alerts := make([]*amv2.PostableAlert, 0, len(transitions)) for _, alertState := range transitions { - alerts = append(alerts, state.StateToPostableAlert(alertState.State, srv.appUrl)) + alerts = append(alerts, state.StateToPostableAlert(alertState, srv.appUrl)) } return response.JSON(http.StatusOK, alerts) @@ -163,9 +174,13 @@ func (srv TestingApiSrv) RouteEvalQueries(c *contextmodel.ReqContext, cmd apimod cond.Condition = cond.Data[len(cond.Data)-1].RefID } - _, err := store.OptimizeAlertQueries(cond.Data) - if err != nil { - return ErrResp(http.StatusInternalServerError, err, "Failed to optimize query") + var optimizations []store.Optimization + if srv.featureManager.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingQueryOptimization) { + var err error + optimizations, err = store.OptimizeAlertQueries(cond.Data) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "Failed to optimize query") + } } evaluator, err := srv.evaluator.Create(eval.NewContext(c.Req.Context(), c.SignedInUser), cond) @@ -185,9 +200,25 @@ func (srv TestingApiSrv) RouteEvalQueries(c *contextmodel.ReqContext, cmd apimod return ErrResp(http.StatusInternalServerError, err, "Failed to evaluate queries and expressions") } + addOptimizedQueryWarnings(evalResults, optimizations) return response.JSONStreaming(http.StatusOK, evalResults) } +// addOptimizedQueryWarnings adds warnings to the query results for any queries that were optimized. +func addOptimizedQueryWarnings(evalResults *backend.QueryDataResponse, optimizations []store.Optimization) { + for _, opt := range optimizations { + if res, ok := evalResults.Responses[opt.RefID]; ok { + if len(res.Frames) > 0 { + res.Frames[0].AppendNotices(data.Notice{ + Severity: data.NoticeSeverityWarning, + Text: "Query optimized from Range to Instant type; all uses exclusively require the last datapoint. " + + "Consider modifying your query to Instant type to ensure accuracy.", // Currently this is the only optimization we do. + }) + } + } + } +} + func (srv TestingApiSrv) BacktestAlertRule(c *contextmodel.ReqContext, cmd apimodels.BacktestConfig) response.Response { if !srv.featureManager.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingBacktesting) { return ErrResp(http.StatusNotFound, nil, "Backgtesting API is not enabled") @@ -207,7 +238,7 @@ func (srv TestingApiSrv) BacktestAlertRule(c *contextmodel.ReqContext, cmd apimo return ErrResp(400, nil, "Bad For interval") } - intervalSeconds, err := validateInterval(srv.cfg, time.Duration(cmd.Interval)) + intervalSeconds, err := validateInterval(time.Duration(cmd.Interval), srv.cfg.BaseInterval) if err != nil { return ErrResp(400, err, "") } diff --git a/pkg/services/ngalert/api/api_testing_test.go b/pkg/services/ngalert/api/api_testing_test.go index e1efb86b6d17b..7a571244b0ee6 100644 --- a/pkg/services/ngalert/api/api_testing_test.go +++ b/pkg/services/ngalert/api/api_testing_test.go @@ -6,7 +6,9 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -14,13 +16,17 @@ import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" acMock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" fakes "github.com/grafana/grafana/pkg/services/datasources/fakes" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/eval/eval_mocks" "github.com/grafana/grafana/pkg/services/ngalert/models" + fakes2 "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/web" ) @@ -137,6 +143,36 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) { }, } + t.Run("should return Forbidden if user cannot access folder", func(t *testing.T) { + ac := acMock.New().WithPermissions([]ac.Permission{ + {Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceAllScope()}, + }) + + ruleStore := fakes2.NewRuleStore(t) + ruleStore.Hook = func(cmd any) error { + q, ok := cmd.(fakes2.GenericRecordedQuery) + if !ok { + return nil + } + if q.Name == "GetNamespaceByUID" { + return dashboards.ErrFolderAccessDenied + } + return nil + } + + srv := createTestingApiSrv(t, nil, ac, eval_mocks.NewEvaluatorFactory(&eval_mocks.ConditionEvaluatorMock{}), &featuremgmt.FeatureManager{}, ruleStore) + + rule := validRule() + + response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{ + Rule: rule, + NamespaceUID: uuid.NewString(), + NamespaceTitle: "test-folder", + }) + + require.Equal(t, http.StatusForbidden, response.Status()) + }) + t.Run("should return Forbidden if user cannot query a data source", func(t *testing.T) { data1 := models.GenerateAlertQuery() data2 := models.GenerateAlertQuery() @@ -145,15 +181,18 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) { {Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)}, }) - srv := createTestingApiSrv(t, nil, ac, eval_mocks.NewEvaluatorFactory(&eval_mocks.ConditionEvaluatorMock{})) + f := randFolder() + ruleStore := fakes2.NewRuleStore(t) + ruleStore.Folders[rc.OrgID] = []*folder.Folder{f} + srv := createTestingApiSrv(t, nil, ac, eval_mocks.NewEvaluatorFactory(&eval_mocks.ConditionEvaluatorMock{}), &featuremgmt.FeatureManager{}, ruleStore) rule := validRule() rule.GrafanaManagedAlert.Data = ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}) rule.GrafanaManagedAlert.Condition = data2.RefID response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{ Rule: rule, - NamespaceUID: "test-folder", - NamespaceTitle: "test-folder", + NamespaceUID: f.UID, + NamespaceTitle: f.Title, }) require.Equal(t, http.StatusForbidden, response.Status()) @@ -179,15 +218,19 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) { evalFactory := eval_mocks.NewEvaluatorFactory(evaluator) - srv := createTestingApiSrv(t, ds, ac, evalFactory) + f := randFolder() + ruleStore := fakes2.NewRuleStore(t) + ruleStore.Folders[rc.OrgID] = []*folder.Folder{f} + + srv := createTestingApiSrv(t, ds, ac, evalFactory, &featuremgmt.FeatureManager{}, ruleStore) rule := validRule() rule.GrafanaManagedAlert.Data = ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}) rule.GrafanaManagedAlert.Condition = data2.RefID response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{ Rule: rule, - NamespaceUID: "test-folder", - NamespaceTitle: "test-folder", + NamespaceUID: f.UID, + NamespaceTitle: f.Title, }) require.Equal(t, http.StatusOK, response.Status()) @@ -254,7 +297,9 @@ func TestRouteEvalQueries(t *testing.T) { } evaluator.EXPECT().EvaluateRaw(mock.Anything, mock.Anything).Return(result, nil) - srv := createTestingApiSrv(t, ds, ac, eval_mocks.NewEvaluatorFactory(evaluator)) + ruleStore := fakes2.NewRuleStore(t) + + srv := createTestingApiSrv(t, ds, ac, eval_mocks.NewEvaluatorFactory(evaluator), &featuremgmt.FeatureManager{}, ruleStore) response := srv.RouteEvalQueries(rc, definitions.EvalQueriesPayload{ Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}), @@ -266,9 +311,83 @@ func TestRouteEvalQueries(t *testing.T) { evaluator.AssertCalled(t, "EvaluateRaw", mock.Anything, currentTime) }) }) + + t.Run("when query is optimizable", func(t *testing.T) { + rc := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: &http.Request{}, + }, + SignedInUser: &user.SignedInUser{ + OrgID: 1, + }, + } + t.Run("should return warning notice on optimized queries", func(t *testing.T) { + queries := []models.AlertQuery{ + models.CreatePrometheusQuery("A", "1", 1000, 43200, false, "some-ds"), + models.CreatePrometheusQuery("B", "1", 1000, 43200, false, "some-ds"), + models.CreatePrometheusQuery("C", "1", 1000, 43200, false, "some-ds"), // Not optimizable. + models.CreateReduceExpression("D", "A", "last"), + models.CreateReduceExpression("E", "B", "last"), + } + + currentTime := time.Now() + + ac := acMock.New().WithPermissions([]ac.Permission{ + {Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(queries[0].DatasourceUID)}, + }) + + ds := &fakes.FakeCacheService{DataSources: []*datasources.DataSource{ + {UID: queries[0].DatasourceUID}, + }} + + evaluator := &eval_mocks.ConditionEvaluatorMock{} + createEmptyFrameResponse := func(refId string) backend.DataResponse { + frame := data.NewFrame("") + frame.RefID = refId + frame.SetMeta(&data.FrameMeta{}) + return backend.DataResponse{ + Frames: []*data.Frame{frame}, + Error: nil, + } + } + result := &backend.QueryDataResponse{ + Responses: map[string]backend.DataResponse{ + "A": createEmptyFrameResponse("A"), + "B": createEmptyFrameResponse("B"), + "C": createEmptyFrameResponse("C"), + }, + } + evaluator.EXPECT().EvaluateRaw(mock.Anything, mock.Anything).Return(result, nil) + + ruleStore := fakes2.NewRuleStore(t) + + srv := createTestingApiSrv(t, ds, ac, eval_mocks.NewEvaluatorFactory(evaluator), featuremgmt.WithManager(featuremgmt.FlagAlertingQueryOptimization), ruleStore) + + response := srv.RouteEvalQueries(rc, definitions.EvalQueriesPayload{ + Data: ApiAlertQueriesFromAlertQueries(queries), + Now: currentTime, + }) + + require.Equal(t, http.StatusOK, response.Status()) + + evaluator.AssertCalled(t, "EvaluateRaw", mock.Anything, currentTime) + + require.Equal(t, []data.Notice{{ + Severity: data.NoticeSeverityWarning, + Text: "Query optimized from Range to Instant type; all uses exclusively require the last datapoint. Consider modifying your query to Instant type to ensure accuracy.", + }}, result.Responses["A"].Frames[0].Meta.Notices) + + require.Equal(t, []data.Notice{{ + Severity: data.NoticeSeverityWarning, + Text: "Query optimized from Range to Instant type; all uses exclusively require the last datapoint. Consider modifying your query to Instant type to ensure accuracy.", + }}, result.Responses["B"].Frames[0].Meta.Notices) + + require.Equal(t, 0, len(result.Responses["C"].Frames[0].Meta.Notices)) + }) + }) } -func createTestingApiSrv(t *testing.T, ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator eval.EvaluatorFactory) *TestingApiSrv { +func createTestingApiSrv(t *testing.T, ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator eval.EvaluatorFactory, featureManager *featuremgmt.FeatureManager, ruleStore RuleStore) *TestingApiSrv { if ac == nil { ac = acMock.New() } @@ -279,5 +398,7 @@ func createTestingApiSrv(t *testing.T, ds *fakes.FakeCacheService, ac *acMock.Mo evaluator: evaluator, cfg: config(t), tracer: tracing.InitializeTracerForTest(), + featureManager: featureManager, + folderService: ruleStore, } } diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 92a131721e510..cd11b5254d14b 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -21,32 +21,49 @@ func (api *API) authorize(method, path string) web.Handler { // Grafana Paths case http.MethodDelete + "/api/ruler/grafana/api/v1/rules/{Namespace}/{Groupname}": - eval = ac.EvalPermission(ac.ActionAlertingRuleDelete, dashboards.ScopeFoldersProvider.GetResourceScopeName(ac.Parameter(":Namespace"))) + eval = ac.EvalPermission(ac.ActionAlertingRuleDelete, dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.Parameter(":Namespace"))) case http.MethodDelete + "/api/ruler/grafana/api/v1/rules/{Namespace}": - eval = ac.EvalPermission(ac.ActionAlertingRuleDelete, dashboards.ScopeFoldersProvider.GetResourceScopeName(ac.Parameter(":Namespace"))) + eval = ac.EvalPermission(ac.ActionAlertingRuleDelete, dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.Parameter(":Namespace"))) case http.MethodGet + "/api/ruler/grafana/api/v1/rules/{Namespace}/{Groupname}": - eval = ac.EvalPermission(ac.ActionAlertingRuleRead, dashboards.ScopeFoldersProvider.GetResourceScopeName(ac.Parameter(":Namespace"))) + eval = ac.EvalPermission(ac.ActionAlertingRuleRead, dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.Parameter(":Namespace"))) case http.MethodGet + "/api/ruler/grafana/api/v1/rules/{Namespace}": - eval = ac.EvalPermission(ac.ActionAlertingRuleRead, dashboards.ScopeFoldersProvider.GetResourceScopeName(ac.Parameter(":Namespace"))) + eval = ac.EvalPermission(ac.ActionAlertingRuleRead, dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.Parameter(":Namespace"))) case http.MethodGet + "/api/ruler/grafana/api/v1/rules", http.MethodGet + "/api/ruler/grafana/api/v1/export/rules": eval = ac.EvalPermission(ac.ActionAlertingRuleRead) case http.MethodPost + "/api/ruler/grafana/api/v1/rules/{Namespace}/export": - scope := dashboards.ScopeFoldersProvider.GetResourceScopeName(ac.Parameter(":Namespace")) + scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.Parameter(":Namespace")) // more granular permissions are enforced by the handler via "authorizeRuleChanges" eval = ac.EvalPermission(ac.ActionAlertingRuleRead, scope) case http.MethodPost + "/api/ruler/grafana/api/v1/rules/{Namespace}": - scope := dashboards.ScopeFoldersProvider.GetResourceScopeName(ac.Parameter(":Namespace")) + scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.Parameter(":Namespace")) // more granular permissions are enforced by the handler via "authorizeRuleChanges" eval = ac.EvalAny( ac.EvalPermission(ac.ActionAlertingRuleUpdate, scope), ac.EvalPermission(ac.ActionAlertingRuleCreate, scope), ac.EvalPermission(ac.ActionAlertingRuleDelete, scope), ) + // Grafana rule state history paths case http.MethodGet + "/api/v1/rules/history": eval = ac.EvalPermission(ac.ActionAlertingRuleRead) + // Grafana receivers paths + case http.MethodGet + "/api/v1/notifications/receivers": + // additional authorization is done at the service level + eval = ac.EvalAny( + ac.EvalPermission(ac.ActionAlertingNotificationsRead), + ac.EvalPermission(ac.ActionAlertingReceiversList), + ac.EvalPermission(ac.ActionAlertingReceiversRead), + ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets), + ) + case http.MethodGet + "/api/v1/notifications/receivers/{Name}": + // TODO: scope to :Name + eval = ac.EvalAny( + ac.EvalPermission(ac.ActionAlertingReceiversRead), + ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets), + ) + // Grafana, Prometheus-compatible Paths case http.MethodGet + "/api/prometheus/grafana/api/v1/rules": eval = ac.EvalPermission(ac.ActionAlertingRuleRead) @@ -218,8 +235,12 @@ func (api *API) authorize(method, path string) web.Handler { http.MethodPost + "/api/v1/provisioning/alert-rules", http.MethodPut + "/api/v1/provisioning/alert-rules/{UID}", http.MethodDelete + "/api/v1/provisioning/alert-rules/{UID}", - http.MethodPut + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": + http.MethodPut + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}", + http.MethodDelete + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": eval = ac.EvalPermission(ac.ActionAlertingProvisioningWrite) // organization scope + case http.MethodGet + "/api/v1/notifications/time-intervals/{name}", + http.MethodGet + "/api/v1/notifications/time-intervals": + eval = ac.EvalAny(ac.EvalPermission(ac.ActionAlertingNotificationsRead), ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsRead), ac.EvalPermission(ac.ActionAlertingProvisioningRead)) } if eval != nil { diff --git a/pkg/services/ngalert/api/authorization_test.go b/pkg/services/ngalert/api/authorization_test.go index 2502ea3e0920d..a4603e2c06a02 100644 --- a/pkg/services/ngalert/api/authorization_test.go +++ b/pkg/services/ngalert/api/authorization_test.go @@ -40,13 +40,14 @@ func TestAuthorize(t *testing.T) { } paths[p] = methods } - require.Len(t, paths, 54) + require.Len(t, paths, 58) ac := acmock.New() api := &API{AccessControl: ac} t.Run("should not panic on known routes", func(t *testing.T) { for path, methods := range paths { + path := swaggerSpec.Spec().BasePath + path for _, method := range methods { require.NotPanics(t, func() { api.authorize(method, path) diff --git a/pkg/services/ngalert/api/compat.go b/pkg/services/ngalert/api/compat.go index d27980d096884..8f671b113416b 100644 --- a/pkg/services/ngalert/api/compat.go +++ b/pkg/services/ngalert/api/compat.go @@ -15,43 +15,45 @@ import ( // AlertRuleFromProvisionedAlertRule converts definitions.ProvisionedAlertRule to models.AlertRule func AlertRuleFromProvisionedAlertRule(a definitions.ProvisionedAlertRule) (models.AlertRule, error) { return models.AlertRule{ - ID: a.ID, - UID: a.UID, - OrgID: a.OrgID, - NamespaceUID: a.FolderUID, - RuleGroup: a.RuleGroup, - Title: a.Title, - Condition: a.Condition, - Data: AlertQueriesFromApiAlertQueries(a.Data), - Updated: a.Updated, - NoDataState: models.NoDataState(a.NoDataState), // TODO there must be a validation - ExecErrState: models.ExecutionErrorState(a.ExecErrState), // TODO there must be a validation - For: time.Duration(a.For), - Annotations: a.Annotations, - Labels: a.Labels, - IsPaused: a.IsPaused, + ID: a.ID, + UID: a.UID, + OrgID: a.OrgID, + NamespaceUID: a.FolderUID, + RuleGroup: a.RuleGroup, + Title: a.Title, + Condition: a.Condition, + Data: AlertQueriesFromApiAlertQueries(a.Data), + Updated: a.Updated, + NoDataState: models.NoDataState(a.NoDataState), // TODO there must be a validation + ExecErrState: models.ExecutionErrorState(a.ExecErrState), // TODO there must be a validation + For: time.Duration(a.For), + Annotations: a.Annotations, + Labels: a.Labels, + IsPaused: a.IsPaused, + NotificationSettings: NotificationSettingsFromAlertRuleNotificationSettings(a.NotificationSettings), }, nil } // ProvisionedAlertRuleFromAlertRule converts models.AlertRule to definitions.ProvisionedAlertRule and sets provided provenance status func ProvisionedAlertRuleFromAlertRule(rule models.AlertRule, provenance models.Provenance) definitions.ProvisionedAlertRule { return definitions.ProvisionedAlertRule{ - ID: rule.ID, - UID: rule.UID, - OrgID: rule.OrgID, - FolderUID: rule.NamespaceUID, - RuleGroup: rule.RuleGroup, - Title: rule.Title, - For: model.Duration(rule.For), - Condition: rule.Condition, - Data: ApiAlertQueriesFromAlertQueries(rule.Data), - Updated: rule.Updated, - NoDataState: definitions.NoDataState(rule.NoDataState), // TODO there may be a validation - ExecErrState: definitions.ExecutionErrorState(rule.ExecErrState), // TODO there may be a validation - Annotations: rule.Annotations, - Labels: rule.Labels, - Provenance: definitions.Provenance(provenance), // TODO validate enum conversion? - IsPaused: rule.IsPaused, + ID: rule.ID, + UID: rule.UID, + OrgID: rule.OrgID, + FolderUID: rule.NamespaceUID, + RuleGroup: rule.RuleGroup, + Title: rule.Title, + For: model.Duration(rule.For), + Condition: rule.Condition, + Data: ApiAlertQueriesFromAlertQueries(rule.Data), + Updated: rule.Updated, + NoDataState: definitions.NoDataState(rule.NoDataState), // TODO there may be a validation + ExecErrState: definitions.ExecutionErrorState(rule.ExecErrState), // TODO there may be a validation + Annotations: rule.Annotations, + Labels: rule.Labels, + Provenance: definitions.Provenance(provenance), // TODO validate enum conversion? + IsPaused: rule.IsPaused, + NotificationSettings: AlertRuleNotificationSettingsFromNotificationSettings(rule.NotificationSettings), } } @@ -175,16 +177,17 @@ func AlertRuleExportFromAlertRule(rule models.AlertRule) (definitions.AlertRuleE } result := definitions.AlertRuleExport{ - UID: rule.UID, - Title: rule.Title, - For: model.Duration(rule.For), - Condition: rule.Condition, - Data: data, - DashboardUID: rule.DashboardUID, - PanelID: rule.PanelID, - NoDataState: definitions.NoDataState(rule.NoDataState), - ExecErrState: definitions.ExecutionErrorState(rule.ExecErrState), - IsPaused: rule.IsPaused, + UID: rule.UID, + Title: rule.Title, + For: model.Duration(rule.For), + Condition: rule.Condition, + Data: data, + DashboardUID: rule.DashboardUID, + PanelID: rule.PanelID, + NoDataState: definitions.NoDataState(rule.NoDataState), + ExecErrState: definitions.ExecutionErrorState(rule.ExecErrState), + IsPaused: rule.IsPaused, + NotificationSettings: AlertRuleNotificationSettingsExportFromNotificationSettings(rule.NotificationSettings), } if rule.For.Seconds() > 0 { result.ForString = util.Pointer(model.Duration(rule.For).String()) @@ -373,3 +376,61 @@ func MuteTimingIntervalToMuteTimeIntervalHclExport(m definitions.MuteTimeInterva err = j.Unmarshal(mdata, &result) return result, err } + +// AlertRuleNotificationSettingsFromNotificationSettings converts []models.NotificationSettings to definitions.AlertRuleNotificationSettings +func AlertRuleNotificationSettingsFromNotificationSettings(ns []models.NotificationSettings) *definitions.AlertRuleNotificationSettings { + if len(ns) == 0 { + return nil + } + m := ns[0] + return &definitions.AlertRuleNotificationSettings{ + Receiver: m.Receiver, + GroupBy: m.GroupBy, + GroupWait: m.GroupWait, + GroupInterval: m.GroupInterval, + RepeatInterval: m.RepeatInterval, + MuteTimeIntervals: m.MuteTimeIntervals, + } +} + +// AlertRuleNotificationSettingsFromNotificationSettings converts []models.NotificationSettings to definitions.AlertRuleNotificationSettingsExport +func AlertRuleNotificationSettingsExportFromNotificationSettings(ns []models.NotificationSettings) *definitions.AlertRuleNotificationSettingsExport { + if len(ns) == 0 { + return nil + } + m := ns[0] + + toStringIfNotNil := func(d *model.Duration) *string { + if d == nil { + return nil + } + s := d.String() + return &s + } + + return &definitions.AlertRuleNotificationSettingsExport{ + Receiver: m.Receiver, + GroupBy: m.GroupBy, + GroupWait: toStringIfNotNil(m.GroupWait), + GroupInterval: toStringIfNotNil(m.GroupInterval), + RepeatInterval: toStringIfNotNil(m.RepeatInterval), + MuteTimeIntervals: m.MuteTimeIntervals, + } +} + +// NotificationSettingsFromAlertRuleNotificationSettings converts definitions.AlertRuleNotificationSettings to []models.NotificationSettings +func NotificationSettingsFromAlertRuleNotificationSettings(ns *definitions.AlertRuleNotificationSettings) []models.NotificationSettings { + if ns == nil { + return nil + } + return []models.NotificationSettings{ + { + Receiver: ns.Receiver, + GroupBy: ns.GroupBy, + GroupWait: ns.GroupWait, + GroupInterval: ns.GroupInterval, + RepeatInterval: ns.RepeatInterval, + MuteTimeIntervals: ns.MuteTimeIntervals, + }, + } +} diff --git a/pkg/services/ngalert/api/generated_base_api_notifications.go b/pkg/services/ngalert/api/generated_base_api_notifications.go new file mode 100644 index 0000000000000..17b82056b7d93 --- /dev/null +++ b/pkg/services/ngalert/api/generated_base_api_notifications.go @@ -0,0 +1,96 @@ +/*Package api contains base API implementation of unified alerting + * + *Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + * + *Do not manually edit these files, please find ngalert/api/swagger-codegen/ for commands on how to generate them. + */ +package api + +import ( + "net/http" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/middleware/requestmeta" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/ngalert/metrics" + "github.com/grafana/grafana/pkg/web" +) + +type NotificationsApi interface { + RouteGetReceiver(*contextmodel.ReqContext) response.Response + RouteGetReceivers(*contextmodel.ReqContext) response.Response + RouteNotificationsGetTimeInterval(*contextmodel.ReqContext) response.Response + RouteNotificationsGetTimeIntervals(*contextmodel.ReqContext) response.Response +} + +func (f *NotificationsApiHandler) RouteGetReceiver(ctx *contextmodel.ReqContext) response.Response { + // Parse Path Parameters + nameParam := web.Params(ctx.Req)[":name"] + return f.handleRouteGetReceiver(ctx, nameParam) +} +func (f *NotificationsApiHandler) RouteGetReceivers(ctx *contextmodel.ReqContext) response.Response { + return f.handleRouteGetReceivers(ctx) +} +func (f *NotificationsApiHandler) RouteNotificationsGetTimeInterval(ctx *contextmodel.ReqContext) response.Response { + // Parse Path Parameters + nameParam := web.Params(ctx.Req)[":name"] + return f.handleRouteNotificationsGetTimeInterval(ctx, nameParam) +} +func (f *NotificationsApiHandler) RouteNotificationsGetTimeIntervals(ctx *contextmodel.ReqContext) response.Response { + return f.handleRouteNotificationsGetTimeIntervals(ctx) +} + +func (api *API) RegisterNotificationsApiEndpoints(srv NotificationsApi, m *metrics.API) { + api.RouteRegister.Group("", func(group routing.RouteRegister) { + group.Get( + toMacaronPath("/api/v1/notifications/receivers/{Name}"), + requestmeta.SetOwner(requestmeta.TeamAlerting), + requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), + api.authorize(http.MethodGet, "/api/v1/notifications/receivers/{Name}"), + metrics.Instrument( + http.MethodGet, + "/api/v1/notifications/receivers/{Name}", + api.Hooks.Wrap(srv.RouteGetReceiver), + m, + ), + ) + group.Get( + toMacaronPath("/api/v1/notifications/receivers"), + requestmeta.SetOwner(requestmeta.TeamAlerting), + requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), + api.authorize(http.MethodGet, "/api/v1/notifications/receivers"), + metrics.Instrument( + http.MethodGet, + "/api/v1/notifications/receivers", + api.Hooks.Wrap(srv.RouteGetReceivers), + m, + ), + ) + group.Get( + toMacaronPath("/api/v1/notifications/time-intervals/{name}"), + requestmeta.SetOwner(requestmeta.TeamAlerting), + requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), + api.authorize(http.MethodGet, "/api/v1/notifications/time-intervals/{name}"), + metrics.Instrument( + http.MethodGet, + "/api/v1/notifications/time-intervals/{name}", + api.Hooks.Wrap(srv.RouteNotificationsGetTimeInterval), + m, + ), + ) + group.Get( + toMacaronPath("/api/v1/notifications/time-intervals"), + requestmeta.SetOwner(requestmeta.TeamAlerting), + requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), + api.authorize(http.MethodGet, "/api/v1/notifications/time-intervals"), + metrics.Instrument( + http.MethodGet, + "/api/v1/notifications/time-intervals", + api.Hooks.Wrap(srv.RouteNotificationsGetTimeIntervals), + m, + ), + ) + }, middleware.ReqSignedIn) +} diff --git a/pkg/services/ngalert/api/generated_base_api_provisioning.go b/pkg/services/ngalert/api/generated_base_api_provisioning.go index 453d151101eba..202d643a11d4f 100644 --- a/pkg/services/ngalert/api/generated_base_api_provisioning.go +++ b/pkg/services/ngalert/api/generated_base_api_provisioning.go @@ -21,6 +21,7 @@ import ( type ProvisioningApi interface { RouteDeleteAlertRule(*contextmodel.ReqContext) response.Response + RouteDeleteAlertRuleGroup(*contextmodel.ReqContext) response.Response RouteDeleteContactpoints(*contextmodel.ReqContext) response.Response RouteDeleteMuteTiming(*contextmodel.ReqContext) response.Response RouteDeleteTemplate(*contextmodel.ReqContext) response.Response @@ -57,6 +58,12 @@ func (f *ProvisioningApiHandler) RouteDeleteAlertRule(ctx *contextmodel.ReqConte uIDParam := web.Params(ctx.Req)[":UID"] return f.handleRouteDeleteAlertRule(ctx, uIDParam) } +func (f *ProvisioningApiHandler) RouteDeleteAlertRuleGroup(ctx *contextmodel.ReqContext) response.Response { + // Parse Path Parameters + folderUIDParam := web.Params(ctx.Req)[":FolderUID"] + groupParam := web.Params(ctx.Req)[":Group"] + return f.handleRouteDeleteAlertRuleGroup(ctx, folderUIDParam, groupParam) +} func (f *ProvisioningApiHandler) RouteDeleteContactpoints(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters uIDParam := web.Params(ctx.Req)[":UID"] @@ -237,6 +244,18 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApi, m *metrics m, ), ) + group.Delete( + toMacaronPath("/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}"), + requestmeta.SetOwner(requestmeta.TeamAlerting), + requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), + api.authorize(http.MethodDelete, "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}"), + metrics.Instrument( + http.MethodDelete, + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}", + api.Hooks.Wrap(srv.RouteDeleteAlertRuleGroup), + m, + ), + ) group.Delete( toMacaronPath("/api/v1/provisioning/contact-points/{UID}"), requestmeta.SetOwner(requestmeta.TeamAlerting), diff --git a/pkg/services/ngalert/api/lotex_ruler.go b/pkg/services/ngalert/api/lotex_ruler.go index 4e0100304cc83..bcffe81c63f69 100644 --- a/pkg/services/ngalert/api/lotex_ruler.go +++ b/pkg/services/ngalert/api/lotex_ruler.go @@ -3,6 +3,7 @@ package api import ( "bytes" "fmt" + "io" "net/http" "net/url" @@ -43,15 +44,22 @@ var subtypeToPrefix = map[string]string{ Mimir: mimirPrefix, } +// The requester is primarily used for testing purposes, allowing us to inject a different implementation of withReq. +type requester interface { + withReq(ctx *contextmodel.ReqContext, method string, u *url.URL, body io.Reader, extractor func(*response.NormalResponse) (any, error), headers map[string]string) response.Response +} + type LotexRuler struct { log log.Logger *AlertingProxy + requester requester } func NewLotexRuler(proxy *AlertingProxy, log log.Logger) *LotexRuler { return &LotexRuler{ log: log, AlertingProxy: proxy, + requester: proxy, } } @@ -60,12 +68,12 @@ func (r *LotexRuler) RouteDeleteNamespaceRulesConfig(ctx *contextmodel.ReqContex if err != nil { return ErrResp(500, err, "") } - return r.withReq( + return r.requester.withReq( ctx, http.MethodDelete, withPath( *ctx.Req.URL, - fmt.Sprintf("%s/%s", legacyRulerPrefix, namespace), + fmt.Sprintf("%s/%s", legacyRulerPrefix, url.PathEscape(namespace)), ), nil, messageExtractor, @@ -78,7 +86,7 @@ func (r *LotexRuler) RouteDeleteRuleGroupConfig(ctx *contextmodel.ReqContext, na if err != nil { return ErrResp(500, err, "") } - return r.withReq( + return r.requester.withReq( ctx, http.MethodDelete, withPath( @@ -86,8 +94,8 @@ func (r *LotexRuler) RouteDeleteRuleGroupConfig(ctx *contextmodel.ReqContext, na fmt.Sprintf( "%s/%s/%s", legacyRulerPrefix, - namespace, - group, + url.PathEscape(namespace), + url.PathEscape(group), ), ), nil, @@ -101,7 +109,7 @@ func (r *LotexRuler) RouteGetNamespaceRulesConfig(ctx *contextmodel.ReqContext, if err != nil { return ErrResp(500, err, "") } - return r.withReq( + return r.requester.withReq( ctx, http.MethodGet, withPath( @@ -109,7 +117,7 @@ func (r *LotexRuler) RouteGetNamespaceRulesConfig(ctx *contextmodel.ReqContext, fmt.Sprintf( "%s/%s", legacyRulerPrefix, - namespace, + url.PathEscape(namespace), ), ), nil, @@ -123,7 +131,7 @@ func (r *LotexRuler) RouteGetRulegGroupConfig(ctx *contextmodel.ReqContext, name if err != nil { return ErrResp(500, err, "") } - return r.withReq( + return r.requester.withReq( ctx, http.MethodGet, withPath( @@ -131,8 +139,8 @@ func (r *LotexRuler) RouteGetRulegGroupConfig(ctx *contextmodel.ReqContext, name fmt.Sprintf( "%s/%s/%s", legacyRulerPrefix, - namespace, - group, + url.PathEscape(namespace), + url.PathEscape(group), ), ), nil, @@ -147,7 +155,7 @@ func (r *LotexRuler) RouteGetRulesConfig(ctx *contextmodel.ReqContext) response. return ErrResp(500, err, "") } - return r.withReq( + return r.requester.withReq( ctx, http.MethodGet, withPath( @@ -170,7 +178,7 @@ func (r *LotexRuler) RoutePostNameRulesConfig(ctx *contextmodel.ReqContext, conf return ErrResp(500, err, "Failed marshal rule group") } u := withPath(*ctx.Req.URL, fmt.Sprintf("%s/%s", legacyRulerPrefix, ns)) - return r.withReq(ctx, http.MethodPost, u, bytes.NewBuffer(yml), jsonExtractor(nil), nil) + return r.requester.withReq(ctx, http.MethodPost, u, bytes.NewBuffer(yml), jsonExtractor(nil), nil) } func (r *LotexRuler) validateAndGetPrefix(ctx *contextmodel.ReqContext) (string, error) { @@ -216,7 +224,8 @@ func (r *LotexRuler) validateAndGetPrefix(ctx *contextmodel.ReqContext) (string, } func withPath(u url.URL, newPath string) *url.URL { - // TODO: handle path escaping - u.Path = newPath + u.Path, _ = url.PathUnescape(newPath) + u.RawPath = newPath + return &u } diff --git a/pkg/services/ngalert/api/lotex_ruler_test.go b/pkg/services/ngalert/api/lotex_ruler_test.go index 661377d68f325..c54272e9953ee 100644 --- a/pkg/services/ngalert/api/lotex_ruler_test.go +++ b/pkg/services/ngalert/api/lotex_ruler_test.go @@ -3,11 +3,15 @@ package api import ( "context" "errors" + "io" "net/http" + "net/url" "testing" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/auth/identity" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" @@ -129,3 +133,262 @@ func (f fakeCacheService) GetDatasourceByUID(ctx context.Context, datasourceUID return f.datasource, nil } + +func TestLotexRuler_RouteDeleteNamespaceRulesConfig(t *testing.T) { + tc := []struct { + name string + namespace string + expected string + urlParams string + namedParams map[string]string + datasource *datasources.DataSource + }{ + { + name: "with a namespace that has to be escaped", + namespace: "namespace/with/slashes", + expected: "http://mimir.com/config/v1/rules/namespace%2Fwith%2Fslashes?subtype=mimir", + urlParams: "?subtype=mimir", + namedParams: map[string]string{":DatasourceUID": "d164"}, + datasource: &datasources.DataSource{URL: "http://mimir.com", Type: PrometheusDatasourceType}, + }, + { + name: "with a namespace that does not need to be escaped", + namespace: "namespace_without_slashes", + expected: "http://mimir.com/config/v1/rules/namespace_without_slashes?subtype=mimir", + urlParams: "?subtype=mimir", + namedParams: map[string]string{":DatasourceUID": "d164"}, + datasource: &datasources.DataSource{URL: "http://mimir.com", Type: PrometheusDatasourceType}, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + requestMock := RequestMock{} + defer requestMock.AssertExpectations(t) + + requestMock.On( + "withReq", + mock.Anything, + mock.Anything, + mock.AnythingOfType("*url.URL"), + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(response.Empty(200)).Run(func(args mock.Arguments) { + // Validate that the full url as string is equal to the expected value + require.Equal(t, tt.expected, args.Get(2).(*url.URL).String()) + }) + + // Setup Proxy. + proxy := &AlertingProxy{DataProxy: &datasourceproxy.DataSourceProxyService{DataSourceCache: fakeCacheService{datasource: tt.datasource}}} + ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger(), requester: &requestMock} + + // Setup request context. + httpReq, err := http.NewRequest(http.MethodGet, tt.datasource.URL+tt.urlParams, nil) + require.NoError(t, err) + ctx := &contextmodel.ReqContext{Context: &web.Context{Req: web.SetURLParams(httpReq, tt.namedParams)}} + + ruler.RouteDeleteNamespaceRulesConfig(ctx, tt.namespace) + }) + } +} + +func TestLotexRuler_RouteDeleteRuleGroupConfig(t *testing.T) { + tc := []struct { + name string + namespace string + group string + expected string + urlParams string + namedParams map[string]string + datasource *datasources.DataSource + }{ + { + name: "with a namespace that has to be escaped", + namespace: "namespace/with/slashes", + group: "group/with/slashes", + expected: "http://mimir.com/config/v1/rules/namespace%2Fwith%2Fslashes/group%2Fwith%2Fslashes?subtype=mimir", + urlParams: "?subtype=mimir", + namedParams: map[string]string{":DatasourceUID": "d164"}, + datasource: &datasources.DataSource{URL: "http://mimir.com", Type: PrometheusDatasourceType}, + }, + { + name: "with a namespace that does not need to be escaped", + namespace: "namespace_without_slashes", + group: "group_without_slashes", + expected: "http://mimir.com/config/v1/rules/namespace_without_slashes/group_without_slashes?subtype=mimir", + urlParams: "?subtype=mimir", + namedParams: map[string]string{":DatasourceUID": "d164"}, + datasource: &datasources.DataSource{URL: "http://mimir.com", Type: PrometheusDatasourceType}, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + requestMock := RequestMock{} + defer requestMock.AssertExpectations(t) + + requestMock.On( + "withReq", + mock.Anything, + mock.Anything, + mock.AnythingOfType("*url.URL"), + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(response.Empty(200)).Run(func(args mock.Arguments) { + // Validate that the full url as string is equal to the expected value + require.Equal(t, tt.expected, args.Get(2).(*url.URL).String()) + }) + + // Setup Proxy. + proxy := &AlertingProxy{DataProxy: &datasourceproxy.DataSourceProxyService{DataSourceCache: fakeCacheService{datasource: tt.datasource}}} + ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger(), requester: &requestMock} + + // Setup request context. + httpReq, err := http.NewRequest(http.MethodGet, tt.datasource.URL+tt.urlParams, nil) + require.NoError(t, err) + ctx := &contextmodel.ReqContext{Context: &web.Context{Req: web.SetURLParams(httpReq, tt.namedParams)}} + + ruler.RouteDeleteRuleGroupConfig(ctx, tt.namespace, tt.group) + }) + } +} + +func TestLotexRuler_RouteGetNamespaceRulesConfig(t *testing.T) { + tc := []struct { + name string + namespace string + group string + expected string + urlParams string + namedParams map[string]string + datasource *datasources.DataSource + }{ + { + name: "with a namespace that has to be escaped", + namespace: "namespace/with/slashes", + expected: "http://mimir.com/config/v1/rules/namespace%2Fwith%2Fslashes?subtype=mimir", + urlParams: "?subtype=mimir", + namedParams: map[string]string{":DatasourceUID": "d164"}, + datasource: &datasources.DataSource{URL: "http://mimir.com", Type: PrometheusDatasourceType}, + }, + { + name: "with a namespace that does not need to be escaped", + namespace: "namespace_without_slashes", + expected: "http://mimir.com/config/v1/rules/namespace_without_slashes?subtype=mimir", + urlParams: "?subtype=mimir", + namedParams: map[string]string{":DatasourceUID": "d164"}, + datasource: &datasources.DataSource{URL: "http://mimir.com", Type: PrometheusDatasourceType}, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + requestMock := RequestMock{} + defer requestMock.AssertExpectations(t) + + requestMock.On( + "withReq", + mock.Anything, + mock.Anything, + mock.AnythingOfType("*url.URL"), + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(response.Empty(200)).Run(func(args mock.Arguments) { + // Validate that the full url as string is equal to the expected value + require.Equal(t, tt.expected, args.Get(2).(*url.URL).String()) + }) + + // Setup Proxy. + proxy := &AlertingProxy{DataProxy: &datasourceproxy.DataSourceProxyService{DataSourceCache: fakeCacheService{datasource: tt.datasource}}} + ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger(), requester: &requestMock} + + // Setup request context. + httpReq, err := http.NewRequest(http.MethodGet, tt.datasource.URL+tt.urlParams, nil) + require.NoError(t, err) + ctx := &contextmodel.ReqContext{Context: &web.Context{Req: web.SetURLParams(httpReq, tt.namedParams)}} + + ruler.RouteGetNamespaceRulesConfig(ctx, tt.namespace) + }) + } +} + +func TestLotexRuler_RouteGetRulegGroupConfig(t *testing.T) { + tc := []struct { + name string + namespace string + group string + expected string + urlParams string + namedParams map[string]string + datasource *datasources.DataSource + }{ + { + name: "with a namespace that has to be escaped", + namespace: "namespace/with/slashes", + group: "group/with/slashes", + expected: "http://mimir.com/config/v1/rules/namespace%2Fwith%2Fslashes/group%2Fwith%2Fslashes?subtype=mimir", + urlParams: "?subtype=mimir", + namedParams: map[string]string{":DatasourceUID": "d164"}, + datasource: &datasources.DataSource{URL: "http://mimir.com", Type: PrometheusDatasourceType}, + }, + { + name: "with a namespace that does not need to be escaped", + namespace: "namespace_without_slashes", + group: "group_without_slashes", + expected: "http://mimir.com/config/v1/rules/namespace_without_slashes/group_without_slashes?subtype=mimir", + urlParams: "?subtype=mimir", + namedParams: map[string]string{":DatasourceUID": "d164"}, + datasource: &datasources.DataSource{URL: "http://mimir.com", Type: PrometheusDatasourceType}, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + requestMock := RequestMock{} + defer requestMock.AssertExpectations(t) + + requestMock.On( + "withReq", + mock.Anything, + mock.Anything, + mock.AnythingOfType("*url.URL"), + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(response.Empty(200)).Run(func(args mock.Arguments) { + // Validate that the full url as string is equal to the expected value + require.Equal(t, tt.expected, args.Get(2).(*url.URL).String()) + }) + + // Setup Proxy. + proxy := &AlertingProxy{DataProxy: &datasourceproxy.DataSourceProxyService{DataSourceCache: fakeCacheService{datasource: tt.datasource}}} + ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger(), requester: &requestMock} + + // Setup request context. + httpReq, err := http.NewRequest(http.MethodGet, tt.datasource.URL+tt.urlParams, nil) + require.NoError(t, err) + ctx := &contextmodel.ReqContext{Context: &web.Context{Req: web.SetURLParams(httpReq, tt.namedParams)}} + + ruler.RouteGetRulegGroupConfig(ctx, tt.namespace, tt.group) + }) + } +} + +type RequestMock struct { + mock.Mock +} + +func (a *RequestMock) withReq( + ctx *contextmodel.ReqContext, + method string, + u *url.URL, + body io.Reader, + extractor func(*response.NormalResponse) (any, error), + headers map[string]string, +) response.Response { + args := a.Called(ctx, method, u, body, extractor, headers) + return args.Get(0).(response.Response) +} diff --git a/pkg/services/ngalert/api/notifications.go b/pkg/services/ngalert/api/notifications.go new file mode 100644 index 0000000000000..5a189e00fddfb --- /dev/null +++ b/pkg/services/ngalert/api/notifications.go @@ -0,0 +1,32 @@ +package api + +import ( + "github.com/grafana/grafana/pkg/api/response" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" +) + +type NotificationsApiHandler struct { + notificationSrv *NotificationSrv +} + +func NewNotificationsApi(notificationSrv *NotificationSrv) *NotificationsApiHandler { + return &NotificationsApiHandler{ + notificationSrv: notificationSrv, + } +} + +func (f *NotificationsApiHandler) handleRouteNotificationsGetTimeInterval(ctx *contextmodel.ReqContext, name string) response.Response { + return f.notificationSrv.RouteGetTimeInterval(ctx, name) +} + +func (f *NotificationsApiHandler) handleRouteNotificationsGetTimeIntervals(ctx *contextmodel.ReqContext) response.Response { + return f.notificationSrv.RouteGetTimeIntervals(ctx) +} + +func (f *NotificationsApiHandler) handleRouteGetReceiver(ctx *contextmodel.ReqContext, name string) response.Response { + return f.notificationSrv.RouteGetReceiver(ctx, name) +} + +func (f *NotificationsApiHandler) handleRouteGetReceivers(ctx *contextmodel.ReqContext) response.Response { + return f.notificationSrv.RouteGetReceivers(ctx) +} diff --git a/pkg/services/ngalert/api/persist.go b/pkg/services/ngalert/api/persist.go index 82217a5f8cf5b..ebefb6e33abc9 100644 --- a/pkg/services/ngalert/api/persist.go +++ b/pkg/services/ngalert/api/persist.go @@ -10,9 +10,11 @@ import ( // RuleStore is the interface for persisting alert rules and instances type RuleStore interface { + // TODO after deprecating namespace_id field in GettableGrafanaRule we can simplify this interface + // by returning map[string]struct{} instead of map[string]*folder.Folder GetUserVisibleNamespaces(context.Context, int64, identity.Requester) (map[string]*folder.Folder, error) - GetNamespaceByTitle(context.Context, string, int64, identity.Requester) (*folder.Folder, error) GetNamespaceByUID(ctx context.Context, uid string, orgID int64, user identity.Requester) (*folder.Folder, error) + GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmodels.GetAlertRulesGroupByRuleUIDQuery) ([]*ngmodels.AlertRule, error) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) (ngmodels.RulesGroup, error) @@ -23,5 +25,5 @@ type RuleStore interface { DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error // IncreaseVersionForAllRulesInNamespace Increases version for all rules that have specified namespace. Returns all rules that belong to the namespace - IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersionAndPauseStatus, error) + IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersion, error) } diff --git a/pkg/services/ngalert/api/provisioning.go b/pkg/services/ngalert/api/provisioning.go index f43e9bb8f7cdf..91d1a6da639ec 100644 --- a/pkg/services/ngalert/api/provisioning.go +++ b/pkg/services/ngalert/api/provisioning.go @@ -135,3 +135,7 @@ func (f *ProvisioningApiHandler) handleRouteExportMuteTiming(ctx *contextmodel.R func (f *ProvisioningApiHandler) handleRouteExportMuteTimings(ctx *contextmodel.ReqContext) response.Response { return f.svc.RouteGetMuteTimingsExport(ctx) } + +func (f *ProvisioningApiHandler) handleRouteDeleteAlertRuleGroup(ctx *contextmodel.ReqContext, folderUID, group string) response.Response { + return f.svc.RouteDeleteAlertRuleGroup(ctx, folderUID, group) +} diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl index 33f84709de6da..2cfb1ba5b0ef5 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl @@ -77,5 +77,14 @@ resource "grafana_rule_group" "rule_group_0000" { no_data_state = "NoData" exec_err_state = "Alerting" is_paused = false + + notification_settings { + receiver = "Test-Receiver" + group_by = ["alertname", "grafana_folder", "test"] + group_wait = "1s" + group_interval = "5s" + repeat_interval = "5m" + mute_time_intervals = ["test-mute"] + } } } diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.json b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.json index 2feec5bab39bb..6af0194cb8ab1 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.json +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.json @@ -109,7 +109,15 @@ "noDataState": "NoData", "execErrState": "Alerting", "for": "0s", - "isPaused": false + "isPaused": false, + "notification_settings":{ + "receiver":"Test-Receiver", + "group_by":["alertname","grafana_folder","test"], + "group_wait":"1s", + "group_interval":"5s", + "repeat_interval":"5m", + "mute_time_intervals":["test-mute"] + } } ] } diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.yaml b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.yaml index 9f87a264fc2b4..f91d13ec63555 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.yaml +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.yaml @@ -83,3 +83,14 @@ groups: execErrState: Alerting for: 0s isPaused: false + notification_settings: + receiver: Test-Receiver + group_by: + - alertname + - grafana_folder + - test + group_wait: 1s + group_interval: 5s + repeat_interval: 5m + mute_time_intervals: + - test-mute diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101.json b/pkg/services/ngalert/api/test-data/post-rulegroup-101.json index 61f7c1652894b..2871b1f578780 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101.json +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101.json @@ -109,7 +109,15 @@ } ], "no_data_state": "NoData", - "exec_err_state": "Alerting" + "exec_err_state": "Alerting", + "notification_settings":{ + "receiver":"Test-Receiver", + "group_by":["alertname","grafana_folder","test"], + "group_wait":"1s", + "group_interval":"5s", + "repeat_interval":"5m", + "mute_time_intervals":["test-mute"] + } } } ] diff --git a/pkg/services/ngalert/api/test-data/ruler-grafana-recipient.http b/pkg/services/ngalert/api/test-data/ruler-grafana-recipient.http index 33650932d5765..ff61d3b7b45b7 100644 --- a/pkg/services/ngalert/api/test-data/ruler-grafana-recipient.http +++ b/pkg/services/ngalert/api/test-data/ruler-grafana-recipient.http @@ -1,7 +1,7 @@ @grafanaRecipient = grafana -// should point to an existing folder named alerting -@namespace1 = foo%20bar +// should point to an existing folder UID +@namespace1 = baf2c548-5e1e-42e8-8fde-8320e50d801e // create group42 under unknown namespace - it should fail POST http://admin:admin@localhost:3000/api/ruler/{{grafanaRecipient}}/api/v1/rules/unknown diff --git a/pkg/services/ngalert/api/tooling/README.md b/pkg/services/ngalert/api/tooling/README.md index bee2a4dcb4f29..692deb5b8478a 100644 --- a/pkg/services/ngalert/api/tooling/README.md +++ b/pkg/services/ngalert/api/tooling/README.md @@ -19,10 +19,10 @@ Instead, we use a hybrid approach - we define the types in Golang, with comments ### Stability -We have some endpoints that we document publically as being stable, and others that we consider unstable. The stable endpoints are documented in `api.json`, where all endpoints are available in `post.json`. +We have some endpoints that we document publicly as being stable, and others that we consider unstable. The stable endpoints are documented in `api.json`, where all endpoints are available in `post.json`. To stabilize an endpoint, add the `stable` tag to its route comment: ``` -// swagger:route GET /api/provisioning/contact-points provisioning stable RouteGetContactpoints +// swagger:route GET /provisioning/contact-points provisioning stable RouteGetContactpoints ``` diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index 1242b4f33cb3b..26bbb2e880dd6 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -1,5 +1,5 @@ { - "basePath": "/api/v1", + "basePath": "/api", "consumes": [ "application/json" ], @@ -211,6 +211,9 @@ ], "type": "string" }, + "notification_settings": { + "$ref": "#/definitions/AlertRuleNotificationSettingsExport" + }, "panelId": { "format": "int64", "type": "integer" @@ -280,6 +283,90 @@ }, "type": "object" }, + "AlertRuleNotificationSettings": { + "properties": { + "group_by": { + "default": [ + "alertname", + "grafana_folder" + ], + "description": "Override the labels by which incoming alerts are grouped together. For example, multiple alerts coming in for\ncluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels\nuse the special value '...' as the sole label name.\nThis effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what\nyou want, unless you have a very low alert volume or your upstream notification system performs its own grouping.\nMust include 'alertname' and 'grafana_folder' if not using '...'.", + "example": [ + "alertname", + "grafana_folder", + "cluster" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "group_interval": { + "description": "Override how long to wait before sending a notification about new alerts that are added to a group of alerts for\nwhich an initial notification has already been sent. (Usually ~5m or more.)", + "example": "5m", + "type": "string" + }, + "group_wait": { + "description": "Override how long to initially wait to send a notification for a group of alerts. Allows to wait for an\ninhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.)", + "example": "30s", + "type": "string" + }, + "mute_time_intervals": { + "description": "Override the times when notifications should be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration mute_time_intervals section. When muted it will not send any notifications, but\notherwise acts normally.", + "example": [ + "maintenance" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "receiver": { + "description": "Name of the receiver to send notifications to.", + "example": "grafana-default-email", + "type": "string" + }, + "repeat_interval": { + "description": "Override how long to wait before sending a notification again if it has already been sent successfully for an\nalert. (Usually ~3h or more).\nNote that this parameter is implicitly bound by Alertmanager's `--data.retention` configuration flag.\nNotifications will be resent after either repeat_interval or the data retention period have passed, whichever\noccurs first. `repeat_interval` should not be less than `group_interval`.", + "example": "4h", + "type": "string" + } + }, + "required": [ + "receiver" + ], + "type": "object" + }, + "AlertRuleNotificationSettingsExport": { + "properties": { + "group_by": { + "items": { + "type": "string" + }, + "type": "array" + }, + "group_interval": { + "type": "string" + }, + "group_wait": { + "type": "string" + }, + "mute_time_intervals": { + "items": { + "type": "string" + }, + "type": "array" + }, + "receiver": { + "type": "string" + }, + "repeat_interval": { + "type": "string" + } + }, + "title": "AlertRuleNotificationSettingsExport is the provisioned export of models.NotificationSettings.", + "type": "object" + }, "AlertingFileExport": { "properties": { "apiVersion": { @@ -543,6 +630,7 @@ "type": "array" }, "mute_time_intervals": { + "description": "MuteTimeIntervals is deprecated and will be removed before Alertmanager 1.0.", "items": { "$ref": "#/definitions/MuteTimeInterval" }, @@ -556,6 +644,12 @@ "type": "string" }, "type": "array" + }, + "time_intervals": { + "items": { + "$ref": "#/definitions/TimeInterval" + }, + "type": "array" } }, "title": "Config is the top-level configuration for Alertmanager's config files.", @@ -651,6 +745,9 @@ }, "webhook_url": { "$ref": "#/definitions/SecretURL" + }, + "webhook_url_file": { + "type": "string" } }, "title": "DiscordConfig configures notifications via Discord.", @@ -1155,13 +1252,22 @@ }, "typeVersion": { "$ref": "#/definitions/FrameTypeVersion" + }, + "uniqueRowIdFields": { + "description": "Array of field indices which values create a unique id for each row. Ideally this should be globally unique ID\nbut that isn't guarantied. Should help with keeping track and deduplicating rows in visualizations, especially\nwith streaming data with frequent updates.", + "example": "TraceID in Tempo, table name + primary key in SQL", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" } }, "title": "FrameMeta matches:", "type": "object" }, "FrameType": { - "description": "A FrameType string, when present in a frame's metadata, asserts that the\nframe's structure conforms to the FrameType's specification.\nThis property is currently optional, so FrameType may be FrameTypeUnknown even if the properties of\nthe Frame correspond to a defined FrameType.", + "description": "A FrameType string, when present in a frame's metadata, asserts that the\nframe's structure conforms to the FrameType's specification.\nThis property is currently optional, so FrameType may be FrameTypeUnknown even if the properties of\nthe Frame correspond to a defined FrameType.\n+enum", "type": "string" }, "FrameTypeVersion": { @@ -1180,6 +1286,14 @@ "title": "Frames is a slice of Frame pointers.", "type": "array" }, + "GenericPublicError": { + "properties": { + "body": { + "$ref": "#/definitions/PublicError" + } + }, + "type": "object" + }, "GettableAlertmanagers": { "properties": { "data": { @@ -1209,6 +1323,7 @@ "type": "object" }, "mute_time_intervals": { + "description": "MuteTimeIntervals is deprecated and will be removed before Alertmanager 1.0.", "items": { "$ref": "#/definitions/MuteTimeInterval" }, @@ -1229,6 +1344,12 @@ "type": "string" }, "type": "array" + }, + "time_intervals": { + "items": { + "$ref": "#/definitions/TimeInterval" + }, + "type": "array" } }, "type": "object" @@ -1442,6 +1563,9 @@ ], "type": "string" }, + "notification_settings": { + "$ref": "#/definitions/AlertRuleNotificationSettings" + }, "orgId": { "format": "int64", "type": "integer" @@ -1558,6 +1682,23 @@ ], "type": "object" }, + "GettableTimeIntervals": { + "properties": { + "name": { + "type": "string" + }, + "provenance": { + "$ref": "#/definitions/Provenance" + }, + "time_intervals": { + "items": { + "$ref": "#/definitions/TimeIntervalItem" + }, + "type": "array" + } + }, + "type": "object" + }, "GettableUserConfig": { "properties": { "alertmanager_config": { @@ -1858,6 +1999,9 @@ "send_resolved": { "type": "boolean" }, + "summary": { + "type": "string" + }, "text": { "type": "string" }, @@ -1866,6 +2010,9 @@ }, "webhook_url": { "$ref": "#/definitions/SecretURL" + }, + "webhook_url_file": { + "type": "string" } }, "type": "object" @@ -2126,9 +2273,18 @@ "title": "OAuth2 is the oauth2 client configuration.", "type": "object" }, + "ObjectMatcher": { + "items": { + "type": "string" + }, + "title": "ObjectMatcher is a matcher that can be used to filter alerts.", + "type": "array" + }, "ObjectMatchers": { - "$ref": "#/definitions/Matchers", - "description": "ObjectMatchers is Matchers with a different Unmarshal and Marshal methods that accept matchers as objects\nthat have already been parsed." + "items": { + "$ref": "#/definitions/ObjectMatcher" + }, + "type": "array" }, "OpsGenieConfig": { "properties": { @@ -2308,25 +2464,8 @@ "PermissionDenied": { "type": "object" }, - "Point": { - "description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.", - "properties": { - "H": { - "$ref": "#/definitions/FloatHistogram" - }, - "T": { - "format": "int64", - "type": "integer" - }, - "V": { - "format": "double", - "type": "number" - } - }, - "title": "Point represents a single data point for a given timestamp.", - "type": "object" - }, "PostableApiAlertingConfig": { + "description": "nolint:revive", "properties": { "global": { "$ref": "#/definitions/GlobalConfig" @@ -2338,6 +2477,7 @@ "type": "array" }, "mute_time_intervals": { + "description": "MuteTimeIntervals is deprecated and will be removed before Alertmanager 1.0.", "items": { "$ref": "#/definitions/MuteTimeInterval" }, @@ -2358,11 +2498,18 @@ "type": "string" }, "type": "array" + }, + "time_intervals": { + "items": { + "$ref": "#/definitions/TimeInterval" + }, + "type": "array" } }, "type": "object" }, "PostableApiReceiver": { + "description": "nolint:revive", "properties": { "discord_configs": { "items": { @@ -2580,6 +2727,9 @@ ], "type": "string" }, + "notification_settings": { + "$ref": "#/definitions/AlertRuleNotificationSettings" + }, "title": { "type": "string" }, @@ -2619,6 +2769,20 @@ }, "type": "object" }, + "PostableTimeIntervals": { + "properties": { + "name": { + "type": "string" + }, + "time_intervals": { + "items": { + "$ref": "#/definitions/TimeIntervalItem" + }, + "type": "array" + } + }, + "type": "object" + }, "PostableUserConfig": { "properties": { "alertmanager_config": { @@ -2742,6 +2906,9 @@ ], "type": "string" }, + "notification_settings": { + "$ref": "#/definitions/AlertRuleNotificationSettings" + }, "orgID": { "format": "int64", "type": "integer" @@ -3422,7 +3589,12 @@ "type": "object" }, "Sample": { + "description": "Sample is a single sample belonging to a metric. It represents either a float\nsample or a histogram sample. If H is nil, it is a float sample. Otherwise,\nit is a histogram sample.", "properties": { + "F": { + "format": "double", + "type": "number" + }, "H": { "$ref": "#/definitions/FloatHistogram" }, @@ -3432,13 +3604,8 @@ "T": { "format": "int64", "type": "integer" - }, - "V": { - "format": "double", - "type": "number" } }, - "title": "Sample is a single sample belonging to a metric.", "type": "object" }, "Secret": { @@ -3931,7 +4098,21 @@ "type": "string" }, "TimeInterval": { - "description": "TimeInterval describes intervals of time. ContainsTime will tell you if a golang time is contained\nwithin the interval.", + "properties": { + "name": { + "type": "string" + }, + "time_intervals": { + "items": { + "$ref": "#/definitions/TimeInterval" + }, + "type": "array" + } + }, + "title": "TimeInterval represents a named set of time intervals for which a route should be muted.", + "type": "object" + }, + "TimeIntervalItem": { "properties": { "days_of_month": { "items": { @@ -3950,7 +4131,7 @@ }, "times": { "items": { - "$ref": "#/definitions/TimeRange" + "$ref": "#/definitions/TimeIntervalTimeRange" }, "type": "array" }, @@ -3969,6 +4150,17 @@ }, "type": "object" }, + "TimeIntervalTimeRange": { + "properties": { + "end_time": { + "type": "string" + }, + "start_time": { + "type": "string" + } + }, + "type": "object" + }, "TimeRange": { "description": "Redefining this to avoid an import cycle", "properties": { @@ -3984,6 +4176,7 @@ "type": "object" }, "URL": { + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "properties": { "ForceQuery": { "type": "boolean" @@ -4019,7 +4212,7 @@ "$ref": "#/definitions/Userinfo" } }, - "title": "URL is a custom URL type that allows validation at configuration load time.", + "title": "A URL represents a parsed URL (technically, a URI reference).", "type": "object" }, "UpdateRuleGroupResponse": { @@ -4072,7 +4265,7 @@ "type": "array" }, "Vector": { - "description": "Vector is basically only an alias for model.Samples, but the\ncontract is that in a Vector, all Samples have the same timestamp.", + "description": "Vector is basically only an alias for []Sample, but the contract is that\nin a Vector, all Samples have the same timestamp.", "items": { "$ref": "#/definitions/Sample" }, @@ -4225,6 +4418,7 @@ "type": "object" }, "alertGroup": { + "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -4462,12 +4656,14 @@ "type": "object" }, "gettableSilences": { + "description": "GettableSilences gettable silences", "items": { "$ref": "#/definitions/gettableSilence" }, "type": "array" }, "integration": { + "description": "Integration integration", "properties": { "lastNotifyAttempt": { "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", @@ -4611,7 +4807,6 @@ "type": "array" }, "postableSilence": { - "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -4769,7 +4964,7 @@ "version": "1.1.0" }, "paths": { - "/api/v1/provisioning/alert-rules": { + "/v1/provisioning/alert-rules": { "get": { "operationId": "RouteGetAlertRules", "responses": { @@ -4824,7 +5019,7 @@ ] } }, - "/api/v1/provisioning/alert-rules/export": { + "/v1/provisioning/alert-rules/export": { "get": { "operationId": "RouteGetAlertRulesExport", "parameters": [ @@ -4881,7 +5076,7 @@ ] } }, - "/api/v1/provisioning/alert-rules/{UID}": { + "/v1/provisioning/alert-rules/{UID}": { "delete": { "operationId": "RouteDeleteAlertRule", "parameters": [ @@ -4981,7 +5176,7 @@ ] } }, - "/api/v1/provisioning/alert-rules/{UID}/export": { + "/v1/provisioning/alert-rules/{UID}/export": { "get": { "operationId": "RouteGetAlertRuleExport", "parameters": [ @@ -5029,7 +5224,7 @@ ] } }, - "/api/v1/provisioning/contact-points": { + "/v1/provisioning/contact-points": { "get": { "operationId": "RouteGetContactpoints", "parameters": [ @@ -5092,7 +5287,7 @@ ] } }, - "/api/v1/provisioning/contact-points/export": { + "/v1/provisioning/contact-points/export": { "get": { "operationId": "RouteGetContactpointsExport", "parameters": [ @@ -5144,7 +5339,7 @@ ] } }, - "/api/v1/provisioning/contact-points/{UID}": { + "/v1/provisioning/contact-points/{UID}": { "delete": { "consumes": [ "application/json" @@ -5215,7 +5410,45 @@ ] } }, - "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": { + "/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": { + "delete": { + "description": "Delete rule group", + "operationId": "RouteDeleteAlertRuleGroup", + "parameters": [ + { + "in": "path", + "name": "FolderUID", + "required": true, + "type": "string" + }, + { + "in": "path", + "name": "Group", + "required": true, + "type": "string" + } + ], + "responses": { + "204": { + "description": " The alert rule group was deleted successfully." + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + }, + "tags": [ + "provisioning" + ] + }, "get": { "operationId": "RouteGetAlertRuleGroup", "parameters": [ @@ -5299,7 +5532,7 @@ ] } }, - "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export": { + "/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export": { "get": { "operationId": "RouteGetAlertRuleGroupExport", "parameters": [ @@ -5352,7 +5585,7 @@ ] } }, - "/api/v1/provisioning/mute-timings": { + "/v1/provisioning/mute-timings": { "get": { "operationId": "RouteGetMuteTimings", "responses": { @@ -5407,7 +5640,7 @@ ] } }, - "/api/v1/provisioning/mute-timings/export": { + "/v1/provisioning/mute-timings/export": { "get": { "operationId": "RouteExportMuteTimings", "parameters": [ @@ -5446,7 +5679,7 @@ ] } }, - "/api/v1/provisioning/mute-timings/{name}": { + "/v1/provisioning/mute-timings/{name}": { "delete": { "operationId": "RouteDeleteMuteTiming", "parameters": [ @@ -5461,6 +5694,12 @@ "responses": { "204": { "description": " The mute timing was deleted successfully." + }, + "409": { + "description": "GenericPublicError", + "schema": { + "$ref": "#/definitions/GenericPublicError" + } } }, "summary": "Delete a mute timing.", @@ -5522,7 +5761,7 @@ } ], "responses": { - "200": { + "202": { "description": "MuteTimeInterval", "schema": { "$ref": "#/definitions/MuteTimeInterval" @@ -5541,7 +5780,7 @@ ] } }, - "/api/v1/provisioning/mute-timings/{name}/export": { + "/v1/provisioning/mute-timings/{name}/export": { "get": { "operationId": "RouteExportMuteTiming", "parameters": [ @@ -5587,7 +5826,7 @@ ] } }, - "/api/v1/provisioning/policies": { + "/v1/provisioning/policies": { "delete": { "consumes": [ "application/json" @@ -5661,7 +5900,7 @@ ] } }, - "/api/v1/provisioning/policies/export": { + "/v1/provisioning/policies/export": { "get": { "operationId": "RouteGetPolicyTreeExport", "responses": { @@ -5684,7 +5923,7 @@ ] } }, - "/api/v1/provisioning/templates": { + "/v1/provisioning/templates": { "get": { "operationId": "RouteGetTemplates", "responses": { @@ -5704,7 +5943,7 @@ ] } }, - "/api/v1/provisioning/templates/{name}": { + "/v1/provisioning/templates/{name}": { "delete": { "operationId": "RouteDeleteTemplate", "parameters": [ @@ -5804,6 +6043,36 @@ "application/json" ], "responses": { + "GetAllIntervalsResponse": { + "description": "", + "schema": { + "items": { + "$ref": "#/definitions/GettableTimeIntervals" + }, + "type": "array" + } + }, + "GetIntervalsByNameResponse": { + "description": "", + "schema": { + "$ref": "#/definitions/GettableTimeIntervals" + } + }, + "GetReceiverResponse": { + "description": "", + "schema": { + "$ref": "#/definitions/GettableApiReceiver" + } + }, + "GetReceiversResponse": { + "description": "", + "schema": { + "items": { + "$ref": "#/definitions/GettableApiReceiver" + }, + "type": "array" + } + }, "GettableHistoricUserConfigs": { "description": "", "schema": { diff --git a/pkg/services/ngalert/api/tooling/definitions/admin.go b/pkg/services/ngalert/api/tooling/definitions/admin.go index 25488d1fb6e75..3c7e3ef394456 100644 --- a/pkg/services/ngalert/api/tooling/definitions/admin.go +++ b/pkg/services/ngalert/api/tooling/definitions/admin.go @@ -4,7 +4,7 @@ import ( v1 "github.com/prometheus/client_golang/api/prometheus/v1" ) -// swagger:route GET /api/v1/ngalert configuration RouteGetStatus +// swagger:route GET /v1/ngalert configuration RouteGetStatus // // Get the status of the alerting engine // @@ -14,7 +14,7 @@ import ( // Responses: // 200: AlertingStatus -// swagger:route GET /api/v1/ngalert/alertmanagers configuration RouteGetAlertmanagers +// swagger:route GET /v1/ngalert/alertmanagers configuration RouteGetAlertmanagers // // Get the discovered and dropped Alertmanagers of the user's organization based on the specified configuration. // @@ -24,7 +24,7 @@ import ( // Responses: // 200: GettableAlertmanagers -// swagger:route GET /api/v1/ngalert/admin_config configuration RouteGetNGalertConfig +// swagger:route GET /v1/ngalert/admin_config configuration RouteGetNGalertConfig // // Get the NGalert configuration of the user's organization, returns 404 if no configuration is present. // @@ -36,7 +36,7 @@ import ( // 404: Failure // 500: Failure -// swagger:route POST /api/v1/ngalert/admin_config configuration RoutePostNGalertConfig +// swagger:route POST /v1/ngalert/admin_config configuration RoutePostNGalertConfig // // Creates or updates the NGalert configuration of the user's organization. If no value is sent for alertmanagersChoice, it defaults to "all". // @@ -47,7 +47,7 @@ import ( // 201: Ack // 400: ValidationError -// swagger:route DELETE /api/v1/ngalert/admin_config configuration RouteDeleteNGalertConfig +// swagger:route DELETE /v1/ngalert/admin_config configuration RouteDeleteNGalertConfig // // Deletes the NGalert configuration of the user's organization. // diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index 9e3d3a28c1434..3a9b6cc09b47e 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -4,20 +4,17 @@ import ( "context" "encoding/json" "fmt" - "reflect" - "sort" - "strings" "time" "github.com/go-openapi/strfmt" + "github.com/grafana/alerting/definition" amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/config" - "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/common/model" "gopkg.in/yaml.v3" ) -// swagger:route POST /api/alertmanager/grafana/config/api/v1/alerts alertmanager RoutePostGrafanaAlertingConfig +// swagger:route POST /alertmanager/grafana/config/api/v1/alerts alertmanager RoutePostGrafanaAlertingConfig // // sets an Alerting config // @@ -25,7 +22,7 @@ import ( // 201: Ack // 400: ValidationError -// swagger:route POST /api/alertmanager/{DatasourceUID}/config/api/v1/alerts alertmanager RoutePostAlertingConfig +// swagger:route POST /alertmanager/{DatasourceUID}/config/api/v1/alerts alertmanager RoutePostAlertingConfig // // sets an Alerting config // @@ -34,7 +31,7 @@ import ( // 400: ValidationError // 404: NotFound -// swagger:route GET /api/alertmanager/grafana/config/api/v1/alerts alertmanager RouteGetGrafanaAlertingConfig +// swagger:route GET /alertmanager/grafana/config/api/v1/alerts alertmanager RouteGetGrafanaAlertingConfig // // gets an Alerting config // @@ -42,7 +39,7 @@ import ( // 200: GettableUserConfig // 400: ValidationError -// swagger:route GET /api/alertmanager/{DatasourceUID}/config/api/v1/alerts alertmanager RouteGetAlertingConfig +// swagger:route GET /alertmanager/{DatasourceUID}/config/api/v1/alerts alertmanager RouteGetAlertingConfig // // gets an Alerting config // @@ -51,14 +48,14 @@ import ( // 400: ValidationError // 404: NotFound -// swagger:route GET /api/alertmanager/grafana/config/history alertmanager RouteGetGrafanaAlertingConfigHistory +// swagger:route GET /alertmanager/grafana/config/history alertmanager RouteGetGrafanaAlertingConfigHistory // // gets Alerting configurations that were successfully applied in the past // // Responses: // 200: GettableHistoricUserConfigs -// swagger:route POST /api/alertmanager/grafana/config/history/{id}/_activate alertmanager RoutePostGrafanaAlertingConfigHistoryActivate +// swagger:route POST /alertmanager/grafana/config/history/{id}/_activate alertmanager RoutePostGrafanaAlertingConfigHistoryActivate // // revert Alerting configuration to the historical configuration specified by the given id // @@ -67,7 +64,7 @@ import ( // 400: ValidationError // 404: NotFound -// swagger:route DELETE /api/alertmanager/grafana/config/api/v1/alerts alertmanager RouteDeleteGrafanaAlertingConfig +// swagger:route DELETE /alertmanager/grafana/config/api/v1/alerts alertmanager RouteDeleteGrafanaAlertingConfig // // deletes the Alerting config for a tenant // @@ -75,7 +72,7 @@ import ( // 200: Ack // 400: ValidationError -// swagger:route DELETE /api/alertmanager/{DatasourceUID}/config/api/v1/alerts alertmanager RouteDeleteAlertingConfig +// swagger:route DELETE /alertmanager/{DatasourceUID}/config/api/v1/alerts alertmanager RouteDeleteAlertingConfig // // deletes the Alerting config for a tenant // @@ -84,7 +81,7 @@ import ( // 400: ValidationError // 404: NotFound -// swagger:route GET /api/alertmanager/grafana/api/v2/status alertmanager RouteGetGrafanaAMStatus +// swagger:route GET /alertmanager/grafana/api/v2/status alertmanager RouteGetGrafanaAMStatus // // get alertmanager status and configuration // @@ -92,7 +89,7 @@ import ( // 200: GettableStatus // 400: ValidationError -// swagger:route GET /api/alertmanager/{DatasourceUID}/api/v2/status alertmanager RouteGetAMStatus +// swagger:route GET /alertmanager/{DatasourceUID}/api/v2/status alertmanager RouteGetAMStatus // // get alertmanager status and configuration // @@ -101,7 +98,7 @@ import ( // 400: ValidationError // 404: NotFound -// swagger:route GET /api/alertmanager/grafana/api/v2/alerts alertmanager RouteGetGrafanaAMAlerts +// swagger:route GET /alertmanager/grafana/api/v2/alerts alertmanager RouteGetGrafanaAMAlerts // // get alertmanager alerts // @@ -109,7 +106,7 @@ import ( // 200: gettableAlerts // 400: ValidationError -// swagger:route GET /api/alertmanager/{DatasourceUID}/api/v2/alerts alertmanager RouteGetAMAlerts +// swagger:route GET /alertmanager/{DatasourceUID}/api/v2/alerts alertmanager RouteGetAMAlerts // // get alertmanager alerts // @@ -118,7 +115,7 @@ import ( // 400: ValidationError // 404: NotFound -// swagger:route POST /api/alertmanager/{DatasourceUID}/api/v2/alerts alertmanager RoutePostAMAlerts +// swagger:route POST /alertmanager/{DatasourceUID}/api/v2/alerts alertmanager RoutePostAMAlerts // // create alertmanager alerts // @@ -127,7 +124,7 @@ import ( // 400: ValidationError // 404: NotFound -// swagger:route GET /api/alertmanager/grafana/api/v2/alerts/groups alertmanager RouteGetGrafanaAMAlertGroups +// swagger:route GET /alertmanager/grafana/api/v2/alerts/groups alertmanager RouteGetGrafanaAMAlertGroups // // get alertmanager alerts // @@ -135,7 +132,7 @@ import ( // 200: alertGroups // 400: ValidationError -// swagger:route GET /api/alertmanager/{DatasourceUID}/api/v2/alerts/groups alertmanager RouteGetAMAlertGroups +// swagger:route GET /alertmanager/{DatasourceUID}/api/v2/alerts/groups alertmanager RouteGetAMAlertGroups // // get alertmanager alerts // @@ -144,14 +141,14 @@ import ( // 400: ValidationError // 404: NotFound -// swagger:route GET /api/alertmanager/grafana/config/api/v1/receivers alertmanager RouteGetGrafanaReceivers +// swagger:route GET /alertmanager/grafana/config/api/v1/receivers alertmanager RouteGetGrafanaReceivers // // Get a list of all receivers // // Responses: // 200: receiversResponse -// swagger:route POST /api/alertmanager/grafana/config/api/v1/receivers/test alertmanager RoutePostTestGrafanaReceivers +// swagger:route POST /alertmanager/grafana/config/api/v1/receivers/test alertmanager RoutePostTestGrafanaReceivers // // Test Grafana managed receivers without saving them. // @@ -165,7 +162,7 @@ import ( // 408: Failure // 409: AlertManagerNotReady -// swagger:route POST /api/alertmanager/grafana/config/api/v1/templates/test alertmanager RoutePostTestGrafanaTemplates +// swagger:route POST /alertmanager/grafana/config/api/v1/templates/test alertmanager RoutePostTestGrafanaTemplates // // Test Grafana managed templates without saving them. // Produces: @@ -178,7 +175,7 @@ import ( // 403: PermissionDenied // 409: AlertManagerNotReady -// swagger:route GET /api/alertmanager/grafana/api/v2/silences alertmanager RouteGetGrafanaSilences +// swagger:route GET /alertmanager/grafana/api/v2/silences alertmanager RouteGetGrafanaSilences // // get silences // @@ -186,7 +183,7 @@ import ( // 200: gettableSilences // 400: ValidationError -// swagger:route GET /api/alertmanager/{DatasourceUID}/api/v2/silences alertmanager RouteGetSilences +// swagger:route GET /alertmanager/{DatasourceUID}/api/v2/silences alertmanager RouteGetSilences // // get silences // @@ -195,7 +192,7 @@ import ( // 400: ValidationError // 404: NotFound -// swagger:route POST /api/alertmanager/grafana/api/v2/silences alertmanager RouteCreateGrafanaSilence +// swagger:route POST /alertmanager/grafana/api/v2/silences alertmanager RouteCreateGrafanaSilence // // create silence // @@ -203,7 +200,7 @@ import ( // 202: postSilencesOKBody // 400: ValidationError -// swagger:route POST /api/alertmanager/{DatasourceUID}/api/v2/silences alertmanager RouteCreateSilence +// swagger:route POST /alertmanager/{DatasourceUID}/api/v2/silences alertmanager RouteCreateSilence // // create silence // @@ -212,7 +209,7 @@ import ( // 400: ValidationError // 404: NotFound -// swagger:route GET /api/alertmanager/grafana/api/v2/silence/{SilenceId} alertmanager RouteGetGrafanaSilence +// swagger:route GET /alertmanager/grafana/api/v2/silence/{SilenceId} alertmanager RouteGetGrafanaSilence // // get silence // @@ -220,7 +217,7 @@ import ( // 200: gettableSilence // 400: ValidationError -// swagger:route GET /api/alertmanager/{DatasourceUID}/api/v2/silence/{SilenceId} alertmanager RouteGetSilence +// swagger:route GET /alertmanager/{DatasourceUID}/api/v2/silence/{SilenceId} alertmanager RouteGetSilence // // get silence // @@ -229,7 +226,7 @@ import ( // 400: ValidationError // 404: NotFound -// swagger:route DELETE /api/alertmanager/grafana/api/v2/silence/{SilenceId} alertmanager RouteDeleteGrafanaSilence +// swagger:route DELETE /alertmanager/grafana/api/v2/silence/{SilenceId} alertmanager RouteDeleteGrafanaSilence // // delete silence // @@ -237,7 +234,7 @@ import ( // 200: Ack // 400: ValidationError -// swagger:route DELETE /api/alertmanager/{DatasourceUID}/api/v2/silence/{SilenceId} alertmanager RouteDeleteSilence +// swagger:route DELETE /alertmanager/{DatasourceUID}/api/v2/silence/{SilenceId} alertmanager RouteDeleteSilence // // delete silence // @@ -246,6 +243,31 @@ import ( // 400: ValidationError // 404: NotFound +// Alias all the needed Alertmanager types, functions and constants so that they can be imported directly from grafana/alerting +// without having to modify any of the usage within Grafana. +type ( + Config = definition.Config + Route = definition.Route + PostableGrafanaReceiver = definition.PostableGrafanaReceiver + PostableApiAlertingConfig = definition.PostableApiAlertingConfig + RawMessage = definition.RawMessage + Provenance = definition.Provenance + ObjectMatchers = definition.ObjectMatchers + PostableApiReceiver = definition.PostableApiReceiver + PostableGrafanaReceivers = definition.PostableGrafanaReceivers + ReceiverType = definition.ReceiverType +) + +const ( + GrafanaReceiverType = definition.GrafanaReceiverType + AlertmanagerReceiverType = definition.AlertmanagerReceiverType +) + +var ( + AsGrafanaRoute = definition.AsGrafanaRoute + AllReceivers = definition.AllReceivers +) + // swagger:model type PermissionDenied struct{} @@ -646,8 +668,6 @@ func (c *PostableUserConfig) UnmarshalYAML(value *yaml.Node) error { return nil } -type Provenance string - // swagger:model type GettableUserConfig struct { TemplateFiles map[string]string `yaml:"template_files" json:"template_files"` @@ -735,233 +755,22 @@ type GettableApiAlertingConfig struct { Receivers []*GettableApiReceiver `yaml:"receivers,omitempty" json:"receivers,omitempty"` } -func (c *GettableApiAlertingConfig) UnmarshalJSON(b []byte) error { - type plain GettableApiAlertingConfig - if err := json.Unmarshal(b, (*plain)(c)); err != nil { - return err - } - - // Since Config implements json.Unmarshaler, we must handle _all_ other fields independently. - // Otherwise, the json decoder will detect this and only use the embedded type. - // Additionally, we'll use pointers to slices in order to reference the intended target. - type overrides struct { - Receivers *[]*GettableApiReceiver `yaml:"receivers,omitempty" json:"receivers,omitempty"` - } - - if err := json.Unmarshal(b, &overrides{Receivers: &c.Receivers}); err != nil { - return err - } - - return c.validate() -} - -// validate ensures that the two routing trees use the correct receiver types. -func (c *GettableApiAlertingConfig) validate() error { - receivers := make(map[string]struct{}, len(c.Receivers)) - - var hasGrafReceivers, hasAMReceivers bool - for _, r := range c.Receivers { - receivers[r.Name] = struct{}{} - switch r.Type() { - case GrafanaReceiverType: - hasGrafReceivers = true - case AlertmanagerReceiverType: - hasAMReceivers = true - default: - continue - } - } - - if hasGrafReceivers && hasAMReceivers { - return fmt.Errorf("cannot mix Alertmanager & Grafana receiver types") - } - - for _, receiver := range AllReceivers(c.Route.AsAMRoute()) { - _, ok := receivers[receiver] - if !ok { - return fmt.Errorf("unexpected receiver (%s) is undefined", receiver) - } - } - - return nil -} - -// Config is the top-level configuration for Alertmanager's config files. -type Config struct { - Global *config.GlobalConfig `yaml:"global,omitempty" json:"global,omitempty"` - Route *Route `yaml:"route,omitempty" json:"route,omitempty"` - InhibitRules []config.InhibitRule `yaml:"inhibit_rules,omitempty" json:"inhibit_rules,omitempty"` - MuteTimeIntervals []config.MuteTimeInterval `yaml:"mute_time_intervals,omitempty" json:"mute_time_intervals,omitempty"` - Templates []string `yaml:"templates" json:"templates"` -} - -// A Route is a node that contains definitions of how to handle alerts. This is modified -// from the upstream alertmanager in that it adds the ObjectMatchers property. -type Route struct { - Receiver string `yaml:"receiver,omitempty" json:"receiver,omitempty"` - - GroupByStr []string `yaml:"group_by,omitempty" json:"group_by,omitempty"` - GroupBy []model.LabelName `yaml:"-" json:"-"` - GroupByAll bool `yaml:"-" json:"-"` - // Deprecated. Remove before v1.0 release. - Match map[string]string `yaml:"match,omitempty" json:"match,omitempty"` - // Deprecated. Remove before v1.0 release. - MatchRE config.MatchRegexps `yaml:"match_re,omitempty" json:"match_re,omitempty"` - Matchers config.Matchers `yaml:"matchers,omitempty" json:"matchers,omitempty"` - ObjectMatchers ObjectMatchers `yaml:"object_matchers,omitempty" json:"object_matchers,omitempty"` - MuteTimeIntervals []string `yaml:"mute_time_intervals,omitempty" json:"mute_time_intervals,omitempty"` - Continue bool `yaml:"continue" json:"continue,omitempty"` - Routes []*Route `yaml:"routes,omitempty" json:"routes,omitempty"` - - GroupWait *model.Duration `yaml:"group_wait,omitempty" json:"group_wait,omitempty"` - GroupInterval *model.Duration `yaml:"group_interval,omitempty" json:"group_interval,omitempty"` - RepeatInterval *model.Duration `yaml:"repeat_interval,omitempty" json:"repeat_interval,omitempty"` - - Provenance Provenance `yaml:"provenance,omitempty" json:"provenance,omitempty"` -} - -// UnmarshalYAML implements the yaml.Unmarshaler interface for Route. This is a copy of alertmanager's upstream except it removes validation on the label key. -func (r *Route) UnmarshalYAML(unmarshal func(interface{}) error) error { - type plain Route - if err := unmarshal((*plain)(r)); err != nil { - return err - } - - return r.validateChild() -} - -// AsAMRoute returns an Alertmanager route from a Grafana route. The ObjectMatchers are converted to Matchers. -func (r *Route) AsAMRoute() *config.Route { - amRoute := &config.Route{ - Receiver: r.Receiver, - GroupByStr: r.GroupByStr, - GroupBy: r.GroupBy, - GroupByAll: r.GroupByAll, - Match: r.Match, - MatchRE: r.MatchRE, - Matchers: append(r.Matchers, r.ObjectMatchers...), - MuteTimeIntervals: r.MuteTimeIntervals, - Continue: r.Continue, - - GroupWait: r.GroupWait, - GroupInterval: r.GroupInterval, - RepeatInterval: r.RepeatInterval, - - Routes: make([]*config.Route, 0, len(r.Routes)), - } - for _, rt := range r.Routes { - amRoute.Routes = append(amRoute.Routes, rt.AsAMRoute()) - } - - return amRoute +func (c *GettableApiAlertingConfig) GetReceivers() []*GettableApiReceiver { + return c.Receivers } -// AsGrafanaRoute returns a Grafana route from an Alertmanager route. The Matchers are converted to ObjectMatchers. -func AsGrafanaRoute(r *config.Route) *Route { - gRoute := &Route{ - Receiver: r.Receiver, - GroupByStr: r.GroupByStr, - GroupBy: r.GroupBy, - GroupByAll: r.GroupByAll, - Match: r.Match, - MatchRE: r.MatchRE, - ObjectMatchers: ObjectMatchers(r.Matchers), - MuteTimeIntervals: r.MuteTimeIntervals, - Continue: r.Continue, - - GroupWait: r.GroupWait, - GroupInterval: r.GroupInterval, - RepeatInterval: r.RepeatInterval, - - Routes: make([]*Route, 0, len(r.Routes)), - } - for _, rt := range r.Routes { - gRoute.Routes = append(gRoute.Routes, AsGrafanaRoute(rt)) - } - - return gRoute +func (c *GettableApiAlertingConfig) GetMuteTimeIntervals() []config.MuteTimeInterval { + return c.MuteTimeIntervals } -func (r *Route) ResourceType() string { - return "route" -} +func (c *GettableApiAlertingConfig) GetTimeIntervals() []config.TimeInterval { return c.TimeIntervals } -func (r *Route) ResourceID() string { - return "" +func (c *GettableApiAlertingConfig) GetRoute() *Route { + return c.Route } -// Config is the entrypoint for the embedded Alertmanager config with the exception of receivers. -// Prometheus historically uses yaml files as the method of configuration and thus some -// post-validation is included in the UnmarshalYAML method. Here we simply run this with -// a noop unmarshaling function in order to benefit from said validation. -func (c *Config) UnmarshalJSON(b []byte) error { - type plain Config - if err := json.Unmarshal(b, (*plain)(c)); err != nil { - return err - } - - noopUnmarshal := func(_ interface{}) error { return nil } - - if c.Global != nil { - if err := c.Global.UnmarshalYAML(noopUnmarshal); err != nil { - return err - } - } - - if c.Route == nil { - return fmt.Errorf("no routes provided") - } - - err := c.Route.Validate() - if err != nil { - return err - } - - for _, r := range c.InhibitRules { - if err := r.UnmarshalYAML(noopUnmarshal); err != nil { - return err - } - } - - tiNames := make(map[string]struct{}) - for _, mt := range c.MuteTimeIntervals { - if mt.Name == "" { - return fmt.Errorf("missing name in mute time interval") - } - if _, ok := tiNames[mt.Name]; ok { - return fmt.Errorf("mute time interval %q is not unique", mt.Name) - } - tiNames[mt.Name] = struct{}{} - } - return checkTimeInterval(c.Route, tiNames) -} - -func checkTimeInterval(r *Route, timeIntervals map[string]struct{}) error { - for _, sr := range r.Routes { - if err := checkTimeInterval(sr, timeIntervals); err != nil { - return err - } - } - if len(r.MuteTimeIntervals) == 0 { - return nil - } - for _, mt := range r.MuteTimeIntervals { - if _, ok := timeIntervals[mt]; !ok { - return fmt.Errorf("undefined time interval %q used in route", mt) - } - } - return nil -} - -type PostableApiAlertingConfig struct { - Config `yaml:",inline"` - - // Override with our superset receiver type - Receivers []*PostableApiReceiver `yaml:"receivers,omitempty" json:"receivers,omitempty"` -} - -func (c *PostableApiAlertingConfig) UnmarshalJSON(b []byte) error { - type plain PostableApiAlertingConfig +func (c *GettableApiAlertingConfig) UnmarshalJSON(b []byte) error { + type plain GettableApiAlertingConfig if err := json.Unmarshal(b, (*plain)(c)); err != nil { return err } @@ -970,7 +779,7 @@ func (c *PostableApiAlertingConfig) UnmarshalJSON(b []byte) error { // Otherwise, the json decoder will detect this and only use the embedded type. // Additionally, we'll use pointers to slices in order to reference the intended target. type overrides struct { - Receivers *[]*PostableApiReceiver `yaml:"receivers,omitempty" json:"receivers,omitempty"` + Receivers *[]*GettableApiReceiver `yaml:"receivers,omitempty" json:"receivers,omitempty"` } if err := json.Unmarshal(b, &overrides{Receivers: &c.Receivers}); err != nil { @@ -981,7 +790,7 @@ func (c *PostableApiAlertingConfig) UnmarshalJSON(b []byte) error { } // validate ensures that the two routing trees use the correct receiver types. -func (c *PostableApiAlertingConfig) validate() error { +func (c *GettableApiAlertingConfig) validate() error { receivers := make(map[string]struct{}, len(c.Receivers)) var hasGrafReceivers, hasAMReceivers bool @@ -1001,21 +810,6 @@ func (c *PostableApiAlertingConfig) validate() error { return fmt.Errorf("cannot mix Alertmanager & Grafana receiver types") } - if hasGrafReceivers { - // Taken from https://github.com/prometheus/alertmanager/blob/master/config/config.go#L170-L191 - // Check if we have a root route. We cannot check for it in the - // UnmarshalYAML method because it won't be called if the input is empty - // (e.g. the config file is empty or only contains whitespace). - if c.Route == nil { - return fmt.Errorf("no route provided in config") - } - - // Check if continue in root route. - if c.Route.Continue { - return fmt.Errorf("cannot have continue in root route") - } - } - for _, receiver := range AllReceivers(c.Route.AsAMRoute()) { _, ok := receivers[receiver] if !ok { @@ -1026,80 +820,6 @@ func (c *PostableApiAlertingConfig) validate() error { return nil } -// Type requires validate has been called and just checks the first receiver type -func (c *PostableApiAlertingConfig) ReceiverType() ReceiverType { - for _, r := range c.Receivers { - switch r.Type() { - case GrafanaReceiverType: - return GrafanaReceiverType - case AlertmanagerReceiverType: - return AlertmanagerReceiverType - default: - continue - } - } - return EmptyReceiverType -} - -// AllReceivers will recursively walk a routing tree and return a list of all the -// referenced receiver names. -func AllReceivers(route *config.Route) (res []string) { - if route == nil { - return res - } - - if route.Receiver != "" { - res = append(res, route.Receiver) - } - - for _, subRoute := range route.Routes { - res = append(res, AllReceivers(subRoute)...) - } - return res -} - -type RawMessage json.RawMessage // This type alias adds YAML marshaling to the json.RawMessage. - -// MarshalJSON returns m as the JSON encoding of m. -func (r RawMessage) MarshalJSON() ([]byte, error) { - return json.Marshal(json.RawMessage(r)) -} - -func (r *RawMessage) UnmarshalJSON(data []byte) error { - var raw json.RawMessage - err := json.Unmarshal(data, &raw) - if err != nil { - return err - } - *r = RawMessage(raw) - return nil -} - -func (r *RawMessage) UnmarshalYAML(unmarshal func(interface{}) error) error { - var data interface{} - if err := unmarshal(&data); err != nil { - return err - } - bytes, err := json.Marshal(data) - if err != nil { - return err - } - *r = bytes - return nil -} - -func (r RawMessage) MarshalYAML() (interface{}, error) { - if r == nil { - return nil, nil - } - var d interface{} - err := json.Unmarshal(r, &d) - if err != nil { - return nil, err - } - return d, nil -} - type GettableGrafanaReceiver struct { UID string `json:"uid"` Name string `json:"name"` @@ -1110,41 +830,6 @@ type GettableGrafanaReceiver struct { Provenance Provenance `json:"provenance,omitempty"` } -type PostableGrafanaReceiver struct { - UID string `json:"uid"` - Name string `json:"name"` - Type string `json:"type"` - DisableResolveMessage bool `json:"disableResolveMessage"` - Settings RawMessage `json:"settings,omitempty"` - SecureSettings map[string]string `json:"secureSettings"` -} - -type ReceiverType int - -const ( - GrafanaReceiverType ReceiverType = 1 << iota - AlertmanagerReceiverType - EmptyReceiverType = GrafanaReceiverType | AlertmanagerReceiverType -) - -func (r ReceiverType) String() string { - switch r { - case GrafanaReceiverType: - return "grafana" - case AlertmanagerReceiverType: - return "alertmanager" - case EmptyReceiverType: - return "empty" - default: - return "unknown" - } -} - -// Can determines whether a receiver type can implement another receiver type. -// This is useful as receivers with just names but no contact points -// are valid in all backends. -func (r ReceiverType) Can(other ReceiverType) bool { return r&other != 0 } - type GettableApiReceiver struct { config.Receiver `yaml:",inline"` GettableGrafanaReceivers `yaml:",inline"` @@ -1195,186 +880,12 @@ func (r *GettableApiReceiver) Type() ReceiverType { return AlertmanagerReceiverType } -type PostableApiReceiver struct { - config.Receiver `yaml:",inline"` - PostableGrafanaReceivers `yaml:",inline"` -} - -func (r *PostableApiReceiver) UnmarshalYAML(unmarshal func(interface{}) error) error { - if err := unmarshal(&r.PostableGrafanaReceivers); err != nil { - return err - } - - if err := unmarshal(&r.Receiver); err != nil { - return err - } - - return nil -} - -func (r *PostableApiReceiver) UnmarshalJSON(b []byte) error { - type plain PostableApiReceiver - if err := json.Unmarshal(b, (*plain)(r)); err != nil { - return err - } - - hasGrafanaReceivers := len(r.PostableGrafanaReceivers.GrafanaManagedReceivers) > 0 - - if hasGrafanaReceivers { - if len(r.EmailConfigs) > 0 { - return fmt.Errorf("cannot have both Alertmanager EmailConfigs & Grafana receivers together") - } - if len(r.PagerdutyConfigs) > 0 { - return fmt.Errorf("cannot have both Alertmanager PagerdutyConfigs & Grafana receivers together") - } - if len(r.SlackConfigs) > 0 { - return fmt.Errorf("cannot have both Alertmanager SlackConfigs & Grafana receivers together") - } - if len(r.WebhookConfigs) > 0 { - return fmt.Errorf("cannot have both Alertmanager WebhookConfigs & Grafana receivers together") - } - if len(r.OpsGenieConfigs) > 0 { - return fmt.Errorf("cannot have both Alertmanager OpsGenieConfigs & Grafana receivers together") - } - if len(r.WechatConfigs) > 0 { - return fmt.Errorf("cannot have both Alertmanager WechatConfigs & Grafana receivers together") - } - if len(r.PushoverConfigs) > 0 { - return fmt.Errorf("cannot have both Alertmanager PushoverConfigs & Grafana receivers together") - } - if len(r.VictorOpsConfigs) > 0 { - return fmt.Errorf("cannot have both Alertmanager VictorOpsConfigs & Grafana receivers together") - } - } - return nil -} - -func (r *PostableApiReceiver) Type() ReceiverType { - if len(r.PostableGrafanaReceivers.GrafanaManagedReceivers) > 0 { - return GrafanaReceiverType - } - - cpy := r.Receiver - cpy.Name = "" - if reflect.ValueOf(cpy).IsZero() { - return EmptyReceiverType - } - - return AlertmanagerReceiverType +func (r *GettableApiReceiver) GetName() string { + return r.Receiver.Name } type GettableGrafanaReceivers struct { GrafanaManagedReceivers []*GettableGrafanaReceiver `yaml:"grafana_managed_receiver_configs,omitempty" json:"grafana_managed_receiver_configs,omitempty"` } -type PostableGrafanaReceivers struct { - GrafanaManagedReceivers []*PostableGrafanaReceiver `yaml:"grafana_managed_receiver_configs,omitempty" json:"grafana_managed_receiver_configs,omitempty"` -} - type EncryptFn func(ctx context.Context, payload []byte) ([]byte, error) - -// ObjectMatchers is Matchers with a different Unmarshal and Marshal methods that accept matchers as objects -// that have already been parsed. -type ObjectMatchers labels.Matchers - -// UnmarshalYAML implements the yaml.Unmarshaler interface for Matchers. -func (m *ObjectMatchers) UnmarshalYAML(unmarshal func(interface{}) error) error { - var rawMatchers [][3]string - if err := unmarshal(&rawMatchers); err != nil { - return err - } - for _, rawMatcher := range rawMatchers { - var matchType labels.MatchType - switch rawMatcher[1] { - case "=": - matchType = labels.MatchEqual - case "!=": - matchType = labels.MatchNotEqual - case "=~": - matchType = labels.MatchRegexp - case "!~": - matchType = labels.MatchNotRegexp - default: - return fmt.Errorf("unsupported match type %q in matcher", rawMatcher[1]) - } - - // When Prometheus serializes a matcher, the value gets wrapped in quotes: - // https://github.com/prometheus/alertmanager/blob/main/pkg/labels/matcher.go#L77 - // Remove these quotes so that we are matching against the right value. - // - // This is a stop-gap solution which will be superceded by https://github.com/grafana/grafana/issues/50040. - // - // The ngalert migration converts matchers into the Prom-style, quotes included. - // The UI then stores the quotes into ObjectMatchers without removing them. - // This approach allows these extra quotes to be stored in the database, and fixes them at read time. - // This works because the database stores matchers as JSON text. - // - // There is a subtle bug here, where users might intentionally add quotes to matchers. This method can remove such quotes. - // Since ObjectMatchers will be deprecated entirely, this bug will go away naturally with time. - rawMatcher[2] = strings.TrimPrefix(rawMatcher[2], "\"") - rawMatcher[2] = strings.TrimSuffix(rawMatcher[2], "\"") - - matcher, err := labels.NewMatcher(matchType, rawMatcher[0], rawMatcher[2]) - if err != nil { - return err - } - *m = append(*m, matcher) - } - sort.Sort(labels.Matchers(*m)) - return nil -} - -// UnmarshalJSON implements the json.Unmarshaler interface for Matchers. -func (m *ObjectMatchers) UnmarshalJSON(data []byte) error { - var rawMatchers [][3]string - if err := json.Unmarshal(data, &rawMatchers); err != nil { - return err - } - for _, rawMatcher := range rawMatchers { - var matchType labels.MatchType - switch rawMatcher[1] { - case "=": - matchType = labels.MatchEqual - case "!=": - matchType = labels.MatchNotEqual - case "=~": - matchType = labels.MatchRegexp - case "!~": - matchType = labels.MatchNotRegexp - default: - return fmt.Errorf("unsupported match type %q in matcher", rawMatcher[1]) - } - - rawMatcher[2] = strings.TrimPrefix(rawMatcher[2], "\"") - rawMatcher[2] = strings.TrimSuffix(rawMatcher[2], "\"") - - matcher, err := labels.NewMatcher(matchType, rawMatcher[0], rawMatcher[2]) - if err != nil { - return err - } - *m = append(*m, matcher) - } - sort.Sort(labels.Matchers(*m)) - return nil -} - -// MarshalYAML implements the yaml.Marshaler interface for Matchers. -func (m ObjectMatchers) MarshalYAML() (interface{}, error) { - result := make([][3]string, len(m)) - for i, matcher := range m { - result[i] = [3]string{matcher.Name, matcher.Type.String(), matcher.Value} - } - return result, nil -} - -// MarshalJSON implements the json.Marshaler interface for Matchers. -func (m ObjectMatchers) MarshalJSON() ([]byte, error) { - if len(m) == 0 { - return nil, nil - } - result := make([][3]string, len(m)) - for i, matcher := range m { - result[i] = [3]string{matcher.Name, matcher.Type.String(), matcher.Value} - } - return json.Marshal(result) -} diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager_test.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager_test.go index 224c484fd4043..55821b9ba58c9 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager_test.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager_test.go @@ -2,7 +2,6 @@ package definitions import ( "encoding/json" - "errors" "os" "strings" "testing" @@ -14,675 +13,6 @@ import ( "gopkg.in/yaml.v3" ) -func Test_ApiReceiver_Marshaling(t *testing.T) { - for _, tc := range []struct { - desc string - input PostableApiReceiver - err bool - }{ - { - desc: "success AM", - input: PostableApiReceiver{ - Receiver: config.Receiver{ - Name: "foo", - EmailConfigs: []*config.EmailConfig{{}}, - }, - }, - }, - { - desc: "success GM", - input: PostableApiReceiver{ - Receiver: config.Receiver{ - Name: "foo", - }, - PostableGrafanaReceivers: PostableGrafanaReceivers{ - GrafanaManagedReceivers: []*PostableGrafanaReceiver{{}}, - }, - }, - }, - { - desc: "failure mixed", - input: PostableApiReceiver{ - Receiver: config.Receiver{ - Name: "foo", - EmailConfigs: []*config.EmailConfig{{}}, - }, - PostableGrafanaReceivers: PostableGrafanaReceivers{ - GrafanaManagedReceivers: []*PostableGrafanaReceiver{{}}, - }, - }, - err: true, - }, - } { - t.Run(tc.desc, func(t *testing.T) { - encoded, err := json.Marshal(tc.input) - require.Nil(t, err) - - var out PostableApiReceiver - err = json.Unmarshal(encoded, &out) - - if tc.err { - require.Error(t, err) - } else { - require.Nil(t, err) - require.Equal(t, tc.input, out) - } - }) - } -} - -func Test_APIReceiverType(t *testing.T) { - for _, tc := range []struct { - desc string - input PostableApiReceiver - expected ReceiverType - }{ - { - desc: "empty", - input: PostableApiReceiver{ - Receiver: config.Receiver{ - Name: "foo", - }, - }, - expected: EmptyReceiverType, - }, - { - desc: "am", - input: PostableApiReceiver{ - Receiver: config.Receiver{ - Name: "foo", - EmailConfigs: []*config.EmailConfig{{}}, - }, - }, - expected: AlertmanagerReceiverType, - }, - { - desc: "graf", - input: PostableApiReceiver{ - Receiver: config.Receiver{ - Name: "foo", - }, - PostableGrafanaReceivers: PostableGrafanaReceivers{ - GrafanaManagedReceivers: []*PostableGrafanaReceiver{{}}, - }, - }, - expected: GrafanaReceiverType, - }, - } { - t.Run(tc.desc, func(t *testing.T) { - require.Equal(t, tc.expected, tc.input.Type()) - }) - } -} - -func Test_AllReceivers(t *testing.T) { - input := &Route{ - Receiver: "foo", - Routes: []*Route{ - { - Receiver: "bar", - Routes: []*Route{ - { - Receiver: "bazz", - }, - }, - }, - { - Receiver: "buzz", - }, - }, - } - - require.Equal(t, []string{"foo", "bar", "bazz", "buzz"}, AllReceivers(input.AsAMRoute())) - - // test empty - var empty []string - emptyRoute := &Route{} - require.Equal(t, empty, AllReceivers(emptyRoute.AsAMRoute())) -} - -func Test_ApiAlertingConfig_Marshaling(t *testing.T) { - for _, tc := range []struct { - desc string - input PostableApiAlertingConfig - err bool - }{ - { - desc: "success am", - input: PostableApiAlertingConfig{ - Config: Config{ - Route: &Route{ - Receiver: "am", - Routes: []*Route{ - { - Receiver: "am", - }, - }, - }, - }, - Receivers: []*PostableApiReceiver{ - { - Receiver: config.Receiver{ - Name: "am", - EmailConfigs: []*config.EmailConfig{{}}, - }, - }, - }, - }, - }, - { - desc: "success graf", - input: PostableApiAlertingConfig{ - Config: Config{ - Route: &Route{ - Receiver: "graf", - Routes: []*Route{ - { - Receiver: "graf", - }, - }, - }, - }, - Receivers: []*PostableApiReceiver{ - { - Receiver: config.Receiver{ - Name: "graf", - }, - PostableGrafanaReceivers: PostableGrafanaReceivers{ - GrafanaManagedReceivers: []*PostableGrafanaReceiver{{}}, - }, - }, - }, - }, - }, - { - desc: "failure undefined am receiver", - input: PostableApiAlertingConfig{ - Config: Config{ - Route: &Route{ - Receiver: "am", - Routes: []*Route{ - { - Receiver: "unmentioned", - }, - }, - }, - }, - Receivers: []*PostableApiReceiver{ - { - Receiver: config.Receiver{ - Name: "am", - EmailConfigs: []*config.EmailConfig{{}}, - }, - }, - }, - }, - err: true, - }, - { - desc: "failure undefined graf receiver", - input: PostableApiAlertingConfig{ - Config: Config{ - Route: &Route{ - Receiver: "graf", - Routes: []*Route{ - { - Receiver: "unmentioned", - }, - }, - }, - }, - Receivers: []*PostableApiReceiver{ - { - Receiver: config.Receiver{ - Name: "graf", - }, - PostableGrafanaReceivers: PostableGrafanaReceivers{ - GrafanaManagedReceivers: []*PostableGrafanaReceiver{{}}, - }, - }, - }, - }, - err: true, - }, - { - desc: "failure graf no route", - input: PostableApiAlertingConfig{ - Receivers: []*PostableApiReceiver{ - { - Receiver: config.Receiver{ - Name: "graf", - }, - PostableGrafanaReceivers: PostableGrafanaReceivers{ - GrafanaManagedReceivers: []*PostableGrafanaReceiver{{}}, - }, - }, - }, - }, - err: true, - }, - { - desc: "failure graf no default receiver", - input: PostableApiAlertingConfig{ - Config: Config{ - Route: &Route{ - Routes: []*Route{ - { - Receiver: "graf", - }, - }, - }, - }, - Receivers: []*PostableApiReceiver{ - { - Receiver: config.Receiver{ - Name: "graf", - }, - PostableGrafanaReceivers: PostableGrafanaReceivers{ - GrafanaManagedReceivers: []*PostableGrafanaReceiver{{}}, - }, - }, - }, - }, - err: true, - }, - { - desc: "failure graf root route with matchers", - input: PostableApiAlertingConfig{ - Config: Config{ - Route: &Route{ - Receiver: "graf", - Routes: []*Route{ - { - Receiver: "graf", - }, - }, - Match: map[string]string{"foo": "bar"}, - }, - }, - Receivers: []*PostableApiReceiver{ - { - Receiver: config.Receiver{ - Name: "graf", - }, - PostableGrafanaReceivers: PostableGrafanaReceivers{ - GrafanaManagedReceivers: []*PostableGrafanaReceiver{{}}, - }, - }, - }, - }, - err: true, - }, - { - desc: "failure graf nested route duplicate group by labels", - input: PostableApiAlertingConfig{ - Config: Config{ - Route: &Route{ - Receiver: "graf", - Routes: []*Route{ - { - Receiver: "graf", - GroupByStr: []string{"foo", "bar", "foo"}, - }, - }, - }, - }, - Receivers: []*PostableApiReceiver{ - { - Receiver: config.Receiver{ - Name: "graf", - }, - PostableGrafanaReceivers: PostableGrafanaReceivers{ - GrafanaManagedReceivers: []*PostableGrafanaReceiver{{}}, - }, - }, - }, - }, - err: true, - }, - } { - t.Run(tc.desc, func(t *testing.T) { - encoded, err := json.Marshal(tc.input) - require.Nil(t, err) - - var out PostableApiAlertingConfig - err = json.Unmarshal(encoded, &out) - - if tc.err { - require.Error(t, err) - } else { - require.Nil(t, err) - require.Equal(t, tc.input, out) - } - }) - } -} - -func Test_PostableApiReceiver_Unmarshaling_YAML(t *testing.T) { - for _, tc := range []struct { - desc string - input string - rtype ReceiverType - }{ - { - desc: "grafana receivers", - input: ` -name: grafana_managed -grafana_managed_receiver_configs: - - uid: alertmanager UID - name: an alert manager receiver - type: prometheus-alertmanager - sendreminder: false - disableresolvemessage: false - frequency: 5m - isdefault: false - settings: {} - securesettings: - basicAuthPassword: <basicAuthPassword> - - uid: dingding UID - name: a dingding receiver - type: dingding - sendreminder: false - disableresolvemessage: false - frequency: 5m - isdefault: false`, - rtype: GrafanaReceiverType, - }, - { - desc: "receiver", - input: ` -name: example-email -email_configs: - - to: 'youraddress@example.org'`, - rtype: AlertmanagerReceiverType, - }, - } { - t.Run(tc.desc, func(t *testing.T) { - var r PostableApiReceiver - err := yaml.Unmarshal([]byte(tc.input), &r) - require.Nil(t, err) - assert.Equal(t, tc.rtype, r.Type()) - }) - } -} - -func Test_ConfigUnmashaling(t *testing.T) { - for _, tc := range []struct { - desc, input string - err error - }{ - { - desc: "empty mute time name should error", - err: errors.New("missing name in mute time interval"), - input: ` - { - "route": { - "receiver": "grafana-default-email" - }, - "mute_time_intervals": [ - { - "name": "", - "time_intervals": [ - { - "times": [ - { - "start_time": "00:00", - "end_time": "12:00" - } - ] - } - ] - } - ], - "templates": null, - "receivers": [ - { - "name": "grafana-default-email", - "grafana_managed_receiver_configs": [ - { - "uid": "uxwfZvtnz", - "name": "email receiver", - "type": "email", - "disableResolveMessage": false, - "settings": { - "addresses": "<example@email.com>" - }, - "secureFields": {} - } - ] - } - ] - } - `, - }, - { - desc: "not unique mute time names should error", - err: errors.New("mute time interval \"test1\" is not unique"), - input: ` - { - "route": { - "receiver": "grafana-default-email" - }, - "mute_time_intervals": [ - { - "name": "test1", - "time_intervals": [ - { - "times": [ - { - "start_time": "00:00", - "end_time": "12:00" - } - ] - } - ] - }, - { - "name": "test1", - "time_intervals": [ - { - "times": [ - { - "start_time": "00:00", - "end_time": "12:00" - } - ] - } - ] - } - ], - "templates": null, - "receivers": [ - { - "name": "grafana-default-email", - "grafana_managed_receiver_configs": [ - { - "uid": "uxwfZvtnz", - "name": "email receiver", - "type": "email", - "disableResolveMessage": false, - "settings": { - "addresses": "<example@email.com>" - }, - "secureFields": {} - } - ] - } - ] - } - `, - }, - { - desc: "mute time intervals on root route should error", - err: errors.New("root route must not have any mute time intervals"), - input: ` - { - "route": { - "receiver": "grafana-default-email", - "mute_time_intervals": ["test1"] - }, - "mute_time_intervals": [ - { - "name": "test1", - "time_intervals": [ - { - "times": [ - { - "start_time": "00:00", - "end_time": "12:00" - } - ] - } - ] - } - ], - "templates": null, - "receivers": [ - { - "name": "grafana-default-email", - "grafana_managed_receiver_configs": [ - { - "uid": "uxwfZvtnz", - "name": "email receiver", - "type": "email", - "disableResolveMessage": false, - "settings": { - "addresses": "<example@email.com>" - }, - "secureFields": {} - } - ] - } - ] - } - `, - }, - { - desc: "undefined mute time names in routes should error", - err: errors.New("undefined time interval \"test2\" used in route"), - input: ` - { - "route": { - "receiver": "grafana-default-email", - "routes": [ - { - "receiver": "grafana-default-email", - "object_matchers": [ - [ - "a", - "=", - "b" - ] - ], - "mute_time_intervals": [ - "test2" - ] - } - ] - }, - "mute_time_intervals": [ - { - "name": "test1", - "time_intervals": [ - { - "times": [ - { - "start_time": "00:00", - "end_time": "12:00" - } - ] - } - ] - } - ], - "templates": null, - "receivers": [ - { - "name": "grafana-default-email", - "grafana_managed_receiver_configs": [ - { - "uid": "uxwfZvtnz", - "name": "email receiver", - "type": "email", - "disableResolveMessage": false, - "settings": { - "addresses": "<example@email.com>" - }, - "secureFields": {} - } - ] - } - ] - } - `, - }, - { - desc: "valid config should not error", - input: ` - { - "route": { - "receiver": "grafana-default-email", - "routes": [ - { - "receiver": "grafana-default-email", - "object_matchers": [ - [ - "a", - "=", - "b" - ] - ], - "mute_time_intervals": [ - "test1" - ] - } - ] - }, - "mute_time_intervals": [ - { - "name": "test1", - "time_intervals": [ - { - "times": [ - { - "start_time": "00:00", - "end_time": "12:00" - } - ] - } - ] - } - ], - "templates": null, - "receivers": [ - { - "name": "grafana-default-email", - "grafana_managed_receiver_configs": [ - { - "uid": "uxwfZvtnz", - "name": "email receiver", - "type": "email", - "disableResolveMessage": false, - "settings": { - "addresses": "<example@email.com>" - }, - "secureFields": {} - } - ] - } - ] - } - `, - }, - } { - t.Run(tc.desc, func(t *testing.T) { - var out Config - err := json.Unmarshal([]byte(tc.input), &out) - require.Equal(t, tc.err, err) - }) - } -} - func Test_GettableUserConfigUnmarshaling(t *testing.T) { for _, tc := range []struct { desc, input string @@ -829,207 +159,6 @@ func Test_GettableUserConfigRoundtrip(t *testing.T) { require.Equal(t, string(yamlEncoded), string(out)) } -func Test_ReceiverCompatibility(t *testing.T) { - for _, tc := range []struct { - desc string - a, b ReceiverType - expected bool - }{ - { - desc: "grafana=grafana", - a: GrafanaReceiverType, - b: GrafanaReceiverType, - expected: true, - }, - { - desc: "am=am", - a: AlertmanagerReceiverType, - b: AlertmanagerReceiverType, - expected: true, - }, - { - desc: "empty=grafana", - a: EmptyReceiverType, - b: AlertmanagerReceiverType, - expected: true, - }, - { - desc: "empty=am", - a: EmptyReceiverType, - b: AlertmanagerReceiverType, - expected: true, - }, - { - desc: "empty=empty", - a: EmptyReceiverType, - b: EmptyReceiverType, - expected: true, - }, - { - desc: "graf!=am", - a: GrafanaReceiverType, - b: AlertmanagerReceiverType, - expected: false, - }, - { - desc: "am!=graf", - a: AlertmanagerReceiverType, - b: GrafanaReceiverType, - expected: false, - }, - } { - t.Run(tc.desc, func(t *testing.T) { - require.Equal(t, tc.expected, tc.a.Can(tc.b)) - }) - } -} - -func Test_ReceiverMatchesBackend(t *testing.T) { - for _, tc := range []struct { - desc string - rec ReceiverType - b ReceiverType - ok bool - }{ - { - desc: "graf=graf", - rec: GrafanaReceiverType, - b: GrafanaReceiverType, - ok: true, - }, - { - desc: "empty=graf", - rec: EmptyReceiverType, - b: GrafanaReceiverType, - ok: true, - }, - { - desc: "am=am", - rec: AlertmanagerReceiverType, - b: AlertmanagerReceiverType, - ok: true, - }, - { - desc: "empty=am", - rec: EmptyReceiverType, - b: AlertmanagerReceiverType, - ok: true, - }, - { - desc: "graf!=am", - rec: GrafanaReceiverType, - b: AlertmanagerReceiverType, - ok: false, - }, - } { - t.Run(tc.desc, func(t *testing.T) { - ok := tc.rec.Can(tc.b) - require.Equal(t, tc.ok, ok) - }) - } -} - -func TestObjectMatchers_UnmarshalJSON(t *testing.T) { - j := `{ - "receiver": "autogen-contact-point-default", - "routes": [{ - "receiver": "autogen-contact-point-1", - "object_matchers": [ - [ - "a", - "=", - "MFR3Gxrnk" - ], - [ - "b", - "=", - "\"MFR3Gxrnk\"" - ], - [ - "c", - "=~", - "^[a-z0-9-]{1}[a-z0-9-]{0,30}$" - ], - [ - "d", - "=~", - "\"^[a-z0-9-]{1}[a-z0-9-]{0,30}$\"" - ] - ], - "group_interval": "3s", - "repeat_interval": "10s" - }] -}` - var r Route - if err := json.Unmarshal([]byte(j), &r); err != nil { - require.NoError(t, err) - } - - matchers := r.Routes[0].ObjectMatchers - - // Without quotes. - require.Equal(t, matchers[0].Name, "a") - require.Equal(t, matchers[0].Value, "MFR3Gxrnk") - - // With double quotes. - require.Equal(t, matchers[1].Name, "b") - require.Equal(t, matchers[1].Value, "MFR3Gxrnk") - - // Regexp without quotes. - require.Equal(t, matchers[2].Name, "c") - require.Equal(t, matchers[2].Value, "^[a-z0-9-]{1}[a-z0-9-]{0,30}$") - - // Regexp with quotes. - require.Equal(t, matchers[3].Name, "d") - require.Equal(t, matchers[3].Value, "^[a-z0-9-]{1}[a-z0-9-]{0,30}$") -} - -func TestObjectMatchers_UnmarshalYAML(t *testing.T) { - y := `--- -receiver: autogen-contact-point-default -routes: -- receiver: autogen-contact-point-1 - object_matchers: - - - a - - "=" - - MFR3Gxrnk - - - b - - "=" - - '"MFR3Gxrnk"' - - - c - - "=~" - - "^[a-z0-9-]{1}[a-z0-9-]{0,30}$" - - - d - - "=~" - - '"^[a-z0-9-]{1}[a-z0-9-]{0,30}$"' - group_interval: 3s - repeat_interval: 10s -` - - var r Route - if err := yaml.Unmarshal([]byte(y), &r); err != nil { - require.NoError(t, err) - } - - matchers := r.Routes[0].ObjectMatchers - - // Without quotes. - require.Equal(t, matchers[0].Name, "a") - require.Equal(t, matchers[0].Value, "MFR3Gxrnk") - - // With double quotes. - require.Equal(t, matchers[1].Name, "b") - require.Equal(t, matchers[1].Value, "MFR3Gxrnk") - - // Regexp without quotes. - require.Equal(t, matchers[2].Name, "c") - require.Equal(t, matchers[2].Value, "^[a-z0-9-]{1}[a-z0-9-]{0,30}$") - - // Regexp with quotes. - require.Equal(t, matchers[3].Name, "d") - require.Equal(t, matchers[3].Value, "^[a-z0-9-]{1}[a-z0-9-]{0,30}$") -} - func Test_Marshaling_Validation(t *testing.T) { jsonEncoded, err := os.ReadFile("alertmanager_test_artifact.json") require.Nil(t, err) diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go index 276f30ee2eb45..72ca0ebb730fe 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go @@ -6,58 +6,11 @@ import ( "regexp" "strings" tmpltext "text/template" - "time" "github.com/prometheus/alertmanager/template" - "github.com/prometheus/common/model" "gopkg.in/yaml.v3" ) -// Validate normalizes a possibly nested Route r, and returns errors if r is invalid. -func (r *Route) validateChild() error { - r.GroupBy = nil - r.GroupByAll = false - for _, l := range r.GroupByStr { - if l == "..." { - r.GroupByAll = true - } else { - r.GroupBy = append(r.GroupBy, model.LabelName(l)) - } - } - - if len(r.GroupBy) > 0 && r.GroupByAll { - return fmt.Errorf("cannot have wildcard group_by (`...`) and other other labels at the same time") - } - - groupBy := map[model.LabelName]struct{}{} - - for _, ln := range r.GroupBy { - if _, ok := groupBy[ln]; ok { - return fmt.Errorf("duplicated label %q in group_by, %s %s", ln, r.Receiver, r.GroupBy) - } - groupBy[ln] = struct{}{} - } - - if r.GroupInterval != nil && time.Duration(*r.GroupInterval) == time.Duration(0) { - return fmt.Errorf("group_interval cannot be zero") - } - if r.RepeatInterval != nil && time.Duration(*r.RepeatInterval) == time.Duration(0) { - return fmt.Errorf("repeat_interval cannot be zero") - } - - // Routes are a self-referential structure. - if r.Routes != nil { - for _, child := range r.Routes { - err := child.validateChild() - if err != nil { - return err - } - } - } - - return nil -} - func (t *NotificationTemplate) Validate() error { if t.Name == "" { return fmt.Errorf("template must have a name") @@ -99,48 +52,6 @@ func (t *NotificationTemplate) Validate() error { return nil } -// Validate normalizes a Route r, and returns errors if r is an invalid root route. Root routes must satisfy a few additional conditions. -func (r *Route) Validate() error { - if len(r.Receiver) == 0 { - return fmt.Errorf("root route must specify a default receiver") - } - if len(r.Match) > 0 || len(r.MatchRE) > 0 { - return fmt.Errorf("root route must not have any matchers") - } - if len(r.MuteTimeIntervals) > 0 { - return fmt.Errorf("root route must not have any mute time intervals") - } - return r.validateChild() -} - -func (r *Route) ValidateReceivers(receivers map[string]struct{}) error { - if _, exists := receivers[r.Receiver]; !exists { - return fmt.Errorf("receiver '%s' does not exist", r.Receiver) - } - for _, children := range r.Routes { - err := children.ValidateReceivers(receivers) - if err != nil { - return err - } - } - return nil -} - -func (r *Route) ValidateMuteTimes(muteTimes map[string]struct{}) error { - for _, name := range r.MuteTimeIntervals { - if _, exists := muteTimes[name]; !exists { - return fmt.Errorf("mute time interval '%s' does not exist", name) - } - } - for _, child := range r.Routes { - err := child.ValidateMuteTimes(muteTimes) - if err != nil { - return err - } - } - return nil -} - func (mt *MuteTimeInterval) Validate() error { s, err := yaml.Marshal(mt.MuteTimeInterval) if err != nil { diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation_test.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation_test.go index cfb504b7291f7..4fbbd983b1342 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation_test.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation_test.go @@ -48,7 +48,7 @@ func TestValidateRoutes(t *testing.T) { for _, c := range cases { t.Run(c.desc, func(t *testing.T) { - err := c.route.validateChild() + err := c.route.ValidateChild() require.NoError(t, err) }) @@ -117,7 +117,7 @@ func TestValidateRoutes(t *testing.T) { for _, c := range cases { t.Run(c.desc, func(t *testing.T) { - err := c.route.validateChild() + err := c.route.ValidateChild() require.Error(t, err) require.Contains(t, err.Error(), c.expMsg) @@ -132,7 +132,7 @@ func TestValidateRoutes(t *testing.T) { GroupByStr: []string{"abc", "def"}, } - _ = route.validateChild() + _ = route.ValidateChild() require.False(t, route.GroupByAll) require.Equal(t, []model.LabelName{"abc", "def"}, route.GroupBy) @@ -144,7 +144,7 @@ func TestValidateRoutes(t *testing.T) { GroupByStr: []string{"..."}, } - _ = route.validateChild() + _ = route.ValidateChild() require.True(t, route.GroupByAll) require.Nil(t, route.GroupBy) @@ -156,9 +156,9 @@ func TestValidateRoutes(t *testing.T) { GroupByStr: []string{"abc", "def"}, } - err := route.validateChild() + err := route.ValidateChild() require.NoError(t, err) - err = route.validateChild() + err = route.ValidateChild() require.NoError(t, err) require.False(t, route.GroupByAll) diff --git a/pkg/services/ngalert/api/tooling/definitions/api.go b/pkg/services/ngalert/api/tooling/definitions/api.go index b1a5b1a9fdeae..0c9c6786dc9b9 100644 --- a/pkg/services/ngalert/api/tooling/definitions/api.go +++ b/pkg/services/ngalert/api/tooling/definitions/api.go @@ -4,7 +4,7 @@ // spec for the Grafana Alerting API. // // Schemes: http, https -// BasePath: /api/v1 +// BasePath: /api // Version: 1.1.0 // // Consumes: diff --git a/pkg/services/ngalert/api/tooling/definitions/contact_points.go b/pkg/services/ngalert/api/tooling/definitions/contact_points.go index 5797c0314b3a1..b5569aa2845fd 100644 --- a/pkg/services/ngalert/api/tooling/definitions/contact_points.go +++ b/pkg/services/ngalert/api/tooling/definitions/contact_points.go @@ -188,8 +188,9 @@ type SlackIntegration struct { type TelegramIntegration struct { DisableResolveMessage *bool `json:"-" yaml:"-" hcl:"disable_resolve_message"` - BotToken Secret `json:"bottoken" yaml:"bottoken" hcl:"token"` - ChatID string `json:"chatid,omitempty" yaml:"chatid,omitempty" hcl:"chat_id"` + BotToken Secret `json:"bottoken" yaml:"bottoken" hcl:"token"` + ChatID string `json:"chatid,omitempty" yaml:"chatid,omitempty" hcl:"chat_id"` + MessageThreadID string `json:"message_thread_id,omitempty" yaml:"message_thread_id,omitempty" hcl:"message_thread_id"` Message *string `json:"message,omitempty" yaml:"message,omitempty" hcl:"message"` ParseMode *string `json:"parse_mode,omitempty" yaml:"parse_mode,omitempty" hcl:"parse_mode"` diff --git a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go index 5db0b821f49d6..6227be78e4d36 100644 --- a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go +++ b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go @@ -8,7 +8,7 @@ import ( "github.com/prometheus/common/model" ) -// swagger:route Get /api/ruler/grafana/api/v1/rules ruler RouteGetGrafanaRulesConfig +// swagger:route Get /ruler/grafana/api/v1/rules ruler RouteGetGrafanaRulesConfig // // List rule groups // @@ -20,7 +20,7 @@ import ( // 403: ForbiddenError // -// swagger:route Get /api/ruler/grafana/api/v1/export/rules ruler RouteGetRulesForExport +// swagger:route Get /ruler/grafana/api/v1/export/rules ruler RouteGetRulesForExport // // List rules in provisioning format // @@ -33,7 +33,7 @@ import ( // 403: ForbiddenError // 404: description: Not found. -// swagger:route Get /api/ruler/{DatasourceUID}/api/v1/rules ruler RouteGetRulesConfig +// swagger:route Get /ruler/{DatasourceUID}/api/v1/rules ruler RouteGetRulesConfig // // List rule groups // @@ -45,7 +45,7 @@ import ( // 403: ForbiddenError // 404: NotFound -// swagger:route POST /api/ruler/grafana/api/v1/rules/{Namespace} ruler RoutePostNameGrafanaRulesConfig +// swagger:route POST /ruler/grafana/api/v1/rules/{Namespace} ruler RoutePostNameGrafanaRulesConfig // // Creates or updates a rule group // @@ -58,7 +58,7 @@ import ( // 403: ForbiddenError // -// swagger:route POST /api/ruler/grafana/api/v1/rules/{Namespace}/export ruler RoutePostRulesGroupForExport +// swagger:route POST /ruler/grafana/api/v1/rules/{Namespace}/export ruler RoutePostRulesGroupForExport // // Converts submitted rule group to provisioning format // @@ -71,7 +71,7 @@ import ( // 403: ForbiddenError // 404: description: Not found. -// swagger:route POST /api/ruler/{DatasourceUID}/api/v1/rules/{Namespace} ruler RoutePostNameRulesConfig +// swagger:route POST /ruler/{DatasourceUID}/api/v1/rules/{Namespace} ruler RoutePostNameRulesConfig // // Creates or updates a rule group // @@ -84,7 +84,7 @@ import ( // 403: ForbiddenError // 404: NotFound -// swagger:route Get /api/ruler/grafana/api/v1/rules/{Namespace} ruler RouteGetNamespaceGrafanaRulesConfig +// swagger:route Get /ruler/grafana/api/v1/rules/{Namespace} ruler RouteGetNamespaceGrafanaRulesConfig // // Get rule groups by namespace // @@ -95,7 +95,7 @@ import ( // 403: ForbiddenError // 202: NamespaceConfigResponse -// swagger:route Get /api/ruler/{DatasourceUID}/api/v1/rules/{Namespace} ruler RouteGetNamespaceRulesConfig +// swagger:route Get /ruler/{DatasourceUID}/api/v1/rules/{Namespace} ruler RouteGetNamespaceRulesConfig // // Get rule groups by namespace // @@ -107,7 +107,7 @@ import ( // 403: ForbiddenError // 404: NotFound -// swagger:route Delete /api/ruler/grafana/api/v1/rules/{Namespace} ruler RouteDeleteNamespaceGrafanaRulesConfig +// swagger:route Delete /ruler/grafana/api/v1/rules/{Namespace} ruler RouteDeleteNamespaceGrafanaRulesConfig // // Delete namespace // @@ -115,7 +115,7 @@ import ( // 202: Ack // 403: ForbiddenError -// swagger:route Delete /api/ruler/{DatasourceUID}/api/v1/rules/{Namespace} ruler RouteDeleteNamespaceRulesConfig +// swagger:route Delete /ruler/{DatasourceUID}/api/v1/rules/{Namespace} ruler RouteDeleteNamespaceRulesConfig // // Delete namespace // @@ -124,7 +124,7 @@ import ( // 403: ForbiddenError // 404: NotFound -// swagger:route Get /api/ruler/grafana/api/v1/rules/{Namespace}/{Groupname} ruler RouteGetGrafanaRuleGroupConfig +// swagger:route Get /ruler/grafana/api/v1/rules/{Namespace}/{Groupname} ruler RouteGetGrafanaRuleGroupConfig // // Get rule group // @@ -135,7 +135,7 @@ import ( // 202: RuleGroupConfigResponse // 403: ForbiddenError -// swagger:route Get /api/ruler/{DatasourceUID}/api/v1/rules/{Namespace}/{Groupname} ruler RouteGetRulegGroupConfig +// swagger:route Get /ruler/{DatasourceUID}/api/v1/rules/{Namespace}/{Groupname} ruler RouteGetRulegGroupConfig // // Get rule group // @@ -147,7 +147,7 @@ import ( // 403: ForbiddenError // 404: NotFound -// swagger:route Delete /api/ruler/grafana/api/v1/rules/{Namespace}/{Groupname} ruler RouteDeleteGrafanaRuleGroupConfig +// swagger:route Delete /ruler/grafana/api/v1/rules/{Namespace}/{Groupname} ruler RouteDeleteGrafanaRuleGroupConfig // // Delete rule group // @@ -155,7 +155,7 @@ import ( // 202: Ack // 403: ForbiddenError -// swagger:route Delete /api/ruler/{DatasourceUID}/api/v1/rules/{Namespace}/{Groupname} ruler RouteDeleteRuleGroupConfig +// swagger:route Delete /ruler/{DatasourceUID}/api/v1/rules/{Namespace}/{Groupname} ruler RouteDeleteRuleGroupConfig // // Delete rule group // @@ -166,6 +166,7 @@ import ( // swagger:parameters RoutePostNameRulesConfig RoutePostNameGrafanaRulesConfig RoutePostRulesGroupForExport type NamespaceConfig struct { + // The UID of the rule folder // in:path Namespace string // in:body @@ -174,12 +175,14 @@ type NamespaceConfig struct { // swagger:parameters RouteGetNamespaceRulesConfig RouteDeleteNamespaceRulesConfig RouteGetNamespaceGrafanaRulesConfig RouteDeleteNamespaceGrafanaRulesConfig type PathNamespaceConfig struct { + // The UID of the rule folder // in: path Namespace string } // swagger:parameters RouteGetRulegGroupConfig RouteDeleteRuleGroupConfig RouteGetGrafanaRuleGroupConfig RouteDeleteGrafanaRuleGroupConfig type PathRouleGroupConfig struct { + // The UID of the rule folder // in: path Namespace string // in: path @@ -403,34 +406,80 @@ const ( ErrorErrState ExecutionErrorState = "Error" ) +// swagger:model +type AlertRuleNotificationSettings struct { + // Name of the receiver to send notifications to. + // required: true + // example: grafana-default-email + Receiver string `json:"receiver"` + + // Optional settings + + // Override the labels by which incoming alerts are grouped together. For example, multiple alerts coming in for + // cluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels + // use the special value '...' as the sole label name. + // This effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what + // you want, unless you have a very low alert volume or your upstream notification system performs its own grouping. + // Must include 'alertname' and 'grafana_folder' if not using '...'. + // default: ["alertname", "grafana_folder"] + // example: ["alertname", "grafana_folder", "cluster"] + GroupBy []string `json:"group_by,omitempty"` + + // Override how long to initially wait to send a notification for a group of alerts. Allows to wait for an + // inhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.) + // example: 30s + GroupWait *model.Duration `json:"group_wait,omitempty"` + + // Override how long to wait before sending a notification about new alerts that are added to a group of alerts for + // which an initial notification has already been sent. (Usually ~5m or more.) + // example: 5m + GroupInterval *model.Duration `json:"group_interval,omitempty"` + + // Override how long to wait before sending a notification again if it has already been sent successfully for an + // alert. (Usually ~3h or more). + // Note that this parameter is implicitly bound by Alertmanager's `--data.retention` configuration flag. + // Notifications will be resent after either repeat_interval or the data retention period have passed, whichever + // occurs first. `repeat_interval` should not be less than `group_interval`. + // example: 4h + RepeatInterval *model.Duration `json:"repeat_interval,omitempty"` + + // Override the times when notifications should be muted. These must match the name of a mute time interval defined + // in the alertmanager configuration mute_time_intervals section. When muted it will not send any notifications, but + // otherwise acts normally. + // example: ["maintenance"] + MuteTimeIntervals []string `json:"mute_time_intervals,omitempty"` +} + // swagger:model type PostableGrafanaRule struct { - Title string `json:"title" yaml:"title"` - Condition string `json:"condition" yaml:"condition"` - Data []AlertQuery `json:"data" yaml:"data"` - UID string `json:"uid" yaml:"uid"` - NoDataState NoDataState `json:"no_data_state" yaml:"no_data_state"` - ExecErrState ExecutionErrorState `json:"exec_err_state" yaml:"exec_err_state"` - IsPaused *bool `json:"is_paused" yaml:"is_paused"` + Title string `json:"title" yaml:"title"` + Condition string `json:"condition" yaml:"condition"` + Data []AlertQuery `json:"data" yaml:"data"` + UID string `json:"uid" yaml:"uid"` + NoDataState NoDataState `json:"no_data_state" yaml:"no_data_state"` + ExecErrState ExecutionErrorState `json:"exec_err_state" yaml:"exec_err_state"` + IsPaused *bool `json:"is_paused" yaml:"is_paused"` + NotificationSettings *AlertRuleNotificationSettings `json:"notification_settings" yaml:"notification_settings"` } // swagger:model type GettableGrafanaRule struct { - ID int64 `json:"id" yaml:"id"` - OrgID int64 `json:"orgId" yaml:"orgId"` - Title string `json:"title" yaml:"title"` - Condition string `json:"condition" yaml:"condition"` - Data []AlertQuery `json:"data" yaml:"data"` - Updated time.Time `json:"updated" yaml:"updated"` - IntervalSeconds int64 `json:"intervalSeconds" yaml:"intervalSeconds"` - Version int64 `json:"version" yaml:"version"` - UID string `json:"uid" yaml:"uid"` - NamespaceUID string `json:"namespace_uid" yaml:"namespace_uid"` - RuleGroup string `json:"rule_group" yaml:"rule_group"` - NoDataState NoDataState `json:"no_data_state" yaml:"no_data_state"` - ExecErrState ExecutionErrorState `json:"exec_err_state" yaml:"exec_err_state"` - Provenance Provenance `json:"provenance,omitempty" yaml:"provenance,omitempty"` - IsPaused bool `json:"is_paused" yaml:"is_paused"` + ID int64 `json:"id" yaml:"id"` + OrgID int64 `json:"orgId" yaml:"orgId"` + Title string `json:"title" yaml:"title"` + Condition string `json:"condition" yaml:"condition"` + Data []AlertQuery `json:"data" yaml:"data"` + Updated time.Time `json:"updated" yaml:"updated"` + IntervalSeconds int64 `json:"intervalSeconds" yaml:"intervalSeconds"` + Version int64 `json:"version" yaml:"version"` + UID string `json:"uid" yaml:"uid"` + NamespaceUID string `json:"namespace_uid" yaml:"namespace_uid"` + RuleGroup string `json:"rule_group" yaml:"rule_group"` + NoDataState NoDataState `json:"no_data_state" yaml:"no_data_state"` + ExecErrState ExecutionErrorState `json:"exec_err_state" yaml:"exec_err_state"` + Provenance Provenance `json:"provenance,omitempty" yaml:"provenance,omitempty"` + IsPaused bool `json:"is_paused" yaml:"is_paused"` + NotificationSettings *AlertRuleNotificationSettings `json:"notification_settings,omitempty" yaml:"notification_settings,omitempty"` } // AlertQuery represents a single query associated with an alert definition. diff --git a/pkg/services/ngalert/api/tooling/definitions/prom.go b/pkg/services/ngalert/api/tooling/definitions/prom.go index 97e03ee74c884..43ae592d32941 100644 --- a/pkg/services/ngalert/api/tooling/definitions/prom.go +++ b/pkg/services/ngalert/api/tooling/definitions/prom.go @@ -9,14 +9,14 @@ import ( v1 "github.com/prometheus/client_golang/api/prometheus/v1" ) -// swagger:route GET /api/prometheus/grafana/api/v1/rules prometheus RouteGetGrafanaRuleStatuses +// swagger:route GET /prometheus/grafana/api/v1/rules prometheus RouteGetGrafanaRuleStatuses // // gets the evaluation statuses of all rules // // Responses: // 200: RuleResponse -// swagger:route GET /api/prometheus/{DatasourceUID}/api/v1/rules prometheus RouteGetRuleStatuses +// swagger:route GET /prometheus/{DatasourceUID}/api/v1/rules prometheus RouteGetRuleStatuses // // gets the evaluation statuses of all rules // @@ -24,14 +24,14 @@ import ( // 200: RuleResponse // 404: NotFound -// swagger:route GET /api/prometheus/grafana/api/v1/alerts prometheus RouteGetGrafanaAlertStatuses +// swagger:route GET /prometheus/grafana/api/v1/alerts prometheus RouteGetGrafanaAlertStatuses // // gets the current alerts // // Responses: // 200: AlertResponse -// swagger:route GET /api/prometheus/{DatasourceUID}/api/v1/alerts prometheus RouteGetAlertStatuses +// swagger:route GET /prometheus/{DatasourceUID}/api/v1/alerts prometheus RouteGetAlertStatuses // // gets the current alerts // diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go index 30233f8cff1cd..3fd9f8159c5ce 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go @@ -6,14 +6,14 @@ import ( "github.com/prometheus/common/model" ) -// swagger:route GET /api/v1/provisioning/alert-rules provisioning stable RouteGetAlertRules +// swagger:route GET /v1/provisioning/alert-rules provisioning stable RouteGetAlertRules // // Get all the alert rules. // // Responses: // 200: ProvisionedAlertRules -// swagger:route GET /api/v1/provisioning/alert-rules/export provisioning stable RouteGetAlertRulesExport +// swagger:route GET /v1/provisioning/alert-rules/export provisioning stable RouteGetAlertRulesExport // // Export all alert rules in provisioning file format. // @@ -21,7 +21,7 @@ import ( // 200: AlertingFileExport // 404: description: Not found. -// swagger:route GET /api/v1/provisioning/alert-rules/{UID} provisioning stable RouteGetAlertRule +// swagger:route GET /v1/provisioning/alert-rules/{UID} provisioning stable RouteGetAlertRule // // Get a specific alert rule by UID. // @@ -29,7 +29,7 @@ import ( // 200: ProvisionedAlertRule // 404: description: Not found. -// swagger:route GET /api/v1/provisioning/alert-rules/{UID}/export provisioning stable RouteGetAlertRuleExport +// swagger:route GET /v1/provisioning/alert-rules/{UID}/export provisioning stable RouteGetAlertRuleExport // // Export an alert rule in provisioning file format. // @@ -42,7 +42,7 @@ import ( // 200: AlertingFileExport // 404: description: Not found. -// swagger:route POST /api/v1/provisioning/alert-rules provisioning stable RoutePostAlertRule +// swagger:route POST /v1/provisioning/alert-rules provisioning stable RoutePostAlertRule // // Create a new alert rule. // @@ -53,7 +53,7 @@ import ( // 201: ProvisionedAlertRule // 400: ValidationError -// swagger:route PUT /api/v1/provisioning/alert-rules/{UID} provisioning stable RoutePutAlertRule +// swagger:route PUT /v1/provisioning/alert-rules/{UID} provisioning stable RoutePutAlertRule // // Update an existing alert rule. // @@ -64,7 +64,7 @@ import ( // 200: ProvisionedAlertRule // 400: ValidationError -// swagger:route DELETE /api/v1/provisioning/alert-rules/{UID} provisioning stable RouteDeleteAlertRule +// swagger:route DELETE /v1/provisioning/alert-rules/{UID} provisioning stable RouteDeleteAlertRule // // Delete a specific alert rule by UID. // @@ -156,9 +156,11 @@ type ProvisionedAlertRule struct { Provenance Provenance `json:"provenance,omitempty"` // example: false IsPaused bool `json:"isPaused"` + // example: {"receiver":"email","group_by":["alertname","grafana_folder","cluster"],"group_wait":"30s","group_interval":"1m","repeat_interval":"4d","mute_time_intervals":["Weekends","Holidays"]} + NotificationSettings *AlertRuleNotificationSettings `json:"notification_settings"` } -// swagger:route GET /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group} provisioning stable RouteGetAlertRuleGroup +// swagger:route GET /v1/provisioning/folder/{FolderUID}/rule-groups/{Group} provisioning stable RouteGetAlertRuleGroup // // Get a rule group. // @@ -166,7 +168,16 @@ type ProvisionedAlertRule struct { // 200: AlertRuleGroup // 404: description: Not found. -// swagger:route GET /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export provisioning stable RouteGetAlertRuleGroupExport +// swagger:route DELETE /v1/provisioning/folder/{FolderUID}/rule-groups/{Group} provisioning stable RouteDeleteAlertRuleGroup +// +// Delete rule group +// +// Responses: +// 204: description: The alert rule group was deleted successfully. +// 403: ForbiddenError +// 404: NotFound + +// swagger:route GET /v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export provisioning stable RouteGetAlertRuleGroupExport // // Export an alert rule group in provisioning file format. // @@ -179,7 +190,7 @@ type ProvisionedAlertRule struct { // 200: AlertingFileExport // 404: description: Not found. -// swagger:route PUT /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group} provisioning stable RoutePutAlertRuleGroup +// swagger:route PUT /v1/provisioning/folder/{FolderUID}/rule-groups/{Group} provisioning stable RoutePutAlertRuleGroup // // Update the interval of a rule group. // @@ -190,13 +201,13 @@ type ProvisionedAlertRule struct { // 200: AlertRuleGroup // 400: ValidationError -// swagger:parameters RouteGetAlertRuleGroup RoutePutAlertRuleGroup RouteGetAlertRuleGroupExport +// swagger:parameters RouteGetAlertRuleGroup RoutePutAlertRuleGroup RouteGetAlertRuleGroupExport RouteDeleteAlertRuleGroup type FolderUIDPathParam struct { // in:path FolderUID string `json:"FolderUID"` } -// swagger:parameters RouteGetAlertRuleGroup RoutePutAlertRuleGroup RouteGetAlertRuleGroupExport +// swagger:parameters RouteGetAlertRuleGroup RoutePutAlertRuleGroup RouteGetAlertRuleGroupExport RouteDeleteAlertRuleGroup type RuleGroupPathParam struct { // in:path Group string `json:"Group"` @@ -246,10 +257,11 @@ type AlertRuleExport struct { // ForString is used to: // - Only export the for field for HCL if it is non-zero. // - Format the Prometheus model.Duration type properly for HCL. - ForString *string `json:"-" yaml:"-" hcl:"for"` - Annotations *map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty" hcl:"annotations"` - Labels *map[string]string `json:"labels,omitempty" yaml:"labels,omitempty" hcl:"labels"` - IsPaused bool `json:"isPaused" yaml:"isPaused" hcl:"is_paused"` + ForString *string `json:"-" yaml:"-" hcl:"for"` + Annotations *map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty" hcl:"annotations"` + Labels *map[string]string `json:"labels,omitempty" yaml:"labels,omitempty" hcl:"labels"` + IsPaused bool `json:"isPaused" yaml:"isPaused" hcl:"is_paused"` + NotificationSettings *AlertRuleNotificationSettingsExport `json:"notification_settings,omitempty" yaml:"notification_settings,omitempty" hcl:"notification_settings,block"` } // AlertQueryExport is the provisioned export of models.AlertQuery. @@ -266,3 +278,14 @@ type RelativeTimeRangeExport struct { FromSeconds int64 `json:"from" yaml:"from" hcl:"from"` ToSeconds int64 `json:"to" yaml:"to" hcl:"to"` } + +// AlertRuleNotificationSettingsExport is the provisioned export of models.NotificationSettings. +type AlertRuleNotificationSettingsExport struct { + Receiver string `yaml:"receiver,omitempty" json:"receiver,omitempty" hcl:"receiver"` + + GroupBy []string `yaml:"group_by,omitempty" json:"group_by,omitempty" hcl:"group_by"` + GroupWait *string `yaml:"group_wait,omitempty" json:"group_wait,omitempty" hcl:"group_wait,optional"` + GroupInterval *string `yaml:"group_interval,omitempty" json:"group_interval,omitempty" hcl:"group_interval,optional"` + RepeatInterval *string `yaml:"repeat_interval,omitempty" json:"repeat_interval,omitempty" hcl:"repeat_interval,optional"` + MuteTimeIntervals []string `yaml:"mute_time_intervals,omitempty" json:"mute_time_intervals,omitempty" hcl:"mute_time_intervals"` +} diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go index cea1209f0d0ed..e7ca49a8043cc 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go @@ -4,14 +4,14 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" ) -// swagger:route GET /api/v1/provisioning/contact-points provisioning stable RouteGetContactpoints +// swagger:route GET /v1/provisioning/contact-points provisioning stable RouteGetContactpoints // // Get all the contact points. // // Responses: // 200: ContactPoints -// swagger:route GET /api/v1/provisioning/contact-points/export provisioning stable RouteGetContactpointsExport +// swagger:route GET /v1/provisioning/contact-points/export provisioning stable RouteGetContactpointsExport // // Export all contact points in provisioning file format. // @@ -19,7 +19,7 @@ import ( // 200: AlertingFileExport // 403: PermissionDenied -// swagger:route POST /api/v1/provisioning/contact-points provisioning stable RoutePostContactpoints +// swagger:route POST /v1/provisioning/contact-points provisioning stable RoutePostContactpoints // // Create a contact point. // @@ -30,7 +30,7 @@ import ( // 202: EmbeddedContactPoint // 400: ValidationError -// swagger:route PUT /api/v1/provisioning/contact-points/{UID} provisioning stable RoutePutContactpoint +// swagger:route PUT /v1/provisioning/contact-points/{UID} provisioning stable RoutePutContactpoint // // Update an existing contact point. // @@ -41,7 +41,7 @@ import ( // 202: Ack // 400: ValidationError -// swagger:route DELETE /api/v1/provisioning/contact-points/{UID} provisioning stable RouteDeleteContactpoints +// swagger:route DELETE /v1/provisioning/contact-points/{UID} provisioning stable RouteDeleteContactpoints // // Delete a contact point. // diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_mute_timings.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_mute_timings.go index 739c6e30a0530..232e5a366fbd6 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_mute_timings.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_mute_timings.go @@ -4,14 +4,14 @@ import ( "github.com/prometheus/alertmanager/config" ) -// swagger:route GET /api/v1/provisioning/mute-timings provisioning stable RouteGetMuteTimings +// swagger:route GET /v1/provisioning/mute-timings provisioning stable RouteGetMuteTimings // // Get all the mute timings. // // Responses: // 200: MuteTimings -// swagger:route GET /api/v1/provisioning/mute-timings/export provisioning stable RouteExportMuteTimings +// swagger:route GET /v1/provisioning/mute-timings/export provisioning stable RouteExportMuteTimings // // Export all mute timings in provisioning format. // @@ -19,7 +19,7 @@ import ( // 200: AlertingFileExport // 403: PermissionDenied -// swagger:route GET /api/v1/provisioning/mute-timings/{name} provisioning stable RouteGetMuteTiming +// swagger:route GET /v1/provisioning/mute-timings/{name} provisioning stable RouteGetMuteTiming // // Get a mute timing. // @@ -27,7 +27,7 @@ import ( // 200: MuteTimeInterval // 404: description: Not found. -// swagger:route GET /api/v1/provisioning/mute-timings/{name}/export provisioning stable RouteExportMuteTiming +// swagger:route GET /v1/provisioning/mute-timings/{name}/export provisioning stable RouteExportMuteTiming // // Export a mute timing in provisioning format. // @@ -35,7 +35,7 @@ import ( // 200: AlertingFileExport // 403: PermissionDenied -// swagger:route POST /api/v1/provisioning/mute-timings provisioning stable RoutePostMuteTiming +// swagger:route POST /v1/provisioning/mute-timings provisioning stable RoutePostMuteTiming // // Create a new mute timing. // @@ -46,7 +46,7 @@ import ( // 201: MuteTimeInterval // 400: ValidationError -// swagger:route PUT /api/v1/provisioning/mute-timings/{name} provisioning stable RoutePutMuteTiming +// swagger:route PUT /v1/provisioning/mute-timings/{name} provisioning stable RoutePutMuteTiming // // Replace an existing mute timing. // @@ -54,15 +54,16 @@ import ( // - application/json // // Responses: -// 200: MuteTimeInterval +// 202: MuteTimeInterval // 400: ValidationError -// swagger:route DELETE /api/v1/provisioning/mute-timings/{name} provisioning stable RouteDeleteMuteTiming +// swagger:route DELETE /v1/provisioning/mute-timings/{name} provisioning stable RouteDeleteMuteTiming // // Delete a mute timing. // // Responses: // 204: description: The mute timing was deleted successfully. +// 409: GenericPublicError // swagger:route diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_policies.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_policies.go index 9da558cda88f1..7d4ab74d35712 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_policies.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_policies.go @@ -4,7 +4,7 @@ import ( "github.com/prometheus/alertmanager/config" ) -// swagger:route GET /api/v1/provisioning/policies provisioning stable RouteGetPolicyTree +// swagger:route GET /v1/provisioning/policies provisioning stable RouteGetPolicyTree // // Get the notification policy tree. // @@ -12,7 +12,7 @@ import ( // 200: Route // description: The currently active notification routing tree -// swagger:route PUT /api/v1/provisioning/policies provisioning stable RoutePutPolicyTree +// swagger:route PUT /v1/provisioning/policies provisioning stable RoutePutPolicyTree // // Sets the notification policy tree. // @@ -23,7 +23,7 @@ import ( // 202: Ack // 400: ValidationError -// swagger:route DELETE /api/v1/provisioning/policies provisioning stable RouteResetPolicyTree +// swagger:route DELETE /v1/provisioning/policies provisioning stable RouteResetPolicyTree // // Clears the notification policy tree. // @@ -33,7 +33,7 @@ import ( // Responses: // 202: Ack -// swagger:route GET /api/v1/provisioning/policies/export provisioning stable RouteGetPolicyTreeExport +// swagger:route GET /v1/provisioning/policies/export provisioning stable RouteGetPolicyTreeExport // // Export the notification policy tree in provisioning file format. // diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_templates.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_templates.go index 6184105bc930e..b7779e22321a4 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_templates.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_templates.go @@ -1,6 +1,6 @@ package definitions -// swagger:route GET /api/v1/provisioning/templates provisioning stable RouteGetTemplates +// swagger:route GET /v1/provisioning/templates provisioning stable RouteGetTemplates // // Get all notification templates. // @@ -8,7 +8,7 @@ package definitions // 200: NotificationTemplates // 404: description: Not found. -// swagger:route GET /api/v1/provisioning/templates/{name} provisioning stable RouteGetTemplate +// swagger:route GET /v1/provisioning/templates/{name} provisioning stable RouteGetTemplate // // Get a notification template. // @@ -16,7 +16,7 @@ package definitions // 200: NotificationTemplate // 404: description: Not found. -// swagger:route PUT /api/v1/provisioning/templates/{name} provisioning stable RoutePutTemplate +// swagger:route PUT /v1/provisioning/templates/{name} provisioning stable RoutePutTemplate // // Updates an existing notification template. // @@ -27,7 +27,7 @@ package definitions // 202: NotificationTemplate // 400: ValidationError -// swagger:route DELETE /api/v1/provisioning/templates/{name} provisioning stable RouteDeleteTemplate +// swagger:route DELETE /v1/provisioning/templates/{name} provisioning stable RouteDeleteTemplate // // Delete a template. // diff --git a/pkg/services/ngalert/api/tooling/definitions/receivers.go b/pkg/services/ngalert/api/tooling/definitions/receivers.go new file mode 100644 index 0000000000000..3e89f819e98e8 --- /dev/null +++ b/pkg/services/ngalert/api/tooling/definitions/receivers.go @@ -0,0 +1,56 @@ +package definitions + +// swagger:route GET /v1/notifications/receivers/{Name} notifications RouteGetReceiver +// +// Get a receiver by name. +// +// Responses: +// 200: GetReceiverResponse +// 403: PermissionDenied +// 404: NotFound + +// swagger:route GET /v1/notifications/receivers notifications RouteGetReceivers +// +// Get all receivers. +// +// Responses: +// 200: GetReceiversResponse +// 403: PermissionDenied + +// swagger:parameters RouteGetReceiver +type GetReceiverParams struct { + // in:path + // required: true + Name string `json:"name"` + // in:query + // required: false + Decrypt bool `json:"decrypt"` +} + +// swagger:parameters RouteGetReceivers +type GetReceiversParams struct { + // in:query + // required: false + Names []string `json:"names"` + // in:query + // required: false + Limit int `json:"limit"` + // in:query + // required: false + Offset int `json:"offset"` + // in:query + // required: false + Decrypt bool `json:"decrypt"` +} + +// swagger:response GetReceiverResponse +type GetReceiverResponse struct { + // in:body + Body GettableApiReceiver +} + +// swagger:response GetReceiversResponse +type GetReceiversResponse struct { + // in:body + Body []GettableApiReceiver +} diff --git a/pkg/services/ngalert/api/tooling/definitions/ruler_state_history.go b/pkg/services/ngalert/api/tooling/definitions/ruler_state_history.go index 9d365659cc8bf..ae3aa956cd968 100644 --- a/pkg/services/ngalert/api/tooling/definitions/ruler_state_history.go +++ b/pkg/services/ngalert/api/tooling/definitions/ruler_state_history.go @@ -2,7 +2,7 @@ package definitions import "github.com/grafana/grafana-plugin-sdk-go/data" -// swagger:route GET /api/v1/rules/history history RouteGetStateHistory +// swagger:route GET /v1/rules/history history RouteGetStateHistory // // Query state history. // diff --git a/pkg/services/ngalert/api/tooling/definitions/shared.go b/pkg/services/ngalert/api/tooling/definitions/shared.go index 4d7a49b95c61f..9f8e8447aebb9 100644 --- a/pkg/services/ngalert/api/tooling/definitions/shared.go +++ b/pkg/services/ngalert/api/tooling/definitions/shared.go @@ -20,3 +20,10 @@ type ForbiddenError struct { // in: body Body errutil.PublicError `json:"body"` } + +// swagger:model +type GenericPublicError struct { + // The response message + // in: body + Body errutil.PublicError `json:"body"` +} diff --git a/pkg/services/ngalert/api/tooling/definitions/testing.go b/pkg/services/ngalert/api/tooling/definitions/testing.go index 90c304434e14c..2c228e94758c7 100644 --- a/pkg/services/ngalert/api/tooling/definitions/testing.go +++ b/pkg/services/ngalert/api/tooling/definitions/testing.go @@ -13,7 +13,7 @@ import ( "github.com/prometheus/prometheus/promql" ) -// swagger:route Post /api/v1/rule/test/grafana testing RouteTestRuleGrafanaConfig +// swagger:route Post /v1/rule/test/grafana testing RouteTestRuleGrafanaConfig // // Test a rule against Grafana ruler // @@ -28,7 +28,7 @@ import ( // 400: ValidationError // 404: NotFound -// swagger:route Post /api/v1/rule/test/{DatasourceUID} testing RouteTestRuleConfig +// swagger:route Post /v1/rule/test/{DatasourceUID} testing RouteTestRuleConfig // // Test a rule against external data source ruler // @@ -42,7 +42,7 @@ import ( // 200: TestRuleResponse // 404: NotFound -// swagger:route Post /api/v1/eval testing RouteEvalQueries +// swagger:route Post /v1/eval testing RouteEvalQueries // // Test rule // @@ -55,7 +55,7 @@ import ( // Responses: // 200: EvalQueriesResponse -// swagger:route Post /api/v1/rule/backtest testing BacktestConfig +// swagger:route Post /v1/rule/backtest testing BacktestConfig // // Test rule // diff --git a/pkg/services/ngalert/api/tooling/definitions/time_intervals.go b/pkg/services/ngalert/api/tooling/definitions/time_intervals.go new file mode 100644 index 0000000000000..f8d2c9ea82911 --- /dev/null +++ b/pkg/services/ngalert/api/tooling/definitions/time_intervals.go @@ -0,0 +1,64 @@ +package definitions + +// swagger:route GET /v1/notifications/time-intervals notifications RouteNotificationsGetTimeIntervals +// +// Get all the time intervals +// +// Responses: +// 200: GetAllIntervalsResponse +// 403: ForbiddenError + +// swagger:route GET /v1/notifications/time-intervals/{name} notifications RouteNotificationsGetTimeInterval +// +// Get a time interval by name. +// +// Responses: +// 200: GetIntervalsByNameResponse +// 404: NotFound +// 403: ForbiddenError + +// swagger:parameters RouteNotificationsGetTimeInterval +type RouteTimeIntervalNameParam struct { + // Time interval name + // in:path + Name string `json:"name"` +} + +// swagger:response GetAllIntervalsResponse +type GetAllIntervalsResponse struct { + // in:body + Body []GettableTimeIntervals +} + +// swagger:response GetIntervalsByNameResponse +type GetIntervalsByNameResponse struct { + // in:body + Body GettableTimeIntervals +} + +// swagger:model +type PostableTimeIntervals struct { + Name string `json:"name" hcl:"name"` + TimeIntervals []TimeIntervalItem `json:"time_intervals" hcl:"intervals,block"` +} + +type TimeIntervalItem struct { + Times []TimeIntervalTimeRange `json:"times,omitempty" hcl:"times,block"` + Weekdays *[]string `json:"weekdays,omitempty" hcl:"weekdays"` + DaysOfMonth *[]string `json:"days_of_month,omitempty" hcl:"days_of_month"` + Months *[]string `json:"months,omitempty" hcl:"months"` + Years *[]string `json:"years,omitempty" hcl:"years"` + Location *string `json:"location,omitempty" hcl:"location"` +} + +type TimeIntervalTimeRange struct { + StartMinute string `json:"start_time" hcl:"start"` + EndMinute string `json:"end_time" hcl:"end"` +} + +// swagger:model +type GettableTimeIntervals struct { + Name string `json:"name" hcl:"name"` + TimeIntervals []TimeIntervalItem `json:"time_intervals" hcl:"intervals,block"` + Provenance Provenance `json:"provenance,omitempty"` +} diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index fd465f7d7ebd5..e47479466c849 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -1,5 +1,5 @@ { - "basePath": "/api/v1", + "basePath": "/api", "consumes": [ "application/json" ], @@ -211,6 +211,9 @@ ], "type": "string" }, + "notification_settings": { + "$ref": "#/definitions/AlertRuleNotificationSettingsExport" + }, "panelId": { "format": "int64", "type": "integer" @@ -280,6 +283,90 @@ }, "type": "object" }, + "AlertRuleNotificationSettings": { + "properties": { + "group_by": { + "default": [ + "alertname", + "grafana_folder" + ], + "description": "Override the labels by which incoming alerts are grouped together. For example, multiple alerts coming in for\ncluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels\nuse the special value '...' as the sole label name.\nThis effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what\nyou want, unless you have a very low alert volume or your upstream notification system performs its own grouping.\nMust include 'alertname' and 'grafana_folder' if not using '...'.", + "example": [ + "alertname", + "grafana_folder", + "cluster" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "group_interval": { + "description": "Override how long to wait before sending a notification about new alerts that are added to a group of alerts for\nwhich an initial notification has already been sent. (Usually ~5m or more.)", + "example": "5m", + "type": "string" + }, + "group_wait": { + "description": "Override how long to initially wait to send a notification for a group of alerts. Allows to wait for an\ninhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.)", + "example": "30s", + "type": "string" + }, + "mute_time_intervals": { + "description": "Override the times when notifications should be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration mute_time_intervals section. When muted it will not send any notifications, but\notherwise acts normally.", + "example": [ + "maintenance" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "receiver": { + "description": "Name of the receiver to send notifications to.", + "example": "grafana-default-email", + "type": "string" + }, + "repeat_interval": { + "description": "Override how long to wait before sending a notification again if it has already been sent successfully for an\nalert. (Usually ~3h or more).\nNote that this parameter is implicitly bound by Alertmanager's `--data.retention` configuration flag.\nNotifications will be resent after either repeat_interval or the data retention period have passed, whichever\noccurs first. `repeat_interval` should not be less than `group_interval`.", + "example": "4h", + "type": "string" + } + }, + "required": [ + "receiver" + ], + "type": "object" + }, + "AlertRuleNotificationSettingsExport": { + "properties": { + "group_by": { + "items": { + "type": "string" + }, + "type": "array" + }, + "group_interval": { + "type": "string" + }, + "group_wait": { + "type": "string" + }, + "mute_time_intervals": { + "items": { + "type": "string" + }, + "type": "array" + }, + "receiver": { + "type": "string" + }, + "repeat_interval": { + "type": "string" + } + }, + "title": "AlertRuleNotificationSettingsExport is the provisioned export of models.NotificationSettings.", + "type": "object" + }, "AlertingFileExport": { "properties": { "apiVersion": { @@ -543,6 +630,7 @@ "type": "array" }, "mute_time_intervals": { + "description": "MuteTimeIntervals is deprecated and will be removed before Alertmanager 1.0.", "items": { "$ref": "#/definitions/MuteTimeInterval" }, @@ -556,6 +644,12 @@ "type": "string" }, "type": "array" + }, + "time_intervals": { + "items": { + "$ref": "#/definitions/TimeInterval" + }, + "type": "array" } }, "title": "Config is the top-level configuration for Alertmanager's config files.", @@ -651,6 +745,9 @@ }, "webhook_url": { "$ref": "#/definitions/SecretURL" + }, + "webhook_url_file": { + "type": "string" } }, "title": "DiscordConfig configures notifications via Discord.", @@ -1155,13 +1252,22 @@ }, "typeVersion": { "$ref": "#/definitions/FrameTypeVersion" + }, + "uniqueRowIdFields": { + "description": "Array of field indices which values create a unique id for each row. Ideally this should be globally unique ID\nbut that isn't guarantied. Should help with keeping track and deduplicating rows in visualizations, especially\nwith streaming data with frequent updates.", + "example": "TraceID in Tempo, table name + primary key in SQL", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" } }, "title": "FrameMeta matches:", "type": "object" }, "FrameType": { - "description": "A FrameType string, when present in a frame's metadata, asserts that the\nframe's structure conforms to the FrameType's specification.\nThis property is currently optional, so FrameType may be FrameTypeUnknown even if the properties of\nthe Frame correspond to a defined FrameType.", + "description": "A FrameType string, when present in a frame's metadata, asserts that the\nframe's structure conforms to the FrameType's specification.\nThis property is currently optional, so FrameType may be FrameTypeUnknown even if the properties of\nthe Frame correspond to a defined FrameType.\n+enum", "type": "string" }, "FrameTypeVersion": { @@ -1180,6 +1286,14 @@ "title": "Frames is a slice of Frame pointers.", "type": "array" }, + "GenericPublicError": { + "properties": { + "body": { + "$ref": "#/definitions/PublicError" + } + }, + "type": "object" + }, "GettableAlertmanagers": { "properties": { "data": { @@ -1209,6 +1323,7 @@ "type": "object" }, "mute_time_intervals": { + "description": "MuteTimeIntervals is deprecated and will be removed before Alertmanager 1.0.", "items": { "$ref": "#/definitions/MuteTimeInterval" }, @@ -1229,6 +1344,12 @@ "type": "string" }, "type": "array" + }, + "time_intervals": { + "items": { + "$ref": "#/definitions/TimeInterval" + }, + "type": "array" } }, "type": "object" @@ -1442,6 +1563,9 @@ ], "type": "string" }, + "notification_settings": { + "$ref": "#/definitions/AlertRuleNotificationSettings" + }, "orgId": { "format": "int64", "type": "integer" @@ -1558,6 +1682,23 @@ ], "type": "object" }, + "GettableTimeIntervals": { + "properties": { + "name": { + "type": "string" + }, + "provenance": { + "$ref": "#/definitions/Provenance" + }, + "time_intervals": { + "items": { + "$ref": "#/definitions/TimeIntervalItem" + }, + "type": "array" + } + }, + "type": "object" + }, "GettableUserConfig": { "properties": { "alertmanager_config": { @@ -1858,6 +1999,9 @@ "send_resolved": { "type": "boolean" }, + "summary": { + "type": "string" + }, "text": { "type": "string" }, @@ -1866,6 +2010,9 @@ }, "webhook_url": { "$ref": "#/definitions/SecretURL" + }, + "webhook_url_file": { + "type": "string" } }, "type": "object" @@ -2126,9 +2273,18 @@ "title": "OAuth2 is the oauth2 client configuration.", "type": "object" }, + "ObjectMatcher": { + "items": { + "type": "string" + }, + "title": "ObjectMatcher is a matcher that can be used to filter alerts.", + "type": "array" + }, "ObjectMatchers": { - "$ref": "#/definitions/Matchers", - "description": "ObjectMatchers is Matchers with a different Unmarshal and Marshal methods that accept matchers as objects\nthat have already been parsed." + "items": { + "$ref": "#/definitions/ObjectMatcher" + }, + "type": "array" }, "OpsGenieConfig": { "properties": { @@ -2308,25 +2464,8 @@ "PermissionDenied": { "type": "object" }, - "Point": { - "description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.", - "properties": { - "H": { - "$ref": "#/definitions/FloatHistogram" - }, - "T": { - "format": "int64", - "type": "integer" - }, - "V": { - "format": "double", - "type": "number" - } - }, - "title": "Point represents a single data point for a given timestamp.", - "type": "object" - }, "PostableApiAlertingConfig": { + "description": "nolint:revive", "properties": { "global": { "$ref": "#/definitions/GlobalConfig" @@ -2338,6 +2477,7 @@ "type": "array" }, "mute_time_intervals": { + "description": "MuteTimeIntervals is deprecated and will be removed before Alertmanager 1.0.", "items": { "$ref": "#/definitions/MuteTimeInterval" }, @@ -2358,11 +2498,18 @@ "type": "string" }, "type": "array" + }, + "time_intervals": { + "items": { + "$ref": "#/definitions/TimeInterval" + }, + "type": "array" } }, "type": "object" }, "PostableApiReceiver": { + "description": "nolint:revive", "properties": { "discord_configs": { "items": { @@ -2580,6 +2727,9 @@ ], "type": "string" }, + "notification_settings": { + "$ref": "#/definitions/AlertRuleNotificationSettings" + }, "title": { "type": "string" }, @@ -2619,6 +2769,20 @@ }, "type": "object" }, + "PostableTimeIntervals": { + "properties": { + "name": { + "type": "string" + }, + "time_intervals": { + "items": { + "$ref": "#/definitions/TimeIntervalItem" + }, + "type": "array" + } + }, + "type": "object" + }, "PostableUserConfig": { "properties": { "alertmanager_config": { @@ -2742,6 +2906,9 @@ ], "type": "string" }, + "notification_settings": { + "$ref": "#/definitions/AlertRuleNotificationSettings" + }, "orgID": { "format": "int64", "type": "integer" @@ -3422,7 +3589,12 @@ "type": "object" }, "Sample": { + "description": "Sample is a single sample belonging to a metric. It represents either a float\nsample or a histogram sample. If H is nil, it is a float sample. Otherwise,\nit is a histogram sample.", "properties": { + "F": { + "format": "double", + "type": "number" + }, "H": { "$ref": "#/definitions/FloatHistogram" }, @@ -3432,13 +3604,8 @@ "T": { "format": "int64", "type": "integer" - }, - "V": { - "format": "double", - "type": "number" } }, - "title": "Sample is a single sample belonging to a metric.", "type": "object" }, "Secret": { @@ -3931,7 +4098,21 @@ "type": "string" }, "TimeInterval": { - "description": "TimeInterval describes intervals of time. ContainsTime will tell you if a golang time is contained\nwithin the interval.", + "properties": { + "name": { + "type": "string" + }, + "time_intervals": { + "items": { + "$ref": "#/definitions/TimeInterval" + }, + "type": "array" + } + }, + "title": "TimeInterval represents a named set of time intervals for which a route should be muted.", + "type": "object" + }, + "TimeIntervalItem": { "properties": { "days_of_month": { "items": { @@ -3950,7 +4131,7 @@ }, "times": { "items": { - "$ref": "#/definitions/TimeRange" + "$ref": "#/definitions/TimeIntervalTimeRange" }, "type": "array" }, @@ -3969,6 +4150,17 @@ }, "type": "object" }, + "TimeIntervalTimeRange": { + "properties": { + "end_time": { + "type": "string" + }, + "start_time": { + "type": "string" + } + }, + "type": "object" + }, "TimeRange": { "description": "Redefining this to avoid an import cycle", "properties": { @@ -3984,7 +4176,6 @@ "type": "object" }, "URL": { - "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "properties": { "ForceQuery": { "type": "boolean" @@ -4020,7 +4211,7 @@ "$ref": "#/definitions/Userinfo" } }, - "title": "A URL represents a parsed URL (technically, a URI reference).", + "title": "URL is a custom URL type that allows validation at configuration load time.", "type": "object" }, "UpdateRuleGroupResponse": { @@ -4073,7 +4264,7 @@ "type": "array" }, "Vector": { - "description": "Vector is basically only an alias for model.Samples, but the\ncontract is that in a Vector, all Samples have the same timestamp.", + "description": "Vector is basically only an alias for []Sample, but the contract is that\nin a Vector, all Samples have the same timestamp.", "items": { "$ref": "#/definitions/Sample" }, @@ -4409,6 +4600,7 @@ "type": "object" }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/definitions/gettableAlert" }, @@ -4613,7 +4805,6 @@ "type": "array" }, "postableSilence": { - "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -4770,7 +4961,7 @@ "version": "1.1.0" }, "paths": { - "/api/alertmanager/grafana/api/v2/alerts": { + "/alertmanager/grafana/api/v2/alerts": { "get": { "description": "get alertmanager alerts", "operationId": "RouteGetGrafanaAMAlerts", @@ -4831,7 +5022,7 @@ ] } }, - "/api/alertmanager/grafana/api/v2/alerts/groups": { + "/alertmanager/grafana/api/v2/alerts/groups": { "get": { "description": "get alertmanager alerts", "operationId": "RouteGetGrafanaAMAlertGroups", @@ -4892,7 +5083,7 @@ ] } }, - "/api/alertmanager/grafana/api/v2/silence/{SilenceId}": { + "/alertmanager/grafana/api/v2/silence/{SilenceId}": { "delete": { "description": "delete silence", "operationId": "RouteDeleteGrafanaSilence", @@ -4952,7 +5143,7 @@ ] } }, - "/api/alertmanager/grafana/api/v2/silences": { + "/alertmanager/grafana/api/v2/silences": { "get": { "description": "get silences", "operationId": "RouteGetGrafanaSilences", @@ -5015,7 +5206,7 @@ ] } }, - "/api/alertmanager/grafana/api/v2/status": { + "/alertmanager/grafana/api/v2/status": { "get": { "description": "get alertmanager status and configuration", "operationId": "RouteGetGrafanaAMStatus", @@ -5038,7 +5229,7 @@ ] } }, - "/api/alertmanager/grafana/config/api/v1/alerts": { + "/alertmanager/grafana/config/api/v1/alerts": { "delete": { "description": "deletes the Alerting config for a tenant", "operationId": "RouteDeleteGrafanaAlertingConfig", @@ -5112,7 +5303,7 @@ ] } }, - "/api/alertmanager/grafana/config/api/v1/receivers": { + "/alertmanager/grafana/config/api/v1/receivers": { "get": { "description": "Get a list of all receivers", "operationId": "RouteGetGrafanaReceivers", @@ -5126,7 +5317,7 @@ ] } }, - "/api/alertmanager/grafana/config/api/v1/receivers/test": { + "/alertmanager/grafana/config/api/v1/receivers/test": { "post": { "operationId": "RoutePostTestGrafanaReceivers", "parameters": [ @@ -5188,7 +5379,7 @@ ] } }, - "/api/alertmanager/grafana/config/api/v1/templates/test": { + "/alertmanager/grafana/config/api/v1/templates/test": { "post": { "operationId": "RoutePostTestGrafanaTemplates", "parameters": [ @@ -5235,7 +5426,7 @@ ] } }, - "/api/alertmanager/grafana/config/history": { + "/alertmanager/grafana/config/history": { "get": { "description": "gets Alerting configurations that were successfully applied in the past", "operationId": "RouteGetGrafanaAlertingConfigHistory", @@ -5258,7 +5449,7 @@ ] } }, - "/api/alertmanager/grafana/config/history/{id}/_activate": { + "/alertmanager/grafana/config/history/{id}/_activate": { "post": { "description": "revert Alerting configuration to the historical configuration specified by the given id", "operationId": "RoutePostGrafanaAlertingConfigHistoryActivate", @@ -5297,7 +5488,7 @@ ] } }, - "/api/alertmanager/{DatasourceUID}/api/v2/alerts": { + "/alertmanager/{DatasourceUID}/api/v2/alerts": { "get": { "description": "get alertmanager alerts", "operationId": "RouteGetAMAlerts", @@ -5417,7 +5608,7 @@ ] } }, - "/api/alertmanager/{DatasourceUID}/api/v2/alerts/groups": { + "/alertmanager/{DatasourceUID}/api/v2/alerts/groups": { "get": { "description": "get alertmanager alerts", "operationId": "RouteGetAMAlertGroups", @@ -5491,7 +5682,7 @@ ] } }, - "/api/alertmanager/{DatasourceUID}/api/v2/silence/{SilenceId}": { + "/alertmanager/{DatasourceUID}/api/v2/silence/{SilenceId}": { "delete": { "description": "delete silence", "operationId": "RouteDeleteSilence", @@ -5577,7 +5768,7 @@ ] } }, - "/api/alertmanager/{DatasourceUID}/api/v2/silences": { + "/alertmanager/{DatasourceUID}/api/v2/silences": { "get": { "description": "get silences", "operationId": "RouteGetSilences", @@ -5666,7 +5857,7 @@ ] } }, - "/api/alertmanager/{DatasourceUID}/api/v2/status": { + "/alertmanager/{DatasourceUID}/api/v2/status": { "get": { "description": "get alertmanager status and configuration", "operationId": "RouteGetAMStatus", @@ -5704,7 +5895,7 @@ ] } }, - "/api/alertmanager/{DatasourceUID}/config/api/v1/alerts": { + "/alertmanager/{DatasourceUID}/config/api/v1/alerts": { "delete": { "description": "deletes the Alerting config for a tenant", "operationId": "RouteDeleteAlertingConfig", @@ -5821,7 +6012,7 @@ ] } }, - "/api/prometheus/grafana/api/v1/alerts": { + "/prometheus/grafana/api/v1/alerts": { "get": { "description": "gets the current alerts", "operationId": "RouteGetGrafanaAlertStatuses", @@ -5847,7 +6038,7 @@ ] } }, - "/api/prometheus/grafana/api/v1/rules": { + "/prometheus/grafana/api/v1/rules": { "get": { "description": "gets the evaluation statuses of all rules", "operationId": "RouteGetGrafanaRuleStatuses", @@ -5886,7 +6077,7 @@ ] } }, - "/api/prometheus/{DatasourceUID}/api/v1/alerts": { + "/prometheus/{DatasourceUID}/api/v1/alerts": { "get": { "description": "gets the current alerts", "operationId": "RouteGetAlertStatuses", @@ -5918,7 +6109,7 @@ ] } }, - "/api/prometheus/{DatasourceUID}/api/v1/rules": { + "/prometheus/{DatasourceUID}/api/v1/rules": { "get": { "description": "gets the evaluation statuses of all rules", "operationId": "RouteGetRuleStatuses", @@ -5950,7 +6141,7 @@ ] } }, - "/api/ruler/grafana/api/v1/export/rules": { + "/ruler/grafana/api/v1/export/rules": { "get": { "consumes": [ "application/json", @@ -6017,7 +6208,7 @@ ] } }, - "/api/ruler/grafana/api/v1/rules": { + "/ruler/grafana/api/v1/rules": { "get": { "description": "List rule groups", "operationId": "RouteGetGrafanaRulesConfig", @@ -6056,12 +6247,13 @@ ] } }, - "/api/ruler/grafana/api/v1/rules/{Namespace}": { + "/ruler/grafana/api/v1/rules/{Namespace}": { "delete": { "description": "Delete namespace", "operationId": "RouteDeleteNamespaceGrafanaRulesConfig", "parameters": [ { + "description": "The UID of the rule folder", "in": "path", "name": "Namespace", "required": true, @@ -6091,6 +6283,7 @@ "operationId": "RouteGetNamespaceGrafanaRulesConfig", "parameters": [ { + "description": "The UID of the rule folder", "in": "path", "name": "Namespace", "required": true, @@ -6127,6 +6320,7 @@ "operationId": "RoutePostNameGrafanaRulesConfig", "parameters": [ { + "description": "The UID of the rule folder", "in": "path", "name": "Namespace", "required": true, @@ -6159,7 +6353,7 @@ ] } }, - "/api/ruler/grafana/api/v1/rules/{Namespace}/export": { + "/ruler/grafana/api/v1/rules/{Namespace}/export": { "post": { "consumes": [ "application/json", @@ -6169,6 +6363,7 @@ "operationId": "RoutePostRulesGroupForExport", "parameters": [ { + "description": "The UID of the rule folder", "in": "path", "name": "Namespace", "required": true, @@ -6218,12 +6413,13 @@ ] } }, - "/api/ruler/grafana/api/v1/rules/{Namespace}/{Groupname}": { + "/ruler/grafana/api/v1/rules/{Namespace}/{Groupname}": { "delete": { "description": "Delete rule group", "operationId": "RouteDeleteGrafanaRuleGroupConfig", "parameters": [ { + "description": "The UID of the rule folder", "in": "path", "name": "Namespace", "required": true, @@ -6259,6 +6455,7 @@ "operationId": "RouteGetGrafanaRuleGroupConfig", "parameters": [ { + "description": "The UID of the rule folder", "in": "path", "name": "Namespace", "required": true, @@ -6293,7 +6490,7 @@ ] } }, - "/api/ruler/{DatasourceUID}/api/v1/rules": { + "/ruler/{DatasourceUID}/api/v1/rules": { "get": { "description": "List rule groups", "operationId": "RouteGetRulesConfig", @@ -6345,7 +6542,7 @@ ] } }, - "/api/ruler/{DatasourceUID}/api/v1/rules/{Namespace}": { + "/ruler/{DatasourceUID}/api/v1/rules/{Namespace}": { "delete": { "description": "Delete namespace", "operationId": "RouteDeleteNamespaceRulesConfig", @@ -6358,6 +6555,7 @@ "type": "string" }, { + "description": "The UID of the rule folder", "in": "path", "name": "Namespace", "required": true, @@ -6400,6 +6598,7 @@ "type": "string" }, { + "description": "The UID of the rule folder", "in": "path", "name": "Namespace", "required": true, @@ -6449,6 +6648,7 @@ "type": "string" }, { + "description": "The UID of the rule folder", "in": "path", "name": "Namespace", "required": true, @@ -6487,7 +6687,7 @@ ] } }, - "/api/ruler/{DatasourceUID}/api/v1/rules/{Namespace}/{Groupname}": { + "/ruler/{DatasourceUID}/api/v1/rules/{Namespace}/{Groupname}": { "delete": { "description": "Delete rule group", "operationId": "RouteDeleteRuleGroupConfig", @@ -6500,6 +6700,7 @@ "type": "string" }, { + "description": "The UID of the rule folder", "in": "path", "name": "Namespace", "required": true, @@ -6548,6 +6749,7 @@ "type": "string" }, { + "description": "The UID of the rule folder", "in": "path", "name": "Namespace", "required": true, @@ -6588,7 +6790,7 @@ ] } }, - "/api/v1/eval": { + "/v1/eval": { "post": { "consumes": [ "application/json" @@ -6620,7 +6822,7 @@ ] } }, - "/api/v1/ngalert": { + "/v1/ngalert": { "get": { "description": "Get the status of the alerting engine", "operationId": "RouteGetStatus", @@ -6640,7 +6842,7 @@ ] } }, - "/api/v1/ngalert/admin_config": { + "/v1/ngalert/admin_config": { "delete": { "consumes": [ "application/json" @@ -6729,7 +6931,7 @@ ] } }, - "/api/v1/ngalert/alertmanagers": { + "/v1/ngalert/alertmanagers": { "get": { "operationId": "RouteGetAlertmanagers", "produces": [ @@ -6749,7 +6951,148 @@ ] } }, - "/api/v1/provisioning/alert-rules": { + "/v1/notifications/receivers": { + "get": { + "operationId": "RouteGetReceivers", + "parameters": [ + { + "in": "query", + "items": { + "type": "string" + }, + "name": "names", + "type": "array" + }, + { + "format": "int64", + "in": "query", + "name": "limit", + "type": "integer" + }, + { + "format": "int64", + "in": "query", + "name": "offset", + "type": "integer" + }, + { + "in": "query", + "name": "decrypt", + "type": "boolean" + } + ], + "responses": { + "200": { + "$ref": "#/responses/GetReceiversResponse" + }, + "403": { + "description": "PermissionDenied", + "schema": { + "$ref": "#/definitions/PermissionDenied" + } + } + }, + "summary": "Get all receivers.", + "tags": [ + "notifications" + ] + } + }, + "/v1/notifications/receivers/{Name}": { + "get": { + "operationId": "RouteGetReceiver", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "type": "string" + }, + { + "in": "query", + "name": "decrypt", + "type": "boolean" + } + ], + "responses": { + "200": { + "$ref": "#/responses/GetReceiverResponse" + }, + "403": { + "description": "PermissionDenied", + "schema": { + "$ref": "#/definitions/PermissionDenied" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + }, + "summary": "Get a receiver by name.", + "tags": [ + "notifications" + ] + } + }, + "/v1/notifications/time-intervals": { + "get": { + "description": "Get all the time intervals", + "operationId": "RouteNotificationsGetTimeIntervals", + "responses": { + "200": { + "$ref": "#/responses/GetAllIntervalsResponse" + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + }, + "tags": [ + "notifications" + ] + } + }, + "/v1/notifications/time-intervals/{name}": { + "get": { + "operationId": "RouteNotificationsGetTimeInterval", + "parameters": [ + { + "description": "Time interval name", + "in": "path", + "name": "name", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "$ref": "#/responses/GetIntervalsByNameResponse" + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + }, + "summary": "Get a time interval by name.", + "tags": [ + "notifications" + ] + } + }, + "/v1/provisioning/alert-rules": { "get": { "operationId": "RouteGetAlertRules", "responses": { @@ -6804,7 +7147,7 @@ ] } }, - "/api/v1/provisioning/alert-rules/export": { + "/v1/provisioning/alert-rules/export": { "get": { "operationId": "RouteGetAlertRulesExport", "parameters": [ @@ -6861,7 +7204,7 @@ ] } }, - "/api/v1/provisioning/alert-rules/{UID}": { + "/v1/provisioning/alert-rules/{UID}": { "delete": { "operationId": "RouteDeleteAlertRule", "parameters": [ @@ -6961,7 +7304,7 @@ ] } }, - "/api/v1/provisioning/alert-rules/{UID}/export": { + "/v1/provisioning/alert-rules/{UID}/export": { "get": { "operationId": "RouteGetAlertRuleExport", "parameters": [ @@ -7009,7 +7352,7 @@ ] } }, - "/api/v1/provisioning/contact-points": { + "/v1/provisioning/contact-points": { "get": { "operationId": "RouteGetContactpoints", "parameters": [ @@ -7072,7 +7415,7 @@ ] } }, - "/api/v1/provisioning/contact-points/export": { + "/v1/provisioning/contact-points/export": { "get": { "operationId": "RouteGetContactpointsExport", "parameters": [ @@ -7124,7 +7467,7 @@ ] } }, - "/api/v1/provisioning/contact-points/{UID}": { + "/v1/provisioning/contact-points/{UID}": { "delete": { "consumes": [ "application/json" @@ -7195,7 +7538,45 @@ ] } }, - "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": { + "/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": { + "delete": { + "description": "Delete rule group", + "operationId": "RouteDeleteAlertRuleGroup", + "parameters": [ + { + "in": "path", + "name": "FolderUID", + "required": true, + "type": "string" + }, + { + "in": "path", + "name": "Group", + "required": true, + "type": "string" + } + ], + "responses": { + "204": { + "description": " The alert rule group was deleted successfully." + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + }, + "tags": [ + "provisioning" + ] + }, "get": { "operationId": "RouteGetAlertRuleGroup", "parameters": [ @@ -7279,7 +7660,7 @@ ] } }, - "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export": { + "/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export": { "get": { "operationId": "RouteGetAlertRuleGroupExport", "parameters": [ @@ -7332,7 +7713,7 @@ ] } }, - "/api/v1/provisioning/mute-timings": { + "/v1/provisioning/mute-timings": { "get": { "operationId": "RouteGetMuteTimings", "responses": { @@ -7387,7 +7768,7 @@ ] } }, - "/api/v1/provisioning/mute-timings/export": { + "/v1/provisioning/mute-timings/export": { "get": { "operationId": "RouteExportMuteTimings", "parameters": [ @@ -7426,7 +7807,7 @@ ] } }, - "/api/v1/provisioning/mute-timings/{name}": { + "/v1/provisioning/mute-timings/{name}": { "delete": { "operationId": "RouteDeleteMuteTiming", "parameters": [ @@ -7441,6 +7822,12 @@ "responses": { "204": { "description": " The mute timing was deleted successfully." + }, + "409": { + "description": "GenericPublicError", + "schema": { + "$ref": "#/definitions/GenericPublicError" + } } }, "summary": "Delete a mute timing.", @@ -7502,7 +7889,7 @@ } ], "responses": { - "200": { + "202": { "description": "MuteTimeInterval", "schema": { "$ref": "#/definitions/MuteTimeInterval" @@ -7521,7 +7908,7 @@ ] } }, - "/api/v1/provisioning/mute-timings/{name}/export": { + "/v1/provisioning/mute-timings/{name}/export": { "get": { "operationId": "RouteExportMuteTiming", "parameters": [ @@ -7567,7 +7954,7 @@ ] } }, - "/api/v1/provisioning/policies": { + "/v1/provisioning/policies": { "delete": { "consumes": [ "application/json" @@ -7641,7 +8028,7 @@ ] } }, - "/api/v1/provisioning/policies/export": { + "/v1/provisioning/policies/export": { "get": { "operationId": "RouteGetPolicyTreeExport", "responses": { @@ -7664,7 +8051,7 @@ ] } }, - "/api/v1/provisioning/templates": { + "/v1/provisioning/templates": { "get": { "operationId": "RouteGetTemplates", "responses": { @@ -7684,7 +8071,7 @@ ] } }, - "/api/v1/provisioning/templates/{name}": { + "/v1/provisioning/templates/{name}": { "delete": { "operationId": "RouteDeleteTemplate", "parameters": [ @@ -7779,7 +8166,7 @@ ] } }, - "/api/v1/rule/backtest": { + "/v1/rule/backtest": { "post": { "consumes": [ "application/json" @@ -7811,7 +8198,7 @@ ] } }, - "/api/v1/rule/test/grafana": { + "/v1/rule/test/grafana": { "post": { "consumes": [ "application/json" @@ -7852,7 +8239,7 @@ ] } }, - "/api/v1/rule/test/{DatasourceUID}": { + "/v1/rule/test/{DatasourceUID}": { "post": { "consumes": [ "application/json" @@ -7897,7 +8284,7 @@ ] } }, - "/api/v1/rules/history": { + "/v1/rules/history": { "get": { "operationId": "RouteGetStateHistory", "produces": [ @@ -7919,6 +8306,36 @@ "application/json" ], "responses": { + "GetAllIntervalsResponse": { + "description": "", + "schema": { + "items": { + "$ref": "#/definitions/GettableTimeIntervals" + }, + "type": "array" + } + }, + "GetIntervalsByNameResponse": { + "description": "", + "schema": { + "$ref": "#/definitions/GettableTimeIntervals" + } + }, + "GetReceiverResponse": { + "description": "", + "schema": { + "$ref": "#/definitions/GettableApiReceiver" + } + }, + "GetReceiversResponse": { + "description": "", + "schema": { + "items": { + "$ref": "#/definitions/GettableApiReceiver" + }, + "type": "array" + } + }, "GettableHistoricUserConfigs": { "description": "", "schema": { diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index f295e6a339701..aec92052259f4 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -15,9 +15,9 @@ "title": "Grafana Alerting API.", "version": "1.1.0" }, - "basePath": "/api/v1", + "basePath": "/api", "paths": { - "/api/alertmanager/grafana/api/v2/alerts": { + "/alertmanager/grafana/api/v2/alerts": { "get": { "description": "get alertmanager alerts", "tags": [ @@ -78,7 +78,7 @@ } } }, - "/api/alertmanager/grafana/api/v2/alerts/groups": { + "/alertmanager/grafana/api/v2/alerts/groups": { "get": { "description": "get alertmanager alerts", "tags": [ @@ -139,7 +139,7 @@ } } }, - "/api/alertmanager/grafana/api/v2/silence/{SilenceId}": { + "/alertmanager/grafana/api/v2/silence/{SilenceId}": { "get": { "description": "get silence", "tags": [ @@ -199,7 +199,7 @@ } } }, - "/api/alertmanager/grafana/api/v2/silences": { + "/alertmanager/grafana/api/v2/silences": { "get": { "description": "get silences", "tags": [ @@ -262,7 +262,7 @@ } } }, - "/api/alertmanager/grafana/api/v2/status": { + "/alertmanager/grafana/api/v2/status": { "get": { "description": "get alertmanager status and configuration", "tags": [ @@ -285,7 +285,7 @@ } } }, - "/api/alertmanager/grafana/config/api/v1/alerts": { + "/alertmanager/grafana/config/api/v1/alerts": { "get": { "description": "gets an Alerting config", "tags": [ @@ -359,7 +359,7 @@ } } }, - "/api/alertmanager/grafana/config/api/v1/receivers": { + "/alertmanager/grafana/config/api/v1/receivers": { "get": { "description": "Get a list of all receivers", "tags": [ @@ -373,7 +373,7 @@ } } }, - "/api/alertmanager/grafana/config/api/v1/receivers/test": { + "/alertmanager/grafana/config/api/v1/receivers/test": { "post": { "tags": [ "alertmanager" @@ -435,7 +435,7 @@ } } }, - "/api/alertmanager/grafana/config/api/v1/templates/test": { + "/alertmanager/grafana/config/api/v1/templates/test": { "post": { "produces": [ "application/json" @@ -482,7 +482,7 @@ } } }, - "/api/alertmanager/grafana/config/history": { + "/alertmanager/grafana/config/history": { "get": { "description": "gets Alerting configurations that were successfully applied in the past", "tags": [ @@ -505,7 +505,7 @@ } } }, - "/api/alertmanager/grafana/config/history/{id}/_activate": { + "/alertmanager/grafana/config/history/{id}/_activate": { "post": { "description": "revert Alerting configuration to the historical configuration specified by the given id", "tags": [ @@ -544,7 +544,7 @@ } } }, - "/api/alertmanager/{DatasourceUID}/api/v2/alerts": { + "/alertmanager/{DatasourceUID}/api/v2/alerts": { "get": { "description": "get alertmanager alerts", "tags": [ @@ -664,7 +664,7 @@ } } }, - "/api/alertmanager/{DatasourceUID}/api/v2/alerts/groups": { + "/alertmanager/{DatasourceUID}/api/v2/alerts/groups": { "get": { "description": "get alertmanager alerts", "tags": [ @@ -738,7 +738,7 @@ } } }, - "/api/alertmanager/{DatasourceUID}/api/v2/silence/{SilenceId}": { + "/alertmanager/{DatasourceUID}/api/v2/silence/{SilenceId}": { "get": { "description": "get silence", "tags": [ @@ -824,7 +824,7 @@ } } }, - "/api/alertmanager/{DatasourceUID}/api/v2/silences": { + "/alertmanager/{DatasourceUID}/api/v2/silences": { "get": { "description": "get silences", "tags": [ @@ -913,7 +913,7 @@ } } }, - "/api/alertmanager/{DatasourceUID}/api/v2/status": { + "/alertmanager/{DatasourceUID}/api/v2/status": { "get": { "description": "get alertmanager status and configuration", "tags": [ @@ -951,7 +951,7 @@ } } }, - "/api/alertmanager/{DatasourceUID}/config/api/v1/alerts": { + "/alertmanager/{DatasourceUID}/config/api/v1/alerts": { "get": { "description": "gets an Alerting config", "tags": [ @@ -1068,7 +1068,7 @@ } } }, - "/api/prometheus/grafana/api/v1/alerts": { + "/prometheus/grafana/api/v1/alerts": { "get": { "description": "gets the current alerts", "tags": [ @@ -1094,7 +1094,7 @@ } } }, - "/api/prometheus/grafana/api/v1/rules": { + "/prometheus/grafana/api/v1/rules": { "get": { "description": "gets the evaluation statuses of all rules", "tags": [ @@ -1133,7 +1133,7 @@ } } }, - "/api/prometheus/{DatasourceUID}/api/v1/alerts": { + "/prometheus/{DatasourceUID}/api/v1/alerts": { "get": { "description": "gets the current alerts", "tags": [ @@ -1165,7 +1165,7 @@ } } }, - "/api/prometheus/{DatasourceUID}/api/v1/rules": { + "/prometheus/{DatasourceUID}/api/v1/rules": { "get": { "description": "gets the evaluation statuses of all rules", "tags": [ @@ -1197,7 +1197,7 @@ } } }, - "/api/ruler/grafana/api/v1/export/rules": { + "/ruler/grafana/api/v1/export/rules": { "get": { "description": "List rules in provisioning format", "consumes": [ @@ -1264,7 +1264,7 @@ } } }, - "/api/ruler/grafana/api/v1/rules": { + "/ruler/grafana/api/v1/rules": { "get": { "description": "List rule groups", "produces": [ @@ -1303,7 +1303,7 @@ } } }, - "/api/ruler/grafana/api/v1/rules/{Namespace}": { + "/ruler/grafana/api/v1/rules/{Namespace}": { "get": { "description": "Get rule groups by namespace", "produces": [ @@ -1316,6 +1316,7 @@ "parameters": [ { "type": "string", + "description": "The UID of the rule folder", "name": "Namespace", "in": "path", "required": true @@ -1349,6 +1350,7 @@ "parameters": [ { "type": "string", + "description": "The UID of the rule folder", "name": "Namespace", "in": "path", "required": true @@ -1385,6 +1387,7 @@ "parameters": [ { "type": "string", + "description": "The UID of the rule folder", "name": "Namespace", "in": "path", "required": true @@ -1406,7 +1409,7 @@ } } }, - "/api/ruler/grafana/api/v1/rules/{Namespace}/export": { + "/ruler/grafana/api/v1/rules/{Namespace}/export": { "post": { "description": "Converts submitted rule group to provisioning format", "consumes": [ @@ -1420,6 +1423,7 @@ "parameters": [ { "type": "string", + "description": "The UID of the rule folder", "name": "Namespace", "in": "path", "required": true @@ -1465,7 +1469,7 @@ } } }, - "/api/ruler/grafana/api/v1/rules/{Namespace}/{Groupname}": { + "/ruler/grafana/api/v1/rules/{Namespace}/{Groupname}": { "get": { "description": "Get rule group", "produces": [ @@ -1478,6 +1482,7 @@ "parameters": [ { "type": "string", + "description": "The UID of the rule folder", "name": "Namespace", "in": "path", "required": true @@ -1513,6 +1518,7 @@ "parameters": [ { "type": "string", + "description": "The UID of the rule folder", "name": "Namespace", "in": "path", "required": true @@ -1540,7 +1546,7 @@ } } }, - "/api/ruler/{DatasourceUID}/api/v1/rules": { + "/ruler/{DatasourceUID}/api/v1/rules": { "get": { "description": "List rule groups", "produces": [ @@ -1592,7 +1598,7 @@ } } }, - "/api/ruler/{DatasourceUID}/api/v1/rules/{Namespace}": { + "/ruler/{DatasourceUID}/api/v1/rules/{Namespace}": { "get": { "description": "Get rule groups by namespace", "produces": [ @@ -1612,6 +1618,7 @@ }, { "type": "string", + "description": "The UID of the rule folder", "name": "Namespace", "in": "path", "required": true @@ -1658,6 +1665,7 @@ }, { "type": "string", + "description": "The UID of the rule folder", "name": "Namespace", "in": "path", "required": true @@ -1707,6 +1715,7 @@ }, { "type": "string", + "description": "The UID of the rule folder", "name": "Namespace", "in": "path", "required": true @@ -1734,7 +1743,7 @@ } } }, - "/api/ruler/{DatasourceUID}/api/v1/rules/{Namespace}/{Groupname}": { + "/ruler/{DatasourceUID}/api/v1/rules/{Namespace}/{Groupname}": { "get": { "description": "Get rule group", "produces": [ @@ -1754,6 +1763,7 @@ }, { "type": "string", + "description": "The UID of the rule folder", "name": "Namespace", "in": "path", "required": true @@ -1802,6 +1812,7 @@ }, { "type": "string", + "description": "The UID of the rule folder", "name": "Namespace", "in": "path", "required": true @@ -1835,7 +1846,7 @@ } } }, - "/api/v1/eval": { + "/v1/eval": { "post": { "description": "Test rule", "consumes": [ @@ -1867,7 +1878,7 @@ } } }, - "/api/v1/ngalert": { + "/v1/ngalert": { "get": { "description": "Get the status of the alerting engine", "produces": [ @@ -1887,7 +1898,7 @@ } } }, - "/api/v1/ngalert/admin_config": { + "/v1/ngalert/admin_config": { "get": { "produces": [ "application/json" @@ -1976,7 +1987,7 @@ } } }, - "/api/v1/ngalert/alertmanagers": { + "/v1/ngalert/alertmanagers": { "get": { "produces": [ "application/json" @@ -1996,7 +2007,148 @@ } } }, - "/api/v1/provisioning/alert-rules": { + "/v1/notifications/receivers": { + "get": { + "tags": [ + "notifications" + ], + "summary": "Get all receivers.", + "operationId": "RouteGetReceivers", + "parameters": [ + { + "type": "array", + "items": { + "type": "string" + }, + "name": "names", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "name": "offset", + "in": "query" + }, + { + "type": "boolean", + "name": "decrypt", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/GetReceiversResponse" + }, + "403": { + "description": "PermissionDenied", + "schema": { + "$ref": "#/definitions/PermissionDenied" + } + } + } + } + }, + "/v1/notifications/receivers/{Name}": { + "get": { + "tags": [ + "notifications" + ], + "summary": "Get a receiver by name.", + "operationId": "RouteGetReceiver", + "parameters": [ + { + "type": "string", + "name": "name", + "in": "path", + "required": true + }, + { + "type": "boolean", + "name": "decrypt", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/GetReceiverResponse" + }, + "403": { + "description": "PermissionDenied", + "schema": { + "$ref": "#/definitions/PermissionDenied" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + } + } + }, + "/v1/notifications/time-intervals": { + "get": { + "description": "Get all the time intervals", + "tags": [ + "notifications" + ], + "operationId": "RouteNotificationsGetTimeIntervals", + "responses": { + "200": { + "$ref": "#/responses/GetAllIntervalsResponse" + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + } + } + }, + "/v1/notifications/time-intervals/{name}": { + "get": { + "tags": [ + "notifications" + ], + "summary": "Get a time interval by name.", + "operationId": "RouteNotificationsGetTimeInterval", + "parameters": [ + { + "type": "string", + "description": "Time interval name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/GetIntervalsByNameResponse" + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + } + } + }, + "/v1/provisioning/alert-rules": { "get": { "tags": [ "provisioning", @@ -2053,7 +2205,7 @@ } } }, - "/api/v1/provisioning/alert-rules/export": { + "/v1/provisioning/alert-rules/export": { "get": { "tags": [ "provisioning", @@ -2111,7 +2263,7 @@ } } }, - "/api/v1/provisioning/alert-rules/{UID}": { + "/v1/provisioning/alert-rules/{UID}": { "get": { "tags": [ "provisioning", @@ -2214,7 +2366,7 @@ } } }, - "/api/v1/provisioning/alert-rules/{UID}/export": { + "/v1/provisioning/alert-rules/{UID}/export": { "get": { "produces": [ "application/json", @@ -2263,7 +2415,7 @@ } } }, - "/api/v1/provisioning/contact-points": { + "/v1/provisioning/contact-points": { "get": { "tags": [ "provisioning", @@ -2328,7 +2480,7 @@ } } }, - "/api/v1/provisioning/contact-points/export": { + "/v1/provisioning/contact-points/export": { "get": { "tags": [ "provisioning", @@ -2381,7 +2533,7 @@ } } }, - "/api/v1/provisioning/contact-points/{UID}": { + "/v1/provisioning/contact-points/{UID}": { "put": { "consumes": [ "application/json" @@ -2454,7 +2606,7 @@ } } }, - "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": { + "/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": { "get": { "tags": [ "provisioning", @@ -2538,9 +2690,48 @@ } } } + }, + "delete": { + "description": "Delete rule group", + "tags": [ + "provisioning", + "stable" + ], + "operationId": "RouteDeleteAlertRuleGroup", + "parameters": [ + { + "type": "string", + "name": "FolderUID", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "Group", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": " The alert rule group was deleted successfully." + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + } } }, - "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export": { + "/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export": { "get": { "produces": [ "application/json", @@ -2594,7 +2785,7 @@ } } }, - "/api/v1/provisioning/mute-timings": { + "/v1/provisioning/mute-timings": { "get": { "tags": [ "provisioning", @@ -2651,7 +2842,7 @@ } } }, - "/api/v1/provisioning/mute-timings/export": { + "/v1/provisioning/mute-timings/export": { "get": { "tags": [ "provisioning", @@ -2691,7 +2882,7 @@ } } }, - "/api/v1/provisioning/mute-timings/{name}": { + "/v1/provisioning/mute-timings/{name}": { "get": { "tags": [ "provisioning", @@ -2752,7 +2943,7 @@ } ], "responses": { - "200": { + "202": { "description": "MuteTimeInterval", "schema": { "$ref": "#/definitions/MuteTimeInterval" @@ -2785,11 +2976,17 @@ "responses": { "204": { "description": " The mute timing was deleted successfully." + }, + "409": { + "description": "GenericPublicError", + "schema": { + "$ref": "#/definitions/GenericPublicError" + } } } } }, - "/api/v1/provisioning/mute-timings/{name}/export": { + "/v1/provisioning/mute-timings/{name}/export": { "get": { "tags": [ "provisioning", @@ -2836,7 +3033,7 @@ } } }, - "/api/v1/provisioning/policies": { + "/v1/provisioning/policies": { "get": { "tags": [ "provisioning", @@ -2913,7 +3110,7 @@ } } }, - "/api/v1/provisioning/policies/export": { + "/v1/provisioning/policies/export": { "get": { "tags": [ "provisioning", @@ -2937,7 +3134,7 @@ } } }, - "/api/v1/provisioning/templates": { + "/v1/provisioning/templates": { "get": { "tags": [ "provisioning", @@ -2958,7 +3155,7 @@ } } }, - "/api/v1/provisioning/templates/{name}": { + "/v1/provisioning/templates/{name}": { "get": { "tags": [ "provisioning", @@ -3056,7 +3253,7 @@ } } }, - "/api/v1/rule/backtest": { + "/v1/rule/backtest": { "post": { "description": "Test rule", "consumes": [ @@ -3088,7 +3285,7 @@ } } }, - "/api/v1/rule/test/grafana": { + "/v1/rule/test/grafana": { "post": { "description": "Test a rule against Grafana ruler", "consumes": [ @@ -3129,7 +3326,7 @@ } } }, - "/api/v1/rule/test/{DatasourceUID}": { + "/v1/rule/test/{DatasourceUID}": { "post": { "description": "Test a rule against external data source ruler", "consumes": [ @@ -3174,7 +3371,7 @@ } } }, - "/api/v1/rules/history": { + "/v1/rules/history": { "get": { "produces": [ "application/json" @@ -3402,6 +3599,9 @@ "OK" ] }, + "notification_settings": { + "$ref": "#/definitions/AlertRuleNotificationSettingsExport" + }, "panelId": { "type": "integer", "format": "int64" @@ -3469,6 +3669,90 @@ } } }, + "AlertRuleNotificationSettings": { + "type": "object", + "required": [ + "receiver" + ], + "properties": { + "group_by": { + "description": "Override the labels by which incoming alerts are grouped together. For example, multiple alerts coming in for\ncluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels\nuse the special value '...' as the sole label name.\nThis effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what\nyou want, unless you have a very low alert volume or your upstream notification system performs its own grouping.\nMust include 'alertname' and 'grafana_folder' if not using '...'.", + "type": "array", + "default": [ + "alertname", + "grafana_folder" + ], + "items": { + "type": "string" + }, + "example": [ + "alertname", + "grafana_folder", + "cluster" + ] + }, + "group_interval": { + "description": "Override how long to wait before sending a notification about new alerts that are added to a group of alerts for\nwhich an initial notification has already been sent. (Usually ~5m or more.)", + "type": "string", + "example": "5m" + }, + "group_wait": { + "description": "Override how long to initially wait to send a notification for a group of alerts. Allows to wait for an\ninhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.)", + "type": "string", + "example": "30s" + }, + "mute_time_intervals": { + "description": "Override the times when notifications should be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration mute_time_intervals section. When muted it will not send any notifications, but\notherwise acts normally.", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "maintenance" + ] + }, + "receiver": { + "description": "Name of the receiver to send notifications to.", + "type": "string", + "example": "grafana-default-email" + }, + "repeat_interval": { + "description": "Override how long to wait before sending a notification again if it has already been sent successfully for an\nalert. (Usually ~3h or more).\nNote that this parameter is implicitly bound by Alertmanager's `--data.retention` configuration flag.\nNotifications will be resent after either repeat_interval or the data retention period have passed, whichever\noccurs first. `repeat_interval` should not be less than `group_interval`.", + "type": "string", + "example": "4h" + } + } + }, + "AlertRuleNotificationSettingsExport": { + "type": "object", + "title": "AlertRuleNotificationSettingsExport is the provisioned export of models.NotificationSettings.", + "properties": { + "group_by": { + "type": "array", + "items": { + "type": "string" + } + }, + "group_interval": { + "type": "string" + }, + "group_wait": { + "type": "string" + }, + "mute_time_intervals": { + "type": "array", + "items": { + "type": "string" + } + }, + "receiver": { + "type": "string" + }, + "repeat_interval": { + "type": "string" + } + } + }, "AlertingFileExport": { "type": "object", "title": "AlertingFileExport is the full provisioned file export.", @@ -3734,6 +4018,7 @@ } }, "mute_time_intervals": { + "description": "MuteTimeIntervals is deprecated and will be removed before Alertmanager 1.0.", "type": "array", "items": { "$ref": "#/definitions/MuteTimeInterval" @@ -3747,6 +4032,12 @@ "items": { "type": "string" } + }, + "time_intervals": { + "type": "array", + "items": { + "$ref": "#/definitions/TimeInterval" + } } } }, @@ -3842,6 +4133,9 @@ }, "webhook_url": { "$ref": "#/definitions/SecretURL" + }, + "webhook_url_file": { + "type": "string" } } }, @@ -4349,11 +4643,20 @@ }, "typeVersion": { "$ref": "#/definitions/FrameTypeVersion" + }, + "uniqueRowIdFields": { + "description": "Array of field indices which values create a unique id for each row. Ideally this should be globally unique ID\nbut that isn't guarantied. Should help with keeping track and deduplicating rows in visualizations, especially\nwith streaming data with frequent updates.", + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "example": "TraceID in Tempo, table name + primary key in SQL" } } }, "FrameType": { - "description": "A FrameType string, when present in a frame's metadata, asserts that the\nframe's structure conforms to the FrameType's specification.\nThis property is currently optional, so FrameType may be FrameTypeUnknown even if the properties of\nthe Frame correspond to a defined FrameType.", + "description": "A FrameType string, when present in a frame's metadata, asserts that the\nframe's structure conforms to the FrameType's specification.\nThis property is currently optional, so FrameType may be FrameTypeUnknown even if the properties of\nthe Frame correspond to a defined FrameType.\n+enum", "type": "string" }, "FrameTypeVersion": { @@ -4372,6 +4675,14 @@ "$ref": "#/definitions/Frame" } }, + "GenericPublicError": { + "type": "object", + "properties": { + "body": { + "$ref": "#/definitions/PublicError" + } + } + }, "GettableAlertmanagers": { "type": "object", "properties": { @@ -4402,6 +4713,7 @@ } }, "mute_time_intervals": { + "description": "MuteTimeIntervals is deprecated and will be removed before Alertmanager 1.0.", "type": "array", "items": { "$ref": "#/definitions/MuteTimeInterval" @@ -4422,6 +4734,12 @@ "items": { "type": "string" } + }, + "time_intervals": { + "type": "array", + "items": { + "$ref": "#/definitions/TimeInterval" + } } } }, @@ -4635,6 +4953,9 @@ "OK" ] }, + "notification_settings": { + "$ref": "#/definitions/AlertRuleNotificationSettings" + }, "orgId": { "type": "integer", "format": "int64" @@ -4750,6 +5071,23 @@ } } }, + "GettableTimeIntervals": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "provenance": { + "$ref": "#/definitions/Provenance" + }, + "time_intervals": { + "type": "array", + "items": { + "$ref": "#/definitions/TimeIntervalItem" + } + } + } + }, "GettableUserConfig": { "type": "object", "properties": { @@ -5051,6 +5389,9 @@ "send_resolved": { "type": "boolean" }, + "summary": { + "type": "string" + }, "text": { "type": "string" }, @@ -5059,6 +5400,9 @@ }, "webhook_url": { "$ref": "#/definitions/SecretURL" + }, + "webhook_url_file": { + "type": "string" } } }, @@ -5319,9 +5663,18 @@ } } }, + "ObjectMatcher": { + "type": "array", + "title": "ObjectMatcher is a matcher that can be used to filter alerts.", + "items": { + "type": "string" + } + }, "ObjectMatchers": { - "description": "ObjectMatchers is Matchers with a different Unmarshal and Marshal methods that accept matchers as objects\nthat have already been parsed.", - "$ref": "#/definitions/Matchers" + "type": "array", + "items": { + "$ref": "#/definitions/ObjectMatcher" + } }, "OpsGenieConfig": { "type": "object", @@ -5501,25 +5854,8 @@ "PermissionDenied": { "type": "object" }, - "Point": { - "description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.", - "type": "object", - "title": "Point represents a single data point for a given timestamp.", - "properties": { - "H": { - "$ref": "#/definitions/FloatHistogram" - }, - "T": { - "type": "integer", - "format": "int64" - }, - "V": { - "type": "number", - "format": "double" - } - } - }, "PostableApiAlertingConfig": { + "description": "nolint:revive", "type": "object", "properties": { "global": { @@ -5532,6 +5868,7 @@ } }, "mute_time_intervals": { + "description": "MuteTimeIntervals is deprecated and will be removed before Alertmanager 1.0.", "type": "array", "items": { "$ref": "#/definitions/MuteTimeInterval" @@ -5552,10 +5889,17 @@ "items": { "type": "string" } + }, + "time_intervals": { + "type": "array", + "items": { + "$ref": "#/definitions/TimeInterval" + } } } }, "PostableApiReceiver": { + "description": "nolint:revive", "type": "object", "properties": { "discord_configs": { @@ -5774,6 +6118,9 @@ "OK" ] }, + "notification_settings": { + "$ref": "#/definitions/AlertRuleNotificationSettings" + }, "title": { "type": "string" }, @@ -5812,6 +6159,20 @@ } } }, + "PostableTimeIntervals": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "time_intervals": { + "type": "array", + "items": { + "$ref": "#/definitions/TimeIntervalItem" + } + } + } + }, "PostableUserConfig": { "type": "object", "properties": { @@ -5947,6 +6308,9 @@ "OK" ] }, + "notification_settings": { + "$ref": "#/definitions/AlertRuleNotificationSettings" + }, "orgID": { "type": "integer", "format": "int64" @@ -6615,9 +6979,13 @@ } }, "Sample": { + "description": "Sample is a single sample belonging to a metric. It represents either a float\nsample or a histogram sample. If H is nil, it is a float sample. Otherwise,\nit is a histogram sample.", "type": "object", - "title": "Sample is a single sample belonging to a metric.", "properties": { + "F": { + "type": "number", + "format": "double" + }, "H": { "$ref": "#/definitions/FloatHistogram" }, @@ -6627,10 +6995,6 @@ "T": { "type": "integer", "format": "int64" - }, - "V": { - "type": "number", - "format": "double" } } }, @@ -7124,7 +7488,21 @@ "type": "string" }, "TimeInterval": { - "description": "TimeInterval describes intervals of time. ContainsTime will tell you if a golang time is contained\nwithin the interval.", + "type": "object", + "title": "TimeInterval represents a named set of time intervals for which a route should be muted.", + "properties": { + "name": { + "type": "string" + }, + "time_intervals": { + "type": "array", + "items": { + "$ref": "#/definitions/TimeInterval" + } + } + } + }, + "TimeIntervalItem": { "type": "object", "properties": { "days_of_month": { @@ -7145,7 +7523,7 @@ "times": { "type": "array", "items": { - "$ref": "#/definitions/TimeRange" + "$ref": "#/definitions/TimeIntervalTimeRange" } }, "weekdays": { @@ -7162,6 +7540,17 @@ } } }, + "TimeIntervalTimeRange": { + "type": "object", + "properties": { + "end_time": { + "type": "string" + }, + "start_time": { + "type": "string" + } + } + }, "TimeRange": { "description": "Redefining this to avoid an import cycle", "type": "object", @@ -7177,9 +7566,8 @@ } }, "URL": { - "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "type": "object", - "title": "A URL represents a parsed URL (technically, a URI reference).", + "title": "URL is a custom URL type that allows validation at configuration load time.", "properties": { "ForceQuery": { "type": "boolean" @@ -7266,7 +7654,7 @@ } }, "Vector": { - "description": "Vector is basically only an alias for model.Samples, but the\ncontract is that in a Vector, all Samples have the same timestamp.", + "description": "Vector is basically only an alias for []Sample, but the contract is that\nin a Vector, all Samples have the same timestamp.", "type": "array", "items": { "$ref": "#/definitions/Sample" @@ -7419,7 +7807,6 @@ } }, "alertGroup": { - "description": "AlertGroup alert group", "type": "object", "required": [ "alerts", @@ -7444,6 +7831,7 @@ "$ref": "#/definitions/alertGroup" }, "alertGroups": { + "description": "AlertGroups alert groups", "type": "array", "items": { "$ref": "#/definitions/alertGroup" @@ -7549,6 +7937,7 @@ } }, "gettableAlert": { + "description": "GettableAlert gettable alert", "type": "object", "required": [ "labels", @@ -7605,6 +7994,7 @@ "$ref": "#/definitions/gettableAlert" }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "type": "array", "items": { "$ref": "#/definitions/gettableAlert" @@ -7612,7 +8002,6 @@ "$ref": "#/definitions/gettableAlerts" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -7669,6 +8058,7 @@ "$ref": "#/definitions/gettableSilences" }, "integration": { + "description": "Integration integration", "type": "object", "required": [ "name", @@ -7813,7 +8203,6 @@ } }, "postableSilence": { - "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", @@ -7852,6 +8241,7 @@ "$ref": "#/definitions/postableSilence" }, "receiver": { + "description": "Receiver receiver", "type": "object", "required": [ "active", @@ -7967,6 +8357,36 @@ } }, "responses": { + "GetAllIntervalsResponse": { + "description": "", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/GettableTimeIntervals" + } + } + }, + "GetIntervalsByNameResponse": { + "description": "", + "schema": { + "$ref": "#/definitions/GettableTimeIntervals" + } + }, + "GetReceiverResponse": { + "description": "", + "schema": { + "$ref": "#/definitions/GettableApiReceiver" + } + }, + "GetReceiversResponse": { + "description": "", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/GettableApiReceiver" + } + } + }, "GettableHistoricUserConfigs": { "description": "", "schema": { diff --git a/pkg/services/ngalert/api/tooling/swagger-codegen/templates/controller-api.mustache b/pkg/services/ngalert/api/tooling/swagger-codegen/templates/controller-api.mustache index b19a78ac650af..f9902d265c7b6 100644 --- a/pkg/services/ngalert/api/tooling/swagger-codegen/templates/controller-api.mustache +++ b/pkg/services/ngalert/api/tooling/swagger-codegen/templates/controller-api.mustache @@ -36,13 +36,13 @@ func (f *{{classname}}Handler) {{nickname}}(ctx *contextmodel.ReqContext) respon func (api *API) Register{{classname}}Endpoints(srv {{classname}}, m *metrics.API) { api.RouteRegister.Group("", func(group routing.RouteRegister){ {{#operations}}{{#operation}} group.{{httpMethod}}( - toMacaronPath("{{{path}}}"), + toMacaronPath("/api{{{path}}}"), requestmeta.SetOwner(requestmeta.TeamAlerting), requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), - api.authorize(http.Method{{httpMethod}}, "{{{path}}}"), + api.authorize(http.Method{{httpMethod}}, "/api{{{path}}}"), metrics.Instrument( http.Method{{httpMethod}}, - "{{{path}}}", + "/api{{{path}}}", api.Hooks.Wrap(srv.{{nickname}}), m, ), diff --git a/pkg/services/ngalert/backtesting/engine.go b/pkg/services/ngalert/backtesting/engine.go index db0586072451b..30d4c8135b9d6 100644 --- a/pkg/services/ngalert/backtesting/engine.go +++ b/pkg/services/ngalert/backtesting/engine.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/schedule" "github.com/grafana/grafana/pkg/services/ngalert/state" ) @@ -35,6 +36,7 @@ type backtestingEvaluator interface { type stateManager interface { ProcessEvalResults(ctx context.Context, evaluatedAt time.Time, alertRule *models.AlertRule, results eval.Results, extraLabels data.Labels) []state.StateTransition + schedule.RuleStateProvider } type Engine struct { @@ -47,17 +49,16 @@ func NewEngine(appUrl *url.URL, evalFactory eval.EvaluatorFactory, tracer tracin evalFactory: evalFactory, createStateManager: func() stateManager { cfg := state.ManagerCfg{ - Metrics: nil, - ExternalURL: appUrl, - InstanceStore: nil, - Images: &NoopImageService{}, - Clock: clock.New(), - Historian: nil, - MaxStateSaveConcurrency: 1, - Tracer: tracer, - Log: log.New("ngalert.state.manager"), + Metrics: nil, + ExternalURL: appUrl, + InstanceStore: nil, + Images: &NoopImageService{}, + Clock: clock.New(), + Historian: nil, + Tracer: tracer, + Log: log.New("ngalert.state.manager"), } - return state.NewManager(cfg) + return state.NewManager(cfg, state.NewNoopPersister()) }, } } @@ -74,13 +75,16 @@ func (e *Engine) Test(ctx context.Context, user identity.Requester, rule *models } length := int(to.Sub(from).Seconds()) / int(rule.IntervalSeconds) - evaluator, err := backtestingEvaluatorFactory(ruleCtx, e.evalFactory, user, rule.GetEvalCondition()) + stateManager := e.createStateManager() + + evaluator, err := backtestingEvaluatorFactory(ruleCtx, e.evalFactory, user, rule.GetEvalCondition(), &schedule.AlertingResultsFromRuleState{ + Manager: stateManager, + Rule: rule, + }) if err != nil { return nil, errors.Join(ErrInvalidInputData, err) } - stateManager := e.createStateManager() - logger.Info("Start testing alert rule", "from", from, "to", to, "interval", rule.IntervalSeconds, "evaluations", length) start := time.Now() @@ -126,7 +130,7 @@ func (e *Engine) Test(ctx context.Context, user identity.Requester, rule *models return result, nil } -func newBacktestingEvaluator(ctx context.Context, evalFactory eval.EvaluatorFactory, user identity.Requester, condition models.Condition) (backtestingEvaluator, error) { +func newBacktestingEvaluator(ctx context.Context, evalFactory eval.EvaluatorFactory, user identity.Requester, condition models.Condition, reader eval.AlertingResultsReader) (backtestingEvaluator, error) { for _, q := range condition.Data { if q.DatasourceUID == "__data__" || q.QueryType == "__data__" { if len(condition.Data) != 1 { @@ -152,9 +156,7 @@ func newBacktestingEvaluator(ctx context.Context, evalFactory eval.EvaluatorFact } } - evaluator, err := evalFactory.Create(eval.EvaluationContext{Ctx: ctx, - User: user, - }, condition) + evaluator, err := evalFactory.Create(eval.NewContextWithPreviousResults(ctx, user, reader), condition) if err != nil { return nil, err diff --git a/pkg/services/ngalert/backtesting/engine_test.go b/pkg/services/ngalert/backtesting/engine_test.go index f275593cd521d..f8d058f141026 100644 --- a/pkg/services/ngalert/backtesting/engine_test.go +++ b/pkg/services/ngalert/backtesting/engine_test.go @@ -145,7 +145,7 @@ func TestNewBacktestingEvaluator(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - e, err := newBacktestingEvaluator(context.Background(), evalFactory, nil, testCase.condition) + e, err := newBacktestingEvaluator(context.Background(), evalFactory, nil, testCase.condition, nil) if testCase.error { require.Error(t, err) return @@ -175,7 +175,7 @@ func TestEvaluatorTest(t *testing.T) { } manager := &fakeStateManager{} - backtestingEvaluatorFactory = func(ctx context.Context, evalFactory eval.EvaluatorFactory, user identity.Requester, condition models.Condition) (backtestingEvaluator, error) { + backtestingEvaluatorFactory = func(ctx context.Context, evalFactory eval.EvaluatorFactory, user identity.Requester, condition models.Condition, r eval.AlertingResultsReader) (backtestingEvaluator, error) { return evaluator, nil } @@ -386,6 +386,10 @@ func (f *fakeStateManager) ProcessEvalResults(_ context.Context, evaluatedAt tim return f.stateCallback(evaluatedAt) } +func (f *fakeStateManager) GetStatesForRuleUID(orgID int64, alertRuleUID string) []*state.State { + return nil +} + type fakeBacktestingEvaluator struct { evalCallback func(now time.Time) (eval.Results, error) } diff --git a/pkg/services/ngalert/backtesting/eval_data.go b/pkg/services/ngalert/backtesting/eval_data.go index cd0fecdccff2d..999c0bc6302f6 100644 --- a/pkg/services/ngalert/backtesting/eval_data.go +++ b/pkg/services/ngalert/backtesting/eval_data.go @@ -16,8 +16,8 @@ import ( type dataEvaluator struct { refID string data []mathexp.Series - downsampleFunction string - upsampleFunction string + downsampleFunction mathexp.ReducerID + upsampleFunction mathexp.Upsampler } func newDataEvaluator(refID string, frame *data.Frame) (*dataEvaluator, error) { @@ -32,8 +32,8 @@ func newDataEvaluator(refID string, frame *data.Frame) (*dataEvaluator, error) { return &dataEvaluator{ refID: refID, data: series, - downsampleFunction: "last", - upsampleFunction: "pad", + downsampleFunction: mathexp.ReducerLast, + upsampleFunction: mathexp.UpsamplerPad, }, nil } diff --git a/pkg/services/ngalert/client/client.go b/pkg/services/ngalert/client/client.go new file mode 100644 index 0000000000000..90b080c0bc695 --- /dev/null +++ b/pkg/services/ngalert/client/client.go @@ -0,0 +1,72 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strconv" + + "github.com/grafana/dskit/instrument" +) + +// Requester executes an HTTP request. +type Requester interface { + Do(req *http.Request) (*http.Response, error) +} + +// TimedClient instruments a request. It implements Requester. +type TimedClient struct { + client Requester + collector instrument.Collector +} + +type contextKey int + +// OperationNameContextKey specifies the operation name location within the context +// for instrumentation. +const OperationNameContextKey contextKey = 0 + +// NewTimedClient creates a Requester that instruments requests on `client`. +func NewTimedClient(client Requester, collector instrument.Collector) *TimedClient { + return &TimedClient{ + client: client, + collector: collector, + } +} + +// Do executes the request. +func (c TimedClient) Do(r *http.Request) (*http.Response, error) { + return TimeRequest(r.Context(), c.operationName(r), c.collector, c.client, r) +} + +// RoundTrip implements the RoundTripper interface. +func (c TimedClient) RoundTrip(r *http.Request) (*http.Response, error) { + return c.Do(r) +} + +func (c TimedClient) operationName(r *http.Request) string { + operation, _ := r.Context().Value(OperationNameContextKey).(string) + if operation == "" { + operation = r.URL.Path + } + return operation +} + +// TimeRequest performs an HTTP client request and records the duration in a histogram. +func TimeRequest(ctx context.Context, operation string, coll instrument.Collector, client Requester, request *http.Request) (*http.Response, error) { + var response *http.Response + doRequest := func(_ context.Context) error { + var err error + response, err = client.Do(request) // nolint:bodyclose + return err + } + toStatusCode := func(err error) string { + if err == nil { + return strconv.Itoa(response.StatusCode) + } + return "error" + } + err := instrument.CollectedRequest(ctx, fmt.Sprintf("%s %s", request.Method, operation), + coll, toStatusCode, doRequest) + return response, err +} diff --git a/pkg/services/ngalert/client/client_test.go b/pkg/services/ngalert/client/client_test.go new file mode 100644 index 0000000000000..25d26576e662e --- /dev/null +++ b/pkg/services/ngalert/client/client_test.go @@ -0,0 +1,29 @@ +package client + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTimedClient_operationName(t *testing.T) { + r, err := http.NewRequest("GET", "https://weave.test", nil) + assert.NoError(t, err) + + r = r.WithContext(context.WithValue(context.Background(), OperationNameContextKey, "opp")) + c := NewTimedClient(http.DefaultClient, nil) + + assert.Equal(t, "opp", c.operationName(r)) +} + +func TestTimedClient_operationName_Default(t *testing.T) { + r, err := http.NewRequest("GET", "https://weave.test/you/know/me", nil) + assert.NoError(t, err) + + r = r.WithContext(context.Background()) + c := NewTimedClient(http.DefaultClient, nil) + + assert.Equal(t, "/you/know/me", c.operationName(r)) +} diff --git a/pkg/services/ngalert/eval/context.go b/pkg/services/ngalert/eval/context.go index 7315d727fa468..ffff56c4a5413 100644 --- a/pkg/services/ngalert/eval/context.go +++ b/pkg/services/ngalert/eval/context.go @@ -3,13 +3,22 @@ package eval import ( "context" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/services/auth/identity" ) +// AlertingResultsReader provides fingerprints of results that are in alerting state. +// It is used during the evaluation of queries. +type AlertingResultsReader interface { + Read() map[data.Fingerprint]struct{} +} + // EvaluationContext represents the context in which a condition is evaluated. type EvaluationContext struct { - Ctx context.Context - User identity.Requester + Ctx context.Context + User identity.Requester + AlertingResultsReader AlertingResultsReader } func NewContext(ctx context.Context, user identity.Requester) EvaluationContext { @@ -18,3 +27,11 @@ func NewContext(ctx context.Context, user identity.Requester) EvaluationContext User: user, } } + +func NewContextWithPreviousResults(ctx context.Context, user identity.Requester, reader AlertingResultsReader) EvaluationContext { + return EvaluationContext{ + Ctx: ctx, + User: user, + AlertingResultsReader: reader, + } +} diff --git a/pkg/services/ngalert/eval/eval.go b/pkg/services/ngalert/eval/eval.go index 6766c2e4c585c..29eab3ac08c7b 100644 --- a/pkg/services/ngalert/eval/eval.go +++ b/pkg/services/ngalert/eval/eval.go @@ -275,6 +275,23 @@ func (s State) String() string { return [...]string{"Normal", "Alerting", "Pending", "NoData", "Error"}[s] } +func ParseStateString(repr string) (State, error) { + switch strings.ToLower(repr) { + case "normal": + return Normal, nil + case "alerting": + return Alerting, nil + case "pending": + return Pending, nil + case "nodata": + return NoData, nil + case "error": + return Error, nil + default: + return -1, fmt.Errorf("invalid state: %s", repr) + } +} + func buildDatasourceHeaders(ctx context.Context) map[string]string { headers := map[string]string{ // Many data sources check this in query method as sometimes alerting needs special considerations. @@ -298,16 +315,55 @@ func buildDatasourceHeaders(ctx context.Context) map[string]string { } // getExprRequest validates the condition, gets the datasource information and creates an expr.Request from it. -func getExprRequest(ctx EvaluationContext, data []models.AlertQuery, dsCacheService datasources.CacheService) (*expr.Request, error) { +func getExprRequest(ctx EvaluationContext, condition models.Condition, dsCacheService datasources.CacheService, reader AlertingResultsReader) (*expr.Request, error) { req := &expr.Request{ OrgId: ctx.User.GetOrgID(), Headers: buildDatasourceHeaders(ctx.Ctx), User: ctx.User, } + datasources := make(map[string]*datasources.DataSource, len(condition.Data)) + + for _, q := range condition.Data { + var err error + ds, ok := datasources[q.DatasourceUID] + if !ok { + switch nodeType := expr.NodeTypeFromDatasourceUID(q.DatasourceUID); nodeType { + case expr.TypeDatasourceNode: + ds, err = dsCacheService.GetDatasourceByUID(ctx.Ctx, q.DatasourceUID, ctx.User, false /*skipCache*/) + default: + ds, err = expr.DataSourceModelFromNodeType(nodeType) + } + if err != nil { + return nil, fmt.Errorf("failed to build query '%s': %w", q.RefID, err) + } + datasources[q.DatasourceUID] = ds + } + + // TODO rewrite the code below and remove the mutable component from AlertQuery - datasources := make(map[string]*datasources.DataSource, len(data)) + // if the query is command expression and it's a hysteresis, patch it with the current state + // it's important to do this before GetModel + if ds.Type == expr.DatasourceType { + isHysteresis, err := q.IsHysteresisExpression() + if err != nil { + return nil, fmt.Errorf("failed to build query '%s': %w", q.RefID, err) + } + if isHysteresis { + // make sure we allow hysteresis expressions to be specified only as the alert condition. + // This guarantees us that the AlertResultsReader can be correctly applied to the expression tree. + if q.RefID != condition.Condition { + return nil, fmt.Errorf("recovery threshold '%s' is only allowed to be the alert condition", q.RefID) + } + if reader != nil { + logger.FromContext(ctx.Ctx).Debug("Detected hysteresis threshold command. Populating with the results") + err = q.PatchHysteresisExpression(reader.Read()) + if err != nil { + return nil, fmt.Errorf("failed to amend hysteresis command '%s': %w", q.RefID, err) + } + } + } + } - for _, q := range data { model, err := q.GetModel() if err != nil { return nil, fmt.Errorf("failed to get query model from '%s': %w", q.RefID, err) @@ -322,20 +378,6 @@ func getExprRequest(ctx EvaluationContext, data []models.AlertQuery, dsCacheServ return nil, fmt.Errorf("failed to retrieve maxDatapoints from '%s': %w", q.RefID, err) } - ds, ok := datasources[q.DatasourceUID] - if !ok { - switch nodeType := expr.NodeTypeFromDatasourceUID(q.DatasourceUID); nodeType { - case expr.TypeDatasourceNode: - ds, err = dsCacheService.GetDatasourceByUID(ctx.Ctx, q.DatasourceUID, ctx.User, false /*skipCache*/) - default: - ds, err = expr.DataSourceModelFromNodeType(nodeType) - } - if err != nil { - return nil, fmt.Errorf("failed to build query '%s': %w", q.RefID, err) - } - datasources[q.DatasourceUID] = ds - } - req.Queries = append(req.Queries, expr.Query{ TimeRange: q.RelativeTimeRange.ToTimeRange(), DataSource: ds, @@ -724,7 +766,7 @@ func (evalResults Results) AsDataFrame() data.Frame { } func (e *evaluatorImpl) Validate(ctx EvaluationContext, condition models.Condition) error { - req, err := getExprRequest(ctx, condition.Data, e.dataSourceCache) + req, err := getExprRequest(ctx, condition, e.dataSourceCache, ctx.AlertingResultsReader) if err != nil { return err } @@ -760,7 +802,7 @@ func (e *evaluatorImpl) Create(ctx EvaluationContext, condition models.Condition if len(condition.Condition) == 0 { return nil, errors.New("condition must not be empty") } - req, err := getExprRequest(ctx, condition.Data, e.dataSourceCache) + req, err := getExprRequest(ctx, condition, e.dataSourceCache, ctx.AlertingResultsReader) if err != nil { return nil, err } diff --git a/pkg/services/ngalert/eval/eval_test.go b/pkg/services/ngalert/eval/eval_test.go index cb456be06cd97..1a7615ef5dc21 100644 --- a/pkg/services/ngalert/eval/eval_test.go +++ b/pkg/services/ngalert/eval/eval_test.go @@ -524,6 +524,59 @@ func TestValidate(t *testing.T) { } }, }, + { + name: "fail if hysteresis command is not the condition", + error: true, + condition: func(services services) models.Condition { + dsQuery := models.GenerateAlertQuery() + ds := &datasources.DataSource{ + UID: dsQuery.DatasourceUID, + Type: util.GenerateShortUID(), + } + services.cache.DataSources = append(services.cache.DataSources, ds) + services.pluginsStore.PluginList = append(services.pluginsStore.PluginList, pluginstore.Plugin{ + JSONData: plugins.JSONData{ + ID: ds.Type, + Backend: true, + }, + }) + + return models.Condition{ + Condition: "C", + Data: []models.AlertQuery{ + dsQuery, + models.CreateHysteresisExpression(t, "B", dsQuery.RefID, 4, 1), + models.CreateClassicConditionExpression("C", "B", "last", "gt", rand.Int()), + }, + } + }, + }, + { + name: "pass if hysteresis command and it is the condition", + error: false, + condition: func(services services) models.Condition { + dsQuery := models.GenerateAlertQuery() + ds := &datasources.DataSource{ + UID: dsQuery.DatasourceUID, + Type: util.GenerateShortUID(), + } + services.cache.DataSources = append(services.cache.DataSources, ds) + services.pluginsStore.PluginList = append(services.pluginsStore.PluginList, pluginstore.Plugin{ + JSONData: plugins.JSONData{ + ID: ds.Type, + Backend: true, + }, + }) + + return models.Condition{ + Condition: "B", + Data: []models.AlertQuery{ + dsQuery, + models.CreateHysteresisExpression(t, "B", dsQuery.RefID, 4, 1), + }, + } + }, + }, } for _, testCase := range testCases { @@ -550,6 +603,133 @@ func TestValidate(t *testing.T) { } } +func TestCreate_HysteresisCommand(t *testing.T) { + type services struct { + cache *fakes.FakeCacheService + pluginsStore *pluginstore.FakePluginStore + } + + testCases := []struct { + name string + reader AlertingResultsReader + condition func(services services) models.Condition + error bool + }{ + { + name: "fail if hysteresis command is not the condition", + error: true, + condition: func(services services) models.Condition { + dsQuery := models.GenerateAlertQuery() + ds := &datasources.DataSource{ + UID: dsQuery.DatasourceUID, + Type: util.GenerateShortUID(), + } + services.cache.DataSources = append(services.cache.DataSources, ds) + services.pluginsStore.PluginList = append(services.pluginsStore.PluginList, pluginstore.Plugin{ + JSONData: plugins.JSONData{ + ID: ds.Type, + Backend: true, + }, + }) + + return models.Condition{ + Condition: "C", + Data: []models.AlertQuery{ + dsQuery, + models.CreateHysteresisExpression(t, "B", dsQuery.RefID, 4, 1), + models.CreateClassicConditionExpression("C", "B", "last", "gt", rand.Int()), + }, + } + }, + }, + { + name: "populate with loaded metrics", + error: false, + reader: FakeLoadedMetricsReader{fingerprints: map[data.Fingerprint]struct{}{1: {}, 2: {}, 3: {}}}, + condition: func(services services) models.Condition { + dsQuery := models.GenerateAlertQuery() + ds := &datasources.DataSource{ + UID: dsQuery.DatasourceUID, + Type: util.GenerateShortUID(), + } + services.cache.DataSources = append(services.cache.DataSources, ds) + services.pluginsStore.PluginList = append(services.pluginsStore.PluginList, pluginstore.Plugin{ + JSONData: plugins.JSONData{ + ID: ds.Type, + Backend: true, + }, + }) + + return models.Condition{ + Condition: "B", + Data: []models.AlertQuery{ + dsQuery, + models.CreateHysteresisExpression(t, "B", dsQuery.RefID, 4, 1), + }, + } + }, + }, + { + name: "do nothing if reader is not specified", + error: false, + reader: nil, + condition: func(services services) models.Condition { + dsQuery := models.GenerateAlertQuery() + ds := &datasources.DataSource{ + UID: dsQuery.DatasourceUID, + Type: util.GenerateShortUID(), + } + services.cache.DataSources = append(services.cache.DataSources, ds) + services.pluginsStore.PluginList = append(services.pluginsStore.PluginList, pluginstore.Plugin{ + JSONData: plugins.JSONData{ + ID: ds.Type, + Backend: true, + }, + }) + + return models.Condition{ + Condition: "B", + Data: []models.AlertQuery{ + dsQuery, + models.CreateHysteresisExpression(t, "B", dsQuery.RefID, 4, 1), + }, + } + }, + }, + } + + for _, testCase := range testCases { + u := &user.SignedInUser{} + + t.Run(testCase.name, func(t *testing.T) { + cacheService := &fakes.FakeCacheService{} + store := &pluginstore.FakePluginStore{} + condition := testCase.condition(services{ + cache: cacheService, + pluginsStore: store, + }) + evaluator := NewEvaluatorFactory(setting.UnifiedAlertingSettings{}, cacheService, expr.ProvideService(&setting.Cfg{ExpressionsEnabled: true}, nil, nil, featuremgmt.WithFeatures(featuremgmt.FlagRecoveryThreshold), nil, tracing.InitializeTracerForTest()), store) + evalCtx := NewContextWithPreviousResults(context.Background(), u, testCase.reader) + + eval, err := evaluator.Create(evalCtx, condition) + if testCase.error { + require.Error(t, err) + return + } + require.IsType(t, &conditionEvaluator{}, eval) + ce := eval.(*conditionEvaluator) + + cmds := expr.GetCommandsFromPipeline[*expr.HysteresisCommand](ce.pipeline) + require.Len(t, cmds, 1) + if testCase.reader == nil { + require.Empty(t, cmds[0].LoadedDimensions) + } else { + require.EqualValues(t, testCase.reader.Read(), cmds[0].LoadedDimensions) + } + }) + } +} + func TestEvaluate(t *testing.T) { cases := []struct { name string diff --git a/pkg/services/ngalert/eval/testing.go b/pkg/services/ngalert/eval/testing.go index f38ed3fa2d09f..b06d071011bdc 100644 --- a/pkg/services/ngalert/eval/testing.go +++ b/pkg/services/ngalert/eval/testing.go @@ -79,3 +79,11 @@ func WithLabels(labels data.Labels) ResultMutator { r.Instance = labels } } + +type FakeLoadedMetricsReader struct { + fingerprints map[data.Fingerprint]struct{} +} + +func (f FakeLoadedMetricsReader) Read() map[data.Fingerprint]struct{} { + return f.fingerprints +} diff --git a/pkg/services/ngalert/image/service.go b/pkg/services/ngalert/image/service.go index edaedb1383285..39d1e4f9f0bde 100644 --- a/pkg/services/ngalert/image/service.go +++ b/pkg/services/ngalert/image/service.go @@ -93,12 +93,12 @@ func NewScreenshotImageServiceFromCfg(cfg *setting.Cfg, db *store.DBstore, ds da if cfg.UnifiedAlerting.Screenshots.Capture { cache = NewInmemCacheService(screenshotCacheTTL, r) limiter = screenshot.NewTokenRateLimiter(cfg.UnifiedAlerting.Screenshots.MaxConcurrentScreenshots) - screenshots = screenshot.NewHeadlessScreenshotService(ds, rs, r) + screenshots = screenshot.NewHeadlessScreenshotService(cfg, ds, rs, r) screenshotTimeout = cfg.UnifiedAlerting.Screenshots.CaptureTimeout // Image uploading is an optional feature if cfg.UnifiedAlerting.Screenshots.UploadExternalImageStorage { - m, err := imguploader.NewImageUploader() + m, err := imguploader.NewImageUploader(cfg) if err != nil { return nil, fmt.Errorf("failed to initialize uploading screenshot service: %w", err) } diff --git a/pkg/services/ngalert/limits.go b/pkg/services/ngalert/limits.go index ec459d0867347..1ba538eb66f60 100644 --- a/pkg/services/ngalert/limits.go +++ b/pkg/services/ngalert/limits.go @@ -65,14 +65,6 @@ func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { return limits, nil } - var alertOrgQuota int64 - var alertGlobalQuota int64 - - if cfg.UnifiedAlerting.IsEnabled() { - alertOrgQuota = cfg.Quota.Org.AlertRule - alertGlobalQuota = cfg.Quota.Global.AlertRule - } - globalQuotaTag, err := quota.NewTag(models.QuotaTargetSrv, models.QuotaTarget, quota.GlobalScope) if err != nil { return limits, err @@ -82,7 +74,7 @@ func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { return limits, err } - limits.Set(globalQuotaTag, alertGlobalQuota) - limits.Set(orgQuotaTag, alertOrgQuota) + limits.Set(globalQuotaTag, cfg.Quota.Global.AlertRule) + limits.Set(orgQuotaTag, cfg.Quota.Org.AlertRule) return limits, nil } diff --git a/pkg/services/ngalert/metrics/alertmanager.go b/pkg/services/ngalert/metrics/alertmanager.go index 58b4630b04321..2ae5bae521044 100644 --- a/pkg/services/ngalert/metrics/alertmanager.go +++ b/pkg/services/ngalert/metrics/alertmanager.go @@ -18,12 +18,13 @@ func NewAlertmanagerMetrics(r prometheus.Registerer) *Alertmanager { other := prometheus.WrapRegistererWithPrefix(fmt.Sprintf("%s_%s_", Namespace, Subsystem), r) return &Alertmanager{ Registerer: r, - Alerts: metrics.NewAlerts("grafana", other), + Alerts: metrics.NewAlerts(other), AlertmanagerConfigMetrics: NewAlertmanagerConfigMetrics(r), } } type AlertmanagerConfigMetrics struct { + ConfigHash *prometheus.GaugeVec Matchers prometheus.Gauge MatchRE prometheus.Gauge Match prometheus.Gauge @@ -32,6 +33,10 @@ type AlertmanagerConfigMetrics struct { func NewAlertmanagerConfigMetrics(r prometheus.Registerer) *AlertmanagerConfigMetrics { m := &AlertmanagerConfigMetrics{ + ConfigHash: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "alertmanager_config_hash", + Help: "The hash of the Alertmanager configuration.", + }, []string{"org"}), Matchers: prometheus.NewGauge(prometheus.GaugeOpts{ Name: "alertmanager_config_matchers", Help: "The total number of matchers", @@ -50,7 +55,7 @@ func NewAlertmanagerConfigMetrics(r prometheus.Registerer) *AlertmanagerConfigMe }), } if r != nil { - r.MustRegister(m.Matchers, m.MatchRE, m.Match, m.ObjectMatchers) + r.MustRegister(m.ConfigHash, m.Matchers, m.MatchRE, m.Match, m.ObjectMatchers) } return m } diff --git a/pkg/services/ngalert/metrics/historian.go b/pkg/services/ngalert/metrics/historian.go index 9d2febf9890b7..aba23d9816ade 100644 --- a/pkg/services/ngalert/metrics/historian.go +++ b/pkg/services/ngalert/metrics/historian.go @@ -1,9 +1,9 @@ package metrics import ( + "github.com/grafana/dskit/instrument" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/weaveworks/common/instrument" ) type Historian struct { diff --git a/pkg/services/ngalert/metrics/multi_org_alertmanager.go b/pkg/services/ngalert/metrics/multi_org_alertmanager.go index 150019f774d9c..7ed927c901f51 100644 --- a/pkg/services/ngalert/metrics/multi_org_alertmanager.go +++ b/pkg/services/ngalert/metrics/multi_org_alertmanager.go @@ -73,10 +73,11 @@ type AlertmanagerAggregatedMetrics struct { registries *metrics.TenantRegistries // metrics gather from the in-house "Alertmanager" directly. - numReceivedAlerts *prometheus.Desc - numInvalidAlerts *prometheus.Desc - configuredReceivers *prometheus.Desc - configuredIntegrations *prometheus.Desc + numReceivedAlerts *prometheus.Desc + numInvalidAlerts *prometheus.Desc + configuredReceivers *prometheus.Desc + configuredIntegrations *prometheus.Desc + configuredInhibitionRules *prometheus.Desc // exported metrics, gathered from Alertmanager PipelineBuilder numNotifications *prometheus.Desc @@ -117,6 +118,8 @@ type AlertmanagerAggregatedMetrics struct { matchRE *prometheus.Desc match *prometheus.Desc objectMatchers *prometheus.Desc + + configHash *prometheus.Desc } func NewAlertmanagerAggregatedMetrics(registries *metrics.TenantRegistries) *AlertmanagerAggregatedMetrics { @@ -139,6 +142,10 @@ func NewAlertmanagerAggregatedMetrics(registries *metrics.TenantRegistries) *Ale fmt.Sprintf("%s_%s_alertmanager_integrations", Namespace, Subsystem), "Number of configured receivers.", []string{"org", "type"}, nil), + configuredInhibitionRules: prometheus.NewDesc( + fmt.Sprintf("%s_%s_alertmanager_inhibition_rules", Namespace, Subsystem), + "Number of configured inhibition rules.", + []string{"org"}, nil), numNotifications: prometheus.NewDesc( fmt.Sprintf("%s_%s_notifications_total", Namespace, Subsystem), @@ -253,6 +260,11 @@ func NewAlertmanagerAggregatedMetrics(registries *metrics.TenantRegistries) *Ale fmt.Sprintf("%s_%s_alertmanager_config_object_matchers", Namespace, Subsystem), "The total number of object_matchers", nil, nil), + + configHash: prometheus.NewDesc( + fmt.Sprintf("%s_%s_alertmanager_config_hash", Namespace, Subsystem), + "The hash of the Alertmanager configuration.", + []string{"org"}, nil), } return aggregatedMetrics @@ -263,6 +275,7 @@ func (a *AlertmanagerAggregatedMetrics) Describe(out chan<- *prometheus.Desc) { out <- a.numInvalidAlerts out <- a.configuredReceivers out <- a.configuredIntegrations + out <- a.configuredInhibitionRules out <- a.numNotifications out <- a.numFailedNotifications @@ -296,6 +309,8 @@ func (a *AlertmanagerAggregatedMetrics) Describe(out chan<- *prometheus.Desc) { out <- a.matchRE out <- a.match out <- a.objectMatchers + + out <- a.configHash } func (a *AlertmanagerAggregatedMetrics) Collect(out chan<- prometheus.Metric) { @@ -305,6 +320,7 @@ func (a *AlertmanagerAggregatedMetrics) Collect(out chan<- prometheus.Metric) { data.SendSumOfCountersPerTenant(out, a.numInvalidAlerts, "alertmanager_alerts_invalid_total") data.SendSumOfGaugesPerTenantWithLabels(out, a.configuredReceivers, "grafana_alerting_alertmanager_receivers", "state") data.SendSumOfGaugesPerTenantWithLabels(out, a.configuredIntegrations, "grafana_alerting_alertmanager_integrations", "type") + data.SendSumOfGaugesPerTenant(out, a.configuredInhibitionRules, "grafana_alerting_alertmanager_inhibition_rules") data.SendSumOfCountersPerTenant(out, a.numNotifications, "alertmanager_notifications_total", metrics.WithLabels("integration"), metrics.WithSkipZeroValueMetrics) data.SendSumOfCountersPerTenant(out, a.numFailedNotifications, "alertmanager_notifications_failed_total", metrics.WithLabels("integration"), metrics.WithSkipZeroValueMetrics) @@ -338,4 +354,6 @@ func (a *AlertmanagerAggregatedMetrics) Collect(out chan<- prometheus.Metric) { data.SendSumOfGauges(out, a.matchRE, "alertmanager_config_match_re") data.SendSumOfGauges(out, a.match, "alertmanager_config_match") data.SendSumOfGauges(out, a.objectMatchers, "alertmanager_config_object_matchers") + + data.SendMaxOfGaugesPerTenant(out, a.configHash, "alertmanager_config_hash") } diff --git a/pkg/services/ngalert/metrics/ngalert.go b/pkg/services/ngalert/metrics/ngalert.go index e5f7df291e2ba..d03597f89b7c6 100644 --- a/pkg/services/ngalert/metrics/ngalert.go +++ b/pkg/services/ngalert/metrics/ngalert.go @@ -30,6 +30,7 @@ type NGAlert struct { multiOrgAlertmanagerMetrics *MultiOrgAlertmanager apiMetrics *API historianMetrics *Historian + remoteAlertmanagerMetrics *RemoteAlertmanager } // NewNGAlert manages the metrics of all the alerting components. @@ -41,6 +42,7 @@ func NewNGAlert(r prometheus.Registerer) *NGAlert { multiOrgAlertmanagerMetrics: NewMultiOrgAlertmanagerMetrics(r), apiMetrics: NewAPIMetrics(r), historianMetrics: NewHistorianMetrics(r, Subsystem), + remoteAlertmanagerMetrics: NewRemoteAlertmanagerMetrics(r), } } @@ -63,3 +65,7 @@ func (ng *NGAlert) GetMultiOrgAlertmanagerMetrics() *MultiOrgAlertmanager { func (ng *NGAlert) GetHistorianMetrics() *Historian { return ng.historianMetrics } + +func (ng *NGAlert) GetRemoteAlertmanagerMetrics() *RemoteAlertmanager { + return ng.remoteAlertmanagerMetrics +} diff --git a/pkg/services/ngalert/metrics/remote_alertmanager.go b/pkg/services/ngalert/metrics/remote_alertmanager.go new file mode 100644 index 0000000000000..5963f9facb6dd --- /dev/null +++ b/pkg/services/ngalert/metrics/remote_alertmanager.go @@ -0,0 +1,84 @@ +package metrics + +import ( + "github.com/grafana/dskit/instrument" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +const ( + ModeRemoteSecondary = "remote_secondary" + ModeRemotePrimary = "remote_primary" + ModeRemoteOnly = "remote_only" +) + +type RemoteAlertmanager struct { + Info *prometheus.GaugeVec + RequestLatency *instrument.HistogramCollector + LastReadinessCheck prometheus.Gauge + ConfigSyncsTotal prometheus.Counter + ConfigSyncErrorsTotal prometheus.Counter + LastConfigSync prometheus.Gauge + StateSyncsTotal prometheus.Counter + StateSyncErrorsTotal prometheus.Counter + LastStateSync prometheus.Gauge +} + +func NewRemoteAlertmanagerMetrics(r prometheus.Registerer) *RemoteAlertmanager { + return &RemoteAlertmanager{ + Info: promauto.With(r).NewGaugeVec(prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "remote_alertmanager_info", + Help: "Information about the remote Alertmanager.", + }, []string{"mode"}), + RequestLatency: instrument.NewHistogramCollector(promauto.With(r).NewHistogramVec(prometheus.HistogramOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "remote_alertmanager_latency_seconds", + Help: "Histogram of request latencies to the remote Alertmanager.", + }, instrument.HistogramCollectorBuckets)), + LastReadinessCheck: promauto.With(r).NewGauge(prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "remote_alertmanager_last_readiness_check_timestamp_seconds", + Help: "Timestamp of the last successful readiness check to the remote Alertmanager in seconds.", + }), + ConfigSyncsTotal: promauto.With(r).NewCounter(prometheus.CounterOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "remote_alertmanager_configuration_syncs_total", + Help: "Total number of configuration syncs to the remote Alertmanager.", + }), + ConfigSyncErrorsTotal: promauto.With(r).NewCounter(prometheus.CounterOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "remote_alertmanager_configuration_sync_failures_total", + Help: "Total number of failed attempts to sync configurations between Alertmanagers.", + }), + LastConfigSync: promauto.With(r).NewGauge(prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "remote_alertmanager_last_configuration_sync_timestamp_seconds", + Help: "Timestamp of the last successful configuration sync to the remote Alertmanager in seconds.", + }), + StateSyncsTotal: promauto.With(r).NewCounter(prometheus.CounterOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "remote_alertmanager_state_syncs_total", + Help: "Total number of state syncs to the remote Alertmanager.", + }), + StateSyncErrorsTotal: promauto.With(r).NewCounter(prometheus.CounterOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "remote_alertmanager_state_sync_failures_total", + Help: "Total number of failed attempts to sync state between Alertmanagers.", + }), + LastStateSync: promauto.With(r).NewGauge(prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "remote_alertmanager_last_state_sync_timestamp_seconds", + Help: "Timestamp of the last successful state sync to the remote Alertmanager in seconds.", + }), + } +} diff --git a/pkg/services/ngalert/metrics/scheduler.go b/pkg/services/ngalert/metrics/scheduler.go index d3f045a46404e..35b56b957323e 100644 --- a/pkg/services/ngalert/metrics/scheduler.go +++ b/pkg/services/ngalert/metrics/scheduler.go @@ -20,7 +20,9 @@ type Scheduler struct { EvalDuration *prometheus.HistogramVec ProcessDuration *prometheus.HistogramVec SendDuration *prometheus.HistogramVec + SimpleNotificationRules *prometheus.GaugeVec GroupRules *prometheus.GaugeVec + Groups *prometheus.GaugeVec SchedulePeriodicDuration prometheus.Histogram SchedulableAlertRules prometheus.Gauge SchedulableAlertRulesHash prometheus.Gauge @@ -90,6 +92,15 @@ func NewSchedulerMetrics(r prometheus.Registerer) *Scheduler { }, []string{"org"}, ), + SimpleNotificationRules: promauto.With(r).NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "simple_routing_rules", + Help: "The number of alert rules using simplified routing.", + }, + []string{"org"}, + ), // TODO: partition on rule group as well as tenant, similar to loki|cortex. GroupRules: promauto.With(r).NewGaugeVec( prometheus.GaugeOpts{ @@ -100,6 +111,15 @@ func NewSchedulerMetrics(r prometheus.Registerer) *Scheduler { }, []string{"org", "state"}, ), + Groups: promauto.With(r).NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "rule_groups", + Help: "The number of alert rule groups", + }, + []string{"org"}, + ), SchedulePeriodicDuration: promauto.With(r).NewHistogram( prometheus.HistogramOpts{ Namespace: Namespace, diff --git a/pkg/services/ngalert/metrics/state.go b/pkg/services/ngalert/metrics/state.go index 83b87f72d0574..92093ad6efa3f 100644 --- a/pkg/services/ngalert/metrics/state.go +++ b/pkg/services/ngalert/metrics/state.go @@ -6,8 +6,9 @@ import ( ) type State struct { - StateUpdateDuration prometheus.Histogram - r prometheus.Registerer + StateUpdateDuration prometheus.Histogram + StateFullSyncDuration prometheus.Histogram + r prometheus.Registerer } // Registerer exposes the Prometheus register directly. The state package needs this as, it uses a collector to fetch the current alerts by state in the system. @@ -27,5 +28,14 @@ func NewStateMetrics(r prometheus.Registerer) *State { Buckets: []float64{0.01, 0.1, 1, 2, 5, 10}, }, ), + StateFullSyncDuration: promauto.With(r).NewHistogram( + prometheus.HistogramOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "state_full_sync_duration_seconds", + Help: "The duration of fully synchronizing the state with the database.", + Buckets: []float64{0.01, 0.1, 1, 2, 5, 10, 60}, + }, + ), } } diff --git a/pkg/services/ngalert/migration/alert_rule.go b/pkg/services/ngalert/migration/alert_rule.go deleted file mode 100644 index 8ea089c372457..0000000000000 --- a/pkg/services/ngalert/migration/alert_rule.go +++ /dev/null @@ -1,321 +0,0 @@ -package migration - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/prometheus/common/model" - - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/infra/log" - legacymodels "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/datasources" - migmodels "github.com/grafana/grafana/pkg/services/ngalert/migration/models" - ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/store" - "github.com/grafana/grafana/pkg/tsdb/graphite" - "github.com/grafana/grafana/pkg/util" -) - -func addLabelsAndAnnotations(l log.Logger, alert *legacymodels.Alert, dashboardUID string, channels []*legacymodels.AlertNotification) (data.Labels, data.Labels) { - tags := alert.GetTagsFromSettings() - lbls := make(data.Labels, len(tags)+len(channels)+1) - - for _, t := range tags { - lbls[t.Key] = t.Value - } - - // Add a label for routing - lbls[ngmodels.MigratedUseLegacyChannelsLabel] = "true" - for _, c := range channels { - lbls[contactLabel(c.Name)] = "true" - } - - annotations := make(data.Labels, 4) - annotations[ngmodels.DashboardUIDAnnotation] = dashboardUID - annotations[ngmodels.PanelIDAnnotation] = fmt.Sprintf("%v", alert.PanelID) - annotations[ngmodels.MigratedAlertIdAnnotation] = fmt.Sprintf("%v", alert.ID) - - message := MigrateTmpl(l.New("field", "message"), alert.Message) - annotations[ngmodels.MigratedMessageAnnotation] = message - - return lbls, annotations -} - -// migrateAlert migrates a single dashboard alert from legacy alerting to unified alerting. -func (om *OrgMigration) migrateAlert(ctx context.Context, l log.Logger, alert *legacymodels.Alert, info migmodels.DashboardUpgradeInfo) (*ngmodels.AlertRule, error) { - l.Debug("Migrating alert rule to Unified Alerting") - rawSettings, err := json.Marshal(alert.Settings) - if err != nil { - return nil, fmt.Errorf("get settings: %w", err) - } - var parsedSettings dashAlertSettings - err = json.Unmarshal(rawSettings, &parsedSettings) - if err != nil { - return nil, fmt.Errorf("parse settings: %w", err) - } - cond, err := transConditions(ctx, l, parsedSettings, alert.OrgID, om.migrationStore) - if err != nil { - return nil, fmt.Errorf("transform conditions: %w", err) - } - - channels := om.extractChannels(l, parsedSettings) - - lbls, annotations := addLabelsAndAnnotations(l, alert, info.DashboardUID, channels) - - data, err := migrateAlertRuleQueries(l, cond.Data) - if err != nil { - return nil, fmt.Errorf("queries: %w", err) - } - - isPaused := false - if alert.State == "paused" { - isPaused = true - } - - // Here we ensure that the alert rule title is unique within the folder. - titleDeduplicator := om.titleDeduplicatorForFolder(info.NewFolderUID) - name, err := titleDeduplicator.Deduplicate(alert.Name) - if err != nil { - return nil, err - } - if name != alert.Name { - l.Info(fmt.Sprintf("Alert rule title modified to be unique within the folder and fit within the maximum length of %d", store.AlertDefinitionMaxTitleLength), "old", alert.Name, "new", name) - } - - dashUID := info.DashboardUID - ar := &ngmodels.AlertRule{ - OrgID: alert.OrgID, - Title: name, - UID: util.GenerateShortUID(), - Condition: cond.Condition, - Data: data, - IntervalSeconds: ruleAdjustInterval(alert.Frequency), - Version: 1, - NamespaceUID: info.NewFolderUID, - DashboardUID: &dashUID, - PanelID: &alert.PanelID, - RuleGroup: groupName(ruleAdjustInterval(alert.Frequency), info.DashboardName), - For: alert.For, - Updated: time.Now().UTC(), - Annotations: annotations, - Labels: lbls, - RuleGroupIndex: 1, // Every rule is in its own group. - IsPaused: isPaused, - NoDataState: transNoData(l, parsedSettings.NoDataState), - ExecErrState: transExecErr(l, parsedSettings.ExecutionErrorState), - } - - // Label for routing and silences. - n, v := getLabelForSilenceMatching(ar.UID) - ar.Labels[n] = v - - if parsedSettings.ExecutionErrorState == string(legacymodels.ExecutionErrorKeepState) { - if err := om.addErrorSilence(ar); err != nil { - om.log.Error("Alert migration error: failed to create silence for Error", "rule_name", ar.Title, "err", err) - } - } - - if parsedSettings.NoDataState == string(legacymodels.NoDataKeepState) { - if err := om.addNoDataSilence(ar); err != nil { - om.log.Error("Alert migration error: failed to create silence for NoData", "rule_name", ar.Title, "err", err) - } - } - - return ar, nil -} - -// migrateAlertRuleQueries attempts to fix alert rule queries so they can work in unified alerting. Queries of some data sources are not compatible with unified alerting. -func migrateAlertRuleQueries(l log.Logger, data []ngmodels.AlertQuery) ([]ngmodels.AlertQuery, error) { - result := make([]ngmodels.AlertQuery, 0, len(data)) - for _, d := range data { - // queries that are expression are not relevant, skip them. - if d.DatasourceUID == expressionDatasourceUID { - result = append(result, d) - continue - } - var fixedData map[string]json.RawMessage - err := json.Unmarshal(d.Model, &fixedData) - if err != nil { - return nil, err - } - // remove hidden tag from the query (if exists) - delete(fixedData, "hide") - fixedData = fixGraphiteReferencedSubQueries(fixedData) - fixedData = fixPrometheusBothTypeQuery(l, fixedData) - updatedModel, err := json.Marshal(fixedData) - if err != nil { - return nil, err - } - d.Model = updatedModel - result = append(result, d) - } - return result, nil -} - -// fixGraphiteReferencedSubQueries attempts to fix graphite referenced sub queries, given unified alerting does not support this. -// targetFull of Graphite data source contains the expanded version of field 'target', so let's copy that. -func fixGraphiteReferencedSubQueries(queryData map[string]json.RawMessage) map[string]json.RawMessage { - fullQuery, ok := queryData[graphite.TargetFullModelField] - if ok { - delete(queryData, graphite.TargetFullModelField) - queryData[graphite.TargetModelField] = fullQuery - } - - return queryData -} - -// fixPrometheusBothTypeQuery converts Prometheus 'Both' type queries to range queries. -func fixPrometheusBothTypeQuery(l log.Logger, queryData map[string]json.RawMessage) map[string]json.RawMessage { - // There is the possibility to support this functionality by: - // - Splitting the query into two: one for instant and one for range. - // - Splitting the condition into two: one for each query, separated by OR. - // However, relying on a 'Both' query instead of multiple conditions to do this in legacy is likely - // to be unintentional. In addition, this would require more robust operator precedence in classic conditions. - // Given these reasons, we opt to convert them to range queries and log a warning. - - var instant bool - if instantRaw, ok := queryData["instant"]; ok { - if err := json.Unmarshal(instantRaw, &instant); err != nil { - // Nothing to do here, we can't parse the instant field. - if isPrometheus, _ := isPrometheusQuery(queryData); isPrometheus { - l.Info("Failed to parse instant field on Prometheus query", "instant", string(instantRaw), "err", err) - } - return queryData - } - } - var rng bool - if rangeRaw, ok := queryData["range"]; ok { - if err := json.Unmarshal(rangeRaw, &rng); err != nil { - // Nothing to do here, we can't parse the range field. - if isPrometheus, _ := isPrometheusQuery(queryData); isPrometheus { - l.Info("Failed to parse range field on Prometheus query", "range", string(rangeRaw), "err", err) - } - return queryData - } - } - - if !instant || !rng { - // Only apply this fix to 'Both' type queries. - return queryData - } - - isPrometheus, err := isPrometheusQuery(queryData) - if err != nil { - l.Info("Unable to convert alert rule that resembles a Prometheus 'Both' type query to 'Range'", "err", err) - return queryData - } - if !isPrometheus { - // Only apply this fix to Prometheus. - return queryData - } - - // Convert 'Both' type queries to `Range` queries by disabling the `Instant` portion. - l.Warn("Prometheus 'Both' type queries are not supported in unified alerting. Converting to range query.") - queryData["instant"] = []byte("false") - - return queryData -} - -// isPrometheusQuery checks if the query is for Prometheus. -func isPrometheusQuery(queryData map[string]json.RawMessage) (bool, error) { - ds, ok := queryData["datasource"] - if !ok { - return false, fmt.Errorf("missing datasource field") - } - var datasource struct { - Type string `json:"type"` - } - if err := json.Unmarshal(ds, &datasource); err != nil { - return false, fmt.Errorf("parse datasource '%s': %w", string(ds), err) - } - if datasource.Type == "" { - return false, fmt.Errorf("missing type field '%s'", string(ds)) - } - return datasource.Type == datasources.DS_PROMETHEUS, nil -} - -func ruleAdjustInterval(freq int64) int64 { - // 10 corresponds to the SchedulerCfg, but TODO not worrying about fetching for now. - var baseFreq int64 = 10 - if freq <= baseFreq { - return 10 - } - return freq - (freq % baseFreq) -} - -func transNoData(l log.Logger, s string) ngmodels.NoDataState { - switch legacymodels.NoDataOption(s) { - case legacymodels.NoDataSetOK: - return ngmodels.OK // values from ngalert/models/rule - case "", legacymodels.NoDataSetNoData: - return ngmodels.NoData - case legacymodels.NoDataSetAlerting: - return ngmodels.Alerting - case legacymodels.NoDataKeepState: - return ngmodels.NoData // "keep last state" translates to no data because we now emit a special alert when the state is "noData". The result is that the evaluation will not return firing and instead we'll raise the special alert. - default: - l.Warn("Unable to translate execution of NoData state. Using default execution", "old", s, "new", ngmodels.NoData) - return ngmodels.NoData - } -} - -func transExecErr(l log.Logger, s string) ngmodels.ExecutionErrorState { - switch legacymodels.ExecutionErrorOption(s) { - case "", legacymodels.ExecutionErrorSetAlerting: - return ngmodels.AlertingErrState - case legacymodels.ExecutionErrorKeepState: - // Keep last state is translated to error as we now emit a - // DatasourceError alert when the state is error - return ngmodels.ErrorErrState - case legacymodels.ExecutionErrorSetOk: - return ngmodels.OkErrState - default: - l.Warn("Unable to translate execution of Error state. Using default execution", "old", s, "new", ngmodels.ErrorErrState) - return ngmodels.ErrorErrState - } -} - -// truncate truncates the given name to the maximum allowed length. -func truncate(daName string, length int) string { - if len(daName) > length { - return daName[:length] - } - return daName -} - -// extractChannels extracts notification channels from the given legacy dashboard alert parsed settings. -func (om *OrgMigration) extractChannels(l log.Logger, parsedSettings dashAlertSettings) []*legacymodels.AlertNotification { - // Extracting channels. - channels := make([]*legacymodels.AlertNotification, 0, len(parsedSettings.Notifications)) - for _, key := range parsedSettings.Notifications { - // Either id or uid can be defined in the dashboard alert notification settings. See alerting.NewRuleFromDBAlert. - if key.ID > 0 { - if c, ok := om.channelCache.GetChannelByID(key.ID); ok { - channels = append(channels, c) - continue - } - } - - if key.UID != "" { - if c, ok := om.channelCache.GetChannelByUID(key.UID); ok { - channels = append(channels, c) - continue - } - } - - l.Warn("Failed to get alert notification, skipping", "notificationKey", key) - } - return channels -} - -// groupName constructs a group name from the dashboard title and the interval. It truncates the dashboard title -// if necessary to ensure that the group name is not longer than the maximum allowed length. -func groupName(interval int64, dashboardTitle string) string { - duration := model.Duration(time.Duration(interval) * time.Second) // Humanize. - panelSuffix := fmt.Sprintf(" - %s", duration.String()) - truncatedDashboard := truncate(dashboardTitle, store.AlertRuleMaxRuleGroupNameLength-len(panelSuffix)) - return fmt.Sprintf("%s%s", truncatedDashboard, panelSuffix) -} diff --git a/pkg/services/ngalert/migration/alert_rule_test.go b/pkg/services/ngalert/migration/alert_rule_test.go deleted file mode 100644 index a45a0595dc7e0..0000000000000 --- a/pkg/services/ngalert/migration/alert_rule_test.go +++ /dev/null @@ -1,319 +0,0 @@ -package migration - -import ( - "context" - "fmt" - "strings" - "testing" - - "github.com/google/uuid" - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/log/logtest" - legacymodels "github.com/grafana/grafana/pkg/services/alerting/models" - migmodels "github.com/grafana/grafana/pkg/services/ngalert/migration/models" - "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/store" -) - -func TestMigrateAlertRuleQueries(t *testing.T) { - tc := []struct { - name string - input *simplejson.Json - expected string - err error - }{ - { - name: "when a query has a sub query - it is extracted", - input: simplejson.NewFromAny(map[string]any{"targetFull": "thisisafullquery", "target": "ahalfquery"}), - expected: `{"target":"thisisafullquery"}`, - }, - { - name: "when a query does not have a sub query - it no-ops", - input: simplejson.NewFromAny(map[string]any{"target": "ahalfquery"}), - expected: `{"target":"ahalfquery"}`, - }, - { - name: "when query was hidden, it removes the flag", - input: simplejson.NewFromAny(map[string]any{"hide": true}), - expected: `{}`, - }, - { - name: "when prometheus both type query, convert to range", - input: simplejson.NewFromAny(map[string]any{ - "datasource": map[string]string{ - "type": "prometheus", - }, - "instant": true, - "range": true, - }), - expected: `{"datasource":{"type":"prometheus"},"instant":false,"range":true}`, - }, - { - name: "when prometheus instant type query, do nothing", - input: simplejson.NewFromAny(map[string]any{ - "datasource": map[string]string{ - "type": "prometheus", - }, - "instant": true, - }), - expected: `{"datasource":{"type":"prometheus"},"instant":true}`, - }, - { - name: "when non-prometheus with instant and range, do nothing", - input: simplejson.NewFromAny(map[string]any{ - "datasource": map[string]string{ - "type": "something", - }, - "instant": true, - "range": true, - }), - expected: `{"datasource":{"type":"something"},"instant":true,"range":true}`, - }, - } - - for _, tt := range tc { - t.Run(tt.name, func(t *testing.T) { - model, err := tt.input.Encode() - require.NoError(t, err) - queries, err := migrateAlertRuleQueries(&logtest.Fake{}, []models.AlertQuery{{Model: model}}) - if tt.err != nil { - require.Error(t, err) - require.EqualError(t, err, tt.err.Error()) - return - } - - require.NoError(t, err) - r, err := queries[0].Model.MarshalJSON() - require.NoError(t, err) - require.JSONEq(t, tt.expected, string(r)) - }) - } -} - -func TestAddMigrationInfo(t *testing.T) { - tt := []struct { - name string - alert *legacymodels.Alert - dashboard string - expectedLabels data.Labels - expectedAnnotations data.Labels - }{ - { - name: "when alert rule tags are a JSON array, they're ignored.", - alert: &legacymodels.Alert{ID: 43, PanelID: 42, Message: "message", Settings: simplejson.NewFromAny(map[string]any{ - "alertRuleTags": []string{"one", "two", "three", "four"}, - })}, - dashboard: "dashboard", - expectedLabels: data.Labels{models.MigratedUseLegacyChannelsLabel: "true"}, - expectedAnnotations: data.Labels{models.MigratedAlertIdAnnotation: "43", models.DashboardUIDAnnotation: "dashboard", models.PanelIDAnnotation: "42", "message": "message"}, - }, - { - name: "when alert rule tags are a JSON object", - alert: &legacymodels.Alert{ID: 43, PanelID: 42, Message: "message", Settings: simplejson.NewFromAny(map[string]any{ - "alertRuleTags": map[string]any{"key": "value", "key2": "value2"}, - })}, dashboard: "dashboard", - expectedLabels: data.Labels{models.MigratedUseLegacyChannelsLabel: "true", "key": "value", "key2": "value2"}, - expectedAnnotations: data.Labels{models.MigratedAlertIdAnnotation: "43", models.DashboardUIDAnnotation: "dashboard", models.PanelIDAnnotation: "42", "message": "message"}, - }, - } - - for _, tc := range tt { - t.Run(tc.name, func(t *testing.T) { - labels, annotations := addLabelsAndAnnotations(&logtest.Fake{}, tc.alert, tc.dashboard, nil) - require.Equal(t, tc.expectedLabels, labels) - require.Equal(t, tc.expectedAnnotations, annotations) - }) - } -} - -func TestMakeAlertRule(t *testing.T) { - sqlStore := db.InitTestDB(t) - info := migmodels.DashboardUpgradeInfo{ - DashboardUID: "dashboarduid", - DashboardName: "dashboardname", - NewFolderUID: "newfolderuid", - NewFolderName: "newfoldername", - } - t.Run("when mapping rule names", func(t *testing.T) { - t.Run("leaves basic names untouched", func(t *testing.T) { - service := NewTestMigrationService(t, sqlStore, nil) - m := service.newOrgMigration(1) - da := createTestDashAlert() - - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, da, info) - - require.NoError(t, err) - require.Equal(t, da.Name, ar.Title) - }) - - t.Run("truncates very long names to max length", func(t *testing.T) { - service := NewTestMigrationService(t, sqlStore, nil) - m := service.newOrgMigration(1) - da := createTestDashAlert() - da.Name = strings.Repeat("a", store.AlertDefinitionMaxTitleLength+1) - - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, da, info) - - require.NoError(t, err) - require.Len(t, ar.Title, store.AlertDefinitionMaxTitleLength) - }) - - t.Run("deduplicate names in same org and folder", func(t *testing.T) { - service := NewTestMigrationService(t, sqlStore, nil) - m := service.newOrgMigration(1) - da := createTestDashAlert() - da.Name = strings.Repeat("a", store.AlertDefinitionMaxTitleLength+1) - - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, da, info) - - require.NoError(t, err) - require.Len(t, ar.Title, store.AlertDefinitionMaxTitleLength) - - da = createTestDashAlert() - da.Name = strings.Repeat("a", store.AlertDefinitionMaxTitleLength+1) - - ar, err = m.migrateAlert(context.Background(), &logtest.Fake{}, da, info) - - require.NoError(t, err) - require.Len(t, ar.Title, store.AlertDefinitionMaxTitleLength) - require.Equal(t, ar.Title, fmt.Sprintf("%s #2", strings.Repeat("a", store.AlertDefinitionMaxTitleLength-3))) - }) - }) - - t.Run("alert is not paused", func(t *testing.T) { - service := NewTestMigrationService(t, sqlStore, nil) - m := service.newOrgMigration(1) - da := createTestDashAlert() - - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, da, info) - require.NoError(t, err) - require.False(t, ar.IsPaused) - }) - - t.Run("paused dash alert is paused", func(t *testing.T) { - service := NewTestMigrationService(t, sqlStore, nil) - m := service.newOrgMigration(1) - da := createTestDashAlert() - da.State = "paused" - - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, da, info) - require.NoError(t, err) - require.True(t, ar.IsPaused) - }) - - t.Run("use default if execution of NoData is not known", func(t *testing.T) { - service := NewTestMigrationService(t, sqlStore, nil) - m := service.newOrgMigration(1) - da := createTestDashAlert() - da.Settings.Set("noDataState", uuid.NewString()) - - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, da, info) - require.Nil(t, err) - require.Equal(t, models.NoData, ar.NoDataState) - }) - - t.Run("use default if execution of Error is not known", func(t *testing.T) { - service := NewTestMigrationService(t, sqlStore, nil) - m := service.newOrgMigration(1) - da := createTestDashAlert() - da.Settings.Set("executionErrorState", uuid.NewString()) - - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, da, info) - require.Nil(t, err) - require.Equal(t, models.ErrorErrState, ar.ExecErrState) - }) - - t.Run("migrate message template", func(t *testing.T) { - service := NewTestMigrationService(t, sqlStore, nil) - m := service.newOrgMigration(1) - da := createTestDashAlert() - da.Message = "Instance ${instance} is down" - - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, da, info) - require.Nil(t, err) - expected := - "{{- $mergedLabels := mergeLabelValues $values -}}\n" + - "Instance {{$mergedLabels.instance}} is down" - require.Equal(t, expected, ar.Annotations["message"]) - }) - - t.Run("create unique group from dashboard title and humanized interval", func(t *testing.T) { - service := NewTestMigrationService(t, sqlStore, nil) - m := service.newOrgMigration(1) - da := createTestDashAlert() - da.PanelID = 42 - - intervalTests := []struct { - interval int64 - expected string - }{ - {interval: 10, expected: "10s"}, - {interval: 30, expected: "30s"}, - {interval: 60, expected: "1m"}, - {interval: 120, expected: "2m"}, - {interval: 3600, expected: "1h"}, - {interval: 7200, expected: "2h"}, - {interval: 86400, expected: "1d"}, - {interval: 172800, expected: "2d"}, - {interval: 604800, expected: "1w"}, - {interval: 1209600, expected: "2w"}, - {interval: 31536000, expected: "1y"}, - {interval: 63072000, expected: "2y"}, - {interval: 60 + 30, expected: "1m30s"}, - {interval: 3600 + 10, expected: "1h10s"}, - {interval: 3600 + 60, expected: "1h1m"}, - {interval: 3600 + 60 + 10, expected: "1h1m10s"}, - {interval: 86400 + 10, expected: "1d10s"}, - {interval: 86400 + 60, expected: "1d1m"}, - {interval: 86400 + 3600, expected: "1d1h"}, - {interval: 86400 + 3600 + 60, expected: "1d1h1m"}, - {interval: 86400 + 3600 + 10, expected: "1d1h10s"}, - {interval: 86400 + 60 + 10, expected: "1d1m10s"}, - {interval: 86400 + 3600 + 60 + 10, expected: "1d1h1m10s"}, - {interval: 604800 + 86400 + 3600 + 60 + 10, expected: "8d1h1m10s"}, - {interval: 31536000 + 604800 + 86400 + 3600 + 60 + 10, expected: "373d1h1m10s"}, - } - - for _, test := range intervalTests { - t.Run(fmt.Sprintf("interval %ds should be %s", test.interval, test.expected), func(t *testing.T) { - da.Frequency = test.interval - - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, da, info) - - require.NoError(t, err) - require.Equal(t, fmt.Sprintf("%s - %s", info.DashboardName, test.expected), ar.RuleGroup) - }) - } - }) - - t.Run("truncate dashboard name part of rule group if too long", func(t *testing.T) { - service := NewTestMigrationService(t, sqlStore, nil) - m := service.newOrgMigration(1) - da := createTestDashAlert() - info := migmodels.DashboardUpgradeInfo{ - DashboardUID: "dashboarduid", - DashboardName: strings.Repeat("a", store.AlertRuleMaxRuleGroupNameLength-1), - NewFolderUID: "newfolderuid", - NewFolderName: "newfoldername", - } - - ar, err := m.migrateAlert(context.Background(), &logtest.Fake{}, da, info) - - require.NoError(t, err) - require.Len(t, ar.RuleGroup, store.AlertRuleMaxRuleGroupNameLength) - suffix := fmt.Sprintf(" - %ds", ar.IntervalSeconds) - require.Equal(t, fmt.Sprintf("%s%s", strings.Repeat("a", store.AlertRuleMaxRuleGroupNameLength-len(suffix)), suffix), ar.RuleGroup) - }) -} - -func createTestDashAlert() *legacymodels.Alert { - return &legacymodels.Alert{ - ID: 1, - Name: "test", - Settings: simplejson.New(), - } -} diff --git a/pkg/services/ngalert/migration/channel.go b/pkg/services/ngalert/migration/channel.go deleted file mode 100644 index 78dc2c9c57de1..0000000000000 --- a/pkg/services/ngalert/migration/channel.go +++ /dev/null @@ -1,236 +0,0 @@ -package migration - -import ( - "context" - "encoding/base64" - "errors" - "fmt" - "time" - - alertingNotify "github.com/grafana/alerting/notify" - "github.com/prometheus/alertmanager/config" - "github.com/prometheus/alertmanager/pkg/labels" - "github.com/prometheus/common/model" - - "github.com/grafana/grafana/pkg/components/simplejson" - legacymodels "github.com/grafana/grafana/pkg/services/alerting/models" - apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" - migmodels "github.com/grafana/grafana/pkg/services/ngalert/migration/models" - ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/secrets" -) - -const ( - // DisabledRepeatInterval is a large duration that will be used as a pseudo-disable in case a legacy channel doesn't have SendReminders enabled. - DisabledRepeatInterval = model.Duration(time.Duration(8736) * time.Hour) // 1y -) - -// migrateChannels creates Alertmanager configs with migrated receivers and routes. -func (om *OrgMigration) migrateChannels(channels []*legacymodels.AlertNotification) (*migmodels.Alertmanager, error) { - amConfig := migmodels.NewAlertmanager() - empty := true - // Create all newly migrated receivers from legacy notification channels. - for _, c := range channels { - receiver, err := om.createReceiver(c) - if err != nil { - if errors.Is(err, ErrDiscontinued) { - om.log.Error("Alert migration error: discontinued notification channel found", "type", c.Type, "name", c.Name, "uid", c.UID) - continue - } - return nil, fmt.Errorf("channel '%s': %w", c.Name, err) - } - - empty = false - route, err := createRoute(c, receiver.Name) - if err != nil { - return nil, fmt.Errorf("channel '%s': %w", c.Name, err) - } - amConfig.AddRoute(route) - amConfig.AddReceiver(receiver) - } - if empty { - return nil, nil - } - - return amConfig, nil -} - -// validateAlertmanagerConfig validates the alertmanager configuration produced by the migration against the receivers. -func (om *OrgMigration) validateAlertmanagerConfig(config *apimodels.PostableUserConfig) error { - for _, r := range config.AlertmanagerConfig.Receivers { - for _, gr := range r.GrafanaManagedReceivers { - data, err := gr.Settings.MarshalJSON() - if err != nil { - return err - } - var ( - cfg = &alertingNotify.GrafanaIntegrationConfig{ - UID: gr.UID, - Name: gr.Name, - Type: gr.Type, - DisableResolveMessage: gr.DisableResolveMessage, - Settings: data, - SecureSettings: gr.SecureSettings, - } - ) - - _, err = alertingNotify.BuildReceiverConfiguration(context.Background(), &alertingNotify.APIReceiver{ - GrafanaIntegrations: alertingNotify.GrafanaIntegrations{Integrations: []*alertingNotify.GrafanaIntegrationConfig{cfg}}, - }, om.encryptionService.GetDecryptedValue) - if err != nil { - return err - } - } - } - - return nil -} - -// createNotifier creates a PostableGrafanaReceiver from a legacy notification channel. -func (om *OrgMigration) createNotifier(c *legacymodels.AlertNotification) (*apimodels.PostableGrafanaReceiver, error) { - settings, secureSettings, err := om.migrateSettingsToSecureSettings(c.Type, c.Settings, c.SecureSettings) - if err != nil { - return nil, err - } - - data, err := settings.MarshalJSON() - if err != nil { - return nil, err - } - - return &apimodels.PostableGrafanaReceiver{ - UID: c.UID, - Name: c.Name, - Type: c.Type, - DisableResolveMessage: c.DisableResolveMessage, - Settings: data, - SecureSettings: secureSettings, - }, nil -} - -var ErrDiscontinued = errors.New("discontinued") - -// createReceiver creates a receiver from a legacy notification channel. -func (om *OrgMigration) createReceiver(channel *legacymodels.AlertNotification) (*apimodels.PostableApiReceiver, error) { - if channel.Type == "hipchat" || channel.Type == "sensu" { - return nil, fmt.Errorf("'%s': %w", channel.Type, ErrDiscontinued) - } - - notifier, err := om.createNotifier(channel) - if err != nil { - return nil, err - } - - return &apimodels.PostableApiReceiver{ - Receiver: config.Receiver{ - Name: channel.Name, // Channel name is unique within an Org. - }, - PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{ - GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{notifier}, - }, - }, nil -} - -// createRoute creates a route from a legacy notification channel, and matches using a label based on the channel UID. -func createRoute(channel *legacymodels.AlertNotification, receiverName string) (*apimodels.Route, error) { - // We create a matchers based on channel name so that we only need a single route per channel. - // All channel routes are nested in a single route under the root. This is so we can keep the migrated channels separate - // and organized. - // Since default channels are attached to all alerts in legacy, we use a catch-all matcher after migration instead - // of a specific label matcher. - // - // For example, if an alert needs to send to channel1 and channel2 it will have one label to route to the nested - // policy and two channel-specific labels to route to the correct contact points: - // - __legacy_use_channels__="true" - // - __legacy_c_channel1__="true" - // - __legacy_c_channel2__="true" - // - // If an alert needs to send to channel1 and the default channel, it will have one label to route to the nested - // policy and one channel-specific label to route to channel1, and a catch-all policy will ensure it also routes to - // the default channel. - - label := contactLabel(channel.Name) - mat, err := labels.NewMatcher(labels.MatchEqual, label, "true") - if err != nil { - return nil, err - } - - // If the channel is default, we create a catch-all matcher instead so this always matches. - if channel.IsDefault { - mat, _ = labels.NewMatcher(labels.MatchRegexp, model.AlertNameLabel, ".+") - } - - repeatInterval := DisabledRepeatInterval - if channel.SendReminder { - repeatInterval = model.Duration(channel.Frequency) - } - - return &apimodels.Route{ - Receiver: receiverName, - ObjectMatchers: apimodels.ObjectMatchers{mat}, - Continue: true, // We continue so that each sibling contact point route can separately match. - RepeatInterval: &repeatInterval, - }, nil -} - -// contactLabel creates a label matcher key used to route alerts to a contact point. -func contactLabel(name string) string { - return ngmodels.MigratedContactLabelPrefix + name + "__" -} - -var secureKeysToMigrate = map[string][]string{ - "slack": {"url", "token"}, - "pagerduty": {"integrationKey"}, - "webhook": {"password"}, - "prometheus-alertmanager": {"basicAuthPassword"}, - "opsgenie": {"apiKey"}, - "telegram": {"bottoken"}, - "line": {"token"}, - "pushover": {"apiToken", "userKey"}, - "threema": {"api_secret"}, -} - -// Some settings were migrated from settings to secure settings in between. -// See https://grafana.com/docs/grafana/latest/installation/upgrading/#ensure-encryption-of-existing-alert-notification-channel-secrets. -// migrateSettingsToSecureSettings takes care of that. -func (om *OrgMigration) migrateSettingsToSecureSettings(chanType string, settings *simplejson.Json, secureSettings SecureJsonData) (*simplejson.Json, map[string]string, error) { - keys := secureKeysToMigrate[chanType] - newSecureSettings := secureSettings.Decrypt() - cloneSettings := simplejson.New() - settingsMap, err := settings.Map() - if err != nil { - return nil, nil, err - } - for k, v := range settingsMap { - cloneSettings.Set(k, v) - } - for _, k := range keys { - if v, ok := newSecureSettings[k]; ok && v != "" { - continue - } - - sv := cloneSettings.Get(k).MustString() - if sv != "" { - newSecureSettings[k] = sv - cloneSettings.Del(k) - } - } - - err = om.encryptSecureSettings(newSecureSettings) - if err != nil { - return nil, nil, err - } - - return cloneSettings, newSecureSettings, nil -} - -func (om *OrgMigration) encryptSecureSettings(secureSettings map[string]string) error { - for key, value := range secureSettings { - encryptedData, err := om.encryptionService.Encrypt(context.Background(), []byte(value), secrets.WithoutScope()) - if err != nil { - return fmt.Errorf("encrypt secure settings: %w", err) - } - secureSettings[key] = base64.StdEncoding.EncodeToString(encryptedData) - } - return nil -} diff --git a/pkg/services/ngalert/migration/channel_test.go b/pkg/services/ngalert/migration/channel_test.go deleted file mode 100644 index 8bb5713f96fdc..0000000000000 --- a/pkg/services/ngalert/migration/channel_test.go +++ /dev/null @@ -1,493 +0,0 @@ -package migration - -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/prometheus/alertmanager/config" - "github.com/prometheus/alertmanager/pkg/labels" - "github.com/prometheus/common/model" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db" - legacymodels "github.com/grafana/grafana/pkg/services/alerting/models" - apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" - ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" -) - -func TestCreateRoute(t *testing.T) { - tc := []struct { - name string - channel *legacymodels.AlertNotification - recv *apimodels.PostableApiReceiver - expected *apimodels.Route - }{ - { - name: "when a receiver is passed in, the route should exact match based on channel uid with continue=true", - channel: &legacymodels.AlertNotification{UID: "uid1", Name: "recv1"}, - recv: createPostableApiReceiver("uid1", "recv1"), - expected: &apimodels.Route{ - Receiver: "recv1", - ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("recv1"), Value: "true"}}, - Routes: nil, - Continue: true, - GroupByStr: nil, - RepeatInterval: durationPointer(DisabledRepeatInterval), - }, - }, - { - name: "notification channel labels matcher should work with special characters", - channel: &legacymodels.AlertNotification{UID: "uid1", Name: `. ^ $ * + - ? ( ) [ ] { } \ |`}, - recv: createPostableApiReceiver("uid1", `. ^ $ * + - ? ( ) [ ] { } \ |`), - expected: &apimodels.Route{ - Receiver: `. ^ $ * + - ? ( ) [ ] { } \ |`, - ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel(`. ^ $ * + - ? ( ) [ ] { } \ |`), Value: "true"}}, - Routes: nil, - Continue: true, - GroupByStr: nil, - RepeatInterval: durationPointer(DisabledRepeatInterval), - }, - }, - { - name: "when a channel has sendReminder=true, the route should use the frequency in repeat interval", - channel: &legacymodels.AlertNotification{SendReminder: true, Frequency: time.Duration(42) * time.Hour, UID: "uid1", Name: "recv1"}, - recv: createPostableApiReceiver("uid1", "recv1"), - expected: &apimodels.Route{ - Receiver: "recv1", - ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("recv1"), Value: "true"}}, - Routes: nil, - Continue: true, - GroupByStr: nil, - RepeatInterval: durationPointer(model.Duration(time.Duration(42) * time.Hour)), - }, - }, - { - name: "when a channel has sendReminder=false, the route should ignore the frequency in repeat interval and use DisabledRepeatInterval", - channel: &legacymodels.AlertNotification{SendReminder: false, Frequency: time.Duration(42) * time.Hour, UID: "uid1", Name: "recv1"}, - recv: createPostableApiReceiver("uid1", "recv1"), - expected: &apimodels.Route{ - Receiver: "recv1", - ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("recv1"), Value: "true"}}, - Routes: nil, - Continue: true, - GroupByStr: nil, - RepeatInterval: durationPointer(DisabledRepeatInterval), - }, - }, - } - - for _, tt := range tc { - t.Run(tt.name, func(t *testing.T) { - res, err := createRoute(tt.channel, tt.recv.Name) - require.NoError(t, err) - - // Order of nested routes is not guaranteed. - cOpt := []cmp.Option{ - cmpopts.SortSlices(func(a, b *apimodels.Route) bool { - if a.Receiver != b.Receiver { - return a.Receiver < b.Receiver - } - return a.ObjectMatchers[0].Value < b.ObjectMatchers[0].Value - }), - cmpopts.IgnoreUnexported(apimodels.Route{}, labels.Matcher{}), - } - - if !cmp.Equal(tt.expected, res, cOpt...) { - t.Errorf("Unexpected Route: %v", cmp.Diff(tt.expected, res, cOpt...)) - } - }) - } -} - -func createNotChannel(t *testing.T, uid string, id int64, name string, isDefault bool, frequency time.Duration) *legacymodels.AlertNotification { - t.Helper() - return &legacymodels.AlertNotification{ - OrgID: 1, - UID: uid, - ID: id, - Name: name, - Type: "email", - SendReminder: frequency > 0, - Frequency: frequency, - Settings: simplejson.New(), - IsDefault: isDefault, - Created: now, - Updated: now, - } -} - -func createBasicNotChannel(t *testing.T, notType string) *legacymodels.AlertNotification { - t.Helper() - a := createNotChannel(t, "uid1", int64(1), "name1", false, 0) - a.Type = notType - return a -} - -func TestCreateReceivers(t *testing.T) { - tc := []struct { - name string - channel *legacymodels.AlertNotification - expRecv *apimodels.PostableApiReceiver - expErr error - }{ - { - name: "when given notification channels migrate them to receivers", - channel: createNotChannel(t, "uid1", int64(1), "name1", false, 0), - expRecv: createPostableApiReceiver("uid1", "name1"), - }, - { - name: "when given hipchat return discontinued error", - channel: createBasicNotChannel(t, "hipchat"), - expErr: fmt.Errorf("'hipchat': %w", ErrDiscontinued), - }, - { - name: "when given sensu return discontinued error", - channel: createBasicNotChannel(t, "sensu"), - expErr: fmt.Errorf("'sensu': %w", ErrDiscontinued), - }, - } - - sqlStore := db.InitTestDB(t) - for _, tt := range tc { - t.Run(tt.name, func(t *testing.T) { - service := NewTestMigrationService(t, sqlStore, nil) - m := service.newOrgMigration(1) - recv, err := m.createReceiver(tt.channel) - if tt.expErr != nil { - require.Error(t, err) - require.EqualError(t, err, tt.expErr.Error()) - return - } - require.NoError(t, err) - require.Equal(t, tt.expRecv, recv) - }) - } -} - -func TestMigrateNotificationChannelSecureSettings(t *testing.T) { - legacyEncryptFn := func(data string) string { - raw, err := util.Encrypt([]byte(data), setting.SecretKey) - require.NoError(t, err) - return string(raw) - } - decryptFn := func(data string, m *OrgMigration) string { - decoded, err := base64.StdEncoding.DecodeString(data) - require.NoError(t, err) - raw, err := m.encryptionService.Decrypt(context.Background(), decoded) - require.NoError(t, err) - return string(raw) - } - gen := func(nType string, fn func(channel *legacymodels.AlertNotification)) *legacymodels.AlertNotification { - not := &legacymodels.AlertNotification{ - UID: "uid", - ID: 1, - Name: "channel name", - Type: nType, - Settings: simplejson.NewFromAny(map[string]any{ - "something": "some value", - }), - SecureSettings: map[string][]byte{}, - } - if fn != nil { - fn(not) - } - return not - } - genExpSlack := func(fn func(channel *apimodels.PostableGrafanaReceiver)) *apimodels.PostableGrafanaReceiver { - rawSettings, err := json.Marshal(map[string]string{ - "something": "some value", - }) - require.NoError(t, err) - - recv := &apimodels.PostableGrafanaReceiver{ - UID: "uid", - Name: "channel name", - Type: "slack", - Settings: rawSettings, - SecureSettings: map[string]string{ - "token": "secure token", - "url": "secure url", - }, - } - - if fn != nil { - fn(recv) - } - return recv - } - - tc := []struct { - name string - channel *legacymodels.AlertNotification - expRecv *apimodels.PostableGrafanaReceiver - expErr error - }{ - { - name: "when secure settings exist, migrate them to receiver secure settings", - channel: gen("slack", func(channel *legacymodels.AlertNotification) { - channel.SecureSettings = map[string][]byte{ - "token": []byte(legacyEncryptFn("secure token")), - "url": []byte(legacyEncryptFn("secure url")), - } - }), - expRecv: genExpSlack(nil), - }, - { - name: "when no secure settings are encrypted, do nothing", - channel: gen("slack", nil), - expRecv: genExpSlack(func(recv *apimodels.PostableGrafanaReceiver) { - delete(recv.SecureSettings, "token") - delete(recv.SecureSettings, "url") - }), - }, - { - name: "when some secure settings are available unencrypted in settings, migrate them to secureSettings and encrypt", - channel: gen("slack", func(channel *legacymodels.AlertNotification) { - channel.SecureSettings = map[string][]byte{ - "url": []byte(legacyEncryptFn("secure url")), - } - channel.Settings.Set("token", "secure token") - }), - expRecv: genExpSlack(nil), - }, - } - sqlStore := db.InitTestDB(t) - for _, tt := range tc { - t.Run(tt.name, func(t *testing.T) { - service := NewTestMigrationService(t, sqlStore, nil) - m := service.newOrgMigration(1) - recv, err := m.createNotifier(tt.channel) - if tt.expErr != nil { - require.Error(t, err) - require.EqualError(t, err, tt.expErr.Error()) - return - } - require.NoError(t, err) - - if len(tt.expRecv.SecureSettings) > 0 { - require.NotEqual(t, tt.expRecv, recv) // Make sure they were actually encrypted at first. - } - for k, v := range recv.SecureSettings { - recv.SecureSettings[k] = decryptFn(v, m) - } - require.Equal(t, tt.expRecv, recv) - }) - } - - // Generate tests for each notification channel type. - t.Run("secure settings migrations for each notifier type", func(t *testing.T) { - notifiers := channels_config.GetAvailableNotifiers() - t.Run("migrate notification channel secure settings to receiver secure settings", func(t *testing.T) { - for _, notifier := range notifiers { - nType := notifier.Type - secureSettings, err := channels_config.GetSecretKeysForContactPointType(nType) - require.NoError(t, err) - t.Run(nType, func(t *testing.T) { - service := NewTestMigrationService(t, sqlStore, nil) - m := service.newOrgMigration(1) - channel := gen(nType, func(channel *legacymodels.AlertNotification) { - for _, key := range secureSettings { - channel.SecureSettings[key] = []byte(legacyEncryptFn("secure " + key)) - } - }) - recv, err := m.createNotifier(channel) - require.NoError(t, err) - - require.Equal(t, nType, recv.Type) - if len(secureSettings) > 0 { - for _, key := range secureSettings { - require.NotEqual(t, "secure "+key, recv.SecureSettings[key]) // Make sure they were actually encrypted at first. - } - } - require.Len(t, recv.SecureSettings, len(secureSettings)) - for _, key := range secureSettings { - require.Equal(t, "secure "+key, decryptFn(recv.SecureSettings[key], m)) - } - }) - } - }) - - t.Run("for certain legacy channel types, migrate secure fields stored in settings to secure settings", func(t *testing.T) { - for _, notifier := range notifiers { - nType := notifier.Type - secureSettings, ok := secureKeysToMigrate[nType] - if !ok { - continue - } - t.Run(nType, func(t *testing.T) { - service := NewTestMigrationService(t, sqlStore, nil) - m := service.newOrgMigration(1) - - channel := gen(nType, func(channel *legacymodels.AlertNotification) { - for _, key := range secureSettings { - // Key difference to above. We store the secure settings in the settings field and expect - // them to be migrated to secureSettings. - channel.Settings.Set(key, "secure "+key) - } - }) - recv, err := m.createNotifier(channel) - require.NoError(t, err) - - require.Equal(t, nType, recv.Type) - if len(secureSettings) > 0 { - for _, key := range secureSettings { - require.NotEqual(t, "secure "+key, recv.SecureSettings[key]) // Make sure they were actually encrypted at first. - } - } - require.Len(t, recv.SecureSettings, len(secureSettings)) - for _, key := range secureSettings { - require.Equal(t, "secure "+key, decryptFn(recv.SecureSettings[key], m)) - } - }) - } - }) - }) -} - -func TestSetupAlertmanagerConfig(t *testing.T) { - tc := []struct { - name string - channels []*legacymodels.AlertNotification - amConfig *apimodels.PostableUserConfig - expErr error - }{ - { - name: "when given multiple notification channels migrate them to receivers", - channels: []*legacymodels.AlertNotification{createNotChannel(t, "uid1", int64(1), "notifier1", false, 0), createNotChannel(t, "uid2", int64(2), "notifier2", false, 0)}, - amConfig: &apimodels.PostableUserConfig{ - AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ - Config: apimodels.Config{Route: &apimodels.Route{ - Receiver: "autogen-contact-point-default", - GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, - Routes: []*apimodels.Route{ - { - ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}}, - Continue: true, - Routes: []*apimodels.Route{ - {Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - {Receiver: "notifier2", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier2"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - }, - }, - }, - }}, - Receivers: []*apimodels.PostableApiReceiver{ - {Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{}}}, - createPostableApiReceiver("uid1", "notifier1"), - createPostableApiReceiver("uid2", "notifier2"), - }, - }, - }, - }, - { - name: "when given default notification channels migrate them to a routes with catchall matcher", - channels: []*legacymodels.AlertNotification{createNotChannel(t, "uid1", int64(1), "notifier1", false, 0), createNotChannel(t, "uid2", int64(2), "notifier2", true, 0)}, - amConfig: &apimodels.PostableUserConfig{ - AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ - Config: apimodels.Config{Route: &apimodels.Route{ - Receiver: "autogen-contact-point-default", - GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, - Routes: []*apimodels.Route{ - { - ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}}, - Continue: true, - Routes: []*apimodels.Route{ - {Receiver: "notifier2", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchRegexp, Name: model.AlertNameLabel, Value: ".+"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - {Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - }, - }, - }, - }}, - Receivers: []*apimodels.PostableApiReceiver{ - {Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{}}}, - createPostableApiReceiver("uid1", "notifier1"), - createPostableApiReceiver("uid2", "notifier2"), - }, - }, - }, - }, - { - name: "when given notification channels with SendReminder true migrate them to a route with frequency set", - channels: []*legacymodels.AlertNotification{createNotChannel(t, "uid1", int64(1), "notifier1", false, time.Duration(42)), createNotChannel(t, "uid2", int64(2), "notifier2", false, time.Duration(43))}, - amConfig: &apimodels.PostableUserConfig{ - AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ - Config: apimodels.Config{Route: &apimodels.Route{ - Receiver: "autogen-contact-point-default", - GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, - Routes: []*apimodels.Route{ - { - ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}}, - Continue: true, - Routes: []*apimodels.Route{ - {Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(42)}, - {Receiver: "notifier2", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier2"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(43)}, - }, - }, - }, - }}, - Receivers: []*apimodels.PostableApiReceiver{ - {Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{}}}, - createPostableApiReceiver("uid1", "notifier1"), - createPostableApiReceiver("uid2", "notifier2")}, - }, - }, - }, - } - - for _, tt := range tc { - t.Run(tt.name, func(t *testing.T) { - sqlStore := db.InitTestDB(t) - - service := NewTestMigrationService(t, sqlStore, nil) - m := service.newOrgMigration(1) - am, err := m.migrateChannels(tt.channels) - if tt.expErr != nil { - require.Error(t, err) - require.EqualError(t, err, tt.expErr.Error()) - return - } - require.NoError(t, err) - - amConfig := am.Config - opts := []cmp.Option{ - cmpopts.IgnoreUnexported(apimodels.PostableUserConfig{}, labels.Matcher{}), - cmpopts.SortSlices(func(a, b *apimodels.Route) bool { return a.Receiver < b.Receiver }), - } - if !cmp.Equal(tt.amConfig, amConfig, opts...) { - t.Errorf("Unexpected Config: %v", cmp.Diff(tt.amConfig, amConfig, opts...)) - } - }) - } -} - -func createPostableApiReceiver(uid string, name string) *apimodels.PostableApiReceiver { - return &apimodels.PostableApiReceiver{ - Receiver: config.Receiver{ - Name: name, - }, - PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{ - GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{ - { - UID: uid, - Type: "email", - Name: name, - Settings: apimodels.RawMessage("{}"), - SecureSettings: map[string]string{}, - }, - }, - }, - } -} - -func durationPointer(d model.Duration) *model.Duration { - return &d -} diff --git a/pkg/services/ngalert/migration/cond_trans.go b/pkg/services/ngalert/migration/cond_trans.go deleted file mode 100644 index 0f5af56132500..0000000000000 --- a/pkg/services/ngalert/migration/cond_trans.go +++ /dev/null @@ -1,418 +0,0 @@ -package migration - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - "time" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/datasources" - migrationStore "github.com/grafana/grafana/pkg/services/ngalert/migration/store" - ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/tsdb/legacydata" - "github.com/grafana/grafana/pkg/tsdb/legacydata/interval" - "github.com/grafana/grafana/pkg/util" -) - -// It is defined in pkg/expr/service.go as "DatasourceType" -const expressionDatasourceUID = "__expr__" - -// dashAlertSettings is a type for the JSON that is in the settings field of -// the alert table. -type dashAlertSettings struct { - NoDataState string `json:"noDataState"` - ExecutionErrorState string `json:"executionErrorState"` - Conditions []dashAlertCondition `json:"conditions"` - AlertRuleTags any `json:"alertRuleTags"` - Notifications []notificationKey `json:"notifications"` -} - -// notificationKey is the object that represents the Notifications array in legacymodels.Alert.Settings. -// At least one of ID or UID should always be present, otherwise the legacy channel was invalid. -type notificationKey struct { - UID string `json:"uid,omitempty"` - ID int64 `json:"id,omitempty"` -} - -// dashAlertingConditionJSON is like classic.ClassicConditionJSON except that it -// includes the model property with the query. -type dashAlertCondition struct { - Evaluator evaluator `json:"evaluator"` - - Operator struct { - Type string `json:"type"` - } `json:"operator"` - - Query struct { - Params []string `json:"params"` - DatasourceID int64 `json:"datasourceId"` - Model json.RawMessage - } `json:"query"` - - Reducer struct { - // Params []any `json:"params"` (Unused) - Type string `json:"type"` - } -} - -type evaluator struct { - Params []float64 `json:"params"` - Type string `json:"type"` // e.g. "gt" -} - -//nolint:gocyclo -func transConditions(ctx context.Context, l log.Logger, set dashAlertSettings, orgID int64, store migrationStore.Store) (*condition, error) { - // TODO: needs a significant refactor to reduce complexity. - usr := getMigrationUser(orgID) - - refIDtoCondIdx := make(map[string][]int) // a map of original refIds to their corresponding condition index - for i, cond := range set.Conditions { - if len(cond.Query.Params) != 3 { - return nil, fmt.Errorf("unexpected number of query parameters in cond %v, want 3 got %v", i+1, len(cond.Query.Params)) - } - refID := cond.Query.Params[0] - refIDtoCondIdx[refID] = append(refIDtoCondIdx[refID], i) - } - - newRefIDstoCondIdx := make(map[string][]int) // a map of the new refIds to their coresponding condition index - - refIDs := make([]string, 0, len(refIDtoCondIdx)) // a unique sorted list of the original refIDs - for refID := range refIDtoCondIdx { - refIDs = append(refIDs, refID) - } - sort.Strings(refIDs) - - newRefIDsToTimeRanges := make(map[string][2]string) // a map of new RefIDs to their time range string tuple representation - for _, refID := range refIDs { - condIdxes := refIDtoCondIdx[refID] - - if len(condIdxes) == 1 { - // If the refID does not exist yet and the condition only has one reference, we can add it directly. - if _, exists := newRefIDstoCondIdx[refID]; !exists { - // If the refID is used in only condition, keep the letter a new refID - newRefIDstoCondIdx[refID] = append(newRefIDstoCondIdx[refID], condIdxes[0]) - newRefIDsToTimeRanges[refID] = [2]string{set.Conditions[condIdxes[0]].Query.Params[1], set.Conditions[condIdxes[0]].Query.Params[2]} - continue - } - } - - // track unique time ranges within the same refID - timeRangesToCondIdx := make(map[[2]string][]int) // a map of the time range tuple to the condition index - for _, idx := range condIdxes { - timeParamFrom := set.Conditions[idx].Query.Params[1] - timeParamTo := set.Conditions[idx].Query.Params[2] - key := [2]string{timeParamFrom, timeParamTo} - timeRangesToCondIdx[key] = append(timeRangesToCondIdx[key], idx) - } - - if len(timeRangesToCondIdx) == 1 { - // If the refID does not exist yet and the condition only has one reference, we can add it directly. - if _, exists := newRefIDstoCondIdx[refID]; !exists { - // if all shared time range, no need to create a new query with a new RefID - for i := range condIdxes { - newRefIDstoCondIdx[refID] = append(newRefIDstoCondIdx[refID], condIdxes[i]) - newRefIDsToTimeRanges[refID] = [2]string{set.Conditions[condIdxes[i]].Query.Params[1], set.Conditions[condIdxes[i]].Query.Params[2]} - } - continue - } - } - - // This referenced query/refID has different time ranges, so new queries are needed for each unique time range. - timeRanges := make([][2]string, 0, len(timeRangesToCondIdx)) // a sorted list of unique time ranges for the query - for tr := range timeRangesToCondIdx { - timeRanges = append(timeRanges, tr) - } - - sort.Slice(timeRanges, func(i, j int) bool { - switch { - case timeRanges[i][0] < timeRanges[j][0]: - return true - case timeRanges[i][0] > timeRanges[j][0]: - return false - default: - return timeRanges[i][1] < timeRanges[j][1] - } - }) - - for _, tr := range timeRanges { - idxes := timeRangesToCondIdx[tr] - for i := 0; i < len(idxes); i++ { - newLetter, err := getNewRefID(newRefIDstoCondIdx) - if err != nil { - return nil, err - } - newRefIDstoCondIdx[newLetter] = append(newRefIDstoCondIdx[newLetter], idxes[i]) - newRefIDsToTimeRanges[newLetter] = [2]string{set.Conditions[idxes[i]].Query.Params[1], set.Conditions[idxes[i]].Query.Params[2]} - } - } - } - - newRefIDs := make([]string, 0, len(newRefIDstoCondIdx)) // newRefIds is a sorted list of the unique refIds of new queries - for refID := range newRefIDstoCondIdx { - newRefIDs = append(newRefIDs, refID) - } - sort.Strings(newRefIDs) - - newCond := &condition{} - condIdxToNewRefID := make(map[int]string) // a map of condition indices to the RefIDs of new queries - - // build the new data source queries - for _, refID := range newRefIDs { - condIdxes := newRefIDstoCondIdx[refID] - for i, condIdx := range condIdxes { - condIdxToNewRefID[condIdx] = refID - if i > 0 { - // only create each unique query once - continue - } - - var queryObj map[string]any // copy the model - err := json.Unmarshal(set.Conditions[condIdx].Query.Model, &queryObj) - if err != nil { - return nil, err - } - - var queryType string - if v, ok := queryObj["queryType"]; ok { - if s, ok := v.(string); ok { - queryType = s - } - } - - // Could have an alert saved but datasource deleted, so can not require match. - ds, err := store.GetDatasource(ctx, set.Conditions[condIdx].Query.DatasourceID, usr) - if err != nil && !errors.Is(err, datasources.ErrDataSourceNotFound) { - return nil, err - } - - queryObj["refId"] = refID - - // See services/alerting/conditions/query.go's newQueryCondition - queryObj["maxDataPoints"] = interval.DefaultRes - - simpleJson, err := simplejson.NewJson(set.Conditions[condIdx].Query.Model) - if err != nil { - return nil, err - } - - rawFrom := newRefIDsToTimeRanges[refID][0] - rawTo := newRefIDsToTimeRanges[refID][1] - - // We check if the minInterval stored in the model is parseable. If it's not, we use "1s" instead. - // The reason for this is because of a bug in legacy alerting which allows arbitrary variables to be used - // as the min interval, even though those variables do not work and will cause the legacy alert - // to fail with `interval calculation failed: time: invalid duration`. - if _, err := interval.GetIntervalFrom(ds, simpleJson, time.Millisecond*1); err != nil { - l.Warn("failed to parse min interval from query model, using '1s' instead", "interval", simpleJson.Get("interval").MustString(), "err", err) - simpleJson.Set("interval", "1s") - } - - calculatedInterval, err := calculateInterval(legacydata.NewDataTimeRange(rawFrom, rawTo), simpleJson, ds) - if err != nil { - return nil, err - } - queryObj["intervalMs"] = calculatedInterval.Milliseconds() - - encodedObj, err := json.Marshal(queryObj) - if err != nil { - return nil, err - } - - rTR, err := getRelativeDuration(rawFrom, rawTo) - if err != nil { - return nil, err - } - - alertQuery := ngmodels.AlertQuery{ - RefID: refID, - Model: encodedObj, - RelativeTimeRange: *rTR, - QueryType: queryType, - } - - if ds != nil { - alertQuery.DatasourceUID = ds.UID - } - - newCond.Data = append(newCond.Data, alertQuery) - } - } - - // build the new classic condition pointing our new equivalent queries - conditions := make([]classicCondition, len(set.Conditions)) - for i, cond := range set.Conditions { - newCond := classicCondition{} - newCond.Evaluator = evaluator{ - Type: cond.Evaluator.Type, - Params: cond.Evaluator.Params, - } - newCond.Operator.Type = cond.Operator.Type - newCond.Query.Params = append(newCond.Query.Params, condIdxToNewRefID[i]) - newCond.Reducer.Type = cond.Reducer.Type - - conditions[i] = newCond - } - - ccRefID, err := getNewRefID(newRefIDstoCondIdx) // get refID for the classic condition - if err != nil { - return nil, err - } - newCond.Condition = ccRefID // set the alert condition to point to the classic condition - newCond.OrgID = orgID - - exprModel := struct { - Type string `json:"type"` - RefID string `json:"refId"` - Conditions []classicCondition `json:"conditions"` - }{ - "classic_conditions", - ccRefID, - conditions, - } - - exprModelJSON, err := json.Marshal(&exprModel) - if err != nil { - return nil, err - } - - ccAlertQuery := ngmodels.AlertQuery{ - RefID: ccRefID, - Model: exprModelJSON, - DatasourceUID: expressionDatasourceUID, - } - - newCond.Data = append(newCond.Data, ccAlertQuery) - - sort.Slice(newCond.Data, func(i, j int) bool { - return newCond.Data[i].RefID < newCond.Data[j].RefID - }) - - return newCond, nil -} - -type condition struct { - // Condition is the RefID of the query or expression from - // the Data property to get the results for. - Condition string `json:"condition"` - OrgID int64 `json:"-"` - - // Data is an array of data source queries and/or server side expressions. - Data []ngmodels.AlertQuery `json:"data"` -} - -const alpha = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - -// getNewRefID finds first capital letter in the alphabet not in use -// to use for a new RefID. It errors if it runs out of letters. -func getNewRefID(refIDs map[string][]int) (string, error) { - for _, r := range alpha { - sR := string(r) - if _, ok := refIDs[sR]; ok { - continue - } - return sR, nil - } - for i := 0; i < 20; i++ { - sR := util.GenerateShortUID() - if _, ok := refIDs[sR]; ok { - continue - } - return sR, nil - } - return "", errors.New("failed to generate unique RefID") -} - -// getRelativeDuration turns the alerting durations for dashboard conditions -// into a relative time range. -func getRelativeDuration(rawFrom, rawTo string) (*ngmodels.RelativeTimeRange, error) { - fromD, err := getFrom(rawFrom) - if err != nil { - return nil, err - } - - toD, err := getTo(rawTo) - if err != nil { - return nil, err - } - return &ngmodels.RelativeTimeRange{ - From: ngmodels.Duration(fromD), - To: ngmodels.Duration(toD), - }, nil -} - -func getFrom(from string) (time.Duration, error) { - fromRaw := strings.Replace(from, "now-", "", 1) - - d, err := time.ParseDuration("-" + fromRaw) - if err != nil { - return 0, err - } - return -d, err -} - -func getTo(to string) (time.Duration, error) { - if to == "now" { - return 0, nil - } else if strings.HasPrefix(to, "now-") { - withoutNow := strings.Replace(to, "now-", "", 1) - - d, err := time.ParseDuration("-" + withoutNow) - if err != nil { - return 0, err - } - return -d, nil - } - - d, err := time.ParseDuration(to) - if err != nil { - return 0, err - } - return -d, nil -} - -type classicCondition struct { - Evaluator evaluator `json:"evaluator"` - - Operator struct { - Type string `json:"type"` - } `json:"operator"` - - Query struct { - Params []string `json:"params"` - } `json:"query"` - - Reducer struct { - // Params []any `json:"params"` (Unused) - Type string `json:"type"` - } `json:"reducer"` -} - -// Copied from services/alerting/conditions/query.go's calculateInterval -func calculateInterval(timeRange legacydata.DataTimeRange, model *simplejson.Json, dsInfo *datasources.DataSource) (time.Duration, error) { - // if there is no min-interval specified in the datasource or in the dashboard-panel, - // the value of 1ms is used (this is how it is done in the dashboard-interval-calculation too, - // see https://github.com/grafana/grafana/blob/9a0040c0aeaae8357c650cec2ee644a571dddf3d/packages/grafana-data/src/datetime/rangeutil.ts#L264) - defaultMinInterval := time.Millisecond * 1 - - // interval.GetIntervalFrom has two problems (but they do not affect us here): - // - it returns the min-interval, so it should be called interval.GetMinIntervalFrom - // - it falls back to model.intervalMs. it should not, because that one is the real final - // interval-value calculated by the browser. but, in this specific case (old-alert), - // that value is not set, so the fallback never happens. - minInterval, err := interval.GetIntervalFrom(dsInfo, model, defaultMinInterval) - - if err != nil { - return time.Duration(0), err - } - - calc := interval.NewCalculator() - - intvl := calc.Calculate(timeRange, minInterval) - - return intvl.Value, nil -} diff --git a/pkg/services/ngalert/migration/cond_trans_test.go b/pkg/services/ngalert/migration/cond_trans_test.go deleted file mode 100644 index e9d4db0e2ab84..0000000000000 --- a/pkg/services/ngalert/migration/cond_trans_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package migration - -import ( - "context" - "strings" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/expr" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/log/logtest" - "github.com/grafana/grafana/pkg/services/ngalert/migration/store" - "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/setting" -) - -func TestCondTransMultiCondOnSingleQuery(t *testing.T) { - // Here we are testing that we got a query that is referenced by multiple conditions, all conditions get set correctly. - ordID := int64(1) - - settings := dashAlertSettings{} - - cond1 := dashAlertCondition{} - cond1.Evaluator.Params = []float64{20} - cond1.Evaluator.Type = "lt" - cond1.Operator.Type = "and" - cond1.Query.DatasourceID = 4 - cond1.Query.Model = []byte(`{"datasource":{"type":"graphite","uid":"000000004"},"intervalMs":2000,"maxDataPoints":1500,"refId":"F","target":"my_metrics"}`) - cond1.Query.Params = []string{ - "F", - "75m", - "now-15m", - } - cond1.Reducer.Type = "avg" - - cond2 := dashAlertCondition{} - cond2.Evaluator.Params = []float64{500} - cond2.Evaluator.Type = "gt" - cond2.Operator.Type = "or" - cond2.Query.DatasourceID = 4 - cond1.Query.Model = []byte(`{"datasource":{"type":"graphite","uid":"000000004"},"intervalMs":2000,"maxDataPoints":1500,"refId":"F","target":"my_metrics"}`) - cond2.Query.Params = []string{ - "F", - "75m", - "now-15m", - } - cond2.Reducer.Type = "avg" - - settings.Conditions = []dashAlertCondition{cond1, cond2} - - alertQuery1 := models.AlertQuery{ - RefID: "A", - DatasourceUID: expr.DatasourceUID, - Model: []byte(`{"type":"classic_conditions","refId":"A","conditions":[{"evaluator":{"params":[20],"type":"lt"},"operator":{"type":"and"},"query":{"params":["F"]},"reducer":{"type":"avg"}},{"evaluator":{"params":[500],"type":"gt"},"operator":{"type":"or"},"query":{"params":["F"]},"reducer":{"type":"avg"}}]}`), - } - alertQuery2 := models.AlertQuery{ - RefID: "F", - RelativeTimeRange: models.RelativeTimeRange{ - From: 4500000000000, - To: 900000000000, - }, - Model: cond1.Query.Model, - } - expected := &condition{ - Condition: "A", - OrgID: ordID, - Data: []models.AlertQuery{alertQuery1, alertQuery2}, - } - - migrationStore := store.NewTestMigrationStore(t, db.InitTestDB(t), &setting.Cfg{}) - c, err := transConditions(context.Background(), &logtest.Fake{}, settings, ordID, migrationStore) - - require.NoError(t, err) - require.Equal(t, expected, c) -} - -func TestCondTransExtended(t *testing.T) { - // Here we are testing that we got a query that is referenced with multiple different offsets, the migration - // generated correctly all subqueries for each offset. RefID A exists twice with a different offset (cond1, cond4). - ordID := int64(1) - - settings := dashAlertSettings{} - - cond1 := dashAlertCondition{} - cond1.Evaluator.Params = []float64{-500000} - cond1.Evaluator.Type = "lt" - cond1.Operator.Type = "and" - cond1.Query.DatasourceID = 4 - cond1.Query.Model = []byte(`{"datasource":{"type":"graphite","uid":"1"},"hide":false,"intervalMs":15000,"maxDataPoints":1500,"refCount":0,"refId":"A","target":"my_metric_1","textEditor":true}`) - cond1.Query.Params = []string{ - "A", - "1h", - "now", - } - cond1.Reducer.Type = "diff" - - cond2 := dashAlertCondition{} - cond2.Evaluator.Params = []float64{ - -0.01, - 0.01, - } - cond2.Evaluator.Type = "within_range" - cond2.Operator.Type = "or" - cond2.Query.DatasourceID = 4 - cond2.Query.Model = []byte(`{"datasource":{"type":"graphite","uid":"1"},"hide":true,"intervalMs":15000,"maxDataPoints":1500,"refCount":0,"refId":"B","target":"my_metric_2","textEditor":false}`) - cond2.Query.Params = []string{ - "B", - "6h", - "now", - } - cond2.Reducer.Type = "diff" - - cond3 := dashAlertCondition{} - cond3.Evaluator.Params = []float64{ - -500000, - } - cond3.Evaluator.Type = "lt" - cond3.Operator.Type = "or" - cond3.Query.DatasourceID = 4 - cond3.Query.Model = []byte(`{"datasource":{"type":"graphite","uid":"1"},"hide":false,"intervalMs":15000,"maxDataPoints":1500,"refCount":0,"refId":"C","target":"my_metric_3","textEditor":false}`) - cond3.Query.Params = []string{ - "C", - "1m", - "now", - } - cond3.Reducer.Type = "diff" - - cond4 := dashAlertCondition{} - cond4.Evaluator.Params = []float64{ - 1000000, - } - cond4.Evaluator.Type = "gt" - cond4.Operator.Type = "and" - cond4.Query.DatasourceID = 4 - cond4.Query.Model = []byte(`{"datasource":{"type":"graphite","uid":"1"},"hide":false,"intervalMs":15000,"maxDataPoints":1500,"refCount":0,"refId":"A","target":"my_metric_1","textEditor":true}`) - cond4.Query.Params = []string{ - "A", - "5m", - "now", - } - cond4.Reducer.Type = "last" - - settings.Conditions = []dashAlertCondition{cond1, cond2, cond3, cond4} - - alertQuery1 := models.AlertQuery{ - RefID: "A", - RelativeTimeRange: models.RelativeTimeRange{ - From: 3600000000000, - }, - Model: cond1.Query.Model, - } - alertQuery2 := models.AlertQuery{ - RefID: "B", - RelativeTimeRange: models.RelativeTimeRange{ - From: 300000000000, - }, - Model: []byte(strings.ReplaceAll(string(cond1.Query.Model), "refId\":\"A", "refId\":\"B")), - } - alertQuery3 := models.AlertQuery{ - RefID: "C", - RelativeTimeRange: models.RelativeTimeRange{ - From: 21600000000000, - }, - Model: []byte(strings.ReplaceAll(string(cond2.Query.Model), "refId\":\"B", "refId\":\"C")), - } - alertQuery4 := models.AlertQuery{ - RefID: "D", - RelativeTimeRange: models.RelativeTimeRange{ - From: 60000000000, - }, - Model: []byte(strings.ReplaceAll(string(cond3.Query.Model), "refId\":\"C", "refId\":\"D")), - } - alertQuery5 := models.AlertQuery{ - RefID: "E", - DatasourceUID: "__expr__", - Model: []byte(`{"type":"classic_conditions","refId":"E","conditions":[{"evaluator":{"params":[-500000],"type":"lt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"diff"}},{"evaluator":{"params":[-0.01,0.01],"type":"within_range"},"operator":{"type":"or"},"query":{"params":["C"]},"reducer":{"type":"diff"}},{"evaluator":{"params":[-500000],"type":"lt"},"operator":{"type":"or"},"query":{"params":["D"]},"reducer":{"type":"diff"}},{"evaluator":{"params":[1000000],"type":"gt"},"operator":{"type":"and"},"query":{"params":["B"]},"reducer":{"type":"last"}}]}`), - } - - expected := &condition{ - Condition: "E", - OrgID: ordID, - Data: []models.AlertQuery{alertQuery1, alertQuery2, alertQuery3, alertQuery4, alertQuery5}, - } - - migrationStore := store.NewTestMigrationStore(t, db.InitTestDB(t), &setting.Cfg{}) - c, err := transConditions(context.Background(), &logtest.Fake{}, settings, ordID, migrationStore) - - require.NoError(t, err) - require.Equal(t, expected, c) -} diff --git a/pkg/services/ngalert/migration/migration_test.go b/pkg/services/ngalert/migration/migration_test.go deleted file mode 100644 index 9806746559c70..0000000000000 --- a/pkg/services/ngalert/migration/migration_test.go +++ /dev/null @@ -1,1433 +0,0 @@ -package migration - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/prometheus/alertmanager/config" - "github.com/prometheus/alertmanager/pkg/labels" - "github.com/prometheus/common/model" - "github.com/stretchr/testify/require" - - "xorm.io/xorm" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/datasources" - apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" - migrationStore "github.com/grafana/grafana/pkg/services/ngalert/migration/store" - ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/legacydata" -) - -// TestServiceStart tests the wrapper method that decides when to run the migration based on migration status and settings. -func TestServiceStart(t *testing.T) { - tc := []struct { - name string - config *setting.Cfg - starting migrationStore.AlertingType - expectedErr bool - expected migrationStore.AlertingType - }{ - { - name: "when unified alerting enabled and migration not already run, then run migration", - config: &setting.Cfg{ - UnifiedAlerting: setting.UnifiedAlertingSettings{ - Enabled: pointer(true), - }, - }, - starting: migrationStore.Legacy, - expected: migrationStore.UnifiedAlerting, - }, - { - name: "when unified alerting disabled, migration is already run and CleanUpgrade is enabled, then revert migration", - config: &setting.Cfg{ - UnifiedAlerting: setting.UnifiedAlertingSettings{ - Enabled: pointer(false), - Upgrade: setting.UnifiedAlertingUpgradeSettings{ - CleanUpgrade: true, - }, - }, - }, - starting: migrationStore.UnifiedAlerting, - expected: migrationStore.Legacy, - }, - { - name: "when unified alerting disabled, migration is already run and CleanUpgrade is disabled, then the migration status should set to false", - config: &setting.Cfg{ - UnifiedAlerting: setting.UnifiedAlertingSettings{ - Enabled: pointer(false), - Upgrade: setting.UnifiedAlertingUpgradeSettings{ - CleanUpgrade: false, - }, - }, - }, - starting: migrationStore.UnifiedAlerting, - expected: migrationStore.Legacy, - }, - { - name: "when unified alerting enabled and migration is already run, then do nothing", - config: &setting.Cfg{ - UnifiedAlerting: setting.UnifiedAlertingSettings{ - Enabled: pointer(true), - }, - }, - starting: migrationStore.UnifiedAlerting, - expected: migrationStore.UnifiedAlerting, - }, - { - name: "when unified alerting disabled and migration is not already run, then do nothing", - config: &setting.Cfg{ - UnifiedAlerting: setting.UnifiedAlertingSettings{ - Enabled: pointer(false), - }, - }, - starting: migrationStore.Legacy, - expected: migrationStore.Legacy, - }, - { - name: "when unified alerting disabled, migration is already run and force migration is enabled, then revert migration", - config: &setting.Cfg{ - UnifiedAlerting: setting.UnifiedAlertingSettings{ - Enabled: pointer(false), - }, - ForceMigration: true, - }, - starting: migrationStore.UnifiedAlerting, - expected: migrationStore.Legacy, - }, - { - name: "when unified alerting disabled, migration is already run and force migration is disabled, then the migration status should set to false", - config: &setting.Cfg{ - UnifiedAlerting: setting.UnifiedAlertingSettings{ - Enabled: pointer(false), - }, - ForceMigration: false, - }, - starting: migrationStore.UnifiedAlerting, - expected: migrationStore.Legacy, - }, - } - - sqlStore := db.InitTestDB(t) - for _, tt := range tc { - t.Run(tt.name, func(t *testing.T) { - ctx := context.Background() - service := NewTestMigrationService(t, sqlStore, tt.config) - - require.NoError(t, service.migrationStore.SetCurrentAlertingType(ctx, tt.starting)) - - err := service.Run(ctx) - if tt.expectedErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } - - aType, err := service.migrationStore.GetCurrentAlertingType(ctx) - require.NoError(t, err) - require.Equal(t, tt.expected, aType) - }) - } -} - -// TestAMConfigMigration tests the execution of the migration specifically for migrations of channels and routes. -func TestAMConfigMigration(t *testing.T) { - sqlStore := db.InitTestDB(t) - x := sqlStore.GetEngine() - service := NewTestMigrationService(t, sqlStore, &setting.Cfg{}) - tc := []struct { - name string - legacyChannels []*models.AlertNotification - alerts []*models.Alert - - expected map[int64]*apimodels.PostableUserConfig - expErrors []string - }{ - { - name: "general multi-org, multi-alert, multi-channel migration", - legacyChannels: []*models.AlertNotification{ - createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false), - createAlertNotification(t, int64(1), "notifier2", "slack", slackSettings, false), - createAlertNotification(t, int64(1), "notifier3", "opsgenie", opsgenieSettings, false), - createAlertNotification(t, int64(2), "notifier4", "email", emailSettings, false), - createAlertNotification(t, int64(2), "notifier5", "slack", slackSettings, false), - createAlertNotification(t, int64(2), "notifier6", "opsgenie", opsgenieSettings, true), // default - }, - alerts: []*models.Alert{ - createAlert(t, 1, 1, 1, "alert1", []string{"notifier1"}), - createAlert(t, 1, 1, 2, "alert2", []string{"notifier2", "notifier3"}), - createAlert(t, 1, 2, 3, "alert3", []string{"notifier3"}), - createAlert(t, 2, 3, 1, "alert4", []string{"notifier4"}), - createAlert(t, 2, 3, 2, "alert5", []string{"notifier4", "notifier5"}), - createAlert(t, 2, 4, 3, "alert6", []string{}), - }, - expected: map[int64]*apimodels.PostableUserConfig{ - int64(1): { - AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ - Config: apimodels.Config{Route: &apimodels.Route{ - Receiver: "autogen-contact-point-default", - GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, - Routes: []*apimodels.Route{ - { - ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}}, - Continue: true, - Routes: []*apimodels.Route{ - {Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - {Receiver: "notifier2", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier2"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - {Receiver: "notifier3", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier3"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - }, - }, - }, - }}, - Receivers: []*apimodels.PostableApiReceiver{ - {Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{}}, - {Receiver: config.Receiver{Name: "notifier1"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}}, - {Receiver: config.Receiver{Name: "notifier2"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier2", Type: "slack"}}}}, - {Receiver: config.Receiver{Name: "notifier3"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier3", Type: "opsgenie"}}}}, - }, - }, - }, - int64(2): { - AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ - Config: apimodels.Config{Route: &apimodels.Route{ - Receiver: "autogen-contact-point-default", - GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, - Routes: []*apimodels.Route{ - { - ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}}, - Continue: true, - Routes: []*apimodels.Route{ - {Receiver: "notifier6", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchRegexp, Name: model.AlertNameLabel, Value: ".+"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - {Receiver: "notifier4", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier4"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - {Receiver: "notifier5", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier5"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - }, - }, - }, - }}, - Receivers: []*apimodels.PostableApiReceiver{ - {Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{}}, - {Receiver: config.Receiver{Name: "notifier6"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier6", Type: "opsgenie"}}}}, - {Receiver: config.Receiver{Name: "notifier4"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier4", Type: "email"}}}}, - {Receiver: config.Receiver{Name: "notifier5"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier5", Type: "slack"}}}}, - }, - }, - }, - }, - }, - { - name: "when no default channel, create empty autogen-contact-point-default", - legacyChannels: []*models.AlertNotification{ - createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false), - }, - alerts: []*models.Alert{}, - expected: map[int64]*apimodels.PostableUserConfig{ - int64(1): { - AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ - Config: apimodels.Config{Route: &apimodels.Route{ - Receiver: "autogen-contact-point-default", - GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, - Routes: []*apimodels.Route{ - { - ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}}, - Continue: true, - Routes: []*apimodels.Route{ - {Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - }, - }, - }, - }}, - Receivers: []*apimodels.PostableApiReceiver{ - {Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{}}, - {Receiver: config.Receiver{Name: "notifier1"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}}, - }, - }, - }, - }, - }, - { - name: "when multiple default channels, they all have catch-all matchers", - legacyChannels: []*models.AlertNotification{ - createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, true), - createAlertNotification(t, int64(1), "notifier2", "slack", slackSettings, true), - }, - alerts: []*models.Alert{}, - expected: map[int64]*apimodels.PostableUserConfig{ - int64(1): { - AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ - Config: apimodels.Config{Route: &apimodels.Route{ - Receiver: "autogen-contact-point-default", - GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, - Routes: []*apimodels.Route{ - { - ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}}, - Continue: true, - Routes: []*apimodels.Route{ - {Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchRegexp, Name: model.AlertNameLabel, Value: ".+"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - {Receiver: "notifier2", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchRegexp, Name: model.AlertNameLabel, Value: ".+"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - }, - }, - }, - }}, - Receivers: []*apimodels.PostableApiReceiver{ - {Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{}}, - {Receiver: config.Receiver{Name: "notifier1"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}}, - {Receiver: config.Receiver{Name: "notifier2"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier2", Type: "slack"}}}}, - }, - }, - }, - }, - }, - { - name: "when alerts share channels, only create one receiver per legacy channel", - legacyChannels: []*models.AlertNotification{ - createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false), - createAlertNotification(t, int64(1), "notifier2", "slack", slackSettings, false), - }, - alerts: []*models.Alert{ - createAlert(t, 1, 1, 1, "alert1", []string{"notifier1"}), - createAlert(t, 1, 1, 1, "alert2", []string{"notifier1", "notifier2"}), - }, - expected: map[int64]*apimodels.PostableUserConfig{ - int64(1): { - AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ - Config: apimodels.Config{Route: &apimodels.Route{ - Receiver: "autogen-contact-point-default", - GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, - Routes: []*apimodels.Route{ - { - ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}}, - Continue: true, - Routes: []*apimodels.Route{ - {Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - {Receiver: "notifier2", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier2"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - }, - }, - }, - }}, - Receivers: []*apimodels.PostableApiReceiver{ - {Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{}}, - {Receiver: config.Receiver{Name: "notifier1"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}}, - {Receiver: config.Receiver{Name: "notifier2"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier2", Type: "slack"}}}}, - }, - }, - }, - }, - }, - { - name: "when channel not linked to any alerts, still create a receiver for it", - legacyChannels: []*models.AlertNotification{ - createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false), - }, - alerts: []*models.Alert{}, - expected: map[int64]*apimodels.PostableUserConfig{ - int64(1): { - AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ - Config: apimodels.Config{Route: &apimodels.Route{ - Receiver: "autogen-contact-point-default", - GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, - Routes: []*apimodels.Route{ - { - ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}}, - Continue: true, - Routes: []*apimodels.Route{ - {Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - }, - }, - }, - }}, - Receivers: []*apimodels.PostableApiReceiver{ - {Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{}}, - {Receiver: config.Receiver{Name: "notifier1"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}}, - }, - }, - }, - }, - }, - { - name: "when unsupported channels, do not migrate them", - legacyChannels: []*models.AlertNotification{ - createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false), - createAlertNotification(t, int64(1), "notifier2", "hipchat", "", false), - createAlertNotification(t, int64(1), "notifier3", "sensu", "", false), - }, - alerts: []*models.Alert{}, - expected: map[int64]*apimodels.PostableUserConfig{ - int64(1): { - AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ - Config: apimodels.Config{Route: &apimodels.Route{ - Receiver: "autogen-contact-point-default", - GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, - Routes: []*apimodels.Route{ - { - ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}}, - Continue: true, - Routes: []*apimodels.Route{ - {Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - }, - }, - }, - }}, - Receivers: []*apimodels.PostableApiReceiver{ - {Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{}}, - {Receiver: config.Receiver{Name: "notifier1"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}}, - }, - }, - }, - }, - }, - { - name: "when unsupported channel linked to alert, do not migrate only that channel", - legacyChannels: []*models.AlertNotification{ - createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false), - createAlertNotification(t, int64(1), "notifier2", "sensu", "", false), - }, - alerts: []*models.Alert{ - createAlert(t, 1, 1, 1, "alert1", []string{"notifier1", "notifier2"}), - }, - expected: map[int64]*apimodels.PostableUserConfig{ - int64(1): { - AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ - Config: apimodels.Config{Route: &apimodels.Route{ - Receiver: "autogen-contact-point-default", - GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, - Routes: []*apimodels.Route{ - { - ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}}, - Continue: true, - Routes: []*apimodels.Route{ - {Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, - }, - }, - }, - }}, - Receivers: []*apimodels.PostableApiReceiver{ - {Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{}}, - {Receiver: config.Receiver{Name: "notifier1"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}}, - }, - }, - }, - }, - }, - { - name: "failed channel migration fails upgrade", - legacyChannels: []*models.AlertNotification{ - createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false), - createAlertNotification(t, int64(1), "notifier2", "slack", brokenSettings, false), - }, - alerts: []*models.Alert{ - createAlert(t, 1, 1, 1, "alert1", []string{"notifier1"}), - }, - expErrors: []string{"channel 'notifier2'"}, - }, - } - - for _, tt := range tc { - t.Run(tt.name, func(t *testing.T) { - defer teardown(t, x, service) - dashes := []*dashboards.Dashboard{ - createDashboard(t, 1, 1, "dash1-1", 5, nil), - createDashboard(t, 2, 1, "dash2-1", 5, nil), - createDashboard(t, 3, 2, "dash3-2", 6, nil), - createDashboard(t, 4, 2, "dash4-2", 6, nil), - } - folders := []*dashboards.Dashboard{ - createFolder(t, 5, 1, "folder5-1"), - createFolder(t, 6, 2, "folder6-2"), - } - setupLegacyAlertsTables(t, x, tt.legacyChannels, tt.alerts, folders, dashes) - - err := service.Run(context.Background()) - if len(tt.expErrors) > 0 { - for _, expErr := range tt.expErrors { - require.ErrorContains(t, err, expErr) - } - return - } - require.NoError(t, err) - - for orgId := range tt.expected { - amConfig := getAlertmanagerConfig(t, x, orgId) - - // Order of nested GrafanaManagedReceivers is not guaranteed. - cOpt := []cmp.Option{ - cmpopts.IgnoreUnexported(apimodels.PostableApiReceiver{}), - cmpopts.IgnoreFields(apimodels.PostableGrafanaReceiver{}, "UID", "Settings", "SecureSettings"), - cmpopts.SortSlices(func(a, b *apimodels.PostableGrafanaReceiver) bool { return a.Name < b.Name }), - cmpopts.SortSlices(func(a, b *apimodels.PostableApiReceiver) bool { return a.Name < b.Name }), - } - if !cmp.Equal(tt.expected[orgId].AlertmanagerConfig.Receivers, amConfig.AlertmanagerConfig.Receivers, cOpt...) { - t.Errorf("Unexpected Receivers: %v", cmp.Diff(tt.expected[orgId].AlertmanagerConfig.Receivers, amConfig.AlertmanagerConfig.Receivers, cOpt...)) - } - - // Order of routes is not guaranteed. - cOpt = []cmp.Option{ - cmpopts.SortSlices(func(a, b *apimodels.Route) bool { - if a.Receiver != b.Receiver { - return a.Receiver < b.Receiver - } - return a.ObjectMatchers[0].Value < b.ObjectMatchers[0].Value - }), - cmpopts.IgnoreUnexported(apimodels.Route{}, labels.Matcher{}), - cmpopts.IgnoreFields(apimodels.Route{}, "GroupBy", "GroupByAll"), - } - if !cmp.Equal(tt.expected[orgId].AlertmanagerConfig.Route, amConfig.AlertmanagerConfig.Route, cOpt...) { - t.Errorf("Unexpected Route: %v", cmp.Diff(tt.expected[orgId].AlertmanagerConfig.Route, amConfig.AlertmanagerConfig.Route, cOpt...)) - } - } - }) - } -} - -// TestDashAlertMigration tests the execution of the migration specifically for alert rules. -func TestDashAlertMigration(t *testing.T) { - withDefaults := func(lbls map[string]string) map[string]string { - lbls[ngModels.MigratedUseLegacyChannelsLabel] = "true" - return lbls - } - - t.Run("when DashAlertMigration create ContactLabel on migrated AlertRules", func(t *testing.T) { - sqlStore := db.InitTestDB(t) - x := sqlStore.GetEngine() - service := NewTestMigrationService(t, sqlStore, &setting.Cfg{}) - legacyChannels := []*models.AlertNotification{ - createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false), - createAlertNotification(t, int64(1), "notifier2", "slack", slackSettings, false), - createAlertNotification(t, int64(1), "notifier3", "opsgenie", opsgenieSettings, false), - createAlertNotification(t, int64(2), "notifier4", "email", emailSettings, false), - createAlertNotification(t, int64(2), "notifier5", "slack", slackSettings, false), - createAlertNotification(t, int64(2), "notifier6", "opsgenie", opsgenieSettings, true), // default - } - alerts := []*models.Alert{ - createAlert(t, 1, 1, 1, "alert1", []string{"notifier1"}), - createAlert(t, 1, 1, 2, "alert2", []string{"notifier2", "notifier3"}), - createAlert(t, 1, 2, 3, "alert3", []string{"notifier3"}), - createAlert(t, 2, 3, 1, "alert4", []string{"notifier4"}), - createAlert(t, 2, 3, 2, "alert5", []string{"notifier4", "notifier5"}), - createAlert(t, 2, 4, 3, "alert6", []string{}), - } - expected := map[int64]map[string]*ngModels.AlertRule{ - int64(1): { - "alert1": {Labels: withDefaults(map[string]string{contactLabel("notifier1"): "true"})}, - "alert2": {Labels: withDefaults(map[string]string{contactLabel("notifier2"): "true", contactLabel("notifier3"): "true"})}, - "alert3": {Labels: withDefaults(map[string]string{contactLabel("notifier3"): "true"})}, - }, - int64(2): { - // Don't include default channels. - "alert4": {Labels: withDefaults(map[string]string{contactLabel("notifier4"): "true"})}, - "alert5": {Labels: withDefaults(map[string]string{contactLabel("notifier4"): "true", contactLabel("notifier5"): "true"})}, - "alert6": {Labels: withDefaults(map[string]string{})}, - }, - } - dashes := []*dashboards.Dashboard{ - createDashboard(t, 1, 1, "dash1-1", 5, nil), - createDashboard(t, 2, 1, "dash2-1", 5, nil), - createDashboard(t, 3, 2, "dash3-2", 6, nil), - createDashboard(t, 4, 2, "dash4-2", 6, nil), - } - folders := []*dashboards.Dashboard{ - createFolder(t, 5, 1, "folder5-1"), - createFolder(t, 6, 2, "folder6-2"), - } - setupLegacyAlertsTables(t, x, legacyChannels, alerts, folders, dashes) - err := service.Run(context.Background()) - require.NoError(t, err) - - for orgId := range expected { - rules := getAlertRules(t, x, orgId) - expectedRulesMap := expected[orgId] - require.Len(t, rules, len(expectedRulesMap)) - for _, r := range rules { - delete(r.Labels, "rule_uid") // Not checking this here. - exp := expectedRulesMap[r.Title].Labels - require.Lenf(t, r.Labels, len(exp), "rule doesn't have correct number of labels: %s", r.Title) - for l := range r.Labels { - require.Equal(t, exp[l], r.Labels[l]) - } - } - } - }) - - t.Run("when folder is missing put alert in General folder", func(t *testing.T) { - sqlStore := db.InitTestDB(t) - x := sqlStore.GetEngine() - service := NewTestMigrationService(t, sqlStore, &setting.Cfg{}) - o := createOrg(t, 1) - folder1 := createFolder(t, 1, o.ID, "folder-1") - dash1 := createDashboard(t, 3, o.ID, "dash1", folder1.ID, nil) - dash2 := createDashboard(t, 4, o.ID, "dash2", 22, nil) // missing folder - - a1 := createAlert(t, int(o.ID), int(dash1.ID), 1, "alert-1", []string{}) - a2 := createAlert(t, int(o.ID), int(dash2.ID), 1, "alert-2", []string{}) - - _, err := x.Insert(o, folder1, dash1, dash2, a1, a2) - require.NoError(t, err) - - err = service.Run(context.Background()) - require.NoError(t, err) - - rules := getAlertRules(t, x, o.ID) - require.Len(t, rules, 2) - - var generalFolder dashboards.Dashboard - _, err = x.Table(&dashboards.Dashboard{}).Where("title = ? AND org_id = ?", generalAlertingFolderTitle, o.ID).Get(&generalFolder) - require.NoError(t, err) - - require.NotNil(t, generalFolder) - - for _, rule := range rules { - var expectedFolder dashboards.Dashboard - if rule.Title == a1.Name { - expectedFolder = *folder1 - } else { - expectedFolder = generalFolder - } - require.Equal(t, expectedFolder.UID, rule.NamespaceUID) - } - }) - - t.Run("when alert notification settings contain different combinations of id and uid", func(t *testing.T) { - sqlStore := db.InitTestDB(t) - x := sqlStore.GetEngine() - service := NewTestMigrationService(t, sqlStore, &setting.Cfg{}) - legacyChannels := []*models.AlertNotification{ - createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false), - createAlertNotification(t, int64(1), "notifier2", "slack", slackSettings, false), - createAlertNotification(t, int64(1), "notifier3", "opsgenie", opsgenieSettings, false), - createAlertNotification(t, int64(2), "notifier4", "email", emailSettings, false), - createAlertNotification(t, int64(2), "notifier5", "slack", slackSettings, false), - createAlertNotification(t, int64(2), "notifier6", "opsgenie", opsgenieSettings, true), // default - } - alerts := []*models.Alert{ - createAlert(t, 1, 1, 1, "alert1", nil), - createAlert(t, 1, 1, 2, "alert2", nil), - createAlert(t, 1, 2, 3, "alert3", nil), - createAlert(t, 2, 3, 1, "alert4", nil), - createAlert(t, 2, 3, 2, "alert5", nil), - createAlert(t, 2, 4, 3, "alert6", nil), - } - alerts[0].Settings.Set("notifications", []notificationKey{{UID: "notifier1"}}) - alerts[1].Settings.Set("notifications", []notificationKey{{ID: 2}, {UID: "notifier3"}}) - alerts[2].Settings.Set("notifications", []notificationKey{{ID: 3, UID: "notifier4"}}) // This shouldn't happen, but if it does we choose the ID. - alerts[3].Settings.Set("notifications", []notificationKey{{ID: -99}}) // Unknown ID - alerts[4].Settings.Set("notifications", []notificationKey{{UID: "unknown"}}) // Unknown UID - alerts[5].Settings.Set("notifications", []notificationKey{{ID: -99}, {UID: "unknown"}, {UID: "notifier4"}, {ID: 5}}) // Mixed unknown and known. - - expected := map[int64]map[string]*ngModels.AlertRule{ - int64(1): { - "alert1": {Labels: withDefaults(map[string]string{contactLabel("notifier1"): "true"})}, - "alert2": {Labels: withDefaults(map[string]string{contactLabel("notifier2"): "true", contactLabel("notifier3"): "true"})}, - "alert3": {Labels: withDefaults(map[string]string{contactLabel("notifier3"): "true"})}, - }, - int64(2): { - // Don't include default channels. - "alert4": {Labels: withDefaults(map[string]string{})}, - "alert5": {Labels: withDefaults(map[string]string{})}, - "alert6": {Labels: withDefaults(map[string]string{contactLabel("notifier4"): "true", contactLabel("notifier5"): "true"})}, - }, - } - dashes := []*dashboards.Dashboard{ - createDashboard(t, 1, 1, "dash1-1", 5, nil), - createDashboard(t, 2, 1, "dash2-1", 5, nil), - createDashboard(t, 3, 2, "dash3-2", 6, nil), - createDashboard(t, 4, 2, "dash4-2", 6, nil), - } - folders := []*dashboards.Dashboard{ - createFolder(t, 5, 1, "folder5-1"), - createFolder(t, 6, 2, "folder6-2"), - } - setupLegacyAlertsTables(t, x, legacyChannels, alerts, folders, dashes) - err := service.Run(context.Background()) - require.NoError(t, err) - - for orgId := range expected { - rules := getAlertRules(t, x, orgId) - expectedRulesMap := expected[orgId] - require.Len(t, rules, len(expectedRulesMap)) - for _, r := range rules { - delete(r.Labels, "rule_uid") // Not checking this here. - exp := expectedRulesMap[r.Title].Labels - require.Lenf(t, r.Labels, len(exp), "rule doesn't have correct number of labels: %s", r.Title) - for l := range r.Labels { - require.Equal(t, exp[l], r.Labels[l]) - } - } - } - }) -} - -// TestDashAlertQueryMigration tests the execution of the migration specifically for alert rule queries. -func TestDashAlertQueryMigration(t *testing.T) { - sqlStore := db.InitTestDB(t) - x := sqlStore.GetEngine() - service := NewTestMigrationService(t, sqlStore, &setting.Cfg{}) - - newQueryModel := `{"datasource":{"type":"prometheus","uid":"gdev-prometheus"},"expr":"up{job=\"fake-data-gen\"}","instant":false,"interval":"%s","intervalMs":%d,"maxDataPoints":1500,"refId":"%s"}` - createAlertQueryWithModel := func(refId string, ds string, from string, to string, model string) ngModels.AlertQuery { - rel, _ := getRelativeDuration(from, to) - return ngModels.AlertQuery{ - RefID: refId, - RelativeTimeRange: ngModels.RelativeTimeRange{From: rel.From, To: rel.To}, - DatasourceUID: ds, - Model: []byte(model), - } - } - - createAlertQuery := func(refId string, ds string, from string, to string) ngModels.AlertQuery { - dur, _ := calculateInterval(legacydata.NewDataTimeRange(from, to), simplejson.New(), nil) - return createAlertQueryWithModel(refId, ds, from, to, fmt.Sprintf(newQueryModel, "", dur.Milliseconds(), refId)) - } - - createClassicConditionQuery := func(refId string, conditions []classicCondition) ngModels.AlertQuery { - exprModel := struct { - Type string `json:"type"` - RefID string `json:"refId"` - Conditions []classicCondition `json:"conditions"` - }{ - "classic_conditions", - refId, - conditions, - } - exprModelJSON, _ := json.Marshal(&exprModel) - - q := ngModels.AlertQuery{ - RefID: refId, - DatasourceUID: expressionDatasourceUID, - Model: exprModelJSON, - } - // IntervalMS and MaxDataPoints are created PreSave by AlertQuery. They don't appear to be necessary for expressions, - // but run PreSave here to match the expected model. - _ = q.PreSave() - return q - } - - cond := func(refId string, reducer string, evalType string, thresh float64) classicCondition { - return classicCondition{ - Evaluator: evaluator{Params: []float64{thresh}, Type: evalType}, - Operator: struct { - Type string `json:"type"` - }{Type: "and"}, - Query: struct { - Params []string `json:"params"` - }{Params: []string{refId}}, - Reducer: struct { - Type string `json:"type"` - }{Type: reducer}, - } - } - - genAlert := func(mutators ...ngModels.AlertRuleMutator) *ngModels.AlertRule { - rule := &ngModels.AlertRule{ - ID: 1, - OrgID: 1, - Title: "alert1", - Condition: "B", - Data: []ngModels.AlertQuery{}, - IntervalSeconds: 60, - Version: 1, - NamespaceUID: "folder5-1", - DashboardUID: pointer("dash1-1"), - PanelID: pointer(int64(1)), - RuleGroup: "dash1-1", - RuleGroupIndex: 1, - NoDataState: ngModels.NoData, - ExecErrState: ngModels.AlertingErrState, - For: 60 * time.Second, - Annotations: map[string]string{ - "message": "message", - }, - Labels: map[string]string{ngModels.MigratedUseLegacyChannelsLabel: "true"}, - IsPaused: false, - } - - for _, mutator := range mutators { - mutator(rule) - } - - rule.RuleGroup = fmt.Sprintf("%s - 1m", *rule.DashboardUID) - - rule.Annotations["__dashboardUid__"] = *rule.DashboardUID - rule.Annotations["__panelId__"] = strconv.FormatInt(*rule.PanelID, 10) - return rule - } - - type testcase struct { - name string - alerts []*models.Alert - - expectedFolder *dashboards.Dashboard - expected map[int64][]*ngModels.AlertRule - expErrors []string - } - - tc := []testcase{ - { - name: "simple query and condition", - alerts: []*models.Alert{ - createAlertWithCond(t, 1, 1, 1, "alert1", nil, - []dashAlertCondition{createCondition("A", "max", "gt", 42, 1, "5m", "now")}), - createAlertWithCond(t, 2, 3, 1, "alert1", nil, - []dashAlertCondition{createCondition("A", "max", "gt", 42, 3, "5m", "now")}), - }, - expected: map[int64][]*ngModels.AlertRule{ - int64(1): { - genAlert(func(rule *ngModels.AlertRule) { - rule.Data = append(rule.Data, createAlertQuery("A", "ds1-1", "5m", "now")) - rule.Data = append(rule.Data, createClassicConditionQuery("B", []classicCondition{ - cond("A", "max", "gt", 42), - })) - }), - }, - int64(2): { - genAlert(func(rule *ngModels.AlertRule) { - rule.OrgID = 2 - rule.DashboardUID = pointer("dash3-2") - rule.NamespaceUID = "folder6-2" - rule.Data = append(rule.Data, createAlertQuery("A", "ds3-2", "5m", "now")) - rule.Data = append(rule.Data, createClassicConditionQuery("B", []classicCondition{ - cond("A", "max", "gt", 42), - })) - }), - }, - }, - }, - { - name: "multiple conditions", - alerts: []*models.Alert{ - createAlertWithCond(t, 1, 1, 1, "alert1", nil, - []dashAlertCondition{ - createCondition("A", "avg", "gt", 42, 1, "5m", "now"), - createCondition("B", "max", "gt", 43, 2, "3m", "now"), - createCondition("C", "min", "lt", 20, 2, "3m", "now"), - }), - }, - expected: map[int64][]*ngModels.AlertRule{ - int64(1): { - genAlert(func(rule *ngModels.AlertRule) { - rule.Condition = "D" - rule.Data = append(rule.Data, createAlertQuery("A", "ds1-1", "5m", "now")) - rule.Data = append(rule.Data, createAlertQuery("B", "ds2-1", "3m", "now")) - rule.Data = append(rule.Data, createAlertQuery("C", "ds2-1", "3m", "now")) - rule.Data = append(rule.Data, createClassicConditionQuery("D", []classicCondition{ - cond("A", "avg", "gt", 42), - cond("B", "max", "gt", 43), - cond("C", "min", "lt", 20), - })) - }), - }, - }, - }, - { - name: "multiple conditions on same query with same timerange should not create multiple queries", - alerts: []*models.Alert{ - createAlertWithCond(t, 1, 1, 1, "alert1", nil, - []dashAlertCondition{ - createCondition("A", "max", "gt", 42, 1, "5m", "now"), - createCondition("A", "avg", "gt", 20, 1, "5m", "now"), - }), - }, - expected: map[int64][]*ngModels.AlertRule{ - int64(1): { - genAlert(func(rule *ngModels.AlertRule) { - rule.Condition = "B" - rule.Data = append(rule.Data, createAlertQuery("A", "ds1-1", "5m", "now")) - rule.Data = append(rule.Data, createClassicConditionQuery("B", []classicCondition{ - cond("A", "max", "gt", 42), - cond("A", "avg", "gt", 20), - })) - }), - }, - }, - }, - { - name: "multiple conditions on same query with different timeranges should create multiple queries", - alerts: []*models.Alert{ - createAlertWithCond(t, 1, 1, 1, "alert1", nil, - []dashAlertCondition{ - createCondition("A", "max", "gt", 42, 1, "5m", "now"), - createCondition("A", "avg", "gt", 20, 1, "3m", "now"), - }), - }, - expected: map[int64][]*ngModels.AlertRule{ - int64(1): { - genAlert(func(rule *ngModels.AlertRule) { - rule.Condition = "C" - rule.Data = append(rule.Data, createAlertQuery("A", "ds1-1", "3m", "now")) // Ordered by time range. - rule.Data = append(rule.Data, createAlertQuery("B", "ds1-1", "5m", "now")) - rule.Data = append(rule.Data, createClassicConditionQuery("C", []classicCondition{ - cond("B", "max", "gt", 42), - cond("A", "avg", "gt", 20), - })) - }), - }, - }, - }, - { - name: "multiple conditions custom refIds", - alerts: []*models.Alert{ - createAlertWithCond(t, 1, 1, 1, "alert1", nil, - []dashAlertCondition{ - createCondition("Q1", "avg", "gt", 42, 1, "5m", "now"), - createCondition("Q2", "max", "gt", 43, 2, "3m", "now"), - createCondition("Q3", "min", "lt", 20, 2, "3m", "now"), - }), - }, - expected: map[int64][]*ngModels.AlertRule{ - int64(1): { - genAlert(func(rule *ngModels.AlertRule) { - rule.Condition = "A" - rule.Data = append(rule.Data, createClassicConditionQuery("A", []classicCondition{ - cond("Q1", "avg", "gt", 42), - cond("Q2", "max", "gt", 43), - cond("Q3", "min", "lt", 20), - })) - rule.Data = append(rule.Data, createAlertQuery("Q1", "ds1-1", "5m", "now")) - rule.Data = append(rule.Data, createAlertQuery("Q2", "ds2-1", "3m", "now")) - rule.Data = append(rule.Data, createAlertQuery("Q3", "ds2-1", "3m", "now")) - }), - }, - }, - }, - { - name: "multiple conditions out of order refIds, queries should be sorted by refId and conditions should be in original order", - alerts: []*models.Alert{ - createAlertWithCond(t, 1, 1, 1, "alert1", nil, - []dashAlertCondition{ - createCondition("B", "avg", "gt", 42, 1, "5m", "now"), - createCondition("C", "max", "gt", 43, 2, "3m", "now"), - createCondition("A", "min", "lt", 20, 2, "3m", "now"), - }), - }, - expected: map[int64][]*ngModels.AlertRule{ - int64(1): { - genAlert(func(rule *ngModels.AlertRule) { - rule.Condition = "D" - rule.Data = append(rule.Data, createAlertQuery("A", "ds2-1", "3m", "now")) - rule.Data = append(rule.Data, createAlertQuery("B", "ds1-1", "5m", "now")) - rule.Data = append(rule.Data, createAlertQuery("C", "ds2-1", "3m", "now")) - rule.Data = append(rule.Data, createClassicConditionQuery("D", []classicCondition{ - cond("B", "avg", "gt", 42), - cond("C", "max", "gt", 43), - cond("A", "min", "lt", 20), - })) - }), - }, - }, - }, - { - name: "multiple conditions out of order with duplicate refIds", - alerts: []*models.Alert{ - createAlertWithCond(t, 1, 1, 1, "alert1", nil, - []dashAlertCondition{ - createCondition("C", "avg", "gt", 42, 1, "5m", "now"), - createCondition("C", "max", "gt", 43, 1, "3m", "now"), - createCondition("B", "min", "lt", 20, 2, "5m", "now"), - createCondition("B", "min", "lt", 21, 2, "3m", "now"), - }), - }, - expected: map[int64][]*ngModels.AlertRule{ - int64(1): { - genAlert(func(rule *ngModels.AlertRule) { - rule.Condition = "E" - rule.Data = append(rule.Data, createAlertQuery("A", "ds2-1", "3m", "now")) - rule.Data = append(rule.Data, createAlertQuery("B", "ds2-1", "5m", "now")) - rule.Data = append(rule.Data, createAlertQuery("C", "ds1-1", "3m", "now")) - rule.Data = append(rule.Data, createAlertQuery("D", "ds1-1", "5m", "now")) - rule.Data = append(rule.Data, createClassicConditionQuery("E", []classicCondition{ - cond("D", "avg", "gt", 42), - cond("C", "max", "gt", 43), - cond("B", "min", "lt", 20), - cond("A", "min", "lt", 21), - })) - }), - }, - }, - }, - { - name: "alerts with unknown datasource id migrates with empty datasource uid", - alerts: []*models.Alert{ - createAlertWithCond(t, 1, 1, 1, "alert1", nil, - []dashAlertCondition{createCondition("A", "max", "gt", 42, 123, "5m", "now")}), // Unknown datasource id. - }, - expected: map[int64][]*ngModels.AlertRule{ - int64(1): { - genAlert(func(rule *ngModels.AlertRule) { - rule.Data = append(rule.Data, createAlertQuery("A", "", "5m", "now")) // Empty datasource UID. - rule.Data = append(rule.Data, createClassicConditionQuery("B", []classicCondition{ - cond("A", "max", "gt", 42), - })) - }), - }, - }, - }, - { - name: "alerts with unknown dashboard do not migrate", - alerts: []*models.Alert{ - createAlertWithCond(t, 1, 22, 1, "alert1", nil, - []dashAlertCondition{ - createCondition("A", "avg", "gt", 42, 1, "5m", "now"), - }), - }, - expected: map[int64][]*ngModels.AlertRule{ - int64(1): {}, - }, - }, - { - name: "alerts with unknown org do not migrate", - alerts: []*models.Alert{ - createAlertWithCond(t, 22, 1, 1, "alert1", nil, - []dashAlertCondition{ - createCondition("A", "avg", "gt", 42, 1, "5m", "now"), - }), - }, - expected: map[int64][]*ngModels.AlertRule{ - int64(22): {}, - }, - }, - { - name: "alerts in general folder migrate to existing general alerting", - alerts: []*models.Alert{ - createAlertWithCond(t, 1, 8, 1, "alert1", nil, - []dashAlertCondition{ - createCondition("A", "avg", "gt", 42, 1, "5m", "now"), - }), - }, - expected: map[int64][]*ngModels.AlertRule{ - int64(1): { - genAlert(func(rule *ngModels.AlertRule) { - rule.NamespaceUID = "General Alerting" - rule.DashboardUID = pointer("dash-in-general-1") - rule.Data = append(rule.Data, createAlertQuery("A", "ds1-1", "5m", "now")) - rule.Data = append(rule.Data, createClassicConditionQuery("B", []classicCondition{ - cond("A", "avg", "gt", 42), - })) - }), - }, - }, - }, - { - name: "alerts in general folder migrate to newly created general alerting if one doesn't exist", - alerts: []*models.Alert{ - createAlertWithCond(t, 2, 9, 1, "alert1", nil, // Org 2 doesn't have general alerting folder. - []dashAlertCondition{ - createCondition("A", "avg", "gt", 42, 3, "5m", "now"), - }), - }, - expectedFolder: &dashboards.Dashboard{ - OrgID: 2, - Title: "General Alerting", - FolderID: 0, // nolint:staticcheck - Slug: "general-alerting", - }, - expected: map[int64][]*ngModels.AlertRule{ - int64(2): { - genAlert(func(rule *ngModels.AlertRule) { - rule.OrgID = 2 - rule.DashboardUID = pointer("dash-in-general-2") - rule.Data = append(rule.Data, createAlertQuery("A", "ds3-2", "5m", "now")) - rule.Data = append(rule.Data, createClassicConditionQuery("B", []classicCondition{ - cond("A", "avg", "gt", 42), - })) - }), - }, - }, - }, - { - name: "failed alert migration fails upgrade", - alerts: []*models.Alert{ - createAlertWithCond(t, 1, 1, 1, "alert1", nil, - []dashAlertCondition{{}}), - }, - expErrors: []string{"migrate alert 'alert1'"}, - }, - { - name: "simple query with interval, calculates intervalMs using it as min interval", - alerts: []*models.Alert{ - createAlertWithCond(t, 1, 1, 1, "alert1", nil, - []dashAlertCondition{ - withQueryModel( - createCondition("A", "max", "gt", 42, 1, "5m", "now"), - fmt.Sprintf(queryModel, "A", "1s"), - ), - }), - }, - expected: map[int64][]*ngModels.AlertRule{ - int64(1): { - genAlert(func(rule *ngModels.AlertRule) { - rule.Data = append(rule.Data, createAlertQueryWithModel("A", "ds1-1", "5m", "now", fmt.Sprintf(newQueryModel, "1s", 1000, "A"))) - rule.Data = append(rule.Data, createClassicConditionQuery("B", []classicCondition{ - cond("A", "max", "gt", 42), - })) - }), - }, - }, - }, - { - name: "simple query with interval as variable, calculates intervalMs using default as min interval", - alerts: []*models.Alert{ - createAlertWithCond(t, 1, 1, 1, "alert1", nil, - []dashAlertCondition{ - withQueryModel( - createCondition("A", "max", "gt", 42, 1, "5m", "now"), - fmt.Sprintf(queryModel, "A", "$min_interval"), - ), - }), - }, - expected: map[int64][]*ngModels.AlertRule{ - int64(1): { - genAlert(func(rule *ngModels.AlertRule) { - rule.Data = append(rule.Data, createAlertQueryWithModel("A", "ds1-1", "5m", "now", fmt.Sprintf(newQueryModel, "$min_interval", 1000, "A"))) - rule.Data = append(rule.Data, createClassicConditionQuery("B", []classicCondition{ - cond("A", "max", "gt", 42), - })) - }), - }, - }, - }, - } - for _, tt := range tc { - t.Run(tt.name, func(t *testing.T) { - defer teardown(t, x, service) - dashes := []*dashboards.Dashboard{ - createDashboard(t, 1, 1, "dash1-1", 5, nil), - createDashboard(t, 2, 1, "dash2-1", 5, nil), - createDashboard(t, 3, 2, "dash3-2", 6, nil), - createDashboard(t, 4, 2, "dash4-2", 6, nil), - createDashboard(t, 8, 1, "dash-in-general-1", 0, nil), - createDashboard(t, 9, 2, "dash-in-general-2", 0, nil), - createDashboard(t, 10, 1, "dash-with-acl-1", 5, func(d *dashboards.Dashboard) { - d.Title = "Dashboard With ACL 1" - d.HasACL = true - }), - } - folders := []*dashboards.Dashboard{ - createFolder(t, 5, 1, "folder5-1"), - createFolder(t, 6, 2, "folder6-2"), - createFolder(t, 7, 1, "General Alerting"), - } - setupLegacyAlertsTables(t, x, nil, tt.alerts, folders, dashes) - - err := service.Run(context.Background()) - if len(tt.expErrors) > 0 { - for _, expErr := range tt.expErrors { - require.ErrorContains(t, err, expErr) - } - return - } - require.NoError(t, err) - - for orgId, expected := range tt.expected { - rules := getAlertRules(t, x, orgId) - - for _, r := range rules { - // Remove generated fields. - require.NotEqual(t, r.Labels["rule_uid"], "") - delete(r.Labels, "rule_uid") - require.NotEqual(t, r.Annotations[ngModels.MigratedAlertIdAnnotation], "") - delete(r.Annotations, ngModels.MigratedAlertIdAnnotation) - - // If folder is created, we check if separately - if tt.expectedFolder != nil { - folder := getDashboard(t, x, orgId, r.NamespaceUID) - require.Equal(t, tt.expectedFolder.Title, folder.Title) - require.Equal(t, tt.expectedFolder.OrgID, folder.OrgID) - // nolint:staticcheck - require.Equal(t, tt.expectedFolder.FolderID, folder.FolderID) - } - } - - cOpt := []cmp.Option{ - cmpopts.SortSlices(func(a, b *ngModels.AlertRule) bool { - return a.ID < b.ID - }), - cmpopts.IgnoreUnexported(ngModels.AlertRule{}, ngModels.AlertQuery{}), - cmpopts.IgnoreFields(ngModels.AlertRule{}, "Updated", "UID", "ID"), - } - if tt.expectedFolder != nil { - cOpt = append(cOpt, cmpopts.IgnoreFields(ngModels.AlertRule{}, "NamespaceUID")) - } - if !cmp.Equal(expected, rules, cOpt...) { - t.Errorf("Unexpected Rule: %v", cmp.Diff(expected, rules, cOpt...)) - } - } - }) - } -} - -const ( - emailSettings = `{"addresses": "test"}` - slackSettings = `{"recipient": "test", "token": "test"}` - opsgenieSettings = `{"apiKey": "test"}` - brokenSettings = `[{"unknown": 1.5}]` -) - -var ( - now = time.Now() -) - -// createAlertNotificationWithReminder creates a legacy alert notification channel for inserting into the test database. -func createAlertNotificationWithReminder(t *testing.T, orgId int64, uid string, channelType string, settings string, defaultChannel bool, sendReminder bool, frequency time.Duration) *models.AlertNotification { - t.Helper() - settingsJson := simplejson.New() - if settings != "" { - s, err := simplejson.NewJson([]byte(settings)) - if err != nil { - t.Fatalf("Failed to unmarshal alert notification json: %v", err) - } - settingsJson = s - } - - return &models.AlertNotification{ - OrgID: orgId, - UID: uid, - Name: uid, // Same as uid to make testing easier. - Type: channelType, - DisableResolveMessage: false, - IsDefault: defaultChannel, - Settings: settingsJson, - SecureSettings: make(map[string][]byte), - Created: now, - Updated: now, - SendReminder: sendReminder, - Frequency: frequency, - } -} - -// createAlertNotification creates a legacy alert notification channel for inserting into the test database. -func createAlertNotification(t *testing.T, orgId int64, uid string, channelType string, settings string, defaultChannel bool) *models.AlertNotification { - return createAlertNotificationWithReminder(t, orgId, uid, channelType, settings, defaultChannel, false, time.Duration(0)) -} - -func withQueryModel(base dashAlertCondition, model string) dashAlertCondition { - base.Query.Model = []byte(model) - return base -} - -var queryModel = `{"datasource":{"type":"prometheus","uid":"gdev-prometheus"},"expr":"up{job=\"fake-data-gen\"}","instant":false,"refId":"%s","interval":"%s"}` - -func createCondition(refId string, reducer string, evalType string, thresh float64, datasourceId int64, from string, to string) dashAlertCondition { - return dashAlertCondition{ - Evaluator: evaluator{ - Params: []float64{thresh}, - Type: evalType, - }, - Operator: struct { - Type string `json:"type"` - }{ - Type: "and", - }, - Query: struct { - Params []string `json:"params"` - DatasourceID int64 `json:"datasourceId"` - Model json.RawMessage - }{ - Params: []string{refId, from, to}, - DatasourceID: datasourceId, - Model: []byte(fmt.Sprintf(queryModel, refId, "")), - }, - Reducer: struct { - Type string `json:"type"` - }{ - Type: reducer, - }, - } -} - -// createAlert creates a legacy alert rule for inserting into the test database. -func createAlert(t *testing.T, orgId int, dashboardId int, panelsId int, name string, notifierUids []string) *models.Alert { - return createAlertWithCond(t, orgId, dashboardId, panelsId, name, notifierUids, []dashAlertCondition{}) -} - -// createAlert creates a legacy alert rule for inserting into the test database. -func createAlertWithCond(t *testing.T, orgId int, dashboardId int, panelsId int, name string, notifierUids []string, cond []dashAlertCondition) *models.Alert { - t.Helper() - - var settings = simplejson.New() - if len(notifierUids) != 0 { - notifiers := make([]any, 0) - for _, n := range notifierUids { - notifiers = append(notifiers, notificationKey{UID: n}) - } - - settings.Set("notifications", notifiers) - } - settings.Set("conditions", cond) - - return &models.Alert{ - OrgID: int64(orgId), - DashboardID: int64(dashboardId), - PanelID: int64(panelsId), - Name: name, - Message: "message", - Frequency: int64(60), - For: 60 * time.Second, - State: models.AlertStateOK, - Settings: settings, - NewStateDate: now, - Created: now, - Updated: now, - } -} - -// createDashboard creates a folder for inserting into the test database. -func createFolder(t *testing.T, id int64, orgId int64, uid string) *dashboards.Dashboard { - f := createDashboard(t, id, orgId, uid, 0, nil) - f.IsFolder = true - return f -} - -// createDashboard creates a dashboard for inserting into the test database. -func createDashboard(t *testing.T, id int64, orgId int64, uid string, folderId int64, mut func(*dashboards.Dashboard)) *dashboards.Dashboard { - t.Helper() - d := &dashboards.Dashboard{ - ID: id, - OrgID: orgId, - UID: uid, - Created: now, - Updated: now, - Title: uid, // Not tested, needed to satisfy constraint. - FolderID: folderId, // nolint:staticcheck - Data: simplejson.New(), - Version: 1, - } - if mut != nil { - mut(d) - } - return d -} - -// createDatasource creates a datasource for inserting into the test database. -func createDatasource(t *testing.T, id int64, orgId int64, uid string) *datasources.DataSource { - t.Helper() - return &datasources.DataSource{ - ID: id, - OrgID: orgId, - UID: uid, - Created: now, - Updated: now, - Name: uid, // Not tested, needed to satisfy constraint. - } -} - -func createOrg(t *testing.T, id int64) *org.Org { - t.Helper() - return &org.Org{ - ID: id, - Version: 1, - Name: fmt.Sprintf("org_%d", id), - Created: time.Now(), - Updated: time.Now(), - } -} - -// teardown cleans the input tables between test cases. -func teardown(t *testing.T, x *xorm.Engine, service *migrationService) { - _, err := x.Exec("DELETE from org") - require.NoError(t, err) - _, err = x.Exec("DELETE from alert") - require.NoError(t, err) - _, err = x.Exec("DELETE from alert_notification") - require.NoError(t, err) - _, err = x.Exec("DELETE from dashboard") - require.NoError(t, err) - _, err = x.Exec("DELETE from data_source") - require.NoError(t, err) - err = service.migrationStore.RevertAllOrgs(context.Background()) - require.NoError(t, err) -} - -// setupLegacyAlertsTables inserts data into the legacy alerting tables that is needed for testing the -func setupLegacyAlertsTables(t *testing.T, x *xorm.Engine, legacyChannels []*models.AlertNotification, alerts []*models.Alert, folders []*dashboards.Dashboard, dashes []*dashboards.Dashboard) { - t.Helper() - - orgs := []org.Org{ - *createOrg(t, 1), - *createOrg(t, 2), - } - - // Setup folders. - if len(folders) > 0 { - _, err := x.Insert(folders) - require.NoError(t, err) - } - - // Setup dashboards. - if len(dashes) > 0 { - _, err := x.Insert(dashes) - require.NoError(t, err) - } - - // Setup data_sources. - dataSources := []datasources.DataSource{ - *createDatasource(t, 1, 1, "ds1-1"), - *createDatasource(t, 2, 1, "ds2-1"), - *createDatasource(t, 3, 2, "ds3-2"), - *createDatasource(t, 4, 2, "ds4-2"), - } - - _, errOrgs := x.Insert(orgs) - require.NoError(t, errOrgs) - - _, errDataSourcess := x.Insert(dataSources) - require.NoError(t, errDataSourcess) - - if len(legacyChannels) > 0 { - _, channelErr := x.Insert(legacyChannels) - require.NoError(t, channelErr) - } - - if len(alerts) > 0 { - _, alertErr := x.Insert(alerts) - require.NoError(t, alertErr) - } -} - -// getAlertmanagerConfig retreives the Alertmanager Config from the database for a given orgId. -func getAlertmanagerConfig(t *testing.T, x *xorm.Engine, orgId int64) *apimodels.PostableUserConfig { - amConfig := "" - _, err := x.Table("alert_configuration").Where("org_id = ?", orgId).Cols("alertmanager_configuration").Get(&amConfig) - require.NoError(t, err) - - config := apimodels.PostableUserConfig{} - err = json.Unmarshal([]byte(amConfig), &config) - require.NoError(t, err) - return &config -} - -// getAlertmanagerConfig retreives the Alertmanager Config from the database for a given orgId. -func getAlertRules(t *testing.T, x *xorm.Engine, orgId int64) []*ngModels.AlertRule { - rules := make([]*ngModels.AlertRule, 0) - err := x.Table("alert_rule").Where("org_id = ?", orgId).Find(&rules) - require.NoError(t, err) - - return rules -} - -// getDashboard retrieves a dashboard from the database for a given org, uid. -func getDashboard(t *testing.T, x *xorm.Engine, orgId int64, uid string) *dashboards.Dashboard { - dashes := make([]*dashboards.Dashboard, 0) - err := x.Table("dashboard").Where("org_id = ? AND uid = ?", orgId, uid).Find(&dashes) - require.NoError(t, err) - if len(dashes) > 1 { - t.Error("Expected only one dashboard to be returned") - } - if len(dashes) == 0 { - return nil - } - - return dashes[0] -} - -func pointer[T any](b T) *T { - return &b -} diff --git a/pkg/services/ngalert/migration/models.go b/pkg/services/ngalert/migration/models.go deleted file mode 100644 index 4bb05b2561dfd..0000000000000 --- a/pkg/services/ngalert/migration/models.go +++ /dev/null @@ -1,90 +0,0 @@ -package migration - -import ( - pb "github.com/prometheus/alertmanager/silence/silencepb" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/accesscontrol" - legacymodels "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/folder" - migmodels "github.com/grafana/grafana/pkg/services/ngalert/migration/models" - migrationStore "github.com/grafana/grafana/pkg/services/ngalert/migration/store" - "github.com/grafana/grafana/pkg/services/ngalert/store" - "github.com/grafana/grafana/pkg/services/secrets" - "github.com/grafana/grafana/pkg/setting" -) - -// OrgMigration is a helper struct for migrating alerts for a single org. It contains state, services, and caches. -type OrgMigration struct { - cfg *setting.Cfg - log log.Logger - - migrationStore migrationStore.Store - encryptionService secrets.Service - - orgID int64 - silences []*pb.MeshSilence - titleDeduplicatorForFolder func(folderUID string) *migmodels.Deduplicator - channelCache *ChannelCache - - // Migrated folder for a dashboard based on permissions. Parent Folder ID -> unique dashboard permission -> custom folder. - permissionsMap map[int64]map[permissionHash]*folder.Folder - folderCache map[int64]*folder.Folder // Folder ID -> Folder. - folderPermissionCache map[string][]accesscontrol.ResourcePermission // Folder UID -> Folder Permissions. - generalAlertingFolder *folder.Folder - - state *migmodels.OrgMigrationState -} - -// newOrgMigration creates a new OrgMigration for the given orgID. -func (ms *migrationService) newOrgMigration(orgID int64) *OrgMigration { - titlededuplicatorPerFolder := make(map[string]*migmodels.Deduplicator) - return &OrgMigration{ - cfg: ms.cfg, - log: ms.log.New("orgID", orgID), - - migrationStore: ms.migrationStore, - encryptionService: ms.encryptionService, - - orgID: orgID, - silences: make([]*pb.MeshSilence, 0), - titleDeduplicatorForFolder: func(folderUID string) *migmodels.Deduplicator { - if _, ok := titlededuplicatorPerFolder[folderUID]; !ok { - titlededuplicatorPerFolder[folderUID] = migmodels.NewDeduplicator(ms.migrationStore.CaseInsensitive(), store.AlertDefinitionMaxTitleLength) - } - return titlededuplicatorPerFolder[folderUID] - }, - channelCache: &ChannelCache{cache: make(map[any]*legacymodels.AlertNotification)}, - - permissionsMap: make(map[int64]map[permissionHash]*folder.Folder), - folderCache: make(map[int64]*folder.Folder), - folderPermissionCache: make(map[string][]accesscontrol.ResourcePermission), - - state: &migmodels.OrgMigrationState{ - OrgID: orgID, - CreatedFolders: make([]string, 0), - }, - } -} - -// ChannelCache caches channels by ID and UID. -type ChannelCache struct { - cache map[any]*legacymodels.AlertNotification -} - -func (c *ChannelCache) LoadChannels(channels []*legacymodels.AlertNotification) { - for _, channel := range channels { - c.cache[channel.ID] = channel - c.cache[channel.UID] = channel - } -} - -func (c *ChannelCache) GetChannelByID(id int64) (*legacymodels.AlertNotification, bool) { - channel, ok := c.cache[id] - return channel, ok -} - -func (c *ChannelCache) GetChannelByUID(uid string) (*legacymodels.AlertNotification, bool) { - channel, ok := c.cache[uid] - return channel, ok -} diff --git a/pkg/services/ngalert/migration/models/alertmanager.go b/pkg/services/ngalert/migration/models/alertmanager.go deleted file mode 100644 index 3d0869e8fd949..0000000000000 --- a/pkg/services/ngalert/migration/models/alertmanager.go +++ /dev/null @@ -1,79 +0,0 @@ -package models - -import ( - "github.com/prometheus/alertmanager/config" - "github.com/prometheus/alertmanager/pkg/labels" - "github.com/prometheus/common/model" - - apiModels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" - ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" -) - -// Alertmanager is a helper struct for creating migrated alertmanager configs. -type Alertmanager struct { - Config *apiModels.PostableUserConfig - legacyRoute *apiModels.Route -} - -// NewAlertmanager creates a new Alertmanager. -func NewAlertmanager() *Alertmanager { - c, r := createBaseConfig() - return &Alertmanager{ - Config: c, - legacyRoute: r, - } -} - -// AddRoute adds a route to the alertmanager config. -func (am *Alertmanager) AddRoute(route *apiModels.Route) { - am.legacyRoute.Routes = append(am.legacyRoute.Routes, route) -} - -// AddReceiver adds a receiver to the alertmanager config. -func (am *Alertmanager) AddReceiver(recv *apiModels.PostableApiReceiver) { - am.Config.AlertmanagerConfig.Receivers = append(am.Config.AlertmanagerConfig.Receivers, recv) -} - -// createBaseConfig creates an alertmanager config with the root-level route, default receiver, and nested route -// for migrated channels. -func createBaseConfig() (*apiModels.PostableUserConfig, *apiModels.Route) { - defaultRoute, nestedRoute := createDefaultRoute() - return &apiModels.PostableUserConfig{ - AlertmanagerConfig: apiModels.PostableApiAlertingConfig{ - Receivers: []*apiModels.PostableApiReceiver{ - { - Receiver: config.Receiver{ - Name: "autogen-contact-point-default", - }, - PostableGrafanaReceivers: apiModels.PostableGrafanaReceivers{ - GrafanaManagedReceivers: []*apiModels.PostableGrafanaReceiver{}, - }, - }, - }, - Config: apiModels.Config{ - Route: defaultRoute, - }, - }, - }, nestedRoute -} - -// createDefaultRoute creates a default root-level route and associated nested route that will contain all the migrated channels. -func createDefaultRoute() (*apiModels.Route, *apiModels.Route) { - nestedRoute := createNestedLegacyRoute() - return &apiModels.Route{ - Receiver: "autogen-contact-point-default", - Routes: []*apiModels.Route{nestedRoute}, - GroupByStr: []string{ngmodels.FolderTitleLabel, model.AlertNameLabel}, // To keep parity with pre-migration notifications. - RepeatInterval: nil, - }, nestedRoute -} - -// createNestedLegacyRoute creates a nested route that will contain all the migrated channels. -// This route is matched on the UseLegacyChannelsLabel and mostly exists to keep the migrated channels separate and organized. -func createNestedLegacyRoute() *apiModels.Route { - mat, _ := labels.NewMatcher(labels.MatchEqual, ngmodels.MigratedUseLegacyChannelsLabel, "true") - return &apiModels.Route{ - ObjectMatchers: apiModels.ObjectMatchers{mat}, - Continue: true, - } -} diff --git a/pkg/services/ngalert/migration/models/models.go b/pkg/services/ngalert/migration/models/models.go deleted file mode 100644 index 8764d77ee3f8a..0000000000000 --- a/pkg/services/ngalert/migration/models/models.go +++ /dev/null @@ -1,104 +0,0 @@ -package models - -import ( - "fmt" - "strings" - - "github.com/grafana/grafana/pkg/util" -) - -// MaxDeduplicationAttempts is the maximum number of attempts to try to deduplicate a string using any -// individual method, such as sequential index suffixes or uids. -const MaxDeduplicationAttempts = 10 - -// Deduplicator is a utility for deduplicating strings. It keeps track of the strings it has seen and appends a unique -// suffix to strings that have already been seen. It can optionally truncate strings before appending the suffix to -// ensure that the resulting string is not longer than maxLen. -// This implementation will first try to deduplicate via a sequential index suffix of the form " #2", " #3", etc. -// If after MaxIncrementDeduplicationAttempts attempts it still cannot find a unique string, it will generate a new -// unique uid and append that to the string. -type Deduplicator struct { - set map[string]int - caseInsensitive bool - maxLen int - uidGenerator func() string -} - -// NewDeduplicator creates a new deduplicator. -// caseInsensitive determines whether the string comparison should be case-insensitive. -// maxLen determines the maximum length of deduplicated strings. If the deduplicated string would be longer than -// maxLen, it will be truncated. -func NewDeduplicator(caseInsensitive bool, maxLen int) *Deduplicator { - return &Deduplicator{ - set: make(map[string]int), - caseInsensitive: caseInsensitive, - maxLen: maxLen, - uidGenerator: util.GenerateShortUID, - } -} - -// Deduplicate returns a unique string based on the given base string. If the base string has not already been seen by -// this deduplicator, it will be returned as-is. If the base string has already been seen, a unique suffix will be -// appended to the base string to make it unique. -func (s *Deduplicator) Deduplicate(base string) (string, error) { - if s.maxLen > 0 && len(base) > s.maxLen { - base = base[:s.maxLen] - } - cnt, ok := s.contains(base) - if !ok { - s.add(base, 0) - return base, nil - } - - // Start at 2, so we get a, a_2, a_3, etc. - for i := 2 + cnt; i < 2+cnt+MaxDeduplicationAttempts; i++ { - dedup := s.appendSuffix(base, fmt.Sprintf(" #%d", i)) - if _, ok := s.contains(dedup); !ok { - s.add(dedup, 0) - return dedup, nil - } - } - - // None of the simple suffixes worked, so we generate a new uid. We try a few times, just in case, but this should - // almost always create a unique string on the first try. - for i := 0; i < MaxDeduplicationAttempts; i++ { - dedup := s.appendSuffix(base, fmt.Sprintf("_%s", s.uidGenerator())) - if _, ok := s.contains(dedup); !ok { - s.add(dedup, 0) - return dedup, nil - } - } - - return "", fmt.Errorf("failed to deduplicate %q", base) -} - -// contains checks whether the given string has already been seen by this deduplicator. -func (s *Deduplicator) contains(u string) (int, bool) { - dedup := u - if s.caseInsensitive { - dedup = strings.ToLower(dedup) - } - if s.maxLen > 0 && len(dedup) > s.maxLen { - dedup = dedup[:s.maxLen] - } - cnt, seen := s.set[dedup] - return cnt, seen -} - -// appendSuffix appends the given suffix to the given base string. If the resulting string would be longer than maxLen, -// the base string will be truncated. -func (s *Deduplicator) appendSuffix(base, suffix string) string { - if s.maxLen > 0 && len(base)+len(suffix) > s.maxLen { - return base[:s.maxLen-len(suffix)] + suffix - } - return base + suffix -} - -// add adds the given string to the deduplicator. -func (s *Deduplicator) add(uid string, cnt int) { - dedup := uid - if s.caseInsensitive { - dedup = strings.ToLower(dedup) - } - s.set[dedup] = cnt -} diff --git a/pkg/services/ngalert/migration/models/models_test.go b/pkg/services/ngalert/migration/models/models_test.go deleted file mode 100644 index 14f9476dc84fc..0000000000000 --- a/pkg/services/ngalert/migration/models/models_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package models - -import ( - "fmt" - "strings" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/util" -) - -func TestDeduplicator(t *testing.T) { - tc := []struct { - name string - maxLen int - caseInsensitive bool - input []string - expected []string - expectedState map[string]struct{} - }{ - { - name: "when case insensitive, it deduplicates case-insensitively", - caseInsensitive: true, - input: []string{"a", "A", "B", "b", "a", "A", "b", "B"}, - expected: []string{"a", "A #2", "B", "b #2", "a #3", "A #4", "b #3", "B #4"}, - }, - { - name: "when case sensitive, it deduplicates case-sensitively", - caseInsensitive: false, - input: []string{"a", "A", "B", "b", "a", "A", "b", "B"}, - expected: []string{"a", "A", "B", "b", "a #2", "A #2", "b #2", "B #2"}, - }, - { - name: "when maxLen is 0, it does not truncate", - maxLen: 0, - input: []string{strings.Repeat("a", 1000), strings.Repeat("a", 1000)}, - expected: []string{strings.Repeat("a", 1000), strings.Repeat("a", 1000) + " #2"}, - }, - { - name: "when maxLen is > 0, it truncates - caseInsensitive", - caseInsensitive: true, - maxLen: 10, - input: []string{strings.Repeat("A", 15), strings.Repeat("a", 15), strings.Repeat("A", 15)}, - expected: []string{strings.Repeat("A", 10), strings.Repeat("a", 7) + " #2", strings.Repeat("A", 7) + " #3"}, - }, - { - name: "when maxLen is > 0, it truncates - caseSensitive", - maxLen: 10, - input: []string{strings.Repeat("A", 15), strings.Repeat("a", 15), strings.Repeat("A", 15)}, - expected: []string{strings.Repeat("A", 10), strings.Repeat("a", 10), strings.Repeat("A", 7) + " #2"}, - }, - { - name: "when truncate causes collision, it deduplicates - caseInsensitive", - caseInsensitive: true, - maxLen: 10, - input: []string{strings.Repeat("A", 15), strings.Repeat("a", 10), strings.Repeat("b", 15), strings.Repeat("B", 10)}, - expected: []string{strings.Repeat("A", 10), strings.Repeat("a", 7) + " #2", strings.Repeat("b", 10), strings.Repeat("B", 7) + " #2"}, - }, - { - name: "when truncate causes collision, it deduplicates - caseSensitive", - maxLen: 10, - input: []string{strings.Repeat("A", 15), strings.Repeat("A", 10)}, - expected: []string{strings.Repeat("A", 10), strings.Repeat("A", 7) + " #2"}, - }, - { - name: "when deduplicate causes collision, it deduplicates - caseInsensitive", - caseInsensitive: true, - maxLen: 10, - input: []string{"A", "a", "a #2", "b", "B", "B #2"}, - expected: []string{"A", "a #2", "a #2 #2", "b", "B #2", "B #2 #2"}, - }, - { - name: "when deduplicate causes collision, it deduplicates - caseSensitive", - maxLen: 10, - input: []string{"a", "a", "a #2", "b", "b", "b #2"}, - expected: []string{"a", "a #2", "a #2 #2", "b", "b #2", "b #2 #2"}, - }, - { - name: "when deduplicate causes collision, it finds next available increment - caseInsensitive", - caseInsensitive: true, - maxLen: 10, - input: []string{"a", "A #2", "a #3", "A #4", "a #5", "A #6", "a #7", "A #8", "a #9", "A #10", "a"}, - expected: []string{"a", "A #2", "a #3", "A #4", "a #5", "A #6", "a #7", "A #8", "a #9", "A #10", "a #11"}, - }, - { - name: "when deduplicate causes collision, it finds next available increment - caseSensitive", - maxLen: 10, - input: []string{"a", "a #2", "a #3", "a #4", "a #5", "a #6", "a #7", "a #8", "a #9", "a #10", "a"}, - expected: []string{"a", "a #2", "a #3", "a #4", "a #5", "a #6", "a #7", "a #8", "a #9", "a #10", "a #11"}, - }, - { - name: "when deduplicate causes collision enough times, it deduplicates with uid - caseInsensitive", - caseInsensitive: true, - maxLen: 10, - input: []string{"a", "A #2", "a #3", "A #4", "a #5", "A #6", "a #7", "A #8", "a #9", "A #10", "a #11", "A"}, - expected: []string{"a", "A #2", "a #3", "A #4", "a #5", "A #6", "a #7", "A #8", "a #9", "A #10", "a #11", "A_uid-1"}, - }, - { - name: "when deduplicate causes collision enough times, it deduplicates with uid - caseSensitive", - maxLen: 10, - input: []string{"a", "a #2", "a #3", "a #4", "a #5", "a #6", "a #7", "a #8", "a #9", "a #10", "a #11", "a"}, - expected: []string{"a", "a #2", "a #3", "a #4", "a #5", "a #6", "a #7", "a #8", "a #9", "a #10", "a #11", "a_uid-1"}, - }, - } - - for _, tt := range tc { - t.Run(tt.name, func(t *testing.T) { - inc := 0 - mockUidGenerator := func() string { - inc++ - return fmt.Sprintf("uid-%d", inc) - } - dedup := Deduplicator{ - set: make(map[string]int), - caseInsensitive: tt.caseInsensitive, - maxLen: tt.maxLen, - uidGenerator: mockUidGenerator, - } - out := make([]string, 0, len(tt.input)) - for _, in := range tt.input { - d, err := dedup.Deduplicate(in) - require.NoError(t, err) - out = append(out, d) - } - require.Equal(t, tt.expected, out) - }) - } -} - -func Test_shortUIDCaseInsensitiveConflicts(t *testing.T) { - s := Deduplicator{ - set: make(map[string]int), - caseInsensitive: true, - } - - // 10000 uids seems to be enough to cause a collision in almost every run if using util.GenerateShortUID directly. - for i := 0; i < 10000; i++ { - s.add(util.GenerateShortUID(), 0) - } - - // check if any are case-insensitive duplicates. - deduped := make(map[string]struct{}) - for k := range s.set { - deduped[strings.ToLower(k)] = struct{}{} - } - - require.Equal(t, len(s.set), len(deduped)) -} diff --git a/pkg/services/ngalert/migration/models/state.go b/pkg/services/ngalert/migration/models/state.go deleted file mode 100644 index c965443ba67bc..0000000000000 --- a/pkg/services/ngalert/migration/models/state.go +++ /dev/null @@ -1,15 +0,0 @@ -package models - -// OrgMigrationState contains information about the state of an org migration. -type OrgMigrationState struct { - OrgID int64 `json:"orgId"` - CreatedFolders []string `json:"createdFolders"` -} - -// DashboardUpgradeInfo contains information about a dashboard that was upgraded. -type DashboardUpgradeInfo struct { - DashboardUID string - DashboardName string - NewFolderUID string - NewFolderName string -} diff --git a/pkg/services/ngalert/migration/permissions.go b/pkg/services/ngalert/migration/permissions.go deleted file mode 100644 index 5013f02ac6041..0000000000000 --- a/pkg/services/ngalert/migration/permissions.go +++ /dev/null @@ -1,422 +0,0 @@ -package migration - -import ( - "context" - "crypto" - "encoding/hex" - "errors" - "fmt" - "sort" - "strings" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/auth/identity" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" - "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/folder" - migmodels "github.com/grafana/grafana/pkg/services/ngalert/migration/models" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/user" -) - -// DASHBOARD_FOLDER is the format used to generate the folder name for migrated dashboards with custom permissions. -const DASHBOARD_FOLDER = "%s Alerts - %s" - -// MaxFolderName is the maximum length of the folder name generated using DASHBOARD_FOLDER format -const MaxFolderName = 255 - -var ( - // migratorPermissions are the permissions required for the background user to migrate alerts. - migratorPermissions = []accesscontrol.Permission{ - {Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll}, - {Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeDashboardsAll}, - {Action: dashboards.ActionFoldersPermissionsRead, Scope: dashboards.ScopeFoldersAll}, - {Action: dashboards.ActionDashboardsPermissionsRead, Scope: dashboards.ScopeDashboardsAll}, - {Action: dashboards.ActionFoldersCreate}, - {Action: datasources.ActionRead, Scope: datasources.ScopeAll}, - {Action: accesscontrol.ActionOrgUsersRead, Scope: accesscontrol.ScopeUsersAll}, - {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, - } - - // generalAlertingFolderTitle is the title of the general alerting folder. This is used for dashboard alerts in the general folder. - generalAlertingFolderTitle = "General Alerting" - - // permissionMap maps the "friendly" permission name for a ResourcePermissions actions to the dashboardaccess.PermissionType. - // A sort of reverse accesscontrol Service.MapActions similar to api.dashboardPermissionMap. - permissionMap = map[string]dashboardaccess.PermissionType{ - "View": dashboardaccess.PERMISSION_VIEW, - "Edit": dashboardaccess.PERMISSION_EDIT, - "Admin": dashboardaccess.PERMISSION_ADMIN, - } -) - -// getMigrationUser returns a background user for the given orgID with permissions to execute migration-related tasks. -func getMigrationUser(orgID int64) identity.Requester { - return accesscontrol.BackgroundUser("ngalert_migration", orgID, org.RoleAdmin, migratorPermissions) -} - -func (om *OrgMigration) migratedFolder(ctx context.Context, log log.Logger, dashID int64) (*migmodels.DashboardUpgradeInfo, error) { - dash, err := om.migrationStore.GetDashboard(ctx, om.orgID, dashID) - if err != nil { - return nil, err - } - l := log.New("dashboardTitle", dash.Title, "dashboardUid", dash.UID) - - dashFolder, err := om.getFolder(ctx, dash) - if err != nil { - // nolint:staticcheck - l.Warn("Failed to find folder for dashboard", "missing_folder_id", dash.FolderID, "error", err) - } - if dashFolder != nil { - l = l.New("folderUid", dashFolder.UID, "folderName", dashFolder.Title) - } - - migratedFolder, err := om.getOrCreateMigratedFolder(ctx, l, dash, dashFolder) - if err != nil { - return nil, err - } - - return &migmodels.DashboardUpgradeInfo{ - DashboardUID: dash.UID, - DashboardName: dash.Title, - NewFolderUID: migratedFolder.UID, - NewFolderName: migratedFolder.Title, - }, nil -} - -// getOrCreateMigratedFolder returns the folder that alerts in a given dashboard should migrate to. -// If the dashboard has no custom permissions, this should be the same folder as dash.FolderID. -// If the dashboard has custom permissions that affect access, this should be a new folder with migrated permissions relating to both the dashboard and parent folder. -// Any dashboard that has greater read/write permissions for an orgRole/team/user compared to its folder will necessitate creating a new folder with the same permissions as the dashboard. -func (om *OrgMigration) getOrCreateMigratedFolder(ctx context.Context, l log.Logger, dash *dashboards.Dashboard, parentFolder *folder.Folder) (*folder.Folder, error) { - // If parentFolder does not exist then the dashboard is an orphan. We migrate the alert to the general alerting folder. - // The general alerting folder is only accessible to admins. - if parentFolder == nil { - l.Warn("Migrating alert to the general alerting folder: original folder not found") - f, err := om.getOrCreateGeneralAlertingFolder(ctx, om.orgID) - if err != nil { - return nil, fmt.Errorf("general alerting folder: %w", err) - } - return f, nil - } - - // Check if the dashboard has custom permissions. If it does, we need to create a new folder for it. - // This folder will be cached for re-use for each dashboard in the folder with the same permissions. - // nolint:staticcheck - permissionsToFolder, ok := om.permissionsMap[parentFolder.ID] - if !ok { - permissionsToFolder = make(map[permissionHash]*folder.Folder) - // nolint:staticcheck - om.permissionsMap[parentFolder.ID] = permissionsToFolder - - folderPerms, err := om.getFolderPermissions(ctx, parentFolder) - if err != nil { - return nil, fmt.Errorf("folder permissions: %w", err) - } - newFolderPerms, _ := om.convertResourcePerms(folderPerms) - - // We assign the folder to the cache so that any dashboards with identical equivalent permissions will use the parent folder instead of creating a new one. - folderPermsHash, err := createHash(newFolderPerms) - if err != nil { - return nil, fmt.Errorf("hash of folder permissions: %w", err) - } - permissionsToFolder[folderPermsHash] = parentFolder - } - - // Now we compute the hash of the dashboard permissions and check if we have a folder for it. If not, we create a new one. - perms, err := om.getDashboardPermissions(ctx, dash) - if err != nil { - return nil, fmt.Errorf("dashboard permissions: %w", err) - } - newPerms, unusedPerms := om.convertResourcePerms(perms) - hash, err := createHash(newPerms) - if err != nil { - return nil, fmt.Errorf("hash of dashboard permissions: %w", err) - } - - customFolder, ok := permissionsToFolder[hash] - if !ok { - folderName := generateAlertFolderName(parentFolder, hash) - l.Info("Dashboard has custom permissions, create a new folder for alerts.", "newFolder", folderName) - f, err := om.createFolder(ctx, om.orgID, folderName, newPerms) - if err != nil { - return nil, err - } - - // If the role is not managed or basic we don't attempt to migrate its permissions. This is because - // the logic to migrate would be complex, error-prone, and even if done correctly would have significant - // drawbacks in the case of custom provisioned roles. Instead, we log if the role has dashboard permissions that could - // potentially override the folder permissions. These overrides would always be to increase permissions not decrease them, - // so the risk of giving users access to alerts they shouldn't have access to is mitigated. - overrides := potentialOverrides(unusedPerms, newPerms) - if len(overrides) > 0 { - l.Warn("Some roles were not migrated but had the potential to allow additional access. Please verify the permissions of the new folder.", "roles", overrides, "newFolder", folderName) - } - - permissionsToFolder[hash] = f - return f, nil - } - - return customFolder, nil -} - -// generateAlertFolderName generates a folder name for alerts that belong to a dashboard with custom permissions. -// Formats the string according to DASHBOARD_FOLDER format. -// If the resulting string's length exceeds migration.MaxFolderName, the dashboard title is stripped to be at the maximum length. -func generateAlertFolderName(f *folder.Folder, hash permissionHash) string { - maxLen := MaxFolderName - len(fmt.Sprintf(DASHBOARD_FOLDER, "", hash)) - title := f.Title - if len(title) > maxLen { - title = title[:maxLen] - } - return fmt.Sprintf(DASHBOARD_FOLDER, title, hash) // Include hash in the name to avoid collision. -} - -// isBasic returns true if the given roleName is a basic role. -func isBasic(roleName string) bool { - return strings.HasPrefix(roleName, accesscontrol.BasicRolePrefix) -} - -// convertResourcePerms converts the given resource permissions (from a dashboard or folder) to a set of unique, sorted SetResourcePermissionCommands. -// This is done by iterating over the managed, basic, and inherited resource permissions and adding the highest permission for each orgrole/user/team. -// -// # Details -// -// There are two role types that we consider: -// - managed (ex. managed:users:1:permissions, managed:builtins:editor:permissions, managed:teams:1:permissions): -// These are the only roles that exist in OSS. For each of these roles, we add the actions of the highest -// dashboardaccess.PermissionType between the folder and the dashboard. Permissions from the folder are inherited. -// The added actions should have scope=folder:uid:xxxxxx, where xxxxxx is the new folder uid. -// - basic (ex. basic:admin, basic:editor): -// These are roles used in enterprise. Every user should have one of these roles. They should be considered -// equivalent to managed:builtins. The highest dashboardaccess.PermissionType between the two should be used. -// -// There are two role types that we do not consider: -// - fixed: (ex. fixed:dashboards:reader, fixed:dashboards:writer): -// These are roles with fixed actions/scopes. They should not be given any extra actions/scopes because they -// can be overwritten. Because of this, to ensure that all users with this role have the correct access to the -// new folder we would need to find all users with this role and add a permission for -// action folders:read/write -> folder:uid:xxxxxx to their managed:users:X:permissions. -// This will eventually fall out of sync. -// - custom: Custom roles created via API or provisioning. -// Similar to fixed roles, we can't give them any extra actions/scopes because they can be overwritten. -// -// For now, we choose the simpler approach of handling managed and basic roles. Fixed and custom roles will not -// be taken into account, but we will log a warning if they had the potential to override the folder permissions. -func (om *OrgMigration) convertResourcePerms(rperms []accesscontrol.ResourcePermission) ([]accesscontrol.SetResourcePermissionCommand, []accesscontrol.ResourcePermission) { - keep := make(map[accesscontrol.SetResourcePermissionCommand]dashboardaccess.PermissionType) - unusedPerms := make([]accesscontrol.ResourcePermission, 0) - for _, p := range rperms { - if p.IsManaged || p.IsInherited || isBasic(p.RoleName) { - if permission := om.migrationStore.MapActions(p); permission != "" { - sp := accesscontrol.SetResourcePermissionCommand{ - UserID: p.UserId, - TeamID: p.TeamId, - BuiltinRole: p.BuiltInRole, - } - // We could have redundant perms, ex: if one is inherited from the parent folder, or we have basic roles from enterprise. - // We use the highest permission available. - pType := permissionMap[permission] - current, ok := keep[sp] - if !ok || pType > current { - keep[sp] = pType - } - } - } else { - // Keep track of unused perms, so we can later log a warning if they had the potential to override the folder permissions. - unusedPerms = append(unusedPerms, p) - } - } - - permissions := make([]accesscontrol.SetResourcePermissionCommand, 0, len(keep)) - for p, pType := range keep { - p.Permission = pType.String() - permissions = append(permissions, p) - } - - // Stable sort since we will be creating a hash of this to compare dashboard perms to folder perms. - sort.SliceStable(permissions, func(i, j int) bool { - if permissions[i].BuiltinRole != permissions[j].BuiltinRole { - return permissions[i].BuiltinRole < permissions[j].BuiltinRole - } - if permissions[i].UserID != permissions[j].UserID { - return permissions[i].UserID < permissions[j].UserID - } - if permissions[i].TeamID != permissions[j].TeamID { - return permissions[i].TeamID < permissions[j].TeamID - } - return permissions[i].Permission < permissions[j].Permission - }) - - return permissions, unusedPerms -} - -// potentialOverrides returns a map of roles from unusedOldPerms that have dashboard permissions that could potentially -// override the given folder permissions in newPerms. These overrides are always to increase permissions not decrease them. -func potentialOverrides(unusedOldPerms []accesscontrol.ResourcePermission, newPerms []accesscontrol.SetResourcePermissionCommand) map[string]dashboardaccess.PermissionType { - var lowestPermission dashboardaccess.PermissionType - for _, p := range newPerms { - if p.BuiltinRole == string(org.RoleEditor) || p.BuiltinRole == string(org.RoleViewer) { - pType := permissionMap[p.Permission] - if pType < lowestPermission { - lowestPermission = pType - } - } - } - - nonManagedPermissionTypes := make(map[string]dashboardaccess.PermissionType) - for _, p := range unusedOldPerms { - existing, ok := nonManagedPermissionTypes[p.RoleName] - if ok && existing == dashboardaccess.PERMISSION_EDIT { - // We've already handled the highest permission we care about, no need to check this role anymore. - continue - } - - if p.Contains([]string{dashboards.ActionDashboardsWrite}) { - existing = dashboardaccess.PERMISSION_EDIT - } else if p.Contains([]string{dashboards.ActionDashboardsRead}) { - existing = dashboardaccess.PERMISSION_VIEW - } - - if existing > lowestPermission && existing > nonManagedPermissionTypes[p.RoleName] { - nonManagedPermissionTypes[p.RoleName] = existing - } - } - - return nonManagedPermissionTypes -} - -type permissionHash string - -// createHash returns a hash of the given permissions. -func createHash(setResourcePermissionCommands []accesscontrol.SetResourcePermissionCommand) (permissionHash, error) { - // Speed is not particularly important here. - digester := crypto.MD5.New() - var separator = []byte{255} - for _, perm := range setResourcePermissionCommands { - _, err := fmt.Fprint(digester, separator) - if err != nil { - return "", err - } - _, err = fmt.Fprint(digester, perm) - if err != nil { - return "", err - } - } - return permissionHash(hex.EncodeToString(digester.Sum(nil))), nil -} - -// getFolderPermissions Get permissions for folder. -func (om *OrgMigration) getFolderPermissions(ctx context.Context, f *folder.Folder) ([]accesscontrol.ResourcePermission, error) { - if p, ok := om.folderPermissionCache[f.UID]; ok { - return p, nil - } - p, err := om.migrationStore.GetFolderPermissions(ctx, getMigrationUser(f.OrgID), f.UID) - if err != nil { - return nil, err - } - om.folderPermissionCache[f.UID] = p - return p, nil -} - -// getDashboardPermissions Get permissions for dashboard. -func (om *OrgMigration) getDashboardPermissions(ctx context.Context, d *dashboards.Dashboard) ([]accesscontrol.ResourcePermission, error) { - p, err := om.migrationStore.GetDashboardPermissions(ctx, getMigrationUser(om.orgID), d.UID) - if err != nil { - return nil, err - } - return p, nil -} - -// getFolder returns the parent folder for the given dashboard. If the dashboard is in the general folder, it returns the general alerting folder. -func (om *OrgMigration) getFolder(ctx context.Context, dash *dashboards.Dashboard) (*folder.Folder, error) { - // nolint:staticcheck - if f, ok := om.folderCache[dash.FolderID]; ok { - return f, nil - } - - // nolint:staticcheck - if dash.FolderID <= 0 { - // Don't use general folder since it has no uid, instead we use a new "General Alerting" folder. - migratedFolder, err := om.getOrCreateGeneralAlertingFolder(ctx, om.orgID) - if err != nil { - return nil, fmt.Errorf("get or create general folder: %w", err) - } - return migratedFolder, err - } - - // nolint:staticcheck - f, err := om.migrationStore.GetFolder(ctx, &folder.GetFolderQuery{ID: &dash.FolderID, OrgID: om.orgID, SignedInUser: getMigrationUser(om.orgID)}) - if err != nil { - if errors.Is(err, dashboards.ErrFolderNotFound) { - // nolint:staticcheck - return nil, fmt.Errorf("folder with id %v not found", dash.FolderID) - } - // nolint:staticcheck - return nil, fmt.Errorf("get folder %d: %w", dash.FolderID, err) - } - // nolint:staticcheck - om.folderCache[dash.FolderID] = f - return f, nil -} - -// getOrCreateGeneralAlertingFolder returns the general alerting folder under the specific organisation -// If the general alerting folder does not exist it creates it. -func (om *OrgMigration) getOrCreateGeneralAlertingFolder(ctx context.Context, orgID int64) (*folder.Folder, error) { - if om.generalAlertingFolder != nil { - return om.generalAlertingFolder, nil - } - f, err := om.migrationStore.GetFolder(ctx, &folder.GetFolderQuery{OrgID: orgID, Title: &generalAlertingFolderTitle, SignedInUser: getMigrationUser(orgID)}) - if err != nil { - if errors.Is(err, dashboards.ErrFolderNotFound) { - // create general alerting folder without permissions to mimic the general folder. - f, err := om.createFolder(ctx, orgID, generalAlertingFolderTitle, nil) - if err != nil { - return nil, fmt.Errorf("create general alerting folder: %w", err) - } - om.generalAlertingFolder = f - return f, err - } - return nil, fmt.Errorf("get folder '%s': %w", generalAlertingFolderTitle, err) - } - om.generalAlertingFolder = f - return f, nil -} - -// createFolder creates a new folder with given permissions. -func (om *OrgMigration) createFolder(ctx context.Context, orgID int64, title string, newPerms []accesscontrol.SetResourcePermissionCommand) (*folder.Folder, error) { - f, err := om.migrationStore.CreateFolder(ctx, &folder.CreateFolderCommand{ - OrgID: orgID, - Title: title, - SignedInUser: getMigrationUser(orgID).(*user.SignedInUser), - }) - if err != nil { - if errors.Is(err, dashboards.ErrFolderSameNameExists) { - // If the folder already exists, we return the existing folder. - // This isn't perfect since permissions might have been manually modified, - // but the only folders we should be creating here are ones with permission - // hash suffix or general alerting. Neither of which is likely to spuriously - // conflict with an existing folder. - om.log.Warn("Folder already exists, using existing folder", "title", title) - f, err := om.migrationStore.GetFolder(ctx, &folder.GetFolderQuery{OrgID: orgID, Title: &title, SignedInUser: getMigrationUser(orgID)}) - if err != nil { - return nil, err - } - return f, nil - } - return nil, err - } - - if len(newPerms) > 0 { - _, err = om.migrationStore.SetFolderPermissions(ctx, orgID, f.UID, newPerms...) - if err != nil { - return nil, fmt.Errorf("set permissions: %w", err) - } - } - - om.state.CreatedFolders = append(om.state.CreatedFolders, f.UID) - - return f, nil -} diff --git a/pkg/services/ngalert/migration/permissions_test.go b/pkg/services/ngalert/migration/permissions_test.go deleted file mode 100644 index 078570bc7a98e..0000000000000 --- a/pkg/services/ngalert/migration/permissions_test.go +++ /dev/null @@ -1,821 +0,0 @@ -package migration - -import ( - "context" - "encoding/json" - "fmt" - "sort" - "strings" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" - ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/team" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" -) - -// TestDashAlertPermissionMigration tests the execution of the migration specifically for dashboards with custom permissions. -// -//nolint:gocyclo -func TestDashAlertPermissionMigration(t *testing.T) { - genLegacyAlert := func(name string, dashboardId int64, mutators ...func(*models.Alert)) *models.Alert { - a := createAlert(t, 1, int(dashboardId), 1, name, nil) - if len(mutators) > 0 { - for _, mutator := range mutators { - mutator(a) - } - } - return a - } - - genAlert := func(title string, namespaceUID string, dashboardUID string, mutators ...func(*ngModels.AlertRule)) *ngModels.AlertRule { - dashTitle := "Dashboard Title " + dashboardUID - a := &ngModels.AlertRule{ - ID: 1, - OrgID: 1, - Title: title, - Condition: "A", - Data: []ngModels.AlertQuery{ - { - RefID: "A", - DatasourceUID: "__expr__", - Model: json.RawMessage(`{"conditions":[],"intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"classic_conditions"}`), - }, - }, - NamespaceUID: namespaceUID, - DashboardUID: &dashboardUID, - RuleGroup: fmt.Sprintf("%s - 1m", dashTitle), - IntervalSeconds: 60, - Version: 1, - PanelID: pointer(int64(1)), - RuleGroupIndex: 1, - NoDataState: ngModels.NoData, - ExecErrState: ngModels.AlertingErrState, - For: 60 * time.Second, - Annotations: map[string]string{ - "message": "message", - "__dashboardUid__": dashboardUID, - "__panelId__": "1", - }, - Labels: map[string]string{ngModels.MigratedUseLegacyChannelsLabel: "true"}, - IsPaused: false, - } - if len(mutators) > 0 { - for _, mutator := range mutators { - mutator(a) - } - } - return a - } - - withPanelId := func(id int64) func(*ngModels.AlertRule) { - return func(a *ngModels.AlertRule) { - a.PanelID = pointer(id) - a.Annotations["__panelId__"] = fmt.Sprintf("%d", id) - } - } - - genFolder := func(t *testing.T, id int64, uid string, mutators ...func(f *dashboards.Dashboard)) *dashboards.Dashboard { - d := createFolder(t, id, 1, uid) - d.Title = "Original Folder " + uid - if len(mutators) > 0 { - for _, mutator := range mutators { - mutator(d) - } - } - return d - } - - genCreatedFolder := func(t *testing.T, title string, mutators ...func(f *dashboards.Dashboard)) *dashboards.Dashboard { - d := createFolder(t, 1, 1, "") // Leave generated UID blank, so we don't compare. - d.Title = title - d.CreatedBy = -1 - d.UpdatedBy = -1 - if len(mutators) > 0 { - for _, mutator := range mutators { - mutator(d) - } - } - return d - } - - genDashboard := func(t *testing.T, id int64, uid string, folderId int64, mutators ...func(f *dashboards.Dashboard)) *dashboards.Dashboard { - d := createDashboard(t, id, 1, uid, folderId, nil) - d.Title = "Dashboard Title " + uid - if len(mutators) > 0 { - for _, mutator := range mutators { - mutator(d) - } - } - return d - } - - genPerms := func(perms ...accesscontrol.SetResourcePermissionCommand) []accesscontrol.SetResourcePermissionCommand { - return perms - } - - type expectedAlertMigration struct { - Alert *ngModels.AlertRule - Folder *dashboards.Dashboard - Perms []accesscontrol.SetResourcePermissionCommand - } - - type testcase struct { - name string - enterprise bool - folders []*dashboards.Dashboard - folderPerms map[string][]accesscontrol.SetResourcePermissionCommand // UID -> Perms - dashboards []*dashboards.Dashboard - dashboardPerms map[string][]accesscontrol.SetResourcePermissionCommand // UID -> Perms - alerts []*models.Alert - roles map[accesscontrol.Role][]accesscontrol.Permission - - expected []expectedAlertMigration - } - - // Used to perform the same tests for each of builtins, users, and teams. - splitTestcase := func(raw testcase) []testcase { - permTypes := make(map[string]func(accesscontrol.SetResourcePermissionCommand) accesscontrol.SetResourcePermissionCommand, 3) - permTypes["builtins"] = func(p accesscontrol.SetResourcePermissionCommand) accesscontrol.SetResourcePermissionCommand { - return p - } - mapping := map[string]int64{ - string(org.RoleEditor): 1, - string(org.RoleViewer): 2, - } - permTypes["users"] = func(p accesscontrol.SetResourcePermissionCommand) accesscontrol.SetResourcePermissionCommand { - id, ok := mapping[p.BuiltinRole] - if !ok { - return p - } - p.UserID = id - p.BuiltinRole = "" - return p - } - permTypes["teams"] = func(p accesscontrol.SetResourcePermissionCommand) accesscontrol.SetResourcePermissionCommand { - id, ok := mapping[p.BuiltinRole] - if !ok { - return p - } - p.TeamID = id - p.BuiltinRole = "" - return p - } - - applyTransform := func(tt testcase, pfunc func(p accesscontrol.SetResourcePermissionCommand) accesscontrol.SetResourcePermissionCommand) testcase { - folderPerms := make(map[string][]accesscontrol.SetResourcePermissionCommand, len(tt.folderPerms)) - for _, f := range tt.folders { - perms := make([]accesscontrol.SetResourcePermissionCommand, 0, len(tt.folderPerms[f.UID])) - for _, p := range tt.folderPerms[f.UID] { - perms = append(perms, pfunc(p)) - } - folderPerms[f.UID] = perms - } - tt.folderPerms = folderPerms - - dashboardPerms := make(map[string][]accesscontrol.SetResourcePermissionCommand, len(tt.dashboardPerms)) - for _, d := range tt.dashboards { - perms := make([]accesscontrol.SetResourcePermissionCommand, 0, len(tt.dashboardPerms[d.UID])) - for _, p := range tt.dashboardPerms[d.UID] { - perms = append(perms, pfunc(p)) - } - dashboardPerms[d.UID] = perms - } - tt.dashboardPerms = dashboardPerms - - expected := make([]expectedAlertMigration, 0, len(tt.expected)) - for _, ex := range tt.expected { - permissions := make([]accesscontrol.SetResourcePermissionCommand, 0, len(ex.Perms)) - for _, p := range ex.Perms { - permissions = append(permissions, pfunc(p)) - } - ex.Perms = permissions - - sort.SliceStable(permissions, func(i, j int) bool { - if permissions[i].BuiltinRole != permissions[j].BuiltinRole { - return permissions[i].BuiltinRole < permissions[j].BuiltinRole - } - if permissions[i].UserID != permissions[j].UserID { - return permissions[i].UserID < permissions[j].UserID - } - if permissions[i].TeamID != permissions[j].TeamID { - return permissions[i].TeamID < permissions[j].TeamID - } - return permissions[i].Permission < permissions[j].Permission - }) - - f := *ex.Folder - if strings.Contains(f.Title, "%s") { - hash, err := createHash(permissions) - require.NoError(t, err) - f.Title = fmt.Sprintf(f.Title, hash) - } - - expected = append(expected, expectedAlertMigration{ - Alert: ex.Alert, - Folder: &f, - Perms: permissions, - }) - } - tt.expected = expected - - return tt - } - - cases := make([]testcase, 0, 3) - for k, pfunc := range permTypes { - tt := applyTransform(raw, pfunc) - tt.name = k - cases = append(cases, tt) - } - return cases - } - - basicFolder := genFolder(t, 1, "f_1") - basicDashboard := genDashboard(t, 2, "d_1", basicFolder.ID) - defaultPerms := genPerms( - accesscontrol.SetResourcePermissionCommand{BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, - accesscontrol.SetResourcePermissionCommand{BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()}, - ) - - basicAlert1 := genLegacyAlert("alert1", basicDashboard.ID, func(a *models.Alert) { a.PanelID = 1 }) - basicAlert2 := genLegacyAlert("alert2", basicDashboard.ID, func(a *models.Alert) { a.PanelID = 2 }) - - basicPerms := func() map[accesscontrol.Role][]accesscontrol.Permission { - basic := make(map[accesscontrol.Role][]accesscontrol.Permission) - var permissions []accesscontrol.Permission - ts := time.Now() - for _, action := range append(ossaccesscontrol.DashboardAdminActions, ossaccesscontrol.FolderAdminActions...) { - if isDashboardAction := strings.HasPrefix(action, "dashboards"); isDashboardAction { - permissions = append(permissions, accesscontrol.Permission{ - Action: action, - Scope: dashboards.ScopeDashboardsAll, - Created: ts, - Updated: ts, - }) - } - permissions = append(permissions, accesscontrol.Permission{ - Action: action, - Scope: dashboards.ScopeFoldersAll, - Created: ts, - Updated: ts, - }) - } - basic[accesscontrol.Role{Name: accesscontrol.BasicRolePrefix + "admin"}] = permissions - return basic - } - - tc := []testcase{ - { - name: "alerts in dashboard and folder with default permissions migrate to same folder", - folders: []*dashboards.Dashboard{basicFolder}, - folderPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicFolder.UID: defaultPerms}, - dashboards: []*dashboards.Dashboard{basicDashboard}, - dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicDashboard.UID: defaultPerms}, - alerts: []*models.Alert{basicAlert1, basicAlert2}, - expected: []expectedAlertMigration{ - { - Alert: genAlert(basicAlert1.Name, basicFolder.UID, basicDashboard.UID, withPanelId(basicAlert1.PanelID)), - Folder: basicFolder, - Perms: defaultPerms, - }, - { - Alert: genAlert(basicAlert2.Name, basicFolder.UID, basicDashboard.UID, withPanelId(basicAlert2.PanelID)), - Folder: basicFolder, - Perms: defaultPerms, - }, - }, - }, - { - name: "dashboard override cannot lessen folder permissions", - folders: []*dashboards.Dashboard{basicFolder}, - folderPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicFolder.UID: defaultPerms}, - dashboards: []*dashboards.Dashboard{basicDashboard}, - dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ - basicDashboard.UID: { - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_VIEW.String()}, // Change. - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()}, - }, - }, - alerts: []*models.Alert{basicAlert1}, - expected: []expectedAlertMigration{ - { - Alert: genAlert(basicAlert1.Name, basicFolder.UID, basicDashboard.UID), - Folder: basicFolder, - Perms: []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, // Inherits from Folder. - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()}, - }, - }, - }, - }, - { - name: "dashboard with various permission overrides should create new folder", - folders: []*dashboards.Dashboard{basicFolder}, - folderPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicFolder.UID: defaultPerms}, - dashboards: []*dashboards.Dashboard{ - genDashboard(t, 2, "d_1", basicFolder.ID), - genDashboard(t, 3, "d_2", basicFolder.ID), - genDashboard(t, 4, "d_3", basicFolder.ID), - genDashboard(t, 5, "d_4", basicFolder.ID), - genDashboard(t, 6, "d_5", basicFolder.ID), - }, - dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ - "d_1": { - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_EDIT.String()}, // Change. - }, - "d_2": { - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, // Change. - }, - "d_3": { - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, // Change. - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_EDIT.String()}, // Change. - }, - "d_4": { - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, // Change. - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()}, - }, - "d_5": { - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, // Change. - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, // Change. - }, - }, - alerts: []*models.Alert{genLegacyAlert("alert1", 2), genLegacyAlert("alert2", 3), genLegacyAlert("alert3", 4), genLegacyAlert("alert4", 5), genLegacyAlert("alert5", 6)}, - expected: []expectedAlertMigration{ - { - Alert: genAlert("alert1", "", "d_1"), - Folder: genCreatedFolder(t, "Original Folder f_1 Alerts - %s"), - Perms: []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_EDIT.String()}, // Change. - }, - }, - { - Alert: genAlert("alert2", "", "d_2"), - Folder: genCreatedFolder(t, "Original Folder f_1 Alerts - %s"), - Perms: []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, // Change. - }, - }, - { - Alert: genAlert("alert3", "", "d_3"), - Folder: genCreatedFolder(t, "Original Folder f_1 Alerts - %s"), - Perms: []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, // Change. - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_EDIT.String()}, // Change. - }, - }, - { - Alert: genAlert("alert4", "", "d_4"), - Folder: genCreatedFolder(t, "Original Folder f_1 Alerts - %s"), - Perms: []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, // Change. - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()}, - }, - }, - { - Alert: genAlert("alert5", "", "d_5"), - Folder: genCreatedFolder(t, "Original Folder f_1 Alerts - %s"), - Perms: []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, // Change. - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, // Change. - }, - }, - }, - }, - { - name: "missing dashboard permission is inherited from folder", - folders: []*dashboards.Dashboard{genFolder(t, 1, "f_1"), genFolder(t, 2, "f_2")}, - folderPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ - "f_1": { - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, - }, - "f_2": { - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_VIEW.String()}, - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()}, - }, - }, - dashboards: []*dashboards.Dashboard{ - genDashboard(t, 3, "d_1", 1), - genDashboard(t, 4, "d_2", 1), - genDashboard(t, 5, "d_3", 2), - genDashboard(t, 6, "d_4", 2), - }, - dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ - "d_1": { - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()}, - }, - "d_2": { - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, - }, - "d_3": { - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()}, - }, - "d_4": { - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, - }, - }, - alerts: []*models.Alert{genLegacyAlert("alert1", 3), genLegacyAlert("alert2", 4), genLegacyAlert("alert3", 5), genLegacyAlert("alert4", 6)}, - expected: []expectedAlertMigration{ - { - Alert: genAlert("alert1", "f_1", "d_1"), - Folder: genFolder(t, 1, "f_1"), // Original folder since the perms didn't change. - Perms: []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, // Inherits from Folder. - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, // Overrides from Folder. - }, - }, - { - Alert: genAlert("alert2", "f_1", "d_2"), - Folder: genFolder(t, 1, "f_1"), // Original folder since the perms didn't change. - Perms: []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, // Overrides from Folder. - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, // Inherits from Folder. - }, - }, - { - Alert: genAlert("alert3", "f_2", "d_3"), - Folder: genFolder(t, 2, "f_2"), // Original folder since the perms didn't change. - Perms: []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_VIEW.String()}, // Inherits from Folder. - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()}, - }, - }, - { - Alert: genAlert("alert4", "", "d_4"), - Folder: genCreatedFolder(t, "Original Folder f_2 Alerts - %s"), - Perms: []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()}, // Inherits from Folder. - }, - }, - }, - }, - { - name: "missing dashboard and folder view permission is still missing", - folders: []*dashboards.Dashboard{basicFolder}, - folderPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ - basicFolder.UID: { - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, - }, - }, - dashboards: []*dashboards.Dashboard{basicDashboard}, - dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ - basicDashboard.UID: { - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_VIEW.String()}, - }, - }, - alerts: []*models.Alert{basicAlert1}, - expected: []expectedAlertMigration{ - { - Alert: genAlert(basicAlert1.Name, basicFolder.UID, basicDashboard.UID), - Folder: basicFolder, - Perms: []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, - }, - }, - }, - }, - - // General folder. - { - name: "dashboard in general folder with default permissions migrates to General Alerting subfolder for permission", - dashboards: []*dashboards.Dashboard{genDashboard(t, 1, "d_1", 0)}, // Dashboard in general folder. - dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ - "d_1": defaultPerms, - }, - alerts: []*models.Alert{genLegacyAlert("alert1", 1)}, - expected: []expectedAlertMigration{ - { - Alert: genAlert("alert1", "f_1", "d_1"), - Folder: genCreatedFolder(t, "General Alerting Alerts - %s"), - Perms: []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, // From Dashboard. - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()}, // From Dashboard. - }, - }, - }, - }, - { - name: "dashboard in general folder with some perms migrates to General Alerting subfolder with correct permissions", - dashboards: []*dashboards.Dashboard{genDashboard(t, 1, "d_1", 0)}, // Dashboard in general folder. - dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ - "d_1": { // Missing viewer. - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, - }, - }, - alerts: []*models.Alert{genLegacyAlert("alert1", 1)}, - expected: []expectedAlertMigration{ - { - Alert: genAlert("alert1", "f_1", "d_1"), - Folder: genCreatedFolder(t, "General Alerting Alerts - %s"), - Perms: []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, // From Dashboard. - }, - }, - }, - }, - { - name: "dashboard in general folder with empty perms migrates to General Alerting", - dashboards: []*dashboards.Dashboard{genDashboard(t, 1, "d_1", 0)}, // Dashboard in general folder. - alerts: []*models.Alert{genLegacyAlert("alert1", 1)}, - expected: []expectedAlertMigration{ - { - Alert: genAlert("alert1", "f_1", "d_1"), - Folder: genCreatedFolder(t, "General Alerting"), - Perms: []accesscontrol.SetResourcePermissionCommand{}, - }, - }, - }, - - // The following tests handled extra requirements of enterprise RBAC in that they include basic, fixed, and custom roles. - { - name: "should handle basic roles the same as managed builtin roles", - enterprise: true, - roles: basicPerms(), - folders: []*dashboards.Dashboard{basicFolder}, - folderPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicFolder.UID: defaultPerms}, - dashboards: []*dashboards.Dashboard{ - genDashboard(t, 2, "d_1", basicFolder.ID), - }, - dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{ - "d_1": { - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_EDIT.String()}, // Change. - }, - }, - alerts: []*models.Alert{genLegacyAlert("alert1", 2)}, - expected: []expectedAlertMigration{ - { - Alert: genAlert("alert1", "", "d_1"), - Folder: genCreatedFolder(t, "Original Folder f_1 Alerts - %s"), - Perms: []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleAdmin), Permission: dashboardaccess.PERMISSION_ADMIN.String()}, // From basic:admin. - {BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()}, - {BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_EDIT.String()}, // Change. - }, - }, - }, - }, - { - name: "should ignore fixed roles even if they would affect access", - enterprise: true, - roles: map[accesscontrol.Role][]accesscontrol.Permission{ - {Name: "fixed:dashboards:writer"}: { - {Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeDashboardsAll}, - {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeDashboardsAll}, - {Action: dashboards.ActionDashboardsDelete, Scope: dashboards.ScopeDashboardsAll}, - {Action: dashboards.ActionDashboardsCreate, Scope: dashboards.ScopeFoldersAll}, - {Action: dashboards.ActionDashboardsPermissionsRead, Scope: dashboards.ScopeDashboardsAll}, - {Action: dashboards.ActionDashboardsPermissionsWrite, Scope: dashboards.ScopeDashboardsAll}, - }, - }, - folders: []*dashboards.Dashboard{basicFolder}, - folderPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicFolder.UID: defaultPerms}, - dashboards: []*dashboards.Dashboard{basicDashboard}, - dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicDashboard.UID: defaultPerms}, - alerts: []*models.Alert{basicAlert1}, - expected: []expectedAlertMigration{ // Expect no new folder. - { - Alert: genAlert(basicAlert1.Name, basicFolder.UID, basicDashboard.UID), - Folder: basicFolder, - Perms: defaultPerms, - }, - }, - }, - { - name: "should ignore custom roles even if they would affect access", - enterprise: true, - roles: map[accesscontrol.Role][]accesscontrol.Permission{ - {Name: "custom role"}: { - {Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeDashboardsAll}, - {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeDashboardsAll}, - {Action: dashboards.ActionDashboardsDelete, Scope: dashboards.ScopeDashboardsAll}, - {Action: dashboards.ActionDashboardsCreate, Scope: dashboards.ScopeFoldersAll}, - {Action: dashboards.ActionDashboardsPermissionsRead, Scope: dashboards.ScopeDashboardsAll}, - {Action: dashboards.ActionDashboardsPermissionsWrite, Scope: dashboards.ScopeDashboardsAll}, - }, - }, - folders: []*dashboards.Dashboard{basicFolder}, - folderPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicFolder.UID: defaultPerms}, - dashboards: []*dashboards.Dashboard{basicDashboard}, - dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{basicDashboard.UID: defaultPerms}, - alerts: []*models.Alert{basicAlert1}, - expected: []expectedAlertMigration{ // Expect no new folder. - { - Alert: genAlert(basicAlert1.Name, basicFolder.UID, basicDashboard.UID), - Folder: basicFolder, - Perms: defaultPerms, - }, - }, - }, - } - for _, ttRaw := range tc { - t.Run(ttRaw.name, func(t *testing.T) { - for _, tt := range splitTestcase(ttRaw) { - t.Run(tt.name, func(t *testing.T) { - sqlStore := db.InitTestDB(t) - x := sqlStore.GetEngine() - - if tt.enterprise { - createRoles(t, context.Background(), sqlStore, tt.roles) - } - - service := NewTestMigrationService(t, sqlStore, &setting.Cfg{}) - setupLegacyAlertsTables(t, x, nil, tt.alerts, tt.folders, tt.dashboards) - - for i := 1; i < 3; i++ { - _, err := x.Insert(user.User{ - ID: int64(i), - OrgID: 1, - Name: fmt.Sprintf("user%v", i), - Login: fmt.Sprintf("user%v", i), - Email: fmt.Sprintf("user%v@example.org", i), - Created: now, - Updated: now, - }) - require.NoError(t, err) - } - - for i := 1; i < 3; i++ { - _, err := x.Insert(team.Team{ - ID: int64(i), - OrgID: 1, - UID: fmt.Sprintf("team%v", i), - Name: fmt.Sprintf("team%v", i), - Created: now, - Updated: now, - }) - require.NoError(t, err) - } - - for _, f := range tt.folders { - _, err := service.migrationStore.SetFolderPermissions(context.Background(), 1, f.UID, tt.folderPerms[f.UID]...) - require.NoError(t, err) - } - for _, d := range tt.dashboards { - _, err := service.migrationStore.SetDashboardPermissions(context.Background(), 1, d.UID, tt.dashboardPerms[d.UID]...) - require.NoError(t, err) - } - - err := service.Run(context.Background()) - require.NoError(t, err) - - // construct actuals. - orgId := int64(1) - rules := getAlertRules(t, x, orgId) - actual := make([]expectedAlertMigration, 0, len(rules)) - for i, r := range rules { - // Remove generated fields. - require.NotEqual(t, r.Labels["rule_uid"], "") - delete(r.Labels, "rule_uid") - require.NotEqual(t, r.Annotations[ngModels.MigratedAlertIdAnnotation], "") - delete(r.Annotations, ngModels.MigratedAlertIdAnnotation) - - folder := getDashboard(t, x, orgId, r.NamespaceUID) - rperms, err := service.migrationStore.GetFolderPermissions(context.Background(), getMigrationUser(orgId), folder.UID) - require.NoError(t, err) - - expected := tt.expected[i] - if expected.Folder.UID == "" { - // We're expecting the UID to be generated, so remove it from comparison. - folder.UID = "" - r.NamespaceUID = "" - expected.Alert.NamespaceUID = "" - } - - keep := make(map[accesscontrol.SetResourcePermissionCommand]dashboardaccess.PermissionType) - for _, p := range rperms { - if permission := service.migrationStore.MapActions(p); permission != "" { - sp := accesscontrol.SetResourcePermissionCommand{ - UserID: p.UserId, - TeamID: p.TeamId, - BuiltinRole: p.BuiltInRole, - } - pType := permissionMap[permission] - current, ok := keep[sp] - if !ok || pType > current { - keep[sp] = pType - } - } - } - perms := make([]accesscontrol.SetResourcePermissionCommand, 0, len(keep)) - for p, pType := range keep { - p.Permission = pType.String() - perms = append(perms, p) - } - - actual = append(actual, expectedAlertMigration{ - Alert: r, - Folder: folder, - Perms: perms, - }) - } - - cOpt := []cmp.Option{ - cmpopts.SortSlices(func(a, b expectedAlertMigration) bool { - return a.Alert.Title < b.Alert.Title - }), - cmpopts.SortSlices(func(a, b accesscontrol.SetResourcePermissionCommand) bool { - if a.BuiltinRole != b.BuiltinRole { - return a.BuiltinRole < b.BuiltinRole - } - if a.UserID != b.UserID { - return a.UserID < b.UserID - } - if a.TeamID != b.TeamID { - return a.TeamID < b.TeamID - } - return a.Permission < b.Permission - }), - cmpopts.IgnoreUnexported(ngModels.AlertRule{}, ngModels.AlertQuery{}), - cmpopts.IgnoreFields(ngModels.AlertRule{}, "ID", "Updated", "UID"), - cmpopts.IgnoreFields(dashboards.Dashboard{}, "ID", "Created", "Updated", "Data", "Slug"), - } - if !cmp.Equal(tt.expected, actual, cOpt...) { - t.Errorf("Unexpected Rule: %v", cmp.Diff(tt.expected, actual, cOpt...)) - } - }) - } - }) - } -} - -func createRoles(t testing.TB, ctx context.Context, store db.DB, rolePerms map[accesscontrol.Role][]accesscontrol.Permission) { - _ = store.WithDbSession(ctx, func(sess *db.Session) error { - ts := time.Now() - var roles []accesscontrol.Role - - basic := accesscontrol.BuildBasicRoleDefinitions() - - var permissions []accesscontrol.Permission - var builtinRoleAssignments []accesscontrol.BuiltinRole - var userRoleAssignments []accesscontrol.UserRole - var teamRoleAssignments []accesscontrol.TeamRole - i := int64(1) - for role, perms := range rolePerms { - if role.IsBasic() { - for roleType, br := range basic { - if br.Name == role.Name { - role = br.Role() - builtinRoleAssignments = append(builtinRoleAssignments, accesscontrol.BuiltinRole{ - OrgID: accesscontrol.GlobalOrgID, RoleID: i, Role: roleType, Created: ts, Updated: ts, - }) - } - } - } else { - userRoleAssignments = append(userRoleAssignments, accesscontrol.UserRole{ - OrgID: accesscontrol.GlobalOrgID, RoleID: i, UserID: 1, Created: ts, - }) - teamRoleAssignments = append(teamRoleAssignments, accesscontrol.TeamRole{ - OrgID: accesscontrol.GlobalOrgID, RoleID: i, TeamID: 1, Created: ts, - }) - } - role.ID = i - role.Created = ts - role.Updated = ts - - roles = append(roles, role) - - for _, p := range perms { - permissions = append(permissions, accesscontrol.Permission{ - RoleID: role.ID, Action: p.Action, Scope: p.Scope, Created: ts, Updated: ts, - }) - } - i++ - } - - _, err := sess.InsertMulti(&roles) - require.NoError(t, err) - - _, err = sess.InsertMulti(&permissions) - require.NoError(t, err) - - _, err = sess.InsertMulti(&builtinRoleAssignments) - require.NoError(t, err) - - _, err = sess.InsertMulti(&userRoleAssignments) - require.NoError(t, err) - - _, err = sess.InsertMulti(&teamRoleAssignments) - require.NoError(t, err) - - return nil - }) -} diff --git a/pkg/services/ngalert/migration/securejsondata.go b/pkg/services/ngalert/migration/securejsondata.go deleted file mode 100644 index 9de357bb00a64..0000000000000 --- a/pkg/services/ngalert/migration/securejsondata.go +++ /dev/null @@ -1,31 +0,0 @@ -package migration - -import ( - "os" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" -) - -// SecureJsonData is used to store encrypted data (for example in data_source table). Only values are separately -// encrypted. -type SecureJsonData map[string][]byte - -var seclogger = log.New("securejsondata") - -// Decrypt returns map of the same type but where the all the values are decrypted. Opposite of what -// GetEncryptedJsonData is doing. -func (s SecureJsonData) Decrypt() map[string]string { - decrypted := make(map[string]string) - for key, data := range s { - decryptedData, err := util.Decrypt(data, setting.SecretKey) - if err != nil { - seclogger.Error(err.Error()) - os.Exit(1) - } - - decrypted[key] = string(decryptedData) - } - return decrypted -} diff --git a/pkg/services/ngalert/migration/service.go b/pkg/services/ngalert/migration/service.go deleted file mode 100644 index 6cd36953f7420..0000000000000 --- a/pkg/services/ngalert/migration/service.go +++ /dev/null @@ -1,189 +0,0 @@ -package migration - -import ( - "context" - "fmt" - "time" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/serverlock" - migrationStore "github.com/grafana/grafana/pkg/services/ngalert/migration/store" - "github.com/grafana/grafana/pkg/services/secrets" - "github.com/grafana/grafana/pkg/setting" -) - -// actionName is the unique row-level lock name for serverlock.ServerLockService. -const actionName = "alerting migration" - -type UpgradeService interface { - Run(ctx context.Context) error -} - -type migrationService struct { - lock *serverlock.ServerLockService - cfg *setting.Cfg - log log.Logger - store db.DB - migrationStore migrationStore.Store - - encryptionService secrets.Service -} - -func ProvideService( - lock *serverlock.ServerLockService, - cfg *setting.Cfg, - store db.DB, - migrationStore migrationStore.Store, - encryptionService secrets.Service, -) (UpgradeService, error) { - return &migrationService{ - lock: lock, - log: log.New("ngalert.migration"), - cfg: cfg, - store: store, - migrationStore: migrationStore, - encryptionService: encryptionService, - }, nil -} - -// Run starts the migration to transition between legacy alerting and unified alerting based on the current and desired -// alerting type as determined by the kvstore and configuration, respectively. -func (ms *migrationService) Run(ctx context.Context) error { - var errMigration error - errLock := ms.lock.LockExecuteAndRelease(ctx, actionName, time.Minute*10, func(ctx context.Context) { - ms.log.Info("Starting") - errMigration = ms.store.InTransaction(ctx, func(ctx context.Context) error { - currentType, err := ms.migrationStore.GetCurrentAlertingType(ctx) - if err != nil { - return fmt.Errorf("getting migration status: %w", err) - } - return ms.applyTransition(ctx, newTransition(currentType, ms.cfg)) - }) - }) - if errLock != nil { - ms.log.Warn("Server lock for alerting migration already exists") - return nil - } - if errMigration != nil { - return fmt.Errorf("migration failed: %w", errMigration) - } - return nil -} - -// newTransition creates a transition based on the current alerting type and the current configuration. -func newTransition(currentType migrationStore.AlertingType, cfg *setting.Cfg) transition { - desiredType := migrationStore.Legacy - if cfg.UnifiedAlerting.IsEnabled() { - desiredType = migrationStore.UnifiedAlerting - } - return transition{ - CurrentType: currentType, - DesiredType: desiredType, - CleanOnDowngrade: cfg.ForceMigration, - CleanOnUpgrade: cfg.UnifiedAlerting.Upgrade.CleanUpgrade, - } -} - -// transition represents a migration from one alerting type to another. -type transition struct { - CurrentType migrationStore.AlertingType - DesiredType migrationStore.AlertingType - CleanOnDowngrade bool - CleanOnUpgrade bool -} - -// isNoChange returns true if the migration is a no-op. -func (t transition) isNoChange() bool { - return t.CurrentType == t.DesiredType -} - -// isUpgrading returns true if the migration is an upgrade from legacy alerting to unified alerting. -func (t transition) isUpgrading() bool { - return t.CurrentType == migrationStore.Legacy && t.DesiredType == migrationStore.UnifiedAlerting -} - -// isDowngrading returns true if the migration is a downgrade from unified alerting to legacy alerting. -func (t transition) isDowngrading() bool { - return t.CurrentType == migrationStore.UnifiedAlerting && t.DesiredType == migrationStore.Legacy -} - -// shouldClean returns true if the migration should delete all unified alerting data. -func (t transition) shouldClean() bool { - return t.isDowngrading() && t.CleanOnDowngrade || t.isUpgrading() && t.CleanOnUpgrade -} - -// applyTransition applies the transition to the database. -// If the transition is a no-op, nothing will be done. -// If the transition is a downgrade and CleanOnDowngrade is false, nothing will be done. -// If the transition is a downgrade and CleanOnDowngrade is true, all unified alerting data will be deleted. -// If the transition is an upgrade and CleanOnUpgrade is false, all orgs will be migrated. -// If the transition is an upgrade and CleanOnUpgrade is true, all unified alerting data will be deleted and then all orgs will be migrated. -func (ms *migrationService) applyTransition(ctx context.Context, t transition) error { - l := ms.log.New( - "CurrentType", t.CurrentType, - "DesiredType", t.DesiredType, - "CleanOnDowngrade", t.CleanOnDowngrade, - "CleanOnUpgrade", t.CleanOnUpgrade, - ) - if t.isNoChange() { - l.Info("Migration already complete") - return nil - } - - if t.shouldClean() { - l.Info("Cleaning up unified alerting data") - if err := ms.migrationStore.RevertAllOrgs(ctx); err != nil { - return fmt.Errorf("cleaning up unified alerting data: %w", err) - } - l.Info("Unified alerting data deleted") - } - - if t.isUpgrading() { - if err := ms.migrateAllOrgs(ctx); err != nil { - return fmt.Errorf("executing migration: %w", err) - } - } - - if err := ms.migrationStore.SetCurrentAlertingType(ctx, t.DesiredType); err != nil { - return fmt.Errorf("setting migration status: %w", err) - } - - l.Info("Completed legacy migration") - return nil -} - -// migrateAllOrgs executes the migration for all orgs. -func (ms *migrationService) migrateAllOrgs(ctx context.Context) error { - orgs, err := ms.migrationStore.GetAllOrgs(ctx) - if err != nil { - return fmt.Errorf("get orgs: %w", err) - } - - for _, o := range orgs { - om := ms.newOrgMigration(o.ID) - migrated, err := ms.migrationStore.IsMigrated(ctx, o.ID) - if err != nil { - return fmt.Errorf("getting migration status for org %d: %w", o.ID, err) - } - if migrated { - om.log.Info("Org already migrated, skipping") - continue - } - - if err := om.migrateOrg(ctx); err != nil { - return fmt.Errorf("migrate org %d: %w", o.ID, err) - } - - err = om.migrationStore.SetOrgMigrationState(ctx, o.ID, om.state) - if err != nil { - return fmt.Errorf("set org migration state: %w", err) - } - - err = ms.migrationStore.SetMigrated(ctx, o.ID, true) - if err != nil { - return fmt.Errorf("setting migration status: %w", err) - } - } - return nil -} diff --git a/pkg/services/ngalert/migration/service_test.go b/pkg/services/ngalert/migration/service_test.go deleted file mode 100644 index e41f21ad11b5a..0000000000000 --- a/pkg/services/ngalert/migration/service_test.go +++ /dev/null @@ -1,373 +0,0 @@ -package migration - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/stretchr/testify/require" - - migrationStore "github.com/grafana/grafana/pkg/services/ngalert/migration/store" - "xorm.io/xorm" - - "github.com/grafana/grafana/pkg/infra/db" - legacymodels "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/setting" -) - -// TestServiceRevert tests migration revert. -func TestServiceRevert(t *testing.T) { - alerts := []*legacymodels.Alert{ - createAlert(t, 1, 1, 1, "alert1", []string{"notifier1"}), - } - channels := []*legacymodels.AlertNotification{ - createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false), - } - dashes := []*dashboards.Dashboard{ - createDashboard(t, 1, 1, "dash1-1", 5, nil), - createDashboard(t, 2, 1, "dash2-1", 5, nil), - createDashboard(t, 8, 1, "dash-in-general-1", 0, nil), - } - folders := []*dashboards.Dashboard{ - createFolder(t, 5, 1, "folder5-1"), - } - - t.Run("revert deletes UA resources", func(t *testing.T) { - sqlStore := db.InitTestDB(t) - x := sqlStore.GetEngine() - - setupLegacyAlertsTables(t, x, channels, alerts, folders, dashes) - - dashCount, err := x.Table("dashboard").Count(&dashboards.Dashboard{}) - require.NoError(t, err) - require.Equal(t, int64(4), dashCount) - - // Run migration. - ctx := context.Background() - cfg := &setting.Cfg{ - UnifiedAlerting: setting.UnifiedAlertingSettings{ - Enabled: pointer(true), - }, - } - service := NewTestMigrationService(t, sqlStore, cfg) - - err = service.migrationStore.SetCurrentAlertingType(ctx, migrationStore.Legacy) - require.NoError(t, err) - - require.NoError(t, service.Run(ctx)) - - // Verify migration was run. - checkAlertingType(t, ctx, service, migrationStore.UnifiedAlerting) - checkMigrationStatus(t, ctx, service, 1, true) - - // Currently, we fill in some random data for tables that aren't populated during migration. - _, err = x.Table("ngalert_configuration").Insert(models.AdminConfiguration{OrgID: 1}) - require.NoError(t, err) - _, err = x.Table("alert_instance").Insert(models.AlertInstance{ - AlertInstanceKey: models.AlertInstanceKey{ - RuleOrgID: 1, - RuleUID: "alert1", - LabelsHash: "", - }, - CurrentState: models.InstanceStateNormal, - CurrentStateSince: time.Now(), - CurrentStateEnd: time.Now(), - LastEvalTime: time.Now(), - }) - require.NoError(t, err) - - // Verify various UA resources exist - tables := [][2]string{ - {"alert_rule", "org_id"}, - {"alert_rule_version", "rule_org_id"}, - {"alert_configuration", "org_id"}, - {"ngalert_configuration", "org_id"}, - {"alert_instance", "rule_org_id"}, - } - for _, table := range tables { - count, err := x.Table(table[0]).Where(fmt.Sprintf("%s=?", table[1]), 1).Count() - require.NoErrorf(t, err, "table %s error", table[0]) - require.True(t, count > 0, "table %s should have at least one row", table[0]) - } - - // Revert migration. - err = service.migrationStore.RevertAllOrgs(context.Background()) - require.NoError(t, err) - - // Verify revert was run. - checkAlertingType(t, ctx, service, migrationStore.Legacy) - checkMigrationStatus(t, ctx, service, 1, false) - - // Verify various UA resources are gone - for _, table := range tables { - count, err := x.Table(table[0]).Where(fmt.Sprintf("%s=?", table[1]), 1).Count() - require.NoErrorf(t, err, "table %s error", table[0]) - require.Equal(t, int64(0), count, "table %s should have no rows", table[0]) - } - }) - - t.Run("revert deletes folders created during migration", func(t *testing.T) { - sqlStore := db.InitTestDB(t) - x := sqlStore.GetEngine() - alerts = []*legacymodels.Alert{ - createAlert(t, 1, 8, 1, "alert1", []string{"notifier1"}), - } - setupLegacyAlertsTables(t, x, channels, alerts, folders, dashes) - - dashCount, err := x.Table("dashboard").Count(&dashboards.Dashboard{}) - require.NoError(t, err) - require.Equal(t, int64(4), dashCount) - - // Run migration. - ctx := context.Background() - cfg := &setting.Cfg{ - UnifiedAlerting: setting.UnifiedAlertingSettings{ - Enabled: pointer(true), - }, - } - service := NewTestMigrationService(t, sqlStore, cfg) - - err = service.migrationStore.SetCurrentAlertingType(ctx, migrationStore.Legacy) - require.NoError(t, err) - - require.NoError(t, service.Run(ctx)) - - // Verify migration was run. - checkAlertingType(t, ctx, service, migrationStore.UnifiedAlerting) - checkMigrationStatus(t, ctx, service, 1, true) - - // Verify we created some folders. - newDashCount, err := x.Table("dashboard").Count(&dashboards.Dashboard{}) - require.NoError(t, err) - require.Truef(t, newDashCount > dashCount, "newDashCount: %d should be greater than dashCount: %d", newDashCount, dashCount) - - // Check that dashboards and folders from before migration still exist. - require.NotNil(t, getDashboard(t, x, 1, "dash1-1")) - require.NotNil(t, getDashboard(t, x, 1, "dash2-1")) - require.NotNil(t, getDashboard(t, x, 1, "dash-in-general-1")) - - state, err := service.migrationStore.GetOrgMigrationState(ctx, 1) - require.NoError(t, err) - - // Verify list of created folders. - require.NotEmpty(t, state.CreatedFolders) - for _, uid := range state.CreatedFolders { - require.NotNil(t, getDashboard(t, x, 1, uid)) - } - - // Revert migration. - err = service.migrationStore.RevertAllOrgs(context.Background()) - require.NoError(t, err) - - // Verify revert was run. Should only set migration status for org. - checkAlertingType(t, ctx, service, migrationStore.Legacy) - checkMigrationStatus(t, ctx, service, 1, false) - - // Verify we are back to the original count. - newDashCount, err = x.Table("dashboard").Count(&dashboards.Dashboard{}) - require.NoError(t, err) - require.Equalf(t, dashCount, newDashCount, "newDashCount: %d should be equal to dashCount: %d after revert", newDashCount, dashCount) - - // Check that dashboards and folders from before migration still exist. - require.NotNil(t, getDashboard(t, x, 1, "dash1-1")) - require.NotNil(t, getDashboard(t, x, 1, "dash2-1")) - require.NotNil(t, getDashboard(t, x, 1, "dash-in-general-1")) - - // Check that folders created during migration are gone. - for _, uid := range state.CreatedFolders { - require.Nil(t, getDashboard(t, x, 1, uid)) - } - }) - - t.Run("revert skips migrated folders that are not empty", func(t *testing.T) { - sqlStore := db.InitTestDB(t) - x := sqlStore.GetEngine() - alerts = []*legacymodels.Alert{ - createAlert(t, 1, 8, 1, "alert1", []string{"notifier1"}), - } - setupLegacyAlertsTables(t, x, channels, alerts, folders, dashes) - - dashCount, err := x.Table("dashboard").Count(&dashboards.Dashboard{}) - require.NoError(t, err) - require.Equal(t, int64(4), dashCount) - - // Run migration. - ctx := context.Background() - cfg := &setting.Cfg{ - UnifiedAlerting: setting.UnifiedAlertingSettings{ - Enabled: pointer(true), - Upgrade: setting.UnifiedAlertingUpgradeSettings{}, - }, - } - service := NewTestMigrationService(t, sqlStore, cfg) - - err = service.migrationStore.SetCurrentAlertingType(ctx, migrationStore.Legacy) - require.NoError(t, err) - - require.NoError(t, service.Run(ctx)) - - // Verify migration was run. - checkAlertingType(t, ctx, service, migrationStore.UnifiedAlerting) - checkMigrationStatus(t, ctx, service, 1, true) - - // Verify we created some folders. - newDashCount, err := x.Table("dashboard").Count(&dashboards.Dashboard{OrgID: 1}) - require.NoError(t, err) - require.Truef(t, newDashCount > dashCount, "newDashCount: %d should be greater than dashCount: %d", newDashCount, dashCount) - - // Check that dashboards and folders from before migration still exist. - require.NotNil(t, getDashboard(t, x, 1, "dash1-1")) - require.NotNil(t, getDashboard(t, x, 1, "dash2-1")) - require.NotNil(t, getDashboard(t, x, 1, "dash-in-general-1")) - - state, err := service.migrationStore.GetOrgMigrationState(ctx, 1) - require.NoError(t, err) - - // Verify list of created folders. - require.NotEmpty(t, state.CreatedFolders) - var generalAlertingFolder *dashboards.Dashboard - for _, uid := range state.CreatedFolders { - f := getDashboard(t, x, 1, uid) - require.NotNil(t, f) - if f.Slug == "general-alerting" { - generalAlertingFolder = f - } - } - require.NotNil(t, generalAlertingFolder) - - // Create dashboard in general alerting. - newDashes := []*dashboards.Dashboard{ - createDashboard(t, 99, 1, "dash-in-general-alerting-1", generalAlertingFolder.ID, nil), - } - _, err = x.Insert(newDashes) - require.NoError(t, err) - - newF := getDashboard(t, x, 1, "dash-in-general-alerting-1") - require.NotNil(t, newF) - - // Revert migration. - err = service.migrationStore.RevertAllOrgs(context.Background()) - require.NoError(t, err) - - // Verify revert was run. Should only set migration status for org. - checkAlertingType(t, ctx, service, migrationStore.Legacy) - checkMigrationStatus(t, ctx, service, 1, false) - - // Verify we are back to the original count + 2. - newDashCount, err = x.Table("dashboard").Count(&dashboards.Dashboard{OrgID: 1}) - require.NoError(t, err) - require.Equalf(t, dashCount+2, newDashCount, "newDashCount: %d should be equal to dashCount + 2: %d after revert", newDashCount, dashCount) - - // Check that dashboards and folders from before migration still exist. - require.NotNil(t, getDashboard(t, x, 1, "dash1-1")) - require.NotNil(t, getDashboard(t, x, 1, "dash2-1")) - require.NotNil(t, getDashboard(t, x, 1, "dash-in-general-1")) - - // Check that the general alerting folder still exists. - require.NotNil(t, getDashboard(t, x, 1, generalAlertingFolder.UID)) - // Check that the new dashboard in general alerting folder still exists. - require.NotNil(t, getDashboard(t, x, 1, "dash-in-general-alerting-1")) - - // Check that other folders created during migration are gone. - for _, uid := range state.CreatedFolders { - if uid == generalAlertingFolder.UID { - continue - } - require.Nil(t, getDashboard(t, x, 1, uid)) - } - }) - - t.Run("CleanUpgrade story", func(t *testing.T) { - sqlStore := db.InitTestDB(t) - x := sqlStore.GetEngine() - - setupLegacyAlertsTables(t, x, channels, alerts, folders, dashes) - - ctx := context.Background() - cfg := &setting.Cfg{ - UnifiedAlerting: setting.UnifiedAlertingSettings{ - Enabled: pointer(true), - }, - } - service := NewTestMigrationService(t, sqlStore, cfg) - checkAlertingType(t, ctx, service, migrationStore.Legacy) - checkMigrationStatus(t, ctx, service, 1, false) - checkAlertRulesCount(t, x, 1, 0) - - // Enable UA. - // First run should migrate org. - require.NoError(t, service.Run(ctx)) - checkAlertingType(t, ctx, service, migrationStore.UnifiedAlerting) - checkMigrationStatus(t, ctx, service, 1, true) - checkAlertRulesCount(t, x, 1, 1) - - // Disable UA. - // This run should just set migration status to false. - service.cfg.UnifiedAlerting.Enabled = pointer(false) - require.NoError(t, service.Run(ctx)) - checkAlertingType(t, ctx, service, migrationStore.Legacy) - checkMigrationStatus(t, ctx, service, 1, true) - checkAlertRulesCount(t, x, 1, 1) - - // Add another alert. - // Enable UA without clean flag. - // This run should not remigrate org, new alert is not migrated. - _, alertErr := x.Insert(createAlert(t, 1, 1, 2, "alert2", []string{"notifier1"})) - require.NoError(t, alertErr) - service.cfg.UnifiedAlerting.Enabled = pointer(true) - require.NoError(t, service.Run(ctx)) - checkAlertingType(t, ctx, service, migrationStore.UnifiedAlerting) - checkMigrationStatus(t, ctx, service, 1, true) - checkAlertRulesCount(t, x, 1, 1) // Still 1 - - // Disable UA with clean flag. - // This run should not revert UA data. - service.cfg.UnifiedAlerting.Enabled = pointer(false) - service.cfg.UnifiedAlerting.Upgrade.CleanUpgrade = true - require.NoError(t, service.Run(ctx)) - checkAlertingType(t, ctx, service, migrationStore.Legacy) - checkMigrationStatus(t, ctx, service, 1, true) - checkAlertRulesCount(t, x, 1, 1) // Still 1 - - // Enable UA with clean flag. - // This run should revert and remigrate org, new alert is migrated. - service.cfg.UnifiedAlerting.Enabled = pointer(true) - require.NoError(t, service.Run(ctx)) - checkAlertingType(t, ctx, service, migrationStore.UnifiedAlerting) - checkMigrationStatus(t, ctx, service, 1, true) - checkAlertRulesCount(t, x, 1, 2) // Now we have 2 - - // The following tests ForceMigration which is deprecated and will be removed in v11. - service.cfg.UnifiedAlerting.Upgrade.CleanUpgrade = false - - // Disable UA with force flag. - // This run should not revert UA data. - service.cfg.UnifiedAlerting.Enabled = pointer(false) - service.cfg.ForceMigration = true - require.NoError(t, service.Run(ctx)) - checkAlertingType(t, ctx, service, migrationStore.Legacy) - checkMigrationStatus(t, ctx, service, 1, false) - checkAlertRulesCount(t, x, 1, 0) - }) -} - -func checkMigrationStatus(t *testing.T, ctx context.Context, service *migrationService, orgID int64, expected bool) { - migrated, err := service.migrationStore.IsMigrated(ctx, orgID) - require.NoError(t, err) - require.Equal(t, expected, migrated) -} - -func checkAlertingType(t *testing.T, ctx context.Context, service *migrationService, expected migrationStore.AlertingType) { - aType, err := service.migrationStore.GetCurrentAlertingType(ctx) - require.NoError(t, err) - require.Equal(t, expected, aType) -} - -func checkAlertRulesCount(t *testing.T, x *xorm.Engine, orgID int64, count int) { - cnt, err := x.Table("alert_rule").Where("org_id=?", orgID).Count() - require.NoError(t, err, "table alert_rule error") - require.Equal(t, int(cnt), count, "table alert_rule should have no rows") -} diff --git a/pkg/services/ngalert/migration/silences.go b/pkg/services/ngalert/migration/silences.go deleted file mode 100644 index b5431e854f801..0000000000000 --- a/pkg/services/ngalert/migration/silences.go +++ /dev/null @@ -1,159 +0,0 @@ -package migration - -import ( - "bytes" - "errors" - "fmt" - "io" - "math/rand" - "os" - "path/filepath" - "strconv" - "time" - - "github.com/google/uuid" - "github.com/matttproud/golang_protobuf_extensions/pbutil" - pb "github.com/prometheus/alertmanager/silence/silencepb" - "github.com/prometheus/common/model" - - "github.com/grafana/grafana/pkg/services/ngalert/models" -) - -const ( - // Should be the same as 'NoDataAlertName' in pkg/services/schedule/compat.go. - NoDataAlertName = "DatasourceNoData" - - ErrorAlertName = "DatasourceError" -) - -// addErrorSilence adds a silence for the given rule to the orgMigration if the ExecutionErrorState was set to keep_state. -func (om *OrgMigration) addErrorSilence(rule *models.AlertRule) error { - uid, err := uuid.NewRandom() - if err != nil { - return errors.New("create uuid for silence") - } - - s := &pb.MeshSilence{ - Silence: &pb.Silence{ - Id: uid.String(), - Matchers: []*pb.Matcher{ - { - Type: pb.Matcher_EQUAL, - Name: model.AlertNameLabel, - Pattern: ErrorAlertName, - }, - { - Type: pb.Matcher_EQUAL, - Name: "rule_uid", - Pattern: rule.UID, - }, - }, - StartsAt: time.Now(), - EndsAt: time.Now().AddDate(1, 0, 0), // 1 year - CreatedBy: "Grafana Migration", - Comment: fmt.Sprintf("Created during migration to unified alerting to silence Error state for alert rule ID '%s' and Title '%s' because the option 'Keep Last State' was selected for Error state", rule.UID, rule.Title), - }, - ExpiresAt: time.Now().AddDate(1, 0, 0), // 1 year - } - om.silences = append(om.silences, s) - return nil -} - -// addNoDataSilence adds a silence for the given rule to the orgMigration if the NoDataState was set to keep_state. -func (om *OrgMigration) addNoDataSilence(rule *models.AlertRule) error { - uid, err := uuid.NewRandom() - if err != nil { - return errors.New("create uuid for silence") - } - - s := &pb.MeshSilence{ - Silence: &pb.Silence{ - Id: uid.String(), - Matchers: []*pb.Matcher{ - { - Type: pb.Matcher_EQUAL, - Name: model.AlertNameLabel, - Pattern: NoDataAlertName, - }, - { - Type: pb.Matcher_EQUAL, - Name: "rule_uid", - Pattern: rule.UID, - }, - }, - StartsAt: time.Now(), - EndsAt: time.Now().AddDate(1, 0, 0), // 1 year. - CreatedBy: "Grafana Migration", - Comment: fmt.Sprintf("Created during migration to unified alerting to silence NoData state for alert rule ID '%s' and Title '%s' because the option 'Keep Last State' was selected for NoData state", rule.UID, rule.Title), - }, - ExpiresAt: time.Now().AddDate(1, 0, 0), // 1 year. - } - om.silences = append(om.silences, s) - return nil -} - -func (om *OrgMigration) writeSilencesFile() error { - var buf bytes.Buffer - om.log.Debug("Writing silences file", "silences", len(om.silences)) - for _, e := range om.silences { - if _, err := pbutil.WriteDelimited(&buf, e); err != nil { - return err - } - } - - f, err := openReplace(silencesFileNameForOrg(om.cfg.DataPath, om.orgID)) - if err != nil { - return err - } - - if _, err := io.Copy(f, bytes.NewReader(buf.Bytes())); err != nil { - return err - } - - return f.Close() -} - -func silencesFileNameForOrg(dataPath string, orgID int64) string { - return filepath.Join(dataPath, "alerting", strconv.Itoa(int(orgID)), "silences") -} - -// replaceFile wraps a file that is moved to another filename on closing. -type replaceFile struct { - *os.File - filename string -} - -func (f *replaceFile) Close() error { - if err := f.File.Sync(); err != nil { - return err - } - if err := f.File.Close(); err != nil { - return err - } - return os.Rename(f.File.Name(), f.filename) -} - -// openReplace opens a new temporary file that is moved to filename on closing. -func openReplace(filename string) (*replaceFile, error) { - tmpFilename := fmt.Sprintf("%s.%x", filename, uint64(rand.Int63())) - - if err := os.MkdirAll(filepath.Dir(tmpFilename), os.ModePerm); err != nil { - return nil, err - } - - //nolint:gosec - f, err := os.Create(tmpFilename) - if err != nil { - return nil, err - } - - rf := &replaceFile{ - File: f, - filename: filename, - } - return rf, nil -} - -func getLabelForSilenceMatching(ruleUID string) (string, string) { - return "rule_uid", ruleUID -} diff --git a/pkg/services/ngalert/migration/store/database.go b/pkg/services/ngalert/migration/store/database.go deleted file mode 100644 index b82a96b3cba76..0000000000000 --- a/pkg/services/ngalert/migration/store/database.go +++ /dev/null @@ -1,481 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/kvstore" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/accesscontrol" - legacyalerting "github.com/grafana/grafana/pkg/services/alerting" - legacymodels "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/auth/identity" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/folder" - apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" - migmodels "github.com/grafana/grafana/pkg/services/ngalert/migration/models" - "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/notifier" - "github.com/grafana/grafana/pkg/services/ngalert/store" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/sqlstore/migrator" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" -) - -// Store is the database abstraction for migration persistence. -type Store interface { - InsertAlertRules(ctx context.Context, rules ...models.AlertRule) error - - SaveAlertmanagerConfiguration(ctx context.Context, orgID int64, amConfig *apimodels.PostableUserConfig) error - - GetAllOrgs(ctx context.Context) ([]*org.OrgDTO, error) - - GetDatasource(ctx context.Context, datasourceID int64, user identity.Requester) (*datasources.DataSource, error) - - GetNotificationChannels(ctx context.Context, orgID int64) ([]*legacymodels.AlertNotification, error) - - GetOrgDashboardAlerts(ctx context.Context, orgID int64) (map[int64][]*legacymodels.Alert, int, error) - - GetDashboardPermissions(ctx context.Context, user identity.Requester, resourceID string) ([]accesscontrol.ResourcePermission, error) - GetFolderPermissions(ctx context.Context, user identity.Requester, resourceID string) ([]accesscontrol.ResourcePermission, error) - SetDashboardPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) - SetFolderPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) - MapActions(permission accesscontrol.ResourcePermission) string - - GetDashboard(ctx context.Context, orgID int64, id int64) (*dashboards.Dashboard, error) - GetFolder(ctx context.Context, cmd *folder.GetFolderQuery) (*folder.Folder, error) - CreateFolder(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) - - IsMigrated(ctx context.Context, orgID int64) (bool, error) - SetMigrated(ctx context.Context, orgID int64, migrated bool) error - GetCurrentAlertingType(ctx context.Context) (AlertingType, error) - SetCurrentAlertingType(ctx context.Context, t AlertingType) error - GetOrgMigrationState(ctx context.Context, orgID int64) (*migmodels.OrgMigrationState, error) - SetOrgMigrationState(ctx context.Context, orgID int64, summary *migmodels.OrgMigrationState) error - - RevertAllOrgs(ctx context.Context) error - - CaseInsensitive() bool -} - -type migrationStore struct { - store db.DB - cfg *setting.Cfg - log log.Logger - kv kvstore.KVStore - alertingStore *store.DBstore - dashboardService dashboards.DashboardService - folderService folder.Service - dataSourceCache datasources.CacheService - folderPermissions accesscontrol.FolderPermissionsService - dashboardPermissions accesscontrol.DashboardPermissionsService - orgService org.Service - - legacyAlertNotificationService *legacyalerting.AlertNotificationService -} - -// MigrationStore implements the Store interface. -var _ Store = (*migrationStore)(nil) - -func ProvideMigrationStore( - cfg *setting.Cfg, - sqlStore db.DB, - kv kvstore.KVStore, - alertingStore *store.DBstore, - dashboardService dashboards.DashboardService, - folderService folder.Service, - dataSourceCache datasources.CacheService, - folderPermissions accesscontrol.FolderPermissionsService, - dashboardPermissions accesscontrol.DashboardPermissionsService, - orgService org.Service, - legacyAlertNotificationService *legacyalerting.AlertNotificationService, -) (Store, error) { - return &migrationStore{ - log: log.New("ngalert.migration-store"), - cfg: cfg, - store: sqlStore, - kv: kv, - alertingStore: alertingStore, - dashboardService: dashboardService, - folderService: folderService, - dataSourceCache: dataSourceCache, - folderPermissions: folderPermissions, - dashboardPermissions: dashboardPermissions, - orgService: orgService, - legacyAlertNotificationService: legacyAlertNotificationService, - }, nil -} - -// KVNamespace is the kvstore namespace used for the migration status. -const KVNamespace = "ngalert.migration" - -// migratedKey is the kvstore key used for the migration status. -const migratedKey = "migrated" - -// stateKey is the kvstore key used for the OrgMigrationState. -const stateKey = "stateKey" - -// typeKey is the kvstore key used for the current AlertingType. -const typeKey = "currentAlertingType" - -// IsMigrated returns the migration status from the kvstore. -func (ms *migrationStore) IsMigrated(ctx context.Context, orgID int64) (bool, error) { - kv := kvstore.WithNamespace(ms.kv, orgID, KVNamespace) - content, exists, err := kv.Get(ctx, migratedKey) - if err != nil { - return false, err - } - - if !exists { - return false, nil - } - - return strconv.ParseBool(content) -} - -// SetMigrated sets the migration status in the kvstore. -func (ms *migrationStore) SetMigrated(ctx context.Context, orgID int64, migrated bool) error { - kv := kvstore.WithNamespace(ms.kv, orgID, KVNamespace) - return kv.Set(ctx, migratedKey, strconv.FormatBool(migrated)) -} - -// AlertingType represents the current alerting type of Grafana. This is used to detect transitions between -// Legacy and UnifiedAlerting by comparing to the desired type in the configuration. -type AlertingType string - -const ( - Legacy AlertingType = "Legacy" - UnifiedAlerting AlertingType = "UnifiedAlerting" -) - -// typeFromString converts a string to an AlertingType. -func typeFromString(s string) (AlertingType, error) { - switch s { - case "Legacy": - return Legacy, nil - case "UnifiedAlerting": - return UnifiedAlerting, nil - default: - return "", fmt.Errorf("unknown alerting type: %s", s) - } -} - -const anyOrg = 0 - -// GetCurrentAlertingType returns the current AlertingType of Grafana. -func (ms *migrationStore) GetCurrentAlertingType(ctx context.Context) (AlertingType, error) { - kv := kvstore.WithNamespace(ms.kv, anyOrg, KVNamespace) - content, exists, err := kv.Get(ctx, typeKey) - if err != nil { - return "", err - } - - if !exists { - return Legacy, nil - } - - t, err := typeFromString(content) - if err != nil { - return "", err - } - - return t, nil -} - -// SetCurrentAlertingType stores the current AlertingType of Grafana. -func (ms *migrationStore) SetCurrentAlertingType(ctx context.Context, t AlertingType) error { - kv := kvstore.WithNamespace(ms.kv, anyOrg, KVNamespace) - return kv.Set(ctx, typeKey, string(t)) -} - -// GetOrgMigrationState returns a summary of a previous migration. -func (ms *migrationStore) GetOrgMigrationState(ctx context.Context, orgID int64) (*migmodels.OrgMigrationState, error) { - kv := kvstore.WithNamespace(ms.kv, orgID, KVNamespace) - content, exists, err := kv.Get(ctx, stateKey) - if err != nil { - return nil, err - } - - if !exists { - return &migmodels.OrgMigrationState{OrgID: orgID}, nil - } - - var summary migmodels.OrgMigrationState - err = json.Unmarshal([]byte(content), &summary) - if err != nil { - return nil, err - } - - return &summary, nil -} - -// SetOrgMigrationState sets the summary of a previous migration. -func (ms *migrationStore) SetOrgMigrationState(ctx context.Context, orgID int64, summary *migmodels.OrgMigrationState) error { - kv := kvstore.WithNamespace(ms.kv, orgID, KVNamespace) - raw, err := json.Marshal(summary) - if err != nil { - return err - } - - return kv.Set(ctx, stateKey, string(raw)) -} - -func (ms *migrationStore) InsertAlertRules(ctx context.Context, rules ...models.AlertRule) error { - if ms.store.GetDialect().DriverName() == migrator.Postgres { - // Postgresql which will automatically rollback the whole transaction on constraint violation. - // So, for postgresql, insertions will execute in a subtransaction. - err := ms.store.InTransaction(ctx, func(subCtx context.Context) error { - _, err := ms.alertingStore.InsertAlertRules(subCtx, rules) - if err != nil { - return err - } - return nil - }) - if err != nil { - return err - } - } else { - _, err := ms.alertingStore.InsertAlertRules(ctx, rules) - if err != nil { - return err - } - } - - return nil -} - -// SaveAlertmanagerConfiguration saves the alertmanager configuration for the given org. -func (ms *migrationStore) SaveAlertmanagerConfiguration(ctx context.Context, orgID int64, amConfig *apimodels.PostableUserConfig) error { - rawAmConfig, err := json.Marshal(amConfig) - if err != nil { - return err - } - - cmd := models.SaveAlertmanagerConfigurationCmd{ - AlertmanagerConfiguration: string(rawAmConfig), - ConfigurationVersion: fmt.Sprintf("v%d", models.AlertConfigurationVersion), - Default: false, - OrgID: orgID, - LastApplied: 0, - } - return ms.alertingStore.SaveAlertmanagerConfiguration(ctx, &cmd) -} - -// revertPermissions are the permissions required for the background user to revert the migration. -var revertPermissions = []accesscontrol.Permission{ - {Action: dashboards.ActionFoldersDelete, Scope: dashboards.ScopeFoldersAll}, - {Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll}, -} - -// RevertAllOrgs reverts the migration, deleting all unified alerting resources such as alert rules, alertmanager configurations, and silence files. -// In addition, it will delete all folders and permissions originally created by this migration, as well as the various migration statuses stored -// in kvstore, both org-specific and anyOrg. -func (ms *migrationStore) RevertAllOrgs(ctx context.Context) error { - return ms.store.InTransaction(ctx, func(ctx context.Context) error { - return ms.store.WithDbSession(ctx, func(sess *db.Session) error { - if _, err := sess.Exec("DELETE FROM alert_rule"); err != nil { - return err - } - - if _, err := sess.Exec("DELETE FROM alert_rule_version"); err != nil { - return err - } - - orgs, err := ms.GetAllOrgs(ctx) - if err != nil { - return fmt.Errorf("get orgs: %w", err) - } - for _, o := range orgs { - if err := ms.DeleteMigratedFolders(ctx, o.ID); err != nil { - ms.log.Warn("Failed to delete migrated folders", "orgID", o.ID, "err", err) - continue - } - } - - if _, err := sess.Exec("DELETE FROM alert_configuration"); err != nil { - return err - } - - if _, err := sess.Exec("DELETE FROM ngalert_configuration"); err != nil { - return err - } - - if _, err := sess.Exec("DELETE FROM alert_instance"); err != nil { - return err - } - - if _, err := sess.Exec("DELETE FROM kv_store WHERE namespace = ?", notifier.KVNamespace); err != nil { - return err - } - - if _, err := sess.Exec("DELETE FROM kv_store WHERE namespace = ?", KVNamespace); err != nil { - return err - } - - files, err := filepath.Glob(filepath.Join(ms.cfg.DataPath, "alerting", "*", "silences")) - if err != nil { - return err - } - for _, f := range files { - if err := os.Remove(f); err != nil { - ms.log.Error("Failed to remove silence file", "file", f, "err", err) - } - } - - return nil - }) - }) -} - -// DeleteMigratedFolders deletes all folders created by the previous migration run for the given org. This includes all folder permissions. -// If the folder is not empty of all descendants the operation will fail and return an error. -func (ms *migrationStore) DeleteMigratedFolders(ctx context.Context, orgID int64) error { - summary, err := ms.GetOrgMigrationState(ctx, orgID) - if err != nil { - return err - } - return ms.DeleteFolders(ctx, orgID, summary.CreatedFolders...) -} - -var ErrFolderNotDeleted = fmt.Errorf("folder not deleted") - -// DeleteFolders deletes the folders from the given orgs with the given UIDs. This includes all folder permissions. -// If the folder is not empty of all descendants the operation will fail and return an error. -func (ms *migrationStore) DeleteFolders(ctx context.Context, orgID int64, uids ...string) error { - if len(uids) == 0 { - return nil - } - - var errs error - usr := accesscontrol.BackgroundUser("ngalert_migration_revert", orgID, org.RoleAdmin, revertPermissions) - for _, folderUID := range uids { - // Check if folder is empty. If not, we should not delete it. - uid := folderUID - countCmd := folder.GetDescendantCountsQuery{ - UID: &uid, - OrgID: orgID, - SignedInUser: usr.(*user.SignedInUser), - } - count, err := ms.folderService.GetDescendantCounts(ctx, &countCmd) - if err != nil { - errs = errors.Join(errs, fmt.Errorf("folder %s: %w", folderUID, err)) - continue - } - var descendantCounts []string - var cntErr error - for kind, cnt := range count { - if cnt > 0 { - descendantCounts = append(descendantCounts, fmt.Sprintf("%d %s", cnt, kind)) - if err != nil { - cntErr = errors.Join(cntErr, err) - continue - } - } - } - if cntErr != nil { - errs = errors.Join(errs, fmt.Errorf("folder %s: %w", folderUID, cntErr)) - continue - } - - if len(descendantCounts) > 0 { - errs = errors.Join(errs, fmt.Errorf("folder %s contains descendants: %s", folderUID, strings.Join(descendantCounts, ", "))) - continue - } - - cmd := folder.DeleteFolderCommand{ - UID: uid, - OrgID: orgID, - SignedInUser: usr.(*user.SignedInUser), - } - err = ms.folderService.Delete(ctx, &cmd) // Also handles permissions and other related entities. - if err != nil { - errs = errors.Join(errs, fmt.Errorf("folder %s: %w", folderUID, err)) - continue - } - } - if errs != nil { - return fmt.Errorf("%w: %w", ErrFolderNotDeleted, errs) - } - return nil -} - -// GetDashboard returns a single dashboard for the given org and dashboard id. -func (ms *migrationStore) GetDashboard(ctx context.Context, orgID int64, id int64) (*dashboards.Dashboard, error) { - return ms.dashboardService.GetDashboard(ctx, &dashboards.GetDashboardQuery{ID: id, OrgID: orgID}) -} - -// GetAllOrgs returns all orgs. -func (ms *migrationStore) GetAllOrgs(ctx context.Context) ([]*org.OrgDTO, error) { - orgQuery := &org.SearchOrgsQuery{} - return ms.orgService.Search(ctx, orgQuery) -} - -// GetDatasource returns a single datasource for the given org and datasource id. -func (ms *migrationStore) GetDatasource(ctx context.Context, datasourceID int64, user identity.Requester) (*datasources.DataSource, error) { - return ms.dataSourceCache.GetDatasource(ctx, datasourceID, user, false) -} - -// GetNotificationChannels returns all channels for this org. -func (ms *migrationStore) GetNotificationChannels(ctx context.Context, orgID int64) ([]*legacymodels.AlertNotification, error) { - return ms.legacyAlertNotificationService.GetAllAlertNotifications(ctx, &legacymodels.GetAllAlertNotificationsQuery{ - OrgID: orgID, - }) -} - -// GetOrgDashboardAlerts loads all legacy dashboard alerts for the given org mapped by dashboard id. -func (ms *migrationStore) GetOrgDashboardAlerts(ctx context.Context, orgID int64) (map[int64][]*legacymodels.Alert, int, error) { - var dashAlerts []*legacymodels.Alert - err := ms.store.WithDbSession(ctx, func(sess *db.Session) error { - return sess.SQL("select * from alert WHERE org_id = ? AND dashboard_id IN (SELECT id from dashboard)", orgID).Find(&dashAlerts) - }) - if err != nil { - return nil, 0, err - } - - mappedAlerts := make(map[int64][]*legacymodels.Alert) - for _, alert := range dashAlerts { - mappedAlerts[alert.DashboardID] = append(mappedAlerts[alert.DashboardID], alert) - } - return mappedAlerts, len(dashAlerts), nil -} - -func (ms *migrationStore) GetDashboardPermissions(ctx context.Context, user identity.Requester, resourceID string) ([]accesscontrol.ResourcePermission, error) { - return ms.dashboardPermissions.GetPermissions(ctx, user, resourceID) -} - -func (ms *migrationStore) GetFolderPermissions(ctx context.Context, user identity.Requester, resourceID string) ([]accesscontrol.ResourcePermission, error) { - return ms.folderPermissions.GetPermissions(ctx, user, resourceID) -} - -func (ms *migrationStore) GetFolder(ctx context.Context, cmd *folder.GetFolderQuery) (*folder.Folder, error) { - return ms.folderService.Get(ctx, cmd) -} - -func (ms *migrationStore) CreateFolder(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { - return ms.folderService.Create(ctx, cmd) -} - -func (ms *migrationStore) SetDashboardPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) { - return ms.dashboardPermissions.SetPermissions(ctx, orgID, resourceID, commands...) -} - -func (ms *migrationStore) SetFolderPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) { - return ms.folderPermissions.SetPermissions(ctx, orgID, resourceID, commands...) -} - -func (ms *migrationStore) MapActions(permission accesscontrol.ResourcePermission) string { - return ms.dashboardPermissions.MapActions(permission) -} - -func (ms *migrationStore) CaseInsensitive() bool { - return ms.store.GetDialect().SupportEngine() -} diff --git a/pkg/services/ngalert/migration/store/testing.go b/pkg/services/ngalert/migration/store/testing.go deleted file mode 100644 index 40fa3a7ac2bcf..0000000000000 --- a/pkg/services/ngalert/migration/store/testing.go +++ /dev/null @@ -1,108 +0,0 @@ -package store - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/infra/kvstore" - "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/infra/log/logtest" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" - "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" - legacyalerting "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/dashboards/database" - dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/service" - datasourceGuardian "github.com/grafana/grafana/pkg/services/datasources/guardian" - datasourceService "github.com/grafana/grafana/pkg/services/datasources/service" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/folder/folderimpl" - "github.com/grafana/grafana/pkg/services/guardian" - "github.com/grafana/grafana/pkg/services/licensing/licensingtest" - "github.com/grafana/grafana/pkg/services/ngalert/store" - "github.com/grafana/grafana/pkg/services/org/orgimpl" - "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/services/supportbundles/bundleregistry" - "github.com/grafana/grafana/pkg/services/tag/tagimpl" - "github.com/grafana/grafana/pkg/services/team/teamimpl" - "github.com/grafana/grafana/pkg/services/user/userimpl" - "github.com/grafana/grafana/pkg/setting" -) - -func NewTestMigrationStore(t testing.TB, sqlStore *sqlstore.SQLStore, cfg *setting.Cfg) *migrationStore { - if cfg.UnifiedAlerting.BaseInterval == 0 { - cfg.UnifiedAlerting.BaseInterval = time.Second * 10 - } - features := featuremgmt.WithFeatures() - cfg.IsFeatureToggleEnabled = features.IsEnabledGlobally - alertingStore := store.DBstore{ - SQLStore: sqlStore, - Cfg: cfg.UnifiedAlerting, - Logger: &logtest.Fake{}, - } - bus := bus.ProvideBus(tracing.InitializeTracerForTest()) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - - cache := localcache.ProvideService() - quotaService := "atest.FakeQuotaService{} - ac := acimpl.ProvideAccessControl(cfg) - routeRegister := routing.ProvideRegister() - acSvc, err := acimpl.ProvideService(cfg, sqlStore, routing.ProvideRegister(), cache, ac, features) - require.NoError(t, err) - - license := licensingtest.NewFakeLicensing() - license.On("FeatureEnabled", "accesscontrol.enforcement").Return(true).Maybe() - teamSvc := teamimpl.ProvideService(sqlStore, cfg) - orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) - require.NoError(t, err) - userSvc, err := userimpl.ProvideService(sqlStore, orgService, cfg, teamSvc, cache, quotaService, bundleregistry.ProvideService()) - require.NoError(t, err) - - dashboardStore, err := database.ProvideDashboardStore(sqlStore, sqlStore.Cfg, features, tagimpl.ProvideService(sqlStore), quotaService) - require.NoError(t, err) - folderService := folderimpl.ProvideService(ac, bus, cfg, dashboardStore, folderStore, sqlStore, features, nil) - - err = folderService.RegisterService(alertingStore) - require.NoError(t, err) - - folderPermissions, err := ossaccesscontrol.ProvideFolderPermissions( - features, routeRegister, sqlStore, ac, license, dashboardStore, folderService, acSvc, teamSvc, userSvc) - require.NoError(t, err) - dashboardPermissions, err := ossaccesscontrol.ProvideDashboardPermissions( - features, routeRegister, sqlStore, ac, license, dashboardStore, folderService, acSvc, teamSvc, userSvc) - require.NoError(t, err) - - dashboardService, err := dashboardservice.ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, nil, - features, folderPermissions, dashboardPermissions, ac, - folderService, - nil, - ) - require.NoError(t, err) - guardian.InitAccessControlGuardian(setting.NewCfg(), ac, dashboardService) - - err = acSvc.RegisterFixedRoles(context.Background()) - require.NoError(t, err) - - return &migrationStore{ - log: &logtest.Fake{}, - cfg: cfg, - store: sqlStore, - kv: kvstore.ProvideService(sqlStore), - alertingStore: &alertingStore, - dashboardService: dashboardService, - folderService: folderService, - dataSourceCache: datasourceService.ProvideCacheService(cache, sqlStore, datasourceGuardian.ProvideGuardian()), - folderPermissions: folderPermissions, - dashboardPermissions: dashboardPermissions, - orgService: orgService, - legacyAlertNotificationService: legacyalerting.ProvideService(sqlStore, encryptionservice.SetupTestService(t), nil), - } -} diff --git a/pkg/services/ngalert/migration/template.go b/pkg/services/ngalert/migration/template.go deleted file mode 100644 index a6823d8c81615..0000000000000 --- a/pkg/services/ngalert/migration/template.go +++ /dev/null @@ -1,213 +0,0 @@ -// This file contains code that parses templates from old alerting into a sequence -// of tokens. Each token can be either a string literal or a variable. - -package migration - -import ( - "bytes" - "errors" - "fmt" - "strconv" - "strings" - "unicode" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/ngalert/state/template" -) - -// Token contains either a string literal or a variable. -type Token struct { - Literal string - Variable string -} - -func (t Token) IsLiteral() bool { - return t.Literal != "" -} - -func (t Token) IsVariable() bool { - return t.Variable != "" -} - -func (t Token) String() string { - if t.IsLiteral() { - return t.Literal - } else if t.IsVariable() { - return t.Variable - } else { - return "" - } -} - -func MigrateTmpl(l log.Logger, oldTmpl string) string { - var newTmpl string - - tokens := tokenizeTmpl(l, oldTmpl) - tokens = escapeLiterals(tokens) - - if anyVariableToken(tokens) { - tokens = variablesToMapLookups(tokens, "mergedLabels") - newTmpl += fmt.Sprintf("{{- $mergedLabels := %s $values -}}\n", template.MergeLabelValuesFuncName) - } - - newTmpl += tokensToTmpl(tokens) - return newTmpl -} - -func tokenizeTmpl(logger log.Logger, tmpl string) []Token { - var ( - tokens []Token - l int - r int - err error - ) - - in := []rune(tmpl) - for r < len(in) { - if !startVariable(in[r:]) { - r++ - continue - } - - token, offset, tokenErr := tokenizeVariable(in[r:]) - if tokenErr != nil { - err = errors.Join(err, tokenErr) - r += offset - continue - } - - // we've found a variable, so everything from l -> r is the literal before the variable - // ex: "foo ${bar}" -> Literal: "foo ", Variable: "bar" - if r > l { - tokens = append(tokens, Token{Literal: string(in[l:r])}) - } - tokens = append(tokens, token) - - // seek l and r past the variable - r += offset - l = r - } - - // any remaining runes will be a final literal - if r > l { - tokens = append(tokens, Token{Literal: string(in[l:r])}) - } - - if err != nil { - logger.Warn("Encountered malformed template", "template", tmpl, "err", err) - } - - return tokens -} - -func tokenizeVariable(in []rune) (Token, int, error) { - var ( - pos int - r rune - runes []rune - ) - - if !startVariable(in) { - return Token{}, pos, fmt.Errorf("expected '${', got '%s'", string(in[:2])) - } - pos += 2 // seek past opening delimiter - - // consume valid runes until we hit a closing brace - // non-space whitespace and the opening delimiter are invalid - for pos < len(in) { - r = in[pos] - - if unicode.IsSpace(r) && r != ' ' { - return Token{}, pos, errors.New("unexpected whitespace") - } - - if startVariable(in[pos:]) { - return Token{}, pos, errors.New("ambiguous delimiter") - } - - if r == '}' { - pos++ - break - } - - runes = append(runes, r) - pos++ - } - - // variable must end with '}' delimiter - if r != '}' { - return Token{}, pos, fmt.Errorf("expected '}', got '%c'", r) - } - - token := Token{Variable: string(runes)} - if !token.IsVariable() { - return Token{}, pos, errors.New("empty variable") - } - - return token, pos, nil -} - -func startVariable(in []rune) bool { - return len(in) >= 2 && in[0] == '$' && in[1] == '{' -} - -func anyVariableToken(tokens []Token) bool { - for _, token := range tokens { - if token.IsVariable() { - return true - } - } - return false -} - -// tokensToTmpl returns the tokens as a Go template -func tokensToTmpl(tokens []Token) string { - buf := bytes.Buffer{} - for _, token := range tokens { - if token.IsVariable() { - buf.WriteString("{{") - buf.WriteString(token.String()) - buf.WriteString("}}") - } else { - buf.WriteString(token.String()) - } - } - return buf.String() -} - -// escapeLiterals escapes any token literals with substrings that would be interpreted as Go template syntax -func escapeLiterals(tokens []Token) []Token { - result := make([]Token, 0, len(tokens)) - for _, token := range tokens { - if token.IsLiteral() && shouldEscape(token.Literal) { - token.Literal = fmt.Sprintf("{{`%s`}}", token.Literal) - } - result = append(result, token) - } - return result -} - -func shouldEscape(literal string) bool { - return strings.Contains(literal, "{{") || literal[len(literal)-1] == '{' -} - -// variablesToMapLookups converts any variables in a slice of tokens to Go template map lookups -func variablesToMapLookups(tokens []Token, mapName string) []Token { - result := make([]Token, 0, len(tokens)) - for _, token := range tokens { - if token.IsVariable() { - token.Variable = mapLookupString(token.Variable, mapName) - } - result = append(result, token) - } - return result -} - -func mapLookupString(v string, mapName string) string { - for _, r := range v { - if !(unicode.IsDigit(r) || unicode.IsLetter(r) || r == '_') { - return fmt.Sprintf(`index $%s %s`, mapName, strconv.Quote(v)) // quote v to escape any special characters - } - } - return fmt.Sprintf(`$%s.%s`, mapName, v) -} diff --git a/pkg/services/ngalert/migration/template_test.go b/pkg/services/ngalert/migration/template_test.go deleted file mode 100644 index 689ec1b95bcdc..0000000000000 --- a/pkg/services/ngalert/migration/template_test.go +++ /dev/null @@ -1,328 +0,0 @@ -package migration - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/grafana/grafana/pkg/infra/log" -) - -func TestTokenString(t *testing.T) { - t1 := Token{Literal: "this is a literal"} - assert.Equal(t, "this is a literal", t1.String()) - t2 := Token{Variable: "this is a variable"} - assert.Equal(t, "this is a variable", t2.String()) -} - -func TestTokenizeVariable(t *testing.T) { - tests := []struct { - name string - text string - token Token - offset int - err string - }{{ - name: "variable with no trailing text", - text: "${instance}", - token: Token{Variable: "instance"}, - offset: 11, - }, { - name: "variable with trailing text", - text: "${instance} is down", - token: Token{Variable: "instance"}, - offset: 11, - }, { - name: "varaiable with numbers", - text: "${instance1} is down", - token: Token{Variable: "instance1"}, - offset: 12, - }, { - name: "variable with underscores", - text: "${instance_with_underscores} is down", - token: Token{Variable: "instance_with_underscores"}, - offset: 28, - }, { - name: "variable with spaces", - text: "${instance with spaces} is down", - token: Token{Variable: "instance with spaces"}, - offset: 23, - }, { - name: "variable with non-reserved special character", - text: "${@instance1} is down", - token: Token{Variable: "@instance1"}, - offset: 13, - }, { - name: "two variables without spaces", - text: "${variable1}${variable2}", - token: Token{Variable: "variable1"}, - offset: 12, - }, { - name: "variable with two closing braces stops at first brace", - text: "${instance}} is down", - token: Token{Variable: "instance"}, - offset: 11, - }, { - name: "variable with newline", - text: "${instance\n} is down", - offset: 10, - err: "unexpected whitespace", - }, { - name: "variable with ambiguous delimiter returns error", - text: "${${instance}", - offset: 2, - err: "ambiguous delimiter", - }, { - name: "variable without closing brace returns error", - text: "${instance is down", - offset: 18, - err: "expected '}', got 'n'", - }} - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - token, offset, err := tokenizeVariable([]rune(test.text)) - if test.err != "" { - assert.EqualError(t, err, test.err) - } - assert.Equal(t, test.offset, offset) - assert.Equal(t, test.token, token) - }) - } -} - -func TestTokenizeTmpl(t *testing.T) { - tests := []struct { - name string - tmpl string - tokens []Token - }{{ - name: "simple template can be tokenized", - tmpl: "${instance} is down", - tokens: []Token{{Variable: "instance"}, {Literal: " is down"}}, - }, { - name: "complex template can be tokenized", - tmpl: "More than ${value} ${status_code} in the last 5 minutes", - tokens: []Token{ - {Literal: "More than "}, - {Variable: "value"}, - {Literal: " "}, - {Variable: "status_code"}, - {Literal: " in the last 5 minutes"}, - }, - }, { - name: "variables without spaces between can be tokenized", - tmpl: "${value}${status_code}", - tokens: []Token{{Variable: "value"}, {Variable: "status_code"}}, - }, { - name: "variables without spaces between then literal can be tokenized", - tmpl: "${value}${status_code} in the last 5 minutes", - tokens: []Token{{Variable: "value"}, {Variable: "status_code"}, {Literal: " in the last 5 minutes"}}, - }, { - name: "variables with reserved characters can be tokenized", - tmpl: "More than ${$value} ${{status_code} in the last 5 minutes", - tokens: []Token{ - {Literal: "More than "}, - {Variable: "$value"}, - {Literal: " "}, - {Variable: "{status_code"}, - {Literal: " in the last 5 minutes"}, - }, - }, { - name: "ambiguous delimiters are tokenized as literals", - tmpl: "Instance ${instance and ${instance} is down", - tokens: []Token{{Literal: "Instance ${instance and "}, {Variable: "instance"}, {Literal: " is down"}}, - }, { - name: "all '$' runes preceding a variable are included in literal", - tmpl: "Instance $${instance} is down", - tokens: []Token{{Literal: "Instance $"}, {Variable: "instance"}, {Literal: " is down"}}, - }, { - name: "sole '$' rune is included in literal", - tmpl: "Instance $instance and ${instance} is down", - tokens: []Token{{Literal: "Instance $instance and "}, {Variable: "instance"}, {Literal: " is down"}}, - }, { - name: "extra closing brace is included in literal", - tmpl: "Instance ${instance}} and ${instance} is down", - tokens: []Token{{Literal: "Instance "}, {Variable: "instance"}, {Literal: "} and "}, {Variable: "instance"}, {Literal: " is down"}}, - }, { - name: "variable with newline tokenized as literal", - tmpl: "${value}${status_code\n}${value} in the last 5 minutes", - tokens: []Token{{Variable: "value"}, {Literal: "${status_code\n}"}, {Variable: "value"}, {Literal: " in the last 5 minutes"}}, - }, { - name: "extra closing brace between variables is included in literal", - tmpl: "${value}${status_code}}${value} in the last 5 minutes", - tokens: []Token{{Variable: "value"}, {Variable: "status_code"}, {Literal: "}"}, {Variable: "value"}, {Literal: " in the last 5 minutes"}}, - }} - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - tokens := tokenizeTmpl(log.NewNopLogger(), test.tmpl) - assert.Equal(t, test.tokens, tokens) - }) - } -} - -func TestTokensToTmpl(t *testing.T) { - tokens := []Token{{Variable: "instance"}, {Literal: " is down"}} - assert.Equal(t, "{{instance}} is down", tokensToTmpl(tokens)) -} - -func TestTokensToTmplNewlines(t *testing.T) { - tokens := []Token{{Variable: "instance"}, {Literal: " is down\n"}, {Variable: "job"}, {Literal: " is down"}} - assert.Equal(t, "{{instance}} is down\n{{job}} is down", tokensToTmpl(tokens)) -} - -func TestMapLookupString(t *testing.T) { - cases := []struct { - name string - input string - expected string - }{ - { - name: "when there are no spaces", - input: "instance", - expected: "$labels.instance", - }, - { - name: "when there are spaces", - input: "instance with spaces", - expected: `index $labels "instance with spaces"`, - }, - { - name: "when there are quotes", - input: `instance with "quotes"`, - expected: `index $labels "instance with \"quotes\""`, - }, - { - name: "when there are backslashes", - input: `instance with \backslashes\`, - expected: `index $labels "instance with \\backslashes\\"`, - }, - { - name: "when there are legacy delimiter characters", - input: `instance{ with $delim} characters`, - expected: `index $labels "instance{ with $delim} characters"`, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.expected, mapLookupString(tc.input, "labels")) - }) - } -} - -func TestVariablesToMapLookups(t *testing.T) { - tokens := []Token{{Variable: "instance"}, {Literal: " is down"}} - expected := []Token{{Variable: "$labels.instance"}, {Literal: " is down"}} - assert.Equal(t, expected, variablesToMapLookups(tokens, "labels")) -} - -func TestVariablesToMapLookupsSpace(t *testing.T) { - tokens := []Token{{Variable: "instance with spaces"}, {Literal: " is down"}} - expected := []Token{{Variable: "index $labels \"instance with spaces\""}, {Literal: " is down"}} - assert.Equal(t, expected, variablesToMapLookups(tokens, "labels")) -} - -func TestEscapeLiterals(t *testing.T) { - cases := []struct { - name string - input []Token - expected []Token - }{ - { - name: "when there are no literals", - input: []Token{{Variable: "instance"}}, - expected: []Token{{Variable: "instance"}}, - }, - { - name: "literal with double braces: {{", - input: []Token{{Literal: "instance {{"}}, - expected: []Token{{Literal: "{{`instance {{`}}"}}, - }, - { - name: "literal that ends with closing brace: {", - input: []Token{{Literal: "instance {"}}, - expected: []Token{{Literal: "{{`instance {`}}"}}, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.expected, escapeLiterals(tc.input)) - }) - } -} - -func TestMigrateTmpl(t *testing.T) { - cases := []struct { - name string - input string - expected string - vars bool - }{ - { - name: "template does not contain variables", - input: "instance is down", - expected: "instance is down", - vars: false, - }, - { - name: "template contains variable", - input: "${instance} is down", - expected: withDeduplicateMap("{{$mergedLabels.instance}} is down"), - vars: true, - }, - { - name: "template contains double braces", - input: "{{CRITICAL}} instance is down", - expected: "{{`{{CRITICAL}} instance is down`}}", - vars: false, - }, - { - name: "template contains opening brace before variable", - input: `${${instance} is down`, - expected: withDeduplicateMap("{{`${`}}{{$mergedLabels.instance}} is down"), - vars: true, - }, - { - name: "template contains newline", - input: "CRITICAL\n${instance} is down", - expected: withDeduplicateMap("CRITICAL\n{{$mergedLabels.instance}} is down"), - vars: true, - }, - { - name: "partial migration, no variables", - input: "${instance is down", - expected: "${instance is down", - }, - { - name: "partial migration, with variables", - input: "${instance} is down ${${nestedVar}}", - expected: withDeduplicateMap("{{$mergedLabels.instance}}{{` is down ${`}}{{$mergedLabels.nestedVar}}}"), - vars: true, - }, - { - name: "edge cases", - input: "Test test 123 \n$(metric)\n${.}\n${}\n${Condition[0]}", - expected: withDeduplicateMap("Test test 123 \n$(metric)\n{{index $mergedLabels \".\"}}\n${}\n{{index $mergedLabels \"Condition[0]\"}}"), - vars: true, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - tmpl := MigrateTmpl(log.NewNopLogger(), tc.input) - - assert.Equal(t, tc.expected, tmpl) - }) - } -} - -func withDeduplicateMap(input string) string { - // hardcode function name to fail tests if it changes - funcName := "mergeLabelValues" - - return fmt.Sprintf("{{- $mergedLabels := %s $values -}}\n", funcName) + input -} diff --git a/pkg/services/ngalert/migration/testing.go b/pkg/services/ngalert/migration/testing.go deleted file mode 100644 index 73af30ed2113d..0000000000000 --- a/pkg/services/ngalert/migration/testing.go +++ /dev/null @@ -1,42 +0,0 @@ -package migration - -import ( - "context" - "testing" - - "github.com/grafana/grafana/pkg/infra/log/logtest" - "github.com/grafana/grafana/pkg/infra/serverlock" - "github.com/grafana/grafana/pkg/infra/tracing" - migrationStore "github.com/grafana/grafana/pkg/services/ngalert/migration/store" - fake_secrets "github.com/grafana/grafana/pkg/services/secrets/fakes" - "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/setting" -) - -func NewTestMigrationService(t *testing.T, sqlStore *sqlstore.SQLStore, cfg *setting.Cfg) *migrationService { - t.Helper() - if cfg == nil { - cfg = setting.NewCfg() - } - return &migrationService{ - lock: serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()), - log: &logtest.Fake{}, - cfg: cfg, - store: sqlStore, - migrationStore: migrationStore.NewTestMigrationStore(t, sqlStore, cfg), - encryptionService: fake_secrets.NewFakeSecretsService(), - } -} - -func NewFakeMigrationService(t testing.TB) *fakeMigrationService { - t.Helper() - return &fakeMigrationService{} -} - -type fakeMigrationService struct { -} - -func (ms *fakeMigrationService) Run(_ context.Context) error { - // Do nothing. - return nil -} diff --git a/pkg/services/ngalert/migration/ualert.go b/pkg/services/ngalert/migration/ualert.go deleted file mode 100644 index f44783828b308..0000000000000 --- a/pkg/services/ngalert/migration/ualert.go +++ /dev/null @@ -1,110 +0,0 @@ -package migration - -import ( - "context" - "fmt" - - legacymodels "github.com/grafana/grafana/pkg/services/alerting/models" - migmodels "github.com/grafana/grafana/pkg/services/ngalert/migration/models" - "github.com/grafana/grafana/pkg/services/ngalert/models" -) - -func (om *OrgMigration) migrateAlerts(ctx context.Context, alerts []*legacymodels.Alert, info migmodels.DashboardUpgradeInfo) ([]models.AlertRule, error) { - log := om.log.New( - "dashboardUid", info.DashboardUID, - "dashboardName", info.DashboardName, - "newFolderUid", info.NewFolderUID, - "newFolderNane", info.NewFolderName, - ) - - rules := make([]models.AlertRule, 0, len(alerts)) - for _, da := range alerts { - al := log.New("ruleID", da.ID, "ruleName", da.Name) - alertRule, err := om.migrateAlert(ctx, al, da, info) - if err != nil { - return nil, fmt.Errorf("migrate alert '%s': %w", da.Name, err) - } - rules = append(rules, *alertRule) - } - - return rules, nil -} - -func (om *OrgMigration) migrateDashboard(ctx context.Context, dashID int64, alerts []*legacymodels.Alert) ([]models.AlertRule, error) { - info, err := om.migratedFolder(ctx, om.log, dashID) - if err != nil { - return nil, fmt.Errorf("get or create migrated folder: %w", err) - } - rules, err := om.migrateAlerts(ctx, alerts, *info) - if err != nil { - return nil, fmt.Errorf("migrate and save alerts: %w", err) - } - - return rules, nil -} - -func (om *OrgMigration) migrateOrgAlerts(ctx context.Context) error { - mappedAlerts, cnt, err := om.migrationStore.GetOrgDashboardAlerts(ctx, om.orgID) - if err != nil { - return fmt.Errorf("load alerts: %w", err) - } - om.log.Info("Alerts found to migrate", "alerts", cnt) - - for dashID, alerts := range mappedAlerts { - rules, err := om.migrateDashboard(ctx, dashID, alerts) - if err != nil { - return fmt.Errorf("migrate and save dashboard '%d': %w", dashID, err) - } - - if len(rules) > 0 { - om.log.Debug("Inserting migrated alert rules", "count", len(rules)) - err := om.migrationStore.InsertAlertRules(ctx, rules...) - if err != nil { - return fmt.Errorf("insert alert rules: %w", err) - } - } - } - return nil -} - -func (om *OrgMigration) migrateOrgChannels(ctx context.Context) (*migmodels.Alertmanager, error) { - channels, err := om.migrationStore.GetNotificationChannels(ctx, om.orgID) - if err != nil { - return nil, fmt.Errorf("load notification channels: %w", err) - } - - // Cache for later use by alerts - om.channelCache.LoadChannels(channels) - - amConfig, err := om.migrateChannels(channels) - if err != nil { - return nil, err - } - return amConfig, nil -} - -func (om *OrgMigration) migrateOrg(ctx context.Context) error { - om.log.Info("Migrating alerts for organisation") - - amConfig, err := om.migrateOrgChannels(ctx) - if err != nil { - return fmt.Errorf("migrate channels: %w", err) - } - - err = om.migrateOrgAlerts(ctx) - if err != nil { - return fmt.Errorf("migrate alerts: %w", err) - } - - if err := om.writeSilencesFile(); err != nil { - return fmt.Errorf("write silence file for org %d: %w", om.orgID, err) - } - - if amConfig != nil { - if err := om.migrationStore.SaveAlertmanagerConfiguration(ctx, om.orgID, amConfig.Config); err != nil { - return err - } - } - - return nil -} diff --git a/pkg/services/ngalert/migration/ualert_test.go b/pkg/services/ngalert/migration/ualert_test.go deleted file mode 100644 index 0cea95ad99e7f..0000000000000 --- a/pkg/services/ngalert/migration/ualert_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package migration - -import ( - "encoding/json" - "fmt" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/folder" - apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" - "github.com/grafana/grafana/pkg/util" -) - -func Test_validateAlertmanagerConfig(t *testing.T) { - tc := []struct { - name string - receivers []*apimodels.PostableGrafanaReceiver - err error - }{ - { - name: "when a slack receiver does not have a valid URL - it should error", - receivers: []*apimodels.PostableGrafanaReceiver{ - { - UID: "test-uid", - Name: "SlackWithBadURL", - Type: "slack", - Settings: mustRawMessage(map[string]any{}), - SecureSettings: map[string]string{"url": invalidUri}, - }, - }, - err: fmt.Errorf("failed to validate integration \"SlackWithBadURL\" (UID test-uid) of type \"slack\": invalid URL %q", invalidUri), - }, - { - name: "when a slack receiver has an invalid recipient - it should not error", - receivers: []*apimodels.PostableGrafanaReceiver{ - { - UID: util.GenerateShortUID(), - Name: "SlackWithBadRecipient", - Type: "slack", - Settings: mustRawMessage(map[string]any{"recipient": "this passes"}), - SecureSettings: map[string]string{"url": "http://webhook.slack.com/myuser"}, - }, - }, - }, - { - name: "when the configuration is valid - it should not error", - receivers: []*apimodels.PostableGrafanaReceiver{ - { - UID: util.GenerateShortUID(), - Name: "SlackWithBadURL", - Type: "slack", - Settings: mustRawMessage(map[string]interface{}{"recipient": "#a-good-channel"}), - SecureSettings: map[string]string{"url": "http://webhook.slack.com/myuser"}, - }, - }, - }, - } - - sqlStore := db.InitTestDB(t) - for _, tt := range tc { - t.Run(tt.name, func(t *testing.T) { - service := NewTestMigrationService(t, sqlStore, nil) - mg := service.newOrgMigration(1) - - config := configFromReceivers(t, tt.receivers) - require.NoError(t, encryptSecureSettings(config, mg)) // make sure we encrypt the settings - err := mg.validateAlertmanagerConfig(config) - if tt.err != nil { - require.Error(t, err) - require.EqualError(t, err, tt.err.Error()) - } else { - require.NoError(t, err) - } - }) - } -} - -func configFromReceivers(t *testing.T, receivers []*apimodels.PostableGrafanaReceiver) *apimodels.PostableUserConfig { - t.Helper() - - return &apimodels.PostableUserConfig{ - AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ - Receivers: []*apimodels.PostableApiReceiver{ - {PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: receivers}}, - }, - }, - } -} - -func encryptSecureSettings(c *apimodels.PostableUserConfig, m *OrgMigration) error { - for _, r := range c.AlertmanagerConfig.Receivers { - for _, gr := range r.GrafanaManagedReceivers { - err := m.encryptSecureSettings(gr.SecureSettings) - if err != nil { - return err - } - } - } - return nil -} - -const invalidUri = "�6�M��)uk譹1(�h`$�o�N>mĕ����cS2�dh![ę� ���`csB�!��OSxP�{�" - -func Test_getAlertFolderNameFromDashboard(t *testing.T) { - t.Run("should include full title", func(t *testing.T) { - hash := util.GenerateShortUID() - f := &folder.Folder{ - Title: "TEST", - } - name := generateAlertFolderName(f, permissionHash(hash)) - require.Contains(t, name, f.Title) - require.Contains(t, name, hash) - }) - t.Run("should cut title to the length", func(t *testing.T) { - title := "" - for { - title += util.GenerateShortUID() - if len(title) > MaxFolderName { - title = title[:MaxFolderName] - break - } - } - - hash := util.GenerateShortUID() - f := &folder.Folder{ - Title: title, - } - name := generateAlertFolderName(f, permissionHash(hash)) - require.Len(t, name, MaxFolderName) - require.Contains(t, name, hash) - }) -} - -func mustRawMessage[T any](s T) apimodels.RawMessage { - js, _ := json.Marshal(s) - return js -} diff --git a/pkg/services/ngalert/models/alert_query.go b/pkg/services/ngalert/models/alert_query.go index 350335e989fa4..8d16005371c05 100644 --- a/pkg/services/ngalert/models/alert_query.go +++ b/pkg/services/ngalert/models/alert_query.go @@ -6,6 +6,8 @@ import ( "fmt" "time" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/expr" ) @@ -116,6 +118,28 @@ func (aq *AlertQuery) IsExpression() (bool, error) { return expr.NodeTypeFromDatasourceUID(aq.DatasourceUID) == expr.TypeCMDNode, nil } +// IsHysteresisExpression returns true if the model describes a hysteresis command expression. Returns error if the Model is not a valid JSON +func (aq *AlertQuery) IsHysteresisExpression() (bool, error) { + if aq.modelProps == nil { + err := aq.setModelProps() + if err != nil { + return false, err + } + } + return expr.IsHysteresisExpression(aq.modelProps), nil +} + +// PatchHysteresisExpression updates the AlertQuery to include loaded metrics into hysteresis +func (aq *AlertQuery) PatchHysteresisExpression(loadedMetrics map[data.Fingerprint]struct{}) error { + if aq.modelProps == nil { + err := aq.setModelProps() + if err != nil { + return err + } + } + return expr.SetLoadedDimensionsToHysteresisCommand(aq.modelProps, loadedMetrics) +} + // setMaxDatapoints sets the model maxDataPoints if it's missing or invalid func (aq *AlertQuery) setMaxDatapoints() error { if aq.modelProps == nil { diff --git a/pkg/services/ngalert/models/alert_query_test.go b/pkg/services/ngalert/models/alert_query_test.go index b9fd6dc38f917..634b7cf016597 100644 --- a/pkg/services/ngalert/models/alert_query_test.go +++ b/pkg/services/ngalert/models/alert_query_test.go @@ -7,9 +7,11 @@ import ( "testing" "time" - "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/expr" ) func TestAlertQuery(t *testing.T) { @@ -17,6 +19,7 @@ func TestAlertQuery(t *testing.T) { desc string alertQuery AlertQuery expectedIsExpression bool + expectedIsHysteresis bool expectedDatasource string expectedMaxPoints int64 expectedIntervalMS int64 @@ -133,6 +136,64 @@ func TestAlertQuery(t *testing.T) { expectedMaxPoints: int64(defaultMaxDataPoints), expectedIntervalMS: int64(defaultIntervalMS), }, + { + desc: "given a query with threshold expression", + alertQuery: AlertQuery{ + RefID: "A", + DatasourceUID: expr.DatasourceType, + Model: json.RawMessage(`{ + "type": "threshold", + "queryType": "metricQuery", + "extraParam": "some text", + "conditions": [ + { + "evaluator": { + "params": [ + 4 + ], + "type": "gt" + } + } + ] + }`), + }, + expectedIsExpression: true, + expectedIsHysteresis: false, + expectedMaxPoints: int64(defaultMaxDataPoints), + expectedIntervalMS: int64(defaultIntervalMS), + }, + { + desc: "given a query with hysteresis expression", + alertQuery: AlertQuery{ + RefID: "A", + DatasourceUID: expr.DatasourceType, + Model: json.RawMessage(`{ + "type": "threshold", + "queryType": "metricQuery", + "extraParam": "some text", + "conditions": [ + { + "evaluator": { + "params": [ + 4 + ], + "type": "gt" + }, + "unloadEvaluator": { + "params": [ + 2 + ], + "type": "lt" + } + } + ] + }`), + }, + expectedMaxPoints: int64(defaultMaxDataPoints), + expectedIntervalMS: int64(defaultIntervalMS), + expectedIsExpression: true, + expectedIsHysteresis: true, + }, } for _, tc := range testCases { @@ -143,6 +204,12 @@ func TestAlertQuery(t *testing.T) { assert.Equal(t, tc.expectedIsExpression, isExpression) }) + t.Run("can recognize if it's a hysteresis expression", func(t *testing.T) { + isExpression, err := tc.alertQuery.IsHysteresisExpression() + require.NoError(t, err) + assert.Equal(t, tc.expectedIsHysteresis, isExpression) + }) + t.Run("can set queryType for expression", func(t *testing.T) { err := tc.alertQuery.setQueryType() require.NoError(t, err) @@ -186,6 +253,17 @@ func TestAlertQuery(t *testing.T) { require.True(t, ok) require.Equal(t, "some text", extraParam) }) + + if tc.expectedIsHysteresis { + t.Run("can patch the command with loaded metrics", func(t *testing.T) { + require.NoError(t, tc.alertQuery.PatchHysteresisExpression(map[data.Fingerprint]struct{}{1: {}, 2: {}, 3: {}})) + data, ok := tc.alertQuery.modelProps["conditions"].([]any)[0].(map[string]any)["loadedDimensions"] + require.True(t, ok) + require.NotNil(t, data) + _, err := tc.alertQuery.GetModel() + require.NoError(t, err) + }) + } }) } } diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 9b47b51934606..320d29386b55c 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -7,6 +7,7 @@ import ( "fmt" "sort" "strconv" + "strings" "time" "github.com/google/go-cmp/cmp" @@ -27,7 +28,7 @@ var ( ErrCannotEditNamespace = errors.New("user does not have permissions to edit the namespace") ErrRuleGroupNamespaceNotFound = errors.New("rule group not found under this namespace") ErrAlertRuleFailedValidation = errors.New("invalid alert rule") - ErrAlertRuleUniqueConstraintViolation = errors.New("a conflicting alert rule is found: rule title under the same organisation and folder should be unique") + ErrAlertRuleUniqueConstraintViolation = errors.New("rule title under the same organisation and folder should be unique") ErrQuotaReached = errors.New("quota has been exceeded") // ErrNoDashboard is returned when the alert rule does not have a Dashboard UID // in its annotations or the dashboard does not exist. @@ -53,6 +54,8 @@ func NoDataStateFromString(state string) (NoDataState, error) { return NoData, nil case string(OK): return OK, nil + case string(KeepLast): + return KeepLast, nil default: return "", fmt.Errorf("unknown NoData state option %s", state) } @@ -62,6 +65,7 @@ const ( Alerting NoDataState = "Alerting" NoData NoDataState = "NoData" OK NoDataState = "OK" + KeepLast NoDataState = "KeepLast" ) // swagger:enum ExecutionErrorState @@ -79,6 +83,8 @@ func ErrStateFromString(opt string) (ExecutionErrorState, error) { return ErrorErrState, nil case string(OkErrState): return OkErrState, nil + case string(KeepLastErrState): + return KeepLastErrState, nil default: return "", fmt.Errorf("unknown Error state option %s", opt) } @@ -88,6 +94,7 @@ const ( AlertingErrState ExecutionErrorState = "Alerting" ErrorErrState ExecutionErrorState = "Error" OkErrState ExecutionErrorState = "OK" + KeepLastErrState ExecutionErrorState = "KeepLast" ) const ( @@ -112,10 +119,22 @@ const ( MigratedUseLegacyChannelsLabel = MigratedLabelPrefix + "use_channels__" // MigratedContactLabelPrefix is created during legacy migration to route a migrated alert rule to a specific migrated channel. MigratedContactLabelPrefix = MigratedLabelPrefix + "c_" + // MigratedSilenceLabelErrorKeepState is a label that will match a silence rule intended for legacy alerts with error state = keep_state. + MigratedSilenceLabelErrorKeepState = MigratedLabelPrefix + "silence_error_keep_state__" + // MigratedSilenceLabelNodataKeepState is a label that will match a silence rule intended for legacy alerts with nodata state = keep_state. + MigratedSilenceLabelNodataKeepState = MigratedLabelPrefix + "silence_nodata_keep_state__" // MigratedAlertIdAnnotation is created during legacy migration to store the ID of the migrated legacy alert rule. MigratedAlertIdAnnotation = "__alertId__" // MigratedMessageAnnotation is created during legacy migration to store the migrated alert message. MigratedMessageAnnotation = "message" + + // AutogeneratedRouteLabel a label name used to distinguish alerts that are supposed to be handled by the autogenerated policy. Only expected value is `true`. + AutogeneratedRouteLabel = "__grafana_autogenerated__" + // AutogeneratedRouteReceiverNameLabel a label name that contains the name of the receiver that should be used to send notifications for the alert. + AutogeneratedRouteReceiverNameLabel = "__grafana_receiver__" + // AutogeneratedRouteSettingsHashLabel a label name that contains the hash of the notification settings that will be used to send notifications for the alert. + // This should uniquely identify the notification settings (group_by, group_wait, group_interval, repeat_interval, mute_time_intervals) for the alert. + AutogeneratedRouteSettingsHashLabel = "__grafana_route_settings_hash__" ) const ( @@ -125,8 +144,13 @@ const ( StateReasonPaused = "Paused" StateReasonUpdated = "Updated" StateReasonRuleDeleted = "RuleDeleted" + StateReasonKeepLast = "KeepLast" ) +func ConcatReasons(reasons ...string) string { + return strings.Join(reasons, ", ") +} + var ( // InternalLabelNameSet are labels that grafana automatically include as part of the labelset. InternalLabelNameSet = map[string]struct{}{ @@ -138,6 +162,13 @@ var ( PanelIDAnnotation: {}, alertingModels.ImageTokenAnnotation: {}, } + + // LabelsUserCannotSpecify are labels that the user cannot specify when creating an alert rule. + LabelsUserCannotSpecify = map[string]struct{}{ + AutogeneratedRouteLabel: {}, + AutogeneratedRouteReceiverNameLabel: {}, + AutogeneratedRouteSettingsHashLabel: {}, + } ) // AlertRuleGroup is the base model for a rule group in unified alerting. @@ -213,10 +244,11 @@ type AlertRule struct { ExecErrState ExecutionErrorState // ideally this field should have been apimodels.ApiDuration // but this is currently not possible because of circular dependencies - For time.Duration - Annotations map[string]string - Labels map[string]string - IsPaused bool + For time.Duration + Annotations map[string]string + Labels map[string]string + IsPaused bool + NotificationSettings []NotificationSettings `xorm:"notification_settings"` // we use slice to workaround xorm mapping that does not serialize a struct to JSON unless it's a slice } // AlertRuleWithOptionals This is to avoid having to pass in additional arguments deep in the call stack. Alert rule @@ -310,13 +342,19 @@ func (alertRule *AlertRule) GetEvalCondition() Condition { // Diff calculates diff between two alert rules. Returns nil if two rules are equal. Otherwise, returns cmputil.DiffReport func (alertRule *AlertRule) Diff(rule *AlertRule, ignore ...string) cmputil.DiffReport { var reporter cmputil.DiffReporter - ops := make([]cmp.Option, 0, 5) + ops := make([]cmp.Option, 0, 6) // json.RawMessage is a slice of bytes and therefore cmp's default behavior is to compare it by byte, which is not really useful var jsonCmp = cmp.Transformer("", func(in json.RawMessage) string { return string(in) }) - ops = append(ops, cmp.Reporter(&reporter), cmpopts.IgnoreFields(AlertQuery{}, "modelProps"), jsonCmp, cmpopts.EquateEmpty()) + ops = append( + ops, + cmp.Reporter(&reporter), + cmpopts.IgnoreFields(AlertQuery{}, "modelProps"), + jsonCmp, + cmpopts.EquateEmpty(), + ) if len(ignore) > 0 { ops = append(ops, cmpopts.IgnoreFields(AlertRule{}, ignore...)) @@ -364,11 +402,6 @@ type AlertRuleKeyWithVersion struct { AlertRuleKey `xorm:"extends"` } -type AlertRuleKeyWithVersionAndPauseStatus struct { - IsPaused bool - AlertRuleKeyWithVersion `xorm:"extends"` -} - type AlertRuleKeyWithId struct { AlertRuleKey ID int64 @@ -468,6 +501,23 @@ func (alertRule *AlertRule) ValidateAlertRule(cfg setting.UnifiedAlertingSetting if alertRule.For < 0 { return fmt.Errorf("%w: field `for` cannot be negative", ErrAlertRuleFailedValidation) } + + if len(alertRule.Labels) > 0 { + for label := range alertRule.Labels { + if _, ok := LabelsUserCannotSpecify[label]; ok { + return fmt.Errorf("%w: system reserved label %s cannot be defined", ErrAlertRuleFailedValidation, label) + } + } + } + + if len(alertRule.NotificationSettings) > 0 { + if len(alertRule.NotificationSettings) != 1 { + return fmt.Errorf("%w: only one notification settings entry is allowed", ErrAlertRuleFailedValidation) + } + if err := alertRule.NotificationSettings[0].Validate(); err != nil { + return errors.Join(ErrAlertRuleFailedValidation, fmt.Errorf("invalid notification settings: %w", err)) + } + } return nil } @@ -483,6 +533,13 @@ func (alertRule *AlertRule) ResourceOrgID() int64 { return alertRule.OrgID } +func (alertRule *AlertRule) GetFolderKey() FolderKey { + return FolderKey{ + OrgID: alertRule.OrgID, + UID: alertRule.NamespaceUID, + } +} + // AlertRuleVersion is the model for alert rule versions in unified alerting. type AlertRuleVersion struct { ID int64 `xorm:"pk autoincr 'id'"` @@ -504,10 +561,11 @@ type AlertRuleVersion struct { ExecErrState ExecutionErrorState // ideally this field should have been apimodels.ApiDuration // but this is currently not possible because of circular dependencies - For time.Duration - Annotations map[string]string - Labels map[string]string - IsPaused bool + For time.Duration + Annotations map[string]string + Labels map[string]string + IsPaused bool + NotificationSettings []NotificationSettings `xorm:"notification_settings"` // we use slice to workaround xorm mapping that does not serialize a struct to JSON unless it's a slice } // GetAlertRuleByUIDQuery is the query for retrieving/deleting an alert rule by UID and organisation ID. @@ -533,6 +591,8 @@ type ListAlertRulesQuery struct { // to return just those for a dashboard and panel. DashboardUID string PanelID int64 + + ReceiverName string } // CountAlertRulesQuery is the query for counting alert rules @@ -541,12 +601,22 @@ type CountAlertRulesQuery struct { NamespaceUID string } +type FolderKey struct { + OrgID int64 + UID string +} + +func (f FolderKey) String() string { + return fmt.Sprintf("%d:%s", f.OrgID, f.UID) +} + type GetAlertRulesForSchedulingQuery struct { PopulateFolders bool RuleGroups []string - ResultRules []*AlertRule - ResultFoldersTitles map[string]string + ResultRules []*AlertRule + // A map of folder UID to folder Title in NamespaceKey format (see GetNamespaceKey) + ResultFoldersTitles map[FolderKey]string } // ListNamespaceAlertRulesQuery is the query for listing namespace alert rules diff --git a/pkg/services/ngalert/models/alert_rule_test.go b/pkg/services/ngalert/models/alert_rule_test.go index b8c75c2ab3dc2..32836ba4d6726 100644 --- a/pkg/services/ngalert/models/alert_rule_test.go +++ b/pkg/services/ngalert/models/alert_rule_test.go @@ -4,17 +4,21 @@ import ( "encoding/json" "fmt" "math/rand" + "reflect" "sort" "strings" "testing" "time" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/util/cmputil" ) func TestSortAlertRulesByGroupKeyAndIndex(t *testing.T) { @@ -384,7 +388,7 @@ func TestDiff(t *testing.T) { rule1 := AlertRuleGen()() rule2 := AlertRuleGen()() - diffs := rule1.Diff(rule2, "Data", "Annotations", "Labels") // these fields will be tested separately + diffs := rule1.Diff(rule2, "Data", "Annotations", "Labels", "NotificationSettings") // these fields will be tested separately difCnt := 0 if rule1.ID != rule2.ID { @@ -678,6 +682,117 @@ func TestDiff(t *testing.T) { } }) }) + + t.Run("should detect changes in NotificationSettings", func(t *testing.T) { + rule1 := AlertRuleGen()() + + baseSettings := NotificationSettingsGen(NSMuts.WithGroupBy("test1", "test2"))() + rule1.NotificationSettings = []NotificationSettings{baseSettings} + + addTime := func(d *model.Duration, duration time.Duration) *time.Duration { + dur := time.Duration(*d) + dur += duration + return &dur + } + + testCases := []struct { + name string + notificationSettings NotificationSettings + diffs cmputil.DiffReport + }{ + { + name: "should detect changes in Receiver", + notificationSettings: CopyNotificationSettings(baseSettings, NSMuts.WithReceiver(baseSettings.Receiver+"-modified")), + diffs: []cmputil.Diff{ + { + Path: "NotificationSettings[0].Receiver", + Left: reflect.ValueOf(baseSettings.Receiver), + Right: reflect.ValueOf(baseSettings.Receiver + "-modified"), + }, + }, + }, + { + name: "should detect changes in GroupWait", + notificationSettings: CopyNotificationSettings(baseSettings, NSMuts.WithGroupWait(addTime(baseSettings.GroupWait, 1*time.Second))), + diffs: []cmputil.Diff{ + { + Path: "NotificationSettings[0].GroupWait", + Left: reflect.ValueOf(*baseSettings.GroupWait), + Right: reflect.ValueOf(model.Duration(*addTime(baseSettings.GroupWait, 1*time.Second))), + }, + }, + }, + { + name: "should detect changes in GroupInterval", + notificationSettings: CopyNotificationSettings(baseSettings, NSMuts.WithGroupInterval(addTime(baseSettings.GroupInterval, 1*time.Second))), + diffs: []cmputil.Diff{ + { + Path: "NotificationSettings[0].GroupInterval", + Left: reflect.ValueOf(*baseSettings.GroupInterval), + Right: reflect.ValueOf(model.Duration(*addTime(baseSettings.GroupInterval, 1*time.Second))), + }, + }, + }, + { + name: "should detect changes in RepeatInterval", + notificationSettings: CopyNotificationSettings(baseSettings, NSMuts.WithRepeatInterval(addTime(baseSettings.RepeatInterval, 1*time.Second))), + diffs: []cmputil.Diff{ + { + Path: "NotificationSettings[0].RepeatInterval", + Left: reflect.ValueOf(*baseSettings.RepeatInterval), + Right: reflect.ValueOf(model.Duration(*addTime(baseSettings.RepeatInterval, 1*time.Second))), + }, + }, + }, + { + name: "should detect changes in GroupBy", + notificationSettings: CopyNotificationSettings(baseSettings, NSMuts.WithGroupBy(baseSettings.GroupBy[0]+"-modified", baseSettings.GroupBy[1]+"-modified")), + diffs: []cmputil.Diff{ + { + Path: "NotificationSettings[0].GroupBy[0]", + Left: reflect.ValueOf(baseSettings.GroupBy[0]), + Right: reflect.ValueOf(baseSettings.GroupBy[0] + "-modified"), + }, + { + Path: "NotificationSettings[0].GroupBy[1]", + Left: reflect.ValueOf(baseSettings.GroupBy[1]), + Right: reflect.ValueOf(baseSettings.GroupBy[1] + "-modified"), + }, + }, + }, + { + name: "should detect changes in MuteTimeIntervals", + notificationSettings: CopyNotificationSettings(baseSettings, NSMuts.WithMuteTimeIntervals(baseSettings.MuteTimeIntervals[0]+"-modified", baseSettings.MuteTimeIntervals[1]+"-modified")), + diffs: []cmputil.Diff{ + { + Path: "NotificationSettings[0].MuteTimeIntervals[0]", + Left: reflect.ValueOf(baseSettings.MuteTimeIntervals[0]), + Right: reflect.ValueOf(baseSettings.MuteTimeIntervals[0] + "-modified"), + }, + { + Path: "NotificationSettings[0].MuteTimeIntervals[1]", + Left: reflect.ValueOf(baseSettings.MuteTimeIntervals[1]), + Right: reflect.ValueOf(baseSettings.MuteTimeIntervals[1] + "-modified"), + }, + }, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + rule2 := CopyRule(rule1) + rule2.NotificationSettings = []NotificationSettings{tt.notificationSettings} + diffs := rule1.Diff(rule2) + + cOpt := []cmp.Option{ + cmpopts.IgnoreUnexported(cmputil.Diff{}), + } + if !cmp.Equal(diffs, tt.diffs, cOpt...) { + t.Errorf("Unexpected Diffs: %v", cmp.Diff(diffs, tt.diffs, cOpt...)) + } + }) + } + }) } func TestSortByGroupIndex(t *testing.T) { diff --git a/pkg/services/ngalert/models/errors.go b/pkg/services/ngalert/models/errors.go new file mode 100644 index 0000000000000..7f0e6cf1cab25 --- /dev/null +++ b/pkg/services/ngalert/models/errors.go @@ -0,0 +1,16 @@ +package models + +import ( + "github.com/grafana/grafana/pkg/util/errutil" +) + +var ( + errAlertRuleConflictMsg = "conflicting alert rule found [rule_uid: '{{ .Public.RuleUID }}', title: '{{ .Public.Title }}', namespace_uid: '{{ .Public.NamespaceUID }}']: {{ .Public.Error }}" + ErrAlertRuleConflictBase = errutil.Conflict("alerting.alert-rule.conflict"). + MustTemplate(errAlertRuleConflictMsg, errutil.WithPublic(errAlertRuleConflictMsg)) + ErrAlertRuleGroupNotFound = errutil.NotFound("alerting.alert-rule.notFound") +) + +func ErrAlertRuleConflict(rule AlertRule, underlying error) error { + return ErrAlertRuleConflictBase.Build(errutil.TemplateData{Public: map[string]any{"RuleUID": rule.UID, "Title": rule.Title, "NamespaceUID": rule.NamespaceUID, "Error": underlying.Error()}, Error: underlying}) +} diff --git a/pkg/services/ngalert/models/instance.go b/pkg/services/ngalert/models/instance.go index 9a86d1ce3ba72..ea731dd950b93 100644 --- a/pkg/services/ngalert/models/instance.go +++ b/pkg/services/ngalert/models/instance.go @@ -14,6 +14,7 @@ type AlertInstance struct { CurrentStateSince time.Time CurrentStateEnd time.Time LastEvalTime time.Time + ResultFingerprint string } type AlertInstanceKey struct { diff --git a/pkg/services/ngalert/models/notifications.go b/pkg/services/ngalert/models/notifications.go new file mode 100644 index 0000000000000..3271eb6d0ba09 --- /dev/null +++ b/pkg/services/ngalert/models/notifications.go @@ -0,0 +1,167 @@ +package models + +import ( + "encoding/binary" + "errors" + "fmt" + "hash/fnv" + "slices" + "unsafe" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/prometheus/common/model" +) + +// groupByAll is a special value defined by alertmanager that can be used in a Route's GroupBy field to aggregate by all possible labels. +const groupByAll = "..." + +type ListNotificationSettingsQuery struct { + OrgID int64 + ReceiverName string +} + +// NotificationSettings represents the settings for sending notifications for a single AlertRule. It is used to +// automatically generate labels and an associated matching route containing the given settings. +type NotificationSettings struct { + Receiver string `json:"receiver"` + + GroupBy []string `json:"group_by,omitempty"` + GroupWait *model.Duration `json:"group_wait,omitempty"` + GroupInterval *model.Duration `json:"group_interval,omitempty"` + RepeatInterval *model.Duration `json:"repeat_interval,omitempty"` + MuteTimeIntervals []string `json:"mute_time_intervals,omitempty"` +} + +// Validate checks if the NotificationSettings object is valid. +// It returns an error if any of the validation checks fail. +// The receiver must be specified. +// If GroupBy is not empty, it must contain both model.AlertNameLabel and FolderTitleLabel or the special label '...'. +// GroupWait, GroupInterval, RepeatInterval must be positive durations. +func (s *NotificationSettings) Validate() error { + if s.Receiver == "" { + return errors.New("receiver must be specified") + } + if len(s.GroupBy) > 0 { + alertName, folderTitle := false, false + for _, lbl := range s.GroupBy { + if lbl == groupByAll { + alertName, folderTitle = true, true + break + } + if lbl == model.AlertNameLabel { + alertName = true + } + if lbl == FolderTitleLabel { + folderTitle = true + } + } + if !alertName || !folderTitle { + return fmt.Errorf("group by override must contain two required labels: '%s' and '%s' or '...' (group by all)", model.AlertNameLabel, FolderTitleLabel) + } + } + if s.GroupWait != nil && *s.GroupWait < 0 { + return errors.New("group wait must be a positive duration") + } + if s.GroupInterval != nil && *s.GroupInterval < 0 { + return errors.New("group interval must be a positive duration") + } + if s.RepeatInterval != nil && *s.RepeatInterval < 0 { + return errors.New("repeat interval must be a positive duration") + } + return nil +} + +// ToLabels converts the NotificationSettings into data.Labels. When added to an AlertRule these labels ensure it will +// match an autogenerated route with the correct settings. +// Labels returned: +// - AutogeneratedRouteLabel: "true" +// - AutogeneratedRouteReceiverNameLabel: Receiver +// - AutogeneratedRouteSettingsHashLabel: Fingerprint (if the NotificationSettings are not all default) +func (s *NotificationSettings) ToLabels() data.Labels { + result := make(data.Labels, 3) + result[AutogeneratedRouteLabel] = "true" + result[AutogeneratedRouteReceiverNameLabel] = s.Receiver + if !s.IsAllDefault() { + result[AutogeneratedRouteSettingsHashLabel] = s.Fingerprint().String() + } + return result +} + +func (s *NotificationSettings) Equals(other *NotificationSettings) bool { + durationEqual := func(d1, d2 *model.Duration) bool { + if d1 == nil || d2 == nil { + return d1 == d2 + } + return *d1 == *d2 + } + if s == nil || other == nil { + return s == nil && other == nil + } + if s.Receiver != other.Receiver { + return false + } + if !durationEqual(s.GroupWait, other.GroupWait) { + return false + } + if !durationEqual(s.GroupInterval, other.GroupInterval) { + return false + } + if !durationEqual(s.RepeatInterval, other.RepeatInterval) { + return false + } + if !slices.Equal(s.MuteTimeIntervals, other.MuteTimeIntervals) { + return false + } + sGr := s.GroupBy + oGr := other.GroupBy + return slices.Equal(sGr, oGr) +} + +// IsAllDefault checks if the NotificationSettings object has all default values for optional fields (all except Receiver) . +func (s *NotificationSettings) IsAllDefault() bool { + return len(s.GroupBy) == 0 && s.GroupWait == nil && s.GroupInterval == nil && s.RepeatInterval == nil && len(s.MuteTimeIntervals) == 0 +} + +// NewDefaultNotificationSettings creates a new default NotificationSettings with the specified receiver. +func NewDefaultNotificationSettings(receiver string) NotificationSettings { + return NotificationSettings{ + Receiver: receiver, + } +} + +// Fingerprint calculates a hash value to uniquely identify a NotificationSettings by its attributes. +// The hash is calculated by concatenating the strings and durations of the NotificationSettings attributes +// and using an invalid UTF-8 sequence as a separator. +func (s *NotificationSettings) Fingerprint() data.Fingerprint { + h := fnv.New64() + tmp := make([]byte, 8) + + writeString := func(s string) { + // save on extra slice allocation when string is converted to bytes. + _, _ = h.Write(unsafe.Slice(unsafe.StringData(s), len(s))) //nolint:gosec + // ignore errors returned by Write method because fnv never returns them. + _, _ = h.Write([]byte{255}) // use an invalid utf-8 sequence as separator + } + writeDuration := func(d *model.Duration) { + if d == nil { + _, _ = h.Write([]byte{255}) + } else { + binary.LittleEndian.PutUint64(tmp, uint64(*d)) + _, _ = h.Write(tmp) + _, _ = h.Write([]byte{255}) + } + } + + writeString(s.Receiver) + // TODO: Should we sort the group by labels? + for _, gb := range s.GroupBy { + writeString(gb) + } + writeDuration(s.GroupWait) + writeDuration(s.GroupInterval) + writeDuration(s.RepeatInterval) + for _, interval := range s.MuteTimeIntervals { + writeString(interval) + } + return data.Fingerprint(h.Sum64()) +} diff --git a/pkg/services/ngalert/models/notifications_test.go b/pkg/services/ngalert/models/notifications_test.go new file mode 100644 index 0000000000000..34cd1b034c7d8 --- /dev/null +++ b/pkg/services/ngalert/models/notifications_test.go @@ -0,0 +1,145 @@ +package models + +import ( + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/util" +) + +func TestValidate(t *testing.T) { + validNotificationSettings := NotificationSettingsGen(NSMuts.WithGroupBy(model.AlertNameLabel, FolderTitleLabel)) + + testCases := []struct { + name string + notificationSettings NotificationSettings + expErrorContains string + }{ + { + name: "valid notification settings", + notificationSettings: validNotificationSettings(), + }, + { + name: "missing receiver is invalid", + notificationSettings: CopyNotificationSettings(validNotificationSettings(), NSMuts.WithReceiver("")), + expErrorContains: "receiver", + }, + { + name: "group by empty is valid", + notificationSettings: CopyNotificationSettings(validNotificationSettings(), NSMuts.WithGroupBy()), + }, + { + name: "group by ... is valid", + notificationSettings: CopyNotificationSettings(validNotificationSettings(), NSMuts.WithGroupBy("...")), + }, + { + name: "group by with alert name and folder name labels is valid", + notificationSettings: CopyNotificationSettings(validNotificationSettings(), NSMuts.WithGroupBy(model.AlertNameLabel, FolderTitleLabel)), + }, + { + name: "group by missing alert name label is invalid", + notificationSettings: CopyNotificationSettings(validNotificationSettings(), NSMuts.WithGroupBy(FolderTitleLabel)), + expErrorContains: model.AlertNameLabel, + }, + { + name: "group by missing folder name label is invalid", + notificationSettings: CopyNotificationSettings(validNotificationSettings(), NSMuts.WithGroupBy(model.AlertNameLabel)), + expErrorContains: FolderTitleLabel, + }, + { + name: "group wait empty is valid", + notificationSettings: CopyNotificationSettings(validNotificationSettings(), NSMuts.WithGroupWait(nil)), + }, + { + name: "group wait positive is valid", + notificationSettings: CopyNotificationSettings(validNotificationSettings(), NSMuts.WithGroupWait(util.Pointer(1*time.Second))), + }, + { + name: "group wait negative is invalid", + notificationSettings: CopyNotificationSettings(validNotificationSettings(), NSMuts.WithGroupWait(util.Pointer(-1*time.Second))), + expErrorContains: "group wait", + }, + { + name: "group interval empty is valid", + notificationSettings: CopyNotificationSettings(validNotificationSettings(), NSMuts.WithGroupInterval(nil)), + }, + { + name: "group interval positive is valid", + notificationSettings: CopyNotificationSettings(validNotificationSettings(), NSMuts.WithGroupInterval(util.Pointer(1*time.Second))), + }, + { + name: "group interval negative is invalid", + notificationSettings: CopyNotificationSettings(validNotificationSettings(), NSMuts.WithGroupInterval(util.Pointer(-1*time.Second))), + expErrorContains: "group interval", + }, + { + name: "repeat interval empty is valid", + notificationSettings: CopyNotificationSettings(validNotificationSettings(), NSMuts.WithRepeatInterval(nil)), + }, + { + name: "repeat interval positive is valid", + notificationSettings: CopyNotificationSettings(validNotificationSettings(), NSMuts.WithRepeatInterval(util.Pointer(1*time.Second))), + }, + { + name: "repeat interval negative is invalid", + notificationSettings: CopyNotificationSettings(validNotificationSettings(), NSMuts.WithRepeatInterval(util.Pointer(-1*time.Second))), + expErrorContains: "repeat interval", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + err := tt.notificationSettings.Validate() + if tt.expErrorContains != "" { + require.Error(t, err) + require.ErrorContains(t, err, tt.expErrorContains) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestNotificationSettingsLabels(t *testing.T) { + testCases := []struct { + name string + notificationSettings NotificationSettings + labels data.Labels + }{ + { + name: "default notification settings", + notificationSettings: NewDefaultNotificationSettings("receiver name"), + labels: data.Labels{ + AutogeneratedRouteLabel: "true", + AutogeneratedRouteReceiverNameLabel: "receiver name", + }, + }, + { + name: "custom notification settings", + notificationSettings: NotificationSettings{ + Receiver: "receiver name", + GroupBy: []string{"label1", "label2"}, + GroupWait: util.Pointer(model.Duration(1 * time.Minute)), + GroupInterval: util.Pointer(model.Duration(2 * time.Minute)), + RepeatInterval: util.Pointer(model.Duration(3 * time.Minute)), + MuteTimeIntervals: []string{"maintenance1", "maintenance2"}, + }, + labels: data.Labels{ + AutogeneratedRouteLabel: "true", + AutogeneratedRouteReceiverNameLabel: "receiver name", + AutogeneratedRouteSettingsHashLabel: "f0e23250cefc4a31", + }, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + labels := tt.notificationSettings.ToLabels() + require.Equal(t, tt.labels, labels) + }) + } +} diff --git a/pkg/services/ngalert/models/receivers.go b/pkg/services/ngalert/models/receivers.go new file mode 100644 index 0000000000000..1497395eeec2c --- /dev/null +++ b/pkg/services/ngalert/models/receivers.go @@ -0,0 +1,17 @@ +package models + +// GetReceiverQuery represents a query for a single receiver. +type GetReceiverQuery struct { + OrgID int64 + Name string + Decrypt bool +} + +// GetReceiversQuery represents a query for receiver groups. +type GetReceiversQuery struct { + OrgID int64 + Names []string + Limit int + Offset int + Decrypt bool +} diff --git a/pkg/services/ngalert/models/testing.go b/pkg/services/ngalert/models/testing.go index bc00f4b49751e..71ffd9312a965 100644 --- a/pkg/services/ngalert/models/testing.go +++ b/pkg/services/ngalert/models/testing.go @@ -6,10 +6,13 @@ import ( "math/rand" "slices" "sync" + "testing" "time" "github.com/google/uuid" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/services/datasources" @@ -62,26 +65,32 @@ func AlertRuleGen(mutators ...AlertRuleMutator) func() *AlertRule { panelID = &p } + var ns []NotificationSettings + if rand.Int63()%2 == 0 { + ns = append(ns, NotificationSettingsGen()()) + } + rule := &AlertRule{ - ID: rand.Int63n(1500), - OrgID: rand.Int63n(1500) + 1, // Prevent OrgID=0 as this does not pass alert rule validation. - Title: "TEST-ALERT-" + util.GenerateShortUID(), - Condition: "A", - Data: []AlertQuery{GenerateAlertQuery()}, - Updated: time.Now().Add(-time.Duration(rand.Intn(100) + 1)), - IntervalSeconds: rand.Int63n(60) + 1, - Version: rand.Int63n(1500), // Don't generate a rule ID too big for postgres - UID: util.GenerateShortUID(), - NamespaceUID: util.GenerateShortUID(), - DashboardUID: dashUID, - PanelID: panelID, - RuleGroup: "TEST-GROUP-" + util.GenerateShortUID(), - RuleGroupIndex: rand.Intn(1500), - NoDataState: randNoDataState(), - ExecErrState: randErrState(), - For: forInterval, - Annotations: annotations, - Labels: labels, + ID: rand.Int63n(1500), + OrgID: rand.Int63n(1500) + 1, // Prevent OrgID=0 as this does not pass alert rule validation. + Title: "TEST-ALERT-" + util.GenerateShortUID(), + Condition: "A", + Data: []AlertQuery{GenerateAlertQuery()}, + Updated: time.Now().Add(-time.Duration(rand.Intn(100) + 1)), + IntervalSeconds: rand.Int63n(60) + 1, + Version: rand.Int63n(1500), // Don't generate a rule ID too big for postgres + UID: util.GenerateShortUID(), + NamespaceUID: util.GenerateShortUID(), + DashboardUID: dashUID, + PanelID: panelID, + RuleGroup: "TEST-GROUP-" + util.GenerateShortUID(), + RuleGroupIndex: rand.Intn(1500), + NoDataState: randNoDataState(), + ExecErrState: randErrState(), + For: forInterval, + Annotations: annotations, + Labels: labels, + NotificationSettings: ns, } for _, mutator := range mutators { @@ -184,6 +193,12 @@ func WithInterval(interval time.Duration) AlertRuleMutator { } } +func WithIntervalBetween(min, max int64) AlertRuleMutator { + return func(rule *AlertRule) { + rule.IntervalSeconds = rand.Int63n(max-min) + min + } +} + func WithTitle(title string) AlertRuleMutator { return func(rule *AlertRule) { rule.Title = title @@ -258,6 +273,20 @@ func WithUniqueUID(knownUids *sync.Map) AlertRuleMutator { } } +func WithUniqueTitle(knownTitles *sync.Map) AlertRuleMutator { + return func(rule *AlertRule) { + title := rule.Title + for { + _, ok := knownTitles.LoadOrStore(title, struct{}{}) + if !ok { + rule.Title = title + return + } + title = uuid.NewString() + } + } +} + func WithQuery(query ...AlertQuery) AlertRuleMutator { return func(rule *AlertRule) { rule.Data = query @@ -275,6 +304,18 @@ func WithGroupKey(groupKey AlertRuleGroupKey) AlertRuleMutator { } } +func WithNotificationSettingsGen(ns func() NotificationSettings) AlertRuleMutator { + return func(rule *AlertRule) { + rule.NotificationSettings = []NotificationSettings{ns()} + } +} + +func WithNoNotificationSettings() AlertRuleMutator { + return func(rule *AlertRule) { + rule.NotificationSettings = nil + } +} + func GenerateAlertLabels(count int, prefix string) data.Labels { labels := make(data.Labels, count) for i := 0; i < count; i++ { @@ -404,6 +445,10 @@ func CopyRule(r *AlertRule) *AlertRule { } } + for _, s := range r.NotificationSettings { + result.NotificationSettings = append(result.NotificationSettings, CopyNotificationSettings(s)) + } + return &result } @@ -511,6 +556,46 @@ func CreateLokiQuery(refID string, expr string, intervalMs int64, maxDataPoints } } +func CreateHysteresisExpression(t *testing.T, refID string, inputRefID string, threshold int, recoveryThreshold int) AlertQuery { + t.Helper() + q := AlertQuery{ + RefID: refID, + QueryType: expr.DatasourceType, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(fmt.Sprintf(` + { + "refId": "%[1]s", + "type": "threshold", + "datasource": { + "uid": "%[5]s", + "type": "%[6]s" + }, + "expression": "%[2]s", + "conditions": [ + { + "type": "query", + "evaluator": { + "params": [ + %[3]d + ], + "type": "gt" + }, + "unloadEvaluator": { + "params": [ + %[4]d + ], + "type": "lt" + } + } + ] + }`, refID, inputRefID, threshold, recoveryThreshold, expr.DatasourceUID, expr.DatasourceType)), + } + h, err := q.IsHysteresisExpression() + require.NoError(t, err) + require.Truef(t, h, "test model is expected to be a hysteresis expression") + return q +} + type AlertInstanceMutator func(*AlertInstance) // AlertInstanceGen provides a factory function that generates a random AlertInstance. @@ -553,3 +638,108 @@ func AlertInstanceGen(mutators ...AlertInstanceMutator) *AlertInstance { } return instance } + +type Mutator[T any] func(*T) + +// CopyNotificationSettings creates a deep copy of NotificationSettings. +func CopyNotificationSettings(ns NotificationSettings, mutators ...Mutator[NotificationSettings]) NotificationSettings { + c := NotificationSettings{ + Receiver: ns.Receiver, + } + if ns.GroupWait != nil { + c.GroupWait = util.Pointer(*ns.GroupWait) + } + if ns.GroupInterval != nil { + c.GroupInterval = util.Pointer(*ns.GroupInterval) + } + if ns.RepeatInterval != nil { + c.RepeatInterval = util.Pointer(*ns.RepeatInterval) + } + if ns.GroupBy != nil { + c.GroupBy = make([]string, len(ns.GroupBy)) + copy(c.GroupBy, ns.GroupBy) + } + if ns.MuteTimeIntervals != nil { + c.MuteTimeIntervals = make([]string, len(ns.MuteTimeIntervals)) + copy(c.MuteTimeIntervals, ns.MuteTimeIntervals) + } + for _, mutator := range mutators { + mutator(&c) + } + return c +} + +// NotificationSettingsGen generates NotificationSettings using a base and mutators. +func NotificationSettingsGen(mutators ...Mutator[NotificationSettings]) func() NotificationSettings { + return func() NotificationSettings { + c := NotificationSettings{ + Receiver: util.GenerateShortUID(), + GroupBy: []string{model.AlertNameLabel, FolderTitleLabel, util.GenerateShortUID()}, + GroupWait: util.Pointer(model.Duration(time.Duration(rand.Intn(100)+1) * time.Second)), + GroupInterval: util.Pointer(model.Duration(time.Duration(rand.Intn(100)+1) * time.Second)), + RepeatInterval: util.Pointer(model.Duration(time.Duration(rand.Intn(100)+1) * time.Second)), + MuteTimeIntervals: []string{util.GenerateShortUID(), util.GenerateShortUID()}, + } + for _, mutator := range mutators { + mutator(&c) + } + return c + } +} + +var ( + NSMuts = NotificationSettingsMutators{} +) + +type NotificationSettingsMutators struct{} + +func (n NotificationSettingsMutators) WithReceiver(receiver string) Mutator[NotificationSettings] { + return func(ns *NotificationSettings) { + ns.Receiver = receiver + } +} + +func (n NotificationSettingsMutators) WithGroupWait(groupWait *time.Duration) Mutator[NotificationSettings] { + return func(ns *NotificationSettings) { + if groupWait == nil { + ns.GroupWait = nil + return + } + dur := model.Duration(*groupWait) + ns.GroupWait = &dur + } +} + +func (n NotificationSettingsMutators) WithGroupInterval(groupInterval *time.Duration) Mutator[NotificationSettings] { + return func(ns *NotificationSettings) { + if groupInterval == nil { + ns.GroupInterval = nil + return + } + dur := model.Duration(*groupInterval) + ns.GroupInterval = &dur + } +} + +func (n NotificationSettingsMutators) WithRepeatInterval(repeatInterval *time.Duration) Mutator[NotificationSettings] { + return func(ns *NotificationSettings) { + if repeatInterval == nil { + ns.RepeatInterval = nil + return + } + dur := model.Duration(*repeatInterval) + ns.RepeatInterval = &dur + } +} + +func (n NotificationSettingsMutators) WithGroupBy(groupBy ...string) Mutator[NotificationSettings] { + return func(ns *NotificationSettings) { + ns.GroupBy = groupBy + } +} + +func (n NotificationSettingsMutators) WithMuteTimeIntervals(muteTimeIntervals ...string) Mutator[NotificationSettings] { + return func(ns *NotificationSettings) { + ns.MuteTimeIntervals = muteTimeIntervals + } +} diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index d05a5ee0d7cea..39714f9f6c60a 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -7,6 +7,8 @@ import ( "time" "github.com/benbjohnson/clock" + "github.com/prometheus/alertmanager/featurecontrol" + "github.com/prometheus/alertmanager/matchers/compat" "golang.org/x/sync/errgroup" "github.com/grafana/grafana/pkg/api/routing" @@ -24,12 +26,10 @@ import ( "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/ngalert/api" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/image" "github.com/grafana/grafana/pkg/services/ngalert/metrics" - "github.com/grafana/grafana/pkg/services/ngalert/migration" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/provisioning" @@ -71,10 +71,6 @@ func ProvideService( pluginsStore pluginstore.Store, tracer tracing.Tracer, ruleStore *store.DBstore, - upgradeService migration.UpgradeService, - - // This is necessary to ensure the guardian provider is initialized before we run the migration. - _ *guardian.Provider, ) (*AlertNG, error) { ng := &AlertNG{ Cfg: cfg, @@ -101,17 +97,9 @@ func ProvideService( pluginsStore: pluginsStore, tracer: tracer, store: ruleStore, - upgradeService: upgradeService, - } - - // Migration is called even if UA is disabled. If UA is disabled, this will do nothing except handle logic around - // reverting the migration. - err := ng.upgradeService.Run(context.Background()) - if err != nil { - return nil, err } - if !ng.shouldRun() { + if ng.IsDisabled() { return ng, nil } @@ -157,8 +145,6 @@ type AlertNG struct { bus bus.Bus pluginsStore pluginstore.Store tracer tracing.Tracer - - upgradeService migration.UpgradeService } func (ng *AlertNG) init() error { @@ -168,6 +154,12 @@ func (ng *AlertNG) init() error { ng.store.Logger = ng.Log + // This initializes the compat package in fallback mode with logging. It parses first + // using the UTF-8 parser and then fallsback to the classic parser on error. + // UTF-8 is permitted in label names. This should be removed when the compat package + // is removed from Alertmanager. + compat.InitFromFlags(ng.Log, featurecontrol.NoopFlags{}) + // If enabled, configure the remote Alertmanager. // - If several toggles are enabled, the order of precedence is RemoteOnly, RemotePrimary, RemoteSecondary // - If no toggles are enabled, we default to using only the internal Alertmanager @@ -185,6 +177,9 @@ func (ng *AlertNG) init() error { case remoteSecondary: ng.Log.Debug("Starting Grafana with remote secondary mode enabled") + m := ng.Metrics.GetRemoteAlertmanagerMetrics() + m.Info.WithLabelValues(metrics.ModeRemoteSecondary).Set(1) + // This function will be used by the MOA to create new Alertmanagers. override := notifier.WithAlertmanagerOverride(func(factoryFn notifier.OrgAlertmanagerFactory) notifier.OrgAlertmanagerFactory { return func(ctx context.Context, orgID int64) (notifier.Alertmanager, error) { @@ -195,7 +190,7 @@ func (ng *AlertNG) init() error { } // Create remote Alertmanager. - remoteAM, err := createRemoteAlertmanager(orgID, ng.Cfg.UnifiedAlerting.RemoteAlertmanager, ng.KVStore) + remoteAM, err := createRemoteAlertmanager(orgID, ng.Cfg.UnifiedAlerting.RemoteAlertmanager, ng.KVStore, m) if err != nil { moaLogger.Error("Failed to create remote Alertmanager, falling back to using only the internal one", "err", err) return internalAM, nil @@ -221,7 +216,7 @@ func (ng *AlertNG) init() error { decryptFn := ng.SecretsService.GetDecryptedValue multiOrgMetrics := ng.Metrics.GetMultiOrgAlertmanagerMetrics() - moa, err := notifier.NewMultiOrgAlertmanager(ng.Cfg, ng.store, ng.store, ng.KVStore, ng.store, decryptFn, multiOrgMetrics, ng.NotificationService, moaLogger, ng.SecretsService, overrides...) + moa, err := notifier.NewMultiOrgAlertmanager(ng.Cfg, ng.store, ng.store, ng.KVStore, ng.store, decryptFn, multiOrgMetrics, ng.NotificationService, moaLogger, ng.SecretsService, ng.FeatureToggles, overrides...) if err != nil { return err } @@ -263,6 +258,7 @@ func (ng *AlertNG) init() error { BaseInterval: ng.Cfg.UnifiedAlerting.BaseInterval, MinRuleInterval: ng.Cfg.UnifiedAlerting.MinInterval, DisableGrafanaFolder: ng.Cfg.UnifiedAlerting.ReservedLabels.IsReservedLabelDisabled(models.FolderTitleLabel), + JitterEvaluations: schedule.JitterStrategyFrom(ng.Cfg.UnifiedAlerting, ng.FeatureToggles), AppURL: appUrl, EvaluatorFactory: evalFactory, RuleStore: ng.store, @@ -287,12 +283,19 @@ func (ng *AlertNG) init() error { Clock: clk, Historian: history, DoNotSaveNormalState: ng.FeatureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingNoNormalState), - MaxStateSaveConcurrency: ng.Cfg.UnifiedAlerting.MaxStateSaveConcurrency, ApplyNoDataAndErrorToAllStates: ng.FeatureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingNoDataErrorExecution), + MaxStateSaveConcurrency: ng.Cfg.UnifiedAlerting.MaxStateSaveConcurrency, + RulesPerRuleGroupLimit: ng.Cfg.UnifiedAlerting.RulesPerRuleGroupLimit, Tracer: ng.tracer, Log: log.New("ngalert.state.manager"), } - stateManager := state.NewManager(cfg) + logger := log.New("ngalert.state.manager.persist") + statePersister := state.NewSyncStatePersisiter(logger, cfg) + if ng.FeatureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingSaveStatePeriodic) { + ticker := clock.New().Ticker(ng.Cfg.UnifiedAlerting.StatePeriodicSaveInterval) + statePersister = state.NewAsyncStatePersister(logger, ticker, cfg) + } + stateManager := state.NewManager(cfg, statePersister) scheduler := schedule.NewScheduler(schedCfg, stateManager) // if it is required to include folder title to the alerts, we need to subscribe to changes of alert title @@ -303,14 +306,17 @@ func (ng *AlertNG) init() error { ng.stateManager = stateManager ng.schedule = scheduler + receiverService := notifier.NewReceiverService(ng.accesscontrol, ng.store, ng.store, ng.SecretsService, ng.store, ng.Log) + // Provisioning policyService := provisioning.NewNotificationPolicyService(ng.store, ng.store, ng.store, ng.Cfg.UnifiedAlerting, ng.Log) - contactPointService := provisioning.NewContactPointService(ng.store, ng.SecretsService, ng.store, ng.store, ng.Log, ng.accesscontrol) + contactPointService := provisioning.NewContactPointService(ng.store, ng.SecretsService, ng.store, ng.store, receiverService, ng.Log, ng.store) templateService := provisioning.NewTemplateService(ng.store, ng.store, ng.store, ng.Log) muteTimingService := provisioning.NewMuteTimingService(ng.store, ng.store, ng.store, ng.Log) - alertRuleService := provisioning.NewAlertRuleService(ng.store, ng.store, ng.dashboardService, ng.QuotaService, ng.store, + alertRuleService := provisioning.NewAlertRuleService(ng.store, ng.store, ng.folderService, ng.dashboardService, ng.QuotaService, ng.store, int64(ng.Cfg.UnifiedAlerting.DefaultRuleEvaluationInterval.Seconds()), - int64(ng.Cfg.UnifiedAlerting.BaseInterval.Seconds()), ng.Log) + int64(ng.Cfg.UnifiedAlerting.BaseInterval.Seconds()), + ng.Cfg.UnifiedAlerting.RulesPerRuleGroupLimit, ng.Log, notifier.NewNotificationSettingsValidationService(ng.store)) ng.api = &api.API{ Cfg: ng.Cfg, @@ -328,6 +334,7 @@ func (ng *AlertNG) init() error { StateManager: ng.stateManager, AccessControl: ng.accesscontrol, Policies: policyService, + ReceiverService: receiverService, ContactPointService: contactPointService, Templates: templateService, MuteTimings: muteTimingService, @@ -371,16 +378,8 @@ func subscribeToFolderChanges(logger log.Logger, bus bus.Bus, dbStore api.RuleSt }) } -// shouldRun determines if AlertNG should init or run anything more than just the migration. -func (ng *AlertNG) shouldRun() bool { - return ng.Cfg.UnifiedAlerting.IsEnabled() -} - // Run starts the scheduler and Alertmanager. func (ng *AlertNG) Run(ctx context.Context) error { - if !ng.shouldRun() { - return nil - } ng.Log.Debug("Starting", "execute_alerts", ng.Cfg.UnifiedAlerting.ExecuteAlerts) children, subCtx := errgroup.WithContext(ctx) @@ -407,13 +406,20 @@ func (ng *AlertNG) Run(ctx context.Context) error { children.Go(func() error { return ng.schedule.Run(subCtx) }) + children.Go(func() error { + return ng.stateManager.Run(subCtx) + }) } return children.Wait() } // IsDisabled returns true if the alerting service is disabled for this instance. func (ng *AlertNG) IsDisabled() bool { - return ng.Cfg == nil + if ng.Cfg == nil { + return true + } + + return !ng.Cfg.UnifiedAlerting.IsEnabled() } // GetHooks returns a facility for replacing handlers for paths. The handler hook for a path @@ -529,7 +535,7 @@ func ApplyStateHistoryFeatureToggles(cfg *setting.UnifiedAlertingStateHistorySet } } -func createRemoteAlertmanager(orgID int64, amCfg setting.RemoteAlertmanagerSettings, kvstore kvstore.KVStore) (*remote.Alertmanager, error) { +func createRemoteAlertmanager(orgID int64, amCfg setting.RemoteAlertmanagerSettings, kvstore kvstore.KVStore, m *metrics.RemoteAlertmanager) (*remote.Alertmanager, error) { externalAMCfg := remote.AlertmanagerConfig{ OrgID: orgID, URL: amCfg.URL, @@ -538,5 +544,5 @@ func createRemoteAlertmanager(orgID int64, amCfg setting.RemoteAlertmanagerSetti } // We won't be handling files on disk, we can pass an empty string as workingDirPath. stateStore := notifier.NewFileStore(orgID, kvstore, "") - return remote.NewAlertmanager(externalAMCfg, stateStore) + return remote.NewAlertmanager(externalAMCfg, stateStore, m) } diff --git a/pkg/services/ngalert/ngalert_test.go b/pkg/services/ngalert/ngalert_test.go index b7844f1fe18b6..b717f562cb17f 100644 --- a/pkg/services/ngalert/ngalert_test.go +++ b/pkg/services/ngalert/ngalert_test.go @@ -26,7 +26,6 @@ import ( func Test_subscribeToFolderChanges(t *testing.T) { orgID := rand.Int63() folder := &folder.Folder{ - ID: 0, // nolint:staticcheck UID: util.GenerateShortUID(), Title: "Folder" + util.GenerateShortUID(), } @@ -42,7 +41,6 @@ func Test_subscribeToFolderChanges(t *testing.T) { err := bus.Publish(context.Background(), &events.FolderTitleUpdated{ Timestamp: time.Now(), Title: "Folder" + util.GenerateShortUID(), - ID: folder.ID, // nolint:staticcheck UID: folder.UID, OrgID: orgID, }) diff --git a/pkg/services/ngalert/notifier/alertmanager.go b/pkg/services/ngalert/notifier/alertmanager.go index db5a9927d39bc..a4dc3d2ead6df 100644 --- a/pkg/services/ngalert/notifier/alertmanager.go +++ b/pkg/services/ngalert/notifier/alertmanager.go @@ -3,6 +3,7 @@ package notifier import ( "context" "crypto/md5" + "encoding/binary" "encoding/json" "fmt" "path/filepath" @@ -42,6 +43,7 @@ var silenceMaintenanceInterval = 15 * time.Minute type AlertingStore interface { store.AlertingStore store.ImageStore + autogenRuleStore } type alertmanager struct { @@ -56,6 +58,8 @@ type alertmanager struct { decryptFn alertingNotify.GetDecryptedValueFn orgID int64 + + withAutogen bool } // maintenanceOptions represent the options for components that need maintenance on a frequency within the Alertmanager. @@ -85,7 +89,7 @@ func (m maintenanceOptions) MaintenanceFunc(state alertingNotify.State) (int64, func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store AlertingStore, kvStore kvstore.KVStore, peer alertingNotify.ClusterPeer, decryptFn alertingNotify.GetDecryptedValueFn, ns notifications.Service, - m *metrics.Alertmanager) (*alertmanager, error) { + m *metrics.Alertmanager, withAutogen bool) (*alertmanager, error) { workingPath := filepath.Join(cfg.DataPath, workingDir, strconv.Itoa(int(orgID))) fileStore := NewFileStore(orgID, kvStore, workingPath) @@ -119,7 +123,6 @@ func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A } amcfg := &alertingNotify.GrafanaAlertmanagerConfig{ - WorkingDirectory: filepath.Join(cfg.DataPath, workingDir, strconv.Itoa(int(orgID))), ExternalURL: cfg.AppURL, AlertStoreCallback: nil, PeerTimeout: cfg.UnifiedAlerting.HAPeerTimeout, @@ -127,7 +130,7 @@ func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A Nflog: nflogOptions, } - l := log.New("ngalert.notifier.alertmanager", orgID) + l := log.New("ngalert.notifier.alertmanager", "org", orgID) gam, err := alertingNotify.NewGrafanaAlertmanager("orgID", orgID, amcfg, peer, l, alertingNotify.NewGrafanaAlertmanagerMetrics(m.Registerer)) if err != nil { return nil, err @@ -143,6 +146,9 @@ func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A decryptFn: decryptFn, fileStore: fileStore, logger: l, + + // TODO: Preferably, logic around autogen would be outside of the specific alertmanager implementation so that remote alertmanager will get it for free. + withAutogen: withAutogen, } return am, nil @@ -179,11 +185,17 @@ func (am *alertmanager) SaveAndApplyDefaultConfig(ctx context.Context) error { } err = am.Store.SaveAlertmanagerConfigurationWithCallback(ctx, cmd, func() error { - _, err := am.applyConfig(cfg, []byte(am.Settings.UnifiedAlerting.DefaultConfiguration)) + if am.withAutogen { + err := AddAutogenConfig(ctx, am.logger, am.Store, am.orgID, &cfg.AlertmanagerConfig, true) + if err != nil { + return err + } + } + _, err = am.applyConfig(cfg) return err }) if err != nil { - outerErr = nil + outerErr = err return } }) @@ -194,6 +206,9 @@ func (am *alertmanager) SaveAndApplyDefaultConfig(ctx context.Context) error { // SaveAndApplyConfig saves the configuration the database and applies the configuration to the Alertmanager. // It rollbacks the save if we fail to apply the configuration. func (am *alertmanager) SaveAndApplyConfig(ctx context.Context, cfg *apimodels.PostableUserConfig) error { + // Remove autogenerated config from the user config before saving it, may not be necessary as we already remove + // the autogenerated config before provenance guard. However, this is low impact and a good safety net. + RemoveAutogenConfigIfExists(cfg.AlertmanagerConfig.Route) rawConfig, err := json.Marshal(&cfg) if err != nil { return fmt.Errorf("failed to serialize to the Alertmanager configuration: %w", err) @@ -209,7 +224,14 @@ func (am *alertmanager) SaveAndApplyConfig(ctx context.Context, cfg *apimodels.P } err = am.Store.SaveAlertmanagerConfigurationWithCallback(ctx, cmd, func() error { - _, err := am.applyConfig(cfg, rawConfig) + if am.withAutogen { + err := AddAutogenConfig(ctx, am.logger, am.Store, am.orgID, &cfg.AlertmanagerConfig, false) + if err != nil { + return err + } + } + + _, err = am.applyConfig(cfg) return err }) if err != nil { @@ -231,7 +253,18 @@ func (am *alertmanager) ApplyConfig(ctx context.Context, dbCfg *ngmodels.AlertCo var outerErr error am.Base.WithLock(func() { - if err := am.applyAndMarkConfig(ctx, dbCfg.ConfigurationHash, cfg, nil); err != nil { + if am.withAutogen { + err := AddAutogenConfig(ctx, am.logger, am.Store, am.orgID, &cfg.AlertmanagerConfig, true) + if err != nil { + outerErr = err + return + } + } + // Note: Adding the autogen config here causes alert_configuration_history to update last_applied more often. + // Since we will now update last_applied when autogen changes even if the user-created config remains the same. + // To fix this however, the local alertmanager needs to be able to tell the difference between user-created and + // autogen config, which may introduce cross-cutting complexity. + if err := am.applyAndMarkConfig(ctx, dbCfg.ConfigurationHash, cfg); err != nil { outerErr = fmt.Errorf("unable to apply configuration: %w", err) return } @@ -255,6 +288,10 @@ func (am *alertmanager) updateConfigMetrics(cfg *apimodels.PostableUserConfig) { am.ConfigMetrics.MatchRE.Set(float64(amu.MatchRE)) am.ConfigMetrics.Match.Set(float64(amu.Match)) am.ConfigMetrics.ObjectMatchers.Set(float64(amu.ObjectMatchers)) + + am.ConfigMetrics.ConfigHash. + WithLabelValues(strconv.FormatInt(am.orgID, 10)). + Set(hashAsMetricValue(am.Base.ConfigHash())) } func (am *alertmanager) aggregateRouteMatchers(r *apimodels.Route, amu *AggregateMatchersUsage) { @@ -281,45 +318,30 @@ func (am *alertmanager) aggregateInhibitMatchers(rules []config.InhibitRule, amu // applyConfig applies a new configuration by re-initializing all components using the configuration provided. // It returns a boolean indicating whether the user config was changed and an error. // It is not safe to call concurrently. -func (am *alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig []byte) (bool, error) { +func (am *alertmanager) applyConfig(cfg *apimodels.PostableUserConfig) (bool, error) { // First, let's make sure this config is not already loaded - var amConfigChanged bool - if rawConfig == nil { - enc, err := json.Marshal(cfg.AlertmanagerConfig) - if err != nil { - // In theory, this should never happen. - return false, err - } - rawConfig = enc - } - - if am.Base.ConfigHash() != md5.Sum(rawConfig) { - amConfigChanged = true - } - - if cfg.TemplateFiles == nil { - cfg.TemplateFiles = map[string]string{} - } - cfg.TemplateFiles["__default__.tmpl"] = alertingTemplates.DefaultTemplateString - - // next, we need to make sure we persist the templates to disk. - paths, templatesChanged, err := PersistTemplates(am.logger, cfg, am.Base.WorkingDirectory()) + rawConfig, err := json.Marshal(cfg) if err != nil { + // In theory, this should never happen. return false, err } - cfg.AlertmanagerConfig.Templates = paths - // If neither the configuration nor templates have changed, we've got nothing to do. - if !amConfigChanged && !templatesChanged { - am.logger.Debug("Neither config nor template have changed, skipping configuration sync.") + // If configuration hasn't changed, we've got nothing to do. + configHash := md5.Sum(rawConfig) + if am.Base.ConfigHash() == configHash { + am.logger.Debug("Config hasn't changed, skipping configuration sync.") return false, nil } - am.updateConfigMetrics(cfg) - + am.logger.Info("Applying new configuration to Alertmanager", "configHash", fmt.Sprintf("%x", configHash)) err = am.Base.ApplyConfig(AlertingConfiguration{ rawAlertmanagerConfig: rawConfig, - alertmanagerConfig: cfg.AlertmanagerConfig, + configHash: configHash, + route: cfg.AlertmanagerConfig.Route.AsAMRoute(), + inhibitRules: cfg.AlertmanagerConfig.InhibitRules, + muteTimeIntervals: cfg.AlertmanagerConfig.MuteTimeIntervals, + timeIntervals: cfg.AlertmanagerConfig.TimeIntervals, + templates: ToTemplateDefinitions(cfg), receivers: PostableApiAlertingConfigToApiReceivers(cfg.AlertmanagerConfig), receiverIntegrationsFunc: am.buildReceiverIntegrations, }) @@ -327,12 +349,13 @@ func (am *alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig return false, err } + am.updateConfigMetrics(cfg) return true, nil } // applyAndMarkConfig applies a configuration and marks it as applied if no errors occur. -func (am *alertmanager) applyAndMarkConfig(ctx context.Context, hash string, cfg *apimodels.PostableUserConfig, rawConfig []byte) error { - configChanged, err := am.applyConfig(cfg, rawConfig) +func (am *alertmanager) applyAndMarkConfig(ctx context.Context, hash string, cfg *apimodels.PostableUserConfig) error { + configChanged, err := am.applyConfig(cfg) if err != nil { return err } @@ -421,3 +444,13 @@ func (e AlertValidationError) Error() string { type nilLimits struct{} func (n nilLimits) MaxNumberOfAggregationGroups() int { return 0 } + +// This function is taken from upstream, modified to take a [16]byte instead of a []byte. +// https://github.com/prometheus/alertmanager/blob/30fa9cd44bc91c0d6adcc9985609bb08a09a127b/config/coordinator.go#L149-L156 +func hashAsMetricValue(data [16]byte) float64 { + // We only want 48 bits as a float64 only has a 53 bit mantissa. + smallSum := data[0:6] + bytes := make([]byte, 8) + copy(bytes, smallSum) + return float64(binary.LittleEndian.Uint64(bytes)) +} diff --git a/pkg/services/ngalert/notifier/alertmanager_config.go b/pkg/services/ngalert/notifier/alertmanager_config.go index 90d548d5e6cff..32ae92baea299 100644 --- a/pkg/services/ngalert/notifier/alertmanager_config.go +++ b/pkg/services/ngalert/notifier/alertmanager_config.go @@ -8,6 +8,7 @@ import ( "github.com/go-openapi/strfmt" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -34,13 +35,48 @@ type configurationStore interface { GetLatestAlertmanagerConfiguration(ctx context.Context, orgID int64) (*models.AlertConfiguration, error) } -func (moa *MultiOrgAlertmanager) GetAlertmanagerConfiguration(ctx context.Context, org int64) (definitions.GettableUserConfig, error) { +// ApplyConfig will apply the given alertmanager configuration for a given org. +// Can be used to force regeneration of autogenerated routes. +func (moa *MultiOrgAlertmanager) ApplyConfig(ctx context.Context, orgId int64, dbConfig *models.AlertConfiguration) error { + am, err := moa.AlertmanagerFor(orgId) + if err != nil { + // It's okay if the alertmanager isn't ready yet, we're changing its config anyway. + if !errors.Is(err, ErrAlertmanagerNotReady) { + return err + } + } + + err = am.ApplyConfig(ctx, dbConfig) + if err != nil { + return fmt.Errorf("failed to apply configuration: %w", err) + } + return nil +} + +// GetAlertmanagerConfiguration returns the latest alertmanager configuration for a given org. +// If withAutogen is true, the configuration will be augmented with autogenerated routes. +func (moa *MultiOrgAlertmanager) GetAlertmanagerConfiguration(ctx context.Context, org int64, withAutogen bool) (definitions.GettableUserConfig, error) { amConfig, err := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, org) if err != nil { return definitions.GettableUserConfig{}, fmt.Errorf("failed to get latest configuration: %w", err) } - return moa.gettableUserConfigFromAMConfigString(ctx, org, amConfig.AlertmanagerConfiguration) + cfg, err := moa.gettableUserConfigFromAMConfigString(ctx, org, amConfig.AlertmanagerConfiguration) + if err != nil { + return definitions.GettableUserConfig{}, err + } + + if moa.featureManager.IsEnabled(ctx, featuremgmt.FlagAlertingSimplifiedRouting) && withAutogen { + // We validate the notification settings in a similar way to when we POST. + // Otherwise, broken settings (e.g. a receiver that doesn't exist) will cause the config returned here to be + // different than the config currently in-use. + // TODO: Preferably, we'd be getting the config directly from the in-memory AM so adding the autogen config would not be necessary. + err := AddAutogenConfig(ctx, moa.logger, moa.configStore, org, &cfg.AlertmanagerConfig, true) + if err != nil { + return definitions.GettableUserConfig{}, err + } + } + return cfg, nil } // ActivateHistoricalConfiguration will set the current alertmanager configuration to a previous value based on the provided @@ -108,6 +144,7 @@ func (moa *MultiOrgAlertmanager) gettableUserConfigFromAMConfigString(ctx contex if err != nil { return definitions.GettableUserConfig{}, fmt.Errorf("failed to unmarshal alertmanager configuration: %w", err) } + result := definitions.GettableUserConfig{ TemplateFiles: cfg.TemplateFiles, AlertmanagerConfig: definitions.GettableApiAlertingConfig{ @@ -155,7 +192,14 @@ func (moa *MultiOrgAlertmanager) gettableUserConfigFromAMConfigString(ctx contex return result, nil } -func (moa *MultiOrgAlertmanager) ApplyAlertmanagerConfiguration(ctx context.Context, org int64, config definitions.PostableUserConfig) error { +func (moa *MultiOrgAlertmanager) SaveAndApplyAlertmanagerConfiguration(ctx context.Context, org int64, config definitions.PostableUserConfig) error { + // We cannot add this validation to PostableUserConfig as that struct is used for both + // Grafana Alertmanager (where inhibition rules are not supported) and External Alertmanagers + // (including Mimir) where inhibition rules are supported. + if len(config.AlertmanagerConfig.InhibitRules) > 0 { + return errors.New("inhibition rules are not supported") + } + // Get the last known working configuration _, err := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, org) if err != nil { diff --git a/pkg/services/ngalert/notifier/alertmanager_test.go b/pkg/services/ngalert/notifier/alertmanager_test.go index 907e97ad36266..19e21b1c9ea09 100644 --- a/pkg/services/ngalert/notifier/alertmanager_test.go +++ b/pkg/services/ngalert/notifier/alertmanager_test.go @@ -17,8 +17,13 @@ import ( "github.com/grafana/grafana/pkg/services/secrets/database" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func setupAMTest(t *testing.T) *alertmanager { dir := t.TempDir() cfg := &setting.Cfg{ @@ -41,7 +46,7 @@ func setupAMTest(t *testing.T) *alertmanager { kvStore := fakes.NewFakeKVStore(t) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) decryptFn := secretsService.GetDecryptedValue - am, err := NewAlertmanager(context.Background(), 1, cfg, s, kvStore, &NilPeer{}, decryptFn, nil, m) + am, err := NewAlertmanager(context.Background(), 1, cfg, s, kvStore, &NilPeer{}, decryptFn, nil, m, false) require.NoError(t, err) return am } diff --git a/pkg/services/ngalert/notifier/autogen_alertmanager.go b/pkg/services/ngalert/notifier/autogen_alertmanager.go new file mode 100644 index 0000000000000..af5de7947a588 --- /dev/null +++ b/pkg/services/ngalert/notifier/autogen_alertmanager.go @@ -0,0 +1,185 @@ +package notifier + +import ( + "context" + "errors" + "fmt" + "slices" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/prometheus/alertmanager/pkg/labels" + "github.com/prometheus/common/model" + "golang.org/x/exp/maps" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +type autogenRuleStore interface { + ListNotificationSettings(ctx context.Context, q models.ListNotificationSettingsQuery) (map[models.AlertRuleKey][]models.NotificationSettings, error) +} + +// AddAutogenConfig creates the autogenerated configuration and adds it to the given apiAlertingConfig. +// If skipInvalid is true, then invalid notification settings are skipped, otherwise an error is returned. +func AddAutogenConfig[R receiver](ctx context.Context, logger log.Logger, store autogenRuleStore, orgId int64, cfg apiAlertingConfig[R], skipInvalid bool) error { + autogenRoute, err := newAutogeneratedRoute(ctx, logger, store, orgId, cfg, skipInvalid) + if err != nil { + return err + } + + err = autogenRoute.addToRoute(cfg.GetRoute()) + if err != nil { + return err + } + + return nil +} + +// newAutogeneratedRoute creates a new autogenerated route based on the notification settings for the given org. +// cfg is used to construct the settings validator and to ensure we create a dedicated route for each receiver. +// skipInvalid is used to skip invalid settings instead of returning an error. +func newAutogeneratedRoute[R receiver](ctx context.Context, logger log.Logger, store autogenRuleStore, orgId int64, cfg apiAlertingConfig[R], skipInvalid bool) (autogeneratedRoute, error) { + settings, err := store.ListNotificationSettings(ctx, models.ListNotificationSettingsQuery{OrgID: orgId}) + if err != nil { + return autogeneratedRoute{}, fmt.Errorf("failed to list alert rules: %w", err) + } + + notificationSettings := make(map[data.Fingerprint]models.NotificationSettings) + // Add a default notification setting for each contact point. This is to ensure that we always have a route for each + // contact point even if no rules are using it. This will prevent race conditions between AM sync and rule sync. + for _, receiver := range cfg.GetReceivers() { + setting := models.NewDefaultNotificationSettings(receiver.GetName()) + fp := setting.Fingerprint() + notificationSettings[fp] = setting + } + + validator := NewNotificationSettingsValidator(cfg) + for ruleKey, ruleSettings := range settings { + for _, setting := range ruleSettings { + // TODO we should register this errors and somehow present to the users or make sure the config is always valid. + if err = validator.Validate(setting); err != nil { + if skipInvalid { + logger.Error("Rule notification settings are invalid. Skipping", append(ruleKey.LogContext(), "error", err)...) + continue + } + return autogeneratedRoute{}, fmt.Errorf("invalid notification settings for rule %s: %w", ruleKey.UID, err) + } + fp := setting.Fingerprint() + // Keep only unique settings. + if _, ok := notificationSettings[fp]; ok { + continue + } + notificationSettings[fp] = setting + } + } + if len(notificationSettings) == 0 { + return autogeneratedRoute{}, nil + } + newAutogenRoute, err := generateRouteFromSettings(cfg.GetRoute().Receiver, notificationSettings) + if err != nil { + return autogeneratedRoute{}, fmt.Errorf("failed to create autogenerated route: %w", err) + } + return newAutogenRoute, nil +} + +type autogeneratedRoute struct { + Route *definitions.Route +} + +// generateRouteFromSettings generates a route and fingerprint for this route. The route is a tree of 3 layers: +// 1. with matcher by label models.AutogeneratedRouteLabel equals 'true'. +// 2. with matcher by receiver name. +// 3. with matcher by unique combination of optional settings. It is created only if there are optional settings. +func generateRouteFromSettings(defaultReceiver string, settings map[data.Fingerprint]models.NotificationSettings) (autogeneratedRoute, error) { + keys := maps.Keys(settings) + // sort keys to make sure that the hash we calculate using it is stable + slices.Sort(keys) + + rootMatcher, err := labels.NewMatcher(labels.MatchEqual, models.AutogeneratedRouteLabel, "true") + if err != nil { + return autogeneratedRoute{}, err + } + + autoGenRoot := &definitions.Route{ + Receiver: defaultReceiver, + ObjectMatchers: definitions.ObjectMatchers{rootMatcher}, + Continue: false, // We explicitly don't continue toward user-created routes if this matches. + } + + receiverRoutes := make(map[string]*definitions.Route) + for _, fingerprint := range keys { + s := settings[fingerprint] + receiverRoute, ok := receiverRoutes[s.Receiver] + if !ok { + contactMatcher, err := labels.NewMatcher(labels.MatchEqual, models.AutogeneratedRouteReceiverNameLabel, s.Receiver) + if err != nil { + return autogeneratedRoute{}, err + } + receiverRoute = &definitions.Route{ + Receiver: s.Receiver, + ObjectMatchers: definitions.ObjectMatchers{contactMatcher}, + Continue: false, + // Since we'll have many rules from different folders using this policy, we ensure it has these necessary groupings. + GroupByStr: []string{models.FolderTitleLabel, model.AlertNameLabel}, + } + receiverRoutes[s.Receiver] = receiverRoute + autoGenRoot.Routes = append(autoGenRoot.Routes, receiverRoute) + } + + // Do not create hash specific route if all group settings such as mute timings, group_wait, group_interval, etc are default + if s.IsAllDefault() { + continue + } + settingMatcher, err := labels.NewMatcher(labels.MatchEqual, models.AutogeneratedRouteSettingsHashLabel, fingerprint.String()) + if err != nil { + return autogeneratedRoute{}, err + } + receiverRoute.Routes = append(receiverRoute.Routes, &definitions.Route{ + Receiver: s.Receiver, + ObjectMatchers: definitions.ObjectMatchers{settingMatcher}, + Continue: false, // Only a single setting-specific route should match. + + GroupByStr: s.GroupBy, // Note: in order to pass validation at least FolderTitleLabel and AlertNameLabel are always included. + MuteTimeIntervals: s.MuteTimeIntervals, + GroupWait: s.GroupWait, + GroupInterval: s.GroupInterval, + RepeatInterval: s.RepeatInterval, + }) + } + + return autogeneratedRoute{ + Route: autoGenRoot, + }, nil +} + +// addToRoute adds this autogenerated route to the given route as the first top-level route under the root. +func (ar *autogeneratedRoute) addToRoute(route *definitions.Route) error { + if route == nil { + return errors.New("route does not exist") + } + if ar == nil || ar.Route == nil { + return nil + } + // Combine autogenerated route with the user-created route. + ar.Route.Receiver = route.Receiver + + // Remove existing autogenerated route if it exists. + RemoveAutogenConfigIfExists(route) + + route.Routes = append([]*definitions.Route{ar.Route}, route.Routes...) + return nil +} + +// RemoveAutogenConfigIfExists removes all top-level autogenerated routes from the provided route. +// If no autogenerated routes exist, this function does nothing. +func RemoveAutogenConfigIfExists(route *definitions.Route) { + route.Routes = slices.DeleteFunc(route.Routes, func(route *definitions.Route) bool { + return isAutogeneratedRoot(route) + }) +} + +// isAutogeneratedRoot returns true if the route is the root of an autogenerated route. +func isAutogeneratedRoot(route *definitions.Route) bool { + return len(route.ObjectMatchers) == 1 && route.ObjectMatchers[0].Name == models.AutogeneratedRouteLabel +} diff --git a/pkg/services/ngalert/notifier/autogen_alertmanager_test.go b/pkg/services/ngalert/notifier/autogen_alertmanager_test.go new file mode 100644 index 0000000000000..6ac486dbeb306 --- /dev/null +++ b/pkg/services/ngalert/notifier/autogen_alertmanager_test.go @@ -0,0 +1,238 @@ +package notifier + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/pkg/labels" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log/logtest" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/util" +) + +func TestAddAutogenConfig(t *testing.T) { + rootRoute := func() *definitions.Route { + return &definitions.Route{ + Receiver: "default", + } + } + configGen := func(receivers []string, muteIntervals []string) *definitions.PostableApiAlertingConfig { + cfg := &definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + Route: rootRoute(), + }, + } + for _, receiver := range receivers { + cfg.Receivers = append(cfg.Receivers, &definitions.PostableApiReceiver{ + Receiver: config.Receiver{ + Name: receiver, + }, + }) + } + for _, muteInterval := range muteIntervals { + cfg.MuteTimeIntervals = append(cfg.MuteTimeIntervals, config.MuteTimeInterval{ + Name: muteInterval, + }) + } + return cfg + } + + withChildRoutes := func(route *definitions.Route, children ...*definitions.Route) *definitions.Route { + route.Routes = append(route.Routes, children...) + return route + } + + matcher := func(key, val string) definitions.ObjectMatchers { + m, err := labels.NewMatcher(labels.MatchEqual, key, val) + require.NoError(t, err) + return definitions.ObjectMatchers{m} + } + + basicContactRoute := func(receiver string) *definitions.Route { + return &definitions.Route{ + Receiver: receiver, + ObjectMatchers: matcher(models.AutogeneratedRouteReceiverNameLabel, receiver), + GroupByStr: []string{models.FolderTitleLabel, model.AlertNameLabel}, + } + } + + testCases := []struct { + name string + existingConfig *definitions.PostableApiAlertingConfig + storeSettings []models.NotificationSettings + skipInvalid bool + expRoute *definitions.Route + expErrorContains string + }{ + { + name: "no settings or receivers, no change", + existingConfig: configGen(nil, nil), + storeSettings: []models.NotificationSettings{}, + expRoute: rootRoute(), + }, + { + name: "no settings but some receivers, add default routes for receivers", + existingConfig: configGen([]string{"receiver1", "receiver2", "receiver3"}, nil), + storeSettings: []models.NotificationSettings{}, + expRoute: withChildRoutes(rootRoute(), &definitions.Route{ + Receiver: "default", + ObjectMatchers: matcher(models.AutogeneratedRouteLabel, "true"), + Routes: []*definitions.Route{ + basicContactRoute("receiver1"), + basicContactRoute("receiver3"), + basicContactRoute("receiver2"), + }, + }), + }, + { + name: "settings with no custom options, add default routes only", + existingConfig: configGen([]string{"receiver1", "receiver2", "receiver3"}, nil), + storeSettings: []models.NotificationSettings{models.NewDefaultNotificationSettings("receiver1"), models.NewDefaultNotificationSettings("receiver2")}, + expRoute: withChildRoutes(rootRoute(), &definitions.Route{ + Receiver: "default", + ObjectMatchers: matcher(models.AutogeneratedRouteLabel, "true"), + Routes: []*definitions.Route{ + basicContactRoute("receiver1"), + basicContactRoute("receiver3"), + basicContactRoute("receiver2"), + }, + }), + }, + { + name: "settings with custom options, add option-specific routes", + existingConfig: configGen([]string{"receiver1", "receiver2", "receiver3", "receiver4", "receiver5"}, []string{"maintenance"}), + storeSettings: []models.NotificationSettings{ + models.CopyNotificationSettings(models.NewDefaultNotificationSettings("receiver1"), models.NSMuts.WithGroupInterval(util.Pointer(1*time.Minute))), + models.CopyNotificationSettings(models.NewDefaultNotificationSettings("receiver2"), models.NSMuts.WithGroupWait(util.Pointer(2*time.Minute))), + models.CopyNotificationSettings(models.NewDefaultNotificationSettings("receiver3"), models.NSMuts.WithRepeatInterval(util.Pointer(3*time.Minute))), + models.CopyNotificationSettings(models.NewDefaultNotificationSettings("receiver4"), models.NSMuts.WithGroupBy(model.AlertNameLabel, models.FolderTitleLabel, "custom")), + models.CopyNotificationSettings(models.NewDefaultNotificationSettings("receiver5"), models.NSMuts.WithMuteTimeIntervals("maintenance")), + { + Receiver: "receiver1", + GroupBy: []string{model.AlertNameLabel, models.FolderTitleLabel, "custom"}, + GroupInterval: util.Pointer(model.Duration(1 * time.Minute)), + GroupWait: util.Pointer(model.Duration(2 * time.Minute)), + RepeatInterval: util.Pointer(model.Duration(3 * time.Minute)), + MuteTimeIntervals: []string{"maintenance"}, + }, + }, + expRoute: withChildRoutes(rootRoute(), &definitions.Route{ + Receiver: "default", + ObjectMatchers: matcher(models.AutogeneratedRouteLabel, "true"), + Routes: []*definitions.Route{ + withChildRoutes(basicContactRoute("receiver5"), &definitions.Route{ + Receiver: "receiver5", + ObjectMatchers: matcher(models.AutogeneratedRouteSettingsHashLabel, "030d6474aec0b553"), + MuteTimeIntervals: []string{"maintenance"}, + }), + withChildRoutes(basicContactRoute("receiver1"), &definitions.Route{ + Receiver: "receiver1", + ObjectMatchers: matcher(models.AutogeneratedRouteSettingsHashLabel, "dde34b8127e68f31"), + GroupInterval: util.Pointer(model.Duration(1 * time.Minute)), + }, &definitions.Route{ + Receiver: "receiver1", + ObjectMatchers: matcher(models.AutogeneratedRouteSettingsHashLabel, "ed4038c5d6733607"), + GroupByStr: []string{model.AlertNameLabel, models.FolderTitleLabel, "custom"}, + GroupInterval: util.Pointer(model.Duration(1 * time.Minute)), + GroupWait: util.Pointer(model.Duration(2 * time.Minute)), + RepeatInterval: util.Pointer(model.Duration(3 * time.Minute)), + MuteTimeIntervals: []string{"maintenance"}, + }), + withChildRoutes(basicContactRoute("receiver2"), &definitions.Route{ + Receiver: "receiver2", + ObjectMatchers: matcher(models.AutogeneratedRouteSettingsHashLabel, "27e1d1717c9ef621"), + GroupWait: util.Pointer(model.Duration(2 * time.Minute)), + }), + withChildRoutes(basicContactRoute("receiver4"), &definitions.Route{ + Receiver: "receiver4", + ObjectMatchers: matcher(models.AutogeneratedRouteSettingsHashLabel, "5e5ab8d592b12e86"), + GroupByStr: []string{model.AlertNameLabel, models.FolderTitleLabel, "custom"}, + }), + withChildRoutes(basicContactRoute("receiver3"), &definitions.Route{ + Receiver: "receiver3", + ObjectMatchers: matcher(models.AutogeneratedRouteSettingsHashLabel, "9e282ef0193d830a"), + RepeatInterval: util.Pointer(model.Duration(3 * time.Minute)), + }), + }, + }), + }, + { + name: "when skipInvalid=true, invalid settings are skipped", + existingConfig: configGen([]string{"receiver1", "receiver2", "receiver3"}, nil), + storeSettings: []models.NotificationSettings{ + models.NewDefaultNotificationSettings("receiverA"), // Doesn't exist. + models.CopyNotificationSettings(models.NewDefaultNotificationSettings("receiver1"), models.NSMuts.WithMuteTimeIntervals("maintenance")), // Doesn't exist. + models.CopyNotificationSettings(models.NewDefaultNotificationSettings("receiver2"), models.NSMuts.WithGroupWait(util.Pointer(-2*time.Minute))), // Negative. + }, + skipInvalid: true, + expRoute: withChildRoutes(rootRoute(), &definitions.Route{ + Receiver: "default", + ObjectMatchers: matcher(models.AutogeneratedRouteLabel, "true"), + Routes: []*definitions.Route{ + basicContactRoute("receiver1"), + basicContactRoute("receiver3"), + basicContactRoute("receiver2"), + }, + }), + }, + { + name: "when skipInvalid=false, invalid receiver throws error", + existingConfig: configGen([]string{"receiver1", "receiver2", "receiver3"}, nil), + storeSettings: []models.NotificationSettings{models.NewDefaultNotificationSettings("receiverA")}, + skipInvalid: false, + expErrorContains: "receiverA", + }, + { + name: "when skipInvalid=false, invalid settings throws error", + existingConfig: configGen([]string{"receiver1", "receiver2", "receiver3"}, nil), + storeSettings: []models.NotificationSettings{models.CopyNotificationSettings(models.NewDefaultNotificationSettings("receiver1"), models.NSMuts.WithMuteTimeIntervals("maintenance"))}, + skipInvalid: false, + expErrorContains: "maintenance", + }, + { + name: "when skipInvalid=false, invalid settings throws error", + existingConfig: configGen([]string{"receiver1", "receiver2", "receiver3"}, nil), + storeSettings: []models.NotificationSettings{models.CopyNotificationSettings(models.NewDefaultNotificationSettings("receiver2"), models.NSMuts.WithGroupWait(util.Pointer(-2*time.Minute)))}, + skipInvalid: false, + expErrorContains: "group wait", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + orgId := int64(1) + store := &fakeConfigStore{ + notificationSettings: make(map[int64]map[models.AlertRuleKey][]models.NotificationSettings), + } + store.notificationSettings[orgId] = make(map[models.AlertRuleKey][]models.NotificationSettings) + + for _, setting := range tt.storeSettings { + store.notificationSettings[orgId][models.AlertRuleKey{OrgID: orgId, UID: util.GenerateShortUID()}] = []models.NotificationSettings{setting} + } + + err := AddAutogenConfig(context.Background(), &logtest.Fake{}, store, orgId, tt.existingConfig, tt.skipInvalid) + if tt.expErrorContains != "" { + require.Error(t, err) + require.ErrorContains(t, err, tt.expErrorContains) + return + } else { + require.NoError(t, err) + } + + cOpt := []cmp.Option{ + cmpopts.IgnoreUnexported(definitions.Route{}, labels.Matcher{}), + } + if !cmp.Equal(tt.expRoute, tt.existingConfig.Route, cOpt...) { + t.Errorf("Unexpected Route: %v", cmp.Diff(tt.expRoute, tt.existingConfig.Route, cOpt...)) + } + }) + } +} diff --git a/pkg/services/ngalert/notifier/channels_config/available_channels.go b/pkg/services/ngalert/notifier/channels_config/available_channels.go index 5fac854bc100e..b05138a79c4f9 100644 --- a/pkg/services/ngalert/notifier/channels_config/available_channels.go +++ b/pkg/services/ngalert/notifier/channels_config/available_channels.go @@ -815,6 +815,15 @@ func GetAvailableNotifiers() []*NotifierPlugin { PropertyName: "chatid", Required: true, }, + { + Label: "Message Thread ID", + Element: ElementTypeInput, + InputType: InputTypeText, + Description: "Integer Telegram Message Thread Identifier", + PropertyName: "message_thread_id", + Required: false, + ValidationRule: "-?[0-9]{1,10}", + }, { // New in 8.0. Label: "Message", Element: ElementTypeTextArea, diff --git a/pkg/services/ngalert/notifier/compat.go b/pkg/services/ngalert/notifier/compat.go index 41bcb1cab8794..7d6580b9ec735 100644 --- a/pkg/services/ngalert/notifier/compat.go +++ b/pkg/services/ngalert/notifier/compat.go @@ -4,8 +4,12 @@ import ( "encoding/json" alertingNotify "github.com/grafana/alerting/notify" + alertingTemplates "github.com/grafana/alerting/templates" + "github.com/prometheus/alertmanager/config" + "github.com/grafana/grafana/pkg/components/simplejson" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" ) func PostableGrafanaReceiverToGrafanaIntegrationConfig(p *apimodels.PostableGrafanaReceiver) *alertingNotify.GrafanaIntegrationConfig { @@ -40,3 +44,81 @@ func PostableApiAlertingConfigToApiReceivers(c apimodels.PostableApiAlertingConf } return apiReceivers } + +type DecryptFn = func(value string) string + +func PostableToGettableGrafanaReceiver(r *apimodels.PostableGrafanaReceiver, provenance *models.Provenance, decryptFn DecryptFn, listOnly bool) (apimodels.GettableGrafanaReceiver, error) { + out := apimodels.GettableGrafanaReceiver{ + UID: r.UID, + Name: r.Name, + Type: r.Type, + } + if provenance != nil { + out.Provenance = apimodels.Provenance(*provenance) + } + + // if we aren't only listing, include the settings in the output + if !listOnly { + secureFields := make(map[string]bool, len(r.SecureSettings)) + settings, err := simplejson.NewJson([]byte(r.Settings)) + if err != nil { + return apimodels.GettableGrafanaReceiver{}, err + } + + for k, v := range r.SecureSettings { + decryptedValue := decryptFn(v) + if decryptedValue == "" { + continue + } else { + settings.Set(k, decryptedValue) + } + secureFields[k] = true + } + + jsonBytes, err := settings.MarshalJSON() + if err != nil { + return apimodels.GettableGrafanaReceiver{}, err + } + + out.Settings = jsonBytes + out.SecureFields = secureFields + out.DisableResolveMessage = r.DisableResolveMessage + } + + return out, nil +} + +func PostableToGettableApiReceiver(r *apimodels.PostableApiReceiver, provenances map[string]models.Provenance, decryptFn DecryptFn, listOnly bool) (apimodels.GettableApiReceiver, error) { + out := apimodels.GettableApiReceiver{ + Receiver: config.Receiver{ + Name: r.Receiver.Name, + }, + } + + for _, gr := range r.GrafanaManagedReceivers { + var prov *models.Provenance + if p, ok := provenances[gr.UID]; ok { + prov = &p + } + + gettable, err := PostableToGettableGrafanaReceiver(gr, prov, decryptFn, listOnly) + if err != nil { + return apimodels.GettableApiReceiver{}, err + } + out.GrafanaManagedReceivers = append(out.GrafanaManagedReceivers, &gettable) + } + + return out, nil +} + +// ToTemplateDefinitions converts the given PostableUserConfig's TemplateFiles to a slice of TemplateDefinitions. +func ToTemplateDefinitions(cfg *apimodels.PostableUserConfig) []alertingTemplates.TemplateDefinition { + out := make([]alertingTemplates.TemplateDefinition, 0, len(cfg.TemplateFiles)) + for name, tmpl := range cfg.TemplateFiles { + out = append(out, alertingTemplates.TemplateDefinition{ + Name: name, + Template: tmpl, + }) + } + return out +} diff --git a/pkg/services/ngalert/notifier/config.go b/pkg/services/ngalert/notifier/config.go index 86dfb404049e3..bffe1b72ce359 100644 --- a/pkg/services/ngalert/notifier/config.go +++ b/pkg/services/ngalert/notifier/config.go @@ -1,82 +1,15 @@ package notifier import ( - "crypto/md5" "encoding/json" "fmt" - "os" - "path/filepath" alertingNotify "github.com/grafana/alerting/notify" alertingTemplates "github.com/grafana/alerting/templates" - "github.com/grafana/grafana/pkg/infra/log" api "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ) -func PersistTemplates(logger log.Logger, cfg *api.PostableUserConfig, path string) ([]string, bool, error) { - if len(cfg.TemplateFiles) < 1 { - return nil, false, nil - } - - var templatesChanged bool - pathSet := map[string]struct{}{} - for name, content := range cfg.TemplateFiles { - if name != filepath.Base(filepath.Clean(name)) { - return nil, false, fmt.Errorf("template file name '%s' is not valid", name) - } - - err := os.MkdirAll(path, 0750) - if err != nil { - return nil, false, fmt.Errorf("unable to create template directory %q: %s", path, err) - } - - file := filepath.Join(path, name) - pathSet[name] = struct{}{} - - // Check if the template file already exists and if it has changed - // We can safely ignore gosec here as we've previously checked the filename is clean - // nolint:gosec - if tmpl, err := os.ReadFile(file); err == nil && string(tmpl) == content { - // Templates file is the same we have, no-op and continue. - continue - } else if err != nil && !os.IsNotExist(err) { - return nil, false, err - } - - // We can safely ignore gosec here as we've previously checked the filename is clean - // nolint:gosec - if err := os.WriteFile(file, []byte(content), 0644); err != nil { - return nil, false, fmt.Errorf("unable to create Alertmanager template file %q: %s", file, err) - } - - templatesChanged = true - } - - // Now that we have the list of _actual_ templates, let's remove the ones that we don't need. - existingFiles, err := os.ReadDir(path) - if err != nil { - logger.Error("Unable to read directory for deleting Alertmanager templates", "error", err, "path", path) - } - for _, existingFile := range existingFiles { - p := filepath.Join(path, existingFile.Name()) - _, ok := pathSet[existingFile.Name()] - if !ok { - templatesChanged = true - err := os.Remove(p) - if err != nil { - logger.Error("Unable to delete template", "error", err, "file", p) - } - } - } - - paths := make([]string, 0, len(pathSet)) - for path := range pathSet { - paths = append(paths, path) - } - return paths, templatesChanged, nil -} - func Load(rawConfig []byte) (*api.PostableUserConfig, error) { cfg := &api.PostableUserConfig{} @@ -90,8 +23,13 @@ func Load(rawConfig []byte) (*api.PostableUserConfig, error) { // AlertingConfiguration provides configuration for an Alertmanager. // It implements the notify.Configuration interface. type AlertingConfiguration struct { - alertmanagerConfig api.PostableApiAlertingConfig + route *alertingNotify.Route + inhibitRules []alertingNotify.InhibitRule + muteTimeIntervals []alertingNotify.MuteTimeInterval + timeIntervals []alertingNotify.TimeInterval + templates []alertingTemplates.TemplateDefinition rawAlertmanagerConfig []byte + configHash [16]byte receivers []*alertingNotify.APIReceiver receiverIntegrationsFunc func(r *alertingNotify.APIReceiver, tmpl *alertingTemplates.Template) ([]*alertingNotify.Integration, error) @@ -108,11 +46,15 @@ func (a AlertingConfiguration) DispatcherLimits() alertingNotify.DispatcherLimit } func (a AlertingConfiguration) InhibitRules() []alertingNotify.InhibitRule { - return a.alertmanagerConfig.InhibitRules + return a.inhibitRules } func (a AlertingConfiguration) MuteTimeIntervals() []alertingNotify.MuteTimeInterval { - return a.alertmanagerConfig.MuteTimeIntervals + return a.muteTimeIntervals +} + +func (a AlertingConfiguration) TimeIntervals() []alertingNotify.TimeInterval { + return a.timeIntervals } func (a AlertingConfiguration) Receivers() []*alertingNotify.APIReceiver { @@ -120,15 +62,15 @@ func (a AlertingConfiguration) Receivers() []*alertingNotify.APIReceiver { } func (a AlertingConfiguration) RoutingTree() *alertingNotify.Route { - return a.alertmanagerConfig.Route.AsAMRoute() + return a.route } -func (a AlertingConfiguration) Templates() []string { - return a.alertmanagerConfig.Templates +func (a AlertingConfiguration) Templates() []alertingTemplates.TemplateDefinition { + return a.templates } func (a AlertingConfiguration) Hash() [16]byte { - return md5.Sum(a.rawAlertmanagerConfig) + return a.configHash } func (a AlertingConfiguration) Raw() []byte { diff --git a/pkg/services/ngalert/notifier/config_test.go b/pkg/services/ngalert/notifier/config_test.go index c39b4f2b2beca..76b910e795c4d 100644 --- a/pkg/services/ngalert/notifier/config_test.go +++ b/pkg/services/ngalert/notifier/config_test.go @@ -2,110 +2,12 @@ package notifier import ( "errors" - "os" - "path/filepath" "testing" - "github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - api "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ) -func TestPersistTemplates(t *testing.T) { - tc := []struct { - name string - templates map[string]string - existingTemplates map[string]string - expectedPaths []string - expectedError error - expectedChange bool - }{ - { - name: "With valid templates file names, it persists successfully", - templates: map[string]string{"email.template": "a perfectly fine template"}, - expectedChange: true, - expectedError: nil, - expectedPaths: []string{"email.template"}, - }, - { - name: "With a invalid filename, it fails", - templates: map[string]string{"adirectory/email.template": "a perfectly fine template"}, - expectedError: errors.New("template file name 'adirectory/email.template' is not valid"), - }, - { - name: "with a template that has the same name but different content to an existing one", - existingTemplates: map[string]string{"email.template": "a perfectly fine template"}, - templates: map[string]string{"email.template": "a completely different content"}, - expectedChange: true, - expectedError: nil, - expectedPaths: []string{"email.template"}, - }, - { - name: "with a template that has the same name and the same content as an existing one", - existingTemplates: map[string]string{"email.template": "a perfectly fine template"}, - templates: map[string]string{"email.template": "a perfectly fine template"}, - expectedChange: false, - expectedError: nil, - expectedPaths: []string{"email.template"}, - }, - { - name: "with two new template files, it changes the template tree", - existingTemplates: map[string]string{"email.template": "a perfectly fine template"}, - templates: map[string]string{"slack.template": "a perfectly fine template", "webhook.template": "a webhook template"}, - expectedChange: true, - expectedError: nil, - expectedPaths: []string{"slack.template", "webhook.template"}, - }, - { - name: "when we remove a template file from the list, it changes the template tree", - existingTemplates: map[string]string{"slack.template": "a perfectly fine template", "webhook.template": "a webhook template"}, - templates: map[string]string{"slack.template": "a perfectly fine template"}, - expectedChange: true, - expectedError: nil, - expectedPaths: []string{"slack.template"}, - }, - } - - for _, tt := range tc { - t.Run(tt.name, func(t *testing.T) { - dir := t.TempDir() - // Write "existing files" - for name, content := range tt.existingTemplates { - err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644) - require.NoError(t, err) - } - c := &api.PostableUserConfig{TemplateFiles: tt.templates} - - testLogger := logtest.Fake{} - paths, changed, persistErr := PersistTemplates(&testLogger, c, dir) - - files := map[string]string{} - readFiles, err := os.ReadDir(dir) - require.NoError(t, err) - for _, f := range readFiles { - if f.IsDir() || f.Name() == "" { - continue - } - // Safe to disable, this is a test. - // nolint:gosec - content, err := os.ReadFile(filepath.Join(dir, f.Name())) - // nolint:gosec - require.NoError(t, err) - files[f.Name()] = string(content) - } - - require.Equal(t, tt.expectedError, persistErr) - require.ElementsMatch(t, tt.expectedPaths, paths) - require.Equal(t, tt.expectedChange, changed) - if tt.expectedError == nil { - require.Equal(t, tt.templates, files) - } - }) - } -} - func TestLoad(t *testing.T) { tc := []struct { name string diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager.go b/pkg/services/ngalert/notifier/multiorg_alertmanager.go index 34394fec23d79..89ffe28a84574 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -67,8 +68,9 @@ type MultiOrgAlertmanager struct { alertmanagersMtx sync.RWMutex alertmanagers map[int64]Alertmanager - settings *setting.Cfg - logger log.Logger + settings *setting.Cfg + featureManager featuremgmt.FeatureToggles + logger log.Logger // clusterPeer represents the clustering peers of Alertmanagers between Grafana instances. peer alertingNotify.ClusterPeer @@ -95,24 +97,35 @@ func WithAlertmanagerOverride(f func(OrgAlertmanagerFactory) OrgAlertmanagerFact } } -func NewMultiOrgAlertmanager(cfg *setting.Cfg, configStore AlertingStore, orgStore store.OrgStore, - kvStore kvstore.KVStore, provStore provisioningStore, decryptFn alertingNotify.GetDecryptedValueFn, - m *metrics.MultiOrgAlertmanager, ns notifications.Service, l log.Logger, s secrets.Service, opts ...Option, +func NewMultiOrgAlertmanager( + cfg *setting.Cfg, + configStore AlertingStore, + orgStore store.OrgStore, + kvStore kvstore.KVStore, + provStore provisioningStore, + decryptFn alertingNotify.GetDecryptedValueFn, + m *metrics.MultiOrgAlertmanager, + ns notifications.Service, + l log.Logger, + s secrets.Service, + featureManager featuremgmt.FeatureToggles, + opts ...Option, ) (*MultiOrgAlertmanager, error) { moa := &MultiOrgAlertmanager{ Crypto: NewCrypto(s, configStore, l), ProvStore: provStore, - logger: l, - settings: cfg, - alertmanagers: map[int64]Alertmanager{}, - configStore: configStore, - orgStore: orgStore, - kvStore: kvStore, - decryptFn: decryptFn, - metrics: m, - ns: ns, - peer: &NilPeer{}, + logger: l, + settings: cfg, + featureManager: featureManager, + alertmanagers: map[int64]Alertmanager{}, + configStore: configStore, + orgStore: orgStore, + kvStore: kvStore, + decryptFn: decryptFn, + metrics: m, + ns: ns, + peer: &NilPeer{}, } if err := moa.setupClustering(cfg); err != nil { @@ -122,7 +135,7 @@ func NewMultiOrgAlertmanager(cfg *setting.Cfg, configStore AlertingStore, orgSto // Set up the default per tenant Alertmanager factory. moa.factory = func(ctx context.Context, orgID int64) (Alertmanager, error) { m := metrics.NewAlertmanagerMetrics(moa.metrics.GetOrCreateOrgRegistry(orgID)) - return NewAlertmanager(ctx, orgID, moa.settings, moa.configStore, moa.kvStore, moa.peer, moa.decryptFn, moa.ns, m) + return NewAlertmanager(ctx, orgID, moa.settings, moa.configStore, moa.kvStore, moa.peer, moa.decryptFn, moa.ns, m, featureManager.IsEnabled(ctx, featuremgmt.FlagAlertingSimplifiedRouting)) } for _, opt := range opts { diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager_remote_test.go b/pkg/services/ngalert/notifier/multiorg_alertmanager_remote_test.go new file mode 100644 index 0000000000000..603cfb7e654b1 --- /dev/null +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager_remote_test.go @@ -0,0 +1,279 @@ +package notifier_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/ngalert/metrics" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" + "github.com/grafana/grafana/pkg/services/ngalert/remote" + remoteClient "github.com/grafana/grafana/pkg/services/ngalert/remote/client" + ngfakes "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" + "github.com/grafana/grafana/pkg/services/secrets/fakes" + secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" + "github.com/grafana/grafana/pkg/setting" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" +) + +func TestMultiorgAlertmanager_RemoteSecondaryMode(t *testing.T) { + ctx := context.Background() + nopLogger := log.NewNopLogger() + tenantID := "testTenantID" + password := "testPassword" + reg := prometheus.NewPedanticRegistry() + m := metrics.NewNGAlert(reg) + + // We're gonna use an test server to send configuration and state to. + fakeAM := newFakeRemoteAlertmanager(t, tenantID, password) + testsrv := httptest.NewServer(fakeAM) + + // We'll start with the default config and no values for silences and notifications. + kvStore := ngfakes.NewFakeKVStore(t) + require.NoError(t, kvStore.Set(ctx, 1, "alertmanager", notifier.SilencesFilename, "")) + require.NoError(t, kvStore.Set(ctx, 1, "alertmanager", notifier.NotificationLogFilename, "")) + configStore := notifier.NewFakeConfigStore(t, map[int64]*models.AlertConfiguration{ + 1: { + OrgID: 1, + AlertmanagerConfiguration: setting.GetAlertmanagerDefaultConfiguration(), + CreatedAt: time.Now().Unix(), + Default: true, + }, + }) + + // Create the factory function for the MOA using the forked Alertmanager in remote secondary mode. + override := notifier.WithAlertmanagerOverride(func(factoryFn notifier.OrgAlertmanagerFactory) notifier.OrgAlertmanagerFactory { + return func(ctx context.Context, orgID int64) (notifier.Alertmanager, error) { + // Create internal Alertmanager. + internalAM, err := factoryFn(ctx, orgID) + require.NoError(t, err) + + // Create remote Alertmanager. + externalAMCfg := remote.AlertmanagerConfig{ + OrgID: 1, + URL: testsrv.URL, + TenantID: tenantID, + BasicAuthPassword: password, + } + // We won't be handling files on disk, we can pass an empty string as workingDirPath. + stateStore := notifier.NewFileStore(orgID, kvStore, "") + m := metrics.NewRemoteAlertmanagerMetrics(prometheus.NewRegistry()) + remoteAM, err := remote.NewAlertmanager(externalAMCfg, stateStore, m) + require.NoError(t, err) + + // Use both Alertmanager implementations in the forked Alertmanager. + cfg := remote.RemoteSecondaryConfig{ + Logger: nopLogger, + OrgID: orgID, + Store: configStore, + // Note that we're setting a sync interval of 10 seconds. + SyncInterval: 10 * time.Second, + } + return remote.NewRemoteSecondaryForkedAlertmanager(cfg, internalAM, remoteAM) + } + }) + + cfg := &setting.Cfg{ + DataPath: t.TempDir(), + UnifiedAlerting: setting.UnifiedAlertingSettings{ + AlertmanagerConfigPollInterval: 3 * time.Minute, + DefaultConfiguration: setting.GetAlertmanagerDefaultConfiguration(), + }, // do not poll in tests. + } + secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) + moa, err := notifier.NewMultiOrgAlertmanager( + cfg, + configStore, + notifier.NewFakeOrgStore(t, []int64{1}), + kvStore, + ngfakes.NewFakeProvisioningStore(), + secretsService.GetDecryptedValue, + m.GetMultiOrgAlertmanagerMetrics(), + nil, + nopLogger, + secretsService, + &featuremgmt.FeatureManager{}, + override, + ) + require.NoError(t, err) + + // It should send config and state on startup. + var lastConfig *remoteClient.UserGrafanaConfig + var lastState *remoteClient.UserGrafanaState + { + // We should start with no config and no state in the external Alertmanager. + require.Empty(t, fakeAM.config) + require.Empty(t, fakeAM.state) + + // On the first sync (startup), both config and state should be updated. + require.NoError(t, moa.LoadAndSyncAlertmanagersForOrgs(ctx)) + require.NotEmpty(t, fakeAM.config) + require.NotEmpty(t, fakeAM.state) + lastConfig, lastState = fakeAM.config, fakeAM.state + } + + // It should send config and state on an interval. + { + // Let's change the configuration and state. + require.NoError(t, configStore.SaveAlertmanagerConfiguration(ctx, &models.SaveAlertmanagerConfigurationCmd{ + AlertmanagerConfiguration: validConfig, + OrgID: 1, + LastApplied: time.Now().Unix(), + })) + require.NoError(t, kvStore.Set(ctx, 1, "alertmanager", notifier.SilencesFilename, "dGVzdAo=")) // base64-encoded string "test" + require.NoError(t, kvStore.Set(ctx, 1, "alertmanager", notifier.NotificationLogFilename, "dGVzdAo=")) // base64-encoded string "test" + + // The sync interval (10s) has not elapsed yet, syncing should have no effect. + require.NoError(t, moa.LoadAndSyncAlertmanagersForOrgs(ctx)) + require.Equal(t, fakeAM.config, lastConfig) + require.Equal(t, fakeAM.state, lastState) + + // Syncing after the sync interval elapses should update both config and state. + require.Eventually(t, func() bool { + require.NoError(t, moa.LoadAndSyncAlertmanagersForOrgs(ctx)) + return fakeAM.config != lastConfig && fakeAM.state != lastState + }, 15*time.Second, 300*time.Millisecond) + lastConfig, lastState = fakeAM.config, fakeAM.state + } + + // It should send config and state on shutdown. + { + // Let's change the configuration and state again. + require.NoError(t, configStore.SaveAlertmanagerConfiguration(ctx, &models.SaveAlertmanagerConfigurationCmd{ + AlertmanagerConfiguration: setting.GetAlertmanagerDefaultConfiguration(), + Default: true, + OrgID: 1, + LastApplied: time.Now().Unix(), + })) + require.NoError(t, kvStore.Set(ctx, 1, "alertmanager", notifier.SilencesFilename, "dGVzdC0yCg==")) // base64-encoded string "test-2" + require.NoError(t, kvStore.Set(ctx, 1, "alertmanager", notifier.NotificationLogFilename, "dGVzdC0yCg==")) // base64-encoded string "test-2" + + // Both state and config should be updated when shutting the Alertmanager down. + moa.StopAndWait() + require.NotEqual(t, fakeAM.config, lastConfig) + require.NotEqual(t, fakeAM.state, lastState) + } +} + +func newFakeRemoteAlertmanager(t *testing.T, user, pass string) *fakeRemoteAlertmanager { + return &fakeRemoteAlertmanager{ + t: t, + username: user, + password: pass, + } +} + +type fakeRemoteAlertmanager struct { + t *testing.T + config *remoteClient.UserGrafanaConfig + state *remoteClient.UserGrafanaState + username string + password string +} + +// ServeHTTP handles all routes we need for getting and setting state and config. +func (f *fakeRemoteAlertmanager) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + + // Check that basic auth is in place. + user, pass, ok := r.BasicAuth() + require.True(f.t, ok) + require.Equal(f.t, f.username, user) + require.Equal(f.t, f.password, pass) + + switch r.Method { + // GET routes + case http.MethodGet: + switch r.RequestURI { + case "/alertmanager/-/ready": + // Make the readiness check succeed. + w.WriteHeader(http.StatusOK) + case "/api/v1/grafana/config": + f.getConfig(w) + case "/api/v1/grafana/state": + f.getState(w) + default: + w.WriteHeader(http.StatusNotFound) + } + + // POST routes + case http.MethodPost: + switch r.RequestURI { + case "/api/v1/grafana/config": + f.postConfig(w, r) + case "/api/v1/grafana/state": + f.postState(w, r) + default: + w.WriteHeader(http.StatusNotFound) + } + } +} + +type response struct { + Data any `json:"data"` + Status string `json:"status"` +} + +func (f *fakeRemoteAlertmanager) postConfig(w http.ResponseWriter, r *http.Request) { + var cfg remoteClient.UserGrafanaConfig + require.NoError(f.t, json.NewDecoder(r.Body).Decode(&cfg)) + + f.config = &cfg + w.WriteHeader(http.StatusCreated) + require.NoError(f.t, json.NewEncoder(w).Encode(response{Status: "success"})) +} + +func (f *fakeRemoteAlertmanager) getConfig(w http.ResponseWriter) { + res := response{ + Data: f.config, + Status: "success", + } + require.NoError(f.t, json.NewEncoder(w).Encode(res)) +} + +func (f *fakeRemoteAlertmanager) postState(w http.ResponseWriter, r *http.Request) { + var state remoteClient.UserGrafanaState + require.NoError(f.t, json.NewDecoder(r.Body).Decode(&state)) + + f.state = &state + w.WriteHeader(http.StatusCreated) + require.NoError(f.t, json.NewEncoder(w).Encode(response{Status: "success"})) +} + +func (f *fakeRemoteAlertmanager) getState(w http.ResponseWriter) { + res := response{ + Data: f.state, + Status: "success", + } + require.NoError(f.t, json.NewEncoder(w).Encode(res)) +} + +var validConfig = `{ + "template_files": { + "a": "template" + }, + "alertmanager_config": { + "route": { + "receiver": "grafana-default-email" + }, + "receivers": [{ + "name": "grafana-default-email", + "grafana_managed_receiver_configs": [{ + "uid": "", + "name": "email receiver", + "type": "email", + "isDefault": true, + "settings": { + "addresses": "<example@email.com>" + } + }] + }] + } +}` diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go b/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go index 68d9310f4ae7c..569c3a4579c44 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go @@ -15,9 +15,9 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/store" ngfakes "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" "github.com/grafana/grafana/pkg/services/secrets/fakes" @@ -33,7 +33,7 @@ func TestMultiOrgAlertmanager_SyncAlertmanagersForOrgs(t *testing.T) { tmpDir := t.TempDir() kvStore := ngfakes.NewFakeKVStore(t) - provStore := provisioning.NewFakeProvisioningStore() + provStore := ngfakes.NewFakeProvisioningStore() secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) decryptFn := secretsService.GetDecryptedValue reg := prometheus.NewPedanticRegistry() @@ -46,7 +46,7 @@ func TestMultiOrgAlertmanager_SyncAlertmanagersForOrgs(t *testing.T) { DisabledOrgs: map[int64]struct{}{5: {}}, }, // do not poll in tests. } - mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, provStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService) + mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, provStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService, &featuremgmt.FeatureManager{}) require.NoError(t, err) ctx := context.Background() @@ -167,7 +167,7 @@ func TestMultiOrgAlertmanager_SyncAlertmanagersForOrgsWithFailures(t *testing.T) tmpDir := t.TempDir() kvStore := ngfakes.NewFakeKVStore(t) - provStore := provisioning.NewFakeProvisioningStore() + provStore := ngfakes.NewFakeProvisioningStore() secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) decryptFn := secretsService.GetDecryptedValue reg := prometheus.NewPedanticRegistry() @@ -179,7 +179,7 @@ func TestMultiOrgAlertmanager_SyncAlertmanagersForOrgsWithFailures(t *testing.T) DefaultConfiguration: setting.GetAlertmanagerDefaultConfiguration(), }, // do not poll in tests. } - mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, provStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService) + mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, provStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService, &featuremgmt.FeatureManager{}) require.NoError(t, err) ctx := context.Background() @@ -261,12 +261,12 @@ func TestMultiOrgAlertmanager_AlertmanagerFor(t *testing.T) { UnifiedAlerting: setting.UnifiedAlertingSettings{AlertmanagerConfigPollInterval: 3 * time.Minute, DefaultConfiguration: setting.GetAlertmanagerDefaultConfiguration()}, // do not poll in tests. } kvStore := ngfakes.NewFakeKVStore(t) - provStore := provisioning.NewFakeProvisioningStore() + provStore := ngfakes.NewFakeProvisioningStore() secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) decryptFn := secretsService.GetDecryptedValue reg := prometheus.NewPedanticRegistry() m := metrics.NewNGAlert(reg) - mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, provStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService) + mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, provStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService, &featuremgmt.FeatureManager{}) require.NoError(t, err) ctx := context.Background() @@ -313,12 +313,12 @@ func TestMultiOrgAlertmanager_ActivateHistoricalConfiguration(t *testing.T) { UnifiedAlerting: setting.UnifiedAlertingSettings{AlertmanagerConfigPollInterval: 3 * time.Minute, DefaultConfiguration: defaultConfig}, // do not poll in tests. } kvStore := ngfakes.NewFakeKVStore(t) - provStore := provisioning.NewFakeProvisioningStore() + provStore := ngfakes.NewFakeProvisioningStore() secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) decryptFn := secretsService.GetDecryptedValue reg := prometheus.NewPedanticRegistry() m := metrics.NewNGAlert(reg) - mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, provStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService) + mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, provStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService, &featuremgmt.FeatureManager{}) require.NoError(t, err) ctx := context.Background() diff --git a/pkg/services/ngalert/notifier/receiver_svc.go b/pkg/services/ngalert/notifier/receiver_svc.go new file mode 100644 index 0000000000000..aa1f0fbc4452e --- /dev/null +++ b/pkg/services/ngalert/notifier/receiver_svc.go @@ -0,0 +1,201 @@ +package notifier + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "slices" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/secrets" +) + +var ( + // ErrPermissionDenied is returned when the user does not have permission to perform the requested action. + ErrPermissionDenied = errors.New("permission denied") // TODO: convert to errutil + // ErrNotFound is returned when the requested resource does not exist. + ErrNotFound = errors.New("not found") // TODO: convert to errutil +) + +// ReceiverService is the service for managing alertmanager receivers. +type ReceiverService struct { + ac accesscontrol.AccessControl + provisioningStore provisoningStore + cfgStore configStore + encryptionService secrets.Service + xact transactionManager + log log.Logger +} + +type configStore interface { + GetLatestAlertmanagerConfiguration(ctx context.Context, orgID int64) (*models.AlertConfiguration, error) + UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error +} + +type provisoningStore interface { + GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]models.Provenance, error) +} + +type transactionManager interface { + InTransaction(ctx context.Context, work func(ctx context.Context) error) error +} + +func NewReceiverService( + ac accesscontrol.AccessControl, + cfgStore configStore, + provisioningStore provisoningStore, + encryptionService secrets.Service, + xact transactionManager, + log log.Logger, +) *ReceiverService { + return &ReceiverService{ + ac: ac, + provisioningStore: provisioningStore, + cfgStore: cfgStore, + encryptionService: encryptionService, + xact: xact, + log: log, + } +} + +func (rs *ReceiverService) shouldDecrypt(ctx context.Context, user identity.Requester, name string, reqDecrypt bool) (bool, error) { + // TODO: migrate to new permission + eval := accesscontrol.EvalAny( + accesscontrol.EvalPermission(accesscontrol.ActionAlertingReceiversReadSecrets), + accesscontrol.EvalPermission(accesscontrol.ActionAlertingProvisioningReadSecrets), + ) + + decryptAccess, err := rs.ac.Evaluate(ctx, user, eval) + if err != nil { + return false, err + } + + if reqDecrypt && !decryptAccess { + return false, ErrPermissionDenied + } + + return decryptAccess && reqDecrypt, nil +} + +// GetReceiver returns a receiver by name. +// The receiver's secure settings are decrypted if requested and the user has access to do so. +func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, user identity.Requester) (definitions.GettableApiReceiver, error) { + if q.Decrypt && user == nil { + return definitions.GettableApiReceiver{}, ErrPermissionDenied + } + + baseCfg, err := rs.cfgStore.GetLatestAlertmanagerConfiguration(ctx, q.OrgID) + if err != nil { + return definitions.GettableApiReceiver{}, err + } + + cfg := definitions.PostableUserConfig{} + err = json.Unmarshal([]byte(baseCfg.AlertmanagerConfiguration), &cfg) + if err != nil { + return definitions.GettableApiReceiver{}, err + } + + provenances, err := rs.provisioningStore.GetProvenances(ctx, q.OrgID, "contactPoint") + if err != nil { + return definitions.GettableApiReceiver{}, err + } + + receivers := cfg.AlertmanagerConfig.Receivers + for _, r := range receivers { + if r.Name == q.Name { + decrypt, err := rs.shouldDecrypt(ctx, user, q.Name, q.Decrypt) + if err != nil { + return definitions.GettableApiReceiver{}, err + } + decryptFn := rs.decryptOrRedact(ctx, decrypt, q.Name, "") + + return PostableToGettableApiReceiver(r, provenances, decryptFn, false) + } + } + + return definitions.GettableApiReceiver{}, ErrNotFound +} + +// GetReceivers returns a list of receivers a user has access to. +// Receivers can be filtered by name, and secure settings are decrypted if requested and the user has access to do so. +func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceiversQuery, user identity.Requester) ([]definitions.GettableApiReceiver, error) { + if q.Decrypt && user == nil { + return nil, ErrPermissionDenied + } + + baseCfg, err := rs.cfgStore.GetLatestAlertmanagerConfiguration(ctx, q.OrgID) + if err != nil { + return nil, err + } + + cfg := definitions.PostableUserConfig{} + err = json.Unmarshal([]byte(baseCfg.AlertmanagerConfiguration), &cfg) + if err != nil { + return nil, err + } + + provenances, err := rs.provisioningStore.GetProvenances(ctx, q.OrgID, "contactPoint") + if err != nil { + return nil, err + } + + eval := accesscontrol.EvalPermission(accesscontrol.ActionAlertingReceiversList) + listAccess, err := rs.ac.Evaluate(ctx, user, eval) + if err != nil { + return nil, err + } + + var output []definitions.GettableApiReceiver + for i := q.Offset; i < len(cfg.AlertmanagerConfig.Receivers); i++ { + r := cfg.AlertmanagerConfig.Receivers[i] + if len(q.Names) > 0 && !slices.Contains(q.Names, r.Name) { + continue + } + + decrypt, err := rs.shouldDecrypt(ctx, user, r.Name, q.Decrypt) + if err != nil { + return nil, err + } + + decryptFn := rs.decryptOrRedact(ctx, decrypt, r.Name, "") + listOnly := !decrypt && listAccess + + res, err := PostableToGettableApiReceiver(r, provenances, decryptFn, listOnly) + if err != nil { + return nil, err + } + + output = append(output, res) + // stop if we have reached the limit or we have found all the requested receivers + if (len(output) == q.Limit && q.Limit > 0) || (len(output) == len(q.Names)) { + break + } + } + + return output, nil +} + +func (rs *ReceiverService) decryptOrRedact(ctx context.Context, decrypt bool, name, fallback string) func(value string) string { + return func(value string) string { + if !decrypt { + return definitions.RedactedValue + } + + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + rs.log.Warn("failed to decode secure setting", "name", name, "error", err) + return fallback + } + decrypted, err := rs.encryptionService.Decrypt(ctx, decoded) + if err != nil { + rs.log.Warn("failed to decrypt secure setting", "name", name, "error", err) + return fallback + } + return string(decrypted) + } +} diff --git a/pkg/services/ngalert/notifier/receiver_svc_test.go b/pkg/services/ngalert/notifier/receiver_svc_test.go new file mode 100644 index 0000000000000..28e477f809529 --- /dev/null +++ b/pkg/services/ngalert/notifier/receiver_svc_test.go @@ -0,0 +1,261 @@ +package notifier + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" + "github.com/grafana/grafana/pkg/services/secrets" + "github.com/grafana/grafana/pkg/services/secrets/database" + "github.com/grafana/grafana/pkg/services/secrets/manager" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" +) + +func TestReceiverService_GetReceiver(t *testing.T) { + sqlStore := db.InitTestDB(t) + secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) + + t.Run("service gets receiver from AM config", func(t *testing.T) { + sut := createReceiverServiceSut(t, secretsService) + + Receiver, err := sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), nil) + require.NoError(t, err) + require.Equal(t, "slack receiver", Receiver.Name) + require.Len(t, Receiver.GrafanaManagedReceivers, 1) + require.Equal(t, "UID2", Receiver.GrafanaManagedReceivers[0].UID) + }) + + t.Run("service returns error when receiver does not exist", func(t *testing.T) { + sut := createReceiverServiceSut(t, secretsService) + + _, err := sut.GetReceiver(context.Background(), singleQ(1, "nonexistent"), nil) + require.ErrorIs(t, err, ErrNotFound) + }) +} + +func TestReceiverService_GetReceivers(t *testing.T) { + sqlStore := db.InitTestDB(t) + secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) + + t.Run("service gets receivers from AM config", func(t *testing.T) { + sut := createReceiverServiceSut(t, secretsService) + + Receivers, err := sut.GetReceivers(context.Background(), multiQ(1), nil) + require.NoError(t, err) + require.Len(t, Receivers, 2) + require.Equal(t, "grafana-default-email", Receivers[0].Name) + require.Equal(t, "slack receiver", Receivers[1].Name) + }) + + t.Run("service filters receivers by name", func(t *testing.T) { + sut := createReceiverServiceSut(t, secretsService) + + Receivers, err := sut.GetReceivers(context.Background(), multiQ(1, "slack receiver"), nil) + require.NoError(t, err) + require.Len(t, Receivers, 1) + require.Equal(t, "slack receiver", Receivers[0].Name) + }) +} + +func TestReceiverService_DecryptRedact(t *testing.T) { + sqlStore := db.InitTestDB(t) + secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) + ac := acimpl.ProvideAccessControl(setting.NewCfg()) + + getMethods := []string{"single", "multi"} + + readUser := &user.SignedInUser{ + OrgID: 1, + Permissions: map[int64]map[string][]string{ + 1: {accesscontrol.ActionAlertingProvisioningRead: nil}, + }, + } + + secretUser := &user.SignedInUser{ + OrgID: 1, + Permissions: map[int64]map[string][]string{ + 1: { + accesscontrol.ActionAlertingProvisioningRead: nil, + accesscontrol.ActionAlertingProvisioningReadSecrets: nil, + }, + }, + } + + for _, tc := range []struct { + name string + decrypt bool + user *user.SignedInUser + err error + }{ + { + name: "service redacts receivers by default", + decrypt: false, + user: readUser, + err: nil, + }, + { + name: "service returns error when trying to decrypt without permission", + decrypt: true, + user: readUser, + err: ErrPermissionDenied, + }, + { + name: "service returns error if user is nil and decrypt is true", + decrypt: true, + user: nil, + err: ErrPermissionDenied, + }, + { + name: "service decrypts receivers with permission", + decrypt: true, + user: secretUser, + err: nil, + }, + } { + for _, method := range getMethods { + t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) { + sut := createReceiverServiceSut(t, secretsService) + sut.ac = ac + + var res definitions.GettableApiReceiver + var err error + if method == "single" { + q := singleQ(1, "slack receiver") + q.Decrypt = tc.decrypt + res, err = sut.GetReceiver(context.Background(), q, tc.user) + } else { + q := multiQ(1, "slack receiver") + q.Decrypt = tc.decrypt + var multiRes []definitions.GettableApiReceiver + multiRes, err = sut.GetReceivers(context.Background(), q, tc.user) + if tc.err == nil { + require.Len(t, multiRes, 1) + res = multiRes[0] + } + } + require.ErrorIs(t, err, tc.err) + + if tc.err == nil { + require.Equal(t, "slack receiver", res.Name) + require.Len(t, res.GrafanaManagedReceivers, 1) + require.Equal(t, "UID2", res.GrafanaManagedReceivers[0].UID) + + testedSettings, err := simplejson.NewJson([]byte(res.GrafanaManagedReceivers[0].Settings)) + require.NoError(t, err) + if tc.decrypt { + require.Equal(t, "secure url", testedSettings.Get("url").MustString()) + } else { + require.Equal(t, definitions.RedactedValue, testedSettings.Get("url").MustString()) + } + } + }) + } + } +} + +func createReceiverServiceSut(t *testing.T, encryptSvc secrets.Service) *ReceiverService { + cfg := createEncryptedConfig(t, encryptSvc) + store := fakes.NewFakeAlertmanagerConfigStore(cfg) + xact := newNopTransactionManager() + provisioningStore := fakes.NewFakeProvisioningStore() + + return &ReceiverService{ + actest.FakeAccessControl{}, + provisioningStore, + store, + encryptSvc, + xact, + log.NewNopLogger(), + } +} + +func createEncryptedConfig(t *testing.T, secretService secrets.Service) string { + c := &definitions.PostableUserConfig{} + err := json.Unmarshal([]byte(defaultAlertmanagerConfigJSON), c) + require.NoError(t, err) + err = EncryptReceiverConfigs(c.AlertmanagerConfig.Receivers, func(ctx context.Context, payload []byte) ([]byte, error) { + return secretService.Encrypt(ctx, payload, secrets.WithoutScope()) + }) + require.NoError(t, err) + bytes, err := json.Marshal(c) + require.NoError(t, err) + return string(bytes) +} + +func singleQ(orgID int64, name string) models.GetReceiverQuery { + return models.GetReceiverQuery{ + OrgID: orgID, + Name: name, + } +} + +func multiQ(orgID int64, names ...string) models.GetReceiversQuery { + return models.GetReceiversQuery{ + OrgID: orgID, + Names: names, + } +} + +const defaultAlertmanagerConfigJSON = ` +{ + "template_files": null, + "alertmanager_config": { + "route": { + "receiver": "grafana-default-email", + "group_by": [ + "..." + ], + "routes": [{ + "receiver": "grafana-default-email", + "object_matchers": [["a", "=", "b"]] + }] + }, + "templates": null, + "receivers": [{ + "name": "grafana-default-email", + "grafana_managed_receiver_configs": [{ + "uid": "UID1", + "name": "grafana-default-email", + "type": "email", + "disableResolveMessage": false, + "settings": { + "addresses": "\u003cexample@email.com\u003e" + }, + "secureFields": {} + }] + }, { + "name": "slack receiver", + "grafana_managed_receiver_configs": [{ + "uid": "UID2", + "name": "slack receiver", + "type": "slack", + "disableResolveMessage": false, + "settings": {}, + "secureSettings": {"url":"secure url"} + }] + }] + } +} +` + +type NopTransactionManager struct{} + +func newNopTransactionManager() *NopTransactionManager { + return &NopTransactionManager{} +} + +func (n *NopTransactionManager) InTransaction(ctx context.Context, work func(ctx context.Context) error) error { + return work(context.WithValue(ctx, NopTransactionManager{}, struct{}{})) +} diff --git a/pkg/services/ngalert/notifier/status.go b/pkg/services/ngalert/notifier/status.go index bae821f6d4023..bcdf065616ca6 100644 --- a/pkg/services/ngalert/notifier/status.go +++ b/pkg/services/ngalert/notifier/status.go @@ -9,7 +9,7 @@ import ( // TODO: We no longer do apimodels at this layer, move it to the API. func (am *alertmanager) GetStatus() apimodels.GettableStatus { config := &apimodels.PostableUserConfig{} - status := am.Base.GetStatus() + status := am.Base.GetStatus() // TODO: This should return a GettableStatus, for now it returns PostableUserConfig. if status == nil { return *apimodels.NewGettableStatus(&config.AlertmanagerConfig) } diff --git a/pkg/services/ngalert/notifier/testing.go b/pkg/services/ngalert/notifier/testing.go index 8ceb967a9ad9c..6e9d30fd2a743 100644 --- a/pkg/services/ngalert/notifier/testing.go +++ b/pkg/services/ngalert/notifier/testing.go @@ -19,6 +19,35 @@ type fakeConfigStore struct { // historicConfigs stores configs by orgID. historicConfigs map[int64][]*models.HistoricAlertConfiguration + + // notificationSettings stores notification settings by orgID. + notificationSettings map[int64]map[models.AlertRuleKey][]models.NotificationSettings +} + +func (f *fakeConfigStore) ListNotificationSettings(ctx context.Context, q models.ListNotificationSettingsQuery) (map[models.AlertRuleKey][]models.NotificationSettings, error) { + settings, ok := f.notificationSettings[q.OrgID] + if !ok { + return nil, nil + } + if q.ReceiverName != "" { + filteredSettings := make(map[models.AlertRuleKey][]models.NotificationSettings) + for key, notificationSettings := range settings { + // Current semantics is that we only key entries where any of the settings match the receiver name. + var found bool + for _, setting := range notificationSettings { + if q.ReceiverName == setting.Receiver { + found = true + break + } + } + if found { + filteredSettings[key] = notificationSettings + } + } + return filteredSettings, nil + } + + return settings, nil } // Saves the image or returns an error. @@ -180,10 +209,10 @@ type FakeOrgStore struct { orgs []int64 } -func NewFakeOrgStore(t *testing.T, orgs []int64) FakeOrgStore { +func NewFakeOrgStore(t *testing.T, orgs []int64) *FakeOrgStore { t.Helper() - return FakeOrgStore{ + return &FakeOrgStore{ orgs: orgs, } } @@ -199,3 +228,10 @@ type fakeState struct { func (fs *fakeState) MarshalBinary() ([]byte, error) { return []byte(fs.data), nil } + +type NoValidation struct { +} + +func (n NoValidation) Validate(_ models.NotificationSettings) error { + return nil +} diff --git a/pkg/services/ngalert/notifier/receivers.go b/pkg/services/ngalert/notifier/testreceivers.go similarity index 100% rename from pkg/services/ngalert/notifier/receivers.go rename to pkg/services/ngalert/notifier/testreceivers.go diff --git a/pkg/services/ngalert/notifier/receivers_test.go b/pkg/services/ngalert/notifier/testreceivers_test.go similarity index 100% rename from pkg/services/ngalert/notifier/receivers_test.go rename to pkg/services/ngalert/notifier/testreceivers_test.go diff --git a/pkg/services/ngalert/notifier/validation.go b/pkg/services/ngalert/notifier/validation.go new file mode 100644 index 0000000000000..48e0f20d2ad2f --- /dev/null +++ b/pkg/services/ngalert/notifier/validation.go @@ -0,0 +1,136 @@ +package notifier + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/prometheus/alertmanager/config" +) + +// NotificationSettingsValidator validates NotificationSettings against the current Alertmanager configuration +type NotificationSettingsValidator interface { + Validate(s models.NotificationSettings) error +} + +// staticValidator is a NotificationSettingsValidator that uses static pre-fetched values for available receivers and mute timings. +type staticValidator struct { + availableReceivers map[string]struct{} + availableTimeIntervals map[string]struct{} +} + +// apiAlertingConfig contains the methods required to validate NotificationSettings and create autogen routes. +type apiAlertingConfig[R receiver] interface { + GetReceivers() []R + GetMuteTimeIntervals() []config.MuteTimeInterval + GetTimeIntervals() []config.TimeInterval + GetRoute() *definitions.Route +} + +type receiver interface { + GetName() string +} + +// NewNotificationSettingsValidator creates a new NotificationSettingsValidator from the given apiAlertingConfig. +func NewNotificationSettingsValidator[R receiver](am apiAlertingConfig[R]) NotificationSettingsValidator { + availableReceivers := make(map[string]struct{}) + for _, receiver := range am.GetReceivers() { + availableReceivers[receiver.GetName()] = struct{}{} + } + + availableTimeIntervals := make(map[string]struct{}) + for _, interval := range am.GetMuteTimeIntervals() { + availableTimeIntervals[interval.Name] = struct{}{} + } + for _, interval := range am.GetTimeIntervals() { + availableTimeIntervals[interval.Name] = struct{}{} + } + + return staticValidator{ + availableReceivers: availableReceivers, + availableTimeIntervals: availableTimeIntervals, + } +} + +// Validate checks that models.NotificationSettings is valid and references existing receivers and mute timings. +func (n staticValidator) Validate(settings models.NotificationSettings) error { + if err := settings.Validate(); err != nil { + return err + } + var errs []error + if _, ok := n.availableReceivers[settings.Receiver]; !ok { + errs = append(errs, fmt.Errorf("receiver '%s' does not exist", settings.Receiver)) + } + for _, interval := range settings.MuteTimeIntervals { + if _, ok := n.availableTimeIntervals[interval]; !ok { + errs = append(errs, fmt.Errorf("mute time interval '%s' does not exist", interval)) + } + } + return errors.Join(errs...) +} + +// NotificationSettingsValidatorProvider provides a NotificationSettingsValidator for a given orgID. +type NotificationSettingsValidatorProvider interface { + Validator(ctx context.Context, orgID int64) (NotificationSettingsValidator, error) +} + +// notificationSettingsValidationService provides a new NotificationSettingsValidator for a given orgID by loading the latest Alertmanager configuration. +type notificationSettingsValidationService struct { + store store.AlertingStore +} + +func NewNotificationSettingsValidationService(store store.AlertingStore) NotificationSettingsValidatorProvider { + return ¬ificationSettingsValidationService{ + store: store, + } +} + +// Validator returns a NotificationSettingsValidator using the alertmanager configuration from the given orgID. +func (v *notificationSettingsValidationService) Validator(ctx context.Context, orgID int64) (NotificationSettingsValidator, error) { + rawCfg, err := v.store.GetLatestAlertmanagerConfiguration(ctx, orgID) + if err != nil { + return staticValidator{}, err + } + cfg, err := Load([]byte(rawCfg.AlertmanagerConfiguration)) + if err != nil { + return staticValidator{}, err + } + log.New("ngalert.notifier.validator").FromContext(ctx).Debug("Create validator from Alertmanager configuration", "hash", rawCfg.ConfigurationHash) + return NewNotificationSettingsValidator(&cfg.AlertmanagerConfig), nil +} + +type cachedNotificationSettingsValidationService struct { + srv NotificationSettingsValidatorProvider + mtx sync.Mutex + validators map[int64]NotificationSettingsValidator +} + +func NewCachedNotificationSettingsValidationService(store store.AlertingStore) NotificationSettingsValidatorProvider { + return &cachedNotificationSettingsValidationService{ + srv: NewNotificationSettingsValidationService(store), + mtx: sync.Mutex{}, + validators: map[int64]NotificationSettingsValidator{}, + } +} + +// Validator returns a NotificationSettingsValidator using the alertmanager configuration from the given orgID. +func (v *cachedNotificationSettingsValidationService) Validator(ctx context.Context, orgID int64) (NotificationSettingsValidator, error) { + v.mtx.Lock() + defer v.mtx.Unlock() + + result, ok := v.validators[orgID] + if !ok { + vd, err := v.srv.Validator(ctx, orgID) + if err != nil { + return nil, err + } + v.validators[orgID] = vd + result = vd + } + return result, nil +} diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go index 043f7d8d98917..20c6046028fcc 100644 --- a/pkg/services/ngalert/provisioning/alert_rules.go +++ b/pkg/services/ngalert/provisioning/alert_rules.go @@ -7,47 +7,64 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/util" ) +type NotificationSettingsValidatorProvider interface { + Validator(ctx context.Context, orgID int64) (notifier.NotificationSettingsValidator, error) +} + type AlertRuleService struct { defaultIntervalSeconds int64 baseIntervalSeconds int64 + rulesPerRuleGroupLimit int64 ruleStore RuleStore provenanceStore ProvisioningStore + folderService folder.Service dashboardService dashboards.DashboardService quotas QuotaChecker xact TransactionManager log log.Logger + nsValidatorProvider NotificationSettingsValidatorProvider } func NewAlertRuleService(ruleStore RuleStore, provenanceStore ProvisioningStore, + folderService folder.Service, dashboardService dashboards.DashboardService, quotas QuotaChecker, xact TransactionManager, defaultIntervalSeconds int64, baseIntervalSeconds int64, - log log.Logger) *AlertRuleService { + rulesPerRuleGroupLimit int64, + log log.Logger, + ns NotificationSettingsValidatorProvider, +) *AlertRuleService { return &AlertRuleService{ defaultIntervalSeconds: defaultIntervalSeconds, baseIntervalSeconds: baseIntervalSeconds, + rulesPerRuleGroupLimit: rulesPerRuleGroupLimit, ruleStore: ruleStore, provenanceStore: provenanceStore, + folderService: folderService, dashboardService: dashboardService, quotas: quotas, xact: xact, log: log, + nsValidatorProvider: ns, } } -func (service *AlertRuleService) GetAlertRules(ctx context.Context, orgID int64) ([]*models.AlertRule, map[string]models.Provenance, error) { +func (service *AlertRuleService) GetAlertRules(ctx context.Context, user identity.Requester) ([]*models.AlertRule, map[string]models.Provenance, error) { q := models.ListAlertRulesQuery{ - OrgID: orgID, + OrgID: user.GetOrgID(), } rules, err := service.ruleStore.ListAlertRules(ctx, &q) if err != nil { @@ -56,7 +73,7 @@ func (service *AlertRuleService) GetAlertRules(ctx context.Context, orgID int64) provenances := make(map[string]models.Provenance) if len(rules) > 0 { resourceType := rules[0].ResourceType() - provenances, err = service.provenanceStore.GetProvenances(ctx, orgID, resourceType) + provenances, err = service.provenanceStore.GetProvenances(ctx, user.GetOrgID(), resourceType) if err != nil { return nil, nil, err } @@ -64,16 +81,16 @@ func (service *AlertRuleService) GetAlertRules(ctx context.Context, orgID int64) return rules, provenances, nil } -func (service *AlertRuleService) GetAlertRule(ctx context.Context, orgID int64, ruleUID string) (models.AlertRule, models.Provenance, error) { +func (service *AlertRuleService) GetAlertRule(ctx context.Context, user identity.Requester, ruleUID string) (models.AlertRule, models.Provenance, error) { query := &models.GetAlertRuleByUIDQuery{ - OrgID: orgID, + OrgID: user.GetOrgID(), UID: ruleUID, } rule, err := service.ruleStore.GetAlertRuleByUID(ctx, query) if err != nil { return models.AlertRule{}, models.ProvenanceNone, err } - provenance, err := service.provenanceStore.GetProvenance(ctx, rule, orgID) + provenance, err := service.provenanceStore.GetProvenance(ctx, rule, user.GetOrgID()) if err != nil { return models.AlertRule{}, models.ProvenanceNone, err } @@ -86,9 +103,9 @@ type AlertRuleWithFolderTitle struct { } // GetAlertRuleWithFolderTitle returns a single alert rule with its folder title. -func (service *AlertRuleService) GetAlertRuleWithFolderTitle(ctx context.Context, orgID int64, ruleUID string) (AlertRuleWithFolderTitle, error) { +func (service *AlertRuleService) GetAlertRuleWithFolderTitle(ctx context.Context, user identity.Requester, ruleUID string) (AlertRuleWithFolderTitle, error) { query := &models.GetAlertRuleByUIDQuery{ - OrgID: orgID, + OrgID: user.GetOrgID(), UID: ruleUID, } rule, err := service.ruleStore.GetAlertRuleByUID(ctx, query) @@ -97,7 +114,7 @@ func (service *AlertRuleService) GetAlertRuleWithFolderTitle(ctx context.Context } dq := dashboards.GetDashboardQuery{ - OrgID: orgID, + OrgID: user.GetOrgID(), UID: rule.NamespaceUID, } @@ -115,15 +132,15 @@ func (service *AlertRuleService) GetAlertRuleWithFolderTitle(ctx context.Context // CreateAlertRule creates a new alert rule. This function will ignore any // interval that is set in the rule struct and use the already existing group // interval or the default one. -func (service *AlertRuleService) CreateAlertRule(ctx context.Context, rule models.AlertRule, provenance models.Provenance, userID int64) (models.AlertRule, error) { +func (service *AlertRuleService) CreateAlertRule(ctx context.Context, user identity.Requester, rule models.AlertRule, provenance models.Provenance) (models.AlertRule, error) { if rule.UID == "" { rule.UID = util.GenerateShortUID() } else if err := util.ValidateUID(rule.UID); err != nil { return models.AlertRule{}, errors.Join(models.ErrAlertRuleFailedValidation, fmt.Errorf("cannot create rule with UID '%s': %w", rule.UID, err)) } interval, err := service.ruleStore.GetRuleGroupInterval(ctx, rule.OrgID, rule.NamespaceUID, rule.RuleGroup) - // if the alert group does not exists we just use the default interval - if err != nil && errors.Is(err, store.ErrAlertRuleGroupNotFound) { + // if the alert group does not exist we just use the default interval + if err != nil && errors.Is(err, models.ErrAlertRuleGroupNotFound) { interval = service.defaultIntervalSeconds } else if err != nil { return models.AlertRule{}, err @@ -133,7 +150,21 @@ func (service *AlertRuleService) CreateAlertRule(ctx context.Context, rule model if err != nil { return models.AlertRule{}, err } + if err = service.ensureRuleNamespace(ctx, user, rule); err != nil { + return models.AlertRule{}, err + } rule.Updated = time.Now() + if len(rule.NotificationSettings) > 0 { + validator, err := service.nsValidatorProvider.Validator(ctx, rule.OrgID) + if err != nil { + return models.AlertRule{}, err + } + for _, setting := range rule.NotificationSettings { + if err := validator.Validate(setting); err != nil { + return models.AlertRule{}, err + } + } + } err = service.xact.InTransaction(ctx, func(ctx context.Context) error { ids, err := service.ruleStore.InsertAlertRules(ctx, []models.AlertRule{ rule, @@ -153,7 +184,7 @@ func (service *AlertRuleService) CreateAlertRule(ctx context.Context, rule model return errors.New("couldn't find newly created id") } - if err = service.checkLimitsTransactionCtx(ctx, rule.OrgID, userID); err != nil { + if err = service.checkLimitsTransactionCtx(ctx, user); err != nil { return err } @@ -165,9 +196,9 @@ func (service *AlertRuleService) CreateAlertRule(ctx context.Context, rule model return rule, nil } -func (service *AlertRuleService) GetRuleGroup(ctx context.Context, orgID int64, namespaceUID, group string) (models.AlertRuleGroup, error) { +func (service *AlertRuleService) GetRuleGroup(ctx context.Context, user identity.Requester, namespaceUID, group string) (models.AlertRuleGroup, error) { q := models.ListAlertRulesQuery{ - OrgID: orgID, + OrgID: user.GetOrgID(), NamespaceUIDs: []string{namespaceUID}, RuleGroup: group, } @@ -176,7 +207,7 @@ func (service *AlertRuleService) GetRuleGroup(ctx context.Context, orgID int64, return models.AlertRuleGroup{}, err } if len(ruleList) == 0 { - return models.AlertRuleGroup{}, store.ErrAlertRuleGroupNotFound + return models.AlertRuleGroup{}, models.ErrAlertRuleGroupNotFound.Errorf("") } res := models.AlertRuleGroup{ Title: ruleList[0].RuleGroup, @@ -193,13 +224,13 @@ func (service *AlertRuleService) GetRuleGroup(ctx context.Context, orgID int64, } // UpdateRuleGroup will update the interval for all rules in the group. -func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string, intervalSeconds int64) error { +func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, user identity.Requester, namespaceUID string, ruleGroup string, intervalSeconds int64) error { if err := models.ValidateRuleGroupInterval(intervalSeconds, service.baseIntervalSeconds); err != nil { return err } return service.xact.InTransaction(ctx, func(ctx context.Context) error { query := &models.ListAlertRulesQuery{ - OrgID: orgID, + OrgID: user.GetOrgID(), NamespaceUIDs: []string{namespaceUID}, RuleGroup: ruleGroup, } @@ -223,22 +254,80 @@ func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, orgID int6 }) } -func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, orgID int64, group models.AlertRuleGroup, userID int64, provenance models.Provenance) error { +func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, user identity.Requester, group models.AlertRuleGroup, provenance models.Provenance) error { if err := models.ValidateRuleGroupInterval(group.Interval, service.baseIntervalSeconds); err != nil { return err } + delta, err := service.calcDelta(ctx, user, group) + if err != nil { + return err + } + + if len(delta.New) == 0 && len(delta.Update) == 0 && len(delta.Delete) == 0 { + return nil + } + + newOrUpdatedNotificationSettings := delta.NewOrUpdatedNotificationSettings() + if len(newOrUpdatedNotificationSettings) > 0 { + validator, err := service.nsValidatorProvider.Validator(ctx, delta.GroupKey.OrgID) + if err != nil { + return err + } + for _, s := range newOrUpdatedNotificationSettings { + if err := validator.Validate(s); err != nil { + return errors.Join(models.ErrAlertRuleFailedValidation, err) + } + } + } + + return service.persistDelta(ctx, user, delta, provenance) +} + +func (service *AlertRuleService) DeleteRuleGroup(ctx context.Context, user identity.Requester, namespaceUID, group string, provenance models.Provenance) error { + // List all rules in the group. + q := models.ListAlertRulesQuery{ + OrgID: user.GetOrgID(), + NamespaceUIDs: []string{namespaceUID}, + RuleGroup: group, + } + ruleList, err := service.ruleStore.ListAlertRules(ctx, &q) + if err != nil { + return err + } + if len(ruleList) == 0 { + return models.ErrAlertRuleGroupNotFound.Errorf("") + } + + // Check provenance for all rules in the group. Fail to delete if any deletions aren't allowed. + for _, rule := range ruleList { + storedProvenance, err := service.provenanceStore.GetProvenance(ctx, rule, rule.OrgID) + if err != nil { + return err + } + if storedProvenance != provenance && storedProvenance != models.ProvenanceNone { + return fmt.Errorf("cannot delete with provided provenance '%s', needs '%s'", provenance, storedProvenance) + } + } + + // Delete all rules. + return service.xact.InTransaction(ctx, func(ctx context.Context) error { + return service.deleteRules(ctx, user.GetOrgID(), ruleList...) + }) +} + +func (service *AlertRuleService) calcDelta(ctx context.Context, user identity.Requester, group models.AlertRuleGroup) (*store.GroupDelta, error) { // If the provided request did not provide the rules list at all, treat it as though it does not wish to change rules. // This is done for backwards compatibility. Requests which specify only the interval must update only the interval. if group.Rules == nil { listRulesQuery := models.ListAlertRulesQuery{ - OrgID: orgID, + OrgID: user.GetOrgID(), NamespaceUIDs: []string{group.FolderUID}, RuleGroup: group.Title, } ruleList, err := service.ruleStore.ListAlertRules(ctx, &listRulesQuery) if err != nil { - return fmt.Errorf("failed to list alert rules: %w", err) + return nil, fmt.Errorf("failed to list alert rules: %w", err) } group.Rules = make([]models.AlertRule, 0, len(ruleList)) for _, r := range ruleList { @@ -248,37 +337,39 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, orgID int } } + if err := service.checkGroupLimits(group); err != nil { + return nil, fmt.Errorf("write rejected due to exceeded limits: %w", err) + } + key := models.AlertRuleGroupKey{ - OrgID: orgID, + OrgID: user.GetOrgID(), NamespaceUID: group.FolderUID, RuleGroup: group.Title, } rules := make([]*models.AlertRuleWithOptionals, len(group.Rules)) - group = *syncGroupRuleFields(&group, orgID) + group = *syncGroupRuleFields(&group, user.GetOrgID()) for i := range group.Rules { if err := group.Rules[i].SetDashboardAndPanelFromAnnotations(); err != nil { - return err + return nil, err } rules = append(rules, &models.AlertRuleWithOptionals{AlertRule: group.Rules[i], HasPause: true}) } delta, err := store.CalculateChanges(ctx, service.ruleStore, key, rules) if err != nil { - return fmt.Errorf("failed to calculate diff for alert rules: %w", err) + return nil, fmt.Errorf("failed to calculate diff for alert rules: %w", err) } // Refresh all calculated fields across all rules. - delta = store.UpdateCalculatedRuleFields(delta) - - if len(delta.New) == 0 && len(delta.Update) == 0 && len(delta.Delete) == 0 { - return nil - } + return store.UpdateCalculatedRuleFields(delta), nil +} +func (service *AlertRuleService) persistDelta(ctx context.Context, user identity.Requester, delta *store.GroupDelta, provenance models.Provenance) error { return service.xact.InTransaction(ctx, func(ctx context.Context) error { // Delete first as this could prevent future unique constraint violations. if len(delta.Delete) > 0 { for _, del := range delta.Delete { // check that provenance is not changed in an invalid way - storedProvenance, err := service.provenanceStore.GetProvenance(ctx, del, orgID) + storedProvenance, err := service.provenanceStore.GetProvenance(ctx, del, user.GetOrgID()) if err != nil { return err } @@ -286,7 +377,7 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, orgID int return fmt.Errorf("cannot update with provided provenance '%s', needs '%s'", provenance, storedProvenance) } } - if err := service.deleteRules(ctx, orgID, delta.Delete...); err != nil { + if err := service.deleteRules(ctx, user.GetOrgID(), delta.Delete...); err != nil { return err } } @@ -295,7 +386,7 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, orgID int updates := make([]models.UpdateRule, 0, len(delta.Update)) for _, update := range delta.Update { // check that provenance is not changed in an invalid way - storedProvenance, err := service.provenanceStore.GetProvenance(ctx, update.New, orgID) + storedProvenance, err := service.provenanceStore.GetProvenance(ctx, update.New, user.GetOrgID()) if err != nil { return err } @@ -307,11 +398,11 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, orgID int New: *update.New, }) } - if err = service.ruleStore.UpdateAlertRules(ctx, updates); err != nil { + if err := service.ruleStore.UpdateAlertRules(ctx, updates); err != nil { return fmt.Errorf("failed to update alert rules: %w", err) } for _, update := range delta.Update { - if err := service.provenanceStore.SetProvenance(ctx, update.New, orgID, provenance); err != nil { + if err := service.provenanceStore.SetProvenance(ctx, update.New, user.GetOrgID(), provenance); err != nil { return err } } @@ -323,13 +414,13 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, orgID int return fmt.Errorf("failed to insert alert rules: %w", err) } for _, key := range uids { - if err := service.provenanceStore.SetProvenance(ctx, &models.AlertRule{UID: key.UID}, orgID, provenance); err != nil { + if err := service.provenanceStore.SetProvenance(ctx, &models.AlertRule{UID: key.UID}, user.GetOrgID(), provenance); err != nil { return err } } } - if err = service.checkLimitsTransactionCtx(ctx, orgID, userID); err != nil { + if err := service.checkLimitsTransactionCtx(ctx, user); err != nil { return err } @@ -338,14 +429,25 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, orgID int } // UpdateAlertRule updates an alert rule. -func (service *AlertRuleService) UpdateAlertRule(ctx context.Context, rule models.AlertRule, provenance models.Provenance) (models.AlertRule, error) { - storedRule, storedProvenance, err := service.GetAlertRule(ctx, rule.OrgID, rule.UID) +func (service *AlertRuleService) UpdateAlertRule(ctx context.Context, user identity.Requester, rule models.AlertRule, provenance models.Provenance) (models.AlertRule, error) { + storedRule, storedProvenance, err := service.GetAlertRule(ctx, user, rule.UID) if err != nil { return models.AlertRule{}, err } if storedProvenance != provenance && storedProvenance != models.ProvenanceNone { return models.AlertRule{}, fmt.Errorf("cannot change provenance from '%s' to '%s'", storedProvenance, provenance) } + if len(rule.NotificationSettings) > 0 { + validator, err := service.nsValidatorProvider.Validator(ctx, rule.OrgID) + if err != nil { + return models.AlertRule{}, err + } + for _, setting := range rule.NotificationSettings { + if err := validator.Validate(setting); err != nil { + return models.AlertRule{}, err + } + } + } rule.Updated = time.Now() rule.ID = storedRule.ID rule.IntervalSeconds = storedRule.IntervalSeconds @@ -371,9 +473,9 @@ func (service *AlertRuleService) UpdateAlertRule(ctx context.Context, rule model return rule, err } -func (service *AlertRuleService) DeleteAlertRule(ctx context.Context, orgID int64, ruleUID string, provenance models.Provenance) error { +func (service *AlertRuleService) DeleteAlertRule(ctx context.Context, user identity.Requester, ruleUID string, provenance models.Provenance) error { rule := &models.AlertRule{ - OrgID: orgID, + OrgID: user.GetOrgID(), UID: ruleUID, } // check that provenance is not changed in an invalid way @@ -385,14 +487,22 @@ func (service *AlertRuleService) DeleteAlertRule(ctx context.Context, orgID int6 return fmt.Errorf("cannot delete with provided provenance '%s', needs '%s'", provenance, storedProvenance) } return service.xact.InTransaction(ctx, func(ctx context.Context) error { - return service.deleteRules(ctx, orgID, rule) + return service.deleteRules(ctx, user.GetOrgID(), rule) }) } // checkLimitsTransactionCtx checks whether the current transaction (as identified by the ctx) breaches configured alert rule limits. -func (service *AlertRuleService) checkLimitsTransactionCtx(ctx context.Context, orgID, userID int64) error { +func (service *AlertRuleService) checkLimitsTransactionCtx(ctx context.Context, user identity.Requester) error { + // default to 0 if there is no user + userID := int64(0) + u, err := identity.UserIdentifier(user.GetNamespacedID()) + if err != nil { + return fmt.Errorf("failed to check alert rule quota: %w", err) + } + userID = u + limitReached, err := service.quotas.CheckQuotaReached(ctx, models.QuotaTargetSrv, "a.ScopeParameters{ - OrgID: orgID, + OrgID: user.GetOrgID(), UserID: userID, }) if err != nil { @@ -425,9 +535,9 @@ func (service *AlertRuleService) deleteRules(ctx context.Context, orgID int64, t } // GetAlertRuleGroupWithFolderTitle returns the alert rule group with folder title. -func (service *AlertRuleService) GetAlertRuleGroupWithFolderTitle(ctx context.Context, orgID int64, namespaceUID, group string) (models.AlertRuleGroupWithFolderTitle, error) { +func (service *AlertRuleService) GetAlertRuleGroupWithFolderTitle(ctx context.Context, user identity.Requester, namespaceUID, group string) (models.AlertRuleGroupWithFolderTitle, error) { q := models.ListAlertRulesQuery{ - OrgID: orgID, + OrgID: user.GetOrgID(), NamespaceUIDs: []string{namespaceUID}, RuleGroup: group, } @@ -436,11 +546,11 @@ func (service *AlertRuleService) GetAlertRuleGroupWithFolderTitle(ctx context.Co return models.AlertRuleGroupWithFolderTitle{}, err } if len(ruleList) == 0 { - return models.AlertRuleGroupWithFolderTitle{}, store.ErrAlertRuleGroupNotFound + return models.AlertRuleGroupWithFolderTitle{}, models.ErrAlertRuleGroupNotFound.Errorf("") } dq := dashboards.GetDashboardQuery{ - OrgID: orgID, + OrgID: user.GetOrgID(), UID: namespaceUID, } dash, err := service.dashboardService.GetDashboard(ctx, &dq) @@ -453,9 +563,9 @@ func (service *AlertRuleService) GetAlertRuleGroupWithFolderTitle(ctx context.Co } // GetAlertGroupsWithFolderTitle returns all groups with folder title in the folders identified by folderUID that have at least one alert. If argument folderUIDs is nil or empty - returns groups in all folders. -func (service *AlertRuleService) GetAlertGroupsWithFolderTitle(ctx context.Context, orgID int64, folderUIDs []string) ([]models.AlertRuleGroupWithFolderTitle, error) { +func (service *AlertRuleService) GetAlertGroupsWithFolderTitle(ctx context.Context, user identity.Requester, folderUIDs []string) ([]models.AlertRuleGroupWithFolderTitle, error) { q := models.ListAlertRulesQuery{ - OrgID: orgID, + OrgID: user.GetOrgID(), } if len(folderUIDs) > 0 { @@ -533,3 +643,44 @@ func withoutNilAlertRules(ptrs []*models.AlertRule) []models.AlertRule { } return result } + +func (service *AlertRuleService) checkGroupLimits(group models.AlertRuleGroup) error { + if service.rulesPerRuleGroupLimit > 0 && int64(len(group.Rules)) > service.rulesPerRuleGroupLimit { + service.log.Warn("Large rule group was edited. Large groups are discouraged and may be rejected in the future.", + "limit", service.rulesPerRuleGroupLimit, + "actual", len(group.Rules), + "group", group.Title, + ) + } + + return nil +} + +// ensureRuleNamespace ensures that the rule has a valid namespace UID. +// If the rule does not have a namespace UID or the namespace (folder) does not exist it will return an error. +func (service *AlertRuleService) ensureRuleNamespace(ctx context.Context, user identity.Requester, rule models.AlertRule) error { + if rule.NamespaceUID == "" { + return fmt.Errorf("%w: folderUID must be set", models.ErrAlertRuleFailedValidation) + } + + if user == nil { + // user is nil when this is called during file provisioning, + // which already creates the folder if it does not exist + return nil + } + + // ensure the namespace exists + _, err := service.folderService.Get(ctx, &folder.GetFolderQuery{ + OrgID: rule.OrgID, + UID: &rule.NamespaceUID, + SignedInUser: user, + }) + if err != nil { + if errors.Is(err, dashboards.ErrFolderNotFound) { + return fmt.Errorf("%w: folder does not exist", models.ErrAlertRuleFailedValidation) + } + return err + } + + return nil +} diff --git a/pkg/services/ngalert/provisioning/alert_rules_test.go b/pkg/services/ngalert/provisioning/alert_rules_test.go index be924adef9199..f44a94f86ccc5 100644 --- a/pkg/services/ngalert/provisioning/alert_rules_test.go +++ b/pkg/services/ngalert/provisioning/alert_rules_test.go @@ -11,10 +11,13 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/setting" @@ -23,17 +26,21 @@ import ( func TestAlertRuleService(t *testing.T) { ruleService := createAlertRuleService(t) var orgID int64 = 1 + u := &user.SignedInUser{ + UserID: 1, + OrgID: orgID, + } t.Run("group creation should set the right provenance", func(t *testing.T) { group := createDummyGroup("group-test-1", orgID) - err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI) + err := ruleService.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceAPI) require.NoError(t, err) - readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "group-test-1") + readGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "group-test-1") require.NoError(t, err) require.NotEmpty(t, readGroup.Rules) for _, rule := range readGroup.Rules { - _, provenance, err := ruleService.GetAlertRule(context.Background(), orgID, rule.UID) + _, provenance, err := ruleService.GetAlertRule(context.Background(), u, rule.UID) require.NoError(t, err) require.Equal(t, models.ProvenanceAPI, provenance) } @@ -42,15 +49,15 @@ func TestAlertRuleService(t *testing.T) { t.Run("alert rule group should be updated correctly", func(t *testing.T) { rule := dummyRule("test#3", orgID) rule.RuleGroup = "a" - rule, err := ruleService.CreateAlertRule(context.Background(), rule, models.ProvenanceNone, 0) + rule, err := ruleService.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone) require.NoError(t, err) require.Equal(t, int64(60), rule.IntervalSeconds) var interval int64 = 120 - err = ruleService.UpdateRuleGroup(context.Background(), orgID, rule.NamespaceUID, rule.RuleGroup, 120) + err = ruleService.UpdateRuleGroup(context.Background(), u, rule.NamespaceUID, rule.RuleGroup, 120) require.NoError(t, err) - rule, _, err = ruleService.GetAlertRule(context.Background(), orgID, rule.UID) + rule, _, err = ruleService.GetAlertRule(context.Background(), u, rule.UID) require.NoError(t, err) require.Equal(t, interval, rule.IntervalSeconds) }) @@ -59,23 +66,37 @@ func TestAlertRuleService(t *testing.T) { var orgID int64 = 2 rule := dummyRule("test#1", orgID) rule.NamespaceUID = "123abc" - rule, err := ruleService.CreateAlertRule(context.Background(), rule, models.ProvenanceNone, 0) + u := &user.SignedInUser{OrgID: orgID} + rule, err := ruleService.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone) require.NoError(t, err) rule.NamespaceUID = "abc123" - _, err = ruleService.UpdateAlertRule(context.Background(), - rule, models.ProvenanceNone) + _, err = ruleService.UpdateAlertRule(context.Background(), u, rule, models.ProvenanceNone) require.NoError(t, err) }) + t.Run("group update should propagate folderUID from group to rules", func(t *testing.T) { + ruleService := createAlertRuleService(t) + group := createDummyGroup("namespace-test", orgID) + group.Rules[0].NamespaceUID = "" + + err := ruleService.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceAPI) + require.NoError(t, err) + + readGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "namespace-test") + require.NoError(t, err) + require.NotEmpty(t, readGroup.Rules) + require.Equal(t, "my-namespace", readGroup.Rules[0].NamespaceUID) + }) + t.Run("group creation should propagate group title correctly", func(t *testing.T) { group := createDummyGroup("group-test-3", orgID) group.Rules[0].RuleGroup = "something different" - err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI) + err := ruleService.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceAPI) require.NoError(t, err) - readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "group-test-3") + readGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "group-test-3") require.NoError(t, err) require.NotEmpty(t, readGroup.Rules) for _, rule := range readGroup.Rules { @@ -86,16 +107,16 @@ func TestAlertRuleService(t *testing.T) { t.Run("alert rule should get interval from existing rule group", func(t *testing.T) { rule := dummyRule("test#4", orgID) rule.RuleGroup = "b" - rule, err := ruleService.CreateAlertRule(context.Background(), rule, models.ProvenanceNone, 0) + rule, err := ruleService.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone) require.NoError(t, err) var interval int64 = 120 - err = ruleService.UpdateRuleGroup(context.Background(), orgID, rule.NamespaceUID, rule.RuleGroup, 120) + err = ruleService.UpdateRuleGroup(context.Background(), u, rule.NamespaceUID, rule.RuleGroup, 120) require.NoError(t, err) rule = dummyRule("test#4-1", orgID) rule.RuleGroup = "b" - rule, err = ruleService.CreateAlertRule(context.Background(), rule, models.ProvenanceNone, 0) + rule, err = ruleService.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone) require.NoError(t, err) require.Equal(t, interval, rule.IntervalSeconds) }) @@ -108,22 +129,23 @@ func TestAlertRuleService(t *testing.T) { ruleGroup = "abc" newInterval int64 = 120 ) + u := &user.SignedInUser{OrgID: orgID} rule := dummyRule("my_rule", orgID) rule.UID = ruleUID rule.RuleGroup = ruleGroup rule.NamespaceUID = namespaceUID - _, err := ruleService.CreateAlertRule(context.Background(), rule, models.ProvenanceNone, 0) + _, err := ruleService.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone) require.NoError(t, err) - rule, _, err = ruleService.GetAlertRule(context.Background(), orgID, ruleUID) + rule, _, err = ruleService.GetAlertRule(context.Background(), u, ruleUID) require.NoError(t, err) require.Equal(t, int64(1), rule.Version) require.Equal(t, int64(60), rule.IntervalSeconds) - err = ruleService.UpdateRuleGroup(context.Background(), orgID, namespaceUID, ruleGroup, newInterval) + err = ruleService.UpdateRuleGroup(context.Background(), u, namespaceUID, ruleGroup, newInterval) require.NoError(t, err) - rule, _, err = ruleService.GetAlertRule(context.Background(), orgID, ruleUID) + rule, _, err = ruleService.GetAlertRule(context.Background(), u, ruleUID) require.NoError(t, err) require.Equal(t, int64(2), rule.Version) require.Equal(t, newInterval, rule.IntervalSeconds) @@ -131,16 +153,16 @@ func TestAlertRuleService(t *testing.T) { t.Run("updating a group by updating a rule should bump that rule's data and version number", func(t *testing.T) { group := createDummyGroup("group-test-5", orgID) - err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI) + err := ruleService.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceAPI) require.NoError(t, err) - updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "group-test-5") + updatedGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "group-test-5") require.NoError(t, err) updatedGroup.Rules[0].Title = "some-other-title-asdf" - err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI) + err = ruleService.ReplaceRuleGroup(context.Background(), u, updatedGroup, models.ProvenanceAPI) require.NoError(t, err) - readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "group-test-5") + readGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "group-test-5") require.NoError(t, err) require.NotEmpty(t, readGroup.Rules) require.Len(t, readGroup.Rules, 1) @@ -159,17 +181,17 @@ func TestAlertRuleService(t *testing.T) { dummyRule("overlap-test-rule-2", orgID), }, } - err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI) + err := ruleService.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceAPI) require.NoError(t, err) - updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "overlap-test") + updatedGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "overlap-test") require.NoError(t, err) updatedGroup.Rules[0].Title = "overlap-test-rule-2" updatedGroup.Rules[1].Title = "overlap-test-rule-3" - err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI) + err = ruleService.ReplaceRuleGroup(context.Background(), u, updatedGroup, models.ProvenanceAPI) require.NoError(t, err) - readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "overlap-test") + readGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "overlap-test") require.NoError(t, err) require.NotEmpty(t, readGroup.Rules) require.Len(t, readGroup.Rules, 2) @@ -190,17 +212,17 @@ func TestAlertRuleService(t *testing.T) { dummyRule("swap-test-rule-2", orgID), }, } - err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI) + err := ruleService.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceAPI) require.NoError(t, err) - updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "swap-test") + updatedGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "swap-test") require.NoError(t, err) updatedGroup.Rules[0].Title = "swap-test-rule-2" updatedGroup.Rules[1].Title = "swap-test-rule-1" - err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI) + err = ruleService.ReplaceRuleGroup(context.Background(), u, updatedGroup, models.ProvenanceAPI) require.NoError(t, err) - readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "swap-test") + readGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "swap-test") require.NoError(t, err) require.NotEmpty(t, readGroup.Rules) require.Len(t, readGroup.Rules, 2) @@ -222,18 +244,18 @@ func TestAlertRuleService(t *testing.T) { dummyRule("cycle-test-rule-3", orgID), }, } - err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI) + err := ruleService.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceAPI) require.NoError(t, err) - updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "cycle-test") + updatedGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "cycle-test") require.NoError(t, err) updatedGroup.Rules[0].Title = "cycle-test-rule-2" updatedGroup.Rules[1].Title = "cycle-test-rule-3" updatedGroup.Rules[2].Title = "cycle-test-rule-1" - err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI) + err = ruleService.ReplaceRuleGroup(context.Background(), u, updatedGroup, models.ProvenanceAPI) require.NoError(t, err) - readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "cycle-test") + readGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "cycle-test") require.NoError(t, err) require.NotEmpty(t, readGroup.Rules) require.Len(t, readGroup.Rules, 3) @@ -260,9 +282,9 @@ func TestAlertRuleService(t *testing.T) { dummyRule("multi-cycle-test-rule-5", orgID), }, } - err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI) + err := ruleService.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceAPI) require.NoError(t, err) - updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "multi-cycle-test") + updatedGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "multi-cycle-test") require.NoError(t, err) updatedGroup.Rules[0].Title = "multi-cycle-test-rule-2" @@ -272,10 +294,10 @@ func TestAlertRuleService(t *testing.T) { updatedGroup.Rules[3].Title = "multi-cycle-test-rule-5" updatedGroup.Rules[4].Title = "multi-cycle-test-rule-3" - err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI) + err = ruleService.ReplaceRuleGroup(context.Background(), u, updatedGroup, models.ProvenanceAPI) require.NoError(t, err) - readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "multi-cycle-test") + readGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "multi-cycle-test") require.NoError(t, err) require.NotEmpty(t, readGroup.Rules) require.Len(t, readGroup.Rules, 5) @@ -301,7 +323,7 @@ func TestAlertRuleService(t *testing.T) { dummyRule("recreate-test-rule-1", orgID), }, } - err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI) + err := ruleService.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceAPI) require.NoError(t, err) updatedGroup := models.AlertRuleGroup{ Title: "recreate-test", @@ -311,10 +333,10 @@ func TestAlertRuleService(t *testing.T) { dummyRule("recreate-test-rule-1", orgID), }, } - err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI) + err = ruleService.ReplaceRuleGroup(context.Background(), u, updatedGroup, models.ProvenanceAPI) require.NoError(t, err) - readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "recreate-test") + readGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "recreate-test") require.NoError(t, err) require.NotEmpty(t, readGroup.Rules) require.Len(t, readGroup.Rules, 1) @@ -332,17 +354,17 @@ func TestAlertRuleService(t *testing.T) { dummyRule("create-overlap-test-rule-1", orgID), }, } - err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI) + err := ruleService.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceAPI) require.NoError(t, err) - updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "create-overlap-test") + updatedGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "create-overlap-test") require.NoError(t, err) updatedGroup.Rules[0].Title = "create-overlap-test-rule-2" updatedGroup.Rules = append(updatedGroup.Rules, dummyRule("create-overlap-test-rule-1", orgID)) - err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI) + err = ruleService.ReplaceRuleGroup(context.Background(), u, updatedGroup, models.ProvenanceAPI) require.NoError(t, err) - readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "create-overlap-test") + readGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "create-overlap-test") require.NoError(t, err) require.NotEmpty(t, readGroup.Rules) require.Len(t, readGroup.Rules, 2) @@ -361,9 +383,9 @@ func TestAlertRuleService(t *testing.T) { models.PanelIDAnnotation: strconv.FormatInt(panelId, 10), } - err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI) + err := ruleService.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceAPI) require.NoError(t, err) - updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "group-test-5") + updatedGroup, err := ruleService.GetRuleGroup(context.Background(), u, "my-namespace", "group-test-5") require.NoError(t, err) require.NotNil(t, updatedGroup.Rules[0].DashboardUID) @@ -418,12 +440,11 @@ func TestAlertRuleService(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - var orgID int64 = 1 rule := dummyRule(t.Name(), orgID) - rule, err := ruleService.CreateAlertRule(context.Background(), rule, test.from, 0) + rule, err := ruleService.CreateAlertRule(context.Background(), u, rule, test.from) require.NoError(t, err) - _, err = ruleService.UpdateAlertRule(context.Background(), rule, test.to) + _, err = ruleService.UpdateAlertRule(context.Background(), u, rule, test.to) if test.errNil { require.NoError(t, err) } else { @@ -481,11 +502,11 @@ func TestAlertRuleService(t *testing.T) { t.Run(test.name, func(t *testing.T) { var orgID int64 = 1 group := createDummyGroup(t.Name(), orgID) - err := ruleService.ReplaceRuleGroup(context.Background(), 1, group, 0, test.from) + err := ruleService.ReplaceRuleGroup(context.Background(), u, group, test.from) require.NoError(t, err) group.Rules[0].Title = t.Name() - err = ruleService.ReplaceRuleGroup(context.Background(), 1, group, 0, test.to) + err = ruleService.ReplaceRuleGroup(context.Background(), u, group, test.to) if test.errNil { require.NoError(t, err) } else { @@ -501,7 +522,7 @@ func TestAlertRuleService(t *testing.T) { checker.EXPECT().LimitExceeded() ruleService.quotas = checker - _, err := ruleService.CreateAlertRule(context.Background(), dummyRule("test#1", orgID), models.ProvenanceNone, 0) + _, err := ruleService.CreateAlertRule(context.Background(), u, dummyRule("test#1", orgID), models.ProvenanceNone) require.ErrorIs(t, err, models.ErrQuotaReached) }) @@ -512,8 +533,8 @@ func TestAlertRuleService(t *testing.T) { checker.EXPECT().LimitExceeded() ruleService.quotas = checker - group := createDummyGroup("quota-reached", 1) - err := ruleService.ReplaceRuleGroup(context.Background(), 1, group, 0, models.ProvenanceAPI) + group := createDummyGroup("quota-reached", orgID) + err := ruleService.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceAPI) require.ErrorIs(t, err, models.ErrQuotaReached) }) @@ -522,18 +543,19 @@ func TestAlertRuleService(t *testing.T) { func TestCreateAlertRule(t *testing.T) { ruleService := createAlertRuleService(t) var orgID int64 = 1 + u := &user.SignedInUser{OrgID: orgID} t.Run("should return the created id", func(t *testing.T) { - rule, err := ruleService.CreateAlertRule(context.Background(), dummyRule("test#1", orgID), models.ProvenanceNone, 0) + rule, err := ruleService.CreateAlertRule(context.Background(), u, dummyRule("test#1", orgID), models.ProvenanceNone) require.NoError(t, err) require.NotEqual(t, 0, rule.ID, "expected to get the created id and not the zero value") }) t.Run("should set the right provenance", func(t *testing.T) { - rule, err := ruleService.CreateAlertRule(context.Background(), dummyRule("test#2", orgID), models.ProvenanceAPI, 0) + rule, err := ruleService.CreateAlertRule(context.Background(), u, dummyRule("test#2", orgID), models.ProvenanceAPI) require.NoError(t, err) - _, provenance, err := ruleService.GetAlertRule(context.Background(), orgID, rule.UID) + _, provenance, err := ruleService.GetAlertRule(context.Background(), u, rule.UID) require.NoError(t, err) require.Equal(t, models.ProvenanceAPI, provenance) }) @@ -542,17 +564,17 @@ func TestCreateAlertRule(t *testing.T) { t.Run("return error if it is not valid UID", func(t *testing.T) { rule := dummyRule("test#3", orgID) rule.UID = strings.Repeat("1", util.MaxUIDLength+1) - rule, err := ruleService.CreateAlertRule(context.Background(), rule, models.ProvenanceNone, 0) + rule, err := ruleService.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone) require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation) }) t.Run("should create a new rule with this UID", func(t *testing.T) { rule := dummyRule("test#3", orgID) uid := util.GenerateShortUID() rule.UID = uid - created, err := ruleService.CreateAlertRule(context.Background(), rule, models.ProvenanceNone, 0) + created, err := ruleService.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone) require.NoError(t, err) require.Equal(t, uid, created.UID) - _, _, err = ruleService.GetAlertRule(context.Background(), orgID, uid) + _, _, err = ruleService.GetAlertRule(context.Background(), u, uid) require.NoError(t, err) }) }) @@ -561,13 +583,19 @@ func TestCreateAlertRule(t *testing.T) { func createAlertRuleService(t *testing.T) AlertRuleService { t.Helper() sqlStore := db.InitTestDB(t) + folderService := foldertest.NewFakeService() + folderService.ExpectedFolder = &folder.Folder{ + UID: "default-namespace", + } store := store.DBstore{ SQLStore: sqlStore, Cfg: setting.UnifiedAlertingSettings{ BaseInterval: time.Second * 10, }, - Logger: log.NewNopLogger(), + Logger: log.NewNopLogger(), + FolderService: folderService, } + // store := fakes.NewRuleStore(t) quotas := MockQuotaChecker{} quotas.EXPECT().LimitOK() return AlertRuleService{ @@ -578,6 +606,7 @@ func createAlertRuleService(t *testing.T) AlertRuleService { log: log.New("testing"), baseIntervalSeconds: 10, defaultIntervalSeconds: 60, + folderService: folderService, } } diff --git a/pkg/services/ngalert/provisioning/compat.go b/pkg/services/ngalert/provisioning/compat.go index 621ce88b4f521..0502d921d8d17 100644 --- a/pkg/services/ngalert/provisioning/compat.go +++ b/pkg/services/ngalert/provisioning/compat.go @@ -45,3 +45,25 @@ func PostableGrafanaReceiverToEmbeddedContactPoint(contactPoint *definitions.Pos } return embeddedContactPoint, nil } + +func GettableGrafanaReceiverToEmbeddedContactPoint(r *definitions.GettableGrafanaReceiver) (definitions.EmbeddedContactPoint, error) { + settingJson, err := simplejson.NewJson(r.Settings) + if err != nil { + return definitions.EmbeddedContactPoint{}, err + } + + for k := range r.SecureFields { + if settingJson.Get(k).MustString() == "" { + settingJson.Set(k, definitions.RedactedValue) + } + } + + return definitions.EmbeddedContactPoint{ + UID: r.UID, + Name: r.Name, + Type: r.Type, + DisableResolveMessage: r.DisableResolveMessage, + Settings: settingJson, + Provenance: string(r.Provenance), + }, nil +} diff --git a/pkg/services/ngalert/provisioning/config.go b/pkg/services/ngalert/provisioning/config.go index 1c82fba8a6d09..f711b02d71856 100644 --- a/pkg/services/ngalert/provisioning/config.go +++ b/pkg/services/ngalert/provisioning/config.go @@ -3,15 +3,15 @@ package provisioning import ( "context" "encoding/json" - "fmt" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" ) func deserializeAlertmanagerConfig(config []byte) (*definitions.PostableUserConfig, error) { result := definitions.PostableUserConfig{} if err := json.Unmarshal(config, &result); err != nil { - return nil, fmt.Errorf("failed to deserialize alertmanager configuration: %w", err) + return nil, makeErrBadAlertmanagerConfiguration(err) } return &result, nil } @@ -33,7 +33,7 @@ func getLastConfiguration(ctx context.Context, orgID int64, store AMConfigStore) } if alertManagerConfig == nil { - return nil, fmt.Errorf("no alertmanager configuration present in this org") + return nil, ErrNoAlertmanagerConfiguration.Errorf("") } concurrencyToken := alertManagerConfig.ConfigurationHash @@ -48,3 +48,31 @@ func getLastConfiguration(ctx context.Context, orgID int64, store AMConfigStore) version: alertManagerConfig.ConfigurationVersion, }, nil } + +type alertmanagerConfigStore interface { + Get(ctx context.Context, orgID int64) (*cfgRevision, error) + Save(ctx context.Context, revision *cfgRevision, orgID int64) error +} + +type alertmanagerConfigStoreImpl struct { + store AMConfigStore +} + +func (a alertmanagerConfigStoreImpl) Get(ctx context.Context, orgID int64) (*cfgRevision, error) { + return getLastConfiguration(ctx, orgID, a.store) +} + +func (a alertmanagerConfigStoreImpl) Save(ctx context.Context, revision *cfgRevision, orgID int64) error { + serialized, err := serializeAlertmanagerConfig(*revision.cfg) + if err != nil { + return err + } + cmd := models.SaveAlertmanagerConfigurationCmd{ + AlertmanagerConfiguration: string(serialized), + ConfigurationVersion: revision.version, + FetchedConfigurationHash: revision.concurrencyToken, + Default: false, + OrgID: orgID, + } + return PersistConfig(ctx, a.store, &cmd) +} diff --git a/pkg/services/ngalert/provisioning/config_test.go b/pkg/services/ngalert/provisioning/config_test.go new file mode 100644 index 0000000000000..454f7921098be --- /dev/null +++ b/pkg/services/ngalert/provisioning/config_test.go @@ -0,0 +1,127 @@ +package provisioning + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +func TestAlertmanagerConfigStoreGet(t *testing.T) { + orgID := int64(1) + + t.Run("should read the latest config for giving organization", func(t *testing.T) { + storeMock := &MockAMConfigStore{} + store := &alertmanagerConfigStoreImpl{store: storeMock} + + expected := models.AlertConfiguration{ + ID: 1, + AlertmanagerConfiguration: defaultConfig, + ConfigurationHash: "config-hash-123", + ConfigurationVersion: "123", + CreatedAt: time.Now().Unix(), + Default: false, + OrgID: orgID, + } + + expectedCfg := definitions.PostableUserConfig{} + require.NoError(t, json.Unmarshal([]byte(defaultConfig), &expectedCfg)) + + storeMock.EXPECT().GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything).Return(&expected, nil) + + revision, err := store.Get(context.Background(), orgID) + require.NoError(t, err) + + require.Equal(t, expected.ConfigurationVersion, revision.version) + require.Equal(t, expected.ConfigurationHash, revision.concurrencyToken) + require.Equal(t, expectedCfg, *revision.cfg) + + storeMock.AssertCalled(t, "GetLatestAlertmanagerConfiguration", mock.Anything, orgID) + }) + + t.Run("propagate errors", func(t *testing.T) { + t.Run("when underlying store fails", func(t *testing.T) { + storeMock := &MockAMConfigStore{} + store := &alertmanagerConfigStoreImpl{store: storeMock} + expectedErr := errors.New("test=err") + storeMock.EXPECT().GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything).Return(nil, expectedErr) + + _, err := store.Get(context.Background(), orgID) + require.ErrorIs(t, err, expectedErr) + }) + + t.Run("return ErrNoAlertmanagerConfiguration config does not exist", func(t *testing.T) { + storeMock := &MockAMConfigStore{} + store := &alertmanagerConfigStoreImpl{store: storeMock} + storeMock.EXPECT().GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything).Return(nil, nil) + + _, err := store.Get(context.Background(), orgID) + require.Truef(t, ErrNoAlertmanagerConfiguration.Is(err), "expected ErrNoAlertmanagerConfiguration but got %s", err.Error()) + }) + + t.Run("when config cannot be unmarshalled", func(t *testing.T) { + storeMock := &MockAMConfigStore{} + store := &alertmanagerConfigStoreImpl{store: storeMock} + storeMock.EXPECT().GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything).Return(&models.AlertConfiguration{ + AlertmanagerConfiguration: "invalid-json", + }, nil) + + _, err := store.Get(context.Background(), orgID) + require.Truef(t, ErrBadAlertmanagerConfiguration.Base.Is(err), "expected ErrBadAlertmanagerConfiguration but got %s", err.Error()) + }) + }) +} + +func TestAlertmanagerConfigStoreSave(t *testing.T) { + orgID := int64(1) + + cfg := definitions.PostableUserConfig{} + require.NoError(t, json.Unmarshal([]byte(defaultConfig), &cfg)) + expectedCfg, err := serializeAlertmanagerConfig(cfg) + require.NoError(t, err) + + revision := cfgRevision{ + cfg: &cfg, + concurrencyToken: "config-hash-123", + version: "123", + } + + t.Run("should save the config to store", func(t *testing.T) { + storeMock := &MockAMConfigStore{} + store := &alertmanagerConfigStoreImpl{store: storeMock} + + storeMock.EXPECT().UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error { + assert.Equal(t, string(expectedCfg), cmd.AlertmanagerConfiguration) + assert.Equal(t, orgID, cmd.OrgID) + assert.Equal(t, revision.version, cmd.ConfigurationVersion) + assert.Equal(t, false, cmd.Default) + assert.Equal(t, revision.concurrencyToken, cmd.FetchedConfigurationHash) + return nil + }) + + err := store.Save(context.Background(), &revision, orgID) + require.NoError(t, err) + + storeMock.AssertCalled(t, "UpdateAlertmanagerConfiguration", mock.Anything, mock.Anything) + }) + + t.Run("propagates errors when underlying storage returns error", func(t *testing.T) { + storeMock := &MockAMConfigStore{} + store := &alertmanagerConfigStoreImpl{store: storeMock} + + expectedErr := errors.New("test-err") + storeMock.EXPECT().UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything).Return(expectedErr) + + err := store.Save(context.Background(), &revision, orgID) + + require.ErrorIs(t, err, expectedErr) + }) +} diff --git a/pkg/services/ngalert/provisioning/contactpoints.go b/pkg/services/ngalert/provisioning/contactpoints.go index a72bf7d115b7f..37a3c03925a9e 100644 --- a/pkg/services/ngalert/provisioning/contactpoints.go +++ b/pkg/services/ngalert/provisioning/contactpoints.go @@ -3,7 +3,6 @@ package provisioning import ( "context" "encoding/base64" - "encoding/json" "errors" "fmt" "sort" @@ -13,33 +12,48 @@ import ( "github.com/prometheus/alertmanager/config" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/auth/identity" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config" + "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/util" ) +type AlertRuleNotificationSettingsStore interface { + RenameReceiverInNotificationSettings(ctx context.Context, orgID int64, oldReceiver, newReceiver string) (int, error) + ListNotificationSettings(ctx context.Context, q models.ListNotificationSettingsQuery) (map[models.AlertRuleKey][]models.NotificationSettings, error) +} + type ContactPointService struct { - amStore AMConfigStore - encryptionService secrets.Service - provenanceStore ProvisioningStore - xact TransactionManager - log log.Logger - ac accesscontrol.AccessControl + configStore *alertmanagerConfigStoreImpl + encryptionService secrets.Service + provenanceStore ProvisioningStore + notificationSettingsStore AlertRuleNotificationSettingsStore + xact TransactionManager + receiverService receiverService + log log.Logger +} + +type receiverService interface { + GetReceivers(ctx context.Context, query models.GetReceiversQuery, user identity.Requester) ([]apimodels.GettableApiReceiver, error) } func NewContactPointService(store AMConfigStore, encryptionService secrets.Service, - provenanceStore ProvisioningStore, xact TransactionManager, log log.Logger, ac accesscontrol.AccessControl) *ContactPointService { + provenanceStore ProvisioningStore, xact TransactionManager, receiverService receiverService, log log.Logger, + nsStore AlertRuleNotificationSettingsStore) *ContactPointService { return &ContactPointService{ - amStore: store, - encryptionService: encryptionService, - provenanceStore: provenanceStore, - xact: xact, - log: log, - ac: ac, + configStore: &alertmanagerConfigStoreImpl{ + store: store, + }, + receiverService: receiverService, + encryptionService: encryptionService, + provenanceStore: provenanceStore, + xact: xact, + log: log, + notificationSettingsStore: nsStore, } } @@ -51,48 +65,38 @@ type ContactPointQuery struct { Decrypt bool } -func (ecp *ContactPointService) canDecryptSecrets(ctx context.Context, u identity.Requester) bool { - if u == nil { - return false - } - permitted, err := ecp.ac.Evaluate(ctx, u, accesscontrol.EvalPermission(accesscontrol.ActionAlertingProvisioningReadSecrets)) - if err != nil { - ecp.log.Error("Failed to evaluate user permissions", "error", err) - permitted = false - } - return permitted -} - // GetContactPoints returns contact points. If q.Decrypt is true and the user is an OrgAdmin, decrypted secure settings are included instead of redacted ones. func (ecp *ContactPointService) GetContactPoints(ctx context.Context, q ContactPointQuery, u identity.Requester) ([]apimodels.EmbeddedContactPoint, error) { - if q.Decrypt && !ecp.canDecryptSecrets(ctx, u) { - return nil, fmt.Errorf("%w: user requires Admin role or alert.provisioning.secrets:read permission to view decrypted secure settings", ErrPermissionDenied) + receiverQuery := models.GetReceiversQuery{ + OrgID: q.OrgID, + Decrypt: q.Decrypt, } - revision, err := getLastConfiguration(ctx, q.OrgID, ecp.amStore) - if err != nil { - return nil, err + if q.Name != "" { + receiverQuery.Names = []string{q.Name} } - provenances, err := ecp.provenanceStore.GetProvenances(ctx, q.OrgID, "contactPoint") - if err != nil { - return nil, err - } - var contactPoints []apimodels.EmbeddedContactPoint - for _, contactPoint := range revision.cfg.GetGrafanaReceiverMap() { - if q.Name != "" && contactPoint.Name != q.Name { - continue + res, err := ecp.receiverService.GetReceivers(ctx, receiverQuery, u) + if err != nil { + return nil, convertRecSvcErr(err) + } + grafanaReceivers := []*apimodels.GettableGrafanaReceiver{} + if q.Name != "" && len(res) > 0 { + grafanaReceivers = res[0].GettableGrafanaReceivers.GrafanaManagedReceivers // we only expect one receiver group + } else { + for _, r := range res { + grafanaReceivers = append(grafanaReceivers, r.GettableGrafanaReceivers.GrafanaManagedReceivers...) } + } - embeddedContactPoint, err := PostableGrafanaReceiverToEmbeddedContactPoint( - contactPoint, - provenances[contactPoint.UID], - ecp.decryptValueOrRedacted(q.Decrypt, contactPoint.UID), - ) + var contactPoints []apimodels.EmbeddedContactPoint + for _, gr := range grafanaReceivers { + contactPoint, err := GettableGrafanaReceiverToEmbeddedContactPoint(gr) if err != nil { return nil, err } - contactPoints = append(contactPoints, embeddedContactPoint) + contactPoints = append(contactPoints, contactPoint) } + sort.SliceStable(contactPoints, func(i, j int) bool { switch strings.Compare(contactPoints[i].Name, contactPoints[j].Name) { case -1: @@ -102,13 +106,14 @@ func (ecp *ContactPointService) GetContactPoints(ctx context.Context, q ContactP } return contactPoints[i].UID < contactPoints[j].UID }) + return contactPoints, nil } // getContactPointDecrypted is an internal-only function that gets full contact point info, included encrypted fields. // nil is returned if no matching contact point exists. func (ecp *ContactPointService) getContactPointDecrypted(ctx context.Context, orgID int64, uid string) (apimodels.EmbeddedContactPoint, error) { - revision, err := getLastConfiguration(ctx, orgID, ecp.amStore) + revision, err := ecp.configStore.Get(ctx, orgID) if err != nil { return apimodels.EmbeddedContactPoint{}, err } @@ -118,7 +123,7 @@ func (ecp *ContactPointService) getContactPointDecrypted(ctx context.Context, or } embeddedContactPoint, err := PostableGrafanaReceiverToEmbeddedContactPoint( receiver, - models.ProvenanceNone, + models.ProvenanceNone, // TODO should be correct provenance? ecp.decryptValueOrRedacted(true, receiver.UID), ) if err != nil { @@ -135,7 +140,7 @@ func (ecp *ContactPointService) CreateContactPoint(ctx context.Context, orgID in return apimodels.EmbeddedContactPoint{}, fmt.Errorf("%w: %s", ErrValidation, err.Error()) } - revision, err := getLastConfiguration(ctx, orgID, ecp.amStore) + revision, err := ecp.configStore.Get(ctx, orgID) if err != nil { return apimodels.EmbeddedContactPoint{}, err } @@ -201,28 +206,11 @@ func (ecp *ContactPointService) CreateContactPoint(ctx context.Context, orgID in }) } - data, err := json.Marshal(revision.cfg) - if err != nil { - return apimodels.EmbeddedContactPoint{}, err - } - err = ecp.xact.InTransaction(ctx, func(ctx context.Context) error { - err = PersistConfig(ctx, ecp.amStore, &models.SaveAlertmanagerConfigurationCmd{ - AlertmanagerConfiguration: string(data), - FetchedConfigurationHash: revision.concurrencyToken, - ConfigurationVersion: revision.version, - Default: false, - OrgID: orgID, - }) - if err != nil { + if err := ecp.configStore.Save(ctx, revision, orgID); err != nil { return err } - err = ecp.provenanceStore.SetProvenance(ctx, &contactPoint, orgID, provenance) - if err != nil { - return err - } - contactPoint.Provenance = string(provenance) - return nil + return ecp.provenanceStore.SetProvenance(ctx, &contactPoint, orgID, provenance) }) if err != nil { return apimodels.EmbeddedContactPoint{}, err @@ -292,42 +280,39 @@ func (ecp *ContactPointService) UpdateContactPoint(ctx context.Context, orgID in SecureSettings: extractedSecrets, } // save to store - revision, err := getLastConfiguration(ctx, orgID, ecp.amStore) + revision, err := ecp.configStore.Get(ctx, orgID) if err != nil { return err } - configModified := stitchReceiver(revision.cfg, mergedReceiver) + configModified, renamedReceiver := stitchReceiver(revision.cfg, mergedReceiver) if !configModified { return fmt.Errorf("contact point with uid '%s' not found", mergedReceiver.UID) } - data, err := json.Marshal(revision.cfg) - if err != nil { - return err - } - return ecp.xact.InTransaction(ctx, func(ctx context.Context) error { - err = PersistConfig(ctx, ecp.amStore, &models.SaveAlertmanagerConfigurationCmd{ - AlertmanagerConfiguration: string(data), - FetchedConfigurationHash: revision.concurrencyToken, - ConfigurationVersion: revision.version, - Default: false, - OrgID: orgID, - }) - if err != nil { + err = ecp.xact.InTransaction(ctx, func(ctx context.Context) error { + if err := ecp.configStore.Save(ctx, revision, orgID); err != nil { return err } - err = ecp.provenanceStore.SetProvenance(ctx, &contactPoint, orgID, provenance) - if err != nil { - return err + if renamedReceiver != "" && renamedReceiver != mergedReceiver.Name { + affected, err := ecp.notificationSettingsStore.RenameReceiverInNotificationSettings(ctx, orgID, renamedReceiver, mergedReceiver.Name) + if err != nil { + return err + } + if affected > 0 { + ecp.log.Info("Renamed receiver in notification settings", "oldName", renamedReceiver, "newName", mergedReceiver.Name, "affectedSettings", affected) + } } - contactPoint.Provenance = string(provenance) - return nil + return ecp.provenanceStore.SetProvenance(ctx, &contactPoint, orgID, provenance) }) + if err != nil { + return err + } + return nil } func (ecp *ContactPointService) DeleteContactPoint(ctx context.Context, orgID int64, uid string) error { - revision, err := getLastConfiguration(ctx, orgID, ecp.amStore) + revision, err := ecp.configStore.Get(ctx, orgID) if err != nil { return err } @@ -355,25 +340,30 @@ func (ecp *ContactPointService) DeleteContactPoint(ctx context.Context, orgID in if fullRemoval && isContactPointInUse(name, []*apimodels.Route{revision.cfg.AlertmanagerConfig.Route}) { return fmt.Errorf("contact point '%s' is currently used by a notification policy", name) } - data, err := json.Marshal(revision.cfg) - if err != nil { - return err - } + return ecp.xact.InTransaction(ctx, func(ctx context.Context) error { - target := &apimodels.EmbeddedContactPoint{ - UID: uid, + if fullRemoval { + used, err := ecp.notificationSettingsStore.ListNotificationSettings(ctx, models.ListNotificationSettingsQuery{OrgID: orgID, ReceiverName: name}) + if err != nil { + return fmt.Errorf("failed to query alert rules for reference to the contact point '%s': %w", name, err) + } + if len(used) > 0 { + uids := make([]string, 0, len(used)) + for key := range used { + uids = append(uids, key.UID) + } + ecp.log.Error("Cannot delete contact point because it is used in rule's notification settings", "receiverName", name, "rulesUid", strings.Join(uids, ",")) + return fmt.Errorf("contact point '%s' is currently used in notification settings by one or many alert rules", name) + } } - err := ecp.provenanceStore.DeleteProvenance(ctx, target, orgID) - if err != nil { + + if err := ecp.configStore.Save(ctx, revision, orgID); err != nil { return err } - return PersistConfig(ctx, ecp.amStore, &models.SaveAlertmanagerConfigurationCmd{ - AlertmanagerConfiguration: string(data), - FetchedConfigurationHash: revision.concurrencyToken, - ConfigurationVersion: revision.version, - Default: false, - OrgID: orgID, - }) + target := &apimodels.EmbeddedContactPoint{ + UID: uid, + } + return ecp.provenanceStore.DeleteProvenance(ctx, target, orgID) }) } @@ -423,12 +413,14 @@ func (ecp *ContactPointService) encryptValue(value string) (string, error) { return base64.StdEncoding.EncodeToString(encryptedData), nil } -// stitchReceiver modifies a receiver, target, in an alertmanager config. It modifies the given config in-place. -// Returns true if the config was altered in any way, and false otherwise. -func stitchReceiver(cfg *apimodels.PostableUserConfig, target *apimodels.PostableGrafanaReceiver) bool { +// stitchReceiver modifies a receiver, target, in an alertmanager configStore. It modifies the given configStore in-place. +// Returns true if the configStore was altered in any way, and false otherwise. +// If integration was moved to another group and it was the last in the previous group, the second parameter contains the name of the old group that is gone +func stitchReceiver(cfg *apimodels.PostableUserConfig, target *apimodels.PostableGrafanaReceiver) (bool, string) { // Algorithm to fix up receivers. Receivers are very complex and depend heavily on internal consistency. // All receivers in a given receiver group have the same name. We must maintain this across renames. configModified := false + renamedReceiver := "" groupLoop: for groupIdx, receiverGroup := range cfg.AlertmanagerConfig.Receivers { // Does the current group contain the grafana receiver we're interested in? @@ -453,6 +445,7 @@ groupLoop: replaceReferences(receiverGroup.Name, target.Name, cfg.AlertmanagerConfig.Route) receiverGroup.Name = target.Name receiverGroup.GrafanaManagedReceivers[i] = target + renamedReceiver = receiverGroup.Name } // Otherwise, we only want to rename the receiver we are touching... NOT all of them. @@ -494,7 +487,7 @@ groupLoop: } } - return configModified + return configModified, renamedReceiver } func replaceReferences(oldName, newName string, routes ...*apimodels.Route) { @@ -545,3 +538,23 @@ func RemoveSecretsForContactPoint(e *apimodels.EmbeddedContactPoint) (map[string } return s, nil } + +// handleWrappedError unwraps an error and wraps it with a new expected error type. If the error is not wrapped, it returns just the expected error. +func handleWrappedError(err error, expected error) error { + err = errors.Unwrap(err) + if err == nil { + return expected + } + return fmt.Errorf("%w: %s", expected, err.Error()) +} + +// convertRecSvcErr converts errors from notifier.ReceiverService to errors expected from ContactPointService. +func convertRecSvcErr(err error) error { + if errors.Is(err, notifier.ErrPermissionDenied) { + return handleWrappedError(err, ErrPermissionDenied) + } + if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { + return ErrNoAlertmanagerConfiguration.Errorf("") + } + return err +} diff --git a/pkg/services/ngalert/provisioning/contactpoints_test.go b/pkg/services/ngalert/provisioning/contactpoints_test.go index 961cdd7a0b461..12081f1ba5d1c 100644 --- a/pkg/services/ngalert/provisioning/contactpoints_test.go +++ b/pkg/services/ngalert/provisioning/contactpoints_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/prometheus/alertmanager/config" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/components/simplejson" @@ -19,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier" + "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/database" "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -36,27 +38,30 @@ func TestContactPointService(t *testing.T) { cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil) require.NoError(t, err) - require.Len(t, cps, 1) - require.Equal(t, "slack receiver", cps[0].Name) + require.Len(t, cps, 2) + require.Equal(t, "grafana-default-email", cps[0].Name) + require.Equal(t, "slack receiver", cps[1].Name) }) t.Run("service filters contact points by name", func(t *testing.T) { sut := createContactPointServiceSut(t, secretsService) - newCp := createTestContactPoint() - _, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) - require.NoError(t, err) - q := ContactPointQuery{ - OrgID: 1, - Name: "slack receiver", - } - cps, err := sut.GetContactPoints(context.Background(), q, nil) + cps, err := sut.GetContactPoints(context.Background(), cpsQueryWithName(1, "slack receiver"), nil) require.NoError(t, err) require.Len(t, cps, 1) require.Equal(t, "slack receiver", cps[0].Name) }) + t.Run("service filters contact points by name, returns empty when no match", func(t *testing.T) { + sut := createContactPointServiceSut(t, secretsService) + + cps, err := sut.GetContactPoints(context.Background(), cpsQueryWithName(1, "unknown"), nil) + require.NoError(t, err) + + require.Len(t, cps, 0) + }) + t.Run("service stitches contact point into org's AM config", func(t *testing.T) { sut := createContactPointServiceSut(t, secretsService) newCp := createTestContactPoint() @@ -66,9 +71,9 @@ func TestContactPointService(t *testing.T) { cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil) require.NoError(t, err) - require.Len(t, cps, 2) - require.Equal(t, "test-contact-point", cps[1].Name) - require.Equal(t, "slack", cps[1].Type) + require.Len(t, cps, 3) + require.Equal(t, "test-contact-point", cps[2].Name) + require.Equal(t, "slack", cps[2].Type) }) t.Run("it's possible to use a custom uid", func(t *testing.T) { @@ -80,10 +85,10 @@ func TestContactPointService(t *testing.T) { _, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) require.NoError(t, err) - cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil) + cps, err := sut.GetContactPoints(context.Background(), cpsQueryWithName(1, newCp.Name), nil) require.NoError(t, err) - require.Len(t, cps, 2) - require.Equal(t, customUID, cps[1].UID) + require.Len(t, cps, 1) + require.Equal(t, customUID, cps[0].UID) }) t.Run("it's not possible to use invalid UID", func(t *testing.T) { @@ -216,19 +221,19 @@ func TestContactPointService(t *testing.T) { newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, test.from) require.NoError(t, err) - cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil) + cps, err := sut.GetContactPoints(context.Background(), cpsQueryWithName(1, newCp.Name), nil) require.NoError(t, err) - require.Equal(t, newCp.UID, cps[1].UID) - require.Equal(t, test.from, models.Provenance(cps[1].Provenance)) + require.Equal(t, newCp.UID, cps[0].UID) + require.Equal(t, test.from, models.Provenance(cps[0].Provenance)) err = sut.UpdateContactPoint(context.Background(), 1, newCp, test.to) if test.errNil { require.NoError(t, err) - cps, err = sut.GetContactPoints(context.Background(), cpsQuery(1), nil) + cps, err = sut.GetContactPoints(context.Background(), cpsQueryWithName(1, newCp.Name), nil) require.NoError(t, err) - require.Equal(t, newCp.UID, cps[1].UID) - require.Equal(t, test.to, models.Provenance(cps[1].Provenance)) + require.Equal(t, newCp.UID, cps[0].UID) + require.Equal(t, test.to, models.Provenance(cps[0].Provenance)) } else { require.Error(t, err, fmt.Sprintf("cannot change provenance from '%s' to '%s'", test.from, test.to)) } @@ -239,45 +244,56 @@ func TestContactPointService(t *testing.T) { t.Run("service respects concurrency token when updating", func(t *testing.T) { sut := createContactPointServiceSut(t, secretsService) newCp := createTestContactPoint() - config, err := sut.amStore.GetLatestAlertmanagerConfiguration(context.Background(), 1) + config, err := sut.configStore.store.GetLatestAlertmanagerConfiguration(context.Background(), 1) require.NoError(t, err) expectedConcurrencyToken := config.ConfigurationHash _, err = sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) require.NoError(t, err) - fake := sut.amStore.(*fakeAMConfigStore) - intercepted := fake.lastSaveCommand + fake := sut.configStore.store.(*fakes.FakeAlertmanagerConfigStore) + intercepted := fake.LastSaveCommand require.Equal(t, expectedConcurrencyToken, intercepted.FetchedConfigurationHash) }) } func TestContactPointServiceDecryptRedact(t *testing.T) { - sqlStore := db.InitTestDB(t) - secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) - ac := acimpl.ProvideAccessControl(setting.NewCfg()) + secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(db.InitTestDB(t))) + receiverServiceWithAC := func(ecp *ContactPointService) *notifier.ReceiverService { + return notifier.NewReceiverService( + acimpl.ProvideAccessControl(setting.NewCfg()), + // Get won't use the sut's config store, so we can use a different one here. + fakes.NewFakeAlertmanagerConfigStore(createEncryptedConfig(t, secretsService)), + ecp.provenanceStore, + ecp.encryptionService, + ecp.xact, + log.NewNopLogger(), + ) + } + t.Run("GetContactPoints gets redacted contact points by default", func(t *testing.T) { sut := createContactPointServiceSut(t, secretsService) cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil) require.NoError(t, err) - require.Len(t, cps, 1) - require.Equal(t, "slack receiver", cps[0].Name) - require.Equal(t, definitions.RedactedValue, cps[0].Settings.Get("url").MustString()) + require.Len(t, cps, 2) + require.Equal(t, "slack receiver", cps[1].Name) + require.Equal(t, definitions.RedactedValue, cps[1].Settings.Get("url").MustString()) }) + t.Run("GetContactPoints errors when Decrypt = true and user does not have permissions", func(t *testing.T) { sut := createContactPointServiceSut(t, secretsService) - sut.ac = ac + sut.receiverService = receiverServiceWithAC(sut) q := cpsQuery(1) q.Decrypt = true - _, err := sut.GetContactPoints(context.Background(), q, &user.SignedInUser{}) + _, err := sut.GetContactPoints(context.Background(), q, nil) require.ErrorIs(t, err, ErrPermissionDenied) }) t.Run("GetContactPoints errors when Decrypt = true and user is nil", func(t *testing.T) { sut := createContactPointServiceSut(t, secretsService) - sut.ac = ac + sut.receiverService = receiverServiceWithAC(sut) q := cpsQuery(1) q.Decrypt = true @@ -287,9 +303,10 @@ func TestContactPointServiceDecryptRedact(t *testing.T) { t.Run("GetContactPoints gets decrypted contact points when Decrypt = true and user has permissions", func(t *testing.T) { sut := createContactPointServiceSut(t, secretsService) - sut.ac = ac + sut.receiverService = receiverServiceWithAC(sut) - q := cpsQuery(1) + expectedName := "slack receiver" + q := cpsQueryWithName(1, expectedName) q.Decrypt = true cps, err := sut.GetContactPoints(context.Background(), q, &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{ 1: { @@ -299,7 +316,7 @@ func TestContactPointServiceDecryptRedact(t *testing.T) { require.NoError(t, err) require.Len(t, cps, 1) - require.Equal(t, "slack receiver", cps[0].Name) + require.Equal(t, expectedName, cps[0].Name) require.Equal(t, "secure url", cps[0].Settings.Get("url").MustString()) }) } @@ -337,24 +354,27 @@ func TestContactPointInUse(t *testing.T) { func createContactPointServiceSut(t *testing.T, secretService secrets.Service) *ContactPointService { // Encrypt secure settings. - c := &definitions.PostableUserConfig{} - err := json.Unmarshal([]byte(defaultAlertmanagerConfigJSON), c) - require.NoError(t, err) - err = notifier.EncryptReceiverConfigs(c.AlertmanagerConfig.Receivers, func(ctx context.Context, payload []byte) ([]byte, error) { - return secretService.Encrypt(ctx, payload, secrets.WithoutScope()) - }) - require.NoError(t, err) - - raw, err := json.Marshal(c) - require.NoError(t, err) + cfg := createEncryptedConfig(t, secretService) + store := fakes.NewFakeAlertmanagerConfigStore(cfg) + xact := newNopTransactionManager() + provisioningStore := fakes.NewFakeProvisioningStore() + + receiverService := notifier.NewReceiverService( + actest.FakeAccessControl{}, + store, + provisioningStore, + secretService, + xact, + log.NewNopLogger(), + ) return &ContactPointService{ - amStore: newFakeAMConfigStore(string(raw)), - provenanceStore: NewFakeProvisioningStore(), - xact: newNopTransactionManager(), + configStore: &alertmanagerConfigStoreImpl{store: store}, + provenanceStore: provisioningStore, + receiverService: receiverService, + xact: xact, encryptionService: secretService, log: log.NewNopLogger(), - ac: actest.FakeAccessControl{}, } } @@ -373,13 +393,34 @@ func cpsQuery(orgID int64) ContactPointQuery { } } +func cpsQueryWithName(orgID int64, name string) ContactPointQuery { + return ContactPointQuery{ + OrgID: orgID, + Name: name, + } +} + +func createEncryptedConfig(t *testing.T, secretService secrets.Service) string { + c := &definitions.PostableUserConfig{} + err := json.Unmarshal([]byte(defaultAlertmanagerConfigJSON), c) + require.NoError(t, err) + err = notifier.EncryptReceiverConfigs(c.AlertmanagerConfig.Receivers, func(ctx context.Context, payload []byte) ([]byte, error) { + return secretService.Encrypt(ctx, payload, secrets.WithoutScope()) + }) + require.NoError(t, err) + bytes, err := json.Marshal(c) + require.NoError(t, err) + return string(bytes) +} + func TestStitchReceivers(t *testing.T) { type testCase struct { - name string - initial *definitions.PostableUserConfig - new *definitions.PostableGrafanaReceiver - expModified bool - expCfg definitions.PostableApiAlertingConfig + name string + initial *definitions.PostableUserConfig + new *definitions.PostableGrafanaReceiver + expModified bool + expCfg definitions.PostableApiAlertingConfig + expRenamedReceiver string } cases := []testCase{ @@ -459,7 +500,8 @@ func TestStitchReceivers(t *testing.T) { Name: "new-receiver", Type: "slack", }, - expModified: true, + expModified: true, + expRenamedReceiver: "new-receiver", expCfg: definitions.PostableApiAlertingConfig{ Config: definitions.Config{ Route: &definitions.Route{ @@ -1060,7 +1102,8 @@ func TestStitchReceivers(t *testing.T) { Name: "receiver-1", Type: "slack", }, - expModified: true, + expModified: true, + expRenamedReceiver: "receiver-1", expCfg: definitions.PostableApiAlertingConfig{ Config: definitions.Config{ Route: &definitions.Route{ @@ -1112,8 +1155,12 @@ func TestStitchReceivers(t *testing.T) { cfg = c.initial } - modified := stitchReceiver(cfg, c.new) - + modified, renamedReceiver := stitchReceiver(cfg, c.new) + if c.expRenamedReceiver != "" { + assert.Equal(t, c.expRenamedReceiver, renamedReceiver) + } else { + assert.Empty(t, renamedReceiver) + } require.Equal(t, c.expModified, modified) require.Equal(t, c.expCfg, cfg.AlertmanagerConfig) }) diff --git a/pkg/services/ngalert/provisioning/errors.go b/pkg/services/ngalert/provisioning/errors.go index 1ca9f708dd421..31e50224ffe2f 100644 --- a/pkg/services/ngalert/provisioning/errors.go +++ b/pkg/services/ngalert/provisioning/errors.go @@ -3,8 +3,42 @@ package provisioning import ( "errors" "fmt" + + "github.com/grafana/grafana/pkg/util/errutil" ) var ErrValidation = fmt.Errorf("invalid object specification") var ErrNotFound = fmt.Errorf("object not found") var ErrPermissionDenied = errors.New("permission denied") + +var ( + ErrNoAlertmanagerConfiguration = errutil.Internal("alerting.notification.configMissing", errutil.WithPublicMessage("No alertmanager configuration present in this organization")) + ErrBadAlertmanagerConfiguration = errutil.Internal("alerting.notification.configCorrupted").MustTemplate("Failed to unmarshal the Alertmanager configuration", errutil.WithPublic("Current Alertmanager configuration in the storage is corrupted. Reset the configuration or rollback to a recent valid one.")) + + ErrTimeIntervalNotFound = errutil.NotFound("alerting.notifications.time-intervals.notFound") + ErrTimeIntervalExists = errutil.BadRequest("alerting.notifications.time-intervals.nameExists", errutil.WithPublicMessage("Time interval with this name already exists. Use a different name or update existing one.")) + ErrTimeIntervalInvalid = errutil.BadRequest("alerting.notifications.time-intervals.invalidFormat").MustTemplate("Invalid format of the submitted time interval", errutil.WithPublic("Time interval is in invalid format. Correct the payload and try again.")) + ErrTimeIntervalInUse = errutil.Conflict("alerting.notifications.time-intervals.used", errutil.WithPublicMessage("Time interval is used by one or many notification policies")) +) + +func makeErrBadAlertmanagerConfiguration(err error) error { + data := errutil.TemplateData{ + Public: map[string]interface{}{ + "Error": err.Error(), + }, + Error: err, + } + return ErrBadAlertmanagerConfiguration.Build(data) +} + +// MakeErrTimeIntervalInvalid creates an error with the ErrTimeIntervalInvalid template +func MakeErrTimeIntervalInvalid(err error) error { + data := errutil.TemplateData{ + Public: map[string]interface{}{ + "Error": err.Error(), + }, + Error: err, + } + + return ErrTimeIntervalInvalid.Build(data) +} diff --git a/pkg/services/ngalert/provisioning/mute_timings.go b/pkg/services/ngalert/provisioning/mute_timings.go index 54d4b7d4294c9..bb02c8b0c851e 100644 --- a/pkg/services/ngalert/provisioning/mute_timings.go +++ b/pkg/services/ngalert/provisioning/mute_timings.go @@ -2,7 +2,6 @@ package provisioning import ( "context" - "fmt" "github.com/prometheus/alertmanager/config" @@ -12,24 +11,24 @@ import ( ) type MuteTimingService struct { - config AMConfigStore - prov ProvisioningStore - xact TransactionManager - log log.Logger + configStore alertmanagerConfigStore + provenanceStore ProvisioningStore + xact TransactionManager + log log.Logger } func NewMuteTimingService(config AMConfigStore, prov ProvisioningStore, xact TransactionManager, log log.Logger) *MuteTimingService { return &MuteTimingService{ - config: config, - prov: prov, - xact: xact, - log: log, + configStore: &alertmanagerConfigStoreImpl{store: config}, + provenanceStore: prov, + xact: xact, + log: log, } } // GetMuteTimings returns a slice of all mute timings within the specified org. func (svc *MuteTimingService) GetMuteTimings(ctx context.Context, orgID int64) ([]definitions.MuteTimeInterval, error) { - rev, err := getLastConfiguration(ctx, orgID, svc.config) + rev, err := svc.configStore.Get(ctx, orgID) if err != nil { return nil, err } @@ -38,22 +37,55 @@ func (svc *MuteTimingService) GetMuteTimings(ctx context.Context, orgID int64) ( return []definitions.MuteTimeInterval{}, nil } + provenances, err := svc.provenanceStore.GetProvenances(ctx, orgID, (&definitions.MuteTimeInterval{}).ResourceType()) + if err != nil { + return nil, err + } + result := make([]definitions.MuteTimeInterval, 0, len(rev.cfg.AlertmanagerConfig.MuteTimeIntervals)) for _, interval := range rev.cfg.AlertmanagerConfig.MuteTimeIntervals { - result = append(result, definitions.MuteTimeInterval{MuteTimeInterval: interval}) + def := definitions.MuteTimeInterval{MuteTimeInterval: interval} + if prov, ok := provenances[def.ResourceID()]; ok { + def.Provenance = definitions.Provenance(prov) + } + result = append(result, def) + } + return result, nil +} + +// GetMuteTiming returns a mute timing by name +func (svc *MuteTimingService) GetMuteTiming(ctx context.Context, name string, orgID int64) (definitions.MuteTimeInterval, error) { + rev, err := svc.configStore.Get(ctx, orgID) + if err != nil { + return definitions.MuteTimeInterval{}, err + } + + mt, _, err := getMuteTiming(rev, name) + if err != nil { + return definitions.MuteTimeInterval{}, err + } + + result := definitions.MuteTimeInterval{ + MuteTimeInterval: mt, + } + + prov, err := svc.provenanceStore.GetProvenance(ctx, &result, orgID) + if err != nil { + return definitions.MuteTimeInterval{}, err } + result.Provenance = definitions.Provenance(prov) return result, nil } // CreateMuteTiming adds a new mute timing within the specified org. The created mute timing is returned. -func (svc *MuteTimingService) CreateMuteTiming(ctx context.Context, mt definitions.MuteTimeInterval, orgID int64) (*definitions.MuteTimeInterval, error) { +func (svc *MuteTimingService) CreateMuteTiming(ctx context.Context, mt definitions.MuteTimeInterval, orgID int64) (definitions.MuteTimeInterval, error) { if err := mt.Validate(); err != nil { - return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error()) + return definitions.MuteTimeInterval{}, MakeErrTimeIntervalInvalid(err) } - revision, err := getLastConfiguration(ctx, orgID, svc.config) + revision, err := svc.configStore.Get(ctx, orgID) if err != nil { - return nil, err + return definitions.MuteTimeInterval{}, err } if revision.cfg.AlertmanagerConfig.MuteTimeIntervals == nil { @@ -61,98 +93,61 @@ func (svc *MuteTimingService) CreateMuteTiming(ctx context.Context, mt definitio } for _, existing := range revision.cfg.AlertmanagerConfig.MuteTimeIntervals { if mt.Name == existing.Name { - return nil, fmt.Errorf("%w: %s", ErrValidation, "a mute timing with this name already exists") + return definitions.MuteTimeInterval{}, ErrTimeIntervalExists.Errorf("") } } revision.cfg.AlertmanagerConfig.MuteTimeIntervals = append(revision.cfg.AlertmanagerConfig.MuteTimeIntervals, mt.MuteTimeInterval) - serialized, err := serializeAlertmanagerConfig(*revision.cfg) - if err != nil { - return nil, err - } - cmd := models.SaveAlertmanagerConfigurationCmd{ - AlertmanagerConfiguration: string(serialized), - ConfigurationVersion: revision.version, - FetchedConfigurationHash: revision.concurrencyToken, - Default: false, - OrgID: orgID, - } err = svc.xact.InTransaction(ctx, func(ctx context.Context) error { - err = PersistConfig(ctx, svc.config, &cmd) - if err != nil { - return err - } - err = svc.prov.SetProvenance(ctx, &mt, orgID, models.Provenance(mt.Provenance)) - if err != nil { + if err := svc.configStore.Save(ctx, revision, orgID); err != nil { return err } - return nil + return svc.provenanceStore.SetProvenance(ctx, &mt, orgID, models.Provenance(mt.Provenance)) }) if err != nil { - return nil, err + return definitions.MuteTimeInterval{}, err } - - return &mt, nil + return mt, nil } -// UpdateMuteTiming replaces an existing mute timing within the specified org. The replaced mute timing is returned. If the mute timing does not exist, nil is returned and no action is taken. -func (svc *MuteTimingService) UpdateMuteTiming(ctx context.Context, mt definitions.MuteTimeInterval, orgID int64) (*definitions.MuteTimeInterval, error) { +// UpdateMuteTiming replaces an existing mute timing within the specified org. The replaced mute timing is returned. If the mute timing does not exist, ErrMuteTimingsNotFound is returned. +func (svc *MuteTimingService) UpdateMuteTiming(ctx context.Context, mt definitions.MuteTimeInterval, orgID int64) (definitions.MuteTimeInterval, error) { if err := mt.Validate(); err != nil { - return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error()) + return definitions.MuteTimeInterval{}, MakeErrTimeIntervalInvalid(err) } - revision, err := getLastConfiguration(ctx, orgID, svc.config) + revision, err := svc.configStore.Get(ctx, orgID) if err != nil { - return nil, err + return definitions.MuteTimeInterval{}, err } if revision.cfg.AlertmanagerConfig.MuteTimeIntervals == nil { - return nil, nil - } - updated := false - for i, existing := range revision.cfg.AlertmanagerConfig.MuteTimeIntervals { - if mt.Name == existing.Name { - revision.cfg.AlertmanagerConfig.MuteTimeIntervals[i] = mt.MuteTimeInterval - updated = true - break - } - } - if !updated { - return nil, nil + return definitions.MuteTimeInterval{}, nil } - serialized, err := serializeAlertmanagerConfig(*revision.cfg) + _, idx, err := getMuteTiming(revision, mt.Name) if err != nil { - return nil, err - } - cmd := models.SaveAlertmanagerConfigurationCmd{ - AlertmanagerConfiguration: string(serialized), - ConfigurationVersion: revision.version, - FetchedConfigurationHash: revision.concurrencyToken, - Default: false, - OrgID: orgID, + return definitions.MuteTimeInterval{}, err } + revision.cfg.AlertmanagerConfig.MuteTimeIntervals[idx] = mt.MuteTimeInterval + + // TODO add diff and noop detection + // TODO add fail if different provenance err = svc.xact.InTransaction(ctx, func(ctx context.Context) error { - err = PersistConfig(ctx, svc.config, &cmd) - if err != nil { - return err - } - err = svc.prov.SetProvenance(ctx, &mt, orgID, models.Provenance(mt.Provenance)) - if err != nil { + if err := svc.configStore.Save(ctx, revision, orgID); err != nil { return err } - return nil + return svc.provenanceStore.SetProvenance(ctx, &mt, orgID, models.Provenance(mt.Provenance)) }) if err != nil { - return nil, err + return definitions.MuteTimeInterval{}, err } - - return &mt, err + return mt, err } // DeleteMuteTiming deletes the mute timing with the given name in the given org. If the mute timing does not exist, no error is returned. func (svc *MuteTimingService) DeleteMuteTiming(ctx context.Context, name string, orgID int64) error { - revision, err := getLastConfiguration(ctx, orgID, svc.config) + revision, err := svc.configStore.Get(ctx, orgID) if err != nil { return err } @@ -161,7 +156,7 @@ func (svc *MuteTimingService) DeleteMuteTiming(ctx context.Context, name string, return nil } if isMuteTimeInUse(name, []*definitions.Route{revision.cfg.AlertmanagerConfig.Route}) { - return fmt.Errorf("mute time '%s' is currently used by a notification policy", name) + return ErrTimeIntervalInUse.Errorf("") } for i, existing := range revision.cfg.AlertmanagerConfig.MuteTimeIntervals { if name == existing.Name { @@ -170,28 +165,12 @@ func (svc *MuteTimingService) DeleteMuteTiming(ctx context.Context, name string, } } - serialized, err := serializeAlertmanagerConfig(*revision.cfg) - if err != nil { - return err - } - cmd := models.SaveAlertmanagerConfigurationCmd{ - AlertmanagerConfiguration: string(serialized), - ConfigurationVersion: revision.version, - FetchedConfigurationHash: revision.concurrencyToken, - Default: false, - OrgID: orgID, - } return svc.xact.InTransaction(ctx, func(ctx context.Context) error { - err = PersistConfig(ctx, svc.config, &cmd) - if err != nil { + if err := svc.configStore.Save(ctx, revision, orgID); err != nil { return err } target := definitions.MuteTimeInterval{MuteTimeInterval: config.MuteTimeInterval{Name: name}} - err := svc.prov.DeleteProvenance(ctx, &target, orgID) - if err != nil { - return err - } - return nil + return svc.provenanceStore.DeleteProvenance(ctx, &target, orgID) }) } @@ -211,3 +190,15 @@ func isMuteTimeInUse(name string, routes []*definitions.Route) bool { } return false } + +func getMuteTiming(rev *cfgRevision, name string) (config.MuteTimeInterval, int, error) { + if rev.cfg.AlertmanagerConfig.MuteTimeIntervals == nil { + return config.MuteTimeInterval{}, -1, ErrTimeIntervalNotFound.Errorf("") + } + for idx, mt := range rev.cfg.AlertmanagerConfig.MuteTimeIntervals { + if mt.Name == name { + return mt, idx, nil + } + } + return config.MuteTimeInterval{}, -1, ErrTimeIntervalNotFound.Errorf("") +} diff --git a/pkg/services/ngalert/provisioning/mute_timings_test.go b/pkg/services/ngalert/provisioning/mute_timings_test.go index 242012c249781..0eb46eea084ac 100644 --- a/pkg/services/ngalert/provisioning/mute_timings_test.go +++ b/pkg/services/ngalert/provisioning/mute_timings_test.go @@ -2,11 +2,14 @@ package provisioning import ( "context" + "errors" "fmt" + "slices" "testing" "github.com/prometheus/alertmanager/config" - mock "github.com/stretchr/testify/mock" + "github.com/prometheus/alertmanager/timeinterval" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" @@ -14,27 +17,67 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/models" ) -func TestMuteTimingService(t *testing.T) { +func TestGetMuteTimings(t *testing.T) { + orgID := int64(1) + revision := &cfgRevision{ + cfg: &definitions.PostableUserConfig{ + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + MuteTimeIntervals: []config.MuteTimeInterval{ + { + Name: "Test1", + TimeIntervals: nil, + }, + { + Name: "Test2", + TimeIntervals: nil, + }, + { + Name: "Test3", + TimeIntervals: nil, + }, + }, + }, + }, + }, + } + + provenances := map[string]models.Provenance{ + "Test1": models.ProvenanceFile, + "Test2": models.ProvenanceAPI, + } + t.Run("service returns timings from config file", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.config.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) + sut, store, prov := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return revision, nil + } + + prov.EXPECT().GetProvenances(mock.Anything, mock.Anything, mock.Anything).Return(provenances, nil) result, err := sut.GetMuteTimings(context.Background(), 1) require.NoError(t, err) - require.Len(t, result, 1) - require.Equal(t, "asdf", result[0].Name) + require.Len(t, result, len(revision.cfg.AlertmanagerConfig.MuteTimeIntervals)) + require.Equal(t, "Test1", result[0].Name) + require.EqualValues(t, provenances["Test1"], result[0].Provenance) + require.Equal(t, "Test2", result[1].Name) + require.EqualValues(t, provenances["Test2"], result[1].Provenance) + require.Equal(t, "Test3", result[2].Name) + require.EqualValues(t, "", result[2].Provenance) + + require.Len(t, store.Calls, 1) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + prov.AssertCalled(t, "GetProvenances", mock.Anything, orgID, (&definitions.MuteTimeInterval{}).ResourceType()) }) t.Run("service returns empty list when config file contains no mute timings", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.config.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: defaultConfig, - }) + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: &definitions.PostableUserConfig{}}, nil + } result, err := sut.GetMuteTimings(context.Background(), 1) @@ -44,418 +87,594 @@ func TestMuteTimingService(t *testing.T) { t.Run("service propagates errors", func(t *testing.T) { t.Run("when unable to read config", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.config.(*MockAMConfigStore).EXPECT(). - GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(nil, fmt.Errorf("failed")) + sut, store, _ := createMuteTimingSvcSut() + expected := fmt.Errorf("failed") + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return nil, expected + } - _, err := sut.GetMuteTimings(context.Background(), 1) + _, err := sut.GetMuteTimings(context.Background(), orgID) - require.Error(t, err) + require.ErrorIs(t, err, expected) }) - t.Run("when config is invalid", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.config.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: brokenConfig, - }) + t.Run("when unable to read provenance", func(t *testing.T) { + sut, store, prov := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return revision, nil + } + expected := fmt.Errorf("failed") + prov.EXPECT().GetProvenances(mock.Anything, mock.Anything, mock.Anything).Return(nil, expected) - _, err := sut.GetMuteTimings(context.Background(), 1) + _, err := sut.GetMuteTimings(context.Background(), orgID) - require.ErrorContains(t, err, "failed to deserialize") + require.ErrorIs(t, err, expected) }) + }) +} - t.Run("when no AM config in current org", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.config.(*MockAMConfigStore).EXPECT(). - GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(nil, nil) +func TestGetMuteTiming(t *testing.T) { + orgID := int64(1) + revision := &cfgRevision{ + cfg: &definitions.PostableUserConfig{ + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + MuteTimeIntervals: []config.MuteTimeInterval{ + { + Name: "Test1", + TimeIntervals: nil, + }, + }, + }, + }, + }, + } - _, err := sut.GetMuteTimings(context.Background(), 1) + t.Run("service returns timing by name", func(t *testing.T) { + sut, store, prov := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return revision, nil + } + prov.EXPECT().GetProvenance(mock.Anything, mock.Anything, mock.Anything).Return(models.ProvenanceAPI, nil) - require.ErrorContains(t, err, "no alertmanager configuration") - }) + result, err := sut.GetMuteTiming(context.Background(), "Test1", orgID) + + require.NoError(t, err) + + require.Equal(t, "Test1", result.Name) + require.EqualValues(t, models.ProvenanceAPI, result.Provenance) + + require.Len(t, store.Calls, 1) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + prov.AssertCalled(t, "GetProvenance", mock.Anything, &result, orgID) }) - t.Run("creating mute timings", func(t *testing.T) { - t.Run("rejects mute timings that fail validation", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := definitions.MuteTimeInterval{ - MuteTimeInterval: config.MuteTimeInterval{ - Name: "", - }, + t.Run("service returns ErrTimeIntervalNotFound if no mute timings", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: &definitions.PostableUserConfig{}}, nil + } + + _, err := sut.GetMuteTiming(context.Background(), "Test1", orgID) + + require.Truef(t, ErrTimeIntervalNotFound.Is(err), "expected ErrTimeIntervalNotFound but got %s", err) + }) + + t.Run("service returns ErrTimeIntervalNotFound if no mute timing by name", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return revision, nil + } + + _, err := sut.GetMuteTiming(context.Background(), "Test123", orgID) + + require.Truef(t, ErrTimeIntervalNotFound.Is(err), "expected ErrTimeIntervalNotFound but got %s", err) + }) + + t.Run("service propagates errors", func(t *testing.T) { + t.Run("when unable to read config", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + expected := fmt.Errorf("failed") + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return nil, expected } - _, err := sut.CreateMuteTiming(context.Background(), timing, 1) + _, err := sut.GetMuteTiming(context.Background(), "Test1", orgID) - require.ErrorIs(t, err, ErrValidation) + require.ErrorIs(t, err, expected) }) - t.Run("propagates errors", func(t *testing.T) { - t.Run("when unable to read config", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - sut.config.(*MockAMConfigStore).EXPECT(). - GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(nil, fmt.Errorf("failed")) + t.Run("when unable to read provenance", func(t *testing.T) { + sut, store, prov := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return revision, nil + } + expected := fmt.Errorf("failed") + prov.EXPECT().GetProvenance(mock.Anything, mock.Anything, mock.Anything).Return("", expected) - _, err := sut.CreateMuteTiming(context.Background(), timing, 1) + _, err := sut.GetMuteTiming(context.Background(), "Test1", orgID) - require.Error(t, err) - }) + require.ErrorIs(t, err, expected) + }) + }) +} - t.Run("when config is invalid", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - sut.config.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: brokenConfig, - }) +func TestCreateMuteTimings(t *testing.T) { + orgID := int64(1) + + initialConfig := func() *definitions.PostableUserConfig { + return &definitions.PostableUserConfig{ + TemplateFiles: nil, + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + MuteTimeIntervals: []config.MuteTimeInterval{ + { + Name: "TEST", + }, + }, + }, + Receivers: nil, + }, + } + } - _, err := sut.CreateMuteTiming(context.Background(), timing, 1) + expected := config.MuteTimeInterval{ + Name: "Test", + TimeIntervals: []timeinterval.TimeInterval{ + { + Times: []timeinterval.TimeRange{ + { + StartMinute: 10, EndMinute: 60, + }, + }, + }, + }, + } + expectedProvenance := models.ProvenanceAPI + timing := definitions.MuteTimeInterval{ + MuteTimeInterval: expected, + Provenance: definitions.Provenance(expectedProvenance), + } - require.ErrorContains(t, err, "failed to deserialize") - }) + t.Run("returns ErrTimeIntervalInvalid if mute timings fail validation", func(t *testing.T) { + sut, _, _ := createMuteTimingSvcSut() + timing := definitions.MuteTimeInterval{ + MuteTimeInterval: config.MuteTimeInterval{ + Name: "", + }, + Provenance: definitions.Provenance(models.ProvenanceFile), + } - t.Run("when no AM config in current org", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - sut.config.(*MockAMConfigStore).EXPECT(). - GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(nil, nil) + _, err := sut.CreateMuteTiming(context.Background(), timing, orgID) - _, err := sut.CreateMuteTiming(context.Background(), timing, 1) + require.Truef(t, ErrTimeIntervalInvalid.Base.Is(err), "expected ErrTimeIntervalInvalid but got %s", err) + }) - require.ErrorContains(t, err, "no alertmanager configuration") - }) + t.Run("returns ErrTimeIntervalExists if mute timing with the name exists", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } - t.Run("when provenance fails to save", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - sut.config.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) - sut.config.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.prov.(*MockProvisioningStore).EXPECT(). - SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(fmt.Errorf("failed to save provenance")) - - _, err := sut.CreateMuteTiming(context.Background(), timing, 1) - - require.ErrorContains(t, err, "failed to save provenance") - }) + existing := initialConfig().AlertmanagerConfig.MuteTimeIntervals[0] + timing := definitions.MuteTimeInterval{ + MuteTimeInterval: existing, + Provenance: definitions.Provenance(models.ProvenanceFile), + } - t.Run("when AM config fails to save", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - sut.config.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) - sut.config.(*MockAMConfigStore).EXPECT(). - UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(fmt.Errorf("failed to save config")) - sut.prov.(*MockProvisioningStore).EXPECT().SaveSucceeds() - - _, err := sut.CreateMuteTiming(context.Background(), timing, 1) - - require.ErrorContains(t, err, "failed to save config") + _, err := sut.CreateMuteTiming(context.Background(), timing, orgID) + + require.Truef(t, ErrTimeIntervalExists.Is(err), "expected ErrTimeIntervalExists but got %s", err) + }) + + t.Run("saves mute timing and provenance in a transaction", func(t *testing.T) { + sut, store, prov := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + store.SaveFn = func(ctx context.Context, revision *cfgRevision) error { + assertInTransaction(t, ctx) + return nil + } + prov.EXPECT().SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, _ models.Provisionable, _ int64, _ models.Provenance) error { + assertInTransaction(t, ctx) + return nil }) - }) + + result, err := sut.CreateMuteTiming(context.Background(), timing, orgID) + require.NoError(t, err) + + require.EqualValues(t, expected, result.MuteTimeInterval) + require.EqualValues(t, expectedProvenance, result.Provenance) + + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + require.Equal(t, "Save", store.Calls[1].Method) + require.Equal(t, orgID, store.Calls[1].Args[2]) + revision := store.Calls[1].Args[1].(*cfgRevision) + + expectedTimings := append(initialConfig().AlertmanagerConfig.MuteTimeIntervals, expected) + require.EqualValues(t, expectedTimings, revision.cfg.AlertmanagerConfig.MuteTimeIntervals) + + prov.AssertCalled(t, "SetProvenance", mock.Anything, &timing, orgID, expectedProvenance) }) - t.Run("updating mute timings", func(t *testing.T) { - t.Run("rejects mute timings that fail validation", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := definitions.MuteTimeInterval{ - MuteTimeInterval: config.MuteTimeInterval{ - Name: "", - }, + t.Run("propagates errors", func(t *testing.T) { + t.Run("when unable to read config", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + expectedErr := errors.New("test-err") + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return nil, expectedErr } + _, err := sut.CreateMuteTiming(context.Background(), timing, orgID) + require.ErrorIs(t, err, expectedErr) + }) + + t.Run("when provenance fails to save", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + expectedErr := fmt.Errorf("failed to save provenance") + sut.provenanceStore.(*MockProvisioningStore).EXPECT(). + SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(expectedErr) + + _, err := sut.CreateMuteTiming(context.Background(), timing, orgID) + + require.ErrorIs(t, err, expectedErr) - _, err := sut.UpdateMuteTiming(context.Background(), timing, 1) + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) - require.ErrorIs(t, err, ErrValidation) + require.Equal(t, "Save", store.Calls[1].Method) }) - t.Run("returns nil if timing does not exist", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - timing.Name = "does not exist" - sut.config.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) - sut.config.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.prov.(*MockProvisioningStore).EXPECT().SaveSucceeds() - - updated, err := sut.UpdateMuteTiming(context.Background(), timing, 1) - - require.NoError(t, err) - require.Nil(t, updated) + t.Run("when AM config fails to save", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + expectedErr := errors.New("test-err") + store.SaveFn = func(ctx context.Context, revision *cfgRevision) error { + return expectedErr + } + + _, err := sut.CreateMuteTiming(context.Background(), timing, orgID) + + require.ErrorIs(t, err, expectedErr) + + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + require.Equal(t, "Save", store.Calls[1].Method) }) + }) +} - t.Run("propagates errors", func(t *testing.T) { - t.Run("when unable to read config", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - timing.Name = "asdf" - sut.config.(*MockAMConfigStore).EXPECT(). - GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(nil, fmt.Errorf("failed")) +func TestUpdateMuteTimings(t *testing.T) { + orgID := int64(1) + + initialConfig := func() *definitions.PostableUserConfig { + return &definitions.PostableUserConfig{ + TemplateFiles: nil, + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + MuteTimeIntervals: []config.MuteTimeInterval{ + { + Name: "Test", + }, + }, + }, + Receivers: nil, + }, + } + } - _, err := sut.UpdateMuteTiming(context.Background(), timing, 1) + expected := config.MuteTimeInterval{ + Name: "Test", + TimeIntervals: []timeinterval.TimeInterval{ + { + Times: []timeinterval.TimeRange{ + { + StartMinute: 10, EndMinute: 60, + }, + }, + }, + }, + } + expectedProvenance := models.ProvenanceAPI + timing := definitions.MuteTimeInterval{ + MuteTimeInterval: expected, + Provenance: definitions.Provenance(expectedProvenance), + } - require.Error(t, err) - }) + t.Run("rejects mute timings that fail validation", func(t *testing.T) { + sut, _, _ := createMuteTimingSvcSut() + timing := definitions.MuteTimeInterval{ + MuteTimeInterval: config.MuteTimeInterval{ + Name: "", + }, + Provenance: definitions.Provenance(models.ProvenanceFile), + } - t.Run("when config is invalid", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - timing.Name = "asdf" - sut.config.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: brokenConfig, - }) + _, err := sut.UpdateMuteTiming(context.Background(), timing, orgID) - _, err := sut.UpdateMuteTiming(context.Background(), timing, 1) + require.Truef(t, ErrTimeIntervalInvalid.Base.Is(err), "expected ErrTimeIntervalInvalid but got %s", err) + }) - require.ErrorContains(t, err, "failed to deserialize") - }) + t.Run("returns ErrMuteTimingsNotFound if mute timing does not exist", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } - t.Run("when no AM config in current org", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - timing.Name = "asdf" - sut.config.(*MockAMConfigStore).EXPECT(). - GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(nil, nil) + timing := definitions.MuteTimeInterval{ + MuteTimeInterval: config.MuteTimeInterval{ + Name: "No-timing", + }, + Provenance: definitions.Provenance(models.ProvenanceFile), + } - _, err := sut.UpdateMuteTiming(context.Background(), timing, 1) + _, err := sut.UpdateMuteTiming(context.Background(), timing, orgID) - require.ErrorContains(t, err, "no alertmanager configuration") - }) + require.Truef(t, ErrTimeIntervalNotFound.Is(err), "expected ErrTimeIntervalNotFound but got %s", err) + }) - t.Run("when provenance fails to save", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - timing.Name = "asdf" - sut.config.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) - sut.config.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.prov.(*MockProvisioningStore).EXPECT(). - SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(fmt.Errorf("failed to save provenance")) - - _, err := sut.UpdateMuteTiming(context.Background(), timing, 1) - - require.ErrorContains(t, err, "failed to save provenance") + t.Run("saves mute timing and provenance in a transaction", func(t *testing.T) { + sut, store, prov := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + store.SaveFn = func(ctx context.Context, revision *cfgRevision) error { + assertInTransaction(t, ctx) + return nil + } + prov.EXPECT().SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, _ models.Provisionable, _ int64, _ models.Provenance) error { + assertInTransaction(t, ctx) + return nil }) - t.Run("when AM config fails to save", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - timing.Name = "asdf" - sut.config.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) - sut.config.(*MockAMConfigStore).EXPECT(). - UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(fmt.Errorf("failed to save config")) - sut.prov.(*MockProvisioningStore).EXPECT().SaveSucceeds() - - _, err := sut.UpdateMuteTiming(context.Background(), timing, 1) - - require.ErrorContains(t, err, "failed to save config") - }) - }) + result, err := sut.UpdateMuteTiming(context.Background(), timing, orgID) + require.NoError(t, err) + + require.EqualValues(t, expected, result.MuteTimeInterval) + require.EqualValues(t, expectedProvenance, result.Provenance) + + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + require.Equal(t, "Save", store.Calls[1].Method) + require.Equal(t, orgID, store.Calls[1].Args[2]) + revision := store.Calls[1].Args[1].(*cfgRevision) + + require.EqualValues(t, []config.MuteTimeInterval{expected}, revision.cfg.AlertmanagerConfig.MuteTimeIntervals) + + prov.AssertCalled(t, "SetProvenance", mock.Anything, &timing, orgID, expectedProvenance) }) - t.Run("deleting mute timings", func(t *testing.T) { - t.Run("returns nil if timing does not exist", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.config.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) - sut.config.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.prov.(*MockProvisioningStore).EXPECT().SaveSucceeds() + t.Run("propagates errors", func(t *testing.T) { + t.Run("when unable to read config", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + expectedErr := errors.New("test-err") + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return nil, expectedErr + } + _, err := sut.UpdateMuteTiming(context.Background(), timing, orgID) + require.ErrorIs(t, err, expectedErr) + }) + + t.Run("when provenance fails to save", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + expectedErr := fmt.Errorf("failed to save provenance") + sut.provenanceStore.(*MockProvisioningStore).EXPECT(). + SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(expectedErr) + + _, err := sut.UpdateMuteTiming(context.Background(), timing, orgID) - err := sut.DeleteMuteTiming(context.Background(), "does not exist", 1) + require.ErrorIs(t, err, expectedErr) - require.NoError(t, err) + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + require.Equal(t, "Save", store.Calls[1].Method) }) - t.Run("propagates errors", func(t *testing.T) { - t.Run("when unable to read config", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.config.(*MockAMConfigStore).EXPECT(). - GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(nil, fmt.Errorf("failed")) + t.Run("when AM config fails to save", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + expectedErr := errors.New("test-err") + store.SaveFn = func(ctx context.Context, revision *cfgRevision) error { + return expectedErr + } - err := sut.DeleteMuteTiming(context.Background(), "asdf", 1) + _, err := sut.UpdateMuteTiming(context.Background(), timing, orgID) - require.Error(t, err) - }) + require.ErrorIs(t, err, expectedErr) - t.Run("when config is invalid", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.config.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: brokenConfig, - }) + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) - err := sut.DeleteMuteTiming(context.Background(), "asdf", 1) + require.Equal(t, "Save", store.Calls[1].Method) + }) + }) +} - require.ErrorContains(t, err, "failed to deserialize") - }) +func TestDeleteMuteTimings(t *testing.T) { + orgID := int64(1) + + timingToDelete := config.MuteTimeInterval{Name: "unused-timing"} + usedTiming := "used-timing" + initialConfig := func() *definitions.PostableUserConfig { + return &definitions.PostableUserConfig{ + TemplateFiles: nil, + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + Route: &definitions.Route{ + MuteTimeIntervals: []string{usedTiming}, + }, + MuteTimeIntervals: []config.MuteTimeInterval{ + { + Name: usedTiming, + }, + timingToDelete, + }, + }, + Receivers: nil, + }, + } + } - t.Run("when no AM config in current org", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.config.(*MockAMConfigStore).EXPECT(). - GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(nil, nil) + t.Run("re-saves config and deletes provenance if mute timing does not exist", func(t *testing.T) { + sut, store, prov := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + prov.EXPECT().DeleteProvenance(mock.Anything, mock.Anything, mock.Anything).Return(nil) - err := sut.DeleteMuteTiming(context.Background(), "asdf", 1) + err := sut.DeleteMuteTiming(context.Background(), "no-timing", orgID) + require.NoError(t, err) - require.ErrorContains(t, err, "no alertmanager configuration") - }) + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) - t.Run("when provenance fails to save", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.config.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) - sut.config.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.prov.(*MockProvisioningStore).EXPECT(). - DeleteProvenance(mock.Anything, mock.Anything, mock.Anything). - Return(fmt.Errorf("failed to save provenance")) + require.Equal(t, "Save", store.Calls[1].Method) + require.Equal(t, orgID, store.Calls[1].Args[2]) + revision := store.Calls[1].Args[1].(*cfgRevision) - err := sut.DeleteMuteTiming(context.Background(), "asdf", 1) + require.EqualValues(t, initialConfig().AlertmanagerConfig.MuteTimeIntervals, revision.cfg.AlertmanagerConfig.MuteTimeIntervals) - require.ErrorContains(t, err, "failed to save provenance") - }) + prov.AssertCalled(t, "DeleteProvenance", mock.Anything, &definitions.MuteTimeInterval{MuteTimeInterval: config.MuteTimeInterval{Name: "no-timing"}}, orgID) + }) + + t.Run("returns ErrTimeIntervalInUse if mute timing is used", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } - t.Run("when AM config fails to save", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.config.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) - sut.config.(*MockAMConfigStore).EXPECT(). - UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(fmt.Errorf("failed to save config")) - sut.prov.(*MockProvisioningStore).EXPECT().SaveSucceeds() + err := sut.DeleteMuteTiming(context.Background(), usedTiming, orgID) - err := sut.DeleteMuteTiming(context.Background(), "asdf", 1) + require.Len(t, store.Calls, 1) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + require.Truef(t, ErrTimeIntervalInUse.Is(err), "expected ErrTimeIntervalInUse but got %s", err) + }) - require.ErrorContains(t, err, "failed to save config") + t.Run("deletes mute timing and provenance in transaction", func(t *testing.T) { + sut, store, prov := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + store.SaveFn = func(ctx context.Context, revision *cfgRevision) error { + assertInTransaction(t, ctx) + return nil + } + prov.EXPECT().DeleteProvenance(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, _ models.Provisionable, _ int64) error { + assertInTransaction(t, ctx) + return nil }) - t.Run("when mute timing is used in route", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.config.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimingsInRoute, - }) + err := sut.DeleteMuteTiming(context.Background(), timingToDelete.Name, orgID) + require.NoError(t, err) - err := sut.DeleteMuteTiming(context.Background(), "asdf", 1) + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) - require.Error(t, err) - }) + require.Equal(t, "Save", store.Calls[1].Method) + require.Equal(t, orgID, store.Calls[1].Args[2]) + revision := store.Calls[1].Args[1].(*cfgRevision) + + expectedMuteTimings := slices.DeleteFunc(initialConfig().AlertmanagerConfig.MuteTimeIntervals, func(interval config.MuteTimeInterval) bool { + return interval.Name == timingToDelete.Name }) + require.EqualValues(t, expectedMuteTimings, revision.cfg.AlertmanagerConfig.MuteTimeIntervals) + + prov.AssertCalled(t, "DeleteProvenance", mock.Anything, &definitions.MuteTimeInterval{MuteTimeInterval: timingToDelete}, orgID) }) -} -func createMuteTimingSvcSut() *MuteTimingService { - return &MuteTimingService{ - config: &MockAMConfigStore{}, - prov: &MockProvisioningStore{}, - xact: newNopTransactionManager(), - log: log.NewNopLogger(), - } -} + t.Run("propagates errors", func(t *testing.T) { + t.Run("when unable to read config", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + expectedErr := errors.New("test-err") + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return nil, expectedErr + } + err := sut.DeleteMuteTiming(context.Background(), timingToDelete.Name, orgID) + require.ErrorIs(t, err, expectedErr) + }) -func createMuteTiming() definitions.MuteTimeInterval { - return definitions.MuteTimeInterval{ - MuteTimeInterval: config.MuteTimeInterval{ - Name: "interval", - }, - } -} + t.Run("when provenance fails to save", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + expectedErr := fmt.Errorf("failed to save provenance") + sut.provenanceStore.(*MockProvisioningStore).EXPECT(). + DeleteProvenance(mock.Anything, mock.Anything, mock.Anything). + Return(expectedErr) -var configWithMuteTimings = ` -{ - "template_files": { - "a": "template" - }, - "alertmanager_config": { - "route": { - "receiver": "grafana-default-email" - }, - "mute_time_intervals": [{ - "name": "asdf", - "time_intervals": [{ - "times": [], - "weekdays": ["monday"] - }] - }], - "receivers": [{ - "name": "grafana-default-email", - "grafana_managed_receiver_configs": [{ - "uid": "", - "name": "email receiver", - "type": "email", - "isDefault": true, - "settings": { - "addresses": "<example@email.com>" - } - }] - }] - } + err := sut.DeleteMuteTiming(context.Background(), timingToDelete.Name, orgID) + + require.ErrorIs(t, err, expectedErr) + + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + require.Equal(t, "Save", store.Calls[1].Method) + }) + + t.Run("when AM config fails to save", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + expectedErr := errors.New("test-err") + store.SaveFn = func(ctx context.Context, revision *cfgRevision) error { + return expectedErr + } + + err := sut.DeleteMuteTiming(context.Background(), timingToDelete.Name, orgID) + + require.ErrorIs(t, err, expectedErr) + + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + require.Equal(t, "Save", store.Calls[1].Method) + }) + }) } -` - -var configWithMuteTimingsInRoute = ` -{ - "template_files": { - "a": "template" - }, - "alertmanager_config": { - "route": { - "receiver": "grafana-default-email", - "routes": [ - { - "receiver": "grafana-default-email", - "mute_time_intervals": ["asdf"] - } - ] - }, - "mute_time_intervals": [{ - "name": "asdf", - "time_intervals": [{ - "times": [], - "weekdays": ["monday"] - }] - }], - "receivers": [{ - "name": "grafana-default-email", - "grafana_managed_receiver_configs": [{ - "uid": "", - "name": "email receiver", - "type": "email", - "isDefault": true, - "settings": { - "addresses": "<example@email.com>" - } - }] - }] - } + +func createMuteTimingSvcSut() (*MuteTimingService, *alertmanagerConfigStoreFake, *MockProvisioningStore) { + store := &alertmanagerConfigStoreFake{} + prov := &MockProvisioningStore{} + return &MuteTimingService{ + configStore: store, + provenanceStore: prov, + xact: newNopTransactionManager(), + log: log.NewNopLogger(), + }, store, prov } -` diff --git a/pkg/services/ngalert/provisioning/notification_policies.go b/pkg/services/ngalert/provisioning/notification_policies.go index 640f04333f8f3..629a5717b313c 100644 --- a/pkg/services/ngalert/provisioning/notification_policies.go +++ b/pkg/services/ngalert/provisioning/notification_policies.go @@ -11,7 +11,7 @@ import ( ) type NotificationPolicyService struct { - amStore AMConfigStore + configStore *alertmanagerConfigStoreImpl provenanceStore ProvisioningStore xact TransactionManager log log.Logger @@ -21,7 +21,7 @@ type NotificationPolicyService struct { func NewNotificationPolicyService(am AMConfigStore, prov ProvisioningStore, xact TransactionManager, settings setting.UnifiedAlertingSettings, log log.Logger) *NotificationPolicyService { return &NotificationPolicyService{ - amStore: am, + configStore: &alertmanagerConfigStoreImpl{store: am}, provenanceStore: prov, xact: xact, log: log, @@ -30,30 +30,25 @@ func NewNotificationPolicyService(am AMConfigStore, prov ProvisioningStore, } func (nps *NotificationPolicyService) GetAMConfigStore() AMConfigStore { - return nps.amStore + return nps.configStore.store } func (nps *NotificationPolicyService) GetPolicyTree(ctx context.Context, orgID int64) (definitions.Route, error) { - alertManagerConfig, err := nps.amStore.GetLatestAlertmanagerConfiguration(ctx, orgID) + rev, err := nps.configStore.Get(ctx, orgID) if err != nil { return definitions.Route{}, err } - cfg, err := deserializeAlertmanagerConfig([]byte(alertManagerConfig.AlertmanagerConfiguration)) - if err != nil { - return definitions.Route{}, err - } - - if cfg.AlertmanagerConfig.Config.Route == nil { + if rev.cfg.AlertmanagerConfig.Config.Route == nil { return definitions.Route{}, fmt.Errorf("no route present in current alertmanager config") } - provenance, err := nps.provenanceStore.GetProvenance(ctx, cfg.AlertmanagerConfig.Route, orgID) + provenance, err := nps.provenanceStore.GetProvenance(ctx, rev.cfg.AlertmanagerConfig.Route, orgID) if err != nil { return definitions.Route{}, err } - result := *cfg.AlertmanagerConfig.Route + result := *rev.cfg.AlertmanagerConfig.Route result.Provenance = definitions.Provenance(provenance) return result, nil @@ -65,7 +60,7 @@ func (nps *NotificationPolicyService) UpdatePolicyTree(ctx context.Context, orgI return fmt.Errorf("%w: %s", ErrValidation, err.Error()) } - revision, err := getLastConfiguration(ctx, orgID, nps.amStore) + revision, err := nps.configStore.Get(ctx, orgID) if err != nil { return err } @@ -91,33 +86,12 @@ func (nps *NotificationPolicyService) UpdatePolicyTree(ctx context.Context, orgI revision.cfg.AlertmanagerConfig.Config.Route = &tree - serialized, err := serializeAlertmanagerConfig(*revision.cfg) - if err != nil { - return err - } - cmd := models.SaveAlertmanagerConfigurationCmd{ - AlertmanagerConfiguration: string(serialized), - ConfigurationVersion: revision.version, - FetchedConfigurationHash: revision.concurrencyToken, - Default: false, - OrgID: orgID, - } - err = nps.xact.InTransaction(ctx, func(ctx context.Context) error { - err = PersistConfig(ctx, nps.amStore, &cmd) - if err != nil { + return nps.xact.InTransaction(ctx, func(ctx context.Context) error { + if err := nps.configStore.Save(ctx, revision, orgID); err != nil { return err } - err = nps.provenanceStore.SetProvenance(ctx, &tree, orgID, p) - if err != nil { - return err - } - return nil + return nps.provenanceStore.SetProvenance(ctx, &tree, orgID, p) }) - if err != nil { - return err - } - - return nil } func (nps *NotificationPolicyService) ResetPolicyTree(ctx context.Context, orgID int64) (definitions.Route, error) { @@ -128,7 +102,7 @@ func (nps *NotificationPolicyService) ResetPolicyTree(ctx context.Context, orgID } route := defaultCfg.AlertmanagerConfig.Route - revision, err := getLastConfiguration(ctx, orgID, nps.amStore) + revision, err := nps.configStore.Get(ctx, orgID) if err != nil { return definitions.Route{}, err } @@ -138,31 +112,16 @@ func (nps *NotificationPolicyService) ResetPolicyTree(ctx context.Context, orgID return definitions.Route{}, err } - serialized, err := serializeAlertmanagerConfig(*revision.cfg) - if err != nil { - return definitions.Route{}, err - } - cmd := models.SaveAlertmanagerConfigurationCmd{ - AlertmanagerConfiguration: string(serialized), - ConfigurationVersion: revision.version, - FetchedConfigurationHash: revision.concurrencyToken, - Default: false, - OrgID: orgID, - } err = nps.xact.InTransaction(ctx, func(ctx context.Context) error { - err := PersistConfig(ctx, nps.amStore, &cmd) - if err != nil { - return err - } - err = nps.provenanceStore.DeleteProvenance(ctx, route, orgID) - if err != nil { + if err := nps.configStore.Save(ctx, revision, orgID); err != nil { return err } - return nil + return nps.provenanceStore.DeleteProvenance(ctx, route, orgID) }) + if err != nil { return definitions.Route{}, nil - } + } // TODO should be error? return *route, nil } diff --git a/pkg/services/ngalert/provisioning/notification_policies_test.go b/pkg/services/ngalert/provisioning/notification_policies_test.go index 8b54ad3fa5061..6202848606d1f 100644 --- a/pkg/services/ngalert/provisioning/notification_policies_test.go +++ b/pkg/services/ngalert/provisioning/notification_policies_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" "github.com/grafana/grafana/pkg/setting" ) @@ -28,7 +29,7 @@ func TestNotificationPolicyService(t *testing.T) { t.Run("error if referenced mute time interval is not existing", func(t *testing.T) { sut := createNotificationPolicyServiceSut() - sut.amStore = &MockAMConfigStore{} + sut.configStore.store = &MockAMConfigStore{} cfg := createTestAlertingConfig() cfg.AlertmanagerConfig.MuteTimeIntervals = []config.MuteTimeInterval{ { @@ -37,14 +38,14 @@ func TestNotificationPolicyService(t *testing.T) { }, } data, _ := serializeAlertmanagerConfig(*cfg) - sut.amStore.(*MockAMConfigStore).On("GetLatestAlertmanagerConfiguration", mock.Anything, mock.Anything). + sut.configStore.store.(*MockAMConfigStore).On("GetLatestAlertmanagerConfiguration", mock.Anything, mock.Anything). Return(&models.AlertConfiguration{AlertmanagerConfiguration: string(data)}, nil) - sut.amStore.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything). Return(nil) newRoute := createTestRoutingTree() newRoute.Routes = append(newRoute.Routes, &definitions.Route{ - Receiver: "a new receiver", + Receiver: "slack receiver", MuteTimeIntervals: []string{"not-existing"}, }) @@ -54,7 +55,7 @@ func TestNotificationPolicyService(t *testing.T) { t.Run("pass if referenced mute time interval is existing", func(t *testing.T) { sut := createNotificationPolicyServiceSut() - sut.amStore = &MockAMConfigStore{} + sut.configStore.store = &MockAMConfigStore{} cfg := createTestAlertingConfig() cfg.AlertmanagerConfig.MuteTimeIntervals = []config.MuteTimeInterval{ { @@ -63,14 +64,14 @@ func TestNotificationPolicyService(t *testing.T) { }, } data, _ := serializeAlertmanagerConfig(*cfg) - sut.amStore.(*MockAMConfigStore).On("GetLatestAlertmanagerConfiguration", mock.Anything, mock.Anything). + sut.configStore.store.(*MockAMConfigStore).On("GetLatestAlertmanagerConfiguration", mock.Anything, mock.Anything). Return(&models.AlertConfiguration{AlertmanagerConfiguration: string(data)}, nil) - sut.amStore.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything). Return(nil) newRoute := createTestRoutingTree() newRoute.Routes = append(newRoute.Routes, &definitions.Route{ - Receiver: "a new receiver", + Receiver: "slack receiver", MuteTimeIntervals: []string{"existing"}, }) @@ -88,7 +89,7 @@ func TestNotificationPolicyService(t *testing.T) { updated, err := sut.GetPolicyTree(context.Background(), 1) require.NoError(t, err) - require.Equal(t, "a new receiver", updated.Receiver) + require.Equal(t, "slack receiver", updated.Receiver) }) t.Run("not existing receiver reference will error", func(t *testing.T) { @@ -105,12 +106,12 @@ func TestNotificationPolicyService(t *testing.T) { t.Run("existing receiver reference will pass", func(t *testing.T) { sut := createNotificationPolicyServiceSut() - sut.amStore = &MockAMConfigStore{} + sut.configStore.store = &MockAMConfigStore{} cfg := createTestAlertingConfig() data, _ := serializeAlertmanagerConfig(*cfg) - sut.amStore.(*MockAMConfigStore).On("GetLatestAlertmanagerConfiguration", mock.Anything, mock.Anything). + sut.configStore.store.(*MockAMConfigStore).On("GetLatestAlertmanagerConfiguration", mock.Anything, mock.Anything). Return(&models.AlertConfiguration{AlertmanagerConfiguration: string(data)}, nil) - sut.amStore.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything). Return(nil) newRoute := createTestRoutingTree() @@ -153,8 +154,8 @@ func TestNotificationPolicyService(t *testing.T) { err = sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceAPI) require.NoError(t, err) - fake := sut.GetAMConfigStore().(*fakeAMConfigStore) - intercepted := fake.lastSaveCommand + fake := sut.GetAMConfigStore().(*fakes.FakeAlertmanagerConfigStore) + intercepted := fake.LastSaveCommand require.Equal(t, expectedConcurrencyToken, intercepted.FetchedConfigurationHash) }) @@ -183,31 +184,31 @@ func TestNotificationPolicyService(t *testing.T) { t.Run("deleting route with missing default receiver restores receiver", func(t *testing.T) { sut := createNotificationPolicyServiceSut() - sut.amStore = &MockAMConfigStore{} + sut.configStore.store = &MockAMConfigStore{} cfg := createTestAlertingConfig() cfg.AlertmanagerConfig.Route = &definitions.Route{ - Receiver: "a new receiver", + Receiver: "slack receiver", } cfg.AlertmanagerConfig.Receivers = []*definitions.PostableApiReceiver{ { Receiver: config.Receiver{ - Name: "a new receiver", + Name: "slack receiver", }, }, // No default receiver! Only our custom one. } data, _ := serializeAlertmanagerConfig(*cfg) - sut.amStore.(*MockAMConfigStore).On("GetLatestAlertmanagerConfiguration", mock.Anything, mock.Anything). + sut.configStore.store.(*MockAMConfigStore).On("GetLatestAlertmanagerConfiguration", mock.Anything, mock.Anything). Return(&models.AlertConfiguration{AlertmanagerConfiguration: string(data)}, nil) var interceptedSave = models.SaveAlertmanagerConfigurationCmd{} - sut.amStore.(*MockAMConfigStore).EXPECT().SaveSucceedsIntercept(&interceptedSave) + sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceedsIntercept(&interceptedSave) tree, err := sut.ResetPolicyTree(context.Background(), 1) require.NoError(t, err) require.Equal(t, "grafana-default-email", tree.Receiver) require.NotEmpty(t, interceptedSave.AlertmanagerConfiguration) - // Deserializing with no error asserts that the saved config is semantically valid. + // Deserializing with no error asserts that the saved configStore is semantically valid. newCfg, err := deserializeAlertmanagerConfig([]byte(interceptedSave.AlertmanagerConfiguration)) require.NoError(t, err) require.Len(t, newCfg.AlertmanagerConfig.Receivers, 2) @@ -216,8 +217,8 @@ func TestNotificationPolicyService(t *testing.T) { func createNotificationPolicyServiceSut() *NotificationPolicyService { return &NotificationPolicyService{ - amStore: newFakeAMConfigStore(defaultAlertmanagerConfigJSON), - provenanceStore: NewFakeProvisioningStore(), + configStore: &alertmanagerConfigStoreImpl{store: fakes.NewFakeAlertmanagerConfigStore(defaultAlertmanagerConfigJSON)}, + provenanceStore: fakes.NewFakeProvisioningStore(), xact: newNopTransactionManager(), log: log.NewNopLogger(), settings: setting.UnifiedAlertingSettings{ @@ -228,7 +229,7 @@ func createNotificationPolicyServiceSut() *NotificationPolicyService { func createTestRoutingTree() definitions.Route { return definitions.Route{ - Receiver: "a new receiver", + Receiver: "slack receiver", } } @@ -238,7 +239,7 @@ func createTestAlertingConfig() *definitions.PostableUserConfig { &definitions.PostableApiReceiver{ Receiver: config.Receiver{ // default one from createTestRoutingTree() - Name: "a new receiver", + Name: "slack receiver", }, }) cfg.AlertmanagerConfig.Receivers = append(cfg.AlertmanagerConfig.Receivers, diff --git a/pkg/services/ngalert/provisioning/provisioning_test.go b/pkg/services/ngalert/provisioning/provisioning_test.go new file mode 100644 index 0000000000000..f274f08b8697d --- /dev/null +++ b/pkg/services/ngalert/provisioning/provisioning_test.go @@ -0,0 +1,11 @@ +package provisioning + +import ( + "testing" + + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} diff --git a/pkg/services/ngalert/provisioning/templates.go b/pkg/services/ngalert/provisioning/templates.go index 0e655224eb107..2ed37ae37230b 100644 --- a/pkg/services/ngalert/provisioning/templates.go +++ b/pkg/services/ngalert/provisioning/templates.go @@ -10,32 +10,42 @@ import ( ) type TemplateService struct { - config AMConfigStore - prov ProvisioningStore - xact TransactionManager - log log.Logger + configStore *alertmanagerConfigStoreImpl + provenanceStore ProvisioningStore + xact TransactionManager + log log.Logger } func NewTemplateService(config AMConfigStore, prov ProvisioningStore, xact TransactionManager, log log.Logger) *TemplateService { return &TemplateService{ - config: config, - prov: prov, - xact: xact, - log: log, + configStore: &alertmanagerConfigStoreImpl{store: config}, + provenanceStore: prov, + xact: xact, + log: log, } } -func (t *TemplateService) GetTemplates(ctx context.Context, orgID int64) (map[string]string, error) { - revision, err := getLastConfiguration(ctx, orgID, t.config) +func (t *TemplateService) GetTemplates(ctx context.Context, orgID int64) ([]definitions.NotificationTemplate, error) { + revision, err := t.configStore.Get(ctx, orgID) if err != nil { return nil, err } - if revision.cfg.TemplateFiles == nil { - return map[string]string{}, nil + var templates []definitions.NotificationTemplate + for name, tmpl := range revision.cfg.TemplateFiles { + tmpl := definitions.NotificationTemplate{ + Name: name, + Template: tmpl, + } + provenance, err := t.provenanceStore.GetProvenance(ctx, &tmpl, orgID) + if err != nil { + return nil, err + } + tmpl.Provenance = definitions.Provenance(provenance) + templates = append(templates, tmpl) } - return revision.cfg.TemplateFiles, nil + return templates, nil } func (t *TemplateService) SetTemplate(ctx context.Context, orgID int64, tmpl definitions.NotificationTemplate) (definitions.NotificationTemplate, error) { @@ -44,7 +54,7 @@ func (t *TemplateService) SetTemplate(ctx context.Context, orgID int64, tmpl def return definitions.NotificationTemplate{}, fmt.Errorf("%w: %s", ErrValidation, err.Error()) } - revision, err := getLastConfiguration(ctx, orgID, t.config) + revision, err := t.configStore.Get(ctx, orgID) if err != nil { return definitions.NotificationTemplate{}, err } @@ -53,33 +63,12 @@ func (t *TemplateService) SetTemplate(ctx context.Context, orgID int64, tmpl def revision.cfg.TemplateFiles = map[string]string{} } revision.cfg.TemplateFiles[tmpl.Name] = tmpl.Template - tmpls := make([]string, 0, len(revision.cfg.TemplateFiles)) - for name := range revision.cfg.TemplateFiles { - tmpls = append(tmpls, name) - } - revision.cfg.AlertmanagerConfig.Templates = tmpls - serialized, err := serializeAlertmanagerConfig(*revision.cfg) - if err != nil { - return definitions.NotificationTemplate{}, err - } - cmd := models.SaveAlertmanagerConfigurationCmd{ - AlertmanagerConfiguration: string(serialized), - ConfigurationVersion: revision.version, - FetchedConfigurationHash: revision.concurrencyToken, - Default: false, - OrgID: orgID, - } err = t.xact.InTransaction(ctx, func(ctx context.Context) error { - err = PersistConfig(ctx, t.config, &cmd) - if err != nil { + if err := t.configStore.Save(ctx, revision, orgID); err != nil { return err } - err = t.prov.SetProvenance(ctx, &tmpl, orgID, models.Provenance(tmpl.Provenance)) - if err != nil { - return err - } - return nil + return t.provenanceStore.SetProvenance(ctx, &tmpl, orgID, models.Provenance(tmpl.Provenance)) }) if err != nil { return definitions.NotificationTemplate{}, err @@ -89,42 +78,20 @@ func (t *TemplateService) SetTemplate(ctx context.Context, orgID int64, tmpl def } func (t *TemplateService) DeleteTemplate(ctx context.Context, orgID int64, name string) error { - revision, err := getLastConfiguration(ctx, orgID, t.config) + revision, err := t.configStore.Get(ctx, orgID) if err != nil { return err } delete(revision.cfg.TemplateFiles, name) - serialized, err := serializeAlertmanagerConfig(*revision.cfg) - if err != nil { - return err - } - - cmd := models.SaveAlertmanagerConfigurationCmd{ - AlertmanagerConfiguration: string(serialized), - ConfigurationVersion: revision.version, - FetchedConfigurationHash: revision.concurrencyToken, - Default: false, - OrgID: orgID, - } - err = t.xact.InTransaction(ctx, func(ctx context.Context) error { - err = PersistConfig(ctx, t.config, &cmd) - if err != nil { + return t.xact.InTransaction(ctx, func(ctx context.Context) error { + if err := t.configStore.Save(ctx, revision, orgID); err != nil { return err } tgt := definitions.NotificationTemplate{ Name: name, } - err = t.prov.DeleteProvenance(ctx, &tgt, orgID) - if err != nil { - return err - } - return nil + return t.provenanceStore.DeleteProvenance(ctx, &tgt, orgID) }) - if err != nil { - return err - } - - return nil } diff --git a/pkg/services/ngalert/provisioning/templates_test.go b/pkg/services/ngalert/provisioning/templates_test.go index 40bde70e0c6ad..6bbe9b2ca43ad 100644 --- a/pkg/services/ngalert/provisioning/templates_test.go +++ b/pkg/services/ngalert/provisioning/templates_test.go @@ -17,10 +17,11 @@ import ( func TestTemplateService(t *testing.T) { t.Run("service returns templates from config file", func(t *testing.T) { sut := createTemplateServiceSut() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: configWithTemplates, }) + sut.provenanceStore.(*MockProvisioningStore).EXPECT().GetProvenance(mock.Anything, mock.Anything, mock.Anything).Return(models.ProvenanceAPI, nil) result, err := sut.GetTemplates(context.Background(), 1) @@ -30,7 +31,7 @@ func TestTemplateService(t *testing.T) { t.Run("service returns empty map when config file contains no templates", func(t *testing.T) { sut := createTemplateServiceSut() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: defaultConfig, }) @@ -44,7 +45,7 @@ func TestTemplateService(t *testing.T) { t.Run("service propagates errors", func(t *testing.T) { t.Run("when unable to read config", func(t *testing.T) { sut := createTemplateServiceSut() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). Return(nil, fmt.Errorf("failed")) @@ -55,25 +56,25 @@ func TestTemplateService(t *testing.T) { t.Run("when config is invalid", func(t *testing.T) { sut := createTemplateServiceSut() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: brokenConfig, }) _, err := sut.GetTemplates(context.Background(), 1) - require.ErrorContains(t, err, "failed to deserialize") + require.Truef(t, ErrBadAlertmanagerConfiguration.Base.Is(err), "expected ErrBadAlertmanagerConfiguration but got %s", err.Error()) }) t.Run("when no AM config in current org", func(t *testing.T) { sut := createTemplateServiceSut() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). Return(nil, nil) _, err := sut.GetTemplates(context.Background(), 1) - require.ErrorContains(t, err, "no alertmanager configuration") + require.Truef(t, ErrNoAlertmanagerConfiguration.Is(err), "expected ErrNoAlertmanagerConfiguration but got %s", err.Error()) }) }) @@ -94,7 +95,7 @@ func TestTemplateService(t *testing.T) { t.Run("when unable to read config", func(t *testing.T) { sut := createTemplateServiceSut() tmpl := createNotificationTemplate() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). Return(nil, fmt.Errorf("failed")) @@ -106,37 +107,37 @@ func TestTemplateService(t *testing.T) { t.Run("when config is invalid", func(t *testing.T) { sut := createTemplateServiceSut() tmpl := createNotificationTemplate() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: brokenConfig, }) _, err := sut.SetTemplate(context.Background(), 1, tmpl) - require.ErrorContains(t, err, "failed to deserialize") + require.Truef(t, ErrBadAlertmanagerConfiguration.Base.Is(err), "expected ErrBadAlertmanagerConfiguration but got %s", err.Error()) }) t.Run("when no AM config in current org", func(t *testing.T) { sut := createTemplateServiceSut() tmpl := createNotificationTemplate() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). Return(nil, nil) _, err := sut.SetTemplate(context.Background(), 1, tmpl) - require.ErrorContains(t, err, "no alertmanager configuration") + require.Truef(t, ErrNoAlertmanagerConfiguration.Is(err), "expected ErrNoAlertmanagerConfiguration but got %s", err.Error()) }) t.Run("when provenance fails to save", func(t *testing.T) { sut := createTemplateServiceSut() tmpl := createNotificationTemplate() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: configWithTemplates, }) - sut.config.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.prov.(*MockProvisioningStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceeds() + sut.provenanceStore.(*MockProvisioningStore).EXPECT(). SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(fmt.Errorf("failed to save provenance")) @@ -148,14 +149,14 @@ func TestTemplateService(t *testing.T) { t.Run("when AM config fails to save", func(t *testing.T) { sut := createTemplateServiceSut() tmpl := createNotificationTemplate() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: configWithTemplates, }) - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything). Return(fmt.Errorf("failed to save config")) - sut.prov.(*MockProvisioningStore).EXPECT().SaveSucceeds() + sut.provenanceStore.(*MockProvisioningStore).EXPECT().SaveSucceeds() _, err := sut.SetTemplate(context.Background(), 1, tmpl) @@ -166,12 +167,12 @@ func TestTemplateService(t *testing.T) { t.Run("adds new template to config file on success", func(t *testing.T) { sut := createTemplateServiceSut() tmpl := createNotificationTemplate() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: configWithTemplates, }) - sut.config.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.prov.(*MockProvisioningStore).EXPECT().SaveSucceeds() + sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceeds() + sut.provenanceStore.(*MockProvisioningStore).EXPECT().SaveSucceeds() _, err := sut.SetTemplate(context.Background(), 1, tmpl) @@ -181,12 +182,12 @@ func TestTemplateService(t *testing.T) { t.Run("succeeds when stitching config file with no templates", func(t *testing.T) { sut := createTemplateServiceSut() tmpl := createNotificationTemplate() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: defaultConfig, }) - sut.config.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.prov.(*MockProvisioningStore).EXPECT().SaveSucceeds() + sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceeds() + sut.provenanceStore.(*MockProvisioningStore).EXPECT().SaveSucceeds() _, err := sut.SetTemplate(context.Background(), 1, tmpl) @@ -199,12 +200,12 @@ func TestTemplateService(t *testing.T) { Name: "name", Template: "content", } - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: defaultConfig, }) - sut.config.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.prov.(*MockProvisioningStore).EXPECT().SaveSucceeds() + sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceeds() + sut.provenanceStore.(*MockProvisioningStore).EXPECT().SaveSucceeds() result, _ := sut.SetTemplate(context.Background(), 1, tmpl) @@ -218,12 +219,12 @@ func TestTemplateService(t *testing.T) { Name: "name", Template: "{{define \"name\"}}content{{end}}", } - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: defaultConfig, }) - sut.config.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.prov.(*MockProvisioningStore).EXPECT().SaveSucceeds() + sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceeds() + sut.provenanceStore.(*MockProvisioningStore).EXPECT().SaveSucceeds() result, _ := sut.SetTemplate(context.Background(), 1, tmpl) @@ -236,12 +237,12 @@ func TestTemplateService(t *testing.T) { Name: "name", Template: "{{ .MyField }", } - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: defaultConfig, }) - sut.config.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.prov.(*MockProvisioningStore).EXPECT().SaveSucceeds() + sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceeds() + sut.provenanceStore.(*MockProvisioningStore).EXPECT().SaveSucceeds() _, err := sut.SetTemplate(context.Background(), 1, tmpl) @@ -254,12 +255,12 @@ func TestTemplateService(t *testing.T) { Name: "name", Template: "{{ .NotAField }}", } - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: defaultConfig, }) - sut.config.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.prov.(*MockProvisioningStore).EXPECT().SaveSucceeds() + sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceeds() + sut.provenanceStore.(*MockProvisioningStore).EXPECT().SaveSucceeds() _, err := sut.SetTemplate(context.Background(), 1, tmpl) @@ -271,7 +272,7 @@ func TestTemplateService(t *testing.T) { t.Run("propagates errors", func(t *testing.T) { t.Run("when unable to read config", func(t *testing.T) { sut := createTemplateServiceSut() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). Return(nil, fmt.Errorf("failed")) @@ -282,35 +283,35 @@ func TestTemplateService(t *testing.T) { t.Run("when config is invalid", func(t *testing.T) { sut := createTemplateServiceSut() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: brokenConfig, }) err := sut.DeleteTemplate(context.Background(), 1, "template") - require.ErrorContains(t, err, "failed to deserialize") + require.Truef(t, ErrBadAlertmanagerConfiguration.Base.Is(err), "expected ErrBadAlertmanagerConfiguration but got %s", err.Error()) }) t.Run("when no AM config in current org", func(t *testing.T) { sut := createTemplateServiceSut() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). Return(nil, nil) err := sut.DeleteTemplate(context.Background(), 1, "template") - require.ErrorContains(t, err, "no alertmanager configuration") + require.Truef(t, ErrNoAlertmanagerConfiguration.Is(err), "expected ErrNoAlertmanagerConfiguration but got %s", err.Error()) }) t.Run("when provenance fails to save", func(t *testing.T) { sut := createTemplateServiceSut() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: configWithTemplates, }) - sut.config.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.prov.(*MockProvisioningStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceeds() + sut.provenanceStore.(*MockProvisioningStore).EXPECT(). DeleteProvenance(mock.Anything, mock.Anything, mock.Anything). Return(fmt.Errorf("failed to save provenance")) @@ -321,14 +322,14 @@ func TestTemplateService(t *testing.T) { t.Run("when AM config fails to save", func(t *testing.T) { sut := createTemplateServiceSut() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: configWithTemplates, }) - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything). Return(fmt.Errorf("failed to save config")) - sut.prov.(*MockProvisioningStore).EXPECT().SaveSucceeds() + sut.provenanceStore.(*MockProvisioningStore).EXPECT().SaveSucceeds() err := sut.DeleteTemplate(context.Background(), 1, "template") @@ -338,12 +339,12 @@ func TestTemplateService(t *testing.T) { t.Run("deletes template from config file on success", func(t *testing.T) { sut := createTemplateServiceSut() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: configWithTemplates, }) - sut.config.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.prov.(*MockProvisioningStore).EXPECT().SaveSucceeds() + sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceeds() + sut.provenanceStore.(*MockProvisioningStore).EXPECT().SaveSucceeds() err := sut.DeleteTemplate(context.Background(), 1, "a") @@ -352,12 +353,12 @@ func TestTemplateService(t *testing.T) { t.Run("does not error when deleting templates that do not exist", func(t *testing.T) { sut := createTemplateServiceSut() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: configWithTemplates, }) - sut.config.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.prov.(*MockProvisioningStore).EXPECT().SaveSucceeds() + sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceeds() + sut.provenanceStore.(*MockProvisioningStore).EXPECT().SaveSucceeds() err := sut.DeleteTemplate(context.Background(), 1, "does not exist") @@ -366,12 +367,12 @@ func TestTemplateService(t *testing.T) { t.Run("succeeds when deleting from config file with no template section", func(t *testing.T) { sut := createTemplateServiceSut() - sut.config.(*MockAMConfigStore).EXPECT(). + sut.configStore.store.(*MockAMConfigStore).EXPECT(). GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: defaultConfig, }) - sut.config.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.prov.(*MockProvisioningStore).EXPECT().SaveSucceeds() + sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceeds() + sut.provenanceStore.(*MockProvisioningStore).EXPECT().SaveSucceeds() err := sut.DeleteTemplate(context.Background(), 1, "a") @@ -382,10 +383,10 @@ func TestTemplateService(t *testing.T) { func createTemplateServiceSut() *TemplateService { return &TemplateService{ - config: &MockAMConfigStore{}, - prov: &MockProvisioningStore{}, - xact: newNopTransactionManager(), - log: log.NewNopLogger(), + configStore: &alertmanagerConfigStoreImpl{store: &MockAMConfigStore{}}, + provenanceStore: &MockProvisioningStore{}, + xact: newNopTransactionManager(), + log: log.NewNopLogger(), } } diff --git a/pkg/services/ngalert/provisioning/testing.go b/pkg/services/ngalert/provisioning/testing.go index 121601a292dcf..921da0498a849 100644 --- a/pkg/services/ngalert/provisioning/testing.go +++ b/pkg/services/ngalert/provisioning/testing.go @@ -2,13 +2,13 @@ package provisioning import ( "context" - "crypto/md5" - "fmt" - "strings" + "testing" + "github.com/stretchr/testify/assert" mock "github.com/stretchr/testify/mock" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" ) const defaultAlertmanagerConfigJSON = ` @@ -29,8 +29,8 @@ const defaultAlertmanagerConfigJSON = ` "receivers": [{ "name": "grafana-default-email", "grafana_managed_receiver_configs": [{ - "uid": "", - "name": "email receiver", + "uid": "UID1", + "name": "grafana-default-email", "type": "email", "disableResolveMessage": false, "settings": { @@ -39,9 +39,9 @@ const defaultAlertmanagerConfigJSON = ` "secureFields": {} }] }, { - "name": "a new receiver", + "name": "slack receiver", "grafana_managed_receiver_configs": [{ - "uid": "", + "uid": "UID2", "name": "slack receiver", "type": "slack", "disableResolveMessage": false, @@ -53,96 +53,18 @@ const defaultAlertmanagerConfigJSON = ` } ` -type fakeAMConfigStore struct { - config models.AlertConfiguration - lastSaveCommand *models.SaveAlertmanagerConfigurationCmd -} - -func newFakeAMConfigStore(config string) *fakeAMConfigStore { - return &fakeAMConfigStore{ - config: models.AlertConfiguration{ - AlertmanagerConfiguration: config, - ConfigurationVersion: "v1", - Default: true, - OrgID: 1, - }, - lastSaveCommand: nil, - } -} - -func (f *fakeAMConfigStore) GetLatestAlertmanagerConfiguration(ctx context.Context, orgID int64) (*models.AlertConfiguration, error) { - result := &f.config - result.OrgID = orgID - result.ConfigurationHash = fmt.Sprintf("%x", md5.Sum([]byte(f.config.AlertmanagerConfiguration))) - return result, nil -} - -func (f *fakeAMConfigStore) UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error { - f.config = models.AlertConfiguration{ - AlertmanagerConfiguration: cmd.AlertmanagerConfiguration, - ConfigurationVersion: cmd.ConfigurationVersion, - Default: cmd.Default, - OrgID: cmd.OrgID, - } - f.lastSaveCommand = cmd - return nil -} - -type fakeProvisioningStore struct { - records map[int64]map[string]models.Provenance -} - -func NewFakeProvisioningStore() *fakeProvisioningStore { - return &fakeProvisioningStore{ - records: map[int64]map[string]models.Provenance{}, - } -} - -func (f *fakeProvisioningStore) GetProvenance(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) { - if val, ok := f.records[org]; ok { - if prov, ok := val[o.ResourceID()+o.ResourceType()]; ok { - return prov, nil - } - } - return models.ProvenanceNone, nil -} - -func (f *fakeProvisioningStore) GetProvenances(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) { - results := make(map[string]models.Provenance) - if val, ok := f.records[orgID]; ok { - for k, v := range val { - if strings.HasSuffix(k, resourceType) { - results[strings.TrimSuffix(k, resourceType)] = v - } - } - } - return results, nil -} - -func (f *fakeProvisioningStore) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error { - if _, ok := f.records[org]; !ok { - f.records[org] = map[string]models.Provenance{} - } - _ = f.DeleteProvenance(ctx, o, org) // delete old entries first - f.records[org][o.ResourceID()+o.ResourceType()] = p - return nil -} - -func (f *fakeProvisioningStore) DeleteProvenance(ctx context.Context, o models.Provisionable, org int64) error { - if val, ok := f.records[org]; ok { - delete(val, o.ResourceID()+o.ResourceType()) - } - return nil -} - type NopTransactionManager struct{} func newNopTransactionManager() *NopTransactionManager { return &NopTransactionManager{} } +func assertInTransaction(t *testing.T, ctx context.Context) { + assert.Truef(t, ctx.Value(NopTransactionManager{}) != nil, "Expected to be executed in transaction but there is none") +} + func (n *NopTransactionManager) InTransaction(ctx context.Context, work func(ctx context.Context) error) error { - return work(ctx) + return work(context.WithValue(ctx, NopTransactionManager{}, struct{}{})) } func (m *MockAMConfigStore_Expecter) GetsConfig(ac models.AlertConfiguration) *MockAMConfigStore_Expecter { @@ -185,3 +107,43 @@ func (m *MockQuotaChecker_Expecter) LimitExceeded() *MockQuotaChecker_Expecter { m.CheckQuotaReached(mock.Anything, mock.Anything, mock.Anything).Return(true, nil) return m } + +type methodCall struct { + Method string + Args []interface{} +} + +type alertmanagerConfigStoreFake struct { + Calls []methodCall + GetFn func(ctx context.Context, orgID int64) (*cfgRevision, error) + SaveFn func(ctx context.Context, revision *cfgRevision) error +} + +func (a *alertmanagerConfigStoreFake) Get(ctx context.Context, orgID int64) (*cfgRevision, error) { + a.Calls = append(a.Calls, methodCall{ + Method: "Get", + Args: []interface{}{ctx, orgID}, + }) + if a.GetFn != nil { + return a.GetFn(ctx, orgID) + } + return nil, nil +} + +func (a *alertmanagerConfigStoreFake) Save(ctx context.Context, revision *cfgRevision, orgID int64) error { + a.Calls = append(a.Calls, methodCall{ + Method: "Save", + Args: []interface{}{ctx, revision, orgID}, + }) + if a.SaveFn != nil { + return a.SaveFn(ctx, revision) + } + return nil +} + +type NotificationSettingsValidatorProviderFake struct { +} + +func (n *NotificationSettingsValidatorProviderFake) Validator(ctx context.Context, orgID int64) (notifier.NotificationSettingsValidator, error) { + return notifier.NoValidation{}, nil +} diff --git a/pkg/services/ngalert/remote/alertmanager.go b/pkg/services/ngalert/remote/alertmanager.go index 9d2d35d9ea83f..8eabc86fbe4af 100644 --- a/pkg/services/ngalert/remote/alertmanager.go +++ b/pkg/services/ngalert/remote/alertmanager.go @@ -10,6 +10,7 @@ import ( "github.com/go-openapi/strfmt" "github.com/grafana/grafana/pkg/infra/log" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier" remoteClient "github.com/grafana/grafana/pkg/services/ngalert/remote/client" @@ -26,6 +27,7 @@ type stateStore interface { type Alertmanager struct { log log.Logger + metrics *metrics.RemoteAlertmanager orgID int64 ready bool sender *sender.ExternalAlertmanager @@ -59,7 +61,7 @@ func (cfg *AlertmanagerConfig) Validate() error { return nil } -func NewAlertmanager(cfg AlertmanagerConfig, store stateStore) (*Alertmanager, error) { +func NewAlertmanager(cfg AlertmanagerConfig, store stateStore, metrics *metrics.RemoteAlertmanager) (*Alertmanager, error) { if err := cfg.Validate(); err != nil { return nil, err } @@ -76,7 +78,7 @@ func NewAlertmanager(cfg AlertmanagerConfig, store stateStore) (*Alertmanager, e Password: cfg.BasicAuthPassword, Logger: logger, } - mc, err := remoteClient.New(mcCfg) + mc, err := remoteClient.New(mcCfg, metrics) if err != nil { return nil, err } @@ -87,7 +89,7 @@ func NewAlertmanager(cfg AlertmanagerConfig, store stateStore) (*Alertmanager, e Password: cfg.BasicAuthPassword, Logger: logger, } - amc, err := remoteClient.NewAlertmanager(amcCfg) + amc, err := remoteClient.NewAlertmanager(amcCfg, metrics) if err != nil { return nil, err } @@ -104,13 +106,17 @@ func NewAlertmanager(cfg AlertmanagerConfig, store stateStore) (*Alertmanager, e return nil, err } + // Initialize LastReadinessCheck so it's present even if the check fails. + metrics.LastReadinessCheck.Set(0) + return &Alertmanager{ + amClient: amc, log: logger, + metrics: metrics, mimirClient: mc, + orgID: cfg.OrgID, state: store, - amClient: amc, sender: s, - orgID: cfg.OrgID, tenantID: cfg.TenantID, url: cfg.URL, }, nil @@ -159,6 +165,7 @@ func (am *Alertmanager) checkReadiness(ctx context.Context) error { if ready { am.log.Debug("Alertmanager readiness check successful") + am.metrics.LastReadinessCheck.SetToCurrentTime() am.ready = true return nil } @@ -170,6 +177,7 @@ func (am *Alertmanager) checkReadiness(ctx context.Context) error { // If not, it sends the configuration to the remote Alertmanager. func (am *Alertmanager) CompareAndSendConfiguration(ctx context.Context, config *models.AlertConfiguration) error { if am.shouldSendConfig(ctx, config) { + am.metrics.ConfigSyncsTotal.Inc() if err := am.mimirClient.CreateGrafanaAlertmanagerConfig( ctx, config.AlertmanagerConfiguration, @@ -178,8 +186,10 @@ func (am *Alertmanager) CompareAndSendConfiguration(ctx context.Context, config config.CreatedAt, config.Default, ); err != nil { + am.metrics.ConfigSyncErrorsTotal.Inc() return err } + am.metrics.LastConfigSync.SetToCurrentTime() } return nil } @@ -193,9 +203,12 @@ func (am *Alertmanager) CompareAndSendState(ctx context.Context) error { } if am.shouldSendState(ctx, state) { + am.metrics.StateSyncsTotal.Inc() if err := am.mimirClient.CreateGrafanaAlertmanagerState(ctx, state); err != nil { + am.metrics.StateSyncErrorsTotal.Inc() return err } + am.metrics.LastStateSync.SetToCurrentTime() } return nil } diff --git a/pkg/services/ngalert/remote/alertmanager_test.go b/pkg/services/ngalert/remote/alertmanager_test.go index 5a447da5b1d3b..59e6003467795 100644 --- a/pkg/services/ngalert/remote/alertmanager_test.go +++ b/pkg/services/ngalert/remote/alertmanager_test.go @@ -14,12 +14,14 @@ import ( "github.com/go-openapi/strfmt" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/metrics" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" "github.com/grafana/grafana/pkg/util" amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/cluster/clusterpb" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" ) @@ -68,7 +70,8 @@ func TestNewAlertmanager(t *testing.T) { TenantID: test.tenantID, BasicAuthPassword: test.password, } - am, err := NewAlertmanager(cfg, nil) + m := metrics.NewRemoteAlertmanagerMetrics(prometheus.NewRegistry()) + am, err := NewAlertmanager(cfg, nil, m) if test.expErr != "" { require.EqualError(tt, err, test.expErr) return @@ -106,7 +109,8 @@ func TestApplyConfig(t *testing.T) { require.NoError(t, store.Set(ctx, cfg.OrgID, "alertmanager", notifier.SilencesFilename, "test")) require.NoError(t, store.Set(ctx, cfg.OrgID, "alertmanager", notifier.NotificationLogFilename, "test")) - am, err := NewAlertmanager(cfg, fstore) + m := metrics.NewRemoteAlertmanagerMetrics(prometheus.NewRegistry()) + am, err := NewAlertmanager(cfg, fstore, m) require.NoError(t, err) config := &ngmodels.AlertConfiguration{} @@ -175,7 +179,8 @@ func TestIntegrationRemoteAlertmanagerApplyConfigOnlyUploadsOnce(t *testing.T) { require.NoError(t, err) encodedFullState := base64.StdEncoding.EncodeToString(fullState) - am, err := NewAlertmanager(cfg, fstore) + m := metrics.NewRemoteAlertmanagerMetrics(prometheus.NewRegistry()) + am, err := NewAlertmanager(cfg, fstore, m) require.NoError(t, err) // We should have no configuration or state at first. @@ -259,7 +264,8 @@ func TestIntegrationRemoteAlertmanagerSilences(t *testing.T) { TenantID: tenantID, BasicAuthPassword: password, } - am, err := NewAlertmanager(cfg, nil) + m := metrics.NewRemoteAlertmanagerMetrics(prometheus.NewRegistry()) + am, err := NewAlertmanager(cfg, nil, m) require.NoError(t, err) // We should have no silences at first. @@ -339,7 +345,8 @@ func TestIntegrationRemoteAlertmanagerAlerts(t *testing.T) { TenantID: tenantID, BasicAuthPassword: password, } - am, err := NewAlertmanager(cfg, nil) + m := metrics.NewRemoteAlertmanagerMetrics(prometheus.NewRegistry()) + am, err := NewAlertmanager(cfg, nil, m) require.NoError(t, err) // Wait until the Alertmanager is ready to send alerts. @@ -405,7 +412,8 @@ func TestIntegrationRemoteAlertmanagerReceivers(t *testing.T) { BasicAuthPassword: password, } - am, err := NewAlertmanager(cfg, nil) + m := metrics.NewRemoteAlertmanagerMetrics(prometheus.NewRegistry()) + am, err := NewAlertmanager(cfg, nil, m) require.NoError(t, err) // We should start with the default config. diff --git a/pkg/services/ngalert/remote/client/alertmanager.go b/pkg/services/ngalert/remote/client/alertmanager.go index 187b590bcbf2f..88a7ca7638b31 100644 --- a/pkg/services/ngalert/remote/client/alertmanager.go +++ b/pkg/services/ngalert/remote/client/alertmanager.go @@ -9,6 +9,8 @@ import ( httptransport "github.com/go-openapi/runtime/client" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert/client" + "github.com/grafana/grafana/pkg/services/ngalert/metrics" amclient "github.com/prometheus/alertmanager/api/v2/client" ) @@ -24,12 +26,12 @@ type AlertmanagerConfig struct { type Alertmanager struct { *amclient.AlertmanagerAPI - httpClient *http.Client + httpClient client.Requester url *url.URL logger log.Logger } -func NewAlertmanager(cfg *AlertmanagerConfig) (*Alertmanager, error) { +func NewAlertmanager(cfg *AlertmanagerConfig, metrics *metrics.RemoteAlertmanager) (*Alertmanager, error) { // First, add the authentication middleware. c := &http.Client{Transport: &MimirAuthRoundTripper{ TenantID: cfg.TenantID, @@ -37,23 +39,27 @@ func NewAlertmanager(cfg *AlertmanagerConfig) (*Alertmanager, error) { Next: http.DefaultTransport, }} + tc := client.NewTimedClient(c, metrics.RequestLatency) apiEndpoint := *cfg.URL // Next, make sure you set the right path. u := apiEndpoint.JoinPath(alertmanagerAPIMountPath, amclient.DefaultBasePath) - transport := httptransport.NewWithClient(u.Host, u.Path, []string{u.Scheme}, c) + + // Create an Alertmanager client using the timed client as the transport. + r := httptransport.New(u.Host, u.Path, []string{u.Scheme}) + r.Transport = tc return &Alertmanager{ logger: cfg.Logger, url: cfg.URL, - AlertmanagerAPI: amclient.New(transport, nil), - httpClient: c, + AlertmanagerAPI: amclient.New(r, nil), + httpClient: tc, }, nil } -// GetAuthedClient returns a *http.Client that includes a configured MimirAuthRoundTripper. +// GetAuthedClient returns a client.Requester that includes a configured MimirAuthRoundTripper. // Requests using this client are fully authenticated. -func (am *Alertmanager) GetAuthedClient() *http.Client { +func (am *Alertmanager) GetAuthedClient() client.Requester { return am.httpClient } @@ -101,6 +107,10 @@ func (am *Alertmanager) IsReadyWithBackoff(ctx context.Context) (bool, error) { } if status != http.StatusOK { + if status >= 400 && status < 500 { + am.logger.Debug("Ready check failed with non-retriable status code", "attempt", attempts, "status", status) + return false, fmt.Errorf("ready check failed with non-retriable status code %d", status) + } am.logger.Debug("Ready check failed, status code is not 200", "attempt", attempts, "status", status, "err", err) continue } diff --git a/pkg/services/ngalert/remote/client/mimir.go b/pkg/services/ngalert/remote/client/mimir.go index 25bfa15ca7f81..c4e0d96dc7279 100644 --- a/pkg/services/ngalert/remote/client/mimir.go +++ b/pkg/services/ngalert/remote/client/mimir.go @@ -12,6 +12,8 @@ import ( "strings" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert/client" + "github.com/grafana/grafana/pkg/services/ngalert/metrics" ) // MimirClient contains all the methods to query the migration critical endpoints of Mimir instance, it's an interface to allow multiple implementations. @@ -26,9 +28,10 @@ type MimirClient interface { } type Mimir struct { + client client.Requester endpoint *url.URL - client http.Client logger log.Logger + metrics *metrics.RemoteAlertmanager } type Config struct { @@ -60,21 +63,22 @@ func (e *errorResponse) Error() string { return e.Error2 } -func New(cfg *Config) (*Mimir, error) { +func New(cfg *Config, metrics *metrics.RemoteAlertmanager) (*Mimir, error) { rt := &MimirAuthRoundTripper{ TenantID: cfg.TenantID, Password: cfg.Password, Next: http.DefaultTransport, } - c := http.Client{ + c := &http.Client{ Transport: rt, } return &Mimir{ endpoint: cfg.URL, - client: c, + client: client.NewTimedClient(c, metrics.RequestLatency), logger: cfg.Logger, + metrics: metrics, }, nil } diff --git a/pkg/services/ngalert/schedule/alert_rule.go b/pkg/services/ngalert/schedule/alert_rule.go new file mode 100644 index 0000000000000..3b9e4794846d1 --- /dev/null +++ b/pkg/services/ngalert/schedule/alert_rule.go @@ -0,0 +1,461 @@ +package schedule + +import ( + context "context" + "errors" + "fmt" + "net/url" + "time" + + "github.com/benbjohnson/clock" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/ngalert/eval" + "github.com/grafana/grafana/pkg/services/ngalert/metrics" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/state" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/util" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// Rule represents a single piece of work that is executed periodically by the ruler. +type Rule interface { + // Run creates the resources that will perform the rule's work, and starts it. It blocks indefinitely, until Stop is called or another signal is sent. + Run(key ngmodels.AlertRuleKey) error + // Stop shuts down the rule's execution with an optional reason. It has no effect if the rule has not yet been Run. + Stop(reason error) + // Eval sends a signal to execute the work represented by the rule, exactly one time. + // It has no effect if the rule has not yet been Run, or if the rule is Stopped. + Eval(eval *Evaluation) (bool, *Evaluation) + // Update sends a singal to change the definition of the rule. + Update(lastVersion RuleVersionAndPauseStatus) bool +} + +type ruleFactoryFunc func(context.Context) Rule + +func (f ruleFactoryFunc) new(ctx context.Context) Rule { + return f(ctx) +} + +func newRuleFactory( + appURL *url.URL, + disableGrafanaFolder bool, + maxAttempts int64, + sender AlertsSender, + stateManager *state.Manager, + evalFactory eval.EvaluatorFactory, + ruleProvider ruleProvider, + clock clock.Clock, + met *metrics.Scheduler, + logger log.Logger, + tracer tracing.Tracer, + evalAppliedHook evalAppliedFunc, + stopAppliedHook stopAppliedFunc, +) ruleFactoryFunc { + return func(ctx context.Context) Rule { + return newAlertRule( + ctx, + appURL, + disableGrafanaFolder, + maxAttempts, + sender, + stateManager, + evalFactory, + ruleProvider, + clock, + met, + logger, + tracer, + evalAppliedHook, + stopAppliedHook, + ) + } +} + +type evalAppliedFunc = func(ngmodels.AlertRuleKey, time.Time) +type stopAppliedFunc = func(ngmodels.AlertRuleKey) + +type ruleProvider interface { + get(ngmodels.AlertRuleKey) *ngmodels.AlertRule +} + +type alertRule struct { + evalCh chan *Evaluation + updateCh chan RuleVersionAndPauseStatus + ctx context.Context + stopFn util.CancelCauseFunc + + appURL *url.URL + disableGrafanaFolder bool + maxAttempts int64 + + clock clock.Clock + sender AlertsSender + stateManager *state.Manager + evalFactory eval.EvaluatorFactory + ruleProvider ruleProvider + + // Event hooks that are only used in tests. + evalAppliedHook evalAppliedFunc + stopAppliedHook stopAppliedFunc + + metrics *metrics.Scheduler + logger log.Logger + tracer tracing.Tracer +} + +func newAlertRule( + parent context.Context, + appURL *url.URL, + disableGrafanaFolder bool, + maxAttempts int64, + sender AlertsSender, + stateManager *state.Manager, + evalFactory eval.EvaluatorFactory, + ruleProvider ruleProvider, + clock clock.Clock, + met *metrics.Scheduler, + logger log.Logger, + tracer tracing.Tracer, + evalAppliedHook func(ngmodels.AlertRuleKey, time.Time), + stopAppliedHook func(ngmodels.AlertRuleKey), +) *alertRule { + ctx, stop := util.WithCancelCause(parent) + return &alertRule{ + evalCh: make(chan *Evaluation), + updateCh: make(chan RuleVersionAndPauseStatus), + ctx: ctx, + stopFn: stop, + appURL: appURL, + disableGrafanaFolder: disableGrafanaFolder, + maxAttempts: maxAttempts, + clock: clock, + sender: sender, + stateManager: stateManager, + evalFactory: evalFactory, + ruleProvider: ruleProvider, + evalAppliedHook: evalAppliedHook, + stopAppliedHook: stopAppliedHook, + metrics: met, + logger: logger, + tracer: tracer, + } +} + +// eval signals the rule evaluation routine to perform the evaluation of the rule. Does nothing if the loop is stopped. +// Before sending a message into the channel, it does non-blocking read to make sure that there is no concurrent send operation. +// Returns a tuple where first element is +// - true when message was sent +// - false when the send operation is stopped +// +// the second element contains a dropped message that was sent by a concurrent sender. +func (a *alertRule) Eval(eval *Evaluation) (bool, *Evaluation) { + // read the channel in unblocking manner to make sure that there is no concurrent send operation. + var droppedMsg *Evaluation + select { + case droppedMsg = <-a.evalCh: + default: + } + + select { + case a.evalCh <- eval: + return true, droppedMsg + case <-a.ctx.Done(): + return false, droppedMsg + } +} + +// update sends an instruction to the rule evaluation routine to update the scheduled rule to the specified version. The specified version must be later than the current version, otherwise no update will happen. +func (a *alertRule) Update(lastVersion RuleVersionAndPauseStatus) bool { + // check if the channel is not empty. + select { + case <-a.updateCh: + case <-a.ctx.Done(): + return false + default: + } + + select { + case a.updateCh <- lastVersion: + return true + case <-a.ctx.Done(): + return false + } +} + +// stop sends an instruction to the rule evaluation routine to shut down. an optional shutdown reason can be given. +func (a *alertRule) Stop(reason error) { + if a.stopFn != nil { + a.stopFn(reason) + } +} + +func (a *alertRule) Run(key ngmodels.AlertRuleKey) error { + grafanaCtx := ngmodels.WithRuleKey(a.ctx, key) + logger := a.logger.FromContext(grafanaCtx) + logger.Debug("Alert rule routine started") + + evalRunning := false + var currentFingerprint fingerprint + defer a.stopApplied(key) + for { + select { + // used by external services (API) to notify that rule is updated. + case ctx := <-a.updateCh: + if currentFingerprint == ctx.Fingerprint { + logger.Info("Rule's fingerprint has not changed. Skip resetting the state", "currentFingerprint", currentFingerprint) + continue + } + + logger.Info("Clearing the state of the rule because it was updated", "isPaused", ctx.IsPaused, "fingerprint", ctx.Fingerprint) + // clear the state. So the next evaluation will start from the scratch. + a.resetState(grafanaCtx, key, ctx.IsPaused) + currentFingerprint = ctx.Fingerprint + // evalCh - used by the scheduler to signal that evaluation is needed. + case ctx, ok := <-a.evalCh: + if !ok { + logger.Debug("Evaluation channel has been closed. Exiting") + return nil + } + if evalRunning { + continue + } + + func() { + evalRunning = true + defer func() { + evalRunning = false + a.evalApplied(key, ctx.scheduledAt) + }() + + for attempt := int64(1); attempt <= a.maxAttempts; attempt++ { + isPaused := ctx.rule.IsPaused + f := ruleWithFolder{ctx.rule, ctx.folderTitle}.Fingerprint() + // Do not clean up state if the eval loop has just started. + var needReset bool + if currentFingerprint != 0 && currentFingerprint != f { + logger.Debug("Got a new version of alert rule. Clear up the state", "fingerprint", f) + needReset = true + } + // We need to reset state if the loop has started and the alert is already paused. It can happen, + // if we have an alert with state and we do file provision with stateful Grafana, that state + // lingers in DB and won't be cleaned up until next alert rule update. + needReset = needReset || (currentFingerprint == 0 && isPaused) + if needReset { + a.resetState(grafanaCtx, key, isPaused) + } + currentFingerprint = f + if isPaused { + logger.Debug("Skip rule evaluation because it is paused") + return + } + + fpStr := currentFingerprint.String() + utcTick := ctx.scheduledAt.UTC().Format(time.RFC3339Nano) + tracingCtx, span := a.tracer.Start(grafanaCtx, "alert rule execution", trace.WithAttributes( + attribute.String("rule_uid", ctx.rule.UID), + attribute.Int64("org_id", ctx.rule.OrgID), + attribute.Int64("rule_version", ctx.rule.Version), + attribute.String("rule_fingerprint", fpStr), + attribute.String("tick", utcTick), + )) + + // Check before any execution if the context was cancelled so that we don't do any evaluations. + if tracingCtx.Err() != nil { + span.SetStatus(codes.Error, "rule evaluation cancelled") + span.End() + logger.Error("Skip evaluation and updating the state because the context has been cancelled", "version", ctx.rule.Version, "fingerprint", f, "attempt", attempt, "now", ctx.scheduledAt) + return + } + + retry := attempt < a.maxAttempts + err := a.evaluate(tracingCtx, key, f, attempt, ctx, span, retry) + // This is extremely confusing - when we exhaust all retry attempts, or we have no retryable errors + // we return nil - so technically, this is meaningless to know whether the evaluation has errors or not. + span.End() + if err == nil { + return + } + + logger.Error("Failed to evaluate rule", "version", ctx.rule.Version, "fingerprint", f, "attempt", attempt, "now", ctx.scheduledAt, "error", err) + select { + case <-tracingCtx.Done(): + logger.Error("Context has been cancelled while backing off", "version", ctx.rule.Version, "fingerprint", f, "attempt", attempt, "now", ctx.scheduledAt) + return + case <-time.After(retryDelay): + continue + } + } + }() + + case <-grafanaCtx.Done(): + // clean up the state only if the reason for stopping the evaluation loop is that the rule was deleted + if errors.Is(grafanaCtx.Err(), errRuleDeleted) { + // We do not want a context to be unbounded which could potentially cause a go routine running + // indefinitely. 1 minute is an almost randomly chosen timeout, big enough to cover the majority of the + // cases. + ctx, cancelFunc := context.WithTimeout(context.Background(), time.Minute) + defer cancelFunc() + states := a.stateManager.DeleteStateByRuleUID(ngmodels.WithRuleKey(ctx, key), key, ngmodels.StateReasonRuleDeleted) + a.notify(grafanaCtx, key, states) + } + logger.Debug("Stopping alert rule routine") + return nil + } + } +} + +func (a *alertRule) evaluate(ctx context.Context, key ngmodels.AlertRuleKey, f fingerprint, attempt int64, e *Evaluation, span trace.Span, retry bool) error { + orgID := fmt.Sprint(key.OrgID) + evalTotal := a.metrics.EvalTotal.WithLabelValues(orgID) + evalDuration := a.metrics.EvalDuration.WithLabelValues(orgID) + evalTotalFailures := a.metrics.EvalFailures.WithLabelValues(orgID) + processDuration := a.metrics.ProcessDuration.WithLabelValues(orgID) + sendDuration := a.metrics.SendDuration.WithLabelValues(orgID) + + logger := a.logger.FromContext(ctx).New("version", e.rule.Version, "fingerprint", f, "attempt", attempt, "now", e.scheduledAt).FromContext(ctx) + start := a.clock.Now() + + evalCtx := eval.NewContextWithPreviousResults(ctx, SchedulerUserFor(e.rule.OrgID), a.newLoadedMetricsReader(e.rule)) + ruleEval, err := a.evalFactory.Create(evalCtx, e.rule.GetEvalCondition()) + var results eval.Results + var dur time.Duration + if err != nil { + dur = a.clock.Now().Sub(start) + logger.Error("Failed to build rule evaluator", "error", err) + } else { + results, err = ruleEval.Evaluate(ctx, e.scheduledAt) + dur = a.clock.Now().Sub(start) + if err != nil { + logger.Error("Failed to evaluate rule", "error", err, "duration", dur) + } + } + + evalTotal.Inc() + evalDuration.Observe(dur.Seconds()) + + if ctx.Err() != nil { // check if the context is not cancelled. The evaluation can be a long-running task. + span.SetStatus(codes.Error, "rule evaluation cancelled") + logger.Debug("Skip updating the state because the context has been cancelled") + return nil + } + + if err != nil || results.HasErrors() { + evalTotalFailures.Inc() + + // Only retry (return errors) if this isn't the last attempt, otherwise skip these return operations. + if retry { + // The only thing that can return non-nil `err` from ruleEval.Evaluate is the server side expression pipeline. + // This includes transport errors such as transient network errors. + if err != nil { + span.SetStatus(codes.Error, "rule evaluation failed") + span.RecordError(err) + return fmt.Errorf("server side expressions pipeline returned an error: %w", err) + } + + // If the pipeline executed successfully but have other types of errors that can be retryable, we should do so. + if !results.HasNonRetryableErrors() { + span.SetStatus(codes.Error, "rule evaluation failed") + span.RecordError(err) + return fmt.Errorf("the result-set has errors that can be retried: %w", results.Error()) + } + } + + // If results is nil, we assume that the error must be from the SSE pipeline (ruleEval.Evaluate) which is the only code that can actually return an `err`. + if results == nil { + results = append(results, eval.NewResultFromError(err, e.scheduledAt, dur)) + } + + // If err is nil, we assume that the SSS pipeline succeeded and that the error must be embedded in the results. + if err == nil { + err = results.Error() + } + + span.SetStatus(codes.Error, "rule evaluation failed") + span.RecordError(err) + } else { + logger.Debug("Alert rule evaluated", "results", results, "duration", dur) + span.AddEvent("rule evaluated", trace.WithAttributes( + attribute.Int64("results", int64(len(results))), + )) + } + start = a.clock.Now() + processedStates := a.stateManager.ProcessEvalResults( + ctx, + e.scheduledAt, + e.rule, + results, + state.GetRuleExtraLabels(logger, e.rule, e.folderTitle, !a.disableGrafanaFolder), + ) + processDuration.Observe(a.clock.Now().Sub(start).Seconds()) + + start = a.clock.Now() + alerts := state.FromStateTransitionToPostableAlerts(processedStates, a.stateManager, a.appURL) + span.AddEvent("results processed", trace.WithAttributes( + attribute.Int64("state_transitions", int64(len(processedStates))), + attribute.Int64("alerts_to_send", int64(len(alerts.PostableAlerts))), + )) + if len(alerts.PostableAlerts) > 0 { + a.sender.Send(ctx, key, alerts) + } + sendDuration.Observe(a.clock.Now().Sub(start).Seconds()) + + return nil +} + +func (a *alertRule) notify(ctx context.Context, key ngmodels.AlertRuleKey, states []state.StateTransition) { + expiredAlerts := state.FromAlertsStateToStoppedAlert(states, a.appURL, a.clock) + if len(expiredAlerts.PostableAlerts) > 0 { + a.sender.Send(ctx, key, expiredAlerts) + } +} + +func (a *alertRule) resetState(ctx context.Context, key ngmodels.AlertRuleKey, isPaused bool) { + rule := a.ruleProvider.get(key) + reason := ngmodels.StateReasonUpdated + if isPaused { + reason = ngmodels.StateReasonPaused + } + states := a.stateManager.ResetStateByRuleUID(ctx, rule, reason) + a.notify(ctx, key, states) +} + +// evalApplied is only used on tests. +func (a *alertRule) evalApplied(alertDefKey ngmodels.AlertRuleKey, now time.Time) { + if a.evalAppliedHook == nil { + return + } + + a.evalAppliedHook(alertDefKey, now) +} + +// stopApplied is only used on tests. +func (a *alertRule) stopApplied(alertDefKey ngmodels.AlertRuleKey) { + if a.stopAppliedHook == nil { + return + } + + a.stopAppliedHook(alertDefKey) +} + +func SchedulerUserFor(orgID int64) *user.SignedInUser { + return &user.SignedInUser{ + UserID: -1, + IsServiceAccount: true, + Login: "grafana_scheduler", + OrgID: orgID, + OrgRole: org.RoleAdmin, + Permissions: map[int64]map[string][]string{ + orgID: { + datasources.ActionQuery: []string{ + datasources.ScopeAll, + }, + }, + }, + } +} diff --git a/pkg/services/ngalert/schedule/alert_rule_test.go b/pkg/services/ngalert/schedule/alert_rule_test.go new file mode 100644 index 0000000000000..3fbff033dea80 --- /dev/null +++ b/pkg/services/ngalert/schedule/alert_rule_test.go @@ -0,0 +1,721 @@ +package schedule + +import ( + "bytes" + context "context" + "fmt" + "math" + "math/rand" + "runtime" + "sync" + "testing" + "time" + + alertingModels "github.com/grafana/alerting/models" + "github.com/grafana/grafana-plugin-sdk-go/data" + definitions "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/eval" + models "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/state" + "github.com/grafana/grafana/pkg/util" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + prometheusModel "github.com/prometheus/common/model" + "github.com/stretchr/testify/assert" + mock "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestAlertRule(t *testing.T) { + type evalResponse struct { + success bool + droppedEval *Evaluation + } + + t.Run("when rule evaluation is not stopped", func(t *testing.T) { + t.Run("update should send to updateCh", func(t *testing.T) { + r := blankRuleForTests(context.Background()) + resultCh := make(chan bool) + go func() { + resultCh <- r.Update(RuleVersionAndPauseStatus{fingerprint(rand.Uint64()), false}) + }() + select { + case <-r.updateCh: + require.True(t, <-resultCh) + case <-time.After(5 * time.Second): + t.Fatal("No message was received on update channel") + } + }) + t.Run("update should drop any concurrent sending to updateCh", func(t *testing.T) { + r := blankRuleForTests(context.Background()) + version1 := RuleVersionAndPauseStatus{fingerprint(rand.Uint64()), false} + version2 := RuleVersionAndPauseStatus{fingerprint(rand.Uint64()), false} + + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + wg.Done() + r.Update(version1) + wg.Done() + }() + wg.Wait() + wg.Add(2) // one when time1 is sent, another when go-routine for time2 has started + go func() { + wg.Done() + r.Update(version2) + }() + wg.Wait() // at this point tick 1 has already been dropped + select { + case version := <-r.updateCh: + require.Equal(t, version2, version) + case <-time.After(5 * time.Second): + t.Fatal("No message was received on eval channel") + } + }) + t.Run("eval should send to evalCh", func(t *testing.T) { + r := blankRuleForTests(context.Background()) + expected := time.Now() + resultCh := make(chan evalResponse) + data := &Evaluation{ + scheduledAt: expected, + rule: models.AlertRuleGen()(), + folderTitle: util.GenerateShortUID(), + } + go func() { + result, dropped := r.Eval(data) + resultCh <- evalResponse{result, dropped} + }() + select { + case ctx := <-r.evalCh: + require.Equal(t, data, ctx) + result := <-resultCh + require.True(t, result.success) + require.Nilf(t, result.droppedEval, "expected no dropped evaluations but got one") + case <-time.After(5 * time.Second): + t.Fatal("No message was received on eval channel") + } + }) + t.Run("eval should drop any concurrent sending to evalCh", func(t *testing.T) { + r := blankRuleForTests(context.Background()) + time1 := time.UnixMilli(rand.Int63n(math.MaxInt64)) + time2 := time.UnixMilli(rand.Int63n(math.MaxInt64)) + resultCh1 := make(chan evalResponse) + resultCh2 := make(chan evalResponse) + data := &Evaluation{ + scheduledAt: time1, + rule: models.AlertRuleGen()(), + folderTitle: util.GenerateShortUID(), + } + data2 := &Evaluation{ + scheduledAt: time2, + rule: data.rule, + folderTitle: data.folderTitle, + } + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + wg.Done() + result, dropped := r.Eval(data) + wg.Done() + resultCh1 <- evalResponse{result, dropped} + }() + wg.Wait() + wg.Add(2) // one when time1 is sent, another when go-routine for time2 has started + go func() { + wg.Done() + result, dropped := r.Eval(data2) + resultCh2 <- evalResponse{result, dropped} + }() + wg.Wait() // at this point tick 1 has already been dropped + select { + case ctx := <-r.evalCh: + require.Equal(t, time2, ctx.scheduledAt) + result := <-resultCh1 + require.True(t, result.success) + require.Nilf(t, result.droppedEval, "expected no dropped evaluations but got one") + result = <-resultCh2 + require.True(t, result.success) + require.NotNil(t, result.droppedEval, "expected no dropped evaluations but got one") + require.Equal(t, time1, result.droppedEval.scheduledAt) + case <-time.After(5 * time.Second): + t.Fatal("No message was received on eval channel") + } + }) + t.Run("eval should exit when context is cancelled", func(t *testing.T) { + r := blankRuleForTests(context.Background()) + resultCh := make(chan evalResponse) + data := &Evaluation{ + scheduledAt: time.Now(), + rule: models.AlertRuleGen()(), + folderTitle: util.GenerateShortUID(), + } + go func() { + result, dropped := r.Eval(data) + resultCh <- evalResponse{result, dropped} + }() + runtime.Gosched() + r.Stop(nil) + select { + case result := <-resultCh: + require.False(t, result.success) + require.Nilf(t, result.droppedEval, "expected no dropped evaluations but got one") + case <-time.After(5 * time.Second): + t.Fatal("No message was received on eval channel") + } + }) + }) + t.Run("when rule evaluation is stopped", func(t *testing.T) { + t.Run("Update should do nothing", func(t *testing.T) { + r := blankRuleForTests(context.Background()) + r.Stop(errRuleDeleted) + require.ErrorIs(t, r.ctx.Err(), errRuleDeleted) + require.False(t, r.Update(RuleVersionAndPauseStatus{fingerprint(rand.Uint64()), false})) + }) + t.Run("eval should do nothing", func(t *testing.T) { + r := blankRuleForTests(context.Background()) + r.Stop(nil) + data := &Evaluation{ + scheduledAt: time.Now(), + rule: models.AlertRuleGen()(), + folderTitle: util.GenerateShortUID(), + } + success, dropped := r.Eval(data) + require.False(t, success) + require.Nilf(t, dropped, "expected no dropped evaluations but got one") + }) + t.Run("stop should do nothing", func(t *testing.T) { + r := blankRuleForTests(context.Background()) + r.Stop(nil) + r.Stop(nil) + }) + t.Run("stop should do nothing if parent context stopped", func(t *testing.T) { + ctx, cancelFn := context.WithCancel(context.Background()) + r := blankRuleForTests(ctx) + cancelFn() + r.Stop(nil) + }) + }) + t.Run("should be thread-safe", func(t *testing.T) { + r := blankRuleForTests(context.Background()) + wg := sync.WaitGroup{} + go func() { + for { + select { + case <-r.evalCh: + time.Sleep(time.Microsecond) + case <-r.updateCh: + time.Sleep(time.Microsecond) + case <-r.ctx.Done(): + return + } + } + }() + + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + for i := 0; i < 20; i++ { + max := 3 + if i <= 10 { + max = 2 + } + switch rand.Intn(max) + 1 { + case 1: + r.Update(RuleVersionAndPauseStatus{fingerprint(rand.Uint64()), false}) + case 2: + r.Eval(&Evaluation{ + scheduledAt: time.Now(), + rule: models.AlertRuleGen()(), + folderTitle: util.GenerateShortUID(), + }) + case 3: + r.Stop(nil) + } + } + wg.Done() + }() + } + + wg.Wait() + }) +} + +func blankRuleForTests(ctx context.Context) *alertRule { + return newAlertRule(context.Background(), nil, false, 0, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) +} + +func TestRuleRoutine(t *testing.T) { + createSchedule := func( + evalAppliedChan chan time.Time, + senderMock *SyncAlertsSenderMock, + ) (*schedule, *fakeRulesStore, *state.FakeInstanceStore, prometheus.Gatherer) { + ruleStore := newFakeRulesStore() + instanceStore := &state.FakeInstanceStore{} + + registry := prometheus.NewPedanticRegistry() + sch := setupScheduler(t, ruleStore, instanceStore, registry, senderMock, nil) + sch.evalAppliedFunc = func(key models.AlertRuleKey, t time.Time) { + evalAppliedChan <- t + } + return sch, ruleStore, instanceStore, registry + } + + // normal states do not include NoData and Error because currently it is not possible to perform any sensible test + normalStates := []eval.State{eval.Normal, eval.Alerting, eval.Pending} + allStates := [...]eval.State{eval.Normal, eval.Alerting, eval.Pending, eval.NoData, eval.Error} + + for _, evalState := range normalStates { + // TODO rewrite when we are able to mock/fake state manager + t.Run(fmt.Sprintf("when rule evaluation happens (evaluation state %s)", evalState), func(t *testing.T) { + evalAppliedChan := make(chan time.Time) + sch, ruleStore, instanceStore, reg := createSchedule(evalAppliedChan, nil) + + rule := models.AlertRuleGen(withQueryForState(t, evalState))() + ruleStore.PutRule(context.Background(), rule) + folderTitle := ruleStore.getNamespaceTitle(rule.NamespaceUID) + factory := ruleFactoryFromScheduler(sch) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + ruleInfo := factory.new(ctx) + go func() { + _ = ruleInfo.Run(rule.GetKey()) + }() + + expectedTime := time.UnixMicro(rand.Int63()) + + ruleInfo.Eval(&Evaluation{ + scheduledAt: expectedTime, + rule: rule, + folderTitle: folderTitle, + }) + + actualTime := waitForTimeChannel(t, evalAppliedChan) + require.Equal(t, expectedTime, actualTime) + + t.Run("it should add extra labels", func(t *testing.T) { + states := sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) + for _, s := range states { + assert.Equal(t, rule.UID, s.Labels[alertingModels.RuleUIDLabel]) + assert.Equal(t, rule.NamespaceUID, s.Labels[alertingModels.NamespaceUIDLabel]) + assert.Equal(t, rule.Title, s.Labels[prometheusModel.AlertNameLabel]) + assert.Equal(t, folderTitle, s.Labels[models.FolderTitleLabel]) + } + }) + + t.Run("it should process evaluation results via state manager", func(t *testing.T) { + // TODO rewrite when we are able to mock/fake state manager + states := sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) + require.Len(t, states, 1) + s := states[0] + require.Equal(t, rule.UID, s.AlertRuleUID) + require.Len(t, s.Results, 1) + var expectedStatus = evalState + if evalState == eval.Pending { + expectedStatus = eval.Alerting + } + require.Equal(t, expectedStatus.String(), s.Results[0].EvaluationState.String()) + require.Equal(t, expectedTime, s.Results[0].EvaluationTime) + }) + t.Run("it should save alert instances to storage", func(t *testing.T) { + // TODO rewrite when we are able to mock/fake state manager + states := sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) + require.Len(t, states, 1) + s := states[0] + + var cmd *models.AlertInstance + for _, op := range instanceStore.RecordedOps() { + switch q := op.(type) { + case models.AlertInstance: + cmd = &q + } + if cmd != nil { + break + } + } + + require.NotNil(t, cmd) + t.Logf("Saved alert instances: %v", cmd) + require.Equal(t, rule.OrgID, cmd.RuleOrgID) + require.Equal(t, expectedTime, cmd.LastEvalTime) + require.Equal(t, rule.UID, cmd.RuleUID) + require.Equal(t, evalState.String(), string(cmd.CurrentState)) + require.Equal(t, s.Labels, data.Labels(cmd.Labels)) + }) + + t.Run("it reports metrics", func(t *testing.T) { + // duration metric has 0 values because of mocked clock that do not advance + expectedMetric := fmt.Sprintf( + `# HELP grafana_alerting_rule_evaluation_duration_seconds The time to evaluate a rule. + # TYPE grafana_alerting_rule_evaluation_duration_seconds histogram + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="0.01"} 1 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="0.1"} 1 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="0.5"} 1 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="1"} 1 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="5"} 1 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="10"} 1 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="15"} 1 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="30"} 1 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="60"} 1 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="120"} 1 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="180"} 1 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="240"} 1 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="300"} 1 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="+Inf"} 1 + grafana_alerting_rule_evaluation_duration_seconds_sum{org="%[1]d"} 0 + grafana_alerting_rule_evaluation_duration_seconds_count{org="%[1]d"} 1 + # HELP grafana_alerting_rule_evaluation_failures_total The total number of rule evaluation failures. + # TYPE grafana_alerting_rule_evaluation_failures_total counter + grafana_alerting_rule_evaluation_failures_total{org="%[1]d"} 0 + # HELP grafana_alerting_rule_evaluations_total The total number of rule evaluations. + # TYPE grafana_alerting_rule_evaluations_total counter + grafana_alerting_rule_evaluations_total{org="%[1]d"} 1 + # HELP grafana_alerting_rule_process_evaluation_duration_seconds The time to process the evaluation results for a rule. + # TYPE grafana_alerting_rule_process_evaluation_duration_seconds histogram + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="0.01"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="0.1"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="0.5"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="1"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="5"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="10"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="15"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="30"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="60"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="120"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="180"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="240"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="300"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="+Inf"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_sum{org="%[1]d"} 0 + grafana_alerting_rule_process_evaluation_duration_seconds_count{org="%[1]d"} 1 + # HELP grafana_alerting_rule_send_alerts_duration_seconds The time to send the alerts to Alertmanager. + # TYPE grafana_alerting_rule_send_alerts_duration_seconds histogram + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="0.01"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="0.1"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="0.5"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="1"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="5"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="10"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="15"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="30"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="60"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="120"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="180"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="240"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="300"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="+Inf"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_sum{org="%[1]d"} 0 + grafana_alerting_rule_send_alerts_duration_seconds_count{org="%[1]d"} 1 + `, rule.OrgID) + + err := testutil.GatherAndCompare(reg, bytes.NewBufferString(expectedMetric), "grafana_alerting_rule_evaluation_duration_seconds", "grafana_alerting_rule_evaluations_total", "grafana_alerting_rule_evaluation_failures_total", "grafana_alerting_rule_process_evaluation_duration_seconds", "grafana_alerting_rule_send_alerts_duration_seconds") + require.NoError(t, err) + }) + }) + } + + t.Run("should exit", func(t *testing.T) { + t.Run("and not clear the state if parent context is cancelled", func(t *testing.T) { + stoppedChan := make(chan error) + sch, _, _, _ := createSchedule(make(chan time.Time), nil) + + rule := models.AlertRuleGen()() + _ = sch.stateManager.ProcessEvalResults(context.Background(), sch.clock.Now(), rule, eval.GenerateResults(rand.Intn(5)+1, eval.ResultGen(eval.WithEvaluatedAt(sch.clock.Now()))), nil) + expectedStates := sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) + require.NotEmpty(t, expectedStates) + + factory := ruleFactoryFromScheduler(sch) + ctx, cancel := context.WithCancel(context.Background()) + ruleInfo := factory.new(ctx) + go func() { + err := ruleInfo.Run(models.AlertRuleKey{}) + stoppedChan <- err + }() + + cancel() + err := waitForErrChannel(t, stoppedChan) + require.NoError(t, err) + require.Equal(t, len(expectedStates), len(sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID))) + }) + t.Run("and clean up the state if delete is cancellation reason for inner context", func(t *testing.T) { + stoppedChan := make(chan error) + sch, _, _, _ := createSchedule(make(chan time.Time), nil) + + rule := models.AlertRuleGen()() + _ = sch.stateManager.ProcessEvalResults(context.Background(), sch.clock.Now(), rule, eval.GenerateResults(rand.Intn(5)+1, eval.ResultGen(eval.WithEvaluatedAt(sch.clock.Now()))), nil) + require.NotEmpty(t, sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID)) + + factory := ruleFactoryFromScheduler(sch) + ruleInfo := factory.new(context.Background()) + go func() { + err := ruleInfo.Run(rule.GetKey()) + stoppedChan <- err + }() + + ruleInfo.Stop(errRuleDeleted) + err := waitForErrChannel(t, stoppedChan) + require.NoError(t, err) + + require.Empty(t, sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID)) + }) + }) + + t.Run("when a message is sent to update channel", func(t *testing.T) { + rule := models.AlertRuleGen(withQueryForState(t, eval.Normal))() + folderTitle := "folderName" + ruleFp := ruleWithFolder{rule, folderTitle}.Fingerprint() + + evalAppliedChan := make(chan time.Time) + + sender := NewSyncAlertsSenderMock() + sender.EXPECT().Send(mock.Anything, rule.GetKey(), mock.Anything).Return() + + sch, ruleStore, _, _ := createSchedule(evalAppliedChan, sender) + ruleStore.PutRule(context.Background(), rule) + sch.schedulableAlertRules.set([]*models.AlertRule{rule}, map[models.FolderKey]string{rule.GetFolderKey(): folderTitle}) + factory := ruleFactoryFromScheduler(sch) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + ruleInfo := factory.new(ctx) + + go func() { + _ = ruleInfo.Run(rule.GetKey()) + }() + + // init evaluation loop so it got the rule version + ruleInfo.Eval(&Evaluation{ + scheduledAt: sch.clock.Now(), + rule: rule, + folderTitle: folderTitle, + }) + + waitForTimeChannel(t, evalAppliedChan) + + // define some state + states := make([]*state.State, 0, len(allStates)) + for _, s := range allStates { + for i := 0; i < 2; i++ { + states = append(states, &state.State{ + AlertRuleUID: rule.UID, + CacheID: util.GenerateShortUID(), + OrgID: rule.OrgID, + State: s, + StartsAt: sch.clock.Now(), + EndsAt: sch.clock.Now().Add(time.Duration(rand.Intn(25)+5) * time.Second), + Labels: rule.Labels, + }) + } + } + sch.stateManager.Put(states) + + states = sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) + expectedToBeSent := 0 + for _, s := range states { + if s.State == eval.Normal || s.State == eval.Pending { + continue + } + expectedToBeSent++ + } + require.Greaterf(t, expectedToBeSent, 0, "State manager was expected to return at least one state that can be expired") + + t.Run("should do nothing if version in channel is the same", func(t *testing.T) { + ruleInfo.Update(RuleVersionAndPauseStatus{ruleFp, false}) + ruleInfo.Update(RuleVersionAndPauseStatus{ruleFp, false}) // second time just to make sure that previous messages were handled + + actualStates := sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) + require.Len(t, actualStates, len(states)) + + sender.AssertNotCalled(t, "Send", mock.Anything, mock.Anything) + }) + + t.Run("should clear the state and expire firing alerts if version in channel is greater", func(t *testing.T) { + ruleInfo.Update(RuleVersionAndPauseStatus{ruleFp + 1, false}) + + require.Eventually(t, func() bool { + return len(sender.Calls()) > 0 + }, 5*time.Second, 100*time.Millisecond) + + require.Empty(t, sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID)) + sender.AssertNumberOfCalls(t, "Send", 1) + args, ok := sender.Calls()[0].Arguments[2].(definitions.PostableAlerts) + require.Truef(t, ok, fmt.Sprintf("expected argument of function was supposed to be 'definitions.PostableAlerts' but got %T", sender.Calls()[0].Arguments[2])) + require.Len(t, args.PostableAlerts, expectedToBeSent) + }) + }) + + t.Run("when evaluation fails", func(t *testing.T) { + rule := models.AlertRuleGen(withQueryForState(t, eval.Error))() + rule.ExecErrState = models.ErrorErrState + + evalAppliedChan := make(chan time.Time) + + sender := NewSyncAlertsSenderMock() + sender.EXPECT().Send(mock.Anything, rule.GetKey(), mock.Anything).Return() + + sch, ruleStore, _, reg := createSchedule(evalAppliedChan, sender) + sch.maxAttempts = 3 + ruleStore.PutRule(context.Background(), rule) + factory := ruleFactoryFromScheduler(sch) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + ruleInfo := factory.new(ctx) + + go func() { + _ = ruleInfo.Run(rule.GetKey()) + }() + + ruleInfo.Eval(&Evaluation{ + scheduledAt: sch.clock.Now(), + rule: rule, + }) + + waitForTimeChannel(t, evalAppliedChan) + + t.Run("it should increase failure counter", func(t *testing.T) { + // duration metric has 0 values because of mocked clock that do not advance + expectedMetric := fmt.Sprintf( + `# HELP grafana_alerting_rule_evaluation_duration_seconds The time to evaluate a rule. + # TYPE grafana_alerting_rule_evaluation_duration_seconds histogram + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="0.01"} 3 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="0.1"} 3 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="0.5"} 3 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="1"} 3 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="5"} 3 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="10"} 3 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="15"} 3 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="30"} 3 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="60"} 3 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="120"} 3 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="180"} 3 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="240"} 3 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="300"} 3 + grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="+Inf"} 3 + grafana_alerting_rule_evaluation_duration_seconds_sum{org="%[1]d"} 0 + grafana_alerting_rule_evaluation_duration_seconds_count{org="%[1]d"} 3 + # HELP grafana_alerting_rule_evaluation_failures_total The total number of rule evaluation failures. + # TYPE grafana_alerting_rule_evaluation_failures_total counter + grafana_alerting_rule_evaluation_failures_total{org="%[1]d"} 3 + # HELP grafana_alerting_rule_evaluations_total The total number of rule evaluations. + # TYPE grafana_alerting_rule_evaluations_total counter + grafana_alerting_rule_evaluations_total{org="%[1]d"} 3 + # HELP grafana_alerting_rule_process_evaluation_duration_seconds The time to process the evaluation results for a rule. + # TYPE grafana_alerting_rule_process_evaluation_duration_seconds histogram + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="0.01"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="0.1"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="0.5"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="1"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="5"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="10"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="15"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="30"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="60"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="120"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="180"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="240"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="300"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="+Inf"} 1 + grafana_alerting_rule_process_evaluation_duration_seconds_sum{org="%[1]d"} 0 + grafana_alerting_rule_process_evaluation_duration_seconds_count{org="%[1]d"} 1 + # HELP grafana_alerting_rule_send_alerts_duration_seconds The time to send the alerts to Alertmanager. + # TYPE grafana_alerting_rule_send_alerts_duration_seconds histogram + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="0.01"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="0.1"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="0.5"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="1"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="5"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="10"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="15"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="30"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="60"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="120"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="180"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="240"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="300"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="+Inf"} 1 + grafana_alerting_rule_send_alerts_duration_seconds_sum{org="%[1]d"} 0 + grafana_alerting_rule_send_alerts_duration_seconds_count{org="%[1]d"} 1 + `, rule.OrgID) + + err := testutil.GatherAndCompare(reg, bytes.NewBufferString(expectedMetric), "grafana_alerting_rule_evaluation_duration_seconds", "grafana_alerting_rule_evaluations_total", "grafana_alerting_rule_evaluation_failures_total", "grafana_alerting_rule_process_evaluation_duration_seconds", "grafana_alerting_rule_send_alerts_duration_seconds") + require.NoError(t, err) + }) + + t.Run("it should send special alert DatasourceError", func(t *testing.T) { + sender.AssertNumberOfCalls(t, "Send", 1) + args, ok := sender.Calls()[0].Arguments[2].(definitions.PostableAlerts) + require.Truef(t, ok, fmt.Sprintf("expected argument of function was supposed to be 'definitions.PostableAlerts' but got %T", sender.Calls()[0].Arguments[2])) + assert.Len(t, args.PostableAlerts, 1) + assert.Equal(t, state.ErrorAlertName, args.PostableAlerts[0].Labels[prometheusModel.AlertNameLabel]) + }) + }) + + t.Run("when there are alerts that should be firing", func(t *testing.T) { + t.Run("it should call sender", func(t *testing.T) { + // eval.Alerting makes state manager to create notifications for alertmanagers + rule := models.AlertRuleGen(withQueryForState(t, eval.Alerting))() + + evalAppliedChan := make(chan time.Time) + + sender := NewSyncAlertsSenderMock() + sender.EXPECT().Send(mock.Anything, rule.GetKey(), mock.Anything).Return() + + sch, ruleStore, _, _ := createSchedule(evalAppliedChan, sender) + ruleStore.PutRule(context.Background(), rule) + factory := ruleFactoryFromScheduler(sch) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + ruleInfo := factory.new(ctx) + + go func() { + _ = ruleInfo.Run(rule.GetKey()) + }() + + ruleInfo.Eval(&Evaluation{ + scheduledAt: sch.clock.Now(), + rule: rule, + }) + + waitForTimeChannel(t, evalAppliedChan) + + sender.AssertNumberOfCalls(t, "Send", 1) + args, ok := sender.Calls()[0].Arguments[2].(definitions.PostableAlerts) + require.Truef(t, ok, fmt.Sprintf("expected argument of function was supposed to be 'definitions.PostableAlerts' but got %T", sender.Calls()[0].Arguments[2])) + + require.Len(t, args.PostableAlerts, 1) + }) + }) + + t.Run("when there are no alerts to send it should not call notifiers", func(t *testing.T) { + rule := models.AlertRuleGen(withQueryForState(t, eval.Normal))() + + evalAppliedChan := make(chan time.Time) + + sender := NewSyncAlertsSenderMock() + sender.EXPECT().Send(mock.Anything, rule.GetKey(), mock.Anything).Return() + + sch, ruleStore, _, _ := createSchedule(evalAppliedChan, sender) + ruleStore.PutRule(context.Background(), rule) + factory := ruleFactoryFromScheduler(sch) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + ruleInfo := factory.new(ctx) + + go func() { + _ = ruleInfo.Run(rule.GetKey()) + }() + + ruleInfo.Eval(&Evaluation{ + scheduledAt: sch.clock.Now(), + rule: rule, + }) + + waitForTimeChannel(t, evalAppliedChan) + + sender.AssertNotCalled(t, "Send", mock.Anything, mock.Anything) + + require.NotEmpty(t, sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID)) + }) +} + +func ruleFactoryFromScheduler(sch *schedule) ruleFactory { + return newRuleFactory(sch.appURL, sch.disableGrafanaFolder, sch.maxAttempts, sch.alertsSender, sch.stateManager, sch.evaluatorFactory, &sch.schedulableAlertRules, sch.clock, sch.metrics, sch.log, sch.tracer, sch.evalAppliedFunc, sch.stopAppliedFunc) +} diff --git a/pkg/services/ngalert/schedule/fetcher.go b/pkg/services/ngalert/schedule/fetcher.go index 1db61e9ad9a27..fba2831d48f6a 100644 --- a/pkg/services/ngalert/schedule/fetcher.go +++ b/pkg/services/ngalert/schedule/fetcher.go @@ -3,36 +3,11 @@ package schedule import ( "context" "fmt" - "hash/fnv" - "sort" "time" "github.com/grafana/grafana/pkg/services/ngalert/models" ) -// hashUIDs returns a fnv64 hash of the UIDs for all alert rules. -// The order of the alert rules does not matter as hashUIDs sorts -// the UIDs in increasing order. -func hashUIDs(alertRules []*models.AlertRule) uint64 { - h := fnv.New64() - for _, uid := range sortedUIDs(alertRules) { - // We can ignore err as fnv64 does not return an error - // nolint:errcheck,gosec - h.Write([]byte(uid)) - } - return h.Sum64() -} - -// sortedUIDs returns a slice of sorted UIDs. -func sortedUIDs(alertRules []*models.AlertRule) []string { - uids := make([]string, 0, len(alertRules)) - for _, alertRule := range alertRules { - uids = append(uids, alertRule.UID) - } - sort.Strings(uids) - return uids -} - // updateSchedulableAlertRules updates the alert rules for the scheduler. // It returns diff that contains rule keys that were updated since the last poll, // and an error if the database query encountered problems. diff --git a/pkg/services/ngalert/schedule/jitter.go b/pkg/services/ngalert/schedule/jitter.go new file mode 100644 index 0000000000000..61adadb1286fe --- /dev/null +++ b/pkg/services/ngalert/schedule/jitter.go @@ -0,0 +1,70 @@ +package schedule + +import ( + "fmt" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/services/featuremgmt" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/setting" +) + +// JitterStrategy represents a modifier to alert rule timing that affects how evaluations are distributed. +type JitterStrategy int + +const ( + JitterNever JitterStrategy = iota + JitterByGroup + JitterByRule +) + +// JitterStrategyFrom returns the JitterStrategy indicated by the current Grafana feature toggles. +func JitterStrategyFrom(cfg setting.UnifiedAlertingSettings, toggles featuremgmt.FeatureToggles) JitterStrategy { + strategy := JitterByGroup + if cfg.DisableJitter { + return JitterNever + } + if toggles == nil { + return strategy + } + if toggles.IsEnabledGlobally(featuremgmt.FlagJitterAlertRulesWithinGroups) { + strategy = JitterByRule + } + return strategy +} + +// jitterOffsetInTicks gives the jitter offset for a rule, in terms of a number of ticks relative to its interval and a base interval. +// The resulting number of ticks is non-negative. We assume the rule is well-formed and has an IntervalSeconds greater to or equal than baseInterval. +func jitterOffsetInTicks(r *ngmodels.AlertRule, baseInterval time.Duration, strategy JitterStrategy) int64 { + if strategy == JitterNever { + return 0 + } + + itemFrequency := r.IntervalSeconds / int64(baseInterval.Seconds()) + offset := jitterHash(r, strategy) % uint64(itemFrequency) + // Offset is always nonnegative and less than int64.max, because above we mod by itemFrequency which fits in the positive half of int64. + // offset <= itemFrequency <= int64.max + // So, this will not overflow and produce a negative offset. + res := int64(offset) + + // Regardless, take an absolute value anyway for an extra layer of safety in case the above logic ever changes. + // Our contract requires that the result is nonnegative and less than int64.max. + if res < 0 { + return -res + } + return res +} + +func jitterHash(r *ngmodels.AlertRule, strategy JitterStrategy) uint64 { + ls := data.Labels{ + "name": r.RuleGroup, + "file": r.NamespaceUID, + "orgId": fmt.Sprint(r.OrgID), + } + + if strategy == JitterByRule { + ls["uid"] = r.UID + } + return uint64(ls.Fingerprint()) +} diff --git a/pkg/services/ngalert/schedule/jitter_test.go b/pkg/services/ngalert/schedule/jitter_test.go new file mode 100644 index 0000000000000..ae7ead86fe798 --- /dev/null +++ b/pkg/services/ngalert/schedule/jitter_test.go @@ -0,0 +1,100 @@ +package schedule + +import ( + "testing" + "time" + + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/stretchr/testify/require" +) + +func TestJitter(t *testing.T) { + t.Run("when strategy is JitterNever", func(t *testing.T) { + t.Run("offset is always zero", func(t *testing.T) { + rules := createTestRules(100, ngmodels.WithIntervalBetween(10, 600)) + baseInterval := 10 * time.Second + + for _, r := range rules { + offset := jitterOffsetInTicks(r, baseInterval, JitterNever) + require.Zero(t, offset, "unexpected offset, should be zero with jitter disabled; got %d", offset) + } + }) + }) + + t.Run("when strategy is JitterByGroup", func(t *testing.T) { + t.Run("offset is stable for the same rule", func(t *testing.T) { + rule := ngmodels.AlertRuleGen(ngmodels.WithIntervalBetween(10, 600))() + baseInterval := 10 * time.Second + original := jitterOffsetInTicks(rule, baseInterval, JitterByGroup) + + for i := 0; i < 100; i++ { + offset := jitterOffsetInTicks(rule, baseInterval, JitterByGroup) + require.Equal(t, original, offset, "jitterOffsetInTicks should return the same value for the same rule") + } + }) + + t.Run("offset is on the interval [0, interval/baseInterval)", func(t *testing.T) { + baseInterval := 10 * time.Second + rules := createTestRules(1000, ngmodels.WithIntervalBetween(10, 600)) + + for _, r := range rules { + offset := jitterOffsetInTicks(r, baseInterval, JitterByGroup) + require.GreaterOrEqual(t, offset, int64(0), "offset cannot be negative, got %d for rule with interval %d", offset, r.IntervalSeconds) + upperLimit := r.IntervalSeconds / int64(baseInterval.Seconds()) + require.Less(t, offset, upperLimit, "offset cannot be equal to or greater than interval/baseInterval of %d", upperLimit) + } + }) + + t.Run("offset for any rule in the same group is always the same", func(t *testing.T) { + baseInterval := 10 * time.Second + group1 := ngmodels.AlertRuleGroupKey{} + group2 := ngmodels.AlertRuleGroupKey{} + rules1 := createTestRules(1000, ngmodels.WithInterval(60*time.Second), ngmodels.WithGroupKey(group1)) + rules2 := createTestRules(1000, ngmodels.WithInterval(1*time.Hour), ngmodels.WithGroupKey(group2)) + + group1Offset := jitterOffsetInTicks(rules1[0], baseInterval, JitterByGroup) + for _, r := range rules1 { + offset := jitterOffsetInTicks(r, baseInterval, JitterByGroup) + require.Equal(t, group1Offset, offset) + } + group2Offset := jitterOffsetInTicks(rules2[0], baseInterval, JitterByGroup) + for _, r := range rules2 { + offset := jitterOffsetInTicks(r, baseInterval, JitterByGroup) + require.Equal(t, group2Offset, offset) + } + }) + }) + + t.Run("when strategy is JitterByRule", func(t *testing.T) { + t.Run("offset is stable for the same rule", func(t *testing.T) { + rule := ngmodels.AlertRuleGen(ngmodels.WithIntervalBetween(10, 600))() + baseInterval := 10 * time.Second + original := jitterOffsetInTicks(rule, baseInterval, JitterByRule) + + for i := 0; i < 100; i++ { + offset := jitterOffsetInTicks(rule, baseInterval, JitterByRule) + require.Equal(t, original, offset, "jitterOffsetInTicks should return the same value for the same rule") + } + }) + + t.Run("offset is on the interval [0, interval/baseInterval)", func(t *testing.T) { + baseInterval := 10 * time.Second + rules := createTestRules(1000, ngmodels.WithIntervalBetween(10, 600)) + + for _, r := range rules { + offset := jitterOffsetInTicks(r, baseInterval, JitterByRule) + require.GreaterOrEqual(t, offset, int64(0), "offset cannot be negative, got %d for rule with interval %d", offset, r.IntervalSeconds) + upperLimit := r.IntervalSeconds / int64(baseInterval.Seconds()) + require.Less(t, offset, upperLimit, "offset cannot be equal to or greater than interval/baseInterval of %d", upperLimit) + } + }) + }) +} + +func createTestRules(n int, mutators ...ngmodels.AlertRuleMutator) []*ngmodels.AlertRule { + result := make([]*ngmodels.AlertRule, 0, n) + for i := 0; i < n; i++ { + result = append(result, ngmodels.AlertRuleGen(mutators...)()) + } + return result +} diff --git a/pkg/services/ngalert/schedule/loaded_metrics_reader.go b/pkg/services/ngalert/schedule/loaded_metrics_reader.go new file mode 100644 index 0000000000000..b407f4d615edb --- /dev/null +++ b/pkg/services/ngalert/schedule/loaded_metrics_reader.go @@ -0,0 +1,44 @@ +package schedule + +import ( + "github.com/grafana/grafana-plugin-sdk-go/data" + + "github.com/grafana/grafana/pkg/services/ngalert/eval" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/state" +) + +var _ eval.AlertingResultsReader = AlertingResultsFromRuleState{} + +func (a *alertRule) newLoadedMetricsReader(rule *ngmodels.AlertRule) eval.AlertingResultsReader { + return &AlertingResultsFromRuleState{ + Manager: a.stateManager, + Rule: rule, + } +} + +type RuleStateProvider interface { + GetStatesForRuleUID(orgID int64, alertRuleUID string) []*state.State +} + +// AlertingResultsFromRuleState implements eval.AlertingResultsReader that gets the data from state manager. +// It returns results fingerprints only for Alerting and Pending states that have empty StateReason. +type AlertingResultsFromRuleState struct { + Manager RuleStateProvider + Rule *ngmodels.AlertRule +} + +func (n AlertingResultsFromRuleState) Read() map[data.Fingerprint]struct{} { + states := n.Manager.GetStatesForRuleUID(n.Rule.OrgID, n.Rule.UID) + + active := map[data.Fingerprint]struct{}{} + for _, st := range states { + if st.StateReason != "" { + continue + } + if st.State == eval.Alerting || st.State == eval.Pending { + active[st.ResultFingerprint] = struct{}{} + } + } + return active +} diff --git a/pkg/services/ngalert/schedule/loaded_metrics_reader_test.go b/pkg/services/ngalert/schedule/loaded_metrics_reader_test.go new file mode 100644 index 0000000000000..14648031c8a4f --- /dev/null +++ b/pkg/services/ngalert/schedule/loaded_metrics_reader_test.go @@ -0,0 +1,65 @@ +package schedule + +import ( + "testing" + + "github.com/google/uuid" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/ngalert/eval" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/state" +) + +func TestLoadedResultsFromRuleState(t *testing.T) { + rule := ngmodels.AlertRuleGen()() + p := &FakeRuleStateProvider{ + map[ngmodels.AlertRuleKey][]*state.State{ + rule.GetKey(): { + {State: eval.Alerting, ResultFingerprint: data.Fingerprint(1)}, + {State: eval.Pending, ResultFingerprint: data.Fingerprint(2)}, + {State: eval.Normal, ResultFingerprint: data.Fingerprint(3)}, + {State: eval.NoData, ResultFingerprint: data.Fingerprint(4)}, + {State: eval.Error, ResultFingerprint: data.Fingerprint(5)}, + }, + }, + } + + reader := AlertingResultsFromRuleState{ + Manager: p, + Rule: rule, + } + + t.Run("should return pending and alerting states", func(t *testing.T) { + loaded := reader.Read() + require.Len(t, loaded, 2) + require.Contains(t, loaded, data.Fingerprint(1)) + require.Contains(t, loaded, data.Fingerprint(2)) + }) + + t.Run("should not return any states with reason", func(t *testing.T) { + for _, s := range p.states[rule.GetKey()] { + s.StateReason = uuid.NewString() + } + loaded := reader.Read() + require.Empty(t, loaded) + }) + + t.Run("empty if no states", func(t *testing.T) { + p.states[rule.GetKey()] = nil + loaded := reader.Read() + require.Empty(t, loaded) + }) +} + +type FakeRuleStateProvider struct { + states map[ngmodels.AlertRuleKey][]*state.State +} + +func (f FakeRuleStateProvider) GetStatesForRuleUID(orgID int64, UID string) []*state.State { + return f.states[ngmodels.AlertRuleKey{ + OrgID: orgID, + UID: UID, + }] +} diff --git a/pkg/services/ngalert/schedule/metrics.go b/pkg/services/ngalert/schedule/metrics.go new file mode 100644 index 0000000000000..62e9e59079479 --- /dev/null +++ b/pkg/services/ngalert/schedule/metrics.go @@ -0,0 +1,75 @@ +package schedule + +import ( + "fmt" + "hash/fnv" + "sort" + + "github.com/grafana/grafana/pkg/services/ngalert/metrics" + models "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +// hashUIDs returns a fnv64 hash of the UIDs for all alert rules. +// The order of the alert rules does not matter as hashUIDs sorts +// the UIDs in increasing order. +func hashUIDs(alertRules []*models.AlertRule) uint64 { + h := fnv.New64() + for _, uid := range sortedUIDs(alertRules) { + // We can ignore err as fnv64 does not return an error + // nolint:errcheck,gosec + h.Write([]byte(uid)) + } + return h.Sum64() +} + +// sortedUIDs returns a slice of sorted UIDs. +func sortedUIDs(alertRules []*models.AlertRule) []string { + uids := make([]string, 0, len(alertRules)) + for _, alertRule := range alertRules { + uids = append(uids, alertRule.UID) + } + sort.Strings(uids) + return uids +} + +func (sch *schedule) updateRulesMetrics(alertRules []*models.AlertRule) { + rulesPerOrg := make(map[int64]int64) // orgID -> count + orgsPaused := make(map[int64]int64) // orgID -> count + orgsNfSettings := make(map[int64]int64) // orgID -> count + groupsPerOrg := make(map[int64]map[string]struct{}) // orgID -> set of groups + for _, rule := range alertRules { + rulesPerOrg[rule.OrgID]++ + + if rule.IsPaused { + orgsPaused[rule.OrgID]++ + } + + if len(rule.NotificationSettings) > 0 { + orgsNfSettings[rule.OrgID]++ + } + + orgGroups, ok := groupsPerOrg[rule.OrgID] + if !ok { + orgGroups = make(map[string]struct{}) + groupsPerOrg[rule.OrgID] = orgGroups + } + orgGroups[rule.RuleGroup] = struct{}{} + } + + for orgID, numRules := range rulesPerOrg { + numRulesPaused := orgsPaused[orgID] + numRulesNfSettings := orgsNfSettings[orgID] + sch.metrics.GroupRules.WithLabelValues(fmt.Sprint(orgID), metrics.AlertRuleActiveLabelValue).Set(float64(numRules - numRulesPaused)) + sch.metrics.GroupRules.WithLabelValues(fmt.Sprint(orgID), metrics.AlertRulePausedLabelValue).Set(float64(numRulesPaused)) + sch.metrics.SimpleNotificationRules.WithLabelValues(fmt.Sprint(orgID)).Set(float64(numRulesNfSettings)) + } + + for orgID, groups := range groupsPerOrg { + sch.metrics.Groups.WithLabelValues(fmt.Sprint(orgID)).Set(float64(len(groups))) + } + + // While these are the rules that we iterate over, at the moment there's no 100% guarantee that they'll be + // scheduled as rules could be removed before we get a chance to evaluate them. + sch.metrics.SchedulableAlertRules.Set(float64(len(alertRules))) + sch.metrics.SchedulableAlertRulesHash.Set(float64(hashUIDs(alertRules))) +} diff --git a/pkg/services/ngalert/schedule/fetcher_test.go b/pkg/services/ngalert/schedule/metrics_test.go similarity index 92% rename from pkg/services/ngalert/schedule/fetcher_test.go rename to pkg/services/ngalert/schedule/metrics_test.go index 6d93c4237af83..6f924535cd53d 100644 --- a/pkg/services/ngalert/schedule/fetcher_test.go +++ b/pkg/services/ngalert/schedule/metrics_test.go @@ -3,9 +3,8 @@ package schedule import ( "testing" + models "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/stretchr/testify/assert" - - "github.com/grafana/grafana/pkg/services/ngalert/models" ) func TestHashUIDs(t *testing.T) { diff --git a/pkg/services/ngalert/schedule/registry.go b/pkg/services/ngalert/schedule/registry.go index 202b645d312b9..3d8975de3b680 100644 --- a/pkg/services/ngalert/schedule/registry.go +++ b/pkg/services/ngalert/schedule/registry.go @@ -13,120 +13,74 @@ import ( "unsafe" "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/util" ) var errRuleDeleted = errors.New("rule deleted") -type alertRuleInfoRegistry struct { - mu sync.Mutex - alertRuleInfo map[models.AlertRuleKey]*alertRuleInfo +type ruleFactory interface { + new(context.Context) Rule } -// getOrCreateInfo gets rule routine information from registry by the key. If it does not exist, it creates a new one. -// Returns a pointer to the rule routine information and a flag that indicates whether it is a new struct or not. -func (r *alertRuleInfoRegistry) getOrCreateInfo(context context.Context, key models.AlertRuleKey) (*alertRuleInfo, bool) { +type ruleRegistry struct { + mu sync.Mutex + rules map[models.AlertRuleKey]Rule +} + +func newRuleRegistry() ruleRegistry { + return ruleRegistry{rules: make(map[models.AlertRuleKey]Rule)} +} + +// getOrCreate gets rule routine from registry by the key. If it does not exist, it creates a new one. +// Returns a pointer to the rule routine and a flag that indicates whether it is a new struct or not. +func (r *ruleRegistry) getOrCreate(context context.Context, key models.AlertRuleKey, factory ruleFactory) (Rule, bool) { r.mu.Lock() defer r.mu.Unlock() - info, ok := r.alertRuleInfo[key] + rule, ok := r.rules[key] if !ok { - info = newAlertRuleInfo(context) - r.alertRuleInfo[key] = info + rule = factory.new(context) + r.rules[key] = rule } - return info, !ok + return rule, !ok } -func (r *alertRuleInfoRegistry) exists(key models.AlertRuleKey) bool { +func (r *ruleRegistry) exists(key models.AlertRuleKey) bool { r.mu.Lock() defer r.mu.Unlock() - _, ok := r.alertRuleInfo[key] + _, ok := r.rules[key] return ok } -// del removes pair that has specific key from alertRuleInfo. +// del removes pair that has specific key from the registry. // Returns 2-tuple where the first element is value of the removed pair // and the second element indicates whether element with the specified key existed. -func (r *alertRuleInfoRegistry) del(key models.AlertRuleKey) (*alertRuleInfo, bool) { +func (r *ruleRegistry) del(key models.AlertRuleKey) (Rule, bool) { r.mu.Lock() defer r.mu.Unlock() - info, ok := r.alertRuleInfo[key] + rule, ok := r.rules[key] if ok { - delete(r.alertRuleInfo, key) + delete(r.rules, key) } - return info, ok + return rule, ok } -func (r *alertRuleInfoRegistry) keyMap() map[models.AlertRuleKey]struct{} { +func (r *ruleRegistry) keyMap() map[models.AlertRuleKey]struct{} { r.mu.Lock() defer r.mu.Unlock() - definitionsIDs := make(map[models.AlertRuleKey]struct{}, len(r.alertRuleInfo)) - for k := range r.alertRuleInfo { + definitionsIDs := make(map[models.AlertRuleKey]struct{}, len(r.rules)) + for k := range r.rules { definitionsIDs[k] = struct{}{} } return definitionsIDs } -type ruleVersionAndPauseStatus struct { +type RuleVersionAndPauseStatus struct { Fingerprint fingerprint IsPaused bool } -type alertRuleInfo struct { - evalCh chan *evaluation - updateCh chan ruleVersionAndPauseStatus - ctx context.Context - stop func(reason error) -} - -func newAlertRuleInfo(parent context.Context) *alertRuleInfo { - ctx, stop := util.WithCancelCause(parent) - return &alertRuleInfo{evalCh: make(chan *evaluation), updateCh: make(chan ruleVersionAndPauseStatus), ctx: ctx, stop: stop} -} - -// eval signals the rule evaluation routine to perform the evaluation of the rule. Does nothing if the loop is stopped. -// Before sending a message into the channel, it does non-blocking read to make sure that there is no concurrent send operation. -// Returns a tuple where first element is -// - true when message was sent -// - false when the send operation is stopped -// -// the second element contains a dropped message that was sent by a concurrent sender. -func (a *alertRuleInfo) eval(eval *evaluation) (bool, *evaluation) { - // read the channel in unblocking manner to make sure that there is no concurrent send operation. - var droppedMsg *evaluation - select { - case droppedMsg = <-a.evalCh: - default: - } - - select { - case a.evalCh <- eval: - return true, droppedMsg - case <-a.ctx.Done(): - return false, droppedMsg - } -} - -// update sends an instruction to the rule evaluation routine to update the scheduled rule to the specified version. The specified version must be later than the current version, otherwise no update will happen. -func (a *alertRuleInfo) update(lastVersion ruleVersionAndPauseStatus) bool { - // check if the channel is not empty. - select { - case <-a.updateCh: - case <-a.ctx.Done(): - return false - default: - } - - select { - case a.updateCh <- lastVersion: - return true - case <-a.ctx.Done(): - return false - } -} - -type evaluation struct { +type Evaluation struct { scheduledAt time.Time rule *models.AlertRule folderTitle string @@ -134,12 +88,12 @@ type evaluation struct { type alertRulesRegistry struct { rules map[models.AlertRuleKey]*models.AlertRule - folderTitles map[string]string + folderTitles map[models.FolderKey]string mu sync.Mutex } // all returns all rules in the registry. -func (r *alertRulesRegistry) all() ([]*models.AlertRule, map[string]string) { +func (r *alertRulesRegistry) all() ([]*models.AlertRule, map[models.FolderKey]string) { r.mu.Lock() defer r.mu.Unlock() result := make([]*models.AlertRule, 0, len(r.rules)) @@ -156,7 +110,7 @@ func (r *alertRulesRegistry) get(k models.AlertRuleKey) *models.AlertRule { } // set replaces all rules in the registry. Returns difference between previous and the new current version of the registry -func (r *alertRulesRegistry) set(rules []*models.AlertRule, folders map[string]string) diff { +func (r *alertRulesRegistry) set(rules []*models.AlertRule, folders map[models.FolderKey]string) diff { r.mu.Lock() defer r.mu.Unlock() rulesMap := make(map[models.AlertRuleKey]*models.AlertRule) @@ -336,6 +290,11 @@ func (r ruleWithFolder) Fingerprint() fingerprint { writeInt(0) } + for _, setting := range rule.NotificationSettings { + binary.LittleEndian.PutUint64(tmp, uint64(setting.Fingerprint())) + writeBytes(tmp) + } + // fields that do not affect the state. // TODO consider removing fields below from the fingerprint writeInt(rule.ID) diff --git a/pkg/services/ngalert/schedule/registry_test.go b/pkg/services/ngalert/schedule/registry_test.go index 5b70b6b797820..70d4ec7b1729c 100644 --- a/pkg/services/ngalert/schedule/registry_test.go +++ b/pkg/services/ngalert/schedule/registry_test.go @@ -1,13 +1,9 @@ package schedule import ( - "context" "encoding/json" - "math" "math/rand" "reflect" - "runtime" - "sync" "testing" "time" @@ -16,230 +12,15 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/util" ) -func TestSchedule_alertRuleInfo(t *testing.T) { - type evalResponse struct { - success bool - droppedEval *evaluation - } - - t.Run("when rule evaluation is not stopped", func(t *testing.T) { - t.Run("update should send to updateCh", func(t *testing.T) { - r := newAlertRuleInfo(context.Background()) - resultCh := make(chan bool) - go func() { - resultCh <- r.update(ruleVersionAndPauseStatus{fingerprint(rand.Uint64()), false}) - }() - select { - case <-r.updateCh: - require.True(t, <-resultCh) - case <-time.After(5 * time.Second): - t.Fatal("No message was received on update channel") - } - }) - t.Run("update should drop any concurrent sending to updateCh", func(t *testing.T) { - r := newAlertRuleInfo(context.Background()) - version1 := ruleVersionAndPauseStatus{fingerprint(rand.Uint64()), false} - version2 := ruleVersionAndPauseStatus{fingerprint(rand.Uint64()), false} - - wg := sync.WaitGroup{} - wg.Add(1) - go func() { - wg.Done() - r.update(version1) - wg.Done() - }() - wg.Wait() - wg.Add(2) // one when time1 is sent, another when go-routine for time2 has started - go func() { - wg.Done() - r.update(version2) - }() - wg.Wait() // at this point tick 1 has already been dropped - select { - case version := <-r.updateCh: - require.Equal(t, version2, version) - case <-time.After(5 * time.Second): - t.Fatal("No message was received on eval channel") - } - }) - t.Run("eval should send to evalCh", func(t *testing.T) { - r := newAlertRuleInfo(context.Background()) - expected := time.Now() - resultCh := make(chan evalResponse) - data := &evaluation{ - scheduledAt: expected, - rule: models.AlertRuleGen()(), - folderTitle: util.GenerateShortUID(), - } - go func() { - result, dropped := r.eval(data) - resultCh <- evalResponse{result, dropped} - }() - select { - case ctx := <-r.evalCh: - require.Equal(t, data, ctx) - result := <-resultCh - require.True(t, result.success) - require.Nilf(t, result.droppedEval, "expected no dropped evaluations but got one") - case <-time.After(5 * time.Second): - t.Fatal("No message was received on eval channel") - } - }) - t.Run("eval should drop any concurrent sending to evalCh", func(t *testing.T) { - r := newAlertRuleInfo(context.Background()) - time1 := time.UnixMilli(rand.Int63n(math.MaxInt64)) - time2 := time.UnixMilli(rand.Int63n(math.MaxInt64)) - resultCh1 := make(chan evalResponse) - resultCh2 := make(chan evalResponse) - data := &evaluation{ - scheduledAt: time1, - rule: models.AlertRuleGen()(), - folderTitle: util.GenerateShortUID(), - } - data2 := &evaluation{ - scheduledAt: time2, - rule: data.rule, - folderTitle: data.folderTitle, - } - wg := sync.WaitGroup{} - wg.Add(1) - go func() { - wg.Done() - result, dropped := r.eval(data) - wg.Done() - resultCh1 <- evalResponse{result, dropped} - }() - wg.Wait() - wg.Add(2) // one when time1 is sent, another when go-routine for time2 has started - go func() { - wg.Done() - result, dropped := r.eval(data2) - resultCh2 <- evalResponse{result, dropped} - }() - wg.Wait() // at this point tick 1 has already been dropped - select { - case ctx := <-r.evalCh: - require.Equal(t, time2, ctx.scheduledAt) - result := <-resultCh1 - require.True(t, result.success) - require.Nilf(t, result.droppedEval, "expected no dropped evaluations but got one") - result = <-resultCh2 - require.True(t, result.success) - require.NotNil(t, result.droppedEval, "expected no dropped evaluations but got one") - require.Equal(t, time1, result.droppedEval.scheduledAt) - case <-time.After(5 * time.Second): - t.Fatal("No message was received on eval channel") - } - }) - t.Run("eval should exit when context is cancelled", func(t *testing.T) { - r := newAlertRuleInfo(context.Background()) - resultCh := make(chan evalResponse) - data := &evaluation{ - scheduledAt: time.Now(), - rule: models.AlertRuleGen()(), - folderTitle: util.GenerateShortUID(), - } - go func() { - result, dropped := r.eval(data) - resultCh <- evalResponse{result, dropped} - }() - runtime.Gosched() - r.stop(nil) - select { - case result := <-resultCh: - require.False(t, result.success) - require.Nilf(t, result.droppedEval, "expected no dropped evaluations but got one") - case <-time.After(5 * time.Second): - t.Fatal("No message was received on eval channel") - } - }) - }) - t.Run("when rule evaluation is stopped", func(t *testing.T) { - t.Run("Update should do nothing", func(t *testing.T) { - r := newAlertRuleInfo(context.Background()) - r.stop(errRuleDeleted) - require.ErrorIs(t, r.ctx.Err(), errRuleDeleted) - require.False(t, r.update(ruleVersionAndPauseStatus{fingerprint(rand.Uint64()), false})) - }) - t.Run("eval should do nothing", func(t *testing.T) { - r := newAlertRuleInfo(context.Background()) - r.stop(nil) - data := &evaluation{ - scheduledAt: time.Now(), - rule: models.AlertRuleGen()(), - folderTitle: util.GenerateShortUID(), - } - success, dropped := r.eval(data) - require.False(t, success) - require.Nilf(t, dropped, "expected no dropped evaluations but got one") - }) - t.Run("stop should do nothing", func(t *testing.T) { - r := newAlertRuleInfo(context.Background()) - r.stop(nil) - r.stop(nil) - }) - t.Run("stop should do nothing if parent context stopped", func(t *testing.T) { - ctx, cancelFn := context.WithCancel(context.Background()) - r := newAlertRuleInfo(ctx) - cancelFn() - r.stop(nil) - }) - }) - t.Run("should be thread-safe", func(t *testing.T) { - r := newAlertRuleInfo(context.Background()) - wg := sync.WaitGroup{} - go func() { - for { - select { - case <-r.evalCh: - time.Sleep(time.Microsecond) - case <-r.updateCh: - time.Sleep(time.Microsecond) - case <-r.ctx.Done(): - return - } - } - }() - - for i := 0; i < 10; i++ { - wg.Add(1) - go func() { - for i := 0; i < 20; i++ { - max := 3 - if i <= 10 { - max = 2 - } - switch rand.Intn(max) + 1 { - case 1: - r.update(ruleVersionAndPauseStatus{fingerprint(rand.Uint64()), false}) - case 2: - r.eval(&evaluation{ - scheduledAt: time.Now(), - rule: models.AlertRuleGen()(), - folderTitle: util.GenerateShortUID(), - }) - case 3: - r.stop(nil) - } - } - wg.Done() - }() - } - - wg.Wait() - }) -} - func TestSchedulableAlertRulesRegistry(t *testing.T) { r := alertRulesRegistry{rules: make(map[models.AlertRuleKey]*models.AlertRule)} rules, folders := r.all() assert.Len(t, rules, 0) assert.Len(t, folders, 0) - expectedFolders := map[string]string{"test-uid": "test-title"} + expectedFolders := map[models.FolderKey]string{{OrgID: 1, UID: "test-uid"}: "test-title"} // replace all rules in the registry with foo r.set([]*models.AlertRule{{OrgID: 1, UID: "foo", Version: 1}}, expectedFolders) rules, folders = r.all() @@ -308,7 +89,7 @@ func TestSchedulableAlertRulesRegistry_set(t *testing.T) { for _, rule := range initialRules { newRules = append(newRules, models.CopyRule(rule)) } - diff := r.set(newRules, map[string]string{}) + diff := r.set(newRules, map[models.FolderKey]string{}) require.Truef(t, diff.IsEmpty(), "Diff is not empty. Probably we check something else than key + version") }) t.Run("should return empty diff if version does not change", func(t *testing.T) { @@ -324,7 +105,7 @@ func TestSchedulableAlertRulesRegistry_set(t *testing.T) { newRules = append(newRules, rule) } - diff := r.set(newRules, map[string]string{}) + diff := r.set(newRules, map[models.FolderKey]string{}) require.Truef(t, diff.IsEmpty(), "Diff is not empty. Probably we check something else than key + version") }) t.Run("should return key in diff if version changes", func(t *testing.T) { @@ -340,7 +121,7 @@ func TestSchedulableAlertRulesRegistry_set(t *testing.T) { } require.NotEmptyf(t, expectedUpdated, "Input parameters have changed. Nothing to assert") - diff := r.set(newRules, map[string]string{}) + diff := r.set(newRules, map[models.FolderKey]string{}) require.Falsef(t, diff.IsEmpty(), "Diff is empty but should not be") require.Equal(t, expectedUpdated, diff.updated) }) @@ -415,6 +196,9 @@ func TestRuleWithFolderFingerprint(t *testing.T) { "key-label": "value-label", }, IsPaused: false, + NotificationSettings: []models.NotificationSettings{ + models.NotificationSettingsGen()(), + }, } r2 := &models.AlertRule{ ID: 2, @@ -450,6 +234,9 @@ func TestRuleWithFolderFingerprint(t *testing.T) { "key-label": "value-label23", }, IsPaused: true, + NotificationSettings: []models.NotificationSettings{ + models.NotificationSettingsGen()(), + }, } excludedFields := map[string]struct{}{ diff --git a/pkg/services/ngalert/schedule/schedule.go b/pkg/services/ngalert/schedule/schedule.go index 7eb8dc60fbfbe..a2ccbe53c7fa2 100644 --- a/pkg/services/ngalert/schedule/schedule.go +++ b/pkg/services/ngalert/schedule/schedule.go @@ -2,27 +2,20 @@ package schedule import ( "context" - "errors" "fmt" "net/url" "time" "github.com/benbjohnson/clock" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/trace" "golang.org/x/sync/errgroup" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/metrics" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/state" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util/ticker" ) @@ -54,8 +47,8 @@ type schedule struct { // base tick rate (fastest possible configured check) baseInterval time.Duration - // each alert rule gets its own channel and routine - registry alertRuleInfoRegistry + // each rule gets its own channel and routine + registry ruleRegistry maxAttempts int64 @@ -81,6 +74,7 @@ type schedule struct { appURL *url.URL disableGrafanaFolder bool + jitterEvaluations JitterStrategy metrics *metrics.Scheduler @@ -104,6 +98,7 @@ type SchedulerCfg struct { MinRuleInterval time.Duration DisableGrafanaFolder bool AppURL *url.URL + JitterEvaluations JitterStrategy EvaluatorFactory eval.EvaluatorFactory RuleStore RulesStore Metrics *metrics.Scheduler @@ -112,10 +107,16 @@ type SchedulerCfg struct { Log log.Logger } -// NewScheduler returns a new schedule. +// NewScheduler returns a new scheduler. func NewScheduler(cfg SchedulerCfg, stateManager *state.Manager) *schedule { + const minMaxAttempts = int64(1) + if cfg.MaxAttempts < minMaxAttempts { + cfg.Log.Warn("Invalid scheduler maxAttempts, using a safe minimum", "configured", cfg.MaxAttempts, "actual", minMaxAttempts) + cfg.MaxAttempts = minMaxAttempts + } + sch := schedule{ - registry: alertRuleInfoRegistry{alertRuleInfo: make(map[ngmodels.AlertRuleKey]*alertRuleInfo)}, + registry: newRuleRegistry(), maxAttempts: cfg.MaxAttempts, clock: cfg.C, baseInterval: cfg.BaseInterval, @@ -125,6 +126,7 @@ func NewScheduler(cfg SchedulerCfg, stateManager *state.Manager) *schedule { metrics: cfg.Metrics, appURL: cfg.AppURL, disableGrafanaFolder: cfg.DisableGrafanaFolder, + jitterEvaluations: cfg.JitterEvaluations, stateManager: stateManager, minRuleInterval: cfg.MinRuleInterval, schedulableAlertRules: alertRulesRegistry{rules: make(map[ngmodels.AlertRuleKey]*ngmodels.AlertRule)}, @@ -136,7 +138,7 @@ func NewScheduler(cfg SchedulerCfg, stateManager *state.Manager) *schedule { } func (sch *schedule) Run(ctx context.Context) error { - sch.log.Info("Starting scheduler", "tickInterval", sch.baseInterval) + sch.log.Info("Starting scheduler", "tickInterval", sch.baseInterval, "maxAttempts", sch.maxAttempts) t := ticker.New(sch.clock, sch.baseInterval, sch.metrics.Ticker) defer t.Stop() @@ -146,6 +148,13 @@ func (sch *schedule) Run(ctx context.Context) error { return nil } +// Rules fetches the entire set of rules considered for evaluation by the scheduler on the next tick. +// Such rules are not guaranteed to have been evaluated by the scheduler. +// Rules returns all supplementary metadata for the rules that is stored by the scheduler - namely, the set of folder titles. +func (sch *schedule) Rules() ([]*ngmodels.AlertRule, map[ngmodels.FolderKey]string) { + return sch.schedulableAlertRules.all() +} + // deleteAlertRule stops evaluation of the rule, deletes it from active rules, and cleans up state cache. func (sch *schedule) deleteAlertRule(keys ...ngmodels.AlertRuleKey) { for _, key := range keys { @@ -156,13 +165,13 @@ func (sch *schedule) deleteAlertRule(keys ...ngmodels.AlertRuleKey) { sch.log.Info("Alert rule cannot be removed from the scheduler as it is not scheduled", key.LogContext()...) } // Delete the rule routine - ruleInfo, ok := sch.registry.del(key) + ruleRoutine, ok := sch.registry.del(key) if !ok { sch.log.Info("Alert rule cannot be stopped as it is not running", key.LogContext()...) continue } // stop rule evaluation - ruleInfo.stop(errRuleDeleted) + ruleRoutine.Stop(errRuleDeleted) } // Our best bet at this point is that we update the metrics with what we hope to schedule in the next tick. alertRules, _ := sch.schedulableAlertRules.all() @@ -193,30 +202,8 @@ func (sch *schedule) schedulePeriodic(ctx context.Context, t *ticker.T) error { } type readyToRunItem struct { - ruleInfo *alertRuleInfo - evaluation -} - -func (sch *schedule) updateRulesMetrics(alertRules []*ngmodels.AlertRule) { - orgs := make(map[int64]int64, len(alertRules)) - orgsPaused := make(map[int64]int64, len(alertRules)) - for _, rule := range alertRules { - orgs[rule.OrgID]++ - if rule.IsPaused { - orgsPaused[rule.OrgID]++ - } - } - - for orgID, numRules := range orgs { - numRulesPaused := orgsPaused[orgID] - sch.metrics.GroupRules.WithLabelValues(fmt.Sprint(orgID), metrics.AlertRuleActiveLabelValue).Set(float64(numRules - numRulesPaused)) - sch.metrics.GroupRules.WithLabelValues(fmt.Sprint(orgID), metrics.AlertRulePausedLabelValue).Set(float64(numRulesPaused)) - } - - // While these are the rules that we iterate over, at the moment there's no 100% guarantee that they'll be - // scheduled as rules could be removed before we get a chance to evaluate them. - sch.metrics.SchedulableAlertRules.Set(float64(len(alertRules))) - sch.metrics.SchedulableAlertRulesHash.Set(float64(hashUIDs(alertRules))) + ruleRoutine Rule + Evaluation } // TODO refactor to accept a callback for tests that will be called with things that are returned currently, and return nothing. @@ -248,9 +235,24 @@ func (sch *schedule) processTick(ctx context.Context, dispatcherGroup *errgroup. readyToRun := make([]readyToRunItem, 0) updatedRules := make([]ngmodels.AlertRuleKeyWithVersion, 0, len(updated)) // this is needed for tests only missingFolder := make(map[string][]string) + ruleFactory := newRuleFactory( + sch.appURL, + sch.disableGrafanaFolder, + sch.maxAttempts, + sch.alertsSender, + sch.stateManager, + sch.evaluatorFactory, + &sch.schedulableAlertRules, + sch.clock, + sch.metrics, + sch.log, + sch.tracer, + sch.evalAppliedFunc, + sch.stopAppliedFunc, + ) for _, item := range alertRules { key := item.GetKey() - ruleInfo, newRoutine := sch.registry.getOrCreateInfo(ctx, key) + ruleRoutine, newRoutine := sch.registry.getOrCreate(ctx, key, ruleFactory) // enforce minimum evaluation interval if item.IntervalSeconds < int64(sch.minRuleInterval.Seconds()) { @@ -262,7 +264,7 @@ func (sch *schedule) processTick(ctx context.Context, dispatcherGroup *errgroup. if newRoutine && !invalidInterval { dispatcherGroup.Go(func() error { - return sch.ruleRoutine(ruleInfo.ctx, key, ruleInfo.evalCh, ruleInfo.updateCh) + return ruleRoutine.Run(key) }) } @@ -274,11 +276,12 @@ func (sch *schedule) processTick(ctx context.Context, dispatcherGroup *errgroup. } itemFrequency := item.IntervalSeconds / int64(sch.baseInterval.Seconds()) - isReadyToRun := item.IntervalSeconds != 0 && tickNum%itemFrequency == 0 + offset := jitterOffsetInTicks(item, sch.baseInterval, sch.jitterEvaluations) + isReadyToRun := item.IntervalSeconds != 0 && (tickNum%itemFrequency)-offset == 0 var folderTitle string if !sch.disableGrafanaFolder { - title, ok := folderTitles[item.NamespaceUID] + title, ok := folderTitles[item.GetFolderKey()] if ok { folderTitle = title } else { @@ -287,7 +290,8 @@ func (sch *schedule) processTick(ctx context.Context, dispatcherGroup *errgroup. } if isReadyToRun { - readyToRun = append(readyToRun, readyToRunItem{ruleInfo: ruleInfo, evaluation: evaluation{ + sch.log.Debug("Rule is ready to run on the current tick", "uid", item.UID, "tick", tickNum, "frequency", itemFrequency, "offset", offset) + readyToRun = append(readyToRun, readyToRunItem{ruleRoutine: ruleRoutine, Evaluation: Evaluation{ scheduledAt: tick, rule: item, folderTitle: folderTitle, @@ -296,12 +300,12 @@ func (sch *schedule) processTick(ctx context.Context, dispatcherGroup *errgroup. if _, isUpdated := updated[key]; isUpdated && !isReadyToRun { // if we do not need to eval the rule, check the whether rule was just updated and if it was, notify evaluation routine about that sch.log.Debug("Rule has been updated. Notifying evaluation routine", key.LogContext()...) - go func(ri *alertRuleInfo, rule *ngmodels.AlertRule) { - ri.update(ruleVersionAndPauseStatus{ + go func(routine Rule, rule *ngmodels.AlertRule) { + routine.Update(RuleVersionAndPauseStatus{ Fingerprint: ruleWithFolder{rule: rule, folderTitle: folderTitle}.Fingerprint(), IsPaused: rule.IsPaused, }) - }(ruleInfo, item) + }(ruleRoutine, item) updatedRules = append(updatedRules, ngmodels.AlertRuleKeyWithVersion{ Version: item.Version, AlertRuleKey: item.GetKey(), @@ -326,7 +330,7 @@ func (sch *schedule) processTick(ctx context.Context, dispatcherGroup *errgroup. time.AfterFunc(time.Duration(int64(i)*step), func() { key := item.rule.GetKey() - success, dropped := item.ruleInfo.eval(&item.evaluation) + success, dropped := item.ruleRoutine.Eval(&item.Evaluation) if !success { sch.log.Debug("Scheduled evaluation was canceled because evaluation routine was stopped", append(key.LogContext(), "time", tick)...) return @@ -347,269 +351,3 @@ func (sch *schedule) processTick(ctx context.Context, dispatcherGroup *errgroup. sch.deleteAlertRule(toDelete...) return readyToRun, registeredDefinitions, updatedRules } - -//nolint:gocyclo -func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertRuleKey, evalCh <-chan *evaluation, updateCh <-chan ruleVersionAndPauseStatus) error { - grafanaCtx = ngmodels.WithRuleKey(grafanaCtx, key) - logger := sch.log.FromContext(grafanaCtx) - logger.Debug("Alert rule routine started") - - orgID := fmt.Sprint(key.OrgID) - evalTotal := sch.metrics.EvalTotal.WithLabelValues(orgID) - evalDuration := sch.metrics.EvalDuration.WithLabelValues(orgID) - evalTotalFailures := sch.metrics.EvalFailures.WithLabelValues(orgID) - processDuration := sch.metrics.ProcessDuration.WithLabelValues(orgID) - sendDuration := sch.metrics.SendDuration.WithLabelValues(orgID) - - notify := func(states []state.StateTransition) { - expiredAlerts := state.FromAlertsStateToStoppedAlert(states, sch.appURL, sch.clock) - if len(expiredAlerts.PostableAlerts) > 0 { - sch.alertsSender.Send(grafanaCtx, key, expiredAlerts) - } - } - - resetState := func(ctx context.Context, isPaused bool) { - rule := sch.schedulableAlertRules.get(key) - reason := ngmodels.StateReasonUpdated - if isPaused { - reason = ngmodels.StateReasonPaused - } - states := sch.stateManager.ResetStateByRuleUID(ctx, rule, reason) - notify(states) - } - - evaluate := func(ctx context.Context, f fingerprint, attempt int64, e *evaluation, span trace.Span, retry bool) error { - logger := logger.New("version", e.rule.Version, "fingerprint", f, "attempt", attempt, "now", e.scheduledAt).FromContext(ctx) - start := sch.clock.Now() - - evalCtx := eval.NewContext(ctx, SchedulerUserFor(e.rule.OrgID)) - ruleEval, err := sch.evaluatorFactory.Create(evalCtx, e.rule.GetEvalCondition()) - var results eval.Results - var dur time.Duration - if err != nil { - dur = sch.clock.Now().Sub(start) - logger.Error("Failed to build rule evaluator", "error", err) - } else { - results, err = ruleEval.Evaluate(ctx, e.scheduledAt) - dur = sch.clock.Now().Sub(start) - if err != nil { - logger.Error("Failed to evaluate rule", "error", err, "duration", dur) - } - } - - evalTotal.Inc() - evalDuration.Observe(dur.Seconds()) - - if ctx.Err() != nil { // check if the context is not cancelled. The evaluation can be a long-running task. - span.SetStatus(codes.Error, "rule evaluation cancelled") - logger.Debug("Skip updating the state because the context has been cancelled") - return nil - } - - if err != nil || results.HasErrors() { - evalTotalFailures.Inc() - - // Only retry (return errors) if this isn't the last attempt, otherwise skip these return operations. - if retry { - // The only thing that can return non-nil `err` from ruleEval.Evaluate is the server side expression pipeline. - // This includes transport errors such as transient network errors. - if err != nil { - span.SetStatus(codes.Error, "rule evaluation failed") - span.RecordError(err) - return fmt.Errorf("server side expressions pipeline returned an error: %w", err) - } - - // If the pipeline executed successfully but have other types of errors that can be retryable, we should do so. - if !results.HasNonRetryableErrors() { - span.SetStatus(codes.Error, "rule evaluation failed") - span.RecordError(err) - return fmt.Errorf("the result-set has errors that can be retried: %w", results.Error()) - } - } - - // If results is nil, we assume that the error must be from the SSE pipeline (ruleEval.Evaluate) which is the only code that can actually return an `err`. - if results == nil { - results = append(results, eval.NewResultFromError(err, e.scheduledAt, dur)) - } - - // If err is nil, we assume that the SSS pipeline succeeded and that the error must be embedded in the results. - if err == nil { - err = results.Error() - } - - span.SetStatus(codes.Error, "rule evaluation failed") - span.RecordError(err) - } else { - logger.Debug("Alert rule evaluated", "results", results, "duration", dur) - span.AddEvent("rule evaluated", trace.WithAttributes( - attribute.Int64("results", int64(len(results))), - )) - } - start = sch.clock.Now() - processedStates := sch.stateManager.ProcessEvalResults( - ctx, - e.scheduledAt, - e.rule, - results, - state.GetRuleExtraLabels(e.rule, e.folderTitle, !sch.disableGrafanaFolder), - ) - processDuration.Observe(sch.clock.Now().Sub(start).Seconds()) - - start = sch.clock.Now() - alerts := state.FromStateTransitionToPostableAlerts(processedStates, sch.stateManager, sch.appURL) - span.AddEvent("results processed", trace.WithAttributes( - attribute.Int64("state_transitions", int64(len(processedStates))), - attribute.Int64("alerts_to_send", int64(len(alerts.PostableAlerts))), - )) - if len(alerts.PostableAlerts) > 0 { - sch.alertsSender.Send(ctx, key, alerts) - } - sendDuration.Observe(sch.clock.Now().Sub(start).Seconds()) - - return nil - } - - evalRunning := false - var currentFingerprint fingerprint - defer sch.stopApplied(key) - for { - select { - // used by external services (API) to notify that rule is updated. - case ctx := <-updateCh: - if currentFingerprint == ctx.Fingerprint { - logger.Info("Rule's fingerprint has not changed. Skip resetting the state", "currentFingerprint", currentFingerprint) - continue - } - - logger.Info("Clearing the state of the rule because it was updated", "isPaused", ctx.IsPaused, "fingerprint", ctx.Fingerprint) - // clear the state. So the next evaluation will start from the scratch. - resetState(grafanaCtx, ctx.IsPaused) - currentFingerprint = ctx.Fingerprint - // evalCh - used by the scheduler to signal that evaluation is needed. - case ctx, ok := <-evalCh: - if !ok { - logger.Debug("Evaluation channel has been closed. Exiting") - return nil - } - if evalRunning { - continue - } - - func() { - evalRunning = true - defer func() { - evalRunning = false - sch.evalApplied(key, ctx.scheduledAt) - }() - - for attempt := int64(1); attempt <= sch.maxAttempts; attempt++ { - isPaused := ctx.rule.IsPaused - f := ruleWithFolder{ctx.rule, ctx.folderTitle}.Fingerprint() - // Do not clean up state if the eval loop has just started. - var needReset bool - if currentFingerprint != 0 && currentFingerprint != f { - logger.Debug("Got a new version of alert rule. Clear up the state", "fingerprint", f) - needReset = true - } - // We need to reset state if the loop has started and the alert is already paused. It can happen, - // if we have an alert with state and we do file provision with stateful Grafana, that state - // lingers in DB and won't be cleaned up until next alert rule update. - needReset = needReset || (currentFingerprint == 0 && isPaused) - if needReset { - resetState(grafanaCtx, isPaused) - } - currentFingerprint = f - if isPaused { - logger.Debug("Skip rule evaluation because it is paused") - return - } - - fpStr := currentFingerprint.String() - utcTick := ctx.scheduledAt.UTC().Format(time.RFC3339Nano) - tracingCtx, span := sch.tracer.Start(grafanaCtx, "alert rule execution", trace.WithAttributes( - attribute.String("rule_uid", ctx.rule.UID), - attribute.Int64("org_id", ctx.rule.OrgID), - attribute.Int64("rule_version", ctx.rule.Version), - attribute.String("rule_fingerprint", fpStr), - attribute.String("tick", utcTick), - )) - - // Check before any execution if the context was cancelled so that we don't do any evaluations. - if tracingCtx.Err() != nil { - span.SetStatus(codes.Error, "rule evaluation cancelled") - span.End() - logger.Error("Skip evaluation and updating the state because the context has been cancelled", "version", ctx.rule.Version, "fingerprint", f, "attempt", attempt, "now", ctx.scheduledAt) - return - } - - retry := attempt < sch.maxAttempts - err := evaluate(tracingCtx, f, attempt, ctx, span, retry) - // This is extremely confusing - when we exhaust all retry attempts, or we have no retryable errors - // we return nil - so technically, this is meaningless to know whether the evaluation has errors or not. - span.End() - if err == nil { - return - } - - logger.Error("Failed to evaluate rule", "version", ctx.rule.Version, "fingerprint", f, "attempt", attempt, "now", ctx.scheduledAt) - select { - case <-tracingCtx.Done(): - logger.Error("Context has been cancelled while backing off", "version", ctx.rule.Version, "fingerprint", f, "attempt", attempt, "now", ctx.scheduledAt) - return - case <-time.After(retryDelay): - continue - } - } - }() - - case <-grafanaCtx.Done(): - // clean up the state only if the reason for stopping the evaluation loop is that the rule was deleted - if errors.Is(grafanaCtx.Err(), errRuleDeleted) { - // We do not want a context to be unbounded which could potentially cause a go routine running - // indefinitely. 1 minute is an almost randomly chosen timeout, big enough to cover the majority of the - // cases. - ctx, cancelFunc := context.WithTimeout(context.Background(), time.Minute) - defer cancelFunc() - states := sch.stateManager.DeleteStateByRuleUID(ngmodels.WithRuleKey(ctx, key), key, ngmodels.StateReasonRuleDeleted) - notify(states) - } - logger.Debug("Stopping alert rule routine") - return nil - } - } -} - -// evalApplied is only used on tests. -func (sch *schedule) evalApplied(alertDefKey ngmodels.AlertRuleKey, now time.Time) { - if sch.evalAppliedFunc == nil { - return - } - - sch.evalAppliedFunc(alertDefKey, now) -} - -// stopApplied is only used on tests. -func (sch *schedule) stopApplied(alertDefKey ngmodels.AlertRuleKey) { - if sch.stopAppliedFunc == nil { - return - } - - sch.stopAppliedFunc(alertDefKey) -} - -func SchedulerUserFor(orgID int64) *user.SignedInUser { - return &user.SignedInUser{ - UserID: -1, - IsServiceAccount: true, - Login: "grafana_scheduler", - OrgID: orgID, - OrgRole: org.RoleAdmin, - Permissions: map[int64]map[string][]string{ - orgID: { - datasources.ActionQuery: []string{ - datasources.ScopeAll, - }, - }, - }, - } -} diff --git a/pkg/services/ngalert/schedule/schedule_unit_test.go b/pkg/services/ngalert/schedule/schedule_unit_test.go index f0613be1b1869..c30b54d390116 100644 --- a/pkg/services/ngalert/schedule/schedule_unit_test.go +++ b/pkg/services/ngalert/schedule/schedule_unit_test.go @@ -11,11 +11,8 @@ import ( "time" "github.com/benbjohnson/clock" - alertingModels "github.com/grafana/alerting/models" - "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" - prometheusModel "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -24,15 +21,14 @@ import ( "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + datasources "github.com/grafana/grafana/pkg/services/datasources/fakes" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" ) type evalAppliedInfo struct { @@ -58,7 +54,7 @@ func TestProcessTicks(t *testing.T) { mockedClock := clock.NewMock() - notifier := &AlertsSenderMock{} + notifier := NewSyncAlertsSenderMock() notifier.EXPECT().Send(mock.Anything, mock.Anything, mock.Anything).Return() appUrl := &url.URL{ @@ -66,28 +62,31 @@ func TestProcessTicks(t *testing.T) { Host: "localhost", } + cacheServ := &datasources.FakeCacheService{} + evaluator := eval.NewEvaluatorFactory(setting.UnifiedAlertingSettings{}, cacheServ, expr.ProvideService(&setting.Cfg{ExpressionsEnabled: true}, nil, nil, &featuremgmt.FeatureManager{}, nil, tracing.InitializeTracerForTest()), &pluginstore.FakePluginStore{}) + schedCfg := SchedulerCfg{ - BaseInterval: cfg.BaseInterval, - C: mockedClock, - AppURL: appUrl, - RuleStore: ruleStore, - Metrics: testMetrics.GetSchedulerMetrics(), - AlertSender: notifier, - Tracer: testTracer, - Log: log.New("ngalert.scheduler"), + BaseInterval: cfg.BaseInterval, + C: mockedClock, + AppURL: appUrl, + EvaluatorFactory: evaluator, + RuleStore: ruleStore, + Metrics: testMetrics.GetSchedulerMetrics(), + AlertSender: notifier, + Tracer: testTracer, + Log: log.New("ngalert.scheduler"), } managerCfg := state.ManagerCfg{ - Metrics: testMetrics.GetStateMetrics(), - ExternalURL: nil, - InstanceStore: nil, - Images: &state.NoopImageService{}, - Clock: mockedClock, - Historian: &state.FakeHistorian{}, - MaxStateSaveConcurrency: 1, - Tracer: testTracer, - Log: log.New("ngalert.state.manager"), + Metrics: testMetrics.GetStateMetrics(), + ExternalURL: nil, + InstanceStore: nil, + Images: &state.NoopImageService{}, + Clock: mockedClock, + Historian: &state.FakeHistorian{}, + Tracer: testTracer, + Log: log.New("ngalert.state.manager"), } - st := state.NewManager(managerCfg) + st := state.NewManager(managerCfg, state.NewNoopPersister()) sched := NewScheduler(schedCfg, st) @@ -357,480 +356,16 @@ func TestProcessTicks(t *testing.T) { }) } -func TestSchedule_ruleRoutine(t *testing.T) { - createSchedule := func( - evalAppliedChan chan time.Time, - senderMock *AlertsSenderMock, - ) (*schedule, *fakeRulesStore, *state.FakeInstanceStore, prometheus.Gatherer) { - ruleStore := newFakeRulesStore() - instanceStore := &state.FakeInstanceStore{} - - registry := prometheus.NewPedanticRegistry() - sch := setupScheduler(t, ruleStore, instanceStore, registry, senderMock, nil) - sch.evalAppliedFunc = func(key models.AlertRuleKey, t time.Time) { - evalAppliedChan <- t - } - return sch, ruleStore, instanceStore, registry - } - - // normal states do not include NoData and Error because currently it is not possible to perform any sensible test - normalStates := []eval.State{eval.Normal, eval.Alerting, eval.Pending} - allStates := [...]eval.State{eval.Normal, eval.Alerting, eval.Pending, eval.NoData, eval.Error} - - for _, evalState := range normalStates { - // TODO rewrite when we are able to mock/fake state manager - t.Run(fmt.Sprintf("when rule evaluation happens (evaluation state %s)", evalState), func(t *testing.T) { - evalChan := make(chan *evaluation) - evalAppliedChan := make(chan time.Time) - sch, ruleStore, instanceStore, reg := createSchedule(evalAppliedChan, nil) - - rule := models.AlertRuleGen(withQueryForState(t, evalState))() - ruleStore.PutRule(context.Background(), rule) - folderTitle := ruleStore.getNamespaceTitle(rule.NamespaceUID) - go func() { - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan, make(chan ruleVersionAndPauseStatus)) - }() - - expectedTime := time.UnixMicro(rand.Int63()) - - evalChan <- &evaluation{ - scheduledAt: expectedTime, - rule: rule, - folderTitle: folderTitle, - } - - actualTime := waitForTimeChannel(t, evalAppliedChan) - require.Equal(t, expectedTime, actualTime) - - t.Run("it should add extra labels", func(t *testing.T) { - states := sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) - for _, s := range states { - assert.Equal(t, rule.UID, s.Labels[alertingModels.RuleUIDLabel]) - assert.Equal(t, rule.NamespaceUID, s.Labels[alertingModels.NamespaceUIDLabel]) - assert.Equal(t, rule.Title, s.Labels[prometheusModel.AlertNameLabel]) - assert.Equal(t, folderTitle, s.Labels[models.FolderTitleLabel]) - } - }) - - t.Run("it should process evaluation results via state manager", func(t *testing.T) { - // TODO rewrite when we are able to mock/fake state manager - states := sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) - require.Len(t, states, 1) - s := states[0] - require.Equal(t, rule.UID, s.AlertRuleUID) - require.Len(t, s.Results, 1) - var expectedStatus = evalState - if evalState == eval.Pending { - expectedStatus = eval.Alerting - } - require.Equal(t, expectedStatus.String(), s.Results[0].EvaluationState.String()) - require.Equal(t, expectedTime, s.Results[0].EvaluationTime) - }) - t.Run("it should save alert instances to storage", func(t *testing.T) { - // TODO rewrite when we are able to mock/fake state manager - states := sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) - require.Len(t, states, 1) - s := states[0] - - var cmd *models.AlertInstance - for _, op := range instanceStore.RecordedOps { - switch q := op.(type) { - case models.AlertInstance: - cmd = &q - } - if cmd != nil { - break - } - } - - require.NotNil(t, cmd) - t.Logf("Saved alert instances: %v", cmd) - require.Equal(t, rule.OrgID, cmd.RuleOrgID) - require.Equal(t, expectedTime, cmd.LastEvalTime) - require.Equal(t, rule.UID, cmd.RuleUID) - require.Equal(t, evalState.String(), string(cmd.CurrentState)) - require.Equal(t, s.Labels, data.Labels(cmd.Labels)) - }) - - t.Run("it reports metrics", func(t *testing.T) { - // duration metric has 0 values because of mocked clock that do not advance - expectedMetric := fmt.Sprintf( - `# HELP grafana_alerting_rule_evaluation_duration_seconds The time to evaluate a rule. - # TYPE grafana_alerting_rule_evaluation_duration_seconds histogram - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="0.01"} 1 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="0.1"} 1 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="0.5"} 1 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="1"} 1 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="5"} 1 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="10"} 1 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="15"} 1 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="30"} 1 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="60"} 1 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="120"} 1 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="180"} 1 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="240"} 1 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="300"} 1 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="+Inf"} 1 - grafana_alerting_rule_evaluation_duration_seconds_sum{org="%[1]d"} 0 - grafana_alerting_rule_evaluation_duration_seconds_count{org="%[1]d"} 1 - # HELP grafana_alerting_rule_evaluation_failures_total The total number of rule evaluation failures. - # TYPE grafana_alerting_rule_evaluation_failures_total counter - grafana_alerting_rule_evaluation_failures_total{org="%[1]d"} 0 - # HELP grafana_alerting_rule_evaluations_total The total number of rule evaluations. - # TYPE grafana_alerting_rule_evaluations_total counter - grafana_alerting_rule_evaluations_total{org="%[1]d"} 1 - # HELP grafana_alerting_rule_process_evaluation_duration_seconds The time to process the evaluation results for a rule. - # TYPE grafana_alerting_rule_process_evaluation_duration_seconds histogram - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="0.01"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="0.1"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="0.5"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="1"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="5"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="10"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="15"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="30"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="60"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="120"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="180"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="240"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="300"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="+Inf"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_sum{org="%[1]d"} 0 - grafana_alerting_rule_process_evaluation_duration_seconds_count{org="%[1]d"} 1 - # HELP grafana_alerting_rule_send_alerts_duration_seconds The time to send the alerts to Alertmanager. - # TYPE grafana_alerting_rule_send_alerts_duration_seconds histogram - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="0.01"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="0.1"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="0.5"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="1"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="5"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="10"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="15"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="30"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="60"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="120"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="180"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="240"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="300"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="+Inf"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_sum{org="%[1]d"} 0 - grafana_alerting_rule_send_alerts_duration_seconds_count{org="%[1]d"} 1 - `, rule.OrgID) - - err := testutil.GatherAndCompare(reg, bytes.NewBufferString(expectedMetric), "grafana_alerting_rule_evaluation_duration_seconds", "grafana_alerting_rule_evaluations_total", "grafana_alerting_rule_evaluation_failures_total", "grafana_alerting_rule_process_evaluation_duration_seconds", "grafana_alerting_rule_send_alerts_duration_seconds") - require.NoError(t, err) - }) - }) - } - - t.Run("should exit", func(t *testing.T) { - t.Run("and not clear the state if parent context is cancelled", func(t *testing.T) { - stoppedChan := make(chan error) - sch, _, _, _ := createSchedule(make(chan time.Time), nil) - - rule := models.AlertRuleGen()() - _ = sch.stateManager.ProcessEvalResults(context.Background(), sch.clock.Now(), rule, eval.GenerateResults(rand.Intn(5)+1, eval.ResultGen(eval.WithEvaluatedAt(sch.clock.Now()))), nil) - expectedStates := sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) - require.NotEmpty(t, expectedStates) - - ctx, cancel := context.WithCancel(context.Background()) - go func() { - err := sch.ruleRoutine(ctx, models.AlertRuleKey{}, make(chan *evaluation), make(chan ruleVersionAndPauseStatus)) - stoppedChan <- err - }() - - cancel() - err := waitForErrChannel(t, stoppedChan) - require.NoError(t, err) - require.Equal(t, len(expectedStates), len(sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID))) - }) - t.Run("and clean up the state if delete is cancellation reason ", func(t *testing.T) { - stoppedChan := make(chan error) - sch, _, _, _ := createSchedule(make(chan time.Time), nil) - - rule := models.AlertRuleGen()() - _ = sch.stateManager.ProcessEvalResults(context.Background(), sch.clock.Now(), rule, eval.GenerateResults(rand.Intn(5)+1, eval.ResultGen(eval.WithEvaluatedAt(sch.clock.Now()))), nil) - require.NotEmpty(t, sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID)) - - ctx, cancel := util.WithCancelCause(context.Background()) - go func() { - err := sch.ruleRoutine(ctx, rule.GetKey(), make(chan *evaluation), make(chan ruleVersionAndPauseStatus)) - stoppedChan <- err - }() - - cancel(errRuleDeleted) - err := waitForErrChannel(t, stoppedChan) - require.NoError(t, err) - - require.Empty(t, sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID)) - }) - }) - - t.Run("when a message is sent to update channel", func(t *testing.T) { - rule := models.AlertRuleGen(withQueryForState(t, eval.Normal))() - folderTitle := "folderName" - ruleFp := ruleWithFolder{rule, folderTitle}.Fingerprint() - - evalChan := make(chan *evaluation) - evalAppliedChan := make(chan time.Time) - updateChan := make(chan ruleVersionAndPauseStatus) - - sender := AlertsSenderMock{} - sender.EXPECT().Send(mock.Anything, rule.GetKey(), mock.Anything).Return() - - sch, ruleStore, _, _ := createSchedule(evalAppliedChan, &sender) - ruleStore.PutRule(context.Background(), rule) - sch.schedulableAlertRules.set([]*models.AlertRule{rule}, map[string]string{rule.NamespaceUID: folderTitle}) - - go func() { - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan, updateChan) - }() - - // init evaluation loop so it got the rule version - evalChan <- &evaluation{ - scheduledAt: sch.clock.Now(), - rule: rule, - folderTitle: folderTitle, - } - - waitForTimeChannel(t, evalAppliedChan) - - // define some state - states := make([]*state.State, 0, len(allStates)) - for _, s := range allStates { - for i := 0; i < 2; i++ { - states = append(states, &state.State{ - AlertRuleUID: rule.UID, - CacheID: util.GenerateShortUID(), - OrgID: rule.OrgID, - State: s, - StartsAt: sch.clock.Now(), - EndsAt: sch.clock.Now().Add(time.Duration(rand.Intn(25)+5) * time.Second), - Labels: rule.Labels, - }) - } - } - sch.stateManager.Put(states) - - states = sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) - expectedToBeSent := 0 - for _, s := range states { - if s.State == eval.Normal || s.State == eval.Pending { - continue - } - expectedToBeSent++ - } - require.Greaterf(t, expectedToBeSent, 0, "State manager was expected to return at least one state that can be expired") - - t.Run("should do nothing if version in channel is the same", func(t *testing.T) { - updateChan <- ruleVersionAndPauseStatus{ruleFp, false} - updateChan <- ruleVersionAndPauseStatus{ruleFp, false} // second time just to make sure that previous messages were handled - - actualStates := sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) - require.Len(t, actualStates, len(states)) - - sender.AssertNotCalled(t, "Send", mock.Anything, mock.Anything) - }) - - t.Run("should clear the state and expire firing alerts if version in channel is greater", func(t *testing.T) { - updateChan <- ruleVersionAndPauseStatus{ruleFp + 1, false} - - require.Eventually(t, func() bool { - return len(sender.Calls) > 0 - }, 5*time.Second, 100*time.Millisecond) - - require.Empty(t, sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID)) - sender.AssertNumberOfCalls(t, "Send", 1) - args, ok := sender.Calls[0].Arguments[2].(definitions.PostableAlerts) - require.Truef(t, ok, fmt.Sprintf("expected argument of function was supposed to be 'definitions.PostableAlerts' but got %T", sender.Calls[0].Arguments[2])) - require.Len(t, args.PostableAlerts, expectedToBeSent) - }) - }) - - t.Run("when evaluation fails", func(t *testing.T) { - rule := models.AlertRuleGen(withQueryForState(t, eval.Error))() - rule.ExecErrState = models.ErrorErrState - - evalChan := make(chan *evaluation) - evalAppliedChan := make(chan time.Time) - - sender := AlertsSenderMock{} - sender.EXPECT().Send(mock.Anything, rule.GetKey(), mock.Anything).Return() - - sch, ruleStore, _, reg := createSchedule(evalAppliedChan, &sender) - sch.maxAttempts = 3 - ruleStore.PutRule(context.Background(), rule) - - go func() { - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan, make(chan ruleVersionAndPauseStatus)) - }() - - evalChan <- &evaluation{ - scheduledAt: sch.clock.Now(), - rule: rule, - } - - waitForTimeChannel(t, evalAppliedChan) - - t.Run("it should increase failure counter", func(t *testing.T) { - // duration metric has 0 values because of mocked clock that do not advance - expectedMetric := fmt.Sprintf( - `# HELP grafana_alerting_rule_evaluation_duration_seconds The time to evaluate a rule. - # TYPE grafana_alerting_rule_evaluation_duration_seconds histogram - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="0.01"} 3 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="0.1"} 3 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="0.5"} 3 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="1"} 3 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="5"} 3 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="10"} 3 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="15"} 3 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="30"} 3 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="60"} 3 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="120"} 3 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="180"} 3 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="240"} 3 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="300"} 3 - grafana_alerting_rule_evaluation_duration_seconds_bucket{org="%[1]d",le="+Inf"} 3 - grafana_alerting_rule_evaluation_duration_seconds_sum{org="%[1]d"} 0 - grafana_alerting_rule_evaluation_duration_seconds_count{org="%[1]d"} 3 - # HELP grafana_alerting_rule_evaluation_failures_total The total number of rule evaluation failures. - # TYPE grafana_alerting_rule_evaluation_failures_total counter - grafana_alerting_rule_evaluation_failures_total{org="%[1]d"} 3 - # HELP grafana_alerting_rule_evaluations_total The total number of rule evaluations. - # TYPE grafana_alerting_rule_evaluations_total counter - grafana_alerting_rule_evaluations_total{org="%[1]d"} 3 - # HELP grafana_alerting_rule_process_evaluation_duration_seconds The time to process the evaluation results for a rule. - # TYPE grafana_alerting_rule_process_evaluation_duration_seconds histogram - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="0.01"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="0.1"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="0.5"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="1"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="5"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="10"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="15"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="30"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="60"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="120"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="180"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="240"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="300"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_bucket{org="%[1]d",le="+Inf"} 1 - grafana_alerting_rule_process_evaluation_duration_seconds_sum{org="%[1]d"} 0 - grafana_alerting_rule_process_evaluation_duration_seconds_count{org="%[1]d"} 1 - # HELP grafana_alerting_rule_send_alerts_duration_seconds The time to send the alerts to Alertmanager. - # TYPE grafana_alerting_rule_send_alerts_duration_seconds histogram - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="0.01"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="0.1"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="0.5"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="1"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="5"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="10"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="15"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="30"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="60"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="120"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="180"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="240"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="300"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_bucket{org="%[1]d",le="+Inf"} 1 - grafana_alerting_rule_send_alerts_duration_seconds_sum{org="%[1]d"} 0 - grafana_alerting_rule_send_alerts_duration_seconds_count{org="%[1]d"} 1 - `, rule.OrgID) - - err := testutil.GatherAndCompare(reg, bytes.NewBufferString(expectedMetric), "grafana_alerting_rule_evaluation_duration_seconds", "grafana_alerting_rule_evaluations_total", "grafana_alerting_rule_evaluation_failures_total", "grafana_alerting_rule_process_evaluation_duration_seconds", "grafana_alerting_rule_send_alerts_duration_seconds") - require.NoError(t, err) - }) - - t.Run("it should send special alert DatasourceError", func(t *testing.T) { - sender.AssertNumberOfCalls(t, "Send", 1) - args, ok := sender.Calls[0].Arguments[2].(definitions.PostableAlerts) - require.Truef(t, ok, fmt.Sprintf("expected argument of function was supposed to be 'definitions.PostableAlerts' but got %T", sender.Calls[0].Arguments[2])) - assert.Len(t, args.PostableAlerts, 1) - assert.Equal(t, state.ErrorAlertName, args.PostableAlerts[0].Labels[prometheusModel.AlertNameLabel]) - }) - }) - - t.Run("when there are alerts that should be firing", func(t *testing.T) { - t.Run("it should call sender", func(t *testing.T) { - // eval.Alerting makes state manager to create notifications for alertmanagers - rule := models.AlertRuleGen(withQueryForState(t, eval.Alerting))() - - evalChan := make(chan *evaluation) - evalAppliedChan := make(chan time.Time) - - sender := AlertsSenderMock{} - sender.EXPECT().Send(mock.Anything, rule.GetKey(), mock.Anything).Return() - - sch, ruleStore, _, _ := createSchedule(evalAppliedChan, &sender) - ruleStore.PutRule(context.Background(), rule) - - go func() { - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan, make(chan ruleVersionAndPauseStatus)) - }() - - evalChan <- &evaluation{ - scheduledAt: sch.clock.Now(), - rule: rule, - } - - waitForTimeChannel(t, evalAppliedChan) - - sender.AssertNumberOfCalls(t, "Send", 1) - args, ok := sender.Calls[0].Arguments[2].(definitions.PostableAlerts) - require.Truef(t, ok, fmt.Sprintf("expected argument of function was supposed to be 'definitions.PostableAlerts' but got %T", sender.Calls[0].Arguments[2])) - - require.Len(t, args.PostableAlerts, 1) - }) - }) - - t.Run("when there are no alerts to send it should not call notifiers", func(t *testing.T) { - rule := models.AlertRuleGen(withQueryForState(t, eval.Normal))() - - evalChan := make(chan *evaluation) - evalAppliedChan := make(chan time.Time) - - sender := AlertsSenderMock{} - sender.EXPECT().Send(mock.Anything, rule.GetKey(), mock.Anything).Return() - - sch, ruleStore, _, _ := createSchedule(evalAppliedChan, &sender) - ruleStore.PutRule(context.Background(), rule) - - go func() { - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan, make(chan ruleVersionAndPauseStatus)) - }() - - evalChan <- &evaluation{ - scheduledAt: sch.clock.Now(), - rule: rule, - } - - waitForTimeChannel(t, evalAppliedChan) - - sender.AssertNotCalled(t, "Send", mock.Anything, mock.Anything) - - require.NotEmpty(t, sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID)) - }) -} - func TestSchedule_deleteAlertRule(t *testing.T) { t.Run("when rule exists", func(t *testing.T) { t.Run("it should stop evaluation loop and remove the controller from registry", func(t *testing.T) { sch := setupScheduler(t, nil, nil, nil, nil, nil) + ruleFactory := ruleFactoryFromScheduler(sch) rule := models.AlertRuleGen()() key := rule.GetKey() - info, _ := sch.registry.getOrCreateInfo(context.Background(), key) + info, _ := sch.registry.getOrCreate(context.Background(), key, ruleFactory) sch.deleteAlertRule(key) - require.ErrorIs(t, info.ctx.Err(), errRuleDeleted) + require.ErrorIs(t, info.(*alertRule).ctx.Err(), errRuleDeleted) require.False(t, sch.registry.exists(key)) }) }) @@ -843,7 +378,7 @@ func TestSchedule_deleteAlertRule(t *testing.T) { }) } -func setupScheduler(t *testing.T, rs *fakeRulesStore, is *state.FakeInstanceStore, registry *prometheus.Registry, senderMock *AlertsSenderMock, evalMock eval.EvaluatorFactory) *schedule { +func setupScheduler(t *testing.T, rs *fakeRulesStore, is *state.FakeInstanceStore, registry *prometheus.Registry, senderMock *SyncAlertsSenderMock, evalMock eval.EvaluatorFactory) *schedule { t.Helper() testTracer := tracing.InitializeTracerForTest() @@ -873,7 +408,7 @@ func setupScheduler(t *testing.T, rs *fakeRulesStore, is *state.FakeInstanceStor } if senderMock == nil { - senderMock = &AlertsSenderMock{} + senderMock = NewSyncAlertsSenderMock() senderMock.EXPECT().Send(mock.Anything, mock.Anything, mock.Anything).Return() } @@ -901,11 +436,12 @@ func setupScheduler(t *testing.T, rs *fakeRulesStore, is *state.FakeInstanceStor Images: &state.NoopImageService{}, Clock: mockedClock, Historian: &state.FakeHistorian{}, - MaxStateSaveConcurrency: 1, Tracer: testTracer, Log: log.New("ngalert.state.manager"), + MaxStateSaveConcurrency: 1, } - st := state.NewManager(managerCfg) + syncStatePersister := state.NewSyncStatePersisiter(log.New("ngalert.state.manager.perist"), managerCfg) + st := state.NewManager(managerCfg, syncStatePersister) return NewScheduler(schedCfg, st) } diff --git a/pkg/services/ngalert/schedule/testing.go b/pkg/services/ngalert/schedule/testing.go index 803f084825c8d..2f00c213ac3b2 100644 --- a/pkg/services/ngalert/schedule/testing.go +++ b/pkg/services/ngalert/schedule/testing.go @@ -2,10 +2,14 @@ package schedule import ( "context" + "slices" + "sync" "testing" "time" + definitions "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" + mock "github.com/stretchr/testify/mock" ) // waitForTimeChannel blocks the execution until either the channel ch has some data or a timeout of 10 second expires. @@ -57,10 +61,11 @@ func (f *fakeRulesStore) GetAlertRulesKeysForScheduling(ctx context.Context) ([] } func (f *fakeRulesStore) GetAlertRulesForScheduling(ctx context.Context, query *models.GetAlertRulesForSchedulingQuery) error { - query.ResultFoldersTitles = map[string]string{} + query.ResultFoldersTitles = map[models.FolderKey]string{} for _, rule := range f.rules { query.ResultRules = append(query.ResultRules, rule) - query.ResultFoldersTitles[rule.NamespaceUID] = f.getNamespaceTitle(rule.NamespaceUID) + key := models.FolderKey{OrgID: rule.OrgID, UID: rule.UID} + query.ResultFoldersTitles[key] = f.getNamespaceTitle(rule.NamespaceUID) } return nil } @@ -80,3 +85,26 @@ func (f *fakeRulesStore) DeleteRule(rules ...*models.AlertRule) { func (f *fakeRulesStore) getNamespaceTitle(uid string) string { return "TEST-FOLDER-" + uid } + +type SyncAlertsSenderMock struct { + *AlertsSenderMock + mu sync.Mutex +} + +func NewSyncAlertsSenderMock() *SyncAlertsSenderMock { + return &SyncAlertsSenderMock{ + AlertsSenderMock: new(AlertsSenderMock), + } +} + +func (m *SyncAlertsSenderMock) Send(ctx context.Context, key models.AlertRuleKey, alerts definitions.PostableAlerts) { + m.mu.Lock() + defer m.mu.Unlock() + m.AlertsSenderMock.Send(ctx, key, alerts) +} + +func (m *SyncAlertsSenderMock) Calls() []mock.Call { + m.mu.Lock() + defer m.mu.Unlock() + return slices.Clone(m.AlertsSenderMock.Calls) +} diff --git a/pkg/services/ngalert/sender/notifier.go b/pkg/services/ngalert/sender/notifier.go index d8066fd16e433..22f82099e7a54 100644 --- a/pkg/services/ngalert/sender/notifier.go +++ b/pkg/services/ngalert/sender/notifier.go @@ -336,7 +336,7 @@ func (n *Manager) Send(alerts ...*Alert) { } }) - a.Labels = lb.Labels(a.Labels) + a.Labels = lb.Labels() } alerts = n.relabelAlerts(alerts) diff --git a/pkg/services/ngalert/sender/notifier_ext.go b/pkg/services/ngalert/sender/notifier_ext.go index 27effd18913df..544ed4ae0724c 100644 --- a/pkg/services/ngalert/sender/notifier_ext.go +++ b/pkg/services/ngalert/sender/notifier_ext.go @@ -24,7 +24,7 @@ import ( // ApplyConfig updates the status state as the new config requires. // Extension: add new parameter headers. -func (n *Manager) ApplyConfig(conf *config.Config, headers map[string]map[string]string) error { +func (n *Manager) ApplyConfig(conf *config.Config, headers map[string]http.Header) error { n.mtx.Lock() defer n.mtx.Unlock() @@ -57,7 +57,7 @@ type alertmanagerSet struct { client *http.Client // Extension: headers that should be used for the http requests to the alertmanagers. - headers map[string]string + headers http.Header metrics *alertMetrics @@ -144,7 +144,7 @@ func (n *Manager) sendAll(alerts ...*Alert) bool { defer cancel() // Extension: added headers parameter. - go func(client *http.Client, url string, headers map[string]string) { + go func(client *http.Client, url string, headers http.Header) { if err := n.sendOne(ctx, client, url, payload, headers); err != nil { level.Error(n.logger).Log("alertmanager", url, "count", len(alerts), "msg", "Error sending alert", "err", err) n.metrics.errors.WithLabelValues(url).Inc() @@ -167,7 +167,7 @@ func (n *Manager) sendAll(alerts ...*Alert) bool { } // Extension: added headers parameter. -func (n *Manager) sendOne(ctx context.Context, c *http.Client, url string, b []byte, headers map[string]string) error { +func (n *Manager) sendOne(ctx context.Context, c *http.Client, url string, b []byte, headers http.Header) error { req, err := http.NewRequest("POST", url, bytes.NewReader(b)) if err != nil { return err @@ -176,7 +176,9 @@ func (n *Manager) sendOne(ctx context.Context, c *http.Client, url string, b []b req.Header.Set("Content-Type", contentTypeJSON) // Extension: set headers. for k, v := range headers { - req.Header.Set(k, v) + for _, vv := range v { + req.Header.Set(k, vv) + } } resp, err := n.opts.Do(ctx, c, req) if err != nil { diff --git a/pkg/services/ngalert/sender/router.go b/pkg/services/ngalert/sender/router.go index 6cac839f3cbe2..077901d96137c 100644 --- a/pkg/services/ngalert/sender/router.go +++ b/pkg/services/ngalert/sender/router.go @@ -275,17 +275,16 @@ func (d *AlertsRouter) buildExternalURL(ds *datasources.DataSource) (string, err } } - // if basic auth is enabled we need to build the url with basic auth baked in - if !ds.BasicAuth { - return parsed.String(), nil + // If basic auth is enabled we need to build the url with basic auth baked in. + if ds.BasicAuth { + password := d.secretService.GetDecryptedValue(context.Background(), ds.SecureJsonData, "basicAuthPassword", "") + if password == "" { + return "", fmt.Errorf("basic auth enabled but no password set") + } + parsed.User = url.UserPassword(ds.BasicAuthUser, password) } - password := d.secretService.GetDecryptedValue(context.Background(), ds.SecureJsonData, "basicAuthPassword", "") - if password == "" { - return "", fmt.Errorf("basic auth enabled but no password set") - } - return fmt.Sprintf("%s://%s:%s@%s%s%s", parsed.Scheme, ds.BasicAuthUser, - password, parsed.Host, parsed.Path, parsed.RawQuery), nil + return parsed.String(), nil } func (d *AlertsRouter) Send(ctx context.Context, key models.AlertRuleKey, alerts definitions.PostableAlerts) { diff --git a/pkg/services/ngalert/sender/router_test.go b/pkg/services/ngalert/sender/router_test.go index e9cfafeed8fa8..7fa5a9ae76d4c 100644 --- a/pkg/services/ngalert/sender/router_test.go +++ b/pkg/services/ngalert/sender/router_test.go @@ -19,11 +19,11 @@ import ( "github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/grafana/grafana/pkg/services/datasources" fake_ds "github.com/grafana/grafana/pkg/services/datasources/fakes" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier" - "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" fake_secrets "github.com/grafana/grafana/pkg/services/secrets/fakes" @@ -411,7 +411,7 @@ func createMultiOrgAlertmanager(t *testing.T, orgs []int64) *notifier.MultiOrgAl m := metrics.NewNGAlert(registry) secretsService := secretsManager.SetupTestService(t, fake_secrets.NewFakeSecretsStore()) decryptFn := secretsService.GetDecryptedValue - moa, err := notifier.NewMultiOrgAlertmanager(cfg, cfgStore, &orgStore, kvStore, provisioning.NewFakeProvisioningStore(), decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService) + moa, err := notifier.NewMultiOrgAlertmanager(cfg, cfgStore, orgStore, kvStore, fakes.NewFakeProvisioningStore(), decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService, &featuremgmt.FeatureManager{}) require.NoError(t, err) require.NoError(t, moa.LoadAndSyncAlertmanagersForOrgs(context.Background())) require.Eventually(t, func() bool { @@ -461,6 +461,18 @@ func TestBuildExternalURL(t *testing.T) { }, expectedURL: "https://johndoe:123@localhost:9000", }, + { + name: "datasource with auth that needs escaping", + ds: &datasources.DataSource{ + URL: "https://localhost:9000", + BasicAuth: true, + BasicAuthUser: "johndoe", + SecureJsonData: map[string][]byte{ + "basicAuthPassword": []byte("123#!"), + }, + }, + expectedURL: "https://johndoe:123%23%21@localhost:9000", + }, { name: "datasource with auth and path", ds: &datasources.DataSource{ diff --git a/pkg/services/ngalert/sender/sender.go b/pkg/services/ngalert/sender/sender.go index eef29402c0591..60d609243813a 100644 --- a/pkg/services/ngalert/sender/sender.go +++ b/pkg/services/ngalert/sender/sender.go @@ -43,7 +43,7 @@ type ExternalAlertmanager struct { type ExternalAMcfg struct { URL string - Headers map[string]string + Headers http.Header } type Option func(*ExternalAlertmanager) @@ -127,13 +127,14 @@ func (s *ExternalAlertmanager) ApplyConfig(orgId, id int64, alertmanagers []Exte } func (s *ExternalAlertmanager) Run() { + logger := s.logger s.wg.Add(2) go func() { - s.logger.Info("Initiating communication with a group of external Alertmanagers") + logger.Info("Initiating communication with a group of external Alertmanagers") if err := s.sdManager.Run(); err != nil { - s.logger.Error("Failed to start the sender service discovery manager", "error", err) + logger.Error("Failed to start the sender service discovery manager", "error", err) } s.wg.Done() }() @@ -176,9 +177,9 @@ func (s *ExternalAlertmanager) DroppedAlertmanagers() []*url.URL { return s.manager.DroppedAlertmanagers() } -func buildNotifierConfig(alertmanagers []ExternalAMcfg) (*config.Config, map[string]map[string]string, error) { +func buildNotifierConfig(alertmanagers []ExternalAMcfg) (*config.Config, map[string]http.Header, error) { amConfigs := make([]*config.AlertmanagerConfig, 0, len(alertmanagers)) - headers := map[string]map[string]string{} + headers := map[string]http.Header{} for i, am := range alertmanagers { u, err := url.Parse(am.URL) if err != nil { diff --git a/pkg/services/ngalert/state/cache.go b/pkg/services/ngalert/state/cache.go index 7f7e7f510f51c..ae82381310eed 100644 --- a/pkg/services/ngalert/state/cache.go +++ b/pkg/services/ngalert/state/cache.go @@ -121,9 +121,36 @@ func (rs *ruleStates) getOrAdd(stateCandidate State) *State { } func calculateState(ctx context.Context, log log.Logger, alertRule *ngModels.AlertRule, result eval.Result, extraLabels data.Labels, externalURL *url.URL) State { + var reserved []string + resultLabels := result.Instance + if len(resultLabels) > 0 { + for key := range ngModels.LabelsUserCannotSpecify { + if value, ok := resultLabels[key]; ok { + if reserved == nil { // make a copy of labels if we are going to modify it + resultLabels = result.Instance.Copy() + } + reserved = append(reserved, key) + delete(resultLabels, key) + // we cannot delete the reserved label completely because it can cause alert instances to collide (when this label is only unique across results) + // so we just rename it to something that does not collide with reserved labels + newKey := strings.TrimSuffix(strings.TrimPrefix(key, "__"), "__") + if _, ok = resultLabels[newKey]; newKey == "" || newKey == key || ok { // in the case if in the future the LabelsUserCannotSpecify contains labels that do not have double underscore + newKey = key + "_user" + } + if _, ok = resultLabels[newKey]; !ok { // if it still collides with another existing label, we just drop the label + resultLabels[newKey] = value + } else { + log.Warn("Result contains reserved label, and, after renaming, a new label collides with an existing one. Removing the label completely", "deletedLabel", key, "renamedLabel", newKey) + } + } + } + if len(reserved) > 0 { + log.Debug("Found collision of result labels and system reserved. Renamed labels with suffix '_user'", "renamedLabels", strings.Join(reserved, ",")) + } + } // Merge both the extra labels and the labels from the evaluation into a common set // of labels that can be expanded in custom labels and annotations. - templateData := template.NewData(mergeLabels(extraLabels, result.Instance), result) + templateData := template.NewData(mergeLabels(extraLabels, resultLabels), result) // For now, do nothing with these errors as they are already logged in expand. // In the future, we want to show these errors to the user somehow. @@ -139,7 +166,7 @@ func calculateState(ctx context.Context, log log.Logger, alertRule *ngModels.Ale } } - lbs := make(data.Labels, len(extraLabels)+len(labels)+len(result.Instance)) + lbs := make(data.Labels, len(extraLabels)+len(labels)+len(resultLabels)) dupes := make(data.Labels) for key, val := range extraLabels { lbs[key] = val @@ -159,7 +186,7 @@ func calculateState(ctx context.Context, log log.Logger, alertRule *ngModels.Ale log.Warn("Rule declares one or many reserved labels. Those rules labels will be ignored", "labels", dupes) } dupes = make(data.Labels) - for key, val := range result.Instance { + for key, val := range resultLabels { _, ok := lbs[key] // if duplicate labels exist, reserved or alert rule label will take precedence if ok { @@ -190,6 +217,7 @@ func calculateState(ctx context.Context, log log.Logger, alertRule *ngModels.Ale Values: values, StartsAt: result.EvaluatedAt, EndsAt: result.EvaluatedAt, + ResultFingerprint: result.Instance.Fingerprint(), // remember original result fingerprint } return newState } @@ -329,6 +357,37 @@ func (c *cache) removeByRuleUID(orgID int64, uid string) []*State { return states } +// asInstances returns the whole content of the cache as a slice of AlertInstance. +func (c *cache) asInstances(skipNormalState bool) []ngModels.AlertInstance { + var states []ngModels.AlertInstance + c.mtxStates.RLock() + defer c.mtxStates.RUnlock() + for _, orgStates := range c.states { + for _, v1 := range orgStates { + for _, v2 := range v1.states { + if skipNormalState && IsNormalStateWithNoReason(v2) { + continue + } + key, err := v2.GetAlertInstanceKey() + if err != nil { + continue + } + states = append(states, ngModels.AlertInstance{ + AlertInstanceKey: key, + Labels: ngModels.InstanceLabels(v2.Labels), + CurrentState: ngModels.InstanceStateType(v2.State.String()), + CurrentReason: v2.StateReason, + LastEvalTime: v2.LastEvaluationTime, + CurrentStateSince: v2.StartsAt, + CurrentStateEnd: v2.EndsAt, + ResultFingerprint: v2.ResultFingerprint.String(), + }) + } + } + } + return states +} + // if duplicate labels exist, keep the value from the first set func mergeLabels(a, b data.Labels) data.Labels { newLbs := make(data.Labels, len(a)+len(b)) diff --git a/pkg/services/ngalert/state/cache_test.go b/pkg/services/ngalert/state/cache_test.go index 95e81513b381f..58a0adbfafdf1 100644 --- a/pkg/services/ngalert/state/cache_test.go +++ b/pkg/services/ngalert/state/cache_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -263,6 +264,61 @@ func Test_getOrCreate(t *testing.T) { state := c.getOrCreate(context.Background(), l, rule, result, nil, url) assert.Equal(t, map[string]float64{"B0": 1, "B1": 2}, state.Values) }) + + t.Run("when result labels collide with system labels from LabelsUserCannotSpecify", func(t *testing.T) { + result := eval.Result{ + Instance: models.GenerateAlertLabels(5, "result-"), + } + m := models.LabelsUserCannotSpecify + t.Cleanup(func() { + models.LabelsUserCannotSpecify = m + }) + + models.LabelsUserCannotSpecify = map[string]struct{}{ + "__label1__": {}, + "label2__": {}, + "__label3": {}, + "label4": {}, + } + result.Instance["__label1__"] = uuid.NewString() + result.Instance["label2__"] = uuid.NewString() + result.Instance["__label3"] = uuid.NewString() + result.Instance["label4"] = uuid.NewString() + + rule := generateRule() + + state := c.getOrCreate(context.Background(), l, rule, result, nil, url) + + for key := range models.LabelsUserCannotSpecify { + assert.NotContains(t, state.Labels, key) + } + assert.Contains(t, state.Labels, "label1") + assert.Equal(t, state.Labels["label1"], result.Instance["__label1__"]) + + assert.Contains(t, state.Labels, "label2") + assert.Equal(t, state.Labels["label2"], result.Instance["label2__"]) + + assert.Contains(t, state.Labels, "label3") + assert.Equal(t, state.Labels["label3"], result.Instance["__label3"]) + + assert.Contains(t, state.Labels, "label4_user") + assert.Equal(t, state.Labels["label4_user"], result.Instance["label4"]) + + t.Run("should drop label if renamed collides with existing", func(t *testing.T) { + result.Instance["label1"] = uuid.NewString() + result.Instance["label1_user"] = uuid.NewString() + result.Instance["label4_user"] = uuid.NewString() + + state = c.getOrCreate(context.Background(), l, rule, result, nil, url) + assert.NotContains(t, state.Labels, "__label1__") + assert.Contains(t, state.Labels, "label1") + assert.Equal(t, state.Labels["label1"], result.Instance["label1"]) + assert.Equal(t, state.Labels["label1_user"], result.Instance["label1_user"]) + + assert.NotContains(t, state.Labels, "label4") + assert.Equal(t, state.Labels["label4_user"], result.Instance["label4_user"]) + }) + }) } func Test_mergeLabels(t *testing.T) { diff --git a/pkg/services/ngalert/state/compat.go b/pkg/services/ngalert/state/compat.go index e660b3b232cd1..a17b6637a0217 100644 --- a/pkg/services/ngalert/state/compat.go +++ b/pkg/services/ngalert/state/compat.go @@ -33,7 +33,8 @@ const ( // - if evaluation state is either NoData or Error, the resulting set of labels is changed: // - original alert name (label: model.AlertNameLabel) is backed up to OriginalAlertName // - label model.AlertNameLabel is overwritten to either NoDataAlertName or ErrorAlertName -func StateToPostableAlert(alertState *State, appURL *url.URL) *models.PostableAlert { +func StateToPostableAlert(transition StateTransition, appURL *url.URL) *models.PostableAlert { + alertState := transition.State nL := alertState.Labels.Copy() nA := data.Labels(alertState.Annotations).Copy() @@ -71,11 +72,19 @@ func StateToPostableAlert(alertState *State, appURL *url.URL) *models.PostableAl urlStr = "" } - if alertState.State == eval.NoData { + state := alertState.State + if alertState.Resolved { + // If this is a resolved alert, we need to send an alert with the correct labels such that they will expire the previous alert. + // In most cases the labels on the state will be correct, however when the previous alert was a NoData or Error alert, we need to + // ensure to modify it appropriately. + state = transition.PreviousState + } + + if state == eval.NoData { return noDataAlert(nL, nA, alertState, urlStr) } - if alertState.State == eval.Error { + if state == eval.Error { return errorAlert(nL, nA, alertState, urlStr) } @@ -139,7 +148,7 @@ func FromStateTransitionToPostableAlerts(firingStates []StateTransition, stateMa if !alertState.NeedsSending(stateManager.ResendDelay) { continue } - alert := StateToPostableAlert(alertState.State, appURL) + alert := StateToPostableAlert(alertState, appURL) alerts.PostableAlerts = append(alerts.PostableAlerts, *alert) if alertState.StateReason == ngModels.StateReasonMissingSeries { // do not put stale state back to state manager continue @@ -160,7 +169,7 @@ func FromAlertsStateToStoppedAlert(firingStates []StateTransition, appURL *url.U if transition.PreviousState == eval.Normal || transition.PreviousState == eval.Pending { continue } - postableAlert := StateToPostableAlert(transition.State, appURL) + postableAlert := StateToPostableAlert(transition, appURL) postableAlert.EndsAt = strfmt.DateTime(ts) alerts.PostableAlerts = append(alerts.PostableAlerts, *postableAlert) } diff --git a/pkg/services/ngalert/state/compat_test.go b/pkg/services/ngalert/state/compat_test.go index 21de47f026a67..e0f889a96ea94 100644 --- a/pkg/services/ngalert/state/compat_test.go +++ b/pkg/services/ngalert/state/compat_test.go @@ -10,6 +10,7 @@ import ( "github.com/benbjohnson/clock" "github.com/go-openapi/strfmt" alertingModels "github.com/grafana/alerting/models" + "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" @@ -56,7 +57,7 @@ func Test_StateToPostableAlert(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Run("it generates proper URL", func(t *testing.T) { t.Run("to alert rule", func(t *testing.T) { - alertState := randomState(tc.state) + alertState := randomTransition(eval.Normal, tc.state) alertState.Labels[alertingModels.RuleUIDLabel] = alertState.AlertRuleUID result := StateToPostableAlert(alertState, appURL) u := *appURL @@ -65,7 +66,7 @@ func Test_StateToPostableAlert(t *testing.T) { }) t.Run("app URL as is if rule UID is not specified", func(t *testing.T) { - alertState := randomState(tc.state) + alertState := randomTransition(eval.Normal, tc.state) alertState.Labels[alertingModels.RuleUIDLabel] = "" result := StateToPostableAlert(alertState, appURL) require.Equal(t, appURL.String(), result.Alert.GeneratorURL.String()) @@ -76,7 +77,7 @@ func Test_StateToPostableAlert(t *testing.T) { }) t.Run("empty string if app URL is not provided", func(t *testing.T) { - alertState := randomState(tc.state) + alertState := randomTransition(eval.Normal, tc.state) alertState.Labels[alertingModels.RuleUIDLabel] = alertState.AlertRuleUID result := StateToPostableAlert(alertState, nil) require.Equal(t, "", result.Alert.GeneratorURL.String()) @@ -84,20 +85,20 @@ func Test_StateToPostableAlert(t *testing.T) { }) t.Run("Start and End timestamps should be the same", func(t *testing.T) { - alertState := randomState(tc.state) + alertState := randomTransition(eval.Normal, tc.state) result := StateToPostableAlert(alertState, appURL) require.Equal(t, strfmt.DateTime(alertState.StartsAt), result.StartsAt) require.Equal(t, strfmt.DateTime(alertState.EndsAt), result.EndsAt) }) t.Run("should copy annotations", func(t *testing.T) { - alertState := randomState(tc.state) + alertState := randomTransition(eval.Normal, tc.state) alertState.Annotations = randomMapOfStrings() result := StateToPostableAlert(alertState, appURL) require.Equal(t, models.LabelSet(alertState.Annotations), result.Annotations) t.Run("add __value_string__ if it has results", func(t *testing.T) { - alertState := randomState(tc.state) + alertState := randomTransition(eval.Normal, tc.state) alertState.Annotations = randomMapOfStrings() expectedValueString := util.GenerateShortUID() alertState.LastEvaluationString = expectedValueString @@ -119,7 +120,7 @@ func Test_StateToPostableAlert(t *testing.T) { }) t.Run("add __alertImageToken__ if there is an image token", func(t *testing.T) { - alertState := randomState(tc.state) + alertState := randomTransition(eval.Normal, tc.state) alertState.Annotations = randomMapOfStrings() alertState.Image = &ngModels.Image{Token: "test_token"} @@ -135,7 +136,7 @@ func Test_StateToPostableAlert(t *testing.T) { }) t.Run("don't add __alertImageToken__ if there's no image token", func(t *testing.T) { - alertState := randomState(tc.state) + alertState := randomTransition(eval.Normal, tc.state) alertState.Annotations = randomMapOfStrings() alertState.Image = &ngModels.Image{} @@ -151,7 +152,7 @@ func Test_StateToPostableAlert(t *testing.T) { }) t.Run("should add state reason annotation if not empty", func(t *testing.T) { - alertState := randomState(tc.state) + alertState := randomTransition(eval.Normal, tc.state) alertState.StateReason = "TEST_STATE_REASON" result := StateToPostableAlert(alertState, appURL) require.Equal(t, alertState.StateReason, result.Annotations[ngModels.StateReasonAnnotation]) @@ -160,7 +161,7 @@ func Test_StateToPostableAlert(t *testing.T) { switch tc.state { case eval.NoData: t.Run("should keep existing labels and change name", func(t *testing.T) { - alertState := randomState(tc.state) + alertState := randomTransition(eval.Normal, tc.state) alertState.Labels = randomMapOfStrings() alertName := util.GenerateShortUID() alertState.Labels[model.AlertNameLabel] = alertName @@ -177,7 +178,7 @@ func Test_StateToPostableAlert(t *testing.T) { require.Equal(t, expected, result.Labels) t.Run("should not backup original alert name if it does not exist", func(t *testing.T) { - alertState := randomState(tc.state) + alertState := randomTransition(eval.Normal, tc.state) alertState.Labels = randomMapOfStrings() delete(alertState.Labels, model.AlertNameLabel) @@ -189,7 +190,7 @@ func Test_StateToPostableAlert(t *testing.T) { }) case eval.Error: t.Run("should keep existing labels and change name", func(t *testing.T) { - alertState := randomState(tc.state) + alertState := randomTransition(eval.Normal, tc.state) alertState.Labels = randomMapOfStrings() alertName := util.GenerateShortUID() alertState.Labels[model.AlertNameLabel] = alertName @@ -206,7 +207,7 @@ func Test_StateToPostableAlert(t *testing.T) { require.Equal(t, expected, result.Labels) t.Run("should not backup original alert name if it does not exist", func(t *testing.T) { - alertState := randomState(tc.state) + alertState := randomTransition(eval.Normal, tc.state) alertState.Labels = randomMapOfStrings() delete(alertState.Labels, model.AlertNameLabel) @@ -218,7 +219,7 @@ func Test_StateToPostableAlert(t *testing.T) { }) default: t.Run("should copy labels as is", func(t *testing.T) { - alertState := randomState(tc.state) + alertState := randomTransition(eval.Normal, tc.state) alertState.Labels = randomMapOfStrings() result := StateToPostableAlert(alertState, appURL) require.Equal(t, models.LabelSet(alertState.Labels), result.Labels) @@ -228,6 +229,52 @@ func Test_StateToPostableAlert(t *testing.T) { } } +func TestStateToPostableAlertFromNodataError(t *testing.T) { + appURL := &url.URL{ + Scheme: "http:", + Host: fmt.Sprintf("host-%d", rand.Int()), + Path: fmt.Sprintf("path-%d", rand.Int()), + } + + standardLabels := models.LabelSet{model.AlertNameLabel: "name"} + noDataLabels := models.LabelSet{Rulename: "name", model.AlertNameLabel: NoDataAlertName} + errorLabels := models.LabelSet{Rulename: "name", model.AlertNameLabel: ErrorAlertName} + + testCases := []struct { + name string + resolved bool + from eval.State + to eval.State + expectedLabels models.LabelSet + }{ + // These are the important cases. + {name: "from NoData to Normal resolved", resolved: true, from: eval.NoData, to: eval.Normal, expectedLabels: noDataLabels}, + {name: "from Error to Normal resolved", resolved: true, from: eval.Error, to: eval.Normal, expectedLabels: errorLabels}, + + // Regressions. + {name: "from NoData to Normal unresolved", resolved: false, from: eval.NoData, to: eval.Normal, expectedLabels: standardLabels}, + {name: "from Error to Normal unresolved", resolved: false, from: eval.Error, to: eval.Normal, expectedLabels: standardLabels}, + {name: "from NoData to Alerting unresolved", resolved: false, from: eval.NoData, to: eval.Alerting, expectedLabels: standardLabels}, + {name: "from Error to Alerting unresolved", resolved: false, from: eval.Error, to: eval.Alerting, expectedLabels: standardLabels}, + {name: "from NoData to Pending unresolved", resolved: false, from: eval.NoData, to: eval.Pending, expectedLabels: standardLabels}, + {name: "from Error to Pending unresolved", resolved: false, from: eval.Error, to: eval.Pending, expectedLabels: standardLabels}, + {name: "from NoData to NoData unresolved", resolved: false, from: eval.NoData, to: eval.NoData, expectedLabels: noDataLabels}, + {name: "from Error to NoData unresolved", resolved: false, from: eval.Error, to: eval.NoData, expectedLabels: noDataLabels}, + {name: "from NoData to Error unresolved", resolved: false, from: eval.NoData, to: eval.Error, expectedLabels: errorLabels}, + {name: "from Error to Error unresolved", resolved: false, from: eval.Error, to: eval.Error, expectedLabels: errorLabels}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + alertState := randomTransition(tc.from, tc.to) + alertState.Resolved = tc.resolved + alertState.Labels = data.Labels(standardLabels) + result := StateToPostableAlert(alertState, appURL) + require.Equal(t, tc.expectedLabels, result.Labels) + }) + } +} + func Test_FromAlertsStateToStoppedAlert(t *testing.T) { appURL := &url.URL{ Scheme: "http:", @@ -239,10 +286,7 @@ func Test_FromAlertsStateToStoppedAlert(t *testing.T) { states := make([]StateTransition, 0, len(evalStates)*len(evalStates)) for _, to := range evalStates { for _, from := range evalStates { - states = append(states, StateTransition{ - State: randomState(to), - PreviousState: from, - }) + states = append(states, randomTransition(from, to)) } } @@ -254,7 +298,7 @@ func Test_FromAlertsStateToStoppedAlert(t *testing.T) { if !(s.PreviousState == eval.Alerting || s.PreviousState == eval.Error || s.PreviousState == eval.NoData) { continue } - alert := StateToPostableAlert(s.State, appURL) + alert := StateToPostableAlert(s, appURL) alert.EndsAt = strfmt.DateTime(clk.Now()) expected = append(expected, *alert) } @@ -285,17 +329,20 @@ func randomTimeInPast() time.Time { return time.Now().Add(-randomDuration()) } -func randomState(evalState eval.State) *State { - return &State{ - State: evalState, - AlertRuleUID: util.GenerateShortUID(), - StartsAt: time.Now(), - EndsAt: randomTimeInFuture(), - LastEvaluationTime: randomTimeInPast(), - EvaluationDuration: randomDuration(), - LastSentAt: randomTimeInPast(), - Annotations: make(map[string]string), - Labels: make(map[string]string), - Values: make(map[string]float64), +func randomTransition(from, to eval.State) StateTransition { + return StateTransition{ + PreviousState: from, + State: &State{ + State: to, + AlertRuleUID: util.GenerateShortUID(), + StartsAt: time.Now(), + EndsAt: randomTimeInFuture(), + LastEvaluationTime: randomTimeInPast(), + EvaluationDuration: randomDuration(), + LastSentAt: randomTimeInPast(), + Annotations: make(map[string]string), + Labels: make(map[string]string), + Values: make(map[string]float64), + }, } } diff --git a/pkg/services/ngalert/state/historian/annotation.go b/pkg/services/ngalert/state/historian/annotation.go index e4ae3b9f8d3d5..a9a2efd63f3f7 100644 --- a/pkg/services/ngalert/state/historian/annotation.go +++ b/pkg/services/ngalert/state/historian/annotation.go @@ -111,8 +111,8 @@ func (h *AnnotationBackend) Query(ctx context.Context, query ngmodels.HistoryQue q := annotations.ItemQuery{ AlertID: rule.ID, OrgID: query.OrgID, - From: query.From.Unix(), - To: query.To.Unix(), + From: query.From.UnixMilli(), + To: query.To.UnixMilli(), SignedInUser: query.SignedInUser, } items, err := h.store.Find(ctx, &q) @@ -173,12 +173,12 @@ func (h *AnnotationBackend) Query(ctx context.Context, query ngmodels.HistoryQue func buildAnnotations(rule history_model.RuleMeta, states []state.StateTransition, logger log.Logger) []annotations.Item { items := make([]annotations.Item, 0, len(states)) for _, state := range states { - if !shouldRecordAnnotation(state) { + if !ShouldRecordAnnotation(state) { continue } logger.Debug("Alert state changed creating annotation", "newState", state.Formatted(), "oldState", state.PreviousFormatted()) - annotationText, annotationData := buildAnnotationTextAndData(rule, state.State) + annotationText, annotationData := BuildAnnotationTextAndData(rule, state.State) item := annotations.Item{ AlertID: rule.ID, @@ -195,7 +195,7 @@ func buildAnnotations(rule history_model.RuleMeta, states []state.StateTransitio return items } -func buildAnnotationTextAndData(rule history_model.RuleMeta, currentState *state.State) (string, *simplejson.Json) { +func BuildAnnotationTextAndData(rule history_model.RuleMeta, currentState *state.State) (string, *simplejson.Json) { jsonData := simplejson.New() var value string diff --git a/pkg/services/ngalert/state/historian/annotation_test.go b/pkg/services/ngalert/state/historian/annotation_test.go index 4108323cfedb9..0192d3216590b 100644 --- a/pkg/services/ngalert/state/historian/annotation_test.go +++ b/pkg/services/ngalert/state/historian/annotation_test.go @@ -47,6 +47,25 @@ func TestAnnotationHistorian(t *testing.T) { } }) + t.Run("annotation queries send expected item query", func(t *testing.T) { + store := &interceptingAnnotationStore{} + anns := createTestAnnotationSutWithStore(t, store) + now := time.Now().UTC() + + q := models.HistoryQuery{ + RuleUID: "my-rule", + OrgID: 1, + From: now.Add(-10 * time.Second), + To: now, + } + _, err := anns.Query(context.Background(), q) + + require.NoError(t, err) + query := store.lastQuery + require.Equal(t, now.UnixMilli(), query.To) + require.Equal(t, now.Add(-10*time.Second).UnixMilli(), query.From) + }) + t.Run("writing state transitions as annotations succeeds", func(t *testing.T) { anns := createTestAnnotationBackendSut(t) rule := createTestRule() @@ -104,6 +123,16 @@ func createTestAnnotationBackendSut(t *testing.T) *AnnotationBackend { return createTestAnnotationBackendSutWithMetrics(t, metrics.NewHistorianMetrics(prometheus.NewRegistry(), metrics.Subsystem)) } +func createTestAnnotationSutWithStore(t *testing.T, annotations AnnotationStore) *AnnotationBackend { + t.Helper() + met := metrics.NewHistorianMetrics(prometheus.NewRegistry(), metrics.Subsystem) + rules := fakes.NewRuleStore(t) + rules.Rules[1] = []*models.AlertRule{ + models.AlertRuleGen(withOrgID(1), withUID("my-rule"))(), + } + return NewAnnotationBackend(annotations, rules, met) +} + func createTestAnnotationBackendSutWithMetrics(t *testing.T, met *metrics.Historian) *AnnotationBackend { t.Helper() fakeAnnoRepo := annotationstest.NewFakeAnnotationsRepo() @@ -213,3 +242,16 @@ func assertValidJSON(t *testing.T, j *simplejson.Json) string { require.NoError(t, err) return string(ser) } + +type interceptingAnnotationStore struct { + lastQuery *annotations.ItemQuery +} + +func (i *interceptingAnnotationStore) Find(ctx context.Context, query *annotations.ItemQuery) ([]*annotations.ItemDTO, error) { + i.lastQuery = query + return []*annotations.ItemDTO{}, nil +} + +func (i *interceptingAnnotationStore) Save(ctx context.Context, panel *PanelKey, annotations []annotations.Item, orgID int64, logger log.Logger) error { + return nil +} diff --git a/pkg/services/ngalert/state/historian/core.go b/pkg/services/ngalert/state/historian/core.go index bdaba3368078b..cbeb4f4520538 100644 --- a/pkg/services/ngalert/state/historian/core.go +++ b/pkg/services/ngalert/state/historian/core.go @@ -34,13 +34,19 @@ func shouldRecord(transition state.StateTransition) bool { return true } -// shouldRecordAnnotation returns true if an annotation should be created for a given state transition. +// ShouldRecordAnnotation returns true if an annotation should be created for a given state transition. // This is stricter than shouldRecord to avoid cluttering panels with state transitions. -func shouldRecordAnnotation(t state.StateTransition) bool { +func ShouldRecordAnnotation(t state.StateTransition) bool { if !shouldRecord(t) { return false } + // Do not log transitions when keeping last state + toKeepLast := strings.Contains(t.StateReason, models.StateReasonKeepLast) && !strings.Contains(t.PreviousStateReason, models.StateReasonKeepLast) + if toKeepLast { + return false + } + // Do not record transitions between Normal and Normal (NoData) if t.State.State == eval.Normal && t.PreviousState == eval.Normal { if (t.State.StateReason == "" && t.PreviousStateReason == models.StateReasonNoData) || diff --git a/pkg/services/ngalert/state/historian/core_test.go b/pkg/services/ngalert/state/historian/core_test.go index 857aaf10c6f92..f798bd26cef46 100644 --- a/pkg/services/ngalert/state/historian/core_test.go +++ b/pkg/services/ngalert/state/historian/core_test.go @@ -110,8 +110,8 @@ func TestShouldRecordAnnotation(t *testing.T) { forward := transition(eval.Normal, "", eval.Normal, models.StateReasonNoData) backward := transition(eval.Normal, models.StateReasonNoData, eval.Normal, "") - require.False(t, shouldRecordAnnotation(forward), "Normal -> Normal(NoData) should be false") - require.False(t, shouldRecordAnnotation(backward), "Normal(NoData) -> Normal should be false") + require.False(t, ShouldRecordAnnotation(forward), "Normal -> Normal(NoData) should be false") + require.False(t, ShouldRecordAnnotation(backward), "Normal(NoData) -> Normal should be false") }) t.Run("other Normal transitions involving NoData still recorded", func(t *testing.T) { @@ -121,11 +121,11 @@ func TestShouldRecordAnnotation(t *testing.T) { errorBackward := transition(eval.Normal, models.StateReasonError, eval.Normal, models.StateReasonNoData) missingSeriesBackward := transition(eval.Normal, models.StateReasonMissingSeries, eval.Normal, models.StateReasonNoData) - require.True(t, shouldRecordAnnotation(pauseForward), "Normal(NoData) -> Normal(Paused) should be true") - require.True(t, shouldRecordAnnotation(pauseBackward), "Normal(Paused) -> Normal(NoData) should be true") - require.True(t, shouldRecordAnnotation(errorForward), "Normal(NoData) -> Normal(Error) should be true") - require.True(t, shouldRecordAnnotation(errorBackward), "Normal(Error) -> Normal(NoData) should be true") - require.True(t, shouldRecordAnnotation(missingSeriesBackward), "Normal(MissingSeries) -> Normal(NoData) should be true") + require.True(t, ShouldRecordAnnotation(pauseForward), "Normal(NoData) -> Normal(Paused) should be true") + require.True(t, ShouldRecordAnnotation(pauseBackward), "Normal(Paused) -> Normal(NoData) should be true") + require.True(t, ShouldRecordAnnotation(errorForward), "Normal(NoData) -> Normal(Error) should be true") + require.True(t, ShouldRecordAnnotation(errorBackward), "Normal(Error) -> Normal(NoData) should be true") + require.True(t, ShouldRecordAnnotation(missingSeriesBackward), "Normal(MissingSeries) -> Normal(NoData) should be true") }) t.Run("respects filters in shouldRecord()", func(t *testing.T) { @@ -133,19 +133,19 @@ func TestShouldRecordAnnotation(t *testing.T) { unpause := transition(eval.Normal, models.StateReasonPaused, eval.Normal, "") afterUpdate := transition(eval.Normal, models.StateReasonUpdated, eval.Normal, "") - require.False(t, shouldRecordAnnotation(missingSeries), "Normal -> Normal(MissingSeries) should be false") - require.False(t, shouldRecordAnnotation(unpause), "Normal(Paused) -> Normal should be false") - require.False(t, shouldRecordAnnotation(afterUpdate), "Normal(Updated) -> Normal should be false") + require.False(t, ShouldRecordAnnotation(missingSeries), "Normal -> Normal(MissingSeries) should be false") + require.False(t, ShouldRecordAnnotation(unpause), "Normal(Paused) -> Normal should be false") + require.False(t, ShouldRecordAnnotation(afterUpdate), "Normal(Updated) -> Normal should be false") // Smoke test a few basic ones, exhaustive tests for shouldRecord() already exist elsewhere. basicPending := transition(eval.Normal, "", eval.Pending, "") basicAlerting := transition(eval.Pending, "", eval.Alerting, "") basicResolve := transition(eval.Alerting, "", eval.Normal, "") basicError := transition(eval.Normal, "", eval.Error, "") - require.True(t, shouldRecordAnnotation(basicPending), "Normal -> Pending should be true") - require.True(t, shouldRecordAnnotation(basicAlerting), "Pending -> Alerting should be true") - require.True(t, shouldRecordAnnotation(basicResolve), "Alerting -> Normal should be true") - require.True(t, shouldRecordAnnotation(basicError), "Normal -> Error should be true") + require.True(t, ShouldRecordAnnotation(basicPending), "Normal -> Pending should be true") + require.True(t, ShouldRecordAnnotation(basicAlerting), "Pending -> Alerting should be true") + require.True(t, ShouldRecordAnnotation(basicResolve), "Alerting -> Normal should be true") + require.True(t, ShouldRecordAnnotation(basicError), "Normal -> Error should be true") }) } diff --git a/pkg/services/ngalert/state/historian/loki.go b/pkg/services/ngalert/state/historian/loki.go index 5275531e806a4..1c4c8b071bb02 100644 --- a/pkg/services/ngalert/state/historian/loki.go +++ b/pkg/services/ngalert/state/historian/loki.go @@ -10,11 +10,11 @@ import ( "github.com/benbjohnson/clock" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/weaveworks/common/http/client" "go.opentelemetry.io/otel/trace" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert/client" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -73,7 +73,7 @@ func (h *RemoteLokiBackend) TestConnection(ctx context.Context) error { // Record writes a number of state transitions for a given rule to an external Loki instance. func (h *RemoteLokiBackend) Record(ctx context.Context, rule history_model.RuleMeta, states []state.StateTransition) <-chan error { logger := h.log.FromContext(ctx) - logStream := statesToStream(rule, states, h.externalLabels, logger) + logStream := StatesToStream(rule, states, h.externalLabels, logger) errCh := make(chan error, 1) if len(logStream.Values) == 0 { @@ -112,7 +112,7 @@ func (h *RemoteLokiBackend) Record(ctx context.Context, rule history_model.RuleM // Query retrieves state history entries from an external Loki instance and formats the results into a dataframe. func (h *RemoteLokiBackend) Query(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { - logQL, err := buildLogQuery(query) + logQL, err := BuildLogQuery(query) if err != nil { return nil, err } @@ -200,7 +200,7 @@ func merge(res QueryRes, ruleUID string) (*data.Frame, error) { if minElStreamIdx == -1 { break } - var entry lokiEntry + var entry LokiEntry err := json.Unmarshal([]byte(minEl.V), &entry) if err != nil { return nil, fmt.Errorf("failed to unmarshal entry: %w", err) @@ -231,7 +231,7 @@ func merge(res QueryRes, ruleUID string) (*data.Frame, error) { return frame, nil } -func statesToStream(rule history_model.RuleMeta, states []state.StateTransition, externalLabels map[string]string, logger log.Logger) Stream { +func StatesToStream(rule history_model.RuleMeta, states []state.StateTransition, externalLabels map[string]string, logger log.Logger) Stream { labels := mergeLabels(make(map[string]string), externalLabels) // System-defined labels take precedence over user-defined external labels. labels[StateHistoryLabelKey] = StateHistoryLabelValue @@ -246,7 +246,7 @@ func statesToStream(rule history_model.RuleMeta, states []state.StateTransition, } sanitizedLabels := removePrivateLabels(state.Labels) - entry := lokiEntry{ + entry := LokiEntry{ SchemaVersion: 1, Previous: state.PreviousFormatted(), Current: state.Formatted(), @@ -292,7 +292,7 @@ func (h *RemoteLokiBackend) recordStreams(ctx context.Context, streams []Stream, return nil } -type lokiEntry struct { +type LokiEntry struct { SchemaVersion int `json:"schemaVersion"` Previous string `json:"previous"` Current string `json:"current"` @@ -322,7 +322,7 @@ func jsonifyRow(line string) (json.RawMessage, error) { // Ser/deser to validate the contents of the log line before shipping it forward. // TODO: We may want to remove this in the future, as we already have the value in the form of a []byte, and json.RawMessage is also a []byte. // TODO: Though, if the log line does not contain valid JSON, this can cause problems later on when rendering the dataframe. - var entry lokiEntry + var entry LokiEntry if err := json.Unmarshal([]byte(line), &entry); err != nil { return nil, err } @@ -366,7 +366,7 @@ func isValidOperator(op string) bool { return false } -func buildLogQuery(query models.HistoryQuery) (string, error) { +func BuildLogQuery(query models.HistoryQuery) (string, error) { selectors, err := buildSelectors(query) if err != nil { return "", fmt.Errorf("failed to build the provided selectors: %w", err) diff --git a/pkg/services/ngalert/state/historian/loki_http.go b/pkg/services/ngalert/state/historian/loki_http.go index 003904e987132..44a7c0044eb37 100644 --- a/pkg/services/ngalert/state/historian/loki_http.go +++ b/pkg/services/ngalert/state/historian/loki_http.go @@ -12,9 +12,9 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert/client" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/setting" - "github.com/weaveworks/common/http/client" ) const defaultPageSize = 1000 @@ -40,6 +40,7 @@ type LokiConfig struct { TenantID string ExternalLabels map[string]string Encoder encoder + MaxQueryLength time.Duration } func NewLokiConfig(cfg setting.UnifiedAlertingStateHistorySettings) (LokiConfig, error) { @@ -74,6 +75,7 @@ func NewLokiConfig(cfg setting.UnifiedAlertingStateHistorySettings) (LokiConfig, BasicAuthPassword: cfg.LokiBasicAuthPassword, TenantID: cfg.LokiTenantID, ExternalLabels: cfg.ExternalLabels, + MaxQueryLength: cfg.LokiMaxQueryLength, // Snappy-compressed protobuf is the default, same goes for Promtail. Encoder: SnappyProtoEncoder{}, }, nil @@ -193,26 +195,20 @@ func (c *HttpLokiClient) Push(ctx context.Context, s []Stream) error { c.metrics.BytesWritten.Add(float64(len(enc))) req = req.WithContext(ctx) resp, err := c.client.Do(req) - if resp != nil { - defer func() { - if err := resp.Body.Close(); err != nil { - c.log.Warn("Failed to close response body", "err", err) - } - }() - } if err != nil { return fmt.Errorf("failed to send request: %w", err) } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - byt, _ := io.ReadAll(resp.Body) - if len(byt) > 0 { - c.log.Error("Error response from Loki", "response", string(byt), "status", resp.StatusCode) - } else { - c.log.Error("Error response from Loki with an empty body", "status", resp.StatusCode) + defer func() { + if err := resp.Body.Close(); err != nil { + c.log.Warn("Failed to close response body", "err", err) } - return fmt.Errorf("received a non-200 response from loki, status: %d", resp.StatusCode) + }() + + _, err = c.handleLokiResponse(resp) + if err != nil { + return err } + return nil } @@ -231,6 +227,7 @@ func (c *HttpLokiClient) RangeQuery(ctx context.Context, logQL string, start, en if start > end { return QueryRes{}, fmt.Errorf("start time cannot be after end time") } + start, end = ClampRange(start, end, c.cfg.MaxQueryLength.Nanoseconds()) if limit < 1 { limit = defaultPageSize } @@ -261,23 +258,15 @@ func (c *HttpLokiClient) RangeQuery(ctx context.Context, logQL string, start, en if err != nil { return QueryRes{}, fmt.Errorf("error executing request: %w", err) } - defer func() { - _ = res.Body.Close() + if err := res.Body.Close(); err != nil { + c.log.Warn("Failed to close response body", "err", err) + } }() - data, err := io.ReadAll(res.Body) + data, err := c.handleLokiResponse(res) if err != nil { - return QueryRes{}, fmt.Errorf("error reading request response: %w", err) - } - - if res.StatusCode < 200 || res.StatusCode >= 300 { - if len(data) > 0 { - c.log.Error("Error response from Loki", "response", string(data), "status", res.StatusCode) - } else { - c.log.Error("Error response from Loki with an empty body", "status", res.StatusCode) - } - return QueryRes{}, fmt.Errorf("received a non-200 response from loki, status: %d", res.StatusCode) + return QueryRes{}, err } result := QueryRes{} @@ -297,3 +286,36 @@ type QueryRes struct { type QueryData struct { Result []Stream `json:"result"` } + +func (c *HttpLokiClient) handleLokiResponse(res *http.Response) ([]byte, error) { + if res == nil { + return nil, fmt.Errorf("response is nil") + } + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("error reading request response: %w", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + if len(data) > 0 { + c.log.Error("Error response from Loki", "response", string(data), "status", res.StatusCode) + } else { + c.log.Error("Error response from Loki with an empty body", "status", res.StatusCode) + } + return nil, fmt.Errorf("received a non-200 response from loki, status: %d", res.StatusCode) + } + + return data, nil +} + +// ClampRange ensures that the time range is within the configured maximum query length. +func ClampRange(start, end, maxTimeRange int64) (newStart int64, newEnd int64) { + newStart, newEnd = start, end + + if maxTimeRange != 0 && end-start > maxTimeRange { + newStart = end - maxTimeRange + } + + return newStart, newEnd +} diff --git a/pkg/services/ngalert/state/historian/loki_http_test.go b/pkg/services/ngalert/state/historian/loki_http_test.go index 764f9e61f82bb..961f323809784 100644 --- a/pkg/services/ngalert/state/historian/loki_http_test.go +++ b/pkg/services/ngalert/state/historian/loki_http_test.go @@ -11,11 +11,11 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/services/ngalert/client" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/setting" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" - "github.com/weaveworks/common/http/client" "github.com/grafana/grafana/pkg/infra/log" ) @@ -338,6 +338,49 @@ func TestStream(t *testing.T) { }) } +func TestClampRange(t *testing.T) { + tc := []struct { + name string + oldRange []int64 + max int64 + newRange []int64 + }{ + { + name: "clamps start value if max is smaller than range", + oldRange: []int64{5, 10}, + max: 1, + newRange: []int64{9, 10}, + }, + { + name: "returns same values if max is greater than range", + oldRange: []int64{5, 10}, + max: 20, + newRange: []int64{5, 10}, + }, + { + name: "returns same values if max is equal to range", + oldRange: []int64{5, 10}, + max: 5, + newRange: []int64{5, 10}, + }, + { + name: "returns same values if max is zero", + oldRange: []int64{5, 10}, + max: 0, + newRange: []int64{5, 10}, + }, + } + + for _, c := range tc { + t.Run(c.name, func(t *testing.T) { + start, end := ClampRange(c.oldRange[0], c.oldRange[1], c.max) + + require.Equal(t, c.newRange[0], start) + require.Equal(t, c.newRange[1], end) + }) + } +} + func createTestLokiClient(req client.Requester) *HttpLokiClient { url, _ := url.Parse("http://some.url") cfg := LokiConfig{ diff --git a/pkg/services/ngalert/state/historian/loki_test.go b/pkg/services/ngalert/state/historian/loki_test.go index 02971c8c0bbfc..caa0db0aa4d56 100644 --- a/pkg/services/ngalert/state/historian/loki_test.go +++ b/pkg/services/ngalert/state/historian/loki_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert/client" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -21,7 +22,6 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/require" - "github.com/weaveworks/common/http/client" ) func TestRemoteLokiBackend(t *testing.T) { @@ -31,7 +31,7 @@ func TestRemoteLokiBackend(t *testing.T) { l := log.NewNopLogger() states := singleFromNormal(&state.State{State: eval.Normal}) - res := statesToStream(rule, states, nil, l) + res := StatesToStream(rule, states, nil, l) require.Empty(t, res.Values) }) @@ -41,7 +41,7 @@ func TestRemoteLokiBackend(t *testing.T) { l := log.NewNopLogger() states := singleFromNormal(&state.State{State: eval.Error, Error: fmt.Errorf("oh no")}) - res := statesToStream(rule, states, nil, l) + res := StatesToStream(rule, states, nil, l) entry := requireSingleEntry(t, res) require.Contains(t, entry.Error, "oh no") @@ -52,7 +52,7 @@ func TestRemoteLokiBackend(t *testing.T) { l := log.NewNopLogger() states := singleFromNormal(&state.State{State: eval.NoData}) - res := statesToStream(rule, states, nil, l) + res := StatesToStream(rule, states, nil, l) _ = requireSingleEntry(t, res) }) @@ -65,7 +65,7 @@ func TestRemoteLokiBackend(t *testing.T) { Labels: data.Labels{"a": "b"}, }) - res := statesToStream(rule, states, nil, l) + res := StatesToStream(rule, states, nil, l) exp := map[string]string{ StateHistoryLabelKey: StateHistoryLabelValue, @@ -84,7 +84,7 @@ func TestRemoteLokiBackend(t *testing.T) { Labels: data.Labels{"__private__": "b"}, }) - res := statesToStream(rule, states, nil, l) + res := StatesToStream(rule, states, nil, l) require.NotContains(t, res.Stream, "__private__") }) @@ -97,7 +97,8 @@ func TestRemoteLokiBackend(t *testing.T) { Labels: data.Labels{"a": "b"}, }) - res := statesToStream(rule, states, nil, l) + res := StatesToStream(rule, states, nil, l) + entry := requireSingleEntry(t, res) require.Equal(t, rule.Title, entry.RuleTitle) @@ -113,7 +114,7 @@ func TestRemoteLokiBackend(t *testing.T) { Labels: data.Labels{"statelabel": "labelvalue"}, }) - res := statesToStream(rule, states, nil, l) + res := StatesToStream(rule, states, nil, l) entry := requireSingleEntry(t, res) require.Contains(t, entry.InstanceLabels, "statelabel") @@ -131,7 +132,7 @@ func TestRemoteLokiBackend(t *testing.T) { }, }) - res := statesToStream(rule, states, nil, l) + res := StatesToStream(rule, states, nil, l) entry := requireSingleEntry(t, res) require.Len(t, entry.InstanceLabels, 3) @@ -145,7 +146,7 @@ func TestRemoteLokiBackend(t *testing.T) { Values: map[string]float64{"A": 2.0, "B": 5.5}, }) - res := statesToStream(rule, states, nil, l) + res := StatesToStream(rule, states, nil, l) entry := requireSingleEntry(t, res) require.NotNil(t, entry.Values) @@ -164,7 +165,7 @@ func TestRemoteLokiBackend(t *testing.T) { Labels: data.Labels{"a": "b"}, }) - res := statesToStream(rule, states, nil, l) + res := StatesToStream(rule, states, nil, l) entry := requireSingleEntry(t, res) require.Equal(t, rule.Condition, entry.Condition) @@ -182,7 +183,7 @@ func TestRemoteLokiBackend(t *testing.T) { }, }) - res := statesToStream(rule, states, nil, l) + res := StatesToStream(rule, states, nil, l) entry := requireSingleEntry(t, res) exp := labelFingerprint(states[0].Labels) @@ -281,7 +282,7 @@ func TestRemoteLokiBackend(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - res, err := buildLogQuery(tc.query) + res, err := BuildLogQuery(tc.query) require.NoError(t, err) require.Equal(t, tc.exp, res) }) @@ -537,15 +538,15 @@ func createTestRule() history_model.RuleMeta { } } -func requireSingleEntry(t *testing.T, res Stream) lokiEntry { +func requireSingleEntry(t *testing.T, res Stream) LokiEntry { require.Len(t, res.Values, 1) return requireEntry(t, res.Values[0]) } -func requireEntry(t *testing.T, row Sample) lokiEntry { +func requireEntry(t *testing.T, row Sample) LokiEntry { t.Helper() - var entry lokiEntry + var entry LokiEntry err := json.Unmarshal([]byte(row.V), &entry) require.NoError(t, err) return entry diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index 19add64d5a1b0..da6d614ff5ef5 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -3,10 +3,10 @@ package state import ( "context" "net/url" + "strconv" "time" "github.com/benbjohnson/clock" - "github.com/grafana/dskit/concurrency" "github.com/grafana/grafana-plugin-sdk-go/data" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -29,6 +29,11 @@ type AlertInstanceManager interface { GetStatesForRuleUID(orgID int64, alertRuleUID string) []*State } +type StatePersister interface { + Async(ctx context.Context, cache *cache) + Sync(ctx context.Context, span trace.Span, states, staleStates []StateTransition) +} + type Manager struct { log log.Logger metrics *metrics.State @@ -44,8 +49,10 @@ type Manager struct { externalURL *url.URL doNotSaveNormalState bool - maxStateSaveConcurrency int applyNoDataAndErrorToAllStates bool + rulesPerRuleGroupLimit int64 + + persister StatePersister } type ManagerCfg struct { @@ -59,16 +66,16 @@ type ManagerCfg struct { DoNotSaveNormalState bool // MaxStateSaveConcurrency controls the number of goroutines (per rule) that can save alert state in parallel. MaxStateSaveConcurrency int - // ApplyNoDataAndErrorToAllStates makes state manager to apply exceptional results (NoData and Error) // to all states when corresponding execution in the rule definition is set to either `Alerting` or `OK` ApplyNoDataAndErrorToAllStates bool + RulesPerRuleGroupLimit int64 Tracer tracing.Tracer Log log.Logger } -func NewManager(cfg ManagerCfg) *Manager { +func NewManager(cfg ManagerCfg, statePersister StatePersister) *Manager { // Metrics for the cache use a collector, so they need access to the register directly. c := newCache() if cfg.Metrics != nil { @@ -86,8 +93,9 @@ func NewManager(cfg ManagerCfg) *Manager { clock: cfg.Clock, externalURL: cfg.ExternalURL, doNotSaveNormalState: cfg.DoNotSaveNormalState, - maxStateSaveConcurrency: cfg.MaxStateSaveConcurrency, applyNoDataAndErrorToAllStates: cfg.ApplyNoDataAndErrorToAllStates, + rulesPerRuleGroupLimit: cfg.RulesPerRuleGroupLimit, + persister: statePersister, tracer: cfg.Tracer, } @@ -98,6 +106,11 @@ func NewManager(cfg ManagerCfg) *Manager { return m } +func (st *Manager) Run(ctx context.Context) error { + st.persister.Async(ctx, st.cache) + return nil +} + func (st *Manager) Warm(ctx context.Context, rulesReader RuleReader) { if st.instanceStore == nil { st.log.Info("Skip warming the state because instance store is not configured") @@ -124,8 +137,23 @@ func (st *Manager) Warm(ctx context.Context, rulesReader RuleReader) { } ruleByUID := make(map[string]*ngModels.AlertRule, len(alertRules)) + groupSizes := make(map[string]int64) for _, rule := range alertRules { ruleByUID[rule.UID] = rule + groupSizes[rule.RuleGroup] += 1 + } + + // Emit a warning if we detect a large group. + // We will not enforce this here, but it's convenient to emit the warning here as we load up all the rules. + for name, size := range groupSizes { + if st.rulesPerRuleGroupLimit > 0 && size > st.rulesPerRuleGroupLimit { + st.log.Warn( + "Large rule group was loaded. Large groups are discouraged and changes to them may be disallowed in the future.", + "limit", st.rulesPerRuleGroupLimit, + "actual", size, + "group", name, + ) + } } orgStates := make(map[string]*ruleStates, len(ruleByUID)) @@ -158,6 +186,14 @@ func (st *Manager) Warm(ctx context.Context, rulesReader RuleReader) { if err != nil { st.log.Error("Error getting cacheId for entry", "error", err) } + var resultFp data.Fingerprint + if entry.ResultFingerprint != "" { + fp, err := strconv.ParseUint(entry.ResultFingerprint, 16, 64) + if err != nil { + st.log.Error("Failed to parse result fingerprint of alert instance", "error", err, "ruleUID", entry.RuleUID) + } + resultFp = data.Fingerprint(fp) + } rulesStates.states[cacheID] = &State{ AlertRuleUID: entry.RuleUID, OrgID: entry.RuleOrgID, @@ -170,6 +206,7 @@ func (st *Manager) Warm(ctx context.Context, rulesReader RuleReader) { EndsAt: entry.CurrentStateEnd, LastEvaluationTime: entry.LastEvalTime, Annotations: ruleForEntry.Annotations, + ResultFingerprint: resultFp, } statesCount++ } @@ -207,7 +244,7 @@ func (st *Manager) DeleteStateByRuleUID(ctx context.Context, ruleKey ngModels.Al s.SetNormal(reason, startsAt, now) // Set Resolved property so the scheduler knows to send a postable alert // to Alertmanager. - s.Resolved = oldState == eval.Alerting + s.Resolved = oldState == eval.Alerting || oldState == eval.Error || oldState == eval.NoData s.LastEvaluationTime = now s.Values = map[string]float64{} transitions = append(transitions, StateTransition{ @@ -269,16 +306,7 @@ func (st *Manager) ProcessEvalResults(ctx context.Context, evaluatedAt time.Time )) staleStates := st.deleteStaleStatesFromCache(ctx, logger, evaluatedAt, alertRule) - st.deleteAlertStates(tracingCtx, logger, staleStates) - - if len(staleStates) > 0 { - span.AddEvent("deleted stale states", trace.WithAttributes( - attribute.Int64("state_transitions", int64(len(staleStates))), - )) - } - - st.saveAlertStates(tracingCtx, logger, states...) - span.AddEvent("updated database") + st.persister.Sync(tracingCtx, span, states, staleStates) allChanges := append(states, staleStates...) if st.historian != nil { @@ -288,14 +316,14 @@ func (st *Manager) ProcessEvalResults(ctx context.Context, evaluatedAt time.Time } func (st *Manager) setNextStateForRule(ctx context.Context, alertRule *ngModels.AlertRule, results eval.Results, extraLabels data.Labels, logger log.Logger) []StateTransition { - if st.applyNoDataAndErrorToAllStates && results.IsNoData() && (alertRule.NoDataState == ngModels.Alerting || alertRule.NoDataState == ngModels.OK) { // If it is no data, check the mapping and switch all results to the new state + if st.applyNoDataAndErrorToAllStates && results.IsNoData() && (alertRule.NoDataState == ngModels.Alerting || alertRule.NoDataState == ngModels.OK || alertRule.NoDataState == ngModels.KeepLast) { // If it is no data, check the mapping and switch all results to the new state // TODO aggregate UID of datasources that returned NoData into one and provide as auxiliary info, probably annotation transitions := st.setNextStateForAll(ctx, alertRule, results[0], logger) if len(transitions) > 0 { return transitions // if there are no current states for the rule. Create ones for each result } } - if st.applyNoDataAndErrorToAllStates && results.IsError() && (alertRule.ExecErrState == ngModels.AlertingErrState || alertRule.ExecErrState == ngModels.OkErrState) { + if st.applyNoDataAndErrorToAllStates && results.IsError() && (alertRule.ExecErrState == ngModels.AlertingErrState || alertRule.ExecErrState == ngModels.OkErrState || alertRule.ExecErrState == ngModels.KeepLastErrState) { // TODO squash all errors into one, and provide as annotation transitions := st.setNextStateForAll(ctx, alertRule, results[0], logger) if len(transitions) > 0 { @@ -324,6 +352,7 @@ func (st *Manager) setNextStateForAll(ctx context.Context, alertRule *ngModels.A // Set the current state based on evaluation results func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRule, currentState *State, result eval.Result, logger log.Logger) StateTransition { start := st.clock.Now() + currentState.LastEvaluationTime = result.EvaluatedAt currentState.EvaluationDuration = result.EvaluationDuration currentState.Results = append(currentState.Results, Evaluation{ @@ -364,10 +393,10 @@ func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRu switch result.State { case eval.Normal: logger.Debug("Setting next state", "handler", "resultNormal") - resultNormal(currentState, alertRule, result, logger) + resultNormal(currentState, alertRule, result, logger, "") case eval.Alerting: logger.Debug("Setting next state", "handler", "resultAlerting") - resultAlerting(currentState, alertRule, result, logger) + resultAlerting(currentState, alertRule, result, logger, "") case eval.Error: logger.Debug("Setting next state", "handler", "resultError") resultError(currentState, alertRule, result, logger) @@ -384,7 +413,7 @@ func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRu if currentState.State != result.State && result.State != eval.Normal && result.State != eval.Alerting { - currentState.StateReason = result.State.String() + currentState.StateReason = resultStateReason(result, alertRule) } // Set Resolved property so the scheduler knows to send a postable alert @@ -418,6 +447,14 @@ func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRu return nextState } +func resultStateReason(result eval.Result, rule *ngModels.AlertRule) string { + if rule.ExecErrState == ngModels.KeepLastErrState || rule.NoDataState == ngModels.KeepLast { + return ngModels.ConcatReasons(result.State.String(), ngModels.StateReasonKeepLast) + } + + return result.State.String() +} + func (st *Manager) GetAll(orgID int64) []*State { allStates := st.cache.getAll(orgID, st.doNotSaveNormalState) return allStates @@ -432,71 +469,6 @@ func (st *Manager) Put(states []*State) { } } -// TODO: Is the `State` type necessary? Should it embed the instance? -func (st *Manager) saveAlertStates(ctx context.Context, logger log.Logger, states ...StateTransition) { - if st.instanceStore == nil || len(states) == 0 { - return - } - - saveState := func(ctx context.Context, idx int) error { - s := states[idx] - // Do not save normal state to database and remove transition to Normal state but keep mapped states - if st.doNotSaveNormalState && IsNormalStateWithNoReason(s.State) && !s.Changed() { - return nil - } - - key, err := s.GetAlertInstanceKey() - if err != nil { - logger.Error("Failed to create a key for alert state to save it to database. The state will be ignored ", "cacheID", s.CacheID, "error", err, "labels", s.Labels.String()) - return nil - } - instance := ngModels.AlertInstance{ - AlertInstanceKey: key, - Labels: ngModels.InstanceLabels(s.Labels), - CurrentState: ngModels.InstanceStateType(s.State.State.String()), - CurrentReason: s.StateReason, - LastEvalTime: s.LastEvaluationTime, - CurrentStateSince: s.StartsAt, - CurrentStateEnd: s.EndsAt, - } - - err = st.instanceStore.SaveAlertInstance(ctx, instance) - if err != nil { - logger.Error("Failed to save alert state", "labels", s.Labels.String(), "state", s.State, "error", err) - return nil - } - return nil - } - - start := time.Now() - logger.Debug("Saving alert states", "count", len(states), "max_state_save_concurrency", st.maxStateSaveConcurrency) - _ = concurrency.ForEachJob(ctx, len(states), st.maxStateSaveConcurrency, saveState) - logger.Debug("Saving alert states done", "count", len(states), "max_state_save_concurrency", st.maxStateSaveConcurrency, "duration", time.Since(start)) -} - -func (st *Manager) deleteAlertStates(ctx context.Context, logger log.Logger, states []StateTransition) { - if st.instanceStore == nil || len(states) == 0 { - return - } - - logger.Debug("Deleting alert states", "count", len(states)) - toDelete := make([]ngModels.AlertInstanceKey, 0, len(states)) - - for _, s := range states { - key, err := s.GetAlertInstanceKey() - if err != nil { - logger.Error("Failed to delete alert instance with invalid labels", "cacheID", s.CacheID, "error", err) - continue - } - toDelete = append(toDelete, key) - } - - err := st.instanceStore.DeleteAlertInstances(ctx, toDelete...) - if err != nil { - logger.Error("Failed to delete stale states", "error", err) - } -} - func translateInstanceState(state ngModels.InstanceStateType) eval.State { switch state { case ngModels.InstanceStateFiring: diff --git a/pkg/services/ngalert/state/manager_bench_test.go b/pkg/services/ngalert/state/manager_bench_test.go index 3e6ce0e2ec083..24543e296cef3 100644 --- a/pkg/services/ngalert/state/manager_bench_test.go +++ b/pkg/services/ngalert/state/manager_bench_test.go @@ -26,12 +26,11 @@ func BenchmarkProcessEvalResults(b *testing.B) { store := historian.NewAnnotationStore(&as, nil, metrics) hist := historian.NewAnnotationBackend(store, nil, metrics) cfg := state.ManagerCfg{ - Historian: hist, - MaxStateSaveConcurrency: 1, - Tracer: tracing.InitializeTracerForTest(), - Log: log.New("ngalert.state.manager"), + Historian: hist, + Tracer: tracing.InitializeTracerForTest(), + Log: log.New("ngalert.state.manager"), } - sut := state.NewManager(cfg) + sut := state.NewManager(cfg, state.NewNoopPersister()) now := time.Now().UTC() rule := makeBenchRule() results := makeBenchResults(100) diff --git a/pkg/services/ngalert/state/manager_private_test.go b/pkg/services/ngalert/state/manager_private_test.go index 4293f92dd18b4..798b37a0720bd 100644 --- a/pkg/services/ngalert/state/manager_private_test.go +++ b/pkg/services/ngalert/state/manager_private_test.go @@ -19,13 +19,10 @@ import ( "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/metrics" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - - "github.com/grafana/grafana/pkg/util" ) // Not for parallel tests. @@ -82,84 +79,6 @@ func TestStateIsStale(t *testing.T) { } } -func TestManager_saveAlertStates(t *testing.T) { - type stateWithReason struct { - State eval.State - Reason string - } - create := func(s eval.State, r string) stateWithReason { - return stateWithReason{ - State: s, - Reason: r, - } - } - allStates := [...]stateWithReason{ - create(eval.Normal, ""), - create(eval.Normal, eval.NoData.String()), - create(eval.Normal, eval.Error.String()), - create(eval.Normal, util.GenerateShortUID()), - create(eval.Alerting, ""), - create(eval.Pending, ""), - create(eval.NoData, ""), - create(eval.Error, ""), - } - - transitionToKey := map[ngmodels.AlertInstanceKey]StateTransition{} - transitions := make([]StateTransition, 0) - for _, fromState := range allStates { - for i, toState := range allStates { - tr := StateTransition{ - State: &State{ - State: toState.State, - StateReason: toState.Reason, - Labels: ngmodels.GenerateAlertLabels(5, fmt.Sprintf("%d--", i)), - }, - PreviousState: fromState.State, - PreviousStateReason: fromState.Reason, - } - key, err := tr.GetAlertInstanceKey() - require.NoError(t, err) - transitionToKey[key] = tr - transitions = append(transitions, tr) - } - } - - t.Run("should save all transitions if doNotSaveNormalState is false", func(t *testing.T) { - st := &FakeInstanceStore{} - m := Manager{instanceStore: st, doNotSaveNormalState: false, maxStateSaveConcurrency: 1} - m.saveAlertStates(context.Background(), &logtest.Fake{}, transitions...) - - savedKeys := map[ngmodels.AlertInstanceKey]ngmodels.AlertInstance{} - for _, op := range st.RecordedOps { - saved := op.(ngmodels.AlertInstance) - savedKeys[saved.AlertInstanceKey] = saved - } - assert.Len(t, transitionToKey, len(savedKeys)) - - for key, tr := range transitionToKey { - assert.Containsf(t, savedKeys, key, "state %s (%s) was not saved but should be", tr.State.State, tr.StateReason) - } - }) - - t.Run("should not save Normal->Normal if doNotSaveNormalState is true", func(t *testing.T) { - st := &FakeInstanceStore{} - m := Manager{instanceStore: st, doNotSaveNormalState: true, maxStateSaveConcurrency: 1} - m.saveAlertStates(context.Background(), &logtest.Fake{}, transitions...) - - savedKeys := map[ngmodels.AlertInstanceKey]ngmodels.AlertInstance{} - for _, op := range st.RecordedOps { - saved := op.(ngmodels.AlertInstance) - savedKeys[saved.AlertInstanceKey] = saved - } - for key, tr := range transitionToKey { - if tr.State.State == eval.Normal && tr.StateReason == "" && tr.PreviousState == eval.Normal && tr.PreviousStateReason == "" { - continue - } - assert.Containsf(t, savedKeys, key, "state %s (%s) was not saved but should be", tr.State.State, tr.StateReason) - } - }) -} - // TestProcessEvalResults_StateTransitions tests how state.Manager's ProcessEvalResults processes results and creates or changes states. // In other words, it tests the state transition. // @@ -286,6 +205,15 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { "system + rule + datasource-error": mergeLabels(mergeLabels(expectedDatasourceErrorLabels, baseRule.Labels), systemLabels), } + resultFingerprints := map[string]data.Fingerprint{ + "system + rule": data.Labels{}.Fingerprint(), + "system + rule + labels1": labels1.Fingerprint(), + "system + rule + labels2": labels2.Fingerprint(), + "system + rule + labels3": labels3.Fingerprint(), + "system + rule + no-data": noDataLabels.Fingerprint(), + "system + rule + datasource-error": data.Labels{}.Fingerprint(), + } + patchState := func(r *ngmodels.AlertRule, s *State) { // patch all optional fields of the expected state setCacheID(s) @@ -304,6 +232,14 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { if s.Values == nil { s.Values = make(map[string]float64) } + if s.ResultFingerprint == data.Fingerprint(0) { + for key, set := range labels { + if set.Fingerprint() == s.Labels.Fingerprint() { + s.ResultFingerprint = resultFingerprints[key] + break + } + } + } } executeTest := func(t *testing.T, alertRule *ngmodels.AlertRule, resultsAtTime map[time.Time]eval.Results, expectedTransitionsAtTime map[time.Time][]StateTransition, applyNoDataErrorToAllStates bool) { @@ -311,19 +247,18 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { testMetrics := metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics() cfg := ManagerCfg{ - Metrics: testMetrics, - Tracer: tracing.InitializeTracerForTest(), - Log: log.New("ngalert.state.manager"), - ExternalURL: nil, - InstanceStore: &FakeInstanceStore{}, - Images: &NotAvailableImageService{}, - Clock: clk, - Historian: &FakeHistorian{}, - MaxStateSaveConcurrency: 1, + Metrics: testMetrics, + Tracer: tracing.InitializeTracerForTest(), + Log: log.New("ngalert.state.manager"), + ExternalURL: nil, + InstanceStore: &FakeInstanceStore{}, + Images: &NotAvailableImageService{}, + Clock: clk, + Historian: &FakeHistorian{}, ApplyNoDataAndErrorToAllStates: applyNoDataErrorToAllStates, } - st := NewManager(cfg) + st := NewManager(cfg, NewNoopPersister()) tss := make([]time.Time, 0, len(resultsAtTime)) for ts, results := range resultsAtTime { @@ -877,6 +812,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { ngmodels.NoData: baseRuleWith(ngmodels.WithNoDataExecAs(ngmodels.NoData)), ngmodels.Alerting: baseRuleWith(ngmodels.WithNoDataExecAs(ngmodels.Alerting)), ngmodels.OK: baseRuleWith(ngmodels.WithNoDataExecAs(ngmodels.OK)), + ngmodels.KeepLast: baseRuleWith(ngmodels.WithNoDataExecAs(ngmodels.KeepLast)), } type noDataTestCase struct { @@ -984,6 +920,24 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLast: { + t1: { + { + PreviousState: eval.Normal, + State: &State{ + Labels: labels["system + rule + no-data"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t1, eval.NoData), + }, + StartsAt: t1, + EndsAt: t1, + LastEvaluationTime: t1, + }, + }, + }, + }, }, }, { @@ -1050,6 +1004,24 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLast: { + t2: { + { + PreviousState: eval.Normal, + State: &State{ + Labels: labels["system + rule + no-data"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t2, eval.NoData), + }, + StartsAt: t2, + EndsAt: t2, + LastEvaluationTime: t2, + }, + }, + }, + }, }, expectedTransitionsApplyNoDataErrorToAllStates: map[ngmodels.NoDataState]map[time.Time][]StateTransition{ ngmodels.Alerting: { @@ -1090,6 +1062,25 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLast: { + t2: { + { + PreviousState: eval.Normal, + State: &State{ + Labels: labels["system + rule + labels1"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t1, eval.Normal), + newEvaluation(t2, eval.NoData), + }, + StartsAt: t1, + EndsAt: t1, + LastEvaluationTime: t2, + }, + }, + }, + }, }, }, { @@ -1267,6 +1258,55 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLast: { + t3: { + { + PreviousState: eval.Normal, + State: &State{ + Labels: labels["system + rule + labels1"], + State: eval.Normal, + StateReason: ngmodels.StateReasonMissingSeries, + Results: []Evaluation{ + newEvaluation(t1, eval.Normal), + }, + StartsAt: t1, + EndsAt: t3, + LastEvaluationTime: t3, + }, + }, + { + PreviousState: eval.Alerting, + State: &State{ + Labels: labels["system + rule + labels2"], + State: eval.Normal, + StateReason: ngmodels.StateReasonMissingSeries, + Results: []Evaluation{ + newEvaluation(t1, eval.Alerting), + }, + StartsAt: t1, + EndsAt: t3, + LastEvaluationTime: t3, + Resolved: true, + }, + }, + { + PreviousState: eval.Normal, + PreviousStateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + State: &State{ + Labels: labels["system + rule + no-data"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t2, eval.NoData), + newEvaluation(t3, eval.NoData), + }, + StartsAt: t2, + EndsAt: t2, + LastEvaluationTime: t3, + }, + }, + }, + }, }, expectedTransitionsApplyNoDataErrorToAllStates: map[ngmodels.NoDataState]map[time.Time][]StateTransition{ ngmodels.Alerting: { @@ -1410,6 +1450,76 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLast: { + t2: { + { + PreviousState: eval.Normal, + State: &State{ + Labels: labels["system + rule + labels1"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t1, eval.Normal), + newEvaluation(t2, eval.NoData), + }, + StartsAt: t1, + EndsAt: t1, + LastEvaluationTime: t2, + }, + }, + { + PreviousState: eval.Alerting, + State: &State{ + Labels: labels["system + rule + labels2"], + State: eval.Alerting, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t1, eval.Alerting), + newEvaluation(t2, eval.NoData), + }, + StartsAt: t1, + EndsAt: t2.Add(ResendDelay * 4), + LastEvaluationTime: t2, + }, + }, + }, + t3: { + { + PreviousState: eval.Normal, + PreviousStateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + State: &State{ + Labels: labels["system + rule + labels1"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t1, eval.Normal), + newEvaluation(t2, eval.NoData), + newEvaluation(t3, eval.NoData), + }, + StartsAt: t1, + EndsAt: t1, + LastEvaluationTime: t3, + }, + }, + { + PreviousState: eval.Alerting, + PreviousStateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + State: &State{ + Labels: labels["system + rule + labels2"], + State: eval.Alerting, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t1, eval.Alerting), + newEvaluation(t2, eval.NoData), + newEvaluation(t3, eval.NoData), + }, + StartsAt: t1, + EndsAt: t3.Add(ResendDelay * 4), + LastEvaluationTime: t3, + }, + }, + }, + }, }, }, { @@ -1598,6 +1708,53 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLast: { + t3: { + { + PreviousState: eval.Normal, + State: &State{ + Labels: labels["system + rule + labels1"], + State: eval.Normal, + StateReason: ngmodels.StateReasonMissingSeries, + Results: []Evaluation{ + newEvaluation(t1, eval.Normal), + }, + StartsAt: t1, + EndsAt: t3, + LastEvaluationTime: t3, + }, + }, + { + PreviousState: eval.Pending, + State: &State{ + Labels: labels["system + rule + labels2"], + State: eval.Normal, + StateReason: ngmodels.StateReasonMissingSeries, + Results: []Evaluation{ + newEvaluation(t1, eval.Alerting), + }, + StartsAt: t1, + EndsAt: t3, + LastEvaluationTime: t3, + }, + }, + { + PreviousState: eval.Normal, + PreviousStateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + State: &State{ + Labels: labels["system + rule + no-data"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t3, eval.NoData), + }, + StartsAt: t2, + EndsAt: t2, + LastEvaluationTime: t3, + }, + }, + }, + }, }, expectedTransitionsApplyNoDataErrorToAllStates: map[ngmodels.NoDataState]map[time.Time][]StateTransition{ ngmodels.Alerting: { @@ -1728,31 +1885,95 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, - }, - }, - { - desc: "t1[1:alerting] t2[NoData] t3[1:alerting] and 'for'=2 at t3", - ruleMutators: []ngmodels.AlertRuleMutator{ngmodels.WithForNTimes(2)}, - results: map[time.Time]eval.Results{ - t1: { - newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), - }, - t2: { - newResult(eval.WithState(eval.NoData), eval.WithLabels(noDataLabels)), - }, - t3: { - newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), - }, - }, - expectedTransitions: map[ngmodels.NoDataState]map[time.Time][]StateTransition{ - ngmodels.NoData: { - t3: { + ngmodels.KeepLast: { + t2: { { - PreviousState: eval.Pending, + PreviousState: eval.Normal, State: &State{ - Labels: labels["system + rule + labels1"], - State: eval.Alerting, - Results: []Evaluation{ + Labels: labels["system + rule + labels1"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t2, eval.NoData), + }, + StartsAt: t1, + EndsAt: t1, + LastEvaluationTime: t2, + }, + }, + { + PreviousState: eval.Pending, + State: &State{ + Labels: labels["system + rule + labels2"], + State: eval.Alerting, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t2, eval.NoData), + }, + StartsAt: t2, + EndsAt: t2.Add(ResendDelay * 4), + LastEvaluationTime: t2, + }, + }, + }, + t3: { + { + PreviousState: eval.Normal, + PreviousStateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + State: &State{ + Labels: labels["system + rule + labels1"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t3, eval.NoData), + }, + StartsAt: t1, + EndsAt: t1, + LastEvaluationTime: t3, + }, + }, + { + PreviousState: eval.Alerting, + PreviousStateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + State: &State{ + Labels: labels["system + rule + labels2"], + State: eval.Alerting, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t3, eval.NoData), + }, + StartsAt: t2, + EndsAt: t3.Add(ResendDelay * 4), + LastEvaluationTime: t3, + }, + }, + }, + }, + }, + }, + { + desc: "t1[1:alerting] t2[NoData] t3[1:alerting] and 'for'=2 at t3", + ruleMutators: []ngmodels.AlertRuleMutator{ngmodels.WithForNTimes(2)}, + results: map[time.Time]eval.Results{ + t1: { + newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), + }, + t2: { + newResult(eval.WithState(eval.NoData), eval.WithLabels(noDataLabels)), + }, + t3: { + newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), + }, + }, + expectedTransitions: map[ngmodels.NoDataState]map[time.Time][]StateTransition{ + ngmodels.NoData: { + t3: { + { + PreviousState: eval.Pending, + State: &State{ + Labels: labels["system + rule + labels1"], + State: eval.Alerting, + Results: []Evaluation{ newEvaluation(t1, eval.Alerting), newEvaluation(t3, eval.Alerting), }, @@ -1799,6 +2020,24 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLast: { + t3: { + { + PreviousState: eval.Pending, + State: &State{ + Labels: labels["system + rule + labels1"], + State: eval.Alerting, + Results: []Evaluation{ + newEvaluation(t1, eval.Alerting), + newEvaluation(t3, eval.Alerting), + }, + StartsAt: t3, + EndsAt: t3.Add(ResendDelay * 4), + LastEvaluationTime: t3, + }, + }, + }, + }, }, expectedTransitionsApplyNoDataErrorToAllStates: map[ngmodels.NoDataState]map[time.Time][]StateTransition{ ngmodels.Alerting: { @@ -1839,6 +2078,25 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLast: { + t3: { + { + PreviousState: eval.Pending, + PreviousStateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + State: &State{ + Labels: labels["system + rule + labels1"], + State: eval.Alerting, + Results: []Evaluation{ + newEvaluation(t2, eval.NoData), + newEvaluation(t3, eval.Alerting), + }, + StartsAt: t3, + EndsAt: t3.Add(ResendDelay * 4), + LastEvaluationTime: t3, + }, + }, + }, + }, }, }, { @@ -1954,6 +2212,39 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLast: { + t3: { + { + PreviousState: eval.Normal, + State: &State{ + Labels: labels["system + rule + labels1"], + State: eval.Normal, + Results: []Evaluation{ + newEvaluation(t2, eval.Normal), + newEvaluation(t3, eval.Normal), + }, + StartsAt: t2, + EndsAt: t2, + LastEvaluationTime: t3, + }, + }, + { + PreviousState: eval.Normal, + PreviousStateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + State: &State{ + Labels: labels["system + rule + no-data"], + State: eval.Normal, + StateReason: ngmodels.StateReasonMissingSeries, + Results: []Evaluation{ + newEvaluation(t1, eval.NoData), + }, + StartsAt: t1, + EndsAt: t3, + LastEvaluationTime: t3, + }, + }, + }, + }, }, }, { @@ -2020,6 +2311,24 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLast: { + t2: { + { + PreviousState: eval.Normal, + State: &State{ + Labels: labels["system + rule + no-data"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t2, eval.NoData), + }, + StartsAt: t2, + EndsAt: t2, + LastEvaluationTime: t2, + }, + }, + }, + }, }, expectedTransitionsApplyNoDataErrorToAllStates: map[ngmodels.NoDataState]map[time.Time][]StateTransition{ ngmodels.Alerting: { @@ -2060,10 +2369,29 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLast: { + t2: { + { + PreviousState: eval.Normal, + State: &State{ + Labels: labels["system + rule"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t1, eval.Normal), + newEvaluation(t2, eval.NoData), + }, + StartsAt: t1, + EndsAt: t1, + LastEvaluationTime: t2, + }, + }, + }, + }, }, }, { - desc: "t1[{}:alerting] t2[NoData] t3[NoData] at t3", + desc: "t1[{}:alerting] t2[NoData] t3[NoData] at t2,t3", results: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Alerting)), @@ -2194,6 +2522,41 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLast: { + t3: { + { + PreviousState: eval.Alerting, + State: &State{ + Labels: labels["system + rule"], + State: eval.Normal, + StateReason: ngmodels.StateReasonMissingSeries, + Results: []Evaluation{ + newEvaluation(t1, eval.Alerting), + }, + StartsAt: t1, + EndsAt: t3, + LastEvaluationTime: t3, + Resolved: true, + }, + }, + { + PreviousState: eval.Normal, + PreviousStateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + State: &State{ + Labels: labels["system + rule + no-data"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t2, eval.NoData), + newEvaluation(t3, eval.NoData), + }, + StartsAt: t2, + EndsAt: t2, + LastEvaluationTime: t3, + }, + }, + }, + }, }, expectedTransitionsApplyNoDataErrorToAllStates: map[ngmodels.NoDataState]map[time.Time][]StateTransition{ ngmodels.Alerting: { @@ -2273,6 +2636,44 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLast: { + t2: { + { + PreviousState: eval.Alerting, + State: &State{ + Labels: labels["system + rule"], + State: eval.Alerting, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t1, eval.Alerting), + newEvaluation(t2, eval.NoData), + }, + StartsAt: t1, + EndsAt: t2.Add(ResendDelay * 4), + LastEvaluationTime: t2, + }, + }, + }, + t3: { + { + PreviousState: eval.Alerting, + PreviousStateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + State: &State{ + Labels: labels["system + rule"], + State: eval.Alerting, + StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t1, eval.Alerting), + newEvaluation(t2, eval.NoData), + newEvaluation(t3, eval.NoData), + }, + StartsAt: t1, + EndsAt: t3.Add(ResendDelay * 4), + LastEvaluationTime: t3, + }, + }, + }, + }, }, }, { @@ -2359,6 +2760,24 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLast: { + t3: { + { + PreviousState: eval.Pending, + State: &State{ + Labels: labels["system + rule"], + State: eval.Alerting, + Results: []Evaluation{ + newEvaluation(t1, eval.Alerting), + newEvaluation(t3, eval.Alerting), + }, + StartsAt: t3, + EndsAt: t3.Add(ResendDelay * 4), + LastEvaluationTime: t3, + }, + }, + }, + }, }, expectedTransitionsApplyNoDataErrorToAllStates: map[ngmodels.NoDataState]map[time.Time][]StateTransition{ ngmodels.Alerting: { @@ -2399,6 +2818,25 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLast: { + t3: { + { + PreviousState: eval.Pending, + PreviousStateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), + State: &State{ + Labels: labels["system + rule"], + State: eval.Alerting, + Results: []Evaluation{ + newEvaluation(t2, eval.NoData), + newEvaluation(t3, eval.Alerting), + }, + StartsAt: t3, + EndsAt: t3.Add(ResendDelay * 4), + LastEvaluationTime: t3, + }, + }, + }, + }, }, }, } @@ -2415,6 +2853,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { ngmodels.ErrorErrState: baseRuleWith(ngmodels.WithErrorExecAs(ngmodels.ErrorErrState)), ngmodels.AlertingErrState: baseRuleWith(ngmodels.WithErrorExecAs(ngmodels.AlertingErrState)), ngmodels.OkErrState: baseRuleWith(ngmodels.WithErrorExecAs(ngmodels.OkErrState)), + ngmodels.KeepLastErrState: baseRuleWith(ngmodels.WithErrorExecAs(ngmodels.KeepLastErrState)), } cacheID := func(lbls data.Labels) string { @@ -2537,6 +2976,24 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLastErrState: { + t1: { + { + PreviousState: eval.Normal, + State: &State{ + Labels: labels["system + rule"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.Error.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t1, eval.Error), + }, + StartsAt: t1, + EndsAt: t1, + LastEvaluationTime: t1, + }, + }, + }, + }, }, }, { @@ -2605,6 +3062,24 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLastErrState: { + t1: { + { + PreviousState: eval.Normal, + State: &State{ + Labels: labels["system + rule"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.Error.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t1, eval.Error), + }, + StartsAt: t1, + EndsAt: t1, + LastEvaluationTime: t1, + }, + }, + }, + }, }, }, { @@ -2678,6 +3153,24 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLastErrState: { + t2: { + { + PreviousState: eval.Normal, + State: &State{ + Labels: labels["system + rule"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.Error.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t2, eval.Error), + }, + StartsAt: t2, + EndsAt: t2, + LastEvaluationTime: t2, + }, + }, + }, + }, }, expectedTransitionsApplyNoDataErrorToAllStates: map[ngmodels.ExecutionErrorState]map[time.Time][]StateTransition{ ngmodels.AlertingErrState: { @@ -2717,6 +3210,24 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLastErrState: { + t2: { + { + PreviousState: eval.Pending, + State: &State{ + Labels: labels["system + rule + labels1"], + State: eval.Alerting, + StateReason: ngmodels.ConcatReasons(eval.Error.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t2, eval.Error), + }, + StartsAt: t2, + EndsAt: t2.Add(ResendDelay * 4), + LastEvaluationTime: t2, + }, + }, + }, + }, }, }, { @@ -2789,6 +3300,24 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLastErrState: { + t2: { + { + PreviousState: eval.Normal, + State: &State{ + Labels: labels["system + rule"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.Error.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t2, eval.Error), + }, + StartsAt: t2, + EndsAt: t2, + LastEvaluationTime: t2, + }, + }, + }, + }, }, expectedTransitionsApplyNoDataErrorToAllStates: map[ngmodels.ExecutionErrorState]map[time.Time][]StateTransition{ ngmodels.AlertingErrState: { @@ -2830,6 +3359,25 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLastErrState: { + t2: { + { + PreviousState: eval.Normal, + State: &State{ + Labels: labels["system + rule + labels1"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.Error.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t1, eval.Normal), + newEvaluation(t2, eval.Error), + }, + StartsAt: t1, + EndsAt: t1, + LastEvaluationTime: t2, + }, + }, + }, + }, }, }, { @@ -2951,6 +3499,39 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLastErrState: { + t3: { + { + PreviousState: eval.Normal, + State: &State{ + Labels: labels["system + rule + labels1"], + State: eval.Normal, + Results: []Evaluation{ + newEvaluation(t2, eval.Normal), + newEvaluation(t3, eval.Normal), + }, + StartsAt: t2, + EndsAt: t2, + LastEvaluationTime: t3, + }, + }, + { + PreviousState: eval.Normal, + PreviousStateReason: ngmodels.ConcatReasons(eval.Error.String(), ngmodels.StateReasonKeepLast), + State: &State{ + Labels: labels["system + rule"], + State: eval.Normal, + StateReason: ngmodels.StateReasonMissingSeries, + Results: []Evaluation{ + newEvaluation(t1, eval.Error), + }, + StartsAt: t1, + EndsAt: t3, + LastEvaluationTime: t3, + }, + }, + }, + }, }, }, { @@ -3026,6 +3607,25 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLastErrState: { + t2: { + { + PreviousState: eval.Normal, + State: &State{ + Labels: labels["system + rule"], + State: eval.Normal, + StateReason: ngmodels.ConcatReasons(eval.Error.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t1, eval.Normal), + newEvaluation(t2, eval.Error), + }, + StartsAt: t1, + EndsAt: t1, + LastEvaluationTime: t2, + }, + }, + }, + }, }, }, { @@ -3114,6 +3714,24 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLastErrState: { + t2: { + { + PreviousState: eval.Pending, + State: &State{ + Labels: labels["system + rule"], + State: eval.Alerting, + StateReason: ngmodels.ConcatReasons(eval.Error.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t2, eval.Error), + }, + StartsAt: t2, + EndsAt: t2.Add(ResendDelay * 4), + LastEvaluationTime: t2, + }, + }, + }, + }, }, }, { @@ -3243,6 +3861,42 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLastErrState: { + t2: { + { + PreviousState: eval.Pending, + State: &State{ + Labels: labels["system + rule"], + State: eval.Pending, + StateReason: ngmodels.ConcatReasons(eval.Error.String(), ngmodels.StateReasonKeepLast), + Results: []Evaluation{ + newEvaluation(t1, eval.Alerting), + newEvaluation(t2, eval.Error), + }, + StartsAt: t1, + EndsAt: t1.Add(ResendDelay * 4), + LastEvaluationTime: t2, + }, + }, + }, + t3: { + { + PreviousState: eval.Pending, + PreviousStateReason: ngmodels.ConcatReasons(eval.Error.String(), ngmodels.StateReasonKeepLast), + State: &State{ + Labels: labels["system + rule"], + State: eval.Alerting, + Results: []Evaluation{ + newEvaluation(t2, eval.Error), + newEvaluation(t3, eval.Alerting), + }, + StartsAt: t3, + EndsAt: t3.Add(ResendDelay * 4), + LastEvaluationTime: t3, + }, + }, + }, + }, }, }, { @@ -3313,6 +3967,25 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, }, }, + ngmodels.KeepLastErrState: { + t2: { + { + PreviousState: eval.Normal, + PreviousStateReason: ngmodels.ConcatReasons(eval.Error.String(), ngmodels.StateReasonKeepLast), + State: &State{ + Labels: labels["system + rule"], + State: eval.Normal, + Results: []Evaluation{ + newEvaluation(t1, eval.Error), + newEvaluation(t2, eval.Normal), + }, + StartsAt: t1, + EndsAt: t1, + LastEvaluationTime: t2, + }, + }, + }, + }, }, }, } diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index f6f519ad94a54..38a7519f84688 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "math" "math/rand" "sort" "strings" @@ -33,9 +34,14 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/ngalert/state/historian" "github.com/grafana/grafana/pkg/services/ngalert/tests" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestWarmStateCache(t *testing.T) { evaluationTime, err := time.Parse("2006-01-02", "2021-03-25") require.NoError(t, err) @@ -58,6 +64,7 @@ func TestWarmStateCache(t *testing.T) { EndsAt: evaluationTime.Add(1 * time.Minute), LastEvaluationTime: evaluationTime, Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, + ResultFingerprint: data.Fingerprint(math.MaxUint64), }, { AlertRuleUID: rule.UID, OrgID: rule.OrgID, @@ -70,6 +77,7 @@ func TestWarmStateCache(t *testing.T) { EndsAt: evaluationTime.Add(1 * time.Minute), LastEvaluationTime: evaluationTime, Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, + ResultFingerprint: data.Fingerprint(math.MaxUint64 - 1), }, { AlertRuleUID: rule.UID, @@ -83,6 +91,7 @@ func TestWarmStateCache(t *testing.T) { EndsAt: evaluationTime.Add(1 * time.Minute), LastEvaluationTime: evaluationTime, Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, + ResultFingerprint: data.Fingerprint(0), }, { AlertRuleUID: rule.UID, @@ -96,6 +105,7 @@ func TestWarmStateCache(t *testing.T) { EndsAt: evaluationTime.Add(1 * time.Minute), LastEvaluationTime: evaluationTime, Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, + ResultFingerprint: data.Fingerprint(1), }, { AlertRuleUID: rule.UID, @@ -109,6 +119,7 @@ func TestWarmStateCache(t *testing.T) { EndsAt: evaluationTime.Add(1 * time.Minute), LastEvaluationTime: evaluationTime, Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, + ResultFingerprint: data.Fingerprint(2), }, } @@ -127,6 +138,7 @@ func TestWarmStateCache(t *testing.T) { CurrentStateSince: evaluationTime.Add(-1 * time.Minute), CurrentStateEnd: evaluationTime.Add(1 * time.Minute), Labels: labels, + ResultFingerprint: data.Fingerprint(math.MaxUint64).String(), }) labels = models.InstanceLabels{"test2": "testValue2"} @@ -142,6 +154,7 @@ func TestWarmStateCache(t *testing.T) { CurrentStateSince: evaluationTime.Add(-1 * time.Minute), CurrentStateEnd: evaluationTime.Add(1 * time.Minute), Labels: labels, + ResultFingerprint: data.Fingerprint(math.MaxUint64 - 1).String(), }) labels = models.InstanceLabels{"test3": "testValue3"} @@ -157,6 +170,7 @@ func TestWarmStateCache(t *testing.T) { CurrentStateSince: evaluationTime.Add(-1 * time.Minute), CurrentStateEnd: evaluationTime.Add(1 * time.Minute), Labels: labels, + ResultFingerprint: data.Fingerprint(0).String(), }) labels = models.InstanceLabels{"test4": "testValue4"} @@ -172,6 +186,7 @@ func TestWarmStateCache(t *testing.T) { CurrentStateSince: evaluationTime.Add(-1 * time.Minute), CurrentStateEnd: evaluationTime.Add(1 * time.Minute), Labels: labels, + ResultFingerprint: data.Fingerprint(1).String(), }) labels = models.InstanceLabels{"test5": "testValue5"} @@ -187,23 +202,23 @@ func TestWarmStateCache(t *testing.T) { CurrentStateSince: evaluationTime.Add(-1 * time.Minute), CurrentStateEnd: evaluationTime.Add(1 * time.Minute), Labels: labels, + ResultFingerprint: data.Fingerprint(2).String(), }) for _, instance := range instances { _ = dbstore.SaveAlertInstance(ctx, instance) } cfg := state.ManagerCfg{ - Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(), - ExternalURL: nil, - InstanceStore: dbstore, - Images: &state.NoopImageService{}, - Clock: clock.NewMock(), - Historian: &state.FakeHistorian{}, - MaxStateSaveConcurrency: 1, - Tracer: tracing.InitializeTracerForTest(), - Log: log.New("ngalert.state.manager"), + Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(), + ExternalURL: nil, + InstanceStore: dbstore, + Images: &state.NoopImageService{}, + Clock: clock.NewMock(), + Historian: &state.FakeHistorian{}, + Tracer: tracing.InitializeTracerForTest(), + Log: log.New("ngalert.state.manager"), } - st := state.NewManager(cfg) + st := state.NewManager(cfg, state.NewNoopPersister()) st.Warm(ctx, dbstore) t.Run("instance cache has expected entries", func(t *testing.T) { @@ -231,17 +246,16 @@ func TestDashboardAnnotations(t *testing.T) { store := historian.NewAnnotationStore(fakeAnnoRepo, &dashboards.FakeDashboardService{}, historianMetrics) hist := historian.NewAnnotationBackend(store, nil, historianMetrics) cfg := state.ManagerCfg{ - Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(), - ExternalURL: nil, - InstanceStore: dbstore, - Images: &state.NoopImageService{}, - Clock: clock.New(), - Historian: hist, - MaxStateSaveConcurrency: 1, - Tracer: tracing.InitializeTracerForTest(), - Log: log.New("ngalert.state.manager"), + Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(), + ExternalURL: nil, + InstanceStore: dbstore, + Images: &state.NoopImageService{}, + Clock: clock.New(), + Historian: hist, + Tracer: tracing.InitializeTracerForTest(), + Log: log.New("ngalert.state.manager"), } - st := state.NewManager(cfg) + st := state.NewManager(cfg, state.NewNoopPersister()) const mainOrgID int64 = 1 @@ -384,8 +398,9 @@ func TestProcessEvalResults(t *testing.T) { }, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Normal, + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Normal, Results: []state.Evaluation{ newEvaluation(t1, eval.Normal), }, @@ -407,8 +422,9 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 1, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Normal, + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Normal, Results: []state.Evaluation{ newEvaluation(t1, eval.Normal), }, @@ -417,8 +433,9 @@ func TestProcessEvalResults(t *testing.T) { LastEvaluationTime: t1, }, { - Labels: labels["system + rule + labels2"], - State: eval.Alerting, + Labels: labels["system + rule + labels2"], + ResultFingerprint: labels2.Fingerprint(), + State: eval.Alerting, Results: []state.Evaluation{ newEvaluation(t1, eval.Alerting), }, @@ -441,8 +458,9 @@ func TestProcessEvalResults(t *testing.T) { }, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Normal, + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Normal, Results: []state.Evaluation{ newEvaluation(t1, eval.Normal), newEvaluation(tn(6), eval.Normal), @@ -467,8 +485,9 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 1, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Alerting, + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Alerting, Results: []state.Evaluation{ newEvaluation(t1, eval.Normal), newEvaluation(t2, eval.Alerting), @@ -499,8 +518,9 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 2, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Alerting, + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Alerting, Results: []state.Evaluation{ newEvaluation(t3, eval.Alerting), newEvaluation(tn(4), eval.Alerting), @@ -534,8 +554,9 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 3, // Normal -> Pending, Pending -> NoData, NoData -> Pending expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Pending, + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Pending, Results: []state.Evaluation{ newEvaluation(tn(4), eval.Alerting), newEvaluation(tn(5), eval.Alerting), @@ -566,8 +587,9 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 3, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.NoData, + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.NoData, Results: []state.Evaluation{ newEvaluation(t3, eval.Alerting), newEvaluation(tn(4), eval.NoData), @@ -592,7 +614,8 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 1, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), State: eval.Pending, Results: []state.Evaluation{ @@ -619,8 +642,9 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 1, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Pending, + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Pending, Results: []state.Evaluation{ newEvaluation(t1, eval.Alerting), newEvaluation(t2, eval.Alerting), @@ -645,9 +669,10 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 1, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Pending, - StateReason: eval.NoData.String(), + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Pending, + StateReason: eval.NoData.String(), Results: []state.Evaluation{ newEvaluation(t1, eval.Normal), newEvaluation(t2, eval.NoData), @@ -681,9 +706,10 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 2, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Alerting, - StateReason: eval.NoData.String(), + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Alerting, + StateReason: eval.NoData.String(), Results: []state.Evaluation{ newEvaluation(t3, eval.NoData), newEvaluation(tn(4), eval.NoData), @@ -709,8 +735,9 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 1, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.NoData, + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.NoData, Results: []state.Evaluation{ newEvaluation(t1, eval.Normal), newEvaluation(t2, eval.NoData), @@ -735,8 +762,9 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 1, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Normal, + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Normal, Results: []state.Evaluation{ newEvaluation(t1, eval.Normal), }, @@ -745,8 +773,9 @@ func TestProcessEvalResults(t *testing.T) { LastEvaluationTime: t1, }, { - Labels: labels["system + rule"], - State: eval.NoData, + Labels: labels["system + rule"], + ResultFingerprint: data.Labels{}.Fingerprint(), + State: eval.NoData, Results: []state.Evaluation{ newEvaluation(t2, eval.NoData), }, @@ -771,8 +800,9 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 1, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Normal, + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Normal, Results: []state.Evaluation{ newEvaluation(t1, eval.Normal), }, @@ -781,8 +811,9 @@ func TestProcessEvalResults(t *testing.T) { LastEvaluationTime: t1, }, { - Labels: labels["system + rule + labels2"], - State: eval.Normal, + Labels: labels["system + rule + labels2"], + ResultFingerprint: labels2.Fingerprint(), + State: eval.Normal, Results: []state.Evaluation{ newEvaluation(t1, eval.Normal), }, @@ -791,8 +822,9 @@ func TestProcessEvalResults(t *testing.T) { LastEvaluationTime: t1, }, { - Labels: labels["system + rule"], - State: eval.NoData, + Labels: labels["system + rule"], + ResultFingerprint: data.Labels{}.Fingerprint(), + State: eval.NoData, Results: []state.Evaluation{ newEvaluation(t2, eval.NoData), }, @@ -819,8 +851,9 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 1, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Normal, + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Normal, Results: []state.Evaluation{ newEvaluation(t1, eval.Normal), newEvaluation(t3, eval.Normal), @@ -830,8 +863,9 @@ func TestProcessEvalResults(t *testing.T) { LastEvaluationTime: t3, }, { - Labels: labels["system + rule + no-data"], - State: eval.NoData, + Labels: labels["system + rule + no-data"], + ResultFingerprint: noDataLabels.Fingerprint(), + State: eval.NoData, Results: []state.Evaluation{ newEvaluation(t2, eval.NoData), }, @@ -841,6 +875,76 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + { + desc: "normal -> normal (NoData, KeepLastState) -> alerting -> alerting (NoData, KeepLastState) - keeps last state when result is NoData and NoDataState is KeepLast", + alertRule: baseRuleWith(models.WithForNTimes(0), models.WithNoDataExecAs(models.KeepLast)), + evalResults: map[time.Time]eval.Results{ + t1: { + newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), + }, + t2: { + newResult(eval.WithState(eval.NoData), eval.WithLabels(labels1)), // TODO fix it because NoData does not have same labels + }, + t3: { + newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), + }, + tn(4): { + newResult(eval.WithState(eval.NoData), eval.WithLabels(labels1)), // TODO fix it because NoData does not have same labels + }, + }, + expectedAnnotations: 1, + expectedStates: []*state.State{ + { + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Alerting, + StateReason: models.ConcatReasons(eval.NoData.String(), models.StateReasonKeepLast), + Results: []state.Evaluation{ + newEvaluation(t1, eval.Normal), + newEvaluation(t2, eval.NoData), + newEvaluation(t3, eval.Alerting), + newEvaluation(tn(4), eval.NoData), + }, + StartsAt: t3, + EndsAt: tn(4).Add(state.ResendDelay * 4), + LastEvaluationTime: tn(4), + }, + }, + }, + { + desc: "normal -> pending -> pending (NoData, KeepLastState) -> alerting (NoData, KeepLastState) - keep last state respects For when result is NoData", + alertRule: baseRuleWith(models.WithForNTimes(2), models.WithNoDataExecAs(models.KeepLast)), + evalResults: map[time.Time]eval.Results{ + t1: { + newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), + }, + t2: { + newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), + }, + t3: { + newResult(eval.WithState(eval.NoData), eval.WithLabels(labels1)), // TODO fix it because NoData does not have same labels + }, + tn(4): { + newResult(eval.WithState(eval.NoData), eval.WithLabels(labels1)), // TODO fix it because NoData does not have same labels + }, + }, + expectedAnnotations: 2, + expectedStates: []*state.State{ + { + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Alerting, + StateReason: models.ConcatReasons(eval.NoData.String(), models.StateReasonKeepLast), + Results: []state.Evaluation{ + newEvaluation(t3, eval.NoData), + newEvaluation(tn(4), eval.NoData), + }, + StartsAt: tn(4), + EndsAt: tn(4).Add(state.ResendDelay * 4), + LastEvaluationTime: tn(4), + }, + }, + }, { desc: "normal -> normal when result is NoData and NoDataState is ok", alertRule: baseRuleWith(models.WithNoDataExecAs(models.OK)), @@ -855,9 +959,10 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 0, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Normal, - StateReason: eval.NoData.String(), + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Normal, + StateReason: eval.NoData.String(), Results: []state.Evaluation{ newEvaluation(t1, eval.Normal), newEvaluation(t2, eval.NoData), @@ -882,10 +987,11 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 1, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Pending, - StateReason: eval.Error.String(), - Error: errors.New("with_state_error"), + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Pending, + StateReason: eval.Error.String(), + Error: errors.New("with_state_error"), Results: []state.Evaluation{ newEvaluation(t1, eval.Normal), newEvaluation(t2, eval.Error), @@ -919,10 +1025,11 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 2, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Alerting, - StateReason: eval.Error.String(), - Error: errors.New("with_state_error"), + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Alerting, + StateReason: eval.Error.String(), + Error: errors.New("with_state_error"), Results: []state.Evaluation{ newEvaluation(t3, eval.Error), newEvaluation(tn(4), eval.Error), @@ -960,8 +1067,9 @@ func TestProcessEvalResults(t *testing.T) { "datasource_uid": "datasource_uid_1", "ref_id": "A", }), - State: eval.Error, - Error: expr.MakeQueryError("A", "datasource_uid_1", errors.New("this is an error")), + ResultFingerprint: labels1.Fingerprint(), + State: eval.Error, + Error: expr.MakeQueryError("A", "datasource_uid_1", errors.New("this is an error")), Results: []state.Evaluation{ newEvaluation(t1, eval.Normal), newEvaluation(t2, eval.Error), @@ -974,6 +1082,76 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + { + desc: "normal -> normal (Error, KeepLastState) -> alerting -> alerting (Error, KeepLastState) - keeps last state when result is Error and ExecErrState is KeepLast", + alertRule: baseRuleWith(models.WithForNTimes(0), models.WithErrorExecAs(models.KeepLastErrState)), + evalResults: map[time.Time]eval.Results{ + t1: { + newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), + }, + t2: { + newResult(eval.WithError(expr.MakeQueryError("A", "datasource_uid_1", errors.New("this is an error"))), eval.WithLabels(labels1)), // TODO fix it because error labels are different + }, + t3: { + newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), + }, + tn(4): { + newResult(eval.WithError(expr.MakeQueryError("A", "datasource_uid_1", errors.New("this is an error"))), eval.WithLabels(labels1)), // TODO fix it because error labels are different + }, + }, + expectedAnnotations: 1, + expectedStates: []*state.State{ + { + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Alerting, + StateReason: models.ConcatReasons(eval.Error.String(), models.StateReasonKeepLast), + Results: []state.Evaluation{ + newEvaluation(t1, eval.Normal), + newEvaluation(t2, eval.Error), + newEvaluation(t3, eval.Alerting), + newEvaluation(tn(4), eval.Error), + }, + StartsAt: t3, + EndsAt: tn(4).Add(state.ResendDelay * 4), + LastEvaluationTime: tn(4), + }, + }, + }, + { + desc: "normal -> pending -> pending (Error, KeepLastState) -> alerting (Error, KeepLastState) - keep last state respects For when result is Error", + alertRule: baseRuleWith(models.WithForNTimes(2), models.WithErrorExecAs(models.KeepLastErrState)), + evalResults: map[time.Time]eval.Results{ + t1: { + newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), + }, + t2: { + newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), + }, + t3: { + newResult(eval.WithError(expr.MakeQueryError("A", "datasource_uid_1", errors.New("this is an error"))), eval.WithLabels(labels1)), // TODO fix it because error labels are different + }, + tn(4): { + newResult(eval.WithError(expr.MakeQueryError("A", "datasource_uid_1", errors.New("this is an error"))), eval.WithLabels(labels1)), // TODO fix it because error labels are different + }, + }, + expectedAnnotations: 2, + expectedStates: []*state.State{ + { + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Alerting, + StateReason: models.ConcatReasons(eval.Error.String(), models.StateReasonKeepLast), + Results: []state.Evaluation{ + newEvaluation(t3, eval.Error), + newEvaluation(tn(4), eval.Error), + }, + StartsAt: tn(4), + EndsAt: tn(4).Add(state.ResendDelay * 4), + LastEvaluationTime: tn(4), + }, + }, + }, { desc: "normal -> normal when result is Error and ExecErrState is OK", alertRule: baseRuleWith(models.WithForNTimes(6), models.WithErrorExecAs(models.OkErrState)), @@ -988,9 +1166,10 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 1, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Normal, - StateReason: eval.Error.String(), + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Normal, + StateReason: eval.Error.String(), Results: []state.Evaluation{ newEvaluation(t1, eval.Normal), newEvaluation(t2, eval.Error), @@ -1015,9 +1194,10 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 2, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Normal, - StateReason: eval.Error.String(), + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Normal, + StateReason: eval.Error.String(), Results: []state.Evaluation{ newEvaluation(t1, eval.Alerting), newEvaluation(t2, eval.Error), @@ -1054,9 +1234,10 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 3, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Error, - Error: fmt.Errorf("with_state_error"), + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Error, + Error: fmt.Errorf("with_state_error"), Results: []state.Evaluation{ newEvaluation(tn(5), eval.Error), newEvaluation(tn(6), eval.Error), @@ -1087,8 +1268,9 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 3, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.Pending, + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Pending, Results: []state.Evaluation{ newEvaluation(tn(4), eval.Alerting), newEvaluation(tn(5), eval.Error), @@ -1120,8 +1302,9 @@ func TestProcessEvalResults(t *testing.T) { expectedAnnotations: 3, expectedStates: []*state.State{ { - Labels: labels["system + rule + labels1"], - State: eval.NoData, + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.NoData, Results: []state.Evaluation{ newEvaluation(tn(4), eval.Alerting), newEvaluation(tn(5), eval.Error), @@ -1166,6 +1349,11 @@ func TestProcessEvalResults(t *testing.T) { LastEvaluationTime: t1, EvaluationDuration: evaluationDuration, Annotations: map[string]string{"summary": "grafana is down in us-central-1 cluster -> prod namespace"}, + ResultFingerprint: data.Labels{ + "cluster": "us-central-1", + "namespace": "prod", + "pod": "grafana", + }.Fingerprint(), }, }, }, @@ -1186,8 +1374,9 @@ func TestProcessEvalResults(t *testing.T) { }, expectedStates: []*state.State{ { - Labels: labels["system + rule"], - State: eval.Alerting, + Labels: labels["system + rule"], + ResultFingerprint: data.Labels{}.Fingerprint(), + State: eval.Alerting, Results: []state.Evaluation{ newEvaluation(t1, eval.Alerting), newEvaluation(t2, eval.Error), @@ -1211,24 +1400,23 @@ func TestProcessEvalResults(t *testing.T) { hist := historian.NewAnnotationBackend(store, nil, m) clk := clock.NewMock() cfg := state.ManagerCfg{ - Metrics: stateMetrics, - ExternalURL: nil, - InstanceStore: &state.FakeInstanceStore{}, - Images: &state.NotAvailableImageService{}, - Clock: clk, - Historian: hist, - MaxStateSaveConcurrency: 1, - Tracer: tracing.InitializeTracerForTest(), - Log: log.New("ngalert.state.manager"), + Metrics: stateMetrics, + ExternalURL: nil, + InstanceStore: &state.FakeInstanceStore{}, + Images: &state.NotAvailableImageService{}, + Clock: clk, + Historian: hist, + Tracer: tracing.InitializeTracerForTest(), + Log: log.New("ngalert.state.manager"), } - st := state.NewManager(cfg) + st := state.NewManager(cfg, state.NewNoopPersister()) evals := make([]time.Time, 0, len(tc.evalResults)) for evalTime := range tc.evalResults { evals = append(evals, evalTime) } - slices.SortFunc(evals, func(a, b time.Time) bool { - return a.Before(b) + slices.SortFunc(evals, func(a, b time.Time) int { + return a.Compare(b) }) results := 0 for _, evalTime := range evals { @@ -1318,11 +1506,12 @@ func TestProcessEvalResults(t *testing.T) { Images: &state.NotAvailableImageService{}, Clock: clk, Historian: &state.FakeHistorian{}, - MaxStateSaveConcurrency: 1, Tracer: tracing.InitializeTracerForTest(), Log: log.New("ngalert.state.manager"), + MaxStateSaveConcurrency: 1, } - st := state.NewManager(cfg) + statePersister := state.NewSyncStatePersisiter(log.New("ngalert.state.manager.persist"), cfg) + st := state.NewManager(cfg, statePersister) rule := models.AlertRuleGen()() var results = eval.GenerateResults(rand.Intn(4)+1, eval.ResultGen(eval.WithEvaluatedAt(clk.Now()))) @@ -1331,7 +1520,7 @@ func TestProcessEvalResults(t *testing.T) { require.NotEmpty(t, states) savedStates := make(map[string]models.AlertInstance) - for _, op := range instanceStore.RecordedOps { + for _, op := range instanceStore.RecordedOps() { switch q := op.(type) { case models.AlertInstance: cacheId, err := q.Labels.StringKey() @@ -1454,6 +1643,7 @@ func TestStaleResultsHandler(t *testing.T) { LastEvaluationTime: evaluationTime, EvaluationDuration: 0, Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, + ResultFingerprint: data.Labels{"test1": "testValue1"}.Fingerprint(), }, }, startingStateCount: 2, @@ -1464,17 +1654,16 @@ func TestStaleResultsHandler(t *testing.T) { for _, tc := range testCases { ctx := context.Background() cfg := state.ManagerCfg{ - Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(), - ExternalURL: nil, - InstanceStore: dbstore, - Images: &state.NoopImageService{}, - Clock: clock.New(), - Historian: &state.FakeHistorian{}, - MaxStateSaveConcurrency: 1, - Tracer: tracing.InitializeTracerForTest(), - Log: log.New("ngalert.state.manager"), + Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(), + ExternalURL: nil, + InstanceStore: dbstore, + Images: &state.NoopImageService{}, + Clock: clock.New(), + Historian: &state.FakeHistorian{}, + Tracer: tracing.InitializeTracerForTest(), + Log: log.New("ngalert.state.manager"), } - st := state.NewManager(cfg) + st := state.NewManager(cfg, state.NewNoopPersister()) st.Warm(ctx, dbstore) existingStatesForRule := st.GetStatesForRuleUID(rule.OrgID, rule.UID) @@ -1547,17 +1736,16 @@ func TestStaleResults(t *testing.T) { store := &state.FakeInstanceStore{} cfg := state.ManagerCfg{ - Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(), - ExternalURL: nil, - InstanceStore: store, - Images: &state.NoopImageService{}, - Clock: clk, - Historian: &state.FakeHistorian{}, - MaxStateSaveConcurrency: 1, - Tracer: tracing.InitializeTracerForTest(), - Log: log.New("ngalert.state.manager"), + Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(), + ExternalURL: nil, + InstanceStore: store, + Images: &state.NoopImageService{}, + Clock: clk, + Historian: &state.FakeHistorian{}, + Tracer: tracing.InitializeTracerForTest(), + Log: log.New("ngalert.state.manager"), } - st := state.NewManager(cfg) + st := state.NewManager(cfg, state.NewNoopPersister()) rule := models.AlertRuleGen(models.WithFor(0))() @@ -1621,7 +1809,7 @@ func TestStaleResults(t *testing.T) { }) t.Run("should delete stale states from the database", func(t *testing.T) { - for _, op := range store.RecordedOps { + for _, op := range store.RecordedOps() { switch q := op.(type) { case state.FakeInstanceStoreOp: keys, ok := q.Args[1].([]models.AlertInstanceKey) @@ -1721,17 +1909,16 @@ func TestDeleteStateByRuleUID(t *testing.T) { clk := clock.NewMock() clk.Set(time.Now()) cfg := state.ManagerCfg{ - Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(), - ExternalURL: nil, - InstanceStore: dbstore, - Images: &state.NoopImageService{}, - Clock: clk, - Historian: &state.FakeHistorian{}, - MaxStateSaveConcurrency: 1, - Tracer: tracing.InitializeTracerForTest(), - Log: log.New("ngalert.state.manager"), + Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(), + ExternalURL: nil, + InstanceStore: dbstore, + Images: &state.NoopImageService{}, + Clock: clk, + Historian: &state.FakeHistorian{}, + Tracer: tracing.InitializeTracerForTest(), + Log: log.New("ngalert.state.manager"), } - st := state.NewManager(cfg) + st := state.NewManager(cfg, state.NewNoopPersister()) st.Warm(ctx, dbstore) q := &models.ListAlertInstancesQuery{RuleOrgID: rule.OrgID, RuleUID: rule.UID} alerts, _ := dbstore.ListAlertInstances(ctx, q) @@ -1863,17 +2050,16 @@ func TestResetStateByRuleUID(t *testing.T) { clk := clock.NewMock() clk.Set(time.Now()) cfg := state.ManagerCfg{ - Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(), - ExternalURL: nil, - InstanceStore: dbstore, - Images: &state.NoopImageService{}, - Clock: clk, - Historian: fakeHistorian, - MaxStateSaveConcurrency: 1, - Tracer: tracing.InitializeTracerForTest(), - Log: log.New("ngalert.state.manager"), + Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(), + ExternalURL: nil, + InstanceStore: dbstore, + Images: &state.NoopImageService{}, + Clock: clk, + Historian: fakeHistorian, + Tracer: tracing.InitializeTracerForTest(), + Log: log.New("ngalert.state.manager"), } - st := state.NewManager(cfg) + st := state.NewManager(cfg, state.NewNoopPersister()) st.Warm(ctx, dbstore) q := &models.ListAlertInstancesQuery{RuleOrgID: rule.OrgID, RuleUID: rule.UID} alerts, _ := dbstore.ListAlertInstances(ctx, q) diff --git a/pkg/services/ngalert/state/persist.go b/pkg/services/ngalert/state/persist.go index b12deba5af6a3..b3e237a4fb7ef 100644 --- a/pkg/services/ngalert/state/persist.go +++ b/pkg/services/ngalert/state/persist.go @@ -14,6 +14,7 @@ type InstanceStore interface { SaveAlertInstance(ctx context.Context, instance models.AlertInstance) error DeleteAlertInstances(ctx context.Context, keys ...models.AlertInstanceKey) error DeleteAlertInstancesByRule(ctx context.Context, key models.AlertRuleKey) error + FullSync(ctx context.Context, instances []models.AlertInstance) error } // RuleReader represents the ability to fetch alert rules. diff --git a/pkg/services/ngalert/state/persister_async.go b/pkg/services/ngalert/state/persister_async.go new file mode 100644 index 0000000000000..a0118b92df8b9 --- /dev/null +++ b/pkg/services/ngalert/state/persister_async.go @@ -0,0 +1,69 @@ +package state + +import ( + "context" + "time" + + "github.com/benbjohnson/clock" + "go.opentelemetry.io/otel/trace" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert/metrics" +) + +type AsyncStatePersister struct { + log log.Logger + // doNotSaveNormalState controls whether eval.Normal state is persisted to the database and returned by get methods. + doNotSaveNormalState bool + store InstanceStore + ticker *clock.Ticker + metrics *metrics.State +} + +func NewAsyncStatePersister(log log.Logger, ticker *clock.Ticker, cfg ManagerCfg) StatePersister { + return &AsyncStatePersister{ + log: log, + store: cfg.InstanceStore, + ticker: ticker, + doNotSaveNormalState: cfg.DoNotSaveNormalState, + metrics: cfg.Metrics, + } +} + +func (a *AsyncStatePersister) Async(ctx context.Context, cache *cache) { + for { + select { + case <-a.ticker.C: + if err := a.fullSync(ctx, cache); err != nil { + a.log.Error("Failed to do a full state sync to database", "err", err) + } + case <-ctx.Done(): + a.log.Info("Scheduler is shutting down, doing a final state sync.") + if err := a.fullSync(context.Background(), cache); err != nil { + a.log.Error("Failed to do a full state sync to database", "err", err) + } + a.ticker.Stop() + a.log.Info("State async worker is shut down.") + return + } + } +} + +func (a *AsyncStatePersister) fullSync(ctx context.Context, cache *cache) error { + startTime := time.Now() + a.log.Debug("Full state sync start") + instances := cache.asInstances(a.doNotSaveNormalState) + if err := a.store.FullSync(ctx, instances); err != nil { + a.log.Error("Full state sync failed", "duration", time.Since(startTime), "instances", len(instances)) + return err + } + a.log.Debug("Full state sync done", "duration", time.Since(startTime), "instances", len(instances)) + if a.metrics != nil { + a.metrics.StateFullSyncDuration.Observe(time.Since(startTime).Seconds()) + } + return nil +} + +func (a *AsyncStatePersister) Sync(_ context.Context, _ trace.Span, _, _ []StateTransition) { + a.log.Debug("Sync: No-Op") +} diff --git a/pkg/services/ngalert/state/persister_async_test.go b/pkg/services/ngalert/state/persister_async_test.go new file mode 100644 index 0000000000000..63705c01f47aa --- /dev/null +++ b/pkg/services/ngalert/state/persister_async_test.go @@ -0,0 +1,77 @@ +package state + +import ( + "context" + "testing" + "time" + + "github.com/benbjohnson/clock" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert/eval" +) + +func TestAsyncStatePersister_Async(t *testing.T) { + t.Run("It should save on tick", func(t *testing.T) { + mockClock := clock.NewMock() + store := &FakeInstanceStore{} + logger := log.New("async.test") + + persister := NewAsyncStatePersister(logger, mockClock.Ticker(1*time.Second), ManagerCfg{ + InstanceStore: store, + }) + + ctx, cancel := context.WithCancel(context.Background()) + + defer func() { + cancel() + }() + + cache := newCache() + + go persister.Async(ctx, cache) + + cache.set(&State{ + OrgID: 1, + State: eval.Alerting, + AlertRuleUID: "1", + }) + // Let one tick pass + mockClock.Add(1 * time.Second) + + // Check if the state was saved + require.Eventually(t, func() bool { + return len(store.RecordedOps()) == 1 + }, time.Second*5, time.Second) + }) + t.Run("It should save on context done", func(t *testing.T) { + mockClock := clock.NewMock() + store := &FakeInstanceStore{} + logger := log.New("async.test") + + persister := NewAsyncStatePersister(logger, mockClock.Ticker(1*time.Second), ManagerCfg{ + InstanceStore: store, + }) + + ctx, cancel := context.WithCancel(context.Background()) + + cache := newCache() + + go persister.Async(ctx, cache) + + cache.set(&State{ + OrgID: 1, + State: eval.Alerting, + AlertRuleUID: "1", + }) + + // Now we cancel the context + cancel() + + // Check if the context cancellation was handled correctly + require.Eventually(t, func() bool { + return len(store.RecordedOps()) == 1 + }, time.Second*5, time.Second) + }) +} diff --git a/pkg/services/ngalert/state/persister_noop.go b/pkg/services/ngalert/state/persister_noop.go new file mode 100644 index 0000000000000..a500f34eeadf6 --- /dev/null +++ b/pkg/services/ngalert/state/persister_noop.go @@ -0,0 +1,16 @@ +package state + +import ( + "context" + + "go.opentelemetry.io/otel/trace" +) + +type NoopPersister struct{} + +func (n *NoopPersister) Async(_ context.Context, _ *cache) {} +func (n *NoopPersister) Sync(_ context.Context, _ trace.Span, _, _ []StateTransition) {} + +func NewNoopPersister() StatePersister { + return &NoopPersister{} +} diff --git a/pkg/services/ngalert/state/persister_sync.go b/pkg/services/ngalert/state/persister_sync.go new file mode 100644 index 0000000000000..bc8c5cf16736a --- /dev/null +++ b/pkg/services/ngalert/state/persister_sync.go @@ -0,0 +1,110 @@ +package state + +import ( + "context" + "time" + + "github.com/grafana/dskit/concurrency" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + + "github.com/grafana/grafana/pkg/infra/log" + ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +type SyncStatePersister struct { + log log.Logger + store InstanceStore + // doNotSaveNormalState controls whether eval.Normal state is persisted to the database and returned by get methods. + doNotSaveNormalState bool + // maxStateSaveConcurrency controls the number of goroutines (per rule) that can save alert state in parallel. + maxStateSaveConcurrency int +} + +func NewSyncStatePersisiter(log log.Logger, cfg ManagerCfg) StatePersister { + return &SyncStatePersister{ + log: log, + store: cfg.InstanceStore, + doNotSaveNormalState: cfg.DoNotSaveNormalState, + maxStateSaveConcurrency: cfg.MaxStateSaveConcurrency, + } +} + +func (a *SyncStatePersister) Async(_ context.Context, _ *cache) { + a.log.Debug("Async: No-Op") +} +func (a *SyncStatePersister) Sync(ctx context.Context, span trace.Span, states, staleStates []StateTransition) { + a.deleteAlertStates(ctx, staleStates) + if len(staleStates) > 0 { + span.AddEvent("deleted stale states", trace.WithAttributes( + attribute.Int64("state_transitions", int64(len(staleStates))), + )) + } + + a.saveAlertStates(ctx, states...) + span.AddEvent("updated database") +} + +func (a *SyncStatePersister) deleteAlertStates(ctx context.Context, states []StateTransition) { + if a.store == nil || len(states) == 0 { + return + } + + a.log.Debug("Deleting alert states", "count", len(states)) + toDelete := make([]ngModels.AlertInstanceKey, 0, len(states)) + + for _, s := range states { + key, err := s.GetAlertInstanceKey() + if err != nil { + a.log.Error("Failed to delete alert instance with invalid labels", "cacheID", s.CacheID, "error", err) + continue + } + toDelete = append(toDelete, key) + } + + err := a.store.DeleteAlertInstances(ctx, toDelete...) + if err != nil { + a.log.Error("Failed to delete stale states", "error", err) + } +} + +func (a *SyncStatePersister) saveAlertStates(ctx context.Context, states ...StateTransition) { + if a.store == nil || len(states) == 0 { + return + } + + saveState := func(ctx context.Context, idx int) error { + s := states[idx] + // Do not save normal state to database and remove transition to Normal state but keep mapped states + if a.doNotSaveNormalState && IsNormalStateWithNoReason(s.State) && !s.Changed() { + return nil + } + + key, err := s.GetAlertInstanceKey() + if err != nil { + a.log.Error("Failed to create a key for alert state to save it to database. The state will be ignored ", "cacheID", s.CacheID, "error", err, "labels", s.Labels.String()) + return nil + } + instance := ngModels.AlertInstance{ + AlertInstanceKey: key, + Labels: ngModels.InstanceLabels(s.Labels), + CurrentState: ngModels.InstanceStateType(s.State.State.String()), + CurrentReason: s.StateReason, + LastEvalTime: s.LastEvaluationTime, + CurrentStateSince: s.StartsAt, + CurrentStateEnd: s.EndsAt, + } + + err = a.store.SaveAlertInstance(ctx, instance) + if err != nil { + a.log.Error("Failed to save alert state", "labels", s.Labels.String(), "state", s.State, "error", err) + return nil + } + return nil + } + + start := time.Now() + a.log.Debug("Saving alert states", "count", len(states), "max_state_save_concurrency", a.maxStateSaveConcurrency) + _ = concurrency.ForEachJob(ctx, len(states), a.maxStateSaveConcurrency, saveState) + a.log.Debug("Saving alert states done", "count", len(states), "max_state_save_concurrency", a.maxStateSaveConcurrency, "duration", time.Since(start)) +} diff --git a/pkg/services/ngalert/state/persister_sync_test.go b/pkg/services/ngalert/state/persister_sync_test.go new file mode 100644 index 0000000000000..4465cebdb1e1e --- /dev/null +++ b/pkg/services/ngalert/state/persister_sync_test.go @@ -0,0 +1,103 @@ +package state + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/component-base/tracing" + + "github.com/grafana/grafana/pkg/infra/log/logtest" + "github.com/grafana/grafana/pkg/services/ngalert/eval" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/util" +) + +func TestSyncPersister_saveAlertStates(t *testing.T) { + type stateWithReason struct { + State eval.State + Reason string + } + create := func(s eval.State, r string) stateWithReason { + return stateWithReason{ + State: s, + Reason: r, + } + } + allStates := [...]stateWithReason{ + create(eval.Normal, ""), + create(eval.Normal, eval.NoData.String()), + create(eval.Normal, eval.Error.String()), + create(eval.Normal, util.GenerateShortUID()), + create(eval.Alerting, ""), + create(eval.Pending, ""), + create(eval.NoData, ""), + create(eval.Error, ""), + } + + transitionToKey := map[ngmodels.AlertInstanceKey]StateTransition{} + transitions := make([]StateTransition, 0) + for _, fromState := range allStates { + for i, toState := range allStates { + tr := StateTransition{ + State: &State{ + State: toState.State, + StateReason: toState.Reason, + Labels: ngmodels.GenerateAlertLabels(5, fmt.Sprintf("%d--", i)), + }, + PreviousState: fromState.State, + PreviousStateReason: fromState.Reason, + } + key, err := tr.GetAlertInstanceKey() + require.NoError(t, err) + transitionToKey[key] = tr + transitions = append(transitions, tr) + } + } + + t.Run("should save all transitions if doNotSaveNormalState is false", func(t *testing.T) { + trace := tracing.NewNoopTracerProvider().Tracer("test") + _, span := trace.Start(context.Background(), "") + st := &FakeInstanceStore{} + syncStatePersister := NewSyncStatePersisiter(&logtest.Fake{}, ManagerCfg{ + InstanceStore: st, + MaxStateSaveConcurrency: 1, + }) + syncStatePersister.Sync(context.Background(), span, transitions, nil) + savedKeys := map[ngmodels.AlertInstanceKey]ngmodels.AlertInstance{} + for _, op := range st.RecordedOps() { + saved := op.(ngmodels.AlertInstance) + savedKeys[saved.AlertInstanceKey] = saved + } + assert.Len(t, transitionToKey, len(savedKeys)) + + for key, tr := range transitionToKey { + assert.Containsf(t, savedKeys, key, "state %s (%s) was not saved but should be", tr.State.State, tr.StateReason) + } + }) + + t.Run("should not save Normal->Normal if doNotSaveNormalState is true", func(t *testing.T) { + trace := tracing.NewNoopTracerProvider().Tracer("test") + _, span := trace.Start(context.Background(), "") + st := &FakeInstanceStore{} + syncStatePersister := NewSyncStatePersisiter(&logtest.Fake{}, ManagerCfg{ + InstanceStore: st, + MaxStateSaveConcurrency: 1, + }) + syncStatePersister.Sync(context.Background(), span, transitions, nil) + + savedKeys := map[ngmodels.AlertInstanceKey]ngmodels.AlertInstance{} + for _, op := range st.RecordedOps() { + saved := op.(ngmodels.AlertInstance) + savedKeys[saved.AlertInstanceKey] = saved + } + for key, tr := range transitionToKey { + if tr.State.State == eval.Normal && tr.StateReason == "" && tr.PreviousState == eval.Normal && tr.PreviousStateReason == "" { + continue + } + assert.Containsf(t, savedKeys, key, "state %s (%s) was not saved but should be", tr.State.State, tr.StateReason) + } + }) +} diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go index 89ad132cac60c..11c86f6b10e41 100644 --- a/pkg/services/ngalert/state/state.go +++ b/pkg/services/ngalert/state/state.go @@ -2,6 +2,7 @@ package state import ( "context" + "encoding/json" "errors" "fmt" "math" @@ -34,6 +35,9 @@ type State struct { // StateReason is a textual description to explain why the state has its current state. StateReason string + // ResultFingerprint is a hash of labels of the result before it is processed by + ResultFingerprint data.Fingerprint + // Results contains the result of the current and previous evaluations. Results []Evaluation @@ -187,7 +191,7 @@ func NewEvaluationValues(m map[string]eval.NumberValueCapture) map[string]*float return result } -func resultNormal(state *State, _ *models.AlertRule, result eval.Result, logger log.Logger) { +func resultNormal(state *State, _ *models.AlertRule, result eval.Result, logger log.Logger, reason string) { if state.State == eval.Normal { logger.Debug("Keeping state", "state", state.State) } else { @@ -202,11 +206,11 @@ func resultNormal(state *State, _ *models.AlertRule, result eval.Result, logger "next_ends_at", nextEndsAt) // Normal states have the same start and end timestamps - state.SetNormal("", nextEndsAt, nextEndsAt) + state.SetNormal(reason, nextEndsAt, nextEndsAt) } } -func resultAlerting(state *State, rule *models.AlertRule, result eval.Result, logger log.Logger) { +func resultAlerting(state *State, rule *models.AlertRule, result eval.Result, logger log.Logger, reason string) { switch state.State { case eval.Alerting: prevEndsAt := state.EndsAt @@ -231,7 +235,7 @@ func resultAlerting(state *State, rule *models.AlertRule, result eval.Result, lo state.EndsAt, "next_ends_at", nextEndsAt) - state.SetAlerting("", result.EvaluatedAt, nextEndsAt) + state.SetAlerting(reason, result.EvaluatedAt, nextEndsAt) } default: nextEndsAt := nextEndsTime(rule.IntervalSeconds, result.EvaluatedAt) @@ -246,7 +250,7 @@ func resultAlerting(state *State, rule *models.AlertRule, result eval.Result, lo state.EndsAt, "next_ends_at", nextEndsAt) - state.SetPending("", result.EvaluatedAt, nextEndsAt) + state.SetPending(reason, result.EvaluatedAt, nextEndsAt) } else { logger.Debug("Changing state", "previous_state", @@ -257,18 +261,20 @@ func resultAlerting(state *State, rule *models.AlertRule, result eval.Result, lo state.EndsAt, "next_ends_at", nextEndsAt) - state.SetAlerting("", result.EvaluatedAt, nextEndsAt) + state.SetAlerting(reason, result.EvaluatedAt, nextEndsAt) } } } + func resultError(state *State, rule *models.AlertRule, result eval.Result, logger log.Logger) { + handlerStr := "resultError" + switch rule.ExecErrState { case models.AlertingErrState: - logger.Debug("Execution error state is Alerting", "handler", "resultAlerting", "previous_handler", "resultError") - resultAlerting(state, rule, result, logger) + logger.Debug("Execution error state is Alerting", "handler", "resultAlerting", "previous_handler", handlerStr) + resultAlerting(state, rule, result, logger, models.StateReasonError) // This is a special case where Alerting and Pending should also have an error and reason state.Error = result.Error - state.StateReason = models.StateReasonError case models.ErrorErrState: if state.State == eval.Error { prevEndsAt := state.EndsAt @@ -312,8 +318,11 @@ func resultError(state *State, rule *models.AlertRule, result eval.Result, logge } } case models.OkErrState: - logger.Debug("Execution error state is Normal", "handler", "resultNormal", "previous_handler", "resultError") - resultNormal(state, rule, result, logger) + logger.Debug("Execution error state is Normal", "handler", "resultNormal", "previous_handler", handlerStr) + resultNormal(state, rule, result, logger, "") // TODO: Should we add a reason? + case models.KeepLastErrState: + logger := logger.New("previous_handler", handlerStr) + resultKeepLast(state, rule, result, logger) default: err := fmt.Errorf("unsupported execution error state: %s", rule.ExecErrState) state.SetError(err, state.StartsAt, nextEndsTime(rule.IntervalSeconds, result.EvaluatedAt)) @@ -322,11 +331,12 @@ func resultError(state *State, rule *models.AlertRule, result eval.Result, logge } func resultNoData(state *State, rule *models.AlertRule, result eval.Result, logger log.Logger) { + handlerStr := "resultNoData" + switch rule.NoDataState { case models.Alerting: - logger.Debug("Execution no data state is Alerting", "handler", "resultAlerting", "previous_handler", "resultNoData") - resultAlerting(state, rule, result, logger) - state.StateReason = models.StateReasonNoData + logger.Debug("Execution no data state is Alerting", "handler", "resultAlerting", "previous_handler", handlerStr) + resultAlerting(state, rule, result, logger, models.StateReasonNoData) case models.NoData: if state.State == eval.NoData { prevEndsAt := state.EndsAt @@ -353,9 +363,11 @@ func resultNoData(state *State, rule *models.AlertRule, result eval.Result, logg state.SetNoData("", result.EvaluatedAt, nextEndsAt) } case models.OK: - logger.Debug("Execution no data state is Normal", "handler", "resultNormal", "previous_handler", "resultNoData") - resultNormal(state, rule, result, logger) - state.StateReason = models.StateReasonNoData + logger.Debug("Execution no data state is Normal", "handler", "resultNormal", "previous_handler", handlerStr) + resultNormal(state, rule, result, logger, models.StateReasonNoData) + case models.KeepLast: + logger := logger.New("previous_handler", handlerStr) + resultKeepLast(state, rule, result, logger) default: err := fmt.Errorf("unsupported no data state: %s", rule.NoDataState) state.SetError(err, state.StartsAt, nextEndsTime(rule.IntervalSeconds, result.EvaluatedAt)) @@ -363,6 +375,31 @@ func resultNoData(state *State, rule *models.AlertRule, result eval.Result, logg } } +func resultKeepLast(state *State, rule *models.AlertRule, result eval.Result, logger log.Logger) { + reason := models.ConcatReasons(result.State.String(), models.StateReasonKeepLast) + + switch state.State { + case eval.Alerting: + logger.Debug("Execution keep last state is Alerting", "handler", "resultAlerting") + resultAlerting(state, rule, result, logger, reason) + case eval.Pending: + // respect 'for' setting on rule + if result.EvaluatedAt.Sub(state.StartsAt) >= rule.For { + logger.Debug("Execution keep last state is Pending", "handler", "resultAlerting") + resultAlerting(state, rule, result, logger, reason) + } else { + logger.Debug("Ignoring set next state to pending") + } + case eval.Normal: + logger.Debug("Execution keep last state is Normal", "handler", "resultNormal") + resultNormal(state, rule, result, logger, reason) + default: + // this should not happen, add as failsafe + logger.Debug("Reverting invalid state to normal", "handler", "resultNormal") + resultNormal(state, rule, result, logger, reason) + } +} + func (a *State) NeedsSending(resendDelay time.Duration) bool { switch a.State { case eval.Pending: @@ -479,8 +516,36 @@ func FormatStateAndReason(state eval.State, reason string) string { return s } +// ParseFormattedState parses a state string in the format "state (reason)" +// and returns the state and reason separately. +func ParseFormattedState(stateStr string) (eval.State, string, error) { + p := 0 + // walk string until we find a space + for i, c := range stateStr { + if c == ' ' { + p = i + break + } + } + if p == 0 { + p = len(stateStr) + } + + state, err := eval.ParseStateString(stateStr[:p]) + if err != nil { + return -1, "", err + } + + if p == len(stateStr) { + return state, "", nil + } + + reason := strings.Trim(stateStr[p+1:], "()") + return state, reason, nil +} + // GetRuleExtraLabels returns a map of built-in labels that should be added to an alert before it is sent to the Alertmanager or its state is cached. -func GetRuleExtraLabels(rule *models.AlertRule, folderTitle string, includeFolder bool) map[string]string { +func GetRuleExtraLabels(l log.Logger, rule *models.AlertRule, folderTitle string, includeFolder bool) map[string]string { extraLabels := make(map[string]string, 4) extraLabels[alertingModels.NamespaceUIDLabel] = rule.NamespaceUID @@ -490,5 +555,15 @@ func GetRuleExtraLabels(rule *models.AlertRule, folderTitle string, includeFolde if includeFolder { extraLabels[models.FolderTitleLabel] = folderTitle } + + if len(rule.NotificationSettings) > 0 { + // Notification settings are defined as a slice to workaround xorm behavior. + // Any items past the first should not exist so we ignore them. + if len(rule.NotificationSettings) > 1 { + ignored, _ := json.Marshal(rule.NotificationSettings[1:]) + l.Error("Detected multiple notification settings, which is not supported. Only the first will be applied", "ignored_settings", string(ignored)) + } + return mergeLabels(extraLabels, rule.NotificationSettings[0].ToLabels()) + } return extraLabels } diff --git a/pkg/services/ngalert/state/state_test.go b/pkg/services/ngalert/state/state_test.go index 03e72d76e31db..1684b7036c17f 100644 --- a/pkg/services/ngalert/state/state_test.go +++ b/pkg/services/ngalert/state/state_test.go @@ -10,9 +10,13 @@ import ( "github.com/benbjohnson/clock" "github.com/golang/mock/gomock" + "github.com/google/uuid" + "github.com/grafana/alerting/models" + "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/eval" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/screenshot" @@ -666,3 +670,112 @@ func TestTakeImage(t *testing.T) { assert.Equal(t, ngmodels.Image{Path: "foo.png"}, *image) }) } + +func TestParseFormattedState(t *testing.T) { + t.Run("should parse formatted state", func(t *testing.T) { + stateStr := "Normal (MissingSeries)" + s, reason, err := ParseFormattedState(stateStr) + require.NoError(t, err) + + require.Equal(t, eval.Normal, s) + require.Equal(t, ngmodels.StateReasonMissingSeries, reason) + }) + + t.Run("should parse formatted state with concatenated reasons", func(t *testing.T) { + stateStr := "Normal (Error, KeepLast)" + s, reason, err := ParseFormattedState(stateStr) + require.NoError(t, err) + + require.Equal(t, eval.Normal, s) + require.Equal(t, ngmodels.ConcatReasons(ngmodels.StateReasonError, ngmodels.StateReasonKeepLast), reason) + }) + + t.Run("should error on empty string", func(t *testing.T) { + stateStr := "" + _, _, err := ParseFormattedState(stateStr) + require.Error(t, err) + }) + + t.Run("should error on invalid string content", func(t *testing.T) { + stateStr := "NotAState" + _, _, err := ParseFormattedState(stateStr) + require.Error(t, err) + }) +} + +func TestGetRuleExtraLabels(t *testing.T) { + logger := log.New() + + rule := ngmodels.AlertRuleGen()() + rule.NotificationSettings = nil + folderTitle := uuid.NewString() + + ns := ngmodels.NotificationSettings{ + Receiver: "Test", + GroupBy: []string{"alertname"}, + GroupWait: util.Pointer(model.Duration(1 * time.Second)), + } + + testCases := map[string]struct { + rule *ngmodels.AlertRule + includeFolder bool + expected map[string]string + }{ + "no_folder_no_notification": { + rule: ngmodels.CopyRule(rule), + includeFolder: false, + expected: map[string]string{ + models.NamespaceUIDLabel: rule.NamespaceUID, + model.AlertNameLabel: rule.Title, + models.RuleUIDLabel: rule.UID, + }, + }, + "with_folder_no_notification": { + rule: ngmodels.CopyRule(rule), + includeFolder: true, + expected: map[string]string{ + models.NamespaceUIDLabel: rule.NamespaceUID, + model.AlertNameLabel: rule.Title, + models.RuleUIDLabel: rule.UID, + models.FolderTitleLabel: folderTitle, + }, + }, + "with_notification": { + rule: func() *ngmodels.AlertRule { + r := ngmodels.CopyRule(rule) + r.NotificationSettings = []ngmodels.NotificationSettings{ns} + return r + }(), + expected: map[string]string{ + models.NamespaceUIDLabel: rule.NamespaceUID, + model.AlertNameLabel: rule.Title, + models.RuleUIDLabel: rule.UID, + ngmodels.AutogeneratedRouteLabel: "true", + ngmodels.AutogeneratedRouteReceiverNameLabel: ns.Receiver, + ngmodels.AutogeneratedRouteSettingsHashLabel: ns.Fingerprint().String(), + }, + }, + "ignore_multiple_notifications": { + rule: func() *ngmodels.AlertRule { + r := ngmodels.CopyRule(rule) + r.NotificationSettings = []ngmodels.NotificationSettings{ns, ngmodels.NotificationSettingsGen()(), ngmodels.NotificationSettingsGen()()} + return r + }(), + expected: map[string]string{ + models.NamespaceUIDLabel: rule.NamespaceUID, + model.AlertNameLabel: rule.Title, + models.RuleUIDLabel: rule.UID, + ngmodels.AutogeneratedRouteLabel: "true", + ngmodels.AutogeneratedRouteReceiverNameLabel: ns.Receiver, + ngmodels.AutogeneratedRouteSettingsHashLabel: ns.Fingerprint().String(), + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + result := GetRuleExtraLabels(logger, tc.rule, folderTitle, tc.includeFolder) + require.Equal(t, tc.expected, result) + }) + } +} diff --git a/pkg/services/ngalert/state/testing.go b/pkg/services/ngalert/state/testing.go index 2809f0ca6e70a..07c05aa6b356d 100644 --- a/pkg/services/ngalert/state/testing.go +++ b/pkg/services/ngalert/state/testing.go @@ -2,6 +2,7 @@ package state import ( "context" + "slices" "sync" "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -13,7 +14,7 @@ var _ InstanceStore = &FakeInstanceStore{} type FakeInstanceStore struct { mtx sync.Mutex - RecordedOps []any + recordedOps []any } type FakeInstanceStoreOp struct { @@ -21,17 +22,23 @@ type FakeInstanceStoreOp struct { Args []any } +func (f *FakeInstanceStore) RecordedOps() []any { + f.mtx.Lock() + defer f.mtx.Unlock() + return slices.Clone(f.recordedOps) +} + func (f *FakeInstanceStore) ListAlertInstances(_ context.Context, q *models.ListAlertInstancesQuery) ([]*models.AlertInstance, error) { f.mtx.Lock() defer f.mtx.Unlock() - f.RecordedOps = append(f.RecordedOps, *q) + f.recordedOps = append(f.recordedOps, *q) return nil, nil } func (f *FakeInstanceStore) SaveAlertInstance(_ context.Context, q models.AlertInstance) error { f.mtx.Lock() defer f.mtx.Unlock() - f.RecordedOps = append(f.RecordedOps, q) + f.recordedOps = append(f.recordedOps, q) return nil } @@ -40,7 +47,7 @@ func (f *FakeInstanceStore) FetchOrgIds(_ context.Context) ([]int64, error) { re func (f *FakeInstanceStore) DeleteAlertInstances(ctx context.Context, q ...models.AlertInstanceKey) error { f.mtx.Lock() defer f.mtx.Unlock() - f.RecordedOps = append(f.RecordedOps, FakeInstanceStoreOp{ + f.recordedOps = append(f.recordedOps, FakeInstanceStoreOp{ Name: "DeleteAlertInstances", Args: []any{ ctx, q, @@ -53,6 +60,16 @@ func (f *FakeInstanceStore) DeleteAlertInstancesByRule(ctx context.Context, key return nil } +func (f *FakeInstanceStore) FullSync(ctx context.Context, instances []models.AlertInstance) error { + f.mtx.Lock() + defer f.mtx.Unlock() + f.recordedOps = []any{} + for _, instance := range instances { + f.recordedOps = append(f.recordedOps, instance) + } + return nil +} + type FakeRuleReader struct{} func (f *FakeRuleReader) ListAlertRules(_ context.Context, q *models.ListAlertRulesQuery) (models.RulesGroup, error) { diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 49161802f28aa..44048490f1c4a 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -2,24 +2,28 @@ package store import ( "context" + "encoding/json" "errors" "fmt" "strings" "github.com/google/uuid" + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/search/model" + "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/util" + "xorm.io/xorm" ) // AlertRuleMaxTitleLength is the maximum length of the alert rule title @@ -29,8 +33,7 @@ const AlertRuleMaxTitleLength = 190 const AlertRuleMaxRuleGroupNameLength = 190 var ( - ErrAlertRuleGroupNotFound = errors.New("rulegroup not found") - ErrOptimisticLock = errors.New("version conflict while updating a record in the database with optimistic locking") + ErrOptimisticLock = errors.New("version conflict while updating a record in the database with optimistic locking") ) func getAlertRuleByUID(sess *db.Session, alertRuleUID string, orgID int64) (*ngmodels.AlertRule, error) { @@ -72,8 +75,8 @@ func (st DBstore) DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUI } // IncreaseVersionForAllRulesInNamespace Increases version for all rules that have specified namespace. Returns all rules that belong to the namespace -func (st DBstore) IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersionAndPauseStatus, error) { - var keys []ngmodels.AlertRuleKeyWithVersionAndPauseStatus +func (st DBstore) IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersion, error) { + var keys []ngmodels.AlertRuleKeyWithVersion err := st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error { now := TimeNow() _, err := sess.Exec("UPDATE alert_rule SET version = version + 1, updated = ? WHERE namespace_uid = ? AND org_id = ?", now, namespaceUID, orgID) @@ -141,22 +144,23 @@ func (st DBstore) InsertAlertRules(ctx context.Context, rules []ngmodels.AlertRu } newRules = append(newRules, r) ruleVersions = append(ruleVersions, ngmodels.AlertRuleVersion{ - RuleUID: r.UID, - RuleOrgID: r.OrgID, - RuleNamespaceUID: r.NamespaceUID, - RuleGroup: r.RuleGroup, - ParentVersion: 0, - Version: r.Version, - Created: r.Updated, - Condition: r.Condition, - Title: r.Title, - Data: r.Data, - IntervalSeconds: r.IntervalSeconds, - NoDataState: r.NoDataState, - ExecErrState: r.ExecErrState, - For: r.For, - Annotations: r.Annotations, - Labels: r.Labels, + RuleUID: r.UID, + RuleOrgID: r.OrgID, + RuleNamespaceUID: r.NamespaceUID, + RuleGroup: r.RuleGroup, + ParentVersion: 0, + Version: r.Version, + Created: r.Updated, + Condition: r.Condition, + Title: r.Title, + Data: r.Data, + IntervalSeconds: r.IntervalSeconds, + NoDataState: r.NoDataState, + ExecErrState: r.ExecErrState, + For: r.For, + Annotations: r.Annotations, + Labels: r.Labels, + NotificationSettings: r.NotificationSettings, }) } if len(newRules) > 0 { @@ -165,7 +169,7 @@ func (st DBstore) InsertAlertRules(ctx context.Context, rules []ngmodels.AlertRu for i := range newRules { if _, err := sess.Insert(&newRules[i]); err != nil { if st.SQLStore.GetDialect().IsUniqueConstraintViolation(err) { - return ngmodels.ErrAlertRuleUniqueConstraintViolation + return ngmodels.ErrAlertRuleConflict(newRules[i], ngmodels.ErrAlertRuleUniqueConstraintViolation) } return fmt.Errorf("failed to create new rules: %w", err) } @@ -208,7 +212,7 @@ func (st DBstore) UpdateAlertRules(ctx context.Context, rules []ngmodels.UpdateR if updated, err := sess.ID(r.Existing.ID).AllCols().Update(r.New); err != nil || updated == 0 { if err != nil { if st.SQLStore.GetDialect().IsUniqueConstraintViolation(err) { - return ngmodels.ErrAlertRuleUniqueConstraintViolation + return ngmodels.ErrAlertRuleConflict(r.New, ngmodels.ErrAlertRuleUniqueConstraintViolation) } return fmt.Errorf("failed to update rule [%s] %s: %w", r.New.UID, r.New.Title, err) } @@ -216,23 +220,24 @@ func (st DBstore) UpdateAlertRules(ctx context.Context, rules []ngmodels.UpdateR } parentVersion = r.Existing.Version ruleVersions = append(ruleVersions, ngmodels.AlertRuleVersion{ - RuleOrgID: r.New.OrgID, - RuleUID: r.New.UID, - RuleNamespaceUID: r.New.NamespaceUID, - RuleGroup: r.New.RuleGroup, - RuleGroupIndex: r.New.RuleGroupIndex, - ParentVersion: parentVersion, - Version: r.New.Version + 1, - Created: r.New.Updated, - Condition: r.New.Condition, - Title: r.New.Title, - Data: r.New.Data, - IntervalSeconds: r.New.IntervalSeconds, - NoDataState: r.New.NoDataState, - ExecErrState: r.New.ExecErrState, - For: r.New.For, - Annotations: r.New.Annotations, - Labels: r.New.Labels, + RuleOrgID: r.New.OrgID, + RuleUID: r.New.UID, + RuleNamespaceUID: r.New.NamespaceUID, + RuleGroup: r.New.RuleGroup, + RuleGroupIndex: r.New.RuleGroupIndex, + ParentVersion: parentVersion, + Version: r.New.Version + 1, + Created: r.New.Updated, + Condition: r.New.Condition, + Title: r.New.Title, + Data: r.New.Data, + IntervalSeconds: r.New.IntervalSeconds, + NoDataState: r.New.NoDataState, + ExecErrState: r.New.ExecErrState, + For: r.New.For, + Annotations: r.New.Annotations, + Labels: r.New.Labels, + NotificationSettings: r.New.NotificationSettings, }) } if len(ruleVersions) > 0 { @@ -317,11 +322,18 @@ func newTitlesOverlapExisting(rules []ngmodels.UpdateRule) bool { // CountInFolder is a handler for retrieving the number of alert rules of // specific organisation associated with a given namespace (parent folder). -func (st DBstore) CountInFolder(ctx context.Context, orgID int64, folderUID string, u identity.Requester) (int64, error) { +func (st DBstore) CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, u identity.Requester) (int64, error) { + if len(folderUIDs) == 0 { + return 0, nil + } var count int64 var err error err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { - q := sess.Table("alert_rule").Where("org_id = ?", orgID).Where("namespace_uid = ?", folderUID) + args := make([]any, 0, len(folderUIDs)) + for _, folderUID := range folderUIDs { + args = append(args, folderUID) + } + q := sess.Table("alert_rule").Where("org_id = ?", orgID).Where(fmt.Sprintf("namespace_uid IN (%s)", strings.Repeat("?,", len(folderUIDs)-1)+"?"), args...) count, err = q.Count() return err }) @@ -358,6 +370,13 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR q = q.Where("rule_group = ?", query.RuleGroup) } + if query.ReceiverName != "" { + q, err = st.filterByReceiverName(query.ReceiverName, q) + if err != nil { + return err + } + } + q = q.Asc("namespace_uid", "rule_group", "rule_group_idx", "id") alertRules := make([]*ngmodels.AlertRule, 0) @@ -378,6 +397,13 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR st.Logger.Error("Invalid rule found in DB store, ignoring it", "func", "ListAlertRules", "error", err) continue } + if query.ReceiverName != "" { // remove false-positive hits from the result + if !slices.ContainsFunc(rule.NotificationSettings, func(settings ngmodels.NotificationSettings) bool { + return settings.Receiver == query.ReceiverName + }) { + continue + } + } alertRules = append(alertRules, rule) } @@ -419,74 +445,41 @@ func (st DBstore) GetRuleGroupInterval(ctx context.Context, orgID int64, namespa ngmodels.AlertRule{OrgID: orgID, RuleGroup: ruleGroup, NamespaceUID: namespaceUID}, ) if len(ruleGroups) == 0 { - return ErrAlertRuleGroupNotFound + return ngmodels.ErrAlertRuleGroupNotFound.Errorf("") } interval = ruleGroups[0].IntervalSeconds return err }) } -// GetUserVisibleNamespaces returns the folders that are visible to the user and have at least one alert in it +// GetUserVisibleNamespaces returns the folders that are visible to the user func (st DBstore) GetUserVisibleNamespaces(ctx context.Context, orgID int64, user identity.Requester) (map[string]*folder.Folder, error) { - namespaceMap := make(map[string]*folder.Folder) - - searchQuery := dashboards.FindPersistedDashboardsQuery{ - OrgId: orgID, + folders, err := st.FolderService.GetFolders(ctx, folder.GetFoldersQuery{ + OrgID: orgID, + WithFullpath: true, SignedInUser: user, - Type: searchstore.TypeAlertFolder, - Limit: -1, - Permission: dashboardaccess.PERMISSION_VIEW, - Sort: model.SortOption{}, - Filters: []any{ - searchstore.FolderWithAlertsFilter{}, - }, - } - - var page int64 = 1 - for { - query := searchQuery - query.Page = page - proj, err := st.DashboardService.FindDashboards(ctx, &query) - if err != nil { - return nil, err - } - - if len(proj) == 0 { - break - } - - for _, hit := range proj { - if !hit.IsFolder { - continue - } - namespaceMap[hit.UID] = &folder.Folder{ - UID: hit.UID, - Title: hit.Title, - } - } - page += 1 - } - return namespaceMap, nil -} - -// GetNamespaceByTitle is a handler for retrieving a namespace by its title. Alerting rules follow a Grafana folder-like structure which we call namespaces. -func (st DBstore) GetNamespaceByTitle(ctx context.Context, namespace string, orgID int64, user identity.Requester) (*folder.Folder, error) { - folder, err := st.FolderService.Get(ctx, &folder.GetFolderQuery{OrgID: orgID, Title: &namespace, SignedInUser: user}) + }) if err != nil { return nil, err } - return folder, nil + namespaceMap := make(map[string]*folder.Folder) + for _, f := range folders { + namespaceMap[f.UID] = f + } + return namespaceMap, nil } // GetNamespaceByUID is a handler for retrieving a namespace by its UID. Alerting rules follow a Grafana folder-like structure which we call namespaces. func (st DBstore) GetNamespaceByUID(ctx context.Context, uid string, orgID int64, user identity.Requester) (*folder.Folder, error) { - folder, err := st.FolderService.Get(ctx, &folder.GetFolderQuery{OrgID: orgID, UID: &uid, SignedInUser: user}) + f, err := st.FolderService.GetFolders(ctx, folder.GetFoldersQuery{OrgID: orgID, UIDs: []string{uid}, WithFullpath: true, SignedInUser: user}) if err != nil { return nil, err } - - return folder, nil + if len(f) == 0 { + return nil, dashboards.ErrFolderAccessDenied + } + return f[0], nil } func (st DBstore) GetAlertRulesKeysForScheduling(ctx context.Context) ([]ngmodels.AlertRuleKeyWithVersion, error) { @@ -514,10 +507,6 @@ func (st DBstore) GetAlertRulesKeysForScheduling(ctx context.Context) ([]ngmodel // GetAlertRulesForScheduling returns a short version of all alert rules except those that belong to an excluded list of organizations func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodels.GetAlertRulesForSchedulingQuery) error { - var folders []struct { - Uid string - Title string - } var rules []*ngmodels.AlertRule return st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { var disabledOrgs []int64 @@ -552,10 +541,12 @@ func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodel st.Logger.Error("Invalid rule found in DB store, ignoring it", "func", "GetAlertRulesForScheduling", "error", err) continue } - if optimizations, err := OptimizeAlertQueries(rule.Data); err != nil { - st.Logger.Error("Could not migrate rule from range to instant query", "rule", rule.UID, "err", err) - } else if len(optimizations) > 0 { - st.Logger.Info("Migrated rule from range to instant query", "rule", rule.UID, "migrated_queries", len(optimizations)) + if st.FeatureToggles.IsEnabled(ctx, featuremgmt.FlagAlertingQueryOptimization) { + if optimizations, err := OptimizeAlertQueries(rule.Data); err != nil { + st.Logger.Error("Could not migrate rule from range to instant query", "rule", rule.UID, "err", err) + } else if len(optimizations) > 0 { + st.Logger.Info("Migrated rule from range to instant query", "rule", rule.UID, "migrated_queries", len(optimizations)) + } } rules = append(rules, rule) } @@ -563,19 +554,36 @@ func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodel query.ResultRules = rules if query.PopulateFolders { - foldersSql := sess.Table("dashboard").Alias("d").Select("d.uid, d.title"). - Where("is_folder = ?", st.SQLStore.GetDialect().BooleanStr(true)). - And(`EXISTS (SELECT 1 FROM alert_rule a WHERE d.uid = a.namespace_uid)`) - if len(disabledOrgs) > 0 { - foldersSql.NotIn("org_id", disabledOrgs) - } - - if err := foldersSql.Find(&folders); err != nil { - return fmt.Errorf("failed to fetch a list of folders that contain alert rules: %w", err) + query.ResultFoldersTitles = map[ngmodels.FolderKey]string{} + uids := map[int64]map[string]struct{}{} + for _, r := range rules { + om, ok := uids[r.OrgID] + if !ok { + om = make(map[string]struct{}) + uids[r.OrgID] = om + } + om[r.NamespaceUID] = struct{}{} } - query.ResultFoldersTitles = make(map[string]string, len(folders)) - for _, folder := range folders { - query.ResultFoldersTitles[folder.Uid] = folder.Title + for orgID, uids := range uids { + schedulerUser := accesscontrol.BackgroundUser("grafana_scheduler", orgID, org.RoleAdmin, + []accesscontrol.Permission{ + { + Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll, + }, + }) + + folders, err := st.FolderService.GetFolders(ctx, folder.GetFoldersQuery{ + OrgID: orgID, + UIDs: maps.Keys(uids), + WithFullpath: true, + SignedInUser: schedulerUser, + }) + if err != nil { + return fmt.Errorf("failed to fetch a list of folders that contain alert rules: %w", err) + } + for _, f := range folders { + query.ResultFoldersTitles[ngmodels.FolderKey{OrgID: f.OrgID, UID: f.UID}] = f.Fullpath + } } } return nil @@ -583,35 +591,37 @@ func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodel } // DeleteInFolder deletes the rules contained in a given folder along with their associated data. -func (st DBstore) DeleteInFolder(ctx context.Context, orgID int64, folderUID string, user identity.Requester) error { - evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAlertingRuleDelete, dashboards.ScopeFoldersProvider.GetResourceScopeName(folderUID)) - canSave, err := st.AccessControl.Evaluate(ctx, user, evaluator) - if err != nil { - st.Logger.Error("Failed to evaluate access control", "error", err) - return err - } - if !canSave { - st.Logger.Error("user is not allowed to delete alert rules in folder", "folder", folderUID, "user") - return dashboards.ErrFolderAccessDenied - } +func (st DBstore) DeleteInFolders(ctx context.Context, orgID int64, folderUIDs []string, user identity.Requester) error { + for _, folderUID := range folderUIDs { + evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAlertingRuleDelete, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folderUID)) + canSave, err := st.AccessControl.Evaluate(ctx, user, evaluator) + if err != nil { + st.Logger.Error("Failed to evaluate access control", "error", err) + return err + } + if !canSave { + st.Logger.Error("user is not allowed to delete alert rules in folder", "folder", folderUID, "user") + return dashboards.ErrFolderAccessDenied + } - rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{ - OrgID: orgID, - NamespaceUIDs: []string{folderUID}, - }) - if err != nil { - return err - } + rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{ + OrgID: orgID, + NamespaceUIDs: []string{folderUID}, + }) + if err != nil { + return err + } - uids := make([]string, 0, len(rules)) - for _, tgt := range rules { - if tgt != nil { - uids = append(uids, tgt.UID) + uids := make([]string, 0, len(rules)) + for _, tgt := range rules { + if tgt != nil { + uids = append(uids, tgt.UID) + } } - } - if err := st.DeleteAlertRulesByUID(ctx, orgID, uids...); err != nil { - return err + if err := st.DeleteAlertRulesByUID(ctx, orgID, uids...); err != nil { + return err + } } return nil } @@ -657,3 +667,91 @@ func (st DBstore) validateAlertRule(alertRule ngmodels.AlertRule) error { return nil } + +// ListNotificationSettings fetches all notification settings for given organization +func (st DBstore) ListNotificationSettings(ctx context.Context, q ngmodels.ListNotificationSettingsQuery) (map[ngmodels.AlertRuleKey][]ngmodels.NotificationSettings, error) { + var rules []ngmodels.AlertRule + err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { + query := sess.Table(ngmodels.AlertRule{}).Select("uid, notification_settings").Where("org_id = ?", q.OrgID) + if q.ReceiverName != "" { + var err error + query, err = st.filterByReceiverName(q.ReceiverName, query) + if err != nil { + return err + } + } else { + query = query.And("notification_settings IS NOT NULL AND notification_settings <> 'null'") + } + return query.Find(&rules) + }) + if err != nil { + return nil, err + } + result := make(map[ngmodels.AlertRuleKey][]ngmodels.NotificationSettings, len(rules)) + for _, rule := range rules { + var ns []ngmodels.NotificationSettings + if q.ReceiverName != "" { // if filter by receiver name is specified, perform fine filtering on client to avoid false-positives + for _, setting := range rule.NotificationSettings { + if q.ReceiverName == setting.Receiver { // currently, there can be only one setting. If in future there are more, we will return all settings of a rule that has a setting with receiver + ns = rule.NotificationSettings + break + } + } + } else { + ns = rule.NotificationSettings + } + if len(ns) > 0 { + key := ngmodels.AlertRuleKey{ + OrgID: q.OrgID, + UID: rule.UID, + } + result[key] = rule.NotificationSettings + } + } + return result, nil +} + +func (st DBstore) filterByReceiverName(receiver string, sess *xorm.Session) (*xorm.Session, error) { + if receiver == "" { + return sess, nil + } + // marshall string according to JSON rules so we follow escaping rules. + b, err := json.Marshal(receiver) + if err != nil { + return nil, fmt.Errorf("failed to marshall receiver name query: %w", err) + } + var search = string(b) + if st.SQLStore.GetDialect().DriverName() != migrator.SQLite { + // this escapes escaped double quote (\") to \\\" + search = strings.ReplaceAll(strings.ReplaceAll(search, `\`, `\\`), `"`, `\"`) + } + return sess.And(fmt.Sprintf("notification_settings %s ?", st.SQLStore.GetDialect().LikeStr()), "%"+search+"%"), nil +} + +func (st DBstore) RenameReceiverInNotificationSettings(ctx context.Context, orgID int64, oldReceiver, newReceiver string) (int, error) { + // fetch entire rules because Update method requires it because it copies rules to version table + rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{ + OrgID: orgID, + ReceiverName: oldReceiver, + }) + if err != nil { + return 0, err + } + if len(rules) == 0 { + return 0, nil + } + var updates []ngmodels.UpdateRule + for _, rule := range rules { + r := ngmodels.CopyRule(rule) + for idx := range r.NotificationSettings { + if r.NotificationSettings[idx].Receiver == oldReceiver { + r.NotificationSettings[idx].Receiver = newReceiver + } + } + updates = append(updates, ngmodels.UpdateRule{ + Existing: rule, + New: *r, + }) + } + return len(updates), st.UpdateAlertRules(ctx, updates) +} diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 1b15c1db45ecb..ca0e0765b9237 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -5,16 +5,19 @@ import ( "errors" "fmt" "strings" + "sync" "testing" "time" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -44,7 +47,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) { store := &DBstore{ SQLStore: sqlStore, Cfg: cfg.UnifiedAlerting, - FolderService: setupFolderService(t, sqlStore, cfg), + FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()), Logger: &logtest.Fake{}, } generator := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueID()) @@ -98,7 +101,7 @@ func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T) store := &DBstore{ SQLStore: sqlStore, Cfg: cfg.UnifiedAlerting, - FolderService: setupFolderService(t, sqlStore, cfg), + FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()), Logger: &logtest.Fake{}, } @@ -316,6 +319,28 @@ func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T) require.Equal(t, newRule3.Title, dbrule3.Title) require.Equal(t, newRule4.Title, dbrule4.Title) }) + + t.Run("should fail with unique constraint violation", func(t *testing.T) { + rule1 := createRuleInFolder("unique-rule1", 1, "my-namespace") + rule2 := createRuleInFolder("unique-rule2", 1, "my-namespace") + + newRule1 := models.CopyRule(rule1) + newRule2 := models.CopyRule(rule2) + newRule2.Title = newRule1.Title + + err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + Existing: rule2, + New: *newRule2, + }, + }) + require.ErrorIs(t, err, models.ErrAlertRuleUniqueConstraintViolation) + require.NotEqual(t, newRule2.UID, "") + require.NotEqual(t, newRule2.Title, "") + require.NotEqual(t, newRule2.NamespaceUID, "") + require.ErrorContains(t, err, newRule2.UID) + require.ErrorContains(t, err, newRule2.Title) + require.ErrorContains(t, err, newRule2.NamespaceUID) + }) } func TestIntegration_GetAlertRulesForScheduling(t *testing.T) { @@ -332,22 +357,31 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) { store := &DBstore{ SQLStore: sqlStore, Cfg: cfg.UnifiedAlerting, - FolderService: setupFolderService(t, sqlStore, cfg), + FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()), FeatureToggles: featuremgmt.WithFeatures(), } generator := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueID(), models.WithUniqueOrgID()) rule1 := createRule(t, store, generator) rule2 := createRule(t, store, generator) - createFolder(t, store, rule1.NamespaceUID, rule1.Title, rule1.OrgID) - createFolder(t, store, rule2.NamespaceUID, rule2.Title, rule2.OrgID) + + parentFolderUid := uuid.NewString() + parentFolderTitle := "Very Parent Folder" + createFolder(t, store, parentFolderUid, parentFolderTitle, rule1.OrgID, "") + rule1FolderTitle := "folder-" + rule1.Title + rule2FolderTitle := "folder-" + rule2.Title + createFolder(t, store, rule1.NamespaceUID, rule1FolderTitle, rule1.OrgID, parentFolderUid) + createFolder(t, store, rule2.NamespaceUID, rule2FolderTitle, rule2.OrgID, "") + + createFolder(t, store, rule2.NamespaceUID, "same UID folder", generator().OrgID, "") // create a folder with the same UID but in the different org tc := []struct { name string rules []string ruleGroups []string disabledOrgs []int64 - folders map[string]string + folders map[models.FolderKey]string + flags []string }{ { name: "without a rule group filter, it returns all created rules", @@ -366,13 +400,13 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) { { name: "with populate folders enabled, it returns them", rules: []string{rule1.Title, rule2.Title}, - folders: map[string]string{rule1.NamespaceUID: rule1.Title, rule2.NamespaceUID: rule2.Title}, + folders: map[models.FolderKey]string{rule1.GetFolderKey(): rule1FolderTitle, rule2.GetFolderKey(): rule2FolderTitle}, }, { name: "with populate folders enabled and a filter on orgs, it only returns selected information", rules: []string{rule1.Title}, disabledOrgs: []int64{rule2.OrgID}, - folders: map[string]string{rule1.NamespaceUID: rule1.Title}, + folders: map[models.FolderKey]string{rule1.GetFolderKey(): rule1FolderTitle}, }, } @@ -409,6 +443,20 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) { } }) } + + t.Run("when nested folders are enabled folders should contain full path", func(t *testing.T) { + store.FolderService = setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) + query := &models.GetAlertRulesForSchedulingQuery{ + PopulateFolders: true, + } + require.NoError(t, store.GetAlertRulesForScheduling(context.Background(), query)) + + expected := map[models.FolderKey]string{ + rule1.GetFolderKey(): parentFolderTitle + "/" + rule1FolderTitle, + rule2.GetFolderKey(): rule2FolderTitle, + } + require.Equal(t, expected, query.ResultFoldersTitles) + }) } func withIntervalMatching(baseInterval time.Duration) func(*models.AlertRule) { @@ -425,7 +473,7 @@ func TestIntegration_CountAlertRules(t *testing.T) { sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() - store := &DBstore{SQLStore: sqlStore, FolderService: setupFolderService(t, sqlStore, cfg)} + store := &DBstore{SQLStore: sqlStore, FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())} rule := createRule(t, store, nil) tests := map[string]struct { @@ -453,8 +501,8 @@ func TestIntegration_CountAlertRules(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - count, err := store.CountInFolder(context.Background(), - test.query.OrgID, test.query.NamespaceUID, nil) + count, err := store.CountInFolders(context.Background(), + test.query.OrgID, []string{test.query.NamespaceUID}, nil) if test.expectErr { require.Error(t, err) } else { @@ -474,14 +522,14 @@ func TestIntegration_DeleteInFolder(t *testing.T) { cfg := setting.NewCfg() store := &DBstore{ SQLStore: sqlStore, - FolderService: setupFolderService(t, sqlStore, cfg), + FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()), Logger: log.New("test-dbstore"), } rule := createRule(t, store, nil) t.Run("should not be able to delete folder without permissions to delete rules", func(t *testing.T) { store.AccessControl = acmock.New() - err := store.DeleteInFolder(context.Background(), rule.OrgID, rule.NamespaceUID, &user.SignedInUser{}) + err := store.DeleteInFolders(context.Background(), rule.OrgID, []string{rule.NamespaceUID}, &user.SignedInUser{}) require.ErrorIs(t, err, dashboards.ErrFolderAccessDenied) }) @@ -489,10 +537,10 @@ func TestIntegration_DeleteInFolder(t *testing.T) { store.AccessControl = acmock.New().WithPermissions([]accesscontrol.Permission{ {Action: accesscontrol.ActionAlertingRuleDelete, Scope: dashboards.ScopeFoldersAll}, }) - err := store.DeleteInFolder(context.Background(), rule.OrgID, rule.NamespaceUID, &user.SignedInUser{}) + err := store.DeleteInFolders(context.Background(), rule.OrgID, []string{rule.NamespaceUID}, &user.SignedInUser{}) require.NoError(t, err) - c, err := store.CountInFolder(context.Background(), rule.OrgID, rule.NamespaceUID, &user.SignedInUser{}) + c, err := store.CountInFolders(context.Background(), rule.OrgID, []string{rule.NamespaceUID}, &user.SignedInUser{}) require.NoError(t, err) require.Equal(t, int64(0), c) }) @@ -507,7 +555,7 @@ func TestIntegration_GetNamespaceByUID(t *testing.T) { cfg := setting.NewCfg() store := &DBstore{ SQLStore: sqlStore, - FolderService: setupFolderService(t, sqlStore, cfg), + FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()), Logger: log.New("test-dbstore"), } @@ -519,13 +567,36 @@ func TestIntegration_GetNamespaceByUID(t *testing.T) { } uid := uuid.NewString() - title := "folder-title" - createFolder(t, store, uid, title, 1) + parentUid := uuid.NewString() + title := "folder/title" + parentTitle := "parent-title" + createFolder(t, store, parentUid, parentTitle, 1, "") + createFolder(t, store, uid, title, 1, parentUid) actual, err := store.GetNamespaceByUID(context.Background(), uid, 1, u) require.NoError(t, err) require.Equal(t, title, actual.Title) require.Equal(t, uid, actual.UID) + require.Equal(t, title, actual.Fullpath) + + t.Run("error when user does not have permissions", func(t *testing.T) { + someUser := &user.SignedInUser{ + UserID: 2, + OrgID: 1, + OrgRole: org.RoleViewer, + } + _, err = store.GetNamespaceByUID(context.Background(), uid, 1, someUser) + require.ErrorIs(t, err, dashboards.ErrFolderAccessDenied) + }) + + t.Run("when nested folders are enabled full path should be populated with correct value", func(t *testing.T) { + store.FolderService = setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) + actual, err := store.GetNamespaceByUID(context.Background(), uid, 1, u) + require.NoError(t, err) + require.Equal(t, title, actual.Title) + require.Equal(t, uid, actual.UID) + require.Equal(t, "parent-title/folder\\/title", actual.Fullpath) + }) } func TestIntegrationInsertAlertRules(t *testing.T) { @@ -538,7 +609,7 @@ func TestIntegrationInsertAlertRules(t *testing.T) { cfg.UnifiedAlerting.BaseInterval = 1 * time.Second store := &DBstore{ SQLStore: sqlStore, - FolderService: setupFolderService(t, sqlStore, cfg), + FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()), Logger: log.New("test-dbstore"), Cfg: cfg.UnifiedAlerting, } @@ -570,12 +641,185 @@ func TestIntegrationInsertAlertRules(t *testing.T) { } require.Truef(t, found, "Rule with key %#v was not found in database", keyWithID) } + + _, err = store.InsertAlertRules(context.Background(), []models.AlertRule{deref[0]}) + require.ErrorIs(t, err, models.ErrAlertRuleUniqueConstraintViolation) + require.NotEqual(t, deref[0].UID, "") + require.NotEqual(t, deref[0].Title, "") + require.NotEqual(t, deref[0].NamespaceUID, "") + require.ErrorContains(t, err, deref[0].UID) + require.ErrorContains(t, err, deref[0].Title) + require.ErrorContains(t, err, deref[0].NamespaceUID) +} + +func TestIntegrationAlertRulesNotificationSettings(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + sqlStore := db.InitTestDB(t) + cfg := setting.NewCfg() + cfg.UnifiedAlerting.BaseInterval = 1 * time.Second + store := &DBstore{ + SQLStore: sqlStore, + FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()), + Logger: log.New("test-dbstore"), + Cfg: cfg.UnifiedAlerting, + } + + uniqueUids := &sync.Map{} + receiverName := "receiver\"-" + uuid.NewString() + rules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithOrgID(1), withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueUID(uniqueUids))) + receiveRules := models.GenerateAlertRules(3, + models.AlertRuleGen( + models.WithOrgID(1), + withIntervalMatching(store.Cfg.BaseInterval), + models.WithUniqueUID(uniqueUids), + models.WithNotificationSettingsGen(models.NotificationSettingsGen(models.NSMuts.WithReceiver(receiverName))))) + noise := models.GenerateAlertRules(3, + models.AlertRuleGen( + models.WithOrgID(1), + withIntervalMatching(store.Cfg.BaseInterval), + models.WithUniqueUID(uniqueUids), + models.WithNotificationSettingsGen(models.NotificationSettingsGen(models.NSMuts.WithMuteTimeIntervals(receiverName))))) // simulate collision of names of receiver and mute timing + deref := make([]models.AlertRule, 0, len(rules)+len(receiveRules)+len(noise)) + for _, rule := range append(append(rules, receiveRules...), noise...) { + r := *rule + r.ID = 0 + deref = append(deref, r) + } + + _, err := store.InsertAlertRules(context.Background(), deref) + require.NoError(t, err) + + t.Run("should find rules by receiver name", func(t *testing.T) { + expectedUIDs := map[string]struct{}{} + for _, rule := range receiveRules { + expectedUIDs[rule.UID] = struct{}{} + } + actual, err := store.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{ + OrgID: 1, + ReceiverName: receiverName, + }) + require.NoError(t, err) + assert.Len(t, actual, len(expectedUIDs)) + for _, rule := range actual { + assert.Contains(t, expectedUIDs, rule.UID) + } + }) + + t.Run("RenameReceiverInNotificationSettings should update all rules that refer to the old receiver", func(t *testing.T) { + newName := "new-receiver" + affected, err := store.RenameReceiverInNotificationSettings(context.Background(), 1, receiverName, newName) + require.NoError(t, err) + require.Equal(t, len(receiveRules), affected) + + expectedUIDs := map[string]struct{}{} + for _, rule := range receiveRules { + expectedUIDs[rule.UID] = struct{}{} + } + actual, err := store.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{ + OrgID: 1, + ReceiverName: newName, + }) + require.NoError(t, err) + assert.Len(t, actual, len(expectedUIDs)) + for _, rule := range actual { + assert.Contains(t, expectedUIDs, rule.UID) + } + + actual, err = store.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{ + OrgID: 1, + ReceiverName: receiverName, + }) + require.NoError(t, err) + require.Empty(t, actual) + }) +} + +func TestIntegrationListNotificationSettings(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + sqlStore := db.InitTestDB(t) + cfg := setting.NewCfg() + cfg.UnifiedAlerting.BaseInterval = 1 * time.Second + store := &DBstore{ + SQLStore: sqlStore, + FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()), + Logger: log.New("test-dbstore"), + Cfg: cfg.UnifiedAlerting, + } + + uids := &sync.Map{} + titles := &sync.Map{} + receiverName := `receiver%"-👍'test` + rulesWithNotifications := models.GenerateAlertRules(5, models.AlertRuleGen( + models.WithOrgID(1), + models.WithUniqueUID(uids), + models.WithUniqueTitle(titles), + withIntervalMatching(store.Cfg.BaseInterval), + models.WithNotificationSettingsGen(models.NotificationSettingsGen(models.NSMuts.WithReceiver(receiverName))), + )) + rulesInOtherOrg := models.GenerateAlertRules(5, models.AlertRuleGen( + models.WithOrgID(2), + models.WithUniqueUID(uids), + models.WithUniqueTitle(titles), + withIntervalMatching(store.Cfg.BaseInterval), + models.WithNotificationSettingsGen(models.NotificationSettingsGen()), + )) + rulesWithNoNotifications := models.GenerateAlertRules(5, models.AlertRuleGen( + models.WithOrgID(1), + models.WithUniqueUID(uids), + models.WithUniqueTitle(titles), + withIntervalMatching(store.Cfg.BaseInterval), + models.WithNoNotificationSettings(), + )) + deref := make([]models.AlertRule, 0, len(rulesWithNotifications)+len(rulesWithNoNotifications)+len(rulesInOtherOrg)) + for _, rule := range append(append(rulesWithNotifications, rulesWithNoNotifications...), rulesInOtherOrg...) { + r := *rule + r.ID = 0 + deref = append(deref, r) + } + + _, err := store.InsertAlertRules(context.Background(), deref) + require.NoError(t, err) + + result, err := store.ListNotificationSettings(context.Background(), models.ListNotificationSettingsQuery{OrgID: 1}) + require.NoError(t, err) + require.Len(t, result, len(rulesWithNotifications)) + for _, rule := range rulesWithNotifications { + if !assert.Contains(t, result, rule.GetKey()) { + continue + } + assert.EqualValues(t, rule.NotificationSettings, result[rule.GetKey()]) + } + + t.Run("should list notification settings by receiver name", func(t *testing.T) { + expectedUIDs := map[models.AlertRuleKey]struct{}{} + for _, rule := range rulesWithNotifications { + expectedUIDs[rule.GetKey()] = struct{}{} + } + + actual, err := store.ListNotificationSettings(context.Background(), models.ListNotificationSettingsQuery{ + OrgID: 1, + ReceiverName: receiverName, + }) + require.NoError(t, err) + assert.Len(t, actual, len(expectedUIDs)) + for ruleKey := range actual { + assert.Contains(t, expectedUIDs, ruleKey) + } + }) } +// createAlertRule creates an alert rule in the database and returns it. +// If a generator is not specified, uniqueness of primary key is not guaranteed. func createRule(t *testing.T, store *DBstore, generate func() *models.AlertRule) *models.AlertRule { t.Helper() if generate == nil { - generate = models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueID()) + generate = models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval)) } rule := generate() err := store.SQLStore.WithDbSession(context.Background(), func(sess *db.Session) error { @@ -602,7 +846,7 @@ func createRule(t *testing.T, store *DBstore, generate func() *models.AlertRule) return rule } -func createFolder(t *testing.T, store *DBstore, uid, title string, orgID int64) { +func createFolder(t *testing.T, store *DBstore, uid, title string, orgID int64, parentUID string) { t.Helper() u := &user.SignedInUser{ UserID: 1, @@ -617,16 +861,17 @@ func createFolder(t *testing.T, store *DBstore, uid, title string, orgID int64) Title: title, Description: "", SignedInUser: u, + ParentUID: parentUID, }) require.NoError(t, err) } -func setupFolderService(t *testing.T, sqlStore *sqlstore.SQLStore, cfg *setting.Cfg) folder.Service { +func setupFolderService(t *testing.T, sqlStore *sqlstore.SQLStore, cfg *setting.Cfg, features featuremgmt.FeatureToggles) folder.Service { tracer := tracing.InitializeTracerForTest() inProcBus := bus.ProvideBus(tracer) folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) _, dashboardStore := testutil.SetupDashboardService(t, sqlStore, folderStore, cfg) - return testutil.SetupFolderService(t, cfg, sqlStore, dashboardStore, folderStore, inProcBus) + return testutil.SetupFolderService(t, cfg, sqlStore, dashboardStore, folderStore, inProcBus, features, &actest.FakeAccessControl{}) } diff --git a/pkg/services/ngalert/store/alertmanager_test.go b/pkg/services/ngalert/store/alertmanager_test.go index 35a6920c615f8..4433760c8e171 100644 --- a/pkg/services/ngalert/store/alertmanager_test.go +++ b/pkg/services/ngalert/store/alertmanager_test.go @@ -13,8 +13,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationAlertmanagerStore(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/ngalert/store/deltas.go b/pkg/services/ngalert/store/deltas.go index 15184da809fb4..27fe5d86289ae 100644 --- a/pkg/services/ngalert/store/deltas.go +++ b/pkg/services/ngalert/store/deltas.go @@ -31,6 +31,27 @@ func (c *GroupDelta) IsEmpty() bool { return len(c.Update)+len(c.New)+len(c.Delete) == 0 } +// NewOrUpdatedNotificationSettings returns a list of notification settings that are either new or updated in the group. +func (c *GroupDelta) NewOrUpdatedNotificationSettings() []models.NotificationSettings { + var settings []models.NotificationSettings + for _, rule := range c.New { + if len(rule.NotificationSettings) > 0 { + settings = append(settings, rule.NotificationSettings...) + } + } + for _, delta := range c.Update { + if len(delta.New.NotificationSettings) == 0 { + continue + } + d := delta.Diff.GetDiffsForField("NotificationSettings") + if len(d) == 0 { + continue + } + settings = append(settings, delta.New.NotificationSettings...) + } + return settings +} + type RuleReader interface { ListAlertRules(ctx context.Context, query *models.ListAlertRulesQuery) (models.RulesGroup, error) GetAlertRulesGroupByRuleUID(ctx context.Context, query *models.GetAlertRulesGroupByRuleUIDQuery) ([]*models.AlertRule, error) diff --git a/pkg/services/ngalert/store/deltas_test.go b/pkg/services/ngalert/store/deltas_test.go index cba46b399c263..8d29c5db099c5 100644 --- a/pkg/services/ngalert/store/deltas_test.go +++ b/pkg/services/ngalert/store/deltas_test.go @@ -457,7 +457,6 @@ func withUIDs(uids map[string]*models.AlertRule) func(rule *models.AlertRule) { func randFolder() *folder.Folder { return &folder.Folder{ - ID: rand.Int63(), // nolint:staticcheck UID: util.GenerateShortUID(), Title: "TEST-FOLDER-" + util.GenerateShortUID(), URL: "", diff --git a/pkg/services/ngalert/store/instance_database.go b/pkg/services/ngalert/store/instance_database.go index 37571ae85fa0e..57766b9f6f2a4 100644 --- a/pkg/services/ngalert/store/instance_database.go +++ b/pkg/services/ngalert/store/instance_database.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/sqlstore" ) // ListAlertInstances is a handler for retrieving alert instances within specific organisation @@ -54,12 +55,12 @@ func (st DBstore) SaveAlertInstance(ctx context.Context, alertInstance models.Al if err != nil { return err } - params := append(make([]any, 0), alertInstance.RuleOrgID, alertInstance.RuleUID, labelTupleJSON, alertInstance.LabelsHash, alertInstance.CurrentState, alertInstance.CurrentReason, alertInstance.CurrentStateSince.Unix(), alertInstance.CurrentStateEnd.Unix(), alertInstance.LastEvalTime.Unix()) + params := append(make([]any, 0), alertInstance.RuleOrgID, alertInstance.RuleUID, labelTupleJSON, alertInstance.LabelsHash, alertInstance.CurrentState, alertInstance.CurrentReason, alertInstance.CurrentStateSince.Unix(), alertInstance.CurrentStateEnd.Unix(), alertInstance.LastEvalTime.Unix(), alertInstance.ResultFingerprint) upsertSQL := st.SQLStore.GetDialect().UpsertSQL( "alert_instance", []string{"rule_org_id", "rule_uid", "labels_hash"}, - []string{"rule_org_id", "rule_uid", "labels", "labels_hash", "current_state", "current_reason", "current_state_since", "current_state_end", "last_eval_time"}) + []string{"rule_org_id", "rule_uid", "labels", "labels_hash", "current_state", "current_reason", "current_state_since", "current_state_end", "last_eval_time", "result_fingerprint"}) _, err = sess.SQL(upsertSQL, params...).Query() if err != nil { return err @@ -197,3 +198,36 @@ func (st DBstore) DeleteAlertInstancesByRule(ctx context.Context, key models.Ale return err }) } + +func (st DBstore) FullSync(ctx context.Context, instances []models.AlertInstance) error { + if len(instances) == 0 { + return nil + } + return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + // First we delete all records from the table + if _, err := sess.Exec("DELETE FROM alert_instance"); err != nil { + return fmt.Errorf("failed to delete alert_instance table: %w", err) + } + for _, alertInstance := range instances { + if err := models.ValidateAlertInstance(alertInstance); err != nil { + st.Logger.Warn("Failed to validate alert instance, skipping", "err", err, "rule_uid", alertInstance.RuleUID) + continue + } + labelTupleJSON, err := alertInstance.Labels.StringKey() + if err != nil { + st.Logger.Warn("Failed to generate alert instance labels key, skipping", "err", err, "rule_uid", alertInstance.RuleUID) + continue + } + + _, err = sess.Exec("INSERT INTO alert_instance (rule_org_id, rule_uid, labels, labels_hash, current_state, current_reason, current_state_since, current_state_end, last_eval_time) VALUES (?,?,?,?,?,?,?,?,?)", + alertInstance.RuleOrgID, alertInstance.RuleUID, labelTupleJSON, alertInstance.LabelsHash, alertInstance.CurrentState, alertInstance.CurrentReason, alertInstance.CurrentStateSince.Unix(), alertInstance.CurrentStateEnd.Unix(), alertInstance.LastEvalTime.Unix()) + if err != nil { + return fmt.Errorf("failed to insert into alert_instance table: %w", err) + } + } + if err := sess.Commit(); err != nil { + return fmt.Errorf("failed to commit alert_instance table: %w", err) + } + return nil + }) +} diff --git a/pkg/services/ngalert/store/instance_database_test.go b/pkg/services/ngalert/store/instance_database_test.go index 3a055dab3cc3a..bfe44560cc71b 100644 --- a/pkg/services/ngalert/store/instance_database_test.go +++ b/pkg/services/ngalert/store/instance_database_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "testing" + "time" "github.com/stretchr/testify/require" @@ -294,3 +295,97 @@ func TestIntegrationAlertInstanceOperations(t *testing.T) { require.Equal(t, instance2.CurrentState, alerts[0].CurrentState) }) } + +func TestIntegrationFullSync(t *testing.T) { + ctx := context.Background() + _, dbstore := tests.SetupTestEnv(t, baseIntervalSeconds) + + orgID := int64(1) + + ruleUIDs := []string{"a", "b", "c", "d"} + + instances := make([]models.AlertInstance, len(ruleUIDs)) + for i, ruleUID := range ruleUIDs { + instances[i] = generateTestAlertInstance(orgID, ruleUID) + } + + t.Run("Should do a proper full sync", func(t *testing.T) { + err := dbstore.FullSync(ctx, instances) + require.NoError(t, err) + + res, err := dbstore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{ + RuleOrgID: orgID, + }) + require.NoError(t, err) + require.Len(t, res, len(instances)) + for _, ruleUID := range ruleUIDs { + found := false + for _, instance := range res { + if instance.RuleUID == ruleUID { + found = true + continue + } + } + if !found { + t.Errorf("Instance with RuleUID '%s' not found", ruleUID) + } + } + }) + t.Run("Should remove non existing entries on sync", func(t *testing.T) { + err := dbstore.FullSync(ctx, instances[1:]) + require.NoError(t, err) + + res, err := dbstore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{ + RuleOrgID: orgID, + }) + require.NoError(t, err) + require.Len(t, res, len(instances)-1) + for _, instance := range res { + if instance.RuleUID == "a" { + t.Error("Instance with RuleUID 'a' should not be exist anymore") + } + } + }) + t.Run("Should add new entries on sync", func(t *testing.T) { + newRuleUID := "y" + err := dbstore.FullSync(ctx, append(instances, generateTestAlertInstance(orgID, newRuleUID))) + require.NoError(t, err) + + res, err := dbstore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{ + RuleOrgID: orgID, + }) + require.NoError(t, err) + require.Len(t, res, len(instances)+1) + for _, ruleUID := range append(ruleUIDs, newRuleUID) { + found := false + for _, instance := range res { + if instance.RuleUID == ruleUID { + found = true + continue + } + } + if !found { + t.Errorf("Instance with RuleUID '%s' not found", ruleUID) + } + } + }) +} + +func generateTestAlertInstance(orgID int64, ruleID string) models.AlertInstance { + return models.AlertInstance{ + AlertInstanceKey: models.AlertInstanceKey{ + RuleOrgID: orgID, + RuleUID: ruleID, + LabelsHash: "abc", + }, + CurrentState: models.InstanceStateFiring, + Labels: map[string]string{ + "hello": "world", + }, + ResultFingerprint: "abc", + CurrentStateEnd: time.Now(), + CurrentStateSince: time.Now(), + LastEvalTime: time.Now(), + CurrentReason: "abc", + } +} diff --git a/pkg/services/ngalert/tests/fakes/config.go b/pkg/services/ngalert/tests/fakes/config.go new file mode 100644 index 0000000000000..c77450f6ca50e --- /dev/null +++ b/pkg/services/ngalert/tests/fakes/config.go @@ -0,0 +1,58 @@ +package fakes + +import ( + "context" + "crypto/md5" + "fmt" + + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +type FakeAlertmanagerConfigStore struct { + Config models.AlertConfiguration + // GetFn is an optional function that can be set to mock the GetLatestAlertmanagerConfiguration method + GetFn func(ctx context.Context, orgID int64) (*models.AlertConfiguration, error) + // UpdateFn is an optional function that can be set to mock the UpdateAlertmanagerConfiguration method + UpdateFn func(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error + // LastSaveCommand is the last command that was passed to UpdateAlertmanagerConfiguration + LastSaveCommand *models.SaveAlertmanagerConfigurationCmd +} + +func NewFakeAlertmanagerConfigStore(config string) *FakeAlertmanagerConfigStore { + return &FakeAlertmanagerConfigStore{ + Config: models.AlertConfiguration{ + AlertmanagerConfiguration: config, + ConfigurationVersion: "v1", + Default: true, + OrgID: 1, + }, + LastSaveCommand: nil, + } +} + +func (f *FakeAlertmanagerConfigStore) GetLatestAlertmanagerConfiguration(ctx context.Context, orgID int64) (*models.AlertConfiguration, error) { + if f.GetFn != nil { + return f.GetFn(ctx, orgID) + } + + result := &f.Config + result.OrgID = orgID + result.ConfigurationHash = fmt.Sprintf("%x", md5.Sum([]byte(f.Config.AlertmanagerConfiguration))) + result.ConfigurationVersion = f.Config.ConfigurationVersion + return result, nil +} + +func (f *FakeAlertmanagerConfigStore) UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error { + if f.UpdateFn != nil { + return f.UpdateFn(ctx, cmd) + } + + f.Config = models.AlertConfiguration{ + AlertmanagerConfiguration: cmd.AlertmanagerConfiguration, + ConfigurationVersion: cmd.ConfigurationVersion, + Default: cmd.Default, + OrgID: cmd.OrgID, + } + f.LastSaveCommand = cmd + return nil +} diff --git a/pkg/services/ngalert/tests/fakes/provisioning.go b/pkg/services/ngalert/tests/fakes/provisioning.go new file mode 100644 index 0000000000000..1274fa42ff638 --- /dev/null +++ b/pkg/services/ngalert/tests/fakes/provisioning.go @@ -0,0 +1,55 @@ +package fakes + +import ( + "context" + "strings" + + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +type FakeProvisioningStore struct { + Records map[int64]map[string]models.Provenance +} + +func NewFakeProvisioningStore() *FakeProvisioningStore { + return &FakeProvisioningStore{ + Records: map[int64]map[string]models.Provenance{}, + } +} + +func (f *FakeProvisioningStore) GetProvenance(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) { + if val, ok := f.Records[org]; ok { + if prov, ok := val[o.ResourceID()+o.ResourceType()]; ok { + return prov, nil + } + } + return models.ProvenanceNone, nil +} + +func (f *FakeProvisioningStore) GetProvenances(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) { + results := make(map[string]models.Provenance) + if val, ok := f.Records[orgID]; ok { + for k, v := range val { + if strings.HasSuffix(k, resourceType) { + results[strings.TrimSuffix(k, resourceType)] = v + } + } + } + return results, nil +} + +func (f *FakeProvisioningStore) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error { + if _, ok := f.Records[org]; !ok { + f.Records[org] = map[string]models.Provenance{} + } + _ = f.DeleteProvenance(ctx, o, org) // delete old entries first + f.Records[org][o.ResourceID()+o.ResourceType()] = p + return nil +} + +func (f *FakeProvisioningStore) DeleteProvenance(ctx context.Context, o models.Provisionable, org int64) error { + if val, ok := f.Records[org]; ok { + delete(val, o.ResourceID()+o.ResourceType()) + } + return nil +} diff --git a/pkg/services/ngalert/tests/fakes/receivers.go b/pkg/services/ngalert/tests/fakes/receivers.go new file mode 100644 index 0000000000000..0840e100f9068 --- /dev/null +++ b/pkg/services/ngalert/tests/fakes/receivers.go @@ -0,0 +1,60 @@ +package fakes + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +type ReceiverServiceMethodCall struct { + Method string + Args []interface{} +} + +type FakeReceiverService struct { + MethodCalls []ReceiverServiceMethodCall + GetReceiverFn func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) + GetReceiversFn func(ctx context.Context, q models.GetReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) +} + +func NewFakeReceiverService() *FakeReceiverService { + return &FakeReceiverService{ + GetReceiverFn: defaultReceiverFn, + GetReceiversFn: defaultReceiversFn, + } +} + +func (f *FakeReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) { + f.MethodCalls = append(f.MethodCalls, ReceiverServiceMethodCall{Method: "GetReceiver", Args: []interface{}{ctx, q}}) + return f.GetReceiverFn(ctx, q, u) +} + +func (f *FakeReceiverService) GetReceivers(ctx context.Context, q models.GetReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) { + f.MethodCalls = append(f.MethodCalls, ReceiverServiceMethodCall{Method: "GetReceivers", Args: []interface{}{ctx, q}}) + return f.GetReceiversFn(ctx, q, u) +} + +func (f *FakeReceiverService) PopMethodCall() ReceiverServiceMethodCall { + if len(f.MethodCalls) == 0 { + return ReceiverServiceMethodCall{} + } + call := f.MethodCalls[len(f.MethodCalls)-1] + f.MethodCalls = f.MethodCalls[:len(f.MethodCalls)-1] + return call +} + +func (f *FakeReceiverService) Reset() { + f.MethodCalls = nil + f.GetReceiverFn = defaultReceiverFn + f.GetReceiversFn = defaultReceiversFn +} + +func defaultReceiverFn(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) { + return definitions.GettableApiReceiver{}, nil +} + +func defaultReceiversFn(ctx context.Context, q models.GetReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) { + return nil, nil +} diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index 05654557d922f..9185047c98950 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -67,6 +68,7 @@ mainloop: } } if existing == nil { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.NGAlerts).Inc() folders = append(folders, &folder.Folder{ ID: rand.Int63(), // nolint:staticcheck UID: r.NamespaceUID, @@ -245,22 +247,18 @@ func (f *RuleStore) GetUserVisibleNamespaces(_ context.Context, orgID int64, _ i return namespacesMap, nil } -func (f *RuleStore) GetNamespaceByTitle(_ context.Context, title string, orgID int64, _ identity.Requester) (*folder.Folder, error) { - folders := f.Folders[orgID] - for _, folder := range folders { - if folder.Title == title { - return folder, nil - } - } - return nil, fmt.Errorf("not found") -} - -func (f *RuleStore) GetNamespaceByUID(_ context.Context, uid string, orgID int64, _ identity.Requester) (*folder.Folder, error) { - f.RecordedOps = append(f.RecordedOps, GenericRecordedQuery{ +func (f *RuleStore) GetNamespaceByUID(_ context.Context, uid string, orgID int64, user identity.Requester) (*folder.Folder, error) { + q := GenericRecordedQuery{ Name: "GetNamespaceByUID", - Params: []any{orgID, uid}, - }) - + Params: []any{orgID, uid, user}, + } + defer func() { + f.RecordedOps = append(f.RecordedOps, q) + }() + err := f.Hook(q) + if err != nil { + return nil, err + } folders := f.Folders[orgID] for _, folder := range folders { if folder.UID == uid { @@ -317,7 +315,7 @@ func (f *RuleStore) UpdateRuleGroup(ctx context.Context, orgID int64, namespaceU return nil } -func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, orgID int64, namespaceUID string) ([]models.AlertRuleKeyWithVersionAndPauseStatus, error) { +func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, orgID int64, namespaceUID string) ([]models.AlertRuleKeyWithVersion, error) { f.mtx.Lock() defer f.mtx.Unlock() @@ -326,24 +324,21 @@ func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, org Params: []any{orgID, namespaceUID}, }) - var result []models.AlertRuleKeyWithVersionAndPauseStatus + var result []models.AlertRuleKeyWithVersion for _, rule := range f.Rules[orgID] { if rule.NamespaceUID == namespaceUID && rule.OrgID == orgID { rule.Version++ rule.Updated = time.Now() - result = append(result, models.AlertRuleKeyWithVersionAndPauseStatus{ - IsPaused: rule.IsPaused, - AlertRuleKeyWithVersion: models.AlertRuleKeyWithVersion{ - Version: rule.Version, - AlertRuleKey: rule.GetKey(), - }, + result = append(result, models.AlertRuleKeyWithVersion{ + Version: rule.Version, + AlertRuleKey: rule.GetKey(), }) } } return result, nil } -func (f *RuleStore) CountInFolder(ctx context.Context, orgID int64, folderUID string, u identity.Requester) (int64, error) { +func (f *RuleStore) CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, u identity.Requester) (int64, error) { return 0, nil } diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index e31e9da48025d..0149af632c113 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -25,7 +25,6 @@ import ( "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/ngalert" "github.com/grafana/grafana/pkg/services/ngalert/metrics" - "github.com/grafana/grafana/pkg/services/ngalert/migration" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/ngalert/testutil" @@ -61,17 +60,18 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, bus := bus.ProvideBus(tracer) folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) dashboardService, dashboardStore := testutil.SetupDashboardService(tb, sqlStore, folderStore, cfg) - folderService := testutil.SetupFolderService(tb, cfg, sqlStore, dashboardStore, folderStore, bus) + features := featuremgmt.WithFeatures() + folderService := testutil.SetupFolderService(tb, cfg, sqlStore, dashboardStore, folderStore, bus, features, ac) ruleStore, err := store.ProvideDBStore(cfg, featuremgmt.WithFeatures(), sqlStore, folderService, &dashboards.FakeDashboardService{}, ac) require.NoError(tb, err) ng, err := ngalert.ProvideService( - cfg, featuremgmt.WithFeatures(), nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, quotatest.New(false, nil), + cfg, features, nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, quotatest.New(false, nil), secretsService, nil, m, folderService, ac, &dashboards.FakeDashboardService{}, nil, bus, ac, - annotationstest.NewFakeAnnotationsRepo(), &pluginstore.FakePluginStore{}, tracer, ruleStore, migration.NewFakeMigrationService(tb), nil, + annotationstest.NewFakeAnnotationsRepo(), &pluginstore.FakePluginStore{}, tracer, ruleStore, ) require.NoError(tb, err) return ng, &store.DBstore{ - FeatureToggles: ng.FeatureToggles, + FeatureToggles: features, SQLStore: ng.SQLStore, Cfg: setting.UnifiedAlertingSettings{ BaseInterval: baseInterval * time.Second, diff --git a/pkg/services/ngalert/testutil/testutil.go b/pkg/services/ngalert/testutil/testutil.go index b0957e0850126..1a4acf5e610db 100644 --- a/pkg/services/ngalert/testutil/testutil.go +++ b/pkg/services/ngalert/testutil/testutil.go @@ -20,17 +20,14 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/setting" ) -func SetupFolderService(tb testing.TB, cfg *setting.Cfg, db db.DB, dashboardStore dashboards.Store, folderStore *folderimpl.DashboardFolderStoreImpl, bus *bus.InProcBus) folder.Service { +func SetupFolderService(tb testing.TB, cfg *setting.Cfg, db db.DB, dashboardStore dashboards.Store, folderStore *folderimpl.DashboardFolderStoreImpl, bus *bus.InProcBus, features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl) folder.Service { tb.Helper() - - ac := acmock.New() - features := featuremgmt.WithFeatures() - - return folderimpl.ProvideService(ac, bus, cfg, dashboardStore, folderStore, db, features, nil) + return folderimpl.ProvideService(ac, bus, cfg, dashboardStore, folderStore, db, features, supportbundlestest.NewFakeBundleService(), nil) } func SetupDashboardService(tb testing.TB, sqlStore *sqlstore.SQLStore, fs *folderimpl.DashboardFolderStoreImpl, cfg *setting.Cfg) (*dashboardservice.DashboardServiceImpl, dashboards.Store) { @@ -58,7 +55,7 @@ func SetupDashboardService(tb testing.TB, sqlStore *sqlstore.SQLStore, fs *folde require.NoError(tb, err) dashboardService, err := dashboardservice.ProvideDashboardServiceImpl( - cfg, dashboardStore, fs, nil, + cfg, dashboardStore, fs, features, folderPermissions, dashboardPermissions, ac, foldertest.NewFakeService(), nil, diff --git a/pkg/services/notifications/codes.go b/pkg/services/notifications/codes.go index d345755191ef9..1b2df44c9743a 100644 --- a/pkg/services/notifications/codes.go +++ b/pkg/services/notifications/codes.go @@ -19,7 +19,7 @@ const timeLimitCodeLength = timeLimitStartDateLength + timeLimitMinutesLength + // create a time limit code // code format: 12 length date time string + 6 minutes string + 64 HMAC-SHA256 encoded string -func createTimeLimitCode(payload string, minutes int, startStr string) (string, error) { +func createTimeLimitCode(secretKey string, payload string, minutes int, startStr string) (string, error) { format := "200601021504" var start, end time.Time @@ -42,7 +42,7 @@ func createTimeLimitCode(payload string, minutes int, startStr string) (string, endStr = end.Format(format) // create HMAC-SHA256 encoded string - key := []byte(setting.SecretKey) + key := []byte(secretKey) h := hmac.New(sha256.New, key) if _, err := h.Write([]byte(payload + startStr + endStr)); err != nil { return "", fmt.Errorf("cannot create hmac: %v", err) @@ -70,8 +70,8 @@ func validateUserEmailCode(cfg *setting.Cfg, user *user.User, code string) (bool } // right active code - payload := strconv.FormatInt(user.ID, 10) + user.Email + user.Login + user.Password + user.Rands - expectedCode, err := createTimeLimitCode(payload, minutes, startStr) + payload := strconv.FormatInt(user.ID, 10) + user.Email + user.Login + string(user.Password) + user.Rands + expectedCode, err := createTimeLimitCode(cfg.SecretKey, payload, minutes, startStr) if err != nil { return false, err } @@ -103,8 +103,8 @@ func getLoginForEmailCode(code string) string { func createUserEmailCode(cfg *setting.Cfg, user *user.User, startStr string) (string, error) { minutes := cfg.EmailCodeValidMinutes - payload := strconv.FormatInt(user.ID, 10) + user.Email + user.Login + user.Password + user.Rands - code, err := createTimeLimitCode(payload, minutes, startStr) + payload := strconv.FormatInt(user.ID, 10) + user.Email + user.Login + string(user.Password) + user.Rands + code, err := createTimeLimitCode(cfg.SecretKey, payload, minutes, startStr) if err != nil { return "", err } diff --git a/pkg/services/notifications/codes_test.go b/pkg/services/notifications/codes_test.go index fbcfce51e197b..36105c7545f7c 100644 --- a/pkg/services/notifications/codes_test.go +++ b/pkg/services/notifications/codes_test.go @@ -18,7 +18,7 @@ func TestTimeLimitCodes(t *testing.T) { user := &user.User{ID: 10, Email: "t@a.com", Login: "asd", Password: "1", Rands: "2"} format := "200601021504" - mailPayload := strconv.FormatInt(user.ID, 10) + user.Email + user.Login + user.Password + user.Rands + mailPayload := strconv.FormatInt(user.ID, 10) + user.Email + user.Login + string(user.Password) + user.Rands tenMinutesAgo := time.Now().Add(-time.Minute * 10) tests := []struct { @@ -76,7 +76,7 @@ func TestTimeLimitCodes(t *testing.T) { for _, test := range tests { t.Run(test.desc, func(t *testing.T) { - code, err := createTimeLimitCode(test.payload, test.minutes, test.start.Format(format)) + code, err := createTimeLimitCode(cfg.SecretKey, test.payload, test.minutes, test.start.Format(format)) require.NoError(t, err) isValid, err := validateUserEmailCode(cfg, user, code) @@ -86,7 +86,7 @@ func TestTimeLimitCodes(t *testing.T) { } t.Run("tampered minutes", func(t *testing.T) { - code, err := createTimeLimitCode(mailPayload, 5, tenMinutesAgo.Format(format)) + code, err := createTimeLimitCode(cfg.SecretKey, mailPayload, 5, tenMinutesAgo.Format(format)) require.NoError(t, err) // code is expired @@ -102,7 +102,7 @@ func TestTimeLimitCodes(t *testing.T) { }) t.Run("tampered start string", func(t *testing.T) { - code, err := createTimeLimitCode(mailPayload, 5, tenMinutesAgo.Format(format)) + code, err := createTimeLimitCode(cfg.SecretKey, mailPayload, 5, tenMinutesAgo.Format(format)) require.NoError(t, err) // code is expired diff --git a/pkg/services/notifications/email.go b/pkg/services/notifications/email.go index 2b2fa0fce6494..e50646b966234 100644 --- a/pkg/services/notifications/email.go +++ b/pkg/services/notifications/email.go @@ -25,7 +25,7 @@ type Message struct { } func setDefaultTemplateData(cfg *setting.Cfg, data map[string]any, u *user.User) { - data["AppUrl"] = setting.AppUrl + data["AppUrl"] = cfg.AppURL data["BuildVersion"] = setting.BuildVersion data["BuildStamp"] = setting.BuildStamp data["EmailCodeValidHours"] = cfg.EmailCodeValidMinutes / 60 diff --git a/pkg/services/notifications/mailer.go b/pkg/services/notifications/mailer.go index 2627fc412a2c1..7382454751f08 100644 --- a/pkg/services/notifications/mailer.go +++ b/pkg/services/notifications/mailer.go @@ -6,6 +6,7 @@ package notifications import ( "bytes" + "context" "fmt" "html/template" "net/mail" @@ -34,10 +35,10 @@ func init() { } type Mailer interface { - Send(messages ...*Message) (int, error) + Send(ctx context.Context, messages ...*Message) (int, error) } -func (ns *NotificationService) Send(msg *Message) (int, error) { +func (ns *NotificationService) Send(ctx context.Context, msg *Message) (int, error) { messages := []*Message{} if msg.SingleEmail { @@ -50,7 +51,7 @@ func (ns *NotificationService) Send(msg *Message) (int, error) { } } - return ns.mailer.Send(messages...) + return ns.mailer.Send(ctx, messages...) } func (ns *NotificationService) buildEmailMessage(cmd *SendEmailCommand) (*Message, error) { diff --git a/pkg/services/notifications/mock.go b/pkg/services/notifications/mock.go index 2e874ad6570c7..89240b26bcb05 100644 --- a/pkg/services/notifications/mock.go +++ b/pkg/services/notifications/mock.go @@ -2,13 +2,17 @@ package notifications import ( "context" + + "github.com/grafana/grafana/pkg/services/user" ) type NotificationServiceMock struct { - Webhook SendWebhookSync - EmailSync SendEmailCommandSync - Email SendEmailCommand - ShouldError error + Webhook SendWebhookSync + EmailSync SendEmailCommandSync + Email SendEmailCommand + EmailVerified bool + EmailVerification SendVerifyEmailCommand + ShouldError error WebhookHandler func(context.Context, *SendWebhookSync) error EmailHandlerSync func(context.Context, *SendEmailCommandSync) error @@ -39,4 +43,20 @@ func (ns *NotificationServiceMock) SendEmailCommandHandler(ctx context.Context, return ns.ShouldError } +func (ns *NotificationServiceMock) SendResetPasswordEmail(ctx context.Context, cmd *SendResetPasswordEmailCommand) error { + // TODO: Implement if needed + return ns.ShouldError +} + +func (ns *NotificationServiceMock) ValidateResetPasswordCode(ctx context.Context, query *ValidateResetPasswordCodeQuery, userByLogin GetUserByLoginFunc) (*user.User, error) { + // TODO: Implement if needed + return nil, ns.ShouldError +} + +func (ns *NotificationServiceMock) SendVerificationEmail(ctx context.Context, cmd *SendVerifyEmailCommand) error { + ns.EmailVerified = true + ns.EmailVerification = *cmd + return ns.ShouldError +} + func MockNotificationService() *NotificationServiceMock { return &NotificationServiceMock{} } diff --git a/pkg/services/notifications/models.go b/pkg/services/notifications/models.go index 1b266a6ed6ba2..289b2566c2713 100644 --- a/pkg/services/notifications/models.go +++ b/pkg/services/notifications/models.go @@ -51,3 +51,9 @@ type SendResetPasswordEmailCommand struct { type ValidateResetPasswordCodeQuery struct { Code string } + +type SendVerifyEmailCommand struct { + User *user.User + Code string + Email string +} diff --git a/pkg/services/notifications/notifications.go b/pkg/services/notifications/notifications.go index 17d66f6450563..b69b43f01c874 100644 --- a/pkg/services/notifications/notifications.go +++ b/pkg/services/notifications/notifications.go @@ -28,15 +28,28 @@ type EmailSender interface { SendEmailCommandHandlerSync(ctx context.Context, cmd *SendEmailCommandSync) error SendEmailCommandHandler(ctx context.Context, cmd *SendEmailCommand) error } +type PasswordResetMailer interface { + SendResetPasswordEmail(ctx context.Context, cmd *SendResetPasswordEmailCommand) error + ValidateResetPasswordCode(ctx context.Context, query *ValidateResetPasswordCodeQuery, userByLogin GetUserByLoginFunc) (*user.User, error) +} +type EmailVerificationMailer interface { + SendVerificationEmail(ctx context.Context, cmd *SendVerifyEmailCommand) error +} type Service interface { WebhookSender EmailSender + PasswordResetMailer + EmailVerificationMailer } var mailTemplates *template.Template -var tmplResetPassword = "reset_password" -var tmplSignUpStarted = "signup_started" -var tmplWelcomeOnSignUp = "welcome_on_signup" + +const ( + tmplResetPassword = "reset_password" + tmplSignUpStarted = "signup_started" + tmplWelcomeOnSignUp = "welcome_on_signup" + tmplVerifyEmail = "verify_email" +) func ProvideService(bus bus.Bus, cfg *setting.Cfg, mailer Mailer, store TempUserStore) (*NotificationService, error) { ns := &NotificationService{ @@ -112,7 +125,7 @@ func (ns *NotificationService) Run(ctx context.Context) error { ns.log.Error("Failed to send webrequest ", "error", err) } case msg := <-ns.mailQueue: - num, err := ns.Send(msg) + num, err := ns.Send(ctx, msg) tos := strings.Join(msg.To, "; ") info := "" if err != nil { @@ -203,7 +216,7 @@ func (ns *NotificationService) SendEmailCommandHandlerSync(ctx context.Context, return err } - _, err = ns.Send(message) + _, err = ns.Send(ctx, message) return err } @@ -257,8 +270,22 @@ func (ns *NotificationService) ValidateResetPasswordCode(ctx context.Context, qu return user, nil } +func (ns *NotificationService) SendVerificationEmail(ctx context.Context, cmd *SendVerifyEmailCommand) error { + return ns.SendEmailCommandHandlerSync(ctx, &SendEmailCommandSync{ + SendEmailCommand: SendEmailCommand{ + To: []string{cmd.Email}, + Template: tmplVerifyEmail, + Data: map[string]any{ + "Code": url.QueryEscape(cmd.Code), + "Name": cmd.User.Name, + "VerificationEmailLifetimeHours": int(ns.Cfg.VerificationEmailMaxLifetime.Hours()), + }, + }, + }) +} + func (ns *NotificationService) signUpStartedHandler(ctx context.Context, evt *events.SignUpStarted) error { - if !setting.VerifyEmailEnabled { + if !ns.Cfg.VerifyEmailEnabled { return nil } diff --git a/pkg/services/notifications/smtp.go b/pkg/services/notifications/smtp.go index d53b57802374d..2c46982b352ef 100644 --- a/pkg/services/notifications/smtp.go +++ b/pkg/services/notifications/smtp.go @@ -1,18 +1,29 @@ package notifications import ( + "bufio" + "bytes" + "context" "crypto/tls" "fmt" "io" "net" + "net/textproto" "strconv" "strings" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" gomail "gopkg.in/mail.v2" "github.com/grafana/grafana/pkg/setting" ) +var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/notifications") + type SmtpClient struct { cfg setting.SmtpSettings } @@ -29,7 +40,12 @@ func NewSmtpClient(cfg setting.SmtpSettings) (*SmtpClient, error) { return client, nil } -func (sc *SmtpClient) Send(messages ...*Message) (int, error) { +func (sc *SmtpClient) Send(ctx context.Context, messages ...*Message) (int, error) { + ctx, span := tracer.Start(ctx, "notifications.SmtpClient.Send", + trace.WithAttributes(attribute.Int("messages", len(messages))), + ) + defer span.End() + sentEmailsCount := 0 dialer, err := sc.createDialer() if err != nil { @@ -37,7 +53,12 @@ func (sc *SmtpClient) Send(messages ...*Message) (int, error) { } for _, msg := range messages { - m := sc.buildEmail(msg) + span.SetAttributes( + attribute.String("smtp.sender", msg.From), + attribute.StringSlice("smtp.recipients", msg.To), + ) + + m := sc.buildEmail(ctx, msg) innerError := dialer.DialAndSend(m) emailsSentTotal.Inc() @@ -50,6 +71,9 @@ func (sc *SmtpClient) Send(messages ...*Message) (int, error) { } err = fmt.Errorf("failed to send notification to email addresses: %s: %w", strings.Join(msg.To, ";"), innerError) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + continue } @@ -60,7 +84,7 @@ func (sc *SmtpClient) Send(messages ...*Message) (int, error) { } // buildEmail converts the Message DTO to a gomail message. -func (sc *SmtpClient) buildEmail(msg *Message) *gomail.Message { +func (sc *SmtpClient) buildEmail(ctx context.Context, msg *Message) *gomail.Message { m := gomail.NewMessage() // add all static headers to the email message for h, val := range sc.cfg.StaticHeaders { @@ -69,6 +93,11 @@ func (sc *SmtpClient) buildEmail(msg *Message) *gomail.Message { m.SetHeader("From", msg.From) m.SetHeader("To", msg.To...) m.SetHeader("Subject", msg.Subject) + + if sc.cfg.EnableTracing { + otel.GetTextMapPropagator().Inject(ctx, gomailHeaderCarrier{m}) + } + sc.setFiles(m, msg) for _, replyTo := range msg.ReplyTo { m.SetAddressHeader("Reply-To", replyTo, "") @@ -130,12 +159,8 @@ func (sc *SmtpClient) createDialer() (*gomail.Dialer, error) { d := gomail.NewDialer(host, iPort, sc.cfg.User, sc.cfg.Password) d.TLSConfig = tlsconfig d.StartTLSPolicy = getStartTLSPolicy(sc.cfg.StartTLSPolicy) + d.LocalName = sc.cfg.EhloIdentity - if sc.cfg.EhloIdentity != "" { - d.LocalName = sc.cfg.EhloIdentity - } else { - d.LocalName = setting.InstanceName - } return d, nil } @@ -149,3 +174,36 @@ func getStartTLSPolicy(policy string) gomail.StartTLSPolicy { return 0 } } + +type gomailHeaderCarrier struct { + *gomail.Message +} + +var _ propagation.TextMapCarrier = (*gomailHeaderCarrier)(nil) + +func (c gomailHeaderCarrier) Get(key string) string { + if hdr := c.Message.GetHeader(key); len(hdr) > 0 { + return hdr[0] + } + + return "" +} + +func (c gomailHeaderCarrier) Set(key string, value string) { + c.Message.SetHeader(key, value) +} + +func (c gomailHeaderCarrier) Keys() []string { + // there's no way to get all the header keys directly from a gomail.Message, + // but we can encode the whole message and re-parse. This is not ideal, but + // this function shouldn't be used in the hot path. + buf := bytes.Buffer{} + _, _ = c.Message.WriteTo(&buf) + hdr, _ := textproto.NewReader(bufio.NewReader(&buf)).ReadMIMEHeader() + keys := make([]string, 0, len(hdr)) + for k := range hdr { + keys = append(keys, k) + } + + return keys +} diff --git a/pkg/services/notifications/smtp_test.go b/pkg/services/notifications/smtp_test.go index 6d81061b15a9e..6a6d8fb8ca36a 100644 --- a/pkg/services/notifications/smtp_test.go +++ b/pkg/services/notifications/smtp_test.go @@ -2,12 +2,14 @@ package notifications import ( "bytes" + "context" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/setting" ) @@ -30,8 +32,10 @@ func TestBuildMail(t *testing.T) { ReplyTo: []string{"from@address.com"}, } + ctx := context.Background() + t.Run("Can successfully build mail", func(t *testing.T) { - email := sc.buildEmail(message) + email := sc.buildEmail(ctx, message) staticHeader := email.GetHeader("Foo-Header")[0] assert.Equal(t, staticHeader, "foo_value") @@ -45,9 +49,35 @@ func TestBuildMail(t *testing.T) { assert.Contains(t, buf.String(), "Some plain text body") assert.Less(t, strings.Index(buf.String(), "Some plain text body"), strings.Index(buf.String(), "Some HTML body")) }) + + t.Run("Skips trace headers when context has no span", func(t *testing.T) { + cfg.Smtp.EnableTracing = true + + sc, err := NewSmtpClient(cfg.Smtp) + require.NoError(t, err) + + email := sc.buildEmail(ctx, message) + assert.Empty(t, email.GetHeader("traceparent")) + }) + + t.Run("Adds trace headers when context has span", func(t *testing.T) { + cfg.Smtp.EnableTracing = true + + sc, err := NewSmtpClient(cfg.Smtp) + require.NoError(t, err) + + tracer := tracing.InitializeTracerForTest() + ctx, span := tracer.Start(ctx, "notifications.SmtpClient.SendContext") + defer span.End() + + email := sc.buildEmail(ctx, message) + assert.NotEmpty(t, email.GetHeader("traceparent")) + }) } func TestSmtpDialer(t *testing.T) { + ctx := context.Background() + t.Run("When SMTP hostname is invalid", func(t *testing.T) { cfg := createSmtpConfig() cfg.Smtp.Host = "invalid%hostname:123:456" @@ -63,7 +93,7 @@ func TestSmtpDialer(t *testing.T) { }, } - count, err := client.Send(message) + count, err := client.Send(ctx, message) require.Equal(t, 0, count) require.EqualError(t, err, "address invalid%hostname:123:456: too many colons in address") @@ -84,7 +114,7 @@ func TestSmtpDialer(t *testing.T) { }, } - count, err := client.Send(message) + count, err := client.Send(ctx, message) require.Equal(t, 0, count) require.EqualError(t, err, "strconv.Atoi: parsing \"123a\": invalid syntax") @@ -106,7 +136,7 @@ func TestSmtpDialer(t *testing.T) { }, } - count, err := client.Send(message) + count, err := client.Send(ctx, message) require.Equal(t, 0, count) require.EqualError(t, err, "could not load cert or key file: open /var/certs/does-not-exist.pem: no such file or directory") diff --git a/pkg/services/notifications/testing.go b/pkg/services/notifications/testing.go index ad06b2bef1adf..f632bef503851 100644 --- a/pkg/services/notifications/testing.go +++ b/pkg/services/notifications/testing.go @@ -1,6 +1,9 @@ package notifications -import "fmt" +import ( + "context" + "fmt" +) type FakeMailer struct { Sent []*Message @@ -12,7 +15,7 @@ func NewFakeMailer() *FakeMailer { } } -func (fm *FakeMailer) Send(messages ...*Message) (int, error) { +func (fm *FakeMailer) Send(ctx context.Context, messages ...*Message) (int, error) { sentEmailsCount := 0 for _, msg := range messages { fm.Sent = append(fm.Sent, msg) @@ -27,7 +30,7 @@ func NewFakeDisconnectedMailer() *FakeDisconnectedMailer { return &FakeDisconnectedMailer{} } -func (fdm *FakeDisconnectedMailer) Send(messages ...*Message) (int, error) { +func (fdm *FakeDisconnectedMailer) Send(ctx context.Context, messages ...*Message) (int, error) { return 0, fmt.Errorf("connect: connection refused") } diff --git a/pkg/services/oauthtoken/oauth_token.go b/pkg/services/oauthtoken/oauth_token.go index b7670c7525e57..632b252b3530f 100644 --- a/pkg/services/oauthtoken/oauth_token.go +++ b/pkg/services/oauthtoken/oauth_token.go @@ -7,10 +7,12 @@ import ( "strings" "time" + "github.com/go-jose/go-jose/v3/jwt" "github.com/prometheus/client_golang/prometheus" "golang.org/x/oauth2" "golang.org/x/sync/singleflight" + "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/services/auth/identity" @@ -29,28 +31,33 @@ var ( ErrNotAnOAuthProvider = errors.New("not an oauth provider") ) +const maxOAuthTokenCacheTTL = 10 * time.Minute + type Service struct { Cfg *setting.Cfg SocialService social.Service AuthInfoService login.AuthInfoService singleFlightGroup *singleflight.Group + cache *localcache.CacheService tokenRefreshDuration *prometheus.HistogramVec } +//go:generate mockery --name OAuthTokenService --structname MockService --outpkg oauthtokentest --filename service_mock.go --output ./oauthtokentest/ type OAuthTokenService interface { GetCurrentOAuthToken(context.Context, identity.Requester) *oauth2.Token IsOAuthPassThruEnabled(*datasources.DataSource) bool HasOAuthEntry(context.Context, identity.Requester) (*login.UserAuth, bool, error) - TryTokenRefresh(context.Context, *login.UserAuth) error + TryTokenRefresh(context.Context, identity.Requester) error InvalidateOAuthTokens(context.Context, *login.UserAuth) error } func ProvideService(socialService social.Service, authInfoService login.AuthInfoService, cfg *setting.Cfg, registerer prometheus.Registerer) *Service { return &Service{ + AuthInfoService: authInfoService, Cfg: cfg, SocialService: socialService, - AuthInfoService: authInfoService, + cache: localcache.New(maxOAuthTokenCacheTTL, 15*time.Minute), singleFlightGroup: new(singleflight.Group), tokenRefreshDuration: newTokenRefreshDurationMetric(registerer), } @@ -58,36 +65,12 @@ func ProvideService(socialService social.Service, authInfoService login.AuthInfo // GetCurrentOAuthToken returns the OAuth token, if any, for the authenticated user. Will try to refresh the token if it has expired. func (o *Service) GetCurrentOAuthToken(ctx context.Context, usr identity.Requester) *oauth2.Token { - if usr == nil || usr.IsNil() { - // No user, therefore no token - return nil - } - - namespace, id := usr.GetNamespacedID() - if namespace != identity.NamespaceUser { - // Not a user, therefore no token. - return nil - } - - userID, err := identity.IntIdentifier(namespace, id) - if err != nil { - logger.Error("Failed to convert user id to int", "namespace", namespace, "userId", id, "error", err) + authInfo, ok, _ := o.HasOAuthEntry(ctx, usr) + if !ok { return nil } - authInfoQuery := &login.GetAuthInfoQuery{UserId: userID} - authInfo, err := o.AuthInfoService.GetAuthInfo(ctx, authInfoQuery) - if err != nil { - if errors.Is(err, user.ErrUserNotFound) { - // Not necessarily an error. User may be logged in another way. - logger.Debug("No oauth token for user found", "userId", userID, "username", usr.GetLogin()) - } else { - logger.Error("Failed to get oauth token for user", "userId", userID, "username", usr.GetLogin(), "error", err) - } - return nil - } - - token, err := o.tryGetOrRefreshAccessToken(ctx, authInfo) + token, err := o.tryGetOrRefreshOAuthToken(ctx, authInfo) if err != nil { if errors.Is(err, ErrNoRefreshTokenFound) { return buildOAuthTokenFromAuthInfo(authInfo) @@ -119,6 +102,7 @@ func (o *Service) HasOAuthEntry(ctx context.Context, usr identity.Requester) (*l userID, err := identity.IntIdentifier(namespace, id) if err != nil { + logger.Error("Failed to convert user id to int", "namespace", namespace, "userId", id, "error", err) return nil, false, err } @@ -127,6 +111,7 @@ func (o *Service) HasOAuthEntry(ctx context.Context, usr identity.Requester) (*l if err != nil { if errors.Is(err, user.ErrUserNotFound) { // Not necessarily an error. User may be logged in another way. + logger.Debug("No oauth token found for user", "userId", userID, "username", usr.GetLogin()) return nil, false, nil } logger.Error("Failed to fetch oauth token for user", "userId", userID, "username", usr.GetLogin(), "error", err) @@ -140,13 +125,72 @@ func (o *Service) HasOAuthEntry(ctx context.Context, usr identity.Requester) (*l // TryTokenRefresh returns an error in case the OAuth token refresh was unsuccessful // It uses a singleflight.Group to prevent getting the Refresh Token multiple times for a given User -func (o *Service) TryTokenRefresh(ctx context.Context, usr *login.UserAuth) error { - lockKey := fmt.Sprintf("oauth-refresh-token-%d", usr.UserId) - _, err, _ := o.singleFlightGroup.Do(lockKey, func() (any, error) { +func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester) error { + if usr == nil || usr.IsNil() { + logger.Warn("Can only refresh OAuth tokens for existing users", "user", "nil") + // Not user, no token. + return nil + } + + namespace, id := usr.GetNamespacedID() + if namespace != identity.NamespaceUser { + // Not a user, therefore no token. + logger.Warn("Can only refresh OAuth tokens for users", "namespace", namespace, "userId", id) + return nil + } + + userID, err := identity.IntIdentifier(namespace, id) + if err != nil { + logger.Warn("Failed to convert user id to int", "namespace", namespace, "userId", id, "error", err) + return nil + } + + lockKey := fmt.Sprintf("oauth-refresh-token-%d", userID) + if _, ok := o.cache.Get(lockKey); ok { + logger.Debug("Expiration check has been cached, no need to refresh", "userID", userID) + return nil + } + _, err, _ = o.singleFlightGroup.Do(lockKey, func() (any, error) { logger.Debug("Singleflight request for getting a new access token", "key", lockKey) - return o.tryGetOrRefreshAccessToken(ctx, usr) + authInfo, exists, err := o.HasOAuthEntry(ctx, usr) + if !exists { + if err != nil { + logger.Debug("Failed to fetch oauth entry", "id", userID, "error", err) + } else { + // User is not logged in via OAuth no need to check + o.cache.Set(lockKey, struct{}{}, maxOAuthTokenCacheTTL) + } + return nil, nil + } + + _, needRefresh, ttl := needTokenRefresh(authInfo) + if !needRefresh { + o.cache.Set(lockKey, struct{}{}, ttl) + return nil, nil + } + + // get the token's auth provider (f.e. azuread) + provider := strings.TrimPrefix(authInfo.AuthModule, "oauth_") + currentOAuthInfo := o.SocialService.GetOAuthInfoProvider(provider) + if currentOAuthInfo == nil { + logger.Warn("OAuth provider not found", "provider", provider) + return nil, nil + } + + // if refresh token handling is disabled for this provider, we can skip the refresh + if !currentOAuthInfo.UseRefreshToken { + logger.Debug("Skipping token refresh", "provider", provider) + return nil, nil + } + + return o.tryGetOrRefreshOAuthToken(ctx, authInfo) }) + // Silence ErrNoRefreshTokenFound + if errors.Is(err, ErrNoRefreshTokenFound) { + return nil + } + return err } @@ -195,11 +239,23 @@ func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr *login.UserAuth }) } -func (o *Service) tryGetOrRefreshAccessToken(ctx context.Context, usr *login.UserAuth) (*oauth2.Token, error) { +func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, usr *login.UserAuth) (*oauth2.Token, error) { + key := getCheckCacheKey(usr.UserId) + if _, ok := o.cache.Get(key); ok { + logger.Debug("Expiration check has been cached", "userID", usr.UserId) + return buildOAuthTokenFromAuthInfo(usr), nil + } + if err := checkOAuthRefreshToken(usr); err != nil { return nil, err } + persistedToken, refreshNeeded, ttl := needTokenRefresh(usr) + if !refreshNeeded { + o.cache.Set(key, struct{}{}, ttl) + return persistedToken, nil + } + authProvider := usr.AuthModule connect, err := o.SocialService.GetConnector(authProvider) if err != nil { @@ -214,8 +270,6 @@ func (o *Service) tryGetOrRefreshAccessToken(ctx context.Context, usr *login.Use } ctx = context.WithValue(ctx, oauth2.HTTPClient, client) - persistedToken := buildOAuthTokenFromAuthInfo(usr) - start := time.Now() // TokenSource handles refreshing the token if it has expired token, err := connect.TokenSource(ctx, persistedToken).Token() @@ -278,8 +332,91 @@ func newTokenRefreshDurationMetric(registerer prometheus.Registerer) *prometheus // tokensEq checks for OAuth2 token equivalence given the fields of the struct Grafana is interested in func tokensEq(t1, t2 *oauth2.Token) bool { + t1IdToken, ok1 := t1.Extra("id_token").(string) + t2IdToken, ok2 := t2.Extra("id_token").(string) + return t1.AccessToken == t2.AccessToken && t1.RefreshToken == t2.RefreshToken && t1.Expiry.Equal(t2.Expiry) && - t1.TokenType == t2.TokenType + t1.TokenType == t2.TokenType && + ok1 == ok2 && + t1IdToken == t2IdToken +} + +func needTokenRefresh(usr *login.UserAuth) (*oauth2.Token, bool, time.Duration) { + var accessTokenExpires, idTokenExpires time.Time + var hasAccessTokenExpired, hasIdTokenExpired bool + + persistedToken := buildOAuthTokenFromAuthInfo(usr) + idTokenExp, err := getIDTokenExpiry(usr) + if err != nil { + logger.Warn("Could not get ID Token expiry", "error", err) + } + if !persistedToken.Expiry.IsZero() { + accessTokenExpires, hasAccessTokenExpired = getExpiryWithSkew(persistedToken.Expiry) + } + if !idTokenExp.IsZero() { + idTokenExpires, hasIdTokenExpired = getExpiryWithSkew(idTokenExp) + } + if !hasAccessTokenExpired && !hasIdTokenExpired { + logger.Debug("Neither access nor id token have expired yet", "id", usr.Id) + return persistedToken, false, getOAuthTokenCacheTTL(accessTokenExpires, idTokenExpires) + } + if hasIdTokenExpired { + // Force refreshing token when id token is expired + persistedToken.AccessToken = "" + } + return persistedToken, true, time.Second +} + +func getCheckCacheKey(usrID int64) string { + return fmt.Sprintf("token-check-%d", usrID) +} + +func getOAuthTokenCacheTTL(accessTokenExpiry, idTokenExpiry time.Time) time.Duration { + min := maxOAuthTokenCacheTTL + if !accessTokenExpiry.IsZero() { + d := time.Until(accessTokenExpiry) + if d < min { + min = d + } + } + if !idTokenExpiry.IsZero() { + d := time.Until(idTokenExpiry) + if d < min { + min = d + } + } + if accessTokenExpiry.IsZero() && idTokenExpiry.IsZero() { + return maxOAuthTokenCacheTTL + } + return min +} + +// getIDTokenExpiry extracts the expiry time from the ID token +func getIDTokenExpiry(usr *login.UserAuth) (time.Time, error) { + if usr.OAuthIdToken == "" { + return time.Time{}, nil + } + + parsedToken, err := jwt.ParseSigned(usr.OAuthIdToken) + if err != nil { + return time.Time{}, fmt.Errorf("error parsing id token: %w", err) + } + + type Claims struct { + Exp int64 `json:"exp"` + } + var claims Claims + if err := parsedToken.UnsafeClaimsWithoutVerification(&claims); err != nil { + return time.Time{}, fmt.Errorf("error getting claims from id token: %w", err) + } + + return time.Unix(claims.Exp, 0), nil +} + +func getExpiryWithSkew(expiry time.Time) (adjustedExpiry time.Time, hasTokenExpired bool) { + adjustedExpiry = expiry.Round(0).Add(-ExpiryDelta) + hasTokenExpired = adjustedExpiry.Before(time.Now()) + return } diff --git a/pkg/services/oauthtoken/oauth_token_test.go b/pkg/services/oauthtoken/oauth_token_test.go index 28e56398ac6dc..ebcbb76fb3fce 100644 --- a/pkg/services/oauthtoken/oauth_token_test.go +++ b/pkg/services/oauthtoken/oauth_token_test.go @@ -13,13 +13,23 @@ import ( "golang.org/x/oauth2" "golang.org/x/sync/singleflight" + "github.com/grafana/grafana/pkg/infra/localcache" + "github.com/grafana/grafana/pkg/infra/remotecache" + "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/login/social/socialtest" + "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login/authinfoimpl" + "github.com/grafana/grafana/pkg/services/login/authinfotest" + "github.com/grafana/grafana/pkg/services/secrets/fakes" + secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) +var EXPIRED_JWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + func TestService_HasOAuthEntry(t *testing.T) { testCases := []struct { name string @@ -65,10 +75,10 @@ func TestService_HasOAuthEntry(t *testing.T) { { name: "returns true when the auth entry is found", user: &user.SignedInUser{UserID: 1}, - want: &login.UserAuth{AuthModule: "oauth_generic_oauth"}, + want: &login.UserAuth{AuthModule: login.GenericOAuthModule}, wantExist: true, wantErr: false, - getAuthInfoUser: login.UserAuth{AuthModule: "oauth_generic_oauth"}, + getAuthInfoUser: login.UserAuth{AuthModule: login.GenericOAuthModule}, }, } for _, tc := range testCases { @@ -92,150 +102,26 @@ func TestService_HasOAuthEntry(t *testing.T) { } } -func TestService_TryTokenRefresh_ValidToken(t *testing.T) { - srv, authInfoStore, socialConnector := setupOAuthTokenService(t) - ctx := context.Background() - token := &oauth2.Token{ - AccessToken: "testaccess", - RefreshToken: "testrefresh", - Expiry: time.Now(), - TokenType: "Bearer", - } - usr := &login.UserAuth{ - AuthModule: "oauth_generic_oauth", - OAuthAccessToken: token.AccessToken, - OAuthRefreshToken: token.RefreshToken, - OAuthExpiry: token.Expiry, - OAuthTokenType: token.TokenType, - } - - authInfoStore.ExpectedOAuth = usr - - socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(token)) - - err := srv.TryTokenRefresh(ctx, usr) - assert.Nil(t, err) - socialConnector.AssertNumberOfCalls(t, "TokenSource", 1) - - authInfoQuery := &login.GetAuthInfoQuery{} - resultUsr, err := srv.AuthInfoService.GetAuthInfo(ctx, authInfoQuery) - - assert.Nil(t, err) - - // User's token data had not been updated - assert.Equal(t, resultUsr.OAuthAccessToken, token.AccessToken) - assert.Equal(t, resultUsr.OAuthExpiry, token.Expiry) - assert.Equal(t, resultUsr.OAuthRefreshToken, token.RefreshToken) - assert.Equal(t, resultUsr.OAuthTokenType, token.TokenType) -} - -func TestService_TryTokenRefresh_NoRefreshToken(t *testing.T) { - srv, _, socialConnector := setupOAuthTokenService(t) - ctx := context.Background() - token := &oauth2.Token{ - AccessToken: "testaccess", - RefreshToken: "", - Expiry: time.Now().Add(-time.Hour), - TokenType: "Bearer", - } - usr := &login.UserAuth{ - AuthModule: "oauth_generic_oauth", - OAuthAccessToken: token.AccessToken, - OAuthRefreshToken: token.RefreshToken, - OAuthExpiry: token.Expiry, - OAuthTokenType: token.TokenType, - } - - socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(token)) - - err := srv.TryTokenRefresh(ctx, usr) - - assert.NotNil(t, err) - assert.ErrorIs(t, err, ErrNoRefreshTokenFound) - - socialConnector.AssertNotCalled(t, "TokenSource") -} - -func TestService_TryTokenRefresh_ExpiredToken(t *testing.T) { - srv, authInfoStore, socialConnector := setupOAuthTokenService(t) - ctx := context.Background() - token := &oauth2.Token{ - AccessToken: "testaccess", - RefreshToken: "testrefresh", - Expiry: time.Now().Add(-time.Hour), - TokenType: "Bearer", - } - - newToken := &oauth2.Token{ - AccessToken: "testaccess_new", - RefreshToken: "testrefresh_new", - Expiry: time.Now().Add(time.Hour), - TokenType: "Bearer", - } - - usr := &login.UserAuth{ - AuthModule: "oauth_generic_oauth", - OAuthAccessToken: token.AccessToken, - OAuthRefreshToken: token.RefreshToken, - OAuthExpiry: token.Expiry, - OAuthTokenType: token.TokenType, - } - - authInfoStore.ExpectedOAuth = usr - - socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.ReuseTokenSource(token, oauth2.StaticTokenSource(newToken)), nil) - - err := srv.TryTokenRefresh(ctx, usr) - - assert.Nil(t, err) - socialConnector.AssertNumberOfCalls(t, "TokenSource", 1) - - authInfoQuery := &login.GetAuthInfoQuery{} - authInfo, err := srv.AuthInfoService.GetAuthInfo(ctx, authInfoQuery) - - assert.Nil(t, err) - - // newToken should be returned after the .Token() call, therefore the User had to be updated - assert.Equal(t, authInfo.OAuthAccessToken, newToken.AccessToken) - assert.Equal(t, authInfo.OAuthExpiry, newToken.Expiry) - assert.Equal(t, authInfo.OAuthRefreshToken, newToken.RefreshToken) - assert.Equal(t, authInfo.OAuthTokenType, newToken.TokenType) -} - -func TestService_TryTokenRefresh_DifferentAuthModuleForUser(t *testing.T) { - srv, _, socialConnector := setupOAuthTokenService(t) - ctx := context.Background() - token := &oauth2.Token{} - usr := &login.UserAuth{ - AuthModule: "auth.saml", - } - - socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(token)) - - err := srv.TryTokenRefresh(ctx, usr) - - assert.NotNil(t, err) - assert.ErrorIs(t, err, ErrNotAnOAuthProvider) - - socialConnector.AssertNotCalled(t, "TokenSource") -} - func setupOAuthTokenService(t *testing.T) (*Service, *FakeAuthInfoStore, *socialtest.MockSocialConnector) { t.Helper() socialConnector := &socialtest.MockSocialConnector{} socialService := &socialtest.FakeSocialService{ ExpectedConnector: socialConnector, + ExpectedAuthInfoProvider: &social.OAuthInfo{ + UseRefreshToken: true, + }, } - authInfoStore := &FakeAuthInfoStore{} - authInfoService := authinfoimpl.ProvideService(authInfoStore) + authInfoStore := &FakeAuthInfoStore{ExpectedOAuth: &login.UserAuth{}} + authInfoService := authinfoimpl.ProvideService(authInfoStore, remotecache.NewFakeCacheStorage(), secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())) return &Service{ Cfg: setting.NewCfg(), SocialService: socialService, AuthInfoService: authInfoService, singleFlightGroup: &singleflight.Group{}, tokenRefreshDuration: newTokenRefreshDurationMetric(prometheus.NewRegistry()), + cache: localcache.New(maxOAuthTokenCacheTTL, 15*time.Minute), }, authInfoStore, socialConnector } @@ -264,3 +150,461 @@ func (f *FakeAuthInfoStore) UpdateAuthInfo(ctx context.Context, cmd *login.Updat func (f *FakeAuthInfoStore) DeleteAuthInfo(ctx context.Context, cmd *login.DeleteAuthInfoCommand) error { return f.ExpectedError } + +func TestService_TryTokenRefresh(t *testing.T) { + type environment struct { + authInfoService *authinfotest.FakeService + cache *localcache.CacheService + identity identity.Requester + socialConnector *socialtest.MockSocialConnector + socialService *socialtest.FakeSocialService + + service *Service + } + type testCase struct { + desc string + expectedErr error + setup func(env *environment) + } + + tests := []testCase{ + { + desc: "should skip sync when identity is nil", + }, + { + desc: "should skip sync when identity is not a user", + setup: func(env *environment) { + env.identity = &authn.Identity{ID: "service-account:1"} + }, + }, + { + desc: "should skip token refresh and return nil if namespace and id cannot be converted to user ID", + setup: func(env *environment) { + env.identity = &authn.Identity{ID: "user:invalidIdentifierFormat"} + }, + }, + { + desc: "should skip token refresh since the token is still valid", + setup: func(env *environment) { + token := &oauth2.Token{ + AccessToken: "testaccess", + RefreshToken: "testrefresh", + Expiry: time.Now().Add(time.Hour), + TokenType: "Bearer", + } + + env.authInfoService.ExpectedUserAuth = &login.UserAuth{ + AuthModule: login.GenericOAuthModule, + OAuthAccessToken: token.AccessToken, + OAuthRefreshToken: token.RefreshToken, + OAuthExpiry: token.Expiry, + OAuthTokenType: token.TokenType, + } + + env.identity = &authn.Identity{ + AuthenticatedBy: login.GenericOAuthModule, + ID: "user:1234", + } + }, + }, + { + desc: "should skip token refresh if the expiration check has already been cached", + setup: func(env *environment) { + env.identity = &authn.Identity{ID: "user:1234"} + env.cache.Set("oauth-refresh-token-1234", true, 1*time.Minute) + }, + }, + { + desc: "should skip token refresh if there's an unexpected error while looking up the user oauth entry, additionally, no error should be returned", + setup: func(env *environment) { + env.identity = &authn.Identity{ID: "user:1234"} + env.authInfoService.ExpectedError = errors.New("some error") + }, + }, + { + desc: "should skip token refresh if the user doesn't have an oauth entry", + setup: func(env *environment) { + env.identity = &authn.Identity{ID: "user:1234"} + env.authInfoService.ExpectedUserAuth = &login.UserAuth{ + AuthModule: login.SAMLAuthModule, + } + }, + }, + { + desc: "should do token refresh if access token or id token have not expired yet", + setup: func(env *environment) { + env.identity = &authn.Identity{ID: "user:1234"} + env.authInfoService.ExpectedUserAuth = &login.UserAuth{ + AuthModule: login.GenericOAuthModule, + } + }, + }, + { + desc: "should skip token refresh when no oauth provider was found", + setup: func(env *environment) { + env.identity = &authn.Identity{ID: "user:1234"} + env.authInfoService.ExpectedUserAuth = &login.UserAuth{ + AuthModule: login.GenericOAuthModule, + OAuthIdToken: EXPIRED_JWT, + } + }, + }, + { + desc: "should skip token refresh when oauth provider token handling is disabled (UseRefreshToken is false)", + setup: func(env *environment) { + env.identity = &authn.Identity{ID: "user:1234"} + env.authInfoService.ExpectedUserAuth = &login.UserAuth{ + AuthModule: login.GenericOAuthModule, + OAuthIdToken: EXPIRED_JWT, + } + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: false, + } + }, + }, + { + desc: "should skip token refresh when there is no refresh token", + setup: func(env *environment) { + env.identity = &authn.Identity{ID: "user:1234"} + env.authInfoService.ExpectedUserAuth = &login.UserAuth{ + AuthModule: login.GenericOAuthModule, + OAuthIdToken: EXPIRED_JWT, + OAuthRefreshToken: "", + } + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + }, + { + desc: "should do token refresh when the token is expired", + setup: func(env *environment) { + token := &oauth2.Token{ + AccessToken: "testaccess", + RefreshToken: "testrefresh", + Expiry: time.Now().Add(-time.Hour), + TokenType: "Bearer", + } + env.identity = &authn.Identity{ID: "user:1234"} + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.ExpectedUserAuth = &login.UserAuth{ + AuthModule: login.GenericOAuthModule, + AuthId: "subject", + UserId: 1, + OAuthAccessToken: token.AccessToken, + OAuthRefreshToken: token.RefreshToken, + OAuthExpiry: token.Expiry, + OAuthTokenType: token.TokenType, + } + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(token)).Once() + }, + }, + { + desc: "should refresh token when the id token is expired", + setup: func(env *environment) { + token := &oauth2.Token{ + AccessToken: "testaccess", + RefreshToken: "testrefresh", + Expiry: time.Now().Add(time.Hour), + TokenType: "Bearer", + } + env.identity = &authn.Identity{ID: "user:1234"} + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.ExpectedUserAuth = &login.UserAuth{ + AuthModule: login.GenericOAuthModule, + AuthId: "subject", + UserId: 1, + OAuthAccessToken: token.AccessToken, + OAuthRefreshToken: token.RefreshToken, + OAuthExpiry: token.Expiry, + OAuthTokenType: token.TokenType, + OAuthIdToken: EXPIRED_JWT, + } + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(token)).Once() + }, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + socialConnector := &socialtest.MockSocialConnector{} + + env := environment{ + authInfoService: &authinfotest.FakeService{}, + cache: localcache.New(maxOAuthTokenCacheTTL, 15*time.Minute), + socialConnector: socialConnector, + socialService: &socialtest.FakeSocialService{ + ExpectedConnector: socialConnector, + }, + } + + if tt.setup != nil { + tt.setup(&env) + } + + env.service = &Service{ + AuthInfoService: env.authInfoService, + Cfg: setting.NewCfg(), + cache: env.cache, + singleFlightGroup: &singleflight.Group{}, + SocialService: env.socialService, + tokenRefreshDuration: newTokenRefreshDurationMetric(prometheus.NewRegistry()), + } + + // token refresh + err := env.service.TryTokenRefresh(context.Background(), env.identity) + + // test and validations + assert.ErrorIs(t, err, tt.expectedErr) + socialConnector.AssertExpectations(t) + }) + } +} + +func TestOAuthTokenSync_getOAuthTokenCacheTTL(t *testing.T) { + defaultTime := time.Now() + tests := []struct { + name string + accessTokenExpiry time.Time + idTokenExpiry time.Time + want time.Duration + }{ + { + name: "should return maxOAuthTokenCacheTTL when no expiry is given", + accessTokenExpiry: time.Time{}, + idTokenExpiry: time.Time{}, + + want: maxOAuthTokenCacheTTL, + }, + { + name: "should return maxOAuthTokenCacheTTL when access token is not given and id token expiry is greater than max cache ttl", + accessTokenExpiry: time.Time{}, + idTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL), + + want: maxOAuthTokenCacheTTL, + }, + { + name: "should return idTokenExpiry when access token is not given and id token expiry is less than max cache ttl", + accessTokenExpiry: time.Time{}, + idTokenExpiry: defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL), + want: time.Until(defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL)), + }, + { + name: "should return maxOAuthTokenCacheTTL when access token expiry is greater than max cache ttl and id token is not given", + accessTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL), + idTokenExpiry: time.Time{}, + want: maxOAuthTokenCacheTTL, + }, + { + name: "should return accessTokenExpiry when access token expiry is less than max cache ttl and id token is not given", + accessTokenExpiry: defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL), + idTokenExpiry: time.Time{}, + want: time.Until(defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL)), + }, + { + name: "should return accessTokenExpiry when access token expiry is less than max cache ttl and less than id token expiry", + accessTokenExpiry: defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL), + idTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL), + want: time.Until(defaultTime.Add(-5*time.Minute + maxOAuthTokenCacheTTL)), + }, + { + name: "should return idTokenExpiry when id token expiry is less than max cache ttl and less than access token expiry", + accessTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL), + idTokenExpiry: defaultTime.Add(-3*time.Minute + maxOAuthTokenCacheTTL), + want: time.Until(defaultTime.Add(-3*time.Minute + maxOAuthTokenCacheTTL)), + }, + { + name: "should return maxOAuthTokenCacheTTL when access token expiry is greater than max cache ttl and id token expiry is greater than max cache ttl", + accessTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL), + idTokenExpiry: defaultTime.Add(5*time.Minute + maxOAuthTokenCacheTTL), + want: maxOAuthTokenCacheTTL, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getOAuthTokenCacheTTL(tt.accessTokenExpiry, tt.idTokenExpiry) + + assert.Equal(t, tt.want.Round(time.Second), got.Round(time.Second)) + }) + } +} + +func TestOAuthTokenSync_needTokenRefresh(t *testing.T) { + tests := []struct { + name string + usr *login.UserAuth + expectedTokenRefreshFlag bool + expectedTokenDuration time.Duration + }{ + { + name: "should not need token refresh when token has no expiration date", + usr: &login.UserAuth{}, + expectedTokenRefreshFlag: false, + expectedTokenDuration: maxOAuthTokenCacheTTL, + }, + { + name: "should not need token refresh with an invalid jwt token that might result in an error when parsing", + usr: &login.UserAuth{ + OAuthIdToken: "invalid_jwt_format", + }, + expectedTokenRefreshFlag: false, + expectedTokenDuration: maxOAuthTokenCacheTTL, + }, + { + name: "should flag token refresh with id token is expired", + usr: &login.UserAuth{ + OAuthIdToken: EXPIRED_JWT, + }, + expectedTokenRefreshFlag: true, + expectedTokenDuration: time.Second, + }, + { + name: "should flag token refresh when expiry date is zero", + usr: &login.UserAuth{ + OAuthExpiry: time.Unix(0, 0), + }, + expectedTokenRefreshFlag: true, + expectedTokenDuration: time.Second, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token, needsTokenRefresh, tokenDuration := needTokenRefresh(tt.usr) + + assert.NotNil(t, token) + assert.Equal(t, tt.expectedTokenRefreshFlag, needsTokenRefresh) + assert.Equal(t, tt.expectedTokenDuration, tokenDuration) + }) + } +} + +func TestOAuthTokenSync_tryGetOrRefreshOAuthToken(t *testing.T) { + timeNow := time.Now() + token := &oauth2.Token{ + AccessToken: "oauth_access_token", + RefreshToken: "refresh_token_found", + Expiry: timeNow, + TokenType: "Bearer", + } + type environment struct { + authInfoService *authinfotest.FakeService + cache *localcache.CacheService + socialConnector *socialtest.MockSocialConnector + socialService *socialtest.FakeSocialService + + service *Service + } + tests := []struct { + desc string + expectedErr error + expectedToken *oauth2.Token + usr *login.UserAuth + setup func(env *environment) + }{ + { + desc: "should find and retrieve token from cache", + usr: &login.UserAuth{ + UserId: int64(1234), + OAuthAccessToken: "new_access_token", + OAuthExpiry: timeNow, + }, + setup: func(env *environment) { + env.cache.Set("token-check-1234", token, 1*time.Minute) + }, + expectedToken: &oauth2.Token{ + AccessToken: "new_access_token", + Expiry: timeNow, + }, + }, + { + desc: "should return ErrNotAnOAuthProvider error when the user is not an oauth provider", + usr: &login.UserAuth{ + UserId: int64(1234), + AuthModule: login.SAMLAuthModule, + }, + expectedErr: ErrNotAnOAuthProvider, + }, + { + desc: "should return ErrNoRefreshTokenFound error when the no refresh token was found", + usr: &login.UserAuth{ + UserId: int64(1234), + AuthModule: login.GenericOAuthModule, + }, + expectedErr: ErrNoRefreshTokenFound, + }, + { + desc: "should not refresh token if the token is not expired", + usr: &login.UserAuth{ + UserId: int64(1234), + AuthModule: login.GenericOAuthModule, + OAuthAccessToken: token.AccessToken, + OAuthRefreshToken: token.RefreshToken, + OAuthExpiry: timeNow.Add(time.Hour), + OAuthTokenType: "Bearer", + }, + expectedToken: &oauth2.Token{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + Expiry: timeNow.Add(time.Hour), + TokenType: "Bearer", + }, + }, + { + desc: "should update saved token if the user auth has new access/refresh tokens", + usr: &login.UserAuth{ + UserId: int64(1234), + AuthModule: login.GenericOAuthModule, + OAuthAccessToken: "new_oauth_access_token", + OAuthRefreshToken: "new_refresh_token_found", + OAuthExpiry: timeNow, + }, + expectedToken: &oauth2.Token{ + AccessToken: "oauth_access_token", + RefreshToken: "refresh_token_found", + Expiry: timeNow, + TokenType: "Bearer", + }, + setup: func(env *environment) { + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(token)).Once() + }, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + socialConnector := &socialtest.MockSocialConnector{} + + env := environment{ + authInfoService: &authinfotest.FakeService{}, + cache: localcache.New(maxOAuthTokenCacheTTL, 15*time.Minute), + socialConnector: socialConnector, + socialService: &socialtest.FakeSocialService{ + ExpectedConnector: socialConnector, + }, + } + + if tt.setup != nil { + tt.setup(&env) + } + + env.service = &Service{ + AuthInfoService: env.authInfoService, + Cfg: setting.NewCfg(), + cache: env.cache, + singleFlightGroup: &singleflight.Group{}, + SocialService: env.socialService, + tokenRefreshDuration: newTokenRefreshDurationMetric(prometheus.NewRegistry()), + } + + token, err := env.service.tryGetOrRefreshOAuthToken(context.Background(), tt.usr) + + if tt.expectedToken != nil { + assert.Equal(t, tt.expectedToken, token) + } + assert.ErrorIs(t, tt.expectedErr, err) + socialConnector.AssertExpectations(t) + }) + } +} diff --git a/pkg/services/oauthtoken/oauthtokentest/mock.go b/pkg/services/oauthtoken/oauthtokentest/mock.go index 17c97665c4fc6..c273b86b0d8d2 100644 --- a/pkg/services/oauthtoken/oauthtokentest/mock.go +++ b/pkg/services/oauthtoken/oauthtokentest/mock.go @@ -15,7 +15,7 @@ type MockOauthTokenService struct { IsOAuthPassThruEnabledFunc func(ds *datasources.DataSource) bool HasOAuthEntryFunc func(ctx context.Context, usr identity.Requester) (*login.UserAuth, bool, error) InvalidateOAuthTokensFunc func(ctx context.Context, usr *login.UserAuth) error - TryTokenRefreshFunc func(ctx context.Context, usr *login.UserAuth) error + TryTokenRefreshFunc func(ctx context.Context, usr identity.Requester) error } func (m *MockOauthTokenService) GetCurrentOAuthToken(ctx context.Context, usr identity.Requester) *oauth2.Token { @@ -46,7 +46,7 @@ func (m *MockOauthTokenService) InvalidateOAuthTokens(ctx context.Context, usr * return nil } -func (m *MockOauthTokenService) TryTokenRefresh(ctx context.Context, usr *login.UserAuth) error { +func (m *MockOauthTokenService) TryTokenRefresh(ctx context.Context, usr identity.Requester) error { if m.TryTokenRefreshFunc != nil { return m.TryTokenRefreshFunc(ctx, usr) } diff --git a/pkg/services/oauthtoken/oauthtokentest/oauthtokentest.go b/pkg/services/oauthtoken/oauthtokentest/oauthtokentest.go index 1fe14dc8ed93b..c2ecb12d105e3 100644 --- a/pkg/services/oauthtoken/oauthtokentest/oauthtokentest.go +++ b/pkg/services/oauthtoken/oauthtokentest/oauthtokentest.go @@ -33,7 +33,7 @@ func (s *Service) HasOAuthEntry(context.Context, identity.Requester) (*login.Use return nil, false, nil } -func (s *Service) TryTokenRefresh(context.Context, *login.UserAuth) error { +func (s *Service) TryTokenRefresh(context.Context, identity.Requester) error { return nil } diff --git a/pkg/services/oauthtoken/oauthtokentest/service_mock.go b/pkg/services/oauthtoken/oauthtokentest/service_mock.go new file mode 100644 index 0000000000000..7b4d8943bafad --- /dev/null +++ b/pkg/services/oauthtoken/oauthtokentest/service_mock.go @@ -0,0 +1,146 @@ +// Code generated by mockery v2.40.1. DO NOT EDIT. + +package oauthtokentest + +import ( + context "context" + + identity "github.com/grafana/grafana/pkg/services/auth/identity" + datasources "github.com/grafana/grafana/pkg/services/datasources" + + login "github.com/grafana/grafana/pkg/services/login" + + mock "github.com/stretchr/testify/mock" + + oauth2 "golang.org/x/oauth2" +) + +// MockService is an autogenerated mock type for the OAuthTokenService type +type MockService struct { + mock.Mock +} + +// GetCurrentOAuthToken provides a mock function with given fields: _a0, _a1 +func (_m *MockService) GetCurrentOAuthToken(_a0 context.Context, _a1 identity.Requester) *oauth2.Token { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for GetCurrentOAuthToken") + } + + var r0 *oauth2.Token + if rf, ok := ret.Get(0).(func(context.Context, identity.Requester) *oauth2.Token); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*oauth2.Token) + } + } + + return r0 +} + +// HasOAuthEntry provides a mock function with given fields: _a0, _a1 +func (_m *MockService) HasOAuthEntry(_a0 context.Context, _a1 identity.Requester) (*login.UserAuth, bool, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for HasOAuthEntry") + } + + var r0 *login.UserAuth + var r1 bool + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, identity.Requester) (*login.UserAuth, bool, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, identity.Requester) *login.UserAuth); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*login.UserAuth) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, identity.Requester) bool); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Get(1).(bool) + } + + if rf, ok := ret.Get(2).(func(context.Context, identity.Requester) error); ok { + r2 = rf(_a0, _a1) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// InvalidateOAuthTokens provides a mock function with given fields: _a0, _a1 +func (_m *MockService) InvalidateOAuthTokens(_a0 context.Context, _a1 *login.UserAuth) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for InvalidateOAuthTokens") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *login.UserAuth) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// IsOAuthPassThruEnabled provides a mock function with given fields: _a0 +func (_m *MockService) IsOAuthPassThruEnabled(_a0 *datasources.DataSource) bool { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for IsOAuthPassThruEnabled") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(*datasources.DataSource) bool); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// TryTokenRefresh provides a mock function with given fields: _a0, _a1 +func (_m *MockService) TryTokenRefresh(_a0 context.Context, _a1 identity.Requester) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for TryTokenRefresh") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, identity.Requester) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockService(t interface { + mock.TestingT + Cleanup(func()) +}) *MockService { + mock := &MockService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/services/org/orgimpl/store_test.go b/pkg/services/org/orgimpl/store_test.go index bc9d04521839d..292c08d033549 100644 --- a/pkg/services/org/orgimpl/store_test.go +++ b/pkg/services/org/orgimpl/store_test.go @@ -21,8 +21,13 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationOrgDataAccess(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/playlist/playlistimpl/store_test.go b/pkg/services/playlist/playlistimpl/store_test.go index f0d733deb1c4a..9eb8c57f76714 100644 --- a/pkg/services/playlist/playlistimpl/store_test.go +++ b/pkg/services/playlist/playlistimpl/store_test.go @@ -11,8 +11,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/playlist" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type getStore func(db.DB) store func testIntegrationPlaylistDataAccess(t *testing.T, fn getStore) { diff --git a/pkg/services/plugindashboards/plugindashboards.go b/pkg/services/plugindashboards/plugindashboards.go index 153a9e306dcfd..086d23fca5f7b 100644 --- a/pkg/services/plugindashboards/plugindashboards.go +++ b/pkg/services/plugindashboards/plugindashboards.go @@ -8,16 +8,14 @@ import ( // PluginDashboard plugin dashboard model.. type PluginDashboard struct { - UID string `json:"uid"` - PluginId string `json:"pluginId"` - Title string `json:"title"` - Imported bool `json:"imported"` - ImportedUri string `json:"importedUri"` - ImportedUrl string `json:"importedUrl"` - Slug string `json:"slug"` - DashboardId int64 `json:"dashboardId"` - // Deprecated: use FolderUID instead - FolderId int64 `json:"folderId"` + UID string `json:"uid"` + PluginId string `json:"pluginId"` + Title string `json:"title"` + Imported bool `json:"imported"` + ImportedUri string `json:"importedUri"` + ImportedUrl string `json:"importedUrl"` + Slug string `json:"slug"` + DashboardId int64 `json:"dashboardId"` ImportedRevision int64 `json:"importedRevision"` Revision int64 `json:"revision"` Description string `json:"description"` diff --git a/pkg/services/pluginsintegration/angulardetectorsprovider/dynamic.go b/pkg/services/pluginsintegration/angulardetectorsprovider/dynamic.go index a2b326df0f03d..3e5229d2e3e0c 100644 --- a/pkg/services/pluginsintegration/angulardetectorsprovider/dynamic.go +++ b/pkg/services/pluginsintegration/angulardetectorsprovider/dynamic.go @@ -47,7 +47,7 @@ type Dynamic struct { mux sync.RWMutex } -func ProvideDynamic(cfg *config.Cfg, store angularpatternsstore.Service, features featuremgmt.FeatureToggles) (*Dynamic, error) { +func ProvideDynamic(cfg *config.PluginManagementCfg, store angularpatternsstore.Service, features featuremgmt.FeatureToggles) (*Dynamic, error) { d := &Dynamic{ log: log.New("plugin.angulardetectorsprovider.dynamic"), features: features, diff --git a/pkg/services/pluginsintegration/angulardetectorsprovider/dynamic_test.go b/pkg/services/pluginsintegration/angulardetectorsprovider/dynamic_test.go index 9ffbfc409a87e..c3fa42273bd38 100644 --- a/pkg/services/pluginsintegration/angulardetectorsprovider/dynamic_test.go +++ b/pkg/services/pluginsintegration/angulardetectorsprovider/dynamic_test.go @@ -574,7 +574,7 @@ func provideDynamic(t *testing.T, gcomURL string, opts ...provideDynamicOpts) *D opt.store = angularpatternsstore.ProvideService(kvstore.NewFakeKVStore()) } d, err := ProvideDynamic( - &config.Cfg{GrafanaComURL: gcomURL}, + &config.PluginManagementCfg{GrafanaComURL: gcomURL}, opt.store, featuremgmt.WithFeatures(featuremgmt.FlagPluginsDynamicAngularDetectionPatterns), ) diff --git a/pkg/services/pluginsintegration/angularinspector/angularinspector.go b/pkg/services/pluginsintegration/angularinspector/angularinspector.go index 9eb8b20ab39a9..1fd9f67101912 100644 --- a/pkg/services/pluginsintegration/angularinspector/angularinspector.go +++ b/pkg/services/pluginsintegration/angularinspector/angularinspector.go @@ -1,7 +1,6 @@ package angularinspector import ( - "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/manager/loader/angular/angulardetector" "github.com/grafana/grafana/pkg/plugins/manager/loader/angular/angularinspector" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -12,11 +11,11 @@ type Service struct { angularinspector.Inspector } -func ProvideService(cfg *config.Cfg, dynamic *angulardetectorsprovider.Dynamic) (*Service, error) { +func ProvideService(features featuremgmt.FeatureToggles, dynamic *angulardetectorsprovider.Dynamic) (*Service, error) { var detectorsProvider angulardetector.DetectorsProvider var err error static := angularinspector.NewDefaultStaticDetectorsProvider() - if cfg.Features != nil && cfg.Features.IsEnabledGlobally(featuremgmt.FlagPluginsDynamicAngularDetectionPatterns) { + if features.IsEnabledGlobally(featuremgmt.FlagPluginsDynamicAngularDetectionPatterns) { detectorsProvider = angulardetector.SequenceDetectorsProvider{dynamic, static} } else { detectorsProvider = static diff --git a/pkg/services/pluginsintegration/angularinspector/angularinspector_test.go b/pkg/services/pluginsintegration/angularinspector/angularinspector_test.go index de98c2f8382ce..b95b6d37ed434 100644 --- a/pkg/services/pluginsintegration/angularinspector/angularinspector_test.go +++ b/pkg/services/pluginsintegration/angularinspector/angularinspector_test.go @@ -17,14 +17,14 @@ import ( func TestProvideService(t *testing.T) { t.Run("uses hardcoded inspector if feature flag is not present", func(t *testing.T) { - pCfg := &config.Cfg{Features: featuremgmt.WithFeatures()} + features := featuremgmt.WithFeatures() dynamic, err := angulardetectorsprovider.ProvideDynamic( - pCfg, + &config.PluginManagementCfg{}, angularpatternsstore.ProvideService(kvstore.NewFakeKVStore()), - featuremgmt.WithFeatures(featuremgmt.FlagPluginsDynamicAngularDetectionPatterns), + features, ) require.NoError(t, err) - inspector, err := ProvideService(pCfg, dynamic) + inspector, err := ProvideService(features, dynamic) require.NoError(t, err) require.IsType(t, inspector.Inspector, &angularinspector.PatternsListInspector{}) patternsListInspector := inspector.Inspector.(*angularinspector.PatternsListInspector) @@ -33,16 +33,16 @@ func TestProvideService(t *testing.T) { }) t.Run("uses dynamic inspector with hardcoded fallback if feature flag is present", func(t *testing.T) { - pCfg := &config.Cfg{Features: featuremgmt.WithFeatures( + features := featuremgmt.WithFeatures( featuremgmt.FlagPluginsDynamicAngularDetectionPatterns, - )} + ) dynamic, err := angulardetectorsprovider.ProvideDynamic( - pCfg, + &config.PluginManagementCfg{}, angularpatternsstore.ProvideService(kvstore.NewFakeKVStore()), - featuremgmt.WithFeatures(), + features, ) require.NoError(t, err) - inspector, err := ProvideService(pCfg, dynamic) + inspector, err := ProvideService(features, dynamic) require.NoError(t, err) require.IsType(t, inspector.Inspector, &angularinspector.PatternsListInspector{}) require.IsType(t, inspector.Inspector.(*angularinspector.PatternsListInspector).DetectorsProvider, angulardetector.SequenceDetectorsProvider{}) diff --git a/pkg/services/pluginsintegration/clientmiddleware/caching_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/caching_middleware.go index 3bc00043462e5..f77b8f100bb85 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/caching_middleware.go +++ b/pkg/services/pluginsintegration/clientmiddleware/caching_middleware.go @@ -27,7 +27,7 @@ func NewCachingMiddleware(cachingService caching.CachingService) plugins.ClientM // NewCachingMiddlewareWithFeatureManager creates a new plugins.ClientMiddleware that will // attempt to read and write query results to the cache with a feature manager -func NewCachingMiddlewareWithFeatureManager(cachingService caching.CachingService, features *featuremgmt.FeatureManager) plugins.ClientMiddleware { +func NewCachingMiddlewareWithFeatureManager(cachingService caching.CachingService, features featuremgmt.FeatureToggles) plugins.ClientMiddleware { log := log.New("caching_middleware") if err := prometheus.Register(QueryCachingRequestHistogram); err != nil { log.Error("Error registering prometheus collector 'QueryRequestHistogram'", "error", err) @@ -49,7 +49,7 @@ type CachingMiddleware struct { next plugins.Client caching caching.CachingService log log.Logger - features *featuremgmt.FeatureManager + features featuremgmt.FeatureToggles } // QueryData receives a data request and attempts to access results already stored in the cache for that request. diff --git a/pkg/services/pluginsintegration/clientmiddleware/forward_id_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/forward_id_middleware.go index ee7cf30738cd1..71989ebe86718 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/forward_id_middleware.go +++ b/pkg/services/pluginsintegration/clientmiddleware/forward_id_middleware.go @@ -5,11 +5,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/contexthandler" - "github.com/grafana/grafana/pkg/services/datasources" ) const forwardIDHeaderName = "X-Grafana-Id" @@ -31,17 +28,8 @@ type ForwardIDMiddleware struct { func (m *ForwardIDMiddleware) applyToken(ctx context.Context, pCtx backend.PluginContext, req backend.ForwardHTTPHeaders) error { reqCtx := contexthandler.FromContext(ctx) - // if request not for a datasource or no HTTP request context skip middleware - if req == nil || reqCtx == nil || reqCtx.SignedInUser == nil || pCtx.DataSourceInstanceSettings == nil { - return nil - } - - jsonDataBytes, err := simplejson.NewJson(pCtx.DataSourceInstanceSettings.JSONData) - if err != nil { - return err - } - - if !auth.IsIDForwardingEnabledForDataSource(&datasources.DataSource{JsonData: jsonDataBytes}) { + // no HTTP request context => skip middleware + if req == nil || reqCtx == nil || reqCtx.SignedInUser == nil { return nil } diff --git a/pkg/services/pluginsintegration/clientmiddleware/forward_id_middleware_test.go b/pkg/services/pluginsintegration/clientmiddleware/forward_id_middleware_test.go index 42c0953cad41e..2972c612a92da 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/forward_id_middleware_test.go +++ b/pkg/services/pluginsintegration/clientmiddleware/forward_id_middleware_test.go @@ -2,7 +2,6 @@ package clientmiddleware import ( "context" - "encoding/json" "net/http" "testing" @@ -17,15 +16,9 @@ import ( ) func TestForwardIDMiddleware(t *testing.T) { - settingWithEnabled, err := json.Marshal(map[string]any{ - "forwardGrafanaIdToken": true, - }) - require.NoError(t, err) - - settingWithDisabled, err := json.Marshal(map[string]any{ - "forwardGrafanaIdToken": false, - }) - require.NoError(t, err) + pluginContext := backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, + } t.Run("Should set forwarded id header if present", func(t *testing.T) { cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares(NewForwardIDMiddleware())) @@ -36,53 +29,46 @@ func TestForwardIDMiddleware(t *testing.T) { }) err := cdt.Decorator.CallResource(ctx, &backend.CallResourceRequest{ - PluginContext: backend.PluginContext{ - DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ - JSONData: settingWithEnabled, - }, - }, + PluginContext: pluginContext, }, nopCallResourceSender) require.NoError(t, err) require.Equal(t, "some-token", cdt.CallResourceReq.Headers[forwardIDHeaderName][0]) }) - t.Run("Should not set forwarded id header if setting is disabled", func(t *testing.T) { + t.Run("Should not set forwarded id header if not present", func(t *testing.T) { cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares(NewForwardIDMiddleware())) ctx := context.WithValue(context.Background(), ctxkey.Key{}, &contextmodel.ReqContext{ Context: &web.Context{Req: &http.Request{}}, - SignedInUser: &user.SignedInUser{IDToken: "some-token"}, + SignedInUser: &user.SignedInUser{}, }) err := cdt.Decorator.CallResource(ctx, &backend.CallResourceRequest{ - PluginContext: backend.PluginContext{ - DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ - JSONData: settingWithDisabled, - }, - }, + PluginContext: pluginContext, }, nopCallResourceSender) require.NoError(t, err) + require.Len(t, cdt.CallResourceReq.Headers[forwardIDHeaderName], 0) }) - t.Run("Should not set forwarded id header if not present", func(t *testing.T) { + pluginContext = backend.PluginContext{ + AppInstanceSettings: &backend.AppInstanceSettings{}, + } + + t.Run("Should set forwarded id header to app plugin if present", func(t *testing.T) { cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares(NewForwardIDMiddleware())) ctx := context.WithValue(context.Background(), ctxkey.Key{}, &contextmodel.ReqContext{ Context: &web.Context{Req: &http.Request{}}, - SignedInUser: &user.SignedInUser{}, + SignedInUser: &user.SignedInUser{IDToken: "some-token"}, }) err := cdt.Decorator.CallResource(ctx, &backend.CallResourceRequest{ - PluginContext: backend.PluginContext{ - DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ - JSONData: settingWithEnabled, - }, - }, + PluginContext: pluginContext, }, nopCallResourceSender) require.NoError(t, err) - require.Len(t, cdt.CallResourceReq.Headers[forwardIDHeaderName], 0) + require.Equal(t, "some-token", cdt.CallResourceReq.Headers[forwardIDHeaderName][0]) }) } diff --git a/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware.go new file mode 100644 index 0000000000000..15c2a1c1ccc51 --- /dev/null +++ b/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware.go @@ -0,0 +1,162 @@ +package clientmiddleware + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/url" + + "github.com/google/uuid" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" +) + +const GrafanaRequestID = "X-Grafana-Request-Id" +const GrafanaSignedRequestID = "X-Grafana-Signed-Request-Id" +const XRealIPHeader = "X-Real-Ip" +const GrafanaInternalRequest = "X-Grafana-Internal-Request" + +// NewHostedGrafanaACHeaderMiddleware creates a new plugins.ClientMiddleware that will +// generate a random request ID, sign it using internal key and populate X-Grafana-Request-ID with the request ID +// and X-Grafana-Signed-Request-ID with signed request ID. We can then use this to verify that the request +// is coming from hosted Grafana and is not an external request. This is used for IP range access control. +func NewHostedGrafanaACHeaderMiddleware(cfg *setting.Cfg) plugins.ClientMiddleware { + return plugins.ClientMiddlewareFunc(func(next plugins.Client) plugins.Client { + return &HostedGrafanaACHeaderMiddleware{ + next: next, + log: log.New("ip_header_middleware"), + cfg: cfg, + } + }) +} + +type HostedGrafanaACHeaderMiddleware struct { + next plugins.Client + log log.Logger + cfg *setting.Cfg +} + +func (m *HostedGrafanaACHeaderMiddleware) applyGrafanaRequestIDHeader(ctx context.Context, pCtx backend.PluginContext, h backend.ForwardHTTPHeaders) { + // if request is not for a datasource, skip the middleware + if h == nil || pCtx.DataSourceInstanceSettings == nil { + return + } + + // Check if the request is for a datasource that is allowed to have the header + dsURL := pCtx.DataSourceInstanceSettings.URL + dsBaseURL, err := url.Parse(dsURL) + if err != nil { + m.log.Debug("Failed to parse data source URL", "error", err) + return + } + if !IsRequestURLInAllowList(dsBaseURL, m.cfg) { + m.log.Debug("Data source URL not among the allow-listed URLs", "url", dsBaseURL.String()) + return + } + + var req *http.Request + reqCtx := contexthandler.FromContext(ctx) + if reqCtx != nil { + req = reqCtx.Req + } + for k, v := range GetGrafanaRequestIDHeaders(req, m.cfg, m.log) { + h.SetHTTPHeader(k, v) + } +} + +func IsRequestURLInAllowList(url *url.URL, cfg *setting.Cfg) bool { + for _, allowedURL := range cfg.IPRangeACAllowedURLs { + // Only look at the scheme and host, ignore the path + if allowedURL.Host == url.Host && allowedURL.Scheme == url.Scheme { + return true + } + } + return false +} + +func GetGrafanaRequestIDHeaders(req *http.Request, cfg *setting.Cfg, logger log.Logger) map[string]string { + // Generate a new Grafana request ID and sign it with the secret key + uid, err := uuid.NewRandom() + if err != nil { + logger.Debug("Failed to generate Grafana request ID", "error", err) + return nil + } + grafanaRequestID := uid.String() + + hmac := hmac.New(sha256.New, []byte(cfg.IPRangeACSecretKey)) + if _, err := hmac.Write([]byte(grafanaRequestID)); err != nil { + logger.Debug("Failed to sign IP range access control header", "error", err) + return nil + } + signedGrafanaRequestID := hex.EncodeToString(hmac.Sum(nil)) + + headers := make(map[string]string) + headers[GrafanaRequestID] = grafanaRequestID + headers[GrafanaSignedRequestID] = signedGrafanaRequestID + + // If the remote address is not specified, treat the request as internal + remoteAddress := "" + if req != nil { + remoteAddress = web.RemoteAddr(req) + } + if remoteAddress != "" { + headers[XRealIPHeader] = remoteAddress + } else { + headers[GrafanaInternalRequest] = "true" + } + + return headers +} + +func (m *HostedGrafanaACHeaderMiddleware) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + if req == nil { + return m.next.QueryData(ctx, req) + } + + m.applyGrafanaRequestIDHeader(ctx, req.PluginContext, req) + + return m.next.QueryData(ctx, req) +} + +func (m *HostedGrafanaACHeaderMiddleware) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + if req == nil { + return m.next.CallResource(ctx, req, sender) + } + + m.applyGrafanaRequestIDHeader(ctx, req.PluginContext, req) + + return m.next.CallResource(ctx, req, sender) +} + +func (m *HostedGrafanaACHeaderMiddleware) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + if req == nil { + return m.next.CheckHealth(ctx, req) + } + + m.applyGrafanaRequestIDHeader(ctx, req.PluginContext, req) + + return m.next.CheckHealth(ctx, req) +} + +func (m *HostedGrafanaACHeaderMiddleware) CollectMetrics(ctx context.Context, req *backend.CollectMetricsRequest) (*backend.CollectMetricsResult, error) { + return m.next.CollectMetrics(ctx, req) +} + +func (m *HostedGrafanaACHeaderMiddleware) SubscribeStream(ctx context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) { + return m.next.SubscribeStream(ctx, req) +} + +func (m *HostedGrafanaACHeaderMiddleware) PublishStream(ctx context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) { + return m.next.PublishStream(ctx, req) +} + +func (m *HostedGrafanaACHeaderMiddleware) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error { + return m.next.RunStream(ctx, req, sender) +} diff --git a/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware_test.go b/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware_test.go new file mode 100644 index 0000000000000..3c389edea1dfe --- /dev/null +++ b/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware_test.go @@ -0,0 +1,138 @@ +package clientmiddleware + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/plugins/manager/client/clienttest" + "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" +) + +func Test_HostedGrafanaACHeaderMiddleware(t *testing.T) { + t.Run("Should set Grafana request ID headers if the data source URL is in the allow list", func(t *testing.T) { + cfg := setting.NewCfg() + allowedURL := &url.URL{Scheme: "https", Host: "logs.grafana.net"} + cfg.IPRangeACAllowedURLs = []*url.URL{allowedURL} + cfg.IPRangeACSecretKey = "secret" + cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares(NewHostedGrafanaACHeaderMiddleware(cfg))) + + ctx := context.WithValue(context.Background(), ctxkey.Key{}, &contextmodel.ReqContext{ + Context: &web.Context{Req: &http.Request{ + Header: map[string][]string{"X-Real-Ip": {"1.2.3.4"}}, + }}, + SignedInUser: &user.SignedInUser{}, + }) + + err := cdt.Decorator.CallResource(ctx, &backend.CallResourceRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + URL: "https://logs.grafana.net", + }, + }, + }, nopCallResourceSender) + require.NoError(t, err) + + require.Len(t, cdt.CallResourceReq.Headers[GrafanaRequestID], 1) + require.Len(t, cdt.CallResourceReq.Headers[GrafanaSignedRequestID], 1) + + requestID := cdt.CallResourceReq.Headers[GrafanaRequestID][0] + + instance := hmac.New(sha256.New, []byte(cfg.IPRangeACSecretKey)) + _, err = instance.Write([]byte(requestID)) + require.NoError(t, err) + computed := hex.EncodeToString(instance.Sum(nil)) + + require.Equal(t, cdt.CallResourceReq.Headers[GrafanaSignedRequestID][0], computed) + + require.Len(t, cdt.CallResourceReq.Headers[XRealIPHeader], 1) + require.Equal(t, cdt.CallResourceReq.Headers[XRealIPHeader][0], "1.2.3.4") + + // Internal header should not be set + require.Len(t, cdt.CallResourceReq.Headers[GrafanaInternalRequest], 0) + }) + + t.Run("Should not set Grafana request ID headers if the data source URL is not in the allow list", func(t *testing.T) { + cfg := setting.NewCfg() + allowedURL := &url.URL{Scheme: "https", Host: "logs.grafana.net"} + cfg.IPRangeACAllowedURLs = []*url.URL{allowedURL} + cfg.IPRangeACSecretKey = "secret" + cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares(NewHostedGrafanaACHeaderMiddleware(cfg))) + + ctx := context.WithValue(context.Background(), ctxkey.Key{}, &contextmodel.ReqContext{ + Context: &web.Context{Req: &http.Request{}}, + SignedInUser: &user.SignedInUser{}, + }) + + err := cdt.Decorator.CallResource(ctx, &backend.CallResourceRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + URL: "https://logs.not-grafana.net", + }, + }, + }, nopCallResourceSender) + require.NoError(t, err) + + require.Len(t, cdt.CallResourceReq.Headers[GrafanaRequestID], 0) + require.Len(t, cdt.CallResourceReq.Headers[GrafanaSignedRequestID], 0) + }) + + t.Run("Should set Grafana request ID headers if URL scheme and host match a URL from the allow list", func(t *testing.T) { + cfg := setting.NewCfg() + allowedURL := &url.URL{Scheme: "https", Host: "logs.grafana.net"} + cfg.IPRangeACAllowedURLs = []*url.URL{allowedURL} + cfg.IPRangeACSecretKey = "secret" + cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares(NewHostedGrafanaACHeaderMiddleware(cfg))) + + ctx := context.WithValue(context.Background(), ctxkey.Key{}, &contextmodel.ReqContext{ + Context: &web.Context{Req: &http.Request{}}, + SignedInUser: &user.SignedInUser{}, + }) + + err := cdt.Decorator.CallResource(ctx, &backend.CallResourceRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + URL: "https://logs.grafana.net/abc/../some/path", + }, + }, + }, nopCallResourceSender) + require.NoError(t, err) + + require.Len(t, cdt.CallResourceReq.Headers[GrafanaRequestID], 1) + require.Len(t, cdt.CallResourceReq.Headers[GrafanaSignedRequestID], 1) + }) + + t.Run("Should set Grafana internal request header if the request is internal (doesn't have X-Real-IP header set)", func(t *testing.T) { + cfg := setting.NewCfg() + allowedURL := &url.URL{Scheme: "https", Host: "logs.grafana.net"} + cfg.IPRangeACAllowedURLs = []*url.URL{allowedURL} + cfg.IPRangeACSecretKey = "secret" + cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares(NewHostedGrafanaACHeaderMiddleware(cfg))) + + ctx := context.WithValue(context.Background(), ctxkey.Key{}, &contextmodel.ReqContext{ + Context: &web.Context{Req: &http.Request{}}, + SignedInUser: &user.SignedInUser{}, + }) + + err := cdt.Decorator.CallResource(ctx, &backend.CallResourceRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + URL: "https://logs.grafana.net", + }, + }, + }, nopCallResourceSender) + require.NoError(t, err) + require.Equal(t, cdt.CallResourceReq.Headers[GrafanaInternalRequest][0], "true") + }) +} diff --git a/pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go index 5dc5eea60945f..9982aefcb66f6 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go +++ b/pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go @@ -50,9 +50,7 @@ func (m *LoggerMiddleware) logRequest(ctx context.Context, fn func(ctx context.C if err != nil { logParams = append(logParams, "error", err) } - if m.features.IsEnabled(ctx, featuremgmt.FlagPluginsInstrumentationStatusSource) { - logParams = append(logParams, "statusSource", pluginrequestmeta.StatusSourceFromContext(ctx)) - } + logParams = append(logParams, "statusSource", pluginrequestmeta.StatusSourceFromContext(ctx)) ctxLogger := m.logger.FromContext(ctx) logFunc := ctxLogger.Info @@ -81,9 +79,11 @@ func (m *LoggerMiddleware) QueryData(ctx context.Context, req *backend.QueryData ctxLogger := m.logger.FromContext(ctx) for refID, dr := range resp.Responses { if dr.Error != nil { - logParams := []any{"refID", refID, "status", int(dr.Status), "error", dr.Error} - if m.features.IsEnabled(ctx, featuremgmt.FlagPluginsInstrumentationStatusSource) { - logParams = append(logParams, "statusSource", pluginrequestmeta.StatusSourceFromPluginErrorSource(dr.ErrorSource)) + logParams := []any{ + "refID", refID, + "status", int(dr.Status), + "error", dr.Error, + "statusSource", pluginrequestmeta.StatusSourceFromPluginErrorSource(dr.ErrorSource), } ctxLogger.Error("Partial data response error", logParams...) } diff --git a/pkg/services/pluginsintegration/clientmiddleware/metrics_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/metrics_middleware.go index 329117f5c87b1..8b7cd2dc02d4b 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/metrics_middleware.go +++ b/pkg/services/pluginsintegration/clientmiddleware/metrics_middleware.go @@ -32,10 +32,7 @@ type MetricsMiddleware struct { } func newMetricsMiddleware(promRegisterer prometheus.Registerer, pluginRegistry registry.Service, features featuremgmt.FeatureToggles) *MetricsMiddleware { - var additionalLabels []string - if features.IsEnabledGlobally(featuremgmt.FlagPluginsInstrumentationStatusSource) { - additionalLabels = []string{"status_source"} - } + additionalLabels := []string{"status_source"} pluginRequestCounter := prometheus.NewCounterVec(prometheus.CounterOpts{ Namespace: "grafana", Name: "plugin_request_total", @@ -89,8 +86,8 @@ func NewMetricsMiddleware(promRegisterer prometheus.Registerer, pluginRegistry r } // pluginTarget returns the value for the "target" Prometheus label for the given plugin ID. -func (m *MetricsMiddleware) pluginTarget(ctx context.Context, pluginID string) (string, error) { - p, exists := m.pluginRegistry.Plugin(ctx, pluginID) +func (m *MetricsMiddleware) pluginTarget(ctx context.Context, pluginID, pluginVersion string) (string, error) { + p, exists := m.pluginRegistry.Plugin(ctx, pluginID, pluginVersion) if !exists { return "", plugins.ErrPluginNotRegistered } @@ -99,7 +96,7 @@ func (m *MetricsMiddleware) pluginTarget(ctx context.Context, pluginID string) ( // instrumentPluginRequestSize tracks the size of the given request in the m.pluginRequestSize metric. func (m *MetricsMiddleware) instrumentPluginRequestSize(ctx context.Context, pluginCtx backend.PluginContext, endpoint string, requestSize float64) error { - target, err := m.pluginTarget(ctx, pluginCtx.PluginID) + target, err := m.pluginTarget(ctx, pluginCtx.PluginID, pluginCtx.PluginVersion) if err != nil { return err } @@ -109,7 +106,7 @@ func (m *MetricsMiddleware) instrumentPluginRequestSize(ctx context.Context, plu // instrumentPluginRequest increments the m.pluginRequestCounter metric and tracks the duration of the given request. func (m *MetricsMiddleware) instrumentPluginRequest(ctx context.Context, pluginCtx backend.PluginContext, endpoint string, fn func(context.Context) (requestStatus, error)) error { - target, err := m.pluginTarget(ctx, pluginCtx.PluginID) + target, err := m.pluginTarget(ctx, pluginCtx.PluginID, pluginCtx.PluginVersion) if err != nil { return err } @@ -119,19 +116,11 @@ func (m *MetricsMiddleware) instrumentPluginRequest(ctx context.Context, pluginC status, err := fn(ctx) elapsed := time.Since(start) - pluginRequestDurationLabels := []string{pluginCtx.PluginID, endpoint, target} - pluginRequestCounterLabels := []string{pluginCtx.PluginID, endpoint, status.String(), target} - pluginRequestDurationSecondsLabels := []string{"grafana-backend", pluginCtx.PluginID, endpoint, status.String(), target} - if m.features.IsEnabled(ctx, featuremgmt.FlagPluginsInstrumentationStatusSource) { - statusSource := pluginrequestmeta.StatusSourceFromContext(ctx) - pluginRequestDurationLabels = append(pluginRequestDurationLabels, string(statusSource)) - pluginRequestCounterLabels = append(pluginRequestCounterLabels, string(statusSource)) - pluginRequestDurationSecondsLabels = append(pluginRequestDurationSecondsLabels, string(statusSource)) - } + statusSource := pluginrequestmeta.StatusSourceFromContext(ctx) - pluginRequestDurationWithLabels := m.pluginRequestDuration.WithLabelValues(pluginRequestDurationLabels...) - pluginRequestCounterWithLabels := m.pluginRequestCounter.WithLabelValues(pluginRequestCounterLabels...) - pluginRequestDurationSecondsWithLabels := m.pluginRequestDurationSeconds.WithLabelValues(pluginRequestDurationSecondsLabels...) + pluginRequestDurationWithLabels := m.pluginRequestDuration.WithLabelValues(pluginCtx.PluginID, endpoint, target, string(statusSource)) + pluginRequestCounterWithLabels := m.pluginRequestCounter.WithLabelValues(pluginCtx.PluginID, endpoint, status.String(), target, string(statusSource)) + pluginRequestDurationSecondsWithLabels := m.pluginRequestDurationSeconds.WithLabelValues("grafana-backend", pluginCtx.PluginID, endpoint, status.String(), target, string(statusSource)) if traceID := tracing.TraceIDFromContext(ctx, true); traceID != "" { pluginRequestDurationWithLabels.(prometheus.ExemplarObserver).ObserveWithExemplar( diff --git a/pkg/services/pluginsintegration/clientmiddleware/metrics_middleware_test.go b/pkg/services/pluginsintegration/clientmiddleware/metrics_middleware_test.go index 66a1bc813d8ae..478a65469d72b 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/metrics_middleware_test.go +++ b/pkg/services/pluginsintegration/clientmiddleware/metrics_middleware_test.go @@ -90,7 +90,7 @@ func TestInstrumentationMiddleware(t *testing.T) { require.Equal(t, 1, testutil.CollectAndCount(promRegistry, metricRequestDurationMs)) require.Equal(t, 1, testutil.CollectAndCount(promRegistry, metricRequestDurationS)) - counter := mw.pluginMetrics.pluginRequestCounter.WithLabelValues(pluginID, tc.expEndpoint, requestStatusOK.String(), string(backendplugin.TargetUnknown)) + counter := mw.pluginMetrics.pluginRequestCounter.WithLabelValues(pluginID, tc.expEndpoint, requestStatusOK.String(), string(backendplugin.TargetUnknown), string(pluginrequestmeta.DefaultStatusSource)) require.Equal(t, 1.0, testutil.ToFloat64(counter)) for _, m := range []string{metricRequestDurationMs, metricRequestDurationS} { require.NoError(t, checkHistogram(promRegistry, m, map[string]string{ @@ -115,12 +115,6 @@ func TestInstrumentationMiddleware(t *testing.T) { func TestInstrumentationMiddlewareStatusSource(t *testing.T) { const labelStatusSource = "status_source" - queryDataOKCounterLabels := prometheus.Labels{ - "plugin_id": pluginID, - "endpoint": endpointQueryData, - "status": requestStatusOK.String(), - "target": string(backendplugin.TargetUnknown), - } queryDataErrorCounterLabels := prometheus.Labels{ "plugin_id": pluginID, "endpoint": endpointQueryData, @@ -159,8 +153,7 @@ func TestInstrumentationMiddlewareStatusSource(t *testing.T) { require.NoError(t, pluginsRegistry.Add(context.Background(), &plugins.Plugin{ JSONData: plugins.JSONData{ID: pluginID, Backend: true}, })) - features := featuremgmt.WithFeatures(featuremgmt.FlagPluginsInstrumentationStatusSource) - metricsMw := newMetricsMiddleware(promRegistry, pluginsRegistry, features) + metricsMw := newMetricsMiddleware(promRegistry, pluginsRegistry, featuremgmt.WithFeatures()) cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares( NewPluginRequestMetaMiddleware(), plugins.ClientMiddlewareFunc(func(next plugins.Client) plugins.Client { @@ -171,53 +164,21 @@ func TestInstrumentationMiddlewareStatusSource(t *testing.T) { )) t.Run("Metrics", func(t *testing.T) { - t.Run("Should ignore ErrorSource if feature flag is disabled", func(t *testing.T) { - // Use different middleware without feature flag - metricsMw := newMetricsMiddleware(prometheus.NewRegistry(), pluginsRegistry, featuremgmt.WithFeatures()) - cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares( - plugins.ClientMiddlewareFunc(func(next plugins.Client) plugins.Client { - metricsMw.next = next - return metricsMw - }), - )) - - cdt.TestClient.QueryDataFunc = func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - return &backend.QueryDataResponse{Responses: map[string]backend.DataResponse{"A": downstreamErrorResponse}}, nil - } - _, err := cdt.Decorator.QueryData(context.Background(), &backend.QueryDataRequest{PluginContext: pCtx}) - require.NoError(t, err) - counter, err := metricsMw.pluginMetrics.pluginRequestCounter.GetMetricWith(newLabels(queryDataErrorCounterLabels, nil)) - require.NoError(t, err) - require.Equal(t, 1.0, testutil.ToFloat64(counter)) - - // error_source should not be defined at all - _, err = metricsMw.pluginMetrics.pluginRequestCounter.GetMetricWith(newLabels( - queryDataOKCounterLabels, - prometheus.Labels{ - labelStatusSource: string(backend.ErrorSourceDownstream), - }), - ) - require.Error(t, err) - require.ErrorContains(t, err, "inconsistent label cardinality") - }) - - t.Run("Should add error_source label if feature flag is enabled", func(t *testing.T) { - metricsMw.pluginMetrics.pluginRequestCounter.Reset() + metricsMw.pluginMetrics.pluginRequestCounter.Reset() - cdt.TestClient.QueryDataFunc = func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - return &backend.QueryDataResponse{Responses: map[string]backend.DataResponse{"A": downstreamErrorResponse}}, nil - } - _, err := cdt.Decorator.QueryData(context.Background(), &backend.QueryDataRequest{PluginContext: pCtx}) - require.NoError(t, err) - counter, err := metricsMw.pluginMetrics.pluginRequestCounter.GetMetricWith(newLabels( - queryDataErrorCounterLabels, - prometheus.Labels{ - labelStatusSource: string(backend.ErrorSourceDownstream), - }), - ) - require.NoError(t, err) - require.Equal(t, 1.0, testutil.ToFloat64(counter)) - }) + cdt.TestClient.QueryDataFunc = func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return &backend.QueryDataResponse{Responses: map[string]backend.DataResponse{"A": downstreamErrorResponse}}, nil + } + _, err := cdt.Decorator.QueryData(context.Background(), &backend.QueryDataRequest{PluginContext: pCtx}) + require.NoError(t, err) + counter, err := metricsMw.pluginMetrics.pluginRequestCounter.GetMetricWith(newLabels( + queryDataErrorCounterLabels, + prometheus.Labels{ + labelStatusSource: string(backend.ErrorSourceDownstream), + }), + ) + require.NoError(t, err) + require.Equal(t, 1.0, testutil.ToFloat64(counter)) }) t.Run("Priority", func(t *testing.T) { diff --git a/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware.go index 2c75be1b0e6b3..06003fe502d46 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware.go +++ b/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware.go @@ -34,7 +34,7 @@ func (m *TracingHeaderMiddleware) applyHeaders(ctx context.Context, req backend. return } - var headersList = []string{query.HeaderQueryGroupID, query.HeaderPanelID, query.HeaderDashboardUID, query.HeaderDatasourceUID, query.HeaderFromExpression, `X-Grafana-Org-Id`} + var headersList = []string{query.HeaderQueryGroupID, query.HeaderPanelID, query.HeaderDashboardUID, query.HeaderDatasourceUID, query.HeaderFromExpression, `X-Grafana-Org-Id`, query.HeaderPanelPluginId} for _, headerName := range headersList { gotVal := reqCtx.Req.Header.Get(headerName) diff --git a/pkg/services/pluginsintegration/config/config.go b/pkg/services/pluginsintegration/config/config.go deleted file mode 100644 index cabd7d4a3980b..0000000000000 --- a/pkg/services/pluginsintegration/config/config.go +++ /dev/null @@ -1,71 +0,0 @@ -package config - -import ( - "fmt" - "strings" - - pCfg "github.com/grafana/grafana/pkg/plugins/config" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" -) - -func ProvideConfig(settingProvider setting.Provider, grafanaCfg *setting.Cfg, features *featuremgmt.FeatureManager) (*pCfg.Cfg, error) { - plugins := settingProvider.Section("plugins") - allowedUnsigned := grafanaCfg.PluginsAllowUnsigned - if len(plugins.KeyValue("allow_loading_unsigned_plugins").Value()) > 0 { - allowedUnsigned = strings.Split(plugins.KeyValue("allow_loading_unsigned_plugins").Value(), ",") - } - - aws := settingProvider.Section("aws") - allowedAuth := grafanaCfg.AWSAllowedAuthProviders - if len(aws.KeyValue("allowed_auth_providers").Value()) > 0 { - allowedUnsigned = strings.Split(settingProvider.KeyValue("plugins", "allow_loading_unsigned_plugins").Value(), ",") - } - - tracingCfg, err := newTracingCfg(grafanaCfg) - if err != nil { - return nil, fmt.Errorf("new opentelemetry cfg: %w", err) - } - - return pCfg.NewCfg( - settingProvider.KeyValue("", "app_mode").MustBool(grafanaCfg.Env == setting.Dev), - grafanaCfg.PluginsPath, - extractPluginSettings(settingProvider), - allowedUnsigned, - allowedAuth, - aws.KeyValue("assume_role_enabled").MustBool(grafanaCfg.AWSAssumeRoleEnabled), - aws.KeyValue("external_id").Value(), - grafanaCfg.Azure, - grafanaCfg.SecureSocksDSProxy, - grafanaCfg.BuildVersion, - grafanaCfg.PluginLogBackendRequests, - grafanaCfg.PluginsCDNURLTemplate, - grafanaCfg.AppURL, - grafanaCfg.AppSubURL, - tracingCfg, - features, - grafanaCfg.AngularSupportEnabled, - grafanaCfg.GrafanaComURL, - grafanaCfg.DisablePlugins, - grafanaCfg.HideAngularDeprecation, - grafanaCfg.ForwardHostEnvVars, - ), nil -} - -func extractPluginSettings(settingProvider setting.Provider) setting.PluginSettings { - ps := setting.PluginSettings{} - for sectionName, sectionCopy := range settingProvider.Current() { - if !strings.HasPrefix(sectionName, "plugin.") { - continue - } - // Calling Current() returns a redacted version of section. We need to replace the map values with the unredacted values. - section := settingProvider.Section(sectionName) - for k := range sectionCopy { - sectionCopy[k] = section.KeyValue(k).MustString("") - } - pluginID := strings.Replace(sectionName, "plugin.", "", 1) - ps[pluginID] = sectionCopy - } - - return ps -} diff --git a/pkg/services/pluginsintegration/dashboards/filestore.go b/pkg/services/pluginsintegration/dashboards/filestore.go index 988650e2a4252..e854e6e13401c 100644 --- a/pkg/services/pluginsintegration/dashboards/filestore.go +++ b/pkg/services/pluginsintegration/dashboards/filestore.go @@ -24,8 +24,8 @@ func ProvideFileStoreManager(pluginStore pluginstore.Store, pluginFileStore plug } } -var openDashboardFile = func(ctx context.Context, pluginFileStore plugins.FileStore, pluginID, name string) (*plugins.File, error) { - f, err := pluginFileStore.File(ctx, pluginID, name) +var openDashboardFile = func(ctx context.Context, pluginFileStore plugins.FileStore, pluginID, pluginVersion, name string) (*plugins.File, error) { + f, err := pluginFileStore.File(ctx, pluginID, pluginVersion, name) if err != nil { return &plugins.File{}, err } @@ -93,7 +93,7 @@ func (m *FileStoreManager) GetPluginDashboardFileContents(ctx context.Context, a return nil, err } - file, err := openDashboardFile(ctx, m.pluginFileStore, plugin.ID, cleanPath) + file, err := openDashboardFile(ctx, m.pluginFileStore, plugin.ID, plugin.Info.Version, cleanPath) if err != nil { return nil, err } diff --git a/pkg/services/pluginsintegration/dashboards/filestore_test.go b/pkg/services/pluginsintegration/dashboards/filestore_test.go index 9981e768d02f5..8f8a76252d444 100644 --- a/pkg/services/pluginsintegration/dashboards/filestore_test.go +++ b/pkg/services/pluginsintegration/dashboards/filestore_test.go @@ -132,7 +132,7 @@ func TestDashboardFileStore(t *testing.T) { Data: []byte("dash2"), }, } - openDashboardFile = func(ctx context.Context, pluginFiles plugins.FileStore, pluginID, name string) (*plugins.File, error) { + openDashboardFile = func(ctx context.Context, pluginFiles plugins.FileStore, pluginID, _, name string) (*plugins.File, error) { f, err := mapFs.Open(name) require.NoError(t, err) diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index 1f3386851ee27..fe5696672caf0 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -24,9 +24,8 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/sources" - "github.com/grafana/grafana/pkg/plugins/plugindef" + "github.com/grafana/grafana/pkg/plugins/pfs" "github.com/grafana/grafana/pkg/plugins/pluginscdn" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" @@ -60,7 +59,7 @@ func TestLoader_Load(t *testing.T) { tests := []struct { name string class plugins.Class - cfg *config.Cfg + cfg *config.PluginManagementCfg pluginPaths []string want []*plugins.Plugin pluginErrors map[string]*plugins.SignatureError @@ -68,7 +67,7 @@ func TestLoader_Load(t *testing.T) { { name: "Load a Core plugin", class: plugins.ClassCore, - cfg: &config.Cfg{Features: featuremgmt.WithFeatures()}, + cfg: &config.PluginManagementCfg{}, pluginPaths: []string{filepath.Join(corePluginDir(t), "app/plugins/datasource/cloudwatch")}, want: []*plugins.Plugin{ { @@ -83,8 +82,8 @@ func TestLoader_Load(t *testing.T) { }, Description: "Data source for Amazon AWS monitoring service", Logos: plugins.Logos{ - Small: "/public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", - Large: "/public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", + Small: "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", + Large: "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", }, }, Includes: []*plugins.Includes{ @@ -106,9 +105,8 @@ func TestLoader_Load(t *testing.T) { Backend: true, QueryOptions: map[string]bool{"minInterval": true}, }, - Module: "core:plugin/cloudwatch", - BaseURL: "/public/app/plugins/datasource/cloudwatch", - + Module: "core:plugin/cloudwatch", + BaseURL: "public/app/plugins/datasource/cloudwatch", FS: mustNewStaticFSForTests(t, filepath.Join(corePluginDir(t), "app/plugins/datasource/cloudwatch")), Signature: plugins.SignatureStatusInternal, Class: plugins.ClassCore, @@ -118,7 +116,7 @@ func TestLoader_Load(t *testing.T) { { name: "Load a Bundled plugin", class: plugins.ClassBundled, - cfg: &config.Cfg{Features: featuremgmt.WithFeatures()}, + cfg: &config.PluginManagementCfg{}, pluginPaths: []string{filepath.Join(testDataDir(t), "valid-v2-signature")}, want: []*plugins.Plugin{ { @@ -133,8 +131,8 @@ func TestLoader_Load(t *testing.T) { }, Version: "1.0.0", Logos: plugins.Logos{ - Small: "/public/img/icn-datasource.svg", - Large: "/public/img/icn-datasource.svg", + Small: "public/img/icn-datasource.svg", + Large: "public/img/icn-datasource.svg", }, Description: "Test", }, @@ -146,8 +144,8 @@ func TestLoader_Load(t *testing.T) { Backend: true, State: "alpha", }, - Module: "/public/plugins/test-datasource/module.js", - BaseURL: "/public/plugins/test-datasource", + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-signature/plugin/")), Signature: "valid", SignatureType: plugins.SignatureTypeGrafana, @@ -155,10 +153,11 @@ func TestLoader_Load(t *testing.T) { Class: plugins.ClassBundled, }, }, - }, { + }, + { name: "Load plugin with symbolic links", class: plugins.ClassExternal, - cfg: &config.Cfg{Features: featuremgmt.WithFeatures()}, + cfg: &config.PluginManagementCfg{}, pluginPaths: []string{filepath.Join(testDataDir(t), "symbolic-plugin-dirs")}, want: []*plugins.Plugin{ { @@ -172,8 +171,8 @@ func TestLoader_Load(t *testing.T) { URL: "http://test.com", }, Logos: plugins.Logos{ - Small: "/public/plugins/test-app/img/logo_small.png", - Large: "/public/plugins/test-app/img/logo_large.png", + Small: "public/plugins/test-app/img/logo_small.png", + Large: "public/plugins/test-app/img/logo_large.png", }, Links: []plugins.InfoLink{ {Name: "Project site", URL: "http://project.com"}, @@ -181,11 +180,12 @@ func TestLoader_Load(t *testing.T) { }, Description: "Official Grafana Test App & Dashboard bundle", Screenshots: []plugins.Screenshots{ - {Path: "/public/plugins/test-app/img/screenshot1.png", Name: "img1"}, - {Path: "/public/plugins/test-app/img/screenshot2.png", Name: "img2"}, + {Path: "public/plugins/test-app/img/screenshot1.png", Name: "img1"}, + {Path: "public/plugins/test-app/img/screenshot2.png", Name: "img2"}, }, - Version: "1.0.0", - Updated: "2015-02-10", + Version: "1.0.0", + Updated: "2015-02-10", + Keywords: []string{"test"}, }, Dependencies: plugins.Dependencies{ GrafanaVersion: "3.x.x", @@ -213,7 +213,8 @@ func TestLoader_Load(t *testing.T) { Name: "Nginx Panel", Type: string(plugins.TypePanel), Role: org.RoleViewer, - Slug: "nginx-panel"}, + Slug: "nginx-panel", + }, { Name: "Nginx Datasource", Type: string(plugins.TypeDataSource), @@ -223,20 +224,20 @@ func TestLoader_Load(t *testing.T) { }, }, Class: plugins.ClassExternal, - Module: "/public/plugins/test-app/module.js", - BaseURL: "/public/plugins/test-app", + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")), Signature: "valid", SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", }, }, - }, { + }, + { name: "Load an unsigned plugin (development)", class: plugins.ClassExternal, - cfg: &config.Cfg{ - DevMode: true, - Features: featuremgmt.WithFeatures(), + cfg: &config.PluginManagementCfg{ + DevMode: true, }, pluginPaths: []string{filepath.Join(testDataDir(t), "unsigned-datasource")}, want: []*plugins.Plugin{ @@ -251,8 +252,8 @@ func TestLoader_Load(t *testing.T) { URL: "https://grafana.com", }, Logos: plugins.Logos{ - Small: "/public/img/icn-datasource.svg", - Large: "/public/img/icn-datasource.svg", + Small: "public/img/icn-datasource.svg", + Large: "public/img/icn-datasource.svg", }, Description: "Test", }, @@ -264,16 +265,17 @@ func TestLoader_Load(t *testing.T) { State: plugins.ReleaseStateAlpha, }, Class: plugins.ClassExternal, - Module: "/public/plugins/test-datasource/module.js", - BaseURL: "/public/plugins/test-datasource", + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")), Signature: "unsigned", }, }, - }, { + }, + { name: "Load an unsigned plugin (production)", class: plugins.ClassExternal, - cfg: &config.Cfg{Features: featuremgmt.WithFeatures()}, + cfg: &config.PluginManagementCfg{}, pluginPaths: []string{filepath.Join(testDataDir(t), "unsigned-datasource")}, want: []*plugins.Plugin{}, pluginErrors: map[string]*plugins.SignatureError{ @@ -286,9 +288,8 @@ func TestLoader_Load(t *testing.T) { { name: "Load an unsigned plugin using PluginsAllowUnsigned config (production)", class: plugins.ClassExternal, - cfg: &config.Cfg{ + cfg: &config.PluginManagementCfg{ PluginsAllowUnsigned: []string{"test-datasource"}, - Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{filepath.Join(testDataDir(t), "unsigned-datasource")}, want: []*plugins.Plugin{ @@ -303,8 +304,8 @@ func TestLoader_Load(t *testing.T) { URL: "https://grafana.com", }, Logos: plugins.Logos{ - Small: "/public/img/icn-datasource.svg", - Large: "/public/img/icn-datasource.svg", + Small: "public/img/icn-datasource.svg", + Large: "public/img/icn-datasource.svg", }, Description: "Test", }, @@ -316,8 +317,8 @@ func TestLoader_Load(t *testing.T) { State: plugins.ReleaseStateAlpha, }, Class: plugins.ClassExternal, - Module: "/public/plugins/test-datasource/module.js", - BaseURL: "/public/plugins/test-datasource", + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")), Signature: plugins.SignatureStatusUnsigned, }, @@ -326,7 +327,7 @@ func TestLoader_Load(t *testing.T) { { name: "Load a plugin with v1 manifest should return signatureInvalid", class: plugins.ClassExternal, - cfg: &config.Cfg{Features: featuremgmt.WithFeatures()}, + cfg: &config.PluginManagementCfg{}, pluginPaths: []string{filepath.Join(testDataDir(t), "lacking-files")}, want: []*plugins.Plugin{}, pluginErrors: map[string]*plugins.SignatureError{ @@ -339,9 +340,8 @@ func TestLoader_Load(t *testing.T) { { name: "Load a plugin with v1 manifest using PluginsAllowUnsigned config (production) should return signatureInvalid", class: plugins.ClassExternal, - cfg: &config.Cfg{ + cfg: &config.PluginManagementCfg{ PluginsAllowUnsigned: []string{"test-datasource"}, - Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{filepath.Join(testDataDir(t), "lacking-files")}, want: []*plugins.Plugin{}, @@ -355,9 +355,8 @@ func TestLoader_Load(t *testing.T) { { name: "Load a plugin with manifest which has a file not found in plugin folder", class: plugins.ClassExternal, - cfg: &config.Cfg{ + cfg: &config.PluginManagementCfg{ PluginsAllowUnsigned: []string{"test-datasource"}, - Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{filepath.Join(testDataDir(t), "invalid-v2-missing-file")}, want: []*plugins.Plugin{}, @@ -371,9 +370,8 @@ func TestLoader_Load(t *testing.T) { { name: "Load a plugin with file which is missing from the manifest", class: plugins.ClassExternal, - cfg: &config.Cfg{ + cfg: &config.PluginManagementCfg{ PluginsAllowUnsigned: []string{"test-datasource"}, - Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{filepath.Join(testDataDir(t), "invalid-v2-extra-file")}, want: []*plugins.Plugin{}, @@ -387,91 +385,51 @@ func TestLoader_Load(t *testing.T) { { name: "Load an app with includes", class: plugins.ClassExternal, - cfg: &config.Cfg{ + cfg: &config.PluginManagementCfg{ PluginsAllowUnsigned: []string{"test-app"}, - Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{filepath.Join(testDataDir(t), "test-app-with-includes")}, - want: []*plugins.Plugin{ - {JSONData: plugins.JSONData{ - ID: "test-app", - Type: plugins.TypeApp, - Name: "Test App", - Info: plugins.Info{ - Author: plugins.InfoLink{ - Name: "Test Inc.", - URL: "http://test.com", - }, - Description: "Official Grafana Test App & Dashboard bundle", - Version: "1.0.0", - Links: []plugins.InfoLink{ - {Name: "Project site", URL: "http://project.com"}, - {Name: "License & Terms", URL: "http://license.com"}, - }, - Logos: plugins.Logos{ - Small: "/public/img/icn-app.svg", - Large: "/public/img/icn-app.svg", - }, - Updated: "2015-02-10", - }, - Dependencies: plugins.Dependencies{ - GrafanaDependency: ">=8.0.0", - GrafanaVersion: "*", - Plugins: []plugins.Dependency{}, - }, - Includes: []*plugins.Includes{ - {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Slug: "nginx-memory"}, - {Name: "Root Page (react)", Type: "page", Role: org.RoleViewer, Path: "/a/my-simple-app", DefaultNav: true, AddToNav: true, Slug: "root-page-react"}, - }, - Backend: false, - }, - DefaultNavURL: "/plugins/test-app/page/root-page-react", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-includes")), - Class: plugins.ClassExternal, - Signature: plugins.SignatureStatusUnsigned, - Module: "/public/plugins/test-app/module.js", - BaseURL: "/public/plugins/test-app", - }, - }, - }, - { - name: "Load a plugin with app sub url set", - class: plugins.ClassExternal, - cfg: &config.Cfg{ - DevMode: true, - GrafanaAppSubURL: "grafana", - Features: featuremgmt.WithFeatures(), - }, - pluginPaths: []string{filepath.Join(testDataDir(t), "unsigned-datasource")}, want: []*plugins.Plugin{ { JSONData: plugins.JSONData{ - ID: "test-datasource", - Type: plugins.TypeDataSource, - Name: "Test", + ID: "test-app", + Type: plugins.TypeApp, + Name: "Test App", Info: plugins.Info{ Author: plugins.InfoLink{ - Name: "Grafana Labs", - URL: "https://grafana.com", + Name: "Test Inc.", + URL: "http://test.com", + }, + Description: "Official Grafana Test App & Dashboard bundle", + Version: "1.0.0", + Links: []plugins.InfoLink{ + {Name: "Project site", URL: "http://project.com"}, + {Name: "License & Terms", URL: "http://license.com"}, }, Logos: plugins.Logos{ - Small: "/grafana/public/img/icn-datasource.svg", - Large: "/grafana/public/img/icn-datasource.svg", + Small: "public/img/icn-app.svg", + Large: "public/img/icn-app.svg", }, - Description: "Test", + Updated: "2015-02-10", + Keywords: []string{"test"}, }, Dependencies: plugins.Dependencies{ - GrafanaVersion: "*", - Plugins: []plugins.Dependency{}, + GrafanaDependency: ">=8.0.0", + GrafanaVersion: "*", + Plugins: []plugins.Dependency{}, }, - Backend: true, - State: plugins.ReleaseStateAlpha, + Includes: []*plugins.Includes{ + {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Slug: "nginx-memory"}, + {Name: "Root Page (react)", Type: "page", Role: org.RoleViewer, Path: "/a/my-simple-app", DefaultNav: true, AddToNav: true, Slug: "root-page-react"}, + }, + Backend: false, }, - Class: plugins.ClassExternal, - Module: "/grafana/public/plugins/test-datasource/module.js", - BaseURL: "/grafana/public/plugins/test-datasource", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")), - Signature: plugins.SignatureStatusUnsigned, + DefaultNavURL: "/plugins/test-app/page/root-page-react", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-includes")), + Class: plugins.ClassExternal, + Signature: plugins.SignatureStatusUnsigned, + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", }, }, }, @@ -502,154 +460,52 @@ func TestLoader_Load(t *testing.T) { } func TestLoader_Load_ExternalRegistration(t *testing.T) { - boolPtr := func(b bool) *bool { return &b } stringPtr := func(s string) *string { return &s } - t.Run("Load a plugin with oauth client registration", func(t *testing.T) { - cfg := &config.Cfg{ - Features: fakes.NewFakeFeatureToggles(featuremgmt.FlagExternalServiceAuth), + t.Run("Load a plugin with service account registration", func(t *testing.T) { + cfg := &config.PluginManagementCfg{ PluginsAllowUnsigned: []string{"grafana-test-datasource"}, } - pluginPaths := []string{filepath.Join(testDataDir(t), "oauth-external-registration")} + pluginPaths := []string{filepath.Join(testDataDir(t), "external-registration")} expected := []*plugins.Plugin{ - {JSONData: plugins.JSONData{ - ID: "grafana-test-datasource", - Type: plugins.TypeDataSource, - Name: "Test", - Backend: true, - Executable: "gpx_test_datasource", - Info: plugins.Info{ - Author: plugins.InfoLink{ - Name: "Grafana Labs", - URL: "https://grafana.com", + { + JSONData: plugins.JSONData{ + ID: "grafana-test-datasource", + Type: plugins.TypeDataSource, + Name: "Test", + Backend: true, + Executable: "gpx_test_datasource", + Info: plugins.Info{ + Author: plugins.InfoLink{ + Name: "Grafana Labs", + URL: "https://grafana.com", + }, + Version: "1.0.0", + Logos: plugins.Logos{ + Small: "public/plugins/grafana-test-datasource/img/ds.svg", + Large: "public/plugins/grafana-test-datasource/img/ds.svg", + }, + Updated: "2023-08-03", + Screenshots: []plugins.Screenshots{}, }, - Version: "1.0.0", - Logos: plugins.Logos{ - Small: "/public/plugins/grafana-test-datasource/img/ds.svg", - Large: "/public/plugins/grafana-test-datasource/img/ds.svg", + Dependencies: plugins.Dependencies{ + GrafanaVersion: "*", + Plugins: []plugins.Dependency{}, }, - Updated: "2023-08-03", - Screenshots: []plugins.Screenshots{}, - }, - Dependencies: plugins.Dependencies{ - GrafanaVersion: "*", - Plugins: []plugins.Dependency{}, - }, - IAM: &plugindef.IAM{ - Impersonation: &plugindef.Impersonation{ - Groups: boolPtr(true), - Permissions: []plugindef.Permission{ + IAM: &pfs.IAM{ + Permissions: []pfs.Permission{ { Action: "read", Scope: stringPtr("datasource"), }, }, }, - Permissions: []plugindef.Permission{ - { - Action: "read", - Scope: stringPtr("datasource"), - }, - }, - }, - }, - FS: mustNewStaticFSForTests(t, pluginPaths[0]), - Class: plugins.ClassExternal, - Signature: plugins.SignatureStatusUnsigned, - Module: "/public/plugins/grafana-test-datasource/module.js", - BaseURL: "/public/plugins/grafana-test-datasource", - ExternalService: &auth.ExternalService{ - ClientID: "client-id", - ClientSecret: "secretz", - PrivateKey: "priv@t3", - }, - }, - } - - backendFactoryProvider := fakes.NewFakeBackendProcessProvider() - backendFactoryProvider.BackendFactoryFunc = func(ctx context.Context, plugin *plugins.Plugin) backendplugin.PluginFactoryFunc { - return func(pluginID string, logger log.Logger, env func() []string) (backendplugin.Plugin, error) { - require.Equal(t, "grafana-test-datasource", pluginID) - require.Equal(t, []string{"GF_VERSION=", "GF_EDITION=", "GF_ENTERPRISE_LICENSE_PATH=", - "GF_ENTERPRISE_APP_URL=", "GF_ENTERPRISE_LICENSE_TEXT=", "GF_APP_URL=", - "GF_PLUGIN_APP_CLIENT_ID=client-id", "GF_PLUGIN_APP_CLIENT_SECRET=secretz", - "GF_PLUGIN_APP_PRIVATE_KEY=priv@t3", "GF_INSTANCE_FEATURE_TOGGLES_ENABLE=externalServiceAuth"}, env()) - return &fakes.FakeBackendPlugin{}, nil - } - } - - l := newLoaderWithOpts(t, cfg, loaderDepOpts{ - authServiceRegistry: &fakes.FakeAuthService{ - Result: &auth.ExternalService{ - ClientID: "client-id", - ClientSecret: "secretz", - PrivateKey: "priv@t3", }, - }, - backendFactoryProvider: backendFactoryProvider, - }) - got, err := l.Load(context.Background(), &fakes.FakePluginSource{ - PluginClassFunc: func(ctx context.Context) plugins.Class { - return plugins.ClassExternal - }, - PluginURIsFunc: func(ctx context.Context) []string { - return pluginPaths - }, - DefaultSignatureFunc: func(ctx context.Context) (plugins.Signature, bool) { - return plugins.Signature{}, false - }, - }) - - require.NoError(t, err) - if !cmp.Equal(got, expected, compareOpts...) { - t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...)) - } - }) - - t.Run("Load a plugin with service account registration", func(t *testing.T) { - cfg := &config.Cfg{ - Features: fakes.NewFakeFeatureToggles(featuremgmt.FlagExternalServiceAuth), - PluginsAllowUnsigned: []string{"grafana-test-datasource"}, - } - pluginPaths := []string{filepath.Join(testDataDir(t), "external-registration")} - expected := []*plugins.Plugin{ - {JSONData: plugins.JSONData{ - ID: "grafana-test-datasource", - Type: plugins.TypeDataSource, - Name: "Test", - Backend: true, - Executable: "gpx_test_datasource", - Info: plugins.Info{ - Author: plugins.InfoLink{ - Name: "Grafana Labs", - URL: "https://grafana.com", - }, - Version: "1.0.0", - Logos: plugins.Logos{ - Small: "/public/plugins/grafana-test-datasource/img/ds.svg", - Large: "/public/plugins/grafana-test-datasource/img/ds.svg", - }, - Updated: "2023-08-03", - Screenshots: []plugins.Screenshots{}, - }, - Dependencies: plugins.Dependencies{ - GrafanaVersion: "*", - Plugins: []plugins.Dependency{}, - }, - IAM: &plugindef.IAM{ - Permissions: []plugindef.Permission{ - { - Action: "read", - Scope: stringPtr("datasource"), - }, - }, - }, - }, FS: mustNewStaticFSForTests(t, pluginPaths[0]), Class: plugins.ClassExternal, Signature: plugins.SignatureStatusUnsigned, - Module: "/public/plugins/grafana-test-datasource/module.js", - BaseURL: "/public/plugins/grafana-test-datasource", + Module: "public/plugins/grafana-test-datasource/module.js", + BaseURL: "public/plugins/grafana-test-datasource", ExternalService: &auth.ExternalService{ ClientID: "client-id", ClientSecret: "secretz", @@ -661,10 +517,6 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) { backendFactoryProvider.BackendFactoryFunc = func(ctx context.Context, plugin *plugins.Plugin) backendplugin.PluginFactoryFunc { return func(pluginID string, logger log.Logger, env func() []string) (backendplugin.Plugin, error) { require.Equal(t, "grafana-test-datasource", pluginID) - require.Equal(t, []string{"GF_VERSION=", "GF_EDITION=", "GF_ENTERPRISE_LICENSE_PATH=", - "GF_ENTERPRISE_APP_URL=", "GF_ENTERPRISE_LICENSE_TEXT=", "GF_APP_URL=", - "GF_PLUGIN_APP_CLIENT_ID=client-id", "GF_PLUGIN_APP_CLIENT_SECRET=secretz", - "GF_INSTANCE_FEATURE_TOGGLES_ENABLE=externalServiceAuth"}, env()) return &fakes.FakeBackendPlugin{}, nil } } @@ -699,12 +551,11 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) { func TestLoader_Load_CustomSource(t *testing.T) { t.Run("Load a plugin", func(t *testing.T) { - cfg := &config.Cfg{ + cfg := &config.PluginManagementCfg{ PluginsCDNURLTemplate: "https://cdn.example.com", PluginSettings: setting.PluginSettings{ "grafana-worldmap-panel": {"cdn": "true"}, }, - Features: featuremgmt.WithFeatures(), } pluginPaths := []string{filepath.Join(testDataDir(t), "cdn")} @@ -777,7 +628,7 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) { t.Run("Load multiple", func(t *testing.T) { tests := []struct { name string - cfg *config.Cfg + cfg *config.PluginManagementCfg pluginPaths []string existingPlugins map[string]struct{} want []*plugins.Plugin @@ -785,9 +636,8 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) { }{ { name: "Load multiple plugins (broken, valid, unsigned)", - cfg: &config.Cfg{ + cfg: &config.PluginManagementCfg{ GrafanaAppURL: "http://localhost:3000", - Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{ filepath.Join(testDataDir(t), "invalid-plugin-json"), // test-app @@ -806,8 +656,8 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) { URL: "https://willbrowne.com", }, Logos: plugins.Logos{ - Small: "/public/img/icn-datasource.svg", - Large: "/public/img/icn-datasource.svg", + Small: "public/img/icn-datasource.svg", + Large: "public/img/icn-datasource.svg", }, Description: "Test", Version: "1.0.0", @@ -821,8 +671,8 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) { State: plugins.ReleaseStateAlpha, }, Class: plugins.ClassExternal, - Module: "/public/plugins/test-datasource/module.js", - BaseURL: "/public/plugins/test-datasource", + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature/plugin")), Signature: "valid", SignatureType: plugins.SignatureTypePrivate, @@ -875,16 +725,15 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) { func TestLoader_Load_RBACReady(t *testing.T) { tests := []struct { name string - cfg *config.Cfg + cfg *config.PluginManagementCfg pluginPaths []string existingPlugins map[string]struct{} want []*plugins.Plugin }{ { name: "Load plugin defining one RBAC role", - cfg: &config.Cfg{ + cfg: &config.PluginManagementCfg{ GrafanaAppURL: "http://localhost:3000", - Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{filepath.Join(testDataDir(t), "test-app-with-roles")}, want: []*plugins.Plugin{ @@ -902,10 +751,11 @@ func TestLoader_Load_RBACReady(t *testing.T) { Version: "1.0.0", Links: []plugins.InfoLink{}, Logos: plugins.Logos{ - Small: "/public/img/icn-app.svg", - Large: "/public/img/icn-app.svg", + Small: "public/img/icn-app.svg", + Large: "public/img/icn-app.svg", }, - Updated: "2015-02-10", + Updated: "2015-02-10", + Keywords: []string{"test"}, }, Dependencies: plugins.Dependencies{ GrafanaVersion: "*", @@ -934,8 +784,8 @@ func TestLoader_Load_RBACReady(t *testing.T) { Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypePrivate, SignatureOrg: "gabrielmabille", - Module: "/public/plugins/test-app/module.js", - BaseURL: "/public/plugins/test-app", + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", }, }, }, @@ -979,8 +829,8 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) { Author: plugins.InfoLink{Name: "Will Browne", URL: "https://willbrowne.com"}, Description: "Test", Logos: plugins.Logos{ - Small: "/public/img/icn-datasource.svg", - Large: "/public/img/icn-datasource.svg", + Small: "public/img/icn-datasource.svg", + Large: "public/img/icn-datasource.svg", }, Version: "1.0.0", }, @@ -994,15 +844,15 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) { Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypePrivate, SignatureOrg: "Will Browne", - Module: "/public/plugins/test-datasource/module.js", - BaseURL: "/public/plugins/test-datasource", + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", }, } reg := fakes.NewFakePluginRegistry() procPrvdr := fakes.NewFakeBackendProcessProvider() procMgr := fakes.NewFakeProcessManager() - cfg := &config.Cfg{GrafanaAppURL: defaultAppURL, Features: featuremgmt.WithFeatures()} + cfg := &config.PluginManagementCfg{GrafanaAppURL: defaultAppURL} l := newLoader(t, cfg, reg, procMgr, procPrvdr, newFakeSignatureErrorTracker()) got, err := l.Load(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { @@ -1041,14 +891,15 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) { {Name: "License & Terms", URL: "http://license.com"}, }, Logos: plugins.Logos{ - Small: "/public/plugins/test-app/img/logo_small.png", - Large: "/public/plugins/test-app/img/logo_large.png", + Small: "public/plugins/test-app/img/logo_small.png", + Large: "public/plugins/test-app/img/logo_large.png", }, Screenshots: []plugins.Screenshots{ - {Path: "/public/plugins/test-app/img/screenshot1.png", Name: "img1"}, - {Path: "/public/plugins/test-app/img/screenshot2.png", Name: "img2"}, + {Path: "public/plugins/test-app/img/screenshot1.png", Name: "img1"}, + {Path: "public/plugins/test-app/img/screenshot2.png", Name: "img2"}, }, - Updated: "2015-02-10", + Updated: "2015-02-10", + Keywords: []string{"test"}, }, Dependencies: plugins.Dependencies{ GrafanaVersion: "3.x.x", @@ -1070,15 +921,15 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) { Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", - Module: "/public/plugins/test-app/module.js", - BaseURL: "/public/plugins/test-app", + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", }, } reg := fakes.NewFakePluginRegistry() procPrvdr := fakes.NewFakeBackendProcessProvider() procMgr := fakes.NewFakeProcessManager() - cfg := &config.Cfg{Features: featuremgmt.WithFeatures()} + cfg := &config.PluginManagementCfg{} l := newLoader(t, cfg, reg, procMgr, procPrvdr, newFakeSignatureErrorTracker()) got, err := l.Load(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { @@ -1121,14 +972,15 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) { {Name: "License & Terms", URL: "http://license.com"}, }, Logos: plugins.Logos{ - Small: "/public/plugins/test-app/img/logo_small.png", - Large: "/public/plugins/test-app/img/logo_large.png", + Small: "public/plugins/test-app/img/logo_small.png", + Large: "public/plugins/test-app/img/logo_large.png", }, Screenshots: []plugins.Screenshots{ - {Path: "/public/plugins/test-app/img/screenshot1.png", Name: "img1"}, - {Path: "/public/plugins/test-app/img/screenshot2.png", Name: "img2"}, + {Path: "public/plugins/test-app/img/screenshot1.png", Name: "img1"}, + {Path: "public/plugins/test-app/img/screenshot2.png", Name: "img2"}, }, - Updated: "2015-02-10", + Updated: "2015-02-10", + Keywords: []string{"test"}, }, Dependencies: plugins.Dependencies{ GrafanaVersion: "3.x.x", @@ -1150,8 +1002,8 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) { Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", - Module: "/public/plugins/test-app/module.js", - BaseURL: "/public/plugins/test-app", + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", }, } @@ -1167,7 +1019,7 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) { } } procMgr := fakes.NewFakeProcessManager() - cfg := &config.Cfg{Features: featuremgmt.WithFeatures()} + cfg := &config.PluginManagementCfg{} l := newLoader(t, cfg, reg, procMgr, procPrvdr, newFakeSignatureErrorTracker()) got, err := l.Load(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { @@ -1224,7 +1076,7 @@ func TestLoader_AngularClass(t *testing.T) { }, } // if angularDetected = true, it means that the detection has run - l := newLoaderWithOpts(t, &config.Cfg{AngularSupportEnabled: true, Features: featuremgmt.WithFeatures()}, loaderDepOpts{ + l := newLoaderWithOpts(t, &config.PluginManagementCfg{AngularSupportEnabled: true}, loaderDepOpts{ angularInspector: angularinspector.AlwaysAngularFakeInspector, }) p, err := l.Load(context.Background(), fakePluginSource) @@ -1250,10 +1102,10 @@ func TestLoader_Load_Angular(t *testing.T) { } for _, cfgTc := range []struct { name string - cfg *config.Cfg + cfg *config.PluginManagementCfg }{ - {name: "angular support enabled", cfg: &config.Cfg{AngularSupportEnabled: true, Features: featuremgmt.WithFeatures()}}, - {name: "angular support disabled", cfg: &config.Cfg{AngularSupportEnabled: false, Features: featuremgmt.WithFeatures()}}, + {name: "angular support enabled", cfg: &config.PluginManagementCfg{AngularSupportEnabled: true}}, + {name: "angular support disabled", cfg: &config.PluginManagementCfg{AngularSupportEnabled: false}}, } { t.Run(cfgTc.name, func(t *testing.T) { for _, tc := range []struct { @@ -1300,23 +1152,20 @@ func TestLoader_HideAngularDeprecation(t *testing.T) { } for _, tc := range []struct { name string - cfg *config.Cfg + cfg *config.PluginManagementCfg expHideAngularDeprecation bool }{ - {name: "with plugin id in HideAngularDeprecation list", cfg: &config.Cfg{ + {name: "with plugin id in HideAngularDeprecation list", cfg: &config.PluginManagementCfg{ AngularSupportEnabled: true, HideAngularDeprecation: []string{"one-app", "two-panel", "test-datasource", "three-datasource"}, - Features: featuremgmt.WithFeatures(), }, expHideAngularDeprecation: true}, - {name: "without plugin id in HideAngularDeprecation list", cfg: &config.Cfg{ + {name: "without plugin id in HideAngularDeprecation list", cfg: &config.PluginManagementCfg{ AngularSupportEnabled: true, HideAngularDeprecation: []string{"one-app", "two-panel", "three-datasource"}, - Features: featuremgmt.WithFeatures(), }, expHideAngularDeprecation: false}, - {name: "with empty HideAngularDeprecation", cfg: &config.Cfg{ + {name: "with empty HideAngularDeprecation", cfg: &config.PluginManagementCfg{ AngularSupportEnabled: true, HideAngularDeprecation: nil, - Features: featuremgmt.WithFeatures(), }, expHideAngularDeprecation: false}, } { t.Run(tc.name, func(t *testing.T) { @@ -1343,8 +1192,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { URL: "http://grafana.com", }, Logos: plugins.Logos{ - Small: "/public/img/icn-datasource.svg", - Large: "/public/img/icn-datasource.svg", + Small: "public/img/icn-datasource.svg", + Large: "public/img/icn-datasource.svg", }, Description: "Parent plugin", Version: "1.0.0", @@ -1356,8 +1205,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, Backend: true, }, - Module: "/public/plugins/test-datasource/module.js", - BaseURL: "/public/plugins/test-datasource", + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")), Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, @@ -1376,8 +1225,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { URL: "http://grafana.com", }, Logos: plugins.Logos{ - Small: "/public/img/icn-panel.svg", - Large: "/public/img/icn-panel.svg", + Small: "public/img/icn-panel.svg", + Large: "public/img/icn-panel.svg", }, Description: "Child plugin", Version: "1.0.1", @@ -1388,8 +1237,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { Plugins: []plugins.Dependency{}, }, }, - Module: "/public/plugins/test-panel/module.js", - BaseURL: "/public/plugins/test-panel", + Module: "public/plugins/test-panel/module.js", + BaseURL: "public/plugins/test-panel", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")), Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, @@ -1404,7 +1253,7 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { procPrvdr := fakes.NewFakeBackendProcessProvider() procMgr := fakes.NewFakeProcessManager() reg := fakes.NewFakePluginRegistry() - cfg := &config.Cfg{Features: featuremgmt.WithFeatures()} + cfg := &config.PluginManagementCfg{} l := newLoader(t, cfg, reg, procMgr, procPrvdr, newFakeSignatureErrorTracker()) got, err := l.Load(context.Background(), &fakes.FakePluginSource{ @@ -1468,13 +1317,14 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { {Name: "License", URL: "https://github.com/grafana/grafana-starter-app/blob/master/LICENSE"}, }, Logos: plugins.Logos{ - Small: "/public/plugins/myorgid-simple-app/img/logo.svg", - Large: "/public/plugins/myorgid-simple-app/img/logo.svg", + Small: "public/plugins/myorgid-simple-app/img/logo.svg", + Large: "public/plugins/myorgid-simple-app/img/logo.svg", }, Screenshots: []plugins.Screenshots{}, Description: "Grafana App Plugin Template", Version: "", Updated: "", + Keywords: []string{"panel", "template"}, }, Dependencies: plugins.Dependencies{ GrafanaVersion: "7.0.0", @@ -1524,8 +1374,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, Backend: false, }, - Module: "/public/plugins/myorgid-simple-app/module.js", - BaseURL: "/public/plugins/myorgid-simple-app", + Module: "public/plugins/myorgid-simple-app/module.js", + BaseURL: "public/plugins/myorgid-simple-app", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist")), DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react", Signature: plugins.SignatureStatusValid, @@ -1548,13 +1398,14 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { {Name: "License", URL: "https://github.com/grafana/grafana-starter-panel/blob/master/LICENSE"}, }, Logos: plugins.Logos{ - Small: "/public/plugins/myorgid-simple-panel/img/logo.svg", - Large: "/public/plugins/myorgid-simple-panel/img/logo.svg", + Small: "public/plugins/myorgid-simple-panel/img/logo.svg", + Large: "public/plugins/myorgid-simple-panel/img/logo.svg", }, Screenshots: []plugins.Screenshots{}, Description: "Grafana Panel Plugin Template", Version: "", Updated: "", + Keywords: []string{"panel", "template"}, }, Dependencies: plugins.Dependencies{ GrafanaDependency: ">=7.0.0", @@ -1562,8 +1413,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { Plugins: []plugins.Dependency{}, }, }, - Module: "/public/plugins/myorgid-simple-app/child/module.js", - BaseURL: "/public/plugins/myorgid-simple-app", + Module: "public/plugins/myorgid-simple-app/child/module.js", + BaseURL: "public/plugins/myorgid-simple-app", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist/child")), IncludedInAppID: parent.ID, Signature: plugins.SignatureStatusValid, @@ -1579,7 +1430,7 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { reg := fakes.NewFakePluginRegistry() procPrvdr := fakes.NewFakeBackendProcessProvider() procMgr := fakes.NewFakeProcessManager() - cfg := &config.Cfg{Features: featuremgmt.WithFeatures()} + cfg := &config.PluginManagementCfg{} l := newLoader(t, cfg, reg, procMgr, procPrvdr, newFakeSignatureErrorTracker()) got, err := l.Load(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { @@ -1610,26 +1461,25 @@ type loaderDepOpts struct { backendFactoryProvider plugins.BackendFactoryProvider } -func newLoader(t *testing.T, cfg *config.Cfg, reg registry.Service, proc process.Manager, - backendFactory plugins.BackendFactoryProvider, sigErrTracker pluginerrs.SignatureErrorTracker) *Loader { +func newLoader(t *testing.T, cfg *config.PluginManagementCfg, reg registry.Service, proc process.Manager, + backendFactory plugins.BackendFactoryProvider, sigErrTracker pluginerrs.SignatureErrorTracker, +) *Loader { assets := assetpath.ProvideService(cfg, pluginscdn.ProvideService(cfg)) - lic := fakes.NewFakeLicensingService() angularInspector := angularinspector.NewStaticInspector() terminate, err := pipeline.ProvideTerminationStage(cfg, reg, proc) require.NoError(t, err) return ProvideService(pipeline.ProvideDiscoveryStage(cfg, - finder.NewLocalFinder(false, featuremgmt.WithFeatures()), reg), + finder.NewLocalFinder(false), reg), pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), assets), pipeline.ProvideValidationStage(cfg, signature.NewValidator(signature.NewUnsignedAuthorizer(cfg)), angularInspector, sigErrTracker), - pipeline.ProvideInitializationStage(cfg, reg, lic, backendFactory, proc, &fakes.FakeAuthService{}, fakes.NewFakeRoleRegistry()), + pipeline.ProvideInitializationStage(cfg, reg, backendFactory, proc, &fakes.FakeAuthService{}, fakes.NewFakeRoleRegistry(), fakes.NewFakePluginEnvProvider()), terminate) } -func newLoaderWithOpts(t *testing.T, cfg *config.Cfg, opts loaderDepOpts) *Loader { +func newLoaderWithOpts(t *testing.T, cfg *config.PluginManagementCfg, opts loaderDepOpts) *Loader { assets := assetpath.ProvideService(cfg, pluginscdn.ProvideService(cfg)) - lic := fakes.NewFakeLicensingService() reg := fakes.NewFakePluginRegistry() proc := fakes.NewFakeProcessManager() @@ -1653,19 +1503,20 @@ func newLoaderWithOpts(t *testing.T, cfg *config.Cfg, opts loaderDepOpts) *Loade } return ProvideService(pipeline.ProvideDiscoveryStage(cfg, - finder.NewLocalFinder(false, featuremgmt.WithFeatures()), reg), + finder.NewLocalFinder(false), reg), pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), assets), pipeline.ProvideValidationStage(cfg, signature.NewValidator(signature.NewUnsignedAuthorizer(cfg)), angularInspector, sigErrTracker), - pipeline.ProvideInitializationStage(cfg, reg, lic, backendFactoryProvider, proc, authServiceRegistry, fakes.NewFakeRoleRegistry()), + pipeline.ProvideInitializationStage(cfg, reg, backendFactoryProvider, proc, authServiceRegistry, fakes.NewFakeRoleRegistry(), fakes.NewFakePluginEnvProvider()), terminate) } func verifyState(t *testing.T, ps []*plugins.Plugin, reg registry.Service, - procPrvdr *fakes.FakeBackendProcessProvider, procMngr *fakes.FakeProcessManager) { + procPrvdr *fakes.FakeBackendProcessProvider, procMngr *fakes.FakeProcessManager, +) { t.Helper() for _, p := range ps { - regP, exists := reg.Plugin(context.Background(), p.ID) + regP, exists := reg.Plugin(context.Background(), p.ID, p.Info.Version) require.True(t, exists) if !cmp.Equal(p, regP, compareOpts...) { t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(p, regP, compareOpts...)) diff --git a/pkg/services/pluginsintegration/pipeline/pipeline.go b/pkg/services/pluginsintegration/pipeline/pipeline.go index 1c2c9598e613a..b2b59b79b4e0c 100644 --- a/pkg/services/pluginsintegration/pipeline/pipeline.go +++ b/pkg/services/pluginsintegration/pipeline/pipeline.go @@ -21,7 +21,7 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" ) -func ProvideDiscoveryStage(cfg *config.Cfg, pf finder.Finder, pr registry.Service) *discovery.Discovery { +func ProvideDiscoveryStage(cfg *config.PluginManagementCfg, pf finder.Finder, pr registry.Service) *discovery.Discovery { return discovery.New(cfg, discovery.Opts{ FindFunc: pf.Find, FindFilterFuncs: []discovery.FindFilterFunc{ @@ -29,7 +29,7 @@ func ProvideDiscoveryStage(cfg *config.Cfg, pf finder.Finder, pr registry.Servic plugins.TypeDataSource, plugins.TypeApp, plugins.TypePanel, plugins.TypeSecretsManager, }), func(ctx context.Context, _ plugins.Class, b []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) { - return discovery.NewDuplicatePluginFilterStep(pr).Filter(ctx, b) + return NewDuplicatePluginIDFilterStep(pr).Filter(ctx, b) }, func(_ context.Context, _ plugins.Class, b []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) { return NewDisablePluginsStep(cfg).Filter(b) @@ -41,14 +41,14 @@ func ProvideDiscoveryStage(cfg *config.Cfg, pf finder.Finder, pr registry.Servic }) } -func ProvideBootstrapStage(cfg *config.Cfg, sc plugins.SignatureCalculator, a *assetpath.Service) *bootstrap.Bootstrap { +func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.SignatureCalculator, a *assetpath.Service) *bootstrap.Bootstrap { return bootstrap.New(cfg, bootstrap.Opts{ ConstructFunc: bootstrap.DefaultConstructFunc(sc, a), DecorateFuncs: bootstrap.DefaultDecorateFuncs(cfg), }) } -func ProvideValidationStage(cfg *config.Cfg, sv signature.Validator, ai angularinspector.Inspector, +func ProvideValidationStage(cfg *config.PluginManagementCfg, sv signature.Validator, ai angularinspector.Inspector, et pluginerrs.SignatureErrorTracker) *validation.Validate { return validation.New(cfg, validation.Opts{ ValidateFuncs: []validation.ValidateFunc{ @@ -59,13 +59,13 @@ func ProvideValidationStage(cfg *config.Cfg, sv signature.Validator, ai angulari }) } -func ProvideInitializationStage(cfg *config.Cfg, pr registry.Service, l plugins.Licensing, - bp plugins.BackendFactoryProvider, pm process.Manager, externalServiceRegistry auth.ExternalServiceRegistry, - roleRegistry plugins.RoleRegistry) *initialization.Initialize { +func ProvideInitializationStage(cfg *config.PluginManagementCfg, pr registry.Service, bp plugins.BackendFactoryProvider, + pm process.Manager, externalServiceRegistry auth.ExternalServiceRegistry, + roleRegistry plugins.RoleRegistry, pluginEnvProvider envvars.Provider) *initialization.Initialize { return initialization.New(cfg, initialization.Opts{ InitializeFuncs: []initialization.InitializeFunc{ ExternalServiceRegistrationStep(cfg, externalServiceRegistry), - initialization.BackendClientInitStep(envvars.NewProvider(cfg, l), bp), + initialization.BackendClientInitStep(pluginEnvProvider, bp), initialization.PluginRegistrationStep(pr), initialization.BackendProcessStartStep(pm), RegisterPluginRolesStep(roleRegistry), @@ -74,7 +74,7 @@ func ProvideInitializationStage(cfg *config.Cfg, pr registry.Service, l plugins. }) } -func ProvideTerminationStage(cfg *config.Cfg, pr registry.Service, pm process.Manager) (*termination.Terminate, error) { +func ProvideTerminationStage(cfg *config.PluginManagementCfg, pr registry.Service, pm process.Manager) (*termination.Terminate, error) { return termination.New(cfg, termination.Opts{ TerminateFuncs: []termination.TerminateFunc{ termination.BackendProcessTerminatorStep(pm), diff --git a/pkg/services/pluginsintegration/pipeline/steps.go b/pkg/services/pluginsintegration/pipeline/steps.go index 3b414b502f998..d39babd1e5f6a 100644 --- a/pkg/services/pluginsintegration/pipeline/steps.go +++ b/pkg/services/pluginsintegration/pipeline/steps.go @@ -3,6 +3,7 @@ package pipeline import ( "context" "errors" + "slices" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/plugins" @@ -11,25 +12,25 @@ import ( "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/initialization" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/validation" + "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" - "github.com/grafana/grafana/pkg/plugins/plugindef" - "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/plugins/pfs" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" ) // ExternalServiceRegistration implements an InitializeFunc for registering external services. type ExternalServiceRegistration struct { - cfg *config.Cfg + cfg *config.PluginManagementCfg externalServiceRegistry auth.ExternalServiceRegistry log log.Logger } // ExternalServiceRegistrationStep returns an InitializeFunc for registering external services. -func ExternalServiceRegistrationStep(cfg *config.Cfg, externalServiceRegistry auth.ExternalServiceRegistry) initialization.InitializeFunc { +func ExternalServiceRegistrationStep(cfg *config.PluginManagementCfg, externalServiceRegistry auth.ExternalServiceRegistry) initialization.InitializeFunc { return newExternalServiceRegistration(cfg, externalServiceRegistry).Register } -func newExternalServiceRegistration(cfg *config.Cfg, serviceRegistry auth.ExternalServiceRegistry) *ExternalServiceRegistration { +func newExternalServiceRegistration(cfg *config.PluginManagementCfg, serviceRegistry auth.ExternalServiceRegistry) *ExternalServiceRegistration { return &ExternalServiceRegistration{ cfg: cfg, externalServiceRegistry: serviceRegistry, @@ -40,7 +41,7 @@ func newExternalServiceRegistration(cfg *config.Cfg, serviceRegistry auth.Extern // Register registers the external service with the external service registry, if the feature is enabled. func (r *ExternalServiceRegistration) Register(ctx context.Context, p *plugins.Plugin) (*plugins.Plugin, error) { if p.IAM != nil { - s, err := r.externalServiceRegistry.RegisterExternalService(ctx, p.ID, plugindef.Type(p.Type), p.IAM) + s, err := r.externalServiceRegistry.RegisterExternalService(ctx, p.ID, pfs.Type(p.Type), p.IAM) if err != nil { r.log.Error("Could not register an external service. Initialization skipped", "pluginId", p.ID, "error", err) return nil, err @@ -126,11 +127,11 @@ func (v *SignatureValidation) Validate(ctx context.Context, p *plugins.Plugin) e // DisablePlugins is a filter step that will filter out any configured plugins type DisablePlugins struct { log log.Logger - cfg *config.Cfg + cfg *config.PluginManagementCfg } // NewDisablePluginsStep returns a new DisablePlugins. -func NewDisablePluginsStep(cfg *config.Cfg) *DisablePlugins { +func NewDisablePluginsStep(cfg *config.PluginManagementCfg) *DisablePlugins { return &DisablePlugins{ cfg: cfg, log: log.New("plugins.disable"), @@ -162,11 +163,11 @@ func (c *DisablePlugins) Filter(bundles []*plugins.FoundBundle) ([]*plugins.Foun // AsExternal is a filter step that will skip loading a core plugin to use an external one. type AsExternal struct { log log.Logger - cfg *config.Cfg + cfg *config.PluginManagementCfg } -// NewDisablePluginsStep returns a new DisablePlugins. -func NewAsExternalStep(cfg *config.Cfg) *AsExternal { +// NewAsExternalStep returns a new DisablePlugins. +func NewAsExternalStep(cfg *config.PluginManagementCfg) *AsExternal { return &AsExternal{ cfg: cfg, log: log.New("plugins.asExternal"), @@ -175,7 +176,7 @@ func NewAsExternalStep(cfg *config.Cfg) *AsExternal { // Filter will filter out any plugins that are marked to be disabled. func (c *AsExternal) Filter(cl plugins.Class, bundles []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) { - if c.cfg.Features == nil || !c.cfg.Features.IsEnabledGlobally(featuremgmt.FlagExternalCorePlugins) { + if !c.cfg.Features.ExternalCorePluginsEnabled { return bundles, nil } @@ -193,26 +194,56 @@ func (c *AsExternal) Filter(cl plugins.Class, bundles []*plugins.FoundBundle) ([ } return res, nil } + return bundles, nil +} - if cl == plugins.ClassExternal { - // Warn if the plugin is not found in the external plugins directory. - asExternal := map[string]bool{} - for pluginID, pluginCfg := range c.cfg.PluginSettings { - if pluginCfg["as_external"] == "true" { - asExternal[pluginID] = true - } +// DuplicatePluginIDValidation is a filter step that will filter out any plugins that are already registered with the same +// plugin ID. This includes both the primary plugin and child plugins, which are matched using the plugin.json plugin +// ID field. +type DuplicatePluginIDValidation struct { + registry registry.Service + log log.Logger +} + +// NewDuplicatePluginIDFilterStep returns a new DuplicatePluginIDValidation. +func NewDuplicatePluginIDFilterStep(registry registry.Service) *DuplicatePluginIDValidation { + return &DuplicatePluginIDValidation{ + registry: registry, + log: log.New("plugins.dedupe"), + } +} + +// Filter will filter out any plugins that have already been registered under the same plugin ID. +func (d *DuplicatePluginIDValidation) Filter(ctx context.Context, bundles []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) { + res := make([]*plugins.FoundBundle, 0, len(bundles)) + + var matchesPluginIDFunc = func(fp plugins.FoundPlugin) func(p *plugins.Plugin) bool { + return func(p *plugins.Plugin) bool { + return p.ID == fp.JSONData.ID } - for _, bundle := range bundles { - if asExternal[bundle.Primary.JSONData.ID] { - delete(asExternal, bundle.Primary.JSONData.ID) - } + } + + for _, b := range bundles { + ps := d.registry.Plugins(ctx) + + if slices.ContainsFunc(ps, matchesPluginIDFunc(b.Primary)) { + d.log.Warn("Skipping loading of plugin as it's a duplicate", "pluginId", b.Primary.JSONData.ID) + continue } - if len(asExternal) > 0 { - for p := range asExternal { - c.log.Error("Core plugin expected to be loaded as external, but it is missing", "pluginID", p) + + var nonDupeChildren []*plugins.FoundPlugin + for _, child := range b.Children { + if slices.ContainsFunc(ps, matchesPluginIDFunc(*child)) { + d.log.Warn("Skipping loading of child plugin as it's a duplicate", "pluginId", child.JSONData.ID) + continue } + nonDupeChildren = append(nonDupeChildren, child) } + res = append(res, &plugins.FoundBundle{ + Primary: b.Primary, + Children: nonDupeChildren, + }) } - return bundles, nil + return res, nil } diff --git a/pkg/services/pluginsintegration/pipeline/steps_test.go b/pkg/services/pluginsintegration/pipeline/steps_test.go index 98c665d0d1053..24b7def7bde78 100644 --- a/pkg/services/pluginsintegration/pipeline/steps_test.go +++ b/pkg/services/pluginsintegration/pipeline/steps_test.go @@ -1,18 +1,19 @@ package pipeline import ( + "context" "testing" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" - "github.com/grafana/grafana/pkg/plugins/log" - "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/require" ) func TestSkipPlugins(t *testing.T) { - cfg := &config.Cfg{ + cfg := &config.PluginManagementCfg{ DisablePlugins: []string{"plugin1", "plugin2"}, } s := NewDisablePluginsStep(cfg) @@ -66,8 +67,10 @@ func TestAsExternal(t *testing.T) { } t.Run("should skip a core plugin", func(t *testing.T) { - cfg := &config.Cfg{ - Features: featuremgmt.WithFeatures(featuremgmt.FlagExternalCorePlugins), + cfg := &config.PluginManagementCfg{ + Features: config.Features{ + ExternalCorePluginsEnabled: true, + }, PluginSettings: setting.PluginSettings{ "plugin1": map[string]string{ "as_external": "true", @@ -81,24 +84,99 @@ func TestAsExternal(t *testing.T) { require.Len(t, filtered, 1) require.Equal(t, filtered[0].Primary.JSONData.ID, "plugin2") }) +} - t.Run("should log an error if an external plugin is not available", func(t *testing.T) { - cfg := &config.Cfg{ - Features: featuremgmt.WithFeatures(featuremgmt.FlagExternalCorePlugins), - PluginSettings: setting.PluginSettings{ - "plugin3": map[string]string{ - "as_external": "true", +func TestDuplicatePluginIDValidation(t *testing.T) { + tcs := []struct { + name string + registeredPlugins []string + in []*plugins.FoundBundle + out []*plugins.FoundBundle + }{ + { + name: "should filter out a plugin if it already exists in the plugin registry", + registeredPlugins: []string{"foobar-datasource"}, + in: []*plugins.FoundBundle{ + { + Primary: plugins.FoundPlugin{ + JSONData: plugins.JSONData{ + ID: "foobar-datasource", + }, + }, }, }, - } + out: []*plugins.FoundBundle{}, + }, + { + name: "should not filter out a plugin if it doesn't exist in the plugin registry", + registeredPlugins: []string{"foobar-datasource"}, + in: []*plugins.FoundBundle{ + { + Primary: plugins.FoundPlugin{ + JSONData: plugins.JSONData{ + ID: "test-datasource", + }, + }, + }, + }, + out: []*plugins.FoundBundle{ + { + Primary: plugins.FoundPlugin{ + JSONData: plugins.JSONData{ + ID: "test-datasource", + }, + }, + }, + }, + }, + { + name: "should filter out child plugins if they are already registered", + registeredPlugins: []string{"foobar-datasource"}, + in: []*plugins.FoundBundle{ + { + Primary: plugins.FoundPlugin{ + JSONData: plugins.JSONData{ + ID: "test-datasource", + }, + }, + Children: []*plugins.FoundPlugin{ + { + JSONData: plugins.JSONData{ + ID: "foobar-datasource", + }, + }, + }, + }, + }, + out: []*plugins.FoundBundle{ + { + Primary: plugins.FoundPlugin{ + JSONData: plugins.JSONData{ + ID: "test-datasource", + }, + }, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + r := registry.NewInMemory() + s := NewDuplicatePluginIDFilterStep(r) - fakeLogger := log.NewTestLogger() - s := NewAsExternalStep(cfg) - s.log = fakeLogger + ctx := context.Background() + for _, pluginID := range tc.registeredPlugins { + err := r.Add(ctx, &plugins.Plugin{ + JSONData: plugins.JSONData{ + ID: pluginID, + }, + }) + require.NoError(t, err) + } - filtered, err := s.Filter(plugins.ClassExternal, bundles) - require.NoError(t, err) - require.Len(t, filtered, 2) - require.Equal(t, fakeLogger.ErrorLogs.Calls, 1) - }) + res, err := s.Filter(ctx, tc.in) + require.NoError(t, err) + require.Equal(t, tc.out, res) + }) + } } diff --git a/pkg/services/pluginsintegration/pluginaccesscontrol/accesscontrol.go b/pkg/services/pluginsintegration/pluginaccesscontrol/accesscontrol.go index b462bc14ac190..1a7840d545ca3 100644 --- a/pkg/services/pluginsintegration/pluginaccesscontrol/accesscontrol.go +++ b/pkg/services/pluginsintegration/pluginaccesscontrol/accesscontrol.go @@ -30,7 +30,7 @@ func ReqCanAdminPlugins(cfg *setting.Cfg) func(rc *contextmodel.ReqContext) bool } } -func DeclareRBACRoles(service ac.Service, cfg *setting.Cfg) error { +func DeclareRBACRoles(service ac.Service, cfg *setting.Cfg, features featuremgmt.FeatureToggles) error { AppPluginsReader := ac.RoleRegistration{ Role: ac.RoleDTO{ Name: ac.FixedRolePrefix + "plugins.app:reader", @@ -69,7 +69,7 @@ func DeclareRBACRoles(service ac.Service, cfg *setting.Cfg) error { } if !cfg.PluginAdminEnabled || - (cfg.PluginAdminExternalManageEnabled && !cfg.IsFeatureToggleEnabled(featuremgmt.FlagManagedPluginsInstall)) { + (cfg.PluginAdminExternalManageEnabled && !features.IsEnabledGlobally(featuremgmt.FlagManagedPluginsInstall)) { PluginsMaintainer.Grants = []string{} } diff --git a/pkg/services/pluginsintegration/pluginconfig/config.go b/pkg/services/pluginsintegration/pluginconfig/config.go new file mode 100644 index 0000000000000..913643a7301da --- /dev/null +++ b/pkg/services/pluginsintegration/pluginconfig/config.go @@ -0,0 +1,147 @@ +package pluginconfig + +import ( + "fmt" + "strings" + + "github.com/grafana/grafana/pkg/util" + + "github.com/grafana/grafana-azure-sdk-go/azsettings" + + "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" +) + +// ProvidePluginManagementConfig returns a new config.PluginManagementCfg. +// It is used to provide configuration to Grafana's implementation of the plugin management system. +func ProvidePluginManagementConfig(cfg *setting.Cfg, settingProvider setting.Provider, features featuremgmt.FeatureToggles) (*config.PluginManagementCfg, error) { + plugins := settingProvider.Section("plugins") + allowedUnsigned := cfg.PluginsAllowUnsigned + if len(plugins.KeyValue("allow_loading_unsigned_plugins").Value()) > 0 { + allowedUnsigned = strings.Split(plugins.KeyValue("allow_loading_unsigned_plugins").Value(), ",") + } + + return config.NewPluginManagementCfg( + settingProvider.KeyValue("", "app_mode").MustBool(cfg.Env == setting.Dev), + cfg.PluginsPath, + extractPluginSettings(settingProvider), + allowedUnsigned, + cfg.PluginsCDNURLTemplate, + cfg.AppURL, + config.Features{ + ExternalCorePluginsEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalCorePlugins), + SkipHostEnvVarsEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSkipHostEnvVars), + }, + cfg.AngularSupportEnabled, + cfg.GrafanaComURL, + cfg.DisablePlugins, + cfg.HideAngularDeprecation, + cfg.ForwardHostEnvVars, + ), nil +} + +// PluginInstanceCfg contains the configuration for a plugin instance. +// It is used to provide configuration to the plugin instance either via env vars or via each plugin request. +type PluginInstanceCfg struct { + GrafanaAppURL string + Features featuremgmt.FeatureToggles + + Tracing config.Tracing + + PluginSettings setting.PluginSettings + + AWSAllowedAuthProviders []string + AWSAssumeRoleEnabled bool + AWSExternalId string + AWSSessionDuration string + AWSListMetricsPageLimit string + AWSForwardSettingsPlugins []string + + Azure *azsettings.AzureSettings + AzureAuthEnabled bool + + ProxySettings setting.SecureSocksDSProxySettings + + GrafanaVersion string + + ConcurrentQueryCount int + ResponseLimit int64 + + UserFacingDefaultError string + + DataProxyRowLimit int64 + + SQLDatasourceMaxOpenConnsDefault int + SQLDatasourceMaxIdleConnsDefault int + SQLDatasourceMaxConnLifetimeDefault int + + SigV4AuthEnabled bool + SigV4VerboseLogging bool +} + +// ProvidePluginInstanceConfig returns a new PluginInstanceCfg. +func ProvidePluginInstanceConfig(cfg *setting.Cfg, settingProvider setting.Provider, features featuremgmt.FeatureToggles) (*PluginInstanceCfg, error) { + aws := settingProvider.Section("aws") + allowedAuth := cfg.AWSAllowedAuthProviders + if len(aws.KeyValue("allowed_auth_providers").Value()) > 0 { + allowedAuth = util.SplitString(aws.KeyValue("allowed_auth_providers").Value()) + } + awsForwardSettingsPlugins := cfg.AWSForwardSettingsPlugins + if len(aws.KeyValue("forward_settings_to_plugins").Value()) > 0 { + awsForwardSettingsPlugins = util.SplitString(aws.KeyValue("forward_settings_to_plugins").Value()) + } + + tracingCfg, err := newTracingCfg(cfg) + if err != nil { + return nil, fmt.Errorf("new opentelemetry cfg: %w", err) + } + + if cfg.Azure == nil { + cfg.Azure = &azsettings.AzureSettings{} + } + + return &PluginInstanceCfg{ + GrafanaAppURL: cfg.AppURL, + Features: features, + Tracing: tracingCfg, + PluginSettings: extractPluginSettings(settingProvider), + AWSAllowedAuthProviders: allowedAuth, + AWSAssumeRoleEnabled: aws.KeyValue("assume_role_enabled").MustBool(cfg.AWSAssumeRoleEnabled), + AWSExternalId: aws.KeyValue("external_id").Value(), + AWSSessionDuration: aws.KeyValue("session_duration").Value(), + AWSListMetricsPageLimit: aws.KeyValue("list_metrics_page_limit").Value(), + AWSForwardSettingsPlugins: awsForwardSettingsPlugins, + Azure: cfg.Azure, + AzureAuthEnabled: cfg.Azure.AzureAuthEnabled, + ProxySettings: cfg.SecureSocksDSProxy, + GrafanaVersion: cfg.BuildVersion, + ConcurrentQueryCount: cfg.ConcurrentQueryCount, + UserFacingDefaultError: cfg.UserFacingDefaultError, + DataProxyRowLimit: cfg.DataProxyRowLimit, + SQLDatasourceMaxOpenConnsDefault: cfg.SqlDatasourceMaxOpenConnsDefault, + SQLDatasourceMaxIdleConnsDefault: cfg.SqlDatasourceMaxIdleConnsDefault, + SQLDatasourceMaxConnLifetimeDefault: cfg.SqlDatasourceMaxConnLifetimeDefault, + ResponseLimit: cfg.ResponseLimit, + SigV4AuthEnabled: cfg.SigV4AuthEnabled, + SigV4VerboseLogging: cfg.SigV4VerboseLogging, + }, nil +} + +func extractPluginSettings(settingProvider setting.Provider) setting.PluginSettings { + ps := setting.PluginSettings{} + for sectionName, sectionCopy := range settingProvider.Current() { + if !strings.HasPrefix(sectionName, "plugin.") { + continue + } + // Calling Current() returns a redacted version of section. We need to replace the map values with the unredacted values. + section := settingProvider.Section(sectionName) + for k := range sectionCopy { + sectionCopy[k] = section.KeyValue(k).MustString("") + } + pluginID := strings.Replace(sectionName, "plugin.", "", 1) + ps[pluginID] = sectionCopy + } + + return ps +} diff --git a/pkg/services/pluginsintegration/config/config_test.go b/pkg/services/pluginsintegration/pluginconfig/config_test.go similarity index 97% rename from pkg/services/pluginsintegration/config/config_test.go rename to pkg/services/pluginsintegration/pluginconfig/config_test.go index 3ee6eddf2cd96..e5bebbc93aa2a 100644 --- a/pkg/services/pluginsintegration/config/config_test.go +++ b/pkg/services/pluginsintegration/pluginconfig/config_test.go @@ -1,4 +1,4 @@ -package config +package pluginconfig import ( "testing" @@ -18,7 +18,7 @@ func TestPluginSettings(t *testing.T) { [plugin.test-datasource] foo = 5m bar = something - + [plugin.secret-plugin] secret_key = secret normal_key = not a secret`)) diff --git a/pkg/services/pluginsintegration/pluginconfig/envvars.go b/pkg/services/pluginsintegration/pluginconfig/envvars.go new file mode 100644 index 0000000000000..bb8c2a0360327 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginconfig/envvars.go @@ -0,0 +1,178 @@ +package pluginconfig + +import ( + "context" + "fmt" + "os" + "slices" + "strconv" + "strings" + + "github.com/grafana/grafana-aws-sdk/pkg/awsds" + "github.com/grafana/grafana-azure-sdk-go/azsettings" + "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" + + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/envvars" + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +var _ envvars.Provider = (*EnvVarsProvider)(nil) + +type EnvVarsProvider struct { + cfg *PluginInstanceCfg + license plugins.Licensing +} + +func NewEnvVarsProvider(cfg *PluginInstanceCfg, license plugins.Licensing) *EnvVarsProvider { + return &EnvVarsProvider{ + cfg: cfg, + license: license, + } +} + +func (p *EnvVarsProvider) PluginEnvVars(ctx context.Context, plugin *plugins.Plugin) []string { + hostEnv := []string{ + envVar("GF_VERSION", p.cfg.GrafanaVersion), + } + + if p.license != nil { + hostEnv = append( + hostEnv, + envVar("GF_EDITION", p.license.Edition()), + envVar("GF_ENTERPRISE_LICENSE_PATH", p.license.Path()), + envVar("GF_ENTERPRISE_APP_URL", p.license.AppURL()), + ) + hostEnv = append(hostEnv, p.license.Environment()...) + } + + if plugin.ExternalService != nil { + hostEnv = append( + hostEnv, + envVar("GF_APP_URL", p.cfg.GrafanaAppURL), + envVar("GF_PLUGIN_APP_CLIENT_ID", plugin.ExternalService.ClientID), + envVar("GF_PLUGIN_APP_CLIENT_SECRET", plugin.ExternalService.ClientSecret), + ) + if plugin.ExternalService.PrivateKey != "" { + hostEnv = append(hostEnv, envVar("GF_PLUGIN_APP_PRIVATE_KEY", plugin.ExternalService.PrivateKey)) + } + } + + hostEnv = append(hostEnv, p.featureToggleEnableVars(ctx)...) + hostEnv = append(hostEnv, p.awsEnvVars(plugin.PluginID())...) + hostEnv = append(hostEnv, p.secureSocksProxyEnvVars()...) + hostEnv = append(hostEnv, azsettings.WriteToEnvStr(p.cfg.Azure)...) + hostEnv = append(hostEnv, p.tracingEnvVars(plugin)...) + hostEnv = append(hostEnv, p.pluginSettingsEnvVars(plugin.PluginID())...) + + // If SkipHostEnvVars is enabled, get some allowed variables from the current process and pass + // them down to the plugin. If the flag is not set, do not add anything else because ALL env vars + // from the current process (os.Environ()) will be forwarded to the plugin's process by go-plugin + if plugin.SkipHostEnvVars { + hostEnv = append(hostEnv, envvars.PermittedHostEnvVars()...) + } + + return hostEnv +} + +func (p *EnvVarsProvider) featureToggleEnableVars(ctx context.Context) []string { + var variables []string // an array is used for consistency and keep the logic simpler for no features case + + if p.cfg.Features == nil { + return variables + } + + enabledFeatures := p.cfg.Features.GetEnabled(ctx) + if len(enabledFeatures) > 0 { + features := make([]string, 0, len(enabledFeatures)) + for feat := range enabledFeatures { + features = append(features, feat) + } + variables = append(variables, envVar("GF_INSTANCE_FEATURE_TOGGLES_ENABLE", strings.Join(features, ","))) + } + + return variables +} + +func (p *EnvVarsProvider) awsEnvVars(pluginID string) []string { + if !slices.Contains[[]string, string](p.cfg.AWSForwardSettingsPlugins, pluginID) { + return []string{} + } + + var variables []string + if !p.cfg.AWSAssumeRoleEnabled { + variables = append(variables, envVar(awsds.AssumeRoleEnabledEnvVarKeyName, "false")) + } + if len(p.cfg.AWSAllowedAuthProviders) > 0 { + variables = append(variables, envVar(awsds.AllowedAuthProvidersEnvVarKeyName, strings.Join(p.cfg.AWSAllowedAuthProviders, ","))) + } + if p.cfg.AWSExternalId != "" { + variables = append(variables, envVar(awsds.GrafanaAssumeRoleExternalIdKeyName, p.cfg.AWSExternalId)) + } + if p.cfg.AWSSessionDuration != "" { + variables = append(variables, envVar(awsds.SessionDurationEnvVarKeyName, p.cfg.AWSSessionDuration)) + } + if p.cfg.AWSListMetricsPageLimit != "" { + variables = append(variables, envVar(awsds.ListMetricsPageLimitKeyName, p.cfg.AWSListMetricsPageLimit)) + } + + return variables +} + +func (p *EnvVarsProvider) secureSocksProxyEnvVars() []string { + if p.cfg.ProxySettings.Enabled { + return []string{ + envVar(proxy.PluginSecureSocksProxyClientCert, p.cfg.ProxySettings.ClientCert), + envVar(proxy.PluginSecureSocksProxyClientKey, p.cfg.ProxySettings.ClientKey), + envVar(proxy.PluginSecureSocksProxyRootCACert, p.cfg.ProxySettings.RootCA), + envVar(proxy.PluginSecureSocksProxyProxyAddress, p.cfg.ProxySettings.ProxyAddress), + envVar(proxy.PluginSecureSocksProxyServerName, p.cfg.ProxySettings.ServerName), + envVar(proxy.PluginSecureSocksProxyEnabled, strconv.FormatBool(p.cfg.ProxySettings.Enabled)), + envVar(proxy.PluginSecureSocksProxyAllowInsecure, strconv.FormatBool(p.cfg.ProxySettings.AllowInsecure)), + } + } + return nil +} + +func (p *EnvVarsProvider) tracingEnvVars(plugin *plugins.Plugin) []string { + pluginTracingEnabled := p.cfg.Features.IsEnabledGlobally(featuremgmt.FlagEnablePluginsTracingByDefault) + if v, exists := p.cfg.PluginSettings[plugin.ID]["tracing"]; exists && !pluginTracingEnabled { + pluginTracingEnabled = v == "true" + } + + if !p.cfg.Tracing.IsEnabled() || !pluginTracingEnabled { + return nil + } + + vars := []string{ + envVar("GF_INSTANCE_OTLP_ADDRESS", p.cfg.Tracing.OpenTelemetry.Address), + envVar("GF_INSTANCE_OTLP_PROPAGATION", p.cfg.Tracing.OpenTelemetry.Propagation), + envVar("GF_INSTANCE_OTLP_SAMPLER_TYPE", p.cfg.Tracing.OpenTelemetry.Sampler), + fmt.Sprintf("GF_INSTANCE_OTLP_SAMPLER_PARAM=%.6f", p.cfg.Tracing.OpenTelemetry.SamplerParam), + envVar("GF_INSTANCE_OTLP_SAMPLER_REMOTE_URL", p.cfg.Tracing.OpenTelemetry.SamplerRemoteURL), + } + if plugin.Info.Version != "" { + vars = append(vars, fmt.Sprintf("GF_PLUGIN_VERSION=%s", plugin.Info.Version)) + } + return vars +} + +func (p *EnvVarsProvider) pluginSettingsEnvVars(pluginID string) []string { + const customConfigPrefix = "GF_PLUGIN" + var env []string + for k, v := range p.cfg.PluginSettings[pluginID] { + if k == "path" || strings.ToLower(k) == "id" { + continue + } + key := fmt.Sprintf("%s_%s", customConfigPrefix, strings.ToUpper(k)) + if value := os.Getenv(key); value != "" { + v = value + } + env = append(env, fmt.Sprintf("%s=%s", key, v)) + } + return env +} + +func envVar(key, value string) string { + return fmt.Sprintf("%s=%s", key, value) +} diff --git a/pkg/plugins/envvars/envvars_test.go b/pkg/services/pluginsintegration/pluginconfig/envvars_test.go similarity index 62% rename from pkg/plugins/envvars/envvars_test.go rename to pkg/services/pluginsintegration/pluginconfig/envvars_test.go index daf8df83c75cc..1a967b8a14ea4 100644 --- a/pkg/plugins/envvars/envvars_test.go +++ b/pkg/services/pluginsintegration/pluginconfig/envvars_test.go @@ -1,24 +1,28 @@ -package envvars +package pluginconfig import ( "context" "strings" "testing" + "gopkg.in/ini.v1" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana-azure-sdk-go/azsettings" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/auth" "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/envvars" "github.com/grafana/grafana/pkg/plugins/manager/fakes" - "github.com/grafana/grafana/pkg/plugins/plugindef" + "github.com/grafana/grafana/pkg/plugins/pfs" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" ) -func TestInitializer_envVars(t *testing.T) { +func TestPluginEnvVarsProvider_PluginEnvVars(t *testing.T) { t.Run("backend datasource with license", func(t *testing.T) { p := &plugins.Plugin{ JSONData: plugins.JSONData{ @@ -33,26 +37,29 @@ func TestInitializer_envVars(t *testing.T) { LicenseAppURL: "https://myorg.com/", } - envVarsProvider := NewProvider(&config.Cfg{ + cfg := &PluginInstanceCfg{ PluginSettings: map[string]map[string]string{ "test": { "custom_env_var": "customVal", }, }, - }, licensing) + AWSAssumeRoleEnabled: true, + Features: featuremgmt.WithFeatures(), + } - envVars := envVarsProvider.Get(context.Background(), p) + provider := NewEnvVarsProvider(cfg, licensing) + envVars := provider.PluginEnvVars(context.Background(), p) assert.Len(t, envVars, 6) - assert.Equal(t, "GF_PLUGIN_CUSTOM_ENV_VAR=customVal", envVars[0]) - assert.Equal(t, "GF_VERSION=", envVars[1]) - assert.Equal(t, "GF_EDITION=test", envVars[2]) - assert.Equal(t, "GF_ENTERPRISE_LICENSE_PATH=/path/to/ent/license", envVars[3]) - assert.Equal(t, "GF_ENTERPRISE_APP_URL=https://myorg.com/", envVars[4]) - assert.Equal(t, "GF_ENTERPRISE_LICENSE_TEXT=token", envVars[5]) + assert.Equal(t, "GF_VERSION=", envVars[0]) + assert.Equal(t, "GF_EDITION=test", envVars[1]) + assert.Equal(t, "GF_ENTERPRISE_LICENSE_PATH=/path/to/ent/license", envVars[2]) + assert.Equal(t, "GF_ENTERPRISE_APP_URL=https://myorg.com/", envVars[3]) + assert.Equal(t, "GF_ENTERPRISE_LICENSE_TEXT=token", envVars[4]) + assert.Equal(t, "GF_PLUGIN_CUSTOM_ENV_VAR=customVal", envVars[5]) }) } -func TestInitializer_skipHostEnvVars(t *testing.T) { +func TestPluginEnvVarsProvider_skipHostEnvVars(t *testing.T) { const ( envVarName = "HTTP_PROXY" envVarValue = "lorem ipsum" @@ -67,8 +74,12 @@ func TestInitializer_skipHostEnvVars(t *testing.T) { } t.Run("without FlagPluginsSkipHostEnvVars should not populate host env vars", func(t *testing.T) { - envVarsProvider := NewProvider(&config.Cfg{Features: featuremgmt.WithFeatures()}, nil) - envVars := envVarsProvider.Get(context.Background(), p) + cfg := setting.NewCfg() + pCfg, err := ProvidePluginInstanceConfig(cfg, setting.ProvideProvider(cfg), featuremgmt.WithFeatures()) + require.NoError(t, err) + + provider := NewEnvVarsProvider(pCfg, nil) + envVars := provider.PluginEnvVars(context.Background(), p) // We want to test that the envvars.Provider does not add any of the host env vars. // When starting the plugin via go-plugin, ALL host env vars will be added by go-plugin, @@ -78,21 +89,22 @@ func TestInitializer_skipHostEnvVars(t *testing.T) { }) t.Run("with SkipHostEnvVars = true", func(t *testing.T) { - p := &plugins.Plugin{ - JSONData: plugins.JSONData{ID: "test"}, - SkipHostEnvVars: true, - } - envVarsProvider := NewProvider(&config.Cfg{}, nil) + p.SkipHostEnvVars = true + + cfg := setting.NewCfg() + pCfg, err := ProvidePluginInstanceConfig(cfg, setting.ProvideProvider(cfg), featuremgmt.WithFeatures()) + require.NoError(t, err) + provider := NewEnvVarsProvider(pCfg, nil) t.Run("should populate allowed host env vars", func(t *testing.T) { // Set all allowed variables - for _, ev := range allowedHostEnvVarNames { + for _, ev := range envvars.PermittedHostEnvVarNames() { t.Setenv(ev, envVarValue) } - envVars := envVarsProvider.Get(context.Background(), p) + envVars := provider.PluginEnvVars(context.Background(), p) // Test against each variable - for _, expEvName := range allowedHostEnvVarNames { + for _, expEvName := range envvars.PermittedHostEnvVarNames() { gotEvValue, ok := getEnvVarWithExists(envVars, expEvName) require.True(t, ok, "host env var should be present") require.Equal(t, envVarValue, gotEvValue) @@ -101,20 +113,20 @@ func TestInitializer_skipHostEnvVars(t *testing.T) { t.Run("should not populate host env vars that aren't allowed", func(t *testing.T) { // Set all allowed variables - for _, ev := range allowedHostEnvVarNames { + for _, ev := range envvars.PermittedHostEnvVarNames() { t.Setenv(ev, envVarValue) } // ...and an extra one, which should not leak const superSecretEnvVariableName = "SUPER_SECRET_VALUE" t.Setenv(superSecretEnvVariableName, "01189998819991197253") - envVars := envVarsProvider.Get(context.Background(), p) + envVars := provider.PluginEnvVars(context.Background(), p) // Super secret should not leak _, ok := getEnvVarWithExists(envVars, superSecretEnvVariableName) require.False(t, ok, "super secret env var should not be leaked") // Everything else should be present - for _, expEvName := range allowedHostEnvVarNames { + for _, expEvName := range envvars.PermittedHostEnvVarNames() { var gotEvValue string gotEvValue, ok = getEnvVarWithExists(envVars, expEvName) require.True(t, ok, "host env var should be present") @@ -124,7 +136,7 @@ func TestInitializer_skipHostEnvVars(t *testing.T) { }) } -func TestInitializer_tracingEnvironmentVariables(t *testing.T) { +func TestPluginEnvVarsProvider_tracingEnvironmentVariables(t *testing.T) { const pluginID = "plugin_id" defaultPlugin := &plugins.Plugin{ @@ -195,53 +207,63 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) { for _, tc := range []struct { name string - cfg *config.Cfg + cfg *PluginInstanceCfg plugin *plugins.Plugin exp func(t *testing.T, envVars []string) }{ { name: "otel not configured", - cfg: &config.Cfg{ - Tracing: config.Tracing{}, + cfg: &PluginInstanceCfg{ + AWSAssumeRoleEnabled: false, + Tracing: config.Tracing{}, + Features: featuremgmt.WithFeatures(), }, plugin: defaultPlugin, exp: expNoTracing, }, { name: "otel not configured but plugin-tracing enabled", - cfg: &config.Cfg{ - Tracing: config.Tracing{}, + cfg: &PluginInstanceCfg{ PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, + Tracing: config.Tracing{}, + Features: featuremgmt.WithFeatures(), }, plugin: defaultPlugin, exp: expNoTracing, }, { name: "otlp no propagation plugin enabled", - cfg: &config.Cfg{ - Tracing: config.Tracing{ - OpenTelemetry: defaultOTelCfg, - }, + cfg: &PluginInstanceCfg{ PluginSettings: map[string]map[string]string{ pluginID: {"tracing": "true"}, }, + Tracing: config.Tracing{ + OpenTelemetry: defaultOTelCfg, + }, + Features: featuremgmt.WithFeatures(), }, + plugin: defaultPlugin, exp: expDefaultOtlp, }, { name: "otlp no propagation disabled by default", - cfg: &config.Cfg{ + cfg: &PluginInstanceCfg{ Tracing: config.Tracing{ OpenTelemetry: defaultOTelCfg, }, + Features: featuremgmt.WithFeatures(), }, plugin: defaultPlugin, exp: expNoTracing, }, { name: "otlp propagation plugin enabled", - cfg: &config.Cfg{ + cfg: &PluginInstanceCfg{ + PluginSettings: map[string]map[string]string{ + pluginID: {"tracing": "true"}, + }, + AWSAssumeRoleEnabled: true, Tracing: config.Tracing{ OpenTelemetry: config.OpenTelemetryCfg{ Address: "127.0.0.1:4317", @@ -253,26 +275,28 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) { SamplerRemoteURL: "", }, }, - PluginSettings: map[string]map[string]string{ - pluginID: {"tracing": "true"}, - }, + Features: featuremgmt.WithFeatures(), }, plugin: defaultPlugin, exp: func(t *testing.T, envVars []string) { assert.Len(t, envVars, 8) - assert.Equal(t, "GF_PLUGIN_TRACING=true", envVars[0]) - assert.Equal(t, "GF_VERSION=", envVars[1]) - assert.Equal(t, "GF_INSTANCE_OTLP_ADDRESS=127.0.0.1:4317", envVars[2]) - assert.Equal(t, "GF_INSTANCE_OTLP_PROPAGATION=w3c", envVars[3]) - assert.Equal(t, "GF_INSTANCE_OTLP_SAMPLER_TYPE=", envVars[4]) - assert.Equal(t, "GF_INSTANCE_OTLP_SAMPLER_PARAM=1.000000", envVars[5]) - assert.Equal(t, "GF_INSTANCE_OTLP_SAMPLER_REMOTE_URL=", envVars[6]) - assert.Equal(t, "GF_PLUGIN_VERSION=1.0.0", envVars[7]) + assert.Equal(t, "GF_VERSION=", envVars[0]) + assert.Equal(t, "GF_INSTANCE_OTLP_ADDRESS=127.0.0.1:4317", envVars[1]) + assert.Equal(t, "GF_INSTANCE_OTLP_PROPAGATION=w3c", envVars[2]) + assert.Equal(t, "GF_INSTANCE_OTLP_SAMPLER_TYPE=", envVars[3]) + assert.Equal(t, "GF_INSTANCE_OTLP_SAMPLER_PARAM=1.000000", envVars[4]) + assert.Equal(t, "GF_INSTANCE_OTLP_SAMPLER_REMOTE_URL=", envVars[5]) + assert.Equal(t, "GF_PLUGIN_VERSION=1.0.0", envVars[6]) + assert.Equal(t, "GF_PLUGIN_TRACING=true", envVars[7]) }, }, { name: "otlp enabled composite propagation", - cfg: &config.Cfg{ + cfg: &PluginInstanceCfg{ + PluginSettings: map[string]map[string]string{ + pluginID: {"tracing": "true"}, + }, + AWSAssumeRoleEnabled: true, Tracing: config.Tracing{ OpenTelemetry: config.OpenTelemetryCfg{ Address: "127.0.0.1:4317", @@ -284,111 +308,142 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) { SamplerRemoteURL: "", }, }, - PluginSettings: map[string]map[string]string{ - pluginID: {"tracing": "true"}, - }, + Features: featuremgmt.WithFeatures(), }, plugin: defaultPlugin, exp: func(t *testing.T, envVars []string) { assert.Len(t, envVars, 8) - assert.Equal(t, "GF_PLUGIN_TRACING=true", envVars[0]) - assert.Equal(t, "GF_VERSION=", envVars[1]) - assert.Equal(t, "GF_INSTANCE_OTLP_ADDRESS=127.0.0.1:4317", envVars[2]) - assert.Equal(t, "GF_INSTANCE_OTLP_PROPAGATION=w3c,jaeger", envVars[3]) - assert.Equal(t, "GF_INSTANCE_OTLP_SAMPLER_TYPE=", envVars[4]) - assert.Equal(t, "GF_INSTANCE_OTLP_SAMPLER_PARAM=1.000000", envVars[5]) - assert.Equal(t, "GF_INSTANCE_OTLP_SAMPLER_REMOTE_URL=", envVars[6]) - assert.Equal(t, "GF_PLUGIN_VERSION=1.0.0", envVars[7]) + assert.Equal(t, "GF_VERSION=", envVars[0]) + assert.Equal(t, "GF_INSTANCE_OTLP_ADDRESS=127.0.0.1:4317", envVars[1]) + assert.Equal(t, "GF_INSTANCE_OTLP_PROPAGATION=w3c,jaeger", envVars[2]) + assert.Equal(t, "GF_INSTANCE_OTLP_SAMPLER_TYPE=", envVars[3]) + assert.Equal(t, "GF_INSTANCE_OTLP_SAMPLER_PARAM=1.000000", envVars[4]) + assert.Equal(t, "GF_INSTANCE_OTLP_SAMPLER_REMOTE_URL=", envVars[5]) + assert.Equal(t, "GF_PLUGIN_VERSION=1.0.0", envVars[6]) + assert.Equal(t, "GF_PLUGIN_TRACING=true", envVars[7]) }, }, { name: "otlp no propagation disabled by default", - cfg: &config.Cfg{ + cfg: &PluginInstanceCfg{ Tracing: config.Tracing{ OpenTelemetry: config.OpenTelemetryCfg{ Address: "127.0.0.1:4317", Propagation: "w3c", }, }, + Features: featuremgmt.WithFeatures(), }, plugin: defaultPlugin, exp: expNoTracing, }, { name: "disabled on plugin", - cfg: &config.Cfg{ - Tracing: config.Tracing{ - OpenTelemetry: defaultOTelCfg, - }, + cfg: &PluginInstanceCfg{ PluginSettings: setting.PluginSettings{ pluginID: map[string]string{"tracing": "false"}, }, + Tracing: config.Tracing{ + OpenTelemetry: defaultOTelCfg, + }, + Features: featuremgmt.WithFeatures(), }, plugin: defaultPlugin, exp: expNoTracing, }, { name: "disabled on plugin with other plugin settings", - cfg: &config.Cfg{ - Tracing: config.Tracing{ - OpenTelemetry: defaultOTelCfg, - }, + cfg: &PluginInstanceCfg{ PluginSettings: map[string]map[string]string{ pluginID: {"some_other_option": "true"}, }, + AWSAssumeRoleEnabled: true, + Tracing: config.Tracing{ + OpenTelemetry: defaultOTelCfg, + }, + Features: featuremgmt.WithFeatures(), }, plugin: defaultPlugin, exp: expNoTracing, }, { name: "enabled on plugin with other plugin settings", - cfg: &config.Cfg{ + cfg: &PluginInstanceCfg{ + PluginSettings: map[string]map[string]string{ + pluginID: {"some_other_option": "true", "tracing": "true"}, + }, Tracing: config.Tracing{ OpenTelemetry: defaultOTelCfg, }, - PluginSettings: map[string]map[string]string{ - pluginID: {"some_other_option": "true", "tracing": "true"}, + Features: featuremgmt.WithFeatures(), + }, + plugin: defaultPlugin, + exp: expDefaultOtlp, + }, + { + name: `enabled on plugin with no "tracing" plugin setting but with enablePluginsTracingByDefault feature flag`, + cfg: &PluginInstanceCfg{ + PluginSettings: map[string]map[string]string{pluginID: {}}, + Tracing: config.Tracing{ + OpenTelemetry: defaultOTelCfg, }, + Features: featuremgmt.WithFeatures(featuremgmt.FlagEnablePluginsTracingByDefault), + }, + plugin: defaultPlugin, + exp: expDefaultOtlp, + }, + { + name: `enabled on plugin with plugin setting "tracing=false" but with enablePluginsTracingByDefault feature flag`, + cfg: &PluginInstanceCfg{ + PluginSettings: map[string]map[string]string{pluginID: {"tracing": "false"}}, + Tracing: config.Tracing{ + OpenTelemetry: defaultOTelCfg, + }, + Features: featuremgmt.WithFeatures(featuremgmt.FlagEnablePluginsTracingByDefault), }, plugin: defaultPlugin, exp: expDefaultOtlp, }, { name: "GF_PLUGIN_VERSION is not present if tracing is disabled", - cfg: &config.Cfg{ + cfg: &PluginInstanceCfg{ + PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, Tracing: config.Tracing{ OpenTelemetry: config.OpenTelemetryCfg{}, }, - PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, + Features: featuremgmt.WithFeatures(), }, plugin: defaultPlugin, exp: expGfPluginVersionNotPresent, }, { name: "GF_PLUGIN_VERSION is present if tracing is enabled and plugin has version", - cfg: &config.Cfg{ + cfg: &PluginInstanceCfg{ + PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, Tracing: config.Tracing{ OpenTelemetry: defaultOTelCfg, }, - PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, + Features: featuremgmt.WithFeatures(), }, plugin: defaultPlugin, exp: expGfPluginVersionPresent, }, { name: "GF_PLUGIN_VERSION is not present if tracing is enabled but plugin doesn't have a version", - cfg: &config.Cfg{ + cfg: &PluginInstanceCfg{ + PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, Tracing: config.Tracing{ OpenTelemetry: config.OpenTelemetryCfg{}, }, - PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, + Features: featuremgmt.WithFeatures(), }, plugin: pluginWithoutVersion, exp: expGfPluginVersionNotPresent, }, { name: "no sampling (neversample)", - cfg: &config.Cfg{ + cfg: &PluginInstanceCfg{ + PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, Tracing: config.Tracing{ OpenTelemetry: config.OpenTelemetryCfg{ Address: "127.0.0.1:4317", @@ -398,7 +453,7 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) { SamplerRemoteURL: "", }, }, - PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, + Features: featuremgmt.WithFeatures(), }, plugin: defaultPlugin, exp: func(t *testing.T, envVars []string) { @@ -409,7 +464,8 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) { }, { name: "empty sampler with param", - cfg: &config.Cfg{ + cfg: &PluginInstanceCfg{ + PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, Tracing: config.Tracing{ OpenTelemetry: config.OpenTelemetryCfg{ Address: "127.0.0.1:4317", @@ -419,7 +475,7 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) { SamplerRemoteURL: "", }, }, - PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, + Features: featuremgmt.WithFeatures(), }, plugin: defaultPlugin, exp: func(t *testing.T, envVars []string) { @@ -430,7 +486,8 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) { }, { name: "const sampler with param", - cfg: &config.Cfg{ + cfg: &PluginInstanceCfg{ + PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, Tracing: config.Tracing{ OpenTelemetry: config.OpenTelemetryCfg{ Address: "127.0.0.1:4317", @@ -440,7 +497,7 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) { SamplerRemoteURL: "", }, }, - PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, + Features: featuremgmt.WithFeatures(), }, plugin: defaultPlugin, exp: func(t *testing.T, envVars []string) { @@ -451,7 +508,8 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) { }, { name: "rateLimiting sampler", - cfg: &config.Cfg{ + cfg: &PluginInstanceCfg{ + PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, Tracing: config.Tracing{ OpenTelemetry: config.OpenTelemetryCfg{ Address: "127.0.0.1:4317", @@ -461,7 +519,7 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) { SamplerRemoteURL: "", }, }, - PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, + Features: featuremgmt.WithFeatures(), }, plugin: defaultPlugin, exp: func(t *testing.T, envVars []string) { @@ -472,7 +530,9 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) { }, { name: "remote sampler", - cfg: &config.Cfg{ + cfg: &PluginInstanceCfg{ + PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, + Features: featuremgmt.WithFeatures(), Tracing: config.Tracing{ OpenTelemetry: config.OpenTelemetryCfg{ Address: "127.0.0.1:4317", @@ -482,7 +542,6 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) { SamplerRemoteURL: "127.0.0.1:10001", }, }, - PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}}, }, plugin: defaultPlugin, exp: func(t *testing.T, envVars []string) { @@ -493,8 +552,8 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - envVarsProvider := NewProvider(tc.cfg, nil) - envVars := envVarsProvider.Get(context.Background(), tc.plugin) + p := NewEnvVarsProvider(tc.cfg, nil) + envVars := p.PluginEnvVars(context.Background(), tc.plugin) tc.exp(t, envVars) }) } @@ -526,12 +585,12 @@ func getEnvVar(vars []string, wanted string) string { return v } -func TestInitializer_authEnvVars(t *testing.T) { +func TestPluginEnvVarsProvider_authEnvVars(t *testing.T) { t.Run("backend datasource with auth registration", func(t *testing.T) { p := &plugins.Plugin{ JSONData: plugins.JSONData{ ID: "test", - IAM: &plugindef.IAM{}, + IAM: &pfs.IAM{}, }, ExternalService: &auth.ExternalService{ ClientID: "clientID", @@ -540,10 +599,16 @@ func TestInitializer_authEnvVars(t *testing.T) { }, } - envVarsProvider := NewProvider(&config.Cfg{ - GrafanaAppURL: "https://myorg.com/", - }, nil) - envVars := envVarsProvider.Get(context.Background(), p) + cfg := &setting.Cfg{ + Raw: ini.Empty(), + AppURL: "https://myorg.com/", + } + + pCfg, err := ProvidePluginInstanceConfig(cfg, setting.ProvideProvider(cfg), featuremgmt.WithFeatures()) + require.NoError(t, err) + + provider := NewEnvVarsProvider(pCfg, nil) + envVars := provider.PluginEnvVars(context.Background(), p) assert.Equal(t, "GF_VERSION=", envVars[0]) assert.Equal(t, "GF_APP_URL=https://myorg.com/", envVars[1]) assert.Equal(t, "GF_PLUGIN_APP_CLIENT_ID=clientID", envVars[2]) @@ -552,20 +617,52 @@ func TestInitializer_authEnvVars(t *testing.T) { }) } -func TestInitalizer_awsEnvVars(t *testing.T) { +func TestPluginEnvVarsProvider_awsEnvVars(t *testing.T) { t.Run("backend datasource with aws settings", func(t *testing.T) { - p := &plugins.Plugin{} - envVarsProvider := NewProvider(&config.Cfg{ - AWSAssumeRoleEnabled: true, - AWSAllowedAuthProviders: []string{"grafana_assume_role", "keys"}, - AWSExternalId: "mock_external_id", - }, nil) - envVars := envVarsProvider.Get(context.Background(), p) - assert.ElementsMatch(t, []string{"GF_VERSION=", "AWS_AUTH_AssumeRoleEnabled=true", "AWS_AUTH_AllowedAuthProviders=grafana_assume_role,keys", "AWS_AUTH_EXTERNAL_ID=mock_external_id"}, envVars) + tcs := []struct { + name string + pluginID string + forwardToPlugins []string + expected []string + }{ + { + name: "Will generate AWS env vars for plugin as long as is in the forwardToPlugins list", + forwardToPlugins: []string{"foobar-datasource", "cloudwatch", "prometheus"}, + pluginID: "cloudwatch", + expected: []string{"GF_VERSION=", "AWS_AUTH_AssumeRoleEnabled=false", "AWS_AUTH_AllowedAuthProviders=grafana_assume_role,keys", "AWS_AUTH_EXTERNAL_ID=mock_external_id", "AWS_AUTH_SESSION_DURATION=10m", "AWS_CW_LIST_METRICS_PAGE_LIMIT=100"}, + }, + { + name: "Will not generate AWS env vars for plugin as long as is in not the forwardToPlugins list", + forwardToPlugins: []string{"cloudwatch", "foobar-datasource"}, + pluginID: "prometheus", + expected: []string{"GF_VERSION="}, + }, + } + + for _, tc := range tcs { + p := &plugins.Plugin{ + JSONData: plugins.JSONData{ + ID: tc.pluginID, + }, + } + cfg := &PluginInstanceCfg{ + AWSAssumeRoleEnabled: false, + AWSAllowedAuthProviders: []string{"grafana_assume_role", "keys"}, + AWSExternalId: "mock_external_id", + AWSSessionDuration: "10m", + AWSListMetricsPageLimit: "100", + AWSForwardSettingsPlugins: tc.forwardToPlugins, + Features: featuremgmt.WithFeatures(), + } + + provider := NewEnvVarsProvider(cfg, nil) + envVars := provider.PluginEnvVars(context.Background(), p) + assert.ElementsMatch(t, tc.expected, envVars) + } }) } -func TestInitializer_featureToggleEnvVar(t *testing.T) { +func TestPluginEnvVarsProvider_featureToggleEnvVar(t *testing.T) { t.Run("backend datasource with feature toggle", func(t *testing.T) { expectedFeatures := []string{"feat-1", "feat-2"} featuresLookup := map[string]bool{ @@ -573,12 +670,12 @@ func TestInitializer_featureToggleEnvVar(t *testing.T) { expectedFeatures[1]: true, } - p := &plugins.Plugin{} - envVarsProvider := NewProvider(&config.Cfg{ + cfg := &PluginInstanceCfg{ Features: featuremgmt.WithFeatures(expectedFeatures[0], true, expectedFeatures[1], true), - }, nil) - envVars := envVarsProvider.Get(context.Background(), p) + } + p := NewEnvVarsProvider(cfg, nil) + envVars := p.PluginEnvVars(context.Background(), &plugins.Plugin{}) assert.Equal(t, 2, len(envVars)) toggleExpression := strings.Split(envVars[1], "=") @@ -599,10 +696,11 @@ func TestInitializer_featureToggleEnvVar(t *testing.T) { }) } -func TestInitalizer_azureEnvVars(t *testing.T) { +func TestPluginEnvVarsProvider_azureEnvVars(t *testing.T) { t.Run("backend datasource with azure settings", func(t *testing.T) { - p := &plugins.Plugin{} - envVarsProvider := NewProvider(&config.Cfg{ + cfg := &setting.Cfg{ + Raw: ini.Empty(), + AWSAssumeRoleEnabled: true, Azure: &azsettings.AzureSettings{ Cloud: azsettings.AzurePublic, ManagedIdentityEnabled: true, @@ -621,8 +719,13 @@ func TestInitalizer_azureEnvVars(t *testing.T) { UsernameAssertion: true, }, }, - }, nil) - envVars := envVarsProvider.Get(context.Background(), p) + } + + pCfg, err := ProvidePluginInstanceConfig(cfg, setting.ProvideProvider(cfg), featuremgmt.WithFeatures()) + require.NoError(t, err) + + provider := NewEnvVarsProvider(pCfg, nil) + envVars := provider.PluginEnvVars(context.Background(), &plugins.Plugin{}) assert.ElementsMatch(t, []string{"GF_VERSION=", "GFAZPL_AZURE_CLOUD=AzureCloud", "GFAZPL_MANAGED_IDENTITY_ENABLED=true", "GFAZPL_MANAGED_IDENTITY_CLIENT_ID=mock_managed_identity_client_id", "GFAZPL_WORKLOAD_IDENTITY_ENABLED=true", @@ -637,212 +740,3 @@ func TestInitalizer_azureEnvVars(t *testing.T) { }, envVars) }) } - -func TestService_GetConfigMap(t *testing.T) { - tcs := []struct { - name string - cfg *config.Cfg - expected map[string]string - }{ - { - name: "Both features and proxy settings enabled", - cfg: &config.Cfg{ - Features: featuremgmt.WithFeatures("feat-2", "feat-500", "feat-1"), - ProxySettings: setting.SecureSocksDSProxySettings{ - Enabled: true, - ShowUI: true, - ClientCert: "c3rt", - ClientKey: "k3y", - RootCA: "ca", - ProxyAddress: "https://proxy.grafana.com", - ServerName: "secureProxy", - AllowInsecure: true, - }, - }, - expected: map[string]string{ - "GF_INSTANCE_FEATURE_TOGGLES_ENABLE": "feat-1,feat-2,feat-500", - "GF_SECURE_SOCKS_DATASOURCE_PROXY_SERVER_ENABLED": "true", - "GF_SECURE_SOCKS_DATASOURCE_PROXY_CLIENT_CERT": "c3rt", - "GF_SECURE_SOCKS_DATASOURCE_PROXY_CLIENT_KEY": "k3y", - "GF_SECURE_SOCKS_DATASOURCE_PROXY_ROOT_CA_CERT": "ca", - "GF_SECURE_SOCKS_DATASOURCE_PROXY_PROXY_ADDRESS": "https://proxy.grafana.com", - "GF_SECURE_SOCKS_DATASOURCE_PROXY_SERVER_NAME": "secureProxy", - "GF_SECURE_SOCKS_DATASOURCE_PROXY_ALLOW_INSECURE": "true", - }, - }, - { - name: "Features enabled but proxy settings disabled", - cfg: &config.Cfg{ - Features: featuremgmt.WithFeatures("feat-2", "feat-500", "feat-1"), - ProxySettings: setting.SecureSocksDSProxySettings{ - Enabled: false, - ShowUI: true, - ClientCert: "c3rt", - ClientKey: "k3y", - RootCA: "ca", - ProxyAddress: "https://proxy.grafana.com", - ServerName: "secureProxy", - }, - }, - expected: map[string]string{ - "GF_INSTANCE_FEATURE_TOGGLES_ENABLE": "feat-1,feat-2,feat-500", - }, - }, - { - name: "Both features and proxy settings disabled", - cfg: &config.Cfg{ - Features: featuremgmt.WithFeatures("feat-2", false), - ProxySettings: setting.SecureSocksDSProxySettings{ - Enabled: false, - ShowUI: true, - ClientCert: "c3rt", - ClientKey: "k3y", - RootCA: "ca", - ProxyAddress: "https://proxy.grafana.com", - ServerName: "secureProxy", - }, - }, - expected: map[string]string{}, - }, - { - name: "Both features and proxy settings empty", - cfg: &config.Cfg{ - Features: nil, - ProxySettings: setting.SecureSocksDSProxySettings{}, - }, - expected: map[string]string{}, - }, - } - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - s := &Service{ - cfg: tc.cfg, - } - require.Equal(t, tc.expected, s.GetConfigMap(context.Background(), "", nil)) - }) - } -} - -func TestService_GetConfigMap_featureToggles(t *testing.T) { - t.Run("Feature toggles list is deterministic", func(t *testing.T) { - tcs := []struct { - enabledFeatures []string - expectedConfig map[string]string - }{ - { - enabledFeatures: nil, - expectedConfig: map[string]string{}, - }, - { - enabledFeatures: []string{}, - expectedConfig: map[string]string{}, - }, - { - enabledFeatures: []string{"A", "B", "C"}, - expectedConfig: map[string]string{"GF_INSTANCE_FEATURE_TOGGLES_ENABLE": "A,B,C"}, - }, - { - enabledFeatures: []string{"C", "B", "A"}, - expectedConfig: map[string]string{"GF_INSTANCE_FEATURE_TOGGLES_ENABLE": "A,B,C"}, - }, - { - enabledFeatures: []string{"b", "a", "c", "d"}, - expectedConfig: map[string]string{"GF_INSTANCE_FEATURE_TOGGLES_ENABLE": "a,b,c,d"}, - }, - } - - for _, tc := range tcs { - s := &Service{ - cfg: &config.Cfg{ - Features: fakes.NewFakeFeatureToggles(tc.enabledFeatures...), - }, - } - require.Equal(t, tc.expectedConfig, s.GetConfigMap(context.Background(), "", nil)) - } - }) -} - -func TestService_GetConfigMap_appURL(t *testing.T) { - t.Run("Uses the configured app URL", func(t *testing.T) { - s := &Service{ - cfg: &config.Cfg{ - GrafanaAppURL: "https://myorg.com/", - }, - } - require.Equal(t, map[string]string{"GF_APP_URL": "https://myorg.com/"}, s.GetConfigMap(context.Background(), "", nil)) - }) -} - -func TestService_GetConfigMap_azure(t *testing.T) { - azSettings := &azsettings.AzureSettings{ - Cloud: azsettings.AzurePublic, - ManagedIdentityEnabled: true, - ManagedIdentityClientId: "mock_managed_identity_client_id", - WorkloadIdentityEnabled: true, - WorkloadIdentitySettings: &azsettings.WorkloadIdentitySettings{ - TenantId: "mock_workload_identity_tenant_id", - ClientId: "mock_workload_identity_client_id", - TokenFile: "mock_workload_identity_token_file", - }, - UserIdentityEnabled: true, - UserIdentityTokenEndpoint: &azsettings.TokenEndpointSettings{ - TokenUrl: "mock_user_identity_token_url", - ClientId: "mock_user_identity_client_id", - ClientSecret: "mock_user_identity_client_secret", - UsernameAssertion: true, - }, - ForwardSettingsPlugins: []string{"grafana-azure-monitor-datasource", "prometheus", "grafana-azure-data-explorer-datasource", "mssql"}, - } - - t.Run("uses the azure settings for an Azure plugin", func(t *testing.T) { - s := &Service{ - cfg: &config.Cfg{ - Azure: azSettings, - }, - } - require.Equal(t, map[string]string{ - "GFAZPL_AZURE_CLOUD": "AzureCloud", "GFAZPL_MANAGED_IDENTITY_ENABLED": "true", - "GFAZPL_MANAGED_IDENTITY_CLIENT_ID": "mock_managed_identity_client_id", - "GFAZPL_WORKLOAD_IDENTITY_ENABLED": "true", - "GFAZPL_WORKLOAD_IDENTITY_TENANT_ID": "mock_workload_identity_tenant_id", - "GFAZPL_WORKLOAD_IDENTITY_CLIENT_ID": "mock_workload_identity_client_id", - "GFAZPL_WORKLOAD_IDENTITY_TOKEN_FILE": "mock_workload_identity_token_file", - "GFAZPL_USER_IDENTITY_ENABLED": "true", - "GFAZPL_USER_IDENTITY_TOKEN_URL": "mock_user_identity_token_url", - "GFAZPL_USER_IDENTITY_CLIENT_ID": "mock_user_identity_client_id", - "GFAZPL_USER_IDENTITY_CLIENT_SECRET": "mock_user_identity_client_secret", - "GFAZPL_USER_IDENTITY_ASSERTION": "username", - }, s.GetConfigMap(context.Background(), "grafana-azure-monitor-datasource", nil)) - }) - - t.Run("does not use the azure settings for a non-Azure plugin", func(t *testing.T) { - s := &Service{ - cfg: &config.Cfg{ - Azure: azSettings, - }, - } - require.Equal(t, map[string]string{}, s.GetConfigMap(context.Background(), "", nil)) - }) - - t.Run("uses the azure settings for a non-Azure user-specified plugin", func(t *testing.T) { - azSettings.ForwardSettingsPlugins = append(azSettings.ForwardSettingsPlugins, "test-datasource") - s := &Service{ - cfg: &config.Cfg{ - Azure: azSettings, - }, - } - require.Equal(t, map[string]string{ - "GFAZPL_AZURE_CLOUD": "AzureCloud", "GFAZPL_MANAGED_IDENTITY_ENABLED": "true", - "GFAZPL_MANAGED_IDENTITY_CLIENT_ID": "mock_managed_identity_client_id", - "GFAZPL_WORKLOAD_IDENTITY_ENABLED": "true", - "GFAZPL_WORKLOAD_IDENTITY_TENANT_ID": "mock_workload_identity_tenant_id", - "GFAZPL_WORKLOAD_IDENTITY_CLIENT_ID": "mock_workload_identity_client_id", - "GFAZPL_WORKLOAD_IDENTITY_TOKEN_FILE": "mock_workload_identity_token_file", - "GFAZPL_USER_IDENTITY_ENABLED": "true", - "GFAZPL_USER_IDENTITY_TOKEN_URL": "mock_user_identity_token_url", - "GFAZPL_USER_IDENTITY_CLIENT_ID": "mock_user_identity_client_id", - "GFAZPL_USER_IDENTITY_CLIENT_SECRET": "mock_user_identity_client_secret", - "GFAZPL_USER_IDENTITY_ASSERTION": "username", - }, s.GetConfigMap(context.Background(), "test-datasource", nil)) - }) -} diff --git a/pkg/services/pluginsintegration/pluginconfig/fakes.go b/pkg/services/pluginsintegration/pluginconfig/fakes.go new file mode 100644 index 0000000000000..c58a8443801a2 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginconfig/fakes.go @@ -0,0 +1,16 @@ +package pluginconfig + +import "context" + +var _ PluginRequestConfigProvider = (*FakePluginRequestConfigProvider)(nil) + +type FakePluginRequestConfigProvider struct{} + +func NewFakePluginRequestConfigProvider() *FakePluginRequestConfigProvider { + return &FakePluginRequestConfigProvider{} +} + +// PluginRequestConfig returns a map of configuration that should be passed in a plugin request. +func (s *FakePluginRequestConfigProvider) PluginRequestConfig(ctx context.Context, pluginID string) map[string]string { + return map[string]string{} +} diff --git a/pkg/services/pluginsintegration/pluginconfig/request.go b/pkg/services/pluginsintegration/pluginconfig/request.go new file mode 100644 index 0000000000000..da7f8a164c8b0 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginconfig/request.go @@ -0,0 +1,160 @@ +package pluginconfig + +import ( + "context" + "slices" + "sort" + "strconv" + "strings" + + "github.com/grafana/grafana-aws-sdk/pkg/awsds" + "github.com/grafana/grafana-azure-sdk-go/azsettings" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" + "github.com/grafana/grafana-plugin-sdk-go/experimental/featuretoggles" +) + +var _ PluginRequestConfigProvider = (*RequestConfigProvider)(nil) + +type PluginRequestConfigProvider interface { + PluginRequestConfig(ctx context.Context, pluginID string) map[string]string +} + +type RequestConfigProvider struct { + cfg *PluginInstanceCfg +} + +func NewRequestConfigProvider(cfg *PluginInstanceCfg) *RequestConfigProvider { + return &RequestConfigProvider{ + cfg: cfg, + } +} + +// PluginRequestConfig returns a map of configuration that should be passed in a plugin request. +// nolint:gocyclo +func (s *RequestConfigProvider) PluginRequestConfig(ctx context.Context, pluginID string) map[string]string { + m := make(map[string]string) + + if s.cfg.GrafanaAppURL != "" { + m[backend.AppURL] = s.cfg.GrafanaAppURL + } + if s.cfg.ConcurrentQueryCount != 0 { + m[backend.ConcurrentQueryCount] = strconv.Itoa(s.cfg.ConcurrentQueryCount) + } + + enabledFeatures := s.cfg.Features.GetEnabled(ctx) + if len(enabledFeatures) > 0 { + features := make([]string, 0, len(enabledFeatures)) + for feat := range enabledFeatures { + features = append(features, feat) + } + sort.Strings(features) + m[featuretoggles.EnabledFeatures] = strings.Join(features, ",") + } + + if slices.Contains[[]string, string](s.cfg.AWSForwardSettingsPlugins, pluginID) { + if !s.cfg.AWSAssumeRoleEnabled { + m[awsds.AssumeRoleEnabledEnvVarKeyName] = "false" + } + if len(s.cfg.AWSAllowedAuthProviders) > 0 { + m[awsds.AllowedAuthProvidersEnvVarKeyName] = strings.Join(s.cfg.AWSAllowedAuthProviders, ",") + } + if s.cfg.AWSExternalId != "" { + m[awsds.GrafanaAssumeRoleExternalIdKeyName] = s.cfg.AWSExternalId + } + if s.cfg.AWSSessionDuration != "" { + m[awsds.SessionDurationEnvVarKeyName] = s.cfg.AWSSessionDuration + } + if s.cfg.AWSListMetricsPageLimit != "" { + m[awsds.ListMetricsPageLimitKeyName] = s.cfg.AWSListMetricsPageLimit + } + } + + if s.cfg.ProxySettings.Enabled { + m[proxy.PluginSecureSocksProxyEnabled] = "true" + m[proxy.PluginSecureSocksProxyClientCert] = s.cfg.ProxySettings.ClientCert + m[proxy.PluginSecureSocksProxyClientKey] = s.cfg.ProxySettings.ClientKey + m[proxy.PluginSecureSocksProxyRootCACert] = s.cfg.ProxySettings.RootCA + m[proxy.PluginSecureSocksProxyProxyAddress] = s.cfg.ProxySettings.ProxyAddress + m[proxy.PluginSecureSocksProxyServerName] = s.cfg.ProxySettings.ServerName + m[proxy.PluginSecureSocksProxyAllowInsecure] = strconv.FormatBool(s.cfg.ProxySettings.AllowInsecure) + } + + // Settings here will be extracted by grafana-azure-sdk-go from the plugin context + if s.cfg.AzureAuthEnabled { + m[azsettings.AzureAuthEnabled] = strconv.FormatBool(s.cfg.AzureAuthEnabled) + } + azureSettings := s.cfg.Azure + if azureSettings != nil && slices.Contains[[]string, string](azureSettings.ForwardSettingsPlugins, pluginID) { + if azureSettings.Cloud != "" { + m[azsettings.AzureCloud] = azureSettings.Cloud + } + + if azureSettings.ManagedIdentityEnabled { + m[azsettings.ManagedIdentityEnabled] = "true" + + if azureSettings.ManagedIdentityClientId != "" { + m[azsettings.ManagedIdentityClientID] = azureSettings.ManagedIdentityClientId + } + } + + if azureSettings.UserIdentityEnabled { + m[azsettings.UserIdentityEnabled] = "true" + + if azureSettings.UserIdentityTokenEndpoint != nil { + if azureSettings.UserIdentityTokenEndpoint.TokenUrl != "" { + m[azsettings.UserIdentityTokenURL] = azureSettings.UserIdentityTokenEndpoint.TokenUrl + } + if azureSettings.UserIdentityTokenEndpoint.ClientId != "" { + m[azsettings.UserIdentityClientID] = azureSettings.UserIdentityTokenEndpoint.ClientId + } + if azureSettings.UserIdentityTokenEndpoint.ClientSecret != "" { + m[azsettings.UserIdentityClientSecret] = azureSettings.UserIdentityTokenEndpoint.ClientSecret + } + if azureSettings.UserIdentityTokenEndpoint.UsernameAssertion { + m[azsettings.UserIdentityAssertion] = "username" + } + } + } + + if azureSettings.WorkloadIdentityEnabled { + m[azsettings.WorkloadIdentityEnabled] = "true" + + if azureSettings.WorkloadIdentitySettings != nil { + if azureSettings.WorkloadIdentitySettings.ClientId != "" { + m[azsettings.WorkloadIdentityClientID] = azureSettings.WorkloadIdentitySettings.ClientId + } + if azureSettings.WorkloadIdentitySettings.TenantId != "" { + m[azsettings.WorkloadIdentityTenantID] = azureSettings.WorkloadIdentitySettings.TenantId + } + if azureSettings.WorkloadIdentitySettings.TokenFile != "" { + m[azsettings.WorkloadIdentityTokenFile] = azureSettings.WorkloadIdentitySettings.TokenFile + } + } + } + } + + if s.cfg.UserFacingDefaultError != "" { + m[backend.UserFacingDefaultError] = s.cfg.UserFacingDefaultError + } + + if s.cfg.DataProxyRowLimit != 0 { + m[backend.SQLRowLimit] = strconv.FormatInt(s.cfg.DataProxyRowLimit, 10) + } + + m[backend.SQLMaxOpenConnsDefault] = strconv.Itoa(s.cfg.SQLDatasourceMaxOpenConnsDefault) + m[backend.SQLMaxIdleConnsDefault] = strconv.Itoa(s.cfg.SQLDatasourceMaxIdleConnsDefault) + m[backend.SQLMaxConnLifetimeSecondsDefault] = strconv.Itoa(s.cfg.SQLDatasourceMaxConnLifetimeDefault) + + if s.cfg.ResponseLimit > 0 { + m[backend.ResponseLimit] = strconv.FormatInt(s.cfg.ResponseLimit, 10) + } + + if s.cfg.SigV4AuthEnabled { + m[awsds.SigV4AuthEnabledEnvVarKeyName] = "true" + m[awsds.SigV4VerboseLoggingEnvVarKeyName] = strconv.FormatBool(s.cfg.SigV4VerboseLogging) + } + + return m +} diff --git a/pkg/services/pluginsintegration/pluginconfig/request_test.go b/pkg/services/pluginsintegration/pluginconfig/request_test.go new file mode 100644 index 0000000000000..409958b2876f8 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginconfig/request_test.go @@ -0,0 +1,398 @@ +package pluginconfig + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana-azure-sdk-go/azsettings" + + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" +) + +func TestRequestConfigProvider_PluginRequestConfig_Defaults(t *testing.T) { + cfg := setting.NewCfg() + pCfg, err := ProvidePluginInstanceConfig(cfg, setting.ProvideProvider(cfg), featuremgmt.WithFeatures()) + require.NoError(t, err) + + p := NewRequestConfigProvider(pCfg) + require.Equal(t, map[string]string{ + "GF_SQL_MAX_OPEN_CONNS_DEFAULT": "0", + "GF_SQL_MAX_IDLE_CONNS_DEFAULT": "0", + "GF_SQL_MAX_CONN_LIFETIME_SECONDS_DEFAULT": "0", + }, p.PluginRequestConfig(context.Background(), "")) +} + +func TestRequestConfigProvider_PluginRequestConfig(t *testing.T) { + tcs := []struct { + name string + cfg *PluginInstanceCfg + expected map[string]string + }{ + { + name: "Both features and proxy settings enabled", + cfg: &PluginInstanceCfg{ + ProxySettings: setting.SecureSocksDSProxySettings{ + Enabled: true, + ShowUI: true, + ClientCert: "c3rt", + ClientKey: "k3y", + RootCA: "ca", + ProxyAddress: "https://proxy.grafana.com", + ServerName: "secureProxy", + AllowInsecure: true, + }, + Features: featuremgmt.WithFeatures("feat-2", "feat-500", "feat-1"), + }, + expected: map[string]string{ + "GF_INSTANCE_FEATURE_TOGGLES_ENABLE": "feat-1,feat-2,feat-500", + "GF_SECURE_SOCKS_DATASOURCE_PROXY_SERVER_ENABLED": "true", + "GF_SECURE_SOCKS_DATASOURCE_PROXY_CLIENT_CERT": "c3rt", + "GF_SECURE_SOCKS_DATASOURCE_PROXY_CLIENT_KEY": "k3y", + "GF_SECURE_SOCKS_DATASOURCE_PROXY_ROOT_CA_CERT": "ca", + "GF_SECURE_SOCKS_DATASOURCE_PROXY_PROXY_ADDRESS": "https://proxy.grafana.com", + "GF_SECURE_SOCKS_DATASOURCE_PROXY_SERVER_NAME": "secureProxy", + "GF_SECURE_SOCKS_DATASOURCE_PROXY_ALLOW_INSECURE": "true", + }, + }, + { + name: "Features enabled but proxy settings disabled", + cfg: &PluginInstanceCfg{ + ProxySettings: setting.SecureSocksDSProxySettings{ + Enabled: false, + ShowUI: true, + ClientCert: "c3rt", + ClientKey: "k3y", + RootCA: "ca", + ProxyAddress: "https://proxy.grafana.com", + ServerName: "secureProxy", + }, + Features: featuremgmt.WithFeatures("feat-2", "feat-500", "feat-1"), + }, + expected: map[string]string{ + "GF_INSTANCE_FEATURE_TOGGLES_ENABLE": "feat-1,feat-2,feat-500", + }, + }, + { + name: "Both features and proxy settings disabled", + cfg: &PluginInstanceCfg{ + ProxySettings: setting.SecureSocksDSProxySettings{ + Enabled: false, + ShowUI: true, + ClientCert: "c3rt", + ClientKey: "k3y", + RootCA: "ca", + ProxyAddress: "https://proxy.grafana.com", + ServerName: "secureProxy", + }, + Features: featuremgmt.WithFeatures("feat-2", false), + }, + expected: map[string]string{}, + }, + { + name: "Both features and proxy settings empty", + cfg: &PluginInstanceCfg{ + ProxySettings: setting.SecureSocksDSProxySettings{}, + Features: featuremgmt.WithFeatures(), + }, + expected: map[string]string{}, + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + p := NewRequestConfigProvider(tc.cfg) + require.Subset(t, p.PluginRequestConfig(context.Background(), ""), tc.expected) + }) + } +} + +func TestRequestConfigProvider_PluginRequestConfig_featureToggles(t *testing.T) { + t.Run("Feature toggles list is deterministic", func(t *testing.T) { + tcs := []struct { + features featuremgmt.FeatureToggles + expectedConfig map[string]string + }{ + { + features: featuremgmt.WithFeatures(), + expectedConfig: map[string]string{}, + }, + { + features: featuremgmt.WithFeatures("A", "B", "C"), + expectedConfig: map[string]string{"GF_INSTANCE_FEATURE_TOGGLES_ENABLE": "A,B,C"}, + }, + { + features: featuremgmt.WithFeatures("C", "B", "A"), + expectedConfig: map[string]string{"GF_INSTANCE_FEATURE_TOGGLES_ENABLE": "A,B,C"}, + }, + { + features: featuremgmt.WithFeatures("b", "a", "c", "d"), + expectedConfig: map[string]string{"GF_INSTANCE_FEATURE_TOGGLES_ENABLE": "a,b,c,d"}, + }, + } + + for _, tc := range tcs { + cfg := setting.NewCfg() + pCfg, err := ProvidePluginInstanceConfig(cfg, setting.ProvideProvider(cfg), tc.features) + require.NoError(t, err) + + p := NewRequestConfigProvider(pCfg) + require.Subset(t, p.PluginRequestConfig(context.Background(), ""), tc.expectedConfig) + } + }) +} + +func TestRequestConfigProvider_PluginRequestConfig_appURL(t *testing.T) { + t.Run("Uses the configured app URL", func(t *testing.T) { + cfg := setting.NewCfg() + cfg.AppURL = "https://myorg.com/" + + pCfg, err := ProvidePluginInstanceConfig(cfg, setting.ProvideProvider(cfg), featuremgmt.WithFeatures()) + require.NoError(t, err) + + p := NewRequestConfigProvider(pCfg) + require.Subset(t, p.PluginRequestConfig(context.Background(), ""), map[string]string{"GF_APP_URL": "https://myorg.com/"}) + }) +} + +func TestRequestConfigProvider_PluginRequestConfig_SQL(t *testing.T) { + t.Run("Uses the configured values", func(t *testing.T) { + cfg := setting.NewCfg() + cfg.DataProxyRowLimit = 23 + cfg.SqlDatasourceMaxOpenConnsDefault = 24 + cfg.SqlDatasourceMaxIdleConnsDefault = 25 + cfg.SqlDatasourceMaxConnLifetimeDefault = 26 + + pCfg, err := ProvidePluginInstanceConfig(cfg, setting.ProvideProvider(cfg), featuremgmt.WithFeatures()) + require.NoError(t, err) + + p := NewRequestConfigProvider(pCfg) + require.Subset(t, p.PluginRequestConfig(context.Background(), ""), map[string]string{ + "GF_SQL_ROW_LIMIT": "23", + "GF_SQL_MAX_OPEN_CONNS_DEFAULT": "24", + "GF_SQL_MAX_IDLE_CONNS_DEFAULT": "25", + "GF_SQL_MAX_CONN_LIFETIME_SECONDS_DEFAULT": "26", + }) + }) + + t.Run("Uses the configured max-default-values, even when they are zero", func(t *testing.T) { + cfg := setting.NewCfg() + cfg.SqlDatasourceMaxOpenConnsDefault = 0 + cfg.SqlDatasourceMaxIdleConnsDefault = 0 + cfg.SqlDatasourceMaxConnLifetimeDefault = 0 + + pCfg, err := ProvidePluginInstanceConfig(cfg, setting.ProvideProvider(cfg), featuremgmt.WithFeatures()) + require.NoError(t, err) + + p := NewRequestConfigProvider(pCfg) + require.Equal(t, map[string]string{ + "GF_SQL_MAX_OPEN_CONNS_DEFAULT": "0", + "GF_SQL_MAX_IDLE_CONNS_DEFAULT": "0", + "GF_SQL_MAX_CONN_LIFETIME_SECONDS_DEFAULT": "0", + }, p.PluginRequestConfig(context.Background(), "")) + }) +} + +func TestRequestConfigProvider_PluginRequestConfig_concurrentQueryCount(t *testing.T) { + t.Run("Uses the configured concurrent query count", func(t *testing.T) { + cfg := setting.NewCfg() + cfg.ConcurrentQueryCount = 42 + + pCfg, err := ProvidePluginInstanceConfig(cfg, setting.ProvideProvider(cfg), featuremgmt.WithFeatures()) + require.NoError(t, err) + + p := NewRequestConfigProvider(pCfg) + require.Subset(t, p.PluginRequestConfig(context.Background(), ""), map[string]string{"GF_CONCURRENT_QUERY_COUNT": "42"}) + }) + + t.Run("Doesn't set the concurrent query count if it is not in the config", func(t *testing.T) { + cfg := setting.NewCfg() + pCfg, err := ProvidePluginInstanceConfig(cfg, setting.ProvideProvider(cfg), featuremgmt.WithFeatures()) + require.NoError(t, err) + + p := NewRequestConfigProvider(pCfg) + require.NotContains(t, p.PluginRequestConfig(context.Background(), ""), "GF_CONCURRENT_QUERY_COUNT") + }) + + t.Run("Doesn't set the concurrent query count if it is zero", func(t *testing.T) { + cfg := setting.NewCfg() + cfg.ConcurrentQueryCount = 0 + + pCfg, err := ProvidePluginInstanceConfig(cfg, setting.ProvideProvider(cfg), featuremgmt.WithFeatures()) + require.NoError(t, err) + + p := NewRequestConfigProvider(pCfg) + require.NotContains(t, p.PluginRequestConfig(context.Background(), ""), "GF_CONCURRENT_QUERY_COUNT") + }) +} + +func TestRequestConfigProvider_PluginRequestConfig_azureAuthEnabled(t *testing.T) { + t.Run("Uses the configured azureAuthEnabled", func(t *testing.T) { + cfg := &PluginInstanceCfg{ + AzureAuthEnabled: true, + Features: featuremgmt.WithFeatures(), + } + + p := NewRequestConfigProvider(cfg) + require.Subset(t, p.PluginRequestConfig(context.Background(), ""), map[string]string{"GFAZPL_AZURE_AUTH_ENABLED": "true"}) + }) + + t.Run("Doesn't set the azureAuthEnabled if it is not in the config", func(t *testing.T) { + cfg := &PluginInstanceCfg{ + Features: featuremgmt.WithFeatures(), + } + + p := NewRequestConfigProvider(cfg) + require.NotContains(t, p.PluginRequestConfig(context.Background(), ""), "GFAZPL_AZURE_AUTH_ENABLED") + }) + + t.Run("Doesn't set the azureAuthEnabled if it is false", func(t *testing.T) { + cfg := &PluginInstanceCfg{ + AzureAuthEnabled: false, + Features: featuremgmt.WithFeatures(), + } + + p := NewRequestConfigProvider(cfg) + require.NotContains(t, p.PluginRequestConfig(context.Background(), ""), "GFAZPL_AZURE_AUTH_ENABLED") + }) +} + +func TestRequestConfigProvider_PluginRequestConfig_azure(t *testing.T) { + azSettings := &azsettings.AzureSettings{ + Cloud: azsettings.AzurePublic, + ManagedIdentityEnabled: true, + ManagedIdentityClientId: "mock_managed_identity_client_id", + WorkloadIdentityEnabled: true, + WorkloadIdentitySettings: &azsettings.WorkloadIdentitySettings{ + TenantId: "mock_workload_identity_tenant_id", + ClientId: "mock_workload_identity_client_id", + TokenFile: "mock_workload_identity_token_file", + }, + UserIdentityEnabled: true, + UserIdentityTokenEndpoint: &azsettings.TokenEndpointSettings{ + TokenUrl: "mock_user_identity_token_url", + ClientId: "mock_user_identity_client_id", + ClientSecret: "mock_user_identity_client_secret", + UsernameAssertion: true, + }, + ForwardSettingsPlugins: []string{"grafana-azure-monitor-datasource", "prometheus", "grafana-azure-data-explorer-datasource", "mssql"}, + } + + t.Run("uses the azure settings for an Azure plugin", func(t *testing.T) { + cfg := setting.NewCfg() + cfg.Azure = azSettings + + pCfg, err := ProvidePluginInstanceConfig(cfg, setting.ProvideProvider(cfg), featuremgmt.WithFeatures()) + require.NoError(t, err) + + p := NewRequestConfigProvider(pCfg) + require.Subset(t, p.PluginRequestConfig(context.Background(), "grafana-azure-monitor-datasource"), map[string]string{ + "GFAZPL_AZURE_CLOUD": "AzureCloud", "GFAZPL_MANAGED_IDENTITY_ENABLED": "true", + "GFAZPL_MANAGED_IDENTITY_CLIENT_ID": "mock_managed_identity_client_id", + "GFAZPL_WORKLOAD_IDENTITY_ENABLED": "true", + "GFAZPL_WORKLOAD_IDENTITY_TENANT_ID": "mock_workload_identity_tenant_id", + "GFAZPL_WORKLOAD_IDENTITY_CLIENT_ID": "mock_workload_identity_client_id", + "GFAZPL_WORKLOAD_IDENTITY_TOKEN_FILE": "mock_workload_identity_token_file", + "GFAZPL_USER_IDENTITY_ENABLED": "true", + "GFAZPL_USER_IDENTITY_TOKEN_URL": "mock_user_identity_token_url", + "GFAZPL_USER_IDENTITY_CLIENT_ID": "mock_user_identity_client_id", + "GFAZPL_USER_IDENTITY_CLIENT_SECRET": "mock_user_identity_client_secret", + "GFAZPL_USER_IDENTITY_ASSERTION": "username", + }) + }) + + t.Run("does not use the azure settings for a non-Azure plugin", func(t *testing.T) { + cfg := setting.NewCfg() + cfg.Azure = azSettings + + pCfg, err := ProvidePluginInstanceConfig(cfg, setting.ProvideProvider(cfg), featuremgmt.WithFeatures()) + require.NoError(t, err) + + p := NewRequestConfigProvider(pCfg) + m := p.PluginRequestConfig(context.Background(), "") + require.NotContains(t, m, "GFAZPL_AZURE_CLOUD") + require.NotContains(t, m, "GFAZPL_MANAGED_IDENTITY_ENABLED") + require.NotContains(t, m, "GFAZPL_MANAGED_IDENTITY_CLIENT_ID") + require.NotContains(t, m, "GFAZPL_WORKLOAD_IDENTITY_ENABLED") + require.NotContains(t, m, "GFAZPL_WORKLOAD_IDENTITY_TENANT_ID") + require.NotContains(t, m, "GFAZPL_WORKLOAD_IDENTITY_CLIENT_ID") + require.NotContains(t, m, "GFAZPL_WORKLOAD_IDENTITY_TOKEN_FILE") + require.NotContains(t, m, "GFAZPL_USER_IDENTITY_ENABLED") + require.NotContains(t, m, "GFAZPL_USER_IDENTITY_TOKEN_URL") + require.NotContains(t, m, "GFAZPL_USER_IDENTITY_CLIENT_ID") + require.NotContains(t, m, "GFAZPL_USER_IDENTITY_CLIENT_SECRET") + require.NotContains(t, m, "GFAZPL_USER_IDENTITY_ASSERTION") + }) + + t.Run("uses the azure settings for a non-Azure user-specified plugin", func(t *testing.T) { + azSettings.ForwardSettingsPlugins = append(azSettings.ForwardSettingsPlugins, "test-datasource") + cfg := setting.NewCfg() + cfg.Azure = azSettings + + pCfg, err := ProvidePluginInstanceConfig(cfg, setting.ProvideProvider(cfg), featuremgmt.WithFeatures()) + require.NoError(t, err) + + p := NewRequestConfigProvider(pCfg) + require.Subset(t, p.PluginRequestConfig(context.Background(), "test-datasource"), map[string]string{ + "GFAZPL_AZURE_CLOUD": "AzureCloud", "GFAZPL_MANAGED_IDENTITY_ENABLED": "true", + "GFAZPL_MANAGED_IDENTITY_CLIENT_ID": "mock_managed_identity_client_id", + "GFAZPL_WORKLOAD_IDENTITY_ENABLED": "true", + "GFAZPL_WORKLOAD_IDENTITY_TENANT_ID": "mock_workload_identity_tenant_id", + "GFAZPL_WORKLOAD_IDENTITY_CLIENT_ID": "mock_workload_identity_client_id", + "GFAZPL_WORKLOAD_IDENTITY_TOKEN_FILE": "mock_workload_identity_token_file", + "GFAZPL_USER_IDENTITY_ENABLED": "true", + "GFAZPL_USER_IDENTITY_TOKEN_URL": "mock_user_identity_token_url", + "GFAZPL_USER_IDENTITY_CLIENT_ID": "mock_user_identity_client_id", + "GFAZPL_USER_IDENTITY_CLIENT_SECRET": "mock_user_identity_client_secret", + "GFAZPL_USER_IDENTITY_ASSERTION": "username", + }) + }) +} + +func TestRequestConfigProvider_PluginRequestConfig_aws(t *testing.T) { + cfg := &PluginInstanceCfg{ + Features: featuremgmt.WithFeatures(), + } + + cfg.AWSAssumeRoleEnabled = false + cfg.AWSAllowedAuthProviders = []string{"grafana_assume_role", "keys"} + cfg.AWSExternalId = "mock_external_id" + cfg.AWSSessionDuration = "10m" + cfg.AWSListMetricsPageLimit = "100" + cfg.AWSForwardSettingsPlugins = []string{"cloudwatch", "prometheus", "elasticsearch"} + + p := NewRequestConfigProvider(cfg) + + t.Run("uses the aws settings for an AWS plugin", func(t *testing.T) { + require.Subset(t, p.PluginRequestConfig(context.Background(), "cloudwatch"), map[string]string{ + "AWS_AUTH_AssumeRoleEnabled": "false", + "AWS_AUTH_AllowedAuthProviders": "grafana_assume_role,keys", + "AWS_AUTH_EXTERNAL_ID": "mock_external_id", + "AWS_AUTH_SESSION_DURATION": "10m", + "AWS_CW_LIST_METRICS_PAGE_LIMIT": "100", + }) + }) + + t.Run("does not use the aws settings for a non-aws plugin", func(t *testing.T) { + m := p.PluginRequestConfig(context.Background(), "") + require.NotContains(t, m, "AWS_AUTH_AssumeRoleEnabled") + require.NotContains(t, m, "AWS_AUTH_AllowedAuthProviders") + require.NotContains(t, m, "AWS_AUTH_EXTERNAL_ID") + require.NotContains(t, m, "AWS_AUTH_SESSION_DURATION") + require.NotContains(t, m, "AWS_CW_LIST_METRICS_PAGE_LIMIT") + }) + + t.Run("uses the aws settings for a non-aws user-specified plugin", func(t *testing.T) { + cfg.AWSForwardSettingsPlugins = append(cfg.AWSForwardSettingsPlugins, "test-datasource") + + p = NewRequestConfigProvider(cfg) + require.Subset(t, p.PluginRequestConfig(context.Background(), "test-datasource"), map[string]string{ + "AWS_AUTH_AssumeRoleEnabled": "false", + "AWS_AUTH_AllowedAuthProviders": "grafana_assume_role,keys", + "AWS_AUTH_EXTERNAL_ID": "mock_external_id", + "AWS_AUTH_SESSION_DURATION": "10m", + "AWS_CW_LIST_METRICS_PAGE_LIMIT": "100", + }) + }) +} diff --git a/pkg/services/pluginsintegration/config/tracing.go b/pkg/services/pluginsintegration/pluginconfig/tracing.go similarity index 97% rename from pkg/services/pluginsintegration/config/tracing.go rename to pkg/services/pluginsintegration/pluginconfig/tracing.go index 6dcc3b4abdcf5..0eada776cebc8 100644 --- a/pkg/services/pluginsintegration/config/tracing.go +++ b/pkg/services/pluginsintegration/pluginconfig/tracing.go @@ -1,4 +1,4 @@ -package config +package pluginconfig import ( "fmt" diff --git a/pkg/services/pluginsintegration/config/tracing_test.go b/pkg/services/pluginsintegration/pluginconfig/tracing_test.go similarity index 98% rename from pkg/services/pluginsintegration/config/tracing_test.go rename to pkg/services/pluginsintegration/pluginconfig/tracing_test.go index 6f5367da02c23..b7ad8333eff10 100644 --- a/pkg/services/pluginsintegration/config/tracing_test.go +++ b/pkg/services/pluginsintegration/pluginconfig/tracing_test.go @@ -1,4 +1,4 @@ -package config +package pluginconfig import ( "testing" diff --git a/pkg/services/pluginsintegration/plugincontext/plugincontext.go b/pkg/services/pluginsintegration/plugincontext/plugincontext.go index f96026c6614ac..4d601b7b34f8d 100644 --- a/pkg/services/pluginsintegration/plugincontext/plugincontext.go +++ b/pkg/services/pluginsintegration/plugincontext/plugincontext.go @@ -11,14 +11,14 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/useragent" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/config" - "github.com/grafana/grafana/pkg/plugins/envvars" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/pluginsintegration/adapters" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" @@ -30,32 +30,34 @@ const ( ) func ProvideService(cfg *setting.Cfg, cacheService *localcache.CacheService, pluginStore pluginstore.Store, - dataSourceService datasources.DataSourceService, pluginSettingsService pluginsettings.Service, - licensing plugins.Licensing, pCfg *config.Cfg) *Provider { + dataSourceCache datasources.CacheService, dataSourceService datasources.DataSourceService, + pluginSettingsService pluginsettings.Service, pluginRequestConfigProvider pluginconfig.PluginRequestConfigProvider) *Provider { return &Provider{ - cfg: cfg, - cacheService: cacheService, - pluginStore: pluginStore, - dataSourceService: dataSourceService, - pluginSettingsService: pluginSettingsService, - pluginEnvVars: envvars.NewProvider(pCfg, licensing), - logger: log.New("plugin.context"), + cfg: cfg, + cacheService: cacheService, + pluginStore: pluginStore, + dataSourceCache: dataSourceCache, + dataSourceService: dataSourceService, + pluginSettingsService: pluginSettingsService, + pluginRequestConfigProvider: pluginRequestConfigProvider, + logger: log.New("plugin.context"), } } type Provider struct { - cfg *setting.Cfg - pluginEnvVars *envvars.Service - cacheService *localcache.CacheService - pluginStore pluginstore.Store - dataSourceService datasources.DataSourceService - pluginSettingsService pluginsettings.Service - logger log.Logger + cfg *setting.Cfg + pluginRequestConfigProvider pluginconfig.PluginRequestConfigProvider + cacheService *localcache.CacheService + pluginStore pluginstore.Store + dataSourceCache datasources.CacheService + dataSourceService datasources.DataSourceService + pluginSettingsService pluginsettings.Service + logger log.Logger } -// Get allows getting plugin context by its ID. If datasourceUID is not empty string -// then PluginContext.DataSourceInstanceSettings will be resolved and appended to -// returned context. +// Get will retrieve plugin context by the provided pluginID and orgID. +// This is intended to be used for app plugin requests. +// PluginContext.AppInstanceSettings will be resolved and appended to the returned context. // Note: identity.Requester can be nil. func (p *Provider) Get(ctx context.Context, pluginID string, user identity.Requester, orgID int64) (backend.PluginContext, error) { plugin, exists := p.pluginStore.Plugin(ctx, pluginID) @@ -80,7 +82,7 @@ func (p *Provider) Get(ctx context.Context, pluginID string, user identity.Reque pCtx.AppInstanceSettings = appSettings } - settings := p.pluginEnvVars.GetConfigMap(ctx, pluginID, plugin.ExternalService) + settings := p.pluginRequestConfigProvider.PluginRequestConfig(ctx, pluginID) pCtx.GrafanaConfig = backend.NewGrafanaCfg(settings) ua, err := useragent.New(p.cfg.BuildVersion, runtime.GOOS, runtime.GOARCH) @@ -92,9 +94,10 @@ func (p *Provider) Get(ctx context.Context, pluginID string, user identity.Reque return pCtx, nil } -// GetWithDataSource allows getting plugin context by its ID and PluginContext.DataSourceInstanceSettings will be -// resolved and appended to the returned context. -// Note: *user.SignedInUser can be nil. +// GetWithDataSource will retrieve plugin context by the provided pluginID and datasource. +// This is intended to be used for datasource plugin requests. +// PluginContext.DataSourceInstanceSettings will be resolved and appended to the returned context. +// Note: identity.Requester can be nil. func (p *Provider) GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error) { plugin, exists := p.pluginStore.Plugin(ctx, pluginID) if !exists { @@ -116,7 +119,55 @@ func (p *Provider) GetWithDataSource(ctx context.Context, pluginID string, user } pCtx.DataSourceInstanceSettings = datasourceSettings - settings := p.pluginEnvVars.GetConfigMap(ctx, pluginID, plugin.ExternalService) + settings := p.pluginRequestConfigProvider.PluginRequestConfig(ctx, pluginID) + pCtx.GrafanaConfig = backend.NewGrafanaCfg(settings) + + ua, err := useragent.New(p.cfg.BuildVersion, runtime.GOOS, runtime.GOARCH) + if err != nil { + p.logger.Warn("Could not create user agent", "error", err) + } + pCtx.UserAgent = ua + + return pCtx, nil +} + +func (p *Provider) GetDataSourceInstanceSettings(ctx context.Context, uid string) (*backend.DataSourceInstanceSettings, error) { + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } + ds, err := p.dataSourceCache.GetDatasourceByUID(ctx, uid, user, false) + if err != nil { + return nil, err + } + return adapters.ModelToInstanceSettings(ds, p.decryptSecureJsonDataFn(ctx)) +} + +// PluginContextForDataSource will retrieve plugin context by the provided pluginID and datasource UID / K8s name. +// This is intended to be used for datasource API server plugin requests. +func (p *Provider) PluginContextForDataSource(ctx context.Context, datasourceSettings *backend.DataSourceInstanceSettings) (backend.PluginContext, error) { + pluginID := datasourceSettings.Type + plugin, exists := p.pluginStore.Plugin(ctx, pluginID) + if !exists { + return backend.PluginContext{}, plugins.ErrPluginNotRegistered + } + + user, err := appcontext.User(ctx) + if err != nil { + return backend.PluginContext{}, err + } + pCtx := backend.PluginContext{ + PluginID: plugin.ID, + PluginVersion: plugin.Info.Version, + } + if user != nil && !user.IsNil() { + pCtx.OrgID = user.GetOrgID() + pCtx.User = adapters.BackendUserFromSignedInUser(user) + } + + pCtx.DataSourceInstanceSettings = datasourceSettings + + settings := p.pluginRequestConfigProvider.PluginRequestConfig(ctx, pluginID) pCtx.GrafanaConfig = backend.NewGrafanaCfg(settings) ua, err := useragent.New(p.cfg.BuildVersion, runtime.GOOS, runtime.GOARCH) diff --git a/pkg/services/pluginsintegration/plugincontext/plugincontext_test.go b/pkg/services/pluginsintegration/plugincontext/plugincontext_test.go index 9d4c750ff2c59..feec2e53db072 100644 --- a/pkg/services/pluginsintegration/plugincontext/plugincontext_test.go +++ b/pkg/services/pluginsintegration/plugincontext/plugincontext_test.go @@ -10,11 +10,11 @@ import ( "github.com/grafana/grafana/pkg/infra/db/dbtest" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/config" pluginFakes "github.com/grafana/grafana/pkg/plugins/manager/fakes" "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/services/datasources" fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" @@ -42,8 +42,8 @@ func TestGet(t *testing.T) { ds := &fakeDatasources.FakeDataSourceService{} db := &dbtest.FakeDB{ExpectedError: pluginsettings.ErrPluginSettingNotFound} pcp := plugincontext.ProvideService(cfg, localcache.ProvideService(), - pluginstore.New(preg, &pluginFakes.FakeLoader{}), - ds, pluginSettings.ProvideService(db, secretstest.NewFakeSecretsService()), pluginFakes.NewFakeLicensingService(), &config.Cfg{}, + pluginstore.New(preg, &pluginFakes.FakeLoader{}), &fakeDatasources.FakeCacheService{}, + ds, pluginSettings.ProvideService(db, secretstest.NewFakeSecretsService()), pluginconfig.NewFakePluginRequestConfigProvider(), ) identity := &user.SignedInUser{OrgID: int64(1), Login: "admin"} diff --git a/pkg/services/pluginsintegration/pluginexternal/check.go b/pkg/services/pluginsintegration/pluginexternal/check.go new file mode 100644 index 0000000000000..f96f60653b20a --- /dev/null +++ b/pkg/services/pluginsintegration/pluginexternal/check.go @@ -0,0 +1,43 @@ +package pluginexternal + +import ( + "context" + + "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" + "github.com/grafana/grafana/pkg/setting" +) + +type Service struct { + cfg *setting.Cfg + logger log.Logger + pluginStore pluginstore.Store +} + +func ProvideService( + cfg *setting.Cfg, pluginStore pluginstore.Store, +) (*Service, error) { + logger := log.New("datasources") + s := &Service{ + cfg: cfg, + logger: logger, + pluginStore: pluginStore, + } + return s, nil +} + +func (s *Service) Run(ctx context.Context) error { + s.validateExternal() + return ctx.Err() +} + +func (s *Service) validateExternal() { + for pluginID, pluginCfg := range s.cfg.PluginSettings { + if pluginCfg["as_external"] == "true" { + _, exists := s.pluginStore.Plugin(context.Background(), pluginID) + if !exists { + s.logger.Error("Core plugin expected to be loaded as external, but it is missing", "pluginID", pluginID) + } + } + } +} diff --git a/pkg/services/pluginsintegration/pluginexternal/check_test.go b/pkg/services/pluginsintegration/pluginexternal/check_test.go new file mode 100644 index 0000000000000..68cfb68dcfb5e --- /dev/null +++ b/pkg/services/pluginsintegration/pluginexternal/check_test.go @@ -0,0 +1,53 @@ +package pluginexternal + +import ( + "testing" + + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" +) + +func TestService_validateExternal(t *testing.T) { + cfg := setting.NewCfg() + cfg.PluginSettings = setting.PluginSettings{ + "grafana-testdata-datasource": map[string]string{ + "as_external": "true", + }, + } + + t.Run("should not log error if core plugin is loaded as external", func(t *testing.T) { + l := log.NewTestLogger() + s := &Service{ + cfg: cfg, + logger: l, + pluginStore: &pluginstore.FakePluginStore{ + PluginList: []pluginstore.Plugin{ + { + JSONData: plugins.JSONData{ + ID: "grafana-testdata-datasource", + }, + }, + }, + }, + } + s.validateExternal() + require.Equal(t, l.ErrorLogs.Calls, 0) + }) + + t.Run("should log error if a core plugin is missing", func(t *testing.T) { + l := log.NewTestLogger() + s := &Service{ + cfg: cfg, + logger: l, + pluginStore: &pluginstore.FakePluginStore{ + PluginList: []pluginstore.Plugin{}, + }, + } + s.validateExternal() + require.Equal(t, l.ErrorLogs.Calls, 1) + require.Contains(t, l.ErrorLogs.Message, "Core plugin expected to be loaded as external") + }) +} diff --git a/pkg/services/pluginsintegration/plugins_integration_test.go b/pkg/services/pluginsintegration/plugins_integration_test.go index 7cff8e07a0656..8cdc1217c816f 100644 --- a/pkg/services/pluginsintegration/plugins_integration_test.go +++ b/pkg/services/pluginsintegration/plugins_integration_test.go @@ -17,12 +17,11 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" - "github.com/grafana/grafana/pkg/plugins/manager/registry" - "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/searchV2" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" cloudmonitoring "github.com/grafana/grafana/pkg/tsdb/cloud-monitoring" "github.com/grafana/grafana/pkg/tsdb/cloudwatch" @@ -42,6 +41,10 @@ import ( "github.com/grafana/grafana/pkg/tsdb/tempo" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationPluginManager(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -52,52 +55,42 @@ func TestIntegrationPluginManager(t *testing.T) { bundledPluginsPath, err := filepath.Abs("../../../plugins-bundled/internal") require.NoError(t, err) - // We use the raw config here as it forms the basis for the setting.Provider implementation - // The plugin manager also relies directly on the setting.Cfg struct to provide Grafana specific - // properties such as the loading paths - raw, err := ini.Load([]byte(` - app_mode = production - - [plugin.test-app] - path=../../plugins/manager/testdata/test-app - - [plugin.test-panel] - not=included - `), - ) - require.NoError(t, err) - features := featuremgmt.WithFeatures() cfg := &setting.Cfg{ - Raw: raw, + Raw: ini.Empty(), StaticRootPath: staticRootPath, BundledPluginsPath: bundledPluginsPath, Azure: &azsettings.AzureSettings{}, - - // nolint:staticcheck - IsFeatureToggleEnabled: features.IsEnabledGlobally, + PluginSettings: map[string]map[string]string{ + "test-app": { + "path": "../../plugins/manager/testdata/test-app", + }, + "test-panel": { + "not": "included", + }, + }, } tracer := tracing.InitializeTracerForTest() hcp := httpclient.NewProvider() - am := azuremonitor.ProvideService(cfg, hcp, features) - cw := cloudwatch.ProvideService(cfg, hcp, features) - cm := cloudmonitoring.ProvideService(hcp, tracer) + am := azuremonitor.ProvideService(hcp) + cw := cloudwatch.ProvideService(hcp) + cm := cloudmonitoring.ProvideService(hcp) es := elasticsearch.ProvideService(hcp, tracer) grap := graphite.ProvideService(hcp, tracer) idb := influxdb.ProvideService(hcp, features) lk := loki.ProvideService(hcp, features, tracer) otsdb := opentsdb.ProvideService(hcp) - pr := prometheus.ProvideService(hcp, cfg, features) + pr := prometheus.ProvideService(hcp) tmpo := tempo.ProvideService(hcp) td := testdatasource.ProvideService() pg := postgres.ProvideService(cfg) - my := mysql.ProvideService(cfg, hcp) + my := mysql.ProvideService() ms := mssql.ProvideService(cfg) sv2 := searchV2.ProvideService(cfg, db.InitTestDB(t), nil, nil, tracer, features, nil, nil, nil) graf := grafanads.ProvideService(sv2, nil) - pyroscope := pyroscope.ProvideService(hcp, acimpl.ProvideAccessControl(cfg)) + pyroscope := pyroscope.ProvideService(hcp) parca := parca.ProvideService(hcp) coreRegistry := coreplugin.ProvideCoreRegistry(tracing.InitializeTracerForTest(), am, cw, cm, es, grap, idb, lk, otsdb, pr, tmpo, td, pg, my, ms, graf, pyroscope, parca) @@ -105,8 +98,7 @@ func TestIntegrationPluginManager(t *testing.T) { ctx := context.Background() verifyCorePluginCatalogue(t, ctx, testCtx.PluginStore) - verifyBundledPlugins(t, ctx, testCtx.PluginStore) - verifyPluginStaticRoutes(t, ctx, testCtx.PluginStore, testCtx.PluginRegistry) + verifyPluginStaticRoutes(t, ctx, testCtx.PluginStore, testCtx.PluginStore) verifyBackendProcesses(t, testCtx.PluginRegistry.Plugins(ctx)) verifyPluginQuery(t, ctx, testCtx.PluginClient) } @@ -140,7 +132,6 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *pluginstor t.Helper() expPanels := map[string]struct{}{ - "alertGroups": {}, "alertlist": {}, "annolist": {}, "barchart": {}, @@ -177,8 +168,8 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *pluginstor expDataSources := map[string]struct{}{ "cloudwatch": {}, - "stackdriver": {}, "grafana-azure-monitor-datasource": {}, + "stackdriver": {}, "elasticsearch": {}, "graphite": {}, "influxdb": {}, @@ -193,7 +184,6 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *pluginstor "grafana": {}, "alertmanager": {}, "dashboard": {}, - "input": {}, "jaeger": {}, "mixed": {}, "zipkin": {}, @@ -235,45 +225,17 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *pluginstor require.Equal(t, len(expPanels)+len(expDataSources)+len(expApps), len(ps.Plugins(ctx))) } -func verifyBundledPlugins(t *testing.T, ctx context.Context, ps *pluginstore.Service) { - t.Helper() - - dsPlugins := make(map[string]struct{}) - for _, p := range ps.Plugins(ctx, plugins.TypeDataSource) { - dsPlugins[p.ID] = struct{}{} - } - - inputPlugin, exists := ps.Plugin(ctx, "input") - require.True(t, exists) - require.NotEqual(t, pluginstore.Plugin{}, inputPlugin) - require.NotNil(t, dsPlugins["input"]) - - pluginRoutes := make(map[string]*plugins.StaticRoute) - for _, r := range ps.Routes(ctx) { - pluginRoutes[r.PluginID] = r - } - - for _, pluginID := range []string{"input"} { - require.Contains(t, pluginRoutes, pluginID) - require.Equal(t, pluginRoutes[pluginID].Directory, inputPlugin.Base()) - } -} - -func verifyPluginStaticRoutes(t *testing.T, ctx context.Context, rr plugins.StaticRouteResolver, reg registry.Service) { +func verifyPluginStaticRoutes(t *testing.T, ctx context.Context, rr plugins.StaticRouteResolver, ps *pluginstore.Service) { routes := make(map[string]*plugins.StaticRoute) for _, route := range rr.Routes(ctx) { routes[route.PluginID] = route } - require.Len(t, routes, 2) - - inputPlugin, _ := reg.Plugin(ctx, "input") - require.NotNil(t, routes["input"]) - require.Equal(t, routes["input"].Directory, inputPlugin.FS.Base()) + require.Len(t, routes, 1) - testAppPlugin, _ := reg.Plugin(ctx, "test-app") + testAppPlugin, _ := ps.Plugin(ctx, "test-app") require.Contains(t, routes, "test-app") - require.Equal(t, routes["test-app"].Directory, testAppPlugin.FS.Base()) + require.Equal(t, routes["test-app"].Directory, testAppPlugin.Base()) } func verifyBackendProcesses(t *testing.T, ps []*plugins.Plugin) { diff --git a/pkg/services/pluginsintegration/pluginsettings/service/service_test.go b/pkg/services/pluginsintegration/pluginsettings/service/service_test.go index e76d1aedf2829..44b7ccea597fb 100644 --- a/pkg/services/pluginsintegration/pluginsettings/service/service_test.go +++ b/pkg/services/pluginsintegration/pluginsettings/service/service_test.go @@ -12,8 +12,13 @@ import ( "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestService_DecryptedValuesCache(t *testing.T) { t.Run("When plugin settings hasn't been updated, encrypted JSON should be fetched from cache", func(t *testing.T) { ctx := context.Background() diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go index f1c246076141a..43d5ec020acd8 100644 --- a/pkg/services/pluginsintegration/pluginsintegration.go +++ b/pkg/services/pluginsintegration/pluginsintegration.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/auth" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/provider" - pCfg "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/envvars" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/client" "github.com/grafana/grafana/pkg/plugins/manager/filestore" @@ -35,15 +35,15 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/angularinspector" "github.com/grafana/grafana/pkg/services/pluginsintegration/angularpatternsstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/clientmiddleware" - "github.com/grafana/grafana/pkg/services/pluginsintegration/config" "github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever" "github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever/dynamic" "github.com/grafana/grafana/pkg/services/pluginsintegration/keystore" "github.com/grafana/grafana/pkg/services/pluginsintegration/licensing" "github.com/grafana/grafana/pkg/services/pluginsintegration/loader" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" - "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginexternal" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" @@ -55,7 +55,12 @@ import ( // WireSet provides a wire.ProviderSet of plugin providers. var WireSet = wire.NewSet( - config.ProvideConfig, + pluginconfig.ProvidePluginManagementConfig, + pluginconfig.ProvidePluginInstanceConfig, + pluginconfig.NewEnvVarsProvider, + wire.Bind(new(envvars.Provider), new(*pluginconfig.EnvVarsProvider)), + pluginconfig.NewRequestConfigProvider, + wire.Bind(new(pluginconfig.PluginRequestConfigProvider), new(*pluginconfig.RequestConfigProvider)), pluginstore.ProvideService, wire.Bind(new(pluginstore.Store), new(*pluginstore.Service)), wire.Bind(new(plugins.SecretsPluginManager), new(*pluginstore.Service)), @@ -94,7 +99,6 @@ var WireSet = wire.NewSet( wire.Bind(new(registry.Service), new(*registry.InMemory)), repo.ProvideService, wire.Bind(new(repo.Service), new(*repo.Manager)), - plugincontext.ProvideService, licensing.ProvideLicensing, wire.Bind(new(plugins.Licensing), new(*licensing.Service)), wire.Bind(new(sources.Registry), new(*sources.Service)), @@ -114,6 +118,7 @@ var WireSet = wire.NewSet( wire.Bind(new(auth.ExternalServiceRegistry), new(*serviceregistration.Service)), renderer.ProvideService, wire.Bind(new(rendering.PluginManager), new(*renderer.Manager)), + pluginexternal.ProvideService, ) // WireExtensionSet provides a wire.ProviderSet of plugin providers that can be @@ -130,7 +135,7 @@ var WireExtensionSet = wire.NewSet( ) func ProvideClientDecorator( - cfg *setting.Cfg, pCfg *pCfg.Cfg, + cfg *setting.Cfg, pluginRegistry registry.Service, oAuthTokenService oauthtoken.OAuthTokenService, tracer tracing.Tracer, @@ -138,27 +143,23 @@ func ProvideClientDecorator( features *featuremgmt.FeatureManager, promRegisterer prometheus.Registerer, ) (*client.Decorator, error) { - return NewClientDecorator(cfg, pCfg, pluginRegistry, oAuthTokenService, tracer, cachingService, features, promRegisterer, pluginRegistry) + return NewClientDecorator(cfg, pluginRegistry, oAuthTokenService, tracer, cachingService, features, promRegisterer, pluginRegistry) } func NewClientDecorator( - cfg *setting.Cfg, pCfg *pCfg.Cfg, + cfg *setting.Cfg, pluginRegistry registry.Service, oAuthTokenService oauthtoken.OAuthTokenService, tracer tracing.Tracer, cachingService caching.CachingService, features *featuremgmt.FeatureManager, promRegisterer prometheus.Registerer, registry registry.Service, ) (*client.Decorator, error) { - c := client.ProvideService(pluginRegistry, pCfg) + c := client.ProvideService(pluginRegistry) middlewares := CreateMiddlewares(cfg, oAuthTokenService, tracer, cachingService, features, promRegisterer, registry) return client.NewDecorator(c, middlewares...) } func CreateMiddlewares(cfg *setting.Cfg, oAuthTokenService oauthtoken.OAuthTokenService, tracer tracing.Tracer, cachingService caching.CachingService, features *featuremgmt.FeatureManager, promRegisterer prometheus.Registerer, registry registry.Service) []plugins.ClientMiddleware { - var middlewares []plugins.ClientMiddleware - - if features.IsEnabledGlobally(featuremgmt.FlagPluginsInstrumentationStatusSource) { - middlewares = []plugins.ClientMiddleware{ - clientmiddleware.NewPluginRequestMetaMiddleware(), - } + middlewares := []plugins.ClientMiddleware{ + clientmiddleware.NewPluginRequestMetaMiddleware(), } skipCookiesNames := []string{cfg.LoginCookieName} @@ -172,13 +173,9 @@ func CreateMiddlewares(cfg *setting.Cfg, oAuthTokenService oauthtoken.OAuthToken clientmiddleware.NewOAuthTokenMiddleware(oAuthTokenService), clientmiddleware.NewCookiesMiddleware(skipCookiesNames), clientmiddleware.NewResourceResponseMiddleware(), + clientmiddleware.NewCachingMiddlewareWithFeatureManager(cachingService, features), ) - // Placing the new service implementation behind a feature flag until it is known to be stable - if features.IsEnabledGlobally(featuremgmt.FlagUseCachingService) { - middlewares = append(middlewares, clientmiddleware.NewCachingMiddlewareWithFeatureManager(cachingService, features)) - } - if features.IsEnabledGlobally(featuremgmt.FlagIdForwarding) { middlewares = append(middlewares, clientmiddleware.NewForwardIDMiddleware()) } @@ -187,13 +184,15 @@ func CreateMiddlewares(cfg *setting.Cfg, oAuthTokenService oauthtoken.OAuthToken middlewares = append(middlewares, clientmiddleware.NewUserHeaderMiddleware()) } + if cfg.IPRangeACEnabled { + middlewares = append(middlewares, clientmiddleware.NewHostedGrafanaACHeaderMiddleware(cfg)) + } + middlewares = append(middlewares, clientmiddleware.NewHTTPClientMiddleware()) - if features.IsEnabledGlobally(featuremgmt.FlagPluginsInstrumentationStatusSource) { - // StatusSourceMiddleware should be at the very bottom, or any middlewares below it won't see the - // correct status source in their context.Context - middlewares = append(middlewares, clientmiddleware.NewStatusSourceMiddleware()) - } + // StatusSourceMiddleware should be at the very bottom, or any middlewares below it won't see the + // correct status source in their context.Context + middlewares = append(middlewares, clientmiddleware.NewStatusSourceMiddleware()) return middlewares } diff --git a/pkg/services/pluginsintegration/pluginstore/store.go b/pkg/services/pluginsintegration/pluginstore/store.go index 164876d187bee..e4d188572d27b 100644 --- a/pkg/services/pluginsintegration/pluginstore/store.go +++ b/pkg/services/pluginsintegration/pluginstore/store.go @@ -18,6 +18,7 @@ var _ Store = (*Service)(nil) // Store is the publicly accessible storage for plugins. type Store interface { // Plugin finds a plugin by its ID. + // Note: version is not required since Grafana only supports single versions of a plugin. Plugin(ctx context.Context, pluginID string) (Plugin, bool) // Plugins returns plugins by their requested type. Plugins(ctx context.Context, pluginTypes ...plugins.Type) []Plugin @@ -104,7 +105,7 @@ func (s *Service) SecretsManager(ctx context.Context) *plugins.Plugin { // plugin finds a plugin with `pluginID` from the registry that is not decommissioned func (s *Service) plugin(ctx context.Context, pluginID string) (*plugins.Plugin, bool) { - p, exists := s.pluginRegistry.Plugin(ctx, pluginID) + p, exists := s.pluginRegistry.Plugin(ctx, pluginID, "") // version is not required since Grafana only supports single versions of a plugin if !exists { return nil, false } diff --git a/pkg/services/pluginsintegration/renderer/renderer.go b/pkg/services/pluginsintegration/renderer/renderer.go index 102c5da7adf09..178c37740be39 100644 --- a/pkg/services/pluginsintegration/renderer/renderer.go +++ b/pkg/services/pluginsintegration/renderer/renderer.go @@ -19,32 +19,36 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/sources" - "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" "github.com/grafana/grafana/pkg/services/rendering" ) -func ProvideService(cfg *config.Cfg, registry registry.Service, licensing plugins.Licensing, - features featuremgmt.FeatureToggles) (*Manager, error) { - l, err := createLoader(cfg, registry, licensing, features) +func ProvideService(cfg *config.PluginManagementCfg, pluginEnvProvider envvars.Provider, + registry registry.Service) (*Manager, error) { + l, err := createLoader(cfg, pluginEnvProvider, registry) if err != nil { return nil, err } - return &Manager{ - cfg: cfg, - loader: l, - log: log.New("plugins.renderer"), - }, nil + return NewManager(cfg, l), nil } type Manager struct { - cfg *config.Cfg + cfg *config.PluginManagementCfg loader loader.Service log log.Logger renderer *Plugin } +func NewManager(cfg *config.PluginManagementCfg, loader loader.Service) *Manager { + return &Manager{ + cfg: cfg, + loader: loader, + log: log.New("renderer.manager"), + } +} + type Plugin struct { plugin *plugins.Plugin @@ -77,27 +81,35 @@ func (m *Manager) Renderer(ctx context.Context) (rendering.Plugin, bool) { return m.renderer, true } - ps, err := m.loader.Load(ctx, sources.NewLocalSource(plugins.ClassExternal, []string{m.cfg.PluginsPath})) + srcs, err := sources.DirAsLocalSources(m.cfg.PluginsPath, plugins.ClassExternal) if err != nil { - m.log.Error("Failed to load renderer plugin", "error", err) + m.log.Error("Failed to get renderer plugin sources", "error", err) return nil, false } - if len(ps) >= 1 { - m.renderer = &Plugin{plugin: ps[0]} - return m.renderer, true + for _, src := range srcs { + ps, err := m.loader.Load(ctx, src) + if err != nil { + m.log.Error("Failed to load renderer plugin", "error", err) + return nil, false + } + + if len(ps) >= 1 { + m.renderer = &Plugin{plugin: ps[0]} + return m.renderer, true + } } return nil, false } -func createLoader(cfg *config.Cfg, pr registry.Service, l plugins.Licensing, - features featuremgmt.FeatureToggles) (loader.Service, error) { +func createLoader(cfg *config.PluginManagementCfg, pluginEnvProvider envvars.Provider, + pr registry.Service) (loader.Service, error) { d := discovery.New(cfg, discovery.Opts{ FindFilterFuncs: []discovery.FindFilterFunc{ discovery.NewPermittedPluginTypesFilterStep([]plugins.Type{plugins.TypeRenderer}), func(ctx context.Context, class plugins.Class, bundles []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) { - return discovery.NewDuplicatePluginFilterStep(pr).Filter(ctx, bundles) + return pipeline.NewDuplicatePluginIDFilterStep(pr).Filter(ctx, bundles) }, }, }) @@ -111,7 +123,7 @@ func createLoader(cfg *config.Cfg, pr registry.Service, l plugins.Licensing, }) i := initialization.New(cfg, initialization.Opts{ InitializeFuncs: []initialization.InitializeFunc{ - initialization.BackendClientInitStep(envvars.NewProvider(cfg, l), provider.New(provider.RendererProvider)), + initialization.BackendClientInitStep(pluginEnvProvider, provider.New(provider.RendererProvider)), initialization.PluginRegistrationStep(pr), }, }) diff --git a/pkg/services/pluginsintegration/renderer/renderer_test.go b/pkg/services/pluginsintegration/renderer/renderer_test.go new file mode 100644 index 0000000000000..2f7f5d0c91337 --- /dev/null +++ b/pkg/services/pluginsintegration/renderer/renderer_test.go @@ -0,0 +1,78 @@ +package renderer + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/manager/fakes" +) + +func TestRenderer(t *testing.T) { + t.Run("Test Renderer will treat directories under plugins path as individual sources", func(t *testing.T) { + testdataDir := filepath.Join("testdata", "plugins") + + numLoaded := 0 + numUnloaded := 0 + loader := &fakes.FakeLoader{ + LoadFunc: func(ctx context.Context, src plugins.PluginSource) ([]*plugins.Plugin, error) { + require.True(t, src.PluginClass(ctx) == plugins.ClassExternal) + require.Len(t, src.PluginURIs(ctx), 1) + require.True(t, strings.HasPrefix(src.PluginURIs(ctx)[0], testdataDir)) + + numLoaded++ + return []*plugins.Plugin{}, nil + }, + UnloadFunc: func(_ context.Context, _ *plugins.Plugin) (*plugins.Plugin, error) { + numUnloaded++ + return nil, nil + }, + } + cfg := &config.PluginManagementCfg{PluginsPath: filepath.Join(testdataDir)} + + m := NewManager(cfg, loader) + + r, exists := m.Renderer(context.Background()) + require.False(t, exists) + require.Equal(t, 4, numLoaded) + require.Equal(t, 0, numUnloaded) + require.Nil(t, r) + }) + + t.Run("Test Renderer load all directories until a plugin is returned", func(t *testing.T) { + testdataDir := filepath.Join("testdata", "plugins") + + numLoaded := 0 + numUnloaded := 0 + p := &plugins.Plugin{ + JSONData: plugins.JSONData{ID: "test"}, + } + loader := &fakes.FakeLoader{ + LoadFunc: func(ctx context.Context, src plugins.PluginSource) ([]*plugins.Plugin, error) { + numLoaded++ + if strings.HasPrefix(src.PluginURIs(ctx)[0], filepath.Join(testdataDir, "renderer")) { + return []*plugins.Plugin{p}, nil + } + return []*plugins.Plugin{}, nil + }, + UnloadFunc: func(_ context.Context, _ *plugins.Plugin) (*plugins.Plugin, error) { + numUnloaded++ + return nil, nil + }, + } + cfg := &config.PluginManagementCfg{PluginsPath: filepath.Join(testdataDir)} + + m := NewManager(cfg, loader) + + r, exists := m.Renderer(context.Background()) + require.True(t, exists) + require.Equal(t, 3, numLoaded) + require.Equal(t, 0, numUnloaded) + require.NotNil(t, r) + }) +} diff --git a/pkg/services/pluginsintegration/renderer/testdata/plugins/app/plugin.json b/pkg/services/pluginsintegration/renderer/testdata/plugins/app/plugin.json new file mode 100644 index 0000000000000..14dfb7655afe8 --- /dev/null +++ b/pkg/services/pluginsintegration/renderer/testdata/plugins/app/plugin.json @@ -0,0 +1,3 @@ +{ + "type": "app" +} diff --git a/pkg/services/pluginsintegration/renderer/testdata/plugins/datasource/plugin.json b/pkg/services/pluginsintegration/renderer/testdata/plugins/datasource/plugin.json new file mode 100644 index 0000000000000..5c57cba8286a5 --- /dev/null +++ b/pkg/services/pluginsintegration/renderer/testdata/plugins/datasource/plugin.json @@ -0,0 +1,3 @@ +{ + "type": "datasource" +} diff --git a/pkg/services/pluginsintegration/renderer/testdata/plugins/renderer/plugin.json b/pkg/services/pluginsintegration/renderer/testdata/plugins/renderer/plugin.json new file mode 100644 index 0000000000000..6d423963a4b48 --- /dev/null +++ b/pkg/services/pluginsintegration/renderer/testdata/plugins/renderer/plugin.json @@ -0,0 +1,3 @@ +{ + "type": "renderer" +} diff --git a/pkg/services/pluginsintegration/renderer/testdata/plugins/secrets-manager/plugin.json b/pkg/services/pluginsintegration/renderer/testdata/plugins/secrets-manager/plugin.json new file mode 100644 index 0000000000000..635a3ff47b94b --- /dev/null +++ b/pkg/services/pluginsintegration/renderer/testdata/plugins/secrets-manager/plugin.json @@ -0,0 +1,3 @@ +{ + "type": "secretsmanager" +} diff --git a/pkg/services/pluginsintegration/serviceregistration/serviceregistration.go b/pkg/services/pluginsintegration/serviceregistration/serviceregistration.go index b57408a170204..2658ea5659921 100644 --- a/pkg/services/pluginsintegration/serviceregistration/serviceregistration.go +++ b/pkg/services/pluginsintegration/serviceregistration/serviceregistration.go @@ -5,9 +5,8 @@ import ( "errors" "github.com/grafana/grafana/pkg/plugins/auth" - "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" - "github.com/grafana/grafana/pkg/plugins/plugindef" + "github.com/grafana/grafana/pkg/plugins/pfs" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/extsvcauth" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -21,9 +20,9 @@ type Service struct { settingsSvc pluginsettings.Service } -func ProvideService(cfg *config.Cfg, reg extsvcauth.ExternalServiceRegistry, settingsSvc pluginsettings.Service) *Service { +func ProvideService(features featuremgmt.FeatureToggles, reg extsvcauth.ExternalServiceRegistry, settingsSvc pluginsettings.Service) *Service { s := &Service{ - featureEnabled: cfg.Features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) || cfg.Features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts), + featureEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts), log: log.New("plugins.external.registration"), reg: reg, settingsSvc: settingsSvc, @@ -41,7 +40,7 @@ func (s *Service) HasExternalService(ctx context.Context, pluginID string) (bool } // RegisterExternalService is a simplified wrapper around SaveExternalService for the plugin use case. -func (s *Service) RegisterExternalService(ctx context.Context, pluginID string, pType plugindef.Type, svc *plugindef.IAM) (*auth.ExternalService, error) { +func (s *Service) RegisterExternalService(ctx context.Context, pluginID string, pType pfs.Type, svc *pfs.IAM) (*auth.ExternalService, error) { if !s.featureEnabled { s.log.Warn("Skipping External Service Registration. The feature is behind a feature toggle and needs to be enabled.") return nil, nil @@ -50,7 +49,7 @@ func (s *Service) RegisterExternalService(ctx context.Context, pluginID string, // Datasource plugins can only be enabled enabled := true // App plugins can be disabled - if pType == plugindef.TypeApp { + if pType == pfs.TypeApp { settings, err := s.settingsSvc.GetPluginSettingByPluginID(ctx, &pluginsettings.GetByPluginIDArgs{PluginID: pluginID}) if err != nil && !errors.Is(err, pluginsettings.ErrPluginSettingNotFound) { return nil, err @@ -58,18 +57,6 @@ func (s *Service) RegisterExternalService(ctx context.Context, pluginID string, enabled = (settings != nil) && settings.Enabled } - - impersonation := extsvcauth.ImpersonationCfg{} - if svc.Impersonation != nil { - impersonation.Permissions = toAccessControlPermissions(svc.Impersonation.Permissions) - impersonation.Enabled = enabled - if svc.Impersonation.Groups != nil { - impersonation.Groups = *svc.Impersonation.Groups - } else { - impersonation.Groups = true - } - } - self := extsvcauth.SelfCfg{} self.Enabled = enabled if len(svc.Permissions) > 0 { @@ -77,16 +64,9 @@ func (s *Service) RegisterExternalService(ctx context.Context, pluginID string, } registration := &extsvcauth.ExternalServiceRegistration{ - Name: pluginID, - Impersonation: impersonation, - Self: self, - } - - // Default authProvider now is ServiceAccounts - registration.AuthProvider = extsvcauth.ServiceAccounts - if svc.Impersonation != nil { - registration.AuthProvider = extsvcauth.OAuth2Server - registration.OAuthProviderCfg = &extsvcauth.OAuthProviderCfg{Key: &extsvcauth.KeyOption{Generate: true}} + Name: pluginID, + Self: self, + AuthProvider: extsvcauth.ServiceAccounts, } extSvc, err := s.reg.SaveExternalService(ctx, registration) @@ -105,7 +85,7 @@ func (s *Service) RegisterExternalService(ctx context.Context, pluginID string, PrivateKey: privateKey}, nil } -func toAccessControlPermissions(ps []plugindef.Permission) []accesscontrol.Permission { +func toAccessControlPermissions(ps []pfs.Permission) []accesscontrol.Permission { res := make([]accesscontrol.Permission, 0, len(ps)) for _, p := range ps { scope := "" diff --git a/pkg/services/pluginsintegration/test_helper.go b/pkg/services/pluginsintegration/test_helper.go index 5c63d56c1c61f..0515e6a50cd68 100644 --- a/pkg/services/pluginsintegration/test_helper.go +++ b/pkg/services/pluginsintegration/test_helper.go @@ -28,8 +28,8 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/pluginsintegration/config" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" @@ -42,7 +42,7 @@ type IntegrationTestCtx struct { } func CreateIntegrationTestCtx(t *testing.T, cfg *setting.Cfg, coreRegistry *coreplugin.Registry) *IntegrationTestCtx { - pCfg, err := config.ProvideConfig(setting.ProvideProvider(cfg), cfg, featuremgmt.WithFeatures()) + pCfg, err := pluginconfig.ProvidePluginManagementConfig(cfg, setting.ProvideProvider(cfg), featuremgmt.WithFeatures()) require.NoError(t, err) cdn := pluginscdn.ProvideService(pCfg) @@ -51,10 +51,10 @@ func CreateIntegrationTestCtx(t *testing.T, cfg *setting.Cfg, coreRegistry *core proc := process.ProvideService() errTracker := pluginerrs.ProvideSignatureErrorTracker() - disc := pipeline.ProvideDiscoveryStage(pCfg, finder.NewLocalFinder(true, pCfg.Features), reg) + disc := pipeline.ProvideDiscoveryStage(pCfg, finder.NewLocalFinder(true), reg) boot := pipeline.ProvideBootstrapStage(pCfg, signature.ProvideService(pCfg, statickey.New()), assetpath.ProvideService(pCfg, cdn)) valid := pipeline.ProvideValidationStage(pCfg, signature.NewValidator(signature.NewUnsignedAuthorizer(pCfg)), angularInspector, errTracker) - init := pipeline.ProvideInitializationStage(pCfg, reg, fakes.NewFakeLicensingService(), provider.ProvideService(coreRegistry), proc, &fakes.FakeAuthService{}, fakes.NewFakeRoleRegistry()) + init := pipeline.ProvideInitializationStage(pCfg, reg, provider.ProvideService(coreRegistry), proc, &fakes.FakeAuthService{}, fakes.NewFakeRoleRegistry(), nil) term, err := pipeline.ProvideTerminationStage(pCfg, reg, proc) require.NoError(t, err) @@ -66,11 +66,11 @@ func CreateIntegrationTestCtx(t *testing.T, cfg *setting.Cfg, coreRegistry *core Terminator: term, }) - ps, err := pluginstore.ProvideService(reg, sources.ProvideService(cfg, pCfg), l) + ps, err := pluginstore.ProvideService(reg, sources.ProvideService(cfg), l) require.NoError(t, err) return &IntegrationTestCtx{ - PluginClient: client.ProvideService(reg, pCfg), + PluginClient: client.ProvideService(reg), PluginStore: ps, PluginRegistry: reg, } @@ -84,9 +84,9 @@ type LoaderOpts struct { Initializer initialization.Initializer } -func CreateTestLoader(t *testing.T, cfg *pluginsCfg.Cfg, opts LoaderOpts) *loader.Loader { +func CreateTestLoader(t *testing.T, cfg *pluginsCfg.PluginManagementCfg, opts LoaderOpts) *loader.Loader { if opts.Discoverer == nil { - opts.Discoverer = pipeline.ProvideDiscoveryStage(cfg, finder.NewLocalFinder(cfg.DevMode, cfg.Features), registry.ProvideService()) + opts.Discoverer = pipeline.ProvideDiscoveryStage(cfg, finder.NewLocalFinder(cfg.DevMode), registry.ProvideService()) } if opts.Bootstrapper == nil { @@ -100,7 +100,7 @@ func CreateTestLoader(t *testing.T, cfg *pluginsCfg.Cfg, opts LoaderOpts) *loade if opts.Initializer == nil { reg := registry.ProvideService() coreRegistry := coreplugin.NewRegistry(make(map[string]backendplugin.PluginFactoryFunc)) - opts.Initializer = pipeline.ProvideInitializationStage(cfg, reg, fakes.NewFakeLicensingService(), provider.ProvideService(coreRegistry), process.ProvideService(), &fakes.FakeAuthService{}, fakes.NewFakeRoleRegistry()) + opts.Initializer = pipeline.ProvideInitializationStage(cfg, reg, provider.ProvideService(coreRegistry), process.ProvideService(), &fakes.FakeAuthService{}, fakes.NewFakeRoleRegistry(), nil) } if opts.Terminator == nil { diff --git a/pkg/services/preference/prefimpl/store_test.go b/pkg/services/preference/prefimpl/store_test.go index 43d56d4c97f94..afbfea8624047 100644 --- a/pkg/services/preference/prefimpl/store_test.go +++ b/pkg/services/preference/prefimpl/store_test.go @@ -11,8 +11,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" pref "github.com/grafana/grafana/pkg/services/preference" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type getStore func(db.DB) store func testIntegrationPreferencesDataAccess(t *testing.T, fn getStore) { diff --git a/pkg/services/provisioning/alerting/provisioner.go b/pkg/services/provisioning/alerting/provisioner.go index bff80ce870638..1555de96f0582 100644 --- a/pkg/services/provisioning/alerting/provisioner.go +++ b/pkg/services/provisioning/alerting/provisioner.go @@ -29,15 +29,6 @@ func Provision(ctx context.Context, cfg ProvisionerConfig) error { } logger.Info("starting to provision alerting") logger.Debug("read all alerting files", "file_count", len(files)) - ruleProvisioner := NewAlertRuleProvisioner( - logger, - cfg.DashboardService, - cfg.DashboardProvService, - cfg.RuleService) - err = ruleProvisioner.Provision(ctx, files) - if err != nil { - return fmt.Errorf("alert rules: %w", err) - } cpProvisioner := NewContactPointProvisoner(logger, cfg.ContactPointService) err = cpProvisioner.Provision(ctx, files) if err != nil { @@ -62,10 +53,6 @@ func Provision(ctx context.Context, cfg ProvisionerConfig) error { if err != nil { return fmt.Errorf("notification policies: %w", err) } - err = cpProvisioner.Unprovision(ctx, files) - if err != nil { - return fmt.Errorf("contact points: %w", err) - } err = mtProvisioner.Unprovision(ctx, files) if err != nil { return fmt.Errorf("mute times: %w", err) @@ -74,6 +61,19 @@ func Provision(ctx context.Context, cfg ProvisionerConfig) error { if err != nil { return fmt.Errorf("text templates: %w", err) } + ruleProvisioner := NewAlertRuleProvisioner( + logger, + cfg.DashboardService, + cfg.DashboardProvService, + cfg.RuleService) + err = ruleProvisioner.Provision(ctx, files) + if err != nil { + return fmt.Errorf("alert rules: %w", err) + } + err = cpProvisioner.Unprovision(ctx, files) // Unprovision contact points after rules to make sure all references in rules are updated + if err != nil { + return fmt.Errorf("contact points: %w", err) + } logger.Info("finished to provision alerting") return nil } diff --git a/pkg/services/provisioning/alerting/rules_provisioner.go b/pkg/services/provisioning/alerting/rules_provisioner.go index 5a84d0bd13ba5..205018c7ef90d 100644 --- a/pkg/services/provisioning/alerting/rules_provisioner.go +++ b/pkg/services/provisioning/alerting/rules_provisioner.go @@ -6,9 +6,13 @@ import ( "fmt" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/metrics" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" alert_models "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/provisioning" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" ) @@ -40,6 +44,7 @@ func (prov *defaultAlertRuleProvisioner) Provision(ctx context.Context, files []*AlertingFile) error { for _, file := range files { for _, group := range file.Groups { + u := provisionerUser(group.OrgID) folderUID, err := prov.getOrCreateFolderUID(ctx, group.FolderTitle, group.OrgID) if err != nil { return err @@ -52,19 +57,18 @@ func (prov *defaultAlertRuleProvisioner) Provision(ctx context.Context, for _, rule := range group.Rules { rule.NamespaceUID = folderUID rule.RuleGroup = group.Title - err = prov.provisionRule(ctx, group.OrgID, rule) + err = prov.provisionRule(ctx, u, rule) if err != nil { return err } } - err = prov.ruleService.UpdateRuleGroup(ctx, group.OrgID, folderUID, group.Title, group.Interval) + err = prov.ruleService.UpdateRuleGroup(ctx, u, folderUID, group.Title, group.Interval) if err != nil { return err } } for _, deleteRule := range file.DeleteRules { - err := prov.ruleService.DeleteAlertRule(ctx, deleteRule.OrgID, - deleteRule.UID, alert_models.ProvenanceFile) + err := prov.ruleService.DeleteAlertRule(ctx, provisionerUser(deleteRule.OrgID), deleteRule.UID, alert_models.ProvenanceFile) if err != nil { return err } @@ -75,26 +79,27 @@ func (prov *defaultAlertRuleProvisioner) Provision(ctx context.Context, func (prov *defaultAlertRuleProvisioner) provisionRule( ctx context.Context, - orgID int64, + user identity.Requester, rule alert_models.AlertRule) error { prov.logger.Debug("provisioning alert rule", "uid", rule.UID, "org", rule.OrgID) - _, _, err := prov.ruleService.GetAlertRule(ctx, orgID, rule.UID) + _, _, err := prov.ruleService.GetAlertRule(ctx, user, rule.UID) if err != nil && !errors.Is(err, alert_models.ErrAlertRuleNotFound) { return err } else if err != nil { prov.logger.Debug("creating rule", "uid", rule.UID, "org", rule.OrgID) - // 0 is passed as userID as then the quota logic will only check for - // the organization quota, as we don't have any user scope here. - _, err = prov.ruleService.CreateAlertRule(ctx, rule, alert_models.ProvenanceFile, 0) + // a nil user is passed in as then the quota logic will only check for + // the organization quota since we don't have any user scope here. + _, err = prov.ruleService.CreateAlertRule(ctx, user, rule, alert_models.ProvenanceFile) } else { prov.logger.Debug("updating rule", "uid", rule.UID, "org", rule.OrgID) - _, err = prov.ruleService.UpdateAlertRule(ctx, rule, alert_models.ProvenanceFile) + _, err = prov.ruleService.UpdateAlertRule(ctx, user, rule, alert_models.ProvenanceFile) } return err } func (prov *defaultAlertRuleProvisioner) getOrCreateFolderUID( ctx context.Context, folderName string, orgID int64) (string, error) { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Provisioning).Inc() cmd := &dashboards.GetDashboardQuery{ Title: &folderName, FolderID: util.Pointer(int64(0)), // nolint:staticcheck @@ -107,13 +112,12 @@ func (prov *defaultAlertRuleProvisioner) getOrCreateFolderUID( // dashboard folder not found. create one. if errors.Is(err, dashboards.ErrDashboardNotFound) { - dash := &dashboards.SaveDashboardDTO{} - dash.Dashboard = dashboards.NewDashboardFolder(folderName) - dash.Dashboard.IsFolder = true - dash.Overwrite = true - dash.OrgID = orgID - dash.Dashboard.SetUID(util.GenerateShortUID()) - dbDash, err := prov.dashboardProvService.SaveFolderForProvisionedDashboards(ctx, dash) + createCmd := &folder.CreateFolderCommand{ + OrgID: orgID, + UID: util.GenerateShortUID(), + Title: folderName, + } + dbDash, err := prov.dashboardProvService.SaveFolderForProvisionedDashboards(ctx, createCmd) if err != nil { return "", err } @@ -127,3 +131,8 @@ func (prov *defaultAlertRuleProvisioner) getOrCreateFolderUID( return cmdResult.UID, nil } + +// UserID is 0 to use org quota +var provisionerUser = func(orgID int64) identity.Requester { + return &user.SignedInUser{UserID: 0, Login: "alert_provisioner", OrgID: orgID} +} diff --git a/pkg/services/provisioning/alerting/rules_types.go b/pkg/services/provisioning/alerting/rules_types.go index 8c245d0ee9a81..5e72156b2262d 100644 --- a/pkg/services/provisioning/alerting/rules_types.go +++ b/pkg/services/provisioning/alerting/rules_types.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/provisioning/values" + "github.com/grafana/grafana/pkg/util" ) type RuleDelete struct { @@ -61,18 +62,19 @@ func (ruleGroupV1 *AlertRuleGroupV1) MapToModel() (models.AlertRuleGroupWithFold } type AlertRuleV1 struct { - UID values.StringValue `json:"uid" yaml:"uid"` - Title values.StringValue `json:"title" yaml:"title"` - Condition values.StringValue `json:"condition" yaml:"condition"` - Data []QueryV1 `json:"data" yaml:"data"` - DashboardUID values.StringValue `json:"dasboardUid" yaml:"dashboardUid"` - PanelID values.Int64Value `json:"panelId" yaml:"panelId"` - NoDataState values.StringValue `json:"noDataState" yaml:"noDataState"` - ExecErrState values.StringValue `json:"execErrState" yaml:"execErrState"` - For values.StringValue `json:"for" yaml:"for"` - Annotations values.StringMapValue `json:"annotations" yaml:"annotations"` - Labels values.StringMapValue `json:"labels" yaml:"labels"` - IsPaused values.BoolValue `json:"isPaused" yaml:"isPaused"` + UID values.StringValue `json:"uid" yaml:"uid"` + Title values.StringValue `json:"title" yaml:"title"` + Condition values.StringValue `json:"condition" yaml:"condition"` + Data []QueryV1 `json:"data" yaml:"data"` + DashboardUID values.StringValue `json:"dasboardUid" yaml:"dashboardUid"` + PanelID values.Int64Value `json:"panelId" yaml:"panelId"` + NoDataState values.StringValue `json:"noDataState" yaml:"noDataState"` + ExecErrState values.StringValue `json:"execErrState" yaml:"execErrState"` + For values.StringValue `json:"for" yaml:"for"` + Annotations values.StringMapValue `json:"annotations" yaml:"annotations"` + Labels values.StringMapValue `json:"labels" yaml:"labels"` + IsPaused values.BoolValue `json:"isPaused" yaml:"isPaused"` + NotificationSettings *NotificationSettingsV1 `json:"notification_settings" yaml:"notification_settings"` } func (rule *AlertRuleV1) mapToModel(orgID int64) (models.AlertRule, error) { @@ -130,6 +132,13 @@ func (rule *AlertRuleV1) mapToModel(orgID int64) (models.AlertRule, error) { return models.AlertRule{}, fmt.Errorf("rule '%s' failed to parse: no data set", alertRule.Title) } alertRule.IsPaused = rule.IsPaused.Value() + if rule.NotificationSettings != nil { + ns, err := rule.NotificationSettings.mapToModel() + if err != nil { + return models.AlertRule{}, fmt.Errorf("rule '%s' failed to parse: %w", alertRule.Title, err) + } + alertRule.NotificationSettings = append(alertRule.NotificationSettings, ns) + } return alertRule, nil } @@ -169,3 +178,71 @@ func (queryV1 *QueryV1) mapToModel() (models.AlertQuery, error) { Model: rawMessage, }, nil } + +type NotificationSettingsV1 struct { + Receiver values.StringValue `json:"receiver" yaml:"receiver"` + GroupBy []values.StringValue `json:"group_by,omitempty" yaml:"group_by"` + GroupWait values.StringValue `json:"group_wait,omitempty" yaml:"group_wait"` + GroupInterval values.StringValue `json:"group_interval,omitempty" yaml:"group_interval"` + RepeatInterval values.StringValue `json:"repeat_interval,omitempty" yaml:"repeat_interval"` + MuteTimeIntervals []values.StringValue `json:"mute_time_intervals,omitempty" yaml:"mute_time_intervals"` +} + +func (nsV1 *NotificationSettingsV1) mapToModel() (models.NotificationSettings, error) { + if nsV1.Receiver.Value() == "" { + return models.NotificationSettings{}, fmt.Errorf("receiver must not be empty") + } + var gw, gi, ri *model.Duration + if nsV1.GroupWait.Value() != "" { + dur, err := model.ParseDuration(nsV1.GroupWait.Value()) + if err != nil { + return models.NotificationSettings{}, fmt.Errorf("failed to parse group wait: %w", err) + } + gw = util.Pointer(dur) + } + if nsV1.GroupInterval.Value() != "" { + dur, err := model.ParseDuration(nsV1.GroupInterval.Value()) + if err != nil { + return models.NotificationSettings{}, fmt.Errorf("failed to parse group interval: %w", err) + } + gi = util.Pointer(dur) + } + if nsV1.RepeatInterval.Value() != "" { + dur, err := model.ParseDuration(nsV1.RepeatInterval.Value()) + if err != nil { + return models.NotificationSettings{}, fmt.Errorf("failed to parse repeat interval: %w", err) + } + ri = util.Pointer(dur) + } + + var groupBy []string + if len(nsV1.GroupBy) > 0 { + groupBy = make([]string, 0, len(nsV1.GroupBy)) + for _, value := range nsV1.GroupBy { + if value.Value() == "" { + continue + } + groupBy = append(groupBy, value.Value()) + } + } + + var mute []string + if len(nsV1.MuteTimeIntervals) > 0 { + mute = make([]string, 0, len(nsV1.MuteTimeIntervals)) + for _, value := range nsV1.MuteTimeIntervals { + if value.Value() == "" { + continue + } + mute = append(mute, value.Value()) + } + } + + return models.NotificationSettings{ + Receiver: nsV1.Receiver.Value(), + GroupBy: groupBy, + GroupWait: gw, + GroupInterval: gi, + RepeatInterval: ri, + MuteTimeIntervals: mute, + }, nil +} diff --git a/pkg/services/provisioning/alerting/rules_types_test.go b/pkg/services/provisioning/alerting/rules_types_test.go index dcca42e0574ad..fda8947cf8b5d 100644 --- a/pkg/services/provisioning/alerting/rules_types_test.go +++ b/pkg/services/provisioning/alerting/rules_types_test.go @@ -4,11 +4,13 @@ import ( "testing" "time" + "github.com/prometheus/common/model" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/provisioning/values" + "github.com/grafana/grafana/pkg/util" ) func TestRuleGroup(t *testing.T) { @@ -187,6 +189,109 @@ func TestRules(t *testing.T) { require.NoError(t, err) require.Equal(t, ruleMapped.NoDataState, models.NoData) }) + t.Run("a rule with notification settings should map it correctly", func(t *testing.T) { + rule := validRuleV1(t) + rule.NotificationSettings = &NotificationSettingsV1{ + Receiver: stringToStringValue("test-receiver"), + } + ruleMapped, err := rule.mapToModel(1) + require.NoError(t, err) + require.Len(t, ruleMapped.NotificationSettings, 1) + require.Equal(t, models.NotificationSettings{Receiver: "test-receiver"}, ruleMapped.NotificationSettings[0]) + }) +} + +func TestNotificationsSettingsV1MapToModel(t *testing.T) { + tests := []struct { + name string + input NotificationSettingsV1 + expected models.NotificationSettings + wantErr bool + }{ + { + name: "Valid Input", + input: NotificationSettingsV1{ + Receiver: stringToStringValue("test-receiver"), + GroupBy: []values.StringValue{stringToStringValue("test-group_by")}, + GroupWait: stringToStringValue("1s"), + GroupInterval: stringToStringValue("2s"), + RepeatInterval: stringToStringValue("3s"), + MuteTimeIntervals: []values.StringValue{stringToStringValue("test-mute")}, + }, + expected: models.NotificationSettings{ + Receiver: "test-receiver", + GroupBy: []string{"test-group_by"}, + GroupWait: util.Pointer(model.Duration(1 * time.Second)), + GroupInterval: util.Pointer(model.Duration(2 * time.Second)), + RepeatInterval: util.Pointer(model.Duration(3 * time.Second)), + MuteTimeIntervals: []string{"test-mute"}, + }, + }, + { + name: "Skips empty elements in group_by", + input: NotificationSettingsV1{ + Receiver: stringToStringValue("test-receiver"), + GroupBy: []values.StringValue{stringToStringValue("test-group_by1"), stringToStringValue(""), stringToStringValue("test-group_by2")}, + }, + expected: models.NotificationSettings{ + Receiver: "test-receiver", + GroupBy: []string{"test-group_by1", "test-group_by2"}, + }, + }, + { + name: "Skips empty elements in mute timings", + input: NotificationSettingsV1{ + Receiver: stringToStringValue("test-receiver"), + MuteTimeIntervals: []values.StringValue{stringToStringValue("test-mute1"), stringToStringValue(""), stringToStringValue("test-mute2")}, + }, + expected: models.NotificationSettings{ + Receiver: "test-receiver", + MuteTimeIntervals: []string{"test-mute1", "test-mute2"}, + }, + }, + { + name: "Empty Receiver", + input: NotificationSettingsV1{ + Receiver: stringToStringValue(""), + }, + wantErr: true, + }, + { + name: "Invalid GroupWait Duration", + input: NotificationSettingsV1{ + Receiver: stringToStringValue("test-receiver"), + GroupWait: stringToStringValue("invalidDuration"), + }, + wantErr: true, + }, + { + name: "Invalid GroupInterval Duration", + input: NotificationSettingsV1{ + Receiver: stringToStringValue("test-receiver"), + GroupInterval: stringToStringValue("invalidDuration"), + }, + wantErr: true, + }, + { + name: "Invalid RepeatInterval Duration", + input: NotificationSettingsV1{ + Receiver: stringToStringValue("test-receiver"), + GroupInterval: stringToStringValue("invalidDuration"), + }, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := tc.input.mapToModel() + if tc.wantErr { + require.Error(t, err) + return + } + require.Equal(t, tc.expected, got) + }) + } } func validRuleGroupV1(t *testing.T) AlertRuleGroupV1 { @@ -238,3 +343,12 @@ func validRuleV1(t *testing.T) AlertRuleV1 { Data: []QueryV1{{}}, } } + +func stringToStringValue(s string) values.StringValue { + result := values.StringValue{} + err := yaml.Unmarshal([]byte(s), &result) + if err != nil { + panic(err) + } + return result +} diff --git a/pkg/services/provisioning/dashboards/dashboard.go b/pkg/services/provisioning/dashboards/dashboard.go index b9c6c69095b96..0a689d2e2951d 100644 --- a/pkg/services/provisioning/dashboards/dashboard.go +++ b/pkg/services/provisioning/dashboards/dashboard.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/provisioning/utils" ) @@ -23,7 +24,7 @@ type DashboardProvisioner interface { } // DashboardProvisionerFactory creates DashboardProvisioners based on input -type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService, org.Service, utils.DashboardStore) (DashboardProvisioner, error) +type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService, org.Service, utils.DashboardStore, folder.Service) (DashboardProvisioner, error) // Provisioner is responsible for syncing dashboard from disk to Grafana's database. type Provisioner struct { @@ -39,7 +40,7 @@ func (provider *Provisioner) HasDashboardSources() bool { } // New returns a new DashboardProvisioner -func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, orgService org.Service, dashboardStore utils.DashboardStore) (DashboardProvisioner, error) { +func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, orgService org.Service, dashboardStore utils.DashboardStore, folderService folder.Service) (DashboardProvisioner, error) { logger := log.New("provisioning.dashboard") cfgReader := &configReader{path: configDirectory, log: logger, orgService: orgService} configs, err := cfgReader.readConfig(ctx) @@ -47,7 +48,7 @@ func New(ctx context.Context, configDirectory string, provisioner dashboards.Das return nil, fmt.Errorf("%v: %w", "Failed to read dashboards config", err) } - fileReaders, err := getFileReaders(configs, logger, provisioner, dashboardStore) + fileReaders, err := getFileReaders(configs, logger, provisioner, dashboardStore, folderService) if err != nil { return nil, fmt.Errorf("%v: %w", "Failed to initialize file readers", err) } @@ -66,6 +67,8 @@ func New(ctx context.Context, configDirectory string, provisioner dashboards.Das // Provision scans the disk for dashboards and updates // the database with the latest versions of those dashboards. func (provider *Provisioner) Provision(ctx context.Context) error { + provider.log.Info("starting to provision dashboards") + for _, reader := range provider.fileReaders { if err := reader.walkDisk(ctx); err != nil { if os.IsNotExist(err) { @@ -79,6 +82,7 @@ func (provider *Provisioner) Provision(ctx context.Context) error { } provider.duplicateValidator.validate() + provider.log.Info("finished to provision dashboards") return nil } @@ -127,14 +131,24 @@ func (provider *Provisioner) GetAllowUIUpdatesFromConfig(name string) bool { } func getFileReaders( - configs []*config, logger log.Logger, service dashboards.DashboardProvisioningService, store utils.DashboardStore, + configs []*config, + logger log.Logger, + service dashboards.DashboardProvisioningService, + store utils.DashboardStore, + folderService folder.Service, ) ([]*FileReader, error) { var readers []*FileReader for _, config := range configs { switch config.Type { case "file": - fileReader, err := NewDashboardFileReader(config, logger.New("type", config.Type, "name", config.Name), service, store) + fileReader, err := NewDashboardFileReader( + config, + logger.New("type", config.Type, "name", config.Name), + service, + store, + folderService, + ) if err != nil { return nil, fmt.Errorf("failed to create file reader for config %v: %w", config.Name, err) } diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index df34fc8e68649..e80aefe1a9d37 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -13,8 +13,10 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/provisioning/utils" "github.com/grafana/grafana/pkg/util" ) @@ -34,6 +36,7 @@ type FileReader struct { dashboardProvisioningService dashboards.DashboardProvisioningService dashboardStore utils.DashboardStore FoldersFromFilesStructure bool + folderService folder.Service mux sync.RWMutex usageTracker *usageTracker @@ -41,7 +44,8 @@ type FileReader struct { } // NewDashboardFileReader returns a new filereader based on `config` -func NewDashboardFileReader(cfg *config, log log.Logger, service dashboards.DashboardProvisioningService, dashboardStore utils.DashboardStore) (*FileReader, error) { +func NewDashboardFileReader(cfg *config, log log.Logger, service dashboards.DashboardProvisioningService, + dashboardStore utils.DashboardStore, folderService folder.Service) (*FileReader, error) { var path string path, ok := cfg.Options["path"].(string) if !ok { @@ -64,6 +68,7 @@ func NewDashboardFileReader(cfg *config, log log.Logger, service dashboards.Dash log: log, dashboardProvisioningService: service, dashboardStore: dashboardStore, + folderService: folderService, FoldersFromFilesStructure: foldersFromFilesStructure, usageTracker: newUsageTracker(), }, nil @@ -243,27 +248,37 @@ func (fr *FileReader) saveDashboard(ctx context.Context, path string, folderID i // keeps track of which UIDs and titles we have already provisioned dash := jsonFile.dashboard provisioningMetadata.uid = dash.Dashboard.UID + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Provisioning).Inc() // nolint:staticcheck provisioningMetadata.identity = dashboardIdentity{title: dash.Dashboard.Title, folderID: dash.Dashboard.FolderID} // fix empty folder_uid from already provisioned dashboards if upToDate && folderUID != "" { + // search for root dashboard with the specified uid or title d, err := fr.dashboardStore.GetDashboard( ctx, &dashboards.GetDashboardQuery{ - OrgID: jsonFile.dashboard.OrgID, - UID: jsonFile.dashboard.Dashboard.UID, + OrgID: jsonFile.dashboard.OrgID, + UID: jsonFile.dashboard.Dashboard.UID, + Title: &jsonFile.dashboard.Dashboard.Title, + FolderUID: util.Pointer(""), }, ) if err != nil { - return provisioningMetadata, err - } - if d.FolderUID != folderUID { - upToDate = false + // if no problematic entry is found it's safe to ignore + if !errors.Is(err, dashboards.ErrDashboardNotFound) { + return provisioningMetadata, err + } + } else { + // inconsistency is detected so force updating the dashboard + if d.FolderUID != folderUID { + upToDate = false + } } } if upToDate { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Provisioning).Inc() // nolint:staticcheck fr.log.Debug("provisioned dashboard is up to date", "provisioner", fr.Cfg.Name, "file", path, "folderId", dash.Dashboard.FolderID, "folderUid", dash.Dashboard.FolderUID) return provisioningMetadata, nil @@ -279,6 +294,7 @@ func (fr *FileReader) saveDashboard(ctx context.Context, path string, folderID i } if !fr.isDatabaseAccessRestricted() { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Provisioning).Inc() // nolint:staticcheck fr.log.Debug("saving new dashboard", "provisioner", fr.Cfg.Name, "file", path, "folderId", dash.Dashboard.FolderID, "folderUid", dash.Dashboard.FolderUID) dp := &dashboards.DashboardProvisioning{ @@ -292,6 +308,7 @@ func (fr *FileReader) saveDashboard(ctx context.Context, path string, folderID i return provisioningMetadata, err } } else { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Provisioning).Inc() // nolint:staticcheck fr.log.Warn("Not saving new dashboard due to restricted database access", "provisioner", fr.Cfg.Name, "file", path, "folderId", dash.Dashboard.FolderID) @@ -320,6 +337,7 @@ func (fr *FileReader) getOrCreateFolder(ctx context.Context, cfg *config, servic return 0, "", ErrFolderNameMissing } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Provisioning).Inc() cmd := &dashboards.GetDashboardQuery{ Title: &folderName, FolderID: util.Pointer(int64(0)), // nolint:staticcheck @@ -333,22 +351,24 @@ func (fr *FileReader) getOrCreateFolder(ctx context.Context, cfg *config, servic // dashboard folder not found. create one. if errors.Is(err, dashboards.ErrDashboardNotFound) { - dash := &dashboards.SaveDashboardDTO{} - dash.Dashboard = dashboards.NewDashboardFolder(folderName) - dash.Dashboard.IsFolder = true - dash.Overwrite = true - dash.OrgID = cfg.OrgID // set dashboard folderUid if given if cfg.FolderUID == accesscontrol.GeneralFolderUID { return 0, "", dashboards.ErrFolderInvalidUID } - dash.Dashboard.SetUID(cfg.FolderUID) - dbDash, err := service.SaveFolderForProvisionedDashboards(ctx, dash) + + createCmd := &folder.CreateFolderCommand{ + OrgID: cfg.OrgID, + UID: cfg.FolderUID, + Title: folderName, + } + + f, err := service.SaveFolderForProvisionedDashboards(ctx, createCmd) if err != nil { return 0, "", err } - - return dbDash.ID, dbDash.UID, nil + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Provisioning).Inc() + // nolint:staticcheck + return f.ID, f.UID, nil } if !result.IsFolder { diff --git a/pkg/services/provisioning/dashboards/file_reader_symlink_test.go b/pkg/services/provisioning/dashboards/file_reader_symlink_test.go index 911d09a953bbc..3085da3cd75d6 100644 --- a/pkg/services/provisioning/dashboards/file_reader_symlink_test.go +++ b/pkg/services/provisioning/dashboards/file_reader_symlink_test.go @@ -26,7 +26,7 @@ func TestProvisionedSymlinkedFolder(t *testing.T) { Options: map[string]any{"path": symlinkedFolder}, } - reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil) + reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil) if err != nil { t.Error("expected err to be nil") } diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go index d886b11fe8b65..ac55b38e8515f 100644 --- a/pkg/services/provisioning/dashboards/file_reader_test.go +++ b/pkg/services/provisioning/dashboards/file_reader_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/util" ) @@ -41,7 +42,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) { t.Run("using path parameter", func(t *testing.T) { cfg := setup() cfg.Options["path"] = defaultDashboards - reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil) + reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil) require.NoError(t, err) require.NotEqual(t, reader.Path, "") }) @@ -49,7 +50,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) { t.Run("using folder as options", func(t *testing.T) { cfg := setup() cfg.Options["folder"] = defaultDashboards - reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil) + reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil) require.NoError(t, err) require.NotEqual(t, reader.Path, "") }) @@ -58,7 +59,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) { cfg := setup() cfg.Options["path"] = foldersFromFilesStructure cfg.Options["foldersFromFilesStructure"] = true - reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil) + reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil) require.NoError(t, err) require.NotEqual(t, reader.Path, "") }) @@ -71,7 +72,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) { } cfg.Options["folder"] = fullPath - reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil) + reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil) require.NoError(t, err) require.Equal(t, reader.Path, fullPath) @@ -81,7 +82,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) { t.Run("using relative path", func(t *testing.T) { cfg := setup() cfg.Options["folder"] = defaultDashboards - reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil) + reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil) require.NoError(t, err) resolvedPath := reader.resolvedPath() @@ -113,10 +114,10 @@ func TestDashboardFileReader(t *testing.T) { cfg.Folder = "Team A" fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(nil, nil).Once() - fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{ID: 1}, nil).Once() + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&folder.Folder{}, nil).Once() fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{ID: 2}, nil).Times(2) - reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, nil) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -136,7 +137,7 @@ func TestDashboardFileReader(t *testing.T) { inserted++ }) - reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, nil) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -173,7 +174,7 @@ func TestDashboardFileReader(t *testing.T) { fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(provisionedDashboard, nil).Once() - reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, nil) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -201,7 +202,7 @@ func TestDashboardFileReader(t *testing.T) { fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(provisionedDashboard, nil).Once() fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Once() - reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, nil) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -236,7 +237,7 @@ func TestDashboardFileReader(t *testing.T) { fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(provisionedDashboard, nil).Once() - reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, nil) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -264,7 +265,7 @@ func TestDashboardFileReader(t *testing.T) { fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(provisionedDashboard, nil).Once() fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Once() - reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, nil) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -279,7 +280,7 @@ func TestDashboardFileReader(t *testing.T) { fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(nil, nil).Once() fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Once() - reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, nil) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -293,10 +294,10 @@ func TestDashboardFileReader(t *testing.T) { cfg.Options["foldersFromFilesStructure"] = true fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(nil, nil).Once() - fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(2) + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&folder.Folder{}, nil).Times(2) fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(3) - reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, nil) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -313,7 +314,7 @@ func TestDashboardFileReader(t *testing.T) { Folder: "", } - _, err := NewDashboardFileReader(cfg, logger, nil, nil) + _, err := NewDashboardFileReader(cfg, logger, nil, nil, nil) require.NotNil(t, err) }) @@ -321,7 +322,7 @@ func TestDashboardFileReader(t *testing.T) { setup() cfg.Options["path"] = brokenDashboards - _, err := NewDashboardFileReader(cfg, logger, nil, nil) + _, err := NewDashboardFileReader(cfg, logger, nil, nil, nil) require.NoError(t, err) }) @@ -331,17 +332,17 @@ func TestDashboardFileReader(t *testing.T) { cfg2 := &config{Name: "2", Type: "file", OrgID: 1, Folder: "f2", Options: map[string]any{"path": containingID}} fakeService.On("GetProvisionedDashboardData", mock.Anything, mock.AnythingOfType("string")).Return(nil, nil).Times(2) - fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(2) + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&folder.Folder{}, nil).Times(2) fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(2) - reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore) + reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore, nil) reader1.dashboardProvisioningService = fakeService require.NoError(t, err) err = reader1.walkDisk(context.Background()) require.NoError(t, err) - reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore) + reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore, nil) reader2.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -361,7 +362,7 @@ func TestDashboardFileReader(t *testing.T) { "folder": defaultDashboards, }, } - r, err := NewDashboardFileReader(cfg, logger, nil, nil) + r, err := NewDashboardFileReader(cfg, logger, nil, nil, nil) require.NoError(t, err) _, _, err = r.getOrCreateFolder(context.Background(), cfg, fakeService, cfg.Folder) @@ -379,9 +380,9 @@ func TestDashboardFileReader(t *testing.T) { "folder": defaultDashboards, }, } - fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{ID: 1}, nil).Once() + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&folder.Folder{}, nil).Once() - r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) + r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, nil) require.NoError(t, err) _, _, err = r.getOrCreateFolder(context.Background(), cfg, fakeService, cfg.Folder) @@ -401,7 +402,7 @@ func TestDashboardFileReader(t *testing.T) { }, } - r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) + r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, nil) require.NoError(t, err) _, _, err = r.getOrCreateFolder(context.Background(), cfg, fakeService, cfg.Folder) @@ -456,7 +457,7 @@ func TestDashboardFileReader(t *testing.T) { cfg.DisableDeletion = true - reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, nil) require.NoError(t, err) reader.dashboardProvisioningService = fakeService @@ -471,7 +472,7 @@ func TestDashboardFileReader(t *testing.T) { fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Once() fakeService.On("DeleteProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() - reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, nil) reader.dashboardProvisioningService = fakeService require.NoError(t, err) diff --git a/pkg/services/provisioning/dashboards/types.go b/pkg/services/provisioning/dashboards/types.go index 87b365a8ffb81..18e58c1ce7fb4 100644 --- a/pkg/services/provisioning/dashboards/types.go +++ b/pkg/services/provisioning/dashboards/types.go @@ -5,6 +5,7 @@ import ( "time" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/provisioning/values" ) @@ -63,6 +64,7 @@ func createDashboardJSON(data *simplejson.Json, lastModified time.Time, cfg *con dash.Overwrite = true dash.OrgID = cfg.OrgID dash.Dashboard.OrgID = cfg.OrgID + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Provisioning).Inc() // nolint:staticcheck dash.Dashboard.FolderID = folderID dash.Dashboard.FolderUID = folderUID diff --git a/pkg/services/provisioning/dashboards/validator.go b/pkg/services/provisioning/dashboards/validator.go index 1dab51c79eaea..dd3aa5b092740 100644 --- a/pkg/services/provisioning/dashboards/validator.go +++ b/pkg/services/provisioning/dashboards/validator.go @@ -5,6 +5,7 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/metrics" ) type duplicate struct { @@ -102,6 +103,7 @@ func (c *duplicateValidator) logWarnings(duplicatesByOrg map[int64]duplicateEntr for id, usage := range duplicates.Titles { if usage.Sum > 1 { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Provisioning).Inc() // nolint:staticcheck c.logger.Warn("dashboard title is not unique in folder", "orgId", orgID, "title", id.title, "folderID", id.folderID, "times", usage.Sum, "providers", keysToSlice(usage.InvolvedReaders)) diff --git a/pkg/services/provisioning/dashboards/validator_test.go b/pkg/services/provisioning/dashboards/validator_test.go index 18975387f73dc..de8d438d02f14 100644 --- a/pkg/services/provisioning/dashboards/validator_test.go +++ b/pkg/services/provisioning/dashboards/validator_test.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" ) const ( @@ -34,9 +35,9 @@ func TestDuplicatesValidator(t *testing.T) { const folderName = "duplicates-validator-folder" fakeStore := &fakeDashboardStore{} - r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) + r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, nil) require.NoError(t, err) - fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(6) + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&folder.Folder{}, nil).Times(6) fakeService.On("GetProvisionedDashboardData", mock.Anything, mock.AnythingOfType("string")).Return([]*dashboards.DashboardProvisioning{}, nil).Times(4) fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(5) _, folderUID, err := r.getOrCreateFolder(context.Background(), cfg, fakeService, folderName) @@ -53,11 +54,11 @@ func TestDuplicatesValidator(t *testing.T) { Options: map[string]any{"path": dashboardContainingUID}, } - reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore) + reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore, nil) reader1.dashboardProvisioningService = fakeService require.NoError(t, err) - reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore) + reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore, nil) reader2.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -90,7 +91,7 @@ func TestDuplicatesValidator(t *testing.T) { const folderName = "duplicates-validator-folder" fakeStore := &fakeDashboardStore{} - r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) + r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, nil) require.NoError(t, err) _, folderUID, err := r.getOrCreateFolder(context.Background(), cfg, fakeService, folderName) require.NoError(t, err) @@ -106,11 +107,11 @@ func TestDuplicatesValidator(t *testing.T) { Options: map[string]any{"path": dashboardContainingUID}, } - reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore) + reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore, nil) reader1.dashboardProvisioningService = fakeService require.NoError(t, err) - reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore) + reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore, nil) reader2.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -150,7 +151,7 @@ func TestDuplicatesValidator(t *testing.T) { }) t.Run("Duplicates validator should restrict write access only for readers with duplicates", func(t *testing.T) { - fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(5) + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&folder.Folder{}, nil).Times(5) fakeService.On("GetProvisionedDashboardData", mock.Anything, mock.AnythingOfType("string")).Return([]*dashboards.DashboardProvisioning{}, nil).Times(3) fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(5) fakeStore := &fakeDashboardStore{} @@ -167,15 +168,15 @@ func TestDuplicatesValidator(t *testing.T) { Name: "third", Type: "file", OrgID: 2, Folder: "duplicates-validator-folder", Options: map[string]any{"path": twoDashboardsWithUID}, } - reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore) + reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore, nil) reader1.dashboardProvisioningService = fakeService require.NoError(t, err) - reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore) + reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore, nil) reader2.dashboardProvisioningService = fakeService require.NoError(t, err) - reader3, err := NewDashboardFileReader(cfg3, logger, nil, fakeStore) + reader3, err := NewDashboardFileReader(cfg3, logger, nil, fakeStore, nil) reader3.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -192,7 +193,7 @@ func TestDuplicatesValidator(t *testing.T) { duplicates := duplicateValidator.getDuplicates() - r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) + r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, nil) require.NoError(t, err) _, folderUID, err := r.getOrCreateFolder(context.Background(), cfg, fakeService, cfg1.Folder) require.NoError(t, err) @@ -209,7 +210,7 @@ func TestDuplicatesValidator(t *testing.T) { sort.Strings(titleUsageReaders) require.Equal(t, []string{"first"}, titleUsageReaders) - r, err = NewDashboardFileReader(cfg3, logger, nil, fakeStore) + r, err = NewDashboardFileReader(cfg3, logger, nil, fakeStore, nil) require.NoError(t, err) _, folderUID, err = r.getOrCreateFolder(context.Background(), cfg3, fakeService, cfg3.Folder) require.NoError(t, err) diff --git a/pkg/services/provisioning/notifiers/alert_notifications.go b/pkg/services/provisioning/notifiers/alert_notifications.go deleted file mode 100644 index a7baedb8a4aae..0000000000000 --- a/pkg/services/provisioning/notifiers/alert_notifications.go +++ /dev/null @@ -1,176 +0,0 @@ -package notifiers - -import ( - "context" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/encryption" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/services/org" -) - -type Manager interface { - GetAlertNotifications(ctx context.Context, query *models.GetAlertNotificationsQuery) (*models.AlertNotification, error) - CreateAlertNotificationCommand(ctx context.Context, cmd *models.CreateAlertNotificationCommand) (*models.AlertNotification, error) - UpdateAlertNotification(ctx context.Context, cmd *models.UpdateAlertNotificationCommand) (*models.AlertNotification, error) - DeleteAlertNotification(ctx context.Context, cmd *models.DeleteAlertNotificationCommand) error - GetAllAlertNotifications(ctx context.Context, query *models.GetAllAlertNotificationsQuery) ([]*models.AlertNotification, error) - GetOrCreateAlertNotificationState(ctx context.Context, cmd *models.GetOrCreateNotificationStateQuery) (*models.AlertNotificationState, error) - SetAlertNotificationStateToCompleteCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToCompleteCommand) error - SetAlertNotificationStateToPendingCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToPendingCommand) error - GetAlertNotificationsWithUid(ctx context.Context, query *models.GetAlertNotificationsWithUidQuery) (*models.AlertNotification, error) - DeleteAlertNotificationWithUid(ctx context.Context, cmd *models.DeleteAlertNotificationWithUidCommand) error - GetAlertNotificationsWithUidToSend(ctx context.Context, query *models.GetAlertNotificationsWithUidToSendQuery) ([]*models.AlertNotification, error) - UpdateAlertNotificationWithUid(ctx context.Context, cmd *models.UpdateAlertNotificationWithUidCommand) (*models.AlertNotification, error) -} - -// Provision alert notifiers -func Provision(ctx context.Context, configDirectory string, alertingService Manager, orgService org.Service, encryptionService encryption.Internal, notificationService *notifications.NotificationService) error { - dc := newNotificationProvisioner(orgService, alertingService, encryptionService, notificationService, log.New("provisioning.notifiers")) - return dc.applyChanges(ctx, configDirectory) -} - -// NotificationProvisioner is responsible for provsioning alert notifiers -type NotificationProvisioner struct { - log log.Logger - cfgProvider *configReader - alertingManager Manager - orgService org.Service -} - -func newNotificationProvisioner(orgService org.Service, alertingManager Manager, encryptionService encryption.Internal, notifiationService *notifications.NotificationService, log log.Logger) NotificationProvisioner { - return NotificationProvisioner{ - log: log, - alertingManager: alertingManager, - cfgProvider: &configReader{ - encryptionService: encryptionService, - notificationService: notifiationService, - log: log, - orgService: orgService, - }, - orgService: orgService, - } -} - -func (dc *NotificationProvisioner) apply(ctx context.Context, cfg *notificationsAsConfig) error { - if err := dc.deleteNotifications(ctx, cfg.DeleteNotifications); err != nil { - return err - } - - if err := dc.mergeNotifications(ctx, cfg.Notifications); err != nil { - return err - } - - return nil -} - -func (dc *NotificationProvisioner) deleteNotifications(ctx context.Context, notificationToDelete []*deleteNotificationConfig) error { - for _, notification := range notificationToDelete { - dc.log.Info("Deleting alert notification", "name", notification.Name, "uid", notification.UID) - - if notification.OrgID == 0 && notification.OrgName != "" { - getOrg := org.GetOrgByNameQuery{Name: notification.OrgName} - res, err := dc.orgService.GetByName(ctx, &getOrg) - if err != nil { - return err - } - notification.OrgID = res.ID - } else if notification.OrgID < 0 { - notification.OrgID = 1 - } - - getNotification := &models.GetAlertNotificationsWithUidQuery{UID: notification.UID, OrgID: notification.OrgID} - - res, err := dc.alertingManager.GetAlertNotificationsWithUid(ctx, getNotification) - if err != nil { - return err - } - - if res != nil { - cmd := &models.DeleteAlertNotificationWithUidCommand{UID: res.UID, OrgID: getNotification.OrgID} - if err := dc.alertingManager.DeleteAlertNotificationWithUid(ctx, cmd); err != nil { - return err - } - } - } - - return nil -} - -func (dc *NotificationProvisioner) mergeNotifications(ctx context.Context, notificationToMerge []*notificationFromConfig) error { - for _, notification := range notificationToMerge { - if notification.OrgID == 0 && notification.OrgName != "" { - getOrg := org.GetOrgByNameQuery{Name: notification.OrgName} - res, err := dc.orgService.GetByName(ctx, &getOrg) - if err != nil { - return err - } - notification.OrgID = res.ID - } else if notification.OrgID < 0 { - notification.OrgID = 1 - } - - cmd := &models.GetAlertNotificationsWithUidQuery{OrgID: notification.OrgID, UID: notification.UID} - res, err := dc.alertingManager.GetAlertNotificationsWithUid(ctx, cmd) - if err != nil { - return err - } - - if res == nil { - dc.log.Debug("inserting alert notification from configuration", "name", notification.Name, "uid", notification.UID) - insertCmd := &models.CreateAlertNotificationCommand{ - UID: notification.UID, - Name: notification.Name, - Type: notification.Type, - IsDefault: notification.IsDefault, - Settings: notification.SettingsToJSON(), - SecureSettings: notification.SecureSettings, - OrgID: notification.OrgID, - DisableResolveMessage: notification.DisableResolveMessage, - Frequency: notification.Frequency, - SendReminder: notification.SendReminder, - } - - _, err := dc.alertingManager.CreateAlertNotificationCommand(ctx, insertCmd) - if err != nil { - return err - } - } else { - dc.log.Debug("updating alert notification from configuration", "name", notification.Name) - updateCmd := &models.UpdateAlertNotificationWithUidCommand{ - UID: notification.UID, - Name: notification.Name, - Type: notification.Type, - IsDefault: notification.IsDefault, - Settings: notification.SettingsToJSON(), - SecureSettings: notification.SecureSettings, - OrgID: notification.OrgID, - DisableResolveMessage: notification.DisableResolveMessage, - Frequency: notification.Frequency, - SendReminder: notification.SendReminder, - } - - if _, err := dc.alertingManager.UpdateAlertNotificationWithUid(ctx, updateCmd); err != nil { - return err - } - } - } - - return nil -} - -func (dc *NotificationProvisioner) applyChanges(ctx context.Context, configPath string) error { - configs, err := dc.cfgProvider.readConfig(ctx, configPath) - if err != nil { - return err - } - - for _, cfg := range configs { - if err := dc.apply(ctx, cfg); err != nil { - return err - } - } - - return nil -} diff --git a/pkg/services/provisioning/notifiers/config_reader.go b/pkg/services/provisioning/notifiers/config_reader.go deleted file mode 100644 index 7e87891a932ca..0000000000000 --- a/pkg/services/provisioning/notifiers/config_reader.go +++ /dev/null @@ -1,192 +0,0 @@ -package notifiers - -import ( - "context" - "fmt" - "io/fs" - "os" - "path/filepath" - "strings" - - "gopkg.in/yaml.v3" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/encryption" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/provisioning/utils" - "github.com/grafana/grafana/pkg/setting" -) - -type configReader struct { - encryptionService encryption.Internal - notificationService *notifications.NotificationService - orgService org.Service - log log.Logger -} - -func (cr *configReader) readConfig(ctx context.Context, path string) ([]*notificationsAsConfig, error) { - var notifications []*notificationsAsConfig - cr.log.Debug("Looking for alert notification provisioning files", "path", path) - - files, err := os.ReadDir(path) - if err != nil { - cr.log.Error("Can't read alert notification provisioning files from directory", "path", path, "error", err) - return notifications, nil - } - - for _, file := range files { - if strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml") { - cr.log.Debug("Parsing alert notifications provisioning file", "path", path, "file.Name", file.Name()) - notifs, err := cr.parseNotificationConfig(path, file) - if err != nil { - return nil, err - } - - if notifs != nil { - notifications = append(notifications, notifs) - } - } - } - - cr.log.Debug("Validating alert notifications") - if err = cr.validateRequiredField(notifications); err != nil { - return nil, err - } - - if err := cr.checkOrgIDAndOrgName(ctx, notifications); err != nil { - return nil, err - } - - if err := cr.validateNotifications(notifications); err != nil { - return nil, err - } - - return notifications, nil -} - -func (cr *configReader) parseNotificationConfig(path string, file fs.DirEntry) (*notificationsAsConfig, error) { - filename, _ := filepath.Abs(filepath.Join(path, file.Name())) - - // nolint:gosec - // We can ignore the gosec G304 warning on this one because `filename` comes from ps.Cfg.ProvisioningPath - yamlFile, err := os.ReadFile(filename) - if err != nil { - return nil, err - } - - var cfg *notificationsAsConfigV0 - err = yaml.Unmarshal(yamlFile, &cfg) - if err != nil { - return nil, err - } - - return cfg.mapToNotificationFromConfig(), nil -} - -func (cr *configReader) checkOrgIDAndOrgName(ctx context.Context, notifications []*notificationsAsConfig) error { - for i := range notifications { - for _, notification := range notifications[i].Notifications { - if notification.OrgID < 1 { - if notification.OrgName == "" { - notification.OrgID = 1 - } else { - notification.OrgID = 0 - } - } else { - if err := utils.CheckOrgExists(ctx, cr.orgService, notification.OrgID); err != nil { - return fmt.Errorf("failed to provision %q notification: %w", notification.Name, err) - } - } - } - - for _, notification := range notifications[i].DeleteNotifications { - if notification.OrgID < 1 { - if notification.OrgName == "" { - notification.OrgID = 1 - } else { - notification.OrgID = 0 - } - } - } - } - return nil -} - -func (cr *configReader) validateRequiredField(notifications []*notificationsAsConfig) error { - for i := range notifications { - var errStrings []string - for index, notification := range notifications[i].Notifications { - if notification.Name == "" { - errStrings = append( - errStrings, - fmt.Sprintf("Added alert notification item %d in configuration doesn't contain required field name", index+1), - ) - } - - if notification.UID == "" { - errStrings = append( - errStrings, - fmt.Sprintf("Added alert notification item %d in configuration doesn't contain required field uid", index+1), - ) - } - } - - for index, notification := range notifications[i].DeleteNotifications { - if notification.Name == "" { - errStrings = append( - errStrings, - fmt.Sprintf("Deleted alert notification item %d in configuration doesn't contain required field name", index+1), - ) - } - - if notification.UID == "" { - errStrings = append( - errStrings, - fmt.Sprintf("Deleted alert notification item %d in configuration doesn't contain required field uid", index+1), - ) - } - } - - if len(errStrings) != 0 { - return fmt.Errorf(strings.Join(errStrings, "\n")) - } - } - - return nil -} - -func (cr *configReader) validateNotifications(notifications []*notificationsAsConfig) error { - for i := range notifications { - if notifications[i].Notifications == nil { - continue - } - - for _, notification := range notifications[i].Notifications { - encryptedSecureSettings, err := cr.encryptionService.EncryptJsonData( - context.Background(), - notification.SecureSettings, - setting.SecretKey, - ) - - if err != nil { - return err - } - - _, err = alerting.InitNotifier(&models.AlertNotification{ - Name: notification.Name, - Settings: notification.SettingsToJSON(), - SecureSettings: encryptedSecureSettings, - Type: notification.Type, - }, cr.encryptionService.GetDecryptedValue, cr.notificationService) - - if err != nil { - return err - } - } - } - - return nil -} diff --git a/pkg/services/provisioning/notifiers/config_reader_test.go b/pkg/services/provisioning/notifiers/config_reader_test.go deleted file mode 100644 index 8f0bd71bb6733..0000000000000 --- a/pkg/services/provisioning/notifiers/config_reader_test.go +++ /dev/null @@ -1,369 +0,0 @@ -package notifiers - -import ( - "context" - "fmt" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/alerting/models" - "github.com/grafana/grafana/pkg/services/alerting/notifiers" - encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/org/orgimpl" - "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/sqlstore" -) - -var ( - correctProperties = "./testdata/test-configs/correct-properties" - incorrectSettings = "./testdata/test-configs/incorrect-settings" - noRequiredFields = "./testdata/test-configs/no-required-fields" - correctPropertiesWithOrgName = "./testdata/test-configs/correct-properties-with-orgName" - brokenYaml = "./testdata/test-configs/broken-yaml" - doubleNotificationsConfig = "./testdata/test-configs/double-default" - emptyFolder = "./testdata/test-configs/empty_folder" - emptyFile = "./testdata/test-configs/empty" - twoNotificationsConfig = "./testdata/test-configs/two-notifications" - unknownNotifier = "./testdata/test-configs/unknown-notifier" -) - -func TestNotificationAsConfig(t *testing.T) { - var sqlStore *sqlstore.SQLStore - var orgService org.Service - var ns *alerting.AlertNotificationService - logger := log.New("fake.log") - - encryptionService := encryptionservice.SetupTestService(t) - - t.Run("Testing notification as configuration", func(t *testing.T) { - setup := func() { - sqlStore = db.InitTestDB(t) - orgService, _ = orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotatest.New(false, nil)) - nm := ¬ifications.NotificationService{} - ns = alerting.ProvideService(sqlStore, encryptionService, nm) - - for i := 1; i < 5; i++ { - orgCommand := org.CreateOrgCommand{Name: fmt.Sprintf("Main Org. %v", i)} - _, err := orgService.CreateWithMember(context.Background(), &orgCommand) - require.NoError(t, err) - } - - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "slack", - Name: "slack", - Factory: notifiers.NewSlackNotifier, - }) - - alerting.RegisterNotifier(&alerting.NotifierPlugin{ - Type: "email", - Name: "email", - Factory: notifiers.NewEmailNotifier, - }) - } - - t.Run("Can read correct properties", func(t *testing.T) { - setup() - t.Setenv("TEST_VAR", "default") - cfgProvider := &configReader{ - orgService: orgService, - encryptionService: encryptionService, - log: log.New("test logger"), - } - - cfg, err := cfgProvider.readConfig(context.Background(), correctProperties) - if err != nil { - t.Fatalf("readConfig return an error %v", err) - } - require.Equal(t, len(cfg), 1) - - ntCfg := cfg[0] - nts := ntCfg.Notifications - require.Equal(t, len(nts), 4) - - nt := nts[0] - require.Equal(t, nt.Name, "default-slack-notification") - require.Equal(t, nt.Type, "slack") - require.Equal(t, nt.OrgID, int64(2)) - require.Equal(t, nt.UID, "notifier1") - require.True(t, nt.IsDefault) - require.Equal(t, nt.Settings, map[string]any{ - "recipient": "XXX", "token": "xoxb", "uploadImage": true, "url": "https://slack.com", - }) - require.Equal(t, nt.SecureSettings, map[string]string{ - "token": "xoxbsecure", "url": "https://slack.com/secure", - }) - require.True(t, nt.SendReminder) - require.Equal(t, nt.Frequency, "1h") - - nt = nts[1] - require.Equal(t, nt.Name, "another-not-default-notification") - require.Equal(t, nt.Type, "email") - require.Equal(t, nt.OrgID, int64(3)) - require.Equal(t, nt.UID, "notifier2") - require.False(t, nt.IsDefault) - - nt = nts[2] - require.Equal(t, nt.Name, "check-unset-is_default-is-false") - require.Equal(t, nt.Type, "slack") - require.Equal(t, nt.OrgID, int64(3)) - require.Equal(t, nt.UID, "notifier3") - require.False(t, nt.IsDefault) - - nt = nts[3] - require.Equal(t, nt.Name, "Added notification with whitespaces in name") - require.Equal(t, nt.Type, "email") - require.Equal(t, nt.UID, "notifier4") - require.Equal(t, nt.OrgID, int64(3)) - - deleteNts := ntCfg.DeleteNotifications - require.Equal(t, len(deleteNts), 4) - - deleteNt := deleteNts[0] - require.Equal(t, deleteNt.Name, "default-slack-notification") - require.Equal(t, deleteNt.UID, "notifier1") - require.Equal(t, deleteNt.OrgID, int64(2)) - - deleteNt = deleteNts[1] - require.Equal(t, deleteNt.Name, "deleted-notification-without-orgId") - require.Equal(t, deleteNt.OrgID, int64(1)) - require.Equal(t, deleteNt.UID, "notifier2") - - deleteNt = deleteNts[2] - require.Equal(t, deleteNt.Name, "deleted-notification-with-0-orgId") - require.Equal(t, deleteNt.OrgID, int64(1)) - require.Equal(t, deleteNt.UID, "notifier3") - - deleteNt = deleteNts[3] - require.Equal(t, deleteNt.Name, "Deleted notification with whitespaces in name") - require.Equal(t, deleteNt.OrgID, int64(1)) - require.Equal(t, deleteNt.UID, "notifier4") - }) - - t.Run("One configured notification", func(t *testing.T) { - t.Run("no notification in database", func(t *testing.T) { - setup() - fakeAlertNotification := &fakeAlertNotification{} - fakeAlertNotification.ExpectedAlertNotification = &models.AlertNotification{OrgID: 1} - dc := newNotificationProvisioner(orgService, fakeAlertNotification, encryptionService, nil, logger) - - err := dc.applyChanges(context.Background(), twoNotificationsConfig) - if err != nil { - t.Fatalf("applyChanges return an error %v", err) - } - }) - - t.Run("One notification in database with same name and uid", func(t *testing.T) { - setup() - existingNotificationCmd := models.CreateAlertNotificationCommand{ - Name: "channel1", - OrgID: 1, - UID: "notifier1", - Type: "slack", - } - res, err := ns.SQLStore.CreateAlertNotificationCommand(context.Background(), &existingNotificationCmd) - require.NoError(t, err) - require.NotNil(t, res) - notificationsQuery := models.GetAllAlertNotificationsQuery{OrgID: 1} - results, err := ns.SQLStore.GetAllAlertNotifications(context.Background(), ¬ificationsQuery) - require.NoError(t, err) - require.NotNil(t, results) - require.Equal(t, len(results), 1) - - t.Run("should update one notification", func(t *testing.T) { - dc := newNotificationProvisioner(orgService, &fakeAlertNotification{}, encryptionService, nil, logger) - err = dc.applyChanges(context.Background(), twoNotificationsConfig) - if err != nil { - t.Fatalf("applyChanges return an error %v", err) - } - }) - }) - - t.Run("Two notifications with is_default", func(t *testing.T) { - setup() - dc := newNotificationProvisioner(orgService, &fakeAlertNotification{}, encryptionService, nil, logger) - err := dc.applyChanges(context.Background(), doubleNotificationsConfig) - t.Run("should both be inserted", func(t *testing.T) { - require.NoError(t, err) - }) - }) - }) - - t.Run("Two configured notification", func(t *testing.T) { - t.Run("two other notifications in database", func(t *testing.T) { - setup() - existingNotificationCmd := models.CreateAlertNotificationCommand{ - Name: "channel0", - OrgID: 1, - UID: "notifier0", - Type: "slack", - } - _, err := ns.SQLStore.CreateAlertNotificationCommand(context.Background(), &existingNotificationCmd) - require.NoError(t, err) - existingNotificationCmd = models.CreateAlertNotificationCommand{ - Name: "channel3", - OrgID: 1, - UID: "notifier3", - Type: "slack", - } - _, err = ns.SQLStore.CreateAlertNotificationCommand(context.Background(), &existingNotificationCmd) - require.NoError(t, err) - - notificationsQuery := models.GetAllAlertNotificationsQuery{OrgID: 1} - res, err := ns.GetAllAlertNotifications(context.Background(), ¬ificationsQuery) - require.NoError(t, err) - require.NotNil(t, res) - require.Equal(t, len(res), 2) - - t.Run("should have two new notifications", func(t *testing.T) { - dc := newNotificationProvisioner(orgService, &fakeAlertNotification{}, encryptionService, nil, logger) - err := dc.applyChanges(context.Background(), twoNotificationsConfig) - if err != nil { - t.Fatalf("applyChanges return an error %v", err) - } - }) - }) - }) - - t.Run("Can read correct properties with orgName instead of orgId", func(t *testing.T) { - setup() - - existingNotificationCmd := models.CreateAlertNotificationCommand{ - Name: "default-notification-delete", - OrgID: 1, - UID: "notifier2", - Type: "slack", - } - _, err := ns.SQLStore.CreateAlertNotificationCommand(context.Background(), &existingNotificationCmd) - require.NoError(t, err) - - dc := newNotificationProvisioner(orgService, &fakeAlertNotification{}, encryptionService, nil, logger) - err = dc.applyChanges(context.Background(), correctPropertiesWithOrgName) - if err != nil { - t.Fatalf("applyChanges return an error %v", err) - } - }) - - t.Run("Config doesn't contain required field", func(t *testing.T) { - setup() - dc := newNotificationProvisioner(orgService, &fakeAlertNotification{}, encryptionService, nil, logger) - err := dc.applyChanges(context.Background(), noRequiredFields) - require.NotNil(t, err) - - errString := err.Error() - require.Contains(t, errString, "Deleted alert notification item 1 in configuration doesn't contain required field uid") - require.Contains(t, errString, "Deleted alert notification item 2 in configuration doesn't contain required field name") - require.Contains(t, errString, "Added alert notification item 1 in configuration doesn't contain required field name") - require.Contains(t, errString, "Added alert notification item 2 in configuration doesn't contain required field uid") - }) - - t.Run("Empty yaml file", func(t *testing.T) { - t.Run("should have not changed repo", func(t *testing.T) { - setup() - dc := newNotificationProvisioner(orgService, &fakeAlertNotification{}, encryptionService, nil, logger) - err := dc.applyChanges(context.Background(), emptyFile) - if err != nil { - t.Fatalf("applyChanges return an error %v", err) - } - notificationsQuery := models.GetAllAlertNotificationsQuery{OrgID: 1} - res, err := ns.GetAllAlertNotifications(context.Background(), ¬ificationsQuery) - require.NoError(t, err) - require.Empty(t, res) - }) - }) - - t.Run("Broken yaml should return error", func(t *testing.T) { - reader := &configReader{ - orgService: orgService, - encryptionService: encryptionService, - log: log.New("test logger"), - } - - _, err := reader.readConfig(context.Background(), brokenYaml) - require.NotNil(t, err) - }) - - t.Run("Skip invalid directory", func(t *testing.T) { - cfgProvider := &configReader{ - orgService: orgService, - encryptionService: encryptionService, - log: log.New("test logger"), - } - - cfg, err := cfgProvider.readConfig(context.Background(), emptyFolder) - if err != nil { - t.Fatalf("readConfig return an error %v", err) - } - require.Equal(t, len(cfg), 0) - }) - - t.Run("Unknown notifier should return error", func(t *testing.T) { - cfgProvider := &configReader{ - orgService: orgService, - encryptionService: encryptionService, - log: log.New("test logger"), - } - _, err := cfgProvider.readConfig(context.Background(), unknownNotifier) - require.NotNil(t, err) - require.Equal(t, err.Error(), `unsupported notification type "nonexisting"`) - }) - - t.Run("Read incorrect properties", func(t *testing.T) { - cfgProvider := &configReader{ - orgService: orgService, - encryptionService: encryptionService, - log: log.New("test logger"), - } - _, err := cfgProvider.readConfig(context.Background(), incorrectSettings) - require.NotNil(t, err) - require.Equal(t, err.Error(), "alert validation error: token must be specified when using the Slack chat API") - }) - }) -} - -type fakeAlertNotification struct { - ExpectedAlertNotification *models.AlertNotification -} - -func (f *fakeAlertNotification) GetAlertNotifications(ctx context.Context, query *models.GetAlertNotificationsQuery) (*models.AlertNotification, error) { - return f.ExpectedAlertNotification, nil -} -func (f *fakeAlertNotification) CreateAlertNotificationCommand(ctx context.Context, cmd *models.CreateAlertNotificationCommand) (*models.AlertNotification, error) { - return nil, nil -} -func (f *fakeAlertNotification) UpdateAlertNotification(ctx context.Context, cmd *models.UpdateAlertNotificationCommand) (*models.AlertNotification, error) { - return nil, nil -} -func (f *fakeAlertNotification) DeleteAlertNotification(ctx context.Context, cmd *models.DeleteAlertNotificationCommand) error { - return nil -} -func (f *fakeAlertNotification) GetAllAlertNotifications(ctx context.Context, query *models.GetAllAlertNotificationsQuery) ([]*models.AlertNotification, error) { - return nil, nil -} -func (f *fakeAlertNotification) GetOrCreateAlertNotificationState(ctx context.Context, cmd *models.GetOrCreateNotificationStateQuery) (*models.AlertNotificationState, error) { - return nil, nil -} -func (f *fakeAlertNotification) SetAlertNotificationStateToCompleteCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToCompleteCommand) error { - return nil -} -func (f *fakeAlertNotification) SetAlertNotificationStateToPendingCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToPendingCommand) error { - return nil -} -func (f *fakeAlertNotification) GetAlertNotificationsWithUid(ctx context.Context, query *models.GetAlertNotificationsWithUidQuery) (*models.AlertNotification, error) { - return nil, nil -} -func (f *fakeAlertNotification) DeleteAlertNotificationWithUid(ctx context.Context, cmd *models.DeleteAlertNotificationWithUidCommand) error { - return nil -} -func (f *fakeAlertNotification) GetAlertNotificationsWithUidToSend(ctx context.Context, query *models.GetAlertNotificationsWithUidToSendQuery) ([]*models.AlertNotification, error) { - return nil, nil -} - -func (f *fakeAlertNotification) UpdateAlertNotificationWithUid(ctx context.Context, cmd *models.UpdateAlertNotificationWithUidCommand) (*models.AlertNotification, error) { - return nil, nil -} diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/broken.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/broken.yaml deleted file mode 100644 index 72f2fbdbf635f..0000000000000 --- a/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/broken.yaml +++ /dev/null @@ -1,9 +0,0 @@ -notifiers: - - name: notification-channel-1 - type: slack - org_id: 2 - is_default: true - settings: - recipient: "XXX" - token: "xoxb" - uploadImage: true \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/not.yaml.text b/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/not.yaml.text deleted file mode 100644 index 9050f543cefdf..0000000000000 --- a/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/not.yaml.text +++ /dev/null @@ -1,6 +0,0 @@ -#sfxzgnsxzcvnbzcvn -cvbn -cvbn -c -vbn -cvbncvbn \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties-with-orgName/correct-properties-with-orgName.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties-with-orgName/correct-properties-with-orgName.yaml deleted file mode 100644 index 25c4536d1f397..0000000000000 --- a/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties-with-orgName/correct-properties-with-orgName.yaml +++ /dev/null @@ -1,12 +0,0 @@ -notifiers: - - name: default-notification-create - type: email - uid: notifier2 - settings: - addresses: example@example.com - org_name: Main Org. 2 - is_default: false -delete_notifiers: - - name: default-notification-delete - org_name: Main Org. 2 - uid: notifier2 \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties/correct-properties.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties/correct-properties.yaml deleted file mode 100644 index 417455ff85782..0000000000000 --- a/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties/correct-properties.yaml +++ /dev/null @@ -1,46 +0,0 @@ -notifiers: - - name: $TEST_VAR-slack-notification - type: slack - uid: notifier1 - org_id: 2 - is_default: true - send_reminder: true - frequency: 1h - settings: - recipient: "XXX" - token: "xoxb" - uploadImage: true - url: https://slack.com - secure_settings: - url: https://slack.com/secure - token: "xoxbsecure" - - name: another-not-default-notification - type: email - settings: - addresses: example@example.com - org_id: 3 - uid: "notifier2" - is_default: false - - name: check-unset-is_default-is-false - type: slack - org_id: 3 - uid: "notifier3" - settings: - url: https://slack.com - - name: Added notification with whitespaces in name - type: email - org_id: 3 - uid: "notifier4" - settings: - addresses: example@example.com -delete_notifiers: - - name: default-slack-notification - org_id: 2 - uid: notifier1 - - name: deleted-notification-without-orgId - uid: "notifier2" - - name: deleted-notification-with-0-orgId - org_id: 0 - uid: "notifier3" - - name: Deleted notification with whitespaces in name - uid: "notifier4" diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-1.yml b/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-1.yml deleted file mode 100644 index d9d2fe6608117..0000000000000 --- a/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-1.yml +++ /dev/null @@ -1,7 +0,0 @@ -notifiers: - - name: first-default - type: slack - uid: notifier1 - is_default: true - settings: - url: https://slack.com \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-2.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-2.yaml deleted file mode 100644 index 878f8b48aa56e..0000000000000 --- a/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-2.yaml +++ /dev/null @@ -1,7 +0,0 @@ -notifiers: - - name: second-default - type: email - uid: notifier2 - is_default: true - settings: - addresses: example@example.com \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/empty_folder/.gitignore b/pkg/services/provisioning/notifiers/testdata/test-configs/empty_folder/.gitignore deleted file mode 100644 index 86d0cb2726c6c..0000000000000 --- a/pkg/services/provisioning/notifiers/testdata/test-configs/empty_folder/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Ignore everything in this directory -* -# Except this file -!.gitignore \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/incorrect-settings/incorrect-settings.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/incorrect-settings/incorrect-settings.yaml deleted file mode 100644 index b79c6e9761c92..0000000000000 --- a/pkg/services/provisioning/notifiers/testdata/test-configs/incorrect-settings/incorrect-settings.yaml +++ /dev/null @@ -1,9 +0,0 @@ -notifiers: - - name: slack-notification-without-token-in-settings - type: slack - org_id: 2 - uid: notifier1 - is_default: true - settings: - recipient: "XXX" - uploadImage: true diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/no-required-fields/no-required-fields.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/no-required-fields/no-required-fields.yaml deleted file mode 100644 index 55ff545525e05..0000000000000 --- a/pkg/services/provisioning/notifiers/testdata/test-configs/no-required-fields/no-required-fields.yaml +++ /dev/null @@ -1,35 +0,0 @@ -notifiers: - - type: slack - org_id: 2 - uid: no-name_added-notification - is_default: true - settings: - recipient: "XXX" - token: "xoxb" - uploadImage: true - - name: no-uid - type: slack - org_id: 2 - is_default: true - settings: - recipient: "XXX" - token: "xoxb" - uploadImage: true -delete_notifiers: - - name: no-uid - type: slack - org_id: 2 - is_default: true - settings: - recipient: "XXX" - token: "xoxb" - uploadImage: true - - type: slack - org_id: 2 - uid: no-name_added-notification - is_default: true - settings: - recipient: "XXX" - token: "xoxb" - uploadImage: true - \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/two-notifications/two-notifications.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/two-notifications/two-notifications.yaml deleted file mode 100644 index aeeb718e6debd..0000000000000 --- a/pkg/services/provisioning/notifiers/testdata/test-configs/two-notifications/two-notifications.yaml +++ /dev/null @@ -1,12 +0,0 @@ -notifiers: - - name: channel1 - type: email - uid: notifier1 - org_id: 1 - settings: - addresses: example@example.com - - name: channel2 - type: slack - uid: notifier2 - settings: - url: http://slack.com diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/unknown-notifier/notification.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/unknown-notifier/notification.yaml deleted file mode 100644 index ca0d3fa3c754b..0000000000000 --- a/pkg/services/provisioning/notifiers/testdata/test-configs/unknown-notifier/notification.yaml +++ /dev/null @@ -1,4 +0,0 @@ -notifiers: - - name: unknown-notifier - type: nonexisting - uid: notifier1 \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/types.go b/pkg/services/provisioning/notifiers/types.go deleted file mode 100644 index 9353dc67ded27..0000000000000 --- a/pkg/services/provisioning/notifiers/types.go +++ /dev/null @@ -1,107 +0,0 @@ -package notifiers - -import ( - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/provisioning/values" -) - -// notificationsAsConfig is normalized data object for notifications config data. Any config version should be mappable -// to this type. -type notificationsAsConfig struct { - Notifications []*notificationFromConfig - DeleteNotifications []*deleteNotificationConfig -} - -type deleteNotificationConfig struct { - UID string - Name string - OrgID int64 - OrgName string -} - -type notificationFromConfig struct { - UID string - OrgID int64 - OrgName string - Name string - Type string - SendReminder bool - DisableResolveMessage bool - Frequency string - IsDefault bool - Settings map[string]any - SecureSettings map[string]string -} - -// notificationsAsConfigV0 is mapping for zero version configs. This is mapped to its normalised version. -type notificationsAsConfigV0 struct { - Notifications []*notificationFromConfigV0 `json:"notifiers" yaml:"notifiers"` - DeleteNotifications []*deleteNotificationConfigV0 `json:"delete_notifiers" yaml:"delete_notifiers"` -} - -type deleteNotificationConfigV0 struct { - UID values.StringValue `json:"uid" yaml:"uid"` - Name values.StringValue `json:"name" yaml:"name"` - OrgID values.Int64Value `json:"org_id" yaml:"org_id"` - OrgName values.StringValue `json:"org_name" yaml:"org_name"` -} - -type notificationFromConfigV0 struct { - UID values.StringValue `json:"uid" yaml:"uid"` - OrgID values.Int64Value `json:"org_id" yaml:"org_id"` - OrgName values.StringValue `json:"org_name" yaml:"org_name"` - Name values.StringValue `json:"name" yaml:"name"` - Type values.StringValue `json:"type" yaml:"type"` - SendReminder values.BoolValue `json:"send_reminder" yaml:"send_reminder"` - DisableResolveMessage values.BoolValue `json:"disable_resolve_message" yaml:"disable_resolve_message"` - Frequency values.StringValue `json:"frequency" yaml:"frequency"` - IsDefault values.BoolValue `json:"is_default" yaml:"is_default"` - Settings values.JSONValue `json:"settings" yaml:"settings"` - SecureSettings values.StringMapValue `json:"secure_settings" yaml:"secure_settings"` -} - -func (notification notificationFromConfig) SettingsToJSON() *simplejson.Json { - settings := simplejson.New() - if len(notification.Settings) > 0 { - for k, v := range notification.Settings { - settings.Set(k, v) - } - } - return settings -} - -// mapToNotificationFromConfig maps config syntax to normalized notificationsAsConfig object. Every version -// of the config syntax should have this function. -func (cfg *notificationsAsConfigV0) mapToNotificationFromConfig() *notificationsAsConfig { - r := ¬ificationsAsConfig{} - if cfg == nil { - return r - } - - for _, notification := range cfg.Notifications { - r.Notifications = append(r.Notifications, ¬ificationFromConfig{ - UID: notification.UID.Value(), - OrgID: notification.OrgID.Value(), - OrgName: notification.OrgName.Value(), - Name: notification.Name.Value(), - Type: notification.Type.Value(), - IsDefault: notification.IsDefault.Value(), - Settings: notification.Settings.Value(), - DisableResolveMessage: notification.DisableResolveMessage.Value(), - Frequency: notification.Frequency.Value(), - SendReminder: notification.SendReminder.Value(), - SecureSettings: notification.SecureSettings.Value(), - }) - } - - for _, notification := range cfg.DeleteNotifications { - r.DeleteNotifications = append(r.DeleteNotifications, &deleteNotificationConfig{ - UID: notification.UID.Value(), - OrgID: notification.OrgID.Value(), - OrgName: notification.OrgName.Value(), - Name: notification.Name.Value(), - }) - } - - return r -} diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index 873295b3db3bd..3e62ef7870990 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -10,12 +10,12 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/correlations" dashboardservice "github.com/grafana/grafana/pkg/services/dashboards" datasourceservice "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/encryption" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/notifications" @@ -25,7 +25,6 @@ import ( prov_alerting "github.com/grafana/grafana/pkg/services/provisioning/alerting" "github.com/grafana/grafana/pkg/services/provisioning/dashboards" "github.com/grafana/grafana/pkg/services/provisioning/datasources" - "github.com/grafana/grafana/pkg/services/provisioning/notifiers" "github.com/grafana/grafana/pkg/services/provisioning/plugins" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/searchV2" @@ -45,7 +44,6 @@ func ProvideService( correlationsService correlations.Service, dashboardService dashboardservice.DashboardService, folderService folder.Service, - alertingService *alerting.AlertNotificationService, pluginSettings pluginsettings.Service, searchService searchV2.SearchService, quotaService quota.Service, @@ -60,7 +58,6 @@ func ProvideService( EncryptionService: encryptionService, NotificationService: notificatonService, newDashboardProvisioner: dashboards.New, - provisionNotifiers: notifiers.Provision, provisionDatasources: datasources.Provision, provisionPlugins: plugins.Provision, provisionAlerting: prov_alerting.Provision, @@ -68,13 +65,13 @@ func ProvideService( dashboardService: dashboardService, datasourceService: datasourceService, correlationsService: correlationsService, - alertingService: alertingService, pluginsSettings: pluginSettings, searchService: searchService, quotaService: quotaService, secretService: secrectService, log: log.New("provisioning"), orgService: orgService, + folderService: folderService, } return s, nil } @@ -84,7 +81,6 @@ type ProvisioningService interface { RunInitProvisioners(ctx context.Context) error ProvisionDatasources(ctx context.Context) error ProvisionPlugins(ctx context.Context) error - ProvisionNotifications(ctx context.Context) error ProvisionDashboards(ctx context.Context) error ProvisionAlerting(ctx context.Context) error GetDashboardProvisionerResolvedPath(name string) string @@ -97,7 +93,6 @@ func NewProvisioningServiceImpl() *ProvisioningServiceImpl { return &ProvisioningServiceImpl{ log: logger, newDashboardProvisioner: dashboards.New, - provisionNotifiers: notifiers.Provision, provisionDatasources: datasources.Provision, provisionPlugins: plugins.Provision, } @@ -106,14 +101,12 @@ func NewProvisioningServiceImpl() *ProvisioningServiceImpl { // Used for testing purposes func newProvisioningServiceImpl( newDashboardProvisioner dashboards.DashboardProvisionerFactory, - provisionNotifiers func(context.Context, string, notifiers.Manager, org.Service, encryption.Internal, *notifications.NotificationService) error, provisionDatasources func(context.Context, string, datasources.Store, datasources.CorrelationsStore, org.Service) error, provisionPlugins func(context.Context, string, pluginstore.Store, pluginsettings.Service, org.Service) error, ) *ProvisioningServiceImpl { return &ProvisioningServiceImpl{ log: log.New("provisioning"), newDashboardProvisioner: newDashboardProvisioner, - provisionNotifiers: provisionNotifiers, provisionDatasources: provisionDatasources, provisionPlugins: provisionPlugins, } @@ -131,7 +124,6 @@ type ProvisioningServiceImpl struct { pollingCtxCancel context.CancelFunc newDashboardProvisioner dashboards.DashboardProvisionerFactory dashboardProvisioner dashboards.DashboardProvisioner - provisionNotifiers func(context.Context, string, notifiers.Manager, org.Service, encryption.Internal, *notifications.NotificationService) error provisionDatasources func(context.Context, string, datasources.Store, datasources.CorrelationsStore, org.Service) error provisionPlugins func(context.Context, string, pluginstore.Store, pluginsettings.Service, org.Service) error provisionAlerting func(context.Context, prov_alerting.ProvisionerConfig) error @@ -140,11 +132,11 @@ type ProvisioningServiceImpl struct { dashboardService dashboardservice.DashboardService datasourceService datasourceservice.DataSourceService correlationsService correlations.Service - alertingService *alerting.AlertNotificationService pluginsSettings pluginsettings.Service searchService searchV2.SearchService quotaService quota.Service secretService secrets.Service + folderService folder.Service } func (ps *ProvisioningServiceImpl) RunInitProvisioners(ctx context.Context) error { @@ -160,12 +152,6 @@ func (ps *ProvisioningServiceImpl) RunInitProvisioners(ctx context.Context) erro return err } - err = ps.ProvisionNotifications(ctx) - if err != nil { - ps.log.Error("Failed to provision alert notifications", "error", err) - return err - } - err = ps.ProvisionAlerting(ctx) if err != nil { ps.log.Error("Failed to provision alerting", "error", err) @@ -227,19 +213,9 @@ func (ps *ProvisioningServiceImpl) ProvisionPlugins(ctx context.Context) error { return nil } -func (ps *ProvisioningServiceImpl) ProvisionNotifications(ctx context.Context) error { - alertNotificationsPath := filepath.Join(ps.Cfg.ProvisioningPath, "notifiers") - if err := ps.provisionNotifiers(ctx, alertNotificationsPath, ps.alertingService, ps.orgService, ps.EncryptionService, ps.NotificationService); err != nil { - err = fmt.Errorf("%v: %w", "Alert notification provisioning error", err) - ps.log.Error("Failed to provision alert notifications", "error", err) - return err - } - return nil -} - func (ps *ProvisioningServiceImpl) ProvisionDashboards(ctx context.Context) error { dashboardPath := filepath.Join(ps.Cfg.ProvisioningPath, "dashboards") - dashProvisioner, err := ps.newDashboardProvisioner(ctx, dashboardPath, ps.dashboardProvisioningService, ps.orgService, ps.dashboardService) + dashProvisioner, err := ps.newDashboardProvisioner(ctx, dashboardPath, ps.dashboardProvisioningService, ps.orgService, ps.dashboardService, ps.folderService) if err != nil { return fmt.Errorf("%v: %w", "Failed to create provisioner", err) } @@ -272,14 +248,17 @@ func (ps *ProvisioningServiceImpl) ProvisionAlerting(ctx context.Context) error ruleService := provisioning.NewAlertRuleService( st, st, + ps.folderService, ps.dashboardService, ps.quotaService, ps.SQLStore, int64(ps.Cfg.UnifiedAlerting.DefaultRuleEvaluationInterval.Seconds()), int64(ps.Cfg.UnifiedAlerting.BaseInterval.Seconds()), - ps.log) + ps.Cfg.UnifiedAlerting.RulesPerRuleGroupLimit, + ps.log, notifier.NewCachedNotificationSettingsValidationService(&st)) + receiverSvc := notifier.NewReceiverService(ps.ac, &st, st, ps.secretService, ps.SQLStore, ps.log) contactPointService := provisioning.NewContactPointService(&st, ps.secretService, - st, ps.SQLStore, ps.log, ps.ac) + st, ps.SQLStore, receiverSvc, ps.log, &st) notificationPolicyService := provisioning.NewNotificationPolicyService(&st, st, ps.SQLStore, ps.Cfg.UnifiedAlerting, ps.log) mutetimingsService := provisioning.NewMuteTimingService(&st, st, &st, ps.log) diff --git a/pkg/services/provisioning/provisioning_mock.go b/pkg/services/provisioning/provisioning_mock.go index 8c2aaa7cd6171..d094821f36e6e 100644 --- a/pkg/services/provisioning/provisioning_mock.go +++ b/pkg/services/provisioning/provisioning_mock.go @@ -6,7 +6,6 @@ type Calls struct { RunInitProvisioners []any ProvisionDatasources []any ProvisionPlugins []any - ProvisionNotifications []any ProvisionDashboards []any ProvisionAlerting []any GetDashboardProvisionerResolvedPath []any @@ -19,7 +18,6 @@ type ProvisioningServiceMock struct { RunInitProvisionersFunc func(ctx context.Context) error ProvisionDatasourcesFunc func(ctx context.Context) error ProvisionPluginsFunc func() error - ProvisionNotificationsFunc func() error ProvisionDashboardsFunc func() error GetDashboardProvisionerResolvedPathFunc func(name string) string GetAllowUIUpdatesFromConfigFunc func(name string) bool @@ -56,14 +54,6 @@ func (mock *ProvisioningServiceMock) ProvisionPlugins(ctx context.Context) error return nil } -func (mock *ProvisioningServiceMock) ProvisionNotifications(ctx context.Context) error { - mock.Calls.ProvisionNotifications = append(mock.Calls.ProvisionNotifications, nil) - if mock.ProvisionNotificationsFunc != nil { - return mock.ProvisionNotificationsFunc() - } - return nil -} - func (mock *ProvisioningServiceMock) ProvisionDashboards(ctx context.Context) error { mock.Calls.ProvisionDashboards = append(mock.Calls.ProvisionDashboards, nil) if mock.ProvisionDashboardsFunc != nil { diff --git a/pkg/services/provisioning/provisioning_test.go b/pkg/services/provisioning/provisioning_test.go index e7626af9d3744..209732ce4b301 100644 --- a/pkg/services/provisioning/provisioning_test.go +++ b/pkg/services/provisioning/provisioning_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" dashboardstore "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/provisioning/dashboards" "github.com/grafana/grafana/pkg/services/provisioning/utils" @@ -95,12 +96,11 @@ func setup() *serviceTestStruct { } serviceTest.service = newProvisioningServiceImpl( - func(context.Context, string, dashboardstore.DashboardProvisioningService, org.Service, utils.DashboardStore) (dashboards.DashboardProvisioner, error) { + func(context.Context, string, dashboardstore.DashboardProvisioningService, org.Service, utils.DashboardStore, folder.Service) (dashboards.DashboardProvisioner, error) { return serviceTest.mock, nil }, nil, nil, - nil, ) serviceTest.service.Cfg = setting.NewCfg() diff --git a/pkg/services/publicdashboards/api/api.go b/pkg/services/publicdashboards/api/api.go index 77fa58df291a8..2aa8dea58d9a5 100644 --- a/pkg/services/publicdashboards/api/api.go +++ b/pkg/services/publicdashboards/api/api.go @@ -14,6 +14,7 @@ import ( contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/publicdashboards" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" "github.com/grafana/grafana/pkg/services/publicdashboards/validation" @@ -27,7 +28,8 @@ type Api struct { accessControl accesscontrol.AccessControl cfg *setting.Cfg - features *featuremgmt.FeatureManager + features featuremgmt.FeatureToggles + license licensing.Licensing log log.Logger routeRegister routing.RouteRegister } @@ -36,9 +38,10 @@ func ProvideApi( pd publicdashboards.Service, rr routing.RouteRegister, ac accesscontrol.AccessControl, - features *featuremgmt.FeatureManager, + features featuremgmt.FeatureToggles, md publicdashboards.Middleware, cfg *setting.Cfg, + license licensing.Licensing, ) *Api { api := &Api{ PublicDashboardService: pd, @@ -46,6 +49,7 @@ func ProvideApi( accessControl: ac, cfg: cfg, features: features, + license: license, log: log.New("publicdashboards.api"), routeRegister: rr, } @@ -158,8 +162,8 @@ func (api *Api) GetPublicDashboard(c *contextmodel.ReqContext) response.Response return response.Err(err) } - if pd == nil { - response.Err(ErrPublicDashboardNotFound.Errorf("GetPublicDashboard: public dashboard not found")) + if pd == nil || (!api.license.FeatureEnabled(FeaturePublicDashboardsEmailSharing) && pd.Share == EmailShareType) { + return response.Err(ErrPublicDashboardNotFound.Errorf("GetPublicDashboard: public dashboard not found")) } return response.JSON(http.StatusOK, pd) @@ -297,7 +301,7 @@ func (api *Api) DeletePublicDashboard(c *contextmodel.ReqContext) response.Respo } // Copied from pkg/api/metrics.go -func toJsonStreamingResponse(ctx context.Context, features *featuremgmt.FeatureManager, qdr *backend.QueryDataResponse) response.Response { +func toJsonStreamingResponse(ctx context.Context, features featuremgmt.FeatureToggles, qdr *backend.QueryDataResponse) response.Response { statusWhenError := http.StatusBadRequest if features.IsEnabled(ctx, featuremgmt.FlagDatasourceQueryMultiStatus) { statusWhenError = http.StatusMultiStatus diff --git a/pkg/services/publicdashboards/api/api_test.go b/pkg/services/publicdashboards/api/api_test.go index 662b2cefaebbc..1984e7de1da17 100644 --- a/pkg/services/publicdashboards/api/api_test.go +++ b/pkg/services/publicdashboards/api/api_test.go @@ -269,10 +269,7 @@ func TestAPIDeletePublicDashboard(t *testing.T) { assert.Equal(t, test.ExpectedHttpResponse, response.Code) if test.ExpectedHttpResponse == http.StatusOK { - var jsonResp any - err := json.Unmarshal(response.Body.Bytes(), &jsonResp) - require.NoError(t, err) - assert.Equal(t, jsonResp, nil) + assert.Equal(t, []byte(nil), response.Body.Bytes()) } if !test.ShouldCallService { diff --git a/pkg/services/publicdashboards/api/common_test.go b/pkg/services/publicdashboards/api/common_test.go index b8dc715b71c10..e4ea82ea5d813 100644 --- a/pkg/services/publicdashboards/api/common_test.go +++ b/pkg/services/publicdashboards/api/common_test.go @@ -16,8 +16,6 @@ import ( "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/config" - "github.com/grafana/grafana/pkg/plugins/manager/fakes" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" @@ -26,17 +24,25 @@ import ( "github.com/grafana/grafana/pkg/services/datasources/guardian" datasourceService "github.com/grafana/grafana/pkg/services/datasources/service" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/licensing/licensingtest" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/publicdashboards" + publicdashboardModels "github.com/grafana/grafana/pkg/services/publicdashboards/models" "github.com/grafana/grafana/pkg/services/query" fakeSecrets "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/web" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func setupTestServer( t *testing.T, cfg *setting.Cfg, @@ -68,7 +74,9 @@ func setupTestServer( } // build api, this will mount the routes at the same time if the feature is enabled - ProvideApi(service, rr, ac, features, &Middleware{}, cfg) + license := licensingtest.NewFakeLicensing() + license.On("FeatureEnabled", publicdashboardModels.FeaturePublicDashboardsEmailSharing).Return(false) + ProvideApi(service, rr, ac, features, &Middleware{}, cfg, license) // connect routes to mux rr.Register(m.Router) @@ -140,8 +148,8 @@ func buildQueryDataService(t *testing.T, cs datasources.CacheService, fpc *fakeP }, }, }, - }, ds, pluginSettings.ProvideService(store, fakeSecrets.NewFakeSecretsService()), fakes.NewFakeLicensingService(), - &config.Cfg{}) + }, &fakeDatasources.FakeCacheService{}, ds, + pluginSettings.ProvideService(store, fakeSecrets.NewFakeSecretsService()), pluginconfig.NewFakePluginRequestConfigProvider()) return query.ProvideService( setting.NewCfg(), diff --git a/pkg/services/publicdashboards/api/middleware.go b/pkg/services/publicdashboards/api/middleware.go index dfca53d23131b..32582a37ba763 100644 --- a/pkg/services/publicdashboards/api/middleware.go +++ b/pkg/services/publicdashboards/api/middleware.go @@ -83,3 +83,6 @@ func (m *Middleware) HandleView(c *contextmodel.ReqContext) { } func (m *Middleware) HandleAccessView(c *contextmodel.ReqContext) { } +func (m *Middleware) HandleConfirmAccessView(c *contextmodel.ReqContext) { + +} diff --git a/pkg/services/publicdashboards/api/query_test.go b/pkg/services/publicdashboards/api/query_test.go index d6efc69b738cd..27b80eeb16731 100644 --- a/pkg/services/publicdashboards/api/query_test.go +++ b/pkg/services/publicdashboards/api/query_test.go @@ -32,6 +32,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/folder/foldertest" + "github.com/grafana/grafana/pkg/services/licensing/licensingtest" "github.com/grafana/grafana/pkg/services/publicdashboards" publicdashboardsStore "github.com/grafana/grafana/pkg/services/publicdashboards/database" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" @@ -276,7 +277,6 @@ func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T) // Create Dashboard saveDashboardCmd := dashboards.SaveDashboardCommand{ OrgID: 1, - FolderID: 1, // nolint:staticcheck FolderUID: "1", IsFolder: false, Dashboard: simplejson.NewFromAny(map[string]any{ @@ -326,13 +326,15 @@ func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T) folderStore := folderimpl.ProvideDashboardFolderStore(db) dashPermissionService := acmock.NewMockedPermissionsService() dashService, err := service.ProvideDashboardServiceImpl( - cfg, dashboardStoreService, folderStore, nil, + cfg, dashboardStoreService, folderStore, featuremgmt.WithFeatures(), acmock.NewMockedPermissionsService(), dashPermissionService, ac, foldertest.NewFakeService(), nil, ) require.NoError(t, err) - pds := publicdashboardsService.ProvideService(cfg, store, qds, annotationsService, ac, ws, dashService) + license := licensingtest.NewFakeLicensing() + license.On("FeatureEnabled", FeaturePublicDashboardsEmailSharing).Return(false) + pds := publicdashboardsService.ProvideService(cfg, store, qds, annotationsService, ac, ws, dashService, license) pubdash, err := pds.Create(context.Background(), &user.SignedInUser{}, savePubDashboardCmd) require.NoError(t, err) diff --git a/pkg/services/publicdashboards/database/database.go b/pkg/services/publicdashboards/database/database.go index f786909b4f0f4..4e1fee501073a 100644 --- a/pkg/services/publicdashboards/database/database.go +++ b/pkg/services/publicdashboards/database/database.go @@ -283,15 +283,11 @@ func (d *PublicDashboardStoreImpl) Delete(ctx context.Context, uid string) (int6 return affectedRows, err } -func (d *PublicDashboardStoreImpl) FindByDashboardFolder(ctx context.Context, dashboard *dashboards.Dashboard) ([]*PublicDashboard, error) { - if dashboard == nil || !dashboard.IsFolder { - return nil, nil - } - +func (d *PublicDashboardStoreImpl) FindByFolder(ctx context.Context, orgId int64, folderUid string) ([]*PublicDashboard, error) { var pubdashes []*PublicDashboard err := d.sqlStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - return sess.SQL("SELECT * from dashboard_public WHERE (dashboard_uid, org_id) IN (SELECT uid, org_id FROM dashboard WHERE folder_id = ?)", dashboard.ID).Find(&pubdashes) + return sess.SQL("SELECT * from dashboard_public WHERE (dashboard_uid, org_id) IN (SELECT uid, org_id FROM dashboard WHERE org_id = ? AND folder_uid = ?)", orgId, folderUid).Find(&pubdashes) }) if err != nil { return nil, err diff --git a/pkg/services/publicdashboards/database/database_test.go b/pkg/services/publicdashboards/database/database_test.go index 03f826afa4f30..a76ca6d315423 100644 --- a/pkg/services/publicdashboards/database/database_test.go +++ b/pkg/services/publicdashboards/database/database_test.go @@ -24,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) @@ -33,6 +34,11 @@ var DefaultTimeSettings = &TimeSettings{} // Default time to pass in with seconds rounded var DefaultTime = time.Now().UTC().Round(time.Second) +// run tests with cleanup +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestLogPrefix(t *testing.T) { assert.Equal(t, LogPrefix, "publicdashboards.store") } @@ -64,9 +70,9 @@ func TestIntegrationListPublicDashboard(t *testing.T) { require.NoError(t, err) publicdashboardStore = ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) - bDash = insertTestDashboard(t, dashboardStore, "b", orgId, 0, "", false) - aDash = insertTestDashboard(t, dashboardStore, "a", orgId, 0, "", false) - cDash = insertTestDashboard(t, dashboardStore, "c", orgId, 0, "", false) + bDash = insertTestDashboard(t, dashboardStore, "b", orgId, "", false) + aDash = insertTestDashboard(t, dashboardStore, "a", orgId, "", false) + cDash = insertTestDashboard(t, dashboardStore, "c", orgId, "", false) // these are in order of how they should be returned from ListPUblicDashboards aPublicDash = insertPublicDashboard(t, publicdashboardStore, aDash.UID, orgId, false, PublicShareType) @@ -179,7 +185,7 @@ func TestIntegrationExistsEnabledByAccessToken(t *testing.T) { require.NoError(t, err) dashboardStore = store publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) - savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true) + savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) } t.Run("ExistsEnabledByAccessToken will return true when at least one public dashboard has a matching access token", func(t *testing.T) { setup() @@ -252,7 +258,7 @@ func TestIntegrationExistsEnabledByDashboardUid(t *testing.T) { require.NoError(t, err) dashboardStore = store publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) - savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true) + savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) } t.Run("ExistsEnabledByDashboardUid Will return true when dashboard has at least one enabled public dashboard", func(t *testing.T) { @@ -317,7 +323,7 @@ func TestIntegrationFindByDashboardUid(t *testing.T) { require.NoError(t, err) dashboardStore = store publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) - savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true) + savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) } t.Run("returns public dashboard by dashboardUid", func(t *testing.T) { @@ -384,7 +390,7 @@ func TestIntegrationFindByAccessToken(t *testing.T) { dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) - savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true) + savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) } t.Run("returns public dashboard by accessToken", func(t *testing.T) { @@ -454,8 +460,8 @@ func TestIntegrationCreatePublicDashboard(t *testing.T) { require.NoError(t, err) dashboardStore = store publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) - savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true) - savedDashboard2 = insertTestDashboard(t, dashboardStore, "testDashie2", 1, 0, "", true) + savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) + savedDashboard2 = insertTestDashboard(t, dashboardStore, "testDashie2", 1, "", true) insertPublicDashboard(t, publicdashboardStore, savedDashboard2.UID, savedDashboard2.OrgID, false, PublicShareType) } @@ -533,8 +539,8 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) - savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true) - anotherSavedDashboard = insertTestDashboard(t, dashboardStore, "test another Dashie", 1, 0, "", true) + savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) + anotherSavedDashboard = insertTestDashboard(t, dashboardStore, "test another Dashie", 1, "", true) } t.Run("updates an existing dashboard", func(t *testing.T) { @@ -637,7 +643,7 @@ func TestIntegrationGetOrgIdByAccessToken(t *testing.T) { dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) - savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true) + savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) } t.Run("GetOrgIdByAccessToken will OrgId when enabled", func(t *testing.T) { setup() @@ -709,7 +715,7 @@ func TestIntegrationDelete(t *testing.T) { dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) - savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true) + savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) savedPublicDashboard = insertPublicDashboard(t, publicdashboardStore, savedDashboard.UID, savedDashboard.OrgID, true, PublicShareType) } @@ -735,38 +741,45 @@ func TestIntegrationDelete(t *testing.T) { }) } -func TestGetDashboardByFolder(t *testing.T) { +func TestFindByFolder(t *testing.T) { t.Run("returns nil when dashboard is not a folder", func(t *testing.T) { sqlStore, _ := db.InitTestDBwithCfg(t) - dashboard := &dashboards.Dashboard{IsFolder: false} + dashboard := &dashboards.Dashboard{OrgID: 1, UID: "dashboarduid", IsFolder: false} store := ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) - pubdashes, err := store.FindByDashboardFolder(context.Background(), dashboard) + pubdashes, err := store.FindByFolder(context.Background(), dashboard.OrgID, dashboard.UID) require.NoError(t, err) assert.Nil(t, pubdashes) }) - t.Run("returns nil when dashboard is nil", func(t *testing.T) { + t.Run("returns nil when parameters are empty", func(t *testing.T) { sqlStore, _ := db.InitTestDBwithCfg(t) store := ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) - pubdashes, err := store.FindByDashboardFolder(context.Background(), nil) + pubdashes, err := store.FindByFolder(context.Background(), 0, "") require.NoError(t, err) assert.Nil(t, pubdashes) }) t.Run("can get all pubdashes for dashboard folder and org", func(t *testing.T) { - sqlStore, cfg := db.InitTestDBwithCfg(t) + sqlStore, _ := db.InitTestDBwithCfg(t) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) - require.NoError(t, err) - pubdashStore := ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) - dashboard := insertTestDashboard(t, dashboardStore, "title", 1, 1, "1", true, PublicShareType) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) + require.NoError(t, err) + pubdashStore := ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) + // insert folders + folder := insertTestDashboard(t, dashboardStore, "This is a folder", 1, "", true, PublicShareType) + folder2 := insertTestDashboard(t, dashboardStore, "This is another folder", 1, "", true, PublicShareType) + // insert dashboard in a folder + dashboard := insertTestDashboard(t, dashboardStore, "Dashboard in a folder", 1, folder.UID, false, PublicShareType) + // insert a dashboard in a different folder + dashboard2 := insertTestDashboard(t, dashboardStore, "Another Dashboard in a different folder", 1, folder2.UID, false, PublicShareType) + + // create 2 public dashboards pubdash := insertPublicDashboard(t, pubdashStore, dashboard.UID, dashboard.OrgID, true, PublicShareType) - dashboard2 := insertTestDashboard(t, dashboardStore, "title", 1, 2, "2", true, PublicShareType) _ = insertPublicDashboard(t, pubdashStore, dashboard2.UID, dashboard2.OrgID, true, PublicShareType) - pubdashes, err := pubdashStore.FindByDashboardFolder(context.Background(), dashboard) + pubdashes, err := pubdashStore.FindByFolder(context.Background(), folder.OrgID, folder.UID) require.NoError(t, err) assert.Len(t, pubdashes, 1) @@ -794,10 +807,10 @@ func TestGetMetrics(t *testing.T) { require.NoError(t, err) dashboardStore = store publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) - savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", false) - savedDashboard2 = insertTestDashboard(t, dashboardStore, "testDashie2", 1, 0, "", false) - savedDashboard3 = insertTestDashboard(t, dashboardStore, "testDashie3", 2, 0, "", false) - savedDashboard4 = insertTestDashboard(t, dashboardStore, "testDashie4", 2, 0, "", false) + savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", false) + savedDashboard2 = insertTestDashboard(t, dashboardStore, "testDashie2", 1, "", false) + savedDashboard3 = insertTestDashboard(t, dashboardStore, "testDashie3", 2, "", false) + savedDashboard4 = insertTestDashboard(t, dashboardStore, "testDashie4", 2, "", false) insertPublicDashboard(t, publicdashboardStore, savedDashboard.UID, savedDashboard.OrgID, true, PublicShareType) insertPublicDashboard(t, publicdashboardStore, savedDashboard2.UID, savedDashboard2.OrgID, true, PublicShareType) insertPublicDashboard(t, publicdashboardStore, savedDashboard3.UID, savedDashboard3.OrgID, true, EmailShareType) @@ -838,12 +851,11 @@ func TestGetMetrics(t *testing.T) { } // helper function to insert a dashboard -func insertTestDashboard(t *testing.T, dashboardStore dashboards.Store, title string, orgId int64, - folderId int64, folderUID string, isFolder bool, tags ...any) *dashboards.Dashboard { +func insertTestDashboard(t *testing.T, dashboardStore dashboards.Store, title string, orgID int64, + folderUID string, isFolder bool, tags ...any) *dashboards.Dashboard { t.Helper() cmd := dashboards.SaveDashboardCommand{ - OrgID: orgId, - FolderID: folderId, // nolint:staticcheck + OrgID: orgID, FolderUID: folderUID, IsFolder: isFolder, Dashboard: simplejson.NewFromAny(map[string]any{ diff --git a/pkg/services/publicdashboards/models/models.go b/pkg/services/publicdashboards/models/models.go index e646be7f54807..676eb96c96608 100644 --- a/pkg/services/publicdashboards/models/models.go +++ b/pkg/services/publicdashboards/models/models.go @@ -24,10 +24,11 @@ func (e PublicDashboardErr) Error() string { } const ( - QuerySuccess = "success" - QueryFailure = "failure" - EmailShareType ShareType = "email" - PublicShareType ShareType = "public" + QuerySuccess = "success" + QueryFailure = "failure" + EmailShareType ShareType = "email" + PublicShareType ShareType = "public" + FeaturePublicDashboardsEmailSharing = "publicDashboardsEmailSharing" ) var ( diff --git a/pkg/services/publicdashboards/public_dashboard_middleware_mock.go b/pkg/services/publicdashboards/public_dashboard_middleware_mock.go index 67361df812e9d..f47e99dc0e2a2 100644 --- a/pkg/services/publicdashboards/public_dashboard_middleware_mock.go +++ b/pkg/services/publicdashboards/public_dashboard_middleware_mock.go @@ -12,18 +12,23 @@ type FakePublicDashboardMiddleware struct { mock.Mock } +// HandleAccessView provides a mock function with given fields: c +func (_m *FakePublicDashboardMiddleware) HandleAccessView(c *contextmodel.ReqContext) { + _m.Called(c) +} + // HandleApi provides a mock function with given fields: c func (_m *FakePublicDashboardMiddleware) HandleApi(c *contextmodel.ReqContext) { _m.Called(c) } -// HandleGet provides a mock function with given fields: c -func (_m *FakePublicDashboardMiddleware) HandleView(c *contextmodel.ReqContext) { +// HandleConfirmAccessView provides a mock function with given fields: c +func (_m *FakePublicDashboardMiddleware) HandleConfirmAccessView(c *contextmodel.ReqContext) { _m.Called(c) } -// HandleRequestOrConfirmAccess provides a mock function with given fields: c -func (_m *FakePublicDashboardMiddleware) HandleAccessView(c *contextmodel.ReqContext) { +// HandleView provides a mock function with given fields: c +func (_m *FakePublicDashboardMiddleware) HandleView(c *contextmodel.ReqContext) { _m.Called(c) } diff --git a/pkg/services/publicdashboards/public_dashboard_service_mock.go b/pkg/services/publicdashboards/public_dashboard_service_mock.go index 949e0d9b3d3f7..5b5bc8fbd6398 100644 --- a/pkg/services/publicdashboards/public_dashboard_service_mock.go +++ b/pkg/services/publicdashboards/public_dashboard_service_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.32.0. DO NOT EDIT. +// Code generated by mockery v2.36.1. DO NOT EDIT. package publicdashboards diff --git a/pkg/services/publicdashboards/public_dashboard_service_wrapper_mock.go b/pkg/services/publicdashboards/public_dashboard_service_wrapper_mock.go index f257f713c81c6..93a19a225d863 100644 --- a/pkg/services/publicdashboards/public_dashboard_service_wrapper_mock.go +++ b/pkg/services/publicdashboards/public_dashboard_service_wrapper_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.32.0. DO NOT EDIT. +// Code generated by mockery v2.36.1. DO NOT EDIT. package publicdashboards diff --git a/pkg/services/publicdashboards/public_dashboard_store_mock.go b/pkg/services/publicdashboards/public_dashboard_store_mock.go index 218287ef503dd..b33c1172a44aa 100644 --- a/pkg/services/publicdashboards/public_dashboard_store_mock.go +++ b/pkg/services/publicdashboards/public_dashboard_store_mock.go @@ -1,14 +1,12 @@ -// Code generated by mockery v2.32.0. DO NOT EDIT. +// Code generated by mockery v2.36.1. DO NOT EDIT. package publicdashboards import ( context "context" - dashboards "github.com/grafana/grafana/pkg/services/dashboards" - mock "github.com/stretchr/testify/mock" - models "github.com/grafana/grafana/pkg/services/publicdashboards/models" + mock "github.com/stretchr/testify/mock" ) // FakePublicDashboardStore is an autogenerated mock type for the Store type @@ -190,25 +188,25 @@ func (_m *FakePublicDashboardStore) FindByAccessToken(ctx context.Context, acces return r0, r1 } -// FindByDashboardFolder provides a mock function with given fields: ctx, dashboard -func (_m *FakePublicDashboardStore) FindByDashboardFolder(ctx context.Context, dashboard *dashboards.Dashboard) ([]*models.PublicDashboard, error) { - ret := _m.Called(ctx, dashboard) +// FindByDashboardUid provides a mock function with given fields: ctx, orgId, dashboardUid +func (_m *FakePublicDashboardStore) FindByDashboardUid(ctx context.Context, orgId int64, dashboardUid string) (*models.PublicDashboard, error) { + ret := _m.Called(ctx, orgId, dashboardUid) - var r0 []*models.PublicDashboard + var r0 *models.PublicDashboard var r1 error - if rf, ok := ret.Get(0).(func(context.Context, *dashboards.Dashboard) ([]*models.PublicDashboard, error)); ok { - return rf(ctx, dashboard) + if rf, ok := ret.Get(0).(func(context.Context, int64, string) (*models.PublicDashboard, error)); ok { + return rf(ctx, orgId, dashboardUid) } - if rf, ok := ret.Get(0).(func(context.Context, *dashboards.Dashboard) []*models.PublicDashboard); ok { - r0 = rf(ctx, dashboard) + if rf, ok := ret.Get(0).(func(context.Context, int64, string) *models.PublicDashboard); ok { + r0 = rf(ctx, orgId, dashboardUid) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).([]*models.PublicDashboard) + r0 = ret.Get(0).(*models.PublicDashboard) } } - if rf, ok := ret.Get(1).(func(context.Context, *dashboards.Dashboard) error); ok { - r1 = rf(ctx, dashboard) + if rf, ok := ret.Get(1).(func(context.Context, int64, string) error); ok { + r1 = rf(ctx, orgId, dashboardUid) } else { r1 = ret.Error(1) } @@ -216,25 +214,25 @@ func (_m *FakePublicDashboardStore) FindByDashboardFolder(ctx context.Context, d return r0, r1 } -// FindByDashboardUid provides a mock function with given fields: ctx, orgId, dashboardUid -func (_m *FakePublicDashboardStore) FindByDashboardUid(ctx context.Context, orgId int64, dashboardUid string) (*models.PublicDashboard, error) { - ret := _m.Called(ctx, orgId, dashboardUid) +// FindByFolder provides a mock function with given fields: ctx, orgId, folderUid +func (_m *FakePublicDashboardStore) FindByFolder(ctx context.Context, orgId int64, folderUid string) ([]*models.PublicDashboard, error) { + ret := _m.Called(ctx, orgId, folderUid) - var r0 *models.PublicDashboard + var r0 []*models.PublicDashboard var r1 error - if rf, ok := ret.Get(0).(func(context.Context, int64, string) (*models.PublicDashboard, error)); ok { - return rf(ctx, orgId, dashboardUid) + if rf, ok := ret.Get(0).(func(context.Context, int64, string) ([]*models.PublicDashboard, error)); ok { + return rf(ctx, orgId, folderUid) } - if rf, ok := ret.Get(0).(func(context.Context, int64, string) *models.PublicDashboard); ok { - r0 = rf(ctx, orgId, dashboardUid) + if rf, ok := ret.Get(0).(func(context.Context, int64, string) []*models.PublicDashboard); ok { + r0 = rf(ctx, orgId, folderUid) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*models.PublicDashboard) + r0 = ret.Get(0).([]*models.PublicDashboard) } } if rf, ok := ret.Get(1).(func(context.Context, int64, string) error); ok { - r1 = rf(ctx, orgId, dashboardUid) + r1 = rf(ctx, orgId, folderUid) } else { r1 = ret.Error(1) } diff --git a/pkg/services/publicdashboards/publicdashboard.go b/pkg/services/publicdashboards/publicdashboard.go index 51bd8e3e25779..a0acb6786cf28 100644 --- a/pkg/services/publicdashboards/publicdashboard.go +++ b/pkg/services/publicdashboards/publicdashboard.go @@ -59,7 +59,7 @@ type Store interface { Delete(ctx context.Context, uid string) (int64, error) GetOrgIdByAccessToken(ctx context.Context, accessToken string) (int64, error) - FindByDashboardFolder(ctx context.Context, dashboard *dashboards.Dashboard) ([]*PublicDashboard, error) + FindByFolder(ctx context.Context, orgId int64, folderUid string) ([]*PublicDashboard, error) ExistsEnabledByAccessToken(ctx context.Context, accessToken string) (bool, error) ExistsEnabledByDashboardUid(ctx context.Context, dashboardUid string) (bool, error) GetMetrics(ctx context.Context) (*Metrics, error) @@ -70,4 +70,5 @@ type Middleware interface { HandleApi(c *contextmodel.ReqContext) HandleView(c *contextmodel.ReqContext) HandleAccessView(c *contextmodel.ReqContext) + HandleConfirmAccessView(c *contextmodel.ReqContext) } diff --git a/pkg/services/publicdashboards/service/common_test.go b/pkg/services/publicdashboards/service/common_test.go new file mode 100644 index 0000000000000..5840346d5a8db --- /dev/null +++ b/pkg/services/publicdashboards/service/common_test.go @@ -0,0 +1,51 @@ +package service + +import ( + "testing" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/services/annotations/annotationsimpl" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/licensing/licensingtest" + "github.com/grafana/grafana/pkg/services/publicdashboards" + "github.com/grafana/grafana/pkg/services/publicdashboards/database" + . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/publicdashboards/service/intervalv2" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/tag/tagimpl" +) + +func newPublicDashboardServiceImpl( + t *testing.T, + publicDashboardStore publicdashboards.Store, + dashboardService dashboards.DashboardService, + annotationsRepo annotations.Repository, +) (*PublicDashboardServiceImpl, *sqlstore.SQLStore) { + t.Helper() + + sqlStore := sqlstore.InitTestDB(t) + tagService := tagimpl.ProvideService(sqlStore) + if annotationsRepo == nil { + annotationsRepo = annotationsimpl.ProvideService(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagService) + } + + if publicDashboardStore == nil { + publicDashboardStore = database.ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) + } + serviceWrapper := ProvideServiceWrapper(publicDashboardStore) + + license := licensingtest.NewFakeLicensing() + license.On("FeatureEnabled", FeaturePublicDashboardsEmailSharing).Return(false) + + return &PublicDashboardServiceImpl{ + AnnotationsRepo: annotationsRepo, + log: log.New("test.logger"), + intervalCalculator: intervalv2.NewCalculator(), + dashboardService: dashboardService, + store: publicDashboardStore, + serviceWrapper: serviceWrapper, + license: license, + }, sqlStore +} diff --git a/pkg/services/publicdashboards/service/intervalv2/intervalv2.go b/pkg/services/publicdashboards/service/intervalv2/intervalv2.go new file mode 100644 index 0000000000000..b20defc27f691 --- /dev/null +++ b/pkg/services/publicdashboards/service/intervalv2/intervalv2.go @@ -0,0 +1,77 @@ +package intervalv2 + +import ( + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" +) + +var ( + DefaultRes int64 = 1500 + defaultMinInterval = time.Millisecond * 1 +) + +type Interval struct { + Text string + Value time.Duration +} + +type intervalCalculator struct { + minInterval time.Duration +} + +type Calculator interface { + Calculate(timerange backend.TimeRange, minInterval time.Duration, maxDataPoints int64) Interval + CalculateSafeInterval(timerange backend.TimeRange, resolution int64) Interval +} + +type CalculatorOptions struct { + MinInterval time.Duration +} + +func NewCalculator(opts ...CalculatorOptions) *intervalCalculator { + calc := &intervalCalculator{} + + for _, o := range opts { + if o.MinInterval == 0 { + calc.minInterval = defaultMinInterval + } else { + calc.minInterval = o.MinInterval + } + } + + return calc +} + +func (i *Interval) Milliseconds() int64 { + return i.Value.Nanoseconds() / int64(time.Millisecond) +} + +func (ic *intervalCalculator) Calculate(timerange backend.TimeRange, minInterval time.Duration, maxDataPoints int64) Interval { + to := timerange.To.UnixNano() + from := timerange.From.UnixNano() + resolution := maxDataPoints + if resolution == 0 { + resolution = DefaultRes + } + + calculatedInterval := time.Duration((to - from) / resolution) + + if calculatedInterval < minInterval { + return Interval{Text: gtime.FormatInterval(minInterval), Value: minInterval} + } + + rounded := gtime.RoundInterval(calculatedInterval) + + return Interval{Text: gtime.FormatInterval(rounded), Value: rounded} +} + +func (ic *intervalCalculator) CalculateSafeInterval(timerange backend.TimeRange, safeRes int64) Interval { + to := timerange.To.UnixNano() + from := timerange.From.UnixNano() + safeInterval := time.Duration((to - from) / safeRes) + + rounded := gtime.RoundInterval(safeInterval) + return Interval{Text: gtime.FormatInterval(rounded), Value: rounded} +} diff --git a/pkg/tsdb/intervalv2/intervalv2_test.go b/pkg/services/publicdashboards/service/intervalv2/intervalv2_test.go similarity index 54% rename from pkg/tsdb/intervalv2/intervalv2_test.go rename to pkg/services/publicdashboards/service/intervalv2/intervalv2_test.go index 21b5a48abeffe..fe9900d90afb3 100644 --- a/pkg/tsdb/intervalv2/intervalv2_test.go +++ b/pkg/services/publicdashboards/service/intervalv2/intervalv2_test.go @@ -6,8 +6,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/stretchr/testify/assert" - - "github.com/grafana/grafana/pkg/services/datasources" ) func TestIntervalCalculator_Calculate(t *testing.T) { @@ -63,70 +61,3 @@ func TestIntervalCalculator_CalculateSafeInterval(t *testing.T) { }) } } - -func TestRoundInterval(t *testing.T) { - testCases := []struct { - name string - interval time.Duration - expected time.Duration - }{ - {"10ms", time.Millisecond * 10, time.Millisecond * 1}, - {"15ms", time.Millisecond * 15, time.Millisecond * 10}, - {"30ms", time.Millisecond * 30, time.Millisecond * 20}, - {"45ms", time.Millisecond * 45, time.Millisecond * 50}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.expected, roundInterval(tc.interval)) - }) - } -} - -func TestFormatDuration(t *testing.T) { - testCases := []struct { - name string - duration time.Duration - expected string - }{ - {"61s", time.Second * 61, "1m"}, - {"30ms", time.Millisecond * 30, "30ms"}, - {"23h", time.Hour * 23, "23h"}, - {"24h", time.Hour * 24, "1d"}, - {"367d", time.Hour * 24 * 367, "1y"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.expected, FormatDuration(tc.duration)) - }) - } -} - -func TestGetIntervalFrom(t *testing.T) { - testCases := []struct { - name string - dsInfo *datasources.DataSource - queryInterval string - queryIntervalMs int64 - defaultInterval time.Duration - expected time.Duration - }{ - {"45s", nil, "45s", 0, time.Second * 15, time.Second * 45}, - {"45", nil, "45", 0, time.Second * 15, time.Second * 45}, - {"2m", nil, "2m", 0, time.Second * 15, time.Minute * 2}, - {"1d", nil, "1d", 0, time.Second * 15, time.Hour * 24}, - {"intervalMs", nil, "", 45000, time.Second * 15, time.Second * 45}, - {"intervalMs sub-seconds", nil, "", 45200, time.Second * 15, time.Millisecond * 45200}, - {"defaultInterval when interval empty", nil, "", 0, time.Second * 15, time.Second * 15}, - {"defaultInterval when intervalMs 0", nil, "", 0, time.Second * 15, time.Second * 15}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - actual, err := GetIntervalFrom(tc.queryInterval, "", tc.queryIntervalMs, tc.defaultInterval) - assert.Nil(t, err) - assert.Equal(t, tc.expected, actual) - }) - } -} diff --git a/pkg/services/publicdashboards/service/query_test.go b/pkg/services/publicdashboards/service/query_test.go index 46775f5822faf..ec1eaa0c21ade 100644 --- a/pkg/services/publicdashboards/service/query_test.go +++ b/pkg/services/publicdashboards/service/query_test.go @@ -12,23 +12,17 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/log" dashboard2 "github.com/grafana/grafana/pkg/kinds/dashboard" "github.com/grafana/grafana/pkg/services/annotations" - "github.com/grafana/grafana/pkg/services/annotations/annotationsimpl" "github.com/grafana/grafana/pkg/services/dashboards" dashboardsDB "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/featuremgmt" . "github.com/grafana/grafana/pkg/services/publicdashboards" - "github.com/grafana/grafana/pkg/services/publicdashboards/database" "github.com/grafana/grafana/pkg/services/publicdashboards/internal" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" "github.com/grafana/grafana/pkg/services/query" "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" "github.com/grafana/grafana/pkg/tsdb/legacydata" "github.com/grafana/grafana/pkg/util" @@ -682,23 +676,14 @@ const ( ) func TestGetQueryDataResponse(t *testing.T) { - sqlStore := sqlstore.InitTestDB(t) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) - require.NoError(t, err) - publicdashboardStore := database.ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) - serviceWrapper := ProvideServiceWrapper(publicdashboardStore) + fakeDashboardService := &dashboards.FakeDashboardService{} + service, sqlStore := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) fakeQueryService := &query.FakeQueryService{} fakeQueryService.On("QueryData", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&backend.QueryDataResponse{}, nil) - fakeDashboardService := &dashboards.FakeDashboardService{} + service.QueryDataService = fakeQueryService - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: publicdashboardStore, - intervalCalculator: intervalv2.NewCalculator(), - QueryDataService: fakeQueryService, - serviceWrapper: serviceWrapper, - dashboardService: fakeDashboardService, - } + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) + require.NoError(t, err) publicDashboardQueryDTO := PublicDashboardQueryDTO{ IntervalMs: int64(1), @@ -748,21 +733,12 @@ func TestFindAnnotations(t *testing.T) { color := "red" name := "annoName" t.Run("will build anonymous user with correct permissions to get annotations", func(t *testing.T) { - sqlStore := sqlstore.InitTestDB(t) - config := setting.NewCfg() - tagService := tagimpl.ProvideService(sqlStore) - annotationsRepo := annotationsimpl.ProvideService(sqlStore, config, featuremgmt.WithFeatures(), tagService) - fakeStore := FakePublicDashboardStore{} + fakeStore := &FakePublicDashboardStore{} fakeStore.On("FindByAccessToken", mock.Anything, mock.AnythingOfType("string")). Return(&PublicDashboard{Uid: "uid1", IsEnabled: true}, nil) fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboards.NewDashboard("dash1"), nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: &fakeStore, - AnnotationsRepo: annotationsRepo, - dashboardService: fakeDashboardService, - } + service, _ := newPublicDashboardServiceImpl(t, fakeStore, fakeDashboardService, nil) reqDTO := AnnotationsQueryDTO{ From: 1, @@ -807,20 +783,15 @@ func TestFindAnnotations(t *testing.T) { annos := []DashAnnotation{grafanaAnnotation, grafanaTagAnnotation} dashboard := AddAnnotationsToDashboard(t, dash, annos) - annotationsRepo := annotations.FakeAnnotationsRepo{} pubdash := &PublicDashboard{Uid: "uid1", IsEnabled: true, OrgId: 1, DashboardUid: dashboard.UID, AnnotationsEnabled: true} - fakeStore := FakePublicDashboardStore{} + annotationsRepo := &annotations.FakeAnnotationsRepo{} + fakeStore := &FakePublicDashboardStore{} fakeStore.On("FindByAccessToken", mock.Anything, mock.AnythingOfType("string")).Return(pubdash, nil) fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: &fakeStore, - AnnotationsRepo: &annotationsRepo, - dashboardService: fakeDashboardService, - } + service, _ := newPublicDashboardServiceImpl(t, fakeStore, fakeDashboardService, annotationsRepo) annotationsRepo.On("Find", mock.Anything, mock.Anything).Return([]*annotations.ItemDTO{ { @@ -870,19 +841,14 @@ func TestFindAnnotations(t *testing.T) { annos := []DashAnnotation{grafanaAnnotation} dashboard := AddAnnotationsToDashboard(t, dash, annos) - annotationsRepo := annotations.FakeAnnotationsRepo{} - fakeStore := FakePublicDashboardStore{} + annotationsRepo := &annotations.FakeAnnotationsRepo{} + fakeStore := &FakePublicDashboardStore{} pubdash := &PublicDashboard{Uid: "uid1", IsEnabled: true, OrgId: 1, DashboardUid: dashboard.UID, AnnotationsEnabled: true} fakeStore.On("FindByAccessToken", mock.Anything, mock.AnythingOfType("string")).Return(pubdash, nil) fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: &fakeStore, - AnnotationsRepo: &annotationsRepo, - dashboardService: fakeDashboardService, - } + service, _ := newPublicDashboardServiceImpl(t, fakeStore, fakeDashboardService, annotationsRepo) annotationsRepo.On("Find", mock.Anything, mock.Anything).Return([]*annotations.ItemDTO{ { @@ -944,19 +910,14 @@ func TestFindAnnotations(t *testing.T) { annos := []DashAnnotation{grafanaAnnotation, queryAnnotation, disabledGrafanaAnnotation} dashboard := AddAnnotationsToDashboard(t, dash, annos) - annotationsRepo := annotations.FakeAnnotationsRepo{} + annotationsRepo := &annotations.FakeAnnotationsRepo{} pubdash := &PublicDashboard{Uid: "uid1", IsEnabled: true, OrgId: 1, DashboardUid: dashboard.UID, AnnotationsEnabled: true} - fakeStore := FakePublicDashboardStore{} + fakeStore := &FakePublicDashboardStore{} fakeStore.On("FindByAccessToken", mock.Anything, mock.AnythingOfType("string")).Return(pubdash, nil) fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: &fakeStore, - AnnotationsRepo: &annotationsRepo, - dashboardService: fakeDashboardService, - } + service, _ := newPublicDashboardServiceImpl(t, fakeStore, fakeDashboardService, annotationsRepo) annotationsRepo.On("Find", mock.Anything, mock.Anything).Return([]*annotations.ItemDTO{ { @@ -990,19 +951,13 @@ func TestFindAnnotations(t *testing.T) { }) t.Run("test will return nothing when dashboard has no annotations", func(t *testing.T) { - annotationsRepo := annotations.FakeAnnotationsRepo{} dashboard := dashboards.NewDashboard("dashWithNoAnnotations") pubdash := &PublicDashboard{Uid: "uid1", IsEnabled: true, OrgId: 1, DashboardUid: dashboard.UID, AnnotationsEnabled: true} - fakeStore := FakePublicDashboardStore{} + fakeStore := &FakePublicDashboardStore{} fakeStore.On("FindByAccessToken", mock.Anything, mock.AnythingOfType("string")).Return(pubdash, nil) fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: &fakeStore, - AnnotationsRepo: &annotationsRepo, - dashboardService: fakeDashboardService, - } + service, _ := newPublicDashboardServiceImpl(t, fakeStore, fakeDashboardService, nil) items, err := service.FindAnnotations(context.Background(), AnnotationsQueryDTO{}, "abc123") @@ -1028,17 +983,11 @@ func TestFindAnnotations(t *testing.T) { annos := []DashAnnotation{grafanaAnnotation} dashboard := AddAnnotationsToDashboard(t, dash, annos) pubdash := &PublicDashboard{Uid: "uid1", IsEnabled: true, OrgId: 1, DashboardUid: dashboard.UID, AnnotationsEnabled: false} - annotationsRepo := annotations.FakeAnnotationsRepo{} - fakeStore := FakePublicDashboardStore{} + fakeStore := &FakePublicDashboardStore{} fakeStore.On("FindByAccessToken", mock.Anything, mock.AnythingOfType("string")).Return(pubdash, nil) fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: &fakeStore, - AnnotationsRepo: &annotationsRepo, - dashboardService: fakeDashboardService, - } + service, _ := newPublicDashboardServiceImpl(t, fakeStore, fakeDashboardService, nil) items, err := service.FindAnnotations(context.Background(), AnnotationsQueryDTO{}, "abc123") @@ -1060,23 +1009,17 @@ func TestFindAnnotations(t *testing.T) { }, } dash := dashboards.NewDashboard("test") - annotationsRepo := annotations.FakeAnnotationsRepo{} + annotationsRepo := &annotations.FakeAnnotationsRepo{} + annotationsRepo.On("Find", mock.Anything, mock.Anything).Return(nil, errors.New("failed")).Maybe() annos := []DashAnnotation{grafanaAnnotation} dash = AddAnnotationsToDashboard(t, dash, annos) pubdash := &PublicDashboard{Uid: "uid1", IsEnabled: true, OrgId: 1, DashboardUid: dash.UID, AnnotationsEnabled: true} - fakeStore := FakePublicDashboardStore{} + fakeStore := &FakePublicDashboardStore{} fakeStore.On("FindByAccessToken", mock.Anything, mock.AnythingOfType("string")).Return(pubdash, nil) fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dash, nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: &fakeStore, - AnnotationsRepo: &annotationsRepo, - dashboardService: fakeDashboardService, - } - - annotationsRepo.On("Find", mock.Anything, mock.Anything).Return(nil, errors.New("failed")).Maybe() + service, _ := newPublicDashboardServiceImpl(t, fakeStore, fakeDashboardService, annotationsRepo) items, err := service.FindAnnotations(context.Background(), AnnotationsQueryDTO{}, "abc123") @@ -1099,12 +1042,12 @@ func TestFindAnnotations(t *testing.T) { dashboard := AddAnnotationsToDashboard(t, dash, annos) pubdash := &PublicDashboard{Uid: "uid1", IsEnabled: true, OrgId: 1, DashboardUid: dashboard.UID, AnnotationsEnabled: true} - fakeStore := FakePublicDashboardStore{} + fakeStore := &FakePublicDashboardStore{} fakeStore.On("FindByAccessToken", mock.Anything, mock.AnythingOfType("string")).Return(pubdash, nil) fakeStore.On("FindByAccessToken", mock.Anything, mock.AnythingOfType("string")).Return(pubdash, nil) fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - annotationsRepo := annotations.FakeAnnotationsRepo{} + annotationsRepo := &annotations.FakeAnnotationsRepo{} annotationsRepo.On("Find", mock.Anything, mock.Anything).Return([]*annotations.ItemDTO{ { ID: 1, @@ -1117,12 +1060,7 @@ func TestFindAnnotations(t *testing.T) { }, }, nil).Maybe() - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: &fakeStore, - AnnotationsRepo: &annotationsRepo, - dashboardService: fakeDashboardService, - } + service, _ := newPublicDashboardServiceImpl(t, fakeStore, fakeDashboardService, annotationsRepo) items, err := service.FindAnnotations(context.Background(), AnnotationsQueryDTO{}, "abc123") @@ -1145,22 +1083,17 @@ func TestFindAnnotations(t *testing.T) { } func TestGetMetricRequest(t *testing.T) { - sqlStore := db.InitTestDB(t) + service, sqlStore := newPublicDashboardServiceImpl(t, nil, nil, nil) dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) - publicdashboardStore := database.ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]interface{}{}, nil) + publicDashboard := &PublicDashboard{ Uid: "1", DashboardUid: dashboard.UID, IsEnabled: true, AccessToken: "abc123", } - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: publicdashboardStore, - intervalCalculator: intervalv2.NewCalculator(), - } t.Run("will return an error when validation fails", func(t *testing.T) { publicDashboardQueryDTO := PublicDashboardQueryDTO{ @@ -1230,24 +1163,16 @@ func TestGetUniqueDashboardDatasourceUids(t *testing.T) { } func TestBuildMetricRequest(t *testing.T) { - sqlStore := db.InitTestDB(t) + fakeDashboardService := &dashboards.FakeDashboardService{} + service, sqlStore := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) - publicdashboardStore := database.ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) - serviceWrapper := ProvideServiceWrapper(publicdashboardStore) publicDashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]interface{}{}, nil) nonPublicDashboard := insertTestDashboard(t, dashboardStore, "testNonPublicDashie", 1, 0, "", true, []map[string]interface{}{}, nil) - fakeDashboardService := &dashboards.FakeDashboardService{} - fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(publicDashboard, nil) from, to := internal.GetTimeRangeFromDashboard(t, publicDashboard.Data) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: publicdashboardStore, - intervalCalculator: intervalv2.NewCalculator(), - serviceWrapper: serviceWrapper, - dashboardService: fakeDashboardService, - } + fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(publicDashboard, nil) publicDashboardQueryDTO := PublicDashboardQueryDTO{ IntervalMs: int64(10000000), diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go index 8ac9a8b24d80e..b0aff99150be8 100644 --- a/pkg/services/publicdashboards/service/service.go +++ b/pkg/services/publicdashboards/service/service.go @@ -8,18 +8,21 @@ import ( "github.com/google/uuid" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/publicdashboards" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/publicdashboards/service/intervalv2" "github.com/grafana/grafana/pkg/services/publicdashboards/validation" "github.com/grafana/grafana/pkg/services/query" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" "github.com/grafana/grafana/pkg/tsdb/legacydata" "github.com/grafana/grafana/pkg/util" ) @@ -36,6 +39,7 @@ type PublicDashboardServiceImpl struct { ac accesscontrol.AccessControl serviceWrapper publicdashboards.ServiceWrapper dashboardService dashboards.DashboardService + license licensing.Licensing } var LogPrefix = "publicdashboards.service" @@ -54,6 +58,7 @@ func ProvideService( ac accesscontrol.AccessControl, serviceWrapper publicdashboards.ServiceWrapper, dashboardService dashboards.DashboardService, + license licensing.Licensing, ) *PublicDashboardServiceImpl { return &PublicDashboardServiceImpl{ log: log.New(LogPrefix), @@ -65,6 +70,7 @@ func ProvideService( ac: ac, serviceWrapper: serviceWrapper, dashboardService: dashboardService, + license: license, } } @@ -74,6 +80,7 @@ func (pd *PublicDashboardServiceImpl) GetPublicDashboardForView(ctx context.Cont return nil, err } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.PublicDashboards).Inc() meta := dtos.DashboardMeta{ Slug: dash.Slug, Type: dashboards.DashTypeDB, @@ -151,6 +158,10 @@ func (pd *PublicDashboardServiceImpl) FindEnabledPublicDashboardAndDashboardByAc return nil, nil, ErrPublicDashboardNotEnabled.Errorf("FindEnabledPublicDashboardAndDashboardByAccessToken: Public dashboard is not enabled accessToken: %s", accessToken) } + if !pd.license.FeatureEnabled(FeaturePublicDashboardsEmailSharing) && pubdash.Share == EmailShareType { + return nil, nil, ErrPublicDashboardNotFound.Errorf("FindEnabledPublicDashboardAndDashboardByAccessToken: Dashboard not found accessToken: %s", accessToken) + } + return pubdash, dash, err } @@ -194,6 +205,12 @@ func (pd *PublicDashboardServiceImpl) Create(ctx context.Context, u *user.Signed } if existingPubdash != nil { + // If there is no license and the public dashboard was email-shared, we should update it to public + if !pd.license.FeatureEnabled(FeaturePublicDashboardsEmailSharing) && existingPubdash.Share == EmailShareType { + dto.Uid = existingPubdash.Uid + dto.PublicDashboard.Share = PublicShareType + return pd.Update(ctx, u, dto) + } return nil, ErrDashboardIsPublic.Errorf("Create: public dashboard for dashboard %s already exists", dto.DashboardUid) } @@ -358,7 +375,7 @@ func (pd *PublicDashboardServiceImpl) Delete(ctx context.Context, uid string, da func (pd *PublicDashboardServiceImpl) DeleteByDashboard(ctx context.Context, dashboard *dashboards.Dashboard) error { if dashboard.IsFolder { // get all pubdashes for the folder - pubdashes, err := pd.store.FindByDashboardFolder(ctx, dashboard) + pubdashes, err := pd.store.FindByFolder(ctx, dashboard.OrgID, dashboard.UID) if err != nil { return err } diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go index fdd5f10d10575..6216e926adbfa 100644 --- a/pkg/services/publicdashboards/service/service_test.go +++ b/pkg/services/publicdashboards/service/service_test.go @@ -16,21 +16,19 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/dashboards" dashboardsDB "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/featuremgmt" . "github.com/grafana/grafana/pkg/services/publicdashboards" - "github.com/grafana/grafana/pkg/services/publicdashboards/database" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/publicdashboards/service/intervalv2" "github.com/grafana/grafana/pkg/services/publicdashboards/validation" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/errutil" ) @@ -40,6 +38,10 @@ var defaultPubdashTimeSettings = &TimeSettings{} var dashboardData = simplejson.NewFromAny(map[string]any{"time": map[string]any{"from": "now-8h", "to": "now"}}) var SignedInUser = &user.SignedInUser{UserID: 1234, Login: "user@login.com"} +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestLogPrefix(t *testing.T) { assert.Equal(t, LogPrefix, "publicdashboards.service") } @@ -381,16 +383,11 @@ func TestGetPublicDashboardForView(t *testing.T) { for _, test := range testCases { t.Run(test.Name, func(t *testing.T) { - fakeStore := FakePublicDashboardStore{} - fakeDashboardService := &dashboards.FakeDashboardService{} - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: &fakeStore, - dashboardService: fakeDashboardService, - } - + fakeStore := &FakePublicDashboardStore{} fakeStore.On("FindByAccessToken", mock.Anything, mock.Anything).Return(test.StoreResp.pd, test.StoreResp.err) + fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(test.StoreResp.d, test.StoreResp.err) + service, _ := newPublicDashboardServiceImpl(t, fakeStore, fakeDashboardService, nil) dashboardFullWithMeta, err := service.GetPublicDashboardForView(context.Background(), test.AccessToken) if test.ErrResp != nil { @@ -496,15 +493,10 @@ func TestGetPublicDashboard(t *testing.T) { for _, test := range testCases { t.Run(test.Name, func(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} - fakeStore := FakePublicDashboardStore{} - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: &fakeStore, - dashboardService: fakeDashboardService, - } - - fakeStore.On("FindByAccessToken", mock.Anything, mock.Anything).Return(test.StoreResp.pd, test.StoreResp.err) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(test.StoreResp.d, test.StoreResp.err) + fakeStore := &FakePublicDashboardStore{} + fakeStore.On("FindByAccessToken", mock.Anything, mock.Anything).Return(test.StoreResp.pd, test.StoreResp.err) + service, _ := newPublicDashboardServiceImpl(t, fakeStore, fakeDashboardService, nil) pdc, dash, err := service.FindPublicDashboardAndDashboardByAccessToken(context.Background(), test.AccessToken) if test.ErrResp != nil { @@ -563,16 +555,11 @@ func TestGetEnabledPublicDashboard(t *testing.T) { for _, test := range testCases { t.Run(test.Name, func(t *testing.T) { - fakeStore := FakePublicDashboardStore{} - fakeDashboardService := &dashboards.FakeDashboardService{} - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: &fakeStore, - dashboardService: fakeDashboardService, - } - + fakeStore := &FakePublicDashboardStore{} fakeStore.On("FindByAccessToken", mock.Anything, mock.Anything).Return(test.StoreResp.pd, test.StoreResp.err) + fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(test.StoreResp.d, test.StoreResp.err) + service, _ := newPublicDashboardServiceImpl(t, fakeStore, fakeDashboardService, nil) pdc, dash, err := service.FindEnabledPublicDashboardAndDashboardByAccessToken(context.Background(), test.AccessToken) if test.ErrResp != nil { @@ -595,24 +582,15 @@ func TestGetEnabledPublicDashboard(t *testing.T) { // the correct order is convoluted. func TestCreatePublicDashboard(t *testing.T) { t.Run("Create public dashboard", func(t *testing.T) { - sqlStore := db.InitTestDB(t) + fakeDashboardService := &dashboards.FakeDashboardService{} + service, sqlStore := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) + quotaService := quotatest.New(false, nil) dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - publicdashboardStore := database.ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) - serviceWrapper := ProvideServiceWrapper(publicdashboardStore) - - fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: publicdashboardStore, - serviceWrapper: serviceWrapper, - dashboardService: fakeDashboardService, - } - isEnabled, annotationsEnabled, timeSelectionEnabled := true, false, true dto := &SavePublicDashboardDTO{ @@ -685,23 +663,14 @@ func TestCreatePublicDashboard(t *testing.T) { for _, tt := range testCases { t.Run(fmt.Sprintf("Create public dashboard with %s null boolean fields stores them as false", tt.Name), func(t *testing.T) { - sqlStore := db.InitTestDB(t) + fakeDashboardService := &dashboards.FakeDashboardService{} + service, sqlStore := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - publicdashboardStore := database.ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) - serviceWrapper := ProvideServiceWrapper(publicdashboardStore) - fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: publicdashboardStore, - serviceWrapper: serviceWrapper, - dashboardService: fakeDashboardService, - } - dto := &SavePublicDashboardDTO{ DashboardUid: dashboard.UID, UserId: 7, @@ -726,24 +695,14 @@ func TestCreatePublicDashboard(t *testing.T) { } t.Run("Validate pubdash has default time setting value", func(t *testing.T) { - sqlStore := db.InitTestDB(t) + fakeDashboardService := &dashboards.FakeDashboardService{} + service, sqlStore := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - publicdashboardStore := database.ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) - serviceWrapper := ProvideServiceWrapper(publicdashboardStore) - - fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: publicdashboardStore, - serviceWrapper: serviceWrapper, - dashboardService: fakeDashboardService, - } - isEnabled := true dto := &SavePublicDashboardDTO{ DashboardUid: dashboard.UID, @@ -763,24 +722,16 @@ func TestCreatePublicDashboard(t *testing.T) { }) t.Run("Creates pubdash whose dashboard has template variables successfully", func(t *testing.T) { - sqlStore := db.InitTestDB(t) + fakeDashboardService := &dashboards.FakeDashboardService{} + service, sqlStore := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - publicdashboardStore := database.ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) + templateVars := make([]map[string]any, 1) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, templateVars, nil) - serviceWrapper := ProvideServiceWrapper(publicdashboardStore) - fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: publicdashboardStore, - serviceWrapper: serviceWrapper, - dashboardService: fakeDashboardService, - } - isEnabled := true dto := &SavePublicDashboardDTO{ DashboardUid: dashboard.UID, @@ -818,14 +769,7 @@ func TestCreatePublicDashboard(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - serviceWrapper := ProvideServiceWrapper(publicDashboardStore) - - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: publicDashboardStore, - serviceWrapper: serviceWrapper, - dashboardService: fakeDashboardService, - } + service, _ := newPublicDashboardServiceImpl(t, publicDashboardStore, fakeDashboardService, nil) isEnabled := true dto := &SavePublicDashboardDTO{ @@ -844,23 +788,14 @@ func TestCreatePublicDashboard(t *testing.T) { }) t.Run("Create public dashboard with given pubdash uid", func(t *testing.T) { - sqlStore := db.InitTestDB(t) + fakeDashboardService := &dashboards.FakeDashboardService{} + service, sqlStore := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - publicdashboardStore := database.ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) - serviceWrapper := ProvideServiceWrapper(publicdashboardStore) - fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: publicdashboardStore, - serviceWrapper: serviceWrapper, - dashboardService: fakeDashboardService, - } - isEnabled := true dto := &SavePublicDashboardDTO{ @@ -900,14 +835,7 @@ func TestCreatePublicDashboard(t *testing.T) { publicDashboardStore.On("FindByDashboardUid", mock.Anything, mock.Anything, mock.Anything).Return(nil, ErrPublicDashboardNotFound.Errorf("")) fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - serviceWrapper := ProvideServiceWrapper(publicDashboardStore) - - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: publicDashboardStore, - serviceWrapper: serviceWrapper, - dashboardService: fakeDashboardService, - } + service, _ := newPublicDashboardServiceImpl(t, publicDashboardStore, fakeDashboardService, nil) isEnabled := true dto := &SavePublicDashboardDTO{ @@ -926,23 +854,14 @@ func TestCreatePublicDashboard(t *testing.T) { }) t.Run("Create public dashboard with given pubdash access token", func(t *testing.T) { - sqlStore := db.InitTestDB(t) + fakeDashboardService := &dashboards.FakeDashboardService{} + service, sqlStore := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - publicdashboardStore := database.ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]interface{}{}, nil) - serviceWrapper := ProvideServiceWrapper(publicdashboardStore) - fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: publicdashboardStore, - serviceWrapper: serviceWrapper, - dashboardService: fakeDashboardService, - } - isEnabled := true dto := &SavePublicDashboardDTO{ @@ -976,14 +895,7 @@ func TestCreatePublicDashboard(t *testing.T) { publicDashboardStore := &FakePublicDashboardStore{} publicDashboardStore.On("FindByAccessToken", mock.Anything, mock.Anything).Return(pubdash, nil) - - serviceWrapper := ProvideServiceWrapper(publicDashboardStore) - - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: publicDashboardStore, - serviceWrapper: serviceWrapper, - } + service, _ := newPublicDashboardServiceImpl(t, publicDashboardStore, nil, nil) _, err := service.NewPublicDashboardAccessToken(context.Background()) require.Error(t, err) @@ -991,25 +903,16 @@ func TestCreatePublicDashboard(t *testing.T) { }) t.Run("Returns error if public dashboard exists", func(t *testing.T) { - sqlStore := db.InitTestDB(t) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) - require.NoError(t, err) - - dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) - publicdashboardStore := &FakePublicDashboardStore{} publicdashboardStore.On("FindByDashboardUid", mock.Anything, mock.Anything, mock.Anything).Return(&PublicDashboard{Uid: "newPubdashUid"}, nil) publicdashboardStore.On("Find", mock.Anything, mock.Anything).Return(nil, nil) fakeDashboardService := &dashboards.FakeDashboardService{} - fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - serviceWrapper := ProvideServiceWrapper(publicdashboardStore) + service, sqlStore := newPublicDashboardServiceImpl(t, publicdashboardStore, fakeDashboardService, nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: publicdashboardStore, - serviceWrapper: serviceWrapper, - dashboardService: fakeDashboardService, - } + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) + require.NoError(t, err) + dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) + fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) isEnabled, annotationsEnabled := true, false dto := &SavePublicDashboardDTO{ @@ -1028,23 +931,15 @@ func TestCreatePublicDashboard(t *testing.T) { }) t.Run("Validate pubdash has default share value", func(t *testing.T) { - sqlStore := db.InitTestDB(t) + fakeDashboardService := &dashboards.FakeDashboardService{} + service, sqlStore := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) + quotaService := quotatest.New(false, nil) dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - publicdashboardStore := database.ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) - serviceWrapper := ProvideServiceWrapper(publicdashboardStore) - fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: publicdashboardStore, - serviceWrapper: serviceWrapper, - dashboardService: fakeDashboardService, - } - isEnabled := true dto := &SavePublicDashboardDTO{ DashboardUid: dashboard.UID, @@ -1074,22 +969,15 @@ func assertFalseIfNull(t *testing.T, expectedValue bool, nullableValue *bool) { } func TestUpdatePublicDashboard(t *testing.T) { - sqlStore := db.InitTestDB(t) + fakeDashboardService := &dashboards.FakeDashboardService{} + service, sqlStore := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) + quotaService := quotatest.New(false, nil) dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - publicdashboardStore := database.ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) - serviceWrapper := ProvideServiceWrapper(publicdashboardStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) dashboard2 := insertTestDashboard(t, dashboardStore, "testDashie2", 1, 0, "", true, []map[string]any{}, nil) - fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: publicdashboardStore, - serviceWrapper: serviceWrapper, - dashboardService: fakeDashboardService, - } t.Run("Updating public dashboard", func(t *testing.T) { isEnabled, annotationsEnabled, timeSelectionEnabled := true, false, false @@ -1264,23 +1152,15 @@ func TestUpdatePublicDashboard(t *testing.T) { for _, tt := range testCases { t.Run(fmt.Sprintf("Update public dashboard with %s null boolean fields let those fields with old persisted value", tt.Name), func(t *testing.T) { - sqlStore := db.InitTestDB(t) + fakeDashboardService := &dashboards.FakeDashboardService{} + service, sqlStore := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) + quotaService := quotatest.New(false, nil) dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - publicdashboardStore := database.ProvideStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures()) - serviceWrapper := ProvideServiceWrapper(publicdashboardStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) - fakeDashboardService := &dashboards.FakeDashboardService{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: publicdashboardStore, - serviceWrapper: serviceWrapper, - dashboardService: fakeDashboardService, - } - isEnabled, annotationsEnabled, timeSelectionEnabled := true, true, false dto := &SavePublicDashboardDTO{ @@ -1393,15 +1273,7 @@ func TestDeletePublicDashboard(t *testing.T) { if tt.ExpectedErrResp == nil || tt.mockDeleteStore.StoreRespErr != nil { store.On("Delete", mock.Anything, mock.Anything).Return(tt.mockDeleteStore.AffectedRowsResp, tt.mockDeleteStore.StoreRespErr) } - serviceWrapper := &PublicDashboardServiceWrapperImpl{ - log: log.New("test.logger"), - store: store, - } - service := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: store, - serviceWrapper: serviceWrapper, - } + service, _ := newPublicDashboardServiceImpl(t, store, nil, nil) err := service.Delete(context.Background(), "pubdashUID", "uid") if tt.ExpectedErrResp != nil { @@ -1618,12 +1490,8 @@ func TestPublicDashboardServiceImpl_ListPublicDashboards(t *testing.T) { store := NewFakePublicDashboardStore(t) store.On("FindAllWithPagination", mock.Anything, mock.Anything). Return(tt.mockResponse.PublicDashboardListResponseWithPagination, tt.mockResponse.Err) - - pd := &PublicDashboardServiceImpl{ - log: log.New("test.logger"), - store: store, - ac: ac, - } + pd, _ := newPublicDashboardServiceImpl(t, store, nil, nil) + pd.ac = ac got, err := pd.FindAllWithPagination(tt.args.ctx, tt.args.query) if !tt.wantErr(t, err, fmt.Sprintf("FindAllWithPagination(%v, %v)", tt.args.ctx, tt.args.query)) { @@ -1790,8 +1658,7 @@ func TestDeleteByDashboard(t *testing.T) { dashboard := &dashboards.Dashboard{UID: "1", OrgID: 1, IsFolder: true} pubdash1 := &PublicDashboard{Uid: "2", OrgId: 1, DashboardUid: dashboard.UID} pubdash2 := &PublicDashboard{Uid: "3", OrgId: 1, DashboardUid: dashboard.UID} - store.On("FindByDashboardFolder", mock.Anything, mock.Anything).Return([]*PublicDashboard{pubdash1, pubdash2}, nil) - store.On("Delete", mock.Anything, mock.Anything, mock.Anything).Return(int64(1), nil) + store.On("FindByFolder", mock.Anything, mock.Anything, mock.Anything).Return([]*PublicDashboard{pubdash1, pubdash2}, nil) store.On("Delete", mock.Anything, mock.Anything, mock.Anything).Return(int64(1), nil) err := pd.DeleteByDashboard(context.Background(), dashboard) diff --git a/pkg/services/query/query.go b/pkg/services/query/query.go index 5d8b0fd1f464b..34f2fabdee731 100644 --- a/pkg/services/query/query.go +++ b/pkg/services/query/query.go @@ -28,10 +28,11 @@ import ( ) const ( - HeaderPluginID = "X-Plugin-Id" // can be used for routing - HeaderDatasourceUID = "X-Datasource-Uid" // can be used for routing/ load balancing - HeaderDashboardUID = "X-Dashboard-Uid" // mainly useful for debugging slow queries - HeaderPanelID = "X-Panel-Id" // mainly useful for debugging slow queries + HeaderPluginID = "X-Plugin-Id" // can be used for routing + HeaderDatasourceUID = "X-Datasource-Uid" // can be used for routing/ load balancing + HeaderDashboardUID = "X-Dashboard-Uid" // mainly useful for debugging slow queries + HeaderPanelID = "X-Panel-Id" // mainly useful for debugging slow queries + HeaderPanelPluginId = "X-Panel-Plugin-Id" HeaderQueryGroupID = "X-Query-Group-Id" // mainly useful for finding related queries with query chunking HeaderFromExpression = "X-Grafana-From-Expr" // used by datasources to identify expression queries ) @@ -305,14 +306,12 @@ func (s *ServiceImpl) parseMetricRequest(ctx context.Context, user identity.Requ req.parsedQueries[ds.UID] = []parsedQuery{} } - s.log.Debug("Processing metrics query", "query", query) - modelJSON, err := query.MarshalJSON() if err != nil { return nil, err } - req.parsedQueries[ds.UID] = append(req.parsedQueries[ds.UID], parsedQuery{ + pq := parsedQuery{ datasource: ds, query: backend.DataQuery{ TimeRange: backend.TimeRange{ @@ -326,7 +325,16 @@ func (s *ServiceImpl) parseMetricRequest(ctx context.Context, user identity.Requ JSON: modelJSON, }, rawQuery: query, - }) + } + req.parsedQueries[ds.UID] = append(req.parsedQueries[ds.UID], pq) + + s.log.Debug("Processed metrics query", + "ref_id", pq.query.RefID, + "from", timeRange.GetFromAsMsEpoch(), + "to", timeRange.GetToAsMsEpoch(), + "interval", pq.query.Interval.Milliseconds(), + "max_data_points", pq.query.MaxDataPoints, + "query", string(modelJSON)) } return req, req.validateRequest(ctx) diff --git a/pkg/services/query/query_test.go b/pkg/services/query/query_test.go index c1cd5166b6f3a..486529466f28a 100644 --- a/pkg/services/query/query_test.go +++ b/pkg/services/query/query_test.go @@ -24,8 +24,6 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/config" - pluginFakes "github.com/grafana/grafana/pkg/plugins/manager/fakes" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" @@ -33,6 +31,7 @@ import ( "github.com/grafana/grafana/pkg/services/datasources" fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" @@ -41,9 +40,14 @@ import ( secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/web" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestParseMetricRequest(t *testing.T) { t.Run("Test a simple single datasource query", func(t *testing.T) { tc := setup(t) @@ -476,8 +480,8 @@ func setup(t *testing.T) *testContext { {JSONData: plugins.JSONData{ID: "testdata"}}, {JSONData: plugins.JSONData{ID: "mysql"}}, }, - }, fakeDatasourceService, - pluginSettings.ProvideService(sqlStore, secretsService), pluginFakes.NewFakeLicensingService(), &config.Cfg{}, + }, &fakeDatasources.FakeCacheService{}, fakeDatasourceService, + pluginSettings.ProvideService(sqlStore, secretsService), pluginconfig.NewFakePluginRequestConfigProvider(), ) exprService := expr.ProvideService(&setting.Cfg{ExpressionsEnabled: true}, pc, pCtxProvider, &featuremgmt.FeatureManager{}, nil, tracing.InitializeTracerForTest()) diff --git a/pkg/services/queryhistory/database.go b/pkg/services/queryhistory/database.go index 3d780481a0180..a6357d48014ad 100644 --- a/pkg/services/queryhistory/database.go +++ b/pkg/services/queryhistory/database.go @@ -45,7 +45,7 @@ func (s QueryHistoryService) createQuery(ctx context.Context, user *user.SignedI // searchQueries searches for queries in query history based on provided parameters func (s QueryHistoryService) searchQueries(ctx context.Context, user *user.SignedInUser, query SearchInQueryHistoryQuery) (QueryHistorySearchResult, error) { var dtos []QueryHistoryDTO - var allQueries []any + var totalCount int if query.To <= 0 { query.To = s.now().Unix() @@ -73,7 +73,7 @@ func (s QueryHistoryService) searchQueries(ctx context.Context, user *user.Signe query_history.comment, query_history.queries, `) - writeStarredSQL(query, s.store, &dtosBuilder) + writeStarredSQL(query, s.store, &dtosBuilder, false) writeFiltersSQL(query, user, s.store, &dtosBuilder) writeSortSQL(query, s.store, &dtosBuilder) writeLimitSQL(query, s.store, &dtosBuilder) @@ -87,9 +87,9 @@ func (s QueryHistoryService) searchQueries(ctx context.Context, user *user.Signe countBuilder := db.SQLBuilder{} countBuilder.Write(`SELECT `) - writeStarredSQL(query, s.store, &countBuilder) + writeStarredSQL(query, s.store, &countBuilder, true) writeFiltersSQL(query, user, s.store, &countBuilder) - err = session.SQL(countBuilder.GetSQLString(), countBuilder.GetParams()...).Find(&allQueries) + _, err = session.SQL(countBuilder.GetSQLString(), countBuilder.GetParams()...).Get(&totalCount) return err }) @@ -99,7 +99,7 @@ func (s QueryHistoryService) searchQueries(ctx context.Context, user *user.Signe response := QueryHistorySearchResult{ QueryHistory: dtos, - TotalCount: len(allQueries), + TotalCount: totalCount, Page: query.Page, PerPage: query.Limit, } diff --git a/pkg/services/queryhistory/queryhistory_test.go b/pkg/services/queryhistory/queryhistory_test.go index 203ae361eef74..80535378d860f 100644 --- a/pkg/services/queryhistory/queryhistory_test.go +++ b/pkg/services/queryhistory/queryhistory_test.go @@ -23,6 +23,7 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/web" ) @@ -33,6 +34,10 @@ var ( testDsUID2 = "ABch1a1" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type scenarioContext struct { ctx *web.Context service *QueryHistoryService diff --git a/pkg/services/queryhistory/writers.go b/pkg/services/queryhistory/writers.go index 00199f4716450..b1b6806b648f4 100644 --- a/pkg/services/queryhistory/writers.go +++ b/pkg/services/queryhistory/writers.go @@ -8,18 +8,29 @@ import ( "github.com/grafana/grafana/pkg/services/user" ) -func writeStarredSQL(query SearchInQueryHistoryQuery, sqlStore db.DB, builder *db.SQLBuilder) { +func writeStarredSQL(query SearchInQueryHistoryQuery, sqlStore db.DB, builder *db.SQLBuilder, isCount bool) { + var sql bytes.Buffer + if isCount { + sql.WriteString(`COUNT(`) + } + if query.OnlyStarred { + sql.WriteString(sqlStore.GetDialect().BooleanStr(true)) + } else { + sql.WriteString(`CASE WHEN query_history_star.query_uid IS NULL THEN ` + sqlStore.GetDialect().BooleanStr(false) + ` ELSE ` + sqlStore.GetDialect().BooleanStr(true) + ` END`) + } + if isCount { + sql.WriteString(`)`) + } + sql.WriteString(` AS starred FROM query_history `) + if query.OnlyStarred { - builder.Write(sqlStore.GetDialect().BooleanStr(true) + ` AS starred - FROM query_history - INNER JOIN query_history_star ON query_history_star.query_uid = query_history.uid - `) + sql.WriteString(`INNER`) } else { - builder.Write(` CASE WHEN query_history_star.query_uid IS NULL THEN ` + sqlStore.GetDialect().BooleanStr(false) + ` ELSE ` + sqlStore.GetDialect().BooleanStr(true) + ` END AS starred - FROM query_history - LEFT JOIN query_history_star ON query_history_star.query_uid = query_history.uid - `) + sql.WriteString(`LEFT`) } + + sql.WriteString(` JOIN query_history_star ON query_history_star.query_uid = query_history.uid `) + builder.Write(sql.String()) } func writeFiltersSQL(query SearchInQueryHistoryQuery, user *user.SignedInUser, sqlStore db.DB, builder *db.SQLBuilder) { diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index a1af008784cc1..23ecb2f82e83a 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -27,7 +27,6 @@ import ( "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/ngalert" "github.com/grafana/grafana/pkg/services/ngalert/metrics" - "github.com/grafana/grafana/pkg/services/ngalert/migration" ngalertmodels "github.com/grafana/grafana/pkg/services/ngalert/models" ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/org" @@ -488,7 +487,7 @@ func setupEnv(t *testing.T, sqlStore *sqlstore.SQLStore, b bus.Bus, quotaService _, err = ngalert.ProvideService( sqlStore.Cfg, featuremgmt.WithFeatures(), nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, quotaService, secretsService, nil, m, &foldertest.FakeService{}, &acmock.Mock{}, &dashboards.FakeDashboardService{}, nil, b, &acmock.Mock{}, - annotationstest.NewFakeAnnotationsRepo(), &pluginstore.FakePluginStore{}, tracer, ruleStore, migration.NewFakeMigrationService(t), nil, + annotationstest.NewFakeAnnotationsRepo(), &pluginstore.FakePluginStore{}, tracer, ruleStore, ) require.NoError(t, err) _, err = storesrv.ProvideService(sqlStore, featuremgmt.WithFeatures(), sqlStore.Cfg, quotaService, storesrv.ProvideSystemUsersService()) diff --git a/pkg/services/quota/quotaimpl/store_test.go b/pkg/services/quota/quotaimpl/store_test.go index d332ab97851d1..2e8211871d147 100644 --- a/pkg/services/quota/quotaimpl/store_test.go +++ b/pkg/services/quota/quotaimpl/store_test.go @@ -8,8 +8,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationQuotaDataAccess(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/rendering/http_mode.go b/pkg/services/rendering/http_mode.go index 6e5677f48e498..01d318d5a9a52 100644 --- a/pkg/services/rendering/http_mode.go +++ b/pkg/services/rendering/http_mode.go @@ -14,6 +14,8 @@ import ( "os" "strconv" "time" + + "github.com/grafana/grafana/pkg/services/featuremgmt" ) var netTransport = &http.Transport{ @@ -36,8 +38,16 @@ var ( remoteVersionRefreshInterval = time.Minute * 15 ) -func (rs *RenderingService) renderViaHTTP(ctx context.Context, renderKey string, opts Opts) (*RenderResult, error) { - filePath, err := rs.getNewFilePath(RenderPNG) +func (rs *RenderingService) renderViaHTTP(ctx context.Context, renderType RenderType, renderKey string, opts Opts) (*RenderResult, error) { + if renderType == RenderPDF { + if !rs.features.IsEnabled(ctx, featuremgmt.FlagNewPDFRendering) { + return nil, fmt.Errorf("feature 'newPDFRendering' disabled") + } + + opts.Encoding = "pdf" + } + + filePath, err := rs.getNewFilePath(renderType) if err != nil { return nil, err } diff --git a/pkg/services/rendering/interface.go b/pkg/services/rendering/interface.go index 1748a37248e65..076064b7109d5 100644 --- a/pkg/services/rendering/interface.go +++ b/pkg/services/rendering/interface.go @@ -20,6 +20,7 @@ type RenderType string const ( RenderCSV RenderType = "csv" RenderPNG RenderType = "png" + RenderPDF RenderType = "pdf" ) type TimeoutOpts struct { @@ -93,7 +94,7 @@ type RenderCSVResult struct { FileName string } -type renderFunc func(ctx context.Context, renderKey string, options Opts) (*RenderResult, error) +type renderFunc func(ctx context.Context, renderType RenderType, renderKey string, options Opts) (*RenderResult, error) type renderCSVFunc func(ctx context.Context, renderKey string, options CSVOpts) (*RenderCSVResult, error) type sanitizeFunc func(ctx context.Context, req *SanitizeSVGRequest) (*SanitizeSVGResponse, error) @@ -121,7 +122,7 @@ type CapabilitySupportRequestResult struct { type Service interface { IsAvailable(ctx context.Context) bool Version() string - Render(ctx context.Context, opts Opts, session Session) (*RenderResult, error) + Render(ctx context.Context, renderType RenderType, opts Opts, session Session) (*RenderResult, error) RenderCSV(ctx context.Context, opts CSVOpts, session Session) (*RenderCSVResult, error) RenderErrorImage(theme models.Theme, error error) (*RenderResult, error) GetRenderUser(ctx context.Context, key string) (*RenderUser, bool) diff --git a/pkg/services/rendering/mock.go b/pkg/services/rendering/mock.go index 9404b30ec4649..d6282220428ca 100644 --- a/pkg/services/rendering/mock.go +++ b/pkg/services/rendering/mock.go @@ -1,7 +1,6 @@ // Code generated by MockGen. DO NOT EDIT. // Source: github.com/grafana/grafana/pkg/services/rendering (interfaces: Service) -// Package rendering is a generated GoMock package. package rendering import ( @@ -37,122 +36,122 @@ func (m *MockService) EXPECT() *MockServiceMockRecorder { } // CreateRenderingSession mocks base method. -func (m *MockService) CreateRenderingSession(arg0 context.Context, arg1 AuthOpts, arg2 SessionOpts) (Session, error) { +func (m *MockService) CreateRenderingSession(ctx context.Context, authOpts AuthOpts, sessionOpts SessionOpts) (Session, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateRenderingSession", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "CreateRenderingSession", ctx, authOpts, sessionOpts) ret0, _ := ret[0].(Session) ret1, _ := ret[1].(error) return ret0, ret1 } // CreateRenderingSession indicates an expected call of CreateRenderingSession. -func (mr *MockServiceMockRecorder) CreateRenderingSession(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockServiceMockRecorder) CreateRenderingSession(ctx, authOpts, sessionOpts interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRenderingSession", reflect.TypeOf((*MockService)(nil).CreateRenderingSession), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRenderingSession", reflect.TypeOf((*MockService)(nil).CreateRenderingSession), ctx, authOpts, sessionOpts) } // GetRenderUser mocks base method. -func (m *MockService) GetRenderUser(arg0 context.Context, arg1 string) (*RenderUser, bool) { +func (m *MockService) GetRenderUser(ctx context.Context, key string) (*RenderUser, bool) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetRenderUser", arg0, arg1) + ret := m.ctrl.Call(m, "GetRenderUser", ctx, key) ret0, _ := ret[0].(*RenderUser) ret1, _ := ret[1].(bool) return ret0, ret1 } // GetRenderUser indicates an expected call of GetRenderUser. -func (mr *MockServiceMockRecorder) GetRenderUser(arg0, arg1 any) *gomock.Call { +func (mr *MockServiceMockRecorder) GetRenderUser(ctx, key interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRenderUser", reflect.TypeOf((*MockService)(nil).GetRenderUser), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRenderUser", reflect.TypeOf((*MockService)(nil).GetRenderUser), ctx, key) } // HasCapability mocks base method. -func (m *MockService) HasCapability(arg0 context.Context, arg1 CapabilityName) (CapabilitySupportRequestResult, error) { +func (m *MockService) HasCapability(ctx context.Context, capability CapabilityName) (CapabilitySupportRequestResult, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HasCapability", arg0, arg1) + ret := m.ctrl.Call(m, "HasCapability", ctx, capability) ret0, _ := ret[0].(CapabilitySupportRequestResult) ret1, _ := ret[1].(error) return ret0, ret1 } // HasCapability indicates an expected call of HasCapability. -func (mr *MockServiceMockRecorder) HasCapability(arg0, arg1 any) *gomock.Call { +func (mr *MockServiceMockRecorder) HasCapability(ctx, capability interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasCapability", reflect.TypeOf((*MockService)(nil).HasCapability), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasCapability", reflect.TypeOf((*MockService)(nil).HasCapability), ctx, capability) } // IsAvailable mocks base method. -func (m *MockService) IsAvailable(arg0 context.Context) bool { +func (m *MockService) IsAvailable(ctx context.Context) bool { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IsAvailable", arg0) + ret := m.ctrl.Call(m, "IsAvailable", ctx) ret0, _ := ret[0].(bool) return ret0 } // IsAvailable indicates an expected call of IsAvailable. -func (mr *MockServiceMockRecorder) IsAvailable(arg0 any) *gomock.Call { +func (mr *MockServiceMockRecorder) IsAvailable(ctx interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsAvailable", reflect.TypeOf((*MockService)(nil).IsAvailable), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsAvailable", reflect.TypeOf((*MockService)(nil).IsAvailable), ctx) } // Render mocks base method. -func (m *MockService) Render(arg0 context.Context, arg1 Opts, arg2 Session) (*RenderResult, error) { +func (m *MockService) Render(ctx context.Context, renderType RenderType, opts Opts, session Session) (*RenderResult, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Render", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "Render", ctx, renderType, opts, session) ret0, _ := ret[0].(*RenderResult) ret1, _ := ret[1].(error) return ret0, ret1 } // Render indicates an expected call of Render. -func (mr *MockServiceMockRecorder) Render(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockServiceMockRecorder) Render(ctx, renderType, opts, session interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Render", reflect.TypeOf((*MockService)(nil).Render), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Render", reflect.TypeOf((*MockService)(nil).Render), ctx, renderType, opts, session) } // RenderCSV mocks base method. -func (m *MockService) RenderCSV(arg0 context.Context, arg1 CSVOpts, arg2 Session) (*RenderCSVResult, error) { +func (m *MockService) RenderCSV(ctx context.Context, opts CSVOpts, session Session) (*RenderCSVResult, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RenderCSV", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "RenderCSV", ctx, opts, session) ret0, _ := ret[0].(*RenderCSVResult) ret1, _ := ret[1].(error) return ret0, ret1 } // RenderCSV indicates an expected call of RenderCSV. -func (mr *MockServiceMockRecorder) RenderCSV(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockServiceMockRecorder) RenderCSV(ctx, opts, session interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenderCSV", reflect.TypeOf((*MockService)(nil).RenderCSV), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenderCSV", reflect.TypeOf((*MockService)(nil).RenderCSV), ctx, opts, session) } // RenderErrorImage mocks base method. -func (m *MockService) RenderErrorImage(arg0 models.Theme, arg1 error) (*RenderResult, error) { +func (m *MockService) RenderErrorImage(theme models.Theme, err error) (*RenderResult, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RenderErrorImage", arg0, arg1) + ret := m.ctrl.Call(m, "RenderErrorImage", theme, err) ret0, _ := ret[0].(*RenderResult) ret1, _ := ret[1].(error) return ret0, ret1 } // RenderErrorImage indicates an expected call of RenderErrorImage. -func (mr *MockServiceMockRecorder) RenderErrorImage(arg0, arg1 any) *gomock.Call { +func (mr *MockServiceMockRecorder) RenderErrorImage(theme, error interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenderErrorImage", reflect.TypeOf((*MockService)(nil).RenderErrorImage), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenderErrorImage", reflect.TypeOf((*MockService)(nil).RenderErrorImage), theme, error) } // SanitizeSVG mocks base method. -func (m *MockService) SanitizeSVG(arg0 context.Context, arg1 *SanitizeSVGRequest) (*SanitizeSVGResponse, error) { +func (m *MockService) SanitizeSVG(ctx context.Context, req *SanitizeSVGRequest) (*SanitizeSVGResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SanitizeSVG", arg0, arg1) + ret := m.ctrl.Call(m, "SanitizeSVG", ctx, req) ret0, _ := ret[0].(*SanitizeSVGResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // SanitizeSVG indicates an expected call of SanitizeSVG. -func (mr *MockServiceMockRecorder) SanitizeSVG(arg0, arg1 any) *gomock.Call { +func (mr *MockServiceMockRecorder) SanitizeSVG(ctx, req interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SanitizeSVG", reflect.TypeOf((*MockService)(nil).SanitizeSVG), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SanitizeSVG", reflect.TypeOf((*MockService)(nil).SanitizeSVG), ctx, req) } // Version mocks base method. diff --git a/pkg/services/rendering/plugin_mode.go b/pkg/services/rendering/plugin_mode.go index 5e4ff4897ffb3..5e8772c9c20fc 100644 --- a/pkg/services/rendering/plugin_mode.go +++ b/pkg/services/rendering/plugin_mode.go @@ -6,14 +6,23 @@ import ( "fmt" "github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2" + "github.com/grafana/grafana/pkg/services/featuremgmt" ) -func (rs *RenderingService) renderViaPlugin(ctx context.Context, renderKey string, opts Opts) (*RenderResult, error) { +func (rs *RenderingService) renderViaPlugin(ctx context.Context, renderType RenderType, renderKey string, opts Opts) (*RenderResult, error) { + if renderType == RenderPDF { + if !rs.features.IsEnabled(ctx, featuremgmt.FlagNewPDFRendering) { + return nil, fmt.Errorf("feature 'newPDFRendering' disabled") + } + + opts.Encoding = "pdf" + } + // gives plugin some additional time to timeout and return possible errors. ctx, cancel := context.WithTimeout(ctx, getRequestTimeout(opts.TimeoutOpts)) defer cancel() - filePath, err := rs.getNewFilePath(RenderPNG) + filePath, err := rs.getNewFilePath(renderType) if err != nil { return nil, err } @@ -38,6 +47,7 @@ func (rs *RenderingService) renderViaPlugin(ctx context.Context, renderKey strin Domain: rs.domain, Headers: headers, AuthToken: rs.Cfg.RendererAuthToken, + Encoding: opts.Encoding, } rs.log.Debug("Calling renderer plugin", "req", req) diff --git a/pkg/services/rendering/rendering.go b/pkg/services/rendering/rendering.go index 7f9e95815edd6..75a6e5253ffc0 100644 --- a/pkg/services/rendering/rendering.go +++ b/pkg/services/rendering/rendering.go @@ -38,6 +38,7 @@ type RenderingService struct { version string versionMutex sync.RWMutex capabilities []Capability + pluginAvailable bool perRequestRenderKeyProvider renderKeyProvider Cfg *setting.Cfg @@ -57,16 +58,18 @@ type Plugin interface { } func ProvideService(cfg *setting.Cfg, features *featuremgmt.FeatureManager, remoteCache *remotecache.RemoteCache, rm PluginManager) (*RenderingService, error) { - // ensure ImagesDir exists - err := os.MkdirAll(cfg.ImagesDir, 0700) - if err != nil { - return nil, fmt.Errorf("failed to create images directory %q: %w", cfg.ImagesDir, err) + folders := []string{ + cfg.ImagesDir, + cfg.CSVsDir, + cfg.PDFsDir, } - // ensure CSVsDir exists - err = os.MkdirAll(cfg.CSVsDir, 0700) - if err != nil { - return nil, fmt.Errorf("failed to create CSVs directory %q: %w", cfg.CSVsDir, err) + // ensure folders exists + for _, f := range folders { + err := os.MkdirAll(f, 0700) + if err != nil { + return nil, fmt.Errorf("failed to create directory %q: %w", f, err) + } } logger := log.New("rendering") @@ -108,6 +111,8 @@ func ProvideService(cfg *setting.Cfg, features *featuremgmt.FeatureManager, remo } } + _, exists := rm.Renderer(context.Background()) + s := &RenderingService{ perRequestRenderKeyProvider: renderKeyProvider, capabilities: []Capability{ @@ -131,6 +136,7 @@ func ProvideService(cfg *setting.Cfg, features *featuremgmt.FeatureManager, remo log: logger, domain: domain, sanitizeURL: sanitizeURL, + pluginAvailable: exists, } gob.Register(&RenderUser{}) @@ -200,17 +206,12 @@ func (rs *RenderingService) Run(ctx context.Context) error { return nil } -func (rs *RenderingService) pluginAvailable(ctx context.Context) bool { - _, exists := rs.RendererPluginManager.Renderer(ctx) - return exists -} - func (rs *RenderingService) remoteAvailable() bool { return rs.Cfg.RendererUrl != "" } func (rs *RenderingService) IsAvailable(ctx context.Context) bool { - return rs.remoteAvailable() || rs.pluginAvailable(ctx) + return rs.remoteAvailable() || rs.pluginAvailable } func (rs *RenderingService) Version() string { @@ -245,18 +246,18 @@ func (rs *RenderingService) renderUnavailableImage() *RenderResult { imgPath := "public/img/rendering_plugin_not_installed.png" return &RenderResult{ - FilePath: filepath.Join(setting.HomePath, imgPath), + FilePath: filepath.Join(rs.Cfg.HomePath, imgPath), } } -func (rs *RenderingService) Render(ctx context.Context, opts Opts, session Session) (*RenderResult, error) { +func (rs *RenderingService) Render(ctx context.Context, renderType RenderType, opts Opts, session Session) (*RenderResult, error) { startTime := time.Now() renderKeyProvider := rs.perRequestRenderKeyProvider if session != nil { renderKeyProvider = session } - result, err := rs.render(ctx, opts, renderKeyProvider) + result, err := rs.render(ctx, renderType, opts, renderKeyProvider) elapsedTime := time.Since(startTime).Milliseconds() saveMetrics(elapsedTime, err, RenderPNG) @@ -264,7 +265,7 @@ func (rs *RenderingService) Render(ctx context.Context, opts Opts, session Sessi return result, err } -func (rs *RenderingService) render(ctx context.Context, opts Opts, renderKeyProvider renderKeyProvider) (*RenderResult, error) { +func (rs *RenderingService) render(ctx context.Context, renderType RenderType, opts Opts, renderKeyProvider renderKeyProvider) (*RenderResult, error) { if int(atomic.LoadInt32(&rs.inProgressCount)) > opts.ConcurrentLimit { rs.log.Warn("Could not render image, hit the currency limit", "concurrencyLimit", opts.ConcurrentLimit, "path", opts.Path) if opts.ErrorConcurrentLimitReached { @@ -307,7 +308,7 @@ func (rs *RenderingService) render(ctx context.Context, opts Opts, renderKeyProv }() metrics.MRenderingQueue.Set(float64(atomic.AddInt32(&rs.inProgressCount, 1))) - return rs.renderAction(ctx, renderKey, opts) + return rs.renderAction(ctx, renderType, renderKey, opts) } func (rs *RenderingService) RenderCSV(ctx context.Context, opts CSVOpts, session Session) (*RenderCSVResult, error) { @@ -374,11 +375,18 @@ func (rs *RenderingService) getNewFilePath(rt RenderType) (string, error) { return "", err } - ext := "png" - folder := rs.Cfg.ImagesDir - if rt == RenderCSV { + var ext string + var folder string + switch rt { + case RenderCSV: ext = "csv" folder = rs.Cfg.CSVsDir + case RenderPDF: + ext = "pdf" + folder = rs.Cfg.PDFsDir + default: + ext = "png" + folder = rs.Cfg.ImagesDir } return filepath.Abs(filepath.Join(folder, fmt.Sprintf("%s.%s", rand, ext))) diff --git a/pkg/services/rendering/rendering_test.go b/pkg/services/rendering/rendering_test.go index d8b808aa17054..36e78f36644f0 100644 --- a/pkg/services/rendering/rendering_test.go +++ b/pkg/services/rendering/rendering_test.go @@ -110,7 +110,7 @@ func TestRenderUnavailableError(t *testing.T) { RendererPluginManager: &dummyPluginManager{}, } opts := Opts{ErrorOpts: ErrorOpts{ErrorRenderUnavailable: true}} - result, err := rs.Render(context.Background(), opts, nil) + result, err := rs.Render(context.Background(), RenderPNG, opts, nil) assert.Equal(t, ErrRenderUnavailable, err) assert.Nil(t, result) } @@ -152,7 +152,7 @@ func TestRenderLimitImage(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { opts := Opts{Theme: tc.theme, ConcurrentLimit: 1} - result, err := rs.Render(context.Background(), opts, nil) + result, err := rs.Render(context.Background(), RenderPNG, opts, nil) assert.NoError(t, err) assert.Equal(t, tc.expected, result.FilePath) }) @@ -170,7 +170,7 @@ func TestRenderLimitImageError(t *testing.T) { ConcurrentLimit: 1, Theme: models.ThemeDark, } - result, err := rs.Render(context.Background(), opts, nil) + result, err := rs.Render(context.Background(), RenderPNG, opts, nil) assert.Equal(t, ErrConcurrentLimitReached, err) assert.Nil(t, result) } diff --git a/pkg/services/screenshot/screenshot.go b/pkg/services/screenshot/screenshot.go index da3fd5b2166d9..47e29fdccdb95 100644 --- a/pkg/services/screenshot/screenshot.go +++ b/pkg/services/screenshot/screenshot.go @@ -43,18 +43,20 @@ type ScreenshotService interface { // HeadlessScreenshotService takes screenshots using a headless browser. type HeadlessScreenshotService struct { - ds dashboards.DashboardService - rs rendering.Service + cfg *setting.Cfg + ds dashboards.DashboardService + rs rendering.Service duration prometheus.Histogram failures *prometheus.CounterVec successes prometheus.Counter } -func NewHeadlessScreenshotService(ds dashboards.DashboardService, rs rendering.Service, r prometheus.Registerer) ScreenshotService { +func NewHeadlessScreenshotService(cfg *setting.Cfg, ds dashboards.DashboardService, rs rendering.Service, r prometheus.Registerer) ScreenshotService { return &HeadlessScreenshotService{ - ds: ds, - rs: rs, + cfg: cfg, + ds: ds, + rs: rs, duration: promauto.With(r).NewHistogram(prometheus.HistogramOpts{ Name: "duration_seconds", Buckets: []float64{0.1, 0.25, 0.5, 1, 2, 5, 10, 15}, @@ -120,11 +122,11 @@ func (s *HeadlessScreenshotService) Take(ctx context.Context, opts ScreenshotOpt Width: opts.Width, Height: opts.Height, Theme: opts.Theme, - ConcurrentLimit: setting.AlertingRenderLimit, + ConcurrentLimit: s.cfg.RendererConcurrentRequestLimit, Path: u.String(), } - result, err := s.rs.Render(ctx, renderOpts, nil) + result, err := s.rs.Render(ctx, rendering.RenderPNG, renderOpts, nil) if err != nil { s.instrumentError(err) return nil, fmt.Errorf("failed to take screenshot: %w", err) diff --git a/pkg/services/screenshot/screenshot_test.go b/pkg/services/screenshot/screenshot_test.go index 24794ea85271b..392c45482e5bc 100644 --- a/pkg/services/screenshot/screenshot_test.go +++ b/pkg/services/screenshot/screenshot_test.go @@ -23,7 +23,8 @@ func TestHeadlessScreenshotService(t *testing.T) { d := dashboards.FakeDashboardService{} r := rendering.NewMockService(c) - s := NewHeadlessScreenshotService(&d, r, prometheus.NewRegistry()) + cfg := setting.NewCfg() + s := NewHeadlessScreenshotService(cfg, &d, r, prometheus.NewRegistry()) // a non-existent dashboard should return error d.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(nil, dashboards.ErrDashboardNotFound).Once() @@ -53,7 +54,7 @@ func TestHeadlessScreenshotService(t *testing.T) { Height: DefaultHeight, Theme: DefaultTheme, Path: "d-solo/foo/bar?from=now-6h&orgId=2&panelId=4&to=now-2h", - ConcurrentLimit: setting.AlertingRenderLimit, + ConcurrentLimit: cfg.RendererConcurrentRequestLimit, } opts.From = "now-6h" @@ -61,7 +62,7 @@ func TestHeadlessScreenshotService(t *testing.T) { opts.DashboardUID = "foo" opts.PanelID = 4 r.EXPECT(). - Render(ctx, renderOpts, nil). + Render(ctx, rendering.RenderPNG, renderOpts, nil). Return(&rendering.RenderResult{FilePath: "panel.png"}, nil) screenshot, err = s.Take(ctx, opts) require.NoError(t, err) @@ -69,7 +70,7 @@ func TestHeadlessScreenshotService(t *testing.T) { // a timeout should return error r.EXPECT(). - Render(ctx, renderOpts, nil). + Render(ctx, rendering.RenderPNG, renderOpts, nil). Return(nil, rendering.ErrTimeout) screenshot, err = s.Take(ctx, opts) assert.EqualError(t, err, fmt.Sprintf("failed to take screenshot: %s", rendering.ErrTimeout)) diff --git a/pkg/services/search/service.go b/pkg/services/search/service.go index bfaa9daacc72e..9b5d9e0bbe659 100644 --- a/pkg/services/search/service.go +++ b/pkg/services/search/service.go @@ -5,6 +5,7 @@ import ( "sort" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" "github.com/grafana/grafana/pkg/services/search/model" @@ -79,6 +80,7 @@ func (s *SearchService) SearchHandler(ctx context.Context, query *Query) (model. } } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Search).Inc() dashboardQuery := dashboards.FindPersistedDashboardsQuery{ Title: query.Title, SignedInUser: query.SignedInUser, diff --git a/pkg/services/searchV2/bluge.go b/pkg/services/searchV2/bluge.go index db0b94b5ee183..dd290a2ed8ad3 100644 --- a/pkg/services/searchV2/bluge.go +++ b/pkg/services/searchV2/bluge.go @@ -435,6 +435,12 @@ func doSearchQuery( hasConstraints = true } + // DatasourceType + if q.DatasourceType != "" { + fullQuery.AddMust(bluge.NewTermQuery(q.DatasourceType).SetField(documentFieldDSType)) + hasConstraints = true + } + // Folder if q.Location != "" { fullQuery.AddMust(bluge.NewTermQuery(q.Location).SetField(documentFieldLocation)) diff --git a/pkg/services/searchV2/http.go b/pkg/services/searchV2/http.go index 8751da74eda8e..0b58e134695a4 100644 --- a/pkg/services/searchV2/http.go +++ b/pkg/services/searchV2/http.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "io" + "net/http" "github.com/prometheus/client_golang/prometheus" @@ -39,7 +40,7 @@ func (s *searchHTTPService) doQuery(c *contextmodel.ReqContext) response.Respons "reason": searchReadinessCheckResp.Reason, }).Inc() - return response.JSON(200, &backend.DataResponse{ + return response.JSON(http.StatusOK, &backend.DataResponse{ Frames: []*data.Frame{{ Name: "Loading", }}, @@ -49,30 +50,30 @@ func (s *searchHTTPService) doQuery(c *contextmodel.ReqContext) response.Respons body, err := io.ReadAll(c.Req.Body) if err != nil { - return response.Error(500, "error reading bytes", err) + return response.Error(http.StatusInternalServerError, "error reading bytes", err) } query := &DashboardQuery{} err = json.Unmarshal(body, query) if err != nil { - return response.Error(400, "error parsing body", err) + return response.Error(http.StatusBadRequest, "error parsing body", err) } resp := s.search.doDashboardQuery(c.Req.Context(), c.SignedInUser, c.SignedInUser.GetOrgID(), *query) if resp.Error != nil { - return response.Error(500, "error handling search request", resp.Error) + return response.Error(http.StatusInternalServerError, "error handling search request", resp.Error) } if len(resp.Frames) == 0 { msg := "invalid search response" - return response.Error(500, msg, errors.New(msg)) + return response.Error(http.StatusInternalServerError, msg, errors.New(msg)) } bytes, err := resp.MarshalJSON() if err != nil { - return response.Error(500, "error marshalling response", err) + return response.Error(http.StatusInternalServerError, "error marshalling response", err) } - return response.JSON(200, bytes) + return response.JSON(http.StatusOK, bytes) } diff --git a/pkg/services/searchV2/service_bench_test.go b/pkg/services/searchV2/service_bench_test.go index bd5d95df44d70..7c9c8e55b78d3 100644 --- a/pkg/services/searchV2/service_bench_test.go +++ b/pkg/services/searchV2/service_bench_test.go @@ -19,8 +19,13 @@ import ( "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + // setupBenchEnv will set up a database with folderCount folders and dashboardsPerFolder dashboards per folder // It will also set up and run the search service // and create a signed in user object with explicit permissions on each dashboard and folder. @@ -134,16 +139,16 @@ func populateDB(folderCount, dashboardsPerFolder int, sqlStore *sqlstore.SQLStor for u := start; u < end; u++ { dashID := int64(u + offset) - folderID := int64((u+offset)%folderCount + 1) + folderUID := fmt.Sprintf("folder%v", int64((u+offset)%folderCount+1)) dbs = append(dbs, dashboards.Dashboard{ - ID: dashID, - UID: fmt.Sprintf("dashboard%v", dashID), - Title: fmt.Sprintf("dashboard%v", dashID), - IsFolder: false, - FolderID: folderID, // nolint:staticcheck - OrgID: 1, - Created: now, - Updated: now, + ID: dashID, + UID: fmt.Sprintf("dashboard%v", dashID), + Title: fmt.Sprintf("dashboard%v", dashID), + IsFolder: false, + FolderUID: folderUID, + OrgID: 1, + Created: now, + Updated: now, }) } diff --git a/pkg/services/searchV2/types.go b/pkg/services/searchV2/types.go index 2c6b7fcd72686..f66c7702b4796 100644 --- a/pkg/services/searchV2/types.go +++ b/pkg/services/searchV2/types.go @@ -18,7 +18,7 @@ type DashboardQuery struct { Query string `json:"query"` Location string `json:"location,omitempty"` // parent folder ID Sort string `json:"sort,omitempty"` // field ASC/DESC - Datasource string `json:"ds_uid,omitempty"` // "datasource" collides with the JSON value at the same leel :() + Datasource string `json:"ds_uid,omitempty"` // "datasource" collides with the JSON value at the same level :() DatasourceType string `json:"ds_type,omitempty"` Tags []string `json:"tags,omitempty"` Kind []string `json:"kind,omitempty"` diff --git a/pkg/services/searchusers/searchusers.go b/pkg/services/searchusers/searchusers.go index efddf8fe51d99..b912fbecc97b8 100644 --- a/pkg/services/searchusers/searchusers.go +++ b/pkg/services/searchusers/searchusers.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/searchusers/sortopts" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" ) type Service interface { @@ -17,13 +18,15 @@ type Service interface { } type OSSService struct { + cfg *setting.Cfg searchUserFilter user.SearchUserFilter userService user.Service } -func ProvideUsersService(searchUserFilter user.SearchUserFilter, userService user.Service, +func ProvideUsersService(cfg *setting.Cfg, searchUserFilter user.SearchUserFilter, userService user.Service, ) *OSSService { return &OSSService{ + cfg: cfg, searchUserFilter: searchUserFilter, userService: userService, } @@ -43,7 +46,7 @@ func ProvideUsersService(searchUserFilter user.SearchUserFilter, userService use func (s *OSSService) SearchUsers(c *contextmodel.ReqContext) response.Response { result, err := s.SearchUser(c) if err != nil { - return response.ErrOrFallback(500, "Failed to fetch users", err) + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to fetch users", err) } return response.JSON(http.StatusOK, result.Users) @@ -62,7 +65,7 @@ func (s *OSSService) SearchUsers(c *contextmodel.ReqContext) response.Response { func (s *OSSService) SearchUsersWithPaging(c *contextmodel.ReqContext) response.Response { result, err := s.SearchUser(c) if err != nil { - return response.ErrOrFallback(500, "Failed to fetch users", err) + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to fetch users", err) } return response.JSON(http.StatusOK, result) @@ -108,7 +111,7 @@ func (s *OSSService) SearchUser(c *contextmodel.ReqContext) (*user.SearchUserQue } for _, user := range res.Users { - user.AvatarURL = dtos.GetGravatarUrl(user.Email) + user.AvatarURL = dtos.GetGravatarUrl(s.cfg, user.Email) user.AuthLabels = make([]string, 0) if user.AuthModule != nil && len(user.AuthModule) > 0 { for _, authModule := range user.AuthModule { diff --git a/pkg/services/secrets/database/database.go b/pkg/services/secrets/database/database.go index 5511d4832c7a7..89eb3520d691b 100644 --- a/pkg/services/secrets/database/database.go +++ b/pkg/services/secrets/database/database.go @@ -11,29 +11,35 @@ import ( "github.com/grafana/grafana/pkg/services/secrets" ) -const dataKeysTable = "data_keys" - type SecretsStoreImpl struct { - db db.DB - log log.Logger + db db.DB + log log.Logger + table string } func ProvideSecretsStore(db db.DB) *SecretsStoreImpl { store := &SecretsStoreImpl{ - db: db, - log: log.New("secrets.store"), + db: db, + log: log.New("secrets.store"), + table: "data_keys", } return store } +func NewSecretsStoreForTable(db db.DB, table string) *SecretsStoreImpl { + store := ProvideSecretsStore(db) + store.table = table + return store +} + func (ss *SecretsStoreImpl) GetDataKey(ctx context.Context, id string) (*secrets.DataKey, error) { dataKey := &secrets.DataKey{} var exists bool err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { var err error - exists, err = sess.Table(dataKeysTable). + exists, err = sess.Table(ss.table). Where("name = ?", id). Get(dataKey) return err @@ -56,7 +62,7 @@ func (ss *SecretsStoreImpl) GetCurrentDataKey(ctx context.Context, label string) err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { var err error - exists, err = sess.Table(dataKeysTable). + exists, err = sess.Table(ss.table). Where("label = ? AND active = ?", label, ss.db.GetDialect().BooleanStr(true)). Get(dataKey) return err @@ -76,7 +82,7 @@ func (ss *SecretsStoreImpl) GetCurrentDataKey(ctx context.Context, label string) func (ss *SecretsStoreImpl) GetAllDataKeys(ctx context.Context) ([]*secrets.DataKey, error) { result := make([]*secrets.DataKey, 0) err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { - err := sess.Table(dataKeysTable).Find(&result) + err := sess.Table(ss.table).Find(&result) return err }) return result, err @@ -91,7 +97,7 @@ func (ss *SecretsStoreImpl) CreateDataKey(ctx context.Context, dataKey *secrets. dataKey.Updated = dataKey.Created return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - _, err := sess.Table(dataKeysTable).Insert(dataKey) + _, err := sess.Table(ss.table).Insert(dataKey) if err != nil { return err } @@ -102,7 +108,7 @@ func (ss *SecretsStoreImpl) CreateDataKey(ctx context.Context, dataKey *secrets. func (ss *SecretsStoreImpl) DisableDataKeys(ctx context.Context) error { return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - _, err := sess.Table(dataKeysTable). + _, err := sess.Table(ss.table). Where("active = ?", ss.db.GetDialect().BooleanStr(true)). UseBool("active").Update(&secrets.DataKey{Active: false}) return err @@ -115,7 +121,7 @@ func (ss *SecretsStoreImpl) DeleteDataKey(ctx context.Context, id string) error } return ss.db.WithDbSession(ctx, func(sess *db.Session) error { - _, err := sess.Table(dataKeysTable).Delete(&secrets.DataKey{Id: id}) + _, err := sess.Table(ss.table).Delete(&secrets.DataKey{Id: id}) return err }) @@ -128,7 +134,7 @@ func (ss *SecretsStoreImpl) ReEncryptDataKeys( ) error { keys := make([]*secrets.DataKey, 0) if err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { - return sess.Table(dataKeysTable).Find(&keys) + return sess.Table(ss.table).Find(&keys) }); err != nil { return err } @@ -175,7 +181,7 @@ func (ss *SecretsStoreImpl) ReEncryptDataKeys( return nil } - if _, err := sess.Table(dataKeysTable).Where("name = ?", k.Id).Update(k); err != nil { + if _, err := sess.Table(ss.table).Where("name = ?", k.Id).Update(k); err != nil { ss.log.Warn( "Error while re-encrypting data encryption key", "id", k.Id, diff --git a/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go b/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go index 275c7391a515a..608c7cde49ff3 100644 --- a/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go +++ b/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go @@ -20,8 +20,13 @@ import ( secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func SetupTestDataSourceSecretMigrationService(t *testing.T, sqlStore db.DB, kvStore kvstore.KVStore, secretsStore secretskvs.SecretsKVStore, compatibility bool) *DataSourceSecretMigrationService { t.Helper() cfg := &setting.Cfg{} diff --git a/pkg/services/secrets/kvstore/plugin_test.go b/pkg/services/secrets/kvstore/plugin_test.go index 460adea650dba..8cd70129df6f6 100644 --- a/pkg/services/secrets/kvstore/plugin_test.go +++ b/pkg/services/secrets/kvstore/plugin_test.go @@ -8,8 +8,13 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + // Set fatal flag to true, then simulate a plugin start failure // Should result in an error from the secret store provider func TestFatalPluginErr_PluginFailsToStartWithFatalFlagSet(t *testing.T) { diff --git a/pkg/services/secrets/kvstore/test_helpers.go b/pkg/services/secrets/kvstore/test_helpers.go index c790446a7b3c0..768a94d7085b1 100644 --- a/pkg/services/secrets/kvstore/test_helpers.go +++ b/pkg/services/secrets/kvstore/test_helpers.go @@ -155,6 +155,10 @@ func (f fakeFeatureToggles) IsEnabled(ctx context.Context, feature string) bool return f.returnValue } +func (f fakeFeatureToggles) GetEnabled(ctx context.Context) map[string]bool { + return map[string]bool{} +} + // Fake grpc secrets plugin impl type fakeGRPCSecretsPlugin struct { kv map[Key]string diff --git a/pkg/services/secrets/manager/helpers.go b/pkg/services/secrets/manager/helpers.go index aacbb636e13fc..9633bde3508d8 100644 --- a/pkg/services/secrets/manager/helpers.go +++ b/pkg/services/secrets/manager/helpers.go @@ -23,12 +23,9 @@ func SetupDisabledTestService(tb testing.TB, store secrets.Store) *SecretsServic return setupTestService(tb, store, featuremgmt.WithFeatures(featuremgmt.FlagDisableEnvelopeEncryption)) } -func setupTestService(tb testing.TB, store secrets.Store, features *featuremgmt.FeatureManager) *SecretsService { +func setupTestService(tb testing.TB, store secrets.Store, features featuremgmt.FeatureToggles) *SecretsService { tb.Helper() defaultKey := "SdlklWklckeLS" - if len(setting.SecretKey) > 0 { - defaultKey = setting.SecretKey - } raw, err := ini.Load([]byte(` [security] secret_key = ` + defaultKey + ` diff --git a/pkg/services/secrets/manager/manager.go b/pkg/services/secrets/manager/manager.go index 669e7217c9870..eb8179ece191e 100644 --- a/pkg/services/secrets/manager/manager.go +++ b/pkg/services/secrets/manager/manager.go @@ -160,7 +160,7 @@ var b64 = base64.RawStdEncoding func (s *SecretsService) Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error) { // Use legacy encryption service if featuremgmt.FlagDisableEnvelopeEncryption toggle is on if s.features.IsEnabled(ctx, featuremgmt.FlagDisableEnvelopeEncryption) { - return s.enc.Encrypt(ctx, payload, setting.SecretKey) + return s.enc.Encrypt(ctx, payload, s.cfg.SecretKey) } var err error diff --git a/pkg/services/secrets/manager/manager_test.go b/pkg/services/secrets/manager/manager_test.go index 7b83dc45ef9f2..50396397e07f0 100644 --- a/pkg/services/secrets/manager/manager_test.go +++ b/pkg/services/secrets/manager/manager_test.go @@ -20,9 +20,14 @@ import ( "github.com/grafana/grafana/pkg/services/secrets/database" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestSecretsService_EnvelopeEncryption(t *testing.T) { testDB := db.InitTestDB(t) store := database.ProvideSecretsStore(testDB) diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index 98abc9eb0c9a7..e76a5c29ca29f 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -48,7 +48,7 @@ func NewServiceAccountsAPI( RouterRegister: routerRegister, log: log.New("serviceaccounts.api"), permissionService: permissionService, - isExternalSAEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts) || features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth), + isExternalSAEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts), } } @@ -147,7 +147,7 @@ func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *contextmodel.ReqConte saIDString := strconv.FormatInt(serviceAccount.Id, 10) metadata := api.getAccessControlMetadata(ctx, map[string]bool{saIDString: true}) - serviceAccount.AvatarUrl = dtos.GetGravatarUrlWithDefault("", serviceAccount.Name) + serviceAccount.AvatarUrl = dtos.GetGravatarUrlWithDefault(api.cfg, "", serviceAccount.Name) serviceAccount.AccessControl = metadata[saIDString] tokens, err := api.service.ListTokens(ctx.Req.Context(), &serviceaccounts.GetSATokensQuery{ @@ -198,7 +198,7 @@ func (api *ServiceAccountsAPI) UpdateServiceAccount(c *contextmodel.ReqContext) saIDString := strconv.FormatInt(resp.Id, 10) metadata := api.getAccessControlMetadata(c, map[string]bool{saIDString: true}) - resp.AvatarUrl = dtos.GetGravatarUrlWithDefault("", resp.Name) + resp.AvatarUrl = dtos.GetGravatarUrlWithDefault(api.cfg, "", resp.Name) resp.AccessControl = metadata[saIDString] return response.JSON(http.StatusOK, util.DynMap{ @@ -296,7 +296,7 @@ func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *contextmode saIDs := map[string]bool{} for i := range serviceAccountSearch.ServiceAccounts { sa := serviceAccountSearch.ServiceAccounts[i] - sa.AvatarUrl = dtos.GetGravatarUrlWithDefault("", sa.Name) + sa.AvatarUrl = dtos.GetGravatarUrlWithDefault(api.cfg, "", sa.Name) saIDString := strconv.FormatInt(sa.Id, 10) saIDs[saIDString] = true diff --git a/pkg/services/serviceaccounts/database/store.go b/pkg/services/serviceaccounts/database/store.go index 5621f5743a804..b99925c9978a7 100644 --- a/pkg/services/serviceaccounts/database/store.go +++ b/pkg/services/serviceaccounts/database/store.go @@ -42,10 +42,22 @@ func ProvideServiceAccountsStore(cfg *setting.Cfg, store db.DB, apiKeyService ap } } +// generateLogin makes a generated string to have a ID for the service account across orgs and it's name +// this causes you to create a service account with the same name in different orgs +// not the same name in the same org +// -- WARNING: +// -- if you change this function you need to change the ExtSvcLoginPrefix as well +// -- to make sure they are not considered as regular service accounts +func generateLogin(prefix string, orgId int64, name string) string { + generatedLogin := fmt.Sprintf("%v-%v-%v", prefix, orgId, strings.ToLower(name)) + // in case the name has multiple spaces or dashes in the prefix or otherwise, replace them with a single dash + generatedLogin = strings.Replace(generatedLogin, "--", "-", 1) + return strings.Replace(generatedLogin, " ", "-", -1) +} + // CreateServiceAccount creates service account func (s *ServiceAccountsStoreImpl) CreateServiceAccount(ctx context.Context, orgId int64, saForm *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) { - generatedLogin := serviceaccounts.ServiceAccountPrefix + strings.ToLower(saForm.Name) - generatedLogin = strings.ReplaceAll(generatedLogin, " ", "-") + login := generateLogin(serviceaccounts.ServiceAccountPrefix, orgId, saForm.Name) isDisabled := false role := org.RoleViewer if saForm.IsDisabled != nil { @@ -56,7 +68,7 @@ func (s *ServiceAccountsStoreImpl) CreateServiceAccount(ctx context.Context, org } newSA, err := s.userService.CreateServiceAccount(ctx, &user.CreateUserCommand{ - Login: generatedLogin, + Login: login, OrgID: orgId, Name: saForm.Name, IsDisabled: isDisabled, @@ -322,7 +334,7 @@ func (s *ServiceAccountsStoreImpl) SearchOrgServiceAccounts(ctx context.Context, whereConditions = append( whereConditions, "login "+s.sqlStore.GetDialect().LikeStr()+" ?") - whereParams = append(whereParams, serviceaccounts.ServiceAccountPrefix+serviceaccounts.ExtSvcPrefix+"%") + whereParams = append(whereParams, serviceaccounts.ExtSvcLoginPrefix+"%") default: s.log.Warn("Invalid filter user for service account filtering", "service account search filtering", query.Filter) } @@ -435,7 +447,7 @@ func (s *ServiceAccountsStoreImpl) MigrateApiKey(ctx context.Context, orgId int6 func (s *ServiceAccountsStoreImpl) CreateServiceAccountFromApikey(ctx context.Context, key *apikey.APIKey) error { prefix := "sa-autogen" cmd := user.CreateUserCommand{ - Login: fmt.Sprintf("%v-%v-%v", prefix, key.OrgID, key.Name), + Login: generateLogin(prefix, key.OrgID, key.Name), Name: fmt.Sprintf("%v-%v", prefix, key.Name), OrgID: key.OrgID, DefaultOrgRole: string(key.Role), @@ -456,7 +468,6 @@ func serviceAccountDeletions(dialect migrator.Dialect) []string { "DELETE FROM star WHERE user_id = ?", "DELETE FROM " + dialect.Quote("user") + " WHERE id = ?", "DELETE FROM org_user WHERE user_id = ?", - "DELETE FROM dashboard_acl WHERE user_id = ?", "DELETE FROM preferences WHERE user_id = ?", "DELETE FROM team_member WHERE user_id = ?", "DELETE FROM user_auth WHERE user_id = ?", diff --git a/pkg/services/serviceaccounts/database/store_test.go b/pkg/services/serviceaccounts/database/store_test.go index c3cb77824960f..038971d33d053 100644 --- a/pkg/services/serviceaccounts/database/store_test.go +++ b/pkg/services/serviceaccounts/database/store_test.go @@ -20,13 +20,18 @@ import ( "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + // Service Account should not create an org on its own func TestStore_CreateServiceAccountOrgNonExistant(t *testing.T) { _, store := setupTestDatabase(t) + serviceAccountName := "new Service Account" t.Run("create service account", func(t *testing.T) { - serviceAccountName := "new Service Account" serviceAccountOrgId := int64(1) serviceAccountRole := org.RoleAdmin isDisabled := true @@ -42,13 +47,43 @@ func TestStore_CreateServiceAccountOrgNonExistant(t *testing.T) { } func TestStore_CreateServiceAccount(t *testing.T) { - _, store := setupTestDatabase(t) - orgQuery := &org.CreateOrgCommand{Name: orgimpl.MainOrgName} - orgResult, err := store.orgService.CreateWithMember(context.Background(), orgQuery) - require.NoError(t, err) - + serviceAccountName := "new Service Account" t.Run("create service account", func(t *testing.T) { - serviceAccountName := "new Service Account" + _, store := setupTestDatabase(t) + orgQuery := &org.CreateOrgCommand{Name: orgimpl.MainOrgName} + orgResult, err := store.orgService.CreateWithMember(context.Background(), orgQuery) + require.NoError(t, err) + serviceAccountOrgId := orgResult.ID + serviceAccountRole := org.RoleAdmin + isDisabled := true + saForm := serviceaccounts.CreateServiceAccountForm{ + Name: serviceAccountName, + Role: &serviceAccountRole, + IsDisabled: &isDisabled, + } + + saDTO, err := store.CreateServiceAccount(context.Background(), serviceAccountOrgId, &saForm) + require.NoError(t, err) + assert.Equal(t, serviceAccountName, saDTO.Name) + assert.Equal(t, 0, int(saDTO.Tokens)) + + retrieved, err := store.RetrieveServiceAccount(context.Background(), serviceAccountOrgId, saDTO.Id) + require.NoError(t, err) + assert.Equal(t, serviceAccountName, retrieved.Name) + assert.Equal(t, serviceAccountOrgId, retrieved.OrgId) + assert.Equal(t, string(serviceAccountRole), retrieved.Role) + assert.True(t, retrieved.IsDisabled) + + retrievedId, err := store.RetrieveServiceAccountIdByName(context.Background(), serviceAccountOrgId, serviceAccountName) + require.NoError(t, err) + assert.Equal(t, saDTO.Id, retrievedId) + }) + + t.Run("create service account twice same org, error", func(t *testing.T) { + _, store := setupTestDatabase(t) + orgQuery := &org.CreateOrgCommand{Name: orgimpl.MainOrgName} + orgResult, err := store.orgService.CreateWithMember(context.Background(), orgQuery) + require.NoError(t, err) serviceAccountOrgId := orgResult.ID serviceAccountRole := org.RoleAdmin isDisabled := true @@ -60,13 +95,11 @@ func TestStore_CreateServiceAccount(t *testing.T) { saDTO, err := store.CreateServiceAccount(context.Background(), serviceAccountOrgId, &saForm) require.NoError(t, err) - assert.Equal(t, "sa-new-service-account", saDTO.Login) assert.Equal(t, serviceAccountName, saDTO.Name) assert.Equal(t, 0, int(saDTO.Tokens)) retrieved, err := store.RetrieveServiceAccount(context.Background(), serviceAccountOrgId, saDTO.Id) require.NoError(t, err) - assert.Equal(t, "sa-new-service-account", retrieved.Login) assert.Equal(t, serviceAccountName, retrieved.Name) assert.Equal(t, serviceAccountOrgId, retrieved.OrgId) assert.Equal(t, string(serviceAccountRole), retrieved.Role) @@ -75,6 +108,51 @@ func TestStore_CreateServiceAccount(t *testing.T) { retrievedId, err := store.RetrieveServiceAccountIdByName(context.Background(), serviceAccountOrgId, serviceAccountName) require.NoError(t, err) assert.Equal(t, saDTO.Id, retrievedId) + + // should not b able to create the same service account twice in the same org + _, err = store.CreateServiceAccount(context.Background(), serviceAccountOrgId, &saForm) + require.Error(t, err) + }) + + t.Run("create service account twice different orgs should work", func(t *testing.T) { + _, store := setupTestDatabase(t) + orgQuery := &org.CreateOrgCommand{Name: orgimpl.MainOrgName} + orgResult, err := store.orgService.CreateWithMember(context.Background(), orgQuery) + require.NoError(t, err) + serviceAccountOrgId := orgResult.ID + serviceAccountRole := org.RoleAdmin + isDisabled := true + saForm := serviceaccounts.CreateServiceAccountForm{ + Name: serviceAccountName, + Role: &serviceAccountRole, + IsDisabled: &isDisabled, + } + + saDTO, err := store.CreateServiceAccount(context.Background(), serviceAccountOrgId, &saForm) + require.NoError(t, err) + assert.Equal(t, serviceAccountName, saDTO.Name) + assert.Equal(t, 0, int(saDTO.Tokens)) + + retrieved, err := store.RetrieveServiceAccount(context.Background(), serviceAccountOrgId, saDTO.Id) + require.NoError(t, err) + assert.Equal(t, serviceAccountName, retrieved.Name) + assert.Equal(t, serviceAccountOrgId, retrieved.OrgId) + assert.Equal(t, string(serviceAccountRole), retrieved.Role) + assert.True(t, retrieved.IsDisabled) + + retrievedId, err := store.RetrieveServiceAccountIdByName(context.Background(), serviceAccountOrgId, serviceAccountName) + require.NoError(t, err) + assert.Equal(t, saDTO.Id, retrievedId) + + orgQuerySecond := &org.CreateOrgCommand{Name: "Second Org name"} + orgResultSecond, err := store.orgService.CreateWithMember(context.Background(), orgQuerySecond) + require.NoError(t, err) + serviceAccountOrgIdSecond := orgResultSecond.ID + // should not b able to create the same service account twice in the same org + saDTOSecond, err := store.CreateServiceAccount(context.Background(), serviceAccountOrgIdSecond, &saForm) + require.NoError(t, err) + assert.Equal(t, serviceAccountName, saDTOSecond.Name) + assert.Equal(t, 0, int(saDTOSecond.Tokens)) }) } @@ -95,13 +173,11 @@ func TestStore_CreateServiceAccountRoleNone(t *testing.T) { saDTO, err := store.CreateServiceAccount(context.Background(), serviceAccountOrgId, &saForm) require.NoError(t, err) - assert.Equal(t, "sa-new-service-account", saDTO.Login) assert.Equal(t, serviceAccountName, saDTO.Name) assert.Equal(t, 0, int(saDTO.Tokens)) retrieved, err := store.RetrieveServiceAccount(context.Background(), serviceAccountOrgId, saDTO.Id) require.NoError(t, err) - assert.Equal(t, "sa-new-service-account", retrieved.Login) assert.Equal(t, serviceAccountName, retrieved.Name) assert.Equal(t, serviceAccountOrgId, retrieved.OrgId) assert.Equal(t, string(serviceAccountRole), retrieved.Role) @@ -371,14 +447,14 @@ func TestStore_MigrateAllApiKeys(t *testing.T) { } func TestServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing.T) { initUsers := []tests.TestUser{ - {Name: "satest-1", Role: string(org.RoleViewer), Login: "sa-satest-1", IsServiceAccount: true}, + {Name: "satest-1", Role: string(org.RoleViewer), Login: "sa-1-satest-1", IsServiceAccount: true}, {Name: "usertest-2", Role: string(org.RoleEditor), Login: "usertest-2", IsServiceAccount: false}, - {Name: "satest-3", Role: string(org.RoleEditor), Login: "sa-satest-3", IsServiceAccount: true}, - {Name: "satest-4", Role: string(org.RoleAdmin), Login: "sa-satest-4", IsServiceAccount: true}, - {Name: "extsvc-test-5", Role: string(org.RoleNone), Login: "sa-extsvc-test-5", IsServiceAccount: true}, - {Name: "extsvc-test-6", Role: string(org.RoleNone), Login: "sa-extsvc-test-6", IsServiceAccount: true}, - {Name: "extsvc-test-7", Role: string(org.RoleNone), Login: "sa-extsvc-test-7", IsServiceAccount: true}, - {Name: "extsvc-test-8", Role: string(org.RoleNone), Login: "sa-extsvc-test-8", IsServiceAccount: true}, + {Name: "satest-3", Role: string(org.RoleEditor), Login: "sa-1-satest-3", IsServiceAccount: true}, + {Name: "satest-4", Role: string(org.RoleAdmin), Login: "sa-1-satest-4", IsServiceAccount: true}, + {Name: "extsvc-test-5", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-5", IsServiceAccount: true}, + {Name: "extsvc-test-6", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-6", IsServiceAccount: true}, + {Name: "extsvc-test-7", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-7", IsServiceAccount: true}, + {Name: "extsvc-test-8", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-8", IsServiceAccount: true}, } db, store := setupTestDatabase(t) @@ -446,10 +522,10 @@ func TestServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing.T) { expectedCount: 4, }, { - desc: "should return service accounts with sa-satest login", + desc: "should return service accounts with sa-1-satest login", query: &serviceaccounts.SearchOrgServiceAccountsQuery{ OrgID: orgID, - Query: "sa-satest", + Query: "sa-1-satest", SignedInUser: userWithPerm, Filter: serviceaccounts.FilterIncludeAll, }, diff --git a/pkg/services/serviceaccounts/extsvcaccounts/service.go b/pkg/services/serviceaccounts/extsvcaccounts/service.go index 84c984a0ac3c5..3422a0105b4ce 100644 --- a/pkg/services/serviceaccounts/extsvcaccounts/service.go +++ b/pkg/services/serviceaccounts/extsvcaccounts/service.go @@ -26,7 +26,7 @@ import ( type ExtSvcAccountsService struct { acSvc ac.Service - features *featuremgmt.FeatureManager + features featuremgmt.FeatureToggles logger log.Logger metrics *metrics saSvc sa.Service @@ -45,7 +45,7 @@ func ProvideExtSvcAccountsService(acSvc ac.Service, bus bus.Bus, db db.DB, featu tracer: tracer, } - if features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts) || features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) { + if features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts) { // Register the metrics esa.metrics = newMetrics(reg, saSvc, logger) @@ -133,7 +133,7 @@ func (esa *ExtSvcAccountsService) GetExternalServiceNames(ctx context.Context) ( // SaveExternalService creates, updates or delete a service account (and its token) with the requested permissions. func (esa *ExtSvcAccountsService) SaveExternalService(ctx context.Context, cmd *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) { // This is double proofing, we should never reach here anyway the flags have already been checked. - if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) && !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) { + if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) { esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services") return nil, nil } @@ -148,10 +148,6 @@ func (esa *ExtSvcAccountsService) SaveExternalService(ctx context.Context, cmd * slug := slugify.Slugify(cmd.Name) - if cmd.Impersonation.Enabled { - esa.logger.Warn("Impersonation setup skipped. It is not possible to impersonate with a service account token.", "service", slug) - } - saID, err := esa.ManageExtSvcAccount(ctx, &sa.ManageExtSvcAccountCmd{ ExtSvcSlug: slug, Enabled: cmd.Self.Enabled, @@ -181,7 +177,7 @@ func (esa *ExtSvcAccountsService) SaveExternalService(ctx context.Context, cmd * func (esa *ExtSvcAccountsService) RemoveExternalService(ctx context.Context, name string) error { // This is double proofing, we should never reach here anyway the flags have already been checked. - if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) && !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) { + if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) { esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services") return nil } @@ -220,7 +216,7 @@ func (esa *ExtSvcAccountsService) RemoveExtSvcAccount(ctx context.Context, orgID // ManageExtSvcAccount creates, updates or deletes the service account associated with an external service func (esa *ExtSvcAccountsService) ManageExtSvcAccount(ctx context.Context, cmd *sa.ManageExtSvcAccountCmd) (int64, error) { // This is double proofing, we should never reach here anyway the flags have already been checked. - if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) && !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) { + if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) { esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services") return 0, nil } diff --git a/pkg/services/serviceaccounts/extsvcaccounts/service_test.go b/pkg/services/serviceaccounts/extsvcaccounts/service_test.go index ab0c14c0aeffb..e715dc49a20c0 100644 --- a/pkg/services/serviceaccounts/extsvcaccounts/service_test.go +++ b/pkg/services/serviceaccounts/extsvcaccounts/service_test.go @@ -4,6 +4,9 @@ import ( "context" "testing" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -18,8 +21,6 @@ import ( sa "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" ) type TestEnv struct { @@ -447,13 +448,13 @@ func TestExtSvcAccountsService_GetExternalServiceNames(t *testing.T) { sa1 := sa.ServiceAccountDTO{ Id: 1, Name: sa.ExtSvcPrefix + "sa-svc-1", - Login: sa.ServiceAccountPrefix + sa.ExtSvcPrefix + "sa-svc-1", + Login: sa.ExtSvcLoginPrefix + "sa-svc-1", OrgId: extsvcauth.TmpOrgID, } sa2 := sa.ServiceAccountDTO{ Id: 2, Name: sa.ExtSvcPrefix + "sa-svc-2", - Login: sa.ServiceAccountPrefix + sa.ExtSvcPrefix + "sa-svc-2", + Login: sa.ExtSvcLoginPrefix + "sa-svc-2", OrgId: extsvcauth.TmpOrgID, } tests := []struct { diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index 141e79e1a9482..0f9f7a8fc68ca 100644 --- a/pkg/services/serviceaccounts/manager/service.go +++ b/pkg/services/serviceaccounts/manager/service.go @@ -26,6 +26,7 @@ const ( ) type ServiceAccountsService struct { + acService accesscontrol.Service store store log log.Logger backgroundLog log.Logger @@ -54,6 +55,7 @@ func ProvideServiceAccountsService( orgService, ) s := &ServiceAccountsService{ + acService: accesscontrolService, store: serviceAccountsStore, log: log.New("serviceaccounts"), backgroundLog: log.New("serviceaccounts.background"), @@ -174,7 +176,10 @@ func (sa *ServiceAccountsService) DeleteServiceAccount(ctx context.Context, orgI if err := validServiceAccountID(serviceAccountID); err != nil { return err } - return sa.store.DeleteServiceAccount(ctx, orgID, serviceAccountID) + if err := sa.store.DeleteServiceAccount(ctx, orgID, serviceAccountID); err != nil { + return err + } + return sa.acService.DeleteUserPermissions(ctx, orgID, serviceAccountID) } func (sa *ServiceAccountsService) EnableServiceAccount(ctx context.Context, orgID, serviceAccountID int64, enable bool) error { diff --git a/pkg/services/serviceaccounts/manager/service_test.go b/pkg/services/serviceaccounts/manager/service_test.go index bf0633bf1eb31..c7175f480c87b 100644 --- a/pkg/services/serviceaccounts/manager/service_test.go +++ b/pkg/services/serviceaccounts/manager/service_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" @@ -117,7 +118,8 @@ func (f *SecretsCheckerFake) CheckTokens(ctx context.Context) error { func TestProvideServiceAccount_DeleteServiceAccount(t *testing.T) { storeMock := newServiceAccountStoreFake() - svc := ServiceAccountsService{storeMock, log.New("test"), log.New("background.test"), &SecretsCheckerFake{}, false, 0} + acSvc := actest.FakeService{} + svc := ServiceAccountsService{acSvc, storeMock, log.New("test"), log.New("background.test"), &SecretsCheckerFake{}, false, 0} testOrgId := 1 t.Run("should create service account", func(t *testing.T) { diff --git a/pkg/services/serviceaccounts/manager/stats_test.go b/pkg/services/serviceaccounts/manager/stats_test.go index 3f9099a6f4624..e85e9adb12912 100644 --- a/pkg/services/serviceaccounts/manager/stats_test.go +++ b/pkg/services/serviceaccounts/manager/stats_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -12,8 +13,9 @@ import ( ) func Test_UsageStats(t *testing.T) { + acSvc := actest.FakeService{} storeMock := newServiceAccountStoreFake() - svc := ServiceAccountsService{storeMock, log.New("test"), log.New("background-test"), &SecretsCheckerFake{}, true, 5} + svc := ServiceAccountsService{acSvc, storeMock, log.New("test"), log.New("background-test"), &SecretsCheckerFake{}, true, 5} err := svc.DeleteServiceAccount(context.Background(), 1, 1) require.NoError(t, err) diff --git a/pkg/services/serviceaccounts/models.go b/pkg/services/serviceaccounts/models.go index 71b6aec6ac907..d83d298ea8818 100644 --- a/pkg/services/serviceaccounts/models.go +++ b/pkg/services/serviceaccounts/models.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/extsvcauth" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/util/errutil" ) @@ -18,6 +19,7 @@ var ( const ( ServiceAccountPrefix = "sa-" ExtSvcPrefix = "extsvc-" + ExtSvcLoginPrefix = ServiceAccountPrefix + extsvcauth.TmpOrgIDStr + "-" + ExtSvcPrefix ) const ( diff --git a/pkg/services/serviceaccounts/proxy/service.go b/pkg/services/serviceaccounts/proxy/service.go index beabbd3d29c7d..10f2a601dcd7b 100644 --- a/pkg/services/serviceaccounts/proxy/service.go +++ b/pkg/services/serviceaccounts/proxy/service.go @@ -38,7 +38,7 @@ func ProvideServiceAccountsProxy( s := &ServiceAccountsProxy{ log: log.New("serviceaccounts.proxy"), proxiedService: proxiedService, - isProxyEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts) || features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth), + isProxyEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts), } serviceaccountsAPI := api.NewServiceAccountsAPI(cfg, s, ac, accesscontrolService, routeRegister, permissionService, features) @@ -187,5 +187,5 @@ func isNameValid(name string) bool { } func isExternalServiceAccount(login string) bool { - return strings.HasPrefix(login, serviceaccounts.ServiceAccountPrefix+serviceaccounts.ExtSvcPrefix) + return strings.HasPrefix(login, serviceaccounts.ExtSvcLoginPrefix) } diff --git a/pkg/services/serviceaccounts/proxy/service_test.go b/pkg/services/serviceaccounts/proxy/service_test.go index db0325a2172f8..45e1875adef58 100644 --- a/pkg/services/serviceaccounts/proxy/service_test.go +++ b/pkg/services/serviceaccounts/proxy/service_test.go @@ -8,12 +8,12 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/serviceaccounts" + sa "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/extsvcaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" ) -var _ serviceaccounts.Service = (*tests.FakeServiceAccountService)(nil) +var _ sa.Service = (*tests.FakeServiceAccountService)(nil) func TestProvideServiceAccount_crudServiceAccount(t *testing.T) { testOrgId := int64(1) @@ -29,19 +29,19 @@ func TestProvideServiceAccount_crudServiceAccount(t *testing.T) { t.Run("should create service account", func(t *testing.T) { testCases := []struct { description string - form serviceaccounts.CreateServiceAccountForm + form sa.CreateServiceAccountForm expectedError error }{ { description: "should create service account and not return error", - form: serviceaccounts.CreateServiceAccountForm{ + form: sa.CreateServiceAccountForm{ Name: "my-service-account", }, expectedError: nil, }, { description: "should not allow to create a service account with extsvc prefix", - form: serviceaccounts.CreateServiceAccountForm{ + form: sa.CreateServiceAccountForm{ Name: "extsvc-my-service-account", }, expectedError: extsvcaccounts.ErrInvalidName, @@ -61,20 +61,20 @@ func TestProvideServiceAccount_crudServiceAccount(t *testing.T) { testCases := []struct { description string expectedError error - expectedServiceAccount *serviceaccounts.ServiceAccountProfileDTO + expectedServiceAccount *sa.ServiceAccountProfileDTO }{ { description: "should allow to delete a service account", expectedError: nil, - expectedServiceAccount: &serviceaccounts.ServiceAccountProfileDTO{ + expectedServiceAccount: &sa.ServiceAccountProfileDTO{ Login: "my-service-account", }, }, { - description: "should not allow to delete a service account with sa-extsvc prefix", + description: "should not allow to delete a service account with " + sa.ExtSvcLoginPrefix + " prefix", expectedError: extsvcaccounts.ErrCannotBeDeleted, - expectedServiceAccount: &serviceaccounts.ServiceAccountProfileDTO{ - Login: "sa-extsvc-my-service-account", + expectedServiceAccount: &sa.ServiceAccountProfileDTO{ + Login: sa.ExtSvcLoginPrefix + "my-service-account", }, }, } @@ -88,24 +88,24 @@ func TestProvideServiceAccount_crudServiceAccount(t *testing.T) { } }) - t.Run("should delete service account", func(t *testing.T) { + t.Run("should delete service account token", func(t *testing.T) { testCases := []struct { description string expectedError error - expectedServiceAccount *serviceaccounts.ServiceAccountProfileDTO + expectedServiceAccount *sa.ServiceAccountProfileDTO }{ { description: "should allow to delete a service account token", expectedError: nil, - expectedServiceAccount: &serviceaccounts.ServiceAccountProfileDTO{ + expectedServiceAccount: &sa.ServiceAccountProfileDTO{ Login: "my-service-account", }, }, { description: "should not allow to delete a external service account token", expectedError: extsvcaccounts.ErrCannotDeleteToken, - expectedServiceAccount: &serviceaccounts.ServiceAccountProfileDTO{ - Login: "sa-extsvc-my-service-account", + expectedServiceAccount: &sa.ServiceAccountProfileDTO{ + Login: sa.ExtSvcLoginPrefix + "my-service-account", }, }, } @@ -122,20 +122,20 @@ func TestProvideServiceAccount_crudServiceAccount(t *testing.T) { t.Run("should retrieve service account with IsExternal field", func(t *testing.T) { testCases := []struct { description string - expectedServiceAccount *serviceaccounts.ServiceAccountProfileDTO + expectedServiceAccount *sa.ServiceAccountProfileDTO expectedIsExternal bool }{ { description: "should not mark as external", - expectedServiceAccount: &serviceaccounts.ServiceAccountProfileDTO{ + expectedServiceAccount: &sa.ServiceAccountProfileDTO{ Login: "my-service-account", }, expectedIsExternal: false, }, { description: "should mark as external", - expectedServiceAccount: &serviceaccounts.ServiceAccountProfileDTO{ - Login: "sa-extsvc-my-service-account", + expectedServiceAccount: &sa.ServiceAccountProfileDTO{ + Login: sa.ExtSvcLoginPrefix + "my-service-account", }, expectedIsExternal: true, }, @@ -151,17 +151,17 @@ func TestProvideServiceAccount_crudServiceAccount(t *testing.T) { } }) - t.Run("should mark external service accounts correctly", func(t *testing.T) { - serviceMock.ExpectedSearchOrgServiceAccountsResult = &serviceaccounts.SearchOrgServiceAccountsResult{ + t.Run("should flag external service accounts correctly", func(t *testing.T) { + serviceMock.ExpectedSearchOrgServiceAccountsResult = &sa.SearchOrgServiceAccountsResult{ TotalCount: 2, - ServiceAccounts: []*serviceaccounts.ServiceAccountDTO{ + ServiceAccounts: []*sa.ServiceAccountDTO{ {Login: "test"}, - {Login: serviceaccounts.ServiceAccountPrefix + serviceaccounts.ExtSvcPrefix + "test"}, + {Login: sa.ExtSvcLoginPrefix + "test"}, }, Page: 1, PerPage: 2, } - res, err := svc.SearchOrgServiceAccounts(context.Background(), &serviceaccounts.SearchOrgServiceAccountsQuery{OrgID: 1}) + res, err := svc.SearchOrgServiceAccounts(context.Background(), &sa.SearchOrgServiceAccountsQuery{OrgID: 1}) require.Len(t, res.ServiceAccounts, 2) require.NoError(t, err) require.False(t, res.ServiceAccounts[0].IsExternal) @@ -173,47 +173,47 @@ func TestProvideServiceAccount_crudServiceAccount(t *testing.T) { nameWithProtectedPrefix := "extsvc-my-updated-service-account" testCases := []struct { description string - form serviceaccounts.UpdateServiceAccountForm - expectedServiceAccount *serviceaccounts.ServiceAccountProfileDTO + form sa.UpdateServiceAccountForm + expectedServiceAccount *sa.ServiceAccountProfileDTO expectedError error }{ { description: "should update a non-external service account with a valid name", - form: serviceaccounts.UpdateServiceAccountForm{ + form: sa.UpdateServiceAccountForm{ Name: &nameWithoutProtectedPrefix, }, - expectedServiceAccount: &serviceaccounts.ServiceAccountProfileDTO{ + expectedServiceAccount: &sa.ServiceAccountProfileDTO{ Login: "my-service-account", }, expectedError: nil, }, { description: "should not allow to update a non-external service account with extsvc prefix", - form: serviceaccounts.UpdateServiceAccountForm{ + form: sa.UpdateServiceAccountForm{ Name: &nameWithProtectedPrefix, }, - expectedServiceAccount: &serviceaccounts.ServiceAccountProfileDTO{ + expectedServiceAccount: &sa.ServiceAccountProfileDTO{ Login: "my-service-account", }, expectedError: extsvcaccounts.ErrInvalidName, }, { description: "should not allow to update an external service account with a valid name", - form: serviceaccounts.UpdateServiceAccountForm{ + form: sa.UpdateServiceAccountForm{ Name: &nameWithoutProtectedPrefix, }, - expectedServiceAccount: &serviceaccounts.ServiceAccountProfileDTO{ - Login: "sa-extsvc-my-service-account", + expectedServiceAccount: &sa.ServiceAccountProfileDTO{ + Login: sa.ExtSvcLoginPrefix + "my-service-account", }, expectedError: extsvcaccounts.ErrCannotBeUpdated, }, { description: "should not allow to update an external service account with a extsvc prefix", - form: serviceaccounts.UpdateServiceAccountForm{ + form: sa.UpdateServiceAccountForm{ Name: &nameWithProtectedPrefix, }, - expectedServiceAccount: &serviceaccounts.ServiceAccountProfileDTO{ - Login: "sa-extsvc-my-service-account", + expectedServiceAccount: &sa.ServiceAccountProfileDTO{ + Login: sa.ExtSvcLoginPrefix + "my-service-account", }, expectedError: extsvcaccounts.ErrInvalidName, }, @@ -232,27 +232,27 @@ func TestProvideServiceAccount_crudServiceAccount(t *testing.T) { t.Run("should add service account tokens", func(t *testing.T) { testCases := []struct { description string - cmd serviceaccounts.AddServiceAccountTokenCommand - expectedServiceAccount *serviceaccounts.ServiceAccountProfileDTO + cmd sa.AddServiceAccountTokenCommand + expectedServiceAccount *sa.ServiceAccountProfileDTO expectedError error }{ { description: "should allow to create a service account token", - cmd: serviceaccounts.AddServiceAccountTokenCommand{ + cmd: sa.AddServiceAccountTokenCommand{ OrgId: testOrgId, }, - expectedServiceAccount: &serviceaccounts.ServiceAccountProfileDTO{ + expectedServiceAccount: &sa.ServiceAccountProfileDTO{ Login: "my-service-account", }, expectedError: nil, }, { description: "should not allow to create a service account token", - cmd: serviceaccounts.AddServiceAccountTokenCommand{ + cmd: sa.AddServiceAccountTokenCommand{ OrgId: testOrgId, }, - expectedServiceAccount: &serviceaccounts.ServiceAccountProfileDTO{ - Login: "sa-extsvc-my-service-account", + expectedServiceAccount: &sa.ServiceAccountProfileDTO{ + Login: sa.ExtSvcLoginPrefix + "my-service-account", }, expectedError: extsvcaccounts.ErrCannotCreateToken, }, @@ -271,7 +271,7 @@ func TestProvideServiceAccount_crudServiceAccount(t *testing.T) { t.Run("should identify service account logins for being external or not", func(t *testing.T) { assert.False(t, isExternalServiceAccount("my-service-account")) assert.False(t, isExternalServiceAccount("sa-my-service-account")) - assert.False(t, isExternalServiceAccount("extsvc-my-service-account")) - assert.True(t, isExternalServiceAccount("sa-extsvc-my-service-account")) + assert.False(t, isExternalServiceAccount(sa.ExtSvcPrefix+"my-service-account")) // It's not a external service account login + assert.True(t, isExternalServiceAccount(sa.ExtSvcLoginPrefix+"my-service-account")) }) } diff --git a/pkg/services/shorturls/shorturlimpl/shorturl_test.go b/pkg/services/shorturls/shorturlimpl/shorturl_test.go index 37913f1833ece..e55804bfceae1 100644 --- a/pkg/services/shorturls/shorturlimpl/shorturl_test.go +++ b/pkg/services/shorturls/shorturlimpl/shorturl_test.go @@ -10,8 +10,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestShortURLService(t *testing.T) { user := &user.SignedInUser{UserID: 1} store := db.InitTestDB(t) diff --git a/pkg/services/signingkeys/signingkeystore/store_test.go b/pkg/services/signingkeys/signingkeystore/store_test.go index 2ddebe006cb1b..2cb8cadc60ce5 100644 --- a/pkg/services/signingkeys/signingkeystore/store_test.go +++ b/pkg/services/signingkeys/signingkeystore/store_test.go @@ -10,8 +10,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/signingkeys" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationSigningKeyStore(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/sqlstore/database_config.go b/pkg/services/sqlstore/database_config.go new file mode 100644 index 0000000000000..f1ada93097ea9 --- /dev/null +++ b/pkg/services/sqlstore/database_config.go @@ -0,0 +1,228 @@ +package sqlstore + +import ( + "errors" + "fmt" + "net/url" + "os" + "path" + "path/filepath" + "strings" + + "github.com/go-sql-driver/mysql" + + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" +) + +type DatabaseConfig struct { + Type string + Host string + Name string + User string + Pwd string + Path string + SslMode string + SSLSNI string + CaCertPath string + ClientKeyPath string + ClientCertPath string + ServerCertName string + ConnectionString string + IsolationLevel string + MaxOpenConn int + MaxIdleConn int + ConnMaxLifetime int + CacheMode string + WALEnabled bool + UrlQueryParams map[string][]string + SkipMigrations bool + MigrationLockAttemptTimeout int + LogQueries bool + // SQLite only + QueryRetries int + // SQLite only + TransactionRetries int +} + +func NewDatabaseConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles) (*DatabaseConfig, error) { + if cfg == nil { + return nil, errors.New("cfg cannot be nil") + } + + dbCfg := &DatabaseConfig{} + if err := dbCfg.readConfig(cfg); err != nil { + return nil, err + } + + if err := dbCfg.buildConnectionString(cfg, features); err != nil { + return nil, err + } + + return dbCfg, nil +} + +func (dbCfg *DatabaseConfig) readConfig(cfg *setting.Cfg) error { + sec := cfg.Raw.Section("database") + + cfgURL := sec.Key("url").String() + if len(cfgURL) != 0 { + dbURL, err := url.Parse(cfgURL) + if err != nil { + return err + } + dbCfg.Type = dbURL.Scheme + dbCfg.Host = dbURL.Host + + pathSplit := strings.Split(dbURL.Path, "/") + if len(pathSplit) > 1 { + dbCfg.Name = pathSplit[1] + } + + userInfo := dbURL.User + if userInfo != nil { + dbCfg.User = userInfo.Username() + dbCfg.Pwd, _ = userInfo.Password() + } + + dbCfg.UrlQueryParams = dbURL.Query() + } else { + dbCfg.Type = sec.Key("type").String() + dbCfg.Host = sec.Key("host").String() + dbCfg.Name = sec.Key("name").String() + dbCfg.User = sec.Key("user").String() + dbCfg.ConnectionString = sec.Key("connection_string").String() + dbCfg.Pwd = sec.Key("password").String() + } + + dbCfg.MaxOpenConn = sec.Key("max_open_conn").MustInt(0) + dbCfg.MaxIdleConn = sec.Key("max_idle_conn").MustInt(2) + dbCfg.ConnMaxLifetime = sec.Key("conn_max_lifetime").MustInt(14400) + + dbCfg.SslMode = sec.Key("ssl_mode").String() + dbCfg.SSLSNI = sec.Key("ssl_sni").String() + dbCfg.CaCertPath = sec.Key("ca_cert_path").String() + dbCfg.ClientKeyPath = sec.Key("client_key_path").String() + dbCfg.ClientCertPath = sec.Key("client_cert_path").String() + dbCfg.ServerCertName = sec.Key("server_cert_name").String() + dbCfg.Path = sec.Key("path").MustString("data/grafana.db") + dbCfg.IsolationLevel = sec.Key("isolation_level").String() + + dbCfg.CacheMode = sec.Key("cache_mode").MustString("private") + dbCfg.WALEnabled = sec.Key("wal").MustBool(false) + dbCfg.SkipMigrations = sec.Key("skip_migrations").MustBool() + dbCfg.MigrationLockAttemptTimeout = sec.Key("locking_attempt_timeout_sec").MustInt() + + dbCfg.QueryRetries = sec.Key("query_retries").MustInt() + dbCfg.TransactionRetries = sec.Key("transaction_retries").MustInt(5) + + dbCfg.LogQueries = sec.Key("log_queries").MustBool(false) + + return nil +} + +func (dbCfg *DatabaseConfig) buildConnectionString(cfg *setting.Cfg, features featuremgmt.FeatureToggles) error { + if dbCfg.ConnectionString != "" { + return nil + } + + cnnstr := "" + + switch dbCfg.Type { + case migrator.MySQL: + protocol := "tcp" + if strings.HasPrefix(dbCfg.Host, "/") { + protocol = "unix" + } + + cnnstr = fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true", + dbCfg.User, dbCfg.Pwd, protocol, dbCfg.Host, dbCfg.Name) + + if dbCfg.SslMode == "true" || dbCfg.SslMode == "skip-verify" { + tlsCert, err := makeCert(dbCfg) + if err != nil { + return err + } + if err := mysql.RegisterTLSConfig("custom", tlsCert); err != nil { + return err + } + + cnnstr += "&tls=custom" + } + + if isolation := dbCfg.IsolationLevel; isolation != "" { + val := url.QueryEscape(fmt.Sprintf("'%s'", isolation)) + cnnstr += fmt.Sprintf("&transaction_isolation=%s", val) + } + + if features != nil && features.IsEnabledGlobally(featuremgmt.FlagMysqlAnsiQuotes) { + cnnstr += "&sql_mode='ANSI_QUOTES'" + } + + cnnstr += buildExtraConnectionString('&', dbCfg.UrlQueryParams) + case migrator.Postgres: + addr, err := util.SplitHostPortDefault(dbCfg.Host, "127.0.0.1", "5432") + if err != nil { + return fmt.Errorf("invalid host specifier '%s': %w", dbCfg.Host, err) + } + + args := []any{dbCfg.User, addr.Host, addr.Port, dbCfg.Name, dbCfg.SslMode, dbCfg.ClientCertPath, + dbCfg.ClientKeyPath, dbCfg.CaCertPath} + + for i, arg := range args { + if arg == "" { + args[i] = "''" + } + } + cnnstr = fmt.Sprintf("user=%s host=%s port=%s dbname=%s sslmode=%s sslcert=%s sslkey=%s sslrootcert=%s", args...) + if dbCfg.SSLSNI != "" { + cnnstr += fmt.Sprintf(" sslsni=%s", dbCfg.SSLSNI) + } + if dbCfg.Pwd != "" { + cnnstr += fmt.Sprintf(" password=%s", dbCfg.Pwd) + } + + cnnstr += buildExtraConnectionString(' ', dbCfg.UrlQueryParams) + case migrator.SQLite: + // special case for tests + if !filepath.IsAbs(dbCfg.Path) { + dbCfg.Path = filepath.Join(cfg.DataPath, dbCfg.Path) + } + if err := os.MkdirAll(path.Dir(dbCfg.Path), os.ModePerm); err != nil { + return err + } + + cnnstr = fmt.Sprintf("file:%s?cache=%s&mode=rwc", dbCfg.Path, dbCfg.CacheMode) + + if dbCfg.WALEnabled { + cnnstr += "&_journal_mode=WAL" + } + + cnnstr += buildExtraConnectionString('&', dbCfg.UrlQueryParams) + default: + return fmt.Errorf("unknown database type: %s", dbCfg.Type) + } + + dbCfg.ConnectionString = cnnstr + + return nil +} + +func buildExtraConnectionString(sep rune, urlQueryParams map[string][]string) string { + if urlQueryParams == nil { + return "" + } + + var sb strings.Builder + for key, values := range urlQueryParams { + for _, value := range values { + sb.WriteRune(sep) + sb.WriteString(key) + sb.WriteRune('=') + sb.WriteString(value) + } + } + return sb.String() +} diff --git a/pkg/services/sqlstore/database_config_test.go b/pkg/services/sqlstore/database_config_test.go new file mode 100644 index 0000000000000..cd13fc3b8faf1 --- /dev/null +++ b/pkg/services/sqlstore/database_config_test.go @@ -0,0 +1,223 @@ +package sqlstore + +import ( + "errors" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" +) + +type databaseConfigTest struct { + name string + dbType string + dbHost string + dbURL string + dbUser string + dbPwd string + expConnStr string + features featuremgmt.FeatureToggles + err error +} + +var databaseConfigTestCases = []databaseConfigTest{ + { + name: "MySQL IPv4", + dbType: "mysql", + dbHost: "1.2.3.4:5678", + expConnStr: ":@tcp(1.2.3.4:5678)/test_db?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true", + }, + { + name: "Postgres IPv4", + dbType: "postgres", + dbHost: "1.2.3.4:5678", + expConnStr: "user='' host=1.2.3.4 port=5678 dbname=test_db sslmode='' sslcert='' sslkey='' sslrootcert=''", + }, + { + name: "Postgres IPv4 (Default Port)", + dbType: "postgres", + dbHost: "1.2.3.4", + expConnStr: "user='' host=1.2.3.4 port=5432 dbname=test_db sslmode='' sslcert='' sslkey='' sslrootcert=''", + }, + { + name: "Postgres username and password", + dbType: "postgres", + dbHost: "1.2.3.4", + dbUser: "grafana", + dbPwd: "password", + expConnStr: "user=grafana host=1.2.3.4 port=5432 dbname=test_db sslmode='' sslcert='' sslkey='' sslrootcert='' password=password", + }, + { + name: "Postgres username no password", + dbType: "postgres", + dbHost: "1.2.3.4", + dbUser: "grafana", + dbPwd: "", + expConnStr: "user=grafana host=1.2.3.4 port=5432 dbname=test_db sslmode='' sslcert='' sslkey='' sslrootcert=''", + }, + { + name: "MySQL IPv4 (Default Port)", + dbType: "mysql", + dbHost: "1.2.3.4", + expConnStr: ":@tcp(1.2.3.4)/test_db?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true", + }, + { + name: "MySQL IPv6", + dbType: "mysql", + dbHost: "[fe80::24e8:31b2:91df:b177]:1234", + expConnStr: ":@tcp([fe80::24e8:31b2:91df:b177]:1234)/test_db?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true", + }, + { + name: "Postgres IPv6", + dbType: "postgres", + dbHost: "[fe80::24e8:31b2:91df:b177]:1234", + expConnStr: "user='' host=fe80::24e8:31b2:91df:b177 port=1234 dbname=test_db sslmode='' sslcert='' sslkey='' sslrootcert=''", + }, + { + name: "MySQL IPv6 (Default Port)", + dbType: "mysql", + dbHost: "[::1]", + expConnStr: ":@tcp([::1])/test_db?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true", + }, + { + name: "Postgres IPv6 (Default Port)", + dbType: "postgres", + dbHost: "[::1]", + expConnStr: "user='' host=::1 port=5432 dbname=test_db sslmode='' sslcert='' sslkey='' sslrootcert=''", + }, + { + name: "Invalid database URL", + dbURL: "://invalid.com/", + err: &url.Error{Op: "parse", URL: "://invalid.com/", Err: errors.New("missing protocol scheme")}, + }, + { + name: "MySQL with ANSI_QUOTES mode", + dbType: "mysql", + dbHost: "[::1]", + features: featuremgmt.WithFeatures(featuremgmt.FlagMysqlAnsiQuotes), + expConnStr: ":@tcp([::1])/test_db?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true&sql_mode='ANSI_QUOTES'", + }, +} + +func TestIntegrationSQLConnectionString(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + for _, testCase := range databaseConfigTestCases { + t.Run(testCase.name, func(t *testing.T) { + cfg := makeDatabaseTestConfig(t, testCase) + dbCfg, err := NewDatabaseConfig(cfg, testCase.features) + require.Equal(t, testCase.err, err) + if testCase.expConnStr != "" { + assert.Equal(t, testCase.expConnStr, dbCfg.ConnectionString) + } + }) + } +} + +func makeDatabaseTestConfig(t *testing.T, tc databaseConfigTest) *setting.Cfg { + t.Helper() + + if tc.features == nil { + tc.features = featuremgmt.WithFeatures() + } + // nolint:staticcheck + cfg := setting.NewCfgWithFeatures(tc.features.IsEnabledGlobally) + + sec, err := cfg.Raw.NewSection("database") + require.NoError(t, err) + _, err = sec.NewKey("type", tc.dbType) + require.NoError(t, err) + _, err = sec.NewKey("host", tc.dbHost) + require.NoError(t, err) + _, err = sec.NewKey("url", tc.dbURL) + require.NoError(t, err) + _, err = sec.NewKey("user", tc.dbUser) + require.NoError(t, err) + _, err = sec.NewKey("name", "test_db") + require.NoError(t, err) + _, err = sec.NewKey("password", tc.dbPwd) + require.NoError(t, err) + + return cfg +} +func TestBuildConnectionStringPostgres(t *testing.T) { + testCases := []struct { + name string + dbCfg *DatabaseConfig + expectedConnStr string + }{ + { + name: "Postgres with sslmode disable", + dbCfg: &DatabaseConfig{ + Type: migrator.Postgres, + User: "grafana", + Pwd: "password", + Host: "127.0.0.1:5432", + Name: "grafana_test", + SslMode: "disable", + }, + expectedConnStr: "user=grafana host=127.0.0.1 port=5432 dbname=grafana_test sslmode=disable sslcert='' sslkey='' sslrootcert='' password=password", + }, + { + name: "Postgres with sslmode verify-ca", + dbCfg: &DatabaseConfig{ + Type: migrator.Postgres, + User: "grafana", + Pwd: "password", + Host: "127.0.0.1:5432", + Name: "grafana_test", + SslMode: "verify-ca", + CaCertPath: "/path/to/ca_cert", + ClientKeyPath: "/path/to/client_key", + ClientCertPath: "/path/to/client_cert", + }, + expectedConnStr: "user=grafana host=127.0.0.1 port=5432 dbname=grafana_test sslmode=verify-ca sslcert=/path/to/client_cert sslkey=/path/to/client_key sslrootcert=/path/to/ca_cert password=password", + }, + { + name: "Postgres with sslmode verify-ca without SNI", + dbCfg: &DatabaseConfig{ + Type: migrator.Postgres, + User: "grafana", + Pwd: "password", + Host: "127.0.0.1:5432", + Name: "grafana_test", + SslMode: "verify-ca", + CaCertPath: "/path/to/ca_cert", + ClientKeyPath: "/path/to/client_key", + ClientCertPath: "/path/to/client_cert", + SSLSNI: "0", + }, + expectedConnStr: "user=grafana host=127.0.0.1 port=5432 dbname=grafana_test sslmode=verify-ca sslcert=/path/to/client_cert sslkey=/path/to/client_key sslrootcert=/path/to/ca_cert sslsni=0 password=password", + }, + { + name: "Postgres with sslmode verify-ca with SNI", + dbCfg: &DatabaseConfig{ + Type: migrator.Postgres, + User: "grafana", + Pwd: "password", + Host: "127.0.0.1:5432", + Name: "grafana_test", + SslMode: "verify-ca", + CaCertPath: "/path/to/ca_cert", + ClientKeyPath: "/path/to/client_key", + ClientCertPath: "/path/to/client_cert", + SSLSNI: "1", + }, + expectedConnStr: "user=grafana host=127.0.0.1 port=5432 dbname=grafana_test sslmode=verify-ca sslcert=/path/to/client_cert sslkey=/path/to/client_key sslrootcert=/path/to/ca_cert sslsni=1 password=password", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.dbCfg.buildConnectionString(&setting.Cfg{}, nil) + assert.NoError(t, err) + assert.Equal(t, tc.expectedConnStr, tc.dbCfg.ConnectionString) + }) + } +} diff --git a/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go b/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go index 91bec67adba2c..9a2aeda12873f 100644 --- a/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go +++ b/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go @@ -661,6 +661,188 @@ func (m *managedFolderLibraryPanelActionsMigrator) Exec(sess *xorm.Session, mg * return nil } +const ManagedDashboardAnnotationActionsMigratorID = "managed dashboard permissions annotation actions migration" + +func AddManagedDashboardAnnotationActionsMigration(mg *migrator.Migrator) { + mg.AddMigration(ManagedDashboardAnnotationActionsMigratorID, &managedDashboardAnnotationActionsMigrator{}) +} + +type managedDashboardAnnotationActionsMigrator struct { + migrator.MigrationBase +} + +func (m *managedDashboardAnnotationActionsMigrator) SQL(dialect migrator.Dialect) string { + return CodeMigrationSQL +} + +func (m *managedDashboardAnnotationActionsMigrator) Exec(sess *xorm.Session, mg *migrator.Migrator) error { + // Check if roles have been populated and return early if they haven't - this avoids logging a warning from hasDefaultAnnotationPermissions + roleCount := 0 + _, err := sess.SQL(`SELECT COUNT( DISTINCT r.uid ) FROM role AS r INNER JOIN permission AS p ON r.id = p.role_id WHERE r.uid IN (?, ?, ?)`, "basic_viewer", "basic_editor", "basic_admin").Get(&roleCount) + if err != nil { + return fmt.Errorf("failed to check if basic roles have been populated: %w", err) + } + // Role count will be 0 either for new Grafana installations (in that case no managed roles will exist either, and the next conditional will return nil) + // or for OSS instances, for which basic role permissions can't be changed, so we don't need to run the default permission check in that case. + if roleCount != 0 { + // Check that default annotation permissions are assigned to basic roles. If that is not the case, skip the migration. + if hasDefaultPerms, err := m.hasDefaultAnnotationPermissions(sess, mg); err != nil || !hasDefaultPerms { + return err + } + } + + var ids []any + if err := sess.SQL("SELECT id FROM role WHERE name LIKE 'managed:%'").Find(&ids); err != nil { + return err + } + + if len(ids) == 0 { + return nil + } + + var permissions []ac.Permission + roleQueryBatchSize := 100 + err = batch(len(ids), roleQueryBatchSize, func(start, end int) error { + var batchPermissions []ac.Permission + if err := sess.SQL("SELECT role_id, action, scope FROM permission WHERE role_id IN(?"+strings.Repeat(" ,?", len(ids[start:end])-1)+") AND (scope LIKE 'folders:%' or scope LIKE 'dashboards:%')", ids[start:end]...).Find(&batchPermissions); err != nil { + return err + } + permissions = append(permissions, batchPermissions...) + return nil + }) + if err != nil { + return err + } + + mapped := make(map[int64]map[string]map[string]bool, len(ids)-1) + for _, p := range permissions { + if mapped[p.RoleID] == nil { + mapped[p.RoleID] = make(map[string]map[string]bool) + } + if mapped[p.RoleID][p.Scope] == nil { + mapped[p.RoleID][p.Scope] = make(map[string]bool) + } + mapped[p.RoleID][p.Scope][p.Action] = true + } + + var toAdd []ac.Permission + now := time.Now() + + for roleId, mappedPermissions := range mapped { + for scope, roleActions := range mappedPermissions { + // Create a temporary permission to split the scope into kind, attribute and identifier + tempPerm := ac.Permission{ + Scope: scope, + } + kind, attribute, identifier := tempPerm.SplitScope() + if roleActions[dashboards.ActionDashboardsRead] { + if !roleActions[ac.ActionAnnotationsRead] { + toAdd = append(toAdd, ac.Permission{ + RoleID: roleId, + Updated: now, + Created: now, + Scope: scope, + Action: ac.ActionAnnotationsRead, + Kind: kind, + Attribute: attribute, + Identifier: identifier, + }) + } + } + + if roleActions[dashboards.ActionDashboardsWrite] { + if !roleActions[ac.ActionAnnotationsCreate] { + toAdd = append(toAdd, ac.Permission{ + RoleID: roleId, + Updated: now, + Created: now, + Scope: scope, + Action: ac.ActionAnnotationsCreate, + Kind: kind, + Attribute: attribute, + Identifier: identifier, + }) + } + if !roleActions[ac.ActionAnnotationsDelete] { + toAdd = append(toAdd, ac.Permission{ + RoleID: roleId, + Updated: now, + Created: now, + Scope: scope, + Action: ac.ActionAnnotationsDelete, + Kind: kind, + Attribute: attribute, + Identifier: identifier, + }) + } + if !roleActions[ac.ActionAnnotationsWrite] { + toAdd = append(toAdd, ac.Permission{ + RoleID: roleId, + Updated: now, + Created: now, + Scope: scope, + Action: ac.ActionAnnotationsWrite, + Kind: kind, + Attribute: attribute, + Identifier: identifier, + }) + } + } + } + } + + if len(toAdd) == 0 { + return nil + } + + return batch(len(toAdd), batchSize, func(start, end int) error { + _, err := sess.InsertMulti(toAdd[start:end]) + return err + }) +} + +func (m *managedDashboardAnnotationActionsMigrator) hasDefaultAnnotationPermissions(sess *xorm.Session, mg *migrator.Migrator) (bool, error) { + type basicRolePermission struct { + Uid string + Action string + Scope string + } + + var basicRolePermissions []basicRolePermission + basicRoleUIDs := []any{"basic_viewer", "basic_editor", "basic_admin"} + query := `SELECT r.uid, p.action, p.scope FROM role r +LEFT OUTER JOIN permission p ON p.role_id = r.id +WHERE r.uid IN (?, ?, ?) AND p.action LIKE 'annotations:%' AND p.scope IN ('*', 'annotations:*', 'annotations:type:*', 'annotations:type:dashboard') +` + if err := sess.SQL(query, basicRoleUIDs...).Find(&basicRolePermissions); err != nil { + return false, fmt.Errorf("failed to list basic role permissions: %w", err) + } + + mappedBasicRolePerms := make(map[string]map[string]bool, 0) + for _, p := range basicRolePermissions { + if mappedBasicRolePerms[p.Uid] == nil { + mappedBasicRolePerms[p.Uid] = make(map[string]bool) + } + mappedBasicRolePerms[p.Uid][p.Action] = true + } + + expectedAnnotationActions := []string{ac.ActionAnnotationsRead, ac.ActionAnnotationsCreate, ac.ActionAnnotationsDelete, ac.ActionAnnotationsWrite} + + for _, uid := range basicRoleUIDs { + if mappedBasicRolePerms[uid.(string)] == nil { + mg.Logger.Warn("basic role permissions missing annotation permissions, skipping annotation permission migration", "uid", uid) + return false, nil + } + for _, action := range expectedAnnotationActions { + if !mappedBasicRolePerms[uid.(string)][action] { + mg.Logger.Warn("basic role permissions missing annotation permissions, skipping annotation permission migration", "uid", uid, "action", action) + return false, nil + } + } + } + return true, nil +} + func hasFolderAdmin(permissions []ac.Permission) bool { return hasActions(folderPermissionTranslation[dashboardaccess.PERMISSION_ADMIN], permissions) } diff --git a/pkg/services/sqlstore/migrations/accesscontrol/migrations.go b/pkg/services/sqlstore/migrations/accesscontrol/migrations.go index a7819085f718b..da3c021dee06c 100644 --- a/pkg/services/sqlstore/migrations/accesscontrol/migrations.go +++ b/pkg/services/sqlstore/migrations/accesscontrol/migrations.go @@ -186,4 +186,14 @@ func AddMigration(mg *migrator.Migrator) { mg.AddMigration("add permission identifier index", migrator.NewAddIndexMigration(permissionV1, &migrator.Index{ Cols: []string{"identifier"}, })) + + mg.AddMigration("add permission action scope role_id index", migrator.NewAddIndexMigration(permissionV1, &migrator.Index{ + Type: migrator.UniqueIndex, + Cols: []string{"action", "scope", "role_id"}, + })) + + mg.AddMigration("remove permission role_id action scope index", migrator.NewDropIndexMigration(permissionV1, &migrator.Index{ + Type: migrator.UniqueIndex, + Cols: []string{"role_id", "action", "scope"}, + })) } diff --git a/pkg/services/sqlstore/migrations/accesscontrol/scope_migrator.go b/pkg/services/sqlstore/migrations/accesscontrol/scope_migrator.go new file mode 100644 index 0000000000000..18af932028022 --- /dev/null +++ b/pkg/services/sqlstore/migrations/accesscontrol/scope_migrator.go @@ -0,0 +1,33 @@ +package accesscontrol + +import ( + "xorm.io/xorm" + + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +const ( + AlertingScopeRemovalMigrationID = "removing scope from alert.instances:read action migration" +) + +func AddAlertingScopeRemovalMigration(mg *migrator.Migrator) { + mg.AddMigration(AlertingScopeRemovalMigrationID, &alertingScopeRemovalMigrator{}) +} + +var _ migrator.CodeMigration = new(alertingScopeRemovalMigrator) + +type alertingScopeRemovalMigrator struct { + permissionMigrator +} + +func (p *alertingScopeRemovalMigrator) SQL(dialect migrator.Dialect) string { + return CodeMigrationSQL +} + +func (p *alertingScopeRemovalMigrator) Exec(sess *xorm.Session, migrator *migrator.Migrator) error { + p.sess = sess + p.dialect = migrator.Dialect + _, err := p.sess.Exec("UPDATE permission SET `scope` = '', `kind` = '', `attribute` = '', `identifier` = '' WHERE action = ?", accesscontrol.ActionAlertingInstanceRead) + return err +} diff --git a/pkg/services/sqlstore/migrations/accesscontrol/seed_assignment.go b/pkg/services/sqlstore/migrations/accesscontrol/seed_assignment.go index 7f6ce47e8e439..c6307ce9f75cd 100644 --- a/pkg/services/sqlstore/migrations/accesscontrol/seed_assignment.go +++ b/pkg/services/sqlstore/migrations/accesscontrol/seed_assignment.go @@ -6,6 +6,8 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore/migrator" ) +const PreventSeedingOnCallAccessID = "prevent seeding OnCall access" + const migSQLITERoleNameNullable = `ALTER TABLE seed_assignment ADD COLUMN tmp_role_name VARCHAR(190) DEFAULT NULL; UPDATE seed_assignment SET tmp_role_name = role_name; ALTER TABLE seed_assignment DROP COLUMN role_name; @@ -41,6 +43,13 @@ func AddSeedAssignmentMigrations(mg *migrator.Migrator) { &migrator.Index{Cols: []string{"builtin_role", "action", "scope"}, Type: migrator.UniqueIndex})) mg.AddMigration("add primary key to seed_assigment", &seedAssignmentPrimaryKeyMigrator{}) + + mg.AddMigration("add origin column to seed_assignment", + migrator.NewAddColumnMigration(seedAssignmentTable, + &migrator.Column{Name: "origin", Type: migrator.DB_Varchar, Length: 190, Nullable: true})) + + mg.AddMigration("add origin to plugin seed_assignment", &seedAssignmentOnCallMigrator{}) + mg.AddMigration(PreventSeedingOnCallAccessID, &SeedAssignmentOnCallAccessMigrator{}) } type seedAssignmentPrimaryKeyMigrator struct { @@ -119,3 +128,77 @@ func (m *seedAssignmentPrimaryKeyMigrator) Exec(sess *xorm.Session, mig *migrato return nil } + +type seedAssignmentOnCallMigrator struct { + migrator.MigrationBase +} + +func (m *seedAssignmentOnCallMigrator) SQL(dialect migrator.Dialect) string { + return CodeMigrationSQL +} + +func (m *seedAssignmentOnCallMigrator) Exec(sess *xorm.Session, mig *migrator.Migrator) error { + _, err := sess.Exec( + `UPDATE seed_assignment SET origin = ? WHERE action LIKE ? OR scope = ?`, + "grafana-oncall-app", + "grafana-oncall-app%", + "plugins:id:grafana-oncall-app", + ) + return err +} + +type SeedAssignmentOnCallAccessMigrator struct { + migrator.MigrationBase +} + +func (m *SeedAssignmentOnCallAccessMigrator) SQL(dialect migrator.Dialect) string { + return CodeMigrationSQL +} + +func (m *SeedAssignmentOnCallAccessMigrator) Exec(sess *xorm.Session, mig *migrator.Migrator) error { + // Check if the migration is necessary + hasEntry := 0 + if _, err := sess.SQL(`SELECT 1 FROM seed_assignment LIMIT 1`).Get(&hasEntry); err != nil { + return err + } + if hasEntry == 0 { + // Skip migration the seed assignment table has not been populated + // Hence the oncall access permission can be granted without any risk + return nil + } + + // Check if the permission has not already been seeded + // This is the case for instances that activated the accessControlOnCall feature already. + type SeedAssignment struct { + BuiltinRole, Action, Scope, Origin string + } + assigns := []SeedAssignment{} + err := sess.SQL(`SELECT builtin_role, action, scope, origin FROM seed_assignment WHERE action = ? AND scope = ?`, + "plugins.app:access", "plugins:id:grafana-oncall-app"). + Find(&assigns) + if err != nil { + return err + } + + basicRoles := map[string]bool{"Viewer": true, "Editor": true, "Admin": true, "Grafana Admin": true} + for i := range assigns { + delete(basicRoles, assigns[i].BuiltinRole) + } + if len(basicRoles) == 0 { + return nil + } + + // By default, basic roles have access to all app plugins; no need for extra permission. + // Mark OnCall Access permission as already seeded to prevent it from being added to basic roles. + toSeed := []SeedAssignment{} + for br := range basicRoles { + toSeed = append(toSeed, SeedAssignment{ + BuiltinRole: br, + Action: "plugins.app:access", + Scope: "plugins:id:grafana-oncall-app", + Origin: "grafana-oncall-app", + }) + } + _, err = sess.Table("seed_assignment").InsertMulti(&toSeed) + return err +} diff --git a/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go b/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go index d02b401d2f6f3..f145a5b7b650c 100644 --- a/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go +++ b/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go @@ -2,7 +2,6 @@ package test import ( "fmt" - "os" "testing" "time" @@ -46,6 +45,7 @@ var ( users = []user.User{ { ID: 1, + UID: "u1", Email: "viewer1@example.org", Name: "viewer1", Login: "viewer1", @@ -55,6 +55,7 @@ var ( }, { ID: 2, + UID: "u2", Email: "viewer2@example.org", Name: "viewer2", Login: "viewer2", @@ -64,6 +65,7 @@ var ( }, { ID: 3, + UID: "u3", Email: "editor1@example.org", Name: "editor1", Login: "editor1", @@ -73,6 +75,7 @@ var ( }, { ID: 4, + UID: "u4", Email: "admin1@example.org", Name: "admin1", Login: "admin1", @@ -82,6 +85,7 @@ var ( }, { ID: 5, + UID: "u5", Email: "editor2@example.org", Name: "editor2", Login: "editor2", @@ -100,37 +104,6 @@ func convertToRawPermissions(permissions []accesscontrol.Permission) []rawPermis return raw } -func getDBType() string { - dbType := migrator.SQLite - - // environment variable present for test db? - if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { - dbType = db - } - return dbType -} - -func getTestDB(t *testing.T, dbType string) sqlutil.TestDB { - switch dbType { - case "mysql": - return sqlutil.MySQLTestDB() - case "postgres": - return sqlutil.PostgresTestDB() - default: - f, err := os.CreateTemp(".", "grafana-test-db-") - require.NoError(t, err) - t.Cleanup(func() { - err := os.Remove(f.Name()) - require.NoError(t, err) - }) - - return sqlutil.TestDB{ - DriverName: "sqlite3", - ConnStr: f.Name(), - } - } -} - func TestMigrations(t *testing.T) { // Run initial migration to have a working DB x := setupTestDB(t) @@ -154,9 +127,7 @@ func TestMigrations(t *testing.T) { desc: "with editors can admin", config: &setting.Cfg{ EditorsCanAdmin: true, - // nolint:staticcheck - IsFeatureToggleEnabled: func(key string) bool { return key == "accesscontrol" }, - Raw: ini.Empty(), + Raw: ini.Empty(), }, expectedRolePerms: map[string][]rawPermission{ "managed:users:1:permissions": {{Action: "teams:read", Scope: team1Scope}}, @@ -250,12 +221,21 @@ func TestMigrations(t *testing.T) { func setupTestDB(t *testing.T) *xorm.Engine { t.Helper() - dbType := getDBType() - testDB := getTestDB(t, dbType) + dbType := sqlutil.GetTestDBType() + testDB, err := sqlutil.GetTestDB(dbType) + require.NoError(t, err) + + t.Cleanup(testDB.Cleanup) x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) + t.Cleanup(func() { + if err := x.Close(); err != nil { + fmt.Printf("failed to close xorm engine: %v", err) + } + }) + err = migrator.NewDialect(x.DriverName()).CleanDB(x) require.NoError(t, err) diff --git a/pkg/services/sqlstore/migrations/accesscontrol/test/dashbord_permission_migrator_test.go b/pkg/services/sqlstore/migrations/accesscontrol/test/dashbord_permission_migrator_test.go new file mode 100644 index 0000000000000..935977ebc59da --- /dev/null +++ b/pkg/services/sqlstore/migrations/accesscontrol/test/dashbord_permission_migrator_test.go @@ -0,0 +1,335 @@ +package test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/dashboards" + acmig "github.com/grafana/grafana/pkg/services/sqlstore/migrations/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" +) + +type testCase struct { + desc string + putRolePerms map[int64]map[string][]rawPermission + wantRolePerms map[int64]map[string][]rawPermission +} + +func testCases() []testCase { + allAnnotationPermissions := []rawPermission{ + {Action: accesscontrol.ActionAnnotationsRead, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}, + {Action: accesscontrol.ActionAnnotationsCreate, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}, + {Action: accesscontrol.ActionAnnotationsDelete, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}, + {Action: accesscontrol.ActionAnnotationsWrite, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}, + } + + onlyOrgAnnotations := []rawPermission{ + {Action: accesscontrol.ActionAnnotationsRead, Scope: accesscontrol.ScopeAnnotationsTypeOrganization}, + {Action: accesscontrol.ActionAnnotationsCreate, Scope: accesscontrol.ScopeAnnotationsTypeOrganization}, + {Action: accesscontrol.ActionAnnotationsDelete, Scope: accesscontrol.ScopeAnnotationsTypeOrganization}, + {Action: accesscontrol.ActionAnnotationsWrite, Scope: accesscontrol.ScopeAnnotationsTypeOrganization}, + } + + wildcardAnnotationPermissions := []rawPermission{ + {Action: accesscontrol.ActionAnnotationsRead, Scope: "*"}, + {Action: accesscontrol.ActionAnnotationsCreate, Scope: "annotations:*"}, + {Action: accesscontrol.ActionAnnotationsDelete, Scope: "annotations:type:*"}, + {Action: accesscontrol.ActionAnnotationsWrite, Scope: accesscontrol.ScopeAnnotationsAll}, + } + + return []testCase{ + { + desc: "empty permissions lead to empty permissions", + putRolePerms: map[int64]map[string][]rawPermission{}, + wantRolePerms: map[int64]map[string][]rawPermission{}, + }, + { + desc: "adds new permissions for instances without basic roles (should only be OSS instances)", + putRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "managed:users:1:permissions": {{Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:test"}}, + }, + }, + wantRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "managed:users:1:permissions": { + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:test"}, + {Action: accesscontrol.ActionAnnotationsRead, Scope: "dashboards:uid:test"}, + }, + }, + }, + }, + { + desc: "doesn't add any new permissions if has default annotation permissions on basic roles but no dashboard or folder permissions", + putRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:viewer": allAnnotationPermissions, + "basic:editor": allAnnotationPermissions, + "basic:admin": allAnnotationPermissions, + }, + }, + wantRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:viewer": allAnnotationPermissions, + "basic:editor": allAnnotationPermissions, + "basic:admin": allAnnotationPermissions, + }, + }, + }, + { + desc: "adds new permissions if has default annotation permissions on basic roles and dashboard read permissions", + putRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:viewer": allAnnotationPermissions, + "basic:editor": allAnnotationPermissions, + "basic:admin": allAnnotationPermissions, + "managed:users:1:permissions": {{Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:test"}}, + }, + }, + wantRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:viewer": allAnnotationPermissions, + "basic:editor": allAnnotationPermissions, + "basic:admin": allAnnotationPermissions, + "managed:users:1:permissions": { + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:test"}, + {Action: accesscontrol.ActionAnnotationsRead, Scope: "dashboards:uid:test"}, + }, + }, + }, + }, + { + desc: "adds new permissions if has default annotation permissions on basic roles and dashboard write permissions", + putRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:viewer": allAnnotationPermissions, + "basic:editor": allAnnotationPermissions, + "basic:admin": allAnnotationPermissions, + "managed:users:1:permissions": { + {Action: dashboards.ActionDashboardsWrite, Scope: "dashboards:uid:test"}, + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:test"}, + }, + }, + }, + wantRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:viewer": allAnnotationPermissions, + "basic:editor": allAnnotationPermissions, + "basic:admin": allAnnotationPermissions, + "managed:users:1:permissions": { + {Action: dashboards.ActionDashboardsWrite, Scope: "dashboards:uid:test"}, + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:test"}, + {Action: accesscontrol.ActionAnnotationsRead, Scope: "dashboards:uid:test"}, + {Action: accesscontrol.ActionAnnotationsWrite, Scope: "dashboards:uid:test"}, + {Action: accesscontrol.ActionAnnotationsDelete, Scope: "dashboards:uid:test"}, + {Action: accesscontrol.ActionAnnotationsCreate, Scope: "dashboards:uid:test"}, + }, + }, + }, + }, + { + desc: "adds new permissions if has default annotation permissions on basic roles and folder read permissions", + putRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:viewer": allAnnotationPermissions, + "basic:editor": allAnnotationPermissions, + "basic:admin": allAnnotationPermissions, + "managed:users:1:permissions": {{Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:test"}}, + }, + }, + wantRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:viewer": allAnnotationPermissions, + "basic:editor": allAnnotationPermissions, + "basic:admin": allAnnotationPermissions, + "managed:users:1:permissions": { + {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:test"}, + {Action: accesscontrol.ActionAnnotationsRead, Scope: "folders:uid:test"}, + }, + }, + }, + }, + { + desc: "adds new permissions if has default annotation permissions on basic roles and folder write permissions", + putRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:viewer": allAnnotationPermissions, + "basic:editor": allAnnotationPermissions, + "basic:admin": allAnnotationPermissions, + "managed:users:1:permissions": { + {Action: dashboards.ActionDashboardsWrite, Scope: "folders:uid:test"}, + {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:test"}, + }, + }, + }, + wantRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:viewer": allAnnotationPermissions, + "basic:editor": allAnnotationPermissions, + "basic:admin": allAnnotationPermissions, + "managed:users:1:permissions": { + {Action: dashboards.ActionDashboardsWrite, Scope: "folders:uid:test"}, + {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:test"}, + {Action: accesscontrol.ActionAnnotationsRead, Scope: "folders:uid:test"}, + {Action: accesscontrol.ActionAnnotationsWrite, Scope: "folders:uid:test"}, + {Action: accesscontrol.ActionAnnotationsDelete, Scope: "folders:uid:test"}, + {Action: accesscontrol.ActionAnnotationsCreate, Scope: "folders:uid:test"}, + }, + }, + }, + }, + { + desc: "adds new permissions to several managed roles if has default annotation permissions on basic roles and dashboard read permissions", + putRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:viewer": allAnnotationPermissions, + "basic:editor": allAnnotationPermissions, + "basic:admin": allAnnotationPermissions, + "managed:users:1:permissions": {{Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:test"}}, + "managed:teams:1:permissions": {{Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:test2"}}, + }, + }, + wantRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:viewer": allAnnotationPermissions, + "basic:editor": allAnnotationPermissions, + "basic:admin": allAnnotationPermissions, + "managed:users:1:permissions": { + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:test"}, + {Action: accesscontrol.ActionAnnotationsRead, Scope: "dashboards:uid:test"}, + }, + "managed:teams:1:permissions": { + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:test2"}, + {Action: accesscontrol.ActionAnnotationsRead, Scope: "dashboards:uid:test2"}, + }, + }, + }, + }, + { + desc: "doesn't add any new permissions if annotation permissions are missing from the basic roles", + putRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:editor": allAnnotationPermissions, + "basic:admin": allAnnotationPermissions, + "managed:users:1:permissions": { + {Action: dashboards.ActionDashboardsWrite, Scope: "dashboards:uid:test"}, + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:test"}, + }, + }, + }, + wantRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:editor": allAnnotationPermissions, + "basic:admin": allAnnotationPermissions, + }, + }, + }, + { + desc: "doesn't add any new permissions if annotation permissions from the basic roles don't have the dashboard scope", + putRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:viewer": onlyOrgAnnotations, + "basic:editor": allAnnotationPermissions, + "basic:admin": allAnnotationPermissions, + "managed:users:1:permissions": { + {Action: dashboards.ActionDashboardsWrite, Scope: "dashboards:uid:test"}, + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:test"}, + }, + }, + }, + wantRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:viewer": onlyOrgAnnotations, + "basic:editor": allAnnotationPermissions, + "basic:admin": allAnnotationPermissions, + }, + }, + }, + { + desc: "adds new permissions if has default annotation permissions with different wildcard scopes", + putRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:viewer": wildcardAnnotationPermissions, + "basic:editor": wildcardAnnotationPermissions, + "basic:admin": wildcardAnnotationPermissions, + "managed:users:1:permissions": {{Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:test"}}, + }, + }, + wantRolePerms: map[int64]map[string][]rawPermission{ + 1: { + "basic:viewer": wildcardAnnotationPermissions, + "basic:editor": wildcardAnnotationPermissions, + "basic:admin": wildcardAnnotationPermissions, + "managed:users:1:permissions": { + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:test"}, + {Action: accesscontrol.ActionAnnotationsRead, Scope: "dashboards:uid:test"}, + }, + }, + }, + }, + } +} + +func TestAnnotationActionMigration(t *testing.T) { + // Run initial migration to have a working DB + x := setupTestDB(t) + + for _, tc := range testCases() { + t.Run(tc.desc, func(t *testing.T) { + // Remove migration + _, errDeleteMig := x.Exec(`DELETE FROM migration_log WHERE migration_id LIKE ?`, acmig.ManagedDashboardAnnotationActionsMigratorID) + require.NoError(t, errDeleteMig) + _, errDeletePerm := x.Exec(`DELETE FROM permission`) + require.NoError(t, errDeletePerm) + _, errDeleteRole := x.Exec(`DELETE FROM role`) + require.NoError(t, errDeleteRole) + + // Test running the migrations twice to make sure they don't conflict + for i := 0; i < 2; i++ { + if i == 0 { + // put permissions + putTestPermissions(t, x, tc.putRolePerms) + } + + // Run accesscontrol migration (permissions insertion should not have conflicted) + acmigrator := migrator.NewMigrator(x, &setting.Cfg{Logger: log.New("acmigration.test")}) + acmig.AddManagedDashboardAnnotationActionsMigration(acmigrator) + + errRunningMig := acmigrator.Start(false, 0) + require.NoError(t, errRunningMig) + + // verify got == want + for orgID, roles := range tc.wantRolePerms { + for roleName := range roles { + // Check managed roles exist + role := accesscontrol.Role{} + hasRole, errRoleSearch := x.Table("role").Where("org_id = ? AND name = ?", orgID, roleName).Get(&role) + + require.NoError(t, errRoleSearch) + require.True(t, hasRole, "expected role to exist", "orgID", orgID, "role", roleName) + + // Check permissions associated with each role + perms := []accesscontrol.Permission{} + count, errManagedPermsSearch := x.Table("permission").Where("role_id = ?", role.ID).FindAndCount(&perms) + + require.NoError(t, errManagedPermsSearch) + require.Equal(t, int64(len(tc.wantRolePerms[orgID][roleName])), count, "expected role to be tied to permissions", "orgID", orgID, "role", roleName) + + gotRawPerms := convertToRawPermissions(perms) + require.ElementsMatch(t, gotRawPerms, tc.wantRolePerms[orgID][roleName], "expected role to have permissions", "orgID", orgID, "role", roleName) + + // Check assignment of the roles + br := accesscontrol.BuiltinRole{} + has, errAssignmentSearch := x.Table("builtin_role").Where("role_id = ? AND role = ? AND org_id = ?", role.ID, acmig.ParseRoleFromName(roleName), orgID).Get(&br) + require.NoError(t, errAssignmentSearch) + require.True(t, has, "expected assignment of role to builtin role", "orgID", orgID, "role", roleName) + } + } + } + }) + } +} diff --git a/pkg/services/sqlstore/migrations/accesscontrol/test/managed_permission_migrator_test.go b/pkg/services/sqlstore/migrations/accesscontrol/test/managed_permission_migrator_test.go index 94ce65f9ee712..4d8c820958e39 100644 --- a/pkg/services/sqlstore/migrations/accesscontrol/test/managed_permission_migrator_test.go +++ b/pkg/services/sqlstore/migrations/accesscontrol/test/managed_permission_migrator_test.go @@ -2,7 +2,6 @@ package test import ( "fmt" - "strconv" "strings" "testing" @@ -256,7 +255,10 @@ func TestManagedPermissionsMigrationRunTwice(t *testing.T) { func putTestPermissions(t *testing.T, x *xorm.Engine, rolePerms map[int64]map[string][]rawPermission) { for orgID, roles := range rolePerms { for roleName, perms := range roles { - uid := strconv.FormatInt(orgID, 10) + strings.ReplaceAll(roleName, ":", "_") + uid := strings.ReplaceAll(roleName, ":", "_") + if !strings.HasPrefix(roleName, "basic") { + uid = fmt.Sprintf("%d_%s", orgID, uid) + } role := accesscontrol.Role{ OrgID: orgID, Version: 1, diff --git a/pkg/services/sqlstore/migrations/accesscontrol/test/scope_migrator_test.go b/pkg/services/sqlstore/migrations/accesscontrol/test/scope_migrator_test.go new file mode 100644 index 0000000000000..9b747b27f32e5 --- /dev/null +++ b/pkg/services/sqlstore/migrations/accesscontrol/test/scope_migrator_test.go @@ -0,0 +1,145 @@ +package test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/datasources" + acmig "github.com/grafana/grafana/pkg/services/sqlstore/migrations/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" +) + +func TestScopeMigration(t *testing.T) { + // Run initial migration to have a working DB + x := setupTestDB(t) + + type migrationTestCase struct { + desc string + permissionSeed []*accesscontrol.Permission + wantPermissions []*accesscontrol.Permission + } + testCases := []migrationTestCase{ + { + desc: "empty perms", + permissionSeed: []*accesscontrol.Permission{}, + wantPermissions: []*accesscontrol.Permission{}, + }, + { + desc: "no permissions with alerting instance read action", + permissionSeed: []*accesscontrol.Permission{ + { + RoleID: 1, + Action: accesscontrol.ActionAlertingInstancesExternalRead, + Scope: datasources.ScopeAll, + Kind: "datasources", + Attribute: "*", + Created: now, + Updated: now, + }, + }, + wantPermissions: []*accesscontrol.Permission{ + { + RoleID: 1, + Action: accesscontrol.ActionAlertingInstancesExternalRead, + Scope: datasources.ScopeAll, + Kind: "datasources", + Attribute: "*", + }, + }, + }, + { + desc: "has permission with alerting instance read action and folder scope", + permissionSeed: []*accesscontrol.Permission{ + { + RoleID: 1, + Action: accesscontrol.ActionAlertingInstanceRead, + Scope: dashboards.ScopeFoldersAll, + Kind: "folders", + Attribute: "uid", + Identifier: "*", + Created: now, + Updated: now, + }, + }, + wantPermissions: []*accesscontrol.Permission{ + { + RoleID: 1, + Action: accesscontrol.ActionAlertingInstanceRead, + }, + }, + }, + { + desc: "has permission with alerting instance read action and no scope", + permissionSeed: []*accesscontrol.Permission{ + { + RoleID: 1, + Action: accesscontrol.ActionAlertingInstanceRead, + Created: now, + Updated: now, + }, + }, + wantPermissions: []*accesscontrol.Permission{ + { + RoleID: 1, + Action: accesscontrol.ActionAlertingInstanceRead, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + // Remove migration and permissions + _, errDeleteMig := x.Exec(`DELETE FROM migration_log WHERE migration_id = ?`, acmig.AlertingScopeRemovalMigrationID) + require.NoError(t, errDeleteMig) + _, errDeletePerms := x.Exec(`DELETE FROM permission`) + require.NoError(t, errDeletePerms) + + // seed DB with permissions + if len(tc.permissionSeed) != 0 { + permissionsCount, err := x.Insert(tc.permissionSeed) + require.NoError(t, err) + require.Equal(t, int64(len(tc.permissionSeed)), permissionsCount) + } + + // Run RBAC action name migration + acmigrator := migrator.NewMigrator(x, &setting.Cfg{Logger: log.New("acmigration.test")}) + acmig.AddAlertingScopeRemovalMigration(acmigrator) + + errRunningMig := acmigrator.Start(false, 0) + require.NoError(t, errRunningMig) + + // Check permissions + resultingPermissions := []accesscontrol.Permission{} + err := x.Table("permission").Find(&resultingPermissions) + require.NoError(t, err) + + // verify got == want + assert.Equal(t, len(tc.wantPermissions), len(resultingPermissions)) + for _, wantPermission := range tc.wantPermissions { + foundMatch := false + for _, resultingPermission := range resultingPermissions { + if wantPermission.Action == resultingPermission.Action && + wantPermission.Scope == resultingPermission.Scope && + wantPermission.Kind == resultingPermission.Kind && + wantPermission.Attribute == resultingPermission.Attribute && + wantPermission.Identifier == resultingPermission.Identifier && + wantPermission.RoleID == resultingPermission.RoleID { + assert.NotEmpty(t, resultingPermission.Updated) + assert.NotEmpty(t, resultingPermission.Created) + foundMatch = true + continue + } + } + assert.True(t, foundMatch, fmt.Sprintf("there should be a permission with action %s in the DB after the migration", wantPermission.Action)) + } + }) + } +} diff --git a/pkg/services/sqlstore/migrations/accesscontrol/test/seed_assign_mig_test.go b/pkg/services/sqlstore/migrations/accesscontrol/test/seed_assign_mig_test.go new file mode 100644 index 0000000000000..e95c5990854f7 --- /dev/null +++ b/pkg/services/sqlstore/migrations/accesscontrol/test/seed_assign_mig_test.go @@ -0,0 +1,79 @@ +package test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log" + acmig "github.com/grafana/grafana/pkg/services/sqlstore/migrations/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" +) + +func TestPreventOnCallAccessSeed(t *testing.T) { + // Run initial migration to have a working DB + x := setupTestDB(t) + + type SeedAssignment struct { + BuiltinRole, Action, Scope, Origin string + } + + want := []SeedAssignment{ + {BuiltinRole: "Admin", Action: "plugins.app:access", Scope: "plugins:id:grafana-oncall-app", Origin: "grafana-oncall-app"}, + {BuiltinRole: "Editor", Action: "plugins.app:access", Scope: "plugins:id:grafana-oncall-app", Origin: "grafana-oncall-app"}, + {BuiltinRole: "Viewer", Action: "plugins.app:access", Scope: "plugins:id:grafana-oncall-app", Origin: "grafana-oncall-app"}, + {BuiltinRole: "Grafana Admin", Action: "plugins.app:access", Scope: "plugins:id:grafana-oncall-app", Origin: "grafana-oncall-app"}, + } + + type testCase struct { + desc string + init []SeedAssignment + want []SeedAssignment + } + tt := []testCase{ + { + desc: "fresh table skip migration", + want: []SeedAssignment{}, + }, + { + desc: "seeded with an OnCall access already", + init: []SeedAssignment{ + {BuiltinRole: "Admin", Action: "plugins.app:access", Scope: "plugins:id:grafana-oncall-app", Origin: "grafana-oncall-app"}, + }, + want: want, + }, + { + desc: "seeded without any OnCall access", + init: []SeedAssignment{{BuiltinRole: "Admin", Action: "plugins.app:access", Scope: "plugins:id:*"}}, + want: append(want, SeedAssignment{BuiltinRole: "Admin", Action: "plugins.app:access", Scope: "plugins:id:*"}), + }, + } + + for _, tc := range tt { + t.Run(tc.desc, func(t *testing.T) { + // Remove migration + _, errDeleteMig := x.Exec(`DELETE FROM migration_log WHERE migration_id LIKE ?`, acmig.PreventSeedingOnCallAccessID+"%") + require.NoError(t, errDeleteMig) + _, errDeleteAssigns := x.Exec(`DELETE FROM seed_assignment`) + require.NoError(t, errDeleteAssigns) + + if len(tc.init) > 0 { + _, errInsertAssign := x.Table("seed_assignment").InsertMulti(tc.init) + require.NoError(t, errInsertAssign) + } + + // Run accesscontrol migration + acmigrator := migrator.NewMigrator(x, &setting.Cfg{Logger: log.New("acmigration.test")}) + acmigrator.AddMigration(acmig.PreventSeedingOnCallAccessID, &acmig.SeedAssignmentOnCallAccessMigrator{}) + + errRunningMig := acmigrator.Start(false, 0) + require.NoError(t, errRunningMig) + + got := []SeedAssignment{} + errFind := x.Table("seed_assignment").Find(&got) + require.NoError(t, errFind) + require.ElementsMatch(t, tc.want, got) + }) + } +} diff --git a/pkg/services/sqlstore/migrations/cloud_migrations.go b/pkg/services/sqlstore/migrations/cloud_migrations.go new file mode 100644 index 0000000000000..bcdb8f36dc33f --- /dev/null +++ b/pkg/services/sqlstore/migrations/cloud_migrations.go @@ -0,0 +1,32 @@ +package migrations + +import ( + . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +func addCloudMigrationsMigrations(mg *Migrator) { + migrationTable := Table{ + Name: "cloud_migration", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "auth_token", Type: DB_Text, Nullable: true}, // encrypted + {Name: "stack", Type: DB_Text}, + {Name: "created", Type: DB_DateTime, Nullable: false}, + {Name: "updated", Type: DB_DateTime, Nullable: false}, + }, + } + migrationRunTable := Table{ + Name: "cloud_migration_run", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "cloud_migration_uid", Type: DB_NVarchar, Length: 40, Nullable: true}, // get from the cloud service + {Name: "result", Type: DB_Text, Nullable: false}, + {Name: "created", Type: DB_DateTime, Nullable: false}, + {Name: "updated", Type: DB_DateTime, Nullable: false}, + {Name: "finished", Type: DB_DateTime, Nullable: true}, + }, + } + + mg.AddMigration("create cloud_migration table v1", NewAddTableMigration(migrationTable)) + mg.AddMigration("create cloud_migration_run table v1", NewAddTableMigration(migrationRunTable)) +} diff --git a/pkg/services/sqlstore/migrations/external_alertmanagers.go b/pkg/services/sqlstore/migrations/external_alertmanagers.go index 78d91192a9d69..067feee3a178a 100644 --- a/pkg/services/sqlstore/migrations/external_alertmanagers.go +++ b/pkg/services/sqlstore/migrations/external_alertmanagers.go @@ -77,7 +77,7 @@ func (e externalAlertmanagerToDatasources) Exec(sess *xorm.Session, mg *migrator ds.BasicAuth = true ds.BasicAuthUser = u.User.Username() if password, ok := u.User.Password(); ok { - ds.SecureJsonData = getEncryptedJsonData(map[string]string{ + ds.SecureJsonData = getEncryptedJsonData(mg.Cfg, map[string]string{ "basicAuthPassword": password, }, log.New("securejsondata")) } @@ -132,10 +132,10 @@ func generateNewDatasourceUid(sess *xorm.Session, orgId int64) (string, error) { type secureJsonData map[string][]byte // getEncryptedJsonData returns map where all keys are encrypted. -func getEncryptedJsonData(sjd map[string]string, log log.Logger) secureJsonData { +func getEncryptedJsonData(cfg *setting.Cfg, sjd map[string]string, log log.Logger) secureJsonData { encrypted := make(secureJsonData) for key, data := range sjd { - encryptedData, err := util.Encrypt([]byte(data), setting.SecretKey) + encryptedData, err := util.Encrypt([]byte(data), cfg.SecretKey) if err != nil { log.Error(err.Error()) os.Exit(1) diff --git a/pkg/services/sqlstore/migrations/folder_mig.go b/pkg/services/sqlstore/migrations/folder_mig.go index f6ddbd5bd519f..b107afea97290 100644 --- a/pkg/services/sqlstore/migrations/folder_mig.go +++ b/pkg/services/sqlstore/migrations/folder_mig.go @@ -55,6 +55,31 @@ func addFolderMigrations(mg *migrator.Migrator) { DELETE FROM folder WHERE NOT EXISTS (SELECT 1 FROM dashboard WHERE dashboard.uid = folder.uid AND dashboard.org_id = folder.org_id AND dashboard.is_folder = true) `)) + + mg.AddMigration("Remove unique index UQE_folder_uid_org_id", migrator.NewDropIndexMigration(folderv1(), &migrator.Index{ + Type: migrator.UniqueIndex, + Cols: []string{"uid", "org_id"}, + })) + + mg.AddMigration("Add unique index UQE_folder_org_id_uid", migrator.NewAddIndexMigration(folderv1(), &migrator.Index{ + Type: migrator.UniqueIndex, + Cols: []string{"org_id", "uid"}, + })) + + mg.AddMigration("Remove unique index UQE_folder_title_parent_uid_org_id", migrator.NewDropIndexMigration(folderv1(), &migrator.Index{ + Type: migrator.UniqueIndex, + Cols: []string{"title", "parent_uid", "org_id"}, + })) + + mg.AddMigration("Add unique index UQE_folder_org_id_parent_uid_title", migrator.NewAddIndexMigration(folderv1(), &migrator.Index{ + Type: migrator.UniqueIndex, + Cols: []string{"org_id", "parent_uid", "title"}, + })) + + // No need to introduce IDX_folder_org_id_parent_uid because is covered by UQE_folder_org_id_parent_uid_title + mg.AddMigration("Remove index IDX_folder_parent_uid_org_id", migrator.NewDropIndexMigration(folderv1(), &migrator.Index{ + Cols: []string{"parent_uid", "org_id"}, + })) } func folderv1() migrator.Table { diff --git a/pkg/services/sqlstore/migrations/kv_store_mig.go b/pkg/services/sqlstore/migrations/kv_store_mig.go index 3bdd39335a20f..252daf0a13452 100644 --- a/pkg/services/sqlstore/migrations/kv_store_mig.go +++ b/pkg/services/sqlstore/migrations/kv_store_mig.go @@ -25,3 +25,10 @@ func addKVStoreMigrations(mg *Migrator) { mg.AddMigration("add index kv_store.org_id-namespace-key", NewAddIndexMigration(kvStoreV1, kvStoreV1.Indices[0])) } + +// addKVStoreMySQLValueTypeLongTextMigration adds a migration to change the column type of kv_store.value to LONGTEXT for +// MySQL to be more inline size-wise with PSQL (TEXT) and SQLite. +func addKVStoreMySQLValueTypeLongTextMigration(mg *Migrator) { + mg.AddMigration("alter kv_store.value to longtext", NewRawSQLMigration(""). + Mysql("ALTER TABLE kv_store MODIFY value LONGTEXT NOT NULL;")) +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 50968f6c37894..a9e1de52193b5 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -5,7 +5,6 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore/migrations/accesscontrol" "github.com/grafana/grafana/pkg/services/sqlstore/migrations/anonservice" - "github.com/grafana/grafana/pkg/services/sqlstore/migrations/oauthserver" "github.com/grafana/grafana/pkg/services/sqlstore/migrations/signingkeys" "github.com/grafana/grafana/pkg/services/sqlstore/migrations/ssosettings" "github.com/grafana/grafana/pkg/services/sqlstore/migrations/ualert" @@ -21,13 +20,14 @@ import ( // specifically added type OSSMigrations struct { + features featuremgmt.FeatureToggles } -func ProvideOSSMigrations() *OSSMigrations { - return &OSSMigrations{} +func ProvideOSSMigrations(features featuremgmt.FeatureToggles) *OSSMigrations { + return &OSSMigrations{features} } -func (*OSSMigrations) AddMigration(mg *Migrator) { +func (oss *OSSMigrations) AddMigration(mg *Migrator) { mg.AddCreateMigration() addUserMigrations(mg) addTempUserMigrations(mg) @@ -94,13 +94,6 @@ func (*OSSMigrations) AddMigration(mg *Migrator) { AddExternalAlertmanagerToDatasourceMigration(mg) addFolderMigrations(mg) - // nolint:staticcheck - if mg.Cfg != nil && mg.Cfg.IsFeatureToggleEnabled != nil { - // nolint:staticcheck - if mg.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagExternalServiceAuth) { - oauthserver.AddMigration(mg) - } - } anonservice.AddMigration(mg) signingkeys.AddMigration(mg) @@ -113,6 +106,19 @@ func (*OSSMigrations) AddMigration(mg *Migrator) { ssosettings.AddMigration(mg) ualert.CreateOrgMigratedKVStoreEntries(mg) + + // https://github.com/grafana/identity-access-team/issues/546: tracks removal of the feature toggle from the annotation permission migration + if oss.features != nil && oss.features.IsEnabledGlobally(featuremgmt.FlagAnnotationPermissionUpdate) { + accesscontrol.AddManagedDashboardAnnotationActionsMigration(mg) + } + + addCloudMigrationsMigrations(mg) + + addKVStoreMySQLValueTypeLongTextMigration(mg) + + ualert.AddRuleNotificationSettingsColumns(mg) + + accesscontrol.AddAlertingScopeRemovalMigration(mg) } func addStarMigrations(mg *Migrator) { diff --git a/pkg/services/sqlstore/migrations/migrations_test.go b/pkg/services/sqlstore/migrations/migrations_test.go index 4479e8c95bc74..dc3be715b9eab 100644 --- a/pkg/services/sqlstore/migrations/migrations_test.go +++ b/pkg/services/sqlstore/migrations/migrations_test.go @@ -3,7 +3,6 @@ package migrations import ( "errors" "fmt" - "os" "strings" "sync" "sync/atomic" @@ -21,13 +20,23 @@ import ( ) func TestMigrations(t *testing.T) { - testDB := sqlutil.SQLite3TestDB() + testDB, err := sqlutil.GetTestDB(SQLite) + require.NoError(t, err) + + t.Cleanup(testDB.Cleanup) + const query = `select count(*) as count from migration_log` result := struct{ Count int }{} x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) + t.Cleanup(func() { + if err := x.Close(); err != nil { + fmt.Printf("failed to close xorm engine: %v", err) + } + }) + err = NewDialect(x.DriverName()).CleanDB(x) require.NoError(t, err) @@ -61,16 +70,25 @@ func TestMigrations(t *testing.T) { } func TestMigrationLock(t *testing.T) { - dbType := getDBType() + dbType := sqlutil.GetTestDBType() if dbType == SQLite { t.Skip() } - testDB := getTestDB(t, dbType) + testDB, err := sqlutil.GetTestDB(dbType) + require.NoError(t, err) + + t.Cleanup(testDB.Cleanup) x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) + t.Cleanup(func() { + if err := x.Close(); err != nil { + fmt.Printf("failed to close xorm engine: %v", err) + } + }) + dialect := NewDialect(x.DriverName()) sess := x.NewSession() @@ -157,17 +175,28 @@ func TestMigrationLock(t *testing.T) { } func TestMigratorLocking(t *testing.T) { - dbType := getDBType() - testDB := getTestDB(t, dbType) + dbType := sqlutil.GetTestDBType() + // skip for SQLite for now since it occasionally fails for not clear reason // anyway starting migrations concurretly for the same migrator is impossible use case if dbType == SQLite { t.Skip() } + testDB, err := sqlutil.GetTestDB(dbType) + require.NoError(t, err) + + t.Cleanup(testDB.Cleanup) + x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) + t.Cleanup(func() { + if err := x.Close(); err != nil { + fmt.Printf("failed to close xorm engine: %v", err) + } + }) + err = NewDialect(x.DriverName()).CleanDB(x) require.NoError(t, err) @@ -194,17 +223,27 @@ func TestMigratorLocking(t *testing.T) { } func TestDatabaseLocking(t *testing.T) { - dbType := getDBType() + dbType := sqlutil.GetTestDBType() + // skip for SQLite since there is no database locking (only migrator locking) if dbType == SQLite { t.Skip() } - testDB := getTestDB(t, dbType) + testDB, err := sqlutil.GetTestDB(dbType) + require.NoError(t, err) + + t.Cleanup(testDB.Cleanup) x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) + t.Cleanup(func() { + if err := x.Close(); err != nil { + fmt.Printf("failed to close xorm engine: %v", err) + } + }) + err = NewDialect(x.DriverName()).CleanDB(x) require.NoError(t, err) @@ -280,37 +319,6 @@ func checkStepsAndDatabaseMatch(t *testing.T, mg *Migrator, expected []string) { require.Failf(t, "the number of migrations does not match log in database", msg) } -func getDBType() string { - dbType := SQLite - - // environment variable present for test db? - if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { - dbType = db - } - return dbType -} - -func getTestDB(t *testing.T, dbType string) sqlutil.TestDB { - switch dbType { - case "mysql": - return sqlutil.MySQLTestDB() - case "postgres": - return sqlutil.PostgresTestDB() - default: - f, err := os.CreateTemp(".", "grafana-test-db-") - require.NoError(t, err) - t.Cleanup(func() { - err := os.Remove(f.Name()) - require.NoError(t, err) - }) - - return sqlutil.TestDB{ - DriverName: "sqlite3", - ConnStr: f.Name(), - } - } -} - func replaceDBName(t *testing.T, connStr, dbType string) string { switch dbType { case "mysql": diff --git a/pkg/services/sqlstore/migrations/oauthserver/migrations.go b/pkg/services/sqlstore/migrations/oauthserver/migrations.go deleted file mode 100644 index b47590e7b259a..0000000000000 --- a/pkg/services/sqlstore/migrations/oauthserver/migrations.go +++ /dev/null @@ -1,52 +0,0 @@ -package oauthserver - -import "github.com/grafana/grafana/pkg/services/sqlstore/migrator" - -func AddMigration(mg *migrator.Migrator) { - impersonatePermissionsTable := migrator.Table{ - Name: "oauth_impersonate_permission", - Columns: []*migrator.Column{ - {Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, - {Name: "client_id", Type: migrator.DB_Varchar, Length: 190, Nullable: false}, - {Name: "action", Type: migrator.DB_Varchar, Length: 190, Nullable: false}, - {Name: "scope", Type: migrator.DB_Varchar, Length: 190, Nullable: true}, - }, - Indices: []*migrator.Index{ - {Cols: []string{"client_id", "action", "scope"}, Type: migrator.UniqueIndex}, - }, - } - - clientTable := migrator.Table{ - Name: "oauth_client", - Columns: []*migrator.Column{ - {Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, - {Name: "name", Type: migrator.DB_Varchar, Length: 190, Nullable: true}, - {Name: "client_id", Type: migrator.DB_Varchar, Length: 190, Nullable: false}, - {Name: "secret", Type: migrator.DB_Varchar, Length: 190, Nullable: false}, - {Name: "grant_types", Type: migrator.DB_Text, Nullable: true}, - {Name: "audiences", Type: migrator.DB_Varchar, Length: 190, Nullable: true}, - {Name: "service_account_id", Type: migrator.DB_BigInt, Nullable: true}, - {Name: "public_pem", Type: migrator.DB_Text, Nullable: true}, - {Name: "redirect_uri", Type: migrator.DB_Varchar, Length: 190, Nullable: true}, - }, - Indices: []*migrator.Index{ - {Cols: []string{"client_id"}, Type: migrator.UniqueIndex}, - {Cols: []string{"client_id", "service_account_id"}, Type: migrator.UniqueIndex}, - {Cols: []string{"name"}, Type: migrator.UniqueIndex}, - }, - } - - // Impersonate Permission - mg.AddMigration("create impersonate permissions table", migrator.NewAddTableMigration(impersonatePermissionsTable)) - - //------- indexes ------------------ - mg.AddMigration("add unique index client_id action scope", migrator.NewAddIndexMigration(impersonatePermissionsTable, impersonatePermissionsTable.Indices[0])) - - // Client - mg.AddMigration("create client table", migrator.NewAddTableMigration(clientTable)) - - //------- indexes ------------------ - mg.AddMigration("add unique index client_id", migrator.NewAddIndexMigration(clientTable, clientTable.Indices[0])) - mg.AddMigration("add unique index client_id service_account_id", migrator.NewAddIndexMigration(clientTable, clientTable.Indices[1])) - mg.AddMigration("add unique index name", migrator.NewAddIndexMigration(clientTable, clientTable.Indices[2])) -} diff --git a/pkg/services/sqlstore/migrations/ualert/rule_notification_settings_mig.go b/pkg/services/sqlstore/migrations/ualert/rule_notification_settings_mig.go new file mode 100644 index 0000000000000..7c5eb2a6ebbda --- /dev/null +++ b/pkg/services/sqlstore/migrations/ualert/rule_notification_settings_mig.go @@ -0,0 +1,20 @@ +package ualert + +import ( + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +// AddRuleNotificationSettingsColumns creates a column for notification settings in the alert_rule and alert_rule_version tables. +func AddRuleNotificationSettingsColumns(mg *migrator.Migrator) { + mg.AddMigration("add notification_settings column to alert_rule table", migrator.NewAddColumnMigration(migrator.Table{Name: "alert_rule"}, &migrator.Column{ + Name: "notification_settings", + Type: migrator.DB_Text, + Nullable: true, + })) + + mg.AddMigration("add notification_settings column to alert_rule_version table", migrator.NewAddColumnMigration(migrator.Table{Name: "alert_rule_version"}, &migrator.Column{ + Name: "notification_settings", + Type: migrator.DB_Text, + Nullable: true, + })) +} diff --git a/pkg/services/sqlstore/migrations/ualert/tables.go b/pkg/services/sqlstore/migrations/ualert/tables.go index b6babe3409d4d..1ef0587efe384 100644 --- a/pkg/services/sqlstore/migrations/ualert/tables.go +++ b/pkg/services/sqlstore/migrations/ualert/tables.go @@ -189,6 +189,10 @@ func alertInstanceMigration(mg *migrator.Migrator) { migrator.NewAddColumnMigration(alertInstance, &migrator.Column{ Name: "current_reason", Type: migrator.DB_NVarchar, Length: DefaultFieldMaxLength, Nullable: true, })) + + mg.AddMigration("add result_fingerprint column to alert_instance", migrator.NewAddColumnMigration(alertInstance, &migrator.Column{ + Name: "result_fingerprint", Type: migrator.DB_NVarchar, Length: 16, Nullable: true, + })) } func addAlertRuleMigrations(mg *migrator.Migrator, defaultIntervalSeconds int64) { diff --git a/pkg/services/sqlstore/migrations/user/service_account_multiple_org_login_migrator.go b/pkg/services/sqlstore/migrations/user/service_account_multiple_org_login_migrator.go new file mode 100644 index 0000000000000..064985c0f73b6 --- /dev/null +++ b/pkg/services/sqlstore/migrations/user/service_account_multiple_org_login_migrator.go @@ -0,0 +1,68 @@ +package user + +import ( + "fmt" + + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "xorm.io/xorm" +) + +const ( + AllowSameLoginCrossOrgs = "update login field with orgid to allow for multiple service accounts with same name across orgs" +) + +// Service accounts login were not unique per org. this migration is part of making it unique per org +// to be able to create service accounts that are unique per org +func AddServiceAccountsAllowSameLoginCrossOrgs(mg *migrator.Migrator) { + mg.AddMigration(AllowSameLoginCrossOrgs, &ServiceAccountsSameLoginCrossOrgs{}) +} + +var _ migrator.CodeMigration = new(ServiceAccountsSameLoginCrossOrgs) + +type ServiceAccountsSameLoginCrossOrgs struct { + sess *xorm.Session + dialect migrator.Dialect + migrator.MigrationBase +} + +func (p *ServiceAccountsSameLoginCrossOrgs) SQL(dialect migrator.Dialect) string { + return "code migration" +} + +func (p *ServiceAccountsSameLoginCrossOrgs) Exec(sess *xorm.Session, mg *migrator.Migrator) error { + p.sess = sess + p.dialect = mg.Dialect + var err error + switch p.dialect.DriverName() { + case migrator.Postgres: + _, err = p.sess.Exec(`UPDATE "user" + SET login = 'sa-' || org_id::text || '-' || + CASE + WHEN login LIKE 'sa-%' THEN SUBSTRING(login FROM 4) + ELSE login + END + WHERE login IS NOT NULL AND is_service_account = true;`, + ) + case migrator.MySQL: + _, err = p.sess.Exec(`UPDATE user + SET login = CONCAT('sa-', CAST(org_id AS CHAR), '-', + CASE + WHEN login LIKE 'sa-%' THEN SUBSTRING(login, 4) + ELSE login + END) + WHERE login IS NOT NULL AND is_service_account = 1;`, + ) + case migrator.SQLite: + _, err = p.sess.Exec(`Update ` + p.dialect.Quote("user") + ` + SET login = 'sa-' || CAST(org_id AS TEXT) || '-' || + CASE + WHEN SUBSTR(login, 1, 3) = 'sa-' THEN SUBSTR(login, 4) + ELSE login + END + WHERE login IS NOT NULL AND is_service_account = 1;`, + ) + default: + return fmt.Errorf("dialect not supported: %s", p.dialect) + } + return err +} diff --git a/pkg/services/sqlstore/migrations/user/test/service_account_test.go b/pkg/services/sqlstore/migrations/user/test/service_account_test.go new file mode 100644 index 0000000000000..eab2f5ac4446c --- /dev/null +++ b/pkg/services/sqlstore/migrations/user/test/service_account_test.go @@ -0,0 +1,247 @@ +package test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log" + usermig "github.com/grafana/grafana/pkg/services/sqlstore/migrations/user" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" +) + +func TestIntegrationServiceAccountMigration(t *testing.T) { + // Run initial migration to have a working DB + x := setupTestDB(t) + + orgId := 1 + + type migrationTestCase struct { + desc string + serviceAccounts []*user.User + wantServiceAccounts []*user.User + } + testCases := []migrationTestCase{ + { + desc: "basic case", + serviceAccounts: []*user.User{ + { + ID: 1, + UID: "u1", + Name: "sa-basic", + Login: "sa-basic", + Email: "sa-basic", + OrgID: 1, + Created: now, + Updated: now, + IsServiceAccount: true, + }, + { + ID: 2, + UID: "u2", + Name: "sa-basic-admin", + Login: "sa-basic-admin", + Email: "sa-basic-admin", + OrgID: 1, + Created: now, + Updated: now, + IsServiceAccount: true, + }, + }, + wantServiceAccounts: []*user.User{ + { + ID: 1, + Login: fmt.Sprintf("sa-%d-basic", orgId), + }, + { + ID: 2, + Login: fmt.Sprintf("sa-%d-basic-admin", orgId), + }, + }, + }, + { + desc: "should be able to handle multiple sa", + serviceAccounts: []*user.User{ + { + ID: 3, + UID: "u3", + Name: "sa-doan", + Login: "sa-doan", + Email: "sa-doan", + OrgID: 1, + Created: now, + Updated: now, + IsServiceAccount: true, + }, + { + ID: 4, + UID: "u4", + Name: "sa-admin-doan", + Login: "sa-admin-doan", + Email: "sa-admin-doan", + OrgID: 1, + Created: now, + Updated: now, + IsServiceAccount: true, + }, + }, + wantServiceAccounts: []*user.User{ + { + ID: 3, + Login: fmt.Sprintf("sa-%d-doan", orgId), + }, + { + ID: 4, + Login: fmt.Sprintf("sa-%d-admin-doan", orgId), + }, + }, + }, + { + desc: "duplicate logins across different orgs", + serviceAccounts: []*user.User{ + { + ID: 5, + UID: "u5", + Name: "sa-common", + Login: "sa-common@org1.com", + Email: "sa-common@org1.com", + OrgID: 1, + Created: now, + Updated: now, + IsServiceAccount: true, + }, + { + ID: 6, + UID: "u6", + Name: "sa-common", + Login: "sa-common@org2.com", + Email: "sa-common@org2.com", + OrgID: 2, + Created: now, + Updated: now, + IsServiceAccount: true, + }, + }, + wantServiceAccounts: []*user.User{ + { + ID: 5, + Login: "sa-1-common@org1.com", + }, + { + ID: 6, + Login: "sa-2-common@org2.com", + }, + }, + }, + { + desc: "pre-existing sa- prefix", + serviceAccounts: []*user.User{ + { + ID: 7, + UID: "u7", + Name: "sa-preexisting", + Login: "sa-preexisting", + Email: "sa-preexisting@org.com", + OrgID: 1, + Created: now, + Updated: now, + IsServiceAccount: true, + }, + { + ID: 8, + UID: "u8", + Name: "sa-sa-preexisting", + Login: "sa-sa-preexisting", + Email: "sa-sa-preexisting@org.com", + OrgID: 1, + Created: now, + Updated: now, + IsServiceAccount: true, + }, + }, + wantServiceAccounts: []*user.User{ + { + ID: 7, + Login: "sa-1-preexisting", + }, + { + ID: 8, + Login: "sa-1-sa-preexisting", // Ensuring only the first 'sa-' is handled + }, + }, + }, + { + desc: "extSrv accounts also renamed", + serviceAccounts: []*user.User{ + { + ID: 9, + UID: "u9", + Name: "sa-extsvc-slug", + Login: "sa-extsvc-slug", + Email: "sa-extsvc-slug@org.com", + OrgID: 1, + Created: now, + Updated: now, + IsServiceAccount: true, + }, + { + ID: 10, + UID: "u10", + Name: "sa-extsvc-slug2", + Login: "sa-extsvc-slug2", + Email: "sa-extsvc-slug2@org.com", + OrgID: 2, + Created: now, + Updated: now, + IsServiceAccount: true, + }, + }, + wantServiceAccounts: []*user.User{ + { + ID: 9, + Login: "sa-1-extsvc-slug", + }, + { + ID: 10, + Login: "sa-2-extsvc-slug2", + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + // Remove migration and permissions + _, errDeleteMig := x.Exec(`DELETE FROM migration_log WHERE migration_id = ?`, usermig.AllowSameLoginCrossOrgs) + require.NoError(t, errDeleteMig) + + // insert service accounts + serviceAccoutsCount, err := x.Insert(tc.serviceAccounts) + require.NoError(t, err) + require.Equal(t, int64(len(tc.serviceAccounts)), serviceAccoutsCount) + + // run the migration + usermigrator := migrator.NewMigrator(x, &setting.Cfg{Logger: log.New("usermigration.test")}) + usermig.AddServiceAccountsAllowSameLoginCrossOrgs(usermigrator) + errRunningMig := usermigrator.Start(false, 0) + require.NoError(t, errRunningMig) + + // Check service accounts + resultingServiceAccounts := []user.User{} + err = x.Table("user").Find(&resultingServiceAccounts) + require.NoError(t, err) + + for i := range tc.wantServiceAccounts { + for _, sa := range resultingServiceAccounts { + if sa.ID == tc.wantServiceAccounts[i].ID { + assert.Equal(t, tc.wantServiceAccounts[i].Login, sa.Login) + } + } + } + }) + } +} diff --git a/pkg/services/sqlstore/migrations/user/test/user_test.go b/pkg/services/sqlstore/migrations/user/test/user_test.go new file mode 100644 index 0000000000000..ab7b34e1287c7 --- /dev/null +++ b/pkg/services/sqlstore/migrations/user/test/user_test.go @@ -0,0 +1,55 @@ +package test + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gopkg.in/ini.v1" + "xorm.io/xorm" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/sqlstore/migrations" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" + "github.com/grafana/grafana/pkg/setting" +) + +// Setup users +var ( + now = time.Now() +) + +func setupTestDB(t *testing.T) *xorm.Engine { + t.Helper() + dbType := sqlutil.GetTestDBType() + testDB, err := sqlutil.GetTestDB(dbType) + require.NoError(t, err) + + t.Cleanup(testDB.Cleanup) + + x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) + require.NoError(t, err) + + t.Cleanup(func() { + if err := x.Close(); err != nil { + fmt.Printf("failed to close xorm engine: %v", err) + } + }) + + err = migrator.NewDialect(x.DriverName()).CleanDB(x) + require.NoError(t, err) + + mg := migrator.NewMigrator(x, &setting.Cfg{ + Logger: log.New("users.test"), + Raw: ini.Empty(), + }) + migrations := &migrations.OSSMigrations{} + migrations.AddMigration(mg) + + err = mg.Start(false, 0) + require.NoError(t, err) + + return x +} diff --git a/pkg/services/sqlstore/migrations/user_mig.go b/pkg/services/sqlstore/migrations/user_mig.go index 6edf5161f47ba..77ed12e5876ac 100644 --- a/pkg/services/sqlstore/migrations/user_mig.go +++ b/pkg/services/sqlstore/migrations/user_mig.go @@ -5,6 +5,7 @@ import ( "xorm.io/xorm" + "github.com/grafana/grafana/pkg/services/sqlstore/migrations/user" . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/util" ) @@ -140,6 +141,23 @@ func addUserMigrations(mg *Migrator) { SQLite(migSQLITEisServiceAccountNullable). Postgres("ALTER TABLE `user` ALTER COLUMN is_service_account DROP NOT NULL;"). Mysql("ALTER TABLE user MODIFY is_service_account BOOLEAN DEFAULT 0;")) + + mg.AddMigration("Add uid column to user", NewAddColumnMigration(userV2, &Column{ + Name: "uid", Type: DB_NVarchar, Length: 40, Nullable: true, + })) + + mg.AddMigration("Update uid column values for users", NewRawSQLMigration(""). + SQLite("UPDATE user SET uid=printf('u%09d',id) WHERE uid IS NULL;"). + Postgres("UPDATE `user` SET uid='u' || lpad('' || id::text,9,'0') WHERE uid IS NULL;"). + Mysql("UPDATE user SET uid=concat('u',lpad(id,9,'0')) WHERE uid IS NULL;")) + + mg.AddMigration("Add unique index user_uid", NewAddIndexMigration(userV2, &Index{ + Cols: []string{"uid"}, Type: UniqueIndex, + })) + + // Service accounts login were not unique per org. this migration is part of making it unique per org + // to be able to create service accounts that are unique per org + mg.AddMigration(user.AllowSameLoginCrossOrgs, &user.ServiceAccountsSameLoginCrossOrgs{}) } const migSQLITEisServiceAccountNullable = `ALTER TABLE user ADD COLUMN tmp_service_account BOOLEAN DEFAULT 0; diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go index 183b619de8339..5aae742b6ca99 100644 --- a/pkg/services/sqlstore/migrator/dialect.go +++ b/pkg/services/sqlstore/migrator/dialect.go @@ -1,10 +1,12 @@ package migrator import ( + "context" "fmt" "strconv" "strings" + "github.com/grafana/grafana/pkg/services/sqlstore/session" "golang.org/x/exp/slices" "xorm.io/xorm" ) @@ -82,6 +84,17 @@ type Dialect interface { // column names to values to use in the where clause. // It returns a query string and a slice of parameters that can be executed against the database. UpdateQuery(tableName string, row map[string]any, where map[string]any) (string, []any, error) + // Insert accepts a table name and a map of column names to insert. + // The insert is executed as part of the provided session. + Insert(ctx context.Context, tx *session.SessionTx, tableName string, row map[string]any) error + // Update accepts a table name, a map of column names to values to update, and a map of + // column names to values to use in the where clause. + // The update is executed as part of the provided session. + Update(ctx context.Context, tx *session.SessionTx, tableName string, row map[string]any, where map[string]any) error + // Concat returns the sql statement for concating multiple strings + // Implementations are not expected to quote the arguments + // therefore any callers should take care to quote arguments as necessary + Concat(...string) string } type LockCfg struct { @@ -368,7 +381,7 @@ func (b *BaseDialect) InsertQuery(tableName string, row map[string]any) (string, for col := range row { keys = append(keys, col) } - slices.Sort[string](keys) + slices.Sort(keys) // build query and values for _, col := range keys { @@ -398,7 +411,7 @@ func (b *BaseDialect) UpdateQuery(tableName string, row map[string]any, where ma for col := range row { keys = append(keys, col) } - slices.Sort[string](keys) + slices.Sort(keys) // build update query and values for _, col := range keys { @@ -411,7 +424,7 @@ func (b *BaseDialect) UpdateQuery(tableName string, row map[string]any, where ma for col := range where { keys = append(keys, col) } - slices.Sort[string](keys) + slices.Sort(keys) // build where clause and values for _, col := range keys { @@ -421,3 +434,27 @@ func (b *BaseDialect) UpdateQuery(tableName string, row map[string]any, where ma return fmt.Sprintf("UPDATE %s SET %s WHERE %s", b.dialect.Quote(tableName), strings.Join(cols, ", "), strings.Join(whereCols, " AND ")), vals, nil } + +func (b *BaseDialect) Insert(ctx context.Context, tx *session.SessionTx, tableName string, row map[string]any) error { + query, args, err := b.InsertQuery(tableName, row) + if err != nil { + return err + } + + _, err = tx.Exec(ctx, query, args...) + return err +} + +func (b *BaseDialect) Update(ctx context.Context, tx *session.SessionTx, tableName string, row map[string]any, where map[string]any) error { + query, args, err := b.UpdateQuery(tableName, row, where) + if err != nil { + return err + } + + _, err = tx.Exec(ctx, query, args...) + return err +} + +func (b *BaseDialect) Concat(strs ...string) string { + return fmt.Sprintf("CONCAT(%s)", strings.Join(strs, ", ")) +} diff --git a/pkg/services/sqlstore/migrator/migrator.go b/pkg/services/sqlstore/migrator/migrator.go index 30e2fc74142da..c58da4729b233 100644 --- a/pkg/services/sqlstore/migrator/migrator.go +++ b/pkg/services/sqlstore/migrator/migrator.go @@ -1,13 +1,14 @@ package migrator import ( + "errors" "fmt" "time" _ "github.com/go-sql-driver/mysql" "github.com/golang-migrate/migrate/v4/database" _ "github.com/lib/pq" - _ "github.com/mattn/go-sqlite3" + "github.com/mattn/go-sqlite3" "go.uber.org/atomic" "xorm.io/xorm" @@ -208,6 +209,13 @@ func (mg *Migrator) run() (err error) { err := mg.InTransaction(func(sess *xorm.Session) error { err := mg.exec(m, sess) + // if we get an sqlite busy/locked error, sleep 100ms and try again + if errors.Is(err, sqlite3.ErrLocked) || errors.Is(err, sqlite3.ErrBusy) { + mg.Logger.Debug("Database locked, sleeping then retrying", "error", err, "sql", sql) + time.Sleep(100 * time.Millisecond) + err = mg.exec(m, sess) + } + if err != nil { mg.Logger.Error("Exec failed", "error", err, "sql", sql) record.Error = err.Error() diff --git a/pkg/services/sqlstore/migrator/sqlite_dialect.go b/pkg/services/sqlstore/migrator/sqlite_dialect.go index 17bb097e19971..c2979ea1925d4 100644 --- a/pkg/services/sqlstore/migrator/sqlite_dialect.go +++ b/pkg/services/sqlstore/migrator/sqlite_dialect.go @@ -209,3 +209,7 @@ func (db *SQLite3) UpsertMultipleSQL(tableName string, keyCols, updateCols []str ) return s, nil } + +func (db *SQLite3) Concat(strs ...string) string { + return strings.Join(strs, " || ") +} diff --git a/pkg/services/sqlstore/permissions/dashboard.go b/pkg/services/sqlstore/permissions/dashboard.go index 3c58e6a80e8e3..a91f2ff9103a7 100644 --- a/pkg/services/sqlstore/permissions/dashboard.go +++ b/pkg/services/sqlstore/permissions/dashboard.go @@ -42,7 +42,7 @@ type PermissionsFilter interface { Where() (string, []any) buildClauses() - nestedFoldersSelectors(permSelector string, permSelectorArgs []any, leftTableCol string, rightTableCol string, orgID int64) (string, []any) + nestedFoldersSelectors(permSelector string, permSelectorArgs []any, leftTable string, Col string, rightTableCol string, orgID int64) (string, []any) } // NewAccessControlDashboardPermissionFilter creates a new AccessControlDashboardPermissionFilter that is configured with specific actions calculated based on the dashboardaccess.PermissionType and query type @@ -148,7 +148,7 @@ func (f *accessControlDashboardPermissionFilter) buildClauses() { if len(toCheck) > 0 { if !useSelfContainedPermissions { - builder.WriteString("(dashboard.uid IN (SELECT substr(scope, 16) FROM permission WHERE scope LIKE 'dashboards:uid:%'") + builder.WriteString("(dashboard.uid IN (SELECT identifier FROM permission WHERE kind = 'dashboards' AND attribute = 'uid'") builder.WriteString(rolesFilter) args = append(args, params...) @@ -156,7 +156,7 @@ func (f *accessControlDashboardPermissionFilter) buildClauses() { builder.WriteString(" AND action = ?") args = append(args, toCheck[0]) } else { - builder.WriteString(" AND action IN (?" + strings.Repeat(", ?", len(toCheck)-1) + ") GROUP BY role_id, scope HAVING COUNT(action) = ?") + builder.WriteString(" AND action IN (?" + strings.Repeat(", ?", len(toCheck)-1) + ") GROUP BY role_id, scope, identifier HAVING COUNT(action) = ?") args = append(args, toCheck...) args = append(args, len(toCheck)) } @@ -178,7 +178,7 @@ func (f *accessControlDashboardPermissionFilter) buildClauses() { builder.WriteString(" OR ") if !useSelfContainedPermissions { - permSelector.WriteString("(SELECT substr(scope, 13) FROM permission WHERE scope LIKE 'folders:uid:%' ") + permSelector.WriteString("(SELECT identifier FROM permission WHERE kind = 'folders' AND attribute = 'uid'") permSelector.WriteString(rolesFilter) permSelectorArgs = append(permSelectorArgs, params...) @@ -186,7 +186,7 @@ func (f *accessControlDashboardPermissionFilter) buildClauses() { permSelector.WriteString(" AND action = ?") permSelectorArgs = append(permSelectorArgs, toCheck[0]) } else { - permSelector.WriteString(" AND action IN (?" + strings.Repeat(", ?", len(toCheck)-1) + ") GROUP BY role_id, scope HAVING COUNT(action) = ?") + permSelector.WriteString(" AND action IN (?" + strings.Repeat(", ?", len(toCheck)-1) + ") GROUP BY role_id, scope, identifier HAVING COUNT(action) = ?") permSelectorArgs = append(permSelectorArgs, toCheck...) permSelectorArgs = append(permSelectorArgs, len(toCheck)) } @@ -211,11 +211,11 @@ func (f *accessControlDashboardPermissionFilter) buildClauses() { case true: builder.WriteString("(dashboard.folder_id IN (SELECT d.id FROM dashboard as d ") recQueryName := fmt.Sprintf("RecQry%d", len(f.recQueries)) - f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs) + f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs, orgID) builder.WriteString(fmt.Sprintf("WHERE d.org_id = ? AND d.uid IN (SELECT uid FROM %s)", recQueryName)) args = append(args, orgID) default: - nestedFoldersSelectors, nestedFoldersArgs := f.nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "dashboard.folder_id", "d.id", orgID) + nestedFoldersSelectors, nestedFoldersArgs := f.nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "dashboard", "folder_id", "d.id", orgID) builder.WriteRune('(') builder.WriteString(nestedFoldersSelectors) args = append(args, nestedFoldersArgs...) @@ -258,14 +258,14 @@ func (f *accessControlDashboardPermissionFilter) buildClauses() { toCheck := actionsToCheck(f.folderActions, f.user.GetPermissions(), folderWildcards) if len(toCheck) > 0 { if !useSelfContainedPermissions { - permSelector.WriteString("(SELECT substr(scope, 13) FROM permission WHERE scope LIKE 'folders:uid:%'") + permSelector.WriteString("(SELECT identifier FROM permission WHERE kind = 'folders' AND attribute = 'uid'") permSelector.WriteString(rolesFilter) permSelectorArgs = append(permSelectorArgs, params...) if len(toCheck) == 1 { permSelector.WriteString(" AND action = ?") permSelectorArgs = append(permSelectorArgs, toCheck[0]) } else { - permSelector.WriteString(" AND action IN (?" + strings.Repeat(", ?", len(toCheck)-1) + ") GROUP BY role_id, scope HAVING COUNT(action) = ?") + permSelector.WriteString(" AND action IN (?" + strings.Repeat(", ?", len(toCheck)-1) + ") GROUP BY role_id, scope, identifier HAVING COUNT(action) = ?") permSelectorArgs = append(permSelectorArgs, toCheck...) permSelectorArgs = append(permSelectorArgs, len(toCheck)) } @@ -289,11 +289,11 @@ func (f *accessControlDashboardPermissionFilter) buildClauses() { switch f.recursiveQueriesAreSupported { case true: recQueryName := fmt.Sprintf("RecQry%d", len(f.recQueries)) - f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs) + f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs, orgID) builder.WriteString("(dashboard.uid IN ") builder.WriteString(fmt.Sprintf("(SELECT uid FROM %s)", recQueryName)) default: - nestedFoldersSelectors, nestedFoldersArgs := f.nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "dashboard.uid", "d.uid", orgID) + nestedFoldersSelectors, nestedFoldersArgs := f.nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "dashboard", "uid", "d.uid", orgID) builder.WriteRune('(') builder.WriteString(nestedFoldersSelectors) builder.WriteRune(')') @@ -340,15 +340,17 @@ func (f *accessControlDashboardPermissionFilter) With() (string, []any) { return sb.String(), params } -func (f *accessControlDashboardPermissionFilter) addRecQry(queryName string, whereUIDSelect string, whereParams []any) { +func (f *accessControlDashboardPermissionFilter) addRecQry(queryName string, whereUIDSelect string, whereParams []any, orgID int64) { if f.recQueries == nil { f.recQueries = make([]clause, 0, maximumRecursiveQueries) } c := make([]any, len(whereParams)) copy(c, whereParams) + c = append([]any{orgID}, c...) f.recQueries = append(f.recQueries, clause{ + // covered by UQE_folder_org_id_uid and UQE_folder_org_id_parent_uid_title string: fmt.Sprintf(`%s AS ( - SELECT uid, parent_uid, org_id FROM folder WHERE uid IN %s + SELECT uid, parent_uid, org_id FROM folder WHERE org_id = ? AND uid IN %s UNION ALL SELECT f.uid, f.parent_uid, f.org_id FROM folder f INNER JOIN %s r ON f.parent_uid = r.uid and f.org_id = r.org_id )`, queryName, whereUIDSelect, queryName), params: c, @@ -378,12 +380,13 @@ func actionsToCheck(actions []string, permissions map[string][]string, wildcards return toCheck } -func (f *accessControlDashboardPermissionFilter) nestedFoldersSelectors(permSelector string, permSelectorArgs []any, leftTableCol string, rightTableCol string, orgID int64) (string, []any) { +func (f *accessControlDashboardPermissionFilter) nestedFoldersSelectors(permSelector string, permSelectorArgs []any, leftTable string, leftCol string, rightTableCol string, orgID int64) (string, []any) { wheres := make([]string, 0, folder.MaxNestedFolderDepth+1) args := make([]any, 0, len(permSelectorArgs)*(folder.MaxNestedFolderDepth+1)) joins := make([]string, 0, folder.MaxNestedFolderDepth+2) + // covered by UQE_folder_org_id_uid tmpl := "INNER JOIN folder %s ON %s.%s = %s.uid AND %s.org_id = %s.org_id " prev := "d" @@ -393,8 +396,9 @@ func (f *accessControlDashboardPermissionFilter) nestedFoldersSelectors(permSele s := fmt.Sprintf(tmpl, t, prev, onCol, t, prev, t) joins = append(joins, s) - wheres = append(wheres, fmt.Sprintf("(%s IN (SELECT %s FROM dashboard d %s WHERE %s.org_id = ? AND %s.uid IN %s)", leftTableCol, rightTableCol, strings.Join(joins, " "), t, t, permSelector)) - args = append(args, orgID) + // covered by UQE_folder_org_id_uid + wheres = append(wheres, fmt.Sprintf("(%s.org_id = ? AND %s.%s IN (SELECT %s FROM dashboard d %s WHERE %s.org_id = ? AND %s.uid IN %s)", leftTable, leftTable, leftCol, rightTableCol, strings.Join(joins, " "), t, t, permSelector)) + args = append(args, orgID, orgID) args = append(args, permSelectorArgs...) prev = t diff --git a/pkg/services/sqlstore/permissions/dashboard_filter_no_subquery.go b/pkg/services/sqlstore/permissions/dashboard_filter_no_subquery.go index ae362990167f5..120b432a57e96 100644 --- a/pkg/services/sqlstore/permissions/dashboard_filter_no_subquery.go +++ b/pkg/services/sqlstore/permissions/dashboard_filter_no_subquery.go @@ -59,7 +59,7 @@ func (f *accessControlDashboardPermissionFilterNoFolderSubquery) buildClauses() if len(toCheck) > 0 { if !useSelfContainedPermissions { - builder.WriteString("(dashboard.uid IN (SELECT substr(scope, 16) FROM permission WHERE scope LIKE 'dashboards:uid:%'") + builder.WriteString("(dashboard.uid IN (SELECT identifier FROM permission WHERE kind = 'dashboards' AND attribute = 'uid'") builder.WriteString(rolesFilter) args = append(args, params...) @@ -67,7 +67,7 @@ func (f *accessControlDashboardPermissionFilterNoFolderSubquery) buildClauses() builder.WriteString(" AND action = ?") args = append(args, toCheck[0]) } else { - builder.WriteString(" AND action IN (?" + strings.Repeat(", ?", len(toCheck)-1) + ") GROUP BY role_id, scope HAVING COUNT(action) = ?") + builder.WriteString(" AND action IN (?" + strings.Repeat(", ?", len(toCheck)-1) + ") GROUP BY role_id, scope, identifier HAVING COUNT(action) = ?") args = append(args, toCheck...) args = append(args, len(toCheck)) } @@ -89,7 +89,7 @@ func (f *accessControlDashboardPermissionFilterNoFolderSubquery) buildClauses() builder.WriteString(" OR ") if !useSelfContainedPermissions { - permSelector.WriteString("(SELECT substr(scope, 13) FROM permission WHERE scope LIKE 'folders:uid:%' ") + permSelector.WriteString("(SELECT identifier FROM permission WHERE kind = 'folders' AND attribute = 'uid'") permSelector.WriteString(rolesFilter) permSelectorArgs = append(permSelectorArgs, params...) @@ -97,7 +97,7 @@ func (f *accessControlDashboardPermissionFilterNoFolderSubquery) buildClauses() permSelector.WriteString(" AND action = ?") permSelectorArgs = append(permSelectorArgs, toCheck[0]) } else { - permSelector.WriteString(" AND action IN (?" + strings.Repeat(", ?", len(toCheck)-1) + ") GROUP BY role_id, scope HAVING COUNT(action) = ?") + permSelector.WriteString(" AND action IN (?" + strings.Repeat(", ?", len(toCheck)-1) + ") GROUP BY role_id, scope, identifier HAVING COUNT(action) = ?") permSelectorArgs = append(permSelectorArgs, toCheck...) permSelectorArgs = append(permSelectorArgs, len(toCheck)) } @@ -122,10 +122,10 @@ func (f *accessControlDashboardPermissionFilterNoFolderSubquery) buildClauses() switch f.recursiveQueriesAreSupported { case true: recQueryName := fmt.Sprintf("RecQry%d", len(f.recQueries)) - f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs) + f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs, orgID) builder.WriteString("(folder.uid IN (SELECT uid FROM " + recQueryName) default: - nestedFoldersSelectors, nestedFoldersArgs := f.nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "folder.uid", "", orgID) + nestedFoldersSelectors, nestedFoldersArgs := f.nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "folder", "uid", "", orgID) builder.WriteRune('(') builder.WriteString(nestedFoldersSelectors) args = append(args, nestedFoldersArgs...) @@ -169,14 +169,14 @@ func (f *accessControlDashboardPermissionFilterNoFolderSubquery) buildClauses() toCheck := actionsToCheck(f.folderActions, f.user.GetPermissions(), folderWildcards) if len(toCheck) > 0 { if !useSelfContainedPermissions { - permSelector.WriteString("(SELECT substr(scope, 13) FROM permission WHERE scope LIKE 'folders:uid:%'") + permSelector.WriteString("(SELECT identifier FROM permission WHERE kind = 'folders' AND attribute = 'uid'") permSelector.WriteString(rolesFilter) permSelectorArgs = append(permSelectorArgs, params...) if len(toCheck) == 1 { permSelector.WriteString(" AND action = ?") permSelectorArgs = append(permSelectorArgs, toCheck[0]) } else { - permSelector.WriteString(" AND action IN (?" + strings.Repeat(", ?", len(toCheck)-1) + ") GROUP BY role_id, scope HAVING COUNT(action) = ?") + permSelector.WriteString(" AND action IN (?" + strings.Repeat(", ?", len(toCheck)-1) + ") GROUP BY role_id, scope, identifier HAVING COUNT(action) = ?") permSelectorArgs = append(permSelectorArgs, toCheck...) permSelectorArgs = append(permSelectorArgs, len(toCheck)) } @@ -199,11 +199,11 @@ func (f *accessControlDashboardPermissionFilterNoFolderSubquery) buildClauses() switch f.recursiveQueriesAreSupported { case true: recQueryName := fmt.Sprintf("RecQry%d", len(f.recQueries)) - f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs) + f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs, orgID) builder.WriteString("(dashboard.uid IN ") builder.WriteString(fmt.Sprintf("(SELECT uid FROM %s)", recQueryName)) default: - nestedFoldersSelectors, nestedFoldersArgs := f.nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "dashboard.uid", "", orgID) + nestedFoldersSelectors, nestedFoldersArgs := f.nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "dashboard", "uid", "", orgID) builder.WriteRune('(') builder.WriteString(nestedFoldersSelectors) builder.WriteRune(')') @@ -231,15 +231,18 @@ func (f *accessControlDashboardPermissionFilterNoFolderSubquery) buildClauses() f.where = clause{string: builder.String(), params: args} } -func (f *accessControlDashboardPermissionFilterNoFolderSubquery) nestedFoldersSelectors(permSelector string, permSelectorArgs []any, leftTableCol string, _ string, orgID int64) (string, []any) { +func (f *accessControlDashboardPermissionFilterNoFolderSubquery) nestedFoldersSelectors(permSelector string, permSelectorArgs []any, leftTable string, leftCol string, _ string, orgID int64) (string, []any) { wheres := make([]string, 0, folder.MaxNestedFolderDepth+1) args := make([]any, 0, len(permSelectorArgs)*(folder.MaxNestedFolderDepth+1)) joins := make([]string, 0, folder.MaxNestedFolderDepth+2) + // covered by UQE_folder_org_id_parent_uid_title tmpl := "INNER JOIN folder %s ON %s.parent_uid = %s.uid AND %s.org_id = %s.org_id " - wheres = append(wheres, fmt.Sprintf("(%s IN (SELECT f1.uid FROM folder f1 WHERE f1.uid IN %s)", leftTableCol, permSelector)) + // covered by UQE_folder_org_id_uid + wheres = append(wheres, fmt.Sprintf("(%s.org_id = ? AND %s.%s IN (SELECT f1.uid FROM folder f1 WHERE f1.org_id = ? AND f1.uid IN %s)", leftTable, leftTable, leftCol, permSelector)) + args = append(args, orgID, orgID) args = append(args, permSelectorArgs...) prev := "f1" @@ -248,8 +251,9 @@ func (f *accessControlDashboardPermissionFilterNoFolderSubquery) nestedFoldersSe s := fmt.Sprintf(tmpl, t, prev, t, prev, t) joins = append(joins, s) - wheres = append(wheres, fmt.Sprintf("(%s IN (SELECT f1.uid FROM folder f1 %s WHERE %s.org_id = ? AND %s.uid IN %s)", leftTableCol, strings.Join(joins, " "), t, t, permSelector)) - args = append(args, orgID) + // covered by UQE_folder_org_id_uid + wheres = append(wheres, fmt.Sprintf("(%s.org_id = ? AND %s.%s IN (SELECT f1.uid FROM folder f1 %s WHERE %s.org_id = ? AND %s.uid IN %s)", leftTable, leftTable, leftCol, strings.Join(joins, " "), t, t, permSelector)) + args = append(args, orgID, orgID) args = append(args, permSelectorArgs...) prev = t diff --git a/pkg/services/sqlstore/permissions/dashboard_test.go b/pkg/services/sqlstore/permissions/dashboard_test.go index e6636b59e232a..00a77a7964e5e 100644 --- a/pkg/services/sqlstore/permissions/dashboard_test.go +++ b/pkg/services/sqlstore/permissions/dashboard_test.go @@ -29,10 +29,16 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" + "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegration_DashboardPermissionFilter(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -63,14 +69,6 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { }, expectedResult: 110, }, - { - desc: "Should be able to view dashboards under the root with folders:uid:general scope", - permission: dashboardaccess.PERMISSION_VIEW, - permissions: []accesscontrol.Permission{ - {Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder.GeneralFolderUID)}, - }, - expectedResult: 10, - }, { desc: "Should not be able to view editable dashboards under the root with folders:uid:general scope if missing write action", permission: dashboardaccess.PERMISSION_EDIT, @@ -79,37 +77,19 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { }, expectedResult: 0, }, - { - desc: "Should be able to view editable dashboards under the root with folders:uid:general scope if has write action", - permission: dashboardaccess.PERMISSION_EDIT, - permissions: []accesscontrol.Permission{ - {Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder.GeneralFolderUID)}, - {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder.GeneralFolderUID)}, - }, - expectedResult: 10, - }, { desc: "Should be able to view a subset of dashboards with dashboard scopes", permission: dashboardaccess.PERMISSION_VIEW, permissions: []accesscontrol.Permission{ - {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:110"}, - {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:40"}, - {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:22"}, - {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:13"}, - {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:55"}, - {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:99"}, + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:110", Kind: "dashboards", Identifier: "110"}, + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:40", Kind: "dashboards", Identifier: "40"}, + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:22", Kind: "dashboards", Identifier: "22"}, + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:13", Kind: "dashboards", Identifier: "13"}, + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:55", Kind: "dashboards", Identifier: "55"}, + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:99", Kind: "dashboards", Identifier: "99"}, }, expectedResult: 6, }, - { - desc: "Should be able to view a subset of dashboards with dashboard action and folder scope", - permission: dashboardaccess.PERMISSION_VIEW, - permissions: []accesscontrol.Permission{ - {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:8"}, - {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:10"}, - }, - expectedResult: 20, - }, { desc: "Should be able to view all folders with folder wildcard", permission: dashboardaccess.PERMISSION_VIEW, @@ -122,9 +102,9 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { desc: "Should be able to view a subset folders", permission: dashboardaccess.PERMISSION_VIEW, permissions: []accesscontrol.Permission{ - {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:3"}, - {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:6"}, - {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:9"}, + {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:3", Kind: "folders", Identifier: "3"}, + {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:6", Kind: "folders", Identifier: "6"}, + {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:9", Kind: "folders", Identifier: "9"}, }, expectedResult: 3, }, @@ -132,10 +112,10 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { desc: "Should return folders and dashboard with 'edit' permission", permission: dashboardaccess.PERMISSION_EDIT, permissions: []accesscontrol.Permission{ - {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:3"}, - {Action: dashboards.ActionDashboardsCreate, Scope: "folders:uid:3"}, - {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:33"}, - {Action: dashboards.ActionDashboardsWrite, Scope: "dashboards:uid:33"}, + {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:3", Kind: "folders", Identifier: "3"}, + {Action: dashboards.ActionDashboardsCreate, Scope: "folders:uid:3", Kind: "folders", Identifier: "3"}, + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:33", Kind: "dashboards", Identifier: "33"}, + {Action: dashboards.ActionDashboardsWrite, Scope: "dashboards:uid:33", Kind: "dashboards", Identifier: "33"}, }, expectedResult: 2, }, @@ -143,11 +123,11 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { desc: "Should return the dashboards that the User has dashboards:write permission on in case of 'edit' permission", permission: dashboardaccess.PERMISSION_EDIT, permissions: []accesscontrol.Permission{ - {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:3"}, - {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:31"}, - {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:32"}, - {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:33"}, - {Action: dashboards.ActionDashboardsWrite, Scope: "dashboards:uid:33"}, + {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:3", Kind: "folders", Identifier: "3"}, + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:31", Kind: "dashboards", Identifier: "31"}, + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:32", Kind: "dashboards", Identifier: "32"}, + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:33", Kind: "dashboards", Identifier: "33"}, + {Action: dashboards.ActionDashboardsWrite, Scope: "dashboards:uid:33", Kind: "dashboards", Identifier: "33"}, }, expectedResult: 1, }, @@ -155,11 +135,11 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { desc: "Should return the folders that the User has dashboards:create permission on in case of 'edit' permission", permission: dashboardaccess.PERMISSION_EDIT, permissions: []accesscontrol.Permission{ - {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:3"}, - {Action: dashboards.ActionDashboardsCreate, Scope: "folders:uid:3"}, - {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:4"}, - {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:32"}, - {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:33"}, + {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:3", Kind: "folders", Identifier: "3"}, + {Action: dashboards.ActionDashboardsCreate, Scope: "folders:uid:3", Kind: "folders", Identifier: "3"}, + {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:4", Kind: "folders", Identifier: "4"}, + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:32", Kind: "dashboards", Identifier: "32"}, + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:33", Kind: "dashboards", Identifier: "33"}, }, expectedResult: 1, }, @@ -168,10 +148,10 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { permission: dashboardaccess.PERMISSION_VIEW, queryType: searchstore.TypeAlertFolder, permissions: []accesscontrol.Permission{ - {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:3"}, - {Action: accesscontrol.ActionAlertingRuleRead, Scope: "folders:uid:3"}, - {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:8"}, - {Action: accesscontrol.ActionAlertingRuleRead, Scope: "folders:uid:8"}, + {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:3", Kind: "folders", Identifier: "3"}, + {Action: accesscontrol.ActionAlertingRuleRead, Scope: "folders:uid:3", Kind: "folders", Identifier: "3"}, + {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:8", Kind: "folders", Identifier: "8"}, + {Action: accesscontrol.ActionAlertingRuleRead, Scope: "folders:uid:8", Kind: "folders", Identifier: "8"}, }, expectedResult: 2, }, @@ -181,8 +161,8 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { queryType: searchstore.TypeAlertFolder, permissions: []accesscontrol.Permission{ {Action: dashboards.ActionFoldersRead, Scope: "*"}, - {Action: accesscontrol.ActionAlertingRuleRead, Scope: "folders:uid:3"}, - {Action: accesscontrol.ActionAlertingRuleRead, Scope: "folders:uid:8"}, + {Action: accesscontrol.ActionAlertingRuleRead, Scope: "folders:uid:3", Kind: "folders", Identifier: "3"}, + {Action: accesscontrol.ActionAlertingRuleRead, Scope: "folders:uid:8", Kind: "folders", Identifier: "8"}, }, expectedResult: 2, }, @@ -195,7 +175,7 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { usr := &user.SignedInUser{OrgID: 1, OrgRole: org.RoleViewer, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}} - for _, features := range []*featuremgmt.FeatureManager{featuremgmt.WithFeatures(), featuremgmt.WithFeatures(featuremgmt.FlagPermissionsFilterRemoveSubquery)} { + for _, features := range []featuremgmt.FeatureToggles{featuremgmt.WithFeatures(), featuremgmt.WithFeatures(featuremgmt.FlagPermissionsFilterRemoveSubquery)} { m := features.GetEnabled(context.Background()) keys := make([]string, 0, len(m)) for k := range m { @@ -274,24 +254,6 @@ func TestIntegration_DashboardPermissionFilter_WithSelfContainedPermissions(t *t }, expectedResult: 6, }, - { - desc: "Should be able to view a subset of dashboards with dashboard action and folder scope", - permission: dashboardaccess.PERMISSION_VIEW, - - signedInUserPermissions: []accesscontrol.Permission{ - {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:8"}, - {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:10"}, - }, - expectedResult: 20, - }, - { - desc: "Should be able to view dashboards under the root with folders:uid:general scope", - permission: dashboardaccess.PERMISSION_VIEW, - signedInUserPermissions: []accesscontrol.Permission{ - {Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder.GeneralFolderUID)}, - }, - expectedResult: 10, - }, { desc: "Should not be able to view editable dashboards under the root with folders:uid:general scope if missing write action", permission: dashboardaccess.PERMISSION_EDIT, @@ -300,15 +262,6 @@ func TestIntegration_DashboardPermissionFilter_WithSelfContainedPermissions(t *t }, expectedResult: 0, }, - { - desc: "Should be able to view editable dashboards under the root with folders:uid:general scope if has write action", - permission: dashboardaccess.PERMISSION_EDIT, - signedInUserPermissions: []accesscontrol.Permission{ - {Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder.GeneralFolderUID)}, - {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder.GeneralFolderUID)}, - }, - expectedResult: 10, - }, { desc: "Should be able to view all folders with folder wildcard", permission: dashboardaccess.PERMISSION_VIEW, @@ -394,7 +347,7 @@ func TestIntegration_DashboardPermissionFilter_WithSelfContainedPermissions(t *t usr := &user.SignedInUser{OrgID: 1, OrgRole: org.RoleViewer, AuthenticatedBy: login.ExtendedJWTModule, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.signedInUserPermissions)}} - for _, features := range []*featuremgmt.FeatureManager{featuremgmt.WithFeatures(), featuremgmt.WithFeatures(featuremgmt.FlagPermissionsFilterRemoveSubquery)} { + for _, features := range []featuremgmt.FeatureToggles{featuremgmt.WithFeatures(), featuremgmt.WithFeatures(featuremgmt.FlagPermissionsFilterRemoveSubquery)} { m := features.GetEnabled(context.Background()) keys := make([]string, 0, len(m)) for k := range m { @@ -466,32 +419,12 @@ func TestIntegration_DashboardNestedPermissionFilter(t *testing.T) { features: []any{featuremgmt.FlagNestedFolders}, expectedResult: []string{"dashboard under parent folder", "dashboard under subfolder"}, }, - { - desc: "Should be able to view dashboards under inherited folders if nested folders are enabled", - queryType: searchstore.TypeDashboard, - permission: dashboardaccess.PERMISSION_VIEW, - permissions: []accesscontrol.Permission{ - {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:parent"}, - }, - features: []any{featuremgmt.FlagNestedFolders}, - expectedResult: []string{"dashboard under parent folder", "dashboard under subfolder"}, - }, - { - desc: "Should not be able to view dashboards under inherited folders if nested folders are not enabled", - queryType: searchstore.TypeDashboard, - permission: dashboardaccess.PERMISSION_VIEW, - permissions: []accesscontrol.Permission{ - {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:parent"}, - }, - features: []any{}, - expectedResult: []string{"dashboard under parent folder"}, - }, { desc: "Should be able to view inherited folders if nested folders are enabled", queryType: searchstore.TypeFolder, permission: dashboardaccess.PERMISSION_VIEW, permissions: []accesscontrol.Permission{ - {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:parent"}, + {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:parent", Kind: "folders", Identifier: "parent"}, }, features: []any{featuremgmt.FlagNestedFolders}, expectedResult: []string{"parent", "subfolder"}, @@ -501,31 +434,11 @@ func TestIntegration_DashboardNestedPermissionFilter(t *testing.T) { queryType: searchstore.TypeFolder, permission: dashboardaccess.PERMISSION_VIEW, permissions: []accesscontrol.Permission{ - {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:parent"}, + {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:parent", Kind: "folders", Identifier: "parent"}, }, features: []any{}, expectedResult: []string{"parent"}, }, - { - desc: "Should be able to view inherited dashboards and folders if nested folders are enabled", - permission: dashboardaccess.PERMISSION_VIEW, - permissions: []accesscontrol.Permission{ - {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:parent"}, - {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:parent"}, - }, - features: []any{featuremgmt.FlagNestedFolders}, - expectedResult: []string{"parent", "subfolder", "dashboard under parent folder", "dashboard under subfolder"}, - }, - { - desc: "Should not be able to view inherited dashboards and folders if nested folders are not enabled", - permission: dashboardaccess.PERMISSION_VIEW, - permissions: []accesscontrol.Permission{ - {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:parent"}, - {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:parent"}, - }, - features: []any{}, - expectedResult: []string{"parent", "dashboard under parent folder"}, - }, } origNewGuardian := guardian.New @@ -545,7 +458,7 @@ func TestIntegration_DashboardNestedPermissionFilter(t *testing.T) { }) usr := &user.SignedInUser{OrgID: orgID, OrgRole: org.RoleViewer, Permissions: map[int64]map[string][]string{orgID: accesscontrol.GroupScopesByAction(tc.permissions)}} - for _, features := range []*featuremgmt.FeatureManager{featuremgmt.WithFeatures(tc.features...), featuremgmt.WithFeatures(append(tc.features, featuremgmt.FlagPermissionsFilterRemoveSubquery)...)} { + for _, features := range []featuremgmt.FeatureToggles{featuremgmt.WithFeatures(tc.features...), featuremgmt.WithFeatures(append(tc.features, featuremgmt.FlagPermissionsFilterRemoveSubquery)...)} { m := features.GetEnabled(context.Background()) keys := make([]string, 0, len(m)) for k := range m { @@ -619,26 +532,6 @@ func TestIntegration_DashboardNestedPermissionFilter_WithSelfContainedPermission features: []any{featuremgmt.FlagNestedFolders}, expectedResult: []string{"dashboard under parent folder", "dashboard under subfolder"}, }, - { - desc: "Should be able to view dashboards under inherited folders if nested folders are enabled", - queryType: searchstore.TypeDashboard, - permission: dashboardaccess.PERMISSION_VIEW, - signedInUserPermissions: []accesscontrol.Permission{ - {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:parent"}, - }, - features: []any{featuremgmt.FlagNestedFolders}, - expectedResult: []string{"dashboard under parent folder", "dashboard under subfolder"}, - }, - { - desc: "Should not be able to view dashboards under inherited folders if nested folders are not enabled", - queryType: searchstore.TypeDashboard, - permission: dashboardaccess.PERMISSION_VIEW, - signedInUserPermissions: []accesscontrol.Permission{ - {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:parent"}, - }, - features: []any{}, - expectedResult: []string{"dashboard under parent folder"}, - }, { desc: "Should be able to view inherited folders if nested folders are enabled", queryType: searchstore.TypeFolder, @@ -659,40 +552,6 @@ func TestIntegration_DashboardNestedPermissionFilter_WithSelfContainedPermission features: []any{}, expectedResult: []string{"parent"}, }, - { - desc: "Should be able to view inherited dashboards and folders if nested folders are enabled", - permission: dashboardaccess.PERMISSION_VIEW, - signedInUserPermissions: []accesscontrol.Permission{ - {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:parent"}, - {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:parent"}, - }, - features: []any{featuremgmt.FlagNestedFolders}, - expectedResult: []string{"parent", "subfolder", "dashboard under parent folder", "dashboard under subfolder"}, - }, - { - desc: "Should not be able to view inherited dashboards and folders if nested folders are not enabled", - permission: dashboardaccess.PERMISSION_VIEW, - signedInUserPermissions: []accesscontrol.Permission{ - {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:parent"}, - {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:parent"}, - }, - features: []any{}, - expectedResult: []string{"parent", "dashboard under parent folder"}, - }, - { - desc: "Should be able to edit inherited dashboards and folders if nested folders are enabled", - permission: dashboardaccess.PERMISSION_EDIT, - signedInUserPermissions: []accesscontrol.Permission{ - {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:subfolder"}, - {Action: dashboards.ActionDashboardsCreate, Scope: "folders:uid:subfolder"}, - {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:subfolder"}, - {Action: dashboards.ActionDashboardsWrite, Scope: "folders:uid:subfolder"}, - {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:parent"}, - {Action: dashboards.ActionDashboardsWrite, Scope: "folders:uid:parent"}, - }, - features: []any{featuremgmt.FlagNestedFolders}, - expectedResult: []string{"subfolder", "dashboard under parent folder", "dashboard under subfolder"}, - }, } origNewGuardian := guardian.New @@ -716,7 +575,7 @@ func TestIntegration_DashboardNestedPermissionFilter_WithSelfContainedPermission }), }, } - for _, features := range []*featuremgmt.FeatureManager{featuremgmt.WithFeatures(tc.features...), featuremgmt.WithFeatures(append(tc.features, featuremgmt.FlagPermissionsFilterRemoveSubquery)...)} { + for _, features := range []featuremgmt.FeatureToggles{featuremgmt.WithFeatures(tc.features...), featuremgmt.WithFeatures(append(tc.features, featuremgmt.FlagPermissionsFilterRemoveSubquery)...)} { m := features.GetEnabled(context.Background()) keys := make([]string, 0, len(m)) for k := range m { @@ -776,15 +635,15 @@ func setupTest(t *testing.T, numFolders, numDashboards int, permissions []access folderID = i % (numFolders + 1) } dashes = append(dashes, dashboards.Dashboard{ - OrgID: 1, - IsFolder: false, - FolderID: int64(folderID), // nolint:staticcheck - UID: str, - Slug: str, - Title: str, - Data: simplejson.New(), - Created: time.Now(), - Updated: time.Now(), + OrgID: 1, + IsFolder: false, + FolderUID: strconv.Itoa(folderID), + UID: str, + Slug: str, + Title: str, + Data: simplejson.New(), + Created: time.Now(), + Updated: time.Now(), }) } @@ -820,6 +679,7 @@ func setupTest(t *testing.T, numFolders, numDashboards int, permissions []access permissions[i].RoleID = role.ID permissions[i].Created = time.Now() permissions[i].Updated = time.Now() + permissions[i].Kind, permissions[i].Attribute, permissions[i].Identifier = permissions[i].SplitScope() } if len(permissions) > 0 { _, err = sess.InsertMulti(&permissions) @@ -843,7 +703,7 @@ func setupNestedTest(t *testing.T, usr *user.SignedInUser, perms []accesscontrol dashStore, err := database.ProvideDashboardStore(db, db.Cfg, features, tagimpl.ProvideService(db), quotatest.New(false, nil)) require.NoError(t, err) - folderSvc := folderimpl.ProvideService(mock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), db.Cfg, dashStore, folderimpl.ProvideDashboardFolderStore(db), db, features, nil) + folderSvc := folderimpl.ProvideService(mock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), db.Cfg, dashStore, folderimpl.ProvideDashboardFolderStore(db), db, features, supportbundlestest.NewFakeBundleService(), nil) // create parent folder parent, err := folderSvc.Create(context.Background(), &folder.CreateFolderCommand{ @@ -867,7 +727,6 @@ func setupNestedTest(t *testing.T, usr *user.SignedInUser, perms []accesscontrol // create dashboard under parent folder _, err = dashStore.SaveDashboard(context.Background(), dashboards.SaveDashboardCommand{ OrgID: orgID, - FolderID: parent.ID, // nolint:staticcheck FolderUID: parent.UID, Dashboard: simplejson.NewFromAny(map[string]any{ "title": "dashboard under parent folder", @@ -878,7 +737,6 @@ func setupNestedTest(t *testing.T, usr *user.SignedInUser, perms []accesscontrol // create dashboard under subfolder _, err = dashStore.SaveDashboard(context.Background(), dashboards.SaveDashboardCommand{ OrgID: orgID, - FolderID: subfolder.ID, // nolint:staticcheck FolderUID: subfolder.UID, Dashboard: simplejson.NewFromAny(map[string]any{ "title": "dashboard under subfolder", @@ -913,6 +771,7 @@ func setupNestedTest(t *testing.T, usr *user.SignedInUser, perms []accesscontrol perms[i].RoleID = role.ID perms[i].Created = time.Now() perms[i].Updated = time.Now() + perms[i].Kind, perms[i].Attribute, perms[i].Identifier = perms[i].SplitScope() } if len(perms) > 0 { _, err = sess.InsertMulti(&perms) diff --git a/pkg/services/sqlstore/permissions/dashboards_bench_test.go b/pkg/services/sqlstore/permissions/dashboards_bench_test.go index dd53cbf51b1f9..ca804da246223 100644 --- a/pkg/services/sqlstore/permissions/dashboards_bench_test.go +++ b/pkg/services/sqlstore/permissions/dashboards_bench_test.go @@ -27,6 +27,7 @@ import ( "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" + "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" ) @@ -83,7 +84,7 @@ func setupBenchMark(b *testing.B, usr user.SignedInUser, features featuremgmt.Fe dashboardWriteStore, err := database.ProvideDashboardStore(store, store.Cfg, features, tagimpl.ProvideService(store), quotaService) require.NoError(b, err) - folderSvc := folderimpl.ProvideService(mock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), store.Cfg, dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(store), store, features, nil) + folderSvc := folderimpl.ProvideService(mock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), store.Cfg, dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(store), store, features, supportbundlestest.NewFakeBundleService(), nil) origNewGuardian := guardian.New guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true, CanSaveValue: true}) @@ -125,15 +126,15 @@ func setupBenchMark(b *testing.B, usr user.SignedInUser, features featuremgmt.Fe str := fmt.Sprintf("dashboard under folder %s", leaf.Title) now := time.Now() dashes = append(dashes, dashboards.Dashboard{ - OrgID: usr.OrgID, - IsFolder: false, - UID: str, - Slug: str, - Title: str, - Data: simplejson.New(), - Created: now, - Updated: now, - FolderID: leaf.ID, // nolint:staticcheck + OrgID: usr.OrgID, + IsFolder: false, + UID: str, + Slug: str, + Title: str, + Data: simplejson.New(), + Created: now, + Updated: now, + FolderUID: leaf.UID, }) } @@ -177,8 +178,7 @@ func setupBenchMark(b *testing.B, usr user.SignedInUser, features featuremgmt.Fe }) for _, dash := range dashes { // add permission to read dashboards under the general - // nolint:staticcheck - if dash.FolderID == 0 { + if dash.FolderUID == "" { permissions = append(permissions, accesscontrol.Permission{ RoleID: int64(i), Action: dashboards.ActionDashboardsRead, diff --git a/pkg/services/sqlstore/searchstore/builder.go b/pkg/services/sqlstore/searchstore/builder.go index 675a9fe92d586..d086b97a4b5ff 100644 --- a/pkg/services/sqlstore/searchstore/builder.go +++ b/pkg/services/sqlstore/searchstore/builder.go @@ -38,6 +38,7 @@ func (b *Builder) ToSQL(limit, page int64) (string, []any) { b.sql.WriteString("\n") if b.Features.IsEnabledGlobally(featuremgmt.FlagNestedFolders) { + // covered by UQE_folder_org_id_uid b.sql.WriteString( `LEFT OUTER JOIN folder ON folder.uid = dashboard.folder_uid AND folder.org_id = dashboard.org_id`) } else { diff --git a/pkg/services/sqlstore/searchstore/search_test.go b/pkg/services/sqlstore/searchstore/search_test.go index d2c06ccd795ff..7b60949d3a7a2 100644 --- a/pkg/services/sqlstore/searchstore/search_test.go +++ b/pkg/services/sqlstore/searchstore/search_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) @@ -26,6 +27,10 @@ const ( page int64 = 1 ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestBuilder_EqualResults_Basic(t *testing.T) { user := &user.SignedInUser{ UserID: 1, @@ -175,6 +180,7 @@ func TestBuilder_RBAC(t *testing.T) { }, features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders), expectedParams: []any{ + int64(1), int64(1), int64(1), 0, @@ -186,6 +192,7 @@ func TestBuilder_RBAC(t *testing.T) { 2, int64(1), int64(1), + int64(1), 0, "Viewer", int64(1), @@ -250,6 +257,7 @@ func TestBuilder_RBAC(t *testing.T) { }, features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders, featuremgmt.FlagPermissionsFilterRemoveSubquery), expectedParams: []any{ + int64(1), int64(1), int64(1), 0, @@ -261,6 +269,7 @@ func TestBuilder_RBAC(t *testing.T) { 2, int64(1), int64(1), + int64(1), 0, "Viewer", int64(1), diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 04356f23ec42d..b24615fb6b8cc 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -4,10 +4,7 @@ import ( "context" "errors" "fmt" - "net/url" "os" - "path" - "path/filepath" "strings" "sync" "time" @@ -34,7 +31,6 @@ import ( "github.com/grafana/grafana/pkg/services/stats" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" ) // ContextSessionKey is used as key to save values in `context.Context` @@ -42,10 +38,11 @@ type ContextSessionKey struct{} type SQLStore struct { Cfg *setting.Cfg + features featuremgmt.FeatureToggles sqlxsession *session.SessionDB bus bus.Bus - dbCfg DatabaseConfig + dbCfg *DatabaseConfig engine *xorm.Engine log log.Logger Dialect migrator.Dialect @@ -56,7 +53,10 @@ type SQLStore struct { recursiveQueriesMu sync.Mutex } -func ProvideService(cfg *setting.Cfg, migrations registry.DatabaseMigrator, bus bus.Bus, tracer tracing.Tracer) (*SQLStore, error) { +func ProvideService(cfg *setting.Cfg, + features featuremgmt.FeatureToggles, + migrations registry.DatabaseMigrator, + bus bus.Bus, tracer tracing.Tracer) (*SQLStore, error) { // This change will make xorm use an empty default schema for postgres and // by that mimic the functionality of how it was functioning before // xorm's changes above. @@ -65,9 +65,9 @@ func ProvideService(cfg *setting.Cfg, migrations registry.DatabaseMigrator, bus if err != nil { return nil, err } + s.features = features - // nolint:staticcheck - if err := s.Migrate(cfg.IsFeatureToggleEnabled(featuremgmt.FlagMigrationLocking)); err != nil { + if err := s.Migrate(features.IsEnabledGlobally(featuremgmt.FlagMigrationLocking)); err != nil { return nil, err } @@ -91,8 +91,24 @@ func ProvideService(cfg *setting.Cfg, migrations registry.DatabaseMigrator, bus return s, nil } -func ProvideServiceForTests(cfg *setting.Cfg, migrations registry.DatabaseMigrator) (*SQLStore, error) { - return initTestDB(cfg, migrations, InitTestDBOpt{EnsureDefaultOrgAndUser: true}) +func ProvideServiceForTests(t sqlutil.ITestDB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, migrations registry.DatabaseMigrator) (*SQLStore, error) { + return initTestDB(t, cfg, features, migrations, InitTestDBOpt{EnsureDefaultOrgAndUser: true}) +} + +// NewSQLStoreWithoutSideEffects creates a new *SQLStore without side-effects such as +// running database migrations and/or ensuring main org and admin user exists. +func NewSQLStoreWithoutSideEffects(cfg *setting.Cfg, + features featuremgmt.FeatureToggles, + bus bus.Bus, tracer tracing.Tracer) (*SQLStore, error) { + s, err := newSQLStore(cfg, nil, nil, bus, tracer) + if err != nil { + return nil, err + } + + s.features = features + s.tracer = tracer + + return s, nil } func newSQLStore(cfg *setting.Cfg, engine *xorm.Engine, @@ -145,11 +161,6 @@ func (ss *SQLStore) Migrate(isDatabaseLockingEnabled bool) error { return migrator.Start(isDatabaseLockingEnabled, ss.dbCfg.MigrationLockAttemptTimeout) } -// Sync syncs changes to the database. -func (ss *SQLStore) Sync() error { - return ss.engine.Sync2() -} - // Reset resets database state. // If default org and user creation is enabled, it will be ensured they exist in the database. func (ss *SQLStore) Reset() error { @@ -160,18 +171,6 @@ func (ss *SQLStore) Reset() error { return ss.ensureMainOrgAndAdminUser(false) } -// TestReset resets database state. If default org and user creation is enabled, -// it will be ensured they exist in the database. TestReset() is more permissive -// than Reset in that it will create the user and org whether or not there are -// already users in the database. -func (ss *SQLStore) TestReset() error { - if ss.skipEnsureDefaultOrgAndUser { - return nil - } - - return ss.ensureMainOrgAndAdminUser(true) -} - // Quote quotes the value in the used SQL dialect func (ss *SQLStore) Quote(value string) string { return ss.engine.Quote(value) @@ -227,7 +226,7 @@ func (ss *SQLStore) ensureMainOrgAndAdminUser(test bool) error { if _, err := ss.createUser(ctx, sess, user.CreateUserCommand{ Login: ss.Cfg.AdminUser, Email: ss.Cfg.AdminEmail, - Password: ss.Cfg.AdminPassword, + Password: user.Password(ss.Cfg.AdminPassword), IsAdmin: true, }); err != nil { return fmt.Errorf("failed to create admin user: %s", err) @@ -248,110 +247,6 @@ func (ss *SQLStore) ensureMainOrgAndAdminUser(test bool) error { return err } -func (ss *SQLStore) buildExtraConnectionString(sep rune) string { - if ss.dbCfg.UrlQueryParams == nil { - return "" - } - - var sb strings.Builder - for key, values := range ss.dbCfg.UrlQueryParams { - for _, value := range values { - sb.WriteRune(sep) - sb.WriteString(key) - sb.WriteRune('=') - sb.WriteString(value) - } - } - return sb.String() -} - -func (ss *SQLStore) buildConnectionString() (string, error) { - if err := ss.readConfig(); err != nil { - return "", err - } - - cnnstr := ss.dbCfg.ConnectionString - - // special case used by integration tests - if cnnstr != "" { - return cnnstr, nil - } - - switch ss.dbCfg.Type { - case migrator.MySQL: - protocol := "tcp" - if strings.HasPrefix(ss.dbCfg.Host, "/") { - protocol = "unix" - } - - cnnstr = fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true", - ss.dbCfg.User, ss.dbCfg.Pwd, protocol, ss.dbCfg.Host, ss.dbCfg.Name) - - if ss.dbCfg.SslMode == "true" || ss.dbCfg.SslMode == "skip-verify" { - tlsCert, err := makeCert(ss.dbCfg) - if err != nil { - return "", err - } - if err := mysql.RegisterTLSConfig("custom", tlsCert); err != nil { - return "", err - } - - cnnstr += "&tls=custom" - } - - if isolation := ss.dbCfg.IsolationLevel; isolation != "" { - val := url.QueryEscape(fmt.Sprintf("'%s'", isolation)) - cnnstr += fmt.Sprintf("&transaction_isolation=%s", val) - } - - // nolint:staticcheck - if ss.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagMysqlAnsiQuotes) { - cnnstr += "&sql_mode='ANSI_QUOTES'" - } - - cnnstr += ss.buildExtraConnectionString('&') - case migrator.Postgres: - addr, err := util.SplitHostPortDefault(ss.dbCfg.Host, "127.0.0.1", "5432") - if err != nil { - return "", fmt.Errorf("invalid host specifier '%s': %w", ss.dbCfg.Host, err) - } - - args := []any{ss.dbCfg.User, addr.Host, addr.Port, ss.dbCfg.Name, ss.dbCfg.SslMode, ss.dbCfg.ClientCertPath, - ss.dbCfg.ClientKeyPath, ss.dbCfg.CaCertPath} - for i, arg := range args { - if arg == "" { - args[i] = "''" - } - } - cnnstr = fmt.Sprintf("user=%s host=%s port=%s dbname=%s sslmode=%s sslcert=%s sslkey=%s sslrootcert=%s", args...) - if ss.dbCfg.Pwd != "" { - cnnstr += fmt.Sprintf(" password=%s", ss.dbCfg.Pwd) - } - - cnnstr += ss.buildExtraConnectionString(' ') - case migrator.SQLite: - // special case for tests - if !filepath.IsAbs(ss.dbCfg.Path) { - ss.dbCfg.Path = filepath.Join(ss.Cfg.DataPath, ss.dbCfg.Path) - } - if err := os.MkdirAll(path.Dir(ss.dbCfg.Path), os.ModePerm); err != nil { - return "", err - } - - cnnstr = fmt.Sprintf("file:%s?cache=%s&mode=rwc", ss.dbCfg.Path, ss.dbCfg.CacheMode) - - if ss.dbCfg.WALEnabled { - cnnstr += "&_journal_mode=WAL" - } - - cnnstr += ss.buildExtraConnectionString('&') - default: - return "", fmt.Errorf("unknown database type: %s", ss.dbCfg.Type) - } - - return cnnstr, nil -} - // initEngine initializes ss.engine. func (ss *SQLStore) initEngine(engine *xorm.Engine) error { if ss.engine != nil { @@ -359,18 +254,20 @@ func (ss *SQLStore) initEngine(engine *xorm.Engine) error { return nil } - connectionString, err := ss.buildConnectionString() + dbCfg, err := NewDatabaseConfig(ss.Cfg, ss.features) if err != nil { return err } + ss.dbCfg = dbCfg + if ss.Cfg.DatabaseInstrumentQueries { ss.dbCfg.Type = WrapDatabaseDriverWithHooks(ss.dbCfg.Type, ss.tracer) } ss.log.Info("Connecting to DB", "dbtype", ss.dbCfg.Type) - if ss.dbCfg.Type == migrator.SQLite && strings.HasPrefix(connectionString, "file:") && - !strings.HasPrefix(connectionString, "file::memory:") { + if ss.dbCfg.Type == migrator.SQLite && strings.HasPrefix(ss.dbCfg.ConnectionString, "file:") && + !strings.HasPrefix(ss.dbCfg.ConnectionString, "file::memory:") { exists, err := fs.Exists(ss.dbCfg.Path) if err != nil { return fmt.Errorf("can't check for existence of %q: %w", ss.dbCfg.Path, err) @@ -400,14 +297,14 @@ func (ss *SQLStore) initEngine(engine *xorm.Engine) error { } if engine == nil { var err error - engine, err = xorm.NewEngine(ss.dbCfg.Type, connectionString) + engine, err = xorm.NewEngine(ss.dbCfg.Type, ss.dbCfg.ConnectionString) if err != nil { return err } // Only for MySQL or MariaDB, verify we can connect with the current connection string's system var for transaction isolation. // If not, create a new engine with a compatible connection string. if ss.dbCfg.Type == migrator.MySQL { - engine, err = ss.ensureTransactionIsolationCompatibility(engine, connectionString) + engine, err = ss.ensureTransactionIsolationCompatibility(engine, ss.dbCfg.ConnectionString) if err != nil { return err } @@ -459,62 +356,6 @@ func (ss *SQLStore) ensureTransactionIsolationCompatibility(engine *xorm.Engine, return engine, nil } -// readConfig initializes the SQLStore from its configuration. -func (ss *SQLStore) readConfig() error { - sec := ss.Cfg.Raw.Section("database") - - cfgURL := sec.Key("url").String() - if len(cfgURL) != 0 { - dbURL, err := url.Parse(cfgURL) - if err != nil { - return err - } - ss.dbCfg.Type = dbURL.Scheme - ss.dbCfg.Host = dbURL.Host - - pathSplit := strings.Split(dbURL.Path, "/") - if len(pathSplit) > 1 { - ss.dbCfg.Name = pathSplit[1] - } - - userInfo := dbURL.User - if userInfo != nil { - ss.dbCfg.User = userInfo.Username() - ss.dbCfg.Pwd, _ = userInfo.Password() - } - - ss.dbCfg.UrlQueryParams = dbURL.Query() - } else { - ss.dbCfg.Type = sec.Key("type").String() - ss.dbCfg.Host = sec.Key("host").String() - ss.dbCfg.Name = sec.Key("name").String() - ss.dbCfg.User = sec.Key("user").String() - ss.dbCfg.ConnectionString = sec.Key("connection_string").String() - ss.dbCfg.Pwd = sec.Key("password").String() - } - - ss.dbCfg.MaxOpenConn = sec.Key("max_open_conn").MustInt(0) - ss.dbCfg.MaxIdleConn = sec.Key("max_idle_conn").MustInt(2) - ss.dbCfg.ConnMaxLifetime = sec.Key("conn_max_lifetime").MustInt(14400) - - ss.dbCfg.SslMode = sec.Key("ssl_mode").String() - ss.dbCfg.CaCertPath = sec.Key("ca_cert_path").String() - ss.dbCfg.ClientKeyPath = sec.Key("client_key_path").String() - ss.dbCfg.ClientCertPath = sec.Key("client_cert_path").String() - ss.dbCfg.ServerCertName = sec.Key("server_cert_name").String() - ss.dbCfg.Path = sec.Key("path").MustString("data/grafana.db") - ss.dbCfg.IsolationLevel = sec.Key("isolation_level").String() - - ss.dbCfg.CacheMode = sec.Key("cache_mode").MustString("private") - ss.dbCfg.WALEnabled = sec.Key("wal").MustBool(false) - ss.dbCfg.SkipMigrations = sec.Key("skip_migrations").MustBool() - ss.dbCfg.MigrationLockAttemptTimeout = sec.Key("locking_attempt_timeout_sec").MustInt() - - ss.dbCfg.QueryRetries = sec.Key("query_retries").MustInt() - ss.dbCfg.TransactionRetries = sec.Key("transaction_retries").MustInt(5) - return nil -} - func (ss *SQLStore) GetMigrationLockAttemptTimeout() int { return ss.dbCfg.MigrationLockAttemptTimeout } @@ -558,16 +399,10 @@ func (ss *SQLStore) RecursiveQueriesAreSupported() (bool, error) { return *ss.recursiveQueriesAreSupported, nil } -// ITestDB is an interface of arguments for testing db -type ITestDB interface { - Helper() - Fatalf(format string, args ...any) - Logf(format string, args ...any) - Log(args ...any) -} - +var testSQLStoreSetup = false var testSQLStore *SQLStore var testSQLStoreMutex sync.Mutex +var testSQLStoreCleanup []func() // InitTestDBOpt contains options for InitTestDB. type InitTestDBOpt struct { @@ -576,15 +411,11 @@ type InitTestDBOpt struct { FeatureFlags []string } -var featuresEnabledDuringTests = []string{ - featuremgmt.FlagPanelTitleSearch, - featuremgmt.FlagUnifiedStorage, -} - // InitTestDBWithMigration initializes the test DB given custom migrations. -func InitTestDBWithMigration(t ITestDB, migration registry.DatabaseMigrator, opts ...InitTestDBOpt) *SQLStore { +func InitTestDBWithMigration(t sqlutil.ITestDB, migration registry.DatabaseMigrator, opts ...InitTestDBOpt) *SQLStore { t.Helper() - store, err := initTestDB(setting.NewCfg(), migration, opts...) + features := getFeaturesForTesting(opts...) + store, err := initTestDB(t, setting.NewCfg(), features, migration, opts...) if err != nil { t.Fatalf("failed to initialize sql store: %s", err) } @@ -592,56 +423,106 @@ func InitTestDBWithMigration(t ITestDB, migration registry.DatabaseMigrator, opt } // InitTestDB initializes the test DB. -func InitTestDB(t ITestDB, opts ...InitTestDBOpt) *SQLStore { +func InitTestDB(t sqlutil.ITestDB, opts ...InitTestDBOpt) *SQLStore { t.Helper() - store, err := initTestDB(setting.NewCfg(), &migrations.OSSMigrations{}, opts...) + features := getFeaturesForTesting(opts...) + + store, err := initTestDB(t, setting.NewCfg(), features, migrations.ProvideOSSMigrations(features), opts...) if err != nil { t.Fatalf("failed to initialize sql store: %s", err) } return store } -func InitTestDBWithCfg(t ITestDB, opts ...InitTestDBOpt) (*SQLStore, *setting.Cfg) { - store := InitTestDB(t, opts...) - return store, store.Cfg +func SetupTestDB() { + testSQLStoreMutex.Lock() + defer testSQLStoreMutex.Unlock() + if testSQLStoreSetup { + fmt.Printf("ERROR: Test DB already set up, SetupTestDB called twice\n") + os.Exit(1) + } + testSQLStoreSetup = true } -//nolint:gocyclo -func initTestDB(testCfg *setting.Cfg, migration registry.DatabaseMigrator, opts ...InitTestDBOpt) (*SQLStore, error) { +func CleanupTestDB() { testSQLStoreMutex.Lock() defer testSQLStoreMutex.Unlock() + if !testSQLStoreSetup { + fmt.Printf("ERROR: Test DB not set up, SetupTestDB not called\n") + os.Exit(1) + } + if testSQLStore != nil { + if err := testSQLStore.GetEngine().Close(); err != nil { + fmt.Printf("Failed to close testSQLStore engine: %s\n", err) + } - if len(opts) == 0 { - opts = []InitTestDBOpt{{EnsureDefaultOrgAndUser: false, FeatureFlags: []string{}}} + for _, cleanup := range testSQLStoreCleanup { + cleanup() + } + + testSQLStoreCleanup = []func(){} + testSQLStore = nil } +} - features := make([]string, len(featuresEnabledDuringTests)) - copy(features, featuresEnabledDuringTests) +func getFeaturesForTesting(opts ...InitTestDBOpt) featuremgmt.FeatureToggles { + featureKeys := []any{ + featuremgmt.FlagPanelTitleSearch, + featuremgmt.FlagUnifiedStorage, + } for _, opt := range opts { if len(opt.FeatureFlags) > 0 { - features = append(features, opt.FeatureFlags...) + for _, f := range opt.FeatureFlags { + featureKeys = append(featureKeys, f) + } } } + return featuremgmt.WithFeatures(featureKeys...) +} - if testSQLStore == nil { - dbType := migrator.SQLite +//nolint:gocyclo +func initTestDB(t sqlutil.ITestDB, testCfg *setting.Cfg, + features featuremgmt.FeatureToggles, + migration registry.DatabaseMigrator, + opts ...InitTestDBOpt) (*SQLStore, error) { + testSQLStoreMutex.Lock() + defer testSQLStoreMutex.Unlock() + if !testSQLStoreSetup { + t.Fatalf(` - // environment variable present for test db? - if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { - dbType = db - } +ERROR: Test DB not set up, are you missing TestMain? + +https://github.com/grafana/grafana/blob/main/contribute/backend/style-guide.md + +Example: + +package mypkg + +import ( + "testing" + + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +`) + os.Exit(1) + } + + if len(opts) == 0 { + opts = []InitTestDBOpt{{EnsureDefaultOrgAndUser: false, FeatureFlags: []string{}}} + } + + if testSQLStore == nil { + dbType := sqlutil.GetTestDBType() // set test db config cfg := setting.NewCfg() // nolint:staticcheck - cfg.IsFeatureToggleEnabled = func(key string) bool { - for _, enabledFeature := range features { - if enabledFeature == key { - return true - } - } - return false - } + cfg.IsFeatureToggleEnabled = features.IsEnabledGlobally sec, err := cfg.Raw.NewSection("database") if err != nil { @@ -651,21 +532,21 @@ func initTestDB(testCfg *setting.Cfg, migration registry.DatabaseMigrator, opts if _, err := sec.NewKey("type", dbType); err != nil { return nil, err } - switch dbType { - case "mysql": - if _, err := sec.NewKey("connection_string", sqlutil.MySQLTestDB().ConnStr); err != nil { - return nil, err - } - case "postgres": - if _, err := sec.NewKey("connection_string", sqlutil.PostgresTestDB().ConnStr); err != nil { - return nil, err - } - default: - if _, err := sec.NewKey("connection_string", sqlutil.SQLite3TestDB().ConnStr); err != nil { - return nil, err - } + + testDB, err := sqlutil.GetTestDB(dbType) + if err != nil { + return nil, err + } + + if _, err := sec.NewKey("connection_string", testDB.ConnStr); err != nil { + return nil, err + } + if _, err := sec.NewKey("path", testDB.Path); err != nil { + return nil, err } + testSQLStoreCleanup = append(testSQLStoreCleanup, testDB.Cleanup) + // useful if you already have a database that you want to use for tests. // cannot just set it on testSQLStore as it overrides the config in Init if _, present := os.LookupEnv("SKIP_MIGRATIONS"); present { @@ -708,35 +589,10 @@ func initTestDB(testCfg *setting.Cfg, migration registry.DatabaseMigrator, opts if err := testSQLStore.Migrate(false); err != nil { return nil, err } - - if err := testSQLStore.Dialect.TruncateDBTables(engine); err != nil { - return nil, err - } - - if err := testSQLStore.Reset(); err != nil { - return nil, err - } - - // Make sure the changes are synced, so they get shared with eventual other DB connections - // XXX: Why is this only relevant when not skipping migrations? - if !testSQLStore.dbCfg.SkipMigrations { - if err := testSQLStore.Sync(); err != nil { - return nil, err - } - } - - return testSQLStore, nil } // nolint:staticcheck - testSQLStore.Cfg.IsFeatureToggleEnabled = func(key string) bool { - for _, enabledFeature := range features { - if enabledFeature == key { - return true - } - } - return false - } + testSQLStore.Cfg.IsFeatureToggleEnabled = features.IsEnabledGlobally if err := testSQLStore.Dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil { return nil, err @@ -747,55 +603,3 @@ func initTestDB(testCfg *setting.Cfg, migration registry.DatabaseMigrator, opts return testSQLStore, nil } - -func IsTestDbMySQL() bool { - if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { - return db == migrator.MySQL - } - - return false -} - -func IsTestDbPostgres() bool { - if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { - return db == migrator.Postgres - } - - return false -} - -func IsTestDBMSSQL() bool { - if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { - return db == migrator.MSSQL - } - - return false -} - -type DatabaseConfig struct { - Type string - Host string - Name string - User string - Pwd string - Path string - SslMode string - CaCertPath string - ClientKeyPath string - ClientCertPath string - ServerCertName string - ConnectionString string - IsolationLevel string - MaxOpenConn int - MaxIdleConn int - ConnMaxLifetime int - CacheMode string - WALEnabled bool - UrlQueryParams map[string][]string - SkipMigrations bool - MigrationLockAttemptTimeout int - // SQLite only - QueryRetries int - // SQLite only - TransactionRetries int -} diff --git a/pkg/services/sqlstore/sqlstore_test.go b/pkg/services/sqlstore/sqlstore_test.go index 83154c62eefc8..54da359d9f00b 100644 --- a/pkg/services/sqlstore/sqlstore_test.go +++ b/pkg/services/sqlstore/sqlstore_test.go @@ -2,124 +2,21 @@ package sqlstore import ( "context" - "errors" - "net/url" + "os" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/setting" ) -type sqlStoreTest struct { - name string - dbType string - dbHost string - dbURL string - dbUser string - dbPwd string - expConnStr string - features featuremgmt.FeatureToggles - err error -} - -var sqlStoreTestCases = []sqlStoreTest{ - { - name: "MySQL IPv4", - dbType: "mysql", - dbHost: "1.2.3.4:5678", - expConnStr: ":@tcp(1.2.3.4:5678)/test_db?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true", - }, - { - name: "Postgres IPv4", - dbType: "postgres", - dbHost: "1.2.3.4:5678", - expConnStr: "user='' host=1.2.3.4 port=5678 dbname=test_db sslmode='' sslcert='' sslkey='' sslrootcert=''", - }, - { - name: "Postgres IPv4 (Default Port)", - dbType: "postgres", - dbHost: "1.2.3.4", - expConnStr: "user='' host=1.2.3.4 port=5432 dbname=test_db sslmode='' sslcert='' sslkey='' sslrootcert=''", - }, - { - name: "Postgres username and password", - dbType: "postgres", - dbHost: "1.2.3.4", - dbUser: "grafana", - dbPwd: "password", - expConnStr: "user=grafana host=1.2.3.4 port=5432 dbname=test_db sslmode='' sslcert='' sslkey='' sslrootcert='' password=password", - }, - { - name: "Postgres username no password", - dbType: "postgres", - dbHost: "1.2.3.4", - dbUser: "grafana", - dbPwd: "", - expConnStr: "user=grafana host=1.2.3.4 port=5432 dbname=test_db sslmode='' sslcert='' sslkey='' sslrootcert=''", - }, - { - name: "MySQL IPv4 (Default Port)", - dbType: "mysql", - dbHost: "1.2.3.4", - expConnStr: ":@tcp(1.2.3.4)/test_db?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true", - }, - { - name: "MySQL IPv6", - dbType: "mysql", - dbHost: "[fe80::24e8:31b2:91df:b177]:1234", - expConnStr: ":@tcp([fe80::24e8:31b2:91df:b177]:1234)/test_db?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true", - }, - { - name: "Postgres IPv6", - dbType: "postgres", - dbHost: "[fe80::24e8:31b2:91df:b177]:1234", - expConnStr: "user='' host=fe80::24e8:31b2:91df:b177 port=1234 dbname=test_db sslmode='' sslcert='' sslkey='' sslrootcert=''", - }, - { - name: "MySQL IPv6 (Default Port)", - dbType: "mysql", - dbHost: "[::1]", - expConnStr: ":@tcp([::1])/test_db?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true", - }, - { - name: "Postgres IPv6 (Default Port)", - dbType: "postgres", - dbHost: "[::1]", - expConnStr: "user='' host=::1 port=5432 dbname=test_db sslmode='' sslcert='' sslkey='' sslrootcert=''", - }, - { - name: "Invalid database URL", - dbURL: "://invalid.com/", - err: &url.Error{Op: "parse", URL: "://invalid.com/", Err: errors.New("missing protocol scheme")}, - }, - { - name: "MySQL with ANSI_QUOTES mode", - dbType: "mysql", - dbHost: "[::1]", - features: featuremgmt.WithFeatures(featuremgmt.FlagMysqlAnsiQuotes), - expConnStr: ":@tcp([::1])/test_db?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true&sql_mode='ANSI_QUOTES'", - }, -} - -func TestIntegrationSQLConnectionString(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - for _, testCase := range sqlStoreTestCases { - t.Run(testCase.name, func(t *testing.T) { - sqlstore := &SQLStore{} - sqlstore.Cfg = makeSQLStoreTestConfig(t, testCase) - connStr, err := sqlstore.buildConnectionString() - require.Equal(t, testCase.err, err) - - assert.Equal(t, testCase.expConnStr, connStr) - }) - } +func TestMain(m *testing.M) { + SetupTestDB() + code := m.Run() + CleanupTestDB() + os.Exit(code) } func TestIntegrationIsUniqueConstraintViolation(t *testing.T) { @@ -169,30 +66,3 @@ func TestIntegrationIsUniqueConstraintViolation(t *testing.T) { }) } } - -func makeSQLStoreTestConfig(t *testing.T, tc sqlStoreTest) *setting.Cfg { - t.Helper() - - if tc.features == nil { - tc.features = featuremgmt.WithFeatures() - } - // nolint:staticcheck - cfg := setting.NewCfgWithFeatures(tc.features.IsEnabledGlobally) - - sec, err := cfg.Raw.NewSection("database") - require.NoError(t, err) - _, err = sec.NewKey("type", tc.dbType) - require.NoError(t, err) - _, err = sec.NewKey("host", tc.dbHost) - require.NoError(t, err) - _, err = sec.NewKey("url", tc.dbURL) - require.NoError(t, err) - _, err = sec.NewKey("user", tc.dbUser) - require.NoError(t, err) - _, err = sec.NewKey("name", "test_db") - require.NoError(t, err) - _, err = sec.NewKey("password", tc.dbPwd) - require.NoError(t, err) - - return cfg -} diff --git a/pkg/services/sqlstore/sqlutil/sqlutil.go b/pkg/services/sqlstore/sqlutil/sqlutil.go index 2207bdd1450e6..38bf06b2476a0 100644 --- a/pkg/services/sqlstore/sqlutil/sqlutil.go +++ b/pkg/services/sqlstore/sqlutil/sqlutil.go @@ -1,25 +1,123 @@ package sqlutil import ( + "errors" "fmt" + "io/fs" "os" + "path/filepath" ) +// ITestDB is an interface of arguments for testing db +type ITestDB interface { + Helper() + Fatalf(format string, args ...any) + Logf(format string, args ...any) + Log(args ...any) + Cleanup(func()) +} + type TestDB struct { DriverName string ConnStr string + Path string + Cleanup func() } -func SQLite3TestDB() TestDB { - // To run all tests in a local test database, set ConnStr to "grafana_test.db" - return TestDB{ +func GetTestDBType() string { + dbType := "sqlite3" + + // environment variable present for test db? + if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { + dbType = db + } + return dbType +} + +func GetTestDB(dbType string) (*TestDB, error) { + switch dbType { + case "mysql": + return mySQLTestDB() + case "postgres": + return postgresTestDB() + case "sqlite3": + return sqLite3TestDB() + } + + return nil, fmt.Errorf("unknown test db type: %s", dbType) +} + +func sqLite3TestDB() (*TestDB, error) { + if os.Getenv("SQLITE_INMEMORY") == "true" { + return &TestDB{ + DriverName: "sqlite3", + ConnStr: "file::memory:", + Cleanup: func() {}, + }, nil + } + + ret := &TestDB{ DriverName: "sqlite3", - // ConnStr specifies an In-memory database shared between connections. - ConnStr: "file::memory:?cache=shared", + Cleanup: func() {}, } + + sqliteDb := os.Getenv("SQLITE_TEST_DB") + if sqliteDb == "" { + // try to create a database file in the user's cache directory + dir, err := os.UserCacheDir() + if err != nil { + return nil, err + } + + // if cache dir doesn't exist, fall back to temp dir + if _, err := os.Stat(dir); errors.Is(err, fs.ErrNotExist) { + dir = os.TempDir() + if _, err := os.Stat(dir); err != nil { + return nil, err + } + } + + err = os.Mkdir(filepath.Join(dir, "grafana-test"), 0750) + if err != nil && !errors.Is(err, fs.ErrExist) { + return nil, err + } + + f, err := os.CreateTemp(filepath.Join(dir, "grafana-test"), "grafana-test-*.db") + if err != nil { + return nil, err + } + + sqliteDb = f.Name() + + ret.Cleanup = func() { + // remove db file if it exists + err := os.Remove(sqliteDb) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + fmt.Printf("Error removing sqlite db file %s: %v\n", sqliteDb, err) + } + + // remove wal & shm files if they exist + err = os.Remove(sqliteDb + "-wal") + if err != nil && !errors.Is(err, fs.ErrNotExist) { + fmt.Printf("Error removing sqlite wal file %s: %v\n", sqliteDb+"-wal", err) + } + err = os.Remove(sqliteDb + "-shm") + if err != nil && !errors.Is(err, fs.ErrNotExist) { + fmt.Printf("Error removing sqlite shm file %s: %v\n", sqliteDb+"-shm", err) + } + } + } + + ret.ConnStr = "file:" + sqliteDb + "?cache=private&mode=rwc" + if os.Getenv("SQLITE_JOURNAL_MODE") != "false" { + ret.ConnStr += "&_journal_mode=WAL" + } + ret.Path = sqliteDb + + return ret, nil } -func MySQLTestDB() TestDB { +func mySQLTestDB() (*TestDB, error) { host := os.Getenv("MYSQL_HOST") if host == "" { host = "localhost" @@ -29,13 +127,14 @@ func MySQLTestDB() TestDB { port = "3306" } conn_str := fmt.Sprintf("grafana:password@tcp(%s:%s)/grafana_tests?collation=utf8mb4_unicode_ci&sql_mode='ANSI_QUOTES'&parseTime=true", host, port) - return TestDB{ + return &TestDB{ DriverName: "mysql", ConnStr: conn_str, - } + Cleanup: func() {}, + }, nil } -func PostgresTestDB() TestDB { +func postgresTestDB() (*TestDB, error) { host := os.Getenv("POSTGRES_HOST") if host == "" { host = "localhost" @@ -44,25 +143,10 @@ func PostgresTestDB() TestDB { if port == "" { port = "5432" } - connStr := fmt.Sprintf("user=grafanatest password=grafanatest host=%s port=%s dbname=grafanatest sslmode=disable", - host, port) - return TestDB{ + connStr := fmt.Sprintf("user=grafanatest password=grafanatest host=%s port=%s dbname=grafanatest sslmode=disable", host, port) + return &TestDB{ DriverName: "postgres", ConnStr: connStr, - } -} - -func MSSQLTestDB() TestDB { - host := os.Getenv("MSSQL_HOST") - if host == "" { - host = "localhost" - } - port := os.Getenv("MSSQL_PORT") - if port == "" { - port = "1433" - } - return TestDB{ - DriverName: "mssql", - ConnStr: fmt.Sprintf("server=%s;port=%s;database=grafanatest;user id=grafana;password=Password!", host, port), - } + Cleanup: func() {}, + }, nil } diff --git a/pkg/services/sqlstore/tls_mysql.go b/pkg/services/sqlstore/tls_mysql.go index bb7baf2f4e648..d54b065da1e28 100644 --- a/pkg/services/sqlstore/tls_mysql.go +++ b/pkg/services/sqlstore/tls_mysql.go @@ -11,7 +11,7 @@ import ( var tlslog = log.New("tls_mysql") -func makeCert(config DatabaseConfig) (*tls.Config, error) { +func makeCert(config *DatabaseConfig) (*tls.Config, error) { rootCertPool := x509.NewCertPool() pem, err := os.ReadFile(config.CaCertPath) if err != nil { diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 591b485482e3f..169bbe7ee3fb6 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -88,11 +88,11 @@ func (ss *SQLStore) createUser(ctx context.Context, sess *DBSession, args user.C usr.Rands = rands if len(args.Password) > 0 { - encodedPassword, err := util.EncodePassword(args.Password, usr.Salt) + encodedPassword, err := util.EncodePassword(string(args.Password), usr.Salt) if err != nil { return usr, err } - usr.Password = encodedPassword + usr.Password = user.Password(encodedPassword) } sess.UseBool("is_admin") diff --git a/pkg/services/ssosettings/api/api.go b/pkg/services/ssosettings/api/api.go index 72ea39928545a..d45c2d0db577b 100644 --- a/pkg/services/ssosettings/api/api.go +++ b/pkg/services/ssosettings/api/api.go @@ -2,7 +2,9 @@ package api import ( "context" - "errors" + "encoding/json" + "fmt" + "hash/fnv" "net/http" "github.com/grafana/grafana/pkg/api/response" @@ -40,6 +42,22 @@ func ProvideApi( return api } +// generateFNVETag computes a FNV hash-based ETag for the SSOSettings struct +func generateFNVETag(input any) (string, error) { + hasher := fnv.New64() + data, err := json.Marshal(input) + if err != nil { + return "", err + } + + _, err = hasher.Write(data) + if err != nil { + return "", err + } + + return fmt.Sprintf("%x", hasher.Sum(nil)), nil +} + // RegisterAPIEndpoints Registers Endpoints on Grafana Router func (api *Api) RegisterAPIEndpoints() { api.RouteRegister.Group("/api/v1/sso-settings", func(router routing.RouteRegister) { @@ -57,17 +75,28 @@ func (api *Api) RegisterAPIEndpoints() { }) } +// swagger:route GET /v1/sso-settings sso_settings listAllProvidersSettings +// +// # List all SSO Settings entries +// +// You need to have a permission with action `settings:read` with scope `settings:auth.<provider>:*`. +// +// Responses: +// 200: listSSOSettingsResponse +// 400: badRequestError +// 401: unauthorisedError +// 403: forbiddenError func (api *Api) listAllProvidersSettings(c *contextmodel.ReqContext) response.Response { providers, err := api.getAuthorizedList(c.Req.Context(), c.SignedInUser) if err != nil { - return response.Error(http.StatusInternalServerError, "Failed to get providers", err) + return response.Error(http.StatusInternalServerError, "Failed to list all providers settings", err) } return response.JSON(http.StatusOK, providers) } func (api *Api) getAuthorizedList(ctx context.Context, identity identity.Requester) ([]*models.SSOSettings, error) { - allProviders, err := api.SSOSettingsService.List(ctx) + allProviders, err := api.SSOSettingsService.ListWithRedactedSecrets(ctx) if err != nil { return nil, err } @@ -91,18 +120,35 @@ func (api *Api) getAuthorizedList(ctx context.Context, identity identity.Request return authorizedProviders, nil } +// swagger:route GET /v1/sso-settings/{key} sso_settings getProviderSettings +// +// # Get an SSO Settings entry by Key +// +// You need to have a permission with action `settings:read` with scope `settings:auth.<provider>:*`. +// +// Responses: +// 200: getSSOSettingsResponse +// 400: badRequestError +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError func (api *Api) getProviderSettings(c *contextmodel.ReqContext) response.Response { key, ok := web.Params(c.Req)[":key"] if !ok { return response.Error(http.StatusBadRequest, "Missing key", nil) } - settings, err := api.SSOSettingsService.GetForProvider(c.Req.Context(), key) + provider, err := api.SSOSettingsService.GetForProviderWithRedactedSecrets(c.Req.Context(), key) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to get provider settings", err) + } + + etag, err := generateFNVETag(provider) if err != nil { - return response.Error(http.StatusNotFound, "The provider was not found", err) + return response.Error(http.StatusInternalServerError, "Failed to get provider settings", err) } - return response.JSON(http.StatusOK, settings) + return response.JSON(http.StatusOK, provider).SetHeader("ETag", etag) } // swagger:route PUT /v1/sso-settings/{key} sso_settings updateProviderSettings @@ -132,15 +178,12 @@ func (api *Api) updateProviderSettings(c *contextmodel.ReqContext) response.Resp settings.Provider = key - err := api.SSOSettingsService.Upsert(c.Req.Context(), settings) - // TODO: first check whether the error is referring to validation errors - - // other error + err := api.SSOSettingsService.Upsert(c.Req.Context(), &settings, c.SignedInUser) if err != nil { - return response.Error(http.StatusInternalServerError, "Failed to update provider settings", err) + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to update provider settings", err) } - return response.JSON(http.StatusNoContent, nil) + return response.Empty(http.StatusNoContent) } // swagger:route DELETE /v1/sso-settings/{key} sso_settings removeProviderSettings @@ -156,6 +199,7 @@ func (api *Api) updateProviderSettings(c *contextmodel.ReqContext) response.Resp // 400: badRequestError // 401: unauthorisedError // 403: forbiddenError +// 404: notFoundError // 500: internalServerError func (api *Api) removeProviderSettings(c *contextmodel.ReqContext) response.Response { key, ok := web.Params(c.Req)[":key"] @@ -165,13 +209,21 @@ func (api *Api) removeProviderSettings(c *contextmodel.ReqContext) response.Resp err := api.SSOSettingsService.Delete(c.Req.Context(), key) if err != nil { - if errors.Is(err, ssosettings.ErrNotFound) { - return response.Error(http.StatusNotFound, "The provider was not found", err) - } - return response.Error(http.StatusInternalServerError, "Failed to delete provider settings", err) + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to delete provider settings", err) } - return response.JSON(http.StatusNoContent, nil) + return response.Empty(http.StatusNoContent) +} + +// swagger:parameters listAllProvidersSettings +type ListAllProvidersSettingsParams struct { +} + +// swagger:parameters getProviderSettings +type GetProviderSettingsParams struct { + // in:path + // required:true + Provider string `json:"key"` } // swagger:parameters updateProviderSettings @@ -181,7 +233,11 @@ type UpdateProviderSettingsParams struct { Provider string `json:"key"` // in:body // required:true - Body models.SSOSettings `json:"body"` + Body struct { + ID string `json:"id"` + Provider string `json:"provider"` + Settings map[string]any `json:"settings"` + } `json:"body"` } // swagger:parameters removeProviderSettings @@ -190,3 +246,25 @@ type RemoveProviderSettingsParams struct { // required:true Provider string `json:"key"` } + +// swagger:response listSSOSettingsResponse +type ListSSOSettingsResponse struct { + // in: body + Body []struct { + ID string `json:"id"` + Provider string `json:"provider"` + Settings map[string]any `json:"settings"` + Source string `json:"source"` + } `json:"body"` +} + +// swagger:response getSSOSettingsResponse +type GetSSOSettingsResponse struct { + // in: body + Body struct { + ID string `json:"id"` + Provider string `json:"provider"` + Settings map[string]any `json:"settings"` + Source string `json:"source"` + } `json:"body"` +} diff --git a/pkg/services/ssosettings/api/api_test.go b/pkg/services/ssosettings/api/api_test.go index 153d125c6b433..2530f3cf0aa72 100644 --- a/pkg/services/ssosettings/api/api_test.go +++ b/pkg/services/ssosettings/api/api_test.go @@ -1,26 +1,161 @@ package api import ( + "bytes" + "encoding/json" "errors" "fmt" + "io" "net/http" "testing" + "time" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/ssosettings" + "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web/webtest" ) +func TestSSOSettingsAPI_Update(t *testing.T) { + type TestCase struct { + desc string + key string + body string + action string + scope string + expectedError error + expectedServiceCall bool + expectedStatusCode int + } + + tests := []TestCase{ + { + desc: "successfully updates SSO settings", + key: social.GitHubProviderName, + body: `{"settings": {"enabled": true}}`, + action: "settings:write", + scope: "settings:auth.github:*", + expectedError: nil, + expectedServiceCall: true, + expectedStatusCode: http.StatusNoContent, + }, + { + desc: "fails when action doesn't match", + key: social.GitHubProviderName, + body: `{"settings": {"enabled": true}}`, + action: "settings:read", + scope: "settings:auth.github:*", + expectedError: nil, + expectedServiceCall: false, + expectedStatusCode: http.StatusForbidden, + }, + { + desc: "fails when scope doesn't match", + key: social.GitHubProviderName, + body: `{"settings": {"enabled": true}}`, + action: "settings:write", + scope: "settings:auth.github:read", + expectedError: nil, + expectedServiceCall: false, + expectedStatusCode: http.StatusForbidden, + }, + { + desc: "fails when scope contains another provider", + key: social.GitHubProviderName, + body: `{"settings": {"enabled": true}}`, + action: "settings:write", + scope: "settings:auth.okta:*", + expectedError: nil, + expectedServiceCall: false, + expectedStatusCode: http.StatusForbidden, + }, + { + desc: "fails with not found when key is empty", + key: "", + body: `{"settings": {"enabled": true}}`, + action: "settings:write", + scope: "settings:auth.github:*", + expectedError: nil, + expectedServiceCall: false, + expectedStatusCode: http.StatusNotFound, + }, + { + desc: "fails with bad request when body contains invalid json", + key: social.GitHubProviderName, + body: `{ invalid json }`, + action: "settings:write", + scope: "settings:auth.github:*", + expectedError: nil, + expectedServiceCall: false, + expectedStatusCode: http.StatusBadRequest, + }, + { + desc: "fails with bad request when key was not found", + key: social.GitHubProviderName, + body: `{"settings": {"enabled": true}}`, + action: "settings:write", + scope: "settings:auth.github:*", + expectedError: ssosettings.ErrInvalidProvider.Errorf("invalid provider"), + expectedServiceCall: true, + expectedStatusCode: http.StatusBadRequest, + }, + { + desc: "fails with internal server error when service returns an error", + key: social.GitHubProviderName, + body: `{"settings": {"enabled": true}}`, + action: "settings:write", + scope: "settings:auth.github:*", + expectedError: errors.New("something went wrong"), + expectedServiceCall: true, + expectedStatusCode: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + var input models.SSOSettings + _ = json.Unmarshal([]byte(tt.body), &input) + + settings := models.SSOSettings{ + Provider: tt.key, + Settings: input.Settings, + } + + signedInUser := &user.SignedInUser{ + OrgRole: org.RoleAdmin, + OrgID: 1, + Permissions: getPermissionsForActionAndScope(tt.action, tt.scope), + } + + service := ssosettingstests.NewMockService(t) + if tt.expectedServiceCall { + service.On("Upsert", mock.Anything, &settings, signedInUser).Return(tt.expectedError).Once() + } + server := setupTests(t, service) + + path := fmt.Sprintf("/api/v1/sso-settings/%s", tt.key) + req := server.NewRequest(http.MethodPut, path, bytes.NewBufferString(tt.body)) + webtest.RequestWithSignedInUser(req, signedInUser) + res, err := server.SendJSON(req) + require.NoError(t, err) + + require.Equal(t, tt.expectedStatusCode, res.StatusCode) + require.NoError(t, res.Body.Close()) + }) + } +} + func TestSSOSettingsAPI_Delete(t *testing.T) { type TestCase struct { desc string @@ -35,7 +170,7 @@ func TestSSOSettingsAPI_Delete(t *testing.T) { tests := []TestCase{ { desc: "successfully deletes SSO settings", - key: "azuread", + key: social.AzureADProviderName, action: "settings:write", scope: "settings:auth.azuread:*", expectedError: nil, @@ -44,7 +179,7 @@ func TestSSOSettingsAPI_Delete(t *testing.T) { }, { desc: "fails when action doesn't match", - key: "azuread", + key: social.AzureADProviderName, action: "settings:read", scope: "settings:auth.azuread:*", expectedError: nil, @@ -53,7 +188,7 @@ func TestSSOSettingsAPI_Delete(t *testing.T) { }, { desc: "fails when scope doesn't match", - key: "azuread", + key: social.AzureADProviderName, action: "settings:write", scope: "settings:auth.azuread:read", expectedError: nil, @@ -62,7 +197,7 @@ func TestSSOSettingsAPI_Delete(t *testing.T) { }, { desc: "fails when scope contains another provider", - key: "azuread", + key: social.AzureADProviderName, action: "settings:write", scope: "settings:auth.github:*", expectedError: nil, @@ -80,7 +215,7 @@ func TestSSOSettingsAPI_Delete(t *testing.T) { }, { desc: "fails with not found when key was not found", - key: "azuread", + key: social.AzureADProviderName, action: "settings:write", scope: "settings:auth.azuread:*", expectedError: ssosettings.ErrNotFound, @@ -89,7 +224,7 @@ func TestSSOSettingsAPI_Delete(t *testing.T) { }, { desc: "fails with internal server error when service returns an error", - key: "azuread", + key: social.AzureADProviderName, action: "settings:write", scope: "settings:auth.azuread:*", expectedError: errors.New("something went wrong"), @@ -122,6 +257,297 @@ func TestSSOSettingsAPI_Delete(t *testing.T) { } } +func TestSSOSettingsAPI_GetForProvider(t *testing.T) { + type TestCase struct { + desc string + key string + action string + scope string + expectedResult *models.SSOSettings + expectedError error + expectedServiceCall bool + expectedStatusCode int + } + + tests := []TestCase{ + { + desc: "successfully gets SSO settings", + key: "azuread", + action: "settings:read", + scope: "settings:auth.azuread:*", + expectedResult: &models.SSOSettings{ + ID: "1", + Provider: "azuread", + Settings: make(map[string]interface{}), + Created: time.Now(), + Updated: time.Now(), + IsDeleted: false, + Source: models.DB, + }, + expectedError: nil, + expectedServiceCall: true, + expectedStatusCode: http.StatusOK, + }, + { + desc: "fails when action doesn't match", + key: "azuread", + action: "settings:write", + scope: "settings:auth.azuread:*", + expectedResult: nil, + expectedError: nil, + expectedServiceCall: false, + expectedStatusCode: http.StatusForbidden, + }, + { + desc: "fails when scope doesn't match", + key: "azuread", + action: "settings:read", + scope: "settings:auth.azuread:write", + expectedResult: nil, + expectedError: nil, + expectedServiceCall: false, + expectedStatusCode: http.StatusForbidden, + }, + { + desc: "fails when scope contains another provider", + key: "azuread", + action: "settings:read", + scope: "settings:auth.github:*", + expectedResult: nil, + expectedError: nil, + expectedServiceCall: false, + expectedStatusCode: http.StatusForbidden, + }, + { + desc: "fails with not found when key was not found", + key: "nonexistant", + action: "settings:read", + scope: "settings:auth.nonexistant:*", + expectedResult: nil, + expectedError: ssosettings.ErrNotFound, + expectedServiceCall: true, + expectedStatusCode: http.StatusNotFound, + }, + { + desc: "fails with internal server error when service returns an error", + key: "azuread", + action: "settings:read", + scope: "settings:auth.azuread:*", + expectedResult: nil, + expectedError: errors.New("something went wrong"), + expectedServiceCall: true, + expectedStatusCode: http.StatusInternalServerError, + }, + { + desc: "fails with not found error when the provider is not configurable", + key: "grafana_com", + action: "settings:read", + scope: "settings:*", + expectedResult: nil, + expectedError: ssosettings.ErrNotConfigurable, + expectedServiceCall: true, + expectedStatusCode: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + service := ssosettingstests.NewMockService(t) + if tt.expectedServiceCall { + service.On("GetForProviderWithRedactedSecrets", mock.AnythingOfType("*context.valueCtx"), tt.key).Return(tt.expectedResult, tt.expectedError).Once() + } + server := setupTests(t, service) + + path := fmt.Sprintf("/api/v1/sso-settings/%s", tt.key) + req := server.NewRequest(http.MethodGet, path, nil) + webtest.RequestWithSignedInUser(req, &user.SignedInUser{ + OrgRole: org.RoleEditor, + OrgID: 1, + Permissions: getPermissionsForActionAndScope(tt.action, tt.scope), + }) + res, err := server.SendJSON(req) + require.NoError(t, err) + + require.Equal(t, tt.expectedStatusCode, res.StatusCode) + + if tt.expectedError == nil { + var data models.SSOSettings + require.NoError(t, json.NewDecoder(res.Body).Decode(&data)) + } + + require.NoError(t, res.Body.Close()) + }) + } +} + +func TestSSOSettingsAPI_List(t *testing.T) { + type TestCase struct { + desc string + action string + scope string + expectedResult []*models.SSOSettings + errFromService error + wantErr bool + expectedErrMessage string + expectedServiceCall bool + expectedStatusCode int + } + + tests := []TestCase{ + { + desc: "successfully lists SSO settings", + action: "settings:read", + scope: "settings:auth.azuread:*", + expectedResult: []*models.SSOSettings{ + { + ID: "1", + Provider: "azuread", + Settings: make(map[string]interface{}), + Source: models.DB, + }, + }, + expectedServiceCall: true, + expectedStatusCode: http.StatusOK, + }, + { + desc: "returns empty list when the user has the action but the scope doesn't match any of the providerss scope", + action: "settings:read", + scope: "settings:auth.saml:write", + expectedResult: []*models.SSOSettings{}, + expectedServiceCall: true, + expectedStatusCode: http.StatusOK, + }, + { + desc: "successfully lists SSO settings when scope contains wildcard", + action: "settings:read", + scope: "settings:*", + expectedResult: []*models.SSOSettings{ + { + ID: "1", + Provider: "azuread", + Settings: make(map[string]interface{}), + Source: models.DB, + }, + { + ID: "2", + Provider: "github", + Settings: make(map[string]interface{}), + Source: models.DB, + }, + { + ID: "3", + Provider: "okta", + Settings: make(map[string]interface{}), + Source: models.System, + }, + }, + expectedServiceCall: true, + expectedStatusCode: http.StatusOK, + }, + { + desc: "fails when action doesn't match", + action: "madeupaction:read", + scope: "madeupscope:*", + wantErr: true, + expectedErrMessage: "You'll need additional permissions to perform this action. Permissions needed: settings:read", + expectedResult: nil, + expectedServiceCall: false, + expectedStatusCode: http.StatusForbidden, + }, + { + desc: "fails with internal server error when service returns an error", + action: "settings:read", + scope: "settings:auth.azuread:*", + errFromService: errors.New("something went wrong"), + expectedResult: nil, + wantErr: true, + expectedErrMessage: "Failed to list all providers settings", + expectedServiceCall: true, + expectedStatusCode: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + service := ssosettingstests.NewMockService(t) + + serviceResult := []*models.SSOSettings{ + { + ID: "1", + Provider: "azuread", + Settings: make(map[string]interface{}), + Created: time.Now(), + Updated: time.Now(), + IsDeleted: false, + Source: models.DB, + }, + { + ID: "2", + Provider: "github", + Settings: make(map[string]interface{}), + Created: time.Now(), + Updated: time.Now(), + IsDeleted: false, + Source: models.DB, + }, + { + ID: "3", + Provider: "okta", + Settings: make(map[string]interface{}), + Created: time.Now(), + Updated: time.Now(), + IsDeleted: false, + Source: models.System, + }, + } + if tt.expectedServiceCall { + service.On("ListWithRedactedSecrets", mock.AnythingOfType("*context.valueCtx")).Return(serviceResult, tt.errFromService).Once() + } + server := setupTests(t, service) + + path := "/api/v1/sso-settings" + req := server.NewRequest(http.MethodGet, path, nil) + webtest.RequestWithSignedInUser(req, &user.SignedInUser{ + OrgRole: org.RoleEditor, + OrgID: 1, + Permissions: getPermissionsForActionAndScope(tt.action, tt.scope), + }) + res, err := server.SendJSON(req) + require.NoError(t, err) + + require.Equal(t, tt.expectedStatusCode, res.StatusCode) + + bodyBytes, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + + if tt.wantErr { + var accessErrorResponse struct { + AccessErrorID string `json:"accessErrorId"` + Message string `json:"message"` + Title string `json:"title"` + } + err = json.Unmarshal(bodyBytes, &accessErrorResponse) + if err != nil { + t.Fatalf("Failed to unmarshal response body into accessErrorResponse: %v", err) + } + + require.Equal(t, tt.expectedErrMessage, accessErrorResponse.Message) + return + } + + var actual []*models.SSOSettings + err = json.Unmarshal(bodyBytes, &actual) + require.NoError(t, err) + + require.ElementsMatch(t, tt.expectedResult, actual) + err = res.Body.Close() + require.NoError(t, err) + }) + } +} + func getPermissionsForActionAndScope(action, scope string) map[int64]map[string][]string { return map[int64]map[string][]string{ 1: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{{ diff --git a/pkg/services/ssosettings/database/database.go b/pkg/services/ssosettings/database/database.go index 8d971e8a42328..320c248a07de4 100644 --- a/pkg/services/ssosettings/database/database.go +++ b/pkg/services/ssosettings/database/database.go @@ -12,6 +12,11 @@ import ( "github.com/grafana/grafana/pkg/services/ssosettings/models" ) +const ( + isDeletedColumn = "is_deleted" + updatedColumn = "updated" +) + type SSOSettingsStore struct { sqlStore db.DB log log.Logger @@ -27,12 +32,17 @@ func ProvideStore(sqlStore db.DB) *SSOSettingsStore { var _ ssosettings.Store = (*SSOSettingsStore)(nil) func (s *SSOSettingsStore) Get(ctx context.Context, provider string) (*models.SSOSettings, error) { - result := models.SSOSettings{Provider: provider} - err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { - var err error - sess.Table("sso_setting") - found, err := sess.Where("is_deleted = ?", s.sqlStore.GetDialect().BooleanStr(false)).Get(&result) + if provider == "" { + return nil, ssosettings.ErrNotFound + } + result := models.SSOSettings{ + Provider: provider, + IsDeleted: false, + } + + err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + found, err := sess.UseBool(isDeletedColumn).Get(&result) if err != nil { return err } @@ -53,10 +63,13 @@ func (s *SSOSettingsStore) Get(ctx context.Context, provider string) (*models.SS func (s *SSOSettingsStore) List(ctx context.Context) ([]*models.SSOSettings, error) { result := make([]*models.SSOSettings, 0) + err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { - sess.Table("sso_setting") - err := sess.Where("is_deleted = ?", s.sqlStore.GetDialect().BooleanStr(false)).Find(&result) + condition := &models.SSOSettings{ + IsDeleted: false, + } + err := sess.UseBool(isDeletedColumn).Find(&result, condition) if err != nil { return err } @@ -71,13 +84,18 @@ func (s *SSOSettingsStore) List(ctx context.Context) ([]*models.SSOSettings, err return result, nil } -func (s *SSOSettingsStore) Upsert(ctx context.Context, settings models.SSOSettings) error { +func (s *SSOSettingsStore) Upsert(ctx context.Context, settings *models.SSOSettings) error { + if settings.Provider == "" { + return ssosettings.ErrNotFound + } + return s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { existing := &models.SSOSettings{ Provider: settings.Provider, IsDeleted: false, } - found, err := sess.UseBool("is_deleted").Exist(existing) + + found, err := sess.UseBool(isDeletedColumn).Exist(existing) if err != nil { return err } @@ -90,33 +108,30 @@ func (s *SSOSettingsStore) Upsert(ctx context.Context, settings models.SSOSettin Updated: now, IsDeleted: false, } - _, err = sess.UseBool("is_deleted").Update(updated, existing) + _, err = sess.UseBool(isDeletedColumn).Update(updated, existing) } else { - _, err = sess.Insert(&models.SSOSettings{ - ID: uuid.New().String(), - Provider: settings.Provider, - Settings: settings.Settings, - Created: now, - Updated: now, - }) + settings.ID = uuid.New().String() + settings.Created = now + settings.Updated = now + _, err = sess.Insert(settings) } return err }) } -func (s *SSOSettingsStore) Patch(ctx context.Context, provider string, data map[string]interface{}) error { - panic("not implemented") // TODO: Implement -} - func (s *SSOSettingsStore) Delete(ctx context.Context, provider string) error { + if provider == "" { + return ssosettings.ErrNotFound + } + return s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { existing := &models.SSOSettings{ Provider: provider, IsDeleted: false, } - found, err := sess.UseBool("is_deleted").Get(existing) + found, err := sess.UseBool(isDeletedColumn).Get(existing) if err != nil { return err } @@ -128,7 +143,7 @@ func (s *SSOSettingsStore) Delete(ctx context.Context, provider string) error { existing.Updated = time.Now().UTC() existing.IsDeleted = true - _, err = sess.ID(existing.ID).MustCols("updated", "is_deleted").Update(existing) + _, err = sess.ID(existing.ID).MustCols(updatedColumn, isDeletedColumn).Update(existing) return err }) } diff --git a/pkg/services/ssosettings/database/database_test.go b/pkg/services/ssosettings/database/database_test.go index 5e8f1c6efe115..cc937721b035e 100644 --- a/pkg/services/ssosettings/database/database_test.go +++ b/pkg/services/ssosettings/database/database_test.go @@ -12,12 +12,17 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/ssosettings" "github.com/grafana/grafana/pkg/services/ssosettings/models" + "github.com/grafana/grafana/pkg/tests/testsuite" ) const ( withinDuration = 5 * time.Minute ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationGetSSOSettings(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -33,7 +38,7 @@ func TestIntegrationGetSSOSettings(t *testing.T) { template := models.SSOSettings{ Settings: map[string]any{"enabled": true}, } - err := populateSSOSettings(sqlStore, template, "azuread") + err := populateSSOSettings(sqlStore, template, "azuread", "github", "google") require.NoError(t, err) } @@ -60,10 +65,23 @@ func TestIntegrationGetSSOSettings(t *testing.T) { t.Run("returns not found if the SSO setting is soft deleted for the specified provider", func(t *testing.T) { setup() - err := ssoSettingsStore.Delete(context.Background(), "azuread") + + provider := "okta" + template := models.SSOSettings{ + Settings: map[string]any{"enabled": true}, + IsDeleted: true, + } + err := populateSSOSettings(sqlStore, template, provider) require.NoError(t, err) - _, err = ssoSettingsStore.Get(context.Background(), "azuread") + _, err = ssoSettingsStore.Get(context.Background(), provider) + require.ErrorAs(t, err, &ssosettings.ErrNotFound) + }) + + t.Run("returns not found if the specified provider is empty", func(t *testing.T) { + setup() + + _, err := ssoSettingsStore.Get(context.Background(), "") require.ErrorAs(t, err, &ssosettings.ErrNotFound) }) } @@ -92,7 +110,7 @@ func TestIntegrationUpsertSSOSettings(t *testing.T) { }, } - err := ssoSettingsStore.Upsert(context.Background(), settings) + err := ssoSettingsStore.Upsert(context.Background(), &settings) require.NoError(t, err) actual, err := getSSOSettingsByProvider(sqlStore, settings.Provider, false) @@ -130,7 +148,7 @@ func TestIntegrationUpsertSSOSettings(t *testing.T) { "client_secret": "this-is-a-new-secret", }, } - err = ssoSettingsStore.Upsert(context.Background(), newSettings) + err = ssoSettingsStore.Upsert(context.Background(), &newSettings) require.NoError(t, err) actual, err := getSSOSettingsByProvider(sqlStore, provider, false) @@ -168,7 +186,7 @@ func TestIntegrationUpsertSSOSettings(t *testing.T) { }, } - err = ssoSettingsStore.Upsert(context.Background(), newSettings) + err = ssoSettingsStore.Upsert(context.Background(), &newSettings) require.NoError(t, err) actual, err := getSSOSettingsByProvider(sqlStore, provider, false) @@ -204,7 +222,7 @@ func TestIntegrationUpsertSSOSettings(t *testing.T) { "client_secret": "this-is-my-new-secret", }, } - err = ssoSettingsStore.Upsert(context.Background(), newSettings) + err = ssoSettingsStore.Upsert(context.Background(), &newSettings) require.NoError(t, err) actual, err := getSSOSettingsByProvider(sqlStore, providers[0], false) @@ -218,6 +236,33 @@ func TestIntegrationUpsertSSOSettings(t *testing.T) { require.EqualValues(t, template.Settings, existing.Settings) } }) + + t.Run("fails if the provider is empty", func(t *testing.T) { + setup() + + template := models.SSOSettings{ + Settings: map[string]any{ + "enabled": true, + "client_id": "azuread-client", + "client_secret": "this-is-a-secret", + }, + IsDeleted: true, + } + err := populateSSOSettings(sqlStore, template, "azuread") + require.NoError(t, err) + + settings := models.SSOSettings{ + Provider: "", + Settings: map[string]any{ + "enabled": true, + "client_id": "new-client", + }, + } + + err = ssoSettingsStore.Upsert(context.Background(), &settings) + require.Error(t, err) + require.ErrorIs(t, err, ssosettings.ErrNotFound) + }) } func TestIntegrationListSSOSettings(t *testing.T) { @@ -231,31 +276,58 @@ func TestIntegrationListSSOSettings(t *testing.T) { setup := func() { sqlStore = db.InitTestDB(t) ssoSettingsStore = ProvideStore(sqlStore) + } - template := models.SSOSettings{ + t.Run("returns every SSO settings successfully", func(t *testing.T) { + setup() + + providers := []string{"azuread", "okta", "github"} + settings := models.SSOSettings{ Settings: map[string]any{ - "enabled": true, + "enabled": true, + "client_id": "the_client_id", }, + IsDeleted: false, } - err := populateSSOSettings(sqlStore, template, "azuread") + err := populateSSOSettings(sqlStore, settings, providers...) require.NoError(t, err) - template = models.SSOSettings{ + deleted := models.SSOSettings{ Settings: map[string]any{ - "enabled": true, + "enabled": false, }, + IsDeleted: true, } - err = populateSSOSettings(sqlStore, template, "okta") + err = populateSSOSettings(sqlStore, deleted, "google", "gitlab", "okta") require.NoError(t, err) - } - t.Run("returns every SSO settings successfully", func(t *testing.T) { + list, err := ssoSettingsStore.List(context.Background()) + + require.NoError(t, err) + require.Len(t, list, len(providers)) + + for _, item := range list { + require.Contains(t, providers, item.Provider) + require.EqualValues(t, settings.Settings, item.Settings) + } + }) + + t.Run("returns empty list if no settings are found", func(t *testing.T) { setup() + deleted := models.SSOSettings{ + Settings: map[string]any{ + "enabled": false, + }, + IsDeleted: true, + } + err := populateSSOSettings(sqlStore, deleted, "google", "gitlab", "okta") + require.NoError(t, err) + list, err := ssoSettingsStore.List(context.Background()) require.NoError(t, err) - require.Equal(t, 2, len(list)) + require.Len(t, list, 0) }) } @@ -362,6 +434,28 @@ func TestIntegrationDeleteSSOSettings(t *testing.T) { require.EqualValues(t, 1, deleted) require.EqualValues(t, 1, notDeleted) }) + + t.Run("return not found if the provider is empty", func(t *testing.T) { + setup() + + providers := []string{"github", "google", "okta"} + template := models.SSOSettings{ + Settings: map[string]any{ + "enabled": true, + }, + } + err := populateSSOSettings(sqlStore, template, providers...) + require.NoError(t, err) + + err = ssoSettingsStore.Delete(context.Background(), "") + require.Error(t, err) + require.ErrorIs(t, err, ssosettings.ErrNotFound) + + deleted, notDeleted, err := getSSOSettingsCountByDeleted(sqlStore) + require.NoError(t, err) + require.EqualValues(t, 0, deleted) + require.EqualValues(t, len(providers), notDeleted) + }) } func populateSSOSettings(sqlStore *sqlstore.SQLStore, template models.SSOSettings, providers ...string) error { diff --git a/pkg/services/ssosettings/errors.go b/pkg/services/ssosettings/errors.go index 3bc22582ec011..9e1169d1a1716 100644 --- a/pkg/services/ssosettings/errors.go +++ b/pkg/services/ssosettings/errors.go @@ -1,7 +1,24 @@ package ssosettings -import "errors" +import ( + "github.com/grafana/grafana/pkg/util/errutil" +) var ( - ErrNotFound = errors.New("not found") + errNotFoundBase = errutil.NotFound("sso.notFound", errutil.WithPublicMessage("The provider was not found.")) + ErrNotFound = errNotFoundBase.Errorf("not found") + + ErrNotConfigurable = errNotFoundBase.Errorf("not configurable") + + ErrBaseInvalidOAuthConfig = errutil.ValidationFailed("sso.invalidOauthConfig") + + ErrInvalidOAuthConfig = func(msg string) error { + base := ErrBaseInvalidOAuthConfig.Errorf("OAuth settings are invalid") + base.PublicMessage = msg + return base + } + + ErrInvalidProvider = errutil.ValidationFailed("sso.invalidProvider", errutil.WithPublicMessage("Provider is invalid")) + ErrInvalidSettings = errutil.ValidationFailed("sso.settings", errutil.WithPublicMessage("Settings field is invalid")) + ErrEmptyClientId = errutil.ValidationFailed("sso.emptyClientId", errutil.WithPublicMessage("ClientId cannot be empty")) ) diff --git a/pkg/services/ssosettings/models/models.go b/pkg/services/ssosettings/models/models.go index 405c57fd34b4c..3dd364de52a7a 100644 --- a/pkg/services/ssosettings/models/models.go +++ b/pkg/services/ssosettings/models/models.go @@ -26,6 +26,23 @@ func (s SettingsSource) MarshalJSON() ([]byte, error) { } } +func (s *SettingsSource) UnmarshalJSON(data []byte) error { + var source string + if err := json.Unmarshal(data, &source); err != nil { + return err + } + + switch source { + case "database": + *s = DB + case "system": + *s = System + default: + return fmt.Errorf("unknown source: %s", source) + } + return nil +} + type SSOSettings struct { ID string `xorm:"id pk" json:"id"` Provider string `xorm:"provider" json:"provider"` diff --git a/pkg/services/ssosettings/ssosettings.go b/pkg/services/ssosettings/ssosettings.go index 5e73adf373a11..56e79e58bfe04 100644 --- a/pkg/services/ssosettings/ssosettings.go +++ b/pkg/services/ssosettings/ssosettings.go @@ -4,14 +4,11 @@ import ( "context" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/ssosettings/models" ) var ( - // ConfigurableOAuthProviders is a list of OAuth providers that can be configured from the API - // TODO: make it configurable - ConfigurableOAuthProviders = []string{"github", "gitlab", "google", "generic_oauth", "azuread", "okta"} - AllOAuthProviders = []string{social.GitHubProviderName, social.GitlabProviderName, social.GoogleProviderName, social.GenericOAuthProviderName, social.GrafanaComProviderName, social.AzureADProviderName, social.OktaProviderName} ) @@ -21,10 +18,14 @@ var ( type Service interface { // List returns all SSO settings from DB and config files List(ctx context.Context) ([]*models.SSOSettings, error) + // ListWithRedactedSecrets returns all SSO settings from DB and config files with secret values redacted + ListWithRedactedSecrets(ctx context.Context) ([]*models.SSOSettings, error) // GetForProvider returns the SSO settings for a given provider (DB or config file) GetForProvider(ctx context.Context, provider string) (*models.SSOSettings, error) + // GetForProviderWithRedactedSecrets returns the SSO settings for a given provider (DB or config file) with secret values redacted + GetForProviderWithRedactedSecrets(ctx context.Context, provider string) (*models.SSOSettings, error) // Upsert creates or updates the SSO settings for a given provider - Upsert(ctx context.Context, settings models.SSOSettings) error + Upsert(ctx context.Context, settings *models.SSOSettings, requester identity.Requester) error // Delete deletes the SSO settings for a given provider (soft delete) Delete(ctx context.Context, provider string) error // Patch updates the specified SSO settings (key-value pairs) for a given provider @@ -36,9 +37,11 @@ type Service interface { } // Reloadable is an interface that can be implemented by a provider to allow it to be validated and reloaded +// +//go:generate mockery --name Reloadable --structname MockReloadable --outpkg ssosettingstests --filename reloadable_mock.go --output ./ssosettingstests/ type Reloadable interface { Reload(ctx context.Context, settings models.SSOSettings) error - Validate(ctx context.Context, settings models.SSOSettings) error + Validate(ctx context.Context, settings models.SSOSettings, requester identity.Requester) error } // FallbackStrategy is an interface that can be implemented to allow a provider to load settings from a different source @@ -56,7 +59,8 @@ type FallbackStrategy interface { type Store interface { Get(ctx context.Context, provider string) (*models.SSOSettings, error) List(ctx context.Context) ([]*models.SSOSettings, error) - Upsert(ctx context.Context, settings models.SSOSettings) error - Patch(ctx context.Context, provider string, data map[string]any) error + Upsert(ctx context.Context, settings *models.SSOSettings) error Delete(ctx context.Context, provider string) error } + +type ValidateFunc[T any] func(input *T, requester identity.Requester) error diff --git a/pkg/services/ssosettings/ssosettingsimpl/metrics.go b/pkg/services/ssosettings/ssosettingsimpl/metrics.go new file mode 100644 index 0000000000000..229abf9d431f5 --- /dev/null +++ b/pkg/services/ssosettings/ssosettingsimpl/metrics.go @@ -0,0 +1,31 @@ +package ssosettingsimpl + +import "github.com/prometheus/client_golang/prometheus" + +const ( + metricsNamespace = "grafana" + metricsSubSystem = "ssosettings" +) + +type metrics struct { + reloadFailures *prometheus.CounterVec +} + +func newMetrics(reg prometheus.Registerer) *metrics { + m := &metrics{ + reloadFailures: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + Name: "setting_reload_failures_total", + Help: "Number of SSO Setting reload failures.", + }, []string{"provider"}), + } + + if reg != nil { + reg.MustRegister( + m.reloadFailures, + ) + } + + return m +} diff --git a/pkg/services/ssosettings/ssosettingsimpl/service.go b/pkg/services/ssosettings/ssosettingsimpl/service.go index 8e974da509068..b753f75cc8d9c 100644 --- a/pkg/services/ssosettings/ssosettingsimpl/service.go +++ b/pkg/services/ssosettings/ssosettingsimpl/service.go @@ -2,14 +2,18 @@ package ssosettingsimpl import ( "context" + "encoding/base64" "errors" "fmt" "strings" + "time" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/usagestats" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/ssosettings" @@ -18,24 +22,26 @@ import ( "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/strategies" "github.com/grafana/grafana/pkg/setting" + "github.com/prometheus/client_golang/prometheus" ) -var _ ssosettings.Service = (*SSOSettingsService)(nil) +var _ ssosettings.Service = (*Service)(nil) -type SSOSettingsService struct { - log log.Logger +type Service struct { + logger log.Logger cfg *setting.Cfg store ssosettings.Store ac ac.AccessControl secrets secrets.Service + metrics *metrics fbStrategies []ssosettings.FallbackStrategy reloadables map[string]ssosettings.Reloadable } func ProvideService(cfg *setting.Cfg, sqlStore db.DB, ac ac.AccessControl, - routeRegister routing.RouteRegister, features *featuremgmt.FeatureManager, - secrets secrets.Service) *SSOSettingsService { + routeRegister routing.RouteRegister, features featuremgmt.FeatureToggles, + secrets secrets.Service, usageStats usagestats.Service, registerer prometheus.Registerer) *Service { strategies := []ssosettings.FallbackStrategy{ strategies.NewOAuthStrategy(cfg), // register other strategies here, for example SAML @@ -43,16 +49,19 @@ func ProvideService(cfg *setting.Cfg, sqlStore db.DB, ac ac.AccessControl, store := database.ProvideStore(sqlStore) - svc := &SSOSettingsService{ - log: log.New("ssosettings.service"), + svc := &Service{ + logger: log.New("ssosettings.service"), cfg: cfg, store: store, ac: ac, fbStrategies: strategies, secrets: secrets, + metrics: newMetrics(registerer), reloadables: make(map[string]ssosettings.Reloadable), } + usageStats.RegisterMetricsFunc(svc.getUsageStats) + if features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsApi) { ssoSettingsApi := api.ProvideApi(svc, routeRegister, ac) ssoSettingsApi.RegisterAPIEndpoints() @@ -61,30 +70,50 @@ func ProvideService(cfg *setting.Cfg, sqlStore db.DB, ac ac.AccessControl, return svc } -var _ ssosettings.Service = (*SSOSettingsService)(nil) +var _ ssosettings.Service = (*Service)(nil) -func (s *SSOSettingsService) GetForProvider(ctx context.Context, provider string) (*models.SSOSettings, error) { - storeSettings, err := s.store.Get(ctx, provider) +func (s *Service) GetForProvider(ctx context.Context, provider string) (*models.SSOSettings, error) { + dbSettings, err := s.store.Get(ctx, provider) + if err != nil && !errors.Is(err, ssosettings.ErrNotFound) { + return nil, err + } - if errors.Is(err, ssosettings.ErrNotFound) { - settings, err := s.loadSettingsUsingFallbackStrategy(ctx, provider) + if dbSettings != nil { + // Settings are coming from the database thus secrets are encrypted + dbSettings.Settings, err = s.decryptSecrets(ctx, dbSettings.Settings) if err != nil { return nil, err } + } + + systemSettings, err := s.loadSettingsUsingFallbackStrategy(ctx, provider) + if err != nil { + return nil, err + } + + return s.mergeSSOSettings(dbSettings, systemSettings), nil +} - return settings, nil +func (s *Service) GetForProviderWithRedactedSecrets(ctx context.Context, provider string) (*models.SSOSettings, error) { + if !s.isProviderConfigurable(provider) { + return nil, ssosettings.ErrNotConfigurable } + storeSettings, err := s.GetForProvider(ctx, provider) if err != nil { return nil, err } - storeSettings.Source = models.DB + for k, v := range storeSettings.Settings { + if strVal, ok := v.(string); ok { + storeSettings.Settings[k] = setting.RedactedValue(k, strVal) + } + } return storeSettings, nil } -func (s *SSOSettingsService) List(ctx context.Context) ([]*models.SSOSettings, error) { +func (s *Service) List(ctx context.Context) ([]*models.SSOSettings, error) { result := make([]*models.SSOSettings, 0, len(ssosettings.AllOAuthProviders)) storedSettings, err := s.store.List(ctx) @@ -93,60 +122,145 @@ func (s *SSOSettingsService) List(ctx context.Context) ([]*models.SSOSettings, e } for _, provider := range ssosettings.AllOAuthProviders { - settings := getSettingsByProvider(provider, storedSettings) - if len(settings) == 0 { - // If there is no data in the DB then we need to load the settings using the fallback strategy - fallbackSettings, err := s.loadSettingsUsingFallbackStrategy(ctx, provider) + dbSettings := getSettingByProvider(provider, storedSettings) + if dbSettings != nil { + // Settings are coming from the database thus secrets are encrypted + dbSettings.Settings, err = s.decryptSecrets(ctx, dbSettings.Settings) if err != nil { return nil, err } - - settings = append(settings, fallbackSettings) } - result = append(result, settings...) + fallbackSettings, err := s.loadSettingsUsingFallbackStrategy(ctx, provider) + if err != nil { + return nil, err + } + + result = append(result, s.mergeSSOSettings(dbSettings, fallbackSettings)) } return result, nil } -func (s *SSOSettingsService) Upsert(ctx context.Context, settings models.SSOSettings) error { - var err error - // TODO: also check whether the provider is configurable - // Get the connector for the provider (from the reloadables) and call Validate +func (s *Service) ListWithRedactedSecrets(ctx context.Context) ([]*models.SSOSettings, error) { + storeSettings, err := s.List(ctx) + if err != nil { + return nil, err + } - settings.Settings, err = s.encryptSecrets(ctx, settings.Settings) + configurableSettings := make([]*models.SSOSettings, 0, len(s.cfg.SSOSettingsConfigurableProviders)) + for _, provider := range storeSettings { + if s.isProviderConfigurable(provider.Provider) { + configurableSettings = append(configurableSettings, provider) + } + } + + for _, storeSetting := range configurableSettings { + for k, v := range storeSetting.Settings { + if strVal, ok := v.(string); ok { + storeSetting.Settings[k] = setting.RedactedValue(k, strVal) + } + } + } + + return configurableSettings, nil +} + +func (s *Service) Upsert(ctx context.Context, settings *models.SSOSettings, requester identity.Requester) error { + if !s.isProviderConfigurable(settings.Provider) { + return ssosettings.ErrNotConfigurable + } + + social, ok := s.reloadables[settings.Provider] + if !ok { + return ssosettings.ErrInvalidProvider.Errorf("provider %s not found in reloadables", settings.Provider) + } + + err := social.Validate(ctx, *settings, requester) if err != nil { return err } - return s.store.Upsert(ctx, settings) + storedSettings, err := s.GetForProvider(ctx, settings.Provider) + if err != nil { + return err + } + + secrets := collectSecrets(settings, storedSettings) + + settings.Settings, err = s.encryptSecrets(ctx, settings.Settings, storedSettings.Settings) + if err != nil { + return err + } + + err = s.store.Upsert(ctx, settings) + if err != nil { + return err + } + + // make a copy of current settings for reload operation and apply overrides + reloadSettings := *settings + reloadSettings.Settings = overrideMaps(storedSettings.Settings, settings.Settings, secrets) + + go s.reload(social, settings.Provider, reloadSettings) + + return nil } -func (s *SSOSettingsService) Patch(ctx context.Context, provider string, data map[string]any) error { +func (s *Service) Patch(ctx context.Context, provider string, data map[string]any) error { panic("not implemented") // TODO: Implement } -func (s *SSOSettingsService) Delete(ctx context.Context, provider string) error { - return s.store.Delete(ctx, provider) +func (s *Service) Delete(ctx context.Context, provider string) error { + if !s.isProviderConfigurable(provider) { + return ssosettings.ErrNotConfigurable + } + + social, ok := s.reloadables[provider] + if !ok { + return ssosettings.ErrInvalidProvider.Errorf("provider %s not found in reloadables", provider) + } + + err := s.store.Delete(ctx, provider) + if err != nil { + return err + } + + currentSettings, err := s.GetForProvider(ctx, provider) + if err != nil { + s.logger.Error("failed to get current settings, skipping reload", "provider", provider, "error", err) + return nil + } + + go s.reload(social, provider, *currentSettings) + + return nil +} + +func (s *Service) reload(reloadable ssosettings.Reloadable, provider string, currentSettings models.SSOSettings) { + err := reloadable.Reload(context.Background(), currentSettings) + if err != nil { + s.metrics.reloadFailures.WithLabelValues(provider).Inc() + s.logger.Error("failed to reload the provider", "provider", provider, "error", err) + } } -func (s *SSOSettingsService) Reload(ctx context.Context, provider string) { +func (s *Service) Reload(ctx context.Context, provider string) { panic("not implemented") // TODO: Implement } -func (s *SSOSettingsService) RegisterReloadable(provider string, reloadable ssosettings.Reloadable) { +func (s *Service) RegisterReloadable(provider string, reloadable ssosettings.Reloadable) { if s.reloadables == nil { s.reloadables = make(map[string]ssosettings.Reloadable) } s.reloadables[provider] = reloadable } -func (s *SSOSettingsService) RegisterFallbackStrategy(providerRegex string, strategy ssosettings.FallbackStrategy) { +func (s *Service) RegisterFallbackStrategy(providerRegex string, strategy ssosettings.FallbackStrategy) { s.fbStrategies = append(s.fbStrategies, strategy) } -func (s *SSOSettingsService) loadSettingsUsingFallbackStrategy(ctx context.Context, provider string) (*models.SSOSettings, error) { - loadStrategy, ok := s.getFallBackstrategyFor(provider) +func (s *Service) loadSettingsUsingFallbackStrategy(ctx context.Context, provider string) (*models.SSOSettings, error) { + loadStrategy, ok := s.getFallbackStrategyFor(provider) if !ok { return nil, errors.New("no fallback strategy found for provider: " + provider) } @@ -163,17 +277,16 @@ func (s *SSOSettingsService) loadSettingsUsingFallbackStrategy(ctx context.Conte }, nil } -func getSettingsByProvider(provider string, settings []*models.SSOSettings) []*models.SSOSettings { - result := make([]*models.SSOSettings, 0) +func getSettingByProvider(provider string, settings []*models.SSOSettings) *models.SSOSettings { for _, item := range settings { if item.Provider == provider { - result = append(result, item) + return item } } - return result + return nil } -func (s *SSOSettingsService) getFallBackstrategyFor(provider string) (ssosettings.FallbackStrategy, bool) { +func (s *Service) getFallbackStrategyFor(provider string) (ssosettings.FallbackStrategy, bool) { for _, strategy := range s.fbStrategies { if strategy.IsMatch(provider) { return strategy, true @@ -182,32 +295,213 @@ func (s *SSOSettingsService) getFallBackstrategyFor(provider string) (ssosetting return nil, false } -func (s *SSOSettingsService) encryptSecrets(ctx context.Context, settings map[string]any) (map[string]any, error) { - secretFieldPatterns := []string{"secret"} +func (s *Service) encryptSecrets(ctx context.Context, settings, storedSettings map[string]any) (map[string]any, error) { + result := make(map[string]any) + for k, v := range settings { + if isSecret(k) && v != "" { + strValue, ok := v.(string) + if !ok { + return result, fmt.Errorf("failed to encrypt %s setting because it is not a string: %v", k, v) + } - isSecret := func(field string) bool { - for _, v := range secretFieldPatterns { - if strings.Contains(strings.ToLower(field), strings.ToLower(v)) { - return true + if !isNewSecretValue(strValue) { + strValue = storedSettings[k].(string) } + + encryptedSecret, err := s.secrets.Encrypt(ctx, []byte(strValue), secrets.WithoutScope()) + if err != nil { + return result, err + } + result[k] = base64.RawStdEncoding.EncodeToString(encryptedSecret) + } else { + result[k] = v + } + } + + return result, nil +} + +func (s *Service) Run(ctx context.Context) error { + interval := s.cfg.SSOSettingsReloadInterval + if interval == 0 { + return nil + } + + ticker := time.NewTicker(interval) + + // start a background process for reloading the SSO settings for all providers at a fixed interval + // it is useful for high availability setups running multiple Grafana instances + for { + select { + case <-ticker.C: + s.doReload(ctx) + + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func (s *Service) doReload(ctx context.Context) { + s.logger.Debug("reloading SSO Settings for all providers") + + settingsList, err := s.List(ctx) + if err != nil { + s.logger.Error("failed to fetch SSO Settings for all providers", "err", err) + return + } + + for provider, connector := range s.reloadables { + setting := getSettingByProvider(provider, settingsList) + + err = connector.Reload(ctx, *setting) + if err != nil { + s.metrics.reloadFailures.WithLabelValues(provider).Inc() + s.logger.Error("failed to reload SSO Settings", "provider", provider, "err", err) + continue } - return false } +} +// mergeSSOSettings merges the settings from the database with the system settings +// Required because it is possible that the user has configured some of the settings (current Advanced OAuth settings) +// and the rest of the settings have to be loaded from the system settings +func (s *Service) mergeSSOSettings(dbSettings, systemSettings *models.SSOSettings) *models.SSOSettings { + if dbSettings == nil { + s.logger.Debug("No SSO Settings found in the database, using system settings") + return systemSettings + } + + s.logger.Debug("Merging SSO Settings", "dbSettings", removeSecrets(dbSettings.Settings), "systemSettings", removeSecrets(systemSettings.Settings)) + + result := &models.SSOSettings{ + Provider: dbSettings.Provider, + Source: dbSettings.Source, + Settings: mergeSettings(dbSettings.Settings, systemSettings.Settings), + Created: dbSettings.Created, + Updated: dbSettings.Updated, + } + + return result +} + +func (s *Service) decryptSecrets(ctx context.Context, settings map[string]any) (map[string]any, error) { for k, v := range settings { - if isSecret(k) { + if isSecret(k) && v != "" { strValue, ok := v.(string) if !ok { - return settings, fmt.Errorf("failed to encrypt %s setting because it is not a string: %v", k, v) + s.logger.Error("Failed to parse secret value, it is not a string", "key", k) + return nil, fmt.Errorf("secret value is not a string") } - encryptedSecret, err := s.secrets.Encrypt(ctx, []byte(strValue), secrets.WithoutScope()) + decoded, err := base64.RawStdEncoding.DecodeString(strValue) if err != nil { - return settings, err + s.logger.Error("Failed to decode secret string", "err", err, "value") + return nil, err + } + + decrypted, err := s.secrets.Decrypt(ctx, decoded) + if err != nil { + s.logger.Error("Failed to decrypt secret", "err", err) + return nil, err } - settings[k] = string(encryptedSecret) + + settings[k] = string(decrypted) } } - return settings, nil } + +func (s *Service) isProviderConfigurable(provider string) bool { + _, ok := s.cfg.SSOSettingsConfigurableProviders[provider] + return ok +} + +// removeSecrets removes all the secrets from the map and replaces them with a redacted password +// and returns a new map +func removeSecrets(settings map[string]any) map[string]any { + result := make(map[string]any) + for k, v := range settings { + if isSecret(k) { + result[k] = setting.RedactedPassword + continue + } + result[k] = v + } + return result +} + +// mergeSettings merges two maps in a way that the values from the first map are preserved +// and the values from the second map are added only if they don't exist in the first map +// or if they contain empty URLs. +func mergeSettings(storedSettings, systemSettings map[string]any) map[string]any { + settings := make(map[string]any) + + for k, v := range storedSettings { + settings[k] = v + } + + for k, v := range systemSettings { + if _, ok := settings[k]; !ok { + settings[k] = v + } else if isURL(k) && isEmptyString(settings[k]) { + // Overwrite all URL settings from the DB containing an empty string with their value + // from the system settings. This fixes an issue with empty auth_url, api_url and token_url + // from the DB not being replaced with their values defined in the system settings for + // the Google provider. + settings[k] = v + } + } + + return settings +} + +// collectSecrets collects all the secrets from the request and the currently stored settings +// and returns a new map +func collectSecrets(settings *models.SSOSettings, storedSettings *models.SSOSettings) map[string]any { + secrets := map[string]any{} + for k, v := range settings.Settings { + if isSecret(k) { + if isNewSecretValue(v.(string)) { + secrets[k] = v.(string) // use the new value + continue + } + secrets[k] = storedSettings.Settings[k] // keep the currently stored value + } + } + return secrets +} + +func overrideMaps(maps ...map[string]any) map[string]any { + result := make(map[string]any) + for _, m := range maps { + for k, v := range m { + result[k] = v + } + } + return result +} + +func isSecret(fieldName string) bool { + secretFieldPatterns := []string{"secret"} + + for _, v := range secretFieldPatterns { + if strings.Contains(strings.ToLower(fieldName), strings.ToLower(v)) { + return true + } + } + return false +} + +func isURL(fieldName string) bool { + return strings.HasSuffix(fieldName, "_url") +} + +func isEmptyString(val any) bool { + _, ok := val.(string) + return ok && val == "" +} + +func isNewSecretValue(value string) bool { + return value != setting.RedactedPassword +} diff --git a/pkg/services/ssosettings/ssosettingsimpl/service_test.go b/pkg/services/ssosettings/ssosettingsimpl/service_test.go index 74e384a9bf429..10fa239fdbb54 100644 --- a/pkg/services/ssosettings/ssosettingsimpl/service_test.go +++ b/pkg/services/ssosettings/ssosettingsimpl/service_test.go @@ -2,24 +2,33 @@ package ssosettingsimpl import ( "context" + "encoding/base64" "errors" "fmt" + "maps" + "sync" "testing" + "time" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" secretsFakes "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/ssosettings" "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) -func TestSSOSettingsService_GetForProvider(t *testing.T) { +func TestService_GetForProvider(t *testing.T) { + t.Parallel() + testCases := []struct { name string setup func(env testEnv) @@ -34,10 +43,21 @@ func TestSSOSettingsService_GetForProvider(t *testing.T) { Settings: map[string]any{"enabled": true}, Source: models.DB, } + env.fallbackStrategy.ExpectedIsMatch = true + env.fallbackStrategy.ExpectedConfigs = map[string]map[string]any{ + "github": { + "client_id": "client_id", + "client_secret": "secret", + }, + } }, want: &models.SSOSettings{ Provider: "github", - Settings: map[string]any{"enabled": true}, + Settings: map[string]any{ + "enabled": true, + "client_id": "client_id", + "client_secret": "secret", + }, }, wantErr: false, }, @@ -48,16 +68,23 @@ func TestSSOSettingsService_GetForProvider(t *testing.T) { wantErr: true, }, { - name: "should fallback to strategy if store returns not found", + name: "should fallback to the system settings if store returns not found", setup: func(env testEnv) { env.store.ExpectedError = ssosettings.ErrNotFound env.fallbackStrategy.ExpectedIsMatch = true - env.fallbackStrategy.ExpectedConfig = map[string]any{"enabled": true} + env.fallbackStrategy.ExpectedConfigs = map[string]map[string]any{ + "github": { + "enabled": true, + "client_id": "client_id", + }, + } }, want: &models.SSOSettings{ Provider: "github", - Settings: map[string]any{"enabled": true}, - Source: models.System, + Settings: map[string]any{ + "enabled": true, + "client_id": "client_id"}, + Source: models.System, }, wantErr: false, }, @@ -80,10 +107,130 @@ func TestSSOSettingsService_GetForProvider(t *testing.T) { want: nil, wantErr: true, }, + { + name: "should decrypt secrets if data is coming from store", + setup: func(env testEnv) { + env.store.ExpectedSSOSetting = &models.SSOSettings{ + Provider: "github", + Settings: map[string]any{ + "enabled": true, + "client_secret": base64.RawStdEncoding.EncodeToString([]byte("client_secret")), + "other_secret": base64.RawStdEncoding.EncodeToString([]byte("other_secret")), + }, + Source: models.DB, + } + env.fallbackStrategy.ExpectedIsMatch = true + env.fallbackStrategy.ExpectedConfigs = map[string]map[string]any{ + "github": { + "client_id": "client_id", + }, + } + + env.secrets.On("Decrypt", mock.Anything, []byte("client_secret"), mock.Anything).Return([]byte("decrypted-client-secret"), nil).Once() + env.secrets.On("Decrypt", mock.Anything, []byte("other_secret"), mock.Anything).Return([]byte("decrypted-other-secret"), nil).Once() + }, + want: &models.SSOSettings{ + Provider: "github", + Settings: map[string]any{ + "enabled": true, + "client_id": "client_id", + "client_secret": "decrypted-client-secret", + "other_secret": "decrypted-other-secret", + }, + Source: models.DB, + }, + wantErr: false, + }, + { + name: "should not decrypt secrets if data is coming from the fallback strategy", + setup: func(env testEnv) { + env.store.ExpectedError = ssosettings.ErrNotFound + env.fallbackStrategy.ExpectedIsMatch = true + env.fallbackStrategy.ExpectedConfigs = map[string]map[string]any{ + "github": { + "enabled": true, + "client_id": "client_id", + "client_secret": "client_secret", + }, + } + }, + want: &models.SSOSettings{ + Provider: "github", + Settings: map[string]any{ + "enabled": true, + "client_id": "client_id", + "client_secret": "client_secret", + }, + Source: models.System, + }, + wantErr: false, + }, + { + name: "should return an error if the data in the store is invalid", + setup: func(env testEnv) { + env.store.ExpectedSSOSetting = &models.SSOSettings{ + Provider: "github", + Settings: map[string]any{ + "enabled": true, + "client_secret": "not a valid base64 string", + }, + Source: models.DB, + } + env.fallbackStrategy.ExpectedIsMatch = true + env.fallbackStrategy.ExpectedConfigs = map[string]map[string]any{ + "github": { + "client_id": "client_id", + }, + } + }, + wantErr: true, + }, + { + name: "correctly merge the DB and system settings", + setup: func(env testEnv) { + env.store.ExpectedSSOSetting = &models.SSOSettings{ + Provider: "github", + Settings: map[string]any{ + "enabled": true, + "auth_url": "", + "api_url": "https://overwritten-api.com/user", + "team_ids": "", + }, + Source: models.DB, + } + env.fallbackStrategy.ExpectedIsMatch = true + env.fallbackStrategy.ExpectedConfigs = map[string]map[string]any{ + "github": { + "auth_url": "https://github.com/login/oauth/authorize", + "token_url": "https://github.com/login/oauth/access_token", + "api_url": "https://api.github.com/user", + "team_ids": "10,11,12", + }, + } + }, + want: &models.SSOSettings{ + Provider: "github", + Settings: map[string]any{ + "enabled": true, + "auth_url": "https://github.com/login/oauth/authorize", + "token_url": "https://github.com/login/oauth/access_token", + "api_url": "https://overwritten-api.com/user", + "team_ids": "", + }, + Source: models.DB, + }, + wantErr: false, + }, } for _, tc := range testCases { + // create a local copy of "tc" to allow concurrent access within tests to the different items of testCases, + // otherwise it would be like a moving pointer while tests run in parallel + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) if tc.setup != nil { tc.setup(env) @@ -98,97 +245,205 @@ func TestSSOSettingsService_GetForProvider(t *testing.T) { require.NoError(t, err) require.Equal(t, tc.want, actual) + + env.secrets.AssertExpectations(t) }) } } -func TestSSOSettingsService_List(t *testing.T) { +func TestService_GetForProviderWithRedactedSecrets(t *testing.T) { + t.Parallel() + testCases := []struct { name string setup func(env testEnv) - want []*models.SSOSettings + want *models.SSOSettings wantErr bool }{ { - name: "should return successfully", + name: "should return successfully and redact secrets", setup: func(env testEnv) { - env.store.ExpectedSSOSettings = []*models.SSOSettings{ - { - Provider: "github", - Settings: map[string]any{"enabled": true}, - Source: models.DB, - }, - { - Provider: "okta", - Settings: map[string]any{"enabled": false}, - Source: models.DB, + env.store.ExpectedSSOSetting = &models.SSOSettings{ + Provider: "github", + Settings: map[string]any{ + "enabled": true, + "secret": base64.RawStdEncoding.EncodeToString([]byte("secret")), + "client_secret": base64.RawStdEncoding.EncodeToString([]byte("client_secret")), + "client_id": "client_id", }, + Source: models.DB, } - env.fallbackStrategy.ExpectedIsMatch = true - env.fallbackStrategy.ExpectedConfig = map[string]any{"enabled": false} + env.secrets.On("Decrypt", mock.Anything, []byte("client_secret"), mock.Anything).Return([]byte("decrypted-client-secret"), nil).Once() + env.secrets.On("Decrypt", mock.Anything, []byte("secret"), mock.Anything).Return([]byte("decrypted-secret"), nil).Once() }, - want: []*models.SSOSettings{ - { - Provider: "github", - Settings: map[string]any{"enabled": true}, - Source: models.DB, - }, - { - Provider: "okta", - Settings: map[string]any{"enabled": false}, - Source: models.DB, - }, - { - Provider: "gitlab", - Settings: map[string]any{"enabled": false}, - Source: models.System, - }, - { - Provider: "generic_oauth", - Settings: map[string]any{"enabled": false}, - Source: models.System, - }, - { - Provider: "google", - Settings: map[string]any{"enabled": false}, - Source: models.System, - }, - { - Provider: "azuread", - Settings: map[string]any{"enabled": false}, - Source: models.System, - }, - { - Provider: "grafana_com", - Settings: map[string]any{"enabled": false}, - Source: models.System, + want: &models.SSOSettings{ + Provider: "github", + Settings: map[string]any{ + "enabled": true, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", }, }, wantErr: false, }, { - name: "should return error if store returns an error", + name: "should return error if store returns an error different than not found", setup: func(env testEnv) { env.store.ExpectedError = fmt.Errorf("error") }, want: nil, wantErr: true, }, { - name: "should use the fallback strategy if store returns empty list", + name: "should fallback to strategy if store returns not found", setup: func(env testEnv) { - env.store.ExpectedSSOSettings = []*models.SSOSettings{} + env.store.ExpectedError = ssosettings.ErrNotFound env.fallbackStrategy.ExpectedIsMatch = true - env.fallbackStrategy.ExpectedConfig = map[string]any{"enabled": false} + env.fallbackStrategy.ExpectedConfigs = map[string]map[string]any{ + "github": { + "enabled": true, + }, + } + }, + want: &models.SSOSettings{ + Provider: "github", + Settings: map[string]any{"enabled": true}, + Source: models.System, + }, + wantErr: false, + }, + { + name: "should return error if the fallback strategy was not found", + setup: func(env testEnv) { + env.store.ExpectedError = ssosettings.ErrNotFound + env.fallbackStrategy.ExpectedIsMatch = false + }, + want: nil, + wantErr: true, + }, + { + name: "should return error if fallback strategy returns error", + setup: func(env testEnv) { + env.store.ExpectedError = ssosettings.ErrNotFound + env.fallbackStrategy.ExpectedIsMatch = true + env.fallbackStrategy.ExpectedError = fmt.Errorf("error") + }, + want: nil, + wantErr: true, + }, + } + + for _, tc := range testCases { + // create a local copy of "tc" to allow concurrent access within tests to the different items of testCases, + // otherwise it would be like a moving pointer while tests run in parallel + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + env := setupTestEnv(t) + if tc.setup != nil { + tc.setup(env) + } + + actual, err := env.service.GetForProviderWithRedactedSecrets(context.Background(), "github") + + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, actual) + }) + } +} + +func TestService_List(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + setup func(env testEnv) + want []*models.SSOSettings + wantErr bool + }{ + { + name: "should return successfully", + setup: func(env testEnv) { + env.store.ExpectedSSOSettings = []*models.SSOSettings{ + { + Provider: "github", + Settings: map[string]any{ + "enabled": true, + "client_secret": base64.RawStdEncoding.EncodeToString([]byte("client_secret")), + }, + Source: models.DB, + }, + { + Provider: "okta", + Settings: map[string]any{ + "enabled": false, + "other_secret": base64.RawStdEncoding.EncodeToString([]byte("other_secret")), + }, + Source: models.DB, + }, + } + env.secrets.On("Decrypt", mock.Anything, []byte("client_secret"), mock.Anything).Return([]byte("decrypted-client-secret"), nil).Once() + env.secrets.On("Decrypt", mock.Anything, []byte("other_secret"), mock.Anything).Return([]byte("decrypted-other-secret"), nil).Once() + + env.fallbackStrategy.ExpectedIsMatch = true + env.fallbackStrategy.ExpectedConfigs = map[string]map[string]any{ + "github": { + "enabled": false, + "client_id": "client_id", + "client_secret": "secret1", + "token_url": "token_url", + }, + "okta": { + "enabled": false, + "client_id": "client_id", + "client_secret": "coming-from-system", + "other_secret": "secret2", + "token_url": "token_url", + }, + "gitlab": { + "enabled": false, + }, + "generic_oauth": { + "enabled": false, + }, + "google": { + "enabled": false, + }, + "azuread": { + "enabled": false, + }, + "grafana_com": { + "enabled": false, + }, + } }, want: []*models.SSOSettings{ { Provider: "github", - Settings: map[string]any{"enabled": false}, - Source: models.System, + Settings: map[string]any{ + "enabled": true, + "client_id": "client_id", + "client_secret": "decrypted-client-secret", // client_secret is coming from the database, must be decrypted first + "token_url": "token_url", + }, + Source: models.DB, }, { Provider: "okta", - Settings: map[string]any{"enabled": false}, - Source: models.System, + Settings: map[string]any{ + "enabled": false, + "client_id": "client_id", + "client_secret": "coming-from-system", // client_secret is coming from the system, must not be decrypted + "other_secret": "decrypted-other-secret", + "token_url": "token_url", + }, + Source: models.DB, }, { Provider: "gitlab", @@ -229,7 +484,13 @@ func TestSSOSettingsService_List(t *testing.T) { }, } for _, tc := range testCases { + // create a local copy of "tc" to allow concurrent access within tests to the different items of testCases, + // otherwise it would be like a moving pointer while tests run in parallel + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) if tc.setup != nil { tc.setup(env) @@ -248,50 +509,536 @@ func TestSSOSettingsService_List(t *testing.T) { } } -func TestSSOSettingsService_Upsert(t *testing.T) { - t.Run("successfully upsert SSO settings", func(t *testing.T) { - env := setupTestEnv(t) - - settings := models.SSOSettings{ - Provider: "azuread", - Settings: map[string]any{ - "client_id": "client-id", - "client_secret": "client-secret", - "enabled": true, - }, - IsDeleted: false, - } - - env.secrets.On("Encrypt", mock.Anything, []byte(settings.Settings["client_secret"].(string)), mock.Anything).Return([]byte("encrypted-client-secret"), nil).Once() - - err := env.service.Upsert(context.Background(), settings) - require.NoError(t, err) - }) +func TestService_ListWithRedactedSecrets(t *testing.T) { + t.Parallel() - t.Run("returns error if secrets encryption failed", func(t *testing.T) { - env := setupTestEnv(t) + testCases := []struct { + name string + setup func(env testEnv) + want []*models.SSOSettings + wantErr bool + }{ + { + name: "should return successfully", + setup: func(env testEnv) { + env.store.ExpectedSSOSettings = []*models.SSOSettings{ + { + Provider: "github", + Settings: map[string]any{ + "enabled": true, + "client_secret": base64.RawStdEncoding.EncodeToString([]byte("client_secret")), + "client_id": "client_id", + }, + Source: models.DB, + }, + { + Provider: "okta", + Settings: map[string]any{ + "enabled": false, + "other_secret": base64.RawStdEncoding.EncodeToString([]byte("other_secret")), + "client_id": "client_id", + }, + Source: models.DB, + }, + } + env.secrets.On("Decrypt", mock.Anything, []byte("client_secret"), mock.Anything).Return([]byte("decrypted-client-secret"), nil).Once() + env.secrets.On("Decrypt", mock.Anything, []byte("other_secret"), mock.Anything).Return([]byte("decrypted-other-secret"), nil).Once() - settings := models.SSOSettings{ - Provider: "azuread", - Settings: map[string]any{ - "client_id": "client-id", - "client_secret": "client-secret", - "enabled": true, + env.fallbackStrategy.ExpectedIsMatch = true + env.fallbackStrategy.ExpectedConfigs = map[string]map[string]any{ + "github": { + "enabled": true, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "okta": { + "enabled": true, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "gitlab": { + "enabled": true, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "generic_oauth": { + "enabled": true, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "google": { + "enabled": true, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "azuread": { + "enabled": true, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "grafana_com": { + "enabled": true, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + } + }, + want: []*models.SSOSettings{ + { + Provider: "github", + Settings: map[string]any{ + "enabled": true, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.DB, + }, + { + Provider: "okta", + Settings: map[string]any{ + "enabled": false, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + "other_secret": "*********", + }, + Source: models.DB, + }, + { + Provider: "gitlab", + Settings: map[string]any{ + "enabled": true, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, + }, + { + Provider: "generic_oauth", + Settings: map[string]any{ + "enabled": true, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, + }, + { + Provider: "google", + Settings: map[string]any{ + "enabled": true, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, + }, + { + Provider: "azuread", + Settings: map[string]any{ + "enabled": true, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, + }, + }, + wantErr: false, + }, + { + name: "should return error if store returns an error", + setup: func(env testEnv) { env.store.ExpectedError = fmt.Errorf("error") }, + want: nil, + wantErr: true, + }, + { + name: "should use the fallback strategy if store returns empty list", + setup: func(env testEnv) { + env.store.ExpectedSSOSettings = []*models.SSOSettings{} + env.fallbackStrategy.ExpectedIsMatch = true + env.fallbackStrategy.ExpectedConfigs = map[string]map[string]any{ + "github": { + "enabled": false, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "okta": { + "enabled": false, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "gitlab": { + "enabled": false, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "generic_oauth": { + "enabled": false, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "google": { + "enabled": false, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "azuread": { + "enabled": false, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "grafana_com": { + "enabled": false, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + } + }, + want: []*models.SSOSettings{ + { + Provider: "github", + Settings: map[string]any{ + "enabled": false, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, + }, + { + Provider: "okta", + Settings: map[string]any{ + "enabled": false, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, + }, + { + Provider: "gitlab", + Settings: map[string]any{ + "enabled": false, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, + }, + { + Provider: "generic_oauth", + Settings: map[string]any{ + "enabled": false, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, + }, + { + Provider: "google", + Settings: map[string]any{ + "enabled": false, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, + }, + { + Provider: "azuread", + Settings: map[string]any{ + "enabled": false, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, + }, + }, + wantErr: false, + }, + { + name: "should return error if any of the fallback strategies was not found", + setup: func(env testEnv) { + env.store.ExpectedSSOSettings = []*models.SSOSettings{} + env.fallbackStrategy.ExpectedIsMatch = false + }, + want: nil, + wantErr: true, + }, + } + for _, tc := range testCases { + // create a local copy of "tc" to allow concurrent access within tests to the different items of testCases, + // otherwise it would be like a moving pointer while tests run in parallel + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + env := setupTestEnv(t) + if tc.setup != nil { + tc.setup(env) + } + + actual, err := env.service.ListWithRedactedSecrets(context.Background()) + + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.ElementsMatch(t, tc.want, actual) + }) + } +} + +func TestService_Upsert(t *testing.T) { + t.Parallel() + + t.Run("successfully upsert SSO settings", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv(t) + + provider := social.AzureADProviderName + settings := models.SSOSettings{ + Provider: provider, + Settings: map[string]any{ + "client_id": "client-id", + "client_secret": "client-secret", + "enabled": true, + }, + IsDeleted: false, + } + var wg sync.WaitGroup + wg.Add(1) + + reloadable := ssosettingstests.NewMockReloadable(t) + reloadable.On("Validate", mock.Anything, settings, mock.Anything).Return(nil) + reloadable.On("Reload", mock.Anything, mock.MatchedBy(func(settings models.SSOSettings) bool { + defer wg.Done() + return settings.Provider == provider && + settings.ID == "someid" && + maps.Equal(settings.Settings, map[string]any{ + "client_id": "client-id", + "client_secret": "client-secret", + "enabled": true, + }) + })).Return(nil).Once() + env.reloadables[provider] = reloadable + env.secrets.On("Encrypt", mock.Anything, []byte(settings.Settings["client_secret"].(string)), mock.Anything).Return([]byte("encrypted-client-secret"), nil).Once() + env.secrets.On("Decrypt", mock.Anything, []byte("encrypted-current-client-secret"), mock.Anything).Return([]byte("current-client-secret"), nil).Once() + + env.store.UpsertFn = func(ctx context.Context, settings *models.SSOSettings) error { + currentTime := time.Now() + settings.ID = "someid" + settings.Created = currentTime + settings.Updated = currentTime + + env.store.ActualSSOSettings = *settings + return nil + } + + env.store.GetFn = func(ctx context.Context, provider string) (*models.SSOSettings, error) { + return &models.SSOSettings{ + ID: "someid", + Provider: provider, + Settings: map[string]any{ + "client_secret": base64.RawStdEncoding.EncodeToString([]byte("encrypted-current-client-secret")), + }, + }, nil + } + err := env.service.Upsert(context.Background(), &settings, &user.SignedInUser{}) + require.NoError(t, err) + + // Wait for the goroutine first to assert the Reload call + wg.Wait() + + settings.Settings["client_secret"] = base64.RawStdEncoding.EncodeToString([]byte("encrypted-client-secret")) + require.EqualValues(t, settings, env.store.ActualSSOSettings) + }) + + t.Run("returns error if provider is not configurable", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv(t) + + provider := social.GrafanaComProviderName + settings := &models.SSOSettings{ + Provider: provider, + Settings: map[string]any{ + "client_id": "client-id", + "client_secret": "client-secret", + "enabled": true, + }, + IsDeleted: false, + } + + reloadable := ssosettingstests.NewMockReloadable(t) + env.reloadables[provider] = reloadable + + err := env.service.Upsert(context.Background(), settings, &user.SignedInUser{}) + require.Error(t, err) + }) + + t.Run("returns error if provider was not found in reloadables", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv(t) + + provider := social.AzureADProviderName + settings := &models.SSOSettings{ + Provider: provider, + Settings: map[string]any{ + "client_id": "client-id", + "client_secret": "client-secret", + "enabled": true, + }, + IsDeleted: false, + } + + reloadable := ssosettingstests.NewMockReloadable(t) + // the reloadable is available for other provider + env.reloadables["github"] = reloadable + + err := env.service.Upsert(context.Background(), settings, &user.SignedInUser{}) + require.Error(t, err) + }) + + t.Run("returns error if validation fails", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv(t) + + provider := social.AzureADProviderName + settings := models.SSOSettings{ + Provider: provider, + Settings: map[string]any{ + "client_id": "client-id", + "client_secret": "client-secret", + "enabled": true, + }, + IsDeleted: false, + } + + reloadable := ssosettingstests.NewMockReloadable(t) + reloadable.On("Validate", mock.Anything, settings, mock.Anything).Return(errors.New("validation failed")) + env.reloadables[provider] = reloadable + + err := env.service.Upsert(context.Background(), &settings, &user.SignedInUser{}) + require.Error(t, err) + }) + + t.Run("returns error if a fallback strategy is not available for the provider", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv(t) + + settings := &models.SSOSettings{ + Provider: social.AzureADProviderName, + Settings: map[string]any{ + "client_id": "client-id", + "client_secret": "client-secret", + "enabled": true, + }, + IsDeleted: false, + } + + env.fallbackStrategy.ExpectedIsMatch = false + + err := env.service.Upsert(context.Background(), settings, &user.SignedInUser{}) + require.Error(t, err) + }) + + t.Run("returns error if secrets encryption failed", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv(t) + + provider := social.OktaProviderName + settings := models.SSOSettings{ + Provider: provider, + Settings: map[string]any{ + "client_id": "client-id", + "client_secret": "client-secret", + "enabled": true, }, IsDeleted: false, } + reloadable := ssosettingstests.NewMockReloadable(t) + reloadable.On("Validate", mock.Anything, settings, mock.Anything).Return(nil) + env.reloadables[provider] = reloadable env.secrets.On("Encrypt", mock.Anything, []byte(settings.Settings["client_secret"].(string)), mock.Anything).Return(nil, errors.New("encryption failed")).Once() - err := env.service.Upsert(context.Background(), settings) + err := env.service.Upsert(context.Background(), &settings, &user.SignedInUser{}) require.Error(t, err) }) + t.Run("should not update the current secret if the secret has not been updated", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv(t) + + provider := social.AzureADProviderName + settings := models.SSOSettings{ + Provider: provider, + Settings: map[string]any{ + "client_id": "client-id", + "client_secret": setting.RedactedPassword, + "enabled": true, + }, + IsDeleted: false, + } + + env.store.ExpectedSSOSetting = &models.SSOSettings{ + Provider: provider, + Settings: map[string]any{ + "client_secret": base64.RawStdEncoding.EncodeToString([]byte("current-client-secret")), + }, + } + + reloadable := ssosettingstests.NewMockReloadable(t) + reloadable.On("Validate", mock.Anything, settings, mock.Anything).Return(nil) + reloadable.On("Reload", mock.Anything, mock.Anything).Return(nil).Maybe() + env.reloadables[provider] = reloadable + env.secrets.On("Decrypt", mock.Anything, []byte("current-client-secret"), mock.Anything).Return([]byte("encrypted-client-secret"), nil).Once() + env.secrets.On("Encrypt", mock.Anything, []byte("encrypted-client-secret"), mock.Anything).Return([]byte("current-client-secret"), nil).Once() + + err := env.service.Upsert(context.Background(), &settings, &user.SignedInUser{}) + require.NoError(t, err) + + settings.Settings["client_secret"] = base64.RawStdEncoding.EncodeToString([]byte("current-client-secret")) + require.EqualValues(t, settings, env.store.ActualSSOSettings) + }) + t.Run("returns error if store failed to upsert settings", func(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) + provider := social.AzureADProviderName settings := models.SSOSettings{ - Provider: "azuread", + Provider: provider, Settings: map[string]any{ "client_id": "client-id", "client_secret": "client-secret", @@ -300,76 +1047,366 @@ func TestSSOSettingsService_Upsert(t *testing.T) { IsDeleted: false, } + reloadable := ssosettingstests.NewMockReloadable(t) + reloadable.On("Validate", mock.Anything, settings, mock.Anything).Return(nil) + env.reloadables[provider] = reloadable env.secrets.On("Encrypt", mock.Anything, []byte(settings.Settings["client_secret"].(string)), mock.Anything).Return([]byte("encrypted-client-secret"), nil).Once() - env.store.ExpectedError = errors.New("upsert failed") + env.store.GetFn = func(ctx context.Context, provider string) (*models.SSOSettings, error) { + return &models.SSOSettings{}, nil + } + + env.store.UpsertFn = func(ctx context.Context, settings *models.SSOSettings) error { + return errors.New("failed to upsert settings") + } - err := env.service.Upsert(context.Background(), settings) + err := env.service.Upsert(context.Background(), &settings, &user.SignedInUser{}) require.Error(t, err) }) + + t.Run("successfully upsert SSO settings if reload fails", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv(t) + + provider := social.AzureADProviderName + settings := models.SSOSettings{ + Provider: provider, + Settings: map[string]any{ + "client_id": "client-id", + "client_secret": "client-secret", + "enabled": true, + }, + IsDeleted: false, + } + + reloadable := ssosettingstests.NewMockReloadable(t) + reloadable.On("Validate", mock.Anything, settings, mock.Anything).Return(nil) + reloadable.On("Reload", mock.Anything, mock.Anything).Return(errors.New("failed reloading new settings")).Maybe() + env.reloadables[provider] = reloadable + env.secrets.On("Encrypt", mock.Anything, []byte(settings.Settings["client_secret"].(string)), mock.Anything).Return([]byte("encrypted-client-secret"), nil).Once() + + err := env.service.Upsert(context.Background(), &settings, &user.SignedInUser{}) + require.NoError(t, err) + + settings.Settings["client_secret"] = base64.RawStdEncoding.EncodeToString([]byte("encrypted-client-secret")) + require.EqualValues(t, settings, env.store.ActualSSOSettings) + }) } -func TestSSOSettingsService_Delete(t *testing.T) { +func TestService_Delete(t *testing.T) { + t.Parallel() + t.Run("successfully delete SSO settings", func(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) - provider := "azuread" - env.store.ExpectedError = nil + var wg sync.WaitGroup + wg.Add(1) + + provider := social.AzureADProviderName + reloadable := ssosettingstests.NewMockReloadable(t) + env.reloadables[provider] = reloadable + + env.fallbackStrategy.ExpectedConfigs = map[string]map[string]any{ + provider: { + "client_id": "client-id", + "client_secret": "client-secret", + "enabled": true, + }, + } + + reloadable.On("Reload", mock.Anything, mock.MatchedBy(func(settings models.SSOSettings) bool { + wg.Done() + return settings.Provider == provider && + settings.ID == "" && + maps.Equal(settings.Settings, map[string]any{ + "client_id": "client-id", + "client_secret": "client-secret", + "enabled": true, + }) + })).Return(nil).Once() err := env.service.Delete(context.Background(), provider) require.NoError(t, err) + + // wait for the goroutine first to assert the Reload call + wg.Wait() }) - t.Run("SSO settings not found for the specified provider", func(t *testing.T) { + t.Run("return error if SSO setting was not found for the specified provider", func(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) - provider := "azuread" + provider := social.AzureADProviderName + reloadable := ssosettingstests.NewMockReloadable(t) + env.reloadables[provider] = reloadable env.store.ExpectedError = ssosettings.ErrNotFound err := env.service.Delete(context.Background(), provider) require.Error(t, err) + require.ErrorIs(t, err, ssosettings.ErrNotFound) }) - t.Run("store fails to delete the SSO settings for the specified provider", func(t *testing.T) { + t.Run("should not delete the SSO settings if the provider is not configurable", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv(t) + env.cfg.SSOSettingsConfigurableProviders = map[string]bool{social.AzureADProviderName: true} + + provider := social.GrafanaComProviderName + env.store.ExpectedError = nil + + err := env.service.Delete(context.Background(), provider) + require.ErrorIs(t, err, ssosettings.ErrNotConfigurable) + }) + + t.Run("return error when store fails to delete the SSO settings for the specified provider", func(t *testing.T) { + t.Parallel() + env := setupTestEnv(t) - provider := "azuread" + provider := social.AzureADProviderName env.store.ExpectedError = errors.New("delete sso settings failed") err := env.service.Delete(context.Background(), provider) require.Error(t, err) require.NotErrorIs(t, err, ssosettings.ErrNotFound) }) + + t.Run("return successfully when the deletion was successful but reloading the settings fail", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv(t) + + provider := social.AzureADProviderName + reloadable := ssosettingstests.NewMockReloadable(t) + env.reloadables[provider] = reloadable + + env.store.GetFn = func(ctx context.Context, provider string) (*models.SSOSettings, error) { + return nil, errors.New("failed to get sso settings") + } + + err := env.service.Delete(context.Background(), provider) + + require.NoError(t, err) + }) +} + +func TestService_DoReload(t *testing.T) { + t.Parallel() + + t.Run("successfully reload settings", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv(t) + + settingsList := []*models.SSOSettings{ + { + Provider: "github", + Settings: map[string]any{ + "enabled": true, + "client_id": "github_client_id", + }, + }, + { + Provider: "google", + Settings: map[string]any{ + "enabled": true, + "client_id": "google_client_id", + }, + }, + { + Provider: "azuread", + Settings: map[string]any{ + "enabled": true, + "client_id": "azuread_client_id", + }, + }, + } + env.store.ExpectedSSOSettings = settingsList + + reloadable := ssosettingstests.NewMockReloadable(t) + + for _, settings := range settingsList { + reloadable.On("Reload", mock.Anything, *settings).Return(nil).Once() + env.reloadables[settings.Provider] = reloadable + } + + env.service.doReload(context.Background()) + }) + + t.Run("failed fetching the SSO settings", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv(t) + + provider := "github" + + env.store.ExpectedError = errors.New("failed fetching the settings") + + reloadable := ssosettingstests.NewMockReloadable(t) + env.reloadables[provider] = reloadable + + env.service.doReload(context.Background()) + }) +} + +func TestService_decryptSecrets(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + setup func(env testEnv) + settings map[string]any + want map[string]any + wantErr bool + }{ + { + name: "should decrypt secrets successfully", + setup: func(env testEnv) { + env.secrets.On("Decrypt", mock.Anything, []byte("client_secret"), mock.Anything).Return([]byte("decrypted-client-secret"), nil).Once() + env.secrets.On("Decrypt", mock.Anything, []byte("other_secret"), mock.Anything).Return([]byte("decrypted-other-secret"), nil).Once() + }, + settings: map[string]any{ + "enabled": true, + "client_secret": base64.RawStdEncoding.EncodeToString([]byte("client_secret")), + "other_secret": base64.RawStdEncoding.EncodeToString([]byte("other_secret")), + }, + want: map[string]any{ + "enabled": true, + "client_secret": "decrypted-client-secret", + "other_secret": "decrypted-other-secret", + }, + }, + { + name: "should not decrypt when a secret is empty", + setup: func(env testEnv) { + env.secrets.On("Decrypt", mock.Anything, []byte("other_secret"), mock.Anything).Return([]byte("decrypted-other-secret"), nil).Once() + }, + settings: map[string]any{ + "enabled": true, + "client_secret": "", + "other_secret": base64.RawStdEncoding.EncodeToString([]byte("other_secret")), + }, + want: map[string]any{ + "enabled": true, + "client_secret": "", + "other_secret": "decrypted-other-secret", + }, + }, + { + name: "should return an error if data is not a string", + settings: map[string]any{ + "enabled": true, + "client_secret": 2, + "other_secret": 2, + }, + wantErr: true, + }, + { + name: "should return an error if data is not a valid base64 string", + settings: map[string]any{ + "enabled": true, + "client_secret": "client_secret", + "other_secret": "other_secret", + }, + wantErr: true, + }, + { + name: "should return an error decryption fails", + setup: func(env testEnv) { + env.secrets.On("Decrypt", mock.Anything, []byte("client_secret"), mock.Anything).Return(nil, errors.New("decryption failed")).Once() + }, + settings: map[string]any{ + "enabled": true, + "client_secret": base64.RawStdEncoding.EncodeToString([]byte("client_secret")), + }, + wantErr: true, + }, + } + + for _, tc := range testCases { + // create a local copy of "tc" to allow concurrent access within tests to the different items of testCases, + // otherwise it would be like a moving pointer while tests run in parallel + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + env := setupTestEnv(t) + + if tc.setup != nil { + tc.setup(env) + } + + actual, err := env.service.decryptSecrets(context.Background(), tc.settings) + + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, actual) + + env.secrets.AssertExpectations(t) + }) + } } func setupTestEnv(t *testing.T) testEnv { + t.Helper() + store := ssosettingstests.NewFakeStore() fallbackStrategy := ssosettingstests.NewFakeFallbackStrategy() secrets := secretsFakes.NewMockService(t) accessControl := acimpl.ProvideAccessControl(setting.NewCfg()) + reloadables := make(map[string]ssosettings.Reloadable) + + fallbackStrategy.ExpectedIsMatch = true + + cfg := &setting.Cfg{ + SSOSettingsConfigurableProviders: map[string]bool{ + "github": true, + "okta": true, + "azuread": true, + "google": true, + "generic_oauth": true, + "gitlab": true, + }, + } - svc := &SSOSettingsService{ - log: log.NewNopLogger(), + svc := &Service{ + logger: log.NewNopLogger(), + cfg: cfg, store: store, ac: accessControl, fbStrategies: []ssosettings.FallbackStrategy{fallbackStrategy}, - reloadables: make(map[string]ssosettings.Reloadable), + reloadables: reloadables, + metrics: newMetrics(prometheus.NewRegistry()), secrets: secrets, } return testEnv{ + cfg: cfg, service: svc, store: store, ac: accessControl, fallbackStrategy: fallbackStrategy, secrets: secrets, + reloadables: reloadables, } } type testEnv struct { - service *SSOSettingsService + cfg *setting.Cfg + service *Service store *ssosettingstests.FakeStore ac accesscontrol.AccessControl fallbackStrategy *ssosettingstests.FakeFallbackStrategy secrets *secretsFakes.MockService + reloadables map[string]ssosettings.Reloadable } diff --git a/pkg/services/ssosettings/ssosettingsimpl/usage_stats.go b/pkg/services/ssosettings/ssosettingsimpl/usage_stats.go new file mode 100644 index 0000000000000..747415d2d7553 --- /dev/null +++ b/pkg/services/ssosettings/ssosettingsimpl/usage_stats.go @@ -0,0 +1,30 @@ +package ssosettingsimpl + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/ssosettings/models" +) + +func (s *Service) getUsageStats(ctx context.Context) (map[string]any, error) { + m := map[string]any{} + + settings, err := s.store.List(ctx) + if err != nil { + return nil, err + } + + configuredInDbCounter := 0 + for _, setting := range settings { + enabledValue := 0 + if setting.Source == models.DB { + configuredInDbCounter++ + enabledValue = 1 + } + m["stats.sso."+setting.Provider+".config.database.count"] = enabledValue + } + + m["stats.sso.configured_in_db.count"] = configuredInDbCounter + + return m, nil +} diff --git a/pkg/services/ssosettings/ssosettingsimpl/usage_stats_test.go b/pkg/services/ssosettings/ssosettingsimpl/usage_stats_test.go new file mode 100644 index 0000000000000..bd7d633ffddc9 --- /dev/null +++ b/pkg/services/ssosettings/ssosettingsimpl/usage_stats_test.go @@ -0,0 +1,68 @@ +package ssosettingsimpl + +import ( + "context" + "testing" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ssosettings/models" + "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" +) + +func TestService_getUsageStats(t *testing.T) { + fakeStore := &ssosettingstests.FakeStore{ + ExpectedSSOSettings: []*models.SSOSettings{ + { + Provider: "google", + Source: models.DB, + }, + { + Provider: "github", + Source: models.System, + }, + { + Provider: "grafana_com", + Source: models.System, + }, + { + Provider: "generic_oauth", + Source: models.DB, + }, + { + Provider: "okta", + Source: models.DB, + }, + { + Provider: "azuread", + Source: models.DB, + }, + { + Provider: "gitlab", + Source: models.DB, + }, + }, + } + svc := &Service{ + logger: log.New("test"), + store: fakeStore, + cfg: &setting.Cfg{}, + } + + actual, err := svc.getUsageStats(context.Background()) + require.NoError(t, err) + + expected := map[string]any{ + "stats.sso.configured_in_db.count": 5, + "stats.sso.azuread.config.database.count": 1, + "stats.sso.gitlab.config.database.count": 1, + "stats.sso.google.config.database.count": 1, + "stats.sso.okta.config.database.count": 1, + "stats.sso.generic_oauth.config.database.count": 1, + "stats.sso.grafana_com.config.database.count": 0, + "stats.sso.github.config.database.count": 0, + } + + require.EqualValues(t, expected, actual) +} diff --git a/pkg/services/ssosettings/ssosettingstests/fallback_strategy_fake.go b/pkg/services/ssosettings/ssosettingstests/fallback_strategy_fake.go index 0a8ae3a9d39d6..ed514f9a99074 100644 --- a/pkg/services/ssosettings/ssosettingstests/fallback_strategy_fake.go +++ b/pkg/services/ssosettings/ssosettingstests/fallback_strategy_fake.go @@ -4,7 +4,7 @@ import context "context" type FakeFallbackStrategy struct { ExpectedIsMatch bool - ExpectedConfig map[string]any + ExpectedConfigs map[string]map[string]any ExpectedError error } @@ -18,5 +18,5 @@ func (f *FakeFallbackStrategy) IsMatch(provider string) bool { } func (f *FakeFallbackStrategy) GetProviderConfig(ctx context.Context, provider string) (map[string]any, error) { - return f.ExpectedConfig, f.ExpectedError + return f.ExpectedConfigs[provider], f.ExpectedError } diff --git a/pkg/services/ssosettings/ssosettingstests/reloadable_mock.go b/pkg/services/ssosettings/ssosettingstests/reloadable_mock.go new file mode 100644 index 0000000000000..599fe1c5c19e1 --- /dev/null +++ b/pkg/services/ssosettings/ssosettingstests/reloadable_mock.go @@ -0,0 +1,67 @@ +// Code generated by mockery v2.40.1. DO NOT EDIT. + +package ssosettingstests + +import ( + context "context" + + identity "github.com/grafana/grafana/pkg/services/auth/identity" + mock "github.com/stretchr/testify/mock" + + models "github.com/grafana/grafana/pkg/services/ssosettings/models" +) + +// MockReloadable is an autogenerated mock type for the Reloadable type +type MockReloadable struct { + mock.Mock +} + +// Reload provides a mock function with given fields: ctx, settings +func (_m *MockReloadable) Reload(ctx context.Context, settings models.SSOSettings) error { + ret := _m.Called(ctx, settings) + + if len(ret) == 0 { + panic("no return value specified for Reload") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, models.SSOSettings) error); ok { + r0 = rf(ctx, settings) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Validate provides a mock function with given fields: ctx, settings, requester +func (_m *MockReloadable) Validate(ctx context.Context, settings models.SSOSettings, requester identity.Requester) error { + ret := _m.Called(ctx, settings, requester) + + if len(ret) == 0 { + panic("no return value specified for Validate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, models.SSOSettings, identity.Requester) error); ok { + r0 = rf(ctx, settings, requester) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewMockReloadable creates a new instance of MockReloadable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockReloadable(t interface { + mock.TestingT + Cleanup(func()) +}) *MockReloadable { + mock := &MockReloadable{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/services/ssosettings/ssosettingstests/service_mock.go b/pkg/services/ssosettings/ssosettingstests/service_mock.go index 04765452a8780..02837917856fa 100644 --- a/pkg/services/ssosettings/ssosettingstests/service_mock.go +++ b/pkg/services/ssosettings/ssosettingstests/service_mock.go @@ -1,13 +1,15 @@ -// Code generated by mockery v2.37.1. DO NOT EDIT. +// Code generated by mockery v2.40.1. DO NOT EDIT. package ssosettingstests import ( context "context" - models "github.com/grafana/grafana/pkg/services/ssosettings/models" + identity "github.com/grafana/grafana/pkg/services/auth/identity" mock "github.com/stretchr/testify/mock" + models "github.com/grafana/grafana/pkg/services/ssosettings/models" + ssosettings "github.com/grafana/grafana/pkg/services/ssosettings" ) @@ -20,6 +22,10 @@ type MockService struct { func (_m *MockService) Delete(ctx context.Context, provider string) error { ret := _m.Called(ctx, provider) + if len(ret) == 0 { + panic("no return value specified for Delete") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { r0 = rf(ctx, provider) @@ -34,6 +40,40 @@ func (_m *MockService) Delete(ctx context.Context, provider string) error { func (_m *MockService) GetForProvider(ctx context.Context, provider string) (*models.SSOSettings, error) { ret := _m.Called(ctx, provider) + if len(ret) == 0 { + panic("no return value specified for GetForProvider") + } + + var r0 *models.SSOSettings + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*models.SSOSettings, error)); ok { + return rf(ctx, provider) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *models.SSOSettings); ok { + r0 = rf(ctx, provider) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.SSOSettings) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, provider) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetForProviderWithRedactedSecrets provides a mock function with given fields: ctx, provider +func (_m *MockService) GetForProviderWithRedactedSecrets(ctx context.Context, provider string) (*models.SSOSettings, error) { + ret := _m.Called(ctx, provider) + + if len(ret) == 0 { + panic("no return value specified for GetForProviderWithRedactedSecrets") + } + var r0 *models.SSOSettings var r1 error if rf, ok := ret.Get(0).(func(context.Context, string) (*models.SSOSettings, error)); ok { @@ -60,6 +100,40 @@ func (_m *MockService) GetForProvider(ctx context.Context, provider string) (*mo func (_m *MockService) List(ctx context.Context) ([]*models.SSOSettings, error) { ret := _m.Called(ctx) + if len(ret) == 0 { + panic("no return value specified for List") + } + + var r0 []*models.SSOSettings + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) ([]*models.SSOSettings, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) []*models.SSOSettings); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.SSOSettings) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListWithRedactedSecrets provides a mock function with given fields: ctx +func (_m *MockService) ListWithRedactedSecrets(ctx context.Context) ([]*models.SSOSettings, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for ListWithRedactedSecrets") + } + var r0 []*models.SSOSettings var r1 error if rf, ok := ret.Get(0).(func(context.Context) ([]*models.SSOSettings, error)); ok { @@ -86,6 +160,10 @@ func (_m *MockService) List(ctx context.Context) ([]*models.SSOSettings, error) func (_m *MockService) Patch(ctx context.Context, provider string, data map[string]interface{}) error { ret := _m.Called(ctx, provider, data) + if len(ret) == 0 { + panic("no return value specified for Patch") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, string, map[string]interface{}) error); ok { r0 = rf(ctx, provider, data) @@ -106,13 +184,17 @@ func (_m *MockService) Reload(ctx context.Context, provider string) { _m.Called(ctx, provider) } -// Upsert provides a mock function with given fields: ctx, settings -func (_m *MockService) Upsert(ctx context.Context, settings models.SSOSettings) error { - ret := _m.Called(ctx, settings) +// Upsert provides a mock function with given fields: ctx, settings, requester +func (_m *MockService) Upsert(ctx context.Context, settings *models.SSOSettings, requester identity.Requester) error { + ret := _m.Called(ctx, settings, requester) + + if len(ret) == 0 { + panic("no return value specified for Upsert") + } var r0 error - if rf, ok := ret.Get(0).(func(context.Context, models.SSOSettings) error); ok { - r0 = rf(ctx, settings) + if rf, ok := ret.Get(0).(func(context.Context, *models.SSOSettings, identity.Requester) error); ok { + r0 = rf(ctx, settings, requester) } else { r0 = ret.Error(0) } diff --git a/pkg/services/ssosettings/ssosettingstests/store_fake.go b/pkg/services/ssosettings/ssosettingstests/store_fake.go index 7cdb27e7e1784..016263ce8b53b 100644 --- a/pkg/services/ssosettings/ssosettingstests/store_fake.go +++ b/pkg/services/ssosettings/ssosettingstests/store_fake.go @@ -13,6 +13,11 @@ type FakeStore struct { ExpectedSSOSetting *models.SSOSettings ExpectedSSOSettings []*models.SSOSettings ExpectedError error + + ActualSSOSettings models.SSOSettings + + GetFn func(ctx context.Context, provider string) (*models.SSOSettings, error) + UpsertFn func(ctx context.Context, settings *models.SSOSettings) error } func NewFakeStore() *FakeStore { @@ -20,6 +25,9 @@ func NewFakeStore() *FakeStore { } func (f *FakeStore) Get(ctx context.Context, provider string) (*models.SSOSettings, error) { + if f.GetFn != nil { + return f.GetFn(ctx, provider) + } return f.ExpectedSSOSetting, f.ExpectedError } @@ -27,7 +35,13 @@ func (f *FakeStore) List(ctx context.Context) ([]*models.SSOSettings, error) { return f.ExpectedSSOSettings, f.ExpectedError } -func (f *FakeStore) Upsert(ctx context.Context, settings models.SSOSettings) error { +func (f *FakeStore) Upsert(ctx context.Context, settings *models.SSOSettings) error { + if f.UpsertFn != nil { + return f.UpsertFn(ctx, settings) + } + + f.ActualSSOSettings = *settings + return f.ExpectedError } diff --git a/pkg/services/ssosettings/ssosettingstests/store_mock.go b/pkg/services/ssosettings/ssosettingstests/store_mock.go index 55214c18fe88f..009660cd3e619 100644 --- a/pkg/services/ssosettings/ssosettingstests/store_mock.go +++ b/pkg/services/ssosettings/ssosettingstests/store_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.37.1. DO NOT EDIT. +// Code generated by mockery v2.40.1. DO NOT EDIT. package ssosettingstests @@ -18,6 +18,10 @@ type MockStore struct { func (_m *MockStore) Delete(ctx context.Context, provider string) error { ret := _m.Called(ctx, provider) + if len(ret) == 0 { + panic("no return value specified for Delete") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { r0 = rf(ctx, provider) @@ -32,6 +36,10 @@ func (_m *MockStore) Delete(ctx context.Context, provider string) error { func (_m *MockStore) Get(ctx context.Context, provider string) (*models.SSOSettings, error) { ret := _m.Called(ctx, provider) + if len(ret) == 0 { + panic("no return value specified for Get") + } + var r0 *models.SSOSettings var r1 error if rf, ok := ret.Get(0).(func(context.Context, string) (*models.SSOSettings, error)); ok { @@ -58,6 +66,10 @@ func (_m *MockStore) Get(ctx context.Context, provider string) (*models.SSOSetti func (_m *MockStore) List(ctx context.Context) ([]*models.SSOSettings, error) { ret := _m.Called(ctx) + if len(ret) == 0 { + panic("no return value specified for List") + } + var r0 []*models.SSOSettings var r1 error if rf, ok := ret.Get(0).(func(context.Context) ([]*models.SSOSettings, error)); ok { @@ -80,26 +92,16 @@ func (_m *MockStore) List(ctx context.Context) ([]*models.SSOSettings, error) { return r0, r1 } -// Patch provides a mock function with given fields: ctx, provider, data -func (_m *MockStore) Patch(ctx context.Context, provider string, data map[string]interface{}) error { - ret := _m.Called(ctx, provider, data) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string, map[string]interface{}) error); ok { - r0 = rf(ctx, provider, data) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // Upsert provides a mock function with given fields: ctx, settings -func (_m *MockStore) Upsert(ctx context.Context, settings models.SSOSettings) error { +func (_m *MockStore) Upsert(ctx context.Context, settings *models.SSOSettings) error { ret := _m.Called(ctx, settings) + if len(ret) == 0 { + panic("no return value specified for Upsert") + } + var r0 error - if rf, ok := ret.Get(0).(func(context.Context, models.SSOSettings) error); ok { + if rf, ok := ret.Get(0).(func(context.Context, *models.SSOSettings) error); ok { r0 = rf(ctx, settings) } else { r0 = ret.Error(0) diff --git a/pkg/services/ssosettings/strategies/oauth_strategy.go b/pkg/services/ssosettings/strategies/oauth_strategy.go index 4420902bec26e..578e80e31f7e1 100644 --- a/pkg/services/ssosettings/strategies/oauth_strategy.go +++ b/pkg/services/ssosettings/strategies/oauth_strategy.go @@ -2,8 +2,10 @@ package strategies import ( "context" + "maps" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/login/social/connectors" "github.com/grafana/grafana/pkg/services/ssosettings" "github.com/grafana/grafana/pkg/setting" ) @@ -13,6 +15,15 @@ type OAuthStrategy struct { settingsByProvider map[string]map[string]any } +var extraKeysByProvider = map[string]map[string]connectors.ExtraKeyInfo{ + social.AzureADProviderName: connectors.ExtraAzureADSettingKeys, + social.GenericOAuthProviderName: connectors.ExtraGenericOAuthSettingKeys, + social.GitHubProviderName: connectors.ExtraGithubSettingKeys, + social.GoogleProviderName: connectors.ExtraGoogleSettingKeys, + social.GrafanaComProviderName: connectors.ExtraGrafanaComSettingKeys, + social.GrafanaNetProviderName: connectors.ExtraGrafanaComSettingKeys, +} + var _ ssosettings.FallbackStrategy = (*OAuthStrategy)(nil) func NewOAuthStrategy(cfg *setting.Cfg) *OAuthStrategy { @@ -31,24 +42,34 @@ func (s *OAuthStrategy) IsMatch(provider string) bool { } func (s *OAuthStrategy) GetProviderConfig(_ context.Context, provider string) (map[string]any, error) { - return s.settingsByProvider[provider], nil + providerConfig := s.settingsByProvider[provider] + result := make(map[string]any, len(providerConfig)) + maps.Copy(result, providerConfig) + return result, nil } func (s *OAuthStrategy) loadAllSettings() { allProviders := append(ssosettings.AllOAuthProviders, social.GrafanaNetProviderName) for _, provider := range allProviders { settings := s.loadSettingsForProvider(provider) - if provider == social.GrafanaNetProviderName { + // This is required to support the legacy settings for the provider (auth.grafananet section) + // It will use the settings (and overwrite the current grafana_com settings) from auth.grafananet if + // the auth.grafananet section is enabled and the auth.grafana_com section is disabled. + if provider == social.GrafanaNetProviderName && s.shouldUseGrafanaNetSettings() && settings["enabled"] == true { provider = social.GrafanaComProviderName } s.settingsByProvider[provider] = settings } } +func (s *OAuthStrategy) shouldUseGrafanaNetSettings() bool { + return s.settingsByProvider[social.GrafanaComProviderName]["enabled"] == false +} + func (s *OAuthStrategy) loadSettingsForProvider(provider string) map[string]any { - section := s.cfg.SectionWithEnvOverrides("auth." + provider) + section := s.cfg.Raw.Section("auth." + provider) - return map[string]any{ + result := map[string]any{ "client_id": section.Key("client_id").Value(), "client_secret": section.Key("client_secret").Value(), "scopes": section.Key("scopes").Value(), @@ -81,10 +102,21 @@ func (s *OAuthStrategy) loadSettingsForProvider(provider string) map[string]any "auto_login": section.Key("auto_login").MustBool(false), "allowed_groups": section.Key("allowed_groups").Value(), "signout_redirect_url": section.Key("signout_redirect_url").Value(), - "allowed_organizations": section.Key("allowed_organizations").Value(), - "id_token_attribute_name": section.Key("id_token_attribute_name").Value(), - "login_attribute_path": section.Key("login_attribute_path").Value(), - "name_attribute_path": section.Key("name_attribute_path").Value(), - "team_ids": section.Key("team_ids").Value(), } + + extraKeys := extraKeysByProvider[provider] + for key, keyInfo := range extraKeys { + switch keyInfo.Type { + case connectors.Bool: + result[key] = section.Key(key).MustBool(keyInfo.DefaultValue.(bool)) + default: + if _, ok := keyInfo.DefaultValue.(string); !ok { + result[key] = section.Key(key).Value() + } else { + result[key] = section.Key(key).MustString(keyInfo.DefaultValue.(string)) + } + } + } + + return result } diff --git a/pkg/services/ssosettings/strategies/oauth_strategy_test.go b/pkg/services/ssosettings/strategies/oauth_strategy_test.go index 378ed5bf66ccd..2d5a91bb548fe 100644 --- a/pkg/services/ssosettings/strategies/oauth_strategy_test.go +++ b/pkg/services/ssosettings/strategies/oauth_strategy_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "gopkg.in/ini.v1" + "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/setting" ) @@ -94,10 +95,13 @@ var ( } ) -func TestGetProviderConfig_EnvVarsOnly(t *testing.T) { - setupEnvVars(t) +func TestGetProviderConfig(t *testing.T) { + iniFile, err := ini.Load([]byte(iniContent)) + require.NoError(t, err) cfg := setting.NewCfg() + cfg.Raw = iniFile + strategy := NewOAuthStrategy(cfg) result, err := strategy.GetProviderConfig(context.Background(), "generic_oauth") @@ -106,8 +110,32 @@ func TestGetProviderConfig_EnvVarsOnly(t *testing.T) { require.Equal(t, expectedOAuthInfo, result) } -func TestGetProviderConfig_IniFileOnly(t *testing.T) { - iniFile, err := ini.Load([]byte(iniContent)) +func TestGetProviderConfig_ExtraFields(t *testing.T) { + iniWithExtraFields := ` + [auth.azuread] + force_use_graph_api = true + allowed_organizations = org1, org2 + + [auth.github] + team_ids = first, second + allowed_organizations = org1, org2 + + [auth.generic_oauth] + name_attribute_path = name + login_attribute_path = login + id_token_attribute_name = id_token + team_ids = first, second + allowed_organizations = org1, org2 + + [auth.grafana_com] + enabled = true + allowed_organizations = org1, org2 + + [auth.google] + validate_hd = true + ` + + iniFile, err := ini.Load([]byte(iniWithExtraFields)) require.NoError(t, err) cfg := setting.NewCfg() @@ -115,70 +143,135 @@ func TestGetProviderConfig_IniFileOnly(t *testing.T) { strategy := NewOAuthStrategy(cfg) - result, err := strategy.GetProviderConfig(context.Background(), "generic_oauth") - require.NoError(t, err) + t.Run(social.AzureADProviderName, func(t *testing.T) { + result, err := strategy.GetProviderConfig(context.Background(), social.AzureADProviderName) + require.NoError(t, err) - require.Equal(t, expectedOAuthInfo, result) -} + require.Equal(t, true, result["force_use_graph_api"]) + require.Equal(t, "org1, org2", result["allowed_organizations"]) + }) -func TestGetProviderConfig_EnvVarsOverrideIniFileSettings(t *testing.T) { - t.Setenv("GF_AUTH_GENERIC_OAUTH_ENABLED", "false") - t.Setenv("GF_AUTH_GENERIC_OAUTH_SKIP_ORG_ROLE_SYNC", "false") + t.Run(social.GitHubProviderName, func(t *testing.T) { + result, err := strategy.GetProviderConfig(context.Background(), social.GitHubProviderName) + require.NoError(t, err) - iniFile, err := ini.Load([]byte(iniContent)) - require.NoError(t, err) + require.Equal(t, "first, second", result["team_ids"]) + require.Equal(t, "org1, org2", result["allowed_organizations"]) + }) - cfg := setting.NewCfg() - cfg.Raw = iniFile + t.Run(social.GenericOAuthProviderName, func(t *testing.T) { + result, err := strategy.GetProviderConfig(context.Background(), social.GenericOAuthProviderName) + require.NoError(t, err) - strategy := NewOAuthStrategy(cfg) + require.Equal(t, "first, second", result["team_ids"]) + require.Equal(t, "org1, org2", result["allowed_organizations"]) + require.Equal(t, "name", result["name_attribute_path"]) + require.Equal(t, "login", result["login_attribute_path"]) + require.Equal(t, "id_token", result["id_token_attribute_name"]) + }) - result, err := strategy.GetProviderConfig(context.Background(), "generic_oauth") - require.NoError(t, err) + t.Run(social.GrafanaComProviderName, func(t *testing.T) { + result, err := strategy.GetProviderConfig(context.Background(), social.GrafanaComProviderName) + require.NoError(t, err) + + require.Equal(t, "org1, org2", result["allowed_organizations"]) + }) - expectedOAuthInfoWithOverrides := expectedOAuthInfo - expectedOAuthInfoWithOverrides["enabled"] = false - expectedOAuthInfoWithOverrides["skip_org_role_sync"] = false + t.Run(social.GoogleProviderName, func(t *testing.T) { + result, err := strategy.GetProviderConfig(context.Background(), social.GoogleProviderName) + require.NoError(t, err) - require.Equal(t, expectedOAuthInfoWithOverrides, result) + require.Equal(t, true, result["validate_hd"]) + }) } -func setupEnvVars(t *testing.T) { - t.Setenv("GF_AUTH_GENERIC_OAUTH_NAME", "OAuth") - t.Setenv("GF_AUTH_GENERIC_OAUTH_ICON", "signin") - t.Setenv("GF_AUTH_GENERIC_OAUTH_ENABLED", "true") - t.Setenv("GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP", "false") - t.Setenv("GF_AUTH_GENERIC_OAUTH_AUTO_LOGIN", "true") - t.Setenv("GF_AUTH_GENERIC_OAUTH_CLIENT_ID", "test_client_id") - t.Setenv("GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET", "test_client_secret") - t.Setenv("GF_AUTH_GENERIC_OAUTH_SCOPES", "openid, profile, email") - t.Setenv("GF_AUTH_GENERIC_OAUTH_EMPTY_SCOPES", "") - t.Setenv("GF_AUTH_GENERIC_OAUTH_EMAIL_ATTRIBUTE_NAME", "email:primary") - t.Setenv("GF_AUTH_GENERIC_OAUTH_EMAIL_ATTRIBUTE_PATH", "email") - t.Setenv("GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH", "role") - t.Setenv("GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_STRICT", "true") - t.Setenv("GF_AUTH_GENERIC_OAUTH_GROUPS_ATTRIBUTE_PATH", "groups") - t.Setenv("GF_AUTH_GENERIC_OAUTH_TEAM_IDS_ATTRIBUTE_PATH", "team_ids") - t.Setenv("GF_AUTH_GENERIC_OAUTH_AUTH_URL", "test_auth_url") - t.Setenv("GF_AUTH_GENERIC_OAUTH_TOKEN_URL", "test_token_url") - t.Setenv("GF_AUTH_GENERIC_OAUTH_API_URL", "test_api_url") - t.Setenv("GF_AUTH_GENERIC_OAUTH_TEAMS_URL", "test_teams_url") - t.Setenv("GF_AUTH_GENERIC_OAUTH_ALLOWED_DOMAINS", "domain1.com") - t.Setenv("GF_AUTH_GENERIC_OAUTH_ALLOWED_GROUPS", "") - t.Setenv("GF_AUTH_GENERIC_OAUTH_TLS_SKIP_VERIFY_INSECURE", "true") - t.Setenv("GF_AUTH_GENERIC_OAUTH_TLS_CLIENT_CERT", "") - t.Setenv("GF_AUTH_GENERIC_OAUTH_TLS_CLIENT_KEY", "") - t.Setenv("GF_AUTH_GENERIC_OAUTH_TLS_CLIENT_CA", "") - t.Setenv("GF_AUTH_GENERIC_OAUTH_USE_PKCE", "false") - t.Setenv("GF_AUTH_GENERIC_OAUTH_AUTH_STYLE", "inheader") - t.Setenv("GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN", "true") - t.Setenv("GF_AUTH_GENERIC_OAUTH_SKIP_ORG_ROLE_SYNC", "true") - t.Setenv("GF_AUTH_GENERIC_OAUTH_USE_REFRESH_TOKEN", "true") - t.Setenv("GF_AUTH_GENERIC_OAUTH_HOSTED_DOMAIN", "test_hosted_domain") - t.Setenv("GF_AUTH_GENERIC_OAUTH_ALLOWED_ORGANIZATIONS", "org1, org2") - t.Setenv("GF_AUTH_GENERIC_OAUTH_ID_TOKEN_ATTRIBUTE_NAME", "id_token") - t.Setenv("GF_AUTH_GENERIC_OAUTH_LOGIN_ATTRIBUTE_PATH", "login") - t.Setenv("GF_AUTH_GENERIC_OAUTH_NAME_ATTRIBUTE_PATH", "name") - t.Setenv("GF_AUTH_GENERIC_OAUTH_TEAM_IDS", "first, second") - t.Setenv("GF_AUTH_GENERIC_OAUTH_SIGNOUT_REDIRECT_URL", "test_signout_redirect_url") +// TestGetProviderConfig_GrafanaComGrafanaNet tests that the connector is setup using the correct section and it supports +// the legacy settings for the provider (auth.grafananet section). The test cases are based on the current behavior of the +// SocialService's ProvideService method (TestSocialService_ProvideService_GrafanaComGrafanaNet). +func TestGetProviderConfig_GrafanaComGrafanaNet(t *testing.T) { + testCases := []struct { + name string + rawIniContent string + expectedGrafanaComSettings map[string]any + }{ + { + name: "should setup the connector using auth.grafana_com section if it is enabled", + rawIniContent: ` + [auth.grafana_com] + enabled = true + client_id = grafanaComClientId + + [auth.grafananet] + enabled = false + client_id = grafanaNetClientId`, + expectedGrafanaComSettings: map[string]any{ + "enabled": true, + "client_id": "grafanaComClientId", + }, + }, + { + name: "should setup the connector using auth.grafananet section if it is enabled", + rawIniContent: ` + [auth.grafana_com] + enabled = false + client_id = grafanaComClientId + + [auth.grafananet] + enabled = true + client_id = grafanaNetClientId`, + expectedGrafanaComSettings: map[string]any{ + "enabled": true, + "client_id": "grafanaNetClientId", + }, + }, + { + name: "should setup the connector using auth.grafana_com section if both are enabled", + rawIniContent: ` + [auth.grafana_com] + enabled = true + client_id = grafanaComClientId + + [auth.grafananet] + enabled = true + client_id = grafanaNetClientId`, + expectedGrafanaComSettings: map[string]any{ + "enabled": true, + "client_id": "grafanaComClientId", + }, + }, + { + name: "should not setup the connector when both are disabled", + rawIniContent: ` + [auth.grafana_com] + enabled = false + client_id = grafanaComClientId + + [auth.grafananet] + enabled = false + client_id = grafanaNetClientId`, + expectedGrafanaComSettings: map[string]any{ + "enabled": false, + "client_id": "grafanaComClientId", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + iniFile, err := ini.Load([]byte(tc.rawIniContent)) + require.NoError(t, err) + + cfg := setting.NewCfg() + cfg.Raw = iniFile + + strategy := NewOAuthStrategy(cfg) + + actualConfig, err := strategy.GetProviderConfig(context.Background(), "grafana_com") + require.NoError(t, err) + + for key, value := range tc.expectedGrafanaComSettings { + require.Equal(t, value, actualConfig[key], "Difference in key: %s. Expected: %v, got: %v", key, value, actualConfig[key]) + } + }) + } } diff --git a/pkg/services/ssosettings/strategies/saml_strategy.go b/pkg/services/ssosettings/strategies/saml_strategy.go new file mode 100644 index 0000000000000..b8c8dfabf86cb --- /dev/null +++ b/pkg/services/ssosettings/strategies/saml_strategy.go @@ -0,0 +1,66 @@ +package strategies + +import ( + "context" + "time" + + "github.com/grafana/grafana/pkg/services/ssosettings" + "github.com/grafana/grafana/pkg/setting" +) + +type SAMLStrategy struct { + settingsProvider setting.Provider +} + +var _ ssosettings.FallbackStrategy = (*SAMLStrategy)(nil) + +func NewSAMLStrategy(settingsProvider setting.Provider) *SAMLStrategy { + return &SAMLStrategy{ + settingsProvider: settingsProvider, + } +} + +func (s *SAMLStrategy) IsMatch(provider string) bool { + return provider == "saml" +} + +func (s *SAMLStrategy) GetProviderConfig(_ context.Context, provider string) (map[string]any, error) { + return s.loadSAMLSettings(), nil +} + +func (s *SAMLStrategy) loadSAMLSettings() map[string]any { + section := s.settingsProvider.Section("auth.saml") + result := map[string]any{ + "enabled": section.KeyValue("enabled").MustBool(false), + "single_logout": section.KeyValue("single_logout").MustBool(false), + "allow_sign_up": section.KeyValue("allow_sign_up").MustBool(false), + "auto_login": section.KeyValue("auto_login").MustBool(false), + "certificate": section.KeyValue("certificate").MustString(""), + "certificate_path": section.KeyValue("certificate_path").MustString(""), + "private_key": section.KeyValue("private_key").MustString(""), + "private_key_path": section.KeyValue("private_key_path").MustString(""), + "signature_algorithm": section.KeyValue("signature_algorithm").MustString(""), + "idp_metadata": section.KeyValue("idp_metadata").MustString(""), + "idp_metadata_path": section.KeyValue("idp_metadata_path").MustString(""), + "idp_metadata_url": section.KeyValue("idp_metadata_url").MustString(""), + "max_issue_delay": section.KeyValue("max_issue_delay").MustDuration(90 * time.Second), + "metadata_valid_duration": section.KeyValue("metadata_valid_duration").MustDuration(48 * time.Hour), + "allow_idp_initiated": section.KeyValue("allow_idp_initiated").MustBool(false), + "relay_state": section.KeyValue("relay_state").MustString(""), + "assertion_attribute_name": section.KeyValue("assertion_attribute_name").MustString(""), + "assertion_attribute_login": section.KeyValue("assertion_attribute_login").MustString(""), + "assertion_attribute_email": section.KeyValue("assertion_attribute_email").MustString(""), + "assertion_attribute_groups": section.KeyValue("assertion_attribute_groups").MustString(""), + "assertion_attribute_role": section.KeyValue("assertion_attribute_role").MustString(""), + "assertion_attribute_org": section.KeyValue("assertion_attribute_org").MustString(""), + "allowed_organizations": section.KeyValue("allowed_organizations").MustString(""), + "org_mapping": section.KeyValue("org_mapping").MustString(""), + "role_values_editor": section.KeyValue("role_values_editor").MustString(""), + "role_values_admin": section.KeyValue("role_values_admin").MustString(""), + "role_values_grafana_admin": section.KeyValue("role_values_grafana_admin").MustString(""), + "name_id_format": section.KeyValue("name_id_format").MustString(""), + "skip_org_role_sync": section.KeyValue("skip_org_role_sync").MustBool(false), + "role_values_none": section.KeyValue("role_values_none").MustString(""), + } + return result +} diff --git a/pkg/services/ssosettings/strategies/saml_strategy_test.go b/pkg/services/ssosettings/strategies/saml_strategy_test.go new file mode 100644 index 0000000000000..974561949c671 --- /dev/null +++ b/pkg/services/ssosettings/strategies/saml_strategy_test.go @@ -0,0 +1,103 @@ +package strategies + +import ( + "context" + "testing" + "time" + + "gopkg.in/ini.v1" + + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" +) + +var ( + iniSAMLContent = ` + [auth.saml] + enabled = true + single_logout = true + allow_sign_up = true + auto_login = false + certificate = devenv/docker/blocks/auth/saml-enterprise/cert.crt + certificate_path = /path/to/cert + private_key = dGhpcyBpcyBteSBwcml2YXRlIGtleSB0aGF0IEkgd2FudCB0byBnZXQgZW5jb2RlZCBpbiBiYXNlIDY0 + private_key_path = devenv/docker/blocks/auth/saml-enterprise/key.pem + signature_algorithm = rsa-sha256 + idp_metadata = dGhpcyBpcyBteSBwcml2YXRlIGtleSB0aGF0IEkgd2FudCB0byBnZXQgZW5jb2RlZCBpbiBiYXNlIDY0 + idp_metadata_path = /path/to/metadata + idp_metadata_url = http://localhost:8086/realms/grafana/protocol/saml/descriptor + max_issue_delay = 90s + metadata_valid_duration = 48h + allow_idp_initiated = false + relay_state = relay_state + assertion_attribute_name = name + assertion_attribute_login = login + assertion_attribute_email = email + assertion_attribute_groups = groups + assertion_attribute_role = roles + assertion_attribute_org = orgs + allowed_organizations = org1 org2 + org_mapping = org1:1:editor, *:2:viewer + role_values_editor = editor + role_values_admin = admin + role_values_grafana_admin = serveradmin + name_id_format = urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + skip_org_role_sync = false + role_values_none = guest disabled + ` + + expectedSAMLInfo = map[string]any{ + "enabled": true, + "single_logout": true, + "allow_sign_up": true, + "auto_login": false, + "certificate": "devenv/docker/blocks/auth/saml-enterprise/cert.crt", + "certificate_path": "/path/to/cert", + "private_key": "dGhpcyBpcyBteSBwcml2YXRlIGtleSB0aGF0IEkgd2FudCB0byBnZXQgZW5jb2RlZCBpbiBiYXNlIDY0", + "private_key_path": "devenv/docker/blocks/auth/saml-enterprise/key.pem", + "signature_algorithm": "rsa-sha256", + "idp_metadata": "dGhpcyBpcyBteSBwcml2YXRlIGtleSB0aGF0IEkgd2FudCB0byBnZXQgZW5jb2RlZCBpbiBiYXNlIDY0", + "idp_metadata_path": "/path/to/metadata", + "idp_metadata_url": "http://localhost:8086/realms/grafana/protocol/saml/descriptor", + "max_issue_delay": 90 * time.Second, + "metadata_valid_duration": 48 * time.Hour, + "allow_idp_initiated": false, + "relay_state": "relay_state", + "assertion_attribute_name": "name", + "assertion_attribute_login": "login", + "assertion_attribute_email": "email", + "assertion_attribute_groups": "groups", + "assertion_attribute_role": "roles", + "assertion_attribute_org": "orgs", + "allowed_organizations": "org1 org2", + "org_mapping": "org1:1:editor, *:2:viewer", + "role_values_editor": "editor", + "role_values_admin": "admin", + "role_values_grafana_admin": "serveradmin", + "name_id_format": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "skip_org_role_sync": false, + "role_values_none": "guest disabled", + } +) + +func TestSAMLIsMatch(t *testing.T) { + cfg := setting.NewCfg() + strategy := NewSAMLStrategy(&setting.OSSImpl{Cfg: cfg}) + require.True(t, strategy.IsMatch("saml")) + require.False(t, strategy.IsMatch("oauth")) +} + +func TestSAMLGetProviderConfig(t *testing.T) { + configurationFile, err := ini.Load([]byte(iniSAMLContent)) + require.NoError(t, err) + + cfg := setting.NewCfg() + cfg.Raw = configurationFile + + strategy := NewSAMLStrategy(&setting.OSSImpl{Cfg: cfg}) + + result, err := strategy.GetProviderConfig(context.Background(), "saml") + require.NoError(t, err) + + require.Equal(t, expectedSAMLInfo, result) +} diff --git a/pkg/services/ssosettings/validation/oauth_validators.go b/pkg/services/ssosettings/validation/oauth_validators.go new file mode 100644 index 0000000000000..23c8b25660123 --- /dev/null +++ b/pkg/services/ssosettings/validation/oauth_validators.go @@ -0,0 +1,69 @@ +package validation + +import ( + "fmt" + "net/url" + "strings" + + "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/ssosettings" +) + +func AllowAssignGrafanaAdminValidator(info *social.OAuthInfo, requester identity.Requester) error { + if info.AllowAssignGrafanaAdmin && !requester.GetIsGrafanaAdmin() { + return ssosettings.ErrInvalidOAuthConfig("Allow assign Grafana Admin can only be updated by Grafana Server Admins.") + } + return nil +} + +func SkipOrgRoleSyncAllowAssignGrafanaAdminValidator(info *social.OAuthInfo, requester identity.Requester) error { + if info.AllowAssignGrafanaAdmin && info.SkipOrgRoleSync { + return ssosettings.ErrInvalidOAuthConfig("Allow assign Grafana Admin and Skip org role sync are both set thus Grafana Admin role will not be synced. Consider setting one or the other.") + } + return nil +} + +func RequiredValidator(value string, name string) ssosettings.ValidateFunc[social.OAuthInfo] { + return func(info *social.OAuthInfo, requester identity.Requester) error { + if value == "" { + return ssosettings.ErrInvalidOAuthConfig(fmt.Sprintf("%s is required.", name)) + } + return nil + } +} + +func UrlValidator(value string, name string) ssosettings.ValidateFunc[social.OAuthInfo] { + return func(info *social.OAuthInfo, requester identity.Requester) error { + if !isValidUrl(value) { + return ssosettings.ErrInvalidOAuthConfig(fmt.Sprintf("%s is an invalid URL.", name)) + } + return nil + } +} + +func RequiredUrlValidator(value string, name string) ssosettings.ValidateFunc[social.OAuthInfo] { + return func(info *social.OAuthInfo, requester identity.Requester) error { + if err := RequiredValidator(value, name)(info, requester); err != nil { + return err + } + return UrlValidator(value, name)(info, requester) + } +} + +func MustBeEmptyValidator(value string, name string) ssosettings.ValidateFunc[social.OAuthInfo] { + return func(info *social.OAuthInfo, requester identity.Requester) error { + if value != "" { + return ssosettings.ErrInvalidOAuthConfig(fmt.Sprintf("%s must be empty.", name)) + } + return nil + } +} + +func isValidUrl(actual string) bool { + parsed, err := url.ParseRequestURI(actual) + if err != nil { + return false + } + return strings.HasPrefix(parsed.Scheme, "http") && parsed.Host != "" +} diff --git a/pkg/services/ssosettings/validation/oauth_validators_test.go b/pkg/services/ssosettings/validation/oauth_validators_test.go new file mode 100644 index 0000000000000..1435fa110b974 --- /dev/null +++ b/pkg/services/ssosettings/validation/oauth_validators_test.go @@ -0,0 +1,154 @@ +package validation + +import ( + "testing" + + "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/ssosettings" + "github.com/grafana/grafana/pkg/services/user" + "github.com/stretchr/testify/require" +) + +type testCase struct { + name string + input *social.OAuthInfo + requester identity.Requester + wantErr error +} + +func TestUrlValidator(t *testing.T) { + tc := []testCase{ + { + name: "passes when url is valid", + input: &social.OAuthInfo{ + AuthUrl: "https://example.com/auth", + }, + wantErr: nil, + }, + { + name: "fails when url is invalid", + input: &social.OAuthInfo{ + AuthUrl: "file://etc", + }, + wantErr: ssosettings.ErrInvalidOAuthConfig("Auth URL is an invalid URL."), + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + err := UrlValidator(tt.input.AuthUrl, "Auth URL")(tt.input, tt.requester) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestRequiredValidator(t *testing.T) { + tc := []testCase{ + { + name: "passes when client id is not empty", + input: &social.OAuthInfo{ + ClientId: "client-id", + }, + wantErr: nil, + }, + { + name: "fails when client id is empty", + input: &social.OAuthInfo{ + ClientId: "", + }, + wantErr: ssosettings.ErrInvalidOAuthConfig("Client Id is required."), + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + err := RequiredValidator(tt.input.ClientId, "Client Id")(tt.input, tt.requester) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestAllowAssignGrafanaAdminValidator(t *testing.T) { + tc := []testCase{ + { + name: "passes when user is grafana admin and allow assign grafana admin is true", + input: &social.OAuthInfo{ + AllowAssignGrafanaAdmin: true, + }, + requester: &user.SignedInUser{ + IsGrafanaAdmin: true, + }, + wantErr: nil, + }, + { + name: "fails when user is not grafana admin and allow assign grafana admin is true", + input: &social.OAuthInfo{ + AllowAssignGrafanaAdmin: true, + }, + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + wantErr: ssosettings.ErrInvalidOAuthConfig("Allow assign Grafana Admin can only be updated by Grafana Server Admins."), + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + err := AllowAssignGrafanaAdminValidator(tt.input, tt.requester) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestSkipOrgRoleSyncAllowAssignGrafanaAdminValidator(t *testing.T) { + tc := []testCase{ + { + name: "passes when allow assign grafana admin is set, but skip org role sync is not set", + input: &social.OAuthInfo{ + AllowAssignGrafanaAdmin: true, + SkipOrgRoleSync: false, + }, + wantErr: nil, + }, + { + name: "passes when allow assign grafana admin is not set, but skip org role sync is set", + input: &social.OAuthInfo{ + AllowAssignGrafanaAdmin: false, + SkipOrgRoleSync: true, + }, + wantErr: nil, + }, + { + name: "fails when both allow assign grafana admin and skip org role sync is set", + input: &social.OAuthInfo{ + AllowAssignGrafanaAdmin: true, + SkipOrgRoleSync: true, + }, + wantErr: ssosettings.ErrInvalidOAuthConfig("Allow assign Grafana Admin and Skip org role sync are both set thus Grafana Admin role will not be synced. Consider setting one or the other."), + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + err := SkipOrgRoleSyncAllowAssignGrafanaAdminValidator(tt.input, nil) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} diff --git a/pkg/services/ssosettings/validation/validator.go b/pkg/services/ssosettings/validation/validator.go new file mode 100644 index 0000000000000..b235f7f41cb7a --- /dev/null +++ b/pkg/services/ssosettings/validation/validator.go @@ -0,0 +1,16 @@ +package validation + +import ( + "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/ssosettings" +) + +func Validate(info *social.OAuthInfo, requester identity.Requester, validators ...ssosettings.ValidateFunc[social.OAuthInfo]) error { + for _, validatorFunc := range validators { + if err := validatorFunc(info, requester); err != nil { + return err + } + } + return nil +} diff --git a/pkg/services/star/starimpl/store_test.go b/pkg/services/star/starimpl/store_test.go index 52bb12cfde493..1b725ca522499 100644 --- a/pkg/services/star/starimpl/store_test.go +++ b/pkg/services/star/starimpl/store_test.go @@ -8,8 +8,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/star" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type getStore func(db.DB) store func testIntegrationUserStarsDataAccess(t *testing.T, fn getStore) { diff --git a/pkg/services/stats/statsimpl/stats.go b/pkg/services/stats/statsimpl/stats.go index 0a350461b65b1..e2139c80b57df 100644 --- a/pkg/services/stats/statsimpl/stats.go +++ b/pkg/services/stats/statsimpl/stats.go @@ -7,7 +7,6 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" @@ -93,27 +92,6 @@ func (ss *sqlStatsService) GetSystemStats(ctx context.Context, query *stats.GetS sb.Write(`(SELECT MAX(LENGTH(data)) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS dashboard_bytes_max,`, dialect.BooleanStr(false)) sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS folders,`, dialect.BooleanStr(true)) - sb.Write(`( - SELECT COUNT(acl.id) - FROM `+dialect.Quote("dashboard_acl")+` AS acl - INNER JOIN `+dialect.Quote("dashboard")+` AS d - ON d.id = acl.dashboard_id - WHERE d.is_folder = ? - ) AS dashboard_permissions,`, dialect.BooleanStr(false)) - - sb.Write(`( - SELECT COUNT(acl.id) - FROM `+dialect.Quote("dashboard_acl")+` AS acl - INNER JOIN `+dialect.Quote("dashboard")+` AS d - ON d.id = acl.dashboard_id - WHERE d.is_folder = ? - ) AS folder_permissions,`, dialect.BooleanStr(true)) - - sb.Write(viewersPermissionsCounterSQL(ss.db, "dashboards_viewers_can_edit", false, dashboardaccess.PERMISSION_EDIT)) - sb.Write(viewersPermissionsCounterSQL(ss.db, "dashboards_viewers_can_admin", false, dashboardaccess.PERMISSION_ADMIN)) - sb.Write(viewersPermissionsCounterSQL(ss.db, "folders_viewers_can_edit", true, dashboardaccess.PERMISSION_EDIT)) - sb.Write(viewersPermissionsCounterSQL(ss.db, "folders_viewers_can_admin", true, dashboardaccess.PERMISSION_ADMIN)) - sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_provisioning") + `) AS provisioned_dashboards,`) sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_snapshot") + `) AS snapshots,`) sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_version") + `) AS dashboard_versions,`) @@ -169,19 +147,6 @@ func (ss *sqlStatsService) roleCounterSQL(ctx context.Context) string { return sqlQuery } -func viewersPermissionsCounterSQL(db db.DB, statName string, isFolder bool, permission dashboardaccess.PermissionType) string { - dialect := db.GetDialect() - return `( - SELECT COUNT(*) - FROM ` + dialect.Quote("dashboard_acl") + ` AS acl - INNER JOIN ` + dialect.Quote("dashboard") + ` AS d - ON d.id = acl.dashboard_id - WHERE acl.role = '` + string(org.RoleViewer) + `' - AND d.is_folder = ` + dialect.BooleanStr(isFolder) + ` - AND acl.permission = ` + strconv.FormatInt(int64(permission), 10) + ` - ) AS ` + statName + `, ` -} - func (ss *sqlStatsService) GetAdminStats(ctx context.Context, query *stats.GetAdminStatsQuery) (result *stats.AdminStats, err error) { err = ss.db.WithDbSession(ctx, func(dbSession *db.Session) error { dialect := ss.db.GetDialect() diff --git a/pkg/services/stats/statsimpl/stats_test.go b/pkg/services/stats/statsimpl/stats_test.go index 6b32590ed1866..de191803ac531 100644 --- a/pkg/services/stats/statsimpl/stats_test.go +++ b/pkg/services/stats/statsimpl/stats_test.go @@ -19,8 +19,13 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationStatsDataAccess(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/store/config.go b/pkg/services/store/config.go index 68ff6a632fc48..d2f86616702da 100644 --- a/pkg/services/store/config.go +++ b/pkg/services/store/config.go @@ -52,7 +52,7 @@ func LoadStorageConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles) (* } // Save a template version in config - if changed && setting.Env != setting.Prod { + if changed && cfg.Env != setting.Prod { return g, g.save() } return g, nil diff --git a/pkg/services/store/entity/README.md b/pkg/services/store/entity/README.md index 7dd1cbf63ddfb..6370ca6a803c4 100644 --- a/pkg/services/store/entity/README.md +++ b/pkg/services/store/entity/README.md @@ -41,12 +41,18 @@ idForwarding = true storage_type = unified ``` -With this configuration, you can run everything in-process with: +With this configuration, you can run everything in-process. Run the Grafana backend with: ```sh bra run ``` +or + +```sh +make run +``` + The default kubeconfig sends requests directly to the apiserver, to authenticate as a grafana user, create `grafana.kubeconfig`: ```yaml apiVersion: v1 @@ -70,13 +76,22 @@ users: username: <username> password: <password> ``` -Where `<username>` and `<password>` are credentials for basic auth against Grafana. +Where `<username>` and `<password>` are credentials for basic auth against Grafana. For example, with the [default credentials](https://github.com/grafana/grafana/blob/HEAD/contribute/developer-guide.md#backend): +```yaml + username: admin + password: admin +``` -In this mode, you can interact with the k8s api via: +In this mode, you can interact with the k8s api. Make sure you are in the directory where you created `grafana.kubeconfig`. Then run: ```sh kubectl --kubeconfig=./grafana.kubeconfig get playlist ``` +If this is your first time running the command, a successful response would be: +```sh +No resources found in default namespace. +``` + To create a playlist, create a file `playlist-generate.yaml`: ```yaml apiVersion: playlist.grafana.app/v0alpha1 @@ -99,20 +114,51 @@ then run: kubectl --kubeconfig=./grafana.kubeconfig create -f playlist-generate.yaml ``` +For example, a successful response would be: +```sh +playlist.playlist.grafana.app/u394j4d3-s63j-2d74-g8hf-958773jtybf2 created +``` + +When running +```sh +kubectl --kubeconfig=./grafana.kubeconfig get playlist +``` +you should now see something like: +```sh +NAME TITLE INTERVAL CREATED AT +u394j4d3-s63j-2d74-g8hf-958773jtybf2 Playlist with auto generated UID 5m 2023-12-14T13:53:35Z +``` + ### Use a separate database -To run against a separate database, update custom.ini: +By default Unified Storage uses the Grafana database. To run against a separate database, update `custom.ini` by adding the following section to it: ``` [entity_api] db_type = mysql db_host = localhost:3306 db_name = grafana -db_user = grafanauser -db_pass = grafanapass +db_user = <username> +db_pass = <password> +``` + +MySQL and Postgres are both supported. The `<username>` and `<password>` values can be found in the following devenv docker compose files: [MySQL](https://github.com/grafana/grafana/blob/main/devenv/docker/blocks/mysql/docker-compose.yaml#L6-L7) and [Postgres](https://github.com/grafana/grafana/blob/main/devenv/docker/blocks/postgres/docker-compose.yaml#L4-L5). + +Then, run +```sh +make devenv sources=<source> ``` +where source is either `mysql` or `postgres`. + +Finally, run the Grafana backend with -MySQL and Postgres are both supported. +```sh +bra run +``` +or +```sh +make run +``` ### Run as a GRPC service diff --git a/pkg/services/store/entity/client_wrapper.go b/pkg/services/store/entity/client_wrapper.go index d4dadae9414c1..18d151e985180 100644 --- a/pkg/services/store/entity/client_wrapper.go +++ b/pkg/services/store/entity/client_wrapper.go @@ -1,94 +1,30 @@ package entity import ( - context "context" - "strconv" + "github.com/fullstorydev/grpchan" + "github.com/fullstorydev/grpchan/inprocgrpc" + grpcAuth "github.com/grpc-ecosystem/go-grpc-middleware/auth" + "google.golang.org/grpc" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" - status "google.golang.org/grpc/status" - - "github.com/grafana/grafana/pkg/infra/appcontext" + grpcUtils "github.com/grafana/grafana/pkg/services/store/entity/grpc" ) -var _ EntityStoreServer = (*entityStoreClientWrapper)(nil) - -// wrapper for EntityStoreClient that implements EntityStore interface -type entityStoreClientWrapper struct { - EntityStoreClient -} - -func (c *entityStoreClientWrapper) Read(ctx context.Context, in *ReadEntityRequest) (*Entity, error) { - ctx, err := c.wrapContext(ctx) - if err != nil { - return nil, err - } - return c.EntityStoreClient.Read(ctx, in) -} -func (c *entityStoreClientWrapper) BatchRead(ctx context.Context, in *BatchReadEntityRequest) (*BatchReadEntityResponse, error) { - ctx, err := c.wrapContext(ctx) - if err != nil { - return nil, err - } - return c.EntityStoreClient.BatchRead(ctx, in) -} -func (c *entityStoreClientWrapper) Create(ctx context.Context, in *CreateEntityRequest) (*CreateEntityResponse, error) { - ctx, err := c.wrapContext(ctx) - if err != nil { - return nil, err - } - return c.EntityStoreClient.Create(ctx, in) -} -func (c *entityStoreClientWrapper) Update(ctx context.Context, in *UpdateEntityRequest) (*UpdateEntityResponse, error) { - ctx, err := c.wrapContext(ctx) - if err != nil { - return nil, err - } - return c.EntityStoreClient.Update(ctx, in) -} -func (c *entityStoreClientWrapper) Delete(ctx context.Context, in *DeleteEntityRequest) (*DeleteEntityResponse, error) { - ctx, err := c.wrapContext(ctx) - if err != nil { - return nil, err - } - return c.EntityStoreClient.Delete(ctx, in) -} -func (c *entityStoreClientWrapper) History(ctx context.Context, in *EntityHistoryRequest) (*EntityHistoryResponse, error) { - ctx, err := c.wrapContext(ctx) - if err != nil { - return nil, err - } - return c.EntityStoreClient.History(ctx, in) -} -func (c *entityStoreClientWrapper) List(ctx context.Context, in *EntityListRequest) (*EntityListResponse, error) { - ctx, err := c.wrapContext(ctx) - if err != nil { - return nil, err - } - return c.EntityStoreClient.List(ctx, in) -} -func (c *entityStoreClientWrapper) Watch(*EntityWatchRequest, EntityStore_WatchServer) error { - return status.Errorf(codes.Unimplemented, "method Watch not implemented") -} - -func (c *entityStoreClientWrapper) wrapContext(ctx context.Context) (context.Context, error) { - user, err := appcontext.User(ctx) - if err != nil { - return nil, err - } +func NewEntityStoreClientLocal(server EntityStoreServer) EntityStoreClient { + channel := &inprocgrpc.Channel{} - // set grpc metadata into the context to pass to the grpc server - ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs( - "grafana-idtoken", user.IDToken, - "grafana-userid", strconv.FormatInt(user.UserID, 10), - "grafana-orgid", strconv.FormatInt(user.OrgID, 10), - "grafana-login", user.Login, - )) + auth := &grpcUtils.Authenticator{} - return ctx, nil + channel.RegisterService( + grpchan.InterceptServer( + &EntityStore_ServiceDesc, + grpcAuth.UnaryServerInterceptor(auth.Authenticate), + grpcAuth.StreamServerInterceptor(auth.Authenticate), + ), + server, + ) + return NewEntityStoreClient(grpchan.InterceptClientConn(channel, grpcUtils.UnaryClientInterceptor, grpcUtils.StreamClientInterceptor)) } -func NewEntityStoreClientWrapper(cc grpc.ClientConnInterface) EntityStoreServer { - return &entityStoreClientWrapper{&entityStoreClient{cc}} +func NewEntityStoreClientGRPC(channel *grpc.ClientConn) EntityStoreClient { + return NewEntityStoreClient(grpchan.InterceptClientConn(channel, grpcUtils.UnaryClientInterceptor, grpcUtils.StreamClientInterceptor)) } diff --git a/pkg/services/store/entity/db/dbimpl/dbimpl.go b/pkg/services/store/entity/db/dbimpl/dbimpl.go new file mode 100644 index 0000000000000..ff623fe999e97 --- /dev/null +++ b/pkg/services/store/entity/db/dbimpl/dbimpl.go @@ -0,0 +1,155 @@ +package dbimpl + +import ( + "fmt" + "strings" + "time" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/sqlstore/session" + entitydb "github.com/grafana/grafana/pkg/services/store/entity/db" + "github.com/grafana/grafana/pkg/services/store/entity/db/migrations" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" + "github.com/jmoiron/sqlx" + "xorm.io/xorm" +) + +var _ entitydb.EntityDBInterface = (*EntityDB)(nil) + +func ProvideEntityDB(db db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles) (*EntityDB, error) { + return &EntityDB{ + db: db, + cfg: cfg, + features: features, + log: log.New("entity-db"), + }, nil +} + +type EntityDB struct { + db db.DB + features featuremgmt.FeatureToggles + engine *xorm.Engine + cfg *setting.Cfg + log log.Logger +} + +func (db *EntityDB) Init() error { + _, err := db.GetEngine() + return err +} + +func (db *EntityDB) GetEngine() (*xorm.Engine, error) { + if db.engine != nil { + return db.engine, nil + } + + var engine *xorm.Engine + var err error + + cfgSection := db.cfg.SectionWithEnvOverrides("entity_api") + dbType := cfgSection.Key("db_type").MustString("") + + // if explicit connection settings are provided, use them + if dbType != "" { + dbHost := cfgSection.Key("db_host").MustString("") + dbName := cfgSection.Key("db_name").MustString("") + dbUser := cfgSection.Key("db_user").MustString("") + dbPass := cfgSection.Key("db_pass").MustString("") + + if dbType == "postgres" { + // TODO: support all postgres connection options + dbSslMode := cfgSection.Key("db_sslmode").MustString("disable") + + addr, err := util.SplitHostPortDefault(dbHost, "127.0.0.1", "5432") + if err != nil { + return nil, fmt.Errorf("invalid host specifier '%s': %w", dbHost, err) + } + + connectionString := fmt.Sprintf( + "user=%s password=%s host=%s port=%s dbname=%s sslmode=%s", // sslcert=%s sslkey=%s sslrootcert=%s", + dbUser, dbPass, addr.Host, addr.Port, dbName, dbSslMode, // ss.dbCfg.ClientCertPath, ss.dbCfg.ClientKeyPath, ss.dbCfg.CaCertPath + ) + + engine, err = xorm.NewEngine("postgres", connectionString) + if err != nil { + return nil, err + } + + // FIXME: this config option is cockroachdb-specific, it's not supported by postgres + _, err = engine.Exec("SET SESSION enable_experimental_alter_column_type_general=true") + if err != nil { + db.log.Error("error connecting to postgres", "msg", err.Error()) + // FIXME: return nil, err + } + } else if dbType == "mysql" { + // TODO: support all mysql connection options + protocol := "tcp" + if strings.HasPrefix(dbHost, "/") { + protocol = "unix" + } + + connectionString := fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true", + dbUser, dbPass, protocol, dbHost, dbName) + + engine, err = xorm.NewEngine("mysql", connectionString) + if err != nil { + return nil, err + } + + engine.SetMaxOpenConns(0) + engine.SetMaxIdleConns(2) + engine.SetConnMaxLifetime(time.Second * time.Duration(14400)) + + _, err = engine.Exec("SELECT 1") + if err != nil { + return nil, err + } + } else { + // TODO: sqlite support + return nil, fmt.Errorf("invalid db type specified: %s", dbType) + } + + // configure sql logging + debugSQL := cfgSection.Key("log_queries").MustBool(false) + if !debugSQL { + engine.SetLogger(&xorm.DiscardLogger{}) + } else { + // add stack to database calls to be able to see what repository initiated queries. Top 7 items from the stack as they are likely in the xorm library. + // engine.SetLogger(sqlstore.NewXormLogger(log.LvlInfo, log.WithSuffix(log.New("sqlstore.xorm"), log.CallerContextKey, log.StackCaller(log.DefaultCallerDepth)))) + engine.ShowSQL(true) + engine.ShowExecTime(true) + } + // otherwise, try to use the grafana db connection + } else { + if db.db == nil { + return nil, fmt.Errorf("no db connection provided") + } + + engine = db.db.GetEngine() + } + + db.engine = engine + + if err := migrations.MigrateEntityStore(db, db.features); err != nil { + db.engine = nil + return nil, err + } + + return db.engine, nil +} + +func (db *EntityDB) GetSession() (*session.SessionDB, error) { + engine, err := db.GetEngine() + if err != nil { + return nil, err + } + + return session.GetSession(sqlx.NewDb(engine.DB().DB, engine.DriverName())), nil +} + +func (db *EntityDB) GetCfg() *setting.Cfg { + return db.cfg +} diff --git a/pkg/services/store/entity/migrations/entity_store_mig.go b/pkg/services/store/entity/db/migrations/entity_store_mig.go similarity index 94% rename from pkg/services/store/entity/migrations/entity_store_mig.go rename to pkg/services/store/entity/db/migrations/entity_store_mig.go index feb317d86145b..81f194562a585 100644 --- a/pkg/services/store/entity/migrations/entity_store_mig.go +++ b/pkg/services/store/entity/db/migrations/entity_store_mig.go @@ -7,7 +7,7 @@ import ( ) func initEntityTables(mg *migrator.Migrator) string { - marker := "Initialize entity tables (v12)" // changing this key wipe+rewrite everything + marker := "Initialize entity tables (v15)" // changing this key wipe+rewrite everything mg.AddMigration(marker, &migrator.RawSQLMigration{}) tables := []migrator.Table{} @@ -59,6 +59,8 @@ func initEntityTables(mg *migrator.Migrator) string { {Name: "labels", Type: migrator.DB_Text, Nullable: true}, // JSON object {Name: "fields", Type: migrator.DB_Text, Nullable: true}, // JSON object {Name: "errors", Type: migrator.DB_Text, Nullable: true}, // JSON object + + {Name: "action", Type: migrator.DB_Int, Nullable: false}, // 1: create, 2: update, 3: delete }, Indices: []*migrator.Index{ // The keys are ordered for efficiency in mysql queries, not URL consistency @@ -117,10 +119,18 @@ func initEntityTables(mg *migrator.Migrator) string { {Name: "labels", Type: migrator.DB_Text, Nullable: true}, // JSON object {Name: "fields", Type: migrator.DB_Text, Nullable: true}, // JSON object {Name: "errors", Type: migrator.DB_Text, Nullable: true}, // JSON object + + {Name: "action", Type: migrator.DB_Int, Nullable: false}, // 1: create, 2: update, 3: delete }, Indices: []*migrator.Index{ {Cols: []string{"guid", "resource_version"}, Type: migrator.UniqueIndex}, - {Cols: []string{"namespace", "group", "resource", "name", "resource_version"}, Type: migrator.UniqueIndex}, + { + Cols: []string{"namespace", "group", "resource", "name", "resource_version"}, + Type: migrator.UniqueIndex, + Name: "UQE_entity_history_namespace_group_name_version", + }, + // index to support watch poller + {Cols: []string{"resource_version"}, Type: migrator.IndexType}, }, }) diff --git a/pkg/services/store/entity/migrations/migrator.go b/pkg/services/store/entity/db/migrations/migrator.go similarity index 91% rename from pkg/services/store/entity/migrations/migrator.go rename to pkg/services/store/entity/db/migrations/migrator.go index edb1964a2ae45..ecfac6fa22b29 100644 --- a/pkg/services/store/entity/migrations/migrator.go +++ b/pkg/services/store/entity/db/migrations/migrator.go @@ -7,10 +7,10 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/session" - "github.com/grafana/grafana/pkg/services/store/entity/sqlstash" + "github.com/grafana/grafana/pkg/services/store/entity/db" ) -func MigrateEntityStore(db sqlstash.EntityDB, features featuremgmt.FeatureToggles) error { +func MigrateEntityStore(db db.EntityDBInterface, features featuremgmt.FeatureToggles) error { // Skip if feature flag is not enabled if !features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorage) { return nil diff --git a/pkg/services/store/entity/db/service.go b/pkg/services/store/entity/db/service.go index 17d4a74dc0ad2..7a5414dacd60c 100755 --- a/pkg/services/store/entity/db/service.go +++ b/pkg/services/store/entity/db/service.go @@ -1,158 +1,16 @@ package db import ( - "fmt" - "strings" - "time" - - "github.com/jmoiron/sqlx" "xorm.io/xorm" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/featuremgmt" - // "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/session" - "github.com/grafana/grafana/pkg/services/store/entity/migrations" - "github.com/grafana/grafana/pkg/services/store/entity/sqlstash" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" ) -var _ sqlstash.EntityDB = (*EntityDB)(nil) - -func ProvideEntityDB(db db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles) (*EntityDB, error) { - return &EntityDB{ - db: db, - cfg: cfg, - features: features, - log: log.New("entity-db"), - }, nil -} - -type EntityDB struct { - db db.DB - features featuremgmt.FeatureToggles - engine *xorm.Engine - cfg *setting.Cfg - log log.Logger -} - -func (db *EntityDB) Init() error { - _, err := db.GetEngine() - return err -} - -func (db *EntityDB) GetEngine() (*xorm.Engine, error) { - if db.engine != nil { - return db.engine, nil - } - - var engine *xorm.Engine - var err error - - cfgSection := db.cfg.SectionWithEnvOverrides("entity_api") - dbType := cfgSection.Key("db_type").MustString("") - - // if explicit connection settings are provided, use them - if dbType != "" { - dbHost := cfgSection.Key("db_host").MustString("") - dbName := cfgSection.Key("db_name").MustString("") - dbUser := cfgSection.Key("db_user").MustString("") - dbPass := cfgSection.Key("db_pass").MustString("") - - if dbType == "postgres" { - // TODO: support all postgres connection options - dbSslMode := cfgSection.Key("db_sslmode").MustString("disable") - - addr, err := util.SplitHostPortDefault(dbHost, "127.0.0.1", "5432") - if err != nil { - return nil, fmt.Errorf("invalid host specifier '%s': %w", dbHost, err) - } - - connectionString := fmt.Sprintf( - "user=%s password=%s host=%s port=%s dbname=%s sslmode=%s", // sslcert=%s sslkey=%s sslrootcert=%s", - dbUser, dbPass, addr.Host, addr.Port, dbName, dbSslMode, // ss.dbCfg.ClientCertPath, ss.dbCfg.ClientKeyPath, ss.dbCfg.CaCertPath - ) - - engine, err = xorm.NewEngine("postgres", connectionString) - if err != nil { - return nil, err - } - - // FIXME: this config option is cockroachdb-specific, it's not supported by postgres - _, err = engine.Exec("SET SESSION enable_experimental_alter_column_type_general=true") - if err != nil { - db.log.Error("error connecting to postgres", "msg", err.Error()) - // FIXME: return nil, err - } - } else if dbType == "mysql" { - // TODO: support all mysql connection options - protocol := "tcp" - if strings.HasPrefix(dbHost, "/") { - protocol = "unix" - } - - connectionString := fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true", - dbUser, dbPass, protocol, dbHost, dbName) - - engine, err = xorm.NewEngine("mysql", connectionString) - if err != nil { - return nil, err - } - - engine.SetMaxOpenConns(0) - engine.SetMaxIdleConns(2) - engine.SetConnMaxLifetime(time.Second * time.Duration(14400)) - - _, err = engine.Exec("SELECT 1") - if err != nil { - return nil, err - } - } else { - // TODO: sqlite support - return nil, fmt.Errorf("invalid db type specified: %s", dbType) - } - - // configure sql logging - debugSQL := cfgSection.Key("log_queries").MustBool(false) - if !debugSQL { - engine.SetLogger(&xorm.DiscardLogger{}) - } else { - // add stack to database calls to be able to see what repository initiated queries. Top 7 items from the stack as they are likely in the xorm library. - // engine.SetLogger(sqlstore.NewXormLogger(log.LvlInfo, log.WithSuffix(log.New("sqlstore.xorm"), log.CallerContextKey, log.StackCaller(log.DefaultCallerDepth)))) - engine.ShowSQL(true) - engine.ShowExecTime(true) - } - // otherwise, try to use the grafana db connection - } else { - if db.db == nil { - return nil, fmt.Errorf("no db connection provided") - } - - engine = db.db.GetEngine() - } - - db.engine = engine - - if err := migrations.MigrateEntityStore(db, db.features); err != nil { - db.engine = nil - return nil, err - } - - return db.engine, nil -} - -func (db *EntityDB) GetSession() (*session.SessionDB, error) { - engine, err := db.GetEngine() - if err != nil { - return nil, err - } - - return session.GetSession(sqlx.NewDb(engine.DB().DB, engine.DriverName())), nil -} - -func (db *EntityDB) GetCfg() *setting.Cfg { - return db.cfg +type EntityDBInterface interface { + Init() error + GetSession() (*session.SessionDB, error) + GetEngine() (*xorm.Engine, error) + GetCfg() *setting.Cfg } diff --git a/pkg/services/store/entity/entity.pb.go b/pkg/services/store/entity/entity.pb.go index 786c64d252c76..333d7c998fe12 100644 --- a/pkg/services/store/entity/entity.pb.go +++ b/pkg/services/store/entity/entity.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.31.0 -// protoc v4.25.1 +// protoc-gen-go v1.32.0 +// protoc v4.25.2 // source: entity.proto package entity @@ -20,6 +20,62 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// Status enumeration +type Entity_Action int32 + +const ( + Entity_UNKNOWN Entity_Action = 0 + Entity_CREATED Entity_Action = 1 + Entity_UPDATED Entity_Action = 2 + Entity_DELETED Entity_Action = 3 + Entity_ERROR Entity_Action = 4 +) + +// Enum value maps for Entity_Action. +var ( + Entity_Action_name = map[int32]string{ + 0: "UNKNOWN", + 1: "CREATED", + 2: "UPDATED", + 3: "DELETED", + 4: "ERROR", + } + Entity_Action_value = map[string]int32{ + "UNKNOWN": 0, + "CREATED": 1, + "UPDATED": 2, + "DELETED": 3, + "ERROR": 4, + } +) + +func (x Entity_Action) Enum() *Entity_Action { + p := new(Entity_Action) + *p = x + return p +} + +func (x Entity_Action) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Entity_Action) Descriptor() protoreflect.EnumDescriptor { + return file_entity_proto_enumTypes[0].Descriptor() +} + +func (Entity_Action) Type() protoreflect.EnumType { + return &file_entity_proto_enumTypes[0] +} + +func (x Entity_Action) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Entity_Action.Descriptor instead. +func (Entity_Action) EnumDescriptor() ([]byte, []int) { + return file_entity_proto_rawDescGZIP(), []int{0, 0} +} + // Status enumeration type CreateEntityResponse_Status int32 @@ -51,11 +107,11 @@ func (x CreateEntityResponse_Status) String() string { } func (CreateEntityResponse_Status) Descriptor() protoreflect.EnumDescriptor { - return file_entity_proto_enumTypes[0].Descriptor() + return file_entity_proto_enumTypes[1].Descriptor() } func (CreateEntityResponse_Status) Type() protoreflect.EnumType { - return &file_entity_proto_enumTypes[0] + return &file_entity_proto_enumTypes[1] } func (x CreateEntityResponse_Status) Number() protoreflect.EnumNumber { @@ -101,11 +157,11 @@ func (x UpdateEntityResponse_Status) String() string { } func (UpdateEntityResponse_Status) Descriptor() protoreflect.EnumDescriptor { - return file_entity_proto_enumTypes[1].Descriptor() + return file_entity_proto_enumTypes[2].Descriptor() } func (UpdateEntityResponse_Status) Type() protoreflect.EnumType { - return &file_entity_proto_enumTypes[1] + return &file_entity_proto_enumTypes[2] } func (x UpdateEntityResponse_Status) Number() protoreflect.EnumNumber { @@ -151,11 +207,11 @@ func (x DeleteEntityResponse_Status) String() string { } func (DeleteEntityResponse_Status) Descriptor() protoreflect.EnumDescriptor { - return file_entity_proto_enumTypes[2].Descriptor() + return file_entity_proto_enumTypes[3].Descriptor() } func (DeleteEntityResponse_Status) Type() protoreflect.EnumType { - return &file_entity_proto_enumTypes[2] + return &file_entity_proto_enumTypes[3] } func (x DeleteEntityResponse_Status) Number() protoreflect.EnumNumber { @@ -167,56 +223,6 @@ func (DeleteEntityResponse_Status) EnumDescriptor() ([]byte, []int) { return file_entity_proto_rawDescGZIP(), []int{11, 0} } -// Status enumeration -type EntityWatchResponse_Action int32 - -const ( - EntityWatchResponse_UNKNOWN EntityWatchResponse_Action = 0 - EntityWatchResponse_UPDATED EntityWatchResponse_Action = 1 - EntityWatchResponse_DELETED EntityWatchResponse_Action = 2 -) - -// Enum value maps for EntityWatchResponse_Action. -var ( - EntityWatchResponse_Action_name = map[int32]string{ - 0: "UNKNOWN", - 1: "UPDATED", - 2: "DELETED", - } - EntityWatchResponse_Action_value = map[string]int32{ - "UNKNOWN": 0, - "UPDATED": 1, - "DELETED": 2, - } -) - -func (x EntityWatchResponse_Action) Enum() *EntityWatchResponse_Action { - p := new(EntityWatchResponse_Action) - *p = x - return p -} - -func (x EntityWatchResponse_Action) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (EntityWatchResponse_Action) Descriptor() protoreflect.EnumDescriptor { - return file_entity_proto_enumTypes[3].Descriptor() -} - -func (EntityWatchResponse_Action) Type() protoreflect.EnumType { - return &file_entity_proto_enumTypes[3] -} - -func (x EntityWatchResponse_Action) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use EntityWatchResponse_Action.Descriptor instead. -func (EntityWatchResponse_Action) EnumDescriptor() ([]byte, []int) { - return file_entity_proto_rawDescGZIP(), []int{18, 0} -} - // The canonical entity/document data -- this represents the raw bytes and storage level metadata type Entity struct { state protoimpl.MessageState @@ -277,6 +283,8 @@ type Entity struct { Fields map[string]string `protobuf:"bytes,20,rep,name=fields,proto3" json:"fields,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` // When errors exist Errors []*EntityErrorInfo `protobuf:"bytes,21,rep,name=errors,proto3" json:"errors,omitempty"` + // Action code + Action Entity_Action `protobuf:"varint,3,opt,name=action,proto3,enum=entity.Entity_Action" json:"action,omitempty"` } func (x *Entity) Reset() { @@ -500,6 +508,13 @@ func (x *Entity) GetErrors() []*EntityErrorInfo { return nil } +func (x *Entity) GetAction() Entity_Action { + if x != nil { + return x.Action + } + return Entity_UNKNOWN +} + // This stores additional metadata for items entities that were synced from external systmes type EntityOriginInfo struct { state protoimpl.MessageState @@ -1175,6 +1190,12 @@ type EntityHistoryRequest struct { Limit int64 `protobuf:"varint,3,opt,name=limit,proto3" json:"limit,omitempty"` // Starting from the requested page NextPageToken string `protobuf:"bytes,5,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + // Sorting instructions `field ASC/DESC` + Sort []string `protobuf:"bytes,7,rep,name=sort,proto3" json:"sort,omitempty"` + // Return the full body in each payload + WithBody bool `protobuf:"varint,8,opt,name=with_body,json=withBody,proto3" json:"with_body,omitempty"` + // Return the full body in each payload + WithStatus bool `protobuf:"varint,10,opt,name=with_status,json=withStatus,proto3" json:"with_status,omitempty"` } func (x *EntityHistoryRequest) Reset() { @@ -1230,6 +1251,27 @@ func (x *EntityHistoryRequest) GetNextPageToken() string { return "" } +func (x *EntityHistoryRequest) GetSort() []string { + if x != nil { + return x.Sort + } + return nil +} + +func (x *EntityHistoryRequest) GetWithBody() bool { + if x != nil { + return x.WithBody + } + return false +} + +func (x *EntityHistoryRequest) GetWithStatus() bool { + if x != nil { + return x.WithStatus + } + return false +} + type EntityHistoryResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1241,6 +1283,8 @@ type EntityHistoryResponse struct { Versions []*Entity `protobuf:"bytes,2,rep,name=versions,proto3" json:"versions,omitempty"` // More results exist... pass this in the next request NextPageToken string `protobuf:"bytes,3,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + // ResourceVersion of the response + ResourceVersion int64 `protobuf:"varint,4,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` } func (x *EntityHistoryResponse) Reset() { @@ -1296,6 +1340,13 @@ func (x *EntityHistoryResponse) GetNextPageToken() string { return "" } +func (x *EntityHistoryResponse) GetResourceVersion() int64 { + if x != nil { + return x.ResourceVersion + } + return 0 +} + type EntityListRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1323,6 +1374,8 @@ type EntityListRequest struct { WithBody bool `protobuf:"varint,8,opt,name=with_body,json=withBody,proto3" json:"with_body,omitempty"` // Return the full body in each payload WithStatus bool `protobuf:"varint,10,opt,name=with_status,json=withStatus,proto3" json:"with_status,omitempty"` + // list deleted entities instead of active ones + Deleted bool `protobuf:"varint,12,opt,name=deleted,proto3" json:"deleted,omitempty"` } func (x *EntityListRequest) Reset() { @@ -1434,6 +1487,13 @@ func (x *EntityListRequest) GetWithStatus() bool { return false } +func (x *EntityListRequest) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + type ReferenceRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1533,6 +1593,8 @@ type EntityListResponse struct { Results []*Entity `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` // More results exist... pass this in the next request NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + // ResourceVersion of the list response + ResourceVersion int64 `protobuf:"varint,3,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` } func (x *EntityListResponse) Reset() { @@ -1581,14 +1643,21 @@ func (x *EntityListResponse) GetNextPageToken() string { return "" } +func (x *EntityListResponse) GetResourceVersion() int64 { + if x != nil { + return x.ResourceVersion + } + return 0 +} + type EntityWatchRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - // Timestamp of last changes. Empty will default to + // ResourceVersion of last changes. Empty will default to full history Since int64 `protobuf:"varint,1,opt,name=since,proto3" json:"since,omitempty"` - // Watch sppecific entities + // Watch specific entities Key []string `protobuf:"bytes,2,rep,name=key,proto3" json:"key,omitempty"` // limit to a specific resource (empty is all) Resource []string `protobuf:"bytes,3,rep,name=resource,proto3" json:"resource,omitempty"` @@ -1690,10 +1759,8 @@ type EntityWatchResponse struct { // Timestamp the event was sent Timestamp int64 `protobuf:"varint,1,opt,name=timestamp,proto3" json:"timestamp,omitempty"` - // List of entities with the same action - Entity []*Entity `protobuf:"bytes,2,rep,name=entity,proto3" json:"entity,omitempty"` - // Action code - Action EntityWatchResponse_Action `protobuf:"varint,3,opt,name=action,proto3,enum=entity.EntityWatchResponse_Action" json:"action,omitempty"` + // Entity that was created, updated, or deleted + Entity *Entity `protobuf:"bytes,2,opt,name=entity,proto3" json:"entity,omitempty"` } func (x *EntityWatchResponse) Reset() { @@ -1735,20 +1802,13 @@ func (x *EntityWatchResponse) GetTimestamp() int64 { return 0 } -func (x *EntityWatchResponse) GetEntity() []*Entity { +func (x *EntityWatchResponse) GetEntity() *Entity { if x != nil { return x.Entity } return nil } -func (x *EntityWatchResponse) GetAction() EntityWatchResponse_Action { - if x != nil { - return x.Action - } - return EntityWatchResponse_UNKNOWN -} - type EntitySummary struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1958,7 +2018,7 @@ var File_entity_proto protoreflect.FileDescriptor var file_entity_proto_rawDesc = []byte{ 0x0a, 0x0c, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06, - 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0xa7, 0x07, 0x0a, 0x06, 0x45, 0x6e, 0x74, 0x69, 0x74, + 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0x9f, 0x08, 0x0a, 0x06, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x67, 0x75, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x67, 0x75, 0x69, 0x64, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, @@ -2009,269 +2069,282 @@ var file_entity_proto_rawDesc = []byte{ 0x79, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x2f, 0x0a, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x18, 0x15, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, - 0x66, 0x6f, 0x52, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, - 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a, 0x0b, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, - 0x22, 0x50, 0x0a, 0x10, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, - 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x10, 0x0a, 0x03, - 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x12, - 0x0a, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x74, 0x69, - 0x6d, 0x65, 0x22, 0x62, 0x0a, 0x0f, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, 0x72, 0x72, 0x6f, - 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x5f, 0x6a, - 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x64, 0x65, 0x74, 0x61, 0x69, - 0x6c, 0x73, 0x4a, 0x73, 0x6f, 0x6e, 0x22, 0x8e, 0x01, 0x0a, 0x11, 0x52, 0x65, 0x61, 0x64, 0x45, + 0x66, 0x6f, 0x52, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x12, 0x2d, 0x0a, 0x06, 0x61, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x65, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x41, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, + 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a, 0x0b, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, + 0x47, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, + 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, + 0x44, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, + 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x03, 0x12, 0x09, 0x0a, + 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x22, 0x50, 0x0a, 0x10, 0x45, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x16, 0x0a, 0x06, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x62, 0x0a, 0x0f, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x12, 0x0a, + 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x63, 0x6f, 0x64, + 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x64, + 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x0b, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x4a, 0x73, 0x6f, 0x6e, 0x22, 0x8e, + 0x01, 0x0a, 0x11, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x1f, + 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, + 0x49, 0x0a, 0x16, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x05, 0x62, 0x61, 0x74, + 0x63, 0x68, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x52, 0x05, 0x62, 0x61, 0x74, 0x63, 0x68, 0x22, 0x43, 0x0a, 0x17, 0x42, 0x61, + 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x28, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, + 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, + 0x3d, 0x0a, 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, + 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0xcc, + 0x01, 0x0a, 0x14, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, + 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, + 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, + 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3b, + 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x23, + 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x20, 0x0a, 0x06, 0x53, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x00, + 0x12, 0x0b, 0x0a, 0x07, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x22, 0x68, 0x0a, + 0x13, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x29, 0x0a, 0x10, + 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xdb, 0x01, 0x0a, 0x14, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x17, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, + 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, + 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, + 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3b, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x23, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x22, 0x2f, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x09, + 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x50, 0x44, + 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x55, 0x4e, 0x43, 0x48, 0x41, 0x4e, + 0x47, 0x45, 0x44, 0x10, 0x02, 0x22, 0x52, 0x0a, 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, - 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, - 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, - 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, - 0x68, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x49, 0x0a, 0x16, 0x42, 0x61, 0x74, 0x63, 0x68, - 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x2f, 0x0a, 0x05, 0x62, 0x61, 0x74, 0x63, 0x68, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x19, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x05, 0x62, 0x61, 0x74, - 0x63, 0x68, 0x22, 0x43, 0x0a, 0x17, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x45, - 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x28, 0x0a, - 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, - 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x07, - 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, 0x3d, 0x0a, 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, - 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, - 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, - 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, - 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0xcc, 0x01, 0x0a, 0x14, 0x43, 0x72, 0x65, 0x61, 0x74, - 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, - 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, 0x72, - 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x26, - 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, - 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, - 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3b, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x23, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, - 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x22, 0x20, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x09, 0x0a, - 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x43, 0x52, 0x45, 0x41, - 0x54, 0x45, 0x44, 0x10, 0x01, 0x22, 0x68, 0x0a, 0x13, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, - 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x06, - 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, - 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, - 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, - 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, - 0xdb, 0x01, 0x0a, 0x14, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, - 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, - 0x3b, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, - 0x23, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, - 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x2f, 0x0a, 0x06, - 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, - 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0d, - 0x0a, 0x09, 0x55, 0x4e, 0x43, 0x48, 0x41, 0x4e, 0x47, 0x45, 0x44, 0x10, 0x02, 0x22, 0x52, 0x0a, - 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, - 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x22, 0xda, 0x01, 0x0a, 0x14, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, - 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x65, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, - 0x66, 0x6f, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, - 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x12, 0x3b, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x0e, 0x32, 0x23, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, - 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, - 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x2e, - 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, - 0x52, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x01, - 0x12, 0x0c, 0x0a, 0x08, 0x4e, 0x4f, 0x54, 0x46, 0x4f, 0x55, 0x4e, 0x44, 0x10, 0x02, 0x22, 0x66, - 0x0a, 0x14, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, - 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x26, - 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, - 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x7d, 0x0a, 0x15, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, + 0x75, 0x73, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xda, 0x01, 0x0a, 0x14, 0x44, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x17, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3b, 0x0a, 0x06, 0x73, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x23, 0x2e, 0x65, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, + 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x2e, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x44, + 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x4e, 0x4f, 0x54, 0x46, + 0x4f, 0x55, 0x4e, 0x44, 0x10, 0x02, 0x22, 0xb8, 0x01, 0x0a, 0x14, 0x45, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, - 0x79, 0x12, 0x2a, 0x0a, 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, - 0x69, 0x74, 0x79, 0x52, 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x0a, + 0x79, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, + 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, + 0x12, 0x0a, 0x04, 0x73, 0x6f, 0x72, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x73, + 0x6f, 0x72, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, + 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, + 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, + 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x53, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x22, 0xa8, 0x01, 0x0a, 0x15, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, 0x69, 0x73, 0x74, + 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, + 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, + 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, + 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, + 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xa9, 0x03, 0x0a, + 0x11, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, + 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, + 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, + 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, + 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x0b, 0x20, 0x03, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, + 0x6c, 0x64, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, + 0x65, 0x72, 0x12, 0x3d, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x06, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4c, 0x61, + 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, + 0x73, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6f, 0x72, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x04, 0x73, 0x6f, 0x72, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, 0x6f, + 0x64, 0x79, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, 0x6f, + 0x64, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x53, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0c, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x1a, 0x39, 0x0a, + 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, + 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, + 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xb4, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x66, + 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x8f, 0x03, 0x0a, 0x11, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, - 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, - 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, - 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, - 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, - 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x09, 0x52, 0x03, - 0x6b, 0x65, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x3d, 0x0a, 0x06, 0x6c, - 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x65, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, - 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6f, - 0x72, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6f, 0x72, 0x74, 0x12, 0x1b, - 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x08, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x77, - 0x69, 0x74, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x1a, 0x39, 0x0a, 0x0b, - 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xb4, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x66, 0x65, - 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, - 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, - 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, - 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, - 0x70, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, - 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, - 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x66, - 0x0a, 0x12, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x28, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, - 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, - 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x26, - 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, - 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xa9, 0x02, 0x0a, 0x12, 0x45, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, - 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, 0x69, - 0x6e, 0x63, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x3e, 0x0a, 0x06, 0x6c, 0x61, 0x62, - 0x65, 0x6c, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x65, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, - 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, - 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, - 0x68, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, - 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, - 0x38, 0x01, 0x22, 0xc8, 0x01, 0x0a, 0x13, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, - 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, - 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, - 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x12, 0x3a, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x22, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x41, 0x63, - 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x2f, 0x0a, 0x06, - 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, - 0x4e, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, - 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x02, 0x22, 0xa2, 0x04, - 0x0a, 0x0d, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12, - 0x10, 0x0a, 0x03, 0x55, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x55, 0x49, - 0x44, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, - 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, - 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x06, 0x6c, - 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x65, 0x6e, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, + 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, + 0x75, 0x70, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, + 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, + 0x91, 0x01, 0x0a, 0x12, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x28, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, + 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, + 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, + 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x22, 0xa9, 0x02, 0x0a, 0x12, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, + 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x69, + 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, + 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x03, 0x6b, + 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, + 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x3e, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, + 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, + 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, + 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, + 0x6f, 0x64, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, + 0x6f, 0x64, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x53, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, + 0x5b, 0x0a, 0x13, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x12, 0x26, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0xa2, 0x04, 0x0a, + 0x0d, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x10, + 0x0a, 0x03, 0x55, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x55, 0x49, 0x44, + 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, + 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, + 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x06, 0x6c, 0x61, + 0x62, 0x65, 0x6c, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x65, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, + 0x79, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, + 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x12, 0x0a, + 0x04, 0x73, 0x6c, 0x75, 0x67, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6c, 0x75, + 0x67, 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x17, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, + 0x12, 0x39, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x21, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x2d, 0x0a, 0x06, 0x6e, + 0x65, 0x73, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x75, 0x6d, 0x6d, 0x61, - 0x72, 0x79, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, - 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x12, - 0x0a, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6c, - 0x75, 0x67, 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x17, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x12, 0x39, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x21, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x2d, 0x0a, 0x06, - 0x6e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x65, - 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x75, 0x6d, 0x6d, - 0x61, 0x72, 0x79, 0x52, 0x06, 0x6e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x12, 0x3f, 0x0a, 0x0a, 0x72, - 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x1f, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, - 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, - 0x52, 0x0a, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x1a, 0x39, 0x0a, 0x0b, - 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a, 0x0b, 0x46, 0x69, 0x65, 0x6c, 0x64, - 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, - 0x38, 0x01, 0x22, 0x65, 0x0a, 0x17, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, 0x78, 0x74, 0x65, - 0x72, 0x6e, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x16, 0x0a, - 0x06, 0x66, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, - 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x69, 0x64, 0x65, - 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, - 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x32, 0xa8, 0x04, 0x0a, 0x0b, 0x45, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x31, 0x0a, 0x04, 0x52, 0x65, 0x61, - 0x64, 0x12, 0x19, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x45, - 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0e, 0x2e, 0x65, - 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x4c, 0x0a, 0x09, - 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x12, 0x1e, 0x2e, 0x65, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x65, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x43, 0x72, - 0x65, 0x61, 0x74, 0x65, 0x12, 0x1b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x43, 0x72, - 0x65, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1c, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, - 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x43, 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x1b, 0x2e, 0x65, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x1b, - 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x65, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x07, 0x48, 0x69, 0x73, - 0x74, 0x6f, 0x72, 0x79, 0x12, 0x1c, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x3d, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x19, 0x2e, 0x65, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x42, 0x0a, 0x05, 0x57, 0x61, 0x74, 0x63, 0x68, 0x12, 0x1a, 0x2e, 0x65, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, - 0x6e, 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x30, 0x01, 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, - 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, - 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, - 0x73, 0x74, 0x6f, 0x72, 0x65, 0x2f, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x62, 0x06, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x33, + 0x72, 0x79, 0x52, 0x06, 0x6e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x12, 0x3f, 0x0a, 0x0a, 0x72, 0x65, + 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, + 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, 0x78, + 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, + 0x0a, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, + 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, + 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a, 0x0b, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, + 0x01, 0x22, 0x65, 0x0a, 0x17, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x45, 0x78, 0x74, 0x65, 0x72, + 0x6e, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, + 0x66, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x61, + 0x6d, 0x69, 0x6c, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x69, 0x64, 0x65, 0x6e, + 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x64, + 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x32, 0xa8, 0x04, 0x0a, 0x0b, 0x45, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x31, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, + 0x12, 0x19, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0e, 0x2e, 0x65, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x4c, 0x0a, 0x09, 0x42, + 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x12, 0x1e, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x43, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x12, 0x1b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x43, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x1c, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, + 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x1b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x1b, 0x2e, + 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x65, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x07, 0x48, 0x69, 0x73, 0x74, + 0x6f, 0x72, 0x79, 0x12, 0x1c, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x1d, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x3d, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x19, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x42, 0x0a, 0x05, 0x57, 0x61, 0x74, 0x63, 0x68, 0x12, 0x1a, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2e, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x30, 0x01, 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, + 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x73, + 0x74, 0x6f, 0x72, 0x65, 0x2f, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, } var ( @@ -2289,10 +2362,10 @@ func file_entity_proto_rawDescGZIP() []byte { var file_entity_proto_enumTypes = make([]protoimpl.EnumInfo, 4) var file_entity_proto_msgTypes = make([]protoimpl.MessageInfo, 27) var file_entity_proto_goTypes = []interface{}{ - (CreateEntityResponse_Status)(0), // 0: entity.CreateEntityResponse.Status - (UpdateEntityResponse_Status)(0), // 1: entity.UpdateEntityResponse.Status - (DeleteEntityResponse_Status)(0), // 2: entity.DeleteEntityResponse.Status - (EntityWatchResponse_Action)(0), // 3: entity.EntityWatchResponse.Action + (Entity_Action)(0), // 0: entity.Entity.Action + (CreateEntityResponse_Status)(0), // 1: entity.CreateEntityResponse.Status + (UpdateEntityResponse_Status)(0), // 2: entity.UpdateEntityResponse.Status + (DeleteEntityResponse_Status)(0), // 3: entity.DeleteEntityResponse.Status (*Entity)(nil), // 4: entity.Entity (*EntityOriginInfo)(nil), // 5: entity.EntityOriginInfo (*EntityErrorInfo)(nil), // 6: entity.EntityErrorInfo @@ -2326,25 +2399,25 @@ var file_entity_proto_depIdxs = []int32{ 25, // 1: entity.Entity.labels:type_name -> entity.Entity.LabelsEntry 26, // 2: entity.Entity.fields:type_name -> entity.Entity.FieldsEntry 6, // 3: entity.Entity.errors:type_name -> entity.EntityErrorInfo - 7, // 4: entity.BatchReadEntityRequest.batch:type_name -> entity.ReadEntityRequest - 4, // 5: entity.BatchReadEntityResponse.results:type_name -> entity.Entity - 4, // 6: entity.CreateEntityRequest.entity:type_name -> entity.Entity - 6, // 7: entity.CreateEntityResponse.error:type_name -> entity.EntityErrorInfo - 4, // 8: entity.CreateEntityResponse.entity:type_name -> entity.Entity - 0, // 9: entity.CreateEntityResponse.status:type_name -> entity.CreateEntityResponse.Status - 4, // 10: entity.UpdateEntityRequest.entity:type_name -> entity.Entity - 6, // 11: entity.UpdateEntityResponse.error:type_name -> entity.EntityErrorInfo - 4, // 12: entity.UpdateEntityResponse.entity:type_name -> entity.Entity - 1, // 13: entity.UpdateEntityResponse.status:type_name -> entity.UpdateEntityResponse.Status - 6, // 14: entity.DeleteEntityResponse.error:type_name -> entity.EntityErrorInfo - 4, // 15: entity.DeleteEntityResponse.entity:type_name -> entity.Entity - 2, // 16: entity.DeleteEntityResponse.status:type_name -> entity.DeleteEntityResponse.Status - 4, // 17: entity.EntityHistoryResponse.versions:type_name -> entity.Entity - 27, // 18: entity.EntityListRequest.labels:type_name -> entity.EntityListRequest.LabelsEntry - 4, // 19: entity.EntityListResponse.results:type_name -> entity.Entity - 28, // 20: entity.EntityWatchRequest.labels:type_name -> entity.EntityWatchRequest.LabelsEntry - 4, // 21: entity.EntityWatchResponse.entity:type_name -> entity.Entity - 3, // 22: entity.EntityWatchResponse.action:type_name -> entity.EntityWatchResponse.Action + 0, // 4: entity.Entity.action:type_name -> entity.Entity.Action + 7, // 5: entity.BatchReadEntityRequest.batch:type_name -> entity.ReadEntityRequest + 4, // 6: entity.BatchReadEntityResponse.results:type_name -> entity.Entity + 4, // 7: entity.CreateEntityRequest.entity:type_name -> entity.Entity + 6, // 8: entity.CreateEntityResponse.error:type_name -> entity.EntityErrorInfo + 4, // 9: entity.CreateEntityResponse.entity:type_name -> entity.Entity + 1, // 10: entity.CreateEntityResponse.status:type_name -> entity.CreateEntityResponse.Status + 4, // 11: entity.UpdateEntityRequest.entity:type_name -> entity.Entity + 6, // 12: entity.UpdateEntityResponse.error:type_name -> entity.EntityErrorInfo + 4, // 13: entity.UpdateEntityResponse.entity:type_name -> entity.Entity + 2, // 14: entity.UpdateEntityResponse.status:type_name -> entity.UpdateEntityResponse.Status + 6, // 15: entity.DeleteEntityResponse.error:type_name -> entity.EntityErrorInfo + 4, // 16: entity.DeleteEntityResponse.entity:type_name -> entity.Entity + 3, // 17: entity.DeleteEntityResponse.status:type_name -> entity.DeleteEntityResponse.Status + 4, // 18: entity.EntityHistoryResponse.versions:type_name -> entity.Entity + 27, // 19: entity.EntityListRequest.labels:type_name -> entity.EntityListRequest.LabelsEntry + 4, // 20: entity.EntityListResponse.results:type_name -> entity.Entity + 28, // 21: entity.EntityWatchRequest.labels:type_name -> entity.EntityWatchRequest.LabelsEntry + 4, // 22: entity.EntityWatchResponse.entity:type_name -> entity.Entity 29, // 23: entity.EntitySummary.labels:type_name -> entity.EntitySummary.LabelsEntry 6, // 24: entity.EntitySummary.error:type_name -> entity.EntityErrorInfo 30, // 25: entity.EntitySummary.fields:type_name -> entity.EntitySummary.FieldsEntry diff --git a/pkg/services/store/entity/entity.proto b/pkg/services/store/entity/entity.proto index 200e35da0a3a9..769cc09d6aace 100644 --- a/pkg/services/store/entity/entity.proto +++ b/pkg/services/store/entity/entity.proto @@ -81,6 +81,18 @@ message Entity { // When errors exist repeated EntityErrorInfo errors = 21; + + // Action code + Action action = 3; + + // Status enumeration + enum Action { + UNKNOWN = 0; + CREATED = 1; + UPDATED = 2; + DELETED = 3; + ERROR = 4; + } } // This stores additional metadata for items entities that were synced from external systmes @@ -237,6 +249,15 @@ message EntityHistoryRequest { // Starting from the requested page string next_page_token = 5; + + // Sorting instructions `field ASC/DESC` + repeated string sort = 7; + + // Return the full body in each payload + bool with_body = 8; + + // Return the status in each payload + bool with_status = 10; } message EntityHistoryResponse { @@ -248,6 +269,9 @@ message EntityHistoryResponse { // More results exist... pass this in the next request string next_page_token = 3; + + // Resource version of the response + int64 resource_version = 4; } @@ -288,6 +312,9 @@ message EntityListRequest { // Return the full body in each payload bool with_status = 10; + + // list deleted entities instead of active ones + bool deleted = 12; } message ReferenceRequest { @@ -313,6 +340,9 @@ message EntityListResponse { // More results exist... pass this in the next request string next_page_token = 2; + + // ResourceVersion of the list response + int64 resource_version = 3; } //----------------------------------------------- @@ -320,10 +350,10 @@ message EntityListResponse { //----------------------------------------------- message EntityWatchRequest { - // Timestamp of last changes. Empty will default to + // ResourceVersion of last changes. Empty will default to full history int64 since = 1; - // Watch sppecific entities + // Watch specific entities repeated string key = 2; // limit to a specific resource (empty is all) @@ -346,18 +376,8 @@ message EntityWatchResponse { // Timestamp the event was sent int64 timestamp = 1; - // List of entities with the same action - repeated Entity entity = 2; - - // Action code - Action action = 3; - - // Status enumeration - enum Action { - UNKNOWN = 0; - UPDATED = 1; - DELETED = 2; - } + // Entity that was created, updated, or deleted + Entity entity = 2; } message EntitySummary { diff --git a/pkg/services/store/entity/entity_grpc.pb.go b/pkg/services/store/entity/entity_grpc.pb.go index 2d0fd93a52a25..c1a7f6108d389 100644 --- a/pkg/services/store/entity/entity_grpc.pb.go +++ b/pkg/services/store/entity/entity_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.3.0 -// - protoc v4.25.1 +// - protoc v4.25.2 // source: entity.proto package entity diff --git a/pkg/services/store/entity/grpc/authenticator.go b/pkg/services/store/entity/grpc/authenticator.go new file mode 100644 index 0000000000000..c8e52721f922c --- /dev/null +++ b/pkg/services/store/entity/grpc/authenticator.go @@ -0,0 +1,97 @@ +package grpc + +import ( + "context" + "fmt" + "strconv" + + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/grpcserver/interceptors" + "github.com/grafana/grafana/pkg/services/user" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +type Authenticator struct{} + +func (f *Authenticator) Authenticate(ctx context.Context) (context.Context, error) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, fmt.Errorf("no metadata found") + } + + // TODO: use id token instead of these fields + login := md.Get("grafana-login")[0] + if login == "" { + return nil, fmt.Errorf("no login found in context") + } + userID, err := strconv.ParseInt(md.Get("grafana-userid")[0], 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid user id: %w", err) + } + orgID, err := strconv.ParseInt(md.Get("grafana-orgid")[0], 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid org id: %w", err) + } + + // TODO: validate id token + /* + idToken := md.Get("grafana-idtoken")[0] + if idToken == "" { + return nil, fmt.Errorf("no id token found in context") + } + jwtToken, err := jwt.ParseSigned(idToken) + if err != nil { + return nil, fmt.Errorf("invalid id token: %w", err) + } + claims := jwt.Claims{} + err = jwtToken.UnsafeClaimsWithoutVerification(&claims) + if err != nil { + return nil, fmt.Errorf("invalid id token: %w", err) + } + // fmt.Printf("JWT CLAIMS: %+v\n", claims) + */ + + return appcontext.WithUser(ctx, &user.SignedInUser{ + Login: login, + UserID: userID, + OrgID: orgID, + }), nil +} + +var _ interceptors.Authenticator = (*Authenticator)(nil) + +func UnaryClientInterceptor(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + ctx, err := WrapContext(ctx) + if err != nil { + return err + } + return invoker(ctx, method, req, reply, cc, opts...) +} + +var _ grpc.UnaryClientInterceptor = UnaryClientInterceptor + +func StreamClientInterceptor(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { + ctx, err := WrapContext(ctx) + if err != nil { + return nil, err + } + return streamer(ctx, desc, cc, method, opts...) +} + +var _ grpc.StreamClientInterceptor = StreamClientInterceptor + +func WrapContext(ctx context.Context) (context.Context, error) { + user, err := appcontext.User(ctx) + if err != nil { + return ctx, err + } + + // set grpc metadata into the context to pass to the grpc server + return metadata.NewOutgoingContext(ctx, metadata.Pairs( + "grafana-idtoken", user.IDToken, + "grafana-userid", strconv.FormatInt(user.UserID, 10), + "grafana-orgid", strconv.FormatInt(user.OrgID, 10), + "grafana-login", user.Login, + )), nil +} diff --git a/pkg/services/store/entity/key.go b/pkg/services/store/entity/key.go index 2820c846d8f04..bcd83dd0a71a8 100644 --- a/pkg/services/store/entity/key.go +++ b/pkg/services/store/entity/key.go @@ -14,10 +14,10 @@ type Key struct { } func ParseKey(key string) (*Key, error) { - // /<group>/<resource>/<namespace>(/<name>(/<subresource>)) - parts := strings.SplitN(key, "/", 6) - if len(parts) < 4 { - return nil, fmt.Errorf("invalid key (expecting at least 3 parts): %s", key) + // /<group>/<resource>[/namespaces/<namespace>][/<name>[/<subresource>]] + parts := strings.Split(key, "/") + if len(parts) < 3 { + return nil, fmt.Errorf("invalid key (expecting at least 2 parts): %s", key) } if parts[0] != "" { @@ -25,24 +25,45 @@ func ParseKey(key string) (*Key, error) { } k := &Key{ - Group: parts[1], - Resource: parts[2], - Namespace: parts[3], + Group: parts[1], + Resource: parts[2], } - if len(parts) > 4 { - k.Name = parts[4] + if len(parts) == 3 { + return k, nil } - if len(parts) > 5 { - k.Subresource = parts[5] + if parts[3] != "namespaces" { + k.Name = parts[3] + if len(parts) > 4 { + k.Subresource = strings.Join(parts[4:], "/") + } + return k, nil + } + + if len(parts) < 5 { + return nil, fmt.Errorf("invalid key (expecting namespace after 'namespaces'): %s", key) + } + + k.Namespace = parts[4] + + if len(parts) == 5 { + return k, nil + } + + k.Name = parts[5] + if len(parts) > 6 { + k.Subresource = strings.Join(parts[6:], "/") } return k, nil } func (k *Key) String() string { - s := "/" + k.Group + "/" + k.Resource + "/" + k.Namespace + s := "/" + k.Group + "/" + k.Resource + if len(k.Namespace) > 0 { + s += "/namespaces/" + k.Namespace + } if len(k.Name) > 0 { s += "/" + k.Name if len(k.Subresource) > 0 { diff --git a/pkg/services/store/entity/server/config.go b/pkg/services/store/entity/server/config.go index cef38e756da13..22bdba146469f 100644 --- a/pkg/services/store/entity/server/config.go +++ b/pkg/services/store/entity/server/config.go @@ -41,7 +41,7 @@ func newConfig(cfg *setting.Cfg) *config { host := fmt.Sprintf("%s:%d", ip, port) return &config{ - enabled: true, // cfg.IsFeatureToggleEnabled(featuremgmt.FlagGrafanaStorageServer), + enabled: true, devMode: cfg.Env == setting.Dev, ip: ip, port: port, diff --git a/pkg/services/store/entity/server/service.go b/pkg/services/store/entity/server/service.go index 10a62adddfbca..1f08551cfad24 100644 --- a/pkg/services/store/entity/server/service.go +++ b/pkg/services/store/entity/server/service.go @@ -2,15 +2,10 @@ package server import ( "context" - "fmt" - "strconv" - "github.com/go-jose/go-jose/v3/jwt" "github.com/grafana/dskit/services" "github.com/prometheus/client_golang/prometheus" - "google.golang.org/grpc/metadata" - "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/modules" "github.com/grafana/grafana/pkg/registry" @@ -18,9 +13,9 @@ import ( "github.com/grafana/grafana/pkg/services/grpcserver" "github.com/grafana/grafana/pkg/services/grpcserver/interceptors" "github.com/grafana/grafana/pkg/services/store/entity" - entityDB "github.com/grafana/grafana/pkg/services/store/entity/db" + "github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl" + "github.com/grafana/grafana/pkg/services/store/entity/grpc" "github.com/grafana/grafana/pkg/services/store/entity/sqlstash" - "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -58,53 +53,6 @@ type service struct { authenticator interceptors.Authenticator } -type Authenticator struct{} - -func (f *Authenticator) Authenticate(ctx context.Context) (context.Context, error) { - md, ok := metadata.FromIncomingContext(ctx) - if !ok { - return nil, fmt.Errorf("no metadata found") - } - - // TODO: use id token instead of these fields - login := md.Get("grafana-login")[0] - if login == "" { - return nil, fmt.Errorf("no login found in context") - } - userID, err := strconv.ParseInt(md.Get("grafana-userid")[0], 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid user id: %w", err) - } - orgID, err := strconv.ParseInt(md.Get("grafana-orgid")[0], 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid org id: %w", err) - } - - // TODO: validate id token - idToken := md.Get("grafana-idtoken")[0] - if idToken == "" { - return nil, fmt.Errorf("no id token found in context") - } - jwtToken, err := jwt.ParseSigned(idToken) - if err != nil { - return nil, fmt.Errorf("invalid id token: %w", err) - } - claims := jwt.Claims{} - err = jwtToken.UnsafeClaimsWithoutVerification(&claims) - if err != nil { - return nil, fmt.Errorf("invalid id token: %w", err) - } - // fmt.Printf("JWT CLAIMS: %+v\n", claims) - - return appcontext.WithUser(ctx, &user.SignedInUser{ - Login: login, - UserID: userID, - OrgID: orgID, - }), nil -} - -var _ interceptors.Authenticator = (*Authenticator)(nil) - func ProvideService( cfg *setting.Cfg, features featuremgmt.FeatureToggles, @@ -114,7 +62,7 @@ func ProvideService( return nil, err } - authn := &Authenticator{} + authn := &grpc.Authenticator{} s := &service{ config: newConfig(cfg), @@ -147,7 +95,7 @@ func (s *service) start(ctx context.Context) error { // TODO: use wire // TODO: support using grafana db connection? - eDB, err := entityDB.ProvideEntityDB(nil, s.cfg, s.features) + eDB, err := dbimpl.ProvideEntityDB(nil, s.cfg, s.features) if err != nil { return err } diff --git a/pkg/services/store/entity/sqlstash/broadcaster.go b/pkg/services/store/entity/sqlstash/broadcaster.go new file mode 100644 index 0000000000000..eb5ea6f2b4a3f --- /dev/null +++ b/pkg/services/store/entity/sqlstash/broadcaster.go @@ -0,0 +1,254 @@ +package sqlstash + +import ( + "context" + "fmt" +) + +type ConnectFunc[T any] func(chan T) error + +type Broadcaster[T any] interface { + Subscribe(context.Context) (<-chan T, error) + Unsubscribe(chan T) +} + +func NewBroadcaster[T any](ctx context.Context, connect ConnectFunc[T]) (Broadcaster[T], error) { + b := &broadcaster[T]{} + err := b.start(ctx, connect) + if err != nil { + return nil, err + } + + return b, nil +} + +type broadcaster[T any] struct { + running bool + ctx context.Context + subs map[chan T]struct{} + cache Cache[T] + subscribe chan chan T + unsubscribe chan chan T +} + +func (b *broadcaster[T]) Subscribe(ctx context.Context) (<-chan T, error) { + if !b.running { + return nil, fmt.Errorf("broadcaster not running") + } + + sub := make(chan T, 100) + b.subscribe <- sub + go func() { + <-ctx.Done() + b.unsubscribe <- sub + }() + + return sub, nil +} + +func (b *broadcaster[T]) Unsubscribe(sub chan T) { + b.unsubscribe <- sub +} + +func (b *broadcaster[T]) start(ctx context.Context, connect ConnectFunc[T]) error { + if b.running { + return fmt.Errorf("broadcaster already running") + } + + stream := make(chan T, 100) + + err := connect(stream) + if err != nil { + return err + } + + b.ctx = ctx + + b.cache = NewCache[T](ctx, 100) + b.subscribe = make(chan chan T, 100) + b.unsubscribe = make(chan chan T, 100) + b.subs = make(map[chan T]struct{}) + + go b.stream(stream) + + b.running = true + return nil +} + +func (b *broadcaster[T]) stream(input chan T) { + for { + select { + // context cancelled + case <-b.ctx.Done(): + close(input) + for sub := range b.subs { + close(sub) + delete(b.subs, sub) + } + b.running = false + return + // new subscriber + case sub := <-b.subscribe: + // send initial batch of cached items + err := b.cache.ReadInto(sub) + if err != nil { + close(sub) + continue + } + + b.subs[sub] = struct{}{} + // unsubscribe + case sub := <-b.unsubscribe: + if _, ok := b.subs[sub]; ok { + close(sub) + delete(b.subs, sub) + } + // read item from input + case item, ok := <-input: + // input closed, drain subscribers and exit + if !ok { + for sub := range b.subs { + close(sub) + delete(b.subs, sub) + } + b.running = false + return + } + + b.cache.Add(item) + + for sub := range b.subs { + select { + case sub <- item: + default: + // Slow consumer, drop + b.unsubscribe <- sub + } + } + } + } +} + +const DefaultCacheSize = 100 + +type Cache[T any] interface { + Len() int + Add(item T) + Get(i int) T + Range(f func(T) error) error + Slice() []T + ReadInto(dst chan T) error +} + +type cache[T any] struct { + cache []T + size int + cacheZero int + cacheLen int + add chan T + read chan chan T + ctx context.Context +} + +func NewCache[T any](ctx context.Context, size int) Cache[T] { + c := &cache[T]{} + + c.ctx = ctx + if size <= 0 { + size = DefaultCacheSize + } + c.size = size + c.cache = make([]T, c.size) + + c.add = make(chan T) + c.read = make(chan chan T) + + go c.run() + + return c +} + +func (c *cache[T]) Len() int { + return c.cacheLen +} + +func (c *cache[T]) Add(item T) { + c.add <- item +} + +func (c *cache[T]) run() { + for { + select { + case <-c.ctx.Done(): + return + case item := <-c.add: + i := (c.cacheZero + c.cacheLen) % len(c.cache) + c.cache[i] = item + if c.cacheLen < len(c.cache) { + c.cacheLen++ + } else { + c.cacheZero = (c.cacheZero + 1) % len(c.cache) + } + case r := <-c.read: + read: + for i := 0; i < c.cacheLen; i++ { + select { + case r <- c.cache[(c.cacheZero+i)%len(c.cache)]: + // don't wait for slow consumers + default: + break read + } + } + close(r) + } + } +} + +func (c *cache[T]) Get(i int) T { + r := make(chan T, c.size) + c.read <- r + idx := 0 + for item := range r { + if idx == i { + return item + } + idx++ + } + var zero T + return zero +} + +func (c *cache[T]) Range(f func(T) error) error { + r := make(chan T, c.size) + c.read <- r + for item := range r { + err := f(item) + if err != nil { + return err + } + } + return nil +} + +func (c *cache[T]) Slice() []T { + s := make([]T, 0, c.size) + r := make(chan T, c.size) + c.read <- r + for item := range r { + s = append(s, item) + } + return s +} + +func (c *cache[T]) ReadInto(dst chan T) error { + r := make(chan T, c.size) + c.read <- r + for item := range r { + select { + case dst <- item: + default: + return fmt.Errorf("slow consumer") + } + } + return nil +} diff --git a/pkg/services/store/entity/sqlstash/broadcaster_test.go b/pkg/services/store/entity/sqlstash/broadcaster_test.go new file mode 100644 index 0000000000000..fc6a6369a2da8 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/broadcaster_test.go @@ -0,0 +1,106 @@ +package sqlstash + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCache(t *testing.T) { + c := NewCache[int](context.Background(), 10) + + e := []int{} + err := c.Range(func(i int) error { + e = append(e, i) + return nil + }) + require.Nil(t, err) + require.Equal(t, 0, len(e)) + + c.Add(1) + + e = []int{} + err = c.Range(func(i int) error { + e = append(e, i) + return nil + }) + require.Nil(t, err) + require.Equal(t, 1, len(e)) + require.Equal(t, []int{1}, e) + require.Equal(t, 1, c.Get(0)) + + c.Add(2) + c.Add(3) + c.Add(4) + c.Add(5) + c.Add(6) + + // should be able to range over values + e = []int{} + err = c.Range(func(i int) error { + e = append(e, i) + return nil + }) + require.Nil(t, err) + require.Equal(t, 6, len(e)) + require.Equal(t, []int{1, 2, 3, 4, 5, 6}, e) + + // should be able to get length + require.Equal(t, 6, c.Len()) + + // should be able to get values + require.Equal(t, 1, c.Get(0)) + require.Equal(t, 6, c.Get(5)) + // zero value beyond cache size + require.Equal(t, 0, c.Get(6)) + require.Equal(t, 0, c.Get(20)) + require.Equal(t, 0, c.Get(-10)) + + // slice should return all values + require.Equal(t, []int{1, 2, 3, 4, 5, 6}, c.Slice()) + + c.Add(7) + c.Add(8) + c.Add(9) + c.Add(10) + c.Add(11) + + // should be able to range over values + e = []int{} + err = c.Range(func(i int) error { + e = append(e, i) + return nil + }) + require.Nil(t, err) + require.Equal(t, 10, len(e)) + require.Equal(t, []int{2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, e) + + // should be able to get length + require.Equal(t, 10, c.Len()) + + // should be able to get values + require.Equal(t, 2, c.Get(0)) + require.Equal(t, 3, c.Get(1)) + + // slice should return all values + require.Equal(t, []int{2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, c.Slice()) + + c.Add(12) + c.Add(13) + + // should be able to range over values + e = []int{} + err = c.Range(func(i int) error { + e = append(e, i) + return nil + }) + require.Nil(t, err) + require.Equal(t, 10, len(e)) + require.Equal(t, []int{4, 5, 6, 7, 8, 9, 10, 11, 12, 13}, e) + require.Equal(t, 4, c.Get(0)) + require.Equal(t, 5, c.Get(1)) + + // slice should return all values + require.Equal(t, []int{4, 5, 6, 7, 8, 9, 10, 11, 12, 13}, c.Slice()) +} diff --git a/pkg/services/store/entity/sqlstash/folder_support.go b/pkg/services/store/entity/sqlstash/folder_support.go index aa08815aaeae0..d56894bba9376 100644 --- a/pkg/services/store/entity/sqlstash/folder_support.go +++ b/pkg/services/store/entity/sqlstash/folder_support.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" - foldersV0 "github.com/grafana/grafana/pkg/apis/folders/v0alpha1" + folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" "github.com/grafana/grafana/pkg/services/sqlstore/session" ) @@ -44,7 +44,7 @@ func (s *sqlEntityServer) updateFolderTree(ctx context.Context, tx *session.Sess " FROM entity" + " WHERE " + s.dialect.Quote("group") + "=? AND resource=? AND namespace=?" + " ORDER BY slug asc" - args := []interface{}{foldersV0.GROUP, foldersV0.RESOURCE, namespace} + args := []interface{}{folder.GROUP, folder.RESOURCE, namespace} all := []*folderInfo{} rows, err := tx.Query(ctx, query, args...) diff --git a/pkg/services/store/entity/sqlstash/querybuilder.go b/pkg/services/store/entity/sqlstash/querybuilder.go index fe5e2d5b23735..a5da12f6476cc 100644 --- a/pkg/services/store/entity/sqlstash/querybuilder.go +++ b/pkg/services/store/entity/sqlstash/querybuilder.go @@ -1,20 +1,39 @@ package sqlstash import ( + "encoding/json" "strings" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" ) +type Direction int + +const ( + Ascending Direction = iota + Descending +) + +func (d Direction) String() string { + if d == Descending { + return "DESC" + } + return "ASC" +} + type selectQuery struct { dialect migrator.Dialect fields []string // SELECT xyz from string // FROM object + offset int64 limit int64 oneExtra bool where []string args []any + + orderBy []string + direction []Direction } func (q *selectQuery) addWhere(f string, val ...any) { @@ -53,6 +72,31 @@ func (q *selectQuery) addWhereIn(f string, vals []string) { } } +const sqlLikeEscape = "#" + +var sqlLikeEscapeReplacer = strings.NewReplacer( + sqlLikeEscape, sqlLikeEscape+sqlLikeEscape, + "%", sqlLikeEscape+"%", + "_", sqlLikeEscape+"_", +) + +func escapeJSONStringSQLLike(s string) string { + b, _ := json.Marshal(s) + return sqlLikeEscapeReplacer.Replace(string(b)) +} + +func (q *selectQuery) addWhereJsonContainsKV(field string, key string, value string) { + escapedKey := escapeJSONStringSQLLike(key) + escapedValue := escapeJSONStringSQLLike(value) + q.where = append(q.where, q.dialect.Quote(field)+" LIKE ? ESCAPE ?") + q.args = append(q.args, "{%"+escapedKey+":"+escapedValue+"%}", sqlLikeEscape) +} + +func (q *selectQuery) addOrderBy(field string, direction Direction) { + q.orderBy = append(q.orderBy, field) + q.direction = append(q.direction, direction) +} + func (q *selectQuery) toQuery() (string, []any) { args := q.args sb := strings.Builder{} @@ -77,17 +121,27 @@ func (q *selectQuery) toQuery() (string, []any) { } } - if q.limit > 0 || q.oneExtra { - limit := q.limit - if limit < 1 { - limit = 20 - q.limit = limit - } - if q.oneExtra { - limit = limit + 1 + if len(q.orderBy) > 0 && len(q.direction) == len(q.orderBy) { + sb.WriteString(" ORDER BY ") + for i, f := range q.orderBy { + if i > 0 { + sb.WriteString(",") + } + sb.WriteString(q.dialect.Quote(f)) + sb.WriteString(" ") + sb.WriteString(q.direction[i].String()) } - sb.WriteString(" LIMIT ?") - args = append(args, limit) } + + limit := q.limit + if limit < 1 { + limit = 20 + q.limit = limit + } + if q.oneExtra { + limit = limit + 1 + } + sb.WriteString(q.dialect.LimitOffset(limit, q.offset)) + return sb.String(), args } diff --git a/pkg/services/store/entity/sqlstash/sql_storage_server.go b/pkg/services/store/entity/sqlstash/sql_storage_server.go index ad5c93e5e34fa..e00db39e26655 100644 --- a/pkg/services/store/entity/sqlstash/sql_storage_server.go +++ b/pkg/services/store/entity/sqlstash/sql_storage_server.go @@ -3,59 +3,49 @@ package sqlstash import ( "context" "database/sql" + "encoding/base64" "encoding/json" "errors" "fmt" "math/rand" + "slices" "strings" "time" - "xorm.io/xorm" - "github.com/bwmarrin/snowflake" "github.com/google/uuid" - foldersV0 "github.com/grafana/grafana/pkg/apis/folders/v0alpha1" + folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/session" "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/store/entity" - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/services/store/entity/db" ) -type EntityDB interface { - Init() error - GetSession() (*session.SessionDB, error) - GetEngine() (*xorm.Engine, error) - GetCfg() *setting.Cfg -} - // Make sure we implement both store + admin var _ entity.EntityStoreServer = &sqlEntityServer{} -func ProvideSQLEntityServer(db EntityDB /*, cfg *setting.Cfg */) (entity.EntityStoreServer, error) { - snode, err := snowflake.NewNode(rand.Int63n(1024)) - if err != nil { - return nil, err - } - +func ProvideSQLEntityServer(db db.EntityDBInterface /*, cfg *setting.Cfg */) (entity.EntityStoreServer, error) { entityServer := &sqlEntityServer{ - db: db, - log: log.New("sql-entity-server"), - snowflake: snode, + db: db, + log: log.New("sql-entity-server"), + ctx: context.Background(), } return entityServer, nil } type sqlEntityServer struct { - log log.Logger - db EntityDB // needed to keep xorm engine in scope - sess *session.SessionDB - dialect migrator.Dialect - snowflake *snowflake.Node + log log.Logger + db db.EntityDBInterface // needed to keep xorm engine in scope + sess *session.SessionDB + dialect migrator.Dialect + snowflake *snowflake.Node + broadcaster Broadcaster[*entity.Entity] + ctx context.Context } func (s *sqlEntityServer) Init() error { @@ -84,10 +74,33 @@ func (s *sqlEntityServer) Init() error { s.sess = sess s.dialect = migrator.NewDialect(engine.DriverName()) + + // initialize snowflake generator + s.snowflake, err = snowflake.NewNode(rand.Int63n(1024)) + if err != nil { + return err + } + + // set up the broadcaster + s.broadcaster, err = NewBroadcaster(s.ctx, func(stream chan *entity.Entity) error { + // start the poller + go s.poller(stream) + + return nil + }) + if err != nil { + return err + } + return nil } -func (s *sqlEntityServer) getReadFields(r *entity.ReadEntityRequest) []string { +type FieldSelectRequest interface { + GetWithBody() bool + GetWithStatus() bool +} + +func (s *sqlEntityServer) getReadFields(r FieldSelectRequest) []string { fields := []string{ "guid", "key", @@ -98,19 +111,21 @@ func (s *sqlEntityServer) getReadFields(r *entity.ReadEntityRequest) []string { "origin", "origin_key", "origin_ts", "meta", "title", "slug", "description", "labels", "fields", + "message", + "action", } - if r.WithBody { + if r.GetWithBody() { fields = append(fields, `body`) } - if r.WithStatus { + if r.GetWithStatus() { fields = append(fields, "status") } return fields } -func (s *sqlEntityServer) getReadSelect(r *entity.ReadEntityRequest) (string, error) { +func (s *sqlEntityServer) getReadSelect(r FieldSelectRequest) (string, error) { if err := s.Init(); err != nil { return "", err } @@ -124,7 +139,7 @@ func (s *sqlEntityServer) getReadSelect(r *entity.ReadEntityRequest) (string, er return "SELECT " + strings.Join(quotedFields, ","), nil } -func (s *sqlEntityServer) rowToEntity(ctx context.Context, rows *sql.Rows, r *entity.ReadEntityRequest) (*entity.Entity, error) { +func rowToEntity(rows *sql.Rows, r FieldSelectRequest) (*entity.Entity, error) { raw := &entity.Entity{ Origin: &entity.EntityOriginInfo{}, } @@ -143,11 +158,13 @@ func (s *sqlEntityServer) rowToEntity(ctx context.Context, rows *sql.Rows, r *en &raw.Origin.Source, &raw.Origin.Key, &raw.Origin.Time, &raw.Meta, &raw.Title, &raw.Slug, &raw.Description, &labels, &fields, + &raw.Message, + &raw.Action, } - if r.WithBody { + if r.GetWithBody() { args = append(args, &raw.Body) } - if r.WithStatus { + if r.GetWithStatus() { args = append(args, &raw.Status) } @@ -156,10 +173,6 @@ func (s *sqlEntityServer) rowToEntity(ctx context.Context, rows *sql.Rows, r *en return nil, err } - if raw.Origin.Source == "" { - raw.Origin = nil - } - // unmarshal json labels if labels != "" { if err := json.Unmarshal([]byte(labels), &raw.Labels); err != nil { @@ -167,6 +180,17 @@ func (s *sqlEntityServer) rowToEntity(ctx context.Context, rows *sql.Rows, r *en } } + // set empty body, meta or status to nil + if raw.Body != nil && len(raw.Body) == 0 { + raw.Body = nil + } + if raw.Meta != nil && len(raw.Meta) == 0 { + raw.Meta = nil + } + if raw.Status != nil && len(raw.Status) == 0 { + raw.Status = nil + } + return raw, nil } @@ -197,7 +221,7 @@ func (s *sqlEntityServer) read(ctx context.Context, tx session.SessionQuerier, r if r.ResourceVersion != 0 { table = "entity_history" - where = append(where, s.dialect.Quote("resource_version")+"=?") + where = append(where, s.dialect.Quote("resource_version")+">=?") args = append(args, r.ResourceVersion) } @@ -213,6 +237,11 @@ func (s *sqlEntityServer) read(ctx context.Context, tx session.SessionQuerier, r query += " FROM " + table + " WHERE " + strings.Join(where, " AND ") + if r.ResourceVersion != 0 { + query += " ORDER BY resource_version DESC" + } + query += " LIMIT 1" + s.log.Debug("read", "query", query, "args", args) rows, err := tx.Query(ctx, query, args...) @@ -225,7 +254,7 @@ func (s *sqlEntityServer) read(ctx context.Context, tx session.SessionQuerier, r return &entity.Entity{}, nil } - return s.rowToEntity(ctx, rows, r) + return rowToEntity(rows, r) } func (s *sqlEntityServer) BatchRead(ctx context.Context, b *entity.BatchReadEntityRequest) (*entity.BatchReadEntityResponse, error) { @@ -271,7 +300,7 @@ func (s *sqlEntityServer) BatchRead(ctx context.Context, b *entity.BatchReadEnti // TODO? make sure the results are in order? rsp := &entity.BatchReadEntityResponse{} for rows.Next() { - r, err := s.rowToEntity(ctx, rows, req) + r, err := rowToEntity(rows, req) if err != nil { return nil, err } @@ -287,6 +316,10 @@ func (s *sqlEntityServer) Create(ctx context.Context, r *entity.CreateEntityRequ } createdAt := r.Entity.CreatedAt + if createdAt < 1000 { + createdAt = time.Now().UnixMilli() + } + createdBy := r.Entity.CreatedBy if createdBy == "" { modifier, err := appcontext.User(ctx) @@ -298,6 +331,7 @@ func (s *sqlEntityServer) Create(ctx context.Context, r *entity.CreateEntityRequ } createdBy = store.GetUserIDString(modifier) } + updatedAt := r.Entity.UpdatedAt updatedBy := r.Entity.UpdatedBy @@ -324,6 +358,10 @@ func (s *sqlEntityServer) Create(ctx context.Context, r *entity.CreateEntityRequ // generate guid for new entity current.Guid = uuid.New().String() + // set created at/by + current.CreatedAt = createdAt + current.CreatedBy = createdBy + // parse provided key key, err := entity.ParseKey(r.Entity.Key) if err != nil { @@ -359,6 +397,7 @@ func (s *sqlEntityServer) Create(ctx context.Context, r *entity.CreateEntityRequ etag := createContentsHash(current.Body, current.Meta, current.Status) current.ETag = etag + current.UpdatedAt = updatedAt current.UpdatedBy = updatedBy @@ -374,18 +413,21 @@ func (s *sqlEntityServer) Create(ctx context.Context, r *entity.CreateEntityRequ s.log.Error("error marshalling labels", "msg", err.Error()) return err } + current.Labels = r.Entity.Labels fields, err := json.Marshal(r.Entity.Fields) if err != nil { s.log.Error("error marshalling fields", "msg", err.Error()) return err } + current.Fields = r.Entity.Fields errors, err := json.Marshal(r.Entity.Errors) if err != nil { s.log.Error("error marshalling errors", "msg", err.Error()) return err } + current.Errors = r.Entity.Errors if current.Origin == nil { current.Origin = &entity.EntityOriginInfo{} @@ -408,7 +450,7 @@ func (s *sqlEntityServer) Create(ctx context.Context, r *entity.CreateEntityRequ current.Message = r.Entity.Message } - // Update version + // Update resource version current.ResourceVersion = s.snowflake.Generate().Int64() values := map[string]any{ @@ -418,13 +460,13 @@ func (s *sqlEntityServer) Create(ctx context.Context, r *entity.CreateEntityRequ "group": current.Group, "resource": current.Resource, "name": current.Name, - "created_at": createdAt, - "created_by": createdBy, + "created_at": current.CreatedAt, + "created_by": current.CreatedBy, "group_version": current.GroupVersion, "folder": current.Folder, "slug": current.Slug, - "updated_at": updatedAt, - "updated_by": updatedBy, + "updated_at": current.UpdatedAt, + "updated_by": current.UpdatedBy, "body": current.Body, "meta": current.Meta, "status": current.Status, @@ -440,42 +482,25 @@ func (s *sqlEntityServer) Create(ctx context.Context, r *entity.CreateEntityRequ "origin_key": current.Origin.Key, "origin_ts": current.Origin.Time, "message": current.Message, + "action": entity.Entity_CREATED, } // 1. Add row to the `entity_history` values - query, args, err := s.dialect.InsertQuery("entity_history", values) - if err != nil { - s.log.Error("error building entity history insert", "msg", err.Error()) - return err - } - - s.log.Debug("create", "query", query, "args", args) - - _, err = tx.Exec(ctx, query, args...) - if err != nil { - s.log.Error("error writing entity history", "msg", err.Error()) + if err := s.dialect.Insert(ctx, tx, "entity_history", values); err != nil { + s.log.Error("error inserting entity history", "msg", err.Error()) return err } // 2. Add row to the main `entity` table - query, args, err = s.dialect.InsertQuery("entity", values) - if err != nil { - s.log.Error("error building entity insert sql", "msg", err.Error()) - return err - } - - s.log.Debug("create", "query", query, "args", args) - - _, err = tx.Exec(ctx, query, args...) - if err != nil { + if err := s.dialect.Insert(ctx, tx, "entity", values); err != nil { s.log.Error("error inserting entity", "msg", err.Error()) return err } switch current.Group { - case foldersV0.GROUP: + case folder.GROUP: switch current.Resource { - case foldersV0.RESOURCE: + case folder.RESOURCE: err = s.updateFolderTree(ctx, tx, current.Namespace) if err != nil { s.log.Error("error updating folder tree", "msg", err.Error()) @@ -486,7 +511,7 @@ func (s *sqlEntityServer) Create(ctx context.Context, r *entity.CreateEntityRequ rsp.Entity = current - return nil // s.writeSearchInfo(ctx, tx, current) + return s.setLabels(ctx, tx, current.Guid, current.Labels) }) if err != nil { s.log.Error("error creating entity", "msg", err.Error()) @@ -502,8 +527,11 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ return nil, err } - timestamp := time.Now().UnixMilli() updatedAt := r.Entity.UpdatedAt + if updatedAt < 1000 { + updatedAt = time.Now().UnixMilli() + } + updatedBy := r.Entity.UpdatedBy if updatedBy == "" { modifier, err := appcontext.User(ctx) @@ -515,9 +543,6 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ } updatedBy = store.GetUserIDString(modifier) } - if updatedAt < 1000 { - updatedAt = timestamp - } rsp := &entity.UpdateEntityResponse{ Entity: &entity.Entity{}, @@ -546,10 +571,7 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ rsp.Entity.Guid = current.Guid - // Clear the labels+refs - if _, err := tx.Exec(ctx, "DELETE FROM entity_labels WHERE guid=?", rsp.Entity.Guid); err != nil { - return err - } + // Clear the refs if _, err := tx.Exec(ctx, "DELETE FROM entity_ref WHERE guid=?", rsp.Entity.Guid); err != nil { return err } @@ -580,6 +602,7 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ etag := createContentsHash(current.Body, current.Meta, current.Status) current.ETag = etag + current.UpdatedAt = updatedAt current.UpdatedBy = updatedBy @@ -595,18 +618,21 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ s.log.Error("error marshalling labels", "msg", err.Error()) return err } + current.Labels = r.Entity.Labels fields, err := json.Marshal(r.Entity.Fields) if err != nil { s.log.Error("error marshalling fields", "msg", err.Error()) return err } + current.Fields = r.Entity.Fields errors, err := json.Marshal(r.Entity.Errors) if err != nil { s.log.Error("error marshalling errors", "msg", err.Error()) return err } + current.Errors = r.Entity.Errors if current.Origin == nil { current.Origin = &entity.EntityOriginInfo{} @@ -629,7 +655,7 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ current.Message = r.Entity.Message } - // Update version + // Update resource version current.ResourceVersion = s.snowflake.Generate().Int64() values := map[string]any{ @@ -646,8 +672,8 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ "group_version": current.GroupVersion, "folder": current.Folder, "slug": current.Slug, - "updated_at": updatedAt, - "updated_by": updatedBy, + "updated_at": current.UpdatedAt, + "updated_by": current.UpdatedBy, "body": current.Body, "meta": current.Meta, "status": current.Status, @@ -663,18 +689,12 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ "origin_key": current.Origin.Key, "origin_ts": current.Origin.Time, "message": current.Message, + "action": entity.Entity_UPDATED, } // 1. Add the `entity_history` values - query, args, err := s.dialect.InsertQuery("entity_history", values) - if err != nil { - s.log.Error("error building entity history insert", "msg", err.Error()) - return err - } - - _, err = tx.Exec(ctx, query, args...) - if err != nil { - s.log.Error("error writing entity history", "msg", err.Error()) + if err := s.dialect.Insert(ctx, tx, "entity_history", values); err != nil { + s.log.Error("error inserting entity history", "msg", err.Error()) return err } @@ -689,29 +709,26 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ delete(values, "name") delete(values, "created_at") delete(values, "created_by") + delete(values, "action") - query, args, err = s.dialect.UpdateQuery( + err = s.dialect.Update( + ctx, + tx, "entity", values, map[string]any{ "guid": current.Guid, }, ) - if err != nil { - s.log.Error("error building entity update sql", "msg", err.Error()) - return err - } - - _, err = tx.Exec(ctx, query, args...) if err != nil { s.log.Error("error updating entity", "msg", err.Error()) return err } switch current.Group { - case foldersV0.GROUP: + case folder.GROUP: switch current.Resource { - case foldersV0.RESOURCE: + case folder.RESOURCE: err = s.updateFolderTree(ctx, tx, current.Namespace) if err != nil { s.log.Error("error updating folder tree", "msg", err.Error()) @@ -722,7 +739,7 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ rsp.Entity = current - return nil // s.writeSearchInfo(ctx, tx, current) + return s.setLabels(ctx, tx, current.Guid, current.Labels) }) if err != nil { s.log.Error("error updating entity", "msg", err.Error()) @@ -732,23 +749,22 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ return rsp, err } -/* -func (s *sqlEntityServer) writeSearchInfo( - ctx context.Context, - tx *session.SessionTx, - current *entity.Entity, -) error { - // parent_key := current.getParentKey() +func (s *sqlEntityServer) setLabels(ctx context.Context, tx *session.SessionTx, guid string, labels map[string]string) error { + s.log.Debug("setLabels", "guid", guid, "labels", labels) + + // Clear the old labels + if _, err := tx.Exec(ctx, "DELETE FROM entity_labels WHERE guid=?", guid); err != nil { + return err + } - // Add the labels rows - for k, v := range current.Labels { + // Add the new labels + for k, v := range labels { query, args, err := s.dialect.InsertQuery( "entity_labels", map[string]any{ - "key": current.Key, + "guid": guid, "label": k, "value": v, - // "parent_key": parent_key, }, ) if err != nil { @@ -763,7 +779,6 @@ func (s *sqlEntityServer) writeSearchInfo( return nil } -*/ func (s *sqlEntityServer) Delete(ctx context.Context, r *entity.DeleteEntityRequest) (*entity.DeleteEntityResponse, error) { if err := s.Init(); err != nil { @@ -807,13 +822,79 @@ func (s *sqlEntityServer) Delete(ctx context.Context, r *entity.DeleteEntityRequ } func (s *sqlEntityServer) doDelete(ctx context.Context, tx *session.SessionTx, ent *entity.Entity) error { - _, err := tx.Exec(ctx, "DELETE FROM entity WHERE guid=?", ent.Guid) + // Update resource version + ent.ResourceVersion = s.snowflake.Generate().Int64() + + // Set updated at/by + ent.UpdatedAt = time.Now().UnixMilli() + modifier, err := appcontext.User(ctx) + if err != nil { + return err + } + if modifier == nil { + return fmt.Errorf("can not find user in context") + } + ent.UpdatedBy = store.GetUserIDString(modifier) + + labels, err := json.Marshal(ent.Labels) if err != nil { + s.log.Error("error marshalling labels", "msg", err.Error()) return err } - // TODO: keep history? would need current version bump, and the "write" would have to get from history - _, err = tx.Exec(ctx, "DELETE FROM entity_history WHERE guid=?", ent.Guid) + fields, err := json.Marshal(ent.Fields) + if err != nil { + s.log.Error("error marshalling fields", "msg", err.Error()) + return err + } + + errors, err := json.Marshal(ent.Errors) + if err != nil { + s.log.Error("error marshalling errors", "msg", err.Error()) + return err + } + + values := map[string]any{ + // below are only set in history table + "guid": ent.Guid, + "key": ent.Key, + "namespace": ent.Namespace, + "group": ent.Group, + "resource": ent.Resource, + "name": ent.Name, + "created_at": ent.CreatedAt, + "created_by": ent.CreatedBy, + // below are updated + "group_version": ent.GroupVersion, + "folder": ent.Folder, + "slug": ent.Slug, + "updated_at": ent.UpdatedAt, + "updated_by": ent.UpdatedBy, + "body": ent.Body, + "meta": ent.Meta, + "status": ent.Status, + "size": ent.Size, + "etag": ent.ETag, + "resource_version": ent.ResourceVersion, + "title": ent.Title, + "description": ent.Description, + "labels": labels, + "fields": fields, + "errors": errors, + "origin": ent.Origin.Source, + "origin_key": ent.Origin.Key, + "origin_ts": ent.Origin.Time, + "message": ent.Message, + "action": entity.Entity_DELETED, + } + + // 1. Add the `entity_history` values + if err := s.dialect.Insert(ctx, tx, "entity_history", values); err != nil { + s.log.Error("error inserting entity history", "msg", err.Error()) + return err + } + + _, err = tx.Exec(ctx, "DELETE FROM entity WHERE guid=?", ent.Guid) if err != nil { return err } @@ -827,9 +908,9 @@ func (s *sqlEntityServer) doDelete(ctx context.Context, tx *session.SessionTx, e } switch ent.Group { - case foldersV0.GROUP: + case folder.GROUP: switch ent.Resource { - case foldersV0.RESOURCE: + case folder.RESOURCE: err = s.updateFolderTree(ctx, tx, ent.Namespace) if err != nil { s.log.Error("error updating folder tree", "msg", err.Error()) @@ -846,21 +927,13 @@ func (s *sqlEntityServer) History(ctx context.Context, r *entity.EntityHistoryRe return nil, err } - var limit int64 = 100 - if r.Limit > 0 && r.Limit < 100 { - limit = r.Limit - } - - rr := &entity.ReadEntityRequest{ - Key: r.Key, - WithBody: true, - WithStatus: false, - } - - query, err := s.getReadSelect(rr) + user, err := appcontext.User(ctx) if err != nil { return nil, err } + if user == nil { + return nil, fmt.Errorf("missing user in context") + } if r.Key == "" { return nil, fmt.Errorf("missing key") @@ -871,25 +944,59 @@ func (s *sqlEntityServer) History(ctx context.Context, r *entity.EntityHistoryRe return nil, err } - where := []string{} - args := []any{} + if key.Name == "" { + return nil, fmt.Errorf("missing name") + } - where = append(where, s.dialect.Quote("namespace")+"=?", s.dialect.Quote("group")+"=?", s.dialect.Quote("resource")+"=?", s.dialect.Quote("name")+"=?") - args = append(args, key.Namespace, key.Group, key.Resource, key.Name) + var limit int64 = 100 + if r.Limit > 0 && r.Limit < 100 { + limit = r.Limit + } - if r.NextPageToken != "" { - if true { - return nil, fmt.Errorf("tokens not yet supported") + fields := s.getReadFields(r) + + entityQuery := selectQuery{ + dialect: s.dialect, + fields: fields, + from: "entity_history", // the table + args: []any{}, + limit: r.Limit, + offset: 0, + oneExtra: true, // request one more than the limit (and show next token if it exists) + } + + args := []any{key.Group, key.Resource} + whereclause := "(" + s.dialect.Quote("group") + "=? AND " + s.dialect.Quote("resource") + "=?" + if key.Namespace != "" { + args = append(args, key.Namespace) + whereclause += " AND " + s.dialect.Quote("namespace") + "=?" + } + args = append(args, key.Name) + whereclause += " AND " + s.dialect.Quote("name") + "=?)" + + entityQuery.addWhere(whereclause, args...) + + // if we have a page token, use that to specify the first record + continueToken, err := GetContinueToken(r) + if err != nil { + return nil, err + } + if continueToken != nil { + entityQuery.offset = continueToken.StartOffset + } + + for _, sort := range r.Sort { + sortBy, err := ParseSortBy(sort) + if err != nil { + return nil, err } - where = append(where, "version <= ?") - args = append(args, r.NextPageToken) + entityQuery.addOrderBy(sortBy.Field, sortBy.Direction) } + entityQuery.addOrderBy("resource_version", Ascending) + + query, args := entityQuery.toQuery() - query += " FROM entity_history" + - " WHERE " + strings.Join(where, " AND ") + - " ORDER BY resource_version DESC" + - // select 1 more than we need to see if there is a next page - " LIMIT " + fmt.Sprint(limit+1) + s.log.Debug("history", "query", query, "args", args) rows, err := s.sess.Query(ctx, query, args...) if err != nil { @@ -898,17 +1005,22 @@ func (s *sqlEntityServer) History(ctx context.Context, r *entity.EntityHistoryRe defer func() { _ = rows.Close() }() rsp := &entity.EntityHistoryResponse{ - Key: r.Key, + Key: r.Key, + ResourceVersion: s.snowflake.Generate().Int64(), } for rows.Next() { - v, err := s.rowToEntity(ctx, rows, rr) + v, err := rowToEntity(rows, r) if err != nil { return nil, err } // found more than requested if int64(len(rsp.Versions)) >= limit { - rsp.NextPageToken = fmt.Sprintf("rv:%d", v.ResourceVersion) + continueToken := &ContinueToken{ + Sort: r.Sort, + StartOffset: entityQuery.offset + entityQuery.limit, + } + rsp.NextPageToken = continueToken.String() break } @@ -917,6 +1029,80 @@ func (s *sqlEntityServer) History(ctx context.Context, r *entity.EntityHistoryRe return rsp, err } +type ContinueRequest interface { + GetNextPageToken() string + GetSort() []string +} + +type ContinueToken struct { + Sort []string `json:"s"` + StartOffset int64 `json:"o"` +} + +func (c *ContinueToken) String() string { + b, _ := json.Marshal(c) + return base64.StdEncoding.EncodeToString(b) +} + +func GetContinueToken(r ContinueRequest) (*ContinueToken, error) { + if r.GetNextPageToken() == "" { + return nil, nil + } + + continueVal, err := base64.StdEncoding.DecodeString(r.GetNextPageToken()) + if err != nil { + return nil, fmt.Errorf("error decoding continue token") + } + + t := &ContinueToken{} + err = json.Unmarshal(continueVal, t) + if err != nil { + return nil, err + } + + if !slices.Equal(t.Sort, r.GetSort()) { + return nil, fmt.Errorf("sort order changed") + } + + return t, nil +} + +var sortByFields = []string{ + "guid", + "key", + "namespace", "group", "group_version", "resource", "name", "folder", + "resource_version", "size", "etag", + "created_at", "created_by", + "updated_at", "updated_by", + "origin", "origin_key", "origin_ts", + "title", "slug", "description", +} + +type SortBy struct { + Field string + Direction Direction +} + +func ParseSortBy(sort string) (*SortBy, error) { + sortBy := &SortBy{ + Field: "guid", + Direction: Ascending, + } + + if strings.HasSuffix(sort, "_desc") { + sortBy.Field = sort[:len(sort)-5] + sortBy.Direction = Descending + } else { + sortBy.Field = sort + } + + if !slices.Contains(sortByFields, sortBy.Field) { + return nil, fmt.Errorf("invalid sort field '%s', valid fields: %v", sortBy.Field, sortByFields) + } + + return sortBy, nil +} + func (s *sqlEntityServer) List(ctx context.Context, r *entity.EntityListRequest) (*entity.EntityListResponse, error) { if err := s.Init(); err != nil { return nil, err @@ -930,16 +1116,7 @@ func (s *sqlEntityServer) List(ctx context.Context, r *entity.EntityListRequest) return nil, fmt.Errorf("missing user in context") } - if r.NextPageToken != "" || len(r.Sort) > 0 { - return nil, fmt.Errorf("not yet supported") - } - - rr := &entity.ReadEntityRequest{ - WithBody: r.WithBody, - WithStatus: r.WithStatus, - } - - fields := s.getReadFields(rr) + fields := s.getReadFields(r) entityQuery := selectQuery{ dialect: s.dialect, @@ -947,9 +1124,16 @@ func (s *sqlEntityServer) List(ctx context.Context, r *entity.EntityListRequest) from: "entity", // the table args: []any{}, limit: r.Limit, + offset: 0, oneExtra: true, // request one more than the limit (and show next token if it exists) } + // if we are looking for deleted entities, we list "deleted" entries from the entity_history table + if r.Deleted { + entityQuery.from = "entity_history" + entityQuery.addWhere("action", entity.Entity_DELETED) + } + // TODO fix this // entityQuery.addWhere("namespace", user.OrgID) @@ -970,8 +1154,12 @@ func (s *sqlEntityServer) List(ctx context.Context, r *entity.EntityListRequest) return nil, err } - args = append(args, key.Namespace, key.Group, key.Resource) - whereclause := "(" + s.dialect.Quote("namespace") + "=? AND " + s.dialect.Quote("group") + "=? AND " + s.dialect.Quote("resource") + "=?" + args = append(args, key.Group, key.Resource) + whereclause := "(" + s.dialect.Quote("group") + "=? AND " + s.dialect.Quote("resource") + "=?" + if key.Namespace != "" { + args = append(args, key.Namespace) + whereclause += " AND " + s.dialect.Quote("namespace") + "=?" + } if key.Name != "" { args = append(args, key.Name) whereclause += " AND " + s.dialect.Quote("name") + "=?" @@ -989,26 +1177,48 @@ func (s *sqlEntityServer) List(ctx context.Context, r *entity.EntityListRequest) entityQuery.addWhere("folder", r.Folder) } - if r.NextPageToken != "" { - entityQuery.addWhere("guid>?", r.NextPageToken) + // if we have a page token, use that to specify the first record + continueToken, err := GetContinueToken(r) + if err != nil { + return nil, err + } + if continueToken != nil { + entityQuery.offset = continueToken.StartOffset } if len(r.Labels) > 0 { - var args []any - var conditions []string - for labelKey, labelValue := range r.Labels { - args = append(args, labelKey) - args = append(args, labelValue) - conditions = append(conditions, "(label = ? AND value = ?)") + // if we are looking for deleted entities, we need to use the labels column + if r.Deleted { + for labelKey, labelValue := range r.Labels { + entityQuery.addWhereJsonContainsKV("labels", labelKey, labelValue) + } + // for active entities, we can use the entity_labels table + } else { + var args []any + var conditions []string + for labelKey, labelValue := range r.Labels { + args = append(args, labelKey) + args = append(args, labelValue) + conditions = append(conditions, "(label = ? AND value = ?)") + } + query := "SELECT guid FROM entity_labels" + + " WHERE (" + strings.Join(conditions, " OR ") + ")" + + " GROUP BY guid" + + " HAVING COUNT(label) = ?" + args = append(args, len(r.Labels)) + + entityQuery.addWhereInSubquery("guid", query, args) } - query := "SELECT guid FROM entity_labels" + - " WHERE (" + strings.Join(conditions, " OR ") + ")" + - " GROUP BY guid" + - " HAVING COUNT(label) = ?" - args = append(args, len(r.Labels)) + } - entityQuery.addWhereInSubquery("guid", query, args) + for _, sort := range r.Sort { + sortBy, err := ParseSortBy(sort) + if err != nil { + return nil, err + } + entityQuery.addOrderBy(sortBy.Field, sortBy.Direction) } + entityQuery.addOrderBy("guid", Ascending) query, args := entityQuery.toQuery() @@ -1019,17 +1229,22 @@ func (s *sqlEntityServer) List(ctx context.Context, r *entity.EntityListRequest) return nil, err } defer func() { _ = rows.Close() }() - rsp := &entity.EntityListResponse{} + rsp := &entity.EntityListResponse{ + ResourceVersion: s.snowflake.Generate().Int64(), + } for rows.Next() { - result, err := s.rowToEntity(ctx, rows, rr) + result, err := rowToEntity(rows, r) if err != nil { return rsp, err } // found more than requested if int64(len(rsp.Results)) >= entityQuery.limit { - // TODO? this only works if we sort by guid - rsp.NextPageToken = result.Guid + continueToken := &ContinueToken{ + Sort: r.Sort, + StartOffset: entityQuery.offset + entityQuery.limit, + } + rsp.NextPageToken = continueToken.String() break } @@ -1039,12 +1254,353 @@ func (s *sqlEntityServer) List(ctx context.Context, r *entity.EntityListRequest) return rsp, err } -func (s *sqlEntityServer) Watch(*entity.EntityWatchRequest, entity.EntityStore_WatchServer) error { +func (s *sqlEntityServer) Watch(r *entity.EntityWatchRequest, w entity.EntityStore_WatchServer) error { if err := s.Init(); err != nil { return err } - return fmt.Errorf("unimplemented") + user, err := appcontext.User(w.Context()) + if err != nil { + return err + } + if user == nil { + return fmt.Errorf("missing user in context") + } + + // collect and send any historical events + err = s.watchInit(r, w) + if err != nil { + return err + } + + // subscribe to new events + err = s.watch(r, w) + if err != nil { + s.log.Error("watch error", "err", err) + return err + } + + return nil +} + +// watchInit is a helper function to send the initial set of entities to the client +func (s *sqlEntityServer) watchInit(r *entity.EntityWatchRequest, w entity.EntityStore_WatchServer) error { + fields := s.getReadFields(r) + + entityQuery := selectQuery{ + dialect: s.dialect, + fields: fields, + from: "entity", // the table + args: []any{}, + limit: 100, // r.Limit, + oneExtra: true, // request one more than the limit (and show next token if it exists) + } + + // if we got an initial resource version, start from that location in the history + fromZero := true + if r.Since > 0 { + entityQuery.from = "entity_history" + entityQuery.addWhere("resource_version > ?", r.Since) + fromZero = false + } + + // TODO fix this + // entityQuery.addWhere("namespace", user.OrgID) + + if len(r.Resource) > 0 { + entityQuery.addWhereIn("resource", r.Resource) + } + + if len(r.Key) > 0 { + where := []string{} + args := []any{} + for _, k := range r.Key { + key, err := entity.ParseKey(k) + if err != nil { + return err + } + + args = append(args, key.Group, key.Resource) + whereclause := "(" + s.dialect.Quote("group") + "=? AND " + s.dialect.Quote("resource") + "=?" + if key.Namespace != "" { + args = append(args, key.Namespace) + whereclause += " AND " + s.dialect.Quote("namespace") + "=?" + } + if key.Name != "" { + args = append(args, key.Name) + whereclause += " AND " + s.dialect.Quote("name") + "=?" + } + whereclause += ")" + + where = append(where, whereclause) + } + + entityQuery.addWhere("("+strings.Join(where, " OR ")+")", args...) + } + + // Folder guid + if r.Folder != "" { + entityQuery.addWhere("folder", r.Folder) + } + + if len(r.Labels) > 0 { + if r.Since > 0 { + for labelKey, labelValue := range r.Labels { + entityQuery.addWhereJsonContainsKV("labels", labelKey, labelValue) + } + } else { + var args []any + var conditions []string + for labelKey, labelValue := range r.Labels { + args = append(args, labelKey) + args = append(args, labelValue) + conditions = append(conditions, "(label = ? AND value = ?)") + } + query := "SELECT guid FROM entity_labels" + + " WHERE (" + strings.Join(conditions, " OR ") + ")" + + " GROUP BY guid" + + " HAVING COUNT(label) = ?" + args = append(args, len(r.Labels)) + + entityQuery.addWhereInSubquery("guid", query, args) + } + } + + entityQuery.addOrderBy("resource_version", Ascending) + + var err error + + for hasmore := true; hasmore; { + err = func() error { + query, args := entityQuery.toQuery() + + s.log.Debug("watch init", "query", query, "args", args) + + rows, err := s.sess.Query(w.Context(), query, args...) + if err != nil { + return err + } + defer func() { _ = rows.Close() }() + + found := int64(0) + + for rows.Next() { + found++ + if found > entityQuery.limit { + entityQuery.offset += entityQuery.limit + return nil + } + + result, err := rowToEntity(rows, r) + if err != nil { + return err + } + + if result.ResourceVersion > r.Since { + r.Since = result.ResourceVersion + } + + if fromZero { + result.Action = entity.Entity_CREATED + } + + s.log.Debug("sending init event", "guid", result.Guid, "action", result.Action, "rv", result.ResourceVersion) + err = w.Send(&entity.EntityWatchResponse{ + Timestamp: time.Now().UnixMilli(), + Entity: result, + }) + if err != nil { + return err + } + } + + hasmore = false + return nil + }() + if err != nil { + return err + } + } + + return nil +} + +func (s *sqlEntityServer) poller(stream chan *entity.Entity) { + var err error + since := s.snowflake.Generate().Int64() + + t := time.NewTicker(5 * time.Second) + defer t.Stop() + + for range t.C { + since, err = s.poll(since, stream) + if err != nil { + s.log.Error("watch error", "err", err) + } + } +} + +func (s *sqlEntityServer) poll(since int64, out chan *entity.Entity) (int64, error) { + rr := &entity.ReadEntityRequest{ + WithBody: true, + WithStatus: true, + } + + fields := s.getReadFields(rr) + + for hasmore := true; hasmore; { + err := func() error { + entityQuery := selectQuery{ + dialect: s.dialect, + fields: fields, + from: "entity_history", // the table + args: []any{}, + limit: 100, // r.Limit, + // offset: 0, + oneExtra: true, // request one more than the limit (and show next token if it exists) + orderBy: []string{"resource_version"}, + } + + entityQuery.addWhere("resource_version > ?", since) + + query, args := entityQuery.toQuery() + + rows, err := s.sess.Query(s.ctx, query, args...) + if err != nil { + return err + } + defer func() { _ = rows.Close() }() + + found := int64(0) + for rows.Next() { + found++ + if found > entityQuery.limit { + return nil + } + + result, err := rowToEntity(rows, rr) + if err != nil { + return err + } + + if result.ResourceVersion > since { + since = result.ResourceVersion + } + + s.log.Debug("sending poll result", "guid", result.Guid, "action", result.Action, "rv", result.ResourceVersion) + out <- result + } + + hasmore = false + return nil + }() + if err != nil { + return since, err + } + } + + return since, nil +} + +func watchMatches(r *entity.EntityWatchRequest, result *entity.Entity) bool { + // Resource version too old + if result.ResourceVersion <= r.Since { + return false + } + + // Folder guid + if r.Folder != "" && r.Folder != result.Folder { + return false + } + + // must match at least one resource if specified + if len(r.Resource) > 0 { + matched := false + for _, res := range r.Resource { + if res == result.Resource { + matched = true + break + } + } + if !matched { + return false + } + } + + // must match at least one key if specified + if len(r.Key) > 0 { + matched := false + for _, k := range r.Key { + key, err := entity.ParseKey(k) + if err != nil { + return false + } + + if key.Group == result.Group && key.Resource == result.Resource && (key.Namespace == "" || key.Namespace == result.Namespace) && (key.Name == "" || key.Name == result.Name) { + matched = true + break + } + } + if !matched { + return false + } + } + + // must match all specified label/value pairs + if len(r.Labels) > 0 { + for labelKey, labelValue := range r.Labels { + if result.Labels[labelKey] != labelValue { + return false + } + } + } + + return true +} + +// watch is a helper to get the next set of entities and send them to the client +func (s *sqlEntityServer) watch(r *entity.EntityWatchRequest, w entity.EntityStore_WatchServer) error { + s.log.Debug("watch started", "since", r.Since) + + evts, err := s.broadcaster.Subscribe(w.Context()) + if err != nil { + return err + } + + for { + select { + // user closed the connection + case <-w.Context().Done(): + return nil + // got a raw result from the broadcaster + case result := <-evts: + // result doesn't match our watch params, skip it + if !watchMatches(r, result) { + s.log.Debug("watch result not matched", "guid", result.Guid, "action", result.Action, "rv", result.ResourceVersion) + break + } + + // remove the body and status if not requested + if !r.WithBody { + result.Body = nil + } + if !r.WithStatus { + result.Status = nil + } + + // update r.Since value so we don't send earlier results again + r.Since = result.ResourceVersion + + s.log.Debug("sending watch result", "guid", result.Guid, "action", result.Action, "rv", result.ResourceVersion) + err = w.Send(&entity.EntityWatchResponse{ + Timestamp: time.Now().UnixMilli(), + Entity: result, + }) + if err != nil { + return err + } + } + } } func (s *sqlEntityServer) FindReferences(ctx context.Context, r *entity.ReferenceRequest) (*entity.EntityListResponse, error) { diff --git a/pkg/services/store/entity/sqlstash/sql_storage_server_test.go b/pkg/services/store/entity/sqlstash/sql_storage_server_test.go new file mode 100644 index 0000000000000..2f193b78bdf01 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/sql_storage_server_test.go @@ -0,0 +1,138 @@ +package sqlstash + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/store/entity" + "github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func TestCreate(t *testing.T) { + s := setUpTestServer(t) + + tests := []struct { + name string + ent *entity.Entity + errIsExpected bool + statusIsExpected bool + }{ + { + "request with key and entity creator", + &entity.Entity{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: "default", + Name: "set-minimum-uid", + Key: "/playlist.grafana.app/playlists/namespaces/default/set-minimum-uid", + CreatedBy: "set-minimum-creator", + Origin: &entity.EntityOriginInfo{}, + }, + false, + true, + }, + { + "request with no entity creator", + &entity.Entity{ + Key: "/playlist.grafana.app/playlists/namespaces/default/set-only-key", + }, + true, + false, + }, + { + "request with no key", + &entity.Entity{ + CreatedBy: "entity-creator", + }, + true, + true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := entity.CreateEntityRequest{ + Entity: &entity.Entity{ + Key: tc.ent.Key, + CreatedBy: tc.ent.CreatedBy, + }, + } + resp, err := s.Create(context.Background(), &req) + + if tc.errIsExpected { + require.Error(t, err) + + if tc.statusIsExpected { + require.Equal(t, entity.CreateEntityResponse_ERROR, resp.Status) + } + + return + } + + require.Nil(t, err) + require.Equal(t, entity.CreateEntityResponse_CREATED, resp.Status) + require.NotNil(t, resp) + require.Nil(t, resp.Error) + + read, err := s.Read(context.Background(), &entity.ReadEntityRequest{ + Key: tc.ent.Key, + }) + require.NoError(t, err) + require.NotNil(t, read) + + require.Greater(t, len(read.Guid), 0) + require.Greater(t, read.ResourceVersion, int64(0)) + + expectedETag := createContentsHash(tc.ent.Body, tc.ent.Meta, tc.ent.Status) + require.Equal(t, expectedETag, read.ETag) + require.Equal(t, tc.ent.Origin, read.Origin) + require.Equal(t, tc.ent.Group, read.Group) + require.Equal(t, tc.ent.Resource, read.Resource) + require.Equal(t, tc.ent.Namespace, read.Namespace) + require.Equal(t, tc.ent.Name, read.Name) + require.Equal(t, tc.ent.Subresource, read.Subresource) + require.Equal(t, tc.ent.GroupVersion, read.GroupVersion) + require.Equal(t, tc.ent.Key, read.Key) + require.Equal(t, tc.ent.Folder, read.Folder) + require.Equal(t, tc.ent.Meta, read.Meta) + require.Equal(t, tc.ent.Body, read.Body) + require.Equal(t, tc.ent.Status, read.Status) + require.Equal(t, tc.ent.Title, read.Title) + require.Equal(t, tc.ent.Size, read.Size) + require.Greater(t, read.CreatedAt, int64(0)) + require.Equal(t, tc.ent.CreatedBy, read.CreatedBy) + require.Equal(t, tc.ent.UpdatedAt, read.UpdatedAt) + require.Equal(t, tc.ent.UpdatedBy, read.UpdatedBy) + require.Equal(t, tc.ent.Description, read.Description) + require.Equal(t, tc.ent.Slug, read.Slug) + require.Equal(t, tc.ent.Message, read.Message) + require.Equal(t, tc.ent.Labels, read.Labels) + require.Equal(t, tc.ent.Fields, read.Fields) + require.Equal(t, tc.ent.Errors, read.Errors) + }) + } +} + +func setUpTestServer(t *testing.T) entity.EntityStoreServer { + sqlStore := db.InitTestDB(t) + + entityDB, err := dbimpl.ProvideEntityDB( + sqlStore, + setting.NewCfg(), + featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorage)) + require.NoError(t, err) + + s, err := ProvideSQLEntityServer(entityDB) + require.NoError(t, err) + return s +} diff --git a/pkg/services/store/entity/tests/common.go b/pkg/services/store/entity/tests/common_test.go similarity index 82% rename from pkg/services/store/entity/tests/common.go rename to pkg/services/store/entity/tests/common_test.go index b22980edd584a..1094f2d8cf678 100644 --- a/pkg/services/store/entity/tests/common.go +++ b/pkg/services/store/entity/tests/common_test.go @@ -5,8 +5,6 @@ import ( "testing" "github.com/stretchr/testify/require" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" "github.com/grafana/grafana/pkg/components/satokengen" "github.com/grafana/grafana/pkg/infra/appcontext" @@ -16,10 +14,17 @@ import ( saAPI "github.com/grafana/grafana/pkg/services/serviceaccounts/api" saTests "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/store/entity" + "github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl" + "github.com/grafana/grafana/pkg/services/store/entity/sqlstash" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func createServiceAccountAdminToken(t *testing.T, env *server.TestEnv) (string, *user.SignedInUser) { t.Helper() @@ -53,7 +58,7 @@ func createServiceAccountAdminToken(t *testing.T, env *server.TestEnv) (string, type testContext struct { authToken string - client entity.EntityStoreClient + client entity.EntityStoreServer user *user.SignedInUser ctx context.Context } @@ -74,17 +79,18 @@ func createTestContext(t *testing.T) testContext { authToken, serviceAccountUser := createServiceAccountAdminToken(t, env) - conn, err := grpc.Dial( - env.GRPCServer.GetAddress(), - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) + eDB, err := dbimpl.ProvideEntityDB(env.SQLStore, env.SQLStore.Cfg, env.FeatureToggles) require.NoError(t, err) - client := entity.NewEntityStoreClient(conn) + err = eDB.Init() + require.NoError(t, err) + + store, err := sqlstash.ProvideSQLEntityServer(eDB) + require.NoError(t, err) return testContext{ authToken: authToken, - client: client, + client: store, user: serviceAccountUser, ctx: appcontext.WithUser(context.Background(), serviceAccountUser), } diff --git a/pkg/services/store/entity/tests/server_integration_test.go b/pkg/services/store/entity/tests/server_integration_test.go index ad23dfe5d0a87..44eec13b40142 100644 --- a/pkg/services/store/entity/tests/server_integration_test.go +++ b/pkg/services/store/entity/tests/server_integration_test.go @@ -9,8 +9,8 @@ import ( "time" "github.com/stretchr/testify/require" - "google.golang.org/grpc/metadata" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/store/entity" ) @@ -64,11 +64,11 @@ func requireEntityMatch(t *testing.T, obj *entity.Entity, m rawEntityMatcher) { } if m.createdBy != "" && m.createdBy != obj.CreatedBy { - mismatches += fmt.Sprintf("createdBy: expected:%s, found:%s\n", m.createdBy, obj.CreatedBy) + mismatches += fmt.Sprintf("createdBy: expected: '%s', found: '%s'\n", m.createdBy, obj.CreatedBy) } if m.updatedBy != "" && m.updatedBy != obj.UpdatedBy { - mismatches += fmt.Sprintf("updatedBy: expected:%s, found:%s\n", m.updatedBy, obj.UpdatedBy) + mismatches += fmt.Sprintf("updatedBy: expected: '%s', found: '%s'\n", m.updatedBy, obj.UpdatedBy) } if len(m.body) > 0 { @@ -99,7 +99,7 @@ func requireVersionMatch(t *testing.T, obj *entity.Entity, m objectVersionMatche } if m.updatedBy != "" && m.updatedBy != obj.UpdatedBy { - mismatches += fmt.Sprintf("updatedBy: expected:%s, found:%s\n", m.updatedBy, obj.UpdatedBy) + mismatches += fmt.Sprintf("updatedBy: expected: '%s', found: '%s'\n", m.updatedBy, obj.UpdatedBy) } if m.version != 0 && m.version != obj.ResourceVersion { @@ -110,9 +110,9 @@ func requireVersionMatch(t *testing.T, obj *entity.Entity, m objectVersionMatche } func TestIntegrationEntityServer(t *testing.T) { + // TODO figure out why this still runs into sqlite database locked error if true { - // FIXME - t.Skip() + t.Skip("skipping integration test") } if testing.Short() { @@ -120,7 +120,7 @@ func TestIntegrationEntityServer(t *testing.T) { } testCtx := createTestContext(t) - ctx := metadata.AppendToOutgoingContext(testCtx.ctx, "authorization", fmt.Sprintf("Bearer %s", testCtx.authToken)) + ctx := appcontext.WithUser(testCtx.ctx, testCtx.user) fakeUser := store.GetUserIDString(testCtx.user) firstVersion := int64(0) @@ -130,6 +130,7 @@ func TestIntegrationEntityServer(t *testing.T) { namespace := "default" name := "my-test-entity" testKey := "/" + group + "/" + resource + "/" + namespace + "/" + name + testKey2 := "/" + group + "/" + resource2 + "/" + namespace + "/" + name body := []byte("{\"name\":\"John\"}") t.Run("should not retrieve non-existent objects", func(t *testing.T) { @@ -158,11 +159,18 @@ func TestIntegrationEntityServer(t *testing.T) { createResp, err := testCtx.client.Create(ctx, createReq) require.NoError(t, err) + // clean up in case test fails + t.Cleanup(func() { + _, _ = testCtx.client.Delete(ctx, &entity.DeleteEntityRequest{ + Key: testKey, + }) + }) + versionMatcher := objectVersionMatcher{ - updatedRange: []time.Time{before, time.Now()}, - updatedBy: fakeUser, - version: firstVersion, - comment: &createReq.Entity.Message, + // updatedRange: []time.Time{before, time.Now()}, + // updatedBy: fakeUser, + version: firstVersion, + comment: &createReq.Entity.Message, } requireVersionMatch(t, createResp.Entity, versionMatcher) @@ -182,11 +190,11 @@ func TestIntegrationEntityServer(t *testing.T) { objectMatcher := rawEntityMatcher{ key: testKey, createdRange: []time.Time{before, time.Now()}, - updatedRange: []time.Time{before, time.Now()}, - createdBy: fakeUser, - updatedBy: fakeUser, - body: body, - version: firstVersion, + // updatedRange: []time.Time{before, time.Now()}, + createdBy: fakeUser, + // updatedBy: fakeUser, + body: body, + version: firstVersion, } requireEntityMatch(t, readResp, objectMatcher) @@ -222,6 +230,14 @@ func TestIntegrationEntityServer(t *testing.T) { } createResp, err := testCtx.client.Create(ctx, createReq) require.NoError(t, err) + + // clean up in case test fails + t.Cleanup(func() { + _, _ = testCtx.client.Delete(ctx, &entity.DeleteEntityRequest{ + Key: testKey, + }) + }) + require.Equal(t, entity.CreateEntityResponse_CREATED, createResp.Status) body2 := []byte("{\"name\":\"John2\"}") @@ -238,12 +254,14 @@ func TestIntegrationEntityServer(t *testing.T) { require.NotEqual(t, createResp.Entity.ResourceVersion, updateResp.Entity.ResourceVersion) // Duplicate write (no change) - writeDupRsp, err := testCtx.client.Update(ctx, updateReq) - require.NoError(t, err) - require.Nil(t, writeDupRsp.Error) - require.Equal(t, entity.UpdateEntityResponse_UNCHANGED, writeDupRsp.Status) - require.Equal(t, updateResp.Entity.ResourceVersion, writeDupRsp.Entity.ResourceVersion) - require.Equal(t, updateResp.Entity.ETag, writeDupRsp.Entity.ETag) + /* + writeDupRsp, err := testCtx.client.Update(ctx, updateReq) + require.NoError(t, err) + require.Nil(t, writeDupRsp.Error) + require.Equal(t, entity.UpdateEntityResponse_UNCHANGED, writeDupRsp.Status) + require.Equal(t, updateResp.Entity.ResourceVersion, writeDupRsp.Entity.ResourceVersion) + require.Equal(t, updateResp.Entity.ETag, writeDupRsp.Entity.ETag) + */ body3 := []byte("{\"name\":\"John3\"}") writeReq3 := &entity.UpdateEntityRequest{ @@ -255,6 +273,7 @@ func TestIntegrationEntityServer(t *testing.T) { } writeResp3, err := testCtx.client.Update(ctx, writeReq3) require.NoError(t, err) + require.Equal(t, entity.UpdateEntityResponse_UPDATED, writeResp3.Status) require.NotEqual(t, writeResp3.Entity.ResourceVersion, updateResp.Entity.ResourceVersion) latestMatcher := rawEntityMatcher{ @@ -285,9 +304,7 @@ func TestIntegrationEntityServer(t *testing.T) { requireEntityMatch(t, readRespFirstVer, rawEntityMatcher{ key: testKey, createdRange: []time.Time{before, time.Now()}, - updatedRange: []time.Time{before, time.Now()}, createdBy: fakeUser, - updatedBy: fakeUser, body: body, version: 0, }) @@ -329,7 +346,7 @@ func TestIntegrationEntityServer(t *testing.T) { w3, err := testCtx.client.Create(ctx, &entity.CreateEntityRequest{ Entity: &entity.Entity{ - Key: testKey + "3", + Key: testKey2 + "3", Body: body, }, }) @@ -337,7 +354,7 @@ func TestIntegrationEntityServer(t *testing.T) { w4, err := testCtx.client.Create(ctx, &entity.CreateEntityRequest{ Entity: &entity.Entity{ - Key: testKey + "4", + Key: testKey2 + "4", Body: body, }, }) @@ -358,18 +375,94 @@ func TestIntegrationEntityServer(t *testing.T) { kinds = append(kinds, res.Resource) version = append(version, res.ResourceVersion) } - require.Equal(t, []string{"my-test-entity", "name2", "name3", "name4"}, names) - require.Equal(t, []string{"jsonobj", "jsonobj", "playlist", "playlist"}, kinds) - require.Equal(t, []int64{ + + // default sort is by guid, so we ignore order + require.ElementsMatch(t, []string{"my-test-entity1", "my-test-entity2", "my-test-entity3", "my-test-entity4"}, names) + require.ElementsMatch(t, []string{"jsonobjs", "jsonobjs", "playlists", "playlists"}, kinds) + require.ElementsMatch(t, []int64{ w1.Entity.ResourceVersion, w2.Entity.ResourceVersion, w3.Entity.ResourceVersion, w4.Entity.ResourceVersion, }, version) + // sorted by name + resp, err = testCtx.client.List(ctx, &entity.EntityListRequest{ + Resource: []string{resource, resource2}, + WithBody: false, + Sort: []string{"name"}, + }) + require.NoError(t, err) + + require.NotNil(t, resp) + require.Equal(t, 4, len(resp.Results)) + + require.Equal(t, "my-test-entity1", resp.Results[0].Name) + require.Equal(t, "my-test-entity2", resp.Results[1].Name) + require.Equal(t, "my-test-entity3", resp.Results[2].Name) + require.Equal(t, "my-test-entity4", resp.Results[3].Name) + + require.Equal(t, "jsonobjs", resp.Results[0].Resource) + require.Equal(t, "jsonobjs", resp.Results[1].Resource) + require.Equal(t, "playlists", resp.Results[2].Resource) + require.Equal(t, "playlists", resp.Results[3].Resource) + + // sorted by name desc + resp, err = testCtx.client.List(ctx, &entity.EntityListRequest{ + Resource: []string{resource, resource2}, + WithBody: false, + Sort: []string{"name_desc"}, + }) + require.NoError(t, err) + + require.NotNil(t, resp) + require.Equal(t, 4, len(resp.Results)) + + require.Equal(t, "my-test-entity1", resp.Results[3].Name) + require.Equal(t, "my-test-entity2", resp.Results[2].Name) + require.Equal(t, "my-test-entity3", resp.Results[1].Name) + require.Equal(t, "my-test-entity4", resp.Results[0].Name) + + require.Equal(t, "jsonobjs", resp.Results[3].Resource) + require.Equal(t, "jsonobjs", resp.Results[2].Resource) + require.Equal(t, "playlists", resp.Results[1].Resource) + require.Equal(t, "playlists", resp.Results[0].Resource) + + // with limit + resp, err = testCtx.client.List(ctx, &entity.EntityListRequest{ + Resource: []string{resource, resource2}, + WithBody: false, + Limit: 2, + Sort: []string{"name"}, + }) + require.NoError(t, err) + + require.NotNil(t, resp) + require.Equal(t, 2, len(resp.Results)) + + require.Equal(t, "my-test-entity1", resp.Results[0].Name) + require.Equal(t, "my-test-entity2", resp.Results[1].Name) + + // with limit & continue + resp, err = testCtx.client.List(ctx, &entity.EntityListRequest{ + Resource: []string{resource, resource2}, + WithBody: false, + Limit: 2, + NextPageToken: resp.NextPageToken, + Sort: []string{"name"}, + }) + require.NoError(t, err) + + require.NotNil(t, resp) + require.Equal(t, 2, len(resp.Results)) + + require.Equal(t, "my-test-entity3", resp.Results[0].Name) + require.Equal(t, "my-test-entity4", resp.Results[1].Name) + // Again with only one kind respKind1, err := testCtx.client.List(ctx, &entity.EntityListRequest{ Resource: []string{resource}, + Sort: []string{"name"}, }) require.NoError(t, err) names = make([]string, 0, len(respKind1.Results)) @@ -380,8 +473,8 @@ func TestIntegrationEntityServer(t *testing.T) { kinds = append(kinds, res.Resource) version = append(version, res.ResourceVersion) } - require.Equal(t, []string{"my-test-entity", "name2"}, names) - require.Equal(t, []string{"jsonobj", "jsonobj"}, kinds) + require.Equal(t, []string{"my-test-entity1", "my-test-entity2"}, names) + require.Equal(t, []string{"jsonobjs", "jsonobjs"}, kinds) require.Equal(t, []int64{ w1.Entity.ResourceVersion, w2.Entity.ResourceVersion, @@ -389,25 +482,32 @@ func TestIntegrationEntityServer(t *testing.T) { }) t.Run("should be able to filter objects based on their labels", func(t *testing.T) { - kind := entity.StandardKindDashboard _, err := testCtx.client.Create(ctx, &entity.CreateEntityRequest{ Entity: &entity.Entity{ - Key: "/grafana/dashboards/blue-green", + Key: "/dashboards.grafana.app/dashboards/default/blue-green", Body: []byte(dashboardWithTagsBlueGreen), + Labels: map[string]string{ + "blue": "", + "green": "", + }, }, }) require.NoError(t, err) _, err = testCtx.client.Create(ctx, &entity.CreateEntityRequest{ Entity: &entity.Entity{ - Key: "/grafana/dashboards/red-green", + Key: "/dashboards.grafana.app/dashboards/default/red-green", Body: []byte(dashboardWithTagsRedGreen), + Labels: map[string]string{ + "red": "", + "green": "", + }, }, }) require.NoError(t, err) resp, err := testCtx.client.List(ctx, &entity.EntityListRequest{ - Key: []string{kind}, + Key: []string{"/dashboards.grafana.app/dashboards/default"}, WithBody: false, Labels: map[string]string{ "red": "", @@ -419,7 +519,7 @@ func TestIntegrationEntityServer(t *testing.T) { require.Equal(t, resp.Results[0].Name, "red-green") resp, err = testCtx.client.List(ctx, &entity.EntityListRequest{ - Key: []string{kind}, + Key: []string{"/dashboards.grafana.app/dashboards/default"}, WithBody: false, Labels: map[string]string{ "red": "", @@ -432,7 +532,7 @@ func TestIntegrationEntityServer(t *testing.T) { require.Equal(t, resp.Results[0].Name, "red-green") resp, err = testCtx.client.List(ctx, &entity.EntityListRequest{ - Key: []string{kind}, + Key: []string{"/dashboards.grafana.app/dashboards/default"}, WithBody: false, Labels: map[string]string{ "red": "invalid", @@ -443,7 +543,7 @@ func TestIntegrationEntityServer(t *testing.T) { require.Len(t, resp.Results, 0) resp, err = testCtx.client.List(ctx, &entity.EntityListRequest{ - Key: []string{kind}, + Key: []string{"/dashboards.grafana.app/dashboards/default"}, WithBody: false, Labels: map[string]string{ "green": "", @@ -454,7 +554,7 @@ func TestIntegrationEntityServer(t *testing.T) { require.Len(t, resp.Results, 2) resp, err = testCtx.client.List(ctx, &entity.EntityListRequest{ - Key: []string{kind}, + Key: []string{"/dashboards.grafana.app/dashboards/default"}, WithBody: false, Labels: map[string]string{ "yellow": "", diff --git a/pkg/services/store/http.go b/pkg/services/store/http.go index ad748a4a07b96..432e4d872b990 100644 --- a/pkg/services/store/http.go +++ b/pkg/services/store/http.go @@ -68,7 +68,7 @@ func (s *standardStorageService) doWrite(c *contextmodel.ReqContext) response.Re if err != nil { return response.Error(http.StatusBadRequest, "save error", err) } - return response.JSON(200, rsp) + return response.JSON(http.StatusOK, rsp) } func (s *standardStorageService) doUpload(c *contextmodel.ReqContext) response.Response { @@ -85,7 +85,7 @@ func (s *standardStorageService) doUpload(c *contextmodel.ReqContext) response.R if err := c.Req.ParseMultipartForm(MAX_UPLOAD_SIZE); err != nil { rsp.Message = fmt.Sprintf("Please limit file uploaded under %s", util.ByteCountSI(MAX_UPLOAD_SIZE)) rsp.Error = true - return response.JSON(400, rsp) + return response.JSON(http.StatusBadRequest, rsp) } message := getMultipartFormValue(c.Req, "message") overwriteExistingFile := getMultipartFormValue(c.Req, "overwriteExistingFile") != "false" // must explicitly overwrite @@ -99,7 +99,7 @@ func (s *standardStorageService) doUpload(c *contextmodel.ReqContext) response.R if path == "" && folder == "" { rsp.Message = "please specify the upload folder or full path" rsp.Error = true - return response.JSON(400, rsp) + return response.JSON(http.StatusBadRequest, rsp) } for _, fileHeader := range fileHeaders { @@ -107,15 +107,15 @@ func (s *standardStorageService) doUpload(c *contextmodel.ReqContext) response.R // open each file to copy contents file, err := fileHeader.Open() if err != nil { - return response.Error(500, "Internal Server Error", err) + return response.Error(http.StatusInternalServerError, "Internal Server Error", err) } err = file.Close() if err != nil { - return response.Error(500, "Internal Server Error", err) + return response.Error(http.StatusInternalServerError, "Internal Server Error", err) } data, err := io.ReadAll(file) if err != nil { - return response.Error(500, "Internal Server Error", err) + return response.Error(http.StatusInternalServerError, "Internal Server Error", err) } if path == "" { @@ -147,7 +147,7 @@ func (s *standardStorageService) doUpload(c *contextmodel.ReqContext) response.R } } - return response.JSON(200, rsp) + return response.JSON(http.StatusOK, rsp) } func getMultipartFormValue(req *http.Request, key string) string { @@ -163,11 +163,11 @@ func (s *standardStorageService) read(c *contextmodel.ReqContext) response.Respo scope, path := getPathAndScope(c) file, err := s.Read(c.Req.Context(), c.SignedInUser, scope+"/"+path) if err != nil { - return response.Error(400, "cannot call read", err) + return response.Error(http.StatusBadRequest, "cannot call read", err) } if file == nil || file.Contents == nil { - return response.Error(404, "file does not exist", err) + return response.Error(http.StatusNotFound, "file does not exist", err) } // set the correct content type for svg @@ -181,9 +181,9 @@ func (s *standardStorageService) getOptions(c *contextmodel.ReqContext) response scope, path := getPathAndScope(c) opts, err := s.getWorkflowOptions(c.Req.Context(), c.SignedInUser, scope+"/"+path) if err != nil { - return response.Error(400, err.Error(), err) + return response.Error(http.StatusBadRequest, err.Error(), err) } - return response.JSON(200, opts) + return response.JSON(http.StatusOK, opts) } func (s *standardStorageService) doDelete(c *contextmodel.ReqContext) response.Response { @@ -192,9 +192,9 @@ func (s *standardStorageService) doDelete(c *contextmodel.ReqContext) response.R err := s.Delete(c.Req.Context(), c.SignedInUser, scope+"/"+path) if err != nil { - return response.Error(400, "failed to delete the file: "+err.Error(), err) + return response.Error(http.StatusBadRequest, "failed to delete the file: "+err.Error(), err) } - return response.JSON(200, map[string]any{ + return response.JSON(http.StatusOK, map[string]any{ "message": "Removed file from storage", "success": true, "path": path, @@ -204,26 +204,26 @@ func (s *standardStorageService) doDelete(c *contextmodel.ReqContext) response.R func (s *standardStorageService) doDeleteFolder(c *contextmodel.ReqContext) response.Response { body, err := io.ReadAll(c.Req.Body) if err != nil { - return response.Error(500, "error reading bytes", err) + return response.Error(http.StatusInternalServerError, "error reading bytes", err) } cmd := &DeleteFolderCmd{} err = json.Unmarshal(body, cmd) if err != nil { - return response.Error(400, "error parsing body", err) + return response.Error(http.StatusBadRequest, "error parsing body", err) } if cmd.Path == "" { - return response.Error(400, "empty path", err) + return response.Error(http.StatusBadRequest, "empty path", err) } // full path is api/storage/delete/upload/example.jpg, but we only want the part after upload _, path := getPathAndScope(c) if err := s.DeleteFolder(c.Req.Context(), c.SignedInUser, cmd); err != nil { - return response.Error(400, "failed to delete the folder: "+err.Error(), err) + return response.Error(http.StatusBadRequest, "failed to delete the folder: "+err.Error(), err) } - return response.JSON(200, map[string]any{ + return response.JSON(http.StatusOK, map[string]any{ "message": "Removed folder from storage", "success": true, "path": path, @@ -233,24 +233,24 @@ func (s *standardStorageService) doDeleteFolder(c *contextmodel.ReqContext) resp func (s *standardStorageService) doCreateFolder(c *contextmodel.ReqContext) response.Response { body, err := io.ReadAll(c.Req.Body) if err != nil { - return response.Error(500, "error reading bytes", err) + return response.Error(http.StatusInternalServerError, "error reading bytes", err) } cmd := &CreateFolderCmd{} err = json.Unmarshal(body, cmd) if err != nil { - return response.Error(400, "error parsing body", err) + return response.Error(http.StatusBadRequest, "error parsing body", err) } if cmd.Path == "" { - return response.Error(400, "empty path", err) + return response.Error(http.StatusBadRequest, "empty path", err) } if err := s.CreateFolder(c.Req.Context(), c.SignedInUser, cmd); err != nil { - return response.Error(400, "failed to create the folder: "+err.Error(), err) + return response.Error(http.StatusBadRequest, "failed to create the folder: "+err.Error(), err) } - return response.JSON(200, map[string]any{ + return response.JSON(http.StatusOK, map[string]any{ "message": "Folder created", "success": true, "path": cmd.Path, @@ -263,10 +263,10 @@ func (s *standardStorageService) list(c *contextmodel.ReqContext) response.Respo // maxFiles of 0 will result in default behaviour from wrapper frame, err := s.List(c.Req.Context(), c.SignedInUser, path, 0) if err != nil { - return response.Error(400, "error reading path", err) + return response.Error(http.StatusBadRequest, "error reading path", err) } if frame == nil { - return response.Error(404, "not found", nil) + return response.Error(http.StatusNotFound, "not found", nil) } return response.JSONStreaming(http.StatusOK, frame) } @@ -281,5 +281,5 @@ func (s *standardStorageService) getConfig(c *contextmodel.ReqContext) response. for _, s := range storages { roots = append(roots, s.Meta()) } - return response.JSON(200, roots) + return response.JSON(http.StatusOK, roots) } diff --git a/pkg/services/store/kind/dashboard/summary.go b/pkg/services/store/kind/dashboard/summary.go index 5850bd19fb5a9..7987f1c6c1e46 100644 --- a/pkg/services/store/kind/dashboard/summary.go +++ b/pkg/services/store/kind/dashboard/summary.go @@ -60,43 +60,58 @@ func NewStaticDashboardSummaryBuilder(lookup DatasourceLookup, sanitize bool) en summary.Fields["schemaVersion"] = fmt.Sprint(dash.SchemaVersion) for _, panel := range dash.Panels { - panelRefs := NewReferenceAccumulator() - p := &entity.EntitySummary{ - UID: uid + "#" + strconv.FormatInt(panel.ID, 10), - Kind: "panel", - } - p.Name = panel.Title - p.Description = panel.Description - p.Fields = make(map[string]string, 0) - p.Fields["type"] = panel.Type - - if panel.Type != "row" { - panelRefs.Add(entity.ExternalEntityReferencePlugin, string(plugins.TypePanel), panel.Type) - dashboardRefs.Add(entity.ExternalEntityReferencePlugin, string(plugins.TypePanel), panel.Type) - } - if panel.LibraryPanel != "" { - panelRefs.Add(entity.StandardKindLibraryPanel, panel.Type, panel.LibraryPanel) - dashboardRefs.Add(entity.StandardKindLibraryPanel, panel.Type, panel.LibraryPanel) - } - for _, v := range panel.Datasource { - dashboardRefs.Add(entity.StandardKindDataSource, v.Type, v.UID) - panelRefs.Add(entity.StandardKindDataSource, v.Type, v.UID) - if v.Type != "" { - dashboardRefs.Add(entity.ExternalEntityReferencePlugin, string(plugins.TypeDataSource), v.Type) - } - } - for _, v := range panel.Transformer { - panelRefs.Add(entity.ExternalEntityReferenceRuntime, entity.ExternalEntityReferenceRuntime_Transformer, v) - dashboardRefs.Add(entity.ExternalEntityReferenceRuntime, entity.ExternalEntityReferenceRuntime_Transformer, v) - } - p.References = panelRefs.Get() - summary.Nested = append(summary.Nested, p) + s := panelSummary(panel, uid, dashboardRefs) + summary.Nested = append(summary.Nested, s...) } summary.References = dashboardRefs.Get() if sanitize { body, err = json.MarshalIndent(parsed, "", " ") } + return summary, body, err } } + +// panelSummary take panel info and returns entity summaries for the given panel and all its collapsed panels. +func panelSummary(panel panelInfo, uid string, dashboardRefs ReferenceAccumulator) []*entity.EntitySummary { + panels := []*entity.EntitySummary{} + + panelRefs := NewReferenceAccumulator() + p := &entity.EntitySummary{ + UID: uid + "#" + strconv.FormatInt(panel.ID, 10), + Kind: "panel", + } + p.Name = panel.Title + p.Description = panel.Description + p.Fields = make(map[string]string, 0) + p.Fields["type"] = panel.Type + + if panel.Type != "row" { + panelRefs.Add(entity.ExternalEntityReferencePlugin, string(plugins.TypePanel), panel.Type) + dashboardRefs.Add(entity.ExternalEntityReferencePlugin, string(plugins.TypePanel), panel.Type) + } + if panel.LibraryPanel != "" { + panelRefs.Add(entity.StandardKindLibraryPanel, panel.Type, panel.LibraryPanel) + dashboardRefs.Add(entity.StandardKindLibraryPanel, panel.Type, panel.LibraryPanel) + } + for _, v := range panel.Datasource { + dashboardRefs.Add(entity.StandardKindDataSource, v.Type, v.UID) + panelRefs.Add(entity.StandardKindDataSource, v.Type, v.UID) + if v.Type != "" { + dashboardRefs.Add(entity.ExternalEntityReferencePlugin, string(plugins.TypeDataSource), v.Type) + } + } + for _, v := range panel.Transformer { + panelRefs.Add(entity.ExternalEntityReferenceRuntime, entity.ExternalEntityReferenceRuntime_Transformer, v) + dashboardRefs.Add(entity.ExternalEntityReferenceRuntime, entity.ExternalEntityReferenceRuntime_Transformer, v) + } + p.References = panelRefs.Get() + panels = append(panels, p) + + for _, c := range panel.Collapsed { + collapsed := panelSummary(c, uid, dashboardRefs) + panels = append(panels, collapsed...) + } + return panels +} diff --git a/pkg/services/store/kind/dashboard/testdata/with-library-panels-info.json b/pkg/services/store/kind/dashboard/testdata/with-library-panels-info.json index e360bc820e03a..670b69576aa04 100644 --- a/pkg/services/store/kind/dashboard/testdata/with-library-panels-info.json +++ b/pkg/services/store/kind/dashboard/testdata/with-library-panels-info.json @@ -45,6 +45,32 @@ "type": "panel" } ] + }, + { + "UID": "with-library-panels#3", + "kind": "panel", + "name": "collapsed row", + "fields": { + "type": "row" + } + }, + { + "UID": "with-library-panels#42", + "kind": "panel", + "name": "blue pie", + "fields": { + "type": "" + }, + "references": [ + { + "family": "librarypanel", + "identifier": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur" + }, + { + "family": "plugin", + "type": "panel" + } + ] } ], "references": [ @@ -59,6 +85,10 @@ "family": "librarypanel", "identifier": "e1d5f519-dabd-47c6-9ad7-83d181ce1cee" }, + { + "family": "librarypanel", + "identifier": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur" + }, { "family": "plugin", "type": "panel" diff --git a/pkg/services/store/kind/dashboard/testdata/with-library-panels.json b/pkg/services/store/kind/dashboard/testdata/with-library-panels.json index 3dd0faeb9a1c3..cecd1c64e6071 100644 --- a/pkg/services/store/kind/dashboard/testdata/with-library-panels.json +++ b/pkg/services/store/kind/dashboard/testdata/with-library-panels.json @@ -49,6 +49,34 @@ "uid": "e1d5f519-dabd-47c6-9ad7-83d181ce1cee" }, "title": "green pie" + }, + { + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 9 + }, + "id": 3, + "panels": [ + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 42, + "libraryPanel": { + "name": "blue pie", + "uid": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur" + }, + "title": "blue pie" + } + ], + "title": "collapsed row", + "type": "row" } ], "refresh": "", diff --git a/pkg/services/store/service.go b/pkg/services/store/service.go index 8136b2e0a9a45..fe523f80f52d4 100644 --- a/pkg/services/store/service.go +++ b/pkg/services/store/service.go @@ -125,7 +125,7 @@ func ProvideService( } // Development dashboards - if settings.AddDevEnv && setting.Env != setting.Prod { + if settings.AddDevEnv && cfg.Env != setting.Prod { devenv := filepath.Join(cfg.StaticRootPath, "..", "devenv") if _, err := os.Stat(devenv); !os.IsNotExist(err) { s := newDiskStorage(RootStorageMeta{ diff --git a/pkg/services/store/service_test.go b/pkg/services/store/service_test.go index 0546461787ad8..298d41a0d91d5 100644 --- a/pkg/services/store/service_test.go +++ b/pkg/services/store/service_test.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" testdatasource "github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource" ) @@ -68,6 +69,10 @@ var ( }}) ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestListFiles(t *testing.T) { roots := []storageRuntime{publicStaticFilesStorage} diff --git a/pkg/services/store/testdata/public_testdata.golden.jsonc b/pkg/services/store/testdata/public_testdata.golden.jsonc index bb87315815cc5..a37239596c299 100644 --- a/pkg/services/store/testdata/public_testdata.golden.jsonc +++ b/pkg/services/store/testdata/public_testdata.golden.jsonc @@ -11,15 +11,16 @@ // } // } // Name: -// Dimensions: 3 Fields by 2 Rows -// +--------------------+----------------------+---------------+ -// | Name: name | Name: mediaType | Name: size | -// | Labels: | Labels: | Labels: | -// | Type: []string | Type: []string | Type: []int64 | -// +--------------------+----------------------+---------------+ -// | countries.geojson | application/geo+json | 255943 | -// | usa-states.geojson | application/geo+json | 89263 | -// +--------------------+----------------------+---------------+ +// Dimensions: 3 Fields by 3 Rows +// +----------------------------+----------------------+---------------+ +// | Name: name | Name: mediaType | Name: size | +// | Labels: | Labels: | Labels: | +// | Type: []string | Type: []string | Type: []int64 | +// +----------------------------+----------------------+---------------+ +// | countries.geojson | application/geo+json | 255943 | +// | example-with-style.geojson | application/geo+json | 3332 | +// | usa-states.geojson | application/geo+json | 89263 | +// +----------------------------+----------------------+---------------+ // // // 🌟 This was machine generated. Do not edit. 🌟 @@ -69,14 +70,17 @@ "values": [ [ "countries.geojson", + "example-with-style.geojson", "usa-states.geojson" ], [ + "application/geo+json", "application/geo+json", "application/geo+json" ], [ 255943, + 3332, 89263 ] ] diff --git a/pkg/services/supportbundles/supportbundlesimpl/service.go b/pkg/services/supportbundles/supportbundlesimpl/service.go index df569dc408a46..50d5f9f317c51 100644 --- a/pkg/services/supportbundles/supportbundlesimpl/service.go +++ b/pkg/services/supportbundles/supportbundlesimpl/service.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/infra/usagestats" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/auth/identity" @@ -34,6 +35,7 @@ type Service struct { pluginSettings pluginsettings.Service pluginStore pluginstore.Store store bundleStore + tracer tracing.Tracer log log.Logger encryptionPublicKeys []string @@ -55,7 +57,8 @@ func ProvideService( routeRegister routing.RouteRegister, settings setting.Provider, sql db.DB, - usageStats usagestats.Service) (*Service, error) { + usageStats usagestats.Service, + tracer tracing.Tracer) (*Service, error) { section := cfg.SectionWithEnvOverrides("support_bundles") s := &Service{ accessControl: accessControl, @@ -69,6 +72,7 @@ func ProvideService( pluginStore: pluginStore, serverAdminOnly: section.Key("server_admin_only").MustBool(true), store: newStore(kvStore), + tracer: tracer, } usageStats.RegisterMetricsFunc(s.getUsageStats) diff --git a/pkg/services/supportbundles/supportbundlesimpl/service_bundle.go b/pkg/services/supportbundles/supportbundlesimpl/service_bundle.go index fddd648881284..f6305e78ec764 100644 --- a/pkg/services/supportbundles/supportbundlesimpl/service_bundle.go +++ b/pkg/services/supportbundles/supportbundlesimpl/service_bundle.go @@ -13,6 +13,7 @@ import ( "time" "filippo.io/age" + "go.opentelemetry.io/otel/attribute" "github.com/grafana/grafana/pkg/services/supportbundles" ) @@ -66,6 +67,10 @@ func (s *Service) startBundleWork(ctx context.Context, collectors []string, uid } func (s *Service) bundle(ctx context.Context, collectors []string, uid string) ([]byte, error) { + ctxTracer, span := s.tracer.Start(ctx, "SupportBundle.bundle") + span.SetAttributes(attribute.String("SupportBundle.bundle.uid", uid)) + defer span.End() + lookup := make(map[string]bool, len(collectors)) for _, c := range collectors { lookup[c] = true @@ -83,7 +88,11 @@ func (s *Service) bundle(ctx context.Context, collectors []string, uid string) ( continue } - item, err := collector.Fn(ctx) + // Trace the collector run + ctxBundler, span := s.tracer.Start(ctxTracer, "SupportBundle.bundle.collector") + span.SetAttributes(attribute.String("SupportBundle.bundle.collector.uid", collector.UID)) + + item, err := collector.Fn(ctxBundler) if err != nil { s.log.Warn("Failed to collect support bundle item", "error", err, "collector", collector.UID) } @@ -92,6 +101,8 @@ func (s *Service) bundle(ctx context.Context, collectors []string, uid string) ( if item != nil { files[item.Filename] = item.FileBytes } + + span.End() } // create tar.gz file diff --git a/pkg/services/supportbundles/supportbundlesimpl/service_bundle_test.go b/pkg/services/supportbundles/supportbundlesimpl/service_bundle_test.go index 016e72d5dbb15..5e3891466f04f 100644 --- a/pkg/services/supportbundles/supportbundlesimpl/service_bundle_test.go +++ b/pkg/services/supportbundles/supportbundlesimpl/service_bundle_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/supportbundles" "github.com/grafana/grafana/pkg/services/supportbundles/bundleregistry" "github.com/grafana/grafana/pkg/services/user" @@ -33,6 +34,7 @@ func TestService_bundleCreate(t *testing.T) { log: log.New("test"), bundleRegistry: bundleregistry.ProvideService(), store: newStore(kvstore.NewFakeKVStore()), + tracer: tracing.InitializeTracerForTest(), } cfg := setting.NewCfg() @@ -66,6 +68,7 @@ func TestService_bundleEncryptDecrypt(t *testing.T) { bundleRegistry: bundleregistry.ProvideService(), store: newStore(kvstore.NewFakeKVStore()), encryptionPublicKeys: []string{testAgePublicKey}, + tracer: tracing.InitializeTracerForTest(), } cfg := setting.NewCfg() @@ -98,6 +101,7 @@ func TestService_bundleEncryptDecryptMultipleRecipients(t *testing.T) { bundleRegistry: bundleregistry.ProvideService(), store: newStore(kvstore.NewFakeKVStore()), encryptionPublicKeys: []string{testAgePublicKey, testAgePublicKey2}, + tracer: tracing.InitializeTracerForTest(), } cfg := setting.NewCfg() diff --git a/pkg/services/tag/tagimpl/store_test.go b/pkg/services/tag/tagimpl/store_test.go index 3d631136d39da..a3bf9c2783e14 100644 --- a/pkg/services/tag/tagimpl/store_test.go +++ b/pkg/services/tag/tagimpl/store_test.go @@ -8,8 +8,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/tag" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type getStore func(db.DB) store func testIntegrationSavingTags(t *testing.T, fn getStore) { diff --git a/pkg/services/team/model.go b/pkg/services/team/model.go index 060ab84dfbb00..a4d4fe0a6a64b 100644 --- a/pkg/services/team/model.go +++ b/pkg/services/team/model.go @@ -4,9 +4,6 @@ import ( "errors" "time" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/grafana/grafana/pkg/kinds/team" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" "github.com/grafana/grafana/pkg/services/search/model" @@ -36,18 +33,6 @@ type Team struct { Updated time.Time `json:"updated"` } -func (t *Team) ToResource() team.K8sResource { - r := team.NewK8sResource(t.UID, &team.Spec{ - Name: t.Name, - }) - r.Metadata.CreationTimestamp = v1.NewTime(t.Created) - r.Metadata.SetUpdatedTimestamp(&t.Updated) - if t.Email != "" { - r.Spec.Email = &t.Email - } - return r -} - // --------------------- // COMMANDS diff --git a/pkg/services/team/model_test.go b/pkg/services/team/model_test.go deleted file mode 100644 index 614b3ea2abb43..0000000000000 --- a/pkg/services/team/model_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package team - -import ( - "encoding/json" - "fmt" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestTeamConversion(t *testing.T) { - src := Team{ - ID: 123, - UID: "abc", - Name: "TeamA", - Email: "team@a.org", - OrgID: 11, - Created: time.UnixMilli(946713600000).UTC(), // 2000-01-01 - Updated: time.UnixMilli(1262332800000).UTC(), // 2010-01-01 - } - - dst := src.ToResource() - - require.Equal(t, src.Name, dst.Spec.Name) - - out, err := json.MarshalIndent(dst, "", " ") - require.NoError(t, err) - fmt.Printf("%s", string(out)) - require.JSONEq(t, `{ - "apiVersion": "v0-0-alpha", - "kind": "Team", - "metadata": { - "name": "abc", - "creationTimestamp": "2000-01-01T08:00:00Z", - "annotations": { - "grafana.app/updatedTimestamp": "2010-01-01T08:00:00Z" - } - }, - "spec": { - "email": "team@a.org", - "name": "TeamA" - } - }`, string(out)) -} diff --git a/pkg/services/team/team.go b/pkg/services/team/team.go index 74c51c5547f46..5f1bca402447c 100644 --- a/pkg/services/team/team.go +++ b/pkg/services/team/team.go @@ -14,7 +14,7 @@ type Service interface { GetTeamByID(ctx context.Context, query *GetTeamByIDQuery) (*TeamDTO, error) GetTeamsByUser(ctx context.Context, query *GetTeamsByUserQuery) ([]*TeamDTO, error) GetTeamIDsByUser(ctx context.Context, query *GetTeamIDsByUserQuery) ([]int64, error) - AddTeamMember(userID, orgID, teamID int64, isExternal bool, permission dashboardaccess.PermissionType) error + AddTeamMember(ctx context.Context, userID, orgID, teamID int64, isExternal bool, permission dashboardaccess.PermissionType) error UpdateTeamMember(ctx context.Context, cmd *UpdateTeamMemberCommand) error IsTeamMember(orgId int64, teamId int64, userId int64) (bool, error) RemoveTeamMember(ctx context.Context, cmd *RemoveTeamMemberCommand) error diff --git a/pkg/services/team/teamapi/team.go b/pkg/services/team/teamapi/team.go index b12757856d226..70fcc9ae8e48a 100644 --- a/pkg/services/team/teamapi/team.go +++ b/pkg/services/team/teamapi/team.go @@ -127,6 +127,12 @@ func (tapi *TeamAPI) deleteTeamByID(c *contextmodel.ReqContext) response.Respons } return response.Error(http.StatusInternalServerError, "Failed to delete Team", err) } + + // Clear associated team assignments, managed role and permissions + if err := tapi.ac.DeleteTeamPermissions(c.Req.Context(), orgID, teamID); err != nil { + return response.Error(http.StatusInternalServerError, "Failed to delete Team permissions", err) + } + return response.Success("Team deleted") } @@ -182,7 +188,7 @@ func (tapi *TeamAPI) searchTeams(c *contextmodel.ReqContext) response.Response { teamIDs := map[string]bool{} for _, team := range queryResult.Teams { - team.AvatarURL = dtos.GetGravatarUrlWithDefault(team.Email, team.Name) + team.AvatarURL = dtos.GetGravatarUrlWithDefault(tapi.cfg, team.Email, team.Name) teamIDs[strconv.FormatInt(team.ID, 10)] = true } @@ -234,7 +240,7 @@ func (tapi *TeamAPI) getTeamByID(c *contextmodel.ReqContext) response.Response { // Add accesscontrol metadata queryResult.AccessControl = tapi.getAccessControlMetadata(c, c.SignedInUser.GetOrgID(), "teams:id:", strconv.FormatInt(queryResult.ID, 10)) - queryResult.AvatarURL = dtos.GetGravatarUrlWithDefault(queryResult.Email, queryResult.Name) + queryResult.AvatarURL = dtos.GetGravatarUrlWithDefault(tapi.cfg, queryResult.Email, queryResult.Name) return response.JSON(http.StatusOK, &queryResult) } diff --git a/pkg/services/team/teamapi/team_members.go b/pkg/services/team/teamapi/team_members.go index 75c3317e8afac..89419b8ff09e7 100644 --- a/pkg/services/team/teamapi/team_members.go +++ b/pkg/services/team/teamapi/team_members.go @@ -47,7 +47,7 @@ func (tapi *TeamAPI) getTeamMembers(c *contextmodel.ReqContext) response.Respons continue } - member.AvatarURL = dtos.GetGravatarUrl(member.Email) + member.AvatarURL = dtos.GetGravatarUrl(tapi.cfg, member.Email) member.Labels = []string{} if tapi.license.FeatureEnabled("teamgroupsync") && member.External { diff --git a/pkg/services/team/teamimpl/store.go b/pkg/services/team/teamimpl/store.go index f5d4124bdcee6..f8a85618d99a3 100644 --- a/pkg/services/team/teamimpl/store.go +++ b/pkg/services/team/teamimpl/store.go @@ -11,6 +11,7 @@ import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -25,7 +26,7 @@ type store interface { GetByUser(ctx context.Context, query *team.GetTeamsByUserQuery) ([]*team.TeamDTO, error) GetIDsByUser(ctx context.Context, query *team.GetTeamIDsByUserQuery) ([]int64, error) RemoveUsersMemberships(ctx context.Context, userID int64) error - AddMember(userID, orgID, teamID int64, isExternal bool, permission dashboardaccess.PermissionType) error + AddMember(ctx context.Context, userID, orgID, teamID int64, isExternal bool, permission dashboardaccess.PermissionType) error UpdateMember(ctx context.Context, cmd *team.UpdateTeamMemberCommand) error IsMember(orgId int64, teamId int64, userId int64) (bool, error) RemoveMember(ctx context.Context, cmd *team.RemoveTeamMemberCommand) error @@ -142,7 +143,6 @@ func (ss *xormStore) Delete(ctx context.Context, cmd *team.DeleteTeamCommand) er "DELETE FROM team_member WHERE org_id=? and team_id = ?", "DELETE FROM team WHERE org_id=? and id = ?", "DELETE FROM dashboard_acl WHERE org_id=? and team_id = ?", - "DELETE FROM team_role WHERE org_id=? and team_id = ?", } deletes = append(deletes, ss.deletes...) @@ -153,10 +153,7 @@ func (ss *xormStore) Delete(ctx context.Context, cmd *team.DeleteTeamCommand) er return err } } - - _, err := sess.Exec("DELETE FROM permission WHERE scope=?", ac.Scope("teams", "id", fmt.Sprint(cmd.ID))) - - return err + return nil }) } @@ -354,8 +351,8 @@ WHERE tm.user_id=? AND tm.org_id=?;`, query.UserID, query.OrgID).Find(&queryResu } // AddTeamMember adds a user to a team -func (ss *xormStore) AddMember(userID, orgID, teamID int64, isExternal bool, permission dashboardaccess.PermissionType) error { - return ss.db.WithTransactionalDbSession(context.Background(), func(sess *db.Session) error { +func (ss *xormStore) AddMember(ctx context.Context, userID, orgID, teamID int64, isExternal bool, permission dashboardaccess.PermissionType) error { + return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { if isMember, err := isTeamMember(sess, orgID, teamID, userID); err != nil { return err } else if isMember { @@ -600,3 +597,25 @@ func (ss *xormStore) getTeamMembers(ctx context.Context, query *team.GetTeamMemb func (ss *xormStore) RegisterDelete(query string) { ss.deletes = append(ss.deletes, query) } + +// This is just to ensure that all teams have a valid uid. +// To protect against upgrade / downgrade we need to run this for a couple of releases. +// FIXME: Remove this migration and make uid field required https://github.com/grafana/identity-access-team/issues/552 +func (ss *xormStore) uidMigration() error { + return ss.db.WithDbSession(context.Background(), func(sess *db.Session) error { + switch ss.db.GetDBType() { + case migrator.SQLite: + _, err := sess.Exec("UPDATE team SET uid=printf('t%09d',id) WHERE uid IS NULL;") + return err + case migrator.Postgres: + _, err := sess.Exec("UPDATE team SET uid='t' || lpad('' || id::text,9,'0') WHERE uid IS NULL;") + return err + case migrator.MySQL: + _, err := sess.Exec("UPDATE team SET uid=concat('t',lpad(id,9,'0')) WHERE uid IS NULL;") + return err + default: + // this branch should be unreachable + return nil + } + }) +} diff --git a/pkg/services/team/teamimpl/store_test.go b/pkg/services/team/teamimpl/store_test.go index e864b08bf18e0..b78aefa50a986 100644 --- a/pkg/services/team/teamimpl/store_test.go +++ b/pkg/services/team/teamimpl/store_test.go @@ -22,15 +22,21 @@ import ( "github.com/grafana/grafana/pkg/services/team/sortopts" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationTeamCommandsAndQueries(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } t.Run("Testing Team commands and queries", func(t *testing.T) { sqlStore := db.InitTestDB(t) - teamSvc := ProvideService(sqlStore, sqlStore.Cfg) + teamSvc, err := ProvideService(sqlStore, sqlStore.Cfg) + require.NoError(t, err) testUser := &user.SignedInUser{ OrgID: 1, Permissions: map[int64]map[string][]string{ @@ -86,9 +92,9 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { require.Equal(t, team1.OrgID, testOrgID) require.EqualValues(t, team1.MemberCount, 0) - err = teamSvc.AddTeamMember(userIds[0], testOrgID, team1.ID, false, 0) + err = teamSvc.AddTeamMember(context.Background(), userIds[0], testOrgID, team1.ID, false, 0) require.NoError(t, err) - err = teamSvc.AddTeamMember(userIds[1], testOrgID, team1.ID, true, 0) + err = teamSvc.AddTeamMember(context.Background(), userIds[1], testOrgID, team1.ID, true, 0) require.NoError(t, err) q1 := &team.GetTeamMembersQuery{OrgID: testOrgID, TeamID: team1.ID, SignedInUser: testUser} @@ -146,7 +152,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { team1 := teamQueryResult.Teams[0] - err = teamSvc.AddTeamMember(userId, testOrgID, team1.ID, true, 0) + err = teamSvc.AddTeamMember(context.Background(), userId, testOrgID, team1.ID, true, 0) require.NoError(t, err) memberQuery := &team.GetTeamMembersQuery{OrgID: testOrgID, TeamID: team1.ID, External: true, SignedInUser: testUser} @@ -162,7 +168,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { t.Run("Should be able to update users in a team", func(t *testing.T) { userId := userIds[0] - err = teamSvc.AddTeamMember(userId, testOrgID, team1.ID, false, 0) + err = teamSvc.AddTeamMember(context.Background(), userId, testOrgID, team1.ID, false, 0) require.NoError(t, err) qBeforeUpdate := &team.GetTeamMembersQuery{OrgID: testOrgID, TeamID: team1.ID, SignedInUser: testUser} @@ -189,7 +195,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { sqlStore = db.InitTestDB(t) setup() userID := userIds[0] - err = teamSvc.AddTeamMember(userID, testOrgID, team1.ID, false, 0) + err = teamSvc.AddTeamMember(context.Background(), userID, testOrgID, team1.ID, false, 0) require.NoError(t, err) qBeforeUpdate := &team.GetTeamMembersQuery{OrgID: testOrgID, TeamID: team1.ID, SignedInUser: testUser} @@ -245,7 +251,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { require.NoError(t, err) // Add a team member - err = teamSvc.AddTeamMember(userIds[0], testOrgID, team2.ID, false, 0) + err = teamSvc.AddTeamMember(context.Background(), userIds[0], testOrgID, team2.ID, false, 0) require.NoError(t, err) defer func() { err := teamSvc.RemoveTeamMember(context.Background(), @@ -298,7 +304,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { sqlStore = db.InitTestDB(t) setup() groupId := team2.ID - err := teamSvc.AddTeamMember(userIds[0], testOrgID, groupId, false, 0) + err := teamSvc.AddTeamMember(context.Background(), userIds[0], testOrgID, groupId, false, 0) require.NoError(t, err) query := &team.GetTeamsByUserQuery{ @@ -317,7 +323,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }) t.Run("Should be able to remove users from a group", func(t *testing.T) { - err = teamSvc.AddTeamMember(userIds[0], testOrgID, team1.ID, false, 0) + err = teamSvc.AddTeamMember(context.Background(), userIds[0], testOrgID, team1.ID, false, 0) require.NoError(t, err) err = teamSvc.RemoveTeamMember(context.Background(), &team.RemoveTeamMemberCommand{OrgID: testOrgID, TeamID: team1.ID, UserID: userIds[0]}) @@ -330,7 +336,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }) t.Run("Should have empty teams", func(t *testing.T) { - err = teamSvc.AddTeamMember(userIds[0], testOrgID, team1.ID, false, dashboardaccess.PERMISSION_ADMIN) + err = teamSvc.AddTeamMember(context.Background(), userIds[0], testOrgID, team1.ID, false, dashboardaccess.PERMISSION_ADMIN) require.NoError(t, err) t.Run("A user should be able to remove the admin permission for the last admin", func(t *testing.T) { @@ -347,10 +353,10 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { sqlStore = db.InitTestDB(t) setup() - err = teamSvc.AddTeamMember(userIds[0], testOrgID, team1.ID, false, dashboardaccess.PERMISSION_ADMIN) + err = teamSvc.AddTeamMember(context.Background(), userIds[0], testOrgID, team1.ID, false, dashboardaccess.PERMISSION_ADMIN) require.NoError(t, err) - err = teamSvc.AddTeamMember(userIds[1], testOrgID, team1.ID, false, dashboardaccess.PERMISSION_ADMIN) + err = teamSvc.AddTeamMember(context.Background(), userIds[1], testOrgID, team1.ID, false, dashboardaccess.PERMISSION_ADMIN) require.NoError(t, err) err = teamSvc.UpdateTeamMember(context.Background(), &team.UpdateTeamMemberCommand{OrgID: testOrgID, TeamID: team1.ID, UserID: userIds[0], Permission: 0}) require.NoError(t, err) @@ -373,11 +379,11 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { hiddenUsers := map[string]struct{}{"loginuser0": {}, "loginuser1": {}} teamId := team1.ID - err = teamSvc.AddTeamMember(userIds[0], testOrgID, teamId, false, 0) + err = teamSvc.AddTeamMember(context.Background(), userIds[0], testOrgID, teamId, false, 0) require.NoError(t, err) - err = teamSvc.AddTeamMember(userIds[1], testOrgID, teamId, false, 0) + err = teamSvc.AddTeamMember(context.Background(), userIds[1], testOrgID, teamId, false, 0) require.NoError(t, err) - err = teamSvc.AddTeamMember(userIds[2], testOrgID, teamId, false, 0) + err = teamSvc.AddTeamMember(context.Background(), userIds[2], testOrgID, teamId, false, 0) require.NoError(t, err) searchQuery := &team.SearchTeamsQuery{OrgID: testOrgID, Page: 1, Limit: 10, SignedInUser: signedInUser, HiddenUsers: hiddenUsers} @@ -412,11 +418,11 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { groupId := team2.ID // add service account to team - err = teamSvc.AddTeamMember(serviceAccount.ID, testOrgID, groupId, false, 0) + err = teamSvc.AddTeamMember(context.Background(), serviceAccount.ID, testOrgID, groupId, false, 0) require.NoError(t, err) // add user to team - err = teamSvc.AddTeamMember(userIds[0], testOrgID, groupId, false, 0) + err = teamSvc.AddTeamMember(context.Background(), userIds[0], testOrgID, groupId, false, 0) require.NoError(t, err) teamMembersQuery := &team.GetTeamMembersQuery{ @@ -484,7 +490,8 @@ func TestIntegrationSQLStore_SearchTeams(t *testing.T) { } store := db.InitTestDB(t, db.InitTestDBOpt{}) - teamSvc := ProvideService(store, store.Cfg) + teamSvc, err := ProvideService(store, store.Cfg) + require.NoError(t, err) // Seed 10 teams for i := 1; i <= 10; i++ { @@ -520,7 +527,8 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { // Seed 2 teams with 2 members setup := func(store *sqlstore.SQLStore) { - teamSvc := ProvideService(store, store.Cfg) + teamSvc, err := ProvideService(store, store.Cfg) + require.NoError(t, err) team1, errCreateTeam := teamSvc.CreateTeam("group1 name", "test1@example.org", testOrgID) require.NoError(t, errCreateTeam) team2, errCreateTeam := teamSvc.CreateTeam("group2 name", "test2@example.org", testOrgID) @@ -542,19 +550,20 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { userIds[i] = user.ID } - errAddMember := teamSvc.AddTeamMember(userIds[0], testOrgID, team1.ID, false, 0) + errAddMember := teamSvc.AddTeamMember(context.Background(), userIds[0], testOrgID, team1.ID, false, 0) require.NoError(t, errAddMember) - errAddMember = teamSvc.AddTeamMember(userIds[1], testOrgID, team1.ID, false, 0) + errAddMember = teamSvc.AddTeamMember(context.Background(), userIds[1], testOrgID, team1.ID, false, 0) require.NoError(t, errAddMember) - errAddMember = teamSvc.AddTeamMember(userIds[2], testOrgID, team2.ID, false, 0) + errAddMember = teamSvc.AddTeamMember(context.Background(), userIds[2], testOrgID, team2.ID, false, 0) require.NoError(t, errAddMember) - errAddMember = teamSvc.AddTeamMember(userIds[3], testOrgID, team2.ID, false, 0) + errAddMember = teamSvc.AddTeamMember(context.Background(), userIds[3], testOrgID, team2.ID, false, 0) require.NoError(t, errAddMember) } store := db.InitTestDB(t, db.InitTestDBOpt{}) setup(store) - teamSvc := ProvideService(store, store.Cfg) + teamSvc, err := ProvideService(store, store.Cfg) + require.NoError(t, err) type getTeamMembersTestCase struct { desc string diff --git a/pkg/services/team/teamimpl/team.go b/pkg/services/team/teamimpl/team.go index c6e7fa347f573..11cef2ab58ead 100644 --- a/pkg/services/team/teamimpl/team.go +++ b/pkg/services/team/teamimpl/team.go @@ -13,8 +13,13 @@ type Service struct { store store } -func ProvideService(db db.DB, cfg *setting.Cfg) team.Service { - return &Service{store: &xormStore{db: db, cfg: cfg, deletes: []string{}}} +func ProvideService(db db.DB, cfg *setting.Cfg) (team.Service, error) { + store := &xormStore{db: db, cfg: cfg, deletes: []string{}} + + if err := store.uidMigration(); err != nil { + return nil, err + } + return &Service{store: &xormStore{db: db, cfg: cfg, deletes: []string{}}}, nil } func (s *Service) CreateTeam(name, email string, orgID int64) (team.Team, error) { @@ -45,8 +50,8 @@ func (s *Service) GetTeamIDsByUser(ctx context.Context, query *team.GetTeamIDsBy return s.store.GetIDsByUser(ctx, query) } -func (s *Service) AddTeamMember(userID, orgID, teamID int64, isExternal bool, permission dashboardaccess.PermissionType) error { - return s.store.AddMember(userID, orgID, teamID, isExternal, permission) +func (s *Service) AddTeamMember(ctx context.Context, userID, orgID, teamID int64, isExternal bool, permission dashboardaccess.PermissionType) error { + return s.store.AddMember(ctx, userID, orgID, teamID, isExternal, permission) } func (s *Service) UpdateTeamMember(ctx context.Context, cmd *team.UpdateTeamMemberCommand) error { diff --git a/pkg/services/team/teamtest/team.go b/pkg/services/team/teamtest/team.go index 0f97e9077b315..2c6fccdf3338a 100644 --- a/pkg/services/team/teamtest/team.go +++ b/pkg/services/team/teamtest/team.go @@ -45,7 +45,7 @@ func (s *FakeService) GetTeamsByUser(ctx context.Context, query *team.GetTeamsBy return s.ExpectedTeamsByUser, s.ExpectedError } -func (s *FakeService) AddTeamMember(userID, orgID, teamID int64, isExternal bool, permission dashboardaccess.PermissionType) error { +func (s *FakeService) AddTeamMember(ctx context.Context, userID, orgID, teamID int64, isExternal bool, permission dashboardaccess.PermissionType) error { return s.ExpectedError } diff --git a/pkg/services/temp_user/model.go b/pkg/services/temp_user/model.go index 1e05d15d83280..b0949a793812e 100644 --- a/pkg/services/temp_user/model.go +++ b/pkg/services/temp_user/model.go @@ -15,11 +15,14 @@ var ( type TempUserStatus string const ( - TmpUserSignUpStarted TempUserStatus = "SignUpStarted" - TmpUserInvitePending TempUserStatus = "InvitePending" - TmpUserCompleted TempUserStatus = "Completed" - TmpUserRevoked TempUserStatus = "Revoked" - TmpUserExpired TempUserStatus = "Expired" + TmpUserSignUpStarted TempUserStatus = "SignUpStarted" + TmpUserInvitePending TempUserStatus = "InvitePending" + TmpUserCompleted TempUserStatus = "Completed" + TmpUserRevoked TempUserStatus = "Revoked" + TmpUserExpired TempUserStatus = "Expired" + TmpUserEmailUpdateStarted TempUserStatus = "EmailUpdateStarted" + TmpUserEmailUpdateCompleted TempUserStatus = "EmailUpdateCompleted" + TmpUserEmailUpdateExpired TempUserStatus = "EmailUpdateExpired" ) // TempUser holds data for org invites and unconfirmed sign ups @@ -67,6 +70,12 @@ type ExpireTempUsersCommand struct { NumExpired int64 } +type ExpirePreviousVerificationsCommand struct { + InvitedByUserID int64 + + NumExpired int64 +} + type UpdateTempUserWithEmailSentCommand struct { Code string } diff --git a/pkg/services/temp_user/temp_user.go b/pkg/services/temp_user/temp_user.go index 91b7e49801df0..162e35459cde7 100644 --- a/pkg/services/temp_user/temp_user.go +++ b/pkg/services/temp_user/temp_user.go @@ -11,4 +11,6 @@ type Service interface { GetTempUsersQuery(ctx context.Context, query *GetTempUsersQuery) ([]*TempUserDTO, error) GetTempUserByCode(ctx context.Context, query *GetTempUserByCodeQuery) (*TempUserDTO, error) ExpireOldUserInvites(ctx context.Context, cmd *ExpireTempUsersCommand) error + ExpireOldVerifications(ctx context.Context, cmd *ExpireTempUsersCommand) error + ExpirePreviousVerifications(ctx context.Context, cmd *ExpirePreviousVerificationsCommand) error } diff --git a/pkg/services/temp_user/tempuserimpl/store.go b/pkg/services/temp_user/tempuserimpl/store.go index a0ac0f6670250..9affd89408395 100644 --- a/pkg/services/temp_user/tempuserimpl/store.go +++ b/pkg/services/temp_user/tempuserimpl/store.go @@ -16,6 +16,8 @@ type store interface { GetTempUsersQuery(ctx context.Context, query *tempuser.GetTempUsersQuery) ([]*tempuser.TempUserDTO, error) GetTempUserByCode(ctx context.Context, query *tempuser.GetTempUserByCodeQuery) (*tempuser.TempUserDTO, error) ExpireOldUserInvites(ctx context.Context, cmd *tempuser.ExpireTempUsersCommand) error + ExpireOldVerifications(ctx context.Context, cmd *tempuser.ExpireTempUsersCommand) error + ExpirePreviousVerifications(ctx context.Context, cmd *tempuser.ExpirePreviousVerificationsCommand) error } type xormStore struct { @@ -175,3 +177,27 @@ func (ss *xormStore) ExpireOldUserInvites(ctx context.Context, cmd *tempuser.Exp return nil }) } + +func (ss *xormStore) ExpireOldVerifications(ctx context.Context, cmd *tempuser.ExpireTempUsersCommand) error { + return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + var rawSQL = "UPDATE temp_user SET status = ?, updated = ? WHERE created <= ? AND status = ?" + if result, err := sess.Exec(rawSQL, string(tempuser.TmpUserEmailUpdateExpired), time.Now().Unix(), cmd.OlderThan.Unix(), string(tempuser.TmpUserEmailUpdateStarted)); err != nil { + return err + } else if cmd.NumExpired, err = result.RowsAffected(); err != nil { + return err + } + return nil + }) +} + +func (ss *xormStore) ExpirePreviousVerifications(ctx context.Context, cmd *tempuser.ExpirePreviousVerificationsCommand) error { + return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + var rawSQL = "UPDATE temp_user SET status = ?, updated = ? WHERE invited_by_user_id = ? AND status = ?" + if result, err := sess.Exec(rawSQL, string(tempuser.TmpUserEmailUpdateExpired), time.Now().Unix(), cmd.InvitedByUserID, string(tempuser.TmpUserEmailUpdateStarted)); err != nil { + return err + } else if cmd.NumExpired, err = result.RowsAffected(); err != nil { + return err + } + return nil + }) +} diff --git a/pkg/services/temp_user/tempuserimpl/store_test.go b/pkg/services/temp_user/tempuserimpl/store_test.go index 7fa636110e5dc..6bcd65acd0e8f 100644 --- a/pkg/services/temp_user/tempuserimpl/store_test.go +++ b/pkg/services/temp_user/tempuserimpl/store_test.go @@ -9,8 +9,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" tempuser "github.com/grafana/grafana/pkg/services/temp_user" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationTempUserCommandsAndQueries(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -112,7 +117,32 @@ func TestIntegrationTempUserCommandsAndQueries(t *testing.T) { require.False(t, queryResult[0].EmailSentOn.UTC().Before(queryResult[0].Created.UTC())) }) - t.Run("Should be able expire temp user", func(t *testing.T) { + t.Run("Should be able expire all pending verifications from a user", func(t *testing.T) { + userID := int64(99) + verifications := 5 + cmd := tempuser.CreateTempUserCommand{ + OrgID: -1, + Name: "email-update", + Code: "asd", + Email: "e@as.co", + Status: tempuser.TmpUserEmailUpdateStarted, + InvitedByUserID: userID, + } + db := db.InitTestDB(t) + store = &xormStore{db: db, cfg: db.Cfg} + + for i := 0; i < verifications; i++ { + tempUser, err = store.CreateTempUser(context.Background(), &cmd) + require.Nil(t, err) + } + + cmd2 := tempuser.ExpirePreviousVerificationsCommand{InvitedByUserID: userID} + err := store.ExpirePreviousVerifications(context.Background(), &cmd2) + require.Nil(t, err) + require.Equal(t, int64(verifications), cmd2.NumExpired) + }) + + t.Run("Should be able expire temp user related to org invite", func(t *testing.T) { setup(t) createdAt := time.Unix(tempUser.Created, 0) cmd2 := tempuser.ExpireTempUsersCommand{OlderThan: createdAt.Add(1 * time.Second)} @@ -128,4 +158,34 @@ func TestIntegrationTempUserCommandsAndQueries(t *testing.T) { require.Equal(t, int64(0), cmd2.NumExpired) }) }) + + t.Run("Should be able expire temp user related to email verification", func(t *testing.T) { + cmd := tempuser.CreateTempUserCommand{ + OrgID: 2256, + Name: "email-update", + Code: "asd", + Email: "e@as.co", + Status: tempuser.TmpUserEmailUpdateStarted, + InvitedByUserID: 99, + } + db := db.InitTestDB(t) + store = &xormStore{db: db, cfg: db.Cfg} + + tempUser, err = store.CreateTempUser(context.Background(), &cmd) + require.Nil(t, err) + + createdAt := time.Unix(tempUser.Created, 0) + cmd2 := tempuser.ExpireTempUsersCommand{OlderThan: createdAt.Add(1 * time.Second)} + err := store.ExpireOldVerifications(context.Background(), &cmd2) + require.Nil(t, err) + require.Equal(t, int64(1), cmd2.NumExpired) + + t.Run("Should do nothing when no temp users to expire", func(t *testing.T) { + createdAt := time.Unix(tempUser.Created, 0) + cmd2 := tempuser.ExpireTempUsersCommand{OlderThan: createdAt.Add(1 * time.Second)} + err := store.ExpireOldVerifications(context.Background(), &cmd2) + require.Nil(t, err) + require.Equal(t, int64(0), cmd2.NumExpired) + }) + }) } diff --git a/pkg/services/temp_user/tempuserimpl/temp_user.go b/pkg/services/temp_user/tempuserimpl/temp_user.go index e6dd24fe452c7..89795f1364027 100644 --- a/pkg/services/temp_user/tempuserimpl/temp_user.go +++ b/pkg/services/temp_user/tempuserimpl/temp_user.go @@ -68,3 +68,19 @@ func (s *Service) ExpireOldUserInvites(ctx context.Context, cmd *tempuser.Expire } return nil } + +func (s *Service) ExpireOldVerifications(ctx context.Context, cmd *tempuser.ExpireTempUsersCommand) error { + err := s.store.ExpireOldVerifications(ctx, cmd) + if err != nil { + return err + } + return nil +} + +func (s *Service) ExpirePreviousVerifications(ctx context.Context, cmd *tempuser.ExpirePreviousVerificationsCommand) error { + err := s.store.ExpirePreviousVerifications(ctx, cmd) + if err != nil { + return err + } + return nil +} diff --git a/pkg/services/temp_user/tempusertest/fake.go b/pkg/services/temp_user/tempusertest/fake.go new file mode 100644 index 0000000000000..17d8c2c133061 --- /dev/null +++ b/pkg/services/temp_user/tempusertest/fake.go @@ -0,0 +1,37 @@ +package tempusertest + +import ( + "context" + + tempuser "github.com/grafana/grafana/pkg/services/temp_user" +) + +var _ tempuser.Service = (*FakeTempUserService)(nil) + +type FakeTempUserService struct { + tempuser.Service + CreateTempUserFN func(ctx context.Context, cmd *tempuser.CreateTempUserCommand) (*tempuser.TempUser, error) + ExpirePreviousVerificationsFN func(ctx context.Context, cmd *tempuser.ExpirePreviousVerificationsCommand) error + UpdateTempUserWithEmailSentFN func(ctx context.Context, cmd *tempuser.UpdateTempUserWithEmailSentCommand) error +} + +func (f *FakeTempUserService) CreateTempUser(ctx context.Context, cmd *tempuser.CreateTempUserCommand) (*tempuser.TempUser, error) { + if f.CreateTempUserFN != nil { + return f.CreateTempUserFN(ctx, cmd) + } + return nil, nil +} + +func (f *FakeTempUserService) ExpirePreviousVerifications(ctx context.Context, cmd *tempuser.ExpirePreviousVerificationsCommand) error { + if f.ExpirePreviousVerificationsFN != nil { + return f.ExpirePreviousVerificationsFN(ctx, cmd) + } + return nil +} + +func (f *FakeTempUserService) UpdateTempUserWithEmailSent(ctx context.Context, cmd *tempuser.UpdateTempUserWithEmailSentCommand) error { + if f.UpdateTempUserWithEmailSentFN != nil { + return f.UpdateTempUserWithEmailSentFN(ctx, cmd) + } + return nil +} diff --git a/pkg/services/updatechecker/grafana.go b/pkg/services/updatechecker/grafana.go index 006fc0be575a0..3760fe06b1347 100644 --- a/pkg/services/updatechecker/grafana.go +++ b/pkg/services/updatechecker/grafana.go @@ -20,7 +20,7 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -const grafanaLatestJSONURL = "https://raw.githubusercontent.com/grafana/grafana/main/latest.json" +const grafanaStableVersionURL = "https://grafana.com/api/grafana/versions/stable" type GrafanaService struct { hasUpdate bool @@ -60,7 +60,7 @@ func (s *GrafanaService) IsDisabled() bool { func (s *GrafanaService) Run(ctx context.Context) error { s.instrumentedCheckForUpdates(ctx) - ticker := time.NewTicker(time.Minute * 10) + ticker := time.NewTicker(time.Hour * 24) run := true for run { @@ -92,13 +92,13 @@ func (s *GrafanaService) instrumentedCheckForUpdates(ctx context.Context) { func (s *GrafanaService) checkForUpdates(ctx context.Context) error { ctxLogger := s.log.FromContext(ctx) ctxLogger.Debug("Checking for updates") - req, err := http.NewRequestWithContext(ctx, http.MethodGet, grafanaLatestJSONURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, grafanaStableVersionURL, nil) if err != nil { return err } resp, err := s.httpClient.Do(req) if err != nil { - return fmt.Errorf("failed to get latest.json repo from github.com: %w", err) + return fmt.Errorf("failed to get stable version from grafana.com: %w", err) } defer func() { if err := resp.Body.Close(); err != nil { @@ -107,27 +107,24 @@ func (s *GrafanaService) checkForUpdates(ctx context.Context) error { }() body, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("update check failed, reading response from github.com: %w", err) + return fmt.Errorf("update check failed, reading response from grafana.com: %w", err) } - type latestJSON struct { - Stable string `json:"stable"` - Testing string `json:"testing"` + type grafanaVersionJSON struct { + Version string `json:"version"` } - var latest latestJSON + var latest grafanaVersionJSON err = json.Unmarshal(body, &latest) if err != nil { - return fmt.Errorf("failed to unmarshal latest.json: %w", err) + return fmt.Errorf("failed to unmarshal response from grafana.com: %w", err) } s.mutex.Lock() defer s.mutex.Unlock() - if strings.Contains(s.grafanaVersion, "-") { - s.latestVersion = latest.Testing - s.hasUpdate = !strings.HasPrefix(s.grafanaVersion, latest.Testing) - } else { - s.latestVersion = latest.Stable - s.hasUpdate = latest.Stable != s.grafanaVersion + // only check for updates in stable versions + if !strings.Contains(s.grafanaVersion, "-") { + s.latestVersion = latest.Version + s.hasUpdate = latest.Version != s.grafanaVersion } currVersion, err1 := version.NewVersion(s.grafanaVersion) diff --git a/pkg/services/user/error.go b/pkg/services/user/error.go index ba70fd24baf18..8fd192704fb25 100644 --- a/pkg/services/user/error.go +++ b/pkg/services/user/error.go @@ -18,6 +18,7 @@ var ( ) var ( + ErrEmailConflict = errutil.Conflict("user.email-conflict", errutil.WithPublicMessage("Email is already being used")) ErrEmptyUsernameAndEmail = errutil.BadRequest( "user.empty-username-and-email", errutil.WithPublicMessage("Need to specify either username or email"), ) diff --git a/pkg/services/user/identity.go b/pkg/services/user/identity.go index 49b4edc2c8c99..4b2825f817fb1 100644 --- a/pkg/services/user/identity.go +++ b/pkg/services/user/identity.go @@ -2,15 +2,21 @@ package user import ( "fmt" + "strings" "time" "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/services/auth/identity" ) +const ( + GlobalOrgID = int64(0) +) + type SignedInUser struct { - UserID int64 `xorm:"user_id"` - OrgID int64 `xorm:"org_id"` + UserID int64 `xorm:"user_id"` + UserUID string `xorm:"user_uid"` + OrgID int64 `xorm:"org_id"` OrgName string OrgRole roletype.RoleType Login string @@ -54,6 +60,7 @@ func (u *SignedInUser) NameOrFallback() string { func (u *SignedInUser) ToUserDisplayDTO() *UserDisplayDTO { return &UserDisplayDTO{ ID: u.UserID, + UID: u.UserUID, Login: u.Login, Name: u.Name, // AvatarURL: dtos.GetGravatarUrl(u.GetEmail()), @@ -159,6 +166,19 @@ func (u *SignedInUser) GetPermissions() map[string][]string { return u.Permissions[u.GetOrgID()] } +// GetGlobalPermissions returns the permissions of the active entity that are available across all organizations +func (u *SignedInUser) GetGlobalPermissions() map[string][]string { + if u.Permissions == nil { + return make(map[string][]string) + } + + if u.Permissions[GlobalOrgID] == nil { + return make(map[string][]string) + } + + return u.Permissions[GlobalOrgID] +} + // DEPRECATED: GetTeams returns the teams the entity is a member of // Retrieve the teams from the team service instead of using this method. func (u *SignedInUser) GetTeams() []int64 { @@ -170,28 +190,31 @@ func (u *SignedInUser) GetOrgRole() roletype.RoleType { return u.OrgRole } -// GetNamespacedID returns the namespace and ID of the active entity -// The namespace is one of the constants defined in pkg/services/auth/identity -func (u *SignedInUser) GetNamespacedID() (string, string) { +// GetID returns namespaced id for the entity +func (u *SignedInUser) GetID() string { switch { case u.ApiKeyID != 0: - return identity.NamespaceAPIKey, fmt.Sprintf("%d", u.ApiKeyID) + return namespacedID(identity.NamespaceAPIKey, u.ApiKeyID) case u.IsServiceAccount: - return identity.NamespaceServiceAccount, fmt.Sprintf("%d", u.UserID) + return namespacedID(identity.NamespaceServiceAccount, u.UserID) case u.UserID > 0: - return identity.NamespaceUser, fmt.Sprintf("%d", u.UserID) + return namespacedID(identity.NamespaceUser, u.UserID) case u.IsAnonymous: - return identity.NamespaceAnonymous, "" - case u.AuthenticatedBy == "render": //import cycle render - if u.UserID == 0 { - return identity.NamespaceRenderService, "0" - } else { // this should never happen as u.UserID > 0 already catches this - return identity.NamespaceUser, fmt.Sprintf("%d", u.UserID) - } + return identity.NamespaceAnonymous + ":" + case u.AuthenticatedBy == "render" && u.UserID == 0: + return namespacedID(identity.NamespaceRenderService, 0) } // backwards compatibility - return identity.NamespaceUser, fmt.Sprintf("%d", u.UserID) + return namespacedID(identity.NamespaceUser, u.UserID) +} + +// GetNamespacedID returns the namespace and ID of the active entity +// The namespace is one of the constants defined in pkg/services/auth/identity +func (u *SignedInUser) GetNamespacedID() (string, string) { + parts := strings.Split(u.GetID(), ":") + // Safety: GetID always returns a ':' separated string + return parts[0], parts[1] } // FIXME: remove this method once all services are using an interface @@ -219,3 +242,7 @@ func (u *SignedInUser) GetAuthenticatedBy() string { func (u *SignedInUser) GetIDToken() string { return u.IDToken } + +func namespacedID(namespace string, id int64) string { + return fmt.Sprintf("%s:%d", namespace, id) +} diff --git a/pkg/services/user/model.go b/pkg/services/user/model.go index 81b184286abae..1d7e8db976ed9 100644 --- a/pkg/services/user/model.go +++ b/pkg/services/user/model.go @@ -19,13 +19,21 @@ const ( HelpFlagDashboardHelp1 ) +type UpdateEmailActionType string + +const ( + EmailUpdateAction UpdateEmailActionType = "email-update" + LoginUpdateAction UpdateEmailActionType = "login-update" +) + type User struct { - ID int64 `xorm:"pk autoincr 'id'"` + ID int64 `xorm:"pk autoincr 'id'"` + UID string `json:"uid" xorm:"uid"` Version int Email string Name string Login string - Password string + Password Password Salt string Rands string Company string @@ -44,13 +52,14 @@ type User struct { } type CreateUserCommand struct { + UID string Email string Login string Name string Company string OrgID int64 OrgName string - Password string + Password Password EmailVerified bool IsAdmin bool IsDisabled bool @@ -77,8 +86,8 @@ type UpdateUserCommand struct { } type ChangeUserPasswordCommand struct { - OldPassword string `json:"oldPassword"` - NewPassword string `json:"newPassword"` + OldPassword Password `json:"oldPassword"` + NewPassword Password `json:"newPassword"` UserID int64 `json:"-"` } @@ -115,6 +124,7 @@ type SearchUserQueryResult struct { type UserSearchHitDTO struct { ID int64 `json:"id" xorm:"id"` + UID string `json:"uid" xorm:"id"` Name string `json:"name"` Login string `json:"login"` Email string `json:"email"` @@ -133,6 +143,7 @@ type GetUserProfileQuery struct { type UserProfileDTO struct { ID int64 `json:"id"` + UID string `json:"uid"` Email string `json:"email"` Name string `json:"name"` Login string `json:"login"` @@ -209,12 +220,19 @@ type GetUserByIDQuery struct { ID int64 } +type VerifyEmailCommand struct { + User User + Email string + Action UpdateEmailActionType +} + type ErrCaseInsensitiveLoginConflict struct { Users []User } type UserDisplayDTO struct { ID int64 `json:"id,omitempty"` + UID string `json:"uid,omitempty"` Name string `json:"name,omitempty"` Login string `json:"login,omitempty"` AvatarURL string `json:"avatarUrl"` @@ -275,9 +293,3 @@ type AdminCreateUserResponse struct { ID int64 `json:"id"` Message string `json:"message"` } - -type Password string - -func (p Password) IsWeak() bool { - return len(p) <= 4 -} diff --git a/pkg/services/user/password.go b/pkg/services/user/password.go new file mode 100644 index 0000000000000..70e0e131e58bf --- /dev/null +++ b/pkg/services/user/password.go @@ -0,0 +1,71 @@ +package user + +import ( + "unicode" + + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util/errutil" +) + +var ( + ErrPasswordTooShort = errutil.BadRequest("password.password-policy-too-short", errutil.WithPublicMessage("New password is too short")) + ErrPasswordPolicyInfringe = errutil.BadRequest("password.password-policy-infringe", errutil.WithPublicMessage("New password doesn't comply with the password policy")) + MinPasswordLength = 12 +) + +type Password string + +func NewPassword(newPassword string, config *setting.Cfg) (Password, error) { + if err := ValidatePassword(newPassword, config); err != nil { + return "", err + } + return Password(newPassword), nil +} + +func (p Password) Validate(config *setting.Cfg) error { + return ValidatePassword(string(p), config) +} + +// ValidatePassword checks if a new password meets the required criteria based on the given configuration. +// If BasicAuthStrongPasswordPolicy is disabled, it only checks for password length. +// Otherwise, it ensures the password meets the minimum length requirement and contains at least one uppercase letter, +// one lowercase letter, one number, and one symbol. +func ValidatePassword(newPassword string, config *setting.Cfg) error { + if !config.BasicAuthStrongPasswordPolicy { + if len(newPassword) <= 4 { + return ErrPasswordTooShort.Errorf("new password is too short") + } + return nil + } + if len(newPassword) < MinPasswordLength { + return ErrPasswordPolicyInfringe.Errorf("new password is too short for the strong password policy") + } + + hasUpperCase := false + hasLowerCase := false + hasNumber := false + hasSymbol := false + + for _, r := range newPassword { + if !hasLowerCase && unicode.IsLower(r) { + hasLowerCase = true + } + + if !hasUpperCase && unicode.IsUpper(r) { + hasUpperCase = true + } + + if !hasNumber && unicode.IsNumber(r) { + hasNumber = true + } + + if !hasSymbol && !unicode.IsLetter(r) && !unicode.IsNumber(r) { + hasSymbol = true + } + + if hasUpperCase && hasLowerCase && hasNumber && hasSymbol { + return nil + } + } + return ErrPasswordPolicyInfringe.Errorf("new password doesn't comply with the password policy") +} diff --git a/pkg/services/user/password_test.go b/pkg/services/user/password_test.go new file mode 100644 index 0000000000000..1953d1a152902 --- /dev/null +++ b/pkg/services/user/password_test.go @@ -0,0 +1,93 @@ +package user + +import ( + "testing" + + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" +) + +func TestPasswowrdService_ValidatePasswordHardcodePolicy(t *testing.T) { + LOWERCASE := "lowercase" + UPPERCASE := "UPPERCASE" + NUMBER := "123" + SYMBOLS := "!@#$%" + testCases := []struct { + expectedError error + name string + passwordTest string + strongPasswordPolicyEnabled bool + }{ + { + name: "should return error when the password has less than 4 characters and strong password policy is disabled", + passwordTest: NUMBER, + expectedError: ErrPasswordTooShort.Errorf("new password is too short"), + strongPasswordPolicyEnabled: false, + }, + {name: "should not return error when the password has 4 characters and strong password policy is disabled", + passwordTest: LOWERCASE, + expectedError: nil, + strongPasswordPolicyEnabled: false, + }, + { + name: "should return error when the password has less than 12 characters and strong password policy is enabled", + passwordTest: NUMBER, + expectedError: ErrPasswordPolicyInfringe.Errorf("new password is too short for the strong password policy"), + strongPasswordPolicyEnabled: true, + }, + { + name: "should return error when the password is missing an uppercase character and strong password policy is enabled", + passwordTest: LOWERCASE + NUMBER + SYMBOLS, + expectedError: ErrPasswordPolicyInfringe.Errorf("new password doesn't comply with the password policy"), + strongPasswordPolicyEnabled: true, + }, + { + name: "should return error when the password is missing a lowercase character and strong password policy is enabled", + passwordTest: UPPERCASE + NUMBER + SYMBOLS, + expectedError: ErrPasswordPolicyInfringe.Errorf("new password doesn't comply with the password policy"), + strongPasswordPolicyEnabled: true, + }, + { + name: "should return error when the password is missing a number character and strong password policy is enabled", + passwordTest: LOWERCASE + UPPERCASE + SYMBOLS, + expectedError: ErrPasswordPolicyInfringe.Errorf("new password doesn't comply with the password policy"), + strongPasswordPolicyEnabled: true, + }, + { + name: "should return error when the password is missing a symbol characters and strong password policy is enabled", + passwordTest: LOWERCASE + UPPERCASE + NUMBER, + expectedError: ErrPasswordPolicyInfringe.Errorf("new password doesn't comply with the password policy"), + strongPasswordPolicyEnabled: true, + }, + { + name: "should not return error when the password has lowercase, uppercase, number and symbol and strong password policy is enabled", + passwordTest: LOWERCASE + UPPERCASE + NUMBER + SYMBOLS, + expectedError: nil, + strongPasswordPolicyEnabled: true, + }, + { + name: "should not return error when the password has uppercase, number, symbol and lowercase and strong password policy is enabled", + passwordTest: UPPERCASE + NUMBER + SYMBOLS + LOWERCASE, + expectedError: nil, + strongPasswordPolicyEnabled: true, + }, + { + name: "should not return error when the password has number, symbol, lowercase and uppercase and strong password policy is enabled", + passwordTest: NUMBER + SYMBOLS + LOWERCASE + UPPERCASE, + expectedError: nil, + strongPasswordPolicyEnabled: true, + }, + { + name: "should not return error when the password has symbol, lowercase, uppercase and number and strong password policy is enabled", + passwordTest: SYMBOLS + LOWERCASE + UPPERCASE + NUMBER, + expectedError: nil, + strongPasswordPolicyEnabled: true, + }, + } + for _, tc := range testCases { + cfg := setting.NewCfg() + cfg.BasicAuthStrongPasswordPolicy = tc.strongPasswordPolicyEnabled + err := ValidatePassword(tc.passwordTest, cfg) + assert.Equal(t, tc.expectedError, err) + } +} diff --git a/pkg/services/user/user.go b/pkg/services/user/user.go index 66008e7cd0176..1466fed7c662a 100644 --- a/pkg/services/user/user.go +++ b/pkg/services/user/user.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/registry" ) +//go:generate mockery --name Service --structname MockService --outpkg usertest --filename mock.go --output ./usertest/ type Service interface { registry.ProvidesUsageStats Create(context.Context, *CreateUserCommand) (*User, error) @@ -28,3 +29,7 @@ type Service interface { SetUserHelpFlag(context.Context, *SetUserHelpFlagCommand) error GetProfile(context.Context, *GetUserProfileQuery) (*UserProfileDTO, error) } + +type Verifier interface { + VerifyEmail(ctx context.Context, cmd VerifyEmailCommand) error +} diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index cedd27fe3b775..8923e446348f6 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -63,6 +63,9 @@ func (ss *sqlStore) Insert(ctx context.Context, cmd *user.User) (int64, error) { var err error err = ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { sess.UseBool("is_admin") + if cmd.UID == "" { + cmd.UID = util.GenerateShortUID() + } if _, err = sess.Insert(cmd); err != nil { return err @@ -393,6 +396,7 @@ func (ss *sqlStore) GetSignedInUser(ctx context.Context, query *user.GetSignedIn var rawSQL = `SELECT u.id as user_id, + u.uid as user_uid, u.is_admin as is_grafana_admin, u.email as email, u.login as login, @@ -466,6 +470,7 @@ func (ss *sqlStore) GetProfile(ctx context.Context, query *user.GetUserProfileQu userProfile = user.UserProfileDTO{ ID: usr.ID, + UID: usr.UID, Name: usr.Name, Email: usr.Email, Login: usr.Login, diff --git a/pkg/services/user/userimpl/store_test.go b/pkg/services/user/userimpl/store_test.go index 4940bb7d15f71..9b6cd8391a460 100644 --- a/pkg/services/user/userimpl/store_test.go +++ b/pkg/services/user/userimpl/store_test.go @@ -19,8 +19,13 @@ import ( "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationUserGet(t *testing.T) { testCases := []struct { name string @@ -104,6 +109,7 @@ func TestIntegrationUserGet(t *testing.T) { } else { require.NoError(t, err) require.NotNil(t, usr) + require.NotEmpty(t, usr.UID) } }) } @@ -150,6 +156,32 @@ func TestIntegrationUserDataAccess(t *testing.T) { require.NoError(t, err) }) + t.Run("insert user (with known UID)", func(t *testing.T) { + ctx := context.Background() + id, err := userStore.Insert(ctx, + &user.User{ + UID: "abcd", + Email: "next-test@email.com", + Name: "next-test1", + Login: "next-test1", + Created: time.Now(), + Updated: time.Now(), + }, + ) + require.NoError(t, err) + + found, err := userStore.GetByID(ctx, id) + require.NoError(t, err) + require.Equal(t, "abcd", found.UID) + + siu, err := userStore.GetSignedInUser(ctx, &user.GetSignedInUserQuery{ + UserID: id, + OrgID: found.OrgID, + }) + require.NoError(t, err) + require.Equal(t, "abcd", siu.UserUID) + }) + t.Run("get user", func(t *testing.T) { _, err := userStore.Get(context.Background(), &user.User{ @@ -177,7 +209,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { require.Nil(t, err) require.Equal(t, result.Email, "usertest@test.com") - require.Equal(t, result.Password, "") + require.Equal(t, string(result.Password), "") require.Len(t, result.Rands, 10) require.Len(t, result.Salt, 10) require.False(t, result.IsDisabled) @@ -186,7 +218,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { require.Nil(t, err) require.Equal(t, result.Email, "usertest@test.com") - require.Equal(t, result.Password, "") + require.Equal(t, string(result.Password), "") require.Len(t, result.Rands, 10) require.Len(t, result.Salt, 10) require.False(t, result.IsDisabled) @@ -198,7 +230,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { require.Nil(t, err) require.Equal(t, result.Email, "usertest@test.com") - require.Equal(t, result.Password, "") + require.Equal(t, string(result.Password), "") require.Len(t, result.Rands, 10) require.Len(t, result.Salt, 10) require.False(t, result.IsDisabled) @@ -211,7 +243,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { require.Nil(t, err) require.Equal(t, result.Email, "usertest@test.com") - require.Equal(t, result.Password, "") + require.Equal(t, string(result.Password), "") require.Len(t, result.Rands, 10) require.Len(t, result.Salt, 10) require.False(t, result.IsDisabled) @@ -220,7 +252,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { require.Nil(t, err) require.Equal(t, result.Email, "usertest@test.com") - require.Equal(t, result.Password, "") + require.Equal(t, string(result.Password), "") require.Len(t, result.Rands, 10) require.Len(t, result.Salt, 10) require.False(t, result.IsDisabled) diff --git a/pkg/services/user/userimpl/user.go b/pkg/services/user/userimpl/user.go index c3e64d4a8ee28..cdcd7be9a26d4 100644 --- a/pkg/services/user/userimpl/user.go +++ b/pkg/services/user/userimpl/user.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/serviceaccounts" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/supportbundles" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/user" @@ -60,6 +61,10 @@ func ProvideService( return s, err } + if err := s.uidMigration(db); err != nil { + return nil, err + } + bundleRegistry.RegisterSupportItemCollector(s.supportBundleCollector()) return s, nil } @@ -67,11 +72,16 @@ func ProvideService( func (s *Service) GetUsageStats(ctx context.Context) map[string]any { stats := map[string]any{} caseInsensitiveLoginVal := 0 + basicAuthStrongPasswordPolicyVal := 0 if s.cfg.CaseInsensitiveLogin { caseInsensitiveLoginVal = 1 } + if s.cfg.BasicAuthStrongPasswordPolicy { + basicAuthStrongPasswordPolicyVal = 1 + } stats["stats.case_insensitive_login.count"] = caseInsensitiveLoginVal + stats["stats.password_policy.count"] = basicAuthStrongPasswordPolicyVal count, err := s.store.CountUserAccountsWithEmptyRole(ctx) if err != nil { @@ -129,6 +139,7 @@ func (s *Service) Create(ctx context.Context, cmd *user.CreateUserCommand) (*use // create user usr := &user.User{ + UID: cmd.UID, Email: cmd.Email, Name: cmd.Name, Login: cmd.Login, @@ -155,11 +166,11 @@ func (s *Service) Create(ctx context.Context, cmd *user.CreateUserCommand) (*use usr.Rands = rands if len(cmd.Password) > 0 { - encodedPassword, err := util.EncodePassword(cmd.Password, usr.Salt) + encodedPassword, err := util.EncodePassword(string(cmd.Password), usr.Salt) if err != nil { return nil, err } - usr.Password = encodedPassword + usr.Password = user.Password(encodedPassword) } _, err = s.store.Insert(ctx, usr) @@ -484,3 +495,25 @@ func (s *Service) supportBundleCollector() supportbundles.Collector { Fn: collectorFn, } } + +// This is just to ensure that all users have a valid uid. +// To protect against upgrade / downgrade we need to run this for a couple of releases. +// FIXME: Remove this migration and make uid field required https://github.com/grafana/identity-access-team/issues/552 +func (s *Service) uidMigration(store db.DB) error { + return store.WithDbSession(context.Background(), func(sess *db.Session) error { + switch store.GetDBType() { + case migrator.SQLite: + _, err := sess.Exec("UPDATE user SET uid=printf('u%09d',id) WHERE uid IS NULL;") + return err + case migrator.Postgres: + _, err := sess.Exec("UPDATE `user` SET uid='u' || lpad('' || id::text,9,'0') WHERE uid IS NULL;") + return err + case migrator.MySQL: + _, err := sess.Exec("UPDATE user SET uid=concat('u',lpad(id,9,'0')) WHERE uid IS NULL;") + return err + default: + // this branch should be unreachable + return nil + } + }) +} diff --git a/pkg/services/user/userimpl/user_test.go b/pkg/services/user/userimpl/user_test.go index 549515d458f4b..b33aa2eb4f7da 100644 --- a/pkg/services/user/userimpl/user_test.go +++ b/pkg/services/user/userimpl/user_test.go @@ -219,13 +219,15 @@ func TestMetrics(t *testing.T) { userService.cfg = setting.NewCfg() userService.cfg.CaseInsensitiveLogin = true + userService.cfg.BasicAuthStrongPasswordPolicy = true stats := userService.GetUsageStats(context.Background()) assert.NotEmpty(t, stats) - assert.Len(t, stats, 2, stats) + assert.Len(t, stats, 3, stats) assert.Equal(t, 1, stats["stats.case_insensitive_login.count"]) assert.Equal(t, int64(1), stats["stats.user.role_none.count"]) + assert.Equal(t, 1, stats["stats.password_policy.count"]) }) } diff --git a/pkg/services/user/userimpl/verifier.go b/pkg/services/user/userimpl/verifier.go new file mode 100644 index 0000000000000..719a530937ad6 --- /dev/null +++ b/pkg/services/user/userimpl/verifier.go @@ -0,0 +1,82 @@ +package userimpl + +import ( + "context" + "errors" + "fmt" + + "github.com/grafana/grafana/pkg/services/notifications" + tempuser "github.com/grafana/grafana/pkg/services/temp_user" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/util" +) + +var _ user.Verifier = (*Verifier)(nil) + +func ProvideVerifier(us user.Service, ts tempuser.Service, ns notifications.Service) *Verifier { + return &Verifier{us, ts, ns} +} + +type Verifier struct { + us user.Service + ts tempuser.Service + ns notifications.Service +} + +func (s *Verifier) VerifyEmail(ctx context.Context, cmd user.VerifyEmailCommand) error { + usr, err := s.us.GetByLogin(ctx, &user.GetUserByLoginQuery{ + LoginOrEmail: cmd.Email, + }) + + if err != nil && !errors.Is(err, user.ErrUserNotFound) { + return err + } + + // if email is already used by another user we stop here + if usr != nil && usr.ID != cmd.User.ID { + return user.ErrEmailConflict.Errorf("email already used") + } + + code, err := util.GetRandomString(20) + if err != nil { + return fmt.Errorf("failed to generate verification code: %w", err) + } + + // invalidate any pending verifications for user + if err = s.ts.ExpirePreviousVerifications( + ctx, &tempuser.ExpirePreviousVerificationsCommand{InvitedByUserID: cmd.User.ID}, + ); err != nil { + return fmt.Errorf("failed to expire previous verifications: %w", err) + } + + tmpUsr, err := s.ts.CreateTempUser(ctx, &tempuser.CreateTempUserCommand{ + OrgID: -1, + // used to determine if the user was updating their email or username in the second step of the verification flow + Name: string(cmd.Action), + // used to fetch the User in the second step of the verification flow + InvitedByUserID: cmd.User.ID, + Email: cmd.Email, + Code: code, + Status: tempuser.TmpUserEmailUpdateStarted, + }) + + if err != nil { + return fmt.Errorf("failed to generate temp user for email verification: %w", err) + } + + if err := s.ns.SendVerificationEmail(ctx, ¬ifications.SendVerifyEmailCommand{ + User: &cmd.User, + Code: tmpUsr.Code, + Email: cmd.Email, + }); err != nil { + return fmt.Errorf("failed to send verification email: %w", err) + } + + if err := s.ts.UpdateTempUserWithEmailSent(ctx, &tempuser.UpdateTempUserWithEmailSentCommand{ + Code: tmpUsr.Code, + }); err != nil { + return fmt.Errorf("failed to mark email as sent: %w", err) + } + + return nil +} diff --git a/pkg/services/user/userimpl/verifier_test.go b/pkg/services/user/userimpl/verifier_test.go new file mode 100644 index 0000000000000..c57dc2fabf8bc --- /dev/null +++ b/pkg/services/user/userimpl/verifier_test.go @@ -0,0 +1,108 @@ +package userimpl + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/grafana/grafana/pkg/services/notifications" + tempuser "github.com/grafana/grafana/pkg/services/temp_user" + "github.com/grafana/grafana/pkg/services/temp_user/tempusertest" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/usertest" +) + +func TestVerifier_VerifyEmail(t *testing.T) { + ts := &tempusertest.FakeTempUserService{} + us := &usertest.FakeUserService{} + ns := notifications.MockNotificationService() + + type calls struct { + expireCalled bool + createCalled bool + updateCalled bool + } + + verifier := ProvideVerifier(us, ts, ns) + t.Run("should error if email already exist for other user", func(t *testing.T) { + us.ExpectedUser = &user.User{ID: 1} + err := verifier.VerifyEmail(context.Background(), user.VerifyEmailCommand{ + User: user.User{ID: 2}, + Email: "some@email.com", + Action: user.EmailUpdateAction, + }) + + assert.ErrorIs(t, err, user.ErrEmailConflict) + }) + + t.Run("should succeed when no user has the email", func(t *testing.T) { + us.ExpectedUser = nil + var c calls + ts.ExpirePreviousVerificationsFN = func(ctx context.Context, cmd *tempuser.ExpirePreviousVerificationsCommand) error { + c.expireCalled = true + return nil + } + + ts.CreateTempUserFN = func(ctx context.Context, cmd *tempuser.CreateTempUserCommand) (*tempuser.TempUser, error) { + c.createCalled = true + return &tempuser.TempUser{ + OrgID: cmd.OrgID, + Email: cmd.Email, + Name: cmd.Name, + InvitedByUserID: cmd.InvitedByUserID, + Code: cmd.Code, + }, nil + } + + ts.UpdateTempUserWithEmailSentFN = func(ctx context.Context, cmd *tempuser.UpdateTempUserWithEmailSentCommand) error { + c.updateCalled = true + return nil + } + err := verifier.VerifyEmail(context.Background(), user.VerifyEmailCommand{ + User: user.User{ID: 2}, + Email: "some@email.com", + Action: user.EmailUpdateAction, + }) + + assert.ErrorIs(t, err, nil) + assert.True(t, c.expireCalled) + assert.True(t, c.createCalled) + assert.True(t, c.updateCalled) + }) + + t.Run("should succeed when the user holding the email is the same user that want to verify it", func(t *testing.T) { + us.ExpectedUser = &user.User{ID: 2} + var c calls + ts.ExpirePreviousVerificationsFN = func(ctx context.Context, cmd *tempuser.ExpirePreviousVerificationsCommand) error { + c.expireCalled = true + return nil + } + + ts.CreateTempUserFN = func(ctx context.Context, cmd *tempuser.CreateTempUserCommand) (*tempuser.TempUser, error) { + c.createCalled = true + return &tempuser.TempUser{ + OrgID: cmd.OrgID, + Email: cmd.Email, + Name: cmd.Name, + InvitedByUserID: cmd.InvitedByUserID, + Code: cmd.Code, + }, nil + } + + ts.UpdateTempUserWithEmailSentFN = func(ctx context.Context, cmd *tempuser.UpdateTempUserWithEmailSentCommand) error { + c.updateCalled = true + return nil + } + err := verifier.VerifyEmail(context.Background(), user.VerifyEmailCommand{ + User: user.User{ID: 2}, + Email: "some@email.com", + Action: user.EmailUpdateAction, + }) + + assert.ErrorIs(t, err, nil) + assert.True(t, c.expireCalled) + assert.True(t, c.createCalled) + assert.True(t, c.updateCalled) + }) +} diff --git a/pkg/services/user/usertest/mock.go b/pkg/services/user/usertest/mock.go new file mode 100644 index 0000000000000..590a66c9b08e3 --- /dev/null +++ b/pkg/services/user/usertest/mock.go @@ -0,0 +1,511 @@ +// Code generated by mockery v2.40.1. DO NOT EDIT. + +package usertest + +import ( + context "context" + + user "github.com/grafana/grafana/pkg/services/user" + mock "github.com/stretchr/testify/mock" +) + +// MockService is an autogenerated mock type for the Service type +type MockService struct { + mock.Mock +} + +// BatchDisableUsers provides a mock function with given fields: _a0, _a1 +func (_m *MockService) BatchDisableUsers(_a0 context.Context, _a1 *user.BatchDisableUsersCommand) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for BatchDisableUsers") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *user.BatchDisableUsersCommand) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ChangePassword provides a mock function with given fields: _a0, _a1 +func (_m *MockService) ChangePassword(_a0 context.Context, _a1 *user.ChangeUserPasswordCommand) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for ChangePassword") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *user.ChangeUserPasswordCommand) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Create provides a mock function with given fields: _a0, _a1 +func (_m *MockService) Create(_a0 context.Context, _a1 *user.CreateUserCommand) (*user.User, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 *user.User + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *user.CreateUserCommand) (*user.User, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, *user.CreateUserCommand) *user.User); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*user.User) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *user.CreateUserCommand) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// CreateServiceAccount provides a mock function with given fields: _a0, _a1 +func (_m *MockService) CreateServiceAccount(_a0 context.Context, _a1 *user.CreateUserCommand) (*user.User, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for CreateServiceAccount") + } + + var r0 *user.User + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *user.CreateUserCommand) (*user.User, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, *user.CreateUserCommand) *user.User); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*user.User) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *user.CreateUserCommand) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: _a0, _a1 +func (_m *MockService) Delete(_a0 context.Context, _a1 *user.DeleteUserCommand) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *user.DeleteUserCommand) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Disable provides a mock function with given fields: _a0, _a1 +func (_m *MockService) Disable(_a0 context.Context, _a1 *user.DisableUserCommand) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for Disable") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *user.DisableUserCommand) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetByEmail provides a mock function with given fields: _a0, _a1 +func (_m *MockService) GetByEmail(_a0 context.Context, _a1 *user.GetUserByEmailQuery) (*user.User, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for GetByEmail") + } + + var r0 *user.User + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *user.GetUserByEmailQuery) (*user.User, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, *user.GetUserByEmailQuery) *user.User); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*user.User) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *user.GetUserByEmailQuery) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetByID provides a mock function with given fields: _a0, _a1 +func (_m *MockService) GetByID(_a0 context.Context, _a1 *user.GetUserByIDQuery) (*user.User, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for GetByID") + } + + var r0 *user.User + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *user.GetUserByIDQuery) (*user.User, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, *user.GetUserByIDQuery) *user.User); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*user.User) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *user.GetUserByIDQuery) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetByLogin provides a mock function with given fields: _a0, _a1 +func (_m *MockService) GetByLogin(_a0 context.Context, _a1 *user.GetUserByLoginQuery) (*user.User, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for GetByLogin") + } + + var r0 *user.User + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *user.GetUserByLoginQuery) (*user.User, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, *user.GetUserByLoginQuery) *user.User); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*user.User) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *user.GetUserByLoginQuery) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetProfile provides a mock function with given fields: _a0, _a1 +func (_m *MockService) GetProfile(_a0 context.Context, _a1 *user.GetUserProfileQuery) (*user.UserProfileDTO, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for GetProfile") + } + + var r0 *user.UserProfileDTO + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *user.GetUserProfileQuery) (*user.UserProfileDTO, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, *user.GetUserProfileQuery) *user.UserProfileDTO); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*user.UserProfileDTO) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *user.GetUserProfileQuery) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetSignedInUser provides a mock function with given fields: _a0, _a1 +func (_m *MockService) GetSignedInUser(_a0 context.Context, _a1 *user.GetSignedInUserQuery) (*user.SignedInUser, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for GetSignedInUser") + } + + var r0 *user.SignedInUser + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *user.GetSignedInUserQuery) (*user.SignedInUser, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, *user.GetSignedInUserQuery) *user.SignedInUser); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*user.SignedInUser) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *user.GetSignedInUserQuery) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetSignedInUserWithCacheCtx provides a mock function with given fields: _a0, _a1 +func (_m *MockService) GetSignedInUserWithCacheCtx(_a0 context.Context, _a1 *user.GetSignedInUserQuery) (*user.SignedInUser, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for GetSignedInUserWithCacheCtx") + } + + var r0 *user.SignedInUser + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *user.GetSignedInUserQuery) (*user.SignedInUser, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, *user.GetSignedInUserQuery) *user.SignedInUser); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*user.SignedInUser) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *user.GetSignedInUserQuery) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetUsageStats provides a mock function with given fields: ctx +func (_m *MockService) GetUsageStats(ctx context.Context) map[string]interface{} { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetUsageStats") + } + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(context.Context) map[string]interface{}); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + return r0 +} + +// NewAnonymousSignedInUser provides a mock function with given fields: _a0 +func (_m *MockService) NewAnonymousSignedInUser(_a0 context.Context) (*user.SignedInUser, error) { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for NewAnonymousSignedInUser") + } + + var r0 *user.SignedInUser + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (*user.SignedInUser, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(context.Context) *user.SignedInUser); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*user.SignedInUser) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Search provides a mock function with given fields: _a0, _a1 +func (_m *MockService) Search(_a0 context.Context, _a1 *user.SearchUsersQuery) (*user.SearchUserQueryResult, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for Search") + } + + var r0 *user.SearchUserQueryResult + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *user.SearchUsersQuery) (*user.SearchUserQueryResult, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, *user.SearchUsersQuery) *user.SearchUserQueryResult); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*user.SearchUserQueryResult) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *user.SearchUsersQuery) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SetUserHelpFlag provides a mock function with given fields: _a0, _a1 +func (_m *MockService) SetUserHelpFlag(_a0 context.Context, _a1 *user.SetUserHelpFlagCommand) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for SetUserHelpFlag") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *user.SetUserHelpFlagCommand) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SetUsingOrg provides a mock function with given fields: _a0, _a1 +func (_m *MockService) SetUsingOrg(_a0 context.Context, _a1 *user.SetUsingOrgCommand) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for SetUsingOrg") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *user.SetUsingOrgCommand) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Update provides a mock function with given fields: _a0, _a1 +func (_m *MockService) Update(_a0 context.Context, _a1 *user.UpdateUserCommand) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *user.UpdateUserCommand) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateLastSeenAt provides a mock function with given fields: _a0, _a1 +func (_m *MockService) UpdateLastSeenAt(_a0 context.Context, _a1 *user.UpdateUserLastSeenAtCommand) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for UpdateLastSeenAt") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *user.UpdateUserLastSeenAtCommand) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdatePermissions provides a mock function with given fields: _a0, _a1, _a2 +func (_m *MockService) UpdatePermissions(_a0 context.Context, _a1 int64, _a2 bool) error { + ret := _m.Called(_a0, _a1, _a2) + + if len(ret) == 0 { + panic("no return value specified for UpdatePermissions") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64, bool) error); ok { + r0 = rf(_a0, _a1, _a2) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockService(t interface { + mock.TestingT + Cleanup(func()) +}) *MockService { + mock := &MockService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go index c2d89e072e48a..a66bef430c255 100644 --- a/pkg/setting/provider.go +++ b/pkg/setting/provider.go @@ -144,12 +144,6 @@ func (o *OSSImpl) Section(section string) Section { func (*OSSImpl) RegisterReloadHandler(string, ReloadHandler) {} -// Deprecated: use feature toggles -func (o *OSSImpl) IsFeatureToggleEnabled(name string) bool { - // nolint:staticcheck - return o.Cfg.IsFeatureToggleEnabled(name) -} - type keyValImpl struct { key *ini.Key } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index ed27c66ebb5bf..cdde0c63cfcf8 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -32,6 +32,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/util/osutil" ) type Scheme string @@ -55,12 +56,12 @@ const ( const zoneInfo = "ZONEINFO" var ( + customInitPath = "conf/custom.ini" + // App settings. - Env = Dev - AppUrl string - AppSubUrl string - ServeFromSubPath bool - InstanceName string + Env = Dev + AppUrl string + AppSubUrl string // build BuildVersion string @@ -73,76 +74,9 @@ var ( // packaging Packaging = "unknown" - // Paths - HomePath string - CustomInitPath = "conf/custom.ini" - - // HTTP server options - StaticRootPath string - - // Security settings. - SecretKey string - DisableGravatar bool - DataProxyWhiteList map[string]bool CookieSecure bool CookieSameSiteDisabled bool CookieSameSiteMode http.SameSite - - // Dashboard history - DashboardVersionsToKeep int - MinRefreshInterval string - - // User settings - AllowUserSignUp bool - AllowUserOrgCreate bool - VerifyEmailEnabled bool - LoginHint string - PasswordHint string - DisableSignoutMenu bool - ExternalUserMngLinkUrl string - ExternalUserMngLinkName string - ExternalUserMngInfo string - - // HTTP auth - SigV4AuthEnabled bool - AzureAuthEnabled bool - - // Global setting objects. - Raw *ini.File - - // for logging purposes - configFiles []string - appliedCommandLineProperties []string - appliedEnvOverrides []string - - // Alerting - AlertingEnabled *bool - ExecuteAlerts bool - AlertingRenderLimit int - AlertingErrorOrTimeout string - AlertingNoDataOrNullValues string - - AlertingEvaluationTimeout time.Duration - AlertingNotificationTimeout time.Duration - AlertingMaxAttempts int - AlertingMinInterval int64 - - // Explore UI - ExploreEnabled bool - - // Help UI - HelpEnabled bool - - // Profile UI - ProfileEnabled bool - - // News Feed - NewsFeedEnabled bool - - // Grafana.NET URL - GrafanaComUrl string - - ImageUploadProvider string ) // TODO move all global vars to this struct @@ -151,13 +85,20 @@ type Cfg struct { Raw *ini.File Logger log.Logger + // for logging purposes + configFiles []string + appliedCommandLineProperties []string + appliedEnvOverrides []string + // HTTP Server Settings CertFile string KeyFile string HTTPAddr string HTTPPort string + Env string AppURL string AppSubURL string + InstanceName string ServeFromSubPath bool StaticRootPath string Protocol Scheme @@ -202,11 +143,15 @@ type Cfg struct { // Rendering ImagesDir string CSVsDir string + PDFsDir string RendererUrl string RendererCallbackUrl string RendererAuthToken string RendererConcurrentRequestLimit int RendererRenderKeyLifeTime time.Duration + RendererDefaultImageWidth int + RendererDefaultImageHeight int + RendererDefaultImageScale float64 // Security DisableInitAdminCreation bool @@ -231,6 +176,8 @@ type Cfg struct { CSPReportOnlyTemplate string AngularSupportEnabled bool DisableFrontendSandboxForPlugins []string + DisableGravatar bool + DataProxyWhiteList map[string]bool TempDataLifetime time.Duration @@ -269,79 +216,63 @@ type Cfg struct { MetricsGrafanaEnvironmentInfo map[string]string // Dashboards + DashboardVersionsToKeep int + MinRefreshInterval string DefaultHomeDashboardPath string // Auth - LoginCookieName string - LoginMaxInactiveLifetime time.Duration - LoginMaxLifetime time.Duration - TokenRotationIntervalMinutes int - SigV4AuthEnabled bool - SigV4VerboseLogging bool - AzureAuthEnabled bool - AzureSkipOrgRoleSync bool - BasicAuthEnabled bool - AdminUser string - AdminPassword string - DisableLogin bool - AdminEmail string - DisableLoginForm bool - SignoutRedirectUrl string - IDResponseHeaderEnabled bool - IDResponseHeaderPrefix string - IDResponseHeaderNamespaces map[string]struct{} + LoginCookieName string + LoginMaxInactiveLifetime time.Duration + LoginMaxLifetime time.Duration + TokenRotationIntervalMinutes int + SigV4AuthEnabled bool + SigV4VerboseLogging bool + AzureAuthEnabled bool + AzureSkipOrgRoleSync bool + BasicAuthEnabled bool + BasicAuthStrongPasswordPolicy bool + AdminUser string + AdminPassword string + DisableLogin bool + AdminEmail string + DisableLoginForm bool + SignoutRedirectUrl string + IDResponseHeaderEnabled bool + IDResponseHeaderPrefix string + IDResponseHeaderNamespaces map[string]struct{} // Not documented & not supported // stand in until a more complete solution is implemented AuthConfigUIAdminAccess bool // AWS Plugin Auth - AWSAllowedAuthProviders []string - AWSAssumeRoleEnabled bool - AWSListMetricsPageLimit int - AWSExternalId string + AWSAllowedAuthProviders []string + AWSAssumeRoleEnabled bool + AWSSessionDuration string + AWSExternalId string + AWSListMetricsPageLimit int + AWSForwardSettingsPlugins []string // Azure Cloud settings Azure *azsettings.AzureSettings // Auth proxy settings - AuthProxyEnabled bool - AuthProxyHeaderName string - AuthProxyHeaderProperty string - AuthProxyAutoSignUp bool - AuthProxyEnableLoginToken bool - AuthProxyWhitelist string - AuthProxyHeaders map[string]string - AuthProxyHeadersEncoded bool - AuthProxySyncTTL int + AuthProxy AuthProxySettings // OAuth OAuthAutoLogin bool OAuthCookieMaxAge int OAuthAllowInsecureEmailLookup bool - // JWT Auth - JWTAuthEnabled bool - JWTAuthHeaderName string - JWTAuthURLLogin bool - JWTAuthEmailClaim string - JWTAuthUsernameClaim string - JWTAuthExpectClaims string - JWTAuthJWKSetURL string - JWTAuthCacheTTL time.Duration - JWTAuthKeyFile string - JWTAuthKeyID string - JWTAuthJWKSetFile string - JWTAuthAutoSignUp bool - JWTAuthRoleAttributePath string - JWTAuthRoleAttributeStrict bool - JWTAuthAllowAssignGrafanaAdmin bool - JWTAuthSkipOrgRoleSync bool - + JWTAuth AuthJWTSettings // Extended JWT Auth ExtendedJWTAuthEnabled bool ExtendedJWTExpectIssuer string ExtendedJWTExpectAudience string + // SSO Settings Auth + SSOSettingsReloadInterval time.Duration + SSOSettingsConfigurableProviders map[string]bool + // Dataproxy SendUserHeader bool DataProxyLogging bool @@ -378,9 +309,10 @@ type Cfg struct { DateFormats DateFormats // User - UserInviteMaxLifetime time.Duration - HiddenUsers map[string]struct{} - CaseInsensitiveLogin bool // Login and Email will be considered case insensitive + UserInviteMaxLifetime time.Duration + HiddenUsers map[string]struct{} + CaseInsensitiveLogin bool // Login and Email will be considered case insensitive + VerificationEmailMaxLifetime time.Duration // Service Accounts SATokenExpirationDayLimit int @@ -397,6 +329,13 @@ type Cfg struct { // Data sources DataSourceLimit int + // Number of queries to be executed concurrently. Only for the datasource supports concurrency. + ConcurrentQueryCount int + + // IP range access control + IPRangeACEnabled bool + IPRangeACAllowedURLs []*url.URL + IPRangeACSecretKey string // SQL Data sources SqlDatasourceMaxOpenConnsDefault int @@ -404,23 +343,22 @@ type Cfg struct { SqlDatasourceMaxConnLifetimeDefault int // Snapshots - SnapshotEnabled bool - ExternalSnapshotUrl string - ExternalSnapshotName string - ExternalEnabled bool + SnapshotEnabled bool + ExternalSnapshotUrl string + ExternalSnapshotName string + ExternalEnabled bool + // Deprecated: setting this to false adds deprecation warnings at runtime SnapShotRemoveExpired bool + // Only used in https://snapshots.raintank.io/ SnapshotPublicMode bool ErrTemplateName string - Env string - StackID string Slug string - // Deprecated - ForceMigration bool + LocalFileSystemAvailable bool // Analytics CheckForGrafanaUpdates bool @@ -457,9 +395,20 @@ type Cfg struct { Quota QuotaSettings + // User settings + AllowUserSignUp bool + AllowUserOrgCreate bool + VerifyEmailEnabled bool + LoginHint string + PasswordHint string + DisableSignoutMenu bool + ExternalUserMngLinkUrl string + ExternalUserMngLinkName string + ExternalUserMngInfo string AutoAssignOrg bool AutoAssignOrgId int AutoAssignOrgRole string + LoginDefaultOrgId int64 OAuthSkipOrgRoleUpdateSync bool // ExpressionsEnabled specifies whether expressions are enabled. @@ -544,14 +493,39 @@ type Cfg struct { // Public dashboards PublicDashboardsEnabled bool + // Cloud Migration + CloudMigrationIsTarget bool + // Feature Management Settings FeatureManagement FeatureMgmtSettings + + // Alerting + AlertingEvaluationTimeout time.Duration + AlertingNotificationTimeout time.Duration + AlertingMaxAttempts int + AlertingMinInterval int64 + + // Explore UI + ExploreEnabled bool + + // Help UI + HelpEnabled bool + + // Profile UI + ProfileEnabled bool + + // News Feed + NewsFeedEnabled bool + + // Experimental scope settings + ScopesListScopesURL string + ScopesListDashboardsURL string } // AddChangePasswordLink returns if login form is disabled or not since // the same intention can be used to hide both features. func (cfg *Cfg) AddChangePasswordLink() bool { - return !cfg.DisableLoginForm + return !(cfg.DisableLoginForm || cfg.DisableLogin) } type CommandLineArgs struct { @@ -599,6 +573,8 @@ func RedactedValue(key, value string) string { "ACCOUNT_KEY", "ENCRYPTION_KEY", "VAULT_TOKEN", + "CLIENT_SECRET", + "ENTERPRISE_LICENSE", } { if match, err := regexp.MatchString(pattern, uppercased); match && err == nil { return RedactedPassword @@ -655,8 +631,8 @@ func RedactedURL(value string) (string, error) { return strings.Join(chunks, " "), nil } -func applyEnvVariableOverrides(file *ini.File) error { - appliedEnvOverrides = make([]string, 0) +func (cfg *Cfg) applyEnvVariableOverrides(file *ini.File) error { + cfg.appliedEnvOverrides = make([]string, 0) for _, section := range file.Sections() { for _, key := range section.Keys() { envKey := EnvKey(section.Name(), key.Name()) @@ -664,7 +640,7 @@ func applyEnvVariableOverrides(file *ini.File) error { if len(envValue) > 0 { key.SetValue(envValue) - appliedEnvOverrides = append(appliedEnvOverrides, fmt.Sprintf("%s=%s", envKey, RedactedValue(envKey, envValue))) + cfg.appliedEnvOverrides = append(cfg.appliedEnvOverrides, fmt.Sprintf("%s=%s", envKey, RedactedValue(envKey, envValue))) } } } @@ -719,7 +695,6 @@ func (cfg *Cfg) readAnnotationSettings() error { dashboardAnnotation := cfg.Raw.Section("annotations.dashboard") apiIAnnotation := cfg.Raw.Section("annotations.api") - alertingSection := cfg.Raw.Section("alerting") var newAnnotationCleanupSettings = func(section *ini.Section, maxAgeField string) AnnotationCleanupSettings { maxAge, err := gtime.ParseDuration(section.Key(maxAgeField).MustString("")) @@ -733,7 +708,20 @@ func (cfg *Cfg) readAnnotationSettings() error { } } - cfg.AlertingAnnotationCleanupSetting = newAnnotationCleanupSettings(alertingSection, "max_annotation_age") + alertingAnnotations := cfg.Raw.Section("unified_alerting.state_history.annotations") + if alertingAnnotations.Key("max_age").Value() == "" && section.Key("max_annotations_to_keep").Value() == "" { + // Although this section is not documented anymore, we decided to keep it to avoid potential data-loss when user upgrades Grafana and does not change the setting. + // TODO delete some time after Grafana 11. + alertingSection := cfg.Raw.Section("alerting") + cleanup := newAnnotationCleanupSettings(alertingSection, "max_annotation_age") + if cleanup.MaxCount > 0 || cleanup.MaxAge > 0 { + cfg.Logger.Warn("settings 'max_annotations_to_keep' and 'max_annotation_age' in section [alerting] are deprecated. Please use settings 'max_annotations_to_keep' and 'max_age' in section [unified_alerting.state_history.annotations]") + } + cfg.AlertingAnnotationCleanupSetting = cleanup + } else { + cfg.AlertingAnnotationCleanupSetting = newAnnotationCleanupSettings(alertingAnnotations, "max_age") + } + cfg.DashboardAnnotationCleanupSettings = newAnnotationCleanupSettings(dashboardAnnotation, "max_age") cfg.APIAnnotationCleanupSettings = newAnnotationCleanupSettings(apiIAnnotation, "max_age") @@ -758,22 +746,22 @@ func EnvKey(sectionName string, keyName string) string { return envKey } -func applyCommandLineDefaultProperties(props map[string]string, file *ini.File) { - appliedCommandLineProperties = make([]string, 0) +func (cfg *Cfg) applyCommandLineDefaultProperties(props map[string]string, file *ini.File) { + cfg.appliedCommandLineProperties = make([]string, 0) for _, section := range file.Sections() { for _, key := range section.Keys() { keyString := fmt.Sprintf("default.%s.%s", section.Name(), key.Name()) value, exists := props[keyString] if exists { key.SetValue(value) - appliedCommandLineProperties = append(appliedCommandLineProperties, + cfg.appliedCommandLineProperties = append(cfg.appliedCommandLineProperties, fmt.Sprintf("%s=%s", keyString, RedactedValue(keyString, value))) } } } } -func applyCommandLineProperties(props map[string]string, file *ini.File) { +func (cfg *Cfg) applyCommandLineProperties(props map[string]string, file *ini.File) { for _, section := range file.Sections() { sectionName := section.Name() + "." if section.Name() == ini.DefaultSection { @@ -783,7 +771,7 @@ func applyCommandLineProperties(props map[string]string, file *ini.File) { keyString := sectionName + key.Name() value, exists := props[keyString] if exists { - appliedCommandLineProperties = append(appliedCommandLineProperties, fmt.Sprintf("%s=%s", keyString, value)) + cfg.appliedCommandLineProperties = append(cfg.appliedCommandLineProperties, fmt.Sprintf("%s=%s", keyString, value)) key.SetValue(value) } } @@ -819,7 +807,7 @@ func makeAbsolute(path string, root string) string { func (cfg *Cfg) loadSpecifiedConfigFile(configFile string, masterFile *ini.File) error { if configFile == "" { - configFile = filepath.Join(cfg.HomePath, CustomInitPath) + configFile = filepath.Join(cfg.HomePath, customInitPath) // return without error if custom file does not exist if !pathExists(configFile) { return nil @@ -831,6 +819,9 @@ func (cfg *Cfg) loadSpecifiedConfigFile(configFile string, masterFile *ini.File) return fmt.Errorf("failed to parse %q: %w", configFile, err) } + // micro-optimization since we don't need to share this ini file. In + // general, prefer to leave this flag as true as it is by default to prevent + // data races userConfig.BlockMode = false for _, section := range userConfig.Sections() { @@ -851,14 +842,14 @@ func (cfg *Cfg) loadSpecifiedConfigFile(configFile string, masterFile *ini.File) } } - configFiles = append(configFiles, configFile) + cfg.configFiles = append(cfg.configFiles, configFile) return nil } func (cfg *Cfg) loadConfiguration(args CommandLineArgs) (*ini.File, error) { // load config defaults - defaultConfigFile := path.Join(HomePath, "conf/defaults.ini") - configFiles = append(configFiles, defaultConfigFile) + defaultConfigFile := path.Join(cfg.HomePath, "conf/defaults.ini") + cfg.configFiles = append(cfg.configFiles, defaultConfigFile) // check if config file exists if _, err := os.Stat(defaultConfigFile); os.IsNotExist(err) { @@ -874,12 +865,10 @@ func (cfg *Cfg) loadConfiguration(args CommandLineArgs) (*ini.File, error) { return nil, err } - parsedFile.BlockMode = false - // command line props commandLineProps := cfg.getCommandLineProperties(args.Args) // load default overrides - applyCommandLineDefaultProperties(commandLineProps, parsedFile) + cfg.applyCommandLineDefaultProperties(commandLineProps, parsedFile) // load specified config file err = cfg.loadSpecifiedConfigFile(args.Config, parsedFile) @@ -893,13 +882,13 @@ func (cfg *Cfg) loadConfiguration(args CommandLineArgs) (*ini.File, error) { } // apply environment overrides - err = applyEnvVariableOverrides(parsedFile) + err = cfg.applyEnvVariableOverrides(parsedFile) if err != nil { return nil, err } // apply command line overrides - applyCommandLineProperties(commandLineProps, parsedFile) + cfg.applyCommandLineProperties(commandLineProps, parsedFile) // evaluate config values containing environment variables err = expandConfig(parsedFile) @@ -910,7 +899,7 @@ func (cfg *Cfg) loadConfiguration(args CommandLineArgs) (*ini.File, error) { // update data path and logging config dataPath := valueAsString(parsedFile.Section("paths"), "data", "") - cfg.DataPath = makeAbsolute(dataPath, HomePath) + cfg.DataPath = makeAbsolute(dataPath, cfg.HomePath) err = cfg.initLogging(parsedFile) if err != nil { return nil, err @@ -935,7 +924,6 @@ func pathExists(path string) bool { func (cfg *Cfg) setHomePath(args CommandLineArgs) { if args.HomePath != "" { cfg.HomePath = args.HomePath - HomePath = cfg.HomePath return } @@ -945,7 +933,6 @@ func (cfg *Cfg) setHomePath(args CommandLineArgs) { panic(err) } - HomePath = cfg.HomePath // check if homepath is correct if pathExists(filepath.Join(cfg.HomePath, "conf/defaults.ini")) { return @@ -954,7 +941,6 @@ func (cfg *Cfg) setHomePath(args CommandLineArgs) { // try down one path if pathExists(filepath.Join(cfg.HomePath, "../conf/defaults.ini")) { cfg.HomePath = filepath.Join(cfg.HomePath, "../") - HomePath = cfg.HomePath } } @@ -962,6 +948,7 @@ var skipStaticRootValidation = false func NewCfg() *Cfg { return &Cfg{ + Env: Dev, Target: []string{"all"}, Logger: log.New("settings"), Raw: ini.Empty(), @@ -991,26 +978,46 @@ func NewCfgFromArgs(args CommandLineArgs) (*Cfg, error) { return cfg, nil } +// NewCfgFromBytes specialized function to create a new Cfg from bytes (INI file). +func NewCfgFromBytes(bytes []byte) (*Cfg, error) { + parsedFile, err := ini.Load(bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse bytes as INI file: %w", err) + } + + return NewCfgFromINIFile(parsedFile) +} + +// NewCfgFromINIFile specialized function to create a new Cfg from an ini.File. +func NewCfgFromINIFile(iniFile *ini.File) (*Cfg, error) { + cfg := NewCfg() + + if err := cfg.parseINIFile(iniFile); err != nil { + return nil, fmt.Errorf("failed to parse setting from INI file: %w", err) + } + + return cfg, nil +} + func (cfg *Cfg) validateStaticRootPath() error { if skipStaticRootValidation { return nil } - if _, err := os.Stat(path.Join(StaticRootPath, "build")); err != nil { + if _, err := os.Stat(path.Join(cfg.StaticRootPath, "build")); err != nil { cfg.Logger.Error("Failed to detect generated javascript files in public/build") } return nil } -// nolint:gocyclo func (cfg *Cfg) Load(args CommandLineArgs) error { cfg.setHomePath(args) // Fix for missing IANA db on Windows _, zoneInfoSet := os.LookupEnv(zoneInfo) if runtime.GOOS == "windows" && !zoneInfoSet { - if err := os.Setenv(zoneInfo, filepath.Join(HomePath, "tools", "zoneinfo.zip")); err != nil { + if err := os.Setenv(zoneInfo, filepath.Join(cfg.HomePath, "tools", "zoneinfo.zip")); err != nil { cfg.Logger.Error("Can't set ZONEINFO environment variable", "err", err) } } @@ -1020,10 +1027,19 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { return err } - cfg.Raw = iniFile + err = cfg.parseINIFile(iniFile) + if err != nil { + return err + } - // Temporarily keep global, to make refactor in steps - Raw = cfg.Raw + cfg.LogConfigSources() + + return nil +} + +// nolint:gocyclo +func (cfg *Cfg) parseINIFile(iniFile *ini.File) error { + cfg.Raw = iniFile cfg.BuildVersion = BuildVersion cfg.BuildCommit = BuildCommit @@ -1039,18 +1055,16 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { if Target != "" { cfg.Target = util.SplitString(Target) } - Env = valueAsString(iniFile.Section(""), "app_mode", "development") - cfg.Env = Env + cfg.Env = valueAsString(iniFile.Section(""), "app_mode", "development") cfg.StackID = valueAsString(iniFile.Section("environment"), "stack_id", "") cfg.Slug = valueAsString(iniFile.Section("environment"), "stack_slug", "") - //nolint:staticcheck - cfg.ForceMigration = iniFile.Section("").Key("force_migration").MustBool(false) - InstanceName = valueAsString(iniFile.Section(""), "instance_name", "unknown_instance_name") + cfg.LocalFileSystemAvailable = iniFile.Section("environment").Key("local_file_system_available").MustBool(true) + cfg.InstanceName = valueAsString(iniFile.Section(""), "instance_name", "unknown_instance_name") plugins := valueAsString(iniFile.Section("paths"), "plugins", "") - cfg.PluginsPath = makeAbsolute(plugins, HomePath) - cfg.BundledPluginsPath = makeAbsolute("plugins-bundled", HomePath) + cfg.PluginsPath = makeAbsolute(plugins, cfg.HomePath) + cfg.BundledPluginsPath = makeAbsolute("plugins-bundled", cfg.HomePath) provisioning := valueAsString(iniFile.Section("paths"), "provisioning", "") - cfg.ProvisioningPath = makeAbsolute(provisioning, HomePath) + cfg.ProvisioningPath = makeAbsolute(provisioning, cfg.HomePath) if err := cfg.readServerSettings(iniFile); err != nil { return err @@ -1074,9 +1088,8 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { // read dashboard settings dashboards := iniFile.Section("dashboards") - DashboardVersionsToKeep = dashboards.Key("versions_to_keep").MustInt(20) - MinRefreshInterval = valueAsString(dashboards, "min_refresh_interval", "5s") - + cfg.DashboardVersionsToKeep = dashboards.Key("versions_to_keep").MustInt(20) + cfg.MinRefreshInterval = valueAsString(dashboards, "min_refresh_interval", "5s") cfg.DefaultHomeDashboardPath = dashboards.Key("default_home_dashboard_path").MustString("") if err := readUserSettings(iniFile, cfg); err != nil { @@ -1130,21 +1143,21 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { cfg.ApplicationInsightsEndpointUrl = analytics.Key("application_insights_endpoint_url").String() cfg.FeedbackLinksEnabled = analytics.Key("feedback_links_enabled").MustBool(true) - if err := readAlertingSettings(iniFile); err != nil { + if err := cfg.readAlertingSettings(iniFile); err != nil { return err } explore := iniFile.Section("explore") - ExploreEnabled = explore.Key("enabled").MustBool(true) + cfg.ExploreEnabled = explore.Key("enabled").MustBool(true) help := iniFile.Section("help") - HelpEnabled = help.Key("enabled").MustBool(true) + cfg.HelpEnabled = help.Key("enabled").MustBool(true) profile := iniFile.Section("profile") - ProfileEnabled = profile.Key("enabled").MustBool(true) + cfg.ProfileEnabled = profile.Key("enabled").MustBool(true) news := iniFile.Section("news") - NewsFeedEnabled = news.Key("news_feed_enabled").MustBool(true) + cfg.NewsFeedEnabled = news.Key("news_feed_enabled").MustBool(true) queryHistory := iniFile.Section("query_history") cfg.QueryHistoryEnabled = queryHistory.Key("enabled").MustBool(true) @@ -1174,6 +1187,8 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { cfg.readLDAPConfig() cfg.handleAWSConfig() cfg.readAzureSettings() + cfg.readAuthJWTSettings() + cfg.readAuthProxySettings() cfg.readSessionConfig() if err := cfg.readSmtpSettings(); err != nil { return err @@ -1190,11 +1205,13 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { } cfg.readDataSourcesSettings() + cfg.readDataSourceSecuritySettings() cfg.readSqlDataSourceSettings() cfg.Storage = readStorageSettings(iniFile) cfg.Search = readSearchSettings(iniFile) + var err error cfg.SecureSocksDSProxy, err = readSecureSocksDSProxySettings(iniFile) if err != nil { // if the proxy is misconfigured, disable it rather than crashing @@ -1202,22 +1219,21 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { cfg.Logger.Error("secure_socks_datasource_proxy unable to start up", "err", err.Error()) } - if VerifyEmailEnabled && !cfg.Smtp.Enabled { + if cfg.VerifyEmailEnabled && !cfg.Smtp.Enabled { cfg.Logger.Warn("require_email_validation is enabled but smtp is disabled") } // check old key name - GrafanaComUrl = valueAsString(iniFile.Section("grafana_net"), "url", "") - if GrafanaComUrl == "" { - GrafanaComUrl = valueAsString(iniFile.Section("grafana_com"), "url", "https://grafana.com") + grafanaComUrl := valueAsString(iniFile.Section("grafana_net"), "url", "") + if grafanaComUrl == "" { + grafanaComUrl = valueAsString(iniFile.Section("grafana_com"), "url", "https://grafana.com") } - cfg.GrafanaComURL = GrafanaComUrl + cfg.GrafanaComURL = grafanaComUrl - cfg.GrafanaComAPIURL = valueAsString(iniFile.Section("grafana_com"), "api_url", GrafanaComUrl+"/api") + cfg.GrafanaComAPIURL = valueAsString(iniFile.Section("grafana_com"), "api_url", grafanaComUrl+"/api") imageUploadingSection := iniFile.Section("external_image_storage") cfg.ImageUploadProvider = valueAsString(imageUploadingSection, "provider", "") - ImageUploadProvider = cfg.ImageUploadProvider enterprise := iniFile.Section("enterprise") cfg.EnterpriseLicensePath = valueAsString(enterprise, "license_path", filepath.Join(cfg.DataPath, "license.jwt")) @@ -1239,7 +1255,7 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { basemapJSON := valueAsString(geomapSection, "default_baselayer_config", "") if basemapJSON != "" { layer := make(map[string]any) - err = json.Unmarshal([]byte(basemapJSON), &layer) + err := json.Unmarshal([]byte(basemapJSON), &layer) if err != nil { cfg.Logger.Error("Error reading json from default_baselayer_config", "error", err) } else { @@ -1255,8 +1271,6 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { return err } - cfg.LogConfigSources() - databaseSection := iniFile.Section("database") cfg.DatabaseInstrumentQueries = databaseSection.Key("instrument_queries").MustBool(false) @@ -1265,6 +1279,12 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { cfg.readFeatureManagementConfig() cfg.readPublicDashboardsSettings() + cfg.readCloudMigrationSettings() + + // read experimental scopes settings. + scopesSection := iniFile.Section("scopes") + cfg.ScopesListScopesURL = scopesSection.Key("list_scopes_endpoint").MustString("") + cfg.ScopesListDashboardsURL = scopesSection.Key("list_dashboards_endpoint").MustString("") return nil } @@ -1308,6 +1328,10 @@ func (cfg *Cfg) handleAWSConfig() { } } cfg.AWSListMetricsPageLimit = awsPluginSec.Key("list_metrics_page_limit").MustInt(500) + cfg.AWSExternalId = awsPluginSec.Key("external_id").Value() + cfg.AWSSessionDuration = awsPluginSec.Key("session_duration").Value() + cfg.AWSForwardSettingsPlugins = util.SplitString(awsPluginSec.Key("forward_settings_to_plugins").String()) + // Also set environment variables that can be used by core plugins err := os.Setenv(awsds.AssumeRoleEnabledEnvVarKeyName, strconv.FormatBool(cfg.AWSAssumeRoleEnabled)) if err != nil { @@ -1319,11 +1343,20 @@ func (cfg *Cfg) handleAWSConfig() { cfg.Logger.Error(fmt.Sprintf("could not set environment variable '%s'", awsds.AllowedAuthProvidersEnvVarKeyName), err) } - cfg.AWSExternalId = awsPluginSec.Key("external_id").Value() + err = os.Setenv(awsds.ListMetricsPageLimitKeyName, strconv.Itoa(cfg.AWSListMetricsPageLimit)) + if err != nil { + cfg.Logger.Error(fmt.Sprintf("could not set environment variable '%s'", awsds.ListMetricsPageLimitKeyName), err) + } + err = os.Setenv(awsds.GrafanaAssumeRoleExternalIdKeyName, cfg.AWSExternalId) if err != nil { cfg.Logger.Error(fmt.Sprintf("could not set environment variable '%s'", awsds.GrafanaAssumeRoleExternalIdKeyName), err) } + + err = os.Setenv(awsds.SessionDurationEnvVarKeyName, cfg.AWSSessionDuration) + if err != nil { + cfg.Logger.Error(fmt.Sprintf("could not set environment variable '%s'", awsds.SessionDurationEnvVarKeyName), err) + } } func (cfg *Cfg) readSessionConfig() { @@ -1345,32 +1378,32 @@ func (cfg *Cfg) initLogging(file *ini.File) error { logModes = strings.Split(logModeStr, " ") } logsPath := valueAsString(file.Section("paths"), "logs", "") - cfg.LogsPath = makeAbsolute(logsPath, HomePath) + cfg.LogsPath = makeAbsolute(logsPath, cfg.HomePath) return log.ReadLoggingConfig(logModes, cfg.LogsPath, file) } func (cfg *Cfg) LogConfigSources() { var text bytes.Buffer - for _, file := range configFiles { + for _, file := range cfg.configFiles { cfg.Logger.Info("Config loaded from", "file", file) } - if len(appliedCommandLineProperties) > 0 { - for _, prop := range appliedCommandLineProperties { + if len(cfg.appliedCommandLineProperties) > 0 { + for _, prop := range cfg.appliedCommandLineProperties { cfg.Logger.Info("Config overridden from command line", "arg", prop) } } - if len(appliedEnvOverrides) > 0 { + if len(cfg.appliedEnvOverrides) > 0 { text.WriteString("\tEnvironment variables used:\n") - for _, prop := range appliedEnvOverrides { + for _, prop := range cfg.appliedEnvOverrides { cfg.Logger.Info("Config overridden from Environment variable", "var", prop) } } cfg.Logger.Info("Target", "target", cfg.Target) - cfg.Logger.Info("Path Home", "path", HomePath) + cfg.Logger.Info("Path Home", "path", cfg.HomePath) cfg.Logger.Info("Path Data", "path", cfg.DataPath) cfg.Logger.Info("Path Logs", "path", cfg.LogsPath) cfg.Logger.Info("Path Plugins", "path", cfg.PluginsPath) @@ -1381,13 +1414,14 @@ func (cfg *Cfg) LogConfigSources() { type DynamicSection struct { section *ini.Section Logger log.Logger + env osutil.Env } // Key dynamically overrides keys with environment variables. // As a side effect, the value of the setting key will be updated if an environment variable is present. func (s *DynamicSection) Key(k string) *ini.Key { envKey := EnvKey(s.section.Name(), k) - envValue := os.Getenv(envKey) + envValue := s.env.Getenv(envKey) key := s.section.Key(k) if len(envValue) == 0 { @@ -1404,7 +1438,7 @@ func (s *DynamicSection) KeysHash() map[string]string { hash := s.section.KeysHash() for k := range hash { envKey := EnvKey(s.section.Name(), k) - envValue := os.Getenv(envKey) + envValue := s.env.Getenv(envKey) if len(envValue) > 0 { hash[k] = envValue } @@ -1415,14 +1449,17 @@ func (s *DynamicSection) KeysHash() map[string]string { // SectionWithEnvOverrides dynamically overrides keys with environment variables. // As a side effect, the value of the setting key will be updated if an environment variable is present. func (cfg *Cfg) SectionWithEnvOverrides(s string) *DynamicSection { - return &DynamicSection{cfg.Raw.Section(s), cfg.Logger} + return &DynamicSection{ + section: cfg.Raw.Section(s), + Logger: cfg.Logger, + env: osutil.RealEnv{}, + } } func readSecuritySettings(iniFile *ini.File, cfg *Cfg) error { security := iniFile.Section("security") - SecretKey = valueAsString(security, "secret_key", "") - cfg.SecretKey = SecretKey - DisableGravatar = security.Key("disable_gravatar").MustBool(true) + cfg.SecretKey = valueAsString(security, "secret_key", "") + cfg.DisableGravatar = security.Key("disable_gravatar").MustBool(true) cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false) CookieSecure = security.Key("cookie_secure").MustBool(false) @@ -1477,11 +1514,11 @@ func readSecuritySettings(iniFile *ini.File, cfg *Cfg) error { } // read data source proxy whitelist - DataProxyWhiteList = make(map[string]bool) + cfg.DataProxyWhiteList = make(map[string]bool) securityStr := valueAsString(security, "data_source_proxy_whitelist", "") for _, hostAndIP := range util.SplitString(securityStr) { - DataProxyWhiteList[hostAndIP] = true + cfg.DataProxyWhiteList[hostAndIP] = true } // admin @@ -1524,7 +1561,7 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) { cfg.AuthConfigUIAdminAccess = auth.Key("config_ui_admin_access").MustBool(false) cfg.DisableLoginForm = auth.Key("disable_login_form").MustBool(false) - DisableSignoutMenu = auth.Key("disable_signout_menu").MustBool(false) + cfg.DisableSignoutMenu = auth.Key("disable_signout_menu").MustBool(false) // Deprecated cfg.OAuthAutoLogin = auth.Key("oauth_auto_login").MustBool(false) @@ -1543,13 +1580,11 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) { cfg.DisableLogin = auth.Key("disable_login").MustBool(false) // SigV4 - SigV4AuthEnabled = auth.Key("sigv4_auth_enabled").MustBool(false) - cfg.SigV4AuthEnabled = SigV4AuthEnabled + cfg.SigV4AuthEnabled = auth.Key("sigv4_auth_enabled").MustBool(false) cfg.SigV4VerboseLogging = auth.Key("sigv4_verbose_logging").MustBool(false) // Azure Auth - AzureAuthEnabled = auth.Key("azure_auth_enabled").MustBool(false) - cfg.AzureAuthEnabled = AzureAuthEnabled + cfg.AzureAuthEnabled = auth.Key("azure_auth_enabled").MustBool(false) // ID response header cfg.IDResponseHeaderEnabled = auth.Key("id_response_header_enabled").MustBool(false) @@ -1572,25 +1607,7 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) { // basic auth authBasic := iniFile.Section("auth.basic") cfg.BasicAuthEnabled = authBasic.Key("enabled").MustBool(true) - - // JWT auth - authJWT := iniFile.Section("auth.jwt") - cfg.JWTAuthEnabled = authJWT.Key("enabled").MustBool(false) - cfg.JWTAuthHeaderName = valueAsString(authJWT, "header_name", "") - cfg.JWTAuthURLLogin = authJWT.Key("url_login").MustBool(false) - cfg.JWTAuthEmailClaim = valueAsString(authJWT, "email_claim", "") - cfg.JWTAuthUsernameClaim = valueAsString(authJWT, "username_claim", "") - cfg.JWTAuthExpectClaims = valueAsString(authJWT, "expect_claims", "{}") - cfg.JWTAuthJWKSetURL = valueAsString(authJWT, "jwk_set_url", "") - cfg.JWTAuthCacheTTL = authJWT.Key("cache_ttl").MustDuration(time.Minute * 60) - cfg.JWTAuthKeyFile = valueAsString(authJWT, "key_file", "") - cfg.JWTAuthKeyID = authJWT.Key("key_id").MustString("") - cfg.JWTAuthJWKSetFile = valueAsString(authJWT, "jwk_set_file", "") - cfg.JWTAuthAutoSignUp = authJWT.Key("auto_sign_up").MustBool(false) - cfg.JWTAuthRoleAttributePath = valueAsString(authJWT, "role_attribute_path", "") - cfg.JWTAuthRoleAttributeStrict = authJWT.Key("role_attribute_strict").MustBool(false) - cfg.JWTAuthAllowAssignGrafanaAdmin = authJWT.Key("allow_assign_grafana_admin").MustBool(false) - cfg.JWTAuthSkipOrgRoleSync = authJWT.Key("skip_org_role_sync").MustBool(false) + cfg.BasicAuthStrongPasswordPolicy = authBasic.Key("password_policy").MustBool(false) // Extended JWT auth authExtendedJWT := cfg.SectionWithEnvOverrides("auth.extended_jwt") @@ -1598,31 +1615,15 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) { cfg.ExtendedJWTExpectAudience = authExtendedJWT.Key("expect_audience").MustString("") cfg.ExtendedJWTExpectIssuer = authExtendedJWT.Key("expect_issuer").MustString("") - // Auth Proxy - authProxy := iniFile.Section("auth.proxy") - cfg.AuthProxyEnabled = authProxy.Key("enabled").MustBool(false) - - cfg.AuthProxyHeaderName = valueAsString(authProxy, "header_name", "") - cfg.AuthProxyHeaderProperty = valueAsString(authProxy, "header_property", "") - cfg.AuthProxyAutoSignUp = authProxy.Key("auto_sign_up").MustBool(true) - cfg.AuthProxyEnableLoginToken = authProxy.Key("enable_login_token").MustBool(false) + // SSO Settings + ssoSettings := iniFile.Section("sso_settings") + cfg.SSOSettingsReloadInterval = ssoSettings.Key("reload_interval").MustDuration(1 * time.Minute) + providers := ssoSettings.Key("configurable_providers").String() - cfg.AuthProxySyncTTL = authProxy.Key("sync_ttl").MustInt() - - cfg.AuthProxyWhitelist = valueAsString(authProxy, "whitelist", "") - - cfg.AuthProxyHeaders = make(map[string]string) - headers := valueAsString(authProxy, "headers", "") - - for _, propertyAndHeader := range util.SplitString(headers) { - split := strings.SplitN(propertyAndHeader, ":", 2) - if len(split) == 2 { - cfg.AuthProxyHeaders[split[0]] = split[1] - } + cfg.SSOSettingsConfigurableProviders = make(map[string]bool) + for _, provider := range util.SplitString(providers) { + cfg.SSOSettingsConfigurableProviders[provider] = true } - - cfg.AuthProxyHeadersEncoded = authProxy.Key("headers_encoded").MustBool(false) - return nil } @@ -1643,28 +1644,29 @@ func readOAuth2ServerSettings(cfg *Cfg) { func readUserSettings(iniFile *ini.File, cfg *Cfg) error { users := iniFile.Section("users") - AllowUserSignUp = users.Key("allow_sign_up").MustBool(true) - AllowUserOrgCreate = users.Key("allow_org_create").MustBool(true) + cfg.AllowUserSignUp = users.Key("allow_sign_up").MustBool(true) + cfg.AllowUserOrgCreate = users.Key("allow_org_create").MustBool(true) cfg.AutoAssignOrg = users.Key("auto_assign_org").MustBool(true) cfg.AutoAssignOrgId = users.Key("auto_assign_org_id").MustInt(1) + cfg.LoginDefaultOrgId = users.Key("login_default_org_id").MustInt64(-1) cfg.AutoAssignOrgRole = users.Key("auto_assign_org_role").In( string(roletype.RoleViewer), []string{ string(roletype.RoleNone), string(roletype.RoleViewer), string(roletype.RoleEditor), string(roletype.RoleAdmin)}) - VerifyEmailEnabled = users.Key("verify_email_enabled").MustBool(false) + cfg.VerifyEmailEnabled = users.Key("verify_email_enabled").MustBool(false) cfg.CaseInsensitiveLogin = users.Key("case_insensitive_login").MustBool(true) - LoginHint = valueAsString(users, "login_hint", "") - PasswordHint = valueAsString(users, "password_hint", "") + cfg.LoginHint = valueAsString(users, "login_hint", "") + cfg.PasswordHint = valueAsString(users, "password_hint", "") cfg.DefaultTheme = valueAsString(users, "default_theme", "") cfg.DefaultLanguage = valueAsString(users, "default_language", "") cfg.HomePage = valueAsString(users, "home_page", "") - ExternalUserMngLinkUrl = valueAsString(users, "external_manage_link_url", "") - ExternalUserMngLinkName = valueAsString(users, "external_manage_link_name", "") - ExternalUserMngInfo = valueAsString(users, "external_manage_info", "") + cfg.ExternalUserMngLinkUrl = valueAsString(users, "external_manage_link_url", "") + cfg.ExternalUserMngLinkName = valueAsString(users, "external_manage_link_name", "") + cfg.ExternalUserMngInfo = valueAsString(users, "external_manage_info", "") cfg.ViewersCanEdit = users.Key("viewers_can_edit").MustBool(false) cfg.EditorsCanAdmin = users.Key("editors_can_admin").MustBool(false) @@ -1689,6 +1691,13 @@ func readUserSettings(iniFile *ini.File, cfg *Cfg) error { } } + verificationEmailMaxLifetimeVal := valueAsString(users, "verification_email_max_lifetime_duration", "1h") + verificationEmailMaxLifetimeDuration, err := gtime.ParseDuration(verificationEmailMaxLifetimeVal) + if err != nil { + return err + } + cfg.VerificationEmailMaxLifetime = verificationEmailMaxLifetimeDuration + return nil } @@ -1720,32 +1729,24 @@ func (cfg *Cfg) readRenderingSettings(iniFile *ini.File) error { cfg.RendererConcurrentRequestLimit = renderSec.Key("concurrent_render_request_limit").MustInt(30) cfg.RendererRenderKeyLifeTime = renderSec.Key("render_key_lifetime").MustDuration(5 * time.Minute) + cfg.RendererDefaultImageWidth = renderSec.Key("default_image_width").MustInt(1000) + cfg.RendererDefaultImageHeight = renderSec.Key("default_image_height").MustInt(500) + cfg.RendererDefaultImageScale = renderSec.Key("default_image_scale").MustFloat64(1) cfg.ImagesDir = filepath.Join(cfg.DataPath, "png") cfg.CSVsDir = filepath.Join(cfg.DataPath, "csv") + cfg.PDFsDir = filepath.Join(cfg.DataPath, "pdf") return nil } -func readAlertingSettings(iniFile *ini.File) error { +func (cfg *Cfg) readAlertingSettings(iniFile *ini.File) error { + // This check is kept to prevent users that upgrade to Grafana 11 with the legacy alerting enabled. This should prevent them from accidentally upgrading without migration to Unified Alerting. alerting := iniFile.Section("alerting") enabled, err := alerting.Key("enabled").Bool() - AlertingEnabled = nil - if err == nil { - AlertingEnabled = &enabled + if err == nil && enabled { + cfg.Logger.Error("Option '[alerting].enabled' cannot be true. Legacy Alerting is removed. It is no longer deployed, enhanced, or supported. Delete '[alerting].enabled' and use '[unified_alerting].enabled' to enable Grafana Alerting. For more information, refer to the documentation on upgrading to Grafana Alerting (https://grafana.com/docs/grafana/v10.4/alerting/set-up/migrating-alerts)") + return fmt.Errorf("invalid setting [alerting].enabled") } - ExecuteAlerts = alerting.Key("execute_alerts").MustBool(true) - AlertingRenderLimit = alerting.Key("concurrent_render_limit").MustInt(5) - - AlertingErrorOrTimeout = valueAsString(alerting, "error_or_timeout", "alerting") - AlertingNoDataOrNullValues = valueAsString(alerting, "nodata_or_nullvalues", "no_data") - - evaluationTimeoutSeconds := alerting.Key("evaluation_timeout_seconds").MustInt64(30) - AlertingEvaluationTimeout = time.Second * time.Duration(evaluationTimeoutSeconds) - notificationTimeoutSeconds := alerting.Key("notification_timeout_seconds").MustInt64(30) - AlertingNotificationTimeout = time.Second * time.Duration(notificationTimeoutSeconds) - AlertingMaxAttempts = alerting.Key("max_attempts").MustInt(3) - AlertingMinInterval = alerting.Key("min_interval_seconds").MustInt64(1) - return nil } @@ -1811,12 +1812,6 @@ func readGRPCServerSettings(cfg *Cfg, iniFile *ini.File) error { return nil } -// IsLegacyAlertingEnabled returns whether the legacy alerting is enabled or not. -// It's safe to be used only after readAlertingSettings() and ReadUnifiedAlertingSettings() are executed. -func IsLegacyAlertingEnabled() bool { - return AlertingEnabled != nil && *AlertingEnabled -} - func readSnapshotsSettings(cfg *Cfg, iniFile *ini.File) error { snapshots := iniFile.Section("snapshots") @@ -1839,12 +1834,11 @@ func (cfg *Cfg) readServerSettings(iniFile *ini.File) error { if err != nil { return err } - ServeFromSubPath = server.Key("serve_from_sub_path").MustBool(false) cfg.AppURL = AppUrl cfg.AppSubURL = AppSubUrl - cfg.ServeFromSubPath = ServeFromSubPath cfg.Protocol = HTTPScheme + cfg.ServeFromSubPath = server.Key("serve_from_sub_path").MustBool(false) protocolStr := valueAsString(server, "protocol", "http") @@ -1878,8 +1872,7 @@ func (cfg *Cfg) readServerSettings(iniFile *ini.File) error { cfg.EnableGzip = server.Key("enable_gzip").MustBool(false) cfg.EnforceDomain = server.Key("enforce_domain").MustBool(false) staticRoot := valueAsString(server, "static_root_path", "") - StaticRootPath = makeAbsolute(staticRoot, HomePath) - cfg.StaticRootPath = StaticRootPath + cfg.StaticRootPath = makeAbsolute(staticRoot, cfg.HomePath) if err := cfg.validateStaticRootPath(); err != nil { return err @@ -1923,6 +1916,26 @@ func (cfg *Cfg) GetContentDeliveryURL(prefix string) (string, error) { func (cfg *Cfg) readDataSourcesSettings() { datasources := cfg.Raw.Section("datasources") cfg.DataSourceLimit = datasources.Key("datasource_limit").MustInt(5000) + cfg.ConcurrentQueryCount = datasources.Key("concurrent_query_count").MustInt(10) +} + +func (cfg *Cfg) readDataSourceSecuritySettings() { + datasources := cfg.Raw.Section("datasources.ip_range_security") + cfg.IPRangeACEnabled = datasources.Key("enabled").MustBool(false) + cfg.IPRangeACSecretKey = datasources.Key("secret_key").MustString("") + if cfg.IPRangeACEnabled && cfg.IPRangeACSecretKey == "" { + cfg.Logger.Error("IP range access control is enabled but no secret key is set") + } + allowedURLString := datasources.Key("allow_list").MustString("") + for _, urlString := range util.SplitString(allowedURLString) { + allowedURL, err := url.Parse(urlString) + if err != nil { + cfg.Logger.Error("Error parsing allowed URL for IP range access control", "error", err) + continue + } else { + cfg.IPRangeACAllowedURLs = append(cfg.IPRangeACAllowedURLs, allowedURL) + } + } } func (cfg *Cfg) readSqlDataSourceSettings() { @@ -1981,3 +1994,8 @@ func (cfg *Cfg) readPublicDashboardsSettings() { publicDashboards := cfg.Raw.Section("public_dashboards") cfg.PublicDashboardsEnabled = publicDashboards.Key("enabled").MustBool(true) } + +func (cfg *Cfg) readCloudMigrationSettings() { + cloudMigration := cfg.Raw.Section("cloud_migration") + cfg.CloudMigrationIsTarget = cloudMigration.Key("is_target").MustBool(false) +} diff --git a/pkg/setting/setting_auth_proxy.go b/pkg/setting/setting_auth_proxy.go new file mode 100644 index 0000000000000..36d300e78e92a --- /dev/null +++ b/pkg/setting/setting_auth_proxy.go @@ -0,0 +1,45 @@ +package setting + +import ( + "strings" + + "github.com/grafana/grafana/pkg/util" +) + +type AuthProxySettings struct { + // Auth Proxy + Enabled bool + HeaderName string + HeaderProperty string + AutoSignUp bool + EnableLoginToken bool + Whitelist string + Headers map[string]string + HeadersEncoded bool + SyncTTL int +} + +func (cfg *Cfg) readAuthProxySettings() { + authProxySettings := AuthProxySettings{} + authProxy := cfg.Raw.Section("auth.proxy") + authProxySettings.Enabled = authProxy.Key("enabled").MustBool(false) + authProxySettings.HeaderName = valueAsString(authProxy, "header_name", "") + authProxySettings.HeaderProperty = valueAsString(authProxy, "header_property", "") + authProxySettings.AutoSignUp = authProxy.Key("auto_sign_up").MustBool(true) + authProxySettings.EnableLoginToken = authProxy.Key("enable_login_token").MustBool(false) + authProxySettings.SyncTTL = authProxy.Key("sync_ttl").MustInt(15) + authProxySettings.Whitelist = valueAsString(authProxy, "whitelist", "") + authProxySettings.Headers = make(map[string]string) + headers := valueAsString(authProxy, "headers", "") + + for _, propertyAndHeader := range util.SplitString(headers) { + split := strings.SplitN(propertyAndHeader, ":", 2) + if len(split) == 2 { + authProxySettings.Headers[split[0]] = split[1] + } + } + + authProxySettings.HeadersEncoded = authProxy.Key("headers_encoded").MustBool(false) + + cfg.AuthProxy = authProxySettings +} diff --git a/pkg/setting/setting_azure.go b/pkg/setting/setting_azure.go index 54e8c5e2a5289..77f3e55b082a1 100644 --- a/pkg/setting/setting_azure.go +++ b/pkg/setting/setting_azure.go @@ -9,6 +9,10 @@ func (cfg *Cfg) readAzureSettings() { azureSettings := &azsettings.AzureSettings{} azureSection := cfg.Raw.Section("azure") + authSection := cfg.Raw.Section("auth") + + // This setting is specific to Prometheus + azureSettings.AzureAuthEnabled = authSection.Key("azure_auth_enabled").MustBool(false) // Cloud cloudName := azureSection.Key("cloud").MustString(azsettings.AzurePublic) diff --git a/pkg/setting/setting_azure_test.go b/pkg/setting/setting_azure_test.go index 2f75fd5eb362e..a122e821e149f 100644 --- a/pkg/setting/setting_azure_test.go +++ b/pkg/setting/setting_azure_test.go @@ -64,6 +64,27 @@ func TestAzureSettings(t *testing.T) { } }) + t.Run("prometheus", func(t *testing.T) { + t.Run("should enable azure auth", func(t *testing.T) { + cfg := NewCfg() + + authSection, err := cfg.Raw.NewSection("auth") + require.NoError(t, err) + _, err = authSection.NewKey("azure_auth_enabled", "true") + require.NoError(t, err) + + cfg.readAzureSettings() + require.NotNil(t, cfg.Azure.AzureAuthEnabled) + assert.True(t, cfg.Azure.AzureAuthEnabled) + }) + t.Run("should default to disabled", func(t *testing.T) { + cfg := NewCfg() + + cfg.readAzureSettings() + require.NotNil(t, cfg.Azure.AzureAuthEnabled) + assert.False(t, cfg.Azure.AzureAuthEnabled) + }) + }) t.Run("User Identity", func(t *testing.T) { t.Run("should be disabled by default", func(t *testing.T) { cfg := NewCfg() diff --git a/pkg/setting/setting_data_proxy.go b/pkg/setting/setting_data_proxy.go index f86b4089e3edf..5dd31676e7418 100644 --- a/pkg/setting/setting_data_proxy.go +++ b/pkg/setting/setting_data_proxy.go @@ -12,8 +12,8 @@ func readDataProxySettings(iniFile *ini.File, cfg *Cfg) error { dataproxy := iniFile.Section("dataproxy") cfg.SendUserHeader = dataproxy.Key("send_user_header").MustBool(false) cfg.DataProxyLogging = dataproxy.Key("logging").MustBool(false) - cfg.DataProxyTimeout = dataproxy.Key("timeout").MustInt(10) - cfg.DataProxyDialTimeout = dataproxy.Key("dialTimeout").MustInt(30) + cfg.DataProxyTimeout = dataproxy.Key("timeout").MustInt(30) + cfg.DataProxyDialTimeout = dataproxy.Key("dialTimeout").MustInt(10) cfg.DataProxyKeepAlive = dataproxy.Key("keep_alive_seconds").MustInt(30) cfg.DataProxyTLSHandshakeTimeout = dataproxy.Key("tls_handshake_timeout_seconds").MustInt(10) cfg.DataProxyExpectContinueTimeout = dataproxy.Key("expect_continue_timeout_seconds").MustInt(1) diff --git a/pkg/setting/setting_grafana_javascript_agent.go b/pkg/setting/setting_grafana_javascript_agent.go index cfc04d5136dc9..a623bd242ae14 100644 --- a/pkg/setting/setting_grafana_javascript_agent.go +++ b/pkg/setting/setting_grafana_javascript_agent.go @@ -8,6 +8,7 @@ type GrafanaJavascriptAgent struct { ErrorInstrumentalizationEnabled bool `json:"errorInstrumentalizationEnabled"` ConsoleInstrumentalizationEnabled bool `json:"consoleInstrumentalizationEnabled"` WebVitalsInstrumentalizationEnabled bool `json:"webVitalsInstrumentalizationEnabled"` + InternalLoggerLevel int `json:"internalLoggerLevel"` ApiKey string `json:"apiKey"` } @@ -21,6 +22,7 @@ func (cfg *Cfg) readGrafanaJavascriptAgentConfig() { ErrorInstrumentalizationEnabled: raw.Key("instrumentations_errors_enabled").MustBool(true), ConsoleInstrumentalizationEnabled: raw.Key("instrumentations_console_enabled").MustBool(true), WebVitalsInstrumentalizationEnabled: raw.Key("instrumentations_webvitals_enabled").MustBool(true), + InternalLoggerLevel: raw.Key("internal_logger_level").MustInt(0), ApiKey: raw.Key("api_key").String(), } } diff --git a/pkg/setting/setting_jwt.go b/pkg/setting/setting_jwt.go new file mode 100644 index 0000000000000..1f6a672e526f1 --- /dev/null +++ b/pkg/setting/setting_jwt.go @@ -0,0 +1,48 @@ +package setting + +import "time" + +type AuthJWTSettings struct { + // JWT Auth + Enabled bool + HeaderName string + URLLogin bool + EmailClaim string + UsernameClaim string + ExpectClaims string + JWKSetURL string + CacheTTL time.Duration + KeyFile string + KeyID string + JWKSetFile string + AutoSignUp bool + RoleAttributePath string + RoleAttributeStrict bool + AllowAssignGrafanaAdmin bool + SkipOrgRoleSync bool + GroupsAttributePath string +} + +func (cfg *Cfg) readAuthJWTSettings() { + jwtSettings := AuthJWTSettings{} + authJWT := cfg.Raw.Section("auth.jwt") + jwtSettings.Enabled = authJWT.Key("enabled").MustBool(false) + jwtSettings.HeaderName = valueAsString(authJWT, "header_name", "") + jwtSettings.URLLogin = authJWT.Key("url_login").MustBool(false) + jwtSettings.EmailClaim = valueAsString(authJWT, "email_claim", "") + jwtSettings.UsernameClaim = valueAsString(authJWT, "username_claim", "") + jwtSettings.ExpectClaims = valueAsString(authJWT, "expect_claims", "{}") + jwtSettings.JWKSetURL = valueAsString(authJWT, "jwk_set_url", "") + jwtSettings.CacheTTL = authJWT.Key("cache_ttl").MustDuration(time.Minute * 60) + jwtSettings.KeyFile = valueAsString(authJWT, "key_file", "") + jwtSettings.KeyID = authJWT.Key("key_id").MustString("") + jwtSettings.JWKSetFile = valueAsString(authJWT, "jwk_set_file", "") + jwtSettings.AutoSignUp = authJWT.Key("auto_sign_up").MustBool(false) + jwtSettings.RoleAttributePath = valueAsString(authJWT, "role_attribute_path", "") + jwtSettings.RoleAttributeStrict = authJWT.Key("role_attribute_strict").MustBool(false) + jwtSettings.AllowAssignGrafanaAdmin = authJWT.Key("allow_assign_grafana_admin").MustBool(false) + jwtSettings.SkipOrgRoleSync = authJWT.Key("skip_org_role_sync").MustBool(false) + jwtSettings.GroupsAttributePath = valueAsString(authJWT, "groups_attribute_path", "") + + cfg.JWTAuth = jwtSettings +} diff --git a/pkg/setting/setting_quota.go b/pkg/setting/setting_quota.go index 5011cd6361fda..f35cb29411c48 100644 --- a/pkg/setting/setting_quota.go +++ b/pkg/setting/setting_quota.go @@ -36,19 +36,13 @@ func (cfg *Cfg) readQuotaSettings() { quota := cfg.Raw.Section("quota") cfg.Quota.Enabled = quota.Key("enabled").MustBool(false) - var alertOrgQuota int64 - var alertGlobalQuota int64 - if cfg.UnifiedAlerting.IsEnabled() { - alertOrgQuota = quota.Key("org_alert_rule").MustInt64(100) - alertGlobalQuota = quota.Key("global_alert_rule").MustInt64(-1) - } // per ORG Limits cfg.Quota.Org = OrgQuota{ User: quota.Key("org_user").MustInt64(10), DataSource: quota.Key("org_data_source").MustInt64(10), Dashboard: quota.Key("org_dashboard").MustInt64(10), ApiKey: quota.Key("org_api_key").MustInt64(10), - AlertRule: alertOrgQuota, + AlertRule: quota.Key("org_alert_rule").MustInt64(100), } // per User limits @@ -65,7 +59,7 @@ func (cfg *Cfg) readQuotaSettings() { ApiKey: quota.Key("global_api_key").MustInt64(-1), Session: quota.Key("global_session").MustInt64(-1), File: quota.Key("global_file").MustInt64(-1), - AlertRule: alertGlobalQuota, + AlertRule: quota.Key("global_alert_rule").MustInt64(-1), Correlations: quota.Key("global_correlations").MustInt64(-1), } } diff --git a/pkg/setting/setting_smtp.go b/pkg/setting/setting_smtp.go index 522b02b0cd4d7..dfcbe859dd572 100644 --- a/pkg/setting/setting_smtp.go +++ b/pkg/setting/setting_smtp.go @@ -20,6 +20,7 @@ type SmtpSettings struct { StartTLSPolicy string SkipVerify bool StaticHeaders map[string]string + EnableTracing bool SendWelcomeEmailOnSignUp bool TemplatesPatterns []string @@ -40,6 +41,9 @@ func (cfg *Cfg) readSmtpSettings() error { cfg.Smtp.FromAddress = sec.Key("from_address").String() cfg.Smtp.FromName = sec.Key("from_name").String() cfg.Smtp.EhloIdentity = sec.Key("ehlo_identity").String() + if cfg.Smtp.EhloIdentity == "" { + cfg.Smtp.EhloIdentity = cfg.InstanceName + } cfg.Smtp.StartTLSPolicy = sec.Key("startTLS_policy").String() cfg.Smtp.SkipVerify = sec.Key("skip_verify").MustBool(false) @@ -53,6 +57,8 @@ func (cfg *Cfg) readSmtpSettings() error { return err } + cfg.Smtp.EnableTracing = sec.Key("enable_tracing").MustBool(false) + return nil } diff --git a/pkg/setting/setting_test.go b/pkg/setting/setting_test.go index 41f8fb139a5a5..57bce42da71cc 100644 --- a/pkg/setting/setting_test.go +++ b/pkg/setting/setting_test.go @@ -2,19 +2,22 @@ package setting import ( "bufio" - "math/rand" "net/url" "os" "path" "path/filepath" "runtime" "strings" + "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/ini.v1" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/util/osutil" ) const ( @@ -54,13 +57,13 @@ func TestLoadingSettings(t *testing.T) { }) t.Run("sample.ini should load successfully", func(t *testing.T) { - customInitPath := CustomInitPath - CustomInitPath = "conf/sample.ini" + oldCustomInitPath := customInitPath + customInitPath = "conf/sample.ini" cfg := NewCfg() err := cfg.Load(CommandLineArgs{HomePath: "../../"}) require.Nil(t, err) // Restore CustomInitPath to avoid side effects. - CustomInitPath = customInitPath + customInitPath = oldCustomInitPath }) t.Run("Should be able to override via environment variables", func(t *testing.T) { @@ -71,10 +74,23 @@ func TestLoadingSettings(t *testing.T) { require.Nil(t, err) require.Equal(t, "superduper", cfg.AdminUser) - require.Equal(t, filepath.Join(HomePath, "data"), cfg.DataPath) + require.Equal(t, filepath.Join(cfg.HomePath, "data"), cfg.DataPath) require.Equal(t, filepath.Join(cfg.DataPath, "log"), cfg.LogsPath) }) + t.Run("Should be able to expand parameter from environment variables", func(t *testing.T) { + t.Setenv("DEFAULT_IDP_URL", "grafana.com") + t.Setenv("GF_AUTH_GENERIC_OAUTH_AUTH_URL", "${DEFAULT_IDP_URL}/auth") + + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{HomePath: "../../"}) + require.Nil(t, err) + + genericOAuthSection, err := cfg.Raw.GetSection("auth.generic_oauth") + require.NoError(t, err) + require.Equal(t, "grafana.com/auth", genericOAuthSection.Key("auth_url").Value()) + }) + t.Run("Should replace password when defined in environment", func(t *testing.T) { t.Setenv("GF_SECURITY_ADMIN_PASSWORD", "supersecret") @@ -82,7 +98,7 @@ func TestLoadingSettings(t *testing.T) { err := cfg.Load(CommandLineArgs{HomePath: "../../"}) require.Nil(t, err) - require.Contains(t, appliedEnvOverrides, "GF_SECURITY_ADMIN_PASSWORD=*********") + require.Contains(t, cfg.appliedEnvOverrides, "GF_SECURITY_ADMIN_PASSWORD=*********") }) t.Run("Should replace password in URL when url environment is defined", func(t *testing.T) { @@ -92,7 +108,7 @@ func TestLoadingSettings(t *testing.T) { err := cfg.Load(CommandLineArgs{HomePath: "../../"}) require.Nil(t, err) - require.Contains(t, appliedEnvOverrides, "GF_DATABASE_URL=mysql://user:xxxxx@localhost:3306/database") + require.Contains(t, cfg.appliedEnvOverrides, "GF_DATABASE_URL=mysql://user:xxxxx@localhost:3306/database") }) t.Run("Should get property map from command line args array", func(t *testing.T) { @@ -134,7 +150,7 @@ func TestLoadingSettings(t *testing.T) { Args: []string{ "cfg:default.server.domain=test2", }, - Config: filepath.Join(HomePath, "pkg/setting/testdata/override.ini"), + Config: filepath.Join("../../", "pkg/setting/testdata/override.ini"), }) require.Nil(t, err) @@ -148,7 +164,7 @@ func TestLoadingSettings(t *testing.T) { Args: []string{ "cfg:default.server.min_tls_version=TLS1.3", }, - Config: filepath.Join(HomePath, "pkg/setting/testdata/override.ini"), + Config: filepath.Join("../../", "pkg/setting/testdata/override.ini"), }) require.Nil(t, err) @@ -160,7 +176,7 @@ func TestLoadingSettings(t *testing.T) { cfg := NewCfg() err := cfg.Load(CommandLineArgs{ HomePath: "../../", - Config: filepath.Join(HomePath, "pkg/setting/testdata/override_windows.ini"), + Config: filepath.Join("../../", "pkg/setting/testdata/override_windows.ini"), Args: []string{`cfg:default.paths.data=c:\tmp\data`}, }) require.Nil(t, err) @@ -170,7 +186,7 @@ func TestLoadingSettings(t *testing.T) { cfg := NewCfg() err := cfg.Load(CommandLineArgs{ HomePath: "../../", - Config: filepath.Join(HomePath, "pkg/setting/testdata/override.ini"), + Config: filepath.Join("../../", "pkg/setting/testdata/override.ini"), Args: []string{"cfg:default.paths.data=/tmp/data"}, }) require.Nil(t, err) @@ -184,7 +200,7 @@ func TestLoadingSettings(t *testing.T) { cfg := NewCfg() err := cfg.Load(CommandLineArgs{ HomePath: "../../", - Config: filepath.Join(HomePath, "pkg/setting/testdata/override_windows.ini"), + Config: filepath.Join("../../", "pkg/setting/testdata/override_windows.ini"), Args: []string{`cfg:paths.data=c:\tmp\data`}, }) require.Nil(t, err) @@ -194,7 +210,7 @@ func TestLoadingSettings(t *testing.T) { cfg := NewCfg() err := cfg.Load(CommandLineArgs{ HomePath: "../../", - Config: filepath.Join(HomePath, "pkg/setting/testdata/override.ini"), + Config: filepath.Join("../../", "pkg/setting/testdata/override.ini"), Args: []string{"cfg:paths.data=/tmp/data"}, }) require.Nil(t, err) @@ -236,7 +252,7 @@ func TestLoadingSettings(t *testing.T) { hostname, err := os.Hostname() require.Nil(t, err) - require.Equal(t, hostname, InstanceName) + require.Equal(t, hostname, cfg.InstanceName) }) t.Run("Reading callback_url should add trailing slash", func(t *testing.T) { @@ -258,11 +274,14 @@ func TestLoadingSettings(t *testing.T) { }) require.Nil(t, err) - require.Equal(t, 2, cfg.AuthProxySyncTTL) + require.Equal(t, 2, cfg.AuthProxy.SyncTTL) }) t.Run("Test reading string values from .ini file", func(t *testing.T) { - iniFile, err := ini.Load(path.Join(HomePath, "pkg/setting/testdata/invalid.ini")) + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{HomePath: "../../"}) + require.Nil(t, err) + iniFile, err := ini.Load(path.Join(cfg.HomePath, "pkg/setting/testdata/invalid.ini")) require.Nil(t, err) t.Run("If key is found - should return value from ini file", func(t *testing.T) { @@ -443,359 +462,39 @@ func TestGetCDNPathWithAlphaVersion(t *testing.T) { } func TestAlertingEnabled(t *testing.T) { - anyBoolean := func() bool { - return rand.Int63()%2 == 0 - } + t.Run("fail if legacy alerting enabled", func(t *testing.T) { + f := ini.Empty() + cfg := NewCfg() - testCases := []struct { - desc string - unifiedAlertingEnabled string - legacyAlertingEnabled string - featureToggleSet bool - isEnterprise bool - verifyCfg func(*testing.T, Cfg, *ini.File) - }{ - { - desc: "when legacy alerting is enabled and unified is disabled", - legacyAlertingEnabled: "true", - unifiedAlertingEnabled: "false", - isEnterprise: anyBoolean(), - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - require.NoError(t, err) - assert.NotNil(t, cfg.UnifiedAlerting.Enabled) - assert.Equal(t, *cfg.UnifiedAlerting.Enabled, false) - assert.NotNil(t, AlertingEnabled) - assert.Equal(t, *AlertingEnabled, true) - }, - }, - { - desc: "when legacy alerting is disabled and unified is enabled", - legacyAlertingEnabled: "false", - unifiedAlertingEnabled: "true", - isEnterprise: anyBoolean(), - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - require.NoError(t, err) - assert.NotNil(t, cfg.UnifiedAlerting.Enabled) - assert.Equal(t, *cfg.UnifiedAlerting.Enabled, true) - assert.NotNil(t, AlertingEnabled) - assert.Equal(t, *AlertingEnabled, false) - }, - }, - { - desc: "when both alerting are enabled", - legacyAlertingEnabled: "true", - unifiedAlertingEnabled: "true", - isEnterprise: anyBoolean(), - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - require.Error(t, err) - }, - }, - { - desc: "when legacy alerting is invalid (or not defined) and unified is disabled", - legacyAlertingEnabled: "", - unifiedAlertingEnabled: "false", - isEnterprise: anyBoolean(), - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - require.NoError(t, err) - assert.NotNil(t, cfg.UnifiedAlerting.Enabled) - assert.Equal(t, *cfg.UnifiedAlerting.Enabled, false) - assert.NotNil(t, AlertingEnabled) - assert.Equal(t, *AlertingEnabled, true) - }, - }, - { - desc: "when legacy alerting is invalid (or not defined) and unified is enabled", - legacyAlertingEnabled: "", - unifiedAlertingEnabled: "true", - isEnterprise: anyBoolean(), - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - require.NoError(t, err) - assert.NotNil(t, cfg.UnifiedAlerting.Enabled) - assert.Equal(t, *cfg.UnifiedAlerting.Enabled, true) - assert.NotNil(t, AlertingEnabled) - assert.Equal(t, *AlertingEnabled, false) - }, - }, - { - desc: "when legacy alerting is enabled and unified is not defined [OSS]", - legacyAlertingEnabled: "true", - unifiedAlertingEnabled: "", - isEnterprise: false, - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - require.NoError(t, err) - assert.NotNil(t, cfg.UnifiedAlerting.Enabled) - assert.Equal(t, true, *cfg.UnifiedAlerting.Enabled) - assert.NotNil(t, AlertingEnabled) - assert.Equal(t, false, *AlertingEnabled) - }, - }, - { - desc: "when legacy alerting is enabled and unified is invalid [OSS]", - legacyAlertingEnabled: "true", - unifiedAlertingEnabled: "invalid", - isEnterprise: false, - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - assert.EqualError(t, err, "failed to read unified alerting enabled setting: invalid value invalid, should be either true or false") - assert.Nil(t, cfg.UnifiedAlerting.Enabled) - assert.NotNil(t, AlertingEnabled) - assert.Equal(t, false, *AlertingEnabled) - }, - }, - { - desc: "when legacy alerting is enabled and unified is not defined [Enterprise]", - legacyAlertingEnabled: "true", - unifiedAlertingEnabled: "", - isEnterprise: true, - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - require.NoError(t, err) - assert.NotNil(t, cfg.UnifiedAlerting.Enabled) - assert.Equal(t, true, *cfg.UnifiedAlerting.Enabled) - assert.NotNil(t, AlertingEnabled) - assert.Equal(t, false, *AlertingEnabled) - }, - }, - { - desc: "when legacy alerting is enabled and unified is invalid [Enterprise]", - legacyAlertingEnabled: "true", - unifiedAlertingEnabled: "invalid", - isEnterprise: false, - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - assert.EqualError(t, err, "failed to read unified alerting enabled setting: invalid value invalid, should be either true or false") - assert.Nil(t, cfg.UnifiedAlerting.Enabled) - assert.NotNil(t, AlertingEnabled) - assert.Equal(t, false, *AlertingEnabled) - }, - }, - { - desc: "when legacy alerting is disabled and unified is not defined [OSS]", - legacyAlertingEnabled: "false", - unifiedAlertingEnabled: "", - isEnterprise: false, - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - require.NoError(t, err) - assert.NotNil(t, cfg.UnifiedAlerting.Enabled) - assert.Equal(t, *cfg.UnifiedAlerting.Enabled, true) - assert.NotNil(t, AlertingEnabled) - assert.Equal(t, *AlertingEnabled, false) - }, - }, - { - desc: "when legacy alerting is disabled and unified is invalid [OSS]", - legacyAlertingEnabled: "false", - unifiedAlertingEnabled: "invalid", - isEnterprise: false, - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - assert.EqualError(t, err, "failed to read unified alerting enabled setting: invalid value invalid, should be either true or false") - assert.Nil(t, cfg.UnifiedAlerting.Enabled) - assert.NotNil(t, AlertingEnabled) - assert.Equal(t, false, *AlertingEnabled) - }, - }, - { - desc: "when legacy alerting is disabled and unified is not defined [Enterprise]", - legacyAlertingEnabled: "false", - unifiedAlertingEnabled: "", - isEnterprise: true, - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - require.NoError(t, err) - assert.NotNil(t, cfg.UnifiedAlerting.Enabled) - assert.Equal(t, *cfg.UnifiedAlerting.Enabled, true) - assert.NotNil(t, AlertingEnabled) - assert.Equal(t, *AlertingEnabled, false) - }, - }, - { - desc: "when legacy alerting is disabled and unified is invalid [Enterprise]", - legacyAlertingEnabled: "false", - unifiedAlertingEnabled: "invalid", - isEnterprise: false, - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - assert.EqualError(t, err, "failed to read unified alerting enabled setting: invalid value invalid, should be either true or false") - assert.Nil(t, cfg.UnifiedAlerting.Enabled) - assert.NotNil(t, AlertingEnabled) - assert.Equal(t, false, *AlertingEnabled) - }, - }, - { - desc: "when both are not defined [OSS]", - legacyAlertingEnabled: "", - unifiedAlertingEnabled: "", - isEnterprise: false, - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - require.NoError(t, err) - assert.NotNil(t, cfg.UnifiedAlerting.Enabled) - assert.True(t, *cfg.UnifiedAlerting.Enabled) - assert.Nil(t, AlertingEnabled) - }, - }, - { - desc: "when both are not invalid [OSS]", - legacyAlertingEnabled: "invalid", - unifiedAlertingEnabled: "invalid", - isEnterprise: false, - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - assert.EqualError(t, err, "failed to read unified alerting enabled setting: invalid value invalid, should be either true or false") - assert.Nil(t, cfg.UnifiedAlerting.Enabled) - assert.NotNil(t, AlertingEnabled) - assert.Equal(t, false, *AlertingEnabled) - }, - }, - { - desc: "when both are not defined [Enterprise]", - legacyAlertingEnabled: "", - unifiedAlertingEnabled: "", - isEnterprise: true, - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - require.NoError(t, err) - assert.NotNil(t, cfg.UnifiedAlerting.Enabled) - assert.True(t, *cfg.UnifiedAlerting.Enabled) - assert.Nil(t, AlertingEnabled) - }, - }, - { - desc: "when both are not invalid [Enterprise]", - legacyAlertingEnabled: "invalid", - unifiedAlertingEnabled: "invalid", - isEnterprise: false, - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - assert.EqualError(t, err, "failed to read unified alerting enabled setting: invalid value invalid, should be either true or false") - assert.Nil(t, cfg.UnifiedAlerting.Enabled) - assert.NotNil(t, AlertingEnabled) - assert.Equal(t, false, *AlertingEnabled) - }, - }, - { - desc: "when both are false", - legacyAlertingEnabled: "false", - unifiedAlertingEnabled: "false", - isEnterprise: anyBoolean(), - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - require.NoError(t, err) - assert.NotNil(t, cfg.UnifiedAlerting.Enabled) - assert.Equal(t, *cfg.UnifiedAlerting.Enabled, false) - assert.NotNil(t, AlertingEnabled) - assert.Equal(t, *AlertingEnabled, false) - }, - }, - } + alertingSec, err := f.NewSection("alerting") + require.NoError(t, err) + _, err = alertingSec.NewKey("enabled", "true") + require.NoError(t, err) - var isEnterpriseOld = IsEnterprise - t.Cleanup(func() { - IsEnterprise = isEnterpriseOld + require.Error(t, cfg.readAlertingSettings(f)) }) - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - IsEnterprise = tc.isEnterprise - t.Cleanup(func() { - AlertingEnabled = nil - }) + t.Run("do nothing if it is disabled", func(t *testing.T) { + f := ini.Empty() + cfg := NewCfg() - f := ini.Empty() - cfg := NewCfg() - unifiedAlertingSec, err := f.NewSection("unified_alerting") - require.NoError(t, err) - _, err = unifiedAlertingSec.NewKey("enabled", tc.unifiedAlertingEnabled) - require.NoError(t, err) + alertingSec, err := f.NewSection("alerting") + require.NoError(t, err) + _, err = alertingSec.NewKey("enabled", "false") + require.NoError(t, err) + require.NoError(t, cfg.readAlertingSettings(f)) + }) - alertingSec, err := f.NewSection("alerting") - require.NoError(t, err) - _, err = alertingSec.NewKey("enabled", tc.legacyAlertingEnabled) - require.NoError(t, err) + t.Run("do nothing if it invalid", func(t *testing.T) { + f := ini.Empty() + cfg := NewCfg() - tc.verifyCfg(t, *cfg, f) - }) - } + alertingSec, err := f.NewSection("alerting") + require.NoError(t, err) + _, err = alertingSec.NewKey("enabled", "test") + require.NoError(t, err) + require.NoError(t, cfg.readAlertingSettings(f)) + }) } func TestRedactedValue(t *testing.T) { @@ -817,6 +516,12 @@ func TestRedactedValue(t *testing.T) { value: "/path/to/key", expected: RedactedPassword, }, + { + desc: "license key with non-empty value", + key: "GF_ENTERPRISE_LICENSE_TEXT", + value: "some_license_key_test", + expected: RedactedPassword, + }, { desc: "sensitive key with empty value", key: "private_key_path", @@ -906,3 +611,73 @@ func TestHandleAWSSettings(t *testing.T) { assert.Equal(t, 400, cfg.AWSListMetricsPageLimit) }) } + +const iniString = ` +app_mode = production + +[server] +domain = test.com +` + +func TestNewCfgFromBytes(t *testing.T) { + cfg, err := NewCfgFromBytes([]byte(iniString)) + require.NoError(t, err) + require.NotNil(t, cfg) + require.Equal(t, Prod, cfg.Env) + require.Equal(t, "test.com", cfg.Domain) +} + +func TestNewCfgFromINIFile(t *testing.T) { + parsedFile, err := ini.Load([]byte(iniString)) + require.NoError(t, err) + require.NotNil(t, parsedFile) + + cfg, err := NewCfgFromINIFile(parsedFile) + require.NoError(t, err) + require.NotNil(t, cfg) + require.Equal(t, Prod, cfg.Env) + require.Equal(t, "test.com", cfg.Domain) +} + +func TestDynamicSection(t *testing.T) { + t.Parallel() + + t.Run("repro #44509 - panic on concurrent map write", func(t *testing.T) { + t.Parallel() + + const ( + goroutines = 10 + attempts = 1000 + section = "DEFAULT" + key = "TestDynamicSection_repro_44509" + value = "theval" + ) + + cfg, err := NewCfgFromBytes([]byte(``)) + require.NoError(t, err) + + ds := &DynamicSection{ + section: cfg.Raw.Section(section), + Logger: log.NewNopLogger(), + env: osutil.MapEnv{}, + } + osVar := EnvKey(section, key) + err = ds.env.Setenv(osVar, value) + require.NoError(t, err) + + var wg sync.WaitGroup + for i := 0; i < goroutines; i++ { + wg.Add(1) + go require.NotPanics(t, func() { + for i := 0; i < attempts; i++ { + ds.section.Key(key).SetValue("") + ds.Key(key) + } + wg.Done() + }) + } + wg.Wait() + + assert.Equal(t, value, ds.section.Key(key).String()) + }) +} diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index 87c53db0cdbfa..eaca5d11afcf9 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -1,7 +1,6 @@ package setting import ( - "errors" "fmt" "strconv" "strings" @@ -46,7 +45,7 @@ const ( ` evaluatorDefaultEvaluationTimeout = 30 * time.Second schedulerDefaultAdminConfigPollInterval = time.Minute - schedulereDefaultExecuteAlerts = true + schedulerDefaultExecuteAlerts = true schedulerDefaultMaxAttempts = 1 schedulerDefaultLegacyMinInterval = 1 screenshotsDefaultCapture = false @@ -61,6 +60,7 @@ const ( // DefaultRuleEvaluationInterval indicates a default interval of for how long a rule should be evaluated to change state from Pending to Alerting DefaultRuleEvaluationInterval = SchedulerBaseInterval * 6 // == 60 seconds stateHistoryDefaultEnabled = true + lokiDefaultMaxQueryLength = 721 * time.Hour // 30d1h, matches the default value in Loki ) type UnifiedAlertingSettings struct { @@ -83,6 +83,7 @@ type UnifiedAlertingSettings struct { MaxAttempts int64 MinInterval time.Duration EvaluationTimeout time.Duration + DisableJitter bool ExecuteAlerts bool DefaultConfiguration string Enabled *bool // determines whether unified alerting is enabled. If it is nil then user did not define it and therefore its value will be determined during migration. Services should not use it directly. @@ -96,9 +97,10 @@ type UnifiedAlertingSettings struct { ReservedLabels UnifiedAlertingReservedLabelSettings StateHistory UnifiedAlertingStateHistorySettings RemoteAlertmanager RemoteAlertmanagerSettings - Upgrade UnifiedAlertingUpgradeSettings // MaxStateSaveConcurrency controls the number of goroutines (per rule) that can save alert state in parallel. - MaxStateSaveConcurrency int + MaxStateSaveConcurrency int + StatePeriodicSaveInterval time.Duration + RulesPerRuleGroupLimit int64 } // RemoteAlertmanagerSettings contains the configuration needed @@ -133,16 +135,12 @@ type UnifiedAlertingStateHistorySettings struct { // if one of them is set. LokiBasicAuthPassword string LokiBasicAuthUsername string + LokiMaxQueryLength time.Duration MultiPrimary string MultiSecondaries []string ExternalLabels map[string]string } -type UnifiedAlertingUpgradeSettings struct { - // CleanUpgrade controls whether the upgrade process should clean up UA data when upgrading from legacy alerting. - CleanUpgrade bool -} - // IsEnabled returns true if UnifiedAlertingSettings.Enabled is either nil or true. // It hides the implementation details of the Enabled and simplifies its usage. func (u *UnifiedAlertingSettings) IsEnabled() bool { @@ -163,49 +161,13 @@ func (cfg *Cfg) readUnifiedAlertingEnabledSetting(section *ini.Section) (*bool, // At present an invalid value is considered the same as no value. This means that a // spelling mistake in the string "false" could enable unified alerting rather // than disable it. This issue can be found here - hasEnabled := section.Key("enabled").Value() != "" - if !hasEnabled { - // TODO: Remove in Grafana v10 - if cfg.IsFeatureToggleEnabled("ngalert") { - cfg.Logger.Warn("ngalert feature flag is deprecated: use unified alerting enabled setting instead") - // feature flag overrides the legacy alerting setting - legacyAlerting := false - AlertingEnabled = &legacyAlerting - unifiedAlerting := true - return &unifiedAlerting, nil - } - - // if legacy alerting has not been configured then enable unified alerting - if AlertingEnabled == nil { - unifiedAlerting := true - return &unifiedAlerting, nil - } - - // enable unified alerting and disable legacy alerting - legacyAlerting := false - AlertingEnabled = &legacyAlerting - unifiedAlerting := true - return &unifiedAlerting, nil + if section.Key("enabled").Value() == "" { + return util.Pointer(true), nil } - unifiedAlerting, err := section.Key("enabled").Bool() if err != nil { - // the value for unified alerting is invalid so disable all alerting - legacyAlerting := false - AlertingEnabled = &legacyAlerting return nil, fmt.Errorf("invalid value %s, should be either true or false", section.Key("enabled")) } - - // If both legacy and unified alerting are enabled then return an error - if AlertingEnabled != nil && *AlertingEnabled && unifiedAlerting { - return nil, errors.New("legacy and unified alerting cannot both be enabled at the same time, please disable one of them and restart Grafana") - } - - if AlertingEnabled == nil { - legacyAlerting := !unifiedAlerting - AlertingEnabled = &legacyAlerting - } - return &unifiedAlerting, nil } @@ -274,9 +236,9 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { alerting := iniFile.Section("alerting") - uaExecuteAlerts := ua.Key("execute_alerts").MustBool(schedulereDefaultExecuteAlerts) + uaExecuteAlerts := ua.Key("execute_alerts").MustBool(schedulerDefaultExecuteAlerts) if uaExecuteAlerts { // unified option equals the default (true) - legacyExecuteAlerts := alerting.Key("execute_alerts").MustBool(schedulereDefaultExecuteAlerts) + legacyExecuteAlerts := alerting.Key("execute_alerts").MustBool(schedulerDefaultExecuteAlerts) if !legacyExecuteAlerts { cfg.Logger.Warn("falling back to legacy setting of 'execute_alerts'; please use the configuration option in the `unified_alerting` section if Grafana 8 alerts are enabled.") } @@ -299,6 +261,10 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { uaCfg.BaseInterval = SchedulerBaseInterval + // TODO: This was promoted from a feature toggle and is now the default behavior. + // We can consider removing the knob entirely in a release after 10.4. + uaCfg.DisableJitter = ua.Key("disable_jitter").MustBool(false) + // The base interval of the scheduler for evaluating alerts. // 1. It is used by the internal scheduler's timer to tick at this interval. // 2. to spread evaluations of rules that need to be evaluated at the current tick T. In other words, the evaluation of rules at the tick T will be evenly spread in the interval from T to T+scheduler_tick_interval. @@ -346,6 +312,9 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { uaCfg.DefaultRuleEvaluationInterval = uaMinInterval } + quotas := iniFile.Section("quota") + uaCfg.RulesPerRuleGroupLimit = quotas.Key("alerting_rule_group_rules").MustInt64(100) + remoteAlertmanager := iniFile.Section("remote.alertmanager") uaCfgRemoteAM := RemoteAlertmanagerSettings{ Enable: remoteAlertmanager.Key("enabled").MustBool(false), @@ -395,6 +364,7 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { LokiTenantID: stateHistory.Key("loki_tenant_id").MustString(""), LokiBasicAuthUsername: stateHistory.Key("loki_basic_auth_username").MustString(""), LokiBasicAuthPassword: stateHistory.Key("loki_basic_auth_password").MustString(""), + LokiMaxQueryLength: stateHistory.Key("loki_max_query_length").MustDuration(lokiDefaultMaxQueryLength), MultiPrimary: stateHistory.Key("primary").MustString(""), MultiSecondaries: splitTrim(stateHistory.Key("secondaries").MustString(""), ","), ExternalLabels: stateHistoryLabels.KeysHash(), @@ -403,11 +373,10 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { uaCfg.MaxStateSaveConcurrency = ua.Key("max_state_save_concurrency").MustInt(1) - upgrade := iniFile.Section("unified_alerting.upgrade") - uaCfgUpgrade := UnifiedAlertingUpgradeSettings{ - CleanUpgrade: upgrade.Key("clean_upgrade").MustBool(false), + uaCfg.StatePeriodicSaveInterval, err = gtime.ParseDuration(valueAsString(ua, "state_periodic_save_interval", (time.Minute * 5).String())) + if err != nil { + return err } - uaCfg.Upgrade = uaCfgUpgrade cfg.UnifiedAlerting = uaCfg return nil diff --git a/pkg/setting/setting_unified_alerting_test.go b/pkg/setting/setting_unified_alerting_test.go index cbbb30bd64df7..4e7285203f675 100644 --- a/pkg/setting/setting_unified_alerting_test.go +++ b/pkg/setting/setting_unified_alerting_test.go @@ -93,7 +93,7 @@ func TestUnifiedAlertingSettings(t *testing.T) { alertingOptions: map[string]string{ "max_attempts": strconv.FormatInt(schedulerDefaultMaxAttempts, 10), "min_interval_seconds": strconv.FormatInt(schedulerDefaultLegacyMinInterval, 10), - "execute_alerts": strconv.FormatBool(schedulereDefaultExecuteAlerts), + "execute_alerts": strconv.FormatBool(schedulerDefaultExecuteAlerts), "evaluation_timeout_seconds": strconv.FormatInt(int64(evaluatorDefaultEvaluationTimeout.Seconds()), 10), }, verifyCfg: func(t *testing.T, cfg Cfg) { @@ -111,7 +111,7 @@ func TestUnifiedAlertingSettings(t *testing.T) { unifiedAlertingOptions: map[string]string{ "admin_config_poll_interval": "120s", "min_interval": SchedulerBaseInterval.String(), - "execute_alerts": strconv.FormatBool(schedulereDefaultExecuteAlerts), + "execute_alerts": strconv.FormatBool(schedulerDefaultExecuteAlerts), "evaluation_timeout": evaluatorDefaultEvaluationTimeout.String(), }, alertingOptions: map[string]string{ @@ -148,7 +148,7 @@ func TestUnifiedAlertingSettings(t *testing.T) { require.Equal(t, alertmanagerDefaultConfigPollInterval, cfg.UnifiedAlerting.AdminConfigPollInterval) require.Equal(t, int64(schedulerDefaultMaxAttempts), cfg.UnifiedAlerting.MaxAttempts) require.Equal(t, SchedulerBaseInterval, cfg.UnifiedAlerting.MinInterval) - require.Equal(t, schedulereDefaultExecuteAlerts, cfg.UnifiedAlerting.ExecuteAlerts) + require.Equal(t, schedulerDefaultExecuteAlerts, cfg.UnifiedAlerting.ExecuteAlerts) require.Equal(t, evaluatorDefaultEvaluationTimeout, cfg.UnifiedAlerting.EvaluationTimeout) require.Equal(t, SchedulerBaseInterval, cfg.UnifiedAlerting.BaseInterval) require.Equal(t, DefaultRuleEvaluationInterval, cfg.UnifiedAlerting.DefaultRuleEvaluationInterval) diff --git a/pkg/tests/api/alerting/api_admin_configuration_test.go b/pkg/tests/api/alerting/api_admin_configuration_test.go index 7ceae36e0b05f..ed601c60ff2cc 100644 --- a/pkg/tests/api/alerting/api_admin_configuration_test.go +++ b/pkg/tests/api/alerting/api_admin_configuration_test.go @@ -10,11 +10,13 @@ import ( "testing" "time" - "github.com/grafana/grafana/pkg/expr" + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/services/datasources" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -352,3 +354,49 @@ func TestIntegrationAdminConfiguration_SendingToExternalAlertmanagers(t *testing }, 16*time.Second, 8*time.Second) // the sync interval is 2s so after 8s all alertmanagers (if any) most probably are started } } + +func TestIntegrationAdminConfiguration_CannotCreateInhibitionRules(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + AppModeProduction: true, + }) + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) + createUser(t, store, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "admin", + Login: "admin", + }) + client := newAlertingApiClient(grafanaListedAddr, "admin", "admin") + + cfg := apimodels.PostableUserConfig{ + AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ + Config: apimodels.Config{ + Route: &apimodels.Route{ + Receiver: "test", + }, + InhibitRules: []config.InhibitRule{{ + SourceMatchers: config.Matchers{{ + Type: labels.MatchEqual, + Name: "foo", + Value: "bar", + }}, + TargetMatchers: config.Matchers{{ + Type: labels.MatchEqual, + Name: "bar", + Value: "baz", + }}, + }}, + }, + Receivers: []*apimodels.PostableApiReceiver{{ + Receiver: config.Receiver{ + Name: "test", + }, + }}, + }, + } + ok, err := client.PostConfiguration(t, cfg) + require.False(t, ok) + require.EqualError(t, err, "inhibition rules are not supported") +} diff --git a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go index cc9900e989f6f..52a2ad469418e 100644 --- a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go @@ -10,10 +10,14 @@ import ( "testing" "time" + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/pkg/labels" + "github.com/prometheus/alertmanager/timeinterval" + "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" @@ -21,6 +25,253 @@ import ( "github.com/grafana/grafana/pkg/tests/testinfra" ) +func TestIntegrationAlertmanagerConfiguration(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + AppModeProduction: true, + }) + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) + createUser(t, store, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "admin", + Login: "admin", + }) + client := newAlertingApiClient(grafanaListedAddr, "admin", "admin") + + cases := []struct { + name string + cfg apimodels.PostableUserConfig + expErr string + }{{ + name: "configuration with default route", + cfg: apimodels.PostableUserConfig{ + AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ + Config: apimodels.Config{ + Route: &apimodels.Route{ + Receiver: "test", + }, + }, + Receivers: []*apimodels.PostableApiReceiver{{ + Receiver: config.Receiver{ + Name: "test", + }, + }}, + }, + }, + }, { + name: "configuration with UTF-8 matchers", + cfg: apimodels.PostableUserConfig{ + AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ + Config: apimodels.Config{ + Route: &apimodels.Route{ + Receiver: "test", + Routes: []*apimodels.Route{{ + GroupBy: []model.LabelName{"foo🙂"}, + Matchers: config.Matchers{{ + Type: labels.MatchEqual, + Name: "foo🙂", + Value: "bar", + }, { + Type: labels.MatchNotEqual, + Name: "_bar1", + Value: "baz🙂", + }, { + Type: labels.MatchRegexp, + Name: "0baz", + Value: "[a-zA-Z0-9]+,?", + }, { + Type: labels.MatchNotRegexp, + Name: "corge", + Value: "^[0-9]+((,[0-9]{3})*(,[0-9]{0,3})?)?$", + }, { + Type: labels.MatchEqual, + Name: "Προμηθέας", // Prometheus in Greek + Value: "Prom", + }, { + Type: labels.MatchNotEqual, + Name: "犬", // Dog in Japanese + Value: "Shiba Inu", + }}, + }}, + }, + }, + Receivers: []*apimodels.PostableApiReceiver{{ + Receiver: config.Receiver{ + Name: "test", + }, + }}, + }, + }, + }, { + name: "configuration with UTF-8 object matchers", + cfg: apimodels.PostableUserConfig{ + AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ + Config: apimodels.Config{ + Route: &apimodels.Route{ + Receiver: "test", + Routes: []*apimodels.Route{{ + GroupBy: []model.LabelName{"foo🙂"}, + ObjectMatchers: apimodels.ObjectMatchers{{ + Type: labels.MatchEqual, + Name: "foo🙂", + Value: "bar", + }, { + Type: labels.MatchNotEqual, + Name: "_bar1", + Value: "baz🙂", + }, { + Type: labels.MatchRegexp, + Name: "0baz", + Value: "[a-zA-Z0-9]+,?", + }, { + Type: labels.MatchNotRegexp, + Name: "corge", + Value: "^[0-9]+((,[0-9]{3})*(,[0-9]{0,3})?)?$", + }, { + Type: labels.MatchEqual, + Name: "Προμηθέας", // Prometheus in Greek + Value: "Prom", + }, { + Type: labels.MatchNotEqual, + Name: "犬", // Dog in Japanese + Value: "Shiba Inu", + }}, + }}, + }, + }, + Receivers: []*apimodels.PostableApiReceiver{{ + Receiver: config.Receiver{ + Name: "test", + }, + }}, + }, + }, + }, { + name: "configuration with UTF-8 in both matchers and object matchers", + cfg: apimodels.PostableUserConfig{ + AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ + Config: apimodels.Config{ + Route: &apimodels.Route{ + Receiver: "test", + Routes: []*apimodels.Route{{ + GroupBy: []model.LabelName{"foo🙂"}, + Matchers: config.Matchers{{ + Type: labels.MatchEqual, + Name: "foo🙂", + Value: "bar", + }, { + Type: labels.MatchNotEqual, + Name: "_bar1", + Value: "baz🙂", + }, { + Type: labels.MatchRegexp, + Name: "0baz", + Value: "[a-zA-Z0-9]+,?", + }, { + Type: labels.MatchNotRegexp, + Name: "corge", + Value: "^[0-9]+((,[0-9]{3})*(,[0-9]{0,3})?)?$", + }}, + ObjectMatchers: apimodels.ObjectMatchers{{ + Type: labels.MatchEqual, + Name: "Προμηθέας", // Prometheus in Greek + Value: "Prom", + }, { + Type: labels.MatchNotEqual, + Name: "犬", // Dog in Japanese + Value: "Shiba Inu", + }}, + }}, + }, + }, + Receivers: []*apimodels.PostableApiReceiver{{ + Receiver: config.Receiver{ + Name: "test", + }, + }}, + }, + }, + }, { + // TODO: Mute time intervals is deprecated in Alertmanager and scheduled to be + // removed before version 1.0. Remove this test when support for mute time + // intervals is removed. + name: "configuration with mute time intervals", + cfg: apimodels.PostableUserConfig{ + AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ + Config: apimodels.Config{ + Route: &apimodels.Route{ + Receiver: "test", + Routes: []*apimodels.Route{{ + MuteTimeIntervals: []string{"weekends"}, + }}, + }, + MuteTimeIntervals: []config.MuteTimeInterval{{ + Name: "weekends", + TimeIntervals: []timeinterval.TimeInterval{{ + Weekdays: []timeinterval.WeekdayRange{{ + InclusiveRange: timeinterval.InclusiveRange{ + Begin: 1, + End: 5, + }, + }}, + }}, + }}, + }, + Receivers: []*apimodels.PostableApiReceiver{{ + Receiver: config.Receiver{ + Name: "test", + }, + }}, + }, + }, + }, { + name: "configuration with time intervals", + cfg: apimodels.PostableUserConfig{ + AlertmanagerConfig: apimodels.PostableApiAlertingConfig{ + Config: apimodels.Config{ + Route: &apimodels.Route{ + Receiver: "test", + Routes: []*apimodels.Route{{ + MuteTimeIntervals: []string{"weekends"}, + }}, + }, + TimeIntervals: []config.TimeInterval{{ + Name: "weekends", + TimeIntervals: []timeinterval.TimeInterval{{ + Weekdays: []timeinterval.WeekdayRange{{ + InclusiveRange: timeinterval.InclusiveRange{ + Begin: 1, + End: 5, + }, + }}, + }}, + }}, + }, + Receivers: []*apimodels.PostableApiReceiver{{ + Receiver: config.Receiver{ + Name: "test", + }, + }}, + }, + }, + }} + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ok, err := client.PostConfiguration(t, tc.cfg) + if tc.expErr != "" { + require.EqualError(t, err, tc.expErr) + require.False(t, ok) + } else { + require.NoError(t, err) + require.True(t, ok) + } + }) + } +} + func TestIntegrationAlertmanagerConfigurationIsTransactional(t *testing.T) { testinfra.SQLiteIntegrationTest(t) @@ -231,7 +482,7 @@ func TestIntegrationAlertmanagerConfigurationPersistSecrets(t *testing.T) { // The secure settings must be present { resp := getRequest(t, alertConfigURL, http.StatusOK) // nolint - var c definitions.GettableUserConfig + var c apimodels.GettableUserConfig bb := getBody(t, resp.Body) err := json.Unmarshal([]byte(bb), &c) require.NoError(t, err) diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index 2a8e48b2dc675..4ab44c164dae7 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -12,13 +12,13 @@ import ( "testing" "time" + "github.com/go-openapi/strfmt" + amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/expr" - "github.com/grafana/grafana/pkg/util/errutil" - apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -31,6 +31,8 @@ import ( "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/util/errutil" ) type Response struct { @@ -704,7 +706,7 @@ func TestIntegrationDeleteFolderWithRules(t *testing.T) { apiClient := newAlertingApiClient(grafanaListedAddr, "editor", "editor") // Create the namespace we'll save our alerts to. - namespaceUID := "default" + namespaceUID := "default" //nolint:goconst apiClient.CreateFolder(t, namespaceUID, namespaceUID) createRule(t, apiClient, "default") @@ -1842,6 +1844,155 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { } } +func TestIntegrationAlertmanagerCreateSilence(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + AppModeProduction: true, + }) + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) + createUser(t, store, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "admin", + Login: "admin", + }) + client := newAlertingApiClient(grafanaListedAddr, "admin", "admin") + + cases := []struct { + name string + silence apimodels.PostableSilence + expErr string + }{{ + name: "can create silence for foo=bar", + silence: apimodels.PostableSilence{ + Silence: amv2.Silence{ + Comment: util.Pointer("This is a comment"), + CreatedBy: util.Pointer("test"), + EndsAt: util.Pointer(strfmt.DateTime(time.Now().Add(time.Minute))), + Matchers: amv2.Matchers{{ + IsEqual: util.Pointer(true), + IsRegex: util.Pointer(false), + Name: util.Pointer("foo"), + Value: util.Pointer("bar"), + }}, + StartsAt: util.Pointer(strfmt.DateTime(time.Now())), + }, + }, + }, { + name: "can create silence for _foo1=bar", + silence: apimodels.PostableSilence{ + Silence: amv2.Silence{ + Comment: util.Pointer("This is a comment"), + CreatedBy: util.Pointer("test"), + EndsAt: util.Pointer(strfmt.DateTime(time.Now().Add(time.Minute))), + Matchers: amv2.Matchers{{ + IsEqual: util.Pointer(true), + IsRegex: util.Pointer(false), + Name: util.Pointer("_foo1"), + Value: util.Pointer("bar"), + }}, + StartsAt: util.Pointer(strfmt.DateTime(time.Now())), + }, + }, + }, { + name: "can create silence for 0foo=bar", + silence: apimodels.PostableSilence{ + Silence: amv2.Silence{ + Comment: util.Pointer("This is a comment"), + CreatedBy: util.Pointer("test"), + EndsAt: util.Pointer(strfmt.DateTime(time.Now().Add(time.Minute))), + Matchers: amv2.Matchers{{ + IsEqual: util.Pointer(true), + IsRegex: util.Pointer(false), + Name: util.Pointer("0foo"), + Value: util.Pointer("bar"), + }}, + StartsAt: util.Pointer(strfmt.DateTime(time.Now())), + }, + }, + }, { + name: "can create silence for foo=🙂bar", + silence: apimodels.PostableSilence{ + Silence: amv2.Silence{ + Comment: util.Pointer("This is a comment"), + CreatedBy: util.Pointer("test"), + EndsAt: util.Pointer(strfmt.DateTime(time.Now().Add(time.Minute))), + Matchers: amv2.Matchers{{ + IsEqual: util.Pointer(true), + IsRegex: util.Pointer(false), + Name: util.Pointer("foo"), + Value: util.Pointer("🙂bar"), + }}, + StartsAt: util.Pointer(strfmt.DateTime(time.Now())), + }, + }, + }, { + name: "can create silence for foo🙂=bar", + silence: apimodels.PostableSilence{ + Silence: amv2.Silence{ + Comment: util.Pointer("This is a comment"), + CreatedBy: util.Pointer("test"), + EndsAt: util.Pointer(strfmt.DateTime(time.Now().Add(time.Minute))), + Matchers: amv2.Matchers{{ + IsEqual: util.Pointer(true), + IsRegex: util.Pointer(false), + Name: util.Pointer("foo🙂"), + Value: util.Pointer("bar"), + }}, + StartsAt: util.Pointer(strfmt.DateTime(time.Now())), + }, + }, + }, { + name: "can't create silence for missing label name", + silence: apimodels.PostableSilence{ + Silence: amv2.Silence{ + Comment: util.Pointer("This is a comment"), + CreatedBy: util.Pointer("test"), + EndsAt: util.Pointer(strfmt.DateTime(time.Now().Add(time.Minute))), + Matchers: amv2.Matchers{{ + IsEqual: util.Pointer(true), + IsRegex: util.Pointer(false), + Name: util.Pointer(""), + Value: util.Pointer("bar"), + }}, + StartsAt: util.Pointer(strfmt.DateTime(time.Now())), + }, + }, + expErr: "unable to save silence: silence invalid: invalid label matcher 0: invalid label name \"\": unable to create silence", + }, { + name: "can't create silence for missing label value", + silence: apimodels.PostableSilence{ + Silence: amv2.Silence{ + Comment: util.Pointer("This is a comment"), + CreatedBy: util.Pointer("test"), + EndsAt: util.Pointer(strfmt.DateTime(time.Now().Add(time.Minute))), + Matchers: amv2.Matchers{{ + IsEqual: util.Pointer(true), + IsRegex: util.Pointer(false), + Name: util.Pointer("foo"), + Value: util.Pointer(""), + }}, + StartsAt: util.Pointer(strfmt.DateTime(time.Now())), + }, + }, + expErr: "unable to save silence: silence invalid: at least one matcher must not match the empty string: unable to create silence", + }} + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + silenceID, err := client.PostSilence(t, tc.silence) + if tc.expErr != "" { + require.EqualError(t, err, tc.expErr) + require.Empty(t, silenceID) + } else { + require.NoError(t, err) + require.NotEmpty(t, silenceID) + } + }) + } +} + func TestIntegrationAlertmanagerStatus(t *testing.T) { testinfra.SQLiteIntegrationTest(t) diff --git a/pkg/tests/api/alerting/api_backtesting_test.go b/pkg/tests/api/alerting/api_backtesting_test.go index de83add0e0f55..08c60978bb729 100644 --- a/pkg/tests/api/alerting/api_backtesting_test.go +++ b/pkg/tests/api/alerting/api_backtesting_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/datasources" @@ -92,9 +93,10 @@ func TestBacktesting(t *testing.T) { t.Run("if user does not have permissions", func(t *testing.T) { testUserId := createUser(t, env.SQLStore, user.CreateUserCommand{ - DefaultOrgRole: "", + DefaultOrgRole: string(roletype.RoleNone), Password: "test", Login: "test", + OrgID: 1, }) testUserApiCli := newAlertingApiClient(grafanaListedAddr, "test", "test") diff --git a/pkg/tests/api/alerting/api_notifications_time_interval_test.go b/pkg/tests/api/alerting/api_notifications_time_interval_test.go new file mode 100644 index 0000000000000..76b890ecdc524 --- /dev/null +++ b/pkg/tests/api/alerting/api_notifications_time_interval_test.go @@ -0,0 +1,204 @@ +package alerting + +import ( + "net/http" + "slices" + "strings" + "testing" + "time" + + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/timeinterval" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/util" +) + +func TestTimeInterval(t *testing.T) { + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + }) + + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) + + createUser(t, store, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "admin", + Login: "admin", + }) + + apiClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin") + + t.Run("default config should return empty list", func(t *testing.T) { + mt, status, body := apiClient.GetAllTimeIntervalsWithStatus(t) + requireStatusCode(t, http.StatusOK, status, body) + require.Empty(t, mt) + }) + + emptyTimeInterval := definitions.PostableTimeIntervals{ + Name: "Empty Mute Timing", + TimeIntervals: []definitions.TimeIntervalItem{}, + } + + func() { + // TODO replace with Time-Interval later + emptyMuteTiming := definitions.MuteTimeInterval{ + MuteTimeInterval: config.MuteTimeInterval{ + Name: "Empty Mute Timing", + TimeIntervals: []timeinterval.TimeInterval{}, + }, + } + + // TODO replace with create interval API + // t.Run("should create a new mute timing without any intervals", func(t *testing.T) { + mt, status, body := apiClient.CreateMuteTimingWithStatus(t, emptyMuteTiming) + requireStatusCode(t, http.StatusCreated, status, body) + require.Equal(t, emptyMuteTiming.MuteTimeInterval, mt.MuteTimeInterval) + require.EqualValues(t, models.ProvenanceAPI, mt.Provenance) + // }) + + anotherMuteTiming := definitions.MuteTimeInterval{ + MuteTimeInterval: config.MuteTimeInterval{ + Name: "Not Empty Mute Timing", + TimeIntervals: []timeinterval.TimeInterval{ + { + Times: []timeinterval.TimeRange{ + { + StartMinute: 10, + EndMinute: 45, + }, + }, + Weekdays: []timeinterval.WeekdayRange{ + { + InclusiveRange: timeinterval.InclusiveRange{ + Begin: 0, + End: 2, + }, + }, + { + InclusiveRange: timeinterval.InclusiveRange{ + Begin: 4, + End: 5, + }, + }, + }, + DaysOfMonth: []timeinterval.DayOfMonthRange{ + { + InclusiveRange: timeinterval.InclusiveRange{ + Begin: 1, + End: 7, + }, + }, + { + InclusiveRange: timeinterval.InclusiveRange{ + Begin: 14, + End: 28, + }, + }, + }, + Months: []timeinterval.MonthRange{ + { + InclusiveRange: timeinterval.InclusiveRange{ + Begin: 1, + End: 5, + }, + }, + }, + Years: []timeinterval.YearRange{ + { + InclusiveRange: timeinterval.InclusiveRange{ + Begin: 2024, + End: 2025, + }, + }, + }, + Location: &timeinterval.Location{ + Location: time.UTC, + }, + }, + }, + }, + } + + // t.Run("should create a new mute timing with some settings", func(t *testing.T) { + mt, status, body = apiClient.CreateMuteTimingWithStatus(t, anotherMuteTiming) + requireStatusCode(t, http.StatusCreated, status, body) + require.Equal(t, anotherMuteTiming.MuteTimeInterval, mt.MuteTimeInterval) + require.EqualValues(t, models.ProvenanceAPI, mt.Provenance) + // }) + }() + + anotherTimeInterval := definitions.PostableTimeIntervals{ + Name: "Not Empty Mute Timing", + TimeIntervals: []definitions.TimeIntervalItem{ + { + Times: []definitions.TimeIntervalTimeRange{ + { + StartMinute: "00:10", + EndMinute: "00:45", + }, + }, + Weekdays: util.Pointer([]string{ + "sunday:tuesday", + "thursday:friday", + }), + DaysOfMonth: util.Pointer([]string{ + "1:7", + "14:28", + }), + Months: util.Pointer([]string{ + "1:5", + }), + Years: util.Pointer([]string{ + "2024:2025", + }), + Location: util.Pointer("UTC"), + }, + }, + } + + t.Run("should return time interval by name", func(t *testing.T) { + ti, status, body := apiClient.GetTimeIntervalByNameWithStatus(t, emptyTimeInterval.Name) + requireStatusCode(t, http.StatusOK, status, body) + require.Equal(t, emptyTimeInterval.TimeIntervals, ti.TimeIntervals) + require.Equal(t, emptyTimeInterval.Name, ti.Name) + require.EqualValues(t, models.ProvenanceAPI, ti.Provenance) + + ti, status, body = apiClient.GetTimeIntervalByNameWithStatus(t, anotherTimeInterval.Name) + requireStatusCode(t, http.StatusOK, status, body) + require.Equal(t, anotherTimeInterval.TimeIntervals, ti.TimeIntervals) + require.Equal(t, anotherTimeInterval.Name, ti.Name) + require.EqualValues(t, models.ProvenanceAPI, ti.Provenance) + }) + + t.Run("should return NotFound if time interval does not exist", func(t *testing.T) { + _, status, body := apiClient.GetTimeIntervalByNameWithStatus(t, "some-missing-timing") + requireStatusCode(t, http.StatusNotFound, status, body) + }) + + t.Run("should return all mute timings", func(t *testing.T) { + mt, status, body := apiClient.GetAllTimeIntervalsWithStatus(t) + requireStatusCode(t, http.StatusOK, status, body) + require.Len(t, mt, 2) + + slices.SortFunc(mt, func(a, b definitions.GettableTimeIntervals) int { + return strings.Compare(a.Name, b.Name) + }) + + require.Equal(t, emptyTimeInterval.TimeIntervals, mt[0].TimeIntervals) + require.Equal(t, emptyTimeInterval.Name, mt[0].Name) + require.EqualValues(t, models.ProvenanceAPI, mt[0].Provenance) + + require.Equal(t, anotherTimeInterval.TimeIntervals, mt[1].TimeIntervals) + require.Equal(t, anotherTimeInterval.Name, mt[1].Name) + require.EqualValues(t, models.ProvenanceAPI, mt[1].Provenance) + }) +} diff --git a/pkg/tests/api/alerting/api_provisioning_test.go b/pkg/tests/api/alerting/api_provisioning_test.go index 6346b2689bb69..c0f7e6a48084a 100644 --- a/pkg/tests/api/alerting/api_provisioning_test.go +++ b/pkg/tests/api/alerting/api_provisioning_test.go @@ -21,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/util/errutil" ) func TestIntegrationProvisioning(t *testing.T) { @@ -456,12 +457,12 @@ func TestMuteTimings(t *testing.T) { mt, status, body := apiClient.GetMuteTimingByNameWithStatus(t, emptyMuteTiming.Name) requireStatusCode(t, http.StatusOK, status, body) require.Equal(t, emptyMuteTiming.MuteTimeInterval, mt.MuteTimeInterval) - require.EqualValues(t, "", mt.Provenance) // TODO this is a bug + require.EqualValues(t, models.ProvenanceAPI, mt.Provenance) mt, status, body = apiClient.GetMuteTimingByNameWithStatus(t, anotherMuteTiming.Name) requireStatusCode(t, http.StatusOK, status, body) require.Equal(t, anotherMuteTiming.MuteTimeInterval, mt.MuteTimeInterval) - require.EqualValues(t, "", mt.Provenance) // TODO this is a bug + require.EqualValues(t, models.ProvenanceAPI, mt.Provenance) }) t.Run("should return NotFound if mute timing does not exist", func(t *testing.T) { @@ -479,10 +480,10 @@ func TestMuteTimings(t *testing.T) { }) require.Equal(t, emptyMuteTiming.MuteTimeInterval, mt[0].MuteTimeInterval) - require.EqualValues(t, "", mt[0].Provenance) // TODO this is a bug + require.EqualValues(t, models.ProvenanceAPI, mt[0].Provenance) require.Equal(t, anotherMuteTiming.MuteTimeInterval, mt[1].MuteTimeInterval) - require.EqualValues(t, "", mt[1].Provenance) // TODO this is a bug + require.EqualValues(t, models.ProvenanceAPI, mt[1].Provenance) }) t.Run("should get BadRequest if creates a new mute timing with the same name", func(t *testing.T) { @@ -491,9 +492,10 @@ func TestMuteTimings(t *testing.T) { _, status, body := apiClient.CreateMuteTimingWithStatus(t, m) t.Log(body) requireStatusCode(t, http.StatusBadRequest, status, body) - var validationError map[string]any + var validationError errutil.PublicError assert.NoError(t, json.Unmarshal([]byte(body), &validationError)) - assert.Contains(t, validationError, "message") + assert.NotEmpty(t, validationError, validationError.Message) + assert.Equal(t, "alerting.notifications.time-intervals.nameExists", validationError.MessageID) if t.Failed() { t.Fatalf("response: %s", body) } @@ -585,7 +587,7 @@ func TestMuteTimings(t *testing.T) { requireStatusCode(t, http.StatusNotFound, status, body) }) - t.Run("should get BadRequest if deletes used mute-timing", func(t *testing.T) { + t.Run("should get 409 Conflict if deletes used mute-timing", func(t *testing.T) { route, status, response := apiClient.GetRouteWithStatus(t) requireStatusCode(t, http.StatusOK, status, response) route.Routes = append(route.Routes, &definitions.Route{ @@ -602,7 +604,14 @@ func TestMuteTimings(t *testing.T) { requireStatusCode(t, http.StatusAccepted, status, response) status, response = apiClient.DeleteMuteTimingWithStatus(t, anotherMuteTiming.Name) - requireStatusCode(t, http.StatusInternalServerError, status, response) // TODO should be bad request + requireStatusCode(t, http.StatusConflict, status, response) + var validationError errutil.PublicError + assert.NoError(t, json.Unmarshal([]byte(response), &validationError)) + assert.NotEmpty(t, validationError, validationError.Message) + assert.Equal(t, "alerting.notifications.time-intervals.used", validationError.MessageID) + if t.Failed() { + t.Fatalf("response: %s", response) + } }) } diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index aa73861b1317b..cf9186d8f87fd 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -9,12 +9,15 @@ import ( "math/rand" "net/http" "path" + "slices" "strings" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/google/uuid" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -320,6 +323,360 @@ func TestIntegrationAlertRulePermissions(t *testing.T) { }) } +func TestIntegrationAlertRuleNestedPermissions(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + + // Setup Grafana and its Database + dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{featuremgmt.FlagNestedFolders}, + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + }) + + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, p) + permissionsStore := resourcepermissions.NewStore(store, featuremgmt.WithFeatures()) + + // Create a user to make authenticated requests + userID := createUser(t, store, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + Password: "password", + Login: "grafana", + }) + + apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") + + // Create the namespace we'll save our alerts to. + apiClient.CreateFolder(t, "folder1", "folder1") + // Create the namespace we'll save our alerts to. + apiClient.CreateFolder(t, "folder2", "folder2") + // Create a subfolder + apiClient.CreateFolder(t, "subfolder", "subfolder", "folder1") + + postGroupRaw, err := testData.ReadFile(path.Join("test-data", "rulegroup-1-post.json")) + require.NoError(t, err) + var group1 apimodels.PostableRuleGroupConfig + require.NoError(t, json.Unmarshal(postGroupRaw, &group1)) + + // Create rule under folder1 + _, status, response := apiClient.PostRulesGroupWithStatus(t, "folder1", &group1) + require.Equalf(t, http.StatusAccepted, status, response) + + postGroupRaw, err = testData.ReadFile(path.Join("test-data", "rulegroup-2-post.json")) + require.NoError(t, err) + var group2 apimodels.PostableRuleGroupConfig + require.NoError(t, json.Unmarshal(postGroupRaw, &group2)) + + // Create rule under folder2 + _, status, response = apiClient.PostRulesGroupWithStatus(t, "folder2", &group2) + require.Equalf(t, http.StatusAccepted, status, response) + + postGroupRaw, err = testData.ReadFile(path.Join("test-data", "rulegroup-3-post.json")) + require.NoError(t, err) + var group3 apimodels.PostableRuleGroupConfig + require.NoError(t, json.Unmarshal(postGroupRaw, &group3)) + + // Create rule under subfolder + _, status, response = apiClient.PostRulesGroupWithStatus(t, "subfolder", &group3) + require.Equalf(t, http.StatusAccepted, status, response) + + // With the rules created, let's make sure that rule definitions are stored. + allRules, status, _ := apiClient.GetAllRulesWithStatus(t) + require.Equal(t, http.StatusOK, status) + status, allExportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{ + ExportQueryParams: apimodels.ExportQueryParams{Format: "json"}, + }) + require.Equal(t, http.StatusOK, status) + var allExport apimodels.AlertingFileExport + require.NoError(t, json.Unmarshal([]byte(allExportRaw), &allExport)) + + t.Run("when user has all permissions", func(t *testing.T) { + t.Run("Get all returns all rules", func(t *testing.T) { + var group1, group2, group3 apimodels.GettableRuleGroupConfig + + getGroup1Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-1-get.json")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(getGroup1Raw, &group1)) + getGroup2Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-2-get.json")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(getGroup2Raw, &group2)) + getGroup3Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-3-get.json")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(getGroup3Raw, &group3)) + + expected := apimodels.NamespaceConfigResponse{ + "folder1": []apimodels.GettableRuleGroupConfig{ + group1, + }, + "folder2": []apimodels.GettableRuleGroupConfig{ + group2, + }, + "folder1/subfolder": []apimodels.GettableRuleGroupConfig{ + group3, + }, + } + + pathsToIgnore := []string{ + "GrafanaManagedAlert.Updated", + "GrafanaManagedAlert.UID", + "GrafanaManagedAlert.ID", + "GrafanaManagedAlert.Data.Model", + "GrafanaManagedAlert.NamespaceUID", + "GrafanaManagedAlert.NamespaceID", + } + + // compare expected and actual and ignore the dynamic fields + diff := cmp.Diff(expected, allRules, cmp.FilterPath(func(path cmp.Path) bool { + for _, s := range pathsToIgnore { + if strings.Contains(path.String(), s) { + return true + } + } + return false + }, cmp.Ignore())) + + require.Empty(t, diff) + + for _, rule := range allRules["folder1"][0].Rules { + assert.Equal(t, "folder1", rule.GrafanaManagedAlert.NamespaceUID) + } + + for _, rule := range allRules["folder2"][0].Rules { + assert.Equal(t, "folder2", rule.GrafanaManagedAlert.NamespaceUID) + } + + for _, rule := range allRules["folder1/subfolder"][0].Rules { + assert.Equal(t, "subfolder", rule.GrafanaManagedAlert.NamespaceUID) + } + }) + + t.Run("Get by folder returns groups in folder", func(t *testing.T) { + rules, status, _ := apiClient.GetAllRulesGroupInFolderWithStatus(t, "folder1") + require.Equal(t, http.StatusAccepted, status) + require.Contains(t, rules, "folder1") + require.Len(t, rules["folder1"], 1) + require.Equal(t, allRules["folder1"], rules["folder1"]) + }) + + t.Run("Get group returns a single group", func(t *testing.T) { + rules := apiClient.GetRulesGroup(t, "folder2", allRules["folder2"][0].Name) + cmp.Diff(allRules["folder2"][0], rules.GettableRuleGroupConfig) + }) + + t.Run("Get by folder returns groups in folder with nested folder format", func(t *testing.T) { + rules, status, _ := apiClient.GetAllRulesGroupInFolderWithStatus(t, "subfolder") + require.Equal(t, http.StatusAccepted, status) + + nestedKey := "folder1/subfolder" + require.Contains(t, rules, nestedKey) + require.Len(t, rules[nestedKey], 1) + require.Equal(t, allRules[nestedKey], rules[nestedKey]) + }) + + t.Run("Export returns all rules", func(t *testing.T) { + var group1File, group2File, group3File apimodels.AlertingFileExport + getGroup1Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-1-export.json")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(getGroup1Raw, &group1File)) + getGroup2Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-2-export.json")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(getGroup2Raw, &group2File)) + getGroup3Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-3-export.json")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(getGroup3Raw, &group3File)) + + group1File.Groups = append(group1File.Groups, group2File.Groups...) + group1File.Groups = append(group1File.Groups, group3File.Groups...) + expected := group1File + + pathsToIgnore := []string{ + "Groups.Rules.UID", + "Groups.Folder", + } + + // compare expected and actual and ignore the dynamic fields + diff := cmp.Diff(expected, allExport, cmp.FilterPath(func(path cmp.Path) bool { + for _, s := range pathsToIgnore { + if strings.Contains(path.String(), s) { + return true + } + } + return false + }, cmp.Ignore())) + + require.Empty(t, diff) + + require.Equal(t, "folder1", allExport.Groups[0].Folder) + require.Equal(t, "folder2", allExport.Groups[1].Folder) + require.Equal(t, "subfolder", allExport.Groups[2].Folder) + }) + + t.Run("Export from one folder", func(t *testing.T) { + expected := allExport.Groups[0] + status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{ + ExportQueryParams: apimodels.ExportQueryParams{Format: "json"}, + FolderUID: []string{"folder1"}, + }) + require.Equal(t, http.StatusOK, status) + var export apimodels.AlertingFileExport + require.NoError(t, json.Unmarshal([]byte(exportRaw), &export)) + + require.Len(t, export.Groups, 1) + require.Equal(t, expected, export.Groups[0]) + }) + + t.Run("Export from a subfolder", func(t *testing.T) { + expected := allExport.Groups[2] + status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{ + ExportQueryParams: apimodels.ExportQueryParams{Format: "json"}, + FolderUID: []string{"subfolder"}, + }) + require.Equal(t, http.StatusOK, status) + var export apimodels.AlertingFileExport + require.NoError(t, json.Unmarshal([]byte(exportRaw), &export)) + + require.Len(t, export.Groups, 1) + require.Equal(t, expected, export.Groups[0]) + }) + + t.Run("Export from one group", func(t *testing.T) { + expected := allExport.Groups[0] + status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{ + ExportQueryParams: apimodels.ExportQueryParams{Format: "json"}, + FolderUID: []string{"folder1"}, + GroupName: expected.Name, + }) + require.Equal(t, http.StatusOK, status) + var export apimodels.AlertingFileExport + require.NoError(t, json.Unmarshal([]byte(exportRaw), &export)) + + require.Len(t, export.Groups, 1) + require.Equal(t, expected, export.Groups[0]) + }) + + t.Run("Export from one group under subfolder", func(t *testing.T) { + expected := allExport.Groups[2] + status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{ + ExportQueryParams: apimodels.ExportQueryParams{Format: "json"}, + FolderUID: []string{"subfolder"}, + GroupName: expected.Name, + }) + require.Equal(t, http.StatusOK, status) + var export apimodels.AlertingFileExport + require.NoError(t, json.Unmarshal([]byte(exportRaw), &export)) + + require.Len(t, export.Groups, 1) + require.Equal(t, expected, export.Groups[0]) + }) + + t.Run("Export single rule", func(t *testing.T) { + expected := allExport.Groups[0] + expected.Rules = []apimodels.AlertRuleExport{ + expected.Rules[0], + } + status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{ + ExportQueryParams: apimodels.ExportQueryParams{Format: "json"}, + RuleUID: expected.Rules[0].UID, + }) + + require.Equal(t, http.StatusOK, status) + var export apimodels.AlertingFileExport + t.Log(exportRaw) + require.NoError(t, json.Unmarshal([]byte(exportRaw), &export)) + + require.Len(t, export.Groups, 1) + require.Equal(t, expected, export.Groups[0]) + }) + }) + + t.Run("when permissions for folder2 removed", func(t *testing.T) { + // remove permissions for folder2 + removeFolderPermission(t, permissionsStore, 1, userID, org.RoleEditor, "folder2") + // remove permissions for subfolder (inherits from folder1) + removeFolderPermission(t, permissionsStore, 1, userID, org.RoleEditor, "subfolder") + apiClient.ReloadCachedPermissions(t) + + t.Run("Get all returns all rules", func(t *testing.T) { + newAll, status, _ := apiClient.GetAllRulesWithStatus(t) + require.Equal(t, http.StatusOK, status) + require.Contains(t, newAll, "folder1") + require.NotContains(t, newAll, "folder2") + require.Contains(t, newAll, "folder1/subfolder") + }) + + t.Run("Get by folder returns groups in folder", func(t *testing.T) { + _, status, _ := apiClient.GetAllRulesGroupInFolderWithStatus(t, "folder2") + require.Equal(t, http.StatusForbidden, status) + }) + + t.Run("Get group returns a single group", func(t *testing.T) { + u := fmt.Sprintf("%s/api/ruler/grafana/api/v1/rules/folder2/arulegroup", apiClient.url) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + defer func() { + _ = resp.Body.Close() + }() + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + + t.Run("Export returns all rules", func(t *testing.T) { + status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{ + ExportQueryParams: apimodels.ExportQueryParams{Format: "json"}, + }) + require.Equal(t, http.StatusOK, status) + var export apimodels.AlertingFileExport + require.NoError(t, json.Unmarshal([]byte(exportRaw), &export)) + + require.Equal(t, http.StatusOK, status) + require.Len(t, export.Groups, 2) + require.Equal(t, "folder1", export.Groups[0].Folder) + require.Equal(t, "subfolder", export.Groups[1].Folder) + }) + + t.Run("Export from one folder", func(t *testing.T) { + status, _ := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{ + ExportQueryParams: apimodels.ExportQueryParams{Format: "json"}, + FolderUID: []string{"folder2"}, + }) + assert.Equal(t, http.StatusForbidden, status) + }) + + t.Run("Export from one group", func(t *testing.T) { + status, _ := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{ + ExportQueryParams: apimodels.ExportQueryParams{Format: "json"}, + FolderUID: []string{"folder2"}, + GroupName: "arulegroup", + }) + assert.Equal(t, http.StatusForbidden, status) + }) + + t.Run("Export single rule", func(t *testing.T) { + uid := allRules["folder2"][0].Rules[0].GrafanaManagedAlert.UID + status, _ := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{ + ExportQueryParams: apimodels.ExportQueryParams{Format: "json"}, + RuleUID: uid, + }) + require.Equal(t, http.StatusForbidden, status) + }) + + t.Run("when all permissions are revoked", func(t *testing.T) { + removeFolderPermission(t, permissionsStore, 1, userID, org.RoleEditor, "folder1") + apiClient.ReloadCachedPermissions(t) + + rules, status, _ := apiClient.GetAllRulesWithStatus(t) + require.Equal(t, http.StatusOK, status) + require.Empty(t, rules) + + status, _ = apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{ + ExportQueryParams: apimodels.ExportQueryParams{Format: "json"}, + }) + require.Equal(t, http.StatusNotFound, status) + }) + }) +} + func createRule(t *testing.T, client apiClient, folder string) (apimodels.PostableRuleGroupConfig, string) { t.Helper() @@ -486,11 +843,11 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) { rulesWithUID.Rules = append(rulesWithUID.Rules, rules.Rules[0]) // Create new copy of first rule. _, status, body := apiClient.PostRulesGroupWithStatus(t, "folder1", &rulesWithUID) - assert.Equal(t, http.StatusInternalServerError, status) + assert.Equal(t, http.StatusConflict, status) var res map[string]any require.NoError(t, json.Unmarshal([]byte(body), &res)) - require.Equal(t, "failed to update rule group: failed to add rules: a conflicting alert rule is found: rule title under the same organisation and folder should be unique", res["message"]) + require.Contains(t, res["message"], ngmodels.ErrAlertRuleUniqueConstraintViolation.Error()) }) t.Run("trying to update an alert to the title of an existing alert in the same folder should fail", func(t *testing.T) { @@ -498,11 +855,11 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) { rulesWithUID.Rules[1].GrafanaManagedAlert.Title = "AlwaysFiring" _, status, body := apiClient.PostRulesGroupWithStatus(t, "folder1", &rulesWithUID) - assert.Equal(t, http.StatusInternalServerError, status) + assert.Equal(t, http.StatusConflict, status) var res map[string]any require.NoError(t, json.Unmarshal([]byte(body), &res)) - require.Equal(t, "failed to update rule group: failed to update rules: a conflicting alert rule is found: rule title under the same organisation and folder should be unique", res["message"]) + require.Contains(t, res["message"], ngmodels.ErrAlertRuleUniqueConstraintViolation.Error()) }) t.Run("trying to create alert with same title under another folder should succeed", func(t *testing.T) { @@ -894,19 +1251,21 @@ func TestIntegrationRuleGroupSequence(t *testing.T) { }) client := newAlertingApiClient(grafanaListedAddr, "grafana", "password") - folder1Title := "folder1" - client.CreateFolder(t, util.GenerateShortUID(), folder1Title) + parentFolderUID := util.GenerateShortUID() + client.CreateFolder(t, parentFolderUID, "parent") + folderUID := util.GenerateShortUID() + client.CreateFolder(t, folderUID, "folder1", parentFolderUID) group1 := generateAlertRuleGroup(5, alertRuleGen()) group2 := generateAlertRuleGroup(5, alertRuleGen()) - _, status, _ := client.PostRulesGroupWithStatus(t, folder1Title, &group1) + _, status, _ := client.PostRulesGroupWithStatus(t, folderUID, &group1) require.Equal(t, http.StatusAccepted, status) - _, status, _ = client.PostRulesGroupWithStatus(t, folder1Title, &group2) + _, status, _ = client.PostRulesGroupWithStatus(t, folderUID, &group2) require.Equal(t, http.StatusAccepted, status) t.Run("should persist order of the rules in a group", func(t *testing.T) { - group1Get := client.GetRulesGroup(t, folder1Title, group1.Name) + group1Get := client.GetRulesGroup(t, folderUID, group1.Name) assert.Equal(t, group1.Name, group1Get.Name) assert.Equal(t, group1.Interval, group1Get.Interval) assert.Len(t, group1Get.Rules, len(group1.Rules)) @@ -925,10 +1284,10 @@ func TestIntegrationRuleGroupSequence(t *testing.T) { for _, rule := range postableGroup1.Rules { expectedUids = append(expectedUids, rule.GrafanaManagedAlert.UID) } - _, status, _ := client.PostRulesGroupWithStatus(t, folder1Title, &postableGroup1) + _, status, _ := client.PostRulesGroupWithStatus(t, folderUID, &postableGroup1) require.Equal(t, http.StatusAccepted, status) - group1Get = client.GetRulesGroup(t, folder1Title, group1.Name) + group1Get = client.GetRulesGroup(t, folderUID, group1.Name) require.Len(t, group1Get.Rules, len(postableGroup1.Rules)) @@ -940,8 +1299,8 @@ func TestIntegrationRuleGroupSequence(t *testing.T) { }) t.Run("should be able to move a rule from another group in a specific position", func(t *testing.T) { - group1Get := client.GetRulesGroup(t, folder1Title, group1.Name) - group2Get := client.GetRulesGroup(t, folder1Title, group2.Name) + group1Get := client.GetRulesGroup(t, folderUID, group1.Name) + group2Get := client.GetRulesGroup(t, folderUID, group2.Name) movedRule := convertGettableRuleToPostable(group2Get.Rules[3]) // now shuffle the rules @@ -951,10 +1310,10 @@ func TestIntegrationRuleGroupSequence(t *testing.T) { for _, rule := range postableGroup1.Rules { expectedUids = append(expectedUids, rule.GrafanaManagedAlert.UID) } - _, status, _ := client.PostRulesGroupWithStatus(t, folder1Title, &postableGroup1) + _, status, _ := client.PostRulesGroupWithStatus(t, folderUID, &postableGroup1) require.Equal(t, http.StatusAccepted, status) - group1Get = client.GetRulesGroup(t, folder1Title, group1.Name) + group1Get = client.GetRulesGroup(t, folderUID, group1.Name) require.Len(t, group1Get.Rules, len(postableGroup1.Rules)) @@ -964,7 +1323,7 @@ func TestIntegrationRuleGroupSequence(t *testing.T) { } assert.Equal(t, expectedUids, actualUids) - group2Get = client.GetRulesGroup(t, folder1Title, group2.Name) + group2Get = client.GetRulesGroup(t, folderUID, group2.Name) assert.Len(t, group2Get.Rules, len(group2.Rules)-1) for _, rule := range group2Get.Rules { require.NotEqual(t, movedRule.GrafanaManagedAlert.UID, rule.GrafanaManagedAlert.UID) @@ -972,6 +1331,76 @@ func TestIntegrationRuleGroupSequence(t *testing.T) { }) } +func TestIntegrationRuleCreate(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + AppModeProduction: true, + }) + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) + createUser(t, store, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "admin", + Login: "admin", + }) + client := newAlertingApiClient(grafanaListedAddr, "admin", "admin") + + namespaceUID := "default" + client.CreateFolder(t, namespaceUID, namespaceUID) + + cases := []struct { + name string + config apimodels.PostableRuleGroupConfig + }{{ + name: "can create a rule with UTF-8", + config: apimodels.PostableRuleGroupConfig{ + Name: "test1", + Interval: model.Duration(time.Minute), + Rules: []apimodels.PostableExtendedRuleNode{ + { + ApiRuleNode: &apimodels.ApiRuleNode{ + For: util.Pointer(model.Duration(2 * time.Minute)), + Labels: map[string]string{ + "foo🙂": "bar", + "_bar1": "baz🙂", + }, + Annotations: map[string]string{ + "Προμηθέας": "prom", // Prometheus in Greek + "犬": "Shiba Inu", // Dog in Japanese + }, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "test1 rule1", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(0), + To: apimodels.Duration(15 * time.Minute), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{"type": "math","expression": "1"}`), + }, + }, + }, + }, + }, + }, + }} + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp, status, _ := client.PostRulesGroupWithStatus(t, namespaceUID, &tc.config) + require.Equal(t, http.StatusAccepted, status) + require.Len(t, resp.Created, 1) + require.Len(t, resp.Updated, 0) + require.Len(t, resp.Deleted, 0) + }) + } +} + func TestIntegrationRuleUpdate(t *testing.T) { testinfra.SQLiteIntegrationTest(t) @@ -1018,26 +1447,26 @@ func TestIntegrationRuleUpdate(t *testing.T) { adminClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin") client := newAlertingApiClient(grafanaListedAddr, "grafana", "password") - folder1Title := "folder1" - client.CreateFolder(t, util.GenerateShortUID(), folder1Title) + folderUID := util.GenerateShortUID() + client.CreateFolder(t, folderUID, "folder1") t.Run("should be able to reset 'for' to 0", func(t *testing.T) { group := generateAlertRuleGroup(1, alertRuleGen()) expected := model.Duration(10 * time.Second) group.Rules[0].ApiRuleNode.For = &expected - _, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) + _, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body) - getGroup := client.GetRulesGroup(t, folder1Title, group.Name) + getGroup := client.GetRulesGroup(t, folderUID, group.Name) require.Equal(t, expected, *getGroup.Rules[0].ApiRuleNode.For) group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig) expected = 0 group.Rules[0].ApiRuleNode.For = &expected - _, status, body = client.PostRulesGroupWithStatus(t, folder1Title, &group) + _, status, body = client.PostRulesGroupWithStatus(t, folderUID, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body) - getGroup = client.GetRulesGroup(t, folder1Title, group.Name) + getGroup = client.GetRulesGroup(t, folderUID, group.Name) require.Equal(t, expected, *getGroup.Rules[0].ApiRuleNode.For) }) t.Run("when data source missing", func(t *testing.T) { @@ -1046,10 +1475,10 @@ func TestIntegrationRuleUpdate(t *testing.T) { ds1 := adminClient.CreateTestDatasource(t) group := generateAlertRuleGroup(3, alertRuleGen(withDatasourceQuery(ds1.Body.Datasource.UID))) - _, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) + _, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body) - getGroup := client.GetRulesGroup(t, folder1Title, group.Name) + getGroup := client.GetRulesGroup(t, folderUID, group.Name) group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig) require.Len(t, group.Rules, 3) @@ -1063,59 +1492,59 @@ func TestIntegrationRuleUpdate(t *testing.T) { } t.Run("noop should not fail", func(t *testing.T) { - getGroup := client.GetRulesGroup(t, folder1Title, groupName) + getGroup := client.GetRulesGroup(t, folderUID, groupName) group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig) - _, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) + _, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body) }) t.Run("should not let update rule if it does not fix datasource", func(t *testing.T) { - getGroup := client.GetRulesGroup(t, folder1Title, groupName) + getGroup := client.GetRulesGroup(t, folderUID, groupName) group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig) group.Rules[0].GrafanaManagedAlert.Title = uuid.NewString() - resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) + resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group) if status == http.StatusAccepted { assert.Len(t, resp.Deleted, 1) - getGroup = client.GetRulesGroup(t, folder1Title, group.Name) + getGroup = client.GetRulesGroup(t, folderUID, group.Name) assert.NotEqualf(t, group.Rules[0].GrafanaManagedAlert.Title, getGroup.Rules[0].GrafanaManagedAlert.Title, "group was updated") } require.Equalf(t, http.StatusBadRequest, status, "expected BadRequest. Response: %s", body) assert.Contains(t, body, "data source not found") }) t.Run("should let delete broken rule", func(t *testing.T) { - getGroup := client.GetRulesGroup(t, folder1Title, groupName) + getGroup := client.GetRulesGroup(t, folderUID, groupName) group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig) // remove the last rule. group.Rules = group.Rules[0 : len(group.Rules)-1] - resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) + resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group) require.Equalf(t, http.StatusAccepted, status, "failed to delete last rule from group. Response: %s", body) assert.Len(t, resp.Deleted, 1) - getGroup = client.GetRulesGroup(t, folder1Title, group.Name) + getGroup = client.GetRulesGroup(t, folderUID, group.Name) group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig) require.Len(t, group.Rules, 2) }) t.Run("should let fix single rule", func(t *testing.T) { - getGroup := client.GetRulesGroup(t, folder1Title, groupName) + getGroup := client.GetRulesGroup(t, folderUID, groupName) group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig) ds2 := adminClient.CreateTestDatasource(t) withDatasourceQuery(ds2.Body.Datasource.UID)(&group.Rules[0]) - resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) + resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body) assert.Len(t, resp.Deleted, 0) assert.Len(t, resp.Updated, 2) assert.Len(t, resp.Created, 0) - getGroup = client.GetRulesGroup(t, folder1Title, group.Name) + getGroup = client.GetRulesGroup(t, folderUID, group.Name) group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig) require.Equal(t, ds2.Body.Datasource.UID, group.Rules[0].GrafanaManagedAlert.Data[0].DatasourceUID) }) t.Run("should let delete group", func(t *testing.T) { - status, body := client.DeleteRulesGroup(t, folder1Title, groupName) + status, body := client.DeleteRulesGroup(t, folderUID, groupName) require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body) }) }) @@ -1209,18 +1638,18 @@ func TestIntegrationRulePause(t *testing.T) { }) client := newAlertingApiClient(grafanaListedAddr, "grafana", "password") - folder1Title := "folder1" - client.CreateFolder(t, util.GenerateShortUID(), folder1Title) + folderUID := util.GenerateShortUID() + client.CreateFolder(t, folderUID, "folder1") t.Run("should create a paused rule if isPaused is true", func(t *testing.T) { group := generateAlertRuleGroup(1, alertRuleGen()) expectedIsPaused := true group.Rules[0].GrafanaManagedAlert.IsPaused = &expectedIsPaused - resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) + resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body) require.Len(t, resp.Created, 1) - getGroup := client.GetRulesGroup(t, folder1Title, group.Name) + getGroup := client.GetRulesGroup(t, folderUID, group.Name) require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body) require.Equal(t, expectedIsPaused, getGroup.Rules[0].GrafanaManagedAlert.IsPaused) }) @@ -1230,10 +1659,10 @@ func TestIntegrationRulePause(t *testing.T) { expectedIsPaused := false group.Rules[0].GrafanaManagedAlert.IsPaused = &expectedIsPaused - resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) + resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body) require.Len(t, resp.Created, 1) - getGroup := client.GetRulesGroup(t, folder1Title, group.Name) + getGroup := client.GetRulesGroup(t, folderUID, group.Name) require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body) require.Equal(t, expectedIsPaused, getGroup.Rules[0].GrafanaManagedAlert.IsPaused) }) @@ -1242,10 +1671,10 @@ func TestIntegrationRulePause(t *testing.T) { group := generateAlertRuleGroup(1, alertRuleGen()) group.Rules[0].GrafanaManagedAlert.IsPaused = nil - resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) + resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body) require.Len(t, resp.Created, 1) - getGroup := client.GetRulesGroup(t, folder1Title, group.Name) + getGroup := client.GetRulesGroup(t, folderUID, group.Name) require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body) require.False(t, getGroup.Rules[0].GrafanaManagedAlert.IsPaused) }) @@ -1300,18 +1729,336 @@ func TestIntegrationRulePause(t *testing.T) { group := generateAlertRuleGroup(1, alertRuleGen()) group.Rules[0].GrafanaManagedAlert.IsPaused = &tc.isPausedInDb - _, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) + _, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body) - getGroup := client.GetRulesGroup(t, folder1Title, group.Name) + getGroup := client.GetRulesGroup(t, folderUID, group.Name) require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body) group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig) group.Rules[0].GrafanaManagedAlert.IsPaused = tc.isPausedInBody - _, status, body = client.PostRulesGroupWithStatus(t, folder1Title, &group) + _, status, body = client.PostRulesGroupWithStatus(t, folderUID, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body) - getGroup = client.GetRulesGroup(t, folder1Title, group.Name) + getGroup = client.GetRulesGroup(t, folderUID, group.Name) require.Equal(t, tc.expectedIsPausedInDb, getGroup.Rules[0].GrafanaManagedAlert.IsPaused) }) } } + +func TestIntegrationHysteresisRule(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + + // Setup Grafana and its Database. Scheduler is set to evaluate every 1 second + dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + NGAlertSchedulerBaseInterval: 1 * time.Second, + EnableFeatureToggles: []string{featuremgmt.FlagConfigurableSchedulerTick, featuremgmt.FlagRecoveryThreshold}, + }) + + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, p) + + // Create a user to make authenticated requests + createUser(t, store, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "password", + Login: "grafana", + }) + + apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") + + folder := "hysteresis" + testDs := apiClient.CreateTestDatasource(t) + apiClient.CreateFolder(t, folder, folder) + + bodyRaw, err := testData.ReadFile("test-data/hysteresis_rule.json") + require.NoError(t, err) + + var postData apimodels.PostableRuleGroupConfig + require.NoError(t, json.Unmarshal(bodyRaw, &postData)) + for _, rule := range postData.Rules { + for i := range rule.GrafanaManagedAlert.Data { + rule.GrafanaManagedAlert.Data[i].DatasourceUID = strings.ReplaceAll(rule.GrafanaManagedAlert.Data[i].DatasourceUID, "REPLACE_ME", testDs.Body.Datasource.UID) + } + } + changes, status, body := apiClient.PostRulesGroupWithStatus(t, folder, &postData) + require.Equalf(t, http.StatusAccepted, status, body) + require.Len(t, changes.Created, 1) + ruleUid := changes.Created[0] + + var frame data.Frame + require.Eventuallyf(t, func() bool { + frame, status, body = apiClient.GetRuleHistoryWithStatus(t, ruleUid) + require.Equalf(t, http.StatusOK, status, body) + return frame.Rows() > 1 + }, 15*time.Second, 1*time.Second, "Alert state history expected to have more than one record but got %d. Body: %s", frame.Rows(), body) + + f, _ := frame.FieldByName("next") + + alertingIdx := 0 + normalIdx := 1 + if f.At(alertingIdx).(string) != "Alerting" { + alertingIdx = 1 + normalIdx = 0 + } + + assert.Equalf(t, "Alerting", f.At(alertingIdx).(string), body) + assert.Equalf(t, "Normal", f.At(normalIdx).(string), body) + + type HistoryData struct { + Values map[string]int64 + } + + f, _ = frame.FieldByName("data") + var d HistoryData + require.NoErrorf(t, json.Unmarshal([]byte(f.At(alertingIdx).(string)), &d), body) + assert.EqualValuesf(t, 5, d.Values["B"], body) + require.NoErrorf(t, json.Unmarshal([]byte(f.At(normalIdx).(string)), &d), body) + assert.EqualValuesf(t, 1, d.Values["B"], body) +} + +func TestIntegrationRuleNotificationSettings(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + + // Setup Grafana and its Database. Scheduler is set to evaluate every 1 second + dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + NGAlertSchedulerBaseInterval: 1 * time.Second, + EnableFeatureToggles: []string{featuremgmt.FlagConfigurableSchedulerTick, featuremgmt.FlagAlertingSimplifiedRouting}, + }) + + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, p) + + // Create a user to make authenticated requests + createUser(t, store, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "password", + Login: "grafana", + }) + + apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") + + folder := "Test-Alerting" + apiClient.CreateFolder(t, folder, folder) + + testDataRaw, err := testData.ReadFile(path.Join("test-data", "rule-notification-settings-1-post.json")) + require.NoError(t, err) + + type testData struct { + RuleGroup apimodels.PostableRuleGroupConfig + Receiver apimodels.EmbeddedContactPoint + TimeInterval apimodels.MuteTimeInterval + } + var d testData + err = json.Unmarshal(testDataRaw, &d) + require.NoError(t, err) + + apiClient.EnsureReceiver(t, d.Receiver) + apiClient.EnsureMuteTiming(t, d.TimeInterval) + + t.Run("create should fail if receiver does not exist", func(t *testing.T) { + var copyD testData + err = json.Unmarshal(testDataRaw, ©D) + group := copyD.RuleGroup + ns := group.Rules[0].GrafanaManagedAlert.NotificationSettings + ns.Receiver = "random-receiver" + + _, status, body := apiClient.PostRulesGroupWithStatus(t, folder, &group) + require.Equalf(t, http.StatusBadRequest, status, body) + t.Log(body) + }) + + t.Run("create should fail if mute timing does not exist", func(t *testing.T) { + var copyD testData + err = json.Unmarshal(testDataRaw, ©D) + group := copyD.RuleGroup + ns := group.Rules[0].GrafanaManagedAlert.NotificationSettings + ns.MuteTimeIntervals = []string{"random-time-interval"} + + _, status, body := apiClient.PostRulesGroupWithStatus(t, folder, &group) + require.Equalf(t, http.StatusBadRequest, status, body) + t.Log(body) + }) + + t.Run("create should fail if group_by does not contain special labels", func(t *testing.T) { + var copyD testData + err = json.Unmarshal(testDataRaw, ©D) + group := copyD.RuleGroup + ns := group.Rules[0].GrafanaManagedAlert.NotificationSettings + ns.GroupBy = []string{"label1"} + + _, status, body := apiClient.PostRulesGroupWithStatus(t, folder, &group) + require.Equalf(t, http.StatusBadRequest, status, body) + t.Log(body) + }) + + t.Run("should create rule and generate route", func(t *testing.T) { + _, status, body := apiClient.PostRulesGroupWithStatus(t, folder, &d.RuleGroup) + require.Equalf(t, http.StatusAccepted, status, body) + notificationSettings := d.RuleGroup.Rules[0].GrafanaManagedAlert.NotificationSettings + + var routeBody string + if !assert.EventuallyWithT(t, func(c *assert.CollectT) { + amConfig, status, body := apiClient.GetAlertmanagerConfigWithStatus(t) + routeBody = body + if !assert.Equalf(t, http.StatusOK, status, body) { + return + } + route := amConfig.AlertmanagerConfig.Route + + if !assert.Len(c, route.Routes, 1) { + return + } + + // Check that we are in the auto-generated root + autogenRoute := route.Routes[0] + if !assert.Len(c, autogenRoute.ObjectMatchers, 1) { + return + } + canContinue := assert.Equal(c, ngmodels.AutogeneratedRouteLabel, autogenRoute.ObjectMatchers[0].Name) + assert.Equal(c, labels.MatchEqual, autogenRoute.ObjectMatchers[0].Type) + assert.Equal(c, "true", autogenRoute.ObjectMatchers[0].Value) + + assert.Equalf(c, route.Receiver, autogenRoute.Receiver, "Autogenerated root receiver must be the default one") + assert.Nil(c, autogenRoute.GroupWait) + assert.Nil(c, autogenRoute.GroupInterval) + assert.Nil(c, autogenRoute.RepeatInterval) + assert.Empty(c, autogenRoute.MuteTimeIntervals) + assert.Empty(c, autogenRoute.GroupBy) + if !canContinue { + return + } + // Now check that the second level is route for receivers + if !assert.NotEmpty(c, autogenRoute.Routes) { + return + } + // There can be many routes, for all receivers + idx := slices.IndexFunc(autogenRoute.Routes, func(route *apimodels.Route) bool { + return route.Receiver == notificationSettings.Receiver + }) + if !assert.GreaterOrEqual(t, idx, 0) { + return + } + receiverRoute := autogenRoute.Routes[idx] + if !assert.Len(c, receiverRoute.ObjectMatchers, 1) { + return + } + canContinue = assert.Equal(c, ngmodels.AutogeneratedRouteReceiverNameLabel, receiverRoute.ObjectMatchers[0].Name) + assert.Equal(c, labels.MatchEqual, receiverRoute.ObjectMatchers[0].Type) + assert.Equal(c, notificationSettings.Receiver, receiverRoute.ObjectMatchers[0].Value) + + assert.Equal(c, notificationSettings.Receiver, receiverRoute.Receiver) + assert.Nil(c, receiverRoute.GroupWait) + assert.Nil(c, receiverRoute.GroupInterval) + assert.Nil(c, receiverRoute.RepeatInterval) + assert.Empty(c, receiverRoute.MuteTimeIntervals) + var groupBy []string + for _, name := range receiverRoute.GroupBy { + groupBy = append(groupBy, string(name)) + } + slices.Sort(groupBy) + assert.EqualValues(c, []string{"alertname", "grafana_folder"}, groupBy) + if !canContinue { + return + } + // Now check that we created the 3rd level for specific combination of settings + if !assert.Lenf(c, receiverRoute.Routes, 1, "Receiver route should contain one options route") { + return + } + optionsRoute := receiverRoute.Routes[0] + if !assert.Len(c, optionsRoute.ObjectMatchers, 1) { + return + } + assert.Equal(c, ngmodels.AutogeneratedRouteSettingsHashLabel, optionsRoute.ObjectMatchers[0].Name) + assert.Equal(c, labels.MatchEqual, optionsRoute.ObjectMatchers[0].Type) + assert.EqualValues(c, notificationSettings.GroupWait, optionsRoute.GroupWait) + assert.EqualValues(c, notificationSettings.GroupInterval, optionsRoute.GroupInterval) + assert.EqualValues(c, notificationSettings.RepeatInterval, optionsRoute.RepeatInterval) + assert.EqualValues(c, notificationSettings.MuteTimeIntervals, optionsRoute.MuteTimeIntervals) + groupBy = nil + for _, name := range optionsRoute.GroupBy { + groupBy = append(groupBy, string(name)) + } + assert.EqualValues(c, notificationSettings.GroupBy, groupBy) + }, 10*time.Second, 1*time.Second) { + t.Logf("config: %s", routeBody) + } + }) + + t.Run("should correctly create alerts", func(t *testing.T) { + var response string + if !assert.EventuallyWithT(t, func(c *assert.CollectT) { + groups, status, body := apiClient.GetActiveAlertsWithStatus(t) + require.Equalf(t, http.StatusOK, status, body) + response = body + if len(groups) == 0 { + return + } + g := groups[0] + alert := g.Alerts[0] + assert.Contains(c, alert.Labels, ngmodels.AutogeneratedRouteLabel) + assert.Equal(c, "true", alert.Labels[ngmodels.AutogeneratedRouteLabel]) + assert.Contains(c, alert.Labels, ngmodels.AutogeneratedRouteReceiverNameLabel) + assert.Equal(c, d.Receiver.Name, alert.Labels[ngmodels.AutogeneratedRouteReceiverNameLabel]) + assert.Contains(c, alert.Labels, ngmodels.AutogeneratedRouteSettingsHashLabel) + assert.NotEmpty(c, alert.Labels[ngmodels.AutogeneratedRouteSettingsHashLabel]) + }, 10*time.Second, 1*time.Second) { + t.Logf("response: %s", response) + } + }) + + t.Run("should update rule with empty settings and delete route", func(t *testing.T) { + var copyD testData + err = json.Unmarshal(testDataRaw, ©D) + group := copyD.RuleGroup + notificationSettings := group.Rules[0].GrafanaManagedAlert.NotificationSettings + group.Rules[0].GrafanaManagedAlert.NotificationSettings = nil + + _, status, body := apiClient.PostRulesGroupWithStatus(t, folder, &group) + require.Equalf(t, http.StatusAccepted, status, body) + + var routeBody string + if !assert.EventuallyWithT(t, func(c *assert.CollectT) { + amConfig, status, body := apiClient.GetAlertmanagerConfigWithStatus(t) + routeBody = body + if !assert.Equalf(t, http.StatusOK, status, body) { + return + } + route := amConfig.AlertmanagerConfig.Route + + if !assert.Len(c, route.Routes, 1) { + return + } + // Check that we are in the auto-generated root + autogenRoute := route.Routes[0] + if !assert.Len(c, autogenRoute.ObjectMatchers, 1) { + return + } + if !assert.Equal(c, ngmodels.AutogeneratedRouteLabel, autogenRoute.ObjectMatchers[0].Name) { + return + } + // Now check that the second level is route for receivers + if !assert.NotEmpty(c, autogenRoute.Routes) { + return + } + // There can be many routes, for all receivers + idx := slices.IndexFunc(autogenRoute.Routes, func(route *apimodels.Route) bool { + return route.Receiver == notificationSettings.Receiver + }) + if !assert.GreaterOrEqual(t, idx, 0) { + return + } + receiverRoute := autogenRoute.Routes[idx] + if !assert.Empty(c, receiverRoute.Routes) { + return + } + }, 10*time.Second, 1*time.Second) { + t.Logf("config: %s", routeBody) + } + }) +} diff --git a/pkg/tests/api/alerting/api_testing_test.go b/pkg/tests/api/alerting/api_testing_test.go index eee30ad69b8a7..6b01b4f163d46 100644 --- a/pkg/tests/api/alerting/api_testing_test.go +++ b/pkg/tests/api/alerting/api_testing_test.go @@ -24,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) @@ -31,6 +32,10 @@ const ( TESTDATA_UID = "testdata" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestGrafanaRuleConfig(t *testing.T) { dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, @@ -51,6 +56,8 @@ func TestGrafanaRuleConfig(t *testing.T) { apiCli := newAlertingApiClient(grafanaListedAddr, "admin", "admin") + apiCli.CreateFolder(t, "NamespaceUID", "NamespaceTitle") + dsCmd := &datasources.AddDataSourceCommand{ Name: "TestDatasource", Type: "testdata", diff --git a/pkg/tests/api/alerting/test-data/hysteresis_rule.json b/pkg/tests/api/alerting/test-data/hysteresis_rule.json new file mode 100644 index 0000000000000..53a01c9ac5e9e --- /dev/null +++ b/pkg/tests/api/alerting/test-data/hysteresis_rule.json @@ -0,0 +1,71 @@ +{ + "name": "Default", + "interval": "1s", + "rules": [ + { + "grafana_alert": { + "title": "Hysteresis Test", + "condition": "C", + "no_data_state": "NoData", + "exec_err_state": "Error", + "data": [ + { + "refId": "A", + "datasourceUid": "REPLACE_ME", + "queryType": "", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "model": { + "refId": "A", + "scenarioId": "predictable_csv_wave", + "csvWave": [ + { + "timeStep": 1, + "valuesCSV": "5,3,2,1" + } + ], + "seriesCount": 1 + } + }, + { + "refId": "B", + "datasourceUid": "__expr__", + "model": { + "refId": "B", + "type": "reduce", + "reducer": "last", + "expression": "A" + } + }, + { + "refId": "C", + "datasourceUid": "__expr__", + "model": { + "refId": "C", + "type": "threshold", + "conditions": [ + { + "evaluator": { + "params": [ + 4 + ], + "type": "gt" + }, + "unloadEvaluator": { + "params": [ + 2 + ], + "type": "lt" + } + } + ], + "expression": "B" + } + } + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tests/api/alerting/test-data/rule-notification-settings-1-post.json b/pkg/tests/api/alerting/test-data/rule-notification-settings-1-post.json new file mode 100644 index 0000000000000..0331fd94e27a9 --- /dev/null +++ b/pkg/tests/api/alerting/test-data/rule-notification-settings-1-post.json @@ -0,0 +1,58 @@ +{ + "ruleGroup" : { + "name": "Group1", + "interval": "1m", + "rules": [ + { + "for": "0", + "labels": { + "label1": "test-label" + }, + "annotations": { + "annotation": "test-annotation" + }, + "grafana_alert": { + "title": "Rule1", + "condition": "A", + "data": [ + { + "refId": "A", + "datasourceUid": "__expr__", + "model": { + "expression": "0 > 0", + "type": "math" + } + } + ], + "no_data_state": "NoData", + "exec_err_state": "Alerting", + "notification_settings": { + "receiver": "rule-receiver", + "group_by": [ + "alertname", + "grafana_folder", + "label1" + ], + "group_wait": "100ms", + "group_interval": "5s", + "repeat_interval": "1d", + "mute_time_intervals": [ + "rule-time-interval" + ] + } + } + } + ] + }, + "receiver": { + "name": "rule-receiver", + "type": "webhook", + "settings": { + "url": "http://localhost:3000/_callback" + } + }, + "timeInterval": { + "name": "rule-time-interval", + "time_intervals":[{"times":[{"start_time":"10:00","end_time":"12:00"}]}] + } +} \ No newline at end of file diff --git a/pkg/tests/api/alerting/test-data/rulegroup-3-export.json b/pkg/tests/api/alerting/test-data/rulegroup-3-export.json new file mode 100644 index 0000000000000..5a1ba9e0eac1a --- /dev/null +++ b/pkg/tests/api/alerting/test-data/rulegroup-3-export.json @@ -0,0 +1,75 @@ +{ + "apiVersion": 1, + "groups": [ + { + "orgId": 1, + "name": "Group3", + "folder": "<dynamic>", + "interval": "1m", + "rules": [ + { + "uid": "<dynamic>", + "title": "Rule1", + "condition": "A", + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "0 \u003e 0", + "intervalMs": 1000, + "maxDataPoints": 43200, + "type": "math" + } + } + ], + "noDataState": "NoData", + "execErrState": "Alerting", + "for": "5m", + "annotations": { + "annotation": "test-annotation" + }, + "labels": { + "label1": "test-label" + }, + "isPaused": false + }, + { + "uid": "<dynamic>", + "title": "Rule2", + "condition": "A", + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "0 == 0", + "intervalMs": 1000, + "maxDataPoints": 43200, + "type": "math" + } + } + ], + "noDataState": "NoData", + "execErrState": "Alerting", + "for": "5m", + "annotations": { + "annotation": "test-annotation" + }, + "labels": { + "label1": "test-label" + }, + "isPaused": false + } + ] + } + ] +} \ No newline at end of file diff --git a/pkg/tests/api/alerting/test-data/rulegroup-3-get.json b/pkg/tests/api/alerting/test-data/rulegroup-3-get.json new file mode 100644 index 0000000000000..252dc99f13be1 --- /dev/null +++ b/pkg/tests/api/alerting/test-data/rulegroup-3-get.json @@ -0,0 +1,90 @@ +{ + "name": "Group3", + "interval": "1m", + "rules": [ + { + "expr": "", + "for": "5m", + "labels": { + "label1": "test-label" + }, + "annotations": { + "annotation": "test-annotation" + }, + "grafana_alert": { + "id": 1, + "orgId": 1, + "title": "Rule1", + "condition": "A", + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "0 > 0", + "intervalMs": 1000, + "maxDataPoints": 43200, + "type": "math" + } + } + ], + "updated": "2023-09-29T17:37:19Z", + "intervalSeconds": 60, + "version": 1, + "uid": "<dynamic>", + "namespace_uid": "<dynamic>", + "rule_group": "Group3", + "no_data_state": "NoData", + "exec_err_state": "Alerting", + "is_paused": false + } + }, + { + "expr": "", + "for": "5m", + "labels": { + "label1": "test-label" + }, + "annotations": { + "annotation": "test-annotation" + }, + "grafana_alert": { + "id": 2, + "orgId": 1, + "title": "Rule2", + "condition": "A", + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "0 == 0", + "intervalMs": 1000, + "maxDataPoints": 43200, + "type": "math" + } + } + ], + "updated": "2023-09-29T17:37:19Z", + "intervalSeconds": 60, + "version": 1, + "uid": "<dynamic>", + "namespace_uid": "<dynamic>", + "rule_group": "Group3", + "no_data_state": "NoData", + "exec_err_state": "Alerting", + "is_paused": false + } + } + ] +} \ No newline at end of file diff --git a/pkg/tests/api/alerting/test-data/rulegroup-3-post.json b/pkg/tests/api/alerting/test-data/rulegroup-3-post.json new file mode 100644 index 0000000000000..2f528d19ad1cc --- /dev/null +++ b/pkg/tests/api/alerting/test-data/rulegroup-3-post.json @@ -0,0 +1,56 @@ +{ + "name": "Group3", + "interval": "1m", + "rules": [ + { + "for": "5m", + "labels": { + "label1": "test-label" + }, + "annotations": { + "annotation": "test-annotation" + }, + "grafana_alert": { + "title": "Rule1", + "condition": "A", + "data": [ + { + "refId": "A", + "datasourceUid": "__expr__", + "model": { + "expression": "0 > 0", + "type": "math" + } + } + ], + "no_data_state": "NoData", + "exec_err_state": "Alerting" + } + }, + { + "for": "5m", + "labels": { + "label1": "test-label" + }, + "annotations": { + "annotation": "test-annotation" + }, + "grafana_alert": { + "title": "Rule2", + "condition": "A", + "data": [ + { + "refId": "A", + "datasourceUid": "__expr__", + "model": { + "expression": "0 == 0", + "type": "math" + } + } + ], + "no_data_state": "NoData", + "exec_err_state": "Alerting" + } + } + ] +} \ No newline at end of file diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index 2371d2bcc55ac..208e7856a38a6 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -3,6 +3,7 @@ package alerting import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -12,12 +13,14 @@ import ( "time" "github.com/google/uuid" + "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/services/folder" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/quota" @@ -223,13 +226,14 @@ func convertGettableGrafanaRuleToPostable(gettable *apimodels.GettableGrafanaRul return nil } return &apimodels.PostableGrafanaRule{ - Title: gettable.Title, - Condition: gettable.Condition, - Data: gettable.Data, - UID: gettable.UID, - NoDataState: gettable.NoDataState, - ExecErrState: gettable.ExecErrState, - IsPaused: &gettable.IsPaused, + Title: gettable.Title, + Condition: gettable.Condition, + Data: gettable.Data, + UID: gettable.UID, + NoDataState: gettable.NoDataState, + ExecErrState: gettable.ExecErrState, + IsPaused: &gettable.IsPaused, + NotificationSettings: gettable.NotificationSettings, } } @@ -259,9 +263,20 @@ func (a apiClient) ReloadCachedPermissions(t *testing.T) { } // CreateFolder creates a folder for storing our alerts, and then refreshes the permission cache to make sure that following requests will be accepted -func (a apiClient) CreateFolder(t *testing.T, uID string, title string) { +func (a apiClient) CreateFolder(t *testing.T, uID string, title string, parentUID ...string) { t.Helper() - payload := fmt.Sprintf(`{"uid": "%s","title": "%s"}`, uID, title) + cmd := folder.CreateFolderCommand{ + UID: uID, + Title: title, + } + if len(parentUID) > 0 { + cmd.ParentUID = parentUID[0] + } + + blob, err := json.Marshal(cmd) + require.NoError(t, err) + + payload := string(blob) u := fmt.Sprintf("%s/api/folders", a.url) r := strings.NewReader(payload) // nolint:gosec @@ -328,6 +343,40 @@ func (a apiClient) UpdateAlertRuleOrgQuota(t *testing.T, orgID int64, limit int6 assert.Equal(t, http.StatusOK, resp.StatusCode) } +func (a apiClient) PostConfiguration(t *testing.T, c apimodels.PostableUserConfig) (bool, error) { + t.Helper() + + b, err := json.Marshal(c) + require.NoError(t, err) + + u := fmt.Sprintf("%s/api/alertmanager/grafana/config/api/v1/alerts", a.url) + req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + require.NotNil(t, resp) + + defer func() { + _ = resp.Body.Close() + }() + b, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + data := struct { + Message string `json:"message"` + }{} + require.NoError(t, json.Unmarshal(b, &data)) + + if resp.StatusCode == http.StatusAccepted { + return true, nil + } + + return false, errors.New(data.Message) +} + func (a apiClient) PostRulesGroupWithStatus(t *testing.T, folder string, group *apimodels.PostableRuleGroupConfig) (apimodels.UpdateRuleGroupResponse, int, string) { t.Helper() buf := bytes.Buffer{} @@ -405,6 +454,41 @@ func (a apiClient) DeleteRulesGroup(t *testing.T, folder string, group string) ( return resp.StatusCode, string(b) } +func (a apiClient) PostSilence(t *testing.T, s apimodels.PostableSilence) (string, error) { + t.Helper() + + b, err := json.Marshal(s) + require.NoError(t, err) + + u := fmt.Sprintf("%s/api/alertmanager/grafana/api/v2/silences", a.url) + req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + require.NotNil(t, resp) + + defer func() { + _ = resp.Body.Close() + }() + b, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + data := struct { + SilenceID string `json:"silenceID"` + Message string `json:"message"` + }{} + require.NoError(t, json.Unmarshal(b, &data)) + + if resp.StatusCode == http.StatusAccepted { + return data.SilenceID, nil + } + + return "", errors.New(data.Message) +} + func (a apiClient) GetRulesGroup(t *testing.T, folder string, group string) apimodels.RuleGroupConfigResponse { result, status, _ := a.GetRulesGroupWithStatus(t, folder, group) require.Equal(t, http.StatusAccepted, status) @@ -628,6 +712,13 @@ func (a apiClient) CreateMuteTimingWithStatus(t *testing.T, interval apimodels.M return sendRequest[apimodels.MuteTimeInterval](t, req, http.StatusCreated) } +func (a apiClient) EnsureMuteTiming(t *testing.T, interval apimodels.MuteTimeInterval) { + t.Helper() + + _, status, body := a.CreateMuteTimingWithStatus(t, interval) + require.Equalf(t, http.StatusCreated, status, body) +} + func (a apiClient) UpdateMuteTimingWithStatus(t *testing.T, interval apimodels.MuteTimeInterval) (apimodels.MuteTimeInterval, int, string) { t.Helper() @@ -695,6 +786,75 @@ func (a apiClient) UpdateRouteWithStatus(t *testing.T, route apimodels.Route) (i return resp.StatusCode, string(body) } +func (a apiClient) GetRuleHistoryWithStatus(t *testing.T, ruleUID string) (data.Frame, int, string) { + t.Helper() + u, err := url.Parse(fmt.Sprintf("%s/api/v1/rules/history", a.url)) + require.NoError(t, err) + q := url.Values{} + q.Set("ruleUID", ruleUID) + u.RawQuery = q.Encode() + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + require.NoError(t, err) + + return sendRequest[data.Frame](t, req, http.StatusOK) +} + +func (a apiClient) GetAllTimeIntervalsWithStatus(t *testing.T) ([]apimodels.GettableTimeIntervals, int, string) { + t.Helper() + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/notifications/time-intervals", a.url), nil) + require.NoError(t, err) + + return sendRequest[[]apimodels.GettableTimeIntervals](t, req, http.StatusOK) +} + +func (a apiClient) GetTimeIntervalByNameWithStatus(t *testing.T, name string) (apimodels.GettableTimeIntervals, int, string) { + t.Helper() + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/notifications/time-intervals/%s", a.url, name), nil) + require.NoError(t, err) + + return sendRequest[apimodels.GettableTimeIntervals](t, req, http.StatusOK) +} + +func (a apiClient) CreateReceiverWithStatus(t *testing.T, receiver apimodels.EmbeddedContactPoint) (apimodels.EmbeddedContactPoint, int, string) { + t.Helper() + + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + err := enc.Encode(receiver) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/provisioning/contact-points", a.url), &buf) + req.Header.Add("Content-Type", "application/json") + require.NoError(t, err) + + return sendRequest[apimodels.EmbeddedContactPoint](t, req, http.StatusAccepted) +} + +func (a apiClient) EnsureReceiver(t *testing.T, receiver apimodels.EmbeddedContactPoint) { + t.Helper() + + _, status, body := a.CreateReceiverWithStatus(t, receiver) + require.Equalf(t, http.StatusAccepted, status, body) +} + +func (a apiClient) GetAlertmanagerConfigWithStatus(t *testing.T) (apimodels.GettableUserConfig, int, string) { + t.Helper() + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/alertmanager/grafana/config/api/v1/alerts", a.url), nil) + require.NoError(t, err) + + return sendRequest[apimodels.GettableUserConfig](t, req, http.StatusOK) +} + +func (a apiClient) GetActiveAlertsWithStatus(t *testing.T) (apimodels.AlertGroups, int, string) { + t.Helper() + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/alertmanager/grafana/api/v2/alerts/groups", a.url), nil) + require.NoError(t, err) + return sendRequest[apimodels.AlertGroups](t, req, http.StatusOK) +} + func sendRequest[T any](t *testing.T, req *http.Request, successStatusCode int) (T, int, string) { client := &http.Client{} resp, err := client.Do(req) diff --git a/pkg/tests/api/azuremonitor/azuremonitor_test.go b/pkg/tests/api/azuremonitor/azuremonitor_test.go index 099424f7c919b..a7cfc3f0fb944 100644 --- a/pkg/tests/api/azuremonitor/azuremonitor_test.go +++ b/pkg/tests/api/azuremonitor/azuremonitor_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationAzureMonitor(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/correlations/common_test.go b/pkg/tests/api/correlations/common_test.go index 01c166788f0e6..9dd112fff8be8 100644 --- a/pkg/tests/api/correlations/common_test.go +++ b/pkg/tests/api/correlations/common_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) type errorResponseBody struct { @@ -30,6 +31,10 @@ type TestContext struct { t *testing.T } +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func NewTestEnv(t *testing.T) TestContext { t.Helper() dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ @@ -45,7 +50,7 @@ func NewTestEnv(t *testing.T) TestContext { type User struct { User user.User - password string + password user.Password } type GetParams struct { diff --git a/pkg/tests/api/correlations/correlations_update_test.go b/pkg/tests/api/correlations/correlations_update_test.go index 4d6eb1c9d6ef3..e2f3e9278e7fb 100644 --- a/pkg/tests/api/correlations/correlations_update_test.go +++ b/pkg/tests/api/correlations/correlations_update_test.go @@ -16,6 +16,8 @@ import ( ) func TestIntegrationUpdateCorrelation(t *testing.T) { + // TODO: #82520 Possibly a flaky test + t.Skip() if testing.Short() { t.Skip("skipping integration test") } diff --git a/pkg/tests/api/dashboards/api_dashboards_test.go b/pkg/tests/api/dashboards/api_dashboards_test.go index d6b9297db4a67..60bd745e74006 100644 --- a/pkg/tests/api/dashboards/api_dashboards_test.go +++ b/pkg/tests/api/dashboards/api_dashboards_test.go @@ -30,9 +30,14 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationDashboardQuota(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/elasticsearch/elasticsearch_test.go b/pkg/tests/api/elasticsearch/elasticsearch_test.go index 1bfc4ab9f26b3..d651be84d5ab1 100644 --- a/pkg/tests/api/elasticsearch/elasticsearch_test.go +++ b/pkg/tests/api/elasticsearch/elasticsearch_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationElasticsearch(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/folders/api_folder_test.go b/pkg/tests/api/folders/api_folder_test.go new file mode 100644 index 0000000000000..5dbaf651c0b23 --- /dev/null +++ b/pkg/tests/api/folders/api_folder_test.go @@ -0,0 +1,220 @@ +package folders + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/go-openapi/runtime" + "github.com/grafana/grafana-openapi-client-go/client/folders" + "github.com/grafana/grafana-openapi-client-go/models" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" + "github.com/grafana/grafana/pkg/tests" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const orgID = 1 + +func TestIntegrationUpdateFolder(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + EnableQuota: true, + }) + + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) + // Create user + createUser(t, store, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "admin", + Login: "admin", + }) + + adminClient := tests.GetClient(grafanaListedAddr, "admin", "admin") + resp, err := adminClient.Folders.CreateFolder(&models.CreateFolderCommand{ + Title: "folder", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + + t.Run("update folder should succeed", func(t *testing.T) { + resp, err := adminClient.Folders.UpdateFolder(resp.Payload.UID, &models.UpdateFolderCommand{ + Title: "new title", + Version: resp.Payload.Version, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + require.Equal(t, "new title", resp.Payload.Title) + }) +} + +func TestIntegrationCreateFolder(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + EnableQuota: true, + }) + + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) + // Create user + createUser(t, store, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "admin", + Login: "admin", + }) + + adminClient := tests.GetClient(grafanaListedAddr, "admin", "admin") + + t.Run("create folder under root should succeed", func(t *testing.T) { + resp, err := adminClient.Folders.CreateFolder(&models.CreateFolderCommand{ + Title: "folder", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + + t.Run("create folder with same name under root should fail", func(t *testing.T) { + _, err := adminClient.Folders.CreateFolder(&models.CreateFolderCommand{ + Title: "folder", + }) + require.Error(t, err) + var conflict *folders.CreateFolderConflict + assert.True(t, errors.As(err, &conflict)) + assert.Equal(t, http.StatusConflict, conflict.Code()) + }) + }) +} + +func TestIntegrationNestedFoldersOn(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + EnableQuota: true, + EnableFeatureToggles: []string{featuremgmt.FlagNestedFolders}, + }) + + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) + // Create user + createUser(t, store, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "admin", + Login: "admin", + }) + + adminClient := tests.GetClient(grafanaListedAddr, "admin", "admin") + + t.Run("create folder under root should succeed", func(t *testing.T) { + resp, err := adminClient.Folders.CreateFolder(&models.CreateFolderCommand{ + Title: "folder", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + + t.Run("create folder with same name under root should fail", func(t *testing.T) { + _, err := adminClient.Folders.CreateFolder(&models.CreateFolderCommand{ + Title: "folder", + }) + require.Error(t, err) + var conflict *folders.CreateFolderConflict + assert.True(t, errors.As(err, &conflict)) + assert.Equal(t, http.StatusConflict, conflict.Code()) + }) + }) + + t.Run("create subfolder should succeed", func(t *testing.T) { + resp, err := adminClient.Folders.CreateFolder(&models.CreateFolderCommand{ + Title: "parent", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + parentUID := resp.Payload.UID + + resp, err = adminClient.Folders.CreateFolder(&models.CreateFolderCommand{ + Title: "subfolder", + ParentUID: parentUID, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + + t.Run("create subfolder with same name should fail", func(t *testing.T) { + resp, err = adminClient.Folders.CreateFolder(&models.CreateFolderCommand{ + Title: "subfolder", + ParentUID: parentUID, + }) + require.Error(t, err) + var conflict *folders.CreateFolderConflict + assert.True(t, errors.As(err, &conflict)) + assert.Equal(t, http.StatusConflict, conflict.Code()) + }) + + t.Run("create subfolder with same name under other folder should succeed", func(t *testing.T) { + resp, err := adminClient.Folders.CreateFolder(&models.CreateFolderCommand{ + Title: "other", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + other := resp.Payload.UID + + resp, err = adminClient.Folders.CreateFolder(&models.CreateFolderCommand{ + Title: "subfolder", + ParentUID: other, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.Code()) + assert.Equal(t, other, resp.Payload.ParentUID) + subfolderUnderOther := resp.Payload.UID + + t.Run("move subfolder to other folder containing folder with that name should fail", func(t *testing.T) { + _, err := adminClient.Folders.MoveFolder(subfolderUnderOther, &models.MoveFolderCommand{ + ParentUID: parentUID, + }) + require.Error(t, err) + var apiError *runtime.APIError + assert.True(t, errors.As(err, &apiError)) + assert.Equal(t, http.StatusConflict, apiError.Code) + }) + + t.Run("move subfolder to root should succeed", func(t *testing.T) { + resp, err := adminClient.Folders.MoveFolder(subfolderUnderOther, &models.MoveFolderCommand{}) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.Code()) + assert.Equal(t, "", resp.Payload.ParentUID) + }) + }) + }) +} + +func createUser(t *testing.T, store *sqlstore.SQLStore, cmd user.CreateUserCommand) int64 { + t.Helper() + + store.Cfg.AutoAssignOrg = true + store.Cfg.AutoAssignOrgId = orgID + + quotaService := quotaimpl.ProvideService(store, store.Cfg) + orgService, err := orgimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(store, orgService, store.Cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + require.NoError(t, err) + + u, err := usrSvc.Create(context.Background(), &cmd) + require.NoError(t, err) + return u.ID +} diff --git a/pkg/tests/api/folders/api_folders_test.go b/pkg/tests/api/folders/api_folders_test.go index fa934a996c1c8..d541757864eb6 100644 --- a/pkg/tests/api/folders/api_folders_test.go +++ b/pkg/tests/api/folders/api_folders_test.go @@ -1,13 +1,11 @@ package folders import ( - "context" "fmt" "net/http" - "runtime" "testing" + "time" - "github.com/grafana/dskit/concurrency" "github.com/grafana/grafana-openapi-client-go/client/folders" "github.com/grafana/grafana-openapi-client-go/models" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" @@ -17,10 +15,16 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/grafana/grafana/pkg/util/retryer" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestGetFolders(t *testing.T) { // Setup Grafana and its Database dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ @@ -64,22 +68,36 @@ func TestGetFolders(t *testing.T) { numberOfFolders := 5 indexWithoutPermission := 3 - err := concurrency.ForEachJob(context.Background(), numberOfFolders, runtime.NumCPU(), func(_ context.Context, job int) error { - resp, err := adminClient.Folders.CreateFolder(&models.CreateFolderCommand{ - Title: fmt.Sprintf("Folder %d", job), - UID: fmt.Sprintf("folder-%d", job), - }) - if err != nil { - return err - } - require.Equal(t, http.StatusOK, resp.Code()) - if job == indexWithoutPermission { - tests.RemoveFolderPermission(t, permissionsStore, orgID, org.RoleViewer, resp.Payload.UID) - t.Log("Removed viewer permission from folder", resp.Payload.UID) + + for i := 0; i < numberOfFolders; i++ { + respCode := 0 + folderUID := "" + retries := 0 + maxRetries := 3 + err := retryer.Retry(func() (retryer.RetrySignal, error) { + resp, err := adminClient.Folders.CreateFolder(&models.CreateFolderCommand{ + Title: fmt.Sprintf("Folder %d", i), + UID: fmt.Sprintf("folder-%d", i), + }) + if err != nil { + if retries == maxRetries { + return retryer.FuncError, err + } + retries++ + return retryer.FuncFailure, nil + } + respCode = resp.Code() + folderUID = resp.Payload.UID + return retryer.FuncComplete, nil + }, maxRetries, time.Millisecond*time.Duration(10), time.Second) + require.NoError(t, err) + + require.Equal(t, http.StatusOK, respCode) + if i == indexWithoutPermission { + tests.RemoveFolderPermission(t, permissionsStore, orgID, org.RoleViewer, folderUID) + t.Log("Removed viewer permission from folder", folderUID) } - return nil - }) - require.NoError(t, err) + } t.Run("Admin can get all folders", func(t *testing.T) { res, err := adminClient.Folders.GetFolders(folders.NewGetFoldersParams()) diff --git a/pkg/tests/api/graphite/graphite_test.go b/pkg/tests/api/graphite/graphite_test.go index 615209d625faa..e003574ac1d9c 100644 --- a/pkg/tests/api/graphite/graphite_test.go +++ b/pkg/tests/api/graphite/graphite_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationGraphite(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/influxdb/influxdb_test.go b/pkg/tests/api/influxdb/influxdb_test.go index bdaf6fefe46df..14a9b315fe990 100644 --- a/pkg/tests/api/influxdb/influxdb_test.go +++ b/pkg/tests/api/influxdb/influxdb_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationInflux(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/loki/loki_test.go b/pkg/tests/api/loki/loki_test.go index 8264c0936705f..f5cffd1c39bf9 100644 --- a/pkg/tests/api/loki/loki_test.go +++ b/pkg/tests/api/loki/loki_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationLoki(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/opentdsb/opentdsb_test.go b/pkg/tests/api/opentdsb/opentdsb_test.go index 2c04ac73ad11f..284e36d9297e9 100644 --- a/pkg/tests/api/opentdsb/opentdsb_test.go +++ b/pkg/tests/api/opentdsb/opentdsb_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationOpenTSDB(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/plugins/api_plugins_test.go b/pkg/tests/api/plugins/api_plugins_test.go index 4fc2295ed8bd3..94281f88989db 100644 --- a/pkg/tests/api/plugins/api_plugins_test.go +++ b/pkg/tests/api/plugins/api_plugins_test.go @@ -11,9 +11,13 @@ import ( "path/filepath" "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -22,6 +26,7 @@ import ( "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) const ( @@ -32,6 +37,10 @@ const ( var updateSnapshotFlag = false +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationPlugins(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -108,11 +117,13 @@ func TestIntegrationPlugins(t *testing.T) { require.Equal(t, tc.expStatus, resp.StatusCode) b, err := io.ReadAll(resp.Body) require.NoError(t, err) + var result dtos.PluginList + err = json.Unmarshal(b, &result) + require.NoError(t, err) expResp := expectedResp(t, tc.expRespPath) - same := assert.JSONEq(t, expResp, string(b)) - if !same { + if diff := cmp.Diff(expResp, result, cmpopts.IgnoreFields(plugins.Info{}, "Version")); diff != "" { if updateSnapshotFlag { t.Log("updating snapshot results") var prettyJSON bytes.Buffer @@ -121,6 +132,7 @@ func TestIntegrationPlugins(t *testing.T) { } updateRespSnapshot(t, tc.expRespPath, prettyJSON.String()) } + t.Errorf("unexpected response (-want +got):\n%s", diff) t.FailNow() } }) @@ -218,14 +230,19 @@ func grafanaAPIURL(username string, grafanaListedAddr string, path string) strin return fmt.Sprintf("http://%s:%s@%s/api/%s", username, defaultPassword, grafanaListedAddr, path) } -func expectedResp(t *testing.T, filename string) string { +func expectedResp(t *testing.T, filename string) dtos.PluginList { //nolint:GOSEC contents, err := os.ReadFile(filepath.Join("data", filename)) if err != nil { t.Errorf("failed to load %s: %v", filename, err) } - return string(contents) + var result dtos.PluginList + err = json.Unmarshal(contents, &result) + if err != nil { + t.Errorf("failed to unmarshal %s: %v", filename, err) + } + return result } func updateRespSnapshot(t *testing.T, filename string, body string) { diff --git a/pkg/tests/api/plugins/backendplugin/backendplugin_test.go b/pkg/tests/api/plugins/backendplugin/backendplugin_test.go index 31a53717ab0e5..9da079766e606 100644 --- a/pkg/tests/api/plugins/backendplugin/backendplugin_test.go +++ b/pkg/tests/api/plugins/backendplugin/backendplugin_test.go @@ -27,10 +27,15 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) const loginCookieName = "grafana_session" +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationBackendPlugins(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json index ee92e7548e5be..2e0adc347b5cb 100644 --- a/pkg/tests/api/plugins/data/expectedListResp.json +++ b/pkg/tests/api/plugins/data/expectedListResp.json @@ -13,13 +13,13 @@ "description": "Shows list of alerts and their current status", "links": null, "logos": { - "small": "/public/app/plugins/panel/alertlist/img/icn-singlestat-panel.svg", - "large": "/public/app/plugins/panel/alertlist/img/icn-singlestat-panel.svg" + "small": "public/app/plugins/panel/alertlist/img/icn-singlestat-panel.svg", + "large": "public/app/plugins/panel/alertlist/img/icn-singlestat-panel.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -47,7 +47,7 @@ "name": "Prometheus alertmanager", "url": "https://grafana.com" }, - "description": "", + "description": "Add external Alertmanagers (supports Prometheus and Mimir implementations) so you can use the Grafana Alerting UI to manage silences, contact points, and notification policies.", "links": [ { "name": "Learn more", @@ -55,13 +55,20 @@ } ], "logos": { - "small": "/public/app/plugins/datasource/alertmanager/img/logo.svg", - "large": "/public/app/plugins/datasource/alertmanager/img/logo.svg" + "small": "public/app/plugins/datasource/alertmanager/img/logo.svg", + "large": "public/app/plugins/datasource/alertmanager/img/logo.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": [ + "alerts", + "alerting", + "prometheus", + "alertmanager", + "mimir", + "cortex" + ] }, "dependencies": { "grafanaDependency": "", @@ -92,13 +99,13 @@ "description": "List annotations", "links": null, "logos": { - "small": "/public/app/plugins/panel/annolist/img/icn-annolist-panel.svg", - "large": "/public/app/plugins/panel/annolist/img/icn-annolist-panel.svg" + "small": "public/app/plugins/panel/annolist/img/icn-annolist-panel.svg", + "large": "public/app/plugins/panel/annolist/img/icn-annolist-panel.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -138,30 +145,36 @@ } ], "logos": { - "small": "/public/app/plugins/datasource/azuremonitor/img/logo.jpg", - "large": "/public/app/plugins/datasource/azuremonitor/img/logo.jpg" + "small": "public/app/plugins/datasource/azuremonitor/img/logo.jpg", + "large": "public/app/plugins/datasource/azuremonitor/img/logo.jpg" }, "build": {}, "screenshots": [ { "name": "Azure Contoso Loans", - "path": "/public/app/plugins/datasource/azuremonitor/img/contoso_loans_grafana_dashboard.png" + "path": "public/app/plugins/datasource/azuremonitor/img/contoso_loans_grafana_dashboard.png" }, { "name": "Azure Monitor Network", - "path": "/public/app/plugins/datasource/azuremonitor/img/azure_monitor_network.png" + "path": "public/app/plugins/datasource/azuremonitor/img/azure_monitor_network.png" }, { "name": "Azure Monitor CPU", - "path": "/public/app/plugins/datasource/azuremonitor/img/azure_monitor_cpu.png" + "path": "public/app/plugins/datasource/azuremonitor/img/azure_monitor_cpu.png" } ], - "version": "1.0.0", - "updated": "" + "updated": "", + "keywords": [ + "azure", + "monitor", + "Application Insights", + "Log Analytics", + "App Insights" + ] }, "dependencies": { - "grafanaDependency": "", - "grafanaVersion": "5.2.x", + "grafanaDependency": "\u003e=10.3.0", + "grafanaVersion": "*", "plugins": [] }, "latestVersion": "", @@ -188,13 +201,13 @@ "description": "Categorical charts with group support", "links": null, "logos": { - "small": "/public/app/plugins/panel/barchart/img/barchart.svg", - "large": "/public/app/plugins/panel/barchart/img/barchart.svg" + "small": "public/app/plugins/panel/barchart/img/barchart.svg", + "large": "public/app/plugins/panel/barchart/img/barchart.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -225,13 +238,13 @@ "description": "Horizontal and vertical gauges", "links": null, "logos": { - "small": "/public/app/plugins/panel/bargauge/img/icon_bar_gauge.svg", - "large": "/public/app/plugins/panel/bargauge/img/icon_bar_gauge.svg" + "small": "public/app/plugins/panel/bargauge/img/icon_bar_gauge.svg", + "large": "public/app/plugins/panel/bargauge/img/icon_bar_gauge.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -259,16 +272,21 @@ "name": "Grafana Labs", "url": "https://grafana.com" }, - "description": "", + "description": "Graphical representation of price movements of a security, derivative, or currency.", "links": null, "logos": { - "small": "/public/app/plugins/panel/candlestick/img/candlestick.svg", - "large": "/public/app/plugins/panel/candlestick/img/candlestick.svg" + "small": "public/app/plugins/panel/candlestick/img/candlestick.svg", + "large": "public/app/plugins/panel/candlestick/img/candlestick.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": [ + "financial", + "price", + "currency", + "k-line" + ] }, "dependencies": { "grafanaDependency": "", @@ -299,13 +317,13 @@ "description": "Explicit element placement", "links": null, "logos": { - "small": "/public/app/plugins/panel/canvas/img/icn-canvas.svg", - "large": "/public/app/plugins/panel/canvas/img/icn-canvas.svg" + "small": "public/app/plugins/panel/canvas/img/icn-canvas.svg", + "large": "public/app/plugins/panel/canvas/img/icn-canvas.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -336,13 +354,13 @@ "description": "Data source for Amazon AWS monitoring service", "links": null, "logos": { - "small": "/public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", - "large": "/public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png" + "small": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", + "large": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -373,13 +391,13 @@ "description": "List of dynamic links to other dashboards", "links": null, "logos": { - "small": "/public/app/plugins/panel/dashlist/img/icn-dashlist-panel.svg", - "large": "/public/app/plugins/panel/dashlist/img/icn-dashlist-panel.svg" + "small": "public/app/plugins/panel/dashlist/img/icn-dashlist-panel.svg", + "large": "public/app/plugins/panel/dashlist/img/icn-dashlist-panel.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -410,13 +428,13 @@ "description": "", "links": null, "logos": { - "small": "/public/app/plugins/panel/datagrid/img/icn-table-panel.svg", - "large": "/public/app/plugins/panel/datagrid/img/icn-table-panel.svg" + "small": "public/app/plugins/panel/datagrid/img/icn-table-panel.svg", + "large": "public/app/plugins/panel/datagrid/img/icn-table-panel.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -452,13 +470,15 @@ } ], "logos": { - "small": "/public/app/plugins/datasource/elasticsearch/img/elasticsearch.svg", - "large": "/public/app/plugins/datasource/elasticsearch/img/elasticsearch.svg" + "small": "public/app/plugins/datasource/elasticsearch/img/elasticsearch.svg", + "large": "public/app/plugins/datasource/elasticsearch/img/elasticsearch.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": [ + "elasticsearch" + ] }, "dependencies": { "grafanaDependency": "", @@ -489,13 +509,13 @@ "description": "", "links": null, "logos": { - "small": "/public/app/plugins/panel/flamegraph/img/icn-flamegraph.svg", - "large": "/public/app/plugins/panel/flamegraph/img/icn-flamegraph.svg" + "small": "public/app/plugins/panel/flamegraph/img/icn-flamegraph.svg", + "large": "public/app/plugins/panel/flamegraph/img/icn-flamegraph.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -526,13 +546,13 @@ "description": "Standard gauge visualization", "links": null, "logos": { - "small": "/public/app/plugins/panel/gauge/img/icon_gauge.svg", - "large": "/public/app/plugins/panel/gauge/img/icon_gauge.svg" + "small": "public/app/plugins/panel/gauge/img/icon_gauge.svg", + "large": "public/app/plugins/panel/gauge/img/icon_gauge.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -563,13 +583,13 @@ "description": "Geomap panel", "links": null, "logos": { - "small": "/public/app/plugins/panel/geomap/img/icn-geomap.svg", - "large": "/public/app/plugins/panel/geomap/img/icn-geomap.svg" + "small": "public/app/plugins/panel/geomap/img/icn-geomap.svg", + "large": "public/app/plugins/panel/geomap/img/icn-geomap.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -600,13 +620,13 @@ "description": "", "links": null, "logos": { - "small": "/public/app/plugins/panel/gettingstarted/img/icn-dashlist-panel.svg", - "large": "/public/app/plugins/panel/gettingstarted/img/icn-dashlist-panel.svg" + "small": "public/app/plugins/panel/gettingstarted/img/icn-dashlist-panel.svg", + "large": "public/app/plugins/panel/gettingstarted/img/icn-dashlist-panel.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -637,13 +657,13 @@ "description": "Data source for Google's monitoring service (formerly named Stackdriver)", "links": null, "logos": { - "small": "/public/app/plugins/datasource/cloud-monitoring/img/cloud_monitoring_logo.svg", - "large": "/public/app/plugins/datasource/cloud-monitoring/img/cloud_monitoring_logo.svg" + "small": "public/app/plugins/datasource/cloud-monitoring/img/cloud_monitoring_logo.svg", + "large": "public/app/plugins/datasource/cloud-monitoring/img/cloud_monitoring_logo.svg" }, "build": {}, "screenshots": null, - "version": "1.0.0", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -679,16 +699,24 @@ } ], "logos": { - "small": "/public/app/plugins/datasource/grafana-pyroscope-datasource/img/grafana_pyroscope_icon.svg", - "large": "/public/app/plugins/datasource/grafana-pyroscope-datasource/img/grafana_pyroscope_icon.svg" + "small": "public/app/plugins/datasource/grafana-pyroscope-datasource/img/grafana_pyroscope_icon.svg", + "large": "public/app/plugins/datasource/grafana-pyroscope-datasource/img/grafana_pyroscope_icon.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": [ + "grafana", + "datasource", + "phlare", + "flamegraph", + "profiling", + "continuous profiling", + "pyroscope" + ] }, "dependencies": { - "grafanaDependency": "", + "grafanaDependency": "\u003e=10.3.0-0", "grafanaVersion": "*", "plugins": [] }, @@ -716,13 +744,13 @@ "description": "The old default graph panel", "links": null, "logos": { - "small": "/public/app/plugins/panel/graph/img/icn-graph-panel.svg", - "large": "/public/app/plugins/panel/graph/img/icn-graph-panel.svg" + "small": "public/app/plugins/panel/graph/img/icn-graph-panel.svg", + "large": "public/app/plugins/panel/graph/img/icn-graph-panel.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -762,13 +790,13 @@ } ], "logos": { - "small": "/public/app/plugins/datasource/graphite/img/graphite_logo.png", - "large": "/public/app/plugins/datasource/graphite/img/graphite_logo.png" + "small": "public/app/plugins/datasource/graphite/img/graphite_logo.png", + "large": "public/app/plugins/datasource/graphite/img/graphite_logo.png" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -799,13 +827,13 @@ "description": "Like a histogram over time", "links": null, "logos": { - "small": "/public/app/plugins/panel/heatmap/img/icn-heatmap-panel.svg", - "large": "/public/app/plugins/panel/heatmap/img/icn-heatmap-panel.svg" + "small": "public/app/plugins/panel/heatmap/img/icn-heatmap-panel.svg", + "large": "public/app/plugins/panel/heatmap/img/icn-heatmap-panel.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -833,16 +861,21 @@ "name": "Grafana Labs", "url": "https://grafana.com" }, - "description": "", + "description": "Distribution of values presented as a bar chart.", "links": null, "logos": { - "small": "/public/app/plugins/panel/histogram/img/histogram.svg", - "large": "/public/app/plugins/panel/histogram/img/histogram.svg" + "small": "public/app/plugins/panel/histogram/img/histogram.svg", + "large": "public/app/plugins/panel/histogram/img/histogram.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": [ + "distribution", + "bar chart", + "frequency", + "proportional" + ] }, "dependencies": { "grafanaDependency": "", @@ -873,13 +906,13 @@ "description": "Open source time series database", "links": null, "logos": { - "small": "/public/app/plugins/datasource/influxdb/img/influxdb_logo.svg", - "large": "/public/app/plugins/datasource/influxdb/img/influxdb_logo.svg" + "small": "public/app/plugins/datasource/influxdb/img/influxdb_logo.svg", + "large": "public/app/plugins/datasource/influxdb/img/influxdb_logo.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -919,13 +952,13 @@ } ], "logos": { - "small": "/public/app/plugins/datasource/jaeger/img/jaeger_logo.svg", - "large": "/public/app/plugins/datasource/jaeger/img/jaeger_logo.svg" + "small": "public/app/plugins/datasource/jaeger/img/jaeger_logo.svg", + "large": "public/app/plugins/datasource/jaeger/img/jaeger_logo.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -956,13 +989,13 @@ "description": "", "links": null, "logos": { - "small": "/public/app/plugins/panel/logs/img/icn-logs-panel.svg", - "large": "/public/app/plugins/panel/logs/img/icn-logs-panel.svg" + "small": "public/app/plugins/panel/logs/img/icn-logs-panel.svg", + "large": "public/app/plugins/panel/logs/img/icn-logs-panel.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1002,13 +1035,13 @@ } ], "logos": { - "small": "/public/app/plugins/datasource/loki/img/loki_icon.svg", - "large": "/public/app/plugins/datasource/loki/img/loki_icon.svg" + "small": "public/app/plugins/datasource/loki/img/loki_icon.svg", + "large": "public/app/plugins/datasource/loki/img/loki_icon.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1039,13 +1072,13 @@ "description": "Data source for Microsoft SQL Server compatible databases", "links": null, "logos": { - "small": "/public/app/plugins/datasource/mssql/img/sql_server_logo.svg", - "large": "/public/app/plugins/datasource/mssql/img/sql_server_logo.svg" + "small": "public/app/plugins/datasource/mssql/img/sql_server_logo.svg", + "large": "public/app/plugins/datasource/mssql/img/sql_server_logo.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1076,13 +1109,13 @@ "description": "Data source for MySQL databases", "links": null, "logos": { - "small": "/public/app/plugins/datasource/mysql/img/mysql_logo.svg", - "large": "/public/app/plugins/datasource/mysql/img/mysql_logo.svg" + "small": "public/app/plugins/datasource/mysql/img/mysql_logo.svg", + "large": "public/app/plugins/datasource/mysql/img/mysql_logo.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1113,13 +1146,13 @@ "description": "RSS feed reader", "links": null, "logos": { - "small": "/public/app/plugins/panel/news/img/news.svg", - "large": "/public/app/plugins/panel/news/img/news.svg" + "small": "public/app/plugins/panel/news/img/news.svg", + "large": "public/app/plugins/panel/news/img/news.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1150,13 +1183,13 @@ "description": "", "links": null, "logos": { - "small": "/public/app/plugins/panel/nodeGraph/img/icn-node-graph.svg", - "large": "/public/app/plugins/panel/nodeGraph/img/icn-node-graph.svg" + "small": "public/app/plugins/panel/nodeGraph/img/icn-node-graph.svg", + "large": "public/app/plugins/panel/nodeGraph/img/icn-node-graph.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1187,13 +1220,13 @@ "description": "Open source time series database", "links": null, "logos": { - "small": "/public/app/plugins/datasource/opentsdb/img/opentsdb_logo.png", - "large": "/public/app/plugins/datasource/opentsdb/img/opentsdb_logo.png" + "small": "public/app/plugins/datasource/opentsdb/img/opentsdb_logo.png", + "large": "public/app/plugins/datasource/opentsdb/img/opentsdb_logo.png" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1229,13 +1262,18 @@ } ], "logos": { - "small": "/public/app/plugins/datasource/parca/img/logo-small.svg", - "large": "/public/app/plugins/datasource/parca/img/logo-small.svg" + "small": "public/app/plugins/datasource/parca/img/logo-small.svg", + "large": "public/app/plugins/datasource/parca/img/logo-small.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": [ + "grafana", + "datasource", + "parca", + "profiling" + ] }, "dependencies": { "grafanaDependency": "\u003e=10.3.0-0", @@ -1266,13 +1304,13 @@ "description": "The new core pie chart visualization", "links": null, "logos": { - "small": "/public/app/plugins/panel/piechart/img/icon_piechart.svg", - "large": "/public/app/plugins/panel/piechart/img/icon_piechart.svg" + "small": "public/app/plugins/panel/piechart/img/icon_piechart.svg", + "large": "public/app/plugins/panel/piechart/img/icon_piechart.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1303,13 +1341,13 @@ "description": "Data source for PostgreSQL and compatible databases", "links": null, "logos": { - "small": "/public/app/plugins/datasource/grafana-postgresql-datasource/img/postgresql_logo.svg", - "large": "/public/app/plugins/datasource/grafana-postgresql-datasource/img/postgresql_logo.svg" + "small": "public/app/plugins/datasource/grafana-postgresql-datasource/img/postgresql_logo.svg", + "large": "public/app/plugins/datasource/grafana-postgresql-datasource/img/postgresql_logo.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1345,13 +1383,13 @@ } ], "logos": { - "small": "/public/app/plugins/datasource/prometheus/img/prometheus_logo.svg", - "large": "/public/app/plugins/datasource/prometheus/img/prometheus_logo.svg" + "small": "public/app/plugins/datasource/prometheus/img/prometheus_logo.svg", + "large": "public/app/plugins/datasource/prometheus/img/prometheus_logo.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1382,13 +1420,13 @@ "description": "Big stat values \u0026 sparklines", "links": null, "logos": { - "small": "/public/app/plugins/panel/stat/img/icn-singlestat-panel.svg", - "large": "/public/app/plugins/panel/stat/img/icn-singlestat-panel.svg" + "small": "public/app/plugins/panel/stat/img/icn-singlestat-panel.svg", + "large": "public/app/plugins/panel/stat/img/icn-singlestat-panel.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1419,13 +1457,13 @@ "description": "State changes and durations", "links": null, "logos": { - "small": "/public/app/plugins/panel/state-timeline/img/timeline.svg", - "large": "/public/app/plugins/panel/state-timeline/img/timeline.svg" + "small": "public/app/plugins/panel/state-timeline/img/timeline.svg", + "large": "public/app/plugins/panel/state-timeline/img/timeline.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1456,13 +1494,13 @@ "description": "Periodic status history", "links": null, "logos": { - "small": "/public/app/plugins/panel/status-history/img/status.svg", - "large": "/public/app/plugins/panel/status-history/img/status.svg" + "small": "public/app/plugins/panel/status-history/img/status.svg", + "large": "public/app/plugins/panel/status-history/img/status.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1493,13 +1531,13 @@ "description": "Supports many column styles", "links": null, "logos": { - "small": "/public/app/plugins/panel/table/img/icn-table-panel.svg", - "large": "/public/app/plugins/panel/table/img/icn-table-panel.svg" + "small": "public/app/plugins/panel/table/img/icn-table-panel.svg", + "large": "public/app/plugins/panel/table/img/icn-table-panel.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1530,13 +1568,13 @@ "description": "Table Panel for Grafana", "links": null, "logos": { - "small": "/public/app/plugins/panel/table-old/img/icn-table-panel.svg", - "large": "/public/app/plugins/panel/table-old/img/icn-table-panel.svg" + "small": "public/app/plugins/panel/table-old/img/icn-table-panel.svg", + "large": "public/app/plugins/panel/table-old/img/icn-table-panel.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1572,16 +1610,16 @@ } ], "logos": { - "small": "/public/app/plugins/datasource/tempo/img/tempo_logo.svg", - "large": "/public/app/plugins/datasource/tempo/img/tempo_logo.svg" + "small": "public/app/plugins/datasource/tempo/img/tempo_logo.svg", + "large": "public/app/plugins/datasource/tempo/img/tempo_logo.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { - "grafanaDependency": "", + "grafanaDependency": "\u003e=10.3.0-0", "grafanaVersion": "*", "plugins": [] }, @@ -1609,13 +1647,13 @@ "description": "Generates test data in different forms", "links": null, "logos": { - "small": "/public/app/plugins/datasource/grafana-testdata-datasource/img/testdata.svg", - "large": "/public/app/plugins/datasource/grafana-testdata-datasource/img/testdata.svg" + "small": "public/app/plugins/datasource/grafana-testdata-datasource/img/testdata.svg", + "large": "public/app/plugins/datasource/grafana-testdata-datasource/img/testdata.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "\u003e=10.3.0-0", @@ -1646,13 +1684,13 @@ "description": "Supports markdown and html content", "links": null, "logos": { - "small": "/public/app/plugins/panel/text/img/icn-text-panel.svg", - "large": "/public/app/plugins/panel/text/img/icn-text-panel.svg" + "small": "public/app/plugins/panel/text/img/icn-text-panel.svg", + "large": "public/app/plugins/panel/text/img/icn-text-panel.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1683,13 +1721,13 @@ "description": "Time based line, area and bar charts", "links": null, "logos": { - "small": "/public/app/plugins/panel/timeseries/img/icn-timeseries-panel.svg", - "large": "/public/app/plugins/panel/timeseries/img/icn-timeseries-panel.svg" + "small": "public/app/plugins/panel/timeseries/img/icn-timeseries-panel.svg", + "large": "public/app/plugins/panel/timeseries/img/icn-timeseries-panel.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1720,13 +1758,13 @@ "description": "", "links": null, "logos": { - "small": "/public/app/plugins/panel/traces/img/traces-panel.svg", - "large": "/public/app/plugins/panel/traces/img/traces-panel.svg" + "small": "public/app/plugins/panel/traces/img/traces-panel.svg", + "large": "public/app/plugins/panel/traces/img/traces-panel.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1757,13 +1795,13 @@ "description": "Like timeseries, but when x != time", "links": null, "logos": { - "small": "/public/app/plugins/panel/trend/img/trend.svg", - "large": "/public/app/plugins/panel/trend/img/trend.svg" + "small": "public/app/plugins/panel/trend/img/trend.svg", + "large": "public/app/plugins/panel/trend/img/trend.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1794,13 +1832,13 @@ "description": "", "links": null, "logos": { - "small": "/public/app/plugins/panel/welcome/img/icn-dashlist-panel.svg", - "large": "/public/app/plugins/panel/welcome/img/icn-dashlist-panel.svg" + "small": "public/app/plugins/panel/welcome/img/icn-dashlist-panel.svg", + "large": "public/app/plugins/panel/welcome/img/icn-dashlist-panel.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { "grafanaDependency": "", @@ -1828,16 +1866,19 @@ "name": "Grafana Labs", "url": "https://grafana.com" }, - "description": "", + "description": "Supports arbitrary X vs Y in a graph to visualize the relationship between two variables.", "links": null, "logos": { - "small": "/public/app/plugins/panel/xychart/img/icn-xychart.svg", - "large": "/public/app/plugins/panel/xychart/img/icn-xychart.svg" + "small": "public/app/plugins/panel/xychart/img/icn-xychart.svg", + "large": "public/app/plugins/panel/xychart/img/icn-xychart.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": [ + "scatter", + "plot" + ] }, "dependencies": { "grafanaDependency": "", @@ -1873,16 +1914,16 @@ } ], "logos": { - "small": "/public/app/plugins/datasource/zipkin/img/zipkin-logo.svg", - "large": "/public/app/plugins/datasource/zipkin/img/zipkin-logo.svg" + "small": "public/app/plugins/datasource/zipkin/img/zipkin-logo.svg", + "large": "public/app/plugins/datasource/zipkin/img/zipkin-logo.svg" }, "build": {}, "screenshots": null, - "version": "", - "updated": "" + "updated": "", + "keywords": null }, "dependencies": { - "grafanaDependency": "", + "grafanaDependency": "\u003e=10.3.0-0", "grafanaVersion": "*", "plugins": [] }, diff --git a/pkg/tests/api/prometheus/prometheus_test.go b/pkg/tests/api/prometheus/prometheus_test.go index a2de470823240..df2182ff74381 100644 --- a/pkg/tests/api/prometheus/prometheus_test.go +++ b/pkg/tests/api/prometheus/prometheus_test.go @@ -17,8 +17,13 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationPrometheus(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/stats/admin_test.go b/pkg/tests/api/stats/admin_test.go index 5a9c910711574..0204f24d8fad4 100644 --- a/pkg/tests/api/stats/admin_test.go +++ b/pkg/tests/api/stats/admin_test.go @@ -16,8 +16,13 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationAdminStats(t *testing.T) { t.Run("with unified alerting enabled", func(t *testing.T) { url := grafanaSetup(t, testinfra.GrafanaOpts{ diff --git a/pkg/tests/apis/dashboard/dashboards_test.go b/pkg/tests/apis/dashboard/dashboards_test.go new file mode 100644 index 0000000000000..9771ef718e4a4 --- /dev/null +++ b/pkg/tests/apis/dashboard/dashboards_test.go @@ -0,0 +1,117 @@ +package dashboards + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func TestIntegrationRequiresDevMode(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, // should fail + DisableAnonymous: true, + EnableFeatureToggles: []string{ + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the example service + }, + }) + + _, err := helper.NewDiscoveryClient().ServerResourcesForGroupVersion("dashboard.grafana.app/v0alpha1") + require.Error(t, err) +} + +func TestIntegrationDashboardsApp(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, // required for experimental APIs + DisableAnonymous: true, + EnableFeatureToggles: []string{ + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the example service + }, + }) + _, err := helper.NewDiscoveryClient().ServerResourcesForGroupVersion("dashboard.grafana.app/v0alpha1") + require.NoError(t, err) + + t.Run("Check discovery client", func(t *testing.T) { + disco := helper.GetGroupVersionInfoJSON("dashboard.grafana.app") + // fmt.Printf("%s", string(disco)) + + require.JSONEq(t, `[ + { + "freshness": "Current", + "resources": [ + { + "resource": "dashboards", + "responseKind": { + "group": "", + "kind": "Dashboard", + "version": "" + }, + "scope": "Namespaced", + "singularResource": "dashboard", + "subresources": [ + { + "responseKind": { + "group": "", + "kind": "DashboardAccessInfo", + "version": "" + }, + "subresource": "access", + "verbs": [ + "get" + ] + }, + { + "responseKind": { + "group": "", + "kind": "DashboardVersionList", + "version": "" + }, + "subresource": "versions", + "verbs": [ + "get" + ] + } + ], + "verbs": [ + "create", + "delete", + "get", + "list", + "patch", + "update" + ] + }, + { + "resource": "summary", + "responseKind": { + "group": "", + "kind": "DashboardSummary", + "version": "" + }, + "scope": "Namespaced", + "singularResource": "summary", + "verbs": [ + "get", + "list" + ] + } + ], + "version": "v0alpha1" + } + ]`, disco) + }) +} diff --git a/pkg/tests/apis/dashboard/testdata/dashboard-generate.yaml b/pkg/tests/apis/dashboard/testdata/dashboard-generate.yaml new file mode 100644 index 0000000000000..d5dfcb785645e --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/dashboard-generate.yaml @@ -0,0 +1,6 @@ +apiVersion: dashboard.grafana.app/v0alpha1 +kind: Dashboard +metadata: + generateName: x # anything is ok here... except yes or true -- they become boolean! +spec: + title: Dashboard with auto generated UID ${NOW} \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/dashboard-test-apply.yaml b/pkg/tests/apis/dashboard/testdata/dashboard-test-apply.yaml new file mode 100644 index 0000000000000..912d995742388 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/dashboard-test-apply.yaml @@ -0,0 +1,6 @@ +apiVersion: dashboard.grafana.app/v0alpha1 +kind: Dashboard +metadata: + name: test +spec: + title: Test dashboard (apply from k8s; PATCH) X diff --git a/pkg/tests/apis/dashboard/testdata/dashboard-test-create.yaml b/pkg/tests/apis/dashboard/testdata/dashboard-test-create.yaml new file mode 100644 index 0000000000000..d6a04640771ec --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/dashboard-test-create.yaml @@ -0,0 +1,6 @@ +apiVersion: dashboard.grafana.app/v0alpha1 +kind: Dashboard +metadata: + name: test +spec: + title: Test dashboard (created from k8s; POST) \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/dashboard-test-replace.yaml b/pkg/tests/apis/dashboard/testdata/dashboard-test-replace.yaml new file mode 100644 index 0000000000000..7dc46c4e18725 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/dashboard-test-replace.yaml @@ -0,0 +1,6 @@ +apiVersion: dashboard.grafana.app/v0alpha1 +kind: Dashboard +metadata: + name: test +spec: + title: Test dashboard (replaced from k8s; PUT) \ No newline at end of file diff --git a/pkg/tests/apis/dashboardsnapshot/snapshots_test.go b/pkg/tests/apis/dashboardsnapshot/snapshots_test.go new file mode 100644 index 0000000000000..bf787a7916028 --- /dev/null +++ b/pkg/tests/apis/dashboardsnapshot/snapshots_test.go @@ -0,0 +1,85 @@ +package dashboardsnapshots + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func TestIntegrationDashboardSnapshots(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, // required for experimental apis + DisableAnonymous: true, + EnableFeatureToggles: []string{ + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // required to register dashboardsnapshot.grafana.app + }, + }) + + t.Run("Check discovery client", func(t *testing.T) { + disco := helper.GetGroupVersionInfoJSON("dashboardsnapshot.grafana.app") + + // fmt.Printf("%s", disco) + require.JSONEq(t, `[ + { + "freshness": "Current", + "resources": [ + { + "resource": "dashboardsnapshots", + "responseKind": { + "group": "", + "kind": "DashboardSnapshot", + "version": "" + }, + "scope": "Namespaced", + "singularResource": "dashboardsnapshot", + "subresources": [ + { + "responseKind": { + "group": "", + "kind": "FullDashboardSnapshot", + "version": "" + }, + "subresource": "body", + "verbs": [ + "get" + ] + } + ], + "verbs": [ + "delete", + "get", + "list" + ] + }, + { + "resource": "options", + "responseKind": { + "group": "", + "kind": "SharingOptions", + "version": "" + }, + "scope": "Namespaced", + "singularResource": "options", + "verbs": [ + "get", + "list" + ] + } + ], + "version": "v0alpha1" + } + ]`, disco) + }) +} diff --git a/pkg/tests/apis/datasource/testdata_test.go b/pkg/tests/apis/datasource/testdata_test.go new file mode 100644 index 0000000000000..328cf81e35283 --- /dev/null +++ b/pkg/tests/apis/datasource/testdata_test.go @@ -0,0 +1,148 @@ +package dashboards + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func TestIntegrationTestDatasource(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, // dev mode required for datasource connections + DisableAnonymous: true, + EnableFeatureToggles: []string{ + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the example service + }, + }) + + // Create a single datasource + ds := helper.CreateDS(&datasources.AddDataSourceCommand{ + Name: "test", + Type: datasources.DS_TESTDATA, + UID: "test", + OrgID: int64(1), + }) + require.Equal(t, "test", ds.UID) + + t.Run("Check discovery client", func(t *testing.T) { + disco := helper.GetGroupVersionInfoJSON("testdata.datasource.grafana.app") + // fmt.Printf("%s", string(disco)) + + require.JSONEq(t, `[ + { + "freshness": "Current", + "resources": [ + { + "resource": "connections", + "responseKind": { + "group": "", + "kind": "DataSourceConnection", + "version": "" + }, + "scope": "Namespaced", + "shortNames": [ + "grafana-testdata-datasource-connection" + ], + "singularResource": "connection", + "subresources": [ + { + "responseKind": { + "group": "", + "kind": "HealthCheckResult", + "version": "" + }, + "subresource": "health", + "verbs": [ + "get" + ] + }, + { + "responseKind": { + "group": "", + "kind": "QueryDataResponse", + "version": "" + }, + "subresource": "query", + "verbs": [ + "create" + ] + }, + { + "responseKind": { + "group": "", + "kind": "Status", + "version": "" + }, + "subresource": "resource", + "verbs": [ + "create", + "delete", + "get", + "patch", + "update" + ] + } + ], + "verbs": [ + "get", + "list" + ] + } + ], + "version": "v0alpha1" + } + ]`, disco) + }) + + t.Run("Call subresources", func(t *testing.T) { + client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{ + Group: "testdata.datasource.grafana.app", + Version: "v0alpha1", + Resource: "connections", + }).Namespace("default") + ctx := context.Background() + + list, err := client.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, list.Items, 1, "expected a single connection") + require.Equal(t, "test", list.Items[0].GetName(), "with the test uid") + + rsp, err := client.Get(ctx, "test", metav1.GetOptions{}, "health") + require.NoError(t, err) + body, err := rsp.MarshalJSON() + require.NoError(t, err) + //fmt.Printf("GOT: %v\n", string(body)) + require.JSONEq(t, `{ + "apiVersion": "testdata.datasource.grafana.app/v0alpha1", + "code": 1, + "kind": "HealthCheckResult", + "message": "Data source is working", + "status": "OK" + } + `, string(body)) + + // Test connecting to non-JSON marshaled data + raw := apis.DoRequest[any](helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: "GET", + Path: "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/default/connections/test/resource", + }, nil) + require.Equal(t, `Hello world from test datasource!`, string(raw.Body)) + }) +} diff --git a/pkg/tests/apis/example/example_test.go b/pkg/tests/apis/example/example_test.go index 608ae5aab9fe0..c702402837fd5 100644 --- a/pkg/tests/apis/example/example_test.go +++ b/pkg/tests/apis/example/example_test.go @@ -8,14 +8,20 @@ import ( "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) -func TestExampleApp(t *testing.T) { +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func TestIntegrationExampleApp(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } @@ -23,14 +29,13 @@ func TestExampleApp(t *testing.T) { AppModeProduction: false, // required for experimental APIs DisableAnonymous: true, EnableFeatureToggles: []string{ - featuremgmt.FlagGrafanaAPIServer, featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the example service }, }) t.Run("Check runtime info resource", func(t *testing.T) { // Resource is not namespaced! - client := helper.Org1.Admin.Client.Resource(schema.GroupVersionResource{ + client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{ Group: "example.grafana.app", Version: "v0alpha1", Resource: "runtime", @@ -50,7 +55,7 @@ func TestExampleApp(t *testing.T) { v1Disco, err := json.MarshalIndent(resources, "", " ") require.NoError(t, err) - // fmt.Printf("%s", string(v1Disco)) + //fmt.Printf("%s", string(v1Disco)) require.JSONEq(t, `{ "kind": "APIResourceList", @@ -140,7 +145,7 @@ func TestExampleApp(t *testing.T) { }) t.Run("Check dummy with subresource", func(t *testing.T) { - client := helper.Org1.Viewer.Client.Resource(schema.GroupVersionResource{ + client := helper.Org1.Viewer.ResourceClient(t, schema.GroupVersionResource{ Group: "example.grafana.app", Version: "v0alpha1", Resource: "dummy", @@ -148,7 +153,11 @@ func TestExampleApp(t *testing.T) { rsp, err := client.Get(context.Background(), "test2", metav1.GetOptions{}) require.NoError(t, err) - require.Equal(t, "dummy: test2", rsp.Object["spec"]) + v, ok, err := unstructured.NestedString(rsp.Object, "spec", "Dummy") + require.NoError(t, err) + require.True(t, ok) + + require.Equal(t, "test2", v) require.Equal(t, "DummyResource", rsp.GetObjectKind().GroupVersionKind().Kind) // Now a sub-resource diff --git a/pkg/tests/apis/folders/folders_test.go b/pkg/tests/apis/folder/folders_test.go similarity index 75% rename from pkg/tests/apis/folders/folders_test.go rename to pkg/tests/apis/folder/folders_test.go index 005ed140e1c63..1dbbddf3ff671 100644 --- a/pkg/tests/apis/folders/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -9,24 +9,27 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) -func TestFoldersApp(t *testing.T) { +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func TestIntegrationFoldersApp(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ AppModeProduction: false, // required for experimental APIs - DisableAnonymous: true, EnableFeatureToggles: []string{ - featuremgmt.FlagGrafanaAPIServer, featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the example service }, }) t.Run("Check discovery client", func(t *testing.T) { disco := helper.NewDiscoveryClient() - resources, err := disco.ServerResourcesForGroupVersion("folders.grafana.app/v0alpha1") + resources, err := disco.ServerResourcesForGroupVersion("folder.grafana.app/v0alpha1") require.NoError(t, err) v1Disco, err := json.MarshalIndent(resources, "", " ") @@ -36,7 +39,7 @@ func TestFoldersApp(t *testing.T) { require.JSONEq(t, `{ "kind": "APIResourceList", "apiVersion": "v1", - "groupVersion": "folders.grafana.app/v0alpha1", + "groupVersion": "folder.grafana.app/v0alpha1", "resources": [ { "name": "folders", @@ -53,10 +56,19 @@ func TestFoldersApp(t *testing.T) { ] }, { - "name": "folders/children", + "name": "folders/access", + "singularName": "", + "namespaced": true, + "kind": "FolderAccessInfo", + "verbs": [ + "get" + ] + }, + { + "name": "folders/count", "singularName": "", "namespaced": true, - "kind": "FolderInfo", + "kind": "DescendantCounts", "verbs": [ "get" ] @@ -65,7 +77,7 @@ func TestFoldersApp(t *testing.T) { "name": "folders/parents", "singularName": "", "namespaced": true, - "kind": "FolderInfo", + "kind": "FolderInfoList", "verbs": [ "get" ] diff --git a/pkg/tests/apis/folders/testdata/folder-generate.yaml b/pkg/tests/apis/folder/testdata/folder-generate.yaml similarity index 82% rename from pkg/tests/apis/folders/testdata/folder-generate.yaml rename to pkg/tests/apis/folder/testdata/folder-generate.yaml index 3fec910e859cc..a4c55dbaf5e0c 100644 --- a/pkg/tests/apis/folders/testdata/folder-generate.yaml +++ b/pkg/tests/apis/folder/testdata/folder-generate.yaml @@ -1,4 +1,4 @@ -apiVersion: folders.grafana.app/v0alpha1 +apiVersion: folder.grafana.app/v0alpha1 kind: Folder metadata: generateName: x # anything is ok here... except yes or true -- they become boolean! diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index ee7e2c24affa1..fbb6bc3517663 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "testing" + "time" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/errors" @@ -25,9 +26,9 @@ import ( "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/server" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotaimpl" @@ -54,6 +55,7 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper { t.Helper() dir, path := testinfra.CreateGrafDir(t, opts) _, env := testinfra.StartGrafanaEnv(t, dir, path) + c := &K8sTestHelper{ env: *env, t: t, @@ -63,16 +65,28 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper { c.Org1 = c.createTestUsers("Org1") c.OrgB = c.createTestUsers("OrgB") - // Read the API groups - rsp := DoRequest(c, RequestParams{ - User: c.Org1.Viewer, - Path: "/apis", - // Accept: "application/json;g=apidiscovery.k8s.io;v=v2beta1;as=APIGroupDiscoveryList,application/json", - }, &metav1.APIGroupList{}) - c.groups = rsp.Result.Groups + c.loadAPIGroups() + return c } +func (c *K8sTestHelper) loadAPIGroups() { + for { + rsp := DoRequest(c, RequestParams{ + User: c.Org1.Viewer, + Path: "/apis", + // Accept: "application/json;g=apidiscovery.k8s.io;v=v2beta1;as=APIGroupDiscoveryList,application/json", + }, &metav1.APIGroupList{}) + + if rsp.Response.StatusCode == http.StatusOK { + c.groups = rsp.Result.Groups + return + } + + time.Sleep(100 * time.Millisecond) + } +} + func (c *K8sTestHelper) Shutdown() { err := c.env.Server.Shutdown(context.Background(), "done") require.NoError(c.t, err) @@ -98,10 +112,13 @@ func (c *K8sTestHelper) GetResourceClient(args ResourceClientArgs) *K8sResourceC args.Namespace = c.namespacer(args.User.Identity.GetOrgID()) } + client, err := dynamic.NewForConfig(args.User.NewRestConfig()) + require.NoError(c.t, err) + return &K8sResourceClient{ t: c.t, Args: args, - Resource: args.User.Client.Resource(args.GVR).Namespace(args.Namespace), + Resource: client.Resource(args.GVR).Namespace(args.Namespace), } } @@ -135,6 +152,7 @@ func (c *K8sResourceClient) SanitizeJSON(v *unstructured.Unstructured) string { delete(anno, "grafana.app/originTimestamp") delete(anno, "grafana.app/createdBy") delete(anno, "grafana.app/updatedBy") + delete(anno, "grafana.app/action") deep.SetAnnotations(anno) copy := deep.Object @@ -163,8 +181,31 @@ type OrgUsers struct { type User struct { Identity identity.Requester - Client *dynamic.DynamicClient password string + baseURL string +} + +func (c *User) NewRestConfig() *rest.Config { + return &rest.Config{ + Host: c.baseURL, + Username: c.Identity.GetLogin(), + Password: c.password, + } +} + +func (c *User) ResourceClient(t *testing.T, gvr schema.GroupVersionResource) dynamic.NamespaceableResourceInterface { + client, err := dynamic.NewForConfig(c.NewRestConfig()) + require.NoError(t, err) + return client.Resource(gvr) +} + +func (c *User) RESTClient(t *testing.T, gv *schema.GroupVersion) *rest.RESTClient { + cfg := dynamic.ConfigFor(c.NewRestConfig()) // adds negotiated serializers! + cfg.GroupVersion = gv + cfg.APIPath = "apis" // the plural + client, err := rest.RESTClientFor(cfg) + require.NoError(t, err) + return client } type RequestParams struct { @@ -351,7 +392,9 @@ func (c K8sTestHelper) createTestUsers(orgName string) OrgUsers { store.Cfg.AutoAssignOrg = true store.Cfg.AutoAssignOrgId = int(orgId) - teamSvc := teamimpl.ProvideService(store, store.Cfg) + teamSvc, err := teamimpl.ProvideService(store, store.Cfg) + require.NoError(c.t, err) + cache := localcache.ProvideService() userSvc, err := userimpl.ProvideService(store, orgService, store.Cfg, teamSvc, cache, quotaService, @@ -362,7 +405,7 @@ func (c K8sTestHelper) createTestUsers(orgName string) OrgUsers { createUser := func(key string, role org.RoleType) User { u, err := userSvc.Create(context.Background(), &user.CreateUserCommand{ DefaultOrgRole: string(role), - Password: key, + Password: user.Password(key), Login: fmt.Sprintf("%s-%d", key, orgId), OrgID: orgId, }) @@ -380,19 +423,10 @@ func (c K8sTestHelper) createTestUsers(orgName string) OrgUsers { require.Equal(c.t, orgId, s.OrgID) require.Equal(c.t, role, s.OrgRole) // make sure the role was set properly - config := &rest.Config{ - Host: baseUrl, - Username: s.Login, - Password: key, - } - - client, err := dynamic.NewForConfig(config) - require.NoError(c.t, err) - return User{ Identity: s, - Client: client, password: key, + baseURL: baseUrl, } } return OrgUsers{ diff --git a/pkg/tests/apis/playlist/playlist_test.go b/pkg/tests/apis/playlist/playlist_test.go index decf3c7324028..91ec2f1d23cc1 100644 --- a/pkg/tests/apis/playlist/playlist_test.go +++ b/pkg/tests/apis/playlist/playlist_test.go @@ -18,26 +18,29 @@ import ( "github.com/grafana/grafana/pkg/services/playlist" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + var gvr = schema.GroupVersionResource{ Group: "playlist.grafana.app", Version: "v0alpha1", Resource: "playlists", } -func TestPlaylist(t *testing.T) { +func TestIntegrationPlaylist(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } t.Run("default setup", func(t *testing.T) { h := doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ - AppModeProduction: true, // do not start extra port 6443 - DisableAnonymous: true, - EnableFeatureToggles: []string{ - featuremgmt.FlagGrafanaAPIServer, - }, + AppModeProduction: true, // do not start extra port 6443 + DisableAnonymous: true, + EnableFeatureToggles: []string{}, })) // The accepted verbs will change when dual write is enabled @@ -76,7 +79,6 @@ func TestPlaylist(t *testing.T) { AppModeProduction: true, // do not start extra port 6443 DisableAnonymous: true, EnableFeatureToggles: []string{ - featuremgmt.FlagGrafanaAPIServer, featuremgmt.FlagKubernetesPlaylists, // <<< The change we are testing! }, })) @@ -88,7 +90,6 @@ func TestPlaylist(t *testing.T) { DisableAnonymous: true, APIServerStorageType: "file", // write the files to disk EnableFeatureToggles: []string{ - featuremgmt.FlagGrafanaAPIServer, featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written }, })) @@ -101,7 +102,6 @@ func TestPlaylist(t *testing.T) { APIServerStorageType: "unified", // use the entity api tables EnableFeatureToggles: []string{ featuremgmt.FlagUnifiedStorage, - featuremgmt.FlagGrafanaAPIServer, featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written }, })) @@ -116,7 +116,6 @@ func TestPlaylist(t *testing.T) { DisableAnonymous: true, APIServerStorageType: "etcd", // requires etcd running on localhost:2379 EnableFeatureToggles: []string{ - featuremgmt.FlagGrafanaAPIServer, featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written }, }) diff --git a/pkg/tests/apis/query/query_test.go b/pkg/tests/apis/query/query_test.go new file mode 100644 index 0000000000000..14426e939e072 --- /dev/null +++ b/pkg/tests/apis/query/query_test.go @@ -0,0 +1,129 @@ +package dashboards + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func TestIntegrationSimpleQuery(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, // dev mode required for datasource connections + EnableFeatureToggles: []string{ + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the example service + }, + }) + + // Create a single datasource + ds := helper.CreateDS(&datasources.AddDataSourceCommand{ + Name: "test", + Type: datasources.DS_TESTDATA, + UID: "test", + OrgID: int64(1), + }) + require.Equal(t, "test", ds.UID) + + t.Run("Call query with expression", func(t *testing.T) { + client := helper.Org1.Admin.RESTClient(t, &schema.GroupVersion{ + Group: "query.grafana.app", + Version: "v0alpha1", + }) + + q1 := data.DataQuery{ + CommonQueryProperties: data.CommonQueryProperties{ + RefID: "X", + Datasource: &data.DataSourceRef{ + Type: "grafana-testdata-datasource", + UID: ds.UID, + }, + }, + } + q1.Set("scenarioId", "csv_content") + q1.Set("csvContent", "a\n1") + + q2 := data.DataQuery{ + CommonQueryProperties: data.CommonQueryProperties{ + RefID: "Y", + Datasource: &data.DataSourceRef{ + UID: "__expr__", + }, + }, + } + q2.Set("type", "math") + q2.Set("expression", "$X + 2") + + body, err := json.Marshal(&data.QueryDataRequest{ + Queries: []data.DataQuery{ + q1, q2, + // https://github.com/grafana/grafana-plugin-sdk-go/pull/921 + // data.NewDataQuery(map[string]any{ + // "refId": "X", + // "datasource": data.DataSourceRef{ + // Type: "grafana-testdata-datasource", + // UID: ds.UID, + // }, + // "scenarioId": "csv_content", + // "csvContent": "a\n1", + // }), + // data.NewDataQuery(map[string]any{ + // "refId": "Y", + // "datasource": data.DataSourceRef{ + // UID: "__expr__", + // }, + // "type": "math", + // "expression": "$X + 2", + // }), + }, + }) + + //fmt.Printf("%s", string(body)) + + require.NoError(t, err) + + result := client.Post(). + Namespace("default"). + Suffix("query"). + SetHeader("Content-type", "application/json"). + Body(body). + Do(context.Background()) + + require.NoError(t, result.Error()) + + body, err = result.Raw() + require.NoError(t, err) + fmt.Printf("OUT: %s", string(body)) + + rsp := &backend.QueryDataResponse{} + err = json.Unmarshal(body, rsp) + require.NoError(t, err) + require.Equal(t, 2, len(rsp.Responses)) + + frameX := rsp.Responses["X"].Frames[0] + frameY := rsp.Responses["Y"].Frames[0] + + vX, _ := frameX.Fields[0].ConcreteAt(0) + vY, _ := frameY.Fields[0].ConcreteAt(0) + + require.Equal(t, int64(1), vX) + require.Equal(t, float64(3), vY) // 1 + 2, but always float64 + }) +} diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index cc587bdd52be8..d2b920bfd11e7 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -50,9 +50,8 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes serverOpts := server.Options{Listener: listener, HomePath: grafDir} apiServerOpts := api.ServerOptions{Listener: listener} - env, err := server.InitializeForTest(cfg, serverOpts, apiServerOpts) + env, err := server.InitializeForTest(t, cfg, serverOpts, apiServerOpts) require.NoError(t, err) - require.NoError(t, env.SQLStore.Sync()) require.NotNil(t, env.SQLStore.Cfg) dbSec, err := env.SQLStore.Cfg.Raw.GetSection("database") @@ -99,21 +98,6 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes return addr, env } -// SetUpDatabase sets up the Grafana database. -func SetUpDatabase(t *testing.T, grafDir string) *sqlstore.SQLStore { - t.Helper() - - sqlStore := db.InitTestDB(t, sqlstore.InitTestDBOpt{ - EnsureDefaultOrgAndUser: true, - }) - - // Make sure changes are synced with other goroutines - err := sqlStore.Sync() - require.NoError(t, err) - - return sqlStore -} - // CreateGrafDir creates the Grafana directory. // The log by default is muted in the regression test, to activate it, pass option EnableLog = true func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) { @@ -335,12 +319,6 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) { _, err = usersSection.NewKey("viewers_can_edit", "true") require.NoError(t, err) } - if o.DisableLegacyAlerting { - alertingSection, err := cfg.GetSection("alerting") - require.NoError(t, err) - _, err = alertingSection.NewKey("enabled", "false") - require.NoError(t, err) - } if o.EnableUnifiedAlerting { unifiedAlertingSection, err := getOrCreateSection("unified_alerting") require.NoError(t, err) @@ -394,6 +372,15 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) { require.NoError(t, err) _, err = logSection.NewKey("query_retries", fmt.Sprintf("%d", queryRetries)) require.NoError(t, err) + + if o.NGAlertSchedulerBaseInterval > 0 { + unifiedAlertingSection, err := getOrCreateSection("unified_alerting") + require.NoError(t, err) + _, err = unifiedAlertingSection.NewKey("scheduler_tick_interval", o.NGAlertSchedulerBaseInterval.String()) + require.NoError(t, err) + _, err = unifiedAlertingSection.NewKey("min_interval", o.NGAlertSchedulerBaseInterval.String()) + require.NoError(t, err) + } } cfgPath := filepath.Join(cfgDir, "test.ini") @@ -419,6 +406,7 @@ type GrafanaOpts struct { EnableFeatureToggles []string NGAlertAdminConfigPollInterval time.Duration NGAlertAlertmanagerConfigPollInterval time.Duration + NGAlertSchedulerBaseInterval time.Duration AnonymousUserRole org.RoleType EnableQuota bool DashboardOrgQuota *int64 diff --git a/pkg/tests/testsuite/testsuite.go b/pkg/tests/testsuite/testsuite.go new file mode 100644 index 0000000000000..87ce57eaa15d7 --- /dev/null +++ b/pkg/tests/testsuite/testsuite.go @@ -0,0 +1,15 @@ +package testsuite + +import ( + "os" + "testing" + + "github.com/grafana/grafana/pkg/infra/db" +) + +func Run(m *testing.M) { + db.SetupTestDB() + code := m.Run() + db.CleanupTestDB() + os.Exit(code) +} diff --git a/pkg/tests/web/index_view_test.go b/pkg/tests/web/index_view_test.go index e8208f083f21c..330553c2638af 100644 --- a/pkg/tests/web/index_view_test.go +++ b/pkg/tests/web/index_view_test.go @@ -19,8 +19,13 @@ import ( secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + // TestIntegrationIndexView tests the Grafana index view. func TestIntegrationIndexView(t *testing.T) { if testing.Short() { diff --git a/pkg/tsdb/Magefile.go b/pkg/tsdb/Magefile.go index 89b3722826049..c875964a4cb89 100644 --- a/pkg/tsdb/Magefile.go +++ b/pkg/tsdb/Magefile.go @@ -26,8 +26,16 @@ func find(dir string, name string) ([]string, error) { return files, err } -func findPluginJSONDir(pluginDir string) (string, error) { - pluginJSONMatches, err := find(filepath.Join("../../public/app/plugins/datasource", pluginDir), "plugin.json") +func fileHasString(path string, s string) bool { + f, err := os.ReadFile(path) + if err != nil { + return false + } + return strings.Contains(string(f), s) +} + +func findPluginJSONDir(pluginID string) (string, error) { + pluginJSONMatches, err := filepath.Glob("../../public/app/plugins/datasource/*/plugin.json") if err != nil { return "", err } @@ -36,10 +44,11 @@ func findPluginJSONDir(pluginDir string) (string, error) { } pluginJSONPath := "" for _, pluginJSONMatch := range pluginJSONMatches { - // Ignore dist folder - if filepath.Base(filepath.Dir(pluginJSONMatch)) != "dist" { - pluginJSONPath = pluginJSONMatch + if !fileHasString(pluginJSONMatch, fmt.Sprintf(`"id": "%s"`, pluginID)) { + continue } + pluginJSONPath = pluginJSONMatch + break } pluginJSONPath, err = filepath.Abs(pluginJSONPath) if err != nil { @@ -48,19 +57,25 @@ func findPluginJSONDir(pluginDir string) (string, error) { return filepath.Dir(pluginJSONPath), nil } -func findRootDir(pluginDir string) (string, error) { - matches, err := find(pluginDir, "main.go") +func findRootDir(pluginID string) (string, error) { + matches, err := find(".", "main.go") if err != nil { return "", err } if len(matches) == 0 { return "", fmt.Errorf("Could not find main.go") } - absolutePath, err := filepath.Abs(matches[0]) - if err != nil { - return "", err + pluginDir := "" + for _, match := range matches { + if fileHasString(match, fmt.Sprintf(`datasource.Manage("%s"`, pluginID)) { + pluginDir = filepath.Dir(match) + break + } } - return filepath.Dir(absolutePath), nil + if pluginDir == "" { + return "", nil + } + return filepath.Abs(pluginDir) } func buildPlugin(rootDir, pluginJSONDir string) { @@ -76,44 +91,15 @@ func buildPlugin(rootDir, pluginJSONDir string) { build.BuildAll() } -func BuildPlugin(pluginDir string) error { - rootDir, err := findRootDir(pluginDir) +func BuildPlugin(pluginID string) error { + rootDir, err := findRootDir(pluginID) if err != nil { return err } - pluginJSONDir, err := findPluginJSONDir(pluginDir) + pluginJSONDir, err := findPluginJSONDir(pluginID) if err != nil { return err } buildPlugin(rootDir, pluginJSONDir) return nil } - -func BuildAllPlugins() error { - // Plugins need to have a main.go file - matches, err := find(".", "main.go") - if err != nil { - return err - } - for _, match := range matches { - // Get the directory name of the plugin - parts := strings.Split(filepath.ToSlash(match), "/") - if len(parts) == 0 { - continue - } - pluginDir := parts[0] - rootDir, err := findRootDir(pluginDir) - if err != nil { - return err - } - pluginJSONDir, err := findPluginJSONDir(pluginDir) - if err != nil { - return err - } - buildPlugin(rootDir, pluginJSONDir) - } - return nil -} - -// Default configures the default target. -var Default = BuildAllPlugins diff --git a/pkg/tsdb/azuremonitor/azmoncredentials/builder.go b/pkg/tsdb/azuremonitor/azmoncredentials/builder.go index 24bd0b70a6a97..3f71c2b2e4b61 100644 --- a/pkg/tsdb/azuremonitor/azmoncredentials/builder.go +++ b/pkg/tsdb/azuremonitor/azmoncredentials/builder.go @@ -5,7 +5,7 @@ import ( "github.com/grafana/grafana-azure-sdk-go/azcredentials" "github.com/grafana/grafana-azure-sdk-go/azsettings" - "github.com/grafana/grafana-azure-sdk-go/util/maputil" + "github.com/grafana/grafana-plugin-sdk-go/data/utils/maputil" ) func FromDatasourceData(data map[string]interface{}, secureData map[string]string) (azcredentials.AzureCredentials, error) { diff --git a/pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go b/pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go index e01e6e505d296..13d4c411b8739 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go +++ b/pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" @@ -24,6 +25,7 @@ func getTarget(original string) (target string, err error) { } type httpServiceProxy struct { + logger log.Logger } func (s *httpServiceProxy) Do(rw http.ResponseWriter, req *http.Request, cli *http.Client) (http.ResponseWriter, error) { @@ -38,7 +40,7 @@ func (s *httpServiceProxy) Do(rw http.ResponseWriter, req *http.Request, cli *ht } defer func() { if err := res.Body.Close(); err != nil { - backend.Logger.Warn("Failed to close response body", "err", err) + s.logger.Warn("Failed to close response body", "err", err) } }() @@ -91,7 +93,7 @@ func writeResponse(rw http.ResponseWriter, code int, msg string) { func (s *Service) handleResourceReq(subDataSource string) func(rw http.ResponseWriter, req *http.Request) { return func(rw http.ResponseWriter, req *http.Request) { - backend.Logger.Debug("Received resource call", "url", req.URL.String(), "method", req.Method) + s.logger.Debug("Received resource call", "url", req.URL.String(), "method", req.Method) newPath, err := getTarget(req.URL.Path) if err != nil { diff --git a/pkg/tsdb/azuremonitor/azuremonitor-resource-handler_test.go b/pkg/tsdb/azuremonitor/azuremonitor-resource-handler_test.go index d5e251f2fef9d..487bbc9d358c4 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor-resource-handler_test.go +++ b/pkg/tsdb/azuremonitor/azuremonitor-resource-handler_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - "github.com/grafana/grafana-azure-sdk-go/azsettings" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/metrics" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" @@ -104,8 +104,9 @@ func Test_handleResourceReq(t *testing.T) { im: &fakeInstance{ services: map[string]types.DatasourceService{ azureMonitor: { - URL: routes[azsettings.AzurePublic][azureMonitor].URL, + URL: "https://management.azure.com", HTTPClient: &http.Client{}, + Logger: log.DefaultLogger, }, }, }, @@ -114,6 +115,7 @@ func Test_handleResourceReq(t *testing.T) { Proxy: proxy, }, }, + logger: log.DefaultLogger, } rw := httptest.NewRecorder() req, err := http.NewRequest(http.MethodGet, "http://foo/azuremonitor/subscriptions/44693801", nil) diff --git a/pkg/tsdb/azuremonitor/azuremonitor.go b/pkg/tsdb/azuremonitor/azuremonitor.go index 03fdc7c094521..0fbb2d65391ff 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor.go +++ b/pkg/tsdb/azuremonitor/azuremonitor.go @@ -10,16 +10,14 @@ import ( "net/http" "strconv" - "github.com/grafana/grafana-azure-sdk-go/azcredentials" "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azmoncredentials" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/loganalytics" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/metrics" @@ -27,20 +25,24 @@ import ( "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) -func ProvideService(cfg *setting.Cfg, httpClientProvider *httpclient.Provider, features featuremgmt.FeatureToggles) *Service { - proxy := &httpServiceProxy{} +func ProvideService(httpClientProvider *httpclient.Provider) *Service { + logger := backend.NewLoggerWith("logger", "tsdb.azuremonitor") + proxy := &httpServiceProxy{ + logger: logger, + } executors := map[string]azDatasourceExecutor{ - azureMonitor: &metrics.AzureMonitorDatasource{Proxy: proxy, Features: features}, - azureLogAnalytics: &loganalytics.AzureLogAnalyticsDatasource{Proxy: proxy}, - azureResourceGraph: &resourcegraph.AzureResourceGraphDatasource{Proxy: proxy}, - azureTraces: &loganalytics.AzureLogAnalyticsDatasource{Proxy: proxy}, + azureMonitor: &metrics.AzureMonitorDatasource{Proxy: proxy, Logger: logger}, + azureLogAnalytics: &loganalytics.AzureLogAnalyticsDatasource{Proxy: proxy, Logger: logger}, + azureResourceGraph: &resourcegraph.AzureResourceGraphDatasource{Proxy: proxy, Logger: logger}, + azureTraces: &loganalytics.AzureLogAnalyticsDatasource{Proxy: proxy, Logger: logger}, } - im := datasource.NewInstanceManager(NewInstanceSettings(cfg, httpClientProvider, executors)) + im := datasource.NewInstanceManager(NewInstanceSettings(httpClientProvider, executors, logger)) s := &Service{ im: im, executors: executors, + logger: logger, } s.queryMux = s.newQueryMux() @@ -63,21 +65,23 @@ type Service struct { queryMux *datasource.QueryTypeMux resourceHandler backend.CallResourceHandler + logger log.Logger } -func getDatasourceService(ctx context.Context, settings *backend.DataSourceInstanceSettings, cfg *setting.Cfg, clientProvider *httpclient.Provider, dsInfo types.DatasourceInfo, routeName string) (types.DatasourceService, error) { +func getDatasourceService(ctx context.Context, settings *backend.DataSourceInstanceSettings, azureSettings *azsettings.AzureSettings, clientProvider *httpclient.Provider, dsInfo types.DatasourceInfo, routeName string, logger log.Logger) (types.DatasourceService, error) { route := dsInfo.Routes[routeName] - client, err := newHTTPClient(ctx, route, dsInfo, settings, cfg, clientProvider) + client, err := newHTTPClient(ctx, route, dsInfo, settings, azureSettings, clientProvider) if err != nil { return types.DatasourceService{}, err } return types.DatasourceService{ URL: dsInfo.Routes[routeName].URL, HTTPClient: client, + Logger: logger, }, nil } -func NewInstanceSettings(cfg *setting.Cfg, clientProvider *httpclient.Provider, executors map[string]azDatasourceExecutor) datasource.InstanceFactoryFunc { +func NewInstanceSettings(clientProvider *httpclient.Provider, executors map[string]azDatasourceExecutor, logger log.Logger) datasource.InstanceFactoryFunc { return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { jsonData := map[string]any{} err := json.Unmarshal(settings.JSONData, &jsonData) @@ -91,25 +95,25 @@ func NewInstanceSettings(cfg *setting.Cfg, clientProvider *httpclient.Provider, return nil, fmt.Errorf("error reading settings: %w", err) } - credentials, err := azmoncredentials.FromDatasourceData(jsonData, settings.DecryptedSecureJSONData) + azureSettings, err := azsettings.ReadSettings(ctx) if err != nil { - return nil, fmt.Errorf("error getting credentials: %w", err) - } else if credentials == nil { - credentials = azmoncredentials.GetDefaultCredentials(cfg.Azure) + logger.Error("failed to read Azure settings from Grafana", "error", err.Error()) + return nil, err } - cloud, err := azcredentials.GetAzureCloud(cfg.Azure, credentials) + credentials, err := azmoncredentials.FromDatasourceData(jsonData, settings.DecryptedSecureJSONData) if err != nil { return nil, fmt.Errorf("error getting credentials: %w", err) + } else if credentials == nil { + credentials = azmoncredentials.GetDefaultCredentials(azureSettings) } - routesForModel, err := getAzureRoutes(cloud, settings.JSONData) + routesForModel, err := getAzureMonitorRoutes(azureSettings, credentials, settings.JSONData) if err != nil { return nil, err } model := types.DatasourceInfo{ - Cloud: cloud, Credentials: credentials, Settings: azMonitorSettings, JSONData: jsonData, @@ -120,7 +124,7 @@ func NewInstanceSettings(cfg *setting.Cfg, clientProvider *httpclient.Provider, } for routeName := range executors { - service, err := getDatasourceService(ctx, &settings, cfg, clientProvider, model, routeName) + service, err := getDatasourceService(ctx, &settings, azureSettings, clientProvider, model, routeName, logger) if err != nil { return nil, err } @@ -131,31 +135,6 @@ func NewInstanceSettings(cfg *setting.Cfg, clientProvider *httpclient.Provider, } } -func getCustomizedCloudSettings(cloud string, jsonData json.RawMessage) (types.AzureMonitorCustomizedCloudSettings, error) { - customizedCloudSettings := types.AzureMonitorCustomizedCloudSettings{} - err := json.Unmarshal(jsonData, &customizedCloudSettings) - if err != nil { - return types.AzureMonitorCustomizedCloudSettings{}, fmt.Errorf("error getting customized cloud settings: %w", err) - } - return customizedCloudSettings, nil -} - -func getAzureRoutes(cloud string, jsonData json.RawMessage) (map[string]types.AzRoute, error) { - if cloud == azsettings.AzureCustomized { - customizedCloudSettings, err := getCustomizedCloudSettings(cloud, jsonData) - if err != nil { - return nil, err - } - if customizedCloudSettings.CustomizedRoutes == nil { - return nil, fmt.Errorf("unable to instantiate routes, customizedRoutes must be set") - } - azureRoutes := customizedCloudSettings.CustomizedRoutes - return azureRoutes, nil - } else { - return routes[cloud], nil - } -} - type azDatasourceExecutor interface { ExecuteTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, url string) (*backend.QueryDataResponse, error) ResourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) (http.ResponseWriter, error) @@ -296,7 +275,7 @@ func checkAzureMonitorResourceGraphHealth(dsInfo types.DatasourceInfo, subscript return res, nil } -func metricCheckHealth(dsInfo types.DatasourceInfo) (message string, defaultSubscription string, status backend.HealthStatus) { +func metricCheckHealth(dsInfo types.DatasourceInfo, logger log.Logger) (message string, defaultSubscription string, status backend.HealthStatus) { defaultSubscription = dsInfo.Settings.SubscriptionId metricsRes, err := queryMetricHealth(dsInfo) if err != nil { @@ -319,7 +298,7 @@ func metricCheckHealth(dsInfo types.DatasourceInfo) (message string, defaultSubs } return fmt.Sprintf("Error connecting to Azure Monitor endpoint: %s", string(body)), defaultSubscription, backend.HealthStatusError } - subscriptions, err := parseSubscriptions(metricsRes) + subscriptions, err := parseSubscriptions(metricsRes, logger) if err != nil { return err.Error(), defaultSubscription, backend.HealthStatusError } @@ -383,7 +362,7 @@ func graphLogHealthCheck(dsInfo types.DatasourceInfo, defaultSubscription string return "Successfully connected to Azure Resource Graph endpoint.", backend.HealthStatusOk } -func parseSubscriptions(res *http.Response) ([]string, error) { +func parseSubscriptions(res *http.Response, logger log.Logger) ([]string, error) { var target struct { Value []struct { SubscriptionId string `json:"subscriptionId"` @@ -395,7 +374,7 @@ func parseSubscriptions(res *http.Response) ([]string, error) { } defer func() { if err := res.Body.Close(); err != nil { - backend.Logger.Warn("Failed to close response body", "err", err) + logger.Warn("Failed to close response body", "err", err) } }() @@ -418,7 +397,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque status := backend.HealthStatusOk - metricsLog, defaultSubscription, metricsStatus := metricCheckHealth(dsInfo) + metricsLog, defaultSubscription, metricsStatus := metricCheckHealth(dsInfo, s.logger) if metricsStatus != backend.HealthStatusOk { status = metricsStatus } diff --git a/pkg/tsdb/azuremonitor/azuremonitor_test.go b/pkg/tsdb/azuremonitor/azuremonitor_test.go index 6ccc527228dbf..25ed3b60cd301 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor_test.go +++ b/pkg/tsdb/azuremonitor/azuremonitor_test.go @@ -12,18 +12,43 @@ import ( "github.com/google/go-cmp/cmp" "github.com/grafana/grafana-azure-sdk-go/azcredentials" - "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +var testRoutes = map[string]types.AzRoute{ + azureMonitor: { + URL: "https://management.azure.com", + Scopes: []string{"https://management.azure.com/.default"}, + Headers: map[string]string{"x-ms-app": "Grafana"}, + }, + azureLogAnalytics: { + URL: "https://api.loganalytics.io", + Scopes: []string{"https://api.loganalytics.io/.default"}, + Headers: map[string]string{"x-ms-app": "Grafana", "Cache-Control": "public, max-age=60"}, + }, + azureResourceGraph: { + URL: "https://management.azure.com", + Scopes: []string{"https://management.azure.com/.default"}, + Headers: map[string]string{"x-ms-app": "Grafana"}, + }, + azureTraces: { + URL: "https://api.loganalytics.io", + Scopes: []string{"https://api.loganalytics.io/.default"}, + Headers: map[string]string{"x-ms-app": "Grafana", "Cache-Control": "public, max-age=60"}, + }, + azurePortal: { + URL: "https://portal.azure.com", + }, +} + func TestNewInstanceSettings(t *testing.T) { tests := []struct { name string @@ -39,10 +64,9 @@ func TestNewInstanceSettings(t *testing.T) { ID: 40, }, expectedModel: types.DatasourceInfo{ - Cloud: azsettings.AzurePublic, Credentials: &azcredentials.AzureManagedIdentityCredentials{}, Settings: types.AzureMonitorSettings{}, - Routes: routes[azsettings.AzurePublic], + Routes: testRoutes, JSONData: map[string]any{"azureAuthType": "msi"}, DatasourceID: 40, DecryptedSecureJSONData: map[string]string{"key": "value"}, @@ -58,7 +82,6 @@ func TestNewInstanceSettings(t *testing.T) { ID: 50, }, expectedModel: types.DatasourceInfo{ - Cloud: "AzureCustomizedCloud", Credentials: &azcredentials.AzureClientSecretCredentials{ AzureCloud: "AzureCustomizedCloud", ClientSecret: "secret", @@ -86,15 +109,9 @@ func TestNewInstanceSettings(t *testing.T) { }, } - cfg := &setting.Cfg{ - Azure: &azsettings.AzureSettings{ - Cloud: azsettings.AzurePublic, - }, - } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - factory := NewInstanceSettings(cfg, &httpclient.Provider{}, map[string]azDatasourceExecutor{}) + factory := NewInstanceSettings(&httpclient.Provider{}, map[string]azDatasourceExecutor{}, log.DefaultLogger) instance, err := factory(context.Background(), tt.settings) tt.Err(t, err) if !cmp.Equal(instance, tt.expectedModel) { @@ -105,7 +122,6 @@ func TestNewInstanceSettings(t *testing.T) { } type fakeInstance struct { - cloud string routes map[string]types.AzRoute services map[string]types.DatasourceService settings types.AzureMonitorSettings @@ -113,7 +129,6 @@ type fakeInstance struct { func (f *fakeInstance) Get(_ context.Context, _ backend.PluginContext) (instancemgmt.Instance, error) { return types.DatasourceInfo{ - Cloud: f.cloud, Routes: f.routes, Services: f.services, Settings: f.settings, @@ -155,19 +170,19 @@ func Test_newMux(t *testing.T) { { name: "creates an Azure Monitor executor", queryType: azureMonitor, - expectedURL: routes[azsettings.AzurePublic][azureMonitor].URL, + expectedURL: testRoutes[azureMonitor].URL, Err: require.NoError, }, { name: "creates an Azure Log Analytics executor", queryType: azureLogAnalytics, - expectedURL: routes[azsettings.AzurePublic][azureLogAnalytics].URL, + expectedURL: testRoutes[azureLogAnalytics].URL, Err: require.NoError, }, { name: "creates an Azure Traces executor", queryType: azureTraces, - expectedURL: routes[azsettings.AzurePublic][azureLogAnalytics].URL, + expectedURL: testRoutes[azureLogAnalytics].URL, Err: require.NoError, }, } @@ -176,10 +191,10 @@ func Test_newMux(t *testing.T) { t.Run(tt.name, func(t *testing.T) { s := &Service{ im: &fakeInstance{ - routes: routes[azsettings.AzurePublic], + routes: testRoutes, services: map[string]types.DatasourceService{ tt.queryType: { - URL: routes[azsettings.AzurePublic][tt.queryType].URL, + URL: testRoutes[tt.queryType].URL, HTTPClient: &http.Client{}, }, }, @@ -308,7 +323,6 @@ func TestCheckHealth(t *testing.T) { }) } - cloud := "AzureCloud" tests := []struct { name string errorExpected bool @@ -324,15 +338,15 @@ func TestCheckHealth(t *testing.T) { }, customServices: map[string]types.DatasourceService{ azureMonitor: { - URL: routes[cloud]["Azure Monitor"].URL, + URL: testRoutes["Azure Monitor"].URL, HTTPClient: azureMonitorClient(false, false), }, azureLogAnalytics: { - URL: routes[cloud]["Azure Log Analytics"].URL, + URL: testRoutes["Azure Log Analytics"].URL, HTTPClient: okClient, }, azureResourceGraph: { - URL: routes[cloud]["Azure Resource Graph"].URL, + URL: testRoutes["Azure Resource Graph"].URL, HTTPClient: okClient, }}, }, @@ -347,15 +361,15 @@ func TestCheckHealth(t *testing.T) { }, customServices: map[string]types.DatasourceService{ azureMonitor: { - URL: routes[cloud]["Azure Monitor"].URL, + URL: testRoutes["Azure Monitor"].URL, HTTPClient: azureMonitorClient(false, true), }, azureLogAnalytics: { - URL: routes[cloud]["Azure Log Analytics"].URL, + URL: testRoutes["Azure Log Analytics"].URL, HTTPClient: okClient, }, azureResourceGraph: { - URL: routes[cloud]["Azure Resource Graph"].URL, + URL: testRoutes["Azure Resource Graph"].URL, HTTPClient: okClient, }}, }, @@ -370,15 +384,15 @@ func TestCheckHealth(t *testing.T) { }, customServices: map[string]types.DatasourceService{ azureMonitor: { - URL: routes[cloud]["Azure Monitor"].URL, + URL: testRoutes["Azure Monitor"].URL, HTTPClient: azureMonitorClient(false, false), }, azureLogAnalytics: { - URL: routes[cloud]["Azure Log Analytics"].URL, + URL: testRoutes["Azure Log Analytics"].URL, HTTPClient: failClient(false), }, azureResourceGraph: { - URL: routes[cloud]["Azure Resource Graph"].URL, + URL: testRoutes["Azure Resource Graph"].URL, HTTPClient: okClient, }}, }, @@ -393,15 +407,15 @@ func TestCheckHealth(t *testing.T) { }, customServices: map[string]types.DatasourceService{ azureMonitor: { - URL: routes[cloud]["Azure Monitor"].URL, + URL: testRoutes["Azure Monitor"].URL, HTTPClient: azureMonitorClient(false, false), }, azureLogAnalytics: { - URL: routes[cloud]["Azure Log Analytics"].URL, + URL: testRoutes["Azure Log Analytics"].URL, HTTPClient: okClient, }, azureResourceGraph: { - URL: routes[cloud]["Azure Resource Graph"].URL, + URL: testRoutes["Azure Resource Graph"].URL, HTTPClient: failClient(false), }}, }, @@ -416,15 +430,15 @@ func TestCheckHealth(t *testing.T) { }, customServices: map[string]types.DatasourceService{ azureMonitor: { - URL: routes[cloud]["Azure Monitor"].URL, + URL: testRoutes["Azure Monitor"].URL, HTTPClient: azureMonitorClient(true, false), }, azureLogAnalytics: { - URL: routes[cloud]["Azure Log Analytics"].URL, + URL: testRoutes["Azure Log Analytics"].URL, HTTPClient: okClient, }, azureResourceGraph: { - URL: routes[cloud]["Azure Resource Graph"].URL, + URL: testRoutes["Azure Resource Graph"].URL, HTTPClient: okClient, }}, }, @@ -439,23 +453,22 @@ func TestCheckHealth(t *testing.T) { }, customServices: map[string]types.DatasourceService{ azureMonitor: { - URL: routes[cloud]["Azure Monitor"].URL, + URL: testRoutes["Azure Monitor"].URL, HTTPClient: failClient(true), }, azureLogAnalytics: { - URL: routes[cloud]["Azure Log Analytics"].URL, + URL: testRoutes["Azure Log Analytics"].URL, HTTPClient: failClient(true), }, azureResourceGraph: { - URL: routes[cloud]["Azure Resource Graph"].URL, + URL: testRoutes["Azure Resource Graph"].URL, HTTPClient: failClient(true), }}, }, } instance := &fakeInstance{ - cloud: cloud, - routes: routes[cloud], + routes: testRoutes, services: map[string]types.DatasourceService{}, settings: types.AzureMonitorSettings{ LogAnalyticsDefaultWorkspace: "workspace-id", diff --git a/pkg/tsdb/azuremonitor/httpclient.go b/pkg/tsdb/azuremonitor/httpclient.go index 7aef1b53e0514..74cb282698123 100644 --- a/pkg/tsdb/azuremonitor/httpclient.go +++ b/pkg/tsdb/azuremonitor/httpclient.go @@ -8,10 +8,10 @@ import ( "github.com/grafana/grafana-azure-sdk-go/azcredentials" "github.com/grafana/grafana-azure-sdk-go/azhttpclient" + "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) @@ -21,14 +21,14 @@ type Provider interface { GetTLSConfig(...httpclient.Options) (*tls.Config, error) } -func newHTTPClient(ctx context.Context, route types.AzRoute, model types.DatasourceInfo, settings *backend.DataSourceInstanceSettings, cfg *setting.Cfg, clientProvider Provider) (*http.Client, error) { +func newHTTPClient(ctx context.Context, route types.AzRoute, model types.DatasourceInfo, settings *backend.DataSourceInstanceSettings, azureSettings *azsettings.AzureSettings, clientProvider Provider) (*http.Client, error) { clientOpts, err := settings.HTTPClientOptions(ctx) if err != nil { return nil, fmt.Errorf("error getting HTTP options: %w", err) } for header, value := range route.Headers { - clientOpts.Headers[header] = value + clientOpts.Header.Add(header, value) } // Use Azure credentials if the route has OAuth scopes configured @@ -37,7 +37,7 @@ func newHTTPClient(ctx context.Context, route types.AzRoute, model types.Datasou return nil, fmt.Errorf("unable to initialize HTTP Client: clientSecret not found") } - authOpts := azhttpclient.NewAuthOptions(cfg.Azure) + authOpts := azhttpclient.NewAuthOptions(azureSettings) authOpts.Scopes(route.Scopes) azhttpclient.AddAzureAuthentication(&clientOpts, authOpts, model.Credentials) } diff --git a/pkg/tsdb/azuremonitor/httpclient_test.go b/pkg/tsdb/azuremonitor/httpclient_test.go index 9cd4a41c0cc6c..4bd2211448851 100644 --- a/pkg/tsdb/azuremonitor/httpclient_test.go +++ b/pkg/tsdb/azuremonitor/httpclient_test.go @@ -8,10 +8,10 @@ import ( "testing" "github.com/grafana/grafana-azure-sdk-go/azcredentials" + "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -32,7 +32,9 @@ func TestHttpClient_AzureCredentials(t *testing.T) { }, } - cfg := &setting.Cfg{} + azureSettings := &azsettings.AzureSettings{ + Cloud: azsettings.AzurePublic, + } provider := &fakeHttpClientProvider{} t.Run("should have Azure middleware when scopes provided", func(t *testing.T) { @@ -40,7 +42,7 @@ func TestHttpClient_AzureCredentials(t *testing.T) { Scopes: []string{"https://management.azure.com/.default"}, } - _, err := newHTTPClient(context.Background(), route, model, settings, cfg, provider) + _, err := newHTTPClient(context.Background(), route, model, settings, azureSettings, provider) require.NoError(t, err) require.NotNil(t, provider.opts) @@ -53,7 +55,7 @@ func TestHttpClient_AzureCredentials(t *testing.T) { Scopes: []string{}, } - _, err := newHTTPClient(context.Background(), route, model, settings, cfg, provider) + _, err := newHTTPClient(context.Background(), route, model, settings, azureSettings, provider) require.NoError(t, err) assert.NotNil(t, provider.opts) @@ -70,18 +72,18 @@ func TestHttpClient_AzureCredentials(t *testing.T) { }, } - res := map[string]string{ - "GrafanaHeader": "GrafanaValue", - "AzureHeader": "AzureValue", + res := http.Header{ + "Grafanaheader": {"GrafanaValue"}, + "Azureheader": {"AzureValue"}, } - _, err := newHTTPClient(context.Background(), route, model, settings, cfg, provider) + _, err := newHTTPClient(context.Background(), route, model, settings, azureSettings, provider) require.NoError(t, err) assert.NotNil(t, provider.opts) - if provider.opts.Headers != nil { - assert.Len(t, provider.opts.Headers, 2) - assert.Equal(t, res, provider.opts.Headers) + if provider.opts.Header != nil { + assert.Len(t, provider.opts.Header, 2) + assert.Equal(t, res, provider.opts.Header) } }) } diff --git a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go index edb636b3baf68..69e0886333f24 100644 --- a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go @@ -17,6 +17,7 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" "github.com/grafana/grafana-plugin-sdk-go/data" "go.opentelemetry.io/otel/attribute" @@ -30,7 +31,8 @@ import ( // AzureLogAnalyticsDatasource calls the Azure Log Analytics API's type AzureLogAnalyticsDatasource struct { - Proxy types.ServiceProxy + Proxy types.ServiceProxy + Logger log.Logger } // AzureLogAnalyticsQuery is the query request that is built from the saved values for @@ -300,7 +302,7 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *A defer func() { if err := res.Body.Close(); err != nil { - backend.Logger.Warn("Failed to close response body", "err", err) + e.Logger.Warn("Failed to close response body", "err", err) } }() @@ -324,12 +326,7 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *A return &dataResponse, nil } - azurePortalBaseUrl, err := GetAzurePortalUrl(dsInfo.Cloud) - if err != nil { - return nil, err - } - - queryUrl, err := getQueryUrl(query.Query, query.Resources, azurePortalBaseUrl, query.TimeRange) + queryUrl, err := getQueryUrl(query.Query, query.Resources, dsInfo.Routes["Azure Portal"].URL, query.TimeRange) if err != nil { return nil, err } @@ -363,7 +360,7 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *A } // Use the parent span query for the parent span data link - err = addDataLinksToFields(query, azurePortalBaseUrl, frame, dsInfo, queryUrl) + err = addDataLinksToFields(query, dsInfo.Routes["Azure Portal"].URL, frame, dsInfo, queryUrl) if err != nil { return nil, err } @@ -488,11 +485,19 @@ func (e *AzureLogAnalyticsDatasource) createRequest(ctx context.Context, queryUR } if len(query.Resources) > 1 && query.QueryType == dataquery.AzureQueryTypeAzureLogAnalytics && !query.AppInsightsQuery { - body["workspaces"] = query.Resources + str := strings.ToLower(query.Resources[0]) + + if strings.Contains(str, "microsoft.operationalinsights/workspaces") { + body["workspaces"] = query.Resources + } else { + body["resources"] = query.Resources + } } + if query.AppInsightsQuery { body["applications"] = query.Resources } + jsonValue, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to create request", err) @@ -502,6 +507,7 @@ func (e *AzureLogAnalyticsDatasource) createRequest(ctx context.Context, queryUR if err != nil { return nil, fmt.Errorf("%v: %w", "failed to create request", err) } + req.URL.Path = "/" req.Header.Set("Content-Type", "application/json") req.URL.Path = path.Join(req.URL.Path, query.URL) @@ -610,7 +616,7 @@ func getCorrelationWorkspaces(ctx context.Context, baseResource string, resource defer func() { if err := res.Body.Close(); err != nil { - backend.Logger.Warn("Failed to close response body", "err", err) + azMonService.Logger.Warn("Failed to close response body", "err", err) } }() @@ -714,7 +720,7 @@ func (e *AzureLogAnalyticsDatasource) unmarshalResponse(res *http.Response) (Azu } defer func() { if err := res.Body.Close(); err != nil { - backend.Logger.Warn("Failed to close response body", "err", err) + e.Logger.Warn("Failed to close response body", "err", err) } }() diff --git a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource_test.go b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource_test.go index 69a7edbf4bc7a..56e56a545bb54 100644 --- a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource_test.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource_test.go @@ -1553,6 +1553,42 @@ func TestLogAnalyticsCreateRequest(t *testing.T) { t.Errorf("Unexpected Body: %v", cmp.Diff(string(body), expectedBody)) } }) + + t.Run("correctly classifies resources as workspaces when matching criteria", func(t *testing.T) { + ds := AzureLogAnalyticsDatasource{} + req, err := ds.createRequest(ctx, url, &AzureLogAnalyticsQuery{ + Resources: []string{"/subscriptions/test-sub/resourceGroups/test-rg/providers/microsoft.operationalInsights/workSpaces/ws1", "microsoft.operationalInsights/workspaces/ws2"}, // Note different casings and partial paths + Query: "Perf", + QueryType: dataquery.AzureQueryTypeAzureLogAnalytics, + AppInsightsQuery: false, + DashboardTime: false, + }) + require.NoError(t, err) + expectedBody := `{"query":"Perf","workspaces":["/subscriptions/test-sub/resourceGroups/test-rg/providers/microsoft.operationalInsights/workSpaces/ws1","microsoft.operationalInsights/workspaces/ws2"]}` // Expecting resources to be classified as workspaces + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + if !cmp.Equal(string(body), expectedBody) { + t.Errorf("Unexpected Body: %v", cmp.Diff(string(body), expectedBody)) + } + }) + + t.Run("correctly passes multiple resources not classified as workspaces", func(t *testing.T) { + ds := AzureLogAnalyticsDatasource{} + req, err := ds.createRequest(ctx, url, &AzureLogAnalyticsQuery{ + Resources: []string{"/subscriptions/test-sub/resourceGroups/test-rg/providers/SomeOtherService/serviceInstances/r1", "/subscriptions/test-sub/resourceGroups/test-rg/providers/SomeOtherService/serviceInstances/r2"}, + Query: "Perf", + QueryType: dataquery.AzureQueryTypeAzureLogAnalytics, + AppInsightsQuery: false, + DashboardTime: false, + }) + require.NoError(t, err) + expectedBody := `{"query":"Perf","resources":["/subscriptions/test-sub/resourceGroups/test-rg/providers/SomeOtherService/serviceInstances/r1","/subscriptions/test-sub/resourceGroups/test-rg/providers/SomeOtherService/serviceInstances/r2"]}` + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + if !cmp.Equal(string(body), expectedBody) { + t.Errorf("Unexpected Body: %v", cmp.Diff(string(body), expectedBody)) + } + }) } func Test_executeQueryErrorWithDifferentLogAnalyticsCreds(t *testing.T) { diff --git a/pkg/tsdb/azuremonitor/loganalytics/utils.go b/pkg/tsdb/azuremonitor/loganalytics/utils.go index 2a3f36e64c9d4..402acc4eaa5d5 100644 --- a/pkg/tsdb/azuremonitor/loganalytics/utils.go +++ b/pkg/tsdb/azuremonitor/loganalytics/utils.go @@ -1,9 +1,6 @@ package loganalytics import ( - "fmt" - - "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-plugin-sdk-go/data" ) @@ -34,16 +31,3 @@ func AddConfigLinks(frame data.Frame, dl string, title *string) data.Frame { return frame } - -func GetAzurePortalUrl(azureCloud string) (string, error) { - switch azureCloud { - case azsettings.AzurePublic: - return "https://portal.azure.com", nil - case azsettings.AzureChina: - return "https://portal.azure.cn", nil - case azsettings.AzureUSGovernment: - return "https://portal.azure.us", nil - default: - return "", fmt.Errorf("the cloud is not supported") - } -} diff --git a/pkg/tsdb/azuremonitor/macros/macros.go b/pkg/tsdb/azuremonitor/macros/macros.go index 75f158717574c..953f5f5c914ca 100644 --- a/pkg/tsdb/azuremonitor/macros/macros.go +++ b/pkg/tsdb/azuremonitor/macros/macros.go @@ -8,10 +8,10 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/kinds/dataquery" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" ) const rsIdentifier = `__(timeFilter|timeFrom|timeTo|interval|contains|escapeMulti)` @@ -126,7 +126,7 @@ func (m *kqlMacroEngine) evaluateMacro(name string, defaultTimeField string, arg if dsInterval, ok = dsInfo.JSONData["interval"].(string); !ok { dsInterval = "" } - it, err = intervalv2.GetIntervalFrom(dsInterval, queryInterval.Interval, queryInterval.IntervalMs, defaultInterval) + it, err = gtime.GetIntervalFrom(dsInterval, queryInterval.Interval, queryInterval.IntervalMs, defaultInterval) if err != nil { it = defaultInterval } diff --git a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go index a58e3d4abb935..ad55bbb4a7af0 100644 --- a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go +++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go @@ -14,12 +14,12 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" "github.com/grafana/grafana-plugin-sdk-go/data" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/kinds/dataquery" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/loganalytics" azTime "github.com/grafana/grafana/pkg/tsdb/azuremonitor/time" @@ -28,8 +28,8 @@ import ( // AzureMonitorDatasource calls the Azure Monitor API - one of the four API's supported type AzureMonitorDatasource struct { - Proxy types.ServiceProxy - Features featuremgmt.FeatureToggles + Proxy types.ServiceProxy + Logger log.Logger } var ( @@ -278,7 +278,7 @@ func (e *AzureMonitorDatasource) retrieveSubscriptionDetails(cli *http.Client, c defer func() { if err := res.Body.Close(); err != nil { - backend.Logger.Warn("Failed to close response body", "err", err) + e.Logger.Warn("Failed to close response body", "err", err) } }() @@ -330,7 +330,7 @@ func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *types. defer func() { if err := res.Body.Close(); err != nil { - backend.Logger.Warn("Failed to close response body", "err", err) + e.Logger.Warn("Failed to close response body", "err", err) } }() @@ -339,17 +339,12 @@ func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *types. return nil, err } - azurePortalUrl, err := loganalytics.GetAzurePortalUrl(dsInfo.Cloud) - if err != nil { - return nil, err - } - subscription, err := e.retrieveSubscriptionDetails(cli, ctx, query.Subscription, dsInfo.Routes["Azure Monitor"].URL, dsInfo.DatasourceID, dsInfo.OrgID) if err != nil { return nil, err } - frames, err := e.parseResponse(data, query, azurePortalUrl, subscription) + frames, err := e.parseResponse(data, query, dsInfo.Routes["Azure Portal"].URL, subscription) if err != nil { return nil, err } diff --git a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go index f09fa76c17734..1e372632be914 100644 --- a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go +++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go @@ -23,9 +23,10 @@ import ( "github.com/grafana/grafana/pkg/tsdb/azuremonitor/testdata" azTime "github.com/grafana/grafana/pkg/tsdb/azuremonitor/time" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" - "github.com/grafana/grafana/pkg/util" ) +func Pointer[T any](v T) *T { return &v } + func TestAzureMonitorBuildQueries(t *testing.T) { datasource := &AzureMonitorDatasource{} dsInfo := types.DatasourceInfo{ @@ -117,7 +118,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) { expectedInterval: "PT1M", azureMonitorQueryTarget: "aggregation=Average&api-version=2021-05-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute%2FvirtualMachines×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z&top=30", expectedParamFilter: "blob eq '*'", - expectedPortalURL: util.Pointer("http://ds/#blade/Microsoft_Azure_MonitoringMetrics/Metrics.ReactView/Referer/MetricsExplorer/TimeContext/%7B%22absolute%22%3A%7B%22startTime%22%3A%222018-03-15T13%3A00%3A00Z%22%2C%22endTime%22%3A%222018-03-15T13%3A34%3A00Z%22%7D%7D/ChartDefinition/%7B%22v2charts%22%3A%5B%7B%22grouping%22%3A%7B%22dimension%22%3A%22blob%22%2C%22sort%22%3A2%2C%22top%22%3A10%7D%2C%22metrics%22%3A%5B%7B%22resourceMetadata%22%3A%7B%22id%22%3A%22%2Fsubscriptions%2F12345678-aaaa-bbbb-cccc-123456789abc%2FresourceGroups%2Fgrafanastaging%2Fproviders%2FMicrosoft.Compute%2FvirtualMachines%2Fgrafana%22%7D%2C%22name%22%3A%22Percentage%20CPU%22%2C%22aggregationType%22%3A4%2C%22namespace%22%3A%22Microsoft.Compute%2FvirtualMachines%22%2C%22metricVisualization%22%3A%7B%22displayName%22%3A%22Percentage%20CPU%22%2C%22resourceDisplayName%22%3A%22grafana%22%7D%7D%5D%7D%5D%7D"), + expectedPortalURL: Pointer("http://ds/#blade/Microsoft_Azure_MonitoringMetrics/Metrics.ReactView/Referer/MetricsExplorer/TimeContext/%7B%22absolute%22%3A%7B%22startTime%22%3A%222018-03-15T13%3A00%3A00Z%22%2C%22endTime%22%3A%222018-03-15T13%3A34%3A00Z%22%7D%7D/ChartDefinition/%7B%22v2charts%22%3A%5B%7B%22grouping%22%3A%7B%22dimension%22%3A%22blob%22%2C%22sort%22%3A2%2C%22top%22%3A10%7D%2C%22metrics%22%3A%5B%7B%22resourceMetadata%22%3A%7B%22id%22%3A%22%2Fsubscriptions%2F12345678-aaaa-bbbb-cccc-123456789abc%2FresourceGroups%2Fgrafanastaging%2Fproviders%2FMicrosoft.Compute%2FvirtualMachines%2Fgrafana%22%7D%2C%22name%22%3A%22Percentage%20CPU%22%2C%22aggregationType%22%3A4%2C%22namespace%22%3A%22Microsoft.Compute%2FvirtualMachines%22%2C%22metricVisualization%22%3A%7B%22displayName%22%3A%22Percentage%20CPU%22%2C%22resourceDisplayName%22%3A%22grafana%22%7D%7D%5D%7D%5D%7D"), }, { name: "legacy query without resourceURI and has dimensionFilter*s* property with two dimensions", @@ -130,7 +131,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) { expectedInterval: "PT1M", azureMonitorQueryTarget: "aggregation=Average&api-version=2021-05-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute%2FvirtualMachines×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z&top=30", expectedParamFilter: "blob eq '*' and tier eq '*'", - expectedPortalURL: util.Pointer("http://ds/#blade/Microsoft_Azure_MonitoringMetrics/Metrics.ReactView/Referer/MetricsExplorer/TimeContext/%7B%22absolute%22%3A%7B%22startTime%22%3A%222018-03-15T13%3A00%3A00Z%22%2C%22endTime%22%3A%222018-03-15T13%3A34%3A00Z%22%7D%7D/ChartDefinition/%7B%22v2charts%22%3A%5B%7B%22grouping%22%3A%7B%22dimension%22%3A%22blob%22%2C%22sort%22%3A2%2C%22top%22%3A10%7D%2C%22metrics%22%3A%5B%7B%22resourceMetadata%22%3A%7B%22id%22%3A%22%2Fsubscriptions%2F12345678-aaaa-bbbb-cccc-123456789abc%2FresourceGroups%2Fgrafanastaging%2Fproviders%2FMicrosoft.Compute%2FvirtualMachines%2Fgrafana%22%7D%2C%22name%22%3A%22Percentage%20CPU%22%2C%22aggregationType%22%3A4%2C%22namespace%22%3A%22Microsoft.Compute%2FvirtualMachines%22%2C%22metricVisualization%22%3A%7B%22displayName%22%3A%22Percentage%20CPU%22%2C%22resourceDisplayName%22%3A%22grafana%22%7D%7D%5D%7D%5D%7D"), + expectedPortalURL: Pointer("http://ds/#blade/Microsoft_Azure_MonitoringMetrics/Metrics.ReactView/Referer/MetricsExplorer/TimeContext/%7B%22absolute%22%3A%7B%22startTime%22%3A%222018-03-15T13%3A00%3A00Z%22%2C%22endTime%22%3A%222018-03-15T13%3A34%3A00Z%22%7D%7D/ChartDefinition/%7B%22v2charts%22%3A%5B%7B%22grouping%22%3A%7B%22dimension%22%3A%22blob%22%2C%22sort%22%3A2%2C%22top%22%3A10%7D%2C%22metrics%22%3A%5B%7B%22resourceMetadata%22%3A%7B%22id%22%3A%22%2Fsubscriptions%2F12345678-aaaa-bbbb-cccc-123456789abc%2FresourceGroups%2Fgrafanastaging%2Fproviders%2FMicrosoft.Compute%2FvirtualMachines%2Fgrafana%22%7D%2C%22name%22%3A%22Percentage%20CPU%22%2C%22aggregationType%22%3A4%2C%22namespace%22%3A%22Microsoft.Compute%2FvirtualMachines%22%2C%22metricVisualization%22%3A%7B%22displayName%22%3A%22Percentage%20CPU%22%2C%22resourceDisplayName%22%3A%22grafana%22%7D%7D%5D%7D%5D%7D"), }, { name: "legacy query without resourceURI and has a dimension filter without specifying a top", @@ -155,7 +156,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) { expectedInterval: "PT1M", azureMonitorQueryTarget: "aggregation=Average&api-version=2021-05-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute%2FvirtualMachines×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z&top=30", expectedParamFilter: "blob ne 'test'", - expectedPortalURL: util.Pointer("http://ds/#blade/Microsoft_Azure_MonitoringMetrics/Metrics.ReactView/Referer/MetricsExplorer/TimeContext/%7B%22absolute%22%3A%7B%22startTime%22%3A%222018-03-15T13%3A00%3A00Z%22%2C%22endTime%22%3A%222018-03-15T13%3A34%3A00Z%22%7D%7D/ChartDefinition/%7B%22v2charts%22%3A%5B%7B%22filterCollection%22%3A%7B%22filters%22%3A%5B%7B%22key%22%3A%22blob%22%2C%22operator%22%3A1%2C%22values%22%3A%5B%22test%22%5D%7D%5D%7D%2C%22grouping%22%3A%7B%22dimension%22%3A%22blob%22%2C%22sort%22%3A2%2C%22top%22%3A10%7D%2C%22metrics%22%3A%5B%7B%22resourceMetadata%22%3A%7B%22id%22%3A%22%2Fsubscriptions%2F12345678-aaaa-bbbb-cccc-123456789abc%2FresourceGroups%2Fgrafanastaging%2Fproviders%2FMicrosoft.Compute%2FvirtualMachines%2Fgrafana%22%7D%2C%22name%22%3A%22Percentage%20CPU%22%2C%22aggregationType%22%3A4%2C%22namespace%22%3A%22Microsoft.Compute%2FvirtualMachines%22%2C%22metricVisualization%22%3A%7B%22displayName%22%3A%22Percentage%20CPU%22%2C%22resourceDisplayName%22%3A%22grafana%22%7D%7D%5D%7D%5D%7D"), + expectedPortalURL: Pointer("http://ds/#blade/Microsoft_Azure_MonitoringMetrics/Metrics.ReactView/Referer/MetricsExplorer/TimeContext/%7B%22absolute%22%3A%7B%22startTime%22%3A%222018-03-15T13%3A00%3A00Z%22%2C%22endTime%22%3A%222018-03-15T13%3A34%3A00Z%22%7D%7D/ChartDefinition/%7B%22v2charts%22%3A%5B%7B%22filterCollection%22%3A%7B%22filters%22%3A%5B%7B%22key%22%3A%22blob%22%2C%22operator%22%3A1%2C%22values%22%3A%5B%22test%22%5D%7D%5D%7D%2C%22grouping%22%3A%7B%22dimension%22%3A%22blob%22%2C%22sort%22%3A2%2C%22top%22%3A10%7D%2C%22metrics%22%3A%5B%7B%22resourceMetadata%22%3A%7B%22id%22%3A%22%2Fsubscriptions%2F12345678-aaaa-bbbb-cccc-123456789abc%2FresourceGroups%2Fgrafanastaging%2Fproviders%2FMicrosoft.Compute%2FvirtualMachines%2Fgrafana%22%7D%2C%22name%22%3A%22Percentage%20CPU%22%2C%22aggregationType%22%3A4%2C%22namespace%22%3A%22Microsoft.Compute%2FvirtualMachines%22%2C%22metricVisualization%22%3A%7B%22displayName%22%3A%22Percentage%20CPU%22%2C%22resourceDisplayName%22%3A%22grafana%22%7D%7D%5D%7D%5D%7D"), }, { name: "has dimensionFilter*s* property with startsWith operator", @@ -168,7 +169,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) { expectedInterval: "PT1M", azureMonitorQueryTarget: "aggregation=Average&api-version=2021-05-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute%2FvirtualMachines×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z&top=30", expectedParamFilter: "blob sw 'test'", - expectedPortalURL: util.Pointer("http://ds/#blade/Microsoft_Azure_MonitoringMetrics/Metrics.ReactView/Referer/MetricsExplorer/TimeContext/%7B%22absolute%22%3A%7B%22startTime%22%3A%222018-03-15T13%3A00%3A00Z%22%2C%22endTime%22%3A%222018-03-15T13%3A34%3A00Z%22%7D%7D/ChartDefinition/%7B%22v2charts%22%3A%5B%7B%22filterCollection%22%3A%7B%22filters%22%3A%5B%7B%22key%22%3A%22blob%22%2C%22operator%22%3A3%2C%22values%22%3A%5B%22test%22%5D%7D%5D%7D%2C%22grouping%22%3A%7B%22dimension%22%3A%22blob%22%2C%22sort%22%3A2%2C%22top%22%3A10%7D%2C%22metrics%22%3A%5B%7B%22resourceMetadata%22%3A%7B%22id%22%3A%22%2Fsubscriptions%2F12345678-aaaa-bbbb-cccc-123456789abc%2FresourceGroups%2Fgrafanastaging%2Fproviders%2FMicrosoft.Compute%2FvirtualMachines%2Fgrafana%22%7D%2C%22name%22%3A%22Percentage%20CPU%22%2C%22aggregationType%22%3A4%2C%22namespace%22%3A%22Microsoft.Compute%2FvirtualMachines%22%2C%22metricVisualization%22%3A%7B%22displayName%22%3A%22Percentage%20CPU%22%2C%22resourceDisplayName%22%3A%22grafana%22%7D%7D%5D%7D%5D%7D"), + expectedPortalURL: Pointer("http://ds/#blade/Microsoft_Azure_MonitoringMetrics/Metrics.ReactView/Referer/MetricsExplorer/TimeContext/%7B%22absolute%22%3A%7B%22startTime%22%3A%222018-03-15T13%3A00%3A00Z%22%2C%22endTime%22%3A%222018-03-15T13%3A34%3A00Z%22%7D%7D/ChartDefinition/%7B%22v2charts%22%3A%5B%7B%22filterCollection%22%3A%7B%22filters%22%3A%5B%7B%22key%22%3A%22blob%22%2C%22operator%22%3A3%2C%22values%22%3A%5B%22test%22%5D%7D%5D%7D%2C%22grouping%22%3A%7B%22dimension%22%3A%22blob%22%2C%22sort%22%3A2%2C%22top%22%3A10%7D%2C%22metrics%22%3A%5B%7B%22resourceMetadata%22%3A%7B%22id%22%3A%22%2Fsubscriptions%2F12345678-aaaa-bbbb-cccc-123456789abc%2FresourceGroups%2Fgrafanastaging%2Fproviders%2FMicrosoft.Compute%2FvirtualMachines%2Fgrafana%22%7D%2C%22name%22%3A%22Percentage%20CPU%22%2C%22aggregationType%22%3A4%2C%22namespace%22%3A%22Microsoft.Compute%2FvirtualMachines%22%2C%22metricVisualization%22%3A%7B%22displayName%22%3A%22Percentage%20CPU%22%2C%22resourceDisplayName%22%3A%22grafana%22%7D%7D%5D%7D%5D%7D"), }, { name: "correctly sets dimension operator to eq (irrespective of operator) when filter value is '*'", @@ -181,7 +182,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) { expectedInterval: "PT1M", azureMonitorQueryTarget: "aggregation=Average&api-version=2021-05-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute%2FvirtualMachines×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z&top=30", expectedParamFilter: "blob eq '*' and tier eq '*'", - expectedPortalURL: util.Pointer("http://ds/#blade/Microsoft_Azure_MonitoringMetrics/Metrics.ReactView/Referer/MetricsExplorer/TimeContext/%7B%22absolute%22%3A%7B%22startTime%22%3A%222018-03-15T13%3A00%3A00Z%22%2C%22endTime%22%3A%222018-03-15T13%3A34%3A00Z%22%7D%7D/ChartDefinition/%7B%22v2charts%22%3A%5B%7B%22grouping%22%3A%7B%22dimension%22%3A%22blob%22%2C%22sort%22%3A2%2C%22top%22%3A10%7D%2C%22metrics%22%3A%5B%7B%22resourceMetadata%22%3A%7B%22id%22%3A%22%2Fsubscriptions%2F12345678-aaaa-bbbb-cccc-123456789abc%2FresourceGroups%2Fgrafanastaging%2Fproviders%2FMicrosoft.Compute%2FvirtualMachines%2Fgrafana%22%7D%2C%22name%22%3A%22Percentage%20CPU%22%2C%22aggregationType%22%3A4%2C%22namespace%22%3A%22Microsoft.Compute%2FvirtualMachines%22%2C%22metricVisualization%22%3A%7B%22displayName%22%3A%22Percentage%20CPU%22%2C%22resourceDisplayName%22%3A%22grafana%22%7D%7D%5D%7D%5D%7D"), + expectedPortalURL: Pointer("http://ds/#blade/Microsoft_Azure_MonitoringMetrics/Metrics.ReactView/Referer/MetricsExplorer/TimeContext/%7B%22absolute%22%3A%7B%22startTime%22%3A%222018-03-15T13%3A00%3A00Z%22%2C%22endTime%22%3A%222018-03-15T13%3A34%3A00Z%22%7D%7D/ChartDefinition/%7B%22v2charts%22%3A%5B%7B%22grouping%22%3A%7B%22dimension%22%3A%22blob%22%2C%22sort%22%3A2%2C%22top%22%3A10%7D%2C%22metrics%22%3A%5B%7B%22resourceMetadata%22%3A%7B%22id%22%3A%22%2Fsubscriptions%2F12345678-aaaa-bbbb-cccc-123456789abc%2FresourceGroups%2Fgrafanastaging%2Fproviders%2FMicrosoft.Compute%2FvirtualMachines%2Fgrafana%22%7D%2C%22name%22%3A%22Percentage%20CPU%22%2C%22aggregationType%22%3A4%2C%22namespace%22%3A%22Microsoft.Compute%2FvirtualMachines%22%2C%22metricVisualization%22%3A%7B%22displayName%22%3A%22Percentage%20CPU%22%2C%22resourceDisplayName%22%3A%22grafana%22%7D%7D%5D%7D%5D%7D"), }, { name: "correctly constructs target when multiple filter values are provided for the 'eq' operator", @@ -194,7 +195,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) { expectedInterval: "PT1M", azureMonitorQueryTarget: "aggregation=Average&api-version=2021-05-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute%2FvirtualMachines×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z&top=30", expectedParamFilter: "blob eq 'test' or blob eq 'test2'", - expectedPortalURL: util.Pointer("http://ds/#blade/Microsoft_Azure_MonitoringMetrics/Metrics.ReactView/Referer/MetricsExplorer/TimeContext/%7B%22absolute%22%3A%7B%22startTime%22%3A%222018-03-15T13%3A00%3A00Z%22%2C%22endTime%22%3A%222018-03-15T13%3A34%3A00Z%22%7D%7D/ChartDefinition/%7B%22v2charts%22%3A%5B%7B%22filterCollection%22%3A%7B%22filters%22%3A%5B%7B%22key%22%3A%22blob%22%2C%22operator%22%3A0%2C%22values%22%3A%5B%22test%22%2C%22test2%22%5D%7D%5D%7D%2C%22grouping%22%3A%7B%22dimension%22%3A%22blob%22%2C%22sort%22%3A2%2C%22top%22%3A10%7D%2C%22metrics%22%3A%5B%7B%22resourceMetadata%22%3A%7B%22id%22%3A%22%2Fsubscriptions%2F12345678-aaaa-bbbb-cccc-123456789abc%2FresourceGroups%2Fgrafanastaging%2Fproviders%2FMicrosoft.Compute%2FvirtualMachines%2Fgrafana%22%7D%2C%22name%22%3A%22Percentage%20CPU%22%2C%22aggregationType%22%3A4%2C%22namespace%22%3A%22Microsoft.Compute%2FvirtualMachines%22%2C%22metricVisualization%22%3A%7B%22displayName%22%3A%22Percentage%20CPU%22%2C%22resourceDisplayName%22%3A%22grafana%22%7D%7D%5D%7D%5D%7D"), + expectedPortalURL: Pointer("http://ds/#blade/Microsoft_Azure_MonitoringMetrics/Metrics.ReactView/Referer/MetricsExplorer/TimeContext/%7B%22absolute%22%3A%7B%22startTime%22%3A%222018-03-15T13%3A00%3A00Z%22%2C%22endTime%22%3A%222018-03-15T13%3A34%3A00Z%22%7D%7D/ChartDefinition/%7B%22v2charts%22%3A%5B%7B%22filterCollection%22%3A%7B%22filters%22%3A%5B%7B%22key%22%3A%22blob%22%2C%22operator%22%3A0%2C%22values%22%3A%5B%22test%22%2C%22test2%22%5D%7D%5D%7D%2C%22grouping%22%3A%7B%22dimension%22%3A%22blob%22%2C%22sort%22%3A2%2C%22top%22%3A10%7D%2C%22metrics%22%3A%5B%7B%22resourceMetadata%22%3A%7B%22id%22%3A%22%2Fsubscriptions%2F12345678-aaaa-bbbb-cccc-123456789abc%2FresourceGroups%2Fgrafanastaging%2Fproviders%2FMicrosoft.Compute%2FvirtualMachines%2Fgrafana%22%7D%2C%22name%22%3A%22Percentage%20CPU%22%2C%22aggregationType%22%3A4%2C%22namespace%22%3A%22Microsoft.Compute%2FvirtualMachines%22%2C%22metricVisualization%22%3A%7B%22displayName%22%3A%22Percentage%20CPU%22%2C%22resourceDisplayName%22%3A%22grafana%22%7D%7D%5D%7D%5D%7D"), }, { name: "correctly constructs target when multiple filter values are provided for ne 'eq' operator", @@ -207,7 +208,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) { expectedInterval: "PT1M", azureMonitorQueryTarget: "aggregation=Average&api-version=2021-05-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute%2FvirtualMachines×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z&top=30", expectedParamFilter: "blob ne 'test' and blob ne 'test2'", - expectedPortalURL: util.Pointer("http://ds/#blade/Microsoft_Azure_MonitoringMetrics/Metrics.ReactView/Referer/MetricsExplorer/TimeContext/%7B%22absolute%22%3A%7B%22startTime%22%3A%222018-03-15T13%3A00%3A00Z%22%2C%22endTime%22%3A%222018-03-15T13%3A34%3A00Z%22%7D%7D/ChartDefinition/%7B%22v2charts%22%3A%5B%7B%22filterCollection%22%3A%7B%22filters%22%3A%5B%7B%22key%22%3A%22blob%22%2C%22operator%22%3A1%2C%22values%22%3A%5B%22test%22%2C%22test2%22%5D%7D%5D%7D%2C%22grouping%22%3A%7B%22dimension%22%3A%22blob%22%2C%22sort%22%3A2%2C%22top%22%3A10%7D%2C%22metrics%22%3A%5B%7B%22resourceMetadata%22%3A%7B%22id%22%3A%22%2Fsubscriptions%2F12345678-aaaa-bbbb-cccc-123456789abc%2FresourceGroups%2Fgrafanastaging%2Fproviders%2FMicrosoft.Compute%2FvirtualMachines%2Fgrafana%22%7D%2C%22name%22%3A%22Percentage%20CPU%22%2C%22aggregationType%22%3A4%2C%22namespace%22%3A%22Microsoft.Compute%2FvirtualMachines%22%2C%22metricVisualization%22%3A%7B%22displayName%22%3A%22Percentage%20CPU%22%2C%22resourceDisplayName%22%3A%22grafana%22%7D%7D%5D%7D%5D%7D"), + expectedPortalURL: Pointer("http://ds/#blade/Microsoft_Azure_MonitoringMetrics/Metrics.ReactView/Referer/MetricsExplorer/TimeContext/%7B%22absolute%22%3A%7B%22startTime%22%3A%222018-03-15T13%3A00%3A00Z%22%2C%22endTime%22%3A%222018-03-15T13%3A34%3A00Z%22%7D%7D/ChartDefinition/%7B%22v2charts%22%3A%5B%7B%22filterCollection%22%3A%7B%22filters%22%3A%5B%7B%22key%22%3A%22blob%22%2C%22operator%22%3A1%2C%22values%22%3A%5B%22test%22%2C%22test2%22%5D%7D%5D%7D%2C%22grouping%22%3A%7B%22dimension%22%3A%22blob%22%2C%22sort%22%3A2%2C%22top%22%3A10%7D%2C%22metrics%22%3A%5B%7B%22resourceMetadata%22%3A%7B%22id%22%3A%22%2Fsubscriptions%2F12345678-aaaa-bbbb-cccc-123456789abc%2FresourceGroups%2Fgrafanastaging%2Fproviders%2FMicrosoft.Compute%2FvirtualMachines%2Fgrafana%22%7D%2C%22name%22%3A%22Percentage%20CPU%22%2C%22aggregationType%22%3A4%2C%22namespace%22%3A%22Microsoft.Compute%2FvirtualMachines%22%2C%22metricVisualization%22%3A%7B%22displayName%22%3A%22Percentage%20CPU%22%2C%22resourceDisplayName%22%3A%22grafana%22%7D%7D%5D%7D%5D%7D"), }, { name: "Includes a region", @@ -257,7 +258,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) { expectedURL: "/subscriptions/12345678-aaaa-bbbb-cccc-123456789abc/providers/microsoft.insights/metrics", azureMonitorQueryTarget: "aggregation=Average&api-version=2021-05-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute%2FvirtualMachines×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z&top=30", expectedBodyFilter: "(Microsoft.ResourceId eq '/subscriptions/12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm' or Microsoft.ResourceId eq '/subscriptions/12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/rg2/providers/Microsoft.Compute/virtualMachines/vm2') and (blob ne 'test' and blob ne 'test2')", - expectedPortalURL: util.Pointer("http://ds/#blade/Microsoft_Azure_MonitoringMetrics/Metrics.ReactView/Referer/MetricsExplorer/TimeContext/%7B%22absolute%22%3A%7B%22startTime%22%3A%222018-03-15T13%3A00%3A00Z%22%2C%22endTime%22%3A%222018-03-15T13%3A34%3A00Z%22%7D%7D/ChartDefinition/%7B%22v2charts%22%3A%5B%7B%22filterCollection%22%3A%7B%22filters%22%3A%5B%7B%22key%22%3A%22blob%22%2C%22operator%22%3A1%2C%22values%22%3A%5B%22test%22%2C%22test2%22%5D%7D%5D%7D%2C%22grouping%22%3A%7B%22dimension%22%3A%22blob%22%2C%22sort%22%3A2%2C%22top%22%3A10%7D%2C%22metrics%22%3A%5B%7B%22resourceMetadata%22%3A%7B%22id%22%3A%22%2Fsubscriptions%2F12345678-aaaa-bbbb-cccc-123456789abc%2FresourceGroups%2Fgrafanastaging%2Fproviders%2FMicrosoft.Compute%2FvirtualMachines%2Fgrafana%22%7D%2C%22name%22%3A%22Percentage%20CPU%22%2C%22aggregationType%22%3A4%2C%22namespace%22%3A%22Microsoft.Compute%2FvirtualMachines%22%2C%22metricVisualization%22%3A%7B%22displayName%22%3A%22Percentage%20CPU%22%2C%22resourceDisplayName%22%3A%22grafana%22%7D%7D%5D%7D%5D%7D"), + expectedPortalURL: Pointer("http://ds/#blade/Microsoft_Azure_MonitoringMetrics/Metrics.ReactView/Referer/MetricsExplorer/TimeContext/%7B%22absolute%22%3A%7B%22startTime%22%3A%222018-03-15T13%3A00%3A00Z%22%2C%22endTime%22%3A%222018-03-15T13%3A34%3A00Z%22%7D%7D/ChartDefinition/%7B%22v2charts%22%3A%5B%7B%22filterCollection%22%3A%7B%22filters%22%3A%5B%7B%22key%22%3A%22blob%22%2C%22operator%22%3A1%2C%22values%22%3A%5B%22test%22%2C%22test2%22%5D%7D%5D%7D%2C%22grouping%22%3A%7B%22dimension%22%3A%22blob%22%2C%22sort%22%3A2%2C%22top%22%3A10%7D%2C%22metrics%22%3A%5B%7B%22resourceMetadata%22%3A%7B%22id%22%3A%22%2Fsubscriptions%2F12345678-aaaa-bbbb-cccc-123456789abc%2FresourceGroups%2Fgrafanastaging%2Fproviders%2FMicrosoft.Compute%2FvirtualMachines%2Fgrafana%22%7D%2C%22name%22%3A%22Percentage%20CPU%22%2C%22aggregationType%22%3A4%2C%22namespace%22%3A%22Microsoft.Compute%2FvirtualMachines%22%2C%22metricVisualization%22%3A%7B%22displayName%22%3A%22Percentage%20CPU%22%2C%22resourceDisplayName%22%3A%22grafana%22%7D%7D%5D%7D%5D%7D"), }, } diff --git a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go index c04aecc562ea4..b9616ae8cc163 100644 --- a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go +++ b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go @@ -12,6 +12,7 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" "github.com/grafana/grafana-plugin-sdk-go/data" "go.opentelemetry.io/otel/attribute" @@ -30,7 +31,8 @@ type AzureResourceGraphResponse struct { // AzureResourceGraphDatasource calls the Azure Resource Graph API's type AzureResourceGraphDatasource struct { - Proxy types.ServiceProxy + Proxy types.ServiceProxy + Logger log.Logger } // AzureResourceGraphQuery is the query request that is built from the saved values for @@ -160,7 +162,7 @@ func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, query * ) defer span.End() - backend.Logger.Debug("azure resource graph query", "traceID", trace.SpanContextFromContext(ctx).TraceID()) + e.Logger.Debug("azure resource graph query", "traceID", trace.SpanContextFromContext(ctx).TraceID()) res, err := client.Do(req) if err != nil { @@ -188,12 +190,7 @@ func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, query * return &dataResponse, nil } - azurePortalUrl, err := loganalytics.GetAzurePortalUrl(dsInfo.Cloud) - if err != nil { - return nil, err - } - - url := azurePortalUrl + "/#blade/HubsExtension/ArgQueryBlade/query/" + url.PathEscape(query.InterpolatedQuery) + url := dsInfo.Routes["Azure Portal"].URL + "/#blade/HubsExtension/ArgQueryBlade/query/" + url.PathEscape(query.InterpolatedQuery) frameWithLink := loganalytics.AddConfigLinks(*frame, url, nil) if frameWithLink.Meta == nil { frameWithLink.Meta = &data.FrameMeta{} @@ -224,7 +221,7 @@ func (e *AzureResourceGraphDatasource) unmarshalResponse(res *http.Response) (Az defer func() { if err := res.Body.Close(); err != nil { - backend.Logger.Warn("Failed to close response body", "err", err) + e.Logger.Warn("Failed to close response body", "err", err) } }() diff --git a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource_test.go b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource_test.go index 751e0757508a4..f1f7f0ca8aed3 100644 --- a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource_test.go +++ b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource_test.go @@ -10,7 +10,6 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" @@ -136,23 +135,6 @@ func TestAddConfigData(t *testing.T) { } } -func TestGetAzurePortalUrl(t *testing.T) { - clouds := []string{azsettings.AzurePublic, azsettings.AzureChina, azsettings.AzureUSGovernment} - expectedAzurePortalUrl := map[string]any{ - azsettings.AzurePublic: "https://portal.azure.com", - azsettings.AzureChina: "https://portal.azure.cn", - azsettings.AzureUSGovernment: "https://portal.azure.us", - } - - for _, cloud := range clouds { - azurePortalUrl, err := loganalytics.GetAzurePortalUrl(cloud) - if err != nil { - t.Errorf("The cloud not supported") - } - assert.Equal(t, expectedAzurePortalUrl[cloud], azurePortalUrl) - } -} - func TestUnmarshalResponse400(t *testing.T) { datasource := &AzureResourceGraphDatasource{} res, err := datasource.unmarshalResponse(&http.Response{ diff --git a/pkg/tsdb/azuremonitor/routes.go b/pkg/tsdb/azuremonitor/routes.go index 430c1523addf5..a7ca85e40ba26 100644 --- a/pkg/tsdb/azuremonitor/routes.go +++ b/pkg/tsdb/azuremonitor/routes.go @@ -1,6 +1,12 @@ package azuremonitor import ( + "encoding/json" + "fmt" + "net/url" + "path" + + "github.com/grafana/grafana-azure-sdk-go/azcredentials" "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" @@ -12,63 +18,99 @@ const ( azureLogAnalytics = "Azure Log Analytics" azureResourceGraph = "Azure Resource Graph" azureTraces = "Azure Traces" + azurePortal = "Azure Portal" ) -var azManagement = types.AzRoute{ - URL: "https://management.azure.com", - Scopes: []string{"https://management.azure.com/.default"}, - Headers: map[string]string{"x-ms-app": "Grafana"}, -} +func getAzureMonitorRoutes(settings *azsettings.AzureSettings, credentials azcredentials.AzureCredentials, jsonData json.RawMessage) (map[string]types.AzRoute, error) { + azureCloud, err := azcredentials.GetAzureCloud(settings, credentials) + if err != nil { + return nil, err + } -var azUSGovManagement = types.AzRoute{ - URL: "https://management.usgovcloudapi.net", - Scopes: []string{"https://management.usgovcloudapi.net/.default"}, - Headers: map[string]string{"x-ms-app": "Grafana"}, -} + if azureCloud == azsettings.AzureCustomized { + routes, err := getCustomizedCloudRoutes(jsonData) + if err != nil { + return nil, err + } + return routes, nil + } -var azChinaManagement = types.AzRoute{ - URL: "https://management.chinacloudapi.cn", - Scopes: []string{"https://management.chinacloudapi.cn/.default"}, - Headers: map[string]string{"x-ms-app": "Grafana"}, -} + cloudSettings, err := settings.GetCloud(azureCloud) + if err != nil { + return nil, err + } -var azLogAnalytics = types.AzRoute{ - URL: "https://api.loganalytics.io", - Scopes: []string{"https://api.loganalytics.io/.default"}, - Headers: map[string]string{"x-ms-app": "Grafana", "Cache-Control": "public, max-age=60"}, -} + resourceManagerUrl, ok := cloudSettings.Properties["resourceManager"] + if !ok { + err := fmt.Errorf("the Azure cloud '%s' doesn't have configuration for Azure Resource Manager", azureCloud) + return nil, err + } + resourceManagerScopes, err := audienceToScopes(resourceManagerUrl) + if err != nil { + return nil, err + } + resourceManagerRoute := types.AzRoute{ + URL: resourceManagerUrl, + Scopes: resourceManagerScopes, + Headers: map[string]string{"x-ms-app": "Grafana"}, + } + logAnalyticsUrl, ok := cloudSettings.Properties["logAnalytics"] + if !ok { + err := fmt.Errorf("the Azure cloud '%s' doesn't have configuration for Azure Log Analytics", azureCloud) + return nil, err + } + logAnalyticsScopes, err := audienceToScopes(logAnalyticsUrl) + if err != nil { + return nil, err + } + logAnalyticsRoute := types.AzRoute{ + URL: logAnalyticsUrl, + Scopes: logAnalyticsScopes, + Headers: map[string]string{"x-ms-app": "Grafana", "Cache-Control": "public, max-age=60"}, + } + portalUrl, ok := cloudSettings.Properties["portal"] + if !ok { + err := fmt.Errorf("the Azure cloud '%s' doesn't have configuration for Azure Portal", azureCloud) + return nil, err + } + portalRoute := types.AzRoute{ + URL: portalUrl, + } -var azChinaLogAnalytics = types.AzRoute{ - URL: "https://api.loganalytics.azure.cn", - Scopes: []string{"https://api.loganalytics.azure.cn/.default"}, - Headers: map[string]string{"x-ms-app": "Grafana", "Cache-Control": "public, max-age=60"}, + routes := map[string]types.AzRoute{ + azureMonitor: resourceManagerRoute, + azureLogAnalytics: logAnalyticsRoute, + azureResourceGraph: resourceManagerRoute, + azureTraces: logAnalyticsRoute, + azurePortal: portalRoute, + } + + return routes, nil } -var azUSGovLogAnalytics = types.AzRoute{ - URL: "https://api.loganalytics.us", - Scopes: []string{"https://api.loganalytics.us/.default"}, - Headers: map[string]string{"x-ms-app": "Grafana", "Cache-Control": "public, max-age=60"}, +func getCustomizedCloudRoutes(jsonData json.RawMessage) (map[string]types.AzRoute, error) { + customizedCloudSettings := types.AzureMonitorCustomizedCloudSettings{} + err := json.Unmarshal(jsonData, &customizedCloudSettings) + if err != nil { + return nil, fmt.Errorf("error getting customized cloud settings: %w", err) + } + + if customizedCloudSettings.CustomizedRoutes == nil { + return nil, fmt.Errorf("unable to instantiate routes, customizedRoutes must be set") + } + + azureRoutes := customizedCloudSettings.CustomizedRoutes + return azureRoutes, nil } -var ( - // The different Azure routes are identified by its cloud (e.g. public or gov) - // and the service to query (e.g. Azure Monitor or Azure Log Analytics) - routes = map[string]map[string]types.AzRoute{ - azsettings.AzurePublic: { - azureMonitor: azManagement, - azureLogAnalytics: azLogAnalytics, - azureResourceGraph: azManagement, - azureTraces: azLogAnalytics, - }, - azsettings.AzureUSGovernment: { - azureMonitor: azUSGovManagement, - azureLogAnalytics: azUSGovLogAnalytics, - azureResourceGraph: azUSGovManagement, - }, - azsettings.AzureChina: { - azureMonitor: azChinaManagement, - azureLogAnalytics: azChinaLogAnalytics, - azureResourceGraph: azChinaManagement, - }, +func audienceToScopes(audience string) ([]string, error) { + resourceId, err := url.Parse(audience) + if err != nil || resourceId.Scheme == "" || resourceId.Host == "" { + err = fmt.Errorf("endpoint resource ID (audience) '%s' invalid", audience) + return nil, err } -) + + resourceId.Path = path.Join(resourceId.Path, ".default") + scopes := []string{resourceId.String()} + return scopes, nil +} diff --git a/pkg/tsdb/azuremonitor/standalone/datasource.go b/pkg/tsdb/azuremonitor/standalone/datasource.go new file mode 100644 index 0000000000000..082ee29ac3286 --- /dev/null +++ b/pkg/tsdb/azuremonitor/standalone/datasource.go @@ -0,0 +1,38 @@ +package main + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + azuremonitor "github.com/grafana/grafana/pkg/tsdb/azuremonitor" +) + +var ( + _ backend.QueryDataHandler = (*Datasource)(nil) + _ backend.CheckHealthHandler = (*Datasource)(nil) + _ backend.CallResourceHandler = (*Datasource)(nil) +) + +func NewDatasource(context.Context, backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return &Datasource{ + Service: azuremonitor.ProvideService(httpclient.NewProvider()), + }, nil +} + +type Datasource struct { + Service *azuremonitor.Service +} + +func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return d.Service.QueryData(ctx, req) +} + +func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + return d.Service.CallResource(ctx, req, sender) +} + +func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + return d.Service.CheckHealth(ctx, req) +} diff --git a/pkg/tsdb/azuremonitor/standalone/main.go b/pkg/tsdb/azuremonitor/standalone/main.go new file mode 100644 index 0000000000000..e1af6def7a745 --- /dev/null +++ b/pkg/tsdb/azuremonitor/standalone/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "os" + + "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +func main() { + // Start listening to requests sent from Grafana. This call is blocking so + // it won't finish until Grafana shuts down the process or the plugin choose + // to exit by itself using os.Exit. Manage automatically manages life cycle + // of datasource instances. It accepts datasource instance factory as first + // argument. This factory will be automatically called on incoming request + // from Grafana to create different instances of SampleDatasource (per datasource + // ID). When datasource configuration changed Dispose method will be called and + // new datasource instance created using NewSampleDatasource factory. + if err := datasource.Manage("grafana-azure-monitor-datasource", NewDatasource, datasource.ManageOpts{}); err != nil { + log.DefaultLogger.Error(err.Error()) + os.Exit(1) + } +} diff --git a/pkg/tsdb/azuremonitor/time/time-grain.go b/pkg/tsdb/azuremonitor/time/time-grain.go index 6c69642482c73..dfc62d9c1ff0a 100644 --- a/pkg/tsdb/azuremonitor/time/time-grain.go +++ b/pkg/tsdb/azuremonitor/time/time-grain.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" ) // TimeGrain handles conversions between @@ -17,7 +17,7 @@ var ( ) func CreateISO8601DurationFromIntervalMS(it int64) (string, error) { - formatted := intervalv2.FormatDuration(time.Duration(it) * time.Millisecond) + formatted := gtime.FormatInterval(time.Duration(it) * time.Millisecond) if strings.Contains(formatted, "ms") { return "PT1M", nil diff --git a/pkg/tsdb/azuremonitor/types/types.go b/pkg/tsdb/azuremonitor/types/types.go index 36dfa6b70ce0e..f2e15680e1bdd 100644 --- a/pkg/tsdb/azuremonitor/types/types.go +++ b/pkg/tsdb/azuremonitor/types/types.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana-azure-sdk-go/azcredentials" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/kinds/dataquery" ) @@ -44,10 +45,10 @@ type AzureMonitorCustomizedCloudSettings struct { type DatasourceService struct { URL string HTTPClient *http.Client + Logger log.Logger } type DatasourceInfo struct { - Cloud string Credentials azcredentials.AzureCredentials Settings AzureMonitorSettings Routes map[string]AzRoute diff --git a/pkg/tsdb/cloud-monitoring/annotation_query.go b/pkg/tsdb/cloud-monitoring/annotation_query.go index afa54f128e59d..a3c14572c9b5b 100644 --- a/pkg/tsdb/cloud-monitoring/annotation_query.go +++ b/pkg/tsdb/cloud-monitoring/annotation_query.go @@ -8,6 +8,7 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/data" ) @@ -18,10 +19,10 @@ type annotationEvent struct { Text string } -func (s *Service) executeAnnotationQuery(ctx context.Context, req *backend.QueryDataRequest, dsInfo datasourceInfo, queries []cloudMonitoringQueryExecutor) ( +func (s *Service) executeAnnotationQuery(ctx context.Context, req *backend.QueryDataRequest, dsInfo datasourceInfo, queries []cloudMonitoringQueryExecutor, logger log.Logger) ( *backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() - queryRes, dr, _, err := queries[0].run(ctx, req, s, dsInfo, s.tracer) + queryRes, dr, _, err := queries[0].run(ctx, req, s, dsInfo, logger) if err != nil { return resp, err } diff --git a/pkg/tsdb/cloud-monitoring/cloudmonitoring.go b/pkg/tsdb/cloud-monitoring/cloudmonitoring.go index cb63abd0ad6af..c8218bb2959e7 100644 --- a/pkg/tsdb/cloud-monitoring/cloudmonitoring.go +++ b/pkg/tsdb/cloud-monitoring/cloudmonitoring.go @@ -17,20 +17,15 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/infra/httpclient" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/tsdb/cloud-monitoring/kinds/dataquery" ) -var ( - slog = log.New("tsdb.cloudMonitoring") -) - var ( legendKeyFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`) metricNameFormat = regexp.MustCompile(`([\w\d_]+)\.(googleapis\.com|io)/(.+)`) @@ -65,11 +60,11 @@ const ( perSeriesAlignerDefault = "ALIGN_MEAN" ) -func ProvideService(httpClientProvider httpclient.Provider, tracer tracing.Tracer) *Service { +func ProvideService(httpClientProvider *httpclient.Provider) *Service { s := &Service{ - tracer: tracer, - httpClientProvider: httpClientProvider, - im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)), + httpClientProvider: *httpClientProvider, + im: datasource.NewInstanceManager(newInstanceSettings(*httpClientProvider)), + logger: backend.NewLoggerWith("logger", "tsdb.cloudmonitoring"), gceDefaultProjectGetter: utils.GCEDefaultProject, } @@ -109,7 +104,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque } defer func() { if err := res.Body.Close(); err != nil { - slog.Warn("Failed to close response body", "err", err) + s.logger.Warn("Failed to close response body", "err", err) } }() @@ -128,7 +123,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque type Service struct { httpClientProvider httpclient.Provider im instancemgmt.InstanceManager - tracer tracing.Tracer + logger log.Logger resourceHandler backend.CallResourceHandler @@ -194,7 +189,7 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst } for name, info := range routes { - client, err := newHTTPClient(dsInfo, opts, httpClientProvider, name) + client, err := newHTTPClient(dsInfo, opts, &httpClientProvider, name) if err != nil { return nil, err } @@ -332,7 +327,7 @@ func migrateRequest(req *backend.QueryDataRequest) error { // QueryData takes in the frontend queries, parses them into the CloudMonitoring query format // executes the queries against the CloudMonitoring API and parses the response into data frames func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - logger := slog.FromContext(ctx) + logger := s.logger.FromContext(ctx) if len(req.Queries) == 0 { return nil, fmt.Errorf("query contains no queries") } @@ -354,21 +349,21 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) switch req.Queries[0].QueryType { case string(dataquery.QueryTypeAnnotation): - return s.executeAnnotationQuery(ctx, req, *dsInfo, queries) + return s.executeAnnotationQuery(ctx, req, *dsInfo, queries, logger) default: - return s.executeTimeSeriesQuery(ctx, req, *dsInfo, queries) + return s.executeTimeSeriesQuery(ctx, req, *dsInfo, queries, logger) } } -func (s *Service) executeTimeSeriesQuery(ctx context.Context, req *backend.QueryDataRequest, dsInfo datasourceInfo, queries []cloudMonitoringQueryExecutor) ( +func (s *Service) executeTimeSeriesQuery(ctx context.Context, req *backend.QueryDataRequest, dsInfo datasourceInfo, queries []cloudMonitoringQueryExecutor, logger log.Logger) ( *backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, queryExecutor := range queries { - queryRes, dr, executedQueryString, err := queryExecutor.run(ctx, req, s, dsInfo, s.tracer) + queryRes, dr, executedQueryString, err := queryExecutor.run(ctx, req, s, dsInfo, logger) if err != nil { return resp, err } - err = queryExecutor.parseResponse(queryRes, dr, executedQueryString) + err = queryExecutor.parseResponse(queryRes, dr, executedQueryString, logger) if err != nil { queryRes.Error = err } @@ -405,7 +400,6 @@ func (s *Service) buildQueryExecutors(logger log.Logger, req *backend.QueryDataR case string(dataquery.QueryTypeTimeSeriesList), string(dataquery.QueryTypeAnnotation): cmtsf := &cloudMonitoringTimeSeriesList{ refID: query.RefID, - logger: logger, aliasBy: q.AliasBy, } if q.TimeSeriesList.View == nil || *q.TimeSeriesList.View == "" { @@ -427,7 +421,6 @@ func (s *Service) buildQueryExecutors(logger log.Logger, req *backend.QueryDataR case string(dataquery.QueryTypeSlo): cmslo := &cloudMonitoringSLO{ refID: query.RefID, - logger: logger, aliasBy: q.AliasBy, parameters: q.SloQuery, } @@ -436,10 +429,10 @@ func (s *Service) buildQueryExecutors(logger log.Logger, req *backend.QueryDataR case string(dataquery.QueryTypePromQL): cmp := &cloudMonitoringProm{ refID: query.RefID, - logger: logger, aliasBy: q.AliasBy, parameters: q.PromQLQuery, timeRange: req.Queries[0].TimeRange, + logger: logger, } queryInterface = cmp default: @@ -595,7 +588,7 @@ func (s *Service) getDefaultProject(ctx context.Context, dsInfo datasourceInfo) return dsInfo.defaultProject, nil } -func unmarshalResponse(logger log.Logger, res *http.Response) (cloudMonitoringResponse, error) { +func unmarshalResponse(res *http.Response, logger log.Logger) (cloudMonitoringResponse, error) { body, err := io.ReadAll(res.Body) if err != nil { return cloudMonitoringResponse{}, err @@ -646,7 +639,7 @@ func addConfigData(frames data.Frames, dl string, unit string, period *string) d if period != nil && *period != "" { err := addInterval(*period, frames[i].Fields[0]) if err != nil { - slog.Error("Failed to add interval", "error", err) + backend.Logger.Error("Failed to add interval", "error", err) } } } diff --git a/pkg/tsdb/cloud-monitoring/cloudmonitoring_test.go b/pkg/tsdb/cloud-monitoring/cloudmonitoring_test.go index 8b46c7f4d32a4..31972c68f2a64 100644 --- a/pkg/tsdb/cloud-monitoring/cloudmonitoring_test.go +++ b/pkg/tsdb/cloud-monitoring/cloudmonitoring_test.go @@ -12,8 +12,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" - "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/tsdb/cloud-monitoring/kinds/dataquery" "github.com/stretchr/testify/assert" @@ -23,7 +23,7 @@ import ( func TestNewInstanceSettings(t *testing.T) { t.Run("should create a new instance with empty settings", func(t *testing.T) { cli := httpclient.NewProvider() - f := newInstanceSettings(cli) + f := newInstanceSettings(*cli) dsInfo, err := f(context.Background(), backend.DataSourceInstanceSettings{ JSONData: json.RawMessage(`{}`), }) @@ -34,7 +34,7 @@ func TestNewInstanceSettings(t *testing.T) { t.Run("should create a new instance parsing settings", func(t *testing.T) { cli := httpclient.NewProvider() - f := newInstanceSettings(cli) + f := newInstanceSettings(*cli) dsInfo, err := f(context.Background(), backend.DataSourceInstanceSettings{ JSONData: json.RawMessage(`{"authenticationType": "test", "defaultProject": "test", "clientEmail": "test", "tokenUri": "test"}`), }) @@ -53,7 +53,7 @@ func TestCloudMonitoring(t *testing.T) { t.Run("parses a time series list query", func(t *testing.T) { req := baseTimeSeriesList() - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) @@ -71,7 +71,7 @@ func TestCloudMonitoring(t *testing.T) { t.Run("parses a time series query", func(t *testing.T) { req := baseTimeSeriesQuery() - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringQueryFromInterface(t, qes) @@ -95,7 +95,7 @@ func TestCloudMonitoring(t *testing.T) { "aliasBy": "testalias" }`) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) @@ -114,7 +114,7 @@ func TestCloudMonitoring(t *testing.T) { req := deprecatedReq() err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) @@ -157,7 +157,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(query) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, query) + qes, err := service.buildQueryExecutors(service.logger, query) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, 1, len(queries)) @@ -191,7 +191,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, `+1000s`, queries[0].params["aggregation.alignmentPeriod"][0]) @@ -221,7 +221,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, `+60s`, queries[0].params["aggregation.alignmentPeriod"][0]) @@ -257,7 +257,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, `+60s`, queries[0].params["aggregation.alignmentPeriod"][0]) @@ -274,7 +274,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, `+60s`, queries[0].params["aggregation.alignmentPeriod"][0]) @@ -291,7 +291,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, `+300s`, queries[0].params["aggregation.alignmentPeriod"][0]) @@ -308,7 +308,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, `+3600s`, queries[0].params["aggregation.alignmentPeriod"][0]) @@ -329,7 +329,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, `+60s`, queries[0].params["aggregation.alignmentPeriod"][0]) @@ -361,7 +361,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, `+60s`, queries[0].params["aggregation.alignmentPeriod"][0]) @@ -393,7 +393,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, `+300s`, queries[0].params["aggregation.alignmentPeriod"][0]) @@ -425,7 +425,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, `+3600s`, queries[0].params["aggregation.alignmentPeriod"][0]) @@ -457,7 +457,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) assert.Equal(t, `+600s`, queries[0].params["aggregation.alignmentPeriod"][0]) @@ -489,7 +489,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) @@ -535,7 +535,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) @@ -598,7 +598,7 @@ func TestCloudMonitoring(t *testing.T) { require.NoError(t, err) t.Run("and query type is metrics", func(t *testing.T) { - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) @@ -647,7 +647,7 @@ func TestCloudMonitoring(t *testing.T) { err = migrateRequest(req) require.NoError(t, err) - qes, err = service.buildQueryExecutors(slog, req) + qes, err = service.buildQueryExecutors(service.logger, req) require.NoError(t, err) tqueries := getCloudMonitoringQueryFromInterface(t, qes) @@ -675,7 +675,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringSLOFromInterface(t, qes) @@ -705,7 +705,7 @@ func TestCloudMonitoring(t *testing.T) { err = migrateRequest(req) require.NoError(t, err) - qes, err = service.buildQueryExecutors(slog, req) + qes, err = service.buildQueryExecutors(service.logger, req) require.NoError(t, err) qqueries := getCloudMonitoringSLOFromInterface(t, qes) assert.Equal(t, "ALIGN_NEXT_OLDER", qqueries[0].params["aggregation.perSeriesAligner"][0]) @@ -730,7 +730,7 @@ func TestCloudMonitoring(t *testing.T) { err = migrateRequest(req) require.NoError(t, err) - qes, err = service.buildQueryExecutors(slog, req) + qes, err = service.buildQueryExecutors(service.logger, req) require.NoError(t, err) qqqueries := getCloudMonitoringSLOFromInterface(t, qes) assert.Equal(t, `aggregation.alignmentPeriod=%2B60s&aggregation.perSeriesAligner=ALIGN_NEXT_OLDER&filter=select_slo_burn_rate%28%22projects%2Ftest-proj%2Fservices%2Ftest-service%2FserviceLevelObjectives%2Ftest-slo%22%2C+%221h%22%29&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z`, qqqueries[0].params.Encode()) @@ -812,7 +812,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) @@ -842,7 +842,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) @@ -872,7 +872,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) @@ -900,7 +900,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) @@ -930,7 +930,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) @@ -958,7 +958,7 @@ func TestCloudMonitoring(t *testing.T) { err := migrateRequest(req) require.NoError(t, err) - qes, err := service.buildQueryExecutors(slog, req) + qes, err := service.buildQueryExecutors(service.logger, req) require.NoError(t, err) queries := getCloudMonitoringListFromInterface(t, qes) diff --git a/pkg/tsdb/cloud-monitoring/converter/converter.go b/pkg/tsdb/cloud-monitoring/converter/converter.go new file mode 100644 index 0000000000000..7b5cd6b95f61f --- /dev/null +++ b/pkg/tsdb/cloud-monitoring/converter/converter.go @@ -0,0 +1,1175 @@ +package converter + +import ( + "encoding/json" + "fmt" + "strconv" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + sdkjsoniter "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter" + jsoniter "github.com/json-iterator/go" + + "golang.org/x/exp/slices" +) + +// helpful while debugging all the options that may appear +func logf(format string, a ...any) { + // fmt.Printf(format, a...) +} + +type Options struct { + Dataplane bool +} + +func rspErr(e error) backend.DataResponse { + return backend.DataResponse{Error: e} +} + +// ReadPrometheusStyleResult will read results from a prometheus or loki server and return data frames +func ReadPrometheusStyleResult(jIter *jsoniter.Iterator, opt Options) backend.DataResponse { + iter := sdkjsoniter.NewIterator(jIter) + var rsp backend.DataResponse + status := "unknown" + errorType := "" + promErrString := "" + warnings := []data.Notice{} + +l1Fields: + for l1Field, err := iter.ReadObject(); ; l1Field, err = iter.ReadObject() { + if err != nil { + return rspErr(err) + } + switch l1Field { + case "status": + if status, err = iter.ReadString(); err != nil { + return rspErr(err) + } + + case "data": + rsp = readPrometheusData(iter, opt) + if rsp.Error != nil { + return rsp + } + + case "error": + if promErrString, err = iter.ReadString(); err != nil { + return rspErr(err) + } + + case "errorType": + if errorType, err = iter.ReadString(); err != nil { + return rspErr(err) + } + + case "warnings": + if warnings, err = readWarnings(iter); err != nil { + return rspErr(err) + } + + case "": + if err != nil { + return rspErr(err) + } + break l1Fields + + default: + v, err := iter.Read() + if err != nil { + rsp.Error = err + return rsp + } + logf("[ROOT] TODO, support key: %s / %v\n", l1Field, v) + } + } + + if status == "error" { + return backend.DataResponse{ + Error: fmt.Errorf("%s: %s", errorType, promErrString), + } + } + + if len(warnings) > 0 { + for _, frame := range rsp.Frames { + if frame.Meta == nil { + frame.Meta = &data.FrameMeta{} + } + frame.Meta.Notices = warnings + } + } + + return rsp +} + +func readWarnings(iter *sdkjsoniter.Iterator) ([]data.Notice, error) { + warnings := []data.Notice{} + next, err := iter.WhatIsNext() + if err != nil { + return nil, err + } + + if next != sdkjsoniter.ArrayValue { + return warnings, nil + } + + for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { + if err != nil { + return nil, err + } + next, err := iter.WhatIsNext() + if err != nil { + return nil, err + } + if next == sdkjsoniter.StringValue { + s, err := iter.ReadString() + if err != nil { + return nil, err + } + notice := data.Notice{ + Severity: data.NoticeSeverityWarning, + Text: s, + } + warnings = append(warnings, notice) + } + } + + return warnings, nil +} + +func readPrometheusData(iter *sdkjsoniter.Iterator, opt Options) backend.DataResponse { + var rsp backend.DataResponse + t, err := iter.WhatIsNext() + if err != nil { + return rspErr(err) + } + + if t == sdkjsoniter.ArrayValue { + return readArrayData(iter) + } + + if t != sdkjsoniter.ObjectValue { + return backend.DataResponse{ + Error: fmt.Errorf("expected object type"), + } + } + + resultType := "" + resultTypeFound := false + var resultBytes []byte + + encodingFlags := make([]string, 0) + +l1Fields: + for l1Field, err := iter.ReadObject(); ; l1Field, err = iter.ReadObject() { + if err != nil { + return rspErr(err) + } + switch l1Field { + case "encodingFlags": + for ok, err := iter.ReadArray(); ok; ok, err = iter.ReadArray() { + if err != nil { + return rspErr(err) + } + encodingFlag, err := iter.ReadString() + if err != nil { + return rspErr(err) + } + encodingFlags = append(encodingFlags, encodingFlag) + } + if err != nil { + return rspErr(err) + } + case "resultType": + resultType, err = iter.ReadString() + if err != nil { + return rspErr(err) + } + resultTypeFound = true + + // if we have saved resultBytes we will parse them here + // we saved them because when we had them we don't know the resultType + if len(resultBytes) > 0 { + ji := sdkjsoniter.NewIterator(jsoniter.ParseBytes(sdkjsoniter.ConfigDefault, resultBytes)) + rsp = readResult(resultType, rsp, ji, opt, encodingFlags) + } + case "result": + // for some rare cases resultType is coming after the result. + // when that happens we save the bytes and parse them after reading resultType + // see: https://github.com/grafana/grafana/issues/64693 + if resultTypeFound { + rsp = readResult(resultType, rsp, iter, opt, encodingFlags) + } else { + resultBytes, _ = iter.SkipAndReturnBytes() + } + + case "stats": + v, err := iter.Read() + if err != nil { + rspErr(err) + } + if len(rsp.Frames) > 0 { + meta := rsp.Frames[0].Meta + if meta == nil { + meta = &data.FrameMeta{} + rsp.Frames[0].Meta = meta + } + meta.Custom = map[string]any{ + "stats": v, + } + } + + case "": + if err != nil { + return rspErr(err) + } + if !resultTypeFound { + return rspErr(fmt.Errorf("no resultType found")) + } + break l1Fields + + default: + v, err := iter.Read() + if err != nil { + return rspErr(err) + } + logf("[data] TODO, support key: %s / %v\n", l1Field, v) + } + } + + return rsp +} + +// will read the result object based on the resultType and return a DataResponse +func readResult(resultType string, rsp backend.DataResponse, iter *sdkjsoniter.Iterator, opt Options, encodingFlags []string) backend.DataResponse { + switch resultType { + case "matrix", "vector": + rsp = readMatrixOrVectorMulti(iter, resultType, opt) + if rsp.Error != nil { + return rsp + } + case "streams": + if slices.Contains(encodingFlags, "categorize-labels") { + rsp = readCategorizedStream(iter) + } else { + rsp = readStream(iter) + } + if rsp.Error != nil { + return rsp + } + case "string": + rsp = readString(iter) + if rsp.Error != nil { + return rsp + } + case "scalar": + rsp = readScalar(iter, opt.Dataplane) + if rsp.Error != nil { + return rsp + } + default: + if err := iter.Skip(); err != nil { + return rspErr(err) + } + rsp = backend.DataResponse{ + Error: fmt.Errorf("unknown result type: %s", resultType), + } + } + return rsp +} + +// will return strings or exemplars +func readArrayData(iter *sdkjsoniter.Iterator) backend.DataResponse { + lookup := make(map[string]*data.Field) + + var labelFrame *data.Frame + rsp := backend.DataResponse{} + + stringField := data.NewFieldFromFieldType(data.FieldTypeString, 0) + stringField.Name = "Value" + for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { + if err != nil { + rspErr(err) + } + + next, err := iter.WhatIsNext() + if err != nil { + return rspErr(err) + } + + switch next { + case sdkjsoniter.StringValue: + s, err := iter.ReadString() + if err != nil { + return rspErr(err) + } + stringField.Append(s) + + // Either label or exemplars + case sdkjsoniter.ObjectValue: + exemplar, labelPairs, err := readLabelsOrExemplars(iter) + if err != nil { + rspErr(err) + } + if exemplar != nil { + rsp.Frames = append(rsp.Frames, exemplar) + } else if labelPairs != nil { + max := 0 + for _, pair := range labelPairs { + k := pair[0] + v := pair[1] + f, ok := lookup[k] + if !ok { + f = data.NewFieldFromFieldType(data.FieldTypeString, 0) + f.Name = k + lookup[k] = f + + if labelFrame == nil { + labelFrame = data.NewFrame("") + rsp.Frames = append(rsp.Frames, labelFrame) + } + labelFrame.Fields = append(labelFrame.Fields, f) + } + f.Append(fmt.Sprintf("%v", v)) + if f.Len() > max { + max = f.Len() + } + } + + // Make sure all fields have equal length + for _, f := range lookup { + diff := max - f.Len() + if diff > 0 { + f.Extend(diff) + } + } + } + + default: + { + ext, err := iter.ReadAny() + if err != nil { + rspErr(err) + } + v := fmt.Sprintf("%v", ext) + stringField.Append(v) + } + } + } + + if stringField.Len() > 0 { + rsp.Frames = append(rsp.Frames, data.NewFrame("", stringField)) + } + + return rsp +} + +// For consistent ordering read values to an array not a map +func readLabelsAsPairs(iter *sdkjsoniter.Iterator) ([][2]string, error) { + pairs := make([][2]string, 0, 10) + for k, err := iter.ReadObject(); k != ""; k, err = iter.ReadObject() { + if err != nil { + return nil, err + } + v, err := iter.ReadString() + if err != nil { + return nil, err + } + pairs = append(pairs, [2]string{k, v}) + } + return pairs, nil +} + +func readLabelsOrExemplars(iter *sdkjsoniter.Iterator) (*data.Frame, [][2]string, error) { + pairs := make([][2]string, 0, 10) + labels := data.Labels{} + var frame *data.Frame + +l1Fields: + for l1Field, err := iter.ReadObject(); ; l1Field, err = iter.ReadObject() { + if err != nil { + return nil, nil, err + } + switch l1Field { + case "seriesLabels": + err = iter.ReadVal(&labels) + if err != nil { + return nil, nil, err + } + + case "exemplars": + lookup := make(map[string]*data.Field) + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 0) + timeField.Name = data.TimeSeriesTimeFieldName + valueField := data.NewFieldFromFieldType(data.FieldTypeFloat64, 0) + valueField.Name = data.TimeSeriesValueFieldName + valueField.Labels = labels + frame = data.NewFrame("", timeField, valueField) + frame.Meta = &data.FrameMeta{ + Custom: resultTypeToCustomMeta("exemplar"), + } + exCount := 0 + for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { + if err != nil { + return nil, nil, err + } + for l2Field, err := iter.ReadObject(); l2Field != ""; l2Field, err = iter.ReadObject() { + if err != nil { + return nil, nil, err + } + switch l2Field { + // nolint:goconst + case "value": + s, err := iter.ReadString() + if err != nil { + return nil, nil, err + } + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return nil, nil, err + } + valueField.Append(v) + + case "timestamp": + f, err := iter.ReadFloat64() + if err != nil { + return nil, nil, err + } + ts := timeFromFloat(f) + timeField.Append(ts) + + case "labels": + pairs, err := readLabelsAsPairs(iter) + if err != nil { + return nil, nil, err + } + for _, pair := range pairs { + k := pair[0] + v := pair[1] + f, ok := lookup[k] + if !ok { + f = data.NewFieldFromFieldType(data.FieldTypeString, exCount) + f.Name = k + lookup[k] = f + frame.Fields = append(frame.Fields, f) + } + f.Append(v) + } + + // Make sure all fields have equal length + for _, f := range lookup { + diff := exCount + 1 - f.Len() + if diff > 0 { + f.Extend(diff) + } + } + + default: + if err = iter.Skip(); err != nil { + return nil, nil, err + } + + frame.AppendNotices(data.Notice{ + Severity: data.NoticeSeverityError, + Text: fmt.Sprintf("unable to parse key: %s in response body", l2Field), + }) + } + } + exCount++ + } + case "": + if err != nil { + return nil, nil, err + } + break l1Fields + + default: + iV, err := iter.Read() + if err != nil { + return nil, nil, err + } + v := fmt.Sprintf("%v", iV) + pairs = append(pairs, [2]string{l1Field, v}) + } + } + + return frame, pairs, nil +} + +func readString(iter *sdkjsoniter.Iterator) backend.DataResponse { + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 0) + timeField.Name = data.TimeSeriesTimeFieldName + valueField := data.NewFieldFromFieldType(data.FieldTypeString, 0) + valueField.Name = data.TimeSeriesValueFieldName + valueField.Labels = data.Labels{} + + _, err := iter.ReadArray() + if err != nil { + return rspErr(err) + } + + var t float64 + if t, err = iter.ReadFloat64(); err != nil { + return rspErr(err) + } + + if _, err = iter.ReadArray(); err != nil { + return rspErr(err) + } + + var v string + if v, err = iter.ReadString(); err != nil { + return rspErr(err) + } + + if _, err = iter.ReadArray(); err != nil { + return rspErr(err) + } + + tt := timeFromFloat(t) + timeField.Append(tt) + valueField.Append(v) + + frame := data.NewFrame("", timeField, valueField) + frame.Meta = &data.FrameMeta{ + Type: data.FrameTypeTimeSeriesMulti, + Custom: resultTypeToCustomMeta("string"), + } + + return backend.DataResponse{ + Frames: []*data.Frame{frame}, + } +} + +func readScalar(iter *sdkjsoniter.Iterator, dataPlane bool) backend.DataResponse { + rsp := backend.DataResponse{} + + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 0) + timeField.Name = data.TimeSeriesTimeFieldName + valueField := data.NewFieldFromFieldType(data.FieldTypeFloat64, 0) + valueField.Name = data.TimeSeriesValueFieldName + valueField.Labels = data.Labels{} + + t, v, err := readTimeValuePair(iter) + if err != nil { + rsp.Error = err + return rsp + } + timeField.Append(t) + valueField.Append(v) + + frame := data.NewFrame("", timeField, valueField) + frame.Meta = &data.FrameMeta{ + Type: data.FrameTypeNumericMulti, + Custom: resultTypeToCustomMeta("scalar"), + } + + if dataPlane { + frame.Meta.TypeVersion = data.FrameTypeVersion{0, 1} + } + + return backend.DataResponse{ + Frames: []*data.Frame{frame}, + } +} + +func readMatrixOrVectorMulti(iter *sdkjsoniter.Iterator, resultType string, opt Options) backend.DataResponse { + rsp := backend.DataResponse{} + + for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { + if err != nil { + return rspErr(err) + } + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 0) + timeField.Name = data.TimeSeriesTimeFieldName + valueField := data.NewFieldFromFieldType(data.FieldTypeFloat64, 0) + valueField.Name = data.TimeSeriesValueFieldName + valueField.Labels = data.Labels{} + + var histogram *histogramInfo + + for l1Field, err := iter.ReadObject(); l1Field != ""; l1Field, err = iter.ReadObject() { + if err != nil { + return rspErr(err) + } + switch l1Field { + case "metric": + if err = iter.ReadVal(&valueField.Labels); err != nil { + return rspErr(err) + } + + case "value": + t, v, err := readTimeValuePair(iter) + if err != nil { + return rspErr(err) + } + timeField.Append(t) + valueField.Append(v) + + // nolint:goconst + case "values": + for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { + if err != nil { + return rspErr(err) + } + t, v, err := readTimeValuePair(iter) + if err != nil { + return rspErr(err) + } + timeField.Append(t) + valueField.Append(v) + } + + case "histogram": + if histogram == nil { + histogram = newHistogramInfo() + } + err = readHistogram(iter, histogram) + if err != nil { + return rspErr(err) + } + + case "histograms": + if histogram == nil { + histogram = newHistogramInfo() + } + for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { + if err != nil { + return rspErr(err) + } + if err = readHistogram(iter, histogram); err != nil { + return rspErr(err) + } + } + + default: + if err = iter.Skip(); err != nil { + return rspErr(err) + } + logf("readMatrixOrVector: %s\n", l1Field) + } + } + + if histogram != nil { + histogram.yMin.Labels = valueField.Labels + frame := data.NewFrame(valueField.Name, histogram.time, histogram.yMin, histogram.yMax, histogram.count, histogram.yLayout) + frame.Meta = &data.FrameMeta{ + Type: "heatmap-cells", + } + if frame.Name == data.TimeSeriesValueFieldName { + frame.Name = "" // only set the name if useful + } + rsp.Frames = append(rsp.Frames, frame) + } else { + frame := data.NewFrame("", timeField, valueField) + frame.Meta = &data.FrameMeta{ + Type: data.FrameTypeTimeSeriesMulti, + Custom: resultTypeToCustomMeta(resultType), + } + if opt.Dataplane && resultType == "vector" { + frame.Meta.Type = data.FrameTypeNumericMulti + } + if opt.Dataplane { + frame.Meta.TypeVersion = data.FrameTypeVersion{0, 1} + } + rsp.Frames = append(rsp.Frames, frame) + } + } + + return rsp +} + +func readTimeValuePair(iter *sdkjsoniter.Iterator) (time.Time, float64, error) { + if _, err := iter.ReadArray(); err != nil { + return time.Time{}, 0, err + } + + t, err := iter.ReadFloat64() + if err != nil { + return time.Time{}, 0, err + } + + if _, err = iter.ReadArray(); err != nil { + return time.Time{}, 0, err + } + + var v string + if v, err = iter.ReadString(); err != nil { + return time.Time{}, 0, err + } + + if _, err = iter.ReadArray(); err != nil { + return time.Time{}, 0, err + } + + tt := timeFromFloat(t) + fv, err := strconv.ParseFloat(v, 64) + return tt, fv, err +} + +type histogramInfo struct { + // XMax (time) YMin Ymax Count YLayout + time *data.Field + yMin *data.Field // will have labels? + yMax *data.Field + count *data.Field + yLayout *data.Field +} + +func newHistogramInfo() *histogramInfo { + hist := &histogramInfo{ + time: data.NewFieldFromFieldType(data.FieldTypeTime, 0), + yMin: data.NewFieldFromFieldType(data.FieldTypeFloat64, 0), + yMax: data.NewFieldFromFieldType(data.FieldTypeFloat64, 0), + count: data.NewFieldFromFieldType(data.FieldTypeFloat64, 0), + yLayout: data.NewFieldFromFieldType(data.FieldTypeInt8, 0), + } + hist.time.Name = "xMax" + hist.yMin.Name = "yMin" + hist.yMax.Name = "yMax" + hist.count.Name = "count" + hist.yLayout.Name = "yLayout" + return hist +} + +// This will read a single sparse histogram +// [ time, { count, sum, buckets: [...] }] +func readHistogram(iter *sdkjsoniter.Iterator, hist *histogramInfo) error { + // first element + if _, err := iter.ReadArray(); err != nil { + return err + } + + f, err := iter.ReadFloat64() + if err != nil { + return err + } + t := timeFromFloat(f) + + // next object element + if _, err := iter.ReadArray(); err != nil { + return err + } + + for l1Field, err := iter.ReadObject(); l1Field != ""; l1Field, err = iter.ReadObject() { + if err != nil { + return err + } + switch l1Field { + case "count": + if err = iter.Skip(); err != nil { + return err + } + case "sum": + if err = iter.Skip(); err != nil { + return err + } + + case "buckets": + for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { + if err != nil { + return err + } + hist.time.Append(t) + + if _, err := iter.ReadArray(); err != nil { + return err + } + + v, err := iter.ReadInt8() + if err != nil { + return err + } + hist.yLayout.Append(v) + + if _, err := iter.ReadArray(); err != nil { + return err + } + + if err = appendValueFromString(iter, hist.yMin); err != nil { + return err + } + + if _, err := iter.ReadArray(); err != nil { + return err + } + + err = appendValueFromString(iter, hist.yMax) + if err != nil { + return err + } + + if _, err := iter.ReadArray(); err != nil { + return err + } + + err = appendValueFromString(iter, hist.count) + if err != nil { + return err + } + + for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { + if err != nil { + return err + } + return fmt.Errorf("expected close array") + } + } + + default: + if err = iter.Skip(); err != nil { + return err + } + logf("[SKIP]readHistogram: %s\n", l1Field) + } + } + + if more, err := iter.ReadArray(); more || err != nil { + if err != nil { + return err + } + return fmt.Errorf("expected to be done") + } + + return nil +} + +func appendValueFromString(iter *sdkjsoniter.Iterator, field *data.Field) error { + var err error + var s string + if s, err = iter.ReadString(); err != nil { + return err + } + + var v float64 + if v, err = strconv.ParseFloat(s, 64); err != nil { + return err + } + + field.Append(v) + return nil +} + +func readStream(iter *sdkjsoniter.Iterator) backend.DataResponse { + rsp := backend.DataResponse{} + + labelsField := data.NewFieldFromFieldType(data.FieldTypeJSON, 0) + labelsField.Name = "__labels" // avoid automatically spreading this by labels + + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 0) + timeField.Name = "Time" + + lineField := data.NewFieldFromFieldType(data.FieldTypeString, 0) + lineField.Name = "Line" + + // Nanoseconds time field + tsField := data.NewFieldFromFieldType(data.FieldTypeString, 0) + tsField.Name = "TS" + + labels := data.Labels{} + labelJson, err := labelsToRawJson(labels) + if err != nil { + return backend.DataResponse{Error: err} + } + + for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { + if err != nil { + rspErr(err) + } + + l1Fields: + for l1Field, err := iter.ReadObject(); ; l1Field, err = iter.ReadObject() { + if err != nil { + return rspErr(err) + } + switch l1Field { + case "stream": + // we need to clear `labels`, because `iter.ReadVal` + // only appends to it + labels := data.Labels{} + if err = iter.ReadVal(&labels); err != nil { + return rspErr(err) + } + + if labelJson, err = labelsToRawJson(labels); err != nil { + return rspErr(err) + } + + case "values": + for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { + if err != nil { + rsp.Error = err + return rsp + } + + if _, err = iter.ReadArray(); err != nil { + return rspErr(err) + } + + ts, err := iter.ReadString() + if err != nil { + return rspErr(err) + } + + if _, err = iter.ReadArray(); err != nil { + return rspErr(err) + } + + line, err := iter.ReadString() + if err != nil { + return rspErr(err) + } + + if _, err = iter.ReadArray(); err != nil { + return rspErr(err) + } + + t, err := timeFromLokiString(ts) + if err != nil { + return rspErr(err) + } + + labelsField.Append(labelJson) + timeField.Append(t) + lineField.Append(line) + tsField.Append(ts) + } + case "": + if err != nil { + return rspErr(err) + } + break l1Fields + } + } + } + + frame := data.NewFrame("", labelsField, timeField, lineField, tsField) + frame.Meta = &data.FrameMeta{} + rsp.Frames = append(rsp.Frames, frame) + + return rsp +} + +func readCategorizedStream(iter *sdkjsoniter.Iterator) backend.DataResponse { + rsp := backend.DataResponse{} + + labelsField := data.NewFieldFromFieldType(data.FieldTypeJSON, 0) + labelsField.Name = "__labels" // avoid automatically spreading this by labels + + labelTypesField := data.NewFieldFromFieldType(data.FieldTypeJSON, 0) + labelTypesField.Name = "__labelTypes" // avoid automatically spreading this by labels + + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 0) + timeField.Name = "Time" + + lineField := data.NewFieldFromFieldType(data.FieldTypeString, 0) + lineField.Name = "Line" + + // Nanoseconds time field + tsField := data.NewFieldFromFieldType(data.FieldTypeString, 0) + tsField.Name = "TS" + + indexedLabels := data.Labels{} + + for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { + if err != nil { + rspErr(err) + } + + l1Fields: + for l1Field, err := iter.ReadObject(); ; l1Field, err = iter.ReadObject() { + if err != nil { + return rspErr(err) + } + switch l1Field { + case "stream": + // we need to clear `labels`, because `iter.ReadVal` + // only appends to it + indexedLabels = data.Labels{} + if err = iter.ReadVal(&indexedLabels); err != nil { + return rspErr(err) + } + + case "values": + for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { + if err != nil { + rsp.Error = err + return rsp + } + + if _, err = iter.ReadArray(); err != nil { + return rspErr(err) + } + + ts, err := iter.ReadString() + if err != nil { + return rspErr(err) + } + + if _, err = iter.ReadArray(); err != nil { + return rspErr(err) + } + + line, err := iter.ReadString() + if err != nil { + return rspErr(err) + } + + if _, err = iter.ReadArray(); err != nil { + return rspErr(err) + } + + parsedLabelsMap, structuredMetadataMap, err := readCategorizedStreamField(iter) + if err != nil { + return rspErr(err) + } + + if _, err = iter.ReadArray(); err != nil { + return rspErr(err) + } + + t, err := timeFromLokiString(ts) + if err != nil { + return rspErr(err) + } + + typeMap := data.Labels{} + clonedLabels := data.Labels{} + + for k := range indexedLabels { + typeMap[k] = "I" + clonedLabels[k] = indexedLabels[k] + } + + // merge all labels (indexed, parsed, structuredMetadata) into one dataframe field + for k, v := range structuredMetadataMap { + clonedLabels[k] = fmt.Sprintf("%s", v) + typeMap[k] = "S" + } + + for k, v := range parsedLabelsMap { + clonedLabels[k] = fmt.Sprintf("%s", v) + typeMap[k] = "P" + } + + labelJson, err := labelsToRawJson(clonedLabels) + if err != nil { + return rspErr(err) + } + + labelTypesJson, err := labelsToRawJson(typeMap) + if err != nil { + return rspErr(err) + } + + labelsField.Append(labelJson) + labelTypesField.Append(labelTypesJson) + timeField.Append(t) + lineField.Append(line) + tsField.Append(ts) + } + case "": + if err != nil { + return rspErr(err) + } + break l1Fields + } + } + } + + frame := data.NewFrame("", labelsField, timeField, lineField, tsField, labelTypesField) + frame.Meta = &data.FrameMeta{} + rsp.Frames = append(rsp.Frames, frame) + + return rsp +} + +func readCategorizedStreamField(iter *sdkjsoniter.Iterator) (map[string]interface{}, map[string]interface{}, error) { + parsedLabels := data.Labels{} + structuredMetadata := data.Labels{} + var parsedLabelsMap map[string]interface{} + var structuredMetadataMap map[string]interface{} +streamField: + for streamField, err := iter.ReadObject(); ; streamField, err = iter.ReadObject() { + if err != nil { + return nil, nil, err + } + switch streamField { + case "parsed": + if err = iter.ReadVal(&parsedLabels); err != nil { + return nil, nil, err + } + case "structuredMetadata": + if err = iter.ReadVal(&structuredMetadata); err != nil { + return nil, nil, err + } + case "": + if err != nil { + return nil, nil, err + } + if parsedLabelsMap, err = labelsToMap(parsedLabels); err != nil { + return nil, nil, err + } + if structuredMetadataMap, err = labelsToMap(structuredMetadata); err != nil { + return nil, nil, err + } + break streamField + } + } + return parsedLabelsMap, structuredMetadataMap, nil +} + +func resultTypeToCustomMeta(resultType string) map[string]string { + return map[string]string{"resultType": resultType} +} + +func timeFromFloat(fv float64) time.Time { + return time.UnixMilli(int64(fv * 1000.0)).UTC() +} + +func timeFromLokiString(str string) (time.Time, error) { + // normal time values look like: 1645030246277587968 + // and are less than: math.MaxInt65=9223372036854775807 + // This will do a fast path for any date before 2033 + s := len(str) + if s < 19 || (s == 19 && str[0] == '1') { + ns, err := strconv.ParseInt(str, 10, 64) + if err == nil { + return time.Unix(0, ns).UTC(), nil + } + } + + if s < 10 { + return time.Time{}, fmt.Errorf("unexpected time format '%v' in response. response may have been truncated", str) + } + + ss, _ := strconv.ParseInt(str[0:10], 10, 64) + ns, _ := strconv.ParseInt(str[10:], 10, 64) + return time.Unix(ss, ns).UTC(), nil +} + +func labelsToRawJson(labels data.Labels) (json.RawMessage, error) { + // data.Labels when converted to JSON keep the fields sorted + bytes, err := jsoniter.Marshal(labels) + if err != nil { + return nil, err + } + + return json.RawMessage(bytes), nil +} + +func labelsToMap(labels data.Labels) (map[string]interface{}, error) { + // data.Labels when converted to JSON keep the fields sorted + labelJson, err := labelsToRawJson(labels) + if err != nil { + return nil, err + } + + var labelMap map[string]interface{} + err = jsoniter.Unmarshal(labelJson, &labelMap) + if err != nil { + return nil, err + } + + return labelMap, nil +} diff --git a/pkg/tsdb/cloud-monitoring/httpclient.go b/pkg/tsdb/cloud-monitoring/httpclient.go index c0f262a668d73..80f9aa1528fd6 100644 --- a/pkg/tsdb/cloud-monitoring/httpclient.go +++ b/pkg/tsdb/cloud-monitoring/httpclient.go @@ -5,7 +5,6 @@ import ( "github.com/grafana/grafana-google-sdk-go/pkg/tokenprovider" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - infrahttp "github.com/grafana/grafana/pkg/infra/httpclient" ) const ( @@ -59,7 +58,7 @@ func getMiddleware(model *datasourceInfo, routePath string) (httpclient.Middlewa return tokenprovider.AuthMiddleware(provider), nil } -func newHTTPClient(model *datasourceInfo, opts httpclient.Options, clientProvider infrahttp.Provider, route string) (*http.Client, error) { +func newHTTPClient(model *datasourceInfo, opts httpclient.Options, clientProvider *httpclient.Provider, route string) (*http.Client, error) { m, err := getMiddleware(model, route) if err != nil { return nil, err diff --git a/pkg/tsdb/cloud-monitoring/promql_query.go b/pkg/tsdb/cloud-monitoring/promql_query.go index 3709623e4021b..2ee2aba24ab19 100644 --- a/pkg/tsdb/cloud-monitoring/promql_query.go +++ b/pkg/tsdb/cloud-monitoring/promql_query.go @@ -11,28 +11,27 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/tsdb/cloud-monitoring/converter" jsoniter "github.com/json-iterator/go" - - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/util/converter" ) func (promQLQ *cloudMonitoringProm) run(ctx context.Context, req *backend.QueryDataRequest, - s *Service, dsInfo datasourceInfo, tracer tracing.Tracer) (*backend.DataResponse, any, string, error) { + s *Service, dsInfo datasourceInfo, logger log.Logger) (*backend.DataResponse, any, string, error) { dr := &backend.DataResponse{} projectName, err := s.ensureProject(ctx, dsInfo, promQLQ.parameters.ProjectName) if err != nil { dr.Error = err return dr, promResponse{}, "", nil } - r, err := createRequest(ctx, promQLQ.logger, &dsInfo, path.Join("/v1/projects", projectName, "location/global/prometheus/api/v1/query_range"), nil) + r, err := createRequest(ctx, &dsInfo, path.Join("/v1/projects", projectName, "location/global/prometheus/api/v1/query_range"), nil) if err != nil { dr.Error = err return dr, promResponse{}, "", nil } - span := traceReq(ctx, tracer, req, dsInfo, r, "") + span := traceReq(ctx, req, dsInfo, r, "") defer span.End() requestBody := map[string]any{ @@ -43,16 +42,17 @@ func (promQLQ *cloudMonitoringProm) run(ctx context.Context, req *backend.QueryD } res, err := doRequestProm(r, dsInfo, requestBody) - defer func() { - if err := res.Body.Close(); err != nil { - promQLQ.logger.Error("Failed to close response body", "err", err) - } - }() if err != nil { dr.Error = err return dr, promResponse{}, "", nil } + defer func() { + if err := res.Body.Close(); err != nil { + s.logger.Error("Failed to close response body", "err", err) + } + }() + return dr, parseProm(res), r.URL.RawQuery, nil } @@ -83,7 +83,7 @@ func parseProm(res *http.Response) backend.DataResponse { // We are not parsing the response in this function. ReadPrometheusStyleResult needs an open reader and we cannot // pass an open reader to this function because lint complains as it is unsafe func (promQLQ *cloudMonitoringProm) parseResponse(queryRes *backend.DataResponse, - response any, executedQueryString string) error { + response any, executedQueryString string, logger log.Logger) error { r := response.(backend.DataResponse) // Add frame to attach metadata if len(r.Frames) == 0 { diff --git a/pkg/tsdb/cloud-monitoring/promql_query_test.go b/pkg/tsdb/cloud-monitoring/promql_query_test.go index d9994399b21c4..03caad70134ad 100644 --- a/pkg/tsdb/cloud-monitoring/promql_query_test.go +++ b/pkg/tsdb/cloud-monitoring/promql_query_test.go @@ -13,6 +13,7 @@ import ( ) func TestPromqlQuery(t *testing.T) { + service := &Service{} t.Run("parseResponse is returned", func(t *testing.T) { fileData, err := os.ReadFile("./test-data/11-prom-response.json") reader := strings.NewReader(string(fileData)) @@ -24,7 +25,7 @@ func TestPromqlQuery(t *testing.T) { dataRes := &backend.DataResponse{} query := &cloudMonitoringProm{} parsedProm := parseProm(&res) - err = query.parseResponse(dataRes, parsedProm, "") + err = query.parseResponse(dataRes, parsedProm, "", service.logger) require.NoError(t, err) frame := dataRes.Frames[0] experimental.CheckGoldenJSONFrame(t, "test-data", "parse-response-is-returned", frame, false) diff --git a/pkg/tsdb/cloud-monitoring/resource_handler.go b/pkg/tsdb/cloud-monitoring/resource_handler.go index 7197748dd630f..5347b9a9499cb 100644 --- a/pkg/tsdb/cloud-monitoring/resource_handler.go +++ b/pkg/tsdb/cloud-monitoring/resource_handler.go @@ -15,6 +15,7 @@ import ( "time" "github.com/andybalholm/brotli" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" ) @@ -199,14 +200,14 @@ func decode(encoding string, original io.ReadCloser) ([]byte, error) { } defer func() { if err := reader.(io.ReadCloser).Close(); err != nil { - slog.Warn("Failed to close reader body", "err", err) + backend.Logger.Warn("Failed to close reader body", "err", err) } }() case "deflate": reader = flate.NewReader(original) defer func() { if err := reader.(io.ReadCloser).Close(); err != nil { - slog.Warn("Failed to close reader body", "err", err) + backend.Logger.Warn("Failed to close reader body", "err", err) } }() case "br": @@ -246,7 +247,7 @@ func encode(encoding string, body []byte) ([]byte, error) { _, err = writer.Write(body) if writeCloser, ok := writer.(io.WriteCloser); ok { if err := writeCloser.Close(); err != nil { - slog.Warn("Failed to close writer body", "err", err) + backend.Logger.Warn("Failed to close writer body", "err", err) } } if err != nil { @@ -284,7 +285,7 @@ func doRequest(req *http.Request, cli *http.Client, responseFn processResponse) } defer func() { if err := res.Body.Close(); err != nil { - slog.Warn("Failed to close response body", "err", err) + backend.Logger.Warn("Failed to close response body", "err", err) } }() encoding := res.Header.Get("Content-Encoding") @@ -346,7 +347,7 @@ func buildResponse(responses []json.RawMessage, encoding string) ([]byte, error) } func (s *Service) setRequestVariables(req *http.Request, subDataSource string) (*http.Client, int, error) { - slog.Debug("Received resource call", "url", req.URL.String(), "method", req.Method) + s.logger.Debug("Received resource call", "url", req.URL.String(), "method", req.Method) newPath, err := getTarget(req.URL.Path) if err != nil { @@ -386,7 +387,7 @@ func writeResponseBytes(rw http.ResponseWriter, code int, msg []byte) { rw.WriteHeader(code) _, err := rw.Write(msg) if err != nil { - slog.Error("Unable to write HTTP response", "error", err) + backend.Logger.Error("Unable to write HTTP response", "error", err) } } diff --git a/pkg/tsdb/cloud-monitoring/resource_handler_test.go b/pkg/tsdb/cloud-monitoring/resource_handler_test.go index ec69ff18869b7..5f315ef9a5987 100644 --- a/pkg/tsdb/cloud-monitoring/resource_handler_test.go +++ b/pkg/tsdb/cloud-monitoring/resource_handler_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -115,6 +116,7 @@ func Test_setRequestVariables(t *testing.T) { }, }, }, + logger: log.DefaultLogger, } req, err := http.NewRequest(http.MethodGet, "http://foo/cloudmonitoring/v3/projects/bar/metricDescriptors", nil) if err != nil { diff --git a/pkg/tsdb/cloud-monitoring/slo_query.go b/pkg/tsdb/cloud-monitoring/slo_query.go index ee65cc64c95f9..7aa6544482d21 100644 --- a/pkg/tsdb/cloud-monitoring/slo_query.go +++ b/pkg/tsdb/cloud-monitoring/slo_query.go @@ -7,18 +7,17 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" - - "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" ) func (sloQ *cloudMonitoringSLO) run(ctx context.Context, req *backend.QueryDataRequest, - s *Service, dsInfo datasourceInfo, tracer tracing.Tracer) (*backend.DataResponse, any, string, error) { - return runTimeSeriesRequest(ctx, sloQ.logger, req, s, dsInfo, tracer, sloQ.parameters.ProjectName, sloQ.params, nil) + s *Service, dsInfo datasourceInfo, logger log.Logger) (*backend.DataResponse, any, string, error) { + return runTimeSeriesRequest(ctx, req, s, dsInfo, sloQ.parameters.ProjectName, sloQ.params, nil, logger) } func (sloQ *cloudMonitoringSLO) parseResponse(queryRes *backend.DataResponse, - response any, executedQueryString string) error { - return parseTimeSeriesResponse(queryRes, response.(cloudMonitoringResponse), executedQueryString, sloQ, sloQ.params, []string{}) + response any, executedQueryString string, logger log.Logger) error { + return parseTimeSeriesResponse(queryRes, response.(cloudMonitoringResponse), executedQueryString, sloQ, sloQ.params, []string{}, logger) } func (sloQ *cloudMonitoringSLO) buildDeepLink() string { diff --git a/pkg/tsdb/cloud-monitoring/slo_query_test.go b/pkg/tsdb/cloud-monitoring/slo_query_test.go index 1d453dc882799..0533a2201f005 100644 --- a/pkg/tsdb/cloud-monitoring/slo_query_test.go +++ b/pkg/tsdb/cloud-monitoring/slo_query_test.go @@ -12,6 +12,7 @@ import ( ) func SLOQuery(t *testing.T) { + service := &Service{} t.Run("when data from query returns slo and alias by is defined", func(t *testing.T) { data, err := loadTestFile("./test-data/6-series-response-slo.json") require.NoError(t, err) @@ -29,7 +30,7 @@ func SLOQuery(t *testing.T) { }, aliasBy: "{{project}} - {{service}} - {{slo}} - {{selector}}", } - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames require.NoError(t, err) @@ -53,7 +54,7 @@ func SLOQuery(t *testing.T) { SloId: "test-slo", }, } - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames require.NoError(t, err) @@ -68,7 +69,7 @@ func SLOQuery(t *testing.T) { res := &backend.DataResponse{} query := &cloudMonitoringSLO{params: url.Values{}, parameters: &dataquery.SLOQuery{SloId: "yes"}} - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames assert.Equal(t, len(frames[0].Fields[1].Config.Links), 0) diff --git a/pkg/tsdb/cloud-monitoring/standalone/datasource.go b/pkg/tsdb/cloud-monitoring/standalone/datasource.go new file mode 100644 index 0000000000000..169f73cd3109b --- /dev/null +++ b/pkg/tsdb/cloud-monitoring/standalone/datasource.go @@ -0,0 +1,38 @@ +package main + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + cloudmonitoring "github.com/grafana/grafana/pkg/tsdb/cloud-monitoring" +) + +var ( + _ backend.QueryDataHandler = (*Datasource)(nil) + _ backend.CheckHealthHandler = (*Datasource)(nil) + _ backend.CallResourceHandler = (*Datasource)(nil) +) + +func NewDatasource(context.Context, backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return &Datasource{ + Service: cloudmonitoring.ProvideService(httpclient.NewProvider()), + }, nil +} + +type Datasource struct { + Service *cloudmonitoring.Service +} + +func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return d.Service.QueryData(ctx, req) +} + +func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + return d.Service.CallResource(ctx, req, sender) +} + +func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + return d.Service.CheckHealth(ctx, req) +} diff --git a/pkg/tsdb/cloud-monitoring/standalone/main.go b/pkg/tsdb/cloud-monitoring/standalone/main.go new file mode 100644 index 0000000000000..c1acbcad13e7a --- /dev/null +++ b/pkg/tsdb/cloud-monitoring/standalone/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "os" + + "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +func main() { + // Start listening to requests sent from Grafana. This call is blocking so + // it won't finish until Grafana shuts down the process or the plugin choose + // to exit by itself using os.Exit. Manage automatically manages life cycle + // of datasource instances. It accepts datasource instance factory as first + // argument. This factory will be automatically called on incoming request + // from Grafana to create different instances of SampleDatasource (per datasource + // ID). When datasource configuration changed Dispose method will be called and + // new datasource instance created using NewSampleDatasource factory. + if err := datasource.Manage("stackdriver", NewDatasource, datasource.ManageOpts{}); err != nil { + log.DefaultLogger.Error(err.Error()) + os.Exit(1) + } +} diff --git a/pkg/tsdb/cloud-monitoring/time/interval.go b/pkg/tsdb/cloud-monitoring/time/interval.go new file mode 100644 index 0000000000000..c3a1c452f0418 --- /dev/null +++ b/pkg/tsdb/cloud-monitoring/time/interval.go @@ -0,0 +1,76 @@ +// Copied from https://github.com/grafana/grafana/blob/main/pkg/tsdb/intervalv2/intervalv2.go +// We're copying this to not block ourselves from decoupling until the conversation here is resolved +// https://raintank-corp.slack.com/archives/C05QFJUHUQ6/p1700064431005089 +package time + +import ( + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" +) + +var ( + DefaultRes int64 = 1500 + defaultMinInterval = time.Millisecond * 1 +) + +type Interval struct { + Text string + Value time.Duration +} + +type intervalCalculator struct { + minInterval time.Duration +} + +type Calculator interface { + Calculate(timerange backend.TimeRange, minInterval time.Duration, maxDataPoints int64) Interval + CalculateSafeInterval(timerange backend.TimeRange, resolution int64) Interval +} + +type CalculatorOptions struct { + MinInterval time.Duration +} + +func NewCalculator(opts ...CalculatorOptions) *intervalCalculator { + calc := &intervalCalculator{} + + for _, o := range opts { + if o.MinInterval == 0 { + calc.minInterval = defaultMinInterval + } else { + calc.minInterval = o.MinInterval + } + } + + return calc +} + +func (ic *intervalCalculator) Calculate(timerange backend.TimeRange, minInterval time.Duration, maxDataPoints int64) Interval { + to := timerange.To.UnixNano() + from := timerange.From.UnixNano() + resolution := maxDataPoints + if resolution == 0 { + resolution = DefaultRes + } + + calculatedInterval := time.Duration((to - from) / resolution) + + if calculatedInterval < minInterval { + return Interval{Text: gtime.FormatInterval(minInterval), Value: minInterval} + } + + rounded := gtime.RoundInterval(calculatedInterval) + + return Interval{Text: gtime.FormatInterval(rounded), Value: rounded} +} + +func (ic *intervalCalculator) CalculateSafeInterval(timerange backend.TimeRange, safeRes int64) Interval { + to := timerange.To.UnixNano() + from := timerange.From.UnixNano() + safeInterval := time.Duration((to - from) / safeRes) + + rounded := gtime.RoundInterval(safeInterval) + return Interval{Text: gtime.FormatInterval(rounded), Value: rounded} +} diff --git a/pkg/tsdb/cloud-monitoring/time_series_filter.go b/pkg/tsdb/cloud-monitoring/time_series_filter.go index dab733e04ec0b..8725c7223df98 100644 --- a/pkg/tsdb/cloud-monitoring/time_series_filter.go +++ b/pkg/tsdb/cloud-monitoring/time_series_filter.go @@ -8,20 +8,20 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/huandu/xstrings" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/tsdb/cloud-monitoring/kinds/dataquery" ) func (timeSeriesFilter *cloudMonitoringTimeSeriesList) run(ctx context.Context, req *backend.QueryDataRequest, - s *Service, dsInfo datasourceInfo, tracer tracing.Tracer) (*backend.DataResponse, any, string, error) { - return runTimeSeriesRequest(ctx, timeSeriesFilter.logger, req, s, dsInfo, tracer, timeSeriesFilter.parameters.ProjectName, timeSeriesFilter.params, nil) + s *Service, dsInfo datasourceInfo, logger log.Logger) (*backend.DataResponse, any, string, error) { + return runTimeSeriesRequest(ctx, req, s, dsInfo, timeSeriesFilter.parameters.ProjectName, timeSeriesFilter.params, nil, logger) } func parseTimeSeriesResponse(queryRes *backend.DataResponse, - response cloudMonitoringResponse, executedQueryString string, query cloudMonitoringQueryExecutor, params url.Values, groupBys []string) error { + response cloudMonitoringResponse, executedQueryString string, query cloudMonitoringQueryExecutor, params url.Values, groupBys []string, logger log.Logger) error { frames := data.Frames{} for _, series := range response.TimeSeries { @@ -56,8 +56,8 @@ func parseTimeSeriesResponse(queryRes *backend.DataResponse, } func (timeSeriesFilter *cloudMonitoringTimeSeriesList) parseResponse(queryRes *backend.DataResponse, - response any, executedQueryString string) error { - return parseTimeSeriesResponse(queryRes, response.(cloudMonitoringResponse), executedQueryString, timeSeriesFilter, timeSeriesFilter.params, timeSeriesFilter.parameters.GroupBys) + response any, executedQueryString string, logger log.Logger) error { + return parseTimeSeriesResponse(queryRes, response.(cloudMonitoringResponse), executedQueryString, timeSeriesFilter, timeSeriesFilter.params, timeSeriesFilter.parameters.GroupBys, logger) } func (timeSeriesFilter *cloudMonitoringTimeSeriesList) buildDeepLink() string { @@ -89,7 +89,7 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesList) buildDeepLink() string { timeSeriesFilter.params.Get("interval.endTime"), ) if err != nil { - slog.Error( + backend.Logger.Error( "Failed to generate deep link: unable to parse metrics explorer URL", "ProjectName", timeSeriesFilter.parameters.ProjectName, "error", err, diff --git a/pkg/tsdb/cloud-monitoring/time_series_filter_test.go b/pkg/tsdb/cloud-monitoring/time_series_filter_test.go index 27e3c9b0b727c..031d40a03471a 100644 --- a/pkg/tsdb/cloud-monitoring/time_series_filter_test.go +++ b/pkg/tsdb/cloud-monitoring/time_series_filter_test.go @@ -19,6 +19,7 @@ import ( ) func TestTimeSeriesFilter(t *testing.T) { + service := &Service{} t.Run("parses params", func(t *testing.T) { query := &cloudMonitoringTimeSeriesList{parameters: &dataquery.TimeSeriesList{}} query.setParams(time.Time{}, time.Time{}, 0, 0) @@ -63,7 +64,7 @@ func TestTimeSeriesFilter(t *testing.T) { res := &backend.DataResponse{} query := &cloudMonitoringTimeSeriesList{params: url.Values{}, parameters: &dataquery.TimeSeriesList{}} - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames require.Len(t, frames, 1) @@ -86,7 +87,7 @@ func TestTimeSeriesFilter(t *testing.T) { assert.Equal(t, 3, len(data.TimeSeries)) res := &backend.DataResponse{} query := &cloudMonitoringTimeSeriesList{params: url.Values{}, parameters: &dataquery.TimeSeriesList{}} - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) field := res.Frames[0].Fields[1] @@ -128,7 +129,7 @@ func TestTimeSeriesFilter(t *testing.T) { query := &cloudMonitoringTimeSeriesList{params: url.Values{}, parameters: &dataquery.TimeSeriesList{GroupBys: []string{ "metric.label.instance_name", "resource.label.zone", }}} - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames require.NoError(t, err) @@ -153,7 +154,7 @@ func TestTimeSeriesFilter(t *testing.T) { }, aliasBy: "{{metric.type}} - {{metric.label.instance_name}} - {{resource.label.zone}}", } - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames require.NoError(t, err) @@ -170,7 +171,7 @@ func TestTimeSeriesFilter(t *testing.T) { parameters: &dataquery.TimeSeriesList{GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}}, aliasBy: "metric {{metric.name}} service {{metric.service}}", } - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames require.NoError(t, err) @@ -192,7 +193,7 @@ func TestTimeSeriesFilter(t *testing.T) { parameters: &dataquery.TimeSeriesList{}, aliasBy: "{{bucket}}", } - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames require.NoError(t, err) @@ -237,7 +238,7 @@ func TestTimeSeriesFilter(t *testing.T) { parameters: &dataquery.TimeSeriesList{}, aliasBy: "{{bucket}}", } - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames require.NoError(t, err) @@ -275,7 +276,7 @@ func TestTimeSeriesFilter(t *testing.T) { parameters: &dataquery.TimeSeriesList{}, aliasBy: "{{bucket}}", } - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) require.NoError(t, err) assert.Equal(t, 3, len(res.Frames)) @@ -315,7 +316,7 @@ func TestTimeSeriesFilter(t *testing.T) { parameters: &dataquery.TimeSeriesList{}, aliasBy: "{{metadata.system_labels.test}}", } - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames require.NoError(t, err) @@ -333,7 +334,7 @@ func TestTimeSeriesFilter(t *testing.T) { parameters: &dataquery.TimeSeriesList{}, aliasBy: "{{metadata.system_labels.test2}}", } - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames require.NoError(t, err) @@ -349,7 +350,7 @@ func TestTimeSeriesFilter(t *testing.T) { assert.Equal(t, 1, len(data.TimeSeries)) res := &backend.DataResponse{} query := &cloudMonitoringTimeSeriesList{params: url.Values{}, parameters: &dataquery.TimeSeriesList{}} - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames require.NoError(t, err) @@ -362,7 +363,7 @@ func TestTimeSeriesFilter(t *testing.T) { assert.Equal(t, 3, len(data.TimeSeries)) res := &backend.DataResponse{} query := &cloudMonitoringTimeSeriesList{params: url.Values{}, parameters: &dataquery.TimeSeriesList{}} - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames require.NoError(t, err) @@ -391,7 +392,7 @@ func TestTimeSeriesFilter(t *testing.T) { To: fromStart.Add(34 * time.Minute), }, } - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames assert.Equal(t, "test-proj - asia-northeast1-c - 6724404429462225363 - 200", frames[0].Fields[1].Name) @@ -412,7 +413,7 @@ func TestTimeSeriesFilter(t *testing.T) { GraphPeriod: strPtr("60s"), }, } - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) assert.Equal(t, "value_utilization_sum", res.Frames[0].Fields[1].Name) }) @@ -423,7 +424,7 @@ func TestTimeSeriesFilter(t *testing.T) { assert.Equal(t, 3, len(data.TimeSeries)) res := &backend.DataResponse{} query := &cloudMonitoringTimeSeriesList{params: url.Values{}, parameters: &dataquery.TimeSeriesList{}} - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames custom, ok := frames[0].Meta.Custom.(map[string]any) @@ -441,7 +442,7 @@ func TestTimeSeriesFilter(t *testing.T) { query := &cloudMonitoringTimeSeriesList{params: url.Values{ "aggregation.alignmentPeriod": []string{"+60s"}, }, parameters: &dataquery.TimeSeriesList{}} - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames timeField := frames[0].Fields[0] @@ -455,7 +456,7 @@ func TestTimeSeriesFilter(t *testing.T) { assert.Equal(t, 1, len(data.TimeSeries)) res := &backend.DataResponse{} - require.NoError(t, (&cloudMonitoringTimeSeriesList{parameters: &dataquery.TimeSeriesList{GroupBys: []string{"test_group_by"}}}).parseResponse(res, data, "test_query")) + require.NoError(t, (&cloudMonitoringTimeSeriesList{parameters: &dataquery.TimeSeriesList{GroupBys: []string{"test_group_by"}}}).parseResponse(res, data, "test_query", service.logger)) require.NotNil(t, res.Frames[0].Meta) assert.Equal(t, sdkdata.FrameMeta{ @@ -478,7 +479,7 @@ func TestTimeSeriesFilter(t *testing.T) { assert.Equal(t, 1, len(data.TimeSeries)) res := &backend.DataResponse{} - require.NoError(t, (&cloudMonitoringTimeSeriesList{parameters: &dataquery.TimeSeriesList{GroupBys: []string{"test_group_by"}}}).parseResponse(res, data, "test_query")) + require.NoError(t, (&cloudMonitoringTimeSeriesList{parameters: &dataquery.TimeSeriesList{GroupBys: []string{"test_group_by"}}}).parseResponse(res, data, "test_query", service.logger)) require.NotNil(t, res.Frames[0].Meta) assert.Equal(t, sdkdata.FrameMeta{ @@ -501,7 +502,7 @@ func TestTimeSeriesFilter(t *testing.T) { assert.Equal(t, 1, len(data.TimeSeries)) res := &backend.DataResponse{} - require.NoError(t, (&cloudMonitoringTimeSeriesList{parameters: &dataquery.TimeSeriesList{GroupBys: []string{"test_group_by"}}}).parseResponse(res, data, "test_query")) + require.NoError(t, (&cloudMonitoringTimeSeriesList{parameters: &dataquery.TimeSeriesList{GroupBys: []string{"test_group_by"}}}).parseResponse(res, data, "test_query", service.logger)) require.NotNil(t, res.Frames[0].Meta) assert.Equal(t, sdkdata.FrameMeta{ diff --git a/pkg/tsdb/cloud-monitoring/time_series_query.go b/pkg/tsdb/cloud-monitoring/time_series_query.go index 158615f6626df..4a7f29f72069c 100644 --- a/pkg/tsdb/cloud-monitoring/time_series_query.go +++ b/pkg/tsdb/cloud-monitoring/time_series_query.go @@ -7,10 +7,9 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/data" - - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" + gcmTime "github.com/grafana/grafana/pkg/tsdb/cloud-monitoring/time" ) func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) appendGraphPeriod(req *backend.QueryDataRequest) string { @@ -18,7 +17,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) appendGraphPeriod(req *ba // If not set, the default behavior is to set an automatic value if timeSeriesQuery.parameters.GraphPeriod == nil || *timeSeriesQuery.parameters.GraphPeriod != "disabled" { if timeSeriesQuery.parameters.GraphPeriod == nil || *timeSeriesQuery.parameters.GraphPeriod == "auto" || *timeSeriesQuery.parameters.GraphPeriod == "" { - intervalCalculator := intervalv2.NewCalculator(intervalv2.CalculatorOptions{}) + intervalCalculator := gcmTime.NewCalculator(gcmTime.CalculatorOptions{}) interval := intervalCalculator.Calculate(req.Queries[0].TimeRange, time.Duration(timeSeriesQuery.IntervalMS/1000)*time.Second, req.Queries[0].MaxDataPoints) timeSeriesQuery.parameters.GraphPeriod = &interval.Text } @@ -28,7 +27,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) appendGraphPeriod(req *ba } func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) run(ctx context.Context, req *backend.QueryDataRequest, - s *Service, dsInfo datasourceInfo, tracer tracing.Tracer) (*backend.DataResponse, any, string, error) { + s *Service, dsInfo datasourceInfo, logger log.Logger) (*backend.DataResponse, any, string, error) { timeSeriesQuery.parameters.Query += timeSeriesQuery.appendGraphPeriod(req) from := req.Queries[0].TimeRange.From to := req.Queries[0].TimeRange.To @@ -37,11 +36,11 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) run(ctx context.Context, requestBody := map[string]any{ "query": timeSeriesQuery.parameters.Query, } - return runTimeSeriesRequest(ctx, timeSeriesQuery.logger, req, s, dsInfo, tracer, timeSeriesQuery.parameters.ProjectName, nil, requestBody) + return runTimeSeriesRequest(ctx, req, s, dsInfo, timeSeriesQuery.parameters.ProjectName, nil, requestBody, logger) } func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *backend.DataResponse, - res any, executedQueryString string) error { + res any, executedQueryString string, logger log.Logger) error { response := res.(cloudMonitoringResponse) frames := data.Frames{} @@ -103,7 +102,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) buildDeepLink() string { timeSeriesQuery.timeRange.To.Format(time.RFC3339Nano), ) if err != nil { - slog.Error( + backend.Logger.Error( "Failed to generate deep link: unable to parse metrics explorer URL", "ProjectName", timeSeriesQuery.parameters.Query, "error", err, diff --git a/pkg/tsdb/cloud-monitoring/time_series_query_test.go b/pkg/tsdb/cloud-monitoring/time_series_query_test.go index 73dcb8db8079f..8ddfc183366d3 100644 --- a/pkg/tsdb/cloud-monitoring/time_series_query_test.go +++ b/pkg/tsdb/cloud-monitoring/time_series_query_test.go @@ -13,6 +13,7 @@ import ( ) func TestTimeSeriesQuery(t *testing.T) { + service := &Service{} t.Run("multiple point descriptor is returned", func(t *testing.T) { data, err := loadTestFile("./test-data/8-series-response-mql-multiple-point-descriptors.json") require.NoError(t, err) @@ -33,7 +34,7 @@ func TestTimeSeriesQuery(t *testing.T) { To: fromStart.Add(34 * time.Minute), }, } - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) frames := res.Frames assert.Equal(t, "grafana-prod asia-northeast1-c 6724404429462225363 200", frames[0].Fields[1].Name) assert.Equal(t, 843302441.9, frames[0].Fields[1].At(0)) @@ -52,7 +53,7 @@ func TestTimeSeriesQuery(t *testing.T) { To: fromStart.Add(34 * time.Minute), }, } - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) frames := res.Frames assert.Equal(t, "test-proj - asia-northeast1-c - 6724404429462225363 - 200", frames[0].Fields[1].Name) }) @@ -79,7 +80,7 @@ func TestTimeSeriesQuery(t *testing.T) { To: fromStart.Add(34 * time.Minute), }, } - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames assert.Equal(t, 1, len(res.Frames)) @@ -103,7 +104,7 @@ func TestTimeSeriesQuery(t *testing.T) { To: fromStart.Add(34 * time.Minute), }, } - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames custom, ok := frames[0].Meta.Custom.(map[string]any) @@ -131,7 +132,7 @@ func TestTimeSeriesQuery(t *testing.T) { To: fromStart.Add(34 * time.Minute), }, } - err = query.parseResponse(res, data, "") + err = query.parseResponse(res, data, "", service.logger) require.NoError(t, err) frames := res.Frames timeField := frames[0].Fields[0] diff --git a/pkg/tsdb/cloud-monitoring/types.go b/pkg/tsdb/cloud-monitoring/types.go index f00195ee86001..ce4136247e751 100644 --- a/pkg/tsdb/cloud-monitoring/types.go +++ b/pkg/tsdb/cloud-monitoring/types.go @@ -9,19 +9,18 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/huandu/xstrings" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/tsdb/cloud-monitoring/kinds/dataquery" ) type ( cloudMonitoringQueryExecutor interface { - run(ctx context.Context, req *backend.QueryDataRequest, s *Service, dsInfo datasourceInfo, tracer tracing.Tracer) ( + run(ctx context.Context, req *backend.QueryDataRequest, s *Service, dsInfo datasourceInfo, logger log.Logger) ( *backend.DataResponse, any, string, error) - parseResponse(dr *backend.DataResponse, data any, executedQueryString string) error + parseResponse(dr *backend.DataResponse, data any, executedQueryString string, logger log.Logger) error buildDeepLink() string getRefID() string getAliasBy() string @@ -41,7 +40,6 @@ type ( cloudMonitoringTimeSeriesList struct { refID string aliasBy string - logger log.Logger parameters *dataquery.TimeSeriesList // Processed properties params url.Values @@ -50,7 +48,6 @@ type ( cloudMonitoringSLO struct { refID string aliasBy string - logger log.Logger parameters *dataquery.SLOQuery // Processed properties params url.Values @@ -59,8 +56,8 @@ type ( // cloudMonitoringProm is used to build a promQL queries cloudMonitoringProm struct { refID string - aliasBy string logger log.Logger + aliasBy string parameters *dataquery.PromQLQuery timeRange backend.TimeRange IntervalMS int64 @@ -69,8 +66,8 @@ type ( // cloudMonitoringTimeSeriesQuery is used to build MQL queries cloudMonitoringTimeSeriesQuery struct { refID string - aliasBy string logger log.Logger + aliasBy string parameters *dataquery.TimeSeriesQuery // Processed properties timeRange backend.TimeRange diff --git a/pkg/tsdb/cloud-monitoring/utils.go b/pkg/tsdb/cloud-monitoring/utils.go index 946a0f43b557f..3b719ff2c7e35 100644 --- a/pkg/tsdb/cloud-monitoring/utils.go +++ b/pkg/tsdb/cloud-monitoring/utils.go @@ -14,18 +14,17 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" "github.com/grafana/grafana-plugin-sdk-go/data" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" ) func addInterval(period string, field *data.Field) error { period = strings.TrimPrefix(period, "+") - p, err := intervalv2.ParseIntervalStringToTimeDuration(period) + p, err := gtime.ParseIntervalStringToTimeDuration(period) if err != nil { return err } @@ -48,7 +47,7 @@ func toString(v any) string { return v.(string) } -func createRequest(ctx context.Context, logger log.Logger, dsInfo *datasourceInfo, proxyPass string, body io.Reader) (*http.Request, error) { +func createRequest(ctx context.Context, dsInfo *datasourceInfo, proxyPass string, body io.Reader) (*http.Request, error) { u, err := url.Parse(dsInfo.url) if err != nil { return nil, err @@ -61,7 +60,7 @@ func createRequest(ctx context.Context, logger log.Logger, dsInfo *datasourceInf } req, err := http.NewRequestWithContext(ctx, method, dsInfo.services[cloudMonitor].url, body) if err != nil { - logger.Error("Failed to create request", "error", err) + backend.Logger.Error("Failed to create request", "error", err) return nil, fmt.Errorf("failed to create request: %w", err) } @@ -71,7 +70,7 @@ func createRequest(ctx context.Context, logger log.Logger, dsInfo *datasourceInf return req, nil } -func doRequestPage(ctx context.Context, logger log.Logger, r *http.Request, dsInfo datasourceInfo, params url.Values, body map[string]any) (cloudMonitoringResponse, error) { +func doRequestPage(ctx context.Context, r *http.Request, dsInfo datasourceInfo, params url.Values, body map[string]any, logger log.Logger) (cloudMonitoringResponse, error) { if params != nil { r.URL.RawQuery = params.Encode() } @@ -90,11 +89,11 @@ func doRequestPage(ctx context.Context, logger log.Logger, r *http.Request, dsIn defer func() { if err = res.Body.Close(); err != nil { - logger.Warn("Failed to close response body", "error", err) + backend.Logger.Warn("Failed to close response body", "error", err) } }() - dnext, err := unmarshalResponse(logger, res) + dnext, err := unmarshalResponse(res, logger) if err != nil { return cloudMonitoringResponse{}, err } @@ -102,8 +101,8 @@ func doRequestPage(ctx context.Context, logger log.Logger, r *http.Request, dsIn return dnext, nil } -func doRequestWithPagination(ctx context.Context, logger log.Logger, r *http.Request, dsInfo datasourceInfo, params url.Values, body map[string]any) (cloudMonitoringResponse, error) { - d, err := doRequestPage(ctx, logger, r, dsInfo, params, body) +func doRequestWithPagination(ctx context.Context, r *http.Request, dsInfo datasourceInfo, params url.Values, body map[string]any, logger log.Logger) (cloudMonitoringResponse, error) { + d, err := doRequestPage(ctx, r, dsInfo, params, body, logger) if err != nil { return cloudMonitoringResponse{}, err } @@ -114,7 +113,7 @@ func doRequestWithPagination(ctx context.Context, logger log.Logger, r *http.Req if body != nil { body["pageToken"] = d.NextPageToken } - nextPage, err := doRequestPage(ctx, logger, r, dsInfo, params, body) + nextPage, err := doRequestPage(ctx, r, dsInfo, params, body, logger) if err != nil { return cloudMonitoringResponse{}, err } @@ -125,20 +124,20 @@ func doRequestWithPagination(ctx context.Context, logger log.Logger, r *http.Req return d, nil } -func traceReq(ctx context.Context, tracer tracing.Tracer, req *backend.QueryDataRequest, dsInfo datasourceInfo, r *http.Request, target string) trace.Span { - ctx, span := tracer.Start(ctx, "cloudMonitoring query", trace.WithAttributes( +func traceReq(ctx context.Context, req *backend.QueryDataRequest, dsInfo datasourceInfo, r *http.Request, target string) trace.Span { + _, span := tracing.DefaultTracer().Start(ctx, "cloudMonitoring query", trace.WithAttributes( attribute.String("target", target), attribute.String("from", req.Queries[0].TimeRange.From.String()), attribute.String("until", req.Queries[0].TimeRange.To.String()), attribute.Int64("datasource_id", dsInfo.id), attribute.Int64("org_id", req.PluginContext.OrgID), )) - tracer.Inject(ctx, r.Header, span) + defer span.End() return span } -func runTimeSeriesRequest(ctx context.Context, logger log.Logger, req *backend.QueryDataRequest, - s *Service, dsInfo datasourceInfo, tracer tracing.Tracer, projectName string, params url.Values, body map[string]any) (*backend.DataResponse, cloudMonitoringResponse, string, error) { +func runTimeSeriesRequest(ctx context.Context, req *backend.QueryDataRequest, + s *Service, dsInfo datasourceInfo, projectName string, params url.Values, body map[string]any, logger log.Logger) (*backend.DataResponse, cloudMonitoringResponse, string, error) { dr := &backend.DataResponse{} projectName, err := s.ensureProject(ctx, dsInfo, projectName) if err != nil { @@ -149,16 +148,16 @@ func runTimeSeriesRequest(ctx context.Context, logger log.Logger, req *backend.Q if body != nil { timeSeriesMethod += ":query" } - r, err := createRequest(ctx, logger, &dsInfo, path.Join("/v3/projects", projectName, timeSeriesMethod), nil) + r, err := createRequest(ctx, &dsInfo, path.Join("/v3/projects", projectName, timeSeriesMethod), nil) if err != nil { dr.Error = err return dr, cloudMonitoringResponse{}, "", nil } - span := traceReq(ctx, tracer, req, dsInfo, r, params.Encode()) + span := traceReq(ctx, req, dsInfo, r, params.Encode()) defer span.End() - d, err := doRequestWithPagination(ctx, logger, r, dsInfo, params, body) + d, err := doRequestWithPagination(ctx, r, dsInfo, params, body, logger) if err != nil { dr.Error = err return dr, cloudMonitoringResponse{}, "", nil diff --git a/pkg/tsdb/cloudwatch/annotation_query_test.go b/pkg/tsdb/cloudwatch/annotation_query_test.go index 9d2ff5c0fc7f6..6779c82a89681 100644 --- a/pkg/tsdb/cloudwatch/annotation_query_test.go +++ b/pkg/tsdb/cloudwatch/annotation_query_test.go @@ -10,10 +10,7 @@ import ( "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/aws/aws-sdk-go/service/cloudwatch/cloudwatchiface" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" - "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,11 +28,9 @@ func TestQuery_AnnotationQuery(t *testing.T) { t.Run("DescribeAlarmsForMetric is called with minimum parameters", func(t *testing.T) { client = fakeCWAnnotationsClient{describeAlarmsForMetricOutput: &cloudwatch.DescribeAlarmsForMetricOutput{}} - im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil - }) + im := defaultTestInstanceManager() - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, @@ -65,11 +60,9 @@ func TestQuery_AnnotationQuery(t *testing.T) { t.Run("DescribeAlarms is called when prefixMatching is true", func(t *testing.T) { client = fakeCWAnnotationsClient{describeAlarmsOutput: &cloudwatch.DescribeAlarmsOutput{}} - im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil - }) + im := defaultTestInstanceManager() - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, diff --git a/pkg/tsdb/cloudwatch/clients/metrics.go b/pkg/tsdb/cloudwatch/clients/metrics.go index 05456f3cf8a6c..b3648492a59f6 100644 --- a/pkg/tsdb/cloudwatch/clients/metrics.go +++ b/pkg/tsdb/cloudwatch/clients/metrics.go @@ -5,20 +5,19 @@ import ( "github.com/aws/aws-sdk-go/aws/awsutil" "github.com/aws/aws-sdk-go/service/cloudwatch" - "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" ) // this client wraps the CloudWatch API and handles pagination and the composition of the MetricResponse DTO type metricsClient struct { models.CloudWatchMetricsAPIProvider - config *setting.Cfg + listMetricsPageLimit int } -func NewMetricsClient(api models.CloudWatchMetricsAPIProvider, config *setting.Cfg) *metricsClient { - return &metricsClient{CloudWatchMetricsAPIProvider: api, config: config} +func NewMetricsClient(api models.CloudWatchMetricsAPIProvider, pageLimit int) *metricsClient { + return &metricsClient{CloudWatchMetricsAPIProvider: api, listMetricsPageLimit: pageLimit} } func (l *metricsClient) ListMetricsWithPageLimit(ctx context.Context, params *cloudwatch.ListMetricsInput) ([]resources.MetricResponse, error) { @@ -26,7 +25,7 @@ func (l *metricsClient) ListMetricsWithPageLimit(ctx context.Context, params *cl pageNum := 0 err := l.ListMetricsPagesWithContext(ctx, params, func(page *cloudwatch.ListMetricsOutput, lastPage bool) bool { pageNum++ - metrics.MAwsCloudWatchListMetrics.Inc() + utils.QueriesTotalCounter.WithLabelValues(utils.ListMetricsLabel).Inc() metrics, err := awsutil.ValuesAtPath(page, "Metrics") if err == nil { for idx, metric := range metrics { @@ -37,7 +36,7 @@ func (l *metricsClient) ListMetricsWithPageLimit(ctx context.Context, params *cl cloudWatchMetrics = append(cloudWatchMetrics, metric) } } - return !lastPage && pageNum < l.config.AWSListMetricsPageLimit + return !lastPage && pageNum < l.listMetricsPageLimit }) return cloudWatchMetrics, err diff --git a/pkg/tsdb/cloudwatch/clients/metrics_test.go b/pkg/tsdb/cloudwatch/clients/metrics_test.go index 4dd455b65b7a4..644aecf85e4dd 100644 --- a/pkg/tsdb/cloudwatch/clients/metrics_test.go +++ b/pkg/tsdb/cloudwatch/clients/metrics_test.go @@ -6,7 +6,6 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/cloudwatch" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/stretchr/testify/assert" @@ -31,7 +30,7 @@ func TestMetricsClient(t *testing.T) { t.Run("List Metrics and page limit is reached", func(t *testing.T) { pageLimit := 3 fakeApi := &mocks.FakeMetricsAPI{Metrics: metrics, MetricsPerPage: 2} - client := NewMetricsClient(fakeApi, &setting.Cfg{AWSListMetricsPageLimit: pageLimit}) + client := NewMetricsClient(fakeApi, pageLimit) response, err := client.ListMetricsWithPageLimit(ctx, &cloudwatch.ListMetricsInput{}) require.NoError(t, err) @@ -42,7 +41,7 @@ func TestMetricsClient(t *testing.T) { t.Run("List Metrics and page limit is not reached", func(t *testing.T) { pageLimit := 2 fakeApi := &mocks.FakeMetricsAPI{Metrics: metrics} - client := NewMetricsClient(fakeApi, &setting.Cfg{AWSListMetricsPageLimit: pageLimit}) + client := NewMetricsClient(fakeApi, pageLimit) response, err := client.ListMetricsWithPageLimit(ctx, &cloudwatch.ListMetricsInput{}) require.NoError(t, err) @@ -56,7 +55,7 @@ func TestMetricsClient(t *testing.T) { {MetricName: aws.String("Test_MetricName2")}, {MetricName: aws.String("Test_MetricName3")}, }, OwningAccounts: []*string{aws.String("1234567890"), aws.String("1234567890"), aws.String("1234567895")}} - client := NewMetricsClient(fakeApi, &setting.Cfg{AWSListMetricsPageLimit: 100}) + client := NewMetricsClient(fakeApi, 100) response, err := client.ListMetricsWithPageLimit(ctx, &cloudwatch.ListMetricsInput{IncludeLinkedAccounts: aws.Bool(true)}) require.NoError(t, err) @@ -70,7 +69,7 @@ func TestMetricsClient(t *testing.T) { t.Run("Should not return account id in case IncludeLinkedAccounts is set to false", func(t *testing.T) { fakeApi := &mocks.FakeMetricsAPI{Metrics: []*cloudwatch.Metric{{MetricName: aws.String("Test_MetricName1")}}, OwningAccounts: []*string{aws.String("1234567890")}} - client := NewMetricsClient(fakeApi, &setting.Cfg{AWSListMetricsPageLimit: 100}) + client := NewMetricsClient(fakeApi, 100) response, err := client.ListMetricsWithPageLimit(ctx, &cloudwatch.ListMetricsInput{IncludeLinkedAccounts: aws.Bool(false)}) require.NoError(t, err) diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index c51e54edb4ec3..16d89678b012f 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -3,9 +3,9 @@ package cloudwatch import ( "context" "encoding/json" + "errors" "fmt" "net/http" - "sync" "time" "github.com/aws/aws-sdk-go/aws" @@ -18,21 +18,26 @@ import ( "github.com/grafana/grafana-aws-sdk/pkg/awsds" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" - "github.com/grafana/grafana/pkg/infra/httpclient" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/featuremgmt" - ngalertmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/query" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/clients" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/kinds/dataquery" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/patrickmn/go-cache" ) -const tagValueCacheExpiration = time.Hour * 24 +const ( + tagValueCacheExpiration = time.Hour * 24 + + // headerFromExpression is used by datasources to identify expression queries + headerFromExpression = "X-Grafana-From-Expr" + + // headerFromAlert is used by datasources to identify alert queries + headerFromAlert = "FromAlert" +) type DataQueryJson struct { dataquery.CloudWatchAnnotationQuery @@ -42,7 +47,9 @@ type DataQueryJson struct { type DataSource struct { Settings models.CloudWatchSettings HTTPClient *http.Client + sessions SessionCache tagValueCache *cache.Cache + ProxyOpts *proxy.Options } const ( @@ -54,21 +61,21 @@ const ( timeSeriesQuery = "timeSeriesQuery" ) -var logger = log.New("tsdb.cloudwatch") - -func ProvideService(cfg *setting.Cfg, httpClientProvider httpclient.Provider, features featuremgmt.FeatureToggles) *CloudWatchService { +func ProvideService(httpClientProvider *httpclient.Provider) *CloudWatchService { + logger := backend.NewLoggerWith("logger", "tsdb.cloudwatch") logger.Debug("Initializing") - executor := newExecutor(datasource.NewInstanceManager(NewInstanceSettings(httpClientProvider)), cfg, awsds.NewSessionCache(), features) + executor := newExecutor( + datasource.NewInstanceManager(NewInstanceSettings(httpClientProvider)), + logger, + ) return &CloudWatchService{ - Cfg: cfg, Executor: executor, } } type CloudWatchService struct { - Cfg *setting.Cfg Executor *cloudWatchExecutor } @@ -76,21 +83,19 @@ type SessionCache interface { GetSession(c awsds.SessionConfig) (*session.Session, error) } -func newExecutor(im instancemgmt.InstanceManager, cfg *setting.Cfg, sessions SessionCache, features featuremgmt.FeatureToggles) *cloudWatchExecutor { +func newExecutor(im instancemgmt.InstanceManager, logger log.Logger) *cloudWatchExecutor { e := &cloudWatchExecutor{ - im: im, - cfg: cfg, - sessions: sessions, - features: features, + im: im, + logger: logger, } e.resourceHandler = httpadapter.New(e.newResourceMux()) return e } -func NewInstanceSettings(httpClientProvider httpclient.Provider) datasource.InstanceFactoryFunc { +func NewInstanceSettings(httpClientProvider *httpclient.Provider) datasource.InstanceFactoryFunc { return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - instanceSettings, err := models.LoadCloudWatchSettings(settings) + instanceSettings, err := models.LoadCloudWatchSettings(ctx, settings) if err != nil { return nil, fmt.Errorf("error reading settings: %w", err) } @@ -109,21 +114,36 @@ func NewInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst Settings: instanceSettings, HTTPClient: httpClient, tagValueCache: cache.New(tagValueCacheExpiration, tagValueCacheExpiration*5), + sessions: awsds.NewSessionCache(), + // this is used to build a custom dialer when secure socks proxy is enabled + ProxyOpts: opts.ProxyOptions, }, nil } } -// cloudWatchExecutor executes CloudWatch requests. +// cloudWatchExecutor executes CloudWatch requests type cloudWatchExecutor struct { - im instancemgmt.InstanceManager - cfg *setting.Cfg - sessions SessionCache - features featuremgmt.FeatureToggles - regionCache sync.Map + im instancemgmt.InstanceManager + logger log.Logger resourceHandler backend.CallResourceHandler } +// instrumentContext adds plugin key-values to the context; later, logger.FromContext(ctx) will provide a logger +// that adds these values to its output. +// TODO: move this into the sdk (see https://github.com/grafana/grafana/issues/82033) +func instrumentContext(ctx context.Context, endpoint string, pCtx backend.PluginContext) context.Context { + p := []any{"endpoint", endpoint, "pluginId", pCtx.PluginID} + if pCtx.DataSourceInstanceSettings != nil { + p = append(p, "dsName", pCtx.DataSourceInstanceSettings.Name) + p = append(p, "dsUID", pCtx.DataSourceInstanceSettings.UID) + } + if pCtx.User != nil { + p = append(p, "uname", pCtx.User.Login) + } + return log.WithContextualAttributes(ctx, p) +} + func (e *cloudWatchExecutor) getRequestContext(ctx context.Context, pluginCtx backend.PluginContext, region string) (models.RequestContext, error) { r := region instance, err := e.getInstance(ctx, pluginCtx) @@ -139,28 +159,45 @@ func (e *cloudWatchExecutor) getRequestContext(ctx context.Context, pluginCtx ba return models.RequestContext{}, err } - sess, err := e.newSession(ctx, pluginCtx, r) + sess, err := instance.newSession(r) if err != nil { return models.RequestContext{}, err } return models.RequestContext{ OAMAPIProvider: NewOAMAPI(sess), - MetricsClientProvider: clients.NewMetricsClient(NewMetricsAPI(sess), e.cfg), + MetricsClientProvider: clients.NewMetricsClient(NewMetricsAPI(sess), instance.Settings.GrafanaSettings.ListMetricsPageLimit), LogsAPIProvider: NewLogsAPI(sess), EC2APIProvider: ec2Client, Settings: instance.Settings, - Features: e.features, - Logger: logger, + Logger: e.logger.FromContext(ctx), + }, nil +} + +// getRequestContextOnlySettings is useful for resource endpoints that are called before auth has been configured such as external-id that need access to settings but nothing else +func (e *cloudWatchExecutor) getRequestContextOnlySettings(ctx context.Context, pluginCtx backend.PluginContext, _ string) (models.RequestContext, error) { + instance, err := e.getInstance(ctx, pluginCtx) + if err != nil { + return models.RequestContext{}, err + } + + return models.RequestContext{ + OAMAPIProvider: nil, + MetricsClientProvider: nil, + LogsAPIProvider: nil, + EC2APIProvider: nil, + Settings: instance.Settings, + Logger: e.logger.FromContext(ctx), }, nil } func (e *cloudWatchExecutor) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + ctx = instrumentContext(ctx, "callResource", req.PluginContext) return e.resourceHandler.CallResource(ctx, req, sender) } func (e *cloudWatchExecutor) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - logger := logger.FromContext(ctx) + ctx = instrumentContext(ctx, "queryData", req.PluginContext) q := req.Queries[0] var model DataQueryJson err := json.Unmarshal(q.JSON, &model) @@ -168,8 +205,8 @@ func (e *cloudWatchExecutor) QueryData(ctx context.Context, req *backend.QueryDa return nil, err } - _, fromAlert := req.Headers[ngalertmodels.FromAlertHeaderName] - fromExpression := req.GetHTTPHeader(query.HeaderFromExpression) != "" + _, fromAlert := req.Headers[headerFromAlert] + fromExpression := req.GetHTTPHeader(headerFromExpression) != "" // Public dashboard queries execute like alert queries, i.e. they execute on the backend, therefore, we need to handle them synchronously. // Since `model.Type` is set during execution on the frontend by the query runner and isn't saved with the query, we are checking here is // missing the `model.Type` property and if it is a log query in order to determine if it is a public dashboard query. @@ -184,17 +221,18 @@ func (e *cloudWatchExecutor) QueryData(ctx context.Context, req *backend.QueryDa case annotationQuery: result, err = e.executeAnnotationQuery(ctx, req.PluginContext, model, q) case logAction: - result, err = e.executeLogActions(ctx, logger, req) + result, err = e.executeLogActions(ctx, req) case timeSeriesQuery: fallthrough default: - result, err = e.executeTimeSeriesQuery(ctx, logger, req) + result, err = e.executeTimeSeriesQuery(ctx, req) } return result, err } func (e *cloudWatchExecutor) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + ctx = instrumentContext(ctx, "checkHealth", req.PluginContext) status := backend.HealthStatusOk metricsTest := "Successfully queried the CloudWatch metrics API." logsTest := "Successfully queried the CloudWatch logs API." @@ -225,17 +263,23 @@ func (e *cloudWatchExecutor) checkHealthMetrics(ctx context.Context, pluginCtx b MetricName: &metric, } - session, err := e.newSession(ctx, pluginCtx, defaultRegion) + instance, err := e.getInstance(ctx, pluginCtx) + if err != nil { + return err + } + + session, err := instance.newSession(defaultRegion) if err != nil { return err } - metricClient := clients.NewMetricsClient(NewMetricsAPI(session), e.cfg) + + metricClient := clients.NewMetricsClient(NewMetricsAPI(session), instance.Settings.GrafanaSettings.ListMetricsPageLimit) _, err = metricClient.ListMetricsWithPageLimit(ctx, params) return err } func (e *cloudWatchExecutor) checkHealthLogs(ctx context.Context, pluginCtx backend.PluginContext) error { - session, err := e.newSession(ctx, pluginCtx, defaultRegion) + session, err := e.newSessionFromContext(ctx, pluginCtx, defaultRegion) if err != nil { return err } @@ -244,48 +288,65 @@ func (e *cloudWatchExecutor) checkHealthLogs(ctx context.Context, pluginCtx back return err } -func (e *cloudWatchExecutor) newSession(ctx context.Context, pluginCtx backend.PluginContext, region string) (*session.Session, error) { - instance, err := e.getInstance(ctx, pluginCtx) - if err != nil { - return nil, err - } - +func (ds *DataSource) newSession(region string) (*session.Session, error) { if region == defaultRegion { - if len(instance.Settings.Region) == 0 { + if len(ds.Settings.Region) == 0 { return nil, models.ErrMissingRegion } - region = instance.Settings.Region + region = ds.Settings.Region } - - sess, err := e.sessions.GetSession(awsds.SessionConfig{ + sess, err := ds.sessions.GetSession(awsds.SessionConfig{ // https://github.com/grafana/grafana/issues/46365 // HTTPClient: instance.HTTPClient, Settings: awsds.AWSDatasourceSettings{ - Profile: instance.Settings.Profile, + Profile: ds.Settings.Profile, Region: region, - AuthType: instance.Settings.AuthType, - AssumeRoleARN: instance.Settings.AssumeRoleARN, - ExternalID: instance.Settings.ExternalID, - Endpoint: instance.Settings.Endpoint, - DefaultRegion: instance.Settings.Region, - AccessKey: instance.Settings.AccessKey, - SecretKey: instance.Settings.SecretKey, + AuthType: ds.Settings.AuthType, + AssumeRoleARN: ds.Settings.AssumeRoleARN, + ExternalID: ds.Settings.ExternalID, + Endpoint: ds.Settings.Endpoint, + DefaultRegion: ds.Settings.Region, + AccessKey: ds.Settings.AccessKey, + SecretKey: ds.Settings.SecretKey, }, UserAgentName: aws.String("Cloudwatch"), + AuthSettings: &ds.Settings.GrafanaSettings, }) if err != nil { return nil, err } // work around until https://github.com/grafana/grafana/issues/39089 is implemented - if e.cfg.SecureSocksDSProxy.Enabled && instance.Settings.SecureSocksProxyEnabled { + if ds.Settings.GrafanaSettings.SecureSocksDSProxyEnabled && ds.Settings.SecureSocksProxyEnabled { // only update the transport to try to avoid the issue mentioned here https://github.com/grafana/grafana/issues/46365 - sess.Config.HTTPClient.Transport = instance.HTTPClient.Transport + // also, 'sess' is cached and reused, so the first time it might have the transport not set, the following uses it will + if sess.Config.HTTPClient.Transport == nil { + // following go standard library logic (https://pkg.go.dev/net/http#Client), if no Transport is provided, + // then we use http.DefaultTransport + defTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + // this should not happen but validating just in case + return nil, errors.New("default http client transport is not of type http.Transport") + } + sess.Config.HTTPClient.Transport = defTransport.Clone() + } + err = proxy.New(ds.ProxyOpts).ConfigureSecureSocksHTTPProxy(sess.Config.HTTPClient.Transport.(*http.Transport)) + if err != nil { + return nil, fmt.Errorf("error configuring Secure Socks proxy for Transport: %w", err) + } } - return sess, nil } +func (e *cloudWatchExecutor) newSessionFromContext(ctx context.Context, pluginCtx backend.PluginContext, region string) (*session.Session, error) { + instance, err := e.getInstance(ctx, pluginCtx) + if err != nil { + return nil, err + } + + return instance.newSession(region) +} + func (e *cloudWatchExecutor) getInstance(ctx context.Context, pluginCtx backend.PluginContext) (*DataSource, error) { i, err := e.im.Get(ctx, pluginCtx) if err != nil { @@ -297,7 +358,7 @@ func (e *cloudWatchExecutor) getInstance(ctx context.Context, pluginCtx backend. } func (e *cloudWatchExecutor) getCWClient(ctx context.Context, pluginCtx backend.PluginContext, region string) (cloudwatchiface.CloudWatchAPI, error) { - sess, err := e.newSession(ctx, pluginCtx, region) + sess, err := e.newSessionFromContext(ctx, pluginCtx, region) if err != nil { return nil, err } @@ -305,7 +366,7 @@ func (e *cloudWatchExecutor) getCWClient(ctx context.Context, pluginCtx backend. } func (e *cloudWatchExecutor) getCWLogsClient(ctx context.Context, pluginCtx backend.PluginContext, region string) (cloudwatchlogsiface.CloudWatchLogsAPI, error) { - sess, err := e.newSession(ctx, pluginCtx, region) + sess, err := e.newSessionFromContext(ctx, pluginCtx, region) if err != nil { return nil, err } @@ -316,7 +377,7 @@ func (e *cloudWatchExecutor) getCWLogsClient(ctx context.Context, pluginCtx back } func (e *cloudWatchExecutor) getEC2Client(ctx context.Context, pluginCtx backend.PluginContext, region string) (models.EC2APIProvider, error) { - sess, err := e.newSession(ctx, pluginCtx, region) + sess, err := e.newSessionFromContext(ctx, pluginCtx, region) if err != nil { return nil, err } @@ -326,7 +387,7 @@ func (e *cloudWatchExecutor) getEC2Client(ctx context.Context, pluginCtx backend func (e *cloudWatchExecutor) getRGTAClient(ctx context.Context, pluginCtx backend.PluginContext, region string) (resourcegroupstaggingapiiface.ResourceGroupsTaggingAPIAPI, error) { - sess, err := e.newSession(ctx, pluginCtx, region) + sess, err := e.newSessionFromContext(ctx, pluginCtx, region) if err != nil { return nil, err } diff --git a/pkg/tsdb/cloudwatch/cloudwatch_integration_test.go b/pkg/tsdb/cloudwatch/cloudwatch_integration_test.go index 37a6ab40f6867..e9ce1632e2104 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch_integration_test.go +++ b/pkg/tsdb/cloudwatch/cloudwatch_integration_test.go @@ -16,8 +16,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" @@ -55,16 +54,8 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { return &api } - im := datasource.NewInstanceManager((func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{ - AWSDatasourceSettings: awsds.AWSDatasourceSettings{ - Region: "us-east-1", - }, - }}, nil - })) - t.Run("Should handle dimension value request and return values from the api", func(t *testing.T) { - pageLimit := 100 + im := testInstanceManager(100) api = mocks.FakeMetricsAPI{Metrics: []*cloudwatch.Metric{ {MetricName: aws.String("Test_MetricName1"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName1"), Value: aws.String("Value1")}, {Name: aws.String("Test_DimensionName2"), Value: aws.String("Value2")}}}, {MetricName: aws.String("Test_MetricName2"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName1"), Value: aws.String("Value3")}}}, @@ -77,7 +68,7 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { {MetricName: aws.String("Test_MetricName8"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName4"), Value: aws.String("Value1")}}}, {MetricName: aws.String("Test_MetricName9"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName1"), Value: aws.String("Value2")}}}, }, MetricsPerPage: 100} - executor := newExecutor(im, &setting.Cfg{AWSListMetricsPageLimit: pageLimit}, &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) req := &backend.CallResourceRequest{ Method: "GET", @@ -100,7 +91,7 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { }) t.Run("Should handle dimension key filter query and return keys from the api", func(t *testing.T) { - pageLimit := 3 + im := testInstanceManager(3) api = mocks.FakeMetricsAPI{Metrics: []*cloudwatch.Metric{ {MetricName: aws.String("Test_MetricName1"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName1")}, {Name: aws.String("Test_DimensionName2")}}}, {MetricName: aws.String("Test_MetricName2"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName1")}}}, @@ -113,7 +104,7 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { {MetricName: aws.String("Test_MetricName8"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName4")}}}, {MetricName: aws.String("Test_MetricName9"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName1")}}}, }, MetricsPerPage: 2} - executor := newExecutor(im, &setting.Cfg{AWSListMetricsPageLimit: pageLimit}, &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) req := &backend.CallResourceRequest{ Method: "GET", @@ -136,8 +127,9 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { }) t.Run("Should handle standard dimension key query and return hard coded keys", func(t *testing.T) { + im := defaultTestInstanceManager() api = mocks.FakeMetricsAPI{} - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) req := &backend.CallResourceRequest{ Method: "GET", @@ -160,8 +152,9 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { }) t.Run("Should handle custom namespace dimension key query and return hard coded keys", func(t *testing.T) { + im := defaultTestInstanceManager() api = mocks.FakeMetricsAPI{} - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) req := &backend.CallResourceRequest{ Method: "GET", @@ -184,7 +177,7 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { }) t.Run("Should handle custom namespace metrics query and return metrics from api", func(t *testing.T) { - pageLimit := 3 + im := testInstanceManager(3) api = mocks.FakeMetricsAPI{Metrics: []*cloudwatch.Metric{ {MetricName: aws.String("Test_MetricName1"), Namespace: aws.String("AWS/EC2"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName1")}, {Name: aws.String("Test_DimensionName2")}}}, {MetricName: aws.String("Test_MetricName2"), Namespace: aws.String("AWS/EC2"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName1")}}}, @@ -197,7 +190,7 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { {MetricName: aws.String("Test_MetricName8"), Namespace: aws.String("AWS/EC2"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName4")}}}, {MetricName: aws.String("Test_MetricName9"), Namespace: aws.String("AWS/EC2"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName1")}}}, }, MetricsPerPage: 2} - executor := newExecutor(im, &setting.Cfg{AWSListMetricsPageLimit: pageLimit}, &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) req := &backend.CallResourceRequest{ Method: "GET", @@ -220,6 +213,7 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { }) t.Run("Should handle log group fields request", func(t *testing.T) { + im := defaultTestInstanceManager() logApi = mocks.LogsAPI{} logApi.On("GetLogGroupFieldsWithContext", mock.Anything).Return(&cloudwatchlogs.GetLogGroupFieldsOutput{ LogGroupFields: []*cloudwatchlogs.LogGroupField{ @@ -233,7 +227,7 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { }, }, }, nil) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) req := &backend.CallResourceRequest{ Method: "GET", @@ -254,7 +248,8 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { }) t.Run("Should handle region requests and return regions from the api", func(t *testing.T) { - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudwatchNewRegionsHandler, true)) + im := defaultTestInstanceManager() + executor := newExecutor(im, log.NewNullLogger()) req := &backend.CallResourceRequest{ Method: "GET", Path: `/regions`, @@ -272,33 +267,15 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { assert.Contains(t, string(sent.Body), `"name":"us-east-1"`) }) - t.Run("Should handle legacy region requests and feature toggle is turned off", func(t *testing.T) { - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudwatchNewRegionsHandler, false)) - req := &backend.CallResourceRequest{ - Method: "GET", - Path: `/regions`, - PluginContext: backend.PluginContext{ - DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ID: 0}, - PluginID: "cloudwatch", - }, - } - err := executor.CallResource(context.Background(), req, sender) - require.NoError(t, err) - sent := sender.Response - require.NotNil(t, sent) - require.Equal(t, http.StatusOK, sent.Status) - require.Nil(t, err) - assert.Contains(t, string(sent.Body), `"text":"us-east-1"`) - }) - t.Run("Should error for any request when a default region is not selected", func(t *testing.T) { imWithoutDefaultRegion := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { return DataSource{Settings: models.CloudWatchSettings{ AWSDatasourceSettings: awsds.AWSDatasourceSettings{}, + GrafanaSettings: awsds.AuthSettings{ListMetricsPageLimit: 1000}, }}, nil }) - executor := newExecutor(imWithoutDefaultRegion, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudwatchNewRegionsHandler, false)) + executor := newExecutor(imWithoutDefaultRegion, log.NewNullLogger()) req := &backend.CallResourceRequest{ Method: "GET", Path: `/regions`, @@ -313,6 +290,6 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { require.NotNil(t, sent) require.Equal(t, http.StatusBadRequest, sent.Status) require.Nil(t, err) - assert.Contains(t, string(sent.Body), "unexpected error missing default region") + assert.Contains(t, string(sent.Body), "missing default region") }) } diff --git a/pkg/tsdb/cloudwatch/cloudwatch_test.go b/pkg/tsdb/cloudwatch/cloudwatch_test.go index 3e4992a20a84b..c2287c279c50e 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch_test.go +++ b/pkg/tsdb/cloudwatch/cloudwatch_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "testing" + "time" "github.com/aws/aws-sdk-go/aws" awsclient "github.com/aws/aws-sdk-go/aws/client" @@ -14,9 +15,11 @@ import ( "github.com/grafana/grafana-aws-sdk/pkg/awsds" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" - "github.com/grafana/grafana/pkg/infra/httpclient" - "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/features" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" @@ -26,9 +29,11 @@ import ( ) func TestNewInstanceSettings(t *testing.T) { + ctxDuration := 10 * time.Minute tests := []struct { name string settings backend.DataSourceInstanceSettings + settingCtx context.Context expectedDS DataSource Err require.ErrorAssertionFunc }{ @@ -49,6 +54,14 @@ func TestNewInstanceSettings(t *testing.T) { "secretKey": "secret", }, }, + settingCtx: backend.WithGrafanaConfig(context.Background(), backend.NewGrafanaCfg(map[string]string{ + awsds.AllowedAuthProvidersEnvVarKeyName: "foo , bar,baz", + awsds.AssumeRoleEnabledEnvVarKeyName: "false", + awsds.SessionDurationEnvVarKeyName: "10m", + awsds.GrafanaAssumeRoleExternalIdKeyName: "mock_id", + awsds.ListMetricsPageLimitKeyName: "50", + proxy.PluginSecureSocksProxyEnabled: "true", + })), expectedDS: DataSource{ Settings: models.CloudWatchSettings{ AWSDatasourceSettings: awsds.AWSDatasourceSettings{ @@ -62,6 +75,14 @@ func TestNewInstanceSettings(t *testing.T) { SecretKey: "secret", }, Namespace: "ns", + GrafanaSettings: awsds.AuthSettings{ + AllowedAuthProviders: []string{"foo", "bar", "baz"}, + AssumeRoleEnabled: false, + SessionDuration: &ctxDuration, + ExternalID: "mock_id", + ListMetricsPageLimit: 50, + SecureSocksDSProxyEnabled: true, + }, }, }, Err: require.NoError, @@ -71,8 +92,9 @@ func TestNewInstanceSettings(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { f := NewInstanceSettings(httpclient.NewProvider()) - model, err := f(context.Background(), tt.settings) + model, err := f(tt.settingCtx, tt.settings) tt.Err(t, err) + assert.Equal(t, tt.expectedDS.Settings.GrafanaSettings, model.(DataSource).Settings.GrafanaSettings) datasourceComparer := cmp.Comparer(func(d1 DataSource, d2 DataSource) bool { return d1.Settings.Profile == d2.Settings.Profile && d1.Settings.Region == d2.Settings.Region && @@ -109,17 +131,11 @@ func Test_CheckHealth(t *testing.T) { NewLogsAPI = func(sess *session.Session) models.CloudWatchLogsAPIProvider { return client } - im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{ - AWSDatasourceSettings: awsds.AWSDatasourceSettings{ - Region: "us-east-1", - }, - }}, nil - }) + im := defaultTestInstanceManager() t.Run("successfully query metrics and logs", func(t *testing.T) { client = fakeCheckHealthClient{} - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) resp, err := executor.CheckHealth(context.Background(), &backend.CheckHealthRequest{ PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, @@ -138,7 +154,7 @@ func Test_CheckHealth(t *testing.T) { return nil, fmt.Errorf("some logs query error") }} - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) resp, err := executor.CheckHealth(context.Background(), &backend.CheckHealthRequest{ PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, @@ -157,7 +173,7 @@ func Test_CheckHealth(t *testing.T) { return fmt.Errorf("some list metrics error") }} - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) resp, err := executor.CheckHealth(context.Background(), &backend.CheckHealthRequest{ PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, @@ -172,10 +188,16 @@ func Test_CheckHealth(t *testing.T) { t.Run("fail to get clients", func(t *testing.T) { client = fakeCheckHealthClient{} + im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return DataSource{ + Settings: models.CloudWatchSettings{AWSDatasourceSettings: awsds.AWSDatasourceSettings{Region: "us-east-1"}}, + sessions: &fakeSessionCache{getSession: func(c awsds.SessionConfig) (*session.Session, error) { + return nil, fmt.Errorf("some sessions error") + }}, + }, nil + }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{getSession: func(c awsds.SessionConfig) (*session.Session, error) { - return nil, fmt.Errorf("some sessions error") - }}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) resp, err := executor.CheckHealth(context.Background(), &backend.CheckHealthRequest{ PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, @@ -189,6 +211,40 @@ func Test_CheckHealth(t *testing.T) { }) } +func TestNewSession_passes_authSettings(t *testing.T) { + ctxDuration := 15 * time.Minute + expectedSettings := awsds.AuthSettings{ + AllowedAuthProviders: []string{"foo", "bar", "baz"}, + AssumeRoleEnabled: false, + SessionDuration: &ctxDuration, + ExternalID: "mock_id", + ListMetricsPageLimit: 50, + SecureSocksDSProxyEnabled: true, + } + im := datasource.NewInstanceManager((func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return DataSource{ + Settings: models.CloudWatchSettings{ + AWSDatasourceSettings: awsds.AWSDatasourceSettings{ + Region: "us-east-1", + }, + GrafanaSettings: expectedSettings, + }, + sessions: &fakeSessionCache{getSession: func(c awsds.SessionConfig) (*session.Session, error) { + assert.NotNil(t, c.AuthSettings) + assert.Equal(t, expectedSettings, *c.AuthSettings) + return &session.Session{ + Config: &aws.Config{}, + }, nil + }}, + }, nil + })) + executor := newExecutor(im, log.NewNullLogger()) + + _, err := executor.newSessionFromContext(context.Background(), + backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, "us-east-1") + require.NoError(t, err) +} + func TestQuery_ResourceRequest_DescribeLogGroups_with_CrossAccountQuerying(t *testing.T) { sender := &mockedCallResourceResponseSenderForOauth{} origNewMetricsAPI := NewMetricsAPI @@ -210,13 +266,7 @@ func TestQuery_ResourceRequest_DescribeLogGroups_with_CrossAccountQuerying(t *te return &logsApi } - im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{ - AWSDatasourceSettings: awsds.AWSDatasourceSettings{ - Region: "us-east-1", - }, - }}, nil - }) + im := defaultTestInstanceManager() t.Run("maps log group api response to resource response of log-groups", func(t *testing.T) { logsApi = mocks.LogsAPI{} @@ -234,8 +284,8 @@ func TestQuery_ResourceRequest_DescribeLogGroups_with_CrossAccountQuerying(t *te }, } - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying)) - err := executor.CallResource(context.Background(), req, sender) + executor := newExecutor(im, log.NewNullLogger()) + err := executor.CallResource(contextWithFeaturesEnabled(features.FlagCloudWatchCrossAccountQuerying), req, sender) assert.NoError(t, err) assert.JSONEq(t, `[ diff --git a/pkg/tsdb/cloudwatch/constants/metrics.go b/pkg/tsdb/cloudwatch/constants/metrics.go index 7bb996053fc44..4d95496735a28 100644 --- a/pkg/tsdb/cloudwatch/constants/metrics.go +++ b/pkg/tsdb/cloudwatch/constants/metrics.go @@ -10,7 +10,7 @@ var NamespaceMetricsMap = map[string][]string{ "AWS/AppSync": {"4XXError", "5XXError", "Latency", "Requests", "TokensConsumed", "ActiveConnections", "ActiveSubscriptions", "ConnectClientError", "ConnectionDuration", "ConnectServerError", "ConnectSuccess", "DisconnectClientError", "DisconnectServerError", "DisconnectSuccess", "PublishDataMessageClientError", "PublishDataMessageServerError", "PublishDataMessageSize", "PublishDataMessageSuccess", "SubscribeClientError", "SubscribeServerError", "SubscribeSuccess", "UnsubscribeClientError", "UnsubscribeServerError", "UnsubscribeSuccess"}, "AWS/ApplicationELB": {"ActiveConnectionCount", "ClientTLSNegotiationErrorCount", "ConsumedLCUs", "DesyncMitigationMode_NonCompliant_Request_Count", "DroppedInvalidHeaderRequestCount", "ELBAuthError", "ELBAuthFailure", "ELBAuthLatency", "ELBAuthRefreshTokenSuccess", "ELBAuthSuccess", "ELBAuthUserClaimsSizeExceeded", "ForwardedInvalidHeaderRequestCount", "GrpcRequestCount", "HTTPCode_ELB_3XX_Count", "HTTPCode_ELB_4XX_Count", "HTTPCode_ELB_5XX_Count", "HTTPCode_ELB_500_Count", "HTTPCode_ELB_502_Count", "HTTPCode_ELB_503_Count", "HTTPCode_ELB_504_Count", "HTTPCode_Target_2XX_Count", "HTTPCode_Target_3XX_Count", "HTTPCode_Target_4XX_Count", "HTTPCode_Target_5XX_Count", "HTTP_Fixed_Response_Count", "HTTP_Redirect_Count", "HTTP_Redirect_Url_Limit_Exceeded_Count", "HealthyHostCount", "IPv6ProcessedBytes", "IPv6RequestCount", "LambdaInternalError", "LambdaTargetProcessedBytes", "LambdaUserError", "NewConnectionCount", "NonStickyRequestCount", "ProcessedBytes", "RejectedConnectionCount", "RequestCount", "RequestCountPerTarget", "RuleEvaluations", "StandardProcessedBytes", "TargetConnectionErrorCount", "TargetResponseTime", "TargetTLSNegotiationErrorCount", "UnHealthyHostCount"}, "AWS/Athena": {"EngineExecutionTime", "QueryPlanningTime", "QueryQueueTime", "ProcessedBytes", "ServiceProcessingTime", "TotalExecutionTime"}, - "AWS/AutoScaling": {"GroupDesiredCapacity", "GroupInServiceInstances", "GroupMaxSize", "GroupMinSize", "GroupPendingInstances", "GroupStandbyInstances", "GroupTerminatingInstances", "GroupTotalInstances"}, + "AWS/AutoScaling": {"GroupAndWarmPoolDesiredCapacity", "GroupAndWarmPoolTotalCapacity", "GroupDesiredCapacity", "GroupInServiceCapacity", "GroupInServiceInstances", "GroupMaxSize", "GroupMinSize", "GroupPendingCapacity", "GroupPendingInstances", "GroupStandbyCapacity", "GroupStandbyInstances", "GroupTerminatingCapacity", "GroupTerminatingInstances", "GroupTotalCapacity", "GroupTotalInstances", "PredictiveScalingCapacityForecast", "PredictiveScalingLoadForecast", "PredictiveScalingMetricPairCorrelation", "WarmPoolDesiredCapacity", "WarmPoolMinSize", "WarmPoolPendingCapacity", "WarmPoolTerminatingCapacity", "WarmPoolTotalCapacity", "WarmPoolWarmedCapacity"}, "AWS/Bedrock": {"Invocations", "InvocationLatency", "InvocationClientErrors", "InvocationServerErrors", "InvocationThrottles", "InputTokenCount", "OutputImageCount", "OutputTokenCount"}, "AWS/Billing": {"EstimatedCharges"}, "AWS/Backup": {"NumberOfBackupJobsAborted", "NumberOfBackupJobsCompleted", "NumberOfBackupJobsCreated", "NumberOfBackupJobsExpired", "NumberOfBackupJobsFailed", "NumberOfBackupJobsPending", "NumberOfBackupJobsRunning", "NumberOfCopyJobsCompleted", "NumberOfCopyJobsCreated", "NumberOfCopyJobsFailed", "NumberOfCopyJobsRunning", "NumberOfRecoveryPointsCold", "NumberOfRecoveryPointsCompleted", "NumberOfRecoveryPointsDeleting", "NumberOfRecoveryPointsExpired", "NumberOfRecoveryPointsPartial", "NumberOfRestoreJobsCompleted", "NumberOfRestoreJobsFailed", "NumberOfRestoreJobsPending", "NumberOfRestoreJobsRunning"}, @@ -33,7 +33,7 @@ var NamespaceMetricsMap = map[string][]string{ "AWS/DAX": {"CPUUtilization", "NetworkPacketsIn", "NetworkPacketsOut", "GetItemRequestCount", "BatchGetItemRequestCount", "BatchWriteItemRequestCount", "DeleteItemRequestCount", "PutItemRequestCount", "UpdateItemRequestCount", "TransactWriteItemsCount", "TransactGetItemsCount", "ItemCacheHits", "ItemCacheMisses", "QueryCacheHits", "QueryCacheMisses", "ScanCacheHits", "ScanCacheMisses", "TotalRequestCount", "ErrorRequestCount", "FaultRequestCount", "FailedRequestCount", "QueryRequestCount", "ScanRequestCount", "ClientConnections", "EstimatedDbSize", "EvictedSize"}, "AWS/DynamoDB": {"AccountMaxReads", "AccountMaxTableLevelReads", "AccountMaxTableLevelWrites", "AccountMaxWrites", "AccountProvisionedReadCapacityUtilization", "AccountProvisionedWriteCapacityUtilization", "AgeOfOldestUnreplicatedRecord", "ConditionalCheckFailedRequests", "ConsumedChangeDataCaptureUnits", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "FailedToReplicateRecordCount", "MaxProvisionedTableReadCapacityUtilization", "MaxProvisionedTableWriteCapacityUtilization", "OnlineIndexConsumedWriteCapacity", "OnlineIndexPercentageProgress", "OnlineIndexThrottleEvents", "PendingReplicationCount", "ProvisionedReadCapacityUnits", "ProvisionedWriteCapacityUnits", "ReadThrottleEvents", "ReplicationLatency", "ReturnedBytes", "ReturnedItemCount", "ReturnedRecordsCount", "SuccessfulRequestLatency", "SystemErrors", "TimeToLiveDeletedItemCount", "ThrottledPutRecordCount", "ThrottledRequests", "TransactionConflict", "UserErrors", "WriteThrottleEvents"}, "AWS/EBS": {"BurstBalance", "VolumeConsumedReadWriteOps", "VolumeIdleTime", "VolumeQueueLength", "VolumeReadBytes", "VolumeReadOps", "VolumeThroughputPercentage", "VolumeTotalReadTime", "VolumeTotalWriteTime", "VolumeWriteBytes", "VolumeWriteOps"}, - "AWS/EC2": {"CPUCreditBalance", "CPUCreditUsage", "CPUSurplusCreditBalance", "CPUSurplusCreditsCharged", "CPUUtilization", "DiskReadBytes", "DiskReadOps", "DiskWriteBytes", "DiskWriteOps", "EBSByteBalance%", "EBSIOBalance%", "EBSReadBytes", "EBSReadOps", "EBSWriteBytes", "EBSWriteOps", "MetadataNoToken", "NetworkIn", "NetworkOut", "NetworkPacketsIn", "NetworkPacketsOut", "StatusCheckFailed", "StatusCheckFailed_Instance", "StatusCheckFailed_System"}, + "AWS/EC2": {"CPUCreditBalance", "CPUCreditUsage", "CPUSurplusCreditBalance", "CPUSurplusCreditsCharged", "CPUUtilization", "DedicatedHostCPUUtilization", "DiskReadBytes", "DiskReadOps", "DiskWriteBytes", "DiskWriteOps", "EBSByteBalance%", "EBSIOBalance%", "EBSReadBytes", "EBSReadOps", "EBSWriteBytes", "EBSWriteOps", "MetadataNoToken", "NetworkIn", "NetworkOut", "NetworkPacketsIn", "NetworkPacketsOut", "StatusCheckFailed", "StatusCheckFailed_AttachedEBS", "StatusCheckFailed_Instance", "StatusCheckFailed_System"}, "AWS/ElasticGPUs": {"GPUConnectivityCheckFailed", "GPUHealthCheckFailed", "GPUMemoryUtilization"}, "AWS/EC2/API": {"ClientErrors", "RequestLimitExceeded", "ServerErrors", "SuccessfulCalls"}, "AWS/EC2CapacityReservations": {"AvailableInstanceCount", "InstanceUtilization", "TotalInstanceCount", "UsedInstanceCount"}, @@ -74,13 +74,21 @@ var NamespaceMetricsMap = map[string][]string{ "AsynchronousSearchPersistRate", "AsynchronousSearchRejected", "AsynchronousSearchRunningCurrent", + "AsynchronousSearchStoredResponseCount", "AsynchronousSearchStoreHealth", "AsynchronousSearchStoreSize", - "AsynchronousSearchStoredResponseCount", "AsynchronousSearchSubmissionRate", + "AutoFollowLeaderCallFailure", + "AutoFollowNumFailedStartReplication", + "AutoFollowNumSuccessStartReplication", "AutomatedSnapshotFailure", - "CPUCreditBalance", - "CPUUtilization", + "AutoTuneChangesHistoryHeapSize", + "AutoTuneChangesHistoryJVMYoungGenArgs", + "AutoTuneFailed", + "AutoTuneSucceeded", + "AutoTuneValue", + "AvgPointInTimeAliveTime", + "BurstBalance", "ClusterIndexWritesBlocked", "ClusterStatus.green", "ClusterStatus.red", @@ -92,13 +100,30 @@ var NamespaceMetricsMap = map[string][]string{ "ColdToWarmMigrationQueueSize", "ColdToWarmMigrationSuccessCount", "CoordinatingWriteRejected", + "CPUCreditBalance", + "CPUUtilization", + "CrossClusterInboundReplicationRequests", "CrossClusterInboundRequests", "CrossClusterOutboundConnections", + "CrossClusterOutboundReplicationRequests", "CrossClusterOutboundRequests", + "CurrentPointInTime", + "DataNodes", + "DataNodesShards.active", + "DataNodesShards.initializing", + "DataNodesShards.relocating", + "DataNodesShards.unassigned", "DeletedDocuments", "DiskQueueDepth", + "ElasticsearchRequests", + "ESReportingFailedRequestSysErrCount", + "ESReportingFailedRequestUserErrCount", + "ESReportingRequestCount", + "ESReportingSuccessCount", "FollowerCheckPoint", "FreeStorageSpace", + "HasActivePointInTime", + "HasUsedPointInTime", "HotStorageSpaceUtilization", "HotToWarmMigrationFailureCount", "HotToWarmMigrationForceMergeLatency", @@ -115,6 +140,18 @@ var NamespaceMetricsMap = map[string][]string{ "JVMGCYoungCollectionCount", "JVMGCYoungCollectionTime", "JVMMemoryPressure", + "KibanaConcurrentConnections", + "KibanaHealthyNodes", + "KibanaHeapTotal", + "KibanaHeapUsed", + "KibanaHeapUtilization", + "KibanaOS1MinuteLoad", + "KibanaReportingFailedRequestSysErrCount", + "KibanaReportingFailedRequestUserErrCount", + "KibanaReportingRequestCount", + "KibanaReportingSuccessCount", + "KibanaRequestTotal", + "KibanaResponseTimesMaxInMillis", "KMSKeyError", "KMSKeyInaccessible", "KNNCacheCapacityReached", @@ -135,10 +172,7 @@ var NamespaceMetricsMap = map[string][]string{ "KNNScriptQueryErrors", "KNNScriptQueryRequests", "KNNTotalLoadTime", - "KibanaReportingFailedRequestSysErrCount", - "KibanaReportingFailedRequestUserErrCount", - "KibanaReportingRequestCount", - "KibanaReportingSuccessCount", + "LeaderCheckPoint", "LTRFeatureMemoryUsageInBytes", "LTRFeaturesetMemoryUsageInBytes", "LTRMemoryUsage", @@ -146,14 +180,15 @@ var NamespaceMetricsMap = map[string][]string{ "LTRRequestErrorCount", "LTRRequestTotalCount", "LTRStatus.red", - "LeaderCheckPoint", "MasterCPUCreditBalance", "MasterCPUUtilization", "MasterFreeStorageSpace", "MasterJVMMemoryPressure", + "MasterOldGenJVMMemoryPressure", "MasterReachableFromNode", "MasterSysMemoryUtilization", "Nodes", + "OldGenJVMMemoryPressure", "OpenSearchDashboardsConcurrentConnections", "OpenSearchDashboardsHealthyNode", "OpenSearchDashboardsHealthyNodes", @@ -161,6 +196,10 @@ var NamespaceMetricsMap = map[string][]string{ "OpenSearchDashboardsHeapUsed", "OpenSearchDashboardsHeapUtilization", "OpenSearchDashboardsOS1MinuteLoad", + "OpensearchDashboardsReportingFailedRequestSysErrCount", + "OpensearchDashboardsReportingFailedRequestUserErrCount", + "OpensearchDashboardsReportingRequestCount", + "OpensearchDashboardsReportingSuccessCount", "OpenSearchDashboardsRequestTotal", "OpenSearchDashboardsResponseTimesMaxInMillis", "OpenSearchRequests", @@ -169,18 +208,23 @@ var NamespaceMetricsMap = map[string][]string{ "PPLRequestCount", "PrimaryWriteRejected", "ReadIOPS", + "ReadIOPSMicroBursting", "ReadLatency", "ReadThroughput", - "ReplicaWriteRejected", + "ReadThroughputMicroBursting", + "RemoteStorageUsedSpace", + "RemoteStorageWriteRejected", + "ReplicationNumBootstrappingIndices", + "ReplicationNumFailedIndices", + "ReplicationNumPausedIndices", + "ReplicationNumSyncingIndices", "ReplicationRate", - "SQLDefaultCursorRequestCount", - "SQLFailedRequestCountByCusErr", - "SQLFailedRequestCountBySysErr", - "SQLRequestCount", - "SQLUnhealthy", + "ReplicaWriteRejected", + "SearchableDocuments", "SearchLatency", "SearchRate", - "SearchableDocuments", + "SearchShardTaskCancelled", + "SearchTaskCancelled", "SegmentCount", "Shards.active", "Shards.activePrimary", @@ -188,6 +232,11 @@ var NamespaceMetricsMap = map[string][]string{ "Shards.initializing", "Shards.relocating", "Shards.unassigned", + "SQLDefaultCursorRequestCount", + "SQLFailedRequestCountByCusErr", + "SQLFailedRequestCountBySysErr", + "SQLRequestCount", + "SQLUnhealthy", "SysMemoryUtilization", "ThreadpoolBulkQueue", "ThreadpoolBulkRejected", @@ -201,21 +250,24 @@ var NamespaceMetricsMap = map[string][]string{ "ThreadpoolSearchQueue", "ThreadpoolSearchRejected", "ThreadpoolSearchThreads", - "ThreadpoolWriteQueue", - "ThreadpoolWriteRejected", - "ThreadpoolWriteThreads", "Threadpoolsql-workerQueue", "Threadpoolsql-workerRejected", "Threadpoolsql-workerThreads", + "ThreadpoolWriteQueue", + "ThreadpoolWriteRejected", + "ThreadpoolWriteThreads", + "ThroughputThrottle", + "TotalPointInTime", "WarmCPUUtilization", "WarmFreeStorageSpace", "WarmJVMGCOldCollectionCount", "WarmJVMGCYoungCollectionCount", "WarmJVMGCYoungCollectionTime", "WarmJVMMemoryPressure", + "WarmOldGenJVMMemoryPressure", + "WarmSearchableDocuments", "WarmSearchLatency", "WarmSearchRate", - "WarmSearchableDocuments", "WarmStorageSpaceUtilization", "WarmSysMemoryUtilization", "WarmThreadpoolSearchQueue", @@ -227,8 +279,10 @@ var NamespaceMetricsMap = map[string][]string{ "WarmToColdMigrationSuccessCount", "WarmToHotMigrationQueueSize", "WriteIOPS", + "WriteIOPSMicroBursting", "WriteLatency", "WriteThroughput", + "WriteThroughputMicroBursting", }, "AWS/ElastiCache": { "ActiveDefragHits", @@ -336,10 +390,11 @@ var NamespaceMetricsMap = map[string][]string{ "AWS/ElasticBeanstalk": {"ApplicationLatencyP10", "ApplicationLatencyP50", "ApplicationLatencyP75", "ApplicationLatencyP85", "ApplicationLatencyP90", "ApplicationLatencyP95", "ApplicationLatencyP99", "ApplicationLatencyP99.9", "ApplicationRequests2xx", "ApplicationRequests3xx", "ApplicationRequests4xx", "ApplicationRequests5xx", "ApplicationRequestsTotal", "CPUIdle", "CPUIowait", "CPUIrq", "CPUNice", "CPUSoftirq", "CPUSystem", "CPUUser", "EnvironmentHealth", "InstanceHealth", "InstancesDegraded", "InstancesInfo", "InstancesNoData", "InstancesOk", "InstancesPending", "InstancesSevere", "InstancesUnknown", "InstancesWarning", "LoadAverage1min", "LoadAverage5min", "RootFilesystemUtil"}, "AWS/ElasticInference": {"AcceleratorHealthCheckFailed", "AcceleratorMemoryUsage", "ConnectivityCheckFailed"}, "AWS/ElasticMapReduce": {"AppsCompleted", "AppsFailed", "AppsKilled", "AppsPending", "AppsRunning", "AppsSubmitted", "BackupFailed", "CapacityRemainingGB", "Cluster Status", "ContainerAllocated", "ContainerPending", "ContainerPendingRatio", "ContainerReserved", "CoreNodesPending", "CoreNodesRunning", "CorruptBlocks", "DfsPendingReplicationBlocks", "HBase", "HDFSBytesRead", "HDFSBytesWritten", "HDFSUtilization", "HbaseBackupFailed", "IO", "IsIdle", "JobsFailed", "JobsRunning", "LiveDataNodes", "LiveTaskTrackers", "MRActiveNodes", "MRDecommissionedNodes", "MRLostNodes", "MRRebootedNodes", "MRTotalNodes", "MRUnhealthyNodes", "Map/Reduce", "MapSlotsOpen", "MapTasksRemaining", "MapTasksRunning", "MemoryAllocatedMB", "MemoryAvailableMB", "MemoryReservedMB", "MemoryTotalMB", "MissingBlocks", "MostRecentBackupDuration", "Node Status", "PendingDeletionBlocks", "ReduceSlotsOpen", "ReduceTasksRemaining", "ReduceTasksRunning", "RemainingMapTasksPerSlot", "S3BytesRead", "S3BytesWritten", "TaskNodesPending", "TaskNodesRunning", "TimeSinceLastSuccessfulBackup", "TotalLoad", "UnderReplicatedBlocks", "YARNMemoryAvailablePercentage"}, + "AWS/EMRServerless": {"CancellingJobs", "CancelledJobs", "CPUAllocated", "FailedJobs", "IdleWorkerCount", "MaxCPUAllowed", "MaxMemoryAllowed", "MaxStorageAllowed", "MemoryAllocated", "PendingCreationWorkerCount", "PendingJobs", "RunningJobs", "RunningWorkerCount", "ScheduledJobs", "StorageAllocated", "SubmittedJobs", "SuccessJobs", "TotalWorkerCount"}, "AWS/ElasticTranscoder": {"Billed Audio Output", "Billed HD Output", "Billed SD Output", "Errors", "Jobs Completed", "Jobs Errored", "Outputs per Job", "Standby Time", "Throttles"}, "AWS/EventBridge/Pipes": {"Concurrency", "Duration", "EnrichmentStageDuration", "EnrichmentStageFailed", "EventCount", "EventSize", "ExecutionFailed", "ExecutionPartiallyFailed", "ExecutionThrottled", "ExecutionTimeout", "Invocations", "TargetStageDuration", "TargetStageFailed", "TargetStagePartiallyFailed", "TargetStageSkipped"}, "AWS/Events": {"DeadLetterInvocations", "Events", "FailedInvocations", "IngestionToInvocationStartLatency", "Invocations", "InvocationsFailedToBeSentToDlq", "InvocationsSentToDlq", "MatchedEvents", "ThrottledRules", "TriggeredRules"}, - "AWS/Firehose": {"ActivePartitionsLimit", "BackupToS3.Bytes", "BackupToS3.DataFreshness", "BackupToS3.Records", "BackupToS3.Success", "DataReadFromKinesisStream.Bytes", "DataReadFromKinesisStream.Records", "DeliveryToElasticsearch.Bytes", "DeliveryToElasticsearch.Records", "DeliveryToElasticsearch.Success", "DeliveryToRedshift.Bytes", "DeliveryToRedshift.Records", "DeliveryToRedshift.Success", "DeliveryToS3.Bytes", "DeliveryToS3.DataFreshness", "DeliveryToS3.ObjectCount", "DeliveryToS3.Records", "DeliveryToS3.Success", "DeliveryToSplunk.Bytes", "DeliveryToSplunk.DataFreshness", "DeliveryToSplunk.Records", "DeliveryToSplunk.Success", "DescribeDeliveryStream.Latency", "DescribeDeliveryStream.Requests", "ExecuteProcessing.Duration", "ExecuteProcessing.Success", "FailedConversion.Bytes", "FailedConversion.Records", "IncomingBytes", "IncomingRecords", "JQProcessing.Duration", "KinesisMillisBehindLatest", "ListDeliveryStreams.Latency", "ListDeliveryStreams.Requests", "PartitionCount", "PartitionCountExceeded", "PerPartitionThroughput", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Requests", "PutRecordBatch.Bytes", "PutRecordBatch.Latency", "PutRecordBatch.Records", "PutRecordBatch.Requests", "SucceedConversion.Bytes", "SucceedConversion.Records", "SucceedProcessing.Bytes", "SucceedProcessing.Records", "ThrottledDescribeStream", "ThrottledGetRecords", "ThrottledGetShardIterator", "UpdateDeliveryStream.Latency", "UpdateDeliveryStream.Requests"}, + "AWS/Firehose": {"ActivePartitionsLimit", "BackupToS3.Bytes", "BackupToS3.DataFreshness", "BackupToS3.Records", "BackupToS3.Success", "DataReadFromKinesisStream.Bytes", "DataReadFromKinesisStream.Records", "DeliveryToElasticsearch.Bytes", "DeliveryToElasticsearch.Records", "DeliveryToElasticsearch.Success", "DeliveryToRedshift.Bytes", "DeliveryToRedshift.Records", "DeliveryToRedshift.Success", "DeliveryToS3.Bytes", "DeliveryToS3.DataFreshness", "DeliveryToS3.ObjectCount", "DeliveryToS3.Records", "DeliveryToS3.Success", "DeliveryToSplunk.Bytes", "DeliveryToSplunk.DataFreshness", "DeliveryToSplunk.Records", "DeliveryToSplunk.Success", "DescribeDeliveryStream.Latency", "DescribeDeliveryStream.Requests", "ExecuteProcessing.Duration", "ExecuteProcessing.Success", "FailedConversion.Bytes", "FailedConversion.Records", "IncomingBytes", "IncomingRecords", "JQProcessing.Duration", "KinesisMillisBehindLatest", "KMSKeyAccessDenied", "KMSKeyDisabled", "KMSKeyInvalidState", "KMSKeyNotFound", "ListDeliveryStreams.Latency", "ListDeliveryStreams.Requests", "PartitionCount", "PartitionCountExceeded", "PerPartitionThroughput", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Requests", "PutRecordBatch.Bytes", "PutRecordBatch.Latency", "PutRecordBatch.Records", "PutRecordBatch.Requests", "SucceedConversion.Bytes", "SucceedConversion.Records", "SucceedProcessing.Bytes", "SucceedProcessing.Records", "ThrottledDescribeStream", "ThrottledGetRecords", "ThrottledGetShardIterator", "UpdateDeliveryStream.Latency", "UpdateDeliveryStream.Requests"}, "AWS/FSx": {"ClientConnections", "CPUUtilization", "DataReadBytes", "DataReadOperations", "DataWriteBytes", "DataWriteOperations", "DeduplicationSavedStorage", "DiskIopsUtilization", "DiskReadBytes", "DiskReadOperations", "DiskThroughputBalance", "DiskThroughputUtilization", "DiskWriteBytes", "DiskWriteOperations", "FileServerDiskIopsBalance", "FileServerDiskIopsUtilization", "FileServerDiskThroughputBalance", "FileServerDiskThroughputUtilization", "FreeDataStorageCapacity", "FreeStorageCapacity", "MemoryUtilization", "MetadataOperations", "NetworkThroughputUtilization", "StorageCapacityUtilization"}, "AWS/FraudDetector": {"GetEventPrediction", "GetEventPrediction4xxError", "GetEventPrediction5xxError", "GetEventPredictionLatency", "ModelInvocation", "ModelInvocationError", "ModelInvocationLatency", "OutcomeReturned", "Prediction", "PredictionError", "PredictionLatency", "RuleEvaluateError", "RuleEvaluateFalse", "RuleEvaluateTrue", "RuleNotEvaluated", "VariableDefaultReturned", "VariableUsed"}, "AWS/GameLift": {"ActivatingGameSessions", "ActiveGameSessions", "ActiveInstances", "ActiveServerProcesses", "AvailableGameServers", "AvailableGameSessions", "AverageWaitTime", "CurrentPlayerSessions", "CurrentTickets", "DesiredInstances", "DrainingAvailableGameServers", "DrainingUtilizedGameServers", "FirstChoiceNotViable", "FirstChoiceOutOfCapacity", "GameSessionInterruptions", "HealthyServerProcesses", "IdleInstances", "InstanceInterruptions", "LowestLatencyPlacement", "LowestPricePlacement", "MatchAcceptancesTimedOut", "MatchesAccepted", "MatchesCreated", "MatchesPlaced", "MatchesRejected", "MaxInstances", "MinInstances", "PercentAvailableGameSessions", "PercentHealthyServerProcesses", "PercentIdleInstances", "Placement", "PlacementsCanceled", "PlacementsFailed", "PlacementsStarted", "PlacementsSucceeded", "PlacementsTimedOut", "PlayerSessionActivations", "PlayersStarted", "QueueDepth", "RuleEvaluationsFailed", "RuleEvaluationsPassed", "ServerProcessAbnormalTerminations", "ServerProcessActivations", "ServerProcessTerminations", "TicketsFailed", "TicketsStarted", "TicketsTimedOut", "TimeToMatch", "TimeToTicketSuccess", "UtilizedGameServers"}, @@ -352,11 +407,12 @@ var NamespaceMetricsMap = map[string][]string{ "AWS/IoTAnalytics": {"ActionExecution", "ActivityExecutionError", "IncomingMessages"}, "AWS/IoTSiteWise": {"Gateway.Heartbeat", "Gateway.PublishSuccessCount", "Gateway.PublishFailureCount", "Gateway.ProcessFailureCount", "OPCUACollector.Heartbeat", "OPCUACollector.ActiveDataStreamCount", "OPCUACollector.IncomingValuesCount"}, "AWS/KMS": {"SecondsUntilKeyMaterialExpiration"}, - "AWS/Kafka": {"ActiveControllerCount", "BytesInPerSec", "BytesOutPerSec", "CpuIdle", "CpuSystem", "CpuUser", "EstimatedMaxTimeLag", "EstimatedTimeLag", "FetchConsumerLocalTimeMsMean", "FetchConsumerRequestQueueTimeMsMean", "FetchConsumerResponseQueueTimeMsMean", "FetchConsumerResponseSendTimeMsMean", "FetchConsumerTotalTimeMsMean", "FetchFollowerLocalTimeMsMean", "FetchFollowerRequestQueueTimeMsMean", "FetchFollowerResponseQueueTimeMsMean", "FetchFollowerResponseSendTimeMsMean", "FetchFollowerTotalTimeMsMean", "FetchMessageConversionsPerSec", "FetchThrottleByteRate", "FetchThrottleQueueSize", "FetchThrottleTime", "GlobalPartitionCount", "GlobalTopicCount", "KafkaAppLogsDiskUsed", "KafkaDataLogsDiskUsed", "LeaderCount", "MaxOffsetLag", "MemoryBuffered", "MemoryCached", "MemoryFree", "MemoryUsed", "MessagesInPerSec", "NetworkProcessorAvgIdlePercent", "NetworkRxDropped", "NetworkRxErrors", "NetworkRxPackets", "NetworkTxDropped", "NetworkTxErrors", "NetworkTxPackets", "OfflinePartitionsCount", "PartitionCount", "ProduceLocalTimeMsMean", "ProduceMessageConversionsPerSec", "ProduceMessageConversionsTimeMsMean", "ProduceRequestQueueTimeMsMean", "ProduceResponseQueueTimeMsMean", "ProduceResponseSendTimeMsMean", "ProduceThrottleByteRate", "ProduceThrottleQueueSize", "ProduceThrottleTime", "ProduceTotalTimeMsMean", "ReplicationBytesInPerSec", "ReplicationBytesOutPerSec", "RequestBytesMean", "RequestExemptFromThrottleTime", "RequestHandlerAvgIdlePercent", "RequestThrottleQueueSize", "RequestThrottleTime", "RequestTime", "RootDiskUsed", "SumOffsetLag", "SwapFree", "SwapUsed", "OffsetLag", "UnderMinIsrPartitionCount", "UnderReplicatedPartitions", "ZooKeeperRequestLatencyMsMean", "ZooKeeperSessionState"}, + "AWS/Kafka": {"ActiveControllerCount", "BurstBalance", "BwInAllowanceExceeded", "BwOutAllowanceExceeded", "BytesInPerSec", "BytesOutPerSec", "ClientConnectionCount", "ConnectionCloseRate", "ConnectionCount", "ConnectionCreationRate", "ConnTrackAllowanceExceeded", "CPUCreditBalance", "CpuCreditUsage", "CpuIdle", "CpuIoWait", "CpuSystem", "CpuUser", "EstimatedMaxTimeLag", "EstimatedTimeLag", "FetchConsumerLocalTimeMsMean", "FetchConsumerRequestQueueTimeMsMean", "FetchConsumerResponseQueueTimeMsMean", "FetchConsumerResponseSendTimeMsMean", "FetchConsumerTotalTimeMsMean", "FetchFollowerLocalTimeMsMean", "FetchFollowerRequestQueueTimeMsMean", "FetchFollowerResponseQueueTimeMsMean", "FetchFollowerResponseSendTimeMsMean", "FetchFollowerTotalTimeMsMean", "FetchMessageConversionsPerSec", "FetchThrottleByteRate", "FetchThrottleQueueSize", "FetchThrottleTime", "GlobalPartitionCount", "GlobalTopicCount", "HeapMemoryAfterGC", "KafkaAppLogsDiskUsed", "KafkaDataLogsDiskUsed", "LeaderCount", "MaxOffsetLag", "MemoryBuffered", "MemoryCached", "MemoryFree", "MemoryUsed", "MessagesInPerSec", "NetworkProcessorAvgIdlePercent", "NetworkRxDropped", "NetworkRxErrors", "NetworkRxPackets", "NetworkTxDropped", "NetworkTxErrors", "NetworkTxPackets", "OfflinePartitionsCount", "OffsetLag", "PartitionCount", "PpsAllowanceExceeded", "ProduceLocalTimeMsMean", "ProduceMessageConversionsPerSec", "ProduceMessageConversionsTimeMsMean", "ProduceRequestQueueTimeMsMean", "ProduceResponseQueueTimeMsMean", "ProduceResponseSendTimeMsMean", "ProduceThrottleByteRate", "ProduceThrottleQueueSize", "ProduceThrottleTime", "ProduceTotalTimeMsMean", "RemoteCopyBytesPerSec", "RemoteCopyErrorsPerSec", "RemoteCopyLagBytes", "RemoteFetchBytesPerSec", "RemoteFetchErrorsPerSec", "RemoteFetchRequestsPerSec", "RemoteLogManagerTasksAvgIdlePercent", "RemoteLogReaderAvgIdlePercent", "RemoteLogReaderTaskQueueSize", "ReplicationBytesInPerSec", "ReplicationBytesOutPerSec", "RequestBytesMean", "RequestExemptFromThrottleTime", "RequestHandlerAvgIdlePercent", "RequestThrottleQueueSize", "RequestThrottleTime", "RequestTime", "RootDiskUsed", "SumOffsetLag", "SwapFree", "SwapUsed", "TcpConnections", "TrafficBytes", "TrafficShaping", "UnderMinIsrPartitionCount", "UnderReplicatedPartitions", "VolumeQueueLength", "VolumeReadBytes", "VolumeReadOps", "VolumeTotalReadTime", "VolumeTotalWriteTime", "VolumeWriteBytes", "VolumeWriteOps", "ZooKeeperRequestLatencyMsMean", "ZooKeeperSessionState"}, + "AWS/KafkaConnect": {"BytesInPerSec", "BytesOutPerSec", "CpuUtilization", "ErroredTaskCount", "MemoryUtilization", "RebalanceCompletedTotal", "RebalanceTimeAvg", "RebalanceTimeMax", "RebalanceTimeSinceLast", "RunningTaskCount", "SinkRecordReadRate", "SinkRecordSendRate", "SourceRecordPollRate", "SourceRecordWriteRate", "TaskStartupAttemptsTotal", "TaskStartupSuccessPercentage", "WorkerCount"}, "AWS/Kinesis": {"GetRecords.Bytes", "GetRecords.IteratorAge", "GetRecords.IteratorAgeMilliseconds", "GetRecords.Latency", "GetRecords.Records", "GetRecords.Success", "IncomingBytes", "IncomingRecords", "IteratorAgeMilliseconds", "OutgoingBytes", "OutgoingRecords", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Success", "PutRecords.Bytes", "PutRecords.Latency", "PutRecords.Records", "PutRecords.Success", "ReadProvisionedThroughputExceeded", "SubscribeToShard.RateExceeded", "SubscribeToShard.Success", "SubscribeToShardEvent.Bytes", "SubscribeToShardEvent.MillisBehindLatest", "SubscribeToShardEvent.Records", "SubscribeToShardEvent.Success", "WriteProvisionedThroughputExceeded"}, "AWS/KinesisAnalytics": {"Bytes", "InputProcessing.DroppedRecords", "InputProcessing.Duration", "InputProcessing.OkBytes", "InputProcessing.OkRecords", "InputProcessing.ProcessingFailedRecords", "InputProcessing.Success", "KPUs", "LambdaDelivery.DeliveryFailedRecords", "LambdaDelivery.Duration", "LambdaDelivery.OkRecords", "MillisBehindLatest", "Records", "Success"}, "AWS/KinesisVideo": {"GetHLSMasterPlaylist.Latency", "GetHLSMasterPlaylist.Requests", "GetHLSMasterPlaylist.Success", "GetHLSMediaPlaylist.Latency", "GetHLSMediaPlaylist.Requests", "GetHLSMediaPlaylist.Success", "GetHLSStreamingSessionURL.Latency", "GetHLSStreamingSessionURL.Requests", "GetHLSStreamingSessionURL.Success", "GetMP4InitFragment.Latency", "GetMP4InitFragment.Requests", "GetMP4InitFragment.Success", "GetMP4MediaFragment.Latency", "GetMP4MediaFragment.OutgoingBytes", "GetMP4MediaFragment.Requests", "GetMP4MediaFragment.Success", "GetMedia.ConnectionErrors", "GetMedia.MillisBehindNow", "GetMedia.OutgoingBytes", "GetMedia.OutgoingFragments", "GetMedia.OutgoingFrames", "GetMedia.Requests", "GetMedia.Success", "GetMediaForFragmentList.OutgoingBytes", "GetMediaForFragmentList.OutgoingFragments", "GetMediaForFragmentList.OutgoingFrames", "GetMediaForFragmentList.Requests", "GetMediaForFragmentList.Success", "GetTSFragment.Latency", "GetTSFragment.OutgoingBytes", "GetTSFragment.Requests", "GetTSFragment.Success", "ListFragments.Latency", "PutMedia.ActiveConnections", "PutMedia.BufferingAckLatency", "PutMedia.ConnectionErrors", "PutMedia.ErrorAckCount", "PutMedia.FragmentIngestionLatency", "PutMedia.FragmentPersistLatency", "PutMedia.IncomingBytes", "PutMedia.IncomingFragments", "PutMedia.IncomingFrames", "PutMedia.Latency", "PutMedia.PersistedAckLatency", "PutMedia.ReceivedAckLatency", "PutMedia.Requests", "PutMedia.Success"}, - "AWS/Lambda": {"ConcurrentExecutions", "DeadLetterErrors", "DestinationDeliveryFailures", "Duration", "Errors", "Invocations", "IteratorAge", "OffsetLag", "PostRuntimeExtensionsDuration", "ProvisionedConcurrencyInvocations", "ProvisionedConcurrencyUtilization", "ProvisionedConcurrentExecutions", "Throttles", "ProvisionedConcurrencySpilloverInvocations", "UnreservedConcurrentExecutions"}, + "AWS/Lambda": {"AsyncEventAge", "AsyncEventsDropped", "AsyncEventsReceived", "ClaimedAccountConcurrency", "ConcurrentExecutions", "DeadLetterErrors", "DestinationDeliveryFailures", "Duration", "Errors", "Invocations", "IteratorAge", "OffsetLag", "OversizedRecordCount", "PostRuntimeExtensionsDuration", "ProvisionedConcurrencyInvocations", "ProvisionedConcurrencySpilloverInvocations", "ProvisionedConcurrencyUtilization", "ProvisionedConcurrentExecutions", "RecursiveInvocationsDropped", "Throttles", "UnreservedConcurrentExecutions"}, "AWS/Lex": {"BotChannelAuthErrors", "BotChannelConfigurationErrors", "BotChannelInboundThrottledEvents", "BotChannelOutboundThrottledEvents", "BotChannelRequestCount", "BotChannelResponseCardErrors", "BotChannelSystemErrors", "MissedUtteranceCount", "RuntimeInvalidLambdaResponses", "RuntimeLambdaErrors", "RuntimePollyErrors", "RuntimeRequestCount", "RuntimeSucessfulRequestLatency", "RuntimeSystemErrors", "RuntimeThrottledEvents", "RuntimeUserErrors"}, "AWS/Logs": {"DeliveryErrors", "DeliveryThrottling", "ForwardedBytes", "ForwardedLogEvents", "IncomingBytes", "IncomingLogEvents"}, "AWS/LookoutMetrics": {"ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsFailed", "Delivered", "Undelivered"}, @@ -424,7 +480,7 @@ var NamespaceDimensionKeysMap = map[string][]string{ "AWS/AppSync": {"GraphQLAPIId"}, "AWS/ApplicationELB": {"AvailabilityZone", "LoadBalancer", "TargetGroup"}, "AWS/Athena": {"QueryState", "QueryType", "WorkGroup"}, - "AWS/AutoScaling": {"AutoScalingGroupName"}, + "AWS/AutoScaling": {"AutoScalingGroupName", "PairIndex", "PolicyName"}, "AWS/Backup": {"BackupVaultName", "ResourceType"}, "AWS/Bedrock": {"BucketedStepSize", "ImageSize", "ModelId"}, "AWS/Billing": {"Currency", "LinkedAccount", "ServiceName"}, @@ -460,6 +516,7 @@ var NamespaceDimensionKeysMap = map[string][]string{ "AWS/ElasticBeanstalk": {"EnvironmentName", "InstanceId"}, "AWS/ElasticInference": {"ElasticInferenceAcceleratorId", "InstanceId"}, "AWS/ElasticMapReduce": {"ClusterId", "JobFlowId", "JobId"}, + "AWS/EMRServerless": {"ApplicationId", "WorkerType", "CapacityAllocationType"}, "AWS/ElasticTranscoder": {"Operation", "PipelineId"}, "AWS/Events": {"EventBusName", "RuleName"}, "AWS/FSx": {"FileSystemId"}, diff --git a/pkg/tsdb/cloudwatch/features/features.go b/pkg/tsdb/cloudwatch/features/features.go new file mode 100644 index 0000000000000..2c01a2673bfa0 --- /dev/null +++ b/pkg/tsdb/cloudwatch/features/features.go @@ -0,0 +1,16 @@ +package features + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" +) + +const ( + FlagCloudWatchCrossAccountQuerying = "cloudWatchCrossAccountQuerying" + FlagCloudWatchBatchQueries = "cloudWatchBatchQueries" +) + +func IsEnabled(ctx context.Context, feature string) bool { + return backend.GrafanaConfigFromContext(ctx).FeatureToggles().IsEnabled(feature) +} diff --git a/pkg/tsdb/cloudwatch/get_dimension_values_for_wildcards.go b/pkg/tsdb/cloudwatch/get_dimension_values_for_wildcards.go index ab1a6158106fd..3fc8f469942bf 100644 --- a/pkg/tsdb/cloudwatch/get_dimension_values_for_wildcards.go +++ b/pkg/tsdb/cloudwatch/get_dimension_values_for_wildcards.go @@ -4,8 +4,6 @@ import ( "context" "fmt" - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/clients" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" @@ -14,9 +12,9 @@ import ( ) // getDimensionValues gets the actual dimension values for dimensions with a wildcard -func (e *cloudWatchExecutor) getDimensionValuesForWildcards(ctx context.Context, pluginCtx backend.PluginContext, region string, - client models.CloudWatchMetricsAPIProvider, origQueries []*models.CloudWatchQuery, tagValueCache *cache.Cache, logger log.Logger) ([]*models.CloudWatchQuery, error) { - metricsClient := clients.NewMetricsClient(client, e.cfg) +func (e *cloudWatchExecutor) getDimensionValuesForWildcards(ctx context.Context, region string, + client models.CloudWatchMetricsAPIProvider, origQueries []*models.CloudWatchQuery, tagValueCache *cache.Cache, listMetricsPageLimit int) ([]*models.CloudWatchQuery, error) { + metricsClient := clients.NewMetricsClient(client, listMetricsPageLimit) service := services.NewListMetricsService(metricsClient) // create copies of the original query. All the fields besides Dimensions are primitives queries := copyQueries(origQueries) @@ -35,12 +33,12 @@ func (e *cloudWatchExecutor) getDimensionValuesForWildcards(ctx context.Context, cacheKey := fmt.Sprintf("%s-%s-%s-%s-%s", region, accountID, query.Namespace, query.MetricName, dimensionKey) cachedDimensions, found := tagValueCache.Get(cacheKey) if found { - logger.Debug("Fetching dimension values from cache") + e.logger.FromContext(ctx).Debug("Fetching dimension values from cache") query.Dimensions[dimensionKey] = cachedDimensions.([]string) continue } - logger.Debug("Cache miss, fetching dimension values from AWS") + e.logger.FromContext(ctx).Debug("Cache miss, fetching dimension values from AWS") request := resources.DimensionValuesRequest{ ResourceRequest: &resources.ResourceRequest{ Region: region, diff --git a/pkg/tsdb/cloudwatch/get_dimension_values_for_wildcards_test.go b/pkg/tsdb/cloudwatch/get_dimension_values_for_wildcards_test.go index e0951515152f5..7a18ba59f8c8d 100644 --- a/pkg/tsdb/cloudwatch/get_dimension_values_for_wildcards_test.go +++ b/pkg/tsdb/cloudwatch/get_dimension_values_for_wildcards_test.go @@ -3,11 +3,9 @@ package cloudwatch import ( "context" "testing" - "time" "github.com/aws/aws-sdk-go/service/cloudwatch" - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/infra/log/logtest" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" @@ -16,19 +14,15 @@ import ( ) func TestGetDimensionValuesForWildcards(t *testing.T) { - logger := &logtest.Fake{} - executor := &cloudWatchExecutor{} + executor := &cloudWatchExecutor{im: defaultTestInstanceManager(), logger: log.NewNullLogger()} ctx := context.Background() - pluginCtx := backend.PluginContext{ - DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ID: 1, Updated: time.Now()}, - } tagValueCache := cache.New(0, 0) t.Run("Should not change non-wildcard dimension value", func(t *testing.T) { query := getBaseQuery() query.MetricName = "Test_MetricName1" query.Dimensions = map[string][]string{"Test_DimensionName1": {"Value1"}} - queries, err := executor.getDimensionValuesForWildcards(ctx, pluginCtx, "us-east-1", nil, []*models.CloudWatchQuery{query}, tagValueCache, logger) + queries, err := executor.getDimensionValuesForWildcards(ctx, "us-east-1", nil, []*models.CloudWatchQuery{query}, tagValueCache, 50) assert.Nil(t, err) assert.Len(t, queries, 1) assert.NotNil(t, queries[0].Dimensions["Test_DimensionName1"], 1) @@ -39,7 +33,7 @@ func TestGetDimensionValuesForWildcards(t *testing.T) { query := getBaseQuery() query.MetricName = "Test_MetricName1" query.Dimensions = map[string][]string{"Test_DimensionName1": {"*"}} - queries, err := executor.getDimensionValuesForWildcards(ctx, pluginCtx, "us-east-1", nil, []*models.CloudWatchQuery{query}, tagValueCache, logger) + queries, err := executor.getDimensionValuesForWildcards(ctx, "us-east-1", nil, []*models.CloudWatchQuery{query}, tagValueCache, 50) assert.Nil(t, err) assert.Len(t, queries, 1) assert.NotNil(t, queries[0].Dimensions["Test_DimensionName1"]) @@ -58,7 +52,7 @@ func TestGetDimensionValuesForWildcards(t *testing.T) { {MetricName: utils.Pointer("Test_MetricName4"), Dimensions: []*cloudwatch.Dimension{{Name: utils.Pointer("Test_DimensionName1"), Value: utils.Pointer("Value2")}}}, }} api.On("ListMetricsPagesWithContext").Return(nil) - queries, err := executor.getDimensionValuesForWildcards(ctx, pluginCtx, "us-east-1", api, []*models.CloudWatchQuery{query}, tagValueCache, logger) + queries, err := executor.getDimensionValuesForWildcards(ctx, "us-east-1", api, []*models.CloudWatchQuery{query}, tagValueCache, 50) assert.Nil(t, err) assert.Len(t, queries, 1) assert.Equal(t, map[string][]string{"Test_DimensionName1": {"Value1", "Value2", "Value3", "Value4"}}, queries[0].Dimensions) @@ -74,13 +68,13 @@ func TestGetDimensionValuesForWildcards(t *testing.T) { {MetricName: utils.Pointer("Test_MetricName"), Dimensions: []*cloudwatch.Dimension{{Name: utils.Pointer("Test_DimensionName"), Value: utils.Pointer("Value")}}}, }} api.On("ListMetricsPagesWithContext").Return(nil) - _, err := executor.getDimensionValuesForWildcards(ctx, pluginCtx, "us-east-1", api, []*models.CloudWatchQuery{query}, tagValueCache, logger) + _, err := executor.getDimensionValuesForWildcards(ctx, "us-east-1", api, []*models.CloudWatchQuery{query}, tagValueCache, 50) assert.Nil(t, err) // make sure the original query wasn't altered assert.Equal(t, map[string][]string{"Test_DimensionName": {"*"}}, query.Dimensions) //setting the api to nil confirms that it's using the cached value - queries, err := executor.getDimensionValuesForWildcards(ctx, pluginCtx, "us-east-1", nil, []*models.CloudWatchQuery{query}, tagValueCache, logger) + queries, err := executor.getDimensionValuesForWildcards(ctx, "us-east-1", nil, []*models.CloudWatchQuery{query}, tagValueCache, 50) assert.Nil(t, err) assert.Len(t, queries, 1) assert.Equal(t, map[string][]string{"Test_DimensionName": {"Value"}}, queries[0].Dimensions) @@ -94,7 +88,7 @@ func TestGetDimensionValuesForWildcards(t *testing.T) { query.MatchExact = false api := &mocks.MetricsAPI{Metrics: []*cloudwatch.Metric{}} api.On("ListMetricsPagesWithContext").Return(nil) - queries, err := executor.getDimensionValuesForWildcards(ctx, pluginCtx, "us-east-1", api, []*models.CloudWatchQuery{query}, tagValueCache, logger) + queries, err := executor.getDimensionValuesForWildcards(ctx, "us-east-1", api, []*models.CloudWatchQuery{query}, tagValueCache, 50) assert.Nil(t, err) assert.Len(t, queries, 1) // assert that the values was set to an empty array @@ -105,7 +99,7 @@ func TestGetDimensionValuesForWildcards(t *testing.T) { {MetricName: utils.Pointer("Test_MetricName"), Dimensions: []*cloudwatch.Dimension{{Name: utils.Pointer("Test_DimensionName2"), Value: utils.Pointer("Value")}}}, } api.On("ListMetricsPagesWithContext").Return(nil) - queries, err = executor.getDimensionValuesForWildcards(ctx, pluginCtx, "us-east-1", api, []*models.CloudWatchQuery{query}, tagValueCache, logger) + queries, err = executor.getDimensionValuesForWildcards(ctx, "us-east-1", api, []*models.CloudWatchQuery{query}, tagValueCache, 50) assert.Nil(t, err) assert.Len(t, queries, 1) assert.Equal(t, map[string][]string{"Test_DimensionName2": {"Value"}}, queries[0].Dimensions) diff --git a/pkg/tsdb/cloudwatch/get_metric_data_executor.go b/pkg/tsdb/cloudwatch/get_metric_data_executor.go index c81688b40dac0..de89b89af2d4b 100644 --- a/pkg/tsdb/cloudwatch/get_metric_data_executor.go +++ b/pkg/tsdb/cloudwatch/get_metric_data_executor.go @@ -6,7 +6,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/aws/aws-sdk-go/service/cloudwatch/cloudwatchiface" - "github.com/grafana/grafana/pkg/infra/metrics" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" ) func (e *cloudWatchExecutor) executeRequest(ctx context.Context, client cloudwatchiface.CloudWatchAPI, @@ -24,8 +24,7 @@ func (e *cloudWatchExecutor) executeRequest(ctx context.Context, client cloudwat } mdo = append(mdo, resp) - metrics.MAwsCloudWatchGetMetricData.Add(float64(len(metricDataInput.MetricDataQueries))) - + utils.QueriesTotalCounter.WithLabelValues(utils.GetMetricDataLabel).Add(float64(len(metricDataInput.MetricDataQueries))) if resp.NextToken == nil || *resp.NextToken == "" { break } diff --git a/pkg/tsdb/cloudwatch/get_metric_query_batches.go b/pkg/tsdb/cloudwatch/get_metric_query_batches.go index 2b4f0b47667b0..71d87292d999a 100644 --- a/pkg/tsdb/cloudwatch/get_metric_query_batches.go +++ b/pkg/tsdb/cloudwatch/get_metric_query_batches.go @@ -3,7 +3,7 @@ package cloudwatch import ( "regexp" - "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" ) diff --git a/pkg/tsdb/cloudwatch/get_metric_query_batches_test.go b/pkg/tsdb/cloudwatch/get_metric_query_batches_test.go index f00fb982856e0..a5668ba6a453b 100644 --- a/pkg/tsdb/cloudwatch/get_metric_query_batches_test.go +++ b/pkg/tsdb/cloudwatch/get_metric_query_batches_test.go @@ -3,13 +3,13 @@ package cloudwatch import ( "testing" - "github.com/grafana/grafana/pkg/infra/log/logtest" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/stretchr/testify/assert" ) func TestGetMetricQueryBatches(t *testing.T) { - logger := &logtest.Fake{} + nullLogger := log.NewNullLogger() insight1 := models.CloudWatchQuery{ MetricQueryType: models.MetricQueryTypeQuery, Id: "i1", @@ -86,7 +86,7 @@ func TestGetMetricQueryBatches(t *testing.T) { &m88_ref_m98, } - result := getMetricQueryBatches(batch, logger) + result := getMetricQueryBatches(batch, nullLogger) assert.Len(t, result, 3) assert.ElementsMatch(t, []*models.CloudWatchQuery{&insight1}, result[0]) assert.ElementsMatch(t, []*models.CloudWatchQuery{&insight2}, result[1]) @@ -102,7 +102,7 @@ func TestGetMetricQueryBatches(t *testing.T) { &m4_ref_s1, } - result := getMetricQueryBatches(batch, logger) + result := getMetricQueryBatches(batch, nullLogger) assert.Len(t, result, 1) assert.Equal(t, batch, result[0]) }) @@ -113,7 +113,7 @@ func TestGetMetricQueryBatches(t *testing.T) { &metricStat, } - result := getMetricQueryBatches(batch, logger) + result := getMetricQueryBatches(batch, nullLogger) assert.Len(t, result, 1) assert.ElementsMatch(t, batch, result[0]) }) @@ -125,7 +125,7 @@ func TestGetMetricQueryBatches(t *testing.T) { &insight2, } - result := getMetricQueryBatches(batch, logger) + result := getMetricQueryBatches(batch, nullLogger) assert.Len(t, result, 3) assert.ElementsMatch(t, []*models.CloudWatchQuery{&insight1}, result[0]) assert.ElementsMatch(t, []*models.CloudWatchQuery{&metricStat}, result[1]) @@ -142,7 +142,7 @@ func TestGetMetricQueryBatches(t *testing.T) { &m4_ref_s1, } - result := getMetricQueryBatches(batch, logger) + result := getMetricQueryBatches(batch, nullLogger) assert.Len(t, result, 1) assert.ElementsMatch(t, batch, result[0]) }) @@ -157,7 +157,7 @@ func TestGetMetricQueryBatches(t *testing.T) { &m4_ref_s1, } - result := getMetricQueryBatches(batch, logger) + result := getMetricQueryBatches(batch, nullLogger) assert.Len(t, result, 3) assert.ElementsMatch(t, []*models.CloudWatchQuery{&insight2}, result[0]) assert.ElementsMatch(t, []*models.CloudWatchQuery{&insight1, &m1_ref_i1, &m2_ref_i1, &m3_ref_m1_m2}, result[1]) @@ -172,7 +172,7 @@ func TestGetMetricQueryBatches(t *testing.T) { &m4_ref_i1_i3, } - result := getMetricQueryBatches(batch, logger) + result := getMetricQueryBatches(batch, nullLogger) assert.Len(t, result, 3) assert.ElementsMatch(t, []*models.CloudWatchQuery{&insight2}, result[0]) assert.ElementsMatch(t, []*models.CloudWatchQuery{&insight1, &m1_ref_i1}, result[1]) diff --git a/pkg/tsdb/cloudwatch/log_actions.go b/pkg/tsdb/cloudwatch/log_actions.go index 7da3635cfd49e..416aaa676cc05 100644 --- a/pkg/tsdb/cloudwatch/log_actions.go +++ b/pkg/tsdb/cloudwatch/log_actions.go @@ -16,11 +16,10 @@ import ( "github.com/aws/aws-sdk-go/service/cloudwatchlogs/cloudwatchlogsiface" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" - "golang.org/x/sync/errgroup" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/features" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + + "golang.org/x/sync/errgroup" ) const ( @@ -41,7 +40,7 @@ func (e *AWSError) Error() string { return fmt.Sprintf("%s: %s", e.Code, e.Message) } -func (e *cloudWatchExecutor) executeLogActions(ctx context.Context, logger log.Logger, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (e *cloudWatchExecutor) executeLogActions(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() resultChan := make(chan backend.Responses, len(req.Queries)) @@ -56,7 +55,7 @@ func (e *cloudWatchExecutor) executeLogActions(ctx context.Context, logger log.L query := query eg.Go(func() error { - dataframe, err := e.executeLogAction(ectx, logger, logsQuery, query, req.PluginContext) + dataframe, err := e.executeLogAction(ectx, logsQuery, query, req.PluginContext) if err != nil { var AWSError *AWSError if errors.As(err, &AWSError) { @@ -96,7 +95,7 @@ func (e *cloudWatchExecutor) executeLogActions(ctx context.Context, logger log.L return resp, nil } -func (e *cloudWatchExecutor) executeLogAction(ctx context.Context, logger log.Logger, logsQuery models.LogsQuery, query backend.DataQuery, pluginCtx backend.PluginContext) (*data.Frame, error) { +func (e *cloudWatchExecutor) executeLogAction(ctx context.Context, logsQuery models.LogsQuery, query backend.DataQuery, pluginCtx backend.PluginContext) (*data.Frame, error) { instance, err := e.getInstance(ctx, pluginCtx) if err != nil { return nil, err @@ -115,7 +114,7 @@ func (e *cloudWatchExecutor) executeLogAction(ctx context.Context, logger log.Lo var data *data.Frame = nil switch logsQuery.Subtype { case "StartQuery": - data, err = e.handleStartQuery(ctx, logger, logsClient, logsQuery, query.TimeRange, query.RefID) + data, err = e.handleStartQuery(ctx, logsClient, logsQuery, query.TimeRange, query.RefID) case "StopQuery": data, err = e.handleStopQuery(ctx, logsClient, logsQuery) case "GetQueryResults": @@ -212,7 +211,7 @@ func (e *cloudWatchExecutor) executeStartQuery(ctx context.Context, logsClient c QueryString: aws.String(modifiedQueryString), } - if logsQuery.LogGroups != nil && len(logsQuery.LogGroups) > 0 && e.features.IsEnabled(ctx, featuremgmt.FlagCloudWatchCrossAccountQuerying) { + if logsQuery.LogGroups != nil && len(logsQuery.LogGroups) > 0 && features.IsEnabled(ctx, features.FlagCloudWatchCrossAccountQuerying) { var logGroupIdentifiers []string for _, lg := range logsQuery.LogGroups { arn := lg.Arn @@ -229,17 +228,17 @@ func (e *cloudWatchExecutor) executeStartQuery(ctx context.Context, logsClient c startQueryInput.Limit = aws.Int64(*logsQuery.Limit) } - logger.Debug("Calling startquery with context with input", "input", startQueryInput) + e.logger.FromContext(ctx).Debug("Calling startquery with context with input", "input", startQueryInput) return logsClient.StartQueryWithContext(ctx, startQueryInput) } -func (e *cloudWatchExecutor) handleStartQuery(ctx context.Context, logger log.Logger, logsClient cloudwatchlogsiface.CloudWatchLogsAPI, +func (e *cloudWatchExecutor) handleStartQuery(ctx context.Context, logsClient cloudwatchlogsiface.CloudWatchLogsAPI, logsQuery models.LogsQuery, timeRange backend.TimeRange, refID string) (*data.Frame, error) { startQueryResponse, err := e.executeStartQuery(ctx, logsClient, logsQuery, timeRange) if err != nil { var awsErr awserr.Error if errors.As(err, &awsErr) && awsErr.Code() == "LimitExceededException" { - logger.Debug("ExecuteStartQuery limit exceeded", "err", awsErr) + e.logger.FromContext(ctx).Debug("ExecuteStartQuery limit exceeded", "err", awsErr) return nil, &AWSError{Code: limitExceededException, Message: err.Error()} } return nil, err diff --git a/pkg/tsdb/cloudwatch/log_actions_test.go b/pkg/tsdb/cloudwatch/log_actions_test.go index a2219c01e0965..f0d784c60f667 100644 --- a/pkg/tsdb/cloudwatch/log_actions_test.go +++ b/pkg/tsdb/cloudwatch/log_actions_test.go @@ -14,8 +14,9 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/features" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" @@ -87,10 +88,10 @@ func TestQuery_handleGetLogEvents_passes_nil_start_and_end_times_to_GetLogEvents cli = fakeCWLogsClient{} im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, @@ -121,9 +122,9 @@ func TestQuery_GetLogEvents_returns_response_from_GetLogEvents_to_data_frame_fie return cli } im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) cli = &mocks.MockLogEvents{} cli.On("GetLogEventsWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatchlogs.GetLogEventsOutput{ @@ -205,10 +206,10 @@ func TestQuery_StartQuery(t *testing.T) { AWSDatasourceSettings: awsds.AWSDatasourceSettings{ Region: "us-east-2", }, - }}, nil + }, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, @@ -262,10 +263,10 @@ func TestQuery_StartQuery(t *testing.T) { AWSDatasourceSettings: awsds.AWSDatasourceSettings{ Region: "us-east-2", }, - }}, nil + }, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, @@ -320,9 +321,9 @@ func Test_executeStartQuery(t *testing.T) { t.Run("successfully parses information from JSON to StartQueryWithContext", func(t *testing.T) { cli = fakeCWLogsClient{} im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, @@ -356,9 +357,9 @@ func Test_executeStartQuery(t *testing.T) { t.Run("does not populate StartQueryInput.limit when no limit provided", func(t *testing.T) { cli = fakeCWLogsClient{} im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, @@ -382,11 +383,11 @@ func Test_executeStartQuery(t *testing.T) { t.Run("attaches logGroupIdentifiers if the crossAccount feature is enabled", func(t *testing.T) { cli = fakeCWLogsClient{} im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying)) + executor := newExecutor(im, log.NewNullLogger()) - _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + _, err := executor.QueryData(contextWithFeaturesEnabled(features.FlagCloudWatchCrossAccountQuerying), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, Queries: []backend.DataQuery{ { @@ -418,11 +419,11 @@ func Test_executeStartQuery(t *testing.T) { t.Run("attaches logGroupIdentifiers if the crossAccount feature is enabled and strips out trailing *", func(t *testing.T) { cli = fakeCWLogsClient{} im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying)) + executor := newExecutor(im, log.NewNullLogger()) - _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + _, err := executor.QueryData(contextWithFeaturesEnabled(features.FlagCloudWatchCrossAccountQuerying), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, Queries: []backend.DataQuery{ { @@ -454,9 +455,9 @@ func Test_executeStartQuery(t *testing.T) { t.Run("uses LogGroupNames if the cross account feature flag is not enabled, and log group names is present", func(t *testing.T) { cli = fakeCWLogsClient{} im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, Queries: []backend.DataQuery{ @@ -489,9 +490,9 @@ func Test_executeStartQuery(t *testing.T) { t.Run("ignores logGroups if feature flag is disabled even if logGroupNames is not present", func(t *testing.T) { cli = fakeCWLogsClient{} im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, Queries: []backend.DataQuery{ @@ -523,10 +524,10 @@ func Test_executeStartQuery(t *testing.T) { t.Run("it always uses logGroups when feature flag is enabled and ignores log group names", func(t *testing.T) { cli = fakeCWLogsClient{} im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying)) - _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + executor := newExecutor(im, log.NewNullLogger()) + _, err := executor.QueryData(contextWithFeaturesEnabled(features.FlagCloudWatchCrossAccountQuerying), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, Queries: []backend.DataQuery{ { @@ -588,7 +589,7 @@ func TestQuery_StopQuery(t *testing.T) { } im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) timeRange := backend.TimeRange{ @@ -596,7 +597,7 @@ func TestQuery_StopQuery(t *testing.T) { To: time.Unix(1584700643, 0), } - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, @@ -683,10 +684,10 @@ func TestQuery_GetQueryResults(t *testing.T) { } im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, diff --git a/pkg/tsdb/cloudwatch/log_sync_query_test.go b/pkg/tsdb/cloudwatch/log_sync_query_test.go index 9c02267b5d04c..f1bed59ccfaf9 100644 --- a/pkg/tsdb/cloudwatch/log_sync_query_test.go +++ b/pkg/tsdb/cloudwatch/log_sync_query_test.go @@ -15,10 +15,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/services/featuremgmt" - ngalertmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/query" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" "github.com/stretchr/testify/assert" @@ -39,14 +37,14 @@ func Test_executeSyncLogQuery(t *testing.T) { t.Run("getCWLogsClient is called with region from input JSON", func(t *testing.T) { cli = fakeCWLogsClient{queryResults: cloudwatchlogs.GetQueryResultsOutput{Status: aws.String("Complete")}} + sess := fakeSessionCache{} im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &sess}, nil }) - sess := fakeSessionCache{} - executor := newExecutor(im, newTestConfig(), &sess, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ - Headers: map[string]string{ngalertmodels.FromAlertHeaderName: "some value"}, + Headers: map[string]string{headerFromAlert: "some value"}, PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, Queries: []backend.DataQuery{ { @@ -65,14 +63,14 @@ func Test_executeSyncLogQuery(t *testing.T) { t.Run("getCWLogsClient is called with region from instance manager when region is default", func(t *testing.T) { cli = fakeCWLogsClient{queryResults: cloudwatchlogs.GetQueryResultsOutput{Status: aws.String("Complete")}} + sess := fakeSessionCache{} im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{AWSDatasourceSettings: awsds.AWSDatasourceSettings{Region: "instance manager's region"}}}, nil + return DataSource{Settings: models.CloudWatchSettings{AWSDatasourceSettings: awsds.AWSDatasourceSettings{Region: "instance manager's region"}}, sessions: &sess}, nil }) - sess := fakeSessionCache{} - executor := newExecutor(im, newTestConfig(), &sess, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ - Headers: map[string]string{ngalertmodels.FromAlertHeaderName: "some value"}, + Headers: map[string]string{headerFromAlert: "some value"}, PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, Queries: []backend.DataQuery{ { @@ -97,12 +95,12 @@ func Test_executeSyncLogQuery(t *testing.T) { }{ { "alert header", - map[string]string{ngalertmodels.FromAlertHeaderName: "some value"}, + map[string]string{headerFromAlert: "some value"}, true, }, { "expression header", - map[string]string{fmt.Sprintf("http_%s", query.HeaderFromExpression): "some value"}, + map[string]string{fmt.Sprintf("http_%s", headerFromExpression): "some value"}, true, }, { @@ -123,11 +121,10 @@ func Test_executeSyncLogQuery(t *testing.T) { syncCalled = false cli = fakeCWLogsClient{queryResults: cloudwatchlogs.GetQueryResultsOutput{Status: aws.String("Complete")}} im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{AWSDatasourceSettings: awsds.AWSDatasourceSettings{Region: "instance manager's region"}}}, nil + return DataSource{Settings: models.CloudWatchSettings{AWSDatasourceSettings: awsds.AWSDatasourceSettings{Region: "instance manager's region"}}, sessions: &fakeSessionCache{}}, nil }) - sess := fakeSessionCache{} - executor := newExecutor(im, newTestConfig(), &sess, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ Headers: tc.headers, PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, @@ -166,11 +163,10 @@ func Test_executeSyncLogQuery(t *testing.T) { cli = fakeCWLogsClient{queryResults: cloudwatchlogs.GetQueryResultsOutput{Status: aws.String("Complete")}} im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{AWSDatasourceSettings: awsds.AWSDatasourceSettings{Region: "instance manager's region"}}}, nil + return DataSource{Settings: models.CloudWatchSettings{AWSDatasourceSettings: awsds.AWSDatasourceSettings{Region: "instance manager's region"}}, sessions: &fakeSessionCache{}}, nil }) - sess := fakeSessionCache{} - executor := newExecutor(im, newTestConfig(), &sess, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, Queries: []backend.DataQuery{ @@ -207,12 +203,12 @@ func Test_executeSyncLogQuery_handles_RefId_from_input_queries(t *testing.T) { }, nil) cli.On("GetQueryResultsWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatchlogs.GetQueryResultsOutput{Status: aws.String("Complete")}, nil) im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) res, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ - Headers: map[string]string{ngalertmodels.FromAlertHeaderName: "some value"}, + Headers: map[string]string{headerFromAlert: "some value"}, PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, Queries: []backend.DataQuery{ { @@ -236,12 +232,12 @@ func Test_executeSyncLogQuery_handles_RefId_from_input_queries(t *testing.T) { }, nil) cli.On("GetQueryResultsWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatchlogs.GetQueryResultsOutput{Status: aws.String("Complete")}, nil) im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) res, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ - Headers: map[string]string{ngalertmodels.FromAlertHeaderName: "some value"}, + Headers: map[string]string{headerFromAlert: "some value"}, PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, Queries: []backend.DataQuery{ { @@ -305,12 +301,12 @@ func Test_executeSyncLogQuery_handles_RefId_from_input_queries(t *testing.T) { Status: aws.String("Complete")}, nil) im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) res, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ - Headers: map[string]string{ngalertmodels.FromAlertHeaderName: "some value"}, + Headers: map[string]string{headerFromAlert: "some value"}, PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, Queries: []backend.DataQuery{ { @@ -350,13 +346,13 @@ func Test_executeSyncLogQuery_handles_RefId_from_input_queries(t *testing.T) { }, nil) cli.On("GetQueryResultsWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatchlogs.GetQueryResultsOutput{Status: aws.String("Running")}, nil) im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{LogsTimeout: models.Duration{Duration: time.Millisecond}}}, nil + return DataSource{Settings: models.CloudWatchSettings{LogsTimeout: models.Duration{Duration: time.Millisecond}}, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ - Headers: map[string]string{ngalertmodels.FromAlertHeaderName: "some value"}, + Headers: map[string]string{headerFromAlert: "some value"}, PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, Queries: []backend.DataQuery{ { @@ -383,12 +379,12 @@ func Test_executeSyncLogQuery_handles_RefId_from_input_queries(t *testing.T) { &fakeAWSError{code: "foo", message: "bar"}, ) im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) res, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ - Headers: map[string]string{ngalertmodels.FromAlertHeaderName: "some value"}, + Headers: map[string]string{headerFromAlert: "some value"}, PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, Queries: []backend.DataQuery{ { diff --git a/pkg/tsdb/cloudwatch/metric_data_input_builder.go b/pkg/tsdb/cloudwatch/metric_data_input_builder.go index ebb667c2610fb..1cbb848258bd7 100644 --- a/pkg/tsdb/cloudwatch/metric_data_input_builder.go +++ b/pkg/tsdb/cloudwatch/metric_data_input_builder.go @@ -6,11 +6,10 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/cloudwatch" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" ) -func (e *cloudWatchExecutor) buildMetricDataInput(logger log.Logger, startTime time.Time, endTime time.Time, +func (e *cloudWatchExecutor) buildMetricDataInput(startTime time.Time, endTime time.Time, queries []*models.CloudWatchQuery) (*cloudwatch.GetMetricDataInput, error) { metricDataInput := &cloudwatch.GetMetricDataInput{ StartTime: aws.Time(startTime), @@ -27,7 +26,7 @@ func (e *cloudWatchExecutor) buildMetricDataInput(logger log.Logger, startTime t } for _, query := range queries { - metricDataQuery, err := e.buildMetricDataQuery(logger, query) + metricDataQuery, err := e.buildMetricDataQuery(query) if err != nil { return nil, &models.QueryError{Err: err, RefID: query.RefId} } diff --git a/pkg/tsdb/cloudwatch/metric_data_input_builder_test.go b/pkg/tsdb/cloudwatch/metric_data_input_builder_test.go index 320cd8109fdf2..e891f996d2bfe 100644 --- a/pkg/tsdb/cloudwatch/metric_data_input_builder_test.go +++ b/pkg/tsdb/cloudwatch/metric_data_input_builder_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" ) @@ -27,13 +27,13 @@ func TestMetricDataInputBuilder(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(nil, log.NewNullLogger()) query := getBaseQuery() query.TimezoneUTCOffset = tc.timezoneUTCOffset from := now.Add(time.Hour * -2) to := now.Add(time.Hour * -1) - mdi, err := executor.buildMetricDataInput(logger, from, to, []*models.CloudWatchQuery{query}) + mdi, err := executor.buildMetricDataInput(from, to, []*models.CloudWatchQuery{query}) assert.NoError(t, err) require.NotNil(t, mdi) diff --git a/pkg/tsdb/cloudwatch/metric_data_query_builder.go b/pkg/tsdb/cloudwatch/metric_data_query_builder.go index 0a6fa90316b66..aa30e28f5cb68 100644 --- a/pkg/tsdb/cloudwatch/metric_data_query_builder.go +++ b/pkg/tsdb/cloudwatch/metric_data_query_builder.go @@ -9,11 +9,10 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/cloudwatch" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" ) -func (e *cloudWatchExecutor) buildMetricDataQuery(logger log.Logger, query *models.CloudWatchQuery) (*cloudwatch.MetricDataQuery, error) { +func (e *cloudWatchExecutor) buildMetricDataQuery(query *models.CloudWatchQuery) (*cloudwatch.MetricDataQuery, error) { mdq := &cloudwatch.MetricDataQuery{ Id: aws.String(query.Id), ReturnData: aws.Bool(query.ReturnData), diff --git a/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go b/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go index d075fdfd7fde3..b956a88a45f4e 100644 --- a/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go +++ b/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/aws/aws-sdk-go/aws" - "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -13,11 +13,11 @@ import ( func TestMetricDataQueryBuilder(t *testing.T) { t.Run("buildMetricDataQuery", func(t *testing.T) { t.Run("should use metric stat", func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(nil, log.NewNullLogger()) query := getBaseQuery() query.MetricEditorMode = models.MetricEditorModeBuilder query.MetricQueryType = models.MetricQueryTypeSearch - mdq, err := executor.buildMetricDataQuery(logger, query) + mdq, err := executor.buildMetricDataQuery(query) require.NoError(t, err) require.Empty(t, mdq.Expression) assert.Equal(t, query.MetricName, *mdq.MetricStat.Metric.MetricName) @@ -25,70 +25,70 @@ func TestMetricDataQueryBuilder(t *testing.T) { }) t.Run("should pass AccountId in metric stat query", func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(nil, log.NewNullLogger()) query := getBaseQuery() query.MetricEditorMode = models.MetricEditorModeBuilder query.MetricQueryType = models.MetricQueryTypeSearch query.AccountId = aws.String("some account id") - mdq, err := executor.buildMetricDataQuery(logger, query) + mdq, err := executor.buildMetricDataQuery(query) require.NoError(t, err) assert.Equal(t, "some account id", *mdq.AccountId) }) t.Run("should leave AccountId in metric stat query", func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(nil, log.NewNullLogger()) query := getBaseQuery() query.MetricEditorMode = models.MetricEditorModeBuilder query.MetricQueryType = models.MetricQueryTypeSearch - mdq, err := executor.buildMetricDataQuery(logger, query) + mdq, err := executor.buildMetricDataQuery(query) require.NoError(t, err) assert.Nil(t, mdq.AccountId) }) t.Run("should use custom built expression", func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(nil, log.NewNullLogger()) query := getBaseQuery() query.MetricEditorMode = models.MetricEditorModeBuilder query.MetricQueryType = models.MetricQueryTypeSearch query.MatchExact = false - mdq, err := executor.buildMetricDataQuery(logger, query) + mdq, err := executor.buildMetricDataQuery(query) require.NoError(t, err) require.Nil(t, mdq.MetricStat) assert.Equal(t, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization" "LoadBalancer"="lb1"', '', 300))`, *mdq.Expression) }) t.Run("should use sql expression", func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(nil, log.NewNullLogger()) query := getBaseQuery() query.MetricEditorMode = models.MetricEditorModeRaw query.MetricQueryType = models.MetricQueryTypeQuery query.SqlExpression = `SELECT SUM(CPUUTilization) FROM "AWS/EC2"` - mdq, err := executor.buildMetricDataQuery(logger, query) + mdq, err := executor.buildMetricDataQuery(query) require.NoError(t, err) require.Nil(t, mdq.MetricStat) assert.Equal(t, query.SqlExpression, *mdq.Expression) }) t.Run("should use user defined math expression", func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(nil, log.NewNullLogger()) query := getBaseQuery() query.MetricEditorMode = models.MetricEditorModeRaw query.MetricQueryType = models.MetricQueryTypeSearch query.Expression = `SUM(x+y)` - mdq, err := executor.buildMetricDataQuery(logger, query) + mdq, err := executor.buildMetricDataQuery(query) require.NoError(t, err) require.Nil(t, mdq.MetricStat) assert.Equal(t, query.Expression, *mdq.Expression) }) t.Run("should set period in user defined expression", func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(nil, log.NewNullLogger()) query := getBaseQuery() query.MetricEditorMode = models.MetricEditorModeRaw query.MetricQueryType = models.MetricQueryTypeSearch query.MatchExact = false query.Expression = `SUM([a,b])` - mdq, err := executor.buildMetricDataQuery(logger, query) + mdq, err := executor.buildMetricDataQuery(query) require.NoError(t, err) require.Nil(t, mdq.MetricStat) assert.Equal(t, int64(300), *mdq.Period) @@ -96,11 +96,11 @@ func TestMetricDataQueryBuilder(t *testing.T) { }) t.Run("should set label", func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(nil, log.NewNullLogger()) query := getBaseQuery() query.Label = "some label" - mdq, err := executor.buildMetricDataQuery(logger, query) + mdq, err := executor.buildMetricDataQuery(query) assert.NoError(t, err) require.NotNil(t, mdq.Label) @@ -108,18 +108,18 @@ func TestMetricDataQueryBuilder(t *testing.T) { }) t.Run("should not set label for empty string query label", func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(nil, log.NewNullLogger()) query := getBaseQuery() query.Label = "" - mdq, err := executor.buildMetricDataQuery(logger, query) + mdq, err := executor.buildMetricDataQuery(query) assert.NoError(t, err) assert.Nil(t, mdq.Label) }) t.Run(`should not specify accountId when it is "all"`, func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(nil, log.NewNullLogger()) query := &models.CloudWatchQuery{ Namespace: "AWS/EC2", MetricName: "CPUUtilization", @@ -129,7 +129,7 @@ func TestMetricDataQueryBuilder(t *testing.T) { AccountId: aws.String("all"), } - mdq, err := executor.buildMetricDataQuery(logger, query) + mdq, err := executor.buildMetricDataQuery(query) assert.NoError(t, err) require.Nil(t, mdq.MetricStat) @@ -137,7 +137,7 @@ func TestMetricDataQueryBuilder(t *testing.T) { }) t.Run("should set accountId when it is specified", func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(nil, log.NewNullLogger()) query := &models.CloudWatchQuery{ Namespace: "AWS/EC2", MetricName: "CPUUtilization", @@ -147,7 +147,7 @@ func TestMetricDataQueryBuilder(t *testing.T) { AccountId: aws.String("12345"), } - mdq, err := executor.buildMetricDataQuery(logger, query) + mdq, err := executor.buildMetricDataQuery(query) assert.NoError(t, err) require.Nil(t, mdq.MetricStat) diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index 2960d78de8043..8acddfbcbd06f 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -17,7 +17,6 @@ import ( "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/constants" ) type suggestData struct { @@ -40,54 +39,6 @@ func parseMultiSelectValue(input string) []string { return []string{trimmedInput} } -// Whenever this list is updated, the frontend list should also be updated. -// Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html -func (e *cloudWatchExecutor) handleGetRegions(ctx context.Context, pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) { - instance, err := e.getInstance(ctx, pluginCtx) - if err != nil { - return nil, err - } - - profile := instance.Settings.Profile - if cache, ok := e.regionCache.Load(profile); ok { - if cache2, ok2 := cache.([]suggestData); ok2 { - return cache2, nil - } - } - - client, err := e.getEC2Client(ctx, pluginCtx, defaultRegion) - if err != nil { - return nil, err - } - regions := constants.Regions() - ec2Regions, err := client.DescribeRegionsWithContext(ctx, &ec2.DescribeRegionsInput{}) - if err != nil { - // ignore error for backward compatibility - logger.Error("Failed to get regions", "error", err) - } else { - mergeEC2RegionsAndConstantRegions(regions, ec2Regions.Regions) - } - - result := make([]suggestData, 0) - for region := range regions { - result = append(result, suggestData{Text: region, Value: region, Label: region}) - } - sort.Slice(result, func(i, j int) bool { - return result[i].Text < result[j].Text - }) - e.regionCache.Store(profile, result) - - return result, nil -} - -func mergeEC2RegionsAndConstantRegions(regions map[string]struct{}, ec2Regions []*ec2.Region) { - for _, region := range ec2Regions { - if _, ok := regions[*region.RegionName]; !ok { - regions[*region.RegionName] = struct{}{} - } - } -} - func (e *cloudWatchExecutor) handleGetEbsVolumeIds(ctx context.Context, pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) { region := parameters.Get("region") instanceId := parameters.Get("instanceId") diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go index 8a44b9ab3b7b4..400c304162074 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query_test.go +++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "net/url" - "sort" "testing" "github.com/aws/aws-sdk-go/aws" @@ -12,118 +11,15 @@ import ( "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi" "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface" - "github.com/grafana/grafana-aws-sdk/pkg/awsds" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/constants" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) -func TestQuery_Regions(t *testing.T) { - origNewEC2Client := NewEC2Client - t.Cleanup(func() { - NewEC2Client = origNewEC2Client - }) - - ec2Mock := &mocks.EC2Mock{} - NewEC2Client = func(provider client.ConfigProvider) models.EC2APIProvider { - return ec2Mock - } - t.Run("An extra region", func(t *testing.T) { - const regionName = "xtra-region" - ec2Mock.On("DescribeRegionsWithContext", mock.Anything, mock.Anything).Return(&ec2.DescribeRegionsOutput{ - Regions: []*ec2.Region{ - { - RegionName: utils.Pointer(regionName), - }, - }, - }, nil) - - im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{AWSDatasourceSettings: awsds.AWSDatasourceSettings{Region: "us-east-2"}}}, nil - }) - - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) - resp, err := executor.handleGetRegions( - context.Background(), - backend.PluginContext{ - DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, - }, url.Values{ - "region": []string{"us-east-1"}, - "namespace": []string{"custom"}, - }, - ) - require.NoError(t, err) - - expRegions := buildSortedSliceOfDefaultAndExtraRegions(t, regionName) - expFrame := data.NewFrame( - "", - data.NewField("text", nil, expRegions), - data.NewField("value", nil, expRegions), - ) - expFrame.Meta = &data.FrameMeta{ - Custom: map[string]any{ - "rowCount": len(constants.Regions()) + 1, - }, - } - - expResponse := []suggestData{} - for _, region := range expRegions { - expResponse = append(expResponse, suggestData{Text: region, Value: region, Label: region}) - } - assert.Equal(t, expResponse, resp) - }) -} - -func buildSortedSliceOfDefaultAndExtraRegions(t *testing.T, regionName string) []string { - t.Helper() - regions := constants.Regions() - regions[regionName] = struct{}{} - var expRegions []string - for region := range regions { - expRegions = append(expRegions, region) - } - sort.Strings(expRegions) - return expRegions -} - -func Test_handleGetRegions_regionCache(t *testing.T) { - origNewEC2Client := NewEC2Client - t.Cleanup(func() { - NewEC2Client = origNewEC2Client - }) - cli := mockEC2Client{} - NewEC2Client = func(client.ConfigProvider) models.EC2APIProvider { - return &cli - } - im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{AWSDatasourceSettings: awsds.AWSDatasourceSettings{Region: "us-east-2"}}}, nil - }) - - t.Run("AWS only called once for multiple calls to handleGetRegions", func(t *testing.T) { - cli.On("DescribeRegionsWithContext", mock.Anything, mock.Anything).Return(&ec2.DescribeRegionsOutput{}, nil) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) - _, err := executor.handleGetRegions( - context.Background(), - backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, nil) - require.NoError(t, err) - - _, err = executor.handleGetRegions( - context.Background(), - backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, nil) - require.NoError(t, err) - - cli.AssertNumberOfCalls(t, "DescribeRegionsWithContext", 1) - }) -} func TestQuery_InstanceAttributes(t *testing.T) { origNewEC2Client := NewEC2Client t.Cleanup(func() { @@ -157,7 +53,7 @@ func TestQuery_InstanceAttributes(t *testing.T) { } im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) filterMap := map[string][]string{ @@ -166,7 +62,7 @@ func TestQuery_InstanceAttributes(t *testing.T) { filterJson, err := json.Marshal(filterMap) require.NoError(t, err) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) resp, err := executor.handleGetEc2InstanceAttribute( context.Background(), backend.PluginContext{ @@ -241,10 +137,10 @@ func TestQuery_EBSVolumeIDs(t *testing.T) { } im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) resp, err := executor.handleGetEbsVolumeIds( context.Background(), backend.PluginContext{ @@ -302,7 +198,7 @@ func TestQuery_ResourceARNs(t *testing.T) { } im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) tagMap := map[string][]string{ @@ -311,7 +207,7 @@ func TestQuery_ResourceARNs(t *testing.T) { tagJson, err := json.Marshal(tagMap) require.NoError(t, err) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) resp, err := executor.handleGetResourceArns( context.Background(), backend.PluginContext{ diff --git a/pkg/tsdb/cloudwatch/models/api.go b/pkg/tsdb/cloudwatch/models/api.go index f950d750ffd37..c54c156b12397 100644 --- a/pkg/tsdb/cloudwatch/models/api.go +++ b/pkg/tsdb/cloudwatch/models/api.go @@ -10,8 +10,7 @@ import ( "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/oam" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" ) @@ -25,7 +24,6 @@ type RequestContext struct { OAMAPIProvider OAMAPIProvider EC2APIProvider EC2APIProvider Settings CloudWatchSettings - Features featuremgmt.FeatureToggles Logger log.Logger } diff --git a/pkg/tsdb/cloudwatch/models/cloudwatch_query.go b/pkg/tsdb/cloudwatch/models/cloudwatch_query.go index ddf00f2b93933..a6775cd404db1 100644 --- a/pkg/tsdb/cloudwatch/models/cloudwatch_query.go +++ b/pkg/tsdb/cloudwatch/models/cloudwatch_query.go @@ -7,16 +7,15 @@ import ( "math" "net/url" "regexp" - "sort" "strconv" "strings" "time" "github.com/aws/aws-sdk-go/aws/endpoints" "github.com/google/uuid" - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/kinds/dataquery" ) @@ -479,22 +478,7 @@ func parseDimensions(dimensions map[string]any) (map[string][]string, error) { } } - sortedDimensions := sortDimensions(parsedDimensions) - return sortedDimensions, nil -} - -func sortDimensions(dimensions map[string][]string) map[string][]string { - sortedDimensions := make(map[string][]string, len(dimensions)) - keys := make([]string, 0, len(dimensions)) - for k := range dimensions { - keys = append(keys, k) - } - sort.Strings(keys) - - for _, k := range keys { - sortedDimensions[k] = dimensions[k] - } - return sortedDimensions + return parsedDimensions, nil } func getEndpoint(region string) string { diff --git a/pkg/tsdb/cloudwatch/models/cloudwatch_query_test.go b/pkg/tsdb/cloudwatch/models/cloudwatch_query_test.go index 3a5a885d3ea37..7256d61c62f2e 100644 --- a/pkg/tsdb/cloudwatch/models/cloudwatch_query_test.go +++ b/pkg/tsdb/cloudwatch/models/cloudwatch_query_test.go @@ -10,15 +10,15 @@ import ( "github.com/grafana/kindsys" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/kinds/dataquery" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" ) -var logger = &logtest.Fake{} +var logger = log.NewNullLogger() func TestCloudWatchQuery(t *testing.T) { t.Run("Deeplink", func(t *testing.T) { diff --git a/pkg/tsdb/cloudwatch/models/settings.go b/pkg/tsdb/cloudwatch/models/settings.go index a03fbcc9f29fe..9221fe42fb442 100644 --- a/pkg/tsdb/cloudwatch/models/settings.go +++ b/pkg/tsdb/cloudwatch/models/settings.go @@ -1,6 +1,7 @@ package models import ( + "context" "encoding/json" "fmt" "time" @@ -17,22 +18,23 @@ type CloudWatchSettings struct { Namespace string `json:"customMetricsNamespaces"` SecureSocksProxyEnabled bool `json:"enableSecureSocksProxy"` // this can be removed when https://github.com/grafana/grafana/issues/39089 is implemented LogsTimeout Duration `json:"logsTimeout"` + + // GrafanaSettings are fetched from the GrafanaCfg in the context + GrafanaSettings awsds.AuthSettings `json:"-"` } -func LoadCloudWatchSettings(config backend.DataSourceInstanceSettings) (CloudWatchSettings, error) { +func LoadCloudWatchSettings(ctx context.Context, config backend.DataSourceInstanceSettings) (CloudWatchSettings, error) { instance := CloudWatchSettings{} + if config.JSONData != nil && len(config.JSONData) > 1 { if err := json.Unmarshal(config.JSONData, &instance); err != nil { return CloudWatchSettings{}, fmt.Errorf("could not unmarshal DatasourceSettings json: %w", err) } } - if instance.Region == "default" || instance.Region == "" { - instance.Region = instance.DefaultRegion - } - - if instance.Profile == "" { - instance.Profile = config.Database + // load the instance using the loader for the wrapped awsds.AWSDatasourceSettings + if err := instance.Load(config); err != nil { + return CloudWatchSettings{}, err } // logs timeout default is 30 minutes, the same as timeout in frontend logs query @@ -41,8 +43,7 @@ func LoadCloudWatchSettings(config backend.DataSourceInstanceSettings) (CloudWat instance.LogsTimeout = Duration{30 * time.Minute} } - instance.AccessKey = config.DecryptedSecureJSONData["accessKey"] - instance.SecretKey = config.DecryptedSecureJSONData["secretKey"] + instance.GrafanaSettings = *awsds.ReadAuthSettings(ctx) return instance, nil } diff --git a/pkg/tsdb/cloudwatch/models/settings_test.go b/pkg/tsdb/cloudwatch/models/settings_test.go index 5b011b0e4914a..8b6579ac1ea0b 100644 --- a/pkg/tsdb/cloudwatch/models/settings_test.go +++ b/pkg/tsdb/cloudwatch/models/settings_test.go @@ -1,16 +1,23 @@ package models import ( + "context" "testing" "time" "github.com/grafana/grafana-aws-sdk/pkg/awsds" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_Settings_LoadCloudWatchSettings(t *testing.T) { + settingCtx := backend.WithGrafanaConfig(context.Background(), backend.NewGrafanaCfg(map[string]string{ + awsds.AllowedAuthProvidersEnvVarKeyName: "default,keys,credentials", + awsds.AssumeRoleEnabledEnvVarKeyName: "false", + awsds.SessionDurationEnvVarKeyName: "10m", + })) t.Run("Should return error for invalid json", func(t *testing.T) { settings := backend.DataSourceInstanceSettings{ ID: 33, @@ -25,7 +32,7 @@ func Test_Settings_LoadCloudWatchSettings(t *testing.T) { }, } - _, err := LoadCloudWatchSettings(settings) + _, err := LoadCloudWatchSettings(settingCtx, settings) assert.Error(t, err) }) @@ -47,7 +54,7 @@ func Test_Settings_LoadCloudWatchSettings(t *testing.T) { }, } - s, err := LoadCloudWatchSettings(settings) + s, err := LoadCloudWatchSettings(settingCtx, settings) require.NoError(t, err) assert.Equal(t, awsds.AuthTypeKeys, s.AuthType) assert.Equal(t, "arn:aws:iam::123456789012:role/grafana", s.AssumeRoleARN) @@ -78,7 +85,7 @@ func Test_Settings_LoadCloudWatchSettings(t *testing.T) { }, } - s, err := LoadCloudWatchSettings(settings) + s, err := LoadCloudWatchSettings(settingCtx, settings) require.NoError(t, err) assert.Equal(t, awsds.AuthTypeDefault, s.AuthType) assert.Equal(t, "arn:aws:iam::123456789012:role/grafana", s.AssumeRoleARN) @@ -103,7 +110,7 @@ func Test_Settings_LoadCloudWatchSettings(t *testing.T) { }, } - s, err := LoadCloudWatchSettings(settings) + s, err := LoadCloudWatchSettings(settingCtx, settings) require.NoError(t, err) assert.Equal(t, time.Minute*30, s.LogsTimeout.Duration) }) @@ -121,7 +128,7 @@ func Test_Settings_LoadCloudWatchSettings(t *testing.T) { }, } - s, err := LoadCloudWatchSettings(settings) + s, err := LoadCloudWatchSettings(settingCtx, settings) require.NoError(t, err) assert.Equal(t, time.Minute*10, s.LogsTimeout.Duration) }) @@ -139,7 +146,7 @@ func Test_Settings_LoadCloudWatchSettings(t *testing.T) { }, } - s, err := LoadCloudWatchSettings(settings) + s, err := LoadCloudWatchSettings(settingCtx, settings) require.NoError(t, err) assert.Equal(t, time.Duration(1500000000), s.LogsTimeout.Duration) }) @@ -157,7 +164,7 @@ func Test_Settings_LoadCloudWatchSettings(t *testing.T) { }, } - s, err := LoadCloudWatchSettings(settings) + s, err := LoadCloudWatchSettings(settingCtx, settings) require.NoError(t, err) assert.Equal(t, 1500*time.Millisecond, s.LogsTimeout.Duration) }) @@ -175,7 +182,7 @@ func Test_Settings_LoadCloudWatchSettings(t *testing.T) { }, } - _, err := LoadCloudWatchSettings(settings) + _, err := LoadCloudWatchSettings(context.Background(), settings) require.Error(t, err) }) t.Run("Should throw error if logsTimeout is an invalid type", func(t *testing.T) { @@ -192,7 +199,42 @@ func Test_Settings_LoadCloudWatchSettings(t *testing.T) { }, } - _, err := LoadCloudWatchSettings(settings) + _, err := LoadCloudWatchSettings(settingCtx, settings) require.Error(t, err) }) + + t.Run("Should load settings from context", func(t *testing.T) { + settingCtx := backend.WithGrafanaConfig(context.Background(), backend.NewGrafanaCfg(map[string]string{ + awsds.AllowedAuthProvidersEnvVarKeyName: "foo , bar,baz", + awsds.AssumeRoleEnabledEnvVarKeyName: "false", + awsds.SessionDurationEnvVarKeyName: "10m", + awsds.GrafanaAssumeRoleExternalIdKeyName: "mock_id", + awsds.ListMetricsPageLimitKeyName: "50", + proxy.PluginSecureSocksProxyEnabled: "true", + })) + settings := backend.DataSourceInstanceSettings{ + ID: 33, + JSONData: []byte(`{ + "authType": "arn", + "assumeRoleArn": "arn:aws:iam::123456789012:role/grafana" + }`), + DecryptedSecureJSONData: map[string]string{ + "accessKey": "AKIAIOSFODNN7EXAMPLE", + "secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + } + s, err := LoadCloudWatchSettings(settingCtx, settings) + require.NoError(t, err) + + ctxDuration := 10 * time.Minute + expectedGrafanaSettings := awsds.AuthSettings{ + AllowedAuthProviders: []string{"foo", "bar", "baz"}, + AssumeRoleEnabled: false, + SessionDuration: &ctxDuration, + ExternalID: "mock_id", + ListMetricsPageLimit: 50, + SecureSocksDSProxyEnabled: true, + } + assert.Equal(t, expectedGrafanaSettings, s.GrafanaSettings) + }) } diff --git a/pkg/tsdb/cloudwatch/resource_handler.go b/pkg/tsdb/cloudwatch/resource_handler.go index cedbcb2addc8a..9107a76cb5cdd 100644 --- a/pkg/tsdb/cloudwatch/resource_handler.go +++ b/pkg/tsdb/cloudwatch/resource_handler.go @@ -8,70 +8,62 @@ import ( "net/url" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" - - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/routes" ) func (e *cloudWatchExecutor) newResourceMux() *http.ServeMux { mux := http.NewServeMux() - mux.HandleFunc("/ebs-volume-ids", handleResourceReq(e.handleGetEbsVolumeIds)) - mux.HandleFunc("/ec2-instance-attribute", handleResourceReq(e.handleGetEc2InstanceAttribute)) - mux.HandleFunc("/resource-arns", handleResourceReq(e.handleGetResourceArns)) - mux.HandleFunc("/log-groups", routes.ResourceRequestMiddleware(routes.LogGroupsHandler, logger, e.getRequestContext)) - mux.HandleFunc("/metrics", routes.ResourceRequestMiddleware(routes.MetricsHandler, logger, e.getRequestContext)) - mux.HandleFunc("/dimension-values", routes.ResourceRequestMiddleware(routes.DimensionValuesHandler, logger, e.getRequestContext)) - mux.HandleFunc("/dimension-keys", routes.ResourceRequestMiddleware(routes.DimensionKeysHandler, logger, e.getRequestContext)) - mux.HandleFunc("/accounts", routes.ResourceRequestMiddleware(routes.AccountsHandler, logger, e.getRequestContext)) - mux.HandleFunc("/namespaces", routes.ResourceRequestMiddleware(routes.NamespacesHandler, logger, e.getRequestContext)) - mux.HandleFunc("/log-group-fields", routes.ResourceRequestMiddleware(routes.LogGroupFieldsHandler, logger, e.getRequestContext)) - mux.HandleFunc("/external-id", routes.ResourceRequestMiddleware(routes.ExternalIdHandler, logger, e.getRequestContext)) - - // feature is enabled by default, just putting behind a feature flag in case of unexpected bugs - if e.features.IsEnabledGlobally(featuremgmt.FlagCloudwatchNewRegionsHandler) { - mux.HandleFunc("/regions", routes.ResourceRequestMiddleware(routes.RegionsHandler, logger, e.getRequestContext)) - } else { - mux.HandleFunc("/regions", handleResourceReq(e.handleGetRegions)) - } - + mux.HandleFunc("/ebs-volume-ids", handleResourceReq(e.handleGetEbsVolumeIds, e.logger)) + mux.HandleFunc("/ec2-instance-attribute", handleResourceReq(e.handleGetEc2InstanceAttribute, e.logger)) + mux.HandleFunc("/resource-arns", handleResourceReq(e.handleGetResourceArns, e.logger)) + mux.HandleFunc("/log-groups", routes.ResourceRequestMiddleware(routes.LogGroupsHandler, e.logger, e.getRequestContext)) + mux.HandleFunc("/metrics", routes.ResourceRequestMiddleware(routes.MetricsHandler, e.logger, e.getRequestContext)) + mux.HandleFunc("/dimension-values", routes.ResourceRequestMiddleware(routes.DimensionValuesHandler, e.logger, e.getRequestContext)) + mux.HandleFunc("/dimension-keys", routes.ResourceRequestMiddleware(routes.DimensionKeysHandler, e.logger, e.getRequestContext)) + mux.HandleFunc("/accounts", routes.ResourceRequestMiddleware(routes.AccountsHandler, e.logger, e.getRequestContext)) + mux.HandleFunc("/namespaces", routes.ResourceRequestMiddleware(routes.NamespacesHandler, e.logger, e.getRequestContext)) + mux.HandleFunc("/log-group-fields", routes.ResourceRequestMiddleware(routes.LogGroupFieldsHandler, e.logger, e.getRequestContext)) + mux.HandleFunc("/external-id", routes.ResourceRequestMiddleware(routes.ExternalIdHandler, e.logger, e.getRequestContextOnlySettings)) + mux.HandleFunc("/regions", routes.ResourceRequestMiddleware(routes.RegionsHandler, e.logger, e.getRequestContext)) // remove this once AWS's Cross Account Observability is supported in GovCloud - mux.HandleFunc("/legacy-log-groups", handleResourceReq(e.handleGetLogGroups)) + mux.HandleFunc("/legacy-log-groups", handleResourceReq(e.handleGetLogGroups, e.logger)) return mux } type handleFn func(ctx context.Context, pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) -func handleResourceReq(handleFunc handleFn) func(rw http.ResponseWriter, req *http.Request) { +func handleResourceReq(handleFunc handleFn, logger log.Logger) func(rw http.ResponseWriter, req *http.Request) { return func(rw http.ResponseWriter, req *http.Request) { ctx := req.Context() pluginContext := httpadapter.PluginConfigFromContext(ctx) err := req.ParseForm() if err != nil { - writeResponse(rw, http.StatusBadRequest, fmt.Sprintf("unexpected error %v", err)) + writeResponse(rw, http.StatusBadRequest, fmt.Sprintf("unexpected error %v", err), logger.FromContext(ctx)) return } data, err := handleFunc(ctx, pluginContext, req.URL.Query()) if err != nil { - writeResponse(rw, http.StatusBadRequest, fmt.Sprintf("unexpected error %v", err)) + writeResponse(rw, http.StatusBadRequest, fmt.Sprintf("unexpected error %v", err), logger.FromContext(ctx)) return } body, err := json.Marshal(data) if err != nil { - writeResponse(rw, http.StatusBadRequest, fmt.Sprintf("unexpected error %v", err)) + writeResponse(rw, http.StatusBadRequest, fmt.Sprintf("unexpected error %v", err), logger.FromContext(ctx)) return } rw.WriteHeader(http.StatusOK) _, err = rw.Write(body) if err != nil { - logger.Error("Unable to write HTTP response", "error", err) + logger.FromContext(ctx).Error("Unable to write HTTP response", "error", err) return } } } -func writeResponse(rw http.ResponseWriter, code int, msg string) { +func writeResponse(rw http.ResponseWriter, code int, msg string, logger log.Logger) { rw.WriteHeader(code) _, err := rw.Write([]byte(msg)) if err != nil { diff --git a/pkg/tsdb/cloudwatch/response_parser.go b/pkg/tsdb/cloudwatch/response_parser.go index 78b7154afa749..05bb8dcd9e691 100644 --- a/pkg/tsdb/cloudwatch/response_parser.go +++ b/pkg/tsdb/cloudwatch/response_parser.go @@ -2,6 +2,7 @@ package cloudwatch import ( "fmt" + "regexp" "sort" "strings" "time" @@ -12,6 +13,9 @@ import ( "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" ) +// matches a dynamic label +var dynamicLabel = regexp.MustCompile(`\$\{.+\}`) + func (e *cloudWatchExecutor) parseResponse(startTime time.Time, endTime time.Time, metricDataOutputs []*cloudwatch.GetMetricDataOutput, queries []*models.CloudWatchQuery) ([]*responseWrapper, error) { aggregatedResponse := aggregateResponse(metricDataOutputs) @@ -110,6 +114,8 @@ func getLabels(cloudwatchLabel string, query *models.CloudWatchQuery) data.Label func buildDataFrames(startTime time.Time, endTime time.Time, aggregatedResponse models.QueryRowResponse, query *models.CloudWatchQuery) (data.Frames, error) { frames := data.Frames{} + hasStaticLabel := query.Label != "" && !dynamicLabel.MatchString(query.Label) + for _, metric := range aggregatedResponse.Metrics { label := *metric.Label @@ -169,10 +175,15 @@ func buildDataFrames(startTime time.Time, endTime time.Time, aggregatedResponse timeField := data.NewField(data.TimeSeriesTimeFieldName, nil, timestamps) valueField := data.NewField(data.TimeSeriesValueFieldName, labels, points) - valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: label, Links: createDataLinks(deepLink)}) + name := label + // CloudWatch appends the dimensions to the returned label if the query label is not dynamic, so static labels need to be set + if hasStaticLabel { + name = query.Label + } + valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: name, Links: createDataLinks(deepLink)}) frame := data.Frame{ - Name: label, + Name: name, Fields: []*data.Field{ timeField, valueField, diff --git a/pkg/tsdb/cloudwatch/response_parser_test.go b/pkg/tsdb/cloudwatch/response_parser_test.go index 1701bcce63494..5cdedf4296775 100644 --- a/pkg/tsdb/cloudwatch/response_parser_test.go +++ b/pkg/tsdb/cloudwatch/response_parser_test.go @@ -376,6 +376,82 @@ func Test_buildDataFrames_uses_response_label_as_frame_name(t *testing.T) { assert.Equal(t, "some label", frames[0].Name) }) + t.Run("when non-static label set on query", func(t *testing.T) { + timestamp := time.Unix(0, 0) + response := &models.QueryRowResponse{ + Metrics: []*cloudwatch.MetricDataResult{ + { + Id: aws.String("lb3"), + Label: aws.String("some label"), + Timestamps: []*time.Time{ + aws.Time(timestamp), + }, + Values: []*float64{aws.Float64(23)}, + StatusCode: aws.String("Complete"), + }, + }, + } + + query := &models.CloudWatchQuery{ + RefId: "refId1", + Region: "us-east-1", + Namespace: "AWS/ApplicationELB", + MetricName: "TargetResponseTime", + Dimensions: map[string][]string{ + "LoadBalancer": {"lb1"}, + "InstanceType": {"micro"}, + "Resource": {"res"}, + }, + Statistic: "Average", + Period: 60, + MetricQueryType: models.MetricQueryTypeQuery, + MetricEditorMode: models.MetricEditorModeBuilder, + Label: "set ${AVG} label", + } + frames, err := buildDataFrames(startTime, endTime, *response, query) + require.NoError(t, err) + + assert.Equal(t, "some label", frames[0].Name) + }) + + t.Run("unless static label set on query", func(t *testing.T) { + timestamp := time.Unix(0, 0) + response := &models.QueryRowResponse{ + Metrics: []*cloudwatch.MetricDataResult{ + { + Id: aws.String("lb3"), + Label: aws.String("some label"), + Timestamps: []*time.Time{ + aws.Time(timestamp), + }, + Values: []*float64{aws.Float64(23)}, + StatusCode: aws.String("Complete"), + }, + }, + } + + query := &models.CloudWatchQuery{ + RefId: "refId1", + Region: "us-east-1", + Namespace: "AWS/ApplicationELB", + MetricName: "TargetResponseTime", + Dimensions: map[string][]string{ + "LoadBalancer": {"lb1"}, + "InstanceType": {"micro"}, + "Resource": {"res"}, + }, + Statistic: "Average", + Period: 60, + MetricQueryType: models.MetricQueryTypeQuery, + MetricEditorMode: models.MetricEditorModeBuilder, + Label: "actual", + } + frames, err := buildDataFrames(startTime, endTime, *response, query) + require.NoError(t, err) + + assert.Equal(t, "actual", frames[0].Name) + }) + t.Run("Parse cloudwatch response", func(t *testing.T) { timestamp := time.Unix(0, 0) response := &models.QueryRowResponse{ diff --git a/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go b/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go index 42dd3b360ab83..d48887142ce6b 100644 --- a/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go +++ b/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go @@ -9,18 +9,18 @@ import ( "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/services" ) -var logger = &logtest.Fake{} +var logger = log.NewNullLogger() func Test_DimensionKeys_Route(t *testing.T) { t.Run("calls FilterDimensionKeysRequest when a StandardDimensionKeysRequest is passed", func(t *testing.T) { diff --git a/pkg/tsdb/cloudwatch/routes/external_id.go b/pkg/tsdb/cloudwatch/routes/external_id.go index 8be23aeccf8fd..159f8cd81fbea 100644 --- a/pkg/tsdb/cloudwatch/routes/external_id.go +++ b/pkg/tsdb/cloudwatch/routes/external_id.go @@ -5,9 +5,7 @@ import ( "encoding/json" "net/http" "net/url" - "os" - "github.com/grafana/grafana-aws-sdk/pkg/awsds" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" ) @@ -16,9 +14,14 @@ type ExternalIdResponse struct { ExternalId string `json:"externalId"` } -func ExternalIdHandler(ctx context.Context, pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, parameters url.Values) ([]byte, *models.HttpError) { +func ExternalIdHandler(ctx context.Context, pluginCtx backend.PluginContext, reqCtxBeforeAuth models.RequestContextFactoryFunc, parameters url.Values) ([]byte, *models.HttpError) { + reqCtx, err := reqCtxBeforeAuth(ctx, pluginCtx, "") + if err != nil { + return nil, models.NewHttpError("error in ExternalIdHandler", http.StatusInternalServerError, err) + } + response := ExternalIdResponse{ - ExternalId: os.Getenv(awsds.GrafanaAssumeRoleExternalIdKeyName), + ExternalId: reqCtx.Settings.GrafanaSettings.ExternalID, } jsonResponse, err := json.Marshal(response) if err != nil { diff --git a/pkg/tsdb/cloudwatch/routes/external_id_test.go b/pkg/tsdb/cloudwatch/routes/external_id_test.go index b69df5f7177d4..116378e09c6cb 100644 --- a/pkg/tsdb/cloudwatch/routes/external_id_test.go +++ b/pkg/tsdb/cloudwatch/routes/external_id_test.go @@ -1,19 +1,30 @@ package routes import ( + "context" "net/http" "net/http/httptest" "testing" + "github.com/grafana/grafana-aws-sdk/pkg/awsds" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/stretchr/testify/assert" ) func Test_external_id_route(t *testing.T) { - t.Run("successfully returns an external id from the env", func(t *testing.T) { + t.Run("successfully returns an external id from the instance", func(t *testing.T) { t.Setenv("AWS_AUTH_EXTERNAL_ID", "mock-external-id") rr := httptest.NewRecorder() - handler := http.HandlerFunc(ResourceRequestMiddleware(ExternalIdHandler, logger, nil)) + factoryFunc := func(_ context.Context, _ backend.PluginContext, region string) (reqCtx models.RequestContext, err error) { + return models.RequestContext{ + Settings: models.CloudWatchSettings{ + GrafanaSettings: awsds.AuthSettings{ExternalID: "mock-external-id"}, + }, + }, nil + } + handler := http.HandlerFunc(ResourceRequestMiddleware(ExternalIdHandler, logger, factoryFunc)) req := httptest.NewRequest("GET", "/external-id", nil) handler.ServeHTTP(rr, req) @@ -25,7 +36,12 @@ func Test_external_id_route(t *testing.T) { t.Run("returns an empty string if there is no external id", func(t *testing.T) { rr := httptest.NewRecorder() - handler := http.HandlerFunc(ResourceRequestMiddleware(ExternalIdHandler, logger, nil)) + factoryFunc := func(_ context.Context, _ backend.PluginContext, region string) (reqCtx models.RequestContext, err error) { + return models.RequestContext{ + Settings: models.CloudWatchSettings{}, + }, nil + } + handler := http.HandlerFunc(ResourceRequestMiddleware(ExternalIdHandler, logger, factoryFunc)) req := httptest.NewRequest("GET", "/external-id", nil) handler.ServeHTTP(rr, req) diff --git a/pkg/tsdb/cloudwatch/routes/log_group_fields_test.go b/pkg/tsdb/cloudwatch/routes/log_group_fields_test.go index 70e13b710359e..e1c1de40eb458 100644 --- a/pkg/tsdb/cloudwatch/routes/log_group_fields_test.go +++ b/pkg/tsdb/cloudwatch/routes/log_group_fields_test.go @@ -11,16 +11,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" ) func TestLogGroupFieldsRoute(t *testing.T) { - mockFeatures := featuremgmt.WithFeatures() reqCtxFunc := func(_ context.Context, pluginCtx backend.PluginContext, region string) (reqCtx models.RequestContext, err error) { - return models.RequestContext{Features: mockFeatures}, err + return models.RequestContext{}, err } t.Run("returns 400 if an invalid LogGroupFieldsRequest is used", func(t *testing.T) { rr := httptest.NewRecorder() diff --git a/pkg/tsdb/cloudwatch/routes/log_groups.go b/pkg/tsdb/cloudwatch/routes/log_groups.go index 92b2cd6039ea1..34c9f35ab018d 100644 --- a/pkg/tsdb/cloudwatch/routes/log_groups.go +++ b/pkg/tsdb/cloudwatch/routes/log_groups.go @@ -7,8 +7,7 @@ import ( "net/url" "github.com/grafana/grafana-plugin-sdk-go/backend" - - "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/features" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/services" @@ -47,5 +46,5 @@ var newLogGroupsService = func(ctx context.Context, pluginCtx backend.PluginCont return nil, err } - return services.NewLogGroupsService(reqCtx.LogsAPIProvider, reqCtx.Features.IsEnabled(ctx, featuremgmt.FlagCloudWatchCrossAccountQuerying)), nil + return services.NewLogGroupsService(reqCtx.LogsAPIProvider, features.IsEnabled(ctx, features.FlagCloudWatchCrossAccountQuerying)), nil } diff --git a/pkg/tsdb/cloudwatch/routes/log_groups_test.go b/pkg/tsdb/cloudwatch/routes/log_groups_test.go index fad9cc272bf55..b1f9e53037e6e 100644 --- a/pkg/tsdb/cloudwatch/routes/log_groups_test.go +++ b/pkg/tsdb/cloudwatch/routes/log_groups_test.go @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" @@ -24,9 +23,8 @@ func TestLogGroupsRoute(t *testing.T) { newLogGroupsService = origLogGroupsService }) - mockFeatures := featuremgmt.WithFeatures() reqCtxFunc := func(_ context.Context, pluginCtx backend.PluginContext, region string) (reqCtx models.RequestContext, err error) { - return models.RequestContext{Features: mockFeatures}, err + return models.RequestContext{}, err } t.Run("successfully returns 1 log group with account id", func(t *testing.T) { diff --git a/pkg/tsdb/cloudwatch/routes/middleware.go b/pkg/tsdb/cloudwatch/routes/middleware.go index 3ab5ca0816049..cf75a9cc3f35d 100644 --- a/pkg/tsdb/cloudwatch/routes/middleware.go +++ b/pkg/tsdb/cloudwatch/routes/middleware.go @@ -3,9 +3,9 @@ package routes import ( "net/http" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" ) @@ -20,7 +20,7 @@ func ResourceRequestMiddleware(handleFunc models.RouteHandlerFunc, logger log.Lo pluginContext := httpadapter.PluginConfigFromContext(ctx) json, httpError := handleFunc(ctx, pluginContext, reqCtxFactory, req.URL.Query()) if httpError != nil { - logger.Error("Error handling resource request", "error", httpError.Message) + logger.FromContext(ctx).Error("Error handling resource request", "error", httpError.Message) respondWithError(rw, httpError) return } @@ -28,7 +28,7 @@ func ResourceRequestMiddleware(handleFunc models.RouteHandlerFunc, logger log.Lo rw.Header().Set("Content-Type", "application/json") _, err := rw.Write(json) if err != nil { - logger.Error("Error handling resource request", "error", err) + logger.FromContext(ctx).Error("Error handling resource request", "error", err) respondWithError(rw, models.NewHttpError("error writing response in resource request middleware", http.StatusInternalServerError, err)) } } diff --git a/pkg/tsdb/cloudwatch/services/regions.go b/pkg/tsdb/cloudwatch/services/regions.go index 9a2f6244e00fe..2f8fc345e0738 100644 --- a/pkg/tsdb/cloudwatch/services/regions.go +++ b/pkg/tsdb/cloudwatch/services/regions.go @@ -5,7 +5,7 @@ import ( "sort" "github.com/aws/aws-sdk-go/service/ec2" - "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/constants" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" diff --git a/pkg/tsdb/cloudwatch/services/regions_test.go b/pkg/tsdb/cloudwatch/services/regions_test.go index 7a3bb36a3d6ec..bb559ea590770 100644 --- a/pkg/tsdb/cloudwatch/services/regions_test.go +++ b/pkg/tsdb/cloudwatch/services/regions_test.go @@ -5,14 +5,14 @@ import ( "testing" "github.com/aws/aws-sdk-go/service/ec2" - "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" "github.com/stretchr/testify/assert" ) -var testLogger = log.New("test logger") +var testLogger = log.New().With("logger", "test.logger") func TestRegions(t *testing.T) { t.Run("returns regions from the api and merges them with default regions", func(t *testing.T) { diff --git a/pkg/tsdb/cloudwatch/test_utils.go b/pkg/tsdb/cloudwatch/test_utils.go index fe0cd907fb3bd..9e7f531e1942f 100644 --- a/pkg/tsdb/cloudwatch/test_utils.go +++ b/pkg/tsdb/cloudwatch/test_utils.go @@ -2,6 +2,7 @@ package cloudwatch import ( "context" + "strings" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/request" @@ -16,7 +17,11 @@ import ( "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface" "github.com/grafana/grafana-aws-sdk/pkg/awsds" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" + "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/grafana/grafana-plugin-sdk-go/experimental/featuretoggles" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/patrickmn/go-cache" "github.com/stretchr/testify/mock" ) @@ -115,20 +120,6 @@ func (c *fakeCWAnnotationsClient) DescribeAlarms(params *cloudwatch.DescribeAlar return c.describeAlarmsOutput, nil } -type mockEC2Client struct { - mock.Mock -} - -func (c *mockEC2Client) DescribeRegionsWithContext(ctx aws.Context, in *ec2.DescribeRegionsInput, option ...request.Option) (*ec2.DescribeRegionsOutput, error) { - args := c.Called(in) - return args.Get(0).(*ec2.DescribeRegionsOutput), args.Error(1) -} - -func (c *mockEC2Client) DescribeInstancesPagesWithContext(ctx aws.Context, in *ec2.DescribeInstancesInput, fn func(*ec2.DescribeInstancesOutput, bool) bool, opts ...request.Option) error { - args := c.Called(in, fn) - return args.Error(0) -} - // Please use mockEC2Client above, we are slowly migrating towards using testify's mocks only type oldEC2Client struct { ec2iface.EC2API @@ -212,8 +203,21 @@ func (c fakeCheckHealthClient) GetLogGroupFieldsWithContext(ctx context.Context, return nil, nil } -func newTestConfig() *setting.Cfg { - return &setting.Cfg{AWSAllowedAuthProviders: []string{"default"}, AWSAssumeRoleEnabled: true, AWSListMetricsPageLimit: 1000} +func testInstanceManager(pageLimit int) instancemgmt.InstanceManager { + return datasource.NewInstanceManager((func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return DataSource{Settings: models.CloudWatchSettings{ + AWSDatasourceSettings: awsds.AWSDatasourceSettings{ + Region: "us-east-1", + }, + GrafanaSettings: awsds.AuthSettings{ListMetricsPageLimit: pageLimit}, + }, + sessions: &fakeSessionCache{}, + tagValueCache: cache.New(0, 0)}, nil + })) +} + +func defaultTestInstanceManager() instancemgmt.InstanceManager { + return testInstanceManager(1000) } type mockSessionCache struct { @@ -270,3 +274,9 @@ func (e fakeAWSError) Code() string { func (e fakeAWSError) Message() string { return e.message } + +func contextWithFeaturesEnabled(enabled ...string) context.Context { + featureString := strings.Join(enabled, ",") + cfg := backend.NewGrafanaCfg(map[string]string{featuretoggles.EnabledFeatures: featureString}) + return backend.WithGrafanaConfig(context.Background(), cfg) +} diff --git a/pkg/tsdb/cloudwatch/time_series_query.go b/pkg/tsdb/cloudwatch/time_series_query.go index a228f5712117b..cc6b9e772af63 100644 --- a/pkg/tsdb/cloudwatch/time_series_query.go +++ b/pkg/tsdb/cloudwatch/time_series_query.go @@ -3,13 +3,14 @@ package cloudwatch import ( "context" "fmt" + "regexp" - "github.com/grafana/grafana-plugin-sdk-go/backend" "golang.org/x/sync/errgroup" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/features" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" ) type responseWrapper struct { @@ -17,8 +18,8 @@ type responseWrapper struct { RefId string } -func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, logger log.Logger, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - logger.Debug("Executing time series query") +func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + e.logger.FromContext(ctx).Debug("Executing time series query") resp := backend.NewQueryDataResponse() if len(req.Queries) == 0 { @@ -36,8 +37,8 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, logger return nil, err } - requestQueries, err := models.ParseMetricDataQueries(req.Queries, startTime, endTime, instance.Settings.Region, logger, - e.features.IsEnabled(ctx, featuremgmt.FlagCloudWatchCrossAccountQuerying)) + requestQueries, err := models.ParseMetricDataQueries(req.Queries, startTime, endTime, instance.Settings.Region, e.logger.FromContext(ctx), + features.IsEnabled(ctx, features.FlagCloudWatchCrossAccountQuerying)) if err != nil { return nil, err } @@ -60,8 +61,8 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, logger region := r batches := [][]*models.CloudWatchQuery{regionQueries} - if e.features.IsEnabled(ctx, featuremgmt.FlagCloudWatchBatchQueries) { - batches = getMetricQueryBatches(regionQueries, logger) + if features.IsEnabled(ctx, features.FlagCloudWatchBatchQueries) { + batches = getMetricQueryBatches(regionQueries, e.logger.FromContext(ctx)) } for _, batch := range batches { @@ -69,7 +70,7 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, logger eg.Go(func() error { defer func() { if err := recover(); err != nil { - logger.Error("Execute Get Metric Data Query Panic", "error", err, "stack", log.Stack(1)) + e.logger.FromContext(ctx).Error("Execute Get Metric Data Query Panic", "error", err, "stack", utils.Stack(1)) if theErr, ok := err.(error); ok { resultChan <- &responseWrapper{ DataResponse: &backend.DataResponse{ @@ -85,7 +86,7 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, logger return err } - metricDataInput, err := e.buildMetricDataInput(logger, startTime, endTime, requestQueries) + metricDataInput, err := e.buildMetricDataInput(startTime, endTime, requestQueries) if err != nil { return err } @@ -95,11 +96,9 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, logger return err } - if e.features.IsEnabled(ctx, featuremgmt.FlagCloudWatchWildCardDimensionValues) { - requestQueries, err = e.getDimensionValuesForWildcards(ctx, req.PluginContext, region, client, requestQueries, instance.tagValueCache, logger) - if err != nil { - return err - } + requestQueries, err = e.getDimensionValuesForWildcards(ctx, region, client, requestQueries, instance.tagValueCache, instance.Settings.GrafanaSettings.ListMetricsPageLimit) + if err != nil { + return err } res, err := e.parseResponse(startTime, endTime, mdo, requestQueries) @@ -121,6 +120,7 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, logger Error: fmt.Errorf("metric request error: %q", err), } resultChan <- &responseWrapper{ + RefId: getQueryRefIdFromErrorString(err.Error(), requestQueries), DataResponse: &dataResponse, } } @@ -132,3 +132,17 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, logger return resp, nil } + +func getQueryRefIdFromErrorString(err string, queries []*models.CloudWatchQuery) string { + // error can be in format "Error in expression 'test': Invalid syntax" + // so we can find the query id or ref id between the quotations + erroredRefId := "" + + for _, query := range queries { + if regexp.MustCompile(`'`+query.RefId+`':`).MatchString(err) || regexp.MustCompile(`'`+query.Id+`':`).MatchString(err) { + erroredRefId = query.RefId + } + } + // if errorRefId is empty, it means the error concerns all queries (error metric limit exceeded, for example) + return erroredRefId +} diff --git a/pkg/tsdb/cloudwatch/time_series_query_test.go b/pkg/tsdb/cloudwatch/time_series_query_test.go index 81032778e4f55..d1a1d13b47cbe 100644 --- a/pkg/tsdb/cloudwatch/time_series_query_test.go +++ b/pkg/tsdb/cloudwatch/time_series_query_test.go @@ -14,9 +14,10 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/stretchr/testify/mock" - "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/features" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/kinds/dataquery" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" @@ -26,7 +27,7 @@ import ( ) func TestTimeSeriesQuery(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(defaultTestInstanceManager(), log.NewNullLogger()) now := time.Now() origNewCWClient := NewCWClient @@ -50,11 +51,9 @@ func TestTimeSeriesQuery(t *testing.T) { StatusCode: aws.String("Complete"), Id: aws.String("b"), Label: aws.String("NetworkIn"), Values: []*float64{aws.Float64(1.0)}, Timestamps: []*time.Time{&now}, }}}, nil) - im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil - }) + im := defaultTestInstanceManager() - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, @@ -119,7 +118,7 @@ func TestTimeSeriesQuery(t *testing.T) { }) t.Run("End time before start time should result in error", func(t *testing.T) { - _, err := executor.executeTimeSeriesQuery(context.Background(), logger, &backend.QueryDataRequest{Queries: []backend.DataQuery{{TimeRange: backend.TimeRange{ + _, err := executor.executeTimeSeriesQuery(context.Background(), &backend.QueryDataRequest{Queries: []backend.DataQuery{{TimeRange: backend.TimeRange{ From: now.Add(time.Hour * -1), To: now.Add(time.Hour * -2), }}}}) @@ -127,7 +126,7 @@ func TestTimeSeriesQuery(t *testing.T) { }) t.Run("End time equals start time should result in error", func(t *testing.T) { - _, err := executor.executeTimeSeriesQuery(context.Background(), logger, &backend.QueryDataRequest{Queries: []backend.DataQuery{{TimeRange: backend.TimeRange{ + _, err := executor.executeTimeSeriesQuery(context.Background(), &backend.QueryDataRequest{Queries: []backend.DataQuery{{TimeRange: backend.TimeRange{ From: now.Add(time.Hour * -1), To: now.Add(time.Hour * -1), }}}}) @@ -148,19 +147,18 @@ func Test_executeTimeSeriesQuery_getCWClient_is_called_once_per_region_and_GetMe return &mockMetricClient } - im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil - }) - t.Run("Queries with the same region should call GetSession with that region 1 time and call GetMetricDataWithContext 1 time", func(t *testing.T) { mockSessionCache := &mockSessionCache{} mockSessionCache.On("GetSession", mock.MatchedBy( func(config awsds.SessionConfig) bool { return config.Settings.Region == "us-east-1" })). // region from queries is asserted here Return(&session.Session{Config: &aws.Config{}}, nil).Once() + im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return DataSource{Settings: models.CloudWatchSettings{}, sessions: mockSessionCache}, nil + }) mockMetricClient = mocks.MetricsAPI{} mockMetricClient.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) - executor := newExecutor(im, newTestConfig(), mockSessionCache, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, @@ -208,10 +206,15 @@ func Test_executeTimeSeriesQuery_getCWClient_is_called_once_per_region_and_GetMe sessionCache.On("GetSession", mock.MatchedBy( func(config awsds.SessionConfig) bool { return config.Settings.Region == "us-east-2" })). Return(&session.Session{Config: &aws.Config{}}, nil, nil).Once() + + im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return DataSource{Settings: models.CloudWatchSettings{}, sessions: sessionCache}, nil + }) + mockMetricClient = mocks.MetricsAPI{} mockMetricClient.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) - executor := newExecutor(im, newTestConfig(), sessionCache, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, @@ -338,13 +341,13 @@ func Test_QueryData_timeSeriesQuery_GetMetricDataWithContext(t *testing.T) { } im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}, sessions: &fakeSessionCache{}}, nil }) t.Run("passes query label as GetMetricData label", func(t *testing.T) { api = mocks.MetricsAPI{} api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) query := newTestQuery(t, queryParameters{ Label: aws.String("${PROP('Period')} some words ${PROP('Dim.InstanceId')}"), }) @@ -383,7 +386,7 @@ func Test_QueryData_timeSeriesQuery_GetMetricDataWithContext(t *testing.T) { t.Run(name, func(t *testing.T) { api = mocks.MetricsAPI{} api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, @@ -433,10 +436,8 @@ func Test_QueryData_response_data_frame_name_is_always_response_label(t *testing Values: []*float64{aws.Float64(1.0)}, Timestamps: []*time.Time{{}}}, }}, nil) - im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil - }) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + im := defaultTestInstanceManager() + executor := newExecutor(im, log.NewNullLogger()) t.Run("where user defines search expression", func(t *testing.T) { query := newTestQuery(t, queryParameters{ @@ -588,16 +589,14 @@ func TestTimeSeriesQuery_CrossAccountQuerying(t *testing.T) { NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI { return &api } - im := datasource.NewInstanceManager(func(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: models.CloudWatchSettings{}}, nil - }) + im := defaultTestInstanceManager() t.Run("should call GetMetricDataInput with AccountId nil when no AccountId is provided", func(t *testing.T) { api = mocks.MetricsAPI{} api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying)) + executor := newExecutor(im, log.NewNullLogger()) - _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + _, err := executor.QueryData(contextWithFeaturesEnabled(features.FlagCloudWatchCrossAccountQuerying), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, }, @@ -636,7 +635,7 @@ func TestTimeSeriesQuery_CrossAccountQuerying(t *testing.T) { t.Run("should call GetMetricDataInput with AccountId nil when feature flag is false", func(t *testing.T) { api = mocks.MetricsAPI{} api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + executor := newExecutor(im, log.NewNullLogger()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, @@ -677,8 +676,8 @@ func TestTimeSeriesQuery_CrossAccountQuerying(t *testing.T) { t.Run("should call GetMetricDataInput with AccountId in a MetricStat query", func(t *testing.T) { api = mocks.MetricsAPI{} api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying)) - _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + executor := newExecutor(im, log.NewNullLogger()) + _, err := executor.QueryData(contextWithFeaturesEnabled(features.FlagCloudWatchCrossAccountQuerying), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, }, @@ -718,8 +717,8 @@ func TestTimeSeriesQuery_CrossAccountQuerying(t *testing.T) { t.Run("should GetMetricDataInput with AccountId in an inferred search expression query", func(t *testing.T) { api = mocks.MetricsAPI{} api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying)) - _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + executor := newExecutor(im, log.NewNullLogger()) + _, err := executor.QueryData(contextWithFeaturesEnabled(features.FlagCloudWatchCrossAccountQuerying), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, }, diff --git a/pkg/tsdb/cloudwatch/utils/metrics.go b/pkg/tsdb/cloudwatch/utils/metrics.go new file mode 100644 index 0000000000000..07d361ff2ef83 --- /dev/null +++ b/pkg/tsdb/cloudwatch/utils/metrics.go @@ -0,0 +1,22 @@ +package utils + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +const ( + // Labels for the metric counter query types + + ListMetricsLabel = "list_metrics" + GetMetricDataLabel = "get_metric_data" +) + +var QueriesTotalCounter = promauto.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "grafana_plugin", + Name: "aws_cloudwatch_queries_total", + Help: "Counter for AWS Queries", + }, + []string{"query_type"}, +) diff --git a/pkg/tsdb/cloudwatch/utils/utils.go b/pkg/tsdb/cloudwatch/utils/utils.go index 65a4cc8ba4dbf..551638962286f 100644 --- a/pkg/tsdb/cloudwatch/utils/utils.go +++ b/pkg/tsdb/cloudwatch/utils/utils.go @@ -1,3 +1,13 @@ package utils +import "github.com/go-stack/stack" + func Pointer[T any](arg T) *T { return &arg } + +// Stack is copied from grafana/pkg/infra/log +// TODO: maybe this should live in grafana-plugin-sdk-go? +func Stack(skip int) string { + call := stack.Caller(skip) + s := stack.Trace().TrimBelow(call).TrimRuntime() + return s.String() +} diff --git a/pkg/tsdb/elasticsearch/client/client.go b/pkg/tsdb/elasticsearch/client/client.go index 577a40f27561b..f35fba0194636 100644 --- a/pkg/tsdb/elasticsearch/client/client.go +++ b/pkg/tsdb/elasticsearch/client/client.go @@ -13,11 +13,11 @@ import ( "strings" "time" - "github.com/grafana/grafana-plugin-sdk-go/backend" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" + exp "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" ) @@ -55,7 +55,7 @@ type Client interface { } // NewClient creates a new elasticsearch client -var NewClient = func(ctx context.Context, ds *DatasourceInfo, timeRange backend.TimeRange, logger log.Logger, tracer tracing.Tracer) (Client, error) { +var NewClient = func(ctx context.Context, ds *DatasourceInfo, logger log.Logger, tracer tracing.Tracer) (Client, error) { logger = logger.New("entity", "client") ip, err := newIndexPattern(ds.Interval, ds.Database) @@ -64,19 +64,14 @@ var NewClient = func(ctx context.Context, ds *DatasourceInfo, timeRange backend. return nil, err } - indices, err := ip.GetIndices(timeRange) - if err != nil { - return nil, err - } - logger.Debug("Creating new client", "configuredFields", fmt.Sprintf("%#v", ds.ConfiguredFields), "indices", strings.Join(indices, ", "), "interval", ds.Interval, "index", ds.Database) + logger.Debug("Creating new client", "configuredFields", fmt.Sprintf("%#v", ds.ConfiguredFields), "interval", ds.Interval, "index", ds.Database) return &baseClientImpl{ logger: logger, ctx: ctx, ds: ds, configuredFields: ds.ConfiguredFields, - indices: indices, - timeRange: timeRange, + indexPattern: ip, tracer: tracer, }, nil } @@ -85,8 +80,7 @@ type baseClientImpl struct { ctx context.Context ds *DatasourceInfo configuredFields ConfiguredFields - indices []string - timeRange backend.TimeRange + indexPattern IndexPattern logger log.Logger tracer tracing.Tracer } @@ -191,6 +185,10 @@ func (c *baseClientImpl) ExecuteMultisearch(r *MultiSearchRequest) (*MultiSearch status = "cancelled" } lp := []any{"error", err, "status", status, "duration", time.Since(start), "stage", StageDatabaseRequest} + sourceErr := exp.Error{} + if errors.As(err, &sourceErr) { + lp = append(lp, "statusSource", sourceErr.Source()) + } if clientRes != nil { lp = append(lp, "statusCode", clientRes.StatusCode) } @@ -234,11 +232,16 @@ func (c *baseClientImpl) createMultiSearchRequests(searchRequests []*SearchReque multiRequests := []*multiRequest{} for _, searchReq := range searchRequests { + indices, err := c.indexPattern.GetIndices(searchReq.TimeRange) + if err != nil { + c.logger.Error("Failed to get indices from index pattern", "error", err) + continue + } mr := multiRequest{ header: map[string]any{ "search_type": "query_then_fetch", "ignore_unavailable": true, - "index": strings.Join(c.indices, ","), + "index": strings.Join(indices, ","), }, body: searchReq, interval: searchReq.Interval, diff --git a/pkg/tsdb/elasticsearch/client/client_test.go b/pkg/tsdb/elasticsearch/client/client_test.go index e394567555c6a..5f4483f2215fc 100644 --- a/pkg/tsdb/elasticsearch/client/client_test.go +++ b/pkg/tsdb/elasticsearch/client/client_test.go @@ -68,7 +68,7 @@ func TestClient_ExecuteMultisearch(t *testing.T) { To: to, } - c, err := NewClient(context.Background(), &ds, timeRange, log.New("test", "test"), tracing.InitializeTracerForTest()) + c, err := NewClient(context.Background(), &ds, log.New("test", "test"), tracing.InitializeTracerForTest()) require.NoError(t, err) require.NotNil(t, c) @@ -76,7 +76,7 @@ func TestClient_ExecuteMultisearch(t *testing.T) { ts.Close() }) - ms, err := createMultisearchForTest(t, c) + ms, err := createMultisearchForTest(t, c, timeRange) require.NoError(t, err) res, err := c.ExecuteMultisearch(ms) require.NoError(t, err) @@ -111,6 +111,79 @@ func TestClient_ExecuteMultisearch(t *testing.T) { assert.Equal(t, 200, res.Status) require.Len(t, res.Responses, 1) }) + + t.Run("Given a fake http client, 2 queries and a client with response", func(t *testing.T) { + var requestBody *bytes.Buffer + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + buf, err := io.ReadAll(r.Body) + require.NoError(t, err) + + requestBody = bytes.NewBuffer(buf) + + rw.Header().Set("Content-Type", "application/x-ndjson") + _, err = rw.Write([]byte( + `{ + "responses": [ + { + "hits": { "hits": [], "max_score": 0, "total": { "value": 4656, "relation": "eq"} }, + "status": 200 + } + ] + }`)) + require.NoError(t, err) + rw.WriteHeader(200) + })) + + configuredFields := ConfiguredFields{ + TimeField: "testtime", + LogMessageField: "line", + LogLevelField: "lvl", + } + + ds := DatasourceInfo{ + URL: ts.URL, + HTTPClient: ts.Client(), + Database: "[metrics-]YYYY.MM.DD", + ConfiguredFields: configuredFields, + Interval: "Daily", + MaxConcurrentShardRequests: 6, + IncludeFrozen: true, + XPack: true, + } + + from := time.Date(2018, 5, 15, 17, 50, 0, 0, time.UTC) + to := time.Date(2018, 5, 15, 17, 55, 0, 0, time.UTC) + timeRange := backend.TimeRange{ + From: from, + To: to, + } + + from2 := time.Date(2018, 5, 17, 17, 50, 0, 0, time.UTC) + to2 := time.Date(2018, 5, 17, 17, 55, 0, 0, time.UTC) + timeRange2 := backend.TimeRange{ + From: from2, + To: to2, + } + + c, err := NewClient(context.Background(), &ds, log.New("test", "test"), tracing.InitializeTracerForTest()) + require.NoError(t, err) + require.NotNil(t, c) + + t.Cleanup(func() { + ts.Close() + }) + + ms, err := createMultisearchWithMultipleQueriesForTest(t, c, timeRange, timeRange2) + require.NoError(t, err) + _, err = c.ExecuteMultisearch(ms) + require.NoError(t, err) + + require.NotNil(t, requestBody) + + bodyString := requestBody.String() + require.Contains(t, bodyString, "metrics-2018.05.15") + require.Contains(t, bodyString, "metrics-2018.05.17") + }) } func TestClient_Index(t *testing.T) { @@ -190,7 +263,7 @@ func TestClient_Index(t *testing.T) { To: to, } - c, err := NewClient(context.Background(), &ds, timeRange, log.New("test", "test"), tracing.InitializeTracerForTest()) + c, err := NewClient(context.Background(), &ds, log.New("test", "test"), tracing.InitializeTracerForTest()) require.NoError(t, err) require.NotNil(t, c) @@ -198,7 +271,7 @@ func TestClient_Index(t *testing.T) { ts.Close() }) - ms, err := createMultisearchForTest(t, c) + ms, err := createMultisearchForTest(t, c, timeRange) require.NoError(t, err) _, err = c.ExecuteMultisearch(ms) require.NoError(t, err) @@ -217,11 +290,11 @@ func TestClient_Index(t *testing.T) { } } -func createMultisearchForTest(t *testing.T, c Client) (*MultiSearchRequest, error) { +func createMultisearchForTest(t *testing.T, c Client, timeRange backend.TimeRange) (*MultiSearchRequest, error) { t.Helper() msb := c.MultiSearch() - s := msb.Search(15 * time.Second) + s := msb.Search(15*time.Second, timeRange) s.Agg().DateHistogram("2", "@timestamp", func(a *DateHistogramAgg, ab AggBuilder) { a.FixedInterval = "$__interval" @@ -231,3 +304,28 @@ func createMultisearchForTest(t *testing.T, c Client) (*MultiSearchRequest, erro }) return msb.Build() } + +func createMultisearchWithMultipleQueriesForTest(t *testing.T, c Client, firstTimeRange backend.TimeRange, secondTimeRange backend.TimeRange) (*MultiSearchRequest, error) { + t.Helper() + + msb := c.MultiSearch() + s1 := msb.Search(15*time.Second, firstTimeRange) + s1.Agg().DateHistogram("2", "@timestamp", func(a *DateHistogramAgg, ab AggBuilder) { + a.FixedInterval = "$__interval" + + ab.Metric("1", "avg", "@hostname", func(a *MetricAggregation) { + a.Settings["script"] = "$__interval_ms*@hostname" + }) + }) + + s2 := msb.Search(15*time.Second, secondTimeRange) + s2.Agg().DateHistogram("2", "@timestamp", func(a *DateHistogramAgg, ab AggBuilder) { + a.FixedInterval = "$__interval" + + ab.Metric("1", "avg", "@hostname", func(a *MetricAggregation) { + a.Settings["script"] = "$__interval_ms*@hostname" + }) + }) + + return msb.Build() +} diff --git a/pkg/tsdb/elasticsearch/client/index_pattern.go b/pkg/tsdb/elasticsearch/client/index_pattern.go index 0f0b2b3cee2de..aae58ec04ee72 100644 --- a/pkg/tsdb/elasticsearch/client/index_pattern.go +++ b/pkg/tsdb/elasticsearch/client/index_pattern.go @@ -18,11 +18,11 @@ const ( intervalYearly = "yearly" ) -type indexPattern interface { +type IndexPattern interface { GetIndices(timeRange backend.TimeRange) ([]string, error) } -var newIndexPattern = func(interval string, pattern string) (indexPattern, error) { +var newIndexPattern = func(interval string, pattern string) (IndexPattern, error) { if interval == noInterval { return &staticIndexPattern{indexName: pattern}, nil } diff --git a/pkg/tsdb/elasticsearch/client/models.go b/pkg/tsdb/elasticsearch/client/models.go index 43701a2295c64..fd0d5b45d9e88 100644 --- a/pkg/tsdb/elasticsearch/client/models.go +++ b/pkg/tsdb/elasticsearch/client/models.go @@ -3,6 +3,8 @@ package es import ( "encoding/json" "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" ) // SearchRequest represents a search request @@ -14,6 +16,7 @@ type SearchRequest struct { Query *Query Aggs AggArray CustomProps map[string]interface{} + TimeRange backend.TimeRange } // MarshalJSON returns the JSON encoding of the request. diff --git a/pkg/tsdb/elasticsearch/client/search_request.go b/pkg/tsdb/elasticsearch/client/search_request.go index 0b07184836ad0..354147122520f 100644 --- a/pkg/tsdb/elasticsearch/client/search_request.go +++ b/pkg/tsdb/elasticsearch/client/search_request.go @@ -3,6 +3,8 @@ package es import ( "strings" "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" ) const ( @@ -22,15 +24,17 @@ type SearchRequestBuilder struct { queryBuilder *QueryBuilder aggBuilders []AggBuilder customProps map[string]any + timeRange backend.TimeRange } // NewSearchRequestBuilder create a new search request builder -func NewSearchRequestBuilder(interval time.Duration) *SearchRequestBuilder { +func NewSearchRequestBuilder(interval time.Duration, timeRange backend.TimeRange) *SearchRequestBuilder { builder := &SearchRequestBuilder{ interval: interval, sort: make(map[string]any), customProps: make(map[string]any), aggBuilders: make([]AggBuilder, 0), + timeRange: timeRange, } return builder } @@ -39,6 +43,7 @@ func NewSearchRequestBuilder(interval time.Duration) *SearchRequestBuilder { func (b *SearchRequestBuilder) Build() (*SearchRequest, error) { sr := SearchRequest{ Index: b.index, + TimeRange: b.timeRange, Interval: b.interval, Size: b.size, Sort: b.sort, @@ -164,8 +169,8 @@ func NewMultiSearchRequestBuilder() *MultiSearchRequestBuilder { } // Search initiates and returns a new search request builder -func (m *MultiSearchRequestBuilder) Search(interval time.Duration) *SearchRequestBuilder { - b := NewSearchRequestBuilder(interval) +func (m *MultiSearchRequestBuilder) Search(interval time.Duration, timeRange backend.TimeRange) *SearchRequestBuilder { + b := NewSearchRequestBuilder(interval, timeRange) m.requestBuilders = append(m.requestBuilders, b) return b } diff --git a/pkg/tsdb/elasticsearch/client/search_request_test.go b/pkg/tsdb/elasticsearch/client/search_request_test.go index fe4046e2fab43..80113b4996ef1 100644 --- a/pkg/tsdb/elasticsearch/client/search_request_test.go +++ b/pkg/tsdb/elasticsearch/client/search_request_test.go @@ -7,14 +7,21 @@ import ( "github.com/stretchr/testify/require" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/components/simplejson" ) func TestSearchRequest(t *testing.T) { timeField := "@timestamp" + from := time.Date(2018, 5, 10, 17, 50, 0, 0, time.UTC) + to := time.Date(2018, 5, 12, 17, 55, 0, 0, time.UTC) + timeRange := backend.TimeRange{ + From: from, + To: to, + } setup := func() *SearchRequestBuilder { - return NewSearchRequestBuilder(15 * time.Second) + return NewSearchRequestBuilder(15*time.Second, timeRange) } t.Run("When building search request", func(t *testing.T) { @@ -398,9 +405,15 @@ func TestSearchRequest(t *testing.T) { } func TestMultiSearchRequest(t *testing.T) { + from := time.Date(2018, 5, 10, 17, 50, 0, 0, time.UTC) + to := time.Date(2018, 5, 12, 17, 55, 0, 0, time.UTC) + timeRange := backend.TimeRange{ + From: from, + To: to, + } t.Run("When adding one search request", func(t *testing.T) { b := NewMultiSearchRequestBuilder() - b.Search(15 * time.Second) + b.Search(15*time.Second, timeRange) t.Run("When building search request should contain one search request", func(t *testing.T) { mr, err := b.Build() @@ -411,8 +424,8 @@ func TestMultiSearchRequest(t *testing.T) { t.Run("When adding two search requests", func(t *testing.T) { b := NewMultiSearchRequestBuilder() - b.Search(15 * time.Second) - b.Search(15 * time.Second) + b.Search(15*time.Second, timeRange) + b.Search(15*time.Second, timeRange) t.Run("When building search request should contain two search requests", func(t *testing.T) { mr, err := b.Build() diff --git a/pkg/tsdb/elasticsearch/data_query.go b/pkg/tsdb/elasticsearch/data_query.go index 57f4fc9c15379..8ea4cab315c9e 100644 --- a/pkg/tsdb/elasticsearch/data_query.go +++ b/pkg/tsdb/elasticsearch/data_query.go @@ -23,20 +23,27 @@ const ( ) type elasticsearchDataQuery struct { - client es.Client - dataQueries []backend.DataQuery - logger log.Logger - ctx context.Context - tracer tracing.Tracer + client es.Client + dataQueries []backend.DataQuery + logger log.Logger + ctx context.Context + tracer tracing.Tracer + keepLabelsInResponse bool } -var newElasticsearchDataQuery = func(ctx context.Context, client es.Client, dataQuery []backend.DataQuery, logger log.Logger, tracer tracing.Tracer) *elasticsearchDataQuery { +var newElasticsearchDataQuery = func(ctx context.Context, client es.Client, req *backend.QueryDataRequest, logger log.Logger, tracer tracing.Tracer) *elasticsearchDataQuery { + _, fromAlert := req.Headers[headerFromAlert] + fromExpression := req.GetHTTPHeader(headerFromExpression) != "" + return &elasticsearchDataQuery{ client: client, - dataQueries: dataQuery, + dataQueries: req.Queries, logger: logger, ctx: ctx, tracer: tracer, + // To maintain backward compatibility, it is necessary to keep labels in responses for alerting and expressions queries. + // Historically, these labels have been used in alerting rules and transformations. + keepLabelsInResponse: fromAlert || fromExpression, } } @@ -53,9 +60,9 @@ func (e *elasticsearchDataQuery) execute() (*backend.QueryDataResponse, error) { ms := e.client.MultiSearch() - from := e.dataQueries[0].TimeRange.From.UnixNano() / int64(time.Millisecond) - to := e.dataQueries[0].TimeRange.To.UnixNano() / int64(time.Millisecond) for _, q := range queries { + from := q.TimeRange.From.UnixNano() / int64(time.Millisecond) + to := q.TimeRange.To.UnixNano() / int64(time.Millisecond) if err := e.processQuery(q, ms, from, to); err != nil { mq, _ := json.Marshal(q) e.logger.Error("Failed to process query to multisearch request builder", "error", err, "query", string(mq), "queriesLength", len(queries), "duration", time.Since(start), "stage", es.StagePrepareRequest) @@ -77,7 +84,7 @@ func (e *elasticsearchDataQuery) execute() (*backend.QueryDataResponse, error) { return errorsource.AddErrorToResponse(e.dataQueries[0].RefID, response, err), nil } - return parseResponse(e.ctx, res.Responses, queries, e.client.GetConfiguredFields(), e.logger, e.tracer) + return parseResponse(e.ctx, res.Responses, queries, e.client.GetConfiguredFields(), e.keepLabelsInResponse, e.logger, e.tracer) } func (e *elasticsearchDataQuery) processQuery(q *Query, ms *es.MultiSearchRequestBuilder, from, to int64) error { @@ -88,7 +95,7 @@ func (e *elasticsearchDataQuery) processQuery(q *Query, ms *es.MultiSearchReques } defaultTimeField := e.client.GetConfiguredFields().TimeField - b := ms.Search(q.Interval) + b := ms.Search(q.Interval, q.TimeRange) b.Size(0) filters := b.Query().Bool().Filter() filters.AddDateRangeFilter(defaultTimeField, to, from, es.DateFormatEpochMS) diff --git a/pkg/tsdb/elasticsearch/data_query_test.go b/pkg/tsdb/elasticsearch/data_query_test.go index bded5d6071da5..17381d29d9101 100644 --- a/pkg/tsdb/elasticsearch/data_query_test.go +++ b/pkg/tsdb/elasticsearch/data_query_test.go @@ -1862,6 +1862,6 @@ func executeElasticsearchDataQuery(c es.Client, body string, from, to time.Time) }, }, } - query := newElasticsearchDataQuery(context.Background(), c, dataRequest.Queries, log.New("test.logger"), tracing.InitializeTracerForTest()) + query := newElasticsearchDataQuery(context.Background(), c, &dataRequest, log.New("test.logger"), tracing.InitializeTracerForTest()) return query.execute() } diff --git a/pkg/tsdb/elasticsearch/elasticsearch.go b/pkg/tsdb/elasticsearch/elasticsearch.go index b60f392c6c606..a79a76df9181c 100644 --- a/pkg/tsdb/elasticsearch/elasticsearch.go +++ b/pkg/tsdb/elasticsearch/elasticsearch.go @@ -16,18 +16,26 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + exp "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" exphttpclient "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource/httpclient" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - ngalertmodels "github.com/grafana/grafana/pkg/services/ngalert/models" es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client" ) var eslog = log.New("tsdb.elasticsearch") +const ( + // headerFromExpression is used by data sources to identify expression queries + headerFromExpression = "X-Grafana-From-Expr" + // headerFromAlert is used by datasources to identify alert queries + headerFromAlert = "FromAlert" +) + type Service struct { httpClientProvider httpclient.Provider im instancemgmt.InstanceManager @@ -46,7 +54,7 @@ func ProvideService(httpClientProvider httpclient.Provider, tracer tracing.Trace func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { dsInfo, err := s.getDSInfo(ctx, req.PluginContext) - _, fromAlert := req.Headers[ngalertmodels.FromAlertHeaderName] + _, fromAlert := req.Headers[headerFromAlert] logger := s.logger.FromContext(ctx).New("fromAlert", fromAlert) if err != nil { @@ -54,20 +62,20 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return &backend.QueryDataResponse{}, err } - return queryData(ctx, req.Queries, dsInfo, logger, s.tracer) + return queryData(ctx, req, dsInfo, logger, s.tracer) } // separate function to allow testing the whole transformation and query flow -func queryData(ctx context.Context, queries []backend.DataQuery, dsInfo *es.DatasourceInfo, logger log.Logger, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { - if len(queries) == 0 { +func queryData(ctx context.Context, req *backend.QueryDataRequest, dsInfo *es.DatasourceInfo, logger log.Logger, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { + if len(req.Queries) == 0 { return &backend.QueryDataResponse{}, fmt.Errorf("query contains no queries") } - client, err := es.NewClient(ctx, dsInfo, queries[0].TimeRange, logger, tracer) + client, err := es.NewClient(ctx, dsInfo, logger, tracer) if err != nil { return &backend.QueryDataResponse{}, err } - query := newElasticsearchDataQuery(ctx, client, queries, logger, tracer) + query := newElasticsearchDataQuery(ctx, client, req, logger, tracer) return query.execute() } @@ -88,6 +96,8 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst httpCliOpts.SigV4.Service = "es" } + // set the default middlewars from the httpClientProvider + httpCliOpts.Middlewares = httpClientProvider.(*sdkhttpclient.Provider).Opts.Middlewares // enable experimental http client to support errors with source httpCli, err := exphttpclient.New(httpCliOpts) if err != nil { @@ -188,9 +198,10 @@ func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceReq logger := eslog.FromContext(ctx) // allowed paths for resource calls: // - empty string for fetching db version - // - ?/_mapping for fetching index mapping + // - /_mapping for fetching index mapping, e.g. requests going to `index/_mapping` // - _msearch for executing getTerms queries - if req.Path != "" && !strings.HasSuffix(req.Path, "/_mapping") && req.Path != "_msearch" { + // - _mapping for fetching "root" index mappings + if req.Path != "" && !strings.HasSuffix(req.Path, "/_mapping") && req.Path != "_msearch" && req.Path != "_mapping" { logger.Error("Invalid resource path", "path", req.Path) return fmt.Errorf("invalid resource URL: %s", req.Path) } @@ -201,21 +212,11 @@ func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceReq return err } - esUrl, err := url.Parse(ds.URL) - if err != nil { - logger.Error("Failed to parse data source URL", "error", err, "url", ds.URL) - return err - } - - resourcePath, err := url.Parse(req.Path) + esUrl, err := createElasticsearchURL(req, ds) if err != nil { - logger.Error("Failed to parse data source path", "error", err, "url", req.Path) - return err + logger.Error("Failed to create request url", "error", err, "url", ds.URL, "path", req.Path) } - // We take the path and the query-string only - esUrl.RawQuery = resourcePath.RawQuery - esUrl.Path = path.Join(esUrl.Path, resourcePath.Path) request, err := http.NewRequestWithContext(ctx, req.Method, esUrl.String(), bytes.NewBuffer(req.Body)) if err != nil { logger.Error("Failed to create request", "error", err, "url", esUrl.String()) @@ -231,6 +232,10 @@ func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceReq status = "cancelled" } lp := []any{"error", err, "status", status, "duration", time.Since(start), "stage", es.StageDatabaseRequest, "resourcePath", req.Path} + sourceErr := exp.Error{} + if errors.As(err, &sourceErr) { + lp = append(lp, "statusSource", sourceErr.Source()) + } if response != nil { lp = append(lp, "statusCode", response.StatusCode) } @@ -265,3 +270,13 @@ func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceReq Body: body, }) } + +func createElasticsearchURL(req *backend.CallResourceRequest, ds *es.DatasourceInfo) (*url.URL, error) { + esUrl, err := url.Parse(ds.URL) + if err != nil { + return nil, fmt.Errorf("failed to parse data source URL: %s, error: %w", ds.URL, err) + } + + esUrl.Path = path.Join(esUrl.Path, req.Path) + return esUrl, nil +} diff --git a/pkg/tsdb/elasticsearch/elasticsearch_test.go b/pkg/tsdb/elasticsearch/elasticsearch_test.go index 0434a79ae109a..13a6aa79c71af 100644 --- a/pkg/tsdb/elasticsearch/elasticsearch_test.go +++ b/pkg/tsdb/elasticsearch/elasticsearch_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/httpclient" + es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client" ) type datasourceInfo struct { @@ -71,3 +72,32 @@ func TestNewInstanceSettings(t *testing.T) { }) }) } + +func TestCreateElasticsearchURL(t *testing.T) { + tt := []struct { + name string + settings es.DatasourceInfo + req backend.CallResourceRequest + expected string + }{ + {name: "with /_msearch path and valid url", settings: es.DatasourceInfo{URL: "http://localhost:9200"}, req: backend.CallResourceRequest{Path: "_msearch"}, expected: "http://localhost:9200/_msearch"}, + {name: "with _msearch path and valid url", settings: es.DatasourceInfo{URL: "http://localhost:9200"}, req: backend.CallResourceRequest{Path: "_msearch"}, expected: "http://localhost:9200/_msearch"}, + {name: "with _msearch path and valid url with /", settings: es.DatasourceInfo{URL: "http://localhost:9200/"}, req: backend.CallResourceRequest{Path: "_msearch"}, expected: "http://localhost:9200/_msearch"}, + {name: "with _mapping path and valid url", settings: es.DatasourceInfo{URL: "http://localhost:9200"}, req: backend.CallResourceRequest{Path: "/_mapping"}, expected: "http://localhost:9200/_mapping"}, + {name: "with /_mapping path and valid url", settings: es.DatasourceInfo{URL: "http://localhost:9200"}, req: backend.CallResourceRequest{Path: "/_mapping"}, expected: "http://localhost:9200/_mapping"}, + {name: "with /_mapping path and valid url with /", settings: es.DatasourceInfo{URL: "http://localhost:9200/"}, req: backend.CallResourceRequest{Path: "/_mapping"}, expected: "http://localhost:9200/_mapping"}, + {name: "with abc/_mapping path and valid url", settings: es.DatasourceInfo{URL: "http://localhost:9200"}, req: backend.CallResourceRequest{Path: "abc/_mapping"}, expected: "http://localhost:9200/abc/_mapping"}, + {name: "with /abc/_mapping path and valid url", settings: es.DatasourceInfo{URL: "http://localhost:9200"}, req: backend.CallResourceRequest{Path: "abc/_mapping"}, expected: "http://localhost:9200/abc/_mapping"}, + {name: "with /abc/_mapping path and valid url", settings: es.DatasourceInfo{URL: "http://localhost:9200/"}, req: backend.CallResourceRequest{Path: "abc/_mapping"}, expected: "http://localhost:9200/abc/_mapping"}, + // This is to support mappings to cross cluster search that includes ":" + {name: "with path including :", settings: es.DatasourceInfo{URL: "http://localhost:9200/"}, req: backend.CallResourceRequest{Path: "ab:c/_mapping"}, expected: "http://localhost:9200/ab:c/_mapping"}, + } + + for _, test := range tt { + t.Run(test.name, func(t *testing.T) { + url, err := createElasticsearchURL(&test.req, &test.settings) + require.NoError(t, err) + require.Equal(t, test.expected, url.String()) + }) + } +} diff --git a/pkg/tsdb/elasticsearch/healthcheck.go b/pkg/tsdb/elasticsearch/healthcheck.go new file mode 100644 index 0000000000000..9422b87fc8180 --- /dev/null +++ b/pkg/tsdb/elasticsearch/healthcheck.go @@ -0,0 +1,107 @@ +package elasticsearch + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "path" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" +) + +func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + logger := eslog.FromContext(ctx) + + ds, err := s.getDSInfo(ctx, req.PluginContext) + if err != nil { + logger.Error("Failed to get data source info", "error", err) + return &backend.CheckHealthResult{ + Status: backend.HealthStatusUnknown, + Message: "Failed to get data source info", + }, err + } + + esUrl, err := url.Parse(ds.URL) + if err != nil { + logger.Error("Failed to parse data source URL", "error", err, "url", ds.URL) + return &backend.CheckHealthResult{ + Status: backend.HealthStatusUnknown, + Message: "Failed to parse data source URL", + }, err + } + + esUrl.Path = path.Join(esUrl.Path, "_cluster/health") + esUrl.RawQuery = "wait_for_status=yellow" + + request, err := http.NewRequestWithContext(ctx, "GET", esUrl.String(), nil) + if err != nil { + logger.Error("Failed to create request", "error", err, "url", esUrl.String()) + return &backend.CheckHealthResult{ + Status: backend.HealthStatusUnknown, + Message: "Failed to create request", + }, err + } + + start := time.Now() + logger.Debug("Sending healthcheck request to Elasticsearch", "url", esUrl.String()) + response, err := ds.HTTPClient.Do(request) + + if err != nil { + logger.Error("Failed to do healthcheck request", "error", err, "url", esUrl.String()) + return &backend.CheckHealthResult{ + Status: backend.HealthStatusUnknown, + Message: "Failed to do healthcheck request", + }, err + } + + if response.StatusCode == http.StatusRequestTimeout { + return &backend.CheckHealthResult{ + Status: backend.HealthStatusError, + Message: "Elasticsearch data source is not healthy", + }, nil + } + + logger.Info("Response received from Elasticsearch", "statusCode", response.StatusCode, "status", "ok", "duration", time.Since(start)) + + defer func() { + if err := response.Body.Close(); err != nil { + logger.Warn("Failed to close response body", "error", err) + } + }() + + body, err := io.ReadAll(response.Body) + if err != nil { + logger.Error("Error reading response body bytes", "error", err) + return &backend.CheckHealthResult{ + Status: backend.HealthStatusUnknown, + Message: "Failed to read response", + }, err + } + + jsonData := map[string]any{} + + err = json.Unmarshal(body, &jsonData) + if err != nil { + logger.Error("Error during json unmarshal of the body", "error", err) + return &backend.CheckHealthResult{ + Status: backend.HealthStatusUnknown, + Message: "Failed to unmarshal response", + }, err + } + + status := backend.HealthStatusOk + message := "Elasticsearch data source is healthy" + + if jsonData["status"] == "red" { + status = backend.HealthStatusError + message = "Elasticsearch data source is not healthy" + } + + return &backend.CheckHealthResult{ + Status: status, + Message: message, + }, nil +} diff --git a/pkg/tsdb/elasticsearch/healthcheck_test.go b/pkg/tsdb/elasticsearch/healthcheck_test.go new file mode 100644 index 0000000000000..432c2d24413fa --- /dev/null +++ b/pkg/tsdb/elasticsearch/healthcheck_test.go @@ -0,0 +1,80 @@ +package elasticsearch + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client" + "github.com/stretchr/testify/assert" +) + +func Test_Healthcheck_OK(t *testing.T) { + service := GetMockService(true) + res, _ := service.CheckHealth(context.Background(), &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{}, + Headers: nil, + }) + assert.Equal(t, backend.HealthStatusOk, res.Status) + assert.Equal(t, "Elasticsearch data source is healthy", res.Message) +} + +func Test_Healthcheck_Timeout(t *testing.T) { + service := GetMockService(false) + res, _ := service.CheckHealth(context.Background(), &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{}, + Headers: nil, + }) + assert.Equal(t, backend.HealthStatusError, res.Status) + assert.Equal(t, "Elasticsearch data source is not healthy", res.Message) +} + +type FakeRoundTripper struct { + isDsHealthy bool +} + +func (fakeRoundTripper *FakeRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + var res *http.Response + if fakeRoundTripper.isDsHealthy { + res = &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(bytes.NewBufferString("{\"status\":\"green\"}")), + } + } else { + res = &http.Response{ + StatusCode: http.StatusRequestTimeout, + Status: "408 Request Timeout", + Body: io.NopCloser(bytes.NewBufferString("{\"status\":\"red\"}")), + } + } + return res, nil +} + +type FakeInstanceManager struct { + isDsHealthy bool +} + +func (fakeInstanceManager *FakeInstanceManager) Get(tx context.Context, pluginContext backend.PluginContext) (instancemgmt.Instance, error) { + httpClient, _ := sdkhttpclient.New(sdkhttpclient.Options{}) + httpClient.Transport = &FakeRoundTripper{isDsHealthy: fakeInstanceManager.isDsHealthy} + + return es.DatasourceInfo{ + HTTPClient: httpClient, + }, nil +} + +func (*FakeInstanceManager) Do(_ context.Context, _ backend.PluginContext, _ instancemgmt.InstanceCallbackFunc) error { + return nil +} + +func GetMockService(isDsHealthy bool) *Service { + return &Service{ + im: &FakeInstanceManager{isDsHealthy: isDsHealthy}, + } +} diff --git a/pkg/tsdb/elasticsearch/models.go b/pkg/tsdb/elasticsearch/models.go index 1fa6696c80208..d03861d394395 100644 --- a/pkg/tsdb/elasticsearch/models.go +++ b/pkg/tsdb/elasticsearch/models.go @@ -3,6 +3,7 @@ package elasticsearch import ( "time" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/components/simplejson" ) @@ -16,6 +17,7 @@ type Query struct { IntervalMs int64 RefID string MaxDataPoints int64 + TimeRange backend.TimeRange } // BucketAgg represents a bucket aggregation of the time series query model of the datasource diff --git a/pkg/tsdb/elasticsearch/parse_query.go b/pkg/tsdb/elasticsearch/parse_query.go index e3ebf5aa27cb3..6add9272d991e 100644 --- a/pkg/tsdb/elasticsearch/parse_query.go +++ b/pkg/tsdb/elasticsearch/parse_query.go @@ -42,6 +42,7 @@ func parseQuery(tsdbQuery []backend.DataQuery, logger log.Logger) ([]*Query, err IntervalMs: intervalMs, RefID: q.RefID, MaxDataPoints: q.MaxDataPoints, + TimeRange: q.TimeRange, }) } diff --git a/pkg/tsdb/elasticsearch/querydata_test.go b/pkg/tsdb/elasticsearch/querydata_test.go index 7a559b68fb475..d3e7d1f2af4cb 100644 --- a/pkg/tsdb/elasticsearch/querydata_test.go +++ b/pkg/tsdb/elasticsearch/querydata_test.go @@ -114,6 +114,9 @@ type queryDataTestResult struct { func queryDataTestWithResponseCode(queriesBytes []byte, responseStatusCode int, responseBytes []byte) (queryDataTestResult, error) { queries, err := newFlowTestQueries(queriesBytes) + req := backend.QueryDataRequest{ + Queries: queries, + } if err != nil { return queryDataTestResult{}, err } @@ -138,7 +141,7 @@ func queryDataTestWithResponseCode(queriesBytes []byte, responseStatusCode int, return nil }) - result, err := queryData(context.Background(), queries, dsInfo, log.New("test.logger"), tracing.InitializeTracerForTest()) + result, err := queryData(context.Background(), &req, dsInfo, log.New("test.logger"), tracing.InitializeTracerForTest()) if err != nil { return queryDataTestResult{}, err } diff --git a/pkg/tsdb/elasticsearch/response_parser.go b/pkg/tsdb/elasticsearch/response_parser.go index be767a9918154..822a655fb3250 100644 --- a/pkg/tsdb/elasticsearch/response_parser.go +++ b/pkg/tsdb/elasticsearch/response_parser.go @@ -47,7 +47,7 @@ const ( var searchWordsRegex = regexp.MustCompile(regexp.QuoteMeta(es.HighlightPreTagsString) + `(.*?)` + regexp.QuoteMeta(es.HighlightPostTagsString)) -func parseResponse(ctx context.Context, responses []*es.SearchResponse, targets []*Query, configuredFields es.ConfiguredFields, logger log.Logger, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { +func parseResponse(ctx context.Context, responses []*es.SearchResponse, targets []*Query, configuredFields es.ConfiguredFields, keepLabelsInResponse bool, logger log.Logger, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { result := backend.QueryDataResponse{ Responses: backend.Responses{}, } @@ -117,7 +117,7 @@ func parseResponse(ctx context.Context, responses []*es.SearchResponse, targets resSpan.End() return &backend.QueryDataResponse{}, err } - nameFields(queryRes, target) + nameFields(queryRes, target, keepLabelsInResponse) trimDatapoints(queryRes, target) result.Responses[target.RefID] = queryRes @@ -136,8 +136,14 @@ func processLogsResponse(res *es.SearchResponse, target *Query, configuredFields for hitIdx, hit := range res.Hits.Hits { var flattened map[string]interface{} + var sourceString string if hit["_source"] != nil { flattened = flatten(hit["_source"].(map[string]interface{}), 10) + sourceMarshalled, err := json.Marshal(flattened) + if err != nil { + return err + } + sourceString = string(sourceMarshalled) } doc := map[string]interface{}{ @@ -146,7 +152,8 @@ func processLogsResponse(res *es.SearchResponse, target *Query, configuredFields "_index": hit["_index"], "sort": hit["sort"], "highlight": hit["highlight"], - "_source": flattened, + // In case of logs query we want to have the raw source as a string field so it can be visualized in logs panel + "_source": sourceString, } for k, v := range flattened { @@ -881,7 +888,7 @@ func getSortedLabelValues(labels data.Labels) []string { return values } -func nameFields(queryResult backend.DataResponse, target *Query) { +func nameFields(queryResult backend.DataResponse, target *Query, keepLabelsInResponse bool) { set := make(map[string]struct{}) frames := queryResult.Frames for _, v := range frames { @@ -900,10 +907,18 @@ func nameFields(queryResult backend.DataResponse, target *Query) { // another is "number" valueField := frame.Fields[1] fieldName := getFieldName(*valueField, target, metricTypeCount) - if valueField.Config == nil { - valueField.Config = &data.FieldConfig{} + // If we need to keep the labels in the response, to prevent duplication in names and to keep + // backward compatibility with alerting and expressions we use DisplayNameFromDS + if keepLabelsInResponse { + if valueField.Config == nil { + valueField.Config = &data.FieldConfig{} + } + valueField.Config.DisplayNameFromDS = fieldName + // If we don't need to keep labels (how frontend mode worked), we use frame.Name and remove labels + } else { + valueField.Labels = nil + frame.Name = fieldName } - valueField.Config.DisplayNameFromDS = fieldName } } } diff --git a/pkg/tsdb/elasticsearch/response_parser_test.go b/pkg/tsdb/elasticsearch/response_parser_test.go index ee307313ecbc5..2fc85276f7a03 100644 --- a/pkg/tsdb/elasticsearch/response_parser_test.go +++ b/pkg/tsdb/elasticsearch/response_parser_test.go @@ -129,7 +129,7 @@ func TestProcessLogsResponse(t *testing.T) { require.Equal(t, data.FieldTypeNullableFloat64, logsFieldMap["number"].Type()) require.Contains(t, logsFieldMap, "_source") - require.Equal(t, data.FieldTypeNullableJSON, logsFieldMap["_source"].Type()) + require.Equal(t, data.FieldTypeNullableString, logsFieldMap["_source"].Type()) requireStringAt(t, "fdsfs", logsFieldMap["_id"], 0) requireStringAt(t, "kdospaidopa", logsFieldMap["_id"], 1) @@ -138,10 +138,8 @@ func TestProcessLogsResponse(t *testing.T) { requireStringAt(t, "mock-index", logsFieldMap["_index"], 0) requireStringAt(t, "mock-index", logsFieldMap["_index"], 1) - actualJson1, err := json.Marshal(logsFieldMap["_source"].At(0).(*json.RawMessage)) - require.NoError(t, err) - actualJson2, err := json.Marshal(logsFieldMap["_source"].At(1).(*json.RawMessage)) - require.NoError(t, err) + actualJson1 := logsFieldMap["_source"].At(0).(*string) + actualJson2 := logsFieldMap["_source"].At(1).(*string) expectedJson1 := ` { @@ -165,8 +163,8 @@ func TestProcessLogsResponse(t *testing.T) { "fields.lvl": "info" }` - require.JSONEq(t, expectedJson1, string(actualJson1)) - require.JSONEq(t, expectedJson2, string(actualJson2)) + require.JSONEq(t, expectedJson1, *actualJson1) + require.JSONEq(t, expectedJson2, *actualJson2) }) t.Run("creates correct level field", func(t *testing.T) { @@ -332,7 +330,7 @@ func TestProcessLogsResponse(t *testing.T) { ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -419,7 +417,7 @@ func TestProcessLogsResponse(t *testing.T) { ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -527,7 +525,7 @@ func TestProcessRawDataResponse(t *testing.T) { ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -816,7 +814,7 @@ func TestProcessRawDocumentResponse(t *testing.T) { ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -997,7 +995,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -1013,7 +1011,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "p75") + assert.Equal(t, frame.Name, "p75") frame = dataframes[1] require.Len(t, frame.Fields, 2) @@ -1021,7 +1019,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "p90") + assert.Equal(t, frame.Name, "p90") }) }) @@ -1099,7 +1097,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -1162,7 +1160,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -1235,7 +1233,7 @@ func TestProcessBuckets(t *testing.T) { }] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) assert.Nil(t, err) assert.Len(t, result.Responses, 1) frames := result.Responses["A"].Frames @@ -1466,7 +1464,7 @@ func TestProcessBuckets(t *testing.T) { } }] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) assert.Nil(t, err) assert.Len(t, result.Responses, 1) @@ -1480,7 +1478,7 @@ func TestProcessBuckets(t *testing.T) { assert.Len(t, frame.Fields, 2) require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Top Metrics @value") + assert.Equal(t, frame.Name, "Top Metrics @value") v, _ := frame.FloatAt(0, 0) assert.Equal(t, 1609459200000., v) v, _ = frame.FloatAt(1, 0) @@ -1497,7 +1495,7 @@ func TestProcessBuckets(t *testing.T) { assert.Len(t, frame.Fields, 2) require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Top Metrics @anotherValue") + assert.Equal(t, frame.Name, "Top Metrics @anotherValue") v, _ = frame.FloatAt(0, 0) assert.Equal(t, 1609459200000., v) v, _ = frame.FloatAt(1, 0) @@ -1553,7 +1551,7 @@ func TestProcessBuckets(t *testing.T) { }] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) assert.Nil(t, err) assert.Len(t, result.Responses, 1) frames := result.Responses["A"].Frames @@ -1751,7 +1749,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) queryRes := result.Responses["A"] @@ -1766,6 +1764,69 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) + assert.Equal(t, frame.Name, "server1") + + frame = dataframes[1] + require.Len(t, frame.Fields, 2) + require.Equal(t, frame.Fields[0].Name, data.TimeSeriesTimeFieldName) + require.Equal(t, frame.Fields[0].Len(), 2) + require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) + require.Equal(t, frame.Fields[1].Len(), 2) + assert.Equal(t, frame.Name, "server2") + }) + + t.Run("Single group by query one metric with true keepLabelsInResponse", func(t *testing.T) { + targets := map[string]string{ + "A": `{ + "metrics": [{ "type": "count", "id": "1" }], + "bucketAggs": [ + { "type": "terms", "field": "host", "id": "2" }, + { "type": "date_histogram", "field": "@timestamp", "id": "3" } + ] + }`, + } + response := `{ + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "3": { + "buckets": [{ "doc_count": 1, "key": 1000 }, { "doc_count": 3, "key": 2000 }] + }, + "doc_count": 4, + "key": "server1" + }, + { + "3": { + "buckets": [{ "doc_count": 2, "key": 1000 }, { "doc_count": 8, "key": 2000 }] + }, + "doc_count": 10, + "key": "server2" + } + ] + } + } + } + ] + }` + result, err := parseTestResponse(targets, response, true) + require.NoError(t, err) + + queryRes := result.Responses["A"] + require.NotNil(t, queryRes) + dataframes := queryRes.Frames + require.NoError(t, err) + require.Len(t, dataframes, 2) + + frame := dataframes[0] + require.Len(t, frame.Fields, 2) + require.Equal(t, frame.Fields[0].Name, data.TimeSeriesTimeFieldName) + require.Equal(t, frame.Fields[0].Len(), 2) + require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) + require.Equal(t, frame.Fields[1].Len(), 2) + require.Equal(t, frame.Fields[1].Labels, data.Labels{"host": "server1"}) assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server1") frame = dataframes[1] @@ -1774,6 +1835,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) + require.Equal(t, frame.Fields[1].Labels, data.Labels{"host": "server2"}) assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server2") }) @@ -1819,7 +1881,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -1835,7 +1897,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server1 Count") + assert.Equal(t, frame.Name, "server1 Count") frame = dataframes[1] require.Len(t, frame.Fields, 2) @@ -1843,7 +1905,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server1 Average @value") + assert.Equal(t, frame.Name, "server1 Average @value") frame = dataframes[2] require.Len(t, frame.Fields, 2) @@ -1851,7 +1913,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server2 Count") + assert.Equal(t, frame.Name, "server2 Count") frame = dataframes[3] require.Len(t, frame.Fields, 2) @@ -1859,7 +1921,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server2 Average @value") + assert.Equal(t, frame.Name, "server2 Average @value") }) t.Run("Simple group by 2 metrics 4 frames", func(t *testing.T) { @@ -1971,7 +2033,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -1987,7 +2049,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server1 Count and {{not_exist}} server1") + assert.Equal(t, frame.Name, "server1 Count and {{not_exist}} server1") frame = dataframes[1] require.Len(t, frame.Fields, 2) @@ -1995,7 +2057,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server2 Count and {{not_exist}} server2") + assert.Equal(t, frame.Name, "server2 Count and {{not_exist}} server2") frame = dataframes[2] require.Len(t, frame.Fields, 2) @@ -2003,7 +2065,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "0 Count and {{not_exist}} 0") + assert.Equal(t, frame.Name, "0 Count and {{not_exist}} 0") }) }) @@ -2144,7 +2206,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -2160,7 +2222,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 1) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 1) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server1 Max") + assert.Equal(t, frame.Name, "server1 Max") frame = dataframes[1] require.Len(t, frame.Fields, 2) @@ -2168,7 +2230,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 1) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 1) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server1 Std Dev Lower") + assert.Equal(t, frame.Name, "server1 Std Dev Lower") frame = dataframes[2] require.Len(t, frame.Fields, 2) @@ -2176,7 +2238,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 1) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 1) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server1 Std Dev Upper") + assert.Equal(t, frame.Name, "server1 Std Dev Upper") frame = dataframes[3] require.Len(t, frame.Fields, 2) @@ -2184,7 +2246,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 1) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 1) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server2 Max") + assert.Equal(t, frame.Name, "server2 Max") frame = dataframes[4] require.Len(t, frame.Fields, 2) @@ -2192,7 +2254,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 1) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 1) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server2 Std Dev Lower") + assert.Equal(t, frame.Name, "server2 Std Dev Lower") frame = dataframes[5] require.Len(t, frame.Fields, 2) @@ -2200,7 +2262,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 1) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 1) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server2 Std Dev Upper") + assert.Equal(t, frame.Name, "server2 Std Dev Upper") }) }) @@ -2276,7 +2338,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -2292,7 +2354,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Count") + assert.Equal(t, frame.Name, "Count") }) t.Run("Simple query count & avg aggregation", func(t *testing.T) { @@ -2324,7 +2386,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -2341,7 +2403,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Count") + assert.Equal(t, frame.Name, "Count") frame = dataframes[1] require.Len(t, frame.Fields, 2) @@ -2350,7 +2412,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Average value") + assert.Equal(t, frame.Name, "Average value") }) }) @@ -2386,7 +2448,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -2508,7 +2570,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -2524,7 +2586,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "@metric:cpu") + assert.Equal(t, frame.Name, "@metric:cpu") frame = dataframes[1] require.Len(t, frame.Fields, 2) @@ -2532,7 +2594,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "@metric:logins.count") + assert.Equal(t, frame.Name, "@metric:logins.count") }) }) @@ -2611,7 +2673,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -2661,7 +2723,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -2723,7 +2785,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -2739,7 +2801,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 1) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 1) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Average") + assert.Equal(t, frame.Name, "Average") frame = dataframes[1] require.Len(t, frame.Fields, 2) @@ -2747,7 +2809,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 1) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 1) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Count") + assert.Equal(t, frame.Name, "Count") }) t.Run("With drop first and last aggregation (string)", func(t *testing.T) { @@ -2791,7 +2853,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -2807,7 +2869,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 1) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 1) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Average") + assert.Equal(t, frame.Name, "Average") frame = dataframes[1] require.Len(t, frame.Fields, 2) @@ -2815,7 +2877,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 1) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 1) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Count") + assert.Equal(t, frame.Name, "Count") }) }) @@ -2855,7 +2917,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -2909,7 +2971,7 @@ func TestProcessBuckets(t *testing.T) { } ] }` - result, err := parseTestResponse(targets, response) + result, err := parseTestResponse(targets, response, false) require.NoError(t, err) require.Len(t, result.Responses, 1) @@ -2925,7 +2987,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Sum @value") + assert.Equal(t, frame.Name, "Sum @value") frame = dataframes[1] require.Len(t, frame.Fields, 2) @@ -2933,7 +2995,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Max @value") + assert.Equal(t, frame.Name, "Max @value") frame = dataframes[2] require.Len(t, frame.Fields, 2) @@ -2941,7 +3003,7 @@ func TestProcessBuckets(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Sum @value * Max @value") + assert.Equal(t, frame.Name, "Sum @value * Max @value") }) t.Run("Two bucket_script", func(t *testing.T) { @@ -3585,7 +3647,7 @@ func TestTrimEdges(t *testing.T) { requireFrameLength(t, frames[0], 1) } -func parseTestResponse(tsdbQueries map[string]string, responseBody string) (*backend.QueryDataResponse, error) { +func parseTestResponse(tsdbQueries map[string]string, responseBody string, keepLabelsInResponse bool) (*backend.QueryDataResponse, error) { from := time.Date(2018, 5, 15, 17, 50, 0, 0, time.UTC) to := time.Date(2018, 5, 15, 17, 55, 0, 0, time.UTC) configuredFields := es.ConfiguredFields{ @@ -3620,7 +3682,7 @@ func parseTestResponse(tsdbQueries map[string]string, responseBody string) (*bac return nil, err } - return parseResponse(context.Background(), response.Responses, queries, configuredFields, log.New("test.logger"), tracing.InitializeTracerForTest()) + return parseResponse(context.Background(), response.Responses, queries, configuredFields, keepLabelsInResponse, log.New("test.logger"), tracing.InitializeTracerForTest()) } func requireTimeValue(t *testing.T, expected int64, frame *data.Frame, index int) { @@ -3674,16 +3736,5 @@ func requireFloatAt(t *testing.T, expected float64, field *data.Field, index int } func requireTimeSeriesName(t *testing.T, expected string, frame *data.Frame) { - getField := func() *data.Field { - for _, field := range frame.Fields { - if field.Type() != data.FieldTypeTime { - return field - } - } - return nil - } - - field := getField() - require.NotNil(t, expected, field.Config) - require.Equal(t, expected, field.Config.DisplayNameFromDS) + require.Equal(t, expected, frame.Name) } diff --git a/pkg/tsdb/elasticsearch/testdata/trimedges_string.golden.jsonc b/pkg/tsdb/elasticsearch/testdata/trimedges_string.golden.jsonc index b16bfc9bd9aae..17c3bb3eef8d3 100644 --- a/pkg/tsdb/elasticsearch/testdata/trimedges_string.golden.jsonc +++ b/pkg/tsdb/elasticsearch/testdata/trimedges_string.golden.jsonc @@ -7,7 +7,7 @@ // 0 // ] // } -// Name: +// Name: Count // Dimensions: 2 Fields by 3 Rows // +-------------------------------+------------------+ // | Name: Time | Name: Value | @@ -26,6 +26,7 @@ "frames": [ { "schema": { + "name": "Count", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -47,10 +48,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": {}, - "config": { - "displayNameFromDS": "Count" } } ] diff --git a/pkg/tsdb/elasticsearch/testdata_response/logs.a.golden.jsonc b/pkg/tsdb/elasticsearch/testdata_response/logs.a.golden.jsonc index 9fe7a4c133745..105279a888ebf 100644 --- a/pkg/tsdb/elasticsearch/testdata_response/logs.a.golden.jsonc +++ b/pkg/tsdb/elasticsearch/testdata_response/logs.a.golden.jsonc @@ -19,7 +19,7 @@ // +-----------------------------------+---------------------------+----------------------+-----------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+--------------------------+-----------------+------------------+--------------------+--------------------------------------------------------------------------------------------------------------------------------+--------------------------------------+---------------+-----------------+-----------------+-----------------------------------------+------------------------------------+---------------------------------------------------------------------------------+--------------------------+ // | Name: testtime | Name: line | Name: _id | Name: _index | Name: _source | Name: _type | Name: abc | Name: counter | Name: float | Name: highlight | Name: id | Name: is_true | Name: label | Name: level | Name: location | Name: nested_field.internal.nested | Name: shapes | Name: sort | // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | -// | Type: []*time.Time | Type: []*string | Type: []*string | Type: []*string | Type: []*json.RawMessage | Type: []*json.RawMessage | Type: []*string | Type: []*float64 | Type: []*float64 | Type: []*json.RawMessage | Type: []*string | Type: []*bool | Type: []*string | Type: []*string | Type: []*string | Type: []*string | Type: []*json.RawMessage | Type: []*json.RawMessage | +// | Type: []*time.Time | Type: []*string | Type: []*string | Type: []*string | Type: []*string | Type: []*json.RawMessage | Type: []*string | Type: []*float64 | Type: []*float64 | Type: []*json.RawMessage | Type: []*string | Type: []*bool | Type: []*string | Type: []*string | Type: []*string | Type: []*string | Type: []*json.RawMessage | Type: []*json.RawMessage | // +-----------------------------------+---------------------------+----------------------+-----------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+--------------------------+-----------------+------------------+--------------------+--------------------------------------------------------------------------------------------------------------------------------+--------------------------------------+---------------+-----------------+-----------------+-----------------------------------------+------------------------------------+---------------------------------------------------------------------------------+--------------------------+ // | 2023-02-09 14:40:01.475 +0000 UTC | log text [106619125] | g2aeNoYB7vaC3bq-ezfK | logs-2023.02.09 | {"abc":null,"counter":81,"float":10.911972180833306,"is_true":true,"label":"val3","line":"log text [106619125]","location":"-42.73465234425797, -14.097854057104112","lvl":"info","nested_field.internal.nested":"value1","shapes":[{"type":"triangle"},{"type":"triangle"},{"type":"triangle"},{"type":"square"}],"testtime":"09/02/2023"} | null | null | 81 | 10.911972180833306 | {"duplicated":["@HIGHLIGHT@hello@/HIGHLIGHT@"],"line":["@HIGHLIGHT@hello@/HIGHLIGHT@, i am a @HIGHLIGHT@message@/HIGHLIGHT@"]} | logs-2023.02.09#g2aeNoYB7vaC3bq-ezfK | true | val3 | info | -42.73465234425797, -14.097854057104112 | value1 | [{"type":"triangle"},{"type":"triangle"},{"type":"triangle"},{"type":"square"}] | [1675953601475,4] | // | 2023-02-09 14:40:00.513 +0000 UTC | log text with [781660944] | gmaeNoYB7vaC3bq-eDcN | logs-2023.02.09 | {"abc":null,"counter":80,"float":62.94120607636795,"is_true":false,"label":"val3","line":"log text with [781660944]","location":"42.07571917624318, 15.95725088484611","lvl":"error","nested_field.internal.nested":"value2","shapes":[{"type":"triangle"},{"type":"square"}],"testtime":"09/02/2023"} | null | null | 80 | 62.94120607636795 | {"duplicated":["@HIGHLIGHT@hello@/HIGHLIGHT@"],"line":["@HIGHLIGHT@hello@/HIGHLIGHT@, i am a @HIGHLIGHT@message@/HIGHLIGHT@"]} | logs-2023.02.09#gmaeNoYB7vaC3bq-eDcN | false | val3 | error | 42.07571917624318, 15.95725088484611 | value2 | [{"type":"triangle"},{"type":"square"}] | [1675953600513,7] | @@ -96,9 +96,9 @@ }, { "name": "_source", - "type": "other", + "type": "string", "typeInfo": { - "frame": "json.RawMessage", + "frame": "string", "nullable": true }, "config": { @@ -281,124 +281,11 @@ "logs-2023.02.09" ], [ - { - "abc": null, - "counter": 81, - "float": 10.911972180833306, - "is_true": true, - "label": "val3", - "line": "log text [106619125]", - "location": "-42.73465234425797, -14.097854057104112", - "lvl": "info", - "nested_field.internal.nested": "value1", - "shapes": [ - { - "type": "triangle" - }, - { - "type": "triangle" - }, - { - "type": "triangle" - }, - { - "type": "square" - } - ], - "testtime": "09/02/2023" - }, - { - "abc": null, - "counter": 80, - "float": 62.94120607636795, - "is_true": false, - "label": "val3", - "line": "log text with [781660944]", - "location": "42.07571917624318, 15.95725088484611", - "lvl": "error", - "nested_field.internal.nested": "value2", - "shapes": [ - { - "type": "triangle" - }, - { - "type": "square" - } - ], - "testtime": "09/02/2023" - }, - { - "abc": "def", - "counter": 79, - "float": 53.323706427230455, - "is_true": true, - "label": "val1", - "line": "log text [894867430]", - "location": "-38.27341566189766, -23.66739642570781", - "lvl": "info", - "nested_field.internal.nested": "value3", - "shapes": [ - { - "type": "triangle" - }, - { - "type": "square" - } - ], - "testtime": "09/02/2023" - }, - { - "abc": "def", - "counter": 78, - "float": 82.72012623471589, - "is_true": false, - "label": "val1", - "line": "log text [478598889]", - "location": "12.373240290451287, 43.265493464362024", - "lvl": "info", - "nested_field.internal.nested": "value4", - "shapes": [ - { - "type": "triangle" - }, - { - "type": "triangle" - }, - { - "type": "triangle" - }, - { - "type": "square" - } - ], - "testtime": "09/02/2023" - }, - { - "abc": "def", - "counter": 77, - "float": 35.05784443331803, - "is_true": false, - "label": "val3", - "line": "log text [526995818]", - "location": "-31.524344042228194, -32.11254790120572", - "lvl": "info", - "nested_field.internal.nested": "value5", - "shapes": [ - { - "type": "triangle" - }, - { - "type": "triangle" - }, - { - "type": "triangle" - }, - { - "type": "square" - } - ], - "testtime": "09/02/2023" - } + "{\"abc\":null,\"counter\":81,\"float\":10.911972180833306,\"is_true\":true,\"label\":\"val3\",\"line\":\"log text [106619125]\",\"location\":\"-42.73465234425797, -14.097854057104112\",\"lvl\":\"info\",\"nested_field.internal.nested\":\"value1\",\"shapes\":[{\"type\":\"triangle\"},{\"type\":\"triangle\"},{\"type\":\"triangle\"},{\"type\":\"square\"}],\"testtime\":\"09/02/2023\"}", + "{\"abc\":null,\"counter\":80,\"float\":62.94120607636795,\"is_true\":false,\"label\":\"val3\",\"line\":\"log text with [781660944]\",\"location\":\"42.07571917624318, 15.95725088484611\",\"lvl\":\"error\",\"nested_field.internal.nested\":\"value2\",\"shapes\":[{\"type\":\"triangle\"},{\"type\":\"square\"}],\"testtime\":\"09/02/2023\"}", + "{\"abc\":\"def\",\"counter\":79,\"float\":53.323706427230455,\"is_true\":true,\"label\":\"val1\",\"line\":\"log text [894867430]\",\"location\":\"-38.27341566189766, -23.66739642570781\",\"lvl\":\"info\",\"nested_field.internal.nested\":\"value3\",\"shapes\":[{\"type\":\"triangle\"},{\"type\":\"square\"}],\"testtime\":\"09/02/2023\"}", + "{\"abc\":\"def\",\"counter\":78,\"float\":82.72012623471589,\"is_true\":false,\"label\":\"val1\",\"line\":\"log text [478598889]\",\"location\":\"12.373240290451287, 43.265493464362024\",\"lvl\":\"info\",\"nested_field.internal.nested\":\"value4\",\"shapes\":[{\"type\":\"triangle\"},{\"type\":\"triangle\"},{\"type\":\"triangle\"},{\"type\":\"square\"}],\"testtime\":\"09/02/2023\"}", + "{\"abc\":\"def\",\"counter\":77,\"float\":35.05784443331803,\"is_true\":false,\"label\":\"val3\",\"line\":\"log text [526995818]\",\"location\":\"-31.524344042228194, -32.11254790120572\",\"lvl\":\"info\",\"nested_field.internal.nested\":\"value5\",\"shapes\":[{\"type\":\"triangle\"},{\"type\":\"triangle\"},{\"type\":\"triangle\"},{\"type\":\"square\"}],\"testtime\":\"09/02/2023\"}" ], [ null, diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_avg.a.golden.jsonc b/pkg/tsdb/elasticsearch/testdata_response/metric_avg.a.golden.jsonc index 22005bf997fe4..cb6395c9e372f 100644 --- a/pkg/tsdb/elasticsearch/testdata_response/metric_avg.a.golden.jsonc +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_avg.a.golden.jsonc @@ -7,7 +7,7 @@ // 0 // ] // } -// Name: +// Name: Average counter // Dimensions: 2 Fields by 3 Rows // +-----------------------------------+------------------+ // | Name: Time | Name: Value | @@ -26,6 +26,7 @@ "frames": [ { "schema": { + "name": "Average counter", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -47,10 +48,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": {}, - "config": { - "displayNameFromDS": "Average counter" } } ] diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_complex.a.golden.jsonc b/pkg/tsdb/elasticsearch/testdata_response/metric_complex.a.golden.jsonc index cbbf26e2fd868..2013b3542efa1 100644 --- a/pkg/tsdb/elasticsearch/testdata_response/metric_complex.a.golden.jsonc +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_complex.a.golden.jsonc @@ -7,17 +7,17 @@ // 0 // ] // } -// Name: +// Name: val3 Max float // Dimensions: 2 Fields by 3 Rows -// +-------------------------------+--------------------+ -// | Name: Time | Name: Value | -// | Labels: | Labels: label=val3 | -// | Type: []time.Time | Type: []*float64 | -// +-------------------------------+--------------------+ -// | 2022-11-22 12:45:00 +0000 UTC | 97.85990905761719 | -// | 2022-11-22 12:46:00 +0000 UTC | 98.39459228515625 | -// | 2022-11-22 12:47:00 +0000 UTC | 99.76652526855469 | -// +-------------------------------+--------------------+ +// +-------------------------------+-------------------+ +// | Name: Time | Name: Value | +// | Labels: | Labels: | +// | Type: []time.Time | Type: []*float64 | +// +-------------------------------+-------------------+ +// | 2022-11-22 12:45:00 +0000 UTC | 97.85990905761719 | +// | 2022-11-22 12:46:00 +0000 UTC | 98.39459228515625 | +// | 2022-11-22 12:47:00 +0000 UTC | 99.76652526855469 | +// +-------------------------------+-------------------+ // // // @@ -28,11 +28,11 @@ // 0 // ] // } -// Name: +// Name: val3 Min float // Dimensions: 2 Fields by 3 Rows // +-------------------------------+--------------------+ // | Name: Time | Name: Value | -// | Labels: | Labels: label=val3 | +// | Labels: | Labels: | // | Type: []time.Time | Type: []*float64 | // +-------------------------------+--------------------+ // | 2022-11-22 12:45:00 +0000 UTC | 8.375883102416992 | @@ -49,17 +49,17 @@ // 0 // ] // } -// Name: +// Name: val2 Max float // Dimensions: 2 Fields by 3 Rows -// +-------------------------------+--------------------+ -// | Name: Time | Name: Value | -// | Labels: | Labels: label=val2 | -// | Type: []time.Time | Type: []*float64 | -// +-------------------------------+--------------------+ -// | 2022-11-22 12:45:00 +0000 UTC | 87.77692413330078 | -// | 2022-11-22 12:46:00 +0000 UTC | 98.47160339355469 | -// | 2022-11-22 12:47:00 +0000 UTC | 92.53878784179688 | -// +-------------------------------+--------------------+ +// +-------------------------------+-------------------+ +// | Name: Time | Name: Value | +// | Labels: | Labels: | +// | Type: []time.Time | Type: []*float64 | +// +-------------------------------+-------------------+ +// | 2022-11-22 12:45:00 +0000 UTC | 87.77692413330078 | +// | 2022-11-22 12:46:00 +0000 UTC | 98.47160339355469 | +// | 2022-11-22 12:47:00 +0000 UTC | 92.53878784179688 | +// +-------------------------------+-------------------+ // // // @@ -70,11 +70,11 @@ // 0 // ] // } -// Name: +// Name: val2 Min float // Dimensions: 2 Fields by 3 Rows // +-------------------------------+--------------------+ // | Name: Time | Name: Value | -// | Labels: | Labels: label=val2 | +// | Labels: | Labels: | // | Type: []time.Time | Type: []*float64 | // +-------------------------------+--------------------+ // | 2022-11-22 12:45:00 +0000 UTC | 4.540984630584717 | @@ -91,17 +91,17 @@ // 0 // ] // } -// Name: +// Name: val1 Max float // Dimensions: 2 Fields by 3 Rows -// +-------------------------------+--------------------+ -// | Name: Time | Name: Value | -// | Labels: | Labels: label=val1 | -// | Type: []time.Time | Type: []*float64 | -// +-------------------------------+--------------------+ -// | 2022-11-22 12:45:00 +0000 UTC | 98.57181549072266 | -// | 2022-11-22 12:46:00 +0000 UTC | 97.99356079101562 | -// | 2022-11-22 12:47:00 +0000 UTC | 94.45416259765625 | -// +-------------------------------+--------------------+ +// +-------------------------------+-------------------+ +// | Name: Time | Name: Value | +// | Labels: | Labels: | +// | Type: []time.Time | Type: []*float64 | +// +-------------------------------+-------------------+ +// | 2022-11-22 12:45:00 +0000 UTC | 98.57181549072266 | +// | 2022-11-22 12:46:00 +0000 UTC | 97.99356079101562 | +// | 2022-11-22 12:47:00 +0000 UTC | 94.45416259765625 | +// +-------------------------------+-------------------+ // // // @@ -112,11 +112,11 @@ // 0 // ] // } -// Name: +// Name: val1 Min float // Dimensions: 2 Fields by 3 Rows // +-------------------------------+--------------------+ // | Name: Time | Name: Value | -// | Labels: | Labels: label=val1 | +// | Labels: | Labels: | // | Type: []time.Time | Type: []*float64 | // +-------------------------------+--------------------+ // | 2022-11-22 12:45:00 +0000 UTC | 2.859630584716797 | @@ -131,6 +131,7 @@ "frames": [ { "schema": { + "name": "val3 Max float", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -152,12 +153,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": { - "label": "val3" - }, - "config": { - "displayNameFromDS": "val3 Max float" } } ] @@ -179,6 +174,7 @@ }, { "schema": { + "name": "val3 Min float", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -200,12 +196,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": { - "label": "val3" - }, - "config": { - "displayNameFromDS": "val3 Min float" } } ] @@ -227,6 +217,7 @@ }, { "schema": { + "name": "val2 Max float", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -248,12 +239,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": { - "label": "val2" - }, - "config": { - "displayNameFromDS": "val2 Max float" } } ] @@ -275,6 +260,7 @@ }, { "schema": { + "name": "val2 Min float", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -296,12 +282,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": { - "label": "val2" - }, - "config": { - "displayNameFromDS": "val2 Min float" } } ] @@ -323,6 +303,7 @@ }, { "schema": { + "name": "val1 Max float", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -344,12 +325,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": { - "label": "val1" - }, - "config": { - "displayNameFromDS": "val1 Max float" } } ] @@ -371,6 +346,7 @@ }, { "schema": { + "name": "val1 Min float", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -392,12 +368,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": { - "label": "val1" - }, - "config": { - "displayNameFromDS": "val1 Min float" } } ] diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_extended_stats.a.golden.jsonc b/pkg/tsdb/elasticsearch/testdata_response/metric_extended_stats.a.golden.jsonc index 6b51aa3398847..dcaacd9ed857b 100644 --- a/pkg/tsdb/elasticsearch/testdata_response/metric_extended_stats.a.golden.jsonc +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_extended_stats.a.golden.jsonc @@ -7,7 +7,7 @@ // 0 // ] // } -// Name: +// Name: Std Dev Lower counter // Dimensions: 2 Fields by 3 Rows // +-----------------------------------+--------------------+ // | Name: Time | Name: Value | @@ -28,7 +28,7 @@ // 0 // ] // } -// Name: +// Name: Std Dev Upper counter // Dimensions: 2 Fields by 3 Rows // +-----------------------------------+--------------------+ // | Name: Time | Name: Value | @@ -47,6 +47,7 @@ "frames": [ { "schema": { + "name": "Std Dev Lower counter", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -68,10 +69,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": {}, - "config": { - "displayNameFromDS": "Std Dev Lower counter" } } ] @@ -93,6 +90,7 @@ }, { "schema": { + "name": "Std Dev Upper counter", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -114,10 +112,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": {}, - "config": { - "displayNameFromDS": "Std Dev Upper counter" } } ] diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_multi.a.golden.jsonc b/pkg/tsdb/elasticsearch/testdata_response/metric_multi.a.golden.jsonc index 7ca6e62de3a1d..094f675aa33c9 100644 --- a/pkg/tsdb/elasticsearch/testdata_response/metric_multi.a.golden.jsonc +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_multi.a.golden.jsonc @@ -7,7 +7,7 @@ // 0 // ] // } -// Name: +// Name: Max float // Dimensions: 2 Fields by 3 Rows // +-------------------------------+-------------------+ // | Name: Time | Name: Value | @@ -26,6 +26,7 @@ "frames": [ { "schema": { + "name": "Max float", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -47,10 +48,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": {}, - "config": { - "displayNameFromDS": "Max float" } } ] diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_multi.b.golden.jsonc b/pkg/tsdb/elasticsearch/testdata_response/metric_multi.b.golden.jsonc index 61dd567194ffe..32e0d1b13c1d5 100644 --- a/pkg/tsdb/elasticsearch/testdata_response/metric_multi.b.golden.jsonc +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_multi.b.golden.jsonc @@ -7,7 +7,7 @@ // 0 // ] // } -// Name: +// Name: Min float // Dimensions: 2 Fields by 3 Rows // +-------------------------------+---------------------+ // | Name: Time | Name: Value | @@ -26,6 +26,7 @@ "frames": [ { "schema": { + "name": "Min float", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -47,10 +48,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": {}, - "config": { - "displayNameFromDS": "Min float" } } ] diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_percentiles.a.golden.jsonc b/pkg/tsdb/elasticsearch/testdata_response/metric_percentiles.a.golden.jsonc index a33daf632dc2a..2e6ebcd3c5ade 100644 --- a/pkg/tsdb/elasticsearch/testdata_response/metric_percentiles.a.golden.jsonc +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_percentiles.a.golden.jsonc @@ -7,7 +7,7 @@ // 0 // ] // } -// Name: +// Name: p25.0 counter // Dimensions: 2 Fields by 3 Rows // +-----------------------------------+------------------+ // | Name: Time | Name: Value | @@ -28,7 +28,7 @@ // 0 // ] // } -// Name: +// Name: p75.0 counter // Dimensions: 2 Fields by 3 Rows // +-----------------------------------+------------------+ // | Name: Time | Name: Value | @@ -47,6 +47,7 @@ "frames": [ { "schema": { + "name": "p25.0 counter", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -68,10 +69,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": {}, - "config": { - "displayNameFromDS": "p25.0 counter" } } ] @@ -93,6 +90,7 @@ }, { "schema": { + "name": "p75.0 counter", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -114,10 +112,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": {}, - "config": { - "displayNameFromDS": "p75.0 counter" } } ] diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_simple.a.golden.jsonc b/pkg/tsdb/elasticsearch/testdata_response/metric_simple.a.golden.jsonc index e67e507a97679..e1f7ae7f7026e 100644 --- a/pkg/tsdb/elasticsearch/testdata_response/metric_simple.a.golden.jsonc +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_simple.a.golden.jsonc @@ -7,18 +7,18 @@ // 0 // ] // } -// Name: +// Name: val3 // Dimensions: 2 Fields by 4 Rows -// +-----------------------------------+--------------------+ -// | Name: Time | Name: Value | -// | Labels: | Labels: label=val3 | -// | Type: []time.Time | Type: []*float64 | -// +-----------------------------------+--------------------+ -// | 2022-11-14 10:40:00.123 +0000 UTC | 0 | -// | 2022-11-14 10:41:00.123 +0000 UTC | 27 | -// | 2022-11-14 10:42:00.123 +0000 UTC | 21 | -// | 2022-11-14 10:43:00.123 +0000 UTC | 31 | -// +-----------------------------------+--------------------+ +// +-----------------------------------+------------------+ +// | Name: Time | Name: Value | +// | Labels: | Labels: | +// | Type: []time.Time | Type: []*float64 | +// +-----------------------------------+------------------+ +// | 2022-11-14 10:40:00.123 +0000 UTC | 0 | +// | 2022-11-14 10:41:00.123 +0000 UTC | 27 | +// | 2022-11-14 10:42:00.123 +0000 UTC | 21 | +// | 2022-11-14 10:43:00.123 +0000 UTC | 31 | +// +-----------------------------------+------------------+ // // // @@ -29,18 +29,18 @@ // 0 // ] // } -// Name: +// Name: val2 // Dimensions: 2 Fields by 4 Rows -// +-----------------------------------+--------------------+ -// | Name: Time | Name: Value | -// | Labels: | Labels: label=val2 | -// | Type: []time.Time | Type: []*float64 | -// +-----------------------------------+--------------------+ -// | 2022-11-14 10:40:00.123 +0000 UTC | 0 | -// | 2022-11-14 10:41:00.123 +0000 UTC | 28 | -// | 2022-11-14 10:42:00.123 +0000 UTC | 22 | -// | 2022-11-14 10:43:00.123 +0000 UTC | 39 | -// +-----------------------------------+--------------------+ +// +-----------------------------------+------------------+ +// | Name: Time | Name: Value | +// | Labels: | Labels: | +// | Type: []time.Time | Type: []*float64 | +// +-----------------------------------+------------------+ +// | 2022-11-14 10:40:00.123 +0000 UTC | 0 | +// | 2022-11-14 10:41:00.123 +0000 UTC | 28 | +// | 2022-11-14 10:42:00.123 +0000 UTC | 22 | +// | 2022-11-14 10:43:00.123 +0000 UTC | 39 | +// +-----------------------------------+------------------+ // // // @@ -51,18 +51,18 @@ // 0 // ] // } -// Name: +// Name: val1 // Dimensions: 2 Fields by 4 Rows -// +-----------------------------------+--------------------+ -// | Name: Time | Name: Value | -// | Labels: | Labels: label=val1 | -// | Type: []time.Time | Type: []*float64 | -// +-----------------------------------+--------------------+ -// | 2022-11-14 10:40:00.123 +0000 UTC | 0 | -// | 2022-11-14 10:41:00.123 +0000 UTC | 26 | -// | 2022-11-14 10:42:00.123 +0000 UTC | 20 | -// | 2022-11-14 10:43:00.123 +0000 UTC | 41 | -// +-----------------------------------+--------------------+ +// +-----------------------------------+------------------+ +// | Name: Time | Name: Value | +// | Labels: | Labels: | +// | Type: []time.Time | Type: []*float64 | +// +-----------------------------------+------------------+ +// | 2022-11-14 10:40:00.123 +0000 UTC | 0 | +// | 2022-11-14 10:41:00.123 +0000 UTC | 26 | +// | 2022-11-14 10:42:00.123 +0000 UTC | 20 | +// | 2022-11-14 10:43:00.123 +0000 UTC | 41 | +// +-----------------------------------+------------------+ // // // 🌟 This was machine generated. Do not edit. 🌟 @@ -71,6 +71,7 @@ "frames": [ { "schema": { + "name": "val3", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -92,12 +93,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": { - "label": "val3" - }, - "config": { - "displayNameFromDS": "val3" } } ] @@ -121,6 +116,7 @@ }, { "schema": { + "name": "val2", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -142,12 +138,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": { - "label": "val2" - }, - "config": { - "displayNameFromDS": "val2" } } ] @@ -171,6 +161,7 @@ }, { "schema": { + "name": "val1", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -192,12 +183,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": { - "label": "val1" - }, - "config": { - "displayNameFromDS": "val1" } } ] diff --git a/pkg/tsdb/elasticsearch/testdata_response/metric_top_metrics.a.golden.jsonc b/pkg/tsdb/elasticsearch/testdata_response/metric_top_metrics.a.golden.jsonc index e02d8f90d05b5..e91888a595aad 100644 --- a/pkg/tsdb/elasticsearch/testdata_response/metric_top_metrics.a.golden.jsonc +++ b/pkg/tsdb/elasticsearch/testdata_response/metric_top_metrics.a.golden.jsonc @@ -7,7 +7,7 @@ // 0 // ] // } -// Name: +// Name: Top Metrics float // Dimensions: 2 Fields by 3 Rows // +-----------------------------------+-------------------+ // | Name: Time | Name: Value | @@ -26,6 +26,7 @@ "frames": [ { "schema": { + "name": "Top Metrics float", "meta": { "type": "timeseries-multi", "typeVersion": [ @@ -47,10 +48,6 @@ "typeInfo": { "frame": "float64", "nullable": true - }, - "labels": {}, - "config": { - "displayNameFromDS": "Top Metrics float" } } ] diff --git a/pkg/tsdb/grafana-postgresql-datasource/locker.go b/pkg/tsdb/grafana-postgresql-datasource/locker.go deleted file mode 100644 index 796c37c7415f0..0000000000000 --- a/pkg/tsdb/grafana-postgresql-datasource/locker.go +++ /dev/null @@ -1,85 +0,0 @@ -package postgres - -import ( - "fmt" - "sync" -) - -// locker is a named reader/writer mutual exclusion lock. -// The lock for each particular key can be held by an arbitrary number of readers or a single writer. -type locker struct { - locks map[any]*sync.RWMutex - locksRW *sync.RWMutex -} - -func newLocker() *locker { - return &locker{ - locks: make(map[any]*sync.RWMutex), - locksRW: new(sync.RWMutex), - } -} - -// Lock locks named rw mutex with specified key for writing. -// If the lock with the same key is already locked for reading or writing, -// Lock blocks until the lock is available. -func (lkr *locker) Lock(key any) { - lk, ok := lkr.getLock(key) - if !ok { - lk = lkr.newLock(key) - } - lk.Lock() -} - -// Unlock unlocks named rw mutex with specified key for writing. It is a run-time error if rw is -// not locked for writing on entry to Unlock. -func (lkr *locker) Unlock(key any) { - lk, ok := lkr.getLock(key) - if !ok { - panic(fmt.Errorf("lock for key '%s' not initialized", key)) - } - lk.Unlock() -} - -// RLock locks named rw mutex with specified key for reading. -// -// It should not be used for recursive read locking for the same key; a blocked Lock -// call excludes new readers from acquiring the lock. See the -// documentation on the golang RWMutex type. -func (lkr *locker) RLock(key any) { - lk, ok := lkr.getLock(key) - if !ok { - lk = lkr.newLock(key) - } - lk.RLock() -} - -// RUnlock undoes a single RLock call for specified key; -// it does not affect other simultaneous readers of locker for specified key. -// It is a run-time error if locker for specified key is not locked for reading -func (lkr *locker) RUnlock(key any) { - lk, ok := lkr.getLock(key) - if !ok { - panic(fmt.Errorf("lock for key '%s' not initialized", key)) - } - lk.RUnlock() -} - -func (lkr *locker) newLock(key any) *sync.RWMutex { - lkr.locksRW.Lock() - defer lkr.locksRW.Unlock() - - if lk, ok := lkr.locks[key]; ok { - return lk - } - lk := new(sync.RWMutex) - lkr.locks[key] = lk - return lk -} - -func (lkr *locker) getLock(key any) (*sync.RWMutex, bool) { - lkr.locksRW.RLock() - defer lkr.locksRW.RUnlock() - - lock, ok := lkr.locks[key] - return lock, ok -} diff --git a/pkg/tsdb/grafana-postgresql-datasource/locker_test.go b/pkg/tsdb/grafana-postgresql-datasource/locker_test.go deleted file mode 100644 index b1dc64f035170..0000000000000 --- a/pkg/tsdb/grafana-postgresql-datasource/locker_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package postgres - -import ( - "sync" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestIntegrationLocker(t *testing.T) { - if testing.Short() { - t.Skip("Tests with Sleep") - } - const notUpdated = "not_updated" - const atThread1 = "at_thread_1" - const atThread2 = "at_thread_2" - t.Run("Should lock for same keys", func(t *testing.T) { - updated := notUpdated - locker := newLocker() - locker.Lock(1) - var wg sync.WaitGroup - wg.Add(1) - defer func() { - locker.Unlock(1) - wg.Wait() - }() - - go func() { - locker.RLock(1) - defer func() { - locker.RUnlock(1) - wg.Done() - }() - require.Equal(t, atThread1, updated, "Value should be updated in different thread") - updated = atThread2 - }() - time.Sleep(time.Millisecond * 10) - require.Equal(t, notUpdated, updated, "Value should not be updated in different thread") - updated = atThread1 - }) - - t.Run("Should not lock for different keys", func(t *testing.T) { - updated := notUpdated - locker := newLocker() - locker.Lock(1) - defer locker.Unlock(1) - var wg sync.WaitGroup - wg.Add(1) - go func() { - locker.RLock(2) - defer func() { - locker.RUnlock(2) - wg.Done() - }() - require.Equal(t, notUpdated, updated, "Value should not be updated in different thread") - updated = atThread2 - }() - wg.Wait() - require.Equal(t, atThread2, updated, "Value should be updated in different thread") - updated = atThread1 - }) -} diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres.go b/pkg/tsdb/grafana-postgresql-datasource/postgres.go index dad43731aeee1..e995e360912ac 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres.go @@ -2,39 +2,45 @@ package postgres import ( "context" + "database/sql" "encoding/json" + "errors" "fmt" + "os" "reflect" "strconv" "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + pgxstdlib "github.com/jackc/pgx/v5/stdlib" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" - sdkproxy "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" + "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/tls" "github.com/grafana/grafana/pkg/tsdb/sqleng" - "github.com/grafana/grafana/pkg/tsdb/sqleng/proxyutil" ) func ProvideService(cfg *setting.Cfg) *Service { logger := backend.NewLoggerWith("logger", "tsdb.postgres") s := &Service{ - tlsManager: newTLSManager(logger, cfg.DataPath), - logger: logger, + logger: logger, } - s.im = datasource.NewInstanceManager(s.newInstanceSettings(cfg)) + s.im = datasource.NewInstanceManager(s.newInstanceSettings()) return s } type Service struct { - tlsManager tlsSettingsProvider - im instancemgmt.InstanceManager - logger log.Logger + im instancemgmt.InstanceManager + logger log.Logger } func (s *Service) getDSInfo(ctx context.Context, pluginCtx backend.PluginContext) (*sqleng.DataSourceHandler, error) { @@ -54,20 +60,67 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return dsInfo.QueryData(ctx, req) } -func (s *Service) newInstanceSettings(cfg *setting.Cfg) datasource.InstanceFactoryFunc { +func newPostgres(userFacingDefaultError string, rowLimit int64, dsInfo sqleng.DataSourceInfo, logger log.Logger, proxyClient proxy.Client) (*sql.DB, *sqleng.DataSourceHandler, error) { + pgxConf, err := generateConnectionConfig(dsInfo) + if err != nil { + logger.Error("postgres config creation failed", "error", err) + return nil, nil, fmt.Errorf("postgres config creation failed") + } + + if proxyClient.SecureSocksProxyEnabled() { + dialer, err := proxyClient.NewSecureSocksProxyContextDialer() + if err != nil { + logger.Error("postgres proxy creation failed", "error", err) + return nil, nil, fmt.Errorf("postgres proxy creation failed") + } + + pgxConf.DialFunc = newPgxDialFunc(dialer) + } + + config := sqleng.DataPluginConfiguration{ + DSInfo: dsInfo, + MetricColumnTypes: []string{"UNKNOWN", "TEXT", "VARCHAR", "CHAR"}, + RowLimit: rowLimit, + } + + queryResultTransformer := postgresQueryResultTransformer{} + + db := pgxstdlib.OpenDB(*pgxConf) + + db.SetMaxOpenConns(config.DSInfo.JsonData.MaxOpenConns) + db.SetMaxIdleConns(config.DSInfo.JsonData.MaxIdleConns) + db.SetConnMaxLifetime(time.Duration(config.DSInfo.JsonData.ConnMaxLifetime) * time.Second) + + handler, err := sqleng.NewQueryDataHandler(userFacingDefaultError, db, config, &queryResultTransformer, newPostgresMacroEngine(dsInfo.JsonData.Timescaledb), + logger) + if err != nil { + logger.Error("Failed connecting to Postgres", "err", err) + return nil, nil, err + } + + logger.Debug("Successfully connected to Postgres") + return db, handler, nil +} + +func (s *Service) newInstanceSettings() datasource.InstanceFactoryFunc { logger := s.logger - return func(_ context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - logger.Debug("Creating Postgres query endpoint") + return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + cfg := backend.GrafanaConfigFromContext(ctx) + sqlCfg, err := cfg.SQL() + if err != nil { + return nil, err + } + jsonData := sqleng.JsonData{ - MaxOpenConns: cfg.SqlDatasourceMaxOpenConnsDefault, - MaxIdleConns: cfg.SqlDatasourceMaxIdleConnsDefault, - ConnMaxLifetime: cfg.SqlDatasourceMaxConnLifetimeDefault, + MaxOpenConns: sqlCfg.DefaultMaxOpenConns, + MaxIdleConns: sqlCfg.DefaultMaxIdleConns, + ConnMaxLifetime: sqlCfg.DefaultMaxConnLifetimeSeconds, Timescaledb: false, ConfigurationMethod: "file-path", SecureDSProxy: false, } - err := json.Unmarshal(settings.JSONData, &jsonData) + err = json.Unmarshal(settings.JSONData, &jsonData) if err != nil { return nil, fmt.Errorf("error reading settings: %w", err) } @@ -88,37 +141,18 @@ func (s *Service) newInstanceSettings(cfg *setting.Cfg) datasource.InstanceFacto DecryptedSecureJSONData: settings.DecryptedSecureJSONData, } - cnnstr, err := s.generateConnectionString(dsInfo) + userFacingDefaultError, err := cfg.UserFacingDefaultError() if err != nil { return nil, err } - if cfg.Env == setting.Dev { - logger.Debug("GetEngine", "connection", cnnstr) - } - - driverName := "postgres" - // register a proxy driver if the secure socks proxy is enabled - proxyOpts := proxyutil.GetSQLProxyOptions(cfg.SecureSocksDSProxy, dsInfo) - if sdkproxy.New(proxyOpts).SecureSocksProxyEnabled() { - driverName, err = createPostgresProxyDriver(cnnstr, proxyOpts) - if err != nil { - return "", nil - } - } - - config := sqleng.DataPluginConfiguration{ - DriverName: driverName, - ConnectionString: cnnstr, - DSInfo: dsInfo, - MetricColumnTypes: []string{"UNKNOWN", "TEXT", "VARCHAR", "CHAR"}, - RowLimit: cfg.DataProxyRowLimit, + proxyClient, err := settings.ProxyClient(ctx) + if err != nil { + return nil, err } - queryResultTransformer := postgresQueryResultTransformer{} + _, handler, err := newPostgres(userFacingDefaultError, sqlCfg.RowLimit, dsInfo, logger, proxyClient) - handler, err := sqleng.NewQueryDataHandler(cfg, config, &queryResultTransformer, newPostgresMacroEngine(dsInfo.JsonData.Timescaledb), - logger) if err != nil { logger.Error("Failed connecting to Postgres", "err", err) return nil, err @@ -134,13 +168,11 @@ func escape(input string) string { return strings.ReplaceAll(strings.ReplaceAll(input, `\`, `\\`), "'", `\'`) } -func (s *Service) generateConnectionString(dsInfo sqleng.DataSourceInfo) (string, error) { - logger := s.logger +func generateConnectionConfig(dsInfo sqleng.DataSourceInfo) (*pgx.ConnConfig, error) { var host string var port int if strings.HasPrefix(dsInfo.URL, "/") { host = dsInfo.URL - logger.Debug("Generating connection string with Unix socket specifier", "socket", host) } else { index := strings.LastIndex(dsInfo.URL, ":") v6Index := strings.Index(dsInfo.URL, "]") @@ -151,12 +183,8 @@ func (s *Service) generateConnectionString(dsInfo sqleng.DataSourceInfo) (string var err error port, err = strconv.Atoi(sp[1]) if err != nil { - return "", fmt.Errorf("invalid port in host specifier %q: %w", sp[1], err) + return nil, fmt.Errorf("invalid port in host specifier %q: %w", sp[1], err) } - - logger.Debug("Generating connection string with network host/port pair", "host", host, "port", port) - } else { - logger.Debug("Generating connection string with network host", "host", host) } } else { if index == v6Index+1 { @@ -164,46 +192,45 @@ func (s *Service) generateConnectionString(dsInfo sqleng.DataSourceInfo) (string var err error port, err = strconv.Atoi(dsInfo.URL[index+1:]) if err != nil { - return "", fmt.Errorf("invalid port in host specifier %q: %w", dsInfo.URL[index+1:], err) + return nil, fmt.Errorf("invalid port in host specifier %q: %w", dsInfo.URL[index+1:], err) } - - logger.Debug("Generating ipv6 connection string with network host/port pair", "host", host, "port", port) } else { host = dsInfo.URL[1 : len(dsInfo.URL)-1] - logger.Debug("Generating ipv6 connection string with network host", "host", host) } } } - connStr := fmt.Sprintf("user='%s' password='%s' host='%s' dbname='%s'", + // NOTE: we always set sslmode=disable in the connection string, we handle TLS manually later + connStr := fmt.Sprintf("sslmode=disable user='%s' password='%s' host='%s' dbname='%s'", escape(dsInfo.User), escape(dsInfo.DecryptedSecureJSONData["password"]), escape(host), escape(dsInfo.Database)) if port > 0 { connStr += fmt.Sprintf(" port=%d", port) } - tlsSettings, err := s.tlsManager.getTLSSettings(dsInfo) + conf, err := pgx.ParseConfig(connStr) if err != nil { - return "", err + return nil, err } - connStr += fmt.Sprintf(" sslmode='%s'", escape(tlsSettings.Mode)) + tlsConf, err := tls.GetTLSConfig(dsInfo, os.ReadFile, host) + if err != nil { + return nil, err + } - // Attach root certificate if provided - if tlsSettings.RootCertFile != "" { - logger.Debug("Setting server root certificate", "tlsRootCert", tlsSettings.RootCertFile) - connStr += fmt.Sprintf(" sslrootcert='%s'", escape(tlsSettings.RootCertFile)) + // before we set the TLS config, we need to make sure the `.Fallbacks` attribute is unset, see: + // https://github.com/jackc/pgx/discussions/1903#discussioncomment-8430146 + if len(conf.Fallbacks) > 0 { + return nil, errors.New("tls: fallbacks configured, unable to set up TLS config") } + conf.TLSConfig = tlsConf - // Attach client certificate and key if both are provided - if tlsSettings.CertFile != "" && tlsSettings.CertKeyFile != "" { - logger.Debug("Setting TLS/SSL client auth", "tlsCert", tlsSettings.CertFile, "tlsKey", tlsSettings.CertKeyFile) - connStr += fmt.Sprintf(" sslcert='%s' sslkey='%s'", escape(tlsSettings.CertFile), escape(tlsSettings.CertKeyFile)) - } else if tlsSettings.CertFile != "" || tlsSettings.CertKeyFile != "" { - return "", fmt.Errorf("TLS/SSL client certificate and key must both be specified") + // by default pgx resolves hostnames to ip addresses. we must avoid this. + // (certain socks-proxy related functionality relies on the hostname being preserved) + conf.LookupFunc = func(ctx context.Context, host string) ([]string, error) { + return []string{host}, nil } - logger.Debug("Generated Postgres connection string successfully") - return connStr, nil + return conf, nil } type postgresQueryResultTransformer struct{} @@ -231,6 +258,44 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque func (t *postgresQueryResultTransformer) GetConverterList() []sqlutil.StringConverter { return []sqlutil.StringConverter{ + { + Name: "handle TIME WITH TIME ZONE", + InputScanKind: reflect.Interface, + InputTypeName: strconv.Itoa(pgtype.TimetzOID), + ConversionFunc: func(in *string) (*string, error) { return in, nil }, + Replacer: &sqlutil.StringFieldReplacer{ + OutputFieldType: data.FieldTypeNullableTime, + ReplaceFunc: func(in *string) (any, error) { + if in == nil { + return nil, nil + } + v, err := time.Parse("15:04:05-07", *in) + if err != nil { + return nil, err + } + return &v, nil + }, + }, + }, + { + Name: "handle TIME", + InputScanKind: reflect.Interface, + InputTypeName: "TIME", + ConversionFunc: func(in *string) (*string, error) { return in, nil }, + Replacer: &sqlutil.StringFieldReplacer{ + OutputFieldType: data.FieldTypeNullableTime, + ReplaceFunc: func(in *string) (any, error) { + if in == nil { + return nil, nil + } + v, err := time.Parse("15:04:05", *in) + if err != nil { + return nil, err + } + return &v, nil + }, + }, + }, { Name: "handle FLOAT4", InputScanKind: reflect.Interface, diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_snapshot_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_snapshot_test.go new file mode 100644 index 0000000000000..a9582ec7222fb --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_snapshot_test.go @@ -0,0 +1,195 @@ +package postgres + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana-plugin-sdk-go/experimental" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/tsdb/sqleng" + + _ "github.com/lib/pq" +) + +var updateGoldenFiles = false + +// These tests require a real postgres database: +// - make devenv sources=potgres_tests +// - either set the env variable GRAFANA_TEST_DB = postgres +// - or set `forceRun := true` below +// +// The tests require a PostgreSQL db named grafanadstest and a user/password grafanatest/grafanatest! +// Use the docker/blocks/postgres_tests/docker-compose.yaml to spin up a +// preconfigured Postgres server suitable for running these tests. +func TestIntegrationPostgresSnapshots(t *testing.T) { + // the logic in this function is copied from postgres_tests.go + shouldRunTest := func() bool { + if testing.Short() { + return false + } + + testDbName, present := os.LookupEnv("GRAFANA_TEST_DB") + + if present && testDbName == "postgres" { + return true + } + + return false + } + + if !shouldRunTest() { + t.Skip() + } + + sqlQueryCommentRe := regexp.MustCompile(`^-- (.+)\n`) + + readSqlFile := func(path string) (string, string) { + // the file-path is not coming from the outside, + // it is hardcoded in this file. + //nolint:gosec + sqlBytes, err := os.ReadFile(path) + require.NoError(t, err) + + sql := string(sqlBytes) + + // first line of the file contains the sql query to run, commented out + match := sqlQueryCommentRe.FindStringSubmatch(sql) + require.Len(t, match, 2) + + rawSQL := strings.TrimSpace(match[1]) + + return rawSQL, sql + } + + makeQuery := func(rawSQL string, format string) backend.QueryDataRequest { + queryData := map[string]string{ + "rawSql": rawSQL, + "format": format, + } + + queryBytes, err := json.Marshal(queryData) + require.NoError(t, err) + + return backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: queryBytes, + RefID: "A", + TimeRange: backend.TimeRange{ + From: time.Date(2023, 12, 24, 14, 15, 0, 0, time.UTC), + To: time.Date(2023, 12, 24, 14, 45, 0, 0, time.UTC), + }, + }, + }, + } + } + + tt := []struct { + name string + format string + }{ + {format: "time_series", name: "simple"}, + {format: "time_series", name: "no_rows_long"}, + {format: "time_series", name: "no_rows_wide"}, + {format: "time_series", name: "7x_compat_metric_label"}, + {format: "time_series", name: "convert_to_float64"}, + {format: "time_series", name: "convert_to_float64_not"}, + {format: "time_series", name: "fill_null"}, + {format: "time_series", name: "fill_previous"}, + {format: "time_series", name: "fill_value"}, + {format: "table", name: "simple"}, + {format: "table", name: "no_rows"}, + {format: "table", name: "types_numeric"}, + {format: "table", name: "types_char"}, + {format: "table", name: "types_datetime"}, + {format: "table", name: "types_other"}, + {format: "table", name: "timestamp_convert_bigint"}, + {format: "table", name: "timestamp_convert_integer"}, + {format: "table", name: "timestamp_convert_real"}, + {format: "table", name: "timestamp_convert_double"}, + } + + for _, test := range tt { + require.True(t, test.format == "table" || test.format == "time_series") + t.Run(test.name, func(t *testing.T) { + origInterpolate := sqleng.Interpolate + t.Cleanup(func() { + sqleng.Interpolate = origInterpolate + }) + + sqleng.Interpolate = func(query backend.DataQuery, timeRange backend.TimeRange, timeInterval string, sql string) string { + return sql + } + + jsonData := sqleng.JsonData{ + MaxOpenConns: 0, + MaxIdleConns: 2, + ConnMaxLifetime: 14400, + Timescaledb: false, + ConfigurationMethod: "file-path", + Mode: "disable", + } + + host := os.Getenv("POSTGRES_HOST") + if host == "" { + host = "localhost" + } + port := os.Getenv("POSTGRES_PORT") + if port == "" { + port = "5432" + } + + dsInfo := sqleng.DataSourceInfo{ + JsonData: jsonData, + DecryptedSecureJSONData: map[string]string{ + "password": "grafanatest", + }, + URL: host + ":" + port, + Database: "grafanadstest", + User: "grafanatest", + } + + logger := log.New() + + settings := backend.DataSourceInstanceSettings{} + proxyClient, err := settings.ProxyClient(context.Background()) + require.NoError(t, err) + db, handler, err := newPostgres("error", 10000, dsInfo, logger, proxyClient) + + t.Cleanup((func() { + _, err := db.Exec("DROP TABLE tbl") + require.NoError(t, err) + err = db.Close() + require.NoError(t, err) + })) + + require.NoError(t, err) + + sqlFilePath := filepath.Join("testdata", test.format, test.name+".sql") + goldenFileName := filepath.Join(test.format, test.name+".golden") + + rawSQL, sql := readSqlFile(sqlFilePath) + + _, err = db.Exec(sql) + require.NoError(t, err) + + query := makeQuery(rawSQL, test.format) + + result, err := handler.QueryData(context.Background(), &query) + require.Len(t, result.Responses, 1) + response, found := result.Responses["A"] + require.True(t, found) + require.NoError(t, err) + experimental.CheckGoldenJSONResponse(t, "testdata", goldenFileName, &response, updateGoldenFiles) + }) + } +} diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go index 37f229bde3187..473d876f3de51 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go @@ -2,32 +2,33 @@ package postgres import ( "context" - "database/sql" + "errors" "fmt" "math/rand" + "net" + "net/http" "os" "strings" "testing" "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + backendproxy "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/net/proxy" - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/tls" "github.com/grafana/grafana/pkg/tsdb/sqleng" - _ "github.com/lib/pq" + _ "github.com/jackc/pgx/v5/stdlib" ) -// Test generateConnectionString. -func TestIntegrationGenerateConnectionString(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - cfg := setting.NewCfg() - cfg.DataPath = t.TempDir() +func TestGenerateConnectionConfig(t *testing.T) { + rootCertBytes, err := tls.CreateRandomRootCertBytes() + require.NoError(t, err) testCases := []struct { desc string @@ -35,10 +36,15 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { user string password string database string - tlsSettings tlsSettings - expConnStr string + tlsMode string + tlsRootCert []byte expErr string - uid string + expHost string + expPort uint16 + expUser string + expPassword string + expDatabase string + expTLS bool }{ { desc: "Unix socket host", @@ -46,8 +52,11 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { user: "user", password: "password", database: "database", - tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='/var/run/postgresql' dbname='database' sslmode='verify-full'", + tlsMode: "disable", + expUser: "user", + expPassword: "password", + expHost: "/var/run/postgresql", + expDatabase: "database", }, { desc: "TCP host", @@ -55,8 +64,12 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { user: "user", password: "password", database: "database", - tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='verify-full'", + tlsMode: "disable", + expUser: "user", + expPassword: "password", + expHost: "host", + expPort: 5432, + expDatabase: "database", }, { desc: "TCP/port host", @@ -64,8 +77,12 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { user: "user", password: "password", database: "database", - tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='host' dbname='database' port=1234 sslmode='verify-full'", + tlsMode: "disable", + expUser: "user", + expPassword: "password", + expHost: "host", + expPort: 1234, + expDatabase: "database", }, { desc: "Ipv6 host", @@ -73,8 +90,11 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { user: "user", password: "password", database: "database", - tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='::1' dbname='database' sslmode='verify-full'", + tlsMode: "disable", + expUser: "user", + expPassword: "password", + expHost: "::1", + expDatabase: "database", }, { desc: "Ipv6/port host", @@ -82,16 +102,20 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { user: "user", password: "password", database: "database", - tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='::1' dbname='database' port=1234 sslmode='verify-full'", + tlsMode: "disable", + expUser: "user", + expPassword: "password", + expHost: "::1", + expPort: 1234, + expDatabase: "database", }, { - desc: "Invalid port", - host: "host:invalid", - user: "user", - database: "database", - tlsSettings: tlsSettings{}, - expErr: "invalid port in host specifier", + desc: "Invalid port", + host: "host:invalid", + user: "user", + database: "database", + tlsMode: "disable", + expErr: "invalid port in host specifier", }, { desc: "Password with single quote and backslash", @@ -99,8 +123,11 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { user: "user", password: `p'\assword`, database: "database", - tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: `user='user' password='p\'\\assword' host='host' dbname='database' sslmode='verify-full'`, + tlsMode: "disable", + expUser: "user", + expPassword: `p'\assword`, + expHost: "host", + expDatabase: "database", }, { desc: "User/DB with single quote and backslash", @@ -108,8 +135,11 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { user: `u'\ser`, password: `password`, database: `d'\atabase`, - tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: `user='u\'\\ser' password='password' host='host' dbname='d\'\\atabase' sslmode='verify-full'`, + tlsMode: "disable", + expUser: `u'\ser`, + expPassword: "password", + expDatabase: `d'\atabase`, + expHost: "host", }, { desc: "Custom TLS mode disabled", @@ -117,45 +147,55 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { user: "user", password: "password", database: "database", - tlsSettings: tlsSettings{Mode: "disable"}, - expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='disable'", + tlsMode: "disable", + expUser: "user", + expPassword: "password", + expHost: "host", + expDatabase: "database", }, { - desc: "Custom TLS mode verify-full with certificate files", - host: "host", - user: "user", - password: "password", - database: "database", - tlsSettings: tlsSettings{ - Mode: "verify-full", - RootCertFile: "i/am/coding/ca.crt", - CertFile: "i/am/coding/client.crt", - CertKeyFile: "i/am/coding/client.key", - }, - expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='verify-full' " + - "sslrootcert='i/am/coding/ca.crt' sslcert='i/am/coding/client.crt' sslkey='i/am/coding/client.key'", + desc: "Custom TLS mode verify-full with certificate files", + host: "host", + user: "user", + password: "password", + database: "database", + tlsMode: "verify-full", + tlsRootCert: rootCertBytes, + expUser: "user", + expPassword: "password", + expDatabase: "database", + expHost: "host", + expTLS: true, }, } for _, tt := range testCases { t.Run(tt.desc, func(t *testing.T) { - svc := Service{ - tlsManager: &tlsTestManager{settings: tt.tlsSettings}, - logger: backend.NewLoggerWith("logger", "tsdb.postgres"), - } - ds := sqleng.DataSourceInfo{ - URL: tt.host, - User: tt.user, - DecryptedSecureJSONData: map[string]string{"password": tt.password}, - Database: tt.database, - UID: tt.uid, + URL: tt.host, + User: tt.user, + DecryptedSecureJSONData: map[string]string{ + "password": tt.password, + "tlsCACert": string(tt.tlsRootCert), + }, + Database: tt.database, + JsonData: sqleng.JsonData{ + Mode: tt.tlsMode, + ConfigurationMethod: "file-content", + }, } - connStr, err := svc.generateConnectionString(ds) + c, err := generateConnectionConfig(ds) if tt.expErr == "" { require.NoError(t, err, tt.desc) - assert.Equal(t, tt.expConnStr, connStr) + assert.Equal(t, tt.expHost, c.Host) + if tt.expPort != 0 { + assert.Equal(t, tt.expPort, c.Port) + } + assert.Equal(t, tt.expUser, c.User) + assert.Equal(t, tt.expDatabase, c.Database) + assert.Equal(t, tt.expPassword, c.Password) + require.Equal(t, tt.expTLS, c.TLSConfig != nil) } else { require.Error(t, err, tt.desc) assert.True(t, strings.HasPrefix(err.Error(), tt.expErr), @@ -184,23 +224,22 @@ func TestIntegrationPostgres(t *testing.T) { t.Skip() } - x := InitPostgresTestDB(t) - - origDB := sqleng.NewDB origInterpolate := sqleng.Interpolate t.Cleanup(func() { - sqleng.NewDB = origDB sqleng.Interpolate = origInterpolate }) - sqleng.NewDB = func(d, c string) (*sql.DB, error) { - return x, nil - } - sqleng.Interpolate = func(query backend.DataQuery, timeRange backend.TimeRange, timeInterval string, sql string) (string, error) { - return sql, nil + sqleng.Interpolate = func(query backend.DataQuery, timeRange backend.TimeRange, timeInterval string, sql string) string { + return sql } - cfg := setting.NewCfg() - cfg.DataPath = t.TempDir() + host := os.Getenv("POSTGRES_HOST") + if host == "" { + host = "localhost" + } + port := os.Getenv("POSTGRES_PORT") + if port == "" { + port = "5432" + } jsonData := sqleng.JsonData{ MaxOpenConns: 0, @@ -208,30 +247,28 @@ func TestIntegrationPostgres(t *testing.T) { ConnMaxLifetime: 14400, Timescaledb: false, ConfigurationMethod: "file-path", + Mode: "disable", } dsInfo := sqleng.DataSourceInfo{ - JsonData: jsonData, - DecryptedSecureJSONData: map[string]string{}, - } - - config := sqleng.DataPluginConfiguration{ - DriverName: "postgres", - ConnectionString: "", - DSInfo: dsInfo, - MetricColumnTypes: []string{"UNKNOWN", "TEXT", "VARCHAR", "CHAR"}, - RowLimit: 1000000, + JsonData: jsonData, + DecryptedSecureJSONData: map[string]string{ + "password": "grafanatest", + }, + URL: host + ":" + port, + Database: "grafanadstest", + User: "grafanatest", } - queryResultTransformer := postgresQueryResultTransformer{} - logger := backend.NewLoggerWith("logger", "postgres.test") - exe, err := sqleng.NewQueryDataHandler(cfg, config, &queryResultTransformer, newPostgresMacroEngine(dsInfo.JsonData.Timescaledb), - logger) + + settings := backend.DataSourceInstanceSettings{} + proxyClient, err := settings.ProxyClient(context.Background()) + require.NoError(t, err) + db, exe, err := newPostgres("error", 10000, dsInfo, logger, proxyClient) require.NoError(t, err) - db := x fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) t.Run("Given a table with different native data types", func(t *testing.T) { @@ -427,7 +464,8 @@ func TestIntegrationPostgres(t *testing.T) { "rawSql": "SELECT $__timeGroup(time, $__interval) AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", "format": "time_series" }`), - RefID: "A", + RefID: "A", + Interval: time.Second * 60, TimeRange: backend.TimeRange{ From: fromStart, To: fromStart.Add(30 * time.Minute), @@ -1280,18 +1318,11 @@ func TestIntegrationPostgres(t *testing.T) { }) t.Run("When row limit set to 1", func(t *testing.T) { - dsInfo := sqleng.DataSourceInfo{} - config := sqleng.DataPluginConfiguration{ - DriverName: "postgres", - ConnectionString: "", - DSInfo: dsInfo, - MetricColumnTypes: []string{"UNKNOWN", "TEXT", "VARCHAR", "CHAR"}, - RowLimit: 1, - } - - queryResultTransformer := postgresQueryResultTransformer{} + settings := backend.DataSourceInstanceSettings{} + proxyClient, err := settings.ProxyClient(context.Background()) + require.NoError(t, err) + _, handler, err := newPostgres("error", 1, dsInfo, logger, proxyClient) - handler, err := sqleng.NewQueryDataHandler(setting.NewCfg(), config, &queryResultTransformer, newPostgresMacroEngine(false), logger) require.NoError(t, err) t.Run("When doing a table query that returns 2 rows should limit the result to 1 row", func(t *testing.T) { @@ -1392,13 +1423,6 @@ func TestIntegrationPostgres(t *testing.T) { }) } -func InitPostgresTestDB(t *testing.T) *sql.DB { - connStr := postgresTestDBConnString() - x, err := sql.Open("postgres", connStr) - require.NoError(t, err, "Failed to init postgres DB") - return x -} - func genTimeRangeByInterval(from time.Time, duration time.Duration, interval time.Duration) []time.Time { durationSec := int64(duration.Seconds()) intervalSec := int64(interval.Seconds()) @@ -1412,14 +1436,6 @@ func genTimeRangeByInterval(from time.Time, duration time.Duration, interval tim return timeRange } -type tlsTestManager struct { - settings tlsSettings -} - -func (m *tlsTestManager) getTLSSettings(dsInfo sqleng.DataSourceInfo) (tlsSettings, error) { - return m.settings, nil -} - func isTestDbPostgres() bool { if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { return db == "postgres" @@ -1428,15 +1444,59 @@ func isTestDbPostgres() bool { return false } -func postgresTestDBConnString() string { - host := os.Getenv("POSTGRES_HOST") - if host == "" { - host = "localhost" +type testNoResolveDialer struct { +} + +func (d *testNoResolveDialer) Dial(network, addr string) (c net.Conn, err error) { + return nil, fmt.Errorf("test-dialer: dialing to '%s'. not implemented", addr) +} + +var _ proxy.Dialer = (&testNoResolveDialer{}) + +type testNoResolveProxyClient struct { +} + +var _ backendproxy.Client = (&testNoResolveProxyClient{}) + +func (p *testNoResolveProxyClient) SecureSocksProxyEnabled() bool { + return true +} + +func (p *testNoResolveProxyClient) ConfigureSecureSocksHTTPProxy(transport *http.Transport) error { + return errors.New("testNoResolveProxyClient.ConfigureSecureSocksHTTPProxy not implemented") +} + +func (p *testNoResolveProxyClient) NewSecureSocksProxyContextDialer() (proxy.Dialer, error) { + return &testNoResolveDialer{}, nil +} + +// we must make sure that pgx does not resolve hostnames: +// if we say the hostname is `localhost`, then it should +// instruct the socks-proxy to connect to `localhost`, not to `127.0.0.1` +// this is important, becase some other socks-proxy-code relies on this behavior. +func TestNoResolve(t *testing.T) { + jsonData := sqleng.JsonData{ + MaxOpenConns: 0, + MaxIdleConns: 2, + ConnMaxLifetime: 14400, + Timescaledb: false, + ConfigurationMethod: "file-path", } - port := os.Getenv("POSTGRES_PORT") - if port == "" { - port = "5432" + + dsInfo := sqleng.DataSourceInfo{ + JsonData: jsonData, + DecryptedSecureJSONData: map[string]string{ + "password": "password", + }, + URL: "localhost:5432", + Database: "db", + User: "user", } - return fmt.Sprintf("user=grafanatest password=grafanatest host=%s port=%s dbname=grafanadstest sslmode=disable", - host, port) + + db, _, err := newPostgres("error", 10000, dsInfo, log.New(), &testNoResolveProxyClient{}) + require.NoError(t, err) + require.NotNil(t, db) + err = db.Ping() + require.Error(t, err) + require.Contains(t, err.Error(), "test-dialer: dialing to 'localhost:5432'. not implemented") } diff --git a/pkg/tsdb/grafana-postgresql-datasource/proxy.go b/pkg/tsdb/grafana-postgresql-datasource/proxy.go index a840109993a39..0c836eb34594c 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/proxy.go +++ b/pkg/tsdb/grafana-postgresql-datasource/proxy.go @@ -2,89 +2,18 @@ package postgres import ( "context" - "crypto/md5" - "database/sql" - "database/sql/driver" - "fmt" "net" - "slices" - "time" - sdkproxy "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" - "github.com/lib/pq" "golang.org/x/net/proxy" ) -// createPostgresProxyDriver creates and registers a new sql driver that uses a postgres connector and updates the dialer to -// route connections through the secure socks proxy -func createPostgresProxyDriver(cnnstr string, opts *sdkproxy.Options) (string, error) { - // create a unique driver per connection string - hash := fmt.Sprintf("%x", md5.Sum([]byte(cnnstr))) - driverName := "postgres-proxy-" + hash +type PgxDialFunc = func(ctx context.Context, network string, address string) (net.Conn, error) - // only register the driver once - if !slices.Contains(sql.Drivers(), driverName) { - connector, err := pq.NewConnector(cnnstr) - if err != nil { - return "", err +func newPgxDialFunc(dialer proxy.Dialer) PgxDialFunc { + dialFunc := + func(ctx context.Context, network string, addr string) (net.Conn, error) { + return dialer.Dial(network, addr) } - driver, err := newPostgresProxyDriver(connector, opts) - if err != nil { - return "", err - } - - sql.Register(driverName, driver) - } - return driverName, nil -} - -// postgresProxyDriver is a regular postgres driver with an updated dialer. -// This is done because there is no way to save a dialer to the postgres driver in xorm -type postgresProxyDriver struct { - c *pq.Connector -} - -var _ driver.DriverContext = (*postgresProxyDriver)(nil) - -// newPostgresProxyDriver updates the dialer for a postgres connector with a dialer that proxies connections through the secure socks proxy -// and returns a new postgres driver to register -func newPostgresProxyDriver(connector *pq.Connector, opts *sdkproxy.Options) (*postgresProxyDriver, error) { - dialer, err := sdkproxy.New(opts).NewSecureSocksProxyContextDialer() - if err != nil { - return nil, err - } - - // update the postgres dialer with the proxy dialer - connector.Dialer(&postgresProxyDialer{d: dialer}) - - return &postgresProxyDriver{connector}, nil -} - -// postgresProxyDialer implements the postgres dialer using a proxy dialer, as their functions differ slightly -type postgresProxyDialer struct { - d proxy.Dialer -} - -// Dial uses the normal proxy dial function with the updated dialer -func (p *postgresProxyDialer) Dial(network, addr string) (c net.Conn, err error) { - return p.d.Dial(network, addr) -} - -// DialTimeout uses the normal postgres dial timeout function with the updated dialer -func (p *postgresProxyDialer) DialTimeout(network, address string, timeout time.Duration) (net.Conn, error) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - return p.d.(proxy.ContextDialer).DialContext(ctx, network, address) -} - -// OpenConnector returns the normal postgres connector that has the updated dialer context -func (d *postgresProxyDriver) OpenConnector(name string) (driver.Connector, error) { - return d.c, nil -} - -// Open uses the connector with the updated dialer to open a new connection -func (d *postgresProxyDriver) Open(dsn string) (driver.Conn, error) { - return d.c.Connect(context.Background()) + return dialFunc } diff --git a/pkg/tsdb/grafana-postgresql-datasource/proxy_test.go b/pkg/tsdb/grafana-postgresql-datasource/proxy_test.go index cf178d28d97b3..afd205bd37298 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/proxy_test.go +++ b/pkg/tsdb/grafana-postgresql-datasource/proxy_test.go @@ -1,68 +1,39 @@ package postgres import ( - "context" "fmt" + "net" "testing" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/sqleng" - "github.com/grafana/grafana/pkg/tsdb/sqleng/proxyutil" - "github.com/lib/pq" + "github.com/jackc/pgx/v5" + pgxstdlib "github.com/jackc/pgx/v5/stdlib" "github.com/stretchr/testify/require" + "golang.org/x/net/proxy" ) +type testDialer struct { +} + +func (d *testDialer) Dial(network, addr string) (c net.Conn, err error) { + return nil, fmt.Errorf("test-dialer is not functional") +} + +var _ proxy.Dialer = (&testDialer{}) + func TestPostgresProxyDriver(t *testing.T) { - settings := proxyutil.SetupTestSecureSocksProxySettings(t) - proxySettings := setting.SecureSocksDSProxySettings{ - Enabled: true, - ClientCert: settings.ClientCert, - ClientKey: settings.ClientKey, - RootCA: settings.RootCA, - ProxyAddress: settings.ProxyAddress, - ServerName: settings.ServerName, - } - opts := proxyutil.GetSQLProxyOptions(proxySettings, sqleng.DataSourceInfo{UID: "1", JsonData: sqleng.JsonData{SecureDSProxy: true}}) dbURL := "localhost:5432" cnnstr := fmt.Sprintf("postgres://auser:password@%s/db?sslmode=disable", dbURL) - driverName, err := createPostgresProxyDriver(cnnstr, opts) - require.NoError(t, err) - - t.Run("Driver should not be registered more than once", func(t *testing.T) { - testDriver, err := createPostgresProxyDriver(cnnstr, opts) - require.NoError(t, err) - require.Equal(t, driverName, testDriver) - }) - - t.Run("A new driver should be created for a new connection string", func(t *testing.T) { - testDriver, err := createPostgresProxyDriver("server=localhost;user id=sa;password=yourStrong(!)Password;database=db2", opts) - require.NoError(t, err) - require.NotEqual(t, driverName, testDriver) - }) t.Run("Connector should use dialer context that routes through the socks proxy to db", func(t *testing.T) { - connector, err := pq.NewConnector(cnnstr) - require.NoError(t, err) - driver, err := newPostgresProxyDriver(connector, opts) + pgxConf, err := pgx.ParseConfig(cnnstr) require.NoError(t, err) - conn, err := driver.OpenConnector(cnnstr) - require.NoError(t, err) + pgxConf.DialFunc = newPgxDialFunc(&testDialer{}) - _, err = conn.Connect(context.Background()) - require.Contains(t, err.Error(), fmt.Sprintf("socks connect %s %s->%s", "tcp", settings.ProxyAddress, dbURL)) - }) + db := pgxstdlib.OpenDB(*pgxConf) - t.Run("Connector should use dialer context that routes through the socks proxy to db", func(t *testing.T) { - connector, err := pq.NewConnector(cnnstr) - require.NoError(t, err) - driver, err := newPostgresProxyDriver(connector, opts) - require.NoError(t, err) - - conn, err := driver.OpenConnector(cnnstr) - require.NoError(t, err) + err = db.Ping() - _, err = conn.Connect(context.Background()) - require.Contains(t, err.Error(), fmt.Sprintf("socks connect %s %s->%s", "tcp", settings.ProxyAddress, dbURL)) + require.Contains(t, err.Error(), "test-dialer is not functional") }) } diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/no_rows.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/no_rows.golden.jsonc new file mode 100644 index 0000000000000..2cc0ccc47b8b5 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/no_rows.golden.jsonc @@ -0,0 +1,36 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl WHERE false" +// } +// Name: +// Dimensions: 0 Fields by 0 Rows +// + +// + +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl WHERE false" + }, + "fields": [] + }, + "data": { + "values": [] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/no_rows.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/no_rows.sql new file mode 100644 index 0000000000000..bae1feff9f14a --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/no_rows.sql @@ -0,0 +1,12 @@ +-- SELECT * FROM tbl WHERE false +CREATE TEMPORARY TABLE tbl ( + "time" timestamp with time zone, + v double precision, + c text +); + +INSERT INTO tbl ("time", v, c) VALUES +('2023-12-24 14:30:03 UTC', 10, 'a'), +('2023-12-24 14:30:03 UTC', 110, 'b'), +('2023-12-24 14:31:03 UTC', 20, 'a'), +('2023-12-24 14:31:03 UTC', 120, 'b'); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/simple.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/simple.golden.jsonc new file mode 100644 index 0000000000000..68803e3e77ee6 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/simple.golden.jsonc @@ -0,0 +1,112 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl" +// } +// Name: +// Dimensions: 3 Fields by 10 Rows +// +-------------------------------+------------------+-----------------+ +// | Name: time | Name: v | Name: c | +// | Labels: | Labels: | Labels: | +// | Type: []*time.Time | Type: []*float64 | Type: []*string | +// +-------------------------------+------------------+-----------------+ +// | 2023-12-24 14:30:03 +0000 UTC | 10 | a | +// | 2023-12-24 14:30:03 +0000 UTC | 110 | b | +// | 2023-12-24 14:31:03 +0000 UTC | 20 | a | +// | 2023-12-24 14:31:03 +0000 UTC | 120 | b | +// | 2023-12-24 14:32:03 +0000 UTC | 30 | a | +// | 2023-12-24 14:32:03 +0000 UTC | 130 | b | +// | 2023-12-24 14:33:03 +0000 UTC | 40 | a | +// | 2023-12-24 14:33:03 +0000 UTC | 140 | b | +// | 2023-12-24 14:34:03 +0000 UTC | 50 | a | +// | 2023-12-24 14:34:03 +0000 UTC | 150 | b | +// +-------------------------------+------------------+-----------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl" + }, + "fields": [ + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "v", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + } + }, + { + "name": "c", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + 1703428203000, + 1703428203000, + 1703428263000, + 1703428263000, + 1703428323000, + 1703428323000, + 1703428383000, + 1703428383000, + 1703428443000, + 1703428443000 + ], + [ + 10, + 110, + 20, + 120, + 30, + 130, + 40, + 140, + 50, + 150 + ], + [ + "a", + "b", + "a", + "b", + "a", + "b", + "a", + "b", + "a", + "b" + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/simple.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/simple.sql new file mode 100644 index 0000000000000..b89651e591d16 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/simple.sql @@ -0,0 +1,18 @@ +-- SELECT * FROM tbl +CREATE TEMPORARY TABLE tbl ( + "time" timestamp with time zone, + v double precision, + c text +); + +INSERT INTO tbl ("time", v, c) VALUES +('2023-12-24 14:30:03 UTC', 10, 'a'), +('2023-12-24 14:30:03 UTC', 110, 'b'), +('2023-12-24 14:31:03 UTC', 20, 'a'), +('2023-12-24 14:31:03 UTC', 120, 'b'), +('2023-12-24 14:32:03 UTC', 30, 'a'), +('2023-12-24 14:32:03 UTC', 130, 'b'), +('2023-12-24 14:33:03 UTC', 40, 'a'), +('2023-12-24 14:33:03 UTC', 140, 'b'), +('2023-12-24 14:34:03 UTC', 50, 'a'), +('2023-12-24 14:34:03 UTC', 150, 'b'); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_bigint.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_bigint.golden.jsonc new file mode 100644 index 0000000000000..81fe927d20109 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_bigint.golden.jsonc @@ -0,0 +1,113 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl" +// } +// Name: +// Dimensions: 4 Fields by 4 Rows +// +--------------------------------------+-----------------------------------+---------------------+-----------------------------------+ +// | Name: reallyt | Name: time | Name: n | Name: timeend | +// | Labels: | Labels: | Labels: | Labels: | +// | Type: []*time.Time | Type: []*time.Time | Type: []*int64 | Type: []*time.Time | +// +--------------------------------------+-----------------------------------+---------------------+-----------------------------------+ +// | 2023-12-21 12:21:27 +0000 UTC | 2023-12-21 12:21:27 +0000 UTC | 1703161287 | 2023-12-21 12:21:52 +0000 UTC | +// | 2023-12-21 12:21:27.724 +0000 UTC | 2023-12-21 12:21:27.724 +0000 UTC | 1703161287724 | 2023-12-21 12:21:52.522 +0000 UTC | +// | 2023-12-21 12:21:27.724919 +0000 UTC | 2023-12-21 12:21:27.724 +0000 UTC | 1703161287724919000 | 2023-12-21 12:21:52.522 +0000 UTC | +// | null | null | null | null | +// +--------------------------------------+-----------------------------------+---------------------+-----------------------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl" + }, + "fields": [ + { + "name": "reallyt", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "n", + "type": "number", + "typeInfo": { + "frame": "int64", + "nullable": true + } + }, + { + "name": "timeend", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + 1703161287000, + 1703161287724, + 1703161287724, + null + ], + [ + 1703161287000, + 1703161287724, + 1703161287724, + null + ], + [ + 1703161287, + 1703161287724, + 1703161287724919000, + null + ], + [ + 1703161312000, + 1703161312522, + 1703161312522, + null + ] + ], + "nanos": [ + [ + 0, + 0, + 919000, + 0 + ], + null, + null, + null + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_bigint.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_bigint.sql new file mode 100644 index 0000000000000..fee2235c46bf4 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_bigint.sql @@ -0,0 +1,14 @@ +-- SELECT * FROM tbl +-- the time-field and time-end field gets converted to time.Time +CREATE TEMPORARY TABLE tbl ( + reallyt timestamp with time zone, -- reference real timestamp + "time" bigint, + n bigint, -- normal number, it should not get converted to a timestamp + timeend bigint +); + +INSERT INTO tbl (reallyt, "time", n, timeend) VALUES +('2023-12-21T12:21:27 UTC', 1703161287, 1703161287, 1703161312), +('2023-12-21T12:21:27.724 UTC', 1703161287724, 1703161287724, 1703161312522), +('2023-12-21T12:21:27.724919 UTC', 1703161287724919000, 1703161287724919000, 1703161312522186000), +(NULL, NULL, NULL, NULL); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_double.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_double.golden.jsonc new file mode 100644 index 0000000000000..310adf7157d63 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_double.golden.jsonc @@ -0,0 +1,113 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl" +// } +// Name: +// Dimensions: 4 Fields by 4 Rows +// +--------------------------------------+-----------------------------------+-----------------------+-----------------------------------+ +// | Name: reallyt | Name: time | Name: n | Name: timeend | +// | Labels: | Labels: | Labels: | Labels: | +// | Type: []*time.Time | Type: []*time.Time | Type: []*float64 | Type: []*time.Time | +// +--------------------------------------+-----------------------------------+-----------------------+-----------------------------------+ +// | 2023-12-21 12:21:27 +0000 UTC | 2023-12-21 12:21:27 +0000 UTC | 1.703161287e+09 | 2023-12-21 12:21:52 +0000 UTC | +// | 2023-12-21 12:21:27.724 +0000 UTC | 2023-12-21 12:21:27.724 +0000 UTC | 1.703161287724e+12 | 2023-12-21 12:21:52.522 +0000 UTC | +// | 2023-12-21 12:21:27.724919 +0000 UTC | 2023-12-21 12:21:27.724 +0000 UTC | 1.703161287724919e+18 | 2023-12-21 12:21:52.522 +0000 UTC | +// | null | null | null | null | +// +--------------------------------------+-----------------------------------+-----------------------+-----------------------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl" + }, + "fields": [ + { + "name": "reallyt", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "n", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + } + }, + { + "name": "timeend", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + 1703161287000, + 1703161287724, + 1703161287724, + null + ], + [ + 1703161287000, + 1703161287724, + 1703161287724, + null + ], + [ + 1703161287, + 1703161287724, + 1703161287724919000, + null + ], + [ + 1703161312000, + 1703161312522, + 1703161312522, + null + ] + ], + "nanos": [ + [ + 0, + 0, + 919000, + 0 + ], + null, + null, + null + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_double.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_double.sql new file mode 100644 index 0000000000000..0ae41f6db66fc --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_double.sql @@ -0,0 +1,14 @@ +-- SELECT * FROM tbl +-- the time-field and time-end field gets converted to time.Time +CREATE TEMPORARY TABLE tbl ( + reallyt timestamp with time zone, -- reference real timestamp + "time" double precision, + n double precision, -- normal number, it should not get converted to a timestamp + timeend bigint +); + +INSERT INTO tbl (reallyt, "time", n, timeend) VALUES +('2023-12-21T12:21:27 UTC', 1703161287, 1703161287, 1703161312), +('2023-12-21T12:21:27.724 UTC', 1703161287724, 1703161287724, 1703161312522), +('2023-12-21T12:21:27.724919 UTC', 1703161287724919000, 1703161287724919000, 1703161312522186000), +(NULL, NULL, NULL, NULL); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_integer.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_integer.golden.jsonc new file mode 100644 index 0000000000000..fefca8385abb7 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_integer.golden.jsonc @@ -0,0 +1,92 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl" +// } +// Name: +// Dimensions: 4 Fields by 2 Rows +// +-------------------------------+-------------------------------+----------------+-------------------------------+ +// | Name: reallyt | Name: time | Name: n | Name: timeend | +// | Labels: | Labels: | Labels: | Labels: | +// | Type: []*time.Time | Type: []*time.Time | Type: []*int32 | Type: []*time.Time | +// +-------------------------------+-------------------------------+----------------+-------------------------------+ +// | 2023-12-21 12:21:27 +0000 UTC | 2023-12-21 12:21:27 +0000 UTC | 1703161287 | 2023-12-21 12:21:52 +0000 UTC | +// | null | null | null | null | +// +-------------------------------+-------------------------------+----------------+-------------------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl" + }, + "fields": [ + { + "name": "reallyt", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "n", + "type": "number", + "typeInfo": { + "frame": "int32", + "nullable": true + } + }, + { + "name": "timeend", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + 1703161287000, + null + ], + [ + 1703161287000, + null + ], + [ + 1703161287, + null + ], + [ + 1703161312000, + null + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_integer.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_integer.sql new file mode 100644 index 0000000000000..87bec5e98bea8 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_integer.sql @@ -0,0 +1,12 @@ +-- SELECT * FROM tbl +-- the time-field and time-end field gets converted to time.Time +CREATE TEMPORARY TABLE tbl ( + reallyt timestamp with time zone, -- reference real timestamp + "time" integer, -- 32bits, seconds-as-number is highest we can go, milliseconds-as-number does not fit + n integer, -- normal number, it should not get converted to a timestamp + timeend integer +); + +INSERT INTO tbl (reallyt, "time", n, timeend) VALUES +('2023-12-21T12:21:27 UTC', 1703161287, 1703161287, 1703161312), +(NULL, NULL, NULL, NULL); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_real.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_real.golden.jsonc new file mode 100644 index 0000000000000..e6d1dfd238295 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_real.golden.jsonc @@ -0,0 +1,113 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl" +// } +// Name: +// Dimensions: 4 Fields by 4 Rows +// +--------------------------------------+-----------------------------------+-----------------------+-----------------------------------+ +// | Name: reallyt | Name: time | Name: n | Name: timeend | +// | Labels: | Labels: | Labels: | Labels: | +// | Type: []*time.Time | Type: []*time.Time | Type: []*float64 | Type: []*time.Time | +// +--------------------------------------+-----------------------------------+-----------------------+-----------------------------------+ +// | 2023-12-21 12:22:24 +0000 UTC | 2023-12-21 12:22:24 +0000 UTC | 1.703161344e+09 | 2023-12-21 12:22:52 +0000 UTC | +// | 2023-12-21 12:20:33.408 +0000 UTC | 2023-12-21 12:20:33.408 +0000 UTC | 1.703161233408e+12 | 2023-12-21 12:21:52.522 +0000 UTC | +// | 2023-12-21 12:20:41.050022 +0000 UTC | 2023-12-21 12:20:41.05 +0000 UTC | 1.703161241050022e+18 | 2023-12-21 12:21:52.522 +0000 UTC | +// | null | null | null | null | +// +--------------------------------------+-----------------------------------+-----------------------+-----------------------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl" + }, + "fields": [ + { + "name": "reallyt", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "n", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + } + }, + { + "name": "timeend", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + 1703161344000, + 1703161233408, + 1703161241050, + null + ], + [ + 1703161344000, + 1703161233408, + 1703161241050, + null + ], + [ + 1703161344, + 1703161233408, + 1703161241050022000, + null + ], + [ + 1703161372000, + 1703161312522, + 1703161312522, + null + ] + ], + "nanos": [ + [ + 0, + 0, + 22000, + 0 + ], + null, + null, + null + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_real.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_real.sql new file mode 100644 index 0000000000000..b0d00ce9773b4 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/timestamp_convert_real.sql @@ -0,0 +1,14 @@ +-- SELECT * FROM tbl +-- the time-field and time-end field gets converted to time.Time +CREATE TEMPORARY TABLE tbl ( + reallyt timestamp with time zone, -- reference real timestamp + "time" real, + n real, -- normal number, it should not get converted to a timestamp + timeend bigint +); + +INSERT INTO tbl (reallyt, "time", n, timeend) VALUES +('2023-12-21T12:22:24', 1703161344, 1703161344, 1703161372), +('2023-12-21T12:20:33.408', 1703161233408, 1703161233408, 1703161312522), +('2023-12-21T12:20:41.050022', 1703161241050021888, 1703161241050021888, 1703161312522186000), +(NULL, NULL, NULL, NULL); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_char.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_char.golden.jsonc new file mode 100644 index 0000000000000..fb9afae767539 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_char.golden.jsonc @@ -0,0 +1,140 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl" +// } +// Name: +// Dimensions: 8 Fields by 2 Rows +// +-----------------+-----------------+-----------------+-----------------+-----------------+-----------------+-----------------+-----------------+ +// | Name: cv | Name: cvnn | Name: c | Name: cnn | Name: bpc | Name: bpcnn | Name: t | Name: tnn | +// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []*string | Type: []*string | Type: []*string | Type: []*string | Type: []*string | Type: []*string | Type: []*string | Type: []*string | +// +-----------------+-----------------+-----------------+-----------------+-----------------+-----------------+-----------------+-----------------+ +// | one | two | three | four | five | six | seven | eight | +// | null | xtwo | null | xfour | null | xsix | null | xeight | +// +-----------------+-----------------+-----------------+-----------------+-----------------+-----------------+-----------------+-----------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl" + }, + "fields": [ + { + "name": "cv", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "cvnn", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "c", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "cnn", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "bpc", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "bpcnn", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "t", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "tnn", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + "one", + null + ], + [ + "two", + "xtwo" + ], + [ + "three ", + null + ], + [ + "four ", + "xfour " + ], + [ + "five", + null + ], + [ + "six", + "xsix" + ], + [ + "seven", + null + ], + [ + "eight", + "xeight" + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_char.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_char.sql new file mode 100644 index 0000000000000..34551aa9442b3 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_char.sql @@ -0,0 +1,16 @@ +-- SELECT * FROM tbl +-- test all character-based postgres data types +CREATE TEMPORARY TABLE tbl ( + cv character varying(10), + cvnn character varying(10) NOT NULL, + c character(10), + cnn character(10) NOT NULL, + bpc bpchar, + bpcnn bpchar NOT NULL, + t text, + tnn text NOT NULL +); + +INSERT INTO tbl (cv, cvnn, c, cnn, bpc, bpcnn, t, tnn) VALUES +('one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight'), +(NULL, 'xtwo', NULL, 'xfour', NULL, 'xsix', NULL, 'xeight'); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime.golden.jsonc new file mode 100644 index 0000000000000..09e9403459bb6 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime.golden.jsonc @@ -0,0 +1,226 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl" +// } +// Name: +// Dimensions: 12 Fields by 2 Rows +// +--------------------------------------+--------------------------------------+--------------------------------------+--------------------------------------+-------------------------------+-------------------------------+--------------------------------------+--------------------------------------+----------------------------------------+----------------------------------------+-----------------+-----------------+ +// | Name: ts | Name: tsnn | Name: tsz | Name: tsznn | Name: d | Name: dnn | Name: t | Name: tnn | Name: tz | Name: tznn | Name: i | Name: inn | +// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*string | Type: []*string | +// +--------------------------------------+--------------------------------------+--------------------------------------+--------------------------------------+-------------------------------+-------------------------------+--------------------------------------+--------------------------------------+----------------------------------------+----------------------------------------+-----------------+-----------------+ +// | 2023-11-15 05:06:07.123456 +0000 UTC | 2023-11-15 05:06:08.123456 +0000 UTC | 2021-07-22 11:22:33.654321 +0000 UTC | 2021-07-22 11:22:34.654321 +0000 UTC | 2023-12-20 00:00:00 +0000 UTC | 2023-12-21 00:00:00 +0000 UTC | 0000-01-01 12:34:56.234567 +0000 UTC | 0000-01-01 12:34:57.234567 +0000 UTC | 0000-01-01 23:12:36.765432 +0100 +0100 | 0000-01-01 23:12:37.765432 +0100 +0100 | 00:00:00.987654 | 00:00:00.887654 | +// | null | 2023-11-15 05:06:09.123456 +0000 UTC | null | 2021-07-22 11:22:35.654321 +0000 UTC | null | 2023-12-22 00:00:00 +0000 UTC | null | 0000-01-01 12:34:58.234567 +0000 UTC | null | 0000-01-01 23:12:38.765432 +0100 +0100 | null | 00:00:00.787654 | +// +--------------------------------------+--------------------------------------+--------------------------------------+--------------------------------------+-------------------------------+-------------------------------+--------------------------------------+--------------------------------------+----------------------------------------+----------------------------------------+-----------------+-----------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl" + }, + "fields": [ + { + "name": "ts", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "tsnn", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "tsz", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "tsznn", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "d", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "dnn", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "t", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "tnn", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "tz", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "tznn", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "i", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "inn", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + 1700024767123, + null + ], + [ + 1700024768123, + 1700024769123 + ], + [ + 1626952953654, + null + ], + [ + 1626952954654, + 1626952955654 + ], + [ + 1703030400000, + null + ], + [ + 1703116800000, + 1703203200000 + ], + [ + -62167173903766, + null + ], + [ + -62167173902766, + -62167173901766 + ], + [ + -62167139243235, + null + ], + [ + -62167139242235, + -62167139241235 + ], + [ + "00:00:00.987654", + null + ], + [ + "00:00:00.887654", + "00:00:00.787654" + ] + ], + "nanos": [ + [ + 456000, + 0 + ], + [ + 456000, + 456000 + ], + [ + 321000, + 0 + ], + [ + 321000, + 321000 + ], + null, + null, + [ + 567000, + 0 + ], + [ + 567000, + 567000 + ], + [ + 432000, + 0 + ], + [ + 432000, + 432000 + ], + null, + null + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime.sql new file mode 100644 index 0000000000000..d81b3ca235bd1 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime.sql @@ -0,0 +1,44 @@ +-- SELECT * FROM tbl +-- test all date/time-based postgres data types +CREATE TEMPORARY TABLE tbl ( + ts timestamp, + tsnn timestamp NOT NULL, + tsz timestamp with time zone, + tsznn timestamp with time zone NOT NULL, + d date, + dnn date NOT NULL, + t time, + tnn time NOT NULL, + tz time with time zone, + tznn time with time zone NOT NULL, + i interval, + inn interval NOT NULL +); + +INSERT INTO tbl (ts, tsnn, tsz, tsznn, d, dnn, t, tnn, tz, tznn, i, inn) VALUES ( +'2023-11-15 05:06:07.123456', +'2023-11-15 05:06:08.123456', +'2021-07-22 13:22:33.654321 Europe/Berlin', +'2021-07-22 13:22:34.654321 Europe/Berlin', +'2023-12-20', +'2023-12-21', +'12:34:56.234567', +'12:34:57.234567', +'23:12:36.765432+1', +'23:12:37.765432+1', +'987654 microsecond', +'887654 microsecond' +), ( +NULL, +'2023-11-15 05:06:09.123456', +NULL, +'2021-07-22 13:22:35.654321 Europe/Berlin', +NULL, +'2023-12-22', +NULL, +'12:34:58.234567', +NULL, +'23:12:38.765432+1', +NULL, +'787654 microsecond' +); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_numeric.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_numeric.golden.jsonc new file mode 100644 index 0000000000000..d8ac6c700a6a1 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_numeric.golden.jsonc @@ -0,0 +1,188 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl" +// } +// Name: +// Dimensions: 12 Fields by 2 Rows +// +----------------+----------------+----------------+----------------+----------------+----------------+------------------+------------------+------------------+------------------+------------------+------------------+ +// | Name: i16 | Name: i16nn | Name: i32 | Name: i32nn | Name: i64 | Name: i64nn | Name: n | Name: nnn | Name: f32 | Name: f32nn | Name: f64 | Name: f64nn | +// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []*int16 | Type: []*int16 | Type: []*int32 | Type: []*int32 | Type: []*int64 | Type: []*int64 | Type: []*float64 | Type: []*float64 | Type: []*float64 | Type: []*float64 | Type: []*float64 | Type: []*float64 | +// +----------------+----------------+----------------+----------------+----------------+----------------+------------------+------------------+------------------+------------------+------------------+------------------+ +// | 1 | 2 | 3 | 4 | 5 | 6 | 81.75 | 7065.25 | 30.75 | 14.625 | 21.5625 | 14.25 | +// | null | 22 | null | 44 | null | 66 | null | 169.75 | null | 77.125 | null | 215.8125 | +// +----------------+----------------+----------------+----------------+----------------+----------------+------------------+------------------+------------------+------------------+------------------+------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl" + }, + "fields": [ + { + "name": "i16", + "type": "number", + "typeInfo": { + "frame": "int16", + "nullable": true + } + }, + { + "name": "i16nn", + "type": "number", + "typeInfo": { + "frame": "int16", + "nullable": true + } + }, + { + "name": "i32", + "type": "number", + "typeInfo": { + "frame": "int32", + "nullable": true + } + }, + { + "name": "i32nn", + "type": "number", + "typeInfo": { + "frame": "int32", + "nullable": true + } + }, + { + "name": "i64", + "type": "number", + "typeInfo": { + "frame": "int64", + "nullable": true + } + }, + { + "name": "i64nn", + "type": "number", + "typeInfo": { + "frame": "int64", + "nullable": true + } + }, + { + "name": "n", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + } + }, + { + "name": "nnn", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + } + }, + { + "name": "f32", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + } + }, + { + "name": "f32nn", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + } + }, + { + "name": "f64", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + } + }, + { + "name": "f64nn", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + 1, + null + ], + [ + 2, + 22 + ], + [ + 3, + null + ], + [ + 4, + 44 + ], + [ + 5, + null + ], + [ + 6, + 66 + ], + [ + 81.75, + null + ], + [ + 7065.25, + 169.75 + ], + [ + 30.75, + null + ], + [ + 14.625, + 77.125 + ], + [ + 21.5625, + null + ], + [ + 14.25, + 215.8125 + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_numeric.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_numeric.sql new file mode 100644 index 0000000000000..cd8a5c15f5199 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_numeric.sql @@ -0,0 +1,20 @@ +-- SELECT * FROM tbl +-- test all numeric postgres data types +CREATE TEMPORARY TABLE tbl ( + i16 smallint, + i16nn smallint NOT NULL, + i32 integer, + i32nn integer NOT NULL, + i64 bigint, + i64nn bigint NOT NULL, + n numeric(7, 2), + nnn numeric(7,2) NOT NULL, + f32 real, + f32nn real NOT NULL, + f64 double precision, + f64nn double precision NOT NULL +); + +INSERT INTO tbl (i16, i16nn, i32, i32nn, i64, i64nn, n, nnn, f32, f32nn, f64, f64nn) VALUES +(1, 2, 3, 4, 5, 6, 81.75, 7065.25, 30.75, 14.625, 21.5625, 14.25), +(NULL, 22, NULL, 44, NULL, 66, NULL, 169.75, NULL, 77.125, NULL, 215.8125); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_other.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_other.golden.jsonc new file mode 100644 index 0000000000000..f0eb0ba94048f --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_other.golden.jsonc @@ -0,0 +1,123 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl" +// } +// Name: +// Dimensions: 6 Fields by 3 Rows +// +-----------------+-----------------+-----------------+-----------------+---------------+---------------+ +// | Name: m | Name: mnn | Name: bt | Name: btnn | Name: bl | Name: blnn | +// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []*string | Type: []*string | Type: []*string | Type: []*string | Type: []*bool | Type: []*bool | +// +-----------------+-----------------+-----------------+-----------------+---------------+---------------+ +// | $12.34 | $23.45 | ABCD | EFGH | true | true | +// | $12.34 | $23.45 | QRST | UVWX | false | false | +// | null | $34.56 | null | abcd | null | true | +// +-----------------+-----------------+-----------------+-----------------+---------------+---------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl" + }, + "fields": [ + { + "name": "m", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "mnn", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "bt", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "btnn", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "bl", + "type": "boolean", + "typeInfo": { + "frame": "bool", + "nullable": true + } + }, + { + "name": "blnn", + "type": "boolean", + "typeInfo": { + "frame": "bool", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + "$12.34", + "$12.34", + null + ], + [ + "$23.45", + "$23.45", + "$34.56" + ], + [ + "ABCD", + "QRST", + null + ], + [ + "EFGH", + "UVWX", + "abcd" + ], + [ + true, + false, + null + ], + [ + true, + false, + true + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_other.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_other.sql new file mode 100644 index 0000000000000..0d8c2e87920c8 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_other.sql @@ -0,0 +1,15 @@ +-- SELECT * FROM tbl +-- test the less used postgres data types +CREATE TEMPORARY TABLE tbl ( + m money, + mnn money NOT NULL, + bt bytea, + btnn bytea NOT NULL, + bl boolean, + blnn boolean NOT NULL +); + +INSERT INTO tbl (m, mnn, bt, btnn, bl, blnn) VALUES +(12.34, 23.45, '\x41424344'::bytea, '\x45464748'::bytea, TRUE, TRUE), +(12.34, 23.45, '\x51525354'::bytea, '\x55565758'::bytea, FALSE, FALSE), +(NULL, 34.56, NULL, '\x61626364'::bytea, NULL, TRUE); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/7x_compat_metric_label.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/7x_compat_metric_label.golden.jsonc new file mode 100644 index 0000000000000..4bbf17b84250e --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/7x_compat_metric_label.golden.jsonc @@ -0,0 +1,93 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "type": "timeseries-wide", +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl" +// } +// Name: +// Dimensions: 3 Fields by 5 Rows +// +-------------------------------+------------------+------------------+ +// | Name: Time | Name: a | Name: b | +// | Labels: | Labels: | Labels: | +// | Type: []time.Time | Type: []*float64 | Type: []*float64 | +// +-------------------------------+------------------+------------------+ +// | 2023-12-24 14:30:03 +0000 UTC | 10 | 110 | +// | 2023-12-24 14:31:03 +0000 UTC | 20 | 120 | +// | 2023-12-24 14:32:03 +0000 UTC | 30 | 130 | +// | 2023-12-24 14:33:03 +0000 UTC | 40 | 140 | +// | 2023-12-24 14:34:03 +0000 UTC | 50 | 150 | +// +-------------------------------+------------------+------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "type": "timeseries-wide", + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl" + }, + "fields": [ + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "a", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + } + }, + { + "name": "b", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + 1703428203000, + 1703428263000, + 1703428323000, + 1703428383000, + 1703428443000 + ], + [ + 10, + 20, + 30, + 40, + 50 + ], + [ + 110, + 120, + 130, + 140, + 150 + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/7x_compat_metric_label.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/7x_compat_metric_label.sql new file mode 100644 index 0000000000000..8e3256f47893a --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/7x_compat_metric_label.sql @@ -0,0 +1,19 @@ +-- SELECT * FROM tbl +-- there's special backward-compat code which handles a field named 'metric' +CREATE TEMPORARY TABLE tbl ( + "time" timestamp with time zone, + v double precision, + metric text +); + +INSERT INTO tbl ("time", v, metric) VALUES +('2023-12-24 14:30:03 UTC', 10, 'a'), +('2023-12-24 14:30:03 UTC', 110, 'b'), +('2023-12-24 14:31:03 UTC', 20, 'a'), +('2023-12-24 14:31:03 UTC', 120, 'b'), +('2023-12-24 14:32:03 UTC', 30, 'a'), +('2023-12-24 14:32:03 UTC', 130, 'b'), +('2023-12-24 14:33:03 UTC', 40, 'a'), +('2023-12-24 14:33:03 UTC', 140, 'b'), +('2023-12-24 14:34:03 UTC', 50, 'a'), +('2023-12-24 14:34:03 UTC', 150, 'b'); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/convert_to_float64.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/convert_to_float64.golden.jsonc new file mode 100644 index 0000000000000..7411c92183709 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/convert_to_float64.golden.jsonc @@ -0,0 +1,207 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "type": "timeseries-wide", +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl" +// } +// Name: +// Dimensions: 11 Fields by 2 Rows +// +-------------------------------+------------------+------------------+------------------+------------------+------------------+------------------+------------------+------------------+------------------+------------------+ +// | Name: Time | Name: v1 | Name: v1nn | Name: v2 | Name: v2nn | Name: x1 | Name: x1nn | Name: x2 | Name: x2nn | Name: x3 | Name: x3nn | +// | Labels: | Labels: t=one | Labels: t=one | Labels: t=one | Labels: t=one | Labels: t=one | Labels: t=one | Labels: t=one | Labels: t=one | Labels: t=one | Labels: t=one | +// | Type: []time.Time | Type: []*float64 | Type: []*float64 | Type: []*float64 | Type: []*float64 | Type: []*float64 | Type: []*float64 | Type: []*float64 | Type: []*float64 | Type: []*float64 | Type: []*float64 | +// +-------------------------------+------------------+------------------+------------------+------------------+------------------+------------------+------------------+------------------+------------------+------------------+ +// | 2023-12-21 11:30:03 +0000 UTC | 3.78125 | 451.5625 | 52.25 | 511.3125 | 101 | 102 | 103 | 104 | 105 | 106 | +// | 2023-12-21 11:31:03 +0000 UTC | null | 464.375 | null | 346.125 | null | 202 | null | 204 | null | 206 | +// +-------------------------------+------------------+------------------+------------------+------------------+------------------+------------------+------------------+------------------+------------------+------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "type": "timeseries-wide", + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl" + }, + "fields": [ + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "v1", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "t": "one" + } + }, + { + "name": "v1nn", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "t": "one" + } + }, + { + "name": "v2", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "t": "one" + } + }, + { + "name": "v2nn", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "t": "one" + } + }, + { + "name": "x1", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "t": "one" + } + }, + { + "name": "x1nn", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "t": "one" + } + }, + { + "name": "x2", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "t": "one" + } + }, + { + "name": "x2nn", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "t": "one" + } + }, + { + "name": "x3", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "t": "one" + } + }, + { + "name": "x3nn", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "t": "one" + } + } + ] + }, + "data": { + "values": [ + [ + 1703158203000, + 1703158263000 + ], + [ + 3.78125, + null + ], + [ + 451.5625, + 464.375 + ], + [ + 52.25, + null + ], + [ + 511.3125, + 346.125 + ], + [ + 101, + null + ], + [ + 102, + 202 + ], + [ + 103, + null + ], + [ + 104, + 204 + ], + [ + 105, + null + ], + [ + 106, + 206 + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/convert_to_float64.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/convert_to_float64.sql new file mode 100644 index 0000000000000..9cce0c3b107e3 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/convert_to_float64.sql @@ -0,0 +1,29 @@ +-- SELECT * FROM tbl +-- in timeseries mode, most fields gets converted to float64 +CREATE TEMPORARY TABLE tbl ( + "time" timestamp with time zone, + v1 double precision, + v1nn double precision NOT NULL, + v2 real, + v2nn real NOT NULL, + t text, + x1 smallint, + x1nn smallint NOT NULL, + x2 integer, + x2nn integer NOT NULL, + x3 bigint, + x3nn bigint NOT NULL +); + +INSERT INTO tbl ("time", +v1, v1nn, v2, v2nn, +t, +x1, x1nn, x2, x2nn, x3, x3nn) VALUES +('2023-12-21 11:30:03 UTC', +3.78125, 451.5625, 52.25, 511.3125, +'one', +101, 102, 103, 104, 105, 106), +('2023-12-21 11:31:03 UTC', +NULL, 464.375, NULL, 346.125, +'one', +NULL, 202, NULL, 204, NULL, 206); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/convert_to_float64_not.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/convert_to_float64_not.golden.jsonc new file mode 100644 index 0000000000000..df0fcf391436a --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/convert_to_float64_not.golden.jsonc @@ -0,0 +1,97 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "type": "timeseries-wide", +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl" +// } +// Name: +// Dimensions: 3 Fields by 2 Rows +// +-------------------------------+--------------------------------------------------------------------+-------------------------------------------------------------------------------+ +// | Name: Time | Name: v | Name: v | +// | Labels: | Labels: c1=, c1nn=twelve, c2=, c2nn=fourteen, c3=, c3nn=sixteen | Labels: c1=one, c1nn=two, c2=three, c2nn=four, c3=five , c3nn=six | +// | Type: []time.Time | Type: []*float64 | Type: []*float64 | +// +-------------------------------+--------------------------------------------------------------------+-------------------------------------------------------------------------------+ +// | 2023-12-21 11:30:03 +0000 UTC | null | 10.1 | +// | 2023-12-21 11:31:03 +0000 UTC | 20.1 | null | +// +-------------------------------+--------------------------------------------------------------------+-------------------------------------------------------------------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "type": "timeseries-wide", + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl" + }, + "fields": [ + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "v", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "c1": "", + "c1nn": "twelve", + "c2": "", + "c2nn": "fourteen", + "c3": "", + "c3nn": "sixteen " + } + }, + { + "name": "v", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "c1": "one", + "c1nn": "two", + "c2": "three", + "c2nn": "four", + "c3": "five ", + "c3nn": "six " + } + } + ] + }, + "data": { + "values": [ + [ + 1703158203000, + 1703158263000 + ], + [ + null, + 20.1 + ], + [ + 10.1, + null + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/convert_to_float64_not.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/convert_to_float64_not.sql new file mode 100644 index 0000000000000..e360e7fe164de --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/convert_to_float64_not.sql @@ -0,0 +1,22 @@ +-- SELECT * FROM tbl +-- in timeseries mode, most fields gets converted to float64, but text-fields should stay text +CREATE TEMPORARY TABLE tbl ( + "time" timestamp with time zone, + v double precision NOT NULL, + c1 text, + c1nn text NOT NULL, + c2 varchar(10), + c2nn varchar(10) NOT NULL, + c3 char(10), + c3nn char(10) NOT NULL +); + +INSERT INTO tbl ("time", +v, +c1, c1nn, c2, c2nn, c3, c3nn) VALUES +('2023-12-21 11:30:03 UTC', +10.1, +'one', 'two', 'three', 'four', 'five', 'six'), +('2023-12-21 11:31:03 UTC', +20.1, +NULL, 'twelve', NULL, 'fourteen', NULL, 'sixteen'); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_null.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_null.golden.jsonc new file mode 100644 index 0000000000000..ce457d9d7335d --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_null.golden.jsonc @@ -0,0 +1,107 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "type": "timeseries-wide", +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT floor(extract(epoch from \"time\")/300)*300 AS \"time\",c,avg(v) AS \"v\" FROM tbl GROUP BY 1,2 ORDER BY 1,2" +// } +// Name: +// Dimensions: 3 Fields by 7 Rows +// +-------------------------------+------------------+------------------+ +// | Name: Time | Name: v | Name: v | +// | Labels: | Labels: c=a | Labels: c=b | +// | Type: []time.Time | Type: []*float64 | Type: []*float64 | +// +-------------------------------+------------------+------------------+ +// | 2023-12-24 14:15:00 +0000 UTC | null | null | +// | 2023-12-24 14:20:00 +0000 UTC | 15 | 115 | +// | 2023-12-24 14:25:00 +0000 UTC | null | null | +// | 2023-12-24 14:30:00 +0000 UTC | null | null | +// | 2023-12-24 14:35:00 +0000 UTC | 50 | 150 | +// | 2023-12-24 14:40:00 +0000 UTC | null | null | +// | 2023-12-24 14:45:00 +0000 UTC | null | null | +// +-------------------------------+------------------+------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "type": "timeseries-wide", + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT floor(extract(epoch from \"time\")/300)*300 AS \"time\",c,avg(v) AS \"v\" FROM tbl GROUP BY 1,2 ORDER BY 1,2" + }, + "fields": [ + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "v", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "c": "a" + } + }, + { + "name": "v", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "c": "b" + } + } + ] + }, + "data": { + "values": [ + [ + 1703427300000, + 1703427600000, + 1703427900000, + 1703428200000, + 1703428500000, + 1703428800000, + 1703429100000 + ], + [ + null, + 15, + null, + null, + 50, + null, + null + ], + [ + null, + 115, + null, + null, + 150, + null, + null + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_null.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_null.sql new file mode 100644 index 0000000000000..d3ae81dad8fd5 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_null.sql @@ -0,0 +1,15 @@ +-- SELECT $__timeGroup("time",5m,NULL),c,avg(v) AS "v" FROM tbl GROUP BY 1,2 ORDER BY 1,2 +-- tests fill-mode=null +CREATE TEMPORARY TABLE tbl ( + "time" timestamp with time zone, + v double precision, + c text +); + +INSERT INTO tbl ("time", v, c) VALUES +('2023-12-24 14:21:03 UTC', 10, 'a'), +('2023-12-24 14:21:03 UTC', 110, 'b'), +('2023-12-24 14:23:03 UTC', 20, 'a'), +('2023-12-24 14:23:03 UTC', 120, 'b'), +('2023-12-24 14:39:03 UTC', 50, 'a'), +('2023-12-24 14:39:03 UTC', 150, 'b'); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_previous.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_previous.golden.jsonc new file mode 100644 index 0000000000000..a6ba3b9f68ab0 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_previous.golden.jsonc @@ -0,0 +1,107 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "type": "timeseries-wide", +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT floor(extract(epoch from \"time\")/300)*300 AS \"time\",c,avg(v) AS \"v\" FROM tbl GROUP BY 1,2 ORDER BY 1,2" +// } +// Name: +// Dimensions: 3 Fields by 7 Rows +// +-------------------------------+------------------+------------------+ +// | Name: Time | Name: v | Name: v | +// | Labels: | Labels: c=a | Labels: c=b | +// | Type: []time.Time | Type: []*float64 | Type: []*float64 | +// +-------------------------------+------------------+------------------+ +// | 2023-12-24 14:15:00 +0000 UTC | null | null | +// | 2023-12-24 14:20:00 +0000 UTC | 15 | 115 | +// | 2023-12-24 14:25:00 +0000 UTC | 15 | 115 | +// | 2023-12-24 14:30:00 +0000 UTC | 15 | 115 | +// | 2023-12-24 14:35:00 +0000 UTC | 50 | 150 | +// | 2023-12-24 14:40:00 +0000 UTC | 50 | 150 | +// | 2023-12-24 14:45:00 +0000 UTC | 50 | 150 | +// +-------------------------------+------------------+------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "type": "timeseries-wide", + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT floor(extract(epoch from \"time\")/300)*300 AS \"time\",c,avg(v) AS \"v\" FROM tbl GROUP BY 1,2 ORDER BY 1,2" + }, + "fields": [ + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "v", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "c": "a" + } + }, + { + "name": "v", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "c": "b" + } + } + ] + }, + "data": { + "values": [ + [ + 1703427300000, + 1703427600000, + 1703427900000, + 1703428200000, + 1703428500000, + 1703428800000, + 1703429100000 + ], + [ + null, + 15, + 15, + 15, + 50, + 50, + 50 + ], + [ + null, + 115, + 115, + 115, + 150, + 150, + 150 + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_previous.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_previous.sql new file mode 100644 index 0000000000000..625155a9e78e2 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_previous.sql @@ -0,0 +1,15 @@ +-- SELECT $__timeGroup("time",5m,previous),c,avg(v) AS "v" FROM tbl GROUP BY 1,2 ORDER BY 1,2 +-- tests fill-mode=previous +CREATE TEMPORARY TABLE tbl ( + "time" timestamp with time zone, + v double precision, + c text +); + +INSERT INTO tbl ("time", v, c) VALUES +('2023-12-24 14:21:03 UTC', 10, 'a'), +('2023-12-24 14:21:03 UTC', 110, 'b'), +('2023-12-24 14:23:03 UTC', 20, 'a'), +('2023-12-24 14:23:03 UTC', 120, 'b'), +('2023-12-24 14:39:03 UTC', 50, 'a'), +('2023-12-24 14:39:03 UTC', 150, 'b'); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_value.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_value.golden.jsonc new file mode 100644 index 0000000000000..a0d1acc4ec618 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_value.golden.jsonc @@ -0,0 +1,107 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "type": "timeseries-wide", +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT floor(extract(epoch from \"time\")/300)*300 AS \"time\",c,avg(v) AS \"v\" FROM tbl GROUP BY 1,2 ORDER BY 1,2" +// } +// Name: +// Dimensions: 3 Fields by 7 Rows +// +-------------------------------+------------------+------------------+ +// | Name: Time | Name: v | Name: v | +// | Labels: | Labels: c=a | Labels: c=b | +// | Type: []time.Time | Type: []*float64 | Type: []*float64 | +// +-------------------------------+------------------+------------------+ +// | 2023-12-24 14:15:00 +0000 UTC | 27 | 27 | +// | 2023-12-24 14:20:00 +0000 UTC | 15 | 115 | +// | 2023-12-24 14:25:00 +0000 UTC | 27 | 27 | +// | 2023-12-24 14:30:00 +0000 UTC | 27 | 27 | +// | 2023-12-24 14:35:00 +0000 UTC | 50 | 150 | +// | 2023-12-24 14:40:00 +0000 UTC | 27 | 27 | +// | 2023-12-24 14:45:00 +0000 UTC | 27 | 27 | +// +-------------------------------+------------------+------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "type": "timeseries-wide", + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT floor(extract(epoch from \"time\")/300)*300 AS \"time\",c,avg(v) AS \"v\" FROM tbl GROUP BY 1,2 ORDER BY 1,2" + }, + "fields": [ + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "v", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "c": "a" + } + }, + { + "name": "v", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "c": "b" + } + } + ] + }, + "data": { + "values": [ + [ + 1703427300000, + 1703427600000, + 1703427900000, + 1703428200000, + 1703428500000, + 1703428800000, + 1703429100000 + ], + [ + 27, + 15, + 27, + 27, + 50, + 27, + 27 + ], + [ + 27, + 115, + 27, + 27, + 150, + 27, + 27 + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_value.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_value.sql new file mode 100644 index 0000000000000..a986288e241c1 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/fill_value.sql @@ -0,0 +1,15 @@ +-- SELECT $__timeGroup("time",5m,27),c,avg(v) AS "v" FROM tbl GROUP BY 1,2 ORDER BY 1,2 +-- tests fill-mode=value +CREATE TEMPORARY TABLE tbl ( + "time" timestamp with time zone, + v double precision, + c text +); + +INSERT INTO tbl ("time", v, c) VALUES +('2023-12-24 14:21:03 UTC', 10, 'a'), +('2023-12-24 14:21:03 UTC', 110, 'b'), +('2023-12-24 14:23:03 UTC', 20, 'a'), +('2023-12-24 14:23:03 UTC', 120, 'b'), +('2023-12-24 14:39:03 UTC', 50, 'a'), +('2023-12-24 14:39:03 UTC', 150, 'b'); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/no_rows_long.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/no_rows_long.golden.jsonc new file mode 100644 index 0000000000000..2cc0ccc47b8b5 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/no_rows_long.golden.jsonc @@ -0,0 +1,36 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl WHERE false" +// } +// Name: +// Dimensions: 0 Fields by 0 Rows +// + +// + +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl WHERE false" + }, + "fields": [] + }, + "data": { + "values": [] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/no_rows_long.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/no_rows_long.sql new file mode 100644 index 0000000000000..bae1feff9f14a --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/no_rows_long.sql @@ -0,0 +1,12 @@ +-- SELECT * FROM tbl WHERE false +CREATE TEMPORARY TABLE tbl ( + "time" timestamp with time zone, + v double precision, + c text +); + +INSERT INTO tbl ("time", v, c) VALUES +('2023-12-24 14:30:03 UTC', 10, 'a'), +('2023-12-24 14:30:03 UTC', 110, 'b'), +('2023-12-24 14:31:03 UTC', 20, 'a'), +('2023-12-24 14:31:03 UTC', 120, 'b'); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/no_rows_wide.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/no_rows_wide.golden.jsonc new file mode 100644 index 0000000000000..2cc0ccc47b8b5 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/no_rows_wide.golden.jsonc @@ -0,0 +1,36 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl WHERE false" +// } +// Name: +// Dimensions: 0 Fields by 0 Rows +// + +// + +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl WHERE false" + }, + "fields": [] + }, + "data": { + "values": [] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/no_rows_wide.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/no_rows_wide.sql new file mode 100644 index 0000000000000..b34df7a065036 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/no_rows_wide.sql @@ -0,0 +1,10 @@ +-- SELECT * FROM tbl WHERE false +CREATE TEMPORARY TABLE tbl ( + "time" timestamp with time zone, + v1 double precision, + v2 double precision +); + +INSERT INTO tbl ("time", v1, v2) VALUES +('2023-12-24 14:30:03 UTC', 10, 110), +('2023-12-24 14:31:03 UTC', 20, 120); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/simple.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/simple.golden.jsonc new file mode 100644 index 0000000000000..c8075f2b65b40 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/simple.golden.jsonc @@ -0,0 +1,99 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "type": "timeseries-wide", +// "typeVersion": [ +// 0, +// 0 +// ], +// "executedQueryString": "SELECT * FROM tbl" +// } +// Name: +// Dimensions: 3 Fields by 5 Rows +// +-------------------------------+------------------+------------------+ +// | Name: Time | Name: v | Name: v | +// | Labels: | Labels: c=a | Labels: c=b | +// | Type: []time.Time | Type: []*float64 | Type: []*float64 | +// +-------------------------------+------------------+------------------+ +// | 2023-12-24 14:30:03 +0000 UTC | 10 | 110 | +// | 2023-12-24 14:31:03 +0000 UTC | 20 | 120 | +// | 2023-12-24 14:32:03 +0000 UTC | 30 | 130 | +// | 2023-12-24 14:33:03 +0000 UTC | 40 | 140 | +// | 2023-12-24 14:34:03 +0000 UTC | 50 | 150 | +// +-------------------------------+------------------+------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "type": "timeseries-wide", + "typeVersion": [ + 0, + 0 + ], + "executedQueryString": "SELECT * FROM tbl" + }, + "fields": [ + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "v", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "c": "a" + } + }, + { + "name": "v", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": { + "c": "b" + } + } + ] + }, + "data": { + "values": [ + [ + 1703428203000, + 1703428263000, + 1703428323000, + 1703428383000, + 1703428443000 + ], + [ + 10, + 20, + 30, + 40, + 50 + ], + [ + 110, + 120, + 130, + 140, + 150 + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/simple.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/simple.sql new file mode 100644 index 0000000000000..b89651e591d16 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/time_series/simple.sql @@ -0,0 +1,18 @@ +-- SELECT * FROM tbl +CREATE TEMPORARY TABLE tbl ( + "time" timestamp with time zone, + v double precision, + c text +); + +INSERT INTO tbl ("time", v, c) VALUES +('2023-12-24 14:30:03 UTC', 10, 'a'), +('2023-12-24 14:30:03 UTC', 110, 'b'), +('2023-12-24 14:31:03 UTC', 20, 'a'), +('2023-12-24 14:31:03 UTC', 120, 'b'), +('2023-12-24 14:32:03 UTC', 30, 'a'), +('2023-12-24 14:32:03 UTC', 130, 'b'), +('2023-12-24 14:33:03 UTC', 40, 'a'), +('2023-12-24 14:33:03 UTC', 140, 'b'), +('2023-12-24 14:34:03 UTC', 50, 'a'), +('2023-12-24 14:34:03 UTC', 150, 'b'); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/tls/tls.go b/pkg/tsdb/grafana-postgresql-datasource/tls/tls.go new file mode 100644 index 0000000000000..d550d61696235 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/tls/tls.go @@ -0,0 +1,135 @@ +package tls + +import ( + "crypto/tls" + "crypto/x509" + "errors" + + "github.com/grafana/grafana/pkg/tsdb/sqleng" +) + +// we support 4 postgres tls modes: +// disable - no tls +// require - use tls +// verify-ca - use tls, verify root cert but not the hostname +// verify-full - use tls, verify root cert +// (for all the options except `disable`, you can optionally use client certificates) + +func getTLSConfigRequire(certs *Certs, serverName string) (*tls.Config, error) { + // see https://www.postgresql.org/docs/12/libpq-ssl.html , + // mode=require + provided root-cert should behave as mode=verify-ca + if certs.rootCerts != nil { + return getTLSConfigVerifyCA(certs, serverName) + } + + return &tls.Config{ + InsecureSkipVerify: true, // we do not verify the root cert + Certificates: certs.clientCerts, + ServerName: serverName, + }, nil +} + +// to implement the verify-ca mode, we need to do this: +// - for the root certificate +// - verify that the certificate we receive from the server is trusted, +// meaning it relates to our root certificate +// - we DO NOT verify that the hostname of the database matches +// the hostname in the certificate +// +// the problem is, `go“ does not offer such an option. +// by default, it will verify both things. +// +// so what we do is: +// - we turn off the default-verification with `InsecureSkipVerify` +// - we implement our own verification using `VerifyConnection` +// +// extra info about this: +// - there is a rejected feature-request about this at https://github.com/golang/go/issues/21971 +// - the recommended workaround is based on VerifyPeerCertificate +// - there is even example code at https://github.com/golang/go/commit/29cfb4d3c3a97b6f426d1b899234da905be699aa +// - but later the example code was changed to use VerifyConnection instead: +// https://github.com/golang/go/commit/7eb5941b95a588a23f18fa4c22fe42ff0119c311 +// +// a verifyConnection example is at https://pkg.go.dev/crypto/tls#example-Config-VerifyConnection . +// +// this is how the `pgx` library handles verify-ca: +// +// https://github.com/jackc/pgx/blob/5c63f646f820ca9696fc3515c1caf2a557d562e5/pgconn/config.go#L657-L690 +// (unfortunately pgx only handles this for certificate-provided-as-path, so we cannot rely on it) +func getTLSConfigVerifyCA(certs *Certs, serverName string) (*tls.Config, error) { + conf := tls.Config{ + ServerName: serverName, + Certificates: certs.clientCerts, + InsecureSkipVerify: true, // we turn off the default-verification, we'll do VerifyConnection instead + VerifyConnection: func(state tls.ConnectionState) error { + // we add all the certificates to the pool, we skip the first cert. + intermediates := x509.NewCertPool() + for _, c := range state.PeerCertificates[1:] { + intermediates.AddCert(c) + } + + opts := x509.VerifyOptions{ + Roots: certs.rootCerts, + Intermediates: intermediates, + } + + // we call `Verify()` on the first cert (that we skipped previously) + _, err := state.PeerCertificates[0].Verify(opts) + return err + }, + RootCAs: certs.rootCerts, + } + + return &conf, nil +} + +func getTLSConfigVerifyFull(certs *Certs, serverName string) (*tls.Config, error) { + conf := tls.Config{ + Certificates: certs.clientCerts, + ServerName: serverName, + RootCAs: certs.rootCerts, + } + + return &conf, nil +} + +func IsTLSEnabled(dsInfo sqleng.DataSourceInfo) bool { + mode := dsInfo.JsonData.Mode + return mode != "disable" +} + +// returns `nil` if tls is disabled +func GetTLSConfig(dsInfo sqleng.DataSourceInfo, readFile ReadFileFunc, serverName string) (*tls.Config, error) { + mode := dsInfo.JsonData.Mode + // we need to special-case the no-tls-mode + if mode == "disable" { + return nil, nil + } + + // for all the remaining cases we need to load + // both the root-cert if exists, and the client-cert if exists. + certBytes, err := loadCertificateBytes(dsInfo, readFile) + if err != nil { + return nil, err + } + + certs, err := createCertificates(certBytes) + if err != nil { + return nil, err + } + + switch mode { + // `disable` already handled + case "": + // for backward-compatibility reasons this is the same as `require` + return getTLSConfigRequire(certs, serverName) + case "require": + return getTLSConfigRequire(certs, serverName) + case "verify-ca": + return getTLSConfigVerifyCA(certs, serverName) + case "verify-full": + return getTLSConfigVerifyFull(certs, serverName) + default: + return nil, errors.New("tls: invalid mode " + mode) + } +} diff --git a/pkg/tsdb/grafana-postgresql-datasource/tls/tls_loader.go b/pkg/tsdb/grafana-postgresql-datasource/tls/tls_loader.go new file mode 100644 index 0000000000000..6c19d3801d513 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/tls/tls_loader.go @@ -0,0 +1,101 @@ +package tls + +import ( + "crypto/tls" + "crypto/x509" + "errors" + + "github.com/grafana/grafana/pkg/tsdb/sqleng" +) + +// this file deals with locating and loading the certificates, +// from json-data or from disk. + +type CertBytes struct { + rootCert []byte + clientKey []byte + clientCert []byte +} + +type ReadFileFunc = func(name string) ([]byte, error) + +var errPartialClientCertNoKey = errors.New("tls: client cert provided but client key missing") +var errPartialClientCertNoCert = errors.New("tls: client key provided but client cert missing") + +// certificates can be stored either as encrypted-json-data, or as file-path +func loadCertificateBytes(dsInfo sqleng.DataSourceInfo, readFile ReadFileFunc) (*CertBytes, error) { + if dsInfo.JsonData.ConfigurationMethod == "file-content" { + return &CertBytes{ + rootCert: []byte(dsInfo.DecryptedSecureJSONData["tlsCACert"]), + clientKey: []byte(dsInfo.DecryptedSecureJSONData["tlsClientKey"]), + clientCert: []byte(dsInfo.DecryptedSecureJSONData["tlsClientCert"]), + }, nil + } else { + c := CertBytes{} + + if dsInfo.JsonData.RootCertFile != "" { + rootCert, err := readFile(dsInfo.JsonData.RootCertFile) + if err != nil { + return nil, err + } + c.rootCert = rootCert + } + + if dsInfo.JsonData.CertKeyFile != "" { + clientKey, err := readFile(dsInfo.JsonData.CertKeyFile) + if err != nil { + return nil, err + } + c.clientKey = clientKey + } + + if dsInfo.JsonData.CertFile != "" { + clientCert, err := readFile(dsInfo.JsonData.CertFile) + if err != nil { + return nil, err + } + c.clientCert = clientCert + } + + return &c, nil + } +} + +type Certs struct { + clientCerts []tls.Certificate + rootCerts *x509.CertPool +} + +func createCertificates(certBytes *CertBytes) (*Certs, error) { + certs := Certs{} + + if len(certBytes.rootCert) > 0 { + pool := x509.NewCertPool() + ok := pool.AppendCertsFromPEM(certBytes.rootCert) + if !ok { + return nil, errors.New("tls: failed to add root certificate") + } + certs.rootCerts = pool + } + + hasClientKey := len(certBytes.clientKey) > 0 + hasClientCert := len(certBytes.clientCert) > 0 + + if hasClientKey && hasClientCert { + cert, err := tls.X509KeyPair(certBytes.clientCert, certBytes.clientKey) + if err != nil { + return nil, err + } + certs.clientCerts = []tls.Certificate{cert} + } + + if hasClientKey && (!hasClientCert) { + return nil, errPartialClientCertNoCert + } + + if hasClientCert && (!hasClientKey) { + return nil, errPartialClientCertNoKey + } + + return &certs, nil +} diff --git a/pkg/tsdb/grafana-postgresql-datasource/tls/tls_test.go b/pkg/tsdb/grafana-postgresql-datasource/tls/tls_test.go new file mode 100644 index 0000000000000..9d1c9729c0bc9 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/tls/tls_test.go @@ -0,0 +1,402 @@ +package tls + +import ( + "errors" + "os" + "testing" + + "github.com/grafana/grafana/pkg/tsdb/sqleng" + "github.com/stretchr/testify/require" +) + +func noReadFile(path string) ([]byte, error) { + return nil, errors.New("not implemented") +} + +func TestTLSNoMode(t *testing.T) { + // for backward-compatibility reason, + // when mode is unset, it defaults to `require` + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + ConfigurationMethod: "", + }, + } + c, err := GetTLSConfig(dsInfo, noReadFile, "localhost") + require.NoError(t, err) + require.NotNil(t, c) + require.True(t, c.InsecureSkipVerify) +} + +func TestTLSDisable(t *testing.T) { + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "disable", + ConfigurationMethod: "", + }, + } + c, err := GetTLSConfig(dsInfo, noReadFile, "localhost") + require.NoError(t, err) + require.Nil(t, c) +} + +func TestTLSRequire(t *testing.T) { + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "require", + ConfigurationMethod: "", + }, + } + c, err := GetTLSConfig(dsInfo, noReadFile, "localhost") + require.NoError(t, err) + require.NotNil(t, c) + require.True(t, c.InsecureSkipVerify) + require.Nil(t, c.RootCAs) +} + +func TestTLSRequireWithRootCert(t *testing.T) { + rootCertBytes, err := CreateRandomRootCertBytes() + require.NoError(t, err) + + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "require", + ConfigurationMethod: "file-content", + }, + DecryptedSecureJSONData: map[string]string{ + "tlsCACert": string(rootCertBytes), + }, + } + c, err := GetTLSConfig(dsInfo, noReadFile, "localhost") + require.NoError(t, err) + require.NotNil(t, c) + require.True(t, c.InsecureSkipVerify) + require.NotNil(t, c.VerifyConnection) + require.NotNil(t, c.RootCAs) // TODO: not the best, but nothing better available +} + +func TestTLSVerifyCA(t *testing.T) { + rootCertBytes, err := CreateRandomRootCertBytes() + require.NoError(t, err) + + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "verify-ca", + ConfigurationMethod: "file-content", + }, + DecryptedSecureJSONData: map[string]string{ + "tlsCACert": string(rootCertBytes), + }, + } + c, err := GetTLSConfig(dsInfo, noReadFile, "localhost") + require.NoError(t, err) + require.NotNil(t, c) + require.True(t, c.InsecureSkipVerify) + require.NotNil(t, c.VerifyConnection) + require.NotNil(t, c.RootCAs) // TODO: not the best, but nothing better available +} + +func TestTLSVerifyCANoRootCertProvided(t *testing.T) { + // this is ok. go will use the default system certs + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "verify-ca", + ConfigurationMethod: "file-content", + }, + DecryptedSecureJSONData: map[string]string{}, + } + _, err := GetTLSConfig(dsInfo, noReadFile, "localhost") + require.NoError(t, err) +} + +func TestTLSClientCert(t *testing.T) { + clientKey, clientCert, err := CreateRandomClientCert() + require.NoError(t, err) + + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "require", + ConfigurationMethod: "file-content", + }, + DecryptedSecureJSONData: map[string]string{ + "tlsClientCert": string(clientCert), + "tlsClientKey": string(clientKey), + }, + } + c, err := GetTLSConfig(dsInfo, noReadFile, "localhost") + require.NoError(t, err) + require.NotNil(t, c) + require.Len(t, c.Certificates, 1) +} + +func TestTLSMethodFileContentClientCertMissingKey(t *testing.T) { + _, clientCert, err := CreateRandomClientCert() + require.NoError(t, err) + + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "require", + ConfigurationMethod: "file-content", + }, + DecryptedSecureJSONData: map[string]string{ + "tlsClientCert": string(clientCert), + }, + } + _, err = GetTLSConfig(dsInfo, noReadFile, "localhost") + require.ErrorIs(t, err, errPartialClientCertNoKey) +} + +func TestTLSMethodFileContentClientCertMissingCert(t *testing.T) { + clientKey, _, err := CreateRandomClientCert() + require.NoError(t, err) + + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "require", + ConfigurationMethod: "file-content", + }, + DecryptedSecureJSONData: map[string]string{ + "tlsClientKey": string(clientKey), + }, + } + _, err = GetTLSConfig(dsInfo, noReadFile, "localhost") + require.ErrorIs(t, err, errPartialClientCertNoCert) +} + +func TestTLSMethodFilePathClientCertMissingKey(t *testing.T) { + clientKey, _, err := CreateRandomClientCert() + require.NoError(t, err) + + readFile := newMockReadFile(map[string]([]byte){ + "path1": clientKey, + }) + + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "require", + ConfigurationMethod: "file-path", + CertKeyFile: "path1", + }, + } + _, err = GetTLSConfig(dsInfo, readFile, "localhost") + require.ErrorIs(t, err, errPartialClientCertNoCert) +} + +func TestTLSMethodFilePathClientCertMissingCert(t *testing.T) { + _, clientCert, err := CreateRandomClientCert() + require.NoError(t, err) + + readFile := newMockReadFile(map[string]([]byte){ + "path1": clientCert, + }) + + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "require", + ConfigurationMethod: "file-path", + CertFile: "path1", + }, + } + _, err = GetTLSConfig(dsInfo, readFile, "localhost") + require.ErrorIs(t, err, errPartialClientCertNoKey) +} + +func TestTLSVerifyFull(t *testing.T) { + rootCertBytes, err := CreateRandomRootCertBytes() + require.NoError(t, err) + + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "verify-full", + ConfigurationMethod: "file-content", + }, + DecryptedSecureJSONData: map[string]string{ + "tlsCACert": string(rootCertBytes), + }, + } + c, err := GetTLSConfig(dsInfo, noReadFile, "localhost") + require.NoError(t, err) + require.NotNil(t, c) + require.False(t, c.InsecureSkipVerify) + require.Nil(t, c.VerifyConnection) + require.NotNil(t, c.RootCAs) // TODO: not the best, but nothing better available +} + +func TestTLSMethodFileContent(t *testing.T) { + rootCertBytes, err := CreateRandomRootCertBytes() + require.NoError(t, err) + + clientKey, clientCert, err := CreateRandomClientCert() + require.NoError(t, err) + + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "verify-full", + ConfigurationMethod: "file-content", + }, + DecryptedSecureJSONData: map[string]string{ + "tlsCACert": string(rootCertBytes), + "tlsClientCert": string(clientCert), + "tlsClientKey": string(clientKey), + }, + } + c, err := GetTLSConfig(dsInfo, noReadFile, "localhost") + require.NoError(t, err) + require.NotNil(t, c) + require.Len(t, c.Certificates, 1) + require.NotNil(t, c.RootCAs) // TODO: not the best, but nothing better available +} + +func TestTLSMethodFilePath(t *testing.T) { + rootCertBytes, err := CreateRandomRootCertBytes() + require.NoError(t, err) + + clientKey, clientCert, err := CreateRandomClientCert() + require.NoError(t, err) + + readFile := newMockReadFile(map[string]([]byte){ + "root-cert-path": rootCertBytes, + "client-key-path": clientKey, + "client-cert-path": clientCert, + }) + + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "verify-full", + ConfigurationMethod: "file-path", + RootCertFile: "root-cert-path", + CertKeyFile: "client-key-path", + CertFile: "client-cert-path", + }, + } + c, err := GetTLSConfig(dsInfo, readFile, "localhost") + require.NoError(t, err) + require.NotNil(t, c) + require.Len(t, c.Certificates, 1) + require.NotNil(t, c.RootCAs) // TODO: not the best, but nothing better available +} + +func TestTLSMethodFilePathRootCertDoesNotExist(t *testing.T) { + readFile := newMockReadFile(map[string]([]byte){}) + + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "verify-full", + ConfigurationMethod: "file-path", + RootCertFile: "path1", + }, + } + _, err := GetTLSConfig(dsInfo, readFile, "localhost") + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestTLSMethodFilePathClientCertKeyDoesNotExist(t *testing.T) { + _, clientCert, err := CreateRandomClientCert() + require.NoError(t, err) + + readFile := newMockReadFile(map[string]([]byte){ + "cert-path": clientCert, + }) + + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "require", + ConfigurationMethod: "file-path", + CertKeyFile: "key-path", + CertFile: "cert-path", + }, + } + _, err = GetTLSConfig(dsInfo, readFile, "localhost") + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestTLSMethodFilePathClientCertCertDoesNotExist(t *testing.T) { + clientKey, _, err := CreateRandomClientCert() + require.NoError(t, err) + + readFile := newMockReadFile(map[string]([]byte){ + "key-path": clientKey, + }) + + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "require", + ConfigurationMethod: "file-path", + CertKeyFile: "key-path", + CertFile: "cert-path", + }, + } + _, err = GetTLSConfig(dsInfo, readFile, "localhost") + require.ErrorIs(t, err, os.ErrNotExist) +} + +// method="" equals to method="file-path" +func TestTLSMethodEmpty(t *testing.T) { + rootCertBytes, err := CreateRandomRootCertBytes() + require.NoError(t, err) + + clientKey, clientCert, err := CreateRandomClientCert() + require.NoError(t, err) + + readFile := newMockReadFile(map[string]([]byte){ + "root-cert-path": rootCertBytes, + "client-key-path": clientKey, + "client-cert-path": clientCert, + }) + + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "verify-full", + ConfigurationMethod: "", + RootCertFile: "root-cert-path", + CertKeyFile: "client-key-path", + CertFile: "client-cert-path", + }, + } + c, err := GetTLSConfig(dsInfo, readFile, "localhost") + require.NoError(t, err) + require.NotNil(t, c) + require.Len(t, c.Certificates, 1) + require.NotNil(t, c.RootCAs) // TODO: not the best, but nothing better available +} + +func TestTLSVerifyFullNoRootCertProvided(t *testing.T) { + // this is ok. go will use the default system certs + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "verify-full", + ConfigurationMethod: "file-content", + }, + DecryptedSecureJSONData: map[string]string{}, + } + _, err := GetTLSConfig(dsInfo, noReadFile, "localhost") + require.NoError(t, err) +} + +func TestTLSInvalidMode(t *testing.T) { + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "not-a-valid-mode", + }, + } + + _, err := GetTLSConfig(dsInfo, noReadFile, "localhost") + require.Error(t, err) +} + +func TestTLSServerNameSetInEveryMode(t *testing.T) { + modes := []string{"require", "verify-ca", "verify-full"} + + for _, mode := range modes { + t.Run(mode, func(t *testing.T) { + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: mode, + }, + DecryptedSecureJSONData: map[string]string{}, + } + c, err := GetTLSConfig(dsInfo, noReadFile, "example.com") + require.NoError(t, err) + require.Equal(t, "example.com", c.ServerName) + }) + } +} diff --git a/pkg/tsdb/grafana-postgresql-datasource/tls/tls_test_helpers.go b/pkg/tsdb/grafana-postgresql-datasource/tls/tls_test_helpers.go new file mode 100644 index 0000000000000..1b62df63d095e --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/tls/tls_test_helpers.go @@ -0,0 +1,105 @@ +package tls + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "time" +) + +func CreateRandomRootCertBytes() ([]byte, error) { + cert := x509.Certificate{ + SerialNumber: big.NewInt(42), + Subject: pkix.Name{ + CommonName: "test1", + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), + IsCA: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + + bytes, err := x509.CreateCertificate(rand.Reader, &cert, &cert, &key.PublicKey, key) + if err != nil { + return nil, err + } + + return pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: bytes, + }), nil +} + +func CreateRandomClientCert() ([]byte, []byte, error) { + caKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, err + } + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, err + } + + keyBytes := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + + caCert := x509.Certificate{ + SerialNumber: big.NewInt(42), + Subject: pkix.Name{ + CommonName: "test1", + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), + IsCA: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + + cert := x509.Certificate{ + SerialNumber: big.NewInt(2019), + Subject: pkix.Name{ + CommonName: "test1", + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + KeyUsage: x509.KeyUsageDigitalSignature, + } + + certData, err := x509.CreateCertificate(rand.Reader, &cert, &caCert, &key.PublicKey, caKey) + if err != nil { + return nil, nil, err + } + + certBytes := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certData, + }) + + return keyBytes, certBytes, nil +} + +func newMockReadFile(data map[string]([]byte)) ReadFileFunc { + return func(path string) ([]byte, error) { + bytes, ok := data[path] + if !ok { + return nil, os.ErrNotExist + } + return bytes, nil + } +} diff --git a/pkg/tsdb/grafana-postgresql-datasource/tlsmanager.go b/pkg/tsdb/grafana-postgresql-datasource/tlsmanager.go deleted file mode 100644 index 35b3d77a1bb2c..0000000000000 --- a/pkg/tsdb/grafana-postgresql-datasource/tlsmanager.go +++ /dev/null @@ -1,224 +0,0 @@ -package postgres - -import ( - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "time" - - "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/infra/fs" - "github.com/grafana/grafana/pkg/tsdb/sqleng" -) - -var validateCertFunc = validateCertFilePaths -var writeCertFileFunc = writeCertFile - -type certFileType int - -const ( - rootCert = iota - clientCert - clientKey -) - -type tlsSettingsProvider interface { - getTLSSettings(dsInfo sqleng.DataSourceInfo) (tlsSettings, error) -} - -type datasourceCacheManager struct { - locker *locker - cache sync.Map -} - -type tlsManager struct { - logger log.Logger - dsCacheInstance datasourceCacheManager - dataPath string -} - -func newTLSManager(logger log.Logger, dataPath string) tlsSettingsProvider { - return &tlsManager{ - logger: logger, - dataPath: dataPath, - dsCacheInstance: datasourceCacheManager{locker: newLocker()}, - } -} - -type tlsSettings struct { - Mode string - ConfigurationMethod string - RootCertFile string - CertFile string - CertKeyFile string -} - -func (m *tlsManager) getTLSSettings(dsInfo sqleng.DataSourceInfo) (tlsSettings, error) { - tlsconfig := tlsSettings{ - Mode: dsInfo.JsonData.Mode, - } - - isTLSDisabled := (tlsconfig.Mode == "disable") - - if isTLSDisabled { - m.logger.Debug("Postgres TLS/SSL is disabled") - return tlsconfig, nil - } - - m.logger.Debug("Postgres TLS/SSL is enabled", "tlsMode", tlsconfig.Mode) - - tlsconfig.ConfigurationMethod = dsInfo.JsonData.ConfigurationMethod - tlsconfig.RootCertFile = dsInfo.JsonData.RootCertFile - tlsconfig.CertFile = dsInfo.JsonData.CertFile - tlsconfig.CertKeyFile = dsInfo.JsonData.CertKeyFile - - if tlsconfig.ConfigurationMethod == "file-content" { - if err := m.writeCertFiles(dsInfo, &tlsconfig); err != nil { - return tlsconfig, err - } - } else { - if err := validateCertFunc(tlsconfig.RootCertFile, tlsconfig.CertFile, tlsconfig.CertKeyFile); err != nil { - return tlsconfig, err - } - } - return tlsconfig, nil -} - -func (t certFileType) String() string { - switch t { - case rootCert: - return "root certificate" - case clientCert: - return "client certificate" - case clientKey: - return "client key" - default: - panic(fmt.Sprintf("Unrecognized certFileType %d", t)) - } -} - -func getFileName(dataDir string, fileType certFileType) string { - var filename string - switch fileType { - case rootCert: - filename = "root.crt" - case clientCert: - filename = "client.crt" - case clientKey: - filename = "client.key" - default: - panic(fmt.Sprintf("unrecognized certFileType %s", fileType.String())) - } - generatedFilePath := filepath.Join(dataDir, filename) - return generatedFilePath -} - -// writeCertFile writes a certificate file. -func writeCertFile(logger log.Logger, fileContent string, generatedFilePath string) error { - fileContent = strings.TrimSpace(fileContent) - if fileContent != "" { - logger.Debug("Writing cert file", "path", generatedFilePath) - if err := os.WriteFile(generatedFilePath, []byte(fileContent), 0600); err != nil { - return err - } - // Make sure the file has the permissions expected by the Postgresql driver, otherwise it will bail - if err := os.Chmod(generatedFilePath, 0600); err != nil { - return err - } - return nil - } - - logger.Debug("Deleting cert file since no content is provided", "path", generatedFilePath) - exists, err := fs.Exists(generatedFilePath) - if err != nil { - return err - } - if exists { - if err := os.Remove(generatedFilePath); err != nil { - return fmt.Errorf("failed to remove %q: %w", generatedFilePath, err) - } - } - return nil -} - -func (m *tlsManager) writeCertFiles(dsInfo sqleng.DataSourceInfo, tlsconfig *tlsSettings) error { - m.logger.Debug("Writing TLS certificate files to disk") - tlsRootCert := dsInfo.DecryptedSecureJSONData["tlsCACert"] - tlsClientCert := dsInfo.DecryptedSecureJSONData["tlsClientCert"] - tlsClientKey := dsInfo.DecryptedSecureJSONData["tlsClientKey"] - if tlsRootCert == "" && tlsClientCert == "" && tlsClientKey == "" { - m.logger.Debug("No TLS/SSL certificates provided") - } - - // Calculate all files path - workDir := filepath.Join(m.dataPath, "tls", dsInfo.UID+"generatedTLSCerts") - tlsconfig.RootCertFile = getFileName(workDir, rootCert) - tlsconfig.CertFile = getFileName(workDir, clientCert) - tlsconfig.CertKeyFile = getFileName(workDir, clientKey) - - // Find datasource in the cache, if found, skip writing files - cacheKey := strconv.Itoa(int(dsInfo.ID)) - m.dsCacheInstance.locker.RLock(cacheKey) - item, ok := m.dsCacheInstance.cache.Load(cacheKey) - m.dsCacheInstance.locker.RUnlock(cacheKey) - if ok { - if !item.(time.Time).Before(dsInfo.Updated) { - return nil - } - } - - m.dsCacheInstance.locker.Lock(cacheKey) - defer m.dsCacheInstance.locker.Unlock(cacheKey) - - item, ok = m.dsCacheInstance.cache.Load(cacheKey) - if ok { - if !item.(time.Time).Before(dsInfo.Updated) { - return nil - } - } - - // Write certification directory and files - exists, err := fs.Exists(workDir) - if err != nil { - return err - } - if !exists { - if err := os.MkdirAll(workDir, 0700); err != nil { - return err - } - } - - if err = writeCertFileFunc(m.logger, tlsRootCert, tlsconfig.RootCertFile); err != nil { - return err - } - if err = writeCertFileFunc(m.logger, tlsClientCert, tlsconfig.CertFile); err != nil { - return err - } - if err = writeCertFileFunc(m.logger, tlsClientKey, tlsconfig.CertKeyFile); err != nil { - return err - } - - // Update datasource cache - m.dsCacheInstance.cache.Store(cacheKey, dsInfo.Updated) - return nil -} - -// validateCertFilePaths validates configured certificate file paths. -func validateCertFilePaths(rootCert, clientCert, clientKey string) error { - for _, fpath := range []string{rootCert, clientCert, clientKey} { - if fpath == "" { - continue - } - exists, err := fs.Exists(fpath) - if err != nil { - return err - } - if !exists { - return fmt.Errorf("certificate file %q doesn't exist", fpath) - } - } - return nil -} diff --git a/pkg/tsdb/grafana-postgresql-datasource/tlsmanager_test.go b/pkg/tsdb/grafana-postgresql-datasource/tlsmanager_test.go deleted file mode 100644 index d685e61b22bee..0000000000000 --- a/pkg/tsdb/grafana-postgresql-datasource/tlsmanager_test.go +++ /dev/null @@ -1,293 +0,0 @@ -package postgres - -import ( - "fmt" - "path/filepath" - "strconv" - "strings" - "sync" - "testing" - "time" - - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/sqleng" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - _ "github.com/lib/pq" -) - -var writeCertFileCallNum int - -// TestDataSourceCacheManager is to test the Cache manager -func TestDataSourceCacheManager(t *testing.T) { - cfg := setting.NewCfg() - cfg.DataPath = t.TempDir() - mng := tlsManager{ - logger: backend.NewLoggerWith("logger", "tsdb.postgres"), - dsCacheInstance: datasourceCacheManager{locker: newLocker()}, - dataPath: cfg.DataPath, - } - jsonData := sqleng.JsonData{ - Mode: "verify-full", - ConfigurationMethod: "file-content", - } - secureJSONData := map[string]string{ - "tlsClientCert": "I am client certification", - "tlsClientKey": "I am client key", - "tlsCACert": "I am CA certification", - } - - updateTime := time.Now().Add(-5 * time.Minute) - - mockValidateCertFilePaths() - t.Cleanup(resetValidateCertFilePaths) - - t.Run("Check datasource cache creation", func(t *testing.T) { - var wg sync.WaitGroup - wg.Add(10) - for id := int64(1); id <= 10; id++ { - go func(id int64) { - ds := sqleng.DataSourceInfo{ - ID: id, - Updated: updateTime, - Database: "database", - JsonData: jsonData, - DecryptedSecureJSONData: secureJSONData, - UID: "testData", - } - s := tlsSettings{} - err := mng.writeCertFiles(ds, &s) - require.NoError(t, err) - wg.Done() - }(id) - } - wg.Wait() - - t.Run("check cache creation is succeed", func(t *testing.T) { - for id := int64(1); id <= 10; id++ { - updated, ok := mng.dsCacheInstance.cache.Load(strconv.Itoa(int(id))) - require.True(t, ok) - require.Equal(t, updateTime, updated) - } - }) - }) - - t.Run("Check datasource cache modification", func(t *testing.T) { - t.Run("check when version not changed, cache and files are not updated", func(t *testing.T) { - mockWriteCertFile() - t.Cleanup(resetWriteCertFile) - var wg1 sync.WaitGroup - wg1.Add(5) - for id := int64(1); id <= 5; id++ { - go func(id int64) { - ds := sqleng.DataSourceInfo{ - ID: 1, - Updated: updateTime, - Database: "database", - JsonData: jsonData, - DecryptedSecureJSONData: secureJSONData, - UID: "testData", - } - s := tlsSettings{} - err := mng.writeCertFiles(ds, &s) - require.NoError(t, err) - wg1.Done() - }(id) - } - wg1.Wait() - assert.Equal(t, writeCertFileCallNum, 0) - }) - - t.Run("cache is updated with the last datasource version", func(t *testing.T) { - dsV2 := sqleng.DataSourceInfo{ - ID: 1, - Updated: updateTime.Add(time.Minute), - Database: "database", - JsonData: jsonData, - DecryptedSecureJSONData: secureJSONData, - UID: "testData", - } - dsV3 := sqleng.DataSourceInfo{ - ID: 1, - Updated: updateTime.Add(2 * time.Minute), - Database: "database", - JsonData: jsonData, - DecryptedSecureJSONData: secureJSONData, - UID: "testData", - } - s := tlsSettings{} - err := mng.writeCertFiles(dsV2, &s) - require.NoError(t, err) - err = mng.writeCertFiles(dsV3, &s) - require.NoError(t, err) - version, ok := mng.dsCacheInstance.cache.Load("1") - require.True(t, ok) - require.Equal(t, updateTime.Add(2*time.Minute), version) - }) - }) -} - -// Test getFileName - -func TestGetFileName(t *testing.T) { - testCases := []struct { - desc string - datadir string - fileType certFileType - expErr string - expectedGeneratedPath string - }{ - { - desc: "Get File Name for root certification", - datadir: ".", - fileType: rootCert, - expectedGeneratedPath: "root.crt", - }, - { - desc: "Get File Name for client certification", - datadir: ".", - fileType: clientCert, - expectedGeneratedPath: "client.crt", - }, - { - desc: "Get File Name for client certification", - datadir: ".", - fileType: clientKey, - expectedGeneratedPath: "client.key", - }, - } - for _, tt := range testCases { - t.Run(tt.desc, func(t *testing.T) { - generatedPath := getFileName(tt.datadir, tt.fileType) - assert.Equal(t, tt.expectedGeneratedPath, generatedPath) - }) - } -} - -// Test getTLSSettings. -func TestGetTLSSettings(t *testing.T) { - cfg := setting.NewCfg() - cfg.DataPath = t.TempDir() - - mockValidateCertFilePaths() - t.Cleanup(resetValidateCertFilePaths) - - updatedTime := time.Now() - - testCases := []struct { - desc string - expErr string - jsonData sqleng.JsonData - secureJSONData map[string]string - uid string - tlsSettings tlsSettings - updated time.Time - }{ - { - desc: "Custom TLS authentication disabled", - updated: updatedTime, - jsonData: sqleng.JsonData{ - Mode: "disable", - RootCertFile: "i/am/coding/ca.crt", - CertFile: "i/am/coding/client.crt", - CertKeyFile: "i/am/coding/client.key", - ConfigurationMethod: "file-path", - }, - tlsSettings: tlsSettings{Mode: "disable"}, - }, - { - desc: "Custom TLS authentication with file path", - updated: updatedTime.Add(time.Minute), - jsonData: sqleng.JsonData{ - Mode: "verify-full", - ConfigurationMethod: "file-path", - RootCertFile: "i/am/coding/ca.crt", - CertFile: "i/am/coding/client.crt", - CertKeyFile: "i/am/coding/client.key", - }, - tlsSettings: tlsSettings{ - Mode: "verify-full", - ConfigurationMethod: "file-path", - RootCertFile: "i/am/coding/ca.crt", - CertFile: "i/am/coding/client.crt", - CertKeyFile: "i/am/coding/client.key", - }, - }, - { - desc: "Custom TLS mode verify-full with certificate files content", - updated: updatedTime.Add(2 * time.Minute), - uid: "xxx", - jsonData: sqleng.JsonData{ - Mode: "verify-full", - ConfigurationMethod: "file-content", - }, - secureJSONData: map[string]string{ - "tlsCACert": "I am CA certification", - "tlsClientCert": "I am client certification", - "tlsClientKey": "I am client key", - }, - tlsSettings: tlsSettings{ - Mode: "verify-full", - ConfigurationMethod: "file-content", - RootCertFile: filepath.Join(cfg.DataPath, "tls", "xxxgeneratedTLSCerts", "root.crt"), - CertFile: filepath.Join(cfg.DataPath, "tls", "xxxgeneratedTLSCerts", "client.crt"), - CertKeyFile: filepath.Join(cfg.DataPath, "tls", "xxxgeneratedTLSCerts", "client.key"), - }, - }, - } - for _, tt := range testCases { - t.Run(tt.desc, func(t *testing.T) { - var settings tlsSettings - var err error - mng := tlsManager{ - logger: backend.NewLoggerWith("logger", "tsdb.postgres"), - dsCacheInstance: datasourceCacheManager{locker: newLocker()}, - dataPath: cfg.DataPath, - } - - ds := sqleng.DataSourceInfo{ - JsonData: tt.jsonData, - DecryptedSecureJSONData: tt.secureJSONData, - UID: tt.uid, - Updated: tt.updated, - } - - settings, err = mng.getTLSSettings(ds) - - if tt.expErr == "" { - require.NoError(t, err, tt.desc) - assert.Equal(t, tt.tlsSettings, settings) - } else { - require.Error(t, err, tt.desc) - assert.True(t, strings.HasPrefix(err.Error(), tt.expErr), - fmt.Sprintf("%s: %q doesn't start with %q", tt.desc, err, tt.expErr)) - } - }) - } -} - -func mockValidateCertFilePaths() { - validateCertFunc = func(rootCert, clientCert, clientKey string) error { - return nil - } -} - -func resetValidateCertFilePaths() { - validateCertFunc = validateCertFilePaths -} - -func mockWriteCertFile() { - writeCertFileCallNum = 0 - writeCertFileFunc = func(logger log.Logger, fileContent string, generatedFilePath string) error { - writeCertFileCallNum++ - return nil - } -} - -func resetWriteCertFile() { - writeCertFileCallNum = 0 - writeCertFileFunc = writeCertFile -} diff --git a/pkg/tsdb/grafana-pyroscope-datasource/instance.go b/pkg/tsdb/grafana-pyroscope-datasource/instance.go index 17f800d885e21..4c5119876c2d2 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/instance.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/instance.go @@ -11,11 +11,10 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/infra/httpclient" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/prometheus/prometheus/model/labels" "github.com/prometheus/prometheus/promql/parser" "go.opentelemetry.io/otel/attribute" @@ -30,7 +29,7 @@ var ( ) type ProfilingClient interface { - ProfileTypes(context.Context) ([]*ProfileType, error) + ProfileTypes(ctx context.Context, start int64, end int64) ([]*ProfileType, error) LabelNames(ctx context.Context, labelSelector string, start int64, end int64) ([]string, error) LabelValues(ctx context.Context, label string, labelSelector string, start int64, end int64) ([]string, error) GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, step float64) (*SeriesResponse, error) @@ -43,11 +42,10 @@ type PyroscopeDatasource struct { httpClient *http.Client client ProfilingClient settings backend.DataSourceInstanceSettings - ac accesscontrol.AccessControl } // NewPyroscopeDatasource creates a new datasource instance. -func NewPyroscopeDatasource(ctx context.Context, httpClientProvider httpclient.Provider, settings backend.DataSourceInstanceSettings, ac accesscontrol.AccessControl) (instancemgmt.Instance, error) { +func NewPyroscopeDatasource(ctx context.Context, httpClientProvider httpclient.Provider, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { ctxLogger := logger.FromContext(ctx) opt, err := settings.HTTPClientOptions(ctx) if err != nil { @@ -64,7 +62,6 @@ func NewPyroscopeDatasource(ctx context.Context, httpClientProvider httpclient.P httpClient: httpClient, client: NewPyroscopeClient(httpClient, settings.URL), settings: settings, - ac: ac, }, nil } @@ -89,7 +86,30 @@ func (d *PyroscopeDatasource) CallResource(ctx context.Context, req *backend.Cal func (d *PyroscopeDatasource) profileTypes(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { ctxLogger := logger.FromContext(ctx) - types, err := d.client.ProfileTypes(ctx) + + u, err := url.Parse(req.URL) + if err != nil { + ctxLogger.Error("Failed to parse URL", "error", err, "function", logEntrypoint()) + return err + } + query := u.Query() + + var start, end int64 + if query.Has("start") && query.Has("end") { + start, err = strconv.ParseInt(query.Get("start"), 10, 64) + if err != nil { + ctxLogger.Error("Failed to parse start as int", "error", err, "function", logEntrypoint()) + return err + } + + end, err = strconv.ParseInt(query.Get("end"), 10, 64) + if err != nil { + ctxLogger.Error("Failed to parse end as int", "error", err, "function", logEntrypoint()) + return err + } + } + + types, err := d.client.ProfileTypes(ctx, start, end) if err != nil { ctxLogger.Error("Received error from client", "error", err, "function", logEntrypoint()) return err @@ -202,7 +222,7 @@ func (d *PyroscopeDatasource) labelValues(ctx context.Context, req *backend.Call // contains Frames ([]*Frame). func (d *PyroscopeDatasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { ctxLogger := logger.FromContext(ctx) - ctxLogger.Debug("Processing queries", "queryLenght", len(req.Queries), "function", logEntrypoint()) + ctxLogger.Debug("Processing queries", "queryLength", len(req.Queries), "function", logEntrypoint()) // create response struct response := backend.NewQueryDataResponse() @@ -231,7 +251,11 @@ func (d *PyroscopeDatasource) CheckHealth(ctx context.Context, _ *backend.CheckH status := backend.HealthStatusOk message := "Data source is working" - if _, err := d.client.ProfileTypes(ctx); err != nil { + // Since this is a health check mechanism and we only care about whether the + // request succeeded or failed, we set the window to be small. + start := time.Now().Add(-5 * time.Minute).UnixMilli() + end := time.Now().UnixMilli() + if _, err := d.client.ProfileTypes(ctx, start, end); err != nil { status = backend.HealthStatusError message = err.Error() } diff --git a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go index e642de237a689..8a6845055fe45 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go @@ -70,10 +70,13 @@ func NewPyroscopeClient(httpClient *http.Client, url string) *PyroscopeClient { } } -func (c *PyroscopeClient) ProfileTypes(ctx context.Context) ([]*ProfileType, error) { +func (c *PyroscopeClient) ProfileTypes(ctx context.Context, start int64, end int64) ([]*ProfileType, error) { ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.pyroscope.ProfileTypes") defer span.End() - res, err := c.connectClient.ProfileTypes(ctx, connect.NewRequest(&querierv1.ProfileTypesRequest{})) + res, err := c.connectClient.ProfileTypes(ctx, connect.NewRequest(&querierv1.ProfileTypesRequest{ + Start: start, + End: end, + })) if err != nil { logger.Error("Received error from client", "error", err, "function", logEntrypoint()) span.RecordError(err) @@ -253,6 +256,10 @@ func (c *PyroscopeClient) LabelNames(ctx context.Context, labelSelector string, return nil, fmt.Errorf("error sending LabelNames request %v", err) } + if resp.Msg.Names == nil { + return []string{}, nil + } + var filtered []string for _, label := range resp.Msg.Names { if !isPrivateLabel(label) { @@ -278,6 +285,9 @@ func (c *PyroscopeClient) LabelValues(ctx context.Context, label string, labelSe span.SetStatus(codes.Error, err.Error()) return nil, err } + if resp.Msg.Names == nil { + return []string{}, nil + } return resp.Msg.Names, nil } diff --git a/pkg/tsdb/grafana-pyroscope-datasource/query.go b/pkg/tsdb/grafana-pyroscope-datasource/query.go index 9bbb385698006..f62df69879857 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/query.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/query.go @@ -215,7 +215,7 @@ func levelsToTree(levels []*Level, names []string) *ProfileTree { // If we still have levels to go, this should not happen. Something is probably wrong with the flamebearer data. if len(parentsStack) == 0 { - logger.Error("ParentsStack is empty but we are not at the the last level", "currentLevel", currentLevel, "function", logEntrypoint()) + logger.Error("ParentsStack is empty but we are not at the last level", "currentLevel", currentLevel, "function", logEntrypoint()) break } diff --git a/pkg/tsdb/grafana-pyroscope-datasource/query_test.go b/pkg/tsdb/grafana-pyroscope-datasource/query_test.go index 94d068b1e1500..8926fd8d679b7 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/query_test.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/query_test.go @@ -275,7 +275,7 @@ type FakeClient struct { Args []any } -func (f *FakeClient) ProfileTypes(ctx context.Context) ([]*ProfileType, error) { +func (f *FakeClient) ProfileTypes(ctx context.Context, start int64, end int64) ([]*ProfileType, error) { return []*ProfileType{ { ID: "type:1", diff --git a/pkg/tsdb/grafana-pyroscope-datasource/service.go b/pkg/tsdb/grafana-pyroscope-datasource/service.go index 0a019ad74ef03..bad52d489840f 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/service.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/service.go @@ -10,8 +10,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" ) // Make sure PyroscopeDatasource implements required interfaces. This is important to do @@ -35,7 +34,7 @@ type Service struct { logger log.Logger } -var logger = log.New("tsdb.pyroscope") +var logger = backend.NewLoggerWith("logger", "tsdb.pyroscope") // Return the file, line, and (full-path) function name of the caller func getRunContext() (string, int, string) { @@ -64,16 +63,16 @@ func (s *Service) getInstance(ctx context.Context, pluginCtx backend.PluginConte return in, nil } -func ProvideService(httpClientProvider *httpclient.Provider, ac accesscontrol.AccessControl) *Service { +func ProvideService(httpClientProvider *httpclient.Provider) *Service { return &Service{ - im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider, ac)), + im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)), logger: logger, } } -func newInstanceSettings(httpClientProvider *httpclient.Provider, ac accesscontrol.AccessControl) datasource.InstanceFactoryFunc { +func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.InstanceFactoryFunc { return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return NewPyroscopeDatasource(ctx, httpClientProvider, settings, ac) + return NewPyroscopeDatasource(ctx, *httpClientProvider, settings) } } diff --git a/pkg/tsdb/grafana-pyroscope-datasource/standalone/datasource.go b/pkg/tsdb/grafana-pyroscope-datasource/standalone/datasource.go new file mode 100644 index 0000000000000..fc00c805d311f --- /dev/null +++ b/pkg/tsdb/grafana-pyroscope-datasource/standalone/datasource.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + pyroscope "github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource" +) + +var ( + _ backend.QueryDataHandler = (*Datasource)(nil) + _ backend.CheckHealthHandler = (*Datasource)(nil) + _ backend.CallResourceHandler = (*Datasource)(nil) + _ backend.StreamHandler = (*Datasource)(nil) +) + +func NewDatasource(context.Context, backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return &Datasource{ + Service: pyroscope.ProvideService(httpclient.NewProvider()), + }, nil +} + +type Datasource struct { + Service *pyroscope.Service +} + +func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return d.Service.QueryData(ctx, req) +} + +func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + return d.Service.CallResource(ctx, req, sender) +} + +func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + return d.Service.CheckHealth(ctx, req) +} + +func (d *Datasource) SubscribeStream(ctx context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) { + return d.Service.SubscribeStream(ctx, req) +} + +func (d *Datasource) PublishStream(ctx context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) { + return d.Service.PublishStream(ctx, req) +} + +func (d *Datasource) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error { + return d.Service.RunStream(ctx, req, sender) +} diff --git a/pkg/tsdb/grafana-pyroscope-datasource/standalone/main.go b/pkg/tsdb/grafana-pyroscope-datasource/standalone/main.go new file mode 100644 index 0000000000000..345f249371551 --- /dev/null +++ b/pkg/tsdb/grafana-pyroscope-datasource/standalone/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "os" + + "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +func main() { + // Start listening to requests sent from Grafana. This call is blocking so + // it won't finish until Grafana shuts down the process or the plugin choose + // to exit by itself using os.Exit. Manage automatically manages life cycle + // of datasource instances. It accepts datasource instance factory as first + // argument. This factory will be automatically called on incoming request + // from Grafana to create different instances of SampleDatasource (per datasource + // ID). When datasource configuration changed Dispose method will be called and + // new datasource instance created using NewSampleDatasource factory. + if err := datasource.Manage("grafana-pyroscope-datasource", NewDatasource, datasource.ManageOpts{}); err != nil { + log.DefaultLogger.Error(err.Error()) + os.Exit(1) + } +} diff --git a/pkg/tsdb/grafana-testdata-datasource/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/grafana-testdata-datasource/kinds/dataquery/types_dataquery_gen.go index 0a22cb5c8e53e..95fec94c962a3 100644 --- a/pkg/tsdb/grafana-testdata-datasource/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/grafana-testdata-datasource/kinds/dataquery/types_dataquery_gen.go @@ -11,9 +11,11 @@ package dataquery // Defines values for NodesQueryType. const ( - NodesQueryTypeRandom NodesQueryType = "random" - NodesQueryTypeRandomEdges NodesQueryType = "random edges" - NodesQueryTypeResponse NodesQueryType = "response" + NodesQueryTypeFeatureShowcase NodesQueryType = "feature_showcase" + NodesQueryTypeRandom NodesQueryType = "random" + NodesQueryTypeRandomEdges NodesQueryType = "random edges" + NodesQueryTypeResponseMedium NodesQueryType = "response_medium" + NodesQueryTypeResponseSmall NodesQueryType = "response_small" ) // Defines values for StreamingQueryType. @@ -21,6 +23,7 @@ const ( StreamingQueryTypeFetch StreamingQueryType = "fetch" StreamingQueryTypeLogs StreamingQueryType = "logs" StreamingQueryTypeSignal StreamingQueryType = "signal" + StreamingQueryTypeTraces StreamingQueryType = "traces" ) // Defines values for ErrorType. @@ -99,6 +102,7 @@ type DataQuery struct { // NodesQuery defines model for NodesQuery. type NodesQuery struct { Count *int64 `json:"count,omitempty"` + Seed *int64 `json:"seed,omitempty"` Type *NodesQueryType `json:"type,omitempty"` } diff --git a/pkg/tsdb/grafana-testdata-datasource/testdata.go b/pkg/tsdb/grafana-testdata-datasource/testdata.go index 8c994491358bb..e0f6bfc7a4697 100644 --- a/pkg/tsdb/grafana-testdata-datasource/testdata.go +++ b/pkg/tsdb/grafana-testdata-datasource/testdata.go @@ -9,9 +9,13 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource/sims" ) +// ensures that testdata implements all client functions +// var _ plugins.Client = &Service{} + func ProvideService() *Service { s := &Service{ queryMux: datasource.NewQueryTypeMux(), @@ -66,3 +70,8 @@ func (s *Service) CheckHealth(_ context.Context, _ *backend.CheckHealthRequest) Message: "Data source is working", }, nil } + +// CollectMetricsHandler handles metric collection. +func (s *Service) CollectMetrics(ctx context.Context, req *backend.CollectMetricsRequest) (*backend.CollectMetricsResult, error) { + return nil, nil +} diff --git a/pkg/tsdb/influxdb/flux/executor_test.go b/pkg/tsdb/influxdb/flux/executor_test.go index 42dfd17275cf6..4e72c5dde7cdc 100644 --- a/pkg/tsdb/influxdb/flux/executor_test.go +++ b/pkg/tsdb/influxdb/flux/executor_test.go @@ -24,9 +24,9 @@ import ( "github.com/grafana/grafana/pkg/util" ) -//-------------------------------------------------------------- +// -------------------------------------------------------------- // TestData -- reads result from saved files -//-------------------------------------------------------------- +// -------------------------------------------------------------- // MockRunner reads local file path for testdata. type MockRunner struct { @@ -220,7 +220,8 @@ func TestRealQuery(t *testing.T) { json.Set("organization", "test-org") dsInfo := &models.DatasourceInfo{ - URL: "http://localhost:9999", // NOTE! no api/v2 + URL: "http://localhost:9999", // NOTE! no api/v2 + Timeout: 30 * time.Second, } runner, err := runnerFromDataSource(dsInfo) diff --git a/pkg/tsdb/influxdb/flux/flux.go b/pkg/tsdb/influxdb/flux/flux.go index 4f9fc25f78642..18d2a6f725a30 100644 --- a/pkg/tsdb/influxdb/flux/flux.go +++ b/pkg/tsdb/influxdb/flux/flux.go @@ -17,8 +17,7 @@ var ( ) // Query builds flux queries, executes them, and returns the results. -func Query(ctx context.Context, dsInfo *models.DatasourceInfo, tsdbQuery backend.QueryDataRequest) ( - *backend.QueryDataResponse, error) { +func Query(ctx context.Context, dsInfo *models.DatasourceInfo, tsdbQuery backend.QueryDataRequest) (*backend.QueryDataResponse, error) { logger := glog.FromContext(ctx) tRes := backend.NewQueryDataResponse() logger.Debug("Received a query", "query", tsdbQuery) @@ -76,6 +75,7 @@ func runnerFromDataSource(dsInfo *models.DatasourceInfo) (*runner, error) { } opts := influxdb2.DefaultOptions() opts.HTTPOptions().SetHTTPClient(dsInfo.HTTPClient) + opts.SetHTTPRequestTimeout(uint(dsInfo.Timeout.Seconds())) return &runner{ client: influxdb2.NewClientWithOptions(url, dsInfo.Token, opts), org: org, diff --git a/pkg/tsdb/influxdb/flux/macros.go b/pkg/tsdb/influxdb/flux/macros.go index ed3a34d2d20cc..4dce9d76822d0 100644 --- a/pkg/tsdb/influxdb/flux/macros.go +++ b/pkg/tsdb/influxdb/flux/macros.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" ) // $__interval_ms is the exact value in milliseconds @@ -15,7 +15,7 @@ import ( func interpolateInterval(flux string, interval time.Duration) string { intervalMs := int64(interval / time.Millisecond) - intervalText := intervalv2.FormatDuration(interval) + intervalText := gtime.FormatInterval(interval) flux = strings.ReplaceAll(flux, "$__interval_ms", strconv.FormatInt(intervalMs, 10)) flux = strings.ReplaceAll(flux, "$__interval", intervalText) diff --git a/pkg/tsdb/influxdb/fsql/arrow.go b/pkg/tsdb/influxdb/fsql/arrow.go index 576f08b798571..965e17f83572a 100644 --- a/pkg/tsdb/influxdb/fsql/arrow.go +++ b/pkg/tsdb/influxdb/fsql/arrow.go @@ -8,9 +8,9 @@ import ( "runtime/debug" "time" - "github.com/apache/arrow/go/v13/arrow" - "github.com/apache/arrow/go/v13/arrow/array" - "github.com/apache/arrow/go/v13/arrow/scalar" + "github.com/apache/arrow/go/v15/arrow" + "github.com/apache/arrow/go/v15/arrow/array" + "github.com/apache/arrow/go/v15/arrow/scalar" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" diff --git a/pkg/tsdb/influxdb/fsql/arrow_test.go b/pkg/tsdb/influxdb/fsql/arrow_test.go index d6b0121523e5b..fa8844acf1d9c 100644 --- a/pkg/tsdb/influxdb/fsql/arrow_test.go +++ b/pkg/tsdb/influxdb/fsql/arrow_test.go @@ -7,9 +7,9 @@ import ( "testing" "time" - "github.com/apache/arrow/go/v13/arrow" - "github.com/apache/arrow/go/v13/arrow/array" - "github.com/apache/arrow/go/v13/arrow/memory" + "github.com/apache/arrow/go/v15/arrow" + "github.com/apache/arrow/go/v15/arrow/array" + "github.com/apache/arrow/go/v15/arrow/memory" "github.com/google/go-cmp/cmp" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" diff --git a/pkg/tsdb/influxdb/fsql/client.go b/pkg/tsdb/influxdb/fsql/client.go index ce094769b6602..56f31f5da6e1b 100644 --- a/pkg/tsdb/influxdb/fsql/client.go +++ b/pkg/tsdb/influxdb/fsql/client.go @@ -6,10 +6,10 @@ import ( "fmt" "sync" - "github.com/apache/arrow/go/v13/arrow/flight" - "github.com/apache/arrow/go/v13/arrow/flight/flightsql" - "github.com/apache/arrow/go/v13/arrow/ipc" - "github.com/apache/arrow/go/v13/arrow/memory" + "github.com/apache/arrow/go/v15/arrow/flight" + "github.com/apache/arrow/go/v15/arrow/flight/flightsql" + "github.com/apache/arrow/go/v15/arrow/ipc" + "github.com/apache/arrow/go/v15/arrow/memory" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" diff --git a/pkg/tsdb/influxdb/fsql/fsql.go b/pkg/tsdb/influxdb/fsql/fsql.go index 99e4b55628d7b..52ed6614d04e6 100644 --- a/pkg/tsdb/influxdb/fsql/fsql.go +++ b/pkg/tsdb/influxdb/fsql/fsql.go @@ -105,7 +105,7 @@ func runnerFromDataSource(dsInfo *models.DatasourceInfo) (*runner, error) { md.Set("Authorization", fmt.Sprintf("Bearer %s", dsInfo.Token)) } - fsqlClient, err := newFlightSQLClient(addr, md, dsInfo.SecureGrpc) + fsqlClient, err := newFlightSQLClient(addr, md, !dsInfo.InsecureGrpc) if err != nil { return nil, err } diff --git a/pkg/tsdb/influxdb/fsql/fsql_test.go b/pkg/tsdb/influxdb/fsql/fsql_test.go index f6f3bc19b76e5..b2eb94f5b0d0d 100644 --- a/pkg/tsdb/influxdb/fsql/fsql_test.go +++ b/pkg/tsdb/influxdb/fsql/fsql_test.go @@ -6,10 +6,10 @@ import ( "encoding/json" "testing" - "github.com/apache/arrow/go/v13/arrow/flight" - "github.com/apache/arrow/go/v13/arrow/flight/flightsql" - "github.com/apache/arrow/go/v13/arrow/flight/flightsql/example" - "github.com/apache/arrow/go/v13/arrow/memory" + "github.com/apache/arrow/go/v15/arrow/flight" + "github.com/apache/arrow/go/v15/arrow/flight/flightsql" + "github.com/apache/arrow/go/v15/arrow/flight/flightsql/example" + "github.com/apache/arrow/go/v15/arrow/memory" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -57,13 +57,13 @@ func (suite *FSQLTestSuite) TestIntegration_QueryData() { resp, err := Query( context.Background(), &models.DatasourceInfo{ - HTTPClient: nil, - Token: "secret", - URL: "http://localhost:12345", - DbName: "influxdb", - Version: "test", - HTTPMode: "proxy", - SecureGrpc: false, + HTTPClient: nil, + Token: "secret", + URL: "http://localhost:12345", + DbName: "influxdb", + Version: "test", + HTTPMode: "proxy", + InsecureGrpc: true, }, backend.QueryDataRequest{ Queries: []backend.DataQuery{ diff --git a/pkg/tsdb/influxdb/influxdb.go b/pkg/tsdb/influxdb/influxdb.go index 01a7e04437129..48a376e5db7e2 100644 --- a/pkg/tsdb/influxdb/influxdb.go +++ b/pkg/tsdb/influxdb/influxdb.go @@ -82,8 +82,9 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst DefaultBucket: jsonData.DefaultBucket, Organization: jsonData.Organization, MaxSeries: maxSeries, - SecureGrpc: true, + InsecureGrpc: jsonData.InsecureGrpc, Token: settings.DecryptedSecureJSONData["token"], + Timeout: opts.Timeouts.Timeout, } return model, nil } diff --git a/pkg/tsdb/influxdb/influxql/buffered/response_parser.go b/pkg/tsdb/influxdb/influxql/buffered/response_parser.go index 41646c197204e..bcc89ea396069 100644 --- a/pkg/tsdb/influxdb/influxql/buffered/response_parser.go +++ b/pkg/tsdb/influxdb/influxql/buffered/response_parser.go @@ -241,6 +241,12 @@ func transformRowsForTimeSeries(rows []models.Row, query models.Query) data.Fram if !hasTimeCol { newFrame := newFrameWithoutTimeField(row, query) + if len(frames) == 0 { + newFrame.Meta = &data.FrameMeta{ + ExecutedQueryString: query.RawQuery, + PreferredVisualization: util.GetVisType(query.ResultFormat), + } + } frames = append(frames, newFrame) } else { for colIndex, column := range row.Columns { @@ -326,20 +332,21 @@ func newFrameWithoutTimeField(row models.Row, query models.Query) *data.Frame { for _, valuePair := range row.Values { if strings.Contains(strings.ToLower(query.RawQuery), strings.ToLower("SHOW TAG VALUES")) { if len(valuePair) >= 2 { - values = append(values, util.ToPtr(valuePair[1].(string))) + values = append(values, util.ParseString(valuePair[1])) + } + } else if strings.Contains(strings.ToLower(query.RawQuery), strings.ToLower("SHOW DIAGNOSTICS")) { + // https://docs.influxdata.com/platform/monitoring/influxdata-platform/tools/show-diagnostics/ + for _, vp := range valuePair { + values = append(values, util.ParseString(vp)) } } else { if len(valuePair) >= 1 { - values = append(values, util.ToPtr(valuePair[0].(string))) + values = append(values, util.ParseString(valuePair[0])) } } } field := data.NewField("Value", nil, values) frame := data.NewFrame(row.Name, field) - frame.Meta = &data.FrameMeta{ - ExecutedQueryString: query.RawQuery, - PreferredVisualization: util.GetVisType(query.ResultFormat), - } return frame } diff --git a/pkg/tsdb/influxdb/influxql/buffered/response_parser_test.go b/pkg/tsdb/influxdb/influxql/buffered/response_parser_test.go index f3a76b5672335..c6f4028a32f58 100644 --- a/pkg/tsdb/influxdb/influxql/buffered/response_parser_test.go +++ b/pkg/tsdb/influxdb/influxql/buffered/response_parser_test.go @@ -43,6 +43,10 @@ func generateQuery(resFormat string, alias string) *models.Query { } } +// show_diagnostics file won't be added to test files because of its inconsistent +// json data with other responses. But I do add it here just to have it. +// It can be parsed with time_series response but not table. +// It only works with InfluxDB v1.x. The usage of it is quite limited. var testFiles = []string{ "all_values_are_null", "influx_select_all_from_cpu", diff --git a/pkg/tsdb/influxdb/influxql/converter/converter.go b/pkg/tsdb/influxdb/influxql/converter/converter.go index d81d2ffc9b6a3..feaeb4b0406ca 100644 --- a/pkg/tsdb/influxdb/influxql/converter/converter.go +++ b/pkg/tsdb/influxdb/influxql/converter/converter.go @@ -7,11 +7,11 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + sdkjsoniter "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter" jsoniter "github.com/json-iterator/go" "github.com/grafana/grafana/pkg/tsdb/influxdb/influxql/util" "github.com/grafana/grafana/pkg/tsdb/influxdb/models" - "github.com/grafana/grafana/pkg/util/converter/jsonitere" ) func rspErr(e error) *backend.DataResponse { @@ -19,7 +19,7 @@ func rspErr(e error) *backend.DataResponse { } func ReadInfluxQLStyleResult(jIter *jsoniter.Iterator, query *models.Query) *backend.DataResponse { - iter := jsonitere.NewIterator(jIter) + iter := sdkjsoniter.NewIterator(jIter) var rsp *backend.DataResponse l1Fields: @@ -51,7 +51,7 @@ l1Fields: return rsp } -func readResults(iter *jsonitere.Iterator, query *models.Query) *backend.DataResponse { +func readResults(iter *sdkjsoniter.Iterator, query *models.Query) *backend.DataResponse { rsp := &backend.DataResponse{Frames: make(data.Frames, 0)} l1Fields: for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { @@ -79,7 +79,7 @@ l1Fields: return rsp } -func readSeries(iter *jsonitere.Iterator, query *models.Query) *backend.DataResponse { +func readSeries(iter *sdkjsoniter.Iterator, query *models.Query) *backend.DataResponse { var ( measurement string tags map[string]string @@ -179,7 +179,7 @@ func readSeries(iter *jsonitere.Iterator, query *models.Query) *backend.DataResp return rsp } -func readTags(iter *jsonitere.Iterator) (map[string]string, error) { +func readTags(iter *sdkjsoniter.Iterator) (map[string]string, error) { tags := make(map[string]string) for l1Field, err := iter.ReadObject(); l1Field != ""; l1Field, err = iter.ReadObject() { if err != nil { @@ -194,7 +194,7 @@ func readTags(iter *jsonitere.Iterator) (map[string]string, error) { return tags, nil } -func readColumns(iter *jsonitere.Iterator) (columns []string, err error) { +func readColumns(iter *sdkjsoniter.Iterator) (columns []string, err error) { for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { if err != nil { return nil, err @@ -209,7 +209,7 @@ func readColumns(iter *jsonitere.Iterator) (columns []string, err error) { return columns, nil } -func readValues(iter *jsonitere.Iterator, hasTimeColumn bool) (valueFields data.Fields, err error) { +func readValues(iter *sdkjsoniter.Iterator, hasTimeColumn bool) (valueFields data.Fields, err error) { if hasTimeColumn { valueFields = append(valueFields, data.NewField("Time", nil, make([]time.Time, 0))) } diff --git a/pkg/tsdb/influxdb/influxql/influxql.go b/pkg/tsdb/influxdb/influxql/influxql.go index f6cdf46aef50b..ada4a2eade38a 100644 --- a/pkg/tsdb/influxdb/influxql/influxql.go +++ b/pkg/tsdb/influxdb/influxql/influxql.go @@ -3,12 +3,16 @@ package influxql import ( "context" "errors" + "fmt" "net/http" "net/url" "path" "strings" + "sync" + "github.com/grafana/dskit/concurrency" "github.com/grafana/grafana-plugin-sdk-go/backend" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "github.com/grafana/grafana/pkg/infra/log" @@ -17,7 +21,6 @@ import ( "github.com/grafana/grafana/pkg/tsdb/influxdb/influxql/buffered" "github.com/grafana/grafana/pkg/tsdb/influxdb/influxql/querydata" "github.com/grafana/grafana/pkg/tsdb/influxdb/models" - "github.com/grafana/grafana/pkg/tsdb/prometheus/utils" ) const defaultRetentionPolicy = "default" @@ -30,40 +33,91 @@ var ( func Query(ctx context.Context, tracer trace.Tracer, dsInfo *models.DatasourceInfo, req *backend.QueryDataRequest, features featuremgmt.FeatureToggles) (*backend.QueryDataResponse, error) { logger := glog.FromContext(ctx) response := backend.NewQueryDataResponse() + var err error - for _, reqQuery := range req.Queries { - query, err := models.QueryParse(reqQuery) + // We are testing running of queries in parallel behind feature flag + if features.IsEnabled(ctx, featuremgmt.FlagInfluxdbRunQueriesInParallel) { + concurrentQueryCount, err := req.PluginContext.GrafanaConfig.ConcurrentQueryCount() if err != nil { - return &backend.QueryDataResponse{}, err + logger.Debug(fmt.Sprintf("Concurrent Query Count read/parse error: %v", err), featuremgmt.FlagInfluxdbRunQueriesInParallel) + concurrentQueryCount = 10 } - rawQuery, err := query.Build(req) - if err != nil { - return &backend.QueryDataResponse{}, err - } + responseLock := sync.Mutex{} + err = concurrency.ForEachJob(ctx, len(req.Queries), concurrentQueryCount, func(ctx context.Context, idx int) error { + reqQuery := req.Queries[idx] + query, err := models.QueryParse(reqQuery) + if err != nil { + return err + } + + rawQuery, err := query.Build(req) + if err != nil { + return err + } + + query.RefID = reqQuery.RefID + query.RawQuery = rawQuery + + if setting.Env == setting.Dev { + logger.Debug("Influxdb query", "raw query", rawQuery) + } + + request, err := createRequest(ctx, logger, dsInfo, rawQuery, query.Policy) + if err != nil { + return err + } + + resp, err := execute(ctx, tracer, dsInfo, logger, query, request, features.IsEnabled(ctx, featuremgmt.FlagInfluxqlStreamingParser)) + + responseLock.Lock() + defer responseLock.Unlock() + if err != nil { + response.Responses[query.RefID] = backend.DataResponse{Error: err} + } else { + response.Responses[query.RefID] = resp + } + return nil // errors are saved per-query,always return nil + }) - query.RefID = reqQuery.RefID - query.RawQuery = rawQuery - - if setting.Env == setting.Dev { - logger.Debug("Influxdb query", "raw query", rawQuery) - } - - request, err := createRequest(ctx, logger, dsInfo, rawQuery, query.Policy) if err != nil { - return &backend.QueryDataResponse{}, err + logger.Debug("Influxdb concurrent query error", "concurrent query", err) } - - resp, err := execute(ctx, tracer, dsInfo, logger, query, request, features.IsEnabled(ctx, featuremgmt.FlagInfluxqlStreamingParser)) - - if err != nil { - response.Responses[query.RefID] = backend.DataResponse{Error: err} - } else { - response.Responses[query.RefID] = resp + } else { + for _, reqQuery := range req.Queries { + query, err := models.QueryParse(reqQuery) + if err != nil { + return &backend.QueryDataResponse{}, err + } + + rawQuery, err := query.Build(req) + if err != nil { + return &backend.QueryDataResponse{}, err + } + + query.RefID = reqQuery.RefID + query.RawQuery = rawQuery + + if setting.Env == setting.Dev { + logger.Debug("Influxdb query", "raw query", rawQuery) + } + + request, err := createRequest(ctx, logger, dsInfo, rawQuery, query.Policy) + if err != nil { + return &backend.QueryDataResponse{}, err + } + + resp, err := execute(ctx, tracer, dsInfo, logger, query, request, features.IsEnabled(ctx, featuremgmt.FlagInfluxqlStreamingParser)) + + if err != nil { + response.Responses[query.RefID] = backend.DataResponse{Error: err} + } else { + response.Responses[query.RefID] = resp + } } } - return response, nil + return response, err } func createRequest(ctx context.Context, logger log.Logger, dsInfo *models.DatasourceInfo, queryStr string, retentionPolicy string) (*http.Request, error) { @@ -126,7 +180,7 @@ func execute(ctx context.Context, tracer trace.Tracer, dsInfo *models.Datasource } }() - _, endSpan := utils.StartTrace(ctx, tracer, "datasource.influxdb.influxql.parseResponse") + _, endSpan := startTrace(ctx, tracer, "datasource.influxdb.influxql.parseResponse") defer endSpan() var resp *backend.DataResponse @@ -138,3 +192,14 @@ func execute(ctx context.Context, tracer trace.Tracer, dsInfo *models.Datasource } return *resp, nil } + +// startTrace setups a trace but does not panic if tracer is nil which helps with testing +func startTrace(ctx context.Context, tracer trace.Tracer, name string, attributes ...attribute.KeyValue) (context.Context, func()) { + if tracer == nil { + return ctx, func() {} + } + ctx, span := tracer.Start(ctx, name, trace.WithAttributes(attributes...)) + return ctx, func() { + span.End() + } +} diff --git a/pkg/tsdb/influxdb/influxql/testdata/all_values_are_null.json b/pkg/tsdb/influxdb/influxql/testdata/all_values_are_null.json index 83698f9f63c07..a13c8dfcf2dfe 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/all_values_are_null.json +++ b/pkg/tsdb/influxdb/influxql/testdata/all_values_are_null.json @@ -1,29 +1 @@ -{ - "results": [ - { - "series": [ - { - "name": "cpu", - "columns": [ - "time", - "mean" - ], - "values": [ - [ - 100, - null - ], - [ - 101, - null - ], - [ - 102, - null - ] - ] - } - ] - } - ] -} +{"results":[{"series":[{"name":"cpu","columns":["time","mean"],"values":[[100,null],[101,null],[102,null]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/empty_response.json b/pkg/tsdb/influxdb/influxql/testdata/empty_response.json index 745663b4e1efc..040088ac8d77d 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/empty_response.json +++ b/pkg/tsdb/influxdb/influxql/testdata/empty_response.json @@ -1,7 +1 @@ -{ - "results": [ - { - "statement_id": 0 - } - ] -} +{"results": [{"statement_id": 0}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/error_on_top_level_response.json b/pkg/tsdb/influxdb/influxql/testdata/error_on_top_level_response.json index dabef91bb52de..e384265a89a71 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/error_on_top_level_response.json +++ b/pkg/tsdb/influxdb/influxql/testdata/error_on_top_level_response.json @@ -1,3 +1 @@ -{ - "error": "error parsing query: found THING" -} +{"error": "error parsing query: found THING"} diff --git a/pkg/tsdb/influxdb/influxql/testdata/error_response.json b/pkg/tsdb/influxdb/influxql/testdata/error_response.json index 94a42b6817a86..f19b2633c414f 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/error_response.json +++ b/pkg/tsdb/influxdb/influxql/testdata/error_response.json @@ -1,7 +1 @@ -{ - "results": [ - { - "error": "query-timeout limit exceeded" - } - ] -} +{"results":[{"error":"query-timeout limit exceeded"}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/influx_select_all_from_cpu.json b/pkg/tsdb/influxdb/influxql/testdata/influx_select_all_from_cpu.json index f1712b5e334d8..93ae26f83cbc0 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/influx_select_all_from_cpu.json +++ b/pkg/tsdb/influxdb/influxql/testdata/influx_select_all_from_cpu.json @@ -1,44 +1 @@ -{ - "results": [ - { - "statement_id": 0, - "series": [ - { - "name": "cpu", - "columns": [ - "time", - "mean_usage_guest", - "mean_usage_nice", - "mean_usage_idle" - ], - "values": [ - [ - 1697984400000, - 1111, - 1112, - 1113 - ], - [ - 1697984700000, - 2221, - 2222, - 2223 - ], - [ - 1697985000000, - 3331, - 3332, - 3333 - ], - [ - 1697985300000, - 4441, - 4442, - 4443 - ] - ] - } - ] - } - ] -} +{"results":[{"statement_id":0,"series":[{"name":"cpu","columns":["time","mean_usage_guest","mean_usage_nice","mean_usage_idle"],"values":[[1697984400000,1111,1112,1113],[1697984700000,2221,2222,2223],[1697985000000,3331,3332,3333],[1697985300000,4441,4442,4443]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/invalid_timestamp_format.json b/pkg/tsdb/influxdb/influxql/testdata/invalid_timestamp_format.json index 667db53aa6385..38d1b7fb85520 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/invalid_timestamp_format.json +++ b/pkg/tsdb/influxdb/influxql/testdata/invalid_timestamp_format.json @@ -1,33 +1 @@ -{ - "results": [ - { - "series": [ - { - "name": "cpu", - "columns": [ - "time", - "mean" - ], - "values": [ - [ - 100, - 50 - ], - [ - "hello", - 51 - ], - [ - "hello", - "hello" - ], - [ - 102, - 52 - ] - ] - } - ] - } - ] -} +{"results":[{"series":[{"name":"cpu","columns":["time","mean"],"values":[[100,50],["hello",51],["hello","hello"],[102,52]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/invalid_value_format.json b/pkg/tsdb/influxdb/influxql/testdata/invalid_value_format.json index b59a94b41b305..55079cc04cc64 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/invalid_value_format.json +++ b/pkg/tsdb/influxdb/influxql/testdata/invalid_value_format.json @@ -1,29 +1 @@ -{ - "results": [ - { - "series": [ - { - "name": "cpu", - "columns": [ - "time", - "mean" - ], - "values": [ - [ - 100, - 50 - ], - [ - 101, - "hello" - ], - [ - 102, - 52 - ] - ] - } - ] - } - ] -} +{"results":[{"series":[{"name":"cpu","columns":["time","mean"],"values":[[100,50],[101,"hello"],[102,52]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/metric_find_queries.json b/pkg/tsdb/influxdb/influxql/testdata/metric_find_queries.json index 9da51d21690d3..0c4a9dbf7ff9f 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/metric_find_queries.json +++ b/pkg/tsdb/influxdb/influxql/testdata/metric_find_queries.json @@ -1,47 +1 @@ -{ - "results": [ - { - "statement_id": 0, - "series": [ - { - "name": "measurements", - "columns": [ - "name" - ], - "values": [ - [ - "cpu" - ], - [ - "disk" - ], - [ - "diskio" - ], - [ - "kernel" - ], - [ - "logs" - ], - [ - "mem" - ], - [ - "myMeasurement" - ], - [ - "processes" - ], - [ - "swap" - ], - [ - "system" - ] - ] - } - ] - } - ] -} +{"results":[{"statement_id":0,"series":[{"name":"measurements","columns":["name"],"values":[["cpu"],["disk"],["diskio"],["kernel"],["logs"],["mem"],["myMeasurement"],["processes"],["swap"],["system"]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/multiple_measurements.json b/pkg/tsdb/influxdb/influxql/testdata/multiple_measurements.json index 595d3a568855a..aaeff6cbad18d 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/multiple_measurements.json +++ b/pkg/tsdb/influxdb/influxql/testdata/multiple_measurements.json @@ -1,40 +1 @@ -{ - "results": [ - { - "series": [ - { - "name": "cpu.upc", - "columns": [ - "time", - "mean" - ], - "tags": { - "datacenter": "America" - }, - "values": [ - [ - 111, - 222 - ] - ] - }, - { - "name": "logins.count", - "columns": [ - "time", - "mean" - ], - "tags": { - "datacenter": "America" - }, - "values": [ - [ - 111, - 222 - ] - ] - } - ] - } - ] -} +{"results":[{"series":[{"name":"cpu.upc","columns":["time","mean"],"tags":{"datacenter":"America"},"values":[[111,222]]},{"name":"logins.count","columns":["time","mean"],"tags":{"datacenter":"America"},"values":[[111,222]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/multiple_series_with_tags.json b/pkg/tsdb/influxdb/influxql/testdata/multiple_series_with_tags.json index 8a6eae632b3ae..5fc89cc29298d 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/multiple_series_with_tags.json +++ b/pkg/tsdb/influxdb/influxql/testdata/multiple_series_with_tags.json @@ -1,149 +1 @@ -{ - "results": [ - { - "statement_id": 0, - "series": [ - { - "name": "cpu", - "tags": { - "cpu": "cpu-total" - }, - "columns": [ - "time", - "mean" - ], - "values": [ - [ - 1700046000000, - 99.06919189833442 - ], - [ - 1700047200000, - 99.13105510262923 - ], - [ - 1700048400000, - 98.99236330721192 - ], - [ - 1700049600000, - 98.80510091380069 - ] - ] - }, - { - "name": "cpu", - "tags": { - "cpu": "cpu0" - }, - "columns": [ - "time", - "mean" - ], - "values": [ - [ - 1700046000000, - 99.01372119142576 - ], - [ - 1700047200000, - 99.00430308480553 - ], - [ - 1700048400000, - 98.9737996641964 - ], - [ - 1700049600000, - 98.79638916754935 - ] - ] - }, - { - "name": "cpu", - "tags": { - "cpu": "cpu1" - }, - "columns": [ - "time", - "mean" - ], - "values": [ - [ - 1700046000000, - 99.04949983158023 - ], - [ - 1700047200000, - 99.06989461231551 - ], - [ - 1700048400000, - 98.97954813782476 - ], - [ - 1700049600000, - 98.49246231161365 - ] - ] - }, - { - "name": "cpu", - "tags": { - "cpu": "cpu2" - }, - "columns": [ - "time", - "mean" - ], - "values": [ - [ - 1700046000000, - 99.11296419686643 - ], - [ - 1700047200000, - 99.01817278917116 - ], - [ - 1700048400000, - 98.96847021232013 - ], - [ - 1700049600000, - 98.192771084406 - ] - ] - }, - { - "name": "cpu", - "tags": { - "cpu": "cpu3" - }, - "columns": [ - "time", - "mean" - ], - "values": [ - [ - 1700046000000, - 99.0742704326151 - ], - [ - 1700047200000, - 99.17835628293322 - ], - [ - 1700048400000, - 98.98968994907334 - ], - [ - 1700049600000, - 98.69215291745849 - ] - ] - } - ] - } - ] -} +{"results":[{"statement_id":0,"series":[{"name":"cpu","tags":{"cpu":"cpu-total"},"columns":["time","mean"],"values":[[1700046000000,99.06919189833442],[1700047200000,99.13105510262923],[1700048400000,98.99236330721192],[1700049600000,98.80510091380069]]},{"name":"cpu","tags":{"cpu":"cpu0"},"columns":["time","mean"],"values":[[1700046000000,99.01372119142576],[1700047200000,99.00430308480553],[1700048400000,98.9737996641964],[1700049600000,98.79638916754935]]},{"name":"cpu","tags":{"cpu":"cpu1"},"columns":["time","mean"],"values":[[1700046000000,99.04949983158023],[1700047200000,99.06989461231551],[1700048400000,98.97954813782476],[1700049600000,98.49246231161365]]},{"name":"cpu","tags":{"cpu":"cpu2"},"columns":["time","mean"],"values":[[1700046000000,99.11296419686643],[1700047200000,99.01817278917116],[1700048400000,98.96847021232013],[1700049600000,98.192771084406]]},{"name":"cpu","tags":{"cpu":"cpu3"},"columns":["time","mean"],"values":[[1700046000000,99.0742704326151],[1700047200000,99.17835628293322],[1700048400000,98.98968994907334],[1700049600000,98.69215291745849]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/multiple_series_with_tags_and_multiple_columns.json b/pkg/tsdb/influxdb/influxql/testdata/multiple_series_with_tags_and_multiple_columns.json index f01b9addef029..804ecae5ea932 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/multiple_series_with_tags_and_multiple_columns.json +++ b/pkg/tsdb/influxdb/influxql/testdata/multiple_series_with_tags_and_multiple_columns.json @@ -1,273 +1 @@ -{ - "results": [ - { - "statement_id": 0, - "series": [ - { - "name": "cpu", - "tags": { - "cpu": "cpu-total" - }, - "columns": [ - "time", - "mean", - "min", - "p90", - "p95", - "max" - ], - "values": [ - [ - 1700046000000, - 99.06348570053983, - 97.3214285712978, - 99.2066680055868, - 99.24812030075188, - 99.31809065366402 - ] - ] - }, - { - "name": "cpu", - "tags": { - "cpu": "cpu0" - }, - "columns": [ - "time", - "mean", - "min", - "p90", - "p95", - "max" - ], - "values": [ - [ - 1700046000000, - 98.99671817733766, - 96.65991902847126, - 99.29364278499536, - 99.29718875523953, - 99.59839357421622 - ] - ] - }, - { - "name": "cpu", - "tags": { - "cpu": "cpu1" - }, - "columns": [ - "time", - "mean", - "min", - "p90", - "p95", - "max" - ], - "values": [ - [ - 1700046000000, - 99.03148357927465, - 96.67673715996412, - 99.39698492464545, - 99.39759036146867, - 99.59798994966731 - ] - ] - }, - { - "name": "cpu", - "tags": { - "cpu": "cpu2" - }, - "columns": [ - "time", - "mean", - "min", - "p90", - "p95", - "max" - ], - "values": [ - [ - 1700046000000, - 99.03087433486812, - 96.03658536600605, - 99.29859719431953, - 99.39759036146867, - 99.59879638908582 - ] - ] - }, - { - "name": "cpu", - "tags": { - "cpu": "cpu3" - }, - "columns": [ - "time", - "mean", - "min", - "p90", - "p95", - "max" - ], - "values": [ - [ - 1700046000000, - 99.0796957137731, - 97.37903225797402, - 99.39698492464723, - 99.39879759521435, - 99.4984954865762 - ] - ] - }, - { - "name": "cpu", - "tags": { - "cpu": "cpu4" - }, - "columns": [ - "time", - "mean", - "min", - "p90", - "p95", - "max" - ], - "values": [ - [ - 1700046000000, - 99.09573460946685, - 97.57330637016123, - 99.39759036146867, - 99.49698189117252, - 99.59839357450608 - ] - ] - }, - { - "name": "cpu", - "tags": { - "cpu": "cpu5" - }, - "columns": [ - "time", - "mean", - "min", - "p90", - "p95", - "max" - ], - "values": [ - [ - 1700046000000, - 99.0690883079725, - 96.65991902847126, - 99.39698492464545, - 99.39819458377468, - 99.59798994995865 - ] - ] - }, - { - "name": "cpu", - "tags": { - "cpu": "cpu6" - }, - "columns": [ - "time", - "mean", - "min", - "p90", - "p95", - "max" - ], - "values": [ - [ - 1700046000000, - 99.06475215715605, - 97.37108190081956, - 99.39698492464545, - 99.39879759521259, - 99.69879518073434 - ] - ] - }, - { - "name": "cpu", - "tags": { - "cpu": "cpu7" - }, - "columns": [ - "time", - "mean", - "min", - "p90", - "p95", - "max" - ], - "values": [ - [ - 1700046000000, - 99.06204005079694, - 97.7596741344093, - 99.39637826964127, - 99.39759036147042, - 99.59879638908698 - ] - ] - }, - { - "name": "cpu", - "tags": { - "cpu": "cpu8" - }, - "columns": [ - "time", - "mean", - "min", - "p90", - "p95", - "max" - ], - "values": [ - [ - 1700046000000, - 99.0999818796052, - 96.56565656568982, - 99.39698492464723, - 99.39819458377468, - 99.59758551299777 - ] - ] - }, - { - "name": "cpu", - "tags": { - "cpu": "cpu9" - }, - "columns": [ - "time", - "mean", - "min", - "p90", - "p95", - "max" - ], - "values": [ - [ - 1700046000000, - 99.10477313534511, - 96.8463886063268, - 99.39759036146867, - 99.39819458377468, - 99.59839357421622 - ] - ] - } - ] - } - ] -} +{"results":[{"statement_id":0,"series":[{"name":"cpu","tags":{"cpu":"cpu-total"},"columns":["time","mean","min","p90","p95","max"],"values":[[1700046000000,99.06348570053983,97.3214285712978,99.2066680055868,99.24812030075188,99.31809065366402]]},{"name":"cpu","tags":{"cpu":"cpu0"},"columns":["time","mean","min","p90","p95","max"],"values":[[1700046000000,98.99671817733766,96.65991902847126,99.29364278499536,99.29718875523953,99.59839357421622]]},{"name":"cpu","tags":{"cpu":"cpu1"},"columns":["time","mean","min","p90","p95","max"],"values":[[1700046000000,99.03148357927465,96.67673715996412,99.39698492464545,99.39759036146867,99.59798994966731]]},{"name":"cpu","tags":{"cpu":"cpu2"},"columns":["time","mean","min","p90","p95","max"],"values":[[1700046000000,99.03087433486812,96.03658536600605,99.29859719431953,99.39759036146867,99.59879638908582]]},{"name":"cpu","tags":{"cpu":"cpu3"},"columns":["time","mean","min","p90","p95","max"],"values":[[1700046000000,99.0796957137731,97.37903225797402,99.39698492464723,99.39879759521435,99.4984954865762]]},{"name":"cpu","tags":{"cpu":"cpu4"},"columns":["time","mean","min","p90","p95","max"],"values":[[1700046000000,99.09573460946685,97.57330637016123,99.39759036146867,99.49698189117252,99.59839357450608]]},{"name":"cpu","tags":{"cpu":"cpu5"},"columns":["time","mean","min","p90","p95","max"],"values":[[1700046000000,99.0690883079725,96.65991902847126,99.39698492464545,99.39819458377468,99.59798994995865]]},{"name":"cpu","tags":{"cpu":"cpu6"},"columns":["time","mean","min","p90","p95","max"],"values":[[1700046000000,99.06475215715605,97.37108190081956,99.39698492464545,99.39879759521259,99.69879518073434]]},{"name":"cpu","tags":{"cpu":"cpu7"},"columns":["time","mean","min","p90","p95","max"],"values":[[1700046000000,99.06204005079694,97.7596741344093,99.39637826964127,99.39759036147042,99.59879638908698]]},{"name":"cpu","tags":{"cpu":"cpu8"},"columns":["time","mean","min","p90","p95","max"],"values":[[1700046000000,99.0999818796052,96.56565656568982,99.39698492464723,99.39819458377468,99.59758551299777]]},{"name":"cpu","tags":{"cpu":"cpu9"},"columns":["time","mean","min","p90","p95","max"],"values":[[1700046000000,99.10477313534511,96.8463886063268,99.39759036146867,99.39819458377468,99.59839357421622]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/one_measurement_with_two_columns.json b/pkg/tsdb/influxdb/influxql/testdata/one_measurement_with_two_columns.json index 7b6a84f20427e..d179eb5d24494 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/one_measurement_with_two_columns.json +++ b/pkg/tsdb/influxdb/influxql/testdata/one_measurement_with_two_columns.json @@ -1,34 +1 @@ -{ - "results": [ - { - "statement_id": 0, - "series": [ - { - "name": "cpu", - "columns": [ - "time", - "mean" - ], - "values": [ - [ - 1700046000000, - 99.0693929754458 - ], - [ - 1700047200000, - 99.13073313839024 - ], - [ - 1700048400000, - 98.99278645182834 - ], - [ - 1700049600000, - 98.77818123433566 - ] - ] - } - ] - } - ] -} +{"results":[{"statement_id":0,"series":[{"name":"cpu","columns":["time","mean"],"values":[[1700046000000,99.0693929754458],[1700047200000,99.13073313839024],[1700048400000,98.99278645182834],[1700049600000,98.77818123433566]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/response.json b/pkg/tsdb/influxdb/influxql/testdata/response.json index 4bae34be4fd6f..a06def4b4aad9 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/response.json +++ b/pkg/tsdb/influxdb/influxql/testdata/response.json @@ -1,30 +1 @@ -{ - "results": [ - { - "series": [ - { - "name": "cpu.upc", - "columns": [ - "time", - "mean", - "sum" - ], - "tags": { - "datacenter": "America", - "dc.region.name": "Northeast", - "cluster-name": "Cluster", - "/cluster/name/": "Cluster/", - "@cluster@name@": "Cluster@" - }, - "values": [ - [ - 111, - 222, - 333 - ] - ] - } - ] - } - ] -} +{"results":[{"series":[{"name":"cpu.upc","columns":["time","mean","sum"],"tags":{"datacenter":"America","dc.region.name":"Northeast","cluster-name":"Cluster","/cluster/name/":"Cluster/","@cluster@name@":"Cluster@"},"values":[[111,222,333]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/response_with_nil_bools_and_nil_strings.json b/pkg/tsdb/influxdb/influxql/testdata/response_with_nil_bools_and_nil_strings.json index bd376a2d9a9ef..21a534f555355 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/response_with_nil_bools_and_nil_strings.json +++ b/pkg/tsdb/influxdb/influxql/testdata/response_with_nil_bools_and_nil_strings.json @@ -1,40 +1 @@ -{ - "results": [ - { - "series": [ - { - "name": "cpu", - "columns": [ - "time", - "mean", - "path", - "isActive" - ], - "tags": { - "datacenter": "America" - }, - "values": [ - [ - 111, - 222, - null, - null - ], - [ - 111, - 222, - "/usr/path", - false - ], - [ - 111, - null, - "/usr/path", - true - ] - ] - } - ] - } - ] -} +{"results":[{"series":[{"name":"cpu","columns":["time","mean","path","isActive"],"tags":{"datacenter":"America"},"values":[[111,222,null,null],[111,222,"/usr/path",false],[111,null,"/usr/path",true]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/response_with_weird_tag.json b/pkg/tsdb/influxdb/influxql/testdata/response_with_weird_tag.json index 1356b7c1a30c3..7eaad4b7ad3f5 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/response_with_weird_tag.json +++ b/pkg/tsdb/influxdb/influxql/testdata/response_with_weird_tag.json @@ -1,26 +1 @@ -{ - "results": [ - { - "series": [ - { - "name": "cpu.upc", - "columns": [ - "time", - "mean", - "sum" - ], - "tags": { - "@cluster@name@": "Cluster@" - }, - "values": [ - [ - 111, - 222, - 333 - ] - ] - } - ] - } - ] -} +{"results":[{"series":[{"name":"cpu.upc","columns":["time","mean","sum"],"tags":{"@cluster@name@":"Cluster@"},"values":[[111,222,333]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/retention_policy.json b/pkg/tsdb/influxdb/influxql/testdata/retention_policy.json index 1d48229e202c7..7d72b7a941133 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/retention_policy.json +++ b/pkg/tsdb/influxdb/influxql/testdata/retention_policy.json @@ -1,55 +1 @@ -{ - "results": [ - { - "statement_id": 0, - "series": [ - { - "columns": [ - "name", - "duration", - "shardGroupDuration", - "replicaN", - "default" - ], - "values": [ - [ - "default", - "0s", - "168h0m0s", - 1, - true - ], - [ - "autogen", - "0s", - "168h0m0s", - 1, - false - ], - [ - "bar", - "0s", - "168h0m0s", - 1, - false - ], - [ - "5m_avg", - "0s", - "168h0m0s", - 1, - false - ], - [ - "1m_avg", - "0s", - "168h0m0s", - 1, - false - ] - ] - } - ] - } - ] -} +{"results":[{"statement_id":0,"series":[{"columns":["name","duration","shardGroupDuration","replicaN","default"],"values":[["default","0s","168h0m0s",1,true],["autogen","0s","168h0m0s",1,false],["bar","0s","168h0m0s",1,false],["5m_avg","0s","168h0m0s",1,false],["1m_avg","0s","168h0m0s",1,false]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/show_diagnostics.json b/pkg/tsdb/influxdb/influxql/testdata/show_diagnostics.json new file mode 100644 index 0000000000000..25843bd3ac145 --- /dev/null +++ b/pkg/tsdb/influxdb/influxql/testdata/show_diagnostics.json @@ -0,0 +1 @@ +{"results":[{"statement_id":0,"series":[{"name":"build","columns":["Branch","Build Time","Commit","Version"],"values":[["1.8","","688e697c51fd","1.8.10"]]},{"name":"config","columns":["bind-address","reporting-disabled"],"values":[["127.0.0.1:8088",false]]},{"name":"config-coordinator","columns":["log-queries-after","max-concurrent-queries","max-select-buckets","max-select-point","max-select-series","query-timeout","write-timeout"],"values":[["0s",0,0,0,0,"0s","10s"]]},{"name":"config-cqs","columns":["enabled","query-stats-enabled","run-interval"],"values":[[true,false,"1s"]]},{"name":"config-data","columns":["cache-max-memory-size","cache-snapshot-memory-size","cache-snapshot-write-cold-duration","compact-full-write-cold-duration","dir","max-concurrent-compactions","max-index-log-file-size","max-series-per-database","max-values-per-tag","series-file-max-concurrent-compactions","series-id-set-cache-size","strict-error-handling","wal-dir","wal-fsync-delay"],"values":[[1073741824,26214400,"10m0s","4h0m0s","/var/lib/influxdb/data",0,1048576,1000000,100000,0,100,false,"/var/lib/influxdb/wal","0s"]]},{"name":"config-httpd","columns":["access-log-path","bind-address","enabled","https-enabled","max-connection-limit","max-row-limit"],"values":[["",":8086",true,false,0,10000]]},{"name":"config-meta","columns":["dir"],"values":[["/var/lib/influxdb/meta"]]},{"name":"config-monitor","columns":["store-database","store-enabled","store-interval"],"values":[["_internal",true,"10s"]]},{"name":"config-precreator","columns":["advance-period","check-interval","enabled"],"values":[["30m0s","10m0s",true]]},{"name":"config-retention","columns":["check-interval","enabled"],"values":[["30m0s",true]]},{"name":"config-subscriber","columns":["enabled","http-timeout","write-buffer-size","write-concurrency"],"values":[[true,"30s",1000,40]]},{"name":"network","columns":["hostname"],"values":[["635ab5063baa"]]},{"name":"runtime","columns":["GOARCH","GOMAXPROCS","GOOS","version"],"values":[["arm64",10,"linux","go1.13.8"]]},{"name":"system","columns":["PID","currentTime","started","uptime"],"values":[[1,"2024-02-02T11:33:46.519061719Z","2024-01-29T11:11:07.051577923Z","96h22m39.467483796s"]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/show_diagnostics.time_series.golden.jsonc b/pkg/tsdb/influxdb/influxql/testdata/show_diagnostics.time_series.golden.jsonc new file mode 100644 index 0000000000000..7e4837248dcfb --- /dev/null +++ b/pkg/tsdb/influxdb/influxql/testdata/show_diagnostics.time_series.golden.jsonc @@ -0,0 +1,512 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "preferredVisualisationType": "graph", +// "executedQueryString": "Test raw query" +// } +// Name: build +// Dimensions: 1 Fields by 1 Rows +// +-----------------+ +// | Name: Value | +// | Labels: | +// | Type: []*string | +// +-----------------+ +// | 1.8 | +// +-----------------+ +// +// +// +// Frame[1] +// Name: config +// Dimensions: 1 Fields by 1 Rows +// +-----------------+ +// | Name: Value | +// | Labels: | +// | Type: []*string | +// +-----------------+ +// | 127.0.0.1:8088 | +// +-----------------+ +// +// +// +// Frame[2] +// Name: config-coordinator +// Dimensions: 1 Fields by 1 Rows +// +-----------------+ +// | Name: Value | +// | Labels: | +// | Type: []*string | +// +-----------------+ +// | 0s | +// +-----------------+ +// +// +// +// Frame[3] +// Name: config-cqs +// Dimensions: 1 Fields by 1 Rows +// +-----------------+ +// | Name: Value | +// | Labels: | +// | Type: []*string | +// +-----------------+ +// | true | +// +-----------------+ +// +// +// +// Frame[4] +// Name: config-data +// Dimensions: 1 Fields by 1 Rows +// +-----------------+ +// | Name: Value | +// | Labels: | +// | Type: []*string | +// +-----------------+ +// | 1073741824 | +// +-----------------+ +// +// +// +// Frame[5] +// Name: config-httpd +// Dimensions: 1 Fields by 1 Rows +// +-----------------+ +// | Name: Value | +// | Labels: | +// | Type: []*string | +// +-----------------+ +// | | +// +-----------------+ +// +// +// +// Frame[6] +// Name: config-meta +// Dimensions: 1 Fields by 1 Rows +// +------------------------+ +// | Name: Value | +// | Labels: | +// | Type: []*string | +// +------------------------+ +// | /var/lib/influxdb/meta | +// +------------------------+ +// +// +// +// Frame[7] +// Name: config-monitor +// Dimensions: 1 Fields by 1 Rows +// +-----------------+ +// | Name: Value | +// | Labels: | +// | Type: []*string | +// +-----------------+ +// | _internal | +// +-----------------+ +// +// +// +// Frame[8] +// Name: config-precreator +// Dimensions: 1 Fields by 1 Rows +// +-----------------+ +// | Name: Value | +// | Labels: | +// | Type: []*string | +// +-----------------+ +// | 30m0s | +// +-----------------+ +// +// +// +// Frame[9] +// Name: config-retention +// Dimensions: 1 Fields by 1 Rows +// +-----------------+ +// | Name: Value | +// | Labels: | +// | Type: []*string | +// +-----------------+ +// | 30m0s | +// +-----------------+ +// +// +// +// Frame[10] +// Name: config-subscriber +// Dimensions: 1 Fields by 1 Rows +// +-----------------+ +// | Name: Value | +// | Labels: | +// | Type: []*string | +// +-----------------+ +// | true | +// +-----------------+ +// +// +// +// Frame[11] +// Name: network +// Dimensions: 1 Fields by 1 Rows +// +-----------------+ +// | Name: Value | +// | Labels: | +// | Type: []*string | +// +-----------------+ +// | 635ab5063baa | +// +-----------------+ +// +// +// +// Frame[12] +// Name: runtime +// Dimensions: 1 Fields by 1 Rows +// +-----------------+ +// | Name: Value | +// | Labels: | +// | Type: []*string | +// +-----------------+ +// | arm64 | +// +-----------------+ +// +// +// +// Frame[13] +// Name: system +// Dimensions: 1 Fields by 1 Rows +// +-----------------+ +// | Name: Value | +// | Labels: | +// | Type: []*string | +// +-----------------+ +// | 1 | +// +-----------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "name": "build", + "meta": { + "typeVersion": [ + 0, + 0 + ], + "preferredVisualisationType": "graph", + "executedQueryString": "Test raw query" + }, + "fields": [ + { + "name": "Value", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + "1.8" + ] + ] + } + }, + { + "schema": { + "name": "config", + "fields": [ + { + "name": "Value", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + "127.0.0.1:8088" + ] + ] + } + }, + { + "schema": { + "name": "config-coordinator", + "fields": [ + { + "name": "Value", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + "0s" + ] + ] + } + }, + { + "schema": { + "name": "config-cqs", + "fields": [ + { + "name": "Value", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + "true" + ] + ] + } + }, + { + "schema": { + "name": "config-data", + "fields": [ + { + "name": "Value", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + "1073741824" + ] + ] + } + }, + { + "schema": { + "name": "config-httpd", + "fields": [ + { + "name": "Value", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + "" + ] + ] + } + }, + { + "schema": { + "name": "config-meta", + "fields": [ + { + "name": "Value", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + "/var/lib/influxdb/meta" + ] + ] + } + }, + { + "schema": { + "name": "config-monitor", + "fields": [ + { + "name": "Value", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + "_internal" + ] + ] + } + }, + { + "schema": { + "name": "config-precreator", + "fields": [ + { + "name": "Value", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + "30m0s" + ] + ] + } + }, + { + "schema": { + "name": "config-retention", + "fields": [ + { + "name": "Value", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + "30m0s" + ] + ] + } + }, + { + "schema": { + "name": "config-subscriber", + "fields": [ + { + "name": "Value", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + "true" + ] + ] + } + }, + { + "schema": { + "name": "network", + "fields": [ + { + "name": "Value", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + "635ab5063baa" + ] + ] + } + }, + { + "schema": { + "name": "runtime", + "fields": [ + { + "name": "Value", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + "arm64" + ] + ] + } + }, + { + "schema": { + "name": "system", + "fields": [ + { + "name": "Value", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + } + ] + }, + "data": { + "values": [ + [ + "1" + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/influxdb/influxql/testdata/show_tag_values_response.json b/pkg/tsdb/influxdb/influxql/testdata/show_tag_values_response.json index 71d8116a1edb7..06537e3ad6391 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/show_tag_values_response.json +++ b/pkg/tsdb/influxdb/influxql/testdata/show_tag_values_response.json @@ -1,62 +1 @@ -{ - "results": [ - { - "statement_id": 0, - "series": [ - { - "name": "cpu", - "columns": [ - "key", - "value" - ], - "values": [ - [ - "cpu", - "cpu-total" - ], - [ - "cpu", - "cpu0" - ], - [ - "cpu", - "cpu1" - ], - [ - "cpu", - "cpu2" - ], - [ - "cpu", - "cpu3" - ], - [ - "cpu", - "cpu4" - ], - [ - "cpu", - "cpu5" - ], - [ - "cpu", - "cpu6" - ], - [ - "cpu", - "cpu7" - ], - [ - "cpu", - "cpu8" - ], - [ - "cpu", - "cpu9" - ] - ] - } - ] - } - ] -} +{"results":[{"statement_id":0,"series":[{"name":"cpu","columns":["key","value"],"values":[["cpu","cpu-total"],["cpu","cpu0"],["cpu","cpu1"],["cpu","cpu2"],["cpu","cpu3"],["cpu","cpu4"],["cpu","cpu5"],["cpu","cpu6"],["cpu","cpu7"],["cpu","cpu8"],["cpu","cpu9"]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/simple_response.json b/pkg/tsdb/influxdb/influxql/testdata/simple_response.json index 8faaede98b6a0..994a84a6cc57c 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/simple_response.json +++ b/pkg/tsdb/influxdb/influxql/testdata/simple_response.json @@ -1,44 +1 @@ -{ - "results": [ - { - "statement_id": 0, - "series": [ - { - "name": "cpu", - "columns": [ - "time", - "usage_idle", - "usage_iowait" - ], - "values": [ - [ - 1700090120000, - 99.0255173802101, - 0.020092425155804713 - ], - [ - 1700090120000, - 99.29718875523953, - 0 - ], - [ - 1700090120000, - 99.09456740445926, - 0 - ], - [ - 1700090120000, - 99.39455095864957, - 0 - ], - [ - 1700090120000, - 99.09729187566201, - 0 - ] - ] - } - ] - } - ] -} +{"results":[{"statement_id":0,"series":[{"name":"cpu","columns":["time","usage_idle","usage_iowait"],"values":[[1700090120000,99.0255173802101,0.020092425155804713],[1700090120000,99.29718875523953,0],[1700090120000,99.09456740445926,0],[1700090120000,99.39455095864957,0],[1700090120000,99.09729187566201,0]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/simple_response_with_diverse_data_types.json b/pkg/tsdb/influxdb/influxql/testdata/simple_response_with_diverse_data_types.json index c1568210c3578..6f03bbd06c247 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/simple_response_with_diverse_data_types.json +++ b/pkg/tsdb/influxdb/influxql/testdata/simple_response_with_diverse_data_types.json @@ -1,35 +1 @@ -{ - "results": [ - { - "statement_id": 0, - "series": [ - { - "name": "Annotation", - "columns": [ - "time", - "domain", - "type", - "ASD", - "details" - ], - "values": [ - [ - 1697789142916, - "AASD157", - "fghg", - null, - "Something happened AtTime=2023-10-20T08:05:42.902036" - ], - [ - 1697789142918, - "HUY23", - "val23", - null, - "Something else happened AtTime=2023-10-20T08:05:42.902036" - ] - ] - } - ] - } - ] -} +{"results":[{"statement_id":0,"series":[{"name":"Annotation","columns":["time","domain","type","ASD","details"],"values":[[1697789142916,"AASD157","fghg",null,"Something happened AtTime=2023-10-20T08:05:42.902036"],[1697789142918,"HUY23","val23",null,"Something else happened AtTime=2023-10-20T08:05:42.902036"]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/some_values_are_null.json b/pkg/tsdb/influxdb/influxql/testdata/some_values_are_null.json index 26c9979b27cbf..43a4249284669 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/some_values_are_null.json +++ b/pkg/tsdb/influxdb/influxql/testdata/some_values_are_null.json @@ -1,29 +1 @@ -{ - "results": [ - { - "series": [ - { - "name": "cpu", - "columns": [ - "time", - "mean" - ], - "values": [ - [ - 100, - null - ], - [ - 101, - null - ], - [ - 102, - 52 - ] - ] - } - ] - } - ] -} +{"results":[{"series":[{"name":"cpu","columns":["time","mean"],"values":[[100,null],[101,null],[102,52]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/string_column_with_null_value.json b/pkg/tsdb/influxdb/influxql/testdata/string_column_with_null_value.json index a7da6ca76549e..e23cfab65ba8e 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/string_column_with_null_value.json +++ b/pkg/tsdb/influxdb/influxql/testdata/string_column_with_null_value.json @@ -1,51 +1 @@ -{ - "results": [ - { - "statement_id": 0, - "series": [ - { - "name": "series_name", - "tags": {}, - "columns": [ - "time", - "col_1_name" - ], - "values": [ - [ - 1678723623474, - "589051IR" - ] - ] - }, - { - "name": "series_name", - "tags": {}, - "columns": [ - "time", - "col_1_name" - ], - "values": [ - [ - 1678723623474, - null - ] - ] - }, - { - "name": "series_name", - "tags": {}, - "columns": [ - "time", - "col_1_name" - ], - "values": [ - [ - 1678723623474, - null - ] - ] - } - ] - } - ] -} +{"results":[{"statement_id":0,"series":[{"name":"series_name","tags":{},"columns":["time","col_1_name"],"values":[[1678723623474,"589051IR"]]},{"name":"series_name","tags":{},"columns":["time","col_1_name"],"values":[[1678723623474,null]]},{"name":"series_name","tags":{},"columns":["time","col_1_name"],"values":[[1678723623474,null]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/testdata/string_column_with_null_value2.json b/pkg/tsdb/influxdb/influxql/testdata/string_column_with_null_value2.json index 4127a44c7be07..9ccea8bacef55 100644 --- a/pkg/tsdb/influxdb/influxql/testdata/string_column_with_null_value2.json +++ b/pkg/tsdb/influxdb/influxql/testdata/string_column_with_null_value2.json @@ -1,51 +1 @@ -{ - "results": [ - { - "statement_id": 0, - "series": [ - { - "name": "series_name", - "tags": {}, - "columns": [ - "time", - "col_1_name" - ], - "values": [ - [ - 1678723623474, - null - ] - ] - }, - { - "name": "series_name", - "tags": {}, - "columns": [ - "time", - "col_1_name" - ], - "values": [ - [ - 1678723623474, - "someval" - ] - ] - }, - { - "name": "series_name", - "tags": {}, - "columns": [ - "time", - "col_1_name" - ], - "values": [ - [ - 1678723623474, - "anotherval" - ] - ] - } - ] - } - ] -} +{"results":[{"statement_id":0,"series":[{"name":"series_name","tags":{},"columns":["time","col_1_name"],"values":[[1678723623474,null]]},{"name":"series_name","tags":{},"columns":["time","col_1_name"],"values":[[1678723623474,"someval"]]},{"name":"series_name","tags":{},"columns":["time","col_1_name"],"values":[[1678723623474,"anotherval"]]}]}]} diff --git a/pkg/tsdb/influxdb/influxql/util/util.go b/pkg/tsdb/influxdb/influxql/util/util.go index 5d03fcb110449..b4ecc095b6b1c 100644 --- a/pkg/tsdb/influxdb/influxql/util/util.go +++ b/pkg/tsdb/influxdb/influxql/util/util.go @@ -139,6 +139,15 @@ func ParseNumber(value any) *float64 { return &fvalue } +func ParseString(value any) *string { + switch val := value.(type) { + case string: + return ToPtr(val) + default: + return ToPtr(fmt.Sprintf("%v", value)) + } +} + func GetVisType(resFormat string) data.VisType { switch resFormat { case "table": diff --git a/pkg/tsdb/influxdb/influxql/util/util_test.go b/pkg/tsdb/influxdb/influxql/util/util_test.go new file mode 100644 index 0000000000000..a535cc5402656 --- /dev/null +++ b/pkg/tsdb/influxdb/influxql/util/util_test.go @@ -0,0 +1,23 @@ +package util + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseString(t *testing.T) { + t.Run("parse bool value to string", func(t *testing.T) { + val := true + expected := ToPtr("true") + result := ParseString(val) + require.Equal(t, expected, result) + }) + + t.Run("parse number value to string", func(t *testing.T) { + val := 123 + expected := ToPtr("123") + result := ParseString(val) + require.Equal(t, expected, result) + }) +} diff --git a/pkg/tsdb/influxdb/mocks_test.go b/pkg/tsdb/influxdb/mocks_test.go index 8b99d8ef842d0..c08090bb747a3 100644 --- a/pkg/tsdb/influxdb/mocks_test.go +++ b/pkg/tsdb/influxdb/mocks_test.go @@ -118,22 +118,8 @@ func GetMockService(version string, rt RoundTripper) *Service { version: version, fakeRoundTripper: rt, }, - features: &fakeFeatureToggles{ - flags: map[string]bool{ - featuremgmt.FlagInfluxqlStreamingParser: false, - }, - }, - } -} - -type fakeFeatureToggles struct { - flags map[string]bool -} - -func (f *fakeFeatureToggles) IsEnabledGlobally(flag string) bool { - return f.flags[flag] -} -func (f *fakeFeatureToggles) IsEnabled(ctx context.Context, flag string) bool { - return f.flags[flag] + // featuremgmt.FlagInfluxqlStreamingParser: false + features: featuremgmt.WithFeatures(), + } } diff --git a/pkg/tsdb/influxdb/models/datasource_info.go b/pkg/tsdb/influxdb/models/datasource_info.go index 801ec4d082720..2e953ed4a7949 100644 --- a/pkg/tsdb/influxdb/models/datasource_info.go +++ b/pkg/tsdb/influxdb/models/datasource_info.go @@ -2,6 +2,7 @@ package models import ( "net/http" + "time" ) type DatasourceInfo struct { @@ -17,7 +18,8 @@ type DatasourceInfo struct { DefaultBucket string `json:"defaultBucket"` Organization string `json:"organization"` MaxSeries int `json:"maxSeries"` + Timeout time.Duration // FlightSQL grpc connection - SecureGrpc bool `json:"secureGrpc"` + InsecureGrpc bool `json:"insecureGrpc"` } diff --git a/pkg/tsdb/influxdb/models/query.go b/pkg/tsdb/influxdb/models/query.go index e8e6837aae7e8..0620e5d3b027d 100644 --- a/pkg/tsdb/influxdb/models/query.go +++ b/pkg/tsdb/influxdb/models/query.go @@ -8,13 +8,13 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" - - "github.com/grafana/grafana/pkg/tsdb/intervalv2" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" ) var ( - regexpOperatorPattern = regexp.MustCompile(`^\/.*\/$`) - regexpMeasurementPattern = regexp.MustCompile(`^\/.*\/$`) + regexpOperatorPattern = regexp.MustCompile(`^\/.*\/$`) + regexpMeasurementPattern = regexp.MustCompile(`^\/.*\/$`) + regexMatcherWithStartEndPattern = regexp.MustCompile(`^/\^(.*)\$/$`) ) func (query *Query) Build(queryContext *backend.QueryDataRequest) (string, error) { @@ -33,7 +33,7 @@ func (query *Query) Build(queryContext *backend.QueryDataRequest) (string, error res += query.renderTz() } - intervalText := intervalv2.FormatDuration(query.Interval) + intervalText := gtime.FormatInterval(query.Interval) intervalMs := int64(query.Interval / time.Millisecond) res = strings.ReplaceAll(res, "$timeFilter", query.renderTimeFilter(queryContext)) @@ -103,20 +103,20 @@ func (query *Query) renderTags() []string { textValue = fmt.Sprintf("'%s'", strings.ReplaceAll(tag.Value, `\`, `\\`)) } - return textValue, operator + return removeRegexWrappers(textValue, `'`), operator } // quote value unless regex or number var textValue string switch tag.Operator { - case "=~", "!~": + case "=~", "!~", "": textValue = tag.Value case "<", ">", ">=", "<=": - textValue = tag.Value + textValue = removeRegexWrappers(tag.Value, `'`) case "Is", "Is Not": textValue, tag.Operator = isOperatorTypeHandler(tag) default: - textValue = fmt.Sprintf("'%s'", strings.ReplaceAll(tag.Value, `\`, `\\`)) + textValue = fmt.Sprintf("'%s'", strings.ReplaceAll(removeRegexWrappers(tag.Value, ""), `\`, `\\`)) } escapedKey := fmt.Sprintf(`"%s"`, tag.Key) @@ -244,3 +244,15 @@ func epochMStoInfluxTime(tr *backend.TimeRange) (string, string) { return fmt.Sprintf("%dms", from), fmt.Sprintf("%dms", to) } + +func removeRegexWrappers(wrappedValue string, wrapper string) string { + value := wrappedValue + // get the value only in between /^...$/ + matches := regexMatcherWithStartEndPattern.FindStringSubmatch(wrappedValue) + if len(matches) > 1 { + // full match. the value is like /^value$/ + value = wrapper + matches[1] + wrapper + } + + return value +} diff --git a/pkg/tsdb/influxdb/models/query_test.go b/pkg/tsdb/influxdb/models/query_test.go index c590e829d1630..235de42360efa 100644 --- a/pkg/tsdb/influxdb/models/query_test.go +++ b/pkg/tsdb/influxdb/models/query_test.go @@ -303,5 +303,29 @@ func TestInfluxdbQueryBuilder(t *testing.T) { require.Equal(t, query.renderMeasurement(), ` FROM "policy"./apa/`) }) + + t.Run("can render single quoted tag value when regexed value has been sent", func(t *testing.T) { + query := &Query{Tags: []*Tag{{Operator: ">", Value: `/^12.2$/`, Key: "key"}}} + + require.Equal(t, `"key" > '12.2'`, strings.Join(query.renderTags(), "")) + }) + }) +} + +func TestRemoveRegexWrappers(t *testing.T) { + t.Run("remove regex wrappers", func(t *testing.T) { + wrappedText := `/^someValue$/` + expected := `'someValue'` + result := removeRegexWrappers(wrappedText, `'`) + + require.Equal(t, expected, result) + }) + + t.Run("return same value if the value is not wrapped by regex wrappers", func(t *testing.T) { + wrappedText := `someValue` + expected := `someValue` + result := removeRegexWrappers(wrappedText, "") + + require.Equal(t, expected, result) }) } diff --git a/pkg/tsdb/intervalv2/intervalv2.go b/pkg/tsdb/intervalv2/intervalv2.go deleted file mode 100644 index c107f7ab08713..0000000000000 --- a/pkg/tsdb/intervalv2/intervalv2.go +++ /dev/null @@ -1,255 +0,0 @@ -package intervalv2 - -import ( - "fmt" - "regexp" - "strings" - "time" - - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" -) - -var ( - DefaultRes int64 = 1500 - defaultMinInterval = time.Millisecond * 1 - year = time.Hour * 24 * 365 - day = time.Hour * 24 -) - -type Interval struct { - Text string - Value time.Duration -} - -type intervalCalculator struct { - minInterval time.Duration -} - -type Calculator interface { - Calculate(timerange backend.TimeRange, minInterval time.Duration, maxDataPoints int64) Interval - CalculateSafeInterval(timerange backend.TimeRange, resolution int64) Interval -} - -type CalculatorOptions struct { - MinInterval time.Duration -} - -func NewCalculator(opts ...CalculatorOptions) *intervalCalculator { - calc := &intervalCalculator{} - - for _, o := range opts { - if o.MinInterval == 0 { - calc.minInterval = defaultMinInterval - } else { - calc.minInterval = o.MinInterval - } - } - - return calc -} - -func (i *Interval) Milliseconds() int64 { - return i.Value.Nanoseconds() / int64(time.Millisecond) -} - -func (ic *intervalCalculator) Calculate(timerange backend.TimeRange, minInterval time.Duration, maxDataPoints int64) Interval { - to := timerange.To.UnixNano() - from := timerange.From.UnixNano() - resolution := maxDataPoints - if resolution == 0 { - resolution = DefaultRes - } - - calculatedInterval := time.Duration((to - from) / resolution) - - if calculatedInterval < minInterval { - return Interval{Text: FormatDuration(minInterval), Value: minInterval} - } - - rounded := roundInterval(calculatedInterval) - - return Interval{Text: FormatDuration(rounded), Value: rounded} -} - -func (ic *intervalCalculator) CalculateSafeInterval(timerange backend.TimeRange, safeRes int64) Interval { - to := timerange.To.UnixNano() - from := timerange.From.UnixNano() - safeInterval := time.Duration((to - from) / safeRes) - - rounded := roundInterval(safeInterval) - return Interval{Text: FormatDuration(rounded), Value: rounded} -} - -// GetIntervalFrom returns the minimum interval. -// dsInterval is the string representation of data source min interval, if configured. -// queryInterval is the string representation of query interval (min interval), e.g. "10ms" or "10s". -// queryIntervalMS is a pre-calculated numeric representation of the query interval in milliseconds. -func GetIntervalFrom(dsInterval, queryInterval string, queryIntervalMS int64, defaultInterval time.Duration) (time.Duration, error) { - // Apparently we are setting default value of queryInterval to 0s now - interval := queryInterval - if interval == "0s" { - interval = "" - } - if interval == "" { - if queryIntervalMS != 0 { - return time.Duration(queryIntervalMS) * time.Millisecond, nil - } - } - if interval == "" && dsInterval != "" { - interval = dsInterval - } - if interval == "" { - return defaultInterval, nil - } - - parsedInterval, err := ParseIntervalStringToTimeDuration(interval) - if err != nil { - return time.Duration(0), err - } - - return parsedInterval, nil -} - -func ParseIntervalStringToTimeDuration(interval string) (time.Duration, error) { - formattedInterval := strings.Replace(strings.Replace(interval, "<", "", 1), ">", "", 1) - isPureNum, err := regexp.MatchString(`^\d+$`, formattedInterval) - if err != nil { - return time.Duration(0), err - } - if isPureNum { - formattedInterval += "s" - } - parsedInterval, err := gtime.ParseDuration(formattedInterval) - if err != nil { - return time.Duration(0), err - } - return parsedInterval, nil -} - -// FormatDuration converts a duration into the kbn format e.g. 1m 2h or 3d -func FormatDuration(inter time.Duration) string { - if inter >= year { - return fmt.Sprintf("%dy", inter/year) - } - - if inter >= day { - return fmt.Sprintf("%dd", inter/day) - } - - if inter >= time.Hour { - return fmt.Sprintf("%dh", inter/time.Hour) - } - - if inter >= time.Minute { - return fmt.Sprintf("%dm", inter/time.Minute) - } - - if inter >= time.Second { - return fmt.Sprintf("%ds", inter/time.Second) - } - - if inter >= time.Millisecond { - return fmt.Sprintf("%dms", inter/time.Millisecond) - } - - return "1ms" -} - -//nolint:gocyclo -func roundInterval(interval time.Duration) time.Duration { - switch { - // 0.01s - case interval <= 10*time.Millisecond: - return time.Millisecond * 1 // 0.001s - // 0.015s - case interval <= 15*time.Millisecond: - return time.Millisecond * 10 // 0.01s - // 0.035s - case interval <= 35*time.Millisecond: - return time.Millisecond * 20 // 0.02s - // 0.075s - case interval <= 75*time.Millisecond: - return time.Millisecond * 50 // 0.05s - // 0.15s - case interval <= 150*time.Millisecond: - return time.Millisecond * 100 // 0.1s - // 0.35s - case interval <= 350*time.Millisecond: - return time.Millisecond * 200 // 0.2s - // 0.75s - case interval <= 750*time.Millisecond: - return time.Millisecond * 500 // 0.5s - // 1.5s - case interval <= 1500*time.Millisecond: - return time.Millisecond * 1000 // 1s - // 3.5s - case interval <= 3500*time.Millisecond: - return time.Millisecond * 2000 // 2s - // 7.5s - case interval <= 7500*time.Millisecond: - return time.Millisecond * 5000 // 5s - // 12.5s - case interval <= 12500*time.Millisecond: - return time.Millisecond * 10000 // 10s - // 17.5s - case interval <= 17500*time.Millisecond: - return time.Millisecond * 15000 // 15s - // 25s - case interval <= 25000*time.Millisecond: - return time.Millisecond * 20000 // 20s - // 45s - case interval <= 45000*time.Millisecond: - return time.Millisecond * 30000 // 30s - // 1.5m - case interval <= 90000*time.Millisecond: - return time.Millisecond * 60000 // 1m - // 3.5m - case interval <= 210000*time.Millisecond: - return time.Millisecond * 120000 // 2m - // 7.5m - case interval <= 450000*time.Millisecond: - return time.Millisecond * 300000 // 5m - // 12.5m - case interval <= 750000*time.Millisecond: - return time.Millisecond * 600000 // 10m - // 17.5m - case interval <= 1050000*time.Millisecond: - return time.Millisecond * 900000 // 15m - // 25m - case interval <= 1500000*time.Millisecond: - return time.Millisecond * 1200000 // 20m - // 45m - case interval <= 2700000*time.Millisecond: - return time.Millisecond * 1800000 // 30m - // 1.5h - case interval <= 5400000*time.Millisecond: - return time.Millisecond * 3600000 // 1h - // 2.5h - case interval <= 9000000*time.Millisecond: - return time.Millisecond * 7200000 // 2h - // 4.5h - case interval <= 16200000*time.Millisecond: - return time.Millisecond * 10800000 // 3h - // 9h - case interval <= 32400000*time.Millisecond: - return time.Millisecond * 21600000 // 6h - // 24h - case interval <= 86400000*time.Millisecond: - return time.Millisecond * 43200000 // 12h - // 48h - case interval <= 172800000*time.Millisecond: - return time.Millisecond * 86400000 // 24h - // 1w - case interval <= 604800000*time.Millisecond: - return time.Millisecond * 86400000 // 24h - // 3w - case interval <= 1814400000*time.Millisecond: - return time.Millisecond * 604800000 // 1w - // 2y - case interval < 3628800000*time.Millisecond: - return time.Millisecond * 2592000000 // 30d - default: - return time.Millisecond * 31536000000 // 1y - } -} diff --git a/pkg/tsdb/legacydata/conversions.go b/pkg/tsdb/legacydata/conversions.go new file mode 100644 index 0000000000000..4ca7322f9e8e7 --- /dev/null +++ b/pkg/tsdb/legacydata/conversions.go @@ -0,0 +1,84 @@ +package legacydata + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" +) + +// ToDataSourceQueries returns queries that should be sent to a single datasource +// This will throw an error if the queries reference multiple instances +func ToDataSourceQueries(req data.QueryDataRequest) ([]backend.DataQuery, *data.DataSourceRef, error) { + var dsRef *data.DataSourceRef + var tr *backend.TimeRange + if req.From != "" { + val := NewDataTimeRange(req.From, req.To) + tr = &backend.TimeRange{ + From: val.GetFromAsTimeUTC(), + To: val.GetToAsTimeUTC(), + } + } + + queries := []backend.DataQuery{} + if len(req.Queries) > 0 { + dsRef := req.Queries[0].Datasource + for _, generic := range req.Queries { + if generic.Datasource != nil && dsRef != nil { + if dsRef.Type != generic.Datasource.Type { + return queries, dsRef, fmt.Errorf("expect same datasource types") + } + if dsRef.UID != generic.Datasource.UID { + return queries, dsRef, fmt.Errorf("expect same datasource UID") + } + } + q, err := toBackendDataQuery(generic, tr) + if err != nil { + return queries, dsRef, err + } + queries = append(queries, q) + } + return queries, dsRef, nil + } + return queries, dsRef, nil +} + +// Converts a generic query to a backend one +func toBackendDataQuery(q data.DataQuery, defaultTimeRange *backend.TimeRange) (backend.DataQuery, error) { + var err error + bq := backend.DataQuery{ + RefID: q.RefID, + QueryType: q.QueryType, + MaxDataPoints: q.MaxDataPoints, + } + + // Set an explicit time range for the query + if q.TimeRange != nil { + tr := NewDataTimeRange(q.TimeRange.From, q.TimeRange.To) + bq.TimeRange = backend.TimeRange{ + From: tr.GetFromAsTimeUTC(), + To: tr.GetToAsTimeUTC(), + } + } else if defaultTimeRange != nil { + bq.TimeRange = *defaultTimeRange + } + + bq.JSON, err = json.Marshal(q) + if err != nil { + return bq, err + } + if bq.RefID == "" { + bq.RefID = "A" + } + if bq.MaxDataPoints == 0 { + bq.MaxDataPoints = 100 + } + if q.IntervalMS > 0 { + bq.Interval = time.Millisecond * time.Duration(q.IntervalMS) + } else { + bq.Interval = time.Second + } + return bq, nil +} diff --git a/pkg/tsdb/legacydata/interval/interval.go b/pkg/tsdb/legacydata/interval/interval.go index 2ab255a3c5f78..7f9769f471730 100644 --- a/pkg/tsdb/legacydata/interval/interval.go +++ b/pkg/tsdb/legacydata/interval/interval.go @@ -1,7 +1,6 @@ package interval import ( - "fmt" "regexp" "strings" "time" @@ -16,8 +15,6 @@ import ( var ( DefaultRes int64 = 1500 defaultMinInterval = time.Millisecond * 1 - year = time.Hour * 24 * 365 - day = time.Hour * 24 ) type Interval struct { @@ -62,11 +59,11 @@ func (ic *intervalCalculator) Calculate(timerange legacydata.DataTimeRange, minI calculatedInterval := time.Duration((to - from) / DefaultRes) if calculatedInterval < minInterval { - return Interval{Text: FormatDuration(minInterval), Value: minInterval} + return Interval{Text: gtime.FormatInterval(minInterval), Value: minInterval} } rounded := roundInterval(calculatedInterval) - return Interval{Text: FormatDuration(rounded), Value: rounded} + return Interval{Text: gtime.FormatInterval(rounded), Value: rounded} } func (ic *intervalCalculator) CalculateSafeInterval(timerange legacydata.DataTimeRange, safeRes int64) Interval { @@ -75,7 +72,7 @@ func (ic *intervalCalculator) CalculateSafeInterval(timerange legacydata.DataTim safeInterval := time.Duration((to - from) / safeRes) rounded := roundInterval(safeInterval) - return Interval{Text: FormatDuration(rounded), Value: rounded} + return Interval{Text: gtime.FormatInterval(rounded), Value: rounded} } func GetIntervalFrom(dsInfo *datasources.DataSource, queryModel *simplejson.Json, defaultInterval time.Duration) (time.Duration, error) { @@ -117,126 +114,11 @@ func GetIntervalFrom(dsInfo *datasources.DataSource, queryModel *simplejson.Json return parsedInterval, nil } -// FormatDuration converts a duration into the kbn format e.g. 1m 2h or 3d -func FormatDuration(inter time.Duration) string { - if inter >= year { - return fmt.Sprintf("%dy", inter/year) - } - - if inter >= day { - return fmt.Sprintf("%dd", inter/day) - } - - if inter >= time.Hour { - return fmt.Sprintf("%dh", inter/time.Hour) - } - - if inter >= time.Minute { - return fmt.Sprintf("%dm", inter/time.Minute) - } - - if inter >= time.Second { - return fmt.Sprintf("%ds", inter/time.Second) - } - - if inter >= time.Millisecond { - return fmt.Sprintf("%dms", inter/time.Millisecond) - } - - return "1ms" -} - //nolint:gocyclo func roundInterval(interval time.Duration) time.Duration { - switch { // 0.015s - case interval <= 15*time.Millisecond: + if interval <= 15*time.Millisecond { return time.Millisecond * 10 // 0.01s - // 0.035s - case interval <= 35*time.Millisecond: - return time.Millisecond * 20 // 0.02s - // 0.075s - case interval <= 75*time.Millisecond: - return time.Millisecond * 50 // 0.05s - // 0.15s - case interval <= 150*time.Millisecond: - return time.Millisecond * 100 // 0.1s - // 0.35s - case interval <= 350*time.Millisecond: - return time.Millisecond * 200 // 0.2s - // 0.75s - case interval <= 750*time.Millisecond: - return time.Millisecond * 500 // 0.5s - // 1.5s - case interval <= 1500*time.Millisecond: - return time.Millisecond * 1000 // 1s - // 3.5s - case interval <= 3500*time.Millisecond: - return time.Millisecond * 2000 // 2s - // 7.5s - case interval <= 7500*time.Millisecond: - return time.Millisecond * 5000 // 5s - // 12.5s - case interval <= 12500*time.Millisecond: - return time.Millisecond * 10000 // 10s - // 17.5s - case interval <= 17500*time.Millisecond: - return time.Millisecond * 15000 // 15s - // 25s - case interval <= 25000*time.Millisecond: - return time.Millisecond * 20000 // 20s - // 45s - case interval <= 45000*time.Millisecond: - return time.Millisecond * 30000 // 30s - // 1.5m - case interval <= 90000*time.Millisecond: - return time.Millisecond * 60000 // 1m - // 3.5m - case interval <= 210000*time.Millisecond: - return time.Millisecond * 120000 // 2m - // 7.5m - case interval <= 450000*time.Millisecond: - return time.Millisecond * 300000 // 5m - // 12.5m - case interval <= 750000*time.Millisecond: - return time.Millisecond * 600000 // 10m - // 12.5m - case interval <= 1050000*time.Millisecond: - return time.Millisecond * 900000 // 15m - // 25m - case interval <= 1500000*time.Millisecond: - return time.Millisecond * 1200000 // 20m - // 45m - case interval <= 2700000*time.Millisecond: - return time.Millisecond * 1800000 // 30m - // 1.5h - case interval <= 5400000*time.Millisecond: - return time.Millisecond * 3600000 // 1h - // 2.5h - case interval <= 9000000*time.Millisecond: - return time.Millisecond * 7200000 // 2h - // 4.5h - case interval <= 16200000*time.Millisecond: - return time.Millisecond * 10800000 // 3h - // 9h - case interval <= 32400000*time.Millisecond: - return time.Millisecond * 21600000 // 6h - // 24h - case interval <= 86400000*time.Millisecond: - return time.Millisecond * 43200000 // 12h - // 48h - case interval <= 172800000*time.Millisecond: - return time.Millisecond * 86400000 // 24h - // 1w - case interval <= 604800000*time.Millisecond: - return time.Millisecond * 86400000 // 24h - // 3w - case interval <= 1814400000*time.Millisecond: - return time.Millisecond * 604800000 // 1w - // 2y - case interval < 3628800000*time.Millisecond: - return time.Millisecond * 2592000000 // 30d - default: - return time.Millisecond * 31536000000 // 1y } + return gtime.RoundInterval(interval) } diff --git a/pkg/tsdb/legacydata/interval/interval_test.go b/pkg/tsdb/legacydata/interval/interval_test.go index fdd5deb2519eb..b76c36a6774f6 100644 --- a/pkg/tsdb/legacydata/interval/interval_test.go +++ b/pkg/tsdb/legacydata/interval/interval_test.go @@ -74,26 +74,6 @@ func TestRoundInterval(t *testing.T) { } } -func TestFormatDuration(t *testing.T) { - testCases := []struct { - name string - duration time.Duration - expected string - }{ - {"61s", time.Second * 61, "1m"}, - {"30ms", time.Millisecond * 30, "30ms"}, - {"23h", time.Hour * 23, "23h"}, - {"24h", time.Hour * 24, "1d"}, - {"367d", time.Hour * 24 * 367, "1y"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.expected, FormatDuration(tc.duration)) - }) - } -} - func TestGetIntervalFrom(t *testing.T) { dsJSON, err := simplejson.NewJson([]byte(`{"timeInterval": "60s"}`)) require.NoError(t, err) diff --git a/pkg/tsdb/legacydata/service/service_test.go b/pkg/tsdb/legacydata/service/service_test.go index ebf5f98d488fb..a38e7b43bb1ce 100644 --- a/pkg/tsdb/legacydata/service/service_test.go +++ b/pkg/tsdb/legacydata/service/service_test.go @@ -12,12 +12,12 @@ import ( "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/config" - pluginFakes "github.com/grafana/grafana/pkg/plugins/manager/fakes" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/datasources/guardian" datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" @@ -26,9 +26,14 @@ import ( secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/tsdb/legacydata" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestHandleRequest(t *testing.T) { t.Run("Should invoke plugin manager QueryData when handling request for query", func(t *testing.T) { client := &fakePluginsClient{} @@ -42,12 +47,13 @@ func TestHandleRequest(t *testing.T) { secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) datasourcePermissions := acmock.NewMockedPermissionsService() quotaService := quotatest.New(false, nil) + dsCache := datasourceservice.ProvideCacheService(localcache.ProvideService(), sqlStore, guardian.ProvideGuardian()) dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, sqlStore.Cfg, featuremgmt.WithFeatures(), acmock.New(), datasourcePermissions, quotaService, &pluginstore.FakePluginStore{}) require.NoError(t, err) pCtxProvider := plugincontext.ProvideService(sqlStore.Cfg, localcache.ProvideService(), &pluginstore.FakePluginStore{ PluginList: []pluginstore.Plugin{{JSONData: plugins.JSONData{ID: "test"}}}, - }, dsService, pluginSettings.ProvideService(sqlStore, secretsService), pluginFakes.NewFakeLicensingService(), &config.Cfg{}) + }, dsCache, dsService, pluginSettings.ProvideService(sqlStore, secretsService), pluginconfig.NewFakePluginRequestConfigProvider()) s := ProvideService(client, nil, dsService, pCtxProvider) ds := &datasources.DataSource{ID: 12, Type: "test", JsonData: simplejson.New()} diff --git a/pkg/tsdb/loki/api.go b/pkg/tsdb/loki/api.go index 51b43943ce7cd..aa6c9d8839610 100644 --- a/pkg/tsdb/loki/api.go +++ b/pkg/tsdb/loki/api.go @@ -11,18 +11,20 @@ import ( "net/url" "path" "strconv" + "syscall" "time" - "github.com/grafana/grafana-plugin-sdk-go/data" jsoniter "github.com/json-iterator/go" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/promlib/converter" "github.com/grafana/grafana/pkg/tsdb/loki/instrumentation" - "github.com/grafana/grafana/pkg/util/converter" ) type LokiAPI struct { @@ -160,7 +162,7 @@ func readLokiError(body io.ReadCloser) error { return makeLokiError(bytes) } -func (api *LokiAPI) DataQuery(ctx context.Context, query lokiQuery, responseOpts ResponseOpts) (data.Frames, error) { +func (api *LokiAPI) DataQuery(ctx context.Context, query lokiQuery, responseOpts ResponseOpts) (*backend.DataResponse, error) { req, err := makeDataRequest(ctx, api.url, query, api.requestStructuredMetadata) if err != nil { return nil, err @@ -181,7 +183,13 @@ func (api *LokiAPI) DataQuery(ctx context.Context, query lokiQuery, responseOpts lp = append(lp, "statusCode", resp.StatusCode) } api.log.Error("Error received from Loki", lp...) - return nil, err + res := backend.DataResponse{ + Error: err, + } + if errors.Is(err, syscall.ECONNREFUSED) { + res.ErrorSource = backend.ErrorSourceDownstream + } + return &res, nil } defer func() { @@ -194,9 +202,13 @@ func (api *LokiAPI) DataQuery(ctx context.Context, query lokiQuery, responseOpts lp = append(lp, queryAttrs...) if resp.StatusCode/100 != 2 { err := readLokiError(resp.Body) - lp = append(lp, "status", "error", "error", err) + res := backend.DataResponse{ + Error: err, + ErrorSource: backend.ErrorSourceFromHTTPStatus(resp.StatusCode), + } + lp = append(lp, "status", "error", "error", err, "statusSource", res.ErrorSource) api.log.Error("Error received from Loki", lp...) - return nil, err + return &res, nil } else { lp = append(lp, "status", "ok") api.log.Info("Response received from loki", lp...) @@ -221,7 +233,7 @@ func (api *LokiAPI) DataQuery(ctx context.Context, query lokiQuery, responseOpts instrumentation.UpdatePluginParsingResponseDurationSeconds(ctx, time.Since(start), "ok") api.log.Info("Response parsed from loki", "duration", time.Since(start), "metricDataplane", responseOpts.metricDataplane, "framesLength", len(res.Frames), "stage", stageParseResponse) - return res.Frames, nil + return &res, nil } func makeRawRequest(ctx context.Context, lokiDsUrl string, resourcePath string) (*http.Request, error) { @@ -321,7 +333,9 @@ func getSupportingQueryHeaderValue(req *http.Request, supportingQueryType Suppor value = "logsample" case SupportingQueryDataSample: value = "datasample" - default: //ignore + case SupportingQueryInfiniteScroll: + value = "infinitescroll" + default: // ignore } return value diff --git a/pkg/tsdb/loki/api_test.go b/pkg/tsdb/loki/api_test.go index cbe964dbbe98f..c510fd78d018e 100644 --- a/pkg/tsdb/loki/api_test.go +++ b/pkg/tsdb/loki/api_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/grafana/grafana/pkg/tsdb/loki/kinds/dataquery" "github.com/stretchr/testify/require" ) @@ -82,6 +83,30 @@ func TestApiLogVolume(t *testing.T) { }) } +func TestInfiniteScroll(t *testing.T) { + response := []byte(` + { + "status": "success", + "data": { + "resultType" : "matrix", + "result": [] + } + } + `) + + t.Run("infinite scrolling queries should set infinite scroll http header", func(t *testing.T) { + called := false + api := makeMockedAPI(200, "application/json", response, func(req *http.Request) { + called = true + require.Equal(t, "Source=infinitescroll", req.Header.Get("X-Query-Tags")) + }, false) + + _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", SupportingQueryType: dataquery.SupportingQueryTypeInfiniteScroll, QueryType: QueryTypeRange}, ResponseOpts{}) + require.NoError(t, err) + require.True(t, called) + }) +} + func TestApiUrlHandling(t *testing.T) { response := []byte(` { diff --git a/pkg/tsdb/loki/framing_test.go b/pkg/tsdb/loki/framing_test.go index 5480fe3abcc2a..fff9f446d889d 100644 --- a/pkg/tsdb/loki/framing_test.go +++ b/pkg/tsdb/loki/framing_test.go @@ -53,6 +53,7 @@ func TestSuccessResponse(t *testing.T) { {name: "parse an empty response", filepath: "empty", query: matrixQuery}, {name: "parse structured metadata", filepath: "streams_structured_metadata", query: streamsQuery}, + {name: "parse structured metadata different labels each log line", filepath: "streams_structured_metadata_2", query: streamsQuery}, } runTest := func(folder string, path string, query lokiQuery, responseOpts ResponseOpts) { @@ -63,13 +64,9 @@ func TestSuccessResponse(t *testing.T) { bytes, err := os.ReadFile(responseFileName) require.NoError(t, err) - frames, err := runQuery(context.Background(), makeMockedAPI(http.StatusOK, "application/json", bytes, nil, false), &query, responseOpts, log.New("test")) + dr, err := runQuery(context.Background(), makeMockedAPI(http.StatusOK, "application/json", bytes, nil, false), &query, responseOpts, log.New("test")) require.NoError(t, err) - dr := &backend.DataResponse{ - Frames: frames, - Error: err, - } experimental.CheckGoldenJSONResponse(t, folder, goldenFileName, dr, false) } @@ -128,11 +125,57 @@ func TestErrorResponse(t *testing.T) { for _, test := range tt { t.Run(test.name, func(t *testing.T) { - frames, err := runQuery(context.Background(), makeMockedAPI(400, test.contentType, test.body, nil, false), &lokiQuery{QueryType: QueryTypeRange, Direction: DirectionBackward}, ResponseOpts{}, log.New("test")) + dr, err := runQuery(context.Background(), makeMockedAPI(400, test.contentType, test.body, nil, false), &lokiQuery{QueryType: QueryTypeRange, Direction: DirectionBackward}, ResponseOpts{}, log.New("test")) + require.NoError(t, err) + require.Len(t, dr.Frames, 0) + require.Equal(t, dr.Error.Error(), test.errorMessage) + require.Equal(t, dr.ErrorSource, backend.ErrorSourceDownstream) + }) + } +} + +func TestErrorsFromResponseCodes(t *testing.T) { + tt := []struct { + name string + statusCode int + errorSource backend.ErrorSource + }{ + { + name: "parse response with status code 400 into correct error", + statusCode: 400, + errorSource: backend.ErrorSourceDownstream, + }, + { + name: "parse response with status code 406 into correct error", + statusCode: 406, + errorSource: backend.ErrorSourcePlugin, + }, + { + name: "parse response with status code 413 into correct error", + statusCode: 413, + errorSource: backend.ErrorSourcePlugin, + }, + { + name: "parse response with status code 500 into correct error", + statusCode: 500, + errorSource: backend.ErrorSourceDownstream, + }, + { + name: "parse response with status code 501 into correct error", + statusCode: 501, + errorSource: backend.ErrorSourcePlugin, + }, + } + + errorString := "parse error at line 1, col 8: something is wrong" + contentType := "application/json; charset=UTF-8" - require.Len(t, frames, 0) - require.Error(t, err) - require.EqualError(t, err, test.errorMessage) + for _, test := range tt { + t.Run(test.name, func(t *testing.T) { + dr, _ := runQuery(context.Background(), makeMockedAPI(test.statusCode, contentType, []byte(errorString), nil, false), &lokiQuery{QueryType: QueryTypeRange, Direction: DirectionBackward}, ResponseOpts{}, log.New("test")) + require.Len(t, dr.Frames, 0) + require.Equal(t, dr.Error.Error(), errorString) + require.Equal(t, dr.ErrorSource, test.errorSource) }) } } diff --git a/pkg/tsdb/loki/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/loki/kinds/dataquery/types_dataquery_gen.go index c79be5cae6fd5..db33add281377 100644 --- a/pkg/tsdb/loki/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/loki/kinds/dataquery/types_dataquery_gen.go @@ -30,9 +30,10 @@ const ( // Defines values for SupportingQueryType. const ( - SupportingQueryTypeDataSample SupportingQueryType = "dataSample" - SupportingQueryTypeLogsSample SupportingQueryType = "logsSample" - SupportingQueryTypeLogsVolume SupportingQueryType = "logsVolume" + SupportingQueryTypeDataSample SupportingQueryType = "dataSample" + SupportingQueryTypeInfiniteScroll SupportingQueryType = "infiniteScroll" + SupportingQueryTypeLogsSample SupportingQueryType = "logsSample" + SupportingQueryTypeLogsVolume SupportingQueryType = "logsVolume" ) // These are the common properties available to all queries in all datasources. diff --git a/pkg/tsdb/loki/loki.go b/pkg/tsdb/loki/loki.go index 6a6e4f5912840..df2302d772750 100644 --- a/pkg/tsdb/loki/loki.go +++ b/pkg/tsdb/loki/loki.go @@ -239,37 +239,39 @@ func executeQuery(ctx context.Context, query *lokiQuery, req *backend.QueryDataR defer span.End() - frames, err := runQuery(ctx, api, query, responseOpts, plog) - queryRes := backend.DataResponse{} + queryRes, err := runQuery(ctx, api, query, responseOpts, plog) + if queryRes == nil { + // we always want to return a backend.DataResponse object, even if we received just an error + queryRes = &backend.DataResponse{} + } + if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) queryRes.Error = err - } else { - queryRes.Frames = frames } - return queryRes + return *queryRes } // we extracted this part of the functionality to make it easy to unit-test it -func runQuery(ctx context.Context, api *LokiAPI, query *lokiQuery, responseOpts ResponseOpts, plog log.Logger) (data.Frames, error) { - frames, err := api.DataQuery(ctx, *query, responseOpts) +func runQuery(ctx context.Context, api *LokiAPI, query *lokiQuery, responseOpts ResponseOpts, plog log.Logger) (*backend.DataResponse, error) { + res, err := api.DataQuery(ctx, *query, responseOpts) if err != nil { plog.Error("Error querying loki", "error", err) - return data.Frames{}, err + return res, err } - for _, frame := range frames { + for _, frame := range res.Frames { err = adjustFrame(frame, query, !responseOpts.metricDataplane, responseOpts.logsDataplane) if err != nil { plog.Error("Error adjusting frame", "error", err) - return data.Frames{}, err + return res, err } } - return frames, nil + return res, nil } func (s *Service) getDSInfo(ctx context.Context, pluginCtx backend.PluginContext) (*datasourceInfo, error) { diff --git a/pkg/tsdb/loki/parse_query.go b/pkg/tsdb/loki/parse_query.go index 6cfdfaee0c99d..60ace2430c840 100644 --- a/pkg/tsdb/loki/parse_query.go +++ b/pkg/tsdb/loki/parse_query.go @@ -8,8 +8,8 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" "github.com/grafana/grafana/pkg/tsdb/loki/kinds/dataquery" ) @@ -32,8 +32,8 @@ const ( ) func interpolateVariables(expr string, interval time.Duration, timeRange time.Duration, queryType dataquery.LokiQueryType, step time.Duration) string { - intervalText := intervalv2.FormatDuration(interval) - stepText := intervalv2.FormatDuration(step) + intervalText := gtime.FormatInterval(interval) + stepText := gtime.FormatInterval(step) intervalMsText := strconv.FormatInt(int64(interval/time.Millisecond), 10) rangeMs := timeRange.Milliseconds() @@ -112,6 +112,8 @@ func parseSupportingQueryType(jsonPointerValue *string) (SupportingQueryType, er return SupportingQueryLogsSample, nil case "dataSample": return SupportingQueryDataSample, nil + case "infiniteScroll": + return SupportingQueryInfiniteScroll, nil default: return SupportingQueryNone, fmt.Errorf("invalid supportingQueryType: %s", jsonValue) } diff --git a/pkg/tsdb/loki/step.go b/pkg/tsdb/loki/step.go index df66ebef466c9..4023abfe1dbb0 100644 --- a/pkg/tsdb/loki/step.go +++ b/pkg/tsdb/loki/step.go @@ -4,7 +4,7 @@ import ( "math" "time" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" ) // round the duration to the nearest millisecond larger-or-equal-to the duration @@ -31,7 +31,7 @@ func calculateStep(interval time.Duration, timeRange time.Duration, resolution i return ceilMs(chosenStep), nil } - step, err := intervalv2.ParseIntervalStringToTimeDuration(*queryStep) + step, err := gtime.ParseIntervalStringToTimeDuration(*queryStep) if err != nil { return step, err } diff --git a/pkg/tsdb/loki/testdata/streams_structured_metadata_2.golden.jsonc b/pkg/tsdb/loki/testdata/streams_structured_metadata_2.golden.jsonc new file mode 100644 index 0000000000000..2f65417a5afd4 --- /dev/null +++ b/pkg/tsdb/loki/testdata/streams_structured_metadata_2.golden.jsonc @@ -0,0 +1,380 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "custom": { +// "frameType": "LabeledTimeValues" +// }, +// "stats": [ +// { +// "displayName": "Summary: bytes processed per second", +// "unit": "Bps", +// "value": 3507022 +// }, +// { +// "displayName": "Summary: lines processed per second", +// "value": 24818 +// }, +// { +// "displayName": "Summary: total bytes processed", +// "unit": "decbytes", +// "value": 7772 +// }, +// { +// "displayName": "Summary: total lines processed", +// "value": 55 +// }, +// { +// "displayName": "Summary: exec time", +// "unit": "s", +// "value": 0.002216125 +// }, +// { +// "displayName": "Store: total chunks ref", +// "value": 2 +// }, +// { +// "displayName": "Store: total chunks downloaded", +// "value": 3 +// }, +// { +// "displayName": "Store: chunks download time", +// "unit": "s", +// "value": 0.000390958 +// }, +// { +// "displayName": "Store: head chunk bytes", +// "unit": "decbytes", +// "value": 4 +// }, +// { +// "displayName": "Store: head chunk lines", +// "value": 5 +// }, +// { +// "displayName": "Store: decompressed bytes", +// "unit": "decbytes", +// "value": 7772 +// }, +// { +// "displayName": "Store: decompressed lines", +// "value": 55 +// }, +// { +// "displayName": "Store: compressed bytes", +// "unit": "decbytes", +// "value": 31432 +// }, +// { +// "displayName": "Store: total duplicates", +// "value": 6 +// }, +// { +// "displayName": "Ingester: total reached", +// "value": 7 +// }, +// { +// "displayName": "Ingester: total chunks matched", +// "value": 8 +// }, +// { +// "displayName": "Ingester: total batches", +// "value": 9 +// }, +// { +// "displayName": "Ingester: total lines sent", +// "value": 10 +// }, +// { +// "displayName": "Ingester: head chunk bytes", +// "unit": "decbytes", +// "value": 11 +// }, +// { +// "displayName": "Ingester: head chunk lines", +// "value": 12 +// }, +// { +// "displayName": "Ingester: decompressed bytes", +// "unit": "decbytes", +// "value": 13 +// }, +// { +// "displayName": "Ingester: decompressed lines", +// "value": 14 +// }, +// { +// "displayName": "Ingester: compressed bytes", +// "unit": "decbytes", +// "value": 15 +// }, +// { +// "displayName": "Ingester: total duplicates", +// "value": 16 +// } +// ], +// "executedQueryString": "Expr: query1" +// } +// Name: +// Dimensions: 6 Fields by 3 Rows +// +------------------------------------------------------+--------------------------------------+------------------+---------------------+------------------------------------------+------------------------------+ +// | Name: labels | Name: Time | Name: Line | Name: tsNs | Name: labelTypes | Name: id | +// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []json.RawMessage | Type: []time.Time | Type: []string | Type: []string | Type: []json.RawMessage | Type: []string | +// +------------------------------------------------------+--------------------------------------+------------------+---------------------+------------------------------------------+------------------------------+ +// | {"code":"\",two","field2":"two","location":"moon🌙"} | 2024-01-10 14:01:36.244577 +0000 UTC | {"field2":"two"} | 1704895296244577000 | {"code":"I","field2":"P","location":"I"} | 1704895296244577000_194597ad | +// | {"code":"\",two","field1":"one","location":"moon🌙"} | 2024-01-10 14:01:07.503906 +0000 UTC | {"field1":"one"} | 1704895267503906000 | {"code":"I","field1":"P","location":"I"} | 1704895267503906000_90781cdf | +// | {"code":"\",two","field1":"one","location":"moon🌙"} | 2024-01-10 14:00:45.190222 +0000 UTC | {"field1":"one"} | 1704895245190222000 | {"code":"I","field1":"P","location":"I"} | 1704895245190222000_90781cdf | +// +------------------------------------------------------+--------------------------------------+------------------+---------------------+------------------------------------------+------------------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "typeVersion": [ + 0, + 0 + ], + "custom": { + "frameType": "LabeledTimeValues" + }, + "stats": [ + { + "displayName": "Summary: bytes processed per second", + "unit": "Bps", + "value": 3507022 + }, + { + "displayName": "Summary: lines processed per second", + "value": 24818 + }, + { + "displayName": "Summary: total bytes processed", + "unit": "decbytes", + "value": 7772 + }, + { + "displayName": "Summary: total lines processed", + "value": 55 + }, + { + "displayName": "Summary: exec time", + "unit": "s", + "value": 0.002216125 + }, + { + "displayName": "Store: total chunks ref", + "value": 2 + }, + { + "displayName": "Store: total chunks downloaded", + "value": 3 + }, + { + "displayName": "Store: chunks download time", + "unit": "s", + "value": 0.000390958 + }, + { + "displayName": "Store: head chunk bytes", + "unit": "decbytes", + "value": 4 + }, + { + "displayName": "Store: head chunk lines", + "value": 5 + }, + { + "displayName": "Store: decompressed bytes", + "unit": "decbytes", + "value": 7772 + }, + { + "displayName": "Store: decompressed lines", + "value": 55 + }, + { + "displayName": "Store: compressed bytes", + "unit": "decbytes", + "value": 31432 + }, + { + "displayName": "Store: total duplicates", + "value": 6 + }, + { + "displayName": "Ingester: total reached", + "value": 7 + }, + { + "displayName": "Ingester: total chunks matched", + "value": 8 + }, + { + "displayName": "Ingester: total batches", + "value": 9 + }, + { + "displayName": "Ingester: total lines sent", + "value": 10 + }, + { + "displayName": "Ingester: head chunk bytes", + "unit": "decbytes", + "value": 11 + }, + { + "displayName": "Ingester: head chunk lines", + "value": 12 + }, + { + "displayName": "Ingester: decompressed bytes", + "unit": "decbytes", + "value": 13 + }, + { + "displayName": "Ingester: decompressed lines", + "value": 14 + }, + { + "displayName": "Ingester: compressed bytes", + "unit": "decbytes", + "value": 15 + }, + { + "displayName": "Ingester: total duplicates", + "value": 16 + } + ], + "executedQueryString": "Expr: query1" + }, + "fields": [ + { + "name": "labels", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage" + } + }, + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "Line", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "tsNs", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "labelTypes", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage" + }, + "config": { + "custom": { + "hidden": true + } + } + }, + { + "name": "id", + "type": "string", + "typeInfo": { + "frame": "string" + } + } + ] + }, + "data": { + "values": [ + [ + { + "code": "\",two", + "field2": "two", + "location": "moon🌙" + }, + { + "code": "\",two", + "field1": "one", + "location": "moon🌙" + }, + { + "code": "\",two", + "field1": "one", + "location": "moon🌙" + } + ], + [ + 1704895296244, + 1704895267503, + 1704895245190 + ], + [ + "{\"field2\":\"two\"}", + "{\"field1\":\"one\"}", + "{\"field1\":\"one\"}" + ], + [ + "1704895296244577000", + "1704895267503906000", + "1704895245190222000" + ], + [ + { + "code": "I", + "field2": "P", + "location": "I" + }, + { + "code": "I", + "field1": "P", + "location": "I" + }, + { + "code": "I", + "field1": "P", + "location": "I" + } + ], + [ + "1704895296244577000_194597ad", + "1704895267503906000_90781cdf", + "1704895245190222000_90781cdf" + ] + ], + "nanos": [ + null, + [ + 577000, + 906000, + 222000 + ], + null, + null, + null, + null + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/loki/testdata/streams_structured_metadata_2.json b/pkg/tsdb/loki/testdata/streams_structured_metadata_2.json new file mode 100644 index 0000000000000..45dc560f354dd --- /dev/null +++ b/pkg/tsdb/loki/testdata/streams_structured_metadata_2.json @@ -0,0 +1,78 @@ +{ + "status": "success", + "data": { + "encodingFlags": [ + "categorize-labels" + ], + "resultType": "streams", + "result": [ + { + "stream": { + "code": "\",two", + "location": "moon🌙" + }, + "values": [ + [ + "1704895296244577000", + "{\"field2\":\"two\"}", + { + "parsed": { + "field2": "two" + } + } + ], + [ + "1704895267503906000", + "{\"field1\":\"one\"}", + { + "parsed": { + "field1": "one" + } + } + ], + [ + "1704895245190222000", + "{\"field1\":\"one\"}", + { + "parsed": { + "field1": "one" + } + } + ] + ] + } + ], + "stats": { + "summary": { + "bytesProcessedPerSecond": 3507022, + "linesProcessedPerSecond": 24818, + "totalBytesProcessed": 7772, + "totalLinesProcessed": 55, + "execTime": 0.002216125 + }, + "store": { + "totalChunksRef": 2, + "totalChunksDownloaded": 3, + "chunksDownloadTime": 0.000390958, + "headChunkBytes": 4, + "headChunkLines": 5, + "decompressedBytes": 7772, + "decompressedLines": 55, + "compressedBytes": 31432, + "totalDuplicates": 6 + }, + "ingester": { + "totalReached": 7, + "totalChunksMatched": 8, + "totalBatches": 9, + "totalLinesSent": 10, + "headChunkBytes": 11, + "headChunkLines": 12, + "decompressedBytes": 13, + "decompressedLines": 14, + "compressedBytes": 15, + "totalDuplicates": 16 + } + } + } +} \ No newline at end of file diff --git a/pkg/tsdb/loki/testdata_dataplane/streams_structured_metadata_2.golden.jsonc b/pkg/tsdb/loki/testdata_dataplane/streams_structured_metadata_2.golden.jsonc new file mode 100644 index 0000000000000..b2ea873dc1f98 --- /dev/null +++ b/pkg/tsdb/loki/testdata_dataplane/streams_structured_metadata_2.golden.jsonc @@ -0,0 +1,363 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "type": "log-lines", +// "typeVersion": [ +// 0, +// 0 +// ], +// "stats": [ +// { +// "displayName": "Summary: bytes processed per second", +// "unit": "Bps", +// "value": 3507022 +// }, +// { +// "displayName": "Summary: lines processed per second", +// "value": 24818 +// }, +// { +// "displayName": "Summary: total bytes processed", +// "unit": "decbytes", +// "value": 7772 +// }, +// { +// "displayName": "Summary: total lines processed", +// "value": 55 +// }, +// { +// "displayName": "Summary: exec time", +// "unit": "s", +// "value": 0.002216125 +// }, +// { +// "displayName": "Store: total chunks ref", +// "value": 2 +// }, +// { +// "displayName": "Store: total chunks downloaded", +// "value": 3 +// }, +// { +// "displayName": "Store: chunks download time", +// "unit": "s", +// "value": 0.000390958 +// }, +// { +// "displayName": "Store: head chunk bytes", +// "unit": "decbytes", +// "value": 4 +// }, +// { +// "displayName": "Store: head chunk lines", +// "value": 5 +// }, +// { +// "displayName": "Store: decompressed bytes", +// "unit": "decbytes", +// "value": 7772 +// }, +// { +// "displayName": "Store: decompressed lines", +// "value": 55 +// }, +// { +// "displayName": "Store: compressed bytes", +// "unit": "decbytes", +// "value": 31432 +// }, +// { +// "displayName": "Store: total duplicates", +// "value": 6 +// }, +// { +// "displayName": "Ingester: total reached", +// "value": 7 +// }, +// { +// "displayName": "Ingester: total chunks matched", +// "value": 8 +// }, +// { +// "displayName": "Ingester: total batches", +// "value": 9 +// }, +// { +// "displayName": "Ingester: total lines sent", +// "value": 10 +// }, +// { +// "displayName": "Ingester: head chunk bytes", +// "unit": "decbytes", +// "value": 11 +// }, +// { +// "displayName": "Ingester: head chunk lines", +// "value": 12 +// }, +// { +// "displayName": "Ingester: decompressed bytes", +// "unit": "decbytes", +// "value": 13 +// }, +// { +// "displayName": "Ingester: decompressed lines", +// "value": 14 +// }, +// { +// "displayName": "Ingester: compressed bytes", +// "unit": "decbytes", +// "value": 15 +// }, +// { +// "displayName": "Ingester: total duplicates", +// "value": 16 +// } +// ], +// "executedQueryString": "Expr: query1" +// } +// Name: +// Dimensions: 5 Fields by 3 Rows +// +------------------------------------------------------+--------------------------------------+------------------+------------------------------+------------------------------------------+ +// | Name: labels | Name: timestamp | Name: body | Name: id | Name: labelTypes | +// | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []json.RawMessage | Type: []time.Time | Type: []string | Type: []string | Type: []json.RawMessage | +// +------------------------------------------------------+--------------------------------------+------------------+------------------------------+------------------------------------------+ +// | {"code":"\",two","field2":"two","location":"moon🌙"} | 2024-01-10 14:01:36.244577 +0000 UTC | {"field2":"two"} | 1704895296244577000_194597ad | {"code":"I","field2":"P","location":"I"} | +// | {"code":"\",two","field1":"one","location":"moon🌙"} | 2024-01-10 14:01:07.503906 +0000 UTC | {"field1":"one"} | 1704895267503906000_90781cdf | {"code":"I","field1":"P","location":"I"} | +// | {"code":"\",two","field1":"one","location":"moon🌙"} | 2024-01-10 14:00:45.190222 +0000 UTC | {"field1":"one"} | 1704895245190222000_90781cdf | {"code":"I","field1":"P","location":"I"} | +// +------------------------------------------------------+--------------------------------------+------------------+------------------------------+------------------------------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "type": "log-lines", + "typeVersion": [ + 0, + 0 + ], + "stats": [ + { + "displayName": "Summary: bytes processed per second", + "unit": "Bps", + "value": 3507022 + }, + { + "displayName": "Summary: lines processed per second", + "value": 24818 + }, + { + "displayName": "Summary: total bytes processed", + "unit": "decbytes", + "value": 7772 + }, + { + "displayName": "Summary: total lines processed", + "value": 55 + }, + { + "displayName": "Summary: exec time", + "unit": "s", + "value": 0.002216125 + }, + { + "displayName": "Store: total chunks ref", + "value": 2 + }, + { + "displayName": "Store: total chunks downloaded", + "value": 3 + }, + { + "displayName": "Store: chunks download time", + "unit": "s", + "value": 0.000390958 + }, + { + "displayName": "Store: head chunk bytes", + "unit": "decbytes", + "value": 4 + }, + { + "displayName": "Store: head chunk lines", + "value": 5 + }, + { + "displayName": "Store: decompressed bytes", + "unit": "decbytes", + "value": 7772 + }, + { + "displayName": "Store: decompressed lines", + "value": 55 + }, + { + "displayName": "Store: compressed bytes", + "unit": "decbytes", + "value": 31432 + }, + { + "displayName": "Store: total duplicates", + "value": 6 + }, + { + "displayName": "Ingester: total reached", + "value": 7 + }, + { + "displayName": "Ingester: total chunks matched", + "value": 8 + }, + { + "displayName": "Ingester: total batches", + "value": 9 + }, + { + "displayName": "Ingester: total lines sent", + "value": 10 + }, + { + "displayName": "Ingester: head chunk bytes", + "unit": "decbytes", + "value": 11 + }, + { + "displayName": "Ingester: head chunk lines", + "value": 12 + }, + { + "displayName": "Ingester: decompressed bytes", + "unit": "decbytes", + "value": 13 + }, + { + "displayName": "Ingester: decompressed lines", + "value": 14 + }, + { + "displayName": "Ingester: compressed bytes", + "unit": "decbytes", + "value": 15 + }, + { + "displayName": "Ingester: total duplicates", + "value": 16 + } + ], + "executedQueryString": "Expr: query1" + }, + "fields": [ + { + "name": "labels", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage" + } + }, + { + "name": "timestamp", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "body", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "id", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "labelTypes", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage" + }, + "config": { + "custom": { + "hidden": true + } + } + } + ] + }, + "data": { + "values": [ + [ + { + "code": "\",two", + "field2": "two", + "location": "moon🌙" + }, + { + "code": "\",two", + "field1": "one", + "location": "moon🌙" + }, + { + "code": "\",two", + "field1": "one", + "location": "moon🌙" + } + ], + [ + 1704895296244, + 1704895267503, + 1704895245190 + ], + [ + "{\"field2\":\"two\"}", + "{\"field1\":\"one\"}", + "{\"field1\":\"one\"}" + ], + [ + "1704895296244577000_194597ad", + "1704895267503906000_90781cdf", + "1704895245190222000_90781cdf" + ], + [ + { + "code": "I", + "field2": "P", + "location": "I" + }, + { + "code": "I", + "field1": "P", + "location": "I" + }, + { + "code": "I", + "field1": "P", + "location": "I" + } + ] + ], + "nanos": [ + null, + [ + 577000, + 906000, + 222000 + ], + null, + null, + null + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/loki/testdata_dataplane/streams_structured_metadata_2.json b/pkg/tsdb/loki/testdata_dataplane/streams_structured_metadata_2.json new file mode 100644 index 0000000000000..45dc560f354dd --- /dev/null +++ b/pkg/tsdb/loki/testdata_dataplane/streams_structured_metadata_2.json @@ -0,0 +1,78 @@ +{ + "status": "success", + "data": { + "encodingFlags": [ + "categorize-labels" + ], + "resultType": "streams", + "result": [ + { + "stream": { + "code": "\",two", + "location": "moon🌙" + }, + "values": [ + [ + "1704895296244577000", + "{\"field2\":\"two\"}", + { + "parsed": { + "field2": "two" + } + } + ], + [ + "1704895267503906000", + "{\"field1\":\"one\"}", + { + "parsed": { + "field1": "one" + } + } + ], + [ + "1704895245190222000", + "{\"field1\":\"one\"}", + { + "parsed": { + "field1": "one" + } + } + ] + ] + } + ], + "stats": { + "summary": { + "bytesProcessedPerSecond": 3507022, + "linesProcessedPerSecond": 24818, + "totalBytesProcessed": 7772, + "totalLinesProcessed": 55, + "execTime": 0.002216125 + }, + "store": { + "totalChunksRef": 2, + "totalChunksDownloaded": 3, + "chunksDownloadTime": 0.000390958, + "headChunkBytes": 4, + "headChunkLines": 5, + "decompressedBytes": 7772, + "decompressedLines": 55, + "compressedBytes": 31432, + "totalDuplicates": 6 + }, + "ingester": { + "totalReached": 7, + "totalChunksMatched": 8, + "totalBatches": 9, + "totalLinesSent": 10, + "headChunkBytes": 11, + "headChunkLines": 12, + "decompressedBytes": 13, + "decompressedLines": 14, + "compressedBytes": 15, + "totalDuplicates": 16 + } + } + } +} \ No newline at end of file diff --git a/pkg/tsdb/loki/testdata_logs_dataplane/streams_structured_metadata_2.golden.jsonc b/pkg/tsdb/loki/testdata_logs_dataplane/streams_structured_metadata_2.golden.jsonc new file mode 100644 index 0000000000000..b2ea873dc1f98 --- /dev/null +++ b/pkg/tsdb/loki/testdata_logs_dataplane/streams_structured_metadata_2.golden.jsonc @@ -0,0 +1,363 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "type": "log-lines", +// "typeVersion": [ +// 0, +// 0 +// ], +// "stats": [ +// { +// "displayName": "Summary: bytes processed per second", +// "unit": "Bps", +// "value": 3507022 +// }, +// { +// "displayName": "Summary: lines processed per second", +// "value": 24818 +// }, +// { +// "displayName": "Summary: total bytes processed", +// "unit": "decbytes", +// "value": 7772 +// }, +// { +// "displayName": "Summary: total lines processed", +// "value": 55 +// }, +// { +// "displayName": "Summary: exec time", +// "unit": "s", +// "value": 0.002216125 +// }, +// { +// "displayName": "Store: total chunks ref", +// "value": 2 +// }, +// { +// "displayName": "Store: total chunks downloaded", +// "value": 3 +// }, +// { +// "displayName": "Store: chunks download time", +// "unit": "s", +// "value": 0.000390958 +// }, +// { +// "displayName": "Store: head chunk bytes", +// "unit": "decbytes", +// "value": 4 +// }, +// { +// "displayName": "Store: head chunk lines", +// "value": 5 +// }, +// { +// "displayName": "Store: decompressed bytes", +// "unit": "decbytes", +// "value": 7772 +// }, +// { +// "displayName": "Store: decompressed lines", +// "value": 55 +// }, +// { +// "displayName": "Store: compressed bytes", +// "unit": "decbytes", +// "value": 31432 +// }, +// { +// "displayName": "Store: total duplicates", +// "value": 6 +// }, +// { +// "displayName": "Ingester: total reached", +// "value": 7 +// }, +// { +// "displayName": "Ingester: total chunks matched", +// "value": 8 +// }, +// { +// "displayName": "Ingester: total batches", +// "value": 9 +// }, +// { +// "displayName": "Ingester: total lines sent", +// "value": 10 +// }, +// { +// "displayName": "Ingester: head chunk bytes", +// "unit": "decbytes", +// "value": 11 +// }, +// { +// "displayName": "Ingester: head chunk lines", +// "value": 12 +// }, +// { +// "displayName": "Ingester: decompressed bytes", +// "unit": "decbytes", +// "value": 13 +// }, +// { +// "displayName": "Ingester: decompressed lines", +// "value": 14 +// }, +// { +// "displayName": "Ingester: compressed bytes", +// "unit": "decbytes", +// "value": 15 +// }, +// { +// "displayName": "Ingester: total duplicates", +// "value": 16 +// } +// ], +// "executedQueryString": "Expr: query1" +// } +// Name: +// Dimensions: 5 Fields by 3 Rows +// +------------------------------------------------------+--------------------------------------+------------------+------------------------------+------------------------------------------+ +// | Name: labels | Name: timestamp | Name: body | Name: id | Name: labelTypes | +// | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []json.RawMessage | Type: []time.Time | Type: []string | Type: []string | Type: []json.RawMessage | +// +------------------------------------------------------+--------------------------------------+------------------+------------------------------+------------------------------------------+ +// | {"code":"\",two","field2":"two","location":"moon🌙"} | 2024-01-10 14:01:36.244577 +0000 UTC | {"field2":"two"} | 1704895296244577000_194597ad | {"code":"I","field2":"P","location":"I"} | +// | {"code":"\",two","field1":"one","location":"moon🌙"} | 2024-01-10 14:01:07.503906 +0000 UTC | {"field1":"one"} | 1704895267503906000_90781cdf | {"code":"I","field1":"P","location":"I"} | +// | {"code":"\",two","field1":"one","location":"moon🌙"} | 2024-01-10 14:00:45.190222 +0000 UTC | {"field1":"one"} | 1704895245190222000_90781cdf | {"code":"I","field1":"P","location":"I"} | +// +------------------------------------------------------+--------------------------------------+------------------+------------------------------+------------------------------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "type": "log-lines", + "typeVersion": [ + 0, + 0 + ], + "stats": [ + { + "displayName": "Summary: bytes processed per second", + "unit": "Bps", + "value": 3507022 + }, + { + "displayName": "Summary: lines processed per second", + "value": 24818 + }, + { + "displayName": "Summary: total bytes processed", + "unit": "decbytes", + "value": 7772 + }, + { + "displayName": "Summary: total lines processed", + "value": 55 + }, + { + "displayName": "Summary: exec time", + "unit": "s", + "value": 0.002216125 + }, + { + "displayName": "Store: total chunks ref", + "value": 2 + }, + { + "displayName": "Store: total chunks downloaded", + "value": 3 + }, + { + "displayName": "Store: chunks download time", + "unit": "s", + "value": 0.000390958 + }, + { + "displayName": "Store: head chunk bytes", + "unit": "decbytes", + "value": 4 + }, + { + "displayName": "Store: head chunk lines", + "value": 5 + }, + { + "displayName": "Store: decompressed bytes", + "unit": "decbytes", + "value": 7772 + }, + { + "displayName": "Store: decompressed lines", + "value": 55 + }, + { + "displayName": "Store: compressed bytes", + "unit": "decbytes", + "value": 31432 + }, + { + "displayName": "Store: total duplicates", + "value": 6 + }, + { + "displayName": "Ingester: total reached", + "value": 7 + }, + { + "displayName": "Ingester: total chunks matched", + "value": 8 + }, + { + "displayName": "Ingester: total batches", + "value": 9 + }, + { + "displayName": "Ingester: total lines sent", + "value": 10 + }, + { + "displayName": "Ingester: head chunk bytes", + "unit": "decbytes", + "value": 11 + }, + { + "displayName": "Ingester: head chunk lines", + "value": 12 + }, + { + "displayName": "Ingester: decompressed bytes", + "unit": "decbytes", + "value": 13 + }, + { + "displayName": "Ingester: decompressed lines", + "value": 14 + }, + { + "displayName": "Ingester: compressed bytes", + "unit": "decbytes", + "value": 15 + }, + { + "displayName": "Ingester: total duplicates", + "value": 16 + } + ], + "executedQueryString": "Expr: query1" + }, + "fields": [ + { + "name": "labels", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage" + } + }, + { + "name": "timestamp", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "body", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "id", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "labelTypes", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage" + }, + "config": { + "custom": { + "hidden": true + } + } + } + ] + }, + "data": { + "values": [ + [ + { + "code": "\",two", + "field2": "two", + "location": "moon🌙" + }, + { + "code": "\",two", + "field1": "one", + "location": "moon🌙" + }, + { + "code": "\",two", + "field1": "one", + "location": "moon🌙" + } + ], + [ + 1704895296244, + 1704895267503, + 1704895245190 + ], + [ + "{\"field2\":\"two\"}", + "{\"field1\":\"one\"}", + "{\"field1\":\"one\"}" + ], + [ + "1704895296244577000_194597ad", + "1704895267503906000_90781cdf", + "1704895245190222000_90781cdf" + ], + [ + { + "code": "I", + "field2": "P", + "location": "I" + }, + { + "code": "I", + "field1": "P", + "location": "I" + }, + { + "code": "I", + "field1": "P", + "location": "I" + } + ] + ], + "nanos": [ + null, + [ + 577000, + 906000, + 222000 + ], + null, + null, + null + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/loki/testdata_logs_dataplane/streams_structured_metadata_2.json b/pkg/tsdb/loki/testdata_logs_dataplane/streams_structured_metadata_2.json new file mode 100644 index 0000000000000..45dc560f354dd --- /dev/null +++ b/pkg/tsdb/loki/testdata_logs_dataplane/streams_structured_metadata_2.json @@ -0,0 +1,78 @@ +{ + "status": "success", + "data": { + "encodingFlags": [ + "categorize-labels" + ], + "resultType": "streams", + "result": [ + { + "stream": { + "code": "\",two", + "location": "moon🌙" + }, + "values": [ + [ + "1704895296244577000", + "{\"field2\":\"two\"}", + { + "parsed": { + "field2": "two" + } + } + ], + [ + "1704895267503906000", + "{\"field1\":\"one\"}", + { + "parsed": { + "field1": "one" + } + } + ], + [ + "1704895245190222000", + "{\"field1\":\"one\"}", + { + "parsed": { + "field1": "one" + } + } + ] + ] + } + ], + "stats": { + "summary": { + "bytesProcessedPerSecond": 3507022, + "linesProcessedPerSecond": 24818, + "totalBytesProcessed": 7772, + "totalLinesProcessed": 55, + "execTime": 0.002216125 + }, + "store": { + "totalChunksRef": 2, + "totalChunksDownloaded": 3, + "chunksDownloadTime": 0.000390958, + "headChunkBytes": 4, + "headChunkLines": 5, + "decompressedBytes": 7772, + "decompressedLines": 55, + "compressedBytes": 31432, + "totalDuplicates": 6 + }, + "ingester": { + "totalReached": 7, + "totalChunksMatched": 8, + "totalBatches": 9, + "totalLinesSent": 10, + "headChunkBytes": 11, + "headChunkLines": 12, + "decompressedBytes": 13, + "decompressedLines": 14, + "compressedBytes": 15, + "totalDuplicates": 16 + } + } + } +} \ No newline at end of file diff --git a/pkg/tsdb/loki/testdata_metric_dataplane/streams_structured_metadata_2.golden.jsonc b/pkg/tsdb/loki/testdata_metric_dataplane/streams_structured_metadata_2.golden.jsonc new file mode 100644 index 0000000000000..2f65417a5afd4 --- /dev/null +++ b/pkg/tsdb/loki/testdata_metric_dataplane/streams_structured_metadata_2.golden.jsonc @@ -0,0 +1,380 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "custom": { +// "frameType": "LabeledTimeValues" +// }, +// "stats": [ +// { +// "displayName": "Summary: bytes processed per second", +// "unit": "Bps", +// "value": 3507022 +// }, +// { +// "displayName": "Summary: lines processed per second", +// "value": 24818 +// }, +// { +// "displayName": "Summary: total bytes processed", +// "unit": "decbytes", +// "value": 7772 +// }, +// { +// "displayName": "Summary: total lines processed", +// "value": 55 +// }, +// { +// "displayName": "Summary: exec time", +// "unit": "s", +// "value": 0.002216125 +// }, +// { +// "displayName": "Store: total chunks ref", +// "value": 2 +// }, +// { +// "displayName": "Store: total chunks downloaded", +// "value": 3 +// }, +// { +// "displayName": "Store: chunks download time", +// "unit": "s", +// "value": 0.000390958 +// }, +// { +// "displayName": "Store: head chunk bytes", +// "unit": "decbytes", +// "value": 4 +// }, +// { +// "displayName": "Store: head chunk lines", +// "value": 5 +// }, +// { +// "displayName": "Store: decompressed bytes", +// "unit": "decbytes", +// "value": 7772 +// }, +// { +// "displayName": "Store: decompressed lines", +// "value": 55 +// }, +// { +// "displayName": "Store: compressed bytes", +// "unit": "decbytes", +// "value": 31432 +// }, +// { +// "displayName": "Store: total duplicates", +// "value": 6 +// }, +// { +// "displayName": "Ingester: total reached", +// "value": 7 +// }, +// { +// "displayName": "Ingester: total chunks matched", +// "value": 8 +// }, +// { +// "displayName": "Ingester: total batches", +// "value": 9 +// }, +// { +// "displayName": "Ingester: total lines sent", +// "value": 10 +// }, +// { +// "displayName": "Ingester: head chunk bytes", +// "unit": "decbytes", +// "value": 11 +// }, +// { +// "displayName": "Ingester: head chunk lines", +// "value": 12 +// }, +// { +// "displayName": "Ingester: decompressed bytes", +// "unit": "decbytes", +// "value": 13 +// }, +// { +// "displayName": "Ingester: decompressed lines", +// "value": 14 +// }, +// { +// "displayName": "Ingester: compressed bytes", +// "unit": "decbytes", +// "value": 15 +// }, +// { +// "displayName": "Ingester: total duplicates", +// "value": 16 +// } +// ], +// "executedQueryString": "Expr: query1" +// } +// Name: +// Dimensions: 6 Fields by 3 Rows +// +------------------------------------------------------+--------------------------------------+------------------+---------------------+------------------------------------------+------------------------------+ +// | Name: labels | Name: Time | Name: Line | Name: tsNs | Name: labelTypes | Name: id | +// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []json.RawMessage | Type: []time.Time | Type: []string | Type: []string | Type: []json.RawMessage | Type: []string | +// +------------------------------------------------------+--------------------------------------+------------------+---------------------+------------------------------------------+------------------------------+ +// | {"code":"\",two","field2":"two","location":"moon🌙"} | 2024-01-10 14:01:36.244577 +0000 UTC | {"field2":"two"} | 1704895296244577000 | {"code":"I","field2":"P","location":"I"} | 1704895296244577000_194597ad | +// | {"code":"\",two","field1":"one","location":"moon🌙"} | 2024-01-10 14:01:07.503906 +0000 UTC | {"field1":"one"} | 1704895267503906000 | {"code":"I","field1":"P","location":"I"} | 1704895267503906000_90781cdf | +// | {"code":"\",two","field1":"one","location":"moon🌙"} | 2024-01-10 14:00:45.190222 +0000 UTC | {"field1":"one"} | 1704895245190222000 | {"code":"I","field1":"P","location":"I"} | 1704895245190222000_90781cdf | +// +------------------------------------------------------+--------------------------------------+------------------+---------------------+------------------------------------------+------------------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "meta": { + "typeVersion": [ + 0, + 0 + ], + "custom": { + "frameType": "LabeledTimeValues" + }, + "stats": [ + { + "displayName": "Summary: bytes processed per second", + "unit": "Bps", + "value": 3507022 + }, + { + "displayName": "Summary: lines processed per second", + "value": 24818 + }, + { + "displayName": "Summary: total bytes processed", + "unit": "decbytes", + "value": 7772 + }, + { + "displayName": "Summary: total lines processed", + "value": 55 + }, + { + "displayName": "Summary: exec time", + "unit": "s", + "value": 0.002216125 + }, + { + "displayName": "Store: total chunks ref", + "value": 2 + }, + { + "displayName": "Store: total chunks downloaded", + "value": 3 + }, + { + "displayName": "Store: chunks download time", + "unit": "s", + "value": 0.000390958 + }, + { + "displayName": "Store: head chunk bytes", + "unit": "decbytes", + "value": 4 + }, + { + "displayName": "Store: head chunk lines", + "value": 5 + }, + { + "displayName": "Store: decompressed bytes", + "unit": "decbytes", + "value": 7772 + }, + { + "displayName": "Store: decompressed lines", + "value": 55 + }, + { + "displayName": "Store: compressed bytes", + "unit": "decbytes", + "value": 31432 + }, + { + "displayName": "Store: total duplicates", + "value": 6 + }, + { + "displayName": "Ingester: total reached", + "value": 7 + }, + { + "displayName": "Ingester: total chunks matched", + "value": 8 + }, + { + "displayName": "Ingester: total batches", + "value": 9 + }, + { + "displayName": "Ingester: total lines sent", + "value": 10 + }, + { + "displayName": "Ingester: head chunk bytes", + "unit": "decbytes", + "value": 11 + }, + { + "displayName": "Ingester: head chunk lines", + "value": 12 + }, + { + "displayName": "Ingester: decompressed bytes", + "unit": "decbytes", + "value": 13 + }, + { + "displayName": "Ingester: decompressed lines", + "value": 14 + }, + { + "displayName": "Ingester: compressed bytes", + "unit": "decbytes", + "value": 15 + }, + { + "displayName": "Ingester: total duplicates", + "value": 16 + } + ], + "executedQueryString": "Expr: query1" + }, + "fields": [ + { + "name": "labels", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage" + } + }, + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "Line", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "tsNs", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "labelTypes", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage" + }, + "config": { + "custom": { + "hidden": true + } + } + }, + { + "name": "id", + "type": "string", + "typeInfo": { + "frame": "string" + } + } + ] + }, + "data": { + "values": [ + [ + { + "code": "\",two", + "field2": "two", + "location": "moon🌙" + }, + { + "code": "\",two", + "field1": "one", + "location": "moon🌙" + }, + { + "code": "\",two", + "field1": "one", + "location": "moon🌙" + } + ], + [ + 1704895296244, + 1704895267503, + 1704895245190 + ], + [ + "{\"field2\":\"two\"}", + "{\"field1\":\"one\"}", + "{\"field1\":\"one\"}" + ], + [ + "1704895296244577000", + "1704895267503906000", + "1704895245190222000" + ], + [ + { + "code": "I", + "field2": "P", + "location": "I" + }, + { + "code": "I", + "field1": "P", + "location": "I" + }, + { + "code": "I", + "field1": "P", + "location": "I" + } + ], + [ + "1704895296244577000_194597ad", + "1704895267503906000_90781cdf", + "1704895245190222000_90781cdf" + ] + ], + "nanos": [ + null, + [ + 577000, + 906000, + 222000 + ], + null, + null, + null, + null + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/loki/testdata_metric_dataplane/streams_structured_metadata_2.json b/pkg/tsdb/loki/testdata_metric_dataplane/streams_structured_metadata_2.json new file mode 100644 index 0000000000000..45dc560f354dd --- /dev/null +++ b/pkg/tsdb/loki/testdata_metric_dataplane/streams_structured_metadata_2.json @@ -0,0 +1,78 @@ +{ + "status": "success", + "data": { + "encodingFlags": [ + "categorize-labels" + ], + "resultType": "streams", + "result": [ + { + "stream": { + "code": "\",two", + "location": "moon🌙" + }, + "values": [ + [ + "1704895296244577000", + "{\"field2\":\"two\"}", + { + "parsed": { + "field2": "two" + } + } + ], + [ + "1704895267503906000", + "{\"field1\":\"one\"}", + { + "parsed": { + "field1": "one" + } + } + ], + [ + "1704895245190222000", + "{\"field1\":\"one\"}", + { + "parsed": { + "field1": "one" + } + } + ] + ] + } + ], + "stats": { + "summary": { + "bytesProcessedPerSecond": 3507022, + "linesProcessedPerSecond": 24818, + "totalBytesProcessed": 7772, + "totalLinesProcessed": 55, + "execTime": 0.002216125 + }, + "store": { + "totalChunksRef": 2, + "totalChunksDownloaded": 3, + "chunksDownloadTime": 0.000390958, + "headChunkBytes": 4, + "headChunkLines": 5, + "decompressedBytes": 7772, + "decompressedLines": 55, + "compressedBytes": 31432, + "totalDuplicates": 6 + }, + "ingester": { + "totalReached": 7, + "totalChunksMatched": 8, + "totalBatches": 9, + "totalLinesSent": 10, + "headChunkBytes": 11, + "headChunkLines": 12, + "decompressedBytes": 13, + "decompressedLines": 14, + "compressedBytes": 15, + "totalDuplicates": 16 + } + } + } +} \ No newline at end of file diff --git a/pkg/tsdb/loki/types.go b/pkg/tsdb/loki/types.go index 06f8b1836b577..5ac5ee514c348 100644 --- a/pkg/tsdb/loki/types.go +++ b/pkg/tsdb/loki/types.go @@ -16,10 +16,11 @@ const ( ) const ( - SupportingQueryLogsVolume = dataquery.SupportingQueryTypeLogsVolume - SupportingQueryLogsSample = dataquery.SupportingQueryTypeLogsSample - SupportingQueryDataSample = dataquery.SupportingQueryTypeDataSample - SupportingQueryNone SupportingQueryType = "none" + SupportingQueryLogsVolume = dataquery.SupportingQueryTypeLogsVolume + SupportingQueryLogsSample = dataquery.SupportingQueryTypeLogsSample + SupportingQueryDataSample = dataquery.SupportingQueryTypeDataSample + SupportingQueryInfiniteScroll = dataquery.SupportingQueryTypeInfiniteScroll + SupportingQueryNone SupportingQueryType = "none" ) const ( diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index c93f44d2a864b..068a5edccb6a7 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -2,6 +2,7 @@ package mssql import ( "context" + "database/sql" "encoding/json" "fmt" "net/url" @@ -9,12 +10,12 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/grafana/grafana-azure-sdk-go/azcredentials" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" - sdkproxy "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" mssql "github.com/microsoft/go-mssqldb" @@ -24,7 +25,6 @@ import ( "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/mssql/utils" "github.com/grafana/grafana/pkg/tsdb/sqleng" - "github.com/grafana/grafana/pkg/tsdb/sqleng/proxyutil" "github.com/grafana/grafana/pkg/util" ) @@ -65,7 +65,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) } func newInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.InstanceFactoryFunc { - return func(_ context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { jsonData := sqleng.JsonData{ MaxOpenConns: cfg.SqlDatasourceMaxOpenConnsDefault, MaxIdleConns: cfg.SqlDatasourceMaxIdleConnsDefault, @@ -111,22 +111,28 @@ func newInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc driverName = "azuresql" } + proxyClient, err := settings.ProxyClient(ctx) + if err != nil { + return nil, err + } + // register a new proxy driver if the secure socks proxy is enabled - proxyOpts := proxyutil.GetSQLProxyOptions(cfg.SecureSocksDSProxy, dsInfo) - if sdkproxy.New(proxyOpts).SecureSocksProxyEnabled() { + if proxyClient.SecureSocksProxyEnabled() { + dialer, err := proxyClient.NewSecureSocksProxyContextDialer() + if err != nil { + return nil, err + } URL, err := ParseURL(dsInfo.URL, logger) if err != nil { return nil, err } - driverName, err = createMSSQLProxyDriver(cnnstr, URL.Hostname(), proxyOpts) + driverName, err = createMSSQLProxyDriver(cnnstr, URL.Hostname(), dialer) if err != nil { return nil, err } } config := sqleng.DataPluginConfiguration{ - DriverName: driverName, - ConnectionString: cnnstr, DSInfo: dsInfo, MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"}, RowLimit: cfg.DataProxyRowLimit, @@ -136,7 +142,16 @@ func newInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc userError: cfg.UserFacingDefaultError, } - return sqleng.NewQueryDataHandler(cfg, config, &queryResultTransformer, newMssqlMacroEngine(), logger) + db, err := sql.Open(driverName, cnnstr) + if err != nil { + return nil, err + } + + db.SetMaxOpenConns(config.DSInfo.JsonData.MaxOpenConns) + db.SetMaxIdleConns(config.DSInfo.JsonData.MaxIdleConns) + db.SetConnMaxLifetime(time.Duration(config.DSInfo.JsonData.ConnMaxLifetime) * time.Second) + + return sqleng.NewQueryDataHandler(cfg.UserFacingDefaultError, db, config, &queryResultTransformer, newMssqlMacroEngine(), logger) } } @@ -273,7 +288,7 @@ func (t *mssqlQueryResultTransformer) TransformQueryError(logger log.Logger, err // ref https://github.com/denisenkom/go-mssqldb/blob/045585d74f9069afe2e115b6235eb043c8047043/tds.go#L904 if strings.HasPrefix(strings.ToLower(err.Error()), "unable to open tcp connection with host") { logger.Error("Query error", "error", err) - return sqleng.ErrConnectionFailed.Errorf("failed to connect to server - %s", t.userError) + return fmt.Errorf("failed to connect to server - %s", t.userError) } return err diff --git a/pkg/tsdb/mssql/mssql_test.go b/pkg/tsdb/mssql/mssql_test.go index 02130b030385b..9c54e213d81d1 100644 --- a/pkg/tsdb/mssql/mssql_test.go +++ b/pkg/tsdb/mssql/mssql_test.go @@ -5,7 +5,7 @@ import ( "database/sql" "fmt" "math/rand" - "strings" + "os" "testing" "time" @@ -15,8 +15,6 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/sqleng" ) @@ -39,21 +37,9 @@ func TestMSSQL(t *testing.T) { t.Skip() } - x := initMSSQLTestDB(t) - origDB := sqleng.NewDB - t.Cleanup(func() { - sqleng.NewDB = origDB - }) - - sqleng.NewDB = func(d, c string) (*sql.DB, error) { - return x, nil - } - queryResultTransformer := mssqlQueryResultTransformer{} dsInfo := sqleng.DataSourceInfo{} config := sqleng.DataPluginConfiguration{ - DriverName: "mssql", - ConnectionString: "", DSInfo: dsInfo, MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"}, RowLimit: 1000000, @@ -61,10 +47,10 @@ func TestMSSQL(t *testing.T) { logger := backend.NewLoggerWith("logger", "mssql.test") - endpoint, err := sqleng.NewQueryDataHandler(setting.NewCfg(), config, &queryResultTransformer, newMssqlMacroEngine(), logger) - require.NoError(t, err) + db := initMSSQLTestDB(t, config.DSInfo.JsonData) - db := x + endpoint, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger) + require.NoError(t, err) fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) @@ -331,7 +317,8 @@ func TestMSSQL(t *testing.T) { JSON: []byte(`{ "rawSql": "SELECT $__timeGroup(time, $__interval) AS time, avg(value) as value FROM metric GROUP BY $__timeGroup(time, $__interval) ORDER BY 1", "format": "time_series"}`), - RefID: "A", + RefID: "A", + Interval: time.Second * 60, TimeRange: backend.TimeRange{ From: fromStart, To: fromStart.Add(30 * time.Minute), @@ -811,13 +798,11 @@ func TestMSSQL(t *testing.T) { queryResultTransformer := mssqlQueryResultTransformer{} dsInfo := sqleng.DataSourceInfo{} config := sqleng.DataPluginConfiguration{ - DriverName: "mssql", - ConnectionString: "", DSInfo: dsInfo, MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"}, RowLimit: 1000000, } - endpoint, err := sqleng.NewQueryDataHandler(setting.NewCfg(), config, &queryResultTransformer, newMssqlMacroEngine(), logger) + endpoint, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger) require.NoError(t, err) query := &backend.QueryDataRequest{ Queries: []backend.DataQuery{ @@ -1216,14 +1201,12 @@ func TestMSSQL(t *testing.T) { queryResultTransformer := mssqlQueryResultTransformer{} dsInfo := sqleng.DataSourceInfo{} config := sqleng.DataPluginConfiguration{ - DriverName: "mssql", - ConnectionString: "", DSInfo: dsInfo, MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"}, RowLimit: 1, } - handler, err := sqleng.NewQueryDataHandler(setting.NewCfg(), config, &queryResultTransformer, newMssqlMacroEngine(), logger) + handler, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger) require.NoError(t, err) t.Run("When doing a table query that returns 2 rows should limit the result to 1 row", func(t *testing.T) { @@ -1328,23 +1311,23 @@ func TestMSSQL(t *testing.T) { func TestTransformQueryError(t *testing.T) { transformer := &mssqlQueryResultTransformer{} - randomErr := fmt.Errorf("random error") - - tests := []struct { - err error - expectedErr error - }{ - {err: fmt.Errorf("Unable to open tcp connection with host 'localhost:5000': dial tcp: connection refused"), expectedErr: sqleng.ErrConnectionFailed}, - {err: fmt.Errorf("unable to open tcp connection with host 'localhost:5000': dial tcp: connection refused"), expectedErr: sqleng.ErrConnectionFailed}, - {err: randomErr, expectedErr: randomErr}, - } - logger := backend.NewLoggerWith("logger", "mssql.test") - for _, tc := range tests { - resultErr := transformer.TransformQueryError(logger, tc.err) - assert.ErrorIs(t, resultErr, tc.expectedErr) - } + t.Run("Should not return a connection error", func(t *testing.T) { + err := fmt.Errorf("Unable to open tcp connection with host 'localhost:5000': dial tcp: connection refused") + resultErr := transformer.TransformQueryError(logger, err) + errorText := resultErr.Error() + assert.NotEqual(t, err, resultErr) + assert.NotContains(t, errorText, "Unable to open tcp connection with host") + assert.Contains(t, errorText, "failed to connect to server") + }) + + t.Run("Should return a non-connection error unmodified", func(t *testing.T) { + err := fmt.Errorf("normal error") + resultErr := transformer.TransformQueryError(logger, err) + assert.Equal(t, err, resultErr) + assert.ErrorIs(t, err, resultErr) + }) } func TestGenerateConnectionString(t *testing.T) { @@ -1487,15 +1470,26 @@ func TestGenerateConnectionString(t *testing.T) { } } -func initMSSQLTestDB(t *testing.T) *sql.DB { +func initMSSQLTestDB(t *testing.T, jsonData sqleng.JsonData) *sql.DB { t.Helper() - testDB := sqlutil.MSSQLTestDB() - x, err := sql.Open(testDB.DriverName, strings.Replace(testDB.ConnStr, "localhost", - serverIP, 1)) + host := os.Getenv("MSSQL_HOST") + if host == "" { + host = serverIP + } + port := os.Getenv("MSSQL_PORT") + if port == "" { + port = "1433" + } + + db, err := sql.Open("mssql", fmt.Sprintf("server=%s;port=%s;database=grafanatest;user id=grafana;password=Password!", host, port)) require.NoError(t, err) - return x + db.SetMaxOpenConns(jsonData.MaxOpenConns) + db.SetMaxIdleConns(jsonData.MaxIdleConns) + db.SetConnMaxLifetime(time.Duration(jsonData.ConnMaxLifetime) * time.Second) + + return db } func genTimeRangeByInterval(from time.Time, duration time.Duration, interval time.Duration) []time.Time { diff --git a/pkg/tsdb/mssql/proxy.go b/pkg/tsdb/mssql/proxy.go index 84b20000baf65..2518af15d442f 100644 --- a/pkg/tsdb/mssql/proxy.go +++ b/pkg/tsdb/mssql/proxy.go @@ -10,14 +10,13 @@ import ( "net" "slices" - sdkproxy "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" mssql "github.com/microsoft/go-mssqldb" "golang.org/x/net/proxy" ) // createMSSQLProxyDriver creates and registers a new sql driver that uses a mssql connector and updates the dialer to // route connections through the secure socks proxy -func createMSSQLProxyDriver(cnnstr string, hostName string, opts *sdkproxy.Options) (string, error) { +func createMSSQLProxyDriver(cnnstr string, hostName string, dialer proxy.Dialer) (string, error) { // create a unique driver per connection string hash := fmt.Sprintf("%x", md5.Sum([]byte(cnnstr))) driverName := "mssql-proxy-" + hash @@ -29,7 +28,7 @@ func createMSSQLProxyDriver(cnnstr string, hostName string, opts *sdkproxy.Optio return "", err } - driver, err := newMSSQLProxyDriver(connector, hostName, opts) + driver, err := newMSSQLProxyDriver(connector, hostName, dialer) if err != nil { return "", err } @@ -62,12 +61,7 @@ var _ driver.DriverContext = (*mssqlProxyDriver)(nil) // newMSSQLProxyDriver updates the dialer for a mssql connector with a dialer that proxys connections through the secure socks proxy // and returns a new mssql driver to register -func newMSSQLProxyDriver(connector *mssql.Connector, hostName string, opts *sdkproxy.Options) (*mssqlProxyDriver, error) { - dialer, err := sdkproxy.New(opts).NewSecureSocksProxyContextDialer() - if err != nil { - return nil, err - } - +func newMSSQLProxyDriver(connector *mssql.Connector, hostName string, dialer proxy.Dialer) (*mssqlProxyDriver, error) { contextDialer, ok := dialer.(proxy.ContextDialer) if !ok { return nil, errors.New("unable to cast socks proxy dialer to context proxy dialer") diff --git a/pkg/tsdb/mssql/proxy_test.go b/pkg/tsdb/mssql/proxy_test.go index bf30e21f4a109..7a735c00ec0bf 100644 --- a/pkg/tsdb/mssql/proxy_test.go +++ b/pkg/tsdb/mssql/proxy_test.go @@ -3,38 +3,46 @@ package mssql import ( "context" "fmt" + "net" "testing" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/sqleng" - "github.com/grafana/grafana/pkg/tsdb/sqleng/proxyutil" mssql "github.com/microsoft/go-mssqldb" "github.com/stretchr/testify/require" + "golang.org/x/net/proxy" ) +type testDialer struct { +} + +func (d *testDialer) Dial(network, addr string) (c net.Conn, err error) { + return nil, fmt.Errorf("test-dialer: Dial is not functional") +} + +func (d *testDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + return nil, fmt.Errorf("test-dialer: DialContext is not functional") +} + +var _ proxy.Dialer = (&testDialer{}) +var _ proxy.ContextDialer = (&testDialer{}) + +func newTestDialer() proxy.Dialer { + d := testDialer{} + return &d +} + func TestMSSQLProxyDriver(t *testing.T) { - settings := proxyutil.SetupTestSecureSocksProxySettings(t) - proxySettings := setting.SecureSocksDSProxySettings{ - Enabled: true, - ClientCert: settings.ClientCert, - ClientKey: settings.ClientKey, - RootCA: settings.RootCA, - ProxyAddress: settings.ProxyAddress, - ServerName: settings.ServerName, - } - opts := proxyutil.GetSQLProxyOptions(proxySettings, sqleng.DataSourceInfo{UID: "1", JsonData: sqleng.JsonData{SecureDSProxy: true}}) cnnstr := "server=127.0.0.1;port=1433;user id=sa;password=yourStrong(!)Password;database=db" - driverName, err := createMSSQLProxyDriver(cnnstr, "127.0.0.1", opts) + driverName, err := createMSSQLProxyDriver(cnnstr, "127.0.0.1", newTestDialer()) require.NoError(t, err) t.Run("Driver should not be registered more than once", func(t *testing.T) { - testDriver, err := createMSSQLProxyDriver(cnnstr, "127.0.0.1", opts) + testDriver, err := createMSSQLProxyDriver(cnnstr, "127.0.0.1", newTestDialer()) require.NoError(t, err) require.Equal(t, driverName, testDriver) }) t.Run("A new driver should be created for a new connection string", func(t *testing.T) { - testDriver, err := createMSSQLProxyDriver("server=localhost;user id=sa;password=yourStrong(!)Password;database=db2", "localhost", opts) + testDriver, err := createMSSQLProxyDriver("server=localhost;user id=sa;password=yourStrong(!)Password;database=db2", "localhost", newTestDialer()) require.NoError(t, err) require.NotEqual(t, driverName, testDriver) }) @@ -42,23 +50,23 @@ func TestMSSQLProxyDriver(t *testing.T) { t.Run("Connector should use dialer context that routes through the socks proxy to db", func(t *testing.T) { connector, err := mssql.NewConnector(cnnstr) require.NoError(t, err) - driver, err := newMSSQLProxyDriver(connector, "127.0.0.1", opts) + driver, err := newMSSQLProxyDriver(connector, "127.0.0.1", newTestDialer()) require.NoError(t, err) conn, err := driver.OpenConnector(cnnstr) require.NoError(t, err) _, err = conn.Connect(context.Background()) - require.Contains(t, err.Error(), fmt.Sprintf("socks connect tcp %s->127.0.0.1:1433", settings.ProxyAddress)) + require.Contains(t, err.Error(), "test-dialer: DialContext is not functional") }) t.Run("Open should use the connector that routes through the socks proxy to db", func(t *testing.T) { connector, err := mssql.NewConnector(cnnstr) require.NoError(t, err) - driver, err := newMSSQLProxyDriver(connector, "127.0.0.1", opts) + driver, err := newMSSQLProxyDriver(connector, "127.0.0.1", newTestDialer()) require.NoError(t, err) _, err = driver.Open(cnnstr) - require.Contains(t, err.Error(), fmt.Sprintf("socks connect tcp %s->127.0.0.1:1433", settings.ProxyAddress)) + require.Contains(t, err.Error(), "test-dialer: DialContext is not functional") }) } diff --git a/pkg/tsdb/mysql/macros.go b/pkg/tsdb/mysql/macros.go index 4cff452e413ec..05e93c0aee59f 100644 --- a/pkg/tsdb/mysql/macros.go +++ b/pkg/tsdb/mysql/macros.go @@ -8,7 +8,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/sqleng" ) @@ -23,11 +22,11 @@ type mySQLMacroEngine struct { userError string } -func newMysqlMacroEngine(logger log.Logger, cfg *setting.Cfg) sqleng.SQLMacroEngine { +func newMysqlMacroEngine(logger log.Logger, userFacingDefaultError string) sqleng.SQLMacroEngine { return &mySQLMacroEngine{ SQLMacroEngineBase: sqleng.NewSQLMacroEngineBase(), logger: logger, - userError: cfg.UserFacingDefaultError, + userError: userFacingDefaultError, } } diff --git a/pkg/tsdb/mysql/macros_test.go b/pkg/tsdb/mysql/macros_test.go index d8babbfbc38e2..595e06c7dc4fe 100644 --- a/pkg/tsdb/mysql/macros_test.go +++ b/pkg/tsdb/mysql/macros_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" ) @@ -194,7 +193,7 @@ func TestMacroEngine(t *testing.T) { } func TestMacroEngineConcurrency(t *testing.T) { - engine := newMysqlMacroEngine(backend.NewLoggerWith("logger", "test"), setting.NewCfg()) + engine := newMysqlMacroEngine(backend.NewLoggerWith("logger", "test"), "error") query1 := backend.DataQuery{ JSON: []byte{}, } diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index 3d0d51305d228..2e908afc93d93 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -2,6 +2,7 @@ package mysql import ( "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -17,15 +18,11 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" - sdkproxy "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/infra/httpclient" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/sqleng" - "github.com/grafana/grafana/pkg/tsdb/sqleng/proxyutil" ) const ( @@ -35,7 +32,6 @@ const ( ) type Service struct { - Cfg *setting.Cfg im instancemgmt.InstanceManager logger log.Logger } @@ -44,25 +40,30 @@ func characterEscape(s string, escapeChar string) string { return strings.ReplaceAll(s, escapeChar, url.QueryEscape(escapeChar)) } -func ProvideService(cfg *setting.Cfg, httpClientProvider httpclient.Provider) *Service { +func ProvideService() *Service { logger := backend.NewLoggerWith("logger", "tsdb.mysql") return &Service{ - im: datasource.NewInstanceManager(newInstanceSettings(cfg, logger)), + im: datasource.NewInstanceManager(newInstanceSettings(logger)), logger: logger, } } -func newInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.InstanceFactoryFunc { +func newInstanceSettings(logger log.Logger) datasource.InstanceFactoryFunc { return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + cfg := backend.GrafanaConfigFromContext(ctx) + sqlCfg, err := cfg.SQL() + if err != nil { + return nil, err + } jsonData := sqleng.JsonData{ - MaxOpenConns: cfg.SqlDatasourceMaxOpenConnsDefault, - MaxIdleConns: cfg.SqlDatasourceMaxIdleConnsDefault, - ConnMaxLifetime: cfg.SqlDatasourceMaxConnLifetimeDefault, + MaxOpenConns: sqlCfg.DefaultMaxOpenConns, + MaxIdleConns: sqlCfg.DefaultMaxIdleConns, + ConnMaxLifetime: sqlCfg.DefaultMaxConnLifetimeSeconds, SecureDSProxy: false, AllowCleartextPasswords: false, } - err := json.Unmarshal(settings.JSONData, &jsonData) + err = json.Unmarshal(settings.JSONData, &jsonData) if err != nil { return nil, fmt.Errorf("error reading settings: %w", err) } @@ -88,12 +89,20 @@ func newInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc protocol = "unix" } + proxyClient, err := settings.ProxyClient(ctx) + if err != nil { + return nil, err + } + // register the secure socks proxy dialer context, if enabled - proxyOpts := proxyutil.GetSQLProxyOptions(cfg.SecureSocksDSProxy, dsInfo) - if sdkproxy.New(proxyOpts).SecureSocksProxyEnabled() { + if proxyClient.SecureSocksProxyEnabled() { + dialer, err := proxyClient.NewSecureSocksProxyContextDialer() + if err != nil { + return nil, err + } // UID is only unique per org, the only way to ensure uniqueness is to do it by connection information uniqueIdentifier := dsInfo.User + dsInfo.DecryptedSecureJSONData["password"] + dsInfo.URL + dsInfo.Database - protocol, err = registerProxyDialerContext(protocol, uniqueIdentifier, proxyOpts) + protocol, err = registerProxyDialerContext(protocol, uniqueIdentifier, dialer) if err != nil { return nil, err } @@ -133,24 +142,32 @@ func newInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc cnnstr += fmt.Sprintf("&time_zone='%s'", url.QueryEscape(dsInfo.JsonData.Timezone)) } - if cfg.Env == setting.Dev { - logger.Debug("GetEngine", "connection", cnnstr) - } - config := sqleng.DataPluginConfiguration{ - DriverName: "mysql", - ConnectionString: cnnstr, DSInfo: dsInfo, TimeColumnNames: []string{"time", "time_sec"}, MetricColumnTypes: []string{"CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT"}, - RowLimit: cfg.DataProxyRowLimit, + RowLimit: sqlCfg.RowLimit, + } + + userFacingDefaultError, err := cfg.UserFacingDefaultError() + if err != nil { + return nil, err } rowTransformer := mysqlQueryResultTransformer{ - userError: cfg.UserFacingDefaultError, + userError: userFacingDefaultError, } - return sqleng.NewQueryDataHandler(cfg, config, &rowTransformer, newMysqlMacroEngine(logger, cfg), logger) + db, err := sql.Open("mysql", cnnstr) + if err != nil { + return nil, err + } + + db.SetMaxOpenConns(config.DSInfo.JsonData.MaxOpenConns) + db.SetMaxIdleConns(config.DSInfo.JsonData.MaxIdleConns) + db.SetConnMaxLifetime(time.Duration(config.DSInfo.JsonData.ConnMaxLifetime) * time.Second) + + return sqleng.NewQueryDataHandler(userFacingDefaultError, db, config, &rowTransformer, newMysqlMacroEngine(logger, userFacingDefaultError), logger) } } diff --git a/pkg/tsdb/mysql/mysql_test.go b/pkg/tsdb/mysql/mysql_test.go index de99c512e926c..c01ec8bae6cf0 100644 --- a/pkg/tsdb/mysql/mysql_test.go +++ b/pkg/tsdb/mysql/mysql_test.go @@ -13,7 +13,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/sqleng" ) @@ -37,21 +36,13 @@ func TestIntegrationMySQL(t *testing.T) { t.Skip() } - x := InitMySQLTestDB(t) - - origDB := sqleng.NewDB origInterpolate := sqleng.Interpolate t.Cleanup(func() { - sqleng.NewDB = origDB sqleng.Interpolate = origInterpolate }) - sqleng.NewDB = func(d, c string) (*sql.DB, error) { - return x, nil - } - - sqleng.Interpolate = func(query backend.DataQuery, timeRange backend.TimeRange, timeInterval string, sql string) (string, error) { - return sql, nil + sqleng.Interpolate = func(query backend.DataQuery, timeRange backend.TimeRange, timeInterval string, sql string) string { + return sql } dsInfo := sqleng.DataSourceInfo{ @@ -63,8 +54,6 @@ func TestIntegrationMySQL(t *testing.T) { } config := sqleng.DataPluginConfiguration{ - DriverName: "mysql", - ConnectionString: "", DSInfo: dsInfo, TimeColumnNames: []string{"time", "time_sec"}, MetricColumnTypes: []string{"CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT"}, @@ -74,11 +63,13 @@ func TestIntegrationMySQL(t *testing.T) { rowTransformer := mysqlQueryResultTransformer{} logger := backend.NewLoggerWith("logger", "mysql.test") - exe, err := sqleng.NewQueryDataHandler(setting.NewCfg(), config, &rowTransformer, newMysqlMacroEngine(logger, setting.NewCfg()), logger) + + db := InitMySQLTestDB(t, config.DSInfo.JsonData) + + exe, err := sqleng.NewQueryDataHandler("", db, config, &rowTransformer, newMysqlMacroEngine(logger, ""), logger) require.NoError(t, err) - db := x fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC) t.Run("Given a table with different native data types", func(t *testing.T) { @@ -338,7 +329,8 @@ func TestIntegrationMySQL(t *testing.T) { "rawSql": "SELECT $__timeGroup(time, $__interval) AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", "format": "time_series" }`), - RefID: "A", + RefID: "A", + Interval: time.Second * 60, TimeRange: backend.TimeRange{ From: fromStart, To: fromStart.Add(30 * time.Minute), @@ -1178,8 +1170,6 @@ func TestIntegrationMySQL(t *testing.T) { t.Run("When row limit set to 1", func(t *testing.T) { dsInfo := sqleng.DataSourceInfo{} config := sqleng.DataPluginConfiguration{ - DriverName: "mysql", - ConnectionString: "", DSInfo: dsInfo, TimeColumnNames: []string{"time", "time_sec"}, MetricColumnTypes: []string{"CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT"}, @@ -1188,7 +1178,7 @@ func TestIntegrationMySQL(t *testing.T) { queryResultTransformer := mysqlQueryResultTransformer{} - handler, err := sqleng.NewQueryDataHandler(setting.NewCfg(), config, &queryResultTransformer, newMysqlMacroEngine(logger, setting.NewCfg()), logger) + handler, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMysqlMacroEngine(logger, ""), logger) require.NoError(t, err) t.Run("When doing a table query that returns 2 rows should limit the result to 1 row", func(t *testing.T) { @@ -1290,14 +1280,18 @@ func TestIntegrationMySQL(t *testing.T) { }) } -func InitMySQLTestDB(t *testing.T) *sql.DB { +func InitMySQLTestDB(t *testing.T, jsonData sqleng.JsonData) *sql.DB { connStr := mySQLTestDBConnStr() - x, err := sql.Open("mysql", connStr) + db, err := sql.Open("mysql", connStr) if err != nil { t.Fatalf("Failed to init mysql db %v", err) } - return x + db.SetMaxOpenConns(jsonData.MaxOpenConns) + db.SetMaxIdleConns(jsonData.MaxIdleConns) + db.SetConnMaxLifetime(time.Duration(jsonData.ConnMaxLifetime) * time.Second) + + return db } func genTimeRangeByInterval(from time.Time, duration time.Duration, interval time.Duration) []time.Time { diff --git a/pkg/tsdb/mysql/proxy.go b/pkg/tsdb/mysql/proxy.go index c2103734c6435..c2b288ba507b1 100644 --- a/pkg/tsdb/mysql/proxy.go +++ b/pkg/tsdb/mysql/proxy.go @@ -7,15 +7,14 @@ import ( "net" "github.com/go-sql-driver/mysql" - sdkproxy "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" "golang.org/x/net/proxy" ) // registerProxyDialerContext registers a new dialer context to be used by mysql when the proxy network is // specified in the connection string -func registerProxyDialerContext(protocol, cnnstr string, opts *sdkproxy.Options) (string, error) { - // the dialer contains the true network used behind the scenes - dialer, err := getProxyDialerContext(protocol, opts) +func registerProxyDialerContext(protocol, cnnstr string, dialer proxy.Dialer) (string, error) { + // the mysqlDialer contains the true network used behind the scenes + mysqlDialer, err := getProxyDialerContext(protocol, dialer) if err != nil { return "", err } @@ -24,7 +23,7 @@ func registerProxyDialerContext(protocol, cnnstr string, opts *sdkproxy.Options) // have a unique network per connection string hash := fmt.Sprintf("%x", md5.Sum([]byte(cnnstr))) network := "proxy-" + hash - mysql.RegisterDialContext(network, dialer.DialContext) + mysql.RegisterDialContext(network, mysqlDialer.DialContext) return network, nil } @@ -36,14 +35,10 @@ type mySQLContextDialer struct { } // getProxyDialerContext returns a context dialer that will send the request through to the secure socks proxy -func getProxyDialerContext(actualNetwork string, opts *sdkproxy.Options) (*mySQLContextDialer, error) { - dialer, err := sdkproxy.New(opts).NewSecureSocksProxyContextDialer() - if err != nil { - return nil, err - } +func getProxyDialerContext(actualNetwork string, dialer proxy.Dialer) (*mySQLContextDialer, error) { contextDialer, ok := dialer.(proxy.ContextDialer) if !ok { - return nil, err + return nil, fmt.Errorf("mysql proxy creation failed") } return &mySQLContextDialer{dialer: contextDialer, network: actualNetwork}, nil } diff --git a/pkg/tsdb/mysql/proxy_test.go b/pkg/tsdb/mysql/proxy_test.go index 3abffe93ebbe8..bc8b28799b75d 100644 --- a/pkg/tsdb/mysql/proxy_test.go +++ b/pkg/tsdb/mysql/proxy_test.go @@ -3,29 +3,32 @@ package mysql import ( "context" "fmt" + "net" "testing" "github.com/go-sql-driver/mysql" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/sqleng" - "github.com/grafana/grafana/pkg/tsdb/sqleng/proxyutil" "github.com/stretchr/testify/require" + "golang.org/x/net/proxy" ) +type testDialer struct { +} + +func (d *testDialer) Dial(network, addr string) (c net.Conn, err error) { + return nil, fmt.Errorf("test-dialer: Dial is not functional") +} + +func (d *testDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + return nil, fmt.Errorf("test-dialer: DialContext is not functional") +} + +var _ proxy.Dialer = (&testDialer{}) +var _ proxy.ContextDialer = (&testDialer{}) + func TestMySQLProxyDialer(t *testing.T) { - settings := proxyutil.SetupTestSecureSocksProxySettings(t) - proxySettings := setting.SecureSocksDSProxySettings{ - Enabled: true, - ClientCert: settings.ClientCert, - ClientKey: settings.ClientKey, - RootCA: settings.RootCA, - ProxyAddress: settings.ProxyAddress, - ServerName: settings.ServerName, - } protocol := "tcp" - opts := proxyutil.GetSQLProxyOptions(proxySettings, sqleng.DataSourceInfo{UID: "1", JsonData: sqleng.JsonData{SecureDSProxy: true}}) dbURL := "localhost:5432" - network, err := registerProxyDialerContext(protocol, dbURL, opts) + network, err := registerProxyDialerContext(protocol, dbURL, &testDialer{}) require.NoError(t, err) driver := mysql.MySQLDriver{} cnnstr := fmt.Sprintf("test:test@%s(%s)/db", @@ -38,7 +41,7 @@ func TestMySQLProxyDialer(t *testing.T) { }) t.Run("Multiple networks can be created", func(t *testing.T) { - network, err := registerProxyDialerContext(protocol, dbURL, opts) + network, err := registerProxyDialerContext(protocol, dbURL, &testDialer{}) require.NoError(t, err) cnnstr2 := fmt.Sprintf("test:test@%s(%s)/db", network, @@ -56,6 +59,6 @@ func TestMySQLProxyDialer(t *testing.T) { require.NoError(t, err) _, err = conn.Connect(context.Background()) require.Error(t, err) - require.Contains(t, err.Error(), fmt.Sprintf("socks connect %s %s->%s", protocol, settings.ProxyAddress, dbURL)) + require.Contains(t, err.Error(), "test-dialer: DialContext is not functional") }) } diff --git a/pkg/tsdb/prometheus/azureauth/azure.go b/pkg/tsdb/prometheus/azureauth/azure.go index 5b0f9a98a641b..b34ebc418e681 100644 --- a/pkg/tsdb/prometheus/azureauth/azure.go +++ b/pkg/tsdb/prometheus/azureauth/azure.go @@ -8,19 +8,11 @@ import ( "github.com/grafana/grafana-azure-sdk-go/azcredentials" "github.com/grafana/grafana-azure-sdk-go/azhttpclient" "github.com/grafana/grafana-azure-sdk-go/azsettings" - "github.com/grafana/grafana-azure-sdk-go/util/maputil" "github.com/grafana/grafana-plugin-sdk-go/backend" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana-plugin-sdk-go/data/utils/maputil" - "github.com/grafana/grafana/pkg/tsdb/prometheus/utils" -) - -var ( - azurePrometheusScopes = map[string][]string{ - azsettings.AzurePublic: {"https://prometheus.monitor.azure.com/.default"}, - azsettings.AzureChina: {"https://prometheus.monitor.azure.cn/.default"}, - azsettings.AzureUSGovernment: {"https://prometheus.monitor.azure.us/.default"}, - } + "github.com/grafana/grafana/pkg/promlib/utils" ) func ConfigureAzureAuthentication(settings backend.DataSourceInstanceSettings, azureSettings *azsettings.AzureSettings, clientOpts *sdkhttpclient.Options) error { @@ -82,11 +74,28 @@ func getPrometheusScopes(settings *azsettings.AzureSettings, credentials azcrede return nil, err } + cloudSettings, err := settings.GetCloud(azureCloud) + if err != nil { + return nil, err + } + // Get scopes for the given cloud - if scopes, ok := azurePrometheusScopes[azureCloud]; !ok { - err := fmt.Errorf("the Azure cloud '%s' not supported by Prometheus datasource", azureCloud) + resourceIdS, ok := cloudSettings.Properties["prometheusResourceId"] + if !ok { + err := fmt.Errorf("the Azure cloud '%s' doesn't have configuration for Prometheus", azureCloud) + return nil, err + } + return audienceToScopes(resourceIdS) +} + +func audienceToScopes(audience string) ([]string, error) { + resourceId, err := url.Parse(audience) + if err != nil || resourceId.Scheme == "" || resourceId.Host == "" { + err = fmt.Errorf("endpoint resource ID (audience) '%s' invalid", audience) return nil, err - } else { - return scopes, nil } + + resourceId.Path = path.Join(resourceId.Path, ".default") + scopes := []string{resourceId.String()} + return scopes, nil } diff --git a/pkg/tsdb/prometheus/azureauth/azure_test.go b/pkg/tsdb/prometheus/azureauth/azure_test.go index 7a35300d183e5..3b046eb0f666f 100644 --- a/pkg/tsdb/prometheus/azureauth/azure_test.go +++ b/pkg/tsdb/prometheus/azureauth/azure_test.go @@ -3,19 +3,16 @@ package azureauth import ( "testing" + "github.com/grafana/grafana-azure-sdk-go/azcredentials" "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/setting" ) func TestConfigureAzureAuthentication(t *testing.T) { - cfg := &setting.Cfg{ - Azure: &azsettings.AzureSettings{}, - } + azureSettings := &azsettings.AzureSettings{} t.Run("should set Azure middleware when JsonData contains valid credentials", func(t *testing.T) { settings := backend.DataSourceInstanceSettings{ @@ -29,7 +26,7 @@ func TestConfigureAzureAuthentication(t *testing.T) { var opts = &sdkhttpclient.Options{CustomOptions: map[string]any{}} - err := ConfigureAzureAuthentication(settings, cfg.Azure, opts) + err := ConfigureAzureAuthentication(settings, azureSettings, opts) require.NoError(t, err) require.NotNil(t, opts.Middlewares) @@ -43,7 +40,7 @@ func TestConfigureAzureAuthentication(t *testing.T) { var opts = &sdkhttpclient.Options{CustomOptions: map[string]any{}} - err := ConfigureAzureAuthentication(settings, cfg.Azure, opts) + err := ConfigureAzureAuthentication(settings, azureSettings, opts) require.NoError(t, err) assert.NotContains(t, opts.CustomOptions, "_azureCredentials") @@ -58,7 +55,7 @@ func TestConfigureAzureAuthentication(t *testing.T) { } var opts = &sdkhttpclient.Options{CustomOptions: map[string]any{}} - err := ConfigureAzureAuthentication(settings, cfg.Azure, opts) + err := ConfigureAzureAuthentication(settings, azureSettings, opts) assert.Error(t, err) }) @@ -74,7 +71,7 @@ func TestConfigureAzureAuthentication(t *testing.T) { } var opts = &sdkhttpclient.Options{CustomOptions: map[string]any{}} - err := ConfigureAzureAuthentication(settings, cfg.Azure, opts) + err := ConfigureAzureAuthentication(settings, azureSettings, opts) require.NoError(t, err) require.NotNil(t, opts.Middlewares) @@ -90,7 +87,7 @@ func TestConfigureAzureAuthentication(t *testing.T) { } var opts = &sdkhttpclient.Options{CustomOptions: map[string]any{}} - err := ConfigureAzureAuthentication(settings, cfg.Azure, opts) + err := ConfigureAzureAuthentication(settings, azureSettings, opts) require.NoError(t, err) if opts.Middlewares != nil { @@ -111,7 +108,33 @@ func TestConfigureAzureAuthentication(t *testing.T) { var opts = &sdkhttpclient.Options{CustomOptions: map[string]any{}} - err := ConfigureAzureAuthentication(settings, cfg.Azure, opts) + err := ConfigureAzureAuthentication(settings, azureSettings, opts) assert.Error(t, err) }) } + +func TestGetPrometheusScopes(t *testing.T) { + azureSettings := &azsettings.AzureSettings{ + Cloud: azsettings.AzureUSGovernment, + } + + t.Run("should return scopes for cloud from settings with MSI credentials", func(t *testing.T) { + credentials := &azcredentials.AzureManagedIdentityCredentials{} + scopes, err := getPrometheusScopes(azureSettings, credentials) + require.NoError(t, err) + + assert.NotNil(t, scopes) + assert.Len(t, scopes, 1) + assert.Equal(t, "https://prometheus.monitor.azure.us/.default", scopes[0]) + }) + + t.Run("should return scopes for cloud from client secret credentials", func(t *testing.T) { + credentials := &azcredentials.AzureClientSecretCredentials{AzureCloud: azsettings.AzureChina} + scopes, err := getPrometheusScopes(azureSettings, credentials) + require.NoError(t, err) + + assert.NotNil(t, scopes) + assert.Len(t, scopes, 1) + assert.Equal(t, "https://prometheus.monitor.azure.cn/.default", scopes[0]) + }) +} diff --git a/pkg/tsdb/prometheus/client/transport_test.go b/pkg/tsdb/prometheus/client/transport_test.go deleted file mode 100644 index f609ec0af7da5..0000000000000 --- a/pkg/tsdb/prometheus/client/transport_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package client - -import ( - "context" - "testing" - - "github.com/grafana/grafana-azure-sdk-go/azsettings" - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/setting" -) - -func TestCreateTransportOptions(t *testing.T) { - t.Run("creates correct options object", func(t *testing.T) { - settings := backend.DataSourceInstanceSettings{ - BasicAuthEnabled: false, - BasicAuthUser: "", - JSONData: []byte(`{"httpHeaderName1": "foo"}`), - DecryptedSecureJSONData: map[string]string{ - "httpHeaderValue1": "bar", - }, - } - opts, err := CreateTransportOptions(context.Background(), settings, &setting.Cfg{}, backend.NewLoggerWith("logger", "test")) - require.NoError(t, err) - require.Equal(t, map[string]string{"foo": "bar"}, opts.Headers) - require.Equal(t, 2, len(opts.Middlewares)) - }) - - t.Run("add azure credentials if configured", func(t *testing.T) { - settings := backend.DataSourceInstanceSettings{ - BasicAuthEnabled: false, - BasicAuthUser: "", - JSONData: []byte(`{ - "azureCredentials": { - "authType": "msi" - } - }`), - DecryptedSecureJSONData: map[string]string{}, - } - opts, err := CreateTransportOptions(context.Background(), settings, &setting.Cfg{AzureAuthEnabled: true, Azure: &azsettings.AzureSettings{}}, backend.NewLoggerWith("logger", "test")) - require.NoError(t, err) - require.Equal(t, 3, len(opts.Middlewares)) - }) -} diff --git a/pkg/tsdb/prometheus/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/prometheus/kinds/dataquery/types_dataquery_gen.go deleted file mode 100644 index 2112a08c93cfb..0000000000000 --- a/pkg/tsdb/prometheus/kinds/dataquery/types_dataquery_gen.go +++ /dev/null @@ -1,103 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// public/app/plugins/gen.go -// Using jennies: -// PluginGoTypesJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package dataquery - -// Defines values for PromQueryFormat. -const ( - PromQueryFormatHeatmap PromQueryFormat = "heatmap" - PromQueryFormatTable PromQueryFormat = "table" - PromQueryFormatTimeSeries PromQueryFormat = "time_series" -) - -// Defines values for QueryEditorMode. -const ( - QueryEditorModeBuilder QueryEditorMode = "builder" - QueryEditorModeCode QueryEditorMode = "code" -) - -// These are the common properties available to all queries in all datasources. -// Specific implementations will *extend* this interface, adding the required -// properties for the given context. -type DataQuery struct { - // For mixed data sources the selected datasource is on the query level. - // For non mixed scenarios this is undefined. - // TODO find a better way to do this ^ that's friendly to schema - // TODO this shouldn't be unknown but DataSourceRef | null - Datasource *any `json:"datasource,omitempty"` - - // Hide true if query is disabled (ie should not be returned to the dashboard) - // Note this does not always imply that the query should not be executed since - // the results from a hidden query may be used as the input to other queries (SSE etc) - Hide *bool `json:"hide,omitempty"` - - // Specify the query flavor - // TODO make this required and give it a default - QueryType *string `json:"queryType,omitempty"` - - // A unique identifier for the query within the list of targets. - // In server side expressions, the refId is used as a variable name to identify results. - // By default, the UI will assign A->Z; however setting meaningful names may be useful. - RefId string `json:"refId"` -} - -// PromQueryFormat defines model for PromQueryFormat. -type PromQueryFormat string - -// PrometheusDataQuery defines model for PrometheusDataQuery. -type PrometheusDataQuery struct { - // DataQuery These are the common properties available to all queries in all datasources. - // Specific implementations will *extend* this interface, adding the required - // properties for the given context. - DataQuery - - // For mixed data sources the selected datasource is on the query level. - // For non mixed scenarios this is undefined. - // TODO find a better way to do this ^ that's friendly to schema - // TODO this shouldn't be unknown but DataSourceRef | null - Datasource *any `json:"datasource,omitempty"` - EditorMode *QueryEditorMode `json:"editorMode,omitempty"` - - // Execute an additional query to identify interesting raw samples relevant for the given expr - Exemplar *bool `json:"exemplar,omitempty"` - - // The actual expression/query that will be evaluated by Prometheus - Expr string `json:"expr"` - Format *PromQueryFormat `json:"format,omitempty"` - - // Hide true if query is disabled (ie should not be returned to the dashboard) - // Note this does not always imply that the query should not be executed since - // the results from a hidden query may be used as the input to other queries (SSE etc) - Hide *bool `json:"hide,omitempty"` - - // Returns only the latest value that Prometheus has scraped for the requested time series - Instant *bool `json:"instant,omitempty"` - - // @deprecated Used to specify how many times to divide max data points by. We use max data points under query options - // See https://github.com/grafana/grafana/issues/48081 - IntervalFactor *float32 `json:"intervalFactor,omitempty"` - - // Series name override or template. Ex. {{hostname}} will be replaced with label value for hostname - LegendFormat *string `json:"legendFormat,omitempty"` - - // Specify the query flavor - // TODO make this required and give it a default - QueryType *string `json:"queryType,omitempty"` - - // Returns a Range vector, comprised of a set of time series containing a range of data points over time for each time series - Range *bool `json:"range,omitempty"` - - // A unique identifier for the query within the list of targets. - // In server side expressions, the refId is used as a variable name to identify results. - // By default, the UI will assign A->Z; however setting meaningful names may be useful. - RefId string `json:"refId"` -} - -// QueryEditorMode defines model for QueryEditorMode. -type QueryEditorMode string diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go index 9c1a0db794ab6..1f3ab688cbde6 100644 --- a/pkg/tsdb/prometheus/prometheus.go +++ b/pkg/tsdb/prometheus/prometheus.go @@ -2,148 +2,67 @@ package prometheus import ( "context" - "errors" "fmt" - "strings" - "time" + "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" - "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" - "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/patrickmn/go-cache" - apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/prometheus/client" - "github.com/grafana/grafana/pkg/tsdb/prometheus/instrumentation" - "github.com/grafana/grafana/pkg/tsdb/prometheus/querydata" - "github.com/grafana/grafana/pkg/tsdb/prometheus/resource" + "github.com/grafana/grafana/pkg/promlib" + "github.com/grafana/grafana/pkg/tsdb/prometheus/azureauth" ) type Service struct { - im instancemgmt.InstanceManager - features featuremgmt.FeatureToggles - logger log.Logger + lib *promlib.Service } -type instance struct { - queryData *querydata.QueryData - resource *resource.Resource - versionCache *cache.Cache -} - -func ProvideService(httpClientProvider *httpclient.Provider, cfg *setting.Cfg, features featuremgmt.FeatureToggles) *Service { +func ProvideService(httpClientProvider *sdkhttpclient.Provider) *Service { plog := backend.NewLoggerWith("logger", "tsdb.prometheus") plog.Debug("Initializing") return &Service{ - im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider, cfg, features, plog)), - features: features, - logger: plog, + lib: promlib.NewService(httpClientProvider, plog, extendClientOpts), } } -func newInstanceSettings(httpClientProvider *httpclient.Provider, cfg *setting.Cfg, features featuremgmt.FeatureToggles, log log.Logger) datasource.InstanceFactoryFunc { - return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - // Creates a http roundTripper. - opts, err := client.CreateTransportOptions(ctx, settings, cfg, log) - if err != nil { - return nil, fmt.Errorf("error creating transport options: %v", err) - } - httpClient, err := httpClientProvider.New(*opts) - if err != nil { - return nil, fmt.Errorf("error creating http client: %v", err) - } +func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return s.lib.QueryData(ctx, req) +} - // New version using custom client and better response parsing - qd, err := querydata.New(httpClient, features, settings, log) - if err != nil { - return nil, err - } +func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + return s.lib.CallResource(ctx, req, sender) +} - // Resource call management using new custom client same as querydata - r, err := resource.New(httpClient, settings, log) - if err != nil { - return nil, err - } +func (s *Service) GetBuildInfo(ctx context.Context, req promlib.BuildInfoRequest) (*promlib.BuildInfoResponse, error) { + return s.lib.GetBuildInfo(ctx, req) +} - return instance{ - queryData: qd, - resource: r, - versionCache: cache.New(time.Minute*1, time.Minute*5), - }, nil - } +func (s *Service) GetHeuristics(ctx context.Context, req promlib.HeuristicsRequest) (*promlib.Heuristics, error) { + return s.lib.GetHeuristics(ctx, req) } -func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - if len(req.Queries) == 0 { - err := fmt.Errorf("query contains no queries") - instrumentation.UpdateQueryDataMetrics(err, nil) - return &backend.QueryDataResponse{}, err - } +func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, + error) { + return s.lib.CheckHealth(ctx, req) +} - i, err := s.getInstance(ctx, req.PluginContext) - if err != nil { - instrumentation.UpdateQueryDataMetrics(err, nil) - return nil, err +func extendClientOpts(ctx context.Context, settings backend.DataSourceInstanceSettings, clientOpts *sdkhttpclient.Options) error { + // Set SigV4 service namespace + if clientOpts.SigV4 != nil { + clientOpts.SigV4.Service = "aps" } - qd, err := i.queryData.Execute(ctx, req) - instrumentation.UpdateQueryDataMetrics(err, qd) - - return qd, err -} - -func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { - i, err := s.getInstance(ctx, req.PluginContext) + azureSettings, err := azsettings.ReadSettings(ctx) if err != nil { - return err + return fmt.Errorf("failed to read Azure settings from Grafana: %v", err) } - if strings.EqualFold(req.Path, "version-detect") { - versionObj, found := i.versionCache.Get("version") - if found { - return sender.Send(versionObj.(*backend.CallResourceResponse)) - } - - vResp, err := i.resource.DetectVersion(ctx, req) + // Set Azure authentication + if azureSettings.AzureAuthEnabled { + err = azureauth.ConfigureAzureAuthentication(settings, azureSettings, clientOpts) if err != nil { - return err + return fmt.Errorf("error configuring Azure auth: %v", err) } - i.versionCache.Set("version", vResp, cache.DefaultExpiration) - return sender.Send(vResp) - } - - resp, err := i.resource.Execute(ctx, req) - if err != nil { - return err } - return sender.Send(resp) -} - -func (s *Service) getInstance(ctx context.Context, pluginCtx backend.PluginContext) (*instance, error) { - i, err := s.im.Get(ctx, pluginCtx) - if err != nil { - return nil, err - } - in := i.(instance) - return &in, nil -} - -// IsAPIError returns whether err is or wraps a Prometheus error. -func IsAPIError(err error) bool { - // Check if the right error type is in err's chain. - var e *apiv1.Error - return errors.As(err, &e) -} - -func ConvertAPIError(err error) error { - var e *apiv1.Error - if errors.As(err, &e) { - return fmt.Errorf("%s: %s", e.Msg, e.Detail) - } - return err + return nil } diff --git a/pkg/tsdb/prometheus/prometheus_test.go b/pkg/tsdb/prometheus/prometheus_test.go index 735ed25e38c32..458fa31a5ceb7 100644 --- a/pkg/tsdb/prometheus/prometheus_test.go +++ b/pkg/tsdb/prometheus/prometheus_test.go @@ -2,118 +2,53 @@ package prometheus import ( "context" - "io" - "net/http" "testing" - "time" + "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" - "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/stretchr/testify/require" ) -type fakeSender struct{} - -func (sender *fakeSender) Send(resp *backend.CallResourceResponse) error { - return nil -} - -type fakeRoundtripper struct { - Req *http.Request -} - -func (rt *fakeRoundtripper) RoundTrip(req *http.Request) (*http.Response, error) { - rt.Req = req - return &http.Response{ - Status: "200", - StatusCode: 200, - Header: nil, - Body: nil, - ContentLength: 0, - }, nil -} - -type fakeHTTPClientProvider struct { - httpclient.Provider - Roundtripper *fakeRoundtripper -} - -func (provider *fakeHTTPClientProvider) New(opts ...httpclient.Options) (*http.Client, error) { - client := &http.Client{} - provider.Roundtripper = &fakeRoundtripper{} - client.Transport = provider.Roundtripper - return client, nil -} - -func (provider *fakeHTTPClientProvider) GetTransport(opts ...httpclient.Options) (http.RoundTripper, error) { - return &fakeRoundtripper{}, nil -} - -func getMockPromTestSDKProvider(f *fakeHTTPClientProvider) *httpclient.Provider { - anotherFN := func(o httpclient.Options, next http.RoundTripper) http.RoundTripper { - _, _ = f.New() - return f.Roundtripper - } - fn := httpclient.MiddlewareFunc(anotherFN) - mid := httpclient.NamedMiddlewareFunc("mock", fn) - return httpclient.NewProvider(httpclient.ProviderOptions{Middlewares: []httpclient.Middleware{mid}}) -} - -func TestService(t *testing.T) { - t.Run("Service", func(t *testing.T) { - t.Run("CallResource", func(t *testing.T) { - t.Run("creates correct request", func(t *testing.T) { - f := &fakeHTTPClientProvider{} - httpProvider := getMockPromTestSDKProvider(f) - service := &Service{ - im: datasource.NewInstanceManager(newInstanceSettings(httpProvider, &setting.Cfg{}, &featuremgmt.FeatureManager{}, backend.NewLoggerWith("logger", "test"))), - } - - req := &backend.CallResourceRequest{ - PluginContext: backend.PluginContext{ - OrgID: 0, - PluginID: "prometheus", - User: nil, - AppInstanceSettings: nil, - DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ - ID: 0, - UID: "", - Type: "prometheus", - Name: "test-prom", - URL: "http://localhost:9090", - User: "", - Database: "", - BasicAuthEnabled: true, - BasicAuthUser: "admin", - Updated: time.Time{}, - JSONData: []byte("{}"), - }, - }, - Path: "/api/v1/series", - Method: http.MethodPost, - URL: "/api/v1/series", - Body: []byte("match%5B%5D: ALERTS\nstart: 1655271408\nend: 1655293008"), +func TestExtendClientOpts(t *testing.T) { + t.Run("add azure credentials if configured", func(t *testing.T) { + cfg := backend.NewGrafanaCfg(map[string]string{ + azsettings.AzureCloud: azsettings.AzurePublic, + azsettings.AzureAuthEnabled: "true", + }) + settings := backend.DataSourceInstanceSettings{ + BasicAuthEnabled: false, + BasicAuthUser: "", + JSONData: []byte(`{ + "azureCredentials": { + "authType": "msi" } + }`), + DecryptedSecureJSONData: map[string]string{}, + } + ctx := backend.WithGrafanaConfig(context.Background(), cfg) + opts := &sdkhttpclient.Options{} + err := extendClientOpts(ctx, settings, opts) + require.NoError(t, err) + require.Equal(t, 1, len(opts.Middlewares)) + }) - sender := &fakeSender{} - err := service.CallResource(context.Background(), req, sender) - require.NoError(t, err) - require.Equal( - t, - http.Header{ - "Content-Type": {"application/x-www-form-urlencoded"}, - "Idempotency-Key": []string(nil), - }, - f.Roundtripper.Req.Header) - require.Equal(t, http.MethodPost, f.Roundtripper.Req.Method) - body, err := io.ReadAll(f.Roundtripper.Req.Body) - require.NoError(t, err) - require.Equal(t, []byte("match%5B%5D: ALERTS\nstart: 1655271408\nend: 1655293008"), body) - require.Equal(t, "http://localhost:9090/api/v1/series", f.Roundtripper.Req.URL.String()) - }) - }) + t.Run("add sigV4 auth if opts has SigV4 configured", func(t *testing.T) { + settings := backend.DataSourceInstanceSettings{ + BasicAuthEnabled: false, + BasicAuthUser: "", + JSONData: []byte(""), + DecryptedSecureJSONData: map[string]string{}, + } + opts := &sdkhttpclient.Options{ + SigV4: &sdkhttpclient.SigV4Config{ + AuthType: "test", + AccessKey: "accesskey", + SecretKey: "secretkey", + }, + } + err := extendClientOpts(context.Background(), settings, opts) + require.NoError(t, err) + require.Equal(t, "aps", opts.SigV4.Service) }) } diff --git a/pkg/tsdb/sqleng/proxyutil/proxy_test_util.go b/pkg/tsdb/sqleng/proxyutil/proxy_test_util.go deleted file mode 100644 index 8b9783e1b37f1..0000000000000 --- a/pkg/tsdb/sqleng/proxyutil/proxy_test_util.go +++ /dev/null @@ -1,111 +0,0 @@ -package proxyutil - -import ( - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "math/big" - "os" - "path/filepath" - "testing" - "time" - - sdkproxy "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" - "github.com/stretchr/testify/require" -) - -func SetupTestSecureSocksProxySettings(t *testing.T) *sdkproxy.ClientCfg { - t.Helper() - proxyAddress := "localhost:3000" - serverName := "localhost" - tempDir := t.TempDir() - - // generate test rootCA - ca := &x509.Certificate{ - SerialNumber: big.NewInt(2019), - Subject: pkix.Name{ - Organization: []string{"Grafana Labs"}, - CommonName: "Grafana", - }, - NotBefore: time.Now(), - NotAfter: time.Now().AddDate(10, 0, 0), - IsCA: true, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, - BasicConstraintsValid: true, - } - caPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) - require.NoError(t, err) - caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey) - require.NoError(t, err) - rootCACert := filepath.Join(tempDir, "ca.cert") - // nolint:gosec - // The gosec G304 warning can be ignored because all values come from the test - caCertFile, err := os.Create(rootCACert) - require.NoError(t, err) - err = pem.Encode(caCertFile, &pem.Block{ - Type: "CERTIFICATE", - Bytes: caBytes, - }) - require.NoError(t, err) - - err = caCertFile.Close() - require.NoError(t, err) - - // generate test client cert & key - cert := &x509.Certificate{ - SerialNumber: big.NewInt(2019), - Subject: pkix.Name{ - Organization: []string{"Grafana Labs"}, - CommonName: "Grafana", - }, - NotBefore: time.Now(), - NotAfter: time.Now().AddDate(10, 0, 0), - SubjectKeyId: []byte{1, 2, 3, 4, 6}, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - KeyUsage: x509.KeyUsageDigitalSignature, - } - certPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) - require.NoError(t, err) - certBytes, err := x509.CreateCertificate(rand.Reader, cert, ca, &certPrivKey.PublicKey, caPrivKey) - require.NoError(t, err) - clientCert := filepath.Join(tempDir, "client.cert") - // nolint:gosec - // The gosec G304 warning can be ignored because all values come from the test - certFile, err := os.Create(clientCert) - require.NoError(t, err) - err = pem.Encode(certFile, &pem.Block{ - Type: "CERTIFICATE", - Bytes: certBytes, - }) - require.NoError(t, err) - - err = certFile.Close() - require.NoError(t, err) - - clientKey := filepath.Join(tempDir, "client.key") - // nolint:gosec - // The gosec G304 warning can be ignored because all values come from the test - keyFile, err := os.Create(clientKey) - require.NoError(t, err) - err = pem.Encode(keyFile, &pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(certPrivKey), - }) - require.NoError(t, err) - - err = keyFile.Close() - require.NoError(t, err) - - settings := &sdkproxy.ClientCfg{ - ClientCert: clientCert, - ClientKey: clientKey, - RootCA: rootCACert, - ServerName: serverName, - ProxyAddress: proxyAddress, - } - - return settings -} diff --git a/pkg/tsdb/sqleng/proxyutil/proxy_util.go b/pkg/tsdb/sqleng/proxyutil/proxy_util.go deleted file mode 100644 index ce82abb955280..0000000000000 --- a/pkg/tsdb/sqleng/proxyutil/proxy_util.go +++ /dev/null @@ -1,27 +0,0 @@ -package proxyutil - -import ( - sdkproxy "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/sqleng" -) - -func GetSQLProxyOptions(cfg setting.SecureSocksDSProxySettings, dsInfo sqleng.DataSourceInfo) *sdkproxy.Options { - opts := &sdkproxy.Options{ - Enabled: dsInfo.JsonData.SecureDSProxy && cfg.Enabled, - Auth: &sdkproxy.AuthOptions{ - Username: dsInfo.UID, - }, - ClientCfg: &sdkproxy.ClientCfg{ - ClientCert: cfg.ClientCert, - ClientKey: cfg.ClientKey, - ServerName: cfg.ServerName, - RootCA: cfg.RootCA, - ProxyAddress: cfg.ProxyAddress, - }, - } - if dsInfo.JsonData.SecureDSProxyUsername != "" { - opts.Auth.Username = dsInfo.JsonData.SecureDSProxyUsername - } - return opts -} diff --git a/pkg/tsdb/sqleng/sql_engine.go b/pkg/tsdb/sqleng/sql_engine.go index 59d99e8a7d6ca..543ceec0375c0 100644 --- a/pkg/tsdb/sqleng/sql_engine.go +++ b/pkg/tsdb/sqleng/sql_engine.go @@ -13,22 +13,18 @@ import ( "sync" "time" + "github.com/go-stack/stack" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - corelog "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" - "github.com/grafana/grafana/pkg/util/errutil" ) // MetaKeyExecutedQueryString is the key where the executed query should get stored const MetaKeyExecutedQueryString = "executedQueryString" -var ErrConnectionFailed = errutil.Internal("sqleng.connectionError") - // SQLMacroEngine interpolates macros into sql. It takes in the Query to have access to query context and // timeRange to be able to generate queries that use from and to. type SQLMacroEngine interface { @@ -42,15 +38,6 @@ type SqlQueryResultTransformer interface { GetConverterList() []sqlutil.StringConverter } -var sqlIntervalCalculator = intervalv2.NewCalculator() - -// NewDB is a sql.DB factory, that can be stubbed by tests. -// -//nolint:gocritic -var NewDB = func(driverName string, connectionString string) (*sql.DB, error) { - return sql.Open(driverName, connectionString) -} - type JsonData struct { MaxOpenConns int `json:"maxOpenConns"` MaxIdleConns int `json:"maxIdleConns"` @@ -86,9 +73,7 @@ type DataSourceInfo struct { } type DataPluginConfiguration struct { - DriverName string DSInfo DataSourceInfo - ConnectionString string TimeColumnNames []string MetricColumnTypes []string RowLimit int64 @@ -123,19 +108,14 @@ func (e *DataSourceHandler) TransformQueryError(logger log.Logger, err error) er var opErr *net.OpError if errors.As(err, &opErr) { logger.Error("Query error", "err", err) - return ErrConnectionFailed.Errorf("failed to connect to server - %s", e.userError) + return fmt.Errorf("failed to connect to server - %s", e.userError) } return e.queryResultTransformer.TransformQueryError(logger, err) } -func NewQueryDataHandler(cfg *setting.Cfg, config DataPluginConfiguration, queryResultTransformer SqlQueryResultTransformer, +func NewQueryDataHandler(userFacingDefaultError string, db *sql.DB, config DataPluginConfiguration, queryResultTransformer SqlQueryResultTransformer, macroEngine SQLMacroEngine, log log.Logger) (*DataSourceHandler, error) { - log.Debug("Creating engine...") - defer func() { - log.Debug("Engine created") - }() - queryDataHandler := DataSourceHandler{ queryResultTransformer: queryResultTransformer, macroEngine: macroEngine, @@ -143,7 +123,7 @@ func NewQueryDataHandler(cfg *setting.Cfg, config DataPluginConfiguration, query log: log, dsInfo: config.DSInfo, rowLimit: config.RowLimit, - userError: cfg.UserFacingDefaultError, + userError: userFacingDefaultError, } if len(config.TimeColumnNames) > 0 { @@ -154,15 +134,6 @@ func NewQueryDataHandler(cfg *setting.Cfg, config DataPluginConfiguration, query queryDataHandler.metricColumnTypes = config.MetricColumnTypes } - db, err := NewDB(config.DriverName, config.ConnectionString) - if err != nil { - return nil, err - } - - db.SetMaxOpenConns(config.DSInfo.JsonData.MaxOpenConns) - db.SetMaxIdleConns(config.DSInfo.JsonData.MaxIdleConns) - db.SetConnMaxLifetime(time.Duration(config.DSInfo.JsonData.ConnMaxLifetime) * time.Second) - queryDataHandler.db = db return &queryDataHandler, nil } @@ -200,6 +171,13 @@ func (e *DataSourceHandler) QueryData(ctx context.Context, req *backend.QueryDat if err != nil { return nil, fmt.Errorf("error unmarshal query json: %w", err) } + + // the fill-params are only stored inside this function, during query-interpolation. we do not support + // sending them in "from the outside" + if queryjson.Fill || queryjson.FillInterval != 0.0 || queryjson.FillMode != "" || queryjson.FillValue != 0.0 { + return nil, fmt.Errorf("query fill-parameters not supported") + } + if queryjson.RawSql == "" { continue } @@ -220,6 +198,12 @@ func (e *DataSourceHandler) QueryData(ctx context.Context, req *backend.QueryDat return result, nil } +func stackTrace(skip int) string { + call := stack.Caller(skip) + s := stack.Trace().TrimBelow(call).TrimRuntime() + return s.String() +} + func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitGroup, queryContext context.Context, ch chan DBDataResponse, queryJson QueryJson) { defer wg.Done() @@ -232,7 +216,7 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG defer func() { if r := recover(); r != nil { - logger.Error("ExecuteQuery panic", "error", r, "stack", corelog.Stack(1)) + logger.Error("ExecuteQuery panic", "error", r, "stack", stackTrace(1)) if theErr, ok := r.(error); ok { queryResult.dataResponse.Error = theErr } else if theErrString, ok := r.(string); ok { @@ -261,14 +245,10 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG } // global substitutions - interpolatedQuery, err := Interpolate(query, timeRange, e.dsInfo.JsonData.TimeInterval, queryJson.RawSql) - if err != nil { - errAppendDebug("interpolation failed", e.TransformQueryError(logger, err), interpolatedQuery) - return - } + interpolatedQuery := Interpolate(query, timeRange, e.dsInfo.JsonData.TimeInterval, queryJson.RawSql) // data source specific substitutions - interpolatedQuery, err = e.macroEngine.Interpolate(&query, timeRange, interpolatedQuery) + interpolatedQuery, err := e.macroEngine.Interpolate(&query, timeRange, interpolatedQuery) if err != nil { errAppendDebug("interpolation failed", e.TransformQueryError(logger, err), interpolatedQuery) return @@ -387,19 +367,15 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG } // Interpolate provides global macros/substitutions for all sql datasources. -var Interpolate = func(query backend.DataQuery, timeRange backend.TimeRange, timeInterval string, sql string) (string, error) { - minInterval, err := intervalv2.GetIntervalFrom(timeInterval, query.Interval.String(), query.Interval.Milliseconds(), time.Second*60) - if err != nil { - return "", err - } - interval := sqlIntervalCalculator.Calculate(timeRange, minInterval, query.MaxDataPoints) +var Interpolate = func(query backend.DataQuery, timeRange backend.TimeRange, timeInterval string, sql string) string { + interval := query.Interval sql = strings.ReplaceAll(sql, "$__interval_ms", strconv.FormatInt(interval.Milliseconds(), 10)) - sql = strings.ReplaceAll(sql, "$__interval", interval.Text) + sql = strings.ReplaceAll(sql, "$__interval", gtime.FormatInterval(interval)) sql = strings.ReplaceAll(sql, "$__unixEpochFrom()", fmt.Sprintf("%d", timeRange.From.UTC().Unix())) sql = strings.ReplaceAll(sql, "$__unixEpochTo()", fmt.Sprintf("%d", timeRange.To.UTC().Unix())) - return sql, nil + return sql } func (e *DataSourceHandler) newProcessCfg(query backend.DataQuery, queryContext context.Context, @@ -513,329 +489,6 @@ type dataQueryModel struct { queryContext context.Context } -func convertInt64ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - value := float64(origin.At(i).(int64)) - newField.Append(&value) - } -} - -func convertNullableInt64ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - iv := origin.At(i).(*int64) - if iv == nil { - newField.Append(nil) - } else { - value := float64(*iv) - newField.Append(&value) - } - } -} - -func convertUInt64ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - value := float64(origin.At(i).(uint64)) - newField.Append(&value) - } -} - -func convertNullableUInt64ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - iv := origin.At(i).(*uint64) - if iv == nil { - newField.Append(nil) - } else { - value := float64(*iv) - newField.Append(&value) - } - } -} - -func convertInt32ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - value := float64(origin.At(i).(int32)) - newField.Append(&value) - } -} - -func convertNullableInt32ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - iv := origin.At(i).(*int32) - if iv == nil { - newField.Append(nil) - } else { - value := float64(*iv) - newField.Append(&value) - } - } -} - -func convertUInt32ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - value := float64(origin.At(i).(uint32)) - newField.Append(&value) - } -} - -func convertNullableUInt32ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - iv := origin.At(i).(*uint32) - if iv == nil { - newField.Append(nil) - } else { - value := float64(*iv) - newField.Append(&value) - } - } -} - -func convertInt16ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - value := float64(origin.At(i).(int16)) - newField.Append(&value) - } -} - -func convertNullableInt16ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - iv := origin.At(i).(*int16) - if iv == nil { - newField.Append(nil) - } else { - value := float64(*iv) - newField.Append(&value) - } - } -} - -func convertUInt16ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - value := float64(origin.At(i).(uint16)) - newField.Append(&value) - } -} - -func convertNullableUInt16ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - iv := origin.At(i).(*uint16) - if iv == nil { - newField.Append(nil) - } else { - value := float64(*iv) - newField.Append(&value) - } - } -} - -func convertInt8ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - value := float64(origin.At(i).(int8)) - newField.Append(&value) - } -} - -func convertNullableInt8ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - iv := origin.At(i).(*int8) - if iv == nil { - newField.Append(nil) - } else { - value := float64(*iv) - newField.Append(&value) - } - } -} - -func convertUInt8ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - value := float64(origin.At(i).(uint8)) - newField.Append(&value) - } -} - -func convertNullableUInt8ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - iv := origin.At(i).(*uint8) - if iv == nil { - newField.Append(nil) - } else { - value := float64(*iv) - newField.Append(&value) - } - } -} - -func convertUnknownToZero(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - value := float64(0) - newField.Append(&value) - } -} - -func convertNullableFloat32ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - iv := origin.At(i).(*float32) - if iv == nil { - newField.Append(nil) - } else { - value := float64(*iv) - newField.Append(&value) - } - } -} - -func convertFloat32ToFloat64(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - value := float64(origin.At(i).(float32)) - newField.Append(&value) - } -} - -func convertInt64ToEpochMS(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - value := time.Unix(0, int64(epochPrecisionToMS(float64(origin.At(i).(int64))))*int64(time.Millisecond)) - newField.Append(&value) - } -} - -func convertNullableInt64ToEpochMS(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - iv := origin.At(i).(*int64) - if iv == nil { - newField.Append(nil) - } else { - value := time.Unix(0, int64(epochPrecisionToMS(float64(*iv)))*int64(time.Millisecond)) - newField.Append(&value) - } - } -} - -func convertUInt64ToEpochMS(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - value := time.Unix(0, int64(epochPrecisionToMS(float64(origin.At(i).(uint64))))*int64(time.Millisecond)) - newField.Append(&value) - } -} - -func convertNullableUInt64ToEpochMS(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - iv := origin.At(i).(*uint64) - if iv == nil { - newField.Append(nil) - } else { - value := time.Unix(0, int64(epochPrecisionToMS(float64(*iv)))*int64(time.Millisecond)) - newField.Append(&value) - } - } -} - -func convertInt32ToEpochMS(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - value := time.Unix(0, int64(epochPrecisionToMS(float64(origin.At(i).(int32))))*int64(time.Millisecond)) - newField.Append(&value) - } -} - -func convertNullableInt32ToEpochMS(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - iv := origin.At(i).(*int32) - if iv == nil { - newField.Append(nil) - } else { - value := time.Unix(0, int64(epochPrecisionToMS(float64(*iv)))*int64(time.Millisecond)) - newField.Append(&value) - } - } -} - -func convertUInt32ToEpochMS(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - value := time.Unix(0, int64(epochPrecisionToMS(float64(origin.At(i).(uint32))))*int64(time.Millisecond)) - newField.Append(&value) - } -} - -func convertNullableUInt32ToEpochMS(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - iv := origin.At(i).(*uint32) - if iv == nil { - newField.Append(nil) - } else { - value := time.Unix(0, int64(epochPrecisionToMS(float64(*iv)))*int64(time.Millisecond)) - newField.Append(&value) - } - } -} - -func convertFloat64ToEpochMS(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - value := time.Unix(0, int64(epochPrecisionToMS(origin.At(i).(float64)))*int64(time.Millisecond)) - newField.Append(&value) - } -} - -func convertNullableFloat64ToEpochMS(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - iv := origin.At(i).(*float64) - if iv == nil { - newField.Append(nil) - } else { - value := time.Unix(0, int64(epochPrecisionToMS(*iv))*int64(time.Millisecond)) - newField.Append(&value) - } - } -} - -func convertFloat32ToEpochMS(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - value := time.Unix(0, int64(epochPrecisionToMS(float64(origin.At(i).(float32))))*int64(time.Millisecond)) - newField.Append(&value) - } -} - -func convertNullableFloat32ToEpochMS(origin *data.Field, newField *data.Field) { - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - iv := origin.At(i).(*float32) - if iv == nil { - newField.Append(nil) - } else { - value := time.Unix(0, int64(epochPrecisionToMS(float64(*iv)))*int64(time.Millisecond)) - newField.Append(&value) - } - } -} - func convertSQLTimeColumnsToEpochMS(frame *data.Frame, qm *dataQueryModel) error { if qm.timeIndex != -1 { if err := convertSQLTimeColumnToEpochMS(frame, qm.timeIndex); err != nil { @@ -869,33 +522,18 @@ func convertSQLTimeColumnToEpochMS(frame *data.Frame, timeIndex int) error { newField.Name = origin.Name newField.Labels = origin.Labels - switch valueType { - case data.FieldTypeInt64: - convertInt64ToEpochMS(frame.Fields[timeIndex], newField) - case data.FieldTypeNullableInt64: - convertNullableInt64ToEpochMS(frame.Fields[timeIndex], newField) - case data.FieldTypeUint64: - convertUInt64ToEpochMS(frame.Fields[timeIndex], newField) - case data.FieldTypeNullableUint64: - convertNullableUInt64ToEpochMS(frame.Fields[timeIndex], newField) - case data.FieldTypeInt32: - convertInt32ToEpochMS(frame.Fields[timeIndex], newField) - case data.FieldTypeNullableInt32: - convertNullableInt32ToEpochMS(frame.Fields[timeIndex], newField) - case data.FieldTypeUint32: - convertUInt32ToEpochMS(frame.Fields[timeIndex], newField) - case data.FieldTypeNullableUint32: - convertNullableUInt32ToEpochMS(frame.Fields[timeIndex], newField) - case data.FieldTypeFloat64: - convertFloat64ToEpochMS(frame.Fields[timeIndex], newField) - case data.FieldTypeNullableFloat64: - convertNullableFloat64ToEpochMS(frame.Fields[timeIndex], newField) - case data.FieldTypeFloat32: - convertFloat32ToEpochMS(frame.Fields[timeIndex], newField) - case data.FieldTypeNullableFloat32: - convertNullableFloat32ToEpochMS(frame.Fields[timeIndex], newField) - default: - return fmt.Errorf("column type %q is not convertible to time.Time", valueType) + valueLength := origin.Len() + for i := 0; i < valueLength; i++ { + v, err := origin.NullableFloatAt(i) + if err != nil { + return fmt.Errorf("unable to convert data to a time field") + } + if v == nil { + newField.Append(nil) + } else { + timestamp := time.Unix(0, int64(epochPrecisionToMS(*v))*int64(time.Millisecond)) + newField.Append(×tamp) + } } frame.Fields[timeIndex] = newField @@ -903,8 +541,6 @@ func convertSQLTimeColumnToEpochMS(frame *data.Frame, timeIndex int) error { } // convertSQLValueColumnToFloat converts timeseries value column to float. -// -//nolint:gocyclo func convertSQLValueColumnToFloat(frame *data.Frame, Index int) (*data.Frame, error) { if Index < 0 || Index >= len(frame.Fields) { return frame, fmt.Errorf("metricIndex %d is out of range", Index) @@ -916,52 +552,18 @@ func convertSQLValueColumnToFloat(frame *data.Frame, Index int) (*data.Frame, er return frame, nil } - newField := data.NewFieldFromFieldType(data.FieldTypeNullableFloat64, 0) + newField := data.NewFieldFromFieldType(data.FieldTypeNullableFloat64, origin.Len()) newField.Name = origin.Name newField.Labels = origin.Labels - switch valueType { - case data.FieldTypeInt64: - convertInt64ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeNullableInt64: - convertNullableInt64ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeUint64: - convertUInt64ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeNullableUint64: - convertNullableUInt64ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeInt32: - convertInt32ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeNullableInt32: - convertNullableInt32ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeUint32: - convertUInt32ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeNullableUint32: - convertNullableUInt32ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeInt16: - convertInt16ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeNullableInt16: - convertNullableInt16ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeUint16: - convertUInt16ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeNullableUint16: - convertNullableUInt16ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeInt8: - convertInt8ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeNullableInt8: - convertNullableInt8ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeUint8: - convertUInt8ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeNullableUint8: - convertNullableUInt8ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeFloat32: - convertFloat32ToFloat64(frame.Fields[Index], newField) - case data.FieldTypeNullableFloat32: - convertNullableFloat32ToFloat64(frame.Fields[Index], newField) - default: - convertUnknownToZero(frame.Fields[Index], newField) - frame.Fields[Index] = newField - return frame, fmt.Errorf("metricIndex %d type %s can't be converted to float", Index, valueType) + for i := 0; i < origin.Len(); i++ { + v, err := origin.NullableFloatAt(i) + if err != nil { + return frame, err + } + newField.Set(i, v) } + frame.Fields[Index] = newField return frame, nil diff --git a/pkg/tsdb/sqleng/sql_engine_test.go b/pkg/tsdb/sqleng/sql_engine_test.go index 6aef946fadb81..367f6cb143a2c 100644 --- a/pkg/tsdb/sqleng/sql_engine_test.go +++ b/pkg/tsdb/sqleng/sql_engine_test.go @@ -19,39 +19,45 @@ import ( func TestSQLEngine(t *testing.T) { dt := time.Date(2018, 3, 14, 21, 20, 6, int(527345*time.Microsecond), time.UTC) - t.Run("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func(t *testing.T) { + t.Run("Handle interpolating $__interval and $__interval_ms", func(t *testing.T) { from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC) to := from.Add(5 * time.Minute) timeRange := backend.TimeRange{From: from, To: to} - query := backend.DataQuery{JSON: []byte("{}")} - t.Run("interpolate $__interval", func(t *testing.T) { - sql, err := Interpolate(query, timeRange, "", "select $__interval ") - require.NoError(t, err) - require.Equal(t, "select 1m ", sql) + text := "$__interval $__timeGroupAlias(time,$__interval) $__interval_ms" + + t.Run("interpolate 10 minutes $__interval", func(t *testing.T) { + query := backend.DataQuery{JSON: []byte("{}"), MaxDataPoints: 1500, Interval: time.Minute * 10} + sql := Interpolate(query, timeRange, "", text) + require.Equal(t, "10m $__timeGroupAlias(time,10m) 600000", sql) }) - t.Run("interpolate $__interval in $__timeGroup", func(t *testing.T) { - sql, err := Interpolate(query, timeRange, "", "select $__timeGroupAlias(time,$__interval)") - require.NoError(t, err) - require.Equal(t, "select $__timeGroupAlias(time,1m)", sql) + t.Run("interpolate 4seconds $__interval", func(t *testing.T) { + query := backend.DataQuery{JSON: []byte("{}"), MaxDataPoints: 1500, Interval: time.Second * 4} + sql := Interpolate(query, timeRange, "", text) + require.Equal(t, "4s $__timeGroupAlias(time,4s) 4000", sql) }) - t.Run("interpolate $__interval_ms", func(t *testing.T) { - sql, err := Interpolate(query, timeRange, "", "select $__interval_ms ") - require.NoError(t, err) - require.Equal(t, "select 60000 ", sql) + t.Run("interpolate 200 milliseconds $__interval", func(t *testing.T) { + query := backend.DataQuery{JSON: []byte("{}"), MaxDataPoints: 1500, Interval: time.Millisecond * 200} + sql := Interpolate(query, timeRange, "", text) + require.Equal(t, "200ms $__timeGroupAlias(time,200ms) 200", sql) }) + }) + + t.Run("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func(t *testing.T) { + from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC) + to := from.Add(5 * time.Minute) + timeRange := backend.TimeRange{From: from, To: to} + query := backend.DataQuery{JSON: []byte("{}"), MaxDataPoints: 1500, Interval: time.Second * 60} t.Run("interpolate __unixEpochFrom function", func(t *testing.T) { - sql, err := Interpolate(query, timeRange, "", "select $__unixEpochFrom()") - require.NoError(t, err) + sql := Interpolate(query, timeRange, "", "select $__unixEpochFrom()") require.Equal(t, fmt.Sprintf("select %d", from.Unix()), sql) }) t.Run("interpolate __unixEpochTo function", func(t *testing.T) { - sql, err := Interpolate(query, timeRange, "", "select $__unixEpochTo()") - require.NoError(t, err) + sql := Interpolate(query, timeRange, "", "select $__unixEpochTo()") require.Equal(t, fmt.Sprintf("select %d", to.Unix()), sql) }) }) @@ -389,28 +395,32 @@ func TestSQLEngine(t *testing.T) { } }) - t.Run("Should handle connection errors", func(t *testing.T) { - randomErr := fmt.Errorf("random error") - - tests := []struct { - err error - expectedErr error - expectQueryResultTransformerWasCalled bool - }{ - {err: &net.OpError{Op: "Dial", Err: fmt.Errorf("inner-error")}, expectedErr: ErrConnectionFailed, expectQueryResultTransformerWasCalled: false}, - {err: randomErr, expectedErr: randomErr, expectQueryResultTransformerWasCalled: true}, + t.Run("Should not return raw connection errors", func(t *testing.T) { + err := net.OpError{Op: "Dial", Err: fmt.Errorf("inner-error")} + transformer := &testQueryResultTransformer{} + dp := DataSourceHandler{ + log: backend.NewLoggerWith("logger", "test"), + queryResultTransformer: transformer, } + resultErr := dp.TransformQueryError(dp.log, &err) + assert.False(t, transformer.transformQueryErrorWasCalled) + errorText := resultErr.Error() + assert.NotEqual(t, err, resultErr) + assert.NotContains(t, errorText, "inner-error") + assert.Contains(t, errorText, "failed to connect to server") + }) - for _, tc := range tests { - transformer := &testQueryResultTransformer{} - dp := DataSourceHandler{ - log: backend.NewLoggerWith("logger", "test"), - queryResultTransformer: transformer, - } - resultErr := dp.TransformQueryError(dp.log, tc.err) - assert.ErrorIs(t, resultErr, tc.expectedErr) - assert.Equal(t, tc.expectQueryResultTransformerWasCalled, transformer.transformQueryErrorWasCalled) + t.Run("Should return non-connection errors unmodified", func(t *testing.T) { + err := fmt.Errorf("normal error") + transformer := &testQueryResultTransformer{} + dp := DataSourceHandler{ + log: backend.NewLoggerWith("logger", "test"), + queryResultTransformer: transformer, } + resultErr := dp.TransformQueryError(dp.log, err) + assert.True(t, transformer.transformQueryErrorWasCalled) + assert.Equal(t, err, resultErr) + assert.ErrorIs(t, err, resultErr) }) } diff --git a/pkg/tsdb/tempo/grpc.go b/pkg/tsdb/tempo/grpc.go index 474502af75f22..12ff015547119 100644 --- a/pkg/tsdb/tempo/grpc.go +++ b/pkg/tsdb/tempo/grpc.go @@ -8,6 +8,8 @@ import ( "net/url" "strings" + "google.golang.org/grpc/metadata" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/tempo/pkg/tempopb" @@ -18,11 +20,12 @@ import ( var logger = backend.NewLoggerWith("logger", "tsdb.tempo") -// This function creates a new gRPC client to connect to a streaming query service. -// It starts by parsing the URL from the data source settings and extracting the host, since that's what the gRPC connection expects. -// If the URL does not contain a port number, it adds a default port based on the scheme (80 for HTTP and 443 for HTTPS). -// If basic authentication is enabled, it uses TLS transport credentials and sets the basic authentication header for each RPC call. -// Otherwise, it uses insecure credentials. +// newGrpcClient creates a new gRPC client to connect to a streaming query service. +// This uses the default google.golang.org/grpc library. One caveat to that is that it does not allow passing the +// default httpClient to the gRPC client. This means that we cannot use the same middleware that we use for +// standard HTTP requests. +// Using other library like connect-go isn't possible right now because Tempo uses non-standard proto compiler which +// makes generating different client difficult. See https://github.com/grafana/grafana/pull/81683 func newGrpcClient(settings backend.DataSourceInstanceSettings, opts httpclient.Options) (tempopb.StreamingQuerierClient, error) { parsedUrl, err := url.Parse(settings.URL) if err != nil { @@ -30,6 +33,7 @@ func newGrpcClient(settings backend.DataSourceInstanceSettings, opts httpclient. return nil, err } + // Make sure we have some default port if none is set. This is required for gRPC to work. onlyHost := parsedUrl.Host if !strings.Contains(onlyHost, ":") { if parsedUrl.Scheme == "http" { @@ -39,23 +43,54 @@ func newGrpcClient(settings backend.DataSourceInstanceSettings, opts httpclient. } } + clientConn, err := grpc.Dial(onlyHost, getDialOpts(settings, opts)...) + if err != nil { + logger.Error("Error dialing gRPC client", "error", err, "URL", settings.URL, "function", logEntrypoint()) + return nil, err + } + + return tempopb.NewStreamingQuerierClient(clientConn), nil +} + +// getDialOpts creates options and interceptors (middleware) this should roughly match what we do in +// http_client_provider.go for standard http requests. +func getDialOpts(settings backend.DataSourceInstanceSettings, opts httpclient.Options) []grpc.DialOption { + // TODO: Missing middleware TracingMiddleware, DataSourceMetricsMiddleware, ContextualMiddleware, + // ResponseLimitMiddleware RedirectLimitMiddleware. + // Also User agent but that is set before each rpc call as for decoupled DS we have to get it from request context + // and cannot add it to client here. + var dialOps []grpc.DialOption + + dialOps = append(dialOps, grpc.WithChainStreamInterceptor(CustomHeadersStreamInterceptor(opts))) if settings.BasicAuthEnabled { + // If basic authentication is enabled, it uses TLS transport credentials and sets the basic authentication header for each RPC call. dialOps = append(dialOps, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{}))) dialOps = append(dialOps, grpc.WithPerRPCCredentials(&basicAuth{ Header: basicHeaderForAuth(opts.BasicAuth.User, opts.BasicAuth.Password), })) } else { + // Otherwise, it uses insecure credentials. dialOps = append(dialOps, grpc.WithTransportCredentials(insecure.NewCredentials())) } - clientConn, err := grpc.Dial(onlyHost, dialOps...) - if err != nil { - logger.Error("Error dialing gRPC client", "error", err, "URL", settings.URL, "function", logEntrypoint()) - return nil, err - } + return dialOps +} - return tempopb.NewStreamingQuerierClient(clientConn), nil +// CustomHeadersStreamInterceptor adds custom headers to the outgoing context for each RPC call. Should work similar +// to the CustomHeadersMiddleware in the HTTP client provider. +func CustomHeadersStreamInterceptor(httpOpts httpclient.Options) grpc.StreamClientInterceptor { + return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { + if len(httpOpts.Header) != 0 { + for key, value := range httpOpts.Header { + for _, v := range value { + ctx = metadata.AppendToOutgoingContext(ctx, key, v) + } + } + } + + return streamer(ctx, desc, cc, method, opts...) + } } type basicAuth struct { diff --git a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go index e635751839de6..032df5714f2cd 100644 --- a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go @@ -27,7 +27,6 @@ const ( const ( TempoQueryTypeClear TempoQueryType = "clear" TempoQueryTypeNativeSearch TempoQueryType = "nativeSearch" - TempoQueryTypeSearch TempoQueryType = "search" TempoQueryTypeServiceMap TempoQueryType = "serviceMap" TempoQueryTypeTraceId TempoQueryType = "traceId" TempoQueryTypeTraceql TempoQueryType = "traceql" @@ -126,8 +125,8 @@ type TempoQuery struct { // Use service.namespace in addition to service.name to uniquely identify a service. ServiceMapIncludeNamespace *bool `json:"serviceMapIncludeNamespace,omitempty"` - // Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"} - ServiceMapQuery *string `json:"serviceMapQuery,omitempty"` + // Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"}. Providing multiple values will produce union of results for each filter, using PromQL OR operator internally. + ServiceMapQuery *any `json:"serviceMapQuery,omitempty"` // @deprecated Query traces by service name ServiceName *string `json:"serviceName,omitempty"` @@ -142,7 +141,7 @@ type TempoQuery struct { TableType *SearchTableType `json:"tableType,omitempty"` } -// TempoQueryType search = Loki search, nativeSearch = Tempo search for backwards compatibility +// TempoQueryType defines model for TempoQueryType. type TempoQueryType string // TraceqlFilter defines model for TraceqlFilter. diff --git a/pkg/tsdb/tempo/search_stream.go b/pkg/tsdb/tempo/search_stream.go index d2ef5a1d8ae2a..d96fec7a21666 100644 --- a/pkg/tsdb/tempo/search_stream.go +++ b/pkg/tsdb/tempo/search_stream.go @@ -7,6 +7,8 @@ import ( "fmt" "io" + "google.golang.org/grpc/metadata" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" "github.com/grafana/grafana-plugin-sdk-go/data" @@ -60,6 +62,11 @@ func (s *Service) runSearchStream(ctx context.Context, req *backend.RunStreamReq sr.Start = uint32(backendQuery.TimeRange.From.Unix()) sr.End = uint32(backendQuery.TimeRange.To.Unix()) + // Setting the user agent for the gRPC call. When DS is decoupled we don't recreate instance when grafana config + // changes or updates, so we have to get it from context. + // Ideally this would be pushed higher, so it's set once for all rpc calls, but we have only one now. + ctx = metadata.AppendToOutgoingContext(ctx, "User-Agent", backend.UserAgentFromContext(ctx).String()) + stream, err := datasource.StreamingClient.Search(ctx, sr) if err != nil { span.RecordError(err) diff --git a/pkg/tsdb/tempo/standalone/datasource.go b/pkg/tsdb/tempo/standalone/datasource.go new file mode 100644 index 0000000000000..087cb4dff7156 --- /dev/null +++ b/pkg/tsdb/tempo/standalone/datasource.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + tempo "github.com/grafana/grafana/pkg/tsdb/tempo" +) + +type Datasource struct { + Service *tempo.Service +} + +var ( + _ backend.QueryDataHandler = (*Datasource)(nil) + _ backend.StreamHandler = (*Datasource)(nil) +) + +func NewDatasource(c context.Context, b backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return &Datasource{ + Service: tempo.ProvideService(httpclient.NewProvider()), + }, nil +} + +func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return d.Service.QueryData(ctx, req) +} + +func (d *Datasource) SubscribeStream(ctx context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) { + return d.Service.SubscribeStream(ctx, req) +} + +func (d *Datasource) PublishStream(ctx context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) { + return d.Service.PublishStream(ctx, req) +} + +func (d *Datasource) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error { + return d.Service.RunStream(ctx, req, sender) +} diff --git a/pkg/tsdb/tempo/standalone/main.go b/pkg/tsdb/tempo/standalone/main.go new file mode 100644 index 0000000000000..6961ead4c2024 --- /dev/null +++ b/pkg/tsdb/tempo/standalone/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "os" + + "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +func main() { + // Created as described at https://grafana.com/developers/plugin-tools/introduction/backend-plugins + if err := datasource.Manage("tempo", NewDatasource, datasource.ManageOpts{}); err != nil { + log.DefaultLogger.Error(err.Error()) + os.Exit(1) + } +} diff --git a/pkg/util/converter/jsonitere/jsonitere.go b/pkg/util/converter/jsonitere/jsonitere.go deleted file mode 100644 index 4bc0c2ed55fb4..0000000000000 --- a/pkg/util/converter/jsonitere/jsonitere.go +++ /dev/null @@ -1,66 +0,0 @@ -// Package jsonitere wraps json-iterator/go's Iterator methods with error returns -// so linting can catch unchecked errors. -// The underlying iterator's Error property is returned and not reset. -// See json-iterator/go for method documentation and additional methods that -// can be added to this library. -package jsonitere - -import ( - j "github.com/json-iterator/go" -) - -type Iterator struct { - // named property instead of embedded so there is no - // confusion about which method or property is called - i *j.Iterator -} - -func NewIterator(i *j.Iterator) *Iterator { - return &Iterator{i} -} - -func (iter *Iterator) Read() (any, error) { - return iter.i.Read(), iter.i.Error -} - -func (iter *Iterator) ReadAny() (j.Any, error) { - return iter.i.ReadAny(), iter.i.Error -} - -func (iter *Iterator) ReadArray() (bool, error) { - return iter.i.ReadArray(), iter.i.Error -} - -func (iter *Iterator) ReadObject() (string, error) { - return iter.i.ReadObject(), iter.i.Error -} - -func (iter *Iterator) ReadString() (string, error) { - return iter.i.ReadString(), iter.i.Error -} - -func (iter *Iterator) WhatIsNext() (j.ValueType, error) { - return iter.i.WhatIsNext(), iter.i.Error -} - -func (iter *Iterator) Skip() error { - iter.i.Skip() - return iter.i.Error -} - -func (iter *Iterator) SkipAndReturnBytes() []byte { - return iter.i.SkipAndReturnBytes() -} - -func (iter *Iterator) ReadVal(obj any) error { - iter.i.ReadVal(obj) - return iter.i.Error -} - -func (iter *Iterator) ReadFloat64() (float64, error) { - return iter.i.ReadFloat64(), iter.i.Error -} - -func (iter *Iterator) ReadInt8() (int8, error) { - return iter.i.ReadInt8(), iter.i.Error -} diff --git a/pkg/util/filepath.go b/pkg/util/filepath.go index 5d642f7b13ce0..fc76005840748 100644 --- a/pkg/util/filepath.go +++ b/pkg/util/filepath.go @@ -27,10 +27,10 @@ func newWalker(rootDir string) *walker { // it calls the walkFn passed. // // It is similar to filepath.Walk, except that it supports symbolic links and -// can detect infinite loops while following sym links. +// can detect infinite loops while following symlinks. // It solves the issue where your WalkFunc needs a path relative to the symbolic link // (resolving links within walkfunc loses the path to the symbolic link for each traversal). -func Walk(path string, followSymlinks bool, detectSymlinkInfiniteLoop bool, followDistFolder bool, walkFn WalkFunc) error { +func Walk(path string, followSymlinks bool, detectSymlinkInfiniteLoop bool, walkFn WalkFunc) error { info, err := os.Lstat(path) if err != nil { return err @@ -44,7 +44,7 @@ func Walk(path string, followSymlinks bool, detectSymlinkInfiniteLoop bool, foll } } - return newWalker(path).walk(path, info, resolvedPath, symlinkPathsFollowed, followDistFolder, walkFn) + return newWalker(path).walk(path, info, resolvedPath, symlinkPathsFollowed, walkFn) } // walk walks the path. It is a helper/sibling function to Walk. @@ -53,7 +53,7 @@ func Walk(path string, followSymlinks bool, detectSymlinkInfiniteLoop bool, foll // // If resolvedPath is "", then we are not following symbolic links. // If symlinkPathsFollowed is not nil, then we need to detect infinite loop. -func (w *walker) walk(path string, info os.FileInfo, resolvedPath string, symlinkPathsFollowed map[string]bool, followDistFolder bool, walkFn WalkFunc) error { +func (w *walker) walk(path string, info os.FileInfo, resolvedPath string, symlinkPathsFollowed map[string]bool, walkFn WalkFunc) error { if info == nil { return errors.New("walk: Nil FileInfo passed") } @@ -91,7 +91,7 @@ func (w *walker) walk(path string, info os.FileInfo, resolvedPath string, symlin if err != nil { return err } - return w.walk(path, info2, path2, symlinkPathsFollowed, followDistFolder, walkFn) + return w.walk(path, info2, path2, symlinkPathsFollowed, walkFn) } else if info.IsDir() { list, err := os.ReadDir(path) if err != nil { @@ -112,21 +112,19 @@ func (w *walker) walk(path string, info os.FileInfo, resolvedPath string, symlin subFiles = append(subFiles, subFile{path: path2, resolvedPath: resolvedPath2, fileInfo: fileInfo}) } - // If we have found a dist directory in a subdirectory (IE not at root path), and followDistFolder is true, - // then we want to follow only the dist directory and ignore all other subdirectories. - atRootDir := w.rootDir == path - if followDistFolder && w.containsDistFolder(subFiles) && !atRootDir { - return w.walk(filepath.Join(path, "dist"), info, filepath.Join(resolvedPath, "dist"), symlinkPathsFollowed, - followDistFolder, walkFn) + if w.containsDistFolder(subFiles) { + err := w.walk( + filepath.Join(path, "dist"), + info, + filepath.Join(resolvedPath, "dist"), + symlinkPathsFollowed, + walkFn) + if err != nil { + return err + } } else { - // Follow all subdirectories, with special handling for dist directories. for _, p := range subFiles { - // We only want to skip a dist directory if it is not in the root directory, and followDistFolder is false. - if p.isDistDir() && !atRootDir && !followDistFolder { - continue - } - - err = w.walk(p.path, p.fileInfo, p.resolvedPath, symlinkPathsFollowed, followDistFolder, walkFn) + err = w.walk(p.path, p.fileInfo, p.resolvedPath, symlinkPathsFollowed, walkFn) if err != nil { return err } @@ -152,10 +150,6 @@ type subFile struct { fileInfo os.FileInfo } -func (s subFile) isDistDir() bool { - return s.fileInfo.IsDir() && s.fileInfo.Name() == "dist" -} - // CleanRelativePath returns the shortest path name equivalent to path // by purely lexical processing. It makes sure the provided path is rooted // and then uses filepath.Clean and filepath.Rel to make sure the path diff --git a/pkg/util/json.go b/pkg/util/json.go index 7268ff93798ca..b1e7503bf5186 100644 --- a/pkg/util/json.go +++ b/pkg/util/json.go @@ -1,4 +1,112 @@ package util +import ( + "encoding/json" + + "github.com/jmespath/go-jmespath" + + "github.com/grafana/grafana/pkg/util/errutil" +) + // DynMap defines a dynamic map interface. type DynMap map[string]any + +var ( + // ErrEmptyJSON is an error for empty attribute in JSON. + ErrEmptyJSON = errutil.NewBase(errutil.StatusBadRequest, + "json-missing-body", errutil.WithPublicMessage("Empty JSON provided")) + + // ErrNoAttributePathSpecified is an error for no attribute path specified. + ErrNoAttributePathSpecified = errutil.NewBase(errutil.StatusBadRequest, + "json-no-attribute-path-specified", errutil.WithPublicMessage("No attribute path specified")) + + // ErrFailedToUnmarshalJSON is an error for failure in unmarshalling JSON. + ErrFailedToUnmarshalJSON = errutil.NewBase(errutil.StatusBadRequest, + "json-failed-to-unmarshal", errutil.WithPublicMessage("Failed to unmarshal JSON")) + + // ErrFailedToSearchJSON is an error for failure in searching JSON. + ErrFailedToSearchJSON = errutil.NewBase(errutil.StatusBadRequest, + "json-failed-to-search", errutil.WithPublicMessage("Failed to search JSON with provided path")) +) + +// SearchJSONForStringSliceAttr searches for a slice attribute in a JSON object and returns a string slice. +// The attributePath parameter is a string that specifies the path to the attribute. +// The data parameter is the JSON object that we're searching. It can be a byte slice or a go type. +func SearchJSONForStringSliceAttr(attributePath string, data any) ([]string, error) { + val, err := searchJSONForAttr(attributePath, data) + if err != nil { + return []string{}, err + } + + ifArr, ok := val.([]any) + if !ok { + return []string{}, nil + } + + result := []string{} + for _, v := range ifArr { + if strVal, ok := v.(string); ok { + result = append(result, strVal) + } + } + + return result, nil +} + +// SearchJSONForStringAttr searches for a specific attribute in a JSON object and returns a string. +// The attributePath parameter is a string that specifies the path to the attribute. +// The data parameter is the JSON object that we're searching. It can be a byte slice or a go type. +func SearchJSONForStringAttr(attributePath string, data any) (string, error) { + val, err := searchJSONForAttr(attributePath, data) + if err != nil { + return "", err + } + + strVal, ok := val.(string) + if ok { + return strVal, nil + } + + return "", nil +} + +// searchJSONForAttr searches for a specific attribute in a JSON object. +// The attributePath parameter is a string that specifies the path to the attribute. +// The data parameter is the JSON object that we're searching. +// The function returns the value of the attribute and an error if one occurred. +func searchJSONForAttr(attributePath string, data any) (any, error) { + // If no attribute path is specified, return an error + if attributePath == "" { + return "", ErrNoAttributePathSpecified.Errorf("attribute path: %q", attributePath) + } + + // If the data is nil, return an error + if data == nil { + return "", ErrEmptyJSON.Errorf("empty json, attribute path: %q", attributePath) + } + + // Copy the data to a new variable + var jsonData = data + + // If the data is a byte slice, try to unmarshal it into a JSON object + if dataBytes, ok := data.([]byte); ok { + // If the byte slice is empty, return an error + if len(dataBytes) == 0 { + return "", ErrEmptyJSON.Errorf("empty json, attribute path: %q", attributePath) + } + + // Try to unmarshal the byte slice + if err := json.Unmarshal(dataBytes, &jsonData); err != nil { + return "", ErrFailedToUnmarshalJSON.Errorf("%v: %w", "failed to unmarshal user info JSON response", err) + } + } + + // Search for the attribute in the JSON object + value, err := jmespath.Search(attributePath, jsonData) + if err != nil { + return "", ErrFailedToSearchJSON.Errorf("failed to search user info JSON response with provided path: %q: %w", attributePath, err) + } + + // Return the value and nil error + return value, nil +} diff --git a/pkg/util/json_test.go b/pkg/util/json_test.go new file mode 100644 index 0000000000000..232043a05f5c5 --- /dev/null +++ b/pkg/util/json_test.go @@ -0,0 +1,155 @@ +package util_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/util" +) + +func TestSearchJSONForGroups(t *testing.T) { + t.Parallel() + tests := []struct { + Name string + searchObject any + GroupsAttributePath string + ExpectedResult []string + ExpectedError error + }{ + { + Name: "Given an invalid user info JSON response", + searchObject: []byte("{"), + GroupsAttributePath: "attributes.groups", + ExpectedResult: []string{}, + ExpectedError: util.ErrFailedToUnmarshalJSON, + }, + { + Name: "Given an empty user info JSON response and empty JMES path", + searchObject: []byte{}, + GroupsAttributePath: "", + ExpectedResult: []string{}, + ExpectedError: util.ErrNoAttributePathSpecified, + }, + { + Name: "Given an empty user info JSON response and valid JMES path", + searchObject: []byte{}, + GroupsAttributePath: "attributes.groups", + ExpectedResult: []string{}, + ExpectedError: util.ErrEmptyJSON, + }, + { + Name: "Given a nil JSON and valid JMES path", + searchObject: []byte{}, + GroupsAttributePath: "attributes.groups", + ExpectedResult: []string{}, + ExpectedError: util.ErrEmptyJSON, + }, + { + Name: "Given a simple user info JSON response and valid JMES path", + searchObject: []byte(`{ + "attributes": { + "groups": ["foo", "bar"] + } +}`), + GroupsAttributePath: "attributes.groups[]", + ExpectedResult: []string{"foo", "bar"}, + }, + { + Name: "Given a simple object and valid JMES path", + searchObject: map[string]any{ + "attributes": map[string]any{ + "groups": []string{"foo", "bar"}, + }, + }, + GroupsAttributePath: "attributes.groups[]", + ExpectedResult: []string{"foo", "bar"}, + }, + } + + for _, test := range tests { + test := test + t.Run(test.Name, func(t *testing.T) { + t.Parallel() + actualResult, err := util.SearchJSONForStringSliceAttr( + test.GroupsAttributePath, test.searchObject) + if test.ExpectedError == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.ExpectedError) + } + require.Equal(t, test.ExpectedResult, actualResult) + }) + } +} + +func TestSearchJSONForEmail(t *testing.T) { + t.Parallel() + tests := []struct { + Name string + UserInfoJSONResponse any + EmailAttributePath string + ExpectedResult string + ExpectedError error + }{ + { + Name: "Given a simple user info JSON response and valid JMES path", + UserInfoJSONResponse: []byte(`{ + "attributes": { + "email": "grafana@localhost" + } +}`), + EmailAttributePath: "attributes.email", + ExpectedResult: "grafana@localhost", + }, + { + Name: "Given a simple object and valid JMES path", + UserInfoJSONResponse: map[string]any{ + "attributes": map[string]any{ + "email": "grafana@localhost", + }, + }, + EmailAttributePath: "attributes.email", + ExpectedResult: "grafana@localhost", + }, + { + Name: "Given a user info JSON response with e-mails array and valid JMES path", + UserInfoJSONResponse: []byte(`{ + "attributes": { + "emails": ["grafana@localhost", "admin@localhost"] + } +}`), + EmailAttributePath: "attributes.emails[0]", + ExpectedResult: "grafana@localhost", + }, + { + Name: "Given a nested user info JSON response and valid JMES path", + UserInfoJSONResponse: []byte(`{ + "identities": [ + { + "userId": "grafana@localhost" + }, + { + "userId": "admin@localhost" + } + ] +}`), + EmailAttributePath: "identities[0].userId", + ExpectedResult: "grafana@localhost", + }, + } + + for _, test := range tests { + test := test + t.Run(test.Name, func(t *testing.T) { + t.Parallel() + actualResult, err := util.SearchJSONForStringAttr(test.EmailAttributePath, test.UserInfoJSONResponse) + if test.ExpectedError != nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.ExpectedError) + } + require.Equal(t, test.ExpectedResult, actualResult) + }) + } +} diff --git a/pkg/util/maputil/maputil.go b/pkg/util/maputil/maputil.go deleted file mode 100644 index f3ff61baecdd9..0000000000000 --- a/pkg/util/maputil/maputil.go +++ /dev/null @@ -1,73 +0,0 @@ -package maputil - -import "fmt" - -func GetMap(obj map[string]any, key string) (map[string]any, error) { - if untypedValue, ok := obj[key]; ok { - if value, ok := untypedValue.(map[string]any); ok { - return value, nil - } else { - err := fmt.Errorf("the field '%s' should be an object", key) - return nil, err - } - } else { - err := fmt.Errorf("the field '%s' should be set", key) - return nil, err - } -} - -func GetBool(obj map[string]any, key string) (bool, error) { - if untypedValue, ok := obj[key]; ok { - if value, ok := untypedValue.(bool); ok { - return value, nil - } else { - err := fmt.Errorf("the field '%s' should be a bool", key) - return false, err - } - } else { - err := fmt.Errorf("the field '%s' should be set", key) - return false, err - } -} - -func GetBoolOptional(obj map[string]any, key string) (bool, error) { - if untypedValue, ok := obj[key]; ok { - if value, ok := untypedValue.(bool); ok { - return value, nil - } else { - err := fmt.Errorf("the field '%s' should be a bool", key) - return false, err - } - } else { - // Value optional, not error - return false, nil - } -} - -func GetString(obj map[string]any, key string) (string, error) { - if untypedValue, ok := obj[key]; ok { - if value, ok := untypedValue.(string); ok { - return value, nil - } else { - err := fmt.Errorf("the field '%s' should be a string", key) - return "", err - } - } else { - err := fmt.Errorf("the field '%s' should be set", key) - return "", err - } -} - -func GetStringOptional(obj map[string]any, key string) (string, error) { - if untypedValue, ok := obj[key]; ok { - if value, ok := untypedValue.(string); ok { - return value, nil - } else { - err := fmt.Errorf("the field '%s' should be a string", key) - return "", err - } - } else { - // Value optional, not error - return "", nil - } -} diff --git a/pkg/util/osutil/osutil.go b/pkg/util/osutil/osutil.go new file mode 100644 index 0000000000000..605a8812a0fe8 --- /dev/null +++ b/pkg/util/osutil/osutil.go @@ -0,0 +1,42 @@ +package osutil + +import ( + "os" +) + +// Env collects global functions from standard package "os" that are related to +// environment variables. This allows abstracting code and provides a way to +// concurrently test code that needs access to these shared resources. +type Env interface { + Setenv(key, value string) error + Getenv(key string) string +} + +// RealEnv implements Env interface by calling the actual global functions in +// package "os". This should be used by default anywhere that an Env is +// expected, and use MapEnv instead in your unit tests. +type RealEnv struct{} + +func (RealEnv) Setenv(key, value string) error { + return os.Setenv(key, value) +} + +func (RealEnv) Getenv(key string) string { + return os.Getenv(key) +} + +// MapEnv is a fake implementing Env interface. It is purposefully not +// concurrency-safe, so if your tests using it panic due to concurrent map +// access, then you need to fix a data race in your code. This is +// because environment variables are globals to a process, so you should be +// properly synchronizing access to them (e.g. with a mutex). +type MapEnv map[string]string + +func (m MapEnv) Setenv(key, value string) error { + m[key] = value + return nil +} + +func (m MapEnv) Getenv(key string) string { + return m[key] +} diff --git a/pkg/util/osutil/osutil_test.go b/pkg/util/osutil/osutil_test.go new file mode 100644 index 0000000000000..5a9dcc41b5fc5 --- /dev/null +++ b/pkg/util/osutil/osutil_test.go @@ -0,0 +1,35 @@ +package osutil + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRealEnv(t *testing.T) { + // testing here is obviously not parallel since we do need to access real + // environment variables from the os + + const key = "MEREKETENGUE" + const value = "IS ALIVE" + + assert.Equal(t, os.Getenv(key), RealEnv{}.Getenv(key)) + assert.NoError(t, RealEnv{}.Setenv(key, value)) + assert.Equal(t, value, RealEnv{}.Getenv(key)) + assert.Equal(t, value, os.Getenv(key)) +} + +func TestMapEnv(t *testing.T) { + t.Parallel() + + const key = "THE_THING" + const value = "IS ALIVE" + + e := MapEnv{} + assert.Empty(t, e.Getenv(key)) + assert.Len(t, e, 0) + assert.NoError(t, e.Setenv(key, value)) + assert.Equal(t, value, e.Getenv(key)) + assert.Len(t, e, 1) +} diff --git a/pkg/util/shortid_generator.go b/pkg/util/shortid_generator.go index eb841b7bba0fa..db216e622a6e6 100644 --- a/pkg/util/shortid_generator.go +++ b/pkg/util/shortid_generator.go @@ -8,6 +8,7 @@ import ( "sync" "time" + "github.com/bwmarrin/snowflake" "github.com/google/uuid" ) @@ -42,24 +43,39 @@ func IsShortUIDTooLong(uid string) bool { return len(uid) > MaxUIDLength } +var node *snowflake.Node + // GenerateShortUID will generate a UUID that can also be a k8s name // it is guaranteed to have a character as the first letter // This UID will be a valid k8s name func GenerateShortUID() string { mtx.Lock() defer mtx.Unlock() - uid, err := uuid.NewRandom() - if err != nil { - // This should never happen... but this seems better than a panic - for i := range uid { - uid[i] = byte(uidrand.Intn(255)) - } + + if node == nil { + // ignoring the error happens when input outside 0-1023 + node, _ = snowflake.NewNode(rand.Int63n(1024)) } - uuid := uid.String() - if rune(uuid[0]) < rune('a') { - return string(hexLetters[uidrand.Intn(len(hexLetters))]) + uuid[1:] + + // Use UUIDs if snowflake failed (should be never) + if node == nil { + uid, err := uuid.NewRandom() + if err != nil { + // This should never happen... but this seems better than a panic + for i := range uid { + uid[i] = byte(uidrand.Intn(255)) + } + } + uuid := uid.String() + if rune(uuid[0]) < rune('a') { + uuid = string(hexLetters[uidrand.Intn(len(hexLetters))]) + uuid[1:] + } + return uuid } - return uuid + + return string(hexLetters[uidrand.Intn(len(hexLetters))]) + // start with a letter + node.Generate().Base36() + + string(hexLetters[uidrand.Intn(len(hexLetters))]) // a bit more entropy } // ValidateUID checks the format and length of the string and returns error if it does not pass the condition diff --git a/pkg/util/shortid_generator_test.go b/pkg/util/shortid_generator_test.go index 010219244aee4..6bc4bb05b5c3b 100644 --- a/pkg/util/shortid_generator_test.go +++ b/pkg/util/shortid_generator_test.go @@ -1,12 +1,13 @@ package util import ( + "fmt" "sync" "testing" "cuelang.org/go/pkg/strings" - "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/teris-io/shortid" "k8s.io/apimachinery/pkg/util/validation" ) @@ -48,14 +49,27 @@ func TestRandomUIDs(t *testing.T) { t.Fatalf("created invalid name: %v", validation) } - _, err := uuid.Parse(v) - require.NoError(t, err) - - //fmt.Println(v) + // fmt.Println(v) } // t.FailNow() } +func TestCaseInsensitiveCollisionsUIDs(t *testing.T) { + history := make(map[string]bool, 0) + for i := 0; i < 100000; i++ { + v := GenerateShortUID() + if false { + v, _ = shortid.Generate() // collides in less then 500 iterations + } + + lower := strings.ToLower(v) + _, exists := history[lower] + require.False(t, exists, fmt.Sprintf("already found: %s (index:%d)", v, i)) + + history[lower] = true + } +} + func TestIsShortUIDTooLong(t *testing.T) { var tests = []struct { name string diff --git a/pkg/util/xorm/go.mod b/pkg/util/xorm/go.mod index 80a175b05a3f4..78f85ed97f7fa 100644 --- a/pkg/util/xorm/go.mod +++ b/pkg/util/xorm/go.mod @@ -1,13 +1,20 @@ -module xorm.io/xorm +module github.com/grafana/grafana/pkg/util/xorm -go 1.20 +go 1.21 require ( + github.com/mattn/go-sqlite3 v1.14.19 + github.com/stretchr/testify v1.8.4 xorm.io/builder v0.3.6 - xorm.io/core v0.7.2 + xorm.io/core v0.7.3 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect - gopkg.in/yaml.v2 v2.2.8 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/go-sql-driver/mysql v1.7.1 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.11.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/pkg/util/xorm/go.sum b/pkg/util/xorm/go.sum index d9708f043a732..0295fff331529 100644 --- a/pkg/util/xorm/go.sum +++ b/pkg/util/xorm/go.sum @@ -1,28 +1,28 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:9wScpmSP5A3Bk8V3XHWUcJmYTh+ZnlHVyc+A4oZYS3Y= github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:56xuuqnHyryaerycW3BfssRdxQstACi0Epw/yC5E2xM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -google.golang.org/appengine v1.6.0 h1:Tfd7cKwKbFRsI8RMAD3oqqw7JPFRrvFlOsfbgVkjOOw= google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= xorm.io/builder v0.3.6 h1:ha28mQ2M+TFx96Hxo+iq6tQgnkC9IZkM6D8w9sKHHF8= xorm.io/builder v0.3.6/go.mod h1:LEFAPISnRzG+zxaxj2vPicRwz67BdhFreKg8yv8/TgU= -xorm.io/core v0.7.2 h1:mEO22A2Z7a3fPaZMk6gKL/jMD80iiyNwRrX5HOv3XLw= -xorm.io/core v0.7.2/go.mod h1:jJfd0UAEzZ4t87nbQYtVjmqpIODugN6PD2D9E+dJvdM= +xorm.io/core v0.7.3 h1:W8ws1PlrnkS1CZU1YWaYLMQcQilwAmQXU0BJDJon+H0= diff --git a/pkg/util/xorm/session_cols.go b/pkg/util/xorm/session_cols.go index f9622f00b8f70..268c865799177 100644 --- a/pkg/util/xorm/session_cols.go +++ b/pkg/util/xorm/session_cols.go @@ -94,6 +94,12 @@ func (session *Session) AllCols() *Session { return session } +// AllCols ask all columns +func (session *Session) Omit(columns ...string) *Session { + session.statement.Omit(columns...) + return session +} + // MustCols specify some columns must use even if they are empty func (session *Session) MustCols(columns ...string) *Session { session.statement.MustCols(columns...) diff --git a/pkg/util/xorm/xorm_test.go b/pkg/util/xorm/xorm_test.go new file mode 100644 index 0000000000000..988528edb6bb7 --- /dev/null +++ b/pkg/util/xorm/xorm_test.go @@ -0,0 +1,17 @@ +package xorm + +import ( + "testing" + + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/require" +) + +func TestNewEngine(t *testing.T) { + t.Run("successfully create a new engine", func(t *testing.T) { + eng, err := NewEngine("sqlite3", "./test.db") + require.NoError(t, err) + require.NotNil(t, eng) + require.Equal(t, "sqlite3", eng.DriverName()) + }) +} diff --git a/pkg/web/context.go b/pkg/web/context.go index 138945f3dcd14..706da507ece5c 100644 --- a/pkg/web/context.go +++ b/pkg/web/context.go @@ -205,3 +205,9 @@ func (ctx *Context) GetCookie(name string) string { val, _ := url.QueryUnescape(cookie.Value) return val } + +// QueryFloat64 returns query result in float64 type. +func (ctx *Context) QueryFloat64(name string) float64 { + n, _ := strconv.ParseFloat(ctx.Query(name), 64) + return n +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000000..c79a34a5c1199 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,65 @@ +import { defineConfig, devices } from '@playwright/test'; +import path, { dirname } from 'path'; + +import { PluginOptions } from '@grafana/plugin-e2e'; + +const testDirRoot = 'e2e/plugin-e2e/plugin-e2e-api-tests/'; + +export default defineConfig<PluginOptions>({ + fullyParallel: true, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: `http://${process.env.HOST || 'localhost'}:${process.env.PORT || 3000}`, + trace: 'retain-on-failure', + httpCredentials: { + username: 'admin', + password: 'admin', + }, + provisioningRootDir: path.join(process.cwd(), process.env.PROV_DIR ?? 'conf/provisioning'), + }, + projects: [ + // Login to Grafana with admin user and store the cookie on disk for use in other tests + { + name: 'authenticate', + testDir: `${dirname(require.resolve('@grafana/plugin-e2e'))}/auth`, + testMatch: [/.*\.js/], + }, + // Login to Grafana with new user with viewer role and store the cookie on disk for use in other tests + { + name: 'createUserAndAuthenticate', + testDir: `${dirname(require.resolve('@grafana/plugin-e2e'))}/auth`, + testMatch: [/.*\.js/], + use: { + user: { + user: 'viewer', + password: 'password', + role: 'Viewer', + }, + }, + }, + // Run all tests in parallel using user with admin role + { + name: 'admin', + testDir: path.join(testDirRoot, '/as-admin-user'), + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/admin.json', + }, + dependencies: ['authenticate'], + }, + // Run all tests in parallel using user with viewer role + { + name: 'viewer', + testDir: path.join(testDirRoot, '/as-viewer-user'), + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/viewer.json', + }, + dependencies: ['createUserAndAuthenticate'], + }, + ], +}); diff --git a/plugins-bundled/internal/input-datasource/.gitignore b/plugins-bundled/internal/input-datasource/.gitignore deleted file mode 100644 index 61c3bc75a05ef..0000000000000 --- a/plugins-bundled/internal/input-datasource/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.yarn diff --git a/plugins-bundled/internal/input-datasource/README.md b/plugins-bundled/internal/input-datasource/README.md deleted file mode 100644 index 00761696d6a0d..0000000000000 --- a/plugins-bundled/internal/input-datasource/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Direct Input Data Source - Bundled Plugin - -This data source lets you define results directly in CSV. The values are stored either in a shared data source, or directly in panels. diff --git a/plugins-bundled/internal/input-datasource/__mocks__/d3-interpolate.ts b/plugins-bundled/internal/input-datasource/__mocks__/d3-interpolate.ts deleted file mode 100644 index f053ebf7976e3..0000000000000 --- a/plugins-bundled/internal/input-datasource/__mocks__/d3-interpolate.ts +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/plugins-bundled/internal/input-datasource/jest.config.js b/plugins-bundled/internal/input-datasource/jest.config.js deleted file mode 100644 index 88c2dfafc09d5..0000000000000 --- a/plugins-bundled/internal/input-datasource/jest.config.js +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - testEnvironment: 'jest-environment-jsdom', - preset: 'ts-jest', - extensionsToTreatAsEsm: ['.ts'], - transform: { - '^.+\\.(t|j)sx?$': [ - 'ts-jest', - { - useESM: true, - isolatedModules: true, - allowJs: true, - }, - ], - }, - moduleNameMapper: { - '^d3-interpolate$': '<rootDir>/__mocks__/d3-interpolate.ts', - }, -}; diff --git a/plugins-bundled/internal/input-datasource/package.json b/plugins-bundled/internal/input-datasource/package.json deleted file mode 100644 index 8229547e4dd83..0000000000000 --- a/plugins-bundled/internal/input-datasource/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@grafana-plugins/input-datasource", - "version": "10.3.0-pre", - "description": "Input Datasource", - "private": true, - "repository": { - "type": "git", - "url": "http://github.com/grafana/grafana.git" - }, - "scripts": { - "build": "yarn test && webpack -c webpack.config.ts --env production", - "dev": "webpack -w -c webpack.config.ts --env development", - "test": "jest -c jest.config.js" - }, - "author": "Grafana Labs", - "devDependencies": { - "@grafana/tsconfig": "^1.2.0-rc1", - "@types/jest": "26.0.15", - "@types/react": "18.0.28", - "copy-webpack-plugin": "11.0.0", - "eslint-webpack-plugin": "4.0.0", - "fork-ts-checker-webpack-plugin": "8.0.0", - "jest": "29.3.1", - "jest-environment-jsdom": "29.3.1", - "swc-loader": "0.2.3", - "ts-jest": "29.0.5", - "ts-node": "10.9.1", - "webpack": "5.76.0" - }, - "dependencies": { - "@grafana/data": "10.3.0-pre", - "@grafana/ui": "10.3.0-pre", - "react": "18.2.0", - "tslib": "2.5.0" - } -} diff --git a/plugins-bundled/internal/input-datasource/src/InputConfigEditor.tsx b/plugins-bundled/internal/input-datasource/src/InputConfigEditor.tsx deleted file mode 100644 index e79558da14c4e..0000000000000 --- a/plugins-bundled/internal/input-datasource/src/InputConfigEditor.tsx +++ /dev/null @@ -1,69 +0,0 @@ -// Libraries -import React, { PureComponent } from 'react'; - -// Types -import { DataSourcePluginOptionsEditorProps, DataFrame, MutableDataFrame } from '@grafana/data'; -import { TableInputCSV } from '@grafana/ui'; - -import { InputOptions } from './types'; -import { dataFrameToCSV } from './utils'; - -interface Props extends DataSourcePluginOptionsEditorProps<InputOptions> {} - -interface State { - text: string; -} - -export class InputConfigEditor extends PureComponent<Props, State> { - state = { - text: '', - }; - - componentDidMount() { - const { options } = this.props; - if (options.jsonData.data) { - const text = dataFrameToCSV(options.jsonData.data); - this.setState({ text }); - } - } - - onSeriesParsed = (data: DataFrame[], text: string) => { - const { options, onOptionsChange } = this.props; - if (!data) { - data = [new MutableDataFrame()]; - } - // data is a property on 'jsonData' - const jsonData = { - ...options.jsonData, - data, - }; - - onOptionsChange({ - ...options, - jsonData, - }); - this.setState({ text }); - }; - - render() { - const { text } = this.state; - return ( - <div> - <div className="gf-form-group"> - <h4>Shared Data:</h4> - <span>Enter CSV</span> - <TableInputCSV text={text} onSeriesParsed={this.onSeriesParsed} width={'100%'} height={200} /> - </div> - - <div className="grafana-info-box"> - This data is stored in the datasource json and is returned to every user in the initial request for any - datasource. This is an appropriate place to enter a few values. Large datasets will perform better in other - datasources. - <br /> - <br /> - <b>NOTE:</b> Changes to this data will only be reflected after a browser refresh. - </div> - </div> - ); - } -} diff --git a/plugins-bundled/internal/input-datasource/src/InputDatasource.test.ts b/plugins-bundled/internal/input-datasource/src/InputDatasource.test.ts deleted file mode 100644 index e28de87908db4..0000000000000 --- a/plugins-bundled/internal/input-datasource/src/InputDatasource.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - DataFrame, - DataFrameDTO, - DataSourceInstanceSettings, - MutableDataFrame, - PluginMeta, - readCSV, -} from '@grafana/data'; - -import InputDatasource, { describeDataFrame } from './InputDatasource'; -import { getQueryOptions } from './testHelpers'; -import { InputOptions, InputQuery } from './types'; - -describe('InputDatasource', () => { - const data = readCSV('a,b,c\n1,2,3\n4,5,6'); - const instanceSettings: DataSourceInstanceSettings<InputOptions> = { - id: 1, - uid: 'xxx', - type: 'x', - name: 'xxx', - meta: {} as PluginMeta, - access: 'proxy', - readOnly: false, - jsonData: { - data, - }, - }; - - describe('when querying', () => { - test('should return the saved data with a query', () => { - const ds = new InputDatasource(instanceSettings); - const options = getQueryOptions<InputQuery>({ - targets: [{ refId: 'Z' }], - }); - - return ds.query(options).then((rsp) => { - expect(rsp.data.length).toBe(1); - - const series: DataFrame = rsp.data[0]; - expect(series.refId).toBe('Z'); - expect(series.fields[0].values).toEqual(data[0].fields[0].values); - }); - }); - }); - - test('DataFrame descriptions', () => { - expect(describeDataFrame([])).toEqual(''); - expect(describeDataFrame(null as unknown as Array<DataFrameDTO | DataFrame>)).toEqual(''); - expect( - describeDataFrame([ - new MutableDataFrame({ - name: 'x', - fields: [{ name: 'a' }], - }), - ]) - ).toEqual('1 Fields, 0 Rows'); - }); -}); diff --git a/plugins-bundled/internal/input-datasource/src/InputDatasource.ts b/plugins-bundled/internal/input-datasource/src/InputDatasource.ts deleted file mode 100644 index 455452693eeb8..0000000000000 --- a/plugins-bundled/internal/input-datasource/src/InputDatasource.ts +++ /dev/null @@ -1,124 +0,0 @@ -// Types -import { - DataQueryRequest, - DataQueryResponse, - TestDataSourceResponse, - DataSourceApi, - DataSourceInstanceSettings, - MetricFindValue, - DataFrame, - DataFrameDTO, - toDataFrame, -} from '@grafana/data'; - -import { InputQuery, InputOptions } from './types'; - -export class InputDatasource extends DataSourceApi<InputQuery, InputOptions> { - data: DataFrame[] = []; - - constructor(instanceSettings: DataSourceInstanceSettings<InputOptions>) { - super(instanceSettings); - - if (instanceSettings.jsonData.data) { - this.data = instanceSettings.jsonData.data.map((v) => toDataFrame(v)); - } - } - - /** - * Convert a query to a simple text string - */ - getQueryDisplayText(query: InputQuery): string { - if (query.data) { - return 'Panel Data: ' + describeDataFrame(query.data); - } - return `Shared Data From: ${this.name} (${describeDataFrame(this.data)})`; - } - - metricFindQuery(query: string, options?: any): Promise<MetricFindValue[]> { - return new Promise((resolve, reject) => { - const names = []; - for (const series of this.data) { - for (const field of series.fields) { - // TODO, match query/options? - names.push({ - text: field.name, - }); - } - } - resolve(names); - }); - } - - query(options: DataQueryRequest<InputQuery>): Promise<DataQueryResponse> { - const results: DataFrame[] = []; - for (const query of options.targets) { - if (query.hide) { - continue; - } - let data = this.data; - if (query.data) { - data = query.data.map((v) => toDataFrame(v)); - } - for (let i = 0; i < data.length; i++) { - results.push({ - ...data[i], - refId: query.refId, - }); - } - } - return Promise.resolve({ data: results }); - } - - testDatasource(): Promise<TestDataSourceResponse> { - return new Promise((resolve, reject) => { - let rowCount = 0; - let info = `${this.data.length} Series:`; - for (const series of this.data) { - const length = series.length; - info += ` [${series.fields.length} Fields, ${length} Rows]`; - rowCount += length; - } - - if (rowCount > 0) { - resolve({ - status: 'success', - message: info, - }); - } - reject({ - status: 'error', - message: 'No Data Entered', - }); - }); - } -} - -function getLength(data?: DataFrameDTO | DataFrame) { - if (!data || !data.fields || !data.fields.length) { - return 0; - } - if ('length' in data) { - return data.length; - } - return data.fields[0].values!.length; -} - -export function describeDataFrame(data: Array<DataFrameDTO | DataFrame>): string { - if (!data || !data.length) { - return ''; - } - if (data.length > 1) { - const count = data.reduce((acc, series) => { - return acc + getLength(series); - }, 0); - return `${data.length} Series, ${count} Rows`; - } - const series = data[0]; - if (!series.fields) { - return 'Missing Fields'; - } - const length = getLength(series); - return `${series.fields.length} Fields, ${length} Rows`; -} - -export default InputDatasource; diff --git a/plugins-bundled/internal/input-datasource/src/InputQueryEditor.tsx b/plugins-bundled/internal/input-datasource/src/InputQueryEditor.tsx deleted file mode 100644 index b20520629f6a4..0000000000000 --- a/plugins-bundled/internal/input-datasource/src/InputQueryEditor.tsx +++ /dev/null @@ -1,86 +0,0 @@ -// Libraries -import React, { PureComponent } from 'react'; - -// Types -import { DataFrame, toCSV, SelectableValue, MutableDataFrame, QueryEditorProps } from '@grafana/data'; -import { Select, TableInputCSV, LinkButton, Icon, InlineField } from '@grafana/ui'; - -import { InputDatasource, describeDataFrame } from './InputDatasource'; -import { InputQuery, InputOptions } from './types'; -import { dataFrameToCSV } from './utils'; - -type Props = QueryEditorProps<InputDatasource, InputQuery, InputOptions>; - -const options = [ - { value: 'panel', label: 'Panel', description: 'Save data in the panel configuration.' }, - { value: 'shared', label: 'Shared', description: 'Save data in the shared datasource object.' }, -]; - -interface State { - text: string; -} - -export class InputQueryEditor extends PureComponent<Props, State> { - state = { - text: '', - }; - - onComponentDidMount() { - const { query } = this.props; - const text = dataFrameToCSV(query.data); - this.setState({ text }); - } - - onSourceChange = (item: SelectableValue<string>) => { - const { datasource, query, onChange, onRunQuery } = this.props; - let data: DataFrame[] | undefined = undefined; - if (item.value === 'panel') { - if (query.data) { - return; - } - data = [...datasource.data]; - if (!data) { - data = [new MutableDataFrame()]; - } - this.setState({ text: toCSV(data) }); - } - onChange({ ...query, data }); - onRunQuery(); - }; - - onSeriesParsed = (data: DataFrame[], text: string) => { - const { query, onChange, onRunQuery } = this.props; - this.setState({ text }); - if (!data) { - data = [new MutableDataFrame()]; - } - onChange({ ...query, data }); - onRunQuery(); - }; - - render() { - const { datasource, query } = this.props; - const { uid, name } = datasource; - const { text } = this.state; - - const selected = query.data ? options[0] : options[1]; - return ( - <div> - <InlineField label="Data" labelWidth={8}> - <> - <Select width={20} options={options} value={selected} onChange={this.onSourceChange} /> - {query.data ? ( - <div style={{ alignSelf: 'center' }}>{describeDataFrame(query.data)}</div> - ) : ( - <LinkButton fill="text" href={`datasources/edit/${uid}/`}> - {name}: {describeDataFrame(datasource.data)}    - <Icon name="pen" /> - </LinkButton> - )} - </> - </InlineField> - {query.data && <TableInputCSV text={text} onSeriesParsed={this.onSeriesParsed} width={'100%'} height={200} />} - </div> - ); - } -} diff --git a/plugins-bundled/internal/input-datasource/src/img/input.svg b/plugins-bundled/internal/input-datasource/src/img/input.svg deleted file mode 100644 index 5d2d8ce6c5bf4..0000000000000 --- a/plugins-bundled/internal/input-datasource/src/img/input.svg +++ /dev/null @@ -1,14 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- Svg Vector Icons : http://www.onlinewebfonts.com/icon --> -<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> -<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve"> -<defs> -<style>.cls-1{fill:url(#linear-gradient);}</style> -<linearGradient id="linear-gradient" x1="50" y1="101.02" x2="50" y2="4.05" gradientTransform="matrix(1, 0, 0, -1, 0, 102)" gradientUnits="userSpaceOnUse"> -<stop offset="0" stop-color="#70b0df"/> -<stop offset="0.5" stop-color="#1b81c5"/> -<stop offset="1" stop-color="#4a98ce"/> -</linearGradient> -</defs> -<g><path class="cls-1" d="M889.5,814.1h-201v50.2H701c20.8,0,37.7,16.9,37.7,37.7c0,20.8-16.9,37.7-37.7,37.7H600.5c-20.8,0-37.7-16.9-37.7-37.7c0-20.8,16.9-37.7,37.7-37.7h12.6v-50.2H110.5C55,814.1,10,769.1,10,713.6V286.5c0-55.5,45-100.5,100.5-100.5h502.6v-50.3h-12.6c-20.8,0-37.7-16.9-37.7-37.7c0-20.8,16.9-37.7,37.7-37.7H701c20.8,0,37.7,16.9,37.7,37.7c0,20.8-16.9,37.7-37.7,37.7h-12.6v50.3h201c55.5,0,100.5,45,100.5,100.5v427.2C990,769.1,945,814.1,889.5,814.1z M562.8,738.8h50.3V261.3h-50.3 M688.5,261.3v477.5h50.3V261.3H688.5z"/></g> -</svg> \ No newline at end of file diff --git a/plugins-bundled/internal/input-datasource/src/module.ts b/plugins-bundled/internal/input-datasource/src/module.ts deleted file mode 100644 index 66765d458c5bc..0000000000000 --- a/plugins-bundled/internal/input-datasource/src/module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { DataSourcePlugin } from '@grafana/data'; - -import { InputConfigEditor } from './InputConfigEditor'; -import { InputDatasource } from './InputDatasource'; -import { InputQueryEditor } from './InputQueryEditor'; -import { InputOptions, InputQuery } from './types'; - -export const plugin = new DataSourcePlugin<InputDatasource, InputQuery, InputOptions>(InputDatasource) - .setConfigEditor(InputConfigEditor) - .setQueryEditor(InputQueryEditor); diff --git a/plugins-bundled/internal/input-datasource/src/plugin.json b/plugins-bundled/internal/input-datasource/src/plugin.json deleted file mode 100644 index 5cd72d9f1c778..0000000000000 --- a/plugins-bundled/internal/input-datasource/src/plugin.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "type": "datasource", - "name": "Direct Input", - "id": "input", - "state": "alpha", - - "metrics": true, - - "info": { - "version": "1.0.0", - "description": "Data source that supports manual table & CSV input", - "author": { - "name": "Grafana Labs", - "url": "https://grafana.com" - }, - "logos": { - "small": "img/input.svg", - "large": "img/input.svg" - } - } -} diff --git a/plugins-bundled/internal/input-datasource/src/testHelpers.ts b/plugins-bundled/internal/input-datasource/src/testHelpers.ts deleted file mode 100644 index d4f0902542c30..0000000000000 --- a/plugins-bundled/internal/input-datasource/src/testHelpers.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { DataQueryRequest, DataQuery, CoreApp, dateTime } from '@grafana/data'; - -export function getQueryOptions<TQuery extends DataQuery>( - options: Partial<DataQueryRequest<TQuery>> -): DataQueryRequest<TQuery> { - const raw = { from: 'now', to: 'now-1h' }; - const range = { from: dateTime(), to: dateTime(), raw: raw }; - - const defaults: DataQueryRequest<TQuery> = { - requestId: 'TEST', - app: CoreApp.Dashboard, - range: range, - targets: [], - scopedVars: {}, - timezone: 'browser', - panelId: 1, - dashboardUID: 'test-uid-1', - interval: '60s', - intervalMs: 60000, - maxDataPoints: 500, - startTime: 0, - }; - - Object.assign(defaults, options); - - return defaults; -} diff --git a/plugins-bundled/internal/input-datasource/src/types.ts b/plugins-bundled/internal/input-datasource/src/types.ts deleted file mode 100644 index 9dc0164a32a2b..0000000000000 --- a/plugins-bundled/internal/input-datasource/src/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { DataQuery, DataSourceJsonData, DataFrameDTO } from '@grafana/data'; - -export interface InputQuery extends DataQuery { - // Data saved in the panel - data?: DataFrameDTO[]; -} - -export interface InputOptions extends DataSourceJsonData { - // Saved in the datasource and download with bootData - data?: DataFrameDTO[]; -} diff --git a/plugins-bundled/internal/input-datasource/src/utils.ts b/plugins-bundled/internal/input-datasource/src/utils.ts deleted file mode 100644 index 7ca7393a8a1b8..0000000000000 --- a/plugins-bundled/internal/input-datasource/src/utils.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { toDataFrame, DataFrameDTO, toCSV } from '@grafana/data'; - -export function dataFrameToCSV(dto?: DataFrameDTO[]) { - if (!dto || !dto.length) { - return ''; - } - return toCSV(dto.map((v) => toDataFrame(v))); -} diff --git a/plugins-bundled/internal/input-datasource/tsconfig.json b/plugins-bundled/internal/input-datasource/tsconfig.json deleted file mode 100644 index 78788d15d8486..0000000000000 --- a/plugins-bundled/internal/input-datasource/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "@grafana/tsconfig", - "include": ["src", "types"], - "compilerOptions": { - "declaration": false, - "rootDir": "./src", - "baseUrl": "./src" - }, - "ts-node": { - "compilerOptions": { - "module": "commonjs", - "target": "es5", - "esModuleInterop": true - }, - "transpileOnly": true - } -} diff --git a/plugins-bundled/internal/input-datasource/webpack.config.ts b/plugins-bundled/internal/input-datasource/webpack.config.ts deleted file mode 100644 index f90c0a0c5391d..0000000000000 --- a/plugins-bundled/internal/input-datasource/webpack.config.ts +++ /dev/null @@ -1,159 +0,0 @@ -import CopyWebpackPlugin from 'copy-webpack-plugin'; -import ESLintPlugin from 'eslint-webpack-plugin'; -import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; -import path from 'path'; -import { Configuration } from 'webpack'; - -const SOURCE_DIR = path.resolve(__dirname, 'src'); -const DIST_DIR = path.resolve(__dirname, 'dist'); -const PLUGIN_ID = require(path.join(SOURCE_DIR, 'plugin.json')).id; - -const config = async (env: Record<string, string>): Promise<Configuration> => ({ - cache: { - type: 'filesystem', - buildDependencies: { - config: [__filename], - }, - }, - - context: path.join(process.cwd(), SOURCE_DIR), - - devtool: env.production ? 'source-map' : 'eval-source-map', - - entry: { - module: path.join(SOURCE_DIR, 'module.ts'), - }, - - externals: [ - 'lodash', - 'jquery', - 'moment', - 'slate', - 'emotion', - '@emotion/react', - '@emotion/css', - 'prismjs', - 'slate-plain-serializer', - '@grafana/slate-react', - 'react', - 'react-dom', - 'react-redux', - 'redux', - 'rxjs', - 'react-router', - 'react-router-dom', - 'd3', - 'angular', - '@grafana/ui', - '@grafana/runtime', - '@grafana/data', - - // Mark legacy SDK imports as external if their name starts with the "grafana/" prefix - ({ request }, callback) => { - const prefix = 'grafana/'; - const hasPrefix = (request: string) => request.indexOf(prefix) === 0; - const stripPrefix = (request: string) => request.substring(prefix.length); - - if (request && hasPrefix(request)) { - return callback(undefined, stripPrefix(request)); - } - - callback(); - }, - ], - - mode: env.production ? 'production' : 'development', - - module: { - rules: [ - { - exclude: /(node_modules)/, - test: /\.[tj]sx?$/, - use: { - loader: 'swc-loader', - options: { - jsc: { - baseUrl: '.', - target: 'es2015', - loose: false, - parser: { - syntax: 'typescript', - tsx: true, - decorators: false, - dynamicImport: true, - }, - }, - }, - }, - }, - { - test: /\.(png|jpe?g|gif|svg)$/, - type: 'asset/resource', - generator: { - // Keep publicPath relative for host.com/grafana/ deployments - publicPath: `public/plugins/${PLUGIN_ID}/img/`, - outputPath: 'img/', - filename: Boolean(env.production) ? '[hash][ext]' : '[name][ext]', - }, - }, - { - test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/, - type: 'asset/resource', - generator: { - // Keep publicPath relative for host.com/grafana/ deployments - publicPath: `public/plugins/${PLUGIN_ID}/fonts`, - outputPath: 'fonts/', - filename: Boolean(env.production) ? '[hash][ext]' : '[name][ext]', - }, - }, - ], - }, - - output: { - clean: { - keep: new RegExp(`.*?_(amd64|arm(64)?)(.exe)?`), - }, - filename: '[name].js', - library: { - type: 'amd', - }, - path: DIST_DIR, - publicPath: '/', - }, - - plugins: [ - new CopyWebpackPlugin({ - patterns: [ - { from: '../README.md', to: '.', force: true, context: SOURCE_DIR }, - { from: 'plugin.json', to: '.', context: SOURCE_DIR }, - { from: '**/*.json', to: '.', context: SOURCE_DIR }, - { from: '**/*.svg', to: '.', noErrorOnMissing: true, context: SOURCE_DIR }, // Optional - { from: '**/*.png', to: '.', noErrorOnMissing: true, context: SOURCE_DIR }, // Optional - { from: '**/*.html', to: '.', noErrorOnMissing: true, context: SOURCE_DIR }, // Optional - { from: 'img/**/*', to: '.', noErrorOnMissing: true, context: SOURCE_DIR }, // Optional - { from: 'libs/**/*', to: '.', noErrorOnMissing: true, context: SOURCE_DIR }, // Optional - { from: 'static/**/*', to: '.', noErrorOnMissing: true, context: SOURCE_DIR }, // Optional - ], - }), - new ForkTsCheckerWebpackPlugin({ - async: Boolean(env.development), - issue: { - include: [{ file: '**/*.{ts,tsx}' }], - }, - typescript: { configFile: path.join(process.cwd(), 'tsconfig.json') }, - }), - new ESLintPlugin({ - extensions: ['.ts', '.tsx'], - lintDirtyModulesOnly: Boolean(env.development), // don't lint on start, only lint changed files - }), - ], - - resolve: { - extensions: ['.js', '.jsx', '.ts', '.tsx'], - // handle resolving "rootDir" paths - modules: [path.resolve(process.cwd(), 'src'), 'node_modules'], - unsafeCache: true, - }, -}); - -export default config; diff --git a/project.json b/project.json new file mode 100644 index 0000000000000..fe31d5b90ddd5 --- /dev/null +++ b/project.json @@ -0,0 +1,35 @@ +{ + "name": "grafana", + "$schema": "node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "targets": { + "start": { + "dependsOn": [ + "themes-generate", + { + "projects": ["@grafana-plugins/**"], + "target": "build" + } + ] + }, + "build": { + "dependsOn": [ + "themes-generate", + { + "projects": ["@grafana-plugins/**"], + "target": "build" + } + ], + "outputs": ["{workspaceRoot}/public/build"], + "cache": true + }, + "themes-generate": { + "outputs": [ + "{workspaceRoot}/public/sass/_variables.generated.scss", + "{workspaceRoot}/public/sass/_variables.dark.generated.scss", + "{workspaceRoot}/public/sass/_variables.light.generated.scss" + ], + "cache": true + } + } +} diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index 9763217df003c..301069e8f9694 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -318,6 +318,41 @@ } } }, + "/access-control/teams/roles/search": { + "post": { + "description": "Lists the roles that have been directly assigned to the given teams.\n\nYou need to have a permission with action `teams.roles:read` and scope `teams:id:*`.", + "tags": [ + "access_control", + "enterprise" + ], + "summary": "List roles assigned to multiple teams.", + "operationId": "listTeamsRoles", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/RolesSearchQuery" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/listTeamsRolesResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/access-control/teams/{teamId}/roles": { "get": { "description": "You need to have a permission with action `teams.roles:read` and scope `teams:id:\u003cteam ID\u003e`.", @@ -473,6 +508,41 @@ } } }, + "/access-control/users/roles/search": { + "post": { + "description": "Lists the roles that have been directly assigned to the given users. The list does not include built-in roles (Viewer, Editor, Admin or Grafana Admin), and it does not include roles that have been inherited from a team.\n\nYou need to have a permission with action `users.roles:read` and scope `users:id:*`.", + "tags": [ + "access_control", + "enterprise" + ], + "summary": "List roles assigned to multiple users.", + "operationId": "listUsersRoles", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/RolesSearchQuery" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/listUsersRolesResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/access-control/users/{userId}/roles": { "get": { "description": "Lists the roles that have been directly assigned to a given user. The list does not include built-in roles (Viewer, Editor, Admin or Grafana Admin), and it does not include roles that have been inherited from a team.\n\nYou need to have a permission with action `users.roles:read` and scope `users:id:\u003cuser ID\u003e`.", @@ -1274,13 +1344,6 @@ "operationId": "renderReportPDF", "deprecated": true, "parameters": [ - { - "type": "integer", - "format": "int64", - "name": "DashboardID", - "in": "path", - "required": true - }, { "type": "integer", "format": "int64", @@ -1881,6 +1944,10 @@ "type": "integer", "format": "int64" }, + "active_anonymous_devices": { + "type": "integer", + "format": "int64" + }, "active_users": { "type": "integer", "format": "int64" @@ -2080,7 +2147,7 @@ "format": "int64" }, "password": { - "type": "string" + "$ref": "#/definitions/Password" } } }, @@ -2201,7 +2268,7 @@ "type": "object", "properties": { "password": { - "type": "string" + "$ref": "#/definitions/Password" } } }, @@ -2213,200 +2280,6 @@ } } }, - "AlertListItemDTO": { - "type": "object", - "properties": { - "dashboardId": { - "type": "integer", - "format": "int64" - }, - "dashboardSlug": { - "type": "string" - }, - "dashboardUid": { - "type": "string" - }, - "evalData": { - "$ref": "#/definitions/Json" - }, - "evalDate": { - "type": "string", - "format": "date-time" - }, - "executionError": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int64" - }, - "name": { - "type": "string" - }, - "newStateDate": { - "type": "string", - "format": "date-time" - }, - "panelId": { - "type": "integer", - "format": "int64" - }, - "state": { - "$ref": "#/definitions/AlertStateType" - }, - "url": { - "type": "string" - } - } - }, - "AlertNotification": { - "type": "object", - "properties": { - "created": { - "type": "string", - "format": "date-time" - }, - "disableResolveMessage": { - "type": "boolean" - }, - "frequency": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int64" - }, - "isDefault": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "secureFields": { - "type": "object", - "additionalProperties": { - "type": "boolean" - } - }, - "sendReminder": { - "type": "boolean" - }, - "settings": { - "$ref": "#/definitions/Json" - }, - "type": { - "type": "string" - }, - "uid": { - "type": "string" - }, - "updated": { - "type": "string", - "format": "date-time" - } - } - }, - "AlertNotificationLookup": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "isDefault": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - }, - "uid": { - "type": "string" - } - } - }, - "AlertStateInfoDTO": { - "type": "object", - "properties": { - "dashboardId": { - "type": "integer", - "format": "int64" - }, - "id": { - "type": "integer", - "format": "int64" - }, - "newStateDate": { - "type": "string", - "format": "date-time" - }, - "panelId": { - "type": "integer", - "format": "int64" - }, - "state": { - "$ref": "#/definitions/AlertStateType" - } - } - }, - "AlertStateType": { - "type": "string" - }, - "AlertTestCommand": { - "type": "object", - "properties": { - "dashboard": { - "$ref": "#/definitions/Json" - }, - "panelId": { - "type": "integer", - "format": "int64" - } - } - }, - "AlertTestResult": { - "type": "object", - "properties": { - "conditionEvals": { - "type": "string" - }, - "error": { - "type": "string" - }, - "firing": { - "type": "boolean" - }, - "logs": { - "type": "array", - "items": { - "$ref": "#/definitions/AlertTestResultLog" - } - }, - "matches": { - "type": "array", - "items": { - "$ref": "#/definitions/EvalMatch" - } - }, - "state": { - "$ref": "#/definitions/AlertStateType" - }, - "timeMs": { - "type": "string" - } - } - }, - "AlertTestResultLog": { - "type": "object", - "properties": { - "data": {}, - "message": { - "type": "string" - } - } - }, "Annotation": { "type": "object", "properties": { @@ -2953,10 +2826,10 @@ "type": "object", "properties": { "newPassword": { - "type": "string" + "$ref": "#/definitions/Password" }, "oldPassword": { - "type": "string" + "$ref": "#/definitions/Password" } } }, @@ -3102,60 +2975,25 @@ } } }, - "CreateAlertNotificationCommand": { + "CreateCorrelationCommand": { + "description": "CreateCorrelationCommand is the command for creating a correlation", "type": "object", "properties": { - "disableResolveMessage": { - "type": "boolean" + "config": { + "$ref": "#/definitions/CorrelationConfig" }, - "frequency": { - "type": "string" + "description": { + "description": "Optional description of the correlation", + "type": "string", + "example": "Logs to Traces" }, - "isDefault": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "secureSettings": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "sendReminder": { - "type": "boolean" - }, - "settings": { - "$ref": "#/definitions/Json" - }, - "type": { - "type": "string" - }, - "uid": { - "type": "string" - } - } - }, - "CreateCorrelationCommand": { - "description": "CreateCorrelationCommand is the command for creating a correlation", - "type": "object", - "properties": { - "config": { - "$ref": "#/definitions/CorrelationConfig" - }, - "description": { - "description": "Optional description of the correlation", - "type": "string", - "example": "Logs to Traces" - }, - "label": { - "description": "Optional label identifying the correlation", - "type": "string", - "example": "My label" - }, - "provisioned": { - "description": "True if correlation was created with provisioning. This makes it read-only.", + "label": { + "description": "Optional label identifying the correlation", + "type": "string", + "example": "My label" + }, + "provisioned": { + "description": "True if correlation was created with provisioning. This makes it read-only.", "type": "boolean" }, "targetUID": { @@ -3184,8 +3022,12 @@ "dashboard" ], "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional", + "type": "string" + }, "dashboard": { - "$ref": "#/definitions/Json" + "$ref": "#/definitions/Unstructured" }, "deleteKey": { "description": "Unique key used to delete the snapshot. It is different from the `key` so that only the creator can delete the snapshot. Required if `external` is `true`.", @@ -3206,6 +3048,10 @@ "description": "Define the unique key. Required if `external` is `true`.", "type": "string" }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional", + "type": "string" + }, "name": { "description": "Snapshot name", "type": "string" @@ -3544,6 +3390,41 @@ } } }, + "DashboardCreateCommand": { + "description": "These are the values expected to be sent from an end user\n+k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object", + "type": "object", + "required": [ + "dashboard" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional", + "type": "string" + }, + "dashboard": { + "$ref": "#/definitions/Unstructured" + }, + "expires": { + "description": "When the snapshot should expire in seconds in seconds. Default is never to expire.", + "type": "integer", + "format": "int64", + "default": 0 + }, + "external": { + "description": "these are passed when storing an external snapshot ref\nSave the snapshot on an external server rather than locally.", + "type": "boolean", + "default": false + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional", + "type": "string" + }, + "name": { + "description": "Snapshot name", + "type": "string" + } + } + }, "DashboardFullWithMeta": { "type": "object", "properties": { @@ -3960,6 +3841,32 @@ } } }, + "DeviceSearchHitDTO": { + "type": "object", + "properties": { + "clientIp": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "deviceId": { + "type": "string" + }, + "lastSeenAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "userAgent": { + "type": "string" + } + } + }, "DsAccess": { "type": "string" }, @@ -4042,23 +3949,6 @@ "description": "ErrorSource type defines the source of the error", "type": "string" }, - "EvalMatch": { - "type": "object", - "properties": { - "metric": { - "type": "string" - }, - "tags": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "value": { - "type": "string" - } - } - }, "ExplorePanelsState": { "description": "This is an object constructed with the keys as the values of the enum VisType and the value being a bag of properties" }, @@ -4389,11 +4279,20 @@ }, "typeVersion": { "$ref": "#/definitions/FrameTypeVersion" + }, + "uniqueRowIdFields": { + "description": "Array of field indices which values create a unique id for each row. Ideally this should be globally unique ID\nbut that isn't guarantied. Should help with keeping track and deduplicating rows in visualizations, especially\nwith streaming data with frequent updates.", + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "example": "TraceID in Tempo, table name + primary key in SQL" } } }, "FrameType": { - "description": "A FrameType string, when present in a frame's metadata, asserts that the\nframe's structure conforms to the FrameType's specification.\nThis property is currently optional, so FrameType may be FrameTypeUnknown even if the properties of\nthe Frame correspond to a defined FrameType.", + "description": "A FrameType string, when present in a frame's metadata, asserts that the\nframe's structure conforms to the FrameType's specification.\nThis property is currently optional, so FrameType may be FrameTypeUnknown even if the properties of\nthe Frame correspond to a defined FrameType.\n+enum", "type": "string" }, "FrameTypeVersion": { @@ -4669,8 +4568,8 @@ } }, "JSONWebKey": { + "description": "JSONWebKey represents a public or private key in JWK format. It can be\nmarshaled into JSON and unmarshaled from JSON.", "type": "object", - "title": "JSONWebKey represents a public or private key in JWK format.", "properties": { "Algorithm": { "description": "Key algorithm, parsed from `alg` header.", @@ -4703,7 +4602,7 @@ "$ref": "#/definitions/URL" }, "Key": { - "description": "Cryptographic key, can be a symmetric or asymmetric key." + "description": "Key is the Go in-memory representation of this key. It must have one\nof these types:\ned25519.PublicKey\ned25519.PrivateKey\necdsa.PublicKey\necdsa.PrivateKey\nrsa.PublicKey\nrsa.PrivateKey\n[]byte (a symmetric key)\n\nWhen marshaling this JSONWebKey into JSON, the \"kty\" header parameter\nwill be automatically set based on the type of this field." }, "KeyID": { "description": "Key identifier, parsed from `kid` header.", @@ -4723,82 +4622,6 @@ "type": "integer", "format": "int64" }, - "LegacyAlert": { - "type": "object", - "properties": { - "Created": { - "type": "string", - "format": "date-time" - }, - "DashboardID": { - "type": "integer", - "format": "int64" - }, - "EvalData": { - "$ref": "#/definitions/Json" - }, - "ExecutionError": { - "type": "string" - }, - "For": { - "$ref": "#/definitions/Duration" - }, - "Frequency": { - "type": "integer", - "format": "int64" - }, - "Handler": { - "type": "integer", - "format": "int64" - }, - "ID": { - "type": "integer", - "format": "int64" - }, - "Message": { - "type": "string" - }, - "Name": { - "type": "string" - }, - "NewStateDate": { - "type": "string", - "format": "date-time" - }, - "OrgID": { - "type": "integer", - "format": "int64" - }, - "PanelID": { - "type": "integer", - "format": "int64" - }, - "Settings": { - "$ref": "#/definitions/Json" - }, - "Severity": { - "type": "string" - }, - "Silenced": { - "type": "boolean" - }, - "State": { - "$ref": "#/definitions/AlertStateType" - }, - "StateChanges": { - "type": "integer", - "format": "int64" - }, - "Updated": { - "type": "string", - "format": "date-time" - }, - "Version": { - "type": "integer", - "format": "int64" - } - } - }, "LibraryElementArrayResponse": { "type": "object", "title": "LibraryElementArrayResponse is a response struct for an array of LibraryElementDTO.", @@ -5174,39 +4997,6 @@ "format": "int64", "title": "NoticeSeverity is a type for the Severity property of a Notice." }, - "NotificationTestCommand": { - "type": "object", - "properties": { - "disableResolveMessage": { - "type": "boolean" - }, - "frequency": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int64" - }, - "name": { - "type": "string" - }, - "secureSettings": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "sendReminder": { - "type": "boolean" - }, - "settings": { - "$ref": "#/definitions/Json" - }, - "type": { - "type": "string" - } - } - }, "ObjectIdentifier": { "type": "array", "title": "An ObjectIdentifier represents an ASN.1 OBJECT IDENTIFIER.", @@ -5215,6 +5005,20 @@ "format": "int64" } }, + "ObjectMatcher": { + "type": "array", + "title": "ObjectMatcher is a matcher that can be used to filter alerts.", + "items": { + "type": "string" + } + }, + "ObjectMatchers": { + "type": "array", + "title": "ObjectMatchers is a list of matchers that can be used to filter alerts.", + "items": { + "$ref": "#/definitions/ObjectMatcher" + } + }, "OrgDTO": { "type": "object", "properties": { @@ -5295,6 +5099,9 @@ } } }, + "Password": { + "type": "string" + }, "PatchAnnotationsCmd": { "type": "object", "properties": { @@ -5417,26 +5224,6 @@ } } }, - "PauseAlertCommand": { - "type": "object", - "properties": { - "alertId": { - "type": "integer", - "format": "int64" - }, - "paused": { - "type": "boolean" - } - } - }, - "PauseAllAlertsCommand": { - "type": "object", - "properties": { - "paused": { - "type": "boolean" - } - } - }, "Permission": { "type": "object", "title": "Permission is the model for access control permissions.", @@ -5811,7 +5598,7 @@ "type": "object", "title": "QueryDataResponse contains the results from a QueryDataRequest.", "properties": { - "Responses": { + "results": { "$ref": "#/definitions/Responses" } } @@ -6132,6 +5919,9 @@ "templateVars": { "type": "object" }, + "uid": { + "type": "string" + }, "updated": { "type": "string", "format": "date-time" @@ -6194,9 +5984,6 @@ "ReportEmail": { "type": "object", "properties": { - "email": { - "type": "string" - }, "emails": { "description": "Comma-separated list of emails to which to send the report to.", "type": "string" @@ -6229,9 +6016,6 @@ "ReportSchedule": { "type": "object", "properties": { - "day": { - "type": "string" - }, "dayOfMonth": { "type": "string" }, @@ -6242,10 +6026,6 @@ "frequency": { "type": "string" }, - "hour": { - "type": "integer", - "format": "int64" - }, "intervalAmount": { "type": "integer", "format": "int64" @@ -6253,10 +6033,6 @@ "intervalFrequency": { "type": "string" }, - "minute": { - "type": "integer", - "format": "int64" - }, "startDate": { "type": "string", "format": "date-time" @@ -6402,21 +6178,29 @@ } } }, - "SSOSettings": { + "RolesSearchQuery": { "type": "object", "properties": { - "id": { - "type": "string" + "includeHidden": { + "type": "boolean" }, - "provider": { - "type": "string" + "orgId": { + "type": "integer", + "format": "int64" }, - "settings": { - "type": "object", - "additionalProperties": {} + "teamIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } }, - "source": { - "$ref": "#/definitions/SettingsSource" + "userIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } } } }, @@ -6453,6 +6237,29 @@ } } }, + "SearchDeviceQueryResult": { + "type": "object", + "properties": { + "devices": { + "type": "array", + "items": { + "$ref": "#/definitions/DeviceSearchHitDTO" + } + }, + "page": { + "type": "integer", + "format": "int64" + }, + "perPage": { + "type": "integer", + "format": "int64" + }, + "totalCount": { + "type": "integer", + "format": "int64" + } + } + }, "SearchOrgServiceAccountsResult": { "description": "swagger: model", "type": "object", @@ -6716,6 +6523,25 @@ } } }, + "SetResourcePermissionCommand": { + "type": "object", + "properties": { + "builtInRole": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "teamId": { + "type": "integer", + "format": "int64" + }, + "userId": { + "type": "integer", + "format": "int64" + } + } + }, "SetRoleAssignmentsCommand": { "type": "object", "properties": { @@ -6768,10 +6594,6 @@ } } }, - "SettingsSource": { - "type": "integer", - "format": "int64" - }, "ShareType": { "type": "string" }, @@ -7064,6 +6886,10 @@ "account": { "type": "string" }, + "anonymousRatio": { + "type": "integer", + "format": "int64" + }, "company": { "type": "string" }, @@ -7221,6 +7047,21 @@ "Type": { "type": "string" }, + "TypeMeta": { + "description": "+k8s:deepcopy-gen=false", + "type": "object", + "title": "TypeMeta describes an individual object in an API response or request\nwith strings representing the type of the object and its API schema version.\nStructures that are versioned or persisted should inline TypeMeta.", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional", + "type": "string" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional", + "type": "string" + } + } + }, "URL": { "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "type": "object", @@ -7261,77 +7102,14 @@ } } }, - "UpdateAlertNotificationCommand": { + "Unstructured": { + "description": "Unstructured allows objects that do not have Golang structs registered to be manipulated\ngenerically.", "type": "object", "properties": { - "disableResolveMessage": { - "type": "boolean" - }, - "frequency": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int64" - }, - "isDefault": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "secureSettings": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "sendReminder": { - "type": "boolean" - }, - "settings": { - "$ref": "#/definitions/Json" - }, - "type": { - "type": "string" - }, - "uid": { - "type": "string" - } - } - }, - "UpdateAlertNotificationWithUidCommand": { - "type": "object", - "properties": { - "disableResolveMessage": { - "type": "boolean" - }, - "frequency": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "secureSettings": { + "Object": { + "description": "Object is a JSON compatible map with string, float, int, bool, []interface{},\nor map[string]interface{} children.", "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "sendReminder": { - "type": "boolean" - }, - "settings": { - "$ref": "#/definitions/Json" - }, - "type": { - "type": "string" - }, - "uid": { - "type": "string" + "additionalProperties": {} } } }, @@ -7792,6 +7570,9 @@ "theme": { "type": "string" }, + "uid": { + "type": "string" + }, "updatedAt": { "type": "string", "format": "date-time" @@ -7835,6 +7616,9 @@ }, "name": { "type": "string" + }, + "uid": { + "type": "string" } } }, @@ -8016,6 +7800,25 @@ "type": "string" } } + }, + "setPermissionCommand": { + "type": "object", + "properties": { + "permission": { + "type": "string" + } + } + }, + "setPermissionsCommand": { + "type": "object", + "properties": { + "permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/SetResourcePermissionCommand" + } + } + } } }, "responses": { @@ -8263,28 +8066,6 @@ } } }, - "deleteAlertNotificationChannelResponse": { - "description": "", - "schema": { - "type": "object", - "required": [ - "id", - "message" - ], - "properties": { - "id": { - "description": "ID Identifier of the deleted notification channel.", - "type": "integer", - "format": "int64", - "example": 65 - }, - "message": { - "description": "Message Message of the deleted notificatiton channel.", - "type": "string" - } - } - } - }, "deleteCorrelationResponse": { "description": "", "schema": { @@ -8381,6 +8162,12 @@ } } }, + "devicesSearchResponse": { + "description": "", + "schema": { + "$ref": "#/definitions/SearchDeviceQueryResult" + } + }, "folderResponse": { "description": "", "schema": { @@ -8420,45 +8207,6 @@ "$ref": "#/definitions/Status" } }, - "getAlertNotificationChannelResponse": { - "description": "", - "schema": { - "$ref": "#/definitions/AlertNotification" - } - }, - "getAlertNotificationChannelsResponse": { - "description": "", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/AlertNotification" - } - } - }, - "getAlertNotificationLookupResponse": { - "description": "", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/AlertNotificationLookup" - } - } - }, - "getAlertResponse": { - "description": "", - "schema": { - "$ref": "#/definitions/LegacyAlert" - } - }, - "getAlertsResponse": { - "description": "", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/AlertListItemDTO" - } - } - }, "getAllRolesResponse": { "description": "", "schema": { @@ -8531,15 +8279,6 @@ "getDashboardSnapshotResponse": { "description": "" }, - "getDashboardStatesResponse": { - "description": "", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/AlertStateInfoDTO" - } - } - }, "getDashboardsTagsResponse": { "description": "", "schema": { @@ -8797,6 +8536,27 @@ "$ref": "#/definitions/RoleDTO" } }, + "getSSOSettingsResponse": { + "description": "", + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "settings": { + "type": "object", + "additionalProperties": {} + }, + "source": { + "type": "string" + } + } + } + }, "getSharingOptionsResponse": { "description": "", "schema": { @@ -8975,6 +8735,30 @@ } } }, + "listSSOSettingsResponse": { + "description": "", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "settings": { + "type": "object", + "additionalProperties": {} + }, + "source": { + "type": "string" + } + } + } + } + }, "listSortOptionsResponse": { "description": "", "schema": { @@ -8995,6 +8779,18 @@ } } }, + "listTeamsRolesResponse": { + "description": "", + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/RoleDTO" + } + } + } + }, "listTokensResponse": { "description": "", "schema": { @@ -9004,6 +8800,18 @@ } } }, + "listUsersRolesResponse": { + "description": "", + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/RoleDTO" + } + } + } + }, "notFoundError": { "description": "NotFoundError is returned when the requested resource was not found.", "schema": { @@ -9022,53 +8830,6 @@ "$ref": "#/definitions/SuccessResponseBody" } }, - "pauseAlertResponse": { - "description": "", - "schema": { - "type": "object", - "required": [ - "alertId", - "message" - ], - "properties": { - "alertId": { - "type": "integer", - "format": "int64" - }, - "message": { - "type": "string" - }, - "state": { - "description": "Alert result state\nrequired true", - "type": "string" - } - } - } - }, - "pauseAlertsResponse": { - "description": "", - "schema": { - "type": "object", - "required": [ - "alertsAffected", - "message" - ], - "properties": { - "alertsAffected": { - "description": "AlertsAffected is the number of the affected alerts.", - "type": "integer", - "format": "int64" - }, - "message": { - "type": "string" - }, - "state": { - "description": "Alert result state\nrequired true", - "type": "string" - } - } - } - }, "postAPIkeyResponse": { "description": "", "schema": { @@ -9281,12 +9042,6 @@ "$ref": "#/definitions/RoleAssignmentsDTO" } }, - "testAlertResponse": { - "description": "", - "schema": { - "$ref": "#/definitions/AlertTestResult" - } - }, "unauthorisedError": { "description": "UnauthorizedError is returned when the request is not authenticated.", "schema": { diff --git a/public/api-merged.json b/public/api-merged.json index 997789bc40764..af87a514f7444 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -318,6 +318,41 @@ } } }, + "/access-control/teams/roles/search": { + "post": { + "description": "Lists the roles that have been directly assigned to the given teams.\n\nYou need to have a permission with action `teams.roles:read` and scope `teams:id:*`.", + "tags": [ + "access_control", + "enterprise" + ], + "summary": "List roles assigned to multiple teams.", + "operationId": "listTeamsRoles", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/RolesSearchQuery" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/listTeamsRolesResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/access-control/teams/{teamId}/roles": { "get": { "description": "You need to have a permission with action `teams.roles:read` and scope `teams:id:\u003cteam ID\u003e`.", @@ -473,6 +508,41 @@ } } }, + "/access-control/users/roles/search": { + "post": { + "description": "Lists the roles that have been directly assigned to the given users. The list does not include built-in roles (Viewer, Editor, Admin or Grafana Admin), and it does not include roles that have been inherited from a team.\n\nYou need to have a permission with action `users.roles:read` and scope `users:id:*`.", + "tags": [ + "access_control", + "enterprise" + ], + "summary": "List roles assigned to multiple users.", + "operationId": "listUsersRoles", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/RolesSearchQuery" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/listUsersRolesResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/access-control/users/{userId}/roles": { "get": { "description": "Lists the roles that have been directly assigned to a given user. The list does not include built-in roles (Viewer, Editor, Admin or Grafana Admin), and it does not include roles that have been inherited from a team.\n\nYou need to have a permission with action `users.roles:read` and scope `users:id:\u003cuser ID\u003e`.", @@ -639,21 +709,24 @@ } } }, - "/admin/ldap-sync-status": { + "/access-control/{resource}/description": { "get": { - "description": "You need to have a permission with action `ldap.status:read`.", "tags": [ - "ldap_debug", - "enterprise" + "access_control" + ], + "summary": "Get a description of a resource's access control properties.", + "operationId": "getResourceDescription", + "parameters": [ + { + "type": "string", + "name": "resource", + "in": "path", + "required": true + } ], - "summary": "Returns the current state of the LDAP background sync integration.", - "operationId": "getSyncStatus", "responses": { "200": { - "$ref": "#/responses/getSyncStatusResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" + "$ref": "#/responses/resourcePermissionsDescription" }, "403": { "$ref": "#/responses/forbiddenError" @@ -664,211 +737,295 @@ } } }, - "/admin/ldap/reload": { - "post": { - "security": [ + "/access-control/{resource}/{resourceID}": { + "get": { + "tags": [ + "access_control" + ], + "summary": "Get permissions for a resource.", + "operationId": "getResourcePermissions", + "parameters": [ { - "basic": [] + "type": "string", + "name": "resource", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "resourceID", + "in": "path", + "required": true } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.config:reload`.", - "tags": [ - "admin_ldap" - ], - "summary": "Reloads the LDAP configuration.", - "operationId": "reloadLDAPCfg", "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" + "$ref": "#/responses/getResourcePermissionsResponse" }, "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } - } - }, - "/admin/ldap/status": { - "get": { - "security": [ + }, + "post": { + "description": "Assigns permissions for a resource by a given type (`:resource`) and `:resourceID` to one or many\nassignment types. Allowed resources are `datasources`, `teams`, `dashboards`, `folders`, and `serviceaccounts`.\nRefer to the `/access-control/{resource}/description` endpoint for allowed Permissions.", + "tags": [ + "access_control" + ], + "summary": "Set resource permissions.", + "operationId": "setResourcePermissions", + "parameters": [ { - "basic": [] + "type": "string", + "name": "resource", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "resourceID", + "in": "path", + "required": true + }, + { + "name": "Body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/setPermissionsCommand" + } } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.status:read`.", - "tags": [ - "admin_ldap" - ], - "summary": "Attempts to connect to all the configured LDAP servers and returns information on whenever they're available or not.", - "operationId": "getLDAPStatus", "responses": { "200": { "$ref": "#/responses/okResponse" }, - "401": { - "$ref": "#/responses/unauthorisedError" + "400": { + "$ref": "#/responses/badRequestError" }, "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/admin/ldap/sync/{user_id}": { + "/access-control/{resource}/{resourceID}/builtInRoles/{builtInRole}": { "post": { - "security": [ - { - "basic": [] - } - ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.user:sync`.", + "description": "Assigns permissions for a resource by a given type (`:resource`) and `:resourceID` to a built-in role.\nAllowed resources are `datasources`, `teams`, `dashboards`, `folders`, and `serviceaccounts`.\nRefer to the `/access-control/{resource}/description` endpoint for allowed Permissions.", "tags": [ - "admin_ldap" + "access_control" ], - "summary": "Enables a single Grafana user to be synchronized against LDAP.", - "operationId": "postSyncUserWithLDAP", + "summary": "Set resource permissions for a built-in role.", + "operationId": "setResourcePermissionsForBuiltInRole", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "user_id", + "type": "string", + "name": "resource", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "resourceID", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "builtInRole", "in": "path", "required": true + }, + { + "name": "Body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/setPermissionCommand" + } } ], "responses": { "200": { "$ref": "#/responses/okResponse" }, - "401": { - "$ref": "#/responses/unauthorisedError" + "400": { + "$ref": "#/responses/badRequestError" }, "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/admin/ldap/{user_name}": { - "get": { - "security": [ - { - "basic": [] - } - ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.user:read`.", + "/access-control/{resource}/{resourceID}/teams/{teamID}": { + "post": { + "description": "Assigns permissions for a resource by a given type (`:resource`) and `:resourceID` to a team.\nAllowed resources are `datasources`, `teams`, `dashboards`, `folders`, and `serviceaccounts`.\nRefer to the `/access-control/{resource}/description` endpoint for allowed Permissions.", "tags": [ - "admin_ldap" + "access_control" ], - "summary": "Finds an user based on a username in LDAP. This helps illustrate how would the particular user be mapped in Grafana when synced.", - "operationId": "getUserFromLDAP", + "summary": "Set resource permissions for a team.", + "operationId": "setResourcePermissionsForTeam", "parameters": [ { "type": "string", - "name": "user_name", + "name": "resource", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "resourceID", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "name": "teamID", "in": "path", "required": true + }, + { + "name": "Body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/setPermissionCommand" + } } ], "responses": { "200": { "$ref": "#/responses/okResponse" }, - "401": { - "$ref": "#/responses/unauthorisedError" + "400": { + "$ref": "#/responses/badRequestError" }, "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/admin/pause-all-alerts": { + "/access-control/{resource}/{resourceID}/users/{userID}": { "post": { - "security": [ - { - "basic": [] - } - ], + "description": "Assigns permissions for a resource by a given type (`:resource`) and `:resourceID` to a user or a service account.\nAllowed resources are `datasources`, `teams`, `dashboards`, `folders`, and `serviceaccounts`.\nRefer to the `/access-control/{resource}/description` endpoint for allowed Permissions.", "tags": [ - "admin" + "access_control" ], - "summary": "Pause/unpause all (legacy) alerts.", - "operationId": "pauseAllAlerts", + "summary": "Set resource permissions for a user.", + "operationId": "setResourcePermissionsForUser", "parameters": [ { - "name": "body", + "type": "string", + "name": "resource", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "resourceID", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "name": "userID", + "in": "path", + "required": true + }, + { + "name": "Body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/PauseAllAlertsCommand" + "$ref": "#/definitions/setPermissionCommand" } } ], "responses": { "200": { - "$ref": "#/responses/pauseAlertsResponse" + "$ref": "#/responses/okResponse" }, - "401": { - "$ref": "#/responses/unauthorisedError" + "400": { + "$ref": "#/responses/badRequestError" }, "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/admin/provisioning/access-control/reload": { - "post": { + "/admin/ldap-sync-status": { + "get": { + "description": "You need to have a permission with action `ldap.status:read`.", "tags": [ - "access_control_provisioning", + "ldap_debug", "enterprise" ], - "summary": "You need to have a permission with action `provisioning:reload` with scope `provisioners:accesscontrol`.", - "operationId": "adminProvisioningReloadAccessControl", + "summary": "Returns the current state of the LDAP background sync integration.", + "operationId": "getSyncStatus", "responses": { - "202": { - "$ref": "#/responses/acceptedResponse" + "200": { + "$ref": "#/responses/getSyncStatusResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, "403": { "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" } } } }, - "/admin/provisioning/dashboards/reload": { + "/admin/ldap/reload": { "post": { "security": [ { "basic": [] } ], - "description": "Reloads the provisioning config files for dashboards again. It won’t return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `provisioning:reload` and scope `provisioners:dashboards`.", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.config:reload`.", "tags": [ - "admin_provisioning" + "admin_ldap" ], - "summary": "Reload dashboard provisioning configurations.", - "operationId": "adminProvisioningReloadDashboards", + "summary": "Reloads the LDAP configuration.", + "operationId": "reloadLDAPCfg", "responses": { "200": { "$ref": "#/responses/okResponse" @@ -885,19 +1042,144 @@ } } }, - "/admin/provisioning/datasources/reload": { + "/admin/ldap/status": { + "get": { + "security": [ + { + "basic": [] + } + ], + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.status:read`.", + "tags": [ + "admin_ldap" + ], + "summary": "Attempts to connect to all the configured LDAP servers and returns information on whenever they're available or not.", + "operationId": "getLDAPStatus", + "responses": { + "200": { + "$ref": "#/responses/okResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/admin/ldap/sync/{user_id}": { "post": { "security": [ { "basic": [] } ], - "description": "Reloads the provisioning config files for datasources again. It won’t return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `provisioning:reload` and scope `provisioners:datasources`.", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.user:sync`.", + "tags": [ + "admin_ldap" + ], + "summary": "Enables a single Grafana user to be synchronized against LDAP.", + "operationId": "postSyncUserWithLDAP", + "parameters": [ + { + "type": "integer", + "format": "int64", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/okResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/admin/ldap/{user_name}": { + "get": { + "security": [ + { + "basic": [] + } + ], + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.user:read`.", + "tags": [ + "admin_ldap" + ], + "summary": "Finds an user based on a username in LDAP. This helps illustrate how would the particular user be mapped in Grafana when synced.", + "operationId": "getUserFromLDAP", + "parameters": [ + { + "type": "string", + "name": "user_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/okResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/admin/provisioning/access-control/reload": { + "post": { + "tags": [ + "access_control_provisioning", + "enterprise" + ], + "summary": "You need to have a permission with action `provisioning:reload` with scope `provisioners:accesscontrol`.", + "operationId": "adminProvisioningReloadAccessControl", + "responses": { + "202": { + "$ref": "#/responses/acceptedResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + } + } + } + }, + "/admin/provisioning/dashboards/reload": { + "post": { + "security": [ + { + "basic": [] + } + ], + "description": "Reloads the provisioning config files for dashboards again. It won’t return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `provisioning:reload` and scope `provisioners:dashboards`.", "tags": [ "admin_provisioning" ], - "summary": "Reload datasource provisioning configurations.", - "operationId": "adminProvisioningReloadDatasources", + "summary": "Reload dashboard provisioning configurations.", + "operationId": "adminProvisioningReloadDashboards", "responses": { "200": { "$ref": "#/responses/okResponse" @@ -914,19 +1196,19 @@ } } }, - "/admin/provisioning/notifications/reload": { + "/admin/provisioning/datasources/reload": { "post": { "security": [ { "basic": [] } ], - "description": "Reloads the provisioning config files for legacy alert notifiers again. It won’t return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `provisioning:reload` and scope `provisioners:notifications`.", + "description": "Reloads the provisioning config files for datasources again. It won’t return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `provisioning:reload` and scope `provisioners:datasources`.", "tags": [ "admin_provisioning" ], - "summary": "Reload legacy alert notifier provisioning configurations.", - "operationId": "adminProvisioningReloadNotifications", + "summary": "Reload datasource provisioning configurations.", + "operationId": "adminProvisioningReloadDatasources", "responses": { "200": { "$ref": "#/responses/okResponse" @@ -1513,49 +1795,132 @@ } } }, - "/alert-notifications": { + "/annotations": { "get": { - "description": "Returns all notification channels that the authenticated user has permission to view.", + "description": "Starting in Grafana v6.4 regions annotations are now returned in one entity that now includes the timeEnd property.", "tags": [ - "legacy_alerts_notification_channels" + "annotations" + ], + "summary": "Find Annotations.", + "operationId": "getAnnotations", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Find annotations created after specific epoch datetime in milliseconds.", + "name": "from", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Find annotations created before specific epoch datetime in milliseconds.", + "name": "to", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Limit response to annotations created by specific user.", + "name": "userId", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Find annotations for a specified alert.", + "name": "alertId", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Find annotations that are scoped to a specific dashboard", + "name": "dashboardId", + "in": "query" + }, + { + "type": "string", + "description": "Find annotations that are scoped to a specific dashboard", + "name": "dashboardUID", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Find annotations that are scoped to a specific panel", + "name": "panelId", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Max limit for results returned.", + "name": "limit", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi", + "description": "Use this to filter organization annotations. Organization annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. You can filter by multiple tags.", + "name": "tags", + "in": "query" + }, + { + "enum": [ + "alert", + "annotation" + ], + "type": "string", + "description": "Return alerts or user created annotations", + "name": "type", + "in": "query" + }, + { + "type": "boolean", + "description": "Match any or all tags", + "name": "matchAny", + "in": "query" + } ], - "summary": "Get all notification channels.", - "operationId": "getAlertNotificationChannels", "responses": { "200": { - "$ref": "#/responses/getAlertNotificationChannelsResponse" + "$ref": "#/responses/getAnnotationsResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenError" - }, "500": { "$ref": "#/responses/internalServerError" } } }, "post": { - "description": "You can find the full list of [supported notifiers](https://grafana.com/docs/grafana/latest/alerting/old-alerting/notifications/#list-of-supported-notifiers) on the alert notifiers page.", + "description": "Creates an annotation in the Grafana database. The dashboardId and panelId fields are optional. If they are not specified then an organization annotation is created and can be queried in any dashboard that adds the Grafana annotations data source. When creating a region annotation include the timeEnd property.\nThe format for `time` and `timeEnd` should be epoch numbers in millisecond resolution.\nThe response for this HTTP request is slightly different in versions prior to v6.4. In prior versions you would also get an endId if you where creating a region. But in 6.4 regions are represented using a single event with time and timeEnd properties.", "tags": [ - "legacy_alerts_notification_channels" + "annotations" ], - "summary": "Create notification channel.", - "operationId": "createAlertNotificationChannel", + "summary": "Create Annotation.", + "operationId": "postAnnotation", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/CreateAlertNotificationCommand" + "$ref": "#/definitions/PostAnnotationsCmd" } } ], "responses": { "200": { - "$ref": "#/responses/getAlertNotificationChannelResponse" + "$ref": "#/responses/postAnnotationResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -1563,26 +1928,36 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "409": { - "$ref": "#/responses/conflictError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/alert-notifications/lookup": { - "get": { - "description": "Returns all notification channels, but with less detailed information. Accessible by any authenticated user and is mainly used by providing alert notification channels in Grafana UI when configuring alert rule.", + "/annotations/graphite": { + "post": { + "description": "Creates an annotation by using Graphite-compatible event format. The `when` and `data` fields are optional. If `when` is not specified then the current time will be used as annotation’s timestamp. The `tags` field can also be in prior to Graphite `0.10.0` format (string with multiple tags being separated by a space).", "tags": [ - "legacy_alerts_notification_channels" + "annotations" + ], + "summary": "Create Annotation in Graphite format.", + "operationId": "postGraphiteAnnotation", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/PostGraphiteAnnotationsCmd" + } + } ], - "summary": "Get all notification channels (lookup).", - "operationId": "getAlertNotificationLookup", "responses": { "200": { - "$ref": "#/responses/getAlertNotificationLookupResponse" + "$ref": "#/responses/postAnnotationResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -1596,21 +1971,20 @@ } } }, - "/alert-notifications/test": { + "/annotations/mass-delete": { "post": { - "description": "Sends a test notification to the channel.", "tags": [ - "legacy_alerts_notification_channels" + "annotations" ], - "summary": "Test notification channel.", - "operationId": "notificationChannelTest", + "summary": "Delete multiple annotations.", + "operationId": "massDeleteAnnotations", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/NotificationTestCommand" + "$ref": "#/definitions/MassDeleteAnnotationsCmd" } } ], @@ -1618,17 +1992,44 @@ "200": { "$ref": "#/responses/okResponse" }, - "400": { - "$ref": "#/responses/badRequestError" - }, "401": { "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenError" + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/annotations/tags": { + "get": { + "description": "Find all the event tags created in the annotations.", + "tags": [ + "annotations" + ], + "summary": "Find Annotations Tags.", + "operationId": "getAnnotationTags", + "parameters": [ + { + "type": "string", + "description": "Tag is a string that you can use to filter tags.", + "name": "tag", + "in": "query" }, - "412": { - "$ref": "#/responses/SMTPNotEnabledError" + { + "type": "string", + "default": "100", + "description": "Max limit for results returned.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/getAnnotationTagsResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" }, "500": { "$ref": "#/responses/internalServerError" @@ -1636,66 +2037,92 @@ } } }, - "/alert-notifications/uid/{notification_channel_uid}": { + "/annotations/{annotation_id}": { "get": { - "description": "Returns the notification channel given the notification channel UID.", "tags": [ - "legacy_alerts_notification_channels" + "annotations" ], - "summary": "Get notification channel by UID.", - "operationId": "getAlertNotificationChannelByUID", + "summary": "Get Annotation by ID.", + "operationId": "getAnnotationByID", "parameters": [ { "type": "string", - "name": "notification_channel_uid", + "name": "annotation_id", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getAlertNotificationChannelResponse" + "$ref": "#/responses/getAnnotationByIDResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } }, "put": { - "description": "Updates an existing notification channel identified by uid.", + "description": "Updates all properties of an annotation that matches the specified id. To only update certain property, consider using the Patch Annotation operation.", "tags": [ - "legacy_alerts_notification_channels" + "annotations" ], - "summary": "Update notification channel by UID.", - "operationId": "updateAlertNotificationChannelByUID", + "summary": "Update Annotation.", + "operationId": "updateAnnotation", "parameters": [ + { + "type": "string", + "name": "annotation_id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateAlertNotificationWithUidCommand" + "$ref": "#/definitions/UpdateAnnotationsCmd" } + } + ], + "responses": { + "200": { + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, + "delete": { + "description": "Deletes the annotation that matches the specified ID.", + "tags": [ + "annotations" + ], + "summary": "Delete Annotation By ID.", + "operationId": "deleteAnnotationByID", + "parameters": [ { "type": "string", - "name": "notification_channel_uid", + "name": "annotation_id", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getAlertNotificationChannelResponse" + "$ref": "#/responses/okResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -1703,32 +2130,37 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } }, - "delete": { - "description": "Deletes an existing notification channel identified by UID.", + "patch": { + "description": "Updates one or more properties of an annotation that matches the specified ID.\nThis operation currently supports updating of the `text`, `tags`, `time` and `timeEnd` properties.\nThis is available in Grafana 6.0.0-beta2 and above.", "tags": [ - "legacy_alerts_notification_channels" + "annotations" ], - "summary": "Delete alert notification by UID.", - "operationId": "deleteAlertNotificationChannelByUID", + "summary": "Patch Annotation.", + "operationId": "patchAnnotation", "parameters": [ { "type": "string", - "name": "notification_channel_uid", + "name": "annotation_id", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/PatchAnnotationsCmd" + } } ], "responses": { "200": { - "$ref": "#/responses/deleteAlertNotificationChannelResponse" + "$ref": "#/responses/okResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -1745,26 +2177,26 @@ } } }, - "/alert-notifications/{notification_channel_id}": { + "/auth/keys": { "get": { - "description": "Returns the notification channel given the notification channel ID.", + "description": "Will return auth keys.\n\nDeprecated: true.\n\nDeprecated. Please use GET /api/serviceaccounts and GET /api/serviceaccounts/{id}/tokens instead\nsee https://grafana.com/docs/grafana/next/administration/api-keys/#migrate-api-keys-to-grafana-service-accounts-using-the-api.", "tags": [ - "legacy_alerts_notification_channels" + "api_keys" ], - "summary": "Get notification channel by ID.", - "operationId": "getAlertNotificationChannelByID", + "summary": "Get auth keys.", + "operationId": "getAPIkeys", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "notification_channel_id", - "in": "path", - "required": true + "type": "boolean", + "default": false, + "description": "Show expired keys", + "name": "includeExpired", + "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/getAlertNotificationChannelResponse" + "$ref": "#/responses/getAPIkeyResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -1780,33 +2212,30 @@ } } }, - "put": { - "description": "Updates an existing notification channel identified by ID.", + "post": { + "description": "Will return details of the created API key.", "tags": [ - "legacy_alerts_notification_channels" + "api_keys" ], - "summary": "Update notification channel by ID.", - "operationId": "updateAlertNotificationChannel", + "summary": "Creates an API key.", + "operationId": "addAPIkey", + "deprecated": true, "parameters": [ { - "name": "body", + "name": "Body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateAlertNotificationCommand" + "$ref": "#/definitions/AddAPIKeyCommand" } - }, - { - "type": "integer", - "format": "int64", - "name": "notification_channel_id", - "in": "path", - "required": true } ], "responses": { "200": { - "$ref": "#/responses/getAlertNotificationChannelResponse" + "$ref": "#/responses/postAPIkeyResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -1814,26 +2243,29 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" + "409": { + "$ref": "#/responses/conflictError" }, "500": { "$ref": "#/responses/internalServerError" } } - }, + } + }, + "/auth/keys/{id}": { "delete": { - "description": "Deletes an existing notification channel identified by ID.", + "description": "Deletes an API key.\nDeprecated. See: https://grafana.com/docs/grafana/next/administration/api-keys/#migrate-api-keys-to-grafana-service-accounts-using-the-api.", "tags": [ - "legacy_alerts_notification_channels" + "api_keys" ], - "summary": "Delete alert notification by ID.", - "operationId": "deleteAlertNotificationChannel", + "summary": "Delete API key.", + "operationId": "deleteAPIkey", + "deprecated": true, "parameters": [ { "type": "integer", "format": "int64", - "name": "notification_channel_id", + "name": "id", "in": "path", "required": true } @@ -1857,91 +2289,32 @@ } } }, - "/alerts": { + "/dashboard/snapshots": { "get": { "tags": [ - "legacy_alerts" + "snapshots" ], - "summary": "Get legacy alerts.", - "operationId": "getAlerts", + "summary": "List snapshots.", + "operationId": "searchDashboardSnapshots", "parameters": [ - { - "type": "array", - "items": { - "type": "string" - }, - "description": "Limit response to alerts in specified dashboard(s). You can specify multiple dashboards.", - "name": "dashboardId", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "description": "Limit response to alert for a specified panel on a dashboard.", - "name": "panelId", - "in": "query" - }, { "type": "string", - "description": "Limit response to alerts having a name like this value.", + "description": "Search Query", "name": "query", "in": "query" }, - { - "enum": [ - "all", - "no_data", - "paused", - "alerting", - "ok", - "pending", - "unknown" - ], - "type": "string", - "description": "Return alerts with one or more of the following alert states", - "name": "state", - "in": "query" - }, { "type": "integer", "format": "int64", - "description": "Limit response to X number of alerts.", + "default": 1000, + "description": "Limit the number of returned results", "name": "limit", "in": "query" - }, - { - "type": "array", - "items": { - "type": "string" - }, - "collectionFormat": "multi", - "description": "Limit response to alerts of dashboards in specified folder(s). You can specify multiple folders", - "name": "folderId", - "in": "query" - }, - { - "type": "string", - "description": "Limit response to alerts having a dashboard name like this value./ Limit response to alerts having a dashboard name like this value.", - "name": "dashboardQuery", - "in": "query" - }, - { - "type": "array", - "items": { - "type": "string" - }, - "collectionFormat": "multi", - "description": "Limit response to alerts of dashboards with specified tags. To do an “AND” filtering with multiple tags, specify the tags parameter multiple times", - "name": "dashboardTag", - "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/getAlertsResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" + "$ref": "#/responses/searchDashboardSnapshotsResponse" }, "500": { "$ref": "#/responses/internalServerError" @@ -1949,28 +2322,52 @@ } } }, - "/alerts/states-for-dashboard": { - "get": { + "/dashboards/calculate-diff": { + "post": { + "produces": [ + "application/json", + "text/html" + ], "tags": [ - "legacy_alerts" + "dashboards" ], - "summary": "Get alert states for a dashboard.", - "operationId": "getDashboardStates", + "summary": "Perform diff on two dashboards.", + "operationId": "calculateDashboardDiff", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "dashboardId", - "in": "query", - "required": true + "name": "Body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/CalculateDiffTarget" + }, + "diffType": { + "description": "The type of diff to return\nDescription:\n`basic`\n`json`", + "type": "string", + "enum": [ + "basic", + "json" + ] + }, + "new": { + "$ref": "#/definitions/CalculateDiffTarget" + } + } + } } ], "responses": { "200": { - "$ref": "#/responses/getDashboardStatesResponse" + "$ref": "#/responses/calculateDashboardDiffResponse" }, - "400": { - "$ref": "#/responses/badRequestError" + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" }, "500": { "$ref": "#/responses/internalServerError" @@ -1978,32 +2375,43 @@ } } }, - "/alerts/test": { + "/dashboards/db": { "post": { + "description": "Creates a new dashboard or updates an existing dashboard.\nNote: This endpoint is not intended for creating folders, use `POST /api/folders` for that.", "tags": [ - "legacy_alerts" + "dashboards" ], - "summary": "Test alert.", - "operationId": "testAlert", + "summary": "Create / Update dashboard", + "operationId": "postDashboard", "parameters": [ { - "name": "body", + "name": "Body", "in": "body", + "required": true, "schema": { - "$ref": "#/definitions/AlertTestCommand" + "$ref": "#/definitions/SaveDashboardCommand" } } ], "responses": { "200": { - "$ref": "#/responses/testAlertResponse" + "$ref": "#/responses/postDashboardResponse" }, "400": { "$ref": "#/responses/badRequestError" }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "412": { + "$ref": "#/responses/preconditionFailedError" + }, "422": { "$ref": "#/responses/unprocessableEntityError" }, @@ -2013,25 +2421,16 @@ } } }, - "/alerts/{alert_id}": { + "/dashboards/home": { "get": { - "description": "“evalMatches” data in the response is cached in the db when and only when the state of the alert changes (e.g. transitioning from “ok” to “alerting” state).\nIf data from one server triggers the alert first and, before that server is seen leaving alerting state, a second server also enters a state that would trigger the alert, the second server will not be visible in “evalMatches” data.", "tags": [ - "legacy_alerts" - ], - "summary": "Get alert by ID.", - "operationId": "getAlertByID", - "parameters": [ - { - "type": "string", - "name": "alert_id", - "in": "path", - "required": true - } + "dashboards" ], + "summary": "Get home dashboard.", + "operationId": "getHomeDashboard", "responses": { "200": { - "$ref": "#/responses/getAlertResponse" + "$ref": "#/responses/getHomeDashboardResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -2042,32 +2441,27 @@ } } }, - "/alerts/{alert_id}/pause": { - "post": { + "/dashboards/id/{DashboardID}/permissions": { + "get": { + "description": "Please refer to [updated API](#/dashboard_permissions/getDashboardPermissionsListByUID) instead", "tags": [ - "legacy_alerts" + "dashboard_permissions" ], - "summary": "Pause/unpause alert by id.", - "operationId": "pauseAlert", + "summary": "Gets all existing permissions for the given dashboard.", + "operationId": "getDashboardPermissionsListByID", + "deprecated": true, "parameters": [ { - "type": "string", - "name": "alert_id", + "type": "integer", + "format": "int64", + "name": "DashboardID", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/PauseAlertCommand" - } } ], "responses": { "200": { - "$ref": "#/responses/pauseAlertResponse" + "$ref": "#/responses/getDashboardPermissionsListResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -2082,134 +2476,83 @@ "$ref": "#/responses/internalServerError" } } - } - }, - "/annotations": { - "get": { - "description": "Starting in Grafana v6.4 regions annotations are now returned in one entity that now includes the timeEnd property.", + }, + "post": { + "description": "Please refer to [updated API](#/dashboard_permissions/updateDashboardPermissionsByUID) instead\n\nThis operation will remove existing permissions if they’re not included in the request.", "tags": [ - "annotations" + "dashboard_permissions" ], - "summary": "Find Annotations.", - "operationId": "getAnnotations", + "summary": "Updates permissions for a dashboard.", + "operationId": "updateDashboardPermissionsByID", + "deprecated": true, "parameters": [ { - "type": "integer", - "format": "int64", - "description": "Find annotations created after specific epoch datetime in milliseconds.", - "name": "from", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "description": "Find annotations created before specific epoch datetime in milliseconds.", - "name": "to", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "description": "Limit response to annotations created by specific user.", - "name": "userId", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "description": "Find annotations for a specified alert.", - "name": "alertId", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "description": "Find annotations that are scoped to a specific dashboard", - "name": "dashboardId", - "in": "query" - }, - { - "type": "string", - "description": "Find annotations that are scoped to a specific dashboard", - "name": "dashboardUID", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "description": "Find annotations that are scoped to a specific panel", - "name": "panelId", - "in": "query" + "name": "Body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateDashboardACLCommand" + } }, { "type": "integer", "format": "int64", - "description": "Max limit for results returned.", - "name": "limit", - "in": "query" - }, - { - "type": "array", - "items": { - "type": "string" - }, - "collectionFormat": "multi", - "description": "Use this to filter organization annotations. Organization annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. You can filter by multiple tags.", - "name": "tags", - "in": "query" - }, - { - "enum": [ - "alert", - "annotation" - ], - "type": "string", - "description": "Return alerts or user created annotations", - "name": "type", - "in": "query" - }, - { - "type": "boolean", - "description": "Match any or all tags", - "name": "matchAny", - "in": "query" + "name": "DashboardID", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/getAnnotationsResponse" + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } - }, + } + }, + "/dashboards/id/{DashboardID}/restore": { "post": { - "description": "Creates an annotation in the Grafana database. The dashboardId and panelId fields are optional. If they are not specified then an organization annotation is created and can be queried in any dashboard that adds the Grafana annotations data source. When creating a region annotation include the timeEnd property.\nThe format for `time` and `timeEnd` should be epoch numbers in millisecond resolution.\nThe response for this HTTP request is slightly different in versions prior to v6.4. In prior versions you would also get an endId if you where creating a region. But in 6.4 regions are represented using a single event with time and timeEnd properties.", + "description": "Please refer to [updated API](#/dashboard_versions/restoreDashboardVersionByUID) instead", "tags": [ - "annotations" + "dashboard_versions" ], - "summary": "Create Annotation.", - "operationId": "postAnnotation", + "summary": "Restore a dashboard to a given dashboard version.", + "operationId": "restoreDashboardVersionByID", + "deprecated": true, "parameters": [ { - "name": "body", + "name": "Body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/PostAnnotationsCmd" + "$ref": "#/definitions/RestoreDashboardVersionCommand" } + }, + { + "type": "integer", + "format": "int64", + "name": "DashboardID", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/postAnnotationResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/postDashboardResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -2217,36 +2560,36 @@ "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/annotations/graphite": { - "post": { - "description": "Creates an annotation by using Graphite-compatible event format. The `when` and `data` fields are optional. If `when` is not specified then the current time will be used as annotation’s timestamp. The `tags` field can also be in prior to Graphite `0.10.0` format (string with multiple tags being separated by a space).", + "/dashboards/id/{DashboardID}/versions": { + "get": { + "description": "Please refer to [updated API](#/dashboard_versions/getDashboardVersionsByUID) instead", "tags": [ - "annotations" + "dashboard_versions" ], - "summary": "Create Annotation in Graphite format.", - "operationId": "postGraphiteAnnotation", + "summary": "Gets all existing versions for the dashboard.", + "operationId": "getDashboardVersionsByID", + "deprecated": true, "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/PostGraphiteAnnotationsCmd" - } + "type": "integer", + "format": "int64", + "name": "DashboardID", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/postAnnotationResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/dashboardVersionsResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -2254,68 +2597,131 @@ "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/annotations/mass-delete": { + "/dashboards/id/{DashboardID}/versions/{DashboardVersionID}": { + "get": { + "description": "Please refer to [updated API](#/dashboard_versions/getDashboardVersionByUID) instead", + "tags": [ + "dashboard_versions" + ], + "summary": "Get a specific dashboard version.", + "operationId": "getDashboardVersionByID", + "deprecated": true, + "parameters": [ + { + "type": "integer", + "format": "int64", + "name": "DashboardID", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "name": "DashboardVersionID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/dashboardVersionResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/dashboards/import": { "post": { "tags": [ - "annotations" + "dashboards" ], - "summary": "Delete multiple annotations.", - "operationId": "massDeleteAnnotations", + "summary": "Import dashboard.", + "operationId": "importDashboard", "parameters": [ { - "name": "body", + "name": "Body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/MassDeleteAnnotationsCmd" + "$ref": "#/definitions/ImportDashboardRequest" } } ], "responses": { "200": { - "$ref": "#/responses/okResponse" + "$ref": "#/responses/importDashboardResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" }, + "412": { + "$ref": "#/responses/preconditionFailedError" + }, + "422": { + "$ref": "#/responses/unprocessableEntityError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/annotations/tags": { + "/dashboards/public-dashboards": { "get": { - "description": "Find all the event tags created in the annotations.", + "description": "Get list of public dashboards", "tags": [ - "annotations" + "dashboard_public" ], - "summary": "Find Annotations Tags.", - "operationId": "getAnnotationTags", - "parameters": [ - { - "type": "string", - "description": "Tag is a string that you can use to filter tags.", - "name": "tag", - "in": "query" + "operationId": "listPublicDashboards", + "responses": { + "200": { + "$ref": "#/responses/listPublicDashboardsResponse" }, - { - "type": "string", - "default": "100", - "description": "Max limit for results returned.", - "name": "limit", - "in": "query" + "401": { + "$ref": "#/responses/unauthorisedPublicError" + }, + "403": { + "$ref": "#/responses/forbiddenPublicError" + }, + "500": { + "$ref": "#/responses/internalServerPublicError" } + } + } + }, + "/dashboards/tags": { + "get": { + "tags": [ + "dashboards" ], + "summary": "Get all dashboards tags of an organisation.", + "operationId": "getDashboardTags", "responses": { "200": { - "$ref": "#/responses/getAnnotationTagsResponse" + "$ref": "#/responses/getDashboardsTagsResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -2326,85 +2732,103 @@ } } }, - "/annotations/{annotation_id}": { + "/dashboards/uid/{dashboardUid}/public-dashboards": { "get": { + "description": "Get public dashboard by dashboardUid", "tags": [ - "annotations" + "dashboard_public" ], - "summary": "Get Annotation by ID.", - "operationId": "getAnnotationByID", + "operationId": "getPublicDashboard", "parameters": [ { "type": "string", - "name": "annotation_id", + "name": "dashboardUid", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getAnnotationByIDResponse" + "$ref": "#/responses/getPublicDashboardResponse" + }, + "400": { + "$ref": "#/responses/badRequestPublicError" }, "401": { - "$ref": "#/responses/unauthorisedError" + "$ref": "#/responses/unauthorisedPublicError" + }, + "403": { + "$ref": "#/responses/forbiddenPublicError" + }, + "404": { + "$ref": "#/responses/notFoundPublicError" }, "500": { - "$ref": "#/responses/internalServerError" + "$ref": "#/responses/internalServerPublicError" } } }, - "put": { - "description": "Updates all properties of an annotation that matches the specified id. To only update certain property, consider using the Patch Annotation operation.", + "post": { + "description": "Create public dashboard for a dashboard", + "produces": [ + "application/json" + ], "tags": [ - "annotations" + "dashboard_public" ], - "summary": "Update Annotation.", - "operationId": "updateAnnotation", + "operationId": "createPublicDashboard", "parameters": [ { "type": "string", - "name": "annotation_id", + "name": "dashboardUid", "in": "path", "required": true }, { - "name": "body", + "name": "Body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateAnnotationsCmd" + "$ref": "#/definitions/PublicDashboardDTO" } } ], "responses": { "200": { - "$ref": "#/responses/okResponse" + "$ref": "#/responses/createPublicDashboardResponse" }, "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/badRequestPublicError" }, "401": { - "$ref": "#/responses/unauthorisedError" + "$ref": "#/responses/unauthorisedPublicError" }, "403": { - "$ref": "#/responses/forbiddenError" + "$ref": "#/responses/forbiddenPublicError" }, "500": { - "$ref": "#/responses/internalServerError" + "$ref": "#/responses/internalServerPublicError" } } - }, + } + }, + "/dashboards/uid/{dashboardUid}/public-dashboards/{uid}": { "delete": { - "description": "Deletes the annotation that matches the specified ID.", + "description": "Delete public dashboard for a dashboard", "tags": [ - "annotations" + "dashboard_public" ], - "summary": "Delete Annotation By ID.", - "operationId": "deleteAnnotationByID", + "operationId": "deletePublicDashboard", "parameters": [ { "type": "string", - "name": "annotation_id", + "name": "dashboardUid", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "uid", "in": "path", "required": true } @@ -2413,1109 +2837,333 @@ "200": { "$ref": "#/responses/okResponse" }, + "400": { + "$ref": "#/responses/badRequestPublicError" + }, "401": { - "$ref": "#/responses/unauthorisedError" + "$ref": "#/responses/unauthorisedPublicError" }, "403": { - "$ref": "#/responses/forbiddenError" + "$ref": "#/responses/forbiddenPublicError" }, "500": { - "$ref": "#/responses/internalServerError" + "$ref": "#/responses/internalServerPublicError" } } }, "patch": { - "description": "Updates one or more properties of an annotation that matches the specified ID.\nThis operation currently supports updating of the `text`, `tags`, `time` and `timeEnd` properties.\nThis is available in Grafana 6.0.0-beta2 and above.", + "description": "Update public dashboard for a dashboard", + "produces": [ + "application/json" + ], "tags": [ - "annotations" + "dashboard_public" ], - "summary": "Patch Annotation.", - "operationId": "patchAnnotation", + "operationId": "updatePublicDashboard", "parameters": [ { "type": "string", - "name": "annotation_id", + "name": "dashboardUid", "in": "path", "required": true }, { - "name": "body", + "type": "string", + "name": "uid", + "in": "path", + "required": true + }, + { + "name": "Body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/PatchAnnotationsCmd" + "$ref": "#/definitions/PublicDashboardDTO" } } ], "responses": { "200": { - "$ref": "#/responses/okResponse" + "$ref": "#/responses/updatePublicDashboardResponse" + }, + "400": { + "$ref": "#/responses/badRequestPublicError" }, "401": { - "$ref": "#/responses/unauthorisedError" + "$ref": "#/responses/unauthorisedPublicError" }, "403": { - "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" + "$ref": "#/responses/forbiddenPublicError" }, "500": { - "$ref": "#/responses/internalServerError" + "$ref": "#/responses/internalServerPublicError" } } } }, - "/api/v1/provisioning/alert-rules": { + "/dashboards/uid/{uid}": { "get": { + "description": "Will return the dashboard given the dashboard unique identifier (uid).", "tags": [ - "provisioning" - ], - "summary": "Get all the alert rules.", - "operationId": "RouteGetAlertRules", - "responses": { - "200": { - "description": "ProvisionedAlertRules", - "schema": { - "$ref": "#/definitions/ProvisionedAlertRules" - } - } - } - }, - "post": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" + "dashboards" ], - "summary": "Create a new alert rule.", - "operationId": "RoutePostAlertRule", + "summary": "Get dashboard by uid.", + "operationId": "getDashboardByUID", "parameters": [ - { - "name": "Body", - "in": "body", - "schema": { - "$ref": "#/definitions/ProvisionedAlertRule" - } - }, { "type": "string", - "name": "X-Disable-Provenance", - "in": "header" + "name": "uid", + "in": "path", + "required": true } ], "responses": { - "201": { - "description": "ProvisionedAlertRule", - "schema": { - "$ref": "#/definitions/ProvisionedAlertRule" - } + "200": { + "$ref": "#/responses/dashboardResponse" }, - "400": { - "description": "ValidationError", - "schema": { - "$ref": "#/definitions/ValidationError" - } + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" } } - } - }, - "/api/v1/provisioning/alert-rules/export": { - "get": { + }, + "delete": { + "description": "Will delete the dashboard given the specified unique identifier (uid).", "tags": [ - "provisioning" + "dashboards" ], - "summary": "Export all alert rules in provisioning file format.", - "operationId": "RouteGetAlertRulesExport", + "summary": "Delete dashboard by uid.", + "operationId": "deleteDashboardByUID", "parameters": [ - { - "type": "boolean", - "default": false, - "description": "Whether to initiate a download of the file or not.", - "name": "download", - "in": "query" - }, - { - "type": "string", - "default": "yaml", - "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", - "name": "format", - "in": "query" - }, - { - "type": "array", - "items": { - "type": "string" - }, - "description": "UIDs of folders from which to export rules", - "name": "folderUid", - "in": "query" - }, - { - "type": "string", - "description": "Name of group of rules to export. Must be specified only together with a single folder UID", - "name": "group", - "in": "query" - }, { "type": "string", - "description": "UID of alert rule to export. If specified, parameters folderUid and group must be empty.", - "name": "ruleUid", - "in": "query" + "name": "uid", + "in": "path", + "required": true } ], "responses": { "200": { - "description": "AlertingFileExport", - "schema": { - "$ref": "#/definitions/AlertingFileExport" - } + "$ref": "#/responses/deleteDashboardResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" }, "404": { - "description": " Not found." + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" } } } }, - "/api/v1/provisioning/alert-rules/{UID}": { + "/dashboards/uid/{uid}/permissions": { "get": { "tags": [ - "provisioning" + "dashboard_permissions" ], - "summary": "Get a specific alert rule by UID.", - "operationId": "RouteGetAlertRule", + "summary": "Gets all existing permissions for the given dashboard.", + "operationId": "getDashboardPermissionsListByUID", "parameters": [ { "type": "string", - "description": "Alert rule UID", - "name": "UID", + "name": "uid", "in": "path", "required": true } ], "responses": { "200": { - "description": "ProvisionedAlertRule", - "schema": { - "$ref": "#/definitions/ProvisionedAlertRule" - } + "$ref": "#/responses/getDashboardPermissionsListResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" }, "404": { - "description": " Not found." + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" } } }, - "put": { - "consumes": [ - "application/json" - ], + "post": { + "description": "This operation will remove existing permissions if they’re not included in the request.", "tags": [ - "provisioning" + "dashboard_permissions" ], - "summary": "Update an existing alert rule.", - "operationId": "RoutePutAlertRule", + "summary": "Updates permissions for a dashboard.", + "operationId": "updateDashboardPermissionsByUID", "parameters": [ - { - "type": "string", - "description": "Alert rule UID", - "name": "UID", - "in": "path", - "required": true - }, { "name": "Body", "in": "body", + "required": true, "schema": { - "$ref": "#/definitions/ProvisionedAlertRule" + "$ref": "#/definitions/UpdateDashboardACLCommand" } }, { "type": "string", - "name": "X-Disable-Provenance", - "in": "header" + "name": "uid", + "in": "path", + "required": true } ], "responses": { "200": { - "description": "ProvisionedAlertRule", - "schema": { - "$ref": "#/definitions/ProvisionedAlertRule" - } + "$ref": "#/responses/okResponse" }, "400": { - "description": "ValidationError", - "schema": { - "$ref": "#/definitions/ValidationError" - } - } - } - }, - "delete": { - "tags": [ - "provisioning" - ], - "summary": "Delete a specific alert rule by UID.", - "operationId": "RouteDeleteAlertRule", - "parameters": [ - { - "type": "string", - "description": "Alert rule UID", - "name": "UID", - "in": "path", - "required": true + "$ref": "#/responses/badRequestError" }, - { - "type": "string", - "name": "X-Disable-Provenance", - "in": "header" - } - ], - "responses": { - "204": { - "description": " The alert rule was deleted successfully." + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" } } } }, - "/api/v1/provisioning/alert-rules/{UID}/export": { - "get": { - "produces": [ - "application/json", - "application/yaml", - "text/yaml" - ], + "/dashboards/uid/{uid}/restore": { + "post": { "tags": [ - "provisioning" + "dashboard_versions" ], - "summary": "Export an alert rule in provisioning file format.", - "operationId": "RouteGetAlertRuleExport", + "summary": "Restore a dashboard to a given dashboard version using UID.", + "operationId": "restoreDashboardVersionByUID", "parameters": [ { - "type": "boolean", - "default": false, - "description": "Whether to initiate a download of the file or not.", - "name": "download", - "in": "query" - }, - { - "type": "string", - "default": "yaml", - "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", - "name": "format", - "in": "query" + "name": "Body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/RestoreDashboardVersionCommand" + } }, { "type": "string", - "description": "Alert rule UID", - "name": "UID", + "name": "uid", "in": "path", "required": true } ], "responses": { "200": { - "description": "AlertingFileExport", - "schema": { - "$ref": "#/definitions/AlertingFileExport" - } + "$ref": "#/responses/postDashboardResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" }, "404": { - "description": " Not found." + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" } } } }, - "/api/v1/provisioning/contact-points": { + "/dashboards/uid/{uid}/versions": { "get": { "tags": [ - "provisioning" + "dashboard_versions" ], - "summary": "Get all the contact points.", - "operationId": "RouteGetContactpoints", + "summary": "Gets all existing versions for the dashboard using UID.", + "operationId": "getDashboardVersionsByUID", "parameters": [ { "type": "string", - "description": "Filter by name", - "name": "name", - "in": "query" - } - ], - "responses": { - "200": { - "description": "ContactPoints", - "schema": { - "$ref": "#/definitions/ContactPoints" - } - } - } - }, - "post": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], - "summary": "Create a contact point.", - "operationId": "RoutePostContactpoints", - "parameters": [ - { - "name": "Body", - "in": "body", - "schema": { - "$ref": "#/definitions/EmbeddedContactPoint" - } - }, - { - "type": "string", - "name": "X-Disable-Provenance", - "in": "header" - } - ], - "responses": { - "202": { - "description": "EmbeddedContactPoint", - "schema": { - "$ref": "#/definitions/EmbeddedContactPoint" - } - }, - "400": { - "description": "ValidationError", - "schema": { - "$ref": "#/definitions/ValidationError" - } - } - } - } - }, - "/api/v1/provisioning/contact-points/export": { - "get": { - "tags": [ - "provisioning" - ], - "summary": "Export all contact points in provisioning file format.", - "operationId": "RouteGetContactpointsExport", - "parameters": [ - { - "type": "boolean", - "default": false, - "description": "Whether to initiate a download of the file or not.", - "name": "download", - "in": "query" - }, - { - "type": "string", - "default": "yaml", - "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", - "name": "format", - "in": "query" - }, - { - "type": "boolean", - "default": false, - "description": "Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings.", - "name": "decrypt", - "in": "query" - }, - { - "type": "string", - "description": "Filter by name", - "name": "name", - "in": "query" - } - ], - "responses": { - "200": { - "description": "AlertingFileExport", - "schema": { - "$ref": "#/definitions/AlertingFileExport" - } - }, - "403": { - "description": "PermissionDenied", - "schema": { - "$ref": "#/definitions/PermissionDenied" - } - } - } - } - }, - "/api/v1/provisioning/contact-points/{UID}": { - "put": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], - "summary": "Update an existing contact point.", - "operationId": "RoutePutContactpoint", - "parameters": [ - { - "type": "string", - "description": "UID is the contact point unique identifier", - "name": "UID", - "in": "path", - "required": true - }, - { - "name": "Body", - "in": "body", - "schema": { - "$ref": "#/definitions/EmbeddedContactPoint" - } - }, - { - "type": "string", - "name": "X-Disable-Provenance", - "in": "header" - } - ], - "responses": { - "202": { - "description": "Ack", - "schema": { - "$ref": "#/definitions/Ack" - } - }, - "400": { - "description": "ValidationError", - "schema": { - "$ref": "#/definitions/ValidationError" - } - } - } - }, - "delete": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], - "summary": "Delete a contact point.", - "operationId": "RouteDeleteContactpoints", - "parameters": [ - { - "type": "string", - "description": "UID is the contact point unique identifier", - "name": "UID", - "in": "path", - "required": true - } - ], - "responses": { - "202": { - "description": " The contact point was deleted successfully." - } - } - } - }, - "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": { - "get": { - "tags": [ - "provisioning" - ], - "summary": "Get a rule group.", - "operationId": "RouteGetAlertRuleGroup", - "parameters": [ - { - "type": "string", - "name": "FolderUID", - "in": "path", - "required": true - }, - { - "type": "string", - "name": "Group", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "AlertRuleGroup", - "schema": { - "$ref": "#/definitions/AlertRuleGroup" - } - }, - "404": { - "description": " Not found." - } - } - }, - "put": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], - "summary": "Update the interval of a rule group.", - "operationId": "RoutePutAlertRuleGroup", - "parameters": [ - { - "type": "string", - "name": "X-Disable-Provenance", - "in": "header" - }, - { - "type": "string", - "name": "FolderUID", - "in": "path", - "required": true - }, - { - "type": "string", - "name": "Group", - "in": "path", - "required": true - }, - { - "name": "Body", - "in": "body", - "schema": { - "$ref": "#/definitions/AlertRuleGroup" - } - } - ], - "responses": { - "200": { - "description": "AlertRuleGroup", - "schema": { - "$ref": "#/definitions/AlertRuleGroup" - } - }, - "400": { - "description": "ValidationError", - "schema": { - "$ref": "#/definitions/ValidationError" - } - } - } - } - }, - "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export": { - "get": { - "produces": [ - "application/json", - "application/yaml", - "text/yaml" - ], - "tags": [ - "provisioning" - ], - "summary": "Export an alert rule group in provisioning file format.", - "operationId": "RouteGetAlertRuleGroupExport", - "parameters": [ - { - "type": "boolean", - "default": false, - "description": "Whether to initiate a download of the file or not.", - "name": "download", - "in": "query" - }, - { - "type": "string", - "default": "yaml", - "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", - "name": "format", - "in": "query" - }, - { - "type": "string", - "name": "FolderUID", - "in": "path", - "required": true - }, - { - "type": "string", - "name": "Group", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "AlertingFileExport", - "schema": { - "$ref": "#/definitions/AlertingFileExport" - } - }, - "404": { - "description": " Not found." - } - } - } - }, - "/api/v1/provisioning/mute-timings": { - "get": { - "tags": [ - "provisioning" - ], - "summary": "Get all the mute timings.", - "operationId": "RouteGetMuteTimings", - "responses": { - "200": { - "description": "MuteTimings", - "schema": { - "$ref": "#/definitions/MuteTimings" - } - } - } - }, - "post": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], - "summary": "Create a new mute timing.", - "operationId": "RoutePostMuteTiming", - "parameters": [ - { - "name": "Body", - "in": "body", - "schema": { - "$ref": "#/definitions/MuteTimeInterval" - } - }, - { - "type": "string", - "name": "X-Disable-Provenance", - "in": "header" - } - ], - "responses": { - "201": { - "description": "MuteTimeInterval", - "schema": { - "$ref": "#/definitions/MuteTimeInterval" - } - }, - "400": { - "description": "ValidationError", - "schema": { - "$ref": "#/definitions/ValidationError" - } - } - } - } - }, - "/api/v1/provisioning/mute-timings/export": { - "get": { - "tags": [ - "provisioning" - ], - "summary": "Export all mute timings in provisioning format.", - "operationId": "RouteExportMuteTimings", - "parameters": [ - { - "type": "boolean", - "default": false, - "description": "Whether to initiate a download of the file or not.", - "name": "download", - "in": "query" - }, - { - "type": "string", - "default": "yaml", - "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", - "name": "format", - "in": "query" - } - ], - "responses": { - "200": { - "description": "AlertingFileExport", - "schema": { - "$ref": "#/definitions/AlertingFileExport" - } - }, - "403": { - "description": "PermissionDenied", - "schema": { - "$ref": "#/definitions/PermissionDenied" - } - } - } - } - }, - "/api/v1/provisioning/mute-timings/{name}": { - "get": { - "tags": [ - "provisioning" - ], - "summary": "Get a mute timing.", - "operationId": "RouteGetMuteTiming", - "parameters": [ - { - "type": "string", - "description": "Mute timing name", - "name": "name", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "MuteTimeInterval", - "schema": { - "$ref": "#/definitions/MuteTimeInterval" - } - }, - "404": { - "description": " Not found." - } - } - }, - "put": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], - "summary": "Replace an existing mute timing.", - "operationId": "RoutePutMuteTiming", - "parameters": [ - { - "type": "string", - "description": "Mute timing name", - "name": "name", - "in": "path", - "required": true - }, - { - "name": "Body", - "in": "body", - "schema": { - "$ref": "#/definitions/MuteTimeInterval" - } - }, - { - "type": "string", - "name": "X-Disable-Provenance", - "in": "header" - } - ], - "responses": { - "200": { - "description": "MuteTimeInterval", - "schema": { - "$ref": "#/definitions/MuteTimeInterval" - } - }, - "400": { - "description": "ValidationError", - "schema": { - "$ref": "#/definitions/ValidationError" - } - } - } - }, - "delete": { - "tags": [ - "provisioning" - ], - "summary": "Delete a mute timing.", - "operationId": "RouteDeleteMuteTiming", - "parameters": [ - { - "type": "string", - "description": "Mute timing name", - "name": "name", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": " The mute timing was deleted successfully." - } - } - } - }, - "/api/v1/provisioning/mute-timings/{name}/export": { - "get": { - "tags": [ - "provisioning" - ], - "summary": "Export a mute timing in provisioning format.", - "operationId": "RouteExportMuteTiming", - "parameters": [ - { - "type": "boolean", - "default": false, - "description": "Whether to initiate a download of the file or not.", - "name": "download", - "in": "query" - }, - { - "type": "string", - "default": "yaml", - "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", - "name": "format", - "in": "query" - }, - { - "type": "string", - "description": "Mute timing name", - "name": "name", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "AlertingFileExport", - "schema": { - "$ref": "#/definitions/AlertingFileExport" - } - }, - "403": { - "description": "PermissionDenied", - "schema": { - "$ref": "#/definitions/PermissionDenied" - } - } - } - } - }, - "/api/v1/provisioning/policies": { - "get": { - "tags": [ - "provisioning" - ], - "summary": "Get the notification policy tree.", - "operationId": "RouteGetPolicyTree", - "responses": { - "200": { - "description": "Route", - "schema": { - "$ref": "#/definitions/Route" - } - } - } - }, - "put": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], - "summary": "Sets the notification policy tree.", - "operationId": "RoutePutPolicyTree", - "parameters": [ - { - "description": "The new notification routing tree to use", - "name": "Body", - "in": "body", - "schema": { - "$ref": "#/definitions/Route" - } + "name": "uid", + "in": "path", + "required": true }, { - "type": "string", - "name": "X-Disable-Provenance", - "in": "header" - } - ], - "responses": { - "202": { - "description": "Ack", - "schema": { - "$ref": "#/definitions/Ack" - } + "type": "integer", + "format": "int64", + "default": 0, + "description": "Maximum number of results to return", + "name": "limit", + "in": "query" }, - "400": { - "description": "ValidationError", - "schema": { - "$ref": "#/definitions/ValidationError" - } - } - } - }, - "delete": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], - "summary": "Clears the notification policy tree.", - "operationId": "RouteResetPolicyTree", - "responses": { - "202": { - "description": "Ack", - "schema": { - "$ref": "#/definitions/Ack" - } + { + "type": "integer", + "format": "int64", + "default": 0, + "description": "Version to start from when returning queries", + "name": "start", + "in": "query" } - } - } - }, - "/api/v1/provisioning/policies/export": { - "get": { - "tags": [ - "provisioning" ], - "summary": "Export the notification policy tree in provisioning file format.", - "operationId": "RouteGetPolicyTreeExport", "responses": { "200": { - "description": "AlertingFileExport", - "schema": { - "$ref": "#/definitions/AlertingFileExport" - } + "$ref": "#/responses/dashboardVersionsResponse" }, - "404": { - "description": "NotFound", - "schema": { - "$ref": "#/definitions/NotFound" - } - } - } - } - }, - "/api/v1/provisioning/templates": { - "get": { - "tags": [ - "provisioning" - ], - "summary": "Get all notification templates.", - "operationId": "RouteGetTemplates", - "responses": { - "200": { - "description": "NotificationTemplates", - "schema": { - "$ref": "#/definitions/NotificationTemplates" - } + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" }, "404": { - "description": " Not found." + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" } } } }, - "/api/v1/provisioning/templates/{name}": { + "/dashboards/uid/{uid}/versions/{DashboardVersionID}": { "get": { "tags": [ - "provisioning" - ], - "summary": "Get a notification template.", - "operationId": "RouteGetTemplate", - "parameters": [ - { - "type": "string", - "description": "Template Name", - "name": "name", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "NotificationTemplate", - "schema": { - "$ref": "#/definitions/NotificationTemplate" - } - }, - "404": { - "description": " Not found." - } - } - }, - "put": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" + "dashboard_versions" ], - "summary": "Updates an existing notification template.", - "operationId": "RoutePutTemplate", + "summary": "Get a specific dashboard version using UID.", + "operationId": "getDashboardVersionByUID", "parameters": [ { - "type": "string", - "description": "Template Name", - "name": "name", + "type": "integer", + "format": "int64", + "name": "DashboardVersionID", "in": "path", "required": true }, - { - "name": "Body", - "in": "body", - "schema": { - "$ref": "#/definitions/NotificationTemplateContent" - } - }, - { - "type": "string", - "name": "X-Disable-Provenance", - "in": "header" - } - ], - "responses": { - "202": { - "description": "NotificationTemplate", - "schema": { - "$ref": "#/definitions/NotificationTemplate" - } - }, - "400": { - "description": "ValidationError", - "schema": { - "$ref": "#/definitions/ValidationError" - } - } - } - }, - "delete": { - "tags": [ - "provisioning" - ], - "summary": "Delete a template.", - "operationId": "RouteDeleteTemplate", - "parameters": [ { "type": "string", - "description": "Template Name", - "name": "name", + "name": "uid", "in": "path", "required": true } ], - "responses": { - "204": { - "description": " The template was deleted successfully." - } - } - } - }, - "/auth/keys": { - "get": { - "description": "Will return auth keys.\n\nDeprecated: true.\n\nDeprecated. Please use GET /api/serviceaccounts and GET /api/serviceaccounts/{id}/tokens instead\nsee https://grafana.com/docs/grafana/next/administration/api-keys/#migrate-api-keys-to-grafana-service-accounts-using-the-api.", - "tags": [ - "api_keys" - ], - "summary": "Get auth keys.", - "operationId": "getAPIkeys", - "parameters": [ - { - "type": "boolean", - "default": false, - "description": "Show expired keys", - "name": "includeExpired", - "in": "query" - } - ], "responses": { "200": { - "$ref": "#/responses/getAPIkeyResponse" + "$ref": "#/responses/dashboardVersionResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -3530,31 +3178,19 @@ "$ref": "#/responses/internalServerError" } } - }, - "post": { - "description": "Will return details of the created API key.", + } + }, + "/datasources": { + "get": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scope: `datasources:*`.", "tags": [ - "api_keys" - ], - "summary": "Creates an API key.", - "operationId": "addAPIkey", - "deprecated": true, - "parameters": [ - { - "name": "Body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/AddAPIKeyCommand" - } - } + "datasources" ], + "summary": "Get all data sources.", + "operationId": "getDataSources", "responses": { "200": { - "$ref": "#/responses/postAPIkeyResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/getDataSourcesResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -3562,36 +3198,31 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "409": { - "$ref": "#/responses/conflictError" - }, "500": { "$ref": "#/responses/internalServerError" } } - } - }, - "/auth/keys/{id}": { - "delete": { - "description": "Deletes an API key.\nDeprecated. See: https://grafana.com/docs/grafana/next/administration/api-keys/#migrate-api-keys-to-grafana-service-accounts-using-the-api.", + }, + "post": { + "description": "By defining `password` and `basicAuthPassword` under secureJsonData property\nGrafana encrypts them securely as an encrypted blob in the database.\nThe response then lists the encrypted fields under secureJsonFields.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:create`", "tags": [ - "api_keys" + "datasources" ], - "summary": "Delete API key.", - "operationId": "deleteAPIkey", - "deprecated": true, + "summary": "Create a data source.", + "operationId": "addDataSource", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "id", - "in": "path", - "required": true + "name": "Body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/AddDataSourceCommand" + } } ], "responses": { "200": { - "$ref": "#/responses/okResponse" + "$ref": "#/responses/createOrUpdateDatasourceResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -3599,8 +3230,8 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" + "409": { + "$ref": "#/responses/conflictError" }, "500": { "$ref": "#/responses/internalServerError" @@ -3608,85 +3239,51 @@ } } }, - "/dashboard/snapshots": { + "/datasources/correlations": { "get": { "tags": [ - "snapshots" + "correlations" ], - "summary": "List snapshots.", - "operationId": "searchDashboardSnapshots", + "summary": "Gets all correlations.", + "operationId": "getCorrelations", "parameters": [ { - "type": "string", - "description": "Search Query", - "name": "query", + "maximum": 1000, + "type": "integer", + "format": "int64", + "default": 100, + "description": "Limit the maximum number of correlations to return per page", + "name": "limit", "in": "query" }, { "type": "integer", "format": "int64", - "default": 1000, - "description": "Limit the number of returned results", - "name": "limit", + "default": 1, + "description": "Page index for starting fetching correlations", + "name": "page", "in": "query" - } - ], - "responses": { - "200": { - "$ref": "#/responses/searchDashboardSnapshotsResponse" }, - "500": { - "$ref": "#/responses/internalServerError" - } - } - } - }, - "/dashboards/calculate-diff": { - "post": { - "produces": [ - "application/json", - "text/html" - ], - "tags": [ - "dashboards" - ], - "summary": "Perform diff on two dashboards.", - "operationId": "calculateDashboardDiff", - "parameters": [ { - "name": "Body", - "in": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "base": { - "$ref": "#/definitions/CalculateDiffTarget" - }, - "diffType": { - "description": "The type of diff to return\nDescription:\n`basic`\n`json`", - "type": "string", - "enum": [ - "basic", - "json" - ] - }, - "new": { - "$ref": "#/definitions/CalculateDiffTarget" - } - } - } + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi", + "description": "Source datasource UID filter to be applied to correlations", + "name": "sourceUID", + "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/calculateDashboardDiffResponse" + "$ref": "#/responses/getCorrelationsResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenError" + "404": { + "$ref": "#/responses/notFoundError" }, "500": { "$ref": "#/responses/internalServerError" @@ -3694,30 +3291,25 @@ } } }, - "/dashboards/db": { - "post": { - "description": "Creates a new dashboard or updates an existing dashboard.", + "/datasources/id/{name}": { + "get": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:name:*` and `datasources:name:test_datasource` (single data source).", "tags": [ - "dashboards" + "datasources" ], - "summary": "Create / Update dashboard", - "operationId": "postDashboard", + "summary": "Get data source Id by Name.", + "operationId": "getDataSourceIdByName", "parameters": [ { - "name": "Body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/SaveDashboardCommand" - } + "type": "string", + "name": "name", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/postDashboardResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/getDataSourceIDResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -3728,59 +3320,61 @@ "404": { "$ref": "#/responses/notFoundError" }, - "412": { - "$ref": "#/responses/preconditionFailedError" - }, - "422": { - "$ref": "#/responses/unprocessableEntityError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/dashboards/home": { + "/datasources/name/{name}": { "get": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:name:*` and `datasources:name:test_datasource` (single data source).", "tags": [ - "dashboards" + "datasources" + ], + "summary": "Get a single data source by Name.", + "operationId": "getDataSourceByName", + "parameters": [ + { + "type": "string", + "name": "name", + "in": "path", + "required": true + } ], - "summary": "Get home dashboard.", - "operationId": "getHomeDashboard", "responses": { "200": { - "$ref": "#/responses/getHomeDashboardResponse" + "$ref": "#/responses/getDataSourceResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, + "403": { + "$ref": "#/responses/forbiddenError" + }, "500": { "$ref": "#/responses/internalServerError" } } - } - }, - "/dashboards/id/{DashboardID}/permissions": { - "get": { - "description": "Please refer to [updated API](#/dashboard_permissions/getDashboardPermissionsListByUID) instead", + }, + "delete": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:name:*` and `datasources:name:test_datasource` (single data source).", "tags": [ - "dashboard_permissions" + "datasources" ], - "summary": "Gets all existing permissions for the given dashboard.", - "operationId": "getDashboardPermissionsListByID", - "deprecated": true, + "summary": "Delete an existing data source by name.", + "operationId": "deleteDataSourceByName", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "DashboardID", + "type": "string", + "name": "name", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getDashboardPermissionsListResponse" + "$ref": "#/responses/deleteDataSourceByNameResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -3795,35 +3389,33 @@ "$ref": "#/responses/internalServerError" } } - }, - "post": { - "description": "Please refer to [updated API](#/dashboard_permissions/updateDashboardPermissionsByUID) instead\n\nThis operation will remove existing permissions if they’re not included in the request.", + } + }, + "/datasources/proxy/uid/{uid}/{datasource_proxy_route}": { + "get": { + "description": "Proxies all calls to the actual data source.", "tags": [ - "dashboard_permissions" + "datasources" ], - "summary": "Updates permissions for a dashboard.", - "operationId": "updateDashboardPermissionsByID", - "deprecated": true, + "summary": "Data source proxy GET calls.", + "operationId": "datasourceProxyGETByUIDcalls", "parameters": [ { - "name": "Body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/UpdateDashboardACLCommand" - } + "type": "string", + "name": "datasource_proxy_route", + "in": "path", + "required": true }, { - "type": "integer", - "format": "int64", - "name": "DashboardID", + "type": "string", + "name": "uid", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/okResponse" + "description": "(empty)" }, "400": { "$ref": "#/responses/badRequestError" @@ -3841,37 +3433,43 @@ "$ref": "#/responses/internalServerError" } } - } - }, - "/dashboards/id/{DashboardID}/restore": { + }, "post": { - "description": "Please refer to [updated API](#/dashboard_versions/restoreDashboardVersionByUID) instead", + "description": "Proxies all calls to the actual data source. The data source should support POST methods for the specific path and role as defined", "tags": [ - "dashboard_versions" + "datasources" ], - "summary": "Restore a dashboard to a given dashboard version.", - "operationId": "restoreDashboardVersionByID", - "deprecated": true, + "summary": "Data source proxy POST calls.", + "operationId": "datasourceProxyPOSTByUIDcalls", "parameters": [ { - "name": "Body", + "name": "DatasourceProxyParam", "in": "body", "required": true, - "schema": { - "$ref": "#/definitions/RestoreDashboardVersionCommand" - } + "schema": {} }, { - "type": "integer", - "format": "int64", - "name": "DashboardID", + "type": "string", + "name": "datasource_proxy_route", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "uid", "in": "path", "required": true } ], "responses": { - "200": { - "$ref": "#/responses/postDashboardResponse" + "201": { + "description": "(empty)" + }, + "202": { + "description": "(empty)" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -3886,29 +3484,34 @@ "$ref": "#/responses/internalServerError" } } - } - }, - "/dashboards/id/{DashboardID}/versions": { - "get": { - "description": "Please refer to [updated API](#/dashboard_versions/getDashboardVersionsByUID) instead", + }, + "delete": { + "description": "Proxies all calls to the actual data source.", "tags": [ - "dashboard_versions" + "datasources" ], - "summary": "Gets all existing versions for the dashboard.", - "operationId": "getDashboardVersionsByID", - "deprecated": true, + "summary": "Data source proxy DELETE calls.", + "operationId": "datasourceProxyDELETEByUIDcalls", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "DashboardID", + "type": "string", + "name": "uid", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "datasource_proxy_route", "in": "path", "required": true } ], "responses": { - "200": { - "$ref": "#/responses/dashboardVersionsResponse" + "202": { + "description": "(empty)" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -3925,34 +3528,35 @@ } } }, - "/dashboards/id/{DashboardID}/versions/{DashboardVersionID}": { + "/datasources/proxy/{id}/{datasource_proxy_route}": { "get": { - "description": "Please refer to [updated API](#/dashboard_versions/getDashboardVersionByUID) instead", + "description": "Proxies all calls to the actual data source.\n\nPlease refer to [updated API](#/datasources/datasourceProxyGETByUIDcalls) instead", "tags": [ - "dashboard_versions" + "datasources" ], - "summary": "Get a specific dashboard version.", - "operationId": "getDashboardVersionByID", + "summary": "Data source proxy GET calls.", + "operationId": "datasourceProxyGETcalls", "deprecated": true, "parameters": [ { - "type": "integer", - "format": "int64", - "name": "DashboardID", + "type": "string", + "name": "datasource_proxy_route", "in": "path", "required": true }, { - "type": "integer", - "format": "int64", - "name": "DashboardVersionID", + "type": "string", + "name": "id", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/dashboardVersionResponse" + "description": "(empty)" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -3967,28 +3571,41 @@ "$ref": "#/responses/internalServerError" } } - } - }, - "/dashboards/import": { + }, "post": { + "description": "Proxies all calls to the actual data source. The data source should support POST methods for the specific path and role as defined\n\nPlease refer to [updated API](#/datasources/datasourceProxyPOSTByUIDcalls) instead", "tags": [ - "dashboards" + "datasources" ], - "summary": "Import dashboard.", - "operationId": "importDashboard", + "summary": "Data source proxy POST calls.", + "operationId": "datasourceProxyPOSTcalls", + "deprecated": true, "parameters": [ { - "name": "Body", + "name": "DatasourceProxyParam", "in": "body", "required": true, - "schema": { - "$ref": "#/definitions/ImportDashboardRequest" - } + "schema": {} + }, + { + "type": "string", + "name": "datasource_proxy_route", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "id", + "in": "path", + "required": true } ], "responses": { - "200": { - "$ref": "#/responses/importDashboardResponse" + "201": { + "description": "(empty)" + }, + "202": { + "description": "(empty)" }, "400": { "$ref": "#/responses/badRequestError" @@ -3996,238 +3613,228 @@ "401": { "$ref": "#/responses/unauthorisedError" }, - "412": { - "$ref": "#/responses/preconditionFailedError" + "403": { + "$ref": "#/responses/forbiddenError" }, - "422": { - "$ref": "#/responses/unprocessableEntityError" + "404": { + "$ref": "#/responses/notFoundError" }, "500": { "$ref": "#/responses/internalServerError" } } - } - }, - "/dashboards/public-dashboards": { - "get": { - "description": "Get list of public dashboards", + }, + "delete": { + "description": "Proxies all calls to the actual data source.\n\nPlease refer to [updated API](#/datasources/datasourceProxyDELETEByUIDcalls) instead", "tags": [ - "dashboard_public" + "datasources" ], - "operationId": "listPublicDashboards", - "responses": { - "200": { - "$ref": "#/responses/listPublicDashboardsResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedPublicError" - }, - "403": { - "$ref": "#/responses/forbiddenPublicError" + "summary": "Data source proxy DELETE calls.", + "operationId": "datasourceProxyDELETEcalls", + "deprecated": true, + "parameters": [ + { + "type": "string", + "name": "id", + "in": "path", + "required": true }, - "500": { - "$ref": "#/responses/internalServerPublicError" + { + "type": "string", + "name": "datasource_proxy_route", + "in": "path", + "required": true } - } - } - }, - "/dashboards/tags": { - "get": { - "tags": [ - "dashboards" ], - "summary": "Get all dashboards tags of an organisation.", - "operationId": "getDashboardTags", "responses": { - "200": { - "$ref": "#/responses/getDashboardsTagsResponse" + "202": { + "description": "(empty)" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/dashboards/uid/{dashboardUid}/public-dashboards": { + "/datasources/uid/{sourceUID}/correlations": { "get": { - "description": "Get public dashboard by dashboardUid", "tags": [ - "dashboard_public" + "correlations" ], - "operationId": "getPublicDashboard", + "summary": "Gets all correlations originating from the given data source.", + "operationId": "getCorrelationsBySourceUID", "parameters": [ { "type": "string", - "name": "dashboardUid", + "name": "sourceUID", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getPublicDashboardResponse" - }, - "400": { - "$ref": "#/responses/badRequestPublicError" + "$ref": "#/responses/getCorrelationsBySourceUIDResponse" }, "401": { - "$ref": "#/responses/unauthorisedPublicError" - }, - "403": { - "$ref": "#/responses/forbiddenPublicError" + "$ref": "#/responses/unauthorisedError" }, "404": { - "$ref": "#/responses/notFoundPublicError" + "$ref": "#/responses/notFoundError" }, "500": { - "$ref": "#/responses/internalServerPublicError" + "$ref": "#/responses/internalServerError" } } }, "post": { - "description": "Create public dashboard for a dashboard", - "produces": [ - "application/json" - ], "tags": [ - "dashboard_public" + "correlations" ], - "operationId": "createPublicDashboard", + "summary": "Add correlation.", + "operationId": "createCorrelation", "parameters": [ { - "type": "string", - "name": "dashboardUid", - "in": "path", - "required": true - }, - { - "name": "Body", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/PublicDashboardDTO" + "$ref": "#/definitions/CreateCorrelationCommand" } + }, + { + "type": "string", + "name": "sourceUID", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/createPublicDashboardResponse" + "$ref": "#/responses/createCorrelationResponse" }, "400": { - "$ref": "#/responses/badRequestPublicError" + "$ref": "#/responses/badRequestError" }, "401": { - "$ref": "#/responses/unauthorisedPublicError" + "$ref": "#/responses/unauthorisedError" }, "403": { - "$ref": "#/responses/forbiddenPublicError" + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" }, "500": { - "$ref": "#/responses/internalServerPublicError" + "$ref": "#/responses/internalServerError" } } } }, - "/dashboards/uid/{dashboardUid}/public-dashboards/{uid}": { - "delete": { - "description": "Delete public dashboard for a dashboard", + "/datasources/uid/{sourceUID}/correlations/{correlationUID}": { + "get": { "tags": [ - "dashboard_public" + "correlations" ], - "operationId": "deletePublicDashboard", + "summary": "Gets a correlation.", + "operationId": "getCorrelation", "parameters": [ { "type": "string", - "name": "dashboardUid", + "name": "sourceUID", "in": "path", "required": true }, { "type": "string", - "name": "uid", + "name": "correlationUID", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestPublicError" + "$ref": "#/responses/getCorrelationResponse" }, "401": { - "$ref": "#/responses/unauthorisedPublicError" + "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenPublicError" + "404": { + "$ref": "#/responses/notFoundError" }, "500": { - "$ref": "#/responses/internalServerPublicError" + "$ref": "#/responses/internalServerError" } } }, "patch": { - "description": "Update public dashboard for a dashboard", - "produces": [ - "application/json" - ], "tags": [ - "dashboard_public" + "correlations" ], - "operationId": "updatePublicDashboard", + "summary": "Updates a correlation.", + "operationId": "updateCorrelation", "parameters": [ { "type": "string", - "name": "dashboardUid", + "name": "sourceUID", "in": "path", "required": true }, { "type": "string", - "name": "uid", + "name": "correlationUID", "in": "path", "required": true }, { - "name": "Body", + "name": "body", "in": "body", - "required": true, "schema": { - "$ref": "#/definitions/PublicDashboardDTO" + "$ref": "#/definitions/UpdateCorrelationCommand" } } ], "responses": { "200": { - "$ref": "#/responses/updatePublicDashboardResponse" + "$ref": "#/responses/updateCorrelationResponse" }, "400": { - "$ref": "#/responses/badRequestPublicError" + "$ref": "#/responses/badRequestError" }, "401": { - "$ref": "#/responses/unauthorisedPublicError" + "$ref": "#/responses/unauthorisedError" }, "403": { - "$ref": "#/responses/forbiddenPublicError" + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" }, "500": { - "$ref": "#/responses/internalServerPublicError" + "$ref": "#/responses/internalServerError" } } } }, - "/dashboards/uid/{uid}": { + "/datasources/uid/{uid}": { "get": { - "description": "Will return the dashboard given the dashboard unique identifier (uid).", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:kLtEtcRGk` (single data source).", "tags": [ - "dashboards" + "datasources" ], - "summary": "Get dashboard by uid.", - "operationId": "getDashboardByUID", + "summary": "Get a single data source by UID.", + "operationId": "getDataSourceByUID", "parameters": [ { "type": "string", @@ -4238,7 +3845,10 @@ ], "responses": { "200": { - "$ref": "#/responses/dashboardResponse" + "$ref": "#/responses/getDataSourceResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -4254,13 +3864,51 @@ } } }, + "put": { + "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).", + "tags": [ + "datasources" + ], + "summary": "Update an existing data source.", + "operationId": "updateDataSourceByUID", + "parameters": [ + { + "name": "Body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateDataSourceCommand" + } + }, + { + "type": "string", + "name": "uid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/createOrUpdateDatasourceResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, "delete": { - "description": "Will delete the dashboard given the specified unique identifier (uid).", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:kLtEtcRGk` (single data source).", "tags": [ - "dashboards" + "datasources" ], - "summary": "Delete dashboard by uid.", - "operationId": "deleteDashboardByUID", + "summary": "Delete an existing data source by UID.", + "operationId": "deleteDataSourceByUID", "parameters": [ { "type": "string", @@ -4271,7 +3919,7 @@ ], "responses": { "200": { - "$ref": "#/responses/deleteDashboardResponse" + "$ref": "#/responses/okResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -4288,24 +3936,30 @@ } } }, - "/dashboards/uid/{uid}/permissions": { - "get": { + "/datasources/uid/{uid}/correlations/{correlationUID}": { + "delete": { "tags": [ - "dashboard_permissions" + "correlations" ], - "summary": "Gets all existing permissions for the given dashboard.", - "operationId": "getDashboardPermissionsListByUID", + "summary": "Delete a correlation.", + "operationId": "deleteCorrelation", "parameters": [ { "type": "string", "name": "uid", "in": "path", "required": true + }, + { + "type": "string", + "name": "correlationUID", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/getDashboardPermissionsListResponse" + "$ref": "#/responses/deleteCorrelationResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -4320,23 +3974,16 @@ "$ref": "#/responses/internalServerError" } } - }, - "post": { - "description": "This operation will remove existing permissions if they’re not included in the request.", + } + }, + "/datasources/uid/{uid}/health": { + "get": { "tags": [ - "dashboard_permissions" + "datasources" ], - "summary": "Updates permissions for a dashboard.", - "operationId": "updateDashboardPermissionsByUID", + "summary": "Sends a health check request to the plugin datasource identified by the UID.", + "operationId": "checkDatasourceHealthWithUID", "parameters": [ - { - "name": "Body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/UpdateDashboardACLCommand" - } - }, { "type": "string", "name": "uid", @@ -4357,30 +4004,25 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/dashboards/uid/{uid}/restore": { - "post": { + "/datasources/uid/{uid}/resources/{datasource_proxy_route}": { + "get": { "tags": [ - "dashboard_versions" + "datasources" ], - "summary": "Restore a dashboard to a given dashboard version using UID.", - "operationId": "restoreDashboardVersionByUID", + "summary": "Fetch data source resources.", + "operationId": "callDatasourceResourceWithUID", "parameters": [ { - "name": "Body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/RestoreDashboardVersionCommand" - } + "type": "string", + "name": "datasource_proxy_route", + "in": "path", + "required": true }, { "type": "string", @@ -4391,7 +4033,10 @@ ], "responses": { "200": { - "$ref": "#/responses/postDashboardResponse" + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -4408,40 +4053,29 @@ } } }, - "/dashboards/uid/{uid}/versions": { + "/datasources/{id}": { "get": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).\n\nPlease refer to [updated API](#/datasources/getDataSourceByUID) instead", "tags": [ - "dashboard_versions" + "datasources" ], - "summary": "Gets all existing versions for the dashboard using UID.", - "operationId": "getDashboardVersionsByUID", + "summary": "Get a single data source by Id.", + "operationId": "getDataSourceByID", + "deprecated": true, "parameters": [ { "type": "string", - "name": "uid", + "name": "id", "in": "path", "required": true - }, - { - "type": "integer", - "format": "int64", - "default": 0, - "description": "Maximum number of results to return", - "name": "limit", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "default": 0, - "description": "Version to start from when returning queries", - "name": "start", - "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/dashboardVersionsResponse" + "$ref": "#/responses/getDataSourceResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -4456,33 +4090,34 @@ "$ref": "#/responses/internalServerError" } } - } - }, - "/dashboards/uid/{uid}/versions/{DashboardVersionID}": { - "get": { + }, + "put": { + "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).\n\nPlease refer to [updated API](#/datasources/updateDataSourceByUID) instead", "tags": [ - "dashboard_versions" + "datasources" ], - "summary": "Get a specific dashboard version using UID.", - "operationId": "getDashboardVersionByUID", + "summary": "Update an existing data source by its sequential ID.", + "operationId": "updateDataSourceByID", + "deprecated": true, "parameters": [ { - "type": "integer", - "format": "int64", - "name": "DashboardVersionID", - "in": "path", - "required": true + "name": "Body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateDataSourceCommand" + } }, { "type": "string", - "name": "uid", + "name": "id", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/dashboardVersionResponse" + "$ref": "#/responses/createOrUpdateDatasourceResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -4490,26 +4125,30 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } - } - }, - "/datasources": { - "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scope: `datasources:*`.", + }, + "delete": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).\n\nPlease refer to [updated API](#/datasources/deleteDataSourceByUID) instead", "tags": [ "datasources" ], - "summary": "Get all data sources.", - "operationId": "getDataSources", + "summary": "Delete an existing data source by id.", + "operationId": "deleteDataSourceByID", + "deprecated": true, + "parameters": [ + { + "type": "string", + "name": "id", + "in": "path", + "required": true + } + ], "responses": { "200": { - "$ref": "#/responses/getDataSourcesResponse" + "$ref": "#/responses/okResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -4517,31 +4156,38 @@ "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } - }, - "post": { - "description": "By defining `password` and `basicAuthPassword` under secureJsonData property\nGrafana encrypts them securely as an encrypted blob in the database.\nThe response then lists the encrypted fields under secureJsonFields.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:create`", + } + }, + "/datasources/{id}/health": { + "get": { + "description": "Please refer to [updated API](#/datasources/checkDatasourceHealthWithUID) instead", "tags": [ "datasources" ], - "summary": "Create a data source.", - "operationId": "addDataSource", + "summary": "Sends a health check request to the plugin datasource identified by the ID.", + "operationId": "checkDatasourceHealthByID", + "deprecated": true, "parameters": [ { - "name": "Body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/AddDataSourceCommand" - } + "type": "string", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/createOrUpdateDatasourceResponse" + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -4549,58 +4195,48 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "409": { - "$ref": "#/responses/conflictError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/datasources/correlations": { + "/datasources/{id}/resources/{datasource_proxy_route}": { "get": { + "description": "Please refer to [updated API](#/datasources/callDatasourceResourceWithUID) instead", "tags": [ - "correlations" + "datasources" ], - "summary": "Gets all correlations.", - "operationId": "getCorrelations", + "summary": "Fetch data source resources by Id.", + "operationId": "callDatasourceResourceByID", + "deprecated": true, "parameters": [ { - "maximum": 1000, - "type": "integer", - "format": "int64", - "default": 100, - "description": "Limit the maximum number of correlations to return per page", - "name": "limit", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "default": 1, - "description": "Page index for starting fetching correlations", - "name": "page", - "in": "query" + "type": "string", + "name": "datasource_proxy_route", + "in": "path", + "required": true }, { - "type": "array", - "items": { - "type": "string" - }, - "collectionFormat": "multi", - "description": "Source datasource UID filter to be applied to correlations", - "name": "sourceUID", - "in": "query" + "type": "string", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/getCorrelationsResponse" + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" }, + "403": { + "$ref": "#/responses/forbiddenError" + }, "404": { "$ref": "#/responses/notFoundError" }, @@ -4610,25 +4246,33 @@ } } }, - "/datasources/id/{name}": { - "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:name:*` and `datasources:name:test_datasource` (single data source).", + "/ds/query": { + "post": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:query`.", "tags": [ - "datasources" + "ds" ], - "summary": "Get data source Id by Name.", - "operationId": "getDataSourceIdByName", + "summary": "DataSource query metrics with expressions.", + "operationId": "queryMetricsWithExpressions", "parameters": [ { - "type": "string", - "name": "name", - "in": "path", - "required": true + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/MetricRequest" + } } ], "responses": { "200": { - "$ref": "#/responses/getDataSourceIDResponse" + "$ref": "#/responses/queryMetricsWithExpressionsRespons" + }, + "207": { + "$ref": "#/responses/queryMetricsWithExpressionsRespons" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -4636,34 +4280,58 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/datasources/name/{name}": { + "/folders": { "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:name:*` and `datasources:name:test_datasource` (single data source).", + "description": "It returns all folders that the authenticated user has permission to view.\nIf nested folders are enabled, it expects an additional query parameter with the parent folder UID\nand returns the immediate subfolders that the authenticated user has permission to view.\nIf the parameter is not supplied then it returns immediate subfolders under the root\nthat the authenticated user has permission to view.", "tags": [ - "datasources" + "folders" ], - "summary": "Get a single data source by Name.", - "operationId": "getDataSourceByName", + "summary": "Get all folders.", + "operationId": "getFolders", "parameters": [ + { + "type": "integer", + "format": "int64", + "default": 1000, + "description": "Limit the maximum number of folders to return", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "default": 1, + "description": "Page index for starting fetching folders", + "name": "page", + "in": "query" + }, { "type": "string", - "name": "name", - "in": "path", - "required": true + "description": "The parent folder UID", + "name": "parentUid", + "in": "query" + }, + { + "enum": [ + "Edit", + "View" + ], + "type": "string", + "default": "View", + "description": "Set to `Edit` to return folders that the user can edit", + "name": "permission", + "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/getDataSourceResponse" + "$ref": "#/responses/getFoldersResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -4676,24 +4344,29 @@ } } }, - "delete": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:name:*` and `datasources:name:test_datasource` (single data source).", + "post": { + "description": "If nested folders are enabled then it additionally expects the parent folder UID.", "tags": [ - "datasources" + "folders" ], - "summary": "Delete an existing data source by name.", - "operationId": "deleteDataSourceByName", + "summary": "Create folder.", + "operationId": "createFolder", "parameters": [ { - "type": "string", - "name": "name", - "in": "path", - "required": true + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateFolderCommand" + } } ], "responses": { "200": { - "$ref": "#/responses/deleteDataSourceByNameResponse" + "$ref": "#/responses/folderResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -4701,8 +4374,8 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" + "409": { + "$ref": "#/responses/conflictError" }, "500": { "$ref": "#/responses/internalServerError" @@ -4710,34 +4383,27 @@ } } }, - "/datasources/proxy/uid/{uid}/{datasource_proxy_route}": { + "/folders/id/{folder_id}": { "get": { - "description": "Proxies all calls to the actual data source.", + "description": "Returns the folder identified by id. This is deprecated.\nPlease refer to [updated API](#/folders/getFolderByUID) instead", "tags": [ - "datasources" + "folders" ], - "summary": "Data source proxy GET calls.", - "operationId": "datasourceProxyGETByUIDcalls", + "summary": "Get folder by id.", + "operationId": "getFolderByID", + "deprecated": true, "parameters": [ { - "type": "string", - "name": "datasource_proxy_route", - "in": "path", - "required": true - }, - { - "type": "string", - "name": "uid", + "type": "integer", + "format": "int64", + "name": "folder_id", "in": "path", "required": true } ], "responses": { "200": { - "description": "(empty)" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/folderResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -4752,43 +4418,26 @@ "$ref": "#/responses/internalServerError" } } - }, - "post": { - "description": "Proxies all calls to the actual data source. The data source should support POST methods for the specific path and role as defined", + } + }, + "/folders/{folder_uid}": { + "get": { "tags": [ - "datasources" + "folders" ], - "summary": "Data source proxy POST calls.", - "operationId": "datasourceProxyPOSTByUIDcalls", + "summary": "Get folder by uid.", + "operationId": "getFolderByUID", "parameters": [ { - "name": "DatasourceProxyParam", - "in": "body", - "required": true, - "schema": {} - }, - { - "type": "string", - "name": "datasource_proxy_route", - "in": "path", - "required": true - }, - { - "type": "string", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "201": { - "description": "(empty)" - }, - "202": { - "description": "(empty)" - }, - "400": { - "$ref": "#/responses/badRequestError" + "type": "string", + "name": "folder_uid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/folderResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -4804,30 +4453,32 @@ } } }, - "delete": { - "description": "Proxies all calls to the actual data source.", + "put": { "tags": [ - "datasources" + "folders" ], - "summary": "Data source proxy DELETE calls.", - "operationId": "datasourceProxyDELETEByUIDcalls", + "summary": "Update folder.", + "operationId": "updateFolder", "parameters": [ { "type": "string", - "name": "uid", + "name": "folder_uid", "in": "path", "required": true }, { - "type": "string", - "name": "datasource_proxy_route", - "in": "path", - "required": true + "description": "To change the unique identifier (uid), provide another one.\nTo overwrite an existing folder with newer version, set `overwrite` to `true`.\nProvide the current version to safelly update the folder: if the provided version differs from the stored one the request will fail, unless `overwrite` is `true`.", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateFolderCommand" + } } ], "responses": { - "202": { - "description": "(empty)" + "200": { + "$ref": "#/responses/folderResponse" }, "400": { "$ref": "#/responses/badRequestError" @@ -4841,38 +4492,39 @@ "404": { "$ref": "#/responses/notFoundError" }, + "409": { + "$ref": "#/responses/conflictError" + }, "500": { "$ref": "#/responses/internalServerError" } } - } - }, - "/datasources/proxy/{id}/{datasource_proxy_route}": { - "get": { - "description": "Proxies all calls to the actual data source.\n\nPlease refer to [updated API](#/datasources/datasourceProxyGETByUIDcalls) instead", + }, + "delete": { + "description": "Deletes an existing folder identified by UID along with all dashboards (and their alerts) stored in the folder. This operation cannot be reverted.\nIf nested folders are enabled then it also deletes all the subfolders.", "tags": [ - "datasources" + "folders" ], - "summary": "Data source proxy GET calls.", - "operationId": "datasourceProxyGETcalls", - "deprecated": true, + "summary": "Delete folder.", + "operationId": "deleteFolder", "parameters": [ { "type": "string", - "name": "datasource_proxy_route", + "name": "folder_uid", "in": "path", "required": true }, { - "type": "string", - "name": "id", - "in": "path", - "required": true + "type": "boolean", + "default": false, + "description": "If `true` any Grafana 8 Alerts under this folder will be deleted.\nSet to `false` so that the request will fail if the folder contains any Grafana 8 Alerts.", + "name": "forceDeleteRules", + "in": "query" } ], "responses": { "200": { - "description": "(empty)" + "$ref": "#/responses/deleteFolderResponse" }, "400": { "$ref": "#/responses/badRequestError" @@ -4890,44 +4542,26 @@ "$ref": "#/responses/internalServerError" } } - }, - "post": { - "description": "Proxies all calls to the actual data source. The data source should support POST methods for the specific path and role as defined\n\nPlease refer to [updated API](#/datasources/datasourceProxyPOSTByUIDcalls) instead", + } + }, + "/folders/{folder_uid}/counts": { + "get": { "tags": [ - "datasources" + "folders" ], - "summary": "Data source proxy POST calls.", - "operationId": "datasourceProxyPOSTcalls", - "deprecated": true, + "summary": "Gets the count of each descendant of a folder by kind. The folder is identified by UID.", + "operationId": "getFolderDescendantCounts", "parameters": [ - { - "name": "DatasourceProxyParam", - "in": "body", - "required": true, - "schema": {} - }, - { - "type": "string", - "name": "datasource_proxy_route", - "in": "path", - "required": true - }, { "type": "string", - "name": "id", + "name": "folder_uid", "in": "path", "required": true } ], "responses": { - "201": { - "description": "(empty)" - }, - "202": { - "description": "(empty)" - }, - "400": { - "$ref": "#/responses/badRequestError" + "200": { + "$ref": "#/responses/getFolderDescendantCountsResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -4942,35 +4576,34 @@ "$ref": "#/responses/internalServerError" } } - }, - "delete": { - "description": "Proxies all calls to the actual data source.\n\nPlease refer to [updated API](#/datasources/datasourceProxyDELETEByUIDcalls) instead", + } + }, + "/folders/{folder_uid}/move": { + "post": { "tags": [ - "datasources" + "folders" ], - "summary": "Data source proxy DELETE calls.", - "operationId": "datasourceProxyDELETEcalls", - "deprecated": true, + "summary": "Move folder.", + "operationId": "moveFolder", "parameters": [ { "type": "string", - "name": "id", + "name": "folder_uid", "in": "path", "required": true }, { - "type": "string", - "name": "datasource_proxy_route", - "in": "path", - "required": true + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/MoveFolderCommand" + } } ], "responses": { - "202": { - "description": "(empty)" - }, - "400": { - "$ref": "#/responses/badRequestError" + "200": { + "$ref": "#/responses/folderResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -4987,28 +4620,31 @@ } } }, - "/datasources/uid/{sourceUID}/correlations": { + "/folders/{folder_uid}/permissions": { "get": { "tags": [ - "correlations" + "folder_permissions" ], - "summary": "Gets all correlations originating from the given data source.", - "operationId": "getCorrelationsBySourceUID", + "summary": "Gets all existing permissions for the folder with the given `uid`.", + "operationId": "getFolderPermissionList", "parameters": [ { "type": "string", - "name": "sourceUID", + "name": "folder_uid", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getCorrelationsBySourceUIDResponse" + "$ref": "#/responses/getFolderPermissionListResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, + "403": { + "$ref": "#/responses/forbiddenError" + }, "404": { "$ref": "#/responses/notFoundError" }, @@ -5019,32 +4655,29 @@ }, "post": { "tags": [ - "correlations" + "folder_permissions" ], - "summary": "Add correlation.", - "operationId": "createCorrelation", + "summary": "Updates permissions for a folder. This operation will remove existing permissions if they’re not included in the request.", + "operationId": "updateFolderPermissions", "parameters": [ { - "name": "body", + "type": "string", + "name": "folder_uid", + "in": "path", + "required": true + }, + { + "name": "Body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/CreateCorrelationCommand" + "$ref": "#/definitions/UpdateDashboardACLCommand" } - }, - { - "type": "string", - "name": "sourceUID", - "in": "path", - "required": true } ], "responses": { "200": { - "$ref": "#/responses/createCorrelationResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/okResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -5061,72 +4694,109 @@ } } }, - "/datasources/uid/{sourceUID}/correlations/{correlationUID}": { + "/library-elements": { "get": { + "description": "Returns a list of all library elements the authenticated user has permission to view.\nUse the `perPage` query parameter to control the maximum number of library elements returned; the default limit is `100`.\nYou can also use the `page` query parameter to fetch library elements from any page other than the first one.", "tags": [ - "correlations" + "library_elements" ], - "summary": "Gets a correlation.", - "operationId": "getCorrelation", + "summary": "Get all library elements.", + "operationId": "getLibraryElements", "parameters": [ { "type": "string", - "name": "sourceUID", - "in": "path", - "required": true + "description": "Part of the name or description searched for.", + "name": "searchString", + "in": "query" + }, + { + "enum": [ + 1, + 2 + ], + "type": "integer", + "format": "int64", + "description": "Kind of element to search for.", + "name": "kind", + "in": "query" + }, + { + "enum": [ + "alpha-asc", + "alpha-desc" + ], + "type": "string", + "description": "Sort order of elements.", + "name": "sortDirection", + "in": "query" }, { "type": "string", - "name": "correlationUID", - "in": "path", - "required": true + "description": "A comma separated list of types to filter the elements by", + "name": "typeFilter", + "in": "query" + }, + { + "type": "string", + "description": "Element UID to exclude from search results.", + "name": "excludeUid", + "in": "query" + }, + { + "type": "string", + "description": "A comma separated list of folder ID(s) to filter the elements by.", + "name": "folderFilter", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "default": 100, + "description": "The number of results per page.", + "name": "perPage", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "default": 1, + "description": "The page for a set of records, given that only perPage records are returned at a time. Numbering starts at 1.", + "name": "page", + "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/getCorrelationResponse" + "$ref": "#/responses/getLibraryElementsResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } }, - "patch": { + "post": { + "description": "Creates a new library element.", "tags": [ - "correlations" + "library_elements" ], - "summary": "Updates a correlation.", - "operationId": "updateCorrelation", + "summary": "Create library element.", + "operationId": "createLibraryElement", "parameters": [ - { - "type": "string", - "name": "sourceUID", - "in": "path", - "required": true - }, - { - "type": "string", - "name": "correlationUID", - "in": "path", - "required": true - }, { "name": "body", "in": "body", + "required": true, "schema": { - "$ref": "#/definitions/UpdateCorrelationCommand" + "$ref": "#/definitions/CreateLibraryElementCommand" } } ], "responses": { "200": { - "$ref": "#/responses/updateCorrelationResponse" + "$ref": "#/responses/getLibraryElementResponse" }, "400": { "$ref": "#/responses/badRequestError" @@ -5146,35 +4816,29 @@ } } }, - "/datasources/uid/{uid}": { + "/library-elements/name/{library_element_name}": { "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:kLtEtcRGk` (single data source).", + "description": "Returns a library element with the given name.", "tags": [ - "datasources" + "library_elements" ], - "summary": "Get a single data source by UID.", - "operationId": "getDataSourceByUID", + "summary": "Get library element by name.", + "operationId": "getLibraryElementByName", "parameters": [ { "type": "string", - "name": "uid", + "name": "library_element_name", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getDataSourceResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/getLibraryElementArrayResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenError" - }, "404": { "$ref": "#/responses/notFoundError" }, @@ -5182,33 +4846,27 @@ "$ref": "#/responses/internalServerError" } } - }, - "put": { - "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).", + } + }, + "/library-elements/{library_element_uid}": { + "get": { + "description": "Returns a library element with the given UID.", "tags": [ - "datasources" + "library_elements" ], - "summary": "Update an existing data source.", - "operationId": "updateDataSourceByUID", + "summary": "Get library element by UID.", + "operationId": "getLibraryElementByUID", "parameters": [ - { - "name": "Body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/UpdateDataSourceCommand" - } - }, { "type": "string", - "name": "uid", + "name": "library_element_uid", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/createOrUpdateDatasourceResponse" + "$ref": "#/responses/getLibraryElementResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -5216,22 +4874,25 @@ "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } }, "delete": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:kLtEtcRGk` (single data source).", + "description": "Deletes an existing library element as specified by the UID. This operation cannot be reverted.\nYou cannot delete a library element that is connected. This operation cannot be reverted.", "tags": [ - "datasources" + "library_elements" ], - "summary": "Delete an existing data source by UID.", - "operationId": "deleteDataSourceByUID", + "summary": "Delete library element.", + "operationId": "deleteLibraryElementByUID", "parameters": [ { "type": "string", - "name": "uid", + "name": "library_element_uid", "in": "path", "required": true } @@ -5240,6 +4901,9 @@ "200": { "$ref": "#/responses/okResponse" }, + "400": { + "$ref": "#/responses/badRequestError" + }, "401": { "$ref": "#/responses/unauthorisedError" }, @@ -5253,32 +4917,36 @@ "$ref": "#/responses/internalServerError" } } - } - }, - "/datasources/uid/{uid}/correlations/{correlationUID}": { - "delete": { + }, + "patch": { + "description": "Updates an existing library element identified by uid.", "tags": [ - "correlations" + "library_elements" ], - "summary": "Delete a correlation.", - "operationId": "deleteCorrelation", + "summary": "Update library element.", + "operationId": "updateLibraryElement", "parameters": [ { - "type": "string", - "name": "uid", - "in": "path", - "required": true + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/PatchLibraryElementCommand" + } }, { "type": "string", - "name": "correlationUID", + "name": "library_element_uid", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/deleteCorrelationResponse" + "$ref": "#/responses/getLibraryElementResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -5289,33 +4957,34 @@ "404": { "$ref": "#/responses/notFoundError" }, + "412": { + "$ref": "#/responses/preconditionFailedError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/datasources/uid/{uid}/health": { + "/library-elements/{library_element_uid}/connections/": { "get": { + "description": "Returns a list of connections for a library element based on the UID specified.", "tags": [ - "datasources" + "library_elements" ], - "summary": "Sends a health check request to the plugin datasource identified by the UID.", - "operationId": "checkDatasourceHealthWithUID", + "summary": "Get library element connections.", + "operationId": "getLibraryElementConnections", "parameters": [ { "type": "string", - "name": "uid", + "name": "library_element_uid", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/getLibraryElementConnectionsResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -5323,48 +4992,79 @@ "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/datasources/uid/{uid}/resources/{datasource_proxy_route}": { + "/licensing/check": { "get": { "tags": [ - "datasources" + "licensing", + "enterprise" ], - "summary": "Fetch data source resources.", - "operationId": "callDatasourceResourceWithUID", - "parameters": [ - { - "type": "string", - "name": "datasource_proxy_route", - "in": "path", - "required": true - }, - { - "type": "string", - "name": "uid", - "in": "path", - "required": true + "summary": "Check license availability.", + "operationId": "getStatus", + "responses": { + "200": { + "$ref": "#/responses/getStatusResponse" + } + } + } + }, + "/licensing/custom-permissions": { + "get": { + "description": "You need to have a permission with action `licensing.reports:read`.", + "tags": [ + "licensing", + "enterprise" + ], + "summary": "Get custom permissions report.", + "operationId": "getCustomPermissionsReport", + "deprecated": true, + "responses": { + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/licensing/custom-permissions-csv": { + "get": { + "description": "You need to have a permission with action `licensing.reports:read`.", + "produces": [ + "text/csv" + ], + "tags": [ + "licensing", + "enterprise" + ], + "summary": "Get custom permissions report in CSV format.", + "operationId": "getCustomPermissionsCSV", + "deprecated": true, + "responses": { + "500": { + "$ref": "#/responses/internalServerError" } + } + } + }, + "/licensing/refresh-stats": { + "get": { + "description": "You need to have a permission with action `licensing:read`.", + "tags": [ + "licensing", + "enterprise" ], + "summary": "Refresh license stats.", + "operationId": "refreshLicenseStats", "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" + "$ref": "#/responses/refreshLicenseStatsResponse" }, "500": { "$ref": "#/responses/internalServerError" @@ -5372,71 +5072,72 @@ } } }, - "/datasources/{id}": { + "/licensing/token": { "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).\n\nPlease refer to [updated API](#/datasources/getDataSourceByUID) instead", + "description": "You need to have a permission with action `licensing:read`.", "tags": [ - "datasources" + "licensing", + "enterprise" ], - "summary": "Get a single data source by Id.", - "operationId": "getDataSourceByID", - "deprecated": true, + "summary": "Get license token.", + "operationId": "getLicenseToken", + "responses": { + "200": { + "$ref": "#/responses/getLicenseTokenResponse" + } + } + }, + "post": { + "description": "You need to have a permission with action `licensing:update`.", + "tags": [ + "licensing", + "enterprise" + ], + "summary": "Create license token.", + "operationId": "postLicenseToken", "parameters": [ { - "type": "string", - "name": "id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/DeleteTokenCommand" + } } ], "responses": { "200": { - "$ref": "#/responses/getDataSourceResponse" + "$ref": "#/responses/getLicenseTokenResponse" }, "400": { "$ref": "#/responses/badRequestError" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" - }, - "500": { - "$ref": "#/responses/internalServerError" } } }, - "put": { - "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).\n\nPlease refer to [updated API](#/datasources/updateDataSourceByUID) instead", + "delete": { + "description": "Removes the license stored in the Grafana database. Available in Grafana Enterprise v7.4+.\n\nYou need to have a permission with action `licensing:delete`.", "tags": [ - "datasources" + "licensing", + "enterprise" ], - "summary": "Update an existing data source by its sequential ID.", - "operationId": "updateDataSourceByID", - "deprecated": true, + "summary": "Remove license from database.", + "operationId": "deleteLicenseToken", "parameters": [ { - "name": "Body", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateDataSourceCommand" + "$ref": "#/definitions/DeleteTokenCommand" } - }, - { - "type": "string", - "name": "id", - "in": "path", - "required": true } ], "responses": { - "200": { - "$ref": "#/responses/createOrUpdateDatasourceResponse" + "202": { + "$ref": "#/responses/acceptedResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -5444,36 +5145,58 @@ "403": { "$ref": "#/responses/forbiddenError" }, + "422": { + "$ref": "#/responses/unprocessableEntityError" + }, "500": { "$ref": "#/responses/internalServerError" } } - }, - "delete": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).\n\nPlease refer to [updated API](#/datasources/deleteDataSourceByUID) instead", + } + }, + "/licensing/token/renew": { + "post": { + "description": "Manually ask license issuer for a new token. Available in Grafana Enterprise v7.4+.\n\nYou need to have a permission with action `licensing:update`.", "tags": [ - "datasources" + "licensing", + "enterprise" ], - "summary": "Delete an existing data source by id.", - "operationId": "deleteDataSourceByID", - "deprecated": true, + "summary": "Manually force license refresh.", + "operationId": "postRenewLicenseToken", "parameters": [ { - "type": "string", - "name": "id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } } ], "responses": { "200": { - "$ref": "#/responses/okResponse" + "$ref": "#/responses/postRenewLicenseTokenResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenError" + "404": { + "$ref": "#/responses/notFoundError" + } + } + } + }, + "/logout/saml": { + "get": { + "tags": [ + "saml", + "enterprise" + ], + "summary": "GetLogout initiates single logout process.", + "operationId": "getSAMLLogout", + "responses": { + "302": { + "description": "(empty)" }, "404": { "$ref": "#/responses/notFoundError" @@ -5484,29 +5207,16 @@ } } }, - "/datasources/{id}/health": { + "/org": { "get": { - "description": "Please refer to [updated API](#/datasources/checkDatasourceHealthWithUID) instead", "tags": [ - "datasources" - ], - "summary": "Sends a health check request to the plugin datasource identified by the ID.", - "operationId": "checkDatasourceHealthByID", - "deprecated": true, - "parameters": [ - { - "type": "string", - "name": "id", - "in": "path", - "required": true - } + "org" ], + "summary": "Get current Organization.", + "operationId": "getCurrentOrg", "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/getCurrentOrgResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -5518,29 +5228,21 @@ "$ref": "#/responses/internalServerError" } } - } - }, - "/datasources/{id}/resources/{datasource_proxy_route}": { - "get": { - "description": "Please refer to [updated API](#/datasources/callDatasourceResourceWithUID) instead", + }, + "put": { "tags": [ - "datasources" + "org" ], - "summary": "Fetch data source resources by Id.", - "operationId": "callDatasourceResourceByID", - "deprecated": true, + "summary": "Update current Organization.", + "operationId": "updateCurrentOrg", "parameters": [ { - "type": "string", - "name": "datasource_proxy_route", - "in": "path", - "required": true - }, - { - "type": "string", - "name": "id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateOrgForm" + } } ], "responses": { @@ -5556,39 +5258,32 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/ds/query": { - "post": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:query`.", + "/org/address": { + "put": { "tags": [ - "ds" + "org" ], - "summary": "DataSource query metrics with expressions.", - "operationId": "queryMetricsWithExpressions", + "summary": "Update current Organization's address.", + "operationId": "updateCurrentOrgAddress", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/MetricRequest" + "$ref": "#/definitions/UpdateOrgAddressForm" } } ], "responses": { "200": { - "$ref": "#/responses/queryMetricsWithExpressionsRespons" - }, - "207": { - "$ref": "#/responses/queryMetricsWithExpressionsRespons" + "$ref": "#/responses/okResponse" }, "400": { "$ref": "#/responses/badRequestError" @@ -5605,41 +5300,16 @@ } } }, - "/folders": { + "/org/invites": { "get": { - "description": "Returns all folders that the authenticated user has permission to view.\nIf nested folders are enabled, it expects an additional query parameter with the parent folder UID\nand returns the immediate subfolders that the authenticated user has permission to view.\nIf the parameter is not supplied then it returns immediate subfolders under the root\nthat the authenticated user has permission to view.", "tags": [ - "folders" - ], - "summary": "Get all folders.", - "operationId": "getFolders", - "parameters": [ - { - "type": "integer", - "format": "int64", - "default": 1000, - "description": "Limit the maximum number of folders to return", - "name": "limit", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "default": 1, - "description": "Page index for starting fetching folders", - "name": "page", - "in": "query" - }, - { - "type": "string", - "description": "The parent folder UID", - "name": "parentUid", - "in": "query" - } + "org_invites" ], + "summary": "Get pending invites.", + "operationId": "getPendingOrgInvites", "responses": { "200": { - "$ref": "#/responses/getFoldersResponse" + "$ref": "#/responses/getPendingOrgInvitesResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -5653,25 +5323,24 @@ } }, "post": { - "description": "If nested folders are enabled then it additionally expects the parent folder UID.", "tags": [ - "folders" + "org_invites" ], - "summary": "Create folder.", - "operationId": "createFolder", + "summary": "Add invite.", + "operationId": "addOrgInvite", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/CreateFolderCommand" + "$ref": "#/definitions/AddInviteForm" } } ], "responses": { "200": { - "$ref": "#/responses/folderResponse" + "$ref": "#/responses/okResponse" }, "400": { "$ref": "#/responses/badRequestError" @@ -5682,8 +5351,8 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "409": { - "$ref": "#/responses/conflictError" + "412": { + "$ref": "#/responses/SMTPNotEnabledError" }, "500": { "$ref": "#/responses/internalServerError" @@ -5691,27 +5360,24 @@ } } }, - "/folders/id/{folder_id}": { - "get": { - "description": "Returns the folder identified by id. This is deprecated.\nPlease refer to [updated API](#/folders/getFolderByUID) instead", + "/org/invites/{invitation_code}/revoke": { + "delete": { "tags": [ - "folders" + "org_invites" ], - "summary": "Get folder by id.", - "operationId": "getFolderByID", - "deprecated": true, + "summary": "Revoke invite.", + "operationId": "revokeInvite", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "folder_id", + "type": "string", + "name": "invitation_code", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/folderResponse" + "$ref": "#/responses/okResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -5728,24 +5394,16 @@ } } }, - "/folders/{folder_uid}": { + "/org/preferences": { "get": { "tags": [ - "folders" - ], - "summary": "Get folder by uid.", - "operationId": "getFolderByUID", - "parameters": [ - { - "type": "string", - "name": "folder_uid", - "in": "path", - "required": true - } + "org_preferences" ], + "summary": "Get Current Org Prefs.", + "operationId": "getOrgPreferences", "responses": { "200": { - "$ref": "#/responses/folderResponse" + "$ref": "#/responses/getPreferencesResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -5753,9 +5411,6 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } @@ -5763,30 +5418,23 @@ }, "put": { "tags": [ - "folders" + "org_preferences" ], - "summary": "Update folder.", - "operationId": "updateFolder", + "summary": "Update Current Org Prefs.", + "operationId": "updateOrgPreferences", "parameters": [ { - "type": "string", - "name": "folder_uid", - "in": "path", - "required": true - }, - { - "description": "To change the unique identifier (uid), provide another one.\nTo overwrite an existing folder with newer version, set `overwrite` to `true`.\nProvide the current version to safelly update the folder: if the provided version differs from the stored one the request will fail, unless `overwrite` is `true`.", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateFolderCommand" + "$ref": "#/definitions/UpdatePrefsCmd" } } ], "responses": { "200": { - "$ref": "#/responses/folderResponse" + "$ref": "#/responses/okResponse" }, "400": { "$ref": "#/responses/badRequestError" @@ -5797,42 +5445,30 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, - "409": { - "$ref": "#/responses/conflictError" - }, "500": { "$ref": "#/responses/internalServerError" } } }, - "delete": { - "description": "Deletes an existing folder identified by UID along with all dashboards (and their alerts) stored in the folder. This operation cannot be reverted.\nIf nested folders are enabled then it also deletes all the subfolders.", + "patch": { "tags": [ - "folders" + "org_preferences" ], - "summary": "Delete folder.", - "operationId": "deleteFolder", + "summary": "Patch Current Org Prefs.", + "operationId": "patchOrgPreferences", "parameters": [ { - "type": "string", - "name": "folder_uid", - "in": "path", - "required": true - }, - { - "type": "boolean", - "default": false, - "description": "If `true` any Grafana 8 Alerts under this folder will be deleted.\nSet to `false` so that the request will fail if the folder contains any Grafana 8 Alerts.", - "name": "forceDeleteRules", - "in": "query" + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/PatchPrefsCmd" + } } ], "responses": { "200": { - "$ref": "#/responses/deleteFolderResponse" + "$ref": "#/responses/okResponse" }, "400": { "$ref": "#/responses/badRequestError" @@ -5843,33 +5479,23 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/folders/{folder_uid}/counts": { + "/org/quotas": { "get": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:read` and scope `org:id:1` (orgIDScope).", "tags": [ - "folders" - ], - "summary": "Gets the count of each descendant of a folder by kind. The folder is identified by UID.", - "operationId": "getFolderDescendantCounts", - "parameters": [ - { - "type": "string", - "name": "folder_uid", - "in": "path", - "required": true - } + "getCurrentOrg" ], + "summary": "Fetch Organization quota.", + "operationId": "getCurrentOrgQuota", "responses": { "200": { - "$ref": "#/responses/getFolderDescendantCountsResponse" + "$ref": "#/responses/getQuotaResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -5886,32 +5512,49 @@ } } }, - "/folders/{folder_uid}/move": { + "/org/users": { + "get": { + "description": "Returns all org users within the current organization. Accessible to users with org admin role.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", + "tags": [ + "org" + ], + "summary": "Get all users within the current organization.", + "operationId": "getOrgUsersForCurrentOrg", + "responses": { + "200": { + "$ref": "#/responses/getOrgUsersForCurrentOrgResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, "post": { + "description": "Adds a global user to the current organization.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:add` with scope `users:*`.", "tags": [ - "folders" + "org" ], - "summary": "Move folder.", - "operationId": "moveFolder", + "summary": "Add a new user to the current organization.", + "operationId": "addOrgUserToCurrentOrg", "parameters": [ - { - "type": "string", - "name": "folder_uid", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/MoveFolderCommand" + "$ref": "#/definitions/AddOrgUserCommand" } } ], "responses": { "200": { - "$ref": "#/responses/folderResponse" + "$ref": "#/responses/okResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -5919,33 +5562,36 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/folders/{folder_uid}/permissions": { + "/org/users/lookup": { "get": { + "description": "Returns all org users within the current organization, but with less detailed information.\nAccessible to users with org admin role, admin in any folder or admin of any team.\nMainly used by Grafana UI for providing list of users when adding team members and when editing folder/dashboard permissions.", "tags": [ - "folder_permissions" + "org" ], - "summary": "Gets all existing permissions for the folder with the given `uid`.", - "operationId": "getFolderPermissionList", + "summary": "Get all users within the current organization (lookup)", + "operationId": "getOrgUsersForCurrentOrgLookup", "parameters": [ { "type": "string", - "name": "folder_uid", - "in": "path", - "required": true + "name": "query", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "name": "limit", + "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/getFolderPermissionListResponse" + "$ref": "#/responses/getOrgUsersForCurrentOrgLookupResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -5953,161 +5599,168 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } - }, - "post": { + } + }, + "/org/users/{user_id}": { + "delete": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:remove` with scope `users:*`.", "tags": [ - "folder_permissions" + "org" ], - "summary": "Updates permissions for a folder. This operation will remove existing permissions if they’re not included in the request.", - "operationId": "updateFolderPermissions", + "summary": "Delete user in current organization.", + "operationId": "removeOrgUserForCurrentOrg", "parameters": [ { - "type": "string", - "name": "folder_uid", + "type": "integer", + "format": "int64", + "name": "user_id", "in": "path", "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, + "patch": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users.role:update` with scope `users:*`.", + "tags": [ + "org" + ], + "summary": "Updates the given user.", + "operationId": "updateOrgUserForCurrentOrg", + "parameters": [ { - "name": "Body", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateDashboardACLCommand" + "$ref": "#/definitions/UpdateOrgUserCommand" } + }, + { + "type": "integer", + "format": "int64", + "name": "user_id", + "in": "path", + "required": true } ], "responses": { "200": { "$ref": "#/responses/okResponse" }, + "400": { + "$ref": "#/responses/badRequestError" + }, "401": { "$ref": "#/responses/unauthorisedError" }, "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/library-elements": { + "/orgs": { "get": { - "description": "Returns a list of all library elements the authenticated user has permission to view.\nUse the `perPage` query parameter to control the maximum number of library elements returned; the default limit is `100`.\nYou can also use the `page` query parameter to fetch library elements from any page other than the first one.", + "security": [ + { + "basic": [] + } + ], "tags": [ - "library_elements" + "orgs" ], - "summary": "Get all library elements.", - "operationId": "getLibraryElements", + "summary": "Search all Organizations.", + "operationId": "searchOrgs", "parameters": [ { - "type": "string", - "description": "Part of the name or description searched for.", - "name": "searchString", - "in": "query" - }, - { - "enum": [ - 1, - 2 - ], "type": "integer", "format": "int64", - "description": "Kind of element to search for.", - "name": "kind", - "in": "query" - }, - { - "enum": [ - "alpha-asc", - "alpha-desc" - ], - "type": "string", - "description": "Sort order of elements.", - "name": "sortDirection", - "in": "query" - }, - { - "type": "string", - "description": "A comma separated list of types to filter the elements by", - "name": "typeFilter", - "in": "query" - }, - { - "type": "string", - "description": "Element UID to exclude from search results.", - "name": "excludeUid", + "default": 1, + "name": "page", "in": "query" }, { - "type": "string", - "description": "A comma separated list of folder ID(s) to filter the elements by.", - "name": "folderFilter", + "type": "integer", + "format": "int64", + "default": 1000, + "description": "Number of items per page\nThe totalCount field in the response can be used for pagination list E.g. if totalCount is equal to 100 teams and the perpage parameter is set to 10 then there are 10 pages of teams.", + "name": "perpage", "in": "query" }, { - "type": "integer", - "format": "int64", - "default": 100, - "description": "The number of results per page.", - "name": "perPage", + "type": "string", + "name": "name", "in": "query" }, { - "type": "integer", - "format": "int64", - "default": 1, - "description": "The page for a set of records, given that only perPage records are returned at a time. Numbering starts at 1.", - "name": "page", + "type": "string", + "description": "If set it will return results where the query value is contained in the name field. Query values with spaces need to be URL encoded.", + "name": "query", "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/getLibraryElementsResponse" + "$ref": "#/responses/searchOrgsResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "409": { + "$ref": "#/responses/conflictError" + }, "500": { "$ref": "#/responses/internalServerError" } } }, "post": { - "description": "Creates a new library element.", + "description": "Only works if [users.allow_org_create](https://grafana.com/docs/grafana/latest/administration/configuration/#allow_org_create) is set.", "tags": [ - "library_elements" + "orgs" ], - "summary": "Create library element.", - "operationId": "createLibraryElement", + "summary": "Create Organization.", + "operationId": "createOrg", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/CreateLibraryElementCommand" + "$ref": "#/definitions/CreateOrgCommand" } } ], "responses": { "200": { - "$ref": "#/responses/getLibraryElementResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/createOrgResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -6115,8 +5768,8 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" + "409": { + "$ref": "#/responses/conflictError" }, "500": { "$ref": "#/responses/internalServerError" @@ -6124,31 +5777,35 @@ } } }, - "/library-elements/name/{library_element_name}": { + "/orgs/name/{org_name}": { "get": { - "description": "Returns a library element with the given name.", + "security": [ + { + "basic": [] + } + ], "tags": [ - "library_elements" + "orgs" ], - "summary": "Get library element by name.", - "operationId": "getLibraryElementByName", + "summary": "Get Organization by ID.", + "operationId": "getOrgByName", "parameters": [ { "type": "string", - "name": "library_element_name", + "name": "org_name", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getLibraryElementArrayResponse" + "$ref": "#/responses/getOrgByNameResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, - "404": { - "$ref": "#/responses/notFoundError" + "403": { + "$ref": "#/responses/forbiddenError" }, "500": { "$ref": "#/responses/internalServerError" @@ -6156,61 +5813,30 @@ } } }, - "/library-elements/{library_element_uid}": { + "/orgs/{org_id}": { "get": { - "description": "Returns a library element with the given UID.", - "tags": [ - "library_elements" - ], - "summary": "Get library element by UID.", - "operationId": "getLibraryElementByUID", - "parameters": [ + "security": [ { - "type": "string", - "name": "library_element_uid", - "in": "path", - "required": true + "basic": [] } ], - "responses": { - "200": { - "$ref": "#/responses/getLibraryElementResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" - }, - "500": { - "$ref": "#/responses/internalServerError" - } - } - }, - "delete": { - "description": "Deletes an existing library element as specified by the UID. This operation cannot be reverted.\nYou cannot delete a library element that is connected. This operation cannot be reverted.", "tags": [ - "library_elements" + "orgs" ], - "summary": "Delete library element.", - "operationId": "deleteLibraryElementByUID", + "summary": "Get Organization by ID.", + "operationId": "getOrgByID", "parameters": [ { - "type": "string", - "name": "library_element_uid", + "type": "integer", + "format": "int64", + "name": "org_id", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/getOrgByIDResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -6218,40 +5844,42 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } }, - "patch": { - "description": "Updates an existing library element identified by uid.", + "put": { + "security": [ + { + "basic": [] + } + ], "tags": [ - "library_elements" + "orgs" ], - "summary": "Update library element.", - "operationId": "updateLibraryElement", + "summary": "Update Organization.", + "operationId": "updateOrg", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/PatchLibraryElementCommand" + "$ref": "#/definitions/UpdateOrgForm" } }, { - "type": "string", - "name": "library_element_uid", + "type": "integer", + "format": "int64", + "name": "org_id", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getLibraryElementResponse" + "$ref": "#/responses/okResponse" }, "400": { "$ref": "#/responses/badRequestError" @@ -6262,117 +5890,46 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, - "412": { - "$ref": "#/responses/preconditionFailedError" - }, "500": { "$ref": "#/responses/internalServerError" } } - } - }, - "/library-elements/{library_element_uid}/connections/": { - "get": { - "description": "Returns a list of connections for a library element based on the UID specified.", - "tags": [ - "library_elements" - ], - "summary": "Get library element connections.", - "operationId": "getLibraryElementConnections", - "parameters": [ + }, + "delete": { + "security": [ { - "type": "string", - "name": "library_element_uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/responses/getLibraryElementConnectionsResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" - }, - "500": { - "$ref": "#/responses/internalServerError" - } - } - } - }, - "/licensing/check": { - "get": { - "tags": [ - "licensing", - "enterprise" - ], - "summary": "Check license availability.", - "operationId": "getStatus", - "responses": { - "200": { - "$ref": "#/responses/getStatusResponse" - } - } - } - }, - "/licensing/custom-permissions": { - "get": { - "description": "You need to have a permission with action `licensing.reports:read`.", - "tags": [ - "licensing", - "enterprise" - ], - "summary": "Get custom permissions report.", - "operationId": "getCustomPermissionsReport", - "deprecated": true, - "responses": { - "500": { - "$ref": "#/responses/internalServerError" + "basic": [] } - } - } - }, - "/licensing/custom-permissions-csv": { - "get": { - "description": "You need to have a permission with action `licensing.reports:read`.", - "produces": [ - "text/csv" ], "tags": [ - "licensing", - "enterprise" + "orgs" ], - "summary": "Get custom permissions report in CSV format.", - "operationId": "getCustomPermissionsCSV", - "deprecated": true, - "responses": { - "500": { - "$ref": "#/responses/internalServerError" - } - } - } - }, - "/licensing/refresh-stats": { - "get": { - "description": "You need to have a permission with action `licensing:read`.", - "tags": [ - "licensing", - "enterprise" + "summary": "Delete Organization.", + "operationId": "deleteOrgByID", + "parameters": [ + { + "type": "integer", + "format": "int64", + "name": "org_id", + "in": "path", + "required": true + } ], - "summary": "Refresh license stats.", - "operationId": "refreshLicenseStats", "responses": { "200": { - "$ref": "#/responses/refreshLicenseStatsResponse" + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" }, "500": { "$ref": "#/responses/internalServerError" @@ -6380,72 +5937,69 @@ } } }, - "/licensing/token": { - "get": { - "description": "You need to have a permission with action `licensing:read`.", - "tags": [ - "licensing", - "enterprise" - ], - "summary": "Get license token.", - "operationId": "getLicenseToken", - "responses": { - "200": { - "$ref": "#/responses/getLicenseTokenResponse" - } - } - }, - "post": { - "description": "You need to have a permission with action `licensing:update`.", + "/orgs/{org_id}/address": { + "put": { "tags": [ - "licensing", - "enterprise" + "orgs" ], - "summary": "Create license token.", - "operationId": "postLicenseToken", + "summary": "Update Organization's address.", + "operationId": "updateOrgAddress", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/DeleteTokenCommand" + "$ref": "#/definitions/UpdateOrgAddressForm" } + }, + { + "type": "integer", + "format": "int64", + "name": "org_id", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/getLicenseTokenResponse" + "$ref": "#/responses/okResponse" }, "400": { "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" } } - }, - "delete": { - "description": "Removes the license stored in the Grafana database. Available in Grafana Enterprise v7.4+.\n\nYou need to have a permission with action `licensing:delete`.", + } + }, + "/orgs/{org_id}/quotas": { + "get": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:read` and scope `org:id:1` (orgIDScope).", "tags": [ - "licensing", - "enterprise" + "orgs" ], - "summary": "Remove license from database.", - "operationId": "deleteLicenseToken", + "summary": "Fetch Organization quota.", + "operationId": "getOrgQuota", "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/DeleteTokenCommand" - } + "type": "integer", + "format": "int64", + "name": "org_id", + "in": "path", + "required": true } ], "responses": { - "202": { - "$ref": "#/responses/acceptedResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "200": { + "$ref": "#/responses/getQuotaResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -6453,8 +6007,8 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "422": { - "$ref": "#/responses/unprocessableEntityError" + "404": { + "$ref": "#/responses/notFoundError" }, "500": { "$ref": "#/responses/internalServerError" @@ -6462,52 +6016,131 @@ } } }, - "/licensing/token/renew": { - "post": { - "description": "Manually ask license issuer for a new token. Available in Grafana Enterprise v7.4+.\n\nYou need to have a permission with action `licensing:update`.", + "/orgs/{org_id}/quotas/{quota_target}": { + "put": { + "security": [ + { + "basic": [] + } + ], + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:write` and scope `org:id:1` (orgIDScope).", "tags": [ - "licensing", - "enterprise" + "orgs" ], - "summary": "Manually force license refresh.", - "operationId": "postRenewLicenseToken", + "summary": "Update user quota.", + "operationId": "updateOrgQuota", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "type": "object" + "$ref": "#/definitions/UpdateQuotaCmd" } + }, + { + "type": "string", + "name": "quota_target", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "name": "org_id", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/postRenewLicenseTokenResponse" + "$ref": "#/responses/okResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, + "403": { + "$ref": "#/responses/forbiddenError" + }, "404": { "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" } } } }, - "/logout/saml": { + "/orgs/{org_id}/users": { "get": { + "security": [ + { + "basic": [] + } + ], + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", "tags": [ - "saml", - "enterprise" + "orgs" + ], + "summary": "Get Users in Organization.", + "operationId": "getOrgUsers", + "parameters": [ + { + "type": "integer", + "format": "int64", + "name": "org_id", + "in": "path", + "required": true + } ], - "summary": "GetLogout initiates single logout process.", - "operationId": "getSAMLLogout", "responses": { - "302": { - "description": "(empty)" + "200": { + "$ref": "#/responses/getOrgUsersResponse" }, - "404": { - "$ref": "#/responses/notFoundError" + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, + "post": { + "description": "Adds a global user to the current organization.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:add` with scope `users:*`.", + "tags": [ + "orgs" + ], + "summary": "Add a new user to the current organization.", + "operationId": "addOrgUser", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/AddOrgUserCommand" + } + }, + { + "type": "integer", + "format": "int64", + "name": "org_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/okResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" }, "500": { "$ref": "#/responses/internalServerError" @@ -6515,16 +6148,31 @@ } } }, - "/org": { + "/orgs/{org_id}/users/search": { "get": { + "security": [ + { + "basic": [] + } + ], + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", "tags": [ - "org" + "orgs" + ], + "summary": "Search Users in Organization.", + "operationId": "searchOrgUsers", + "parameters": [ + { + "type": "integer", + "format": "int64", + "name": "org_id", + "in": "path", + "required": true + } ], - "summary": "Get current Organization.", - "operationId": "getCurrentOrg", "responses": { "200": { - "$ref": "#/responses/getCurrentOrgResponse" + "$ref": "#/responses/searchOrgUsersResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -6536,21 +6184,30 @@ "$ref": "#/responses/internalServerError" } } - }, - "put": { + } + }, + "/orgs/{org_id}/users/{user_id}": { + "delete": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:remove` with scope `users:*`.", "tags": [ - "org" + "orgs" ], - "summary": "Update current Organization.", - "operationId": "updateCurrentOrg", + "summary": "Delete user in current organization.", + "operationId": "removeOrgUser", "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/UpdateOrgForm" - } + "type": "integer", + "format": "int64", + "name": "org_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "name": "user_id", + "in": "path", + "required": true } ], "responses": { @@ -6570,23 +6227,36 @@ "$ref": "#/responses/internalServerError" } } - } - }, - "/org/address": { - "put": { + }, + "patch": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users.role:update` with scope `users:*`.", "tags": [ - "org" + "orgs" ], - "summary": "Update current Organization's address.", - "operationId": "updateCurrentOrgAddress", + "summary": "Update Users in Organization.", + "operationId": "updateOrgUser", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateOrgAddressForm" + "$ref": "#/definitions/UpdateOrgUserCommand" } + }, + { + "type": "integer", + "format": "int64", + "name": "org_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "name": "user_id", + "in": "path", + "required": true } ], "responses": { @@ -6608,22 +6278,30 @@ } } }, - "/org/invites": { + "/playlists": { "get": { "tags": [ - "org_invites" + "playlists" + ], + "summary": "Get playlists.", + "operationId": "searchPlaylists", + "parameters": [ + { + "type": "string", + "name": "query", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "in:limit", + "name": "limit", + "in": "query" + } ], - "summary": "Get pending invites.", - "operationId": "getPendingOrgInvites", "responses": { "200": { - "$ref": "#/responses/getPendingOrgInvitesResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" + "$ref": "#/responses/searchPlaylistsResponse" }, "500": { "$ref": "#/responses/internalServerError" @@ -6632,26 +6310,23 @@ }, "post": { "tags": [ - "org_invites" + "playlists" ], - "summary": "Add invite.", - "operationId": "addOrgInvite", + "summary": "Create playlist.", + "operationId": "createPlaylist", "parameters": [ { - "name": "body", + "name": "Body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/AddInviteForm" + "$ref": "#/definitions/CreatePlaylistCommand" } } ], "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/createPlaylistResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -6659,8 +6334,8 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "412": { - "$ref": "#/responses/SMTPNotEnabledError" + "404": { + "$ref": "#/responses/notFoundError" }, "500": { "$ref": "#/responses/internalServerError" @@ -6668,24 +6343,24 @@ } } }, - "/org/invites/{invitation_code}/revoke": { - "delete": { + "/playlists/{uid}": { + "get": { "tags": [ - "org_invites" + "playlists" ], - "summary": "Revoke invite.", - "operationId": "revokeInvite", + "summary": "Get playlist.", + "operationId": "getPlaylist", "parameters": [ { "type": "string", - "name": "invitation_code", + "name": "uid", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/okResponse" + "$ref": "#/responses/getPlaylistResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -6700,18 +6375,32 @@ "$ref": "#/responses/internalServerError" } } - } - }, - "/org/preferences": { - "get": { + }, + "put": { "tags": [ - "org_preferences" + "playlists" + ], + "summary": "Update playlist.", + "operationId": "updatePlaylist", + "parameters": [ + { + "name": "Body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdatePlaylistCommand" + } + }, + { + "type": "string", + "name": "uid", + "in": "path", + "required": true + } ], - "summary": "Get Current Org Prefs.", - "operationId": "getOrgPreferences", "responses": { "200": { - "$ref": "#/responses/getPreferencesResponse" + "$ref": "#/responses/updatePlaylistResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -6719,67 +6408,65 @@ "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } }, - "put": { + "delete": { "tags": [ - "org_preferences" + "playlists" ], - "summary": "Update Current Org Prefs.", - "operationId": "updateOrgPreferences", + "summary": "Delete playlist.", + "operationId": "deletePlaylist", "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/UpdatePrefsCmd" - } + "type": "string", + "name": "uid", + "in": "path", + "required": true } ], "responses": { "200": { "$ref": "#/responses/okResponse" }, - "400": { - "$ref": "#/responses/badRequestError" - }, "401": { "$ref": "#/responses/unauthorisedError" }, "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } - }, - "patch": { + } + }, + "/playlists/{uid}/items": { + "get": { "tags": [ - "org_preferences" + "playlists" ], - "summary": "Patch Current Org Prefs.", - "operationId": "patchOrgPreferences", + "summary": "Get playlist items.", + "operationId": "getPlaylistItems", "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/PatchPrefsCmd" - } + "type": "string", + "name": "uid", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/getPlaylistItemsResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -6787,152 +6474,236 @@ "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/org/quotas": { + "/public/dashboards/{accessToken}": { "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:read` and scope `org:id:1` (orgIDScope).", + "description": "Get public dashboard for view", "tags": [ - "getCurrentOrg" + "dashboard_public" + ], + "operationId": "viewPublicDashboard", + "parameters": [ + { + "type": "string", + "name": "accessToken", + "in": "path", + "required": true + } ], - "summary": "Fetch Organization quota.", - "operationId": "getCurrentOrgQuota", "responses": { "200": { - "$ref": "#/responses/getQuotaResponse" + "$ref": "#/responses/viewPublicDashboardResponse" + }, + "400": { + "$ref": "#/responses/badRequestPublicError" }, "401": { - "$ref": "#/responses/unauthorisedError" + "$ref": "#/responses/unauthorisedPublicError" }, "403": { - "$ref": "#/responses/forbiddenError" + "$ref": "#/responses/forbiddenPublicError" }, "404": { - "$ref": "#/responses/notFoundError" + "$ref": "#/responses/notFoundPublicError" }, "500": { - "$ref": "#/responses/internalServerError" + "$ref": "#/responses/internalServerPublicError" } } } }, - "/org/users": { + "/public/dashboards/{accessToken}/annotations": { "get": { - "description": "Returns all org users within the current organization. Accessible to users with org admin role.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", + "description": "Get annotations for a public dashboard", "tags": [ - "org" + "dashboard_public" + ], + "operationId": "getPublicAnnotations", + "parameters": [ + { + "type": "string", + "name": "accessToken", + "in": "path", + "required": true + } ], - "summary": "Get all users within the current organization.", - "operationId": "getOrgUsersForCurrentOrg", "responses": { "200": { - "$ref": "#/responses/getOrgUsersForCurrentOrgResponse" + "$ref": "#/responses/getPublicAnnotationsResponse" + }, + "400": { + "$ref": "#/responses/badRequestPublicError" }, "401": { - "$ref": "#/responses/unauthorisedError" + "$ref": "#/responses/unauthorisedPublicError" + }, + "403": { + "$ref": "#/responses/forbiddenPublicError" }, - "403": { - "$ref": "#/responses/forbiddenError" + "404": { + "$ref": "#/responses/notFoundPublicError" }, "500": { - "$ref": "#/responses/internalServerError" + "$ref": "#/responses/internalServerPublicError" } } - }, + } + }, + "/public/dashboards/{accessToken}/panels/{panelId}/query": { "post": { - "description": "Adds a global user to the current organization.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:add` with scope `users:*`.", + "description": "Get results for a given panel on a public dashboard", "tags": [ - "org" + "dashboard_public" ], - "summary": "Add a new user to the current organization.", - "operationId": "addOrgUserToCurrentOrg", + "operationId": "queryPublicDashboard", "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/AddOrgUserCommand" - } + "type": "string", + "name": "accessToken", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "name": "panelId", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/okResponse" + "$ref": "#/responses/queryPublicDashboardResponse" + }, + "400": { + "$ref": "#/responses/badRequestPublicError" }, "401": { - "$ref": "#/responses/unauthorisedError" + "$ref": "#/responses/unauthorisedPublicError" }, "403": { - "$ref": "#/responses/forbiddenError" + "$ref": "#/responses/forbiddenPublicError" + }, + "404": { + "$ref": "#/responses/notFoundPublicError" }, "500": { - "$ref": "#/responses/internalServerError" + "$ref": "#/responses/internalServerPublicError" } } } }, - "/org/users/lookup": { + "/query-history": { "get": { - "description": "Returns all org users within the current organization, but with less detailed information.\nAccessible to users with org admin role, admin in any folder or admin of any team.\nMainly used by Grafana UI for providing list of users when adding team members and when editing folder/dashboard permissions.", + "description": "Returns a list of queries in the query history that matches the search criteria.\nQuery history search supports pagination. Use the `limit` parameter to control the maximum number of queries returned; the default limit is 100.\nYou can also use the `page` query parameter to fetch queries from any page other than the first one.", "tags": [ - "org" + "query_history" ], - "summary": "Get all users within the current organization (lookup)", - "operationId": "getOrgUsersForCurrentOrgLookup", + "summary": "Query history search.", + "operationId": "searchQueries", "parameters": [ + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi", + "description": "List of data source UIDs to search for", + "name": "datasourceUid", + "in": "query" + }, { "type": "string", - "name": "query", + "description": "Text inside query or comments that is searched for", + "name": "searchString", + "in": "query" + }, + { + "type": "boolean", + "description": "Flag indicating if only starred queries should be returned", + "name": "onlyStarred", + "in": "query" + }, + { + "enum": [ + "time-desc", + "time-asc" + ], + "type": "string", + "default": "time-desc", + "description": "Sort method", + "name": "sort", "in": "query" }, { "type": "integer", "format": "int64", + "description": "Use this parameter to access hits beyond limit. Numbering starts at 1. limit param acts as page size.", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Limit the number of returned results", "name": "limit", "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "From range for the query history search", + "name": "from", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "To range for the query history search", + "name": "to", + "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/getOrgUsersForCurrentOrgLookupResponse" + "$ref": "#/responses/getQueryHistorySearchResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenError" - }, "500": { "$ref": "#/responses/internalServerError" } } - } - }, - "/org/users/{user_id}": { - "delete": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:remove` with scope `users:*`.", + }, + "post": { + "description": "Adds new query to query history.", "tags": [ - "org" + "query_history" ], - "summary": "Delete user in current organization.", - "operationId": "removeOrgUserForCurrentOrg", + "summary": "Add query to query history.", + "operationId": "createQuery", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "user_id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateQueryInQueryHistoryCommand" + } } ], "responses": { "200": { - "$ref": "#/responses/okResponse" + "$ref": "#/responses/getQueryHistoryResponse" }, "400": { "$ref": "#/responses/badRequestError" @@ -6940,211 +6711,146 @@ "401": { "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenError" - }, "500": { "$ref": "#/responses/internalServerError" } } - }, - "patch": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users.role:update` with scope `users:*`.", + } + }, + "/query-history/star/{query_history_uid}": { + "post": { + "description": "Adds star to query in query history as specified by the UID.", "tags": [ - "org" + "query_history" ], - "summary": "Updates the given user.", - "operationId": "updateOrgUserForCurrentOrg", + "summary": "Add star to query in query history.", + "operationId": "starQuery", "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/UpdateOrgUserCommand" - } - }, - { - "type": "integer", - "format": "int64", - "name": "user_id", + "type": "string", + "name": "query_history_uid", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/getQueryHistoryResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenError" - }, "500": { "$ref": "#/responses/internalServerError" } } - } - }, - "/orgs": { - "get": { - "security": [ - { - "basic": [] - } - ], + }, + "delete": { + "description": "Removes star from query in query history as specified by the UID.", "tags": [ - "orgs" + "query_history" ], - "summary": "Search all Organizations.", - "operationId": "searchOrgs", + "summary": "Remove star to query in query history.", + "operationId": "unstarQuery", "parameters": [ - { - "type": "integer", - "format": "int64", - "default": 1, - "name": "page", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "default": 1000, - "description": "Number of items per page\nThe totalCount field in the response can be used for pagination list E.g. if totalCount is equal to 100 teams and the perpage parameter is set to 10 then there are 10 pages of teams.", - "name": "perpage", - "in": "query" - }, - { - "type": "string", - "name": "name", - "in": "query" - }, { "type": "string", - "description": "If set it will return results where the query value is contained in the name field. Query values with spaces need to be URL encoded.", - "name": "query", - "in": "query" + "name": "query_history_uid", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/searchOrgsResponse" + "$ref": "#/responses/getQueryHistoryResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "409": { - "$ref": "#/responses/conflictError" - }, "500": { "$ref": "#/responses/internalServerError" } } - }, - "post": { - "description": "Only works if [users.allow_org_create](https://grafana.com/docs/grafana/latest/administration/configuration/#allow_org_create) is set.", + } + }, + "/query-history/{query_history_uid}": { + "delete": { + "description": "Deletes an existing query in query history as specified by the UID. This operation cannot be reverted.", "tags": [ - "orgs" + "query_history" ], - "summary": "Create Organization.", - "operationId": "createOrg", + "summary": "Delete query in query history.", + "operationId": "deleteQuery", "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/CreateOrgCommand" - } + "type": "string", + "name": "query_history_uid", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/createOrgResponse" + "$ref": "#/responses/getQueryHistoryDeleteQueryResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "409": { - "$ref": "#/responses/conflictError" - }, "500": { "$ref": "#/responses/internalServerError" } } - } - }, - "/orgs/name/{org_name}": { - "get": { - "security": [ - { - "basic": [] - } - ], + }, + "patch": { + "description": "Updates comment for query in query history as specified by the UID.", "tags": [ - "orgs" + "query_history" ], - "summary": "Get Organization by ID.", - "operationId": "getOrgByName", + "summary": "Update comment for query in query history.", + "operationId": "patchQueryComment", "parameters": [ { "type": "string", - "name": "org_name", + "name": "query_history_uid", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/PatchQueryCommentInQueryHistoryCommand" + } } ], "responses": { "200": { - "$ref": "#/responses/getOrgByNameResponse" + "$ref": "#/responses/getQueryHistoryResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/orgs/{org_id}": { + "/recording-rules": { "get": { - "security": [ - { - "basic": [] - } - ], "tags": [ - "orgs" - ], - "summary": "Get Organization by ID.", - "operationId": "getOrgByID", - "parameters": [ - { - "type": "integer", - "format": "int64", - "name": "org_id", - "in": "path", - "required": true - } + "recording_rules", + "enterprise" ], + "summary": "Lists all rules in the database: active or deleted.", + "operationId": "listRecordingRules", "responses": { "200": { - "$ref": "#/responses/getOrgByIDResponse" + "$ref": "#/responses/listRecordingRulesResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -7152,45 +6858,34 @@ "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } }, "put": { - "security": [ - { - "basic": [] - } - ], "tags": [ - "orgs" + "recording_rules", + "enterprise" ], - "summary": "Update Organization.", - "operationId": "updateOrg", + "summary": "Update the active status of a rule.", + "operationId": "updateRecordingRule", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateOrgForm" + "$ref": "#/definitions/RecordingRuleJSON" } - }, - { - "type": "integer", - "format": "int64", - "name": "org_id", - "in": "path", - "required": true } ], "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/recordingRuleResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -7198,37 +6893,34 @@ "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } }, - "delete": { - "security": [ - { - "basic": [] - } - ], + "post": { "tags": [ - "orgs" + "recording_rules", + "enterprise" ], - "summary": "Delete Organization.", - "operationId": "deleteOrgByID", + "summary": "Create a recording rule that is then registered and started.", + "operationId": "createRecordingRule", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "org_id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/RecordingRuleJSON" + } } ], "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/recordingRuleResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -7245,69 +6937,57 @@ } } }, - "/orgs/{org_id}/address": { - "put": { + "/recording-rules/test": { + "post": { "tags": [ - "orgs" + "recording_rules", + "enterprise" ], - "summary": "Update Organization's address.", - "operationId": "updateOrgAddress", + "summary": "Test a recording rule.", + "operationId": "testCreateRecordingRule", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateOrgAddressForm" + "$ref": "#/definitions/RecordingRuleJSON" } - }, - { - "type": "integer", - "format": "int64", - "name": "org_id", - "in": "path", - "required": true } ], "responses": { "200": { "$ref": "#/responses/okResponse" }, - "400": { - "$ref": "#/responses/badRequestError" - }, "401": { "$ref": "#/responses/unauthorisedError" }, "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "422": { + "$ref": "#/responses/unprocessableEntityError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/orgs/{org_id}/quotas": { + "/recording-rules/writer": { "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:read` and scope `org:id:1` (orgIDScope).", "tags": [ - "orgs" - ], - "summary": "Fetch Organization quota.", - "operationId": "getOrgQuota", - "parameters": [ - { - "type": "integer", - "format": "int64", - "name": "org_id", - "in": "path", - "required": true - } + "recording_rules", + "enterprise" ], + "summary": "Return the prometheus remote write target.", + "operationId": "getRecordingRuleWriteTarget", "responses": { "200": { - "$ref": "#/responses/getQuotaResponse" + "$ref": "#/responses/recordingRuleWriteTargetResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -7322,40 +7002,85 @@ "$ref": "#/responses/internalServerError" } } - } - }, - "/orgs/{org_id}/quotas/{quota_target}": { - "put": { - "security": [ - { - "basic": [] - } - ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:write` and scope `org:id:1` (orgIDScope).", + }, + "post": { + "description": "It returns a 422 if there is not an existing prometheus data source configured.", "tags": [ - "orgs" + "recording_rules", + "enterprise" ], - "summary": "Update user quota.", - "operationId": "updateOrgQuota", + "summary": "Create a remote write target.", + "operationId": "createRecordingRuleWriteTarget", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateQuotaCmd" + "$ref": "#/definitions/PrometheusRemoteWriteTargetJSON" } + } + ], + "responses": { + "200": { + "$ref": "#/responses/recordingRuleWriteTargetResponse" }, - { - "type": "string", - "name": "quota_target", - "in": "path", - "required": true + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "422": { + "$ref": "#/responses/unprocessableEntityError" }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, + "delete": { + "tags": [ + "recording_rules", + "enterprise" + ], + "summary": "Delete the remote write target.", + "operationId": "deleteRecordingRuleWriteTarget", + "responses": { + "200": { + "$ref": "#/responses/okResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/recording-rules/{recordingRuleID}": { + "delete": { + "tags": [ + "recording_rules", + "enterprise" + ], + "summary": "Delete removes the rule from the registry and stops it.", + "operationId": "deleteRecordingRule", + "parameters": [ { "type": "integer", "format": "int64", - "name": "org_id", + "name": "recordingRuleID", "in": "path", "required": true } @@ -7379,31 +7104,54 @@ } } }, - "/orgs/{org_id}/users": { + "/reports": { "get": { - "security": [ - { - "basic": [] - } + "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports:read` with scope `reports:*`.", + "tags": [ + "reports", + "enterprise" ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", + "summary": "List reports.", + "operationId": "getReports", + "responses": { + "200": { + "$ref": "#/responses/getReportsResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, + "post": { + "description": "Available to org admins only and with a valid license.\n\nYou need to have a permission with action `reports.admin:create`.", "tags": [ - "orgs" + "reports", + "enterprise" ], - "summary": "Get Users in Organization.", - "operationId": "getOrgUsers", + "summary": "Create a report.", + "operationId": "createReport", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "org_id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateOrUpdateReportConfig" + } } ], "responses": { "200": { - "$ref": "#/responses/getOrgUsersResponse" + "$ref": "#/responses/createReportResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -7411,116 +7159,171 @@ "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } - }, + } + }, + "/reports/email": { "post": { - "description": "Adds a global user to the current organization.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:add` with scope `users:*`.", + "description": "Generate and send a report. This API waits for the report to be generated before returning. We recommend that you set the client’s timeout to at least 60 seconds. Available to org admins only and with a valid license.\n\nOnly available in Grafana Enterprise v7.0+.\nThis API endpoint is experimental and may be deprecated in a future release. On deprecation, a migration strategy will be provided and the endpoint will remain functional until the next major release of Grafana.\n\nYou need to have a permission with action `reports:send`.", "tags": [ - "orgs" + "reports", + "enterprise" ], - "summary": "Add a new user to the current organization.", - "operationId": "addOrgUser", + "summary": "Send a report.", + "operationId": "sendReport", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/AddOrgUserCommand" + "$ref": "#/definitions/ReportEmail" } - }, - { - "type": "integer", - "format": "int64", - "name": "org_id", - "in": "path", - "required": true } ], "responses": { "200": { "$ref": "#/responses/okResponse" }, + "400": { + "$ref": "#/responses/badRequestError" + }, "401": { "$ref": "#/responses/unauthorisedError" }, "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/orgs/{org_id}/users/search": { + "/reports/render/pdf/{dashboardID}": { "get": { - "security": [ - { - "basic": [] - } + "description": "Please refer to [reports enterprise](#/reports/renderReportPDFs) instead. This will be removed in Grafana 10.", + "produces": [ + "application/pdf" ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", "tags": [ - "orgs" + "reports", + "enterprise" ], - "summary": "Search Users in Organization.", - "operationId": "searchOrgUsers", + "summary": "Render report for dashboard.", + "operationId": "renderReportPDF", + "deprecated": true, "parameters": [ { "type": "integer", "format": "int64", - "name": "org_id", + "name": "dashboardID", "in": "path", "required": true + }, + { + "type": "string", + "name": "title", + "in": "query" + }, + { + "type": "string", + "name": "variables", + "in": "query" + }, + { + "type": "string", + "name": "from", + "in": "query" + }, + { + "type": "string", + "name": "to", + "in": "query" + }, + { + "type": "string", + "name": "orientation", + "in": "query" + }, + { + "type": "string", + "name": "layout", + "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/searchOrgUsersResponse" + "$ref": "#/responses/contentResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/orgs/{org_id}/users/{user_id}": { - "delete": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:remove` with scope `users:*`.", + "/reports/render/pdfs": { + "get": { + "description": "Available to all users and with a valid license.", + "produces": [ + "application/pdf" + ], "tags": [ - "orgs" + "reports", + "enterprise" ], - "summary": "Delete user in current organization.", - "operationId": "removeOrgUser", + "summary": "Render report for multiple dashboards.", + "operationId": "renderReportPDFs", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "org_id", - "in": "path", - "required": true + "type": "string", + "name": "dashboardID", + "in": "query" }, { - "type": "integer", - "format": "int64", - "name": "user_id", - "in": "path", - "required": true + "type": "string", + "name": "orientation", + "in": "query" + }, + { + "type": "string", + "name": "layout", + "in": "query" + }, + { + "type": "string", + "name": "title", + "in": "query" + }, + { + "type": "string", + "name": "scaleFactor", + "in": "query" + }, + { + "type": "string", + "name": "includeTables", + "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/okResponse" + "$ref": "#/responses/contentResponse" }, "400": { "$ref": "#/responses/badRequestError" @@ -7528,6 +7331,28 @@ "401": { "$ref": "#/responses/unauthorisedError" }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/reports/settings": { + "get": { + "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.settings:read`x.", + "tags": [ + "reports", + "enterprise" + ], + "summary": "Get settings.", + "operationId": "getReportSettings", + "responses": { + "200": { + "$ref": "#/responses/getReportSettingsResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, "403": { "$ref": "#/responses/forbiddenError" }, @@ -7536,35 +7361,22 @@ } } }, - "patch": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users.role:update` with scope `users:*`.", + "post": { + "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.settings:write`xx.", "tags": [ - "orgs" + "reports", + "enterprise" ], - "summary": "Update Users in Organization.", - "operationId": "updateOrgUser", + "summary": "Save settings.", + "operationId": "saveReportSettings", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateOrgUserCommand" - } - }, - { - "type": "integer", - "format": "int64", - "name": "org_id", - "in": "path", - "required": true - }, - { - "type": "integer", - "format": "int64", - "name": "user_id", - "in": "path", - "required": true + "$ref": "#/definitions/ReportSettings" + } } ], "responses": { @@ -7586,55 +7398,31 @@ } } }, - "/playlists": { - "get": { - "tags": [ - "playlists" - ], - "summary": "Get playlists.", - "operationId": "searchPlaylists", - "parameters": [ - { - "type": "string", - "name": "query", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "description": "in:limit", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "$ref": "#/responses/searchPlaylistsResponse" - }, - "500": { - "$ref": "#/responses/internalServerError" - } - } - }, + "/reports/test-email": { "post": { + "description": "Available to org admins only and with a valid license.\n\nYou need to have a permission with action `reports:send`.", "tags": [ - "playlists" + "reports", + "enterprise" ], - "summary": "Create playlist.", - "operationId": "createPlaylist", + "summary": "Send test report via email.", + "operationId": "sendTestEmail", "parameters": [ { - "name": "Body", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/CreatePlaylistCommand" + "$ref": "#/definitions/CreateOrUpdateReportConfig" } } ], "responses": { "200": { - "$ref": "#/responses/createPlaylistResponse" + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -7651,24 +7439,30 @@ } } }, - "/playlists/{uid}": { + "/reports/{id}": { "get": { + "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports:read` with scope `reports:id:\u003creport ID\u003e`.", "tags": [ - "playlists" + "reports", + "enterprise" ], - "summary": "Get playlist.", - "operationId": "getPlaylist", + "summary": "Get a report.", + "operationId": "getReport", "parameters": [ { - "type": "string", - "name": "uid", + "type": "integer", + "format": "int64", + "name": "id", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getPlaylistResponse" + "$ref": "#/responses/getReportResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -7685,30 +7479,36 @@ } }, "put": { + "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.admin:write` with scope `reports:id:\u003creport ID\u003e`.", "tags": [ - "playlists" + "reports", + "enterprise" ], - "summary": "Update playlist.", - "operationId": "updatePlaylist", + "summary": "Update a report.", + "operationId": "updateReport", "parameters": [ { - "name": "Body", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdatePlaylistCommand" + "$ref": "#/definitions/CreateOrUpdateReportConfig" } }, { - "type": "string", - "name": "uid", + "type": "integer", + "format": "int64", + "name": "id", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/updatePlaylistResponse" + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -7725,15 +7525,18 @@ } }, "delete": { + "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.delete` with scope `reports:id:\u003creport ID\u003e`.", "tags": [ - "playlists" + "reports", + "enterprise" ], - "summary": "Delete playlist.", - "operationId": "deletePlaylist", + "summary": "Delete a report.", + "operationId": "deleteReport", "parameters": [ { - "type": "string", - "name": "uid", + "type": "integer", + "format": "int64", + "name": "id", "in": "path", "required": true } @@ -7742,6 +7545,9 @@ "200": { "$ref": "#/responses/okResponse" }, + "400": { + "$ref": "#/responses/badRequestError" + }, "401": { "$ref": "#/responses/unauthorisedError" }, @@ -7757,261 +7563,306 @@ } } }, - "/playlists/{uid}/items": { - "get": { + "/saml/acs": { + "post": { "tags": [ - "playlists" + "saml", + "enterprise" ], - "summary": "Get playlist items.", - "operationId": "getPlaylistItems", + "summary": "It performs Assertion Consumer Service (ACS).", + "operationId": "postACS", "parameters": [ { "type": "string", - "name": "uid", - "in": "path", - "required": true + "name": "RelayState", + "in": "query" } ], "responses": { - "200": { - "$ref": "#/responses/getPlaylistItemsResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" + "302": { + "description": "(empty)" }, "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/public/dashboards/{accessToken}": { + "/saml/metadata": { "get": { - "description": "Get public dashboard for view", - "tags": [ - "dashboard_public" + "produces": [ + "application/xml;application/samlmetadata+xml" ], - "operationId": "viewPublicDashboard", - "parameters": [ - { - "type": "string", - "name": "accessToken", - "in": "path", - "required": true - } + "tags": [ + "saml", + "enterprise" ], + "summary": "It exposes the SP (Grafana's) metadata for the IdP's consumption.", + "operationId": "getMetadata", "responses": { "200": { - "$ref": "#/responses/viewPublicDashboardResponse" - }, - "400": { - "$ref": "#/responses/badRequestPublicError" - }, - "401": { - "$ref": "#/responses/unauthorisedPublicError" - }, - "403": { - "$ref": "#/responses/forbiddenPublicError" - }, - "404": { - "$ref": "#/responses/notFoundPublicError" - }, - "500": { - "$ref": "#/responses/internalServerPublicError" + "$ref": "#/responses/contentResponse" } } } }, - "/public/dashboards/{accessToken}/annotations": { + "/saml/slo": { "get": { - "description": "Get annotations for a public dashboard", + "description": "There might be two possible requests:\n1. Logout response (callback) when Grafana initiates single logout and IdP returns response to logout request.\n2. Logout request when another SP initiates single logout and IdP sends logout request to the Grafana,\nor in case of IdP-initiated logout.", "tags": [ - "dashboard_public" - ], - "operationId": "getPublicAnnotations", - "parameters": [ - { - "type": "string", - "name": "accessToken", - "in": "path", - "required": true - } + "saml", + "enterprise" ], + "summary": "It performs Single Logout (SLO) callback.", + "operationId": "getSLO", "responses": { - "200": { - "$ref": "#/responses/getPublicAnnotationsResponse" + "302": { + "description": "(empty)" }, "400": { - "$ref": "#/responses/badRequestPublicError" - }, - "401": { - "$ref": "#/responses/unauthorisedPublicError" + "$ref": "#/responses/badRequestError" }, "403": { - "$ref": "#/responses/forbiddenPublicError" - }, - "404": { - "$ref": "#/responses/notFoundPublicError" + "$ref": "#/responses/forbiddenError" }, "500": { - "$ref": "#/responses/internalServerPublicError" + "$ref": "#/responses/internalServerError" } } - } - }, - "/public/dashboards/{accessToken}/panels/{panelId}/query": { + }, "post": { - "description": "Get results for a given panel on a public dashboard", + "description": "There might be two possible requests:\n1. Logout response (callback) when Grafana initiates single logout and IdP returns response to logout request.\n2. Logout request when another SP initiates single logout and IdP sends logout request to the Grafana,\nor in case of IdP-initiated logout.", "tags": [ - "dashboard_public" + "saml", + "enterprise" ], - "operationId": "queryPublicDashboard", + "summary": "It performs Single Logout (SLO) callback.", + "operationId": "postSLO", "parameters": [ { "type": "string", - "name": "accessToken", - "in": "path", - "required": true + "name": "SAMLRequest", + "in": "query" }, { - "type": "integer", - "format": "int64", - "name": "panelId", - "in": "path", - "required": true + "type": "string", + "name": "SAMLResponse", + "in": "query" } ], "responses": { - "200": { - "$ref": "#/responses/queryPublicDashboardResponse" + "302": { + "description": "(empty)" }, "400": { - "$ref": "#/responses/badRequestPublicError" - }, - "401": { - "$ref": "#/responses/unauthorisedPublicError" + "$ref": "#/responses/badRequestError" }, "403": { - "$ref": "#/responses/forbiddenPublicError" - }, - "404": { - "$ref": "#/responses/notFoundPublicError" + "$ref": "#/responses/forbiddenError" }, "500": { - "$ref": "#/responses/internalServerPublicError" + "$ref": "#/responses/internalServerError" } } } }, - "/query-history": { + "/search": { "get": { - "description": "Returns a list of queries in the query history that matches the search criteria.\nQuery history search supports pagination. Use the `limit` parameter to control the maximum number of queries returned; the default limit is 100.\nYou can also use the `page` query parameter to fetch queries from any page other than the first one.", "tags": [ - "query_history" + "search" ], - "summary": "Query history search.", - "operationId": "searchQueries", + "operationId": "search", "parameters": [ + { + "type": "string", + "description": "Search Query", + "name": "query", + "in": "query" + }, { "type": "array", "items": { "type": "string" }, "collectionFormat": "multi", - "description": "List of data source UIDs to search for", - "name": "datasourceUid", + "description": "List of tags to search for", + "name": "tag", "in": "query" }, { + "enum": [ + "dash-folder", + "dash-db" + ], "type": "string", - "description": "Text inside query or comments that is searched for", - "name": "searchString", + "description": "Type to search for, dash-folder or dash-db", + "name": "type", "in": "query" }, { - "type": "boolean", - "description": "Flag indicating if only starred queries should be returned", - "name": "onlyStarred", + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "description": "List of dashboard id’s to search for\nThis is deprecated: users should use the `dashboardUIDs` query parameter instead", + "name": "dashboardIds", "in": "query" }, { - "enum": [ - "time-desc", - "time-asc" - ], - "type": "string", - "default": "time-desc", - "description": "Sort method", - "name": "sort", + "type": "array", + "items": { + "type": "string" + }, + "description": "List of dashboard uid’s to search for", + "name": "dashboardUIDs", "in": "query" }, { - "type": "integer", - "format": "int64", - "description": "Use this parameter to access hits beyond limit. Numbering starts at 1. limit param acts as page size.", - "name": "page", + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "description": "List of folder id’s to search in for dashboards\nIf it's `0` then it will query for the top level folders\nThis is deprecated: users should use the `folderUIDs` query parameter instead", + "name": "folderIds", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of folder UID’s to search in for dashboards\nIf it's an empty string then it will query for the top level folders", + "name": "folderUIDs", + "in": "query" + }, + { + "type": "boolean", + "description": "Flag indicating if only starred Dashboards should be returned", + "name": "starred", "in": "query" }, { "type": "integer", "format": "int64", - "description": "Limit the number of returned results", + "description": "Limit the number of returned results (max 5000)", "name": "limit", "in": "query" }, { "type": "integer", "format": "int64", - "description": "From range for the query history search", - "name": "from", + "description": "Use this parameter to access hits beyond limit. Numbering starts at 1. limit param acts as page size. Only available in Grafana v6.2+.", + "name": "page", "in": "query" }, { - "type": "integer", - "format": "int64", - "description": "To range for the query history search", - "name": "to", + "enum": [ + "Edit", + "View" + ], + "type": "string", + "default": "View", + "description": "Set to `Edit` to return dashboards/folders that the user can edit", + "name": "permission", + "in": "query" + }, + { + "enum": [ + "alpha-asc", + "alpha-desc" + ], + "type": "string", + "default": "alpha-asc", + "description": "Sort method; for listing all the possible sort methods use the search sorting endpoint.", + "name": "sort", "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/getQueryHistorySearchResponse" + "$ref": "#/responses/searchResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, + "422": { + "$ref": "#/responses/unprocessableEntityError" + }, "500": { "$ref": "#/responses/internalServerError" } } }, "post": { - "description": "Adds new query to query history.", + "produces": [ + "application/json" + ], "tags": [ - "query_history" + "devices" ], - "summary": "Add query to query history.", - "operationId": "createQuery", + "summary": "Lists all devices within the last 30 days", + "operationId": "SearchDevices", + "responses": { + "200": { + "$ref": "#/responses/devicesSearchResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/search/sorting": { + "get": { + "tags": [ + "search" + ], + "summary": "List search sorting options.", + "operationId": "listSortOptions", + "responses": { + "200": { + "$ref": "#/responses/listSortOptionsResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + } + } + } + }, + "/serviceaccounts": { + "post": { + "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:*`\n\nRequires basic authentication and that the authenticated user is a Grafana Admin.", + "tags": [ + "service_accounts" + ], + "summary": "Create service account", + "operationId": "createServiceAccount", "parameters": [ { - "name": "body", + "name": "Body", "in": "body", - "required": true, "schema": { - "$ref": "#/definitions/CreateQueryInQueryHistoryCommand" + "$ref": "#/definitions/CreateServiceAccountForm" } } ], "responses": { - "200": { - "$ref": "#/responses/getQueryHistoryResponse" + "201": { + "$ref": "#/responses/createServiceAccountResponse" }, "400": { "$ref": "#/responses/badRequestError" @@ -8019,122 +7870,169 @@ "401": { "$ref": "#/responses/unauthorisedError" }, + "403": { + "$ref": "#/responses/forbiddenError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/query-history/star/{query_history_uid}": { - "post": { - "description": "Adds star to query in query history as specified by the UID.", + "/serviceaccounts/search": { + "get": { + "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:read` scope: `serviceaccounts:*`", "tags": [ - "query_history" + "service_accounts" ], - "summary": "Add star to query in query history.", - "operationId": "starQuery", + "summary": "Search service accounts with paging", + "operationId": "searchOrgServiceAccountsWithPaging", "parameters": [ + { + "type": "boolean", + "name": "Disabled", + "in": "query" + }, + { + "type": "boolean", + "name": "expiredTokens", + "in": "query" + }, { "type": "string", - "name": "query_history_uid", - "in": "path", - "required": true + "description": "It will return results where the query value is contained in one of the name.\nQuery values with spaces need to be URL encoded.", + "name": "query", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "The default value is 1000.", + "name": "perpage", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "The default value is 1.", + "name": "page", + "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/getQueryHistoryResponse" + "$ref": "#/responses/searchOrgServiceAccountsWithPagingResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, + "403": { + "$ref": "#/responses/forbiddenError" + }, "500": { "$ref": "#/responses/internalServerError" } } - }, - "delete": { - "description": "Removes star from query in query history as specified by the UID.", + } + }, + "/serviceaccounts/{serviceAccountId}": { + "get": { + "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:read` scope: `serviceaccounts:id:1` (single service account)", "tags": [ - "query_history" + "service_accounts" ], - "summary": "Remove star to query in query history.", - "operationId": "unstarQuery", + "summary": "Get single serviceaccount by Id", + "operationId": "retrieveServiceAccount", "parameters": [ { - "type": "string", - "name": "query_history_uid", + "type": "integer", + "format": "int64", + "name": "serviceAccountId", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getQueryHistoryResponse" + "$ref": "#/responses/retrieveServiceAccountResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } - } - }, - "/query-history/{query_history_uid}": { + }, "delete": { - "description": "Deletes an existing query in query history as specified by the UID. This operation cannot be reverted.", + "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:delete` scope: `serviceaccounts:id:1` (single service account)", "tags": [ - "query_history" + "service_accounts" ], - "summary": "Delete query in query history.", - "operationId": "deleteQuery", + "summary": "Delete service account", + "operationId": "deleteServiceAccount", "parameters": [ { - "type": "string", - "name": "query_history_uid", + "type": "integer", + "format": "int64", + "name": "serviceAccountId", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getQueryHistoryDeleteQueryResponse" + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" }, + "403": { + "$ref": "#/responses/forbiddenError" + }, "500": { "$ref": "#/responses/internalServerError" } } }, "patch": { - "description": "Updates comment for query in query history as specified by the UID.", + "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)", "tags": [ - "query_history" + "service_accounts" ], - "summary": "Update comment for query in query history.", - "operationId": "patchQueryComment", + "summary": "Update service account", + "operationId": "updateServiceAccount", "parameters": [ { - "type": "string", - "name": "query_history_uid", + "type": "integer", + "format": "int64", + "name": "serviceAccountId", "in": "path", "required": true }, { - "name": "body", + "name": "Body", "in": "body", - "required": true, "schema": { - "$ref": "#/definitions/PatchQueryCommentInQueryHistoryCommand" + "$ref": "#/definitions/UpdateServiceAccountForm" } } ], "responses": { "200": { - "$ref": "#/responses/getQueryHistoryResponse" + "$ref": "#/responses/updateServiceAccountResponse" }, "400": { "$ref": "#/responses/badRequestError" @@ -8142,27 +8040,6 @@ "401": { "$ref": "#/responses/unauthorisedError" }, - "500": { - "$ref": "#/responses/internalServerError" - } - } - } - }, - "/recording-rules": { - "get": { - "tags": [ - "recording_rules", - "enterprise" - ], - "summary": "Lists all rules in the database: active or deleted.", - "operationId": "listRecordingRules", - "responses": { - "200": { - "$ref": "#/responses/listRecordingRulesResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, "403": { "$ref": "#/responses/forbiddenError" }, @@ -8173,27 +8050,31 @@ "$ref": "#/responses/internalServerError" } } - }, - "put": { + } + }, + "/serviceaccounts/{serviceAccountId}/tokens": { + "get": { + "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:read` scope: `global:serviceaccounts:id:1` (single service account)\n\nRequires basic authentication and that the authenticated user is a Grafana Admin.", "tags": [ - "recording_rules", - "enterprise" + "service_accounts" ], - "summary": "Update the active status of a rule.", - "operationId": "updateRecordingRule", + "summary": "Get service account tokens", + "operationId": "listTokens", "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/RecordingRuleJSON" - } + "type": "integer", + "format": "int64", + "name": "serviceAccountId", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/recordingRuleResponse" + "$ref": "#/responses/listTokensResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -8201,34 +8082,40 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } }, "post": { + "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)", "tags": [ - "recording_rules", - "enterprise" + "service_accounts" ], - "summary": "Create a recording rule that is then registered and started.", - "operationId": "createRecordingRule", + "summary": "CreateNewToken adds a token to a service account", + "operationId": "createToken", "parameters": [ { - "name": "body", + "type": "integer", + "format": "int64", + "name": "serviceAccountId", + "in": "path", + "required": true + }, + { + "name": "Body", "in": "body", - "required": true, "schema": { - "$ref": "#/definitions/RecordingRuleJSON" + "$ref": "#/definitions/AddServiceAccountTokenCommand" } } ], "responses": { "200": { - "$ref": "#/responses/recordingRuleResponse" + "$ref": "#/responses/createTokenResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -8239,34 +8126,46 @@ "404": { "$ref": "#/responses/notFoundError" }, + "409": { + "$ref": "#/responses/conflictError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/recording-rules/test": { - "post": { + "/serviceaccounts/{serviceAccountId}/tokens/{tokenId}": { + "delete": { + "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)\n\nRequires basic authentication and that the authenticated user is a Grafana Admin.", "tags": [ - "recording_rules", - "enterprise" + "service_accounts" ], - "summary": "Test a recording rule.", - "operationId": "testCreateRecordingRule", + "summary": "DeleteToken deletes service account tokens", + "operationId": "deleteToken", "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/RecordingRuleJSON" - } + "type": "integer", + "format": "int64", + "name": "tokenId", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "name": "serviceAccountId", + "in": "path", + "required": true } ], "responses": { "200": { "$ref": "#/responses/okResponse" }, + "400": { + "$ref": "#/responses/badRequestError" + }, "401": { "$ref": "#/responses/unauthorisedError" }, @@ -8276,8 +8175,23 @@ "404": { "$ref": "#/responses/notFoundError" }, - "422": { - "$ref": "#/responses/unprocessableEntityError" + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/signing-keys/keys": { + "get": { + "description": "Required permissions\nNone", + "tags": [ + "signing_keys" + ], + "summary": "Get JSON Web Key Set (JWKS) with all the keys that can be used to verify tokens (public keys)", + "operationId": "retrieveJWKS", + "responses": { + "200": { + "$ref": "#/responses/jwksResponse" }, "500": { "$ref": "#/responses/internalServerError" @@ -8285,53 +8199,44 @@ } } }, - "/recording-rules/writer": { + "/snapshot/shared-options": { "get": { "tags": [ - "recording_rules", - "enterprise" + "snapshots" ], - "summary": "Return the prometheus remote write target.", - "operationId": "getRecordingRuleWriteTarget", + "summary": "Get snapshot sharing settings.", + "operationId": "getSharingOptions", "responses": { "200": { - "$ref": "#/responses/recordingRuleWriteTargetResponse" + "$ref": "#/responses/getSharingOptionsResponse" }, "401": { "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" - }, - "500": { - "$ref": "#/responses/internalServerError" } } - }, + } + }, + "/snapshots": { "post": { - "description": "It returns a 422 if there is not an existing prometheus data source configured.", + "description": "Snapshot public mode should be enabled or authentication is required.", "tags": [ - "recording_rules", - "enterprise" + "snapshots" ], - "summary": "Create a remote write target.", - "operationId": "createRecordingRuleWriteTarget", + "summary": "When creating a snapshot using the API, you have to provide the full dashboard payload including the snapshot data. This endpoint is designed for the Grafana UI.", + "operationId": "createDashboardSnapshot", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/PrometheusRemoteWriteTargetJSON" + "$ref": "#/definitions/CreateDashboardSnapshotCommand" } } ], "responses": { "200": { - "$ref": "#/responses/recordingRuleWriteTargetResponse" + "$ref": "#/responses/createDashboardSnapshotResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -8339,24 +8244,28 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, - "422": { - "$ref": "#/responses/unprocessableEntityError" - }, "500": { "$ref": "#/responses/internalServerError" } } - }, - "delete": { + } + }, + "/snapshots-delete/{deleteKey}": { + "get": { + "description": "Snapshot public mode should be enabled or authentication is required.", "tags": [ - "recording_rules", - "enterprise" + "snapshots" + ], + "summary": "Delete Snapshot by deleteKey.", + "operationId": "deleteDashboardSnapshotByDeleteKey", + "parameters": [ + { + "type": "string", + "name": "deleteKey", + "in": "path", + "required": true + } ], - "summary": "Delete the remote write target.", - "operationId": "deleteRecordingRuleWriteTarget", "responses": { "200": { "$ref": "#/responses/okResponse" @@ -8374,21 +8283,48 @@ "$ref": "#/responses/internalServerError" } } - } - }, - "/recording-rules/{recordingRuleID}": { + } + }, + "/snapshots/{key}": { + "get": { + "tags": [ + "snapshots" + ], + "summary": "Get Snapshot by Key.", + "operationId": "getDashboardSnapshot", + "parameters": [ + { + "type": "string", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/getDashboardSnapshotResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, "delete": { "tags": [ - "recording_rules", - "enterprise" + "snapshots" ], - "summary": "Delete removes the rule from the registry and stops it.", - "operationId": "deleteRecordingRule", + "summary": "Delete Snapshot by Key.", + "operationId": "deleteDashboardSnapshot", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "recordingRuleID", + "type": "string", + "name": "key", "in": "path", "required": true } @@ -8397,9 +8333,6 @@ "200": { "$ref": "#/responses/okResponse" }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, "403": { "$ref": "#/responses/forbiddenError" }, @@ -8412,54 +8345,19 @@ } } }, - "/reports": { + "/stats": { "get": { - "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports:read` with scope `reports:*`.", - "tags": [ - "reports", - "enterprise" + "produces": [ + "application/json" ], - "summary": "List reports.", - "operationId": "getReports", - "responses": { - "200": { - "$ref": "#/responses/getReportsResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "500": { - "$ref": "#/responses/internalServerError" - } - } - }, - "post": { - "description": "Available to org admins only and with a valid license.\n\nYou need to have a permission with action `reports.admin:create`.", "tags": [ - "reports", - "enterprise" - ], - "summary": "Create a report.", - "operationId": "createReport", - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/CreateOrUpdateReportConfig" - } - } + "devices" ], + "summary": "Lists all devices within the last 30 days", + "operationId": "listDevices", "responses": { "200": { - "$ref": "#/responses/createReportResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/devicesResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -8476,31 +8374,26 @@ } } }, - "/reports/email": { + "/teams": { "post": { - "description": "Generate and send a report. This API waits for the report to be generated before returning. We recommend that you set the client’s timeout to at least 60 seconds. Available to org admins only and with a valid license.\n\nOnly available in Grafana Enterprise v7.0+.\nThis API endpoint is experimental and may be deprecated in a future release. On deprecation, a migration strategy will be provided and the endpoint will remain functional until the next major release of Grafana.\n\nYou need to have a permission with action `reports:send`.", "tags": [ - "reports", - "enterprise" + "teams" ], - "summary": "Send a report.", - "operationId": "sendReport", + "summary": "Add Team.", + "operationId": "createTeam", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/ReportEmail" + "$ref": "#/definitions/CreateTeamCommand" } } ], "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/createTeamResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -8508,8 +8401,8 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" + "409": { + "$ref": "#/responses/conflictError" }, "500": { "$ref": "#/responses/internalServerError" @@ -8517,128 +8410,77 @@ } } }, - "/reports/render/pdf/{dashboardID}": { + "/teams/search": { "get": { - "description": "Please refer to [reports enterprise](#/reports/renderReportPDFs) instead. This will be removed in Grafana 10.", - "produces": [ - "application/pdf" - ], "tags": [ - "reports", - "enterprise" + "teams" ], - "summary": "Render report for dashboard.", - "operationId": "renderReportPDF", - "deprecated": true, + "summary": "Team Search With Paging.", + "operationId": "searchTeams", "parameters": [ { "type": "integer", "format": "int64", - "name": "DashboardID", - "in": "path", - "required": true + "default": 1, + "name": "page", + "in": "query" }, { "type": "integer", "format": "int64", - "name": "dashboardID", - "in": "path", - "required": true - }, - { - "type": "string", - "name": "title", - "in": "query" - }, - { - "type": "string", - "name": "variables", - "in": "query" - }, - { - "type": "string", - "name": "from", - "in": "query" - }, - { - "type": "string", - "name": "to", + "default": 1000, + "description": "Number of items per page\nThe totalCount field in the response can be used for pagination list E.g. if totalCount is equal to 100 teams and the perpage parameter is set to 10 then there are 10 pages of teams.", + "name": "perpage", "in": "query" }, { "type": "string", - "name": "orientation", + "name": "name", "in": "query" }, { "type": "string", - "name": "layout", + "description": "If set it will return results where the query value is contained in the name field. Query values with spaces need to be URL encoded.", + "name": "query", "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/contentResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/searchTeamsResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, + "403": { + "$ref": "#/responses/forbiddenError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/reports/render/pdfs": { + "/teams/{teamId}/groups": { "get": { - "description": "Available to all users and with a valid license.", - "produces": [ - "application/pdf" - ], "tags": [ - "reports", + "sync_team_groups", "enterprise" ], - "summary": "Render report for multiple dashboards.", - "operationId": "renderReportPDFs", + "summary": "Get External Groups.", + "operationId": "getTeamGroupsApi", "parameters": [ { - "type": "string", - "name": "dashboardID", - "in": "query" - }, - { - "type": "string", - "name": "orientation", - "in": "query" - }, - { - "type": "string", - "name": "layout", - "in": "query" - }, - { - "type": "string", - "name": "title", - "in": "query" - }, - { - "type": "string", - "name": "scaleFactor", - "in": "query" - }, - { - "type": "string", - "name": "includeTables", - "in": "query" + "type": "integer", + "format": "int64", + "name": "teamId", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/contentResponse" + "$ref": "#/responses/getTeamGroupsApiResponse" }, "400": { "$ref": "#/responses/badRequestError" @@ -8646,52 +8488,39 @@ "401": { "$ref": "#/responses/unauthorisedError" }, - "500": { - "$ref": "#/responses/internalServerError" - } - } - } - }, - "/reports/settings": { - "get": { - "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.settings:read`x.", - "tags": [ - "reports", - "enterprise" - ], - "summary": "Get settings.", - "operationId": "getReportSettings", - "responses": { - "200": { - "$ref": "#/responses/getReportSettingsResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } }, "post": { - "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.settings:write`xx.", "tags": [ - "reports", + "sync_team_groups", "enterprise" ], - "summary": "Save settings.", - "operationId": "saveReportSettings", + "summary": "Add External Group.", + "operationId": "addTeamGroupApi", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/ReportSettings" + "$ref": "#/definitions/TeamGroupMapping" } + }, + { + "type": "integer", + "format": "int64", + "name": "teamId", + "in": "path", + "required": true } ], "responses": { @@ -8707,29 +8536,33 @@ "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } - } - }, - "/reports/test-email": { - "post": { - "description": "Available to org admins only and with a valid license.\n\nYou need to have a permission with action `reports:send`.", + }, + "delete": { "tags": [ - "reports", + "sync_team_groups", "enterprise" ], - "summary": "Send test report via email.", - "operationId": "sendTestEmail", + "summary": "Remove External Group.", + "operationId": "removeTeamGroupApiQuery", "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/CreateOrUpdateReportConfig" - } + "type": "string", + "name": "groupId", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "name": "teamId", + "in": "path", + "required": true } ], "responses": { @@ -8754,30 +8587,24 @@ } } }, - "/reports/{id}": { + "/teams/{team_id}": { "get": { - "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports:read` with scope `reports:id:\u003creport ID\u003e`.", "tags": [ - "reports", - "enterprise" + "teams" ], - "summary": "Get a report.", - "operationId": "getReport", + "summary": "Get Team By ID.", + "operationId": "getTeamByID", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "id", + "type": "string", + "name": "team_id", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getReportResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/getTeamByIDResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -8794,26 +8621,23 @@ } }, "put": { - "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.admin:write` with scope `reports:id:\u003creport ID\u003e`.", "tags": [ - "reports", - "enterprise" + "teams" ], - "summary": "Update a report.", - "operationId": "updateReport", + "summary": "Update Team.", + "operationId": "updateTeam", "parameters": [ { "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/CreateOrUpdateReportConfig" + "$ref": "#/definitions/UpdateTeamCommand" } }, { - "type": "integer", - "format": "int64", - "name": "id", + "type": "string", + "name": "team_id", "in": "path", "required": true } @@ -8822,9 +8646,6 @@ "200": { "$ref": "#/responses/okResponse" }, - "400": { - "$ref": "#/responses/badRequestError" - }, "401": { "$ref": "#/responses/unauthorisedError" }, @@ -8834,24 +8655,24 @@ "404": { "$ref": "#/responses/notFoundError" }, + "409": { + "$ref": "#/responses/conflictError" + }, "500": { "$ref": "#/responses/internalServerError" } } }, "delete": { - "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.delete` with scope `reports:id:\u003creport ID\u003e`.", "tags": [ - "reports", - "enterprise" + "teams" ], - "summary": "Delete a report.", - "operationId": "deleteReport", + "summary": "Delete Team By ID.", + "operationId": "deleteTeamByID", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "id", + "type": "string", + "name": "team_id", "in": "path", "required": true } @@ -8860,9 +8681,6 @@ "200": { "$ref": "#/responses/okResponse" }, - "400": { - "$ref": "#/responses/badRequestError" - }, "401": { "$ref": "#/responses/unauthorisedError" }, @@ -8878,282 +8696,278 @@ } } }, - "/saml/acs": { - "post": { + "/teams/{team_id}/members": { + "get": { "tags": [ - "saml", - "enterprise" + "teams" ], - "summary": "It performs Assertion Consumer Service (ACS).", - "operationId": "postACS", + "summary": "Get Team Members.", + "operationId": "getTeamMembers", "parameters": [ { "type": "string", - "name": "RelayState", - "in": "query" - } - ], - "responses": { - "302": { - "description": "(empty)" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "500": { - "$ref": "#/responses/internalServerError" + "name": "team_id", + "in": "path", + "required": true } - } - } - }, - "/saml/metadata": { - "get": { - "produces": [ - "application/xml;application/samlmetadata+xml" - ], - "tags": [ - "saml", - "enterprise" ], - "summary": "It exposes the SP (Grafana's) metadata for the IdP's consumption.", - "operationId": "getMetadata", "responses": { "200": { - "$ref": "#/responses/contentResponse" - } - } - } - }, - "/saml/slo": { - "get": { - "description": "There might be two possible requests:\n1. Logout response (callback) when Grafana initiates single logout and IdP returns response to logout request.\n2. Logout request when another SP initiates single logout and IdP sends logout request to the Grafana,\nor in case of IdP-initiated logout.", - "tags": [ - "saml", - "enterprise" - ], - "summary": "It performs Single Logout (SLO) callback.", - "operationId": "getSLO", - "responses": { - "302": { - "description": "(empty)" + "$ref": "#/responses/getTeamMembersResponse" }, - "400": { - "$ref": "#/responses/badRequestError" + "401": { + "$ref": "#/responses/unauthorisedError" }, "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } }, "post": { - "description": "There might be two possible requests:\n1. Logout response (callback) when Grafana initiates single logout and IdP returns response to logout request.\n2. Logout request when another SP initiates single logout and IdP sends logout request to the Grafana,\nor in case of IdP-initiated logout.", "tags": [ - "saml", - "enterprise" + "teams" ], - "summary": "It performs Single Logout (SLO) callback.", - "operationId": "postSLO", + "summary": "Add Team Member.", + "operationId": "addTeamMember", "parameters": [ { - "type": "string", - "name": "SAMLRequest", - "in": "query" + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/AddTeamMemberCommand" + } }, { "type": "string", - "name": "SAMLResponse", - "in": "query" + "name": "team_id", + "in": "path", + "required": true } ], "responses": { - "302": { - "description": "(empty)" + "200": { + "$ref": "#/responses/okResponse" }, - "400": { - "$ref": "#/responses/badRequestError" + "401": { + "$ref": "#/responses/unauthorisedError" }, "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/search": { - "get": { + "/teams/{team_id}/members/{user_id}": { + "put": { "tags": [ - "search" + "teams" ], - "operationId": "search", + "summary": "Update Team Member.", + "operationId": "updateTeamMember", "parameters": [ { - "type": "string", - "description": "Search Query", - "name": "query", - "in": "query" - }, - { - "type": "array", - "items": { - "type": "string" - }, - "collectionFormat": "multi", - "description": "List of tags to search for", - "name": "tag", - "in": "query" + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateTeamMemberCommand" + } }, { - "enum": [ - "dash-folder", - "dash-db" - ], "type": "string", - "description": "Type to search for, dash-folder or dash-db", - "name": "type", - "in": "query" - }, - { - "type": "array", - "items": { - "type": "integer", - "format": "int64" - }, - "description": "List of dashboard id’s to search for\nThis is deprecated: users should use the `dashboardUIDs` query parameter instead", - "name": "dashboardIds", - "in": "query" + "name": "team_id", + "in": "path", + "required": true }, { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of dashboard uid’s to search for", - "name": "dashboardUIDs", - "in": "query" + "type": "integer", + "format": "int64", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/okResponse" }, - { - "type": "array", - "items": { - "type": "integer", - "format": "int64" - }, - "description": "List of folder id’s to search in for dashboards\nIf it's `0` then it will query for the top level folders\nThis is deprecated: users should use the `folderUIDs` query parameter instead", - "name": "folderIds", - "in": "query" + "401": { + "$ref": "#/responses/unauthorisedError" }, - { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of folder UID’s to search in for dashboards\nIf it's an empty string then it will query for the top level folders", - "name": "folderUIDs", - "in": "query" + "403": { + "$ref": "#/responses/forbiddenError" }, - { - "type": "boolean", - "description": "Flag indicating if only starred Dashboards should be returned", - "name": "starred", - "in": "query" + "404": { + "$ref": "#/responses/notFoundError" }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, + "delete": { + "tags": [ + "teams" + ], + "summary": "Remove Member From Team.", + "operationId": "removeTeamMember", + "parameters": [ { - "type": "integer", - "format": "int64", - "description": "Limit the number of returned results (max 5000)", - "name": "limit", - "in": "query" + "type": "string", + "name": "team_id", + "in": "path", + "required": true }, { "type": "integer", "format": "int64", - "description": "Use this parameter to access hits beyond limit. Numbering starts at 1. limit param acts as page size. Only available in Grafana v6.2+.", - "name": "page", - "in": "query" + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/okResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/teams/{team_id}/preferences": { + "get": { + "tags": [ + "teams" + ], + "summary": "Get Team Preferences.", + "operationId": "getTeamPreferences", + "parameters": [ { - "enum": [ - "Edit", - "View" - ], "type": "string", - "default": "View", - "description": "Set to `Edit` to return dashboards/folders that the user can edit", - "name": "permission", - "in": "query" + "name": "team_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/getPreferencesResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, + "put": { + "tags": [ + "teams" + ], + "summary": "Update Team Preferences.", + "operationId": "updateTeamPreferences", + "parameters": [ { - "enum": [ - "alpha-asc", - "alpha-desc" - ], "type": "string", - "default": "alpha-asc", - "description": "Sort method; for listing all the possible sort methods use the search sorting endpoint.", - "name": "sort", - "in": "query" + "name": "team_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdatePrefsCmd" + } } ], "responses": { "200": { - "$ref": "#/responses/searchResponse" + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" }, - "422": { - "$ref": "#/responses/unprocessableEntityError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/search/sorting": { + "/user": { "get": { + "description": "Get (current authenticated user)", "tags": [ - "search" + "signed_in_user" ], - "summary": "List search sorting options.", - "operationId": "listSortOptions", + "operationId": "getSignedInUser", "responses": { "200": { - "$ref": "#/responses/listSortOptionsResponse" + "$ref": "#/responses/userResponse" }, "401": { "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" } } - } - }, - "/serviceaccounts": { - "post": { - "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:*`\n\nRequires basic authentication and that the authenticated user is a Grafana Admin.", + }, + "put": { "tags": [ - "service_accounts" + "signed_in_user" ], - "summary": "Create service account", - "operationId": "createServiceAccount", + "summary": "Update signed in User.", + "operationId": "updateSignedInUser", "parameters": [ { - "name": "Body", + "description": "To change the email, name, login, theme, provide another one.", + "name": "body", "in": "body", + "required": true, "schema": { - "$ref": "#/definitions/CreateServiceAccountForm" + "$ref": "#/definitions/UpdateUserCommand" } } ], "responses": { - "201": { - "$ref": "#/responses/createServiceAccountResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "200": { + "$ref": "#/responses/okResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -9161,55 +8975,64 @@ "403": { "$ref": "#/responses/forbiddenError" }, + "409": { + "$ref": "#/responses/conflictError" + }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/serviceaccounts/search": { + "/user/auth-tokens": { "get": { - "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:read` scope: `serviceaccounts:*`", + "description": "Return a list of all auth tokens (devices) that the actual user currently have logged in from.", "tags": [ - "service_accounts" + "signed_in_user" ], - "summary": "Search service accounts with paging", - "operationId": "searchOrgServiceAccountsWithPaging", - "parameters": [ - { - "type": "boolean", - "name": "Disabled", - "in": "query" - }, - { - "type": "boolean", - "name": "expiredTokens", - "in": "query" + "summary": "Auth tokens of the actual User.", + "operationId": "getUserAuthTokens", + "responses": { + "200": { + "$ref": "#/responses/getUserAuthTokensResponse" }, - { - "type": "string", - "description": "It will return results where the query value is contained in one of the name.\nQuery values with spaces need to be URL encoded.", - "name": "query", - "in": "query" + "401": { + "$ref": "#/responses/unauthorisedError" }, - { - "type": "integer", - "format": "int64", - "description": "The default value is 1000.", - "name": "perpage", - "in": "query" + "403": { + "$ref": "#/responses/forbiddenError" }, - { - "type": "integer", - "format": "int64", - "description": "The default value is 1.", - "name": "page", - "in": "query" + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/user/email/update": { + "get": { + "description": "Update the email of user given a verification code.", + "tags": [ + "user" + ], + "summary": "Update user email.", + "operationId": "updateUserEmail", + "responses": { + "302": { + "$ref": "#/responses/okResponse" } + } + } + }, + "/user/helpflags/clear": { + "get": { + "tags": [ + "signed_in_user" ], + "summary": "Clear user help flag.", + "operationId": "clearHelpFlags", "responses": { "200": { - "$ref": "#/responses/searchOrgServiceAccountsWithPagingResponse" + "$ref": "#/responses/helpFlagResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -9223,29 +9046,24 @@ } } }, - "/serviceaccounts/{serviceAccountId}": { - "get": { - "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:read` scope: `serviceaccounts:id:1` (single service account)", + "/user/helpflags/{flag_id}": { + "put": { "tags": [ - "service_accounts" + "signed_in_user" ], - "summary": "Get single serviceaccount by Id", - "operationId": "retrieveServiceAccount", + "summary": "Set user help flag.", + "operationId": "setHelpFlag", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "serviceAccountId", + "type": "string", + "name": "flag_id", "in": "path", "required": true } ], "responses": { - "200": { - "$ref": "#/responses/retrieveServiceAccountResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "200": { + "$ref": "#/responses/helpFlagResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -9253,36 +9071,28 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } - }, - "delete": { - "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:delete` scope: `serviceaccounts:id:1` (single service account)", - "tags": [ - "service_accounts" - ], - "summary": "Delete service account", - "operationId": "deleteServiceAccount", - "parameters": [ + } + }, + "/user/orgs": { + "get": { + "security": [ { - "type": "integer", - "format": "int64", - "name": "serviceAccountId", - "in": "path", - "required": true + "basic": [] } ], + "description": "Return a list of all organizations of the current user.", + "tags": [ + "signed_in_user" + ], + "summary": "Organizations of the actual User.", + "operationId": "getSignedInUserOrgList", "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/getSignedInUserOrgListResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -9294,33 +9104,35 @@ "$ref": "#/responses/internalServerError" } } - }, - "patch": { - "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)", + } + }, + "/user/password": { + "put": { + "security": [ + { + "basic": [] + } + ], + "description": "Changes the password for the user.", "tags": [ - "service_accounts" + "signed_in_user" ], - "summary": "Update service account", - "operationId": "updateServiceAccount", + "summary": "Change Password.", + "operationId": "changeUserPassword", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "serviceAccountId", - "in": "path", - "required": true - }, - { - "name": "Body", + "description": "To change the email, name, login, theme, provide another one.", + "name": "body", "in": "body", + "required": true, "schema": { - "$ref": "#/definitions/UpdateServiceAccountForm" + "$ref": "#/definitions/ChangeUserPasswordCommand" } } ], "responses": { "200": { - "$ref": "#/responses/updateServiceAccountResponse" + "$ref": "#/responses/okResponse" }, "400": { "$ref": "#/responses/badRequestError" @@ -9331,76 +9143,51 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/serviceaccounts/{serviceAccountId}/tokens": { + "/user/preferences": { "get": { - "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:read` scope: `global:serviceaccounts:id:1` (single service account)\n\nRequires basic authentication and that the authenticated user is a Grafana Admin.", "tags": [ - "service_accounts" - ], - "summary": "Get service account tokens", - "operationId": "listTokens", - "parameters": [ - { - "type": "integer", - "format": "int64", - "name": "serviceAccountId", - "in": "path", - "required": true - } + "user_preferences" ], + "summary": "Get user preferences.", + "operationId": "getUserPreferences", "responses": { "200": { - "$ref": "#/responses/listTokensResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/getPreferencesResponse" }, "401": { "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenError" - }, "500": { "$ref": "#/responses/internalServerError" } } }, - "post": { - "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)", + "put": { + "description": "Omitting a key (`theme`, `homeDashboardId`, `timezone`) will cause the current value to be replaced with the system default value.", "tags": [ - "service_accounts" + "user_preferences" ], - "summary": "CreateNewToken adds a token to a service account", - "operationId": "createToken", + "summary": "Update user preferences.", + "operationId": "updateUserPreferences", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "serviceAccountId", - "in": "path", - "required": true - }, - { - "name": "Body", + "name": "body", "in": "body", + "required": true, "schema": { - "$ref": "#/definitions/AddServiceAccountTokenCommand" + "$ref": "#/definitions/UpdatePrefsCmd" } } ], "responses": { "200": { - "$ref": "#/responses/createTokenResponse" + "$ref": "#/responses/okResponse" }, "400": { "$ref": "#/responses/badRequestError" @@ -9408,43 +9195,25 @@ "401": { "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" - }, - "409": { - "$ref": "#/responses/conflictError" - }, "500": { "$ref": "#/responses/internalServerError" } } - } - }, - "/serviceaccounts/{serviceAccountId}/tokens/{tokenId}": { - "delete": { - "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)\n\nRequires basic authentication and that the authenticated user is a Grafana Admin.", + }, + "patch": { "tags": [ - "service_accounts" + "user_preferences" ], - "summary": "DeleteToken deletes service account tokens", - "operationId": "deleteToken", + "summary": "Patch user preferences.", + "operationId": "patchUserPreferences", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "tokenId", - "in": "path", - "required": true - }, - { - "type": "integer", - "format": "int64", - "name": "serviceAccountId", - "in": "path", - "required": true + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/PatchPrefsCmd" + } } ], "responses": { @@ -9457,29 +9226,31 @@ "401": { "$ref": "#/responses/unauthorisedError" }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/signing-keys/keys": { + "/user/quotas": { "get": { - "description": "Required permissions\nNone", "tags": [ - "signing_keys" + "signed_in_user" ], - "summary": "Get JSON Web Key Set (JWKS) with all the keys that can be used to verify tokens (public keys)", - "operationId": "retrieveJWKS", + "summary": "Fetch user quota.", + "operationId": "getUserQuotas", "responses": { "200": { - "$ref": "#/responses/jwksResponse" + "$ref": "#/responses/getQuotaResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" }, "500": { "$ref": "#/responses/internalServerError" @@ -9487,44 +9258,65 @@ } } }, - "/snapshot/shared-options": { - "get": { + "/user/revoke-auth-token": { + "post": { + "description": "Revokes the given auth token (device) for the actual user. User of issued auth token (device) will no longer be logged in and will be required to authenticate again upon next activity.", "tags": [ - "snapshots" + "signed_in_user" + ], + "summary": "Revoke an auth token of the actual User.", + "operationId": "revokeUserAuthToken", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/RevokeAuthTokenCmd" + } + } ], - "summary": "Get snapshot sharing settings.", - "operationId": "getSharingOptions", "responses": { "200": { - "$ref": "#/responses/getSharingOptionsResponse" + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" } } } }, - "/snapshots": { + "/user/stars/dashboard/uid/{dashboard_uid}": { "post": { - "description": "Snapshot public mode should be enabled or authentication is required.", + "description": "Stars the given Dashboard for the actual user.", "tags": [ - "snapshots" + "signed_in_user" ], - "summary": "When creating a snapshot using the API, you have to provide the full dashboard payload including the snapshot data. This endpoint is designed for the Grafana UI.", - "operationId": "createDashboardSnapshot", + "summary": "Star a dashboard.", + "operationId": "starDashboardByUID", "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/CreateDashboardSnapshotCommand" - } + "type": "string", + "name": "dashboard_uid", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/createDashboardSnapshotResponse" + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -9536,20 +9328,18 @@ "$ref": "#/responses/internalServerError" } } - } - }, - "/snapshots-delete/{deleteKey}": { - "get": { - "description": "Snapshot public mode should be enabled or authentication is required.", + }, + "delete": { + "description": "Deletes the starring of the given Dashboard for the actual user.", "tags": [ - "snapshots" + "signed_in_user" ], - "summary": "Delete Snapshot by deleteKey.", - "operationId": "deleteDashboardSnapshotByDeleteKey", + "summary": "Unstar a dashboard.", + "operationId": "unstarDashboardByUID", "parameters": [ { "type": "string", - "name": "deleteKey", + "name": "dashboard_uid", "in": "path", "required": true } @@ -9558,45 +9348,50 @@ "200": { "$ref": "#/responses/okResponse" }, + "400": { + "$ref": "#/responses/badRequestError" + }, "401": { "$ref": "#/responses/unauthorisedError" }, "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/snapshots/{key}": { - "get": { + "/user/stars/dashboard/{dashboard_id}": { + "post": { + "description": "Stars the given Dashboard for the actual user.", "tags": [ - "snapshots" + "signed_in_user" ], - "summary": "Get Snapshot by Key.", - "operationId": "getDashboardSnapshot", + "summary": "Star a dashboard.", + "operationId": "starDashboard", + "deprecated": true, "parameters": [ { "type": "string", - "name": "key", + "name": "dashboard_id", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getDashboardSnapshotResponse" + "$ref": "#/responses/okResponse" }, "400": { "$ref": "#/responses/badRequestError" }, - "404": { - "$ref": "#/responses/notFoundError" + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" }, "500": { "$ref": "#/responses/internalServerError" @@ -9604,15 +9399,17 @@ } }, "delete": { + "description": "Deletes the starring of the given Dashboard for the actual user.", "tags": [ - "snapshots" + "signed_in_user" ], - "summary": "Delete Snapshot by Key.", - "operationId": "deleteDashboardSnapshot", + "summary": "Unstar a dashboard.", + "operationId": "unstarDashboard", + "deprecated": true, "parameters": [ { "type": "string", - "name": "key", + "name": "dashboard_id", "in": "path", "required": true } @@ -9621,31 +9418,32 @@ "200": { "$ref": "#/responses/okResponse" }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/stats": { + "/user/teams": { "get": { - "produces": [ - "application/json" - ], + "description": "Return a list of all teams that the current user is member of.", "tags": [ - "devices" + "signed_in_user" ], - "summary": "Lists all devices within the last 30 days", - "operationId": "listDevices", + "summary": "Teams that the actual User is member of.", + "operationId": "getSignedInUserTeamList", "responses": { "200": { - "$ref": "#/responses/devicesResponse" + "$ref": "#/responses/getSignedInUserTeamListResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -9653,35 +9451,35 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "404": { - "$ref": "#/responses/notFoundError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/teams": { + "/user/using/{org_id}": { "post": { + "description": "Switch user context to the given organization.", "tags": [ - "teams" + "signed_in_user" ], - "summary": "Add Team.", - "operationId": "createTeam", + "summary": "Switch user context for signed in user.", + "operationId": "userSetUsingOrg", "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/CreateTeamCommand" - } + "type": "integer", + "format": "int64", + "name": "org_id", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/createTeamResponse" + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -9689,53 +9487,41 @@ "403": { "$ref": "#/responses/forbiddenError" }, - "409": { - "$ref": "#/responses/conflictError" - }, "500": { "$ref": "#/responses/internalServerError" } } } }, - "/teams/search": { + "/users": { "get": { + "description": "Returns all users that the authenticated user has permission to view, admin permission required.", "tags": [ - "teams" + "users" ], - "summary": "Team Search With Paging.", - "operationId": "searchTeams", + "summary": "Get users.", + "operationId": "searchUsers", "parameters": [ - { - "type": "integer", - "format": "int64", - "default": 1, - "name": "page", - "in": "query" - }, { "type": "integer", "format": "int64", "default": 1000, - "description": "Number of items per page\nThe totalCount field in the response can be used for pagination list E.g. if totalCount is equal to 100 teams and the perpage parameter is set to 10 then there are 10 pages of teams.", + "description": "Limit the maximum number of users to return per page", "name": "perpage", "in": "query" }, { - "type": "string", - "name": "name", - "in": "query" - }, - { - "type": "string", - "description": "If set it will return results where the query value is contained in the name field. Query values with spaces need to be URL encoded.", - "name": "query", + "type": "integer", + "format": "int64", + "default": 1, + "description": "Page index for starting fetching users", + "name": "page", "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/searchTeamsResponse" + "$ref": "#/responses/searchUsersResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -9749,74 +9535,25 @@ } } }, - "/teams/{teamId}/groups": { + "/users/lookup": { "get": { "tags": [ - "sync_team_groups", - "enterprise" - ], - "summary": "Get External Groups.", - "operationId": "getTeamGroupsApi", - "parameters": [ - { - "type": "integer", - "format": "int64", - "name": "teamId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/responses/getTeamGroupsApiResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" - }, - "500": { - "$ref": "#/responses/internalServerError" - } - } - }, - "post": { - "tags": [ - "sync_team_groups", - "enterprise" + "users" ], - "summary": "Add External Group.", - "operationId": "addTeamGroupApi", + "summary": "Get user by login or email.", + "operationId": "getUserByLoginOrEmail", "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/TeamGroupMapping" - } - }, - { - "type": "integer", - "format": "int64", - "name": "teamId", - "in": "path", + "type": "string", + "description": "loginOrEmail of the user", + "name": "loginOrEmail", + "in": "query", "required": true } ], "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/userResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -9831,34 +9568,18 @@ "$ref": "#/responses/internalServerError" } } - }, - "delete": { + } + }, + "/users/search": { + "get": { "tags": [ - "sync_team_groups", - "enterprise" - ], - "summary": "Remove External Group.", - "operationId": "removeTeamGroupApiQuery", - "parameters": [ - { - "type": "string", - "name": "groupId", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "name": "teamId", - "in": "path", - "required": true - } + "users" ], + "summary": "Get users with paging.", + "operationId": "searchUsersWithPaging", "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "$ref": "#/responses/searchUsersWithPagingResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -9875,24 +9596,25 @@ } } }, - "/teams/{team_id}": { + "/users/{user_id}": { "get": { "tags": [ - "teams" + "users" ], - "summary": "Get Team By ID.", - "operationId": "getTeamByID", + "summary": "Get user by id.", + "operationId": "getUserByID", "parameters": [ { - "type": "string", - "name": "team_id", + "type": "integer", + "format": "int64", + "name": "user_id", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getTeamByIDResponse" + "$ref": "#/responses/userResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -9909,23 +9631,26 @@ } }, "put": { + "description": "Update the user identified by id.", "tags": [ - "teams" + "users" ], - "summary": "Update Team.", - "operationId": "updateTeam", + "summary": "Update user.", + "operationId": "updateUser", "parameters": [ { + "description": "To change the email, name, login, theme, provide another one.", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateTeamCommand" + "$ref": "#/definitions/UpdateUserCommand" } }, { - "type": "string", - "name": "team_id", + "type": "integer", + "format": "int64", + "name": "user_id", "in": "path", "required": true } @@ -9950,24 +9675,28 @@ "$ref": "#/responses/internalServerError" } } - }, - "delete": { + } + }, + "/users/{user_id}/orgs": { + "get": { + "description": "Get organizations for user identified by id.", "tags": [ - "teams" + "users" ], - "summary": "Delete Team By ID.", - "operationId": "deleteTeamByID", + "summary": "Get organizations for user.", + "operationId": "getUserOrgList", "parameters": [ { - "type": "string", - "name": "team_id", + "type": "integer", + "format": "int64", + "name": "user_id", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/okResponse" + "$ref": "#/responses/getUserOrgListResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -9984,24 +9713,26 @@ } } }, - "/teams/{team_id}/members": { + "/users/{user_id}/teams": { "get": { + "description": "Get teams for user identified by id.", "tags": [ - "teams" + "users" ], - "summary": "Get Team Members.", - "operationId": "getTeamMembers", + "summary": "Get teams for user.", + "operationId": "getUserTeams", "parameters": [ { - "type": "string", - "name": "team_id", + "type": "integer", + "format": "int64", + "name": "user_id", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getTeamMembersResponse" + "$ref": "#/responses/getUserTeamsResponse" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -10016,990 +9747,1128 @@ "$ref": "#/responses/internalServerError" } } + } + }, + "/v1/provisioning/alert-rules": { + "get": { + "tags": [ + "provisioning" + ], + "summary": "Get all the alert rules.", + "operationId": "RouteGetAlertRules", + "responses": { + "200": { + "description": "ProvisionedAlertRules", + "schema": { + "$ref": "#/definitions/ProvisionedAlertRules" + } + } + } }, "post": { + "consumes": [ + "application/json" + ], "tags": [ - "teams" + "provisioning" ], - "summary": "Add Team Member.", - "operationId": "addTeamMember", + "summary": "Create a new alert rule.", + "operationId": "RoutePostAlertRule", "parameters": [ { - "name": "body", + "name": "Body", "in": "body", - "required": true, "schema": { - "$ref": "#/definitions/AddTeamMemberCommand" + "$ref": "#/definitions/ProvisionedAlertRule" } }, { "type": "string", - "name": "team_id", - "in": "path", - "required": true + "name": "X-Disable-Provenance", + "in": "header" } ], "responses": { - "200": { - "$ref": "#/responses/okResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" + "201": { + "description": "ProvisionedAlertRule", + "schema": { + "$ref": "#/definitions/ProvisionedAlertRule" + } }, - "500": { - "$ref": "#/responses/internalServerError" + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } } } } }, - "/teams/{team_id}/members/{user_id}": { - "put": { + "/v1/provisioning/alert-rules/export": { + "get": { "tags": [ - "teams" + "provisioning" ], - "summary": "Update Team Member.", - "operationId": "updateTeamMember", + "summary": "Export all alert rules in provisioning file format.", + "operationId": "RouteGetAlertRulesExport", "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/UpdateTeamMemberCommand" - } + "type": "boolean", + "default": false, + "description": "Whether to initiate a download of the file or not.", + "name": "download", + "in": "query" }, { "type": "string", - "name": "team_id", - "in": "path", - "required": true + "default": "yaml", + "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", + "name": "format", + "in": "query" }, { - "type": "integer", - "format": "int64", - "name": "user_id", + "type": "array", + "items": { + "type": "string" + }, + "description": "UIDs of folders from which to export rules", + "name": "folderUid", + "in": "query" + }, + { + "type": "string", + "description": "Name of group of rules to export. Must be specified only together with a single folder UID", + "name": "group", + "in": "query" + }, + { + "type": "string", + "description": "UID of alert rule to export. If specified, parameters folderUid and group must be empty.", + "name": "ruleUid", + "in": "query" + } + ], + "responses": { + "200": { + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } + }, + "404": { + "description": " Not found." + } + } + } + }, + "/v1/provisioning/alert-rules/{UID}": { + "get": { + "tags": [ + "provisioning" + ], + "summary": "Get a specific alert rule by UID.", + "operationId": "RouteGetAlertRule", + "parameters": [ + { + "type": "string", + "description": "Alert rule UID", + "name": "UID", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" + "description": "ProvisionedAlertRule", + "schema": { + "$ref": "#/definitions/ProvisionedAlertRule" + } }, "404": { - "$ref": "#/responses/notFoundError" - }, - "500": { - "$ref": "#/responses/internalServerError" + "description": " Not found." } } }, - "delete": { + "put": { + "consumes": [ + "application/json" + ], "tags": [ - "teams" + "provisioning" ], - "summary": "Remove Member From Team.", - "operationId": "removeTeamMember", + "summary": "Update an existing alert rule.", + "operationId": "RoutePutAlertRule", "parameters": [ { "type": "string", - "name": "team_id", + "description": "Alert rule UID", + "name": "UID", "in": "path", "required": true }, { - "type": "integer", - "format": "int64", - "name": "user_id", - "in": "path", - "required": true + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/ProvisionedAlertRule" + } + }, + { + "type": "string", + "name": "X-Disable-Provenance", + "in": "header" } ], "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" + "description": "ProvisionedAlertRule", + "schema": { + "$ref": "#/definitions/ProvisionedAlertRule" + } }, - "500": { - "$ref": "#/responses/internalServerError" + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } } } - } - }, - "/teams/{team_id}/preferences": { - "get": { + }, + "delete": { "tags": [ - "teams" + "provisioning" ], - "summary": "Get Team Preferences.", - "operationId": "getTeamPreferences", + "summary": "Delete a specific alert rule by UID.", + "operationId": "RouteDeleteAlertRule", "parameters": [ { "type": "string", - "name": "team_id", + "description": "Alert rule UID", + "name": "UID", "in": "path", "required": true + }, + { + "type": "string", + "name": "X-Disable-Provenance", + "in": "header" } ], "responses": { - "200": { - "$ref": "#/responses/getPreferencesResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "500": { - "$ref": "#/responses/internalServerError" + "204": { + "description": " The alert rule was deleted successfully." } } - }, - "put": { + } + }, + "/v1/provisioning/alert-rules/{UID}/export": { + "get": { + "produces": [ + "application/json", + "application/yaml", + "text/yaml" + ], "tags": [ - "teams" + "provisioning" ], - "summary": "Update Team Preferences.", - "operationId": "updateTeamPreferences", + "summary": "Export an alert rule in provisioning file format.", + "operationId": "RouteGetAlertRuleExport", "parameters": [ + { + "type": "boolean", + "default": false, + "description": "Whether to initiate a download of the file or not.", + "name": "download", + "in": "query" + }, { "type": "string", - "name": "team_id", - "in": "path", - "required": true + "default": "yaml", + "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", + "name": "format", + "in": "query" }, { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/UpdatePrefsCmd" - } + "type": "string", + "description": "Alert rule UID", + "name": "UID", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" - }, - "401": { - "$ref": "#/responses/unauthorisedError" + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } }, - "500": { - "$ref": "#/responses/internalServerError" + "404": { + "description": " Not found." } } } }, - "/user": { + "/v1/provisioning/contact-points": { "get": { - "description": "Get (current authenticated user)", "tags": [ - "signed_in_user" + "provisioning" + ], + "summary": "Get all the contact points.", + "operationId": "RouteGetContactpoints", + "parameters": [ + { + "type": "string", + "description": "Filter by name", + "name": "name", + "in": "query" + } ], - "operationId": "getSignedInUser", "responses": { "200": { - "$ref": "#/responses/userResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" - }, - "500": { - "$ref": "#/responses/internalServerError" + "description": "ContactPoints", + "schema": { + "$ref": "#/definitions/ContactPoints" + } } } }, - "put": { + "post": { + "consumes": [ + "application/json" + ], "tags": [ - "signed_in_user" + "provisioning" ], - "summary": "Update signed in User.", - "operationId": "updateSignedInUser", + "summary": "Create a contact point.", + "operationId": "RoutePostContactpoints", "parameters": [ { - "description": "To change the email, name, login, theme, provide another one.", - "name": "body", + "name": "Body", "in": "body", - "required": true, "schema": { - "$ref": "#/definitions/UpdateUserCommand" + "$ref": "#/definitions/EmbeddedContactPoint" } + }, + { + "type": "string", + "name": "X-Disable-Provenance", + "in": "header" } ], "responses": { - "200": { - "$ref": "#/responses/okResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" + "202": { + "description": "EmbeddedContactPoint", + "schema": { + "$ref": "#/definitions/EmbeddedContactPoint" + } }, - "500": { - "$ref": "#/responses/internalServerError" + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } } } } }, - "/user/auth-tokens": { + "/v1/provisioning/contact-points/export": { "get": { - "description": "Return a list of all auth tokens (devices) that the actual user currently have logged in from.", "tags": [ - "signed_in_user" + "provisioning" ], - "summary": "Auth tokens of the actual User.", - "operationId": "getUserAuthTokens", - "responses": { - "200": { - "$ref": "#/responses/getUserAuthTokensResponse" + "summary": "Export all contact points in provisioning file format.", + "operationId": "RouteGetContactpointsExport", + "parameters": [ + { + "type": "boolean", + "default": false, + "description": "Whether to initiate a download of the file or not.", + "name": "download", + "in": "query" }, - "401": { - "$ref": "#/responses/unauthorisedError" + { + "type": "string", + "default": "yaml", + "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", + "name": "format", + "in": "query" }, - "403": { - "$ref": "#/responses/forbiddenError" + { + "type": "boolean", + "default": false, + "description": "Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings.", + "name": "decrypt", + "in": "query" }, - "500": { - "$ref": "#/responses/internalServerError" + { + "type": "string", + "description": "Filter by name", + "name": "name", + "in": "query" } - } - } - }, - "/user/helpflags/clear": { - "get": { - "tags": [ - "signed_in_user" ], - "summary": "Clear user help flag.", - "operationId": "clearHelpFlags", "responses": { "200": { - "$ref": "#/responses/helpFlagResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } }, "403": { - "$ref": "#/responses/forbiddenError" - }, - "500": { - "$ref": "#/responses/internalServerError" + "description": "PermissionDenied", + "schema": { + "$ref": "#/definitions/PermissionDenied" + } } } } }, - "/user/helpflags/{flag_id}": { + "/v1/provisioning/contact-points/{UID}": { "put": { + "consumes": [ + "application/json" + ], "tags": [ - "signed_in_user" + "provisioning" ], - "summary": "Set user help flag.", - "operationId": "setHelpFlag", + "summary": "Update an existing contact point.", + "operationId": "RoutePutContactpoint", "parameters": [ { "type": "string", - "name": "flag_id", + "description": "UID is the contact point unique identifier", + "name": "UID", "in": "path", "required": true - } - ], - "responses": { - "200": { - "$ref": "#/responses/helpFlagResponse" }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/EmbeddedContactPoint" + } }, - "500": { - "$ref": "#/responses/internalServerError" - } - } - } - }, - "/user/orgs": { - "get": { - "security": [ { - "basic": [] + "type": "string", + "name": "X-Disable-Provenance", + "in": "header" } ], - "description": "Return a list of all organizations of the current user.", - "tags": [ - "signed_in_user" - ], - "summary": "Organizations of the actual User.", - "operationId": "getSignedInUserOrgList", "responses": { - "200": { - "$ref": "#/responses/getSignedInUserOrgListResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" + "202": { + "description": "Ack", + "schema": { + "$ref": "#/definitions/Ack" + } }, - "500": { - "$ref": "#/responses/internalServerError" + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } } } - } - }, - "/user/password": { - "put": { - "security": [ - { - "basic": [] - } + }, + "delete": { + "consumes": [ + "application/json" ], - "description": "Changes the password for the user.", "tags": [ - "signed_in_user" + "provisioning" ], - "summary": "Change Password.", - "operationId": "changeUserPassword", + "summary": "Delete a contact point.", + "operationId": "RouteDeleteContactpoints", "parameters": [ { - "description": "To change the email, name, login, theme, provide another one.", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/ChangeUserPasswordCommand" - } + "type": "string", + "description": "UID is the contact point unique identifier", + "name": "UID", + "in": "path", + "required": true } ], "responses": { - "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "500": { - "$ref": "#/responses/internalServerError" + "202": { + "description": " The contact point was deleted successfully." } } } }, - "/user/preferences": { + "/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": { "get": { "tags": [ - "user_preferences" + "provisioning" + ], + "summary": "Get a rule group.", + "operationId": "RouteGetAlertRuleGroup", + "parameters": [ + { + "type": "string", + "name": "FolderUID", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "Group", + "in": "path", + "required": true + } ], - "summary": "Get user preferences.", - "operationId": "getUserPreferences", "responses": { "200": { - "$ref": "#/responses/getPreferencesResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" + "description": "AlertRuleGroup", + "schema": { + "$ref": "#/definitions/AlertRuleGroup" + } }, - "500": { - "$ref": "#/responses/internalServerError" + "404": { + "description": " Not found." } } }, "put": { - "description": "Omitting a key (`theme`, `homeDashboardId`, `timezone`) will cause the current value to be replaced with the system default value.", + "consumes": [ + "application/json" + ], "tags": [ - "user_preferences" + "provisioning" ], - "summary": "Update user preferences.", - "operationId": "updateUserPreferences", + "summary": "Update the interval of a rule group.", + "operationId": "RoutePutAlertRuleGroup", "parameters": [ { - "name": "body", + "type": "string", + "name": "X-Disable-Provenance", + "in": "header" + }, + { + "type": "string", + "name": "FolderUID", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "Group", + "in": "path", + "required": true + }, + { + "name": "Body", "in": "body", - "required": true, "schema": { - "$ref": "#/definitions/UpdatePrefsCmd" + "$ref": "#/definitions/AlertRuleGroup" } } ], "responses": { "200": { - "$ref": "#/responses/okResponse" + "description": "AlertRuleGroup", + "schema": { + "$ref": "#/definitions/AlertRuleGroup" + } }, "400": { - "$ref": "#/responses/badRequestError" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "500": { - "$ref": "#/responses/internalServerError" + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } } } }, - "patch": { + "delete": { + "description": "Delete rule group", "tags": [ - "user_preferences" + "provisioning" ], - "summary": "Patch user preferences.", - "operationId": "patchUserPreferences", + "operationId": "RouteDeleteAlertRuleGroup", "parameters": [ { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/PatchPrefsCmd" - } + "type": "string", + "name": "FolderUID", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "Group", + "in": "path", + "required": true } ], "responses": { - "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" + "204": { + "description": " The alert rule group was deleted successfully." }, - "401": { - "$ref": "#/responses/unauthorisedError" + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } }, - "500": { - "$ref": "#/responses/internalServerError" + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } } } } }, - "/user/quotas": { + "/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export": { "get": { + "produces": [ + "application/json", + "application/yaml", + "text/yaml" + ], "tags": [ - "signed_in_user" + "provisioning" ], - "summary": "Fetch user quota.", - "operationId": "getUserQuotas", - "responses": { - "200": { - "$ref": "#/responses/getQuotaResponse" + "summary": "Export an alert rule group in provisioning file format.", + "operationId": "RouteGetAlertRuleGroupExport", + "parameters": [ + { + "type": "boolean", + "default": false, + "description": "Whether to initiate a download of the file or not.", + "name": "download", + "in": "query" }, - "401": { - "$ref": "#/responses/unauthorisedError" + { + "type": "string", + "default": "yaml", + "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", + "name": "format", + "in": "query" }, - "403": { - "$ref": "#/responses/forbiddenError" + { + "type": "string", + "name": "FolderUID", + "in": "path", + "required": true }, - "404": { - "$ref": "#/responses/notFoundError" + { + "type": "string", + "name": "Group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } }, - "500": { - "$ref": "#/responses/internalServerError" + "404": { + "description": " Not found." + } + } + } + }, + "/v1/provisioning/mute-timings": { + "get": { + "tags": [ + "provisioning" + ], + "summary": "Get all the mute timings.", + "operationId": "RouteGetMuteTimings", + "responses": { + "200": { + "description": "MuteTimings", + "schema": { + "$ref": "#/definitions/MuteTimings" + } } } - } - }, - "/user/revoke-auth-token": { + }, "post": { - "description": "Revokes the given auth token (device) for the actual user. User of issued auth token (device) will no longer be logged in and will be required to authenticate again upon next activity.", + "consumes": [ + "application/json" + ], "tags": [ - "signed_in_user" + "provisioning" ], - "summary": "Revoke an auth token of the actual User.", - "operationId": "revokeUserAuthToken", + "summary": "Create a new mute timing.", + "operationId": "RoutePostMuteTiming", "parameters": [ { - "name": "body", + "name": "Body", "in": "body", - "required": true, "schema": { - "$ref": "#/definitions/RevokeAuthTokenCmd" + "$ref": "#/definitions/MuteTimeInterval" } + }, + { + "type": "string", + "name": "X-Disable-Provenance", + "in": "header" } ], "responses": { - "200": { - "$ref": "#/responses/okResponse" + "201": { + "description": "MuteTimeInterval", + "schema": { + "$ref": "#/definitions/MuteTimeInterval" + } }, "400": { - "$ref": "#/responses/badRequestError" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "500": { - "$ref": "#/responses/internalServerError" + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } } } } }, - "/user/stars/dashboard/uid/{dashboard_uid}": { - "post": { - "description": "Stars the given Dashboard for the actual user.", + "/v1/provisioning/mute-timings/export": { + "get": { "tags": [ - "signed_in_user" + "provisioning" ], - "summary": "Star a dashboard.", - "operationId": "starDashboardByUID", + "summary": "Export all mute timings in provisioning format.", + "operationId": "RouteExportMuteTimings", "parameters": [ + { + "type": "boolean", + "default": false, + "description": "Whether to initiate a download of the file or not.", + "name": "download", + "in": "query" + }, { "type": "string", - "name": "dashboard_uid", - "in": "path", - "required": true + "default": "yaml", + "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", + "name": "format", + "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" - }, - "401": { - "$ref": "#/responses/unauthorisedError" + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } }, "403": { - "$ref": "#/responses/forbiddenError" - }, - "500": { - "$ref": "#/responses/internalServerError" + "description": "PermissionDenied", + "schema": { + "$ref": "#/definitions/PermissionDenied" + } } } - }, - "delete": { - "description": "Deletes the starring of the given Dashboard for the actual user.", + } + }, + "/v1/provisioning/mute-timings/{name}": { + "get": { "tags": [ - "signed_in_user" + "provisioning" ], - "summary": "Unstar a dashboard.", - "operationId": "unstarDashboardByUID", + "summary": "Get a mute timing.", + "operationId": "RouteGetMuteTiming", "parameters": [ { "type": "string", - "name": "dashboard_uid", + "description": "Mute timing name", + "name": "name", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" + "description": "MuteTimeInterval", + "schema": { + "$ref": "#/definitions/MuteTimeInterval" + } }, - "500": { - "$ref": "#/responses/internalServerError" + "404": { + "description": " Not found." } } - } - }, - "/user/stars/dashboard/{dashboard_id}": { - "post": { - "description": "Stars the given Dashboard for the actual user.", + }, + "put": { + "consumes": [ + "application/json" + ], "tags": [ - "signed_in_user" + "provisioning" ], - "summary": "Star a dashboard.", - "operationId": "starDashboard", - "deprecated": true, + "summary": "Replace an existing mute timing.", + "operationId": "RoutePutMuteTiming", "parameters": [ { "type": "string", - "name": "dashboard_id", + "description": "Mute timing name", + "name": "name", "in": "path", "required": true + }, + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/MuteTimeInterval" + } + }, + { + "type": "string", + "name": "X-Disable-Provenance", + "in": "header" } ], "responses": { - "200": { - "$ref": "#/responses/okResponse" + "202": { + "description": "MuteTimeInterval", + "schema": { + "$ref": "#/definitions/MuteTimeInterval" + } }, "400": { - "$ref": "#/responses/badRequestError" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "500": { - "$ref": "#/responses/internalServerError" + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } } } }, "delete": { - "description": "Deletes the starring of the given Dashboard for the actual user.", "tags": [ - "signed_in_user" + "provisioning" ], - "summary": "Unstar a dashboard.", - "operationId": "unstarDashboard", - "deprecated": true, + "summary": "Delete a mute timing.", + "operationId": "RouteDeleteMuteTiming", "parameters": [ { "type": "string", - "name": "dashboard_id", + "description": "Mute timing name", + "name": "name", "in": "path", "required": true } ], "responses": { - "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" + "204": { + "description": " The mute timing was deleted successfully." }, - "500": { - "$ref": "#/responses/internalServerError" + "409": { + "description": "GenericPublicError", + "schema": { + "$ref": "#/definitions/GenericPublicError" + } } } } }, - "/user/teams": { + "/v1/provisioning/mute-timings/{name}/export": { "get": { - "description": "Return a list of all teams that the current user is member of.", - "tags": [ - "signed_in_user" - ], - "summary": "Teams that the actual User is member of.", - "operationId": "getSignedInUserTeamList", - "responses": { - "200": { - "$ref": "#/responses/getSignedInUserTeamListResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "500": { - "$ref": "#/responses/internalServerError" - } - } - } - }, - "/user/using/{org_id}": { - "post": { - "description": "Switch user context to the given organization.", "tags": [ - "signed_in_user" + "provisioning" ], - "summary": "Switch user context for signed in user.", - "operationId": "userSetUsingOrg", + "summary": "Export a mute timing in provisioning format.", + "operationId": "RouteExportMuteTiming", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "org_id", + "type": "boolean", + "default": false, + "description": "Whether to initiate a download of the file or not.", + "name": "download", + "in": "query" + }, + { + "type": "string", + "default": "yaml", + "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", + "name": "format", + "in": "query" + }, + { + "type": "string", + "description": "Mute timing name", + "name": "name", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/okResponse" - }, - "400": { - "$ref": "#/responses/badRequestError" - }, - "401": { - "$ref": "#/responses/unauthorisedError" + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } }, "403": { - "$ref": "#/responses/forbiddenError" - }, - "500": { - "$ref": "#/responses/internalServerError" + "description": "PermissionDenied", + "schema": { + "$ref": "#/definitions/PermissionDenied" + } } } } }, - "/users": { + "/v1/provisioning/policies": { "get": { - "description": "Returns all users that the authenticated user has permission to view, admin permission required.", "tags": [ - "users" + "provisioning" ], - "summary": "Get users.", - "operationId": "searchUsers", + "summary": "Get the notification policy tree.", + "operationId": "RouteGetPolicyTree", + "responses": { + "200": { + "description": "Route", + "schema": { + "$ref": "#/definitions/Route" + } + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "tags": [ + "provisioning" + ], + "summary": "Sets the notification policy tree.", + "operationId": "RoutePutPolicyTree", "parameters": [ { - "type": "integer", - "format": "int64", - "default": 1000, - "description": "Limit the maximum number of users to return per page", - "name": "perpage", - "in": "query" + "description": "The new notification routing tree to use", + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/Route" + } }, { - "type": "integer", - "format": "int64", - "default": 1, - "description": "Page index for starting fetching users", - "name": "page", - "in": "query" + "type": "string", + "name": "X-Disable-Provenance", + "in": "header" } ], "responses": { - "200": { - "$ref": "#/responses/searchUsersResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" + "202": { + "description": "Ack", + "schema": { + "$ref": "#/definitions/Ack" + } }, - "500": { - "$ref": "#/responses/internalServerError" + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + }, + "delete": { + "consumes": [ + "application/json" + ], + "tags": [ + "provisioning" + ], + "summary": "Clears the notification policy tree.", + "operationId": "RouteResetPolicyTree", + "responses": { + "202": { + "description": "Ack", + "schema": { + "$ref": "#/definitions/Ack" + } } } } }, - "/users/lookup": { + "/v1/provisioning/policies/export": { "get": { "tags": [ - "users" - ], - "summary": "Get user by login or email.", - "operationId": "getUserByLoginOrEmail", - "parameters": [ - { - "type": "string", - "description": "loginOrEmail of the user", - "name": "loginOrEmail", - "in": "query", - "required": true - } + "provisioning" ], + "summary": "Export the notification policy tree in provisioning file format.", + "operationId": "RouteGetPolicyTreeExport", "responses": { "200": { - "$ref": "#/responses/userResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } }, "404": { - "$ref": "#/responses/notFoundError" - }, - "500": { - "$ref": "#/responses/internalServerError" + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } } } } }, - "/users/search": { + "/v1/provisioning/templates": { "get": { "tags": [ - "users" + "provisioning" ], - "summary": "Get users with paging.", - "operationId": "searchUsersWithPaging", + "summary": "Get all notification templates.", + "operationId": "RouteGetTemplates", "responses": { "200": { - "$ref": "#/responses/searchUsersWithPagingResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" + "description": "NotificationTemplates", + "schema": { + "$ref": "#/definitions/NotificationTemplates" + } }, "404": { - "$ref": "#/responses/notFoundError" - }, - "500": { - "$ref": "#/responses/internalServerError" + "description": " Not found." } } } }, - "/users/{user_id}": { + "/v1/provisioning/templates/{name}": { "get": { "tags": [ - "users" + "provisioning" ], - "summary": "Get user by id.", - "operationId": "getUserByID", + "summary": "Get a notification template.", + "operationId": "RouteGetTemplate", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "user_id", + "type": "string", + "description": "Template Name", + "name": "name", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/userResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" + "description": "NotificationTemplate", + "schema": { + "$ref": "#/definitions/NotificationTemplate" + } }, "404": { - "$ref": "#/responses/notFoundError" - }, - "500": { - "$ref": "#/responses/internalServerError" + "description": " Not found." } } }, "put": { - "description": "Update the user identified by id.", + "consumes": [ + "application/json" + ], "tags": [ - "users" + "provisioning" ], - "summary": "Update user.", - "operationId": "updateUser", + "summary": "Updates an existing notification template.", + "operationId": "RoutePutTemplate", "parameters": [ { - "description": "To change the email, name, login, theme, provide another one.", - "name": "body", + "type": "string", + "description": "Template Name", + "name": "name", + "in": "path", + "required": true + }, + { + "name": "Body", "in": "body", - "required": true, "schema": { - "$ref": "#/definitions/UpdateUserCommand" + "$ref": "#/definitions/NotificationTemplateContent" } }, { - "type": "integer", - "format": "int64", - "name": "user_id", - "in": "path", - "required": true + "type": "string", + "name": "X-Disable-Provenance", + "in": "header" } ], "responses": { - "200": { - "$ref": "#/responses/okResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" + "202": { + "description": "NotificationTemplate", + "schema": { + "$ref": "#/definitions/NotificationTemplate" + } }, - "500": { - "$ref": "#/responses/internalServerError" - } - } - } - }, - "/users/{user_id}/orgs": { - "get": { - "description": "Get organizations for user identified by id.", + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + }, + "delete": { "tags": [ - "users" + "provisioning" ], - "summary": "Get organizations for user.", - "operationId": "getUserOrgList", + "summary": "Delete a template.", + "operationId": "RouteDeleteTemplate", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "user_id", + "type": "string", + "description": "Template Name", + "name": "name", "in": "path", "required": true } ], + "responses": { + "204": { + "description": " The template was deleted successfully." + } + } + } + }, + "/v1/sso-settings": { + "get": { + "description": "You need to have a permission with action `settings:read` with scope `settings:auth.\u003cprovider\u003e:*`.", + "tags": [ + "sso_settings" + ], + "summary": "List all SSO Settings entries", + "operationId": "listAllProvidersSettings", "responses": { "200": { - "$ref": "#/responses/getUserOrgListResponse" + "$ref": "#/responses/listSSOSettingsResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" }, "403": { "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" - }, - "500": { - "$ref": "#/responses/internalServerError" } } } }, - "/users/{user_id}/teams": { + "/v1/sso-settings/{key}": { "get": { - "description": "Get teams for user identified by id.", + "description": "You need to have a permission with action `settings:read` with scope `settings:auth.\u003cprovider\u003e:*`.", "tags": [ - "users" + "sso_settings" ], - "summary": "Get teams for user.", - "operationId": "getUserTeams", + "summary": "Get an SSO Settings entry by Key", + "operationId": "getProviderSettings", "parameters": [ { - "type": "integer", - "format": "int64", - "name": "user_id", + "type": "string", + "name": "key", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/getUserTeamsResponse" + "$ref": "#/responses/getSSOSettingsResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" }, "401": { "$ref": "#/responses/unauthorisedError" @@ -11009,14 +10878,9 @@ }, "404": { "$ref": "#/responses/notFoundError" - }, - "500": { - "$ref": "#/responses/internalServerError" } } - } - }, - "/v1/sso-settings/{key}": { + }, "put": { "description": "Inserts or updates the SSO Settings for a provider.\n\nYou need to have a permission with action `settings:write` and scope `settings:auth.\u003cprovider\u003e:*`.", "tags": [ @@ -11036,7 +10900,19 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/SSOSettings" + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "settings": { + "type": "object", + "additionalProperties": false + } + } } } ], @@ -11086,6 +10962,9 @@ "403": { "$ref": "#/responses/forbiddenError" }, + "404": { + "$ref": "#/responses/notFoundError" + }, "500": { "$ref": "#/responses/internalServerError" } @@ -11123,6 +11002,10 @@ "type": "integer", "format": "int64" }, + "active_anonymous_devices": { + "type": "integer", + "format": "int64" + }, "active_users": { "type": "integer", "format": "int64" @@ -11322,7 +11205,7 @@ "format": "int64" }, "password": { - "type": "string" + "$ref": "#/definitions/Password" } } }, @@ -11443,7 +11326,7 @@ "type": "object", "properties": { "password": { - "type": "string" + "$ref": "#/definitions/Password" } } }, @@ -11514,52 +11397,6 @@ } } }, - "AlertListItemDTO": { - "type": "object", - "properties": { - "dashboardId": { - "type": "integer", - "format": "int64" - }, - "dashboardSlug": { - "type": "string" - }, - "dashboardUid": { - "type": "string" - }, - "evalData": { - "$ref": "#/definitions/Json" - }, - "evalDate": { - "type": "string", - "format": "date-time" - }, - "executionError": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int64" - }, - "name": { - "type": "string" - }, - "newStateDate": { - "type": "string", - "format": "date-time" - }, - "panelId": { - "type": "integer", - "format": "int64" - }, - "state": { - "$ref": "#/definitions/AlertStateType" - }, - "url": { - "type": "string" - } - } - }, "AlertManager": { "type": "object", "title": "AlertManager models a configured Alert Manager.", @@ -11590,74 +11427,6 @@ } } }, - "AlertNotification": { - "type": "object", - "properties": { - "created": { - "type": "string", - "format": "date-time" - }, - "disableResolveMessage": { - "type": "boolean" - }, - "frequency": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int64" - }, - "isDefault": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "secureFields": { - "type": "object", - "additionalProperties": { - "type": "boolean" - } - }, - "sendReminder": { - "type": "boolean" - }, - "settings": { - "$ref": "#/definitions/Json" - }, - "type": { - "type": "string" - }, - "uid": { - "type": "string" - }, - "updated": { - "type": "string", - "format": "date-time" - } - } - }, - "AlertNotificationLookup": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "isDefault": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - }, - "uid": { - "type": "string" - } - } - }, "AlertQuery": { "type": "object", "title": "AlertQuery represents a single query associated with an alert definition.", @@ -11775,6 +11544,9 @@ "OK" ] }, + "notification_settings": { + "$ref": "#/definitions/AlertRuleNotificationSettingsExport" + }, "panelId": { "type": "integer", "format": "int64" @@ -11842,82 +11614,86 @@ } } }, - "AlertStateInfoDTO": { + "AlertRuleNotificationSettings": { "type": "object", + "required": [ + "receiver" + ], "properties": { - "dashboardId": { - "type": "integer", - "format": "int64" + "group_by": { + "description": "Override the labels by which incoming alerts are grouped together. For example, multiple alerts coming in for\ncluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels\nuse the special value '...' as the sole label name.\nThis effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what\nyou want, unless you have a very low alert volume or your upstream notification system performs its own grouping.\nMust include 'alertname' and 'grafana_folder' if not using '...'.", + "type": "array", + "default": [ + "alertname", + "grafana_folder" + ], + "items": { + "type": "string" + }, + "example": [ + "alertname", + "grafana_folder", + "cluster" + ] }, - "id": { - "type": "integer", - "format": "int64" + "group_interval": { + "description": "Override how long to wait before sending a notification about new alerts that are added to a group of alerts for\nwhich an initial notification has already been sent. (Usually ~5m or more.)", + "type": "string", + "example": "5m" }, - "newStateDate": { + "group_wait": { + "description": "Override how long to initially wait to send a notification for a group of alerts. Allows to wait for an\ninhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.)", "type": "string", - "format": "date-time" + "example": "30s" }, - "panelId": { - "type": "integer", - "format": "int64" + "mute_time_intervals": { + "description": "Override the times when notifications should be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration mute_time_intervals section. When muted it will not send any notifications, but\notherwise acts normally.", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "maintenance" + ] }, - "state": { - "$ref": "#/definitions/AlertStateType" - } - } - }, - "AlertStateType": { - "type": "string" - }, - "AlertTestCommand": { - "type": "object", - "properties": { - "dashboard": { - "$ref": "#/definitions/Json" + "receiver": { + "description": "Name of the receiver to send notifications to.", + "type": "string", + "example": "grafana-default-email" }, - "panelId": { - "type": "integer", - "format": "int64" + "repeat_interval": { + "description": "Override how long to wait before sending a notification again if it has already been sent successfully for an\nalert. (Usually ~3h or more).\nNote that this parameter is implicitly bound by Alertmanager's `--data.retention` configuration flag.\nNotifications will be resent after either repeat_interval or the data retention period have passed, whichever\noccurs first. `repeat_interval` should not be less than `group_interval`.", + "type": "string", + "example": "4h" } } }, - "AlertTestResult": { + "AlertRuleNotificationSettingsExport": { "type": "object", + "title": "AlertRuleNotificationSettingsExport is the provisioned export of models.NotificationSettings.", "properties": { - "conditionEvals": { - "type": "string" - }, - "error": { - "type": "string" - }, - "firing": { - "type": "boolean" - }, - "logs": { + "group_by": { "type": "array", "items": { - "$ref": "#/definitions/AlertTestResultLog" + "type": "string" } }, - "matches": { + "group_interval": { + "type": "string" + }, + "group_wait": { + "type": "string" + }, + "mute_time_intervals": { "type": "array", "items": { - "$ref": "#/definitions/EvalMatch" + "type": "string" } }, - "state": { - "$ref": "#/definitions/AlertStateType" - }, - "timeMs": { + "receiver": { "type": "string" - } - } - }, - "AlertTestResultLog": { - "type": "object", - "properties": { - "data": {}, - "message": { + }, + "repeat_interval": { "type": "string" } } @@ -12714,10 +12490,10 @@ "type": "object", "properties": { "newPassword": { - "type": "string" + "$ref": "#/definitions/Password" }, "oldPassword": { - "type": "string" + "$ref": "#/definitions/Password" } } }, @@ -12740,6 +12516,7 @@ } }, "mute_time_intervals": { + "description": "MuteTimeIntervals is deprecated and will be removed before Alertmanager 1.0.", "type": "array", "items": { "$ref": "#/definitions/MuteTimeInterval" @@ -12753,6 +12530,12 @@ "items": { "type": "string" } + }, + "time_intervals": { + "type": "array", + "items": { + "$ref": "#/definitions/TimeInterval" + } } } }, @@ -12924,41 +12707,6 @@ "format": "uint8", "title": "CounterResetHint contains the known information about a counter reset," }, - "CreateAlertNotificationCommand": { - "type": "object", - "properties": { - "disableResolveMessage": { - "type": "boolean" - }, - "frequency": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "secureSettings": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "sendReminder": { - "type": "boolean" - }, - "settings": { - "$ref": "#/definitions/Json" - }, - "type": { - "type": "string" - }, - "uid": { - "type": "string" - } - } - }, "CreateCorrelationCommand": { "description": "CreateCorrelationCommand is the command for creating a correlation", "type": "object", @@ -13006,8 +12754,12 @@ "dashboard" ], "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional", + "type": "string" + }, "dashboard": { - "$ref": "#/definitions/Json" + "$ref": "#/definitions/Unstructured" }, "deleteKey": { "description": "Unique key used to delete the snapshot. It is different from the `key` so that only the creator can delete the snapshot. Required if `external` is `true`.", @@ -13028,6 +12780,10 @@ "description": "Define the unique key. Required if `external` is `true`.", "type": "string" }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional", + "type": "string" + }, "name": { "description": "Snapshot name", "type": "string" @@ -13366,6 +13122,41 @@ } } }, + "DashboardCreateCommand": { + "description": "These are the values expected to be sent from an end user\n+k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object", + "type": "object", + "required": [ + "dashboard" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional", + "type": "string" + }, + "dashboard": { + "$ref": "#/definitions/Unstructured" + }, + "expires": { + "description": "When the snapshot should expire in seconds in seconds. Default is never to expire.", + "type": "integer", + "format": "int64", + "default": 0 + }, + "external": { + "description": "these are passed when storing an external snapshot ref\nSave the snapshot on an external server rather than locally.", + "type": "boolean", + "default": false + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional", + "type": "string" + }, + "name": { + "description": "Snapshot name", + "type": "string" + } + } + }, "DashboardFullWithMeta": { "type": "object", "properties": { @@ -13782,6 +13573,32 @@ } } }, + "DeviceSearchHitDTO": { + "type": "object", + "properties": { + "clientIp": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "deviceId": { + "type": "string" + }, + "lastSeenAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "userAgent": { + "type": "string" + } + } + }, "DiscordConfig": { "type": "object", "title": "DiscordConfig configures notifications via Discord.", @@ -13800,6 +13617,9 @@ }, "webhook_url": { "$ref": "#/definitions/SecretURL" + }, + "webhook_url_file": { + "type": "string" } } }, @@ -14038,23 +13858,6 @@ } } }, - "EvalMatch": { - "type": "object", - "properties": { - "metric": { - "type": "string" - }, - "tags": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "value": { - "type": "string" - } - } - }, "EvalQueriesPayload": { "type": "object", "properties": { @@ -14497,11 +14300,20 @@ }, "typeVersion": { "$ref": "#/definitions/FrameTypeVersion" + }, + "uniqueRowIdFields": { + "description": "Array of field indices which values create a unique id for each row. Ideally this should be globally unique ID\nbut that isn't guarantied. Should help with keeping track and deduplicating rows in visualizations, especially\nwith streaming data with frequent updates.", + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "example": "TraceID in Tempo, table name + primary key in SQL" } } }, "FrameType": { - "description": "A FrameType string, when present in a frame's metadata, asserts that the\nframe's structure conforms to the FrameType's specification.\nThis property is currently optional, so FrameType may be FrameTypeUnknown even if the properties of\nthe Frame correspond to a defined FrameType.", + "description": "A FrameType string, when present in a frame's metadata, asserts that the\nframe's structure conforms to the FrameType's specification.\nThis property is currently optional, so FrameType may be FrameTypeUnknown even if the properties of\nthe Frame correspond to a defined FrameType.\n+enum", "type": "string" }, "FrameTypeVersion": { @@ -14520,6 +14332,14 @@ "$ref": "#/definitions/Frame" } }, + "GenericPublicError": { + "type": "object", + "properties": { + "body": { + "$ref": "#/definitions/PublicError" + } + } + }, "GetAnnotationTagsResponse": { "type": "object", "title": "GetAnnotationTagsResponse is a response struct for FindTagsResult.", @@ -14583,6 +14403,7 @@ } }, "mute_time_intervals": { + "description": "MuteTimeIntervals is deprecated and will be removed before Alertmanager 1.0.", "type": "array", "items": { "$ref": "#/definitions/MuteTimeInterval" @@ -14603,6 +14424,12 @@ "items": { "type": "string" } + }, + "time_intervals": { + "type": "array", + "items": { + "$ref": "#/definitions/TimeInterval" + } } } }, @@ -14816,6 +14643,9 @@ "OK" ] }, + "notification_settings": { + "$ref": "#/definitions/AlertRuleNotificationSettings" + }, "orgId": { "type": "integer", "format": "int64" @@ -14931,6 +14761,23 @@ } } }, + "GettableTimeIntervals": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "provenance": { + "$ref": "#/definitions/Provenance" + }, + "time_intervals": { + "type": "array", + "items": { + "$ref": "#/definitions/TimeIntervalItem" + } + } + } + }, "GettableUserConfig": { "type": "object", "properties": { @@ -15359,8 +15206,8 @@ } }, "JSONWebKey": { + "description": "JSONWebKey represents a public or private key in JWK format. It can be\nmarshaled into JSON and unmarshaled from JSON.", "type": "object", - "title": "JSONWebKey represents a public or private key in JWK format.", "properties": { "Algorithm": { "description": "Key algorithm, parsed from `alg` header.", @@ -15393,7 +15240,7 @@ "$ref": "#/definitions/URL" }, "Key": { - "description": "Cryptographic key, can be a symmetric or asymmetric key." + "description": "Key is the Go in-memory representation of this key. It must have one\nof these types:\ned25519.PublicKey\ned25519.PrivateKey\necdsa.PublicKey\necdsa.PrivateKey\nrsa.PublicKey\nrsa.PrivateKey\n[]byte (a symmetric key)\n\nWhen marshaling this JSONWebKey into JSON, the \"kty\" header parameter\nwill be automatically set based on the type of this field." }, "KeyID": { "description": "Key identifier, parsed from `kid` header.", @@ -15451,82 +15298,6 @@ "$ref": "#/definitions/Label" } }, - "LegacyAlert": { - "type": "object", - "properties": { - "Created": { - "type": "string", - "format": "date-time" - }, - "DashboardID": { - "type": "integer", - "format": "int64" - }, - "EvalData": { - "$ref": "#/definitions/Json" - }, - "ExecutionError": { - "type": "string" - }, - "For": { - "$ref": "#/definitions/Duration" - }, - "Frequency": { - "type": "integer", - "format": "int64" - }, - "Handler": { - "type": "integer", - "format": "int64" - }, - "ID": { - "type": "integer", - "format": "int64" - }, - "Message": { - "type": "string" - }, - "Name": { - "type": "string" - }, - "NewStateDate": { - "type": "string", - "format": "date-time" - }, - "OrgID": { - "type": "integer", - "format": "int64" - }, - "PanelID": { - "type": "integer", - "format": "int64" - }, - "Settings": { - "$ref": "#/definitions/Json" - }, - "Severity": { - "type": "string" - }, - "Silenced": { - "type": "boolean" - }, - "State": { - "$ref": "#/definitions/AlertStateType" - }, - "StateChanges": { - "type": "integer", - "format": "int64" - }, - "Updated": { - "type": "string", - "format": "date-time" - }, - "Version": { - "type": "integer", - "format": "int64" - } - } - }, "LibraryElementArrayResponse": { "type": "object", "title": "LibraryElementArrayResponse is a response struct for an array of LibraryElementDTO.", @@ -15749,6 +15520,9 @@ "send_resolved": { "type": "boolean" }, + "summary": { + "type": "string" + }, "text": { "type": "string" }, @@ -15757,6 +15531,9 @@ }, "webhook_url": { "$ref": "#/definitions/SecretURL" + }, + "webhook_url_file": { + "type": "string" } } }, @@ -16097,39 +15874,6 @@ "$ref": "#/definitions/NotificationTemplate" } }, - "NotificationTestCommand": { - "type": "object", - "properties": { - "disableResolveMessage": { - "type": "boolean" - }, - "frequency": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int64" - }, - "name": { - "type": "string" - }, - "secureSettings": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "sendReminder": { - "type": "boolean" - }, - "settings": { - "$ref": "#/definitions/Json" - }, - "type": { - "type": "string" - } - } - }, "NotifierConfig": { "type": "object", "title": "NotifierConfig contains base options common across all notifier configurations.", @@ -16194,9 +15938,19 @@ "format": "int64" } }, + "ObjectMatcher": { + "type": "array", + "title": "ObjectMatcher is a matcher that can be used to filter alerts.", + "items": { + "type": "string" + } + }, "ObjectMatchers": { - "description": "ObjectMatchers is Matchers with a different Unmarshal and Marshal methods that accept matchers as objects\nthat have already been parsed.", - "$ref": "#/definitions/Matchers" + "type": "array", + "title": "ObjectMatchers is a list of matchers that can be used to filter alerts.", + "items": { + "$ref": "#/definitions/ObjectMatcher" + } }, "OpsGenieConfig": { "type": "object", @@ -16453,6 +16207,9 @@ } } }, + "Password": { + "type": "string" + }, "PatchAnnotationsCmd": { "type": "object", "properties": { @@ -16575,26 +16332,6 @@ } } }, - "PauseAlertCommand": { - "type": "object", - "properties": { - "alertId": { - "type": "integer", - "format": "int64" - }, - "paused": { - "type": "boolean" - } - } - }, - "PauseAllAlertsCommand": { - "type": "object", - "properties": { - "paused": { - "type": "boolean" - } - } - }, "Permission": { "type": "object", "title": "Permission is the model for access control permissions.", @@ -16745,24 +16482,6 @@ "$ref": "#/definitions/Playlist" } }, - "Point": { - "description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.", - "type": "object", - "title": "Point represents a single data point for a given timestamp.", - "properties": { - "H": { - "$ref": "#/definitions/FloatHistogram" - }, - "T": { - "type": "integer", - "format": "int64" - }, - "V": { - "type": "number", - "format": "double" - } - } - }, "PostAnnotationsCmd": { "type": "object", "required": [ @@ -16819,6 +16538,7 @@ } }, "PostableApiAlertingConfig": { + "description": "nolint:revive", "type": "object", "properties": { "global": { @@ -16831,6 +16551,7 @@ } }, "mute_time_intervals": { + "description": "MuteTimeIntervals is deprecated and will be removed before Alertmanager 1.0.", "type": "array", "items": { "$ref": "#/definitions/MuteTimeInterval" @@ -16851,10 +16572,17 @@ "items": { "type": "string" } + }, + "time_intervals": { + "type": "array", + "items": { + "$ref": "#/definitions/TimeInterval" + } } } }, "PostableApiReceiver": { + "description": "nolint:revive", "type": "object", "properties": { "discord_configs": { @@ -17073,6 +16801,9 @@ "OK" ] }, + "notification_settings": { + "$ref": "#/definitions/AlertRuleNotificationSettings" + }, "title": { "type": "string" }, @@ -17111,6 +16842,20 @@ } } }, + "PostableTimeIntervals": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "time_intervals": { + "type": "array", + "items": { + "$ref": "#/definitions/TimeIntervalItem" + } + } + } + }, "PostableUserConfig": { "type": "object", "properties": { @@ -17292,6 +17037,9 @@ "OK" ] }, + "notification_settings": { + "$ref": "#/definitions/AlertRuleNotificationSettings" + }, "orgID": { "type": "integer", "format": "int64" @@ -17551,7 +17299,7 @@ "type": "object", "title": "QueryDataResponse contains the results from a QueryDataRequest.", "properties": { - "Responses": { + "results": { "$ref": "#/definitions/Responses" } } @@ -18006,6 +17754,9 @@ "templateVars": { "type": "object" }, + "uid": { + "type": "string" + }, "updated": { "type": "string", "format": "date-time" @@ -18068,9 +17819,6 @@ "ReportEmail": { "type": "object", "properties": { - "email": { - "type": "string" - }, "emails": { "description": "Comma-separated list of emails to which to send the report to.", "type": "string" @@ -18103,9 +17851,6 @@ "ReportSchedule": { "type": "object", "properties": { - "day": { - "type": "string" - }, "dayOfMonth": { "type": "string" }, @@ -18116,10 +17861,6 @@ "frequency": { "type": "string" }, - "hour": { - "type": "integer", - "format": "int64" - }, "intervalAmount": { "type": "integer", "format": "int64" @@ -18127,10 +17868,6 @@ "intervalFrequency": { "type": "string" }, - "minute": { - "type": "integer", - "format": "int64" - }, "startDate": { "type": "string", "format": "date-time" @@ -18284,6 +18021,32 @@ } } }, + "RolesSearchQuery": { + "type": "object", + "properties": { + "includeHidden": { + "type": "boolean" + }, + "orgId": { + "type": "integer", + "format": "int64" + }, + "teamIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + }, + "userIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + } + }, "Route": { "description": "A Route is a node that contains definitions of how to handle alerts. This is modified\nfrom the upstream alertmanager in that it adds the ObjectMatchers property.", "type": "object", @@ -18584,28 +18347,14 @@ } } }, - "SSOSettings": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "settings": { - "type": "object", - "additionalProperties": false - }, - "source": { - "$ref": "#/definitions/SettingsSource" - } - } - }, "Sample": { + "description": "Sample is a single sample belonging to a metric. It represents either a float\nsample or a histogram sample. If H is nil, it is a float sample. Otherwise,\nit is a histogram sample.", "type": "object", - "title": "Sample is a single sample belonging to a metric.", "properties": { + "F": { + "type": "number", + "format": "double" + }, "H": { "$ref": "#/definitions/FloatHistogram" }, @@ -18615,10 +18364,6 @@ "T": { "type": "integer", "format": "int64" - }, - "V": { - "type": "number", - "format": "double" } } }, @@ -18655,6 +18400,29 @@ } } }, + "SearchDeviceQueryResult": { + "type": "object", + "properties": { + "devices": { + "type": "array", + "items": { + "$ref": "#/definitions/DeviceSearchHitDTO" + } + }, + "page": { + "type": "integer", + "format": "int64" + }, + "perPage": { + "type": "integer", + "format": "int64" + }, + "totalCount": { + "type": "integer", + "format": "int64" + } + } + }, "SearchOrgServiceAccountsResult": { "description": "swagger: model", "type": "object", @@ -18926,6 +18694,25 @@ } } }, + "SetResourcePermissionCommand": { + "type": "object", + "properties": { + "builtInRole": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "teamId": { + "type": "integer", + "format": "int64" + }, + "userId": { + "type": "integer", + "format": "int64" + } + } + }, "SetRoleAssignmentsCommand": { "type": "object", "properties": { @@ -18978,10 +18765,6 @@ } } }, - "SettingsSource": { - "type": "integer", - "format": "int64" - }, "ShareType": { "type": "string" }, @@ -19694,7 +19477,21 @@ "type": "string" }, "TimeInterval": { - "description": "TimeInterval describes intervals of time. ContainsTime will tell you if a golang time is contained\nwithin the interval.", + "type": "object", + "title": "TimeInterval represents a named set of time intervals for which a route should be muted.", + "properties": { + "name": { + "type": "string" + }, + "time_intervals": { + "type": "array", + "items": { + "$ref": "#/definitions/TimeInterval" + } + } + } + }, + "TimeIntervalItem": { "type": "object", "properties": { "days_of_month": { @@ -19715,7 +19512,7 @@ "times": { "type": "array", "items": { - "$ref": "#/definitions/TimeRange" + "$ref": "#/definitions/TimeIntervalTimeRange" } }, "weekdays": { @@ -19732,6 +19529,17 @@ } } }, + "TimeIntervalTimeRange": { + "type": "object", + "properties": { + "end_time": { + "type": "string" + }, + "start_time": { + "type": "string" + } + } + }, "TimeRange": { "description": "Redefining this to avoid an import cycle", "type": "object", @@ -19752,6 +19560,10 @@ "account": { "type": "string" }, + "anonymousRatio": { + "type": "integer", + "format": "int64" + }, "company": { "type": "string" }, @@ -19900,126 +19712,78 @@ } } }, - "Transformations": { - "type": "array", - "items": { - "$ref": "#/definitions/Transformation" - } - }, - "Type": { - "type": "string" - }, - "URL": { - "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", - "type": "object", - "title": "A URL represents a parsed URL (technically, a URI reference).", - "properties": { - "ForceQuery": { - "type": "boolean" - }, - "Fragment": { - "type": "string" - }, - "Host": { - "type": "string" - }, - "OmitHost": { - "type": "boolean" - }, - "Opaque": { - "type": "string" - }, - "Path": { - "type": "string" - }, - "RawFragment": { - "type": "string" - }, - "RawPath": { - "type": "string" - }, - "RawQuery": { - "type": "string" - }, - "Scheme": { - "type": "string" - }, - "User": { - "$ref": "#/definitions/Userinfo" - } - } - }, - "UpdateAlertNotificationCommand": { - "type": "object", - "properties": { - "disableResolveMessage": { - "type": "boolean" - }, - "frequency": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int64" - }, - "isDefault": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "secureSettings": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "sendReminder": { - "type": "boolean" - }, - "settings": { - "$ref": "#/definitions/Json" - }, - "type": { + "Transformations": { + "type": "array", + "items": { + "$ref": "#/definitions/Transformation" + } + }, + "Type": { + "type": "string" + }, + "TypeMeta": { + "description": "+k8s:deepcopy-gen=false", + "type": "object", + "title": "TypeMeta describes an individual object in an API response or request\nwith strings representing the type of the object and its API schema version.\nStructures that are versioned or persisted should inline TypeMeta.", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional", "type": "string" }, - "uid": { + "kind": { + "description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional", "type": "string" } } }, - "UpdateAlertNotificationWithUidCommand": { + "URL": { + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "type": "object", + "title": "A URL represents a parsed URL (technically, a URI reference).", "properties": { - "disableResolveMessage": { + "ForceQuery": { "type": "boolean" }, - "frequency": { + "Fragment": { "type": "string" }, - "isDefault": { + "Host": { + "type": "string" + }, + "OmitHost": { "type": "boolean" }, - "name": { + "Opaque": { "type": "string" }, - "secureSettings": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "Path": { + "type": "string" }, - "sendReminder": { - "type": "boolean" + "RawFragment": { + "type": "string" }, - "settings": { - "$ref": "#/definitions/Json" + "RawPath": { + "type": "string" }, - "type": { + "RawQuery": { "type": "string" }, - "uid": { + "Scheme": { "type": "string" + }, + "User": { + "$ref": "#/definitions/Userinfo" + } + } + }, + "Unstructured": { + "description": "Unstructured allows objects that do not have Golang structs registered to be manipulated\ngenerically.", + "type": "object", + "properties": { + "Object": { + "description": "Object is a JSON compatible map with string, float, int, bool, []interface{},\nor map[string]interface{} children.", + "type": "object", + "additionalProperties": false } } }, @@ -20506,6 +20270,9 @@ "theme": { "type": "string" }, + "uid": { + "type": "string" + }, "updatedAt": { "type": "string", "format": "date-time" @@ -20549,6 +20316,9 @@ }, "name": { "type": "string" + }, + "uid": { + "type": "string" } } }, @@ -20628,7 +20398,7 @@ } }, "Vector": { - "description": "Vector is basically only an alias for model.Samples, but the\ncontract is that in a Vector, all Samples have the same timestamp.", + "description": "Vector is basically only an alias for []Sample, but the contract is that\nin a Vector, all Samples have the same timestamp.", "type": "array", "items": { "$ref": "#/definitions/Sample" @@ -20781,6 +20551,7 @@ } }, "alertGroup": { + "description": "AlertGroup alert group", "type": "object", "required": [ "alerts", @@ -21046,12 +20817,14 @@ } }, "gettableSilences": { + "description": "GettableSilences gettable silences", "type": "array", "items": { "$ref": "#/definitions/gettableSilence" } }, "integration": { + "description": "Integration integration", "type": "object", "required": [ "name", @@ -21195,7 +20968,6 @@ } }, "postableSilence": { - "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", @@ -21339,6 +21111,25 @@ } } }, + "setPermissionCommand": { + "type": "object", + "properties": { + "permission": { + "type": "string" + } + } + }, + "setPermissionsCommand": { + "type": "object", + "properties": { + "permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/SetResourcePermissionCommand" + } + } + } + }, "silence": { "description": "Silence silence", "type": "object", @@ -21429,6 +21220,36 @@ } }, "responses": { + "GetAllIntervalsResponse": { + "description": "(empty)", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/GettableTimeIntervals" + } + } + }, + "GetIntervalsByNameResponse": { + "description": "(empty)", + "schema": { + "$ref": "#/definitions/GettableTimeIntervals" + } + }, + "GetReceiverResponse": { + "description": "(empty)", + "schema": { + "$ref": "#/definitions/GettableApiReceiver" + } + }, + "GetReceiversResponse": { + "description": "(empty)", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/GettableApiReceiver" + } + } + }, "GettableHistoricUserConfigs": { "description": "(empty)", "schema": { @@ -21697,28 +21518,6 @@ } } }, - "deleteAlertNotificationChannelResponse": { - "description": "(empty)", - "schema": { - "type": "object", - "required": [ - "id", - "message" - ], - "properties": { - "id": { - "description": "ID Identifier of the deleted notification channel.", - "type": "integer", - "format": "int64", - "example": 65 - }, - "message": { - "description": "Message Message of the deleted notificatiton channel.", - "type": "string" - } - } - } - }, "deleteCorrelationResponse": { "description": "(empty)", "schema": { @@ -21815,6 +21614,12 @@ } } }, + "devicesSearchResponse": { + "description": "(empty)", + "schema": { + "$ref": "#/definitions/SearchDeviceQueryResult" + } + }, "folderResponse": { "description": "(empty)", "schema": { @@ -21854,45 +21659,6 @@ "$ref": "#/definitions/Status" } }, - "getAlertNotificationChannelResponse": { - "description": "(empty)", - "schema": { - "$ref": "#/definitions/AlertNotification" - } - }, - "getAlertNotificationChannelsResponse": { - "description": "(empty)", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/AlertNotification" - } - } - }, - "getAlertNotificationLookupResponse": { - "description": "(empty)", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/AlertNotificationLookup" - } - } - }, - "getAlertResponse": { - "description": "(empty)", - "schema": { - "$ref": "#/definitions/LegacyAlert" - } - }, - "getAlertsResponse": { - "description": "(empty)", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/AlertListItemDTO" - } - } - }, "getAllRolesResponse": { "description": "(empty)", "schema": { @@ -21965,15 +21731,6 @@ "getDashboardSnapshotResponse": { "description": "(empty)" }, - "getDashboardStatesResponse": { - "description": "(empty)", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/AlertStateInfoDTO" - } - } - }, "getDashboardsTagsResponse": { "description": "(empty)", "schema": { @@ -22231,6 +21988,27 @@ "$ref": "#/definitions/RoleDTO" } }, + "getSSOSettingsResponse": { + "description": "(empty)", + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "settings": { + "type": "object", + "additionalProperties": false + }, + "source": { + "type": "string" + } + } + } + }, "getSharingOptionsResponse": { "description": "(empty)", "schema": { @@ -22409,6 +22187,30 @@ } } }, + "listSSOSettingsResponse": { + "description": "(empty)", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "settings": { + "type": "object", + "additionalProperties": false + }, + "source": { + "type": "string" + } + } + } + } + }, "listSortOptionsResponse": { "description": "(empty)", "schema": { @@ -22429,6 +22231,18 @@ } } }, + "listTeamsRolesResponse": { + "description": "(empty)", + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/RoleDTO" + } + } + } + }, "listTokensResponse": { "description": "(empty)", "schema": { @@ -22438,6 +22252,18 @@ } } }, + "listUsersRolesResponse": { + "description": "(empty)", + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/RoleDTO" + } + } + } + }, "notFoundError": { "description": "NotFoundError is returned when the requested resource was not found.", "schema": { @@ -22456,53 +22282,6 @@ "$ref": "#/definitions/SuccessResponseBody" } }, - "pauseAlertResponse": { - "description": "(empty)", - "schema": { - "type": "object", - "required": [ - "alertId", - "message" - ], - "properties": { - "alertId": { - "type": "integer", - "format": "int64" - }, - "message": { - "type": "string" - }, - "state": { - "description": "Alert result state\nrequired true", - "type": "string" - } - } - } - }, - "pauseAlertsResponse": { - "description": "(empty)", - "schema": { - "type": "object", - "required": [ - "alertsAffected", - "message" - ], - "properties": { - "alertsAffected": { - "description": "AlertsAffected is the number of the affected alerts.", - "type": "integer", - "format": "int64" - }, - "message": { - "type": "string" - }, - "state": { - "description": "Alert result state\nrequired true", - "type": "string" - } - } - } - }, "postAPIkeyResponse": { "description": "(empty)", "schema": { @@ -22724,12 +22503,6 @@ "$ref": "#/definitions/RoleAssignmentsDTO" } }, - "testAlertResponse": { - "description": "(empty)", - "schema": { - "$ref": "#/definitions/AlertTestResult" - } - }, "unauthorisedError": { "description": "UnauthorizedError is returned when the request is not authenticated.", "schema": { diff --git a/public/app/app.ts b/public/app/app.ts index c2975ee5b01b8..613b40d560cc3 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -33,14 +33,16 @@ import { setRunRequest, setPluginImportUtils, setPluginExtensionGetter, + setEmbeddedDashboard, setAppEvents, + setReturnToPreviousHook, type GetPluginExtensions, } from '@grafana/runtime'; import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView'; import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer'; import { setPluginPage } from '@grafana/runtime/src/components/PluginPage'; import { getScrollbarWidth } from '@grafana/ui'; -import config from 'app/core/config'; +import config, { updateConfig } from 'app/core/config'; import { arrayMove } from 'app/core/utils/arrayMove'; import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; @@ -51,11 +53,13 @@ import appEvents from './core/app_events'; import { AppChromeService } from './core/components/AppChrome/AppChromeService'; import { getAllOptionEditors, getAllStandardFieldConfigs } from './core/components/OptionsUI/registry'; import { PluginPage } from './core/components/Page/PluginPage'; -import { GrafanaContextType } from './core/context/GrafanaContext'; +import { GrafanaContextType, useReturnToPreviousInternal } from './core/context/GrafanaContext'; import { initIconCache } from './core/icons/iconBundle'; import { initializeI18n } from './core/internationalization'; +import { setMonacoEnv } from './core/monacoEnv'; import { interceptLinkClicks } from './core/navigation/patch/interceptLinkClicks'; import { ModalManager } from './core/services/ModalManager'; +import { NewFrontendAssetsChecker } from './core/services/NewFrontendAssetsChecker'; import { backendSrv } from './core/services/backend_srv'; import { contextSrv } from './core/services/context_srv'; import { Echo } from './core/services/echo/Echo'; @@ -69,8 +73,10 @@ import { GrafanaJavascriptAgentBackend } from './core/services/echo/backends/gra import { KeybindingSrv } from './core/services/keybindingSrv'; import { startMeasure, stopMeasure } from './core/utils/metrics'; import { initDevFeatures } from './dev'; +import { initAlerting } from './features/alerting/unified/initAlerting'; import { initAuthConfig } from './features/auth-config'; import { getTimeSrv } from './features/dashboard/services/TimeSrv'; +import { EmbeddedDashboardLazy } from './features/dashboard-scene/embedding/EmbeddedDashboardLazy'; import { initGrafanaLive } from './features/live'; import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView'; import { PanelRenderer } from './features/panel/components/PanelRenderer'; @@ -120,19 +126,25 @@ export class GrafanaApp { parent.postMessage('GrafanaAppInit', '*'); const initI18nPromise = initializeI18n(config.bootData.user.language); + initI18nPromise.then(({ language }) => updateConfig({ language })); setBackendSrv(backendSrv); initEchoSrv(); initIconCache(); // This needs to be done after the `initEchoSrv` since it is being used under the hood. startMeasure('frontend_app_init'); - addClassIfNoOverlayScrollbar(); + + if (!config.featureToggles.betterPageScrolling) { + addClassIfNoOverlayScrollbar(); + } + setLocale(config.bootData.user.locale); setWeekStart(config.bootData.user.weekStart); setPanelRenderer(PanelRenderer); setPluginPage(PluginPage); setPanelDataErrorView(PanelDataErrorView); setLocationSrv(locationService); + setEmbeddedDashboard(EmbeddedDashboardLazy); setTimeZoneResolver(() => config.bootData.user.timezone); initGrafanaLive(); @@ -149,6 +161,8 @@ export class GrafanaApp { configureStore(); initExtensions(); + initAlerting(); + standardEditorsRegistry.setInit(getAllOptionEditors); standardFieldConfigEditorRegistry.setInit(getAllStandardFieldConfigs); standardTransformersRegistry.setInit(getStandardTransformers); @@ -162,7 +176,9 @@ export class GrafanaApp { createAdHocVariableAdapter(), createSystemVariableAdapter(), ]); + monacoLanguageRegistry.setInit(getDefaultMonacoLanguages); + setMonacoEnv(); setQueryRunnerFactory(() => new QueryRunner()); setVariableQueryRunner(new VariableQueryRunner()); @@ -218,6 +234,8 @@ export class GrafanaApp { const queryParams = locationService.getSearchObject(); const chromeService = new AppChromeService(); const keybindingsService = new KeybindingSrv(locationService, chromeService); + const newAssetsChecker = new NewFrontendAssetsChecker(); + newAssetsChecker.start(); // Read initial kiosk mode from url at app startup chromeService.setKioskModeFromUrl(queryParams.kiosk); @@ -234,9 +252,12 @@ export class GrafanaApp { location: locationService, chrome: chromeService, keybindings: keybindingsService, + newAssetsChecker, config, }; + setReturnToPreviousHook(useReturnToPreviousInternal); + const root = createRoot(document.getElementById('reactRoot')!); root.render( React.createElement(AppWrapper, { diff --git a/public/app/core/actions/index.ts b/public/app/core/actions/index.ts index 88c6c68ab2eaa..d73c489b33e24 100644 --- a/public/app/core/actions/index.ts +++ b/public/app/core/actions/index.ts @@ -1,3 +1,4 @@ import { hideAppNotification, notifyApp } from '../reducers/appNotification'; import { updateNavIndex, updateConfigurationSubtitle } from '../reducers/navModel'; + export { updateNavIndex, updateConfigurationSubtitle, notifyApp, hideAppNotification }; diff --git a/public/app/core/components/AccessControl/AddPermission.tsx b/public/app/core/components/AccessControl/AddPermission.tsx index 6098f20a13e70..3099e84ad54b3 100644 --- a/public/app/core/components/AccessControl/AddPermission.tsx +++ b/public/app/core/components/AccessControl/AddPermission.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { Button, Form, Select, Stack } from '@grafana/ui'; +import { Button, Select, Stack } from '@grafana/ui'; import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; import { ServiceAccountPicker } from 'app/core/components/Select/ServiceAccountPicker'; import { TeamPicker } from 'app/core/components/Select/TeamPicker'; @@ -71,54 +71,54 @@ export const AddPermission = ({ <CloseButton onClick={onCancel} /> <h5>{title}</h5> - <Form + <form name="addPermission" - maxWidth="none" - onSubmit={() => onAdd({ userId, teamId, builtInRole, permission, target })} + onSubmit={(event) => { + event.preventDefault(); + onAdd({ userId, teamId, builtInRole, permission, target }); + }} > - {() => ( - <Stack gap={1} direction="row"> - <Select - aria-label="Role to add new permission to" - value={target} - options={targetOptions} - onChange={(v) => setPermissionTarget(v.value!)} - disabled={targetOptions.length === 0} - width="auto" - /> + <Stack gap={1} direction="row"> + <Select + aria-label="Role to add new permission to" + value={target} + options={targetOptions} + onChange={(v) => setPermissionTarget(v.value!)} + disabled={targetOptions.length === 0} + width="auto" + /> - {target === PermissionTarget.User && <UserPicker onSelected={(u) => setUserId(u?.value || 0)} />} + {target === PermissionTarget.User && <UserPicker onSelected={(u) => setUserId(u?.value || 0)} />} - {target === PermissionTarget.ServiceAccount && ( - <ServiceAccountPicker onSelected={(u) => setUserId(u?.value || 0)} /> - )} + {target === PermissionTarget.ServiceAccount && ( + <ServiceAccountPicker onSelected={(u) => setUserId(u?.value || 0)} /> + )} - {target === PermissionTarget.Team && <TeamPicker onSelected={(t) => setTeamId(t.value?.id || 0)} />} - - {target === PermissionTarget.BuiltInRole && ( - <Select - aria-label={'Built-in role picker'} - options={Object.values(OrgRole) - .filter((r) => r !== OrgRole.None) - .map((r) => ({ value: r, label: r }))} - onChange={(r) => setBuiltinRole(r.value || '')} - width="auto" - /> - )} + {target === PermissionTarget.Team && <TeamPicker onSelected={(t) => setTeamId(t.value?.id || 0)} />} + {target === PermissionTarget.BuiltInRole && ( <Select - aria-label="Permission Level" + aria-label={'Built-in role picker'} + options={Object.values(OrgRole) + .filter((r) => r !== OrgRole.None) + .map((r) => ({ value: r, label: r }))} + onChange={(r) => setBuiltinRole(r.value || '')} width="auto" - value={permissions.find((p) => p === permission)} - options={permissions.map((p) => ({ label: p, value: p }))} - onChange={(v) => setPermission(v.value || '')} /> - <Button type="submit" disabled={!isValid()}> - <Trans i18nKey="access-control.add-permissions.save">Save</Trans> - </Button> - </Stack> - )} - </Form> + )} + + <Select + aria-label="Permission Level" + width="auto" + value={permissions.find((p) => p === permission)} + options={permissions.map((p) => ({ label: p, value: p }))} + onChange={(v) => setPermission(v.value || '')} + /> + <Button type="submit" disabled={!isValid()}> + <Trans i18nKey="access-control.add-permissions.save">Save</Trans> + </Button> + </Stack> + </form> </div> ); }; diff --git a/public/app/core/components/AccessControl/PermissionList.tsx b/public/app/core/components/AccessControl/PermissionList.tsx index baadf95b0c729..a56c98c4d32a6 100644 --- a/public/app/core/components/AccessControl/PermissionList.tsx +++ b/public/app/core/components/AccessControl/PermissionList.tsx @@ -27,6 +27,12 @@ export const PermissionList = ({ title, items, compareKey, permissionLevels, can if (item.actions.length > keep[key].actions.length) { keep[key] = item; + continue; + } + + // If the same permission has been inherited and applied directly, keep the one that is applied directly + if (item.actions.length === keep[key].actions.length && !item.isInherited) { + keep[key] = item; } } return Object.keys(keep).map((k) => keep[k]); diff --git a/public/app/core/components/AccessControl/PermissionListItem.tsx b/public/app/core/components/AccessControl/PermissionListItem.tsx index 77b7e177a2b72..0d6eaf15cf786 100644 --- a/public/app/core/components/AccessControl/PermissionListItem.tsx +++ b/public/app/core/components/AccessControl/PermissionListItem.tsx @@ -1,6 +1,8 @@ +import { css } from '@emotion/css'; import React from 'react'; -import { Button, Icon, Select, Tooltip } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Box, Button, Icon, Select, Tooltip, useStyles2 } from '@grafana/ui'; import { ResourcePermission } from './types'; @@ -12,42 +14,59 @@ interface Props { onChange: (item: ResourcePermission, permission: string) => void; } -export const PermissionListItem = ({ item, permissionLevels, canSet, onRemove, onChange }: Props) => ( - <tr> - <td>{getAvatar(item)}</td> - <td>{getDescription(item)}</td> - <td>{item.isInherited && <em className="muted no-wrap">Inherited from folder</em>}</td> - <td> - <Select - disabled={!canSet || !item.isManaged} - onChange={(p) => onChange(item, p.value!)} - value={permissionLevels.find((p) => p === item.permission)} - options={permissionLevels.map((p) => ({ value: p, label: p }))} - /> - </td> - <td> - <Tooltip content={getPermissionInfo(item)}> - <Icon name="info-circle" /> - </Tooltip> - </td> - <td> - {item.isManaged ? ( - <Button - size="sm" - icon="times" - variant="destructive" - disabled={!canSet} - onClick={() => onRemove(item)} - aria-label={`Remove permission for ${getName(item)}`} +export const PermissionListItem = ({ item, permissionLevels, canSet, onRemove, onChange }: Props) => { + const styles = useStyles2(getStyles); + + return ( + <tr> + <td>{getAvatar(item)}</td> + <td>{getDescription(item)}</td> + <td>{item.isInherited && <em className="muted no-wrap">Inherited from folder</em>}</td> + <td> + <Select + disabled={!canSet || !item.isManaged} + onChange={(p) => onChange(item, p.value!)} + value={permissionLevels.find((p) => p === item.permission)} + options={permissionLevels.map((p) => ({ value: p, label: p }))} /> - ) : ( - <Tooltip content={item.isInherited ? 'Inherited Permission' : 'Provisioned Permission'}> - <Button size="sm" icon="lock" /> - </Tooltip> - )} - </td> - </tr> -); + </td> + <td> + {item.warning ? ( + <Tooltip + content={ + <> + <Box marginBottom={1}>{item.warning}</Box> + {getPermissionInfo(item)} + </> + } + > + <Icon name="exclamation-triangle" className={styles.warning} /> + </Tooltip> + ) : ( + <Tooltip content={getPermissionInfo(item)}> + <Icon name="info-circle" /> + </Tooltip> + )} + </td> + <td> + {item.isManaged ? ( + <Button + size="sm" + icon="times" + variant="destructive" + disabled={!canSet} + onClick={() => onRemove(item)} + aria-label={`Remove permission for ${getName(item)}`} + /> + ) : ( + <Tooltip content={item.isInherited ? 'Inherited Permission' : 'Provisioned Permission'}> + <Button size="sm" icon="lock" /> + </Tooltip> + )} + </td> + </tr> + ); +}; const getAvatar = (item: ResourcePermission) => { if (item.teamId) { @@ -80,3 +99,9 @@ const getDescription = (item: ResourcePermission) => { }; const getPermissionInfo = (p: ResourcePermission) => `Actions: ${[...new Set(p.actions)].sort().join(' ')}`; + +const getStyles = (theme: GrafanaTheme2) => ({ + warning: css({ + color: theme.colors.warning.main, + }), +}); diff --git a/public/app/core/components/AccessControl/Permissions.tsx b/public/app/core/components/AccessControl/Permissions.tsx index 1fb44372b526f..d84e03031e788 100644 --- a/public/app/core/components/AccessControl/Permissions.tsx +++ b/public/app/core/components/AccessControl/Permissions.tsx @@ -3,8 +3,7 @@ import { sortBy } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Space } from '@grafana/experimental'; -import { Button, useStyles2 } from '@grafana/ui'; +import { Text, Box, Button, useStyles2, Space } from '@grafana/ui'; import { SlideDown } from 'app/core/components/Animations/SlideDown'; import { Trans, t } from 'app/core/internationalization'; import { getBackendSrv } from 'app/core/services/backend_srv'; @@ -37,6 +36,8 @@ export type Props = { resource: string; resourceId: ResourceId; canSetPermissions: boolean; + getWarnings?: (items: ResourcePermission[]) => ResourcePermission[]; + epilogue?: (items: ResourcePermission[]) => React.ReactNode; }; export const Permissions = ({ @@ -47,15 +48,21 @@ export const Permissions = ({ resourceId, canSetPermissions, addPermissionTitle, + getWarnings, + epilogue, }: Props) => { const styles = useStyles2(getStyles); const [isAdding, setIsAdding] = useState(false); const [items, setItems] = useState<ResourcePermission[]>([]); const [desc, setDesc] = useState(INITIAL_DESCRIPTION); - const fetchItems = useCallback(() => { - return getPermissions(resource, resourceId).then((r) => setItems(r)); - }, [resource, resourceId]); + const fetchItems = useCallback(async () => { + let items = await getPermissions(resource, resourceId); + if (getWarnings) { + items = getWarnings(items); + } + setItems(items); + }, [resource, resourceId, getWarnings]); useEffect(() => { getDescription(resource).then((r) => { @@ -152,91 +159,90 @@ export const Permissions = ({ const titleTeam = t('access-control.permissions.team', 'Team'); return ( - <div> - {canSetPermissions && ( - <> - {resource === 'folders' && ( - <> - <Trans i18nKey="access-control.permissions.permissions-change-warning"> - This will change permissions for this folder and all its descendants. In total, this will affect: - </Trans> - <DescendantCount - selectedItems={{ - folder: { [resourceId]: true }, - dashboard: {}, - panel: {}, - $all: false, - }} - /> - <Space v={2} /> - </> - )} - <Button - className={styles.addPermissionButton} - variant={'primary'} - key="add-permission" - onClick={() => setIsAdding(true)} - > - {buttonLabel} - </Button> - <SlideDown in={isAdding}> - <AddPermission - title={addPermissionTitle} - onAdd={onAdd} - permissions={desc.permissions} - assignments={desc.assignments} - onCancel={() => setIsAdding(false)} + <> + <div> + {canSetPermissions && resource === 'folders' && ( + <> + <Trans i18nKey="access-control.permissions.permissions-change-warning"> + This will change permissions for this folder and all its descendants. In total, this will affect: + </Trans> + <DescendantCount + selectedItems={{ + folder: { [resourceId]: true }, + dashboard: {}, + panel: {}, + $all: false, + }} /> - </SlideDown> - </> - )} - {items.length === 0 && ( - <table className="filter-table gf-form-group"> - <tbody> - <tr> - <th>{emptyLabel}</th> - </tr> - </tbody> - </table> - )} - <PermissionList - title={titleRole} - items={builtInRoles} - compareKey={'builtInRole'} - permissionLevels={desc.permissions} - onChange={onChange} - onRemove={onRemove} - canSet={canSetPermissions} - /> - <PermissionList - title={titleUser} - items={users} - compareKey={'userLogin'} - permissionLevels={desc.permissions} - onChange={onChange} - onRemove={onRemove} - canSet={canSetPermissions} - /> - <PermissionList - title={titleServiceAccount} - items={serviceAccounts} - compareKey={'userLogin'} - permissionLevels={desc.permissions} - onChange={onChange} - onRemove={onRemove} - canSet={canSetPermissions} - /> - - <PermissionList - title={titleTeam} - items={teams} - compareKey={'team'} - permissionLevels={desc.permissions} - onChange={onChange} - onRemove={onRemove} - canSet={canSetPermissions} - /> - </div> + <Space v={2} /> + </> + )} + {items.length === 0 && ( + <Box> + <Text>{emptyLabel}</Text> + </Box> + )} + <PermissionList + title={titleRole} + items={builtInRoles} + compareKey={'builtInRole'} + permissionLevels={desc.permissions} + onChange={onChange} + onRemove={onRemove} + canSet={canSetPermissions} + /> + <PermissionList + title={titleUser} + items={users} + compareKey={'userLogin'} + permissionLevels={desc.permissions} + onChange={onChange} + onRemove={onRemove} + canSet={canSetPermissions} + /> + <PermissionList + title={titleServiceAccount} + items={serviceAccounts} + compareKey={'userLogin'} + permissionLevels={desc.permissions} + onChange={onChange} + onRemove={onRemove} + canSet={canSetPermissions} + /> + <PermissionList + title={titleTeam} + items={teams} + compareKey={'team'} + permissionLevels={desc.permissions} + onChange={onChange} + onRemove={onRemove} + canSet={canSetPermissions} + /> + {canSetPermissions && ( + <> + <Button + className={styles.addPermissionButton} + variant={'primary'} + key="add-permission" + onClick={() => setIsAdding(true)} + icon="plus" + > + {buttonLabel} + </Button> + <SlideDown in={isAdding}> + <AddPermission + title={addPermissionTitle} + onAdd={onAdd} + permissions={desc.permissions} + assignments={desc.assignments} + onCancel={() => setIsAdding(false)} + /> + </SlideDown> + </> + )} + </div> + {epilogue && epilogue(items)} + </> ); }; diff --git a/public/app/core/components/AccessControl/types.ts b/public/app/core/components/AccessControl/types.ts index e2de980100d70..80ceca019bc89 100644 --- a/public/app/core/components/AccessControl/types.ts +++ b/public/app/core/components/AccessControl/types.ts @@ -13,6 +13,7 @@ export type ResourcePermission = { builtInRole?: string; actions: string[]; permission: string; + warning?: string; }; export type SetPermission = { diff --git a/public/app/core/components/AppChrome/AppChrome.test.tsx b/public/app/core/components/AppChrome/AppChrome.test.tsx index a846714075cc9..870030ab187d3 100644 --- a/public/app/core/components/AppChrome/AppChrome.test.tsx +++ b/public/app/core/components/AppChrome/AppChrome.test.tsx @@ -1,11 +1,11 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { KBarProvider } from 'kbar'; import React, { ReactNode } from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; -import { DataFrame, DataFrameView, FieldType, NavModelItem } from '@grafana/data'; +import { DataFrame, DataFrameView, FieldType } from '@grafana/data'; import { config } from '@grafana/runtime'; import { HOME_NAV_ID } from 'app/core/reducers/navModel'; import { DashboardQueryResult, getGrafanaSearcher, QueryResponse } from 'app/features/search/service'; @@ -19,14 +19,6 @@ jest.mock('@grafana/runtime', () => ({ getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }), })); -const pageNav: NavModelItem = { - text: 'pageNav title', - children: [ - { text: 'pageNav child1', url: '1', active: true }, - { text: 'pageNav child2', url: '2' }, - ], -}; - const searchData: DataFrame = { fields: [ { name: 'kind', type: FieldType.string, config: {}, values: [] }, @@ -92,30 +84,6 @@ describe('AppChrome', () => { jest.clearAllMocks(); }); - it('should render section nav model based on navId', async () => { - setup(<Page navId="child1">Children</Page>); - expect(await screen.findByTestId('page-children')).toBeInTheDocument(); - - expect(screen.getByRole('tab', { name: 'Tab Section name' })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Tab Child1' })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Tab Child1' })).toBeInTheDocument(); - expect(screen.getAllByRole('tab').length).toBe(3); - }); - - it('should render section nav model based on navId and item page nav', async () => { - setup( - <Page navId="child1" pageNav={pageNav}> - Children - </Page> - ); - expect(await screen.findByTestId('page-children')).toBeInTheDocument(); - - expect(screen.getByRole('tab', { name: 'Tab Section name' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'pageNav title' })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Tab Child1' })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Tab pageNav child1' })).toBeInTheDocument(); - }); - it('should create a skip link to skip to main content', async () => { setup(<Page navId="child1">Children</Page>); expect(await screen.findByRole('link', { name: 'Skip to main content' })).toBeInTheDocument(); @@ -132,8 +100,10 @@ describe('AppChrome', () => { it('should not render a skip link if the page is chromeless', async () => { const { context } = setup(<Page navId="child1">Children</Page>); - context.chrome.update({ - chromeless: true, + act(() => { + context.chrome.update({ + chromeless: true, + }); }); waitFor(() => { expect(screen.queryByRole('link', { name: 'Skip to main content' })).not.toBeInTheDocument(); diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index b88f01d332665..dc7492db63061 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -1,8 +1,9 @@ import { css, cx } from '@emotion/css'; import classNames from 'classnames'; -import React, { PropsWithChildren } from 'react'; +import React, { PropsWithChildren, useEffect } from 'react'; -import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; +import { locationSearchToObject, locationService } from '@grafana/runtime'; import { useStyles2, LinkButton, useTheme2 } from '@grafana/ui'; import config from 'app/core/config'; import { useGrafana } from 'app/core/context/GrafanaContext'; @@ -13,10 +14,9 @@ import { KioskMode } from 'app/types'; import { AppChromeMenu } from './AppChromeMenu'; import { DOCKED_LOCAL_STORAGE_KEY, DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY } from './AppChromeService'; -import { MegaMenu as DockedMegaMenu } from './DockedMegaMenu/MegaMenu'; import { MegaMenu } from './MegaMenu/MegaMenu'; import { NavToolbar } from './NavToolbar/NavToolbar'; -import { SectionNav } from './SectionNav/SectionNav'; +import { ReturnToPrevious } from './ReturnToPrevious/ReturnToPrevious'; import { TopSearchBar } from './TopBar/TopSearchBar'; import { TOP_BAR_LEVEL_HEIGHT } from './types'; @@ -34,7 +34,7 @@ export function AppChrome({ children }: Props) { useMediaQueryChange({ breakpoint: dockedMenuBreakpoint, onChange: (e) => { - if (config.featureToggles.dockedMegaMenu && dockedMenuLocalStorageState) { + if (dockedMenuLocalStorageState) { chrome.setMegaMenuDocked(e.matches, false); chrome.setMegaMenuOpen( e.matches ? store.getBool(DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, state.megaMenuOpen) : false @@ -53,6 +53,26 @@ export function AppChrome({ children }: Props) { chrome.setMegaMenuOpen(!state.megaMenuOpen); }; + const { pathname, search } = locationService.getLocation(); + const url = pathname + search; + const shouldShowReturnToPrevious = + config.featureToggles.returnToPrevious && state.returnToPrevious && url !== state.returnToPrevious.href; + + // Clear returnToPrevious when the page is manually navigated to + useEffect(() => { + if (state.returnToPrevious && url === state.returnToPrevious.href) { + chrome.clearReturnToPrevious('auto_dismissed'); + } + // We only want to pay attention when the location changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chrome, url]); + + // Sync updates from kiosk mode query string back into app chrome + useEffect(() => { + const queryParams = locationSearchToObject(search); + chrome.setKioskModeFromUrl(queryParams.kiosk); + }, [chrome, search]); + // Chromeless routes are without topNav, mega menu, search & command palette // We check chromeless twice here instead of having a separate path so {children} // doesn't get re-mounted when chromeless goes from true to false. @@ -68,7 +88,7 @@ export function AppChrome({ children }: Props) { <LinkButton className={styles.skipLink} href="#pageContent"> Skip to main content </LinkButton> - <div className={cx(styles.topNav)}> + <header className={cx(styles.topNav)}> {!searchBarHidden && <TopSearchBar />} <NavToolbar searchBarHidden={searchBarHidden} @@ -79,41 +99,29 @@ export function AppChrome({ children }: Props) { onToggleMegaMenu={handleMegaMenu} onToggleKioskMode={chrome.onToggleKioskMode} /> - </div> + </header> </> )} - <main className={contentClass}> + <div className={contentClass}> <div className={styles.panes}> - {state.layout === PageLayoutType.Standard && state.sectionNav && !config.featureToggles.dockedMegaMenu && ( - <SectionNav model={state.sectionNav} /> + {!state.chromeless && state.megaMenuDocked && state.megaMenuOpen && ( + <MegaMenu className={styles.dockedMegaMenu} onClose={() => chrome.setMegaMenuOpen(false)} /> )} - {config.featureToggles.dockedMegaMenu && !state.chromeless && state.megaMenuDocked && state.megaMenuOpen && ( - <DockedMegaMenu className={styles.dockedMegaMenu} onClose={() => chrome.setMegaMenuOpen(false)} /> - )} - <div className={styles.pageContainer} id="pageContent"> + <main className={styles.pageContainer} id="pageContent"> {children} - </div> + </main> </div> - </main> - {!state.chromeless && !state.megaMenuDocked && ( - <> - {config.featureToggles.dockedMegaMenu ? ( - <AppChromeMenu /> - ) : ( - <MegaMenu searchBarHidden={searchBarHidden} onClose={() => chrome.setMegaMenuOpen(false)} /> - )} - </> - )} + </div> + {!state.chromeless && !state.megaMenuDocked && <AppChromeMenu />} {!state.chromeless && <CommandPalette />} + {shouldShowReturnToPrevious && state.returnToPrevious && ( + <ReturnToPrevious href={state.returnToPrevious.href} title={state.returnToPrevious.title} /> + )} </div> ); } const getStyles = (theme: GrafanaTheme2) => { - const shadow = theme.isDark - ? `0 0.6px 1.5px rgb(0 0 0), 0 2px 4px rgb(0 0 0 / 40%), 0 5px 10px rgb(0 0 0 / 23%)` - : '0 4px 8px rgb(0 0 0 / 4%)'; - return { content: css({ display: 'flex', @@ -131,7 +139,6 @@ const getStyles = (theme: GrafanaTheme2) => { dockedMegaMenu: css({ background: theme.colors.background.primary, borderRight: `1px solid ${theme.colors.border.weak}`, - borderTop: `1px solid ${theme.colors.border.weak}`, display: 'none', zIndex: theme.zIndex.navbarFixed, @@ -145,10 +152,8 @@ const getStyles = (theme: GrafanaTheme2) => { zIndex: theme.zIndex.navbarFixed, left: 0, right: 0, - boxShadow: config.featureToggles.dockedMegaMenu ? undefined : shadow, background: theme.colors.background.primary, flexDirection: 'column', - borderBottom: `1px solid ${theme.colors.border.weak}`, }), panes: css({ label: 'page-panes', @@ -168,6 +173,14 @@ const getStyles = (theme: GrafanaTheme2) => { minHeight: 0, minWidth: 0, overflow: 'auto', + '@media print': { + overflow: 'visible', + }, + '@page': { + margin: 0, + size: 'auto', + padding: 0, + }, }), skipLink: css({ position: 'absolute', diff --git a/public/app/core/components/AppChrome/AppChromeMenu.tsx b/public/app/core/components/AppChrome/AppChromeMenu.tsx index 838ccc7d8ff0a..7471a2416d292 100644 --- a/public/app/core/components/AppChrome/AppChromeMenu.tsx +++ b/public/app/core/components/AppChrome/AppChromeMenu.tsx @@ -10,7 +10,7 @@ import { useStyles2, useTheme2 } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { KioskMode } from 'app/types'; -import { MegaMenu, MENU_WIDTH } from './DockedMegaMenu/MegaMenu'; +import { MegaMenu, MENU_WIDTH } from './MegaMenu/MegaMenu'; import { TOGGLE_BUTTON_ID } from './NavToolbar/NavToolbar'; import { TOP_BAR_LEVEL_HEIGHT } from './types'; @@ -57,7 +57,7 @@ export function AppChromeMenu({}: Props) { classNames={animationStyles.overlay} timeout={{ enter: animationSpeed, exit: 0 }} > - <FocusScope contain autoFocus> + <FocusScope contain autoFocus restoreFocus> <MegaMenu className={styles.menu} onClose={onClose} ref={ref} {...overlayProps} {...dialogProps} /> </FocusScope> </CSSTransition> @@ -76,7 +76,7 @@ export function AppChromeMenu({}: Props) { } const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => { - const topPosition = (searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2) + 1; + const topPosition = searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2; return { backdrop: css({ diff --git a/public/app/core/components/AppChrome/AppChromeService.tsx b/public/app/core/components/AppChrome/AppChromeService.tsx index d8d577820840d..647e21bb638ad 100644 --- a/public/app/core/components/AppChrome/AppChromeService.tsx +++ b/public/app/core/components/AppChrome/AppChromeService.tsx @@ -11,6 +11,8 @@ import { KioskMode } from 'app/types'; import { RouteDescriptor } from '../../navigation/types'; +import { ReturnToPreviousProps } from './ReturnToPrevious/ReturnToPrevious'; + export interface AppChromeState { chromeless?: boolean; sectionNav: NavModel; @@ -21,6 +23,10 @@ export interface AppChromeState { megaMenuDocked: boolean; kioskMode: KioskMode | null; layout: PageLayoutType; + returnToPrevious?: { + title: ReturnToPreviousProps['title']; + href: ReturnToPreviousProps['href']; + }; } export const DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.docked'; @@ -32,13 +38,13 @@ export class AppChromeService { private routeChangeHandled = true; private megaMenuDocked = Boolean( - config.featureToggles.dockedMegaMenu && - store.getBool( - DOCKED_LOCAL_STORAGE_KEY, - Boolean(config.featureToggles.dockedMegaMenu && window.innerWidth >= config.theme2.breakpoints.values.xxl) - ) + window.innerWidth >= config.theme2.breakpoints.values.xl && + store.getBool(DOCKED_LOCAL_STORAGE_KEY, Boolean(window.innerWidth >= config.theme2.breakpoints.values.xxl)) ); + private sessionStorageData = window.sessionStorage.getItem('returnToPrevious'); + private returnToPreviousData = this.sessionStorageData ? JSON.parse(this.sessionStorageData) : undefined; + readonly state = new BehaviorSubject<AppChromeState>({ chromeless: true, // start out hidden to not flash it on pages without chrome sectionNav: { node: { text: t('nav.home.title', 'Home') }, main: { text: '' } }, @@ -47,6 +53,7 @@ export class AppChromeService { megaMenuDocked: this.megaMenuDocked, kioskMode: null, layout: PageLayoutType.Canvas, + returnToPrevious: this.returnToPreviousData, }); public setMatchedRoute(route: RouteDescriptor) { @@ -82,6 +89,38 @@ export class AppChromeService { } } + public setReturnToPrevious = (returnToPrevious: ReturnToPreviousProps) => { + const isReturnToPreviousEnabled = config.featureToggles.returnToPrevious; + if (!isReturnToPreviousEnabled) { + return; + } + const previousPage = this.state.getValue().returnToPrevious; + reportInteraction('grafana_return_to_previous_button_created', { + page: returnToPrevious.href, + previousPage: previousPage?.href, + }); + + this.update({ returnToPrevious }); + window.sessionStorage.setItem('returnToPrevious', JSON.stringify(returnToPrevious)); + }; + + public clearReturnToPrevious = (interactionAction: 'clicked' | 'dismissed' | 'auto_dismissed') => { + const isReturnToPreviousEnabled = config.featureToggles.returnToPrevious; + if (!isReturnToPreviousEnabled) { + return; + } + const existingRtp = this.state.getValue().returnToPrevious; + if (existingRtp) { + reportInteraction('grafana_return_to_previous_button_dismissed', { + action: interactionAction, + page: existingRtp.href, + }); + } + + this.update({ returnToPrevious: undefined }); + window.sessionStorage.removeItem('returnToPrevious'); + }; + private ignoreStateUpdate(newState: AppChromeState, current: AppChromeState) { if (isShallowEqual(newState, current)) { return true; @@ -109,14 +148,10 @@ export class AppChromeService { public setMegaMenuOpen = (newOpenState: boolean) => { const { megaMenuDocked } = this.state.getValue(); - if (config.featureToggles.dockedMegaMenu) { - if (megaMenuDocked) { - store.set(DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, newOpenState); - } - reportInteraction('grafana_mega_menu_open', { state: newOpenState }); - } else { - reportInteraction('grafana_toggle_menu_clicked', { action: newOpenState ? 'open' : 'close' }); + if (megaMenuDocked) { + store.set(DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, newOpenState); } + reportInteraction('grafana_mega_menu_open', { state: newOpenState }); this.update({ megaMenuOpen: newOpenState, }); @@ -156,13 +191,19 @@ export class AppChromeService { } public setKioskModeFromUrl(kiosk: UrlQueryValue) { + let newKioskMode: KioskMode | undefined; + switch (kiosk) { case 'tv': - this.update({ kioskMode: KioskMode.TV }); + newKioskMode = KioskMode.TV; break; case '1': case true: - this.update({ kioskMode: KioskMode.Full }); + newKioskMode = KioskMode.Full; + } + + if (newKioskMode && newKioskMode !== this.state.getValue().kioskMode) { + this.update({ kioskMode: newKioskMode }); } } diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.test.tsx b/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.test.tsx deleted file mode 100644 index 43f9bc20180b0..0000000000000 --- a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.test.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; -import { Router } from 'react-router-dom'; -import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; - -import { NavModelItem } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { locationService } from '@grafana/runtime'; - -import { TestProvider } from '../../../../../test/helpers/TestProvider'; - -import { MegaMenu } from './MegaMenu'; - -const setup = () => { - const navBarTree: NavModelItem[] = [ - { - text: 'Section name', - id: 'section', - url: 'section', - children: [ - { - text: 'Child1', - id: 'child1', - url: 'section/child1', - children: [{ text: 'Grandchild1', id: 'grandchild1', url: 'section/child1/grandchild1' }], - }, - { text: 'Child2', id: 'child2', url: 'section/child2' }, - ], - }, - { - text: 'Profile', - id: 'profile', - url: 'profile', - }, - ]; - - const grafanaContext = getGrafanaContextMock(); - grafanaContext.chrome.setMegaMenuOpen(true); - - return render( - <TestProvider storeState={{ navBarTree }} grafanaContext={grafanaContext}> - <Router history={locationService.getHistory()}> - <MegaMenu onClose={() => {}} /> - </Router> - </TestProvider> - ); -}; - -describe('MegaMenu', () => { - afterEach(() => { - window.localStorage.clear(); - }); - it('should render component', async () => { - setup(); - - expect(await screen.findByTestId(selectors.components.NavMenu.Menu)).toBeInTheDocument(); - expect(await screen.findByRole('link', { name: 'Section name' })).toBeInTheDocument(); - }); - - it('should render children', async () => { - setup(); - await userEvent.click(await screen.findByRole('button', { name: 'Expand section Section name' })); - expect(await screen.findByRole('link', { name: 'Child1' })).toBeInTheDocument(); - expect(await screen.findByRole('link', { name: 'Child2' })).toBeInTheDocument(); - }); - - it('should render grandchildren', async () => { - setup(); - await userEvent.click(await screen.findByRole('button', { name: 'Expand section Section name' })); - expect(await screen.findByRole('link', { name: 'Child1' })).toBeInTheDocument(); - await userEvent.click(await screen.findByRole('button', { name: 'Expand section Child1' })); - expect(await screen.findByRole('link', { name: 'Grandchild1' })).toBeInTheDocument(); - expect(await screen.findByRole('link', { name: 'Child2' })).toBeInTheDocument(); - }); - - it('should filter out profile', async () => { - setup(); - - expect(screen.queryByLabelText('Profile')).not.toBeInTheDocument(); - }); -}); diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx b/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx deleted file mode 100644 index 7006f9cacd059..0000000000000 --- a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { css } from '@emotion/css'; -import { DOMAttributes } from '@react-types/shared'; -import React, { forwardRef } from 'react'; -import { useLocation } from 'react-router-dom'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { CustomScrollbar, Icon, IconButton, useStyles2, Stack } from '@grafana/ui'; -import { useGrafana } from 'app/core/context/GrafanaContext'; -import { t } from 'app/core/internationalization'; -import { useSelector } from 'app/types'; - -import { MegaMenuItem } from './MegaMenuItem'; -import { enrichWithInteractionTracking, getActiveItem } from './utils'; - -export const MENU_WIDTH = '300px'; - -export interface Props extends DOMAttributes { - onClose: () => void; -} - -export const MegaMenu = React.memo( - forwardRef<HTMLDivElement, Props>(({ onClose, ...restProps }, ref) => { - const navTree = useSelector((state) => state.navBarTree); - const styles = useStyles2(getStyles); - const location = useLocation(); - const { chrome } = useGrafana(); - const state = chrome.useState(); - - // Remove profile + help from tree - const navItems = navTree - .filter((item) => item.id !== 'profile' && item.id !== 'help') - .map((item) => enrichWithInteractionTracking(item, state.megaMenuDocked)); - - const activeItem = getActiveItem(navItems, location.pathname); - - const handleDockedMenu = () => { - chrome.setMegaMenuDocked(!state.megaMenuDocked); - if (state.megaMenuDocked) { - chrome.setMegaMenuOpen(false); - } - - // refocus on undock/menu open button when changing state - setTimeout(() => { - document.getElementById(state.megaMenuDocked ? 'mega-menu-toggle' : 'dock-menu-button')?.focus(); - }); - }; - - return ( - <div data-testid={selectors.components.NavMenu.Menu} ref={ref} {...restProps}> - <div className={styles.mobileHeader}> - <Icon name="bars" size="xl" /> - <IconButton - tooltip={t('navigation.megamenu.close', 'Close menu')} - name="times" - onClick={onClose} - size="xl" - variant="secondary" - /> - </div> - <nav className={styles.content}> - <CustomScrollbar showScrollIndicators hideHorizontalTrack> - <ul className={styles.itemList}> - {navItems.map((link, index) => ( - <Stack key={link.text} direction={index === 0 ? 'row-reverse' : 'row'} alignItems="center"> - {index === 0 && ( - <IconButton - id="dock-menu-button" - className={styles.dockMenuButton} - tooltip={ - state.megaMenuDocked - ? t('navigation.megamenu.undock', 'Undock menu') - : t('navigation.megamenu.dock', 'Dock menu') - } - name="web-section-alt" - onClick={handleDockedMenu} - variant="secondary" - /> - )} - <MegaMenuItem - link={link} - onClick={state.megaMenuDocked ? undefined : onClose} - activeItem={activeItem} - /> - </Stack> - ))} - </ul> - </CustomScrollbar> - </nav> - </div> - ); - }) -); - -MegaMenu.displayName = 'MegaMenu'; - -const getStyles = (theme: GrafanaTheme2) => ({ - content: css({ - display: 'flex', - flexDirection: 'column', - height: '100%', - minHeight: 0, - position: 'relative', - }), - mobileHeader: css({ - display: 'flex', - justifyContent: 'space-between', - padding: theme.spacing(1, 1, 1, 2), - borderBottom: `1px solid ${theme.colors.border.weak}`, - - [theme.breakpoints.up('md')]: { - display: 'none', - }, - }), - itemList: css({ - boxSizing: 'border-box', - display: 'flex', - flexDirection: 'column', - listStyleType: 'none', - padding: theme.spacing(1), - [theme.breakpoints.up('md')]: { - width: MENU_WIDTH, - }, - }), - dockMenuButton: css({ - display: 'none', - - [theme.breakpoints.up('xl')]: { - display: 'inline-flex', - }, - }), -}); diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/utils.test.ts b/public/app/core/components/AppChrome/DockedMegaMenu/utils.test.ts deleted file mode 100644 index eacea3cfdf914..0000000000000 --- a/public/app/core/components/AppChrome/DockedMegaMenu/utils.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { GrafanaConfig, locationUtil, NavModelItem } from '@grafana/data'; -import { ContextSrv, setContextSrv } from 'app/core/services/context_srv'; - -import { enrichHelpItem, getActiveItem, isMatchOrChildMatch } from './utils'; - -jest.mock('../../../app_events', () => ({ - publish: jest.fn(), -})); - -describe('enrichConfigItems', () => { - let mockHelpNode: NavModelItem; - - beforeEach(() => { - mockHelpNode = { - id: 'help', - text: 'Help', - }; - }); - - it('enhances the help node with extra child links', () => { - const contextSrv = new ContextSrv(); - setContextSrv(contextSrv); - const helpNode = enrichHelpItem(mockHelpNode); - expect(helpNode!.children).toContainEqual( - expect.objectContaining({ - text: 'Documentation', - }) - ); - expect(helpNode!.children).toContainEqual( - expect.objectContaining({ - text: 'Support', - }) - ); - expect(helpNode!.children).toContainEqual( - expect.objectContaining({ - text: 'Community', - }) - ); - expect(helpNode!.children).toContainEqual( - expect.objectContaining({ - text: 'Keyboard shortcuts', - }) - ); - }); -}); - -describe('isMatchOrChildMatch', () => { - const mockChild: NavModelItem = { - text: 'Child', - url: '/dashboards/child', - }; - const mockItemToCheck: NavModelItem = { - text: 'Dashboards', - url: '/dashboards', - children: [mockChild], - }; - - it('returns true if the itemToCheck is an exact match with the searchItem', () => { - const searchItem = mockItemToCheck; - expect(isMatchOrChildMatch(mockItemToCheck, searchItem)).toBe(true); - }); - - it('returns true if the itemToCheck has a child that matches the searchItem', () => { - const searchItem = mockChild; - expect(isMatchOrChildMatch(mockItemToCheck, searchItem)).toBe(true); - }); - - it('returns false otherwise', () => { - const searchItem: NavModelItem = { - text: 'No match', - url: '/noMatch', - }; - expect(isMatchOrChildMatch(mockItemToCheck, searchItem)).toBe(false); - }); -}); - -describe('getActiveItem', () => { - const mockNavTree: NavModelItem[] = [ - { - text: 'Item', - url: '/item', - }, - { - text: 'Item with query param', - url: '/itemWithQueryParam?foo=bar', - }, - { - text: 'Item after subpath', - url: '/subUrl/itemAfterSubpath', - }, - { - text: 'Item with children', - url: '/itemWithChildren', - children: [ - { - text: 'Child', - url: '/child', - }, - ], - }, - { - text: 'Alerting item', - url: '/alerting/list', - }, - { - text: 'Base', - url: '/', - }, - { - text: 'Starred', - url: '/dashboards?starred', - id: 'starred', - }, - { - text: 'Dashboards', - url: '/dashboards', - }, - { - text: 'More specific dashboard', - url: '/d/moreSpecificDashboard', - }, - ]; - beforeEach(() => { - locationUtil.initialize({ - config: { appSubUrl: '/subUrl' } as GrafanaConfig, - getVariablesUrlParams: () => ({}), - getTimeRangeForUrl: () => ({ from: 'now-7d', to: 'now' }), - }); - }); - - it('returns an exact match at the top level', () => { - const mockPathName = '/item'; - expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ - text: 'Item', - url: '/item', - }); - }); - - it('returns an exact match ignoring root subpath', () => { - const mockPathName = '/itemAfterSubpath'; - expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ - text: 'Item after subpath', - url: '/subUrl/itemAfterSubpath', - }); - }); - - it('returns an exact match ignoring query params', () => { - const mockPathName = '/itemWithQueryParam?bar=baz'; - expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ - text: 'Item with query param', - url: '/itemWithQueryParam?foo=bar', - }); - }); - - it('returns an exact child match', () => { - const mockPathName = '/child'; - expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ - text: 'Child', - url: '/child', - }); - }); - - it('returns the alerting link if the pathname is an alert notification', () => { - const mockPathName = '/alerting/notification/foo'; - expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ - text: 'Alerting item', - url: '/alerting/list', - }); - }); - - it('returns the dashboards route link if the pathname starts with /d/', () => { - const mockPathName = '/d/foo'; - expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ - text: 'Dashboards', - url: '/dashboards', - }); - }); - - it('returns a more specific link if one exists', () => { - const mockPathName = '/d/moreSpecificDashboard'; - expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ - text: 'More specific dashboard', - url: '/d/moreSpecificDashboard', - }); - }); -}); diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/utils.ts b/public/app/core/components/AppChrome/DockedMegaMenu/utils.ts deleted file mode 100644 index 9486c3ee5cecb..0000000000000 --- a/public/app/core/components/AppChrome/DockedMegaMenu/utils.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { locationUtil, NavModelItem } from '@grafana/data'; -import { config, reportInteraction } from '@grafana/runtime'; -import { t } from 'app/core/internationalization'; - -import { ShowModalReactEvent } from '../../../../types/events'; -import appEvents from '../../../app_events'; -import { getFooterLinks } from '../../Footer/Footer'; -import { HelpModal } from '../../help/HelpModal'; - -export const enrichHelpItem = (helpItem: NavModelItem) => { - let menuItems = helpItem.children || []; - - if (helpItem.id === 'help') { - const onOpenShortcuts = () => { - appEvents.publish(new ShowModalReactEvent({ component: HelpModal })); - }; - helpItem.children = [ - ...menuItems, - ...getFooterLinks(), - ...getEditionAndUpdateLinks(), - { - id: 'keyboard-shortcuts', - text: t('nav.help/keyboard-shortcuts', 'Keyboard shortcuts'), - icon: 'keyboard', - onClick: onOpenShortcuts, - }, - ]; - } - return helpItem; -}; - -export const enrichWithInteractionTracking = (item: NavModelItem, megaMenuDockedState: boolean) => { - // creating a new object here to not mutate the original item object - const newItem = { ...item }; - const onClick = newItem.onClick; - newItem.onClick = () => { - reportInteraction('grafana_navigation_item_clicked', { - path: newItem.url ?? newItem.id, - menuIsDocked: megaMenuDockedState, - }); - onClick?.(); - }; - if (newItem.children) { - newItem.children = newItem.children.map((item) => enrichWithInteractionTracking(item, megaMenuDockedState)); - } - return newItem; -}; - -export const isMatchOrChildMatch = (itemToCheck: NavModelItem, searchItem?: NavModelItem) => { - return Boolean(itemToCheck === searchItem || hasChildMatch(itemToCheck, searchItem)); -}; - -export const hasChildMatch = (itemToCheck: NavModelItem, searchItem?: NavModelItem): boolean => { - return Boolean( - itemToCheck.children?.some((child) => { - if (child === searchItem) { - return true; - } else { - return hasChildMatch(child, searchItem); - } - }) - ); -}; - -const stripQueryParams = (url?: string) => { - return url?.split('?')[0] ?? ''; -}; - -const isBetterMatch = (newMatch: NavModelItem, currentMatch?: NavModelItem) => { - const currentMatchUrl = stripQueryParams(currentMatch?.url); - const newMatchUrl = stripQueryParams(newMatch.url); - return newMatchUrl && newMatchUrl.length > currentMatchUrl?.length; -}; - -export const getActiveItem = ( - navTree: NavModelItem[], - pathname: string, - currentBestMatch?: NavModelItem -): NavModelItem | undefined => { - const dashboardLinkMatch = '/dashboards'; - - for (const link of navTree) { - const linkWithoutParams = stripQueryParams(link.url); - const linkPathname = locationUtil.stripBaseFromUrl(linkWithoutParams); - if (linkPathname && link.id !== 'starred') { - if (linkPathname === pathname) { - // exact match - currentBestMatch = link; - break; - } else if (linkPathname !== '/' && pathname.startsWith(linkPathname)) { - // partial match - if (isBetterMatch(link, currentBestMatch)) { - currentBestMatch = link; - } - } else if (linkPathname === '/alerting/list' && pathname.startsWith('/alerting/notification/')) { - // alert channel match - // TODO refactor routes such that we don't need this custom logic - currentBestMatch = link; - break; - } else if (linkPathname === dashboardLinkMatch && pathname.startsWith('/d/')) { - // dashboard match - // TODO refactor routes such that we don't need this custom logic - if (isBetterMatch(link, currentBestMatch)) { - currentBestMatch = link; - } - } - } - if (link.children) { - currentBestMatch = getActiveItem(link.children, pathname, currentBestMatch); - } - if (stripQueryParams(currentBestMatch?.url) === pathname) { - return currentBestMatch; - } - } - return currentBestMatch; -}; - -export function getEditionAndUpdateLinks(): NavModelItem[] { - const { buildInfo, licenseInfo } = config; - const stateInfo = licenseInfo.stateInfo ? ` (${licenseInfo.stateInfo})` : ''; - const links: NavModelItem[] = []; - - links.push({ - target: '_blank', - id: 'version', - text: `${buildInfo.edition}${stateInfo}`, - url: licenseInfo.licenseUrl, - icon: 'external-link-alt', - }); - - if (buildInfo.hasUpdate) { - links.push({ - target: '_blank', - id: 'updateVersion', - text: `New version available!`, - icon: 'download-alt', - url: 'https://grafana.com/grafana/download?utm_source=grafana_footer', - }); - } - - return links; -} diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/FeatureHighlight.tsx b/public/app/core/components/AppChrome/MegaMenu/FeatureHighlight.tsx similarity index 100% rename from public/app/core/components/AppChrome/DockedMegaMenu/FeatureHighlight.tsx rename to public/app/core/components/AppChrome/MegaMenu/FeatureHighlight.tsx diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.test.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.test.tsx index 5267d50c0f4c7..43f9bc20180b0 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.test.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { Router } from 'react-router-dom'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; @@ -18,7 +19,12 @@ const setup = () => { id: 'section', url: 'section', children: [ - { text: 'Child1', id: 'child1', url: 'section/child1' }, + { + text: 'Child1', + id: 'child1', + url: 'section/child1', + children: [{ text: 'Grandchild1', id: 'grandchild1', url: 'section/child1/grandchild1' }], + }, { text: 'Child2', id: 'child2', url: 'section/child2' }, ], }, @@ -42,6 +48,9 @@ const setup = () => { }; describe('MegaMenu', () => { + afterEach(() => { + window.localStorage.clear(); + }); it('should render component', async () => { setup(); @@ -49,6 +58,22 @@ describe('MegaMenu', () => { expect(await screen.findByRole('link', { name: 'Section name' })).toBeInTheDocument(); }); + it('should render children', async () => { + setup(); + await userEvent.click(await screen.findByRole('button', { name: 'Expand section Section name' })); + expect(await screen.findByRole('link', { name: 'Child1' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'Child2' })).toBeInTheDocument(); + }); + + it('should render grandchildren', async () => { + setup(); + await userEvent.click(await screen.findByRole('button', { name: 'Expand section Section name' })); + expect(await screen.findByRole('link', { name: 'Child1' })).toBeInTheDocument(); + await userEvent.click(await screen.findByRole('button', { name: 'Expand section Child1' })); + expect(await screen.findByRole('link', { name: 'Grandchild1' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'Child2' })).toBeInTheDocument(); + }); + it('should filter out profile', async () => { setup(); diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx index 72b90257473f0..4f55f8409904d 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx @@ -1,50 +1,132 @@ import { css } from '@emotion/css'; -import { cloneDeep } from 'lodash'; -import React from 'react'; +import { DOMAttributes } from '@react-types/shared'; +import React, { forwardRef } from 'react'; import { useLocation } from 'react-router-dom'; import { GrafanaTheme2 } from '@grafana/data'; -import { useTheme2 } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; +import { CustomScrollbar, Icon, IconButton, useStyles2, Stack } from '@grafana/ui'; +import { useGrafana } from 'app/core/context/GrafanaContext'; +import { t } from 'app/core/internationalization'; import { useSelector } from 'app/types'; -import { NavBarMenu } from './NavBarMenu'; +import { MegaMenuItem } from './MegaMenuItem'; import { enrichWithInteractionTracking, getActiveItem } from './utils'; -export interface Props { +export const MENU_WIDTH = '300px'; + +export interface Props extends DOMAttributes { onClose: () => void; - searchBarHidden?: boolean; } -export const MegaMenu = React.memo<Props>(({ onClose, searchBarHidden }) => { - const navBarTree = useSelector((state) => state.navBarTree); - const theme = useTheme2(); - const styles = getStyles(theme); - const location = useLocation(); +export const MegaMenu = React.memo( + forwardRef<HTMLDivElement, Props>(({ onClose, ...restProps }, ref) => { + const navTree = useSelector((state) => state.navBarTree); + const styles = useStyles2(getStyles); + const location = useLocation(); + const { chrome } = useGrafana(); + const state = chrome.useState(); - const navTree = cloneDeep(navBarTree); + // Remove profile + help from tree + const navItems = navTree + .filter((item) => item.id !== 'profile' && item.id !== 'help') + .map((item) => enrichWithInteractionTracking(item, state.megaMenuDocked)); - // Remove profile + help from tree - const navItems = navTree - .filter((item) => item.id !== 'profile' && item.id !== 'help') - .map((item) => enrichWithInteractionTracking(item, true)); + const activeItem = getActiveItem(navItems, location.pathname); - const activeItem = getActiveItem(navItems, location.pathname); + const handleDockedMenu = () => { + chrome.setMegaMenuDocked(!state.megaMenuDocked); + if (state.megaMenuDocked) { + chrome.setMegaMenuOpen(false); + } - return ( - <div className={styles.menuWrapper}> - <NavBarMenu activeItem={activeItem} navItems={navItems} onClose={onClose} searchBarHidden={searchBarHidden} /> - </div> - ); -}); + // refocus on undock/menu open button when changing state + setTimeout(() => { + document.getElementById(state.megaMenuDocked ? 'mega-menu-toggle' : 'dock-menu-button')?.focus(); + }); + }; + + return ( + <div data-testid={selectors.components.NavMenu.Menu} ref={ref} {...restProps}> + <div className={styles.mobileHeader}> + <Icon name="bars" size="xl" /> + <IconButton + tooltip={t('navigation.megamenu.close', 'Close menu')} + name="times" + onClick={onClose} + size="xl" + variant="secondary" + /> + </div> + <nav className={styles.content}> + <CustomScrollbar showScrollIndicators hideHorizontalTrack> + <ul className={styles.itemList} aria-label={t('navigation.megamenu.list-label', 'Navigation')}> + {navItems.map((link, index) => ( + <Stack key={link.text} direction={index === 0 ? 'row-reverse' : 'row'} alignItems="center"> + {index === 0 && ( + <IconButton + id="dock-menu-button" + className={styles.dockMenuButton} + tooltip={ + state.megaMenuDocked + ? t('navigation.megamenu.undock', 'Undock menu') + : t('navigation.megamenu.dock', 'Dock menu') + } + name="web-section-alt" + onClick={handleDockedMenu} + variant="secondary" + /> + )} + <MegaMenuItem + link={link} + onClick={state.megaMenuDocked ? undefined : onClose} + activeItem={activeItem} + /> + </Stack> + ))} + </ul> + </CustomScrollbar> + </nav> + </div> + ); + }) +); MegaMenu.displayName = 'MegaMenu'; const getStyles = (theme: GrafanaTheme2) => ({ - menuWrapper: css({ - position: 'fixed', - display: 'grid', - gridAutoFlow: 'column', + content: css({ + display: 'flex', + flexDirection: 'column', height: '100%', - zIndex: theme.zIndex.sidemenu, + minHeight: 0, + position: 'relative', + }), + mobileHeader: css({ + display: 'flex', + justifyContent: 'space-between', + padding: theme.spacing(1, 1, 1, 2), + borderBottom: `1px solid ${theme.colors.border.weak}`, + + [theme.breakpoints.up('md')]: { + display: 'none', + }, + }), + itemList: css({ + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + listStyleType: 'none', + padding: theme.spacing(1, 1, 2, 1), + [theme.breakpoints.up('md')]: { + width: MENU_WIDTH, + }, + }), + dockMenuButton: css({ + display: 'none', + + [theme.breakpoints.up('xl')]: { + display: 'inline-flex', + }, }), }); diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenuItem.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx similarity index 96% rename from public/app/core/components/AppChrome/DockedMegaMenu/MegaMenuItem.tsx rename to public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx index 59c5ba41aa45d..528c0c9a04f88 100644 --- a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenuItem.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx @@ -48,12 +48,12 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClick }: Props) { // scroll active element into center if it's offscreen useEffect(() => { - if (menuIsDocked && isActive && item.current && isElementOffscreen(item.current)) { + if (isActive && item.current && isElementOffscreen(item.current)) { item.current.scrollIntoView({ block: 'center', }); } - }, [isActive, menuIsDocked]); + }, [isActive]); if (!link.url) { return null; @@ -121,7 +121,9 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClick }: Props) { /> )) ) : ( - <div className={styles.emptyMessage}>{link.emptyMessage}</div> + <div className={styles.emptyMessage} aria-live="polite"> + {link.emptyMessage} + </div> )} </ul> )} diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenuItemText.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx similarity index 100% rename from public/app/core/components/AppChrome/DockedMegaMenu/MegaMenuItemText.tsx rename to public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx diff --git a/public/app/core/components/AppChrome/MegaMenu/NavBarItemIcon.tsx b/public/app/core/components/AppChrome/MegaMenu/NavBarItemIcon.tsx deleted file mode 100644 index b259eb97660b1..0000000000000 --- a/public/app/core/components/AppChrome/MegaMenu/NavBarItemIcon.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { css, cx } from '@emotion/css'; -import React from 'react'; - -import { GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { Icon, toIconName, useTheme2 } from '@grafana/ui'; - -import { Branding } from '../../Branding/Branding'; - -interface NavBarItemIconProps { - link: NavModelItem; -} - -export function NavBarItemIcon({ link }: NavBarItemIconProps) { - const theme = useTheme2(); - const styles = getStyles(theme); - - if (link.icon === 'grafana') { - return <Branding.MenuLogo className={styles.img} />; - } else if (link.icon) { - const iconName = toIconName(link.icon); - return <Icon name={iconName ?? 'link'} size="xl" />; - } else { - // consumer of NavBarItemIcon gives enclosing element an appropriate label - return <img className={cx(styles.img, link.roundIcon && styles.round)} src={link.img} alt="" />; - } -} - -function getStyles(theme: GrafanaTheme2) { - return { - img: css({ - height: theme.spacing(3), - width: theme.spacing(3), - }), - round: css({ - borderRadius: theme.shape.radius.circle, - }), - }; -} diff --git a/public/app/core/components/AppChrome/MegaMenu/NavBarMenu.tsx b/public/app/core/components/AppChrome/MegaMenu/NavBarMenu.tsx deleted file mode 100644 index 6fc40275cef71..0000000000000 --- a/public/app/core/components/AppChrome/MegaMenu/NavBarMenu.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { css } from '@emotion/css'; -import { useDialog } from '@react-aria/dialog'; -import { FocusScope } from '@react-aria/focus'; -import { OverlayContainer, useOverlay } from '@react-aria/overlays'; -import React, { useEffect, useRef, useState } from 'react'; -import CSSTransition from 'react-transition-group/CSSTransition'; - -import { GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { CustomScrollbar, Icon, IconButton, useTheme2 } from '@grafana/ui'; -import { useGrafana } from 'app/core/context/GrafanaContext'; - -import { TOP_BAR_LEVEL_HEIGHT } from '../types'; - -import { NavBarMenuItemWrapper } from './NavBarMenuItemWrapper'; - -const MENU_WIDTH = '350px'; - -export interface Props { - activeItem?: NavModelItem; - navItems: NavModelItem[]; - searchBarHidden?: boolean; - onClose: () => void; -} - -export function NavBarMenu({ activeItem, navItems, searchBarHidden, onClose }: Props) { - const theme = useTheme2(); - const styles = getStyles(theme, searchBarHidden); - const animationSpeed = theme.transitions.duration.shortest; - const animStyles = getAnimStyles(theme, animationSpeed); - const { chrome } = useGrafana(); - const state = chrome.useState(); - const ref = useRef(null); - const backdropRef = useRef(null); - const { dialogProps } = useDialog({}, ref); - const [isOpen, setIsOpen] = useState(false); - - const onMenuClose = () => setIsOpen(false); - - const { overlayProps, underlayProps } = useOverlay( - { - isDismissable: true, - isOpen: true, - onClose: onMenuClose, - }, - ref - ); - - useEffect(() => { - if (state.megaMenuOpen) { - setIsOpen(true); - } - }, [state.megaMenuOpen]); - - return ( - <OverlayContainer> - <CSSTransition - nodeRef={ref} - in={isOpen} - unmountOnExit={true} - classNames={animStyles.overlay} - timeout={{ enter: animationSpeed, exit: 0 }} - onExited={onClose} - > - <FocusScope contain autoFocus> - <div - data-testid={selectors.components.NavMenu.Menu} - ref={ref} - {...overlayProps} - {...dialogProps} - className={styles.container} - > - <div className={styles.mobileHeader}> - <Icon name="bars" size="xl" /> - <IconButton - aria-label="Close navigation menu" - tooltip="Close menu" - name="times" - onClick={onMenuClose} - size="xl" - variant="secondary" - /> - </div> - <nav className={styles.content}> - <CustomScrollbar showScrollIndicators hideHorizontalTrack> - <ul className={styles.itemList}> - {navItems.map((link) => ( - <NavBarMenuItemWrapper link={link} onClose={onMenuClose} activeItem={activeItem} key={link.text} /> - ))} - </ul> - </CustomScrollbar> - </nav> - </div> - </FocusScope> - </CSSTransition> - <CSSTransition - nodeRef={backdropRef} - in={isOpen} - unmountOnExit={true} - classNames={animStyles.backdrop} - timeout={{ enter: animationSpeed, exit: 0 }} - > - <div ref={backdropRef} className={styles.backdrop} {...underlayProps} /> - </CSSTransition> - </OverlayContainer> - ); -} - -NavBarMenu.displayName = 'NavBarMenu'; - -const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => { - const topPosition = (searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2) + 1; - - return { - backdrop: css({ - backdropFilter: 'blur(1px)', - backgroundColor: theme.components.overlay.background, - bottom: 0, - left: 0, - position: 'fixed', - right: 0, - top: searchBarHidden ? 0 : TOP_BAR_LEVEL_HEIGHT, - zIndex: theme.zIndex.modalBackdrop, - - [theme.breakpoints.up('md')]: { - top: topPosition, - }, - }), - container: css({ - display: 'flex', - bottom: 0, - flexDirection: 'column', - left: 0, - marginRight: theme.spacing(1.5), - right: 0, - // Needs to below navbar should we change the navbarFixed? add add a new level? - zIndex: theme.zIndex.modal, - position: 'fixed', - top: searchBarHidden ? 0 : TOP_BAR_LEVEL_HEIGHT, - backgroundColor: theme.colors.background.primary, - boxSizing: 'content-box', - flex: '1 1 0', - - [theme.breakpoints.up('md')]: { - borderRight: `1px solid ${theme.colors.border.weak}`, - right: 'unset', - top: topPosition, - }, - }), - content: css({ - display: 'flex', - flexDirection: 'column', - flexGrow: 1, - minHeight: 0, - }), - mobileHeader: css({ - display: 'flex', - justifyContent: 'space-between', - padding: theme.spacing(1, 1, 1, 2), - borderBottom: `1px solid ${theme.colors.border.weak}`, - - [theme.breakpoints.up('md')]: { - display: 'none', - }, - }), - itemList: css({ - display: 'grid', - gridAutoRows: `minmax(${theme.spacing(6)}, auto)`, - gridTemplateColumns: `minmax(${MENU_WIDTH}, auto)`, - minWidth: MENU_WIDTH, - }), - }; -}; - -const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => { - const commonTransition = { - transitionDuration: `${animationDuration}ms`, - transitionTimingFunction: theme.transitions.easing.easeInOut, - [theme.breakpoints.down('md')]: { - overflow: 'hidden', - }, - }; - - const overlayTransition = { - ...commonTransition, - transitionProperty: 'box-shadow, width', - // this is needed to prevent a horizontal scrollbar during the animation on firefox - '.scrollbar-view': { - overflow: 'hidden !important', - }, - }; - - const backdropTransition = { - ...commonTransition, - transitionProperty: 'opacity', - }; - - const overlayOpen = { - width: '100%', - [theme.breakpoints.up('md')]: { - boxShadow: theme.shadows.z3, - width: MENU_WIDTH, - }, - }; - - const overlayClosed = { - boxShadow: 'none', - width: 0, - }; - - const backdropOpen = { - opacity: 1, - }; - - const backdropClosed = { - opacity: 0, - }; - - return { - backdrop: { - enter: css(backdropClosed), - enterActive: css(backdropTransition, backdropOpen), - enterDone: css(backdropOpen), - }, - overlay: { - enter: css(overlayClosed), - enterActive: css(overlayTransition, overlayOpen), - enterDone: css(overlayOpen), - }, - }; -}; diff --git a/public/app/core/components/AppChrome/MegaMenu/NavBarMenuItem.tsx b/public/app/core/components/AppChrome/MegaMenu/NavBarMenuItem.tsx deleted file mode 100644 index 4829d36701a6b..0000000000000 --- a/public/app/core/components/AppChrome/MegaMenu/NavBarMenuItem.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { css, cx } from '@emotion/css'; -import React from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { Icon, IconName, Link, useTheme2 } from '@grafana/ui'; - -export interface Props { - children: React.ReactNode; - icon?: IconName; - isActive?: boolean; - isChild?: boolean; - onClick?: () => void; - target?: HTMLAnchorElement['target']; - url?: string; -} - -export function NavBarMenuItem({ children, icon, isActive, isChild, onClick, target, url }: Props) { - const theme = useTheme2(); - const styles = getStyles(theme, isActive, isChild); - - const linkContent = ( - <div className={styles.linkContent}> - {icon && <Icon data-testid="dropdown-child-icon" name={icon} />} - - <div className={styles.linkText}>{children}</div> - - {target === '_blank' && ( - <Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} /> - )} - </div> - ); - - let element = ( - <button - data-testid={selectors.components.NavMenu.item} - className={cx(styles.button, styles.element)} - onClick={onClick} - > - {linkContent} - </button> - ); - - if (url) { - element = - !target && url.startsWith('/') ? ( - <Link - data-testid={selectors.components.NavMenu.item} - className={styles.element} - href={url} - target={target} - onClick={onClick} - > - {linkContent} - </Link> - ) : ( - <a - data-testid={selectors.components.NavMenu.item} - href={url} - target={target} - className={styles.element} - onClick={onClick} - > - {linkContent} - </a> - ); - } - - return <li className={styles.listItem}>{element}</li>; -} - -NavBarMenuItem.displayName = 'NavBarMenuItem'; - -const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], isChild: Props['isActive']) => ({ - button: css({ - backgroundColor: 'unset', - borderStyle: 'unset', - }), - linkContent: css({ - alignItems: 'center', - display: 'flex', - gap: '0.5rem', - height: '100%', - width: '100%', - }), - linkText: css({ - textOverflow: 'ellipsis', - overflow: 'hidden', - whiteSpace: 'nowrap', - }), - externalLinkIcon: css({ - color: theme.colors.text.secondary, - }), - element: css({ - alignItems: 'center', - boxSizing: 'border-box', - position: 'relative', - color: isActive ? theme.colors.text.primary : theme.colors.text.secondary, - padding: theme.spacing(1, 1, 1, isChild ? 5 : 0), - ...(isChild && { - borderRadius: theme.shape.radius.default, - }), - width: '100%', - '&:hover, &:focus-visible': { - ...(isChild && { - background: theme.colors.emphasize(theme.colors.background.primary, 0.03), - }), - textDecoration: 'underline', - color: theme.colors.text.primary, - }, - '&:focus-visible': { - boxShadow: 'none', - outline: `2px solid ${theme.colors.primary.main}`, - outlineOffset: '-2px', - transition: 'none', - }, - '&::before': { - display: isActive ? 'block' : 'none', - content: '" "', - height: theme.spacing(3), - position: 'absolute', - left: theme.spacing(1), - top: '50%', - transform: 'translateY(-50%)', - width: theme.spacing(0.5), - borderRadius: theme.shape.radius.default, - backgroundImage: theme.colors.gradients.brandVertical, - }, - }), - listItem: css({ - boxSizing: 'border-box', - position: 'relative', - display: 'flex', - width: '100%', - ...(isChild && { - padding: theme.spacing(0, 2), - }), - }), -}); diff --git a/public/app/core/components/AppChrome/MegaMenu/NavBarMenuItemWrapper.tsx b/public/app/core/components/AppChrome/MegaMenu/NavBarMenuItemWrapper.tsx deleted file mode 100644 index d64073c6f6c4d..0000000000000 --- a/public/app/core/components/AppChrome/MegaMenu/NavBarMenuItemWrapper.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { css } from '@emotion/css'; -import React from 'react'; - -import { GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { useStyles2 } from '@grafana/ui'; - -import { NavBarMenuItem } from './NavBarMenuItem'; -import { NavBarMenuSection } from './NavBarMenuSection'; -import { isMatchOrChildMatch } from './utils'; - -export function NavBarMenuItemWrapper({ - link, - activeItem, - onClose, -}: { - link: NavModelItem; - activeItem?: NavModelItem; - onClose: () => void; -}) { - const styles = useStyles2(getStyles); - - if (link.emptyMessage && !linkHasChildren(link)) { - return ( - <NavBarMenuSection onClose={onClose} link={link} activeItem={activeItem}> - <ul className={styles.children}> - <div className={styles.emptyMessage}>{link.emptyMessage}</div> - </ul> - </NavBarMenuSection> - ); - } - - return ( - <NavBarMenuSection onClose={onClose} link={link} activeItem={activeItem}> - {linkHasChildren(link) && ( - <ul className={styles.children}> - {link.children.map((childLink) => { - return ( - !childLink.isCreateAction && ( - <NavBarMenuItem - key={`${link.text}-${childLink.text}`} - isActive={isMatchOrChildMatch(childLink, activeItem)} - isChild - onClick={() => { - childLink.onClick?.(); - onClose(); - }} - target={childLink.target} - url={childLink.url} - > - {childLink.text} - </NavBarMenuItem> - ) - ); - })} - </ul> - )} - </NavBarMenuSection> - ); -} - -const getStyles = (theme: GrafanaTheme2) => ({ - children: css({ - display: 'flex', - flexDirection: 'column', - }), - flex: css({ - display: 'flex', - }), - itemWithoutMenu: css({ - position: 'relative', - placeItems: 'inherit', - justifyContent: 'start', - display: 'flex', - flexGrow: 1, - alignItems: 'center', - }), - fullWidth: css({ - height: '100%', - width: '100%', - }), - iconContainer: css({ - display: 'flex', - placeContent: 'center', - }), - itemWithoutMenuContent: css({ - display: 'grid', - gridAutoFlow: 'column', - gridTemplateColumns: `${theme.spacing(7)} auto`, - alignItems: 'center', - height: '100%', - }), - linkText: css({ - fontSize: theme.typography.pxToRem(14), - justifySelf: 'start', - }), - emptyMessage: css({ - color: theme.colors.text.secondary, - fontStyle: 'italic', - padding: theme.spacing(1, 1.5, 1, 7), - }), -}); - -function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: NavModelItem[] } { - return Boolean(link.children && link.children.length > 0); -} diff --git a/public/app/core/components/AppChrome/MegaMenu/NavBarMenuSection.tsx b/public/app/core/components/AppChrome/MegaMenu/NavBarMenuSection.tsx deleted file mode 100644 index 978181f6b0809..0000000000000 --- a/public/app/core/components/AppChrome/MegaMenu/NavBarMenuSection.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { css, cx } from '@emotion/css'; -import React from 'react'; -import { useLocalStorage } from 'react-use'; - -import { GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { Button, Icon, useStyles2 } from '@grafana/ui'; - -import { NavBarItemIcon } from './NavBarItemIcon'; -import { NavBarMenuItem } from './NavBarMenuItem'; -import { NavFeatureHighlight } from './NavFeatureHighlight'; -import { hasChildMatch } from './utils'; - -export function NavBarMenuSection({ - link, - activeItem, - children, - className, - onClose, -}: { - link: NavModelItem; - activeItem?: NavModelItem; - children: React.ReactNode; - className?: string; - onClose?: () => void; -}) { - const styles = useStyles2(getStyles); - const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment; - const isActive = link === activeItem; - const hasActiveChild = hasChildMatch(link, activeItem); - const [sectionExpanded, setSectionExpanded] = - useLocalStorage(`grafana.navigation.expanded[${link.text}]`, false) ?? Boolean(hasActiveChild); - - return ( - <> - <div className={cx(styles.collapsibleSectionWrapper, className)}> - <NavBarMenuItem - isActive={link === activeItem} - onClick={() => { - link.onClick?.(); - onClose?.(); - }} - target={link.target} - url={link.url} - > - <div - className={cx(styles.labelWrapper, { - [styles.isActive]: isActive, - [styles.hasActiveChild]: hasActiveChild, - })} - > - <FeatureHighlightWrapper> - <NavBarItemIcon link={link} /> - </FeatureHighlightWrapper> - {link.text} - </div> - </NavBarMenuItem> - {children && ( - <Button - aria-label={`${sectionExpanded ? 'Collapse' : 'Expand'} section ${link.text}`} - variant="secondary" - fill="text" - className={styles.collapseButton} - onClick={() => setSectionExpanded(!sectionExpanded)} - > - <Icon name={sectionExpanded ? 'angle-up' : 'angle-down'} size="xl" /> - </Button> - )} - </div> - {sectionExpanded && children} - </> - ); -} - -const getStyles = (theme: GrafanaTheme2) => ({ - collapsibleSectionWrapper: css({ - alignItems: 'center', - display: 'flex', - }), - collapseButton: css({ - color: theme.colors.text.disabled, - padding: theme.spacing(0, 0.5), - marginRight: theme.spacing(1), - }), - collapseWrapperActive: css({ - backgroundColor: theme.colors.action.disabledBackground, - }), - collapseContent: css({ - padding: 0, - }), - labelWrapper: css({ - display: 'grid', - fontSize: theme.typography.pxToRem(14), - gridAutoFlow: 'column', - gridTemplateColumns: `${theme.spacing(7)} auto`, - placeItems: 'center', - fontWeight: theme.typography.fontWeightMedium, - }), - isActive: css({ - color: theme.colors.text.primary, - - '&::before': { - display: 'block', - content: '" "', - height: theme.spacing(3), - position: 'absolute', - left: theme.spacing(1), - top: '50%', - transform: 'translateY(-50%)', - width: theme.spacing(0.5), - borderRadius: theme.shape.radius.default, - backgroundImage: theme.colors.gradients.brandVertical, - }, - }), - hasActiveChild: css({ - color: theme.colors.text.primary, - }), -}); diff --git a/public/app/core/components/AppChrome/MegaMenu/NavFeatureHighlight.tsx b/public/app/core/components/AppChrome/MegaMenu/NavFeatureHighlight.tsx deleted file mode 100644 index 00f7eda9013e2..0000000000000 --- a/public/app/core/components/AppChrome/MegaMenu/NavFeatureHighlight.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { css } from '@emotion/css'; -import React from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2 } from '@grafana/ui'; - -export interface Props { - children: JSX.Element; -} - -export const NavFeatureHighlight = ({ children }: Props): JSX.Element => { - const styles = useStyles2(getStyles); - return ( - <div> - {children} - <span className={styles.highlight} /> - </div> - ); -}; - -const getStyles = (theme: GrafanaTheme2) => { - return { - highlight: css({ - backgroundColor: theme.colors.success.main, - borderRadius: theme.shape.radius.circle, - width: '6px', - height: '6px', - display: 'inline-block', - position: 'absolute', - top: '50%', - transform: 'translateY(-50%)', - }), - }; -}; diff --git a/public/app/core/components/AppChrome/MegaMenu/utils.ts b/public/app/core/components/AppChrome/MegaMenu/utils.ts index 2f180c5e674d7..9486c3ee5cecb 100644 --- a/public/app/core/components/AppChrome/MegaMenu/utils.ts +++ b/public/app/core/components/AppChrome/MegaMenu/utils.ts @@ -29,19 +29,21 @@ export const enrichHelpItem = (helpItem: NavModelItem) => { return helpItem; }; -export const enrichWithInteractionTracking = (item: NavModelItem, expandedState: boolean) => { - const onClick = item.onClick; - item.onClick = () => { +export const enrichWithInteractionTracking = (item: NavModelItem, megaMenuDockedState: boolean) => { + // creating a new object here to not mutate the original item object + const newItem = { ...item }; + const onClick = newItem.onClick; + newItem.onClick = () => { reportInteraction('grafana_navigation_item_clicked', { - path: item.url ?? item.id, - state: expandedState ? 'expanded' : 'collapsed', + path: newItem.url ?? newItem.id, + menuIsDocked: megaMenuDockedState, }); onClick?.(); }; - if (item.children) { - item.children = item.children.map((item) => enrichWithInteractionTracking(item, expandedState)); + if (newItem.children) { + newItem.children = newItem.children.map((item) => enrichWithInteractionTracking(item, megaMenuDockedState)); } - return item; + return newItem; }; export const isMatchOrChildMatch = (itemToCheck: NavModelItem, searchItem?: NavModelItem) => { diff --git a/public/app/core/components/AppChrome/NavToolbar/NavToolbar.tsx b/public/app/core/components/AppChrome/NavToolbar/NavToolbar.tsx index e508f8b80dc51..7a15ebf85c7a6 100644 --- a/public/app/core/components/AppChrome/NavToolbar/NavToolbar.tsx +++ b/public/app/core/components/AppChrome/NavToolbar/NavToolbar.tsx @@ -62,7 +62,6 @@ export function NavToolbar({ <Breadcrumbs breadcrumbs={breadcrumbs} className={styles.breadcrumbsWrapper} /> <div className={styles.actions}> {actions} - {actions && <NavToolbarSeparator />} {searchBarHidden && ( <ToolbarButton onClick={onToggleKioskMode} @@ -71,6 +70,7 @@ export function NavToolbar({ icon="monitor" /> )} + {actions && <NavToolbarSeparator />} <ToolbarButton onClick={onToggleSearchBar} narrow @@ -97,6 +97,7 @@ const getStyles = (theme: GrafanaTheme2) => { display: 'flex', padding: theme.spacing(0, 1, 0, 2), alignItems: 'center', + borderBottom: `1px solid ${theme.colors.border.weak}`, }), menuButton: css({ display: 'flex', @@ -111,7 +112,7 @@ const getStyles = (theme: GrafanaTheme2) => { justifyContent: 'flex-end', paddingLeft: theme.spacing(1), flexGrow: 1, - gap: theme.spacing(0.5), + gap: theme.spacing(1), minWidth: 0, '.body-drawer-open &': { diff --git a/public/app/core/components/AppChrome/ReturnToPrevious/DismissableButton.tsx b/public/app/core/components/AppChrome/ReturnToPrevious/DismissableButton.tsx new file mode 100644 index 0000000000000..765dfed530656 --- /dev/null +++ b/public/app/core/components/AppChrome/ReturnToPrevious/DismissableButton.tsx @@ -0,0 +1,58 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, ButtonGroup, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +export interface DismissableButtonProps { + label: string; + onClick: () => void; + onDismiss: () => void; +} + +export const DismissableButton = ({ label, onClick, onDismiss }: DismissableButtonProps) => { + const styles = useStyles2(getStyles); + + return ( + <ButtonGroup className={styles.buttonGroup}> + <Button + icon="angle-left" + size="sm" + variant="primary" + fill="outline" + onClick={onClick} + title={label} + className={styles.mainDismissableButton} + > + {label} + </Button> + <Button + icon="times" + aria-label={t('return-to-previous.dismissable-button', 'Close')} + variant="primary" + fill="outline" + size="sm" + onClick={onDismiss} + /> + </ButtonGroup> + ); +}; +const getStyles = (theme: GrafanaTheme2) => ({ + mainDismissableButton: css({ + width: '100%', + ['> span']: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + maxWidth: '270px', + display: 'inline-block', + }, + }), + buttonGroup: css({ + width: 'fit-content', + backgroundColor: theme.colors.background.secondary, + }), +}); + +DismissableButton.displayName = 'DismissableButton'; diff --git a/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.test.tsx b/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.test.tsx new file mode 100644 index 0000000000000..0f9b5d62c9477 --- /dev/null +++ b/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; +import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; + +import { config, reportInteraction } from '@grafana/runtime'; + +import { ReturnToPrevious, ReturnToPreviousProps } from './ReturnToPrevious'; + +const mockReturnToPreviousProps: ReturnToPreviousProps = { + title: 'Dashboards Page', + href: '/dashboards', +}; +const reportInteractionMock = jest.mocked(reportInteraction); +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + reportInteraction: reportInteractionMock, + }; +}); +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), + }; +}); +const setup = () => { + const grafanaContext = getGrafanaContextMock(); + grafanaContext.chrome.setReturnToPrevious(mockReturnToPreviousProps); + return render( + <TestProvider grafanaContext={grafanaContext}> + <ReturnToPrevious {...mockReturnToPreviousProps} /> + </TestProvider> + ); +}; + +describe('ReturnToPrevious', () => { + beforeEach(() => { + /* We enabled the feature toggle */ + config.featureToggles.returnToPrevious = true; + }); + afterEach(() => { + window.sessionStorage.clear(); + jest.resetAllMocks(); + config.featureToggles.returnToPrevious = false; + }); + it('should render component', async () => { + setup(); + expect(await screen.findByTitle('Back to Dashboards Page')).toBeInTheDocument(); + }); + + it('should trigger event once when clicking on the RTP button', async () => { + setup(); + const returnButton = await screen.findByTitle('Back to Dashboards Page'); + expect(returnButton).toBeInTheDocument(); + await userEvent.click(returnButton); + const mockCalls = reportInteractionMock.mock.calls; + /* The report is called 'grafana_return_to_previous_button_dismissed' but the action is 'clicked' */ + const mockReturn = mockCalls.filter((call) => call[0] === 'grafana_return_to_previous_button_dismissed'); + expect(mockReturn).toHaveLength(1); + expect(mockReturn[0][1]).toEqual({ action: 'clicked', page: '/dashboards' }); + }); + + it('should trigger event once when clicking on the Close button', async () => { + setup(); + const closeBtn = await screen.findByRole('button', { name: 'Close' }); + expect(closeBtn).toBeInTheDocument(); + await userEvent.click(closeBtn); + const mockCalls = reportInteractionMock.mock.calls; + /* The report is called 'grafana_return_to_previous_button_dismissed' but the action is 'dismissed' */ + const mockDismissed = mockCalls.filter((call) => call[0] === 'grafana_return_to_previous_button_dismissed'); + expect(mockDismissed).toHaveLength(1); + expect(mockDismissed[0][1]).toEqual({ action: 'dismissed', page: '/dashboards' }); + }); +}); diff --git a/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.tsx b/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.tsx new file mode 100644 index 0000000000000..693aa72b83f86 --- /dev/null +++ b/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.tsx @@ -0,0 +1,54 @@ +import { css } from '@emotion/css'; +import React, { useCallback } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { useStyles2 } from '@grafana/ui'; +import { useGrafana } from 'app/core/context/GrafanaContext'; +import { t } from 'app/core/internationalization'; + +import { DismissableButton } from './DismissableButton'; + +export interface ReturnToPreviousProps { + title: string; + href: string; +} + +export const ReturnToPrevious = ({ href, title }: ReturnToPreviousProps) => { + const styles = useStyles2(getStyles); + const { chrome } = useGrafana(); + + const handleOnClick = useCallback(() => { + locationService.push(href); + chrome.clearReturnToPrevious('clicked'); + }, [href, chrome]); + + const handleOnDismiss = useCallback(() => { + chrome.clearReturnToPrevious('dismissed'); + }, [chrome]); + + return ( + <div className={styles.returnToPrevious}> + <DismissableButton + label={t('return-to-previous.button.label', 'Back to {{title}}', { title })} + onClick={handleOnClick} + onDismiss={handleOnDismiss} + /> + </div> + ); +}; +const getStyles = (theme: GrafanaTheme2) => ({ + returnToPrevious: css({ + label: 'return-to-previous', + display: 'flex', + justifyContent: 'center', + left: '50%', + transform: 'translateX(-50%)', + zIndex: theme.zIndex.tooltip, + position: 'fixed', + bottom: theme.spacing.x4, + boxShadow: theme.shadows.z3, + }), +}); + +ReturnToPrevious.displayName = 'ReturnToPrevious'; diff --git a/public/app/core/components/AppChrome/SectionNav/SectionNav.tsx b/public/app/core/components/AppChrome/SectionNav/SectionNav.tsx deleted file mode 100644 index 1b46e1a86c41b..0000000000000 --- a/public/app/core/components/AppChrome/SectionNav/SectionNav.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { css, cx } from '@emotion/css'; -import React, { useEffect, useState } from 'react'; -import { useLocalStorage } from 'react-use'; - -import { NavModel, GrafanaTheme2 } from '@grafana/data'; -import { useStyles2, CustomScrollbar, useTheme2 } from '@grafana/ui'; - -import { SectionNavItem } from './SectionNavItem'; -import { SectionNavToggle } from './SectionNavToggle'; - -export interface Props { - model: NavModel; -} - -export function SectionNav({ model }: Props) { - const styles = useStyles2(getStyles); - const { isExpanded, onToggleSectionNav } = useSectionNavState(); - - if (!Boolean(model.main?.children?.length)) { - return null; - } - - return ( - <div className={styles.navContainer}> - <nav - className={cx(styles.nav, { - [styles.navExpanded]: isExpanded, - })} - > - <CustomScrollbar showScrollIndicators> - <div className={styles.items} role="tablist"> - <SectionNavItem item={model.main} isSectionRoot /> - </div> - </CustomScrollbar> - </nav> - <SectionNavToggle isExpanded={isExpanded} onClick={onToggleSectionNav} /> - </div> - ); -} - -function useSectionNavState() { - const theme = useTheme2(); - - const isSmallScreen = window.matchMedia(`(max-width: ${theme.breakpoints.values.lg}px)`).matches; - const [navExpandedPreference, setNavExpandedPreference] = useLocalStorage<boolean>( - 'grafana.sectionNav.expanded', - !isSmallScreen - ); - const [isExpanded, setIsExpanded] = useState(!isSmallScreen && navExpandedPreference); - - useEffect(() => { - const mediaQuery = window.matchMedia(`(max-width: ${theme.breakpoints.values.lg}px)`); - const onMediaQueryChange = (e: MediaQueryListEvent) => setIsExpanded(e.matches ? false : navExpandedPreference); - mediaQuery.addEventListener('change', onMediaQueryChange); - return () => mediaQuery.removeEventListener('change', onMediaQueryChange); - }, [navExpandedPreference, theme.breakpoints.values.lg]); - - const onToggleSectionNav = () => { - setNavExpandedPreference(!isExpanded); - setIsExpanded(!isExpanded); - }; - - return { isExpanded, onToggleSectionNav }; -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - navContainer: css({ - display: 'flex', - flexDirection: 'column', - position: 'relative', - - [theme.breakpoints.up('md')]: { - flexDirection: 'row', - }, - }), - nav: css({ - display: 'flex', - flexDirection: 'column', - background: theme.colors.background.canvas, - flexShrink: 0, - transition: theme.transitions.create(['width', 'max-height']), - maxHeight: 0, - visibility: 'hidden', - [theme.breakpoints.up('md')]: { - width: 0, - maxHeight: 'unset', - }, - }), - navExpanded: css({ - maxHeight: '50vh', - visibility: 'visible', - [theme.breakpoints.up('md')]: { - width: '250px', - maxHeight: 'unset', - }, - }), - items: css({ - display: 'flex', - flexDirection: 'column', - padding: theme.spacing(2, 1, 2, 2), - minWidth: '250px', - - [theme.breakpoints.up('md')]: { - padding: theme.spacing(4.5, 1, 2, 2), - }, - }), - }; -}; diff --git a/public/app/core/components/AppChrome/SectionNav/SectionNavItem.test.tsx b/public/app/core/components/AppChrome/SectionNav/SectionNavItem.test.tsx deleted file mode 100644 index 90d92e7546217..0000000000000 --- a/public/app/core/components/AppChrome/SectionNav/SectionNavItem.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; - -import { NavModelItem } from '@grafana/data'; - -import { SectionNavItem } from './SectionNavItem'; - -describe('SectionNavItem', () => { - it('should only show the img for a section root if both img and icon are present', () => { - const item: NavModelItem = { - text: 'Test', - icon: 'k6', - img: 'img', - children: [ - { - text: 'Child', - }, - ], - }; - - render(<SectionNavItem item={item} isSectionRoot />); - expect(screen.getByTestId('section-image')).toBeInTheDocument(); - expect(screen.queryByTestId('section-icon')).not.toBeInTheDocument(); - }); -}); diff --git a/public/app/core/components/AppChrome/SectionNav/SectionNavItem.tsx b/public/app/core/components/AppChrome/SectionNav/SectionNavItem.tsx deleted file mode 100644 index b21f8976aa985..0000000000000 --- a/public/app/core/components/AppChrome/SectionNav/SectionNavItem.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { css, cx } from '@emotion/css'; -import React from 'react'; - -import { GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { reportInteraction } from '@grafana/runtime'; -import { useStyles2, Icon } from '@grafana/ui'; - -import { getActiveItem, hasChildMatch } from '../MegaMenu/utils'; - -export interface Props { - item: NavModelItem; - isSectionRoot?: boolean; - level?: number; -} - -// max level depth to render -const MAX_DEPTH = 2; - -export function SectionNavItem({ item, isSectionRoot = false, level = 0 }: Props) { - const styles = useStyles2(getStyles); - - const children = item.children?.filter((x) => !x.hideFromTabs); - const activeItem = item.children && getActiveItem(item.children, location.pathname); - - // If first root child is a section skip the bottom margin (as sections have top margin already) - const noRootMargin = isSectionRoot && Boolean(item.children![0].children?.length); - - const linkClass = cx({ - [styles.link]: true, - [styles.activeStyle]: item.active || (level === MAX_DEPTH && hasChildMatch(item, activeItem)), - [styles.isSection]: level < MAX_DEPTH && (Boolean(children?.length) || item.isSection), - [styles.isSectionRoot]: isSectionRoot, - [styles.noRootMargin]: noRootMargin, - }); - - let icon: React.ReactNode | null = null; - - if (item.img) { - icon = <img data-testid="section-image" className={styles.sectionImg} src={item.img} alt="" />; - } else if (item.icon) { - icon = <Icon data-testid="section-icon" className={styles.sectionImg} name={item.icon} />; - } - - const onItemClicked = () => { - reportInteraction('grafana_navigation_item_clicked', { - path: item.url ?? item.id, - sectionNav: true, - }); - }; - - return ( - <> - <a - onClick={onItemClicked} - href={item.url} - className={linkClass} - aria-label={selectors.components.Tab.title(item.text)} - role="tab" - aria-selected={item.active} - > - {isSectionRoot && icon} - {item.text} - {item.tabSuffix && <item.tabSuffix className={styles.suffix} />} - </a> - {level < MAX_DEPTH && - children?.map((child, index) => <SectionNavItem item={child} key={index} level={level + 1} />)} - </> - ); -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - link: css` - padding: ${theme.spacing(1, 0, 1, 1.5)}; - display: flex; - align-items: flex-start; - border-radius: ${theme.shape.radius.default}; - gap: ${theme.spacing(1)}; - height: 100%; - position: relative; - color: ${theme.colors.text.secondary}; - - &:hover, - &:focus { - text-decoration: underline; - z-index: 1; - } - `, - activeStyle: css` - label: activeTabStyle; - color: ${theme.colors.text.primary}; - font-weight: ${theme.typography.fontWeightMedium}; - background: ${theme.colors.emphasize(theme.colors.background.canvas, 0.03)}; - - &::before { - display: block; - content: ' '; - position: absolute; - left: 0; - width: 4px; - bottom: 2px; - top: 2px; - border-radius: ${theme.shape.radius.default}; - background-image: ${theme.colors.gradients.brandVertical}; - } - `, - suffix: css` - margin-left: ${theme.spacing(1)}; - `, - sectionImg: css({ - margin: '6px 0', - width: theme.spacing(2), - }), - isSectionRoot: css({ - fontSize: theme.typography.h4.fontSize, - marginTop: 0, - marginBottom: theme.spacing(2), - fontWeight: theme.typography.fontWeightMedium, - }), - isSection: css({ - color: theme.colors.text.primary, - fontSize: theme.typography.h5.fontSize, - marginTop: theme.spacing(2), - fontWeight: theme.typography.fontWeightMedium, - }), - noRootMargin: css({ - marginBottom: 0, - }), - }; -}; diff --git a/public/app/core/components/AppChrome/SectionNav/SectionNavToggle.tsx b/public/app/core/components/AppChrome/SectionNav/SectionNavToggle.tsx deleted file mode 100644 index d500d5620fe5f..0000000000000 --- a/public/app/core/components/AppChrome/SectionNav/SectionNavToggle.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { css } from '@emotion/css'; -import classnames from 'classnames'; -import React from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { Button, useTheme2 } from '@grafana/ui'; - -export interface Props { - isExpanded?: boolean; - onClick: () => void; -} - -export const SectionNavToggle = ({ isExpanded, onClick }: Props) => { - const theme = useTheme2(); - const styles = getStyles(theme); - - return ( - <Button - title={'Toggle section navigation'} - aria-label={isExpanded ? 'Close section navigation' : 'Open section navigation'} - icon="arrow-to-right" - className={classnames(styles.icon, { - [styles.iconExpanded]: isExpanded, - })} - variant="secondary" - fill="text" - size="md" - onClick={onClick} - /> - ); -}; - -SectionNavToggle.displayName = 'SectionNavToggle'; - -const getStyles = (theme: GrafanaTheme2) => ({ - icon: css({ - alignSelf: 'center', - margin: theme.spacing(1, 0), - transform: 'rotate(90deg)', - transition: theme.transitions.create('opacity'), - color: theme.colors.text.secondary, - zIndex: 1, - - [theme.breakpoints.up('md')]: { - alignSelf: 'flex-start', - position: 'relative', - left: 0, - margin: theme.spacing(0, 0, 0, 1), - top: theme.spacing(2), - transform: 'none', - }, - - 'div:hover > &, &:focus': { - opacity: 1, - }, - }), - iconExpanded: css({ - rotate: '180deg', - - [theme.breakpoints.up('md')]: { - opacity: 0, - margin: 0, - position: 'absolute', - right: 0, - left: 'initial', - }, - }), -}); diff --git a/public/app/core/components/AppChrome/TopBar/SignInLink.tsx b/public/app/core/components/AppChrome/TopBar/SignInLink.tsx index 8b7650fd73819..19791612093e1 100644 --- a/public/app/core/components/AppChrome/TopBar/SignInLink.tsx +++ b/public/app/core/components/AppChrome/TopBar/SignInLink.tsx @@ -8,7 +8,12 @@ import { useStyles2 } from '@grafana/ui'; export function SignInLink() { const location = useLocation(); const styles = useStyles2(getStyles); - const loginUrl = textUtil.sanitizeUrl(locationUtil.getUrlForPartial(location, { forceLogin: 'true' })); + let loginUrl = textUtil.sanitizeUrl(locationUtil.getUrlForPartial(location, { forceLogin: 'true' })); + + // Fix for loginUrl starting with "//" which is a scheme relative URL + if (loginUrl.startsWith('//')) { + loginUrl = loginUrl.replace(/\/+/g, '/'); + } return ( <a className={styles.link} href={loginUrl} target="_self"> diff --git a/public/app/core/components/Breadcrumbs/utils.ts b/public/app/core/components/Breadcrumbs/utils.ts index 8bd0d5dec6539..c7b094e83ec33 100644 --- a/public/app/core/components/Breadcrumbs/utils.ts +++ b/public/app/core/components/Breadcrumbs/utils.ts @@ -1,5 +1,4 @@ import { NavModelItem } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { Breadcrumb } from './types'; @@ -16,12 +15,9 @@ export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelIte // construct the URL to match const urlParts = node.url?.split('?') ?? ['', '']; let urlToMatch = urlParts[0]; - - if (config.featureToggles.dockedMegaMenu) { - const urlSearchParams = new URLSearchParams(urlParts[1]); - if (urlSearchParams.has('editview')) { - urlToMatch += `?editview=${urlSearchParams.get('editview')}`; - } + const urlSearchParams = new URLSearchParams(urlParts[1]); + if (urlSearchParams.has('editview')) { + urlToMatch += `?editview=${urlSearchParams.get('editview')}`; } // Check if we found home/root if if so return early diff --git a/public/app/core/components/ConfigDescriptionLink.tsx b/public/app/core/components/ConfigDescriptionLink.tsx deleted file mode 100644 index c2cecc84cd912..0000000000000 --- a/public/app/core/components/ConfigDescriptionLink.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { css } from '@emotion/css'; -import React from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2 } from '@grafana/ui'; - -type Props = { - description: string; - suffix: string; - feature: string; -}; - -export function ConfigDescriptionLink(props: Props) { - const { description, suffix, feature } = props; - const text = `Learn more about ${feature}`; - const styles = useStyles2(getStyles); - - return ( - <span className={styles.container}> - {description} - <a - aria-label={text} - href={`https://grafana.com/docs/grafana/next/datasources/${suffix}`} - rel="noreferrer" - target="_blank" - > - {text} - </a> - </span> - ); -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - container: css({ - color: theme.colors.text.secondary, - a: css({ - color: theme.colors.text.link, - textDecoration: 'underline', - marginLeft: '5px', - '&:hover': { - textDecoration: 'none', - }, - }), - }), - }; -}; diff --git a/public/app/core/components/Divider.tsx b/public/app/core/components/Divider.tsx deleted file mode 100644 index 11f02393f4bbd..0000000000000 --- a/public/app/core/components/Divider.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { css } from '@emotion/css'; -import React from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2 } from '@grafana/ui'; - -export const Divider = ({ hideLine = false }) => { - const styles = useStyles2(getStyles); - - if (hideLine) { - return <hr className={styles.dividerHideLine} />; - } - - return <hr className={styles.divider} />; -}; - -const getStyles = (theme: GrafanaTheme2) => ({ - divider: css({ - margin: theme.spacing(4, 0), - }), - dividerHideLine: css({ - border: 'none', - margin: theme.spacing(3, 0), - }), -}); diff --git a/public/app/core/components/FlaggedScroller.tsx b/public/app/core/components/FlaggedScroller.tsx new file mode 100644 index 0000000000000..f7dbdd85dca2d --- /dev/null +++ b/public/app/core/components/FlaggedScroller.tsx @@ -0,0 +1,54 @@ +import { css, cx } from '@emotion/css'; +import React, { useEffect, useRef } from 'react'; + +import { config } from '@grafana/runtime'; +import { CustomScrollbar, useStyles2 } from '@grafana/ui'; + +type FlaggedScrollerProps = Parameters<typeof CustomScrollbar>[0]; + +export default function FlaggedScrollbar(props: FlaggedScrollerProps) { + if (config.featureToggles.betterPageScrolling) { + return <NativeScrollbar {...props}>{props.children}</NativeScrollbar>; + } + + return <CustomScrollbar {...props} />; +} + +// Shim to provide API-compatibility for Page's scroll-related props +function NativeScrollbar({ children, scrollRefCallback, scrollTop }: FlaggedScrollerProps) { + const styles = useStyles2(getStyles); + const ref = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (ref.current && scrollRefCallback) { + scrollRefCallback(ref.current); + } + }, [ref, scrollRefCallback]); + + useEffect(() => { + if (ref.current && scrollTop != null) { + ref.current?.scrollTo(0, scrollTop); + } + }, [scrollTop]); + + return ( + // Set the .scrollbar-view class to help e2e tests find this, like in CustomScrollbar + <div ref={ref} className={cx(styles.nativeScrollbars, 'scrollbar-view')}> + {children} + </div> + ); +} + +function getStyles() { + return { + nativeScrollbars: css({ + label: 'native-scroll-container', + minHeight: `calc(100% + 0px)`, // I don't know, just copied from custom scrollbars + maxHeight: `calc(100% + 0px)`, // I don't know, just copied from custom scrollbars + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + overflow: 'auto', + }), + }; +} diff --git a/public/app/core/components/Footer/Footer.tsx b/public/app/core/components/Footer/Footer.tsx index 84c72a76f750f..a07695cd57c95 100644 --- a/public/app/core/components/Footer/Footer.tsx +++ b/public/app/core/components/Footer/Footer.tsx @@ -69,7 +69,7 @@ export function getVersionLinks(hideEdition?: boolean): FooterLink[] { links.push({ target: '_blank', id: 'version', - text: `v${buildInfo.version} (${buildInfo.commit})`, + text: buildInfo.versionString, url: hasReleaseNotes ? `https://github.com/grafana/grafana/blob/main/CHANGELOG.md` : undefined, }); @@ -103,8 +103,8 @@ export const Footer = React.memo(({ customLinks, hideEdition }: Props) => { <footer className="footer"> <div className="text-center"> <ul> - {links.map((link) => ( - <li key={link.text}> + {links.map((link, index) => ( + <li key={index}> <FooterItem item={link} /> </li> ))} diff --git a/public/app/core/components/ForgottenPassword/ChangePassword.tsx b/public/app/core/components/ForgottenPassword/ChangePassword.tsx index 989ab9f81b29a..4f5dbec643d69 100644 --- a/public/app/core/components/ForgottenPassword/ChangePassword.tsx +++ b/public/app/core/components/ForgottenPassword/ChangePassword.tsx @@ -1,10 +1,18 @@ -import React, { SyntheticEvent } from 'react'; +import React, { SyntheticEvent, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { selectors } from '@grafana/e2e-selectors'; -import { Tooltip, Form, Field, VerticalGroup, Button, Alert, useStyles2 } from '@grafana/ui'; +import { config } from '@grafana/runtime'; +import { Tooltip, Field, VerticalGroup, Button, Alert, useStyles2 } from '@grafana/ui'; import { getStyles } from '../Login/LoginForm'; import { PasswordField } from '../PasswordField/PasswordField'; +import { + ValidationLabels, + strongPasswordValidations, + strongPasswordValidationRegister, +} from '../ValidationLabels/ValidationLabels'; + interface Props { onSubmit: (pw: string) => void; onSkip?: (event?: SyntheticEvent) => void; @@ -18,52 +26,77 @@ interface PasswordDTO { export const ChangePassword = ({ onSubmit, onSkip, showDefaultPasswordWarning }: Props) => { const styles = useStyles2(getStyles); + const [displayValidationLabels, setDisplayValidationLabels] = useState(false); + const [pristine, setPristine] = useState(true); + + const { + handleSubmit, + register, + getValues, + formState: { errors }, + watch, + } = useForm<PasswordDTO>({ + defaultValues: { + newPassword: '', + confirmNew: '', + }, + }); + + const newPassword = watch('newPassword'); const submit = (passwords: PasswordDTO) => { onSubmit(passwords.newPassword); }; return ( - <Form onSubmit={submit}> - {({ errors, register, getValues }) => ( - <> - {showDefaultPasswordWarning && ( - <Alert severity="info" title="Continuing to use the default password exposes you to security risks." /> - )} - <Field label="New password" invalid={!!errors.newPassword} error={errors?.newPassword?.message}> - <PasswordField - {...register('newPassword', { required: 'New Password is required' })} - id="new-password" - autoFocus - autoComplete="new-password" - /> - </Field> - <Field label="Confirm new password" invalid={!!errors.confirmNew} error={errors?.confirmNew?.message}> - <PasswordField - {...register('confirmNew', { - required: 'Confirmed Password is required', - validate: (v: string) => v === getValues().newPassword || 'Passwords must match!', - })} - id="confirm-new-password" - autoComplete="new-password" - /> - </Field> - <VerticalGroup> - <Button type="submit" className={styles.submitButton}> - Submit - </Button> - - {onSkip && ( - <Tooltip - content="If you skip you will be prompted to change password next time you log in." - placement="bottom" - > - <Button fill="text" onClick={onSkip} type="button" data-testid={selectors.pages.Login.skip}> - Skip - </Button> - </Tooltip> - )} - </VerticalGroup> - </> + <form onSubmit={handleSubmit(submit)}> + {showDefaultPasswordWarning && ( + <Alert severity="info" title="Continuing to use the default password exposes you to security risks." /> + )} + <Field label="New password" invalid={!!errors.newPassword} error={errors?.newPassword?.message}> + <PasswordField + onFocus={() => setDisplayValidationLabels(true)} + {...register('newPassword', { + required: 'New Password is required', + onBlur: () => setPristine(false), + validate: { strongPasswordValidationRegister }, + })} + id="new-password" + autoFocus + autoComplete="new-password" + /> + </Field> + {displayValidationLabels && ( + <ValidationLabels + pristine={pristine} + password={newPassword} + strongPasswordValidations={strongPasswordValidations} + /> )} - </Form> + <Field label="Confirm new password" invalid={!!errors.confirmNew} error={errors?.confirmNew?.message}> + <PasswordField + {...register('confirmNew', { + required: 'Confirmed Password is required', + validate: (v: string) => v === getValues().newPassword || 'Passwords must match!', + })} + id="confirm-new-password" + autoComplete="new-password" + /> + </Field> + <VerticalGroup> + <Button type="submit" className={styles.submitButton}> + Submit + </Button> + + {!config.auth.basicAuthStrongPasswordPolicy && onSkip && ( + <Tooltip + content="If you skip you will be prompted to change password next time you log in." + placement="bottom" + > + <Button fill="text" onClick={onSkip} type="button" data-testid={selectors.pages.Login.skip}> + Skip + </Button> + </Tooltip> + )} + </VerticalGroup> + </form> ); }; diff --git a/public/app/core/components/ForgottenPassword/ChangePasswordPage.test.tsx b/public/app/core/components/ForgottenPassword/ChangePasswordPage.test.tsx index 6df0b66cefb80..cc2a0cb6d3590 100644 --- a/public/app/core/components/ForgottenPassword/ChangePasswordPage.test.tsx +++ b/public/app/core/components/ForgottenPassword/ChangePasswordPage.test.tsx @@ -24,6 +24,9 @@ jest.mock('@grafana/runtime', () => ({ licenseUrl: '', }, appSubUrl: '', + auth: { + basicAuthStrongPasswordPolicy: false, + }, }, })); diff --git a/public/app/core/components/ForgottenPassword/ForgottenPassword.tsx b/public/app/core/components/ForgottenPassword/ForgottenPassword.tsx index c15c4fe6e13b9..c4ab8a1b18378 100644 --- a/public/app/core/components/ForgottenPassword/ForgottenPassword.tsx +++ b/public/app/core/components/ForgottenPassword/ForgottenPassword.tsx @@ -1,9 +1,10 @@ import { css } from '@emotion/css'; import React, { useState } from 'react'; +import { useForm } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; -import { Form, Field, Input, Button, Legend, Container, useStyles2, HorizontalGroup, LinkButton } from '@grafana/ui'; +import { Field, Input, Button, Legend, Container, useStyles2, HorizontalGroup, LinkButton } from '@grafana/ui'; import config from 'app/core/config'; interface EmailDTO { @@ -23,6 +24,11 @@ export const ForgottenPassword = () => { const [emailSent, setEmailSent] = useState(false); const styles = useStyles2(paragraphStyles); const loginHref = `${config.appSubUrl}/login`; + const { + handleSubmit, + register, + formState: { errors }, + } = useForm<EmailDTO>(); const sendEmail = async (formModel: EmailDTO) => { const res = await getBackendSrv().post('/api/user/password/send-reset-email', formModel); @@ -43,32 +49,28 @@ export const ForgottenPassword = () => { ); } return ( - <Form onSubmit={sendEmail}> - {({ register, errors }) => ( - <> - <Legend>Reset password</Legend> - <Field - label="User" - description="Enter your information to get a reset link sent to you" - invalid={!!errors.userOrEmail} - error={errors?.userOrEmail?.message} - > - <Input - id="user-input" - placeholder="Email or username" - {...register('userOrEmail', { required: 'Email or username is required' })} - /> - </Field> - <HorizontalGroup> - <Button type="submit">Send reset email</Button> - <LinkButton fill="text" href={loginHref}> - Back to login - </LinkButton> - </HorizontalGroup> + <form onSubmit={handleSubmit(sendEmail)}> + <Legend>Reset password</Legend> + <Field + label="User" + description="Enter your information to get a reset link sent to you" + invalid={!!errors.userOrEmail} + error={errors?.userOrEmail?.message} + > + <Input + id="user-input" + placeholder="Email or username" + {...register('userOrEmail', { required: 'Email or username is required' })} + /> + </Field> + <HorizontalGroup> + <Button type="submit">Send reset email</Button> + <LinkButton fill="text" href={loginHref}> + Back to login + </LinkButton> + </HorizontalGroup> - <p className={styles}>Did you forget your username or email? Contact your Grafana administrator.</p> - </> - )} - </Form> + <p className={styles}>Did you forget your username or email? Contact your Grafana administrator.</p> + </form> ); }; diff --git a/public/app/core/components/Form/Form.tsx b/public/app/core/components/Form/Form.tsx new file mode 100644 index 0000000000000..91ea061c97500 --- /dev/null +++ b/public/app/core/components/Form/Form.tsx @@ -0,0 +1,62 @@ +import { css } from '@emotion/css'; +import React, { HTMLProps, useEffect } from 'react'; +import { + useForm, + Mode, + DefaultValues, + SubmitHandler, + FieldValues, + UseFormReturn, + FieldErrors, + FieldPath, +} from 'react-hook-form'; + +export type FormAPI<T extends FieldValues> = Omit<UseFormReturn<T>, 'handleSubmit'> & { + errors: FieldErrors<T>; +}; + +interface FormProps<T extends FieldValues> extends Omit<HTMLProps<HTMLFormElement>, 'onSubmit' | 'children'> { + validateOn?: Mode; + validateOnMount?: boolean; + validateFieldsOnMount?: FieldPath<T> | Array<FieldPath<T>>; + defaultValues?: DefaultValues<T>; + onSubmit: SubmitHandler<T>; + children: (api: FormAPI<T>) => React.ReactNode; + /** Sets max-width for container. Use it instead of setting individual widths on inputs.*/ + maxWidth?: number | 'none'; +} + +export function Form<T extends FieldValues>({ + defaultValues, + onSubmit, + validateOnMount = false, + validateFieldsOnMount, + children, + validateOn = 'onSubmit', + maxWidth = 600, + ...htmlProps +}: FormProps<T>) { + const { handleSubmit, trigger, formState, ...rest } = useForm<T>({ + mode: validateOn, + defaultValues, + }); + + useEffect(() => { + if (validateOnMount) { + trigger(validateFieldsOnMount); + } + }, [trigger, validateFieldsOnMount, validateOnMount]); + + return ( + <form + className={css({ + maxWidth: maxWidth !== 'none' ? maxWidth + 'px' : maxWidth, + width: '100%', + })} + onSubmit={handleSubmit(onSubmit)} + {...htmlProps} + > + {children({ errors: formState.errors, formState, trigger, ...rest })} + </form> + ); +} diff --git a/public/app/core/components/GraphNG/GraphNG.tsx b/public/app/core/components/GraphNG/GraphNG.tsx index 9ebfa499c819c..9dd45cc312a2c 100644 --- a/public/app/core/components/GraphNG/GraphNG.tsx +++ b/public/app/core/components/GraphNG/GraphNG.tsx @@ -1,27 +1,25 @@ import React, { Component } from 'react'; -import { Subscription } from 'rxjs'; -import { throttleTime } from 'rxjs/operators'; import uPlot, { AlignedData } from 'uplot'; import { DataFrame, - DataHoverClearEvent, - DataHoverEvent, + DataLinkPostProcessor, Field, FieldMatcherID, fieldMatchers, FieldType, - LegacyGraphHoverEvent, + getLinksSupplier, + InterpolateFunction, TimeRange, TimeZone, } from '@grafana/data'; import { VizLegendOptions } from '@grafana/schema'; -import { Themeable2, PanelContext, PanelContextRoot, VizLayout } from '@grafana/ui'; +import { Themeable2, PanelContextRoot, VizLayout } from '@grafana/ui'; import { UPlotChart } from '@grafana/ui/src/components/uPlot/Plot'; import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder'; import { Renderers, UPlotConfigBuilder } from '@grafana/ui/src/components/uPlot/config/UPlotConfigBuilder'; import { ScaleProps } from '@grafana/ui/src/components/uPlot/config/UPlotScaleBuilder'; -import { findMidPointYPosition, pluginLog } from '@grafana/ui/src/components/uPlot/utils'; +import { pluginLog } from '@grafana/ui/src/components/uPlot/utils'; import { GraphNGLegendEvent, XYFieldMatchers } from './types'; import { preparePlotFrame as defaultPreparePlotFrame } from './utils'; @@ -49,6 +47,8 @@ export interface GraphNGProps extends Themeable2 { propsToDiff?: Array<string | PropDiffFn>; preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame | null; renderLegend: (config: UPlotConfigBuilder) => React.ReactElement | null; + replaceVariables: InterpolateFunction; + dataLinkPostProcessor?: DataLinkPostProcessor; /** * needed for propsToDiff to re-init the plot & config @@ -87,11 +87,8 @@ export interface GraphNGState { */ export class GraphNG extends Component<GraphNGProps, GraphNGState> { static contextType = PanelContextRoot; - panelContext: PanelContext = {} as PanelContext; private plotInstance: React.RefObject<uPlot>; - private subscription = new Subscription(); - constructor(props: GraphNGProps) { super(props); let state = this.prepState(props); @@ -105,30 +102,67 @@ export class GraphNG extends Component<GraphNGProps, GraphNGState> { prepState(props: GraphNGProps, withConfig = true) { let state: GraphNGState = null as any; - const { frames, fields, preparePlotFrame } = props; + const { frames, fields, preparePlotFrame, replaceVariables, dataLinkPostProcessor } = props; - const preparePlotFrameFn = preparePlotFrame || defaultPreparePlotFrame; + const preparePlotFrameFn = preparePlotFrame ?? defaultPreparePlotFrame; + + const matchY = fieldMatchers.get(FieldMatcherID.byTypes).get(new Set([FieldType.number, FieldType.enum])); + + // if there are data links, we have to keep all fields so they're index-matched, then filter out dimFields.y + const withLinks = frames.some((frame) => frame.fields.some((field) => (field.config.links?.length ?? 0) > 0)); + + const dimFields = fields ?? { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: withLinks ? () => true : matchY, + }; + + const alignedFrame = preparePlotFrameFn(frames, dimFields, props.timeRange); - const alignedFrame = preparePlotFrameFn( - frames, - fields || { - x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), - y: fieldMatchers.get(FieldMatcherID.byTypes).get(new Set([FieldType.number, FieldType.enum])), - }, - props.timeRange - ); pluginLog('GraphNG', false, 'data aligned', alignedFrame); if (alignedFrame) { + let alignedFrameFinal = alignedFrame; + + if (withLinks) { + const timeZone = Array.isArray(this.props.timeZone) ? this.props.timeZone[0] : this.props.timeZone; + + alignedFrame.fields.forEach((field) => { + field.getLinks = getLinksSupplier( + alignedFrame, + field, + { + ...field.state?.scopedVars, + __dataContext: { + value: { + data: [alignedFrame], + field: field, + frame: alignedFrame, + frameIndex: 0, + }, + }, + }, + replaceVariables, + timeZone, + dataLinkPostProcessor + ); + }); + + // filter join field and dimFields.y + alignedFrameFinal = { + ...alignedFrame, + fields: alignedFrame.fields.filter((field, i) => i === 0 || matchY(field, alignedFrame, [alignedFrame])), + }; + } + let config = this.state?.config; if (withConfig) { - config = props.prepConfig(alignedFrame, this.props.frames, this.getTimeRange); + config = props.prepConfig(alignedFrameFinal, this.props.frames, this.getTimeRange); pluginLog('GraphNG', false, 'config prepared', config); } state = { - alignedFrame, + alignedFrame: alignedFrameFinal, config, }; @@ -138,77 +172,6 @@ export class GraphNG extends Component<GraphNGProps, GraphNGState> { return state; } - handleCursorUpdate(evt: DataHoverEvent | LegacyGraphHoverEvent) { - const time = evt.payload?.point?.time; - const u = this.plotInstance.current; - if (u && time) { - // Try finding left position on time axis - const left = u.valToPos(time, 'x'); - let top; - if (left) { - // find midpoint between points at current idx - top = findMidPointYPosition(u, u.posToIdx(left)); - } - - if (!top || !left) { - return; - } - - u.setCursor({ - left, - top, - }); - } - } - - componentDidMount() { - this.panelContext = this.context as PanelContext; - const { eventBus } = this.panelContext; - - this.subscription.add( - eventBus - .getStream(DataHoverEvent) - .pipe(throttleTime(50)) - .subscribe({ - next: (evt) => { - if (eventBus === evt.origin) { - return; - } - this.handleCursorUpdate(evt); - }, - }) - ); - - // Legacy events (from flot graph) - this.subscription.add( - eventBus - .getStream(LegacyGraphHoverEvent) - .pipe(throttleTime(50)) - .subscribe({ - next: (evt) => this.handleCursorUpdate(evt), - }) - ); - - this.subscription.add( - eventBus - .getStream(DataHoverClearEvent) - .pipe(throttleTime(50)) - .subscribe({ - next: () => { - const u = this.plotInstance?.current; - - // @ts-ignore - if (u && !u.cursor._lock) { - u.setCursor({ - left: -10, - top: -10, - }); - } - }, - }) - ); - } - componentDidUpdate(prevProps: GraphNGProps) { const { frames, structureRev, timeZone, propsToDiff } = this.props; @@ -237,10 +200,6 @@ export class GraphNG extends Component<GraphNGProps, GraphNGState> { } } - componentWillUnmount() { - this.subscription.unsubscribe(); - } - render() { const { width, height, children, renderLegend } = this.props; const { config, alignedFrame, alignedData } = this.state; diff --git a/public/app/core/components/GraphNG/__snapshots__/utils.test.ts.snap b/public/app/core/components/GraphNG/__snapshots__/utils.test.ts.snap index 09f70e81c444d..8443971551dc5 100644 --- a/public/app/core/components/GraphNG/__snapshots__/utils.test.ts.snap +++ b/public/app/core/components/GraphNG/__snapshots__/utils.test.ts.snap @@ -61,13 +61,18 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` }, ], "cursor": { - "dataIdx": [Function], "drag": { "setScale": false, }, "focus": { "prox": 30, }, + "hover": { + "prox": [Function], + "skip": [ + null, + ], + }, "points": { "fill": [Function], "size": [Function], @@ -75,13 +80,10 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "width": [Function], }, "sync": { - "filters": { - "pub": [Function], - }, "key": "__global_", "scales": [ "x", - "__fixed/na-na/na-na/auto/linear/na/number", + null, ], }, }, @@ -156,17 +158,17 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "fill": [Function], "paths": [Function], "points": { - "fill": "#ff0000", + "fill": [Function], "filter": [Function], "show": true, "size": undefined, - "stroke": "#ff0000", + "stroke": [Function], }, "pxAlign": undefined, "scale": "__fixed/na-na/na-na/auto/linear/na/number", "show": true, "spanGaps": false, - "stroke": "#ff0000", + "stroke": [Function], "value": [Function], "width": 2, }, @@ -202,17 +204,17 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "fill": [Function], "paths": [Function], "points": { - "fill": "#ff0000", + "fill": [Function], "filter": [Function], "show": true, "size": undefined, - "stroke": "#ff0000", + "stroke": [Function], }, "pxAlign": undefined, "scale": "__fixed/na-na/na-na/auto/linear/na/number", "show": true, "spanGaps": false, - "stroke": "#ff0000", + "stroke": [Function], "value": [Function], "width": 2, }, @@ -225,17 +227,17 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "fill": [Function], "paths": [Function], "points": { - "fill": "#ff0000", + "fill": [Function], "filter": [Function], "show": true, "size": undefined, - "stroke": "#ff0000", + "stroke": [Function], }, "pxAlign": undefined, "scale": "__fixed/na-na/na-na/auto/linear/na/number", "show": true, "spanGaps": false, - "stroke": "#ff0000", + "stroke": [Function], "value": [Function], "width": 2, }, diff --git a/public/app/core/components/GraphNG/utils.test.ts b/public/app/core/components/GraphNG/utils.test.ts index 9675cd7ca562c..234a587d0e768 100644 --- a/public/app/core/components/GraphNG/utils.test.ts +++ b/public/app/core/components/GraphNG/utils.test.ts @@ -3,7 +3,6 @@ import { DashboardCursorSync, DataFrame, DefaultTimeZone, - EventBusSrv, FieldColorModeId, FieldConfig, FieldMatcherID, @@ -215,7 +214,6 @@ describe('GraphNG utils', () => { theme: createTheme(), timeZones: [DefaultTimeZone], getTimeRange: getDefaultTimeRange, - eventBus: new EventBusSrv(), sync: () => DashboardCursorSync.Tooltip, allFrames: [frame!], }).getConfig(); @@ -353,7 +351,6 @@ describe('GraphNG utils', () => { "config": { "custom": { "drawStyle": "bars", - "spanNulls": -1, }, }, "labels": { @@ -386,7 +383,6 @@ describe('GraphNG utils', () => { "config": { "custom": { "drawStyle": "bars", - "spanNulls": -1, }, }, "labels": { diff --git a/public/app/core/components/GraphNG/utils.ts b/public/app/core/components/GraphNG/utils.ts index 030ea9722dbb2..b0c4368f3bbfb 100644 --- a/public/app/core/components/GraphNG/utils.ts +++ b/public/app/core/components/GraphNG/utils.ts @@ -1,4 +1,5 @@ import { DataFrame, Field, FieldType, outerJoinDataFrames, TimeRange } from '@grafana/data'; +import { NULL_EXPAND, NULL_REMOVE, NULL_RETAIN } from '@grafana/data/src/transformations/transformers/joinDataFrames'; import { applyNullInsertThreshold } from '@grafana/data/src/transformations/transformers/nulls/nullInsertThreshold'; import { nullToUndefThreshold } from '@grafana/data/src/transformations/transformers/nulls/nullToUndefThreshold'; import { GraphDrawStyle } from '@grafana/schema'; @@ -68,23 +69,10 @@ export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers } }); - let numBarSeries = 0; - - frames.forEach((frame) => { - frame.fields.forEach((f) => { - if (isVisibleBarField(f)) { - // prevent minesweeper-expansion of nulls (gaps) when joining bars - // since bar width is determined from the minimum distance between non-undefined values - // (this strategy will still retain any original pre-join nulls, though) - f.config.custom = { - ...f.config.custom, - spanNulls: -1, - }; - - numBarSeries++; - } - }); - }); + let numBarSeries = frames.reduce( + (acc, frame) => acc + frame.fields.reduce((acc, field) => acc + (isVisibleBarField(field) ? 1 : 0), 0), + 0 + ); // to make bar widths of all series uniform (equal to narrowest bar series), find smallest distance between x points let minXDelta = Infinity; @@ -110,6 +98,23 @@ export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers joinBy: dimFields.x, keep: dimFields.y, keepOriginIndices: true, + + // the join transformer force-deletes our state.displayName cache unless keepDisplayNames: true + // https://github.com/grafana/grafana/pull/31121 + // https://github.com/grafana/grafana/pull/71806 + keepDisplayNames: true, + + // prevent minesweeper-expansion of nulls (gaps) when joining bars + // since bar width is determined from the minimum distance between non-undefined values + // (this strategy will still retain any original pre-join nulls, though) + nullMode: (field) => { + if (isVisibleBarField(field)) { + return NULL_RETAIN; + } + + let spanNulls = field.config.custom?.spanNulls; + return spanNulls === true ? NULL_REMOVE : spanNulls === -1 ? NULL_RETAIN : NULL_EXPAND; + }, }); if (alignedFrame) { diff --git a/public/app/core/components/GrotNotFound/GrotNotFound.tsx b/public/app/core/components/GrotNotFound/GrotNotFound.tsx new file mode 100644 index 0000000000000..f0c4c16f68c18 --- /dev/null +++ b/public/app/core/components/GrotNotFound/GrotNotFound.tsx @@ -0,0 +1,62 @@ +import { css } from '@emotion/css'; +import React, { SVGProps } from 'react'; +import SVG from 'react-inlinesvg'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2, useTheme2 } from '@grafana/ui'; + +import dark404 from '../../../../img/grot-404-dark.svg'; +import light404 from '../../../../img/grot-404-light.svg'; + +import useMousePosition from './useMousePosition'; + +const MIN_ARM_ROTATION = -20; +const MAX_ARM_ROTATION = 5; +const MIN_ARM_TRANSLATION = -5; +const MAX_ARM_TRANSLATION = 5; + +export interface Props { + width?: SVGProps<SVGElement>['width']; + height?: SVGProps<SVGElement>['height']; + show404?: boolean; +} + +export const GrotNotFound = ({ width = 'auto', height, show404 = false }: Props) => { + const theme = useTheme2(); + const { x, y } = useMousePosition(); + const styles = useStyles2(getStyles, x, y, show404); + return <SVG src={theme.isDark ? dark404 : light404} className={styles.svg} height={height} width={width} />; +}; + +GrotNotFound.displayName = 'GrotNotFound'; + +const getStyles = (theme: GrafanaTheme2, xPos: number | null, yPos: number | null, show404: boolean) => { + const { innerWidth, innerHeight } = window; + const heightRatio = yPos && yPos / innerHeight; + const widthRatio = xPos && xPos / innerWidth; + const rotation = heightRatio !== null ? getIntermediateValue(heightRatio, MIN_ARM_ROTATION, MAX_ARM_ROTATION) : 0; + const translation = + widthRatio !== null ? getIntermediateValue(widthRatio, MIN_ARM_TRANSLATION, MAX_ARM_TRANSLATION) : 0; + + return { + svg: css({ + '#grot-404-arm, #grot-404-magnifier': { + transform: `rotate(${rotation}deg) translateX(${translation}%)`, + transformOrigin: 'center', + transition: 'transform 50ms linear', + }, + '#grot-404-text': { + display: show404 ? 'block' : 'none', + }, + }), + }; +}; + +/** + * Given a start value, end value, and a ratio, return the intermediate value + * Works with negative and inverted start/end values + */ +const getIntermediateValue = (ratio: number, start: number, end: number) => { + const value = ratio * (end - start) + start; + return value; +}; diff --git a/public/app/core/components/GrotNotFound/useMousePosition.ts b/public/app/core/components/GrotNotFound/useMousePosition.ts new file mode 100644 index 0000000000000..15ee04b4b9e9e --- /dev/null +++ b/public/app/core/components/GrotNotFound/useMousePosition.ts @@ -0,0 +1,29 @@ +import { throttle } from 'lodash'; +import { useState, useEffect } from 'react'; + +interface MousePosition { + x: number | null; + y: number | null; +} + +// For performance reasons, we throttle the mouse position updates +const DEFAULT_THROTTLE_INTERVAL_MS = 50; + +const useMousePosition = (throttleInterval = DEFAULT_THROTTLE_INTERVAL_MS) => { + const [mousePosition, setMousePosition] = useState<MousePosition>({ x: null, y: null }); + + useEffect(() => { + const updateMousePosition = throttle((event: MouseEvent) => { + setMousePosition({ x: event.clientX, y: event.clientY }); + }, throttleInterval); + window.addEventListener('mousemove', updateMousePosition); + + return () => { + window.removeEventListener('mousemove', updateMousePosition); + }; + }, [throttleInterval]); + + return mousePosition; +}; + +export default useMousePosition; diff --git a/public/app/core/components/Login/LoginForm.tsx b/public/app/core/components/Login/LoginForm.tsx index 66ceb149afe93..cfc706ad62cdd 100644 --- a/public/app/core/components/Login/LoginForm.tsx +++ b/public/app/core/components/Login/LoginForm.tsx @@ -1,9 +1,11 @@ import { css } from '@emotion/css'; import React, { ReactElement, useId } from 'react'; +import { useForm } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { Button, Form, Input, Field, useStyles2 } from '@grafana/ui'; +import { Button, Input, Field, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { PasswordField } from '../PasswordField/PasswordField'; @@ -21,42 +23,51 @@ export const LoginForm = ({ children, onSubmit, isLoggingIn, passwordHint, login const styles = useStyles2(getStyles); const usernameId = useId(); const passwordId = useId(); + const { + handleSubmit, + register, + formState: { errors }, + } = useForm<FormModel>({ mode: 'onChange' }); return ( <div className={styles.wrapper}> - <Form onSubmit={onSubmit} validateOn="onChange"> - {({ register, errors }) => ( - <> - <Field label="Email or username" invalid={!!errors.user} error={errors.user?.message}> - <Input - {...register('user', { required: 'Email or username is required' })} - id={usernameId} - autoFocus - autoCapitalize="none" - placeholder={loginHint} - data-testid={selectors.pages.Login.username} - /> - </Field> - <Field label="Password" invalid={!!errors.password} error={errors.password?.message}> - <PasswordField - {...register('password', { required: 'Password is required' })} - id={passwordId} - autoComplete="current-password" - placeholder={passwordHint} - /> - </Field> - <Button - type="submit" - data-testid={selectors.pages.Login.submit} - className={styles.submitButton} - disabled={isLoggingIn} - > - {isLoggingIn ? 'Logging in...' : 'Log in'} - </Button> - {children} - </> - )} - </Form> + <form onSubmit={handleSubmit(onSubmit)}> + <Field + label={t('login.form.username-label', 'Email or username')} + invalid={!!errors.user} + error={errors.user?.message} + > + <Input + {...register('user', { required: t('login.form.username-required', 'Email or username is required') })} + id={usernameId} + autoFocus + autoCapitalize="none" + placeholder={loginHint} + data-testid={selectors.pages.Login.username} + /> + </Field> + <Field + label={t('login.form.password-label', 'Password')} + invalid={!!errors.password} + error={errors.password?.message} + > + <PasswordField + {...register('password', { required: t('login.form.password-required', 'Password is required') })} + id={passwordId} + autoComplete="current-password" + placeholder={passwordHint} + /> + </Field> + <Button + type="submit" + data-testid={selectors.pages.Login.submit} + className={styles.submitButton} + disabled={isLoggingIn} + > + {isLoggingIn ? t('login.form.submit-loading-label', 'Logging in...') : t('login.form.submit-label', 'Log in')} + </Button> + {children} + </form> </div> ); }; diff --git a/public/app/core/components/Login/LoginPage.test.tsx b/public/app/core/components/Login/LoginPage.test.tsx index a065b05ce71b5..40e10cd2292d1 100644 --- a/public/app/core/components/Login/LoginPage.test.tsx +++ b/public/app/core/components/Login/LoginPage.test.tsx @@ -14,6 +14,9 @@ jest.mock('@grafana/runtime', () => ({ post: postMock, }), config: { + auth: { + disableLogin: false, + }, loginError: false, buildInfo: { version: 'v1.0', diff --git a/public/app/core/components/Login/LoginPage.tsx b/public/app/core/components/Login/LoginPage.tsx index cc408a17a43a5..bdefbfd569670 100644 --- a/public/app/core/components/Login/LoginPage.tsx +++ b/public/app/core/components/Login/LoginPage.tsx @@ -4,10 +4,10 @@ import React from 'react'; // Components import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { Alert, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui'; import { Branding } from 'app/core/components/Branding/Branding'; -import config from 'app/core/config'; -import { t } from 'app/core/internationalization'; +import { t, Trans } from 'app/core/internationalization'; import { ChangePassword } from '../ForgottenPassword/ChangePassword'; @@ -48,13 +48,15 @@ export const LoginPage = () => { {!disableLoginForm && ( <LoginForm onSubmit={login} loginHint={loginHint} passwordHint={passwordHint} isLoggingIn={isLoggingIn}> <HorizontalGroup justify="flex-end"> - <LinkButton - className={styles.forgottenPassword} - fill="text" - href={`${config.appSubUrl}/user/password/send-reset-email`} - > - Forgot your password? - </LinkButton> + {!config.auth.disableLogin && ( + <LinkButton + className={styles.forgottenPassword} + fill="text" + href={`${config.appSubUrl}/user/password/send-reset-email`} + > + <Trans i18nKey="login.forgot-password">Forgot your password?</Trans> + </LinkButton> + )} </HorizontalGroup> </LoginForm> )} diff --git a/public/app/core/components/Login/LoginServiceButtons.tsx b/public/app/core/components/Login/LoginServiceButtons.tsx index eefd6e61669b6..084852c348066 100644 --- a/public/app/core/components/Login/LoginServiceButtons.tsx +++ b/public/app/core/components/Login/LoginServiceButtons.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { GrafanaTheme2, DEFAULT_SAML_NAME } from '@grafana/data'; import { Icon, IconName, LinkButton, useStyles2, useTheme2, VerticalGroup } from '@grafana/ui'; import config from 'app/core/config'; +import { Trans } from 'app/core/internationalization'; export interface LoginService { bgColor: string; @@ -150,18 +151,21 @@ export const LoginServiceButtons = () => { return ( <VerticalGroup> <LoginDivider /> - {Object.entries(enabledServices).map(([key, service]) => ( - <LinkButton - key={key} - className={getButtonStyleFor(service, styles, theme)} - href={`login/${service.hrefName ? service.hrefName : key}`} - target="_self" - fullWidth - > - <Icon className={styles.buttonIcon} name={service.icon} /> - Sign in with {service.name} - </LinkButton> - ))} + {Object.entries(enabledServices).map(([key, service]) => { + const serviceName = service.name; + return ( + <LinkButton + key={key} + className={getButtonStyleFor(service, styles, theme)} + href={`login/${service.hrefName ? service.hrefName : key}`} + target="_self" + fullWidth + > + <Icon className={styles.buttonIcon} name={service.icon} /> + <Trans i18nKey="login.services.sing-in-with-prefix">Sign in with {{ serviceName }}</Trans> + </LinkButton> + ); + })} </VerticalGroup> ); } diff --git a/public/app/core/components/Login/UserSignup.tsx b/public/app/core/components/Login/UserSignup.tsx index 56559860549e5..7b039d59113d9 100644 --- a/public/app/core/components/Login/UserSignup.tsx +++ b/public/app/core/components/Login/UserSignup.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { LinkButton, VerticalGroup } from '@grafana/ui'; import { getConfig } from 'app/core/config'; +import { Trans } from 'app/core/internationalization'; export const UserSignup = () => { const href = getConfig().verifyEmailEnabled ? `${getConfig().appSubUrl}/verify` : `${getConfig().appSubUrl}/signup`; @@ -10,7 +11,9 @@ export const UserSignup = () => { return ( <VerticalGroup> - <div className={paddingTop}>New to Grafana?</div> + <div className={paddingTop}> + <Trans i18nKey="login.signup.new-to-question">New to Grafana?</Trans> + </div> <LinkButton className={css({ width: '100%', @@ -20,7 +23,7 @@ export const UserSignup = () => { variant="secondary" fill="outline" > - Sign up + <Trans i18nKey="login.signup.button-label">Sign up</Trans> </LinkButton> </VerticalGroup> ); diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx index f0874362c93ed..7de1643f74dbe 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx @@ -6,7 +6,6 @@ import InfiniteLoader from 'react-window-infinite-loader'; import { GrafanaTheme2 } from '@grafana/data'; import { IconButton, useStyles2 } from '@grafana/ui'; -import { getSvgSize } from '@grafana/ui/src/components/Icon/utils'; import { Text } from '@grafana/ui/src/components/Text/Text'; import { Indent } from 'app/core/components/Indent/Indent'; import { Trans } from 'app/core/internationalization'; @@ -191,6 +190,7 @@ function Row({ index, style: virtualStyles, data }: RowProps) { > <div className={styles.rowBody}> <Indent level={level} spacing={2} /> + {foldersAreOpenable ? ( <IconButton size={CHEVRON_SIZE} @@ -237,9 +237,8 @@ const getStyles = (theme: GrafanaTheme2) => { width: '100%', }), - // Should be the same size as the <IconButton /> for proper alignment folderButtonSpacer: css({ - paddingLeft: `calc(${getSvgSize(CHEVRON_SIZE)}px + ${theme.spacing(0.5)})`, + paddingLeft: theme.spacing(0.5), }), row: css({ diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx index fc71b260d5ccc..a6cb752fb5214 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx @@ -1,11 +1,12 @@ import 'whatwg-fetch'; // fetch polyfill import { fireEvent, render as rtlRender, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { SetupServer, setupServer } from 'msw/node'; import React from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; +import { config } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; import { wellFormedTree } from '../../../features/browse-dashboards/fixtures/dashboardsTreeItem.fixture'; @@ -45,17 +46,37 @@ describe('NestedFolderPicker', () => { beforeAll(() => { window.HTMLElement.prototype.scrollIntoView = function () {}; + server = setupServer( - rest.get('/api/folders/:uid', (_, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - title: folderA.item.title, - uid: folderA.item.uid, + http.get('/api/folders/:uid', () => { + return HttpResponse.json({ + title: folderA.item.title, + uid: folderA.item.uid, + }); + }), + + http.get('/api/folders', ({ request }) => { + const url = new URL(request.url); + const parentUid = url.searchParams.get('parentUid') ?? undefined; + + const limit = parseInt(url.searchParams.get('limit') ?? '1000', 10); + const page = parseInt(url.searchParams.get('page') ?? '1', 10); + + // reconstruct a folder API response from the flat tree fixture + const folders = mockTree + .filter((v) => v.item.kind === 'folder' && v.item.parentUID === parentUid) + .map((folder) => { + return { + uid: folder.item.uid, + title: folder.item.kind === 'folder' ? folder.item.title : "invalid - this shouldn't happen", + }; }) - ); + .slice(limit * (page - 1), limit * page); + + return HttpResponse.json(folders); }) ); + server.listen(); }); @@ -112,6 +133,13 @@ describe('NestedFolderPicker', () => { expect(mockOnChange).toHaveBeenCalledWith(folderA.item.uid, folderA.item.title); }); + it('can clear a selection if clearable is specified', async () => { + render(<NestedFolderPicker clearable value={folderA.item.uid} onChange={mockOnChange} />); + + await userEvent.click(await screen.findByRole('button', { name: 'Clear selection' })); + expect(mockOnChange).toHaveBeenCalledWith(undefined, undefined); + }); + it('can select a folder from the picker with the keyboard', async () => { render(<NestedFolderPicker onChange={mockOnChange} />); const button = await screen.findByRole('button', { name: 'Select folder' }); @@ -122,7 +150,7 @@ describe('NestedFolderPicker', () => { expect(mockOnChange).toHaveBeenCalledWith(folderA.item.uid, folderA.item.title); }); - it('can expand and collapse a folder to show its children', async () => { + it('shows the root folder by default', async () => { render(<NestedFolderPicker onChange={mockOnChange} />); // Open the picker and wait for children to load @@ -130,52 +158,137 @@ describe('NestedFolderPicker', () => { await userEvent.click(button); await screen.findByLabelText(folderA.item.title); - // Expand Folder A - // Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly - fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` })); - - // Folder A's children are visible - expect(await screen.findByLabelText(folderA_folderA.item.title)).toBeInTheDocument(); - expect(await screen.findByLabelText(folderA_folderB.item.title)).toBeInTheDocument(); + await userEvent.click(screen.getByLabelText('Dashboards')); + expect(mockOnChange).toHaveBeenCalledWith('', 'Dashboards'); + }); - // Collapse Folder A - // Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly - fireEvent.mouseDown(screen.getByRole('button', { name: `Collapse folder ${folderA.item.title}` })); - expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument(); - expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument(); + it('hides the root folder if the prop says so', async () => { + render(<NestedFolderPicker showRootFolder={false} onChange={mockOnChange} />); - // Expand Folder A again - // Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly - fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` })); + // Open the picker and wait for children to load + const button = await screen.findByRole('button', { name: 'Select folder' }); + await userEvent.click(button); + await screen.findByLabelText(folderA.item.title); - // Select the first child - await userEvent.click(screen.getByLabelText(folderA_folderA.item.title)); - expect(mockOnChange).toHaveBeenCalledWith(folderA_folderA.item.uid, folderA_folderA.item.title); + expect(screen.queryByLabelText('Dashboards')).not.toBeInTheDocument(); }); - it('can expand and collapse a folder to show its children with the keyboard', async () => { - render(<NestedFolderPicker onChange={mockOnChange} />); - const button = await screen.findByRole('button', { name: 'Select folder' }); + it('hides folders specififed by UID', async () => { + render(<NestedFolderPicker excludeUIDs={[folderA.item.uid]} onChange={mockOnChange} />); + // Open the picker and wait for children to load + const button = await screen.findByRole('button', { name: 'Select folder' }); await userEvent.click(button); + await screen.findByLabelText(folderB.item.title); + + expect(screen.queryByLabelText(folderA.item.title)).not.toBeInTheDocument(); + }); + + describe('when nestedFolders is enabled', () => { + let originalToggles = { ...config.featureToggles }; + + beforeAll(() => { + config.featureToggles.nestedFolders = true; + }); + + afterAll(() => { + config.featureToggles = originalToggles; + }); + + it('can expand and collapse a folder to show its children', async () => { + render(<NestedFolderPicker onChange={mockOnChange} />); + + // Open the picker and wait for children to load + const button = await screen.findByRole('button', { name: 'Select folder' }); + await userEvent.click(button); + await screen.findByLabelText(folderA.item.title); + + // Expand Folder A + // Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly + fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` })); + + // Folder A's children are visible + expect(await screen.findByLabelText(folderA_folderA.item.title)).toBeInTheDocument(); + expect(await screen.findByLabelText(folderA_folderB.item.title)).toBeInTheDocument(); + + // Collapse Folder A + // Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly + fireEvent.mouseDown(screen.getByRole('button', { name: `Collapse folder ${folderA.item.title}` })); + expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument(); + + // Expand Folder A again + // Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly + fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` })); + + // Select the first child + await userEvent.click(screen.getByLabelText(folderA_folderA.item.title)); + expect(mockOnChange).toHaveBeenCalledWith(folderA_folderA.item.uid, folderA_folderA.item.title); + }); + + it('can expand and collapse a folder to show its children with the keyboard', async () => { + render(<NestedFolderPicker onChange={mockOnChange} />); + const button = await screen.findByRole('button', { name: 'Select folder' }); + + await userEvent.click(button); + + // Expand Folder A + await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}'); + + // Folder A's children are visible + expect(screen.getByLabelText(folderA_folderA.item.title)).toBeInTheDocument(); + expect(screen.getByLabelText(folderA_folderB.item.title)).toBeInTheDocument(); + + // Collapse Folder A + await userEvent.keyboard('{ArrowLeft}'); + expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument(); + + // Expand Folder A again + await userEvent.keyboard('{ArrowRight}'); + + // Select the first child + await userEvent.keyboard('{ArrowDown}{Enter}'); + expect(mockOnChange).toHaveBeenCalledWith(folderA_folderA.item.uid, folderA_folderA.item.title); + }); + }); + + describe('when nestedFolders is disabled', () => { + let originalToggles = { ...config.featureToggles }; + + beforeAll(() => { + config.featureToggles.nestedFolders = false; + }); + + afterAll(() => { + config.featureToggles = originalToggles; + }); + + it('does not show an expand button', async () => { + render(<NestedFolderPicker onChange={mockOnChange} />); + + // Open the picker and wait for children to load + const button = await screen.findByRole('button', { name: 'Select folder' }); + await userEvent.click(button); + await screen.findByLabelText(folderA.item.title); - // Expand Folder A - await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}'); + // There should be no expand button + // Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly + expect(screen.queryByRole('button', { name: `Expand folder ${folderA.item.title}` })).not.toBeInTheDocument(); + }); - // Folder A's children are visible - expect(screen.getByLabelText(folderA_folderA.item.title)).toBeInTheDocument(); - expect(screen.getByLabelText(folderA_folderB.item.title)).toBeInTheDocument(); + it('does not expand a folder with the keyboard', async () => { + render(<NestedFolderPicker onChange={mockOnChange} />); + const button = await screen.findByRole('button', { name: 'Select folder' }); - // Collapse Folder A - await userEvent.keyboard('{ArrowLeft}'); - expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument(); - expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument(); + await userEvent.click(button); - // Expand Folder A again - await userEvent.keyboard('{ArrowRight}'); + // try to expand Folder A + await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}'); - // Select the first child - await userEvent.keyboard('{ArrowDown}{Enter}'); - expect(mockOnChange).toHaveBeenCalledWith(folderA_folderA.item.uid, folderA_folderA.item.title); + // Folder A's children are not visible + expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument(); + }); }); }); diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx index baff647e306ab..1f2ab2739d8c1 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx @@ -1,31 +1,22 @@ import { css } from '@emotion/css'; -import React, { useCallback, useId, useMemo, useState } from 'react'; -import { usePopperTooltip } from 'react-popper-tooltip'; -import { useAsync } from 'react-use'; +import { autoUpdate, flip, useClick, useDismiss, useFloating, useInteractions } from '@floating-ui/react'; +import debounce from 'debounce-promise'; +import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { Alert, Icon, Input, LoadingBar, useStyles2 } from '@grafana/ui'; import { t } from 'app/core/internationalization'; import { skipToken, useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; -import { PAGE_SIZE } from 'app/features/browse-dashboards/api/services'; -import { - childrenByParentUIDSelector, - createFlatTree, - fetchNextChildrenPage, - rootItemsSelector, - useBrowseLoadingStatus, - useLoadNextChildrenPage, -} from 'app/features/browse-dashboards/state'; -import { getPaginationPlaceholders } from 'app/features/browse-dashboards/state/utils'; -import { DashboardViewItemCollection } from 'app/features/browse-dashboards/types'; -import { getGrafanaSearcher } from 'app/features/search/service'; +import { DashboardViewItemWithUIItems, DashboardsTreeItem } from 'app/features/browse-dashboards/types'; +import { QueryResponse, getGrafanaSearcher } from 'app/features/search/service'; import { queryResultToViewItem } from 'app/features/search/service/utils'; import { DashboardViewItem } from 'app/features/search/types'; -import { useDispatch, useSelector } from 'app/types/store'; import { getDOMId, NestedFolderList } from './NestedFolderList'; import Trigger from './Trigger'; -import { useTreeInteractions } from './hooks'; +import { ROOT_FOLDER_ITEM, useFoldersQuery } from './useFoldersQuery'; +import { useTreeInteractions } from './useTreeInteractions'; export interface NestedFolderPickerProps { /* Folder UID to show as selected */ @@ -41,57 +32,91 @@ export interface NestedFolderPickerProps { excludeUIDs?: string[]; /* Callback for when the user selects a folder */ - onChange?: (folderUID: string, folderName: string) => void; + onChange?: (folderUID: string | undefined, folderName: string | undefined) => void; + + /* Whether the picker should be clearable */ + clearable?: boolean; } -const EXCLUDED_KINDS = ['empty-folder' as const, 'dashboard' as const]; +const debouncedSearch = debounce(getSearchResults, 300); + +async function getSearchResults(searchQuery: string) { + const queryResponse = await getGrafanaSearcher().search({ + query: searchQuery, + kind: ['folder'], + limit: 100, + }); + + const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view)); + return { ...queryResponse, items }; +} export function NestedFolderPicker({ value, invalid, showRootFolder = true, + clearable = false, excludeUIDs, onChange, }: NestedFolderPickerProps) { const styles = useStyles2(getStyles); - const dispatch = useDispatch(); const selectedFolder = useGetFolderQuery(value || skipToken); - const rootStatus = useBrowseLoadingStatus(undefined); + const nestedFoldersEnabled = Boolean(config.featureToggles.nestedFolders); const [search, setSearch] = useState(''); + const [searchResults, setSearchResults] = useState<(QueryResponse & { items: DashboardViewItem[] }) | null>(null); + const [isFetchingSearchResults, setIsFetchingSearchResults] = useState(false); const [autoFocusButton, setAutoFocusButton] = useState(false); const [overlayOpen, setOverlayOpen] = useState(false); - const [folderOpenState, setFolderOpenState] = useState<Record<string, boolean>>({}); + const [foldersOpenState, setFoldersOpenState] = useState<Record<string, boolean>>({}); const overlayId = useId(); const [error] = useState<Error | undefined>(undefined); // TODO: error not populated anymore + const lastSearchTimestamp = useRef<number>(0); + + const isBrowsing = Boolean(overlayOpen && !(search && searchResults)); + const { + items: browseFlatTree, + isLoading: isBrowseLoading, + requestNextPage: fetchFolderPage, + } = useFoldersQuery(isBrowsing, foldersOpenState); - const searchState = useAsync(async () => { + useEffect(() => { if (!search) { - return undefined; + setSearchResults(null); + return; } - const searcher = getGrafanaSearcher(); - const queryResponse = await searcher.search({ - query: search, - kind: ['folder'], - limit: 100, - }); - - const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view)); - return { ...queryResponse, items }; + const timestamp = Date.now(); + setIsFetchingSearchResults(true); + + debouncedSearch(search).then((queryResponse) => { + // Only keep the results if it's was issued after the most recently resolved search. + // This prevents results showing out of order if first request is slower than later ones. + // We don't need to worry about clearing the isFetching state either - if there's a later + // request in progress, this will clear it for us + if (timestamp > lastSearchTimestamp.current) { + const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view)); + setSearchResults({ ...queryResponse, items }); + setIsFetchingSearchResults(false); + lastSearchTimestamp.current = timestamp; + } + }); }, [search]); - const rootCollection = useSelector(rootItemsSelector); - const childrenCollections = useSelector(childrenByParentUIDSelector); + // the order of middleware is important! + const middleware = [ + flip({ + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + ]; - const { getTooltipProps, setTooltipRef, setTriggerRef, visible, triggerRef } = usePopperTooltip({ - visible: overlayOpen, + const { context, refs, floatingStyles, elements } = useFloating({ + open: overlayOpen, placement: 'bottom', - interactive: true, - offset: [0, 0], - trigger: 'click', - onVisibleChange: (value: boolean) => { + onOpenChange: (value) => { // ensure state is clean on opening the overlay if (value) { setSearch(''); @@ -99,17 +124,24 @@ export function NestedFolderPicker({ } setOverlayOpen(value); }, + middleware, + whileElementsMounted: autoUpdate, }); + const click = useClick(context); + const dismiss = useDismiss(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]); + const handleFolderExpand = useCallback( async (uid: string, newOpenState: boolean) => { - setFolderOpenState((old) => ({ ...old, [uid]: newOpenState })); + setFoldersOpenState((old) => ({ ...old, [uid]: newOpenState })); - if (newOpenState && !folderOpenState[uid]) { - dispatch(fetchNextChildrenPage({ parentUID: uid, pageSize: PAGE_SIZE, excludeKinds: EXCLUDED_KINDS })); + if (newOpenState && !foldersOpenState[uid]) { + fetchFolderPage(uid); } }, - [dispatch, folderOpenState] + [fetchFolderPage, foldersOpenState] ); const handleFolderSelect = useCallback( @@ -122,69 +154,66 @@ export function NestedFolderPicker({ [onChange] ); + const handleClearSelection = useCallback( + (event: React.MouseEvent<SVGElement> | React.KeyboardEvent<SVGElement>) => { + event.preventDefault(); + event.stopPropagation(); + if (onChange) { + onChange(undefined, undefined); + } + }, + [onChange] + ); + const handleCloseOverlay = useCallback(() => setOverlayOpen(false), [setOverlayOpen]); - const baseHandleLoadMore = useLoadNextChildrenPage(EXCLUDED_KINDS); const handleLoadMore = useCallback( (folderUID: string | undefined) => { if (search) { return; } - baseHandleLoadMore(folderUID); + fetchFolderPage(folderUID); }, - [search, baseHandleLoadMore] + [search, fetchFolderPage] ); const flatTree = useMemo(() => { - const searchResults = search && searchState.value; - - if (searchResults) { - const searchCollection: DashboardViewItemCollection = { - isFullyLoaded: true, //searchResults.items.length === searchResults.totalRows, - lastKindHasMoreItems: false, // TODO: paginate search - lastFetchedKind: 'folder', // TODO: paginate search - lastFetchedPage: 1, // TODO: paginate search - items: searchResults.items ?? [], - }; - - return createFlatTree(undefined, searchCollection, childrenCollections, {}, 0, EXCLUDED_KINDS, excludeUIDs); + let flatTree: Array<DashboardsTreeItem<DashboardViewItemWithUIItems>> = []; + + if (isBrowsing) { + flatTree = browseFlatTree; + } else { + flatTree = + searchResults?.items.map((item) => ({ + isOpen: false, + level: 0, + item: { + kind: 'folder' as const, + title: item.title, + uid: item.uid, + }, + })) ?? []; } - let flatTree = createFlatTree( - undefined, - rootCollection, - childrenCollections, - folderOpenState, - 0, - EXCLUDED_KINDS, - excludeUIDs - ); + // It's not super optimal to filter these in an additional iteration, but + // these options are used infrequently that its not a big deal + if (!showRootFolder || excludeUIDs?.length) { + flatTree = flatTree.filter((item) => { + if (!showRootFolder && item === ROOT_FOLDER_ITEM) { + return false; + } - if (showRootFolder) { - // Increase the level of each item to 'make way' for the fake root Dashboards item - for (const item of flatTree) { - item.level += 1; - } + if (excludeUIDs?.includes(item.item.uid)) { + return false; + } - flatTree.unshift({ - isOpen: true, - level: 0, - item: { - kind: 'folder', - title: 'Dashboards', - uid: '', - }, + return true; }); } - // If the root collection hasn't loaded yet, create loading placeholders - if (!rootCollection) { - flatTree = flatTree.concat(getPaginationPlaceholders(PAGE_SIZE, undefined, 0)); - } - return flatTree; - }, [search, searchState.value, rootCollection, childrenCollections, folderOpenState, excludeUIDs, showRootFolder]); + }, [browseFlatTree, excludeUIDs, isBrowsing, searchResults?.items, showRootFolder]); const isItemLoaded = useCallback( (itemIndex: number) => { @@ -192,6 +221,7 @@ export function NestedFolderPicker({ if (!treeItem) { return false; } + const item = treeItem.item; const result = !(item.kind === 'ui' && item.uiKind === 'pagination-placeholder'); @@ -200,7 +230,7 @@ export function NestedFolderPicker({ [flatTree] ); - const isLoading = rootStatus === 'pending' || searchState.loading; + const isLoading = isBrowseLoading || isFetchingSearchResults; const { focusedItemIndex, handleKeyDown } = useTreeInteractions({ tree: flatTree, @@ -209,7 +239,7 @@ export function NestedFolderPicker({ handleFolderExpand, idPrefix: overlayId, search, - visible, + visible: overlayOpen, }); let label = selectedFolder.data?.title; @@ -217,14 +247,15 @@ export function NestedFolderPicker({ label = 'Dashboards'; } - if (!visible) { + if (!overlayOpen) { return ( <Trigger label={label} + handleClearSelection={clearable && value !== undefined ? handleClearSelection : undefined} invalid={invalid} isLoading={selectedFolder.isLoading} autoFocus={autoFocusButton} - ref={setTriggerRef} + ref={refs.setReference} aria-label={ label ? t('browse-dashboards.folder-picker.accessible-label', 'Select folder: {{ label }} currently selected', { @@ -232,6 +263,7 @@ export function NestedFolderPicker({ }) : undefined } + {...getReferenceProps()} /> ); } @@ -239,14 +271,13 @@ export function NestedFolderPicker({ return ( <> <Input - ref={setTriggerRef} + ref={refs.setReference} autoFocus prefix={label ? <Icon name="folder" /> : null} placeholder={label ?? t('browse-dashboards.folder-picker.search-placeholder', 'Search folders')} value={search} invalid={invalid} className={styles.search} - onKeyDown={handleKeyDown} onChange={(e) => setSearch(e.currentTarget.value)} aria-autocomplete="list" aria-expanded @@ -256,16 +287,18 @@ export function NestedFolderPicker({ aria-activedescendant={getDOMId(overlayId, flatTree[focusedItemIndex]?.item.uid)} role="combobox" suffix={<Icon name="search" />} + {...getReferenceProps()} + onKeyDown={handleKeyDown} /> <fieldset - ref={setTooltipRef} + ref={refs.setFloating} id={overlayId} - {...getTooltipProps({ - className: styles.tableWrapper, - style: { - width: triggerRef?.clientWidth, - }, - })} + className={styles.tableWrapper} + style={{ + ...floatingStyles, + width: elements.domReference?.clientWidth, + }} + {...getFloatingProps()} > {error ? ( <Alert @@ -290,7 +323,7 @@ export function NestedFolderPicker({ onFolderExpand={handleFolderExpand} onFolderSelect={handleFolderSelect} idPrefix={overlayId} - foldersAreOpenable={!(search && searchState.value)} + foldersAreOpenable={nestedFoldersEnabled && !(search && searchResults)} isItemLoaded={isItemLoaded} requestLoadMore={handleLoadMore} /> diff --git a/public/app/core/components/NestedFolderPicker/Trigger.tsx b/public/app/core/components/NestedFolderPicker/Trigger.tsx index f1ed9974d9cd4..8b25bd9b32fee 100644 --- a/public/app/core/components/NestedFolderPicker/Trigger.tsx +++ b/public/app/core/components/NestedFolderPicker/Trigger.tsx @@ -4,19 +4,29 @@ import Skeleton from 'react-loading-skeleton'; import { GrafanaTheme2 } from '@grafana/data'; import { Icon, getInputStyles, useTheme2, Text } from '@grafana/ui'; -import { focusCss } from '@grafana/ui/src/themes/mixins'; -import { Trans } from 'app/core/internationalization'; +import { focusCss, getFocusStyles, getMouseFocusStyles } from '@grafana/ui/src/themes/mixins'; +import { Trans, t } from 'app/core/internationalization'; interface TriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> { isLoading: boolean; + handleClearSelection?: (event: React.MouseEvent<SVGElement> | React.KeyboardEvent<SVGElement>) => void; invalid?: boolean; label?: ReactNode; } -function Trigger({ isLoading, invalid, label, ...rest }: TriggerProps, ref: React.ForwardedRef<HTMLButtonElement>) { +function Trigger( + { handleClearSelection, isLoading, invalid, label, ...rest }: TriggerProps, + ref: React.ForwardedRef<HTMLButtonElement> +) { const theme = useTheme2(); const styles = getStyles(theme, invalid); + const handleKeyDown = (event: React.KeyboardEvent<SVGElement>) => { + if (event.key === 'Enter' || event.key === ' ') { + handleClearSelection?.(event); + } + }; + return ( <div className={styles.wrapper}> <div className={styles.inputWrapper}> @@ -41,6 +51,18 @@ function Trigger({ isLoading, invalid, label, ...rest }: TriggerProps, ref: Reac <Trans i18nKey="browse-dashboards.folder-picker.button-label">Select folder</Trans> </Text> )} + + {!isLoading && handleClearSelection && ( + <Icon + role="button" + tabIndex={0} + aria-label={t('browse-dashboards.folder-picker.clear-selection', 'Clear selection')} + className={styles.clearIcon} + name="times" + onClick={handleClearSelection} + onKeyDown={handleKeyDown} + /> + )} </button> <div className={styles.suffix}> @@ -92,11 +114,26 @@ const getStyles = (theme: GrafanaTheme2, invalid = false) => { '&:focus-visible': css` ${focusCss(theme)} `, + alignItems: 'center', + display: 'flex', + flexWrap: 'nowrap', + justifyContent: 'space-between', + paddingRight: 28, }, ]), hasPrefix: css({ paddingLeft: 28, }), + + clearIcon: css({ + color: theme.colors.text.secondary, + cursor: 'pointer', + '&:hover': { + color: theme.colors.text.primary, + }, + '&:focus:not(:focus-visible)': getMouseFocusStyles(theme), + '&:focus-visible': getFocusStyles(theme), + }), }; }; diff --git a/public/app/core/components/NestedFolderPicker/useFoldersQuery.ts b/public/app/core/components/NestedFolderPicker/useFoldersQuery.ts new file mode 100644 index 0000000000000..528ca80ab1c6a --- /dev/null +++ b/public/app/core/components/NestedFolderPicker/useFoldersQuery.ts @@ -0,0 +1,201 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { QueryDefinition, BaseQueryFn } from '@reduxjs/toolkit/dist/query'; +import { QueryActionCreatorResult } from '@reduxjs/toolkit/dist/query/core/buildInitiate'; +import { RequestOptions } from 'http'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { ListFolderQueryArgs, browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; +import { PAGE_SIZE } from 'app/features/browse-dashboards/api/services'; +import { getPaginationPlaceholders } from 'app/features/browse-dashboards/state/utils'; +import { DashboardViewItemWithUIItems, DashboardsTreeItem } from 'app/features/browse-dashboards/types'; +import { RootState } from 'app/store/configureStore'; +import { FolderListItemDTO } from 'app/types'; +import { useDispatch, useSelector } from 'app/types/store'; + +type ListFoldersQuery = ReturnType<ReturnType<typeof browseDashboardsAPI.endpoints.listFolders.select>>; +type ListFoldersRequest = QueryActionCreatorResult< + QueryDefinition< + ListFolderQueryArgs, + BaseQueryFn<RequestOptions>, + 'getFolder', + FolderListItemDTO[], + 'browseDashboardsAPI' + > +>; + +const listFoldersSelector = createSelector( + (state: RootState) => state, + ( + state: RootState, + parentUid: ListFolderQueryArgs['parentUid'], + page: ListFolderQueryArgs['page'], + limit: ListFolderQueryArgs['limit'] + ) => browseDashboardsAPI.endpoints.listFolders.select({ parentUid, page, limit }), + (state, selectFolderList) => selectFolderList(state) +); + +const listAllFoldersSelector = createSelector( + [(state: RootState) => state, (state: RootState, requests: ListFoldersRequest[]) => requests], + (state: RootState, requests: ListFoldersRequest[]) => { + const seenRequests = new Set<string>(); + + const rootPages: ListFoldersQuery[] = []; + const pagesByParent: Record<string, ListFoldersQuery[]> = {}; + let isLoading = false; + + for (const req of requests) { + if (seenRequests.has(req.requestId)) { + continue; + } + + const page = listFoldersSelector(state, req.arg.parentUid, req.arg.page, req.arg.limit); + if (page.status === 'pending') { + isLoading = true; + } + + const parentUid = page.originalArgs?.parentUid; + if (parentUid) { + if (!pagesByParent[parentUid]) { + pagesByParent[parentUid] = []; + } + + pagesByParent[parentUid].push(page); + } else { + rootPages.push(page); + } + } + + return { + isLoading, + rootPages, + pagesByParent, + }; + } +); + +/** + * Returns the whether the set of pages are 'fully loaded', and the last page number + */ +function getPagesLoadStatus(pages: ListFoldersQuery[]): [boolean, number | undefined] { + const lastPage = pages.at(-1); + const lastPageNumber = lastPage?.originalArgs?.page; + + if (!lastPage?.data) { + // If there's no pages yet, or the last page is still loading + return [false, lastPageNumber]; + } else { + return [lastPage.data.length < lastPage.originalArgs.limit, lastPageNumber]; + } +} + +/** + * Returns a loaded folder hierarchy as a flat list and a function to load more pages. + */ +export function useFoldersQuery(isBrowsing: boolean, openFolders: Record<string, boolean>) { + const dispatch = useDispatch(); + + // Keep a list of all requests so we can + // a) unsubscribe from them when the component is unmounted + // b) use them to select the responses out of the state + const requestsRef = useRef<ListFoldersRequest[]>([]); + + const state = useSelector((rootState: RootState) => { + return listAllFoldersSelector(rootState, requestsRef.current); + }); + + // Loads the next page of folders for the given parent UID by inspecting the + // state to determine what the next page is + const requestNextPage = useCallback( + (parentUid: string | undefined) => { + const pages = parentUid ? state.pagesByParent[parentUid] : state.rootPages; + const [fullyLoaded, pageNumber] = getPagesLoadStatus(pages ?? []); + if (fullyLoaded) { + return; + } + + const args = { parentUid, page: (pageNumber ?? 0) + 1, limit: PAGE_SIZE }; + const promise = dispatch(browseDashboardsAPI.endpoints.listFolders.initiate(args)); + + // It's important that we create a new array so we can correctly memoize with it + requestsRef.current = requestsRef.current.concat([promise]); + }, + [state, dispatch] + ); + + // Unsubscribe from all requests when the component is unmounted + useEffect(() => { + return () => { + for (const req of requestsRef.current) { + req.unsubscribe(); + } + }; + }, []); + + // Convert the individual responses into a flat list of folders, with level indicating + // the depth in the hierarchy. + const treeList = useMemo(() => { + if (!isBrowsing) { + return []; + } + + function createFlatList( + parentUid: string | undefined, + pages: ListFoldersQuery[], + level: number + ): Array<DashboardsTreeItem<DashboardViewItemWithUIItems>> { + const flatList = pages.flatMap((page) => { + const pageItems = page.data ?? []; + + return pageItems.flatMap((item) => { + const folderIsOpen = openFolders[item.uid]; + + const flatItem: DashboardsTreeItem<DashboardViewItemWithUIItems> = { + isOpen: Boolean(folderIsOpen), + level: level, + item: { + kind: 'folder' as const, + title: item.title, + uid: item.uid, + }, + }; + + const childPages = folderIsOpen && state.pagesByParent[item.uid]; + if (childPages) { + const childFlatItems = createFlatList(item.uid, childPages, level + 1); + return [flatItem, ...childFlatItems]; + } + + return flatItem; + }); + }); + + const [fullyLoaded] = getPagesLoadStatus(pages); + if (!fullyLoaded) { + flatList.push(...getPaginationPlaceholders(PAGE_SIZE, parentUid, level)); + } + + return flatList; + } + + const rootFlatTree = createFlatList(undefined, state.rootPages, 1); + rootFlatTree.unshift(ROOT_FOLDER_ITEM); + + return rootFlatTree; + }, [state, isBrowsing, openFolders]); + + return { + items: treeList, + isLoading: state.isLoading, + requestNextPage, + }; +} + +export const ROOT_FOLDER_ITEM = { + isOpen: true, + level: 0, + item: { + kind: 'folder' as const, + title: 'Dashboards', + uid: '', + }, +}; diff --git a/public/app/core/components/NestedFolderPicker/hooks.ts b/public/app/core/components/NestedFolderPicker/useTreeInteractions.ts similarity index 91% rename from public/app/core/components/NestedFolderPicker/hooks.ts rename to public/app/core/components/NestedFolderPicker/useTreeInteractions.ts index 29a76e8c28f36..393d35320e5f7 100644 --- a/public/app/core/components/NestedFolderPicker/hooks.ts +++ b/public/app/core/components/NestedFolderPicker/useTreeInteractions.ts @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; +import { config } from '@grafana/runtime'; import { DashboardsTreeItem } from 'app/features/browse-dashboards/types'; import { DashboardViewItem } from 'app/features/search/types'; @@ -25,6 +26,7 @@ export function useTreeInteractions({ visible, }: TreeInteractionProps) { const [focusedItemIndex, setFocusedItemIndex] = useState(-1); + const nestedFoldersEnabled = Boolean(config.featureToggles.nestedFolders); useEffect(() => { if (visible) { @@ -44,7 +46,7 @@ export function useTreeInteractions({ const handleKeyDown = useCallback( (ev: React.KeyboardEvent<HTMLInputElement>) => { - const foldersAreOpenable = !search; + const foldersAreOpenable = nestedFoldersEnabled && !search; switch (ev.key) { // Expand/collapse folder on right/left arrow keys case 'ArrowRight': @@ -84,7 +86,7 @@ export function useTreeInteractions({ break; } }, - [focusedItemIndex, handleCloseOverlay, handleFolderExpand, handleFolderSelect, search, tree] + [focusedItemIndex, handleCloseOverlay, handleFolderExpand, handleFolderSelect, nestedFoldersEnabled, search, tree] ); return { diff --git a/public/app/core/components/OptionsUI/fieldColor.test.tsx b/public/app/core/components/OptionsUI/fieldColor.test.tsx new file mode 100644 index 0000000000000..316679ab359cf --- /dev/null +++ b/public/app/core/components/OptionsUI/fieldColor.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { FieldColorEditor } from './fieldColor'; + +const testRegistryItems = [ + { + id: 'foo', + name: 'Foo', + description: 'This option will appear in the picker', + getCalculator: () => 'red', + }, + { + id: 'bar', + name: 'Bar', + description: 'This option will also appear in the picker', + getCalculator: () => 'green', + }, + { + id: 'baz', + name: 'Baz', + description: 'This option will not appear in the picker', + getCalculator: () => 'blue', + excludeFromPicker: true, + }, +]; + +jest.mock('@grafana/data', () => { + const actualData = jest.requireActual('@grafana/data'); + return { + ...actualData, + fieldColorModeRegistry: new actualData.Registry(() => testRegistryItems), + }; +}); + +describe('fieldColor', () => { + it('filters out registry options with excludeFromPicker=true', async () => { + render( + <FieldColorEditor + value={undefined} + onChange={() => {}} + id="test" + data-testid="test" + context={{ data: [] }} + item={testRegistryItems[0]} + /> + ); + await userEvent.type(screen.getByRole('combobox'), '{arrowdown}'); + expect(screen.getByText(/^Foo/i)).toBeInTheDocument(); + expect(screen.getByText(/^Bar/i)).toBeInTheDocument(); + expect(screen.queryByText(/^Baz/i)).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/core/components/OptionsUI/fieldColor.tsx b/public/app/core/components/OptionsUI/fieldColor.tsx index bfd8c480bcb99..77e67d73d115b 100644 --- a/public/app/core/components/OptionsUI/fieldColor.tsx +++ b/public/app/core/components/OptionsUI/fieldColor.tsx @@ -28,20 +28,22 @@ export const FieldColorEditor = ({ value, onChange, item, id }: Props) => { ? fieldColorModeRegistry.list() : fieldColorModeRegistry.list().filter((m) => !m.isByValue); - const options = availableOptions.map((mode) => { - let suffix = mode.isByValue ? ' (by value)' : ''; - - return { - value: mode.id, - label: `${mode.name}${suffix}`, - description: mode.description, - isContinuous: mode.isContinuous, - isByValue: mode.isByValue, - component() { - return <FieldColorModeViz mode={mode} theme={theme} />; - }, - }; - }); + const options = availableOptions + .filter((mode) => !mode.excludeFromPicker) + .map((mode) => { + let suffix = mode.isByValue ? ' (by value)' : ''; + + return { + value: mode.id, + label: `${mode.name}${suffix}`, + description: mode.description, + isContinuous: mode.isContinuous, + isByValue: mode.isByValue, + component() { + return <FieldColorModeViz mode={mode} theme={theme} />; + }, + }; + }); const onModeChange = (newMode: SelectableValue<string>) => { onChange({ diff --git a/public/app/core/components/OptionsUI/registry.tsx b/public/app/core/components/OptionsUI/registry.tsx index ec01fd133a5ca..dfe87a7943684 100644 --- a/public/app/core/components/OptionsUI/registry.tsx +++ b/public/app/core/components/OptionsUI/registry.tsx @@ -247,22 +247,7 @@ export const getAllStandardFieldConfigs = () => { category, }; - const unitScale: FieldConfigPropertyItem<unknown, boolean, BooleanFieldSettings> = { - id: 'unitScale', - path: 'unitScale', - name: 'Scale units', - description: 'Automatically scale units relative to magnitude of the value', - - editor: standardEditorsRegistry.get('boolean').editor, - override: standardEditorsRegistry.get('boolean').editor, - process: booleanOverrideProcessor, - - defaultValue: true, - shouldApply: () => true, - category, - }; - - const fieldMinMax: FieldConfigPropertyItem<{ min: number; max: number }, boolean, BooleanFieldSettings> = { + const fieldMinMax: FieldConfigPropertyItem<any, boolean, BooleanFieldSettings> = { id: 'fieldMinMax', path: 'fieldMinMax', name: 'Field min/max', @@ -431,19 +416,5 @@ export const getAllStandardFieldConfigs = () => { category, }; - return [ - unit, - unitScale, - min, - max, - fieldMinMax, - decimals, - displayName, - color, - noValue, - links, - mappings, - thresholds, - filterable, - ]; + return [unit, min, max, fieldMinMax, decimals, displayName, color, noValue, links, mappings, thresholds, filterable]; }; diff --git a/public/app/core/components/OptionsUI/slider.tsx b/public/app/core/components/OptionsUI/slider.tsx index e755c00fdddd1..a734cc99facf5 100644 --- a/public/app/core/components/OptionsUI/slider.tsx +++ b/public/app/core/components/OptionsUI/slider.tsx @@ -94,7 +94,7 @@ export const SliderValueEditor = ({ value, onChange, item }: Props) => { <div className={cx(styles.container, styles.slider)}> {/** Slider tooltip's parent component is body and therefore we need Global component to do css overrides for it. */} <Global styles={styles.slider} /> - <label className={cx(styles.sliderInput, ...sliderInputClassNames)}> + <div className={cx(styles.sliderInput, ...sliderInputClassNames)}> <Slider min={min} max={max} @@ -111,7 +111,7 @@ export const SliderValueEditor = ({ value, onChange, item }: Props) => { <span className={stylesSlider.numberInputWrapper} ref={inputRef}> <NumberInput value={sliderValue} onChange={onSliderInputChange} max={max} min={min} step={step} /> </span> - </label> + </div> </div> ); }; diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx index 3af15a6629c1e..7f017fba48732 100644 --- a/public/app/core/components/Page/Page.tsx +++ b/public/app/core/components/Page/Page.tsx @@ -1,12 +1,12 @@ -// Libraries import { css, cx } from '@emotion/css'; import React, { useLayoutEffect } from 'react'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { CustomScrollbar, useStyles2 } from '@grafana/ui'; +import { useStyles2 } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; +import FlaggedScrollbar from '../FlaggedScroller'; + import { PageContents } from './PageContents'; import { PageHeader } from './PageHeader'; import { PageTabs } from './PageTabs'; @@ -53,7 +53,7 @@ export const Page: PageType = ({ return ( <div className={cx(styles.wrapper, className)} {...otherProps}> {layout === PageLayoutType.Standard && ( - <CustomScrollbar autoHeightMin={'100%'} scrollTop={scrollTop} scrollRefCallback={scrollRef}> + <FlaggedScrollbar autoHeightMin={'100%'} scrollTop={scrollTop} scrollRefCallback={scrollRef}> <div className={styles.pageInner}> {pageHeaderNav && ( <PageHeader @@ -68,13 +68,15 @@ export const Page: PageType = ({ {pageNav && pageNav.children && <PageTabs navItem={pageNav} />} <div className={styles.pageContent}>{children}</div> </div> - </CustomScrollbar> + </FlaggedScrollbar> )} + {layout === PageLayoutType.Canvas && ( - <CustomScrollbar autoHeightMin={'100%'} scrollTop={scrollTop} scrollRefCallback={scrollRef}> + <FlaggedScrollbar autoHeightMin={'100%'} scrollTop={scrollTop} scrollRefCallback={scrollRef}> <div className={styles.canvasContent}>{children}</div> - </CustomScrollbar> + </FlaggedScrollbar> )} + {layout === PageLayoutType.Custom && children} </div> ); @@ -96,34 +98,23 @@ const getStyles = (theme: GrafanaTheme2) => { label: 'page-content', flexGrow: 1, }), - pageInner: css( - { - label: 'page-inner', - padding: theme.spacing(2), - borderBottom: 'none', - background: theme.colors.background.primary, - display: 'flex', - flexDirection: 'column', - flexGrow: 1, - margin: theme.spacing(0, 0, 0, 0), - }, - config.featureToggles.dockedMegaMenu - ? { - [theme.breakpoints.up('md')]: { - padding: theme.spacing(4), - }, - } - : { - borderRadius: theme.shape.radius.default, - border: `1px solid ${theme.colors.border.weak}`, - - [theme.breakpoints.up('md')]: { - margin: theme.spacing(2, 2, 0, 1), - padding: theme.spacing(3), - }, - } - ), + primaryBg: css({ + background: theme.colors.background.primary, + }), + pageInner: css({ + label: 'page-inner', + padding: theme.spacing(2), + borderBottom: 'none', + background: theme.colors.background.primary, + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + margin: theme.spacing(0, 0, 0, 0), + [theme.breakpoints.up('md')]: { + padding: theme.spacing(4), + }, + }), canvasContent: css({ label: 'canvas-content', display: 'flex', diff --git a/public/app/core/components/Page/PageHeader.tsx b/public/app/core/components/Page/PageHeader.tsx index d6f8aba8505b6..23a7796c2c121 100644 --- a/public/app/core/components/Page/PageHeader.tsx +++ b/public/app/core/components/Page/PageHeader.tsx @@ -57,6 +57,7 @@ const getStyles = (theme: GrafanaTheme2) => { title: css({ display: 'flex', flexDirection: 'row', + maxWidth: '100%', h1: { display: 'flex', marginBottom: 0, diff --git a/public/app/core/components/Page/types.ts b/public/app/core/components/Page/types.ts index aa76f10af7377..4705291cbc762 100644 --- a/public/app/core/components/Page/types.ts +++ b/public/app/core/components/Page/types.ts @@ -20,9 +20,15 @@ export interface PageProps extends HTMLAttributes<HTMLDivElement> { subTitle?: React.ReactNode; /** Control the page layout. */ layout?: PageLayoutType; - /** Can be used to get the scroll container element to access scroll position */ + /** + * Can be used to get the scroll container element to access scroll position + * */ + // Probably will deprecate this in the future in favor of just scrolling document.body directly scrollRef?: RefCallback<HTMLDivElement>; - /** Can be used to update the current scroll position */ + /** + * Can be used to update the current scroll position + * */ + // Probably will deprecate this in the future in favor of just scrolling document.body directly scrollTop?: number; } diff --git a/public/app/core/components/PageHeader/PanelHeaderMenuItem.tsx b/public/app/core/components/PageHeader/PanelHeaderMenuItem.tsx index e9d8291a48e68..28d02904d9d66 100644 --- a/public/app/core/components/PageHeader/PanelHeaderMenuItem.tsx +++ b/public/app/core/components/PageHeader/PanelHeaderMenuItem.tsx @@ -33,7 +33,10 @@ export const PanelHeaderMenuItem = (props: Props & PanelMenuItem) => { > <a onClick={props.onClick} href={props.href} role="menuitem"> {icon && <Icon name={icon} className={styles.menuIconClassName} />} - <span className="dropdown-item-text" aria-label={selectors.components.Panels.Panel.headerItems(props.text)}> + <span + className="dropdown-item-text" + data-testid={selectors.components.Panels.Panel.headerItems(props.text)} + > {props.text} {isSubMenu && <Icon name="angle-right" className={styles.shortcutIconClassName} />} </span> diff --git a/public/app/core/components/PageNotFound/EntityNotFound.tsx b/public/app/core/components/PageNotFound/EntityNotFound.tsx index 4ed5d97f59aaa..13bc119e5c7f1 100644 --- a/public/app/core/components/PageNotFound/EntityNotFound.tsx +++ b/public/app/core/components/PageNotFound/EntityNotFound.tsx @@ -2,7 +2,9 @@ import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2, useTheme2 } from '@grafana/ui'; +import { useStyles2 } from '@grafana/ui'; + +import { GrotNotFound } from '../GrotNotFound/GrotNotFound'; export interface Props { /** @@ -13,7 +15,6 @@ export interface Props { export function EntityNotFound({ entity = 'Page' }: Props) { const styles = useStyles2(getStyles); - const theme = useTheme2(); return ( <div className={styles.container}> @@ -29,7 +30,7 @@ export function EntityNotFound({ entity = 'Page' }: Props) { </a> </div> <div className={styles.grot}> - <img src={`public/img/grot-404-${theme.isDark ? 'dark' : 'light'}.svg`} width="100%" alt="grot" /> + <GrotNotFound show404={entity === 'Page'} /> </div> </div> ); @@ -52,9 +53,10 @@ export function getStyles(theme: GrafanaTheme2) { textAlign: 'center', }), grot: css({ + alignSelf: 'center', maxWidth: '450px', paddingTop: theme.spacing(8), - margin: '0 auto', + width: '100%', }), }; } diff --git a/public/app/core/components/QueryOperationRow/QueryOperationAction.test.tsx b/public/app/core/components/QueryOperationRow/QueryOperationAction.test.tsx index b7d583a01a210..5895d97c2013b 100644 --- a/public/app/core/components/QueryOperationRow/QueryOperationAction.test.tsx +++ b/public/app/core/components/QueryOperationRow/QueryOperationAction.test.tsx @@ -2,8 +2,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React, { ComponentPropsWithoutRef } from 'react'; -import { selectors } from '@grafana/e2e-selectors'; - import { QueryOperationAction, QueryOperationToggleAction } from './QueryOperationAction'; describe('QueryOperationAction tests', () => { @@ -22,9 +20,7 @@ describe('QueryOperationAction tests', () => { it('should render component', () => { setup(); - expect( - screen.getByRole('button', { name: selectors.components.QueryEditorRow.actionButton('test') }) - ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'test' })).toBeInTheDocument(); }); it('should call on click handler', async () => { @@ -32,7 +28,7 @@ describe('QueryOperationAction tests', () => { setup({ disabled: false, onClick: clickSpy }); expect(clickSpy).not.toHaveBeenCalled(); - const queryButton = screen.getByRole('button', { name: selectors.components.QueryEditorRow.actionButton('test') }); + const queryButton = screen.getByRole('button', { name: 'test' }); await userEvent.click(queryButton); @@ -44,7 +40,7 @@ describe('QueryOperationAction tests', () => { setup({ disabled: true, onClick: clickSpy }); expect(clickSpy).not.toHaveBeenCalled(); - const queryButton = screen.getByRole('button', { name: selectors.components.QueryEditorRow.actionButton('test') }); + const queryButton = screen.getByRole('button', { name: 'test' }); await userEvent.click(queryButton); @@ -69,7 +65,7 @@ describe('QueryOperationToggleAction', () => { expect( screen.getByRole('button', { - name: selectors.components.QueryEditorRow.actionButton('test'), + name: 'test', pressed: false, }) ).toBeInTheDocument(); @@ -78,7 +74,7 @@ describe('QueryOperationToggleAction', () => { expect( screen.getByRole('button', { - name: selectors.components.QueryEditorRow.actionButton('test'), + name: 'test', pressed: true, }) ).toBeInTheDocument(); diff --git a/public/app/core/components/QueryOperationRow/QueryOperationAction.tsx b/public/app/core/components/QueryOperationRow/QueryOperationAction.tsx index fe69509ac4aa3..c9a0bf2a5431d 100644 --- a/public/app/core/components/QueryOperationRow/QueryOperationAction.tsx +++ b/public/app/core/components/QueryOperationRow/QueryOperationAction.tsx @@ -10,6 +10,7 @@ interface BaseQueryOperationActionProps { title: string; onClick: (e: React.MouseEvent) => void; disabled?: boolean; + dataTestId?: string; } function BaseQueryOperationAction(props: QueryOperationActionProps | QueryOperationToggleActionProps) { @@ -24,7 +25,7 @@ function BaseQueryOperationAction(props: QueryOperationActionProps | QueryOperat disabled={!!props.disabled} onClick={props.onClick} type="button" - aria-label={selectors.components.QueryEditorRow.actionButton(props.title)} + data-testid={props.dataTestId ?? selectors.components.QueryEditorRow.actionButton(props.title)} {...('active' in props && { 'aria-pressed': props.active })} /> </div> @@ -45,23 +46,23 @@ export const QueryOperationToggleAction = (props: QueryOperationToggleActionProp const getStyles = (theme: GrafanaTheme2) => { return { - icon: css` - display: flex; - position: relative; - color: ${theme.colors.text.secondary}; - `, - active: css` - &::before { - display: block; - content: ' '; - position: absolute; - left: -1px; - right: 2px; - height: 3px; - border-radius: ${theme.shape.radius.default}; - bottom: -8px; - background-image: ${theme.colors.gradients.brandHorizontal} !important; - } - `, + icon: css({ + display: 'flex', + position: 'relative', + color: theme.colors.text.secondary, + }), + active: css({ + '&:before': { + display: 'block', + content: '" "', + position: 'absolute', + left: -1, + right: 2, + height: 3, + borderRadius: theme.shape.radius.default, + bottom: -8, + backgroundImage: theme.colors.gradients.brandHorizontal, + }, + }), }; }; diff --git a/public/app/core/components/RolePicker/RolePickerInput.tsx b/public/app/core/components/RolePicker/RolePickerInput.tsx index 672b063d9882a..e5caab4b88707 100644 --- a/public/app/core/components/RolePicker/RolePickerInput.tsx +++ b/public/app/core/components/RolePicker/RolePickerInput.tsx @@ -75,7 +75,7 @@ export const RolePickerInput = ({ <div className={styles.wrapper}> {showBasicRoleOnLabel && <ValueContainer>{basicRole}</ValueContainer>} {appliedRoles.map((role) => ( - <ValueContainer key={role.uid}>{role.displayName || role.name}</ValueContainer> + <ValueContainer key={role.uid}>{role.group + ':' + (role.displayName || role.name)}</ValueContainer> ))} {!disabled && ( @@ -114,7 +114,7 @@ export const RolesLabel = ({ showBuiltInRole, numberOfRoles, appliedRoles }: Rol <Tooltip content={ <div className={styles.tooltip}> - {appliedRoles?.map((role) => <p key={role.uid}>{role.displayName}</p>)} + {appliedRoles?.map((role) => <p key={role.uid}>{role.group + ':' + (role.displayName || role.name)}</p>)} </div> } > diff --git a/public/app/core/components/RolePicker/TeamRolePicker.tsx b/public/app/core/components/RolePicker/TeamRolePicker.tsx index c380a12094498..f4f33871a537b 100644 --- a/public/app/core/components/RolePicker/TeamRolePicker.tsx +++ b/public/app/core/components/RolePicker/TeamRolePicker.tsx @@ -53,7 +53,7 @@ export const TeamRolePicker = ({ return pendingRoles; } - if (contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesList)) { + if (contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesList) && teamId > 0) { return await fetchTeamRoles(teamId); } } catch (e) { diff --git a/public/app/core/components/RolePicker/UserRolePicker.tsx b/public/app/core/components/RolePicker/UserRolePicker.tsx index 568344571912b..1b3bab63b7172 100644 --- a/public/app/core/components/RolePicker/UserRolePicker.tsx +++ b/public/app/core/components/RolePicker/UserRolePicker.tsx @@ -62,7 +62,7 @@ export const UserRolePicker = ({ return pendingRoles; } - if (contextSrv.hasPermission(AccessControlAction.ActionUserRolesList)) { + if (contextSrv.hasPermission(AccessControlAction.ActionUserRolesList) && userId > 0) { return await fetchUserRoles(userId, orgId); } } catch (e) { diff --git a/public/app/core/components/Select/FolderPicker.tsx b/public/app/core/components/Select/FolderPicker.tsx index 71cfb8262ff78..1611b96136602 100644 --- a/public/app/core/components/Select/FolderPicker.tsx +++ b/public/app/core/components/Select/FolderPicker.tsx @@ -28,7 +28,9 @@ interface FolderPickerProps extends NestedFolderPickerProps { // Temporary wrapper component to switch between the NestedFolderPicker and the old flat // FolderPicker depending on feature flags export function FolderPicker(props: FolderPickerProps) { - const nestedEnabled = config.featureToggles.nestedFolders && config.featureToggles.nestedFolderPicker; + const nestedEnabled = + config.featureToggles.newFolderPicker || + (config.featureToggles.nestedFolders && config.featureToggles.nestedFolderPicker); const { initialTitle, dashboardId, enableCreateNew, ...newFolderPickerProps } = props; return nestedEnabled ? <NestedFolderPicker {...newFolderPickerProps} /> : <OldFolderPickerWrapper {...props} />; diff --git a/public/app/core/components/Select/OldFolderPicker.test.tsx b/public/app/core/components/Select/OldFolderPicker.test.tsx index 0be47d4847a27..9b86d840f6558 100644 --- a/public/app/core/components/Select/OldFolderPicker.test.tsx +++ b/public/app/core/components/Select/OldFolderPicker.test.tsx @@ -35,7 +35,7 @@ describe('OldFolderPicker', () => { render(<OldFolderPicker onChange={jest.fn()} filter={(hits) => hits.filter((h) => h.uid !== 'wfTJJL5Wz')} />); - const pickerContainer = screen.getByLabelText(selectors.components.FolderPicker.input); + const pickerContainer = screen.getByTestId(selectors.components.FolderPicker.input); selectEvent.openMenu(pickerContainer); const pickerOptions = await screen.findAllByLabelText('Select option'); @@ -62,7 +62,7 @@ describe('OldFolderPicker', () => { render(<OldFolderPicker onChange={onChangeFn} enableCreateNew={true} allowEmpty={true} />); expect(await screen.findByTestId(selectors.components.FolderPicker.containerV2)).toBeInTheDocument(); - await userEvent.type(screen.getByLabelText('Select a folder'), newFolder.title); + await userEvent.type(screen.getByTestId(selectors.components.FolderPicker.input), newFolder.title); const enter = await screen.findByText('Hit enter to add'); await userEvent.click(enter); @@ -89,7 +89,7 @@ describe('OldFolderPicker', () => { const onChangeFn = jest.fn(); render(<OldFolderPicker onChange={onChangeFn} />); expect(await screen.findByTestId(selectors.components.FolderPicker.containerV2)).toBeInTheDocument(); - const pickerContainer = screen.getByLabelText(selectors.components.FolderPicker.input); + const pickerContainer = screen.getByTestId(selectors.components.FolderPicker.input); selectEvent.openMenu(pickerContainer); const pickerOptions = await screen.findAllByLabelText('Select option'); @@ -110,7 +110,7 @@ describe('OldFolderPicker', () => { const onChangeFn = jest.fn(); render(<OldFolderPicker onChange={onChangeFn} showRoot={false} />); expect(await screen.findByTestId(selectors.components.FolderPicker.containerV2)).toBeInTheDocument(); - const pickerContainer = screen.getByLabelText(selectors.components.FolderPicker.input); + const pickerContainer = screen.getByTestId(selectors.components.FolderPicker.input); selectEvent.openMenu(pickerContainer); const pickerOptions = await screen.findAllByLabelText('Select option'); @@ -131,7 +131,7 @@ describe('OldFolderPicker', () => { const onChangeFn = jest.fn(); render(<OldFolderPicker onChange={onChangeFn} />); expect(await screen.findByTestId(selectors.components.FolderPicker.containerV2)).toBeInTheDocument(); - const pickerContainer = screen.getByLabelText(selectors.components.FolderPicker.input); + const pickerContainer = screen.getByTestId(selectors.components.FolderPicker.input); selectEvent.openMenu(pickerContainer); const pickerOptions = await screen.findAllByLabelText('Select option'); @@ -152,7 +152,7 @@ describe('OldFolderPicker', () => { const onChangeFn = jest.fn(); render(<OldFolderPicker onChange={onChangeFn} />); - const pickerContainer = screen.getByLabelText(selectors.components.FolderPicker.input); + const pickerContainer = screen.getByTestId(selectors.components.FolderPicker.input); await userEvent.type(pickerContainer, 'Test'); expect(await screen.findByText('Dash Test')).toBeInTheDocument(); diff --git a/public/app/core/components/Select/OldFolderPicker.tsx b/public/app/core/components/Select/OldFolderPicker.tsx index 4a7d73d3484b2..c144b30fba1b5 100644 --- a/public/app/core/components/Select/OldFolderPicker.tsx +++ b/public/app/core/components/Select/OldFolderPicker.tsx @@ -1,12 +1,12 @@ import { css } from '@emotion/css'; import debounce from 'debounce-promise'; -import React, { useState, useEffect, useMemo, useCallback, FormEvent } from 'react'; +import React, { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { useAsync } from 'react-use'; -import { AppEvents, SelectableValue, GrafanaTheme2 } from '@grafana/data'; +import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { reportInteraction } from '@grafana/runtime'; -import { useStyles2, ActionMeta, Input, InputActionMeta, AsyncVirtualizedSelect } from '@grafana/ui'; +import { ActionMeta, AsyncVirtualizedSelect, Input, InputActionMeta, useStyles2 } from '@grafana/ui'; import appEvents from 'app/core/app_events'; import { t } from 'app/core/internationalization'; import { contextSrv } from 'app/core/services/context_srv'; @@ -54,6 +54,7 @@ export interface Props { skipInitialLoad?: boolean; /** The id of the search input. Use this to set a matching label with htmlFor */ inputId?: string; + invalid?: boolean; } export type SelectedFolder = SelectableValue<string>; @@ -78,6 +79,7 @@ export function OldFolderPicker(props: Props) { searchQueryType, customAdd, folderWarning, + invalid, } = props; const rootName = rootNameProp ?? 'Dashboards'; @@ -338,7 +340,7 @@ export function OldFolderPicker(props: Props) { <FolderWarningWhenSearching /> <AsyncVirtualizedSelect inputId={inputId} - aria-label={selectors.components.FolderPicker.input} + data-testid={selectors.components.FolderPicker.input} loadingMessage={t('folder-picker.loading', 'Loading folders...')} defaultOptions defaultValue={folder} @@ -349,6 +351,7 @@ export function OldFolderPicker(props: Props) { loadOptions={debouncedSearch} onChange={onFolderChange} onCreateOption={createNewFolder} + invalid={invalid} isClearable={isClearable} /> </div> @@ -380,9 +383,9 @@ export async function getInitialValues({ folderName, folderUid, getFolder }: Arg } const getStyles = (theme: GrafanaTheme2) => ({ - newFolder: css` - color: ${theme.colors.warning.main}; - font-size: ${theme.typography.bodySmall.fontSize}; - padding-bottom: ${theme.spacing(1)}; - `, + newFolder: css({ + color: theme.colors.warning.main, + fontSize: theme.typography.bodySmall.fontSize, + paddingBottom: theme.spacing(1), + }), }); diff --git a/public/app/core/components/Select/OrgPicker.tsx b/public/app/core/components/Select/OrgPicker.tsx index e465decd5296e..27214d9f18e23 100644 --- a/public/app/core/components/Select/OrgPicker.tsx +++ b/public/app/core/components/Select/OrgPicker.tsx @@ -45,8 +45,11 @@ export function OrgPicker({ onSelected, className, inputId, autoFocus, excludeOr className={className} isLoading={orgOptionsState.loading} defaultOptions={true} - isSearchable={false} loadOptions={getOrgOptions} + filterOption={(option, rawInput) => { + const input = rawInput.toLowerCase(); + return !!option.value?.name.toLowerCase().includes(input); + }} onChange={onSelected} placeholder="Select organization" noOptionsMessage="No organizations found" diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx index 819608c4fb11c..6bdfc8c89c6be 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx @@ -9,7 +9,6 @@ import { Button, Field, FieldSet, - Form, Label, Select, stylesFactory, @@ -87,7 +86,8 @@ export class SharedPreferences extends PureComponent<Props, State> { }); } - onSubmitForm = async () => { + onSubmitForm = async (event: React.FormEvent<HTMLFormElement>) => { + event.preventDefault(); const confirmationResult = this.props.onConfirm ? await this.props.onConfirm() : true; if (confirmationResult) { @@ -137,94 +137,84 @@ export class SharedPreferences extends PureComponent<Props, State> { const currentThemeOption = this.themeOptions.find((x) => x.value === theme) ?? this.themeOptions[0]; return ( - <Form onSubmit={this.onSubmitForm}> - {() => { - return ( - <> - <FieldSet label={<Trans i18nKey="shared-preferences.title">Preferences</Trans>} disabled={disabled}> - <Field label={t('shared-preferences.fields.theme-label', 'Interface theme')}> - <Select - options={this.themeOptions} - value={currentThemeOption} - onChange={this.onThemeChanged} - inputId="shared-preferences-theme-select" - /> - </Field> - - <Field - label={ - <Label htmlFor="home-dashboard-select"> - <span className={styles.labelText}> - <Trans i18nKey="shared-preferences.fields.home-dashboard-label">Home Dashboard</Trans> - </span> - </Label> - } - data-testid="User preferences home dashboard drop down" - > - <DashboardPicker - value={homeDashboardUID} - onChange={(v) => this.onHomeDashboardChanged(v?.uid ?? '')} - defaultOptions={true} - isClearable={true} - placeholder={t('shared-preferences.fields.home-dashboard-placeholder', 'Default dashboard')} - inputId="home-dashboard-select" - /> - </Field> - - <Field - label={t('shared-dashboard.fields.timezone-label', 'Timezone')} - data-testid={selectors.components.TimeZonePicker.containerV2} - > - <TimeZonePicker - includeInternal={true} - value={timezone} - onChange={this.onTimeZoneChanged} - inputId="shared-preferences-timezone-picker" - /> - </Field> - - <Field - label={t('shared-preferences.fields.week-start-label', 'Week start')} - data-testid={selectors.components.WeekStartPicker.containerV2} - > - <WeekStartPicker - value={weekStart || ''} - onChange={this.onWeekStartChanged} - inputId={'shared-preferences-week-start-picker'} - /> - </Field> - - <Field - label={ - <Label htmlFor="locale-select"> - <span className={styles.labelText}> - <Trans i18nKey="shared-preferences.fields.locale-label">Language</Trans> - </span> - <FeatureBadge featureState={FeatureState.beta} /> - </Label> - } - data-testid="User preferences language drop down" - > - <Select - value={languages.find((lang) => lang.value === language)} - onChange={(lang: SelectableValue<string>) => this.onLanguageChanged(lang.value ?? '')} - options={languages} - placeholder={t('shared-preferences.fields.locale-placeholder', 'Choose language')} - inputId="locale-select" - /> - </Field> - </FieldSet> - <Button - type="submit" - variant="primary" - data-testid={selectors.components.UserProfile.preferencesSaveButton} - > - <Trans i18nKey="common.save">Save</Trans> - </Button> - </> - ); - }} - </Form> + <form onSubmit={this.onSubmitForm} className={styles.form}> + <FieldSet label={<Trans i18nKey="shared-preferences.title">Preferences</Trans>} disabled={disabled}> + <Field label={t('shared-preferences.fields.theme-label', 'Interface theme')}> + <Select + options={this.themeOptions} + value={currentThemeOption} + onChange={this.onThemeChanged} + inputId="shared-preferences-theme-select" + /> + </Field> + + <Field + label={ + <Label htmlFor="home-dashboard-select"> + <span className={styles.labelText}> + <Trans i18nKey="shared-preferences.fields.home-dashboard-label">Home Dashboard</Trans> + </span> + </Label> + } + data-testid="User preferences home dashboard drop down" + > + <DashboardPicker + value={homeDashboardUID} + onChange={(v) => this.onHomeDashboardChanged(v?.uid ?? '')} + defaultOptions={true} + isClearable={true} + placeholder={t('shared-preferences.fields.home-dashboard-placeholder', 'Default dashboard')} + inputId="home-dashboard-select" + /> + </Field> + + <Field + label={t('shared-dashboard.fields.timezone-label', 'Timezone')} + data-testid={selectors.components.TimeZonePicker.containerV2} + > + <TimeZonePicker + includeInternal={true} + value={timezone} + onChange={this.onTimeZoneChanged} + inputId="shared-preferences-timezone-picker" + /> + </Field> + + <Field + label={t('shared-preferences.fields.week-start-label', 'Week start')} + data-testid={selectors.components.WeekStartPicker.containerV2} + > + <WeekStartPicker + value={weekStart || ''} + onChange={this.onWeekStartChanged} + inputId={'shared-preferences-week-start-picker'} + /> + </Field> + + <Field + label={ + <Label htmlFor="locale-select"> + <span className={styles.labelText}> + <Trans i18nKey="shared-preferences.fields.locale-label">Language</Trans> + </span> + <FeatureBadge featureState={FeatureState.beta} /> + </Label> + } + data-testid="User preferences language drop down" + > + <Select + value={languages.find((lang) => lang.value === language)} + onChange={(lang: SelectableValue<string>) => this.onLanguageChanged(lang.value ?? '')} + options={languages} + placeholder={t('shared-preferences.fields.locale-placeholder', 'Choose language')} + inputId="locale-select" + /> + </Field> + </FieldSet> + <Button type="submit" variant="primary" data-testid={selectors.components.UserProfile.preferencesSaveButton}> + <Trans i18nKey="common.save">Save</Trans> + </Button> + </form> ); } } @@ -233,9 +223,13 @@ export default SharedPreferences; const getStyles = stylesFactory(() => { return { - labelText: css` - margin-right: 6px; - `, + labelText: css({ + marginRight: '6px', + }), + form: css({ + width: '100%', + maxWidth: '600px', + }), }; }); diff --git a/public/app/core/components/Signup/SignupPage.test.tsx b/public/app/core/components/Signup/SignupPage.test.tsx index 7878ca3f66f1b..dfda65105d3bd 100644 --- a/public/app/core/components/Signup/SignupPage.test.tsx +++ b/public/app/core/components/Signup/SignupPage.test.tsx @@ -14,6 +14,7 @@ jest.mock('@grafana/runtime', () => ({ post: postMock, }), config: { + ...jest.requireActual('@grafana/runtime').config, loginError: false, buildInfo: { version: 'v1.0', diff --git a/public/app/core/components/Signup/SignupPage.tsx b/public/app/core/components/Signup/SignupPage.tsx index d0d1bd2c434c1..c91cb3646982a 100644 --- a/public/app/core/components/Signup/SignupPage.tsx +++ b/public/app/core/components/Signup/SignupPage.tsx @@ -1,7 +1,8 @@ import React from 'react'; +import { useForm } from 'react-hook-form'; import { getBackendSrv } from '@grafana/runtime'; -import { Form, Field, Input, Button, HorizontalGroup, LinkButton, FormAPI } from '@grafana/ui'; +import { Field, Input, Button, LinkButton, Stack } from '@grafana/ui'; import { getConfig } from 'app/core/config'; import { useAppNotification } from 'app/core/copy/appNotification'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; @@ -27,8 +28,15 @@ interface QueryParams { interface Props extends GrafanaRouteComponentProps<{}, QueryParams> {} -export const SignupPage = (props: Props) => { +export const SignupPage = ({ queryParams }: Props) => { const notifyApp = useAppNotification(); + const { + handleSubmit, + formState: { errors }, + register, + getValues, + } = useForm<SignupDTO>({ defaultValues: { email: queryParams.email, code: queryParams.code } }); + const onSubmit = async (formData: SignupDTO) => { if (formData.name === '') { delete formData.name; @@ -55,72 +63,63 @@ export const SignupPage = (props: Props) => { window.location.assign(getConfig().appSubUrl + '/'); }; - const defaultValues = { - email: props.queryParams.email, - code: props.queryParams.code, - }; - return ( <LoginLayout> <InnerBox> - <Form defaultValues={defaultValues} onSubmit={onSubmit}> - {({ errors, register, getValues }: FormAPI<SignupDTO>) => ( - <> - <Field label="Your name"> - <Input id="user-name" {...register('name')} placeholder="(optional)" /> - </Field> - <Field label="Email" invalid={!!errors.email} error={errors.email?.message}> - <Input - id="email" - {...register('email', { - required: 'Email is required', - pattern: { - value: w3cStandardEmailValidator, - message: 'Email is invalid', - }, - })} - type="email" - placeholder="Email" - /> - </Field> - {!getConfig().autoAssignOrg && ( - <Field label="Org. name"> - <Input id="org-name" {...register('orgName')} placeholder="Org. name" /> - </Field> - )} - {getConfig().verifyEmailEnabled && ( - <Field label="Email verification code (sent to your email)"> - <Input id="verification-code" {...register('code')} placeholder="Code" /> - </Field> - )} - <Field label="Password" invalid={!!errors.password} error={errors?.password?.message}> - <PasswordField - id="new-password" - autoFocus - autoComplete="new-password" - {...register('password', { required: 'Password is required' })} - /> - </Field> - <Field label="Confirm password" invalid={!!errors.confirm} error={errors?.confirm?.message}> - <PasswordField - id="confirm-new-password" - autoComplete="new-password" - {...register('confirm', { - required: 'Confirmed password is required', - validate: (v) => v === getValues().password || 'Passwords must match!', - })} - /> - </Field> - - <HorizontalGroup> - <Button type="submit">Submit</Button> - <LinkButton fill="text" href={getConfig().appSubUrl + '/login'}> - Back to login - </LinkButton> - </HorizontalGroup> - </> + <form onSubmit={handleSubmit(onSubmit)} style={{ width: '100%' }}> + <Field label="Your name"> + <Input id="user-name" {...register('name')} placeholder="(optional)" /> + </Field> + <Field label="Email" invalid={!!errors.email} error={errors.email?.message}> + <Input + id="email" + {...register('email', { + required: 'Email is required', + pattern: { + value: w3cStandardEmailValidator, + message: 'Email is invalid', + }, + })} + type="email" + placeholder="Email" + /> + </Field> + {!getConfig().autoAssignOrg && ( + <Field label="Org. name"> + <Input id="org-name" {...register('orgName')} placeholder="Org. name" /> + </Field> + )} + {getConfig().verifyEmailEnabled && ( + <Field label="Email verification code (sent to your email)"> + <Input id="verification-code" {...register('code')} placeholder="Code" /> + </Field> )} - </Form> + <Field label="Password" invalid={!!errors.password} error={errors?.password?.message}> + <PasswordField + id="new-password" + autoFocus + autoComplete="new-password" + {...register('password', { required: 'Password is required' })} + /> + </Field> + <Field label="Confirm password" invalid={!!errors.confirm} error={errors?.confirm?.message}> + <PasswordField + id="confirm-new-password" + autoComplete="new-password" + {...register('confirm', { + required: 'Confirmed password is required', + validate: (v) => v === getValues().password || 'Passwords must match!', + })} + /> + </Field> + + <Stack> + <Button type="submit">Submit</Button> + <LinkButton fill="text" href={getConfig().appSubUrl + '/login'}> + Back to login + </LinkButton> + </Stack> + </form> </InnerBox> </LoginLayout> ); diff --git a/public/app/core/components/Signup/VerifyEmail.tsx b/public/app/core/components/Signup/VerifyEmail.tsx index e372217e06ce9..cc255572863b8 100644 --- a/public/app/core/components/Signup/VerifyEmail.tsx +++ b/public/app/core/components/Signup/VerifyEmail.tsx @@ -1,7 +1,8 @@ import React, { useState } from 'react'; +import { useForm } from 'react-hook-form'; import { getBackendSrv } from '@grafana/runtime'; -import { Form, Field, Input, Button, Legend, Container, HorizontalGroup, LinkButton } from '@grafana/ui'; +import { Field, Input, Button, Legend, Container, LinkButton, Stack } from '@grafana/ui'; import { getConfig } from 'app/core/config'; import { useAppNotification } from 'app/core/copy/appNotification'; import { w3cStandardEmailValidator } from 'app/features/admin/utils'; @@ -12,6 +13,11 @@ interface EmailDTO { export const VerifyEmail = () => { const notifyApp = useAppNotification(); + const { + handleSubmit, + register, + formState: { errors }, + } = useForm<EmailDTO>(); const [emailSent, setEmailSent] = useState(false); const onSubmit = (formModel: EmailDTO) => { @@ -39,36 +45,32 @@ export const VerifyEmail = () => { } return ( - <Form onSubmit={onSubmit}> - {({ register, errors }) => ( - <> - <Legend>Verify Email</Legend> - <Field - label="Email" - description="Enter your email address to get a verification link sent to you" - invalid={!!errors.email} - error={errors.email?.message} - > - <Input - id="email" - {...register('email', { - required: 'Email is required', - pattern: { - value: w3cStandardEmailValidator, - message: 'Email is invalid', - }, - })} - placeholder="Email" - /> - </Field> - <HorizontalGroup> - <Button type="submit">Send verification email</Button> - <LinkButton fill="text" href={getConfig().appSubUrl + '/login'}> - Back to login - </LinkButton> - </HorizontalGroup> - </> - )} - </Form> + <form onSubmit={handleSubmit(onSubmit)}> + <Legend>Verify Email</Legend> + <Field + label="Email" + description="Enter your email address to get a verification link sent to you" + invalid={!!errors.email} + error={errors.email?.message} + > + <Input + id="email" + {...register('email', { + required: 'Email is required', + pattern: { + value: w3cStandardEmailValidator, + message: 'Email is invalid', + }, + })} + placeholder="Email" + /> + </Field> + <Stack> + <Button type="submit">Send verification email</Button> + <LinkButton fill="text" href={getConfig().appSubUrl + '/login'}> + Back to login + </LinkButton> + </Stack> + </form> ); }; diff --git a/public/app/core/components/Signup/VerifyEmailPage.test.tsx b/public/app/core/components/Signup/VerifyEmailPage.test.tsx index 00dbb378a4d08..ebdf201e9cd38 100644 --- a/public/app/core/components/Signup/VerifyEmailPage.test.tsx +++ b/public/app/core/components/Signup/VerifyEmailPage.test.tsx @@ -12,6 +12,7 @@ jest.mock('@grafana/runtime', () => ({ post: postMock, }), config: { + ...jest.requireActual('@grafana/runtime').config, buildInfo: { version: 'v1.0', commit: '1', diff --git a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx index a282feb0f3bcc..54691d448451c 100644 --- a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx +++ b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx @@ -3,6 +3,7 @@ import React, { createRef, MutableRefObject, PureComponent } from 'react'; import SplitPane, { Split } from 'react-split-pane'; import { GrafanaTheme2 } from '@grafana/data'; +import { getDragStyles } from '@grafana/ui'; import { config } from 'app/core/config'; interface Props { @@ -74,6 +75,7 @@ export class SplitPaneWrapper extends PureComponent<React.PropsWithChildren<Prop // Limit options pane width to 90% of screen. const styles = getStyles(config.theme2, splitVisible); + const dragStyles = getDragStyles(config.theme2); // Need to handle when width is relative. ie a percentage of the viewport const paneSizePx = @@ -89,13 +91,15 @@ export class SplitPaneWrapper extends PureComponent<React.PropsWithChildren<Prop return ( <SplitPane - className={styles.splitPane} split={splitOrientation} minSize={minSize} maxSize={maxSize} size={splitVisible ? paneSizePx : 0} primary={splitVisible ? primary : 'second'} - resizerClassName={splitOrientation === 'horizontal' ? styles.resizerH : styles.resizerV} + resizerClassName={cx( + styles.resizer, + splitOrientation === 'horizontal' ? dragStyles.dragHandleHorizontal : dragStyles.dragHandleVertical + )} onDragStarted={() => this.onDragStarted()} onDragFinished={(size) => this.onDragFinished(size)} style={parentStyle} @@ -109,83 +113,9 @@ export class SplitPaneWrapper extends PureComponent<React.PropsWithChildren<Prop } const getStyles = (theme: GrafanaTheme2, hasSplit: boolean) => { - const handleColor = theme.v1.palette.blue95; - const paneSpacing = theme.spacing(2); - - const resizer = css` - position: relative; - display: ${hasSplit ? 'block' : 'none'}; - - &::before { - content: ''; - position: absolute; - transition: 0.2s border-color ease-in-out; - } - - &::after { - background: ${theme.components.panel.borderColor}; - content: ''; - position: absolute; - left: 50%; - top: 50%; - transition: 0.2s background ease-in-out; - transform: translate(-50%, -50%); - border-radius: ${theme.shape.radius.default}; - } - - &:hover { - &::before { - border-color: ${handleColor}; - } - - &::after { - background: ${handleColor}; - } - } - `; - return { - splitPane: css({ - overflow: 'visible !important', + resizer: css({ + display: hasSplit ? 'block' : 'none', }), - resizerV: cx( - resizer, - css` - cursor: col-resize; - width: ${paneSpacing}; - - &::before { - border-right: 1px solid transparent; - height: 100%; - left: 50%; - transform: translateX(-50%); - } - - &::after { - height: 200px; - width: 4px; - } - ` - ), - resizerH: cx( - resizer, - css` - height: ${paneSpacing}; - cursor: row-resize; - margin-left: ${paneSpacing}; - - &::before { - border-top: 1px solid transparent; - top: 50%; - transform: translateY(-50%); - width: 100%; - } - - &::after { - height: 4px; - width: 200px; - } - ` - ), }; }; diff --git a/public/app/core/components/TimePicker/TimePickerWithHistory.tsx b/public/app/core/components/TimePicker/TimePickerWithHistory.tsx index eb399d90f219e..570d2fb57806b 100644 --- a/public/app/core/components/TimePicker/TimePickerWithHistory.tsx +++ b/public/app/core/components/TimePicker/TimePickerWithHistory.tsx @@ -1,8 +1,10 @@ import { uniqBy } from 'lodash'; import React from 'react'; -import { TimeRange, isDateTime, rangeUtil } from '@grafana/data'; +import { AppEvents, TimeRange, isDateTime, rangeUtil } from '@grafana/data'; import { TimeRangePickerProps, TimeRangePicker } from '@grafana/ui'; +import { t } from '@grafana/ui/src/utils/i18n'; +import appEvents from 'app/core/app_events'; import { LocalStorageValueProvider } from '../LocalStorageValueProvider'; @@ -34,6 +36,12 @@ export const TimePickerWithHistory = (props: Props) => { onAppendToHistory(value, values, onSaveToStore); props.onChange(value); }} + onError={(error?: string) => + appEvents.emit(AppEvents.alertError, [ + t('time-picker.copy-paste.default-error-title', 'Invalid time range'), + t('time-picker.copy-paste.default-error-message', `{{error}} is not a valid time range`, { error }), + ]) + } /> ); }} diff --git a/public/app/core/components/TimeSeries/TimeSeries.tsx b/public/app/core/components/TimeSeries/TimeSeries.tsx index 4441bca98f37e..4ecc98b5b5119 100644 --- a/public/app/core/components/TimeSeries/TimeSeries.tsx +++ b/public/app/core/components/TimeSeries/TimeSeries.tsx @@ -19,21 +19,22 @@ export class UnthemedTimeSeries extends Component<TimeSeriesProps> { declare context: React.ContextType<typeof PanelContextRoot>; prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => { - const { eventBus, eventsScope, sync } = this.context; - const { theme, timeZone, renderers, tweakAxis, tweakScale } = this.props; + const { eventsScope, sync } = this.context; + const { theme, timeZone, options, renderers, tweakAxis, tweakScale } = this.props; return preparePlotConfigBuilder({ frame: alignedFrame, theme, timeZones: Array.isArray(timeZone) ? timeZone : [timeZone], getTimeRange, - eventBus, sync, allFrames, renderers, tweakScale, tweakAxis, eventsScope, + hoverProximity: options?.tooltip?.hoverProximity, + orientation: options?.orientation, }); }; diff --git a/public/app/core/components/TimeSeries/utils.ts b/public/app/core/components/TimeSeries/utils.ts index ea063cd21252c..82b78da39cdf6 100644 --- a/public/app/core/components/TimeSeries/utils.ts +++ b/public/app/core/components/TimeSeries/utils.ts @@ -4,9 +4,6 @@ import uPlot from 'uplot'; import { DashboardCursorSync, DataFrame, - DataHoverClearEvent, - DataHoverEvent, - DataHoverPayload, FieldConfig, FieldType, formattedValueToString, @@ -22,7 +19,7 @@ import { AxisPlacement, GraphDrawStyle, GraphFieldConfig, - GraphTresholdsStyleMode, + GraphThresholdsStyleMode, VisibilityMode, ScaleDirection, ScaleOrientation, @@ -30,6 +27,7 @@ import { GraphTransform, AxisColorMode, GraphGradientMode, + VizOrientation, } from '@grafana/schema'; // unit lookup needed to determine if we want power-of-2 or power-of-10 axis ticks @@ -82,14 +80,17 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ theme, timeZones, getTimeRange, - eventBus, sync, allFrames, renderers, tweakScale = (opts) => opts, tweakAxis = (opts) => opts, eventsScope = '__global_', + hoverProximity, + orientation = VizOrientation.Horizontal, }) => { + // we want the Auto and Horizontal orientation to default to Horizontal + const isHorizontal = orientation !== VizOrientation.Vertical; const builder = new UPlotConfigBuilder(timeZones[0]); let alignedFrame: DataFrame; @@ -108,19 +109,21 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ } const xScaleKey = 'x'; - let xScaleUnit = '_x'; let yScaleKey = ''; const xFieldAxisPlacement = - xField.config.custom?.axisPlacement !== AxisPlacement.Hidden ? AxisPlacement.Bottom : AxisPlacement.Hidden; + xField.config.custom?.axisPlacement === AxisPlacement.Hidden + ? AxisPlacement.Hidden + : isHorizontal + ? AxisPlacement.Bottom + : AxisPlacement.Left; const xFieldAxisShow = xField.config.custom?.axisPlacement !== AxisPlacement.Hidden; if (xField.type === FieldType.time) { - xScaleUnit = 'time'; builder.addScale({ scaleKey: xScaleKey, - orientation: ScaleOrientation.Horizontal, - direction: ScaleDirection.Right, + orientation: isHorizontal ? ScaleOrientation.Horizontal : ScaleOrientation.Vertical, + direction: isHorizontal ? ScaleDirection.Right : ScaleDirection.Up, isTime: true, range: () => { const r = getTimeRange(); @@ -132,7 +135,10 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ const filterTicks: uPlot.Axis.Filter | undefined = timeZones.length > 1 ? (u, splits) => { - return splits.map((v, i) => (i < 2 ? null : v)); + if (isHorizontal) { + return splits.map((v, i) => (i < 2 ? null : v)); + } + return splits; } : undefined; @@ -156,13 +162,12 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ builder.addHook('drawAxes', (u: uPlot) => { u.ctx.save(); - u.ctx.fillStyle = theme.colors.text.primary; - u.ctx.textAlign = 'left'; - u.ctx.textBaseline = 'bottom'; - let i = 0; u.axes.forEach((a) => { - if (a.side === 2) { + if (isHorizontal && a.side === 2) { + u.ctx.fillStyle = theme.colors.text.primary; + u.ctx.textAlign = 'left'; + u.ctx.textBaseline = 'bottom'; //@ts-ignore let cssBaseline: number = a._pos + a._size; u.ctx.fillText(timeZones[i], u.bbox.left, cssBaseline * uPlot.pxRatio); @@ -174,15 +179,10 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ }); } } else { - // Not time! - if (xField.config.unit) { - xScaleUnit = xField.config.unit; - } - builder.addScale({ scaleKey: xScaleKey, - orientation: ScaleOrientation.Horizontal, - direction: ScaleDirection.Right, + orientation: isHorizontal ? ScaleOrientation.Horizontal : ScaleOrientation.Vertical, + direction: isHorizontal ? ScaleDirection.Right : ScaleDirection.Up, range: (u, dataMin, dataMax) => [xField.config.min ?? dataMin, xField.config.max ?? dataMax], }); @@ -242,8 +242,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ tweakScale( { scaleKey, - orientation: ScaleOrientation.Vertical, - direction: ScaleDirection.Up, + orientation: isHorizontal ? ScaleOrientation.Vertical : ScaleOrientation.Horizontal, + direction: isHorizontal ? ScaleDirection.Up : ScaleDirection.Right, distribution: customConfig.scaleDistribution?.type, log: customConfig.scaleDistribution?.log, linearThreshold: customConfig.scaleDistribution?.linearThreshold, @@ -260,16 +260,16 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ return [dataMin, dataMax]; } : field.type === FieldType.enum - ? (u: uPlot, dataMin: number, dataMax: number) => { - // this is the exhaustive enum (stable) - let len = field.config.type!.enum!.text!.length; + ? (u: uPlot, dataMin: number, dataMax: number) => { + // this is the exhaustive enum (stable) + let len = field.config.type!.enum!.text!.length; - return [-1, len]; + return [-1, len]; - // these are only values that are present - // return [dataMin - 1, dataMax + 1] - } - : undefined, + // these are only values that are present + // return [dataMin - 1, dataMax + 1] + } + : undefined, decimals: field.config.decimals, }, field @@ -328,7 +328,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ scaleKey, label: customConfig.axisLabel, size: customConfig.axisWidth, - placement: customConfig.axisPlacement ?? AxisPlacement.Auto, + placement: isHorizontal ? customConfig.axisPlacement ?? AxisPlacement.Auto : AxisPlacement.Bottom, formatValue: (v, decimals) => formattedValueToString(fmt(v, decimals)), theme, grid: { show: customConfig.axisGridShow }, @@ -520,8 +520,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ // Render thresholds in graph if (customConfig.thresholdsStyle && config.thresholds) { - const thresholdDisplay = customConfig.thresholdsStyle.mode ?? GraphTresholdsStyleMode.Off; - if (thresholdDisplay !== GraphTresholdsStyleMode.Off) { + const thresholdDisplay = customConfig.thresholdsStyle.mode ?? GraphThresholdsStyleMode.Off; + if (thresholdDisplay !== GraphThresholdsStyleMode.Off) { builder.addThresholds({ config: customConfig.thresholdsStyle, thresholds: config.thresholds, @@ -558,93 +558,39 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ builder.scaleKeys = [xScaleKey, yScaleKey]; // if hovered value is null, how far we may scan left/right to hover nearest non-null - const hoverProximityPx = 15; + const DEFAULT_HOVER_NULL_PROXIMITY = 15; + const DEFAULT_FOCUS_PROXIMITY = 30; let cursor: Partial<uPlot.Cursor> = { - // this scans left and right from cursor position to find nearest data index with value != null - // TODO: do we want to only scan past undefined values, but halt at explicit null values? - dataIdx: (self, seriesIdx, hoveredIdx, cursorXVal) => { - let seriesData = self.data[seriesIdx]; - - if (seriesData[hoveredIdx] == null) { - let nonNullLft = null, - nonNullRgt = null, - i; - - i = hoveredIdx; - while (nonNullLft == null && i-- > 0) { - if (seriesData[i] != null) { - nonNullLft = i; - } - } - - i = hoveredIdx; - while (nonNullRgt == null && i++ < seriesData.length) { - if (seriesData[i] != null) { - nonNullRgt = i; - } + // horizontal proximity / point hover behavior + hover: { + prox: (self, seriesIdx, hoveredIdx) => { + if (hoverProximity != null) { + return hoverProximity; } - let xVals = self.data[0]; - - let curPos = self.valToPos(cursorXVal, 'x'); - let rgtPos = nonNullRgt == null ? Infinity : self.valToPos(xVals[nonNullRgt], 'x'); - let lftPos = nonNullLft == null ? -Infinity : self.valToPos(xVals[nonNullLft], 'x'); - - let lftDelta = curPos - lftPos; - let rgtDelta = rgtPos - curPos; - - if (lftDelta <= rgtDelta) { - if (lftDelta <= hoverProximityPx) { - hoveredIdx = nonNullLft!; - } - } else { - if (rgtDelta <= hoverProximityPx) { - hoveredIdx = nonNullRgt!; - } + // when hovering null values, scan data left/right up to 15px + const yVal = self.data[seriesIdx][hoveredIdx]; + if (yVal === null) { + return DEFAULT_HOVER_NULL_PROXIMITY; } - } - return hoveredIdx; + // no proximity limit + return null; + }, + skip: [null], + }, + // vertical proximity / series focus behavior + focus: { + prox: hoverProximity ?? DEFAULT_FOCUS_PROXIMITY, }, }; - if (sync && sync() !== DashboardCursorSync.Off && xField.type === FieldType.time) { - const payload: DataHoverPayload = { - point: { - [xScaleKey]: null, - [yScaleKey]: null, - }, - data: frame, - }; - - const hoverEvent = new DataHoverEvent(payload); + if (xField.type === FieldType.time && sync && sync() !== DashboardCursorSync.Off) { cursor.sync = { key: eventsScope, - filters: { - pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => { - if (sync && sync() === DashboardCursorSync.Off) { - return false; - } - - payload.rowIndex = dataIdx; - if (x < 0 && y < 0) { - payload.point[xScaleUnit] = null; - payload.point[yScaleKey] = null; - eventBus.publish(new DataHoverClearEvent()); - } else { - // convert the points - payload.point[xScaleUnit] = src.posToVal(x, xScaleKey); - payload.point[yScaleKey] = src.posToVal(y, yScaleKey); - payload.point.panelRelY = y > 0 ? y / h : 1; // used by old graph panel to position tooltip - eventBus.publish(hoverEvent); - hoverEvent.payload.down = undefined; - } - return true; - }, - }, - scales: [xScaleKey, yScaleKey], - // match: [() => true, (a, b) => a === b], + scales: [xScaleKey, null], + // match: [() => true, () => false], }; } diff --git a/public/app/core/components/TimelineChart/TimelineChart.tsx b/public/app/core/components/TimelineChart/TimelineChart.tsx index b1ad4f08b7ab9..57048baed16e1 100644 --- a/public/app/core/components/TimelineChart/TimelineChart.tsx +++ b/public/app/core/components/TimelineChart/TimelineChart.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { DataFrame, FALLBACK_COLOR, FieldType, TimeRange } from '@grafana/data'; -import { VisibilityMode, TimelineValueAlignment } from '@grafana/schema'; +import { VisibilityMode, TimelineValueAlignment, TooltipDisplayMode, VizTooltipOptions } from '@grafana/schema'; import { PanelContext, PanelContextRoot, UPlotConfigBuilder, VizLayout, VizLegend, VizLegendItem } from '@grafana/ui'; import { GraphNG, GraphNGProps } from '../GraphNG/GraphNG'; @@ -18,6 +18,7 @@ export interface TimelineProps extends Omit<GraphNGProps, 'prepConfig' | 'propsT alignValue?: TimelineValueAlignment; colWidth?: number; legendItems?: VizLegendItem[]; + tooltip?: VizTooltipOptions; } const propsToDiff = ['rowHeight', 'colWidth', 'showValue', 'mergeValues', 'alignValue', 'tooltip']; @@ -42,12 +43,11 @@ export class TimelineChart extends React.Component<TimelineProps> { prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => { this.panelContext = this.context; - const { eventBus, sync } = this.panelContext; + const { sync } = this.panelContext; return preparePlotConfigBuilder({ frame: alignedFrame, getTimeRange, - eventBus, sync, allFrames: this.props.frames, ...this.props, @@ -58,6 +58,8 @@ export class TimelineChart extends React.Component<TimelineProps> { // When there is only one row, use the full space rowHeight: alignedFrame.fields.length > 2 ? this.props.rowHeight : 1, getValueColor: this.getValueColor, + + hoverMulti: this.props.tooltip?.mode === TooltipDisplayMode.Multi, }); }; @@ -90,6 +92,7 @@ export class TimelineChart extends React.Component<TimelineProps> { prepConfig={this.prepConfig} propsToDiff={propsToDiff} renderLegend={this.renderLegend} + dataLinkPostProcessor={this.panelContext?.dataLinkPostProcessor} /> ); } diff --git a/public/app/core/components/TimelineChart/timeline.ts b/public/app/core/components/TimelineChart/timeline.ts index 442c2679a2802..5afd52afbdf6b 100644 --- a/public/app/core/components/TimelineChart/timeline.ts +++ b/public/app/core/components/TimelineChart/timeline.ts @@ -5,7 +5,7 @@ import { alpha } from '@grafana/data/src/themes/colorManipulator'; import { TimelineValueAlignment, VisibilityMode } from '@grafana/schema'; import { FIXED_UNIT } from '@grafana/ui'; import { distribute, SPACE_BETWEEN } from 'app/plugins/panel/barchart/distribute'; -import { pointWithin, Quadtree, Rect } from 'app/plugins/panel/barchart/quadtree'; +import { Quadtree, Rect } from 'app/plugins/panel/barchart/quadtree'; import { FieldConfig as StateTimeLineFieldConfig } from 'app/plugins/panel/state-timeline/panelcfg.gen'; import { FieldConfig as StatusHistoryFieldConfig } from 'app/plugins/panel/status-history/panelcfg.gen'; @@ -55,6 +55,7 @@ export interface TimelineCoreOptions { getFieldConfig: (seriesIdx: number) => StateTimeLineFieldConfig | StatusHistoryFieldConfig; onHover: (seriesIdx: number, valueIdx: number, rect: Rect) => void; onLeave: () => void; + hoverMulti: boolean; } /** @@ -79,6 +80,7 @@ export function getConfig(opts: TimelineCoreOptions) { getFieldConfig, onHover, onLeave, + hoverMulti, } = opts; let qt: Quadtree; @@ -133,10 +135,8 @@ export function getConfig(opts: TimelineCoreOptions) { value: number | null, discrete: boolean ) { - // do not render super small boxes - if (boxWidth < 1) { - return; - } + // clamp width to allow small boxes to be rendered + boxWidth = Math.max(1, boxWidth); const valueColor = getValueColor(seriesIdx + 1, value); const fieldConfig = getFieldConfig(seriesIdx); @@ -384,7 +384,7 @@ export function getConfig(opts: TimelineCoreOptions) { }); }; - function setHovered(cx: number, cy: number, cys: number[]) { + function setHovered(cx: number, cy: number, viaSync = false) { hovered.fill(null); hoveredAtCursor = null; @@ -392,23 +392,23 @@ export function getConfig(opts: TimelineCoreOptions) { return; } - for (let i = 0; i < cys.length; i++) { - let cy2 = cys[i]; - - qt.get(cx, cy2, 1, 1, (o) => { - if (pointWithin(cx, cy2, o.x, o.y, o.x + o.w, o.y + o.h)) { + // first gets all items in all quads intersected by a 1px wide by 10k high rect at the x cursor position and 0 y position. + // (we use 10k instead of plot area height for simplicity and not having to pass around the uPlot instance) + qt.get(cx, 0, uPlot.pxRatio, 1e4, (o) => { + // filter only rects that intersect along x dir + if (cx >= o.x && cx <= o.x + o.w) { + // if also intersect along y dir, set both "direct hovered" and "one-of hovered" + if (cy >= o.y && cy <= o.y + o.h) { + hovered[o.sidx] = hoveredAtCursor = o; + } + // else only set "one-of hovered" (no "direct hovered") in multi mode or when synced + else if (hoverMulti || viaSync) { hovered[o.sidx] = o; - - if (Math.abs(cy - cy2) <= o.h / 2) { - hoveredAtCursor = o; - } } - }); - } + } + }); } - const hoverMulti = mode === TimelineMode.Changes; - const cursor: uPlot.Cursor = { x: mode === TimelineMode.Changes, y: false, @@ -428,7 +428,7 @@ export function getConfig(opts: TimelineCoreOptions) { let prevHovered = hoveredAtCursor; - setHovered(cx, cy, hoverMulti ? yMids : [cy]); + setHovered(cx, cy, u.cursor.event == null); if (hoveredAtCursor != null) { if (hoveredAtCursor !== prevHovered) { diff --git a/public/app/core/components/TimelineChart/utils.test.ts b/public/app/core/components/TimelineChart/utils.test.ts index 15a291602b376..baa114f5da11f 100644 --- a/public/app/core/components/TimelineChart/utils.test.ts +++ b/public/app/core/components/TimelineChart/utils.test.ts @@ -1,6 +1,18 @@ -import { createTheme, FieldType, ThresholdsMode, TimeRange, toDataFrame, dateTime, DataFrame } from '@grafana/data'; +import { + createTheme, + FieldType, + ThresholdsMode, + TimeRange, + toDataFrame, + dateTime, + DataFrame, + fieldMatchers, + FieldMatcherID, +} from '@grafana/data'; import { LegendDisplayMode, VizLegendOptions } from '@grafana/schema'; +import { preparePlotFrame } from '../GraphNG/utils'; + import { findNextStateIndex, fmtDuration, @@ -87,6 +99,173 @@ describe('prepare timeline graph', () => { const result = prepareTimelineFields(frames, true, timeRange, theme); expect(result.frames?.[0].fields[0].values).toEqual([1, 2, 3, 4]); }); + + it('join multiple frames with NULL_RETAIN rather than NULL_EXPAND', () => { + const timeRange2: TimeRange = { + from: dateTime('2023-10-20T05:04:00.000Z'), + to: dateTime('2023-10-20T07:22:00.000Z'), + raw: { + from: dateTime('2023-10-20T05:04:00.000Z'), + to: dateTime('2023-10-20T07:22:00.000Z'), + }, + }; + + const frames = [ + toDataFrame({ + name: 'Mix', + fields: [ + { name: 'time', type: FieldType.time, values: [1697778291972, 1697778393992, 1697778986994, 1697786485890] }, + { name: 'state', type: FieldType.string, values: ['RUN', null, 'RUN', null] }, + ], + }), + toDataFrame({ + name: 'Cook', + fields: [ + { + name: 'time', + type: FieldType.time, + values: [ + 1697779163986, 1697779921045, 1697780221094, 1697780521111, 1697781186192, 1697781786291, 1697783332361, + 1697783784395, 1697783790397, 1697784146478, 1697784517471, 1697784523487, 1697784949480, 1697785369505, + ], + }, + { + name: 'state', + type: FieldType.string, + values: [ + 'Heat', + 'Stage', + null, + 'Heat', + 'Stage', + null, + 'Heat', + 'Stage', + null, + 'Heat', + 'Stage', + null, + 'CCP', + null, + ], + }, + ], + }), + ]; + + const info = prepareTimelineFields(frames, true, timeRange2, theme); + + let joined = preparePlotFrame( + info.frames!, + { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.byType).get('string'), + }, + timeRange2 + ); + + let vals = joined!.fields.map((f) => f.values); + + expect(vals).toEqual([ + [ + 1697778291972, 1697778393992, 1697778986994, 1697779163986, 1697779921045, 1697780221094, 1697780521111, + 1697781186192, 1697781786291, 1697783332361, 1697783784395, 1697783790397, 1697784146478, 1697784517471, + 1697784523487, 1697784949480, 1697785369505, 1697786485890, + ], + [ + 'RUN', + null, + 'RUN', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + null, + ], + [ + undefined, + undefined, + undefined, + 'Heat', + 'Stage', + null, + 'Heat', + 'Stage', + null, + 'Heat', + 'Stage', + null, + 'Heat', + 'Stage', + null, + 'CCP', + null, + undefined, + ], + ]); + }); + + it('join multiple frames with start and end time fields', () => { + const timeRange2: TimeRange = { + from: dateTime('2024-02-28T07:47:21.428Z'), + to: dateTime('2024-02-28T14:12:43.391Z'), + raw: { + from: dateTime('2024-02-28T07:47:21.428Z'), + to: dateTime('2024-02-28T14:12:43.391Z'), + }, + }; + + const frames = [ + toDataFrame({ + name: 'Channel 1', + fields: [ + { name: 'starttime', type: FieldType.time, values: [1709107200000, 1709118000000] }, + { name: 'endtime', type: FieldType.time, values: [1709114400000, 1709128800000] }, + { name: 'state', type: FieldType.string, values: ['OK', 'NO_DATA'] }, + ], + }), + toDataFrame({ + name: 'Channel 2', + fields: [ + { name: 'starttime', type: FieldType.time, values: [1709110800000, 1709123400000] }, + { name: 'endtime', type: FieldType.time, values: [1709116200000, 1709127000000] }, + { name: 'state', type: FieldType.string, values: ['ERROR', 'WARNING'] }, + ], + }), + ]; + + const info = prepareTimelineFields(frames, true, timeRange2, theme); + + let joined = preparePlotFrame( + info.frames!, + { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.byType).get('string'), + }, + timeRange2 + ); + + let vals = joined!.fields.map((f) => f.values); + + expect(vals).toEqual([ + [ + 1709107200000, 1709110800000, 1709114400000, 1709116200000, 1709118000000, 1709123400000, 1709127000000, + 1709128800000, + ], + ['OK', undefined, null, undefined, 'NO_DATA', undefined, undefined, null], + [undefined, 'ERROR', undefined, null, undefined, 'WARNING', null, undefined], + ]); + }); }); describe('findNextStateIndex', () => { diff --git a/public/app/core/components/TimelineChart/utils.ts b/public/app/core/components/TimelineChart/utils.ts index a372190209b74..6a7bccfed0ae4 100644 --- a/public/app/core/components/TimelineChart/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -4,9 +4,6 @@ import uPlot from 'uplot'; import { DataFrame, DashboardCursorSync, - DataHoverPayload, - DataHoverEvent, - DataHoverClearEvent, FALLBACK_COLOR, Field, FieldColorModeId, @@ -21,8 +18,10 @@ import { getFieldConfigWithMinMax, ThresholdsMode, TimeRange, + cacheFieldDisplayNames, + outerJoinDataFrames, } from '@grafana/data'; -import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames'; +import { maybeSortFrame, NULL_RETAIN } from '@grafana/data/src/transformations/transformers/joinDataFrames'; import { applyNullInsertThreshold } from '@grafana/data/src/transformations/transformers/nulls/nullInsertThreshold'; import { nullToValue } from '@grafana/data/src/transformations/transformers/nulls/nullToValue'; import { @@ -63,6 +62,7 @@ interface UPlotConfigOptions { getValueColor: (frameIdx: number, fieldIdx: number, value: unknown) => string; // Identifies the shared key for uPlot cursor sync eventsScope?: string; + hoverMulti: boolean; } /** @@ -96,7 +96,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = ( timeZones, getTimeRange, mode, - eventBus, sync, rowHeight, colWidth, @@ -105,10 +104,10 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = ( mergeValues, getValueColor, eventsScope = '__global_', + hoverMulti, }) => { const builder = new UPlotConfigBuilder(timeZones[0]); - const xScaleUnit = 'time'; const xScaleKey = 'x'; const isDiscrete = (field: Field) => { @@ -165,6 +164,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = ( hoveredDataIdx = null; shouldChangeHover = true; }, + hoverMulti, }; let shouldChangeHover = false; @@ -172,13 +172,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = ( let hoveredDataIdx: number | null = null; const coreConfig = getConfig(opts); - const payload: DataHoverPayload = { - point: { - [xScaleUnit]: null, - [FIXED_UNIT]: null, - }, - data: frame, - }; builder.addHook('init', coreConfig.init); builder.addHook('drawClear', coreConfig.drawClear); @@ -286,25 +279,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = ( cursor.sync = { key: eventsScope, - filters: { - pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => { - if (sync && sync() === DashboardCursorSync.Off) { - return false; - } - payload.rowIndex = dataIdx; - if (x < 0 && y < 0) { - payload.point[xScaleUnit] = null; - payload.point[FIXED_UNIT] = null; - eventBus.publish(new DataHoverClearEvent()); - } else { - payload.point[xScaleUnit] = src.posToVal(x, xScaleKey); - payload.point.panelRelY = y > 0 ? y / h : 1; // used for old graph panel to position tooltip - payload.down = undefined; - eventBus.publish(new DataHoverEvent(payload)); - } - return true; - }, - }, scales: [xScaleKey, null], }; builder.setSync(); @@ -433,19 +407,68 @@ export function prepareTimelineFields( if (!series?.length) { return { warn: 'No data in response' }; } + + cacheFieldDisplayNames(series); + let hasTimeseries = false; const frames: DataFrame[] = []; for (let frame of series) { - let isTimeseries = false; + let startFieldIdx = -1; + let endFieldIdx = -1; + + for (let i = 0; i < frame.fields.length; i++) { + let f = frame.fields[i]; + + if (f.type === FieldType.time) { + if (startFieldIdx === -1) { + startFieldIdx = i; + } else if (endFieldIdx === -1) { + endFieldIdx = i; + break; + } + } + } + + let isTimeseries = startFieldIdx !== -1; let changed = false; - let maybeSortedFrame = maybeSortFrame( - frame, - frame.fields.findIndex((f) => f.type === FieldType.time) - ); + frame = maybeSortFrame(frame, startFieldIdx); + + // if we have a second time field, assume it is state end timestamps + // and insert nulls into the data at the end timestamps + if (endFieldIdx !== -1) { + let startFrame: DataFrame = { + ...frame, + fields: frame.fields.filter((f, i) => i !== endFieldIdx), + }; + + let endFrame: DataFrame = { + length: frame.length, + fields: [frame.fields[endFieldIdx]], + }; + + frame = outerJoinDataFrames({ + frames: [startFrame, endFrame], + keepDisplayNames: true, + nullMode: () => NULL_RETAIN, + })!; + + frame.fields.forEach((f, i) => { + if (i > 0) { + let vals = f.values; + for (let i = 0; i < vals.length; i++) { + if (vals[i] == null) { + vals[i] = null; + } + } + } + }); + + changed = true; + } let nulledFrame = applyNullInsertThreshold({ - frame: maybeSortedFrame, + frame, refFieldPseudoMin: timeRange.from.valueOf(), refFieldPseudoMax: timeRange.to.valueOf(), }); @@ -454,8 +477,10 @@ export function prepareTimelineFields( changed = true; } + frame = nullToValue(nulledFrame); + const fields: Field[] = []; - for (let field of nullToValue(nulledFrame).fields) { + for (let field of frame.fields) { if (field.config.custom?.hideFrom?.viz) { continue; } @@ -488,6 +513,7 @@ export function prepareTimelineFields( }, }, }; + changed = true; fields.push(field); break; default: @@ -498,11 +524,11 @@ export function prepareTimelineFields( hasTimeseries = true; if (changed) { frames.push({ - ...maybeSortedFrame, + ...frame, fields, }); } else { - frames.push(maybeSortedFrame); + frames.push(frame); } } } @@ -513,6 +539,7 @@ export function prepareTimelineFields( if (!frames.length) { return { warn: 'No graphable fields' }; } + return { frames }; } @@ -693,19 +720,19 @@ export function fmtDuration(milliSeconds: number): string { yr > 0 ? yr + 'y ' + (mo > 0 ? mo + 'mo ' : '') + (wk > 0 ? wk + 'w ' : '') + (d > 0 ? d + 'd ' : '') : mo > 0 - ? mo + 'mo ' + (wk > 0 ? wk + 'w ' : '') + (d > 0 ? d + 'd ' : '') - : wk > 0 - ? wk + 'w ' + (d > 0 ? d + 'd ' : '') - : d > 0 - ? d + 'd ' + (h > 0 ? h + 'h ' : '') - : h > 0 - ? h + 'h ' + (m > 0 ? m + 'm ' : '') - : m > 0 - ? m + 'm ' + (s > 0 ? s + 's ' : '') - : s > 0 - ? s + 's ' + (ms > 0 ? ms + 'ms ' : '') - : ms > 0 - ? ms + 'ms ' - : '0' + ? mo + 'mo ' + (wk > 0 ? wk + 'w ' : '') + (d > 0 ? d + 'd ' : '') + : wk > 0 + ? wk + 'w ' + (d > 0 ? d + 'd ' : '') + : d > 0 + ? d + 'd ' + (h > 0 ? h + 'h ' : '') + : h > 0 + ? h + 'h ' + (m > 0 ? m + 'm ' : '') + : m > 0 + ? m + 'm ' + (s > 0 ? s + 's ' : '') + : s > 0 + ? s + 's ' + (ms > 0 ? ms + 'ms ' : '') + : ms > 0 + ? ms + 'ms ' + : '0' ).trim(); } diff --git a/public/app/core/components/ValidationLabels/ValidationLabels.tsx b/public/app/core/components/ValidationLabels/ValidationLabels.tsx new file mode 100644 index 0000000000000..20f50b367083a --- /dev/null +++ b/public/app/core/components/ValidationLabels/ValidationLabels.tsx @@ -0,0 +1,123 @@ +import { css, cx } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Box, Icon, Text, useStyles2 } from '@grafana/ui'; +import config from 'app/core/config'; +import { t } from 'app/core/internationalization'; + +interface StrongPasswordValidation { + message: string; + validation: (value: string) => boolean; +} + +export interface ValidationLabelsProps { + strongPasswordValidations: StrongPasswordValidation[]; + password: string; + pristine: boolean; +} + +export interface ValidationLabelProps { + strongPasswordValidation: StrongPasswordValidation; + password: string; + pristine: boolean; +} + +export const strongPasswordValidations: StrongPasswordValidation[] = [ + { + message: 'At least 12 characters', + validation: (value: string) => value.length >= 12, + }, + { + message: 'One uppercase letter', + validation: (value: string) => /[A-Z]+/.test(value), + }, + { + message: 'One lowercase letter', + validation: (value: string) => /[a-z]+/.test(value), + }, + { + message: 'One number', + validation: (value: string) => /[0-9]+/.test(value), + }, + { + message: 'One symbol', + validation: (value: string) => /[\W]/.test(value), + }, +]; + +export const strongPasswordValidationRegister = (value: string) => { + return ( + !config.auth.basicAuthStrongPasswordPolicy || + strongPasswordValidations.every((validation) => validation.validation(value)) || + t( + 'profile.change-password.strong-password-validation-register', + 'Password does not comply with the strong password policy' + ) + ); +}; + +export const ValidationLabels = ({ strongPasswordValidations, password, pristine }: ValidationLabelsProps) => { + return ( + <Box marginBottom={2}> + {strongPasswordValidations.map((validation) => ( + <ValidationLabel + key={validation.message} + strongPasswordValidation={validation} + password={password} + pristine={pristine} + /> + ))} + </Box> + ); +}; + +export const ValidationLabel = ({ strongPasswordValidation, password, pristine }: ValidationLabelProps) => { + const styles = useStyles2(getStyles); + + const { basicAuthStrongPasswordPolicy } = config.auth; + if (!basicAuthStrongPasswordPolicy) { + return null; + } + + const { message, validation } = strongPasswordValidation; + const result = password.length > 0 && validation(password); + + const iconName = result || pristine ? 'check' : 'exclamation-triangle'; + const textColor = result ? 'secondary' : pristine ? 'primary' : 'error'; + + let iconClassName = undefined; + if (result) { + iconClassName = styles.icon.valid; + } else if (pristine) { + iconClassName = styles.icon.pending; + } else { + iconClassName = styles.icon.error; + } + + return ( + <Box key={message} display={'flex'} alignItems={'center'} marginTop={1}> + <Icon className={cx(styles.icon.style, iconClassName)} name={iconName} /> + <Text color={textColor}>{message}</Text> + </Box> + ); +}; + +export const getStyles = (theme: GrafanaTheme2) => { + return { + icon: { + style: css({ + marginRight: theme.spacing(1), + }), + valid: css({ + color: theme.colors.success.text, + }), + pending: css({ + color: theme.colors.secondary.text, + }), + error: css({ + color: theme.colors.error.text, + }), + }, + }; +}; diff --git a/public/app/core/components/help/HelpModal.tsx b/public/app/core/components/help/HelpModal.tsx index f13cf21700430..3d3b3f5f9049d 100644 --- a/public/app/core/components/help/HelpModal.tsx +++ b/public/app/core/components/help/HelpModal.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import React, { useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Modal, useStyles2 } from '@grafana/ui'; +import { Grid, Modal, useStyles2, Text } from '@grafana/ui'; import { t } from 'app/core/internationalization'; import { getModKey } from 'app/core/utils/browser'; @@ -27,16 +27,51 @@ const getShortcuts = (modKey: string) => { description: t('help-modal.shortcuts-description.exit-edit/setting-views', 'Exit edit/setting views'), }, { - keys: ['h'], + keys: [`${modKey} + h`], description: t('help-modal.shortcuts-description.show-all-shortcuts', 'Show all keyboard shortcuts'), }, { keys: ['c', 't'], description: t('help-modal.shortcuts-description.change-theme', 'Change theme') }, ], }, + { + category: t('help-modal.shortcuts-category.time-range', 'Time range'), + shortcuts: [ + { + keys: ['t', 'z'], + description: t('help-modal.shortcuts-description.zoom-out-time-range', 'Zoom out time range'), + }, + { + keys: ['t', '←'], + description: t('help-modal.shortcuts-description.move-time-range-back', 'Move time range back'), + }, + { + keys: ['t', '→'], + description: t('help-modal.shortcuts-description.move-time-range-forward', 'Move time range forward'), + }, + { + keys: ['t', 'a'], + description: t( + 'help-modal.shortcuts-description.make-time-range-permanent', + 'Make time range absolute/permanent' + ), + }, + { + keys: ['t', 'c'], + description: t('help-modal.shortcuts-description.copy-time-range', 'Copy time range'), + }, + { + keys: ['t', 'v'], + description: t('help-modal.shortcuts-description.paste-time-range', 'Paste time range'), + }, + ], + }, { category: t('help-modal.shortcuts-category.dashboard', 'Dashboard'), shortcuts: [ - { keys: [`${modKey}+s`], description: t('help-modal.shortcuts-description.save-dashboard', 'Save dashboard') }, + { + keys: [`${modKey} + s`], + description: t('help-modal.shortcuts-description.save-dashboard', 'Save dashboard'), + }, { keys: ['d', 'r'], description: t('help-modal.shortcuts-description.refresh-all-panels', 'Refresh all panels'), @@ -53,8 +88,14 @@ const getShortcuts = (modKey: string) => { keys: ['d', 'k'], description: t('help-modal.shortcuts-description.toggle-kiosk', 'Toggle kiosk mode (hides top nav)'), }, - { keys: ['d', 'E'], description: t('help-modal.shortcuts-description.expand-all-rows', 'Expand all rows') }, - { keys: ['d', 'C'], description: t('help-modal.shortcuts-description.collapse-all-rows', 'Collapse all rows') }, + { + keys: ['d', '⇧ + e'], + description: t('help-modal.shortcuts-description.expand-all-rows', 'Expand all rows'), + }, + { + keys: ['d', '⇧ + c'], + description: t('help-modal.shortcuts-description.collapse-all-rows', 'Collapse all rows'), + }, { keys: ['d', 'a'], description: t( @@ -77,7 +118,7 @@ const getShortcuts = (modKey: string) => { ], }, { - category: t('help-modal.shortcuts-category.focused-panel', 'Focused Panel'), + category: t('help-modal.shortcuts-category.focused-panel', 'Focused panel'), shortcuts: [ { keys: ['e'], @@ -99,30 +140,6 @@ const getShortcuts = (modKey: string) => { }, ], }, - { - category: t('help-modal.shortcuts-category.time-range', 'Time Range'), - shortcuts: [ - { - keys: ['t', 'z'], - description: t('help-modal.shortcuts-description.zoom-out-time-range', 'Zoom out time range'), - }, - { - keys: ['t', '←'], - description: t('help-modal.shortcuts-description.move-time-range-back', 'Move time range back'), - }, - { - keys: ['t', '→'], - description: t('help-modal.shortcuts-description.move-time-range-forward', 'Move time range forward'), - }, - { - keys: ['t', 'a'], - description: t( - 'help-modal.shortcuts-description.make-time-range-permanent', - 'Make time range absolute/permanent' - ), - }, - ], - }, ]; }; @@ -132,97 +149,110 @@ export interface HelpModalProps { export const HelpModal = ({ onDismiss }: HelpModalProps): JSX.Element => { const styles = useStyles2(getStyles); + const modKey = useMemo(() => getModKey(), []); const shortcuts = useMemo(() => getShortcuts(modKey), [modKey]); return ( <Modal title={t('help-modal.title', 'Shortcuts')} isOpen onDismiss={onDismiss} onClickBackdrop={onDismiss}> - <div className={styles.categories}> - {Object.values(shortcuts).map(({ category, shortcuts }, i) => ( - <div className={styles.shortcutCategory} key={i}> - <table className={styles.shortcutTable}> - <tbody> + <Grid columns={{ xs: 1, sm: 2 }} gap={3} tabIndex={0}> + {Object.values(shortcuts).map(({ category, shortcuts }) => ( + <section key={category}> + <table className={styles.table}> + <caption> + <Text element="p" variant="h5"> + {category} + </Text> + </caption> + <thead className="sr-only"> <tr> - <th className={styles.shortcutTableCategoryHeader} colSpan={2}> - {category} - </th> + <th>Keys</th> + <th>Description</th> </tr> - {shortcuts.map((shortcut, j) => ( - <tr key={`${i}-${j}`}> - <td className={styles.shortcutTableKeys}> - {shortcut.keys.map((key, k) => ( - <span className={styles.shortcutTableKey} key={`${i}-${j}-${k}`}> - {key} - </span> + </thead> + <tbody> + {shortcuts.map(({ keys, description }) => ( + <tr key={keys.join()}> + <td className={styles.keys}> + {keys.map((key) => ( + <Key key={key}>{key}</Key> ))} </td> - <td className={styles.shortcutTableDescription}>{shortcut.description}</td> + <td> + <Text variant="bodySmall" element="p"> + {description} + </Text> + </td> </tr> ))} </tbody> </table> - </div> + </section> ))} - </div> + </Grid> </Modal> ); }; +interface KeyProps { + children: string; +} + +const Key = ({ children }: KeyProps) => { + const styles = useStyles2(getStyles); + const displayText = useMemo(() => replaceCustomKeyNames(children), [children]); + const displayElement = <span dangerouslySetInnerHTML={{ __html: displayText }}></span>; + return ( + <kbd className={styles.shortcutTableKey}> + <Text variant="code">{displayElement}</Text> + </kbd> + ); +}; + +function replaceCustomKeyNames(key: string) { + let displayName; + let srName; + + if (key.includes('ctrl')) { + displayName = 'ctrl'; + srName = 'Control'; + } else if (key.includes('esc')) { + displayName = 'esc'; + srName = 'Escape'; + } else { + return key; + } + + return key.replace( + displayName, + `<span class="sr-only">${srName}</span><span aria-hidden="true" role="none">${displayName}</span>` + ); +} + function getStyles(theme: GrafanaTheme2) { return { - titleDescription: css` - font-size: ${theme.typography.bodySmall.fontSize}; - font-weight: ${theme.typography.bodySmall.fontWeight}; - color: ${theme.colors.text.disabled}; - padding-bottom: ${theme.spacing(2)}; - `, - categories: css` - font-size: ${theme.typography.bodySmall.fontSize}; - display: flex; - flex-flow: row wrap; - justify-content: space-between; - align-items: flex-start; - `, - shortcutCategory: css` - width: 50%; - font-size: ${theme.typography.bodySmall.fontSize}; - `, - shortcutTable: css` - margin-bottom: ${theme.spacing(2)}; - `, - shortcutTableCategoryHeader: css` - font-weight: normal; - font-size: ${theme.typography.h6.fontSize}; - text-align: left; - `, - shortcutTableDescription: css` - text-align: left; - color: ${theme.colors.text.disabled}; - width: 99%; - padding: ${theme.spacing(1, 2)}; - `, - shortcutTableKeys: css` - white-space: nowrap; - width: 1%; - text-align: right; - color: ${theme.colors.text.primary}; - `, - shortcutTableKey: css` - display: inline-block; - text-align: center; - margin-right: ${theme.spacing(0.5)}; - padding: 3px 5px; - font: - 11px Consolas, - 'Liberation Mono', - Menlo, - Courier, - monospace; - line-height: 10px; - vertical-align: middle; - border: solid 1px ${theme.colors.border.medium}; - border-radius: ${theme.shape.borderRadius(3)}; - color: ${theme.colors.text.primary}; - background-color: ${theme.colors.background.secondary}; - `, + table: css({ + borderCollapse: 'separate', + borderSpacing: theme.spacing(2), + '& caption': { + captionSide: 'top', + }, + }), + keys: css({ + textAlign: 'end', + whiteSpace: 'nowrap', + minWidth: 83, // To match column widths with the widest + }), + shortcutTableKey: css({ + display: 'inline-block', + textAlign: 'center', + marginRight: theme.spacing(0.5), + padding: '3px 5px', + lineHeight: '10px', + verticalAlign: 'middle', + border: `solid 1px ${theme.colors.border.medium}`, + borderRadius: theme.shape.radius.default, + color: theme.colors.text.primary, + backgroundColor: theme.colors.background.secondary, + }), }; } diff --git a/public/app/core/context/GrafanaContext.ts b/public/app/core/context/GrafanaContext.ts index 7acf311c36800..b53788c23cc11 100644 --- a/public/app/core/context/GrafanaContext.ts +++ b/public/app/core/context/GrafanaContext.ts @@ -1,10 +1,10 @@ -import React, { useContext } from 'react'; +import React, { useCallback, useContext } from 'react'; import { GrafanaConfig } from '@grafana/data'; -import { LocationService } from '@grafana/runtime/src/services/LocationService'; -import { BackendSrv } from '@grafana/runtime/src/services/backendSrv'; +import { LocationService, locationService, BackendSrv } from '@grafana/runtime'; import { AppChromeService } from '../components/AppChrome/AppChromeService'; +import { NewFrontendAssetsChecker } from '../services/NewFrontendAssetsChecker'; import { KeybindingSrv } from '../services/keybindingSrv'; export interface GrafanaContextType { @@ -13,6 +13,7 @@ export interface GrafanaContextType { config: GrafanaConfig; chrome: AppChromeService; keybindings: KeybindingSrv; + newAssetsChecker: NewFrontendAssetsChecker; } export const GrafanaContext = React.createContext<GrafanaContextType | undefined>(undefined); @@ -24,3 +25,19 @@ export function useGrafana(): GrafanaContextType { } return context; } + +// Implementation of useReturnToPrevious that's made available through +// @grafana/runtime +export function useReturnToPreviousInternal() { + const { chrome } = useGrafana(); + return useCallback( + (title: string, href?: string) => { + const { pathname, search } = locationService.getLocation(); + chrome.setReturnToPrevious({ + title: title, + href: href ?? pathname + search, + }); + }, + [chrome] + ); +} diff --git a/public/app/core/history/RichHistoryLocalStorage.ts b/public/app/core/history/RichHistoryLocalStorage.ts index 737246ca301da..ab84d420d491f 100644 --- a/public/app/core/history/RichHistoryLocalStorage.ts +++ b/public/app/core/history/RichHistoryLocalStorage.ts @@ -37,10 +37,16 @@ export default class RichHistoryLocalStorage implements RichHistoryStorage { const allQueries = getRichHistoryDTOs().map(fromDTO); const queries = filters.starred ? allQueries.filter((q) => q.starred === true) : allQueries; - const richHistory = filterAndSortQueries(queries, filters.sortOrder, filters.datasourceFilters, filters.search, [ - filters.from, - filters.to, - ]); + const timeFilter: [number, number] | undefined = + filters.from && filters.to ? [filters.from, filters.to] : undefined; + + const richHistory = filterAndSortQueries( + queries, + filters.sortOrder, + filters.datasourceFilters, + filters.search, + timeFilter + ); return { richHistory, total: richHistory.length }; } diff --git a/public/app/core/history/richHistoryLocalStorageUtils.ts b/public/app/core/history/richHistoryLocalStorageUtils.ts index f5ff6da81502e..7f58de9a860ea 100644 --- a/public/app/core/history/richHistoryLocalStorageUtils.ts +++ b/public/app/core/history/richHistoryLocalStorageUtils.ts @@ -57,8 +57,8 @@ function filterQueriesBySearchFilter(queries: RichHistoryQuery[], searchFilter: const listOfMatchingQueries = query.queries.filter((query) => // Remove fields in which we don't want to be searching - Object.values(omit(query, ['datasource', 'key', 'refId', 'hide', 'queryType'])).some( - (value) => value?.toString().includes(searchFilter) + Object.values(omit(query, ['datasource', 'key', 'refId', 'hide', 'queryType'])).some((value) => + value?.toString().includes(searchFilter) ) ); diff --git a/public/app/core/history/richHistoryStorageProvider.ts b/public/app/core/history/richHistoryStorageProvider.ts index 44337ffbb0e93..84defd0f001bb 100644 --- a/public/app/core/history/richHistoryStorageProvider.ts +++ b/public/app/core/history/richHistoryStorageProvider.ts @@ -10,10 +10,16 @@ import RichHistoryStorage from './RichHistoryStorage'; const richHistoryLocalStorage = new RichHistoryLocalStorage(); const richHistoryRemoteStorage = new RichHistoryRemoteStorage(); +// for query history operations export const getRichHistoryStorage = (): RichHistoryStorage => { return config.queryHistoryEnabled ? richHistoryRemoteStorage : richHistoryLocalStorage; }; +// for autocomplete read and write operations +export const getLocalRichHistoryStorage = (): RichHistoryStorage => { + return richHistoryLocalStorage; +}; + interface RichHistorySupportedFeatures { availableFilters: SortOrder[]; lastUsedDataSourcesAvailable: boolean; diff --git a/public/app/core/internationalization/index.test.tsx b/public/app/core/internationalization/index.test.tsx new file mode 100644 index 0000000000000..6f83245b5b0c5 --- /dev/null +++ b/public/app/core/internationalization/index.test.tsx @@ -0,0 +1,26 @@ +import { render } from '@testing-library/react'; +import React from 'react'; + +import { Trans } from './index'; + +describe('internationalization', () => { + describe('Trans component', () => { + it('should interpolate strings without escaping dangerous characters', () => { + const name = '<script></script>'; + const { getByText } = render(<Trans i18nKey="explore.table.title-with-name">Table - {{ name }}</Trans>); + + expect(getByText('Table - <script></script>')).toBeInTheDocument(); + }); + + it('should escape dangerous characters when shouldUnescape is false', () => { + const name = '<script></script>'; + const { getByText } = render( + <Trans i18nKey="explore.table.title-with-name" shouldUnescape={false}> + Table - {{ name }} + </Trans> + ); + + expect(getByText('Table - <script></script>')).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/core/internationalization/index.tsx b/public/app/core/internationalization/index.tsx index 05eca138eb742..1b9d850c6d00e 100644 --- a/public/app/core/internationalization/index.tsx +++ b/public/app/core/internationalization/index.tsx @@ -3,7 +3,7 @@ import LanguageDetector, { DetectorOptions } from 'i18next-browser-languagedetec import React from 'react'; import { Trans as I18NextTrans, initReactI18next } from 'react-i18next'; // eslint-disable-line no-restricted-imports -import { LANGUAGES, VALID_LANGUAGES } from './constants'; +import { DEFAULT_LANGUAGE, LANGUAGES, VALID_LANGUAGES } from './constants'; const getLanguagePartFromCode = (code: string) => code.split('-')[0].toLowerCase(); @@ -23,7 +23,7 @@ const loadTranslations: BackendModule = { }, }; -export function initializeI18n(language: string) { +export function initializeI18n(language: string): Promise<{ language: string | undefined }> { // This is a placeholder so we can put a 'comment' in the message json files. // Starts with an underscore so it's sorted to the top of the file. Even though it is in a comment the following line is still extracted // t('_comment', 'This file is the source of truth for English strings. Edit this to change plurals and other phrases for the UI.'); @@ -35,19 +35,30 @@ export function initializeI18n(language: string) { // If translations are empty strings (no translation), fall back to the default value in source code returnEmptyString: false, + + // Required to ensure that `resolvedLanguage` is set property when an invalid language is passed (such as through 'detect') + supportedLngs: VALID_LANGUAGES, + fallbackLng: DEFAULT_LANGUAGE, }; - let init = i18n; + let i18nInstance = i18n; if (language === 'detect') { - init = init.use(LanguageDetector); + i18nInstance = i18nInstance.use(LanguageDetector); const detection: DetectorOptions = { order: ['navigator'], caches: [] }; options.detection = detection; } else { options.lng = VALID_LANGUAGES.includes(language) ? language : undefined; } - return init + + const loadPromise = i18nInstance .use(loadTranslations) .use(initReactI18next) // passes i18n down to react-i18next .init(options); + + return loadPromise.then(() => { + return { + language: i18nInstance.resolvedLanguage, + }; + }); } export function changeLanguage(locale: string) { @@ -56,7 +67,7 @@ export function changeLanguage(locale: string) { } export const Trans: typeof I18NextTrans = (props) => { - return <I18NextTrans {...props} />; + return <I18NextTrans shouldUnescape {...props} />; }; // Reassign t() so i18next-parser doesn't warn on dynamic key, and we can have 'failOnWarnings' enabled diff --git a/public/app/core/monacoEnv.ts b/public/app/core/monacoEnv.ts new file mode 100644 index 0000000000000..db63c867a1e07 --- /dev/null +++ b/public/app/core/monacoEnv.ts @@ -0,0 +1,32 @@ +import { monacoLanguageRegistry } from '@grafana/data'; +import { CorsWorker as Worker } from 'app/core/utils/CorsWorker'; + +export function setMonacoEnv() { + self.MonacoEnvironment = { + getWorker(_moduleId, label) { + const language = monacoLanguageRegistry.getIfExists(label); + + if (language) { + return language.init(); + } + + if (label === 'json') { + return new Worker(new URL('monaco-editor/esm/vs/language/json/json.worker', import.meta.url)); + } + + if (label === 'css' || label === 'scss' || label === 'less') { + return new Worker(new URL('monaco-editor/esm/vs/language/css/css.worker', import.meta.url)); + } + + if (label === 'html' || label === 'handlebars' || label === 'razor') { + return new Worker(new URL('monaco-editor/esm/vs/language/html/html.worker', import.meta.url)); + } + + if (label === 'typescript' || label === 'javascript') { + return new Worker(new URL('monaco-editor/esm/vs/language/typescript/ts.worker', import.meta.url)); + } + + return new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker', import.meta.url)); + }, + }; +} diff --git a/public/app/core/navigation/GrafanaRouteLoading.tsx b/public/app/core/navigation/GrafanaRouteLoading.tsx index 3e3675f691f16..01830aa745938 100644 --- a/public/app/core/navigation/GrafanaRouteLoading.tsx +++ b/public/app/core/navigation/GrafanaRouteLoading.tsx @@ -1,8 +1,7 @@ -import { css, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; import { BouncingLoader } from '../components/BouncingLoader/BouncingLoader'; @@ -11,11 +10,7 @@ export function GrafanaRouteLoading() { const styles = useStyles2(getStyles); return ( - <div - className={cx(styles.loadingPage, { - [styles.loadingPageDockedNav]: config.featureToggles.dockedMegaMenu, - })} - > + <div className={styles.loadingPage}> <BouncingLoader /> </div> ); @@ -23,13 +18,11 @@ export function GrafanaRouteLoading() { const getStyles = (theme: GrafanaTheme2) => ({ loadingPage: css({ + backgroundColor: theme.colors.background.primary, height: '100%', flexDrection: 'column', display: 'flex', justifyContent: 'center', alignItems: 'center', }), - loadingPageDockedNav: css({ - backgroundColor: theme.colors.background.primary, - }), }); diff --git a/public/app/core/navigation/types.ts b/public/app/core/navigation/types.ts index 5a06963cd0e71..9469221cb6dee 100644 --- a/public/app/core/navigation/types.ts +++ b/public/app/core/navigation/types.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { RouteComponentProps } from 'react-router-dom'; +import { RouteComponentProps } from 'react-router'; import { UrlQueryMap } from '@grafana/data'; diff --git a/public/app/core/reducers/root.test.ts b/public/app/core/reducers/root.test.ts index 6bb8712b23148..3396fc0d18c8c 100644 --- a/public/app/core/reducers/root.test.ts +++ b/public/app/core/reducers/root.test.ts @@ -9,6 +9,7 @@ import { createRootReducer } from './root'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), config: { + ...jest.requireActual('@grafana/runtime').config, bootData: { navTree: [], user: {}, diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 9ab94dbdf07e0..33d86889aee7e 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -2,7 +2,7 @@ import { ReducersMapObject } from '@reduxjs/toolkit'; import { AnyAction, combineReducers } from 'redux'; import sharedReducers from 'app/core/reducers'; -import { togglesApi } from 'app/features/admin/AdminFeatureTogglesAPI'; +import { migrateToCloudAPI } from 'app/features/admin/migrate-to-cloud/api'; import ldapReducers from 'app/features/admin/state/reducers'; import alertingReducers from 'app/features/alerting/state/reducers'; import apiKeysReducers from 'app/features/api-keys/state/reducers'; @@ -56,7 +56,7 @@ const rootReducers = { [alertingApi.reducerPath]: alertingApi.reducer, [publicDashboardApi.reducerPath]: publicDashboardApi.reducer, [browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer, - [togglesApi.reducerPath]: togglesApi.reducer, + [migrateToCloudAPI.reducerPath]: migrateToCloudAPI.reducer, }; const addedReducers = {}; diff --git a/public/app/core/services/NewFrontendAssetsChecker.test.ts b/public/app/core/services/NewFrontendAssetsChecker.test.ts new file mode 100644 index 0000000000000..33a45ec06401b --- /dev/null +++ b/public/app/core/services/NewFrontendAssetsChecker.test.ts @@ -0,0 +1,49 @@ +import { locationService, setBackendSrv, BackendSrv } from '@grafana/runtime'; + +import { NewFrontendAssetsChecker } from './NewFrontendAssetsChecker'; + +describe('NewFrontendAssetsChecker', () => { + const backendApiGet = jest.fn().mockReturnValue(Promise.resolve({})); + const locationReload = jest.fn(); + + const originalLocation = window.location; + + beforeAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { reload: locationReload }, + }); + }); + + afterAll(() => { + Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }); + }); + + setBackendSrv({ + get: backendApiGet, + } as unknown as BackendSrv); + + it('Should skip update checks if below interval', () => { + const checker = new NewFrontendAssetsChecker(); + checker.start(); + + locationService.push('/d/123'); + + expect(backendApiGet).toHaveBeenCalledTimes(0); + }); + + it('Should do update check when changing dashboard or going home', async () => { + const checker = new NewFrontendAssetsChecker(0); + checker.start(); + + locationService.push('/d/asd'); + locationService.push('/d/other'); + locationService.push('/d/other?viewPanel=2'); + locationService.push('/ignored'); + locationService.push('/ignored?asd'); + locationService.push('/ignored/sub'); + locationService.push('/home'); + + expect(backendApiGet).toHaveBeenCalledTimes(2); + }); +}); diff --git a/public/app/core/services/NewFrontendAssetsChecker.ts b/public/app/core/services/NewFrontendAssetsChecker.ts new file mode 100644 index 0000000000000..6a375add6fc2f --- /dev/null +++ b/public/app/core/services/NewFrontendAssetsChecker.ts @@ -0,0 +1,112 @@ +import { Location } from 'history'; +import { isEqual } from 'lodash'; + +import { getBackendSrv, getGrafanaLiveSrv, locationService, reportInteraction } from '@grafana/runtime'; + +export class NewFrontendAssetsChecker { + private hasUpdates = false; + private previous?: FrontendAssetsAPIDTO; + private interval: number; + private checked = Date.now(); + private prevLocationPath = ''; + + public constructor(interval?: number) { + // Default to never check for updates if last check was 5 minutes ago + this.interval = interval ?? 1000 * 60 * 5; + } + + public start() { + // Subscribe to live connection state changes and check for new assets when re-connected + const live = getGrafanaLiveSrv(); + + if (live) { + live.getConnectionState().subscribe((connected) => { + if (connected) { + this._checkForUpdates(); + } + }); + } + + // Subscribe to location changes + locationService.getHistory().listen(this.locationUpdated.bind(this)); + this.prevLocationPath = locationService.getLocation().pathname; + } + + /** + * Tries to detect some navigation events where it's safe to trigger a reload + */ + private locationUpdated(location: Location) { + if (this.prevLocationPath === location.pathname) { + return; + } + + const newLocationSegments = location.pathname.split('/'); + + // We are going to home + if (newLocationSegments[1] === '/' && this.prevLocationPath !== '/') { + this.reloadIfUpdateDetected(); + } + // Moving to dashboard (or changing dashboards) + else if (newLocationSegments[1] === 'd') { + this.reloadIfUpdateDetected(); + } + // Track potential page change + else if (this.hasUpdates) { + reportInteraction('new_frontend_assets_reload_ignored', { + newLocation: location.pathname, + prevLocation: this.prevLocationPath, + }); + } + + this.prevLocationPath = location.pathname; + } + + private async _checkForUpdates() { + if (this.hasUpdates) { + return; + } + + // Don't check too often + if (Date.now() - this.checked < this.interval) { + return; + } + + this.checked = Date.now(); + + const previous = this.previous; + const result: FrontendAssetsAPIDTO = await getBackendSrv().get('/api/frontend/assets'); + + if (previous && !isEqual(previous, result)) { + this.hasUpdates = true; + + // Report that we detected new assets + reportInteraction('new_frontend_assets_detected', { + assets: previous.assets !== result.assets, + plugins: previous.plugins !== result.plugins, + version: previous.version !== result.version, + flags: previous.flags !== result.flags, + }); + } + + this.previous = result; + } + + /** This is called on page navigation events */ + public reloadIfUpdateDetected() { + if (this.hasUpdates) { + // Report that we detected new assets + reportInteraction('new_frontend_assets_reload', {}); + window.location.reload(); + } + + // Async check if the assets have changed + this._checkForUpdates(); + } +} + +interface FrontendAssetsAPIDTO { + assets: string; + flags: string; + plugins: string; + version: string; +} diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index 9bbf41c9fa9c2..559fbff18ff30 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -19,7 +19,7 @@ import { AppEvents, DataQueryErrorType } from '@grafana/data'; import { BackendSrv as BackendService, BackendSrvRequest, config, FetchError, FetchResponse } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; import { getConfig } from 'app/core/config'; -import { getSessionExpiry } from 'app/core/utils/auth'; +import { getSessionExpiry, hasSessionExpiry } from 'app/core/utils/auth'; import { loadUrlToken } from 'app/core/utils/urlToken'; import { DashboardModel } from 'app/features/dashboard/state'; import { DashboardSearchItem } from 'app/features/search/types'; @@ -55,11 +55,16 @@ export interface FolderRequestOptions { const GRAFANA_TRACEID_HEADER = 'grafana-trace-id'; +export interface InspectorStream { + response: FetchResponse | FetchError; + requestId?: string; +} + export class BackendSrv implements BackendService { private inFlightRequests: Subject<string> = new Subject<string>(); private HTTP_REQUEST_CANCELED = -1; private noBackendCache: boolean; - private inspectorStream: Subject<FetchResponse | FetchError> = new Subject<FetchResponse | FetchError>(); + private inspectorStream: Subject<InspectorStream> = new Subject<InspectorStream>(); private readonly fetchQueue: FetchQueue; private readonly responseQueue: ResponseQueue; private _tokenRotationInProgress?: Observable<FetchResponse> | null = null; @@ -333,7 +338,7 @@ export class BackendSrv implements BackendService { }, 50); } - this.inspectorStream.next(err); + this.inspectorStream.next({ response: err, requestId: options.requestId }); return err; } @@ -356,7 +361,7 @@ export class BackendSrv implements BackendService { }), tap((response) => { this.showSuccessAlert(response); - this.inspectorStream.next(response); + this.inspectorStream.next({ response: response, requestId: options.requestId }); }) ); } @@ -385,10 +390,11 @@ export class BackendSrv implements BackendService { } let authChecker = this.loginPing(); - - const expired = getSessionExpiry() * 1000 < Date.now(); - if (config.featureToggles.clientTokenRotation && expired) { - authChecker = this.rotateToken(); + if (hasSessionExpiry()) { + const expired = getSessionExpiry() * 1000 < Date.now(); + if (expired) { + authChecker = this.rotateToken(); + } } return from(authChecker).pipe( @@ -446,7 +452,7 @@ export class BackendSrv implements BackendService { ); } - getInspectorStream(): Observable<FetchResponse | FetchError> { + getInspectorStream(): Observable<InspectorStream> { return this.inspectorStream; } diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index 6b49da8f97e25..ab1016fd71a30 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -15,6 +15,7 @@ export const AutoRefreshInterval = 'auto'; export class User implements Omit<CurrentUserInternal, 'lightTheme'> { isSignedIn: boolean; id: number; + uid: string; login: string; email: string; name: string; @@ -39,6 +40,7 @@ export class User implements Omit<CurrentUserInternal, 'lightTheme'> { constructor() { this.id = 0; + this.uid = ''; this.isGrafanaAdmin = false; this.isSignedIn = false; this.orgRole = ''; @@ -209,11 +211,6 @@ export class ContextSrv { return false; } - // skip if feature toggle is not enabled - if (!config.featureToggles.clientTokenRotation) { - return false; - } - // skip if there is no session to rotate // if a user has a session but not yet a session expiry cookie, can happen during upgrade // from an older version of grafana, we never schedule the job and the fallback logic @@ -227,7 +224,7 @@ export class ContextSrv { } private cancelTokenRotationJob() { - if (config.featureToggles.clientTokenRotation && this.tokenRotationJobId > 0) { + if (this.tokenRotationJobId > 0) { clearTimeout(this.tokenRotationJobId); } } diff --git a/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.test.ts b/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.test.ts index 0127f44ae3d64..fd3631eec7881 100644 --- a/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.test.ts +++ b/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.test.ts @@ -1,25 +1,50 @@ import { BuildInfo } from '@grafana/data'; import { GrafanaEdition } from '@grafana/data/src/types/config'; -import { BaseTransport, Instrumentation, InternalLoggerLevel } from '@grafana/faro-core'; -import { FetchTransport, initializeFaro } from '@grafana/faro-web-sdk'; -import { EchoEventType, EchoMeta } from '@grafana/runtime'; +import { Faro, Instrumentation } from '@grafana/faro-core'; +import * as faroWebSdkModule from '@grafana/faro-web-sdk'; +import { BrowserConfig, FetchTransport } from '@grafana/faro-web-sdk'; +import { EchoSrvTransport } from './EchoSrvTransport'; import { GrafanaJavascriptAgentBackend, GrafanaJavascriptAgentBackendOptions } from './GrafanaJavascriptAgentBackend'; -import { GrafanaJavascriptAgentEchoEvent } from './types'; - -jest.mock('@grafana/faro-web-sdk', () => { - const originalModule = jest.requireActual('@grafana/faro-web-sdk'); - return { - __esModule: true, - ...originalModule, - initializeFaro: jest.fn(), - }; -}); describe('GrafanaJavascriptAgentEchoBackend', () => { + let mockedSetUser: jest.Mock; + let initializeFaroMock: jest.SpyInstance<Faro, [config: BrowserConfig]>; + beforeEach(() => { + // arrange + mockedSetUser = jest.fn(); + const mockedInstrumentationsForConfig: Instrumentation[] = []; + const mockedInstrumentations = { + add: jest.fn(), + instrumentations: mockedInstrumentationsForConfig, + remove: jest.fn(), + }; + const mockedInternalLogger = { + prefix: 'Faro', + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + initializeFaroMock = jest.spyOn(faroWebSdkModule, 'initializeFaro').mockReturnValueOnce({ + ...faroWebSdkModule.faro, + api: { + ...faroWebSdkModule.faro.api, + setUser: mockedSetUser, + }, + config: { + ...faroWebSdkModule.faro.config, + instrumentations: mockedInstrumentationsForConfig, + }, + instrumentations: mockedInstrumentations, + internalLogger: mockedInternalLogger, + }); + }); + + afterEach(() => { jest.resetAllMocks(); - window.fetch = jest.fn(); jest.resetModules(); jest.clearAllMocks(); }); @@ -28,6 +53,7 @@ describe('GrafanaJavascriptAgentEchoBackend', () => { version: '1.0', commit: 'abcd123', env: 'production', + versionString: 'Grafana v1.0 (abcd123)', edition: GrafanaEdition.OpenSource, latestVersion: 'ba', hasUpdate: false, @@ -50,106 +76,27 @@ describe('GrafanaJavascriptAgentEchoBackend', () => { }, }; - it('will set up FetchTransport if customEndpoint is provided', async () => { + it('will set up FetchTransport if customEndpoint is provided', () => { // arrange - const originalModule = jest.requireActual('@grafana/faro-web-sdk'); - jest.mocked(initializeFaro).mockImplementation(originalModule.initializeFaro); + const constructorSpy = jest.spyOn(faroWebSdkModule, 'FetchTransport'); //act - const backend = new GrafanaJavascriptAgentBackend(options); + new GrafanaJavascriptAgentBackend(options); //assert - expect(backend.transports.length).toEqual(1); - expect(backend.transports[0]).toBeInstanceOf(FetchTransport); + expect(constructorSpy).toHaveBeenCalledTimes(1); + expect(initializeFaroMock).toHaveBeenCalledTimes(1); + expect(initializeFaroMock.mock.calls[0][0].transports?.length).toEqual(2); + expect(initializeFaroMock.mock.calls[0][0].transports?.[0]).toBeInstanceOf(EchoSrvTransport); + expect(initializeFaroMock.mock.calls[0][0].transports?.[1]).toBeInstanceOf(FetchTransport); }); it('will initialize GrafanaJavascriptAgent and set user', async () => { - // arrange - const mockedSetUser = jest.fn(); - const mockedInstrumentationsForConfig: Instrumentation[] = []; - const mockedInstrumentations = { - add: jest.fn(), - instrumentations: mockedInstrumentationsForConfig, - remove: jest.fn(), - }; - const mockedInternalLogger = { - prefix: 'Faro', - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }; - const mockedAgent = () => { - return { - api: { - setUser: mockedSetUser, - pushLog: jest.fn(), - callOriginalConsoleMethod: jest.fn(), - pushError: jest.fn(), - pushMeasurement: jest.fn(), - pushTraces: jest.fn(), - pushEvent: jest.fn(), - initOTEL: jest.fn(), - getOTEL: jest.fn(), - getTraceContext: jest.fn(), - changeStacktraceParser: jest.fn(), - getStacktraceParser: jest.fn(), - isOTELInitialized: jest.fn(), - setSession: jest.fn(), - getSession: jest.fn(), - resetUser: jest.fn(), - resetSession: jest.fn(), - setView: jest.fn(), - getView: jest.fn(), - }, - config: { - globalObjectKey: '', - preventGlobalExposure: false, - transports: [], - instrumentations: mockedInstrumentationsForConfig, - metas: [], - parseStacktrace: jest.fn(), - app: jest.fn(), - paused: false, - dedupe: true, - isolate: false, - internalLoggerLevel: InternalLoggerLevel.ERROR, - unpatchedConsole: { ...console }, - }, - metas: { - add: jest.fn(), - remove: jest.fn(), - value: {}, - addListener: jest.fn(), - removeListener: jest.fn(), - }, - transports: { - add: jest.fn(), - execute: jest.fn(), - transports: [], - pause: jest.fn(), - unpause: jest.fn(), - addBeforeSendHooks: jest.fn(), - addIgnoreErrorsPatterns: jest.fn(), - getBeforeSendHooks: jest.fn(), - isPaused: jest.fn(), - remove: jest.fn(), - removeBeforeSendHooks: jest.fn(), - }, - pause: jest.fn(), - unpause: jest.fn(), - instrumentations: mockedInstrumentations, - internalLogger: mockedInternalLogger, - unpatchedConsole: { ...console }, - }; - }; - jest.mocked(initializeFaro).mockImplementation(mockedAgent); - //act new GrafanaJavascriptAgentBackend(options); //assert - expect(initializeFaro).toHaveBeenCalledTimes(1); + expect(initializeFaroMock).toHaveBeenCalledTimes(1); expect(mockedSetUser).toHaveBeenCalledTimes(1); expect(mockedSetUser).toHaveBeenCalledWith({ id: '504', @@ -159,111 +106,6 @@ describe('GrafanaJavascriptAgentEchoBackend', () => { }); }); - it('will forward events to transports', async () => { - //arrange - const mockedSetUser = jest.fn(); - const mockedInstrumentationsForConfig: Instrumentation[] = []; - const mockedInstrumentations = { - add: jest.fn(), - instrumentations: mockedInstrumentationsForConfig, - remove: jest.fn(), - }; - const mockedInternalLogger = { - prefix: 'Faro', - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }; - const mockedAgent = () => { - return { - api: { - setUser: mockedSetUser, - pushLog: jest.fn(), - callOriginalConsoleMethod: jest.fn(), - pushError: jest.fn(), - pushMeasurement: jest.fn(), - pushTraces: jest.fn(), - pushEvent: jest.fn(), - initOTEL: jest.fn(), - getOTEL: jest.fn(), - getTraceContext: jest.fn(), - changeStacktraceParser: jest.fn(), - getStacktraceParser: jest.fn(), - isOTELInitialized: jest.fn(), - setSession: jest.fn(), - getSession: jest.fn(), - resetUser: jest.fn(), - resetSession: jest.fn(), - setView: jest.fn(), - getView: jest.fn(), - }, - config: { - globalObjectKey: '', - preventGlobalExposure: false, - transports: [], - instrumentations: mockedInstrumentationsForConfig, - metas: [], - parseStacktrace: jest.fn(), - app: jest.fn(), - paused: false, - dedupe: true, - isolate: false, - internalLoggerLevel: InternalLoggerLevel.ERROR, - unpatchedConsole: { ...console }, - }, - metas: { - add: jest.fn(), - remove: jest.fn(), - value: {}, - addListener: jest.fn(), - removeListener: jest.fn(), - }, - transports: { - add: jest.fn(), - execute: jest.fn(), - transports: [], - pause: jest.fn(), - unpause: jest.fn(), - addBeforeSendHooks: jest.fn(), - addIgnoreErrorsPatterns: jest.fn(), - getBeforeSendHooks: jest.fn(), - isPaused: jest.fn(), - remove: jest.fn(), - removeBeforeSendHooks: jest.fn(), - }, - pause: jest.fn(), - unpause: jest.fn(), - instrumentations: mockedInstrumentations, - internalLogger: mockedInternalLogger, - unpatchedConsole: { ...console }, - }; - }; - - jest.mocked(initializeFaro).mockImplementation(mockedAgent); - const backend = new GrafanaJavascriptAgentBackend({ - ...options, - preventGlobalExposure: true, - }); - - backend.transports = [ - /* eslint-disable */ - { send: jest.fn() } as unknown as BaseTransport, - { send: jest.fn() } as unknown as BaseTransport, - ]; - const event: GrafanaJavascriptAgentEchoEvent = { - type: EchoEventType.GrafanaJavascriptAgent, - payload: { foo: 'bar' } as unknown as GrafanaJavascriptAgentEchoEvent, - meta: {} as unknown as EchoMeta, - }; - /* eslint-enable */ - backend.addEvent(event); - backend.transports.forEach((transport) => { - expect(transport.send).toHaveBeenCalledTimes(1); - expect(transport.send).toHaveBeenCalledWith(event.payload); - }); - }); - //@FIXME - make integration test work // it('integration test with EchoSrv and GrafanaJavascriptAgent', async () => { diff --git a/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.ts b/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.ts index b93448c2599df..c20b8ec34f1d2 100644 --- a/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.ts +++ b/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.ts @@ -1,13 +1,14 @@ import { BuildInfo } from '@grafana/data'; -import { BaseTransport } from '@grafana/faro-core'; +import { BaseTransport, defaultInternalLoggerLevel } from '@grafana/faro-core'; import { initializeFaro, - defaultMetas, BrowserConfig, ErrorsInstrumentation, ConsoleInstrumentation, WebVitalsInstrumentation, + SessionInstrumentation, FetchTransport, + type Instrumentation, } from '@grafana/faro-web-sdk'; import { EchoBackend, EchoEvent, EchoEventType } from '@grafana/runtime'; @@ -28,15 +29,15 @@ export class GrafanaJavascriptAgentBackend { supportedEvents = [EchoEventType.GrafanaJavascriptAgent]; private faroInstance; - transports: BaseTransport[]; constructor(public options: GrafanaJavascriptAgentBackendOptions) { - // configure instrumentalizations - const instrumentations = []; - this.transports = []; + // configure instrumentations. + const instrumentations: Instrumentation[] = []; + + const transports: BaseTransport[] = [new EchoSrvTransport()]; if (options.customEndpoint) { - this.transports.push(new FetchTransport({ url: options.customEndpoint, apiKey: options.apiKey })); + transports.push(new FetchTransport({ url: options.customEndpoint, apiKey: options.apiKey })); } if (options.errorInstrumentalizationEnabled) { @@ -49,6 +50,9 @@ export class GrafanaJavascriptAgentBackend instrumentations.push(new WebVitalsInstrumentation()); } + // session instrumentation must be added! + instrumentations.push(new SessionInstrumentation()); + // initialize GrafanaJavascriptAgent so it can set up its hooks and start collecting errors const grafanaJavaScriptAgentOptions: BrowserConfig = { globalObjectKey: options.globalObjectKey || 'faro', @@ -58,22 +62,19 @@ export class GrafanaJavascriptAgentBackend environment: options.buildInfo.env, }, instrumentations, - transports: [new EchoSrvTransport()], + transports, ignoreErrors: [ 'ResizeObserver loop limit exceeded', 'ResizeObserver loop completed', 'Non-Error exception captured with keys', ], - metas: [...defaultMetas], sessionTracking: { persistent: true, - generateSessionId() { - return (Math.random() + 1).toString(36).substring(2); - }, }, batching: { sendTimeout: 1000, }, + internalLoggerLevel: options.internalLoggerLevel || defaultInternalLoggerLevel, }; this.faroInstance = initializeFaro(grafanaJavaScriptAgentOptions); @@ -87,9 +88,8 @@ export class GrafanaJavascriptAgentBackend } } - addEvent = (e: EchoEvent) => { - this.transports.forEach((t) => t.send(e.payload)); - }; + // noop because the EchoSrvTransport registered in Faro will already broadcast all signals emitted by the Faro API + addEvent = (e: EchoEvent) => {}; // backend will log events to stdout, and at least in case of hosted grafana they will be // ingested into Loki. Due to Loki limitations logs cannot be backdated, diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 2f126c22145ed..2d48dee13c67a 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -18,6 +18,8 @@ import { ShowModalReactEvent, ZoomOutEvent, AbsoluteTimeEvent, + CopyTimeEvent, + PasteTimeEvent, } from '../../types/events'; import { AppChromeService } from '../components/AppChrome/AppChromeService'; import { HelpModal } from '../components/help/HelpModal'; @@ -38,7 +40,7 @@ export class KeybindingSrv { // Chromeless pages like login and signup page don't get any global bindings if (!route.chromeless) { - this.bind(['?', 'h'], this.showHelpModal); + this.bind(['?', 'mod+h'], this.showHelpModal); this.bind('g h', this.goToHome); this.bind('g d', this.goToDashboards); this.bind('g e', this.goToExplore); @@ -203,6 +205,14 @@ export class KeybindingSrv { this.bind('t right', () => { appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Right, updateUrl })); }); + + this.bind('t c', () => { + appEvents.publish(new CopyTimeEvent()); + }); + + this.bind('t v', () => { + appEvents.publish(new PasteTimeEvent({ updateUrl })); + }); } setupDashboardBindings(dashboard: DashboardModel) { diff --git a/public/app/core/services/theme.ts b/public/app/core/services/theme.ts index b19b04f324298..6368aa07f7697 100644 --- a/public/app/core/services/theme.ts +++ b/public/app/core/services/theme.ts @@ -18,7 +18,7 @@ export async function changeTheme(themeId: string, runtimeOnly?: boolean) { if (oldTheme.colors.mode !== newTheme.colors.mode) { const newCssLink = document.createElement('link'); newCssLink.rel = 'stylesheet'; - newCssLink.href = config.bootData.themePaths[newTheme.colors.mode]; + newCssLink.href = config.bootData.assets[newTheme.colors.mode]; newCssLink.onload = () => { // Remove old css file const bodyLinks = document.getElementsByTagName('link'); diff --git a/public/app/core/specs/backend_srv.test.ts b/public/app/core/specs/backend_srv.test.ts index fad3b7e8665e5..075ec6f0095d4 100644 --- a/public/app/core/specs/backend_srv.test.ts +++ b/public/app/core/specs/backend_srv.test.ts @@ -4,7 +4,7 @@ import { fromFetch } from 'rxjs/fetch'; import { delay } from 'rxjs/operators'; import { AppEvents, DataQueryErrorType, EventBusExtended } from '@grafana/data'; -import { BackendSrvRequest, FetchError, config, FetchResponse } from '@grafana/runtime'; +import { BackendSrvRequest, FetchError, FetchResponse } from '@grafana/runtime'; import { TokenRevokedModal } from '../../features/users/TokenRevokedModal'; import { ShowModalReactEvent } from '../../types/events'; @@ -86,6 +86,11 @@ const getTestContext = (overides?: object, mockFromFetch = true) => { }; }; +jest.mock('app/core/utils/auth', () => ({ + getSessionExpiry: () => 1, + hasSessionExpiry: () => true, +})); + describe('backendSrv', () => { describe('parseRequestOptions', () => { it.each` @@ -158,27 +163,19 @@ describe('backendSrv', () => { }); }); - describe('when making an unsuccessful call and conditions for retry are favorable and loginPing does not throw', () => { + describe('when making an unsuccessful call and conditions for retry are favorable and rotateToken does not throw', () => { const url = '/api/dashboard/'; const okResponse = { ok: true, status: 200, statusText: 'OK', data: { message: 'Ok' } }; - let fetchMock: jest.SpyInstance; + let fetchMock: jest.SpyInstance; afterEach(() => { fetchMock.mockClear(); }); - afterAll(() => { fetchMock.mockRestore(); - config.featureToggles.clientTokenRotation = false; }); - it.each` - clientTokenRotation - ${true} - ${false} - `('then it should retry (clientTokenRotation = %s)', async ({ clientTokenRotation }) => { - config.featureToggles.clientTokenRotation = clientTokenRotation; - + it('then it should retry', async () => { fetchMock = jest .spyOn(global, 'fetch') .mockRejectedValueOnce({ @@ -210,19 +207,13 @@ describe('backendSrv', () => { false ); - backendSrv.loginPing = jest.fn().mockResolvedValue(okResponse); - backendSrv.rotateToken = jest.fn().mockResolvedValue(okResponse); await backendSrv.request({ url, method: 'GET', retry: 0 }).finally(() => { expect(appEventsMock.emit).not.toHaveBeenCalled(); expect(logoutMock).not.toHaveBeenCalled(); - if (config.featureToggles.clientTokenRotation) { - expect(backendSrv.rotateToken).toHaveBeenCalledTimes(1); - } else { - expect(backendSrv.loginPing).toHaveBeenCalledTimes(1); - } - expect(fetchMock).toHaveBeenCalledTimes(2); // expecting 2 calls because of retry and because the loginPing/tokenRotation is mocked + expect(backendSrv.rotateToken).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); // expecting 2 calls because of retry and because the tokenRotation is mocked }); }); }); @@ -238,7 +229,7 @@ describe('backendSrv', () => { url, }); - backendSrv.loginPing = jest.fn(); + backendSrv.rotateToken = jest.fn(); await backendSrv.request({ url, method: 'GET', retry: 0 }).catch(() => { expect(appEventsMock.publish).toHaveBeenCalledTimes(1); @@ -250,7 +241,7 @@ describe('backendSrv', () => { }, }) ); - expect(backendSrv.loginPing).not.toHaveBeenCalled(); + expect(backendSrv.rotateToken).not.toHaveBeenCalled(); expect(logoutMock).not.toHaveBeenCalled(); expectRequestCallChain({ url, method: 'GET', retry: 0 }); }); @@ -267,7 +258,7 @@ describe('backendSrv', () => { data: { message: errorMessage }, }); - backendSrv.loginPing = jest + backendSrv.rotateToken = jest .fn() .mockRejectedValue({ status: 403, statusText: 'Forbidden', data: { message: 'Forbidden' } }); const url = '/api/dashboard/'; @@ -279,7 +270,7 @@ describe('backendSrv', () => { expect(error.statusText).toBe('Forbidden'); expect(error.data).toEqual({ message: 'Forbidden' }); expect(appEventsMock.emit).not.toHaveBeenCalled(); - expect(backendSrv.loginPing).toHaveBeenCalledTimes(1); + expect(backendSrv.rotateToken).toHaveBeenCalledTimes(1); expect(logoutMock).not.toHaveBeenCalled(); expectRequestCallChain({ url, method: 'GET', retry: 0 }); jest.advanceTimersByTime(50); @@ -519,27 +510,19 @@ describe('backendSrv', () => { }); }); - describe('when making an unsuccessful call and conditions for retry are favorable and loginPing does not throw', () => { + describe('when making an unsuccessful call and conditions for retry are favorable and rotateToken does not throw', () => { const url = '/api/dashboard/'; const okResponse = { ok: true, status: 200, statusText: 'OK', data: { message: 'Ok' } }; - let fetchMock: jest.SpyInstance; + let fetchMock: jest.SpyInstance; afterEach(() => { fetchMock.mockClear(); }); - afterAll(() => { fetchMock.mockRestore(); - config.featureToggles.clientTokenRotation = false; }); - it.each` - clientTokenRotation - ${true} - ${false} - `('then it should retry (clientTokenRotation = %s)', async ({ clientTokenRotation }) => { - config.featureToggles.clientTokenRotation = clientTokenRotation; - + it('then it should retry', async () => { fetchMock = jest .spyOn(global, 'fetch') .mockRejectedValueOnce({ @@ -570,18 +553,12 @@ describe('backendSrv', () => { false ); - backendSrv.loginPing = jest.fn().mockResolvedValue(okResponse); - backendSrv.rotateToken = jest.fn().mockResolvedValue(okResponse); await backendSrv.datasourceRequest({ url, method: 'GET', retry: 0 }).finally(() => { expect(logoutMock).not.toHaveBeenCalled(); - if (config.featureToggles.clientTokenRotation) { - expect(backendSrv.rotateToken).toHaveBeenCalledTimes(1); - } else { - expect(backendSrv.loginPing).toHaveBeenCalledTimes(1); - } - expect(fetchMock).toHaveBeenCalledTimes(2); // expecting 2 calls because of retry and because the loginPing/tokenRotation is mocked + expect(backendSrv.rotateToken).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); // expecting 2 calls because of retry and because the tokenRotation is mocked }); }); }); @@ -595,7 +572,7 @@ describe('backendSrv', () => { data: { message: 'Token revoked', error: { id: 'ERR_TOKEN_REVOKED', maxConcurrentSessions: 3 } }, }); - backendSrv.loginPing = jest.fn(); + backendSrv.rotateToken = jest.fn(); const url = '/api/dashboard/'; @@ -609,7 +586,7 @@ describe('backendSrv', () => { }, }) ); - expect(backendSrv.loginPing).not.toHaveBeenCalled(); + expect(backendSrv.rotateToken).not.toHaveBeenCalled(); expect(logoutMock).not.toHaveBeenCalled(); expectRequestCallChain({ url, method: 'GET', retry: 0 }); }); @@ -631,7 +608,7 @@ describe('backendSrv', () => { retry: 0, }; - backendSrv.loginPing = jest + backendSrv.rotateToken = jest .fn() .mockRejectedValue({ status: 403, statusText: 'Forbidden', data: { message: 'Forbidden' } }); @@ -639,7 +616,7 @@ describe('backendSrv', () => { expect(error.status).toBe(403); expect(error.statusText).toBe('Forbidden'); expect(error.data).toEqual({ message: 'Forbidden' }); - expect(backendSrv.loginPing).toHaveBeenCalledTimes(1); + expect(backendSrv.rotateToken).toHaveBeenCalledTimes(1); expect(logoutMock).not.toHaveBeenCalled(); expectRequestCallChain(options); }); @@ -692,7 +669,7 @@ describe('backendSrv', () => { let inspectorPacket: FetchResponse | FetchError; backendSrv.getInspectorStream().subscribe({ - next: (rsp) => (inspectorPacket = rsp), + next: (rsp) => (inspectorPacket = rsp.response), }); await backendSrv.datasourceRequest(options).catch((error) => { diff --git a/public/app/core/specs/ticks.test.ts b/public/app/core/specs/ticks.test.ts index 81ea8601e4fdb..727be9f7a8fb3 100644 --- a/public/app/core/specs/ticks.test.ts +++ b/public/app/core/specs/ticks.test.ts @@ -1,27 +1,6 @@ import * as ticks from '../utils/ticks'; describe('ticks', () => { - describe('getFlotTickDecimals()', () => { - const axis = { - min: null, - max: null, - }; - - it('should calculate decimals precision based on graph height', () => { - let dec = ticks.getFlotTickDecimals(0, 10, axis, 200); - expect(dec.tickDecimals).toBe(1); - expect(dec.scaledDecimals).toBe(1); - - dec = ticks.getFlotTickDecimals(0, 100, axis, 200); - expect(dec.tickDecimals).toBe(0); - expect(dec.scaledDecimals).toBe(-1); - - dec = ticks.getFlotTickDecimals(0, 1, axis, 200); - expect(dec.tickDecimals).toBe(2); - expect(dec.scaledDecimals).toBe(3); - }); - }); - describe('getStringPrecision()', () => { it('"3.12" should return 2', () => { expect(ticks.getStringPrecision('3.12')).toBe(2); diff --git a/public/app/core/specs/time_series.test.ts b/public/app/core/specs/time_series.test.ts index df77e6731284f..87a4a1d885c06 100644 --- a/public/app/core/specs/time_series.test.ts +++ b/public/app/core/specs/time_series.test.ts @@ -406,7 +406,10 @@ describe('TimeSeries', () => { describe('legend decimals', () => { let series: TimeSeries; - let panel: any; + let panel: { + decimals: number | null; + yaxes: Array<{ decimals: number | null }>; + }; const height = 200; beforeEach(() => { testData = { diff --git a/public/app/core/utils/auth.ts b/public/app/core/utils/auth.ts index 42d99cd59c97a..8c6b69cc028bb 100644 --- a/public/app/core/utils/auth.ts +++ b/public/app/core/utils/auth.ts @@ -11,3 +11,7 @@ export function getSessionExpiry() { return parseInt(expiresStr, 10); } + +export function hasSessionExpiry() { + return document.cookie.split('; ').findIndex((row) => row.startsWith('grafana_session_expiry=')) > -1; +} diff --git a/public/app/core/utils/browser.ts b/public/app/core/utils/browser.ts index fac61e9231cad..ec37aa3793a10 100644 --- a/public/app/core/utils/browser.ts +++ b/public/app/core/utils/browser.ts @@ -39,5 +39,5 @@ export function userAgentIsApple() { } export function getModKey() { - return userAgentIsApple() ? 'cmd' : 'ctrl'; + return userAgentIsApple() ? '⌘' : 'ctrl'; } diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index 07a840f7a3e0f..658f3eefd0f88 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -2,7 +2,6 @@ import { DataSourceApi, dateTime, ExploreUrlState, LogsSortOrder } from '@grafan import { serializeStateToUrlParam } from '@grafana/data/src/utils/url'; import { DataQuery } from '@grafana/schema'; import { RefreshPicker } from '@grafana/ui'; -import store from 'app/core/store'; import { DEFAULT_RANGE } from 'app/features/explore/state/utils'; import { DatasourceSrvMock, MockDataSourceApi } from '../../../test/mocks/datasource_srv'; @@ -11,7 +10,6 @@ import { buildQueryTransaction, hasNonEmptyQuery, refreshIntervalToSortOrder, - updateHistory, getExploreUrl, GetExploreUrlArguments, getTimeRange, @@ -151,27 +149,6 @@ describe('getExploreUrl', () => { }); }); -describe('updateHistory()', () => { - const datasourceId = 'myDatasource'; - const key = `grafana.explore.history.${datasourceId}`; - - beforeEach(() => { - store.delete(key); - expect(store.exists(key)).toBeFalsy(); - }); - - test('should save history item to localStorage', () => { - const expected = [ - { - query: { refId: '1', expr: 'metric' }, - }, - ]; - expect(updateHistory([], datasourceId, [{ refId: '1', expr: 'metric' }])).toMatchObject(expected); - expect(store.exists(key)).toBeTruthy(); - expect(store.getObject(key)).toMatchObject(expected); - }); -}); - describe('hasNonEmptyQuery', () => { test('should return true if one query is non-empty', () => { expect(hasNonEmptyQuery([{ refId: '1', key: '2', context: 'explore', expr: 'foo' }])).toBeTruthy(); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 7c138740d6bb6..501a03d57d467 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -1,4 +1,4 @@ -import { nanoid } from '@reduxjs/toolkit'; +import { customAlphabet } from 'nanoid'; import { Unsubscribable } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; @@ -10,7 +10,6 @@ import { DataSourceApi, DataSourceRef, DefaultTimeZone, - HistoryItem, IntervalValues, LogsDedupStrategy, LogsSortOrder, @@ -34,7 +33,8 @@ export const DEFAULT_UI_STATE = { dedupStrategy: LogsDedupStrategy.none, }; -const MAX_HISTORY_ITEMS = 100; +export const ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; +const nanoid = customAlphabet(ID_ALPHABET, 3); const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource'; const lastUsedDatasourceKeyForOrgId = (orgId: number) => `${LAST_USED_DATASOURCE_KEY}.${orgId}`; @@ -52,7 +52,13 @@ export interface GetExploreUrlArguments { } export function generateExploreId() { - return nanoid(3); + while (true) { + const id = nanoid(3); + + if (!/^\d+$/.test(id)) { + return id; + } + } } /** @@ -92,6 +98,10 @@ export async function getExploreUrl(args: GetExploreUrlArguments): Promise<strin return urlUtil.renderUrl('/explore', { panes: exploreState, schemaVersion: 1 }); } +export function requestIdGenerator(exploreId: string) { + return `explore_${exploreId}`; +} + export function buildQueryTransaction( exploreId: string, queries: DataQuery[], @@ -101,19 +111,9 @@ export function buildQueryTransaction( timeZone?: TimeZone, scopedVars?: ScopedVars ): QueryTransaction { - const key = queries.reduce((combinedKey, query) => { - combinedKey += query.key; - return combinedKey; - }, ''); - + const panelId = Number.parseInt(exploreId, 36); const { interval, intervalMs } = getIntervals(range, queryOptions.minInterval, queryOptions.maxDataPoints); - // Most datasource is using `panelId + query.refId` for cancellation logic. - // Using `format` here because it relates to the view panel that the request is for. - // However, some datasources don't use `panelId + query.refId`, but only `panelId`. - // Therefore panel id has to be unique. - const panelId = `${key}`; - const request: DataQueryRequest = { app: CoreApp.Explore, // TODO probably should be taken from preferences but does not seem to be used anyway. @@ -121,12 +121,10 @@ export function buildQueryTransaction( startTime: Date.now(), interval, intervalMs, - // TODO: the query request expects number and we are using string here. Seems like it works so far but can create - // issues down the road. - panelId: panelId as any, + panelId, targets: queries, // Datasources rely on DataQueries being passed under the targets key. range, - requestId: 'explore_' + exploreId, + requestId: requestIdGenerator(exploreId), rangeRaw: range.raw, scopedVars: { __interval: { text: interval, value: interval }, @@ -266,45 +264,15 @@ const validKeys = ['refId', 'key', 'context', 'datasource']; export function hasNonEmptyQuery<TQuery extends DataQuery>(queries: TQuery[]): boolean { return ( queries && - queries.some((query: any) => { - const keys = Object.keys(query) - .filter((key) => validKeys.indexOf(key) === -1) - .map((k) => query[k]) - .filter((v) => v); - return keys.length > 0; + queries.some((query) => { + const entries = Object.entries(query) + .filter(([key, _]) => validKeys.indexOf(key) === -1) + .filter(([_, value]) => value); + return entries.length > 0; }) ); } -/** - * Update the query history. Side-effect: store history in local storage - */ -export function updateHistory<T extends DataQuery>( - history: Array<HistoryItem<T>>, - datasourceId: string, - queries: T[] -): Array<HistoryItem<T>> { - const ts = Date.now(); - let updatedHistory = history; - queries.forEach((query) => { - updatedHistory = [{ query, ts }, ...updatedHistory]; - }); - - if (updatedHistory.length > MAX_HISTORY_ITEMS) { - updatedHistory = updatedHistory.slice(0, MAX_HISTORY_ITEMS); - } - - // Combine all queries of a datasource type into one history - const historyKey = `grafana.explore.history.${datasourceId}`; - try { - store.setObject(historyKey, updatedHistory); - return updatedHistory; - } catch (error) { - console.error(error); - return history; - } -} - export const getQueryKeys = (queries: DataQuery[]): string[] => { const queryKeys = queries.reduce<string[]>((newQueryKeys, query, index) => { const primaryKey = query.datasource?.uid || query.key; diff --git a/public/app/core/utils/fetch.ts b/public/app/core/utils/fetch.ts index f011ae348b9a5..49253f74a31ee 100644 --- a/public/app/core/utils/fetch.ts +++ b/public/app/core/utils/fetch.ts @@ -103,23 +103,29 @@ export async function parseResponseBody<T>( if (responseType) { switch (responseType) { case 'arraybuffer': - return response.arrayBuffer() as any; + // this specifically returns a Promise<ArrayBuffer> + // TODO refactor this function to remove the type assertions + return response.arrayBuffer() as Promise<T>; case 'blob': - return response.blob() as any; + // this specifically returns a Promise<Blob> + // TODO refactor this function to remove the type assertions + return response.blob() as Promise<T>; case 'json': // An empty string is not a valid JSON. // Sometimes (unfortunately) our APIs declare their Content-Type as JSON, however they return an empty body. if (response.headers.get('Content-Length') === '0') { console.warn(`${response.url} returned an invalid JSON`); - return {} as unknown as T; + return {} as T; } return await response.json(); case 'text': - return response.text() as any; + // this specifically returns a Promise<string> + // TODO refactor this function to remove the type assertions + return response.text() as Promise<T>; } } @@ -127,7 +133,7 @@ export async function parseResponseBody<T>( try { return JSON.parse(textData); // majority of the requests this will be something that can be parsed } catch {} - return textData as any; + return textData as T; } function serializeParams(data: Record<string, any>): string { diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts index 3c7aae9780e38..61969eb5d0327 100644 --- a/public/app/core/utils/navBarItem-translations.ts +++ b/public/app/core/utils/navBarItem-translations.ts @@ -1,4 +1,3 @@ -import { config } from '@grafana/runtime'; import { t } from 'app/core/internationalization'; // Maps the ID of the nav item to a translated phrase to later pass to <Trans /> @@ -57,10 +56,14 @@ export function getNavTitle(navId: string | undefined) { return t('nav.oncall.title', 'OnCall'); case 'alerting-legacy': return t('nav.alerting-legacy.title', 'Alerting (legacy)'); + case 'alerting-upgrade': + return t('nav.alerting-upgrade.title', 'Alerting upgrade'); case 'alert-home': return t('nav.alerting-home.title', 'Home'); case 'alert-list': return t('nav.alerting-list.title', 'Alert rules'); + case 'alert-list-legacy': + return t('nav.alert-list-legacy.title', 'Alert rules'); case 'receivers': return t('nav.alerting-receivers.title', 'Contact points'); case 'am-routes': @@ -115,6 +118,8 @@ export function getNavTitle(navId: string | undefined) { return t('nav.server-settings.title', 'Settings'); case 'storage': return t('nav.storage.title', 'Storage'); + case 'migrate-to-cloud': + return t('nav.migrate-to-cloud.title', 'Migrate to Grafana Cloud'); case 'upgrading': return t('nav.upgrading.title', 'Stats and license'); case 'monitoring': @@ -136,9 +141,7 @@ export function getNavTitle(navId: string | undefined) { case 'plugin-page-grafana-slo-app': return t('nav.slo.title', 'SLO'); case 'plugin-page-k6-app': - return config.featureToggles.dockedMegaMenu - ? t('nav.k6.title', 'Performance') - : t('nav.performance-testing.title', 'Performance testing'); + return t('nav.k6.title', 'Performance'); case 'monitoring': return t('nav.observability.title', 'Observability'); case 'plugin-page-grafana-k8s-app': @@ -167,12 +170,16 @@ export function getNavTitle(navId: string | undefined) { return t('nav.connections.title', 'Connections'); case 'connections-add-new-connection': return t('nav.add-new-connections.title', 'Add new connection'); + case 'standalone-plugin-page-/connections/collector': + return t('nav.collector.title', 'Collector'); case 'connections-datasources': return t('nav.data-sources.title', 'Data sources'); case 'standalone-plugin-page-/connections/infrastructure': return t('nav.integrations.title', 'Integrations'); case 'standalone-plugin-page-/connections/connect-data': return t('nav.connect-data.title', 'Connect data'); + case 'standalone-plugin-page-/connections/private-data-source-connections': + return t('nav.private-data-source-connections.title', 'Private data source connect'); case 'plugin-page-grafana-detect-app': return t('nav.detect.title', 'Detect'); case 'plugin-page-grafana-quaderno-app': @@ -201,6 +208,11 @@ export function getNavSubTitle(navId: string | undefined) { return t('nav.library-panels.subtitle', 'Reusable panels that can be added to multiple dashboards'); case 'alerting': return t('nav.alerting.subtitle', 'Learn about problems in your systems moments after they occur'); + case 'alerting-upgrade': + return t( + 'nav.alerting-upgrade.subtitle', + 'Upgrade your existing legacy alerts and notification channels to the new Grafana Alerting' + ); case 'alert-list': return t('nav.alerting-list.subtitle', 'Rules that determine whether an alert will fire'); case 'receivers': @@ -238,6 +250,11 @@ export function getNavSubTitle(navId: string | undefined) { return t('nav.server-settings.subtitle', 'View the settings defined in your Grafana config'); case 'storage': return t('nav.storage.subtitle', 'Manage file storage'); + case 'migrate-to-cloud': + return t( + 'nav.migrate-to-cloud.subtitle', + 'Copy configuration from your self-managed installation to a cloud stack' + ); case 'support-bundles': return t('nav.support-bundles.subtitle', 'Download support bundles'); case 'admin': @@ -267,6 +284,11 @@ export function getNavSubTitle(navId: string | undefined) { return t('nav.connections.subtitle', 'Browse and create new connections'); case 'connections-datasources': return t('nav.data-sources.subtitle', 'View and manage your connected data source connections'); + case 'connections-private-data-source-connections': + return t( + 'nav.private-data-source-connections.subtitle', + 'Query data that lives within a secured network without opening the network to inbound traffic from Grafana Cloud. Learn more in our docs.' + ); default: return undefined; } diff --git a/public/app/core/utils/query.ts b/public/app/core/utils/query.ts index bd5cdefe808c2..ef75c918ce168 100644 --- a/public/app/core/utils/query.ts +++ b/public/app/core/utils/query.ts @@ -1,5 +1,6 @@ import { DataQuery, DataSourceRef } from '@grafana/data'; +// @deprecated use the `getNextRefId` function from grafana/data instead export const getNextRefIdChar = (queries: DataQuery[]): string => { for (let num = 0; ; num++) { const refId = getRefId(num); diff --git a/public/app/core/utils/richHistory.test.ts b/public/app/core/utils/richHistory.test.ts index 4e87f38e7487c..0c6a95b981df7 100644 --- a/public/app/core/utils/richHistory.test.ts +++ b/public/app/core/utils/richHistory.test.ts @@ -109,15 +109,13 @@ describe('richHistory', () => { it('should append query to query history', async () => { Date.now = jest.fn(() => 2); - const { limitExceeded, richHistoryStorageFull } = await addToRichHistory( - mock.testDatasourceUid, - mock.testDatasourceName, - mock.testQueries, - mock.testStarred, - mock.testComment, - true, - true - ); + const { limitExceeded, richHistoryStorageFull } = await addToRichHistory({ + localOverride: false, + datasource: { uid: mock.testDatasourceUid, name: mock.testDatasourceName }, + queries: mock.testQueries, + starred: mock.testStarred, + comment: mock.testComment, + }); expect(limitExceeded).toBeFalsy(); expect(richHistoryStorageFull).toBeFalsy(); expect(richHistoryStorageMock.addToRichHistory).toBeCalledWith({ @@ -142,15 +140,13 @@ describe('richHistory', () => { }); }); - const { richHistoryStorageFull, limitExceeded } = await addToRichHistory( - mock.testDatasourceUid, - mock.testDatasourceName, - mock.testQueries, - mock.testStarred, - mock.testComment, - true, - true - ); + const { richHistoryStorageFull, limitExceeded } = await addToRichHistory({ + localOverride: false, + datasource: { uid: mock.testDatasourceUid, name: mock.testDatasourceName }, + queries: mock.testQueries, + starred: mock.testStarred, + comment: mock.testComment, + }); expect(richHistoryStorageFull).toBeFalsy(); expect(limitExceeded).toBeTruthy(); }); diff --git a/public/app/core/utils/richHistory.ts b/public/app/core/utils/richHistory.ts index 459e62b56d02a..ad95f6256967d 100644 --- a/public/app/core/utils/richHistory.ts +++ b/public/app/core/utils/richHistory.ts @@ -15,7 +15,7 @@ import { RichHistoryStorageWarning, RichHistoryStorageWarningDetails, } from '../history/RichHistoryStorage'; -import { getRichHistoryStorage } from '../history/richHistoryStorageProvider'; +import { getLocalRichHistoryStorage, getRichHistoryStorage } from '../history/richHistoryStorageProvider'; import { RichHistorySearchFilters, RichHistorySettings, SortOrder } from './richHistoryTypes'; @@ -26,15 +26,27 @@ export { RichHistorySearchFilters, RichHistorySettings, SortOrder }; * Side-effect: store history in local storage */ +type addToRichHistoryParams = { + localOverride: boolean; + datasource: { uid: string; name?: string }; + queries: DataQuery[]; + starred: boolean; + comment?: string; + showNotif?: { + quotaExceededError?: boolean; + limitExceededWarning?: boolean; + otherErrors?: boolean; + }; +}; + export async function addToRichHistory( - datasourceUid: string, - datasourceName: string | null, - queries: DataQuery[], - starred: boolean, - comment: string | null, - showQuotaExceededError: boolean, - showLimitExceededWarning: boolean + params: addToRichHistoryParams ): Promise<{ richHistoryStorageFull?: boolean; limitExceeded?: boolean }> { + const { queries, localOverride, datasource, starred, comment, showNotif } = params; + // default showing of errors to true + const showQuotaExceededError = showNotif?.quotaExceededError ?? true; + const showLimitExceededWarning = showNotif?.limitExceededWarning ?? true; + const showOtherErrors = showNotif?.otherErrors ?? true; /* Save only queries, that are not falsy (e.g. empty object, null, ...) */ const newQueriesToSave: DataQuery[] = queries && queries.filter((query) => notEmptyQuery(query)); @@ -44,9 +56,11 @@ export async function addToRichHistory( let warning: RichHistoryStorageWarningDetails | undefined; try { - const result = await getRichHistoryStorage().addToRichHistory({ - datasourceUid: datasourceUid, - datasourceName: datasourceName ?? '', + // for autocomplete we want to ensure writing to local storage + const storage = localOverride ? getLocalRichHistoryStorage() : getRichHistoryStorage(); + const result = await storage.addToRichHistory({ + datasourceUid: datasource.uid, + datasourceName: datasource.name ?? '', queries: newQueriesToSave, starred, comment: comment ?? '', @@ -57,7 +71,7 @@ export async function addToRichHistory( if (error.name === RichHistoryServiceError.StorageFull) { richHistoryStorageFull = true; showQuotaExceededError && dispatch(notifyApp(createErrorNotification(error.message))); - } else if (error.name !== RichHistoryServiceError.DuplicatedEntry) { + } else if (showOtherErrors && error.name !== RichHistoryServiceError.DuplicatedEntry) { dispatch( notifyApp( createErrorNotification( diff --git a/public/app/core/utils/richHistoryTypes.ts b/public/app/core/utils/richHistoryTypes.ts index b311b82752980..c49029c035ba2 100644 --- a/public/app/core/utils/richHistoryTypes.ts +++ b/public/app/core/utils/richHistoryTypes.ts @@ -23,8 +23,8 @@ export type RichHistorySearchFilters = { sortOrder: SortOrder; /** Names of data sources (not uids) - used by local and remote storage **/ datasourceFilters: string[]; - from: number; - to: number; + from?: number; + to?: number; starred: boolean; page?: number; }; diff --git a/public/app/core/utils/ticks.ts b/public/app/core/utils/ticks.ts index 5f2239a4d9db3..5b65bb22a0f0e 100644 --- a/public/app/core/utils/ticks.ts +++ b/public/app/core/utils/ticks.ts @@ -30,127 +30,6 @@ export function getScaledDecimals(decimals: number, tickSize: number) { return decimals - Math.floor(Math.log(tickSize) / Math.LN10); } -/** - * Calculate tick size based on min and max values, number of ticks and precision. - * Implementation from Flot. - * @param min Axis minimum - * @param max Axis maximum - * @param noTicks Number of ticks - * @param tickDecimals Tick decimal precision - */ -export function getFlotTickSize(min: number, max: number, noTicks: number, tickDecimals: number) { - const delta = (max - min) / noTicks; - let dec = -Math.floor(Math.log(delta) / Math.LN10); - const maxDec = tickDecimals; - - const magn = Math.pow(10, -dec); - const norm = delta / magn; // norm is between 1.0 and 10.0 - let size; - - if (norm < 1.5) { - size = 1; - } else if (norm < 3) { - size = 2; - // special case for 2.5, requires an extra decimal - if (norm > 2.25 && (maxDec == null || dec + 1 <= maxDec)) { - size = 2.5; - ++dec; - } - } else if (norm < 7.5) { - size = 5; - } else { - size = 10; - } - - size *= magn; - - return size; -} - -/** - * Calculate axis range (min and max). - * Implementation from Flot. - */ -export function getFlotRange(panelMin: any, panelMax: any, datamin: number, datamax: number) { - const autoscaleMargin = 0.02; - - let min = +(panelMin != null ? panelMin : datamin); - let max = +(panelMax != null ? panelMax : datamax); - const delta = max - min; - - if (delta === 0.0) { - // Grafana fix: wide Y min and max using increased wideFactor - // when all series values are the same - const wideFactor = 0.25; - const widen = Math.abs(max === 0 ? 1 : max * wideFactor); - - if (panelMin === null) { - min -= widen; - } - // always widen max if we couldn't widen min to ensure we - // don't fall into min == max which doesn't work - if (panelMax == null || panelMin != null) { - max += widen; - } - } else { - // consider autoscaling - const margin = autoscaleMargin; - if (margin != null) { - if (panelMin == null) { - min -= delta * margin; - // make sure we don't go below zero if all values - // are positive - if (min < 0 && datamin != null && datamin >= 0) { - min = 0; - } - } - if (panelMax == null) { - max += delta * margin; - if (max > 0 && datamax != null && datamax <= 0) { - max = 0; - } - } - } - } - return { min, max }; -} - -/** - * Calculate tick decimals. - * Implementation from Flot. - */ -export function getFlotTickDecimals(datamin: number, datamax: number, axis: { min: any; max: any }, height: number) { - const { min, max } = getFlotRange(axis.min, axis.max, datamin, datamax); - const noTicks = 0.3 * Math.sqrt(height); - const delta = (max - min) / noTicks; - const dec = -Math.floor(Math.log(delta) / Math.LN10); - - const magn = Math.pow(10, -dec); - // norm is between 1.0 and 10.0 - const norm = delta / magn; - let size; - - if (norm < 1.5) { - size = 1; - } else if (norm < 3) { - size = 2; - // special case for 2.5, requires an extra decimal - if (norm > 2.25) { - size = 2.5; - } - } else if (norm < 7.5) { - size = 5; - } else { - size = 10; - } - size *= magn; - - const tickDecimals = Math.max(0, -Math.floor(Math.log(delta) / Math.LN10) + 1); - // grafana addition - const scaledDecimals = tickDecimals - Math.floor(Math.log(size) / Math.LN10); - return { tickDecimals, scaledDecimals }; -} - /** * Format timestamp similar to Grafana graph panel. * @param ticks Number of ticks diff --git a/public/app/core/utils/timePicker.ts b/public/app/core/utils/timePicker.ts index 08dd76c2531ee..ba862fb185809 100644 --- a/public/app/core/utils/timePicker.ts +++ b/public/app/core/utils/timePicker.ts @@ -1,4 +1,6 @@ -import { TimeRange, toUtc, AbsoluteTimeRange } from '@grafana/data'; +import { TimeRange, toUtc, AbsoluteTimeRange, RawTimeRange } from '@grafana/data'; + +type CopiedTimeRangeResult = { range: RawTimeRange; isError: false } | { range: string; isError: true }; export const getShiftedTimeRange = (direction: number, origRange: TimeRange): AbsoluteTimeRange => { const range = { @@ -38,3 +40,20 @@ export const getZoomedTimeRange = (range: TimeRange, factor: number): AbsoluteTi return { from, to }; }; + +export async function getCopiedTimeRange(): Promise<CopiedTimeRangeResult> { + const raw = await navigator.clipboard.readText(); + let range; + + try { + range = JSON.parse(raw); + + if (!range.from || !range.to) { + return { range: raw, isError: true }; + } + + return { range, isError: false }; + } catch (e) { + return { range: raw, isError: true }; + } +} diff --git a/public/app/features/admin/AdminEditOrgPage.tsx b/public/app/features/admin/AdminEditOrgPage.tsx index e1cab49a2945e..910bc8552510b 100644 --- a/public/app/features/admin/AdminEditOrgPage.tsx +++ b/public/app/features/admin/AdminEditOrgPage.tsx @@ -1,8 +1,9 @@ import React, { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; import { useAsyncFn } from 'react-use'; import { NavModelItem } from '@grafana/data'; -import { Form, Field, Input, Button, Legend, Alert } from '@grafana/ui'; +import { Field, Input, Button, Legend, Alert } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; @@ -27,6 +28,11 @@ const AdminEditOrgPage = ({ match }: Props) => { const [totalPages, setTotalPages] = useState(1); const [orgState, fetchOrg] = useAsyncFn(() => getOrg(orgId), []); + const { + handleSubmit, + register, + formState: { errors }, + } = useForm<OrgNameDTO>(); const [, fetchOrgUsers] = useAsyncFn(async (page) => { const result = await getOrgUsers(orgId, page); @@ -45,8 +51,8 @@ const AdminEditOrgPage = ({ match }: Props) => { fetchOrgUsers(page); }, [fetchOrg, fetchOrgUsers, page]); - const onUpdateOrgName = async (name: string) => { - await updateOrgName(name, orgId); + const onUpdateOrgName = async ({ orgName }: OrgNameDTO) => { + await updateOrgName(orgName, orgId); }; const renderMissingPermissionMessage = () => ( @@ -82,21 +88,18 @@ const AdminEditOrgPage = ({ match }: Props) => { <> <Legend>Edit organization</Legend> {orgState.value && ( - <Form - defaultValues={{ orgName: orgState.value.name }} - onSubmit={(values: OrgNameDTO) => onUpdateOrgName(values.orgName)} - > - {({ register, errors }) => ( - <> - <Field label="Name" invalid={!!errors.orgName} error="Name is required" disabled={!canWriteOrg}> - <Input {...register('orgName', { required: true })} id="org-name-input" /> - </Field> - <Button type="submit" disabled={!canWriteOrg}> - Update - </Button> - </> - )} - </Form> + <form onSubmit={handleSubmit(onUpdateOrgName)} style={{ maxWidth: '600px' }}> + <Field label="Name" invalid={!!errors.orgName} error="Name is required" disabled={!canWriteOrg}> + <Input + {...register('orgName', { required: true })} + id="org-name-input" + defaultValue={orgState.value.name} + /> + </Field> + <Button type="submit" disabled={!canWriteOrg}> + Update + </Button> + </form> )} <div style={{ marginTop: '20px' }}> diff --git a/public/app/features/admin/AdminFeatureTogglesAPI.test.ts b/public/app/features/admin/AdminFeatureTogglesAPI.test.ts new file mode 100644 index 0000000000000..ca6c25be5251b --- /dev/null +++ b/public/app/features/admin/AdminFeatureTogglesAPI.test.ts @@ -0,0 +1,102 @@ +import { BackendSrvRequest, config } from '@grafana/runtime'; + +import { getTogglesAPI } from './AdminFeatureTogglesAPI'; + +// implements @grafana/runtime/BackendSrv +class MockSrv { + constructor() { + this.apiCalls = []; + } + + apiCalls: Array<{ + url: string; + method: string; + }>; + + async get( + url: string, + params?: BackendSrvRequest['params'], + requestId?: BackendSrvRequest['requestId'], + options?: Partial<BackendSrvRequest> + ) { + this.apiCalls.push({ + url: url, + method: 'get', + }); + if (config.featureToggles.kubernetesFeatureToggles && url.indexOf('current') > -1) { + return await { toggles: [] }; + } + + return await {}; + } + + async post(url: string, data?: unknown, options?: Partial<BackendSrvRequest>) { + this.apiCalls.push({ + url: url, + method: 'post', + }); + return await {}; + } + + async patch(url: string, data: unknown, options?: Partial<BackendSrvRequest>) { + this.apiCalls.push({ + url: url, + method: 'patch', + }); + return await {}; + } + + // these aren't needed for this test + async put(url: string, data: unknown, options?: Partial<BackendSrvRequest>) { + return await {}; + } + async delete(url: string, data?: unknown, options?: Partial<BackendSrvRequest>) { + return await {}; + } +} + +const testBackendSrv = new MockSrv(); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getBackendSrv: () => testBackendSrv, + config: { + featureToggles: { + kubernetesFeatureToggles: false, + grafanaAPIServerWithExperimentalAPIs: false, + }, + }, +})); + +describe('AdminFeatureTogglesApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + testBackendSrv.apiCalls.length = 0; + }); + + const originalToggles = { ...config.featureToggles }; + + afterAll(() => { + config.featureToggles = originalToggles; + }); + + it('uses the k8s api when the k8s toggles are on', async () => { + config.featureToggles.kubernetesFeatureToggles = true; + config.featureToggles.grafanaAPIServerWithExperimentalAPIs = true; + + const togglesApi = getTogglesAPI(); + await togglesApi.getFeatureToggles(); + await togglesApi.updateFeatureToggles([]); + const expected = [ + { + method: 'get', + url: '/apis/featuretoggle.grafana.app/v0alpha1/current', + }, + { + method: 'patch', + url: '/apis/featuretoggle.grafana.app/v0alpha1/current', + }, + ]; + expect(testBackendSrv.apiCalls).toEqual(expect.arrayContaining(expected)); + }); +}); diff --git a/public/app/features/admin/AdminFeatureTogglesAPI.ts b/public/app/features/admin/AdminFeatureTogglesAPI.ts index 889f6f0759e03..6ecba0c8b7d6a 100644 --- a/public/app/features/admin/AdminFeatureTogglesAPI.ts +++ b/public/app/features/admin/AdminFeatureTogglesAPI.ts @@ -1,62 +1,77 @@ -import { createApi, BaseQueryFn } from '@reduxjs/toolkit/query/react'; -import { lastValueFrom } from 'rxjs'; - import { getBackendSrv } from '@grafana/runtime'; -type QueryArgs = { - url: string; - method?: string; - body?: { featureToggles: FeatureToggle[] }; -}; - -const backendSrvBaseQuery = - ({ baseUrl }: { baseUrl: string }): BaseQueryFn<QueryArgs> => - async ({ url, method = 'GET', body }) => { - try { - const { data } = await lastValueFrom( - getBackendSrv().fetch({ - url: baseUrl + url, - method, - data: body, - }) - ); - return { data }; - } catch (error) { - return { error }; - } - }; - -export const togglesApi = createApi({ - reducerPath: 'togglesApi', - baseQuery: backendSrvBaseQuery({ baseUrl: '/api' }), - endpoints: (builder) => ({ - getManagerState: builder.query<FeatureMgmtState, void>({ - query: () => ({ url: '/featuremgmt/state' }), - }), - getFeatureToggles: builder.query<FeatureToggle[], void>({ - query: () => ({ url: '/featuremgmt' }), - }), - updateFeatureToggles: builder.mutation<void, FeatureToggle[]>({ - query: (updatedToggles) => ({ - url: '/featuremgmt', - method: 'POST', - body: { featureToggles: updatedToggles }, - }), - }), - }), -}); - -type FeatureToggle = { +export type FeatureToggle = { name: string; description?: string; enabled: boolean; + stage: string; readOnly?: boolean; + hidden?: boolean; }; -type FeatureMgmtState = { +export type CurrentTogglesState = { restartRequired: boolean; allowEditing: boolean; + toggles: FeatureToggle[]; }; -export const { useGetManagerStateQuery, useGetFeatureTogglesQuery, useUpdateFeatureTogglesMutation } = togglesApi; -export type { FeatureToggle, FeatureMgmtState }; +interface ResolvedToggleState { + kind: 'ResolvedToggleState'; + restartRequired?: boolean; + allowEditing?: boolean; + toggles?: K8sToggleSpec[]; // not used in patch + enabled: { [key: string]: boolean }; +} + +interface K8sToggleSpec { + name: string; + description: string; + enabled: boolean; + writeable: boolean; + source: K8sToggleSource; + stage: string; +} + +interface K8sToggleSource { + namespace: string; + name: string; +} + +interface FeatureTogglesAPI { + getFeatureToggles(): Promise<CurrentTogglesState>; + updateFeatureToggles(toggles: FeatureToggle[]): Promise<void>; +} + +class K8sAPI implements FeatureTogglesAPI { + baseURL = '/apis/featuretoggle.grafana.app/v0alpha1'; + + async getFeatureToggles(): Promise<CurrentTogglesState> { + const current = await getBackendSrv().get<ResolvedToggleState>(this.baseURL + '/current'); + return { + restartRequired: Boolean(current.restartRequired), + allowEditing: Boolean(current.allowEditing), + toggles: current.toggles!.map((t) => ({ + name: t.name, + description: t.description!, + enabled: t.enabled, + readOnly: !Boolean(t.writeable), + stage: t.stage, + hidden: false, // only return visible things + })), + }; + } + updateFeatureToggles(toggles: FeatureToggle[]): Promise<void> { + const patchBody: ResolvedToggleState = { + kind: 'ResolvedToggleState', + enabled: {}, + }; + toggles.forEach((t) => { + patchBody.enabled[t.name] = t.enabled; + }); + return getBackendSrv().patch(this.baseURL + '/current', patchBody); + } +} + +export const getTogglesAPI = (): FeatureTogglesAPI => { + return new K8sAPI(); +}; diff --git a/public/app/features/admin/AdminFeatureTogglesPage.tsx b/public/app/features/admin/AdminFeatureTogglesPage.tsx index 7c7fde06c5f53..0684405b3eec4 100644 --- a/public/app/features/admin/AdminFeatureTogglesPage.tsx +++ b/public/app/features/admin/AdminFeatureTogglesPage.tsx @@ -1,26 +1,22 @@ import { css } from '@emotion/css'; import React, { useState } from 'react'; +import { useAsync } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2, Icon } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; -import { useGetFeatureTogglesQuery, useGetManagerStateQuery } from './AdminFeatureTogglesAPI'; +import { getTogglesAPI } from './AdminFeatureTogglesAPI'; import { AdminFeatureTogglesTable } from './AdminFeatureTogglesTable'; export default function AdminFeatureTogglesPage() { - const { data: featureToggles, isLoading, isError } = useGetFeatureTogglesQuery(); - const { data: featureMgmtState } = useGetManagerStateQuery(); - const [updateSuccessful, setUpdateSuccessful] = useState(false); - + const [reload, setReload] = useState(1); + const togglesApi = getTogglesAPI(); + const featureState = useAsync(() => togglesApi.getFeatureToggles(), [reload]); const styles = useStyles2(getStyles); - const getErrorMessage = () => { - return 'Error fetching feature toggles'; - }; - const handleUpdateSuccess = () => { - setUpdateSuccessful(true); + setReload(reload + 1); }; const EditingAlert = () => { @@ -30,7 +26,7 @@ export default function AdminFeatureTogglesPage() { <Icon name="exclamation-triangle" /> </div> <span className={styles.message}> - {featureMgmtState?.restartRequired || updateSuccessful + {featureState.value?.restartRequired ? 'A restart is pending for your Grafana instance to apply the latest feature toggle changes' : 'Saving feature toggle changes will prompt a restart of the instance, which may take a few minutes'} </span> @@ -54,15 +50,16 @@ export default function AdminFeatureTogglesPage() { return ( <Page navId="feature-toggles" subTitle={subTitle}> - <Page.Contents> + <Page.Contents isLoading={featureState.loading}> <> - {isError && getErrorMessage()} - {isLoading && 'Fetching feature toggles'} - {featureMgmtState?.allowEditing && <EditingAlert />} - {featureToggles && ( + {featureState.error} + {featureState.loading && 'Fetching feature toggles'} + + <EditingAlert /> + {featureState.value && ( <AdminFeatureTogglesTable - featureToggles={featureToggles} - allowEditing={featureMgmtState?.allowEditing || false} + featureToggles={featureState.value.toggles} + allowEditing={featureState.value.allowEditing || false} onUpdateSuccess={handleUpdateSuccess} /> )} diff --git a/public/app/features/admin/AdminFeatureTogglesTable.tsx b/public/app/features/admin/AdminFeatureTogglesTable.tsx index d67a60c71eb1d..3be88a6fe7574 100644 --- a/public/app/features/admin/AdminFeatureTogglesTable.tsx +++ b/public/app/features/admin/AdminFeatureTogglesTable.tsx @@ -1,8 +1,8 @@ import React, { useState, useRef } from 'react'; -import { Switch, InteractiveTable, Tooltip, type CellProps, Button, type SortByFn } from '@grafana/ui'; +import { Switch, InteractiveTable, Tooltip, type CellProps, Button, ConfirmModal, type SortByFn } from '@grafana/ui'; -import { type FeatureToggle, useUpdateFeatureTogglesMutation } from './AdminFeatureTogglesAPI'; +import { FeatureToggle, getTogglesAPI } from './AdminFeatureTogglesAPI'; interface Props { featureToggles: FeatureToggle[]; @@ -30,10 +30,13 @@ const sortByEnabled: SortByFn<FeatureToggle> = (a, b) => { }; export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdateSuccess }: Props) { + // sort manually, doesn't look like it can be automatically done in the table + featureToggles.sort((a, b) => a.name.localeCompare(b.name)); const serverToggles = useRef<FeatureToggle[]>(featureToggles); const [localToggles, setLocalToggles] = useState<FeatureToggle[]>(featureToggles); - const [updateFeatureToggles] = useUpdateFeatureTogglesMutation(); const [isSaving, setIsSaving] = useState(false); + const [showSaveModel, setShowSaveModal] = useState(false); + const togglesApi = getTogglesAPI(); const handleToggleChange = (toggle: FeatureToggle, newValue: boolean) => { const updatedToggle = { ...toggle, enabled: newValue }; @@ -47,17 +50,23 @@ export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdat setIsSaving(true); try { const modifiedToggles = getModifiedToggles(); - const resp = await updateFeatureToggles(modifiedToggles); - if (!('error' in resp)) { - // server toggles successfully updated - serverToggles.current = [...localToggles]; - onUpdateSuccess(); - } + await togglesApi.updateFeatureToggles(modifiedToggles); + // Pretend the values came from a new request + serverToggles.current = [...localToggles]; + onUpdateSuccess(); // should trigger a new get } finally { setIsSaving(false); } }; + const saveButtonRef = useRef<HTMLButtonElement | null>(null); + const showSaveChangesModal = (show: boolean) => () => { + setShowSaveModal(show); + if (!show && saveButtonRef.current) { + saveButtonRef.current.focus(); + } + }; + const getModifiedToggles = (): FeatureToggle[] => { return localToggles.filter((toggle, index) => toggle.enabled !== serverToggles.current[index].enabled); }; @@ -72,11 +81,30 @@ export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdat return 'Feature management is not configured for editing'; } if (readOnlyToggle) { - return 'Preview features are not editable'; + return 'This is a non-editable feature'; } return ''; }; + const getStageCell = (stage: string) => { + switch (stage) { + case 'GA': + return ( + <Tooltip content={'General availability'}> + <div>GA</div> + </Tooltip> + ); + case 'privatePreview': + case 'preview': + case 'experimental': + return 'Beta'; + case 'deprecated': + return 'Deprecated'; + default: + return stage; + } + }; + const columns = [ { id: 'name', @@ -90,11 +118,16 @@ export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdat cell: ({ cell: { value } }: CellProps<FeatureToggle, string>) => <div>{value}</div>, sortType: sortByDescription, }, + { + id: 'stage', + header: 'Stage', + cell: ({ cell: { value } }: CellProps<FeatureToggle, string>) => <div>{getStageCell(value)}</div>, + }, { id: 'enabled', header: 'State', - cell: ({ row }: CellProps<FeatureToggle, boolean>) => ( - <Tooltip content={getToggleTooltipContent(row.original.readOnly)}> + cell: ({ row }: CellProps<FeatureToggle, boolean>) => { + const renderStateSwitch = ( <div> <Switch value={row.original.enabled} @@ -102,8 +135,14 @@ export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdat onChange={(e) => handleToggleChange(row.original, e.currentTarget.checked)} /> </div> - </Tooltip> - ), + ); + + return row.original.readOnly ? ( + <Tooltip content={getToggleTooltipContent(row.original.readOnly)}>{renderStateSwitch}</Tooltip> + ) : ( + renderStateSwitch + ); + }, sortType: sortByEnabled, }, ]; @@ -112,9 +151,28 @@ export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdat <> {allowEditing && ( <div style={{ display: 'flex', justifyContent: 'flex-end', padding: '0 0 5px 0' }}> - <Button disabled={!hasModifications() || isSaving} onClick={handleSaveChanges}> + <Button disabled={!hasModifications() || isSaving} onClick={showSaveChangesModal(true)} ref={saveButtonRef}> {isSaving ? 'Saving...' : 'Save Changes'} </Button> + <ConfirmModal + isOpen={showSaveModel} + title="Apply feature toggle changes" + body={ + <div> + <p> + Some features are stable (GA) and enabled by default, whereas some are currently in their preliminary + Beta phase, available for early adoption. + </p> + <p>We advise understanding the implications of each feature change before making modifications.</p> + </div> + } + confirmText="Save changes" + onConfirm={async () => { + showSaveChangesModal(false)(); + handleSaveChanges(); + }} + onDismiss={showSaveChangesModal(false)} + /> </div> )} <InteractiveTable columns={columns} data={localToggles} getRowId={(featureToggle) => featureToggle.name} /> diff --git a/public/app/features/admin/ServerStats.test.tsx b/public/app/features/admin/ServerStats.test.tsx index b658800f12b60..2a88bcb7fe58e 100644 --- a/public/app/features/admin/ServerStats.test.tsx +++ b/public/app/features/admin/ServerStats.test.tsx @@ -51,7 +51,6 @@ describe('ServerStats', () => { }); it('Should render page with anonymous stats', async () => { - config.featureToggles.displayAnonymousStats = true; config.anonymousEnabled = true; config.anonymousDeviceLimit = 10; render(<ServerStats />); diff --git a/public/app/features/admin/ServerStats.tsx b/public/app/features/admin/ServerStats.tsx index 4e94e8a6a4e0c..8d3664954575e 100644 --- a/public/app/features/admin/ServerStats.tsx +++ b/public/app/features/admin/ServerStats.tsx @@ -100,7 +100,7 @@ export const ServerStats = () => { }; const getAnonymousStatsContent = (stats: ServerStat | null, config: GrafanaBootConfig) => { - if (!config.anonymousEnabled || !config.featureToggles.displayAnonymousStats || !stats?.activeDevices) { + if (!config.anonymousEnabled || !stats?.activeDevices) { return []; } if (!config.anonymousDeviceLimit) { diff --git a/public/app/features/admin/UserCreatePage.tsx b/public/app/features/admin/UserCreatePage.tsx index da72bd30d90a3..34bdee39089d0 100644 --- a/public/app/features/admin/UserCreatePage.tsx +++ b/public/app/features/admin/UserCreatePage.tsx @@ -1,9 +1,10 @@ import React, { useCallback } from 'react'; +import { useForm } from 'react-hook-form'; import { useHistory } from 'react-router-dom'; import { NavModelItem } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; -import { Form, Button, Input, Field } from '@grafana/ui'; +import { Button, Input, Field } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; interface UserDTO { @@ -24,6 +25,11 @@ const pageNav: NavModelItem = { const UserCreatePage = () => { const history = useHistory(); + const { + handleSubmit, + register, + formState: { errors }, + } = useForm<UserDTO>({ mode: 'onBlur' }); const onSubmit = useCallback( async (data: UserDTO) => { @@ -37,45 +43,34 @@ const UserCreatePage = () => { return ( <Page navId="global-users" pageNav={pageNav}> <Page.Contents> - <Form onSubmit={onSubmit} validateOn="onBlur"> - {({ register, errors }) => { - return ( - <> - <Field - label="Name" - required - invalid={!!errors.name} - error={errors.name ? 'Name is required' : undefined} - > - <Input id="name-input" {...register('name', { required: true })} /> - </Field> + <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '600px' }}> + <Field label="Name" required invalid={!!errors.name} error={errors.name ? 'Name is required' : undefined}> + <Input id="name-input" {...register('name', { required: true })} /> + </Field> - <Field label="Email"> - <Input id="email-input" {...register('email')} /> - </Field> + <Field label="Email"> + <Input id="email-input" {...register('email')} /> + </Field> - <Field label="Username"> - <Input id="username-input" {...register('login')} /> - </Field> - <Field - label="Password" - required - invalid={!!errors.password} - error={errors.password ? 'Password is required and must contain at least 4 characters' : undefined} - > - <Input - id="password-input" - {...register('password', { - validate: (value) => value.trim() !== '' && value.length >= 4, - })} - type="password" - /> - </Field> - <Button type="submit">Create user</Button> - </> - ); - }} - </Form> + <Field label="Username"> + <Input id="username-input" {...register('login')} /> + </Field> + <Field + label="Password" + required + invalid={!!errors.password} + error={errors.password ? 'Password is required and must contain at least 4 characters' : undefined} + > + <Input + id="password-input" + {...register('password', { + validate: (value) => value.trim() !== '' && value.length >= 4, + })} + type="password" + /> + </Field> + <Button type="submit">Create user</Button> + </form> </Page.Contents> </Page> ); diff --git a/public/app/features/admin/UserListAnonymousPage.tsx b/public/app/features/admin/UserListAnonymousPage.tsx index a452fa5a90a91..3d4355653a9db 100644 --- a/public/app/features/admin/UserListAnonymousPage.tsx +++ b/public/app/features/admin/UserListAnonymousPage.tsx @@ -1,35 +1,85 @@ +import { css } from '@emotion/css'; import React, { useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; +import { GrafanaTheme2 } from '@grafana/data'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { RadioButtonGroup, useStyles2, FilterInput } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { StoreState } from '../../types'; import { AnonUsersDevicesTable } from './Users/AnonUsersTable'; -import { fetchUsersAnonymousDevices } from './state/actions'; +import { fetchUsersAnonymousDevices, changeAnonUserSort, changeAnonPage, changeAnonQuery } from './state/actions'; const mapDispatchToProps = { fetchUsersAnonymousDevices, + changeAnonUserSort, + changeAnonPage, + changeAnonQuery, }; const mapStateToProps = (state: StoreState) => ({ devices: state.userListAnonymousDevices.devices, + query: state.userListAnonymousDevices.query, + showPaging: state.userListAnonymousDevices.showPaging, + totalPages: state.userListAnonymousDevices.totalPages, + page: state.userListAnonymousDevices.page, + filters: state.userListAnonymousDevices.filters, }); +const selectors = e2eSelectors.pages.UserListPage.UserListAdminPage; + const connector = connect(mapStateToProps, mapDispatchToProps); interface OwnProps {} type Props = OwnProps & ConnectedProps<typeof connector>; -const UserListAnonymousDevicesPageUnConnected = ({ devices, fetchUsersAnonymousDevices }: Props) => { +const UserListAnonymousDevicesPageUnConnected = ({ + devices, + fetchUsersAnonymousDevices, + query, + changeAnonQuery, + filters, + showPaging, + totalPages, + page, + changeAnonPage, + changeAnonUserSort, +}: Props) => { + const styles = useStyles2(getStyles); + useEffect(() => { fetchUsersAnonymousDevices(); }, [fetchUsersAnonymousDevices]); return ( <Page.Contents> - <AnonUsersDevicesTable devices={devices} /> + <div className={styles.actionBar} data-testid={selectors.container}> + <div className={styles.row}> + <FilterInput + placeholder="Search devices by ip adress." + autoFocus={true} + value={query} + onChange={changeAnonQuery} + /> + <RadioButtonGroup + options={[{ label: 'Active last 30 days', value: true }]} + // onChange={(value) => changeFilter({ name: 'activeLast30Days', value })} + value={filters.find((f) => f.name === 'activeLast30Days')?.value} + className={styles.filter} + /> + </div> + </div> + <AnonUsersDevicesTable + devices={devices} + showPaging={showPaging} + totalPages={totalPages} + onChangePage={changeAnonPage} + currentPage={page} + fetchData={changeAnonUserSort} + /> </Page.Contents> ); }; @@ -44,4 +94,37 @@ export function UserListAnonymousDevicesPage() { ); } +const getStyles = (theme: GrafanaTheme2) => { + return { + filter: css({ + margin: theme.spacing(0, 1), + [theme.breakpoints.down('sm')]: { + margin: 0, + }, + }), + actionBar: css({ + marginBottom: theme.spacing(2), + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(2), + [theme.breakpoints.down('sm')]: { + flexWrap: 'wrap', + }, + }), + row: css({ + display: 'flex', + alignItems: 'flex-start', + textAlign: 'left', + marginBottom: theme.spacing(0.5), + flexGrow: 1, + + [theme.breakpoints.down('sm')]: { + flexWrap: 'wrap', + gap: theme.spacing(2), + width: '100%', + }, + }), + }; +}; + export default UserListAnonymousDevicesPage; diff --git a/public/app/features/admin/UserListPage.tsx b/public/app/features/admin/UserListPage.tsx index 838031fe76070..378740f95a6f8 100644 --- a/public/app/features/admin/UserListPage.tsx +++ b/public/app/features/admin/UserListPage.tsx @@ -5,6 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { config, featureEnabled } from '@grafana/runtime'; import { useStyles2, TabsBar, Tab } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { contextSrv } from 'app/core/services/context_srv'; import { isPublicDashboardsEnabled } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils'; @@ -27,7 +28,7 @@ const selectors = e2eSelectors.pages.UserListPage; const PublicDashboardsTab = ({ view, setView }: { view: TabView | null; setView: (v: TabView | null) => void }) => ( <Tab - label="Public dashboard users" + label={t('users-access-list.tabs.public-dashboard-users-tab-title', 'Public dashboard users')} active={view === TabView.PUBLIC_DASHBOARDS} onChangeTab={() => setView(TabView.PUBLIC_DASHBOARDS)} data-testid={selectors.tabs.publicDashboardsUsers} @@ -78,7 +79,7 @@ export default function UserListPage() { onChangeTab={() => setView(TabView.ORG)} data-testid={selectors.tabs.orgUsers} /> - {config.anonymousEnabled && config.featureToggles.displayAnonymousStats && ( + {config.anonymousEnabled && ( <Tab label="Anonymous devices" active={view === TabView.ANON} diff --git a/public/app/features/admin/UserListPublicDashboardPage/DashboardsListModalButton.tsx b/public/app/features/admin/UserListPublicDashboardPage/DashboardsListModalButton.tsx index 5a0b0c3e4348e..625b4075679ea 100644 --- a/public/app/features/admin/UserListPublicDashboardPage/DashboardsListModalButton.tsx +++ b/public/app/features/admin/UserListPublicDashboardPage/DashboardsListModalButton.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data/src'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; import { Button, LoadingPlaceholder, Modal, ModalsController, useStyles2 } from '@grafana/ui/src'; +import { Trans, t } from 'app/core/internationalization'; import { generatePublicDashboardConfigUrl, generatePublicDashboardUrl, @@ -18,10 +19,17 @@ export const DashboardsListModal = ({ email, onDismiss }: { email: string; onDis const { data: dashboards, isLoading } = useGetActiveUserDashboardsQuery(email); return ( - <Modal className={styles.modal} isOpen title="Public dashboards" onDismiss={onDismiss}> + <Modal + className={styles.modal} + isOpen + title={t('public-dashboard-users-access-list.modal.dashboard-modal-title', 'Public dashboards')} + onDismiss={onDismiss} + > {isLoading ? ( <div className={styles.loading}> - <LoadingPlaceholder text="Loading..." /> + <LoadingPlaceholder + text={t('public-dashboard-users-access-list.dashboard-modal.loading-text', 'Loading...')} + /> </div> ) : ( dashboards?.map((dash) => ( @@ -35,7 +43,9 @@ export const DashboardsListModal = ({ email, onDismiss }: { email: string; onDis href={generatePublicDashboardUrl(dash.publicDashboardAccessToken)} onClick={onDismiss} > - Public dashboard URL + <Trans i18nKey="public-dashboard-users-access-list.dashboard-modal.public-dashboard-link"> + Public dashboard URL + </Trans> </a> <span className={styles.urlsDivider}>•</span> <a @@ -43,7 +53,9 @@ export const DashboardsListModal = ({ email, onDismiss }: { email: string; onDis href={generatePublicDashboardConfigUrl(dash.dashboardUid, dash.slug)} onClick={onDismiss} > - Public dashboard settings + <Trans i18nKey="public-dashboard-users-access-list.dashboard-modal.public-dashboard-setting"> + Public dashboard settings + </Trans> </a> </div> <hr className={styles.divider} /> @@ -54,20 +66,26 @@ export const DashboardsListModal = ({ email, onDismiss }: { email: string; onDis ); }; -export const DashboardsListModalButton = ({ email }: { email: string }) => ( - <ModalsController> - {({ showModal, hideModal }) => ( - <Button - variant="secondary" - size="sm" - icon="question-circle" - title="Open dashboards list" - aria-label="Open dashboards list" - onClick={() => showModal(DashboardsListModal, { email, onDismiss: hideModal })} - /> - )} - </ModalsController> -); +export const DashboardsListModalButton = ({ email }: { email: string }) => { + const translatedDashboardListModalButtonText = t( + 'public-dashboard-users-access-list.dashboard-modal.open-dashboard-list-text', + 'Open dashboards list' + ); + return ( + <ModalsController> + {({ showModal, hideModal }) => ( + <Button + variant="secondary" + size="sm" + icon="question-circle" + title={translatedDashboardListModalButtonText} + aria-label={translatedDashboardListModalButtonText} + onClick={() => showModal(DashboardsListModal, { email, onDismiss: hideModal })} + /> + )} + </ModalsController> + ); +}; const getStyles = (theme: GrafanaTheme2) => ({ modal: css` diff --git a/public/app/features/admin/UserListPublicDashboardPage/DeleteUserModalButton.tsx b/public/app/features/admin/UserListPublicDashboardPage/DeleteUserModalButton.tsx index 3fc085e9b309d..0ed323c7c4a45 100644 --- a/public/app/features/admin/UserListPublicDashboardPage/DeleteUserModalButton.tsx +++ b/public/app/features/admin/UserListPublicDashboardPage/DeleteUserModalButton.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data/src'; import { Button, Modal, ModalsController, useStyles2 } from '@grafana/ui/src'; +import { Trans, t } from 'app/core/internationalization'; import { SessionUser } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils'; import { useRevokeAllAccessMutation } from '../../dashboard/api/publicDashboardApi'; @@ -17,37 +18,59 @@ const DeleteUserModal = ({ user, hideModal }: { user: SessionUser; hideModal: () }; return ( - <Modal className={styles.modal} isOpen title="Revoke access" onDismiss={hideModal}> - <p className={styles.description}>Are you sure you want to revoke access for {user.email}?</p> + <Modal + className={styles.modal} + isOpen + title={t('public-dashboard-users-access-list.delete-user-modal.revoke-access-title', 'Revoke access')} + onDismiss={hideModal} + > <p className={styles.description}> - This action will immediately revoke {user.email}'s access to all public dashboards. + <Trans i18nKey="public-dashboard-users-access-list.delete-user-modal.revoke-user-access-modal-desc-line1"> + Are you sure you want to revoke access for {{ email: user.email }}? + </Trans> + </p> + <p className={styles.description}> + <Trans + i18nKey="public-dashboard-users-access-list.delete-user-modal.revoke-user-access-modal-desc-line2" + shouldUnescape + > + This action will immediately revoke {{ email: user.email }}'s access to all public dashboards. + </Trans> </p> <Modal.ButtonRow> <Button type="button" variant="secondary" onClick={hideModal} fill="outline"> - Cancel + <Trans i18nKey="public-dashboard-users-access-list.delete-user-modal.delete-user-cancel-button">Cancel</Trans> </Button> <Button type="button" variant="destructive" onClick={onRevokeAccessClick}> - Revoke access + <Trans i18nKey="public-dashboard-users-access-list.delete-user-modal.delete-user-revoke-access-button"> + Revoke access + </Trans> </Button> </Modal.ButtonRow> </Modal> ); }; -export const DeleteUserModalButton = ({ user }: { user: SessionUser }) => ( - <ModalsController> - {({ showModal, hideModal }) => ( - <Button - size="sm" - variant="destructive" - onClick={() => showModal(DeleteUserModal, { user, hideModal })} - icon="times" - aria-label="Delete user" - title="Delete user" - /> - )} - </ModalsController> -); +export const DeleteUserModalButton = ({ user }: { user: SessionUser }) => { + const translatedDeleteUserText = t( + 'public-dashboard-users-access-list.delete-user-modal.delete-user-button-text', + 'Delete user' + ); + return ( + <ModalsController> + {({ showModal, hideModal }) => ( + <Button + size="sm" + variant="destructive" + onClick={() => showModal(DeleteUserModal, { user, hideModal })} + icon="times" + aria-label={translatedDeleteUserText} + title={translatedDeleteUserText} + /> + )} + </ModalsController> + ); +}; const getStyles = (theme: GrafanaTheme2) => ({ modal: css` diff --git a/public/app/features/admin/UserListPublicDashboardPage/UserListPublicDashboardPage.tsx b/public/app/features/admin/UserListPublicDashboardPage/UserListPublicDashboardPage.tsx index 8bf14e8f4ab98..234cdca3f5d57 100644 --- a/public/app/features/admin/UserListPublicDashboardPage/UserListPublicDashboardPage.tsx +++ b/public/app/features/admin/UserListPublicDashboardPage/UserListPublicDashboardPage.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; import { HorizontalGroup, Icon, Tag, Tooltip } from '@grafana/ui/src'; import { Page } from 'app/core/components/Page/Page'; +import { Trans, t } from 'app/core/internationalization'; import { useGetActiveUsersQuery } from '../../dashboard/api/publicDashboardApi'; @@ -19,16 +20,32 @@ export const UserListPublicDashboardPage = () => { <table className="filter-table form-inline" data-testid={selectors.container}> <thead> <tr> - <th>Email</th> <th> - <span>Activated </span> - <Tooltip placement="top" content={'Earliest time user has been an active user to a dashboard'}> + <Trans i18nKey="public-dashboard-users-access-list.table-header.email-label">Email</Trans> + </th> + <th> + <span> + <Trans i18nKey="public-dashboard-users-access-list.table-header.activated-label">Activated</Trans> + </span> + <Tooltip + placement="top" + content={t( + 'public-dashboard-users-access-list.table-header.activated-tooltip', + 'Earliest time user has been an active user to a dashboard' + )} + > <Icon name="question-circle" /> </Tooltip> </th> - <th>Last active</th> - <th>Origin</th> - <th>Role</th> + <th> + <Trans i18nKey="public-dashboard-users-access-list.table-header.last-active-label">Last active</Trans> + </th> + <th> + <Trans i18nKey="public-dashboard-users-access-list.table-header.origin-label">Origin</Trans> + </th> + <th> + <Trans i18nKey="public-dashboard-users-access-list.table-header.role-label">Role</Trans> + </th> <th></th> </tr> </thead> diff --git a/public/app/features/admin/UserOrgs.tsx b/public/app/features/admin/UserOrgs.tsx index a9b2c6c6241c5..5583380263aa1 100644 --- a/public/app/features/admin/UserOrgs.tsx +++ b/public/app/features/admin/UserOrgs.tsx @@ -245,7 +245,6 @@ class UnThemedOrgRow extends PureComponent<OrgRowProps> { confirmVariant="destructive" onCancel={this.onCancelClick} onConfirm={this.onOrgRemove} - autoFocus > Remove from organization </ConfirmButton> diff --git a/public/app/features/admin/UserSessions.tsx b/public/app/features/admin/UserSessions.tsx index 1e63b44448e46..1129dc7201fd5 100644 --- a/public/app/features/admin/UserSessions.tsx +++ b/public/app/features/admin/UserSessions.tsx @@ -78,7 +78,6 @@ class BaseUserSessions extends PureComponent<Props, State> { confirmText="Confirm logout" confirmVariant="destructive" onConfirm={this.onSessionRevoke(session.id)} - autoFocus > Force logout </ConfirmButton> diff --git a/public/app/features/admin/Users/AnonUsersTable.tsx b/public/app/features/admin/Users/AnonUsersTable.tsx index 4ac143119761f..75c7f96aa78d4 100644 --- a/public/app/features/admin/Users/AnonUsersTable.tsx +++ b/public/app/features/admin/Users/AnonUsersTable.tsx @@ -1,6 +1,17 @@ import React, { useMemo } from 'react'; -import { Avatar, CellProps, Column, InteractiveTable, Stack, Badge, Tooltip } from '@grafana/ui'; +import { + Avatar, + CellProps, + Column, + InteractiveTable, + Stack, + Badge, + Tooltip, + Pagination, + FetchDataFunc, +} from '@grafana/ui'; +import { EmptyArea } from 'app/features/alerting/unified/components/EmptyArea'; import { UserAnonymousDeviceDTO } from 'app/types'; type Cell<T extends keyof UserAnonymousDeviceDTO = keyof UserAnonymousDeviceDTO> = CellProps< @@ -48,9 +59,22 @@ const UserAgentCell = ({ value }: UserAgentCellProps) => { interface AnonUsersTableProps { devices: UserAnonymousDeviceDTO[]; + // for pagination + showPaging?: boolean; + totalPages: number; + onChangePage: (page: number) => void; + currentPage: number; + fetchData?: FetchDataFunc<UserAnonymousDeviceDTO>; } -export const AnonUsersDevicesTable = ({ devices }: AnonUsersTableProps) => { +export const AnonUsersDevicesTable = ({ + devices, + showPaging, + totalPages, + onChangePage, + currentPage, + fetchData, +}: AnonUsersTableProps) => { const columns: Array<Column<UserAnonymousDeviceDTO>> = useMemo( () => [ { @@ -70,9 +94,9 @@ export const AnonUsersDevicesTable = ({ devices }: AnonUsersTableProps) => { sortType: 'string', }, { - id: 'lastSeenAt', + id: 'updatedAt', header: 'Last active', - cell: ({ cell: { value } }: Cell<'lastSeenAt'>) => value, + cell: ({ cell: { value } }: Cell<'updatedAt'>) => value, sortType: (a, b) => new Date(a.original.updatedAt).getTime() - new Date(b.original.updatedAt).getTime(), }, { @@ -85,7 +109,17 @@ export const AnonUsersDevicesTable = ({ devices }: AnonUsersTableProps) => { ); return ( <Stack direction={'column'} gap={2}> - <InteractiveTable columns={columns} data={devices} getRowId={(user) => user.deviceId} /> + <InteractiveTable columns={columns} data={devices} getRowId={(user) => user.deviceId} fetchData={fetchData} /> + {showPaging && ( + <Stack justifyContent={'flex-end'}> + <Pagination numberOfPages={totalPages} currentPage={currentPage} onNavigate={onChangePage} /> + </Stack> + )} + {devices.length === 0 && ( + <EmptyArea> + <span>No anonymous users found.</span> + </EmptyArea> + )} </Stack> ); }; diff --git a/public/app/features/admin/Users/OrgUsersTable.tsx b/public/app/features/admin/Users/OrgUsersTable.tsx index 99f0a9ea40c79..135413ae534b7 100644 --- a/public/app/features/admin/Users/OrgUsersTable.tsx +++ b/public/app/features/admin/Users/OrgUsersTable.tsx @@ -15,6 +15,7 @@ import { Pagination, Stack, Tag, + Text, Tooltip, } from '@grafana/ui'; import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; @@ -107,7 +108,9 @@ export const OrgUsersTable = ({ { id: 'lastSeenAtAge', header: 'Last active', - cell: ({ cell: { value } }: Cell<'lastSeenAtAge'>) => value, + cell: ({ cell: { value } }: Cell<'lastSeenAtAge'>) => { + return <>{value && <>{value === '10 years' ? <Text color={'disabled'}>Never</Text> : value}</>}</>; + }, sortType: (a, b) => new Date(a.original.lastSeenAt).getTime() - new Date(b.original.lastSeenAt).getTime(), }, { diff --git a/public/app/features/admin/Users/UsersTable.tsx b/public/app/features/admin/Users/UsersTable.tsx index f945d818d4d41..fd7cdbb68139b 100644 --- a/public/app/features/admin/Users/UsersTable.tsx +++ b/public/app/features/admin/Users/UsersTable.tsx @@ -1,4 +1,5 @@ import React, { useMemo } from 'react'; +import { UseTableRowProps } from 'react-table'; import { Avatar, @@ -38,6 +39,7 @@ export const UsersTable = ({ fetchData, }: UsersTableProps) => { const showLicensedRole = useMemo(() => users.some((user) => user.licensedRole), [users]); + const showBelongsTo = useMemo(() => users.some((user) => user.orgs), [users]); const columns: Array<Column<UserDTO>> = useMemo( () => [ { @@ -63,23 +65,28 @@ export const UsersTable = ({ cell: ({ cell: { value } }: Cell<'name'>) => value, sortType: 'string', }, - { - id: 'orgs', - header: 'Belongs to', - cell: ({ cell: { value, row } }: Cell<'orgs'>) => { - return ( - <Stack alignItems={'center'}> - <OrgUnits units={value} icon={'building'} /> - {row.original.isAdmin && ( - <Tooltip placement="top" content="Grafana Admin"> - <Icon name="shield" /> - </Tooltip> - )} - </Stack> - ); - }, - sortType: (a, b) => (a.original.orgs?.length || 0) - (b.original.orgs?.length || 0), - }, + ...(showBelongsTo + ? [ + { + id: 'orgs', + header: 'Belongs to', + cell: ({ cell: { value, row } }: Cell<'orgs'>) => { + return ( + <Stack alignItems={'center'}> + <OrgUnits units={value} icon={'building'} /> + {row.original.isAdmin && ( + <Tooltip placement="top" content="Grafana Admin"> + <Icon name="shield" /> + </Tooltip> + )} + </Stack> + ); + }, + sortType: (a: UseTableRowProps<UserDTO>, b: UseTableRowProps<UserDTO>) => + (a.original.orgs?.length || 0) - (b.original.orgs?.length || 0), + }, + ] + : []), ...(showLicensedRole ? [ { @@ -140,7 +147,7 @@ export const UsersTable = ({ }, }, ], - [showLicensedRole] + [showLicensedRole, showBelongsTo] ); return ( <Stack direction={'column'} gap={2}> diff --git a/public/app/features/admin/ldap/LdapConnectionStatus.tsx b/public/app/features/admin/ldap/LdapConnectionStatus.tsx index 2566a3ff93cf5..bf4fc842fc8c8 100644 --- a/public/app/features/admin/ldap/LdapConnectionStatus.tsx +++ b/public/app/features/admin/ldap/LdapConnectionStatus.tsx @@ -1,48 +1,65 @@ -import React from 'react'; +import React, { useMemo } from 'react'; -import { Alert, Icon } from '@grafana/ui'; +import { Alert, CellProps, Column, Icon, InteractiveTable, Stack, Text, Tooltip } from '@grafana/ui'; import { AppNotificationSeverity, LdapConnectionInfo, LdapServerInfo } from 'app/types'; interface Props { ldapConnectionInfo: LdapConnectionInfo; } +interface ServerInfo { + host: string; + port: number; + available: boolean; +} + export const LdapConnectionStatus = ({ ldapConnectionInfo }: Props) => { + const columns = useMemo<Array<Column<ServerInfo>>>( + () => [ + { + id: 'host', + header: 'Host', + disableGrow: true, + }, + { + id: 'port', + header: 'Port', + disableGrow: true, + }, + { + id: 'available', + cell: (serverInfo: CellProps<ServerInfo>) => { + return serverInfo.cell.value ? ( + <Stack justifyContent="end"> + <Tooltip content="Connection is available"> + <Icon name="check" className="pull-right" /> + </Tooltip> + </Stack> + ) : ( + <Stack justifyContent="end"> + <Tooltip content="Connection is not available"> + <Icon name="exclamation-triangle" /> + </Tooltip> + </Stack> + ); + }, + }, + ], + [] + ); + + const data = useMemo<ServerInfo[]>(() => ldapConnectionInfo, [ldapConnectionInfo]); + return ( - <> - <h3 className="page-heading">LDAP Connection</h3> - <div className="gf-form-group"> - <div className="gf-form"> - <table className="filter-table form-inline"> - <thead> - <tr> - <th>Host</th> - <th colSpan={2}>Port</th> - </tr> - </thead> - <tbody> - {ldapConnectionInfo && - ldapConnectionInfo.map((serverInfo, index) => ( - <tr key={index}> - <td>{serverInfo.host}</td> - <td>{serverInfo.port}</td> - <td> - {serverInfo.available ? ( - <Icon name="check" className="pull-right" /> - ) : ( - <Icon name="exclamation-triangle" className="pull-right" /> - )} - </td> - </tr> - ))} - </tbody> - </table> - </div> - <div className="gf-form-group"> - <LdapErrorBox ldapConnectionInfo={ldapConnectionInfo} /> - </div> - </div> - </> + <section> + <Stack direction="column" gap={2}> + <Text color="primary" element="h3"> + LDAP Connection + </Text> + <InteractiveTable data={data} columns={columns} getRowId={(serverInfo) => serverInfo.host + serverInfo.port} /> + <LdapErrorBox ldapConnectionInfo={ldapConnectionInfo} /> + </Stack> + </section> ); }; diff --git a/public/app/features/admin/ldap/LdapPage.tsx b/public/app/features/admin/ldap/LdapPage.tsx index e82df553ddc7d..d7bf34f6b87dc 100644 --- a/public/app/features/admin/ldap/LdapPage.tsx +++ b/public/app/features/admin/ldap/LdapPage.tsx @@ -1,9 +1,10 @@ -import React, { PureComponent } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { connect, ConnectedProps } from 'react-redux'; import { NavModelItem } from '@grafana/data'; import { featureEnabled } from '@grafana/runtime'; -import { Alert, Button, Field, Form, HorizontalGroup, Input } from '@grafana/ui'; +import { Alert, Button, Field, Input, Stack } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; @@ -37,10 +38,6 @@ interface OwnProps extends GrafanaRouteComponentProps<{}, { username?: string }> userError?: LdapError; } -interface State { - isLoading: boolean; -} - interface FormModel { username: string; } @@ -52,100 +49,105 @@ const pageNav: NavModelItem = { id: 'LDAP', }; -export class LdapPage extends PureComponent<Props, State> { - state = { - isLoading: true, - }; - - async componentDidMount() { - const { clearUserMappingInfo, queryParams } = this.props; - await clearUserMappingInfo(); - await this.fetchLDAPStatus(); - - if (queryParams.username) { - await this.fetchUserMapping(queryParams.username); +export const LdapPage = ({ + clearUserMappingInfo, + queryParams, + loadLdapState, + loadLdapSyncStatus, + loadUserMapping, + clearUserError, + ldapUser, + userError, + ldapError, + ldapSyncInfo, + ldapConnectionInfo, +}: Props) => { + const [isLoading, setIsLoading] = useState(true); + const { register, handleSubmit } = useForm<FormModel>(); + + const fetchUserMapping = useCallback( + async (username: string) => { + return loadUserMapping(username); + }, + [loadUserMapping] + ); + + useEffect(() => { + const fetchLDAPStatus = async () => { + return Promise.all([loadLdapState(), loadLdapSyncStatus()]); + }; + + async function init() { + await clearUserMappingInfo(); + await fetchLDAPStatus(); + + if (queryParams.username) { + await fetchUserMapping(queryParams.username); + } + + setIsLoading(false); } - this.setState({ isLoading: false }); - } - - async fetchLDAPStatus() { - const { loadLdapState, loadLdapSyncStatus } = this.props; - return Promise.all([loadLdapState(), loadLdapSyncStatus()]); - } + init(); + }, [clearUserMappingInfo, fetchUserMapping, loadLdapState, loadLdapSyncStatus, queryParams]); - async fetchUserMapping(username: string) { - const { loadUserMapping } = this.props; - return await loadUserMapping(username); - } - - search = (username: string) => { + const search = ({ username }: FormModel) => { if (username) { - this.fetchUserMapping(username); + fetchUserMapping(username); } }; - onClearUserError = () => { - this.props.clearUserError(); + const onClearUserError = () => { + clearUserError(); }; - render() { - const { ldapUser, userError, ldapError, ldapSyncInfo, ldapConnectionInfo, queryParams } = this.props; - const { isLoading } = this.state; - const canReadLDAPUser = contextSrv.hasPermission(AccessControlAction.LDAPUsersRead); - - return ( - <Page navId="authentication" pageNav={pageNav}> - <Page.Contents isLoading={isLoading}> - <> - {ldapError && ldapError.title && ( - <Alert title={ldapError.title} severity={AppNotificationSeverity.Error}> - {ldapError.body} - </Alert> - )} - - <LdapConnectionStatus ldapConnectionInfo={ldapConnectionInfo} /> - - {featureEnabled('ldapsync') && ldapSyncInfo && <LdapSyncInfo ldapSyncInfo={ldapSyncInfo} />} - - {canReadLDAPUser && ( - <> - <h3>Test user mapping</h3> - <Form onSubmit={(data: FormModel) => this.search(data.username)}> - {({ register }) => ( - <HorizontalGroup> - <Field label="Username"> - <Input - {...register('username', { required: true })} - id="username" - type="text" - defaultValue={queryParams.username} - /> - </Field> + const canReadLDAPUser = contextSrv.hasPermission(AccessControlAction.LDAPUsersRead); + return ( + <Page navId="authentication" pageNav={pageNav}> + <Page.Contents isLoading={isLoading}> + <Stack direction="column" gap={4}> + {ldapError && ldapError.title && ( + <Alert title={ldapError.title} severity={AppNotificationSeverity.Error}> + {ldapError.body} + </Alert> + )} + + <LdapConnectionStatus ldapConnectionInfo={ldapConnectionInfo} /> + + {featureEnabled('ldapsync') && ldapSyncInfo && <LdapSyncInfo ldapSyncInfo={ldapSyncInfo} />} + + {canReadLDAPUser && ( + <section> + <h3>Test user mapping</h3> + <form onSubmit={handleSubmit(search)}> + <Field label="Username"> + <Input + {...register('username', { required: true })} + width={34} + id="username" + type="text" + defaultValue={queryParams.username} + addonAfter={ <Button variant="primary" type="submit"> Run </Button> - </HorizontalGroup> - )} - </Form> - {userError && userError.title && ( - <Alert - title={userError.title} - severity={AppNotificationSeverity.Error} - onRemove={this.onClearUserError} - > - {userError.body} - </Alert> - )} - {ldapUser && <LdapUserInfo ldapUser={ldapUser} showAttributeMapping={true} />} - </> - )} - </> - </Page.Contents> - </Page> - ); - } -} + } + /> + </Field> + </form> + {userError && userError.title && ( + <Alert title={userError.title} severity={AppNotificationSeverity.Error} onRemove={onClearUserError}> + {userError.body} + </Alert> + )} + {ldapUser && <LdapUserInfo ldapUser={ldapUser} />} + </section> + )} + </Stack> + </Page.Contents> + </Page> + ); +}; const mapStateToProps = (state: StoreState) => ({ ldapConnectionInfo: state.ldap.connectionInfo, diff --git a/public/app/features/admin/ldap/LdapSyncInfo.tsx b/public/app/features/admin/ldap/LdapSyncInfo.tsx index aabcd79358b3a..6dc3dbcf56e7c 100644 --- a/public/app/features/admin/ldap/LdapSyncInfo.tsx +++ b/public/app/features/admin/ldap/LdapSyncInfo.tsx @@ -1,63 +1,38 @@ -import React, { PureComponent } from 'react'; +import React from 'react'; import { dateTimeFormat } from '@grafana/data'; -import { Button, Spinner } from '@grafana/ui'; +import { InteractiveTable, Text } from '@grafana/ui'; import { SyncInfo } from 'app/types'; interface Props { ldapSyncInfo: SyncInfo; } -interface State { - isSyncing: boolean; -} - const format = 'dddd YYYY-MM-DD HH:mm zz'; -export class LdapSyncInfo extends PureComponent<Props, State> { - state = { - isSyncing: false, - }; - - handleSyncClick = () => { - this.setState({ isSyncing: !this.state.isSyncing }); - }; - - render() { - const { ldapSyncInfo } = this.props; - const { isSyncing } = this.state; - const nextSyncTime = dateTimeFormat(ldapSyncInfo.nextSync, { format }); - - return ( - <> - <h3 className="page-heading"> - LDAP Synchronisation - <Button className="pull-right" onClick={this.handleSyncClick} hidden> - <span className="btn-title">Bulk-sync now</span> - {isSyncing && <Spinner inline={true} />} - </Button> - </h3> - <div className="gf-form-group"> - <div className="gf-form"> - <table className="filter-table form-inline"> - <tbody> - <tr> - <td>Active synchronisation</td> - <td colSpan={2}>{ldapSyncInfo.enabled ? 'Enabled' : 'Disabled'}</td> - </tr> - <tr> - <td>Scheduled</td> - <td>{ldapSyncInfo.schedule}</td> - </tr> - <tr> - <td>Next scheduled synchronisation</td> - <td>{nextSyncTime}</td> - </tr> - </tbody> - </table> - </div> - </div> - </> - ); - } -} +export const LdapSyncInfo = ({ ldapSyncInfo }: Props) => { + const nextSyncTime = dateTimeFormat(ldapSyncInfo.nextSync, { format }); + + const columns = [{ id: 'syncAttribute' }, { id: 'syncValue' }]; + const data = [ + { + syncAttribute: 'Active synchronization', + syncValue: ldapSyncInfo.enabled ? 'Enabled' : 'Disabled', + }, + { + syncAttribute: 'Scheduled', + syncValue: ldapSyncInfo.schedule, + }, + { + syncAttribute: 'Next synchronization', + syncValue: nextSyncTime, + }, + ]; + + return ( + <section> + <Text element="h3">LDAP Synchronization</Text> + <InteractiveTable data={data} columns={columns} getRowId={(sync) => sync.syncAttribute} /> + </section> + ); +}; diff --git a/public/app/features/admin/ldap/LdapUserGroups.tsx b/public/app/features/admin/ldap/LdapUserGroups.tsx index 0c8c1a4f83e58..d8a628acf95fe 100644 --- a/public/app/features/admin/ldap/LdapUserGroups.tsx +++ b/public/app/features/admin/ldap/LdapUserGroups.tsx @@ -1,54 +1,52 @@ -import React from 'react'; +import React, { useMemo } from 'react'; -import { Tooltip, Icon } from '@grafana/ui'; +import { Tooltip, Icon, InteractiveTable, type CellProps, Column } from '@grafana/ui'; import { LdapRole } from 'app/types'; interface Props { groups: LdapRole[]; - showAttributeMapping?: boolean; } -export const LdapUserGroups = ({ groups, showAttributeMapping }: Props) => { - const items = showAttributeMapping ? groups : groups.filter((item) => item.orgRole); +export const LdapUserGroups = ({ groups }: Props) => { + const items = useMemo(() => groups, [groups]); + + const columns = useMemo<Array<Column<LdapRole>>>( + () => [ + { + id: 'groupDN', + header: 'LDAP Group', + }, + { + id: 'orgName', + header: 'Organization', + cell: (props: CellProps<LdapRole, string | undefined>) => + props.value && props.row.original.orgRole ? props.value : '', + }, + { + id: 'orgRole', + header: 'Role', + cell: (props: CellProps<LdapRole, string | undefined>) => + props.value || ( + <> + No match{' '} + <Tooltip content="No matching organizations found"> + <Icon name="info-circle" /> + </Tooltip> + </> + ), + }, + ], + [] + ); return ( - <div className="gf-form-group"> - <div className="gf-form"> - <table className="filter-table form-inline"> - <thead> - <tr> - {showAttributeMapping && <th>LDAP Group</th>} - <th> - Organization - <Tooltip placement="top" content="Only the first match for an Organization will be used" theme={'info'}> - <Icon name="info-circle" /> - </Tooltip> - </th> - <th>Role</th> - </tr> - </thead> - <tbody> - {items.map((group, index) => { - return ( - <tr key={`${group.orgId}-${index}`}> - {showAttributeMapping && <td>{group.groupDN}</td>} - {group.orgName && group.orgRole ? <td>{group.orgName}</td> : <td />} - {group.orgRole ? ( - <td>{group.orgRole}</td> - ) : ( - <td> - <span className="text-warning">No match</span> - <Tooltip placement="top" content="No matching groups found" theme={'info'}> - <Icon name="info-circle" /> - </Tooltip> - </td> - )} - </tr> - ); - })} - </tbody> - </table> - </div> - </div> + <InteractiveTable + headerTooltips={{ + orgName: { content: 'Only the first match for an Organization will be used', iconName: 'info-circle' }, + }} + columns={columns} + data={items} + getRowId={(row) => row.orgId + row.orgRole} + /> ); }; diff --git a/public/app/features/admin/ldap/LdapUserInfo.tsx b/public/app/features/admin/ldap/LdapUserInfo.tsx index 441df16acdbd8..9f14a99f02f15 100644 --- a/public/app/features/admin/ldap/LdapUserInfo.tsx +++ b/public/app/features/admin/ldap/LdapUserInfo.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { Box, Stack, Text } from '@grafana/ui'; import { LdapUser } from 'app/types'; import { LdapUserGroups } from './LdapUserGroups'; @@ -9,33 +10,22 @@ import { LdapUserTeams } from './LdapUserTeams'; interface Props { ldapUser: LdapUser; - showAttributeMapping?: boolean; } -export const LdapUserInfo = ({ ldapUser, showAttributeMapping }: Props) => { +export const LdapUserInfo = ({ ldapUser }: Props) => { return ( - <> - <LdapUserMappingInfo info={ldapUser.info} showAttributeMapping={showAttributeMapping} /> + <Stack direction="column" gap={4}> + <LdapUserMappingInfo info={ldapUser.info} /> <LdapUserPermissions permissions={ldapUser.permissions} /> - {ldapUser.roles && ldapUser.roles.length > 0 && ( - <LdapUserGroups groups={ldapUser.roles} showAttributeMapping={showAttributeMapping} /> - )} + {ldapUser.roles && ldapUser.roles.length > 0 && <LdapUserGroups groups={ldapUser.roles} />} {ldapUser.teams && ldapUser.teams.length > 0 ? ( - <LdapUserTeams teams={ldapUser.teams} showAttributeMapping={showAttributeMapping} /> + <LdapUserTeams teams={ldapUser.teams} /> ) : ( - <div className="gf-form-group"> - <div className="gf-form"> - <table className="filter-table form-inline"> - <tbody> - <tr> - <td>No teams found via LDAP</td> - </tr> - </tbody> - </table> - </div> - </div> + <Box> + <Text>No teams found via LDAP</Text> + </Box> )} - </> + </Stack> ); }; diff --git a/public/app/features/admin/ldap/LdapUserMappingInfo.tsx b/public/app/features/admin/ldap/LdapUserMappingInfo.tsx index f762aefd44459..c4989e4bf4dcf 100644 --- a/public/app/features/admin/ldap/LdapUserMappingInfo.tsx +++ b/public/app/features/admin/ldap/LdapUserMappingInfo.tsx @@ -1,47 +1,56 @@ -import React from 'react'; +import React, { useMemo } from 'react'; +import { InteractiveTable } from '@grafana/ui'; import { LdapUserInfo } from 'app/types'; interface Props { info: LdapUserInfo; - showAttributeMapping?: boolean; } -export const LdapUserMappingInfo = ({ info, showAttributeMapping }: Props) => { - return ( - <div className="gf-form-group"> - <div className="gf-form"> - <table className="filter-table form-inline"> - <thead> - <tr> - <th colSpan={2}>User information</th> - {showAttributeMapping && <th>LDAP attribute</th>} - </tr> - </thead> - <tbody> - <tr> - <td className="width-16">First name</td> - <td>{info.name.ldapValue}</td> - {showAttributeMapping && <td>{info.name.cfgAttrValue}</td>} - </tr> - <tr> - <td className="width-16">Surname</td> - <td>{info.surname.ldapValue}</td> - {showAttributeMapping && <td>{info.surname.cfgAttrValue}</td>} - </tr> - <tr> - <td className="width-16">Username</td> - <td>{info.login.ldapValue}</td> - {showAttributeMapping && <td>{info.login.cfgAttrValue}</td>} - </tr> - <tr> - <td className="width-16">Email</td> - <td>{info.email.ldapValue}</td> - {showAttributeMapping && <td>{info.email.cfgAttrValue}</td>} - </tr> - </tbody> - </table> - </div> - </div> +export const LdapUserMappingInfo = ({ info }: Props) => { + const columns = useMemo( + () => [ + { + id: 'userInfo', + header: 'User Information', + disableGrow: true, + }, + { + id: 'ldapValue', + }, + { + id: 'cfgAttrValue', + header: 'LDAP attribute', + }, + ], + [] ); + + const rows = useMemo( + () => [ + { + userInfo: 'First name', + ldapValue: info.name.ldapValue, + cfgAttrValue: info.name.cfgAttrValue, + }, + { + userInfo: 'Surname', + ldapValue: info.surname.ldapValue, + cfgAttrValue: info.surname.cfgAttrValue, + }, + { + userInfo: 'Username', + ldapValue: info.login.ldapValue, + cfgAttrValue: info.login.cfgAttrValue, + }, + { + userInfo: 'Email', + ldapValue: info.email.ldapValue, + cfgAttrValue: info.email.cfgAttrValue, + }, + ], + [info] + ); + + return <InteractiveTable columns={columns} data={rows} getRowId={(row) => row.userInfo} />; }; diff --git a/public/app/features/admin/ldap/LdapUserPermissions.tsx b/public/app/features/admin/ldap/LdapUserPermissions.tsx index f31d0410da8b4..d206ce2d03cf1 100644 --- a/public/app/features/admin/ldap/LdapUserPermissions.tsx +++ b/public/app/features/admin/ldap/LdapUserPermissions.tsx @@ -1,52 +1,59 @@ -import React from 'react'; +import React, { useMemo } from 'react'; -import { Icon } from '@grafana/ui'; +import { Column, Icon, InteractiveTable } from '@grafana/ui'; import { LdapPermissions } from 'app/types'; interface Props { permissions: LdapPermissions; } +interface TableRow { + permission: string; + value: React.ReactNode; +} + export const LdapUserPermissions = ({ permissions }: Props) => { - return ( - <div className="gf-form-group"> - <div className="gf-form"> - <table className="filter-table form-inline"> - <thead> - <tr> - <th colSpan={1}>Permissions</th> - </tr> - </thead> - <tbody> - <tr> - <td className="width-16"> Grafana admin</td> - <td> - {permissions.isGrafanaAdmin ? ( - <> - <Icon name="shield" /> Yes - </> - ) : ( - 'No' - )} - </td> - </tr> - <tr> - <td className="width-16">Status</td> - <td> - {permissions.isDisabled ? ( - <> - <Icon name="times" /> Inactive - </> - ) : ( - <> - <Icon name="check" /> Active - </> - )} - </td> - </tr> - </tbody> - </table> - </div> - </div> + const columns = useMemo<Array<Column<TableRow>>>( + () => [ + { + id: 'permission', + header: 'Permissions', + disableGrow: true, + }, + { + id: 'value', + }, + ], + [] ); + + const data = useMemo<TableRow[]>( + () => [ + { + permission: 'Grafana admin', + value: permissions.isGrafanaAdmin ? ( + <> + <Icon name="shield" /> Yes + </> + ) : ( + 'No' + ), + }, + { + permission: 'Status', + value: permissions.isDisabled ? ( + <> + <Icon name="times" /> Inactive + </> + ) : ( + <> + <Icon name="check" /> Active + </> + ), + }, + ], + [permissions] + ); + + return <InteractiveTable data={data} columns={columns} getRowId={(row) => row.permission} />; }; diff --git a/public/app/features/admin/ldap/LdapUserTeams.tsx b/public/app/features/admin/ldap/LdapUserTeams.tsx index b6edc7089dc6d..0df5926099cc7 100644 --- a/public/app/features/admin/ldap/LdapUserTeams.tsx +++ b/public/app/features/admin/ldap/LdapUserTeams.tsx @@ -1,59 +1,40 @@ -import React from 'react'; +import React, { useMemo } from 'react'; -import { Tooltip, Icon } from '@grafana/ui'; +import { Column, InteractiveTable, CellProps } from '@grafana/ui'; import { LdapTeam } from 'app/types'; interface Props { teams: LdapTeam[]; - showAttributeMapping?: boolean; } -export const LdapUserTeams = ({ teams, showAttributeMapping }: Props) => { - const items = showAttributeMapping ? teams : teams.filter((item) => item.teamName); - - return ( - <div className="gf-form-group"> - <div className="gf-form"> - <table className="filter-table form-inline"> - <thead> - <tr> - {showAttributeMapping && <th>LDAP Group</th>} - <th>Organisation</th> - <th>Team</th> - </tr> - </thead> - <tbody> - {items.map((team, index) => { - return ( - <tr key={`${team.teamName}-${index}`}> - {showAttributeMapping && ( - <> - <td>{team.groupDN}</td> - {!team.orgName && ( - <> - <td /> - <td> - <span className="text-warning">No match</span> - <Tooltip placement="top" content="No matching teams found" theme={'info'}> - <Icon name="info-circle" /> - </Tooltip> - </td> - </> - )} - </> - )} - {team.orgName && ( - <> - <td>{team.orgName}</td> - <td>{team.teamName}</td> - </> - )} - </tr> - ); - })} - </tbody> - </table> - </div> - </div> +export const LdapUserTeams = ({ teams }: Props) => { + const columns = useMemo<Array<Column<LdapTeam>>>( + () => [ + { + id: 'groupDN', + header: 'LDAP Group', + }, + { + id: 'orgName', + header: 'Organization', + cell: ({ + row: { + original: { orgName }, + }, + }: CellProps<LdapTeam, void>) => <>{orgName || 'No matching teams found'}</>, + }, + { + id: 'teamName', + header: 'Team', + cell: ({ + row: { + original: { teamName, orgName }, + }, + }: CellProps<LdapTeam, void>) => (teamName && orgName ? teamName : ''), + }, + ], + [] ); + + return <InteractiveTable data={teams} columns={columns} getRowId={(row) => row.teamName} />; }; diff --git a/public/app/features/admin/migrate-to-cloud/MigrateToCloud.tsx b/public/app/features/admin/migrate-to-cloud/MigrateToCloud.tsx new file mode 100644 index 0000000000000..1b0b16628c6c6 --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/MigrateToCloud.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import { config } from '@grafana/runtime'; +import { Page } from 'app/core/components/Page/Page'; + +import { Page as CloudPage } from './cloud/Page'; +import { Page as OnPremPage } from './onprem/Page'; + +export default function MigrateToCloud() { + return <Page navId="migrate-to-cloud">{config.cloudMigrationIsTarget ? <CloudPage /> : <OnPremPage />}</Page>; +} diff --git a/public/app/features/admin/migrate-to-cloud/api.ts b/public/app/features/admin/migrate-to-cloud/api.ts new file mode 100644 index 0000000000000..b4a065d978b43 --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/api.ts @@ -0,0 +1,207 @@ +import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react'; +import { lastValueFrom } from 'rxjs'; + +import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime'; + +interface RequestOptions extends BackendSrvRequest { + manageError?: (err: unknown) => { error: unknown }; + showErrorAlert?: boolean; +} + +function createBackendSrvBaseQuery({ baseURL }: { baseURL: string }): BaseQueryFn<RequestOptions> { + async function backendSrvBaseQuery(requestOptions: RequestOptions) { + try { + const { data: responseData, ...meta } = await lastValueFrom( + getBackendSrv().fetch({ + ...requestOptions, + url: baseURL + requestOptions.url, + showErrorAlert: requestOptions.showErrorAlert, + }) + ); + return { data: responseData, meta }; + } catch (error) { + return requestOptions.manageError ? requestOptions.manageError(error) : { error }; + } + } + + return backendSrvBaseQuery; +} + +interface MigrateToCloudStatusDTO { + enabled: boolean; + stackURL?: string; +} + +interface CreateMigrationTokenResponseDTO { + token: string; +} + +export interface ConnectStackDTO { + stackURL: string; + token: string; +} + +export interface MigrationResourceDTO { + uid: string; + status: 'not-migrated' | 'migrated' | 'migrating' | 'failed'; + statusMessage?: string; + type: 'datasource' | 'dashboard'; // TODO: in future this would be a discriminated union with the resource details + resource: { + uid: string; + name: string; + type: string; + icon?: string; + }; +} + +const mockApplications = ['auth-service', 'web server', 'backend']; +const mockEnvs = ['DEV', 'PROD']; +const mockRoles = ['db', 'load-balancer', 'server', 'logs']; +const mockDataSources = ['Prometheus', 'Loki', 'AWS Athena', 'AWS Cloudwatch', 'InfluxDB', 'Elasticsearch']; + +const mockDataSourceMetadata: Record<string, { image: string }> = { + Prometheus: { + image: 'https://grafana.com/api/plugins/prometheus/versions/5.0.0/logos/small', + }, + + Loki: { + image: 'https://grafana.com/api/plugins/loki/versions/5.0.0/logos/small', + }, + + 'AWS Athena': { + image: 'https://grafana.com/api/plugins/grafana-athena-datasource/versions/2.13.5/logos/small', + }, + + 'AWS Cloudwatch': { + image: 'https://grafana.com/api/plugins/computest-cloudwatchalarm-datasource/versions/2.0.0/logos/small', + }, + + InfluxDB: { + image: 'https://grafana.com/api/plugins/influxdb/versions/5.0.0/logos/small', + }, + + Elasticsearch: { + image: 'https://grafana.com/api/plugins/elasticsearch/versions/5.0.0/logos/small', + }, +}; + +const mockMigrationResources: MigrationResourceDTO[] = Array.from({ length: 500 }).map((_, index) => { + const dataSource = mockDataSources[index % mockDataSources.length]; + const environment = mockEnvs[index % mockEnvs.length]; + const application = mockApplications[index % mockApplications.length]; + const role = mockRoles[index % mockRoles.length]; + + return { + status: 'not-migrated', + type: 'datasource', + uid: index.toString(16), + resource: { + uid: `${application}-${environment}-${role}-${index}`, + name: `${application} ${environment} ${role}`, + icon: mockDataSourceMetadata[dataSource]?.image, + type: dataSource, + }, + }; +}); + +mockMigrationResources[0].status = 'migrated'; +mockMigrationResources[1].status = 'failed'; +mockMigrationResources[1].statusMessage = `Source map error: Error: request failed with status 404 +Resource URL: http://localhost:3000/public/build/app.f4d0c6a0daa6a5b14892.js +Source Map URL: app.f4d0c6a0daa6a5b14892.js.map`; +mockMigrationResources[2].status = 'migrated'; +mockMigrationResources[3].status = 'migrated'; +mockMigrationResources[4].status = 'migrating'; +mockMigrationResources[5].status = 'migrating'; + +// TODO remove these mock properties/functions +const queryParams = new URLSearchParams(window.location.search); +const MOCK_DELAY_MS = 1000; +const MOCK_TOKEN = 'TODO_thisWillBeABigLongToken'; +let HAS_MIGRATION_TOKEN = false; +let HAS_STACK_DETAILS = !!queryParams.get('mockStackURL'); +let STACK_URL: string | undefined = queryParams.get('mockStackURL') || undefined; + +function dataWithMockDelay<T>(data: T): Promise<{ data: T }> { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ data }); + }, MOCK_DELAY_MS); + }); +} + +export const migrateToCloudAPI = createApi({ + tagTypes: ['migrationToken', 'stackDetails', 'resource'], + reducerPath: 'migrateToCloudAPI', + baseQuery: createBackendSrvBaseQuery({ baseURL: '/api' }), + endpoints: (builder) => ({ + // TODO :) + getStatus: builder.query<MigrateToCloudStatusDTO, void>({ + providesTags: ['stackDetails'], + queryFn: () => { + const responseData: MigrateToCloudStatusDTO = { enabled: HAS_STACK_DETAILS }; + if (STACK_URL) { + responseData.stackURL = STACK_URL; + } + return dataWithMockDelay(responseData); + }, + }), + + connectStack: builder.mutation<void, ConnectStackDTO>({ + invalidatesTags: ['stackDetails'], + queryFn: async ({ stackURL }) => { + HAS_STACK_DETAILS = true; + STACK_URL = stackURL; + return dataWithMockDelay(undefined); + }, + }), + + disconnectStack: builder.mutation<void, void>({ + invalidatesTags: ['stackDetails'], + queryFn: async () => { + HAS_STACK_DETAILS = false; + return dataWithMockDelay(undefined); + }, + }), + + createMigrationToken: builder.mutation<CreateMigrationTokenResponseDTO, void>({ + invalidatesTags: ['migrationToken'], + queryFn: async () => { + HAS_MIGRATION_TOKEN = true; + return dataWithMockDelay({ token: MOCK_TOKEN }); + }, + }), + + deleteMigrationToken: builder.mutation<void, void>({ + invalidatesTags: ['migrationToken'], + queryFn: async () => { + HAS_MIGRATION_TOKEN = false; + return dataWithMockDelay(undefined); + }, + }), + + hasMigrationToken: builder.query<boolean, void>({ + providesTags: ['migrationToken'], + queryFn: async () => { + return dataWithMockDelay(HAS_MIGRATION_TOKEN); + }, + }), + + listResources: builder.query<MigrationResourceDTO[], void>({ + providesTags: ['resource'], + queryFn: async () => { + return dataWithMockDelay(mockMigrationResources); + }, + }), + }), +}); + +export const { + useGetStatusQuery, + useConnectStackMutation, + useDisconnectStackMutation, + useCreateMigrationTokenMutation, + useDeleteMigrationTokenMutation, + useHasMigrationTokenQuery, + useListResourcesQuery, +} = migrateToCloudAPI; diff --git a/public/app/features/admin/migrate-to-cloud/cloud/InfoPane.tsx b/public/app/features/admin/migrate-to-cloud/cloud/InfoPane.tsx new file mode 100644 index 0000000000000..a15b52128646b --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/cloud/InfoPane.tsx @@ -0,0 +1,75 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Box, Stack, TextLink, useStyles2 } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { InfoItem } from '../shared/InfoItem'; + +export const InfoPane = () => { + const styles = useStyles2(getStyles); + + return ( + <Box alignItems="flex-start" display="flex" padding={2} gap={2} direction="column" backgroundColor="secondary"> + <InfoItem + title={t('migrate-to-cloud.migrate-to-this-stack.title', 'Migrate configuration to this stack')} + linkTitle={t('migrate-to-cloud.migrate-to-this-stack.link-title', 'View the full migration guide')} + linkHref="https://grafana.com/docs/grafana-cloud/account-management/migration-guide" + > + <Trans i18nKey="migrate-to-cloud.migrate-to-this-stack.body"> + Some configuration from your self-managed Grafana instance can be automatically copied to this cloud stack. + </Trans> + </InfoItem> + <InfoItem + title={t('migrate-to-cloud.get-started.title', 'How to get started')} + linkTitle={t('migrate-to-cloud.get-started.link-title', 'Learn more about Private Data Source Connect')} + linkHref="https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect" + > + <Stack direction="column" gap={2}> + <Trans i18nKey="migrate-to-cloud.get-started.body"> + The migration process must be started from your self-managed Grafana instance. + </Trans> + <ol className={styles.list}> + <li> + <Trans i18nKey="migrate-to-cloud.get-started.step-1"> + Log in to your self-managed instance and navigate to Administration, General, Migrate to Grafana Cloud. + </Trans> + </li> + <li> + <Trans i18nKey="migrate-to-cloud.get-started.step-2"> + Select "Migrate this instance to Cloud". + </Trans> + </li> + <li> + <Trans i18nKey="migrate-to-cloud.get-started.step-3"> + You'll be prompted for a migration token. Generate one from this screen. + </Trans> + </li> + <li> + <Trans i18nKey="migrate-to-cloud.get-started.step-4"> + In your self-managed instance, select "Upload everything" to upload data sources and + dashboards to this cloud stack. + </Trans> + </li> + <li> + <Trans i18nKey="migrate-to-cloud.get-started.step-5"> + If some of your data sources will not work over the public internet, you’ll need to install Private Data + Source Connect in your self-managed environment. + </Trans> + </li> + </ol> + </Stack> + </InfoItem> + <TextLink href="/connections/private-data-source-connections"> + {t('migrate-to-cloud.get-started.configure-pdc-link', 'Configure PDC for this stack')} + </TextLink> + </Box> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + list: css({ + padding: 'revert', + }), +}); diff --git a/public/app/features/admin/migrate-to-cloud/cloud/MigrationTokenPane/DeleteMigrationTokenModal.tsx b/public/app/features/admin/migrate-to-cloud/cloud/MigrationTokenPane/DeleteMigrationTokenModal.tsx new file mode 100644 index 0000000000000..3d98e6374efd1 --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/cloud/MigrationTokenPane/DeleteMigrationTokenModal.tsx @@ -0,0 +1,45 @@ +import React, { useState } from 'react'; + +import { Modal, Button, Text } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; + +interface Props { + hideModal: () => void; + onConfirm: () => Promise<{ data: void } | { error: unknown }>; +} + +export const DeleteMigrationTokenModal = ({ hideModal, onConfirm }: Props) => { + const [isDeleting, setIsDeleting] = useState(false); + + const onConfirmDelete = async () => { + setIsDeleting(true); + await onConfirm(); + setIsDeleting(false); + hideModal(); + }; + + return ( + <Modal + isOpen + title={t('migrate-to-cloud.migration-token.delete-modal-title', 'Delete migration token')} + onDismiss={hideModal} + > + <Text color="secondary"> + <Trans i18nKey="migrate-to-cloud.migration-token.delete-modal-body"> + If you've already used this token with a self-managed installation, that installation will no longer be + able to upload content. + </Trans> + </Text> + <Modal.ButtonRow> + <Button variant="secondary" onClick={hideModal}> + <Trans i18nKey="migrate-to-cloud.migration-token.delete-modal-cancel">Cancel</Trans> + </Button> + <Button disabled={isDeleting} variant="destructive" onClick={onConfirmDelete}> + {isDeleting + ? t('migrate-to-cloud.migration-token.delete-modal-deleting', 'Deleting...') + : t('migrate-to-cloud.migration-token.delete-modal-confirm', 'Delete')} + </Button> + </Modal.ButtonRow> + </Modal> + ); +}; diff --git a/public/app/features/admin/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenModal.tsx b/public/app/features/admin/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenModal.tsx new file mode 100644 index 0000000000000..70a61593e01f4 --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenModal.tsx @@ -0,0 +1,45 @@ +import React, { useId } from 'react'; + +import { Modal, Button, Input, Stack, ClipboardButton, Field } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; + +interface Props { + hideModal: () => void; + migrationToken: string; +} + +export const MigrationTokenModal = ({ hideModal, migrationToken }: Props) => { + const inputId = useId(); + + return ( + <Modal + isOpen + title={t('migrate-to-cloud.migration-token.modal-title', 'Migration token created')} + onDismiss={hideModal} + > + <Field + description={t( + 'migrate-to-cloud.migration-token.modal-field-description', + 'Copy the token now as you will not be able to see it again. Losing a token requires creating a new one.' + )} + htmlFor={inputId} + label={t('migrate-to-cloud.migration-token.modal-field-label', 'Token')} + > + <Stack> + <Input id={inputId} value={migrationToken} readOnly /> + <ClipboardButton icon="clipboard-alt" getText={() => migrationToken}> + <Trans i18nKey="migrate-to-cloud.migration-token.modal-copy-button">Copy to clipboard</Trans> + </ClipboardButton> + </Stack> + </Field> + <Modal.ButtonRow> + <Button variant="secondary" onClick={hideModal}> + <Trans i18nKey="migrate-to-cloud.migration-token.modal-close">Close</Trans> + </Button> + <ClipboardButton variant="primary" getText={() => migrationToken} onClipboardCopy={hideModal}> + <Trans i18nKey="migrate-to-cloud.migration-token.modal-copy-and-close">Copy to clipboard and close</Trans> + </ClipboardButton> + </Modal.ButtonRow> + </Modal> + ); +}; diff --git a/public/app/features/admin/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenPane.tsx b/public/app/features/admin/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenPane.tsx new file mode 100644 index 0000000000000..884eaf46bc9d2 --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenPane.tsx @@ -0,0 +1,72 @@ +import React from 'react'; + +import { Box, Button, ModalsController, Text } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { useCreateMigrationTokenMutation, useDeleteMigrationTokenMutation, useHasMigrationTokenQuery } from '../../api'; +import { InfoItem } from '../../shared/InfoItem'; + +import { DeleteMigrationTokenModal } from './DeleteMigrationTokenModal'; +import { MigrationTokenModal } from './MigrationTokenModal'; +import { TokenStatus } from './TokenStatus'; + +export const MigrationTokenPane = () => { + const { data: hasToken, isFetching } = useHasMigrationTokenQuery(); + const [createToken, createTokenResponse] = useCreateMigrationTokenMutation(); + const [deleteToken, deleteTokenResponse] = useDeleteMigrationTokenMutation(); + + return ( + <ModalsController> + {({ showModal, hideModal }) => ( + <Box display="flex" alignItems="flex-start" padding={2} gap={2} direction="column" backgroundColor="secondary"> + <InfoItem title={t('migrate-to-cloud.migration-token.title', 'Migration token')}> + <Trans i18nKey="migrate-to-cloud.migration-token.body"> + Your self-managed Grafana instance will require a special authentication token to securely connect to this + cloud stack. + </Trans> + </InfoItem> + <Text color="secondary"> + <Trans i18nKey="migrate-to-cloud.migration-token.status"> + Current status:{' '} + <TokenStatus + hasToken={Boolean(hasToken)} + isFetching={isFetching || createTokenResponse.isLoading || deleteTokenResponse.isLoading} + /> + </Trans> + </Text> + {hasToken ? ( + <Button + variant="destructive" + onClick={() => + showModal(DeleteMigrationTokenModal, { + hideModal, + onConfirm: deleteToken, + }) + } + disabled={isFetching || deleteTokenResponse.isLoading} + > + <Trans i18nKey="migrate-to-cloud.migration-token.delete-button">Delete this migration token</Trans> + </Button> + ) : ( + <Button + disabled={createTokenResponse.isLoading || isFetching} + onClick={async () => { + const response = await createToken(); + if ('data' in response) { + showModal(MigrationTokenModal, { + hideModal, + migrationToken: response.data.token, + }); + } + }} + > + {createTokenResponse.isLoading + ? t('migrate-to-cloud.migration-token.generate-button-loading', 'Generating a migration token...') + : t('migrate-to-cloud.migration-token.generate-button', 'Generate a migration token')} + </Button> + )} + </Box> + )} + </ModalsController> + ); +}; diff --git a/public/app/features/admin/migrate-to-cloud/cloud/MigrationTokenPane/TokenStatus.tsx b/public/app/features/admin/migrate-to-cloud/cloud/MigrationTokenPane/TokenStatus.tsx new file mode 100644 index 0000000000000..a907d06dfe290 --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/cloud/MigrationTokenPane/TokenStatus.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import Skeleton from 'react-loading-skeleton'; + +import { Text } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; + +interface Props { + hasToken: boolean; + isFetching: boolean; +} + +export const TokenStatus = ({ hasToken, isFetching }: Props) => { + if (isFetching) { + return <Skeleton width={100} />; + } + + return hasToken ? ( + <Text color="success"> + <Trans i18nKey="migrate-to-cloud.token-status.active">Token created and active</Trans> + </Text> + ) : ( + <Trans i18nKey="migrate-to-cloud.token-status.no-active">No active token</Trans> + ); +}; diff --git a/public/app/features/admin/migrate-to-cloud/cloud/Page.tsx b/public/app/features/admin/migrate-to-cloud/cloud/Page.tsx new file mode 100644 index 0000000000000..4ae3676ae67e2 --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/cloud/Page.tsx @@ -0,0 +1,34 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Grid, useStyles2 } from '@grafana/ui'; + +import { InfoPane } from './InfoPane'; +import { MigrationTokenPane } from './MigrationTokenPane/MigrationTokenPane'; + +export const Page = () => { + const styles = useStyles2(getStyles); + + return ( + <div className={styles.container}> + <Grid + alignItems="flex-start" + gap={1} + columns={{ + xs: 1, + lg: 2, + }} + > + <InfoPane /> + <MigrationTokenPane /> + </Grid> + </div> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + maxWidth: theme.breakpoints.values.xl, + }), +}); diff --git a/public/app/features/admin/migrate-to-cloud/fixtures/mswAPI.ts b/public/app/features/admin/migrate-to-cloud/fixtures/mswAPI.ts new file mode 100644 index 0000000000000..d8b9748e52557 --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/fixtures/mswAPI.ts @@ -0,0 +1,15 @@ +import { HttpResponse, http } from 'msw'; +import { SetupServer, setupServer } from 'msw/node'; + +export function registerAPIHandlers(): SetupServer { + const server = setupServer( + // TODO + http.get('/api/cloudmigration/status', () => { + return HttpResponse.json({ + enabled: false, + }); + }) + ); + + return server; +} diff --git a/public/app/features/admin/migrate-to-cloud/onprem/DisconnectModal.tsx b/public/app/features/admin/migrate-to-cloud/onprem/DisconnectModal.tsx new file mode 100644 index 0000000000000..30cd42b507f0d --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/onprem/DisconnectModal.tsx @@ -0,0 +1,56 @@ +import React, { useCallback } from 'react'; + +import { Alert, ConfirmModal, Stack } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; + +import { useDisconnectStackMutation } from '../api'; + +interface Props { + isOpen: boolean; + onDismiss: () => void; +} + +export const DisconnectModal = ({ isOpen, onDismiss }: Props) => { + const [disconnectStack, { isLoading, isError }] = useDisconnectStackMutation(); + + const handleConfirm = useCallback(async () => { + const resp = await disconnectStack(); + if (!('error' in resp)) { + onDismiss(); + } + }, [disconnectStack, onDismiss]); + + const confirmBody = ( + <Stack direction="column"> + {isError && ( + <Alert + severity="error" + title={t('migrate-to-cloud.disconnect-modal.error', 'There was an error disconnecting')} + /> + )} + <div> + <Trans i18nKey="migrate-to-cloud.disconnect-modal.body"> + This will remove the migration token from this installation. If you wish to upload more resources in the + future, you will need to enter a new migration token. + </Trans> + </div> + </Stack> + ); + + return ( + <ConfirmModal + isOpen={isOpen} + title={t('migrate-to-cloud.disconnect-modal.title', 'Disconnect from cloud stack')} + body={<></>} // body is mandatory prop, but i don't wanna + description={confirmBody} + confirmText={ + isLoading + ? t('migrate-to-cloud.disconnect-modal.disconnecting', 'Disconnecting...') + : t('migrate-to-cloud.disconnect-modal.disconnect', 'Disconnect') + } + dismissText={t('migrate-to-cloud.disconnect-modal.cancel', 'Cancel')} + onConfirm={handleConfirm} + onDismiss={onDismiss} + /> + ); +}; diff --git a/public/app/features/admin/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx b/public/app/features/admin/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx new file mode 100644 index 0000000000000..0d3e7e55a270a --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +import { Box, Button, ModalsController, Text } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; + +import { useConnectStackMutation, useGetStatusQuery } from '../../../api'; + +import { ConnectModal } from './ConnectModal'; + +export const CallToAction = () => { + const [connectStack, connectResponse] = useConnectStackMutation(); + const { isFetching } = useGetStatusQuery(); + + return ( + <ModalsController> + {({ showModal, hideModal }) => ( + <Box display="flex" padding={5} gap={2} direction="column" alignItems="center" backgroundColor="secondary"> + <Text variant="h3" textAlignment="center"> + <Trans i18nKey="migrate-to-cloud.cta.header">Let us manage your Grafana stack</Trans> + </Text> + <Button + disabled={isFetching || connectResponse.isLoading} + onClick={() => + showModal(ConnectModal, { + hideModal, + onConfirm: connectStack, + }) + } + > + <Trans i18nKey="migrate-to-cloud.cta.button">Migrate this instance to Cloud</Trans> + </Button> + </Box> + )} + </ModalsController> + ); +}; diff --git a/public/app/features/admin/migrate-to-cloud/onprem/EmptyState/CallToAction/ConnectModal.tsx b/public/app/features/admin/migrate-to-cloud/onprem/EmptyState/CallToAction/ConnectModal.tsx new file mode 100644 index 0000000000000..24276f8cc7d45 --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/onprem/EmptyState/CallToAction/ConnectModal.tsx @@ -0,0 +1,128 @@ +import { css } from '@emotion/css'; +import React, { useId, useState } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Modal, Button, Stack, TextLink, Field, Input, Text, useStyles2 } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; + +import { ConnectStackDTO } from '../../../api'; + +interface Props { + hideModal: () => void; + onConfirm: (connectStackData: ConnectStackDTO) => Promise<{ data: void } | { error: unknown }>; +} + +export const ConnectModal = ({ hideModal, onConfirm }: Props) => { + const [isConnecting, setIsConnecting] = useState(false); + const cloudStackId = useId(); + const tokenId = useId(); + const styles = useStyles2(getStyles); + + const { + handleSubmit, + register, + formState: { errors }, + watch, + } = useForm<ConnectStackDTO>({ + defaultValues: { + stackURL: '', + token: '', + }, + }); + + const stackURL = watch('stackURL'); + const token = watch('token'); + + const onConfirmConnect: SubmitHandler<ConnectStackDTO> = async (formData) => { + setIsConnecting(true); + await onConfirm(formData); + setIsConnecting(false); + hideModal(); + }; + + return ( + <Modal isOpen title={t('migrate-to-cloud.connect-modal.title', 'Connect to a cloud stack')} onDismiss={hideModal}> + <form onSubmit={handleSubmit(onConfirmConnect)}> + <Text color="secondary"> + <Stack direction="column" gap={2} alignItems="flex-start"> + <Trans i18nKey="migrate-to-cloud.connect-modal.body-get-started"> + To get started, you'll need a Grafana.com account. + </Trans> + <TextLink href="https://grafana.com/auth/sign-up/create-user?pg=prod-cloud" external> + {t('migrate-to-cloud.connect-modal.body-sign-up', 'Sign up for a Grafana.com account')} + </TextLink> + <Trans i18nKey="migrate-to-cloud.connect-modal.body-cloud-stack"> + You'll also need a cloud stack. If you just signed up, we'll automatically create your first + stack. If you have an account, you'll need to select or create a stack. + </Trans> + <TextLink href="https://grafana.com/auth/sign-in/" external> + {t('migrate-to-cloud.connect-modal.body-view-stacks', 'View my cloud stacks')} + </TextLink> + <Trans i18nKey="migrate-to-cloud.connect-modal.body-paste-stack"> + Once you've decided on a stack, paste the URL below. + </Trans> + <Field + className={styles.field} + invalid={!!errors.stackURL} + error={errors.stackURL?.message} + label={t('migrate-to-cloud.connect-modal.body-url-field', 'Cloud stack URL')} + required + > + <Input + {...register('stackURL', { + required: t('migrate-to-cloud.connect-modal.stack-required-error', 'Stack URL is required'), + })} + id={cloudStackId} + placeholder="https://example.grafana.net/" + /> + </Field> + <span> + <Trans i18nKey="migrate-to-cloud.connect-modal.body-token"> + Your self-managed Grafana installation needs special access to securely migrate content. You'll + need to create a migration token on your chosen cloud stack. + </Trans> + </span> + <span> + <Trans i18nKey="migrate-to-cloud.connect-modal.body-token-instructions"> + Log into your cloud stack and navigate to Administration, General, Migrate to Grafana Cloud. Create a + migration token on that screen and paste the token here. + </Trans> + </span> + <Field + className={styles.field} + invalid={!!errors.token} + error={errors.token?.message} + label={t('migrate-to-cloud.connect-modal.body-token-field', 'Migration token')} + required + > + <Input + {...register('token', { + required: t('migrate-to-cloud.connect-modal.token-required-error', 'Migration token is required'), + })} + id={tokenId} + placeholder={t('migrate-to-cloud.connect-modal.body-token-field-placeholder', 'Paste token here')} + /> + </Field> + </Stack> + </Text> + <Modal.ButtonRow> + <Button variant="secondary" onClick={hideModal}> + <Trans i18nKey="migrate-to-cloud.connect-modal.cancel">Cancel</Trans> + </Button> + <Button type="submit" disabled={isConnecting || !(stackURL && token)}> + {isConnecting + ? t('migrate-to-cloud.connect-modal.connecting', 'Connecting to this stack...') + : t('migrate-to-cloud.connect-modal.connect', 'Connect to this stack')} + </Button> + </Modal.ButtonRow> + </form> + </Modal> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + field: css({ + alignSelf: 'stretch', + }), +}); diff --git a/public/app/features/admin/migrate-to-cloud/onprem/EmptyState/EmptyState.tsx b/public/app/features/admin/migrate-to-cloud/onprem/EmptyState/EmptyState.tsx new file mode 100644 index 0000000000000..30d714ffd5f15 --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/onprem/EmptyState/EmptyState.tsx @@ -0,0 +1,38 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Grid, Stack, useStyles2 } from '@grafana/ui'; + +import { CallToAction } from './CallToAction/CallToAction'; +import { InfoPaneLeft } from './InfoPaneLeft'; +import { InfoPaneRight } from './InfoPaneRight'; + +export const EmptyState = () => { + const styles = useStyles2(getStyles); + + return ( + <div className={styles.container}> + <Stack direction="column"> + <CallToAction /> + <Grid + alignItems="flex-start" + gap={1} + columns={{ + xs: 1, + lg: 2, + }} + > + <InfoPaneLeft /> + <InfoPaneRight /> + </Grid> + </Stack> + </div> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + maxWidth: theme.breakpoints.values.xl, + }), +}); diff --git a/public/app/features/admin/migrate-to-cloud/onprem/EmptyState/InfoPaneLeft.tsx b/public/app/features/admin/migrate-to-cloud/onprem/EmptyState/InfoPaneLeft.tsx new file mode 100644 index 0000000000000..eb2873f1e3d01 --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/onprem/EmptyState/InfoPaneLeft.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { Box } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { InfoItem } from '../../shared/InfoItem'; + +export const InfoPaneLeft = () => { + return ( + <Box alignItems="flex-start" display="flex" padding={2} gap={2} direction="column" backgroundColor="secondary"> + <InfoItem + title={t('migrate-to-cloud.what-is-cloud.title', 'What is Grafana Cloud?')} + linkTitle={t('migrate-to-cloud.what-is-cloud.link-title', 'Learn about cloud features')} + linkHref="https://grafana.com/products/cloud" + > + <Trans i18nKey="migrate-to-cloud.what-is-cloud.body"> + Grafana cloud is a fully managed cloud-hosted observability platform ideal for cloud native environments. + It's everything you love about Grafana without the overhead of maintaining, upgrading, and supporting an + installation. + </Trans> + </InfoItem> + <InfoItem + title={t('migrate-to-cloud.why-host.title', 'Why host with Grafana?')} + linkTitle={t('migrate-to-cloud.why-host.link-title', 'More questions? Talk to an expert')} + linkHref="https://grafana.com/contact" + > + <Trans i18nKey="migrate-to-cloud.why-host.body"> + In addition to the convenience of managed hosting, Grafana Cloud includes many cloud-exclusive features like + SLOs, incident management, machine learning, and powerful observability integrations. + </Trans> + </InfoItem> + <InfoItem + title={t('migrate-to-cloud.is-it-secure.title', 'Is it secure?')} + linkTitle={t('migrate-to-cloud.is-it-secure.link-title', 'Grafana Labs Trust Center')} + linkHref="https://trust.grafana.com" + > + <Trans i18nKey="migrate-to-cloud.is-it-secure.body"> + Grafana Labs is committed to maintaining the highest standards of data privacy and security. By implementing + industry-standard security technologies and procedures, we help protect our customers' data from + unauthorized access, use, or disclosure. + </Trans> + </InfoItem> + </Box> + ); +}; diff --git a/public/app/features/admin/migrate-to-cloud/onprem/EmptyState/InfoPaneRight.tsx b/public/app/features/admin/migrate-to-cloud/onprem/EmptyState/InfoPaneRight.tsx new file mode 100644 index 0000000000000..051640b60c70a --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/onprem/EmptyState/InfoPaneRight.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { Box } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { InfoItem } from '../../shared/InfoItem'; + +export const InfoPaneRight = () => { + return ( + <Box alignItems="flex-start" display="flex" direction="column" gap={2} padding={2} backgroundColor="secondary"> + <InfoItem + title={t('migrate-to-cloud.pdc.title', 'Not all my data sources are on the public internet')} + linkTitle={t('migrate-to-cloud.pdc.link-title', 'Learn about PDC')} + linkHref="https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect" + > + <Trans i18nKey="migrate-to-cloud.pdc.body"> + Exposing your data sources to the internet can raise security concerns. Private data source connect (PDC) + allows Grafana Cloud to access your existing data sources over a secure network tunnel. + </Trans> + </InfoItem> + <InfoItem + title={t('migrate-to-cloud.pricing.title', 'How much does it cost?')} + linkTitle={t('migrate-to-cloud.pricing.link-title', 'Grafana Cloud pricing')} + linkHref="https://grafana.com/pricing" + > + <Trans i18nKey="migrate-to-cloud.pricing.body"> + Grafana Cloud has a generous free plan and a 14 day unlimited usage trial. After your trial expires, + you'll be billed based on usage over the free plan limits. + </Trans> + </InfoItem> + <InfoItem + title={t('migrate-to-cloud.can-i-move.title', 'Can I move this installation to Grafana Cloud?')} + linkTitle={t('migrate-to-cloud.can-i-move.link-title', 'Learn about migrating other settings')} + linkHref="https://grafana.com/docs/grafana-cloud/account-management/migration-guide" + > + <Trans i18nKey="migrate-to-cloud.can-i-move.body"> + Once you connect this installation to a cloud stack, you'll be able to upload data sources and + dashboards. + </Trans> + </InfoItem> + </Box> + ); +}; diff --git a/public/app/features/admin/migrate-to-cloud/onprem/Page.tsx b/public/app/features/admin/migrate-to-cloud/onprem/Page.tsx new file mode 100644 index 0000000000000..4fc0614bed167 --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/onprem/Page.tsx @@ -0,0 +1,37 @@ +import { skipToken } from '@reduxjs/toolkit/query/react'; +import React, { useState } from 'react'; + +import { Button, Stack, Text } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; + +import { useGetStatusQuery, useListResourcesQuery } from '../api'; + +import { DisconnectModal } from './DisconnectModal'; +import { EmptyState } from './EmptyState/EmptyState'; +import { ResourcesTable } from './ResourcesTable'; + +export const Page = () => { + const { data: status, isFetching } = useGetStatusQuery(); + const { data: resources } = useListResourcesQuery(status?.enabled ? undefined : skipToken); + const [isDisconnecting, setIsDisconnecting] = useState(false); + + if (!status?.enabled) { + return <EmptyState />; + } + + return ( + <> + <Stack direction="column" alignItems="flex-start"> + {status.stackURL && <Text variant="h4">{status.stackURL}</Text>} + + <Button disabled={isFetching || isDisconnecting} variant="secondary" onClick={() => setIsDisconnecting(true)}> + <Trans i18nKey="migrate-to-cloud.resources.disconnect">Disconnect</Trans> + </Button> + + {resources && <ResourcesTable resources={resources} />} + </Stack> + + <DisconnectModal isOpen={isDisconnecting} onDismiss={() => setIsDisconnecting(false)} /> + </> + ); +}; diff --git a/public/app/features/admin/migrate-to-cloud/onprem/ResourcesTable.tsx b/public/app/features/admin/migrate-to-cloud/onprem/ResourcesTable.tsx new file mode 100644 index 0000000000000..8806dce5ef58f --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/onprem/ResourcesTable.tsx @@ -0,0 +1,103 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { InteractiveTable, CellProps, Stack, Text, Icon, useStyles2, Button } from '@grafana/ui'; +import { getSvgSize } from '@grafana/ui/src/components/Icon/utils'; +import { t } from 'app/core/internationalization'; + +import { MigrationResourceDTO } from '../api'; + +interface ResourcesTableProps { + resources: MigrationResourceDTO[]; +} + +const columns = [ + { id: 'name', header: 'Name', cell: NameCell }, + { id: 'type', header: 'Type', cell: TypeCell }, + { id: 'status', header: 'Status', cell: StatusCell }, +]; + +export function ResourcesTable({ resources }: ResourcesTableProps) { + return <InteractiveTable columns={columns} data={resources} getRowId={(r) => r.uid} pageSize={15} />; +} + +function NameCell(props: CellProps<MigrationResourceDTO>) { + const data = props.row.original; + return ( + <Stack direction="row" gap={2} alignItems="center"> + <ResourceIcon resource={data} /> + + <Stack direction="column" gap={0}> + <span>{data.resource.name}</span> + <Text color="secondary">{data.resource.type}</Text> + </Stack> + </Stack> + ); +} + +function TypeCell(props: CellProps<MigrationResourceDTO>) { + const { type } = props.row.original; + + if (type === 'datasource') { + return t('migrate-to-cloud.resource-type.datasource', 'Data source'); + } + + if (type === 'dashboard') { + return t('migrate-to-cloud.resource-type.dashboard', 'Dashboard'); + } + + return t('migrate-to-cloud.resource-type.unknown', 'Unknown'); +} + +function StatusCell(props: CellProps<MigrationResourceDTO>) { + const { status, statusMessage } = props.row.original; + + if (status === 'not-migrated') { + return <Text color="secondary">{t('migrate-to-cloud.resource-status.not-migrated', 'Not yet uploaded')}</Text>; + } else if (status === 'migrating') { + return <Text color="info">{t('migrate-to-cloud.resource-status.migrating', 'Uploading...')}</Text>; + } else if (status === 'migrated') { + return <Text color="success">{t('migrate-to-cloud.resource-status.migrated', 'Uploaded to cloud')}</Text>; + } else if (status === 'failed') { + return ( + <Stack alignItems="center"> + <Text color="error">{t('migrate-to-cloud.resource-status.failed', 'Error')}</Text> + + {statusMessage && ( + // TODO: trigger a proper modal, probably from the parent, on click + <Button size="sm" variant="secondary" onClick={() => window.alert(statusMessage)}> + {t('migrate-to-cloud.resource-status.error-details-button', 'Details')} + </Button> + )} + </Stack> + ); + } + + return <Text color="secondary">{t('migrate-to-cloud.resource-status.unknown', 'Unknown')}</Text>; +} + +function ResourceIcon({ resource }: { resource: MigrationResourceDTO }) { + const styles = useStyles2(getIconStyles); + + if (resource.type === 'dashboard') { + return <Icon size="xl" name="dashboard" />; + } + + if (resource.type === 'datasource' && resource.resource.icon) { + return <img className={styles.icon} src={resource.resource.icon} alt="" />; + } else if (resource.type === 'datasource') { + return <Icon size="xl" name="database" />; + } + + return undefined; +} + +function getIconStyles() { + return { + icon: css({ + display: 'block', + width: getSvgSize('xl'), + height: getSvgSize('xl'), + }), + }; +} diff --git a/public/app/features/admin/migrate-to-cloud/shared/InfoItem.tsx b/public/app/features/admin/migrate-to-cloud/shared/InfoItem.tsx new file mode 100644 index 0000000000000..3d66495452908 --- /dev/null +++ b/public/app/features/admin/migrate-to-cloud/shared/InfoItem.tsx @@ -0,0 +1,24 @@ +import React, { ReactNode } from 'react'; + +import { Stack, Text, TextLink } from '@grafana/ui'; + +interface Props { + children: NonNullable<ReactNode>; + title: string; + linkTitle?: string; + linkHref?: string; +} + +export const InfoItem = ({ children, title, linkHref, linkTitle }: Props) => { + return ( + <Stack gap={2} direction="column"> + <Text element="h4">{title}</Text> + <Text color="secondary">{children}</Text> + {linkHref && ( + <TextLink href={linkHref} external> + {linkTitle ?? linkHref} + </TextLink> + )} + </Stack> + ); +}; diff --git a/public/app/features/admin/state/actions.ts b/public/app/features/admin/state/actions.ts index eaca44d51cda9..e83e9f188fe56 100644 --- a/public/app/features/admin/state/actions.ts +++ b/public/app/features/admin/state/actions.ts @@ -6,7 +6,15 @@ import { FetchDataArgs } from '@grafana/ui'; import config from 'app/core/config'; import { contextSrv } from 'app/core/core'; import { accessControlQueryParam } from 'app/core/utils/accessControl'; -import { ThunkResult, LdapUser, UserSession, UserDTO, AccessControlAction, UserFilter } from 'app/types'; +import { + ThunkResult, + LdapUser, + UserSession, + UserDTO, + AccessControlAction, + UserFilter, + AnonUserFilter, +} from 'app/types'; import { userAdminPageLoadedAction, @@ -29,6 +37,9 @@ import { usersFetchEnd, sortChanged, usersAnonymousDevicesFetched, + anonUserSortChanged, + anonPageChanged, + anonQueryChanged, } from './reducers'; // UserAdminPage @@ -337,16 +348,72 @@ export function changeSort({ sortBy }: FetchDataArgs<UserDTO>): ThunkResult<void } // UserListAnonymousPage +const getAnonFilters = (filters: AnonUserFilter[]) => { + return filters + .map((filter) => { + if (Array.isArray(filter.value)) { + return filter.value.map((v) => `${filter.name}=${v.value}`).join('&'); + } + return `${filter.name}=${filter.value}`; + }) + .join('&'); +}; export function fetchUsersAnonymousDevices(): ThunkResult<void> { return async (dispatch, getState) => { try { - let url = `/api/anonymous/devices`; + const { perPage, page, query, filters, sort } = getState().userListAnonymousDevices; + let url = `/api/anonymous/search?perpage=${perPage}&page=${page}&query=${query}&${getAnonFilters(filters)}`; + if (sort) { + url += `&sort=${sort}`; + } const result = await getBackendSrv().get(url); - dispatch(usersAnonymousDevicesFetched({ devices: result })); + dispatch(usersAnonymousDevicesFetched(result)); } catch (error) { - usersFetchEnd(); console.error(error); } }; } + +const fetchAnonUsersWithDebounce = debounce((dispatch) => dispatch(fetchUsersAnonymousDevices()), 500); + +export function changeAnonUserSort({ sortBy }: FetchDataArgs<UserDTO>): ThunkResult<void> { + const sort = sortBy.length ? `${sortBy[0].id}-${sortBy[0].desc ? 'desc' : 'asc'}` : undefined; + return async (dispatch, getState) => { + const currentSort = getState().userListAnonymousDevices.sort; + if (currentSort !== sort) { + // dispatch(usersFetchBegin()); + dispatch(anonUserSortChanged(sort)); + dispatch(fetchUsersAnonymousDevices()); + } + }; +} + +export function changeAnonQuery(query: string): ThunkResult<void> { + return async (dispatch) => { + // dispatch(usersFetchBegin()); + dispatch(anonQueryChanged(query)); + fetchAnonUsersWithDebounce(dispatch); + }; +} + +export function changeAnonPage(page: number): ThunkResult<void> { + return async (dispatch) => { + // dispatch(usersFetchBegin()); + dispatch(anonPageChanged(page)); + dispatch(fetchUsersAnonymousDevices()); + }; +} + +// export function fetchUsersAnonymousDevices(): ThunkResult<void> { +// return async (dispatch, getState) => { +// try { +// let url = `/api/anonymous/devices`; +// const result = await getBackendSrv().get(url); +// dispatch(usersAnonymousDevicesFetched({ devices: result })); +// } catch (error) { +// usersFetchEnd(); +// console.error(error); +// } +// }; +// } diff --git a/public/app/features/admin/state/reducers.ts b/public/app/features/admin/state/reducers.ts index 68af1117cbfe3..550e3de57b64c 100644 --- a/public/app/features/admin/state/reducers.ts +++ b/public/app/features/admin/state/reducers.ts @@ -15,6 +15,7 @@ import { UserFilter, UserListAnonymousDevicesState, UserAnonymousDeviceDTO, + AnonUserFilter, } from 'app/types'; const initialLdapState: LdapState = { @@ -207,10 +208,19 @@ export const userListAdminReducer = userListAdminSlice.reducer; const initialUserListAnonymousDevicesState: UserListAnonymousDevicesState = { devices: [], + query: '', + page: 0, + perPage: 50, + totalPages: 1, + showPaging: false, + filters: [{ name: 'activeLast30Days', value: true }], }; interface UsersAnonymousDevicesFetched { devices: UserAnonymousDeviceDTO[]; + perPage: number; + page: number; + totalCount: number; } export const userListAnonymousDevicesSlice = createSlice({ @@ -218,17 +228,52 @@ export const userListAnonymousDevicesSlice = createSlice({ initialState: initialUserListAnonymousDevicesState, reducers: { usersAnonymousDevicesFetched: (state, action: PayloadAction<UsersAnonymousDevicesFetched>) => { - const { devices } = action.payload; + const { totalCount, perPage, ...rest } = action.payload; + const totalPages = Math.ceil(totalCount / perPage); + return { ...state, - devices, - isLoading: false, + ...rest, + totalPages, + perPage, + showPaging: totalPages > 1, + }; + }, + anonQueryChanged: (state, action: PayloadAction<string>) => ({ + ...state, + query: action.payload, + page: 0, + }), + anonPageChanged: (state, action: PayloadAction<number>) => ({ + ...state, + page: action.payload, + }), + anonUserSortChanged: (state, action: PayloadAction<UserListAnonymousDevicesState['sort']>) => ({ + ...state, + page: 0, + sort: action.payload, + }), + filterChanged: (state, action: PayloadAction<AnonUserFilter>) => { + const { name, value } = action.payload; + + if (state.filters.some((filter) => filter.name === name)) { + return { + ...state, + page: 0, + filters: state.filters.map((filter) => (filter.name === name ? { ...filter, value } : filter)), + }; + } + return { + ...state, + page: 0, + filters: [...state.filters, action.payload], }; }, }, }); -export const { usersAnonymousDevicesFetched } = userListAnonymousDevicesSlice.actions; +export const { usersAnonymousDevicesFetched, anonUserSortChanged, anonPageChanged, anonQueryChanged } = + userListAnonymousDevicesSlice.actions; export const userListAnonymousDevicesReducer = userListAnonymousDevicesSlice.reducer; export default { diff --git a/public/app/features/alerting/AlertHowToModal.tsx b/public/app/features/alerting/AlertHowToModal.tsx deleted file mode 100644 index 9de9bcf7eda2c..0000000000000 --- a/public/app/features/alerting/AlertHowToModal.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; - -import { Modal, VerticalGroup } from '@grafana/ui'; - -export interface AlertHowToModalProps { - onDismiss: () => void; -} - -export function AlertHowToModal({ onDismiss }: AlertHowToModalProps): JSX.Element { - return ( - <Modal title="Adding an Alert" isOpen onDismiss={onDismiss} onClickBackdrop={onDismiss}> - <VerticalGroup spacing="sm"> - <img src="public/img/alert_howto_new.png" alt="" /> - <p> - Alerts are added and configured in the Alert tab of any dashboard graph panel, letting you build and visualize - an alert using existing queries. - </p> - <p>Remember to save the dashboard to persist your alert rule changes.</p> - </VerticalGroup> - </Modal> - ); -} diff --git a/public/app/features/alerting/AlertRuleItem.test.tsx b/public/app/features/alerting/AlertRuleItem.test.tsx deleted file mode 100644 index 9236a78442736..0000000000000 --- a/public/app/features/alerting/AlertRuleItem.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import React from 'react'; - -import AlertRuleItem, { Props } from './AlertRuleItem'; - -const setup = (propOverrides?: object) => { - const props: Props = { - rule: { - id: 1, - dashboardId: 1, - panelId: 1, - name: 'Some rule', - state: 'Open', - stateText: 'state text', - stateIcon: 'anchor', - stateClass: 'state class', - stateAge: 'age', - url: 'https://something.something.darkside', - }, - search: '', - onTogglePause: jest.fn(), - }; - - Object.assign(props, propOverrides); - - return render(<AlertRuleItem {...props} />); -}; - -describe('AlertRuleItem', () => { - it('should render component', () => { - const mockToggle = jest.fn(); - setup({ onTogglePause: mockToggle }); - - expect(screen.getByText('Some rule')).toBeInTheDocument(); - expect(screen.getByText('state text')).toBeInTheDocument(); - expect(screen.getByText('Pause')).toBeInTheDocument(); - expect(screen.getByText('Edit alert')).toBeInTheDocument(); - - fireEvent.click(screen.getByText('Pause')); - expect(mockToggle).toHaveBeenCalled(); - }); -}); diff --git a/public/app/features/alerting/AlertRuleItem.tsx b/public/app/features/alerting/AlertRuleItem.tsx deleted file mode 100644 index 16949dcb034ca..0000000000000 --- a/public/app/features/alerting/AlertRuleItem.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React, { useCallback } from 'react'; -import Highlighter from 'react-highlight-words'; - -import { Icon, Button, LinkButton, Card } from '@grafana/ui'; - -import { AlertRule } from '../../types'; - -export interface Props { - rule: AlertRule; - search: string; - onTogglePause: () => void; -} - -const AlertRuleItem = ({ rule, search, onTogglePause }: Props) => { - const ruleUrl = `${rule.url}?editPanel=${rule.panelId}&tab=alert`; - const renderText = useCallback( - (text: string) => ( - <Highlighter - key={text} - highlightClassName="highlight-search-match" - textToHighlight={text} - searchWords={[search]} - /> - ), - [search] - ); - - return ( - <Card> - <Card.Heading>{renderText(rule.name)}</Card.Heading> - <Card.Figure> - <Icon size="xl" name={rule.stateIcon} className={`alert-rule-item__icon ${rule.stateClass}`} /> - </Card.Figure> - <Card.Meta> - <span key="state"> - <span key="text" className={`${rule.stateClass}`}> - {renderText(rule.stateText)}{' '} - </span> - for {rule.stateAge} - </span> - {rule.info ? renderText(rule.info) : null} - </Card.Meta> - <Card.Actions> - <Button - key="play" - variant="secondary" - icon={rule.state === 'paused' ? 'play' : 'pause'} - onClick={onTogglePause} - > - {rule.state === 'paused' ? 'Resume' : 'Pause'} - </Button> - <LinkButton key="edit" variant="secondary" href={ruleUrl} icon="cog"> - Edit alert - </LinkButton> - </Card.Actions> - </Card> - ); -}; - -export default AlertRuleItem; diff --git a/public/app/features/alerting/AlertRuleList.test.tsx b/public/app/features/alerting/AlertRuleList.test.tsx deleted file mode 100644 index df8f7c795de70..0000000000000 --- a/public/app/features/alerting/AlertRuleList.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; -import { openMenu } from 'react-select-event'; -import { mockToolkitActionCreator } from 'test/core/redux/mocks'; -import { TestProvider } from 'test/helpers/TestProvider'; - -import { locationService } from '@grafana/runtime'; -import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; - -import appEvents from '../../core/app_events'; -import { ShowModalReactEvent } from '../../types/events'; - -import { AlertHowToModal } from './AlertHowToModal'; -import { AlertRuleListUnconnected, Props } from './AlertRuleList'; -import { setSearchQuery } from './state/reducers'; - -jest.mock('../../core/app_events', () => ({ - publish: jest.fn(), -})); - -const defaultProps: Props = { - ...getRouteComponentProps({}), - search: '', - isLoading: false, - alertRules: [], - getAlertRulesAsync: jest.fn().mockResolvedValue([]), - setSearchQuery: mockToolkitActionCreator(setSearchQuery), - togglePauseAlertRule: jest.fn(), -}; - -const setup = (propOverrides?: object) => { - const props: Props = { - ...defaultProps, - ...propOverrides, - }; - - const { rerender } = render( - <TestProvider> - <AlertRuleListUnconnected {...props} /> - </TestProvider> - ); - - return { - rerender: (element: JSX.Element) => rerender(<TestProvider>{element}</TestProvider>), - }; -}; - -afterEach(() => { - jest.clearAllMocks(); -}); - -describe('AlertRuleList', () => { - it('should call fetchrules when mounting', () => { - jest.spyOn(AlertRuleListUnconnected.prototype, 'fetchRules'); - - expect(AlertRuleListUnconnected.prototype.fetchRules).not.toHaveBeenCalled(); - setup(); - expect(AlertRuleListUnconnected.prototype.fetchRules).toHaveBeenCalled(); - }); - - it('should call fetchrules when props change', () => { - const fetchRulesSpy = jest.spyOn(AlertRuleListUnconnected.prototype, 'fetchRules'); - expect(AlertRuleListUnconnected.prototype.fetchRules).not.toHaveBeenCalled(); - const { rerender } = setup(); - expect(AlertRuleListUnconnected.prototype.fetchRules).toHaveBeenCalled(); - - fetchRulesSpy.mockReset(); - rerender(<AlertRuleListUnconnected {...defaultProps} queryParams={{ state: 'ok' }} />); - expect(AlertRuleListUnconnected.prototype.fetchRules).toHaveBeenCalled(); - }); - - describe('Get state filter', () => { - it('should be all if prop is not set', () => { - setup(); - expect(screen.getByText('All')).toBeInTheDocument(); - }); - - it('should return state filter if set', () => { - setup({ - queryParams: { state: 'not_ok' }, - }); - expect(screen.getByText('Not OK')).toBeInTheDocument(); - }); - }); - - describe('State filter changed', () => { - it('should update location', async () => { - setup(); - const stateFilterSelect = screen.getByLabelText('States'); - openMenu(stateFilterSelect); - await userEvent.click(screen.getByText('Not OK')); - expect(locationService.getSearchObject().state).toBe('not_ok'); - }); - }); - - describe('Open how to', () => { - it('should emit show-modal event', async () => { - setup(); - - await userEvent.click(screen.getByRole('button', { name: 'How to add an alert' })); - expect(appEvents.publish).toHaveBeenCalledWith(new ShowModalReactEvent({ component: AlertHowToModal })); - }); - }); - - describe('Search query change', () => { - it('should set search query', async () => { - setup(); - - await userEvent.click(screen.getByPlaceholderText('Search alerts')); - await userEvent.paste('dashboard'); - expect(defaultProps.setSearchQuery).toHaveBeenCalledWith('dashboard'); - }); - }); -}); diff --git a/public/app/features/alerting/AlertRuleList.tsx b/public/app/features/alerting/AlertRuleList.tsx deleted file mode 100644 index 55dc35d0ca21a..0000000000000 --- a/public/app/features/alerting/AlertRuleList.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import React, { PureComponent } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; - -import { SelectableValue } from '@grafana/data'; -import { config, locationService } from '@grafana/runtime'; -import { Button, FilterInput, LinkButton, Select, VerticalGroup } from '@grafana/ui'; -import appEvents from 'app/core/app_events'; -import { Page } from 'app/core/components/Page/Page'; -import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { AlertRule, StoreState } from 'app/types'; - -import { ShowModalReactEvent } from '../../types/events'; - -import { AlertHowToModal } from './AlertHowToModal'; -import AlertRuleItem from './AlertRuleItem'; -import { DeprecationNotice } from './components/DeprecationNotice'; -import { getAlertRulesAsync, togglePauseAlertRule } from './state/actions'; -import { setSearchQuery } from './state/reducers'; -import { getAlertRuleItems, getSearchQuery } from './state/selectors'; - -function mapStateToProps(state: StoreState) { - return { - alertRules: getAlertRuleItems(state), - search: getSearchQuery(state.alertRules), - isLoading: state.alertRules.isLoading, - }; -} - -const mapDispatchToProps = { - getAlertRulesAsync, - setSearchQuery, - togglePauseAlertRule, -}; - -const connector = connect(mapStateToProps, mapDispatchToProps); - -interface OwnProps extends GrafanaRouteComponentProps<{}, { state: string }> {} - -export type Props = OwnProps & ConnectedProps<typeof connector>; - -export class AlertRuleListUnconnected extends PureComponent<Props> { - stateFilters = [ - { label: 'All', value: 'all' }, - { label: 'OK', value: 'ok' }, - { label: 'Not OK', value: 'not_ok' }, - { label: 'Alerting', value: 'alerting' }, - { label: 'No data', value: 'no_data' }, - { label: 'Paused', value: 'paused' }, - { label: 'Pending', value: 'pending' }, - ]; - - componentDidMount() { - this.fetchRules(); - } - - componentDidUpdate(prevProps: Props) { - if (prevProps.queryParams.state !== this.props.queryParams.state) { - this.fetchRules(); - } - } - - async fetchRules() { - await this.props.getAlertRulesAsync({ state: this.getStateFilter() }); - } - - getStateFilter(): string { - return this.props.queryParams.state ?? 'all'; - } - - onStateFilterChanged = (option: SelectableValue) => { - locationService.partial({ state: option.value }); - }; - - onOpenHowTo = () => { - appEvents.publish(new ShowModalReactEvent({ component: AlertHowToModal })); - }; - - onSearchQueryChange = (value: string) => { - this.props.setSearchQuery(value); - }; - - onTogglePause = (rule: AlertRule) => { - this.props.togglePauseAlertRule(rule.id, { paused: rule.state !== 'paused' }); - }; - - alertStateFilterOption = ({ text, value }: { text: string; value: string }) => { - return ( - <option key={value} value={value}> - {text} - </option> - ); - }; - - render() { - const { alertRules, search, isLoading } = this.props; - - return ( - <Page navId="alert-list"> - <Page.Contents isLoading={isLoading}> - <div className="page-action-bar"> - <div className="gf-form gf-form--grow"> - <FilterInput placeholder="Search alerts" value={search} onChange={this.onSearchQueryChange} /> - </div> - <div className="gf-form"> - <label className="gf-form-label" htmlFor="alert-state-filter"> - States - </label> - - <div className="width-13"> - <Select - inputId={'alert-state-filter'} - options={this.stateFilters} - onChange={this.onStateFilterChanged} - value={this.getStateFilter()} - /> - </div> - </div> - <div className="page-action-bar__spacer" /> - {config.unifiedAlertingEnabled && ( - <LinkButton variant="primary" href="alerting/ng/new"> - Add NG Alert - </LinkButton> - )} - <Button variant="secondary" onClick={this.onOpenHowTo}> - How to add an alert - </Button> - </div> - <DeprecationNotice /> - <VerticalGroup spacing="none"> - {alertRules.map((rule) => { - return ( - <AlertRuleItem - rule={rule} - key={rule.id} - search={search} - onTogglePause={() => this.onTogglePause(rule)} - /> - ); - })} - </VerticalGroup> - </Page.Contents> - </Page> - ); - } -} - -export default connector(AlertRuleListUnconnected); diff --git a/public/app/features/alerting/AlertTab.tsx b/public/app/features/alerting/AlertTab.tsx deleted file mode 100644 index 207ec7c6be062..0000000000000 --- a/public/app/features/alerting/AlertTab.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import React, { PureComponent } from 'react'; -import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux'; - -import { EventBusSrv } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { AngularComponent, config, getAngularLoader, getDataSourceSrv } from '@grafana/runtime'; -import { Alert, Button, ConfirmModal, Container, CustomScrollbar, HorizontalGroup, Modal } from '@grafana/ui'; -import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import { getPanelStateForModel } from 'app/features/panel/state/selectors'; -import { AppNotificationSeverity, StoreState } from 'app/types'; - -import { AlertState } from '../../plugins/datasource/alertmanager/types'; -import { PanelNotSupported } from '../dashboard/components/PanelEditor/PanelNotSupported'; -import { DashboardModel } from '../dashboard/state/DashboardModel'; -import { PanelModel } from '../dashboard/state/PanelModel'; - -import StateHistory from './StateHistory'; -import { TestRuleResult } from './TestRuleResult'; -import { getAlertingValidationMessage } from './getAlertingValidationMessage'; - -interface AngularPanelController { - _enableAlert: () => void; - alertState: AlertState | null; - render: () => void; - refresh: () => void; -} - -interface OwnProps { - dashboard: DashboardModel; - panel: PanelModel; -} - -interface ConnectedProps { - angularPanelComponent?: AngularComponent | null; -} - -interface DispatchProps {} - -export type Props = OwnProps & ConnectedProps & DispatchProps; - -interface State { - validationMessage: string; - showStateHistory: boolean; - showDeleteConfirmation: boolean; - showTestRule: boolean; -} - -class UnConnectedAlertTab extends PureComponent<Props, State> { - element?: HTMLDivElement | null; - component?: AngularComponent; - panelCtrl?: AngularPanelController; - - state: State = { - validationMessage: '', - showStateHistory: false, - showDeleteConfirmation: false, - showTestRule: false, - }; - - async componentDidMount() { - if (config.angularSupportEnabled) { - await import(/* webpackChunkName: "AlertTabCtrl" */ 'app/features/alerting/AlertTabCtrl'); - this.loadAlertTab(); - } else { - // TODO probably need to migrate AlertTab to react - alert('Angular support disabled, legacy alerting cannot function without angular support'); - } - } - - onAngularPanelUpdated = () => { - this.forceUpdate(); - }; - - componentDidUpdate(prevProps: Props) { - this.loadAlertTab(); - } - - componentWillUnmount() { - if (this.component) { - this.component.destroy(); - } - } - - async loadAlertTab() { - const { panel, angularPanelComponent } = this.props; - - if (!this.element || this.component) { - return; - } - - if (angularPanelComponent) { - const scope = angularPanelComponent.getScope(); - - // When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet - if (!scope.$$childHead) { - setTimeout(() => { - this.forceUpdate(); - }); - return; - } - - this.panelCtrl = scope.$$childHead.ctrl; - } else { - this.panelCtrl = this.getReactAlertPanelCtrl(); - } - - const loader = getAngularLoader(); - const template = '<alert-tab />'; - const scopeProps = { ctrl: this.panelCtrl }; - - this.component = loader.load(this.element, scopeProps, template); - - const validationMessage = await getAlertingValidationMessage( - panel.transformations, - panel.targets, - getDataSourceSrv(), - panel.datasource - ); - - if (validationMessage) { - this.setState({ validationMessage }); - } - } - - getReactAlertPanelCtrl() { - return { - panel: this.props.panel, - events: new EventBusSrv(), - render: () => { - this.props.panel.render(); - }, - } as any; - } - - onAddAlert = () => { - this.panelCtrl?._enableAlert(); - this.component?.digest(); - this.forceUpdate(); - }; - - onToggleModal = (prop: keyof Omit<State, 'validationMessage'>) => { - const value = this.state[prop]; - this.setState({ ...this.state, [prop]: !value }); - }; - - renderTestRule = () => { - if (!this.state.showTestRule) { - return null; - } - - const { panel, dashboard } = this.props; - const onDismiss = () => this.onToggleModal('showTestRule'); - - return ( - <Modal isOpen={true} icon="bug" title="Testing rule" onDismiss={onDismiss} onClickBackdrop={onDismiss}> - <TestRuleResult panel={panel} dashboard={dashboard} /> - </Modal> - ); - }; - - renderDeleteConfirmation = () => { - if (!this.state.showDeleteConfirmation) { - return null; - } - - const { panel } = this.props; - const onDismiss = () => this.onToggleModal('showDeleteConfirmation'); - - return ( - <ConfirmModal - isOpen={true} - icon="trash-alt" - title="Delete" - body={ - <div> - Are you sure you want to delete this alert rule? - <br /> - <small>You need to save dashboard for the delete to take effect.</small> - </div> - } - confirmText="Delete alert" - onDismiss={onDismiss} - onConfirm={() => { - delete panel.alert; - panel.thresholds = []; - if (this.panelCtrl) { - this.panelCtrl.alertState = null; - this.panelCtrl.render(); - } - this.component?.digest(); - onDismiss(); - }} - /> - ); - }; - - renderStateHistory = () => { - if (!this.state.showStateHistory) { - return null; - } - - const { panel, dashboard } = this.props; - const onDismiss = () => this.onToggleModal('showStateHistory'); - - return ( - <Modal isOpen={true} icon="history" title="State history" onDismiss={onDismiss} onClickBackdrop={onDismiss}> - <StateHistory dashboard={dashboard} panelId={panel.id} onRefresh={() => this.panelCtrl?.refresh()} /> - </Modal> - ); - }; - - render() { - const { alert, transformations } = this.props.panel; - const { validationMessage } = this.state; - const hasTransformations = transformations && transformations.length > 0; - - if (!alert && validationMessage) { - return <PanelNotSupported message={validationMessage} />; - } - - const model = { - title: 'Panel has no alert rule defined', - buttonIcon: 'bell' as const, - onClick: this.onAddAlert, - buttonTitle: 'Create Alert', - }; - - return ( - <> - <CustomScrollbar autoHeightMin="100%"> - <Container padding="md"> - <div data-testid={selectors.components.AlertTab.content}> - {alert && hasTransformations && ( - <Alert - severity={AppNotificationSeverity.Error} - title="Transformations are not supported in alert queries" - /> - )} - - <div ref={(element) => (this.element = element)} /> - {alert && ( - <HorizontalGroup> - <Button onClick={() => this.onToggleModal('showStateHistory')} variant="secondary"> - State history - </Button> - <Button onClick={() => this.onToggleModal('showTestRule')} variant="secondary"> - Test rule - </Button> - <Button onClick={() => this.onToggleModal('showDeleteConfirmation')} variant="destructive"> - Delete - </Button> - </HorizontalGroup> - )} - {!alert && !validationMessage && <EmptyListCTA {...model} />} - </div> - </Container> - </CustomScrollbar> - - {this.renderTestRule()} - {this.renderDeleteConfirmation()} - {this.renderStateHistory()} - </> - ); - } -} - -const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => { - return { - angularPanelComponent: getPanelStateForModel(state, props.panel)?.angularComponent, - }; -}; - -const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {}; - -export const AlertTab = connect(mapStateToProps, mapDispatchToProps)(UnConnectedAlertTab); diff --git a/public/app/features/alerting/AlertTabCtrl.test.ts b/public/app/features/alerting/AlertTabCtrl.test.ts deleted file mode 100644 index 7eb6012815fac..0000000000000 --- a/public/app/features/alerting/AlertTabCtrl.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { DashboardSrv } from '../dashboard/services/DashboardSrv'; -import { DatasourceSrv } from '../plugins/datasource_srv'; - -import { AlertTabCtrl } from './AlertTabCtrl'; - -interface Args { - notifications?: Array<{ uid?: string; id?: number; isDefault: boolean }>; -} - -function setupTestContext({ notifications = [] }: Args = {}) { - const panel = { - alert: { notifications }, - options: [], - title: 'Testing Alerts', - }; - const $scope = { - ctrl: { - panel, - render: jest.fn(), - }, - }; - const dashboardSrv = {} as DashboardSrv; - const uiSegmentSrv = {}; - const datasourceSrv = {} as DatasourceSrv; - - const controller = new AlertTabCtrl($scope, dashboardSrv, uiSegmentSrv, datasourceSrv); - controller.notifications = notifications; - controller.alertNotifications = []; - controller.initModel(); - - return { controller }; -} - -describe('AlertTabCtrl', () => { - describe('when removeNotification is called with an uid', () => { - it('then the correct notifier should be removed', () => { - const { controller } = setupTestContext({ - notifications: [ - { id: 1, uid: 'one', isDefault: true }, - { id: 2, uid: 'two', isDefault: false }, - ], - }); - - expect(controller.alert.notifications).toEqual([ - { id: 1, uid: 'one', isDefault: true, iconClass: 'bell' }, - { id: 2, uid: 'two', isDefault: false, iconClass: 'bell' }, - ]); - expect(controller.alertNotifications).toEqual([ - { id: 2, uid: 'two', isDefault: false, iconClass: 'bell' }, - { id: 1, uid: 'one', isDefault: true, iconClass: 'bell' }, - ]); - - controller.removeNotification({ uid: 'one' }); - - expect(controller.alert.notifications).toEqual([{ id: 2, uid: 'two', isDefault: false, iconClass: 'bell' }]); - expect(controller.alertNotifications).toEqual([{ id: 2, uid: 'two', isDefault: false, iconClass: 'bell' }]); - }); - }); - - describe('when removeNotification is called with an id', () => { - it('then the correct notifier should be removed', () => { - const { controller } = setupTestContext({ - notifications: [ - { id: 1, uid: 'one', isDefault: true }, - { id: 2, uid: 'two', isDefault: false }, - ], - }); - - expect(controller.alert.notifications).toEqual([ - { id: 1, uid: 'one', isDefault: true, iconClass: 'bell' }, - { id: 2, uid: 'two', isDefault: false, iconClass: 'bell' }, - ]); - expect(controller.alertNotifications).toEqual([ - { id: 2, uid: 'two', isDefault: false, iconClass: 'bell' }, - { id: 1, uid: 'one', isDefault: true, iconClass: 'bell' }, - ]); - - controller.removeNotification({ id: 2 }); - - expect(controller.alert.notifications).toEqual([{ id: 1, uid: 'one', isDefault: true, iconClass: 'bell' }]); - expect(controller.alertNotifications).toEqual([{ id: 1, uid: 'one', isDefault: true, iconClass: 'bell' }]); - }); - }); -}); diff --git a/public/app/features/alerting/AlertTabCtrl.ts b/public/app/features/alerting/AlertTabCtrl.ts deleted file mode 100644 index ad1b5a58bb09a..0000000000000 --- a/public/app/features/alerting/AlertTabCtrl.ts +++ /dev/null @@ -1,526 +0,0 @@ -import { find, map, reduce, remove } from 'lodash'; - -import { DataQuery, DataSourceApi, rangeUtil } from '@grafana/data'; -import { getBackendSrv } from '@grafana/runtime'; -import coreModule from 'app/angular/core_module'; -import { promiseToDigest } from 'app/angular/promiseToDigest'; -import appEvents from 'app/core/app_events'; -import config from 'app/core/config'; -import { QueryPart } from 'app/features/alerting/state/query_part'; -import { PanelModel } from 'app/features/dashboard/state'; -import { CoreEvents } from 'app/types'; - -import { ShowConfirmModalEvent } from '../../types/events'; -import { DashboardSrv } from '../dashboard/services/DashboardSrv'; -import { DatasourceSrv } from '../plugins/datasource_srv'; - -import { getDefaultCondition } from './getAlertingValidationMessage'; -import { ThresholdMapper } from './state/ThresholdMapper'; -import alertDef from './state/alertDef'; - -export class AlertTabCtrl { - panel: PanelModel; - panelCtrl: any; - subTabIndex: number; - conditionTypes: any; - alert: any; - conditionModels: any; - evalFunctions: any; - evalOperators: any; - noDataModes: any; - executionErrorModes: any; - addNotificationSegment: any; - notifications: any; - alertNotifications: any; - error?: string; - appSubUrl: string; - alertHistory: any; - newAlertRuleTag: any; - alertingMinIntervalSecs: number; - alertingMinInterval: string; - frequencyWarning: any; - - static $inject = ['$scope', 'dashboardSrv', 'uiSegmentSrv', 'datasourceSrv']; - - constructor( - private $scope: any, - private dashboardSrv: DashboardSrv, - private uiSegmentSrv: any, - private datasourceSrv: DatasourceSrv - ) { - this.panelCtrl = $scope.ctrl; - this.panel = this.panelCtrl.panel; - this.$scope.ctrl = this; - this.subTabIndex = 0; - this.evalFunctions = alertDef.evalFunctions; - this.evalOperators = alertDef.evalOperators; - this.conditionTypes = alertDef.conditionTypes; - this.noDataModes = alertDef.noDataModes; - this.executionErrorModes = alertDef.executionErrorModes; - this.appSubUrl = config.appSubUrl; - this.panelCtrl._enableAlert = this.enable; - this.alertingMinIntervalSecs = config.alertingMinInterval; - this.alertingMinInterval = rangeUtil.secondsToHms(config.alertingMinInterval); - } - - $onInit() { - this.addNotificationSegment = this.uiSegmentSrv.newPlusButton(); - - // subscribe to graph threshold handle changes - const thresholdChangedEventHandler = this.graphThresholdChanged.bind(this); - this.panelCtrl.events.on(CoreEvents.thresholdChanged, thresholdChangedEventHandler); - - // set panel alert edit mode - this.$scope.$on('$destroy', () => { - this.panelCtrl.events.off(CoreEvents.thresholdChanged, thresholdChangedEventHandler); - this.panelCtrl.editingThresholds = false; - this.panelCtrl.render(); - }); - - // build notification model - this.notifications = []; - this.alertNotifications = []; - this.alertHistory = []; - - return promiseToDigest(this.$scope)( - getBackendSrv() - .get('/api/alert-notifications/lookup') - .then((res: any) => { - this.notifications = res; - - this.initModel(); - this.validateModel(); - }) - ); - } - - getAlertHistory() { - promiseToDigest(this.$scope)( - getBackendSrv() - .get(`/api/annotations?dashboardId=${this.panelCtrl.dashboard.id}&panelId=${this.panel.id}&limit=50&type=alert`) - .then((res: any) => { - this.alertHistory = map(res, (ah) => { - ah.time = this.dashboardSrv.getCurrent()?.formatDate(ah.time, 'MMM D, YYYY HH:mm:ss'); - ah.stateModel = alertDef.getStateDisplayModel(ah.newState); - ah.info = alertDef.getAlertAnnotationInfo(ah); - return ah; - }); - }) - ); - } - - getNotificationIcon(type: string): string { - switch (type) { - case 'email': - return 'envelope'; - case 'slack': - return 'slack'; - case 'victorops': - return 'fa fa-pagelines'; - case 'webhook': - return 'cube'; - case 'pagerduty': - return 'fa fa-bullhorn'; - case 'opsgenie': - return 'bell'; - case 'hipchat': - return 'fa fa-mail-forward'; - case 'pushover': - return 'mobile-android'; - case 'kafka': - return 'arrow-random'; - case 'teams': - return 'fa fa-windows'; - } - return 'bell'; - } - - getNotifications() { - return Promise.resolve( - this.notifications.map((item: any) => { - return this.uiSegmentSrv.newSegment(item.name); - }) - ); - } - - notificationAdded() { - const model: any = find(this.notifications, { - name: this.addNotificationSegment.value, - }); - if (!model) { - return; - } - - this.alertNotifications.push({ - name: model.name, - iconClass: this.getNotificationIcon(model.type), - isDefault: false, - uid: model.uid, - }); - - // avoid duplicates using both id and uid to be backwards compatible. - if (!find(this.alert.notifications, (n) => n.id === model.id || n.uid === model.uid)) { - this.alert.notifications.push({ uid: model.uid }); - } - - // reset plus button - this.addNotificationSegment.value = this.uiSegmentSrv.newPlusButton().value; - this.addNotificationSegment.html = this.uiSegmentSrv.newPlusButton().html; - this.addNotificationSegment.fake = true; - } - - removeNotification(an: any) { - // remove notifiers referred to by id and uid to support notifiers added - // before and after we added support for uid - remove(this.alert.notifications, (n: any) => n.uid === an.uid || n.id === an.id); - remove(this.alertNotifications, (n: any) => n.uid === an.uid || n.id === an.id); - } - - addAlertRuleTag() { - if (this.newAlertRuleTag.name) { - this.alert.alertRuleTags[this.newAlertRuleTag.name] = this.newAlertRuleTag.value; - } - this.newAlertRuleTag.name = ''; - this.newAlertRuleTag.value = ''; - } - - removeAlertRuleTag(tagName: string) { - delete this.alert.alertRuleTags[tagName]; - } - - initModel() { - const alert = (this.alert = this.panel.alert); - if (!alert) { - return; - } - - this.checkFrequency(); - - alert.conditions = alert.conditions || []; - if (alert.conditions.length === 0) { - alert.conditions.push(getDefaultCondition()); - } - - alert.noDataState = alert.noDataState || config.alertingNoDataOrNullValues; - alert.executionErrorState = alert.executionErrorState || config.alertingErrorOrTimeout; - alert.frequency = alert.frequency || '1m'; - alert.handler = alert.handler || 1; - alert.notifications = alert.notifications || []; - alert.for = alert.for || '0m'; - alert.alertRuleTags = alert.alertRuleTags || {}; - - const defaultName = this.panel.title + ' alert'; - alert.name = alert.name || defaultName; - - this.conditionModels = reduce( - alert.conditions, - (memo, value) => { - memo.push(this.buildConditionModel(value)); - return memo; - }, - [] as string[] - ); - - ThresholdMapper.alertToGraphThresholds(this.panel); - - for (const addedNotification of alert.notifications) { - let identifier = addedNotification.uid; - // lookup notifier type by uid - let model: any = find(this.notifications, { uid: identifier }); - - // fallback using id if uid is missing - if (!model && addedNotification.id) { - identifier = addedNotification.id; - model = find(this.notifications, { id: identifier }); - } - - if (!model) { - appEvents.publish( - new ShowConfirmModalEvent({ - title: 'Notifier with invalid identifier is detected', - text: `Do you want to delete notifier with invalid identifier: ${identifier} from the dashboard JSON?`, - text2: 'After successful deletion, make sure to save the dashboard for storing the update JSON.', - icon: 'trash-alt', - confirmText: 'Delete', - yesText: 'Delete', - onConfirm: async () => { - this.removeNotification(addedNotification); - }, - }) - ); - } - - if (model && model.isDefault === false) { - model.iconClass = this.getNotificationIcon(model.type); - this.alertNotifications.push(model); - } - } - - for (const notification of this.notifications) { - if (notification.isDefault) { - notification.iconClass = this.getNotificationIcon(notification.type); - this.alertNotifications.push(notification); - } - } - - this.panelCtrl.editingThresholds = true; - this.panelCtrl.render(); - } - - checkFrequency() { - this.frequencyWarning = ''; - - if (!this.alert.frequency) { - return; - } - - if (!this.alert.frequency.match(/^\d+([dhms])$/)) { - this.frequencyWarning = - 'Invalid frequency, has to be numeric followed by one of the following units: "d, h, m, s"'; - return; - } - - try { - const frequencySecs = rangeUtil.intervalToSeconds(this.alert.frequency); - if (frequencySecs < this.alertingMinIntervalSecs) { - this.frequencyWarning = - 'A minimum evaluation interval of ' + - this.alertingMinInterval + - ' have been configured in Grafana and will be used for this alert rule. ' + - 'Please contact the administrator to configure a lower interval.'; - } - } catch (err) { - this.frequencyWarning = err; - } - } - - graphThresholdChanged(evt: any) { - for (const condition of this.alert.conditions) { - if (condition.type === 'query') { - condition.evaluator.params[evt.handleIndex] = evt.threshold.value; - this.evaluatorParamsChanged(); - break; - } - } - } - - validateModel() { - if (!this.alert) { - return; - } - - let firstTarget; - let foundTarget: DataQuery | null = null; - - const promises: Array<Promise<any>> = []; - for (const condition of this.alert.conditions) { - if (condition.type !== 'query') { - continue; - } - - for (const target of this.panel.targets) { - if (!firstTarget) { - firstTarget = target; - } - if (condition.query.params[0] === target.refId) { - foundTarget = target; - break; - } - } - - if (!foundTarget) { - if (firstTarget) { - condition.query.params[0] = firstTarget.refId; - foundTarget = firstTarget; - } else { - this.error = 'Could not find any metric queries'; - return; - } - } - - const datasourceName = foundTarget.datasource || this.panel.datasource; - promises.push( - this.datasourceSrv.get(datasourceName).then( - ((foundTarget) => (ds: DataSourceApi) => { - if (!ds.meta.alerting) { - return Promise.reject('The datasource does not support alerting queries'); - } else if (ds.targetContainsTemplate && ds.targetContainsTemplate(foundTarget)) { - return Promise.reject('Template variables are not supported in alert queries'); - } - return Promise.resolve(); - })(foundTarget) - ) - ); - } - Promise.all(promises).then( - () => { - this.error = ''; - this.$scope.$apply(); - }, - (e) => { - this.error = e; - this.$scope.$apply(); - } - ); - } - - buildConditionModel(source: any) { - const cm: any = { source: source, type: source.type }; - - cm.queryPart = new QueryPart(source.query, alertDef.alertQueryDef); - cm.reducerPart = alertDef.createReducerPart(source.reducer); - cm.evaluator = source.evaluator; - cm.operator = source.operator; - - return cm; - } - - handleQueryPartEvent(conditionModel: any, evt: any) { - switch (evt.name) { - case 'action-remove-part': { - break; - } - case 'get-part-actions': { - return Promise.resolve([]); - } - case 'part-param-changed': { - this.validateModel(); - } - case 'get-param-options': { - const result = this.panel.targets.map((target) => { - return this.uiSegmentSrv.newSegment({ value: target.refId }); - }); - - return Promise.resolve(result); - } - default: { - return Promise.resolve(); - } - } - - return Promise.resolve(); - } - - handleReducerPartEvent(conditionModel: any, evt: any) { - switch (evt.name) { - case 'action': { - conditionModel.source.reducer.type = evt.action.value; - conditionModel.reducerPart = alertDef.createReducerPart(conditionModel.source.reducer); - this.evaluatorParamsChanged(); - break; - } - case 'get-part-actions': { - const result = []; - for (const type of alertDef.reducerTypes) { - if (type.value !== conditionModel.source.reducer.type) { - result.push(type); - } - } - return Promise.resolve(result); - } - } - - return Promise.resolve(); - } - - addCondition(type: string) { - const condition = getDefaultCondition(); - // add to persited model - this.alert.conditions.push(condition); - // add to view model - this.conditionModels.push(this.buildConditionModel(condition)); - } - - removeCondition(index: number) { - this.alert.conditions.splice(index, 1); - this.conditionModels.splice(index, 1); - } - - delete() { - appEvents.publish( - new ShowConfirmModalEvent({ - title: 'Delete Alert', - text: 'Are you sure you want to delete this alert rule?', - text2: 'You need to save dashboard for the delete to take effect', - icon: 'trash-alt', - yesText: 'Delete', - onConfirm: () => { - delete this.panel.alert; - this.alert = null; - this.panel.thresholds = []; - this.conditionModels = []; - this.panelCtrl.alertState = null; - this.panelCtrl.render(); - }, - }) - ); - } - - enable = () => { - this.panel.alert = {}; - this.initModel(); - this.panel.alert.for = '5m'; //default value for new alerts. for existing alerts we use 0m to avoid breaking changes - }; - - evaluatorParamsChanged() { - ThresholdMapper.alertToGraphThresholds(this.panel); - this.panelCtrl.render(); - } - - evaluatorTypeChanged(evaluator: any) { - // ensure params array is correct length - switch (evaluator.type) { - case 'lt': - case 'gt': { - evaluator.params = [evaluator.params[0]]; - break; - } - case 'within_range': - case 'outside_range': { - evaluator.params = [evaluator.params[0], evaluator.params[1]]; - break; - } - case 'no_value': { - evaluator.params = []; - } - } - - this.evaluatorParamsChanged(); - } - - clearHistory() { - appEvents.publish( - new ShowConfirmModalEvent({ - title: 'Delete Alert History', - text: 'Are you sure you want to remove all history & annotations for this alert?', - icon: 'trash-alt', - yesText: 'Yes', - onConfirm: () => { - promiseToDigest(this.$scope)( - getBackendSrv() - .post('/api/annotations/mass-delete', { - dashboardId: this.panelCtrl.dashboard.id, - panelId: this.panel.id, - }) - .then(() => { - this.alertHistory = []; - this.panelCtrl.refresh(); - }) - ); - }, - }) - ); - } -} - -export function alertTab() { - 'use strict'; - return { - restrict: 'E', - scope: true, - templateUrl: 'public/app/features/alerting/partials/alert_tab.html', - controller: AlertTabCtrl, - }; -} - -coreModule.directive('alertTab', alertTab); diff --git a/public/app/features/alerting/AlertTabIndex.tsx b/public/app/features/alerting/AlertTabIndex.tsx deleted file mode 100644 index ec81549084988..0000000000000 --- a/public/app/features/alerting/AlertTabIndex.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { config } from '@grafana/runtime'; - -import { AlertTab } from './AlertTab'; -import { PanelAlertTabContent } from './unified/PanelAlertTabContent'; - -// route between unified and "old" alerting pages based on feature flag - -export default config.unifiedAlertingEnabled ? PanelAlertTabContent : AlertTab; diff --git a/public/app/features/alerting/EditNotificationChannelPage.tsx b/public/app/features/alerting/EditNotificationChannelPage.tsx deleted file mode 100644 index 9860dd10d5aa4..0000000000000 --- a/public/app/features/alerting/EditNotificationChannelPage.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import React, { PureComponent } from 'react'; -import { MapDispatchToProps, MapStateToProps } from 'react-redux'; - -import { config } from '@grafana/runtime'; -import { Form, Spinner } from '@grafana/ui'; -import { Page } from 'app/core/components/Page/Page'; -import { connectWithCleanUp } from 'app/core/components/connectWithCleanUp'; -import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { NotificationChannelType, NotificationChannelDTO, StoreState } from 'app/types'; - -import { NotificationChannelForm } from './components/NotificationChannelForm'; -import { loadNotificationChannel, testNotificationChannel, updateNotificationChannel } from './state/actions'; -import { initialChannelState, resetSecureField } from './state/reducers'; -import { mapChannelsToSelectableValue, transformSubmitData, transformTestData } from './utils/notificationChannels'; - -interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> {} - -interface ConnectedProps { - notificationChannel: any; - notificationChannelTypes: NotificationChannelType[]; -} - -interface DispatchProps { - loadNotificationChannel: typeof loadNotificationChannel; - testNotificationChannel: typeof testNotificationChannel; - updateNotificationChannel: typeof updateNotificationChannel; - resetSecureField: typeof resetSecureField; -} - -type Props = OwnProps & ConnectedProps & DispatchProps; - -export class EditNotificationChannelPage extends PureComponent<Props> { - componentDidMount() { - this.props.loadNotificationChannel(parseInt(this.props.match.params.id, 10)); - } - - onSubmit = (formData: NotificationChannelDTO) => { - const { notificationChannel } = this.props; - - this.props.updateNotificationChannel({ - /* - Some settings which lives in a collapsed section will not be registered since - the section will not be rendered if a user doesn't expand it. Therefore we need to - merge the initialData with any changes from the form. - */ - ...transformSubmitData({ - ...notificationChannel, - ...formData, - settings: { ...notificationChannel.settings, ...formData.settings }, - }), - id: notificationChannel.id, - }); - }; - - onTestChannel = (formData: NotificationChannelDTO) => { - const { notificationChannel } = this.props; - /* - Same as submit - */ - this.props.testNotificationChannel( - transformTestData({ - ...notificationChannel, - ...formData, - settings: { ...notificationChannel.settings, ...formData.settings }, - }) - ); - }; - - render() { - const { notificationChannel, notificationChannelTypes } = this.props; - - return ( - <Page navId="channels"> - <Page.Contents> - <h2 className="page-sub-heading">Edit notification channel</h2> - {notificationChannel && notificationChannel.id > 0 ? ( - <Form - maxWidth={600} - onSubmit={this.onSubmit} - defaultValues={{ - ...notificationChannel, - type: notificationChannelTypes.find((n) => n.value === notificationChannel.type), - }} - > - {({ control, errors, getValues, register, watch }) => { - const selectedChannel = notificationChannelTypes.find((c) => c.value === getValues().type.value); - - return ( - <NotificationChannelForm - selectableChannels={mapChannelsToSelectableValue(notificationChannelTypes, true)} - selectedChannel={selectedChannel} - imageRendererAvailable={config.rendererAvailable} - onTestChannel={this.onTestChannel} - register={register} - watch={watch} - errors={errors} - getValues={getValues} - control={control} - resetSecureField={this.props.resetSecureField} - secureFields={notificationChannel.secureFields} - /> - ); - }} - </Form> - ) : ( - <div> - Loading notification channel - <Spinner /> - </div> - )} - </Page.Contents> - </Page> - ); - } -} - -const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state) => { - return { - notificationChannel: state.notificationChannel.notificationChannel, - notificationChannelTypes: state.notificationChannel.notificationChannelTypes, - }; -}; - -const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { - loadNotificationChannel, - testNotificationChannel, - updateNotificationChannel, - resetSecureField, -}; - -export default connectWithCleanUp( - mapStateToProps, - mapDispatchToProps, - (state) => (state.notificationChannel = initialChannelState) -)(EditNotificationChannelPage); diff --git a/public/app/features/alerting/FeatureTogglePage.tsx b/public/app/features/alerting/FeatureTogglePage.tsx deleted file mode 100644 index e6def5ec3f581..0000000000000 --- a/public/app/features/alerting/FeatureTogglePage.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; - -import { Page } from 'app/core/components/Page/Page'; -import { useNavModel } from 'app/core/hooks/useNavModel'; - -export default function FeatureTogglePage() { - const navModel = useNavModel('alert-list'); - - return ( - <Page navModel={navModel}> - <Page.Contents> - <h1>Alerting is not enabled</h1> - To enable alerting, enable it in the Grafana config: - <div> - <pre> - {`[unified_alerting] -enable = true -`} - </pre> - </div> - <div> - For legacy alerting - <pre> - {`[alerting] -enable = true -`} - </pre> - </div> - </Page.Contents> - </Page> - ); -} diff --git a/public/app/features/alerting/NewNotificationChannelPage.tsx b/public/app/features/alerting/NewNotificationChannelPage.tsx deleted file mode 100644 index 93d6ca43865ce..0000000000000 --- a/public/app/features/alerting/NewNotificationChannelPage.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, { PureComponent } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; - -import { config } from '@grafana/runtime'; -import { Form } from '@grafana/ui'; -import { Page } from 'app/core/components/Page/Page'; -import { getNavModel } from 'app/core/selectors/navModel'; - -import { NotificationChannelDTO, StoreState } from '../../types'; - -import { NotificationChannelForm } from './components/NotificationChannelForm'; -import { createNotificationChannel, loadNotificationTypes, testNotificationChannel } from './state/actions'; -import { resetSecureField } from './state/reducers'; -import { - defaultValues, - mapChannelsToSelectableValue, - transformSubmitData, - transformTestData, -} from './utils/notificationChannels'; - -class NewNotificationChannelPage extends PureComponent<Props> { - componentDidMount() { - this.props.loadNotificationTypes(); - } - - onSubmit = (data: NotificationChannelDTO) => { - this.props.createNotificationChannel(transformSubmitData({ ...defaultValues, ...data })); - }; - - onTestChannel = (data: NotificationChannelDTO) => { - this.props.testNotificationChannel(transformTestData({ ...defaultValues, ...data })); - }; - - render() { - const { navModel, notificationChannelTypes } = this.props; - - return ( - <Page navModel={navModel}> - <Page.Contents> - <h2 className="page-sub-heading">New notification channel</h2> - <Form onSubmit={this.onSubmit} validateOn="onChange" defaultValues={defaultValues} maxWidth={600}> - {({ register, errors, control, getValues, watch }) => { - const selectedChannel = notificationChannelTypes.find((c) => c.value === getValues().type.value); - - return ( - <NotificationChannelForm - selectableChannels={mapChannelsToSelectableValue(notificationChannelTypes, true)} - selectedChannel={selectedChannel} - onTestChannel={this.onTestChannel} - register={register} - errors={errors} - getValues={getValues} - control={control} - watch={watch} - imageRendererAvailable={config.rendererAvailable} - resetSecureField={this.props.resetSecureField} - secureFields={{}} - /> - ); - }} - </Form> - </Page.Contents> - </Page> - ); - } -} - -const mapStateToProps = (state: StoreState) => ({ - navModel: getNavModel(state.navIndex, 'channels'), - notificationChannelTypes: state.notificationChannel.notificationChannelTypes, -}); - -const mapDispatchToProps = { - createNotificationChannel, - loadNotificationTypes, - testNotificationChannel, - resetSecureField, -}; - -const connector = connect(mapStateToProps, mapDispatchToProps); -type Props = ConnectedProps<typeof connector>; -export default connector(NewNotificationChannelPage); diff --git a/public/app/features/alerting/NotificationsListPage.tsx b/public/app/features/alerting/NotificationsListPage.tsx deleted file mode 100644 index 5ad60b94e04e1..0000000000000 --- a/public/app/features/alerting/NotificationsListPage.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import React, { useState, FC, useEffect } from 'react'; -import { useAsyncFn } from 'react-use'; - -import { getBackendSrv } from '@grafana/runtime'; -import { HorizontalGroup, Button, LinkButton } from '@grafana/ui'; -import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import { Page } from 'app/core/components/Page/Page'; -import { appEvents } from 'app/core/core'; -import { useNavModel } from 'app/core/hooks/useNavModel'; -import { AlertNotification } from 'app/types/alerting'; - -import { ShowConfirmModalEvent } from '../../types/events'; - -const NotificationsListPage: FC = () => { - const navModel = useNavModel('channels'); - - const [notifications, setNotifications] = useState<AlertNotification[]>([]); - - const getNotifications = async () => { - return await getBackendSrv().get(`/api/alert-notifications`); - }; - - const [state, fetchNotifications] = useAsyncFn(getNotifications); - useEffect(() => { - fetchNotifications().then((res) => { - setNotifications(res); - }); - }, [fetchNotifications]); - - const deleteNotification = (id: number) => { - appEvents.publish( - new ShowConfirmModalEvent({ - title: 'Delete', - text: 'Do you want to delete this notification channel?', - text2: `Deleting this notification channel will not delete from alerts any references to it`, - icon: 'trash-alt', - confirmText: 'Delete', - yesText: 'Delete', - onConfirm: async () => { - deleteNotificationConfirmed(id); - }, - }) - ); - }; - - const deleteNotificationConfirmed = async (id: number) => { - await getBackendSrv().delete(`/api/alert-notifications/${id}`); - const notifications = await fetchNotifications(); - setNotifications(notifications); - }; - - return ( - <Page navModel={navModel}> - <Page.Contents> - {state.error && <p>{state.error.message}</p>} - {!!notifications.length && ( - <> - <div className="page-action-bar"> - <div className="page-action-bar__spacer" /> - <LinkButton icon="channel-add" href="alerting/notification/new"> - New channel - </LinkButton> - </div> - <table className="filter-table filter-table--hover"> - <thead> - <tr> - <th style={{ minWidth: '200px' }}> - <strong>Name</strong> - </th> - <th style={{ minWidth: '100px' }}>Type</th> - <th style={{ width: '1%' }}></th> - </tr> - </thead> - <tbody> - {notifications.map((notification) => ( - <tr key={notification.id}> - <td className="link-td"> - <a href={`alerting/notification/${notification.id}/edit`}>{notification.name}</a> - </td> - <td className="link-td"> - <a href={`alerting/notification/${notification.id}/edit`}>{notification.type}</a> - </td> - <td className="text-right"> - <HorizontalGroup justify="flex-end"> - {notification.isDefault && ( - <Button disabled variant="secondary" size="sm"> - default - </Button> - )} - <Button - variant="destructive" - icon="times" - size="sm" - onClick={() => { - deleteNotification(notification.id); - }} - /> - </HorizontalGroup> - </td> - </tr> - ))} - </tbody> - </table> - </> - )} - - {!(notifications.length || state.loading) && ( - <EmptyListCTA - title="There are no notification channels defined yet" - buttonIcon="channel-add" - buttonLink="alerting/notification/new" - buttonTitle="Add channel" - proTip="You can include images in your alert notifications." - proTipLink="http://docs.grafana.org/alerting/notifications/" - proTipLinkTitle="Learn more" - proTipTarget="_blank" - /> - )} - </Page.Contents> - </Page> - ); -}; - -export default NotificationsListPage; diff --git a/public/app/features/alerting/StateHistory.tsx b/public/app/features/alerting/StateHistory.tsx deleted file mode 100644 index 158cc30fad60d..0000000000000 --- a/public/app/features/alerting/StateHistory.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { css } from '@emotion/css'; -import React, { PureComponent } from 'react'; - -import { getBackendSrv } from '@grafana/runtime'; -import { Icon, ConfirmButton, Button } from '@grafana/ui'; - -import { DashboardModel } from '../dashboard/state/DashboardModel'; - -import alertDef from './state/alertDef'; - -interface Props { - dashboard: DashboardModel; - panelId: number; - onRefresh: () => void; -} - -interface State { - stateHistoryItems: any[]; -} - -class StateHistory extends PureComponent<Props, State> { - state: State = { - stateHistoryItems: [], - }; - - componentDidMount(): void { - const { dashboard, panelId } = this.props; - - getBackendSrv() - .get( - `/api/annotations?dashboardId=${dashboard.id}&panelId=${panelId}&limit=50&type=alert`, - {}, - `state-history-${dashboard.id}-${panelId}` - ) - .then((data) => { - const items = data.map((item: any) => { - return { - stateModel: alertDef.getStateDisplayModel(item.newState), - time: dashboard.formatDate(item.time, 'MMM D, YYYY HH:mm:ss'), - info: alertDef.getAlertAnnotationInfo(item), - }; - }); - - this.setState({ - stateHistoryItems: items, - }); - }); - } - - clearHistory = async () => { - const { dashboard, panelId, onRefresh } = this.props; - - await getBackendSrv().post('/api/annotations/mass-delete', { - dashboardId: dashboard.id, - panelId: panelId, - }); - - this.setState({ stateHistoryItems: [] }); - onRefresh(); - }; - - render() { - const { stateHistoryItems } = this.state; - - return ( - <div> - {stateHistoryItems.length > 0 && ( - <div className="p-b-1"> - <span className="muted">Last 50 state changes</span> - <ConfirmButton onConfirm={this.clearHistory} confirmVariant="destructive" confirmText="Clear"> - <Button - className={css` - direction: ltr; - `} - variant="destructive" - icon="trash-alt" - > - Clear history - </Button> - </ConfirmButton> - </div> - )} - <ol className="alert-rule-list"> - {stateHistoryItems.length > 0 ? ( - stateHistoryItems.map((item, index) => { - return ( - <li className="alert-rule-item" key={`${item.time}-${index}`}> - <div className={`alert-rule-item__icon ${item.stateModel.stateClass}`}> - <Icon name={item.stateModel.iconClass} size="xl" /> - </div> - <div className="alert-rule-item__body"> - <div className="alert-rule-item__header"> - <p className="alert-rule-item__name">{item.alertName}</p> - <div className="alert-rule-item__text"> - <span className={`${item.stateModel.stateClass}`}>{item.stateModel.text}</span> - </div> - </div> - {item.info} - </div> - <div className="alert-rule-item__time">{item.time}</div> - </li> - ); - }) - ) : ( - <i>No state changes recorded</i> - )} - </ol> - </div> - ); - } -} - -export default StateHistory; diff --git a/public/app/features/alerting/TestRuleResult.test.tsx b/public/app/features/alerting/TestRuleResult.test.tsx deleted file mode 100644 index 9b44afa9f0f22..0000000000000 --- a/public/app/features/alerting/TestRuleResult.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; - -import { PanelModel } from '../dashboard/state'; -import { createDashboardModelFixture, createPanelSaveModel } from '../dashboard/state/__fixtures__/dashboardFixtures'; - -import { TestRuleResult } from './TestRuleResult'; - -const backendSrv = { - post: jest.fn(), -}; - -jest.mock('@grafana/runtime', () => { - const original = jest.requireActual('@grafana/runtime'); - - return { - ...original, - getBackendSrv: () => backendSrv, - }; -}); - -const props: React.ComponentProps<typeof TestRuleResult> = { - panel: new PanelModel({ id: 1 }), - dashboard: createDashboardModelFixture({ - panels: [createPanelSaveModel({ id: 1 })], - }), -}; - -describe('TestRuleResult', () => { - it('should render without error', async () => { - render(<TestRuleResult {...props} />); - await screen.findByRole('button', { name: 'Copy to Clipboard' }); - }); - - it('should call testRule when mounting', async () => { - jest.spyOn(backendSrv, 'post'); - render(<TestRuleResult {...props} />); - await screen.findByRole('button', { name: 'Copy to Clipboard' }); - - expect(backendSrv.post).toHaveBeenCalledWith( - '/api/alerts/test', - expect.objectContaining({ - panelId: 1, - }) - ); - }); -}); diff --git a/public/app/features/alerting/TestRuleResult.tsx b/public/app/features/alerting/TestRuleResult.tsx deleted file mode 100644 index a2c86c917c9a3..0000000000000 --- a/public/app/features/alerting/TestRuleResult.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import React, { PureComponent } from 'react'; - -import { getBackendSrv } from '@grafana/runtime'; -import { - LoadingPlaceholder, - JSONFormatter, - Icon, - HorizontalGroup, - ClipboardButton, - clearButtonStyles, - withTheme2, - Themeable2, -} from '@grafana/ui'; - -import { DashboardModel, PanelModel } from '../dashboard/state'; - -export interface Props extends Themeable2 { - dashboard: DashboardModel; - panel: PanelModel; -} - -interface State { - isLoading: boolean; - allNodesExpanded: boolean | null; - testRuleResponse: {}; -} - -class UnThemedTestRuleResult extends PureComponent<Props, State> { - readonly state: State = { - isLoading: false, - allNodesExpanded: null, - testRuleResponse: {}, - }; - - formattedJson: any; - clipboard: any; - - componentDidMount() { - this.testRule(); - } - - async testRule() { - const { dashboard, panel } = this.props; - - // dashboard save model - const model = dashboard.getSaveModelCloneOld(); - - // now replace panel to get current edits - model.panels = model.panels.map((dashPanel) => { - return dashPanel.id === panel.id ? panel.getSaveModel() : dashPanel; - }); - - const payload = { dashboard: model, panelId: panel.id }; - - this.setState({ isLoading: true }); - const testRuleResponse = await getBackendSrv().post(`/api/alerts/test`, payload); - this.setState({ isLoading: false, testRuleResponse }); - } - - setFormattedJson = (formattedJson: any) => { - this.formattedJson = formattedJson; - }; - - getTextForClipboard = () => { - return JSON.stringify(this.formattedJson, null, 2); - }; - - onToggleExpand = () => { - this.setState((prevState) => ({ - ...prevState, - allNodesExpanded: !this.state.allNodesExpanded, - })); - }; - - getNrOfOpenNodes = () => { - if (this.state.allNodesExpanded === null) { - return 3; // 3 is default, ie when state is null - } else if (this.state.allNodesExpanded) { - return 20; - } - return 1; - }; - - renderExpandCollapse = () => { - const { allNodesExpanded } = this.state; - - const collapse = ( - <> - <Icon name="minus-circle" /> Collapse All - </> - ); - const expand = ( - <> - <Icon name="plus-circle" /> Expand All - </> - ); - return allNodesExpanded ? collapse : expand; - }; - - render() { - const { testRuleResponse, isLoading } = this.state; - const clearButton = clearButtonStyles(this.props.theme); - - if (isLoading === true) { - return <LoadingPlaceholder text="Evaluating rule" />; - } - - const openNodes = this.getNrOfOpenNodes(); - - return ( - <> - <div className="pull-right"> - <HorizontalGroup spacing="md"> - <button type="button" className={clearButton} onClick={this.onToggleExpand}> - {this.renderExpandCollapse()} - </button> - <ClipboardButton getText={this.getTextForClipboard} icon="copy"> - Copy to Clipboard - </ClipboardButton> - </HorizontalGroup> - </div> - - <JSONFormatter json={testRuleResponse} open={openNodes} onDidRender={this.setFormattedJson} /> - </> - ); - } -} - -export const TestRuleResult = withTheme2(UnThemedTestRuleResult); diff --git a/public/app/features/alerting/components/BasicSettings.tsx b/public/app/features/alerting/components/BasicSettings.tsx deleted file mode 100644 index 6705da037efcf..0000000000000 --- a/public/app/features/alerting/components/BasicSettings.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; - -import { SelectableValue } from '@grafana/data'; -import { Field, Input, InputControl, Select } from '@grafana/ui'; - -import { NotificationChannelSecureFields, NotificationChannelType } from '../../../types'; - -import { NotificationSettingsProps } from './NotificationChannelForm'; -import { NotificationChannelOptions } from './NotificationChannelOptions'; - -interface Props extends NotificationSettingsProps { - selectedChannel: NotificationChannelType; - channels: Array<SelectableValue<string>>; - secureFields: NotificationChannelSecureFields; - resetSecureField: (key: string) => void; -} - -export const BasicSettings = ({ - control, - currentFormValues, - errors, - secureFields, - selectedChannel, - channels, - register, - resetSecureField, -}: Props) => { - return ( - <> - <Field label="Name" invalid={!!errors.name} error={errors.name && errors.name.message}> - <Input {...register('name', { required: 'Name is required' })} /> - </Field> - <Field label="Type"> - <InputControl - name="type" - render={({ field: { ref, ...field } }) => <Select {...field} options={channels} />} - control={control} - rules={{ required: true }} - /> - </Field> - <NotificationChannelOptions - selectedChannelOptions={selectedChannel.options.filter((o) => o.required)} - currentFormValues={currentFormValues} - secureFields={secureFields} - onResetSecureField={resetSecureField} - register={register} - errors={errors} - control={control} - /> - </> - ); -}; diff --git a/public/app/features/alerting/components/ChannelSettings.tsx b/public/app/features/alerting/components/ChannelSettings.tsx deleted file mode 100644 index 940863c1f3e68..0000000000000 --- a/public/app/features/alerting/components/ChannelSettings.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; - -import { Alert, CollapsableSection } from '@grafana/ui'; - -import { NotificationChannelSecureFields, NotificationChannelType } from '../../../types'; - -import { NotificationSettingsProps } from './NotificationChannelForm'; -import { NotificationChannelOptions } from './NotificationChannelOptions'; - -interface Props extends NotificationSettingsProps { - selectedChannel: NotificationChannelType; - secureFields: NotificationChannelSecureFields; - resetSecureField: (key: string) => void; -} - -export const ChannelSettings = ({ - control, - currentFormValues, - errors, - selectedChannel, - secureFields, - register, - resetSecureField, -}: Props) => { - return ( - <CollapsableSection label={`Optional ${selectedChannel.heading}`} isOpen={false}> - {selectedChannel.info !== '' && <Alert severity="info" title={selectedChannel.info ?? ''} />} - <NotificationChannelOptions - selectedChannelOptions={selectedChannel.options.filter((o) => !o.required)} - currentFormValues={currentFormValues} - register={register} - errors={errors} - control={control} - onResetSecureField={resetSecureField} - secureFields={secureFields} - /> - </CollapsableSection> - ); -}; diff --git a/public/app/features/alerting/components/DeprecationNotice.tsx b/public/app/features/alerting/components/DeprecationNotice.tsx deleted file mode 100644 index bc3db37c7d803..0000000000000 --- a/public/app/features/alerting/components/DeprecationNotice.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; - -import { Alert } from '@grafana/ui'; - -export const LOCAL_STORAGE_KEY = 'grafana.legacyalerting.unifiedalertingpromo'; - -const DeprecationNotice = () => ( - <Alert severity="warning" title="Grafana legacy alerting is deprecated and will be removed in a future release."> - <p> - You are using Grafana legacy alerting, which has been deprecated since Grafana 9.0. The codebase is now staying as - is and will be removed in Grafana 11.0. - <br /> - We recommend upgrading to Grafana Alerting as soon as possible. - </p> - <p> - See{' '} - <a href="https://grafana.com/docs/grafana/latest/alerting/migrating-alerts/"> - how to upgrade to Grafana Alerting - </a>{' '} - to learn more. - </p> - </Alert> -); - -export { DeprecationNotice }; diff --git a/public/app/features/alerting/components/NotificationChannelForm.tsx b/public/app/features/alerting/components/NotificationChannelForm.tsx deleted file mode 100644 index 60be00150a75a..0000000000000 --- a/public/app/features/alerting/components/NotificationChannelForm.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { css } from '@emotion/css'; -import React, { useEffect } from 'react'; - -import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { Button, FormAPI, HorizontalGroup, Spinner, useStyles2 } from '@grafana/ui'; -import config from 'app/core/config'; - -import { NotificationChannelType, NotificationChannelDTO, NotificationChannelSecureFields } from '../../../types'; - -import { BasicSettings } from './BasicSettings'; -import { ChannelSettings } from './ChannelSettings'; -import { NotificationSettings } from './NotificationSettings'; - -interface Props - extends Pick<FormAPI<NotificationChannelDTO>, 'control' | 'errors' | 'register' | 'watch' | 'getValues'> { - selectableChannels: Array<SelectableValue<string>>; - selectedChannel?: NotificationChannelType; - imageRendererAvailable: boolean; - secureFields: NotificationChannelSecureFields; - resetSecureField: (key: string) => void; - onTestChannel: (data: NotificationChannelDTO) => void; -} - -export interface NotificationSettingsProps - extends Pick<FormAPI<NotificationChannelDTO>, 'control' | 'errors' | 'register'> { - currentFormValues: NotificationChannelDTO; -} - -export const NotificationChannelForm = ({ - control, - errors, - selectedChannel, - selectableChannels, - register, - watch, - getValues, - imageRendererAvailable, - onTestChannel, - resetSecureField, - secureFields, -}: Props) => { - const styles = useStyles2(getStyles); - - useEffect(() => { - /* - Find fields that have dependencies on other fields and removes duplicates. - Needs to be prefixed with settings. - */ - const fieldsToWatch = - new Set( - selectedChannel?.options - .filter((o) => o.showWhen.field) - .map((option) => { - return `settings.${option.showWhen.field}`; - }) - ) || []; - watch(['type', 'sendReminder', 'uploadImage', ...fieldsToWatch]); - }, [selectedChannel?.options, watch]); - - const currentFormValues = getValues(); - - if (!selectedChannel) { - return <Spinner />; - } - - return ( - <div className={styles.formContainer}> - <div className={styles.formItem}> - <BasicSettings - selectedChannel={selectedChannel} - channels={selectableChannels} - secureFields={secureFields} - resetSecureField={resetSecureField} - currentFormValues={currentFormValues} - register={register} - errors={errors} - control={control} - /> - </div> - {/* If there are no non-required fields, don't render this section*/} - {selectedChannel.options.filter((o) => !o.required).length > 0 && ( - <div className={styles.formItem}> - <ChannelSettings - selectedChannel={selectedChannel} - secureFields={secureFields} - resetSecureField={resetSecureField} - currentFormValues={currentFormValues} - register={register} - errors={errors} - control={control} - /> - </div> - )} - <div className={styles.formItem}> - <NotificationSettings - imageRendererAvailable={imageRendererAvailable} - currentFormValues={currentFormValues} - register={register} - errors={errors} - control={control} - /> - </div> - <div className={styles.formButtons}> - <HorizontalGroup> - <Button type="submit">Save</Button> - <Button type="button" variant="secondary" onClick={() => onTestChannel(getValues())}> - Test - </Button> - <a href={`${config.appSubUrl}/alerting/notifications`}> - <Button type="button" variant="secondary"> - Back - </Button> - </a> - </HorizontalGroup> - </div> - </div> - ); -}; - -const getStyles = (theme: GrafanaTheme2) => { - return { - formContainer: css``, - formItem: css` - flex-grow: 1; - padding-top: ${theme.spacing(2)}; - `, - formButtons: css` - padding-top: ${theme.spacing(4)}; - `, - }; -}; diff --git a/public/app/features/alerting/components/NotificationChannelOptions.tsx b/public/app/features/alerting/components/NotificationChannelOptions.tsx deleted file mode 100644 index c94fd99489343..0000000000000 --- a/public/app/features/alerting/components/NotificationChannelOptions.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React from 'react'; - -import { SelectableValue } from '@grafana/data'; -import { Button, Checkbox, Field, Input } from '@grafana/ui'; - -import { NotificationChannelDTO, NotificationChannelOption, NotificationChannelSecureFields } from '../../../types'; - -import { NotificationSettingsProps } from './NotificationChannelForm'; -import { OptionElement } from './OptionElement'; - -interface Props extends NotificationSettingsProps { - selectedChannelOptions: NotificationChannelOption[]; - currentFormValues: NotificationChannelDTO; - secureFields: NotificationChannelSecureFields; - - onResetSecureField: (key: string) => void; -} - -export const NotificationChannelOptions = ({ - control, - currentFormValues, - errors, - selectedChannelOptions, - register, - onResetSecureField, - secureFields, -}: Props) => { - return ( - <> - {selectedChannelOptions.map((option: NotificationChannelOption, index: number) => { - const key = `${option.label}-${index}`; - // Some options can be dependent on other options, this determines what is selected in the dependency options - // I think this needs more thought. - const selectedOptionValue = - currentFormValues[`settings.${option.showWhen.field}`] && - (currentFormValues[`settings.${option.showWhen.field}`] as SelectableValue<string>).value; - - if (option.showWhen.field && selectedOptionValue !== option.showWhen.is) { - return null; - } - - if (option.element === 'checkbox') { - return ( - <Field key={key}> - <Checkbox - {...register( - option.secure ? `secureSettings.${option.propertyName}` : `settings.${option.propertyName}` - )} - label={option.label} - description={option.description} - /> - </Field> - ); - } - return ( - <Field - key={key} - label={option.label} - description={option.description} - invalid={errors.settings && !!errors.settings[option.propertyName]} - error={errors.settings && errors.settings[option.propertyName]?.message} - > - {secureFields && secureFields[option.propertyName] ? ( - <Input - readOnly={true} - value="Configured" - suffix={ - <Button onClick={() => onResetSecureField(option.propertyName)} fill="text" type="button" size="sm"> - Clear - </Button> - } - /> - ) : ( - <OptionElement option={option} register={register} control={control} /> - )} - </Field> - ); - })} - </> - ); -}; diff --git a/public/app/features/alerting/components/NotificationSettings.tsx b/public/app/features/alerting/components/NotificationSettings.tsx deleted file mode 100644 index c88191d65fe2a..0000000000000 --- a/public/app/features/alerting/components/NotificationSettings.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; - -import { Checkbox, CollapsableSection, Field, InfoBox, Input } from '@grafana/ui'; - -import { NotificationSettingsProps } from './NotificationChannelForm'; - -interface Props extends NotificationSettingsProps { - imageRendererAvailable: boolean; -} - -export const NotificationSettings = ({ currentFormValues, imageRendererAvailable, register }: Props) => { - return ( - <CollapsableSection label="Notification settings" isOpen={false}> - <Field> - <Checkbox {...register('isDefault')} label="Default" description="Use this notification for all alerts" /> - </Field> - <Field> - <Checkbox - {...register('settings.uploadImage')} - label="Include image" - description="Captures an image and include it in the notification" - /> - </Field> - {currentFormValues.uploadImage && !imageRendererAvailable && ( - <InfoBox title="No image renderer available/installed"> - Grafana cannot find an image renderer to capture an image for the notification. Please make sure the Grafana - Image Renderer plugin is installed. Please contact your Grafana administrator to install the plugin. - </InfoBox> - )} - <Field> - <Checkbox - {...register('disableResolveMessage')} - label="Disable Resolve Message" - description="Disable the resolve message [OK] that is sent when alerting state returns to false" - /> - </Field> - <Field> - <Checkbox - {...register('sendReminder')} - label="Send reminders" - description="Send additional notifications for triggered alerts" - /> - </Field> - {currentFormValues.sendReminder && ( - <> - <Field - label="Send reminder every" - description="Specify how often reminders should be sent, e.g. every 30s, 1m, 10m, 30m', or 1h etc. - Alert reminders are sent after rules are evaluated. A reminder can never be sent more frequently - than a configured alert rule evaluation interval." - > - <Input {...register('frequency')} width={8} /> - </Field> - </> - )} - </CollapsableSection> - ); -}; diff --git a/public/app/features/alerting/components/OptionElement.tsx b/public/app/features/alerting/components/OptionElement.tsx deleted file mode 100644 index bc43c8dee8512..0000000000000 --- a/public/app/features/alerting/components/OptionElement.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; - -import { FormAPI, Input, InputControl, Select, TextArea } from '@grafana/ui'; - -import { NotificationChannelOption } from '../../../types'; - -interface Props extends Pick<FormAPI<any>, 'register' | 'control'> { - option: NotificationChannelOption; - invalid?: boolean; -} - -export const OptionElement = ({ control, option, register, invalid }: Props) => { - const modelValue = option.secure ? `secureSettings.${option.propertyName}` : `settings.${option.propertyName}`; - switch (option.element) { - case 'input': - return ( - <Input - {...register(`${modelValue}`, { - required: option.required ? 'Required' : false, - validate: (v) => (option.validationRule !== '' ? validateOption(v, option.validationRule) : true), - })} - invalid={invalid} - type={option.inputType} - placeholder={option.placeholder} - /> - ); - - case 'select': - return ( - <InputControl - control={control} - name={`${modelValue}`} - render={({ field: { ref, ...field } }) => ( - <Select {...field} options={option.selectOptions ?? undefined} invalid={invalid} /> - )} - /> - ); - - case 'textarea': - return ( - <TextArea - invalid={invalid} - {...register(`${modelValue}`, { - required: option.required ? 'Required' : false, - validate: (v) => (option.validationRule !== '' ? validateOption(v, option.validationRule) : true), - })} - /> - ); - - default: - console.error('Element not supported', option.element); - return null; - } -}; - -const validateOption = (value: string, validationRule: string) => { - return RegExp(validationRule).test(value) ? true : 'Invalid format'; -}; diff --git a/public/app/features/alerting/getAlertingValidationMessage.test.ts b/public/app/features/alerting/getAlertingValidationMessage.test.ts deleted file mode 100644 index 20f86c6e860db..0000000000000 --- a/public/app/features/alerting/getAlertingValidationMessage.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { - DataSourceApi, - PluginMeta, - DataTransformerConfig, - DataSourceInstanceSettings, - DataSourceRef, - DataQuery, -} from '@grafana/data'; -import { DataSourceSrv } from '@grafana/runtime'; - -import { ElasticsearchQuery } from '../../plugins/datasource/elasticsearch/types'; - -import { getAlertingValidationMessage } from './getAlertingValidationMessage'; - -describe('getAlertingValidationMessage', () => { - describe('when called with some targets containing template variables', () => { - it('then it should return false', async () => { - let call = 0; - const datasource: DataSourceApi = { - meta: { alerting: true } as unknown as PluginMeta, - targetContainsTemplate: () => { - if (call === 0) { - call++; - return true; - } - return false; - }, - name: 'some name', - uid: 'some uid', - } as unknown as DataSourceApi; - const getMock = jest.fn().mockResolvedValue(datasource); - const datasourceSrv: DataSourceSrv = { - get: (ref: DataSourceRef) => { - return getMock(ref.uid); - }, - getList(): DataSourceInstanceSettings[] { - return []; - }, - getInstanceSettings: jest.fn(), - reload: jest.fn(), - }; - const targets: ElasticsearchQuery[] = [ - { refId: 'A', query: '@hostname:$hostname' }, - { refId: 'B', query: '@instance:instance' }, - ]; - const transformations: DataTransformerConfig[] = []; - - const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, { - uid: datasource.uid, - }); - - expect(result).toBe(''); - expect(getMock).toHaveBeenCalledTimes(2); - expect(getMock).toHaveBeenCalledWith(datasource.uid); - }); - }); - - describe('when called with some targets using a datasource that does not support alerting', () => { - it('then it should return false', async () => { - const alertingDatasource: DataSourceApi = { - meta: { alerting: true } as unknown as PluginMeta, - targetContainsTemplate: () => false, - name: 'alertingDatasource', - } as unknown as DataSourceApi; - const datasource: DataSourceApi = { - meta: { alerting: false } as unknown as PluginMeta, - targetContainsTemplate: () => false, - name: 'datasource', - } as unknown as DataSourceApi; - - const datasourceSrv: DataSourceSrv = { - get: (name: string) => { - if (name === datasource.name) { - return Promise.resolve(datasource); - } - - return Promise.resolve(alertingDatasource); - }, - getInstanceSettings: jest.fn(), - getList(): DataSourceInstanceSettings[] { - return []; - }, - reload: jest.fn(), - }; - const targets: DataQuery[] = [ - { refId: 'A', datasource: { type: 'alertingDatasource' } }, - { refId: 'B', datasource: { type: 'datasource' } }, - ]; - const transformations: DataTransformerConfig[] = []; - - const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, { - uid: datasource.name, - }); - - expect(result).toBe(''); - }); - }); - - describe('when called with all targets containing template variables', () => { - it('then it should return false', async () => { - const datasource: DataSourceApi = { - meta: { alerting: true } as unknown as PluginMeta, - targetContainsTemplate: () => true, - name: 'some name', - } as unknown as DataSourceApi; - const getMock = jest.fn().mockResolvedValue(datasource); - const datasourceSrv: DataSourceSrv = { - get: (ref: DataSourceRef) => { - return getMock(ref.uid); - }, - getInstanceSettings: jest.fn(), - getList(): DataSourceInstanceSettings[] { - return []; - }, - reload: jest.fn(), - }; - const targets: ElasticsearchQuery[] = [ - { refId: 'A', query: '@hostname:$hostname' }, - { refId: 'B', query: '@instance:$instance' }, - ]; - const transformations: DataTransformerConfig[] = []; - - const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, { - uid: datasource.name, - }); - - expect(result).toBe('Template variables are not supported in alert queries'); - expect(getMock).toHaveBeenCalledTimes(2); - expect(getMock).toHaveBeenCalledWith(datasource.name); - }); - }); - - describe('when called with all targets using a datasource that does not support alerting', () => { - it('then it should return false', async () => { - const datasource: DataSourceApi = { - meta: { alerting: false } as unknown as PluginMeta, - targetContainsTemplate: () => false, - name: 'some name', - uid: 'theid', - } as unknown as DataSourceApi; - const getMock = jest.fn().mockResolvedValue(datasource); - const datasourceSrv: DataSourceSrv = { - get: (ref: DataSourceRef) => { - return getMock(ref.uid); - }, - getInstanceSettings: jest.fn(), - getList(): DataSourceInstanceSettings[] { - return []; - }, - reload: jest.fn(), - }; - const targets: ElasticsearchQuery[] = [ - { refId: 'A', query: '@hostname:hostname' }, - { refId: 'B', query: '@instance:instance' }, - ]; - const transformations: DataTransformerConfig[] = []; - - const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, { - uid: datasource.uid, - }); - - expect(result).toBe('The datasource does not support alerting queries'); - expect(getMock).toHaveBeenCalledTimes(2); - expect(getMock).toHaveBeenCalledWith(datasource.uid); - }); - }); - - describe('when called with transformations', () => { - it('then it should return false', async () => { - const datasource: DataSourceApi = { - meta: { alerting: true } as unknown as PluginMeta, - targetContainsTemplate: () => false, - name: 'some name', - } as unknown as DataSourceApi; - const getMock = jest.fn().mockResolvedValue(datasource); - const datasourceSrv: DataSourceSrv = { - get: (ref: DataSourceRef) => { - return getMock(ref.uid); - }, - getInstanceSettings: jest.fn(), - getList(): DataSourceInstanceSettings[] { - return []; - }, - reload: jest.fn(), - }; - const targets: ElasticsearchQuery[] = [ - { refId: 'A', query: '@hostname:hostname' }, - { refId: 'B', query: '@instance:instance' }, - ]; - const transformations: DataTransformerConfig[] = [{ id: 'A', options: null }]; - - const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, { - uid: datasource.uid, - }); - - expect(result).toBe('Transformations are not supported in alert queries'); - expect(getMock).toHaveBeenCalledTimes(0); - }); - }); -}); diff --git a/public/app/features/alerting/getAlertingValidationMessage.ts b/public/app/features/alerting/getAlertingValidationMessage.ts deleted file mode 100644 index 69a3aed00ea69..0000000000000 --- a/public/app/features/alerting/getAlertingValidationMessage.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { DataQuery, DataSourceRef, DataTransformerConfig } from '@grafana/data'; -import { DataSourceSrv } from '@grafana/runtime'; - -export const getDefaultCondition = () => ({ - type: 'query', - query: { params: ['A', '5m', 'now'] }, - reducer: { type: 'avg', params: [] }, - evaluator: { type: 'gt', params: [null] }, - operator: { type: 'and' }, -}); - -export const getAlertingValidationMessage = async ( - transformations: DataTransformerConfig[] | undefined, - targets: DataQuery[], - datasourceSrv: DataSourceSrv, - datasource: DataSourceRef | null -): Promise<string> => { - if (targets.length === 0) { - return 'Could not find any metric queries'; - } - - if (transformations && transformations.length) { - return 'Transformations are not supported in alert queries'; - } - - let alertingNotSupported = 0; - let templateVariablesNotSupported = 0; - - for (const target of targets) { - const dsRef = target.datasource || datasource; - const ds = await datasourceSrv.get(dsRef); - if (!ds.meta.alerting) { - alertingNotSupported++; - } else if (ds.targetContainsTemplate && ds.targetContainsTemplate(target)) { - templateVariablesNotSupported++; - } - } - - if (alertingNotSupported === targets.length) { - return 'The datasource does not support alerting queries'; - } - - if (templateVariablesNotSupported === targets.length) { - return 'Template variables are not supported in alert queries'; - } - - return ''; -}; diff --git a/public/app/features/alerting/partials/alert_tab.html b/public/app/features/alerting/partials/alert_tab.html deleted file mode 100644 index 6a4aceff32300..0000000000000 --- a/public/app/features/alerting/partials/alert_tab.html +++ /dev/null @@ -1,244 +0,0 @@ -<div ng-if="ctrl.panel.alert"> - <div class="alert alert-error m-b-2" ng-show="ctrl.error"> - <icon name="'exclamation-triangle'"></icon> {{ctrl.error}} - </div> - - <div class="gf-form-group"> - <h4 class="section-heading">Rule</h4> - <div class="gf-form-inline"> - <div class="gf-form"> - <span class="gf-form-label width-6">Name</span> - <input type="text" class="gf-form-input width-20 gf-form-input--has-help-icon" ng-model="ctrl.alert.name" /> - <info-popover mode="right-absolute"> - If you want to apply templating to the alert rule name, you must use the following syntax - ${Label} - </info-popover> - </div> - <div class="gf-form"> - <span class="gf-form-label width-9">Evaluate every</span> - <input - class="gf-form-input max-width-6" - type="text" - ng-model="ctrl.alert.frequency" - ng-blur="ctrl.checkFrequency()" - /> - </div> - <div class="gf-form max-width-11"> - <label class="gf-form-label width-5">For</label> - <input - type="text" - class="gf-form-input max-width-6 gf-form-input--has-help-icon" - ng-model="ctrl.alert.for" - spellcheck="false" - placeholder="5m" - ng-pattern="/(^\d+([dhms])$)|(0)|(^$)/" - /> - <info-popover mode="right-absolute"> - If an alert rule has a configured and the query violates the configured threshold, then it goes from OK - to Pending. Grafana does not send any notifications for that change. Once the alert rule has been - firing for more than For duration, then the alert changes to Alerting and sends alert notifications. - </info-popover> - </div> - </div> - <div class="gf-form" ng-if="ctrl.frequencyWarning"> - <label class="gf-form-label text-warning"> - <icon name="'exclamation-triangle'"></icon> {{ctrl.frequencyWarning}} - </label> - </div> - </div> - - <div class="gf-form-group"> - <h4 class="section-heading">Conditions</h4> - <div class="gf-form-inline" ng-repeat="conditionModel in ctrl.conditionModels"> - <div class="gf-form"> - <metric-segment-model - css-class="query-keyword width-5" - ng-if="$index" - property="conditionModel.operator.type" - options="ctrl.evalOperators" - custom="false" - ></metric-segment-model> - <span class="gf-form-label query-keyword width-5" ng-if="$index===0">WHEN</span> - </div> - <div class="gf-form"> - <query-part-editor - class="gf-form-label query-part width-9" - part="conditionModel.reducerPart" - handle-event="ctrl.handleReducerPartEvent(conditionModel, $event)" - > - </query-part-editor> - <span class="gf-form-label query-keyword">OF</span> - </div> - <div class="gf-form"> - <query-part-editor - class="gf-form-label query-part" - part="conditionModel.queryPart" - handle-event="ctrl.handleQueryPartEvent(conditionModel, $event)" - > - </query-part-editor> - </div> - <div class="gf-form"> - <metric-segment-model - property="conditionModel.evaluator.type" - options="ctrl.evalFunctions" - custom="false" - css-class="query-keyword" - on-change="ctrl.evaluatorTypeChanged(conditionModel.evaluator)" - ></metric-segment-model> - <input - class="gf-form-input max-width-9" - type="number" - step="any" - ng-hide="conditionModel.evaluator.params.length === 0" - ng-model="conditionModel.evaluator.params[0]" - ng-change="ctrl.evaluatorParamsChanged()" - /> - <label class="gf-form-label query-keyword" ng-show="conditionModel.evaluator.params.length === 2">TO</label> - <input - class="gf-form-input max-width-9" - type="number" - step="any" - ng-if="conditionModel.evaluator.params.length === 2" - ng-model="conditionModel.evaluator.params[1]" - ng-change="ctrl.evaluatorParamsChanged()" - /> - </div> - <div class="gf-form"> - <label class="gf-form-label"> - <a class="pointer" tabindex="1" ng-click="ctrl.removeCondition($index)"> - <icon name="'trash-alt'"></icon> - </a> - </label> - </div> - </div> - - <div class="gf-form"> - <label class="gf-form-label dropdown"> - <a class="pointer dropdown-toggle" data-toggle="dropdown"> - <icon name="'plus-circle'"></icon> - </a> - <ul class="dropdown-menu" role="menu"> - <li ng-repeat="ct in ctrl.conditionTypes" role="menuitem"> - <a ng-click="ctrl.addCondition(ct.value);">{{ct.text}}</a> - </li> - </ul> - </label> - </div> - </div> - - <div class="gf-form-group"> - <h4 class="section-heading">No data and error handling</h4> - <div class="gf-form-inline"> - <div class="gf-form"> - <span class="gf-form-label width-15">If no data or all values are null</span> - </div> - <div class="gf-form"> - <span class="gf-form-label query-keyword">set state to</span> - <div class="gf-form-select-wrapper"> - <select - class="gf-form-input" - ng-model="ctrl.alert.noDataState" - ng-options="f.value as f.text for f in ctrl.noDataModes" - > - </select> - </div> - </div> - </div> - - <div class="gf-form-inline"> - <div class="gf-form"> - <span class="gf-form-label width-15">If execution error or timeout</span> - </div> - <div class="gf-form"> - <span class="gf-form-label query-keyword">set state to</span> - <div class="gf-form-select-wrapper"> - <select - class="gf-form-input" - ng-model="ctrl.alert.executionErrorState" - ng-options="f.value as f.text for f in ctrl.executionErrorModes" - > - </select> - </div> - </div> - </div> - </div> - - <h4 class="section-heading">Notifications</h4> - <div class="gf-form-inline"> - <div class="gf-form"> - <span class="gf-form-label width-8">Send to</span> - </div> - <div class="gf-form" ng-repeat="nc in ctrl.alertNotifications"> - <span class="gf-form-label"> - <icon name="'{{nc.iconClass}}'"></icon> -  {{nc.name}} <span ng-if="nc.isDefault">(default)</span> - <icon - name="'times'" - class="pointer muted" - ng-click="ctrl.removeNotification(nc)" - ng-if="nc.isDefault === false" - ></icon> - </span> - </div> - <div class="gf-form"> - <metric-segment - segment="ctrl.addNotificationSegment" - get-options="ctrl.getNotifications()" - on-change="ctrl.notificationAdded()" - ></metric-segment> - </div> - </div> - <div class="gf-form gf-form--v-stretch"> - <span class="gf-form-label width-8">Message</span> - <textarea - class="gf-form-input gf-form-input--has-help-icon" - rows="10" - ng-model="ctrl.alert.message" - placeholder="Notification message details..." - ></textarea> - <info-popover mode="right-absolute"> - If you want to apply templating to the alert rule name, use the following syntax - ${Label} - </info-popover> - </div> - <div class="gf-form"> - <span class="gf-form-label width-8">Tags</span> - <div class="gf-form-group"> - <div class="gf-form-inline" ng-repeat="(name, value) in ctrl.alert.alertRuleTags"> - <label class="gf-form-label width-15">{{ name }}</label> - <input - class="gf-form-input width-15" - placeholder="Tag value..." - ng-model="ctrl.alert.alertRuleTags[name]" - type="text" - /> - <label class="gf-form-label"> - <a class="pointer" tabindex="1" ng-click="ctrl.removeAlertRuleTag(name)"> - <icon name="'trash-alt'"></icon> - </a> - </label> - </div> - <div class="gf-form-inline"> - <div class="gf-form"> - <input - class="gf-form-input width-15" - placeholder="New tag name..." - ng-model="ctrl.newAlertRuleTag.name" - type="text" - /> - <input - class="gf-form-input width-15" - placeholder="New tag value..." - ng-model="ctrl.newAlertRuleTag.value" - type="text" - /> - </div> - </div> - <div class="gf-form"> - <label class="gf-form-label"> - <a class="pointer" tabindex="1" ng-click="ctrl.addAlertRuleTag()"> - <icon name="'plus-circle'"></icon> Add Tag - </a> - </label> - </div> - </div> - </div> -</div> diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index 4ae31262daa88..ddc9d11277358 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -1,308 +1,223 @@ -import { uniq } from 'lodash'; -import React from 'react'; - import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; -import { NavLandingPage } from 'app/core/components/NavLandingPage/NavLandingPage'; import { config } from 'app/core/config'; -import { RouteDescriptor } from 'app/core/navigation/types'; +import { GrafanaRouteComponent, RouteDescriptor } from 'app/core/navigation/types'; import { AccessControlAction } from 'app/types'; import { evaluateAccess } from './unified/utils/access-control'; -const commonRoutes: RouteDescriptor[] = []; - -const legacyRoutes: RouteDescriptor[] = [ - ...commonRoutes, - { - path: '/alerting', - component: () => <NavLandingPage navId="alerting-legacy" />, - }, - { - path: '/alerting/list', - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AlertRuleListIndex" */ 'app/features/alerting/AlertRuleList') - ), - }, - { - path: '/alerting/ng/list', - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AlertRuleList" */ 'app/features/alerting/AlertRuleList') - ), - }, - { - path: '/alerting/notifications', - roles: config.unifiedAlertingEnabled ? () => ['Editor', 'Admin'] : undefined, - component: SafeDynamicImport( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsListPage') - ), - }, - { - path: '/alerting/notifications/templates/new', - roles: () => ['Editor', 'Admin'], - component: SafeDynamicImport( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsListPage') - ), - }, - { - path: '/alerting/notifications/templates/:id/edit', - roles: () => ['Editor', 'Admin'], - component: SafeDynamicImport( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsListPage') - ), - }, - { - path: '/alerting/notifications/receivers/new', - roles: () => ['Editor', 'Admin'], - component: SafeDynamicImport( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsListPage') - ), - }, - { - path: '/alerting/notifications/receivers/:id/edit', - roles: () => ['Editor', 'Admin'], - component: SafeDynamicImport( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsListPage') - ), - }, - { - path: '/alerting/notifications/global-config', - roles: () => ['Admin', 'Editor'], - component: SafeDynamicImport( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsListPage') - ), - }, - { - path: '/alerting/notification/new', - component: SafeDynamicImport( - () => import(/* webpackChunkName: "NewNotificationChannel" */ 'app/features/alerting/NewNotificationChannelPage') - ), - }, - { - path: '/alerting/notification/:id/edit', - component: SafeDynamicImport( - () => import(/* webpackChunkName: "EditNotificationChannel"*/ 'app/features/alerting/EditNotificationChannelPage') - ), - }, -]; - -const unifiedRoutes: RouteDescriptor[] = [ - ...commonRoutes, - { - path: '/alerting', - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AlertingHome" */ 'app/features/alerting/unified/home/Home') - ), - }, - { - path: '/alerting/home', - exact: false, - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AlertingHome" */ 'app/features/alerting/unified/home/Home') - ), - }, - { - path: '/alerting/list', - roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AlertRuleListIndex" */ 'app/features/alerting/unified/RuleList') - ), - }, - { - path: '/alerting/routes', - roles: evaluateAccess([ - AccessControlAction.AlertingNotificationsRead, - AccessControlAction.AlertingNotificationsExternalRead, - ]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AlertAmRoutes" */ 'app/features/alerting/unified/NotificationPolicies') - ), - }, - { - path: '/alerting/routes/mute-timing/new', - roles: evaluateAccess([ - AccessControlAction.AlertingNotificationsWrite, - AccessControlAction.AlertingNotificationsExternalWrite, - ]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "MuteTimings" */ 'app/features/alerting/unified/MuteTimings') - ), - }, - { - path: '/alerting/routes/mute-timing/edit', - roles: evaluateAccess([ - AccessControlAction.AlertingNotificationsWrite, - AccessControlAction.AlertingNotificationsExternalWrite, - ]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "MuteTimings" */ 'app/features/alerting/unified/MuteTimings') - ), - }, - { - path: '/alerting/silences', - roles: evaluateAccess([ - AccessControlAction.AlertingInstanceRead, - AccessControlAction.AlertingInstancesExternalRead, - ]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') - ), - }, - { - path: '/alerting/silence/new', - roles: evaluateAccess([ - AccessControlAction.AlertingInstanceCreate, - AccessControlAction.AlertingInstancesExternalWrite, - ]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') - ), - }, - { - path: '/alerting/silence/:id/edit', - roles: evaluateAccess([ - AccessControlAction.AlertingInstanceUpdate, - AccessControlAction.AlertingInstancesExternalWrite, - ]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') - ), - }, - { - path: '/alerting/notifications', - roles: evaluateAccess([ - AccessControlAction.AlertingNotificationsRead, - AccessControlAction.AlertingNotificationsExternalRead, - ]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') - ), - }, - { - path: '/alerting/notifications/:type/new', - roles: evaluateAccess([ - AccessControlAction.AlertingNotificationsWrite, - AccessControlAction.AlertingNotificationsExternalWrite, - ]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') - ), - }, - { - path: '/alerting/notifications/receivers/:id/edit', - roles: evaluateAccess([ - AccessControlAction.AlertingNotificationsWrite, - AccessControlAction.AlertingNotificationsExternalWrite, - AccessControlAction.AlertingNotificationsRead, - AccessControlAction.AlertingNotificationsExternalRead, - ]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') - ), - }, - { - path: '/alerting/notifications/:type/:id/edit', - roles: evaluateAccess([ - AccessControlAction.AlertingNotificationsWrite, - AccessControlAction.AlertingNotificationsExternalWrite, - ]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') - ), - }, - { - path: '/alerting/notifications/:type/:id/duplicate', - roles: evaluateAccess([ - AccessControlAction.AlertingNotificationsWrite, - AccessControlAction.AlertingNotificationsExternalWrite, - ]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') - ), - }, - { - path: '/alerting/notifications/:type', - roles: evaluateAccess([ - AccessControlAction.AlertingNotificationsWrite, - AccessControlAction.AlertingNotificationsExternalWrite, - ]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') - ), - }, - { - path: '/alerting/groups/', - roles: evaluateAccess([ - AccessControlAction.AlertingInstanceRead, - AccessControlAction.AlertingInstancesExternalRead, - ]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AlertGroups" */ 'app/features/alerting/unified/AlertGroups') - ), - }, - { - path: '/alerting/new/:type?', - pageClass: 'page-alerting', - roles: evaluateAccess([AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalWrite]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor') - ), - }, - { - path: '/alerting/:id/edit', - pageClass: 'page-alerting', - roles: evaluateAccess([AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleExternalWrite]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor') - ), - }, - { - path: '/alerting/:id/modify-export', - pageClass: 'page-alerting', - roles: evaluateAccess([AccessControlAction.AlertingRuleRead]), - component: SafeDynamicImport( - () => - import( - /* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/components/export/GrafanaModifyExport' - ) - ), - }, - { - path: '/alerting/:sourceName/:id/view', - pageClass: 'page-alerting', - roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AlertingRule"*/ 'app/features/alerting/unified/RuleViewer') - ), - }, - { - path: '/alerting/:sourceName/:name/find', - pageClass: 'page-alerting', - roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]), - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AlertingRedirectToRule"*/ 'app/features/alerting/unified/RedirectToRuleViewer') - ), - }, - { - path: '/alerting/admin', - roles: () => ['Admin'], - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AlertingAdmin" */ 'app/features/alerting/unified/Admin') - ), - }, -]; - export function getAlertingRoutes(cfg = config): RouteDescriptor[] { - if (cfg.unifiedAlertingEnabled) { - return unifiedRoutes; - } else if (cfg.alertingEnabled) { - return legacyRoutes; - } + const routes = [ + { + path: '/alerting', + component: importAlertingComponent( + () => import(/* webpackChunkName: "AlertingHome" */ 'app/features/alerting/unified/home/Home') + ), + }, + { + path: '/alerting/home', + exact: false, + component: importAlertingComponent( + () => import(/* webpackChunkName: "AlertingHome" */ 'app/features/alerting/unified/home/Home') + ), + }, + { + path: '/alerting/list', + roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "AlertRuleListIndex" */ 'app/features/alerting/unified/RuleList') + ), + }, + { + path: '/alerting/routes', + roles: evaluateAccess([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsExternalRead, + ]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "AlertAmRoutes" */ 'app/features/alerting/unified/NotificationPolicies') + ), + }, + { + path: '/alerting/routes/mute-timing/new', + roles: evaluateAccess([ + AccessControlAction.AlertingNotificationsWrite, + AccessControlAction.AlertingNotificationsExternalWrite, + ]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "MuteTimings" */ 'app/features/alerting/unified/MuteTimings') + ), + }, + { + path: '/alerting/routes/mute-timing/edit', + roles: evaluateAccess([ + AccessControlAction.AlertingNotificationsWrite, + AccessControlAction.AlertingNotificationsExternalWrite, + ]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "MuteTimings" */ 'app/features/alerting/unified/MuteTimings') + ), + }, + { + path: '/alerting/silences', + roles: evaluateAccess([ + AccessControlAction.AlertingInstanceRead, + AccessControlAction.AlertingInstancesExternalRead, + ]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') + ), + }, + { + path: '/alerting/silence/new', + roles: evaluateAccess([ + AccessControlAction.AlertingInstanceCreate, + AccessControlAction.AlertingInstancesExternalWrite, + ]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') + ), + }, + { + path: '/alerting/silence/:id/edit', + roles: evaluateAccess([ + AccessControlAction.AlertingInstanceUpdate, + AccessControlAction.AlertingInstancesExternalWrite, + ]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') + ), + }, + { + path: '/alerting/notifications', + roles: evaluateAccess([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsExternalRead, + ]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') + ), + }, + { + path: '/alerting/notifications/:type/new', + roles: evaluateAccess([ + AccessControlAction.AlertingNotificationsWrite, + AccessControlAction.AlertingNotificationsExternalWrite, + ]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') + ), + }, + { + path: '/alerting/notifications/receivers/:id/edit', + roles: evaluateAccess([ + AccessControlAction.AlertingNotificationsWrite, + AccessControlAction.AlertingNotificationsExternalWrite, + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsExternalRead, + ]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') + ), + }, + { + path: '/alerting/notifications/:type/:id/edit', + roles: evaluateAccess([ + AccessControlAction.AlertingNotificationsWrite, + AccessControlAction.AlertingNotificationsExternalWrite, + ]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') + ), + }, + { + path: '/alerting/notifications/:type/:id/duplicate', + roles: evaluateAccess([ + AccessControlAction.AlertingNotificationsWrite, + AccessControlAction.AlertingNotificationsExternalWrite, + ]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') + ), + }, + { + path: '/alerting/notifications/:type', + roles: evaluateAccess([ + AccessControlAction.AlertingNotificationsWrite, + AccessControlAction.AlertingNotificationsExternalWrite, + ]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') + ), + }, + { + path: '/alerting/groups/', + roles: evaluateAccess([ + AccessControlAction.AlertingInstanceRead, + AccessControlAction.AlertingInstancesExternalRead, + ]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "AlertGroups" */ 'app/features/alerting/unified/AlertGroups') + ), + }, + { + path: '/alerting/new/:type?', + pageClass: 'page-alerting', + roles: evaluateAccess([AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalWrite]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor') + ), + }, + { + path: '/alerting/:id/edit', + pageClass: 'page-alerting', + roles: evaluateAccess([AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleExternalWrite]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor') + ), + }, + { + path: '/alerting/:id/modify-export', + pageClass: 'page-alerting', + roles: evaluateAccess([AccessControlAction.AlertingRuleRead]), + component: importAlertingComponent( + () => + import( + /* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/components/export/GrafanaModifyExport' + ) + ), + }, + { + path: '/alerting/:sourceName/:id/view', + pageClass: 'page-alerting', + roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "AlertingRule"*/ 'app/features/alerting/unified/RuleViewer') + ), + }, + { + path: '/alerting/:sourceName/:name/find', + pageClass: 'page-alerting', + roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]), + component: importAlertingComponent( + () => + import(/* webpackChunkName: "AlertingRedirectToRule"*/ 'app/features/alerting/unified/RedirectToRuleViewer') + ), + }, + { + path: '/alerting/admin', + roles: () => ['Admin'], + component: importAlertingComponent( + () => import(/* webpackChunkName: "AlertingAdmin" */ 'app/features/alerting/unified/Admin') + ), + }, + ]; + + return routes; +} - const uniquePaths = uniq([...legacyRoutes, ...unifiedRoutes].map((route) => route.path)); - return uniquePaths.map((path) => ({ - path, - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AlertingFeatureTogglePage"*/ 'app/features/alerting/FeatureTogglePage') - ), - })); +// this function will always load the "feature disabled" component for all alerting routes +function importAlertingComponent(loader: () => any): GrafanaRouteComponent { + const featureDisabledPageLoader = () => + import(/* webpackChunkName: "AlertingDisabled" */ 'app/features/alerting/unified/AlertingNotEnabled'); + return SafeDynamicImport(config.unifiedAlertingEnabled ? loader : featureDisabledPageLoader); } diff --git a/public/app/features/alerting/state/actions.ts b/public/app/features/alerting/state/actions.ts deleted file mode 100644 index 84f5ac3c86e81..0000000000000 --- a/public/app/features/alerting/state/actions.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; -import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification'; -import { AlertRuleDTO, NotifierDTO, ThunkResult } from 'app/types'; - -import { loadAlertRules, loadedAlertRules, notificationChannelLoaded, setNotificationChannels } from './reducers'; - -export function getAlertRulesAsync(options: { state: string }): ThunkResult<void> { - return async (dispatch) => { - dispatch(loadAlertRules()); - const rules: AlertRuleDTO[] = await getBackendSrv().get('/api/alerts', options); - dispatch(loadedAlertRules(rules)); - }; -} - -export function togglePauseAlertRule(id: number, options: { paused: boolean }): ThunkResult<void> { - return async (dispatch) => { - await getBackendSrv().post(`/api/alerts/${id}/pause`, options); - const stateFilter = locationService.getSearchObject().state || 'all'; - dispatch(getAlertRulesAsync({ state: stateFilter.toString() })); - }; -} - -export function createNotificationChannel(data: any): ThunkResult<Promise<void>> { - return async (dispatch) => { - try { - await getBackendSrv().post(`/api/alert-notifications`, data); - dispatch(notifyApp(createSuccessNotification('Notification created'))); - locationService.push('/alerting/notifications'); - } catch (error) { - if (isFetchError(error)) { - dispatch(notifyApp(createErrorNotification(error.data.error))); - } - } - }; -} - -export function updateNotificationChannel(data: any): ThunkResult<void> { - return async (dispatch) => { - try { - await getBackendSrv().put(`/api/alert-notifications/${data.id}`, data); - dispatch(notifyApp(createSuccessNotification('Notification updated'))); - } catch (error) { - if (isFetchError(error)) { - dispatch(notifyApp(createErrorNotification(error.data.error))); - } - } - }; -} - -export function testNotificationChannel(data: any): ThunkResult<void> { - return async (dispatch, getState) => { - const channel = getState().notificationChannel.notificationChannel; - await getBackendSrv().post('/api/alert-notifications/test', { id: channel.id, ...data }); - }; -} - -export function loadNotificationTypes(): ThunkResult<void> { - return async (dispatch) => { - const alertNotifiers: NotifierDTO[] = await getBackendSrv().get(`/api/alert-notifiers`); - - const notificationTypes = alertNotifiers.sort((o1, o2) => { - if (o1.name > o2.name) { - return 1; - } - return -1; - }); - - dispatch(setNotificationChannels(notificationTypes)); - }; -} - -export function loadNotificationChannel(id: number): ThunkResult<void> { - return async (dispatch) => { - await dispatch(loadNotificationTypes()); - const notificationChannel = await getBackendSrv().get(`/api/alert-notifications/${id}`); - dispatch(notificationChannelLoaded(notificationChannel)); - }; -} diff --git a/public/app/features/alerting/unified/AlertingNotEnabled.tsx b/public/app/features/alerting/unified/AlertingNotEnabled.tsx new file mode 100644 index 0000000000000..7d2b89ae9e5df --- /dev/null +++ b/public/app/features/alerting/unified/AlertingNotEnabled.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { NavModel } from '@grafana/data'; +import { Page } from 'app/core/components/Page/Page'; + +export default function FeatureTogglePage() { + const navModel: NavModel = { + node: { + text: 'Alerting is not enabled', + hideFromBreadcrumbs: true, + subTitle: 'To enable alerting, enable it in the Grafana config', + }, + main: { + text: 'Alerting is not enabled', + }, + }; + + return ( + <Page navModel={navModel}> + <Page.Contents> + <pre> + {`[unified_alerting] +enabled = true +`} + </pre> + </Page.Contents> + </Page> + ); +} diff --git a/public/app/features/alerting/unified/AlertsFolderView.test.tsx b/public/app/features/alerting/unified/AlertsFolderView.test.tsx index ad59a43299dd0..4ae40d245b4a6 100644 --- a/public/app/features/alerting/unified/AlertsFolderView.test.tsx +++ b/public/app/features/alerting/unified/AlertsFolderView.test.tsx @@ -8,6 +8,7 @@ import { FolderState } from 'app/types'; import { CombinedRuleNamespace } from 'app/types/unified-alerting'; import { AlertsFolderView } from './AlertsFolderView'; +import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces'; import { mockCombinedRule } from './mocks'; import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; @@ -21,7 +22,7 @@ const ui = { }, }; -const combinedNamespaceMock = jest.fn<CombinedRuleNamespace[], any>(); +const combinedNamespaceMock = jest.fn(useCombinedRuleNamespaces); jest.mock('./hooks/useCombinedRuleNamespaces', () => ({ useCombinedRuleNamespaces: () => combinedNamespaceMock(), })); @@ -41,13 +42,14 @@ const mockFolder = (folderOverride: Partial<FolderState> = {}): FolderState => { }; describe('AlertsFolderView tests', () => { - it('Should display grafana alert rules when the namespace name matches the folder name', () => { + it('Should display grafana alert rules when the folder uid matches the name space uid', () => { // Arrange const folder = mockFolder(); const grafanaNamespace: CombinedRuleNamespace = { name: folder.title, rulesSource: GRAFANA_RULES_SOURCE_NAME, + uid: 'folder-1', groups: [ { name: 'group1', @@ -90,13 +92,14 @@ describe('AlertsFolderView tests', () => { expect(alertRows[5]).toHaveTextContent('Test Alert 6'); }); - it('Should not display alert rules when the namespace name does not match the folder name', () => { + it('Should not display alert rules when the namespace uid does not match the folder uid', () => { // Arrange const folder = mockFolder(); const grafanaNamespace: CombinedRuleNamespace = { name: 'Folder without alerts', rulesSource: GRAFANA_RULES_SOURCE_NAME, + uid: 'folder-2', groups: [ { name: 'default', @@ -129,6 +132,7 @@ describe('AlertsFolderView tests', () => { const grafanaNamespace: CombinedRuleNamespace = { name: folder.title, rulesSource: GRAFANA_RULES_SOURCE_NAME, + uid: 'folder-1', groups: [ { name: 'default', @@ -161,6 +165,7 @@ describe('AlertsFolderView tests', () => { const grafanaNamespace: CombinedRuleNamespace = { name: folder.title, rulesSource: GRAFANA_RULES_SOURCE_NAME, + uid: 'folder-1', groups: [ { name: 'default', diff --git a/public/app/features/alerting/unified/AlertsFolderView.tsx b/public/app/features/alerting/unified/AlertsFolderView.tsx index c07a3e65fe492..ff71c426a72a2 100644 --- a/public/app/features/alerting/unified/AlertsFolderView.tsx +++ b/public/app/features/alerting/unified/AlertsFolderView.tsx @@ -50,7 +50,8 @@ export const AlertsFolderView = ({ folder }: Props) => { const { nameFilter, labelFilter, sortOrder, setNameFilter, setLabelFilter, setSortOrder } = useAlertsFolderViewParams(); - const matchingNamespace = combinedNamespaces.find((namespace) => namespace.name === folder.title); + const matchingNamespace = combinedNamespaces.find((namespace) => namespace.uid === folder.uid); + const alertRules = matchingNamespace?.groups.flatMap((group) => group.rules) ?? []; const filteredRules = filterAndSortRules(alertRules, nameFilter, labelFilter, sortOrder ?? SortOrder.Ascending); @@ -140,8 +141,8 @@ function useAlertsFolderViewParams() { sortParam === SortOrder.Ascending ? SortOrder.Ascending : sortParam === SortOrder.Descending - ? SortOrder.Descending - : undefined + ? SortOrder.Descending + : undefined ); useDebounce( diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index 2cc69d578ddcd..ef58a579ec255 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -1,12 +1,16 @@ +import { isEmpty } from 'lodash'; + import { dateTime } from '@grafana/data'; -import { faro, LogLevel as GrafanaLogLevel } from '@grafana/faro-web-sdk'; -import { getBackendSrv, logError } from '@grafana/runtime'; +import { createMonitoringLogger, getBackendSrv } from '@grafana/runtime'; import { config, reportInteraction } from '@grafana/runtime/src'; import { contextSrv } from 'app/core/core'; import { RuleNamespace } from '../../../types/unified-alerting'; import { RulerRulesConfigDTO } from '../../../types/unified-alerting-dto'; +import { getSearchFilterFromQuery, RulesFilter } from './search/rulesSearchParser'; +import { RuleFormType } from './types/rule-form'; + export const USER_CREATION_MIN_DAYS = 7; export const LogMessages = { @@ -22,33 +26,29 @@ export const LogMessages = { unknownMessageFromError: 'unknown messageFromError', }; -// logInfo from '@grafana/runtime' should be used, but it doesn't handle Grafana JS Agent correctly -export function logInfo(message: string, context: Record<string, string | number> = {}) { - if (config.grafanaJavascriptAgent.enabled) { - faro.api.pushLog([message], { - level: GrafanaLogLevel.INFO, - context: { ...context, module: 'Alerting' }, - }); - } -} +const { logInfo, logError, logMeasurement } = createMonitoringLogger('features.alerting', { module: 'Alerting' }); -export function logAlertingError(error: Error, context: Record<string, string | number> = {}) { - logError(error, { ...context, module: 'Alerting' }); -} +export { logInfo, logError, logMeasurement }; // eslint-disable-next-line @typescript-eslint/no-explicit-any export function withPerformanceLogging<TFunc extends (...args: any[]) => Promise<any>>( + type: string, func: TFunc, - message: string, context: Record<string, string> ): (...args: Parameters<TFunc>) => Promise<Awaited<ReturnType<TFunc>>> { return async function (...args) { const startLoadingTs = performance.now(); + const response = await func(...args); - logInfo(message, { - loadTimeMs: (performance.now() - startLoadingTs).toFixed(0), - ...context, - }); + const loadTimesMs = performance.now() - startLoadingTs; + + logMeasurement( + type, + { + loadTimesMs, + }, + context + ); return response; }; @@ -56,8 +56,8 @@ export function withPerformanceLogging<TFunc extends (...args: any[]) => Promise // eslint-disable-next-line @typescript-eslint/no-explicit-any export function withPromRulesMetadataLogging<TFunc extends (...args: any[]) => Promise<RuleNamespace[]>>( + type: string, func: TFunc, - message: string, context: Record<string, string> ) { return async (...args: Parameters<TFunc>) => { @@ -66,13 +66,16 @@ export function withPromRulesMetadataLogging<TFunc extends (...args: any[]) => P const { namespacesCount, groupsCount, rulesCount } = getPromRulesMetadata(response); - logInfo(message, { - loadTimeMs: (performance.now() - startLoadingTs).toFixed(0), - namespacesCount, - groupsCount, - rulesCount, - ...context, - }); + logMeasurement( + type, + { + loadTimeMs: performance.now() - startLoadingTs, + namespacesCount, + groupsCount, + rulesCount, + }, + context + ); return response; }; } @@ -83,9 +86,9 @@ function getPromRulesMetadata(promRules: RuleNamespace[]) { const rulesCount = promRules.flatMap((ns) => ns.groups).flatMap((g) => g.rules).length; const metadata = { - namespacesCount: namespacesCount.toFixed(0), - groupsCount: groupsCount.toFixed(0), - rulesCount: rulesCount.toFixed(0), + namespacesCount: namespacesCount, + groupsCount: groupsCount, + rulesCount: rulesCount, }; return metadata; @@ -93,8 +96,8 @@ function getPromRulesMetadata(promRules: RuleNamespace[]) { // eslint-disable-next-line @typescript-eslint/no-explicit-any export function withRulerRulesMetadataLogging<TFunc extends (...args: any[]) => Promise<RulerRulesConfigDTO>>( + type: string, func: TFunc, - message: string, context: Record<string, string> ) { return async (...args: Parameters<TFunc>) => { @@ -103,26 +106,29 @@ export function withRulerRulesMetadataLogging<TFunc extends (...args: any[]) => const { namespacesCount, groupsCount, rulesCount } = getRulerRulesMetadata(response); - logInfo(message, { - loadTimeMs: (performance.now() - startLoadingTs).toFixed(0), - namespacesCount, - groupsCount, - rulesCount, - ...context, - }); + logMeasurement( + type, + { + namespacesCount, + groupsCount, + rulesCount, + loadTimeMs: performance.now() - startLoadingTs, + }, + context + ); return response; }; } function getRulerRulesMetadata(rulerRules: RulerRulesConfigDTO) { - const namespacesCount = Object.keys(rulerRules).length; + const namespaces = Object.keys(rulerRules); const groups = Object.values(rulerRules).flatMap((groups) => groups); const rules = groups.flatMap((group) => group.rules); return { - namespacesCount: namespacesCount.toFixed(0), - groupsCount: groups.length.toFixed(0), - rulesCount: rules.length.toFixed(0), + namespacesCount: namespaces.length, + groupsCount: groups.length, + rulesCount: rules.length, }; } @@ -155,27 +161,17 @@ export const trackRuleListNavigation = async ( reportInteraction('grafana_alerting_navigation', props); }; -export const trackNewAlerRuleFormSaved = async (props: AlertRuleTrackingProps) => { - const isNew = await isNewUser(); - if (isNew) { - return; - } +export const trackAlertRuleFormSaved = (props: { formAction: 'create' | 'update'; ruleType?: RuleFormType }) => { reportInteraction('grafana_alerting_rule_creation', props); }; -export const trackNewAlerRuleFormCancelled = async (props: AlertRuleTrackingProps) => { - const isNew = await isNewUser(); - if (isNew) { - return; - } +export const trackAlertRuleFormCancelled = (props: { formAction: 'create' | 'update' }) => { reportInteraction('grafana_alerting_rule_aborted', props); }; -export const trackNewAlerRuleFormError = async (props: AlertRuleTrackingProps & { error: string }) => { - const isNew = await isNewUser(); - if (isNew) { - return; - } +export const trackAlertRuleFormError = ( + props: AlertRuleTrackingProps & { error: string; formAction: 'create' | 'update' } +) => { reportInteraction('grafana_alerting_rule_form_error', props); }; @@ -188,6 +184,55 @@ export const trackInsightsFeedback = async (props: { useful: boolean; panel: str reportInteraction('grafana_alerting_insights', { ...defaults, ...props }); }; +interface RulesSearchInteractionPayload { + filter: string; + triggeredBy: 'typing' | 'component'; +} + +function trackRulesSearchInteraction(payload: RulesSearchInteractionPayload) { + reportInteraction('grafana_alerting_rules_search', { ...payload }); +} + +export function trackRulesSearchInputInteraction({ oldQuery, newQuery }: { oldQuery: string; newQuery: string }) { + try { + const oldFilter = getSearchFilterFromQuery(oldQuery); + const newFilter = getSearchFilterFromQuery(newQuery); + + const oldFilterTerms = extractFilterKeys(oldFilter); + const newFilterTerms = extractFilterKeys(newFilter); + + const newTerms = newFilterTerms.filter((term) => !oldFilterTerms.includes(term)); + newTerms.forEach((term) => { + trackRulesSearchInteraction({ filter: term, triggeredBy: 'typing' }); + }); + } catch (e: unknown) { + if (e instanceof Error) { + logError(e); + } + } +} + +function extractFilterKeys(filter: RulesFilter) { + return Object.entries(filter) + .filter(([_, value]) => !isEmpty(value)) + .map(([key]) => key); +} + +export function trackRulesSearchComponentInteraction(filter: keyof RulesFilter) { + trackRulesSearchInteraction({ filter, triggeredBy: 'component' }); +} + +export function trackRulesListViewChange(payload: { view: string }) { + reportInteraction('grafana_alerting_rules_list_mode', { ...payload }); +} +export function trackSwitchToSimplifiedRouting() { + reportInteraction('grafana_alerting_switch_to_simplified_routing'); +} + +export function trackSwitchToPoliciesRouting() { + reportInteraction('grafana_alerting_switch_to_policies_routing'); +} + export type AlertRuleTrackingProps = { user_id: number; grafana_version?: string; diff --git a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx index 2c8ba1f1190f0..747f66b4eb1e4 100644 --- a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx @@ -1,3 +1,4 @@ +import 'whatwg-fetch'; import { render, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react'; import { setupServer } from 'msw/node'; import React from 'react'; @@ -8,8 +9,8 @@ import { byRole, byTestId, byText } from 'testing-library-selector'; import { selectors } from '@grafana/e2e-selectors/src'; import { config, setBackendSrv, setDataSourceSrv } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; +import { DashboardSearchItem, DashboardSearchItemType } from 'app/features/search/types'; import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; -import 'whatwg-fetch'; import { RuleWithLocation } from 'app/types/unified-alerting'; import { @@ -156,7 +157,9 @@ describe('CloneRuleEditor', function () { 'folder-one': [{ name: 'group1', interval: '20s', rules: [originRule] }], }); - mockSearchApi(server).search([]); + mockSearchApi(server).search([ + mockDashboardSearchItem({ title: 'folder-one', uid: '123', type: DashboardSearchItemType.DashDB }), + ]); mockAlertmanagerConfigResponse(server, GRAFANA_RULES_SOURCE_NAME, amConfig); render(<CloneRuleEditor sourceRuleId={{ uid: 'grafana-rule-1', ruleSourceName: 'grafana' }} />, { @@ -209,7 +212,15 @@ describe('CloneRuleEditor', function () { rules: [originRule], }); - mockSearchApi(server).search([]); + mockSearchApi(server).search([ + mockDashboardSearchItem({ + title: 'folder-one', + uid: '123', + type: DashboardSearchItemType.DashDB, + folderTitle: 'folder-one', + folderUid: '123', + }), + ]); mockAlertmanagerConfigResponse(server, GRAFANA_RULES_SOURCE_NAME, amConfig); render( @@ -362,3 +373,18 @@ describe('CloneRuleEditor', function () { }); }); }); + +function mockDashboardSearchItem(searchItem: Partial<DashboardSearchItem>) { + return { + title: '', + uid: '', + type: DashboardSearchItemType.DashDB, + url: '', + uri: '', + items: [], + tags: [], + slug: '', + isStarred: false, + ...searchItem, + }; +} diff --git a/public/app/features/alerting/unified/GrafanaRuleQueryViewer.test.tsx b/public/app/features/alerting/unified/GrafanaRuleQueryViewer.test.tsx index 6539c35b67764..b77aba3e2e3b0 100644 --- a/public/app/features/alerting/unified/GrafanaRuleQueryViewer.test.tsx +++ b/public/app/features/alerting/unified/GrafanaRuleQueryViewer.test.tsx @@ -1,14 +1,17 @@ -import { render } from '@testing-library/react'; -import { noop } from 'lodash'; +import { render, waitFor } from '@testing-library/react'; import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; import { DataSourceRef } from '@grafana/schema'; import { AlertQuery } from 'app/types/unified-alerting-dto'; import { GrafanaRuleQueryViewer } from './GrafanaRuleQueryViewer'; +import { mockCombinedRule } from './mocks'; describe('GrafanaRuleQueryViewer', () => { - it('renders without crashing', () => { + it('renders without crashing', async () => { + const rule = mockCombinedRule(); + const getDataSourceQuery = (refId: string) => { const query: AlertQuery = { refId: refId, @@ -72,9 +75,11 @@ describe('GrafanaRuleQueryViewer', () => { getExpression('D', { type: '' }), ]; const { getByTestId } = render( - <GrafanaRuleQueryViewer queries={[...queries, ...expressions]} condition="A" onTimeRangeChange={noop} /> + <GrafanaRuleQueryViewer queries={[...queries, ...expressions]} condition="A" rule={rule} />, + { wrapper: TestProvider } ); - expect(getByTestId('queries-container')).toHaveStyle('flex-wrap: wrap'); + + await waitFor(() => expect(getByTestId('queries-container')).toHaveStyle('flex-wrap: wrap')); expect(getByTestId('expressions-container')).toHaveStyle('flex-wrap: wrap'); }); }); diff --git a/public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx b/public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx index 995ce8d64881d..83cf88bfe3724 100644 --- a/public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx +++ b/public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx @@ -1,14 +1,15 @@ import { css, cx } from '@emotion/css'; -import { dump } from 'js-yaml'; import { keyBy, startCase } from 'lodash'; import React from 'react'; -import { DataSourceInstanceSettings, GrafanaTheme2, PanelData, RelativeTimeRange } from '@grafana/data'; +import { DataSourceInstanceSettings, DataSourceRef, GrafanaTheme2, PanelData, urlUtil } from '@grafana/data'; +import { secondsToHms } from '@grafana/data/src/datetime/rangeutil'; import { config } from '@grafana/runtime'; -import { Badge, Stack, useStyles2 } from '@grafana/ui'; -import { mapRelativeTimeRangeToOption } from '@grafana/ui/src/components/DateTimePickers/RelativeTimeRangePicker/utils'; +import { Preview } from '@grafana/sql/src/components/visual-query-builder/Preview'; +import { Badge, ErrorBoundaryAlert, LinkButton, Stack, Text, useStyles2 } from '@grafana/ui'; +import { CombinedRule } from 'app/types/unified-alerting'; -import { AlertQuery } from '../../../types/unified-alerting-dto'; +import { AlertDataQuery, AlertQuery } from '../../../types/unified-alerting-dto'; import { isExpressionQuery } from '../../expressions/guards'; import { downsamplingTypes, @@ -22,51 +23,47 @@ import { } from '../../expressions/types'; import alertDef, { EvalFunction } from '../state/alertDef'; +import { Spacer } from './components/Spacer'; +import { WithReturnButton } from './components/WithReturnButton'; import { ExpressionResult } from './components/expressions/Expression'; import { getThresholdsForQueries, ThresholdDefinition } from './components/rule-editor/util'; import { RuleViewerVisualization } from './components/rule-viewer/RuleViewerVisualization'; +import { DatasourceModelPreview } from './components/rule-viewer/tabs/Query/DataSourceModelPreview'; +import { AlertRuleAction, useAlertRuleAbility } from './hooks/useAbilities'; interface GrafanaRuleViewerProps { + rule: CombinedRule; queries: AlertQuery[]; condition: string; evalDataByQuery?: Record<string, PanelData>; - evalTimeRanges?: Record<string, RelativeTimeRange>; - onTimeRangeChange: (queryRef: string, timeRange: RelativeTimeRange) => void; } -export function GrafanaRuleQueryViewer({ - queries, - condition, - evalDataByQuery = {}, - evalTimeRanges = {}, - onTimeRangeChange, -}: GrafanaRuleViewerProps) { +export function GrafanaRuleQueryViewer({ rule, queries, condition, evalDataByQuery = {} }: GrafanaRuleViewerProps) { const dsByUid = keyBy(Object.values(config.datasources), (ds) => ds.uid); const dataQueries = queries.filter((q) => !isExpressionQuery(q.model)); const expressions = queries.filter((q) => isExpressionQuery(q.model)); const styles = useStyles2(getExpressionViewerStyles); - const thresholds = getThresholdsForQueries(queries); + const thresholds = getThresholdsForQueries(queries, condition); return ( - <Stack gap={2} direction="column"> + <Stack gap={1} direction="column" flex={'1 1 320px'}> <div className={styles.maxWidthContainer}> - <Stack gap={2} wrap="wrap" data-testid="queries-container"> + <Stack gap={1} wrap="wrap" data-testid="queries-container"> {dataQueries.map(({ model, relativeTimeRange, refId, datasourceUid }, index) => { const dataSource = dsByUid[datasourceUid]; return ( <QueryPreview + rule={rule} key={index} refId={refId} isAlertCondition={condition === refId} model={model} relativeTimeRange={relativeTimeRange} - evalTimeRange={evalTimeRanges[refId]} dataSource={dataSource} thresholds={thresholds[refId]} queryData={evalDataByQuery[refId]} - onEvalTimeRangeChange={(timeRange) => onTimeRangeChange(refId, timeRange)} /> ); })} @@ -97,56 +94,100 @@ export function GrafanaRuleQueryViewer({ } interface QueryPreviewProps extends Pick<AlertQuery, 'refId' | 'relativeTimeRange' | 'model'> { + rule: CombinedRule; isAlertCondition: boolean; dataSource?: DataSourceInstanceSettings; queryData?: PanelData; thresholds?: ThresholdDefinition; - evalTimeRange?: RelativeTimeRange; - onEvalTimeRangeChange: (timeRange: RelativeTimeRange) => void; } export function QueryPreview({ refId, - relativeTimeRange, + rule, thresholds, model, dataSource, queryData, - evalTimeRange, - onEvalTimeRangeChange, + relativeTimeRange, }: QueryPreviewProps) { const styles = useStyles2(getQueryPreviewStyles); + const isExpression = isExpressionQuery(model); + const [exploreSupported, exploreAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Explore); + const canExplore = exploreSupported && exploreAllowed; + + const headerItems: React.ReactNode[] = []; + + if (dataSource) { + const dataSourceName = dataSource.name ?? '[[Data source not found]]'; + const dataSourceImgUrl = dataSource.meta.info.logos.small; + + headerItems.push(<DataSourceBadge name={dataSourceName} imgUrl={dataSourceImgUrl} key="datasource" />); + } - // relativeTimeRange is what is defined for a query - // evalTimeRange is temporary value which the user can change - const headerItems = [dataSource?.name ?? '[[Data source not found]]']; if (relativeTimeRange) { - headerItems.push(mapRelativeTimeRangeToOption(relativeTimeRange).display); + headerItems.push( + <Text color="secondary" key="timerange"> + {secondsToHms(relativeTimeRange.from)} to now + </Text> + ); + } + + let exploreLink: string | undefined = undefined; + if (!isExpression && canExplore) { + exploreLink = dataSource && createExploreLink(dataSource, model); } return ( - <QueryBox refId={refId} headerItems={headerItems} className={styles.contentBox}> - <pre className={styles.code}> - <code>{dump(model)}</code> - </pre> - {dataSource && ( - <RuleViewerVisualization - refId={refId} - dsSettings={dataSource} - model={model} - data={queryData} - thresholds={thresholds} - relativeTimeRange={evalTimeRange} - onTimeRangeChange={onEvalTimeRangeChange} - className={styles.visualization} - /> - )} - </QueryBox> + <> + <QueryBox refId={refId} headerItems={headerItems} exploreLink={exploreLink}> + <div className={styles.queryPreviewWrapper}> + <ErrorBoundaryAlert> + {model && dataSource && <DatasourceModelPreview model={model} dataSource={dataSource} />} + </ErrorBoundaryAlert> + </div> + </QueryBox> + {dataSource && <RuleViewerVisualization data={queryData} thresholds={thresholds} />} + </> + ); +} + +function createExploreLink(settings: DataSourceRef, model: AlertDataQuery): string { + const { uid, type } = settings; + const { refId, ...rest } = model; + + /* + In my testing I've found some alerts that don't have a data source embedded inside the model. + At this moment in time it is unclear to me why some alert definitions not have a data source embedded in the model. + + I don't think that should happen here, the fact that the datasource ref is sometimes missing here is a symptom of another cause. (Gilles) + */ + return urlUtil.renderUrl(`${config.appSubUrl}/explore`, { + left: JSON.stringify({ + datasource: settings.uid, + queries: [{ refId: 'A', ...rest, datasource: { type, uid } }], + range: { from: 'now-1h', to: 'now' }, + }), + }); +} + +interface DataSourceBadgeProps { + name: string; + imgUrl: string; +} + +function DataSourceBadge({ name, imgUrl }: DataSourceBadgeProps) { + const styles = useStyles2(getQueryPreviewStyles); + + return ( + <div className={styles.dataSource} key="datasource"> + <img src={imgUrl} width={16} alt={name} /> + {name} + </div> ); } const getQueryPreviewStyles = (theme: GrafanaTheme2) => ({ - code: css` + queryPreviewWrapper: css` margin: ${theme.spacing(1)}; `, contentBox: css` @@ -155,6 +196,14 @@ const getQueryPreviewStyles = (theme: GrafanaTheme2) => ({ visualization: css` padding: ${theme.spacing(1)}; `, + dataSource: css({ + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + padding: theme.spacing(0.5, 1), + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }), }); interface ExpressionPreviewProps extends Pick<AlertQuery, 'refId'> { @@ -182,14 +231,26 @@ function ExpressionPreview({ refId, model, evalData, isAlertCondition }: Express case ExpressionQueryType.threshold: return <ThresholdExpressionViewer model={model} />; + case ExpressionQueryType.sql: + return <Preview rawSql={model.expression || ''} datasourceType={model.datasource?.type} />; + default: return <>Expression not supported: {model.type}</>; } } return ( - <QueryBox refId={refId} headerItems={[startCase(model.type)]} isAlertCondition={isAlertCondition}> + <QueryBox + refId={refId} + headerItems={[ + <Text color="secondary" key="expression-type"> + {startCase(model.type)} + </Text>, + ]} + isAlertCondition={isAlertCondition} + > {renderPreview()} + <Spacer /> {evalData && <ExpressionResult series={evalData.series} isAlertCondition={isAlertCondition} />} </QueryBox> ); @@ -197,27 +258,29 @@ function ExpressionPreview({ refId, model, evalData, isAlertCondition }: Express interface QueryBoxProps extends React.PropsWithChildren<unknown> { refId: string; - headerItems?: string[]; + headerItems?: React.ReactNode; isAlertCondition?: boolean; - className?: string; + exploreLink?: string; } -function QueryBox({ refId, headerItems = [], children, isAlertCondition, className }: QueryBoxProps) { +function QueryBox({ refId, headerItems = [], children, isAlertCondition, exploreLink }: QueryBoxProps) { const styles = useStyles2(getQueryBoxStyles); return ( - <div className={cx(styles.container, className)}> + <div className={cx(styles.container)}> <header className={styles.header}> <span className={styles.refId}>{refId}</span> - {headerItems.map((item, index) => ( - <span key={index} className={styles.textBlock}> - {item} - </span> - ))} - {isAlertCondition && ( - <div className={styles.conditionIndicator}> - <Badge color="green" icon="check" text="Alert condition" /> - </div> + {headerItems} + <Spacer /> + {isAlertCondition && <Badge color="green" icon="check" text="Alert condition" />} + {exploreLink && ( + <WithReturnButton + component={ + <LinkButton size="md" variant="secondary" icon="compass" href={exploreLink}> + View in Explore + </LinkButton> + } + /> )} </header> {children} @@ -226,11 +289,14 @@ function QueryBox({ refId, headerItems = [], children, isAlertCondition, classNa } const getQueryBoxStyles = (theme: GrafanaTheme2) => ({ - container: css` - flex: 1 0 25%; - border: 1px solid ${theme.colors.border.strong}; - max-width: 100%; - `, + container: css({ + flex: '1 0 25%', + border: `1px solid ${theme.colors.border.weak}`, + maxWidth: '100%', + borderRadius: theme.shape.radius.default, + display: 'flex', + flexDirection: 'column', + }), header: css` display: flex; align-items: center; @@ -238,19 +304,18 @@ const getQueryBoxStyles = (theme: GrafanaTheme2) => ({ padding: ${theme.spacing(1)}; background-color: ${theme.colors.background.secondary}; `, - textBlock: css` - border: 1px solid ${theme.colors.border.weak}; - padding: ${theme.spacing(0.5, 1)}; - background-color: ${theme.colors.background.primary}; - `, - refId: css` - color: ${theme.colors.text.link}; - padding: ${theme.spacing(0.5, 1)}; - border: 1px solid ${theme.colors.border.weak}; - `, - conditionIndicator: css` - margin-left: auto; - `, + textBlock: css({ + border: `1px solid ${theme.colors.border.weak}`, + padding: theme.spacing(0.5, 1), + backgroundColor: theme.colors.background.primary, + borderRadius: theme.shape.radius.default, + }), + refId: css({ + color: theme.colors.text.link, + padding: theme.spacing(0.5, 1), + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + }), }); function ClassicConditionViewer({ model }: { model: ExpressionQuery }) { @@ -321,7 +386,7 @@ const getReduceConditionViewerStyles = (theme: GrafanaTheme2) => ({ container: css` padding: ${theme.spacing(1)}; display: grid; - gap: ${theme.spacing(1)}; + gap: ${theme.spacing(0.5)}; grid-template-rows: 1fr 1fr; grid-template-columns: 1fr 1fr 1fr 1fr; @@ -360,7 +425,7 @@ const getResampleExpressionViewerStyles = (theme: GrafanaTheme2) => ({ container: css` padding: ${theme.spacing(1)}; display: grid; - gap: ${theme.spacing(1)}; + gap: ${theme.spacing(0.5)}; grid-template-columns: 1fr 1fr 1fr 1fr; grid-template-rows: 1fr 1fr; `, @@ -377,20 +442,44 @@ function ThresholdExpressionViewer({ model }: { model: ExpressionQuery }) { const isRange = evaluator ? isRangeEvaluator(evaluator) : false; - return ( - <div className={styles.container}> - <div className={styles.label}>Input</div> - <div className={styles.value}>{expression}</div> + const unloadEvaluator = conditions && conditions[0]?.unloadEvaluator; + const unloadThresholdFunction = thresholdFunctions.find((tf) => tf.value === unloadEvaluator?.type); - {evaluator && ( - <> - <div className={styles.blue}>{thresholdFunction?.label}</div> - <div className={styles.bold}> - {isRange ? `(${evaluator.params[0]}; ${evaluator.params[1]})` : evaluator.params[0]} - </div> - </> - )} - </div> + const unloadIsRange = unloadEvaluator ? isRangeEvaluator(unloadEvaluator) : false; + + return ( + <> + <div className={styles.container}> + <div className={styles.label}>Input</div> + <div className={styles.value}>{expression}</div> + + {evaluator && ( + <> + <div className={styles.blue}>{thresholdFunction?.label}</div> + <div className={styles.bold}> + {isRange ? `(${evaluator.params[0]}; ${evaluator.params[1]})` : evaluator.params[0]} + </div> + </> + )} + </div> + <div className={styles.container}> + {unloadEvaluator && ( + <> + <div className={styles.label}>Stop alerting when </div> + <div className={styles.value}>{expression}</div> + + <> + <div className={styles.blue}>{unloadThresholdFunction?.label}</div> + <div className={styles.bold}> + {unloadIsRange + ? `(${unloadEvaluator.params[0]}; ${unloadEvaluator.params[1]})` + : unloadEvaluator.params[0]} + </div> + </> + </> + )} + </div> + </> ); } @@ -405,7 +494,7 @@ const getExpressionViewerStyles = (theme: GrafanaTheme2) => { container: css` padding: ${theme.spacing(1)}; display: flex; - gap: ${theme.spacing(1)}; + gap: ${theme.spacing(0.5)}; `, blue: css` ${blue}; @@ -446,10 +535,12 @@ const getCommonQueryStyles = (theme: GrafanaTheme2) => ({ font-size: ${theme.typography.bodySmall.fontSize}; line-height: ${theme.typography.bodySmall.lineHeight}; font-weight: ${theme.typography.fontWeightBold}; + border-radius: ${theme.shape.radius.default}; `, value: css` padding: ${theme.spacing(0.5, 1)}; border: 1px solid ${theme.colors.border.weak}; + border-radius: ${theme.shape.radius.default}; `, }); diff --git a/public/app/features/alerting/unified/MoreActionsRuleButtons.tsx b/public/app/features/alerting/unified/MoreActionsRuleButtons.tsx deleted file mode 100644 index d3fd600533637..0000000000000 --- a/public/app/features/alerting/unified/MoreActionsRuleButtons.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { isEmpty } from 'lodash'; -import React from 'react'; -import { useLocation } from 'react-router-dom'; -import { useToggle } from 'react-use'; - -import { urlUtil } from '@grafana/data'; -import { Button, Dropdown, Icon, LinkButton, Menu, MenuItem } from '@grafana/ui'; - -import { logInfo, LogMessages } from './Analytics'; -import { GrafanaRulesExporter } from './components/export/GrafanaRulesExporter'; -import { AlertingAction, useAlertingAbility } from './hooks/useAbilities'; - -interface Props {} - -export function MoreActionsRuleButtons({}: Props) { - const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule); - const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule); - const [exportRulesSupported, exportRulesAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules); - - const location = useLocation(); - const [showExportDrawer, toggleShowExportDrawer] = useToggle(false); - - const canCreateGrafanaRules = createRuleSupported && createRuleAllowed; - const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed; - const canExportRules = exportRulesSupported && exportRulesAllowed; - - const menuItems: JSX.Element[] = []; - - if (canCreateGrafanaRules || canCreateCloudRules) { - menuItems.push( - <MenuItem - label="New recording rule" - key="new-recording-rule" - url={urlUtil.renderUrl(`alerting/new/recording`, { - returnTo: location.pathname + location.search, - })} - /> - ); - } - - if (canExportRules) { - menuItems.push( - <MenuItem label="Export all Grafana-managed rules" key="export-all-rules" onClick={toggleShowExportDrawer} /> - ); - } - - return ( - <> - {(canCreateGrafanaRules || canCreateCloudRules) && ( - <LinkButton - href={urlUtil.renderUrl('alerting/new/alerting', { returnTo: location.pathname + location.search })} - icon="plus" - onClick={() => logInfo(LogMessages.alertRuleFromScratch)} - > - New alert rule - </LinkButton> - )} - - {!isEmpty(menuItems) && ( - <Dropdown overlay={<Menu>{menuItems}</Menu>}> - <Button variant="secondary"> - More - <Icon name="angle-down" /> - </Button> - </Dropdown> - )} - {canExportRules && showExportDrawer && <GrafanaRulesExporter onClose={toggleShowExportDrawer} />} - </> - ); -} diff --git a/public/app/features/alerting/unified/MuteTimings.test.tsx b/public/app/features/alerting/unified/MuteTimings.test.tsx index e87db13b33cba..d52434ca228de 100644 --- a/public/app/features/alerting/unified/MuteTimings.test.tsx +++ b/public/app/features/alerting/unified/MuteTimings.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor, fireEvent, within } from '@testing-library/react'; +import { fireEvent, render, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; @@ -10,7 +10,7 @@ import { AccessControlAction } from 'app/types'; import MuteTimings from './MuteTimings'; import { fetchAlertManagerConfig, updateAlertManagerConfig } from './api/alertmanager'; -import { grantUserPermissions, mockDataSource, MockDataSourceSrv } from './mocks'; +import { MockDataSourceSrv, grantUserPermissions, mockDataSource } from './mocks'; import { DataSourceType } from './utils/datasource'; jest.mock('./api/alertmanager'); @@ -71,6 +71,21 @@ const muteTimeInterval: MuteTimeInterval = { }, ], }; +const muteTimeInterval2: MuteTimeInterval = { + name: 'default-mute2', + time_intervals: [ + { + times: [ + { + start_time: '12:00', + end_time: '24:00', + }, + ], + days_of_month: ['15', '-1'], + months: ['august:december', 'march'], + }, + ], +}; const defaultConfig: AlertManagerCortexConfig = { alertmanager_config: { @@ -90,6 +105,44 @@ const defaultConfig: AlertManagerCortexConfig = { }, template_files: {}, }; +const defaultConfigWithNewTimeIntervalsField: AlertManagerCortexConfig = { + alertmanager_config: { + receivers: [{ name: 'default' }, { name: 'critical' }], + route: { + receiver: 'default', + group_by: ['alertname'], + routes: [ + { + matchers: ['env=prod', 'region!=EU'], + mute_time_intervals: [muteTimeInterval.name], + }, + ], + }, + templates: [], + time_intervals: [muteTimeInterval], + }, + template_files: {}, +}; + +const defaultConfigWithBothTimeIntervalsField: AlertManagerCortexConfig = { + alertmanager_config: { + receivers: [{ name: 'default' }, { name: 'critical' }], + route: { + receiver: 'default', + group_by: ['alertname'], + routes: [ + { + matchers: ['env=prod', 'region!=EU'], + mute_time_intervals: [muteTimeInterval.name], + }, + ], + }, + templates: [], + time_intervals: [muteTimeInterval], + mute_time_intervals: [muteTimeInterval2], + }, + template_files: {}, +}; const resetMocks = () => { jest.resetAllMocks(); @@ -110,7 +163,102 @@ describe('Mute timings', () => { grantUserPermissions(Object.values(AccessControlAction)); }); - it('creates a new mute timing', async () => { + it('creates a new mute timing, with mute_time_intervals in config', async () => { + renderMuteTimings(); + + await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled()); + expect(ui.nameField.get()).toBeInTheDocument(); + + await userEvent.type(ui.nameField.get(), 'maintenance period'); + await userEvent.type(ui.startsAt.get(), '22:00'); + await userEvent.type(ui.endsAt.get(), '24:00'); + await userEvent.type(ui.days.get(), '-1'); + await userEvent.type(ui.months.get(), 'january, july'); + + fireEvent.submit(ui.form.get()); + + await waitFor(() => expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled()); + + const { mute_time_intervals: _, ...configWithoutMuteTimings } = defaultConfig.alertmanager_config; + expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith('grafana', { + ...defaultConfig, + alertmanager_config: { + ...configWithoutMuteTimings, + mute_time_intervals: [ + muteTimeInterval, + { + name: 'maintenance period', + time_intervals: [ + { + days_of_month: ['-1'], + months: ['january', 'july'], + times: [ + { + start_time: '22:00', + end_time: '24:00', + }, + ], + }, + ], + }, + ], + }, + }); + }); + + it('creates a new mute timing, with time_intervals in config', async () => { + mocks.api.fetchAlertManagerConfig.mockImplementation(() => { + return Promise.resolve({ + ...defaultConfigWithNewTimeIntervalsField, + }); + }); + renderMuteTimings(); + + await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled()); + expect(ui.nameField.get()).toBeInTheDocument(); + + await userEvent.type(ui.nameField.get(), 'maintenance period'); + await userEvent.type(ui.startsAt.get(), '22:00'); + await userEvent.type(ui.endsAt.get(), '24:00'); + await userEvent.type(ui.days.get(), '-1'); + await userEvent.type(ui.months.get(), 'january, july'); + + fireEvent.submit(ui.form.get()); + + await waitFor(() => expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled()); + + const { mute_time_intervals: _, ...configWithoutMuteTimings } = defaultConfig.alertmanager_config; + expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith('grafana', { + ...defaultConfig, + alertmanager_config: { + ...configWithoutMuteTimings, + mute_time_intervals: [ + muteTimeInterval, + { + name: 'maintenance period', + time_intervals: [ + { + days_of_month: ['-1'], + months: ['january', 'july'], + times: [ + { + start_time: '22:00', + end_time: '24:00', + }, + ], + }, + ], + }, + ], + }, + }); + }); + it('creates a new mute timing, with time_intervals and mute_time_intervals in config', async () => { + mocks.api.fetchAlertManagerConfig.mockImplementation(() => { + return Promise.resolve({ + ...defaultConfigWithBothTimeIntervalsField, + }); + }); renderMuteTimings(); await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled()); @@ -125,12 +273,15 @@ describe('Mute timings', () => { fireEvent.submit(ui.form.get()); await waitFor(() => expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled()); + + const { mute_time_intervals, time_intervals, ...configWithoutMuteTimings } = defaultConfig.alertmanager_config; expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith('grafana', { ...defaultConfig, alertmanager_config: { - ...defaultConfig.alertmanager_config, + ...configWithoutMuteTimings, mute_time_intervals: [ muteTimeInterval, + muteTimeInterval2, { name: 'maintenance period', time_intervals: [ @@ -200,7 +351,6 @@ describe('Mute timings', () => { name: 'default-mute', time_intervals: [ { - times: [], weekdays: ['monday'], days_of_month: ['-7:-1'], months: ['3', '6', '9', '12'], diff --git a/public/app/features/alerting/unified/MuteTimings.tsx b/public/app/features/alerting/unified/MuteTimings.tsx index c4d1584380c7b..a49205c477457 100644 --- a/public/app/features/alerting/unified/MuteTimings.tsx +++ b/public/app/features/alerting/unified/MuteTimings.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { Route, Redirect, Switch, useRouteMatch } from 'react-router-dom'; +import { Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'; import { NavModelItem } from '@grafana/data'; import { Alert } from '@grafana/ui'; @@ -21,8 +21,9 @@ const MuteTimings = () => { const config = currentData?.alertmanager_config; const getMuteTimingByName = useCallback( - (id: string): MuteTimeInterval | undefined => { - const timing = config?.mute_time_intervals?.find(({ name }: MuteTimeInterval) => name === id); + (id: string, fromTimeIntervals: boolean): MuteTimeInterval | undefined => { + const time_intervals = fromTimeIntervals ? config?.time_intervals ?? [] : config?.mute_time_intervals ?? []; + const timing = time_intervals.find(({ name }: MuteTimeInterval) => name === id); if (timing) { const provenance = config?.muteTimeProvenances?.[timing.name]; @@ -53,13 +54,17 @@ const MuteTimings = () => { <Route exact path="/alerting/routes/mute-timing/edit"> {() => { if (queryParams['muteName']) { - const muteTiming = getMuteTimingByName(String(queryParams['muteName'])); + const muteTimingInMuteTimings = getMuteTimingByName(String(queryParams['muteName']), false); + const muteTimingInTimeIntervals = getMuteTimingByName(String(queryParams['muteName']), true); + const inTimeIntervals = Boolean(muteTimingInTimeIntervals); + const muteTiming = inTimeIntervals ? muteTimingInTimeIntervals : muteTimingInMuteTimings; const provenance = muteTiming?.provenance; return ( <MuteTimingForm loading={isLoading} - muteTiming={muteTiming} + fromLegacyTimeInterval={muteTimingInMuteTimings} + fromTimeIntervals={muteTimingInTimeIntervals} showError={!muteTiming && !isLoading} provenance={provenance} /> diff --git a/public/app/features/alerting/unified/NotificationPolicies.test.tsx b/public/app/features/alerting/unified/NotificationPolicies.test.tsx index 459ea8e4e67fa..258cab99483c0 100644 --- a/public/app/features/alerting/unified/NotificationPolicies.test.tsx +++ b/public/app/features/alerting/unified/NotificationPolicies.test.tsx @@ -23,7 +23,7 @@ import { fetchAlertManagerConfig, fetchStatus, updateAlertManagerConfig } from ' import { alertmanagerApi } from './api/alertmanagerApi'; import { discoverAlertmanagerFeatures } from './api/buildInfo'; import * as grafanaApp from './components/receivers/grafanaAppReceivers/grafanaApp'; -import { mockDataSource, MockDataSourceSrv, someCloudAlertManagerConfig, someCloudAlertManagerStatus } from './mocks'; +import { MockDataSourceSrv, mockDataSource, someCloudAlertManagerConfig, someCloudAlertManagerStatus } from './mocks'; import { defaultGroupBy } from './utils/amroutes'; import { getAllDataSources } from './utils/config'; import { ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants'; @@ -759,18 +759,22 @@ describe('findRoutesMatchingFilters', () => { ], }; + it('should not filter when we do not have any valid filters', () => { + expect(findRoutesMatchingFilters(simpleRouteTree, {})).toHaveProperty('filtersApplied', false); + }); + it('should not match non-existing', () => { expect( findRoutesMatchingFilters(simpleRouteTree, { labelMatchersFilter: [['foo', MatcherOperator.equal, 'bar']], - }) - ).toHaveLength(0); + }).matchedRoutesWithPath.size + ).toBe(0); - expect( - findRoutesMatchingFilters(simpleRouteTree, { - contactPointFilter: 'does-not-exist', - }) - ).toHaveLength(0); + const matchingRoutes = findRoutesMatchingFilters(simpleRouteTree, { + contactPointFilter: 'does-not-exist', + }); + + expect(matchingRoutes).toMatchSnapshot(); }); it('should work with only label matchers', () => { @@ -778,8 +782,7 @@ describe('findRoutesMatchingFilters', () => { labelMatchersFilter: [['hello', MatcherOperator.equal, 'world']], }); - expect(matchingRoutes).toHaveLength(1); - expect(matchingRoutes[0]).toHaveProperty('id', '1'); + expect(matchingRoutes).toMatchSnapshot(); }); it('should work with only contact point and inheritance', () => { @@ -787,9 +790,7 @@ describe('findRoutesMatchingFilters', () => { contactPointFilter: 'simple-receiver', }); - expect(matchingRoutes).toHaveLength(2); - expect(matchingRoutes[0]).toHaveProperty('id', '1'); - expect(matchingRoutes[1]).toHaveProperty('id', '2'); + expect(matchingRoutes).toMatchSnapshot(); }); it('should work with non-intersecting filters', () => { @@ -798,7 +799,7 @@ describe('findRoutesMatchingFilters', () => { contactPointFilter: 'does-not-exist', }); - expect(matchingRoutes).toHaveLength(0); + expect(matchingRoutes).toMatchSnapshot(); }); it('should work with all filters', () => { @@ -807,8 +808,7 @@ describe('findRoutesMatchingFilters', () => { contactPointFilter: 'simple-receiver', }); - expect(matchingRoutes).toHaveLength(1); - expect(matchingRoutes[0]).toHaveProperty('id', '1'); + expect(matchingRoutes).toMatchSnapshot(); }); }); diff --git a/public/app/features/alerting/unified/NotificationPolicies.tsx b/public/app/features/alerting/unified/NotificationPolicies.tsx index 8c66e0222d29f..234d7b0feaaf3 100644 --- a/public/app/features/alerting/unified/NotificationPolicies.tsx +++ b/public/app/features/alerting/unified/NotificationPolicies.tsx @@ -1,10 +1,9 @@ import { css } from '@emotion/css'; -import { intersectionBy, isEqual } from 'lodash'; import React, { useEffect, useMemo, useState } from 'react'; import { useAsyncFn } from 'react-use'; import { GrafanaTheme2, UrlQueryMap } from '@grafana/data'; -import { Alert, LoadingPlaceholder, Tab, TabContent, TabsBar, useStyles2, withErrorBoundary, Stack } from '@grafana/ui'; +import { Alert, LoadingPlaceholder, Stack, Tab, TabContent, TabsBar, useStyles2, withErrorBoundary } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { useDispatch } from 'app/types'; @@ -16,12 +15,17 @@ import { useGetContactPointsState } from './api/receiversApi'; import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning'; import { MuteTimingsTable } from './components/mute-timings/MuteTimingsTable'; -import { findRoutesMatchingPredicate, NotificationPoliciesFilter } from './components/notification-policies/Filters'; +import { mergeTimeIntervals } from './components/mute-timings/util'; +import { + NotificationPoliciesFilter, + findRoutesByMatchers, + findRoutesMatchingPredicate, +} from './components/notification-policies/Filters'; import { useAddPolicyModal, - useEditPolicyModal, - useDeletePolicyModal, useAlertGroupsModal, + useDeletePolicyModal, + useEditPolicyModal, } from './components/notification-policies/Modals'; import { Policy } from './components/notification-policies/Policy'; import { useAlertmanagerConfig } from './hooks/useAlertmanagerConfig'; @@ -30,10 +34,15 @@ import { updateAlertManagerConfigAction } from './state/actions'; import { FormAmRoute } from './types/amroutes'; import { useRouteGroupsMatcher } from './useRouteGroupsMatcher'; import { addUniqueIdentifierToRoute } from './utils/amroutes'; -import { normalizeMatchers } from './utils/matchers'; import { computeInheritedTree } from './utils/notification-policies'; import { initialAsyncRequestState } from './utils/redux'; -import { addRouteToParentRoute, mergePartialAmRouteWithRouteTree, omitRouteFromRouteTree } from './utils/routeTree'; +import { + InsertPosition, + addRouteToReferenceRoute, + cleanRouteIDs, + mergePartialAmRouteWithRouteTree, + omitRouteFromRouteTree, +} from './utils/routeTree'; enum ActiveTab { NotificationPolicies = 'notification_policies', @@ -54,8 +63,8 @@ const AmRoutes = () => { const [contactPointFilter, setContactPointFilter] = useState<string | undefined>(); const [labelMatchersFilter, setLabelMatchersFilter] = useState<ObjectMatcher[]>([]); + const { selectedAlertmanager, hasConfigurationAPI, isGrafanaAlertmanager } = useAlertmanager(); const { getRouteGroupsMap } = useRouteGroupsMatcher(); - const { selectedAlertmanager, hasConfigurationAPI } = useAlertmanager(); const contactPointsState = useGetContactPointsState(selectedAlertmanager ?? ''); @@ -93,15 +102,21 @@ const AmRoutes = () => { useEffect(() => { if (rootRoute && alertGroups) { - triggerGetRouteGroupsMap(rootRoute, alertGroups); + triggerGetRouteGroupsMap(rootRoute, alertGroups, { unquoteMatchers: !isGrafanaAlertmanager }); } - }, [rootRoute, alertGroups, triggerGetRouteGroupsMap]); + }, [rootRoute, alertGroups, triggerGetRouteGroupsMap, isGrafanaAlertmanager]); // these are computed from the contactPoint and labels matchers filter const routesMatchingFilters = useMemo(() => { if (!rootRoute) { - return []; + const emptyResult: RoutesMatchingFilters = { + filtersApplied: false, + matchedRoutesWithPath: new Map(), + }; + + return emptyResult; } + return findRoutesMatchingFilters(rootRoute, { contactPointFilter, labelMatchersFilter }); }, [contactPointFilter, labelMatchersFilter, rootRoute]); @@ -123,20 +138,29 @@ const AmRoutes = () => { updateRouteTree(newRouteTree); } - function handleAdd(partialRoute: Partial<FormAmRoute>, parentRoute: RouteWithID) { + function handleAdd(partialRoute: Partial<FormAmRoute>, referenceRoute: RouteWithID, insertPosition: InsertPosition) { if (!rootRoute) { return; } - const newRouteTree = addRouteToParentRoute(selectedAlertmanager ?? '', partialRoute, parentRoute, rootRoute); + const newRouteTree = addRouteToReferenceRoute( + selectedAlertmanager ?? '', + partialRoute, + referenceRoute, + rootRoute, + insertPosition + ); updateRouteTree(newRouteTree); } - function updateRouteTree(routeTree: Route) { + function updateRouteTree(routeTree: Route | RouteWithID) { if (!result) { return; } + // make sure we omit all IDs from our routes + const newRouteTree = cleanRouteIDs(routeTree); + setUpdatingTree(true); dispatch( @@ -145,7 +169,7 @@ const AmRoutes = () => { ...result, alertmanager_config: { ...result.alertmanager_config, - route: routeTree, + route: newRouteTree, }, }, oldConfig: result, @@ -183,8 +207,9 @@ const AmRoutes = () => { if (!selectedAlertmanager) { return null; } + const time_intervals = result?.alertmanager_config ? mergeTimeIntervals(result?.alertmanager_config) : []; - const numberOfMuteTimings = result?.alertmanager_config.mute_time_intervals?.length ?? 0; + const numberOfMuteTimings = time_intervals.length; const haveData = result && !resultError && !resultLoading; const isFetching = !result && resultLoading; const haveError = resultError && !resultLoading; @@ -231,6 +256,7 @@ const AmRoutes = () => { receivers={receivers} onChangeMatchers={setLabelMatchersFilter} onChangeReceiver={setContactPointFilter} + matchingCount={routesMatchingFilters.matchedRoutesWithPath.size} /> )} {rootRoute && ( @@ -249,6 +275,7 @@ const AmRoutes = () => { onShowAlertInstances={showAlertGroupsModal} routesMatchingFilters={routesMatchingFilters} matchingInstancesPreview={{ groupsMap: routeAlertGroupsMap, enabled: !instancesPreviewError }} + isAutoGenerated={false} /> )} </Stack> @@ -273,35 +300,85 @@ type RouteFilters = { labelMatchersFilter?: ObjectMatcher[]; }; -export const findRoutesMatchingFilters = (rootRoute: RouteWithID, filters: RouteFilters): RouteWithID[] => { +type FilterResult = Map<RouteWithID, RouteWithID[]>; + +export interface RoutesMatchingFilters { + filtersApplied: boolean; + matchedRoutesWithPath: FilterResult; +} + +export const findRoutesMatchingFilters = (rootRoute: RouteWithID, filters: RouteFilters): RoutesMatchingFilters => { const { contactPointFilter, labelMatchersFilter = [] } = filters; + const hasFilter = contactPointFilter || labelMatchersFilter.length > 0; + const havebothFilters = Boolean(contactPointFilter) && labelMatchersFilter.length > 0; + // if filters are empty we short-circuit this function + if (!hasFilter) { + return { filtersApplied: false, matchedRoutesWithPath: new Map() }; + } + + // we'll collect all of the routes matching the filters + // we track an array of matching routes, each item in the array is for 1 type of filter + // + // [contactPointMatches, labelMatcherMatches] -> [[{ a: [], b: [] }], [{ a: [], c: [] }]] + // later we'll use intersection to find results in all sets of filter matchers let matchedRoutes: RouteWithID[][] = []; + // compute fully inherited tree so all policies have their inherited receiver const fullRoute = computeInheritedTree(rootRoute); - const routesMatchingContactPoint = contactPointFilter + // find all routes for our contact point filter + const matchingRoutesForContactPoint = contactPointFilter ? findRoutesMatchingPredicate(fullRoute, (route) => route.receiver === contactPointFilter) - : undefined; + : new Map(); + const routesMatchingContactPoint = Array.from(matchingRoutesForContactPoint.keys()); if (routesMatchingContactPoint) { matchedRoutes.push(routesMatchingContactPoint); } - const routesMatchingLabelMatchers = labelMatchersFilter.length - ? findRoutesMatchingPredicate(fullRoute, (route) => { - const routeMatchers = normalizeMatchers(route); - return labelMatchersFilter.every((filter) => routeMatchers.some((matcher) => isEqual(filter, matcher))); - }) - : undefined; + // find all routes matching our label matchers + const matchingRoutesForLabelMatchers = labelMatchersFilter.length + ? findRoutesMatchingPredicate(fullRoute, (route) => findRoutesByMatchers(route, labelMatchersFilter)) + : new Map(); - if (routesMatchingLabelMatchers) { - matchedRoutes.push(routesMatchingLabelMatchers); + const routesMatchingLabelFilters = Array.from(matchingRoutesForLabelMatchers.keys()); + if (matchingRoutesForLabelMatchers.size > 0) { + matchedRoutes.push(routesMatchingLabelFilters); } - return intersectionBy(...matchedRoutes, 'id'); + // now that we have our maps for all filters, we just need to find the intersection of all maps by route if we have both filters + const routesForAllFilterResults = havebothFilters + ? findMapIntersection(matchingRoutesForLabelMatchers, matchingRoutesForContactPoint) + : new Map([...matchingRoutesForLabelMatchers, ...matchingRoutesForContactPoint]); + + return { + filtersApplied: true, + matchedRoutesWithPath: routesForAllFilterResults, + }; }; +// this function takes multiple maps and creates a new map with routes that exist in all maps +// +// map 1: { a: [], b: [] } +// map 2: { a: [], c: [] } +// return: { a: [] } +function findMapIntersection(...matchingRoutes: FilterResult[]): FilterResult { + const result = new Map<RouteWithID, RouteWithID[]>(); + + // Iterate through the keys of the first map' + for (const key of matchingRoutes[0].keys()) { + // Check if the key exists in all other maps + if (matchingRoutes.every((map) => map.has(key))) { + // If yes, add the key to the result map + // @ts-ignore + result.set(key, matchingRoutes[0].get(key)); + } + } + + return result; +} + const getStyles = (theme: GrafanaTheme2) => ({ tabContent: css` margin-top: ${theme.spacing(2)}; diff --git a/public/app/features/alerting/unified/PanelAlertTab.tsx b/public/app/features/alerting/unified/PanelAlertTab.tsx index 6289d88e69cf4..697fe405efcb0 100644 --- a/public/app/features/alerting/unified/PanelAlertTab.tsx +++ b/public/app/features/alerting/unified/PanelAlertTab.tsx @@ -12,6 +12,6 @@ interface Props extends Omit<TabProps, 'counter' | 'ref'> { // it will load rule count from backend export const PanelAlertTab = ({ panel, dashboard, ...otherProps }: Props) => { - const { rules, loading } = usePanelCombinedRules({ panel, dashboard }); + const { rules, loading } = usePanelCombinedRules({ panelId: panel.id, dashboardUID: dashboard.uid }); return <Tab {...otherProps} counter={loading ? null : rules.length} />; }; diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx index add93de4b6990..a1df6a1be3ee8 100644 --- a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx +++ b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx @@ -153,7 +153,7 @@ const dashboard = { }, meta: { canSave: true, - folderId: 1, + folderUid: 'abc', folderTitle: 'super folder', }, } as DashboardModel; diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.tsx index 9f2383cf6ef6b..40ad74a557da3 100644 --- a/public/app/features/alerting/unified/PanelAlertTabContent.tsx +++ b/public/app/features/alerting/unified/PanelAlertTabContent.tsx @@ -20,8 +20,8 @@ interface Props { export const PanelAlertTabContent = ({ dashboard, panel }: Props) => { const styles = useStyles2(getStyles); const { errors, loading, rules } = usePanelCombinedRules({ - dashboard, - panel, + dashboardUID: dashboard.uid, + panelId: panel.id, poll: true, }); const permissions = getRulesPermissions('grafana'); diff --git a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx index 8c335a5a0076c..ea2a169d9988f 100644 --- a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx @@ -2,7 +2,7 @@ import { screen, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; -import { byRole, byText } from 'testing-library-selector'; +import { byText } from 'testing-library-selector'; import { setDataSourceSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; @@ -104,6 +104,7 @@ jest.mock('@grafana/runtime', () => ({ getDataSourceSrv: jest.fn(() => ({ getInstanceSettings: () => dataSources.prom, get: () => dataSources.prom, + getList: () => Object.values(dataSources), })), })); @@ -197,7 +198,7 @@ describe('RuleEditor cloud: checking editable data sources', () => { // check that only rules sources that have ruler available are there const dataSourceSelect = ui.inputs.dataSource.get(); - await userEvent.click(byRole('combobox').get(dataSourceSelect)); + await userEvent.click(dataSourceSelect); expect(byText('cortex with ruler').query()).toBeInTheDocument(); expect(byText('loki with ruler').query()).toBeInTheDocument(); diff --git a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx index 4117406c67dbc..061b5dcbdb41e 100644 --- a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; +import { selectors } from '@grafana/e2e-selectors'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types'; @@ -138,11 +139,11 @@ describe('RuleEditor cloud', () => { //expressions are removed after switching to data-source managed expect(screen.queryAllByLabelText('Remove expression')).toHaveLength(0); - expect(screen.getByTestId('datasource-picker')).toBeInTheDocument(); + expect(screen.getByTestId(selectors.components.DataSourcePicker.inputV2)).toBeInTheDocument(); const dataSourceSelect = await ui.inputs.dataSource.find(); await user.click(dataSourceSelect); - await clickSelectOption(dataSourceSelect, 'Prom (default)'); + await user.click(screen.getByText('Prom')); await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled()); await user.type(await ui.inputs.expr.find(), 'up == 1'); diff --git a/public/app/features/alerting/unified/RuleEditorExisting.test.tsx b/public/app/features/alerting/unified/RuleEditorExisting.test.tsx index f72c42e157de9..87143007b6600 100644 --- a/public/app/features/alerting/unified/RuleEditorExisting.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorExisting.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor, screen, within } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { Route } from 'react-router-dom'; @@ -7,7 +7,7 @@ import { ui } from 'test/helpers/alertingRuleEditor'; import { locationService, setDataSourceSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; -import { DashboardSearchHit } from 'app/features/search/types'; +import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions'; @@ -101,6 +101,7 @@ describe('RuleEditor grafana managed rules', () => { title: 'Folder A', uid: 'abcd', id: 1, + type: DashboardSearchItemType.DashDB, }; const slashedFolder = { @@ -136,7 +137,7 @@ describe('RuleEditor grafana managed rules', () => { [folder.title]: [ { interval: '1m', - name: 'my great new rule', + name: 'group1', rules: [ { annotations: { description: 'some description', summary: 'some summary' }, @@ -199,10 +200,10 @@ describe('RuleEditor grafana managed rules', () => { expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, - 'Folder A', + 'abcd', { interval: '1m', - name: 'my great new rule', + name: 'group1', rules: [ { annotations: { description: 'some description', summary: 'some summary', custom: 'value' }, diff --git a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx index cc15a0c61d1d6..d4f6f031e6fa4 100644 --- a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx @@ -7,7 +7,7 @@ import { byRole } from 'testing-library-selector'; import { setDataSourceSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; -import { DashboardSearchHit } from 'app/features/search/types'; +import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; import { AccessControlAction } from 'app/types'; import { GrafanaAlertStateDecision, PromApplication } from 'app/types/unified-alerting-dto'; @@ -16,7 +16,7 @@ import { searchFolders } from '../../../../app/features/manage-dashboards/state/ import { discoverFeatures } from './api/buildInfo'; import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; -import { grantUserPermissions, mockDataSource, MockDataSourceSrv } from './mocks'; +import { MockDataSourceSrv, grantUserPermissions, mockDataSource } from './mocks'; import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; import * as config from './utils/config'; import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; @@ -105,29 +105,67 @@ describe('RuleEditor grafana managed rules', () => { mocks.api.fetchRulerRules.mockResolvedValue({ 'Folder A': [ { + interval: '1m', name: 'group1', - rules: [], + rules: [ + { + annotations: { description: 'some description', summary: 'some summary' }, + labels: { severity: 'warn', team: 'the a-team' }, + for: '5m', + grafana_alert: { + uid: '23', + namespace_uid: 'abcd', + condition: 'B', + data: getDefaultQueries(), + exec_err_state: GrafanaAlertStateDecision.Error, + no_data_state: GrafanaAlertStateDecision.NoData, + title: 'my great new rule', + }, + }, + ], }, ], namespace2: [ { - name: 'group2', - rules: [], + interval: '1m', + name: 'group1', + rules: [ + { + annotations: { description: 'some description', summary: 'some summary' }, + labels: { severity: 'warn', team: 'the a-team' }, + for: '5m', + grafana_alert: { + uid: '23', + namespace_uid: 'b', + condition: 'B', + data: getDefaultQueries(), + exec_err_state: GrafanaAlertStateDecision.Error, + no_data_state: GrafanaAlertStateDecision.NoData, + title: 'my great new rule', + }, + }, + ], }, ], }); mocks.searchFolders.mockResolvedValue([ { title: 'Folder A', + uid: 'abcd', id: 1, + type: DashboardSearchItemType.DashDB, }, { title: 'Folder B', id: 2, + uid: 'b', + type: DashboardSearchItemType.DashDB, }, { title: 'Folder / with slash', + uid: 'c', id: 2, + type: DashboardSearchItemType.DashDB, }, ] as DashboardSearchHit[]); @@ -163,7 +201,7 @@ describe('RuleEditor grafana managed rules', () => { // 9seg expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, - 'Folder A', + 'abcd', { interval: '1m', name: 'group1', diff --git a/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx b/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx index 681a84611b1ee..8b9455201f4b0 100644 --- a/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx @@ -3,7 +3,7 @@ import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event' import React from 'react'; import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; -import { byRole, byText } from 'testing-library-selector'; +import { byText } from 'testing-library-selector'; import { setDataSourceSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; @@ -72,6 +72,7 @@ jest.mock('@grafana/runtime', () => ({ getDataSourceSrv: jest.fn(() => ({ getInstanceSettings: () => dataSources.default, get: () => dataSources.default, + getList: () => Object.values(dataSources), })), })); @@ -149,9 +150,9 @@ describe('RuleEditor recording rules', () => { await userEvent.type(await ui.inputs.name.find(), 'my great new recording rule'); const dataSourceSelect = ui.inputs.dataSource.get(); - await userEvent.click(byRole('combobox').get(dataSourceSelect)); + await userEvent.click(dataSourceSelect); - await clickSelectOption(dataSourceSelect, 'Prom (default)'); + await userEvent.click(screen.getByText('Prom')); await clickSelectOption(ui.inputs.namespace.get(), 'namespace2'); await clickSelectOption(ui.inputs.group.get(), 'group2'); diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index 8b3f649197d23..3137a401f880b 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -25,9 +25,9 @@ import { discoverFeatures } from './api/buildInfo'; import { fetchRules } from './api/prometheus'; import { deleteNamespace, deleteRulerRulesGroup, fetchRulerRules, setRulerRuleGroup } from './api/ruler'; import { + MockDataSourceSrv, grantUserPermissions, mockDataSource, - MockDataSourceSrv, mockPromAlert, mockPromAlertingRule, mockPromRecordingRule, @@ -42,6 +42,7 @@ import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getPluginLinkExtensions: jest.fn(), + useReturnToPrevious: jest.fn(), })); jest.mock('./api/buildInfo'); jest.mock('./api/prometheus'); @@ -120,11 +121,8 @@ const ui = { rulesFilterInput: byTestId('search-query-input'), moreErrorsButton: byRole('button', { name: /more errors/ }), editCloudGroupIcon: byTestId('edit-group'), - newRuleButton: byRole('link', { name: 'New alert rule' }), - moreButton: byRole('button', { name: 'More' }), - exportButton: byRole('menuitem', { - name: /export all grafana\-managed rules/i, - }), + newRuleButton: byText(/new alert rule/i), + exportButton: byText(/export rules/i), editGroupModal: { dialog: byRole('dialog'), namespaceInput: byRole('textbox', { name: /^Namespace/ }), @@ -728,7 +726,8 @@ describe('RuleList', () => { renderRuleList(); - await userEvent.click(ui.moreButton.get()); + await waitFor(() => expect(mocks.api.fetchRules).toHaveBeenCalledTimes(1)); + expect(ui.exportButton.get()).toBeInTheDocument(); }); }); diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx index dfc7078d9aafa..7bb7f40830249 100644 --- a/public/app/features/alerting/unified/RuleList.tsx +++ b/public/app/features/alerting/unified/RuleList.tsx @@ -1,16 +1,16 @@ import { css } from '@emotion/css'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useLocation } from 'react-router-dom'; import { useAsyncFn, useInterval } from 'react-use'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Button, useStyles2, withErrorBoundary, Stack } from '@grafana/ui'; +import { GrafanaTheme2, urlUtil } from '@grafana/data'; +import { Button, LinkButton, useStyles2, withErrorBoundary } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { useDispatch } from 'app/types'; import { CombinedRuleNamespace } from '../../../types/unified-alerting'; -import { trackRuleListNavigation } from './Analytics'; -import { MoreActionsRuleButtons } from './MoreActionsRuleButtons'; +import { LogMessages, logInfo, trackRuleListNavigation } from './Analytics'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; import { NoRulesSplash } from './components/rules/NoRulesCTA'; import { INSTANCES_DISPLAY_LIMIT } from './components/rules/RuleDetails'; @@ -19,6 +19,7 @@ import { RuleListGroupView } from './components/rules/RuleListGroupView'; import { RuleListStateView } from './components/rules/RuleListStateView'; import { RuleStats } from './components/rules/RuleStats'; import RulesFilter from './components/rules/RulesFilter'; +import { AlertingAction, useAlertingAbility } from './hooks/useAbilities'; import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces'; import { useFilteredRules, useRulesFilter } from './hooks/useFilteredRules'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; @@ -98,6 +99,7 @@ const RuleList = withErrorBoundary( // Show splash only when we loaded all of the data sources and none of them has alerts const hasNoAlertRulesCreatedYet = allPromLoaded && allPromEmpty && promRequests.length > 0 && allRulerEmpty && allRulerLoaded; + const hasAlertRulesCreated = !hasNoAlertRulesCreatedYet; const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces(); const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState); @@ -105,10 +107,10 @@ const RuleList = withErrorBoundary( return ( // We don't want to show the Loading... indicator for the whole page. // We show separate indicators for Grafana-managed and Cloud rules - <AlertingPageWrapper navId="alert-list" isLoading={false}> + <AlertingPageWrapper navId="alert-list" isLoading={false} actions={hasAlertRulesCreated && <CreateAlertButton />}> <RuleListErrors /> <RulesFilter onFilterCleared={onFilterCleared} /> - {!hasNoAlertRulesCreatedYet && ( + {hasAlertRulesCreated && ( <> <div className={styles.break} /> <div className={styles.buttonsContainer}> @@ -125,14 +127,11 @@ const RuleList = withErrorBoundary( )} <RuleStats namespaces={filteredNamespaces} /> </div> - <Stack direction="row" gap={0.5}> - <MoreActionsRuleButtons /> - </Stack> </div> </> )} {hasNoAlertRulesCreatedYet && <NoRulesSplash />} - {!hasNoAlertRulesCreatedYet && <ViewComponent expandAll={expandAll} namespaces={filteredNamespaces} />} + {hasAlertRulesCreated && <ViewComponent expandAll={expandAll} namespaces={filteredNamespaces} />} </AlertingPageWrapper> ); }, @@ -162,3 +161,27 @@ const getStyles = (theme: GrafanaTheme2) => ({ }); export default RuleList; + +export function CreateAlertButton() { + const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule); + const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule); + + const location = useLocation(); + + const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed; + + const canCreateGrafanaRules = createRuleSupported && createRuleAllowed; + + if (canCreateGrafanaRules || canCreateCloudRules) { + return ( + <LinkButton + href={urlUtil.renderUrl('alerting/new/alerting', { returnTo: location.pathname + location.search })} + icon="plus" + onClick={() => logInfo(LogMessages.alertRuleFromScratch)} + > + New alert rule + </LinkButton> + ); + } + return null; +} diff --git a/public/app/features/alerting/unified/RuleViewer.tsx b/public/app/features/alerting/unified/RuleViewer.tsx index d81bcd3fedf0d..32064616fa992 100644 --- a/public/app/features/alerting/unified/RuleViewer.tsx +++ b/public/app/features/alerting/unified/RuleViewer.tsx @@ -1,39 +1,29 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { NavModelItem } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { isFetchError } from '@grafana/runtime'; import { Alert, withErrorBoundary } from '@grafana/ui'; -import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; +import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import { AlertRuleProvider } from './components/rule-viewer/RuleContext'; +import DetailView from './components/rule-viewer/RuleViewer'; import { useCombinedRule } from './hooks/useCombinedRule'; +import { stringifyErrorLike } from './utils/misc'; import { getRuleIdFromPathname, parse as parseRuleId } from './utils/rule-id'; -const DetailViewV1 = SafeDynamicImport(() => import('./components/rule-viewer/RuleViewer.v1')); -const DetailViewV2 = React.lazy(() => import('./components/rule-viewer/v2/RuleViewer.v2')); - type RuleViewerProps = GrafanaRouteComponentProps<{ id: string; sourceName: string; }>; -const newAlertDetailView = Boolean(config.featureToggles.alertingDetailsViewV2) === true; - const RuleViewer = (props: RuleViewerProps): JSX.Element => { - return newAlertDetailView ? <RuleViewerV2Wrapper {...props} /> : <RuleViewerV1Wrapper {...props} />; -}; - -export const defaultPageNav: NavModelItem = { - id: 'alert-rule-view', - text: '', -}; - -const RuleViewerV1Wrapper = (props: RuleViewerProps) => <DetailViewV1 {...props} />; - -const RuleViewerV2Wrapper = (props: RuleViewerProps) => { const id = getRuleIdFromPathname(props.match.params); - const identifier = useMemo(() => { + + // we convert the stringified ID to a rule identifier object which contains additional + // type and source information + const identifier = React.useMemo(() => { if (!id) { throw new Error('Rule ID is required'); } @@ -41,15 +31,15 @@ const RuleViewerV2Wrapper = (props: RuleViewerProps) => { return parseRuleId(id, true); }, [id]); + // we then fetch the rule from the correct API endpoint(s) const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier }); - // TODO improve error handling here if (error) { - if (typeof error === 'string') { - return error; - } - - return <Alert title={'Uh-oh'}>Something went wrong loading the rule</Alert>; + return ( + <AlertingPageWrapper pageNav={defaultPageNav} navId="alert-list"> + <ErrorMessage error={error} /> + </AlertingPageWrapper> + ); } if (loading) { @@ -61,10 +51,36 @@ const RuleViewerV2Wrapper = (props: RuleViewerProps) => { } if (rule) { - return <DetailViewV2 rule={rule} identifier={identifier} />; + return ( + <AlertRuleProvider identifier={identifier} rule={rule}> + <DetailView /> + </AlertRuleProvider> + ); } - return null; + // if we get here assume we can't find the rule + return ( + <AlertingPageWrapper pageNav={defaultPageNav} navId="alert-list"> + <EntityNotFound entity="Rule" /> + </AlertingPageWrapper> + ); }; +export const defaultPageNav: NavModelItem = { + id: 'alert-rule-view', + text: '', +}; + +interface ErrorMessageProps { + error: unknown; +} + +function ErrorMessage({ error }: ErrorMessageProps) { + if (isFetchError(error) && error.status === 404) { + return <EntityNotFound entity="Rule" />; + } + + return <Alert title={'Something went wrong loading the rule'}>{stringifyErrorLike(error)}</Alert>; +} + export default withErrorBoundary(RuleViewer, { style: 'page' }); diff --git a/public/app/features/alerting/unified/Silences.test.tsx b/public/app/features/alerting/unified/Silences.test.tsx index e478cb8baea31..905ed88d476c1 100644 --- a/public/app/features/alerting/unified/Silences.test.tsx +++ b/public/app/features/alerting/unified/Silences.test.tsx @@ -5,6 +5,7 @@ import { TestProvider } from 'test/helpers/TestProvider'; import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testing-library-selector'; import { dateTime } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { config, locationService, setDataSourceSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { AlertState, MatcherOperator } from 'app/plugins/datasource/alertmanager/types'; @@ -15,6 +16,8 @@ import { SilenceState } from '../../../plugins/datasource/alertmanager/types'; import Silences from './Silences'; import { createOrUpdateSilence, fetchAlerts, fetchSilences } from './api/alertmanager'; import { grantUserPermissions, mockAlertmanagerAlert, mockDataSource, MockDataSourceSrv, mockSilence } from './mocks'; +import { AlertmanagerProvider } from './state/AlertmanagerContext'; +import { setupDataSources } from './testSetup/datasources'; import { parseMatchers } from './utils/alertmanager'; import { DataSourceType } from './utils/datasource'; @@ -37,7 +40,9 @@ const renderSilences = (location = '/alerting/silences/') => { return render( <TestProvider> - <Silences /> + <AlertmanagerProvider accessType="instance"> + <Silences /> + </AlertmanagerProvider> </TestProvider> ); }; @@ -58,7 +63,7 @@ const ui = { addSilenceButton: byRole('link', { name: /add silence/i }), queryBar: byPlaceholderText('Search'), editor: { - timeRange: byLabelText('Timepicker', { exact: false }), + timeRange: byTestId(selectors.components.TimePicker.openButton), durationField: byLabelText('Duration'), durationInput: byRole('textbox', { name: /duration/i }), matchersField: byTestId('matcher'), @@ -218,7 +223,7 @@ describe('Silence edit', () => { beforeEach(() => { setUserLogged(true); - setDataSourceSrv(new MockDataSourceSrv(dataSources)); + setupDataSources(dataSources.am); }); it('Should not render createdBy if user is logged in and has a name', async () => { @@ -325,4 +330,32 @@ describe('Silence edit', () => { }, TEST_TIMEOUT ); + + it( + 'silences page should contain alertmanager parameter after creating a silence', + async () => { + const user = userEvent.setup(); + + renderSilences(`${baseUrlPath}?alertmanager=Alertmanager`); + await waitFor(() => expect(ui.editor.durationField.query()).not.toBeNull()); + + await user.type(ui.editor.matcherName.getAll()[0], 'foo'); + await user.type(ui.editor.matcherOperatorSelect.getAll()[0], '='); + await user.type(ui.editor.matcherValue.getAll()[0], 'bar'); + + await user.click(ui.editor.submit.get()); + + await waitFor(() => + expect(mocks.api.createOrUpdateSilence).toHaveBeenCalledWith( + 'Alertmanager', + expect.objectContaining({ + matchers: [{ isEqual: true, isRegex: false, name: 'foo', value: 'bar' }], + }) + ) + ); + + expect(locationService.getSearch().get('alertmanager')).toBe('Alertmanager'); + }, + TEST_TIMEOUT + ); }); diff --git a/public/app/features/alerting/unified/TODO.md b/public/app/features/alerting/unified/TODO.md index 180125b1701f0..86e6081154802 100644 --- a/public/app/features/alerting/unified/TODO.md +++ b/public/app/features/alerting/unified/TODO.md @@ -17,7 +17,7 @@ If the item needs more rationale and you feel like a single sentence is inedequa ## Refactoring - Get rid of "+ Add new" in drop-downs : Let's see if is there a way we can make it work with `<Select allowCustomValue />` -- There is a lot of overlap between `RuleActionButtons` and `RuleDetailsActionButtons`. As these components contain a lot of logic it would be nice to extract that logic into hoooks +- There is a lot of overlap between `RuleActionButtons` and `RuleDetailsActionButtons`. As these components contain a lot of logic it would be nice to extract that logic into hooks - Create a shared timings form that can be used in both `EditDefaultPolicyForm.tsx` and `EditNotificationPolicyForm.tsx` ## Testing diff --git a/public/app/features/alerting/unified/__snapshots__/NotificationPolicies.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/NotificationPolicies.test.tsx.snap new file mode 100644 index 0000000000000..9011ec04694c4 --- /dev/null +++ b/public/app/features/alerting/unified/__snapshots__/NotificationPolicies.test.tsx.snap @@ -0,0 +1,281 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`findRoutesMatchingFilters should not match non-existing 1`] = ` +{ + "filtersApplied": true, + "matchedRoutesWithPath": Map {}, +} +`; + +exports[`findRoutesMatchingFilters should work with all filters 1`] = ` +{ + "filtersApplied": true, + "matchedRoutesWithPath": Map { + { + "id": "1", + "matchers": [ + "hello=world", + "foo!=bar", + ], + "receiver": "simple-receiver", + "routes": [ + { + "id": "2", + "matchers": [ + "bar=baz", + ], + "receiver": "simple-receiver", + "routes": undefined, + }, + ], + } => [ + { + "id": "0", + "receiver": "default-receiver", + "routes": [ + { + "id": "1", + "matchers": [ + "hello=world", + "foo!=bar", + ], + "receiver": "simple-receiver", + "routes": [ + { + "id": "2", + "matchers": [ + "bar=baz", + ], + "receiver": "simple-receiver", + "routes": undefined, + }, + ], + }, + ], + }, + { + "id": "1", + "matchers": [ + "hello=world", + "foo!=bar", + ], + "receiver": "simple-receiver", + "routes": [ + { + "id": "2", + "matchers": [ + "bar=baz", + ], + "receiver": "simple-receiver", + "routes": undefined, + }, + ], + }, + ], + }, +} +`; + +exports[`findRoutesMatchingFilters should work with non-intersecting filters 1`] = ` +{ + "filtersApplied": true, + "matchedRoutesWithPath": Map {}, +} +`; + +exports[`findRoutesMatchingFilters should work with only contact point and inheritance 1`] = ` +{ + "filtersApplied": true, + "matchedRoutesWithPath": Map { + { + "id": "1", + "matchers": [ + "hello=world", + "foo!=bar", + ], + "receiver": "simple-receiver", + "routes": [ + { + "id": "2", + "matchers": [ + "bar=baz", + ], + "receiver": "simple-receiver", + "routes": undefined, + }, + ], + } => [ + { + "id": "0", + "receiver": "default-receiver", + "routes": [ + { + "id": "1", + "matchers": [ + "hello=world", + "foo!=bar", + ], + "receiver": "simple-receiver", + "routes": [ + { + "id": "2", + "matchers": [ + "bar=baz", + ], + "receiver": "simple-receiver", + "routes": undefined, + }, + ], + }, + ], + }, + { + "id": "1", + "matchers": [ + "hello=world", + "foo!=bar", + ], + "receiver": "simple-receiver", + "routes": [ + { + "id": "2", + "matchers": [ + "bar=baz", + ], + "receiver": "simple-receiver", + "routes": undefined, + }, + ], + }, + ], + { + "id": "2", + "matchers": [ + "bar=baz", + ], + "receiver": "simple-receiver", + "routes": undefined, + } => [ + { + "id": "0", + "receiver": "default-receiver", + "routes": [ + { + "id": "1", + "matchers": [ + "hello=world", + "foo!=bar", + ], + "receiver": "simple-receiver", + "routes": [ + { + "id": "2", + "matchers": [ + "bar=baz", + ], + "receiver": "simple-receiver", + "routes": undefined, + }, + ], + }, + ], + }, + { + "id": "1", + "matchers": [ + "hello=world", + "foo!=bar", + ], + "receiver": "simple-receiver", + "routes": [ + { + "id": "2", + "matchers": [ + "bar=baz", + ], + "receiver": "simple-receiver", + "routes": undefined, + }, + ], + }, + { + "id": "2", + "matchers": [ + "bar=baz", + ], + "receiver": "simple-receiver", + "routes": undefined, + }, + ], + }, +} +`; + +exports[`findRoutesMatchingFilters should work with only label matchers 1`] = ` +{ + "filtersApplied": true, + "matchedRoutesWithPath": Map { + { + "id": "1", + "matchers": [ + "hello=world", + "foo!=bar", + ], + "receiver": "simple-receiver", + "routes": [ + { + "id": "2", + "matchers": [ + "bar=baz", + ], + "receiver": "simple-receiver", + "routes": undefined, + }, + ], + } => [ + { + "id": "0", + "receiver": "default-receiver", + "routes": [ + { + "id": "1", + "matchers": [ + "hello=world", + "foo!=bar", + ], + "receiver": "simple-receiver", + "routes": [ + { + "id": "2", + "matchers": [ + "bar=baz", + ], + "receiver": "simple-receiver", + "routes": undefined, + }, + ], + }, + ], + }, + { + "id": "1", + "matchers": [ + "hello=world", + "foo!=bar", + ], + "receiver": "simple-receiver", + "routes": [ + { + "id": "2", + "matchers": [ + "bar=baz", + ], + "receiver": "simple-receiver", + "routes": undefined, + }, + ], + }, + ], + }, +} +`; diff --git a/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap index c6d28162c50bb..eecab06dfdb14 100644 --- a/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap +++ b/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap @@ -13,6 +13,10 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button }, ], "condition": "C", + "folder": { + "title": "super folder", + "uid": "abc", + }, "name": "mypanel", "queries": [ { diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index 7af2db4c40b7a..178833f6745e7 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -154,15 +154,15 @@ export const alertRuleApi = alertingApi.injectEndpoints({ prometheusRuleNamespaces: build.query< RuleNamespace[], - { ruleSourceName: string; namespace?: string; groupName?: string; ruleName?: string } + { ruleSourceName: string; namespace?: string; groupName?: string; ruleName?: string; dashboardUid?: string } >({ - query: ({ ruleSourceName, namespace, groupName, ruleName }) => { - const queryParams: Record<string, string | undefined> = {}; - // if (isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)) { - queryParams['file'] = namespace; - queryParams['rule_group'] = groupName; - queryParams['rule_name'] = ruleName; - // } + query: ({ ruleSourceName, namespace, groupName, ruleName, dashboardUid }) => { + const queryParams: Record<string, string | undefined> = { + file: namespace, + rule_group: groupName, + rule_name: ruleName, + dashboard_uid: dashboardUid, // Supported only by Grafana managed rules + }; return { url: `api/prometheus/${getDatasourceAPIUid(ruleSourceName)}/api/v1/rules`, @@ -229,10 +229,10 @@ export const alertRuleApi = alertingApi.injectEndpoints({ }), exportModifiedRuleGroup: build.mutation< string, - { payload: ModifyExportPayload; format: ExportFormats; nameSpace: string } + { payload: ModifyExportPayload; format: ExportFormats; nameSpaceUID: string } >({ - query: ({ payload, format, nameSpace }) => ({ - url: `/api/ruler/grafana/api/v1/rules/${nameSpace}/export/`, + query: ({ payload, format, nameSpaceUID }) => ({ + url: `/api/ruler/grafana/api/v1/rules/${nameSpaceUID}/export/`, params: { format: format }, responseType: 'text', data: payload, diff --git a/public/app/features/alerting/unified/api/alertingApi.ts b/public/app/features/alerting/unified/api/alertingApi.ts index b27f92d1e7c60..5d90c4c58a109 100644 --- a/public/app/features/alerting/unified/api/alertingApi.ts +++ b/public/app/features/alerting/unified/api/alertingApi.ts @@ -3,7 +3,7 @@ import { lastValueFrom } from 'rxjs'; import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime'; -import { logInfo } from '../Analytics'; +import { logMeasurement } from '../Analytics'; export const backendSrvBaseQuery = (): BaseQueryFn<BackendSrvRequest> => async (requestOptions) => { try { @@ -11,12 +11,17 @@ export const backendSrvBaseQuery = (): BaseQueryFn<BackendSrvRequest> => async ( const { data, ...meta } = await lastValueFrom(getBackendSrv().fetch(requestOptions)); - logInfo('Request finished', { - loadTimeMs: (performance.now() - requestStartTs).toFixed(0), - url: requestOptions.url, - method: requestOptions.method ?? '', - responseStatus: meta.statusText, - }); + logMeasurement( + 'backendSrvBaseQuery', + { + loadTimeMs: performance.now() - requestStartTs, + }, + { + url: requestOptions.url, + method: requestOptions.method ?? 'GET', + responseStatus: meta.statusText, + } + ); return { data, meta }; } catch (error) { @@ -27,6 +32,12 @@ export const backendSrvBaseQuery = (): BaseQueryFn<BackendSrvRequest> => async ( export const alertingApi = createApi({ reducerPath: 'alertingApi', baseQuery: backendSrvBaseQuery(), - tagTypes: ['AlertmanagerChoice', 'AlertmanagerConfiguration', 'OnCallIntegrations'], + tagTypes: [ + 'AlertmanagerChoice', + 'AlertmanagerConfiguration', + 'OnCallIntegrations', + 'OrgMigrationState', + 'DataSourceSettings', + ], endpoints: () => ({}), }); diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index e97588813def8..ea0f1e948d9b1 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -11,7 +11,9 @@ import { ExternalAlertmanagerConfig, ExternalAlertmanagers, ExternalAlertmanagersResponse, + GrafanaManagedContactPoint, Matcher, + MuteTimeInterval, } from '../../../../plugins/datasource/alertmanager/types'; import { NotifierDTO } from '../../../../types'; import { withPerformanceLogging } from '../Analytics'; @@ -162,8 +164,8 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ // wrap our fetchConfig function with some performance logging functions const fetchAMconfigWithLogging = withPerformanceLogging( + 'unifiedalerting/fetchAmConfig', fetchAlertManagerConfig, - `[${alertmanagerSourceName}] Alertmanager config loaded`, { dataSourceName: alertmanagerSourceName, thunk: 'unifiedalerting/fetchAmConfig', @@ -257,5 +259,12 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ })); }, }), + // Grafana Managed Alertmanager only + getContactPointsList: build.query<GrafanaManagedContactPoint[], void>({ + query: () => ({ url: '/api/v1/notifications/receivers' }), + }), + getMuteTimingList: build.query<MuteTimeInterval[], void>({ + query: () => ({ url: '/api/v1/notifications/time-intervals' }), + }), }), }); diff --git a/public/app/features/alerting/unified/api/dataSourcesApi.ts b/public/app/features/alerting/unified/api/dataSourcesApi.ts new file mode 100644 index 0000000000000..94bb2de09371d --- /dev/null +++ b/public/app/features/alerting/unified/api/dataSourcesApi.ts @@ -0,0 +1,15 @@ +import { DataSourceJsonData, DataSourceSettings } from '@grafana/data'; + +import { alertingApi } from './alertingApi'; + +export const dataSourcesApi = alertingApi.injectEndpoints({ + endpoints: (build) => ({ + getAllDataSourceSettings: build.query<Array<DataSourceSettings<DataSourceJsonData>>, void>({ + query: () => ({ url: 'api/datasources' }), + // we'll create individual cache entries for each datasource UID + providesTags: (result) => { + return result ? result.map(({ uid }) => ({ type: 'DataSourceSettings', id: uid })) : ['DataSourceSettings']; + }, + }), + }), +}); diff --git a/public/app/features/alerting/unified/api/featureDiscoveryApi.ts b/public/app/features/alerting/unified/api/featureDiscoveryApi.ts index 659cc191f7627..45f59b75ea7a4 100644 --- a/public/app/features/alerting/unified/api/featureDiscoveryApi.ts +++ b/public/app/features/alerting/unified/api/featureDiscoveryApi.ts @@ -28,8 +28,8 @@ export const featureDiscoveryApi = alertingApi.injectEndpoints({ } const discoverFeaturesWithLogging = withPerformanceLogging( + 'unifiedalerting/featureDiscoveryApi/discoverDsFeatures', discoverFeatures, - `[${rulesSourceName}] Rules source features discovered`, { dataSourceName: rulesSourceName, endpoint: 'unifiedalerting/featureDiscoveryApi/discoverDsFeatures', diff --git a/public/app/features/alerting/unified/api/onCallApi.ts b/public/app/features/alerting/unified/api/onCallApi.ts index bd4baad847a73..a3a4ebdfb6b68 100644 --- a/public/app/features/alerting/unified/api/onCallApi.ts +++ b/public/app/features/alerting/unified/api/onCallApi.ts @@ -42,7 +42,11 @@ export const onCallApi = alertingApi.injectEndpoints({ url: getProxyApiUrl('/api/internal/v1/alert_receive_channels/'), // legacy_grafana_alerting is necessary for OnCall. // We do NOT need to differentiate between these two on our side - params: { filters: true, integration: [GRAFANA_ONCALL_INTEGRATION_TYPE, 'legacy_grafana_alerting'] }, + params: { + filters: true, + integration: [GRAFANA_ONCALL_INTEGRATION_TYPE, 'legacy_grafana_alerting'], + skip_pagination: true, + }, showErrorAlert: false, }), transformResponse: (response: AlertReceiveChannelsResult) => { diff --git a/public/app/features/alerting/unified/api/ruler.ts b/public/app/features/alerting/unified/api/ruler.ts index 348767b5b537d..69104d16ff699 100644 --- a/public/app/features/alerting/unified/api/ruler.ts +++ b/public/app/features/alerting/unified/api/ruler.ts @@ -41,8 +41,8 @@ export function rulerUrlBuilder(rulerConfig: RulerDataSourceConfig) { path: `${rulerPath}/${encodeURIComponent(namespace)}`, params: Object.fromEntries(rulerSearchParams), }), - namespaceGroup: (namespace: string, group: string): RulerRequestUrl => ({ - path: `${rulerPath}/${encodeURIComponent(namespace)}/${encodeURIComponent(group)}`, + namespaceGroup: (namespaceUID: string, group: string): RulerRequestUrl => ({ + path: `${rulerPath}/${encodeURIComponent(namespaceUID)}/${encodeURIComponent(group)}`, params: Object.fromEntries(rulerSearchParams), }), }; @@ -51,10 +51,10 @@ export function rulerUrlBuilder(rulerConfig: RulerDataSourceConfig) { // upsert a rule group. use this to update rule export async function setRulerRuleGroup( rulerConfig: RulerDataSourceConfig, - namespace: string, + namespaceIdentifier: string, group: PostableRulerRuleGroupDTO ): Promise<void> { - const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace); + const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespaceIdentifier); await lastValueFrom( getBackendSrv().fetch<unknown>({ method: 'POST', @@ -102,10 +102,10 @@ export async function fetchTestRulerRulesGroup(dataSourceName: string): Promise< export async function fetchRulerRulesGroup( rulerConfig: RulerDataSourceConfig, - namespace: string, + namespaceIdentifier: string, // can be the namespace name or namespace UID group: string ): Promise<RulerRuleGroupDTO | null> { - const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group); + const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespaceIdentifier, group); return rulerGetRequest<RulerRuleGroupDTO | null>(path, null, params); } diff --git a/public/app/features/alerting/unified/api/upgradeApi.ts b/public/app/features/alerting/unified/api/upgradeApi.ts new file mode 100644 index 0000000000000..baef6c7a06912 --- /dev/null +++ b/public/app/features/alerting/unified/api/upgradeApi.ts @@ -0,0 +1,444 @@ +import { FetchError, isFetchError } from '@grafana/runtime'; + +import { + createErrorNotification, + createSuccessNotification, + createWarningNotification, +} from '../../../../core/copy/appNotification'; +import { notifyApp } from '../../../../core/reducers/appNotification'; +import { ObjectMatcher } from '../../../../plugins/datasource/alertmanager/types'; + +import { alertingApi } from './alertingApi'; + +export interface OrgMigrationSummary { + newDashboards: number; + newAlerts: number; + newChannels: number; + removed: boolean; + hasErrors: boolean; +} + +export interface OrgMigrationState { + orgId: number; + migratedDashboards: DashboardUpgrade[]; + migratedChannels: ContactPair[]; + errors: string[]; +} + +export interface DashboardUpgrade { + migratedAlerts: AlertPair[]; + dashboardId: number; + dashboardUid: string; + dashboardName: string; + folderUid: string; + folderName: string; + newFolderUid?: string; + newFolderName?: string; + provisioned: boolean; + error?: string; + warning: string; + + isUpgrading: boolean; +} + +export interface AlertPair { + legacyAlert: LegacyAlert; + alertRule?: AlertRuleUpgrade; + error?: string; + + isUpgrading: boolean; +} + +export interface ContactPair { + legacyChannel: LegacyChannel; + contactPoint?: ContactPointUpgrade; + provisioned: boolean; + error?: string; + + isUpgrading: boolean; +} + +export interface LegacyAlert { + id: number; + dashboardId: number; + panelId: number; + name: string; +} + +export interface AlertRuleUpgrade { + uid: string; + title: string; + sendsTo: string[]; +} + +export interface LegacyChannel { + id: number; + name: string; + type: string; +} + +export interface ContactPointUpgrade { + name: string; + type: string; + routeMatchers: ObjectMatcher[]; +} + +function isFetchBaseQueryError(error: unknown): error is { error: FetchError } { + return typeof error === 'object' && error != null && 'error' in error; +} + +export const upgradeApi = alertingApi.injectEndpoints({ + endpoints: (build) => ({ + upgradeChannel: build.mutation<OrgMigrationSummary, { channelId: number; skipExisting: boolean }>({ + query: ({ channelId, skipExisting }) => ({ + url: `/api/v1/upgrade/channels/${channelId}${skipExisting ? '?skipExisting=true' : ''}`, + method: 'POST', + showSuccessAlert: false, + showErrorAlert: false, + }), + invalidatesTags: ['OrgMigrationState'], + async onQueryStarted({ channelId }, { dispatch, queryFulfilled }) { + try { + dispatch( + upgradeApi.util.updateQueryData('getOrgUpgradeSummary', undefined, (draft) => { + const index = (draft.migratedChannels ?? []).findIndex((pair) => pair.legacyChannel?.id === channelId); + if (index !== -1) { + draft.migratedChannels[index].isUpgrading = true; + } + }) + ); + const { data } = await queryFulfilled; + if (data.hasErrors) { + dispatch(notifyApp(createWarningNotification(`Failed to upgrade notification channel '${channelId}'`))); + } else { + if (data.removed) { + dispatch( + notifyApp( + createSuccessNotification( + `Notification channel '${channelId}' not found, removed from list of upgrades` + ) + ) + ); + } else { + dispatch(notifyApp(createSuccessNotification(`Upgraded notification channel '${channelId}'`))); + } + } + } catch (e) { + if (isFetchBaseQueryError(e) && isFetchError(e.error)) { + dispatch(notifyApp(createErrorNotification('Request failed', e.error.data.message))); + } else { + dispatch(notifyApp(createErrorNotification(`Request failed`))); + } + } + }, + }), + upgradeAllChannels: build.mutation<OrgMigrationSummary, { skipExisting: boolean }>({ + query: ({ skipExisting }) => ({ + url: `/api/v1/upgrade/channels${skipExisting ? '?skipExisting=true' : ''}`, + method: 'POST', + showSuccessAlert: false, + showErrorAlert: false, + }), + invalidatesTags: ['OrgMigrationState'], + async onQueryStarted({ skipExisting }, { dispatch, queryFulfilled }) { + try { + const { data } = await queryFulfilled; + if (data.hasErrors) { + dispatch( + notifyApp( + createWarningNotification( + `Issues while upgrading ${data.newChannels} ${skipExisting ? 'new ' : ''}notification channels` + ) + ) + ); + } else { + dispatch( + notifyApp( + createSuccessNotification( + `Upgraded ${data.newChannels} ${skipExisting ? 'new ' : ''}notification channels` + ) + ) + ); + } + } catch (e) { + if (isFetchBaseQueryError(e) && isFetchError(e.error)) { + dispatch(notifyApp(createErrorNotification('Request failed', e.error.data.message))); + } else { + dispatch(notifyApp(createErrorNotification(`Request failed`))); + } + } + }, + }), + upgradeAlert: build.mutation<OrgMigrationSummary, { dashboardId: number; panelId: number; skipExisting: boolean }>({ + query: ({ dashboardId, panelId, skipExisting }) => ({ + url: `/api/v1/upgrade/dashboards/${dashboardId}/panels/${panelId}${skipExisting ? '?skipExisting=true' : ''}`, + method: 'POST', + showSuccessAlert: false, + showErrorAlert: false, + }), + invalidatesTags: ['OrgMigrationState'], + async onQueryStarted({ dashboardId, panelId }, { dispatch, queryFulfilled }) { + try { + dispatch( + upgradeApi.util.updateQueryData('getOrgUpgradeSummary', undefined, (draft) => { + const index = (draft.migratedDashboards ?? []).findIndex((du) => du.dashboardId === dashboardId); + if (index !== -1) { + const alertIndex = (draft.migratedDashboards[index]?.migratedAlerts ?? []).findIndex( + (pair) => pair.legacyAlert?.panelId === panelId + ); + if (alertIndex !== -1) { + draft.migratedDashboards[index].migratedAlerts[alertIndex].isUpgrading = true; + } + } + }) + ); + const { data } = await queryFulfilled; + if (data.hasErrors) { + dispatch( + notifyApp( + createWarningNotification(`Failed to upgrade alert from dashboard '${dashboardId}', panel '${panelId}'`) + ) + ); + } else { + if (data.removed) { + dispatch( + notifyApp( + createSuccessNotification( + `Alert from dashboard '${dashboardId}', panel '${panelId}' not found, removed from list of upgrades` + ) + ) + ); + } else { + dispatch( + notifyApp( + createSuccessNotification(`Upgraded alert from dashboard '${dashboardId}', panel '${panelId}'`) + ) + ); + } + } + } catch (e) { + if (isFetchBaseQueryError(e) && isFetchError(e.error)) { + dispatch(notifyApp(createErrorNotification('Request failed', e.error.data.message))); + } else { + dispatch(notifyApp(createErrorNotification(`Request failed`))); + } + } + }, + }), + upgradeDashboard: build.mutation<OrgMigrationSummary, { dashboardId: number; skipExisting: boolean }>({ + query: ({ dashboardId, skipExisting }) => ({ + url: `/api/v1/upgrade/dashboards/${dashboardId}${skipExisting ? '?skipExisting=true' : ''}`, + method: 'POST', + showSuccessAlert: false, + showErrorAlert: false, + }), + invalidatesTags: ['OrgMigrationState'], + async onQueryStarted({ dashboardId, skipExisting }, { dispatch, queryFulfilled }) { + try { + dispatch( + upgradeApi.util.updateQueryData('getOrgUpgradeSummary', undefined, (draft) => { + const index = (draft.migratedDashboards ?? []).findIndex((du) => du.dashboardId === dashboardId); + if (index !== -1) { + draft.migratedDashboards[index].isUpgrading = true; + } + }) + ); + const { data } = await queryFulfilled; + if (data.hasErrors) { + dispatch( + notifyApp( + createWarningNotification( + `Issues while upgrading ${data.newAlerts} ${ + skipExisting ? 'new ' : '' + }alerts from dashboard '${dashboardId}'` + ) + ) + ); + } else { + if (data.removed) { + dispatch( + notifyApp( + createSuccessNotification(`Dashboard '${dashboardId}' not found, removed from list of upgrades`) + ) + ); + } else { + dispatch( + notifyApp( + createSuccessNotification( + `Upgraded ${data.newAlerts} ${skipExisting ? 'new ' : ''}alerts from dashboard '${dashboardId}'` + ) + ) + ); + } + } + } catch (e) { + if (isFetchBaseQueryError(e) && isFetchError(e.error)) { + dispatch(notifyApp(createErrorNotification('Request failed', e.error.data.message))); + } else { + dispatch(notifyApp(createErrorNotification(`Request failed`))); + } + } + }, + }), + upgradeAllDashboards: build.mutation<OrgMigrationSummary, { skipExisting: boolean }>({ + query: ({ skipExisting }) => ({ + url: `/api/v1/upgrade/dashboards${skipExisting ? '?skipExisting=true' : ''}`, + method: 'POST', + showSuccessAlert: false, + showErrorAlert: false, + }), + invalidatesTags: ['OrgMigrationState'], + async onQueryStarted({ skipExisting }, { dispatch, queryFulfilled }) { + try { + const { data } = await queryFulfilled; + if (data.hasErrors) { + dispatch( + notifyApp( + createWarningNotification( + `Issues while upgrading ${data.newAlerts} ${skipExisting ? 'new ' : ''}alerts in ${ + data.newDashboards + } dashboards` + ) + ) + ); + } else { + dispatch( + notifyApp( + createSuccessNotification( + `Upgraded ${data.newAlerts} ${skipExisting ? 'new ' : ''}alerts in ${data.newDashboards} dashboards` + ) + ) + ); + } + } catch (e) { + if (isFetchBaseQueryError(e) && isFetchError(e.error)) { + dispatch(notifyApp(createErrorNotification('Request failed', e.error.data.message))); + } else { + dispatch(notifyApp(createErrorNotification(`Request failed`))); + } + } + }, + }), + upgradeOrg: build.mutation<OrgMigrationSummary, { skipExisting: boolean }>({ + query: ({ skipExisting }) => ({ + url: `/api/v1/upgrade/org${skipExisting ? '?skipExisting=true' : ''}`, + method: 'POST', + showSuccessAlert: false, + showErrorAlert: false, + }), + invalidatesTags: ['OrgMigrationState'], + async onQueryStarted({ skipExisting }, { dispatch, getCacheEntry, queryFulfilled }) { + try { + const { data } = await queryFulfilled; + if (data.hasErrors) { + dispatch( + notifyApp( + createWarningNotification( + `Issues while upgrading ${data.newAlerts} ${skipExisting ? 'new ' : ''}alerts in ${ + data.newDashboards + } dashboards and ${data.newChannels} ${skipExisting ? 'new ' : ''}notification channels` + ) + ) + ); + } else { + dispatch( + notifyApp( + createSuccessNotification( + `Upgraded ${data.newAlerts} ${skipExisting ? 'new ' : ''}alerts in ${ + data.newDashboards + } dashboards and ${data.newChannels} ${skipExisting ? 'new ' : ''}notification channels` + ) + ) + ); + } + } catch (e) { + if (isFetchBaseQueryError(e) && isFetchError(e.error)) { + dispatch(notifyApp(createErrorNotification('Request failed', e.error.data.message))); + } else { + dispatch(notifyApp(createErrorNotification(`Request failed`))); + } + } + }, + }), + cancelOrgUpgrade: build.mutation<void, void>({ + query: () => ({ + url: `/api/v1/upgrade/org`, + method: 'DELETE', + }), + invalidatesTags: ['OrgMigrationState'], + async onQueryStarted(undefined, { dispatch }) { + // This helps prevent flickering of old tables after the cancel button is clicked. + try { + dispatch( + upgradeApi.util.updateQueryData('getOrgUpgradeSummary', undefined, (draft) => { + const defaultState: OrgMigrationState = { + orgId: 0, + migratedDashboards: [], + migratedChannels: [], + errors: [], + }; + Object.assign(draft, defaultState); + }) + ); + } catch {} + }, + }), + getOrgUpgradeSummary: build.query<OrgMigrationState, void>({ + query: () => ({ + url: `/api/v1/upgrade/org`, + }), + providesTags: ['OrgMigrationState'], + transformResponse: (summary: OrgMigrationState): OrgMigrationState => { + summary.migratedDashboards = summary.migratedDashboards ?? []; + summary.migratedChannels = summary.migratedChannels ?? []; + summary.errors = summary.errors ?? []; + + // Sort to show the most problematic rows first. + summary.migratedDashboards.forEach((dashUpgrade) => { + // dashUpgrade.isUpgrading = false; + dashUpgrade.migratedAlerts = dashUpgrade.migratedAlerts ?? []; + dashUpgrade.error = dashUpgrade.error ?? ''; + dashUpgrade.warning = dashUpgrade.warning ?? ''; + dashUpgrade.migratedAlerts.sort((a, b) => { + const byError = (b.error ?? '').localeCompare(a.error ?? ''); + if (byError !== 0) { + return byError; + } + return (a.legacyAlert?.name ?? '').localeCompare(b.legacyAlert?.name ?? ''); + }); + }); + summary.migratedDashboards.sort((a, b) => { + const byErrors = (b.error ?? '').localeCompare(a.error ?? ''); + if (byErrors !== 0) { + return byErrors; + } + const byNestedErrors = + b.migratedAlerts.filter((a) => a.error).length - a.migratedAlerts.filter((a) => a.error).length; + if (byNestedErrors !== 0) { + return byNestedErrors; + } + const byWarnings = (b.warning ?? '').localeCompare(a.warning ?? ''); + if (byWarnings !== 0) { + return byWarnings; + } + const byFolder = a.folderName.localeCompare(b.folderName); + if (byFolder !== 0) { + return byFolder; + } + return a.dashboardName.localeCompare(b.dashboardName); + }); + + // Sort contacts. + summary.migratedChannels.sort((a, b) => { + const byErrors = (b.error ? 1 : 0) - (a.error ? 1 : 0); + if (byErrors !== 0) { + return byErrors; + } + return (a.legacyChannel?.name ?? '').localeCompare(b.legacyChannel?.name ?? ''); + }); + + return summary; + }, + }), + }), +}); diff --git a/public/app/features/alerting/unified/components/AlertLabels.test.tsx b/public/app/features/alerting/unified/components/AlertLabels.test.tsx index 381240ae05990..47eab5467bd84 100644 --- a/public/app/features/alerting/unified/components/AlertLabels.test.tsx +++ b/public/app/features/alerting/unified/components/AlertLabels.test.tsx @@ -12,12 +12,12 @@ describe('AlertLabels', () => { render(<AlertLabels labels={labels} commonLabels={commonLabels} />); expect(screen.getByText('+2 common labels')).toBeInTheDocument(); - userEvent.click(screen.getByRole('button')); + await userEvent.click(screen.getByRole('button')); await waitFor(() => { expect(screen.getByText('Hide common labels')).toBeInTheDocument(); }); - userEvent.click(screen.getByRole('button')); + await userEvent.click(screen.getByRole('button')); await waitFor(() => { expect(screen.getByText('+2 common labels')).toBeInTheDocument(); }); diff --git a/public/app/features/alerting/unified/components/AlertStateDot.tsx b/public/app/features/alerting/unified/components/AlertStateDot.tsx index 01a991d645009..c48efda4af5b9 100644 --- a/public/app/features/alerting/unified/components/AlertStateDot.tsx +++ b/public/app/features/alerting/unified/components/AlertStateDot.tsx @@ -2,8 +2,12 @@ import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { ComponentSize, Stack, useStyles2 } from '@grafana/ui'; -import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; +import { Stack, useStyles2 } from '@grafana/ui'; + +interface DotStylesProps { + color: 'success' | 'error' | 'warning' | 'info'; + includeState?: boolean; +} const AlertStateDot = (props: DotStylesProps) => { const styles = useStyles2(getDotStyles, props); @@ -15,16 +19,14 @@ const AlertStateDot = (props: DotStylesProps) => { ); }; -interface DotStylesProps { - state: PromAlertingRuleState; - includeState?: boolean; - size?: ComponentSize; // TODO support this -} - const getDotStyles = (theme: GrafanaTheme2, props: DotStylesProps) => { const size = theme.spacing(1.25); const outlineSize = `calc(${size} / 2.5)`; + const errorStyle = props.color === 'error'; + const successStyle = props.color === 'success'; + const warningStyle = props.color === 'warning'; + return { dot: css` width: ${size}; @@ -36,23 +38,23 @@ const getDotStyles = (theme: GrafanaTheme2, props: DotStylesProps) => { outline: solid ${outlineSize} ${theme.colors.secondary.transparent}; margin: ${outlineSize}; - ${props.state === PromAlertingRuleState.Inactive && - css` - background-color: ${theme.colors.success.main}; - outline-color: ${theme.colors.success.transparent}; - `} - - ${props.state === PromAlertingRuleState.Pending && - css` - background-color: ${theme.colors.warning.main}; - outline-color: ${theme.colors.warning.transparent}; - `} - - ${props.state === PromAlertingRuleState.Firing && - css` - background-color: ${theme.colors.error.main}; - outline-color: ${theme.colors.error.transparent}; - `} + ${successStyle && + css({ + backgroundColor: theme.colors.success.main, + outlineColor: theme.colors.success.transparent, + })} + + ${warningStyle && + css({ + backgroundColor: theme.colors.warning.main, + outlineColor: theme.colors.warning.transparent, + })} + + ${errorStyle && + css({ + backgroundColor: theme.colors.error.main, + outlineColor: theme.colors.error.transparent, + })} `, }; }; diff --git a/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx b/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx index 62f9069b8db3e..44fe60d61e000 100644 --- a/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx +++ b/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx @@ -18,7 +18,9 @@ interface AlertingPageWrapperProps extends PageProps { export const AlertingPageWrapper = ({ children, isLoading, ...rest }: AlertingPageWrapperProps) => ( <Page {...rest}> - <Page.Contents isLoading={isLoading}>{children}</Page.Contents> + <Page.Contents isLoading={isLoading}> + <div>{children}</div> + </Page.Contents> </Page> ); diff --git a/public/app/features/alerting/components/ConditionalWrap.tsx b/public/app/features/alerting/unified/components/ConditionalWrap.tsx similarity index 100% rename from public/app/features/alerting/components/ConditionalWrap.tsx rename to public/app/features/alerting/unified/components/ConditionalWrap.tsx diff --git a/public/app/features/alerting/unified/components/Expression.test.tsx b/public/app/features/alerting/unified/components/Expression.test.tsx deleted file mode 100644 index a1bfebc72b241..0000000000000 --- a/public/app/features/alerting/unified/components/Expression.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; - -import { PluginType } from '@grafana/data'; - -import { Expression } from './Expression'; - -const expression = - '100 - ( avg by ( agent_hostname ) ( rate ( node_cpu_seconds_total { mode = "idle" } [ 2h ] ) ) * 100 ) > 97'; - -const rulesSource = { - id: 5, - uid: 'gdev-prometheus', - type: 'prometheus', - name: 'gdev-prometheus', - meta: { - id: 'prometheus', - type: PluginType.datasource, - name: 'Prometheus', - info: { - author: { - name: 'Grafana Labs', - url: 'https://grafana.com', - }, - description: 'Open source time series database & alerting', - links: [ - { - name: 'Learn more', - url: 'https://prometheus.io/', - }, - ], - logos: { - small: 'public/app/plugins/datasource/prometheus/img/prometheus_logo.svg', - large: 'public/app/plugins/datasource/prometheus/img/prometheus_logo.svg', - }, - build: {}, - screenshots: [], - version: '', - updated: '', - }, - module: 'app/plugins/datasource/prometheus/module', - baseUrl: 'public/app/plugins/datasource/prometheus', - }, - url: '/api/datasources/proxy/5', - access: 'proxy' as const, - jsonData: { - manageAlerts: true, - }, - readOnly: false, -}; - -describe('Expression', () => { - it('Should not allow to edit the text in the editor', () => { - render(<Expression expression={expression} rulesSource={rulesSource} />); - - const editor = screen.getByTestId('expression-editor'); - userEvent.type(editor, 'something else'); - - expect(editor).toHaveTextContent(expression); - expect(editor).not.toHaveTextContent('something else'); - }); -}); diff --git a/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.test.tsx b/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.test.tsx index 31fe270eaffba..9258301695990 100644 --- a/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.test.tsx +++ b/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.test.tsx @@ -1,10 +1,9 @@ -import { render, screen } from '@testing-library/react'; +import 'whatwg-fetch'; +import { render, screen, waitFor } from '@testing-library/react'; import { setupServer } from 'msw/node'; import React from 'react'; import { Provider } from 'react-redux'; -import 'whatwg-fetch'; - import { setBackendSrv } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; import { configureStore } from 'app/store/configureStore'; @@ -76,7 +75,9 @@ describe('GrafanaAlertmanagerDeliveryWarning', () => { <GrafanaAlertmanagerDeliveryWarning currentAlertmanager={GRAFANA_RULES_SOURCE_NAME} /> ); - expect(container).toBeEmptyDOMElement(); + await waitFor(() => { + expect(container).toBeEmptyDOMElement(); + }); }); it('Should render no warning when choice is All but no active AM instances', async () => { @@ -89,7 +90,9 @@ describe('GrafanaAlertmanagerDeliveryWarning', () => { <GrafanaAlertmanagerDeliveryWarning currentAlertmanager={GRAFANA_RULES_SOURCE_NAME} /> ); - expect(container).toBeEmptyDOMElement(); + await waitFor(() => { + expect(container).toBeEmptyDOMElement(); + }); }); }); diff --git a/public/app/features/alerting/unified/components/HoverCard.tsx b/public/app/features/alerting/unified/components/HoverCard.tsx index 5d592b1113de4..62edfd1c790cf 100644 --- a/public/app/features/alerting/unified/components/HoverCard.tsx +++ b/public/app/features/alerting/unified/components/HoverCard.tsx @@ -53,17 +53,13 @@ export const HoverCard = ({ <GrafanaPopover {...popperProps} {...rest} - wrapperClassName={classnames(styles.popover(arrow ? 1.25 : 0), wrapperClassName)} + wrapperClassName={classnames(styles.popover, wrapperClassName)} onMouseLeave={hidePopper} onMouseEnter={showPopper} onFocus={showPopper} onBlur={hidePopper} referenceElement={popoverRef.current} - renderArrow={ - arrow - ? ({ arrowProps, placement }) => <div className={styles.arrow(placement)} {...arrowProps} /> - : () => <></> - } + renderArrow={arrow} /> )} @@ -82,55 +78,25 @@ export const HoverCard = ({ }; const getStyles = (theme: GrafanaTheme2) => ({ - popover: (offset: number) => css` - border-radius: ${theme.shape.radius.default}; - box-shadow: ${theme.shadows.z3}; - background: ${theme.colors.background.primary}; - border: 1px solid ${theme.colors.border.medium}; - - margin-bottom: ${theme.spacing(offset)}; - `, + popover: css({ + borderRadius: theme.shape.radius.default, + boxShadow: theme.shadows.z3, + background: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.medium}`, + }), card: { - body: css` - padding: ${theme.spacing(1)}; - `, - header: css` - padding: ${theme.spacing(1)}; - background: ${theme.colors.background.secondary}; - border-bottom: solid 1px ${theme.colors.border.medium}; - `, - footer: css` - padding: ${theme.spacing(0.5)} ${theme.spacing(1)}; - background: ${theme.colors.background.secondary}; - border-top: solid 1px ${theme.colors.border.medium}; - `, - }, - // TODO currently only works with bottom placement - arrow: (placement: string) => { - const ARROW_SIZE = '9px'; - - return css` - width: 0; - height: 0; - - border-left: ${ARROW_SIZE} solid transparent; - border-right: ${ARROW_SIZE} solid transparent; - /* using hex colors here because the border colors use alpha transparency */ - border-top: ${ARROW_SIZE} solid ${theme.isLight ? '#d2d3d4' : '#2d3037'}; - - &:after { - content: ''; - position: absolute; - - border: ${ARROW_SIZE} solid ${theme.colors.background.primary}; - border-bottom: 0; - border-left-color: transparent; - border-right-color: transparent; - - margin-top: 1px; - bottom: 1px; - left: -${ARROW_SIZE}; - } - `; + body: css({ + padding: theme.spacing(1), + }), + header: css({ + padding: theme.spacing(1), + background: theme.colors.background.secondary, + borderBottom: `solid 1px ${theme.colors.border.medium}`, + }), + footer: css({ + padding: theme.spacing(0.5, 1), + background: theme.colors.background.secondary, + borderTop: `solid 1px ${theme.colors.border.medium}`, + }), }, }); diff --git a/public/app/features/alerting/unified/components/Label.tsx b/public/app/features/alerting/unified/components/Label.tsx index 6c3788d77abc8..37177f3801c76 100644 --- a/public/app/features/alerting/unified/components/Label.tsx +++ b/public/app/features/alerting/unified/components/Label.tsx @@ -18,9 +18,10 @@ interface Props { // TODO allow customization with color prop const Label = ({ label, value, icon, color, size = 'md' }: Props) => { const styles = useStyles2(getStyles, color, size); + const ariaLabel = `${label}: ${value}`; return ( - <div className={styles.wrapper} role="listitem"> + <div className={styles.wrapper} role="listitem" aria-label={ariaLabel}> <Stack direction="row" gap={0} alignItems="stretch"> <div className={styles.label}> <Stack direction="row" gap={0.5} alignItems="center"> diff --git a/public/app/features/alerting/unified/components/PluginBridge.mock.ts b/public/app/features/alerting/unified/components/PluginBridge.mock.ts index 3644ce5779a03..d71d70c313d32 100644 --- a/public/app/features/alerting/unified/components/PluginBridge.mock.ts +++ b/public/app/features/alerting/unified/components/PluginBridge.mock.ts @@ -1,20 +1,25 @@ -import { rest } from 'msw'; -import { setupServer } from 'msw/node'; - // bit of setup to mock HTTP request responses import 'whatwg-fetch'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; + import { SupportedPlugin } from '../types/pluginBridges'; export const NON_EXISTING_PLUGIN = '__does_not_exist__'; const server = setupServer( - rest.get(`/api/plugins/${NON_EXISTING_PLUGIN}/settings`, async (_req, res, ctx) => res(ctx.status(404))), - rest.get(`/api/plugins/${SupportedPlugin.Incident}/settings`, async (_req, res, ctx) => { - return res( - ctx.json({ - enabled: true, - }) - ); + http.get(`/api/plugins/${NON_EXISTING_PLUGIN}/settings`, async () => + HttpResponse.json( + {}, + { + status: 404, + } + ) + ), + http.get(`/api/plugins/${SupportedPlugin.Incident}/settings`, async () => { + return HttpResponse.json({ + enabled: true, + }); }) ); diff --git a/public/app/features/alerting/unified/components/Provisioning.tsx b/public/app/features/alerting/unified/components/Provisioning.tsx index a5b66f73bc683..78a7c28ac08af 100644 --- a/public/app/features/alerting/unified/components/Provisioning.tsx +++ b/public/app/features/alerting/unified/components/Provisioning.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ComponentPropsWithoutRef } from 'react'; import { Alert, Badge } from '@grafana/ui'; @@ -10,13 +10,16 @@ export enum ProvisionedResource { RootNotificationPolicy = 'root notification policy', } -interface ProvisioningAlertProps { +// we'll omit the props we don't want consumers to overwrite and forward the others to the alert component +type ExtraAlertProps = Omit<ComponentPropsWithoutRef<typeof Alert>, 'title' | 'severity'>; + +interface ProvisioningAlertProps extends ExtraAlertProps { resource: ProvisionedResource; } -export const ProvisioningAlert = ({ resource }: ProvisioningAlertProps) => { +export const ProvisioningAlert = ({ resource, ...rest }: ProvisioningAlertProps) => { return ( - <Alert title={`This ${resource} cannot be edited through the UI`} severity="info"> + <Alert title={`This ${resource} cannot be edited through the UI`} severity="info" {...rest}> This {resource} has been provisioned, that means it was created by config. Please contact your server admin to update this {resource}. </Alert> diff --git a/public/app/features/alerting/unified/components/WithReturnButton.tsx b/public/app/features/alerting/unified/components/WithReturnButton.tsx new file mode 100644 index 0000000000000..5d8ef8c8e343a --- /dev/null +++ b/public/app/features/alerting/unified/components/WithReturnButton.tsx @@ -0,0 +1,19 @@ +import React, { useCallback } from 'react'; + +import { useReturnToPrevious } from '@grafana/runtime'; + +interface WithReturnButtonProps { + component: JSX.Element; + title?: string; +} + +// @TODO translations? +export const WithReturnButton = ({ component, title = 'previous page' }: WithReturnButtonProps) => { + const returnToPrevious = useReturnToPrevious(); + + const returnToThisURL = useCallback(() => { + returnToPrevious(title); + }, [returnToPrevious, title]); + + return React.cloneElement(component, { onClick: returnToThisURL }); +}; diff --git a/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx b/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx index bb9fe55c4a415..598a95f867a5d 100644 --- a/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx +++ b/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx @@ -89,7 +89,7 @@ const ui = { describe('Admin config', () => { beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); // FIXME: scope down grantUserPermissions(Object.values(AccessControlAction)); mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); diff --git a/public/app/features/alerting/unified/components/admin/ConfigEditor.tsx b/public/app/features/alerting/unified/components/admin/ConfigEditor.tsx index aaf9c69a50dae..8ea38ad84cd23 100644 --- a/public/app/features/alerting/unified/components/admin/ConfigEditor.tsx +++ b/public/app/features/alerting/unified/components/admin/ConfigEditor.tsx @@ -1,6 +1,7 @@ import React from 'react'; +import { useForm } from 'react-hook-form'; -import { Button, CodeEditor, ConfirmModal, Field, Form, HorizontalGroup } from '@grafana/ui'; +import { Button, CodeEditor, ConfirmModal, Field, Stack } from '@grafana/ui'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; @@ -29,77 +30,77 @@ export const ConfigEditor = ({ onConfirmReset, onDismiss, }: ConfigEditorProps) => { - return ( - <Form defaultValues={defaultValues} onSubmit={onSubmit} key={defaultValues.configJSON}> - {({ errors, setValue, register }) => { - register('configJSON', { - required: { value: true, message: 'Required' }, - validate: (value: string) => { - try { - JSON.parse(value); - return true; - } catch (e) { - return e instanceof Error ? e.message : 'JSON is invalid'; - } - }, - }); + const { + handleSubmit, + formState: { errors }, + setValue, + register, + } = useForm({ defaultValues }); - return ( - <> - <Field - disabled={loading} - label="Configuration" - invalid={!!errors.configJSON} - error={errors.configJSON?.message} - data-testid={readOnly ? 'readonly-config' : 'config'} - > - <CodeEditor - language="json" - width="100%" - height={500} - showLineNumbers={true} - value={defaultValues.configJSON} - showMiniMap={false} - onSave={(value) => { - setValue('configJSON', value); - }} - onBlur={(value) => { - setValue('configJSON', value); - }} - readOnly={readOnly} - /> - </Field> + register('configJSON', { + required: { value: true, message: 'Required' }, + validate: (value: string) => { + try { + JSON.parse(value); + return true; + } catch (e) { + return e instanceof Error ? e.message : 'JSON is invalid'; + } + }, + }); + return ( + <form onSubmit={handleSubmit(onSubmit)} key={defaultValues.configJSON}> + <Field + disabled={loading} + label="Configuration" + invalid={!!errors.configJSON} + error={errors.configJSON?.message} + data-testid={readOnly ? 'readonly-config' : 'config'} + > + <CodeEditor + language="json" + width="100%" + height={500} + showLineNumbers={true} + value={defaultValues.configJSON} + showMiniMap={false} + onSave={(value) => { + setValue('configJSON', value); + }} + onBlur={(value) => { + setValue('configJSON', value); + }} + readOnly={readOnly} + /> + </Field> - {!readOnly && ( - <HorizontalGroup> - <Button type="submit" variant="primary" disabled={loading}> - Save configuration - </Button> - {onReset && ( - <Button type="button" disabled={loading} variant="destructive" onClick={onReset}> - Reset configuration - </Button> - )} - </HorizontalGroup> - )} + {!readOnly && ( + <Stack gap={1}> + <Button type="submit" variant="primary" disabled={loading}> + Save configuration + </Button> + {onReset && ( + <Button type="button" disabled={loading} variant="destructive" onClick={onReset}> + Reset configuration + </Button> + )} + </Stack> + )} - {Boolean(showConfirmDeleteAMConfig) && onConfirmReset && onDismiss && ( - <ConfirmModal - isOpen={true} - title="Reset Alertmanager configuration" - body={`Are you sure you want to reset configuration ${ - alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME - ? 'for the Grafana Alertmanager' - : `for "${alertManagerSourceName}"` - }? Contact points and notification policies will be reset to their defaults.`} - confirmText="Yes, reset configuration" - onConfirm={onConfirmReset} - onDismiss={onDismiss} - /> - )} - </> - ); - }} - </Form> + {Boolean(showConfirmDeleteAMConfig) && onConfirmReset && onDismiss && ( + <ConfirmModal + isOpen={true} + title="Reset Alertmanager configuration" + body={`Are you sure you want to reset configuration ${ + alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME + ? 'for the Grafana Alertmanager' + : `for "${alertManagerSourceName}"` + }? Contact points and notification policies will be reset to their defaults.`} + confirmText="Yes, reset configuration" + onConfirm={onConfirmReset} + onDismiss={onDismiss} + /> + )} + </form> ); }; diff --git a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx index d84f930d3ccb5..757346b7976fc 100644 --- a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx +++ b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx @@ -5,11 +5,11 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Badge, CallToActionCard, Card, Icon, LinkButton, Tooltip, useStyles2 } from '@grafana/ui'; -import { ExternalDataSourceAM } from '../../hooks/useExternalAmSelector'; +import { ExternalAlertmanagerDataSourceWithStatus } from '../../hooks/useExternalAmSelector'; import { makeDataSourceLink } from '../../utils/misc'; export interface ExternalAlertManagerDataSourcesProps { - alertmanagers: ExternalDataSourceAM[]; + alertmanagers: ExternalAlertmanagerDataSourceWithStatus[]; inactive: boolean; } @@ -39,7 +39,7 @@ export function ExternalAlertmanagerDataSources({ alertmanagers, inactive }: Ext {alertmanagers.length > 0 && ( <div className={styles.externalDs}> {alertmanagers.map((am) => ( - <ExternalAMdataSourceCard key={am.dataSource.uid} alertmanager={am} inactive={inactive} /> + <ExternalAMdataSourceCard key={am.dataSourceSettings.uid} alertmanager={am} inactive={inactive} /> ))} </div> )} @@ -48,20 +48,20 @@ export function ExternalAlertmanagerDataSources({ alertmanagers, inactive }: Ext } interface ExternalAMdataSourceCardProps { - alertmanager: ExternalDataSourceAM; + alertmanager: ExternalAlertmanagerDataSourceWithStatus; inactive: boolean; } export function ExternalAMdataSourceCard({ alertmanager, inactive }: ExternalAMdataSourceCardProps) { const styles = useStyles2(getStyles); - const { dataSource, status, statusInconclusive, url } = alertmanager; + const { dataSourceSettings, status } = alertmanager; return ( <Card> <Card.Heading className={styles.externalHeading}> - {dataSource.name}{' '} - {statusInconclusive && ( + {dataSourceSettings.name}{' '} + {status === 'inconclusive' && ( <Tooltip content="Multiple Alertmanagers have the same URL configured. The state might be inconclusive."> <Icon name="exclamation-triangle" size="md" className={styles.externalWarningIcon} /> </Tooltip> @@ -90,9 +90,9 @@ export function ExternalAMdataSourceCard({ alertmanager, inactive }: ExternalAMd /> )} </Card.Tags> - <Card.Meta>{url}</Card.Meta> + <Card.Meta>{dataSourceSettings.url}</Card.Meta> <Card.Actions> - <LinkButton href={makeDataSourceLink(dataSource)} size="sm" variant="secondary"> + <LinkButton href={makeDataSourceLink(dataSourceSettings.uid)} size="sm" variant="secondary"> Go to datasource </LinkButton> </Card.Actions> diff --git a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx index 1bfb98fe0181a..ddcf5b9176d49 100644 --- a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx +++ b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx @@ -23,6 +23,9 @@ export const ExternalAlertmanagers = () => { const dispatch = useDispatch(); const externalDsAlertManagers = useExternalDataSourceAlertmanagers(); + const gmaHandlingAlertmanagers = externalDsAlertManagers.filter( + (settings) => settings.dataSourceSettings.jsonData.handleGrafanaManagedAlerts === true + ); const { useSaveExternalAlertmanagersConfigMutation, @@ -71,7 +74,7 @@ export const ExternalAlertmanagers = () => { </div> <ExternalAlertmanagerDataSources - alertmanagers={externalDsAlertManagers} + alertmanagers={gmaHandlingAlertmanagers} inactive={alertmanagersChoice === AlertmanagerChoice.Internal} /> </div> diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx index 4b7afe052472e..aad3722a34355 100644 --- a/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx @@ -39,13 +39,11 @@ export const AlertGroupFilter = ({ groups }: Props) => { <div className={styles.wrapper}> <div className={styles.filterSection}> <MatcherFilter - className={styles.filterInput} key={matcherFilterKey} defaultQueryString={queryString} onFilterChange={(value) => setQueryParams({ queryString: value ? value : null })} /> <GroupBy - className={styles.filterInput} groups={groups} groupBy={groupBy} onGroupingChange={(keys) => setQueryParams({ groupBy: keys.length ? keys.join(',') : null })} @@ -73,12 +71,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: flex; flex-direction: row; margin-bottom: ${theme.spacing(3)}; - `, - filterInput: css` - width: 340px; - & + & { - margin-left: ${theme.spacing(1)}; - } + gap: ${theme.spacing(1)}; `, clearButton: css` margin-left: ${theme.spacing(1)}; diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertStateFilter.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertStateFilter.tsx index 507a4d693c6bf..d94e2c36b0e6e 100644 --- a/public/app/features/alerting/unified/components/alert-groups/AlertStateFilter.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertStateFilter.tsx @@ -1,8 +1,7 @@ -import { css } from '@emotion/css'; import React from 'react'; -import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { RadioButtonGroup, Label, useStyles2 } from '@grafana/ui'; +import { SelectableValue } from '@grafana/data'; +import { RadioButtonGroup, Label } from '@grafana/ui'; import { AlertState } from 'app/plugins/datasource/alertmanager/types'; interface Props { @@ -11,7 +10,6 @@ interface Props { } export const AlertStateFilter = ({ onStateFilterChange, stateFilter }: Props) => { - const styles = useStyles2(getStyles); const alertStateOptions: SelectableValue[] = Object.entries(AlertState) .sort(([labelA], [labelB]) => (labelA < labelB ? -1 : 1)) .map(([label, state]) => ({ @@ -20,15 +18,9 @@ export const AlertStateFilter = ({ onStateFilterChange, stateFilter }: Props) => })); return ( - <div className={styles.wrapper}> + <div> <Label>State</Label> <RadioButtonGroup options={alertStateOptions} value={stateFilter} onChange={onStateFilterChange} /> </div> ); }; - -const getStyles = (theme: GrafanaTheme2) => ({ - wrapper: css` - margin-left: ${theme.spacing(1)}; - `, -}); diff --git a/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx b/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx index 7d60a1631fc3a..a35dba726d161 100644 --- a/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx @@ -6,13 +6,12 @@ import { Icon, Label, MultiSelect } from '@grafana/ui'; import { AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types'; interface Props { - className?: string; groups: AlertmanagerGroup[]; groupBy: string[]; onGroupingChange: (keys: string[]) => void; } -export const GroupBy = ({ className, groups, groupBy, onGroupingChange }: Props) => { +export const GroupBy = ({ groups, groupBy, onGroupingChange }: Props) => { const labelKeyOptions = uniq(groups.flatMap((group) => group.alerts).flatMap(({ labels }) => Object.keys(labels))) .filter((label) => !(label.startsWith('__') && label.endsWith('__'))) // Filter out private labels .map<SelectableValue>((key) => ({ @@ -21,7 +20,7 @@ export const GroupBy = ({ className, groups, groupBy, onGroupingChange }: Props) })); return ( - <div data-testid={'group-by-container'} className={className}> + <div data-testid={'group-by-container'}> <Label>Custom group by</Label> <MultiSelect aria-label={'group by label keys'} @@ -32,6 +31,7 @@ export const GroupBy = ({ className, groups, groupBy, onGroupingChange }: Props) onGroupingChange(items.map(({ value }) => value as string)); }} options={labelKeyOptions} + width={34} /> </div> ); diff --git a/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx b/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx index 66d7f580c56f4..02dab1c9eea6e 100644 --- a/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx @@ -3,25 +3,24 @@ import { debounce } from 'lodash'; import React, { FormEvent, useEffect, useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Label, Tooltip, Input, Icon, useStyles2, Stack } from '@grafana/ui'; +import { Field, Icon, Input, Label, Stack, Tooltip, useStyles2 } from '@grafana/ui'; import { logInfo, LogMessages } from '../../Analytics'; +import { parseMatchers } from '../../utils/alertmanager'; interface Props { - className?: string; defaultQueryString?: string; onFilterChange: (filterString: string) => void; } -export const MatcherFilter = ({ className, onFilterChange, defaultQueryString }: Props) => { +export const MatcherFilter = ({ onFilterChange, defaultQueryString }: Props) => { const styles = useStyles2(getStyles); const onSearchInputChanged = useMemo( () => debounce((e: FormEvent<HTMLInputElement>) => { logInfo(LogMessages.filterByLabel); - - const target = e.currentTarget; + const target = e.target as HTMLInputElement; onFilterChange(target.value); }, 600), [onFilterChange] @@ -30,42 +29,56 @@ export const MatcherFilter = ({ className, onFilterChange, defaultQueryString }: useEffect(() => onSearchInputChanged.cancel(), [onSearchInputChanged]); const searchIcon = <Icon name={'search'} />; + const inputInvalid = defaultQueryString ? parseMatchers(defaultQueryString).length === 0 : false; return ( - <div className={className}> - <Label> - <Stack gap={0.5}> - <span>Search by label</span> - <Tooltip - content={ - <div> - Filter alerts using label querying, ex: - <pre>{`{severity="critical", instance=~"cluster-us-.+"}`}</pre> - </div> - } - > - <Icon className={styles.icon} name="info-circle" size="sm" /> - </Tooltip> - </Stack> - </Label> + <Field + className={styles.fixMargin} + invalid={inputInvalid || undefined} + error={inputInvalid ? 'Query must use valid matcher syntax. See the examples in the help tooltip.' : null} + label={ + <Label> + <Stack gap={0.5}> + <span>Search by label</span> + <Tooltip + content={ + <div> + Filter alerts using label querying without spaces, ex: + <pre>{`{severity="critical", instance=~"cluster-us-.+"}`}</pre> + Invalid use of spaces: + <pre>{`{severity= "critical"}`}</pre> + <pre>{`{severity ="critical"}`}</pre> + Valid use of spaces: + <pre>{`{severity=" critical"}`}</pre> + Filter alerts using label querying without braces, ex: + <pre>{`severity="critical", instance=~"cluster-us-.+"`}</pre> + </div> + } + > + <Icon name="info-circle" size="sm" /> + </Tooltip> + </Stack> + </Label> + } + > <Input placeholder="Search" - defaultValue={defaultQueryString} + defaultValue={defaultQueryString ?? ''} onChange={onSearchInputChanged} data-testid="search-query-input" prefix={searchIcon} className={styles.inputWidth} /> - </div> + </Field> ); }; const getStyles = (theme: GrafanaTheme2) => ({ - icon: css` - margin-right: ${theme.spacing(0.5)}; - `, - inputWidth: css` - width: 340px; - flex-grow: 0; - `, + fixMargin: css({ + marginBottom: 0, + }), + inputWidth: css({ + width: 340, + flexGrow: 0, + }), }); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx index fde20c0fadc44..c6fd8c71ca799 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx @@ -127,6 +127,11 @@ describe('contact points', () => { const deleteButton = await screen.queryByRole('menuitem', { name: 'delete' }); expect(deleteButton).toBeDisabled(); } + + // check buttons in Notification Templates + const notificationTemplatesTab = screen.getByRole('tab', { name: 'Tab Notification Templates' }); + await userEvent.click(notificationTemplatesTab); + expect(screen.getByRole('link', { name: 'Add notification template' })).toHaveAttribute('aria-disabled', 'true'); }); it('should call delete when clicked and not disabled', async () => { @@ -326,6 +331,11 @@ describe('contact points', () => { const viewProvisioned = screen.getByRole('link', { name: 'view-action' }); expect(viewProvisioned).toBeInTheDocument(); expect(viewProvisioned).not.toBeDisabled(); + + // check buttons in Notification Templates + const notificationTemplatesTab = screen.getByRole('tab', { name: 'Tab Notification Templates' }); + await userEvent.click(notificationTemplatesTab); + expect(screen.queryByRole('link', { name: 'Add notification template' })).not.toBeInTheDocument(); }); }); }); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx index e8bb3de093586..646fa4e8ef20a 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx @@ -25,7 +25,7 @@ import { Tooltip, useStyles2, } from '@grafana/ui'; -import ConditionalWrap from 'app/features/alerting/components/ConditionalWrap'; +import ConditionalWrap from 'app/features/alerting/unified/components/ConditionalWrap'; import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts'; import { GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types'; import { GrafanaNotifierType, NotifierStatus } from 'app/types/alerting'; @@ -81,6 +81,9 @@ const ContactPoints = () => { const [exportContactPointsSupported, exportContactPointsAllowed] = useAlertmanagerAbility( AlertmanagerAction.ExportContactPoint ); + const [createTemplateSupported, createTemplateAllowed] = useAlertmanagerAbility( + AlertmanagerAction.CreateNotificationTemplate + ); const [DeleteModal, showDeleteModal] = useDeleteContactPointModal(deleteTrigger, updateAlertmanagerState.isLoading); const [ExportDrawer, showExportDrawer] = useExportContactPoint(); @@ -177,9 +180,16 @@ const ContactPoints = () => { Create notification templates to customize your notifications. </Text> <Spacer /> - <LinkButton icon="plus" variant="primary" href="/alerting/notifications/templates/new"> - Add notification template - </LinkButton> + {createTemplateSupported && ( + <LinkButton + icon="plus" + variant="primary" + href="/alerting/notifications/templates/new" + disabled={!createTemplateAllowed} + > + Add notification template + </LinkButton> + )} </Stack> <NotificationTemplates /> </> diff --git a/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts b/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts index a93b7b046c06f..54666de1aa539 100644 --- a/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts +++ b/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts @@ -1,4 +1,5 @@ -import { rest } from 'msw'; +import 'whatwg-fetch'; +import { http, HttpResponse } from 'msw'; import { SetupServer } from 'msw/node'; import { AlertmanagerChoice, AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; @@ -14,15 +15,15 @@ import receiversMock from './receivers.mock.json'; export default (server: SetupServer) => { server.use( // this endpoint is a grafana built-in alertmanager - rest.get('/api/alertmanager/grafana/config/api/v1/alerts', (_req, res, ctx) => - res(ctx.json<AlertManagerCortexConfig>(alertmanagerMock)) + http.get('/api/alertmanager/grafana/config/api/v1/alerts', () => + HttpResponse.json<AlertManagerCortexConfig>(alertmanagerMock) ), // this endpoint is only available for the built-in alertmanager - rest.get('/api/alertmanager/grafana/config/api/v1/receivers', (_req, res, ctx) => - res(ctx.json<ReceiversStateDTO[]>(receiversMock)) + http.get('/api/alertmanager/grafana/config/api/v1/receivers', () => + HttpResponse.json<ReceiversStateDTO[]>(receiversMock) ), // this endpoint will respond if the OnCall plugin is installed - rest.get('/api/plugins/grafana-oncall-app/settings', (_req, res, ctx) => res(ctx.status(404))) + http.get('/api/plugins/grafana-oncall-app/settings', () => HttpResponse.json({}, { status: 404 })) ); // this endpoint is for rendering the "additional AMs to configure" warning @@ -41,12 +42,18 @@ export const setupTestEndpointMock = (server: SetupServer) => { const mock = jest.fn(); server.use( - rest.post('/api/alertmanager/grafana/config/api/v1/receivers/test', async (req, res, ctx) => { - const requestBody = await req.json(); - mock(requestBody); + http.post( + '/api/alertmanager/grafana/config/api/v1/receivers/test', + async ({ request }) => { + const requestBody = await request.json(); + mock(requestBody); - return res.once(ctx.status(200)); - }) + return HttpResponse.json({}); + }, + { + once: true, + } + ) ); return mock; @@ -56,12 +63,18 @@ export const setupSaveEndpointMock = (server: SetupServer) => { const mock = jest.fn(); server.use( - rest.post('/api/alertmanager/grafana/config/api/v1/alerts', async (req, res, ctx) => { - const requestBody = await req.json(); - mock(requestBody); + http.post( + '/api/alertmanager/grafana/config/api/v1/alerts', + async ({ request }) => { + const requestBody = await request.json(); + mock(requestBody); - return res.once(ctx.status(200)); - }) + return HttpResponse.json({}); + }, + { + once: true, + } + ) ); return mock; diff --git a/public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts b/public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts index 88ef97018e997..adbed73cb486a 100644 --- a/public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts +++ b/public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts @@ -1,4 +1,5 @@ -import { rest } from 'msw'; +import 'whatwg-fetch'; +import { http, HttpResponse } from 'msw'; import { SetupServer } from 'msw/lib/node'; import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; @@ -10,14 +11,20 @@ export const MIMIR_DATASOURCE_UID = 'mimir'; export default (server: SetupServer) => { server.use( - rest.get(`/api/alertmanager/${MIMIR_DATASOURCE_UID}/config/api/v1/alerts`, (_req, res, ctx) => - res(ctx.json<AlertManagerCortexConfig>(mimirAlertmanagerMock)) + http.get(`/api/alertmanager/${MIMIR_DATASOURCE_UID}/config/api/v1/alerts`, () => + HttpResponse.json(mimirAlertmanagerMock) ), - rest.get(`/api/datasources/proxy/uid/${MIMIR_DATASOURCE_UID}/api/v1/status/buildinfo`, (_req, res, ctx) => - res(ctx.status(404)) + http.get(`/api/datasources/proxy/uid/${MIMIR_DATASOURCE_UID}/api/v1/status/buildinfo`, () => + HttpResponse.json<AlertManagerCortexConfig>( + { + template_files: {}, + alertmanager_config: {}, + }, + { status: 404 } + ) ), // this endpoint will respond if the OnCall plugin is installed - rest.get('/api/plugins/grafana-oncall-app/settings', (_req, res, ctx) => res(ctx.status(404))) + http.get('/api/plugins/grafana-oncall-app/settings', () => HttpResponse.json({}, { status: 404 })) ); return server; diff --git a/public/app/features/alerting/unified/components/contact-points/__mocks__/vanillaAlertmanagerServer.ts b/public/app/features/alerting/unified/components/contact-points/__mocks__/vanillaAlertmanagerServer.ts index b834a5a515ff2..6c4943e28a2c0 100644 --- a/public/app/features/alerting/unified/components/contact-points/__mocks__/vanillaAlertmanagerServer.ts +++ b/public/app/features/alerting/unified/components/contact-points/__mocks__/vanillaAlertmanagerServer.ts @@ -1,4 +1,5 @@ -import { rest } from 'msw'; +import 'whatwg-fetch'; +import { http, HttpResponse } from 'msw'; import { SetupServer } from 'msw/lib/node'; import { AlertmanagerStatus } from 'app/plugins/datasource/alertmanager/types'; @@ -10,11 +11,11 @@ export const VANILLA_ALERTMANAGER_DATASOURCE_UID = 'alertmanager'; export default (server: SetupServer) => { server.use( - rest.get(`/api/alertmanager/${VANILLA_ALERTMANAGER_DATASOURCE_UID}/api/v2/status`, (_req, res, ctx) => - res(ctx.json<AlertmanagerStatus>(vanillaAlertManagerConfig)) + http.get(`/api/alertmanager/${VANILLA_ALERTMANAGER_DATASOURCE_UID}/api/v2/status`, () => + HttpResponse.json<AlertmanagerStatus>(vanillaAlertManagerConfig) ), // this endpoint will respond if the OnCall plugin is installed - rest.get('/api/plugins/grafana-oncall-app/settings', (_req, res, ctx) => res(ctx.status(404))) + http.get('/api/plugins/grafana-oncall-app/settings', () => HttpResponse.json({}, { status: 404 })) ); return server; diff --git a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap index dd84a65a6aeb2..ea4fc89ca9001 100644 --- a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap +++ b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap @@ -144,5 +144,6 @@ exports[`useContactPoints should return contact points with status 1`] = ` ], "error": undefined, "isLoading": false, + "refetchReceivers": [Function], } `; diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx index 90557614f0e8c..8d6ca99a720b5 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx @@ -27,7 +27,20 @@ const RECEIVER_STATUS_POLLING_INTERVAL = 10 * 1000; // 10 seconds * 3. (if available) additional metadata about Grafana Managed contact points * 4. (if available) the OnCall plugin metadata */ -export function useContactPointsWithStatus() { +interface UseContactPointsWithStatusOptions { + includePoliciesCount: boolean; + receiverStatusPollingInterval?: number; +} + +const defaultHookOptions = { + includePoliciesCount: true, + receiverStatusPollingInterval: RECEIVER_STATUS_POLLING_INTERVAL, +}; + +export function useContactPointsWithStatus({ + includePoliciesCount, + receiverStatusPollingInterval, +}: UseContactPointsWithStatusOptions = defaultHookOptions) { const { selectedAlertmanager, isGrafanaAlertmanager } = useAlertmanager(); const { installed: onCallPluginInstalled, loading: onCallPluginStatusLoading } = usePluginBridge( SupportedPlugin.OnCall @@ -37,8 +50,8 @@ export function useContactPointsWithStatus() { const fetchContactPointsStatus = alertmanagerApi.endpoints.getContactPointsStatus.useQuery(undefined, { refetchOnFocus: true, refetchOnReconnect: true, - // re-fetch status every so often for up-to-date information - pollingInterval: RECEIVER_STATUS_POLLING_INTERVAL, + // re-fetch status every so often for up-to-date information, allow disabling by passing "receiverStatusPollingInterval: 0" + pollingInterval: receiverStatusPollingInterval, // skip fetching receiver statuses if not Grafana AM skip: !isGrafanaAlertmanager, }); @@ -64,6 +77,7 @@ export function useContactPointsWithStatus() { } // fetch the latest config from the Alertmanager + // we use this endpoint only when we need to get the number of policies const fetchAlertmanagerConfiguration = alertmanagerApi.endpoints.getAlertmanagerConfiguration.useQuery( selectedAlertmanager!, { @@ -73,30 +87,56 @@ export function useContactPointsWithStatus() { ...result, contactPoints: result.data ? enhanceContactPointsWithMetadata( - result.data, fetchContactPointsStatus.data, fetchReceiverMetadata.data, - onCallMetadata + onCallMetadata, + result.data.alertmanager_config.receivers ?? [], + result.data ) : [], }), + skip: !includePoliciesCount, } ); + // for Grafana Managed Alertmanager, we use the new read-only endpoint for getting the list of contact points + const fetchGrafanaContactPoints = alertmanagerApi.endpoints.getContactPointsList.useQuery(undefined, { + refetchOnFocus: true, + refetchOnReconnect: true, + selectFromResult: (result) => ({ + ...result, + contactPoints: result.data + ? enhanceContactPointsWithMetadata( + fetchContactPointsStatus.data, + fetchReceiverMetadata.data, + onCallMetadata, + result.data, // contact points from the new readonly endpoint + undefined //no config data + ) + : [], + }), + skip: includePoliciesCount || !isGrafanaAlertmanager, + }); + // we will fail silently for fetching OnCall plugin status and integrations - const error = fetchAlertmanagerConfiguration.error ?? fetchContactPointsStatus.error; + const error = + fetchAlertmanagerConfiguration.error || fetchGrafanaContactPoints.error || fetchContactPointsStatus.error; const isLoading = fetchAlertmanagerConfiguration.isLoading || + fetchGrafanaContactPoints.isLoading || fetchContactPointsStatus.isLoading || onCallPluginStatusLoading || onCallPluginIntegrationsLoading; - const contactPoints = fetchAlertmanagerConfiguration.contactPoints.sort((a, b) => a.name.localeCompare(b.name)); - + const unsortedContactPoints = includePoliciesCount + ? fetchAlertmanagerConfiguration.contactPoints + : fetchGrafanaContactPoints.contactPoints; + const contactPoints = unsortedContactPoints.sort((a, b) => a.name.localeCompare(b.name)); return { error, isLoading, contactPoints, + refetchReceivers: fetchGrafanaContactPoints.refetch, }; } diff --git a/public/app/features/alerting/unified/components/contact-points/utils.test.ts b/public/app/features/alerting/unified/components/contact-points/utils.test.ts new file mode 100644 index 0000000000000..4e340e33577e2 --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/utils.test.ts @@ -0,0 +1,157 @@ +import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types'; + +import { ReceiverTypes } from '../receivers/grafanaAppReceivers/onCall/onCall'; + +import { RECEIVER_META_KEY, RECEIVER_PLUGIN_META_KEY } from './useContactPoints'; +import { + ReceiverConfigWithMetadata, + getReceiverDescription, + isAutoGeneratedPolicy, + isProvisioned, + summarizeEmailAddresses, +} from './utils'; + +describe('isProvisioned', () => { + it('should return true when at least one receiver is provisioned', () => { + const contactPoint: GrafanaManagedContactPoint = { + name: 'my-contact-point', + grafana_managed_receiver_configs: [ + { name: 'email', provenance: 'api', type: 'email', disableResolveMessage: false, settings: {} }, + ], + }; + + expect(isProvisioned(contactPoint)).toBe(true); + }); + + it('should return false when no receiver was provisioned', () => { + const contactPoint: GrafanaManagedContactPoint = { + name: 'my-contact-point', + grafana_managed_receiver_configs: [ + { name: 'email', provenance: undefined, type: 'email', disableResolveMessage: false, settings: {} }, + ], + }; + + expect(isProvisioned(contactPoint)).toBe(false); + }); +}); + +describe('isAutoGeneratedPolicy', () => { + it('should return false when not enabled', () => { + expect(isAutoGeneratedPolicy({})).toBe(false); + }); +}); + +describe('getReceiverDescription', () => { + it('should show multiple email addresses', () => { + const receiver: ReceiverConfigWithMetadata = { + name: 'email', + provenance: undefined, + type: 'email', + disableResolveMessage: false, + settings: { addresses: 'test1@test.com,test2@test.com,test3@test.com,test4@test.com' }, + [RECEIVER_META_KEY]: { + name: 'Email', + description: 'The email receiver', + }, + }; + + expect(getReceiverDescription(receiver)).toBe('test1@test.com, test2@test.com, test3@test.com, +1 more'); + }); + + it('should work for Slack', () => { + const output = '#channel'; + const receiver1: ReceiverConfigWithMetadata = { + name: 'slack', + provenance: undefined, + type: 'slack', + disableResolveMessage: false, + settings: { recipient: '#channel' }, + [RECEIVER_META_KEY]: { + name: 'Slack', + description: 'The Slack receiver', + }, + }; + + const receiver2: ReceiverConfigWithMetadata = { + name: 'slack', + provenance: undefined, + type: 'slack', + disableResolveMessage: false, + settings: { recipient: 'channel' }, + [RECEIVER_META_KEY]: { + name: 'Slack', + description: 'The Slack receiver', + }, + }; + + expect(getReceiverDescription(receiver1)).toBe(output); + expect(getReceiverDescription(receiver2)).toBe(output); + }); + + it('should work for OnCall', () => { + const output = 'The OnCall receiver'; + const input: ReceiverConfigWithMetadata = { + name: 'my oncall', + provenance: undefined, + type: ReceiverTypes.OnCall, + disableResolveMessage: false, + settings: {}, + [RECEIVER_PLUGIN_META_KEY]: { + description: output, + icon: '', + title: '', + }, + [RECEIVER_META_KEY]: { + name: '', + }, + }; + + expect(getReceiverDescription(input)).toBe(output); + }); + + it('should work for any type', () => { + const output = 'Some description of the receiver'; + const input: ReceiverConfigWithMetadata = { + name: 'some receiver', + provenance: undefined, + type: 'some', + disableResolveMessage: false, + settings: {}, + [RECEIVER_META_KEY]: { + name: 'Some Receiver', + description: output, + }, + }; + + expect(getReceiverDescription(input)).toBe(output); + }); + + it('should work for any type with no description', () => { + const input: ReceiverConfigWithMetadata = { + name: 'some receiver', + provenance: undefined, + type: 'some', + disableResolveMessage: false, + settings: {}, + [RECEIVER_META_KEY]: { + name: 'Some Receiver', + }, + }; + + expect(getReceiverDescription(input)).toBe(undefined); + }); +}); + +describe('summarizeEmailAddresses', () => { + it('should work with one email address', () => { + expect(summarizeEmailAddresses('test@test.com')).toBe('test@test.com'); + }); + + it('should work with multiple types of separators', () => { + const output = 'foo@foo.com, bar@bar.com'; + + expect(summarizeEmailAddresses('foo@foo.com, bar@bar.com')).toBe(output); + expect(summarizeEmailAddresses(' foo@foo.com; bar@bar.com')).toBe(output); + expect(summarizeEmailAddresses('foo@foo.com\n bar@bar.com ')).toBe(output); + }); +}); diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts index 79a71733fb7e0..479bde2b49482 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.ts @@ -1,10 +1,13 @@ -import { countBy, split, trim, upperFirst } from 'lodash'; +import { countBy, difference, take, trim, upperFirst } from 'lodash'; import { ReactNode } from 'react'; +import { config } from '@grafana/runtime'; import { AlertManagerCortexConfig, GrafanaManagedContactPoint, GrafanaManagedReceiverConfig, + MatcherOperator, + Receiver, Route, } from 'app/plugins/datasource/alertmanager/types'; import { NotifierDTO, NotifierStatus, ReceiversStateDTO } from 'app/types'; @@ -17,6 +20,8 @@ import { getOnCallMetadata, ReceiverPluginMetadata } from '../receivers/grafanaA import { RECEIVER_META_KEY, RECEIVER_PLUGIN_META_KEY, RECEIVER_STATUS_KEY } from './useContactPoints'; +const AUTOGENERATED_RECEIVER_POLICY_MATCHER_KEY = '__grafana_receiver__'; + export function isProvisioned(contactPoint: GrafanaManagedContactPoint) { // for some reason the provenance is on the receiver and not the entire contact point const provenance = contactPoint.grafana_managed_receiver_configs?.find((receiver) => receiver.provenance)?.provenance; @@ -26,21 +31,30 @@ export function isProvisioned(contactPoint: GrafanaManagedContactPoint) { // TODO we should really add some type information to these receiver settings... export function getReceiverDescription(receiver: ReceiverConfigWithMetadata): ReactNode | undefined { + if (!receiver.settings) { + return undefined; + } switch (receiver.type) { case 'email': { const hasEmailAddresses = 'addresses' in receiver.settings; // when dealing with alertmanager email_configs we don't normalize the settings return hasEmailAddresses ? summarizeEmailAddresses(receiver.settings['addresses']) : undefined; } case 'slack': { - const channelName = receiver.settings['recipient']; - return channelName ? `#${channelName}` : undefined; + const recipient: string | undefined = receiver.settings['recipient']; + if (!recipient) { + return; + } + + // Slack channel name might have a "#" in the recipient already + const channelName = recipient.replace(/^#/, ''); + return `#${channelName}`; } case 'kafka': { - const topicName = receiver.settings['kafkaTopic']; + const topicName: string | undefined = receiver.settings['kafkaTopic']; return topicName; } case 'webhook': { - const url = receiver.settings['url']; + const url: string | undefined = receiver.settings['url']; return url; } case ReceiverTypes.OnCall: { @@ -53,20 +67,22 @@ export function getReceiverDescription(receiver: ReceiverConfigWithMetadata): Re // input: foo+1@bar.com, foo+2@bar.com, foo+3@bar.com, foo+4@bar.com // output: foo+1@bar.com, foo+2@bar.com, +2 more -function summarizeEmailAddresses(addresses: string): string { +export function summarizeEmailAddresses(addresses: string): string { const MAX_ADDRESSES_SHOWN = 3; const SUPPORTED_SEPARATORS = /,|;|\n+/g; + // split all email addresses const emails = addresses.trim().split(SUPPORTED_SEPARATORS).map(trim); - const notShown = emails.length - MAX_ADDRESSES_SHOWN; + // grab the first 3 and the rest + const summary = take(emails, MAX_ADDRESSES_SHOWN); + const rest = difference(emails, summary); - const truncatedAddresses = split(addresses, SUPPORTED_SEPARATORS, MAX_ADDRESSES_SHOWN); - if (notShown > 0) { - truncatedAddresses.push(`+${notShown} more`); + if (rest.length) { + summary.push(`+${rest.length} more`); } - return truncatedAddresses.join(', '); + return summary.join(', '); } // Grafana Managed contact points have receivers with additional diagnostics @@ -83,7 +99,7 @@ export interface ReceiverConfigWithMetadata extends GrafanaManagedReceiverConfig } export interface ContactPointWithMetadata extends GrafanaManagedContactPoint { - numberOfPolicies: number; + numberOfPolicies?: number; // now is optional as we don't have the data from the read-only endpoint grafana_managed_receiver_configs: ReceiverConfigWithMetadata[]; } @@ -91,30 +107,36 @@ export interface ContactPointWithMetadata extends GrafanaManagedContactPoint { * This function adds the status information for each of the integrations (contact point types) in a contact point * 1. we iterate over all contact points * 2. for each contact point we "enhance" it with the status or "undefined" for vanilla Alertmanager + * contactPoints: list of contact points + * alertmanagerConfiguration: optional as is passed when we need to get number of policies for each contact point + * and we prefer using the data from the read-only endpoint. */ export function enhanceContactPointsWithMetadata( - result: AlertManagerCortexConfig, status: ReceiversStateDTO[] = [], notifiers: NotifierDTO[] = [], - onCallIntegrations: OnCallIntegrationDTO[] | undefined | null + onCallIntegrations: OnCallIntegrationDTO[] | undefined | null, + contactPoints: Receiver[], + alertmanagerConfiguration?: AlertManagerCortexConfig ): ContactPointWithMetadata[] { - const contactPoints = result.alertmanager_config.receivers ?? []; - // compute the entire inherited tree before finding what notification policies are using a particular contact point - const fullyInheritedTree = computeInheritedTree(result?.alertmanager_config?.route ?? {}); + const fullyInheritedTree = computeInheritedTree(alertmanagerConfiguration?.alertmanager_config?.route ?? {}); const usedContactPoints = getUsedContactPoints(fullyInheritedTree); const usedContactPointsByName = countBy(usedContactPoints); - return contactPoints.map((contactPoint) => { + const contactPointsList = alertmanagerConfiguration + ? alertmanagerConfiguration?.alertmanager_config.receivers ?? [] + : contactPoints ?? []; + + return contactPointsList.map((contactPoint) => { const receivers = extractReceivers(contactPoint); const statusForReceiver = status.find((status) => status.name === contactPoint.name); return { ...contactPoint, - numberOfPolicies: usedContactPointsByName[contactPoint.name] ?? 0, + numberOfPolicies: + alertmanagerConfiguration && usedContactPointsByName && (usedContactPointsByName[contactPoint.name] ?? 0), grafana_managed_receiver_configs: receivers.map((receiver, index) => { const isOnCallReceiver = receiver.type === ReceiverTypes.OnCall; - return { ...receiver, [RECEIVER_STATUS_KEY]: statusForReceiver?.integrations[index], @@ -127,9 +149,27 @@ export function enhanceContactPointsWithMetadata( }); } +export function isAutoGeneratedPolicy(route: Route) { + const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false; + if (!simplifiedRoutingToggleEnabled) { + return false; + } + if (!route.object_matchers) { + return false; + } + return ( + route.object_matchers.some((objectMatcher) => { + return ( + objectMatcher[0] === AUTOGENERATED_RECEIVER_POLICY_MATCHER_KEY && objectMatcher[1] === MatcherOperator.equal + ); + }) ?? false + ); +} + export function getUsedContactPoints(route: Route): string[] { const childrenContactPoints = route.routes?.flatMap((route) => getUsedContactPoints(route)) ?? []; - if (route.receiver) { + // we don't want to count the autogenerated policy for receiver level, for checking if a contact point is used + if (route.receiver && !isAutoGeneratedPolicy(route)) { return [route.receiver, ...childrenContactPoints]; } diff --git a/public/app/features/alerting/unified/components/export/FileExportPreview.tsx b/public/app/features/alerting/unified/components/export/FileExportPreview.tsx index 2a7f5c06ee6d2..df5fcfd949535 100644 --- a/public/app/features/alerting/unified/components/export/FileExportPreview.tsx +++ b/public/app/features/alerting/unified/components/export/FileExportPreview.tsx @@ -4,9 +4,9 @@ import React, { useCallback, useMemo } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; -import { Button, ClipboardButton, CodeEditor, useStyles2 } from '@grafana/ui'; +import { Alert, Button, ClipboardButton, CodeEditor, TextLink, useStyles2 } from '@grafana/ui'; -import { allGrafanaExportProviders, ExportFormats } from './providers'; +import { allGrafanaExportProviders, ExportFormats, ExportProvider, ProvisioningType } from './providers'; interface FileExportPreviewProps { format: ExportFormats; @@ -19,6 +19,7 @@ interface FileExportPreviewProps { export function FileExportPreview({ format, textDefinition, downloadFileName, onClose }: FileExportPreviewProps) { const styles = useStyles2(fileExportPreviewStyles); + const provider = allGrafanaExportProviders[format]; const onDownload = useCallback(() => { const blob = new Blob([textDefinition], { @@ -28,13 +29,13 @@ export function FileExportPreview({ format, textDefinition, downloadFileName, on }, [textDefinition, downloadFileName, format]); const formattedTextDefinition = useMemo(() => { - const provider = allGrafanaExportProviders[format]; return provider.formatter ? provider.formatter(textDefinition) : textDefinition; - }, [format, textDefinition]); + }, [provider, textDefinition]); return ( // TODO Handle empty content <div className={styles.container}> + <FileExportInlineDocumentation exportProvider={provider} /> <div className={styles.content}> <AutoSizer disableWidth> {({ height }) => ( @@ -87,3 +88,60 @@ const fileExportPreviewStyles = (theme: GrafanaTheme2) => ({ gap: ${theme.spacing(1)}; `, }); + +function FileExportInlineDocumentation({ exportProvider }: { exportProvider: ExportProvider<unknown> }) { + const { name, type } = exportProvider; + + const exportInlineDoc: Record<ProvisioningType, { title: string; component: React.ReactNode }> = { + file: { + title: 'File-provisioning format', + component: ( + <> + {name} format is only valid for File Provisioning.{' '} + <TextLink + href="https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/file-provisioning/" + external + > + Read more in the docs. + </TextLink> + </> + ), + }, + api: { + title: 'API-provisioning format', + component: ( + <> + {name} format is only valid for API Provisioning.{' '} + <TextLink + href="https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/http-api-provisioning/" + external + > + Read more in the docs. + </TextLink> + </> + ), + }, + terraform: { + title: 'Terraform-provisioning format', + component: ( + <> + {name} format is only valid for Terraform Provisioning.{' '} + <TextLink + href="https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/" + external + > + Read more in the docs. + </TextLink> + </> + ), + }, + }; + + const { title, component } = exportInlineDoc[type]; + + return ( + <Alert title={title} severity="info" bottomSpacing={0} topSpacing={0}> + {component} + </Alert> + ); +} diff --git a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx index 3e6b40693467a..48175f94ccfc0 100644 --- a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx +++ b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx @@ -2,10 +2,9 @@ import { render, waitFor, waitForElementToBeRemoved } from '@testing-library/rea import userEvent from '@testing-library/user-event'; import React from 'react'; import { Route } from 'react-router-dom'; -import { AutoSizerProps } from 'react-virtualized-auto-sizer'; +import { Props } from 'react-virtualized-auto-sizer'; import { byRole, byTestId, byText } from 'testing-library-selector'; -import { selectors } from '@grafana/e2e-selectors'; import { locationService } from '@grafana/runtime'; import { TestProvider } from '../../../../../../test/helpers/TestProvider'; @@ -24,7 +23,13 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ })); jest.mock('react-virtualized-auto-sizer', () => { - return ({ children }: AutoSizerProps) => children({ height: 600, width: 1 }); + return ({ children }: Props) => + children({ + height: 600, + scaledHeight: 600, + scaledWidth: 1, + width: 1, + }); }); jest.mock('@grafana/ui', () => ({ ...jest.requireActual('@grafana/ui'), @@ -36,7 +41,6 @@ const ui = { form: { nameInput: byRole('textbox', { name: 'name' }), folder: byTestId('folder-picker'), - folderContainer: byTestId(selectors.components.FolderPicker.containerV2), group: byTestId('group-picker'), annotationKey: (idx: number) => byTestId(`annotation-key-${idx}`), annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`), @@ -75,7 +79,7 @@ describe('GrafanaModifyExport', () => { const grafanaRule = getGrafanaRule(undefined, { uid: 'test-rule-uid', title: 'cpu-usage', - namespace_uid: 'folder-test-uid', + namespace_uid: 'folderUID1', data: [ { refId: 'A', @@ -97,21 +101,23 @@ describe('GrafanaModifyExport', () => { mockSearchApi(server).search([ mockDashboardSearchItem({ title: grafanaRule.namespace.name, - uid: 'folder-test-uid', + uid: 'folderUID1', + url: '', + tags: [], type: DashboardSearchItemType.DashFolder, }), ]); mockAlertRuleApi(server).rulerRules(GRAFANA_RULES_SOURCE_NAME, { [grafanaRule.namespace.name]: [{ name: grafanaRule.group.name, interval: '1m', rules: [grafanaRule.rulerRule!] }], }); - mockAlertRuleApi(server).rulerRuleGroup( - GRAFANA_RULES_SOURCE_NAME, - grafanaRule.namespace.name, - grafanaRule.group.name, - { name: grafanaRule.group.name, interval: '1m', rules: [grafanaRule.rulerRule!] } - ); - mockExportApi(server).modifiedExport(grafanaRule.namespace.name, { + mockAlertRuleApi(server).rulerRuleGroup(GRAFANA_RULES_SOURCE_NAME, 'folderUID1', grafanaRule.group.name, { + name: grafanaRule.group.name, + interval: '1m', + rules: [grafanaRule.rulerRule!], + }); + mockExportApi(server).modifiedExport('folderUID1', { yaml: 'Yaml Export Content', + json: 'Json Export Content', }); const user = userEvent.setup(); @@ -127,6 +133,7 @@ describe('GrafanaModifyExport', () => { expect(drawer).toBeInTheDocument(); expect(ui.exportDrawer.yamlTab.get(drawer)).toHaveAttribute('aria-selected', 'true'); + await waitFor(() => { expect(ui.exportDrawer.editor.get(drawer)).toHaveTextContent('Yaml Export Content'); }); diff --git a/public/app/features/alerting/unified/components/export/providers.ts b/public/app/features/alerting/unified/components/export/providers.ts index 356909609bb08..518ced9e5f2c7 100644 --- a/public/app/features/alerting/unified/components/export/providers.ts +++ b/public/app/features/alerting/unified/components/export/providers.ts @@ -1,12 +1,16 @@ +export type ProvisioningType = 'file' | 'api' | 'terraform'; + export interface ExportProvider<TFormat> { name: string; exportFormat: TFormat; + type: ProvisioningType; formatter?: (raw: string) => string; } export const JsonExportProvider: ExportProvider<'json'> = { name: 'JSON', exportFormat: 'json', + type: 'file', formatter: (raw: string) => { try { return JSON.stringify(JSON.parse(raw), null, 4); @@ -19,11 +23,13 @@ export const JsonExportProvider: ExportProvider<'json'> = { export const YamlExportProvider: ExportProvider<'yaml'> = { name: 'YAML', exportFormat: 'yaml', + type: 'file', }; export const HclExportProvider: ExportProvider<'hcl'> = { name: 'Terraform (HCL)', exportFormat: 'hcl', + type: 'terraform', }; export const allGrafanaExportProviders = { diff --git a/public/app/features/alerting/unified/components/expressions/Expression.tsx b/public/app/features/alerting/unified/components/expressions/Expression.tsx index b748e0308f2bf..73db0a2f5bfaf 100644 --- a/public/app/features/alerting/unified/components/expressions/Expression.tsx +++ b/public/app/features/alerting/unified/components/expressions/Expression.tsx @@ -1,13 +1,15 @@ import { css, cx } from '@emotion/css'; import { uniqueId } from 'lodash'; import React, { FC, useCallback, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { DataFrame, dateTimeFormat, GrafanaTheme2, isTimeSeriesFrames, LoadingState, PanelData } from '@grafana/data'; -import { AutoSizeInput, Button, clearButtonStyles, IconButton, useStyles2, Stack } from '@grafana/ui'; +import { Alert, AutoSizeInput, Button, clearButtonStyles, IconButton, Stack, useStyles2 } from '@grafana/ui'; import { ClassicConditions } from 'app/features/expressions/components/ClassicConditions'; import { Math } from 'app/features/expressions/components/Math'; import { Reduce } from 'app/features/expressions/components/Reduce'; import { Resample } from 'app/features/expressions/components/Resample'; +import { SqlExpr } from 'app/features/expressions/components/SqlExpr'; import { Threshold } from 'app/features/expressions/components/Threshold'; import { ExpressionQuery, @@ -56,6 +58,19 @@ export const Expression: FC<ExpressionProps> = ({ const queryType = query?.type; + const { setError, clearErrors } = useFormContext(); + + const onQueriesValidationError = useCallback( + (errorMsg: string | undefined) => { + if (errorMsg) { + setError('queries', { type: 'custom', message: errorMsg }); + } else { + clearErrors('queries'); + } + }, + [setError, clearErrors] + ); + const isLoading = data && Object.values(data).some((d) => Boolean(d) && d.state === LoadingState.Loading); const hasResults = Array.isArray(data?.series) && !isLoading; const series = data?.series ?? []; @@ -85,13 +100,25 @@ export const Expression: FC<ExpressionProps> = ({ return <ClassicConditions onChange={onChangeQuery} query={query} refIds={availableRefIds} />; case ExpressionQueryType.threshold: - return <Threshold onChange={onChangeQuery} query={query} labelWidth={'auto'} refIds={availableRefIds} />; + return ( + <Threshold + onChange={onChangeQuery} + query={query} + labelWidth={'auto'} + refIds={availableRefIds} + onError={onQueriesValidationError} + useHysteresis={true} + /> + ); + + case ExpressionQueryType.sql: + return <SqlExpr onChange={onChangeQuery} query={query} refIds={availableRefIds} />; default: return <>Expression not supported: {query.type}</>; } }, - [onChangeQuery, queries] + [onChangeQuery, queries, onQueriesValidationError] ); const selectedExpressionType = expressionTypes.find((o) => o.value === queryType); const selectedExpressionDescription = selectedExpressionType?.description ?? ''; @@ -113,12 +140,20 @@ export const Expression: FC<ExpressionProps> = ({ onUpdateRefId={(newRefId) => onUpdateRefId(query.refId, newRefId)} onUpdateExpressionType={(type) => onUpdateExpressionType(query.refId, type)} onSetCondition={onSetCondition} - warning={warning} - error={error} query={query} alertCondition={alertCondition} /> <div className={styles.expression.body}> + {error && ( + <Alert title="Expression failed" severity="error"> + {error.message} + </Alert> + )} + {warning && ( + <Alert title="Expression warning" severity="warning"> + {warning.message} + </Alert> + )} <div className={styles.expression.description}>{selectedExpressionDescription}</div> {renderExpressionType(query)} </div> @@ -252,8 +287,6 @@ interface HeaderProps { onUpdateRefId: (refId: string) => void; onRemoveExpression: () => void; onUpdateExpressionType: (type: ExpressionQueryType) => void; - warning?: Error; - error?: Error; onSetCondition: (refId: string) => void; query: ExpressionQuery; alertCondition: boolean; @@ -264,11 +297,9 @@ const Header: FC<HeaderProps> = ({ queryType, onUpdateRefId, onRemoveExpression, - warning, onSetCondition, alertCondition, query, - error, }) => { const styles = useStyles2(getStyles); const clearButton = useStyles2(clearButtonStyles); @@ -312,12 +343,7 @@ const Header: FC<HeaderProps> = ({ <div>{getExpressionLabel(queryType)}</div> </Stack> <Spacer /> - <ExpressionStatusIndicator - error={error} - warning={warning} - onSetCondition={() => onSetCondition(query.refId)} - isCondition={alertCondition} - /> + <ExpressionStatusIndicator onSetCondition={() => onSetCondition(query.refId)} isCondition={alertCondition} /> <IconButton name="trash-alt" variant="secondary" diff --git a/public/app/features/alerting/unified/components/expressions/ExpressionStatusIndicator.test.tsx b/public/app/features/alerting/unified/components/expressions/ExpressionStatusIndicator.test.tsx index 1d8add4e3d71a..bab68c9c6dc9d 100644 --- a/public/app/features/alerting/unified/components/expressions/ExpressionStatusIndicator.test.tsx +++ b/public/app/features/alerting/unified/components/expressions/ExpressionStatusIndicator.test.tsx @@ -4,47 +4,15 @@ import React from 'react'; import { ExpressionStatusIndicator } from './ExpressionStatusIndicator'; describe('ExpressionStatusIndicator', () => { - it('should render two elements when error and not condition', () => { - render(<ExpressionStatusIndicator isCondition={false} warning={new Error('this is a warning')} />); - - expect(screen.getByText('Warning')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Set as alert condition' })).toBeInTheDocument(); - }); - - it('should render one element when warning and condition', () => { - render(<ExpressionStatusIndicator isCondition warning={new Error('this is a warning')} />); - - expect(screen.getByText('Alert condition')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Set as alert condition' })).not.toBeInTheDocument(); - }); - - it('should render two elements when error and not condition', () => { - render(<ExpressionStatusIndicator isCondition={false} error={new Error('this is a error')} />); - - expect(screen.getByText('Error')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Set as alert condition' })).toBeInTheDocument(); - }); - - it('should render one element when error and condition', () => { - render(<ExpressionStatusIndicator isCondition error={new Error('this is a error')} />); - - expect(screen.getByText('Alert condition')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Set as alert condition' })).not.toBeInTheDocument(); - }); - it('should render one element if condition', () => { render(<ExpressionStatusIndicator isCondition />); - expect(screen.queryByText('Error')).not.toBeInTheDocument(); - expect(screen.queryByText('Warning')).not.toBeInTheDocument(); expect(screen.getByText('Alert condition')).toBeInTheDocument(); }); it('should render one element if not condition', () => { render(<ExpressionStatusIndicator isCondition={false} />); - expect(screen.queryByText('Error')).not.toBeInTheDocument(); - expect(screen.queryByText('Warning')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Alert condition' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Set as alert condition' })).toBeInTheDocument(); }); diff --git a/public/app/features/alerting/unified/components/expressions/ExpressionStatusIndicator.tsx b/public/app/features/alerting/unified/components/expressions/ExpressionStatusIndicator.tsx index 28a668e25b03f..71dbdd91297fb 100644 --- a/public/app/features/alerting/unified/components/expressions/ExpressionStatusIndicator.tsx +++ b/public/app/features/alerting/unified/components/expressions/ExpressionStatusIndicator.tsx @@ -5,35 +5,17 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Badge, clearButtonStyles, useStyles2 } from '@grafana/ui'; interface AlertConditionProps { - warning?: Error; - error?: Error; isCondition?: boolean; onSetCondition?: () => void; } -export const ExpressionStatusIndicator = ({ error, warning, isCondition, onSetCondition }: AlertConditionProps) => { +export const ExpressionStatusIndicator = ({ isCondition, onSetCondition }: AlertConditionProps) => { const styles = useStyles2(getStyles); - const elements: JSX.Element[] = []; - - if (error && isCondition) { - return <Badge color="red" icon="exclamation-circle" text="Alert condition" tooltip={error.message} />; - } else if (error) { - elements.push(<Badge key="error" color="red" icon="exclamation-circle" text="Error" tooltip={error.message} />); - } - - if (warning && isCondition) { - return <Badge color="orange" icon="exclamation-triangle" text="Alert condition" tooltip={warning.message} />; - } else if (warning) { - elements.push( - <Badge key="warning" color="orange" icon="exclamation-triangle" text="Warning" tooltip={warning.message} /> - ); - } - if (isCondition) { - elements.unshift(<Badge key="condition" color="green" icon="check" text="Alert condition" />); + return <Badge key="condition" color="green" icon="check" text="Alert condition" />; } else { - elements.unshift( + return ( <button key="make-condition" type="button" @@ -44,8 +26,6 @@ export const ExpressionStatusIndicator = ({ error, warning, isCondition, onSetCo </button> ); } - - return <>{elements}</>; }; const getStyles = (theme: GrafanaTheme2) => { diff --git a/public/app/features/alerting/unified/components/expressions/util.test.ts b/public/app/features/alerting/unified/components/expressions/util.test.ts index 7a20b352698b6..f8cb2d39d23ab 100644 --- a/public/app/features/alerting/unified/components/expressions/util.test.ts +++ b/public/app/features/alerting/unified/components/expressions/util.test.ts @@ -1,6 +1,18 @@ import { DataFrame, FieldType, toDataFrame } from '@grafana/data'; +import { CombinedRuleNamespace } from 'app/types/unified-alerting'; -import { getSeriesName, formatLabels, getSeriesValue, isEmptySeries, getSeriesLabels } from './util'; +import { mockDataSource } from '../../mocks'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; + +import { + decodeGrafanaNamespace, + encodeGrafanaNamespace, + formatLabels, + getSeriesLabels, + getSeriesName, + getSeriesValue, + isEmptySeries, +} from './util'; const EMPTY_FRAME: DataFrame = toDataFrame([]); const NAMED_FRAME: DataFrame = { @@ -34,6 +46,124 @@ describe('formatLabels', () => { }); }); +describe('decodeGrafanaNamespace', () => { + it('should work for regular Grafana namespaces', () => { + const grafanaNamespace: CombinedRuleNamespace = { + name: `my_rule_namespace`, + rulesSource: GRAFANA_RULES_SOURCE_NAME, + groups: [ + { + name: 'group1', + rules: [], + totals: {}, + }, + ], + }; + expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('name', 'my_rule_namespace'); + expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('parents', []); + }); + + it('should work for Grafana namespaces in nested folders format', () => { + const grafanaNamespace: CombinedRuleNamespace = { + name: `["parentUID","my_rule_namespace"]`, + rulesSource: GRAFANA_RULES_SOURCE_NAME, + groups: [ + { + name: 'group1', + rules: [], + totals: {}, + }, + ], + }; + + expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('name', 'my_rule_namespace'); + expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('parents', ['parentUID']); + }); + + it('should default to name if format is invalid: invalid JSON', () => { + const grafanaNamespace: CombinedRuleNamespace = { + name: `["parentUID"`, + rulesSource: GRAFANA_RULES_SOURCE_NAME, + groups: [ + { + name: 'group1', + rules: [], + totals: {}, + }, + ], + }; + + expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('name', `["parentUID"`); + expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('parents', []); + }); + + it('should default to name if format is invalid: empty array', () => { + const grafanaNamespace: CombinedRuleNamespace = { + name: `[]`, + rulesSource: GRAFANA_RULES_SOURCE_NAME, + groups: [ + { + name: 'group1', + rules: [], + totals: {}, + }, + ], + }; + + expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('name', `[]`); + expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('parents', []); + }); + + it('grab folder name if format is long array', () => { + const grafanaNamespace: CombinedRuleNamespace = { + name: `["parentUID","my_rule_namespace","another_part"]`, + rulesSource: GRAFANA_RULES_SOURCE_NAME, + groups: [ + { + name: 'group1', + rules: [], + totals: {}, + }, + ], + }; + + expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('name', 'another_part'); + expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('parents', ['parentUID', 'my_rule_namespace']); + }); + + it('should not change output for cloud namespaces', () => { + const cloudNamespace: CombinedRuleNamespace = { + name: `["parentUID","my_rule_namespace"]`, + rulesSource: mockDataSource(), + groups: [ + { + name: 'Prom group', + rules: [], + totals: {}, + }, + ], + }; + + expect(decodeGrafanaNamespace(cloudNamespace)).toHaveProperty('name', `["parentUID","my_rule_namespace"]`); + expect(decodeGrafanaNamespace(cloudNamespace)).toHaveProperty('parents', []); + }); +}); + +describe('encodeGrafanaNamespace', () => { + it('should encode with parents', () => { + const name = 'folder'; + const parents = ['1', '2', '3']; + + expect(encodeGrafanaNamespace(name, parents)).toBe(`["1","2","3","folder"]`); + }); + + it('should encode without parents', () => { + const name = 'folder'; + + expect(encodeGrafanaNamespace(name)).toBe(`["folder"]`); + }); +}); + describe('isEmptySeries', () => { it('should be true for empty series', () => { expect(isEmptySeries([EMPTY_FRAME])).toBe(true); diff --git a/public/app/features/alerting/unified/components/expressions/util.ts b/public/app/features/alerting/unified/components/expressions/util.ts index 2375f61e82091..07d732eb27bee 100644 --- a/public/app/features/alerting/unified/components/expressions/util.ts +++ b/public/app/features/alerting/unified/components/expressions/util.ts @@ -1,4 +1,9 @@ +import { dropRight, last } from 'lodash'; + import { DataFrame, Labels, roundDecimals } from '@grafana/data'; +import { CombinedRuleNamespace } from 'app/types/unified-alerting'; + +import { isCloudRulesSource } from '../../utils/datasource'; /** * ⚠️ `frame.fields` could be an empty array ⚠️ @@ -37,10 +42,66 @@ const formatLabels = (labels: Labels): string => { .join(', '); }; +interface DecodedNamespace { + name: string; + parents: string[]; +} + +/** + * After https://github.com/grafana/grafana/pull/74600, + * Grafana folder names will be returned from the API as a combination of the folder name and parent UID in a format of JSON array, + * where first element is parent UID and the second element is Title. + * + * Here we parse this to return the name of the last folder and the array of parent folders + */ +const decodeGrafanaNamespace = (namespace: CombinedRuleNamespace): DecodedNamespace => { + const namespaceName = namespace.name; + + if (isCloudRulesSource(namespace.rulesSource)) { + return { + name: namespaceName, + parents: [], + }; + } + + // try to parse the folder as a nested folder, if it fails fall back to returning the folder name as-is. + try { + const folderParts: string[] = JSON.parse(namespaceName); + if (!Array.isArray(folderParts)) { + throw new Error('not a nested Grafana folder'); + } + + const name = last(folderParts) ?? namespaceName; + const parents = dropRight(folderParts, 1); + + return { + name, + parents, + }; + } catch { + return { + name: namespace.name, + parents: [], + }; + } +}; + +const encodeGrafanaNamespace = (name: string, parents: string[] | undefined = []) => { + return JSON.stringify(parents.concat(name)); +}; + const isEmptySeries = (series: DataFrame[]): boolean => { const isEmpty = series.every((serie) => serie.fields.every((field) => field.values.every((value) => value == null))); return isEmpty; }; -export { getSeriesName, getSeriesValue, getSeriesLabels, formatLabels, isEmptySeries }; +export { + decodeGrafanaNamespace, + encodeGrafanaNamespace, + formatLabels, + getSeriesLabels, + getSeriesName, + getSeriesValue, + isEmptySeries, +}; diff --git a/public/app/features/alerting/unified/components/mute-timings/MuteTimingForm.tsx b/public/app/features/alerting/unified/components/mute-timings/MuteTimingForm.tsx index 781c25ede1bac..fbecd60d8c13e 100644 --- a/public/app/features/alerting/unified/components/mute-timings/MuteTimingForm.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/MuteTimingForm.tsx @@ -12,14 +12,16 @@ import { useAlertmanager } from '../../state/AlertmanagerContext'; import { updateAlertManagerConfigAction } from '../../state/actions'; import { MuteTimingFields } from '../../types/mute-timing-form'; import { renameMuteTimings } from '../../utils/alertmanager'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { makeAMLink } from '../../utils/misc'; -import { createMuteTiming, defaultTimeInterval } from '../../utils/mute-timings'; +import { createMuteTiming, defaultTimeInterval, isTimeIntervalDisabled } from '../../utils/mute-timings'; import { ProvisionedResource, ProvisioningAlert } from '../Provisioning'; import { MuteTimingTimeInterval } from './MuteTimingTimeInterval'; interface Props { - muteTiming?: MuteTimeInterval; + fromLegacyTimeInterval?: MuteTimeInterval; // mute time interval when comes from the old config , mute_time_intervals + fromTimeIntervals?: MuteTimeInterval; // mute time interval when comes from the new config , time_intervals. These two fields are mutually exclusive showError?: boolean; provenance?: string; loading?: boolean; @@ -36,12 +38,13 @@ const useDefaultValues = (muteTiming?: MuteTimeInterval): MuteTimingFields => { } const intervals = muteTiming.time_intervals.map((interval) => ({ - times: interval.times ?? defaultTimeInterval.times, - weekdays: interval.weekdays?.join(', ') ?? defaultTimeInterval.weekdays, - days_of_month: interval.days_of_month?.join(', ') ?? defaultTimeInterval.days_of_month, - months: interval.months?.join(', ') ?? defaultTimeInterval.months, - years: interval.years?.join(', ') ?? defaultTimeInterval.years, + times: interval.times, + weekdays: interval.weekdays?.join(', '), + days_of_month: interval.days_of_month?.join(', '), + months: interval.months?.join(', '), + years: interval.years?.join(', '), location: interval.location ?? defaultTimeInterval.location, + disable: isTimeIntervalDisabled(interval), })); return { @@ -50,7 +53,26 @@ const useDefaultValues = (muteTiming?: MuteTimeInterval): MuteTimingFields => { }; }; -const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) => { +const replaceMuteTiming = ( + originalTimings: MuteTimeInterval[], + existingTiming: MuteTimeInterval | undefined, + newTiming: MuteTimeInterval, + addNew: boolean +) => { + // we only add new timing if addNew is true. Otherwise, we just remove the existing timing + const originalTimingsWithoutNew = existingTiming + ? originalTimings?.filter(({ name }) => name !== existingTiming.name) + : originalTimings; + return addNew ? [...originalTimingsWithoutNew, newTiming] : [...originalTimingsWithoutNew]; +}; + +const MuteTimingForm = ({ + fromLegacyTimeInterval: fromMuteTimings, + fromTimeIntervals, + showError, + loading, + provenance, +}: Props) => { const dispatch = useDispatch(); const { selectedAlertmanager } = useAlertmanager(); const styles = useStyles2(getStyles); @@ -60,6 +82,12 @@ const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) = const { currentData: result } = useAlertmanagerConfig(selectedAlertmanager); const config = result?.alertmanager_config; + const fromIntervals = Boolean(fromTimeIntervals); + const muteTiming = fromIntervals ? fromTimeIntervals : fromMuteTimings; + + const originalMuteTimings = config?.mute_time_intervals ?? []; + const originalTimeIntervals = config?.time_intervals ?? []; + const defaultValues = useDefaultValues(muteTiming); const formApi = useForm({ defaultValues }); @@ -70,19 +98,44 @@ const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) = const newMuteTiming = createMuteTiming(values); - const muteTimings = muteTiming - ? config?.mute_time_intervals?.filter(({ name }) => name !== muteTiming.name) - : config?.mute_time_intervals; - + const isGrafanaDataSource = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME; + const isNewMuteTiming = fromTimeIntervals === undefined && fromMuteTimings === undefined; + + // If is Grafana data source, we wil save mute timings in the alertmanager_config.mute_time_intervals + // Otherwise, we will save it on alertmanager_config.time_intervals or alertmanager_config.mute_time_intervals depending on the original config + + const newMutetimeIntervals = isGrafanaDataSource + ? { + // for Grafana data source, we will save mute timings in the alertmanager_config.mute_time_intervals + mute_time_intervals: [ + ...replaceMuteTiming(originalTimeIntervals, fromTimeIntervals, newMuteTiming, false), + ...replaceMuteTiming(originalMuteTimings, fromMuteTimings, newMuteTiming, true), + ], + } + : { + // for non-Grafana data source, we will save mute timings in the alertmanager_config.time_intervals or alertmanager_config.mute_time_intervals depending on the original config + time_intervals: replaceMuteTiming( + originalTimeIntervals, + fromTimeIntervals, + newMuteTiming, + Boolean(fromTimeIntervals) || isNewMuteTiming + ), + mute_time_intervals: + Boolean(fromMuteTimings) && !isNewMuteTiming + ? replaceMuteTiming(originalMuteTimings, fromMuteTimings, newMuteTiming, true) + : undefined, + }; + + const { mute_time_intervals: _, time_intervals: __, ...configWithoutMuteTimings } = config ?? {}; const newConfig: AlertManagerCortexConfig = { ...result, alertmanager_config: { - ...config, + ...configWithoutMuteTimings, route: muteTiming && newMuteTiming.name !== muteTiming.name ? renameMuteTimings(newMuteTiming.name, muteTiming.name, config?.route ?? {}) : config?.route, - mute_time_intervals: [...(muteTimings || []), newMuteTiming], + ...newMutetimeIntervals, }, }; @@ -123,13 +176,8 @@ const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) = <Input {...formApi.register('name', { required: true, - validate: (value) => { - if (!muteTiming) { - const existingMuteTiming = config?.mute_time_intervals?.find(({ name }) => value === name); - return existingMuteTiming ? `Mute timing already exists for "${value}"` : true; - } - return; - }, + validate: (value) => + validateMuteTiming(value, muteTiming, originalMuteTimings, originalTimeIntervals), })} className={styles.input} data-testid={'mute-timing-name'} @@ -156,6 +204,22 @@ const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) = ); }; +function validateMuteTiming( + value: string, + muteTiming: MuteTimeInterval | undefined, + originalMuteTimings: MuteTimeInterval[], + originalTimeIntervals: MuteTimeInterval[] +) { + if (!muteTiming) { + const existingMuteTimingInMuteTimings = originalMuteTimings?.find(({ name }) => value === name); + const existingMuteTimingInTimeIntervals = originalTimeIntervals?.find(({ name }) => value === name); + return existingMuteTimingInMuteTimings || existingMuteTimingInTimeIntervals + ? `Mute timing already exists for "${value}"` + : true; + } + return; +} + const getStyles = (theme: GrafanaTheme2) => ({ input: css` width: 400px; diff --git a/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeInterval.tsx b/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeInterval.tsx index 2c16184e6de5f..3962b622ab616 100644 --- a/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeInterval.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeInterval.tsx @@ -4,8 +4,9 @@ import React, { useEffect, useState } from 'react'; import { useFieldArray, useFormContext } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; -import { Button, Field, FieldSet, Icon, Input, useStyles2, Stack } from '@grafana/ui'; +import { Button, Field, FieldSet, Icon, InlineSwitch, Input, Stack, useStyles2 } from '@grafana/ui'; +import { useAlertmanager } from '../../state/AlertmanagerContext'; import { MuteTimingFields } from '../../types/mute-timing-form'; import { DAYS_OF_THE_WEEK, defaultTimeInterval, MONTHS, validateArrayField } from '../../utils/mute-timings'; @@ -14,14 +15,15 @@ import { TimezoneSelect } from './timezones'; export const MuteTimingTimeInterval = () => { const styles = useStyles2(getStyles); - const { formState, register, setValue } = useFormContext(); + const { formState, register, setValue } = useFormContext<MuteTimingFields>(); const { fields: timeIntervals, append: addTimeInterval, remove: removeTimeInterval, - } = useFieldArray<MuteTimingFields>({ + } = useFieldArray({ name: 'time_intervals', }); + const { isGrafanaAlertmanager } = useAlertmanager(); return ( <FieldSet label="Time intervals"> @@ -43,7 +45,11 @@ export const MuteTimingTimeInterval = () => { return ( <div key={timeInterval.id} className={styles.timeIntervalSection}> <MuteTimingTimeRange intervalIndex={timeIntervalIndex} /> - <Field label="Location" invalid={Boolean(errors.location)} error={errors.location?.message}> + <Field + label="Location" + invalid={Boolean(errors.time_intervals?.[timeIntervalIndex]?.location)} + error={errors.time_intervals?.[timeIntervalIndex]?.location?.message} + > <TimezoneSelect prefix={<Icon name="map-marker" />} width={50} @@ -127,15 +133,30 @@ export const MuteTimingTimeInterval = () => { data-testid="mute-timing-years" /> </Field> - <Button - type="button" - variant="destructive" - fill="outline" - icon="trash-alt" - onClick={() => removeTimeInterval(timeIntervalIndex)} - > - Remove time interval - </Button> + <Stack direction="row" gap={2}> + <Button + type="button" + variant="destructive" + fill="outline" + icon="trash-alt" + onClick={() => removeTimeInterval(timeIntervalIndex)} + > + Remove time interval + </Button> + {/* + This switch is only available for Grafana Alertmanager, as for now, Grafana alert manager doesn't support this feature + It hanldes empty list as undefined making impossible the use of an empty list for disabling time interval + */} + {!isGrafanaAlertmanager && ( + <InlineSwitch + id={`time_intervals.${timeIntervalIndex}.disable`} + label="Disable" + showLabel + transparent + {...register(`time_intervals.${timeIntervalIndex}.disable`)} + /> + )} + </Stack> </div> ); })} diff --git a/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeRange.tsx b/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeRange.tsx index 9ce8a96faaea4..b057a01e95af1 100644 --- a/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeRange.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeRange.tsx @@ -3,9 +3,10 @@ import React from 'react'; import { useFieldArray, useFormContext } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; -import { Button, Field, Icon, IconButton, InlineField, InlineFieldRow, Input, useStyles2 } from '@grafana/ui'; +import { Button, Field, Icon, IconButton, InlineField, InlineFieldRow, Input, Tooltip, useStyles2 } from '@grafana/ui'; import { MuteTimingFields } from '../../types/mute-timing-form'; +import ConditionalWrap from '../ConditionalWrap'; import { isValidStartAndEndTime, isvalidTimeFormat } from './util'; @@ -17,7 +18,8 @@ const INVALID_FORMAT_MESSAGE = 'Times must be between 00:00 and 24:00 UTC'; export const MuteTimingTimeRange = ({ intervalIndex }: Props) => { const styles = useStyles2(getStyles); - const { register, formState, getValues } = useFormContext<MuteTimingFields>(); + const { register, formState, getValues, watch } = useFormContext<MuteTimingFields>(); + const isDisabled = watch(`time_intervals.${intervalIndex}.disable`); const { fields: timeRanges, @@ -28,7 +30,7 @@ export const MuteTimingTimeRange = ({ intervalIndex }: Props) => { }); const formErrors = formState.errors.time_intervals?.[intervalIndex]; - const timeRangeInvalid = formErrors?.times?.some((value) => value?.start_time || value?.end_time) ?? false; + const timeRangeInvalid = formErrors?.times?.some?.((value) => value?.start_time || value?.end_time) ?? false; return ( <div> @@ -81,6 +83,7 @@ export const MuteTimingTimeRange = ({ intervalIndex }: Props) => { })} className={styles.timeRangeInput} maxLength={5} + readOnly={isDisabled} suffix={<Icon name="clock-nine" />} // @ts-ignore react-hook-form doesn't handle nested field arrays well defaultValue={timeRange.start_time} @@ -112,6 +115,7 @@ export const MuteTimingTimeRange = ({ intervalIndex }: Props) => { })} className={styles.timeRangeInput} maxLength={5} + readOnly={isDisabled} suffix={<Icon name="clock-nine" />} // @ts-ignore react-hook-form doesn't handle nested field arrays well defaultValue={timeRange.end_time} @@ -135,15 +139,25 @@ export const MuteTimingTimeRange = ({ intervalIndex }: Props) => { })} </> </Field> - <Button - className={styles.addTimeRange} - variant="secondary" - type="button" - icon="plus" - onClick={() => addTimeRange({ start_time: '', end_time: '' })} + <ConditionalWrap + shouldWrap={isDisabled} + wrap={(children) => ( + <Tooltip content="This time interval is disabled" placement="right-start"> + {children} + </Tooltip> + )} > - Add another time range - </Button> + <Button + className={styles.addTimeRange} + variant="secondary" + type="button" + icon="plus" + disabled={isDisabled} + onClick={() => addTimeRange({ start_time: '', end_time: '' })} + > + Add another time range + </Button> + </ConditionalWrap> </div> ); }; diff --git a/public/app/features/alerting/unified/components/mute-timings/MuteTimingsTable.tsx b/public/app/features/alerting/unified/components/mute-timings/MuteTimingsTable.tsx index 5db1a2ce98ab5..69dec20bbc41b 100644 --- a/public/app/features/alerting/unified/components/mute-timings/MuteTimingsTable.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/MuteTimingsTable.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useToggle } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; -import { Button, ConfirmModal, IconButton, Link, LinkButton, Menu, Stack, useStyles2 } from '@grafana/ui'; +import { Badge, Button, ConfirmModal, IconButton, Link, LinkButton, Menu, Stack, useStyles2 } from '@grafana/ui'; import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types'; import { useDispatch } from 'app/types/store'; @@ -11,14 +11,16 @@ import { Authorize } from '../../components/Authorize'; import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities'; import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig'; import { deleteMuteTimingAction } from '../../state/actions'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { makeAMLink } from '../../utils/misc'; +import { isDisabled } from '../../utils/mute-timings'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { EmptyAreaWithCTA } from '../EmptyAreaWithCTA'; import { ProvisioningBadge } from '../Provisioning'; import { Spacer } from '../Spacer'; import { GrafanaMuteTimingsExporter } from '../export/GrafanaMuteTimingsExporter'; -import { renderTimeIntervals } from './util'; +import { mergeTimeIntervals, renderTimeIntervals } from './util'; const ALL_MUTE_TIMINGS = Symbol('all mute timings'); @@ -72,9 +74,9 @@ export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hide const config = currentData?.alertmanager_config; const [muteTimingName, setMuteTimingName] = useState<string>(''); - const items = useMemo((): Array<DynamicTableItemProps<MuteTimeInterval>> => { - const muteTimings = config?.mute_time_intervals ?? []; + // merge both fields mute_time_intervals and time_intervals to support both old and new config + const muteTimings = config ? mergeTimeIntervals(config) : []; const muteTimingsProvenances = config?.muteTimeProvenances ?? {}; return muteTimings @@ -88,7 +90,7 @@ export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hide }, }; }); - }, [config?.mute_time_intervals, config?.muteTimeProvenances, muteTimingNames]); + }, [muteTimingNames, config]); const [_, allowedToCreateMuteTiming] = useAlertmanagerAbility(AlertmanagerAction.CreateMuteTiming); @@ -175,9 +177,8 @@ function useColumns( ]); const showActions = !hideActions && (allowedToEdit || allowedToDelete); - // const [ExportDrawer, openExportDrawer] = useExportMuteTiming(); - // const [_, openExportDrawer] = useExportMuteTiming(); const [exportSupported, exportAllowed] = useAlertmanagerAbility(AlertmanagerAction.ExportMuteTimings); + const styles = useStyles2(getStyles); return useMemo((): Array<DynamicTableColumnProps<MuteTimeInterval>> => { const columns: Array<DynamicTableColumnProps<MuteTimeInterval>> = [ @@ -204,43 +205,18 @@ function useColumns( if (showActions) { columns.push({ id: 'actions', - label: 'Actions', + label: '', renderCell: function renderActions({ data }) { - if (data.provenance) { - return ( - <div> - <Link - href={makeAMLink(`/alerting/routes/mute-timing/edit`, alertManagerSourceName, { - muteName: data.name, - })} - > - <IconButton name="file-alt" tooltip="View mute timing" /> - </Link> - </div> - ); - } return ( - <div> - <Authorize actions={[AlertmanagerAction.UpdateMuteTiming]}> - <Link - href={makeAMLink(`/alerting/routes/mute-timing/edit`, alertManagerSourceName, { - muteName: data.name, - })} - > - <IconButton name="edit" tooltip="Edit mute timing" /> - </Link> - </Authorize> - <Authorize actions={[AlertmanagerAction.DeleteMuteTiming]}> - <IconButton - name="trash-alt" - tooltip="Delete mute timing" - onClick={() => setMuteTimingName(data.name)} - /> - </Authorize> - </div> + <ActionsAndBadge + muteTiming={data} + alertManagerSourceName={alertManagerSourceName} + setMuteTimingName={setMuteTimingName} + /> ); }, - size: '80px', + size: '150px', + className: styles.actionsColumn, }); } if (exportSupported) { @@ -265,7 +241,62 @@ function useColumns( }); } return columns; - }, [alertManagerSourceName, setMuteTimingName, showActions, exportSupported, exportAllowed, openExportDrawer]); + }, [ + alertManagerSourceName, + setMuteTimingName, + showActions, + exportSupported, + exportAllowed, + openExportDrawer, + styles.actionsColumn, + ]); +} + +interface ActionsAndBadgeProps { + muteTiming: MuteTimeInterval; + alertManagerSourceName: string; + setMuteTimingName: (name: string) => void; +} + +function ActionsAndBadge({ muteTiming, alertManagerSourceName, setMuteTimingName }: ActionsAndBadgeProps) { + const styles = useStyles2(getStyles); + const isGrafanaDataSource = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME; + + if (muteTiming.provenance) { + return ( + <Stack direction="row" alignItems="center" justifyContent="flex-end"> + {isDisabled(muteTiming) && !isGrafanaDataSource && ( + <Badge text="Disabled" color="orange" className={styles.disabledBadge} /> + )} + <Link + href={makeAMLink(`/alerting/routes/mute-timing/edit`, alertManagerSourceName, { + muteName: muteTiming.name, + })} + > + <IconButton name="file-alt" tooltip="View mute timing" /> + </Link> + </Stack> + ); + } + return ( + <Stack direction="row" alignItems="center" justifyContent="flex-end"> + {isDisabled(muteTiming) && !isGrafanaDataSource && ( + <Badge text="Disabled" color="orange" className={styles.disabledBadge} /> + )} + <Authorize actions={[AlertmanagerAction.UpdateMuteTiming]}> + <Link + href={makeAMLink(`/alerting/routes/mute-timing/edit`, alertManagerSourceName, { + muteName: muteTiming.name, + })} + > + <IconButton name="edit" tooltip="Edit mute timing" className={styles.editButton} /> + </Link> + </Authorize> + <Authorize actions={[AlertmanagerAction.DeleteMuteTiming]}> + <IconButton name="trash-alt" tooltip="Delete mute timing" onClick={() => setMuteTimingName(muteTiming.name)} /> + </Authorize> + </Stack> + ); } const getStyles = (theme: GrafanaTheme2) => ({ @@ -277,4 +308,13 @@ const getStyles = (theme: GrafanaTheme2) => ({ margin-bottom: ${theme.spacing(2)}; align-self: flex-end; `, + disabledBadge: css({ + height: 'fit-content', + }), + editButton: css({ + display: 'flex', + }), + actionsColumn: css({ + justifyContent: 'flex-end', + }), }); diff --git a/public/app/features/alerting/unified/components/mute-timings/util.tsx b/public/app/features/alerting/unified/components/mute-timings/util.tsx index dda90ae1b39ed..00a52f5e8ff1e 100644 --- a/public/app/features/alerting/unified/components/mute-timings/util.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/util.tsx @@ -1,7 +1,7 @@ import moment from 'moment'; import React from 'react'; -import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types'; +import { AlertmanagerConfig, MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types'; import { getDaysOfMonthString, @@ -18,6 +18,12 @@ const isvalidTimeFormat = (timeString: string): boolean => { return timeString ? TIME_RANGE_REGEX.test(timeString) : true; }; +// merge both fields mute_time_intervals and time_intervals to support both old and new config +export const mergeTimeIntervals = (alertManagerConfig: AlertmanagerConfig) => { + return [...(alertManagerConfig.mute_time_intervals ?? []), ...(alertManagerConfig.time_intervals ?? [])]; +}; + +// Usage const isValidStartAndEndTime = (startTime?: string, endTime?: string): boolean => { // empty time range is perfactly valid for a mute timing if (!startTime && !endTime) { @@ -67,4 +73,4 @@ function renderTimeIntervals(muteTiming: MuteTimeInterval) { }); } -export { isvalidTimeFormat, isValidStartAndEndTime, renderTimeIntervals }; +export { isValidStartAndEndTime, isvalidTimeFormat, renderTimeIntervals }; diff --git a/public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx b/public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx index fbfab072ad502..2ae8f5f58d7ea 100644 --- a/public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx @@ -1,6 +1,7 @@ import React, { ReactNode, useState } from 'react'; +import { useForm, Controller } from 'react-hook-form'; -import { Collapse, Field, Form, InputControl, Link, MultiSelect, Select, useStyles2 } from '@grafana/ui'; +import { Collapse, Field, Link, MultiSelect, Select, useStyles2 } from '@grafana/ui'; import { RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { FormAmRoute } from '../../types/amroutes'; @@ -41,125 +42,131 @@ export const AmRootRouteForm = ({ const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues(route.group_by)); const defaultValues = amRouteToFormAmRoute(route); - + const { + handleSubmit, + register, + control, + formState: { errors }, + setValue, + getValues, + } = useForm<FormAmRoute>({ + defaultValues: { + ...defaultValues, + overrideTimings: true, + overrideGrouping: true, + }, + }); return ( - <Form defaultValues={{ ...defaultValues, overrideTimings: true, overrideGrouping: true }} onSubmit={onSubmit}> - {({ register, control, errors, setValue, getValues }) => ( + <form onSubmit={handleSubmit(onSubmit)}> + <Field label="Default contact point" invalid={!!errors.receiver} error={errors.receiver?.message}> <> - <Field label="Default contact point" invalid={!!errors.receiver} error={errors.receiver?.message}> - <> - <div className={styles.container} data-testid="am-receiver-select"> - <InputControl - render={({ field: { onChange, ref, ...field } }) => ( - <Select - aria-label="Default contact point" - {...field} - className={styles.input} - onChange={(value) => onChange(mapSelectValueToString(value))} - options={receivers} - /> - )} - control={control} - name="receiver" - rules={{ required: { value: true, message: 'Required.' } }} - /> - <span>or</span> - <Link - className={styles.linkText} - href={makeAMLink('/alerting/notifications/receivers/new', alertManagerSourceName)} - > - Create a contact point - </Link> - </div> - </> - </Field> - <Field - label="Group by" - description="Group alerts when you receive a notification based on labels." - data-testid="am-group-select" - > - {/* @ts-ignore-check: react-hook-form made me do this */} - <InputControl + <div className={styles.container} data-testid="am-receiver-select"> + <Controller render={({ field: { onChange, ref, ...field } }) => ( - <MultiSelect - aria-label="Group by" + <Select + aria-label="Default contact point" {...field} - allowCustomValue className={styles.input} - onCreateOption={(opt: string) => { - setGroupByOptions((opts) => [...opts, stringToSelectableValue(opt)]); - - // @ts-ignore-check: react-hook-form made me do this - setValue('groupBy', [...field.value, opt]); - }} - onChange={(value) => onChange(mapMultiSelectValueToStrings(value))} - options={[...commonGroupByOptions, ...groupByOptions]} + onChange={(value) => onChange(mapSelectValueToString(value))} + options={receivers} /> )} control={control} - name="groupBy" + name="receiver" + rules={{ required: { value: true, message: 'Required.' } }} + /> + <span>or</span> + <Link + className={styles.linkText} + href={makeAMLink('/alerting/notifications/receivers/new', alertManagerSourceName)} + > + Create a contact point + </Link> + </div> + </> + </Field> + <Field + label="Group by" + description="Group alerts when you receive a notification based on labels." + data-testid="am-group-select" + > + <Controller + render={({ field: { onChange, ref, ...field } }) => ( + <MultiSelect + aria-label="Group by" + {...field} + allowCustomValue + className={styles.input} + onCreateOption={(opt: string) => { + setGroupByOptions((opts) => [...opts, stringToSelectableValue(opt)]); + setValue('groupBy', [...(field.value || []), opt]); + }} + onChange={(value) => onChange(mapMultiSelectValueToStrings(value))} + options={[...commonGroupByOptions, ...groupByOptions]} + /> + )} + control={control} + name="groupBy" + /> + </Field> + <Collapse + collapsible + className={styles.collapse} + isOpen={isTimingOptionsExpanded} + label="Timing options" + onToggle={setIsTimingOptionsExpanded} + > + <div className={styles.timingFormContainer}> + <Field + label="Group wait" + description="The waiting time until the initial notification is sent for a new group created by an incoming alert. Default 30 seconds." + invalid={!!errors.groupWaitValue} + error={errors.groupWaitValue?.message} + data-testid="am-group-wait" + > + <PromDurationInput + {...register('groupWaitValue', { validate: promDurationValidator })} + placeholder={TIMING_OPTIONS_DEFAULTS.group_wait} + className={styles.promDurationInput} + aria-label="Group wait" /> </Field> - <Collapse - collapsible - className={styles.collapse} - isOpen={isTimingOptionsExpanded} - label="Timing options" - onToggle={setIsTimingOptionsExpanded} + <Field + label="Group interval" + description="The waiting time to send a batch of new alerts for that group after the first notification was sent. Default 5 minutes." + invalid={!!errors.groupIntervalValue} + error={errors.groupIntervalValue?.message} + data-testid="am-group-interval" > - <div className={styles.timingFormContainer}> - <Field - label="Group wait" - description="The waiting time until the initial notification is sent for a new group created by an incoming alert. Default 30 seconds." - invalid={!!errors.groupWaitValue} - error={errors.groupWaitValue?.message} - data-testid="am-group-wait" - > - <PromDurationInput - {...register('groupWaitValue', { validate: promDurationValidator })} - placeholder={TIMING_OPTIONS_DEFAULTS.group_wait} - className={styles.promDurationInput} - aria-label="Group wait" - /> - </Field> - <Field - label="Group interval" - description="The waiting time to send a batch of new alerts for that group after the first notification was sent. Default 5 minutes." - invalid={!!errors.groupIntervalValue} - error={errors.groupIntervalValue?.message} - data-testid="am-group-interval" - > - <PromDurationInput - {...register('groupIntervalValue', { validate: promDurationValidator })} - placeholder={TIMING_OPTIONS_DEFAULTS.group_interval} - className={styles.promDurationInput} - aria-label="Group interval" - /> - </Field> - <Field - label="Repeat interval" - description="The waiting time to resend an alert after they have successfully been sent. Default 4 hours. Should be a multiple of Group interval." - invalid={!!errors.repeatIntervalValue} - error={errors.repeatIntervalValue?.message} - data-testid="am-repeat-interval" - > - <PromDurationInput - {...register('repeatIntervalValue', { - validate: (value: string) => { - const groupInterval = getValues('groupIntervalValue'); - return repeatIntervalValidator(value, groupInterval); - }, - })} - placeholder={TIMING_OPTIONS_DEFAULTS.repeat_interval} - className={styles.promDurationInput} - aria-label="Repeat interval" - /> - </Field> - </div> - </Collapse> - <div className={styles.container}>{actionButtons}</div> - </> - )} - </Form> + <PromDurationInput + {...register('groupIntervalValue', { validate: promDurationValidator })} + placeholder={TIMING_OPTIONS_DEFAULTS.group_interval} + className={styles.promDurationInput} + aria-label="Group interval" + /> + </Field> + <Field + label="Repeat interval" + description="The waiting time to resend an alert after they have successfully been sent. Default 4 hours. Should be a multiple of Group interval." + invalid={!!errors.repeatIntervalValue} + error={errors.repeatIntervalValue?.message} + data-testid="am-repeat-interval" + > + <PromDurationInput + {...register('repeatIntervalValue', { + validate: (value: string) => { + const groupInterval = getValues('groupIntervalValue'); + return repeatIntervalValidator(value, groupInterval); + }, + })} + placeholder={TIMING_OPTIONS_DEFAULTS.repeat_interval} + className={styles.promDurationInput} + aria-label="Repeat interval" + /> + </Field> + </div> + </Collapse> + <div className={styles.container}>{actionButtons}</div> + </form> ); }; diff --git a/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx b/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx index d0f3e8c8b3134..b4ef37fd02682 100644 --- a/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx @@ -1,17 +1,15 @@ import { css } from '@emotion/css'; import React, { ReactNode, useState } from 'react'; +import { useForm, Controller, useFieldArray } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; import { Badge, Button, Field, - FieldArray, FieldValidationMessage, - Form, IconButton, Input, - InputControl, MultiSelect, Select, Stack, @@ -75,224 +73,228 @@ export const AmRoutesExpandedForm = ({ object_matchers: route ? formAmRoute.object_matchers : emptyMatcher, }; + const { + handleSubmit, + control, + register, + formState: { errors }, + setValue, + watch, + getValues, + } = useForm<FormAmRoute>({ + defaultValues, + }); + const { fields, append, remove } = useFieldArray({ + control, + name: 'object_matchers', + }); + return ( - <Form defaultValues={defaultValues} onSubmit={onSubmit} maxWidth="none"> - {({ control, register, errors, setValue, watch, getValues }) => ( - <> - <input type="hidden" {...register('id')} /> - {/* @ts-ignore-check: react-hook-form made me do this */} - <FieldArray name="object_matchers" control={control}> - {({ fields, append, remove }) => ( - <> - <Stack direction="column" alignItems="flex-start"> - <div>Matching labels</div> - {fields.length === 0 && ( - <Badge - color="orange" - className={styles.noMatchersWarning} - icon="exclamation-triangle" - text="If no matchers are specified, this notification policy will handle all alert instances." + <form onSubmit={handleSubmit(onSubmit)}> + <input type="hidden" {...register('id')} /> + <Stack direction="column" alignItems="flex-start"> + <div>Matching labels</div> + {fields.length === 0 && ( + <Badge + color="orange" + className={styles.noMatchersWarning} + icon="exclamation-triangle" + text="If no matchers are specified, this notification policy will handle all alert instances." + /> + )} + {fields.length > 0 && ( + <div className={styles.matchersContainer}> + {fields.map((field, index) => { + return ( + <Stack direction="row" key={field.id} alignItems="center"> + <Field + label="Label" + invalid={!!errors.object_matchers?.[index]?.name} + error={errors.object_matchers?.[index]?.name?.message} + > + <Input + {...register(`object_matchers.${index}.name`, { required: 'Field is required' })} + defaultValue={field.name} + placeholder="label" + autoFocus + /> + </Field> + <Field label={'Operator'}> + <Controller + render={({ field: { onChange, ref, ...field } }) => ( + <Select + {...field} + className={styles.matchersOperator} + onChange={(value) => onChange(value?.value)} + options={matcherFieldOptions} + aria-label="Operator" + /> + )} + defaultValue={field.operator} + control={control} + name={`object_matchers.${index}.operator`} + rules={{ required: { value: true, message: 'Required.' } }} /> - )} - {fields.length > 0 && ( - <div className={styles.matchersContainer}> - {fields.map((field, index) => { - return ( - <Stack direction="row" key={field.id} alignItems="center"> - <Field - label="Label" - invalid={!!errors.object_matchers?.[index]?.name} - error={errors.object_matchers?.[index]?.name?.message} - > - <Input - {...register(`object_matchers.${index}.name`, { required: 'Field is required' })} - defaultValue={field.name} - placeholder="label" - autoFocus - /> - </Field> - <Field label={'Operator'}> - <InputControl - render={({ field: { onChange, ref, ...field } }) => ( - <Select - {...field} - className={styles.matchersOperator} - onChange={(value) => onChange(value?.value)} - options={matcherFieldOptions} - aria-label="Operator" - /> - )} - defaultValue={field.operator} - control={control} - name={`object_matchers.${index}.operator`} - rules={{ required: { value: true, message: 'Required.' } }} - /> - </Field> - <Field - label="Value" - invalid={!!errors.object_matchers?.[index]?.value} - error={errors.object_matchers?.[index]?.value?.message} - > - <Input - {...register(`object_matchers.${index}.value`, { required: 'Field is required' })} - defaultValue={field.value} - placeholder="value" - /> - </Field> - <IconButton tooltip="Remove matcher" name={'trash-alt'} onClick={() => remove(index)}> - Remove - </IconButton> - </Stack> - ); - })} - </div> - )} - <Button - className={styles.addMatcherBtn} - icon="plus" - onClick={() => append(emptyArrayFieldMatcher)} - variant="secondary" - type="button" + </Field> + <Field + label="Value" + invalid={!!errors.object_matchers?.[index]?.value} + error={errors.object_matchers?.[index]?.value?.message} > - Add matcher - </Button> + <Input + {...register(`object_matchers.${index}.value`)} + defaultValue={field.value} + placeholder="value" + /> + </Field> + <IconButton tooltip="Remove matcher" name={'trash-alt'} onClick={() => remove(index)}> + Remove + </IconButton> </Stack> - </> - )} - </FieldArray> - <Field label="Contact point"> - <InputControl - render={({ field: { onChange, ref, ...field } }) => ( - <Select - aria-label="Contact point" + ); + })} + </div> + )} + <Button + className={styles.addMatcherBtn} + icon="plus" + onClick={() => append(emptyArrayFieldMatcher)} + variant="secondary" + type="button" + > + Add matcher + </Button> + </Stack> + + <Field label="Contact point"> + <Controller + render={({ field: { onChange, ref, ...field } }) => ( + <Select + aria-label="Contact point" + {...field} + className={formStyles.input} + onChange={(value) => onChange(mapSelectValueToString(value))} + options={receiversWithOnCallOnTop} + isClearable + /> + )} + control={control} + name="receiver" + /> + </Field> + <Field label="Continue matching subsequent sibling nodes"> + <Switch id="continue-toggle" {...register('continue')} /> + </Field> + <Field label="Override grouping"> + <Switch id="override-grouping-toggle" {...register('overrideGrouping')} /> + </Field> + {watch().overrideGrouping && ( + <Field + label="Group by" + description="Group alerts when you receive a notification based on labels. If empty it will be inherited from the parent policy." + > + <Controller + rules={{ + validate: (value) => { + if (!value || value.length === 0) { + return 'At least one group by option is required.'; + } + return true; + }, + }} + render={({ field: { onChange, ref, ...field }, fieldState: { error } }) => ( + <> + <MultiSelect + aria-label="Group by" {...field} + invalid={Boolean(error)} + allowCustomValue className={formStyles.input} - onChange={(value) => onChange(mapSelectValueToString(value))} - options={receiversWithOnCallOnTop} - isClearable + onCreateOption={(opt: string) => { + setGroupByOptions((opts) => [...opts, stringToSelectableValue(opt)]); + setValue('groupBy', [...(field.value || []), opt]); + }} + onChange={(value) => onChange(mapMultiSelectValueToStrings(value))} + options={[...commonGroupByOptions, ...groupByOptions]} /> - )} - control={control} - name="receiver" + {error && <FieldValidationMessage>{error.message}</FieldValidationMessage>} + </> + )} + control={control} + name="groupBy" + /> + </Field> + )} + <Field label="Override general timings"> + <Switch id="override-timings-toggle" {...register('overrideTimings')} /> + </Field> + {watch().overrideTimings && ( + <> + <Field + label={routeTimingsFields.groupWait.label} + description={routeTimingsFields.groupWait.description} + invalid={!!errors.groupWaitValue} + error={errors.groupWaitValue?.message} + > + <PromDurationInput + {...register('groupWaitValue', { validate: promDurationValidator })} + aria-label={routeTimingsFields.groupWait.ariaLabel} + className={formStyles.promDurationInput} /> </Field> - <Field label="Continue matching subsequent sibling nodes"> - <Switch id="continue-toggle" {...register('continue')} /> - </Field> - <Field label="Override grouping"> - <Switch id="override-grouping-toggle" {...register('overrideGrouping')} /> - </Field> - {watch().overrideGrouping && ( - <Field - label="Group by" - description="Group alerts when you receive a notification based on labels. If empty it will be inherited from the parent policy." - > - <InputControl - rules={{ - validate: (value) => { - if (!value || value.length === 0) { - return 'At least one group by option is required.'; - } - return true; - }, - }} - render={({ field: { onChange, ref, ...field }, fieldState: { error } }) => ( - <> - <MultiSelect - aria-label="Group by" - {...field} - invalid={Boolean(error)} - allowCustomValue - className={formStyles.input} - onCreateOption={(opt: string) => { - setGroupByOptions((opts) => [...opts, stringToSelectableValue(opt)]); - - // @ts-ignore-check: react-hook-form made me do this - setValue('groupBy', [...field.value, opt]); - }} - onChange={(value) => onChange(mapMultiSelectValueToStrings(value))} - options={[...commonGroupByOptions, ...groupByOptions]} - /> - {error && <FieldValidationMessage>{error.message}</FieldValidationMessage>} - </> - )} - control={control} - name="groupBy" - /> - </Field> - )} - <Field label="Override general timings"> - <Switch id="override-timings-toggle" {...register('overrideTimings')} /> + <Field + label={routeTimingsFields.groupInterval.label} + description={routeTimingsFields.groupInterval.description} + invalid={!!errors.groupIntervalValue} + error={errors.groupIntervalValue?.message} + > + <PromDurationInput + {...register('groupIntervalValue', { validate: promDurationValidator })} + aria-label={routeTimingsFields.groupInterval.ariaLabel} + className={formStyles.promDurationInput} + /> </Field> - {watch().overrideTimings && ( - <> - <Field - label={routeTimingsFields.groupWait.label} - description={routeTimingsFields.groupWait.description} - invalid={!!errors.groupWaitValue} - error={errors.groupWaitValue?.message} - > - <PromDurationInput - {...register('groupWaitValue', { validate: promDurationValidator })} - aria-label={routeTimingsFields.groupWait.ariaLabel} - className={formStyles.promDurationInput} - /> - </Field> - <Field - label={routeTimingsFields.groupInterval.label} - description={routeTimingsFields.groupInterval.description} - invalid={!!errors.groupIntervalValue} - error={errors.groupIntervalValue?.message} - > - <PromDurationInput - {...register('groupIntervalValue', { validate: promDurationValidator })} - aria-label={routeTimingsFields.groupInterval.ariaLabel} - className={formStyles.promDurationInput} - /> - </Field> - <Field - label={routeTimingsFields.repeatInterval.label} - description={routeTimingsFields.repeatInterval.description} - invalid={!!errors.repeatIntervalValue} - error={errors.repeatIntervalValue?.message} - > - <PromDurationInput - {...register('repeatIntervalValue', { - validate: (value: string) => { - const groupInterval = getValues('groupIntervalValue'); - return repeatIntervalValidator(value, groupInterval); - }, - })} - aria-label={routeTimingsFields.repeatInterval.ariaLabel} - className={formStyles.promDurationInput} - /> - </Field> - </> - )} <Field - label="Mute timings" - data-testid="am-mute-timing-select" - description="Add mute timing to policy" - invalid={!!errors.muteTimeIntervals} + label={routeTimingsFields.repeatInterval.label} + description={routeTimingsFields.repeatInterval.description} + invalid={!!errors.repeatIntervalValue} + error={errors.repeatIntervalValue?.message} > - <InputControl - render={({ field: { onChange, ref, ...field } }) => ( - <MultiSelect - aria-label="Mute timings" - {...field} - className={formStyles.input} - onChange={(value) => onChange(mapMultiSelectValueToStrings(value))} - options={muteTimingOptions} - /> - )} - control={control} - name="muteTimeIntervals" + <PromDurationInput + {...register('repeatIntervalValue', { + validate: (value = '') => { + const groupInterval = getValues('groupIntervalValue'); + return repeatIntervalValidator(value, groupInterval); + }, + })} + aria-label={routeTimingsFields.repeatInterval.ariaLabel} + className={formStyles.promDurationInput} /> </Field> - {actionButtons} </> )} - </Form> + <Field + label="Mute timings" + data-testid="am-mute-timing-select" + description="Add mute timing to policy" + invalid={!!errors.muteTimeIntervals} + > + <Controller + render={({ field: { onChange, ref, ...field } }) => ( + <MultiSelect + aria-label="Mute timings" + {...field} + className={formStyles.input} + onChange={(value) => onChange(mapMultiSelectValueToStrings(value))} + options={muteTimingOptions} + /> + )} + control={control} + name="muteTimeIntervals" + /> + </Field> + {actionButtons} + </form> ); }; @@ -322,6 +324,7 @@ const getStyles = (theme: GrafanaTheme2) => { `, noMatchersWarning: css` padding: ${theme.spacing(1)} ${theme.spacing(2)}; + margin-bottom: ${theme.spacing(1)}; `, }; }; diff --git a/public/app/features/alerting/unified/components/notification-policies/Filters.tsx b/public/app/features/alerting/unified/components/notification-policies/Filters.tsx index 3a9e6cb41c5ea..66b2ee0cd3041 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Filters.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Filters.tsx @@ -1,24 +1,27 @@ import { css } from '@emotion/css'; -import { debounce } from 'lodash'; +import { debounce, isEqual } from 'lodash'; import React, { useCallback, useEffect, useRef } from 'react'; import { SelectableValue } from '@grafana/data'; -import { Button, Field, Icon, Input, Label as LabelElement, Select, Tooltip, useStyles2, Stack } from '@grafana/ui'; +import { Button, Field, Icon, Input, Label, Select, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui'; import { ObjectMatcher, Receiver, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { useURLSearchParams } from '../../hooks/useURLSearchParams'; import { matcherToObjectMatcher, parseMatchers } from '../../utils/alertmanager'; +import { normalizeMatchers } from '../../utils/matchers'; interface NotificationPoliciesFilterProps { receivers: Receiver[]; onChangeMatchers: (labels: ObjectMatcher[]) => void; onChangeReceiver: (receiver: string | undefined) => void; + matchingCount: number; } const NotificationPoliciesFilter = ({ receivers, onChangeReceiver, onChangeMatchers, + matchingCount, }: NotificationPoliciesFilterProps) => { const [searchParams, setSearchParams] = useURLSearchParams(); const searchInputRef = useRef<HTMLInputElement | null>(null); @@ -50,11 +53,11 @@ const NotificationPoliciesFilter = ({ const inputInvalid = queryString && queryString.length > 3 ? parseMatchers(queryString).length === 0 : false; return ( - <Stack direction="row" alignItems="flex-start" gap={0.5}> + <Stack direction="row" alignItems="flex-end" gap={1}> <Field className={styles.noBottom} label={ - <LabelElement> + <Label> <Stack gap={0.5}> <span>Search by matchers</span> <Tooltip @@ -68,7 +71,7 @@ const NotificationPoliciesFilter = ({ <Icon name="info-circle" size="sm" /> </Tooltip> </Stack> - </LabelElement> + </Label> } invalid={inputInvalid} error={inputInvalid ? 'Query must use valid matcher syntax' : null} @@ -99,9 +102,16 @@ const NotificationPoliciesFilter = ({ /> </Field> {hasFilters && ( - <Button variant="secondary" icon="times" onClick={clearFilters} style={{ marginTop: 19 }}> - Clear filters - </Button> + <Stack alignItems="center"> + <Button variant="secondary" icon="times" onClick={clearFilters}> + Clear filters + </Button> + <Text variant="bodySmall" color="secondary"> + {matchingCount === 0 && 'No policies matching filters.'} + {matchingCount === 1 && `${matchingCount} policy matches the filters.`} + {matchingCount > 1 && `${matchingCount} policies match the filters.`} + </Text> + </Stack> )} </Stack> ); @@ -112,19 +122,46 @@ const NotificationPoliciesFilter = ({ */ type FilterPredicate = (route: RouteWithID) => boolean; -export function findRoutesMatchingPredicate(routeTree: RouteWithID, predicateFn: FilterPredicate): RouteWithID[] { - const matches: RouteWithID[] = []; +/** + * Find routes int the tree that match the given predicate function + * @param routeTree the route tree to search + * @param predicateFn the predicate function to match routes + * @returns + * - matches: list of routes that match the predicate + * - matchingRouteIdsWithPath: map with routeids that are part of the path of a matching route + * key is the route id, value is an array of route ids that are part of its path + */ +export function findRoutesMatchingPredicate( + routeTree: RouteWithID, + predicateFn: FilterPredicate +): Map<RouteWithID, RouteWithID[]> { + // map with routids that are part of the path of a matching route + // key is the route id, value is an array of route ids that are part of the path + const matchingRouteIdsWithPath = new Map<RouteWithID, RouteWithID[]>(); + + function findMatch(route: RouteWithID, path: RouteWithID[]) { + const newPath = [...path, route]; - function findMatch(route: RouteWithID) { if (predicateFn(route)) { - matches.push(route); + // if the route matches the predicate, we need to add the path to the map of matching routes + const previousPath = matchingRouteIdsWithPath.get(route) ?? []; + // add the current route id to the map with its path + matchingRouteIdsWithPath.set(route, [...previousPath, ...newPath]); } - route.routes?.forEach(findMatch); + // if the route has subroutes, call findMatch recursively + route.routes?.forEach((route) => findMatch(route, newPath)); } - findMatch(routeTree); - return matches; + findMatch(routeTree, []); + + return matchingRouteIdsWithPath; +} + +export function findRoutesByMatchers(route: RouteWithID, labelMatchersFilter: ObjectMatcher[]): boolean { + const routeMatchers = normalizeMatchers(route); + + return labelMatchersFilter.every((filter) => routeMatchers.some((matcher) => isEqual(filter, matcher))); } const toOption = (receiver: Receiver) => ({ @@ -138,9 +175,9 @@ const getNotificationPoliciesFilters = (searchParams: URLSearchParams) => ({ }); const getStyles = () => ({ - noBottom: css` - margin-bottom: 0; - `, + noBottom: css({ + marginBottom: 0, + }), }); export { NotificationPoliciesFilter }; diff --git a/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx b/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx index f379658d2c799..458dc4a0258f0 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx @@ -6,12 +6,13 @@ import { GrafanaTheme2 } from '@grafana/data'; import { getTagColorsFromName, useStyles2, Stack } from '@grafana/ui'; import { ObjectMatcher } from 'app/plugins/datasource/alertmanager/types'; +import { MatcherFormatter, matcherFormatter } from '../../utils/matchers'; import { HoverCard } from '../HoverCard'; -type MatchersProps = { matchers: ObjectMatcher[] }; +type MatchersProps = { matchers: ObjectMatcher[]; formatter?: MatcherFormatter }; // renders the first N number of matchers -const Matchers: FC<MatchersProps> = ({ matchers }) => { +const Matchers: FC<MatchersProps> = ({ matchers, formatter = 'default' }) => { const styles = useStyles2(getStyles); const NUM_MATCHERS = 5; @@ -24,7 +25,7 @@ const Matchers: FC<MatchersProps> = ({ matchers }) => { <span data-testid="label-matchers"> <Stack direction="row" gap={1} alignItems="center" wrap={'wrap'}> {firstFew.map((matcher) => ( - <MatcherBadge key={uniqueId()} matcher={matcher} /> + <MatcherBadge key={uniqueId()} matcher={matcher} formatter={formatter} /> ))} {/* TODO hover state to show all matchers we're not showing */} {hasMoreMatchers && ( @@ -51,15 +52,16 @@ const Matchers: FC<MatchersProps> = ({ matchers }) => { interface MatcherBadgeProps { matcher: ObjectMatcher; + formatter?: MatcherFormatter; } -const MatcherBadge: FC<MatcherBadgeProps> = ({ matcher: [label, operator, value] }) => { +const MatcherBadge: FC<MatcherBadgeProps> = ({ matcher, formatter = 'default' }) => { const styles = useStyles2(getStyles); return ( - <div className={styles.matcher(label).wrapper}> + <div className={styles.matcher(matcher[0]).wrapper}> <Stack direction="row" gap={0} alignItems="baseline"> - {label} {operator} {value} + {matcherFormatter[formatter](matcher)} </Stack> </div> ); diff --git a/public/app/features/alerting/unified/components/notification-policies/Modals.tsx b/public/app/features/alerting/unified/components/notification-policies/Modals.tsx index 099642f87d332..43338fdbdcf43 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Modals.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Modals.tsx @@ -11,6 +11,8 @@ import { } from 'app/plugins/datasource/alertmanager/types'; import { FormAmRoute } from '../../types/amroutes'; +import { MatcherFormatter } from '../../utils/matchers'; +import { InsertPosition } from '../../utils/routeTree'; import { AlertGroup } from '../alert-groups/AlertGroup'; import { useGetAmRouteReceiverWithGrafanaAppTypes } from '../receivers/grafanaAppReceivers/grafanaApp'; @@ -20,24 +22,28 @@ import { AmRoutesExpandedForm } from './EditNotificationPolicyForm'; import { Matchers } from './Matchers'; type ModalHook<T = undefined> = [JSX.Element, (item: T) => void, () => void]; +type AddModalHook<T = undefined> = [JSX.Element, (item: T, position: InsertPosition) => void, () => void]; type EditModalHook = [JSX.Element, (item: RouteWithID, isDefaultRoute?: boolean) => void, () => void]; const useAddPolicyModal = ( receivers: Receiver[] = [], - handleAdd: (route: Partial<FormAmRoute>, parentRoute: RouteWithID) => void, + handleAdd: (route: Partial<FormAmRoute>, referenceRoute: RouteWithID, position: InsertPosition) => void, loading: boolean -): ModalHook<RouteWithID> => { +): AddModalHook<RouteWithID> => { const [showModal, setShowModal] = useState(false); - const [parentRoute, setParentRoute] = useState<RouteWithID>(); + const [insertPosition, setInsertPosition] = useState<InsertPosition | undefined>(undefined); + const [referenceRoute, setReferenceRoute] = useState<RouteWithID>(); const AmRouteReceivers = useGetAmRouteReceiverWithGrafanaAppTypes(receivers); const handleDismiss = useCallback(() => { - setParentRoute(undefined); + setReferenceRoute(undefined); + setInsertPosition(undefined); setShowModal(false); }, []); - const handleShow = useCallback((parentRoute: RouteWithID) => { - setParentRoute(parentRoute); + const handleShow = useCallback((referenceRoute: RouteWithID, position: InsertPosition) => { + setReferenceRoute(referenceRoute); + setInsertPosition(position); setShowModal(true); }, []); @@ -56,9 +62,13 @@ const useAddPolicyModal = ( <AmRoutesExpandedForm receivers={AmRouteReceivers} defaults={{ - groupBy: parentRoute?.group_by, + groupBy: referenceRoute?.group_by, + }} + onSubmit={(newRoute) => { + if (referenceRoute && insertPosition) { + handleAdd(newRoute, referenceRoute, insertPosition); + } }} - onSubmit={(newRoute) => parentRoute && handleAdd(newRoute, parentRoute)} actionButtons={ <Modal.ButtonRow> <Button type="button" variant="secondary" onClick={handleDismiss} fill="outline"> @@ -70,7 +80,7 @@ const useAddPolicyModal = ( /> </Modal> ), - [AmRouteReceivers, handleAdd, handleDismiss, loading, parentRoute, showModal] + [AmRouteReceivers, handleAdd, handleDismiss, insertPosition, loading, referenceRoute, showModal] ); return [modalElement, handleShow, handleDismiss]; @@ -210,6 +220,7 @@ const useAlertGroupsModal = (): [ const [showModal, setShowModal] = useState(false); const [alertGroups, setAlertGroups] = useState<AlertmanagerGroup[]>([]); const [matchers, setMatchers] = useState<ObjectMatcher[]>([]); + const [formatter, setFormatter] = useState<MatcherFormatter>('default'); const handleDismiss = useCallback(() => { setShowModal(false); @@ -217,13 +228,19 @@ const useAlertGroupsModal = (): [ setMatchers([]); }, []); - const handleShow = useCallback((alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => { - setAlertGroups(alertGroups); - if (matchers) { - setMatchers(matchers); - } - setShowModal(true); - }, []); + const handleShow = useCallback( + (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[], formatter?: MatcherFormatter) => { + setAlertGroups(alertGroups); + if (matchers) { + setMatchers(matchers); + } + if (formatter) { + setFormatter(formatter); + } + setShowModal(true); + }, + [] + ); const instancesByState = useMemo(() => { const instances = alertGroups.flatMap((group) => group.alerts); @@ -242,7 +259,7 @@ const useAlertGroupsModal = (): [ <Stack direction="row" alignItems="center" gap={0.5}> <Icon name="x" /> Matchers </Stack> - <Matchers matchers={matchers} /> + <Matchers matchers={matchers} formatter={formatter} /> </Stack> } > @@ -265,7 +282,7 @@ const useAlertGroupsModal = (): [ </Modal.ButtonRow> </Modal> ), - [alertGroups, handleDismiss, instancesByState, matchers, showModal] + [alertGroups, handleDismiss, instancesByState, matchers, formatter, showModal] ); return [modalElement, handleShow, handleDismiss]; diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx index 88fece21e9f99..88e43ab5a7101 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx @@ -1,10 +1,10 @@ -import { render, screen, within } from '@testing-library/react'; +import { render, renderHook, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { noop } from 'lodash'; import React from 'react'; import { Router } from 'react-router-dom'; -import { locationService } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { contextSrv } from 'app/core/core'; import { AlertmanagerGroup, @@ -19,7 +19,12 @@ import { mockAlertGroup, mockAlertmanagerAlert, mockReceiversState } from '../.. import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; -import { Policy } from './Policy'; +import { + AUTOGENERATED_ROOT_LABEL_NAME, + Policy, + isAutoGeneratedRootAndSimplifiedEnabled, + useCreateDropdownMenuActions, +} from './Policy'; jest.mock('../../hooks/useAbilities', () => ({ ...jest.requireActual('../../hooks/useAbilities'), @@ -163,6 +168,7 @@ describe('Policy', () => { onAddPolicy={onAddPolicy} onDeletePolicy={onDeletePolicy} onShowAlertInstances={onShowAlertInstances} + isAutoGenerated={false} /> ); // should have default policy @@ -379,3 +385,104 @@ const mockRoutes: RouteWithID = { group_interval: undefined, repeat_interval: undefined, }; + +describe('isAutoGeneratedRootAndSimplifiedEnabled', () => { + it('returns false when simplified routing is not enabled', () => { + const route: RouteWithID = { + id: '1', + object_matchers: [['label', MatcherOperator.equal, 'true']], + }; + config.featureToggles.alertingSimplifiedRouting = false; + expect(isAutoGeneratedRootAndSimplifiedEnabled(route)).toBe(false); + }); + + it('returns false when object_matchers is not defined', () => { + const route: RouteWithID = { + id: '1', + }; + config.featureToggles.alertingSimplifiedRouting = true; + expect(isAutoGeneratedRootAndSimplifiedEnabled(route)).toBe(false); + }); + + it('returns true when object_matchers contains AUTOGENERATED_ROOT_LABEL_NAME, and simplified routing is enabled', () => { + const route: RouteWithID = { + id: '1', + object_matchers: [[AUTOGENERATED_ROOT_LABEL_NAME, MatcherOperator.equal, 'true']], + }; + config.featureToggles.alertingSimplifiedRouting = true; + expect(isAutoGeneratedRootAndSimplifiedEnabled(route)).toBe(true); + }); + + it('returns false when object_matchers does not contain AUTOGENERATED_ROOT_LABEL_NAME, and simplified routing is enabled', () => { + const route: RouteWithID = { + id: '1', + object_matchers: [['label', MatcherOperator.equal, 'true']], + }; + config.featureToggles.alertingSimplifiedRouting = true; + expect(isAutoGeneratedRootAndSimplifiedEnabled(route)).toBe(false); + }); +}); + +describe('useCreateDropdownMenuActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const openDetailModal = jest.fn(); + const currentRoute: RouteWithID = { id: '0', routes: [{ id: '1' }] }; + const toggleShowExportDrawer = jest.fn(); + const onDeletePolicy = jest.fn(); + const testCases = [ + { + isAutoGenerated: false, + isDefaultPolicy: true, + provisioned: false, + expectedMenu: ['edit-policy', 'export-policy'], + }, + { + isAutoGenerated: false, + isDefaultPolicy: true, + provisioned: true, + expectedMenu: ['edit-policy', 'export-policy'], + }, + { + isAutoGenerated: false, + isDefaultPolicy: false, + provisioned: false, + expectedMenu: ['edit-policy', 'delete-policy'], + }, + { + isAutoGenerated: false, + isDefaultPolicy: false, + provisioned: true, + expectedMenu: ['edit-policy', 'delete-policy'], + }, + { isAutoGenerated: true, isDefaultPolicy: true, provisioned: true, expectedMenu: ['edit-policy'] }, + { isAutoGenerated: true, isDefaultPolicy: false, provisioned: false, expectedMenu: ['edit-policy'] }, + { isAutoGenerated: true, isDefaultPolicy: true, provisioned: false, expectedMenu: ['edit-policy'] }, + { isAutoGenerated: true, isDefaultPolicy: false, provisioned: true, expectedMenu: ['edit-policy'] }, + ]; + + testCases.forEach(({ isAutoGenerated, isDefaultPolicy, provisioned, expectedMenu }) => { + it(`Having all the permissions returns ${expectedMenu.length} menu items for isAutoGenerated=${isAutoGenerated}, isDefaultPolicy=${isDefaultPolicy}, provisioned=${provisioned}`, () => { + useAlertmanagerAbilitiesMock.mockReturnValue([ + [true, true], + [true, true], + [true, true], + ]); + const { result } = renderHook(() => + useCreateDropdownMenuActions( + isAutoGenerated, + isDefaultPolicy, + provisioned, + openDetailModal, + currentRoute, + toggleShowExportDrawer, + onDeletePolicy + ) + ); + + const menuItemsKeys = result.current.map((item) => item.key ?? ''); + expect(menuItemsKeys).toEqual(expectedMenu); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx index d5e766bc342f1..129734656b732 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx @@ -1,16 +1,18 @@ import { css } from '@emotion/css'; import { defaults, groupBy, isArray, sumBy, uniqueId, upperFirst } from 'lodash'; import pluralize from 'pluralize'; -import React, { FC, Fragment, ReactNode } from 'react'; +import React, { FC, Fragment, ReactNode, useState } from 'react'; import { Link } from 'react-router-dom'; import { useToggle } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { Badge, Button, Dropdown, Icon, + IconButton, Menu, Stack, Text, @@ -18,15 +20,24 @@ import { getTagColorsFromName, useStyles2, } from '@grafana/ui'; -import ConditionalWrap from 'app/features/alerting/components/ConditionalWrap'; -import { AlertmanagerGroup, ObjectMatcher, Receiver, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; +import ConditionalWrap from 'app/features/alerting/unified/components/ConditionalWrap'; +import { + AlertmanagerGroup, + MatcherOperator, + ObjectMatcher, + Receiver, + RouteWithID, +} from 'app/plugins/datasource/alertmanager/types'; import { ReceiversState } from 'app/types'; -import { AlertmanagerAction, useAlertmanagerAbilities } from '../../hooks/useAbilities'; +import { RoutesMatchingFilters } from '../../NotificationPolicies'; +import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities'; import { INTEGRATION_ICONS } from '../../types/contact-points'; -import { normalizeMatchers } from '../../utils/matchers'; +import { getAmMatcherFormatter } from '../../utils/alertmanager'; +import { MatcherFormatter, normalizeMatchers } from '../../utils/matchers'; import { createContactPointLink, createMuteTimingLink } from '../../utils/misc'; -import { getInheritedProperties, InhertitableProperties } from '../../utils/notification-policies'; +import { InheritableProperties, getInheritedProperties } from '../../utils/notification-policies'; +import { InsertPosition } from '../../utils/routeTree'; import { Authorize } from '../Authorize'; import { HoverCard } from '../HoverCard'; import { Label } from '../Label'; @@ -37,7 +48,7 @@ import { Strong } from '../Strong'; import { GrafanaPoliciesExporter } from '../export/GrafanaPoliciesExporter'; import { Matchers } from './Matchers'; -import { TimingOptions, TIMING_OPTIONS_DEFAULTS } from './timingOptions'; +import { TIMING_OPTIONS_DEFAULTS, TimingOptions } from './timingOptions'; interface PolicyComponentProps { receivers?: Receiver[]; @@ -45,65 +56,70 @@ interface PolicyComponentProps { contactPointsState?: ReceiversState; readOnly?: boolean; provisioned?: boolean; - inheritedProperties?: Partial<InhertitableProperties>; - routesMatchingFilters?: RouteWithID[]; - // routeAlertGroupsMap?: Map<string, AlertmanagerGroup[]>; + inheritedProperties?: Partial<InheritableProperties>; + routesMatchingFilters?: RoutesMatchingFilters; - matchingInstancesPreview?: { groupsMap?: Map<string, AlertmanagerGroup[]>; enabled: boolean }; + matchingInstancesPreview?: { + groupsMap?: Map<string, AlertmanagerGroup[]>; + enabled: boolean; + }; routeTree: RouteWithID; currentRoute: RouteWithID; alertManagerSourceName: string; - onEditPolicy: (route: RouteWithID, isDefault?: boolean) => void; - onAddPolicy: (route: RouteWithID) => void; + onEditPolicy: (route: RouteWithID, isDefault?: boolean, isAutogenerated?: boolean) => void; + onAddPolicy: (route: RouteWithID, position: InsertPosition) => void; onDeletePolicy: (route: RouteWithID) => void; - onShowAlertInstances: (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => void; + onShowAlertInstances: ( + alertGroups: AlertmanagerGroup[], + matchers?: ObjectMatcher[], + formatter?: MatcherFormatter + ) => void; + isAutoGenerated?: boolean; } -const Policy: FC<PolicyComponentProps> = ({ - receivers = [], - contactPointsState, - readOnly = false, - provisioned = false, - alertGroups = [], - alertManagerSourceName, - currentRoute, - routeTree, - inheritedProperties, - routesMatchingFilters = [], - matchingInstancesPreview = { enabled: false }, - onEditPolicy, - onAddPolicy, - onDeletePolicy, - onShowAlertInstances, -}) => { +const Policy = (props: PolicyComponentProps) => { + const { + receivers = [], + contactPointsState, + readOnly = false, + provisioned = false, + alertGroups = [], + alertManagerSourceName, + currentRoute, + routeTree, + inheritedProperties, + routesMatchingFilters = { + filtersApplied: false, + matchedRoutesWithPath: new Map<RouteWithID, RouteWithID[]>(), + }, + matchingInstancesPreview = { enabled: false }, + onEditPolicy, + onAddPolicy, + onDeletePolicy, + onShowAlertInstances, + isAutoGenerated = false, + } = props; + const styles = useStyles2(getStyles); - const isDefaultPolicy = currentRoute === routeTree; - const [ - [updatePoliciesSupported, updatePoliciesAllowed], - [deletePolicySupported, deletePolicyAllowed], - [exportPoliciesSupported, exportPoliciesAllowed], - ] = useAlertmanagerAbilities([ - AlertmanagerAction.UpdateNotificationPolicyTree, - AlertmanagerAction.DeleteNotificationPolicy, - AlertmanagerAction.ExportNotificationPolicies, - ]); + const isDefaultPolicy = currentRoute === routeTree; const contactPoint = currentRoute.receiver; const continueMatching = currentRoute.continue ?? false; - const groupBy = currentRoute.group_by; - const muteTimings = currentRoute.mute_time_intervals ?? []; - const timingOptions: TimingOptions = { - group_wait: currentRoute.group_wait, - group_interval: currentRoute.group_interval, - repeat_interval: currentRoute.repeat_interval, - }; const matchers = normalizeMatchers(currentRoute); const hasMatchers = Boolean(matchers && matchers.length); - const hasMuteTimings = Boolean(muteTimings.length); - const hasFocus = routesMatchingFilters.some((route) => route.id === currentRoute.id); + + const { filtersApplied, matchedRoutesWithPath } = routesMatchingFilters; + const matchedRoutes = Array.from(matchedRoutesWithPath.keys()); + + // check if this route matches the filters + const hasFocus = filtersApplied && matchedRoutes.some((route) => route.id === currentRoute.id); + + // check if this route belongs to a path that matches the filters + const routesPath = Array.from(matchedRoutesWithPath.values()).flat(); + const belongsToMatchPath = routesPath.some((route: RouteWithID) => route.id === currentRoute.id); // gather errors here const errors: ReactNode[] = []; @@ -116,33 +132,402 @@ const Policy: FC<PolicyComponentProps> = ({ const actualContactPoint = contactPoint ?? inheritedProperties?.receiver ?? ''; const contactPointErrors = contactPointsState ? getContactPointErrors(actualContactPoint, contactPointsState) : []; + const allChildPolicies = currentRoute.routes ?? []; + + // filter child policies that match + const childPolicies = filtersApplied + ? // filter by the ones that belong to the path that matches the filters + allChildPolicies.filter((policy) => routesPath.some((route: RouteWithID) => route.id === policy.id)) + : allChildPolicies; + + const hasChildPolicies = childPolicies.length > 0; + + const [showExportDrawer, toggleShowExportDrawer] = useToggle(false); + const matchingAlertGroups = matchingInstancesPreview?.groupsMap?.get(currentRoute.id); + + // sum all alert instances for all groups we're handling + const numberOfAlertInstances = matchingAlertGroups + ? sumBy(matchingAlertGroups, (group) => group.alerts.length) + : undefined; + + // simplified routing permissions + const [isSupportedToSeeAutogeneratedChunk, isAllowedToSeeAutogeneratedChunk] = useAlertmanagerAbility( + AlertmanagerAction.ViewAutogeneratedPolicyTree + ); + + // we collapse the auto-generated policies by default + const isAutogeneratedPolicyRoot = isAutoGeneratedRootAndSimplifiedEnabled(currentRoute); + const [showPolicyChildren, togglePolicyChildren] = useToggle(isAutogeneratedPolicyRoot ? false : true); + + const groupBy = currentRoute.group_by; + const muteTimings = currentRoute.mute_time_intervals ?? []; + + const timingOptions: TimingOptions = { + group_wait: currentRoute.group_wait, + group_interval: currentRoute.group_interval, + repeat_interval: currentRoute.repeat_interval, + }; + contactPointErrors.forEach((error) => { errors.push(error); }); - const hasInheritedProperties = inheritedProperties && Object.keys(inheritedProperties).length > 0; + const POLICIES_PER_PAGE = 20; + + const [visibleChildPolicies, setVisibleChildPolicies] = useState(POLICIES_PER_PAGE); + + // build the menu actions for our policy + const dropdownMenuActions: JSX.Element[] = useCreateDropdownMenuActions( + isAutoGenerated, + isDefaultPolicy, + provisioned, + onEditPolicy, + currentRoute, + toggleShowExportDrawer, + onDeletePolicy + ); + + // check if this policy should be visible. If it's autogenerated and the user is not allowed to see autogenerated + // policies then we should not show it. Same if the user is not supported to see autogenerated policies. + const hideCurrentPolicy = + isAutoGenerated && (!isAllowedToSeeAutogeneratedChunk || !isSupportedToSeeAutogeneratedChunk); + const hideCurrentPolicyForFilters = filtersApplied && !belongsToMatchPath; + + if (hideCurrentPolicy || hideCurrentPolicyForFilters) { + return null; + } + + const isImmutablePolicy = isDefaultPolicy || isAutogeneratedPolicyRoot; + // TODO dead branch detection, warnings for all sort of configs that won't work or will never be activated + + const childPoliciesBelongingToMatchPath = childPolicies.filter((child) => + routesPath.some((route: RouteWithID) => route.id === child.id) + ); + + // child policies to render are the ones that belong to the path that matches the filters + const childPoliciesToRender = filtersApplied ? childPoliciesBelongingToMatchPath : childPolicies; + const pageOfChildren = childPoliciesToRender.slice(0, visibleChildPolicies); + + const moreCount = childPoliciesToRender.length - pageOfChildren.length; + const showMore = moreCount > 0; + + return ( + <> + <Stack direction="column" gap={1.5}> + <div + className={styles.policyWrapper(hasFocus)} + data-testid={isDefaultPolicy ? 'am-root-route-container' : 'am-route-container'} + > + {/* continueMatching and showMatchesAllLabelsWarning are mutually exclusive so the icons can't overlap */} + {continueMatching && <ContinueMatchingIndicator />} + {showMatchesAllLabelsWarning && <AllMatchesIndicator />} + + <div className={styles.policyItemWrapper}> + <Stack direction="column" gap={1}> + {/* Matchers and actions */} + <div> + <Stack direction="row" alignItems="center" gap={1}> + {hasChildPolicies ? ( + <IconButton + name={showPolicyChildren ? 'angle-down' : 'angle-right'} + onClick={togglePolicyChildren} + aria-label={showPolicyChildren ? 'Collapse' : 'Expand'} + /> + ) : null} + {isImmutablePolicy ? ( + isAutogeneratedPolicyRoot ? ( + <AutogeneratedRootIndicator /> + ) : ( + <DefaultPolicyIndicator /> + ) + ) : hasMatchers ? ( + <Matchers matchers={matchers ?? []} formatter={getAmMatcherFormatter(alertManagerSourceName)} /> + ) : ( + <span className={styles.metadata}>No matchers</span> + )} + <Spacer /> + {/* TODO maybe we should move errors to the gutter instead? */} + {errors.length > 0 && <Errors errors={errors} />} + {provisioned && <ProvisioningBadge />} + <Stack direction="row" gap={0.5}> + {!isAutoGenerated && !readOnly && ( + <Authorize actions={[AlertmanagerAction.CreateNotificationPolicy]}> + <ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}> + {isDefaultPolicy ? ( + <Button + variant="secondary" + icon="plus" + size="sm" + disabled={provisioned} + type="button" + onClick={() => onAddPolicy(currentRoute, 'child')} + > + New child policy + </Button> + ) : ( + <Dropdown + overlay={ + <Menu> + <Menu.Item + label="Insert above" + icon="arrow-up" + onClick={() => onAddPolicy(currentRoute, 'above')} + /> + <Menu.Item + label="Insert below" + icon="arrow-down" + onClick={() => onAddPolicy(currentRoute, 'below')} + /> + <Menu.Divider /> + <Menu.Item + label="New child policy" + icon="plus" + onClick={() => onAddPolicy(currentRoute, 'child')} + /> + </Menu> + } + > + <Button + size="sm" + variant="secondary" + disabled={provisioned} + icon="angle-down" + type="button" + > + Add new policy + </Button> + </Dropdown> + )} + </ConditionalWrap> + </Authorize> + )} + {dropdownMenuActions.length > 0 && ( + <Dropdown overlay={<Menu>{dropdownMenuActions}</Menu>}> + <Button + icon="ellipsis-h" + variant="secondary" + size="sm" + type="button" + aria-label="more-actions" + data-testid="more-actions" + /> + </Dropdown> + )} + </Stack> + </Stack> + </div> + + {/* Metadata row */} + <MetadataRow + matchingInstancesPreview={matchingInstancesPreview} + numberOfAlertInstances={numberOfAlertInstances} + contactPoint={contactPoint ?? undefined} + groupBy={groupBy} + muteTimings={muteTimings} + timingOptions={timingOptions} + inheritedProperties={inheritedProperties} + alertManagerSourceName={alertManagerSourceName} + receivers={receivers} + matchingAlertGroups={matchingAlertGroups} + matchers={matchers} + isDefaultPolicy={isDefaultPolicy} + onShowAlertInstances={onShowAlertInstances} + /> + </Stack> + </div> + </div> + <div className={styles.childPolicies}> + {showPolicyChildren && ( + <> + {pageOfChildren.map((child) => { + const childInheritedProperties = getInheritedProperties(currentRoute, child, inheritedProperties); + // This child is autogenerated if it's the autogenerated root or if it's a child of an autogenerated policy. + const isThisChildAutoGenerated = isAutoGeneratedRootAndSimplifiedEnabled(child) || isAutoGenerated; + /* pass the "readOnly" prop from the parent, because for any child policy , if its parent it's not editable, + then the child policy should not be editable either */ + const isThisChildReadOnly = readOnly || provisioned || isAutoGenerated; + + return ( + <Policy + key={child.id} + routeTree={routeTree} + currentRoute={child} + receivers={receivers} + contactPointsState={contactPointsState} + readOnly={isThisChildReadOnly} + inheritedProperties={childInheritedProperties} + onAddPolicy={onAddPolicy} + onEditPolicy={onEditPolicy} + onDeletePolicy={onDeletePolicy} + onShowAlertInstances={onShowAlertInstances} + alertManagerSourceName={alertManagerSourceName} + alertGroups={alertGroups} + routesMatchingFilters={routesMatchingFilters} + matchingInstancesPreview={matchingInstancesPreview} + isAutoGenerated={isThisChildAutoGenerated} + provisioned={provisioned} + /> + ); + })} + {showMore && ( + <Button + size="sm" + icon="angle-down" + variant="secondary" + className={styles.moreButtons} + onClick={() => setVisibleChildPolicies(visibleChildPolicies + POLICIES_PER_PAGE)} + > + {moreCount} additional {pluralize('policy', moreCount)} + </Button> + )} + </> + )} + </div> + {showExportDrawer && <GrafanaPoliciesExporter onClose={toggleShowExportDrawer} />} + </Stack> + </> + ); +}; + +interface MetadataRowProps { + matchingInstancesPreview: { groupsMap?: Map<string, AlertmanagerGroup[]>; enabled: boolean }; + numberOfAlertInstances?: number; + contactPoint?: string; + groupBy?: string[]; + muteTimings?: string[]; + timingOptions?: TimingOptions; + inheritedProperties?: Partial<InheritableProperties>; + alertManagerSourceName: string; + receivers: Receiver[]; + matchingAlertGroups?: AlertmanagerGroup[]; + matchers?: ObjectMatcher[]; + isDefaultPolicy: boolean; + onShowAlertInstances: ( + alertGroups: AlertmanagerGroup[], + matchers?: ObjectMatcher[], + formatter?: MatcherFormatter + ) => void; +} + +function MetadataRow({ + numberOfAlertInstances, + isDefaultPolicy, + timingOptions, + groupBy, + muteTimings = [], + matchingInstancesPreview, + inheritedProperties, + matchingAlertGroups, + onShowAlertInstances, + matchers, + contactPoint, + alertManagerSourceName, + receivers, +}: MetadataRowProps) { + const styles = useStyles2(getStyles); - const childPolicies = currentRoute.routes ?? []; + const inheritedGrouping = inheritedProperties && inheritedProperties.group_by; + const hasInheritedProperties = inheritedProperties && Object.keys(inheritedProperties).length > 0; - const inheritedGrouping = hasInheritedProperties && inheritedProperties.group_by; const noGrouping = isArray(groupBy) && groupBy[0] === '...'; const customGrouping = !noGrouping && isArray(groupBy) && groupBy.length > 0; const singleGroup = isDefaultPolicy && isArray(groupBy) && groupBy.length === 0; - const matchingAlertGroups = matchingInstancesPreview?.groupsMap?.get(currentRoute.id); + const hasMuteTimings = Boolean(muteTimings.length); - // sum all alert instances for all groups we're handling - const numberOfAlertInstances = matchingAlertGroups - ? sumBy(matchingAlertGroups, (group) => group.alerts.length) - : undefined; + return ( + <div className={styles.metadataRow}> + <Stack direction="row" alignItems="center" gap={1}> + {matchingInstancesPreview.enabled && ( + <MetaText + icon="layers-alt" + onClick={() => { + matchingAlertGroups && + onShowAlertInstances(matchingAlertGroups, matchers, getAmMatcherFormatter(alertManagerSourceName)); + }} + data-testid="matching-instances" + > + <Strong>{numberOfAlertInstances ?? '-'}</Strong> + <span>{pluralize('instance', numberOfAlertInstances)}</span> + </MetaText> + )} + {contactPoint && ( + <MetaText icon="at" data-testid="contact-point"> + <span>Delivered to</span> + <ContactPointsHoverDetails + alertManagerSourceName={alertManagerSourceName} + receivers={receivers} + contactPoint={contactPoint} + /> + </MetaText> + )} + {!inheritedGrouping && ( + <> + {customGrouping && ( + <MetaText icon="layer-group" data-testid="grouping"> + <span>Grouped by</span> + <Strong>{groupBy.join(', ')}</Strong> + </MetaText> + )} + {singleGroup && ( + <MetaText icon="layer-group"> + <span>Single group</span> + </MetaText> + )} + {noGrouping && ( + <MetaText icon="layer-group"> + <span>Not grouping</span> + </MetaText> + )} + </> + )} + {hasMuteTimings && ( + <MetaText icon="calendar-slash" data-testid="mute-timings"> + <span>Muted when</span> + <MuteTimings timings={muteTimings} alertManagerSourceName={alertManagerSourceName} /> + </MetaText> + )} + {timingOptions && ( + // for the default policy we will also merge the default timings, that way a user can observe what the timing options would be + <TimingOptionsMeta + timingOptions={isDefaultPolicy ? defaults(timingOptions, TIMING_OPTIONS_DEFAULTS) : timingOptions} + /> + )} + {hasInheritedProperties && ( + <> + <MetaText icon="corner-down-right-alt" data-testid="inherited-properties"> + <span>Inherited</span> + <InheritedProperties properties={inheritedProperties} /> + </MetaText> + </> + )} + </Stack> + </div> + ); +} - const [showExportDrawer, toggleShowExportDrawer] = useToggle(false); - const showExportAction = exportPoliciesAllowed && exportPoliciesSupported && isDefaultPolicy; +export const useCreateDropdownMenuActions = ( + isAutoGenerated: boolean, + isDefaultPolicy: boolean, + provisioned: boolean, + onEditPolicy: (route: RouteWithID, isDefault?: boolean, readOnly?: boolean) => void, + currentRoute: RouteWithID, + toggleShowExportDrawer: (nextValue?: any) => void, + onDeletePolicy: (route: RouteWithID) => void +) => { + const [ + [updatePoliciesSupported, updatePoliciesAllowed], + [deletePolicySupported, deletePolicyAllowed], + [exportPoliciesSupported, exportPoliciesAllowed], + ] = useAlertmanagerAbilities([ + AlertmanagerAction.UpdateNotificationPolicyTree, + AlertmanagerAction.DeleteNotificationPolicy, + AlertmanagerAction.ExportNotificationPolicies, + ]); + const dropdownMenuActions = []; + const showExportAction = exportPoliciesAllowed && exportPoliciesSupported && isDefaultPolicy && !isAutoGenerated; const showEditAction = updatePoliciesSupported && updatePoliciesAllowed; - const showDeleteAction = deletePolicySupported && deletePolicyAllowed && !isDefaultPolicy; - - // build the menu actions for our policy - const dropdownMenuActions: JSX.Element[] = []; + const showDeleteAction = deletePolicySupported && deletePolicyAllowed && !isDefaultPolicy && !isAutoGenerated; if (showEditAction) { dropdownMenuActions.push( @@ -150,7 +535,7 @@ const Policy: FC<PolicyComponentProps> = ({ <ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}> <Menu.Item icon="edit" - disabled={provisioned} + disabled={provisioned || isAutoGenerated} label="Edit" onClick={() => onEditPolicy(currentRoute, isDefaultPolicy)} /> @@ -173,7 +558,7 @@ const Policy: FC<PolicyComponentProps> = ({ <Menu.Item destructive icon="trash-alt" - disabled={provisioned} + disabled={provisioned || isAutoGenerated} label="Delete" onClick={() => onDeletePolicy(currentRoute)} /> @@ -181,166 +566,30 @@ const Policy: FC<PolicyComponentProps> = ({ </Fragment> ); } + return dropdownMenuActions; +}; - // TODO dead branch detection, warnings for all sort of configs that won't work or will never be activated - return ( - <Stack direction="column" gap={1.5}> - <div - className={styles.policyWrapper(hasFocus)} - data-testid={isDefaultPolicy ? 'am-root-route-container' : 'am-route-container'} - > - {/* continueMatching and showMatchesAllLabelsWarning are mutually exclusive so the icons can't overlap */} - {continueMatching && <ContinueMatchingIndicator />} - {showMatchesAllLabelsWarning && <AllMatchesIndicator />} - <div className={styles.policyItemWrapper}> - <Stack direction="column" gap={1}> - {/* Matchers and actions */} - <div> - <Stack direction="row" alignItems="center" gap={1}> - {isDefaultPolicy ? ( - <DefaultPolicyIndicator /> - ) : hasMatchers ? ( - <Matchers matchers={matchers ?? []} /> - ) : ( - <span className={styles.metadata}>No matchers</span> - )} - <Spacer /> - {/* TODO maybe we should move errors to the gutter instead? */} - {errors.length > 0 && <Errors errors={errors} />} - {provisioned && <ProvisioningBadge />} - {!readOnly && ( - <Stack direction="row" gap={0.5}> - <Authorize actions={[AlertmanagerAction.CreateNotificationPolicy]}> - <ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}> - <Button - variant="secondary" - icon="plus" - size="sm" - onClick={() => onAddPolicy(currentRoute)} - disabled={provisioned} - type="button" - > - New nested policy - </Button> - </ConditionalWrap> - </Authorize> - {dropdownMenuActions.length > 0 && ( - <Dropdown overlay={<Menu>{dropdownMenuActions}</Menu>}> - <Button - icon="ellipsis-h" - variant="secondary" - size="sm" - type="button" - aria-label="more-actions" - data-testid="more-actions" - /> - </Dropdown> - )} - </Stack> - )} - </Stack> - </div> - - {/* Metadata row */} - <div className={styles.metadataRow}> - <Stack direction="row" alignItems="center" gap={1}> - {matchingInstancesPreview.enabled && ( - <MetaText - icon="layers-alt" - onClick={() => { - matchingAlertGroups && onShowAlertInstances(matchingAlertGroups, matchers); - }} - data-testid="matching-instances" - > - <Strong>{numberOfAlertInstances ?? '-'}</Strong> - <span>{pluralize('instance', numberOfAlertInstances)}</span> - </MetaText> - )} - {contactPoint && ( - <MetaText icon="at" data-testid="contact-point"> - <span>Delivered to</span> - <ContactPointsHoverDetails - alertManagerSourceName={alertManagerSourceName} - receivers={receivers} - contactPoint={contactPoint} - /> - </MetaText> - )} - {!inheritedGrouping && ( - <> - {customGrouping && ( - <MetaText icon="layer-group" data-testid="grouping"> - <span>Grouped by</span> - <Strong>{groupBy.join(', ')}</Strong> - </MetaText> - )} - {singleGroup && ( - <MetaText icon="layer-group"> - <span>Single group</span> - </MetaText> - )} - {noGrouping && ( - <MetaText icon="layer-group"> - <span>Not grouping</span> - </MetaText> - )} - </> - )} - {hasMuteTimings && ( - <MetaText icon="calendar-slash" data-testid="mute-timings"> - <span>Muted when</span> - <MuteTimings timings={muteTimings} alertManagerSourceName={alertManagerSourceName} /> - </MetaText> - )} - {timingOptions && ( - // for the default policy we will also merge the default timings, that way a user can observe what the timing options would be - <TimingOptionsMeta - timingOptions={isDefaultPolicy ? defaults(timingOptions, TIMING_OPTIONS_DEFAULTS) : timingOptions} - /> - )} - {hasInheritedProperties && ( - <> - <MetaText icon="corner-down-right-alt" data-testid="inherited-properties"> - <span>Inherited</span> - <InheritedProperties properties={inheritedProperties} /> - </MetaText> - </> - )} - </Stack> - </div> - </Stack> - </div> - </div> - <div className={styles.childPolicies}> - {/* pass the "readOnly" prop from the parent, because if you can't edit the parent you can't edit children */} - {childPolicies.map((child) => { - const childInheritedProperties = getInheritedProperties(currentRoute, child, inheritedProperties); +export const AUTOGENERATED_ROOT_LABEL_NAME = '__grafana_autogenerated__'; - return ( - <Policy - key={uniqueId()} - routeTree={routeTree} - currentRoute={child} - receivers={receivers} - contactPointsState={contactPointsState} - readOnly={readOnly || provisioned} - inheritedProperties={childInheritedProperties} - onAddPolicy={onAddPolicy} - onEditPolicy={onEditPolicy} - onDeletePolicy={onDeletePolicy} - onShowAlertInstances={onShowAlertInstances} - alertManagerSourceName={alertManagerSourceName} - alertGroups={alertGroups} - routesMatchingFilters={routesMatchingFilters} - matchingInstancesPreview={matchingInstancesPreview} - /> - ); - })} - </div> - {showExportDrawer && <GrafanaPoliciesExporter onClose={toggleShowExportDrawer} />} - </Stack> +export function isAutoGeneratedRootAndSimplifiedEnabled(route: RouteWithID) { + const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false; + if (!simplifiedRoutingToggleEnabled) { + return false; + } + if (!route.object_matchers) { + return false; + } + return ( + route.object_matchers.some((objectMatcher) => { + return ( + objectMatcher[0] === AUTOGENERATED_ROOT_LABEL_NAME && + objectMatcher[1] === MatcherOperator.equal && + objectMatcher[2] === 'true' + ); + }) ?? false ); -}; + // return simplifiedRoutingToggleEnabled && route.receiver === 'contact_point_5'; +} const ProvisionedTooltip = (children: ReactNode) => ( <Tooltip content="Provisioned items cannot be edited in the UI" placement="top"> @@ -388,7 +637,7 @@ const AllMatchesIndicator: FC = () => { ); }; -const DefaultPolicyIndicator: FC = () => { +function DefaultPolicyIndicator() { const styles = useStyles2(getStyles); return ( <> @@ -398,21 +647,31 @@ const DefaultPolicyIndicator: FC = () => { </span> </> ); -}; +} -const InheritedProperties: FC<{ properties: InhertitableProperties }> = ({ properties }) => ( +function AutogeneratedRootIndicator() { + return <strong> Auto-generated policies</strong>; +} + +const InheritedProperties: FC<{ properties: InheritableProperties }> = ({ properties }) => ( <HoverCard arrow placement="top" content={ <Stack direction="row" gap={0.5}> - {Object.entries(properties).map(([key, value]) => ( - <Label - key={key} - label={routePropertyToLabel(key)} - value={<Strong>{routePropertyToValue(key, value)}</Strong>} - /> - ))} + {Object.entries(properties).map(([key, value]) => { + if (!value) { + return null; + } + + return ( + <Label + key={key} + label={routePropertyToLabel(key)} + value={<Strong>{routePropertyToValue(key, value)}</Strong>} + /> + ); + })} </Stack> } > @@ -579,7 +838,7 @@ function getContactPointErrors(contactPoint: string, contactPointsState: Receive return contactPointErrors; } -const routePropertyToLabel = (key: keyof InhertitableProperties | string): string => { +const routePropertyToLabel = (key: keyof InheritableProperties | string): string => { switch (key) { case 'receiver': return 'Contact Point'; @@ -596,10 +855,7 @@ const routePropertyToLabel = (key: keyof InhertitableProperties | string): strin } }; -const routePropertyToValue = ( - key: keyof InhertitableProperties | string, - value: string | string[] -): React.ReactNode => { +const routePropertyToValue = (key: keyof InheritableProperties | string, value: string | string[]): React.ReactNode => { const isNotGrouping = key === 'group_by' && Array.isArray(value) && value[0] === '...'; const isSingleGroup = key === 'group_by' && Array.isArray(value) && value.length === 0; @@ -627,84 +883,75 @@ const getStyles = (theme: GrafanaTheme2) => ({ const { color, borderColor } = getTagColorsFromName(label); return { - wrapper: css` - color: #fff; - background: ${color}; - padding: ${theme.spacing(0.33)} ${theme.spacing(0.66)}; - font-size: ${theme.typography.bodySmall.fontSize}; - - border: solid 1px ${borderColor}; - border-radius: ${theme.shape.radius.default}; - `, + wrapper: css({ + color: '#fff', + background: color, + padding: `${theme.spacing(0.33)} ${theme.spacing(0.66)}`, + fontSize: theme.typography.bodySmall.fontSize, + border: `solid 1px ${borderColor}`, + borderRadius: theme.shape.radius.default, + }), }; }, - childPolicies: css` - margin-left: ${theme.spacing(4)}; - position: relative; - - &:before { - content: ''; - position: absolute; - height: calc(100% - 10px); - - border-left: solid 1px ${theme.colors.border.weak}; - - margin-top: 0; - margin-left: -20px; - } - `, - policyItemWrapper: css` - padding: ${theme.spacing(1.5)}; - `, - metadataRow: css` - background: ${theme.colors.background.secondary}; - - border-bottom-left-radius: ${theme.shape.borderRadius(2)}; - border-bottom-right-radius: ${theme.shape.borderRadius(2)}; - `, - policyWrapper: (hasFocus = false) => css` - flex: 1; - position: relative; - background: ${theme.colors.background.secondary}; - - border-radius: ${theme.shape.radius.default}; - border: solid 1px ${theme.colors.border.weak}; - - ${hasFocus && - css` - border-color: ${theme.colors.primary.border}; - `} - `, - metadata: css` - color: ${theme.colors.text.secondary}; - - font-size: ${theme.typography.bodySmall.fontSize}; - font-weight: ${theme.typography.bodySmall.fontWeight}; - `, - break: css` - width: 100%; - height: 0; - margin-bottom: ${theme.spacing(2)}; - `, - gutterIcon: css` - position: absolute; - - top: 0; - transform: translateY(50%); - left: -${theme.spacing(4)}; - - color: ${theme.colors.text.secondary}; - background: ${theme.colors.background.primary}; - - width: 25px; - height: 25px; - text-align: center; - - border: solid 1px ${theme.colors.border.weak}; - border-radius: ${theme.shape.radius.default}; - - padding: 0; - `, + childPolicies: css({ + marginLeft: theme.spacing(4), + position: 'relative', + '&:before': { + content: '""', + position: 'absolute', + height: 'calc(100% - 10px)', + borderLeft: `solid 1px ${theme.colors.border.weak}`, + marginTop: 0, + marginLeft: '-20px', + }, + }), + policyItemWrapper: css({ + padding: theme.spacing(1.5), + }), + metadataRow: css({ + borderBottomLeftRadius: theme.shape.borderRadius(2), + borderBottomRightRadius: theme.shape.borderRadius(2), + }), + policyWrapper: (hasFocus = false) => + css({ + flex: 1, + position: 'relative', + background: theme.colors.background.secondary, + borderRadius: theme.shape.radius.default, + border: `solid 1px ${theme.colors.border.weak}`, + ...(hasFocus && { + borderColor: theme.colors.primary.border, + background: theme.colors.primary.transparent, + }), + }), + metadata: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.bodySmall.fontWeight, + }), + break: css({ + width: '100%', + height: 0, + marginBottom: theme.spacing(2), + }), + gutterIcon: css({ + position: 'absolute', + top: 0, + transform: 'translateY(50%)', + left: `-${theme.spacing(4)}`, + color: theme.colors.text.secondary, + background: theme.colors.background.primary, + width: '25px', + height: '25px', + textAlign: 'center', + border: `solid 1px ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + padding: 0, + }), + moreButtons: css({ + marginTop: theme.spacing(0.5), + marginBottom: theme.spacing(1.5), + }), }); export { Policy }; diff --git a/public/app/features/alerting/unified/components/receivers/DuplicateTemplateView.tsx b/public/app/features/alerting/unified/components/receivers/DuplicateTemplateView.tsx index 96489e6c2eb94..91f28e94c79fa 100644 --- a/public/app/features/alerting/unified/components/receivers/DuplicateTemplateView.tsx +++ b/public/app/features/alerting/unified/components/receivers/DuplicateTemplateView.tsx @@ -20,7 +20,7 @@ export const DuplicateTemplateView = ({ config, templateName, alertManagerSource if (!template) { return ( <Alert severity="error" title="Template not found"> - Sorry, this template does not seem to exists. + Sorry, this template does not seem to exist. </Alert> ); } diff --git a/public/app/features/alerting/unified/components/receivers/EditTemplateView.tsx b/public/app/features/alerting/unified/components/receivers/EditTemplateView.tsx index b8271e2d98ed0..062d2f58bece6 100644 --- a/public/app/features/alerting/unified/components/receivers/EditTemplateView.tsx +++ b/public/app/features/alerting/unified/components/receivers/EditTemplateView.tsx @@ -18,7 +18,7 @@ export const EditTemplateView = ({ config, templateName, alertManagerSourceName if (!template) { return ( <Alert severity="error" title="Template not found"> - Sorry, this template does not seem to exists. + Sorry, this template does not seem to exist. </Alert> ); } diff --git a/public/app/features/alerting/unified/components/receivers/PayloadEditor.test.tsx b/public/app/features/alerting/unified/components/receivers/PayloadEditor.test.tsx index bb7a52fb4fd42..cd8137f239120 100644 --- a/public/app/features/alerting/unified/components/receivers/PayloadEditor.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/PayloadEditor.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { default as React, useState } from 'react'; import { Provider } from 'react-redux'; -import { AutoSizerProps } from 'react-virtualized-auto-sizer'; +import { Props } from 'react-virtualized-auto-sizer'; import { configureStore } from 'app/store/configureStore'; @@ -30,7 +30,13 @@ jest.mock('@grafana/ui', () => ({ })); jest.mock('react-virtualized-auto-sizer', () => { - return ({ children }: AutoSizerProps) => children({ height: 1, width: 1 }); + return ({ children }: Props) => + children({ + height: 1, + scaledHeight: 1, + scaledWidth: 1, + width: 1, + }); }); const PayloadEditorWithState = () => { diff --git a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx index c8001c7dae57c..6ed886fc8536c 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx @@ -147,7 +147,7 @@ export const TemplateForm = ({ existing, alertManagerSourceName, config, provena watch, } = formApi; - const validateNameIsUnique: Validate<string> = (name: string) => { + const validateNameIsUnique: Validate<string, TemplateFormValues> = (name: string) => { return !config.template_files[name] || existing?.name === name ? true : 'Another template with this name already exists.'; @@ -280,7 +280,7 @@ function TemplatingGuideline() { </Stack> <div className={styles.snippets}> - To make templating easier, we provide a few snippets in the content editor to help you speed up your workflow. + For auto-completion of common templating code, type the following keywords in the content editor: <div className={styles.code}> {Object.values(snippets) .map((s) => s.label) diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx index c0a2ef50d7933..57f58f132713d 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx @@ -42,7 +42,7 @@ export function ChannelOptions<R extends ChannelValues>({ // pathPrefix = items.index. const paths = pathPrefix.split('.'); const selectedOptionValue = - paths.length >= 2 ? currentFormValues.items[Number(paths[1])].settings[option.showWhen.field] : undefined; + paths.length >= 2 ? currentFormValues.items?.[Number(paths[1])].settings?.[option.showWhen.field] : undefined; if (option.showWhen.field && selectedOptionValue !== option.showWhen.is) { return null; diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx index 8fd3f9056a16a..995b81b3c2538 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx @@ -69,7 +69,8 @@ export function ChannelSubForm<R extends ChannelValues>({ // Prevent forgetting about initial values when switching the integration type and the oncall integration type useEffect(() => { // Restore values when switching back from a changed integration to the default one - const subscription = watch((_, { name, type, value }) => { + const subscription = watch((v, { name, type }) => { + const value = name ? v[name] : ''; if (initialValues && name === fieldName('type') && value === initialValues.type && type === 'change') { setValue(fieldName('settings'), initialValues.settings); } diff --git a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx index 48ccc6e5bc2d9..a5bc8ccf2879e 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx @@ -10,7 +10,7 @@ import { useCleanup } from 'app/core/hooks/useCleanup'; import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; import { getMessageFromError } from '../../../../../../core/utils/errors'; -import { logAlertingError } from '../../../Analytics'; +import { logError } from '../../../Analytics'; import { isOnCallFetchError } from '../../../api/onCallApi'; import { useControlledFieldArray } from '../../../hooks/useControlledFieldArray'; import { ChannelValues, CommonSettingsComponentType, ReceiverFormValues } from '../../../types/receiver-form'; @@ -83,7 +83,7 @@ export function ReceiverForm<R extends ChannelValues>({ const { fields, append, remove } = useControlledFieldArray<R>({ name: 'items', formAPI, softDelete: true }); - const validateNameIsAvailable: Validate<string> = useCallback( + const validateNameIsAvailable: Validate<string, ReceiverFormValues<R>> = useCallback( (name: string) => takenReceiverNames.map((name) => name.trim().toLowerCase()).includes(name.trim().toLowerCase()) ? 'Another receiver with this name already exists.' @@ -103,7 +103,7 @@ export function ReceiverForm<R extends ChannelValues>({ const error = new Error('Failed to save the contact point'); error.cause = e; - logAlertingError(error); + logError(error); } throw e; } diff --git a/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx b/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx index 02384e0c63cf4..e5058dca9b941 100644 --- a/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx @@ -122,7 +122,8 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({ {...register(name, { required: determineRequired(option, getValues, pathIndex), validate: { - validationRule: (v) => (option.validationRule ? validateOption(v, option.validationRule) : true), + validationRule: (v) => + option.validationRule ? validateOption(v, option.validationRule, option.required) : true, customValidator: (v) => (customValidator ? customValidator(v) : true), }, setValueAs: option.setValueAs, @@ -167,7 +168,8 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({ rules={{ required: option.required ? 'Option is required' : false, validate: { - validationRule: (v) => (option.validationRule ? validateOption(v, option.validationRule) : true), + validationRule: (v) => + option.validationRule ? validateOption(v, option.validationRule, option.required) : true, customValidator: (v) => (customValidator ? customValidator(v) : true), }, }} @@ -183,7 +185,8 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({ placeholder={option.placeholder} {...register(name, { required: option.required ? 'Required' : false, - validate: (v) => (option.validationRule !== '' ? validateOption(v, option.validationRule) : true), + validate: (v) => + option.validationRule !== '' ? validateOption(v, option.validationRule, option.required) : true, })} /> ); @@ -223,7 +226,11 @@ const getStyles = (theme: GrafanaTheme2) => ({ `, }); -const validateOption = (value: string, validationRule: string) => { +const validateOption = (value: string, validationRule: string, required: boolean) => { + if (value === '' && !required) { + return true; + } + return RegExp(validationRule).test(value) ? true : 'Invalid format'; }; diff --git a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/useReceiversMetadata.ts b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/useReceiversMetadata.ts index 0fb9dcfaac586..ba3f923ea1ac1 100644 --- a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/useReceiversMetadata.ts +++ b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/useReceiversMetadata.ts @@ -39,7 +39,7 @@ export function getOnCallMetadata( } const matchingOnCallIntegration = onCallIntegrations.find( - (integration) => integration.integration_url === receiver.settings.url + (integration) => integration.integration_url === receiver.settings?.url ); return { diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationHeaderField.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationHeaderField.tsx index 892fabb52788c..b92f2568f045f 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationHeaderField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationHeaderField.tsx @@ -38,8 +38,10 @@ const AnnotationHeaderField = ({ switch (annotationField.key) { case Annotation.dashboardUID: label = 'Dashboard and panel'; + break; case Annotation.panelID: label = ''; + break; default: label = annotationLabels[annotation] && annotationLabels[annotation] + ' (optional)'; } diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx index 10df3d5e94049..8227b087bf219 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx @@ -89,20 +89,15 @@ const AnnotationsStep = () => { }; function getAnnotationsSectionDescription() { - const docsLink = - 'https://grafana.com/docs/grafana/latest/alerting/fundamentals/annotation-label/variables-label-annotation'; - return ( <Stack direction="row" gap={0.5} alignItems="baseline"> <Text variant="bodySmall" color="secondary"> - Add annotations to provide more context in your alert notifications. + Add more context in your notification messages. </Text> <NeedHelpInfo - contentText={`Annotations add metadata to provide more information on the alert in your alert notifications. - For example, add a Summary annotation to tell you which value caused the alert to fire or which server it happened on. + contentText={`Annotations add metadata to provide more information on the alert in your alert notification messages. + For example, add a Summary annotation to tell you which value caused the alert to fire or which server it happened on. Annotations can contain a combination of text and template code.`} - externalLink={docsLink} - linkText={`Read about annotations`} title="Annotations" /> </Stack> @@ -110,7 +105,7 @@ const AnnotationsStep = () => { } return ( - <RuleEditorSection stepNo={4} title="Add annotations" description={getAnnotationsSectionDescription()} fullWidth> + <RuleEditorSection stepNo={5} title="Add annotations" description={getAnnotationsSectionDescription()} fullWidth> <Stack direction="column" gap={1}> {fields.map((annotationField, index: number) => { const isUrl = annotations[index]?.key?.toLocaleLowerCase().endsWith('url'); diff --git a/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.test.tsx b/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.test.tsx index e7da3147ca05c..7532f9b7376b0 100644 --- a/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.test.tsx @@ -1,7 +1,7 @@ import { render, waitFor } from '@testing-library/react'; import { noop } from 'lodash'; import React from 'react'; -import { AutoSizerProps } from 'react-virtualized-auto-sizer'; +import { Props } from 'react-virtualized-auto-sizer'; import { byRole } from 'testing-library-selector'; import 'core-js/stable/structured-clone'; @@ -14,7 +14,13 @@ import { mockDashboardDto, mockDashboardSearchItem } from '../../mocks'; import { DashboardPicker } from './DashboardPicker'; jest.mock('react-virtualized-auto-sizer', () => { - return ({ children }: AutoSizerProps) => children({ height: 600, width: 1 }); + return ({ children }: Props) => + children({ + height: 600, + scaledHeight: 600, + scaledWidth: 1, + width: 1, + }); }); const server = setupMswServer(); diff --git a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx index 51b01d8c611b0..45771bcbd8216 100644 --- a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx @@ -39,7 +39,7 @@ import { checkForPathSeparator } from './util'; export const MAX_GROUP_RESULTS = 1000; -export const useFolderGroupOptions = (folderTitle: string, enableProvisionedGroups: boolean) => { +export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups: boolean) => { const dispatch = useDispatch(); // fetch the ruler rules from the database so we can figure out what other "groups" are already defined @@ -52,7 +52,7 @@ export const useFolderGroupOptions = (folderTitle: string, enableProvisionedGrou const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]; const grafanaFolders = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME); - const folderGroups = grafanaFolders.find((f) => f.name === folderTitle)?.groups ?? []; + const folderGroups = grafanaFolders.find((f) => f.uid === folderUid)?.groups ?? []; const groupOptions = folderGroups .map<SelectableValue<string>>((group) => { @@ -105,7 +105,7 @@ export function FolderAndGroup({ const folder = watch('folder'); const group = watch('group'); - const { groupOptions, loading } = useFolderGroupOptions(folder?.title ?? '', enableProvisionedGroups); + const { groupOptions, loading } = useFolderGroupOptions(folder?.uid ?? '', enableProvisionedGroups); const [isCreatingFolder, setIsCreatingFolder] = useState(false); const [isCreatingEvaluationGroup, setIsCreatingEvaluationGroup] = useState(false); @@ -146,55 +146,62 @@ export function FolderAndGroup({ return ( <div className={styles.container}> <Stack alignItems="center"> - <Field - label={ - <Label htmlFor="folder" description={'Select a folder to store your rule.'}> - Folder - </Label> - } - className={styles.formInput} - error={errors.folder?.message} - invalid={!!errors.folder?.message} - data-testid="folder-picker" - > - {(!isCreatingFolder && ( - <InputControl - render={({ field: { ref, ...field } }) => ( - <div style={{ width: 420 }}> - <RuleFolderPicker - inputId="folder" - {...field} - enableReset={true} - onChange={({ title, uid }) => { - field.onChange({ title, uid }); - resetGroup(); + { + <Field + label={ + <Label htmlFor="folder" description={'Select a folder to store your rule.'}> + Folder + </Label> + } + className={styles.formInput} + error={errors.folder?.message} + data-testid="folder-picker" + > + <Stack direction="row" alignItems="center"> + {(!isCreatingFolder && ( + <> + <InputControl + render={({ field: { ref, ...field } }) => ( + <div style={{ width: 420 }}> + <RuleFolderPicker + inputId="folder" + invalid={!!errors.folder?.message} + {...field} + enableReset={true} + onChange={({ title, uid }) => { + field.onChange({ title, uid }); + resetGroup(); + }} + /> + </div> + )} + name="folder" + rules={{ + required: { value: true, message: 'Select a folder' }, + validate: { + pathSeparator: (folder: Folder) => checkForPathSeparator(folder.uid), + }, }} /> - </div> - )} - name="folder" - rules={{ - required: { value: true, message: 'Select a folder' }, - validate: { - pathSeparator: (folder: Folder) => checkForPathSeparator(folder.title), - }, - }} - /> - )) || <div>Creating new folder...</div>} - </Field> - <Box marginTop={2.5} gap={1} display={'flex'} alignItems={'center'}> - <Text color="secondary">or</Text> - <Button - onClick={onOpenFolderCreationModal} - type="button" - icon="plus" - fill="outline" - variant="secondary" - disabled={!contextSrv.hasPermission(AccessControlAction.FoldersCreate)} - > - New folder - </Button> - </Box> + <Text color="secondary">or</Text> + <Button + onClick={onOpenFolderCreationModal} + type="button" + icon="plus" + fill="outline" + variant="secondary" + disabled={!contextSrv.hasPermission(AccessControlAction.FoldersCreate)} + > + New folder + </Button> + </> + )) || <div>Creating new folder...</div>} + </Stack> + </Field> + } + {isCreatingFolder && ( + <FolderCreationModal onCreate={handleFolderCreation} onClose={() => setIsCreatingFolder(false)} /> + )} </Stack> {isCreatingFolder && ( diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaAlertStatePicker.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaAlertStatePicker.tsx index ee732387de360..1494cc9a1359b 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GrafanaAlertStatePicker.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaAlertStatePicker.tsx @@ -15,6 +15,7 @@ const options: SelectableValue[] = [ { value: GrafanaAlertStateDecision.NoData, label: 'No Data' }, { value: GrafanaAlertStateDecision.OK, label: 'OK' }, { value: GrafanaAlertStateDecision.Error, label: 'Error' }, + { value: GrafanaAlertStateDecision.KeepLast, label: 'Keep Last State' }, ]; export const GrafanaAlertStatePicker = ({ includeNoData, includeError, ...props }: Props) => { diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx index a1f88222c2b61..2dfca5f49c6e5 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx @@ -18,7 +18,7 @@ import { } from '@grafana/ui'; import { CombinedRuleGroup, CombinedRuleNamespace } from '../../../../../types/unified-alerting'; -import { logInfo, LogMessages } from '../../Analytics'; +import { LogMessages, logInfo } from '../../Analytics'; import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { RuleFormValues } from '../../types/rule-form'; @@ -57,7 +57,7 @@ const forValidationOptions = (evaluateEvery: string): RegisterOptions => ({ const millisEvery = parsePrometheusDuration(evaluateEvery); return millisFor >= millisEvery ? true - : 'For duration must be greater than or equal to the evaluation interval.'; + : 'Pending period must be greater than or equal to the evaluation interval.'; } catch (err) { // if we fail to parse "every", assume validation is successful, or the error messages // will overlap in the UI @@ -92,16 +92,16 @@ function FolderGroupAndEvaluationInterval({ const { watch, setValue, getValues } = useFormContext<RuleFormValues>(); const [isEditingGroup, setIsEditingGroup] = useState(false); - const [groupName, folderName] = watch(['group', 'folder.title']); + const [groupName, folderUid, folderName] = watch(['group', 'folder.uid', 'folder.title']); const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]; const grafanaNamespaces = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME); - const existingNamespace = grafanaNamespaces.find((ns) => ns.name === folderName); + const existingNamespace = grafanaNamespaces.find((ns) => ns.uid === folderUid); const existingGroup = existingNamespace?.groups.find((g) => g.name === groupName); - const isNewGroup = useIsNewGroup(folderName ?? '', groupName); + const isNewGroup = useIsNewGroup(folderUid ?? '', groupName); useEffect(() => { if (!isNewGroup && existingGroup?.interval) { @@ -118,7 +118,7 @@ function FolderGroupAndEvaluationInterval({ const onOpenEditGroupModal = () => setIsEditingGroup(true); - const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folderName || !groupName; + const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folderUid || !groupName; const emptyNamespace: CombinedRuleNamespace = { name: folderName, @@ -137,6 +137,7 @@ function FolderGroupAndEvaluationInterval({ <EditCloudGroupModal namespace={existingNamespace ?? emptyNamespace} group={existingGroup ?? emptyGroup} + folderUid={folderUid} onClose={() => closeEditGroupModal()} intervalEditOnly hideFolder={true} diff --git a/public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx b/public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx index 49e01b94660aa..4f510ed30700f 100644 --- a/public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx @@ -1,9 +1,19 @@ import { css, cx } from '@emotion/css'; import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { FieldArrayMethodProps, useFieldArray, useFormContext } from 'react-hook-form'; +import { useFieldArray, UseFieldArrayAppend, useFormContext } from 'react-hook-form'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { Button, Field, InlineLabel, Input, LoadingPlaceholder, Stack, Text, useStyles2 } from '@grafana/ui'; +import { + Button, + Field, + InlineLabel, + Input, + InputControl, + LoadingPlaceholder, + Stack, + Text, + useStyles2, +} from '@grafana/ui'; import { useDispatch } from 'app/types'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; @@ -85,10 +95,7 @@ const RemoveButton: FC<{ ); const AddButton: FC<{ - append: ( - value: Partial<{ key: string; value: string }> | Array<Partial<{ key: string; value: string }>>, - options?: FieldArrayMethodProps | undefined - ) => void; + append: UseFieldArrayAppend<RuleFormValues, 'labels'>; className: string; }> = ({ append, className }) => ( <Button @@ -107,11 +114,9 @@ const AddButton: FC<{ const LabelsWithSuggestions: FC<{ dataSourceName: string }> = ({ dataSourceName }) => { const styles = useStyles2(getStyles); const { - register, control, watch, formState: { errors }, - setValue, } = useFormContext<RuleFormValues>(); const labels = watch('labels'); @@ -151,17 +156,24 @@ const LabelsWithSuggestions: FC<{ dataSourceName: string }> = ({ dataSourceName error={errors.labels?.[index]?.key?.message} data-testid={`label-key-${index}`} > - <AlertLabelDropdown - {...register(`labels.${index}.key`, { - required: { value: Boolean(labels[index]?.value), message: 'Required.' }, - })} - defaultValue={field.key ? { label: field.key, value: field.key } : undefined} - options={keys} - onChange={(newValue: SelectableValue) => { - setValue(`labels.${index}.key`, newValue.value); - setSelectedKey(newValue.value); + <InputControl + name={`labels.${index}.key`} + control={control} + rules={{ required: Boolean(labels[index]?.value) ? 'Required.' : false }} + render={({ field: { onChange, ref, ...rest } }) => { + return ( + <AlertLabelDropdown + {...rest} + defaultValue={field.key ? { label: field.key, value: field.key } : undefined} + options={keys} + onChange={(newValue: SelectableValue) => { + onChange(newValue.value); + setSelectedKey(newValue.value); + }} + type="key" + /> + ); }} - type="key" /> </Field> <InlineLabel className={styles.equalSign}>=</InlineLabel> @@ -171,19 +183,26 @@ const LabelsWithSuggestions: FC<{ dataSourceName: string }> = ({ dataSourceName error={errors.labels?.[index]?.value?.message} data-testid={`label-value-${index}`} > - <AlertLabelDropdown - {...register(`labels.${index}.value`, { - required: { value: Boolean(labels[index]?.key), message: 'Required.' }, - })} - defaultValue={field.value ? { label: field.value, value: field.value } : undefined} - options={values} - onChange={(newValue: SelectableValue) => { - setValue(`labels.${index}.value`, newValue.value); - }} - onOpenMenu={() => { - setSelectedKey(labels[index].key); + <InputControl + control={control} + name={`labels.${index}.value`} + rules={{ required: Boolean(labels[index]?.value) ? 'Required.' : false }} + render={({ field: { onChange, ref, ...rest } }) => { + return ( + <AlertLabelDropdown + {...rest} + defaultValue={field.value ? { label: field.value, value: field.value } : undefined} + options={values} + onChange={(newValue: SelectableValue) => { + onChange(newValue.value); + }} + onOpenMenu={() => { + setSelectedKey(labels[index].key); + }} + type="value" + /> + ); }} - type="value" /> </Field> @@ -265,10 +284,10 @@ const LabelsField: FC<Props> = ({ dataSourceName }) => { <Text element="h5">Labels</Text> <Stack direction={'row'} gap={1}> <Text variant="bodySmall" color="secondary"> - Add labels to your rule to annotate your rules, ease searching, or route to a notification policy. + Add labels to your rule for searching, silencing, or routing to a notification policy. </Text> <NeedHelpInfo - contentText="The dropdown only displays labels that you have previously used for alerts. + contentText="The dropdown only displays labels that you have previously used for alerts. Select a label from the options below or type in a new one." title="Labels" /> diff --git a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx index 1a87e3335ee51..d4c9a8af1eef6 100644 --- a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx @@ -37,8 +37,8 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => { return ( <RuleEditorSection - stepNo={type === RuleFormType.cloudRecording ? 4 : 5} - title={type === RuleFormType.cloudRecording ? 'Add labels' : 'Labels and notifications'} + stepNo={4} + title={type === RuleFormType.cloudRecording ? 'Add labels' : 'Configure labels and notifications'} description={ <Stack direction="row" gap={0.5} alignItems="baseline"> {type === RuleFormType.cloudRecording ? ( @@ -59,7 +59,7 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => { <LabelsField dataSourceName={dataSourceName} /> {shouldAllowSimplifiedRouting && ( <div className={styles.configureNotifications}> - <Text element="h5">Configure notifications</Text> + <Text element="h5">Notifications</Text> <Text variant="bodySmall" color="secondary"> Select who should receive a notification when an alert rule fires. </Text> @@ -80,9 +80,9 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => { * - simplified routing is enabled * - the alert rule is a grafana rule * - * This component will render the switch between the manual routing and the notification policy routing. + * This component will render the switch between the select contact point routing and the notification policy routing. * It also renders the section body of the NotificationsStep, depending on the routing option selected. - * If manual routing is selected, it will render the SimplifiedRouting component. + * If select contact point routing is selected, it will render the SimplifiedRouting component. * If notification policy routing is selected, it will render the AutomaticRouting component. * */ @@ -93,8 +93,8 @@ function ManualAndAutomaticRouting({ alertUid }: { alertUid?: string }) { const [manualRouting] = watch(['manualRouting']); const routingOptions = [ - { label: 'Manually select contact point', value: RoutingOptions.ContactPoint }, - { label: 'Auto-select contact point', value: RoutingOptions.NotificationPolicy }, + { label: 'Select contact point', value: RoutingOptions.ContactPoint }, + { label: 'Use notification policy', value: RoutingOptions.NotificationPolicy }, ]; const onRoutingOptionChange = (option: RoutingOptions) => { @@ -229,7 +229,7 @@ export const RoutingOptionDescription = ({ manualRouting }: NotificationsStepDes <Text variant="bodySmall" color="secondary"> {manualRouting ? 'Notifications for firing alerts are routed to a selected contact point.' - : 'Notifications for firing alerts are routed to contact points based on matching labels.'} + : 'Notifications for firing alerts are routed to contact points based on matching labels and the notification policy tree.'} </Text> {manualRouting ? <NeedHelpInfoForContactpoint /> : <NeedHelpInfoForNotificationPolicy />} </div> diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryOptions.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryOptions.tsx index 87e6443b76538..ba7857e015357 100644 --- a/public/app/features/alerting/unified/components/rule-editor/QueryOptions.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/QueryOptions.tsx @@ -55,11 +55,7 @@ export const QueryOptions = ({ </Toggletip> <div className={styles.staticValues}> - <span> - {dateTime(timeRange?.from) - .locale('en') - .fromNow(true)} - </span> + <span>{dateTime(timeRange?.from).locale('en').fromNow(true)}</span> {queryOptions.maxDataPoints && <span>, MD = {queryOptions.maxDataPoints}</span>} {queryOptions.minInterval && <span>, Min. Interval = {queryOptions.minInterval}</span>} </div> diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx index fc17b0b323efd..55cb20a1dac9a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx @@ -144,8 +144,8 @@ export class QueryRows extends PureComponent<Props> { }; render() { - const { queries, expressions } = this.props; - const thresholdByRefId = getThresholdsForQueries([...queries, ...expressions]); + const { queries, expressions, condition } = this.props; + const thresholdByRefId = getThresholdsForQueries([...queries, ...expressions], condition); return ( <DragDropContext onDragEnd={this.onDragEnd}> diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx index 0786eba69db8b..cca7a23919940 100644 --- a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx @@ -13,7 +13,7 @@ import { ThresholdsConfig, } from '@grafana/data'; import { DataQuery } from '@grafana/schema'; -import { GraphTresholdsStyleMode, Icon, InlineField, Input, Tooltip, useStyles2, Stack } from '@grafana/ui'; +import { GraphThresholdsStyleMode, Icon, InlineField, Input, Tooltip, useStyles2, Stack } from '@grafana/ui'; import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow'; import { AlertQuery } from 'app/types/unified-alerting-dto'; @@ -45,7 +45,7 @@ interface Props { onRunQueries: () => void; index: number; thresholds: ThresholdsConfig; - thresholdsType?: GraphTresholdsStyleMode; + thresholdsType?: GraphThresholdsStyleMode; onChangeThreshold?: (thresholds: ThresholdsConfig, index: number) => void; condition: string | null; onSetCondition: (refId: string) => void; @@ -130,16 +130,15 @@ export const QueryWrapper = ({ onChangeQueryOptions={onChangeQueryOptions} index={index} /> - <ExpressionStatusIndicator - error={error} - onSetCondition={() => onSetCondition(query.refId)} - isCondition={isAlertCondition} - /> + <ExpressionStatusIndicator onSetCondition={() => onSetCondition(query.refId)} isCondition={isAlertCondition} /> </Stack> ); } const showVizualisation = data.state !== LoadingState.NotStarted; + // ⚠️ the query editors want the entire array of queries passed as "DataQuery" NOT "AlertQuery" + // TypeScript isn't complaining here because the interfaces just happen to be compatible + const editorQueries = cloneDeep(queries.map((query) => query.model)); return ( <Stack direction="column" gap={0.5}> @@ -159,7 +158,7 @@ export const QueryWrapper = ({ onRemoveQuery={onRemoveQuery} onAddQuery={() => onDuplicateQuery(cloneDeep(query))} onRunQuery={onRunQueries} - queries={queries} + queries={editorQueries} renderHeaderExtras={() => <HeaderExtras query={query} index={index} error={error} />} app={CoreApp.UnifiedAlerting} hideDisableQuery={true} @@ -243,12 +242,12 @@ export function MinIntervalOption({ return ( <InlineField - label="Min interval" + label="Interval" labelWidth={24} tooltip={ <> - A lower limit for the interval. Recommended to be set to write frequency, for example <code>1m</code> if your - data is written every minute. + Interval sent to the data source. Recommended to be set to write frequency, for example <code>1m</code> if + your data is written every minute. </> } > diff --git a/public/app/features/alerting/unified/components/rule-editor/RecordingRuleEditor.tsx b/public/app/features/alerting/unified/components/rule-editor/RecordingRuleEditor.tsx index 3fe2feb640f21..60a0d44fe6198 100644 --- a/public/app/features/alerting/unified/components/rule-editor/RecordingRuleEditor.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/RecordingRuleEditor.tsx @@ -68,8 +68,11 @@ export const RecordingRuleEditor: FC<RecordingRuleEditorProps> = ({ datasource: changedQuery.datasource, refId: changedQuery.refId, editorMode: changedQuery.editorMode, - instant: Boolean(changedQuery.instant), - range: Boolean(changedQuery.range), + // Instant and range are used by Prometheus queries + instant: changedQuery.instant, + range: changedQuery.range, + // Query type is used by Loki queries + queryType: changedQuery.queryType, legendFormat: changedQuery.legendFormat, }, }; diff --git a/public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx b/public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx index 0afb7361a841d..a5ffba920d685 100644 --- a/public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx @@ -2,11 +2,11 @@ import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Icon, Tooltip, useStyles2, Stack } from '@grafana/ui'; -import { OldFolderPicker, Props as FolderPickerProps } from 'app/core/components/Select/OldFolderPicker'; +import { Icon, Stack, Tooltip, useStyles2 } from '@grafana/ui'; +import { Props as FolderPickerProps, OldFolderPicker } from 'app/core/components/Select/OldFolderPicker'; import { PermissionLevelString, SearchQueryType } from 'app/types'; -import { FolderWarning, CustomAdd } from '../../../../../core/components/Select/OldFolderPicker'; +import { CustomAdd, FolderWarning } from '../../../../../core/components/Select/OldFolderPicker'; export interface Folder { title: string; @@ -15,6 +15,7 @@ export interface Folder { export interface RuleFolderPickerProps extends Omit<FolderPickerProps, 'initialTitle' | 'initialFolderId'> { value?: Folder; + invalid?: boolean; } const SlashesWarning = () => { @@ -51,7 +52,6 @@ export function RuleFolderPicker(props: RuleFolderPickerProps) { showRoot={false} rootName="" allowEmpty={true} - initialTitle={value?.title} initialFolderUid={value?.uid} searchQueryType={SearchQueryType.AlertFolder} {...props} diff --git a/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx b/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx index 347738dce3656..bb679e030f9a4 100644 --- a/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx @@ -3,7 +3,7 @@ import React from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2, isTimeSeriesFrames, PanelData, ThresholdsConfig } from '@grafana/data'; -import { GraphTresholdsStyleMode } from '@grafana/schema'; +import { GraphThresholdsStyleMode } from '@grafana/schema'; import { useStyles2 } from '@grafana/ui'; import appEvents from 'app/core/app_events'; import { GraphContainer } from 'app/features/explore/Graph/GraphContainer'; @@ -15,7 +15,7 @@ import { getStatusMessage } from './util'; interface Props { data: PanelData; thresholds?: ThresholdsConfig; - thresholdsType?: GraphTresholdsStyleMode; + thresholdsType?: GraphThresholdsStyleMode; onThresholdsChange?: (thresholds: ThresholdsConfig) => void; } diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index 628dac968074a..645320c32e7da 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -1,11 +1,11 @@ import { css } from '@emotion/css'; import React, { useEffect, useMemo, useState } from 'react'; -import { DeepMap, FieldError, FormProvider, useForm, UseFormWatch } from 'react-hook-form'; +import { FormProvider, SubmitErrorHandler, UseFormWatch, useForm } from 'react-hook-form'; import { Link, useParams } from 'react-router-dom'; import { GrafanaTheme2 } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { Button, ConfirmModal, CustomScrollbar, HorizontalGroup, Spinner, useStyles2, Stack } from '@grafana/ui'; +import { Button, ConfirmModal, CustomScrollbar, HorizontalGroup, Spinner, Stack, useStyles2 } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { useAppNotification } from 'app/core/copy/appNotification'; import { contextSrv } from 'app/core/core'; @@ -14,17 +14,24 @@ import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { useDispatch } from 'app/types'; import { RuleWithLocation } from 'app/types/unified-alerting'; -import { logInfo, LogMessages, trackNewAlerRuleFormError } from '../../../Analytics'; +import { + LogMessages, + logInfo, + trackAlertRuleFormError, + trackAlertRuleFormCancelled, + trackAlertRuleFormSaved, +} from '../../../Analytics'; import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector'; import { deleteRuleAction, saveRuleFormAction } from '../../../state/actions'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { initialAsyncRequestState } from '../../../utils/redux'; import { + MANUAL_ROUTING_KEY, + MINUTE, formValuesFromExistingRule, getDefaultFormValues, getDefaultQueries, ignoreHiddenQueries, - MINUTE, normalizeDefaultAnnotations, } from '../../../utils/rule-form'; import * as ruleId from '../../../utils/rule-id'; @@ -109,6 +116,17 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { return; } + trackAlertRuleFormSaved({ formAction: existing ? 'update' : 'create', ruleType: values.type }); + + // when creating a new rule, we save the manual routing setting in local storage + if (!existing) { + if (values.manualRouting) { + localStorage.setItem(MANUAL_ROUTING_KEY, 'true'); + } else { + localStorage.setItem(MANUAL_ROUTING_KEY, 'false'); + } + } + dispatch( saveRuleFormAction({ values: { @@ -144,21 +162,22 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { } }; - const onInvalid = (errors: DeepMap<RuleFormValues, FieldError>): void => { - if (!existing) { - trackNewAlerRuleFormError({ - grafana_version: config.buildInfo.version, - org_id: contextSrv.user.orgId, - user_id: contextSrv.user.id, - error: Object.keys(errors).toString(), - }); - } + const onInvalid: SubmitErrorHandler<RuleFormValues> = (errors): void => { + trackAlertRuleFormError({ + grafana_version: config.buildInfo.version, + org_id: contextSrv.user.orgId, + user_id: contextSrv.user.id, + error: Object.keys(errors).toString(), + formAction: existing ? 'update' : 'create', + }); notifyApp.error('There are errors in the form. Please correct them and try again!'); }; const cancelRuleCreation = () => { logInfo(LogMessages.cancelSavingAlertRule); + trackAlertRuleFormCancelled({ formAction: existing ? 'update' : 'create' }); }; + const evaluateEveryInForm = watch('evaluateEvery'); useEffect(() => setEvaluateEvery(evaluateEveryInForm), [evaluateEveryInForm]); @@ -240,10 +259,10 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { {type === RuleFormType.cloudRecording && <RecordingRulesNameSpaceAndGroupStep />} {/* Step 4 & 5 */} - {/* Annotations only for cloud and Grafana */} - {type !== RuleFormType.cloudRecording && <AnnotationsStep />} {/* Notifications step*/} <NotificationsStep alertUid={uidFromParams} /> + {/* Annotations only for cloud and Grafana */} + {type !== RuleFormType.cloudRecording && <AnnotationsStep />} </> )} </Stack> diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx index f5506787546c5..87f44a6c2b9d9 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx @@ -46,6 +46,10 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor const [conditionErrorMsg, setConditionErrorMsg] = useState(''); const [evaluateEvery, setEvaluateEvery] = useState(ruleForm?.evaluateEvery ?? MINUTE); + const onInvalid = (): void => { + notifyApp.error('There are errors in the form. Please correct them and try again!'); + }; + const checkAlertCondition = (msg = '') => { setConditionErrorMsg(msg); }; @@ -66,7 +70,7 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor <LinkButton href={returnTo} key="cancel" size="sm" variant="secondary" onClick={() => submit(undefined)}> Cancel </LinkButton>, - <Button key="export-rule" size="sm" onClick={formAPI.handleSubmit((formValues) => submit(formValues))}> + <Button key="export-rule" size="sm" onClick={formAPI.handleSubmit((formValues) => submit(formValues), onInvalid)}> Export </Button>, ]; @@ -93,10 +97,10 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor /> {/* Step 4 & 5 */} - {/* Annotations only for cloud and Grafana */} - <AnnotationsStep /> {/* Notifications step*/} <NotificationsStep alertUid={alertUid} /> + {/* Annotations only for cloud and Grafana */} + <AnnotationsStep /> </Stack> </CustomScrollbar> </div> @@ -107,14 +111,14 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor ); } -const useGetGroup = (nameSpace: string, group: string) => { +const useGetGroup = (nameSpaceUID: string, group: string) => { const { dsFeatures } = useDataSourceFeatures(GRAFANA_RULES_SOURCE_NAME); const rulerConfig = dsFeatures?.rulerConfig; const targetGroup = useAsync(async () => { - return rulerConfig ? await fetchRulerRulesGroup(rulerConfig, nameSpace, group) : undefined; - }, [rulerConfig, nameSpace, group]); + return rulerConfig ? await fetchRulerRulesGroup(rulerConfig, nameSpaceUID, group) : undefined; + }, [rulerConfig, nameSpaceUID, group]); return targetGroup; }; @@ -162,7 +166,7 @@ export const getPayloadToExport = ( }; const useGetPayloadToExport = (values: RuleFormValues, uid: string) => { - const rulerGroupDto = useGetGroup(values.folder?.title ?? '', values.group); + const rulerGroupDto = useGetGroup(values.folder?.uid ?? '', values.group); const payload: ModifyExportPayload = useMemo(() => { return getPayloadToExport(uid, values, rulerGroupDto?.value); }, [uid, rulerGroupDto, values]); @@ -178,11 +182,11 @@ const GrafanaRuleDesignExportPreview = ({ const [getExport, exportData] = alertRuleApi.endpoints.exportModifiedRuleGroup.useMutation(); const { loadingGroup, payload } = useGetPayloadToExport(exportValues, uid); - const nameSpace = exportValues.folder?.title ?? ''; + const nameSpaceUID = exportValues.folder?.uid ?? ''; useEffect(() => { - !loadingGroup && getExport({ payload, format: exportFormat, nameSpace: nameSpace }); - }, [nameSpace, exportFormat, payload, getExport, loadingGroup]); + !loadingGroup && getExport({ payload, format: exportFormat, nameSpaceUID }); + }, [nameSpaceUID, exportFormat, payload, getExport, loadingGroup]); if (exportData.isLoading) { return <LoadingPlaceholder text="Loading...." />; diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx index 436c892861af2..2012a49ec4a19 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx @@ -1,11 +1,13 @@ import { css } from '@emotion/css'; import React, { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; -import { Alert, CollapsableSection, Icon, Link, LoadingPlaceholder, Stack, Text, useStyles2 } from '@grafana/ui'; +import { Alert, CollapsableSection, LoadingPlaceholder, Stack, useStyles2 } from '@grafana/ui'; +import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; import { AlertManagerDataSource } from 'app/features/alerting/unified/utils/datasource'; -import { createUrl } from 'app/features/alerting/unified/utils/url'; +import { ContactPointReceiverSummary } from '../../../contact-points/ContactPoints'; import { useContactPointsWithStatus } from '../../../contact-points/useContactPoints'; import { ContactPointWithMetadata } from '../../../contact-points/utils'; @@ -22,12 +24,33 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo const styles = useStyles2(getStyles); const alertManagerName = alertManager.name; - const { isLoading, error: errorInContactPointStatus, contactPoints } = useContactPointsWithStatus(); - const shouldShowAM = true; + const { + isLoading, + error: errorInContactPointStatus, + contactPoints, + refetchReceivers, + } = useContactPointsWithStatus({ includePoliciesCount: false, receiverStatusPollingInterval: 0 }); const [selectedContactPointWithMetadata, setSelectedContactPointWithMetadata] = useState< ContactPointWithMetadata | undefined >(); + const onSelectContactPoint = (contactPoint?: ContactPointWithMetadata) => { + setSelectedContactPointWithMetadata(contactPoint); + }; + + const { watch } = useFormContext<RuleFormValues>(); + const hasRouteSettings = + watch(`contactPoints.${alertManagerName}.overrideGrouping`) || + watch(`contactPoints.${alertManagerName}.overrideTimings`) || + watch(`contactPoints.${alertManagerName}.muteTimeIntervals`)?.length > 0; + + const options = contactPoints.map((receiver) => { + const integrations = receiver?.grafana_managed_receiver_configs; + const description = <ContactPointReceiverSummary receivers={integrations ?? []} />; + + return { label: receiver.name, value: receiver, description }; + }); + if (errorInContactPointStatus) { return <Alert title="Failed to fetch contact points" severity="error" />; } @@ -36,30 +59,32 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo } return ( <Stack direction="column"> - {shouldShowAM && ( - <Stack direction="row" alignItems="center"> - <div className={styles.firstAlertManagerLine}></div> - <div className={styles.alertManagerName}> - Alert manager: - <img src={alertManager.imgUrl} alt="Alert manager logo" className={styles.img} /> - {alertManagerName} - </div> - <div className={styles.secondAlertManagerLine}></div> - </Stack> - )} + <Stack direction="row" alignItems="center"> + <div className={styles.firstAlertManagerLine}></div> + <div className={styles.alertManagerName}> + Alert manager: + <img src={alertManager.imgUrl} alt="Alert manager logo" className={styles.img} /> + {alertManagerName} + </div> + <div className={styles.secondAlertManagerLine}></div> + </Stack> <Stack direction="row" gap={1} alignItems="center"> <ContactPointSelector alertManager={alertManagerName} - contactPoints={contactPoints} - onSelectContactPoint={setSelectedContactPointWithMetadata} + options={options} + onSelectContactPoint={onSelectContactPoint} + refetchReceivers={refetchReceivers} /> - <LinkToContactPoints /> </Stack> {selectedContactPointWithMetadata?.grafana_managed_receiver_configs && ( <ContactPointDetails receivers={selectedContactPointWithMetadata.grafana_managed_receiver_configs} /> )} <div className={styles.routingSection}> - <CollapsableSection label="Muting, grouping and timings" isOpen={false} className={styles.collapsableSection}> + <CollapsableSection + label="Muting, grouping and timings (optional)" + isOpen={hasRouteSettings} + className={styles.collapsableSection} + > <Stack direction="column" gap={1}> <MuteTimingFields alertManager={alertManagerName} /> <RoutingSettings alertManager={alertManagerName} /> @@ -69,18 +94,6 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo </Stack> ); } -function LinkToContactPoints() { - const hrefToContactPoints = '/alerting/notifications'; - return ( - <Link target="_blank" href={createUrl(hrefToContactPoints)} rel="noopener" aria-label="View alert rule"> - <Stack direction="row" gap={1} alignItems="center" justifyContent="center"> - <Text color="secondary">To browse contact points and create new ones go to</Text> - <Text color="link">Contact points</Text> - <Icon name={'external-link-alt'} size="sm" color="link" /> - </Stack> - </Link> - ); -} const getStyles = (theme: GrafanaTheme2) => ({ firstAlertManagerLine: css({ diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx new file mode 100644 index 0000000000000..fc6db8b26bd40 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx @@ -0,0 +1,433 @@ +import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Route } from 'react-router-dom'; +import { TestProvider } from 'test/helpers/TestProvider'; +import { ui } from 'test/helpers/alertingRuleEditor'; +import { clickSelectOption } from 'test/helpers/selectOptionInTest'; +import { byRole } from 'testing-library-selector'; + +import { config, locationService, setDataSourceSrv } from '@grafana/runtime'; +import { contextSrv } from 'app/core/services/context_srv'; +import RuleEditor from 'app/features/alerting/unified/RuleEditor'; +import { discoverFeatures } from 'app/features/alerting/unified/api/buildInfo'; +import { + fetchRulerRules, + fetchRulerRulesGroup, + fetchRulerRulesNamespace, + setRulerRuleGroup, +} from 'app/features/alerting/unified/api/ruler'; +import * as useContactPoints from 'app/features/alerting/unified/components/contact-points/useContactPoints'; +import * as dsByPermission from 'app/features/alerting/unified/hooks/useAlertManagerSources'; +import { MockDataSourceSrv, grantUserPermissions, mockDataSource } from 'app/features/alerting/unified/mocks'; +import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; +import { fetchRulerRulesIfNotFetchedYet } from 'app/features/alerting/unified/state/actions'; +import * as utils_config from 'app/features/alerting/unified/utils/config'; +import { + AlertManagerDataSource, + DataSourceType, + GRAFANA_DATASOURCE_NAME, + GRAFANA_RULES_SOURCE_NAME, + getAlertManagerDataSourcesByPermission, + useGetAlertManagerDataSourcesByPermissionAndConfig, +} from 'app/features/alerting/unified/utils/datasource'; +import { getDefaultQueries } from 'app/features/alerting/unified/utils/rule-form'; +import { searchFolders } from 'app/features/manage-dashboards/state/actions'; +import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; +import { AccessControlAction } from 'app/types'; +import { GrafanaAlertStateDecision, PromApplication } from 'app/types/unified-alerting-dto'; + +import { RECEIVER_META_KEY } from '../../../contact-points/useContactPoints'; +import { ContactPointWithMetadata } from '../../../contact-points/utils'; +import { ExpressionEditorProps } from '../../ExpressionEditor'; + +jest.mock('app/features/alerting/unified/components/rule-editor/ExpressionEditor', () => ({ + // eslint-disable-next-line react/display-name + ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( + <input value={value} data-testid="expr" onChange={(e) => onChange(e.target.value)} /> + ), +})); + +jest.mock('app/features/alerting/unified/api/buildInfo'); +jest.mock('app/features/alerting/unified/api/ruler'); +jest.mock('app/features/manage-dashboards/state/actions'); + +jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ + AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) => <div>{actions}</div>, +})); + +// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. +// lets just skip it +jest.mock('app/features/query/components/QueryEditorRow', () => ({ + // eslint-disable-next-line react/display-name + QueryEditorRow: () => <p>hi</p>, +})); + +// simplified routing mocks +const grafanaAlertManagerDataSource: AlertManagerDataSource = { + name: GRAFANA_RULES_SOURCE_NAME, + imgUrl: 'public/img/grafana_icon.svg', + hasConfigurationAPI: true, +}; +jest.mock('app/features/alerting/unified/utils/datasource', () => { + return { + ...jest.requireActual('app/features/alerting/unified/utils/datasource'), + getAlertManagerDataSourcesByPermission: jest.fn(), + useGetAlertManagerDataSourcesByPermissionAndConfig: jest.fn(), + getAlertmanagerDataSourceByName: jest.fn(), + }; +}); + +const user = userEvent.setup(); + +jest.spyOn(utils_config, 'getAllDataSources'); +jest.spyOn(dsByPermission, 'useAlertManagersByPermission'); +jest.spyOn(useContactPoints, 'useContactPointsWithStatus'); + +jest.setTimeout(60 * 1000); + +const mocks = { + getAllDataSources: jest.mocked(utils_config.getAllDataSources), + searchFolders: jest.mocked(searchFolders), + useContactPointsWithStatus: jest.mocked(useContactPoints.useContactPointsWithStatus), + useGetAlertManagerDataSourcesByPermissionAndConfig: jest.mocked(useGetAlertManagerDataSourcesByPermissionAndConfig), + getAlertManagerDataSourcesByPermission: jest.mocked(getAlertManagerDataSourcesByPermission), + api: { + discoverFeatures: jest.mocked(discoverFeatures), + fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), + setRulerRuleGroup: jest.mocked(setRulerRuleGroup), + fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), + fetchRulerRules: jest.mocked(fetchRulerRules), + fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), + }, +}; + +describe('Can create a new grafana managed alert unsing simplified routing', () => { + beforeEach(() => { + jest.clearAllMocks(); + contextSrv.isEditor = true; + contextSrv.hasEditPermissionInFolders = true; + grantUserPermissions([ + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleUpdate, + AccessControlAction.AlertingRuleDelete, + AccessControlAction.AlertingRuleCreate, + AccessControlAction.DataSourcesRead, + AccessControlAction.DataSourcesWrite, + AccessControlAction.DataSourcesCreate, + AccessControlAction.FoldersWrite, + AccessControlAction.FoldersRead, + AccessControlAction.AlertingRuleExternalRead, + AccessControlAction.AlertingRuleExternalWrite, + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsWrite, + ]); + mocks.getAlertManagerDataSourcesByPermission.mockReturnValue({ + availableInternalDataSources: [grafanaAlertManagerDataSource], + availableExternalDataSources: [], + }); + + mocks.useGetAlertManagerDataSourcesByPermissionAndConfig.mockReturnValue([grafanaAlertManagerDataSource]); + + jest.mocked(dsByPermission.useAlertManagersByPermission).mockReturnValue({ + availableInternalDataSources: [grafanaAlertManagerDataSource], + availableExternalDataSources: [], + }); + }); + + const dataSources = { + default: mockDataSource( + { + type: 'prometheus', + name: 'Prom', + isDefault: true, + }, + { alerting: false } + ), + am: mockDataSource({ + name: 'Alertmanager', + type: DataSourceType.Alertmanager, + }), + }; + + it('cannot create new grafana managed alert when using simplified routing and not selecting a contact point', async () => { + // no contact points found + mocks.useContactPointsWithStatus.mockReturnValue({ + contactPoints: [], + isLoading: false, + error: undefined, + refetchReceivers: jest.fn(), + }); + + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + mocks.api.setRulerRuleGroup.mockResolvedValue(); + mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); + mocks.api.fetchRulerRulesGroup.mockResolvedValue({ + name: 'group2', + rules: [], + }); + mocks.api.fetchRulerRules.mockResolvedValue({ + 'Folder A': [ + { + interval: '1m', + name: 'group1', + rules: [ + { + annotations: { description: 'some description', summary: 'some summary' }, + labels: { severity: 'warn', team: 'the a-team' }, + for: '5m', + grafana_alert: { + uid: '23', + namespace_uid: 'abcd', + condition: 'B', + data: getDefaultQueries(), + exec_err_state: GrafanaAlertStateDecision.Error, + no_data_state: GrafanaAlertStateDecision.NoData, + title: 'my great new rule', + }, + }, + ], + }, + ], + namespace2: [ + { + interval: '1m', + name: 'group1', + rules: [ + { + annotations: { description: 'some description', summary: 'some summary' }, + labels: { severity: 'warn', team: 'the a-team' }, + for: '5m', + grafana_alert: { + uid: '23', + namespace_uid: 'b', + condition: 'B', + data: getDefaultQueries(), + exec_err_state: GrafanaAlertStateDecision.Error, + no_data_state: GrafanaAlertStateDecision.NoData, + title: 'my great new rule', + }, + }, + ], + }, + ], + }); + mocks.searchFolders.mockResolvedValue([ + { + title: 'Folder A', + uid: 'abcd', + id: 1, + type: DashboardSearchItemType.DashDB, + }, + { + title: 'Folder B', + id: 2, + }, + { + title: 'Folder / with slash', + id: 2, + uid: 'b', + type: DashboardSearchItemType.DashDB, + }, + ] as DashboardSearchHit[]); + + mocks.api.discoverFeatures.mockResolvedValue({ + application: PromApplication.Prometheus, + features: { + rulerApiEnabled: false, + }, + }); + config.featureToggles.alertingSimplifiedRouting = true; + renderSimplifiedRuleEditor(); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); + + await user.type(await ui.inputs.name.find(), 'my great new rule'); + + const folderInput = await ui.inputs.folder.find(); + await clickSelectOption(folderInput, 'Folder A'); + const groupInput = await ui.inputs.group.find(); + await user.click(byRole('combobox').get(groupInput)); + await clickSelectOption(groupInput, 'group1'); + //select contact point routing + await user.click(ui.inputs.simplifiedRouting.contactPointRouting.get()); + // do not select a contact point + // save and check that call to backend was not made + await user.click(ui.buttons.saveAndExit.get()); + await waitFor(() => { + expect(screen.getByText('Contact point is required.')).toBeInTheDocument(); + expect(mocks.api.setRulerRuleGroup).not.toHaveBeenCalled(); + }); + }); + it('can create new grafana managed alert when using simplified routing and selecting a contact point', async () => { + const contactPointsAvailable: ContactPointWithMetadata[] = [ + { + name: 'contact_point1', + grafana_managed_receiver_configs: [ + { + name: 'contact_point1', + type: 'email', + disableResolveMessage: false, + [RECEIVER_META_KEY]: { + name: 'contact_point1', + description: 'contact_point1 description', + }, + settings: {}, + }, + ], + numberOfPolicies: 0, + }, + ]; + mocks.useContactPointsWithStatus.mockReturnValue({ + contactPoints: contactPointsAvailable, + isLoading: false, + error: undefined, + refetchReceivers: jest.fn(), + }); + + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + mocks.api.setRulerRuleGroup.mockResolvedValue(); + mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); + mocks.api.fetchRulerRulesGroup.mockResolvedValue({ + name: 'group2', + rules: [], + }); + mocks.api.fetchRulerRules.mockResolvedValue({ + 'Folder A': [ + { + interval: '1m', + name: 'group1', + rules: [ + { + annotations: { description: 'some description', summary: 'some summary' }, + labels: { severity: 'warn', team: 'the a-team' }, + for: '5m', + grafana_alert: { + uid: '23', + namespace_uid: 'abcd', + condition: 'B', + data: getDefaultQueries(), + exec_err_state: GrafanaAlertStateDecision.Error, + no_data_state: GrafanaAlertStateDecision.NoData, + title: 'my great new rule', + }, + }, + ], + }, + ], + namespace2: [ + { + interval: '1m', + name: 'group1', + rules: [ + { + annotations: { description: 'some description', summary: 'some summary' }, + labels: { severity: 'warn', team: 'the a-team' }, + for: '5m', + grafana_alert: { + uid: '23', + namespace_uid: 'b', + condition: 'B', + data: getDefaultQueries(), + exec_err_state: GrafanaAlertStateDecision.Error, + no_data_state: GrafanaAlertStateDecision.NoData, + title: 'my great new rule', + }, + }, + ], + }, + ], + }); + mocks.searchFolders.mockResolvedValue([ + { + title: 'Folder A', + uid: 'abcd', + id: 1, + type: DashboardSearchItemType.DashDB, + }, + { + title: 'Folder B', + id: 2, + uid: 'b', + type: DashboardSearchItemType.DashDB, + }, + { + title: 'Folder / with slash', + uid: 'c', + id: 2, + type: DashboardSearchItemType.DashDB, + }, + ] as DashboardSearchHit[]); + + mocks.api.discoverFeatures.mockResolvedValue({ + application: PromApplication.Prometheus, + features: { + rulerApiEnabled: false, + }, + }); + config.featureToggles.alertingSimplifiedRouting = true; + renderSimplifiedRuleEditor(); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); + + await user.type(await ui.inputs.name.find(), 'my great new rule'); + + const folderInput = await ui.inputs.folder.find(); + await clickSelectOption(folderInput, 'Folder A'); + const groupInput = await ui.inputs.group.find(); + await user.click(byRole('combobox').get(groupInput)); + await clickSelectOption(groupInput, 'group1'); + //select contact point routing + await user.click(ui.inputs.simplifiedRouting.contactPointRouting.get()); + const contactPointInput = await ui.inputs.simplifiedRouting.contactPoint.find(); + await user.click(byRole('combobox').get(contactPointInput)); + await clickSelectOption(contactPointInput, 'contact_point1'); + + // save and check what was sent to backend + await user.click(ui.buttons.saveAndExit.get()); + await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, + 'abcd', + { + interval: '1m', + name: 'group1', + rules: [ + { + annotations: {}, + labels: {}, + for: '5m', + grafana_alert: { + condition: 'B', + data: getDefaultQueries(), + exec_err_state: GrafanaAlertStateDecision.Error, + is_paused: false, + no_data_state: 'NoData', + title: 'my great new rule', + notification_settings: { + group_by: undefined, + group_interval: undefined, + group_wait: undefined, + mute_timings: undefined, + receiver: 'contact_point1', + repeat_interval: undefined, + }, + }, + }, + ], + } + ); + }); +}); + +function renderSimplifiedRuleEditor() { + locationService.push(`/alerting/new/alerting`); + + return render( + <TestProvider> + <AlertmanagerProvider alertmanagerSourceName={GRAFANA_DATASOURCE_NAME} accessType="notification"> + <Route path={['/alerting/new/:type', '/alerting/:id/edit']} component={RuleEditor} /> + </AlertmanagerProvider> + </TestProvider> + ); +} diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx index 4e9e9cf5948eb..aeee48bcac5e6 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx @@ -1,83 +1,189 @@ -import { css } from '@emotion/css'; -import React from 'react'; +import { css, cx } from '@emotion/css'; +import React, { useCallback, useEffect, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { ActionMeta, Field, FieldValidationMessage, InputControl, Select, Stack, useStyles2 } from '@grafana/ui'; +import { + ActionMeta, + Field, + FieldValidationMessage, + IconButton, + InputControl, + Select, + Stack, + TextLink, + useStyles2, +} from '@grafana/ui'; import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; +import { createUrl } from 'app/features/alerting/unified/utils/url'; -import { ContactPointReceiverSummary } from '../../../../contact-points/ContactPoints'; import { ContactPointWithMetadata } from '../../../../contact-points/utils'; export interface ContactPointSelectorProps { alertManager: string; - contactPoints: ContactPointWithMetadata[]; + options: Array<{ + label: string; + value: ContactPointWithMetadata; + description: React.JSX.Element; + }>; onSelectContactPoint: (contactPoint?: ContactPointWithMetadata) => void; + refetchReceivers: () => Promise<unknown>; } -export function ContactPointSelector({ alertManager, contactPoints, onSelectContactPoint }: ContactPointSelectorProps) { + +const MAX_CONTACT_POINTS_RENDERED = 500; + +export function ContactPointSelector({ + alertManager, + options, + onSelectContactPoint, + refetchReceivers, +}: ContactPointSelectorProps) { const styles = useStyles2(getStyles); - const { - register, - control, - formState: { errors }, - watch, - } = useFormContext<RuleFormValues>(); - - const options = contactPoints.map((receiver) => { - const integrations = receiver?.grafana_managed_receiver_configs; - const description = <ContactPointReceiverSummary receivers={integrations ?? []} />; - - return { label: receiver.name, value: receiver, description }; - }); - - const selectedContactPointWithMetadata = options.find( - (option) => option.value.name === watch(`contactPoints.${alertManager}.selectedContactPoint`) - )?.value; - const selectedContactPointSelectableValue = selectedContactPointWithMetadata - ? { value: selectedContactPointWithMetadata, label: selectedContactPointWithMetadata.name } - : undefined; + const { control, watch, trigger } = useFormContext<RuleFormValues>(); + + const contactPointInForm = watch(`contactPoints.${alertManager}.selectedContactPoint`); + + const selectedContactPointWithMetadata = options.find((option) => option.value.name === contactPointInForm)?.value; + const selectedContactPointSelectableValue: SelectableValue<ContactPointWithMetadata> = + selectedContactPointWithMetadata + ? { value: selectedContactPointWithMetadata, label: selectedContactPointWithMetadata.name } + : { value: undefined, label: '' }; + + const LOADING_SPINNER_DURATION = 1000; + + const [loadingContactPoints, setLoadingContactPoints] = useState(false); + // we need to keep track if the fetching takes more than 1 second, so we can show the loading spinner until the fetching is done + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + // if we have a contact point selected, check if it still exists in the event that someone has deleted it + const validateContactPoint = useCallback(() => { + if (contactPointInForm) { + trigger(`contactPoints.${alertManager}.selectedContactPoint`, { shouldFocus: true }); + } + }, [alertManager, contactPointInForm, trigger]); + + const onClickRefresh = () => { + setLoadingContactPoints(true); + Promise.all([refetchReceivers(), sleep(LOADING_SPINNER_DURATION)]).finally(() => { + setLoadingContactPoints(false); + validateContactPoint(); + }); + }; + + // validate the contact point and check if it still exists when mounting the component + useEffect(() => { + validateContactPoint(); + }, [validateContactPoint]); return ( <Stack direction="column"> - <Field - label="Contact point" - {...register(`contactPoints.${alertManager}.selectedContactPoint`, { required: true })} - invalid={!!errors.contactPoints?.[alertManager]?.selectedContactPoint} - > - <InputControl - render={({ field: { onChange, ref, ...field }, fieldState: { error } }) => ( - <> - <div className={styles.contactPointsSelector}> - <Select - {...field} - defaultValue={selectedContactPointSelectableValue} - aria-label="Contact point" - onChange={(value: SelectableValue<ContactPointWithMetadata>, _: ActionMeta) => { - onChange(value?.value?.name); - onSelectContactPoint(value?.value); - }} - // We are passing a JSX.Element into the "description" for options, which isn't how the TS typings are defined. - // The regular Select component will render it just fine, but we can't update the typings because SelectableValue - // is shared with other components where the "description" _has_ to be a string. - // I've tried unsuccessfully to separate the typings just I'm giving up :'( - // @ts-ignore - options={options} - width={50} - /> - </div> - {error && <FieldValidationMessage>{'Contact point is required.'}</FieldValidationMessage>} - </> - )} - control={control} - name={`contactPoints.${alertManager}.selectedContactPoint`} - /> - </Field> + <Stack direction="row" alignItems="center"> + <Field label="Contact point" data-testid="contact-point-picker"> + <InputControl + render={({ field: { onChange, ref, ...field }, fieldState: { error } }) => ( + <> + <div className={styles.contactPointsSelector}> + <Select<ContactPointWithMetadata> + virtualized={options.length > MAX_CONTACT_POINTS_RENDERED} + aria-label="Contact point" + defaultValue={selectedContactPointSelectableValue} + onChange={(value: SelectableValue<ContactPointWithMetadata>, _: ActionMeta) => { + onChange(value?.value?.name); + onSelectContactPoint(value?.value); + }} + // We are passing a JSX.Element into the "description" for options, which isn't how the TS typings are defined. + // The regular Select component will render it just fine, but we can't update the typings because SelectableValue + // is shared with other components where the "description" _has_ to be a string. + // I've tried unsuccessfully to separate the typings just I'm giving up :'( + // @ts-ignore + options={options} + width={50} + /> + <div className={styles.contactPointsInfo}> + <IconButton + name="sync" + onClick={onClickRefresh} + aria-label="Refresh contact points" + tooltip="Refresh contact points list" + className={cx(styles.refreshButton, { + [styles.loading]: loadingContactPoints, + })} + /> + <LinkToContactPoints /> + </div> + </div> + + {/* Error can come from the required validation we have in here, or from the manual setError we do in the parent component. + The only way I found to check the custom error is to check if the field has a value and if it's not in the options. */} + + {error && <FieldValidationMessage>{error?.message}</FieldValidationMessage>} + </> + )} + rules={{ + required: { + value: true, + message: 'Contact point is required.', + }, + validate: { + contactPointExists: (value: string) => { + if (options.some((option) => option.value.name === value)) { + return true; + } + return `Contact point ${contactPointInForm} does not exist.`; + }, + }, + }} + control={control} + name={`contactPoints.${alertManager}.selectedContactPoint`} + /> + </Field> + </Stack> </Stack> ); } +function LinkToContactPoints() { + const hrefToContactPoints = '/alerting/notifications'; + return ( + <TextLink external href={createUrl(hrefToContactPoints)} aria-label="View or create contact points"> + View or create contact points + </TextLink> + ); +} const getStyles = (theme: GrafanaTheme2) => ({ contactPointsSelector: css({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing(1), marginTop: theme.spacing(1), }), + contactPointsInfo: css({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: theme.spacing(1), + }), + refreshButton: css({ + color: theme.colors.text.secondary, + cursor: 'pointer', + borderRadius: theme.shape.radius.circle, + overflow: 'hidden', + }), + loading: css({ + pointerEvents: 'none', + animation: 'rotation 2s infinite linear', + '@keyframes rotation': { + from: { + transform: 'rotate(720deg)', + }, + to: { + transform: 'rotate(0deg)', + }, + }, + }), + warn: css({ + color: theme.colors.warning.text, + }), }); diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/MuteTimingFields.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/MuteTimingFields.tsx index b5598ecb6e426..ddb9305190293 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/MuteTimingFields.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/MuteTimingFields.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { useFormContext } from 'react-hook-form'; +import { SelectableValue } from '@grafana/data'; import { Field, InputControl, MultiSelect, useStyles2 } from '@grafana/ui'; -import { useMuteTimingOptions } from 'app/features/alerting/unified/hooks/useMuteTimingOptions'; +import { alertmanagerApi } from 'app/features/alerting/unified/api/alertmanagerApi'; import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; +import { timeIntervalToString } from 'app/features/alerting/unified/utils/alertmanager'; import { mapMultiSelectValueToStrings } from 'app/features/alerting/unified/utils/amroutes'; import { getFormStyles } from '../../../../notification-policies/formStyles'; @@ -19,12 +21,12 @@ export function MuteTimingFields({ alertManager }: MuteTimingFieldsProps) { formState: { errors }, } = useFormContext<RuleFormValues>(); - const muteTimingOptions = useMuteTimingOptions(); + const muteTimingOptions = useSelectableMuteTimings(); return ( <Field label="Mute timings" data-testid="am-mute-timing-select" - description="Add mute timing to policy" + description="Select a mute timing to define when not to send notifications for this alert rule" invalid={!!errors.contactPoints?.[alertManager]?.muteTimeIntervals} > <InputControl @@ -35,6 +37,7 @@ export function MuteTimingFields({ alertManager }: MuteTimingFieldsProps) { className={styles.input} onChange={(value) => onChange(mapMultiSelectValueToStrings(value))} options={muteTimingOptions} + placeholder="Select mute timings..." /> )} control={control} @@ -43,3 +46,21 @@ export function MuteTimingFields({ alertManager }: MuteTimingFieldsProps) { </Field> ); } + +function useSelectableMuteTimings(): Array<SelectableValue<string>> { + const fetchGrafanaMuteTimings = alertmanagerApi.endpoints.getMuteTimingList.useQuery(undefined, { + refetchOnFocus: true, + refetchOnReconnect: true, + selectFromResult: (result) => ({ + ...result, + mutetimings: result.data + ? result.data.map((value) => ({ + value: value.name, + label: value.name, + description: value.time_intervals.map((interval) => timeIntervalToString(interval)).join(', AND '), + })) + : [], + }), + }); + return fetchGrafanaMuteTimings.mutetimings; +} diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/RouteSettings.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/RouteSettings.tsx index 972cfcff40e93..900ca46a35cf5 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/RouteSettings.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/RouteSettings.tsx @@ -1,9 +1,20 @@ -import React, { useState } from 'react'; +import { css } from '@emotion/css'; +import React, { useEffect, useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import { Field, FieldValidationMessage, InputControl, MultiSelect, Stack, Switch, Text, useStyles2 } from '@grafana/ui'; -import { useAlertmanagerConfig } from 'app/features/alerting/unified/hooks/useAlertmanagerConfig'; -import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { + Field, + FieldValidationMessage, + InlineField, + InputControl, + MultiSelect, + Stack, + Switch, + Text, + useStyles2, +} from '@grafana/ui'; +import { MultiValueRemove, MultiValueRemoveProps } from '@grafana/ui/src/components/Select/MultiValue'; import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; import { commonGroupByOptions, @@ -17,6 +28,15 @@ import { TIMING_OPTIONS_DEFAULTS } from '../../../../notification-policies/timin import { RouteTimings } from './RouteTimings'; +const REQUIRED_FIELDS_IN_GROUPBY = ['grafana_folder', 'alertname']; + +const DEFAULTS_TIMINGS = { + groupWaitValue: TIMING_OPTIONS_DEFAULTS.group_wait, + groupIntervalValue: TIMING_OPTIONS_DEFAULTS.group_interval, + repeatIntervalValue: TIMING_OPTIONS_DEFAULTS.repeat_interval, +}; +const DISABLE_GROUPING = '...'; + export interface RoutingSettingsProps { alertManager: string; } @@ -26,21 +46,31 @@ export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => { control, watch, register, + setValue, formState: { errors }, } = useFormContext<RuleFormValues>(); const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues([])); - const { groupBy, groupIntervalValue, groupWaitValue, repeatIntervalValue } = useGetDefaultsForRoutingSettings(); + const { groupIntervalValue, groupWaitValue, repeatIntervalValue } = DEFAULTS_TIMINGS; const overrideGrouping = watch(`contactPoints.${alertManager}.overrideGrouping`); const overrideTimings = watch(`contactPoints.${alertManager}.overrideTimings`); + const groupByCount = watch(`contactPoints.${alertManager}.groupBy`)?.length ?? 0; + + const styles = useStyles2(getStyles); + useEffect(() => { + if (overrideGrouping && groupByCount === 0) { + setValue(`contactPoints.${alertManager}.groupBy`, REQUIRED_FIELDS_IN_GROUPBY); + } + }, [overrideGrouping, setValue, alertManager, groupByCount]); + return ( <Stack direction="column"> <Stack direction="row" gap={1} alignItems="center" justifyContent="space-between"> - <Field label="Override grouping"> + <InlineField label="Override grouping" transparent={true} className={styles.switchElement}> <Switch id="override-grouping-toggle" {...register(`contactPoints.${alertManager}.overrideGrouping`)} /> - </Field> + </InlineField> {!overrideGrouping && ( <Text variant="body" color="secondary"> - Grouping: <strong>{groupBy.join(', ')}</strong> + Grouping: <strong>{REQUIRED_FIELDS_IN_GROUPBY.join(', ')}</strong> </Text> )} </Stack> @@ -48,10 +78,27 @@ export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => { <Field label="Group by" description="Group alerts when you receive a notification based on labels. If empty it will be inherited from the default notification policy." - {...register(`contactPoints.${alertManager}.groupBy`, { required: true })} + {...register(`contactPoints.${alertManager}.groupBy`)} invalid={!!errors.contactPoints?.[alertManager]?.groupBy} + className={styles.optionalContent} > <InputControl + rules={{ + validate: (value: string[]) => { + if (!value || value.length === 0) { + return 'At least one group by option is required.'; + } + if (value.length === 1 && value[0] === DISABLE_GROUPING) { + return true; + } + // we need to make sure that the required fields are included + const requiredFieldsIncluded = REQUIRED_FIELDS_IN_GROUPBY.every((field) => value.includes(field)); + if (!requiredFieldsIncluded) { + return `Group by must include ${REQUIRED_FIELDS_IN_GROUPBY.join(', ')}`; + } + return true; + }, + }} render={({ field: { onChange, ref, ...field }, fieldState: { error } }) => ( <> <MultiSelect @@ -65,10 +112,32 @@ export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => { // @ts-ignore-check: react-hook-form made me do this setValue(`contactPoints.${alertManager}.groupBy`, [...field.value, opt]); }} - onChange={(value) => onChange(mapMultiSelectValueToStrings(value))} + onChange={(value) => { + return onChange(mapMultiSelectValueToStrings(value)); + }} options={[...commonGroupByOptions, ...groupByOptions]} + components={{ + MultiValueRemove( + props: React.PropsWithChildren< + MultiValueRemoveProps & + Array<SelectableValue<string>> & { + data: { + label: string; + value: string; + isFixed: boolean; + }; + } + > + ) { + const { data } = props; + if (data.isFixed) { + return null; + } + return MultiValueRemove(props); + }, + }} /> - {error && <FieldValidationMessage>{'At least one group by option is required'}</FieldValidationMessage>} + {error && <FieldValidationMessage>{error.message}</FieldValidationMessage>} </> )} name={`contactPoints.${alertManager}.groupBy`} @@ -77,9 +146,9 @@ export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => { </Field> )} <Stack direction="row" gap={1} alignItems="center" justifyContent="space-between"> - <Field label="Override timings"> + <InlineField label="Override timings" transparent={true} className={styles.switchElement}> <Switch id="override-timings-toggle" {...register(`contactPoints.${alertManager}.overrideTimings`)} /> - </Field> + </InlineField> {!overrideTimings && ( <Text variant="body" color="secondary"> Group wait: <strong>{groupWaitValue}, </strong> @@ -88,21 +157,23 @@ export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => { </Text> )} </Stack> - {overrideTimings && <RouteTimings alertManager={alertManager} />} + {overrideTimings && ( + <div className={styles.optionalContent}> + <RouteTimings alertManager={alertManager} /> + </div> + )} </Stack> ); }; -function useGetDefaultsForRoutingSettings() { - const { selectedAlertmanager } = useAlertmanager(); - const { currentData } = useAlertmanagerConfig(selectedAlertmanager); - const config = currentData?.alertmanager_config; - return React.useMemo(() => { - return { - groupWaitValue: TIMING_OPTIONS_DEFAULTS.group_wait, - groupIntervalValue: TIMING_OPTIONS_DEFAULTS.group_interval, - repeatIntervalValue: TIMING_OPTIONS_DEFAULTS.repeat_interval, - groupBy: config?.route?.group_by ?? [], - }; - }, [config]); -} +const getStyles = (theme: GrafanaTheme2) => ({ + switchElement: css({ + flexFlow: 'row-reverse', + gap: theme.spacing(1), + alignItems: 'center', + }), + optionalContent: css({ + marginLeft: '49px', + marginBottom: theme.spacing(1), + }), +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx index ad459deedb00b..cb7c4492d1cfd 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx @@ -4,18 +4,24 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; +import { MatcherFormatter } from '../../../utils/matchers'; import { Matchers } from '../../notification-policies/Matchers'; import { hasEmptyMatchers, isDefaultPolicy, RouteWithPath } from './route'; -export function NotificationPolicyMatchers({ route }: { route: RouteWithPath }) { +interface Props { + route: RouteWithPath; + matcherFormatter: MatcherFormatter; +} + +export function NotificationPolicyMatchers({ route, matcherFormatter }: Props) { const styles = useStyles2(getStyles); if (isDefaultPolicy(route)) { return <div className={styles.defaultPolicy}>Default policy</div>; } else if (hasEmptyMatchers(route)) { return <div className={styles.textMuted}>No matchers</div>; } else { - return <Matchers matchers={route.object_matchers ?? []} />; + return <Matchers matchers={route.object_matchers ?? []} formatter={matcherFormatter} />; } } diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx index 1db96a0ff64c6..89752ea69f802 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx @@ -71,7 +71,7 @@ export const NotificationPreview = ({ <Stack direction="column"> <div className={styles.routePreviewHeaderRow}> <div className={styles.previewHeader}> - <Text element="h4">Alert instance routing preview</Text> + <Text element="h5">Alert instance routing preview</Text> {isLoading && previewUninitialized && ( <Text color="secondary" variant="bodySmall"> Loading... @@ -124,6 +124,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ flex-direction: row; justify-content: space-between; align-items: flex-start; + margin-top: ${theme.spacing(1)}; `, collapseLabel: css` flex: 1; diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx index 1a7f9a1394494..a95e1bbb8a658 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx @@ -9,6 +9,7 @@ import { Button, getTagColorIndexFromName, TagList, useStyles2 } from '@grafana/ import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types'; import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack'; +import { getAmMatcherFormatter } from '../../../utils/alertmanager'; import { AlertInstanceMatch } from '../../../utils/notification-policies'; import { CollapseToggle } from '../../CollapseToggle'; import { MetaText } from '../../MetaText'; @@ -58,7 +59,10 @@ function NotificationRouteHeader({ <div onClick={() => onExpandRouteClick(!expandRoute)} className={styles.expandable}> <Stack gap={1} direction="row" alignItems="center"> Notification policy - <NotificationPolicyMatchers route={route} /> + <NotificationPolicyMatchers + route={route} + matcherFormatter={getAmMatcherFormatter(alertManagerSourceName)} + /> </Stack> </div> <Spacer /> diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx index f66e525f8a1c8..8de20bc227dfa 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx @@ -3,20 +3,26 @@ import { compact } from 'lodash'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Button, Icon, Modal, useStyles2 } from '@grafana/ui'; +import { Button, Icon, Modal, Stack, useStyles2 } from '@grafana/ui'; import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types'; -import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack'; import { AlertmanagerAction } from '../../../hooks/useAbilities'; import { AlertmanagerProvider } from '../../../state/AlertmanagerContext'; -import { GRAFANA_DATASOURCE_NAME } from '../../../utils/datasource'; +import { getAmMatcherFormatter } from '../../../utils/alertmanager'; +import { MatcherFormatter } from '../../../utils/matchers'; import { makeAMLink } from '../../../utils/misc'; import { Authorize } from '../../Authorize'; import { Matchers } from '../../notification-policies/Matchers'; import { hasEmptyMatchers, isDefaultPolicy, RouteWithPath } from './route'; -function PolicyPath({ route, routesByIdMap }: { routesByIdMap: Map<string, RouteWithPath>; route: RouteWithPath }) { +interface Props { + routesByIdMap: Map<string, RouteWithPath>; + route: RouteWithPath; + matcherFormatter: MatcherFormatter; +} + +function PolicyPath({ route, routesByIdMap, matcherFormatter }: Props) { const styles = useStyles2(getStyles); const routePathIds = route.path?.slice(1) ?? []; const routePathObjects = [...compact(routePathIds.map((id) => routesByIdMap.get(id))), route]; @@ -31,7 +37,7 @@ function PolicyPath({ route, routesByIdMap }: { routesByIdMap: Map<string, Route {hasEmptyMatchers(pathRoute) ? ( <div className={styles.textMuted}>No matchers</div> ) : ( - <Matchers matchers={pathRoute.object_matchers ?? []} /> + <Matchers matchers={pathRoute.object_matchers ?? []} formatter={matcherFormatter} /> )} </div> </div> @@ -60,7 +66,7 @@ export function NotificationRouteDetailsModal({ const isDefault = isDefaultPolicy(route); return ( - <AlertmanagerProvider accessType="notification" alertmanagerSourceName={GRAFANA_DATASOURCE_NAME}> + <AlertmanagerProvider accessType="notification" alertmanagerSourceName={alertManagerSourceName}> <Modal className={styles.detailsModal} isOpen={true} @@ -77,7 +83,11 @@ export function NotificationRouteDetailsModal({ <div className={styles.separator(1)} /> {!isDefault && ( <> - <PolicyPath route={route} routesByIdMap={routesByIdMap} /> + <PolicyPath + route={route} + routesByIdMap={routesByIdMap} + matcherFormatter={getAmMatcherFormatter(alertManagerSourceName)} + /> </> )} <div className={styles.separator(4)} /> diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts index 0762ae77aa2e6..a8df723da120d 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts @@ -6,6 +6,7 @@ import { Labels } from '../../../../../../types/unified-alerting-dto'; import { useAlertmanagerConfig } from '../../../hooks/useAlertmanagerConfig'; import { useRouteGroupsMatcher } from '../../../useRouteGroupsMatcher'; import { addUniqueIdentifierToRoute } from '../../../utils/amroutes'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; import { AlertInstanceMatch, computeInheritedTree, normalizeRoute } from '../../../utils/notification-policies'; import { getRoutesByIdMap, RouteWithPath } from './route'; @@ -55,7 +56,9 @@ export const useAlertmanagerNotificationRoutingPreview = ( if (!rootRoute) { return; } - return await matchInstancesToRoute(rootRoute, potentialInstances); + return await matchInstancesToRoute(rootRoute, potentialInstances, { + unquoteMatchers: alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME, + }); }, [rootRoute, potentialInstances]); return { diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/CloudDataSourceSelector.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/CloudDataSourceSelector.tsx index 938550ae0d8d1..88e4ee494ce9a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/CloudDataSourceSelector.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/CloudDataSourceSelector.tsx @@ -32,7 +32,6 @@ export const CloudDataSourceSelector = ({ disabled, onChangeCloudDatasource }: C label={disabled ? 'Data source' : 'Select data source'} error={errors.dataSourceName?.message} invalid={!!errors.dataSourceName?.message} - data-testid="datasource-picker" > <InputControl render={({ field: { onChange, ref, ...field } }) => ( diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx index 9360b934ad790..eebc027a1b094 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx @@ -40,6 +40,7 @@ import { errorFromCurrentCondition, errorFromPreviewData, findRenamedDataQueryRe import { CloudDataSourceSelector } from './CloudDataSourceSelector'; import { SmartAlertTypeDetector } from './SmartAlertTypeDetector'; +import { DESCRIPTIONS } from './descriptions'; import { addExpressions, addNewDataQuery, @@ -186,8 +187,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P // Invocation cycle => onChange -> dispatch(setDataQueries) -> onRunQueries -> setDataQueries Reducer // As a workaround we update form values as soon as possible to avoid stale state // This way we can access up to date queries in runQueriesPreview without waiting for re-render - setValue('queries', updatedQueries, { shouldValidate: false }); - + const previousQueries = getValues('queries'); + const expressionQueries = previousQueries.filter((query) => isExpressionQuery(query.model)); + setValue('queries', [...updatedQueries, ...expressionQueries], { shouldValidate: false }); updateExpressionAndDatasource(updatedQueries); dispatch(setDataQueries(updatedQueries)); @@ -199,7 +201,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P dispatch(rewireExpressions({ oldRefId, newRefId })); } }, - [queries, setValue, updateExpressionAndDatasource] + [queries, updateExpressionAndDatasource, getValues, setValue] ); const onChangeRecordingRulesQueries = useCallback( @@ -242,6 +244,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P queryType: '', relativeTimeRange: getDefaultRelativeTimeRange(), expr, + instant: true, model: { refId: 'A', hide: false, @@ -369,23 +372,22 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P condition, ]); + const { sectionTitle, helpLabel, helpContent, helpLink } = DESCRIPTIONS[type ?? RuleFormType.grafana]; + return ( <RuleEditorSection stepNo={2} - title={type !== RuleFormType.cloudRecording ? 'Define query and alert condition' : 'Define query'} + title={sectionTitle} description={ - <Stack direction="row" gap={0.5} alignItems="baseline"> + <Stack direction="row" gap={0.5} alignItems="center"> <Text variant="bodySmall" color="secondary"> - Define queries and/or expressions and then choose one of them as the alert rule condition. This is the - threshold that an alert rule must meet or exceed in order to fire. + {helpLabel} </Text> <NeedHelpInfo - contentText={`An alert rule consists of one or more queries and expressions that select the data you want to measure. - Define queries and/or expressions and then choose one of them as the alert rule condition. This is the threshold that an alert rule must meet or exceed in order to fire. - For more information on queries and expressions, see Query and transform data.`} - externalLink={`https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/`} - linkText={`Read about query and condition`} - title="Define query and alert condition" + contentText={helpContent} + externalLink={helpLink} + linkText={'Read more on our documentation website'} + title={helpLabel} /> </Stack> } diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/descriptions.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/descriptions.tsx new file mode 100644 index 0000000000000..fced3fa06a5b9 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/descriptions.tsx @@ -0,0 +1,32 @@ +import { RuleFormType } from '../../../types/rule-form'; + +type FormDescriptions = { + sectionTitle: string; + helpLabel: string; + helpContent: string; + helpLink: string; +}; + +export const DESCRIPTIONS: Record<RuleFormType, FormDescriptions> = { + [RuleFormType.cloudRecording]: { + sectionTitle: 'Define recording rule', + helpLabel: 'Define your recording rule', + helpContent: + 'Pre-compute frequently needed or computationally expensive expressions and save their result as a new set of time series.', + helpLink: '', + }, + [RuleFormType.grafana]: { + sectionTitle: 'Define query and alert condition', + helpLabel: 'Define query and alert condition', + helpContent: + 'An alert rule consists of one or more queries and expressions that select the data you want to measure. Define queries and/or expressions and then choose one of them as the alert rule condition. This is the threshold that an alert rule must meet or exceed in order to fire. For more information on queries and expressions, see Query and transform data.', + helpLink: 'https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/', + }, + [RuleFormType.cloudAlerting]: { + sectionTitle: 'Define query and alert condition', + helpLabel: 'Define query and alert condition', + helpContent: + 'An alert rule consists of one or more queries and expressions that select the data you want to measure. Define queries and/or expressions and then choose one of them as the alert rule condition. This is the threshold that an alert rule must meet or exceed in order to fire. For more information on queries and expressions, see Query and transform data.', + helpLink: 'https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/', + }, +}; diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts index 08aecf832f90e..7072e664d1463 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts @@ -2,7 +2,7 @@ import { createAction, createReducer } from '@reduxjs/toolkit'; import { DataQuery, getDefaultRelativeTimeRange, rangeUtil, RelativeTimeRange } from '@grafana/data'; import { getNextRefIdChar } from 'app/core/utils/query'; -import { findDataSourceFromExpressionRecursive } from 'app/features/alerting/utils/dataSourceFromExpression'; +import { findDataSourceFromExpressionRecursive } from 'app/features/alerting/unified/utils/dataSourceFromExpression'; import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource'; import { isExpressionQuery } from 'app/features/expressions/guards'; import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; diff --git a/public/app/features/alerting/unified/components/rule-editor/util.test.ts b/public/app/features/alerting/unified/components/rule-editor/util.test.ts index fda4601ead380..ec51a60f31edb 100644 --- a/public/app/features/alerting/unified/components/rule-editor/util.test.ts +++ b/public/app/features/alerting/unified/components/rule-editor/util.test.ts @@ -245,12 +245,12 @@ describe('checkForPathSeparator', () => { describe('getThresholdsForQueries', () => { it('should work for threshold condition', () => { - const queries = createThresholdExample('gt'); - expect(getThresholdsForQueries(queries)).toMatchSnapshot(); + const [queries, condition] = createThresholdExample('gt'); + expect(getThresholdsForQueries(queries, condition)).toMatchSnapshot(); }); it('should work for classic_condition', () => { - const [dataQuery] = createThresholdExample('gt'); + const [[dataQuery]] = createThresholdExample('gt'); const classicCondition = { refId: 'B', @@ -282,7 +282,7 @@ describe('getThresholdsForQueries', () => { }, }; - const thresholdsClassic = getThresholdsForQueries([dataQuery, classicCondition]); + const thresholdsClassic = getThresholdsForQueries([dataQuery, classicCondition], classicCondition.refId); expect(thresholdsClassic).toMatchSnapshot(); }); @@ -331,30 +331,32 @@ describe('getThresholdsForQueries', () => { }; expect(() => { - const thresholds = getThresholdsForQueries([dataQuery, classicCondition]); + const thresholds = getThresholdsForQueries([dataQuery, classicCondition], classicCondition.refId); expect(thresholds).toStrictEqual({}); }).not.toThrowError(); }); it('should work for within_range', () => { - const queries = createThresholdExample('within_range'); - const thresholds = getThresholdsForQueries(queries); + const [queries, condition] = createThresholdExample('within_range'); + const thresholds = getThresholdsForQueries(queries, condition); expect(thresholds).toMatchSnapshot(); }); it('should work for lt and gt', () => { - expect(getThresholdsForQueries(createThresholdExample('gt'))).toMatchSnapshot(); - expect(getThresholdsForQueries(createThresholdExample('lt'))).toMatchSnapshot(); + const [gtQueries, qtCondition] = createThresholdExample('gt'); + const [ltQueries, ltCondition] = createThresholdExample('lt'); + expect(getThresholdsForQueries(gtQueries, qtCondition)).toMatchSnapshot(); + expect(getThresholdsForQueries(ltQueries, ltCondition)).toMatchSnapshot(); }); it('should work for outside_range', () => { - const queries = createThresholdExample('outside_range'); - const thresholds = getThresholdsForQueries(queries); + const [queries, condition] = createThresholdExample('outside_range'); + const thresholds = getThresholdsForQueries(queries, condition); expect(thresholds).toMatchSnapshot(); }); }); -function createThresholdExample(thresholdType: string): AlertQuery[] { +function createThresholdExample(thresholdType: string): [AlertQuery[], string] { const dataQuery: AlertQuery = { refId: 'A', datasourceUid: 'abc123', @@ -403,7 +405,7 @@ function createThresholdExample(thresholdType: string): AlertQuery[] { }, }; - return [dataQuery, reduceExpression, thresholdExpression]; + return [[dataQuery, reduceExpression, thresholdExpression], thresholdExpression.refId]; } describe('findRenamedReferences', () => { diff --git a/public/app/features/alerting/unified/components/rule-editor/util.ts b/public/app/features/alerting/unified/components/rule-editor/util.ts index 3bc456259a613..258ffc749646f 100644 --- a/public/app/features/alerting/unified/components/rule-editor/util.ts +++ b/public/app/features/alerting/unified/components/rule-editor/util.ts @@ -9,7 +9,7 @@ import { ThresholdsConfig, ThresholdsMode, } from '@grafana/data'; -import { GraphTresholdsStyleMode } from '@grafana/schema'; +import { GraphThresholdsStyleMode } from '@grafana/schema'; import { config } from 'app/core/config'; import { EvalFunction } from 'app/features/alerting/state/alertDef'; import { isExpressionQuery } from 'app/features/expressions/guards'; @@ -136,7 +136,7 @@ export function warningFromSeries(series: DataFrame[]): Error | undefined { export type ThresholdDefinition = { config: ThresholdsConfig; - mode: GraphTresholdsStyleMode; + mode: GraphThresholdsStyleMode; }; export type ThresholdDefinitions = Record<string, ThresholdDefinition>; @@ -144,10 +144,14 @@ export type ThresholdDefinitions = Record<string, ThresholdDefinition>; /** * This function will retrieve threshold definitions for the given array of data and expression queries. */ -export function getThresholdsForQueries(queries: AlertQuery[]) { +export function getThresholdsForQueries(queries: AlertQuery[], condition: string | null) { const thresholds: ThresholdDefinitions = {}; const SUPPORTED_EXPRESSION_TYPES = [ExpressionQueryType.threshold, ExpressionQueryType.classic]; + if (!condition) { + return thresholds; + } + for (const query of queries) { if (!isExpressionQuery(query.model)) { continue; @@ -162,6 +166,10 @@ export function getThresholdsForQueries(queries: AlertQuery[]) { continue; } + if (query.model.refId !== condition) { + continue; + } + // if any of the conditions are a "range" we switch to an "area" threshold view and ignore single threshold values // the time series panel does not support both. const hasRangeThreshold = query.model.conditions.some(isRangeCondition); @@ -202,7 +210,7 @@ export function getThresholdsForQueries(queries: AlertQuery[]) { mode: ThresholdsMode.Absolute, steps: [], }, - mode: GraphTresholdsStyleMode.Line, + mode: GraphThresholdsStyleMode.Line, }; } @@ -210,7 +218,7 @@ export function getThresholdsForQueries(queries: AlertQuery[]) { appendSingleThreshold(originRefID, threshold[0]); } else if (originRefID && hasValidOrigin && isRangeThreshold) { appendRangeThreshold(originRefID, threshold, condition.evaluator.type); - thresholds[originRefID].mode = GraphTresholdsStyleMode.LineAndArea; + thresholds[originRefID].mode = GraphThresholdsStyleMode.LineAndArea; } }); } catch (err) { diff --git a/public/app/features/alerting/unified/components/rule-viewer/Actions.tsx b/public/app/features/alerting/unified/components/rule-viewer/Actions.tsx new file mode 100644 index 0000000000000..8dd2226018984 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/Actions.tsx @@ -0,0 +1,113 @@ +import React from 'react'; + +import { AppEvents } from '@grafana/data'; +import { Dropdown, LinkButton, Menu } from '@grafana/ui'; +import appEvents from 'app/core/app_events'; +import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; + +import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities'; +import { createShareLink, isLocalDevEnv, isOpenSourceEdition, makeRuleBasedSilenceLink } from '../../utils/misc'; +import * as ruleId from '../../utils/rule-id'; +import { createUrl } from '../../utils/url'; +import MoreButton from '../MoreButton'; +import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton'; + +import { useAlertRule } from './RuleContext'; + +interface Props { + handleDelete: (rule: CombinedRule) => void; + handleDuplicateRule: (identifier: RuleIdentifier) => void; +} + +export const useAlertRulePageActions = ({ handleDelete, handleDuplicateRule }: Props) => { + const { rule, identifier } = useAlertRule(); + + // check all abilities and permissions + const [editSupported, editAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update); + const canEdit = editSupported && editAllowed; + + const [deleteSupported, deleteAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete); + const canDelete = deleteSupported && deleteAllowed; + + const [duplicateSupported, duplicateAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate); + const canDuplicate = duplicateSupported && duplicateAllowed; + + const [silenceSupported, silenceAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Silence); + const canSilence = silenceSupported && silenceAllowed; + + const [exportSupported, exportAllowed] = useAlertRuleAbility(rule, AlertRuleAction.ModifyExport); + const canExport = exportSupported && exportAllowed; + + /** + * Since Incident isn't available as an open-source product we shouldn't show it for Open-Source licenced editions of Grafana. + * We should show it in development mode + */ + const shouldShowDeclareIncidentButton = !isOpenSourceEdition() || isLocalDevEnv(); + const shareUrl = createShareLink(rule.namespace.rulesSource, rule); + + return [ + canEdit && <EditButton key="edit-action" identifier={identifier} />, + <Dropdown + key="more-actions" + overlay={ + <Menu> + {canSilence && ( + <Menu.Item + label="Silence" + icon="bell-slash" + url={makeRuleBasedSilenceLink(identifier.ruleSourceName, rule)} + /> + )} + {shouldShowDeclareIncidentButton && <DeclareIncidentMenuItem title={rule.name} url={''} />} + {canDuplicate && <Menu.Item label="Duplicate" icon="copy" onClick={() => handleDuplicateRule(identifier)} />} + <Menu.Divider /> + <Menu.Item label="Copy link" icon="share-alt" onClick={() => copyToClipboard(shareUrl)} /> + {canExport && ( + <Menu.Item + label="Export" + icon="download-alt" + childItems={[<ExportMenuItem key="export-with-modifications" identifier={identifier} />]} + /> + )} + {canDelete && ( + <> + <Menu.Divider /> + <Menu.Item label="Delete" icon="trash-alt" destructive onClick={() => handleDelete(rule)} /> + </> + )} + </Menu> + } + > + <MoreButton size="md" /> + </Dropdown>, + ]; +}; + +function copyToClipboard(text: string) { + navigator.clipboard?.writeText(text).then(() => { + appEvents.emit(AppEvents.alertSuccess, ['URL copied to clipboard']); + }); +} + +type PropsWithIdentifier = { identifier: RuleIdentifier }; + +const ExportMenuItem = ({ identifier }: PropsWithIdentifier) => { + const returnTo = location.pathname + location.search; + const url = createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export`, { + returnTo, + }); + + return <Menu.Item key="with-modifications" label="With modifications" icon="file-edit-alt" url={url} />; +}; + +const EditButton = ({ identifier }: PropsWithIdentifier) => { + const returnTo = location.pathname + location.search; + const ruleIdentifier = ruleId.stringifyIdentifier(identifier); + const editURL = createUrl(`/alerting/${encodeURIComponent(ruleIdentifier)}/edit`, { returnTo }); + + return ( + <LinkButton variant="secondary" icon="pen" href={editURL}> + Edit + </LinkButton> + ); +}; diff --git a/public/app/features/alerting/unified/components/rule-viewer/v2/DeleteModal.tsx b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx similarity index 90% rename from public/app/features/alerting/unified/components/rule-viewer/v2/DeleteModal.tsx rename to public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx index 1273929168117..ef5c1cdac4ad3 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/v2/DeleteModal.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx @@ -4,9 +4,9 @@ import { ConfirmModal } from '@grafana/ui'; import { dispatch } from 'app/store/store'; import { CombinedRule } from 'app/types/unified-alerting'; -import { deleteRuleAction } from '../../../state/actions'; -import { getRulesSourceName } from '../../../utils/datasource'; -import { fromRulerRule } from '../../../utils/rule-id'; +import { deleteRuleAction } from '../../state/actions'; +import { getRulesSourceName } from '../../utils/datasource'; +import { fromRulerRule } from '../../utils/rule-id'; type DeleteModalHook = [JSX.Element, (rule: CombinedRule) => void, () => void]; diff --git a/public/app/features/alerting/unified/components/rule-viewer/FederatedRuleWarning.tsx b/public/app/features/alerting/unified/components/rule-viewer/FederatedRuleWarning.tsx new file mode 100644 index 0000000000000..76bf9317abf93 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/FederatedRuleWarning.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { Alert, Button, Stack } from '@grafana/ui'; + +export function FederatedRuleWarning() { + return ( + <Alert severity="info" title="This rule is part of a federated rule group." bottomSpacing={0} topSpacing={2}> + <Stack direction="column"> + Federated rule groups are currently an experimental feature. + <Button fill="text" icon="book"> + <a href="https://grafana.com/docs/metrics-enterprise/latest/tenant-management/tenant-federation/#cross-tenant-alerting-and-recording-rule-federation"> + Read documentation + </a> + </Button> + </Stack> + </Alert> + ); +} diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleContext.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleContext.tsx new file mode 100644 index 0000000000000..7a191a0f72179 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleContext.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; + +import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; + +interface Context { + rule: CombinedRule; + identifier: RuleIdentifier; +} + +const AlertRuleContext = React.createContext<Context | undefined>(undefined); + +type Props = Context & React.PropsWithChildren & {}; + +const AlertRuleProvider = ({ children, rule, identifier }: Props) => { + const value: Context = { + rule, + identifier, + }; + + return <AlertRuleContext.Provider value={value}>{children}</AlertRuleContext.Provider>; +}; + +const useAlertRule = () => { + const context = React.useContext(AlertRuleContext); + + if (context === undefined) { + throw new Error('useAlertRule must be used within a AlertRuleContext'); + } + + return context; +}; + +export { AlertRuleProvider, useAlertRule }; diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx new file mode 100644 index 0000000000000..1a82e0561947f --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx @@ -0,0 +1,174 @@ +import { render, waitFor, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; +import { byText, byRole } from 'testing-library-selector'; + +import { setBackendSrv } from '@grafana/runtime'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { AccessControlAction } from 'app/types'; +import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; + +import { getCloudRule, getGrafanaRule, grantUserPermissions } from '../../mocks'; +import { Annotation } from '../../utils/constants'; +import * as ruleId from '../../utils/rule-id'; + +import { AlertRuleProvider } from './RuleContext'; +import RuleViewer from './RuleViewer'; +import { createMockGrafanaServer } from './__mocks__/server'; + +// metadata and interactive elements +const ELEMENTS = { + loading: byText(/Loading rule/i), + metadata: { + summary: (text: string) => byText(text), + runbook: (url: string) => byRole('link', { name: url }), + dashboardAndPanel: byRole('link', { name: 'View panel' }), + evaluationInterval: (interval: string) => byText(`Every ${interval}`), + label: ([key, value]: [string, string]) => byRole('listitem', { name: `${key}: ${value}` }), + }, + actions: { + edit: byRole('link', { name: 'Edit' }), + more: { + button: byRole('button', { name: /More/i }), + actions: { + silence: byRole('link', { name: /Silence/i }), + declareIncident: byRole('menuitem', { name: /Declare incident/i }), + duplicate: byRole('menuitem', { name: /Duplicate/i }), + copyLink: byRole('menuitem', { name: /Copy link/i }), + export: byRole('menuitem', { name: /Export/i }), + delete: byRole('menuitem', { name: /Delete/i }), + }, + }, + }, +}; + +describe('RuleViewer', () => { + describe('Grafana managed alert rule', () => { + const server = createMockGrafanaServer(); + + const mockRule = getGrafanaRule( + { + name: 'Test alert', + annotations: { + [Annotation.dashboardUID]: 'dashboard-1', + [Annotation.panelID]: 'panel-1', + [Annotation.summary]: 'This is the summary for the rule', + [Annotation.runbookURL]: 'https://runbook.site/', + }, + labels: { + team: 'operations', + severity: 'low', + }, + group: { + name: 'my-group', + interval: '15m', + rules: [], + totals: { alerting: 1 }, + }, + }, + { uid: 'test1' } + ); + const mockRuleIdentifier = ruleId.fromCombinedRule('grafana', mockRule); + + beforeAll(() => { + grantUserPermissions([ + AccessControlAction.AlertingRuleCreate, + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleUpdate, + AccessControlAction.AlertingRuleDelete, + AccessControlAction.AlertingInstanceCreate, + ]); + setBackendSrv(backendSrv); + }); + + beforeEach(() => { + server.listen(); + }); + + afterAll(() => { + server.close(); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + it('should render a Grafana managed alert rule', async () => { + await renderRuleViewer(mockRule, mockRuleIdentifier); + + // assert on basic info to be visible + expect(screen.getByText('Test alert')).toBeInTheDocument(); + expect(screen.getByText('Firing')).toBeInTheDocument(); + + // alert rule metadata + const ruleSummary = mockRule.annotations[Annotation.summary]; + const runBookURL = mockRule.annotations[Annotation.runbookURL]; + const groupInterval = mockRule.group.interval; + const labels = mockRule.labels; + + expect(ELEMENTS.metadata.summary(ruleSummary).get()).toBeInTheDocument(); + expect(ELEMENTS.metadata.dashboardAndPanel.get()).toBeInTheDocument(); + expect(ELEMENTS.metadata.runbook(runBookURL).get()).toBeInTheDocument(); + expect(ELEMENTS.metadata.evaluationInterval(groupInterval!).get()).toBeInTheDocument(); + + for (const label in labels) { + expect(ELEMENTS.metadata.label([label, labels[label]]).get()).toBeInTheDocument(); + } + + // actions + await waitFor(() => { + expect(ELEMENTS.actions.edit.get()).toBeInTheDocument(); + expect(ELEMENTS.actions.more.button.get()).toBeInTheDocument(); + }); + + // check the "more actions" button + await userEvent.click(ELEMENTS.actions.more.button.get()); + const menuItems = Object.values(ELEMENTS.actions.more.actions); + for (const menuItem of menuItems) { + expect(menuItem.get()).toBeInTheDocument(); + } + }); + }); + + describe.skip('Data source managed alert rule', () => { + const mockRule = getCloudRule({ name: 'cloud test alert' }); + const mockRuleIdentifier = ruleId.fromCombinedRule('mimir-1', mockRule); + + beforeAll(() => { + grantUserPermissions([ + AccessControlAction.AlertingRuleExternalRead, + AccessControlAction.AlertingRuleExternalWrite, + ]); + }); + + it('should render a data source managed alert rule', () => { + renderRuleViewer(mockRule, mockRuleIdentifier); + + // assert on basic info to be vissible + expect(screen.getByText('Test alert')).toBeInTheDocument(); + expect(screen.getByText('Firing')).toBeInTheDocument(); + + expect(screen.getByText(mockRule.annotations[Annotation.summary])).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'View panel' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: mockRule.annotations[Annotation.runbookURL] })).toBeInTheDocument(); + expect(screen.getByText(`Every ${mockRule.group.interval}`)).toBeInTheDocument(); + }); + }); +}); + +const renderRuleViewer = async (rule: CombinedRule, identifier: RuleIdentifier) => { + render( + <AlertRuleProvider identifier={identifier} rule={rule}> + <RuleViewer /> + </AlertRuleProvider>, + { wrapper: TestProvider } + ); + + await waitFor(() => expect(ELEMENTS.loading.query()).not.toBeInTheDocument()); +}; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + useReturnToPrevious: jest.fn(), +})); diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx new file mode 100644 index 0000000000000..e82a9d76eba94 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx @@ -0,0 +1,335 @@ +import { css } from '@emotion/css'; +import { isEmpty, truncate } from 'lodash'; +import React, { useState } from 'react'; + +import { NavModelItem, UrlQueryValue } from '@grafana/data'; +import { Alert, LinkButton, Stack, TabContent, Text, TextLink, useStyles2 } from '@grafana/ui'; +import { PageInfoItem } from 'app/core/components/Page/types'; +import { useQueryParams } from 'app/core/hooks/useQueryParams'; +import { CombinedRule, RuleHealth, RuleIdentifier } from 'app/types/unified-alerting'; +import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; + +import { defaultPageNav } from '../../RuleViewer'; +import { Annotation } from '../../utils/constants'; +import { makeDashboardLink, makePanelLink } from '../../utils/misc'; +import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule, isRecordingRule } from '../../utils/rules'; +import { createUrl } from '../../utils/url'; +import { AlertLabels } from '../AlertLabels'; +import { AlertingPageWrapper } from '../AlertingPageWrapper'; +import { ProvisionedResource, ProvisioningAlert } from '../Provisioning'; +import { WithReturnButton } from '../WithReturnButton'; +import { decodeGrafanaNamespace } from '../expressions/util'; +import { RedirectToCloneRule } from '../rules/CloneRule'; + +import { useAlertRulePageActions } from './Actions'; +import { useDeleteModal } from './DeleteModal'; +import { FederatedRuleWarning } from './FederatedRuleWarning'; +import { useAlertRule } from './RuleContext'; +import { RecordingBadge, StateBadge } from './StateBadges'; +import { Details } from './tabs/Details'; +import { History } from './tabs/History'; +import { InstancesList } from './tabs/Instances'; +import { QueryResults } from './tabs/Query'; +import { Routing } from './tabs/Routing'; + +enum ActiveTab { + Query = 'query', + Instances = 'instances', + History = 'history', + Routing = 'routing', + Details = 'details', +} + +const RuleViewer = () => { + const { rule } = useAlertRule(); + const { pageNav, activeTab } = usePageNav(rule); + + // this will be used to track if we are in the process of cloning a rule + // we want to be able to show a modal if the rule has been provisioned explain the limitations + // of duplicating provisioned alert rules + const [duplicateRuleIdentifier, setDuplicateRuleIdentifier] = useState<RuleIdentifier>(); + + const [deleteModal, showDeleteModal] = useDeleteModal(); + const actions = useAlertRulePageActions({ + handleDuplicateRule: setDuplicateRuleIdentifier, + handleDelete: showDeleteModal, + }); + + const { annotations, promRule } = rule; + const hasError = isErrorHealth(rule.promRule?.health); + + const isAlertType = isAlertingRule(promRule); + + const isFederatedRule = isFederatedRuleGroup(rule.group); + const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); + + const summary = annotations[Annotation.summary]; + + return ( + <AlertingPageWrapper + pageNav={pageNav} + navId="alert-list" + isLoading={false} + renderTitle={(title) => ( + <Title + name={title} + state={isAlertType ? promRule.state : undefined} + health={rule.promRule?.health} + ruleType={rule.promRule?.type} + /> + )} + actions={actions} + info={createMetadata(rule)} + subTitle={ + <Stack direction="column"> + {summary} + {/* alerts and notifications and stuff */} + {isFederatedRule && <FederatedRuleWarning />} + {/* indicator for rules in a provisioned group */} + {isProvisioned && ( + <ProvisioningAlert resource={ProvisionedResource.AlertRule} bottomSpacing={0} topSpacing={2} /> + )} + {/* error state */} + {hasError && ( + <Alert title="Something went wrong when evaluating this alert rule" bottomSpacing={0} topSpacing={2}> + <pre style={{ marginBottom: 0 }}> + <code>{rule.promRule?.lastError ?? 'No error message'}</code> + </pre> + </Alert> + )} + </Stack> + } + > + <Stack direction="column" gap={2}> + {/* tabs and tab content */} + <TabContent> + {activeTab === ActiveTab.Query && <QueryResults rule={rule} />} + {activeTab === ActiveTab.Instances && <InstancesList rule={rule} />} + {activeTab === ActiveTab.History && isGrafanaRulerRule(rule.rulerRule) && <History rule={rule.rulerRule} />} + {activeTab === ActiveTab.Routing && <Routing />} + {activeTab === ActiveTab.Details && <Details rule={rule} />} + </TabContent> + </Stack> + + {deleteModal} + {duplicateRuleIdentifier && ( + <RedirectToCloneRule + redirectTo={true} + identifier={duplicateRuleIdentifier} + isProvisioned={isProvisioned} + onDismiss={() => setDuplicateRuleIdentifier(undefined)} + /> + )} + </AlertingPageWrapper> + ); +}; + +const createMetadata = (rule: CombinedRule): PageInfoItem[] => { + const { labels, annotations, group } = rule; + const metadata: PageInfoItem[] = []; + + const runbookUrl = annotations[Annotation.runbookURL]; + const dashboardUID = annotations[Annotation.dashboardUID]; + const panelID = annotations[Annotation.panelID]; + + const hasDashboardAndPanel = dashboardUID && panelID; + const hasDashboard = dashboardUID; + const hasLabels = !isEmpty(labels); + + const interval = group.interval; + + if (runbookUrl) { + metadata.push({ + label: 'Runbook', + value: ( + <TextLink variant="bodySmall" href={runbookUrl} external> + {/* TODO instead of truncating the string, we should use flex and text overflow properly to allow it to take up all of the horizontal space available */} + {truncate(runbookUrl, { length: 42 })} + </TextLink> + ), + }); + } + + if (hasDashboardAndPanel) { + metadata.push({ + label: 'Dashboard and panel', + value: ( + <WithReturnButton + title={rule.name} + component={ + <TextLink variant="bodySmall" href={makePanelLink(dashboardUID, panelID)}> + View panel + </TextLink> + } + /> + ), + }); + } else if (hasDashboard) { + metadata.push({ + label: 'Dashboard', + value: ( + <WithReturnButton + title={rule.name} + component={ + <TextLink title={rule.name} variant="bodySmall" href={makeDashboardLink(dashboardUID)}> + View dashboard + </TextLink> + } + /> + ), + }); + } + + if (interval) { + metadata.push({ + label: 'Evaluation interval', + value: <Text color="primary">Every {interval}</Text>, + }); + } + + if (hasLabels) { + metadata.push({ + label: 'Labels', + /* TODO truncate number of labels, maybe build in to component? */ + value: <AlertLabels labels={labels} size="sm" />, + }); + } + + return metadata; +}; + +// TODO move somewhere else +export const createListFilterLink = (values: Array<[string, string]>) => { + const params = new URLSearchParams([['search', values.map(([key, value]) => `${key}:"${value}"`).join(' ')]]); + return createUrl(`/alerting/list?` + params.toString()); +}; + +interface TitleProps { + name: string; + // recording rules don't have a state + state?: PromAlertingRuleState; + health?: RuleHealth; + ruleType?: PromRuleType; +} + +export const Title = ({ name, state, health, ruleType }: TitleProps) => { + const styles = useStyles2(getStyles); + const isRecordingRule = ruleType === PromRuleType.Recording; + + return ( + <div className={styles.title}> + <LinkButton variant="secondary" icon="angle-left" href="/alerting/list" /> + <Text variant="h1" truncate> + {name} + </Text> + {/* recording rules won't have a state */} + {state && <StateBadge state={state} health={health} />} + {isRecordingRule && <RecordingBadge health={health} />} + </div> + ); +}; + +export const isErrorHealth = (health?: RuleHealth) => health === 'error' || health === 'err'; + +function useActiveTab(): [ActiveTab, (tab: ActiveTab) => void] { + const [queryParams, setQueryParams] = useQueryParams(); + const tabFromQuery = queryParams['tab']; + + const activeTab = isValidTab(tabFromQuery) ? tabFromQuery : ActiveTab.Query; + + const setActiveTab = (tab: ActiveTab) => { + setQueryParams({ tab }); + }; + + return [activeTab, setActiveTab]; +} + +function isValidTab(tab: UrlQueryValue): tab is ActiveTab { + const isString = typeof tab === 'string'; + // @ts-ignore + return isString && Object.values(ActiveTab).includes(tab); +} + +function usePageNav(rule: CombinedRule) { + const [activeTab, setActiveTab] = useActiveTab(); + + const { annotations, promRule } = rule; + + const summary = annotations[Annotation.summary]; + const isAlertType = isAlertingRule(promRule); + const numberOfInstance = isAlertType ? (promRule.alerts ?? []).length : undefined; + + const namespaceName = decodeGrafanaNamespace(rule.namespace).name; + const groupName = rule.group.name; + + const isGrafanaAlertRule = isGrafanaRulerRule(rule.rulerRule) && isAlertType; + const isRecordingRuleType = isRecordingRule(rule.promRule); + + const pageNav: NavModelItem = { + ...defaultPageNav, + text: rule.name, + subTitle: summary, + children: [ + { + text: 'Query and conditions', + active: activeTab === ActiveTab.Query, + onClick: () => { + setActiveTab(ActiveTab.Query); + }, + }, + { + text: 'Instances', + active: activeTab === ActiveTab.Instances, + onClick: () => { + setActiveTab(ActiveTab.Instances); + }, + tabCounter: numberOfInstance, + hideFromTabs: isRecordingRuleType, + }, + { + text: 'History', + active: activeTab === ActiveTab.History, + onClick: () => { + setActiveTab(ActiveTab.History); + }, + // alert state history is only available for Grafana managed alert rules + hideFromTabs: !isGrafanaAlertRule, + }, + { + text: 'Details', + active: activeTab === ActiveTab.Details, + onClick: () => { + setActiveTab(ActiveTab.Details); + }, + }, + ], + parentItem: { + text: groupName, + url: createListFilterLink([ + ['namespace', namespaceName], + ['group', groupName], + ]), + // @TODO support nested folders here + parentItem: { + text: namespaceName, + url: createListFilterLink([['namespace', namespaceName]]), + }, + }, + }; + + return { + pageNav, + activeTab, + }; +} + +const getStyles = () => ({ + title: css({ + display: 'flex', + alignItems: 'center', + gap: 8, + minWidth: 0, + }), +}); + +export default RuleViewer; diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx deleted file mode 100644 index 560d7afe82d47..0000000000000 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx +++ /dev/null @@ -1,377 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; -import { TestProvider } from 'test/helpers/TestProvider'; -import { byRole, byText } from 'testing-library-selector'; - -import { PluginExtensionTypes } from '@grafana/data'; -import { getPluginLinkExtensions, locationService, setBackendSrv } from '@grafana/runtime'; -import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { backendSrv } from 'app/core/services/backend_srv'; -import { contextSrv } from 'app/core/services/context_srv'; -import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; -import { AccessControlAction } from 'app/types'; -import { CombinedRule } from 'app/types/unified-alerting'; -import { PromAlertingRuleState, PromApplication } from 'app/types/unified-alerting-dto'; - -import { discoverFeatures } from '../../api/buildInfo'; -import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities'; -import { mockAlertRuleApi, setupMswServer } from '../../mockApi'; -import { - getCloudRule, - getGrafanaRule, - grantUserPermissions, - mockDataSource, - mockPromAlertingRule, - mockRulerAlertingRule, - promRuleFromRulerRule, -} from '../../mocks'; -import { mockAlertmanagerChoiceResponse } from '../../mocks/alertmanagerApi'; -import { mockPluginSettings } from '../../mocks/plugins'; -import { setupDataSources } from '../../testSetup/datasources'; -import { SupportedPlugin } from '../../types/pluginBridges'; -import * as ruleId from '../../utils/rule-id'; - -import { RuleViewer } from './RuleViewer.v1'; - -const mockGrafanaRule = getGrafanaRule({ name: 'Test alert' }, { uid: 'test1', title: 'Test alert' }); -const mockCloudRule = getCloudRule({ name: 'cloud test alert' }); - -const mockRoute = (id?: string): GrafanaRouteComponentProps<{ id?: string; sourceName?: string }> => ({ - route: { - path: '/', - component: RuleViewer, - }, - queryParams: { returnTo: '/alerting/list' }, - match: { params: { id: id ?? 'test1', sourceName: 'grafana' }, isExact: false, url: 'asdf', path: '' }, - history: locationService.getHistory(), - location: { pathname: '', hash: '', search: '', state: '' }, - staticContext: {}, -}); - -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getPluginLinkExtensions: jest.fn(), -})); -jest.mock('../../hooks/useAbilities'); -jest.mock('../../api/buildInfo'); - -const mocks = { - getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions), - useAlertRuleAbility: jest.mocked(useAlertRuleAbility), -}; - -const ui = { - actionButtons: { - edit: byRole('link', { name: /edit/i }), - silence: byRole('link', { name: 'Silence' }), - }, - moreButton: byRole('button', { name: /More/i }), - moreButtons: { - duplicate: byRole('menuitem', { name: /^Duplicate$/i }), - delete: byRole('menuitem', { name: /delete/i }), - }, - loadingIndicator: byText(/Loading rule/i), -}; - -const renderRuleViewer = async (ruleId: string) => { - locationService.push(`/alerting/grafana/${ruleId}/view`); - render( - <TestProvider> - <RuleViewer {...mockRoute(ruleId)} /> - </TestProvider> - ); - - await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument()); -}; - -const server = setupMswServer(); -const user = userEvent.setup(); - -const dsName = 'prometheus'; -const rulerRule = mockRulerAlertingRule({ alert: 'cloud test alert' }); -const rulerRuleIdentifier = ruleId.fromRulerRule('prometheus', 'ns-default', 'group-default', rulerRule); - -beforeAll(() => { - setBackendSrv(backendSrv); - const promDsSettings = mockDataSource({ - name: dsName, - uid: dsName, - }); - - setupDataSources(promDsSettings); -}); - -beforeEach(() => { - // some action buttons need to check what Alertmanager setup we have for Grafana managed rules - mockAlertmanagerChoiceResponse(server, { - alertmanagersChoice: AlertmanagerChoice.Internal, - numExternalAlertmanagers: 1, - }); - // we need to mock this one for the "declare incident" button - mockPluginSettings(server, SupportedPlugin.Incident); - - mockAlertRuleApi(server).rulerRules('grafana', { - [mockGrafanaRule.namespace.name]: [ - { name: mockGrafanaRule.group.name, interval: '1m', rules: [mockGrafanaRule.rulerRule!] }, - ], - }); - - const { name, query, labels, annotations } = mockGrafanaRule; - mockAlertRuleApi(server).prometheusRuleNamespaces('grafana', { - data: { - groups: [ - { - file: mockGrafanaRule.namespace.name, - interval: 60, - name: mockGrafanaRule.group.name, - rules: [mockPromAlertingRule({ name, query, labels, annotations })], - }, - ], - }, - status: 'success', - }); - - mockAlertRuleApi(server).rulerRuleGroup(dsName, 'ns-default', 'group-default', { - name: 'group-default', - interval: '1m', - rules: [rulerRule], - }); - - mockAlertRuleApi(server).prometheusRuleNamespaces(dsName, { - data: { - groups: [ - { - file: 'ns-default', - interval: 60, - name: 'group-default', - rules: [promRuleFromRulerRule(rulerRule, { state: PromAlertingRuleState.Inactive })], - }, - ], - }, - status: 'success', - }); - mocks.getPluginLinkExtensionsMock.mockReturnValue({ - extensions: [ - { - pluginId: 'grafana-ml-app', - id: '1', - type: PluginExtensionTypes.link, - title: 'Run investigation', - category: 'Sift', - description: 'Run a Sift investigation for this alert', - onClick: jest.fn(), - }, - ], - }); -}); - -describe('RuleViewer', () => { - let mockCombinedRule = jest.fn(); - - afterEach(() => { - mockCombinedRule.mockReset(); - }); - - it('should render page with grafana alert', async () => { - mocks.useAlertRuleAbility.mockReturnValue([true, true]); - await renderRuleViewer('test1'); - - expect(screen.getByText(/test alert/i)).toBeInTheDocument(); - }); - - it('should render page with cloud alert', async () => { - jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true); - - jest - .mocked(discoverFeatures) - .mockResolvedValue({ application: PromApplication.Mimir, features: { rulerApiEnabled: true } }); - - mocks.useAlertRuleAbility.mockReturnValue([true, true]); - await renderRuleViewer(ruleId.stringifyIdentifier(rulerRuleIdentifier)); - - expect(screen.getByText(/cloud test alert/i)).toBeInTheDocument(); - }); -}); - -describe('RuleDetails RBAC', () => { - describe('Grafana rules action buttons in details', () => { - let mockCombinedRule = jest.fn(); - - beforeEach(() => { - // mockCombinedRule = jest.mocked(useCombinedRule); - }); - - afterEach(() => { - mockCombinedRule.mockReset(); - }); - it('Should render Edit button for users with the update permission', async () => { - // Arrange - mocks.useAlertRuleAbility.mockImplementation((_rule, action) => { - return action === AlertRuleAction.Update ? [true, true] : [false, false]; - }); - mockCombinedRule.mockReturnValue({ - result: mockGrafanaRule as CombinedRule, - loading: false, - dispatched: true, - requestId: 'A', - error: undefined, - }); - - // Act - await renderRuleViewer('test1'); - - // Assert - expect(ui.actionButtons.edit.get()).toBeInTheDocument(); - }); - - it('Should render Delete button for users with the delete permission', async () => { - // Arrange - mockCombinedRule.mockReturnValue({ - result: mockGrafanaRule as CombinedRule, - loading: false, - dispatched: true, - requestId: 'A', - error: undefined, - }); - mocks.useAlertRuleAbility.mockImplementation((_rule, action) => { - return action === AlertRuleAction.Delete ? [true, true] : [false, false]; - }); - - // Act - await renderRuleViewer('test1'); - await user.click(ui.moreButton.get()); - - // Assert - expect(ui.moreButtons.delete.get()).toBeInTheDocument(); - }); - - it('Should not render Silence button for users wihout the instance create permission', async () => { - // Arrange - mockCombinedRule.mockReturnValue({ - result: mockGrafanaRule as CombinedRule, - loading: false, - dispatched: true, - requestId: 'A', - error: undefined, - }); - jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); - - // Act - await renderRuleViewer('test1'); - - // Assert - await waitFor(() => { - expect(ui.actionButtons.silence.query()).not.toBeInTheDocument(); - }); - }); - - it('Should render Silence button for users with the instance create permissions', async () => { - // Arrange - mocks.useAlertRuleAbility.mockReturnValue([true, true]); - mockCombinedRule.mockReturnValue({ - result: mockGrafanaRule as CombinedRule, - loading: false, - dispatched: true, - requestId: 'A', - error: undefined, - }); - jest - .spyOn(contextSrv, 'hasPermission') - .mockImplementation((action) => action === AccessControlAction.AlertingInstanceCreate); - - // Act - await renderRuleViewer('test1'); - - // Assert - await waitFor(() => { - expect(ui.actionButtons.silence.get()).toBeInTheDocument(); - }); - }); - - it('Should render clone button for users having create rule permission', async () => { - mocks.useAlertRuleAbility.mockImplementation((_rule, action) => { - return action === AlertRuleAction.Duplicate ? [true, true] : [false, false]; - }); - mockCombinedRule.mockReturnValue({ - result: getGrafanaRule({ name: 'Grafana rule' }), - loading: false, - dispatched: true, - }); - grantUserPermissions([AccessControlAction.AlertingRuleCreate]); - - await renderRuleViewer('test1'); - await user.click(ui.moreButton.get()); - - expect(ui.moreButtons.duplicate.get()).toBeInTheDocument(); - }); - - it('Should NOT render clone button for users without create rule permission', async () => { - mocks.useAlertRuleAbility.mockImplementation((_rule, action) => { - return action === AlertRuleAction.Duplicate ? [true, false] : [true, true]; - }); - mockCombinedRule.mockReturnValue({ - result: getGrafanaRule({ name: 'Grafana rule' }), - loading: false, - dispatched: true, - }); - - const { AlertingRuleRead, AlertingRuleUpdate, AlertingRuleDelete } = AccessControlAction; - grantUserPermissions([AlertingRuleRead, AlertingRuleUpdate, AlertingRuleDelete]); - - await renderRuleViewer('test1'); - await user.click(ui.moreButton.get()); - - expect(ui.moreButtons.duplicate.query()).not.toBeInTheDocument(); - }); - }); - - describe('Cloud rules action buttons', () => { - const mockCombinedRule = jest.fn(); - - afterEach(() => { - mockCombinedRule.mockReset(); - }); - - it('Should render edit button for users with the update permission', async () => { - // Arrange - mocks.useAlertRuleAbility.mockImplementation((_rule, action) => { - return action === AlertRuleAction.Update ? [true, true] : [false, false]; - }); - mockCombinedRule.mockReturnValue({ - result: mockCloudRule as CombinedRule, - loading: false, - dispatched: true, - requestId: 'A', - error: undefined, - }); - - // Act - await renderRuleViewer('test1'); - - // Assert - expect(ui.actionButtons.edit.query()).toBeInTheDocument(); - }); - - it('Should render Delete button for users with the delete permission', async () => { - // Arrange - mocks.useAlertRuleAbility.mockImplementation((_rule, action) => { - return action === AlertRuleAction.Delete ? [true, true] : [false, false]; - }); - mockCombinedRule.mockReturnValue({ - result: mockCloudRule as CombinedRule, - loading: false, - dispatched: true, - requestId: 'A', - error: undefined, - }); - - // Act - await renderRuleViewer('test1'); - await user.click(ui.moreButton.get()); - - // Assert - expect(ui.moreButtons.delete.query()).toBeInTheDocument(); - }); - }); -}); diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx deleted file mode 100644 index a5e4cbbf44a33..0000000000000 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { css } from '@emotion/css'; -import React, { useMemo } from 'react'; -import { useToggle } from 'react-use'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { isFetchError } from '@grafana/runtime'; -import { - Alert, - Button, - Collapse, - Icon, - IconButton, - LoadingPlaceholder, - useStyles2, - VerticalGroup, - Stack, - Text, -} from '@grafana/ui'; -import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; - -import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants'; -import { GrafanaRuleDefinition } from '../../../../../types/unified-alerting-dto'; -import { useCombinedRule } from '../../hooks/useCombinedRule'; -import { useCleanAnnotations } from '../../utils/annotations'; -import { getRulesSourceByName } from '../../utils/datasource'; -import * as ruleId from '../../utils/rule-id'; -import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; -import { AlertLabels } from '../AlertLabels'; -import { DetailsField } from '../DetailsField'; -import { ProvisionedResource, ProvisioningAlert } from '../Provisioning'; -import { RuleViewerLayout } from '../rule-viewer/RuleViewerLayout'; -import { RuleDetailsActionButtons } from '../rules/RuleDetailsActionButtons'; -import { RuleDetailsAnnotations } from '../rules/RuleDetailsAnnotations'; -import { RuleDetailsDataSources } from '../rules/RuleDetailsDataSources'; -import { RuleDetailsExpression } from '../rules/RuleDetailsExpression'; -import { RuleDetailsFederatedSources } from '../rules/RuleDetailsFederatedSources'; -import { RuleDetailsMatchingInstances } from '../rules/RuleDetailsMatchingInstances'; -import { RuleHealth } from '../rules/RuleHealth'; -import { RuleState } from '../rules/RuleState'; - -import { QueryResults } from './tabs/Query'; - -type RuleViewerProps = GrafanaRouteComponentProps<{ id?: string; sourceName?: string }>; - -const errorMessage = 'Could not find data source for rule'; -const errorTitle = 'Could not view rule'; -const pageTitle = 'View rule'; - -export function RuleViewer({ match }: RuleViewerProps) { - const styles = useStyles2(getStyles); - const [expandQuery, setExpandQuery] = useToggle(false); - - const identifier = useMemo(() => { - const id = ruleId.getRuleIdFromPathname(match.params); - if (!id) { - throw new Error('Rule ID is required'); - } - - return ruleId.parse(id, true); - }, [match.params]); - - const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier }); - - const annotations = useCleanAnnotations(rule?.annotations || {}); - - if (!identifier?.ruleSourceName) { - return ( - <RuleViewerLayout title={pageTitle}> - <Alert title={errorTitle}> - <details className={styles.errorMessage}>{errorMessage}</details> - </Alert> - </RuleViewerLayout> - ); - } - - const rulesSource = getRulesSourceByName(identifier.ruleSourceName); - - if (loading) { - return ( - <RuleViewerLayout title={pageTitle}> - <LoadingPlaceholder text="Loading rule..." /> - </RuleViewerLayout> - ); - } - - if (error || !rulesSource) { - return ( - <Alert title={errorTitle}> - <details className={styles.errorMessage}> - {isFetchError(error) ? error.message : errorMessage} - <br /> - {/* TODO Fix typescript */} - {/* {error && error?.stack} */} - </details> - </Alert> - ); - } - - if (!rule) { - return ( - <RuleViewerLayout title={pageTitle}> - <span>Rule could not be found.</span> - </RuleViewerLayout> - ); - } - - const isFederatedRule = isFederatedRuleGroup(rule.group); - const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); - - return ( - <RuleViewerLayout - wrapInContent={false} - title={pageTitle} - renderTitle={() => ( - <Stack direction="row" alignItems="flex-start" gap={1}> - <Icon name="bell" size="xl" /> - <Text variant="h3">{rule.name}</Text> - <RuleState rule={rule} isCreating={false} isDeleting={false} /> - </Stack> - )} - > - {isFederatedRule && ( - <Alert severity="info" title="This rule is part of a federated rule group."> - <VerticalGroup> - Federated rule groups are currently an experimental feature. - <Button fill="text" icon="book"> - <a href="https://grafana.com/docs/metrics-enterprise/latest/tenant-management/tenant-federation/#cross-tenant-alerting-and-recording-rule-federation"> - Read documentation - </a> - </Button> - </VerticalGroup> - </Alert> - )} - {isProvisioned && <ProvisioningAlert resource={ProvisionedResource.AlertRule} />} - <> - <RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} isViewMode={true} /> - <div className={styles.details}> - <div className={styles.leftSide}> - {rule.promRule && ( - <DetailsField label="Health" horizontal={true}> - <RuleHealth rule={rule.promRule} /> - </DetailsField> - )} - {!!rule.labels && !!Object.keys(rule.labels).length && ( - <DetailsField label="Labels" horizontal={true}> - <AlertLabels labels={rule.labels} /> - </DetailsField> - )} - <RuleDetailsExpression rulesSource={rulesSource} rule={rule} annotations={annotations} /> - <RuleDetailsAnnotations annotations={annotations} /> - </div> - <div className={styles.rightSide}> - <RuleDetailsDataSources rule={rule} rulesSource={rulesSource} /> - {isFederatedRule && <RuleDetailsFederatedSources group={rule.group} />} - <DetailsField label="Namespace / Group" className={styles.rightSideDetails}> - {rule.namespace.name} / {rule.group.name} - </DetailsField> - {isGrafanaRulerRule(rule.rulerRule) && <GrafanaRuleUID rule={rule.rulerRule.grafana_alert} />} - </div> - </div> - <div> - <DetailsField label="Matching instances" horizontal={true}> - <RuleDetailsMatchingInstances - rule={rule} - pagination={{ itemsPerPage: DEFAULT_PER_PAGE_PAGINATION }} - enableFiltering - /> - </DetailsField> - </div> - </> - <Collapse - label="Query & Results" - isOpen={expandQuery} - onToggle={setExpandQuery} - collapsible={true} - className={styles.collapse} - > - {expandQuery && <QueryResults rule={rule} />} - </Collapse> - </RuleViewerLayout> - ); -} - -function GrafanaRuleUID({ rule }: { rule: GrafanaRuleDefinition }) { - const styles = useStyles2(getStyles); - const copyUID = () => navigator.clipboard && navigator.clipboard.writeText(rule.uid); - - return ( - <DetailsField label="Rule UID" childrenWrapperClassName={styles.ruleUid}> - {rule.uid} <IconButton name="copy" onClick={copyUID} tooltip="Copy rule UID" /> - </DetailsField> - ); -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - errorMessage: css` - white-space: pre-wrap; - `, - queries: css` - height: 100%; - width: 100%; - `, - collapse: css` - margin-top: ${theme.spacing(2)}; - border-color: ${theme.colors.border.weak}; - border-radius: ${theme.shape.radius.default}; - `, - queriesTitle: css` - padding: ${theme.spacing(2, 0.5)}; - font-size: ${theme.typography.h5.fontSize}; - font-weight: ${theme.typography.fontWeightBold}; - font-family: ${theme.typography.h5.fontFamily}; - `, - query: css` - border-bottom: 1px solid ${theme.colors.border.medium}; - padding: ${theme.spacing(2)}; - `, - queryWarning: css` - margin: ${theme.spacing(4, 0)}; - `, - title: css` - font-size: ${theme.typography.h4.fontSize}; - font-weight: ${theme.typography.fontWeightBold}; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - `, - details: css` - display: flex; - flex-direction: row; - gap: ${theme.spacing(4)}; - `, - leftSide: css` - flex: 1; - overflow: hidden; - `, - rightSide: css` - padding-right: ${theme.spacing(3)}; - - max-width: 360px; - word-break: break-all; - overflow: hidden; - `, - rightSideDetails: css` - & > div:first-child { - width: auto; - } - `, - labels: css` - justify-content: flex-start; - `, - ruleUid: css` - display: flex; - align-items: center; - gap: ${theme.spacing(1)}; - `, - }; -}; - -export default RuleViewer; diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx index ead6e0cf27f69..7cba1c7386dfa 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx @@ -1,141 +1,19 @@ -import { css } from '@emotion/css'; -import React, { useCallback } from 'react'; +import React from 'react'; -import { - DataSourceInstanceSettings, - DataSourceJsonData, - DateTime, - dateTime, - GrafanaTheme2, - PanelData, - RelativeTimeRange, - urlUtil, -} from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { DataSourceRef } from '@grafana/schema'; -import { DateTimePicker, LinkButton, useStyles2 } from '@grafana/ui'; -import { contextSrv } from 'app/core/core'; -import { isExpressionQuery } from 'app/features/expressions/guards'; -import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; +import { PanelData } from '@grafana/data'; import { VizWrapper } from '../rule-editor/VizWrapper'; import { ThresholdDefinition } from '../rule-editor/util'; -interface RuleViewerVisualizationProps extends Pick<AlertQuery, 'refId' | 'model' | 'relativeTimeRange'> { - dsSettings: DataSourceInstanceSettings<DataSourceJsonData>; +interface RuleViewerVisualizationProps { data?: PanelData; thresholds?: ThresholdDefinition; - onTimeRangeChange: (range: RelativeTimeRange) => void; - className?: string; } -const headerHeight = 4; - -export function RuleViewerVisualization({ - data, - model, - thresholds, - dsSettings, - relativeTimeRange, - onTimeRangeChange, - className, -}: RuleViewerVisualizationProps): JSX.Element | null { - const styles = useStyles2(getStyles); - const isExpression = isExpressionQuery(model); - - const onTimeChange = useCallback( - (newDateTime: DateTime) => { - const now = dateTime().unix() - newDateTime.unix(); - - if (relativeTimeRange) { - const interval = relativeTimeRange.from - relativeTimeRange.to; - onTimeRangeChange({ from: now + interval, to: now }); - } - }, - [onTimeRangeChange, relativeTimeRange] - ); - - const setDateTime = useCallback((relativeTimeRangeTo: number) => { - return relativeTimeRangeTo === 0 ? dateTime() : dateTime().subtract(relativeTimeRangeTo, 'seconds'); - }, []); - +export function RuleViewerVisualization({ data, thresholds }: RuleViewerVisualizationProps): JSX.Element | null { if (!data) { return null; } - const allowedToExploreDataSources = contextSrv.hasAccessToExplore(); - - return ( - <div className={className}> - <div className={styles.header}> - <div className={styles.actions}> - {!isExpression && relativeTimeRange ? ( - <DateTimePicker date={setDateTime(relativeTimeRange.to)} onChange={onTimeChange} maxDate={new Date()} /> - ) : null} - - {allowedToExploreDataSources && !isExpression && ( - <LinkButton - size="md" - variant="secondary" - icon="compass" - target="_blank" - href={createExploreLink(dsSettings, model)} - > - View in Explore - </LinkButton> - )} - </div> - </div> - <VizWrapper data={data} thresholds={thresholds?.config} thresholdsType={thresholds?.mode} /> - </div> - ); -} - -function createExploreLink(settings: DataSourceRef, model: AlertDataQuery): string { - const { uid, type } = settings; - const { refId, ...rest } = model; - - /* - In my testing I've found some alerts that don't have a data source embedded inside the model. - At this moment in time it is unclear to me why some alert definitions not have a data source embedded in the model. - - I don't think that should happen here, the fact that the datasource ref is sometimes missing here is a symptom of another cause. (Gilles) - */ - return urlUtil.renderUrl(`${config.appSubUrl}/explore`, { - left: JSON.stringify({ - datasource: settings.uid, - queries: [{ refId: 'A', ...rest, datasource: { type, uid } }], - range: { from: 'now-1h', to: 'now' }, - }), - }); + return <VizWrapper data={data} thresholds={thresholds?.config} thresholdsType={thresholds?.mode} />; } - -const getStyles = (theme: GrafanaTheme2) => { - return { - header: css` - height: ${theme.spacing(headerHeight)}; - display: flex; - align-items: center; - justify-content: flex-end; - white-space: nowrap; - margin-bottom: ${theme.spacing(2)}; - `, - refId: css` - font-weight: ${theme.typography.fontWeightMedium}; - color: ${theme.colors.text.link}; - overflow: hidden; - `, - dataSource: css` - margin-left: ${theme.spacing(1)}; - font-style: italic; - color: ${theme.colors.text.secondary}; - `, - actions: css` - display: flex; - align-items: center; - `, - errorMessage: css` - white-space: pre-wrap; - `, - }; -}; diff --git a/public/app/features/alerting/unified/components/rule-viewer/StateBadges.tsx b/public/app/features/alerting/unified/components/rule-viewer/StateBadges.tsx new file mode 100644 index 0000000000000..8c478be41dea6 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/StateBadges.tsx @@ -0,0 +1,80 @@ +import React, { ReactNode } from 'react'; + +import { Stack, Text } from '@grafana/ui'; +import { RuleHealth } from 'app/types/unified-alerting'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; + +import { AlertStateDot } from '../AlertStateDot'; + +import { isErrorHealth } from './RuleViewer'; + +interface RecordingBadgeProps { + health?: RuleHealth; +} + +export const RecordingBadge = ({ health }: RecordingBadgeProps) => { + const hasError = isErrorHealth(health); + + const color = hasError ? 'error' : 'success'; + const text = hasError ? 'Recording error' : 'Recording'; + + return <Badge color={color} text={text} />; +}; + +// we're making a distinction here between the "state" of the rule and its "health". +interface StateBadgeProps { + state: PromAlertingRuleState; + health?: RuleHealth; +} + +export const StateBadge = ({ state, health }: StateBadgeProps) => { + let stateLabel: string; + let color: BadgeColor; + + switch (state) { + case PromAlertingRuleState.Inactive: + color = 'success'; + stateLabel = 'Normal'; + break; + case PromAlertingRuleState.Firing: + color = 'error'; + stateLabel = 'Firing'; + break; + case PromAlertingRuleState.Pending: + color = 'warning'; + stateLabel = 'Pending'; + break; + } + + // if the rule is in "error" health we don't really care about the state + if (isErrorHealth(health)) { + color = 'error'; + stateLabel = 'Error'; + } + + if (health === 'nodata') { + color = 'warning'; + stateLabel = 'No data'; + } + + return <Badge color={color} text={stateLabel} />; +}; + +// the generic badge component +type BadgeColor = 'success' | 'error' | 'warning'; + +interface BadgeProps { + color: BadgeColor; + text: NonNullable<ReactNode>; +} + +function Badge({ color, text }: BadgeProps) { + return ( + <Stack direction="row" gap={0.5} wrap={'nowrap'} flex={'0 0 auto'}> + <AlertStateDot color={color} /> + <Text variant="bodySmall" color={color}> + {text} + </Text> + </Stack> + ); +} diff --git a/public/app/features/alerting/unified/components/rule-viewer/__mocks__/server.ts b/public/app/features/alerting/unified/components/rule-viewer/__mocks__/server.ts new file mode 100644 index 0000000000000..83584bb415572 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/__mocks__/server.ts @@ -0,0 +1,57 @@ +import 'whatwg-fetch'; +import { http, HttpResponse } from 'msw'; +import { SetupServer, setupServer } from 'msw/node'; + +import { AlertmanagersChoiceResponse } from 'app/features/alerting/unified/api/alertmanagerApi'; +import { mockAlertmanagerChoiceResponse } from 'app/features/alerting/unified/mocks/alertmanagerApi'; +import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; +import { AccessControlAction } from 'app/types'; + +const alertmanagerChoiceMockedResponse: AlertmanagersChoiceResponse = { + alertmanagersChoice: AlertmanagerChoice.Internal, + numExternalAlertmanagers: 0, +}; + +const folderAccess = { + [AccessControlAction.AlertingRuleCreate]: true, + [AccessControlAction.AlertingRuleRead]: true, + [AccessControlAction.AlertingRuleUpdate]: true, + [AccessControlAction.AlertingRuleDelete]: true, +}; + +export function createMockGrafanaServer() { + const server = setupServer(); + + mockFolderAccess(server, folderAccess); + mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse); + mockGrafanaIncidentPluginSettings(server); + + return server; +} + +// this endpoint is used to determine of we have edit / delete permissions for the Grafana managed alert rule +// a user must alsso have permissions for the folder (namespace) in which the alert rule is stored +function mockFolderAccess(server: SetupServer, accessControl: Partial<Record<AccessControlAction, boolean>>) { + server.use( + http.get('/api/folders/:uid', ({ request }) => { + const url = new URL(request.url); + const uid = url.searchParams.get('uid'); + + return HttpResponse.json({ + title: 'My Folder', + uid, + accessControl, + }); + }) + ); + + return server; +} + +function mockGrafanaIncidentPluginSettings(server: SetupServer) { + server.use( + http.get('/api/plugins/grafana-incident-app/settings', () => { + return HttpResponse.json({}); + }) + ); +} diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx index 1c0da4966ad80..6ff99b02903bd 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx @@ -50,6 +50,8 @@ const Details = ({ rule }: DetailsProps) => { ? rule.annotations ?? [] : undefined; + const hasEvaluationDuration = Number.isFinite(evaluationDuration); + return ( <Stack direction="column" gap={3}> <div className={styles.metadataWrapper}> @@ -74,7 +76,7 @@ const Details = ({ rule }: DetailsProps) => { {/* evaluation duration and pending period */} <MetaText direction="column"> - {evaluationDuration && ( + {hasEvaluationDuration && ( <> Last evaluation {evaluationTimestamp && evaluationDuration && ( diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx index 6fa54b32874cc..8988d3a6d99fe 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx @@ -1,12 +1,10 @@ -import { produce } from 'immer'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { useObservable } from 'react-use'; -import { LoadingState, PanelData, RelativeTimeRange } from '@grafana/data'; +import { LoadingState, PanelData } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { Alert } from '@grafana/ui'; +import { Alert, Stack } from '@grafana/ui'; import { CombinedRule } from 'app/types/unified-alerting'; -import { AlertQuery } from 'app/types/unified-alerting-dto'; import { GrafanaRuleQueryViewer, QueryPreview } from '../../../GrafanaRuleQueryViewer'; import { useAlertQueriesStatus } from '../../../hooks/useAlertQueriesStatus'; @@ -19,8 +17,6 @@ interface Props { } const QueryResults = ({ rule }: Props) => { - const [evaluationTimeRanges, setEvaluationTimeRanges] = useState<Record<string, RelativeTimeRange>>({}); - const runner = useMemo(() => new AlertingQueryRunner(), []); const data = useObservable(runner.get()); const loadingData = isLoading(data); @@ -31,27 +27,13 @@ const QueryResults = ({ rule }: Props) => { const onRunQueries = useCallback(() => { if (queries.length > 0 && allDataSourcesAvailable) { - const evalCustomizedQueries = queries.map<AlertQuery>((q) => ({ - ...q, - relativeTimeRange: evaluationTimeRanges[q.refId] ?? q.relativeTimeRange, - })); - let condition; if (rule && isGrafanaRulerRule(rule.rulerRule)) { condition = rule.rulerRule.grafana_alert.condition; } - runner.run(evalCustomizedQueries, condition ?? 'A'); + runner.run(queries, condition ?? 'A'); } - }, [queries, allDataSourcesAvailable, rule, runner, evaluationTimeRanges]); - - useEffect(() => { - const alertQueries = alertRuleToQueries(rule); - const defaultEvalTimeRanges = Object.fromEntries( - alertQueries.map((q) => [q.refId, q.relativeTimeRange ?? { from: 0, to: 0 }]) - ); - - setEvaluationTimeRanges(defaultEvalTimeRanges); - }, [rule]); + }, [queries, allDataSourcesAvailable, rule, runner]); useEffect(() => { if (allDataSourcesAvailable) { @@ -63,16 +45,6 @@ const QueryResults = ({ rule }: Props) => { return () => runner.destroy(); }, [runner]); - const onQueryTimeRangeChange = useCallback( - (refId: string, timeRange: RelativeTimeRange) => { - const newEvalTimeRanges = produce(evaluationTimeRanges, (draft) => { - draft[refId] = timeRange; - }); - setEvaluationTimeRanges(newEvalTimeRanges); - }, - [evaluationTimeRanges, setEvaluationTimeRanges] - ); - const isFederatedRule = isFederatedRuleGroup(rule.group); return ( @@ -83,32 +55,30 @@ const QueryResults = ({ rule }: Props) => { <> {isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && ( <GrafanaRuleQueryViewer + rule={rule} condition={rule.rulerRule.grafana_alert.condition} queries={queries} evalDataByQuery={data} - evalTimeRanges={evaluationTimeRanges} - onTimeRangeChange={onQueryTimeRangeChange} /> )} {!isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && data && Object.keys(data).length > 0 && ( - <div> + <Stack direction="column" gap={1}> {queries.map((query) => { return ( <QueryPreview key={query.refId} + rule={rule} refId={query.refId} model={query.model} dataSource={Object.values(config.datasources).find((ds) => ds.uid === query.datasourceUid)} queryData={data[query.refId]} relativeTimeRange={query.relativeTimeRange} - evalTimeRange={evaluationTimeRanges[query.refId]} - onEvalTimeRangeChange={(timeRange) => onQueryTimeRangeChange(query.refId, timeRange)} isAlertCondition={false} /> ); })} - </div> + </Stack> )} {!isFederatedRule && !allDataSourcesAvailable && ( <Alert title="Query not available" severity="warning"> diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/DataSourceModelPreview.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/DataSourceModelPreview.tsx new file mode 100644 index 0000000000000..c0349bb88dc03 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/DataSourceModelPreview.tsx @@ -0,0 +1,40 @@ +import { dump } from 'js-yaml'; +import React from 'react'; + +import { DataSourceInstanceSettings } from '@grafana/data'; +import { AlertDataQuery } from 'app/types/unified-alerting-dto'; + +import { DataSourceType } from '../../../../utils/datasource'; +import { isPromOrLokiQuery } from '../../../../utils/rule-form'; + +import { isSQLLikeQuery, SQLQueryPreview } from './SQLQueryPreview'; + +const PrometheusQueryPreview = React.lazy(() => import('./PrometheusQueryPreview')); +const LokiQueryPreview = React.lazy(() => import('./LokiQueryPreview')); + +interface DatasourceModelPreviewProps { + model: AlertDataQuery; + dataSource: DataSourceInstanceSettings; +} + +function DatasourceModelPreview({ model, dataSource: datasource }: DatasourceModelPreviewProps): React.ReactNode { + if (datasource.type === DataSourceType.Prometheus && isPromOrLokiQuery(model)) { + return <PrometheusQueryPreview query={model.expr} />; + } + + if (datasource.type === DataSourceType.Loki && isPromOrLokiQuery(model)) { + return <LokiQueryPreview query={model.expr ?? ''} />; + } + + if (isSQLLikeQuery(model)) { + return <SQLQueryPreview expression={model.rawSql} />; + } + + return ( + <pre> + <code>{dump(model)}</code> + </pre> + ); +} + +export { DatasourceModelPreview }; diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/LokiQueryPreview.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/LokiQueryPreview.tsx new file mode 100644 index 0000000000000..7bffee7b3d129 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/LokiQueryPreview.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { RawQuery } from '@grafana/experimental'; +import lokiGrammar from 'app/plugins/datasource/loki/syntax'; + +interface Props { + query: string; +} + +const LokiQueryPreview = ({ query }: Props) => { + return <RawQuery query={query} language={{ grammar: lokiGrammar, name: 'promql' }} />; +}; + +export default LokiQueryPreview; diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/PrometheusQueryPreview.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/PrometheusQueryPreview.tsx new file mode 100644 index 0000000000000..2d9323ef56488 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/PrometheusQueryPreview.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { RawQuery } from '@grafana/experimental'; +import { promqlGrammar } from '@grafana/prometheus'; + +interface Props { + query: string; +} + +const PrometheusQueryPreview = ({ query }: Props) => { + return <RawQuery query={query} language={{ grammar: promqlGrammar, name: 'promql' }} />; +}; + +export default PrometheusQueryPreview; diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/SQLQueryPreview.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/SQLQueryPreview.tsx new file mode 100644 index 0000000000000..d491690980e36 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/SQLQueryPreview.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import { ReactMonacoEditor } from '@grafana/ui'; +import { AlertDataQuery } from 'app/types/unified-alerting-dto'; + +interface Props { + expression: string; +} + +export const SQLQueryPreview = ({ expression }: Props) => ( + <ReactMonacoEditor + options={{ + readOnly: true, + minimap: { + enabled: false, + }, + scrollBeyondLastColumn: 0, + scrollBeyondLastLine: false, + lineNumbers: 'off', + cursorWidth: 0, + overviewRulerLanes: 0, + }} + defaultLanguage="sql" + height={80} + defaultValue={expression} + width="100%" + /> +); + +export interface SQLLike { + refId: string; + rawSql: string; +} + +export function isSQLLikeQuery(model: AlertDataQuery): model is SQLLike { + return 'rawSql' in model; +} + +export default SQLQueryPreview; diff --git a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx deleted file mode 100644 index 43bac4335af2b..0000000000000 --- a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx +++ /dev/null @@ -1,393 +0,0 @@ -import { isEmpty, truncate } from 'lodash'; -import React from 'react'; - -import { AppEvents, NavModelItem, UrlQueryValue } from '@grafana/data'; -import { Alert, Button, Dropdown, LinkButton, Menu, Stack, TabContent, Text, TextLink } from '@grafana/ui'; -import { PageInfoItem } from 'app/core/components/Page/types'; -import { appEvents } from 'app/core/core'; -import { useQueryParams } from 'app/core/hooks/useQueryParams'; -import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; -import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; - -import { defaultPageNav } from '../../../RuleViewer'; -import { AlertRuleAction, useAlertRuleAbility } from '../../../hooks/useAbilities'; -import { Annotation } from '../../../utils/constants'; -import { - createShareLink, - isLocalDevEnv, - isOpenSourceEdition, - makeDashboardLink, - makePanelLink, - makeRuleBasedSilenceLink, -} from '../../../utils/misc'; -import * as ruleId from '../../../utils/rule-id'; -import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../../utils/rules'; -import { createUrl } from '../../../utils/url'; -import { AlertLabels } from '../../AlertLabels'; -import { AlertStateDot } from '../../AlertStateDot'; -import { AlertingPageWrapper } from '../../AlertingPageWrapper'; -import MoreButton from '../../MoreButton'; -import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning'; -import { DeclareIncidentMenuItem } from '../../bridges/DeclareIncidentButton'; -import { Details } from '../tabs/Details'; -import { History } from '../tabs/History'; -import { InstancesList } from '../tabs/Instances'; -import { QueryResults } from '../tabs/Query'; -import { Routing } from '../tabs/Routing'; - -import { useDeleteModal } from './DeleteModal'; - -type RuleViewerProps = { - rule: CombinedRule; - identifier: RuleIdentifier; -}; - -enum ActiveTab { - Query = 'query', - Instances = 'instances', - History = 'history', - Routing = 'routing', - Details = 'details', -} - -const RuleViewer = ({ rule, identifier }: RuleViewerProps) => { - const { pageNav, activeTab } = usePageNav(rule); - const [deleteModal, showDeleteModal] = useDeleteModal(); - - const [editSupported, editAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update); - const canEdit = editSupported && editAllowed; - - const [deleteSupported, deleteAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete); - const canDelete = deleteSupported && deleteAllowed; - - const [duplicateSupported, duplicateAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate); - const canDuplicate = duplicateSupported && duplicateAllowed; - - const [silenceSupported, silenceAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Silence); - const canSilence = silenceSupported && silenceAllowed; - - const [exportSupported, exportAllowed] = useAlertRuleAbility(rule, AlertRuleAction.ModifyExport); - const canExport = exportSupported && exportAllowed; - - const promRule = rule.promRule; - - const isAlertType = isAlertingRule(promRule); - - const isFederatedRule = isFederatedRuleGroup(rule.group); - const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); - - /** - * Since Incident isn't available as an open-source product we shouldn't show it for Open-Source licenced editions of Grafana. - * We should show it in development mode - */ - const shouldShowDeclareIncidentButton = !isOpenSourceEdition() || isLocalDevEnv(); - const shareUrl = createShareLink(rule.namespace.rulesSource, rule); - - const copyShareUrl = () => { - if (navigator.clipboard) { - navigator.clipboard.writeText(shareUrl); - appEvents.emit(AppEvents.alertSuccess, ['URL copied to clipboard']); - } - }; - - return ( - <AlertingPageWrapper - pageNav={pageNav} - navId="alert-list" - isLoading={false} - renderTitle={(title) => { - return <Title name={title} state={isAlertType ? promRule.state : undefined} />; - }} - actions={[ - canEdit && <EditButton key="edit-action" identifier={identifier} />, - <Dropdown - key="more-actions" - overlay={ - <Menu> - {canSilence && ( - <Menu.Item - label="Silence" - icon="bell-slash" - url={makeRuleBasedSilenceLink(identifier.ruleSourceName, rule)} - /> - )} - {shouldShowDeclareIncidentButton && <DeclareIncidentMenuItem title={rule.name} url={''} />} - {canDuplicate && <Menu.Item label="Duplicate" icon="copy" />} - <Menu.Divider /> - <Menu.Item label="Copy link" icon="share-alt" onClick={copyShareUrl} /> - {canExport && ( - <Menu.Item - label="Export" - icon="download-alt" - childItems={[ - <Menu.Item key="no-modifications" label="Without modifications" icon="file-blank" />, - <Menu.Item key="with-modifications" label="With modifications" icon="file-alt" />, - ]} - /> - )} - {canDelete && ( - <> - <Menu.Divider /> - <Menu.Item label="Delete" icon="trash-alt" destructive onClick={() => showDeleteModal(rule)} /> - </> - )} - </Menu> - } - > - <MoreButton size="md" /> - </Dropdown>, - ]} - info={createMetadata(rule)} - > - <Stack direction="column" gap={2}> - {/* actions */} - <Stack direction="column" gap={2}> - {/* alerts and notifications and stuff */} - {isFederatedRule && ( - <Alert severity="info" title="This rule is part of a federated rule group."> - <Stack direction="column"> - Federated rule groups are currently an experimental feature. - <Button fill="text" icon="book"> - <a href="https://grafana.com/docs/metrics-enterprise/latest/tenant-management/tenant-federation/#cross-tenant-alerting-and-recording-rule-federation"> - Read documentation - </a> - </Button> - </Stack> - </Alert> - )} - {isProvisioned && <ProvisioningAlert resource={ProvisionedResource.AlertRule} />} - {/* tabs and tab content */} - <TabContent> - {activeTab === ActiveTab.Query && <QueryResults rule={rule} />} - {activeTab === ActiveTab.Instances && <InstancesList rule={rule} />} - {activeTab === ActiveTab.History && isGrafanaRulerRule(rule.rulerRule) && <History rule={rule.rulerRule} />} - {activeTab === ActiveTab.Routing && <Routing />} - {activeTab === ActiveTab.Details && <Details rule={rule} />} - </TabContent> - </Stack> - </Stack> - {deleteModal} - </AlertingPageWrapper> - ); -}; - -interface EditButtonProps { - identifier: RuleIdentifier; -} - -export const EditButton = ({ identifier }: EditButtonProps) => { - const returnTo = location.pathname + location.search; - const ruleIdentifier = ruleId.stringifyIdentifier(identifier); - const editURL = createUrl(`/alerting/${encodeURIComponent(ruleIdentifier)}/edit`, { returnTo }); - - return ( - <LinkButton variant="secondary" icon="pen" href={editURL}> - Edit - </LinkButton> - ); -}; - -const createMetadata = (rule: CombinedRule): PageInfoItem[] => { - const { labels, annotations, group } = rule; - const metadata: PageInfoItem[] = []; - - const runbookUrl = annotations[Annotation.runbookURL]; - const dashboardUID = annotations[Annotation.dashboardUID]; - const panelID = annotations[Annotation.panelID]; - - const hasPanel = dashboardUID && panelID; - const hasDashboardWithoutPanel = dashboardUID && !panelID; - const hasLabels = !isEmpty(labels); - - const interval = group.interval; - - if (runbookUrl) { - metadata.push({ - label: 'Runbook', - value: ( - <TextLink variant="bodySmall" href={runbookUrl} external> - {/* TODO instead of truncating the string, we should use flex and text overflow properly to allow it to take up all of the horizontal space available */} - {truncate(runbookUrl, { length: 42 })} - </TextLink> - ), - }); - } - - if (hasPanel) { - metadata.push({ - label: 'Dashboard and panel', - value: ( - <TextLink variant="bodySmall" href={makePanelLink(dashboardUID, panelID)} external> - View panel - </TextLink> - ), - }); - } else if (hasDashboardWithoutPanel) { - metadata.push({ - label: 'Dashboard', - value: ( - <TextLink variant="bodySmall" href={makeDashboardLink(dashboardUID)} external> - View dashboard - </TextLink> - ), - }); - } - - if (interval) { - metadata.push({ - label: 'Evaluation interval', - value: <Text color="primary">Every {interval}</Text>, - }); - } - - if (hasLabels) { - metadata.push({ - label: 'Labels', - /* TODO truncate number of labels, maybe build in to component? */ - value: <AlertLabels labels={labels} size="sm" />, - }); - } - - return metadata; -}; - -// TODO move somewhere else -export const createListFilterLink = (values: Array<[string, string]>) => { - const params = new URLSearchParams([['search', values.map(([key, value]) => `${key}:"${value}"`).join(' ')]]); - return createUrl(`/alerting/list?` + params.toString()); -}; - -interface TitleProps { - name: string; - // recording rules don't have a state - state?: PromAlertingRuleState; -} - -export const Title = ({ name, state }: TitleProps) => ( - <div style={{ display: 'flex', alignItems: 'center', gap: 8, maxWidth: '100%' }}> - <LinkButton variant="secondary" icon="angle-left" href="/alerting/list" /> - <Text element="h1" truncate> - {name} - </Text> - {/* recording rules won't have a state */} - {state && <StateBadge state={state} />} - </div> -); - -interface StateBadgeProps { - state: PromAlertingRuleState; -} - -// TODO move to separate component -const StateBadge = ({ state }: StateBadgeProps) => { - let stateLabel: string; - let textColor: 'success' | 'error' | 'warning'; - - switch (state) { - case PromAlertingRuleState.Inactive: - textColor = 'success'; - stateLabel = 'Normal'; - break; - case PromAlertingRuleState.Firing: - textColor = 'error'; - stateLabel = 'Firing'; - break; - case PromAlertingRuleState.Pending: - textColor = 'warning'; - stateLabel = 'Pending'; - break; - } - - return ( - <Stack direction="row" gap={0.5}> - <AlertStateDot size="md" state={state} /> - <Text variant="bodySmall" color={textColor}> - {stateLabel} - </Text> - </Stack> - ); -}; - -function useActiveTab(): [ActiveTab, (tab: ActiveTab) => void] { - const [queryParams, setQueryParams] = useQueryParams(); - const tabFromQuery = queryParams['tab']; - - const activeTab = isValidTab(tabFromQuery) ? tabFromQuery : ActiveTab.Query; - - const setActiveTab = (tab: ActiveTab) => { - setQueryParams({ tab }); - }; - - return [activeTab, setActiveTab]; -} - -function isValidTab(tab: UrlQueryValue): tab is ActiveTab { - const isString = typeof tab === 'string'; - // @ts-ignore - return isString && Object.values(ActiveTab).includes(tab); -} - -function usePageNav(rule: CombinedRule) { - const [activeTab, setActiveTab] = useActiveTab(); - - const { annotations, promRule } = rule; - - const summary = annotations[Annotation.summary]; - const isAlertType = isAlertingRule(promRule); - const numberOfInstance = isAlertType ? (promRule.alerts ?? []).length : undefined; - - const pageNav: NavModelItem = { - ...defaultPageNav, - text: rule.name, - subTitle: summary, - children: [ - { - text: 'Query and conditions', - active: activeTab === ActiveTab.Query, - onClick: () => { - setActiveTab(ActiveTab.Query); - }, - }, - { - text: 'Instances', - active: activeTab === ActiveTab.Instances, - onClick: () => { - setActiveTab(ActiveTab.Instances); - }, - tabCounter: numberOfInstance, - }, - { - text: 'History', - active: activeTab === ActiveTab.History, - onClick: () => { - setActiveTab(ActiveTab.History); - }, - }, - { - text: 'Details', - active: activeTab === ActiveTab.Details, - onClick: () => { - setActiveTab(ActiveTab.Details); - }, - }, - ], - parentItem: { - text: rule.group.name, - url: createListFilterLink([ - ['namespace', rule.namespace.name], - ['group', rule.group.name], - ]), - parentItem: { - text: rule.namespace.name, - url: createListFilterLink([['namespace', rule.namespace.name]]), - }, - }, - }; - - return { - pageNav, - activeTab, - }; -} - -export default RuleViewer; diff --git a/public/app/features/alerting/unified/components/rules/CloneRule.tsx b/public/app/features/alerting/unified/components/rules/CloneRule.tsx index c422796823cbb..d8cd6af4e882a 100644 --- a/public/app/features/alerting/unified/components/rules/CloneRule.tsx +++ b/public/app/features/alerting/unified/components/rules/CloneRule.tsx @@ -1,9 +1,7 @@ -import { css } from '@emotion/css'; import React, { useState } from 'react'; -import { Redirect } from 'react-router-dom'; +import { Redirect, useLocation } from 'react-router-dom'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Button, ConfirmModal, useStyles2 } from '@grafana/ui'; +import { Button, ConfirmModal } from '@grafana/ui'; import { RuleIdentifier } from 'app/types/unified-alerting'; import * as ruleId from '../../utils/rule-id'; @@ -11,19 +9,31 @@ import * as ruleId from '../../utils/rule-id'; interface ConfirmCloneRuleModalProps { identifier: RuleIdentifier; isProvisioned: boolean; + redirectTo?: boolean; onDismiss: () => void; } -export function RedirectToCloneRule({ identifier, isProvisioned, onDismiss }: ConfirmCloneRuleModalProps) { - const styles = useStyles2(getStyles); - +export function RedirectToCloneRule({ + identifier, + isProvisioned, + redirectTo = false, + onDismiss, +}: ConfirmCloneRuleModalProps) { // For provisioned rules an additional confirmation step is required // Users have to be aware that the cloned rule will NOT be marked as provisioned + const location = useLocation(); const [stage, setStage] = useState<'redirect' | 'confirm'>(isProvisioned ? 'confirm' : 'redirect'); if (stage === 'redirect') { - const cloneUrl = `/alerting/new?copyFrom=${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}`; - return <Redirect to={cloneUrl} push />; + const copyFrom = ruleId.stringifyIdentifier(identifier); + const returnTo = location.pathname + location.search; + + const queryParams = new URLSearchParams({ + copyFrom, + returnTo: redirectTo ? returnTo : '', + }); + + return <Redirect to={`/alerting/new?` + queryParams.toString()} push />; } return ( @@ -33,7 +43,7 @@ export function RedirectToCloneRule({ identifier, isProvisioned, onDismiss }: Co body={ <div> <p> - The new rule will <span className={styles.bold}>NOT</span> be marked as a provisioned rule. + The new rule will <strong>not</strong> be marked as a provisioned rule. </p> <p> You will need to set a new evaluation group for the copied rule because the original one has been @@ -87,9 +97,3 @@ export const CloneRuleButton = React.forwardRef<HTMLButtonElement, CloneRuleButt ); CloneRuleButton.displayName = 'CloneRuleButton'; - -const getStyles = (theme: GrafanaTheme2) => ({ - bold: css` - font-weight: ${theme.typography.fontWeightBold}; - `, -}); diff --git a/public/app/features/alerting/unified/components/rules/CloudRules.tsx b/public/app/features/alerting/unified/components/rules/CloudRules.tsx index 398fcea48b989..6c5cfc3a355af 100644 --- a/public/app/features/alerting/unified/components/rules/CloudRules.tsx +++ b/public/app/features/alerting/unified/components/rules/CloudRules.tsx @@ -1,12 +1,14 @@ import { css } from '@emotion/css'; import pluralize from 'pluralize'; import React, { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; -import { GrafanaTheme2 } from '@grafana/data'; -import { LoadingPlaceholder, Pagination, Spinner, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2, urlUtil } from '@grafana/data'; +import { LinkButton, LoadingPlaceholder, Pagination, Spinner, useStyles2 } from '@grafana/ui'; import { CombinedRuleNamespace } from 'app/types/unified-alerting'; import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants'; +import { AlertingAction, useAlertingAbility } from '../../hooks/useAbilities'; import { usePagination } from '../../hooks/usePagination'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { getPaginationStyles } from '../../styles/pagination'; @@ -52,15 +54,18 @@ export const CloudRules = ({ namespaces, expandAll }: Props) => { return ( <section className={styles.wrapper}> <div className={styles.sectionHeader}> - <h5>Mimir / Cortex / Loki</h5> - {dataSourcesLoading.length ? ( - <LoadingPlaceholder - className={styles.loader} - text={`Loading rules from ${dataSourcesLoading.length} ${pluralize('source', dataSourcesLoading.length)}`} - /> - ) : ( - <div /> - )} + <div className={styles.headerRow}> + <h5>Mimir / Cortex / Loki</h5> + {dataSourcesLoading.length ? ( + <LoadingPlaceholder + className={styles.loader} + text={`Loading rules from ${dataSourcesLoading.length} ${pluralize('source', dataSourcesLoading.length)}`} + /> + ) : ( + <div /> + )} + <CreateRecordingRuleButton /> + </div> </div> {pageItems.map(({ group, namespace }) => { @@ -106,4 +111,35 @@ const getStyles = (theme: GrafanaTheme2) => ({ padding: ${theme.spacing(2)}; `, pagination: getPaginationStyles(theme), + headerRow: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + marginBottom: theme.spacing(1), + }), }); + +export function CreateRecordingRuleButton() { + const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule); + + const location = useLocation(); + + const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed; + + if (canCreateCloudRules) { + return ( + <LinkButton + key="new-recording-rule" + href={urlUtil.renderUrl(`alerting/new/recording`, { + returnTo: location.pathname + location.search, + })} + icon="plus" + variant="secondary" + > + New recording rule + </LinkButton> + ); + } + return null; +} diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx index 395d7d33f0208..4864237c17ccb 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx @@ -20,6 +20,7 @@ import { AlertInfo, getAlertInfo, isRecordingRulerRule } from '../../utils/rules import { parsePrometheusDuration, safeParseDurationstr } from '../../utils/time'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning'; +import { decodeGrafanaNamespace, encodeGrafanaNamespace } from '../expressions/util'; import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior'; const ITEMS_PER_PAGE = 10; @@ -82,7 +83,7 @@ export const RulesForGroupTable = ({ rulesWithoutRecordingRules }: { rulesWithou }, { id: 'for', - label: 'For', + label: 'Pending period', renderCell: ({ data: { forDuration } }) => { return <>{forDuration}</>; }, @@ -158,11 +159,12 @@ export interface ModalProps { onClose: (saved?: boolean) => void; intervalEditOnly?: boolean; folderUrl?: string; + folderUid?: string; hideFolder?: boolean; } export function EditCloudGroupModal(props: ModalProps): React.ReactElement { - const { namespace, group, onClose, intervalEditOnly } = props; + const { namespace, group, onClose, intervalEditOnly, folderUid } = props; const styles = useStyles2(getStyles); const dispatch = useDispatch(); @@ -172,7 +174,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { const defaultValues = useMemo( (): FormValues => ({ - namespaceName: namespace.name, + namespaceName: decodeGrafanaNamespace(namespace).name, groupName: group.name, groupInterval: group.interval ?? '', }), @@ -182,6 +184,9 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { const rulesSourceName = getRulesSourceName(namespace.rulesSource); const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME; + // parse any parent folders the alert rule might be stored in + const nestedFolderParents = decodeGrafanaNamespace(namespace).parents; + const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace'; // close modal if successfully saved @@ -193,14 +198,20 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { useCleanup((state) => (state.unifiedAlerting.updateLotexNamespaceAndGroup = initialAsyncRequestState)); const onSubmit = (values: FormValues) => { + // make sure that when dealing with a nested folder for Grafana managed rules we encode the folder properly + const newNamespaceName = isGrafanaManagedGroup + ? encodeGrafanaNamespace(values.namespaceName, nestedFolderParents) + : values.namespaceName; + dispatch( updateLotexNamespaceAndGroupAction({ rulesSourceName: rulesSourceName, groupName: group.name, newGroupName: values.groupName, namespaceName: namespace.name, - newNamespaceName: values.namespaceName, + newNamespaceName: newNamespaceName, groupInterval: values.groupInterval || undefined, + folderUid, }) ); }; @@ -210,6 +221,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { defaultValues, shouldFocusError: true, }); + const { handleSubmit, register, diff --git a/public/app/features/alerting/unified/components/rules/GrafanaRules.tsx b/public/app/features/alerting/unified/components/rules/GrafanaRules.tsx index a5272c5d7d94a..7c846272b4e21 100644 --- a/public/app/features/alerting/unified/components/rules/GrafanaRules.tsx +++ b/public/app/features/alerting/unified/components/rules/GrafanaRules.tsx @@ -1,18 +1,21 @@ import { css } from '@emotion/css'; import React from 'react'; +import { useToggle } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; -import { LoadingPlaceholder, Pagination, Spinner, useStyles2 } from '@grafana/ui'; +import { Button, LoadingPlaceholder, Pagination, Spinner, useStyles2 } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { CombinedRuleNamespace } from 'app/types/unified-alerting'; import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants'; +import { AlertingAction, useAlertingAbility } from '../../hooks/useAbilities'; import { flattenGrafanaManagedRules } from '../../hooks/useCombinedRuleNamespaces'; import { usePagination } from '../../hooks/usePagination'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { getPaginationStyles } from '../../styles/pagination'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { initialAsyncRequestState } from '../../utils/redux'; +import { GrafanaRulesExporter } from '../export/GrafanaRulesExporter'; import { RulesGroup } from './RulesGroup'; import { useCombinedGroupNamespace } from './useCombinedGroupNamespace'; @@ -45,11 +48,31 @@ export const GrafanaRules = ({ namespaces, expandAll }: Props) => { DEFAULT_PER_PAGE_PAGINATION ); + const [exportRulesSupported, exportRulesAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules); + const canExportRules = exportRulesSupported && exportRulesAllowed; + + const [showExportDrawer, toggleShowExportDrawer] = useToggle(false); + const hasGrafanaAlerts = namespaces.length > 0; + return ( <section className={styles.wrapper}> <div className={styles.sectionHeader}> - <h5>Grafana</h5> - {loading ? <LoadingPlaceholder className={styles.loader} text="Loading..." /> : <div />} + <div className={styles.headerRow}> + <h5>Grafana</h5> + {loading ? <LoadingPlaceholder className={styles.loader} text="Loading..." /> : <div />} + {hasGrafanaAlerts && canExportRules && ( + <Button + aria-label="export all grafana rules" + data-testid="export-all-grafana-rules" + icon="download-alt" + tooltip="Export all Grafana-managed rules" + onClick={toggleShowExportDrawer} + variant="secondary" + > + Export rules + </Button> + )} + </div> </div> {pageItems.map(({ group, namespace }) => ( @@ -70,6 +93,7 @@ export const GrafanaRules = ({ namespaces, expandAll }: Props) => { onNavigate={onPageChange} hideWhenSinglePage /> + {canExportRules && showExportDrawer && <GrafanaRulesExporter onClose={toggleShowExportDrawer} />} </section> ); }; @@ -91,4 +115,11 @@ const getStyles = (theme: GrafanaTheme2) => ({ padding: ${theme.spacing(2)}; `, pagination: getPaginationStyles(theme), + headerRow: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + flexDirection: 'row', + }), }); diff --git a/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx index e720ec9d8fb74..063f0b6d6ac39 100644 --- a/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx @@ -27,12 +27,13 @@ interface ModalProps { namespace: CombinedRuleNamespace; group: CombinedRuleGroup; onClose: () => void; + folderUid?: string; } type CombinedRuleWithUID = { uid: string } & CombinedRule; export const ReorderCloudGroupModal = (props: ModalProps) => { - const { group, namespace, onClose } = props; + const { group, namespace, onClose, folderUid } = props; const [pending, setPending] = useState<boolean>(false); const [rulesList, setRulesList] = useState<CombinedRule[]>(group.rules); @@ -63,6 +64,7 @@ export const ReorderCloudGroupModal = (props: ModalProps) => { groupName: group.name, rulesSourceName: rulesSourceName, newRules: rulerRules, + folderUid: folderUid || namespace.name, }) ) .unwrap() @@ -70,7 +72,7 @@ export const ReorderCloudGroupModal = (props: ModalProps) => { setPending(false); }); }, - [group.name, namespace.name, namespace.rulesSource, rulesList] + [group.name, namespace.name, namespace.rulesSource, rulesList, folderUid] ); // assign unique but stable identifiers to each (alerting / recording) rule diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx index 53caeeeccea39..59e8900988174 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx @@ -1,4 +1,6 @@ +import 'whatwg-fetch'; import { render, screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import React from 'react'; import { Provider } from 'react-redux'; @@ -18,12 +20,14 @@ import { AlertmanagersChoiceResponse } from '../../api/alertmanagerApi'; import { useIsRuleEditable } from '../../hooks/useIsRuleEditable'; import { getCloudRule, getGrafanaRule } from '../../mocks'; import { mockAlertmanagerChoiceResponse } from '../../mocks/alertmanagerApi'; +import { SupportedPlugin } from '../../types/pluginBridges'; import { RuleDetails } from './RuleDetails'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getPluginLinkExtensions: jest.fn(), + useReturnToPrevious: jest.fn(), })); jest.mock('../../hooks/useIsRuleEditable'); @@ -41,7 +45,13 @@ const ui = { }, }; -const server = setupServer(); +const server = setupServer( + http.get(`/api/plugins/${SupportedPlugin.Incident}/settings`, async () => { + return HttpResponse.json({ + enabled: false, + }); + }) +); const alertmanagerChoiceMockedResponse: AlertmanagersChoiceResponse = { alertmanagersChoice: AlertmanagerChoice.Internal, @@ -73,6 +83,7 @@ beforeEach(() => { ], }); server.resetHandlers(); + mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse); }); describe('RuleDetails RBAC', () => { @@ -106,7 +117,6 @@ describe('RuleDetails RBAC', () => { it('Should not render Silence button for users wihout the instance create permission', async () => { // Arrange jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); - mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse); // Act renderRuleDetails(grafanaRule); @@ -117,8 +127,6 @@ describe('RuleDetails RBAC', () => { }); it('Should render Silence button for users with the instance create permissions', async () => { - mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse); - // Arrange jest .spyOn(contextSrv, 'hasPermission') diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx index 070622f132c31..3e42e25f1465b 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx @@ -37,7 +37,7 @@ export const RuleDetails = ({ rule }: Props) => { return ( <div> - <RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} isViewMode={false} /> + <RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} /> <div className={styles.wrapper}> <div className={styles.leftSide}> {<EvaluationBehaviorSummary rule={rule} />} @@ -72,7 +72,7 @@ const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) => // recording rules don't have a for duration if (!isRecordingRulerRule(rule.rulerRule)) { - forDuration = rule.rulerRule?.for; + forDuration = rule.rulerRule?.for ?? '0s'; } return ( @@ -82,11 +82,10 @@ const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) => Every {every} </DetailsField> )} - {forDuration && ( - <DetailsField label="For" horizontal={true}> - {forDuration} - </DetailsField> - )} + + <DetailsField label="Pending period" horizontal={true}> + {forDuration} + </DetailsField> {lastEvaluation && !isNullDate(lastEvaluation) && ( <DetailsField label="Last evaluation" horizontal={true}> diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index 6719638200713..a9a6014380f77 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -1,22 +1,10 @@ import { css } from '@emotion/css'; import { uniqueId } from 'lodash'; import React, { Fragment, useState } from 'react'; -import { useLocation } from 'react-router-dom'; -import { GrafanaTheme2, textUtil, urlUtil } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { - Button, - ClipboardButton, - ConfirmModal, - Dropdown, - HorizontalGroup, - Icon, - LinkButton, - Menu, - useStyles2, -} from '@grafana/ui'; -import { useAppNotification } from 'app/core/copy/appNotification'; +import { GrafanaTheme2, textUtil } from '@grafana/data'; +import { config, useReturnToPrevious } from '@grafana/runtime'; +import { Button, ConfirmModal, Dropdown, HorizontalGroup, Icon, LinkButton, Menu, useStyles2 } from '@grafana/ui'; import { useDispatch } from 'app/types'; import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-alerting'; import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; @@ -36,7 +24,6 @@ import { } from '../../utils/misc'; import * as ruleId from '../../utils/rule-id'; import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; -import { createUrl } from '../../utils/url'; import { DeclareIncidentButton } from '../bridges/DeclareIncidentButton'; import { RedirectToCloneRule } from './CloneRule'; @@ -44,16 +31,15 @@ import { RedirectToCloneRule } from './CloneRule'; interface Props { rule: CombinedRule; rulesSource: RulesSource; - isViewMode: boolean; } -export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Props) => { +export const RuleDetailsActionButtons = ({ rule, rulesSource }: Props) => { const style = useStyles2(getStyles); - const { namespace, group, rulerRule } = rule; + const { group } = rule; const { StateHistoryModal, showStateHistoryModal } = useStateHistoryModal(); const dispatch = useDispatch(); - const location = useLocation(); - const notifyApp = useAppNotification(); + + const setReturnToPrevious = useReturnToPrevious(); const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>(); const [redirectToClone, setRedirectToClone] = useState< @@ -64,11 +50,8 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop ? rulesSource : getAlertmanagerByUid(rulesSource.jsonData.alertmanagerUid)?.name; - const [duplicateSupported, duplicateAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate); const [silenceSupported, silenceAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Silence); const [exploreSupported, exploreAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Explore); - const [deleteSupported, deleteAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete); - const [editSupported, editAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update); const buttons: JSX.Element[] = []; const rightButtons: JSX.Element[] = []; @@ -83,24 +66,19 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop ruleToDelete.rulerRule ); - dispatch(deleteRuleAction(identifier, { navigateTo: isViewMode ? '/alerting/list' : undefined })); + dispatch(deleteRuleAction(identifier, { navigateTo: undefined })); setRuleToDelete(undefined); } }; const isFederated = isFederatedRuleGroup(group); - const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); const isFiringRule = isAlertingRule(rule.promRule) && rule.promRule.state === PromAlertingRuleState.Firing; - const canDelete = deleteSupported && deleteAllowed; - const canEdit = editSupported && editAllowed; const canSilence = silenceSupported && silenceAllowed && alertmanagerSourceName; - const canDuplicateRule = duplicateSupported && duplicateAllowed && !isFederated; const buildShareUrl = () => createShareLink(rulesSource, rule); - const returnTo = location.pathname + location.search; // explore does not support grafana rule queries atm // neither do "federated rules" if (isCloudRulesSource(rulesSource) && exploreSupported && exploreAllowed && !isFederated) { @@ -133,6 +111,7 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop } if (rule.annotations[Annotation.dashboardUID]) { const dashboardUID = rule.annotations[Annotation.dashboardUID]; + const isReturnToPreviousEnabled = config.featureToggles.returnToPrevious; if (dashboardUID) { buttons.push( <LinkButton @@ -140,8 +119,11 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop key="dashboard" variant="primary" icon="apps" - target="_blank" + target={isReturnToPreviousEnabled ? undefined : '_blank'} href={`d/${encodeURIComponent(dashboardUID)}`} + onClick={() => { + setReturnToPrevious(rule.name); + }} > Go to dashboard </LinkButton> @@ -154,8 +136,11 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop key="panel" variant="primary" icon="apps" - target="_blank" + target={isReturnToPreviousEnabled ? undefined : '_blank'} href={`d/${encodeURIComponent(dashboardUID)}?viewPanel=${encodeURIComponent(panelId)}`} + onClick={() => { + setReturnToPrevious(rule.name); + }} > Go to panel </LinkButton> @@ -201,63 +186,6 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop ); } - if (isViewMode && rulerRule) { - const sourceName = getRulesSourceName(rulesSource); - const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule); - - if (canEdit) { - rightButtons.push( - <ClipboardButton - key="copy" - icon="copy" - onClipboardError={(copiedText) => { - notifyApp.error('Error while copying URL', copiedText); - }} - size="sm" - getText={buildShareUrl} - > - Copy link to rule - </ClipboardButton> - ); - - if (!isProvisioned) { - const editURL = urlUtil.renderUrl( - `${config.appSubUrl}/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`, - { - returnTo, - } - ); - - rightButtons.push( - <LinkButton size="sm" key="edit" variant="secondary" icon="pen" href={editURL}> - Edit - </LinkButton> - ); - } - } - - if (isGrafanaRulerRule(rulerRule)) { - const modifyUrl = createUrl( - `/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export` - ); - - moreActionsButtons.push(<Menu.Item label="Modify export" icon="edit" url={modifyUrl} />); - } - - if (canDuplicateRule) { - moreActionsButtons.push( - <Menu.Item label="Duplicate" icon="copy" onClick={() => setRedirectToClone({ identifier, isProvisioned })} /> - ); - } - - if (canDelete) { - moreActionsButtons.push(<Menu.Divider />); - moreActionsButtons.push( - <Menu.Item key="delete" label="Delete" icon="trash-alt" onClick={() => setRuleToDelete(rule)} /> - ); - } - } - if (buttons.length || rightButtons.length || moreActionsButtons.length) { return ( <> diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx index b2af9f915089f..fcc1a0ac7fb29 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx @@ -115,13 +115,11 @@ export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null { <div className={cx(styles.flexRow, styles.spaceBetween)}> <div className={styles.flexRow}> <MatcherFilter - className={styles.rowChild} key={queryStringKey} defaultQueryString={queryString} onFilterChange={(value) => setQueryString(value)} /> <AlertInstanceStateFilter - className={styles.rowChild} filterType={stateFilterType} stateFilter={alertState} onStateFilterChange={setAlertState} @@ -164,13 +162,11 @@ const getStyles = (theme: GrafanaTheme2) => { width: 100%; flex-wrap: wrap; margin-bottom: ${theme.spacing(1)}; + gap: ${theme.spacing(1)}; `, spaceBetween: css` justify-content: space-between; `, - rowChild: css` - margin-right: ${theme.spacing(1)}; - `, footerRow: css` display: flex; flex-direction: column; diff --git a/public/app/features/alerting/unified/components/rules/RuleHealth.tsx b/public/app/features/alerting/unified/components/rules/RuleHealth.tsx index 8016e5f255c74..66e4e33919971 100644 --- a/public/app/features/alerting/unified/components/rules/RuleHealth.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleHealth.tsx @@ -5,6 +5,8 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Icon, Tooltip, useStyles2 } from '@grafana/ui'; import { Rule } from 'app/types/unified-alerting'; +import { isErrorHealth } from '../rule-viewer/RuleViewer'; + interface Prom { rule: Rule; } @@ -12,7 +14,7 @@ interface Prom { export const RuleHealth = ({ rule }: Prom) => { const style = useStyles2(getStyle); - if (rule.health === 'err' || rule.health === 'error') { + if (isErrorHealth(rule.health)) { return ( <Tooltip theme="error" content={rule.lastError || 'No error message provided.'}> <div className={style.warn}> diff --git a/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx b/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx index c595014ac9693..16da99ee314c4 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx @@ -53,7 +53,7 @@ export function RuleListErrors(): ReactElement { result.push( <> Failed to load the data source configuration for{' '} - <a href={makeDataSourceLink(dataSource)} className={styles.dsLink}> + <a href={makeDataSourceLink(dataSource.uid)} className={styles.dsLink}> {dataSource.name} </a> : {error.message || 'Unknown error.'} @@ -65,7 +65,7 @@ export function RuleListErrors(): ReactElement { result.push( <> Failed to load rules state from{' '} - <a href={makeDataSourceLink(dataSource)} className={styles.dsLink}> + <a href={makeDataSourceLink(dataSource.uid)} className={styles.dsLink}> {dataSource.name} </a> : {error.message || 'Unknown error.'} @@ -77,7 +77,7 @@ export function RuleListErrors(): ReactElement { result.push( <> Failed to load rules config from{' '} - <a href={makeDataSourceLink(dataSource)} className={styles.dsLink}> + <a href={makeDataSourceLink(dataSource.uid)} className={styles.dsLink}> {dataSource.name} </a> : {error.message || 'Unknown error.'} diff --git a/public/app/features/alerting/unified/components/rules/RulesFilter.tsx b/public/app/features/alerting/unified/components/rules/RulesFilter.tsx index d84b3c1804e6e..2f77f19184ac9 100644 --- a/public/app/features/alerting/unified/components/rules/RulesFilter.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesFilter.tsx @@ -3,11 +3,18 @@ import React, { useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { Button, Field, Icon, Input, Label, RadioButtonGroup, Tooltip, useStyles2, Stack } from '@grafana/ui'; +import { Button, Field, Icon, Input, Label, RadioButtonGroup, Stack, Tooltip, useStyles2 } from '@grafana/ui'; +import { DashboardPicker } from 'app/core/components/Select/DashboardPicker'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; -import { logInfo, LogMessages } from '../../Analytics'; +import { + logInfo, + LogMessages, + trackRulesListViewChange, + trackRulesSearchComponentInteraction, + trackRulesSearchInputInteraction, +} from '../../Analytics'; import { useRulesFilter } from '../../hooks/useFilteredRules'; import { RuleHealth } from '../../search/rulesSearchParser'; import { alertStateToReadable } from '../../utils/rules'; @@ -89,6 +96,12 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) => }); setFilterKey((key) => key + 1); + trackRulesSearchComponentInteraction('dataSourceNames'); + }; + + const handleDashboardChange = (dashboardUid: string | undefined) => { + updateFilters({ ...filterState, dashboardUid }); + trackRulesSearchComponentInteraction('dashboardUid'); }; const clearDataSource = () => { @@ -99,21 +112,17 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) => const handleAlertStateChange = (value: PromAlertingRuleState) => { logInfo(LogMessages.clickingAlertStateFilters); updateFilters({ ...filterState, ruleState: value }); - setFilterKey((key) => key + 1); - }; - - const handleViewChange = (view: string) => { - setQueryParams({ view }); + trackRulesSearchComponentInteraction('ruleState'); }; const handleRuleTypeChange = (ruleType: PromRuleType) => { updateFilters({ ...filterState, ruleType }); - setFilterKey((key) => key + 1); + trackRulesSearchComponentInteraction('ruleType'); }; const handleRuleHealthChange = (ruleHealth: RuleHealth) => { updateFilters({ ...filterState, ruleHealth }); - setFilterKey((key) => key + 1); + trackRulesSearchComponentInteraction('ruleHealth'); }; const handleClearFiltersClick = () => { @@ -123,11 +132,16 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) => setTimeout(() => setFilterKey(filterKey + 1), 100); }; + const handleViewChange = (view: string) => { + setQueryParams({ view }); + trackRulesListViewChange({ view }); + }; + const searchIcon = <Icon name={'search'} />; return ( <div className={styles.container}> <Stack direction="column" gap={1}> - <Stack direction="row" gap={1}> + <Stack direction="row" gap={1} wrap="wrap"> <Field className={styles.dsPickerContainer} label={ @@ -148,7 +162,7 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) => </div> } > - <Icon name="info-circle" size="sm" /> + <Icon id="data-source-picker-inline-help" name="info-circle" size="sm" /> </Tooltip> </Stack> </Label> @@ -165,6 +179,22 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) => /> </Field> + <Field + className={styles.dashboardPickerContainer} + label={<Label htmlFor="filters-dashboard-picker">Dashboard</Label>} + > + {/* The key prop is to clear the picker value */} + {/* DashboardPicker doesn't do that itself when value is undefined */} + <DashboardPicker + inputId="filters-dashboard-picker" + key={filterState.dashboardUid ? 'dashboard-defined' : 'dashboard-not-defined'} + value={filterState.dashboardUid} + onChange={(value) => handleDashboardChange(value?.uid)} + isClearable + cacheOptions + /> + </Field> + <div> <Label>State</Label> <RadioButtonGroup @@ -193,6 +223,7 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) => onSubmit={handleSubmit((data) => { setSearchQuery(data.searchQuery); searchQueryRef.current?.blur(); + trackRulesSearchInputInteraction({ oldQuery: searchQuery, newQuery: data.searchQuery }); })} > <Field @@ -246,18 +277,21 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) => const getStyles = (theme: GrafanaTheme2) => { return { - container: css` - margin-bottom: ${theme.spacing(1)}; - `, - dsPickerContainer: css` - width: 550px; - flex-grow: 0; - margin: 0; - `, - searchInput: css` - flex: 1; - margin: 0; - `, + container: css({ + marginBottom: theme.spacing(1), + }), + dsPickerContainer: css({ + width: theme.spacing(60), + flexGrow: 0, + margin: 0, + }), + dashboardPickerContainer: css({ + minWidth: theme.spacing(50), + }), + searchInput: css({ + flex: 1, + margin: 0, + }), }; }; @@ -279,6 +313,7 @@ function SearchQueryHelp() { <HelpRow title="State" expr="state:firing|normal|pending" /> <HelpRow title="Type" expr="type:alerting|recording" /> <HelpRow title="Health" expr="health:ok|nodata|error" /> + <HelpRow title="Dashboard UID" expr="dashboard:eadde4c7-54e6-4964-85c0-484ab852fd04" /> </div> </div> ); @@ -296,16 +331,16 @@ function HelpRow({ title, expr }: { title: string; expr: string }) { } const helpStyles = (theme: GrafanaTheme2) => ({ - grid: css` - display: grid; - grid-template-columns: max-content auto; - gap: ${theme.spacing(1)}; - align-items: center; - `, - code: css` - display: block; - text-align: center; - `, + grid: css({ + display: 'grid', + gridTemplateColumns: 'max-content auto', + gap: theme.spacing(1), + alignItems: 'center', + }), + code: css({ + display: 'block', + textAlign: 'center', + }), }); export default RulesFilter; diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx index 33f1411e3d122..3093caf579c37 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; -import { AutoSizerProps } from 'react-virtualized-auto-sizer'; +import { Props } from 'react-virtualized-auto-sizer'; import { byRole, byTestId, byText } from 'testing-library-selector'; import { contextSrv } from 'app/core/services/context_srv'; @@ -22,7 +22,13 @@ jest.mock('../../hooks/useHasRuler'); jest.spyOn(analytics, 'logInfo'); jest.mock('react-virtualized-auto-sizer', () => { - return ({ children }: AutoSizerProps) => children({ height: 600, width: 1 }); + return ({ children }: Props) => + children({ + height: 600, + scaledHeight: 600, + scaledWidth: 1, + width: 1, + }); }); jest.mock('@grafana/ui', () => ({ ...jest.requireActual('@grafana/ui'), @@ -102,10 +108,12 @@ describe('Rules group tests', () => { groups: [group], }; - it('Should hide delete and edit group buttons', () => { + it('Should hide delete and edit group buttons', async () => { // Act mockUseHasRuler(true, true); + mockFolderApi(server).folder('cpu-usage', mockFolder({ uid: 'cpu-usage', canSave: false })); renderRulesGroup(namespace, group); + expect(await screen.findByTestId('rule-group')).toBeInTheDocument(); // Assert expect(ui.deleteGroupButton.query()).not.toBeInTheDocument(); diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index 0cf3aa15f8a4a..fd6072e359fa9 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -3,11 +3,11 @@ import pluralize from 'pluralize'; import React, { useEffect, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Badge, ConfirmModal, HorizontalGroup, Icon, Spinner, Tooltip, useStyles2, Stack } from '@grafana/ui'; +import { Badge, ConfirmModal, HorizontalGroup, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui'; import { useDispatch } from 'app/types'; import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; -import { logInfo, LogMessages } from '../../Analytics'; +import { LogMessages, logInfo } from '../../Analytics'; import { useFolder } from '../../hooks/useFolder'; import { useHasRuler } from '../../hooks/useHasRuler'; import { deleteRulesGroupAction } from '../../state/actions'; @@ -19,6 +19,7 @@ import { CollapseToggle } from '../CollapseToggle'; import { RuleLocation } from '../RuleLocation'; import { GrafanaRuleFolderExporter } from '../export/GrafanaRuleFolderExporter'; import { GrafanaRuleGroupExporter } from '../export/GrafanaRuleGroupExporter'; +import { decodeGrafanaNamespace } from '../expressions/util'; import { ActionIcon } from './ActionIcon'; import { EditCloudGroupModal } from './EditRuleGroupModal'; @@ -204,9 +205,9 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: // ungrouped rules are rules that are in the "default" group name const groupName = isListView ? ( - <RuleLocation namespace={namespace.name} /> + <RuleLocation namespace={decodeGrafanaNamespace(namespace).name} /> ) : ( - <RuleLocation namespace={namespace.name} group={group.name} /> + <RuleLocation namespace={decodeGrafanaNamespace(namespace).name} group={group.name} /> ); const closeEditModal = (saved = false) => { @@ -278,10 +279,16 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: group={group} onClose={() => closeEditModal()} folderUrl={folder?.canEdit ? makeFolderSettingsLink(folder) : undefined} + folderUid={folderUID} /> )} {isReorderingGroup && ( - <ReorderCloudGroupModal group={group} namespace={namespace} onClose={() => setIsReorderingGroup(false)} /> + <ReorderCloudGroupModal + group={group} + folderUid={folderUID} + namespace={namespace} + onClose={() => setIsReorderingGroup(false)} + /> )} <ConfirmModal isOpen={isDeletingGroup} diff --git a/public/app/features/alerting/unified/components/rules/state-history/LogTimelineViewer.tsx b/public/app/features/alerting/unified/components/rules/state-history/LogTimelineViewer.tsx index b0d2d286b50ca..f0e2d72fc3b3e 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/LogTimelineViewer.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/LogTimelineViewer.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useRef } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { BehaviorSubject } from 'rxjs'; -import { DataFrame, TimeRange } from '@grafana/data'; +import { DataFrame, InterpolateFunction, TimeRange } from '@grafana/data'; import { VisibilityMode } from '@grafana/schema'; import { LegendDisplayMode, UPlotConfigBuilder, useTheme2 } from '@grafana/ui'; import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart'; @@ -15,6 +15,9 @@ interface LogTimelineViewerProps { onPointerMove?: (seriesIdx: number, pointerIdx: number) => void; } +// noop +const replaceVariables: InterpolateFunction = (v) => v; + export const LogTimelineViewer = React.memo(({ frames, timeRange, onPointerMove = noop }: LogTimelineViewerProps) => { const theme = useTheme2(); const { setupCursorTracking } = useCursorTimelinePosition(onPointerMove); @@ -45,6 +48,7 @@ export const LogTimelineViewer = React.memo(({ frames, timeRange, onPointerMove { label: 'NoData', color: theme.colors.info.main, yAxis: 1 }, { label: 'Mixed', color: theme.colors.text.secondary, yAxis: 1 }, ]} + replaceVariables={replaceVariables} > {(builder) => { setupCursorTracking(builder); diff --git a/public/app/features/alerting/unified/components/rules/state-history/LokiStateHistory.test.tsx b/public/app/features/alerting/unified/components/rules/state-history/LokiStateHistory.test.tsx index 24380f45f3fba..3925cab0c315a 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/LokiStateHistory.test.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/LokiStateHistory.test.tsx @@ -1,9 +1,10 @@ +import 'whatwg-fetch'; import { render, waitFor } from '@testing-library/react'; -import { rest } from 'msw'; +import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import React from 'react'; -import { byRole, byText } from 'testing-library-selector'; -import 'whatwg-fetch'; +import { Props } from 'react-virtualized-auto-sizer'; +import { byRole, byTestId, byText } from 'testing-library-selector'; import { DataFrameJSON } from '@grafana/data'; import { setBackendSrv } from '@grafana/runtime'; @@ -15,58 +16,66 @@ import LokiStateHistory from './LokiStateHistory'; const server = setupServer(); +jest.mock('react-virtualized-auto-sizer', () => { + return ({ children }: Props) => + children({ + height: 600, + scaledHeight: 600, + scaledWidth: 1, + width: 1, + }); +}); + beforeAll(() => { setBackendSrv(backendSrv); server.listen({ onUnhandledRequest: 'error' }); server.use( - rest.get('/api/v1/rules/history', (req, res, ctx) => - res( - ctx.json<DataFrameJSON>({ - data: { - values: [ - [1681739580000, 1681739580000, 1681739580000], - [ - { - previous: 'Normal', - current: 'Pending', - values: { - B: 0.010344684900897919, - C: 1, - }, - labels: { - handler: '/api/prometheus/grafana/api/v1/rules', - }, + http.get('/api/v1/rules/history', () => + HttpResponse.json<DataFrameJSON>({ + data: { + values: [ + [1681739580000, 1681739580000, 1681739580000], + [ + { + previous: 'Normal', + current: 'Pending', + values: { + B: 0.010344684900897919, + C: 1, + }, + labels: { + handler: '/api/prometheus/grafana/api/v1/rules', + }, + }, + { + previous: 'Normal', + current: 'Pending', + values: { + B: 0.010344684900897919, + C: 1, + }, + dashboardUID: '', + panelID: 0, + labels: { + handler: '/api/live/ws', }, - { - previous: 'Normal', - current: 'Pending', - values: { - B: 0.010344684900897919, - C: 1, - }, - dashboardUID: '', - panelID: 0, - labels: { - handler: '/api/live/ws', - }, + }, + { + previous: 'Normal', + current: 'Pending', + values: { + B: 0.010344684900897919, + C: 1, }, - { - previous: 'Normal', - current: 'Pending', - values: { - B: 0.010344684900897919, - C: 1, - }, - labels: { - handler: '/api/folders/:uid/', - }, + labels: { + handler: '/api/folders/:uid/', }, - ], + }, ], - }, - }) - ) + ], + }, + }) ) ); }); @@ -82,6 +91,7 @@ const ui = { timestampViewer: byRole('list', { name: 'State history by timestamp' }), record: byRole('listitem'), noRecords: byText('No state transitions have occurred in the last 30 days'), + timelineChart: byTestId('uplot-main-div'), }; describe('LokiStateHistory', () => { @@ -98,10 +108,18 @@ describe('LokiStateHistory', () => { expect(timestampViewerElement).toHaveTextContent('/api/folders/:uid/'); }); + it('should render timeline chart', async () => { + render(<LokiStateHistory ruleUID="ABC123" />, { wrapper: TestProvider }); + + await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument()); + + expect(ui.timelineChart.get()).toBeInTheDocument(); + }); + it('should render no entries message when no records are returned', async () => { server.use( - rest.get('/api/v1/rules/history', (req, res, ctx) => - res(ctx.json<DataFrameJSON>({ data: { values: [] }, schema: { fields: [] } })) + http.get('/api/v1/rules/history', () => + HttpResponse.json<DataFrameJSON>({ data: { values: [] }, schema: { fields: [] } }) ) ); diff --git a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx index 5b33f85197bf3..3d8c21c4b8313 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx @@ -146,15 +146,14 @@ const useFilteredSilences = (silences: Silence[], expired = false) => { } if (queryString) { const matchers = parseMatchers(queryString); - const matchersMatch = matchers.every( - (matcher) => - silence.matchers?.some( - ({ name, value, isEqual, isRegex }) => - matcher.name === name && - matcher.value === value && - matcher.isEqual === isEqual && - matcher.isRegex === isRegex - ) + const matchersMatch = matchers.every((matcher) => + silence.matchers?.some( + ({ name, value, isEqual, isRegex }) => + matcher.name === name && + matcher.value === value && + matcher.isEqual === isEqual && + matcher.isRegex === isRegex + ) ); if (!matchersMatch) { return false; diff --git a/public/app/features/alerting/unified/home/GettingStarted.tsx b/public/app/features/alerting/unified/home/GettingStarted.tsx index bc06edb7e9f4d..6415801a0585e 100644 --- a/public/app/features/alerting/unified/home/GettingStarted.tsx +++ b/public/app/features/alerting/unified/home/GettingStarted.tsx @@ -4,7 +4,7 @@ import SVG from 'react-inlinesvg'; import { GrafanaTheme2 } from '@grafana/data'; import { EmbeddedScene, SceneFlexLayout, SceneFlexItem, SceneReactObject } from '@grafana/scenes'; -import { Icon, useStyles2, useTheme2, Stack } from '@grafana/ui'; +import { useStyles2, useTheme2, Stack, Text, TextLink } from '@grafana/ui'; export const getOverviewScene = () => { return new EmbeddedScene({ @@ -20,16 +20,15 @@ export const getOverviewScene = () => { }); }; -export default function GettingStarted({ showWelcomeHeader }: { showWelcomeHeader?: boolean }) { +export default function GettingStarted() { const theme = useTheme2(); const styles = useStyles2(getWelcomePageStyles); return ( <div className={styles.grid}> - {showWelcomeHeader && <WelcomeHeader className={styles.ctaContainer} />} - <ContentBox className={styles.flowBlock}> - <div> - <h3>How it works</h3> + <ContentBox> + <Stack direction="column" gap={1}> + <Text element="h3">How it works</Text> <ul className={styles.list}> <li> Grafana alerting periodically queries data sources and evaluates the condition defined in the alert rule @@ -38,107 +37,73 @@ export default function GettingStarted({ showWelcomeHeader }: { showWelcomeHeade <li>Firing instances are routed to notification policies based on matching labels</li> <li>Notifications are sent out to the contact points specified in the notification policy</li> </ul> - </div> - <SVG - src={`public/img/alerting/at_a_glance_${theme.name.toLowerCase()}.svg`} - width={undefined} - height={undefined} - /> + <div className={styles.svgContainer}> + <Stack justifyContent={'center'}> + <SVG + src={`public/img/alerting/at_a_glance_${theme.name.toLowerCase()}.svg`} + width={undefined} + height={undefined} + /> + </Stack> + </div> + </Stack> </ContentBox> - <ContentBox className={styles.gettingStartedBlock}> - <h3>Get started</h3> - <Stack direction="column"> + <ContentBox> + <Stack direction="column" gap={1}> + <Text element="h3">Get started</Text> <ul className={styles.list}> <li> - <strong>Create an alert rule</strong> by adding queries and expressions from multiple data sources. + <Text weight="bold">Create an alert rule</Text> by adding queries and expressions from multiple data + sources. </li> <li> - <strong>Add labels</strong> to your alert rules <strong>to connect them to notification policies</strong> + <Text weight="bold">Add labels</Text> to your alert rules{' '} + <Text weight="bold">to connect them to notification policies</Text> </li> <li> - <strong>Configure contact points</strong> to define where to send your notifications to. + <Text weight="bold">Configure contact points</Text> to define where to send your notifications to. </li> <li> - <strong>Configure notification policies</strong> to route your alert instances to contact points. + <Text weight="bold">Configure notification policies</Text> to route your alert instances to contact + points. </li> </ul> - <div> - <ArrowLink href="https://grafana.com/docs/grafana/latest/alerting/" title="Read more in the Docs" /> - </div> + <TextLink href="https://grafana.com/docs/grafana/latest/alerting/" icon="angle-right" inline={false} external> + Read more in the docs + </TextLink> </Stack> </ContentBox> - <ContentBox className={styles.videoBlock}> - <iframe - title="Alerting - Introductory video" - src="https://player.vimeo.com/video/720001629?h=c6c1732f92" - width="960" - height="540" - allow="autoplay; fullscreen" - allowFullScreen - frameBorder="0" - // This is necessary because color-scheme defined on :root has impact on iframe elements - // More about how color-scheme works for iframes https://github.com/w3c/csswg-drafts/issues/4772 - // Summary: If the color scheme of an iframe differs from embedding document iframe gets an opaque canvas bg appropriate to its color scheme - style={{ colorScheme: 'light dark' }} - ></iframe> - </ContentBox> </div> ); } const getWelcomePageStyles = (theme: GrafanaTheme2) => ({ - grid: css` - display: grid; - grid-template-rows: min-content auto auto; - grid-template-columns: 1fr 1fr 1fr 1fr 1fr; - gap: ${theme.spacing(2)}; - width: 100%; - `, - ctaContainer: css` - grid-column: 1 / span 5; - `, - flowBlock: css` - grid-column: 1 / span 5; - - display: flex; - flex-wrap: wrap; - gap: ${theme.spacing(1)}; - - & > div { - flex: 2; - min-width: 350px; - } - & > svg { - flex: 3; - min-width: 500px; - } - `, - videoBlock: css` - grid-column: 3 / span 3; - - // Video required - position: relative; - padding: 56.25% 0 0 0; /* 16:9 */ - - iframe { - position: absolute; - top: ${theme.spacing(2)}; - left: ${theme.spacing(2)}; - width: calc(100% - ${theme.spacing(4)}); - height: calc(100% - ${theme.spacing(4)}); - border: none; - } - `, - gettingStartedBlock: css` - grid-column: 1 / span 2; - justify-content: space-between; - `, - list: css` - margin: ${theme.spacing(0, 2)}; - & > li { - margin-bottom: ${theme.spacing(1)}; - } - `, + grid: css({ + display: 'grid', + gridTemplateRows: 'min-content auto auto', + gridTemplateColumns: '1fr', + gap: theme.spacing(2), + width: '100%', + + [theme.breakpoints.up('lg')]: { + gridTemplateColumns: '3fr 2fr', + }, + }), + ctaContainer: css({ + gridColumn: '1 / span 5', + }), + svgContainer: css({ + '& svg': { + maxWidth: '900px', + flex: 1, + }, + }), + list: css({ + margin: theme.spacing(0, 2), + '& > li': { + marginBottom: theme.spacing(1), + }, + }), }); export function WelcomeHeader({ className }: { className?: string }) { @@ -182,26 +147,25 @@ const getWelcomeHeaderStyles = (theme: GrafanaTheme2) => ({ color: theme.colors.text.secondary, paddingBottom: theme.spacing(2), }), - ctaContainer: css` - padding: ${theme.spacing(4, 2)}; - display: flex; - gap: ${theme.spacing(4)}; - justify-content: space-between; - flex-wrap: wrap; - - ${theme.breakpoints.down('lg')} { - flex-direction: column; - } - `, - - separator: css` - width: 1px; - background-color: ${theme.colors.border.medium}; + ctaContainer: css({ + padding: theme.spacing(2), + display: 'flex', + gap: theme.spacing(4), + justifyContent: 'space-between', + flexWrap: 'wrap', + + [theme.breakpoints.down('lg')]: { + flexDirection: 'column', + }, + }), + separator: css({ + width: '1px', + backgroundColor: theme.colors.border.medium, - ${theme.breakpoints.down('lg')} { - display: none; - } - `, + [theme.breakpoints.down('lg')]: { + display: 'none', + }, + }), }); interface WelcomeCTABoxProps { @@ -216,47 +180,45 @@ function WelcomeCTABox({ title, description, href, hrefText }: WelcomeCTABoxProp return ( <div className={styles.container}> - <h3 className={styles.title}>{title}</h3> + <Text element="h2" variant="h3"> + {title} + </Text> <div className={styles.desc}>{description}</div> <div className={styles.actionRow}> - <a href={href} className={styles.link}> + <TextLink href={href} inline={false}> {hrefText} - </a> + </TextLink> </div> </div> ); } const getWelcomeCTAButtonStyles = (theme: GrafanaTheme2) => ({ - container: css` - flex: 1; - min-width: 240px; - display: grid; - gap: ${theme.spacing(1)}; - grid-template-columns: min-content 1fr 1fr 1fr; - grid-template-rows: min-content auto min-content; - `, - - title: css` - margin-bottom: 0; - grid-column: 2 / span 3; - grid-row: 1; - `, - - desc: css` - grid-column: 2 / span 3; - grid-row: 2; - `, + container: css({ + flex: 1, + minWidth: '240px', + display: 'grid', + rowGap: theme.spacing(1), + gridTemplateColumns: 'min-content 1fr 1fr 1fr', + gridTemplateRows: 'min-content auto min-content', + + '& h2': { + marginBottom: 0, + gridColumn: '2 / span 3', + gridRow: 1, + }, + }), - actionRow: css` - grid-column: 2 / span 3; - grid-row: 3; - max-width: 240px; - `, + desc: css({ + gridColumn: '2 / span 3', + gridRow: 2, + }), - link: css` - color: ${theme.colors.text.link}; - `, + actionRow: css({ + gridColumn: '2 / span 3', + gridRow: 3, + maxWidth: '240px', + }), }); function ContentBox({ children, className }: React.PropsWithChildren<{ className?: string }>) { @@ -266,26 +228,9 @@ function ContentBox({ children, className }: React.PropsWithChildren<{ className } const getContentBoxStyles = (theme: GrafanaTheme2) => ({ - box: css` - padding: ${theme.spacing(2)}; - background-color: ${theme.colors.background.secondary}; - border-radius: ${theme.shape.radius.default}; - `, -}); - -function ArrowLink({ href, title }: { href: string; title: string }) { - const styles = useStyles2(getArrowLinkStyles); - - return ( - <a href={href} className={styles.link} rel="noreferrer"> - {title} <Icon name="angle-right" size="xl" /> - </a> - ); -} - -const getArrowLinkStyles = (theme: GrafanaTheme2) => ({ - link: css` - display: block; - color: ${theme.colors.text.link}; - `, + box: css({ + padding: theme.spacing(2), + backgroundColor: theme.colors.background.secondary, + borderRadius: theme.shape.radius.default, + }), }); diff --git a/public/app/features/alerting/unified/home/Insights.tsx b/public/app/features/alerting/unified/home/Insights.tsx index 44f70e04703af..29fed164d73f2 100644 --- a/public/app/features/alerting/unified/home/Insights.tsx +++ b/public/app/features/alerting/unified/home/Insights.tsx @@ -20,9 +20,9 @@ import { import { config } from '../../../../core/config'; import { SectionFooter } from '../insights/SectionFooter'; import { SectionSubheader } from '../insights/SectionSubheader'; +import { getActiveGrafanaAlertsScene } from '../insights/grafana/Active'; import { getGrafanaInstancesByStateScene } from '../insights/grafana/AlertsByStateScene'; import { getGrafanaEvalSuccessVsFailuresScene } from '../insights/grafana/EvalSuccessVsFailuresScene'; -import { getFiringGrafanaAlertsScene } from '../insights/grafana/Firing'; import { getInstanceStatByStatusScene } from '../insights/grafana/InstanceStatusScene'; import { getGrafanaMissedIterationsScene } from '../insights/grafana/MissedIterationsScene'; import { getMostFiredInstancesScene } from '../insights/grafana/MostFiredInstancesTable'; @@ -192,7 +192,7 @@ function getGrafanaManagedScenes() { new SceneFlexLayout({ children: [ getMostFiredInstancesScene(ashDs, 'Top 10 firing instances'), - getFiringGrafanaAlertsScene(cloudUsageDs, 'Firing rules'), + getActiveGrafanaAlertsScene(cloudUsageDs, 'Active rules'), getPausedGrafanaAlertsScene(cloudUsageDs, 'Paused rules'), ], }), diff --git a/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap b/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap index 98f40f5cac593..2b0417df56542 100644 --- a/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap +++ b/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap @@ -1,5 +1,71 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`AlertRule abilities should report no permissions while we are loading data for cloud rule 1`] = ` +{ + "delete-alert-rule": [ + false, + false, + ], + "duplicate-alert-rule": [ + false, + false, + ], + "explore-alert-rule": [ + true, + false, + ], + "modify-export-rule": [ + false, + false, + ], + "silence-alert-rule": [ + false, + false, + ], + "update-alert-rule": [ + false, + false, + ], + "view-alert-rule": [ + true, + false, + ], +} +`; + +exports[`AlertRule abilities should report that all actions are supported for a Grafana Managed alert rule 1`] = ` +{ + "delete-alert-rule": [ + true, + false, + ], + "duplicate-alert-rule": [ + true, + false, + ], + "explore-alert-rule": [ + true, + false, + ], + "modify-export-rule": [ + true, + false, + ], + "silence-alert-rule": [ + true, + false, + ], + "update-alert-rule": [ + true, + false, + ], + "view-alert-rule": [ + true, + false, + ], +} +`; + exports[`alertmanager abilities should report Create / Update / Delete actions aren't supported for external vanilla alertmanager 1`] = ` { "create-contact-point": [ @@ -78,6 +144,10 @@ exports[`alertmanager abilities should report Create / Update / Delete actions a true, false, ], + "view-autogenerated-policy-tree": [ + false, + false, + ], "view-contact-point": [ true, false, @@ -183,6 +253,10 @@ exports[`alertmanager abilities should report everything except exporting for Mi true, true, ], + "view-autogenerated-policy-tree": [ + false, + false, + ], "view-contact-point": [ true, true, @@ -288,6 +362,10 @@ exports[`alertmanager abilities should report everything is supported for builti true, false, ], + "view-autogenerated-policy-tree": [ + true, + false, + ], "view-contact-point": [ true, true, diff --git a/public/app/features/alerting/unified/hooks/useAbilities.test.tsx b/public/app/features/alerting/unified/hooks/useAbilities.test.tsx index 8d3e2ba8a5bff..b0f12275dd4cd 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.test.tsx +++ b/public/app/features/alerting/unified/hooks/useAbilities.test.tsx @@ -7,7 +7,7 @@ import { TestProvider } from 'test/helpers/TestProvider'; import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; -import { getGrafanaRule, grantUserPermissions, mockDataSource } from '../mocks'; +import { getCloudRule, getGrafanaRule, grantUserPermissions, mockDataSource } from '../mocks'; import { AlertmanagerProvider } from '../state/AlertmanagerContext'; import { setupDataSources } from '../testSetup/datasources'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; @@ -136,7 +136,7 @@ describe('alertmanager abilities', () => { }); }); -describe('rule permissions', () => { +describe('AlertRule abilities', () => { it('should report that all actions are supported for a Grafana Managed alert rule', async () => { const rule = getGrafanaRule(); @@ -149,9 +149,21 @@ describe('rule permissions', () => { expect(supported).toBe(true); } }); + + expect(abilities.result.current).toMatchSnapshot(); }); - it('should report the correct set of supported actions for an external rule with ruler API', async () => {}); + it('should report no permissions while we are loading data for cloud rule', async () => { + const rule = getCloudRule(); + + const abilities = renderHook(() => useAllAlertRuleAbilities(rule), { wrapper: TestProvider }); + + await waitFor(() => { + expect(abilities.result.current).not.toBeUndefined(); + }); + + expect(abilities.result.current).toMatchSnapshot(); + }); it('should not allow certain actions for provisioned rules', () => {}); diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index 0638fc4b2bb9b..9906553bf7b42 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { contextSrv as ctx } from 'app/core/services/context_srv'; +import { contextSrv, contextSrv as ctx } from 'app/core/services/context_srv'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; @@ -45,6 +45,7 @@ export enum AlertmanagerAction { UpdateNotificationPolicyTree = 'update-notification-policy-tree', DeleteNotificationPolicy = 'delete-notification-policy', ExportNotificationPolicies = 'export-notification-policies', + ViewAutogeneratedPolicyTree = 'view-autogenerated-policy-tree', // silences – these cannot be deleted only "expired" (updated) CreateSilence = 'create-silence', @@ -145,11 +146,11 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities<AlertRul const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); const isFederated = isFederatedRuleGroup(rule.group); + const isGrafanaManagedAlertRule = isGrafanaRulerRule(rule.rulerRule); // if a rule is either provisioned or a federated rule, we don't allow it to be removed or edited const immutableRule = isProvisioned || isFederated; - // TODO refactor this hook maybe const { isEditable, isRemovable, @@ -172,7 +173,7 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities<AlertRul [AlertRuleAction.Delete]: [MaybeSupportedUnlessImmutable, isRemovable ?? false], [AlertRuleAction.Explore]: toAbility(AlwaysSupported, AccessControlAction.DataSourcesExplore), [AlertRuleAction.Silence]: canSilence, - [AlertRuleAction.ModifyExport]: [MaybeSupported, exportAllowed], + [AlertRuleAction.ModifyExport]: [isGrafanaManagedAlertRule, exportAllowed], }; return abilities; @@ -190,6 +191,9 @@ export function useAllAlertmanagerAbilities(): Abilities<AlertmanagerAction> { const notificationsPermissions = getNotificationsPermissions(selectedAlertmanager!); const instancePermissions = getInstancesPermissions(selectedAlertmanager!); + //we need to know user role to determine if they can view autogenerated policy tree + const isAdmin = contextSrv.hasRole('Admin') || contextSrv.isGrafanaAdmin; + // list out all of the abilities, and if the user has permissions to perform them const abilities: Abilities<AlertmanagerAction> = { // -- configuration -- @@ -226,6 +230,7 @@ export function useAllAlertmanagerAbilities(): Abilities<AlertmanagerAction> { isGrafanaFlavoredAlertmanager, notificationsPermissions.provisioning.readSecrets ), + [AlertmanagerAction.ViewAutogeneratedPolicyTree]: [isGrafanaFlavoredAlertmanager, isAdmin], // -- silences -- // for now, all supported Alertmanager flavors have API endpoints for managing silences [AlertmanagerAction.CreateSilence]: toAbility(AlwaysSupported, instancePermissions.create), diff --git a/public/app/features/alerting/unified/hooks/useCombinedRule.ts b/public/app/features/alerting/unified/hooks/useCombinedRule.ts index 21c64a2e4b5d6..b3070d219049d 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRule.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRule.ts @@ -67,7 +67,7 @@ export function useCloudCombinedRulesMatching( const { dsFeatures, isLoadingDsFeatures } = useDataSourceFeatures(ruleSourceName); const { - currentData: promRuleNs = [], + currentData, isLoading: isLoadingPromRules, error: promRuleNsError, } = alertRuleApi.endpoints.prometheusRuleNamespaces.useQuery({ @@ -87,6 +87,7 @@ export function useCloudCombinedRulesMatching( if (promRuleNsError) { throw new Error('Unable to obtain Prometheus rules'); } + const promRuleNs = currentData || []; const rulerGroups: RulerRuleGroupDTO[] = []; if (dsFeatures?.rulerConfig) { @@ -114,7 +115,7 @@ export function useCloudCombinedRulesMatching( const rules = namespaces.flatMap((ns) => ns.groups.flatMap((group) => group.rules)); return rules; - }, [dsSettings, dsFeatures, isLoadingPromRules, promRuleNsError, promRuleNs, fetchRulerRuleGroup]); + }, [dsSettings, dsFeatures, isLoadingPromRules, promRuleNsError, currentData, fetchRulerRuleGroup]); return { loading: isLoadingDsFeatures || loading, error: error, rules: value }; } diff --git a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts index 7297e8a83fa6d..18fc75e3755ac 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts @@ -91,6 +91,13 @@ export function useCombinedRuleNamespaces( name: namespaceName, groups: [], }; + + // We need to set the namespace_uid for grafana rules as it's required to obtain the rule's groups + // All rules from all groups have the same namespace_uid so we're taking the first one. + if (isGrafanaRulerRule(groups[0].rules[0])) { + namespace.uid = groups[0].rules[0].grafana_alert.namespace_uid; + } + namespaces[namespaceName] = namespace; addRulerGroupsToCombinedNamespace(namespace, groups); }); @@ -366,28 +373,28 @@ function rulerRuleToCombinedRule( filteredInstanceTotals: {}, } : isRecordingRulerRule(rule) - ? { - name: rule.record, - query: rule.expr, - labels: rule.labels || {}, - annotations: {}, - rulerRule: rule, - namespace, - group, - instanceTotals: {}, - filteredInstanceTotals: {}, - } - : { - name: rule.grafana_alert.title, - query: '', - labels: rule.labels || {}, - annotations: rule.annotations || {}, - rulerRule: rule, - namespace, - group, - instanceTotals: {}, - filteredInstanceTotals: {}, - }; + ? { + name: rule.record, + query: rule.expr, + labels: rule.labels || {}, + annotations: {}, + rulerRule: rule, + namespace, + group, + instanceTotals: {}, + filteredInstanceTotals: {}, + } + : { + name: rule.grafana_alert.title, + query: '', + labels: rule.labels || {}, + annotations: rule.annotations || {}, + rulerRule: rule, + namespace, + group, + instanceTotals: {}, + filteredInstanceTotals: {}, + }; } // find existing rule in group that matches the given prom rule diff --git a/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx index f45b2bc0ffe96..1e15071190b1d 100644 --- a/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx +++ b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx @@ -1,16 +1,14 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import { setupServer } from 'msw/node'; -import React from 'react'; -import { Provider } from 'react-redux'; - import 'whatwg-fetch'; +import { renderHook, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { SetupServer, setupServer } from 'msw/node'; +import { TestProvider } from 'test/helpers/TestProvider'; -import { DataSourceJsonData, DataSourceSettings } from '@grafana/data'; -import { config, setBackendSrv } from '@grafana/runtime'; +import { DataSourceSettings } from '@grafana/data'; +import { setBackendSrv } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types'; -import { mockDataSource, mockDataSourcesStore, mockStore } from '../mocks'; import { mockAlertmanagersResponse } from '../mocks/alertmanagerApi'; import { useExternalDataSourceAlertmanagers } from './useExternalAmSelector'; @@ -31,46 +29,42 @@ afterAll(() => { }); describe('useExternalDataSourceAlertmanagers', () => { - it('Should merge data sources information from config and api responses', async () => { + it('Should get the correct data source settings', async () => { // Arrange - const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource({ url: 'http://grafana.com' }); + setupAlertmanagerDataSource(server, { url: 'http://grafana.com' }); + mockAlertmanagersResponse(server, { data: { activeAlertManagers: [], droppedAlertManagers: [] } }); - config.datasources = { - 'External Alertmanager': dsInstanceSettings, - }; + // Act + const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper: TestProvider }); + await waitFor(() => { + // Assert + const { current } = result; - const store = mockDataSourcesStore({ - dataSources: [dsSettings], + expect(current).toHaveLength(1); + expect(current[0].dataSourceSettings.uid).toBe('1'); + expect(current[0].dataSourceSettings.url).toBe('http://grafana.com'); }); + }); + it('Should have uninterested state if data source does not want alerts', async () => { + // Arrange + setupAlertmanagerDataSource(server, { url: 'http://grafana.com', jsonData: { handleGrafanaManagedAlerts: false } }); mockAlertmanagersResponse(server, { data: { activeAlertManagers: [], droppedAlertManagers: [] } }); - const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>; - // Act - const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper }); + const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper: TestProvider }); await waitFor(() => { // Assert const { current } = result; expect(current).toHaveLength(1); - expect(current[0].dataSource.uid).toBe('1'); - expect(current[0].url).toBe('http://grafana.com'); + expect(current[0].status).toBe('uninterested'); }); }); it('Should have active state if available in the activeAlertManagers', async () => { // Arrange - const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource({ url: 'http://grafana.com' }); - - config.datasources = { - 'External Alertmanager': dsInstanceSettings, - }; - - const store = mockStore((state) => { - state.dataSources.dataSources = [dsSettings]; - }); - + setupAlertmanagerDataSource(server, { url: 'http://grafana.com' }); mockAlertmanagersResponse(server, { data: { activeAlertManagers: [{ url: 'http://grafana.com/api/v2/alerts' }], @@ -78,32 +72,20 @@ describe('useExternalDataSourceAlertmanagers', () => { }, }); - const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>; - // Act - const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper }); + const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper: TestProvider }); await waitFor(() => { // Assert const { current } = result; expect(current).toHaveLength(1); expect(current[0].status).toBe('active'); - expect(current[0].statusInconclusive).toBe(false); }); }); it('Should have dropped state if available in the droppedAlertManagers', async () => { // Arrange - const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource({ url: 'http://grafana.com' }); - - config.datasources = { - 'External Alertmanager': dsInstanceSettings, - }; - - const store = mockStore((state) => { - state.dataSources.dataSources = [dsSettings]; - }); - + setupAlertmanagerDataSource(server, { url: 'http://grafana.com' }); mockAlertmanagersResponse(server, { data: { activeAlertManagers: [], @@ -111,10 +93,8 @@ describe('useExternalDataSourceAlertmanagers', () => { }, }); - const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>; - // Act - const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper }); + const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper: TestProvider }); await waitFor(() => { // Assert @@ -122,22 +102,12 @@ describe('useExternalDataSourceAlertmanagers', () => { expect(current).toHaveLength(1); expect(current[0].status).toBe('dropped'); - expect(current[0].statusInconclusive).toBe(false); }); }); it('Should have pending state if not available neither in dropped nor in active alertManagers', async () => { // Arrange - const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource(); - - config.datasources = { - 'External Alertmanager': dsInstanceSettings, - }; - - const store = mockStore((state) => { - state.dataSources.dataSources = [dsSettings]; - }); - + setupAlertmanagerDataSource(server); mockAlertmanagersResponse(server, { data: { activeAlertManagers: [], @@ -145,10 +115,8 @@ describe('useExternalDataSourceAlertmanagers', () => { }, }); - const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>; - // Act - const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper }); + const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper: TestProvider }); await waitFor(() => { // Assert @@ -156,22 +124,12 @@ describe('useExternalDataSourceAlertmanagers', () => { expect(current).toHaveLength(1); expect(current[0].status).toBe('pending'); - expect(current[0].statusInconclusive).toBe(false); }); }); it('Should match Alertmanager url when datasource url does not have protocol specified', async () => { // Arrange - const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource({ url: 'localhost:9093' }); - - config.datasources = { - 'External Alertmanager': dsInstanceSettings, - }; - - const store = mockStore((state) => { - state.dataSources.dataSources = [dsSettings]; - }); - + setupAlertmanagerDataSource(server, { url: 'localhost:9093' }); mockAlertmanagersResponse(server, { data: { activeAlertManagers: [{ url: 'http://localhost:9093/api/v2/alerts' }], @@ -179,10 +137,8 @@ describe('useExternalDataSourceAlertmanagers', () => { }, }); - const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>; - // Act - const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper }); + const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper: TestProvider }); await waitFor(() => { // Assert @@ -190,85 +146,76 @@ describe('useExternalDataSourceAlertmanagers', () => { expect(current).toHaveLength(1); expect(current[0].status).toBe('active'); - expect(current[0].url).toBe('localhost:9093'); + expect(current[0].dataSourceSettings.url).toBe('localhost:9093'); }); }); - it('Should have inconclusive state when there are many Alertmanagers of the same URL', async () => { + it('Should have inconclusive state when there are many Alertmanagers of the same URL on both active and inactive', async () => { // Arrange mockAlertmanagersResponse(server, { data: { - activeAlertManagers: [{ url: 'http://grafana.com/api/v2/alerts' }, { url: 'http://grafana.com/api/v2/alerts' }], - droppedAlertManagers: [], + activeAlertManagers: [{ url: 'http://grafana.com/api/v2/alerts' }], + droppedAlertManagers: [{ url: 'http://grafana.com/api/v2/alerts' }], }, }); - const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource({ url: 'http://grafana.com' }); + setupAlertmanagerDataSource(server, { url: 'http://grafana.com' }); - config.datasources = { - 'External Alertmanager': dsInstanceSettings, - }; + // Act + const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { + wrapper: TestProvider, + }); + + await waitFor(() => { + // Assert + expect(result.current).toHaveLength(1); + expect(result.current[0].status).toBe('inconclusive'); + }); + }); - const store = mockStore((state) => { - state.dataSources.dataSources = [dsSettings]; + it('Should have not have inconclusive state when all Alertmanagers of the same URL are active', async () => { + // Arrange + mockAlertmanagersResponse(server, { + data: { + activeAlertManagers: [{ url: 'http://grafana.com/api/v2/alerts' }, { url: 'http://grafana.com/api/v2/alerts' }], + droppedAlertManagers: [], + }, }); - const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>; + setupAlertmanagerDataSource(server, { url: 'http://grafana.com' }); // Act const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { - wrapper, + wrapper: TestProvider, }); await waitFor(() => { // Assert expect(result.current).toHaveLength(1); expect(result.current[0].status).toBe('active'); - expect(result.current[0].statusInconclusive).toBe(true); }); }); }); -function setupAlertmanagerDataSource(partialDsSettings?: Partial<DataSourceSettings<AlertManagerDataSourceJsonData>>) { +function setupAlertmanagerDataSource( + server: SetupServer, + partialDsSettings?: Partial<DataSourceSettings<AlertManagerDataSourceJsonData>> +) { const dsCommonConfig = { uid: '1', name: 'External Alertmanager', type: 'alertmanager', - jsonData: { handleGrafanaManagedAlerts: true } as AlertManagerDataSourceJsonData, + jsonData: { handleGrafanaManagedAlerts: true }, }; - const dsInstanceSettings = mockDataSource(dsCommonConfig); - - const dsSettings = mockApiDataSource({ + const dsSettings = { ...dsCommonConfig, ...partialDsSettings, - }); - - return { dsSettings, dsInstanceSettings }; -} - -function mockApiDataSource(partial: Partial<DataSourceSettings<DataSourceJsonData, {}>> = {}) { - const dsSettings: DataSourceSettings<DataSourceJsonData, {}> = { - uid: '1', - id: 1, - name: '', - url: '', - type: '', - access: '', - orgId: 1, - typeLogoUrl: '', - typeName: '', - user: '', - database: '', - basicAuth: false, - isDefault: false, - basicAuthUser: '', - jsonData: { handleGrafanaManagedAlerts: true } as AlertManagerDataSourceJsonData, - secureJsonFields: {}, - readOnly: false, - withCredentials: false, - ...partial, }; - return dsSettings; + server.use( + http.get('/api/datasources', () => { + return HttpResponse.json([dsSettings]); + }) + ); } diff --git a/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts b/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts index 8ffe85bb8cb22..8cbe722869850 100644 --- a/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts +++ b/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts @@ -1,76 +1,114 @@ -import { countBy, keyBy } from 'lodash'; -import { createSelector } from 'reselect'; - -import { DataSourceInstanceSettings, DataSourceJsonData, DataSourceSettings } from '@grafana/data'; -import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types'; -import { StoreState, useSelector } from 'app/types'; +import { DataSourceSettings } from '@grafana/data'; +import { AlertManagerDataSourceJsonData, ExternalAlertmanagers } from 'app/plugins/datasource/alertmanager/types'; import { alertmanagerApi } from '../api/alertmanagerApi'; -import { getAlertManagerDataSources } from '../utils/datasource'; +import { dataSourcesApi } from '../api/dataSourcesApi'; +import { isAlertmanagerDataSource } from '../utils/datasource'; -export interface ExternalDataSourceAM { - dataSource: DataSourceInstanceSettings<AlertManagerDataSourceJsonData>; - url?: string; - status: 'active' | 'pending' | 'dropped'; - statusInconclusive?: boolean; -} +type ConnectionStatus = 'active' | 'pending' | 'dropped' | 'inconclusive' | 'uninterested' | 'unknown'; -export function useExternalDataSourceAlertmanagers(): ExternalDataSourceAM[] { - const { useGetExternalAlertmanagersQuery } = alertmanagerApi; - const { currentData: discoveredAlertmanagers } = useGetExternalAlertmanagersQuery(); +export interface ExternalAlertmanagerDataSourceWithStatus { + dataSourceSettings: DataSourceSettings<AlertManagerDataSourceJsonData>; + status: ConnectionStatus; +} - const externalDsAlertManagers = getAlertManagerDataSources().filter((ds) => ds.jsonData.handleGrafanaManagedAlerts); +/** + * Returns all configured Alertmanager data sources and their connection status with the internal ruler + */ +export function useExternalDataSourceAlertmanagers(): ExternalAlertmanagerDataSourceWithStatus[] { + // firstly we'll fetch the settings for all datasources and filter for "alertmanager" type + const { alertmanagerDataSources } = dataSourcesApi.endpoints.getAllDataSourceSettings.useQuery(undefined, { + refetchOnReconnect: true, + selectFromResult: (result) => { + const alertmanagerDataSources = result.currentData?.filter(isAlertmanagerDataSource) ?? []; + return { ...result, alertmanagerDataSources }; + }, + }); - const alertmanagerDatasources = useSelector( - createSelector( - (state: StoreState) => state.dataSources.dataSources.filter((ds) => ds.type === 'alertmanager'), - (datasources) => keyBy(datasources, (ds) => ds.uid) - ) + // we'll also fetch the configuration for which Alertmanagers we are forwarding Grafana-managed alerts too + // @TODO use polling when we have one or more alertmanagers in pending state + const { currentData: externalAlertmanagers } = alertmanagerApi.endpoints.getExternalAlertmanagers.useQuery( + undefined, + { refetchOnReconnect: true } ); - const droppedAMUrls = countBy(discoveredAlertmanagers?.droppedAlertManagers, (x) => x.url); - const activeAMUrls = countBy(discoveredAlertmanagers?.activeAlertManagers, (x) => x.url); + if (!alertmanagerDataSources) { + return []; + } + + return alertmanagerDataSources.map<ExternalAlertmanagerDataSourceWithStatus>((dataSourceSettings) => { + const status = externalAlertmanagers + ? determineAlertmanagerConnectionStatus(externalAlertmanagers, dataSourceSettings) + : 'unknown'; - return externalDsAlertManagers.map<ExternalDataSourceAM>((dsAm) => { - const dsSettings = alertmanagerDatasources[dsAm.uid]; + return { + dataSourceSettings, + status, + }; + }); +} - if (!dsSettings) { - return { - dataSource: dsAm, - status: 'pending', - }; - } +// using the information from /api/v1/ngalert/alertmanagers we should derive the connection status of a single data source +function determineAlertmanagerConnectionStatus( + externalAlertmanagers: ExternalAlertmanagers, + dataSourceSettings: DataSourceSettings<AlertManagerDataSourceJsonData> +): ConnectionStatus { + const isInterestedInAlerts = dataSourceSettings.jsonData.handleGrafanaManagedAlerts; + if (!isInterestedInAlerts) { + return 'uninterested'; + } - const amUrl = getDataSourceUrlWithProtocol(dsSettings); - const amStatusUrl = `${amUrl}/api/v2/alerts`; + const isActive = + externalAlertmanagers?.activeAlertManagers.some((am) => { + return isAlertmanagerMatchByURL(dataSourceSettings.url, am.url); + }) ?? []; - const matchingDroppedUrls = droppedAMUrls[amStatusUrl] ?? 0; - const matchingActiveUrls = activeAMUrls[amStatusUrl] ?? 0; + const isDropped = + externalAlertmanagers?.droppedAlertManagers.some((am) => { + return isAlertmanagerMatchByURL(dataSourceSettings.url, am.url); + }) ?? []; - const isDropped = matchingDroppedUrls > 0; - const isActive = matchingActiveUrls > 0; + // the Alertmanager is being adopted (pending) if it is interested in handling alerts but not in either "active" or "dropped" + const isPending = !isActive && !isDropped; + if (isPending) { + return 'pending'; + } - // Multiple Alertmanagers of the same URL may exist (e.g. with different credentials) - // Alertmanager response only contains URLs, so in case of duplication, we are not able - // to distinguish which is which, resulting in an inconclusive status. - const isStatusInconclusive = matchingDroppedUrls + matchingActiveUrls > 1; + // Multiple Alertmanagers of the same URL may exist (e.g. with different credentials) + // Alertmanager response only contains URLs, so when the URL exists in both active and dropped, we are not able + // to distinguish which is which, resulting in an inconclusive status. + const isInconclusive = isActive && isDropped; + if (isInconclusive) { + return 'inconclusive'; + } - const status = isDropped ? 'dropped' : isActive ? 'active' : 'pending'; + // if we get here, it's neither "uninterested", nor "inconclusive" nor "pending" + if (isActive) { + return 'active'; + } else if (isDropped) { + return 'dropped'; + } - return { - dataSource: dsAm, - url: dsSettings.url, - status, - statusInconclusive: isStatusInconclusive, - }; - }); + return 'unknown'; } -function getDataSourceUrlWithProtocol<T extends DataSourceJsonData>(dsSettings: DataSourceSettings<T>) { - const hasProtocol = new RegExp('^[^:]*://').test(dsSettings.url); - if (!hasProtocol) { - return `http://${dsSettings.url}`; // Grafana append http protocol if there is no any - } +// the vanilla Alertmanager and Mimir Alertmanager mount their API endpoints on different sub-paths +// Cortex also uses the same paths as Mimir +const MIMIR_ALERTMANAGER_PATH = '/alertmanager/api/v2/alerts'; +const VANILLA_ALERTMANAGER_PATH = '/api/v2/alerts'; + +// when using the Mimir Alertmanager, those paths are mounted under "/alertmanager" +function isAlertmanagerMatchByURL(dataSourceUrl: string, alertmanagerUrl: string) { + const normalizedUrl = normalizeDataSourceURL(dataSourceUrl); + + const prometheusAlertmanagerMatch = alertmanagerUrl === `${normalizedUrl}${VANILLA_ALERTMANAGER_PATH}`; + const mimirAlertmanagerMatch = alertmanagerUrl === `${normalizedUrl}${MIMIR_ALERTMANAGER_PATH}`; + + return prometheusAlertmanagerMatch || mimirAlertmanagerMatch; +} - return dsSettings.url; +// Grafana prepends the http protocol if there isn't one, but it doesn't store that in the datasource settings +function normalizeDataSourceURL(url: string) { + const hasProtocol = new RegExp('^[^:]*://').test(url); + return hasProtocol ? url : `http://${url}`; } diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts index 5b2c151e34d56..b102a5b20c673 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts @@ -15,6 +15,7 @@ import { mockRulerGrafanaRule, } from '../mocks'; import { RuleHealth } from '../search/rulesSearchParser'; +import { Annotation } from '../utils/constants'; import { getFilter } from '../utils/search'; import { filterRules } from './useFilteredRules'; @@ -230,4 +231,29 @@ describe('filterRules', function () { expect(filtered[0].groups[0].rules).toHaveLength(1); expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low'); }); + + it('should filter out rules by dashboard UID', () => { + const rules = [ + mockCombinedRule({ + name: 'Memory too low', + annotations: { [Annotation.dashboardUID]: 'dashboard-memory' }, + }), + mockCombinedRule({ + name: 'CPU too high', + annotations: { [Annotation.dashboardUID]: 'dashboard-cpu' }, + }), + mockCombinedRule({ + name: 'Disk is dead', + }), + ]; + + const ns = mockCombinedRuleNamespace({ + groups: [mockCombinedRuleGroup('Resources usage group', rules)], + }); + + const filtered = filterRules([ns], getFilter({ dashboardUid: 'dashboard-cpu' })); + + expect(filtered[0]?.groups[0]?.rules).toHaveLength(1); + expect(filtered[0]?.groups[0]?.rules[0]?.name).toBe('CPU too high'); + }); }); diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index e8d7b222b7627..68ca7c4cc1d3f 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -10,6 +10,7 @@ import { isPromAlertingRuleState, PromRuleType, RulerGrafanaRuleDTO } from 'app/ import { applySearchFilterToQuery, getSearchFilterFromQuery, RulesFilter } from '../search/rulesSearchParser'; import { labelsMatchMatchers, matcherToMatcherField, parseMatchers } from '../utils/alertmanager'; +import { Annotation } from '../utils/constants'; import { isCloudRulesSource } from '../utils/datasource'; import { parseMatcher } from '../utils/matchers'; import { getRuleHealth, isAlertingRule, isGrafanaRulerRule, isPromRuleType } from '../utils/rules'; @@ -194,7 +195,7 @@ const reduceGroups = (filterState: RulesFilter) => { const matchesFilterFor = chain(filterState) // ⚠️ keep this list of properties we filter for here up-to-date ⚠️ // We are ignoring predicates we've matched before we get here (like "freeFormWords") - .pick(['ruleType', 'dataSourceNames', 'ruleHealth', 'labels', 'ruleState']) + .pick(['ruleType', 'dataSourceNames', 'ruleHealth', 'labels', 'ruleState', 'dashboardUid']) .omitBy(isEmpty) .mapValues(() => false) .value(); @@ -253,6 +254,13 @@ const reduceGroups = (filterState: RulesFilter) => { } } + if ( + 'dashboardUid' in matchesFilterFor && + rule.annotations[Annotation.dashboardUID] === filterState.dashboardUid + ) { + matchesFilterFor.dashboardUid = true; + } + return Object.values(matchesFilterFor).every((match) => match === true); }); diff --git a/public/app/features/alerting/unified/hooks/useMuteTimingOptions.ts b/public/app/features/alerting/unified/hooks/useMuteTimingOptions.ts index 675c92197586e..777f615edecde 100644 --- a/public/app/features/alerting/unified/hooks/useMuteTimingOptions.ts +++ b/public/app/features/alerting/unified/hooks/useMuteTimingOptions.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { SelectableValue } from '@grafana/data'; +import { mergeTimeIntervals } from '../components/mute-timings/util'; import { useAlertmanager } from '../state/AlertmanagerContext'; import { timeIntervalToString } from '../utils/alertmanager'; @@ -13,8 +14,9 @@ export function useMuteTimingOptions(): Array<SelectableValue<string>> { const config = currentData?.alertmanager_config; return useMemo(() => { + const time_intervals = config ? mergeTimeIntervals(config) : []; const muteTimingsOptions: Array<SelectableValue<string>> = - config?.mute_time_intervals?.map((value) => ({ + time_intervals?.map((value) => ({ value: value.name, label: value.name, description: value.time_intervals.map((interval) => timeIntervalToString(interval)).join(', AND '), diff --git a/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts b/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts index a0a88f1d83e54..eb8b176b40d9e 100644 --- a/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts +++ b/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts @@ -1,7 +1,6 @@ import { SerializedError } from '@reduxjs/toolkit'; import { useEffect, useMemo } from 'react'; -import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { useDispatch } from 'app/types'; import { CombinedRule } from 'app/types/unified-alerting'; @@ -14,8 +13,8 @@ import { useCombinedRuleNamespaces } from './useCombinedRuleNamespaces'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; interface Options { - dashboard: DashboardModel; - panel: PanelModel; + dashboardUID: string; + panelId: number; poll?: boolean; } @@ -27,7 +26,7 @@ interface ReturnBag { loading?: boolean; } -export function usePanelCombinedRules({ dashboard, panel, poll = false }: Options): ReturnBag { +export function usePanelCombinedRules({ dashboardUID, panelId, poll = false }: Options): ReturnBag { const dispatch = useDispatch(); const promRuleRequest = @@ -41,13 +40,13 @@ export function usePanelCombinedRules({ dashboard, panel, poll = false }: Option dispatch( fetchPromRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME, - filter: { dashboardUID: dashboard.uid, panelId: panel.id }, + filter: { dashboardUID, panelId }, }) ); dispatch( fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME, - filter: { dashboardUID: dashboard.uid, panelId: panel.id }, + filter: { dashboardUID, panelId }, }) ); }; @@ -59,7 +58,7 @@ export function usePanelCombinedRules({ dashboard, panel, poll = false }: Option }; } return () => {}; - }, [dispatch, poll, panel.id, dashboard.uid]); + }, [dispatch, poll, panelId, dashboardUID]); const loading = promRuleRequest.loading || rulerRuleRequest.loading; const errors = [promRuleRequest.error, rulerRuleRequest.error].filter( @@ -76,10 +75,10 @@ export function usePanelCombinedRules({ dashboard, panel, poll = false }: Option .flatMap((group) => group.rules) .filter( (rule) => - rule.annotations[Annotation.dashboardUID] === dashboard.uid && - rule.annotations[Annotation.panelID] === String(panel.id) + rule.annotations[Annotation.dashboardUID] === dashboardUID && + rule.annotations[Annotation.panelID] === String(panelId) ), - [combinedNamespaces, dashboard, panel] + [combinedNamespaces, dashboardUID, panelId] ); return { diff --git a/public/app/features/alerting/unified/initAlerting.tsx b/public/app/features/alerting/unified/initAlerting.tsx new file mode 100644 index 0000000000000..b00d24d64576b --- /dev/null +++ b/public/app/features/alerting/unified/initAlerting.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { config } from '@grafana/runtime'; +import { contextSrv } from 'app/core/services/context_srv'; + +import { addCustomRightAction } from '../../dashboard/components/DashNav/DashNav'; + +import { getRulesPermissions } from './utils/access-control'; +import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; + +const AlertRulesToolbarButton = React.lazy( + () => import(/* webpackChunkName: "alert-rules-toolbar-button" */ './integration/AlertRulesToolbarButton') +); + +export function initAlerting() { + const grafanaRulesPermissions = getRulesPermissions(GRAFANA_RULES_SOURCE_NAME); + + if (contextSrv.hasPermission(grafanaRulesPermissions.read)) { + addCustomRightAction({ + show: () => config.unifiedAlertingEnabled, + component: ({ dashboard }) => ( + <React.Suspense fallback={null} key="alert-rules-button"> + {dashboard && <AlertRulesToolbarButton dashboardUid={dashboard.uid} />} + </React.Suspense> + ), + index: -2, + }); + } +} diff --git a/public/app/features/alerting/unified/insights/grafana/Firing.tsx b/public/app/features/alerting/unified/insights/grafana/Active.tsx similarity index 86% rename from public/app/features/alerting/unified/insights/grafana/Firing.tsx rename to public/app/features/alerting/unified/insights/grafana/Active.tsx index cdf154979c8cd..636780cce3b83 100644 --- a/public/app/features/alerting/unified/insights/grafana/Firing.tsx +++ b/public/app/features/alerting/unified/insights/grafana/Active.tsx @@ -7,7 +7,7 @@ import { DataSourceRef } from '@grafana/schema'; import { INSTANCE_ID, PANEL_STYLES } from '../../home/Insights'; import { InsightsRatingModal } from '../RatingModal'; -export function getFiringGrafanaAlertsScene(datasource: DataSourceRef, panelTitle: string) { +export function getActiveGrafanaAlertsScene(datasource: DataSourceRef, panelTitle: string) { const expr = INSTANCE_ID ? `sum by (state) (grafanacloud_grafana_instance_alerting_rule_group_rules{state="active", id="${INSTANCE_ID}"})` : `sum by (state) (grafanacloud_grafana_instance_alerting_rule_group_rules{state="active"})`; @@ -27,17 +27,17 @@ export function getFiringGrafanaAlertsScene(datasource: DataSourceRef, panelTitl ...PANEL_STYLES, body: PanelBuilders.stat() .setTitle(panelTitle) - .setDescription('The number of currently firing alert rules') + .setDescription('The number of currently active alert rules') .setData(query) .setThresholds({ mode: ThresholdsMode.Absolute, steps: [ { - color: 'red', + color: 'green', value: 0, }, { - color: 'red', + color: 'green', value: 80, }, ], diff --git a/public/app/features/alerting/unified/integration/AlertRulesDrawer.tsx b/public/app/features/alerting/unified/integration/AlertRulesDrawer.tsx new file mode 100644 index 0000000000000..4c6e6431ca2e3 --- /dev/null +++ b/public/app/features/alerting/unified/integration/AlertRulesDrawer.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { Drawer, LoadingPlaceholder, Stack, TextLink } from '@grafana/ui'; + +import { t } from '../../../../core/internationalization'; +import { createUrl } from '../utils/url'; + +const AlertRulesDrawerContent = React.lazy( + () => import(/* webpackChunkName: "alert-rules-drawer-content" */ './AlertRulesDrawerContent') +); + +interface Props { + dashboardUid: string; + onClose: () => void; +} + +export function AlertRulesDrawer({ dashboardUid, onClose }: Props) { + return ( + <Drawer title="Alert rules" subtitle={<DrawerSubtitle dashboardUid={dashboardUid} />} onClose={onClose} size="lg"> + <React.Suspense fallback={<LoadingPlaceholder text="Loading alert rules" />}> + <AlertRulesDrawerContent dashboardUid={dashboardUid} /> + </React.Suspense> + </Drawer> + ); +} + +function DrawerSubtitle({ dashboardUid }: { dashboardUid: string }) { + const searchParams = new URLSearchParams({ search: `dashboard:${dashboardUid}` }); + + return ( + <Stack gap={2}> + <div>{t('dashboard.alert-rules-drawer.subtitle', 'Alert rules related to this dashboard')}</div> + <TextLink href={createUrl(`/alerting/list/?${searchParams.toString()}`)}> + {t('dashboard.alert-rules-drawer.redirect-link', 'List in Grafana Alerting')} + </TextLink> + </Stack> + ); +} diff --git a/public/app/features/alerting/unified/integration/AlertRulesDrawerContent.tsx b/public/app/features/alerting/unified/integration/AlertRulesDrawerContent.tsx new file mode 100644 index 0000000000000..22bd4a2a0935d --- /dev/null +++ b/public/app/features/alerting/unified/integration/AlertRulesDrawerContent.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useAsync } from 'react-use'; + +import { LoadingPlaceholder } from '@grafana/ui'; +import { useDispatch } from 'app/types'; + +import { RulesTable } from '../components/rules/RulesTable'; +import { useCombinedRuleNamespaces } from '../hooks/useCombinedRuleNamespaces'; +import { fetchPromAndRulerRulesAction } from '../state/actions'; +import { Annotation } from '../utils/constants'; +import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; + +interface Props { + dashboardUid: string; +} + +export default function AlertRulesDrawerContent({ dashboardUid }: Props) { + const dispatch = useDispatch(); + + const { loading: loadingAlertRules } = useAsync(async () => { + await dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME })); + }, [dispatch]); + + const grafanaNamespaces = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME); + const rules = grafanaNamespaces + .flatMap((ns) => ns.groups) + .flatMap((g) => g.rules) + .filter((rule) => rule.annotations[Annotation.dashboardUID] === dashboardUid); + + const loading = loadingAlertRules; + + return ( + <> + {loading ? ( + <LoadingPlaceholder text="Loading alert rules" /> + ) : ( + <RulesTable rules={rules} showNextEvaluationColumn={false} showGroupColumn={false} /> + )} + </> + ); +} diff --git a/public/app/features/alerting/unified/integration/AlertRulesToolbarButton.tsx b/public/app/features/alerting/unified/integration/AlertRulesToolbarButton.tsx new file mode 100644 index 0000000000000..963ceec182d78 --- /dev/null +++ b/public/app/features/alerting/unified/integration/AlertRulesToolbarButton.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +import { ToolbarButton } from '@grafana/ui'; + +import { t } from '../../../../core/internationalization'; +import { useDashNavModalController } from '../../../dashboard/components/DashNav/DashNav'; +import { alertRuleApi } from '../api/alertRuleApi'; +import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; + +import { AlertRulesDrawer } from './AlertRulesDrawer'; + +interface AlertRulesToolbarButtonProps { + dashboardUid: string; +} + +export default function AlertRulesToolbarButton({ dashboardUid }: AlertRulesToolbarButtonProps) { + const { showModal, hideModal } = useDashNavModalController(); + + const { data: namespaces = [] } = alertRuleApi.endpoints.prometheusRuleNamespaces.useQuery({ + ruleSourceName: GRAFANA_RULES_SOURCE_NAME, + dashboardUid: dashboardUid, + }); + + if (namespaces.length === 0) { + return null; + } + + return ( + <ToolbarButton + tooltip={t('dashboard.toolbar.alert-rules', 'Alert rules')} + icon="bell" + onClick={() => showModal(<AlertRulesDrawer dashboardUid={dashboardUid} onClose={hideModal} />)} + key="button-alerting" + /> + ); +} diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts index 3fafab6ee7381..9c3ad1e853991 100644 --- a/public/app/features/alerting/unified/mockApi.ts +++ b/public/app/features/alerting/unified/mockApi.ts @@ -1,7 +1,7 @@ +import 'whatwg-fetch'; import { uniqueId } from 'lodash'; -import { rest } from 'msw'; +import { http, HttpResponse } from 'msw'; import { setupServer, SetupServer } from 'msw/node'; -import 'whatwg-fetch'; import { DataSourceInstanceSettings, PluginMeta } from '@grafana/data'; import { setBackendSrv } from '@grafana/runtime'; @@ -120,7 +120,9 @@ class GrafanaReceiverConfigBuilder { } addSetting(key: string, value: string): GrafanaReceiverConfigBuilder { - this.grafanaReceiverConfig.settings[key] = value; + if (this.grafanaReceiverConfig.settings) { + this.grafanaReceiverConfig.settings[key] = value; + } return this; } @@ -190,85 +192,74 @@ export function mockApi(server: SetupServer) { configure(builder); server.use( - rest.get(`api/alertmanager/${amName}/config/api/v1/alerts`, (req, res, ctx) => - res( - ctx.status(200), - ctx.json<AlertManagerCortexConfig>({ - alertmanager_config: builder.build(), - template_files: {}, - }) - ) + http.get(`api/alertmanager/${amName}/config/api/v1/alerts`, () => + HttpResponse.json<AlertManagerCortexConfig>({ + alertmanager_config: builder.build(), + template_files: {}, + }) ) ); }, eval: (response: AlertingQueryResponse) => { server.use( - rest.post('/api/v1/eval', (_, res, ctx) => { - return res(ctx.status(200), ctx.json(response)); + http.post('/api/v1/eval', () => { + return HttpResponse.json(response); }) ); }, grafanaNotifiers: (response: NotifierDTO[]) => { - server.use( - rest.get(`api/alert-notifiers`, (req, res, ctx) => res(ctx.status(200), ctx.json<NotifierDTO[]>(response))) - ); + server.use(http.get(`api/alert-notifiers`, () => HttpResponse.json(response))); }, plugins: { getPluginSettings: (response: PluginMeta) => { - server.use( - rest.get(`api/plugins/${response.id}/settings`, (req, res, ctx) => - res(ctx.status(200), ctx.json<PluginMeta>(response)) - ) - ); + server.use(http.get(`api/plugins/${response.id}/settings`, () => HttpResponse.json(response))); }, }, oncall: { getOnCallIntegrations: (response: OnCallIntegrationDTO[]) => { server.use( - rest.get(`api/plugin-proxy/grafana-oncall-app/api/internal/v1/alert_receive_channels`, (_, res, ctx) => - res(ctx.status(200), ctx.json<OnCallIntegrationDTO[]>(response)) + http.get(`api/plugin-proxy/grafana-oncall-app/api/internal/v1/alert_receive_channels`, () => + HttpResponse.json<OnCallIntegrationDTO[]>(response) ) ); }, features: (response: string[]) => { server.use( - rest.get(`api/plugin-proxy/grafana-oncall-app/api/internal/v1/features`, (_, res, ctx) => - res(ctx.status(200), ctx.json<string[]>(response)) - ) + http.get(`api/plugin-proxy/grafana-oncall-app/api/internal/v1/features`, () => HttpResponse.json(response)) ); }, validateIntegrationName: (invalidNames: string[]) => { server.use( - rest.get( + http.get( `api/plugin-proxy/grafana-oncall-app/api/internal/v1/alert_receive_channels/validate_name`, - (req, res, ctx) => { - const isValid = !invalidNames.includes(req.url.searchParams.get('verbal_name') ?? ''); - return res(ctx.status(isValid ? 200 : 409), ctx.json<boolean>(isValid)); + ({ request }) => { + const url = new URL(request.url); + const isValid = !invalidNames.includes(url.searchParams.get('verbal_name') ?? ''); + return HttpResponse.json(isValid, { + status: isValid ? 200 : 409, + }); } ) ); }, createIntegraion: () => { server.use( - rest.post<CreateIntegrationDTO>( + http.post<{}, CreateIntegrationDTO>( `api/plugin-proxy/grafana-oncall-app/api/internal/v1/alert_receive_channels`, - async (req, res, ctx) => { - const body = await req.json<CreateIntegrationDTO>(); + async ({ request }) => { + const body = await request.json(); const integrationId = uniqueId('oncall-integration-'); - return res( - ctx.status(200), - ctx.json<NewOnCallIntegrationDTO>({ - id: integrationId, - integration: body.integration, - integration_url: `https://oncall-endpoint.example.com/${integrationId}`, - verbal_name: body.verbal_name, - connected_escalations_chains_count: 0, - }) - ); + return HttpResponse.json<NewOnCallIntegrationDTO>({ + id: integrationId, + integration: body.integration, + integration_url: `https://oncall-endpoint.example.com/${integrationId}`, + verbal_name: body.verbal_name, + connected_escalations_chains_count: 0, + }); } ) ); @@ -281,21 +272,15 @@ export function mockAlertRuleApi(server: SetupServer) { return { prometheusRuleNamespaces: (dsName: string, response: PromRulesResponse) => { server.use( - rest.get(`api/prometheus/${dsName}/api/v1/rules`, (req, res, ctx) => - res(ctx.status(200), ctx.json<PromRulesResponse>(response)) - ) + http.get(`api/prometheus/${dsName}/api/v1/rules`, () => HttpResponse.json<PromRulesResponse>(response)) ); }, rulerRules: (dsName: string, response: RulerRulesConfigDTO) => { - server.use( - rest.get(`/api/ruler/${dsName}/api/v1/rules`, (req, res, ctx) => res(ctx.status(200), ctx.json(response))) - ); + server.use(http.get(`/api/ruler/${dsName}/api/v1/rules`, () => HttpResponse.json(response))); }, rulerRuleGroup: (dsName: string, namespace: string, group: string, response: RulerRuleGroupDTO) => { server.use( - rest.get(`/api/ruler/${dsName}/api/v1/rules/${namespace}/${group}`, (req, res, ctx) => - res(ctx.status(200), ctx.json(response)) - ) + http.get(`/api/ruler/${dsName}/api/v1/rules/${namespace}/${group}`, () => HttpResponse.json(response)) ); }, }; @@ -312,9 +297,7 @@ export function mockFeatureDiscoveryApi(server: SetupServer) { * @param response Use `buildInfoResponse` to get a pre-defined response for Prometheus and Mimir */ discoverDsFeatures: (dsSettings: DataSourceInstanceSettings, response: PromBuildInfoResponse) => { - server.use( - rest.get(`${dsSettings.url}/api/v1/status/buildinfo`, (_, res, ctx) => res(ctx.status(200), ctx.json(response))) - ); + server.use(http.get(`${dsSettings.url}/api/v1/status/buildinfo`, () => HttpResponse.json(response))); }, }; } @@ -323,16 +306,20 @@ export function mockProvisioningApi(server: SetupServer) { return { exportRuleGroup: (folderUid: string, groupName: string, response: Record<string, string>) => { server.use( - rest.get(`/api/v1/provisioning/folder/${folderUid}/rule-groups/${groupName}/export`, (req, res, ctx) => - res(ctx.status(200), ctx.text(response[req.url.searchParams.get('format') ?? 'yaml'])) - ) + http.get(`/api/v1/provisioning/folder/${folderUid}/rule-groups/${groupName}/export`, ({ request }) => { + const url = new URL(request.url); + const format = url.searchParams.get('format') ?? 'yaml'; + return HttpResponse.text(response[format]); + }) ); }, exportReceiver: (response: Record<string, string>) => { server.use( - rest.get(`/api/v1/provisioning/contact-points/export/`, (req, res, ctx) => - res(ctx.status(200), ctx.text(response[req.url.searchParams.get('format') ?? 'yaml'])) - ) + http.get(`/api/v1/provisioning/contact-points/export/`, ({ request }) => { + const url = new URL(request.url); + const format = url.searchParams.get('format') ?? 'yaml'; + return HttpResponse.text(response[format]); + }) ); }, }; @@ -344,43 +331,51 @@ export function mockExportApi(server: SetupServer) { // exportRule requires ruleUid parameter and doesn't allow folderUid and group parameters exportRule: (ruleUid: string, response: Record<string, string>) => { server.use( - rest.get('/api/ruler/grafana/api/v1/export/rules', (req, res, ctx) => { - if (req.url.searchParams.get('ruleUid') === ruleUid) { - return res(ctx.status(200), ctx.text(response[req.url.searchParams.get('format') ?? 'yaml'])); + http.get('/api/ruler/grafana/api/v1/export/rules', ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get('ruleUid') === ruleUid) { + const format = url.searchParams.get('format') ?? 'yaml'; + return HttpResponse.text(response[format]); } - return res(ctx.status(500)); + return HttpResponse.text('', { status: 500 }); }) ); }, // exportRulesGroup requires folderUid and group parameters and doesn't allow ruleUid parameter exportRulesGroup: (folderUid: string, group: string, response: Record<string, string>) => { server.use( - rest.get('/api/ruler/grafana/api/v1/export/rules', (req, res, ctx) => { - if (req.url.searchParams.get('folderUid') === folderUid && req.url.searchParams.get('group') === group) { - return res(ctx.status(200), ctx.text(response[req.url.searchParams.get('format') ?? 'yaml'])); + http.get('/api/ruler/grafana/api/v1/export/rules', ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get('folderUid') === folderUid && url.searchParams.get('group') === group) { + const format = url.searchParams.get('format') ?? 'yaml'; + return HttpResponse.text(response[format]); } - return res(ctx.status(500)); + return HttpResponse.text('', { status: 500 }); }) ); }, // exportRulesFolder requires folderUid parameter exportRulesFolder: (folderUid: string, response: Record<string, string>) => { server.use( - rest.get('/api/ruler/grafana/api/v1/export/rules', (req, res, ctx) => { - if (req.url.searchParams.get('folderUid') === folderUid) { - return res(ctx.status(200), ctx.text(response[req.url.searchParams.get('format') ?? 'yaml'])); + http.get('/api/ruler/grafana/api/v1/export/rules', ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get('folderUid') === folderUid) { + const format = url.searchParams.get('format') ?? 'yaml'; + return HttpResponse.text(response[format]); } - return res(ctx.status(500)); + return HttpResponse.text('', { status: 500 }); }) ); }, - modifiedExport: (namespace: string, response: Record<string, string>) => { + modifiedExport: (namespaceUID: string, response: Record<string, string>) => { server.use( - rest.post(`/api/ruler/grafana/api/v1/rules/${namespace}/export`, (req, res, ctx) => { - return res(ctx.status(200), ctx.text(response[req.url.searchParams.get('format') ?? 'yaml'])); + http.post(`/api/ruler/grafana/api/v1/rules/${namespaceUID}/export`, ({ request }) => { + const url = new URL(request.url); + const format = url.searchParams.get('format') ?? 'yaml'; + return HttpResponse.text(response[format]); }) ); }, @@ -390,7 +385,7 @@ export function mockExportApi(server: SetupServer) { export function mockFolderApi(server: SetupServer) { return { folder: (folderUid: string, response: FolderDTO) => { - server.use(rest.get(`/api/folders/${folderUid}`, (_, res, ctx) => res(ctx.status(200), ctx.json(response)))); + server.use(http.get(`/api/folders/${folderUid}`, () => HttpResponse.json(response))); }, }; } @@ -398,7 +393,7 @@ export function mockFolderApi(server: SetupServer) { export function mockSearchApi(server: SetupServer) { return { search: (results: DashboardSearchItem[]) => { - server.use(rest.get(`/api/search`, (_, res, ctx) => res(ctx.status(200), ctx.json(results)))); + server.use(http.get(`/api/search`, () => HttpResponse.json(results))); }, }; } @@ -406,14 +401,10 @@ export function mockSearchApi(server: SetupServer) { export function mockDashboardApi(server: SetupServer) { return { search: (results: DashboardSearchItem[]) => { - server.use(rest.get(`/api/search`, (_, res, ctx) => res(ctx.status(200), ctx.json(results)))); + server.use(http.get(`/api/search`, () => HttpResponse.json(results))); }, dashboard: (response: DashboardDTO) => { - server.use( - rest.get(`/api/dashboards/uid/${response.dashboard.uid}`, (_, res, ctx) => - res(ctx.status(200), ctx.json(response)) - ) - ); + server.use(http.get(`/api/dashboards/uid/${response.dashboard.uid}`, () => HttpResponse.json(response))); }, }; } diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 1c6f623090f55..3683e9f56baf8 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -395,10 +395,7 @@ export class MockDataSourceSrv implements DataSourceSrv { * Get settings and plugin metadata by name or uid */ getInstanceSettings(nameOrUid: string | null | undefined): DataSourceInstanceSettings | undefined { - return ( - DatasourceSrv.prototype.getInstanceSettings.call(this, nameOrUid) || - ({ meta: { info: { logos: {} } } } as unknown as DataSourceInstanceSettings) - ); + return DatasourceSrv.prototype.getInstanceSettings.call(this, nameOrUid); } async loadDatasource(name: string): Promise<DataSourceApi<any, any>> { @@ -602,19 +599,6 @@ export const grantUserPermissions = (permissions: AccessControlAction[]) => { .mockImplementation((action) => permissions.includes(action as AccessControlAction)); }; -export function mockDataSourcesStore(partial?: Partial<StoreState['dataSources']>) { - const defaultState = configureStore().getState(); - const store = configureStore({ - ...defaultState, - dataSources: { - ...defaultState.dataSources, - ...partial, - }, - }); - - return store; -} - export function mockUnifiedAlertingStore(unifiedAlerting?: Partial<StoreState['unifiedAlerting']>) { const defaultState = configureStore().getState(); diff --git a/public/app/features/alerting/unified/mocks/alertRuleApi.ts b/public/app/features/alerting/unified/mocks/alertRuleApi.ts index 7eb598b37fa4c..d748fb8973ae9 100644 --- a/public/app/features/alerting/unified/mocks/alertRuleApi.ts +++ b/public/app/features/alerting/unified/mocks/alertRuleApi.ts @@ -1,4 +1,5 @@ -import { rest } from 'msw'; +import 'whatwg-fetch'; +import { http, HttpResponse } from 'msw'; import { SetupServer } from 'msw/node'; import { PromRulesResponse } from 'app/types/unified-alerting-dto'; @@ -6,9 +7,9 @@ import { PromRulesResponse } from 'app/types/unified-alerting-dto'; import { PreviewResponse, PREVIEW_URL, PROM_RULES_URL } from '../api/alertRuleApi'; export function mockPreviewApiResponse(server: SetupServer, result: PreviewResponse) { - server.use(rest.post(PREVIEW_URL, (req, res, ctx) => res(ctx.json<PreviewResponse>(result)))); + server.use(http.post(PREVIEW_URL, () => HttpResponse.json(result))); } export function mockPromRulesApiResponse(server: SetupServer, result: PromRulesResponse) { - server.use(rest.get(PROM_RULES_URL, (req, res, ctx) => res(ctx.json<PromRulesResponse>(result)))); + server.use(http.get(PROM_RULES_URL, () => HttpResponse.json(result))); } diff --git a/public/app/features/alerting/unified/mocks/alertmanagerApi.ts b/public/app/features/alerting/unified/mocks/alertmanagerApi.ts index 492e01e74a28a..1b7e98806dcb8 100644 --- a/public/app/features/alerting/unified/mocks/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/mocks/alertmanagerApi.ts @@ -1,4 +1,5 @@ -import { rest } from 'msw'; +import 'whatwg-fetch'; +import { http, HttpResponse } from 'msw'; import { SetupServer } from 'msw/node'; import { @@ -14,7 +15,7 @@ export const defaultAlertmanagerChoiceResponse: AlertmanagersChoiceResponse = { numExternalAlertmanagers: 0, }; export function mockAlertmanagerChoiceResponse(server: SetupServer, response: AlertmanagersChoiceResponse) { - server.use(rest.get('/api/v1/ngalert', (req, res, ctx) => res(ctx.status(200), ctx.json(response)))); + server.use(http.get('/api/v1/ngalert', () => HttpResponse.json(response))); } export const emptyExternalAlertmanagersResponse: ExternalAlertmanagersResponse = { @@ -24,7 +25,7 @@ export const emptyExternalAlertmanagersResponse: ExternalAlertmanagersResponse = }, }; export function mockAlertmanagersResponse(server: SetupServer, response: ExternalAlertmanagersResponse) { - server.use(rest.get('/api/v1/ngalert/alertmanagers', (req, res, ctx) => res(ctx.status(200), ctx.json(response)))); + server.use(http.get('/api/v1/ngalert/alertmanagers', () => HttpResponse.json(response))); } export function mockAlertmanagerConfigResponse( @@ -33,8 +34,8 @@ export function mockAlertmanagerConfigResponse( response: AlertManagerCortexConfig ) { server.use( - rest.get(`/api/alertmanager/${getDatasourceAPIUid(alertManagerSourceName)}/config/api/v1/alerts`, (req, res, ctx) => - res(ctx.status(200), ctx.json(response)) + http.get(`/api/alertmanager/${getDatasourceAPIUid(alertManagerSourceName)}/config/api/v1/alerts`, () => + HttpResponse.json(response) ) ); } diff --git a/public/app/features/alerting/unified/mocks/plugins.ts b/public/app/features/alerting/unified/mocks/plugins.ts index eb8a2b5eab72b..df657222de971 100644 --- a/public/app/features/alerting/unified/mocks/plugins.ts +++ b/public/app/features/alerting/unified/mocks/plugins.ts @@ -1,4 +1,5 @@ -import { rest } from 'msw'; +import 'whatwg-fetch'; +import { http, HttpResponse } from 'msw'; import { SetupServer } from 'msw/lib/node'; import { PluginMeta } from '@grafana/data'; @@ -7,8 +8,11 @@ import { SupportedPlugin } from '../types/pluginBridges'; export function mockPluginSettings(server: SetupServer, plugin: SupportedPlugin, response?: PluginMeta) { server.use( - rest.get(`/api/plugins/${plugin}/settings`, (_req, res, ctx) => { - return response ? res(ctx.status(200), ctx.json(response)) : res(ctx.status(404)); + http.get(`/api/plugins/${plugin}/settings`, () => { + if (response) { + return HttpResponse.json(response); + } + return HttpResponse.json({}, { status: 404 }); }) ); } diff --git a/public/app/features/alerting/unified/mocks/rulerApi.ts b/public/app/features/alerting/unified/mocks/rulerApi.ts index 71339a4cf7241..d689d02382f57 100644 --- a/public/app/features/alerting/unified/mocks/rulerApi.ts +++ b/public/app/features/alerting/unified/mocks/rulerApi.ts @@ -1,14 +1,11 @@ -import { rest } from 'msw'; +import 'whatwg-fetch'; +import { http, HttpResponse } from 'msw'; import { SetupServer } from 'msw/node'; import { RulerRuleGroupDTO, RulerRulesConfigDTO } from '../../../../types/unified-alerting-dto'; export function mockRulerRulesApiResponse(server: SetupServer, rulesSourceName: string, response: RulerRulesConfigDTO) { - server.use( - rest.get(`/api/ruler/${rulesSourceName}/api/v1/rules`, (req, res, ctx) => - res(ctx.json<RulerRulesConfigDTO>(response)) - ) - ); + server.use(http.get(`/api/ruler/${rulesSourceName}/api/v1/rules`, () => HttpResponse.json(response))); } export function mockRulerRulesGroupApiResponse( @@ -19,8 +16,6 @@ export function mockRulerRulesGroupApiResponse( response: RulerRuleGroupDTO ) { server.use( - rest.get(`/api/ruler/${rulesSourceName}/api/v1/rules/${namespace}/${group}`, (req, res, ctx) => - res(ctx.json(response)) - ) + http.get(`/api/ruler/${rulesSourceName}/api/v1/rules/${namespace}/${group}`, () => HttpResponse.json(response)) ); } diff --git a/public/app/features/alerting/unified/mocks/templatesApi.ts b/public/app/features/alerting/unified/mocks/templatesApi.ts index cb6d18d4cf38c..d0e591fb3c544 100644 --- a/public/app/features/alerting/unified/mocks/templatesApi.ts +++ b/public/app/features/alerting/unified/mocks/templatesApi.ts @@ -1,12 +1,13 @@ -import { rest } from 'msw'; +import 'whatwg-fetch'; +import { http, HttpResponse } from 'msw'; import { SetupServer } from 'msw/node'; import { previewTemplateUrl, TemplatePreviewResponse } from '../api/templateApi'; export function mockPreviewTemplateResponse(server: SetupServer, response: TemplatePreviewResponse) { - server.use(rest.post(previewTemplateUrl, (req, res, ctx) => res(ctx.status(200), ctx.json(response)))); + server.use(http.post(previewTemplateUrl, () => HttpResponse.json(response))); } export function mockPreviewTemplateResponseRejected(server: SetupServer) { - server.use(rest.post(previewTemplateUrl, (req, res, ctx) => res(ctx.status(500), ctx.json('error')))); + server.use(http.post(previewTemplateUrl, () => HttpResponse.json('error', { status: 500 }))); } diff --git a/public/app/features/alerting/unified/routeGroupsMatcher.ts b/public/app/features/alerting/unified/routeGroupsMatcher.ts index 80b951ffc140b..bce330c7f786e 100644 --- a/public/app/features/alerting/unified/routeGroupsMatcher.ts +++ b/public/app/features/alerting/unified/routeGroupsMatcher.ts @@ -6,11 +6,20 @@ import { findMatchingAlertGroups, findMatchingRoutes, normalizeRoute, + unquoteRouteMatchers, } from './utils/notification-policies'; +export interface MatchOptions { + unquoteMatchers?: boolean; +} + export const routeGroupsMatcher = { - getRouteGroupsMap(rootRoute: RouteWithID, groups: AlertmanagerGroup[]): Map<string, AlertmanagerGroup[]> { - const normalizedRootRoute = normalizeRoute(rootRoute); + getRouteGroupsMap( + rootRoute: RouteWithID, + groups: AlertmanagerGroup[], + options?: MatchOptions + ): Map<string, AlertmanagerGroup[]> { + const normalizedRootRoute = getNormalizedRoute(rootRoute, options); function addRouteGroups(route: RouteWithID, acc: Map<string, AlertmanagerGroup[]>) { const routeGroups = findMatchingAlertGroups(normalizedRootRoute, route, groups); @@ -25,10 +34,14 @@ export const routeGroupsMatcher = { return routeGroupsMap; }, - matchInstancesToRoute(routeTree: RouteWithID, instancesToMatch: Labels[]): Map<string, AlertInstanceMatch[]> { + matchInstancesToRoute( + routeTree: RouteWithID, + instancesToMatch: Labels[], + options?: MatchOptions + ): Map<string, AlertInstanceMatch[]> { const result = new Map<string, AlertInstanceMatch[]>(); - const normalizedRootRoute = normalizeRoute(routeTree); + const normalizedRootRoute = getNormalizedRoute(routeTree, options); instancesToMatch.forEach((instance) => { const matchingRoutes = findMatchingRoutes(normalizedRootRoute, Object.entries(instance)); @@ -47,4 +60,8 @@ export const routeGroupsMatcher = { }, }; +function getNormalizedRoute(route: RouteWithID, options?: MatchOptions): RouteWithID { + return options?.unquoteMatchers ? unquoteRouteMatchers(normalizeRoute(route)) : normalizeRoute(route); +} + export type RouteGroupsMatcher = typeof routeGroupsMatcher; diff --git a/public/app/features/alerting/unified/search/rulesSearchParser.ts b/public/app/features/alerting/unified/search/rulesSearchParser.ts index 8e9b6157180d4..d1d47f707e67b 100644 --- a/public/app/features/alerting/unified/search/rulesSearchParser.ts +++ b/public/app/features/alerting/unified/search/rulesSearchParser.ts @@ -20,6 +20,7 @@ export interface RulesFilter { dataSourceNames: string[]; labels: string[]; ruleHealth?: RuleHealth; + dashboardUid?: string; } const filterSupportedTerms: FilterSupportedTerm[] = [ @@ -31,6 +32,7 @@ const filterSupportedTerms: FilterSupportedTerm[] = [ FilterSupportedTerm.state, FilterSupportedTerm.type, FilterSupportedTerm.health, + FilterSupportedTerm.dashboard, ]; export enum RuleHealth { @@ -53,6 +55,7 @@ export function getSearchFilterFromQuery(query: string): RulesFilter { [terms.StateToken]: (value) => (filter.ruleState = parseStateToken(value)), [terms.TypeToken]: (value) => (isPromRuleType(value) ? (filter.ruleType = value) : undefined), [terms.HealthToken]: (value) => (filter.ruleHealth = getRuleHealth(value)), + [terms.DashboardToken]: (value) => (filter.dashboardUid = value), [terms.FreeFormExpression]: (value) => filter.freeFormWords.push(value), }; @@ -92,6 +95,9 @@ export function applySearchFilterToQuery(query: string, filter: RulesFilter): st if (filter.labels) { filterStateArray.push(...filter.labels.map((l) => ({ type: terms.LabelToken, value: l }))); } + if (filter.dashboardUid) { + filterStateArray.push({ type: terms.DashboardToken, value: filter.dashboardUid }); + } if (filter.freeFormWords) { filterStateArray.push(...filter.freeFormWords.map((word) => ({ type: terms.FreeFormExpression, value: word }))); } diff --git a/public/app/features/alerting/unified/search/search.grammar b/public/app/features/alerting/unified/search/search.grammar index a8d64b6dceb12..f1be544522016 100644 --- a/public/app/features/alerting/unified/search/search.grammar +++ b/public/app/features/alerting/unified/search/search.grammar @@ -1,6 +1,6 @@ @top AlertRuleSearch { expression+ } -@dialects { dataSourceFilter, nameSpaceFilter, labelFilter, groupFilter, ruleFilter, stateFilter, typeFilter, healthFilter } +@dialects { dataSourceFilter, nameSpaceFilter, labelFilter, groupFilter, ruleFilter, stateFilter, typeFilter, healthFilter, dashboardFilter } expression { (FilterExpression | FreeFormExpression) expression } @@ -14,7 +14,8 @@ FilterExpression { filter<RuleToken> | filter<StateToken> | filter<TypeToken> | - filter<HealthToken> + filter<HealthToken> | + filter<DashboardToken> } filter<token> { token FilterValue } @@ -41,6 +42,7 @@ filter<token> { token FilterValue } StateToken[@dialect=stateFilter] { filterToken<"state"> } TypeToken[@dialect=typeFilter] { filterToken<"type"> } HealthToken[@dialect=healthFilter] { filterToken<"health"> } + DashboardToken[@dialect=dashboardFilter] { filterToken<"dashboard"> } @precedence { DataSourceToken, word } @precedence { NameSpaceToken, word } @@ -50,5 +52,6 @@ filter<token> { token FilterValue } @precedence { StateToken, word } @precedence { TypeToken, word } @precedence { HealthToken, word } + @precedence { DashboardToken, word } } diff --git a/public/app/features/alerting/unified/search/search.js b/public/app/features/alerting/unified/search/search.js index 8ee40ac1d7cb9..8dd0e3c309e38 100644 --- a/public/app/features/alerting/unified/search/search.js +++ b/public/app/features/alerting/unified/search/search.js @@ -3,28 +3,29 @@ import { LRParser } from '@lezer/lr'; export const parser = LRParser.deserialize({ version: 14, states: - "!vOQOPOOOrOPO'#ChOOOO'#Ch'#ChOQOPO'#ClOOOO'#Ci'#CiQQOPOOO!gOQO'#C^O!lOPO'#CjO!qOPO,59SOOOO,59W,59WOOOO-E6g-E6gOOOO,58x,58xOOOO,59U,59UOOOO-E6h-E6h", + "!vOQOPOOOuOPO'#CiOOOO'#Ci'#CiOQOPO'#CmOOOO'#Cj'#CjQQOPOOO!mOQO'#C^O!rOPO'#CkO!wOPO,59TOOOO,59X,59XOOOO-E6h-E6hOOOO,58x,58xOOOO,59V,59VOOOO-E6i-E6i", stateData: - '$O~ORUOTUOUUOVUOWUOXUOYUOZUOaPOcQO~ObVOR[XT[XU[XV[XW[XX[XY[XZ[Xa[Xc[X~OSZO~Oa[O~ObVOR[aT[aU[aV[aW[aX[aY[aZ[aa[ac[a~OR~T~U~V~W~Y~Z~RZYXWVUTa~', - goto: 'zaPPbPPPPPPPPPbgmPsVRORTQTORYTQWPR]WSSOTRXR', + '$[~ORUOTUOUUOVUOWUOXUOYUOZUO[UObPOdQO~OcVOR]XT]XU]XV]XW]XX]XY]XZ]X[]Xb]Xd]X~OSZO~Ob[O~OcVOR]aT]aU]aV]aW]aX]aY]aZ]a[]ab]ad]a~OR~T~U~V~W~Y~Z~[~R[ZYXWVUTb~', + goto: '{bPPcPPPPPPPPPPchnPtVRORTQTORYTQWPR]WSSOTRXR', nodeNames: - '⚠ AlertRuleSearch FilterExpression DataSourceToken FilterValue NameSpaceToken LabelToken GroupToken RuleToken StateToken TypeToken HealthToken FreeFormExpression', - maxTerm: 19, + '⚠ AlertRuleSearch FilterExpression DataSourceToken FilterValue NameSpaceToken LabelToken GroupToken RuleToken StateToken TypeToken HealthToken DashboardToken FreeFormExpression', + maxTerm: 20, skippedNodes: [0], repeatNodeCount: 2, tokenData: - "#$QRRqqr#Yrs&fst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]+h!]#W#Y#W#X,z#X#Z#Y#Z#[=b#[#]F[#]#`#Y#`#a!!n#a#b#Y#b#c!+h#c#f#Y#f#g!:f#g#h!Av#h#i!Jp#i$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR#acSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YQ$qcSQqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lQ&PP;=`<%l$lQ&VP;=`;NQ$lR&]P;=`<%l#YR&cP;=`;NQ#YR&irX^(spq(sqr(sst(stu(suv(svw(swx(sxy(syz(sz{(s{|(s|!P(s!P!Q(s!Q![(s![!](s!]#y(s#y#z(s#z$f(s$f$g(s$g#BY(s#BY#BZ(s#BZ$Ch(s$IS$I_(s$I|$JO(s$JT$JU(s$JU$KV(s$KV$KW(s$KW&FU(s&FU&FV(s&FV;'S(s;'S;(d+[;(d;(e+b<%lO(sR(vsX^(spq(sqr(srs+Tst(stu(suv(svw(swx(sxy(syz(sz{(s{|(s|!P(s!P!Q(s!Q![(s![!](s!]#y(s#y#z(s#z$f(s$f$g(s$g#BY(s#BY#BZ(s#BZ$Ch(s$IS$I_(s$I|$JO(s$JT$JU(s$JU$KV(s$KV$KW(s$KW&FU(s&FU&FV(s&FV;'S(s;'S;(d+[;(d;(e+b<%lO(sR+[OSQcPR+_P;=`<%l(sR+eP;=`;NQ(sR+ocSQbPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR-ReSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U.d#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR.keSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#h#Y#h#i/|#i$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR0TeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U1f#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR1meSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#g#Y#g#h3O#h$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR3VeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#c#Y#c#d4h#d$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR4oeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#i#Y#i#j6Q#j$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR6XeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#f#Y#f#g7j#g$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR7qeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#V#Y#V#W9S#W$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR9ZeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y:l#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR:scSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]<O!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR<VcSQRPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR=ieSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#f#Y#f#g>z#g$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR?ReSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#c#Y#c#d@d#d$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR@keSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#i#Y#i#jA|#j$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRBTeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#d#Y#d#eCf#e$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRCmcSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]Dx!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YREPcSQVPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lRFceSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#YGt#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRG{eSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#UI^#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRIeeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#`#Y#`#aJv#a$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRJ}eSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#h#Y#h#iL`#i$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRLgeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#[#Y#[#]Mx#]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRNPcSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]! [!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR! ccSQZPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR!!ueSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U!$W#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!$_eSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#U#Y#U#V!%p#V$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!%weSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y!'Y#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!'aeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#`#Y#`#a!(r#a$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!(ycSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]!*U!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!*]cSQUPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR!+oeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U!-Q#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!-XeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#a#Y#a#b!.j#b$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!.qeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y!0S#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!0ZeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#g#Y#g#h!1l#h$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!1seSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#d#Y#d#e!3U#e$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!3]eSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U!4n#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!4ueSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#V#Y#V#W!6W#W$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!6_eSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y!7p#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!7wcSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]!9S!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!9ZcSQTPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR!:meSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#i#Y#i#j!<O#j$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!<VeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#`#Y#`#a!=h#a$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!=oeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y!?Q#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!?XcSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]!@d!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!@kcSQWPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR!A}eSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#h#Y#h#i!C`#i$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!CgeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U!Dx#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!EPeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#h#Y#h#i!Fb#i$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!FieSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y!Gz#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!HRcSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]!I^!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!IecSQXPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR!JweSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#m#Y#m#n!LY#n$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!LaeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#d#Y#d#e!Mr#e$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!MyeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y# [#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR# ccSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]#!n!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR#!ucSQYPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$l", + "#0PRRqqr#Yrs&fst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]+h!]#W#Y#W#X,z#X#Z#Y#Z#[Ia#[#]!$Z#]#`#Y#`#a!.m#a#b#Y#b#c!7g#c#f#Y#f#g!Fe#g#h!Mu#h#i#(o#i$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR#acSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YQ$qcSQqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lQ&PP;=`<%l$lQ&VP;=`;NQ$lR&]P;=`<%l#YR&cP;=`;NQ#YR&irX^(spq(sqr(sst(stu(suv(svw(swx(sxy(syz(sz{(s{|(s|!P(s!P!Q(s!Q![(s![!](s!]#y(s#y#z(s#z$f(s$f$g(s$g#BY(s#BY#BZ(s#BZ$Ch(s$IS$I_(s$I|$JO(s$JT$JU(s$JU$KV(s$KV$KW(s$KW&FU(s&FU&FV(s&FV;'S(s;'S;(d+[;(d;(e+b<%lO(sR(vsX^(spq(sqr(srs+Tst(stu(suv(svw(swx(sxy(syz(sz{(s{|(s|!P(s!P!Q(s!Q![(s![!](s!]#y(s#y#z(s#z$f(s$f$g(s$g#BY(s#BY#BZ(s#BZ$Ch(s$IS$I_(s$I|$JO(s$JT$JU(s$JU$KV(s$KV$KW(s$KW&FU(s&FU&FV(s&FV;'S(s;'S;(d+[;(d;(e+b<%lO(sR+[OSQdPR+_P;=`<%l(sR+eP;=`;NQ(sR+ocSQcPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR-ReSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U.d#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR.kfSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#g#Y#g#h0P#h#i;{#i$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR0WeSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#[#Y#[#]1i#]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR1peSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#U#Y#U#V3R#V$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR3YeSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#c#Y#c#d4k#d$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR4reSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U6T#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR6[eSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#f#Y#f#g7m#g$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR7teSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#W#Y#W#X9V#X$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR9^cSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]:i!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR:pcSQ[Pqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR<SeSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U=e#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR=leSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#g#Y#g#h>}#h$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR?UeSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#c#Y#c#d@g#d$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR@neSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#i#Y#i#jBP#j$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRBWeSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#f#Y#f#gCi#g$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRCpeSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#V#Y#V#WER#W$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YREYeSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#YFk#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRFrcSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]G}!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRHUcSQRPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lRIheSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#f#Y#f#gJy#g$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRKQeSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#c#Y#c#dLc#d$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRLjeSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#i#Y#i#jM{#j$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRNSeSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#d#Y#d#e! e#e$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR! lcSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]!!w!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!#OcSQVPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR!$beSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y!%s#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!%zeSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U!']#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!'deSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#`#Y#`#a!(u#a$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!(|eSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#h#Y#h#i!*_#i$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!*feSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#[#Y#[#]!+w#]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!,OcSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]!-Z!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!-bcSQZPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR!.teSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U!0V#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!0^eSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#U#Y#U#V!1o#V$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!1veSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y!3X#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!3`eSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#`#Y#`#a!4q#a$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!4xcSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]!6T!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!6[cSQUPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR!7neSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U!9P#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!9WeSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#a#Y#a#b!:i#b$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!:peSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y!<R#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!<YeSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#g#Y#g#h!=k#h$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!=reSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#d#Y#d#e!?T#e$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!?[eSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U!@m#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!@teSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#V#Y#V#W!BV#W$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!B^eSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y!Co#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!CvcSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]!ER!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!EYcSQTPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR!FleSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#i#Y#i#j!G}#j$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!HUeSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#`#Y#`#a!Ig#a$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!IneSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y!KP#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!KWcSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]!Lc!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!LjcSQWPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR!M|eSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#h#Y#h#i# _#i$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR# feSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U#!w#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR##OeSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#h#Y#h#i#$a#i$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR#$heSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y#%y#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR#&QcSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]#']!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR#'dcSQXPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR#(veSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#m#Y#m#n#*X#n$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR#*`eSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#d#Y#d#e#+q#e$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR#+xeSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y#-Z#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR#-bcSQbPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]#.m!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR#.tcSQYPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$l", tokenizers: [0, 1], topRules: { AlertRuleSearch: [0, 1] }, dialects: { - dataSourceFilter: 114, - nameSpaceFilter: 116, - labelFilter: 118, - groupFilter: 120, - ruleFilter: 122, - stateFilter: 66, - typeFilter: 124, - healthFilter: 126, + dataSourceFilter: 123, + nameSpaceFilter: 125, + labelFilter: 127, + groupFilter: 129, + ruleFilter: 131, + stateFilter: 72, + typeFilter: 133, + healthFilter: 135, + dashboardFilter: 137, }, - tokenPrec: 128, + tokenPrec: 139, }); diff --git a/public/app/features/alerting/unified/search/search.terms.js b/public/app/features/alerting/unified/search/search.terms.js index 0cbe635d4731b..c90c841a0853f 100644 --- a/public/app/features/alerting/unified/search/search.terms.js +++ b/public/app/features/alerting/unified/search/search.terms.js @@ -10,7 +10,8 @@ export const AlertRuleSearch = 1, StateToken = 9, TypeToken = 10, HealthToken = 11, - FreeFormExpression = 12, + DashboardToken = 12, + FreeFormExpression = 13, Dialect_dataSourceFilter = 0, Dialect_nameSpaceFilter = 1, Dialect_labelFilter = 2, @@ -18,4 +19,5 @@ export const AlertRuleSearch = 1, Dialect_ruleFilter = 4, Dialect_stateFilter = 5, Dialect_typeFilter = 6, - Dialect_healthFilter = 7; + Dialect_healthFilter = 7, + Dialect_dashboardFilter = 8; diff --git a/public/app/features/alerting/unified/search/searchParser.ts b/public/app/features/alerting/unified/search/searchParser.ts index 75069558987b4..bf3e4be391ecb 100644 --- a/public/app/features/alerting/unified/search/searchParser.ts +++ b/public/app/features/alerting/unified/search/searchParser.ts @@ -13,6 +13,7 @@ const filterTokenToTypeMap: Record<number, string> = { [terms.StateToken]: 'state', [terms.TypeToken]: 'type', [terms.HealthToken]: 'health', + [terms.DashboardToken]: 'dashboard', }; // This enum allows to configure parser behavior @@ -27,6 +28,7 @@ export enum FilterSupportedTerm { state = 'stateFilter', type = 'typeFilter', health = 'healthFilter', + dashboard = 'dashboardFilter', } export type QueryFilterMapper = Record<number, (filter: string) => void>; diff --git a/public/app/features/alerting/unified/state/AlertmanagerContext.tsx b/public/app/features/alerting/unified/state/AlertmanagerContext.tsx index 05fdc334d9442..f2d6e774ce8d8 100644 --- a/public/app/features/alerting/unified/state/AlertmanagerContext.tsx +++ b/public/app/features/alerting/unified/state/AlertmanagerContext.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; -import { useQueryParams } from 'app/core/hooks/useQueryParams'; import store from 'app/core/store'; import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types'; import { useAlertManagersByPermission } from '../hooks/useAlertManagerSources'; +import { useURLSearchParams } from '../hooks/useURLSearchParams'; import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from '../utils/constants'; import { AlertManagerDataSource, @@ -30,7 +30,7 @@ interface Props extends React.PropsWithChildren { } const AlertmanagerProvider = ({ children, accessType, alertmanagerSourceName }: Props) => { - const [queryParams, updateQueryParams] = useQueryParams(); + const [queryParams, updateQueryParams] = useURLSearchParams(); const allAvailableAlertManagers = useAlertManagersByPermission(accessType); const availableAlertManagers = allAvailableAlertManagers.availableInternalDataSources.concat( allAvailableAlertManagers.availableExternalDataSources @@ -44,7 +44,7 @@ const AlertmanagerProvider = ({ children, accessType, alertmanagerSourceName }: if (selectedAlertManager === GRAFANA_RULES_SOURCE_NAME) { store.delete(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY); - updateQueryParams({ [ALERTMANAGER_NAME_QUERY_KEY]: null }); + updateQueryParams({ [ALERTMANAGER_NAME_QUERY_KEY]: undefined }); } else { store.set(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, selectedAlertManager); updateQueryParams({ [ALERTMANAGER_NAME_QUERY_KEY]: selectedAlertManager }); @@ -53,10 +53,19 @@ const AlertmanagerProvider = ({ children, accessType, alertmanagerSourceName }: [availableAlertManagers, updateQueryParams] ); - const sourceFromQuery = queryParams[ALERTMANAGER_NAME_QUERY_KEY]; + const sourceFromQuery = queryParams.get(ALERTMANAGER_NAME_QUERY_KEY); const sourceFromStore = store.get(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY); const defaultSource = GRAFANA_RULES_SOURCE_NAME; + // This overrides AM in the store to be in sync with the one in the URL + // When the user uses multiple tabs with different AMs, the store will be changing all the time + // It's safest to always use URLs with alertmanager query param + React.useEffect(() => { + if (sourceFromQuery && sourceFromQuery !== sourceFromStore) { + store.set(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, sourceFromQuery); + } + }, [sourceFromQuery, sourceFromStore]); + // queryParam > localStorage > default const desiredAlertmanager = alertmanagerSourceName ?? sourceFromQuery ?? sourceFromStore ?? defaultSource; const selectedAlertmanager = isAlertManagerAvailable(availableAlertManagers, desiredAlertmanager) diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index f6cb7a5e0a371..377877f84a448 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -2,6 +2,7 @@ import { AsyncThunk, createAsyncThunk } from '@reduxjs/toolkit'; import { isEmpty } from 'lodash'; import { locationService } from '@grafana/runtime'; +import { logMeasurement } from '@grafana/runtime/src/utils/logging'; import { AlertmanagerAlert, AlertManagerCortexConfig, @@ -36,6 +37,8 @@ import { backendSrv } from '../../../../core/services/backend_srv'; import { logInfo, LogMessages, + trackSwitchToPoliciesRouting, + trackSwitchToSimplifiedRouting, withPerformanceLogging, withPromRulesMetadataLogging, withRulerRulesMetadataLogging, @@ -65,6 +68,7 @@ import { FetchRulerRulesFilter, setRulerRuleGroup, } from '../api/ruler'; +import { encodeGrafanaNamespace } from '../components/expressions/util'; import { RuleFormType, RuleFormValues } from '../types/rule-form'; import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager'; import { @@ -77,7 +81,7 @@ import { makeAMLink } from '../utils/misc'; import { AsyncRequestMapSlice, withAppEvents, withSerializedError } from '../utils/redux'; import * as ruleId from '../utils/rule-id'; import { getRulerClient } from '../utils/rulerClient'; -import { getAlertInfo, isRulerNotSupportedResponse } from '../utils/rules'; +import { getAlertInfo, isGrafanaRulerRule, isRulerNotSupportedResponse } from '../utils/rules'; import { safeParseDurationstr } from '../utils/time'; function getDataSourceConfig(getState: () => unknown, rulesSourceName: string) { @@ -121,11 +125,10 @@ export const fetchPromRulesAction = createAsyncThunk( ): Promise<RuleNamespace[]> => { await thunkAPI.dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })); - const fetchRulesWithLogging = withPromRulesMetadataLogging( - fetchRules, - `[${rulesSourceName}] Prometheus rules loaded`, - { dataSourceName: rulesSourceName, thunk: 'unifiedalerting/fetchPromRules' } - ); + const fetchRulesWithLogging = withPromRulesMetadataLogging('unifiedalerting/fetchPromRules', fetchRules, { + dataSourceName: rulesSourceName, + thunk: 'unifiedalerting/fetchPromRules', + }); return await withSerializedError( fetchRulesWithLogging(rulesSourceName, filter, limitAlerts, matcher, state, identifier) @@ -163,8 +166,8 @@ export const fetchRulerRulesAction = createAsyncThunk( const rulerConfig = getDataSourceRulerConfig(getState, rulesSourceName); const fetchRulerRulesWithLogging = withRulerRulesMetadataLogging( + 'unifiedalerting/fetchRulerRules', fetchRulerRules, - `[${rulesSourceName}] Ruler rules loaded`, { dataSourceName: rulesSourceName, thunk: 'unifiedalerting/fetchRulerRules', @@ -189,7 +192,7 @@ export function fetchPromAndRulerRulesAction({ limitAlerts?: number; matcher?: Matcher[]; state?: string[]; -}): ThunkResult<void> { +}): ThunkResult<Promise<void>> { return async (dispatch, getState) => { await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })); const dsConfig = getDataSourceConfig(getState, rulesSourceName); @@ -204,14 +207,9 @@ export function fetchPromAndRulerRulesAction({ export const fetchSilencesAction = createAsyncThunk( 'unifiedalerting/fetchSilences', (alertManagerSourceName: string): Promise<Silence[]> => { - const fetchSilencesWithLogging = withPerformanceLogging( - fetchSilences, - `[${alertManagerSourceName}] Silences loaded`, - { - dataSourceName: alertManagerSourceName, - thunk: 'unifiedalerting/fetchSilences', - } - ); + const fetchSilencesWithLogging = withPerformanceLogging('unifiedalerting/fetchSilences', fetchSilences, { + dataSourceName: alertManagerSourceName, + }); return withSerializedError(fetchSilencesWithLogging(alertManagerSourceName)); } @@ -264,8 +262,8 @@ export const fetchRulesSourceBuildInfoAction = createAsyncThunk( const { id, name } = ds; const discoverFeaturesWithLogging = withPerformanceLogging( + 'unifiedalerting/fetchPromBuildinfo', discoverFeatures, - `[${rulesSourceName}] Rules source features discovered`, { dataSourceName: rulesSourceName, thunk: 'unifiedalerting/fetchPromBuildinfo', @@ -337,8 +335,8 @@ export function fetchAllPromAndRulerRulesAction( }) ); - logInfo('All Prom and Ruler rules loaded', { - loadTimeMs: (performance.now() - allStartLoadingTs).toFixed(0), + logMeasurement('unifiedalerting/fetchAllPromAndRulerRulesAction', { + loadTimeMs: performance.now() - allStartLoadingTs, }); }; } @@ -456,6 +454,7 @@ export const saveRuleFormAction = createAsyncThunk( const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, GRAFANA_RULES_SOURCE_NAME); const rulerClient = getRulerClient(rulerConfig); identifier = await rulerClient.saveGrafanaRule(values, evaluateEvery, existing); + reportSwitchingRoutingType(values, existing); await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME })); } else { throw new Error('Unexpected rule form type'); @@ -490,6 +489,24 @@ export const saveRuleFormAction = createAsyncThunk( ) ); +function reportSwitchingRoutingType(values: RuleFormValues, existingRule: RuleWithLocation<RulerRuleDTO> | undefined) { + // track if the user switched from simplified routing to policies routing or vice versa + if (isGrafanaRulerRule(existingRule?.rule)) { + const ga = existingRule?.rule.grafana_alert; + const existingWasUsingSimplifiedRouting = Boolean(ga?.notification_settings?.receiver); + const newValuesUsesSimplifiedRouting = values.manualRouting; + const shouldTrackSwitchToSimplifiedRouting = !existingWasUsingSimplifiedRouting && newValuesUsesSimplifiedRouting; + const shouldTrackSwitchToPoliciesRouting = existingWasUsingSimplifiedRouting && !newValuesUsesSimplifiedRouting; + + if (shouldTrackSwitchToSimplifiedRouting) { + trackSwitchToSimplifiedRouting(); + } + if (shouldTrackSwitchToPoliciesRouting) { + trackSwitchToPoliciesRouting(); + } + } +} + export const fetchGrafanaNotifiersAction = createAsyncThunk( 'unifiedalerting/fetchGrafanaNotifiers', (): Promise<NotifierDTO[]> => withSerializedError(fetchNotifiers()) @@ -575,7 +592,7 @@ export const createOrUpdateSilenceAction = createAsyncThunk<void, UpdateSilenceA (async () => { await createOrUpdateSilence(alertManagerSourceName, payload); if (exitOnSave) { - locationService.push('/alerting/silences'); + locationService.push(makeAMLink('/alerting/silences', alertManagerSourceName)); } })() ), @@ -692,10 +709,24 @@ export const deleteMuteTimingAction = (alertManagerSourceName: string, muteTimin alertmanagerApi.endpoints.getAlertmanagerConfiguration.initiate(alertManagerSourceName) ).unwrap(); - const muteIntervals = - config?.alertmanager_config?.mute_time_intervals?.filter(({ name }) => name !== muteTimingName) ?? []; + const isGrafanaDatasource = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME; + + const muteIntervalsFiltered = + (config?.alertmanager_config?.mute_time_intervals ?? [])?.filter(({ name }) => name !== muteTimingName) ?? []; + const timeIntervalsFiltered = + (config?.alertmanager_config?.time_intervals ?? [])?.filter(({ name }) => name !== muteTimingName) ?? []; + + const time_intervals_without_mute_to_save = isGrafanaDatasource + ? { + mute_time_intervals: [...muteIntervalsFiltered, ...timeIntervalsFiltered], + } + : { + time_intervals: timeIntervalsFiltered, + mute_time_intervals: muteIntervalsFiltered, + }; if (config) { + const { mute_time_intervals: _, ...configWithoutMuteTimings } = config?.alertmanager_config ?? {}; withAppEvents( dispatch( updateAlertManagerConfigAction({ @@ -704,11 +735,11 @@ export const deleteMuteTimingAction = (alertManagerSourceName: string, muteTimin newConfig: { ...config, alertmanager_config: { - ...config.alertmanager_config, + ...configWithoutMuteTimings, route: config.alertmanager_config.route ? removeMuteTimingFromRoute(muteTimingName, config.alertmanager_config?.route) : undefined, - mute_time_intervals: muteIntervals, + ...time_intervals_without_mute_to_save, }, }, }) @@ -745,6 +776,7 @@ interface UpdateNamespaceAndGroupOptions { newNamespaceName: string; newGroupName: string; groupInterval?: string; + folderUid?: string; } export const rulesInSameGroupHaveInvalidFor = (rules: RulerRuleDTO[], everyDuration: string) => { @@ -768,13 +800,22 @@ export const updateLotexNamespaceAndGroupAction: AsyncThunk< return withAppEvents( withSerializedError( (async () => { - const { rulesSourceName, namespaceName, groupName, newNamespaceName, newGroupName, groupInterval } = options; + const { + rulesSourceName, + namespaceName, + groupName, + newNamespaceName, + newGroupName, + groupInterval, + folderUid, + } = options; const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, rulesSourceName); // fetch rules and perform sanity checks const rulesResult = await fetchRulerRules(rulerConfig); const existingNamespace = Boolean(rulesResult[namespaceName]); + if (!existingNamespace) { throw new Error(`Namespace "${namespaceName}" not found.`); } @@ -793,11 +834,14 @@ export const updateLotexNamespaceAndGroupAction: AsyncThunk< } const newNamespaceAlreadyExists = Boolean(rulesResult[newNamespaceName]); - if (newNamespaceName !== namespaceName && newNamespaceAlreadyExists) { + const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME; + const originalNamespace = isGrafanaManagedGroup ? encodeGrafanaNamespace(namespaceName) : namespaceName; + + if (newNamespaceName !== originalNamespace && newNamespaceAlreadyExists) { throw new Error(`Namespace "${newNamespaceName}" already exists.`); } if ( - newNamespaceName === namespaceName && + newNamespaceName === originalNamespace && groupName === newGroupName && groupInterval === existingGroup.interval ) { @@ -819,8 +863,8 @@ export const updateLotexNamespaceAndGroupAction: AsyncThunk< } } // if renaming namespace - make new copies of all groups, then delete old namespace - - if (newNamespaceName !== namespaceName) { + // this is only possible for cloud rules + if (newNamespaceName !== originalNamespace) { for (const group of rulesResult[namespaceName]) { await setRulerRuleGroup( rulerConfig, @@ -834,19 +878,19 @@ export const updateLotexNamespaceAndGroupAction: AsyncThunk< : group ); } - await deleteNamespace(rulerConfig, namespaceName); + await deleteNamespace(rulerConfig, folderUid || namespaceName); // if only modifying group... } else { // save updated group - await setRulerRuleGroup(rulerConfig, namespaceName, { + await setRulerRuleGroup(rulerConfig, folderUid || namespaceName, { ...existingGroup, name: newGroupName, interval: groupInterval, }); // if group name was changed, delete old group if (newGroupName !== groupName) { - await deleteRulerRulesGroup(rulerConfig, namespaceName, groupName); + await deleteRulerRulesGroup(rulerConfig, folderUid || namespaceName, groupName); } } @@ -867,6 +911,7 @@ interface UpdateRulesOrderOptions { namespaceName: string; groupName: string; newRules: RulerRuleDTO[]; + folderUid: string; } export const updateRulesOrder = createAsyncThunk( @@ -875,7 +920,7 @@ export const updateRulesOrder = createAsyncThunk( return withAppEvents( withSerializedError( (async () => { - const { rulesSourceName, namespaceName, groupName, newRules } = options; + const { rulesSourceName, namespaceName, groupName, newRules, folderUid } = options; const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, rulesSourceName); const rulesResult = await fetchRulerRules(rulerConfig); @@ -891,7 +936,7 @@ export const updateRulesOrder = createAsyncThunk( rules: newRules, }; - await setRulerRuleGroup(rulerConfig, namespaceName, payload); + await setRulerRuleGroup(rulerConfig, folderUid ?? namespaceName, payload); await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName })); })() diff --git a/public/app/features/alerting/unified/types/mute-timing-form.ts b/public/app/features/alerting/unified/types/mute-timing-form.ts index 241e29424f06b..c8e32e7cecd67 100644 --- a/public/app/features/alerting/unified/types/mute-timing-form.ts +++ b/public/app/features/alerting/unified/types/mute-timing-form.ts @@ -6,10 +6,11 @@ export type MuteTimingFields = { }; export type MuteTimingIntervalFields = { - times: TimeRange[]; - weekdays: string; - days_of_month: string; - months: string; - years: string; + times?: TimeRange[]; + weekdays?: string; + days_of_month?: string; + months?: string; + years?: string; location?: string; + disable: boolean; }; diff --git a/public/app/features/alerting/unified/types/receiver-form.ts b/public/app/features/alerting/unified/types/receiver-form.ts index f6c48ffae7263..d896f03f3f35a 100644 --- a/public/app/features/alerting/unified/types/receiver-form.ts +++ b/public/app/features/alerting/unified/types/receiver-form.ts @@ -6,7 +6,7 @@ import { CloudNotifierType, NotifierType } from 'app/types'; import { ControlledField } from '../hooks/useControlledFieldArray'; export interface ChannelValues { - __id: string; // used to correllate form values to original DTOs + __id: string; // used to correlate form values to original DTOs type: string; settings: Record<string, any>; secureSettings: Record<string, any>; diff --git a/public/app/features/alerting/unified/useRouteGroupsMatcher.ts b/public/app/features/alerting/unified/useRouteGroupsMatcher.ts index e70f1d0f79e4d..a48d3e4c61127 100644 --- a/public/app/features/alerting/unified/useRouteGroupsMatcher.ts +++ b/public/app/features/alerting/unified/useRouteGroupsMatcher.ts @@ -1,14 +1,12 @@ import * as comlink from 'comlink'; import { useCallback, useEffect } from 'react'; -import { logError } from '@grafana/runtime'; - import { AlertmanagerGroup, RouteWithID } from '../../../plugins/datasource/alertmanager/types'; import { Labels } from '../../../types/unified-alerting-dto'; -import { logInfo } from './Analytics'; +import { logError, logInfo } from './Analytics'; import { createWorker } from './createRouteGroupsMatcherWorker'; -import type { RouteGroupsMatcher } from './routeGroupsMatcher'; +import type { MatchOptions, RouteGroupsMatcher } from './routeGroupsMatcher'; let routeMatcher: comlink.Remote<RouteGroupsMatcher> | undefined; @@ -57,43 +55,49 @@ export function useRouteGroupsMatcher() { return () => null; }, []); - const getRouteGroupsMap = useCallback(async (rootRoute: RouteWithID, alertGroups: AlertmanagerGroup[]) => { - validateWorker(routeMatcher); + const getRouteGroupsMap = useCallback( + async (rootRoute: RouteWithID, alertGroups: AlertmanagerGroup[], options?: MatchOptions) => { + validateWorker(routeMatcher); - const startTime = performance.now(); + const startTime = performance.now(); - const result = await routeMatcher.getRouteGroupsMap(rootRoute, alertGroups); + const result = await routeMatcher.getRouteGroupsMap(rootRoute, alertGroups, options); - const timeSpent = performance.now() - startTime; + const timeSpent = performance.now() - startTime; - logInfo(`Route Groups Matched in ${timeSpent} ms`, { - matchingTime: timeSpent.toString(), - alertGroupsCount: alertGroups.length.toString(), - // Counting all nested routes might be too time-consuming, so we only count the first level - topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0', - }); + logInfo(`Route Groups Matched in ${timeSpent} ms`, { + matchingTime: timeSpent.toString(), + alertGroupsCount: alertGroups.length.toString(), + // Counting all nested routes might be too time-consuming, so we only count the first level + topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0', + }); - return result; - }, []); + return result; + }, + [] + ); - const matchInstancesToRoute = useCallback(async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => { - validateWorker(routeMatcher); + const matchInstancesToRoute = useCallback( + async (rootRoute: RouteWithID, instancesToMatch: Labels[], options?: MatchOptions) => { + validateWorker(routeMatcher); - const startTime = performance.now(); + const startTime = performance.now(); - const result = await routeMatcher.matchInstancesToRoute(rootRoute, instancesToMatch); + const result = await routeMatcher.matchInstancesToRoute(rootRoute, instancesToMatch, options); - const timeSpent = performance.now() - startTime; + const timeSpent = performance.now() - startTime; - logInfo(`Instances Matched in ${timeSpent} ms`, { - matchingTime: timeSpent.toString(), - instancesToMatchCount: instancesToMatch.length.toString(), - // Counting all nested routes might be too time-consuming, so we only count the first level - topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0', - }); + logInfo(`Instances Matched in ${timeSpent} ms`, { + matchingTime: timeSpent.toString(), + instancesToMatchCount: instancesToMatch.length.toString(), + // Counting all nested routes might be too time-consuming, so we only count the first level + topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0', + }); - return result; - }, []); + return result; + }, + [] + ); return { getRouteGroupsMap, matchInstancesToRoute }; } diff --git a/public/app/features/alerting/unified/utils/__snapshots__/routeTree.test.ts.snap b/public/app/features/alerting/unified/utils/__snapshots__/routeTree.test.ts.snap new file mode 100644 index 0000000000000..bd1b648b40672 --- /dev/null +++ b/public/app/features/alerting/unified/utils/__snapshots__/routeTree.test.ts.snap @@ -0,0 +1,87 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`addRouteToReferenceRoute should be able to add above 1`] = ` +{ + "id": "route-1", + "routes": [ + { + "id": "route-2", + }, + { + "continue": undefined, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, + "match": undefined, + "match_re": undefined, + "matchers": undefined, + "mute_time_intervals": undefined, + "object_matchers": undefined, + "receiver": "new-route", + "repeat_interval": undefined, + "routes": undefined, + }, + { + "id": "route-3", + }, + ], +} +`; + +exports[`addRouteToReferenceRoute should be able to add as child 1`] = ` +{ + "id": "route-1", + "routes": [ + { + "id": "route-2", + }, + { + "id": "route-3", + "routes": [ + { + "continue": undefined, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, + "match": undefined, + "match_re": undefined, + "matchers": undefined, + "mute_time_intervals": undefined, + "object_matchers": undefined, + "receiver": "new-route", + "repeat_interval": undefined, + "routes": undefined, + }, + ], + }, + ], +} +`; + +exports[`addRouteToReferenceRoute should be able to add below 1`] = ` +{ + "id": "route-1", + "routes": [ + { + "id": "route-2", + }, + { + "id": "route-3", + }, + { + "continue": undefined, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, + "match": undefined, + "match_re": undefined, + "matchers": undefined, + "mute_time_intervals": undefined, + "object_matchers": undefined, + "receiver": "new-route", + "repeat_interval": undefined, + "routes": undefined, + }, + ], +} +`; diff --git a/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap b/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap index d41c9d98a822c..feca637e835a9 100644 --- a/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap +++ b/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap @@ -10,11 +10,11 @@ exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form valu "for": "5m", "grafana_alert": { "condition": "A", - "contactPoints": undefined, "data": [], "exec_err_state": "Error", "is_paused": false, "no_data_state": "NoData", + "notification_settings": undefined, "title": "", }, "labels": { @@ -33,7 +33,6 @@ exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range "for": "5m", "grafana_alert": { "condition": "A", - "contactPoints": undefined, "data": [ { "datasourceUid": "dsuid", @@ -54,6 +53,7 @@ exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range "exec_err_state": "Error", "is_paused": false, "no_data_state": "NoData", + "notification_settings": undefined, "title": "", }, "labels": { diff --git a/public/app/features/alerting/unified/utils/alertmanager.ts b/public/app/features/alerting/unified/utils/alertmanager.ts index e81b9195ffcc8..2738b70384400 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.ts @@ -15,7 +15,8 @@ import { Labels } from 'app/types/unified-alerting-dto'; import { MatcherFieldValue } from '../types/silence-form'; import { getAllDataSources } from './config'; -import { DataSourceType } from './datasource'; +import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './datasource'; +import { MatcherFormatter, unquoteWithUnescape } from './matchers'; export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig { // add default receiver if it does not exist @@ -53,6 +54,10 @@ export function renameMuteTimings(newMuteTimingName: string, oldMuteTimingName: }; } +export function unescapeObjectMatchers(matchers: ObjectMatcher[]): ObjectMatcher[] { + return matchers.map(([name, operator, value]) => [name, operator, unquoteWithUnescape(value)]); +} + export function matcherToOperator(matcher: Matcher): MatcherOperator { if (matcher.isEqual) { if (matcher.isRegex) { @@ -177,6 +182,10 @@ export function combineMatcherStrings(...matcherStrings: string[]): string { return matchersToString(uniqueMatchers); } +export function getAmMatcherFormatter(alertmanagerSourceName?: string): MatcherFormatter { + return alertmanagerSourceName === GRAFANA_RULES_SOURCE_NAME ? 'default' : 'unquote'; +} + export function getAllAlertmanagerDataSources() { return getAllDataSources().filter((ds) => ds.type === DataSourceType.Alertmanager); } diff --git a/public/app/features/alerting/unified/utils/amroutes.test.ts b/public/app/features/alerting/unified/utils/amroutes.test.ts index 3c66e772d0b86..343e4587c4a04 100644 --- a/public/app/features/alerting/unified/utils/amroutes.test.ts +++ b/public/app/features/alerting/unified/utils/amroutes.test.ts @@ -1,8 +1,9 @@ -import { Route } from 'app/plugins/datasource/alertmanager/types'; +import { MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types'; import { FormAmRoute } from '../types/amroutes'; import { amRouteToFormAmRoute, emptyRoute, formAmRouteToAmRoute } from './amroutes'; +import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; const emptyAmRoute: Route = { receiver: '', @@ -53,6 +54,84 @@ describe('formAmRouteToAmRoute', () => { expect(amRoute.group_by).toStrictEqual(['SHOULD BE SET']); }); }); + + it('should quote and escape matcher values', () => { + // Arrange + const route: FormAmRoute = buildFormAmRoute({ + id: '1', + object_matchers: [ + { name: 'foo', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'foo', operator: MatcherOperator.equal, value: 'bar"baz' }, + { name: 'foo', operator: MatcherOperator.equal, value: 'bar\\baz' }, + { name: 'foo', operator: MatcherOperator.equal, value: '\\bar\\baz"\\' }, + ], + }); + + // Act + const amRoute = formAmRouteToAmRoute('mimir-am', route, { id: 'root' }); + + // Assert + expect(amRoute.matchers).toStrictEqual([ + '"foo"="bar"', + '"foo"="bar\\"baz"', + '"foo"="bar\\\\baz"', + '"foo"="\\\\bar\\\\baz\\"\\\\"', + ]); + }); + + it('should quote and escape matcher names', () => { + // Arrange + const route: FormAmRoute = buildFormAmRoute({ + id: '1', + object_matchers: [ + { name: 'foo', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'foo with spaces', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'foo\\slash', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'foo"quote', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'fo\\o', operator: MatcherOperator.equal, value: 'ba\\r' }, + ], + }); + + // Act + const amRoute = formAmRouteToAmRoute('mimir-am', route, { id: 'root' }); + + // Assert + expect(amRoute.matchers).toStrictEqual([ + '"foo"="bar"', + '"foo with spaces"="bar"', + '"foo\\\\slash"="bar"', + '"foo\\"quote"="bar"', + '"fo\\\\o"="ba\\\\r"', + ]); + }); + + it('should allow matchers with empty values for cloud AM', () => { + // Arrange + const route: FormAmRoute = buildFormAmRoute({ + id: '1', + object_matchers: [{ name: 'foo', operator: MatcherOperator.equal, value: '' }], + }); + + // Act + const amRoute = formAmRouteToAmRoute('mimir-am', route, { id: 'root' }); + + // Assert + expect(amRoute.matchers).toStrictEqual(['"foo"=""']); + }); + + it('should allow matchers with empty values for Grafana AM', () => { + // Arrange + const route: FormAmRoute = buildFormAmRoute({ + id: '1', + object_matchers: [{ name: 'foo', operator: MatcherOperator.equal, value: '' }], + }); + + // Act + const amRoute = formAmRouteToAmRoute(GRAFANA_RULES_SOURCE_NAME, route, { id: 'root' }); + + // Assert + expect(amRoute.object_matchers).toStrictEqual([['foo', MatcherOperator.equal, '']]); + }); }); describe('amRouteToFormAmRoute', () => { @@ -101,4 +180,42 @@ describe('amRouteToFormAmRoute', () => { expect(formRoute.overrideGrouping).toBe(true); }); }); + + it('should unquote and unescape matchers values', () => { + // Arrange + const amRoute = buildAmRoute({ + matchers: ['foo=bar', 'foo="bar"', 'foo="bar"baz"', 'foo="bar\\\\baz"', 'foo="\\\\bar\\\\baz"\\\\"'], + }); + + // Act + const formRoute = amRouteToFormAmRoute(amRoute); + + // Assert + expect(formRoute.object_matchers).toStrictEqual([ + { name: 'foo', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'foo', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'foo', operator: MatcherOperator.equal, value: 'bar"baz' }, + { name: 'foo', operator: MatcherOperator.equal, value: 'bar\\baz' }, + { name: 'foo', operator: MatcherOperator.equal, value: '\\bar\\baz"\\' }, + ]); + }); + + it('should unquote and unescape matcher names', () => { + // Arrange + const amRoute = buildAmRoute({ + matchers: ['"foo"=bar', '"foo with spaces"=bar', '"foo\\\\slash"=bar', '"foo"quote"=bar', '"fo\\\\o"="ba\\\\r"'], + }); + + // Act + const formRoute = amRouteToFormAmRoute(amRoute); + + // Assert + expect(formRoute.object_matchers).toStrictEqual([ + { name: 'foo', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'foo with spaces', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'foo\\slash', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'foo"quote', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'fo\\o', operator: MatcherOperator.equal, value: 'ba\\r' }, + ]); + }); }); diff --git a/public/app/features/alerting/unified/utils/amroutes.ts b/public/app/features/alerting/unified/utils/amroutes.ts index fdbb13b0c8e16..0802276d9f13e 100644 --- a/public/app/features/alerting/unified/utils/amroutes.ts +++ b/public/app/features/alerting/unified/utils/amroutes.ts @@ -8,7 +8,7 @@ import { MatcherFieldValue } from '../types/silence-form'; import { matcherToMatcherField } from './alertmanager'; import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; -import { normalizeMatchers, parseMatcher } from './matchers'; +import { normalizeMatchers, parseMatcher, quoteWithEscape, unquoteWithUnescape } from './matchers'; import { findExistingRoute } from './routeTree'; import { isValidPrometheusDuration, safeParseDurationstr } from './time'; @@ -44,8 +44,8 @@ export const defaultGroupBy = ['grafana_folder', 'alertname']; // Common route group_by options for multiselect drop-down export const commonGroupByOptions = [ - { label: 'grafana_folder', value: 'grafana_folder' }, - { label: 'alertname', value: 'alertname' }, + { label: 'grafana_folder', value: 'grafana_folder', isFixed: true }, + { label: 'alertname', value: 'alertname', isFixed: true }, { label: 'Disable (...)', value: '...' }, ]; @@ -94,7 +94,14 @@ export const amRouteToFormAmRoute = (route: RouteWithID | Route | undefined): Fo const objectMatchers = route.object_matchers?.map((matcher) => ({ name: matcher[0], operator: matcher[1], value: matcher[2] })) ?? []; - const matchers = route.matchers?.map((matcher) => matcherToMatcherField(parseMatcher(matcher))) ?? []; + const matchers = + route.matchers + ?.map((matcher) => matcherToMatcherField(parseMatcher(matcher))) + .map(({ name, operator, value }) => ({ + name: unquoteWithUnescape(name), + operator, + value: unquoteWithUnescape(value), + })) ?? []; return { id, @@ -149,8 +156,10 @@ export const formAmRouteToAmRoute = ( const overrideRepeatInterval = overrideTimings && repeatIntervalValue; const repeat_interval = overrideRepeatInterval ? repeatIntervalValue : INHERIT_FROM_PARENT; + + // Empty matcher values are valid. Such matchers require specified label to not exists const object_matchers: ObjectMatcher[] | undefined = formAmRoute.object_matchers - ?.filter((route) => route.name && route.value && route.operator) + ?.filter((route) => route.name && route.operator && route.value !== null && route.value !== undefined) .map(({ name, operator, value }) => [name, operator, value]); const routes = formAmRoute.routes?.map((subRoute) => @@ -176,7 +185,9 @@ export const formAmRouteToAmRoute = ( // Grafana maintains a fork of AM to support all utf-8 characters in the "object_matchers" property values but this // does not exist in upstream AlertManager if (alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME) { - amRoute.matchers = formAmRoute.object_matchers?.map(({ name, operator, value }) => `${name}${operator}${value}`); + amRoute.matchers = formAmRoute.object_matchers?.map( + ({ name, operator, value }) => `${quoteWithEscape(name)}${operator}${quoteWithEscape(value)}` + ); amRoute.object_matchers = undefined; } else { amRoute.object_matchers = normalizeMatchers(amRoute); @@ -198,10 +209,10 @@ export const stringToSelectableValue = (str: string): SelectableValue<string> => export const stringsToSelectableValues = (arr: string[] | undefined): Array<SelectableValue<string>> => (arr ?? []).map(stringToSelectableValue); -export const mapSelectValueToString = (selectableValue: SelectableValue<string>): string | undefined => { +export const mapSelectValueToString = (selectableValue: SelectableValue<string>): string | null => { // this allows us to deal with cleared values if (selectableValue === null) { - return undefined; + return null; } if (!selectableValue) { @@ -221,8 +232,8 @@ export const mapMultiSelectValueToStrings = ( return selectableValuesToStrings(selectableValues); }; -export function promDurationValidator(duration: string) { - if (duration.length === 0) { +export function promDurationValidator(duration?: string) { + if (!duration || duration.length === 0) { return true; } @@ -237,7 +248,7 @@ export const objectMatchersToString = (matchers: ObjectMatcher[]): string[] => { }); }; -export const repeatIntervalValidator = (repeatInterval: string, groupInterval: string) => { +export const repeatIntervalValidator = (repeatInterval: string, groupInterval = '') => { if (repeatInterval.length === 0) { return true; } diff --git a/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts b/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts index df76463fb5acd..494e9ef231c09 100644 --- a/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts +++ b/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts @@ -175,6 +175,16 @@ export const cloudNotifierTypes: Array<NotifierDTO<CloudNotifierType>> = [ placeholder: '1h', } ), + option( + 'ttl', + 'TTL', + 'The number of seconds before a message expires and is deleted automatically. Examples: 10s, 5m30s, 8h.', + { + // allow 30s, 4m30s, etc + validationRule: '^(\\d+[s|m|h])+$|^$', + element: 'input', + } + ), httpConfigOption, ], }, diff --git a/public/app/features/alerting/unified/utils/config.ts b/public/app/features/alerting/unified/utils/config.ts index 9472a8b543e03..d5f7817561d14 100644 --- a/public/app/features/alerting/unified/utils/config.ts +++ b/public/app/features/alerting/unified/utils/config.ts @@ -1,12 +1,9 @@ import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types'; import { isValidPrometheusDuration, parsePrometheusDuration } from './time'; -export function getAllDataSources(): Array< - DataSourceInstanceSettings<DataSourceJsonData | AlertManagerDataSourceJsonData> -> { +export function getAllDataSources(): Array<DataSourceInstanceSettings<DataSourceJsonData>> { return Object.values(config.datasources); } diff --git a/public/app/features/alerting/utils/dataSourceFromExpression.ts b/public/app/features/alerting/unified/utils/dataSourceFromExpression.ts similarity index 100% rename from public/app/features/alerting/utils/dataSourceFromExpression.ts rename to public/app/features/alerting/unified/utils/dataSourceFromExpression.ts diff --git a/public/app/features/alerting/unified/utils/datasource.ts b/public/app/features/alerting/unified/utils/datasource.ts index de43e65864982..e2f70cb8586c6 100644 --- a/public/app/features/alerting/unified/utils/datasource.ts +++ b/public/app/features/alerting/unified/utils/datasource.ts @@ -1,4 +1,4 @@ -import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; +import { DataSourceInstanceSettings, DataSourceJsonData, DataSourceSettings } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { @@ -51,12 +51,22 @@ export function getRulesDataSource(rulesSourceName: string) { export function getAlertManagerDataSources() { return getAllDataSources() - .filter( - (ds): ds is DataSourceInstanceSettings<AlertManagerDataSourceJsonData> => ds.type === DataSourceType.Alertmanager - ) + .filter(isAlertmanagerDataSourceInstance) .sort((a, b) => a.name.localeCompare(b.name)); } +export function isAlertmanagerDataSourceInstance( + dataSource: DataSourceInstanceSettings +): dataSource is DataSourceInstanceSettings<AlertManagerDataSourceJsonData> { + return dataSource.type === DataSourceType.Alertmanager; +} + +export function isAlertmanagerDataSource( + dataSource: DataSourceSettings +): dataSource is DataSourceSettings<AlertManagerDataSourceJsonData> { + return dataSource.type === DataSourceType.Alertmanager; +} + export function getExternalDsAlertManagers() { return getAlertManagerDataSources().filter((ds) => ds.jsonData.handleGrafanaManagedAlerts); } @@ -205,10 +215,9 @@ export function getDataSourceByName(name: string): DataSourceInstanceSettings<Da } export function getAlertmanagerDataSourceByName(name: string) { - return getAllDataSources().find( - (source): source is DataSourceInstanceSettings<AlertManagerDataSourceJsonData> => - source.name === name && source.type === 'alertmanager' - ); + return getAllDataSources() + .filter(isAlertmanagerDataSourceInstance) + .find((source) => source.name === name); } export function getRulesSourceByName(name: string): RulesSource | undefined { diff --git a/public/app/features/alerting/unified/utils/matchers.test.ts b/public/app/features/alerting/unified/utils/matchers.test.ts index 43b3cbc2320b7..9f879789a22bb 100644 --- a/public/app/features/alerting/unified/utils/matchers.test.ts +++ b/public/app/features/alerting/unified/utils/matchers.test.ts @@ -1,6 +1,12 @@ import { MatcherOperator, Route } from '../../../../plugins/datasource/alertmanager/types'; -import { getMatcherQueryParams, normalizeMatchers, parseQueryParamMatchers } from './matchers'; +import { + getMatcherQueryParams, + normalizeMatchers, + parseQueryParamMatchers, + quoteWithEscape, + unquoteWithUnescape, +} from './matchers'; describe('Unified Alerting matchers', () => { describe('getMatcherQueryParams tests', () => { @@ -61,3 +67,37 @@ describe('Unified Alerting matchers', () => { }); }); }); + +describe('quoteWithEscape', () => { + const samples: string[][] = [ + ['bar', '"bar"'], + ['b"ar"', '"b\\"ar\\""'], + ['b\\ar\\', '"b\\\\ar\\\\"'], + ['wa{r}ni$ng!', '"wa{r}ni$ng!"'], + ]; + + it.each(samples)('should escape and quote %s to %s', (raw, quoted) => { + const quotedMatcher = quoteWithEscape(raw); + expect(quotedMatcher).toBe(quoted); + }); +}); + +describe('unquoteWithUnescape', () => { + const samples: string[][] = [ + ['bar', 'bar'], + ['"bar"', 'bar'], + ['"b\\"ar\\""', 'b"ar"'], + ['"b\\\\ar\\\\"', 'b\\ar\\'], + ['"wa{r}ni$ng!"', 'wa{r}ni$ng!'], + ]; + + it.each(samples)('should unquote and unescape %s to %s', (quoted, raw) => { + const unquotedMatcher = unquoteWithUnescape(quoted); + expect(unquotedMatcher).toBe(raw); + }); + + it('should not unescape unquoted string', () => { + const unquoted = unquoteWithUnescape('un\\"quo\\\\ted'); + expect(unquoted).toBe('un\\"quo\\\\ted'); + }); +}); diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts index aa1b7187f85bb..c24c15d8f7852 100644 --- a/public/app/features/alerting/unified/utils/matchers.ts +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -108,4 +108,42 @@ export const normalizeMatchers = (route: Route): ObjectMatcher[] => { return matchers; }; +/** + * Quotes string and escapes double quote and backslash characters + */ +export function quoteWithEscape(input: string) { + const escaped = input.replace(/[\\"]/g, (c) => `\\${c}`); + return `"${escaped}"`; +} + +/** + * Unquotes and unescapes a string **if it has been quoted** + */ +export function unquoteWithUnescape(input: string) { + if (!/^"(.*)"$/.test(input)) { + return input; + } + + return input + .replace(/^"(.*)"$/, '$1') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); +} + +export const matcherFormatter = { + default: ([name, operator, value]: ObjectMatcher): string => { + // Value can be an empty string which we want to display as "" + const formattedValue = value || ''; + return `${name} ${operator} ${formattedValue}`; + }, + unquote: ([name, operator, value]: ObjectMatcher): string => { + const unquotedName = unquoteWithUnescape(name); + // Unquoted value can be an empty string which we want to display as "" + const unquotedValue = unquoteWithUnescape(value) || '""'; + return `${unquotedName} ${operator} ${unquotedValue}`; + }, +} as const; + +export type MatcherFormatter = keyof typeof matcherFormatter; + export type Label = [string, string]; diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index d3051a0de69f7..2fd698b37968b 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -1,8 +1,8 @@ import { sortBy } from 'lodash'; -import { UrlQueryMap, Labels, DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; +import { UrlQueryMap, Labels } from '@grafana/data'; import { GrafanaEdition } from '@grafana/data/src/types/config'; -import { config } from '@grafana/runtime'; +import { config, isFetchError } from '@grafana/runtime'; import { DataSourceRef } from '@grafana/schema'; import { escapePathSeparators } from 'app/features/alerting/unified/utils/rule-id'; import { alertInstanceKey } from 'app/features/alerting/unified/utils/rules'; @@ -105,7 +105,7 @@ export function makeAMLink(path: string, alertManagerName?: string, options?: UR const search = new URLSearchParams(options); if (alertManagerName) { - search.append(ALERTMANAGER_NAME_QUERY_KEY, alertManagerName); + search.set(ALERTMANAGER_NAME_QUERY_KEY, alertManagerName); } return `${path}?${search.toString()}`; } @@ -137,24 +137,35 @@ export function makeLabelBasedSilenceLink(alertManagerSourceName: string, labels return createUrl('/alerting/silence/new', silenceUrlParams); } -export function makeDataSourceLink<T extends DataSourceJsonData>(dataSource: DataSourceInstanceSettings<T>) { - return createUrl(`/datasources/edit/${dataSource.uid}`); +export function makeDataSourceLink(uid: string) { + return createUrl(`/datasources/edit/${uid}`); } export function makeFolderLink(folderUID: string): string { return createUrl(`/dashboards/f/${folderUID}`); } +export function makeFolderAlertsLink(folderUID: string, title: string): string { + return createUrl(`/dashboards/f/${folderUID}/${title}/alerting`); +} + export function makeFolderSettingsLink(folder: FolderDTO): string { - return createUrl(`/dashboards/f/${folder.uid}/${folder.title}/settings`); + return createUrl(`/dashboards/f/${folder.uid}/settings`); } export function makeDashboardLink(dashboardUID: string): string { return createUrl(`/d/${encodeURIComponent(dashboardUID)}`); } -export function makePanelLink(dashboardUID: string, panelId: string): string { - return createUrl(`/d/${encodeURIComponent(dashboardUID)}`, { viewPanel: panelId }); +type PanelLinkParams = { + viewPanel?: string; + editPanel?: string; + tab?: 'alert' | 'transform' | 'query'; +}; + +export function makePanelLink(dashboardUID: string, panelId: string, queryParams: PanelLinkParams = {}): string { + const panelParams = new URLSearchParams(queryParams); + return createUrl(`/d/${encodeURIComponent(dashboardUID)}`, panelParams); } // keep retrying fn if it's error passes shouldRetry(error) and timeout has not elapsed yet @@ -221,3 +232,16 @@ export function isLocalDevEnv() { const buildInfo = config.buildInfo; return buildInfo.env === 'development'; } + +export function isErrorLike(error: unknown): error is Error { + return 'message' in (error as Error); +} + +export function stringifyErrorLike(error: unknown): string { + const fetchError = isFetchError(error); + if (fetchError) { + return error.data.message; + } + + return isErrorLike(error) ? error.message : String(error); +} diff --git a/public/app/features/alerting/unified/utils/mute-timings.ts b/public/app/features/alerting/unified/utils/mute-timings.ts index abac60fda0a54..fca26871e1a2e 100644 --- a/public/app/features/alerting/unified/utils/mute-timings.ts +++ b/public/app/features/alerting/unified/utils/mute-timings.ts @@ -1,6 +1,6 @@ -import { omitBy, isUndefined } from 'lodash'; +import { isUndefined, omitBy } from 'lodash'; -import { MuteTimeInterval, TimeInterval } from 'app/plugins/datasource/alertmanager/types'; +import { MuteTimeInterval, TimeInterval, TimeRange } from 'app/plugins/datasource/alertmanager/types'; import { MuteTimingFields, MuteTimingIntervalFields } from '../types/mute-timing-form'; @@ -28,9 +28,14 @@ export const defaultTimeInterval: MuteTimingIntervalFields = { months: '', years: '', location: '', + disable: false, }; -export const validateArrayField = (value: string, validateValue: (input: string) => boolean, invalidText: string) => { +export const validateArrayField = ( + value: string | undefined, + validateValue: (input: string) => boolean, + invalidText: string +) => { if (value) { return ( value @@ -43,15 +48,15 @@ export const validateArrayField = (value: string, validateValue: (input: string) } }; -const convertStringToArray = (str: string) => { +const convertStringToArray = (str?: string) => { return str ? str.split(',').map((s) => s.trim()) : undefined; }; export const createMuteTiming = (fields: MuteTimingFields): MuteTimeInterval => { const timeIntervals: TimeInterval[] = fields.time_intervals.map( - ({ times, weekdays, days_of_month, months, years, location }) => { + ({ times, weekdays, days_of_month, months, years, location, disable }) => { const interval = { - times: times.filter(({ start_time, end_time }) => !!start_time && !!end_time), + times: convertTimesToDto(times, disable), weekdays: convertStringToArray(weekdays)?.map((v) => v.toLowerCase()), days_of_month: convertStringToArray(days_of_month), months: convertStringToArray(months), @@ -68,3 +73,47 @@ export const createMuteTiming = (fields: MuteTimingFields): MuteTimeInterval => time_intervals: timeIntervals, }; }; + +/* + * Convert times from form to dto, if disable is true, then return an empty array as times + If the times array is empty and disable is false, then return undefined + * @param muteTimeInterval + * @returns MuteTimingFields + * + */ +function convertTimesToDto(times: TimeRange[] | undefined, disable: boolean) { + if (disable) { + return []; + } + const timesToReturn = times?.filter(({ start_time, end_time }) => !!start_time && !!end_time); + return timesToReturn?.length ? timesToReturn : undefined; +} + +/* + * Get disable field from dto, if any of the lists is an empty array, then the disable field is true + * @param muteTimeInterval + * @returns MuteTimingFields + * + */ + +export function isTimeIntervalDisabled(intervals: TimeInterval): boolean { + if ( + intervals.times?.length === 0 || + intervals.weekdays?.length === 0 || + intervals.days_of_month?.length === 0 || + intervals.months?.length === 0 || + intervals.years?.length === 0 + ) { + return true; + } + return false; +} + +/* + Return true if all the time intervals are disabled + * @param muteTimeInterval + * @returns MuteTimingFields + * */ +export function isDisabled(muteTiming: MuteTimeInterval) { + return muteTiming.time_intervals.every((timeInterval) => isTimeIntervalDisabled(timeInterval)); +} diff --git a/public/app/features/alerting/unified/utils/notification-policies.test.ts b/public/app/features/alerting/unified/utils/notification-policies.test.ts index e5eb78e6706f3..b6f12be2866f7 100644 --- a/public/app/features/alerting/unified/utils/notification-policies.test.ts +++ b/public/app/features/alerting/unified/utils/notification-policies.test.ts @@ -1,11 +1,13 @@ import { MatcherOperator, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { + InheritableProperties, computeInheritedTree, findMatchingRoutes, getInheritedProperties, matchLabels, normalizeRoute, + unquoteRouteMatchers, } from './notification-policies'; import 'core-js/stable/structured-clone'; @@ -203,9 +205,19 @@ describe('getInheritedProperties()', () => { const childInherited = getInheritedProperties(parent, child); expect(childInherited).toHaveProperty('group_by', ['label']); }); + + it('should inherit from grandparent when parent is inheriting', () => { + const parentInheritedProperties: InheritableProperties = { receiver: 'grandparent' }; + const parent: Route = { receiver: null, group_by: ['foo'] }; + const child: Route = { receiver: null }; + + const childInherited = getInheritedProperties(parent, child, parentInheritedProperties); + expect(childInherited).toHaveProperty('receiver', 'grandparent'); + expect(childInherited.group_by).toEqual(['foo']); + }); }); - describe('regular "undefined" values', () => { + describe('regular "undefined" or "null" values', () => { it('should compute inherited properties being undefined', () => { const parent: Route = { receiver: 'PARENT', @@ -220,6 +232,20 @@ describe('getInheritedProperties()', () => { expect(childInherited).toHaveProperty('group_wait', '10s'); }); + it('should compute inherited properties being null', () => { + const parent: Route = { + receiver: 'PARENT', + group_wait: '10s', + }; + + const child: Route = { + receiver: null, + }; + + const childInherited = getInheritedProperties(parent, child); + expect(childInherited).toHaveProperty('receiver', 'PARENT'); + }); + it('should compute inherited properties being undefined from parent inherited properties', () => { const parent: Route = { receiver: 'PARENT', @@ -451,3 +477,39 @@ describe('matchLabels', () => { expect(result.labelsMatch).toMatchSnapshot(); }); }); + +describe('unquoteRouteMatchers', () => { + it('should unquote and unescape matchers values', () => { + const route: RouteWithID = { + id: '1', + object_matchers: [ + ['foo', MatcherOperator.equal, 'bar'], + ['foo', MatcherOperator.equal, '"bar"'], + ['foo', MatcherOperator.equal, '"b\\\\ar b\\"az"'], + ], + }; + + const unwrapped = unquoteRouteMatchers(route); + + expect(unwrapped.object_matchers).toHaveLength(3); + expect(unwrapped.object_matchers).toContainEqual(['foo', MatcherOperator.equal, 'bar']); + expect(unwrapped.object_matchers).toContainEqual(['foo', MatcherOperator.equal, 'bar']); + expect(unwrapped.object_matchers).toContainEqual(['foo', MatcherOperator.equal, 'b\\ar b"az']); + }); + + it('should unquote and unescape matcher names', () => { + const route: RouteWithID = { + id: '1', + object_matchers: [ + ['"f\\"oo with quote"', MatcherOperator.equal, 'bar'], + ['"f\\\\oo with slash"', MatcherOperator.equal, 'bar'], + ], + }; + + const unwrapped = unquoteRouteMatchers(route); + + expect(unwrapped.object_matchers).toHaveLength(2); + expect(unwrapped.object_matchers).toContainEqual(['f"oo with quote', MatcherOperator.equal, 'bar']); + expect(unwrapped.object_matchers).toContainEqual(['f\\oo with slash', MatcherOperator.equal, 'bar']); + }); +}); diff --git a/public/app/features/alerting/unified/utils/notification-policies.ts b/public/app/features/alerting/unified/utils/notification-policies.ts index 504f162f3c18c..8b419161078c9 100644 --- a/public/app/features/alerting/unified/utils/notification-policies.ts +++ b/public/app/features/alerting/unified/utils/notification-policies.ts @@ -1,4 +1,4 @@ -import { isArray, merge, pick, reduce } from 'lodash'; +import { isArray, pick, reduce } from 'lodash'; import { AlertmanagerGroup, @@ -9,7 +9,7 @@ import { } from 'app/plugins/datasource/alertmanager/types'; import { Labels } from 'app/types/unified-alerting-dto'; -import { Label, normalizeMatchers } from './matchers'; +import { Label, normalizeMatchers, unquoteWithUnescape } from './matchers'; // If a policy has no matchers it still can be a match, hence matchers can be empty and match can be true // So we cannot use null as an indicator of no match @@ -20,7 +20,7 @@ interface LabelMatchResult { export const INHERITABLE_KEYS = ['receiver', 'group_by', 'group_wait', 'group_interval', 'repeat_interval'] as const; export type InheritableKeys = typeof INHERITABLE_KEYS; -export type InhertitableProperties = Pick<Route, InheritableKeys[number]>; +export type InheritableProperties = Pick<Route, InheritableKeys[number]>; type LabelsMatch = Map<Label, LabelMatchResult>; @@ -124,6 +124,20 @@ export function normalizeRoute(rootRoute: RouteWithID): RouteWithID { return normalizedRootRoute; } +export function unquoteRouteMatchers(route: RouteWithID): RouteWithID { + function unquoteRoute(route: RouteWithID) { + route.object_matchers = route.object_matchers?.map(([name, operator, value]) => { + return [unquoteWithUnescape(name), operator, unquoteWithUnescape(value)]; + }); + route.routes?.forEach(unquoteRoute); + } + + const unwrappedRootRoute = structuredClone(route); + unquoteRoute(unwrappedRootRoute); + + return unwrappedRootRoute; +} + /** * find all of the groups that have instances that match the route, thay way we can find all instances * (and their grouping) for the given route @@ -158,22 +172,23 @@ function findMatchingAlertGroups( function getInheritedProperties( parentRoute: Route, childRoute: Route, - propertiesParentInherited?: Partial<InhertitableProperties> -) { - const fullParentProperties = merge({}, parentRoute, propertiesParentInherited); - - const inheritableProperties: InhertitableProperties = pick(fullParentProperties, INHERITABLE_KEYS); + propertiesParentInherited?: InheritableProperties +): InheritableProperties { + const propsFromParent: InheritableProperties = pick(parentRoute, INHERITABLE_KEYS); + const inheritableProperties: InheritableProperties = { + ...propsFromParent, + ...propertiesParentInherited, + }; - // TODO how to solve this TypeScript mystery? const inherited = reduce( inheritableProperties, - (inheritedProperties: Partial<Route> = {}, parentValue, property) => { - const parentHasValue = parentValue !== undefined; + (inheritedProperties: InheritableProperties, parentValue, property) => { + const parentHasValue = parentValue != null; + const inheritableValues = [undefined, '', null]; // @ts-ignore - const inheritFromParentUndefined = parentHasValue && childRoute[property] === undefined; - // @ts-ignore - const inheritFromParentEmptyString = parentHasValue && childRoute[property] === ''; + const childIsInheriting = inheritableValues.some((value) => childRoute[property] === value); + const inheritFromValue = childIsInheriting && parentHasValue; const inheritEmptyGroupByFromParent = property === 'group_by' && @@ -181,8 +196,7 @@ function getInheritedProperties( isArray(childRoute[property]) && childRoute[property]?.length === 0; - const inheritFromParent = - inheritFromParentUndefined || inheritFromParentEmptyString || inheritEmptyGroupByFromParent; + const inheritFromParent = inheritFromValue || inheritEmptyGroupByFromParent; if (inheritFromParent) { // @ts-ignore diff --git a/public/app/features/alerting/unified/utils/query.ts b/public/app/features/alerting/unified/utils/query.ts index 0f5648638a6f5..bde53876d92b9 100644 --- a/public/app/features/alerting/unified/utils/query.ts +++ b/public/app/features/alerting/unified/utils/query.ts @@ -1,3 +1,5 @@ +import { produce } from 'immer'; + import { DataSourceInstanceSettings } from '@grafana/data'; import { DataQuery } from '@grafana/schema'; import { LokiQuery } from 'app/plugins/datasource/loki/types'; @@ -5,8 +7,9 @@ import { PromQuery } from 'app/plugins/datasource/prometheus/types'; import { CombinedRule } from 'app/types/unified-alerting'; import { AlertQuery } from 'app/types/unified-alerting-dto'; -import { isCloudRulesSource, isGrafanaRulesSource } from './datasource'; +import { isCloudRulesSource } from './datasource'; import { isGrafanaRulerRule } from './rules'; +import { safeParseDurationstr } from './time'; export function alertRuleToQueries(combinedRule: CombinedRule | undefined | null): AlertQuery[] { if (!combinedRule) { @@ -15,10 +18,9 @@ export function alertRuleToQueries(combinedRule: CombinedRule | undefined | null const { namespace, rulerRule } = combinedRule; const { rulesSource } = namespace; - if (isGrafanaRulesSource(rulesSource)) { - if (isGrafanaRulerRule(rulerRule)) { - return rulerRule.grafana_alert.data; - } + if (isGrafanaRulerRule(rulerRule)) { + const query = rulerRule.grafana_alert.data; + return widenRelativeTimeRanges(query, rulerRule.for, combinedRule.group.interval); } if (isCloudRulesSource(rulesSource)) { @@ -30,6 +32,37 @@ export function alertRuleToQueries(combinedRule: CombinedRule | undefined | null return []; } +/** + * This function will figure out how large the time range for visualizing the alert rule detail view should be + * We try to show as much data as is relevant for triaging / root cause analysis + * + * The function for it is; + * + * Math.max(3 * pending period, query range + (2 * pending period)) + * + * We can safely ignore the evaluation interval because the pending period is guaranteed to be largen than or equal that + */ +export function widenRelativeTimeRanges(queries: AlertQuery[], pendingPeriod: string, groupInterval?: string) { + // if pending period is zero that means inherit from group interval, if that is empty then assume 1m + const pendingPeriodDurationMillis = + safeParseDurationstr(pendingPeriod) ?? safeParseDurationstr(groupInterval ?? '1m'); + const pendingPeriodDuration = Math.floor(pendingPeriodDurationMillis / 1000); + + return queries.map((query) => + produce(query, (draft) => { + const fromQueryRange = draft.relativeTimeRange?.from ?? 0; + + // use whichever has the largest time range + const from = Math.max(pendingPeriodDuration * 3, fromQueryRange + pendingPeriodDuration * 2); + + draft.relativeTimeRange = { + from, + to: 0, + }; + }) + ); +} + export function dataQueryToAlertQuery(dataQuery: DataQuery, dataSourceUid: string): AlertQuery { return { refId: dataQuery.refId, diff --git a/public/app/features/alerting/unified/utils/routeTree.test.ts b/public/app/features/alerting/unified/utils/routeTree.test.ts new file mode 100644 index 0000000000000..8fbefd5462a31 --- /dev/null +++ b/public/app/features/alerting/unified/utils/routeTree.test.ts @@ -0,0 +1,107 @@ +import { RouteWithID } from 'app/plugins/datasource/alertmanager/types'; + +import { FormAmRoute } from '../types/amroutes'; + +import { GRAFANA_DATASOURCE_NAME } from './datasource'; +import { addRouteToReferenceRoute, cleanRouteIDs, findRouteInTree, omitRouteFromRouteTree } from './routeTree'; + +describe('findRouteInTree', () => { + it('should find the correct route', () => { + const needle: RouteWithID = { id: 'route-2' }; + + const root: RouteWithID = { + id: 'route-0', + routes: [{ id: 'route-1' }, needle, { id: 'route-3', routes: [{ id: 'route-4' }] }], + }; + + expect(findRouteInTree(root, { id: 'route-2' })).toStrictEqual([needle, root, 1]); + }); + + it('should return undefined for unknown route', () => { + const root: RouteWithID = { + id: 'route-0', + routes: [{ id: 'route-1' }], + }; + + expect(findRouteInTree(root, { id: 'none' })).toStrictEqual([undefined, undefined, undefined]); + }); +}); + +describe('addRouteToReferenceRoute', () => { + const targetRoute = { id: 'route-3' }; + const root: RouteWithID = { + id: 'route-1', + routes: [{ id: 'route-2' }, targetRoute], + }; + + const newRoute: Partial<FormAmRoute> = { + id: 'new-route', + receiver: 'new-route', + }; + + it('should be able to add above', () => { + expect(addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRoute, root, 'above')).toMatchSnapshot(); + }); + + it('should be able to add below', () => { + expect(addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRoute, root, 'below')).toMatchSnapshot(); + }); + + it('should be able to add as child', () => { + expect(addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRoute, root, 'child')).toMatchSnapshot(); + }); + + it('should throw if target route does not exist', () => { + expect(() => + addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, { id: 'unknown' }, root, 'child') + ).toThrow(); + }); +}); + +describe('omitRouteFromRouteTree', () => { + it('should omit route from tree', () => { + const tree: RouteWithID = { + id: 'route-1', + receiver: 'root', + routes: [ + { id: 'route-2', receiver: 'receiver-2' }, + { id: 'route-3', receiver: 'receiver-3' }, + ], + }; + + expect(omitRouteFromRouteTree({ id: 'route-2' }, tree)).toEqual({ + id: 'route-1', + receiver: 'root', + routes: [{ id: 'route-3', receiver: 'receiver-3', routes: undefined }], + }); + }); + + it('should throw when removing root route from tree', () => { + const tree: RouteWithID = { + id: 'route-1', + }; + + expect(() => { + omitRouteFromRouteTree(tree, { id: 'route-1' }); + }).toThrow(); + }); +}); + +describe('cleanRouteIDs', () => { + it('should remove IDs from routesr recursively', () => { + expect( + cleanRouteIDs({ + id: '1', + receiver: '1', + routes: [ + { id: '2', receiver: '2' }, + { id: '3', receiver: '3' }, + ], + }) + ).toEqual({ receiver: '1', routes: [{ receiver: '2' }, { receiver: '3' }] }); + }); + + it('should also accept regular routes', () => { + expect(cleanRouteIDs({ receiver: 'test' })).toEqual({ receiver: 'test' }); + }); +}); diff --git a/public/app/features/alerting/unified/utils/routeTree.ts b/public/app/features/alerting/unified/utils/routeTree.ts index 032c9a579be2d..426979f0e95f6 100644 --- a/public/app/features/alerting/unified/utils/routeTree.ts +++ b/public/app/features/alerting/unified/utils/routeTree.ts @@ -2,8 +2,10 @@ * Various helper functions to modify (immutably) the route tree, aka "notification policies" */ +import { produce } from 'immer'; import { omit } from 'lodash'; +import { insertAfterImmutably, insertBeforeImmutably } from '@grafana/data/src/utils/arrayUtils'; import { Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { FormAmRoute } from '../types/amroutes'; @@ -26,22 +28,16 @@ export const mergePartialAmRouteWithRouteTree = ( if (currentRoute.id === partialFormRoute.id) { const newRoute = formAmRouteToAmRoute(alertManagerSourceName, partialFormRoute, routeTree); - updatedRoute = omit( - { - ...currentRoute, - ...newRoute, - }, - 'id' - ); + updatedRoute = { + ...currentRoute, + ...newRoute, + }; } - return omit( - { - ...updatedRoute, - routes: currentRoute.routes?.map(findAndReplace), - }, - 'id' - ); + return { + ...updatedRoute, + routes: currentRoute.routes?.map(findAndReplace), + }; } return findAndReplace(routeTree); @@ -49,69 +45,110 @@ export const mergePartialAmRouteWithRouteTree = ( // remove a route from the policy tree, returns a new tree // make sure to omit the "id" because Prometheus / Loki / Mimir will reject the payload -export const omitRouteFromRouteTree = (findRoute: RouteWithID, routeTree: RouteWithID): Route => { +export const omitRouteFromRouteTree = (findRoute: RouteWithID, routeTree: RouteWithID): RouteWithID => { if (findRoute.id === routeTree.id) { throw new Error('You cant remove the root policy'); } - function findAndOmit(currentRoute: RouteWithID): Route { - return omit( - { - ...currentRoute, - routes: currentRoute.routes?.reduce((acc: Route[] = [], route) => { - if (route.id === findRoute.id) { - return acc; - } - - acc.push(findAndOmit(route)); + function findAndOmit(currentRoute: RouteWithID): RouteWithID { + return { + ...currentRoute, + routes: currentRoute.routes?.reduce((acc: RouteWithID[] = [], route) => { + if (route.id === findRoute.id) { return acc; - }, []), - }, - 'id' - ); + } + + acc.push(route); + return acc; + }, []), + }; } return findAndOmit(routeTree); }; +export type InsertPosition = 'above' | 'below' | 'child'; + // add a new route to a parent route -export const addRouteToParentRoute = ( +export const addRouteToReferenceRoute = ( alertManagerSourceName: string, partialFormRoute: Partial<FormAmRoute>, - parentRoute: RouteWithID, - routeTree: RouteWithID -): Route => { + referenceRoute: RouteWithID, + routeTree: RouteWithID, + position: InsertPosition +): RouteWithID => { const newRoute = formAmRouteToAmRoute(alertManagerSourceName, partialFormRoute, routeTree); - function findAndAdd(currentRoute: RouteWithID): RouteWithID { - if (currentRoute.id === parentRoute.id) { - return { - ...currentRoute, - // TODO fix this typescript exception, it's... complicated - // @ts-ignore - routes: currentRoute.routes?.concat(newRoute), - }; + return produce(routeTree, (draftTree) => { + const [routeInTree, parentRoute, positionInParent] = findRouteInTree(draftTree, referenceRoute); + + if (routeInTree === undefined || parentRoute === undefined || positionInParent === undefined) { + throw new Error(`could not find reference route "${referenceRoute.id}" in tree`); } - return { - ...currentRoute, - routes: currentRoute.routes?.map(findAndAdd), - }; - } + // if user wants to insert new child policy, append to the bottom of children + if (position === 'child') { + if (routeInTree.routes) { + routeInTree.routes.push(newRoute); + } else { + routeInTree.routes = [newRoute]; + } + } - function findAndOmitId(currentRoute: RouteWithID): Route { - return omit( - { - ...currentRoute, - routes: currentRoute.routes?.map(findAndOmitId), - }, - 'id' - ); - } + // insert new policy before / above the referenceRoute + if (position === 'above') { + parentRoute.routes = insertBeforeImmutably(parentRoute.routes ?? [], newRoute, positionInParent); + } - return findAndOmitId(findAndAdd(routeTree)); + // insert new policy after / below the referenceRoute + if (position === 'below') { + parentRoute.routes = insertAfterImmutably(parentRoute.routes ?? [], newRoute, positionInParent); + } + }); }; +type RouteMatch = Route | undefined; + +export function findRouteInTree( + routeTree: RouteWithID, + referenceRoute: RouteWithID +): [matchingRoute: RouteMatch, parentRoute: RouteMatch, positionInParent: number | undefined] { + let matchingRoute: RouteMatch; + let matchingRouteParent: RouteMatch; + let matchingRoutePositionInParent: number | undefined; + + // recurse through the tree to find the matching route, its parent and the position of the route in the parent + function findRouteInTree(currentRoute: RouteWithID, index: number, parentRoute: RouteWithID) { + if (matchingRoute) { + return; + } + + if (currentRoute.id === referenceRoute.id) { + matchingRoute = currentRoute; + matchingRouteParent = parentRoute; + matchingRoutePositionInParent = index; + } + + if (currentRoute.routes) { + currentRoute.routes.forEach((route, index) => findRouteInTree(route, index, currentRoute)); + } + } + + findRouteInTree(routeTree, 0, routeTree); + + return [matchingRoute, matchingRouteParent, matchingRoutePositionInParent]; +} + +export function cleanRouteIDs(route: Route | RouteWithID): Route { + return omit( + { + ...route, + routes: route.routes?.map((route) => cleanRouteIDs(route)), + }, + 'id' + ); +} + export function findExistingRoute(id: string, routeTree: RouteWithID): RouteWithID | undefined { return routeTree.id === id ? routeTree : routeTree.routes?.find((route) => findExistingRoute(id, route)); } diff --git a/public/app/features/alerting/unified/utils/rule-form.test.ts b/public/app/features/alerting/unified/utils/rule-form.test.ts index 5ac93c82d7e2a..735a8f7087b3f 100644 --- a/public/app/features/alerting/unified/utils/rule-form.test.ts +++ b/public/app/features/alerting/unified/utils/rule-form.test.ts @@ -1,13 +1,19 @@ +import { config } from '@grafana/runtime'; import { PromQuery } from 'app/plugins/datasource/prometheus/types'; -import { RulerAlertingRuleDTO } from 'app/types/unified-alerting-dto'; +import { GrafanaAlertStateDecision, GrafanaRuleDefinition, RulerAlertingRuleDTO } from 'app/types/unified-alerting-dto'; -import { RuleFormType, RuleFormValues } from '../types/rule-form'; +import { AlertManagerManualRouting, RuleFormType, RuleFormValues } from '../types/rule-form'; +import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; import { + MANUAL_ROUTING_KEY, alertingRulerRuleToRuleForm, formValuesToRulerGrafanaRuleDTO, formValuesToRulerRuleDTO, + getContactPointsFromDTO, getDefaultFormValues, + getDefautManualRouting, + getNotificationSettingsForDTO, } from './rule-form'; describe('formValuesToRulerGrafanaRuleDTO', () => { @@ -85,3 +91,152 @@ describe('formValuesToRulerGrafanaRuleDTO', () => { expect(alertingRulerRuleToRuleForm(rule)).toMatchSnapshot(); }); }); +describe('getContactPointsFromDTO', () => { + it('should return undefined if notification_settings is not defined', () => { + const ga: GrafanaRuleDefinition = { + uid: '123', + title: 'myalert', + namespace_uid: '123', + condition: 'A', + no_data_state: GrafanaAlertStateDecision.Alerting, + exec_err_state: GrafanaAlertStateDecision.Alerting, + data: [ + { + datasourceUid: '123', + refId: 'A', + queryType: 'huh', + model: { refId: 'A' }, + }, + ], + notification_settings: undefined, + }; + + const result = getContactPointsFromDTO(ga); + expect(result).toBeUndefined(); + }); + + it('should return routingSettings with correct props if notification_settings is defined', () => { + const ga: GrafanaRuleDefinition = { + uid: '123', + title: 'myalert', + namespace_uid: '123', + condition: 'A', + no_data_state: GrafanaAlertStateDecision.Alerting, + exec_err_state: GrafanaAlertStateDecision.Alerting, + data: [ + { + datasourceUid: '123', + refId: 'A', + queryType: 'huh', + model: { refId: 'A' }, + }, + ], + notification_settings: { + receiver: 'receiver', + mute_time_intervals: ['mute_timing'], + group_by: ['group_by'], + group_wait: 'group_wait', + group_interval: 'group_interval', + repeat_interval: 'repeat_interval', + }, + }; + + const result = getContactPointsFromDTO(ga); + expect(result).toEqual({ + [GRAFANA_RULES_SOURCE_NAME]: { + selectedContactPoint: 'receiver', + muteTimeIntervals: ['mute_timing'], + overrideGrouping: true, + overrideTimings: true, + groupBy: ['group_by'], + groupWaitValue: 'group_wait', + groupIntervalValue: 'group_interval', + repeatIntervalValue: 'repeat_interval', + }, + }); + }); +}); + +describe('getNotificationSettingsForDTO', () => { + it('should return undefined if manualRouting is false', () => { + const manualRouting = false; + const contactPoints: AlertManagerManualRouting = { + grafana: { + selectedContactPoint: 'receiver', + muteTimeIntervals: ['mute_timing'], + overrideGrouping: true, + overrideTimings: true, + groupBy: ['group_by'], + groupWaitValue: 'group_wait', + groupIntervalValue: 'group_interval', + repeatIntervalValue: 'repeat_interval', + }, + }; + + const result = getNotificationSettingsForDTO(manualRouting, contactPoints); + expect(result).toBeUndefined(); + }); + + it('should return undefined if selectedContactPoint is not defined', () => { + const manualRouting = true; + + const result = getNotificationSettingsForDTO(manualRouting, undefined); + expect(result).toBeUndefined(); + }); + + it('should return notification settings if manualRouting is true and selectedContactPoint is defined', () => { + const manualRouting = true; + const contactPoints: AlertManagerManualRouting = { + grafana: { + selectedContactPoint: 'receiver', + muteTimeIntervals: ['mute_timing'], + overrideGrouping: true, + overrideTimings: true, + groupBy: ['group_by'], + groupWaitValue: 'group_wait', + groupIntervalValue: 'group_interval', + repeatIntervalValue: 'repeat_interval', + }, + }; + + const result = getNotificationSettingsForDTO(manualRouting, contactPoints); + expect(result).toEqual({ + receiver: 'receiver', + mute_time_intervals: ['mute_timing'], + group_by: ['group_by'], + group_wait: 'group_wait', + group_interval: 'group_interval', + repeat_interval: 'repeat_interval', + }); + }); +}); + +describe('getDefautManualRouting', () => { + afterEach(() => { + window.localStorage.clear(); + }); + + it('returns false if the feature toggle is not enabled', () => { + config.featureToggles.alertingSimplifiedRouting = false; + expect(getDefautManualRouting()).toBe(false); + }); + + it('returns true if the feature toggle is enabled and localStorage is not set', () => { + config.featureToggles.alertingSimplifiedRouting = true; + expect(getDefautManualRouting()).toBe(true); + }); + + it('returns false if the feature toggle is enabled and localStorage is set to "false"', () => { + config.featureToggles.alertingSimplifiedRouting = true; + localStorage.setItem(MANUAL_ROUTING_KEY, 'false'); + expect(getDefautManualRouting()).toBe(false); + }); + + it('returns true if the feature toggle is enabled and localStorage is set to any value other than "false"', () => { + config.featureToggles.alertingSimplifiedRouting = true; + localStorage.setItem(MANUAL_ROUTING_KEY, 'true'); + expect(getDefautManualRouting()).toBe(true); + localStorage.removeItem(MANUAL_ROUTING_KEY); + expect(getDefautManualRouting()).toBe(true); + }); +}); diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index 963e78e4502fc..f7dd7d6c43737 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -11,11 +11,17 @@ import { ScopedVars, TimeRange, } from '@grafana/data'; -import { getDataSourceSrv } from '@grafana/runtime'; +import { config, getDataSourceSrv } from '@grafana/runtime'; import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; +import { sceneGraph, VizPanel } from '@grafana/scenes'; import { DataSourceJsonData } from '@grafana/schema'; import { getNextRefIdChar } from 'app/core/utils/query'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { + getDashboardSceneFor, + getPanelIdForVizPanel, + getQueryRunnerFor, +} from 'app/features/dashboard-scene/utils/utils'; import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; import { LokiQuery } from 'app/plugins/datasource/loki/types'; import { PromQuery } from 'app/plugins/datasource/prometheus/types'; @@ -25,6 +31,8 @@ import { AlertQuery, Annotations, GrafanaAlertStateDecision, + GrafanaNotificationSettings, + GrafanaRuleDefinition, Labels, PostableRuleGrafanaRuleDTO, RulerAlertingRuleDTO, @@ -33,11 +41,11 @@ import { } from 'app/types/unified-alerting-dto'; import { EvalFunction } from '../../state/alertDef'; -import { RuleFormType, RuleFormValues } from '../types/rule-form'; +import { AlertManagerManualRouting, ContactPoint, RuleFormType, RuleFormValues } from '../types/rule-form'; import { getRulesAccess } from './access-control'; import { Annotation, defaultAnnotations } from './constants'; -import { getDefaultOrFirstCompatibleDataSource, isGrafanaRulesSource } from './datasource'; +import { getDefaultOrFirstCompatibleDataSource, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from './datasource'; import { arrayToRecord, recordToArray } from './misc'; import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './rules'; import { parseInterval } from './time'; @@ -46,6 +54,8 @@ export type PromOrLokiQuery = PromQuery | LokiQuery; export const MINUTE = '1m'; +export const MANUAL_ROUTING_KEY = 'grafana.alerting.manualRouting'; + export const getDefaultFormValues = (): RuleFormValues => { const { canCreateGrafanaRules, canCreateCloudRules } = getRulesAccess(); @@ -67,7 +77,7 @@ export const getDefaultFormValues = (): RuleFormValues => { execErrState: GrafanaAlertStateDecision.Error, evaluateFor: '5m', evaluateEvery: MINUTE, - manualRouting: false, // let's decide this later + manualRouting: getDefautManualRouting(), // we default to true if the feature toggle is enabled and the user hasn't set local storage to false contactPoints: {}, overrideGrouping: false, overrideTimings: false, @@ -81,6 +91,18 @@ export const getDefaultFormValues = (): RuleFormValues => { }); }; +export const getDefautManualRouting = () => { + // first check if feature toggle for simplified routing is enabled + const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false; + if (!simplifiedRoutingToggleEnabled) { + return false; + } + //then, check in local storage if the user has enabled simplified routing + // if it's not set, we'll default to true + const manualRouting = localStorage.getItem(MANUAL_ROUTING_KEY); + return manualRouting !== 'false'; +}; + export function formValuesToRulerRuleDTO(values: RuleFormValues): RulerRuleDTO { const { name, expression, forTime, forTimeUnit, keepFiringForTime, keepFiringForTimeUnit, type } = values; if (type === RuleFormType.cloudAlerting) { @@ -138,10 +160,41 @@ export function normalizeDefaultAnnotations(annotations: Array<{ key: string; va return orderedAnnotations; } +export function getNotificationSettingsForDTO( + manualRouting: boolean, + contactPoints?: AlertManagerManualRouting +): GrafanaNotificationSettings | undefined { + if (contactPoints?.grafana?.selectedContactPoint && manualRouting) { + return { + receiver: contactPoints?.grafana?.selectedContactPoint, + mute_time_intervals: contactPoints?.grafana?.muteTimeIntervals, + group_by: contactPoints?.grafana?.overrideGrouping ? contactPoints?.grafana?.groupBy : undefined, + group_wait: + contactPoints?.grafana?.overrideTimings && contactPoints?.grafana?.groupWaitValue + ? contactPoints?.grafana?.groupWaitValue + : undefined, + group_interval: + contactPoints?.grafana?.overrideTimings && contactPoints?.grafana?.groupIntervalValue + ? contactPoints?.grafana?.groupIntervalValue + : undefined, + repeat_interval: + contactPoints?.grafana?.overrideTimings && contactPoints?.grafana?.repeatIntervalValue + ? contactPoints?.grafana?.repeatIntervalValue + : undefined, + }; + } + return undefined; +} + export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): PostableRuleGrafanaRuleDTO { const { name, condition, noDataState, execErrState, evaluateFor, queries, isPaused, contactPoints, manualRouting } = values; if (condition) { + const notificationSettings: GrafanaNotificationSettings | undefined = getNotificationSettingsForDTO( + manualRouting, + contactPoints + ); + return { grafana_alert: { title: name, @@ -150,7 +203,7 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl exec_err_state: execErrState, data: queries.map(fixBothInstantAndRangeQuery), is_paused: Boolean(isPaused), - contactPoints: manualRouting ? contactPoints : undefined, + notification_settings: notificationSettings, }, for: evaluateFor, annotations: arrayToRecord(values.annotations || []), @@ -160,6 +213,32 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl throw new Error('Cannot create rule without specifying alert condition'); } +export function getContactPointsFromDTO(ga: GrafanaRuleDefinition): AlertManagerManualRouting | undefined { + const contactPoint: ContactPoint | undefined = ga.notification_settings + ? { + selectedContactPoint: ga.notification_settings.receiver, + muteTimeIntervals: ga.notification_settings.mute_time_intervals ?? [], + overrideGrouping: + Array.isArray(ga.notification_settings.group_by) && ga.notification_settings.group_by.length > 0, + overrideTimings: [ + ga.notification_settings.group_wait, + ga.notification_settings.group_interval, + ga.notification_settings.repeat_interval, + ].some(Boolean), + groupBy: ga.notification_settings.group_by || [], + groupWaitValue: ga.notification_settings.group_wait || '', + groupIntervalValue: ga.notification_settings.group_interval || '', + repeatIntervalValue: ga.notification_settings.repeat_interval || '', + } + : undefined; + const routingSettings: AlertManagerManualRouting | undefined = contactPoint + ? { + [GRAFANA_RULES_SOURCE_NAME]: contactPoint, + } + : undefined; + return routingSettings; +} + export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleFormValues { const { ruleSourceName, namespace, group, rule } = ruleWithLocation; @@ -168,6 +247,8 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF if (isGrafanaRulerRule(rule)) { const ga = rule.grafana_alert; + const routingSettings: AlertManagerManualRouting | undefined = getContactPointsFromDTO(ga); + return { ...defaultFormValues, name: ga.title, @@ -183,26 +264,9 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF labels: listifyLabelsOrAnnotations(rule.labels, true), folder: { title: namespace, uid: ga.namespace_uid }, isPaused: ga.is_paused, - contactPoints: ga.contactPoints, - manualRouting: Boolean(ga.contactPoints), - // next line is for testing - // manualRouting: true, - // contactPoints: { - // grafana: { - // selectedContactPoint: "contact_point_5", - // muteTimeIntervals: [ - // "mute timing 1" - // ], - // overrideGrouping: true, - // overrideTimings: true, - // "groupBy": [ - // "..." - // ], - // groupWaitValue: "35s", - // groupIntervalValue: "6m", - // repeatIntervalValue: "5h" - // } - // } + + contactPoints: routingSettings, + manualRouting: Boolean(routingSettings), }; } else { throw new Error('Unexpected type of rule for grafana rules source'); @@ -268,9 +332,7 @@ export function alertingRulerRuleToRuleForm( > { const defaultFormValues = getDefaultFormValues(); - const [forTime, forTimeUnit] = rule.for - ? parseInterval(rule.for) - : [defaultFormValues.forTime, defaultFormValues.forTimeUnit]; + const [forTime, forTimeUnit] = rule.for ? parseInterval(rule.for) : [0, 's']; const [keepFiringForTime, keepFiringForTimeUnit] = rule.keep_firing_for ? parseInterval(rule.keep_firing_for) @@ -440,7 +502,7 @@ const dataQueriesToGrafanaQueries = async ( }; const interpolatedTarget = datasource.interpolateVariablesInQueries - ? await datasource.interpolateVariablesInQueries([target], queryVariables)[0] + ? datasource.interpolateVariablesInQueries([target], queryVariables)[0] : target; // expressions @@ -537,6 +599,77 @@ export const panelToRuleFormValues = async ( return formValues; }; +export const scenesPanelToRuleFormValues = async (vizPanel: VizPanel): Promise<Partial<RuleFormValues> | undefined> => { + if (!vizPanel.state.key) { + return undefined; + } + + const timeRange = sceneGraph.getTimeRange(vizPanel); + const queryRunner = getQueryRunnerFor(vizPanel); + if (!queryRunner) { + return undefined; + } + const { queries, datasource, maxDataPoints, minInterval } = queryRunner.state; + + const dashboard = getDashboardSceneFor(vizPanel); + if (!dashboard || !dashboard.state.uid) { + return undefined; + } + + const grafanaQueries = await dataQueriesToGrafanaQueries( + queries, + rangeUtil.timeRangeToRelative(rangeUtil.convertRawToRange(timeRange.state.value.raw)), + { __sceneObject: { value: vizPanel } }, + datasource, + maxDataPoints, + minInterval + ); + + // if no alerting capable queries are found, can't create a rule + if (!grafanaQueries.length || !grafanaQueries.find((query) => query.datasourceUid !== ExpressionDatasourceUID)) { + return undefined; + } + + if (!grafanaQueries.find((query) => query.datasourceUid === ExpressionDatasourceUID)) { + const [reduceExpression, _thresholdExpression] = getDefaultExpressions(getNextRefIdChar(grafanaQueries), '-'); + grafanaQueries.push(reduceExpression); + + const [_reduceExpression, thresholdExpression] = getDefaultExpressions( + reduceExpression.refId, + getNextRefIdChar(grafanaQueries) + ); + grafanaQueries.push(thresholdExpression); + } + + const { folderTitle, folderUid } = dashboard.state.meta; + + const formValues = { + type: RuleFormType.grafana, + folder: + folderUid && folderTitle + ? { + uid: folderUid, + title: folderTitle, + } + : undefined, + queries: grafanaQueries, + name: vizPanel.state.title, + condition: grafanaQueries[grafanaQueries.length - 1].refId, + annotations: [ + { + key: Annotation.dashboardUID, + value: dashboard.state.uid, + }, + { + key: Annotation.panelID, + + value: String(getPanelIdForVizPanel(vizPanel)), + }, + ], + }; + return formValues; +}; + export function getIntervals(range: TimeRange, lowLimit?: string, resolution?: number): IntervalValues { if (!resolution) { if (lowLimit && rangeUtil.intervalToMs(lowLimit) > 1000) { diff --git a/public/app/features/alerting/unified/utils/rulerClient.ts b/public/app/features/alerting/unified/utils/rulerClient.ts index ed5938020b860..1e6405ef0c24c 100644 --- a/public/app/features/alerting/unified/utils/rulerClient.ts +++ b/public/app/features/alerting/unified/utils/rulerClient.ts @@ -6,7 +6,7 @@ import { RulerRuleGroupDTO, } from 'app/types/unified-alerting-dto'; -import { deleteRulerRulesGroup, fetchRulerRulesGroup, fetchRulerRules, setRulerRuleGroup } from '../api/ruler'; +import { deleteRulerRulesGroup, fetchRulerRules, fetchRulerRulesGroup, setRulerRuleGroup } from '../api/ruler'; import { RuleFormValues } from '../types/rule-form'; import * as ruleId from '../utils/rule-id'; @@ -41,6 +41,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient group, ruleSourceName: GRAFANA_RULES_SOURCE_NAME, namespace: namespace, + namespace_uid: (isGrafanaRulerRule(rule) && rule.grafana_alert.namespace_uid) || undefined, rule, }; } @@ -81,15 +82,15 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient }; const deleteRule = async (ruleWithLocation: RuleWithLocation): Promise<void> => { - const { namespace, group, rule } = ruleWithLocation; + const { namespace, group, rule, namespace_uid } = ruleWithLocation; // it was the last rule, delete the entire group if (group.rules.length === 1) { - await deleteRulerRulesGroup(rulerConfig, namespace, group.name); + await deleteRulerRulesGroup(rulerConfig, namespace_uid || namespace, group.name); return; } // post the group with rule removed - await setRulerRuleGroup(rulerConfig, namespace, { + await setRulerRuleGroup(rulerConfig, namespace_uid || namespace, { ...group, rules: group.rules.filter((r) => r !== rule), }); @@ -159,11 +160,11 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient } const newRule = formValuesToRulerGrafanaRuleDTO(values); - const namespace = folder.title; + const namespaceUID = folder.uid; const groupSpec = { name: group, interval: evaluateEvery }; if (!existingRule) { - return addRuleToNamespaceAndGroup(namespace, groupSpec, newRule); + return addRuleToNamespaceAndGroup(namespaceUID, groupSpec, newRule); } // we'll fetch the existing group again, someone might have updated it while we were editing a rule @@ -172,7 +173,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient throw new Error('Rule not found.'); } - const sameNamespace = freshExisting.namespace === namespace; + const sameNamespace = freshExisting.namespace_uid === namespaceUID; const sameGroup = freshExisting.group.name === values.group; const sameLocation = sameNamespace && sameGroup; @@ -181,16 +182,16 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient return updateGrafanaRule(freshExisting, newRule, evaluateEvery); } else { // we're moving a rule to either a different group or namespace - return moveGrafanaRule(namespace, groupSpec, freshExisting, newRule); + return moveGrafanaRule(namespaceUID, groupSpec, freshExisting, newRule); } }; const addRuleToNamespaceAndGroup = async ( - namespace: string, + namespaceUID: string, group: { name: string; interval: string }, newRule: PostableRuleGrafanaRuleDTO ): Promise<RuleIdentifier> => { - const existingGroup = await fetchRulerRulesGroup(rulerConfig, namespace, group.name); + const existingGroup = await fetchRulerRulesGroup(rulerConfig, namespaceUID, group.name); if (!existingGroup) { throw new Error(`No group found with name "${group.name}"`); } @@ -201,7 +202,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient rules: (existingGroup.rules ?? []).concat(newRule as RulerGrafanaRuleDTO), }; - await setRulerRuleGroup(rulerConfig, namespace, payload); + await setRulerRuleGroup(rulerConfig, namespaceUID, payload); return { uid: newRule.grafana_alert.uid ?? '', ruleSourceName: GRAFANA_RULES_SOURCE_NAME }; }; @@ -242,7 +243,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient return rule; }); - await setRulerRuleGroup(rulerConfig, existingRule.namespace, { + await setRulerRuleGroup(rulerConfig, existingRule.namespace_uid ?? '', { name: existingRule.group.name, interval: interval, rules: newRules, diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index 05759a8df6919..3885db0d20df6 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -229,8 +229,8 @@ export const getAlertInfo = (alert: RulerRuleDTO, currentEvaluation: string): Al if (isAlertingRulerRule(alert)) { return { alertName: alert.alert, - forDuration: alert.for ?? '1m', - evaluationsToFire: getNumberEvaluationsToStartAlerting(alert.for ?? '1m', currentEvaluation), + forDuration: alert.for ?? '0s', + evaluationsToFire: getNumberEvaluationsToStartAlerting(alert.for ?? '0s', currentEvaluation), }; } return emptyAlert; diff --git a/public/app/features/alerting/unified/utils/timeRange.ts b/public/app/features/alerting/unified/utils/timeRange.ts index dd7b3370bd1d4..11edb6c9034da 100644 --- a/public/app/features/alerting/unified/utils/timeRange.ts +++ b/public/app/features/alerting/unified/utils/timeRange.ts @@ -29,6 +29,7 @@ const getReferencedIds = (model: ExpressionQuery, queries: AlertQuery[]): string case ExpressionQueryType.classic: return getReferencedIdsForClassicCondition(model); case ExpressionQueryType.math: + case ExpressionQueryType.sql: return getReferencedIdsForMath(model, queries); case ExpressionQueryType.resample: case ExpressionQueryType.reduce: diff --git a/public/app/features/alerting/utils/notificationChannel.test.ts b/public/app/features/alerting/utils/notificationChannel.test.ts deleted file mode 100644 index 757c9f3477fc2..0000000000000 --- a/public/app/features/alerting/utils/notificationChannel.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { NotificationChannelDTO } from '../../../types'; - -import { transformSubmitData } from './notificationChannels'; - -const basicFormData: NotificationChannelDTO = { - id: 1, - uid: 'pX7fbbHGk', - name: 'Pete discord', - type: { - value: 'discord', - label: 'Discord', - type: 'discord', - name: 'Discord', - heading: 'Discord settings', - description: 'Sends notifications to Discord', - info: '', - options: [ - { - element: 'input', - inputType: 'text', - label: 'Message Content', - description: 'Mention a group using @ or a user using <@ID> when notifying in a channel', - placeholder: '', - propertyName: 'content', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'input', - inputType: 'text', - label: 'Webhook URL', - description: '', - placeholder: 'Discord webhook URL', - propertyName: 'url', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: false, - }, - ], - typeName: 'discord', - }, - isDefault: false, - sendReminder: false, - disableResolveMessage: false, - frequency: '', - created: '2020-08-24T10:46:43+02:00', - updated: '2020-09-02T14:08:27+02:00', - settings: { - url: 'https://discordapp.com/api/webhooks/', - uploadImage: true, - content: '', - autoResolve: true, - httpMethod: 'POST', - severity: 'critical', - }, - secureFields: {}, - secureSettings: {}, -}; - -const selectFormData: NotificationChannelDTO = { - id: 23, - uid: 'BxEN9rNGk', - name: 'Webhook', - type: { - value: 'webhook', - label: 'webhook', - type: 'webhook', - name: 'webhook', - heading: 'Webhook settings', - description: 'Sends HTTP POST request to a URL', - info: '', - options: [ - { - element: 'input', - inputType: 'text', - label: 'Url', - description: '', - placeholder: '', - propertyName: 'url', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: false, - }, - { - element: 'select', - inputType: '', - label: 'Http Method', - description: '', - placeholder: '', - propertyName: 'httpMethod', - selectOptions: [ - { value: 'POST', label: 'POST' }, - { value: 'PUT', label: 'PUT' }, - ], - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'input', - inputType: 'text', - label: 'Username', - description: '', - placeholder: '', - propertyName: 'username', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'input', - inputType: 'password', - label: 'Password', - description: '', - placeholder: '', - propertyName: 'password', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: true, - }, - ], - typeName: 'webhook', - }, - isDefault: false, - sendReminder: false, - disableResolveMessage: false, - frequency: '', - created: '2020-08-28T10:47:37+02:00', - updated: '2020-09-03T09:37:21+02:00', - settings: { - autoResolve: true, - httpMethod: 'POST', - password: '', - severity: 'critical', - uploadImage: true, - url: 'http://asdf', - username: 'asdf', - }, - secureFields: { password: true }, - secureSettings: {}, -}; - -describe('Transform submit data', () => { - it('basic transform', () => { - const expected = { - id: 1, - name: 'Pete discord', - type: 'discord', - sendReminder: false, - disableResolveMessage: false, - frequency: '15m', - settings: { - uploadImage: true, - autoResolve: true, - httpMethod: 'POST', - severity: 'critical', - url: 'https://discordapp.com/api/webhooks/', - content: '', - }, - secureSettings: {}, - secureFields: {}, - isDefault: false, - uid: 'pX7fbbHGk', - created: '2020-08-24T10:46:43+02:00', - updated: '2020-09-02T14:08:27+02:00', - }; - - expect(transformSubmitData(basicFormData)).toEqual(expected); - }); - - it('should transform form data with selects', () => { - const expected = { - created: '2020-08-28T10:47:37+02:00', - disableResolveMessage: false, - frequency: '15m', - id: 23, - isDefault: false, - name: 'Webhook', - secureFields: { password: true }, - secureSettings: {}, - sendReminder: false, - settings: { - autoResolve: true, - httpMethod: 'POST', - password: '', - severity: 'critical', - uploadImage: true, - url: 'http://asdf', - username: 'asdf', - }, - type: 'webhook', - uid: 'BxEN9rNGk', - updated: '2020-09-03T09:37:21+02:00', - }; - - expect(transformSubmitData(selectFormData)).toEqual(expected); - }); -}); diff --git a/public/app/features/alerting/utils/notificationChannels.ts b/public/app/features/alerting/utils/notificationChannels.ts deleted file mode 100644 index 7c9419ba39f15..0000000000000 --- a/public/app/features/alerting/utils/notificationChannels.ts +++ /dev/null @@ -1,72 +0,0 @@ -import memoizeOne from 'memoize-one'; - -import { SelectableValue } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { NotificationChannelDTO, NotificationChannelType } from 'app/types'; - -export const defaultValues: NotificationChannelDTO = { - id: -1, - name: '', - type: { value: 'email', label: 'Email' }, - sendReminder: false, - disableResolveMessage: false, - frequency: '15m', - settings: { - uploadImage: config.rendererAvailable, - autoResolve: true, - httpMethod: 'POST', - severity: 'critical', - }, - secureSettings: {}, - secureFields: {}, - isDefault: false, -}; - -export const mapChannelsToSelectableValue = memoizeOne( - (notificationChannels: NotificationChannelType[], includeDescription: boolean): Array<SelectableValue<string>> => { - return notificationChannels.map((channel) => { - if (includeDescription) { - return { - value: channel.value, - label: channel.label, - description: channel.description, - }; - } - return { - value: channel.value, - label: channel.label, - }; - }); - } -); - -export const transformSubmitData = (formData: NotificationChannelDTO) => { - /* - Some settings can be options in a select, in order to not save a SelectableValue<T> - we need to use check if it is a SelectableValue and use its value. - */ - const settings = Object.fromEntries( - Object.entries(formData.settings).map(([key, value]) => { - return [key, value && value.hasOwnProperty('value') ? value.value : value]; - }) - ); - - return { - ...defaultValues, - ...formData, - frequency: formData.frequency === '' ? defaultValues.frequency : formData.frequency, - type: formData.type.value, - settings: { ...defaultValues.settings, ...settings }, - secureSettings: { ...formData.secureSettings }, - }; -}; - -export const transformTestData = (formData: NotificationChannelDTO) => { - return { - name: formData.name, - type: formData.type.value, - frequency: formData.frequency ?? defaultValues.frequency, - settings: { ...Object.assign(defaultValues.settings, formData.settings) }, - secureSettings: { ...formData.secureSettings }, - }; -}; diff --git a/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx b/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx index 4bce32eddb192..b07d7843759b3 100644 --- a/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx +++ b/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx @@ -1,5 +1,4 @@ -import { css, cx } from '@emotion/css'; -import React, { PureComponent } from 'react'; +import React, { PureComponent, ReactElement } from 'react'; import { lastValueFrom } from 'rxjs'; import { @@ -11,7 +10,8 @@ import { DataSourcePluginContextProvider, LoadingState, } from '@grafana/data'; -import { Button, Icon, IconName, Spinner } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; +import { Alert, AlertVariant, Button, Space, Spinner } from '@grafana/ui'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { PanelModel } from 'app/features/dashboard/state'; @@ -112,63 +112,85 @@ export default class StandardAnnotationQueryEditor extends PureComponent<Props, }); }; + getStatusSeverity(response: AnnotationQueryResponse): AlertVariant { + const { events, panelData } = response; + + if (panelData?.errors || panelData?.error) { + return 'error'; + } + + if (!events?.length) { + return 'warning'; + } + + return 'success'; + } + + renderStatusText(response: AnnotationQueryResponse, running: boolean | undefined): ReactElement { + const { events, panelData } = response; + + if (running || response?.panelData?.state === LoadingState.Loading || !response) { + return <p>{'loading...'}</p>; + } + + if (panelData?.errors) { + return ( + <> + {panelData.errors.map((e, i) => ( + <p key={i}>{e.message}</p> + ))} + </> + ); + } + if (panelData?.error) { + return <p>{panelData.error.message ?? 'There was an error fetching data'}</p>; + } + + if (!events?.length) { + return <p>No events found</p>; + } + + const frame = panelData?.series?.[0] ?? panelData?.annotations?.[0]; + return ( + <p> + {events.length} events (from {frame?.fields.length} fields) + </p> + ); + } + renderStatus() { const { response, running } = this.state; - let rowStyle = 'alert-info'; - let text = '...'; - let icon: IconName | undefined = undefined; - if (running || response?.panelData?.state === LoadingState.Loading || !response) { - text = 'loading...'; - } else { - const { events, panelData } = response; - - if (panelData?.error) { - rowStyle = 'alert-error'; - icon = 'exclamation-triangle'; - text = panelData.error.message ?? 'error'; - } else if (!events?.length) { - rowStyle = 'alert-warning'; - icon = 'exclamation-triangle'; - text = 'No events found'; - } else { - const frame = panelData?.series?.[0] ?? panelData?.annotations?.[0]; - - text = `${events.length} events (from ${frame?.fields.length} fields)`; - } + if (!response) { + return null; } + return ( - <div - className={cx( - rowStyle, - css` - margin: 4px 0px; - padding: 4px; - display: flex; - justify-content: space-between; - align-items: center; - ` - )} - > - <div> - {icon && ( - <> - <Icon name={icon} /> -   - </> - )} - {text} - </div> + <> + <Space v={2} /> <div> {running ? ( <Spinner /> ) : ( - <Button variant="secondary" size="xs" onClick={this.onRunQuery}> - TEST + <Button + data-testid={selectors.components.Annotations.editor.testButton} + variant="secondary" + size="xs" + onClick={this.onRunQuery} + > + Test annotation query </Button> )} </div> - </div> + <Space v={2} layout="block" /> + <Alert + data-testid={selectors.components.Annotations.editor.resultContainer} + severity={this.getStatusSeverity(response)} + title="Query result" + > + {this.renderStatusText(response, running)} + </Alert> + </> ); } diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx index db06645a2fa18..4067439b51174 100644 --- a/public/app/features/api-keys/ApiKeysPage.tsx +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -113,18 +113,10 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> { migrationResult, } = this.props; - if (!hasFetched) { - return ( - <Page {...defaultPageProps}> - <Page.Contents isLoading={true}>{}</Page.Contents> - </Page> - ); - } - const showTable = apiKeysCount > 0; return ( <Page {...defaultPageProps}> - <Page.Contents isLoading={false}> + <Page.Contents isLoading={!hasFetched}> <> <MigrateToServiceAccountsCard onMigrate={this.onMigrateApiKeys} apikeysCount={apiKeysCount} /> {showTable ? ( diff --git a/public/app/features/auth-config/AuthDrawer.test.tsx b/public/app/features/auth-config/AuthDrawer.test.tsx new file mode 100644 index 0000000000000..2af62926c0998 --- /dev/null +++ b/public/app/features/auth-config/AuthDrawer.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { AuthDrawer, Props } from './AuthDrawer'; + +const defaultProps: Props = { + onClose: jest.fn(), +}; + +async function getTestContext(overrides: Partial<Props> = {}) { + jest.clearAllMocks(); + + const props = { ...defaultProps, ...overrides }; + const { rerender } = render(<AuthDrawer {...props} />); + + return { rerender, props }; +} + +it('should render with default props', async () => { + await getTestContext({}); + expect(screen.getByText(/Enable insecure email lookup/i)).toBeInTheDocument(); +}); diff --git a/public/app/features/auth-config/AuthDrawer.tsx b/public/app/features/auth-config/AuthDrawer.tsx new file mode 100644 index 0000000000000..b2fbbf5f9f652 --- /dev/null +++ b/public/app/features/auth-config/AuthDrawer.tsx @@ -0,0 +1,104 @@ +import { css } from '@emotion/css'; +import React, { useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { getBackendSrv } from '@grafana/runtime'; +import { Button, Drawer, Text, TextLink, Switch, useStyles2 } from '@grafana/ui'; + +export interface Props { + onClose: () => void; +} + +const SETTINGS_URL = '/api/admin/settings'; + +export const AuthDrawer = ({ onClose }: Props) => { + const [isOauthAllowInsecureEmailLookup, setOauthAllowInsecureEmailLookup] = useState(false); + + const getSettings = async () => { + try { + const response = await getBackendSrv().get(SETTINGS_URL); + setOauthAllowInsecureEmailLookup(response.auth.oauth_allow_insecure_email_lookup?.toLowerCase?.() === 'true'); + } catch (error) {} + }; + const updateSettings = async (property: boolean) => { + try { + const body = { + updates: { + auth: { + oauth_allow_insecure_email_lookup: '' + property, + }, + }, + }; + await getBackendSrv().put(SETTINGS_URL, body); + } catch (error) {} + }; + + const resetButtonOnClick = async () => { + try { + const body = { + removals: { + auth: ['oauth_allow_insecure_email_lookup'], + }, + }; + await getBackendSrv().put(SETTINGS_URL, body); + getSettings(); + } catch (error) {} + }; + + const oauthAllowInsecureEmailLookupOnChange = async () => { + updateSettings(!isOauthAllowInsecureEmailLookup); + setOauthAllowInsecureEmailLookup(!isOauthAllowInsecureEmailLookup); + }; + + const subtitle = ( + <> + Configure auth settings. Find out more in our{' '} + <TextLink + external={true} + href="https://grafana.com/docs/grafana/next/setup-grafana/configure-security/configure-authentication/#settings" + > + documentation + </TextLink> + . + </> + ); + + const styles = useStyles2(getStyles); + + getSettings(); + + return ( + <Drawer title="Auth Settings" subtitle={subtitle} size="md" onClose={onClose}> + <div className={styles.advancedAuth}> + <Text variant="h4">Advanced Auth</Text> + <Text variant="h5">Enable insecure email lookup</Text> + <Text variant="body" color="secondary"> + Allow users to use the same email address to log into Grafana with different identity providers. + </Text> + <Switch value={isOauthAllowInsecureEmailLookup} onChange={oauthAllowInsecureEmailLookupOnChange} /> + </div> + <Button + size="md" + variant="secondary" + className={styles.button} + onClick={resetButtonOnClick} + tooltip="This action will disregard any saved changes and load the configuration from the configuration file." + > + Reset + </Button> + </Drawer> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + advancedAuth: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + }), + button: css({ + marginTop: theme.spacing(2), + }), + }; +}; diff --git a/public/app/features/auth-config/AuthProvidersListPage.tsx b/public/app/features/auth-config/AuthProvidersListPage.tsx index 53d317565295b..7a1330bcf597e 100644 --- a/public/app/features/auth-config/AuthProvidersListPage.tsx +++ b/public/app/features/auth-config/AuthProvidersListPage.tsx @@ -1,11 +1,14 @@ -import React, { JSX, useEffect } from 'react'; +import React, { JSX, useEffect, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; +import { GrafanaEdition } from '@grafana/data/src/types/config'; import { reportInteraction } from '@grafana/runtime'; -import { Grid, TextLink } from '@grafana/ui'; +import { Grid, TextLink, ToolbarButton } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; +import { config } from 'app/core/config'; import { StoreState } from 'app/types'; +import { AuthDrawer } from './AuthDrawer'; import ConfigureAuthCTA from './components/ConfigureAuthCTA'; import { ProviderCard } from './components/ProviderCard'; import { loadSettings } from './state/actions'; @@ -41,10 +44,12 @@ export const AuthConfigPageUnconnected = ({ loadSettings(); }, [loadSettings]); + const [showDrawer, setShowDrawer] = useState(false); + const authProviders = getRegisteredAuthProviders(); const availableProviders = authProviders.filter((p) => !providerStatuses[p.id]?.hide); - const onProviderCardClick = (providerType: string) => { - reportInteraction('authentication_ui_provider_clicked', { provider: providerType }); + const onProviderCardClick = (providerType: string, enabled: boolean) => { + reportInteraction('authentication_ui_provider_clicked', { provider: providerType, enabled }); }; const providerList = availableProviders.length @@ -62,12 +67,22 @@ export const AuthConfigPageUnconnected = ({ subTitle={ <> Manage your auth settings and configure single sign-on. Find out more in our{' '} - <TextLink href="https://grafana.com/docs/grafana/next/setup-grafana/configure-security/configure-authentication"> + <TextLink + external={true} + href="https://grafana.com/docs/grafana/next/setup-grafana/configure-security/configure-authentication" + > documentation </TextLink> . </> } + actions={ + config.buildInfo.edition !== GrafanaEdition.OpenSource && ( + <ToolbarButton icon="cog" variant="canvas" onClick={() => setShowDrawer(true)}> + Auth settings + </ToolbarButton> + ) + } > <Page.Contents isLoading={isLoading}> {!providerList.length ? ( @@ -75,19 +90,20 @@ export const AuthConfigPageUnconnected = ({ ) : ( <Grid gap={3} minColumnWidth={34}> {providerList - // Temporarily filter providers that don't have the UI implemented - .filter(({ provider }) => !['grafana_com', 'generic_oauth'].includes(provider)) + // Temporarily filter out providers that don't have the UI implemented + .filter(({ provider }) => !['grafana_com'].includes(provider)) .map(({ provider, settings }) => ( <ProviderCard key={provider} authType={settings.type || 'OAuth'} providerId={provider} enabled={settings.enabled} - onClick={() => onProviderCardClick(provider)} + onClick={() => onProviderCardClick(provider, settings.enabled)} //@ts-expect-error Remove legacy types configPath={settings.configPath} /> ))} + {showDrawer && <AuthDrawer onClose={() => setShowDrawer(false)}></AuthDrawer>} </Grid> )} </Page.Contents> diff --git a/public/app/features/auth-config/FieldRenderer.tsx b/public/app/features/auth-config/FieldRenderer.tsx new file mode 100644 index 0000000000000..856ac56b252b2 --- /dev/null +++ b/public/app/features/auth-config/FieldRenderer.tsx @@ -0,0 +1,149 @@ +import { css } from '@emotion/css'; +import React, { useEffect, useState } from 'react'; +import { UseFormReturn, Controller } from 'react-hook-form'; + +import { Checkbox, Field, Input, SecretInput, Select, Switch, useTheme2 } from '@grafana/ui'; + +import { fieldMap } from './fields'; +import { SSOProviderDTO, SSOSettingsField } from './types'; +import { isSelectableValue } from './utils/guards'; + +interface FieldRendererProps + extends Pick<UseFormReturn<SSOProviderDTO>, 'register' | 'control' | 'watch' | 'setValue' | 'unregister'> { + field: SSOSettingsField; + errors: UseFormReturn['formState']['errors']; + secretConfigured: boolean; + provider: string; +} + +export const FieldRenderer = ({ + field, + register, + errors, + watch, + setValue, + control, + unregister, + secretConfigured, + provider, +}: FieldRendererProps) => { + const [isSecretConfigured, setIsSecretConfigured] = useState(secretConfigured); + const isDependantField = typeof field !== 'string'; + const name = isDependantField ? field.name : field; + const parentValue = isDependantField ? watch(field.dependsOn) : null; + const fieldData = fieldMap(provider)[name]; + const theme = useTheme2(); + // Unregister a field that depends on a toggle to clear its data + useEffect(() => { + if (isDependantField) { + if (!parentValue) { + unregister(name); + } + } + }, [unregister, name, parentValue, isDependantField]); + + if (!field) { + console.log('missing field:', name); + return null; + } + + if (!!fieldData.hidden) { + return null; + } + + // Dependant field means the field depends on another field's value and shouldn't be rendered if the parent field is false + if (isDependantField) { + const parentValue = watch(field.dependsOn); + if (!parentValue) { + return null; + } + } + const fieldProps = { + label: fieldData.label, + required: !!fieldData.validation?.required, + invalid: !!errors[name], + error: fieldData.validation?.message, + key: name, + description: fieldData.description, + defaultValue: fieldData.defaultValue?.value, + }; + + switch (fieldData.type) { + case 'text': + return ( + <Field {...fieldProps}> + <Input {...register(name, fieldData.validation)} type={fieldData.type} id={name} autoComplete={'off'} /> + </Field> + ); + case 'secret': + return ( + <Field {...fieldProps} htmlFor={name}> + <Controller + name={name} + control={control} + rules={fieldData.validation} + render={({ field: { ref, value, ...field } }) => ( + <SecretInput + {...field} + autoComplete={'off'} + id={name} + value={typeof value === 'string' ? value : ''} + isConfigured={isSecretConfigured} + onReset={() => { + setIsSecretConfigured(false); + setValue(name, ''); + }} + /> + )} + /> + </Field> + ); + case 'select': + const watchOptions = watch(name); + let options = fieldData.options; + if (!fieldData.options?.length) { + options = isSelectableValue(watchOptions) ? watchOptions : []; + } + return ( + <Field {...fieldProps} htmlFor={name}> + <Controller + rules={fieldData.validation} + name={name} + control={control} + render={({ field: { ref, onChange, ...fieldProps }, fieldState: { invalid } }) => { + return ( + <Select + {...fieldProps} + placeholder={fieldData.placeholder} + isMulti={fieldData.multi} + invalid={invalid} + inputId={name} + options={options} + allowCustomValue={!!fieldData.allowCustomValue} + defaultValue={fieldData.defaultValue} + onChange={onChange} + onCreateOption={(v) => { + const customValue = { value: v, label: v }; + onChange([...(options || []), customValue]); + }} + /> + ); + }} + /> + </Field> + ); + case 'switch': + return ( + <Field {...fieldProps}> + <Switch {...register(name)} id={name} /> + </Field> + ); + case 'checkbox': + return ( + <Checkbox {...register(name)} id={name} {...fieldProps} className={css({ marginBottom: theme.spacing(2) })} /> + ); + default: + console.error(`Unknown field type: ${fieldData.type}`); + return null; + } +}; diff --git a/public/app/features/auth-config/ProviderConfigForm.test.tsx b/public/app/features/auth-config/ProviderConfigForm.test.tsx index a458748451d3a..efab7d4294796 100644 --- a/public/app/features/auth-config/ProviderConfigForm.test.tsx +++ b/public/app/features/auth-config/ProviderConfigForm.test.tsx @@ -2,14 +2,19 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React, { JSX } from 'react'; +import { reportInteraction } from '@grafana/runtime'; + import { ProviderConfigForm } from './ProviderConfigForm'; import { SSOProvider } from './types'; import { emptySettings } from './utils/data'; const putMock = jest.fn(() => Promise.resolve({})); +const deleteMock = jest.fn(() => Promise.resolve({})); + jest.mock('@grafana/runtime', () => ({ getBackendSrv: () => ({ put: putMock, + delete: deleteMock, }), config: { panels: { @@ -26,15 +31,20 @@ jest.mock('@grafana/runtime', () => ({ locationService: { push: jest.fn(), }, + reportInteraction: jest.fn(), })); +const reportInteractionMock = jest.mocked(reportInteraction); + // Mock the FormPrompt component as it requires Router setup to work jest.mock('app/core/components/FormPrompt/FormPrompt', () => ({ FormPrompt: () => <></>, })); const testConfig: SSOProvider = { + id: '300f9b7c-0488-40db-9763-a22ce8bf6b3e', provider: 'github', + source: 'database', settings: { ...emptySettings, name: 'GitHub', @@ -52,7 +62,7 @@ const testConfig: SSOProvider = { const emptyConfig = { ...testConfig, - settings: { ...testConfig.settings, clientId: '', clientSecret: '' }, + settings: { ...testConfig.settings, enabled: false, clientId: '', clientSecret: '' }, }; function setup(jsx: JSX.Element) { @@ -69,7 +79,6 @@ describe('ProviderConfigForm', () => { it('renders all fields correctly', async () => { setup(<ProviderConfigForm config={testConfig} provider={testConfig.provider} />); - expect(screen.getByRole('checkbox', { name: /Enabled/i })).toBeInTheDocument(); expect(screen.getByRole('textbox', { name: /Client ID/i })).toBeInTheDocument(); expect(screen.getByRole('combobox', { name: /Team IDs/i })).toBeInTheDocument(); expect(screen.getByRole('combobox', { name: /Allowed organizations/i })).toBeInTheDocument(); @@ -77,7 +86,7 @@ describe('ProviderConfigForm', () => { expect(screen.getByRole('link', { name: /Discard/i })).toBeInTheDocument(); }); - it('should save correct data on form submit', async () => { + it('should save and enable on form submit', async () => { const { user } = setup(<ProviderConfigForm config={emptyConfig} provider={emptyConfig.provider} />); await user.type(screen.getByRole('textbox', { name: /Client ID/i }), 'test-client-id'); await user.type(screen.getByLabelText(/Client secret/i), 'test-client-secret'); @@ -86,31 +95,101 @@ describe('ProviderConfigForm', () => { // Add two orgs await user.type(screen.getByRole('combobox', { name: /Allowed organizations/i }), 'test-org1{enter}'); await user.type(screen.getByRole('combobox', { name: /Allowed organizations/i }), 'test-org2{enter}'); - await user.click(screen.getByRole('button', { name: /Save/i })); + await user.click(screen.getByRole('button', { name: /Save and enable/i })); await waitFor(() => { - expect(putMock).toHaveBeenCalledWith('/api/v1/sso-settings/github', { - ...testConfig, - settings: { - ...testConfig.settings, - allowedOrganizations: 'test-org1,test-org2', - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - teamIds: '12324', - enabled: true, - allowedDomains: '', - allowedGroups: '', - scopes: '', + expect(putMock).toHaveBeenCalledWith( + '/api/v1/sso-settings/github', + { + id: '300f9b7c-0488-40db-9763-a22ce8bf6b3e', + provider: 'github', + settings: { + name: 'GitHub', + allowedOrganizations: 'test-org1,test-org2', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + teamIds: '12324', + enabled: true, + }, }, + { showErrorAlert: false } + ); + + expect(reportInteractionMock).toHaveBeenCalledWith('grafana_authentication_ssosettings_saved', { + provider: 'github', + enabled: true, }); }); }); - it('should validate required fields', async () => { + it('should save on form submit', async () => { const { user } = setup(<ProviderConfigForm config={emptyConfig} provider={emptyConfig.provider} />); - await user.click(screen.getByRole('button', { name: /Save/i })); + await user.type(screen.getByRole('textbox', { name: /Client ID/i }), 'test-client-id'); + await user.type(screen.getByLabelText(/Client secret/i), 'test-client-secret'); + // Type a team name and press enter to select it + await user.type(screen.getByRole('combobox', { name: /Team IDs/i }), '12324{enter}'); + // Add two orgs + await user.type(screen.getByRole('combobox', { name: /Allowed organizations/i }), 'test-org1{enter}'); + await user.type(screen.getByRole('combobox', { name: /Allowed organizations/i }), 'test-org2{enter}'); + await user.click(screen.getByText('Save')); + + await waitFor(() => { + expect(putMock).toHaveBeenCalledWith( + '/api/v1/sso-settings/github', + { + id: '300f9b7c-0488-40db-9763-a22ce8bf6b3e', + provider: 'github', + settings: { + name: 'GitHub', + allowedOrganizations: 'test-org1,test-org2', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + teamIds: '12324', + enabled: false, + }, + }, + { showErrorAlert: false } + ); + + expect(reportInteractionMock).toHaveBeenCalledWith('grafana_authentication_ssosettings_saved', { + provider: 'github', + enabled: false, + }); + }); + }); + + it('should validate required fields on Save', async () => { + const { user } = setup(<ProviderConfigForm config={emptyConfig} provider={emptyConfig.provider} />); + await user.click(screen.getByText('Save')); // Should show an alert for empty client ID expect(await screen.findAllByRole('alert')).toHaveLength(1); }); + + it('should validate required fields on Save and enable', async () => { + const { user } = setup(<ProviderConfigForm config={emptyConfig} provider={emptyConfig.provider} />); + await user.click(screen.getByRole('button', { name: /Save and enable/i })); + + // Should show an alert for empty client ID + expect(await screen.findAllByRole('alert')).toHaveLength(1); + }); + + it('should delete the current config', async () => { + const { user } = setup(<ProviderConfigForm config={emptyConfig} provider={emptyConfig.provider} />); + await user.click(screen.getByTitle(/More actions/i)); + + await user.click(screen.getByRole('menuitem', { name: /Reset to default values/i })); + + expect(screen.getByRole('dialog', { name: /Reset/i })).toBeInTheDocument(); + + await user.click(screen.getByTestId('data-testid Confirm Modal Danger Button')); + + await waitFor(() => { + expect(deleteMock).toHaveBeenCalledWith('/api/v1/sso-settings/github', undefined, { showSuccessAlert: false }); + + expect(reportInteractionMock).toHaveBeenCalledWith('grafana_authentication_ssosettings_removed', { + provider: 'github', + }); + }); + }); }); diff --git a/public/app/features/auth-config/ProviderConfigForm.tsx b/public/app/features/auth-config/ProviderConfigForm.tsx index c4fc426441592..d8b2d3b6414b0 100644 --- a/public/app/features/auth-config/ProviderConfigForm.tsx +++ b/public/app/features/auth-config/ProviderConfigForm.tsx @@ -1,17 +1,29 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { useForm } from 'react-hook-form'; import { AppEvents } from '@grafana/data'; -import { getAppEvents, getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; -import { Button, Field, Input, InputControl, LinkButton, SecretInput, Select, Stack, Switch } from '@grafana/ui'; +import { getAppEvents, getBackendSrv, isFetchError, locationService, reportInteraction } from '@grafana/runtime'; +import { + Box, + Button, + CollapsableSection, + ConfirmModal, + Dropdown, + Field, + IconButton, + LinkButton, + Menu, + Stack, + Switch, +} from '@grafana/ui'; import { FormPrompt } from '../../core/components/FormPrompt/FormPrompt'; import { Page } from '../../core/components/Page/Page'; -import { fieldMap, fields } from './fields'; -import { FieldData, SSOProvider, SSOProviderDTO } from './types'; +import { FieldRenderer } from './FieldRenderer'; +import { fields, sectionFields } from './fields'; +import { SSOProvider, SSOProviderDTO } from './types'; import { dataToDTO, dtoToData } from './utils/data'; -import { isSelectableValue } from './utils/guards'; const appEvents = getAppEvents(); @@ -29,34 +41,59 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf reset, watch, setValue, + unregister, formState: { errors, dirtyFields, isSubmitted }, - } = useForm({ defaultValues: dataToDTO(config) }); + } = useForm({ defaultValues: dataToDTO(config), mode: 'onSubmit', reValidateMode: 'onChange' }); const [isSaving, setIsSaving] = useState(false); - const [isSecretConfigured, setIsSecretConfigured] = useState(!!config?.settings.clientSecret); const providerFields = fields[provider]; const [submitError, setSubmitError] = useState(false); const dataSubmitted = isSubmitted && !submitError; + const sections = sectionFields[provider]; + const [resetConfig, setResetConfig] = useState(false); - useEffect(() => { - if (dataSubmitted) { - locationService.push(`/admin/authentication`); - } - }, [dataSubmitted]); + const additionalActionsMenu = ( + <Menu> + <Menu.Item + label="Reset to default values" + icon="history-alt" + onClick={() => { + setResetConfig(true); + }} + /> + </Menu> + ); const onSubmit = async (data: SSOProviderDTO) => { setIsSaving(true); setSubmitError(false); - const requestData = dtoToData(data); + const requestData = dtoToData(data, provider); try { - await getBackendSrv().put(`/api/v1/sso-settings/${provider}`, { - ...config, - settings: { ...config?.settings, ...requestData }, + await getBackendSrv().put( + `/api/v1/sso-settings/${provider}`, + { + id: config?.id, + provider: config?.provider, + settings: { ...requestData }, + }, + { + showErrorAlert: false, + } + ); + + reportInteraction('grafana_authentication_ssosettings_saved', { + provider, + enabled: requestData.enabled, }); appEvents.publish({ type: AppEvents.alertSuccess.name, payload: ['Settings saved'], }); + reset(data); + // Delay redirect so the form state can update + setTimeout(() => { + locationService.push(`/admin/authentication`); + }, 300); } catch (error) { let message = ''; if (isFetchError(error)) { @@ -69,133 +106,158 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf payload: [message], }); setSubmitError(true); - } finally { setIsSaving(false); } }; - const renderField = (name: keyof SSOProvider['settings'], fieldData: FieldData) => { - switch (fieldData.type) { - case 'text': - return ( - <Field - label={fieldData.label} - required={!!fieldData.validation?.required} - invalid={!!errors[name]} - error={fieldData.validation?.message} - key={name} - > - <Input - {...register(name, { required: !!fieldData.validation?.required })} - type={fieldData.type} - id={name} - autoComplete={'off'} - /> - </Field> - ); - case 'secret': - return ( - <Field - label={fieldData.label} - required={!!fieldData.validation?.required} - invalid={!!errors[name]} - error={fieldData.validation?.message} - key={name} - htmlFor={name} - > - <InputControl - name={name} - control={control} - rules={fieldData.validation} - render={({ field: { ref, value, ...field } }) => ( - <SecretInput - {...field} - autoComplete={'off'} - id={name} - value={typeof value === 'string' ? value : ''} - isConfigured={isSecretConfigured} - onReset={() => { - setIsSecretConfigured(false); - setValue(name, ''); - }} - /> - )} - /> - </Field> - ); - case 'select': - const watchOptions = watch(name); - const options = isSelectableValue(watchOptions) ? watchOptions : [{ label: '', value: '' }]; - return ( - <Field - label={fieldData.label} - htmlFor={name} - key={name} - invalid={!!errors[name]} - error={fieldData.validation?.message} - > - <InputControl - rules={fieldData.validation} - name={name} - control={control} - render={({ field: { ref, onChange, ...fieldProps }, fieldState: { invalid } }) => { - return ( - <Select - {...fieldProps} - placeholder={fieldData.placeholder} - isMulti={fieldData.multi} - invalid={invalid} - inputId={name} - options={options} - allowCustomValue - onChange={onChange} - onCreateOption={(v) => { - const customValue = { value: v, label: v }; - onChange([...options, customValue]); - }} - /> - ); - }} - /> - </Field> - ); - default: - throw new Error(`Unknown field type: ${fieldData.type}`); + const onResetConfig = async () => { + try { + await getBackendSrv().delete(`/api/v1/sso-settings/${provider}`, undefined, { showSuccessAlert: false }); + reportInteraction('grafana_authentication_ssosettings_removed', { + provider, + }); + + appEvents.publish({ + type: AppEvents.alertSuccess.name, + payload: ['Settings reset to defaults'], + }); + setTimeout(() => { + locationService.push(`/admin/authentication`); + }); + } catch (error) { + let message = ''; + if (isFetchError(error)) { + message = error.data.message; + } else if (error instanceof Error) { + message = error.message; + } + appEvents.publish({ + type: AppEvents.alertError.name, + payload: [message], + }); } }; + const isEnabled = config?.settings.enabled; + return ( <Page.Contents isLoading={isLoading}> - <Stack grow={1} direction={'column'}> - <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '600px' }}> + <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '600px' }}> + <FormPrompt + confirmRedirect={!!Object.keys(dirtyFields).length && !dataSubmitted} + onDiscard={() => { + reportInteraction('grafana_authentication_ssosettings_abandoned', { + provider, + }); + reset(); + }} + /> + <Field label="Enabled" hidden={true}> + <Switch {...register('enabled')} id="enabled" label={'Enabled'} /> + </Field> + {sections ? ( + <Stack gap={2} direction={'column'}> + {sections + .filter((section) => !section.hidden) + .map((section, index) => { + return ( + <CollapsableSection label={section.name} isOpen={index === 0} key={section.name}> + {section.fields + .filter((field) => (typeof field !== 'string' ? !field.hidden : true)) + .map((field) => { + return ( + <FieldRenderer + key={typeof field === 'string' ? field : field.name} + field={field} + control={control} + errors={errors} + setValue={setValue} + register={register} + watch={watch} + unregister={unregister} + provider={provider} + secretConfigured={!!config?.settings.clientSecret} + /> + ); + })} + </CollapsableSection> + ); + })} + </Stack> + ) : ( <> - <FormPrompt - // TODO Figure out why isDirty is not working - confirmRedirect={!!Object.keys(dirtyFields).length && !dataSubmitted} - onDiscard={() => { - reset(); - }} - /> - <Field label="Enabled"> - <Switch {...register('enabled')} id="enabled" label={'Enabled'} /> - </Field> - {providerFields.map((fieldName) => { - const field = fieldMap[fieldName]; - return renderField(fieldName, field); + {providerFields.map((field) => { + return ( + <FieldRenderer + key={field} + field={field} + control={control} + errors={errors} + setValue={setValue} + register={register} + watch={watch} + unregister={unregister} + provider={provider} + secretConfigured={!!config?.settings.clientSecret} + /> + ); })} - <Stack gap={2}> - <Field> - <Button type={'submit'}>{isSaving ? 'Saving...' : 'Save'}</Button> - </Field> - <Field> - <LinkButton href={'/admin/authentication'} variant={'secondary'}> - Discard - </LinkButton> - </Field> - </Stack> </> - </form> - </Stack> + )} + <Box display={'flex'} gap={2} marginTop={5}> + <Stack alignItems={'center'} gap={2}> + <Button + type={'submit'} + disabled={isSaving} + onClick={() => setValue('enabled', !isEnabled)} + variant={isEnabled ? 'secondary' : undefined} + > + {isSaving ? (isEnabled ? 'Disabling...' : 'Saving...') : isEnabled ? 'Disable' : 'Save and enable'} + </Button> + + <Button type={'submit'} disabled={isSaving} variant={'secondary'}> + {isSaving ? 'Saving...' : 'Save'} + </Button> + <LinkButton href={'/admin/authentication'} variant={'secondary'}> + Discard + </LinkButton> + + <Dropdown overlay={additionalActionsMenu} placement="bottom-start"> + <IconButton + tooltip="More actions" + title="More actions" + tooltipPlacement="top" + size="md" + variant="secondary" + name="ellipsis-v" + hidden={config?.source === 'system'} + /> + </Dropdown> + </Stack> + </Box> + </form> + {resetConfig && ( + <ConfirmModal + isOpen + icon="trash-alt" + title="Reset" + body={ + <Stack direction={'column'} gap={3}> + <span>Are you sure you want to reset this configuration?</span> + <small> + After resetting these settings Grafana will use the provider configuration from the system (config + file/environment variables) if any. + </small> + </Stack> + } + confirmText="Reset" + onDismiss={() => setResetConfig(false)} + onConfirm={async () => { + await onResetConfig(); + setResetConfig(false); + }} + /> + )} </Page.Contents> ); }; diff --git a/public/app/features/auth-config/ProviderConfigPage.tsx b/public/app/features/auth-config/ProviderConfigPage.tsx index 4204344123d4a..c42956c7aee7e 100644 --- a/public/app/features/auth-config/ProviderConfigPage.tsx +++ b/public/app/features/auth-config/ProviderConfigPage.tsx @@ -2,12 +2,14 @@ import React, { useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { NavModelItem } from '@grafana/data'; +import { Badge, Stack, Text } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { StoreState } from '../../types'; import { ProviderConfigForm } from './ProviderConfigForm'; +import { UIMap } from './constants'; import { loadProviders } from './state/actions'; import { SSOProvider } from './types'; @@ -21,9 +23,11 @@ const getPageNav = (config?: SSOProvider): NavModelItem => { }; } + const providerDisplayName = UIMap[config.provider][1] || config.provider.toUpperCase(); + return { - text: config.settings.name || '', - subTitle: `To configure ${config.settings.name} OAuth2 you must register your application with ${config.settings.name}. ${config.settings.name} will generate a Client ID and Client Secret for you to use.`, + text: providerDisplayName || '', + subTitle: `To configure ${providerDisplayName} OAuth2 you must register your application with ${providerDisplayName}. The provider will generate a Client ID and Client Secret for you to use.`, icon: config.settings.icon || 'shield', id: config.provider, }; @@ -63,7 +67,20 @@ export const ProviderConfigPage = ({ config, loadProviders, isLoading, provider return null; } return ( - <Page navId="authentication" pageNav={pageNav}> + <Page + navId="authentication" + pageNav={pageNav} + renderTitle={(title) => ( + <Stack gap={2} alignItems="center"> + <Text variant={'h1'}>{title}</Text> + <Badge + text={config.settings.enabled ? 'Enabled' : 'Not enabled'} + color={config.settings.enabled ? 'green' : 'blue'} + icon={config.settings.enabled ? 'check' : undefined} + /> + </Stack> + )} + > <ProviderConfigForm config={config} isLoading={isLoading} provider={provider} /> </Page> ); diff --git a/public/app/features/auth-config/components/ProviderCard.tsx b/public/app/features/auth-config/components/ProviderCard.tsx index 69e36eb291d20..e7ddee445617e 100644 --- a/public/app/features/auth-config/components/ProviderCard.tsx +++ b/public/app/features/auth-config/components/ProviderCard.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { IconName, isIconName } from '@grafana/data'; +import { isIconName } from '@grafana/data'; import { Badge, Card, Icon } from '@grafana/ui'; +import { UIMap } from '../constants'; import { getProviderUrl } from '../utils/url'; type Props = { @@ -13,17 +14,6 @@ type Props = { onClick?: () => void; }; -// TODO Remove when this is available from API -const UIMap: Record<string, [IconName, string]> = { - github: ['github', 'GitHub'], - gitlab: ['gitlab', 'GitLab'], - google: ['google', 'Google'], - generic_oauth: ['lock', 'Generic OAuth'], - grafana_com: ['grafana', 'Grafana.com'], - azuread: ['microsoft', 'Azure AD'], - okta: ['okta', 'Okta'], -}; - export function ProviderCard({ providerId, enabled, configPath, authType, onClick }: Props) { //@ts-expect-error const url = getProviderUrl({ configPath, id: providerId }); diff --git a/public/app/features/auth-config/constants.ts b/public/app/features/auth-config/constants.ts index 1bd9e7d1c6ee7..dd23c2fd8cb02 100644 --- a/public/app/features/auth-config/constants.ts +++ b/public/app/features/auth-config/constants.ts @@ -1 +1,14 @@ +import { IconName } from '@grafana/data'; + export const BASE_PATH = 'admin/authentication/'; + +// TODO Remove when this is available from API +export const UIMap: Record<string, [IconName, string]> = { + github: ['github', 'GitHub'], + gitlab: ['gitlab', 'GitLab'], + google: ['google', 'Google'], + generic_oauth: ['lock', 'Generic OAuth'], + grafana_com: ['grafana', 'Grafana.com'], + azuread: ['microsoft', 'Azure AD'], + okta: ['okta', 'Okta'], +}; diff --git a/public/app/features/auth-config/fields.ts b/public/app/features/auth-config/fields.ts deleted file mode 100644 index 22b7d0b482371..0000000000000 --- a/public/app/features/auth-config/fields.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { FieldData, SSOProvider } from './types'; - -/** Map providers to their settings */ -export const fields: Record<SSOProvider['provider'], Array<keyof SSOProvider['settings']>> = { - github: ['clientId', 'clientSecret', 'teamIds', 'allowedOrganizations'], - google: ['clientId', 'clientSecret', 'allowedDomains'], - gitlab: ['clientId', 'clientSecret', 'allowedOrganizations', 'teamIds'], - azuread: ['clientId', 'clientSecret', 'authUrl', 'tokenUrl', 'scopes', 'allowedGroups', 'allowedDomains'], - okta: [ - 'clientId', - 'clientSecret', - 'authUrl', - 'tokenUrl', - 'apiUrl', - 'roleAttributePath', - 'allowedGroups', - 'allowedDomains', - ], -}; - -/** - * List all the fields that can be used in the form - */ -export const fieldMap: Record<string, FieldData> = { - clientId: { - label: 'Client Id', - type: 'text', - validation: { - required: true, - message: 'This field is required', - }, - }, - clientSecret: { - label: 'Client Secret', - type: 'secret', - }, - teamIds: { - label: 'Team Ids', - type: 'select', - multi: true, - allowCustomValue: true, - options: [], - placeholder: 'Enter team IDs and press Enter to add', - validation: { - validate: (value) => { - if (typeof value === 'string') { - return isNumeric(value); - } - return value.every((v) => v?.value && isNumeric(v.value)); - }, - message: 'Team ID must be a number.', - }, - }, - allowedOrganizations: { - label: 'Allowed Organizations', - type: 'select', - multi: true, - allowCustomValue: true, - options: [], - placeholder: 'Enter organizations (my-team, myteam...) and press Enter to add', - }, - allowedDomains: { - label: 'Allowed Domains', - type: 'select', - multi: true, - allowCustomValue: true, - options: [], - }, - authUrl: { - label: 'Auth Url', - type: 'text', - validation: { - required: false, - }, - }, - tokenUrl: { - label: 'Token Url', - type: 'text', - validation: { - required: false, - }, - }, - scopes: { - label: 'Scopes', - type: 'select', - multi: true, - allowCustomValue: true, - options: [], - }, - allowedGroups: { - label: 'Allowed Groups', - type: 'select', - multi: true, - allowCustomValue: true, - options: [], - }, - apiUrl: { - label: 'API Url', - type: 'text', - validation: { - required: false, - }, - }, - roleAttributePath: { - label: 'Role Attribute Path', - type: 'text', - validation: { - required: false, - }, - }, -}; - -// Check if a string contains only numeric values -function isNumeric(value: string) { - return /^-?\d+$/.test(value); -} diff --git a/public/app/features/auth-config/fields.tsx b/public/app/features/auth-config/fields.tsx new file mode 100644 index 0000000000000..38c28e519c860 --- /dev/null +++ b/public/app/features/auth-config/fields.tsx @@ -0,0 +1,494 @@ +import React from 'react'; +import { validate as uuidValidate } from 'uuid'; + +import { config } from '@grafana/runtime'; +import { TextLink } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; + +import { FieldData, SSOProvider, SSOSettingsField } from './types'; +import { isSelectableValue } from './utils/guards'; +import { isUrlValid } from './utils/url'; + +/** Map providers to their settings */ +export const fields: Record<SSOProvider['provider'], Array<keyof SSOProvider['settings']>> = { + github: ['name', 'clientId', 'clientSecret', 'teamIds', 'allowedOrganizations'], + google: ['name', 'clientId', 'clientSecret', 'allowedDomains'], + gitlab: ['name', 'clientId', 'clientSecret', 'allowedOrganizations', 'teamIds'], + okta: [ + 'name', + 'clientId', + 'clientSecret', + 'authUrl', + 'tokenUrl', + 'apiUrl', + 'roleAttributePath', + 'allowedGroups', + 'allowedDomains', + ], +}; + +type Section = Record< + SSOProvider['provider'], + Array<{ + name: string; + id: string; + hidden?: boolean; + fields: SSOSettingsField[]; + }> +>; + +export const sectionFields: Section = { + azuread: [ + { + name: 'General settings', + id: 'general', + fields: [ + 'name', + 'clientId', + 'clientSecret', + 'scopes', + 'authUrl', + 'tokenUrl', + 'allowSignUp', + 'autoLogin', + 'signoutRedirectUrl', + ], + }, + { + name: 'User mapping', + id: 'user', + fields: ['roleAttributePath', 'roleAttributeStrict', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], + }, + { + name: 'Extra security measures', + id: 'extra', + fields: [ + 'allowedOrganizations', + 'allowedDomains', + 'allowedGroups', + 'forceUseGraphApi', + 'usePkce', + 'useRefreshToken', + 'tlsSkipVerifyInsecure', + 'tlsClientCert', + 'tlsClientKey', + 'tlsClientCa', + ], + }, + ], + generic_oauth: [ + { + name: 'General settings', + id: 'general', + fields: [ + 'name', + 'clientId', + 'clientSecret', + 'authStyle', + 'scopes', + 'authUrl', + 'tokenUrl', + 'apiUrl', + 'allowSignUp', + 'autoLogin', + 'signoutRedirectUrl', + ], + }, + { + name: 'User mapping', + id: 'user', + fields: [ + 'nameAttributePath', + 'loginAttributePath', + 'emailAttributeName', + 'emailAttributePath', + 'idTokenAttributeName', + 'roleAttributePath', + 'roleAttributeStrict', + 'allowAssignGrafanaAdmin', + 'skipOrgRoleSync', + ], + }, + { + name: 'Extra security measures', + id: 'extra', + fields: [ + 'allowedOrganizations', + 'allowedDomains', + 'defineAllowedGroups', + { name: 'allowedGroups', dependsOn: 'defineAllowedGroups' }, + { name: 'groupsAttributePath', dependsOn: 'defineAllowedGroups' }, + 'defineAllowedTeamsIds', + { name: 'teamIds', dependsOn: 'defineAllowedTeamsIds' }, + { name: 'teamsUrl', dependsOn: 'defineAllowedTeamsIds' }, + { name: 'teamIdsAttributePath', dependsOn: 'defineAllowedTeamsIds' }, + 'usePkce', + 'useRefreshToken', + 'tlsSkipVerifyInsecure', + 'tlsClientCert', + 'tlsClientKey', + 'tlsClientCa', + ], + }, + ], +}; + +/** + * List all the fields that can be used in the form + */ +export function fieldMap(provider: string): Record<string, FieldData> { + return { + clientId: { + label: 'Client Id', + type: 'text', + description: 'The client Id of your OAuth2 app.', + validation: { + required: true, + message: 'This field is required', + }, + }, + clientSecret: { + label: 'Client secret', + type: 'secret', + description: 'The client secret of your OAuth2 app.', + }, + allowedOrganizations: { + label: 'Allowed organizations', + type: 'select', + description: + 'List of comma- or space-separated organizations. The user should be a member \n' + + 'of at least one organization to log in.', + multi: true, + allowCustomValue: true, + options: [], + placeholder: 'Enter organizations (my-team, myteam...) and press Enter to add', + }, + allowedDomains: { + label: 'Allowed domains', + type: 'select', + description: + 'List of comma- or space-separated domains. The user should belong to at least \n' + 'one domain to log in.', + multi: true, + allowCustomValue: true, + options: [], + }, + authUrl: { + label: 'Auth URL', + type: 'text', + description: 'The authorization endpoint of your OAuth2 provider.', + validation: { + required: true, + validate: (value) => { + return isUrlValid(value); + }, + message: 'This field is required and must be a valid URL.', + }, + }, + authStyle: { + label: 'Auth style', + type: 'select', + description: 'It determines how client_id and client_secret are sent to Oauth2 provider. Default is AutoDetect.', + multi: false, + options: [ + { value: 'AutoDetect', label: 'AutoDetect' }, + { value: 'InParams', label: 'InParams' }, + { value: 'InHeader', label: 'InHeader' }, + ], + defaultValue: { value: 'AutoDetect', label: 'AutoDetect' }, + }, + tokenUrl: { + label: 'Token URL', + type: 'text', + description: 'The token endpoint of your OAuth2 provider.', + validation: { + required: true, + validate: (value) => { + return isUrlValid(value); + }, + message: 'This field is required and must be a valid URL.', + }, + }, + scopes: { + label: 'Scopes', + type: 'select', + description: 'List of comma- or space-separated OAuth2 scopes.', + multi: true, + allowCustomValue: true, + options: [], + }, + allowedGroups: { + label: 'Allowed groups', + type: 'select', + description: ( + <> + List of comma- or space-separated groups. The user should be a member of at least one group to log in.{' '} + {provider === 'generic_oauth' && + 'If you configure allowed_groups, you must also configure groups_attribute_path.'} + </> + ), + multi: true, + allowCustomValue: true, + options: [], + validation: + provider === 'azuread' + ? { + validate: (value) => { + if (typeof value === 'string') { + return uuidValidate(value); + } + if (isSelectableValue(value)) { + return value.every((v) => v?.value && uuidValidate(v.value)); + } + return true; + }, + message: 'Allowed groups must be Object Ids.', + } + : undefined, + }, + apiUrl: { + label: 'API URL', + type: 'text', + description: ( + <> + The user information endpoint of your OAuth2 provider. Information returned by this endpoint must be + compatible with{' '} + <TextLink href={'https://connect2id.com/products/server/docs/api/userinfo'} external variant={'bodySmall'}> + OpenID UserInfo + </TextLink> + . + </> + ), + validation: { + required: false, + validate: (value) => { + if (typeof value !== 'string') { + return false; + } + + if (value.length) { + return isUrlValid(value); + } + + return true; + }, + message: 'This field must be a valid URL if set.', + }, + }, + roleAttributePath: { + label: 'Role attribute path', + description: 'JMESPath expression to use for Grafana role lookup.', + type: 'text', + validation: { + required: false, + }, + }, + name: { + label: 'Display name', + description: + 'Will be displayed on the login page as "Sign in with ...". Helpful if you use more than one identity providers or SSO protocols.', + type: 'text', + }, + allowSignUp: { + label: 'Allow sign up', + description: 'If not enabled, only existing Grafana users can log in using OAuth.', + type: 'switch', + }, + autoLogin: { + label: 'Auto login', + description: 'Log in automatically, skipping the login screen.', + type: 'switch', + }, + signoutRedirectUrl: { + label: 'Sign out redirect URL', + description: 'The URL to redirect the user to after signing out from Grafana.', + type: 'text', + validation: { + required: false, + }, + }, + emailAttributeName: { + label: 'Email attribute name', + description: 'Name of the key to use for user email lookup within the attributes map of OAuth2 ID token.', + type: 'text', + }, + emailAttributePath: { + label: 'Email attribute path', + description: 'JMESPath expression to use for user email lookup from the user information.', + type: 'text', + }, + nameAttributePath: { + label: 'Name attribute path', + description: + 'JMESPath expression to use for user name lookup from the user ID token. \n' + + 'This name will be used as the user’s display name.', + type: 'text', + }, + loginAttributePath: { + label: 'Login attribute path', + description: 'JMESPath expression to use for user login lookup from the user ID token.', + type: 'text', + }, + idTokenAttributeName: { + label: 'ID token attribute name', + description: 'The name of the key used to extract the ID token from the returned OAuth2 token.', + type: 'text', + }, + roleAttributeStrict: { + label: 'Role attribute strict mode', + description: 'If enabled, denies user login if the Grafana role cannot be extracted using Role attribute path.', + type: 'switch', + }, + allowAssignGrafanaAdmin: { + label: 'Allow assign Grafana admin', + description: 'If enabled, it will automatically sync the Grafana server administrator role.', + type: 'switch', + hidden: !contextSrv.isGrafanaAdmin, + }, + skipOrgRoleSync: { + label: 'Skip organization role sync', + description: 'Prevent synchronizing users’ organization roles from your IdP.', + type: 'switch', + }, + defineAllowedGroups: { + label: 'Define allowed groups', + type: 'switch', + }, + defineAllowedTeamsIds: { + label: 'Define allowed teams ids', + type: 'switch', + }, + forceUseGraphApi: { + label: 'Force use Graph API', + description: "If enabled, Grafana will fetch the users' groups using the Microsoft Graph API.", + type: 'checkbox', + }, + usePkce: { + label: 'Use PKCE', + description: ( + <> + If enabled, Grafana will use{' '} + <TextLink external variant={'bodySmall'} href={'https://datatracker.ietf.org/doc/html/rfc7636'}> + Proof Key for Code Exchange (PKCE) + </TextLink>{' '} + with the OAuth2 Authorization Code Grant. + </> + ), + type: 'checkbox', + }, + useRefreshToken: { + label: 'Use refresh token', + description: + 'If enabled, Grafana will fetch a new access token using the refresh token provided by the OAuth2 provider.', + type: 'checkbox', + }, + tlsClientCa: { + label: 'TLS client ca', + description: 'The file path to the trusted certificate authority list. Is not applicable on Grafana Cloud.', + type: 'text', + hidden: !config.localFileSystemAvailable, + }, + tlsClientCert: { + label: 'TLS client cert', + description: 'The file path to the certificate. Is not applicable on Grafana Cloud.', + type: 'text', + hidden: !config.localFileSystemAvailable, + }, + tlsClientKey: { + label: 'TLS client key', + description: 'The file path to the key. Is not applicable on Grafana Cloud.', + type: 'text', + hidden: !config.localFileSystemAvailable, + }, + tlsSkipVerifyInsecure: { + label: 'TLS skip verify', + description: + 'If enabled, the client accepts any certificate presented by the server and any host \n' + + 'name in that certificate. You should only use this for testing, because this mode leaves \n' + + 'SSL/TLS susceptible to man-in-the-middle attacks.', + type: 'switch', + }, + groupsAttributePath: { + label: 'Groups attribute path', + description: + 'JMESPath expression to use for user group lookup. If you configure allowed_groups, \n' + + 'you must also configure groups_attribute_path.', + type: 'text', + }, + teamsUrl: { + label: 'Teams URL', + description: ( + <> + The URL used to query for Team Ids. If not set, the default value is /teams.{' '} + {provider === 'generic_oauth' && + 'If you configure teams_url, you must also configure team_ids_attribute_path.'} + </> + ), + type: 'text', + validation: { + validate: (value, formValues) => { + let result = true; + if (formValues.teamIds.length) { + result = !!value; + } + + if (typeof value === 'string' && value.length) { + result = isUrlValid(value); + } + return result; + }, + message: 'This field must be set if Team Ids are configured and must be a valid URL.', + }, + }, + teamIdsAttributePath: { + label: 'Team Ids attribute path', + description: + 'The JMESPath expression to use for Grafana Team Id lookup within the results returned by the teams_url endpoint.', + type: 'text', + validation: { + validate: (value, formValues) => { + if (formValues.teamIds.length) { + return !!value; + } + return true; + }, + message: 'This field must be set if Team Ids are configured.', + }, + }, + teamIds: { + label: 'Team Ids', + type: 'select', + description: ( + <> + {provider === 'github' ? 'Integer' : 'String'} list of Team Ids. If set, the user must be a member of one of + the given teams to log in.{' '} + {provider === 'generic_oauth' && + 'If you configure team_ids, you must also configure teams_url and team_ids_attribute_path.'} + </> + ), + multi: true, + allowCustomValue: true, + options: [], + placeholder: 'Enter Team Ids and press Enter to add', + validation: + provider === 'github' + ? { + validate: (value) => { + if (typeof value === 'string') { + return isNumeric(value); + } + if (isSelectableValue(value)) { + return value.every((v) => v?.value && isNumeric(v.value)); + } + return true; + }, + message: 'Team Ids must be numbers.', + } + : undefined, + }, + }; +} + +// Check if a string contains only numeric values +function isNumeric(value: string) { + return /^-?\d+$/.test(value); +} diff --git a/public/app/features/auth-config/types.ts b/public/app/features/auth-config/types.ts index c4b5e3dbc19de..02d5d1fe3ba05 100644 --- a/public/app/features/auth-config/types.ts +++ b/public/app/features/auth-config/types.ts @@ -1,6 +1,8 @@ +import { ReactElement } from 'react'; +import { Validate } from 'react-hook-form'; + import { IconName, SelectableValue } from '@grafana/data'; import { Settings } from 'app/types'; - export interface AuthProviderInfo { id: string; type: string; @@ -15,7 +17,6 @@ export type GetStatusHook = () => Promise<AuthProviderStatus>; export type SSOProviderSettingsBase = { allowAssignGrafanaAdmin?: boolean; allowSignUp?: boolean; - apiUrl?: string; authStyle?: string; authUrl?: string; @@ -26,7 +27,7 @@ export type SSOProviderSettingsBase = { emailAttributePath?: string; emptyScopes?: boolean; enabled: boolean; - extra?: Record<string, unknown>; + extra?: Record<string, string>; groupsAttributePath?: string; hostedDomain?: string; icon?: IconName; @@ -43,13 +44,24 @@ export type SSOProviderSettingsBase = { tlsSkipVerify?: boolean; tokenUrl?: string; type: string; - usePKCE?: boolean; + usePkce?: boolean; useRefreshToken?: boolean; + nameAttributePath?: string; + loginAttributePath?: string; + idTokenAttributeName?: string; + defineAllowedGroups?: boolean; + defineAllowedTeamsIds?: boolean; + configureTLS?: boolean; + tlsSkipVerifyInsecure?: boolean; + // For Azure AD + forceUseGraphApi?: boolean; }; // SSO data received from the API and sent to it export type SSOProvider = { + id: string; provider: string; + source: string; settings: SSOProviderSettingsBase & { teamIds: string; allowedOrganizations: string; @@ -94,13 +106,20 @@ export interface SettingsError { export type FieldData = { label: string; type: string; + description?: string | ReactElement; validation?: { required?: boolean; message?: string; - validate?: (value: string | Array<SelectableValue<string>>) => boolean | string | Promise<boolean | string>; + validate?: Validate<SSOProviderDTO[keyof SSOProviderDTO], SSOProviderDTO>; }; multi?: boolean; allowCustomValue?: boolean; options?: Array<SelectableValue<string>>; placeholder?: string; + defaultValue?: SelectableValue<string>; + hidden?: boolean; }; + +export type SSOSettingsField = + | keyof SSOProvider['settings'] + | { name: keyof SSOProvider['settings']; dependsOn: keyof SSOProvider['settings']; hidden?: boolean }; diff --git a/public/app/features/auth-config/utils/data.ts b/public/app/features/auth-config/utils/data.ts index bee1572b0f3d5..d269dcc0ae880 100644 --- a/public/app/features/auth-config/utils/data.ts +++ b/public/app/features/auth-config/utils/data.ts @@ -1,6 +1,6 @@ import { SelectableValue } from '@grafana/data'; -import { fieldMap } from '../fields'; +import { fieldMap, fields } from '../fields'; import { FieldData, SSOProvider, SSOProviderDTO } from '../types'; import { isSelectableValue } from './guards'; @@ -40,7 +40,7 @@ export const emptySettings: SSOProviderDTO = { tlsSkipVerify: false, tokenUrl: '', type: '', - usePKCE: false, + usePkce: false, useRefreshToken: false, }; @@ -58,7 +58,7 @@ export function dataToDTO(data?: SSOProvider): SSOProviderDTO { if (!data) { return emptySettings; } - const arrayFields = getArrayFields(fieldMap); + const arrayFields = getArrayFields(fieldMap(data.provider)); const settings = { ...data.settings }; for (const field of arrayFields) { //@ts-expect-error @@ -72,16 +72,41 @@ const valuesToString = (values: Array<SelectableValue<string>>) => { return values.map(({ value }) => value).join(','); }; +const includeRequiredKeysOnly = ( + obj: SSOProviderDTO, + requiredKeys: Array<keyof SSOProvider['settings']> +): Partial<SSOProviderDTO> => { + if (!requiredKeys) { + return obj; + } + let result: Partial<SSOProviderDTO> = {}; + for (const key of requiredKeys) { + //@ts-expect-error + result[key] = obj[key]; + } + return result; +}; + // Convert the DTO to the data format used by the API -export function dtoToData(dto: SSOProviderDTO) { - const arrayFields = getArrayFields(fieldMap); - const settings = { ...dto }; +export function dtoToData(dto: SSOProviderDTO, provider: string) { + const arrayFields = getArrayFields(fieldMap(provider)); + let current: Partial<SSOProviderDTO> = dto; + + if (fields[provider]) { + current = includeRequiredKeysOnly(dto, [...fields[provider], 'enabled']); + } + const settings = { ...current }; for (const field of arrayFields) { - const value = dto[field]; - if (value && isSelectableValue(value)) { - //@ts-expect-error - settings[field] = valuesToString(value); + const value = current[field]; + if (value) { + if (isSelectableValue(value)) { + //@ts-expect-error + settings[field] = valuesToString(value); + } else if (isSelectableValue([value])) { + //@ts-expect-error + settings[field] = value.value; + } } } return settings; @@ -89,6 +114,6 @@ export function dtoToData(dto: SSOProviderDTO) { export function getArrayFields(obj: Record<string, FieldData>): Array<keyof SSOProviderDTO> { return Object.entries(obj) - .filter(([_, value]) => value.type === 'select' && value.multi === true) + .filter(([_, value]) => value.type === 'select') .map(([key]) => key as keyof SSOProviderDTO); } diff --git a/public/app/features/auth-config/utils/url.ts b/public/app/features/auth-config/utils/url.ts index bb5c435d5446f..cf98cf311c20e 100644 --- a/public/app/features/auth-config/utils/url.ts +++ b/public/app/features/auth-config/utils/url.ts @@ -2,5 +2,17 @@ import { BASE_PATH } from '../constants'; import { AuthProviderInfo } from '../types'; export function getProviderUrl(provider: AuthProviderInfo) { - return BASE_PATH + (provider.configPath || `advanced/${provider.id}`); + return BASE_PATH + (provider.configPath || provider.id); } + +export const isUrlValid = (url: unknown): boolean => { + if (typeof url !== 'string') { + return false; + } + try { + const parsedUrl = new URL(url); + return parsedUrl.protocol.includes('http'); + } catch (_) { + return false; + } +}; diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx index 1d3598c1e9c00..ab005ac3d4811 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx @@ -1,7 +1,7 @@ -import 'whatwg-fetch'; // fetch polyfill +import 'whatwg-fetch'; import { render as rtlRender, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { setupServer, SetupServer } from 'msw/node'; import React, { ComponentProps } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; @@ -30,7 +30,16 @@ jest.mock('react-virtualized-auto-sizer', () => { return { __esModule: true, default(props: ComponentProps<typeof AutoSizer>) { - return <div>{props.children({ width: 800, height: 600 })}</div>; + return ( + <div> + {props.children({ + width: 800, + scaledWidth: 800, + scaledHeight: 600, + height: 600, + })} + </div> + ); }, }; }); @@ -111,25 +120,19 @@ describe('browse-dashboards BrowseDashboardsPage', () => { beforeAll(() => { server = setupServer( - rest.get('/api/folders/:uid', (_, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - title: folderA.item.title, - uid: folderA.item.uid, - }) - ); + http.get('/api/folders/:uid', () => { + return HttpResponse.json({ + title: folderA.item.title, + uid: folderA.item.uid, + }); }), - rest.get('/api/search', (_, res, ctx) => { - return res(ctx.status(200), ctx.json({})); + http.get('/api/search', () => { + return HttpResponse.json({}); }), - rest.get('/api/search/sorting', (_, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - sortOptions: [], - }) - ); + http.get('/api/search/sorting', () => { + return HttpResponse.json({ + sortOptions: [], + }); }) ); server.listen(); diff --git a/public/app/features/browse-dashboards/BrowseFolderAlertingPage.test.tsx b/public/app/features/browse-dashboards/BrowseFolderAlertingPage.test.tsx index e7f01c2a7e1b5..9c101f9bb5485 100644 --- a/public/app/features/browse-dashboards/BrowseFolderAlertingPage.test.tsx +++ b/public/app/features/browse-dashboards/BrowseFolderAlertingPage.test.tsx @@ -1,6 +1,6 @@ -import 'whatwg-fetch'; // fetch polyfill +import 'whatwg-fetch'; import { render as rtlRender, screen } from '@testing-library/react'; -import { rest } from 'msw'; +import { http, HttpResponse } from 'msw'; import { SetupServer, setupServer } from 'msw/node'; import React from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; @@ -47,20 +47,17 @@ describe('browse-dashboards BrowseFolderAlertingPage', () => { beforeAll(() => { server = setupServer( - rest.get('/api/folders/:uid', (_, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - title: mockFolderName, - uid: mockFolderUid, - }) - ); + http.get('/api/folders/:uid', () => { + return HttpResponse.json({ + title: mockFolderName, + uid: mockFolderUid, + }); }), - rest.get('api/ruler/grafana/api/v1/rules', (_, res, ctx) => { - return res(ctx.status(200), ctx.json(mockRulerRulesResponse)); + http.get('api/ruler/grafana/api/v1/rules', () => { + return HttpResponse.json(mockRulerRulesResponse); }), - rest.get('api/prometheus/grafana/api/v1/rules', (_, res, ctx) => { - return res(ctx.status(200), ctx.json(mockPrometheusRulesResponse)); + http.get('api/prometheus/grafana/api/v1/rules', () => { + return HttpResponse.json(mockPrometheusRulesResponse); }) ); server.listen(); diff --git a/public/app/features/browse-dashboards/BrowseFolderLibraryPanelsPage.test.tsx b/public/app/features/browse-dashboards/BrowseFolderLibraryPanelsPage.test.tsx index b188fd20a93b6..1c6632d580bf2 100644 --- a/public/app/features/browse-dashboards/BrowseFolderLibraryPanelsPage.test.tsx +++ b/public/app/features/browse-dashboards/BrowseFolderLibraryPanelsPage.test.tsx @@ -1,6 +1,6 @@ -import 'whatwg-fetch'; // fetch polyfill +import 'whatwg-fetch'; import { render as rtlRender, screen } from '@testing-library/react'; -import { rest } from 'msw'; +import { http, HttpResponse } from 'msw'; import { SetupServer, setupServer } from 'msw/node'; import React from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; @@ -47,25 +47,19 @@ describe('browse-dashboards BrowseFolderLibraryPanelsPage', () => { beforeAll(() => { server = setupServer( - rest.get('/api/folders/:uid', (_, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - title: mockFolderName, - uid: mockFolderUid, - }) - ); + http.get('/api/folders/:uid', () => { + return HttpResponse.json({ + title: mockFolderName, + uid: mockFolderUid, + }); }), - rest.get('/api/library-elements', (_, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - result: mockLibraryElementsResponse, - }) - ); + http.get('/api/library-elements', () => { + return HttpResponse.json({ + result: mockLibraryElementsResponse, + }); }), - rest.get('/api/search/sorting', (_, res, ctx) => { - return res(ctx.status(200), ctx.json({})); + http.get('/api/search/sorting', () => { + return HttpResponse.json({}); }) ); server.listen(); diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index fc68ca8756545..4e60a5c2a4e23 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -3,12 +3,21 @@ import { lastValueFrom } from 'rxjs'; import { isTruthy, locationUtil } from '@grafana/data'; import { BackendSrvRequest, getBackendSrv, locationService } from '@grafana/runtime'; +import { Dashboard } from '@grafana/schema'; import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; import { contextSrv } from 'app/core/core'; import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; -import { DashboardDTO, DescendantCount, DescendantCountDTO, FolderDTO, SaveDashboardResponseDTO } from 'app/types'; +import { + DashboardDTO, + DescendantCount, + DescendantCountDTO, + FolderDTO, + FolderListItemDTO, + ImportDashboardResponseDTO, + SaveDashboardResponseDTO, +} from 'app/types'; import { refetchChildren, refreshParents } from '../state'; import { DashboardTreeSelection } from '../types'; @@ -28,6 +37,20 @@ interface MoveItemsArgs extends DeleteItemsArgs { destinationUID: string; } +export interface ImportInputs { + name: string; + type: string; + value: string; + pluginId?: string; +} + +interface ImportOptions { + dashboard: Dashboard; + overwrite: boolean; + inputs: ImportInputs[]; + folderUid: string; +} + function createBackendSrvBaseQuery({ baseURL }: { baseURL: string }): BaseQueryFn<RequestOptions> { async function backendSrvBaseQuery(requestOptions: RequestOptions) { try { @@ -47,16 +70,28 @@ function createBackendSrvBaseQuery({ baseURL }: { baseURL: string }): BaseQueryF return backendSrvBaseQuery; } +export interface ListFolderQueryArgs { + page: number; + parentUid: string | undefined; + limit: number; +} + export const browseDashboardsAPI = createApi({ tagTypes: ['getFolder'], reducerPath: 'browseDashboardsAPI', baseQuery: createBackendSrvBaseQuery({ baseURL: '/api' }), endpoints: (builder) => ({ + listFolders: builder.query<FolderListItemDTO[], ListFolderQueryArgs>({ + providesTags: (result) => result?.map((folder) => ({ type: 'getFolder', id: folder.uid })) ?? [], + query: ({ page, parentUid, limit }) => ({ url: '/folders', params: { page, parentUid, limit } }), + }), + // get folder info (e.g. title, parents) but *not* children getFolder: builder.query<FolderDTO, string>({ providesTags: (_result, _error, folderUID) => [{ type: 'getFolder', id: folderUID }], query: (folderUID) => ({ url: `/folders/${folderUID}`, params: { accesscontrol: true } }), }), + // create a new folder newFolder: builder.mutation<FolderDTO, { title: string; parentUid?: string }>({ query: ({ title, parentUid }) => ({ @@ -81,6 +116,7 @@ export const browseDashboardsAPI = createApi({ }); }, }), + // save an existing folder (e.g. rename) saveFolder: builder.mutation<FolderDTO, FolderDTO>({ // because the getFolder calls contain the parents, renaming a parent/grandparent/etc needs to invalidate all child folders @@ -273,9 +309,10 @@ export const browseDashboardsAPI = createApi({ }), // save an existing dashboard saveDashboard: builder.mutation<SaveDashboardResponseDTO, SaveDashboardCommand>({ - query: ({ dashboard, folderUid, message, overwrite }) => ({ + query: ({ dashboard, folderUid, message, overwrite, showErrorAlert }) => ({ url: `/dashboards/db`, method: 'POST', + showErrorAlert, data: { dashboard, folderUid, @@ -296,6 +333,30 @@ export const browseDashboardsAPI = createApi({ }); }, }), + importDashboard: builder.mutation<ImportDashboardResponseDTO, ImportOptions>({ + query: ({ dashboard, overwrite, inputs, folderUid }) => ({ + method: 'POST', + url: '/dashboards/import', + data: { + dashboard, + overwrite, + inputs, + folderUid, + }, + }), + onQueryStarted: ({ folderUid }, { queryFulfilled, dispatch }) => { + queryFulfilled.then(async (response) => { + dispatch( + refetchChildren({ + parentUID: folderUid, + pageSize: PAGE_SIZE, + }) + ); + const dashboardUrl = locationUtil.stripBaseFromUrl(response.data.importedUrl); + locationService.push(dashboardUrl); + }); + }, + }), }), }); @@ -311,4 +372,5 @@ export const { useSaveDashboardMutation, useSaveFolderMutation, } = browseDashboardsAPI; + export { skipToken } from '@reduxjs/toolkit/query/react'; diff --git a/public/app/features/browse-dashboards/api/services.ts b/public/app/features/browse-dashboards/api/services.ts index 468392e965ffc..f4318793358c8 100644 --- a/public/app/features/browse-dashboards/api/services.ts +++ b/public/app/features/browse-dashboards/api/services.ts @@ -6,6 +6,7 @@ import { DashboardViewItem } from 'app/features/search/types'; import { contextSrv } from '../../../core/core'; import { AccessControlAction } from '../../../types'; +import { isSharedWithMe } from '../components/utils'; export const PAGE_SIZE = 50; @@ -36,7 +37,7 @@ export async function listFolders( title: item.title, parentTitle, parentUID, - url: `/dashboards/f/${item.uid}/`, + url: isSharedWithMe(item.uid) ? undefined : `/dashboards/f/${item.uid}/`, })); } diff --git a/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.tsx b/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.tsx index 6a4a1604712a1..a45d644693400 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.tsx @@ -1,8 +1,7 @@ import React, { useState } from 'react'; -import { Space } from '@grafana/experimental'; import { config } from '@grafana/runtime'; -import { Alert, ConfirmModal, Text } from '@grafana/ui'; +import { Alert, ConfirmModal, Text, Space } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import { useGetAffectedItemsQuery } from '../../api/browseDashboardsAPI'; diff --git a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx index 182e712f1feae..0691ad442062d 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; +import { selectors } from '@grafana/e2e-selectors'; import { setBackendSrv } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/__mocks__/backend_srv'; import * as api from 'app/features/manage-dashboards/state/actions'; @@ -70,7 +71,7 @@ describe('browse-dashboards MoveModal', () => { it('displays a folder picker', async () => { render(<MoveModal {...props} />); - expect(await screen.findByRole('combobox', { name: 'Select a folder' })).toBeInTheDocument(); + expect(await screen.findByTestId(selectors.components.FolderPicker.input)).toBeInTheDocument(); }); it('displays a warning about permissions if a folder is selected', async () => { @@ -88,7 +89,7 @@ describe('browse-dashboards MoveModal', () => { render(<MoveModal {...props} />); expect(await screen.findByRole('button', { name: 'Move' })).toBeDisabled(); - const folderPicker = await screen.findByRole('combobox', { name: 'Select a folder' }); + const folderPicker = await screen.findByTestId(selectors.components.FolderPicker.input); await selectOptionInTest(folderPicker, mockFolders[1].title); expect(await screen.findByRole('button', { name: 'Move' })).toBeEnabled(); @@ -96,7 +97,7 @@ describe('browse-dashboards MoveModal', () => { it('calls onConfirm when clicking the `Move` button', async () => { render(<MoveModal {...props} />); - const folderPicker = await screen.findByRole('combobox', { name: 'Select a folder' }); + const folderPicker = await screen.findByTestId(selectors.components.FolderPicker.input); await selectOptionInTest(folderPicker, mockFolders[1].title); await userEvent.click(await screen.findByRole('button', { name: 'Move' })); diff --git a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx index f36cbfd5bc515..366d3c3208c1b 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx @@ -1,7 +1,6 @@ import React, { useState } from 'react'; -import { Space } from '@grafana/experimental'; -import { Alert, Button, Field, Modal, Text } from '@grafana/ui'; +import { Alert, Button, Field, Modal, Text, Space } from '@grafana/ui'; import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { t, Trans } from 'app/core/internationalization'; diff --git a/public/app/features/browse-dashboards/components/CheckboxCell.tsx b/public/app/features/browse-dashboards/components/CheckboxCell.tsx index 83f84c9e039ae..fb8ffebfe20e1 100644 --- a/public/app/features/browse-dashboards/components/CheckboxCell.tsx +++ b/public/app/features/browse-dashboards/components/CheckboxCell.tsx @@ -8,26 +8,31 @@ import { t } from 'app/core/internationalization'; import { DashboardsTreeCellProps, SelectionState } from '../types'; +import { isSharedWithMe } from './utils'; + export default function CheckboxCell({ row: { original: row }, isSelected, onItemSelectionChange, }: DashboardsTreeCellProps) { - const styles = useStyles2(getStyles); const item = row.item; if (!isSelected) { - return <span className={styles.checkboxSpacer} />; + return <CheckboxSpacer />; } if (item.kind === 'ui') { if (item.uiKind === 'pagination-placeholder') { return <Checkbox disabled value={false} />; } else { - return <span className={styles.checkboxSpacer} />; + return <CheckboxSpacer />; } } + if (isSharedWithMe(item.uid)) { + return <CheckboxSpacer />; + } + const state = isSelected(item); return ( @@ -41,6 +46,11 @@ export default function CheckboxCell({ ); } +function CheckboxSpacer() { + const styles = useStyles2(getStyles); + return <span className={styles.checkboxSpacer} />; +} + const getStyles = (theme: GrafanaTheme2) => ({ // Should be the same size as the <IconButton /> so Dashboard name is aligned to Folder name siblings checkboxSpacer: css({ diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx index 8a58cd9c4218c..caa039829bd91 100644 --- a/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx +++ b/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx @@ -5,8 +5,14 @@ import { TestProvider } from 'test/helpers/TestProvider'; import { assertIsDefined } from 'test/helpers/asserts'; import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; -import { wellFormedDashboard, wellFormedEmptyFolder, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture'; +import { + sharedWithMeFolder, + wellFormedDashboard, + wellFormedEmptyFolder, + wellFormedFolder, +} from '../fixtures/dashboardsTreeItem.fixture'; import { SelectionState } from '../types'; import { DashboardsTree } from './DashboardsTree'; @@ -27,6 +33,10 @@ describe('browse-dashboards DashboardsTree', () => { const allItemsAreLoaded = () => true; const requestLoadMore = () => Promise.resolve(); + beforeAll(() => { + config.sharedWithMeFolderUID = 'sharedwithme'; + }); + it('renders a dashboard item', () => { render( <DashboardsTree @@ -82,9 +92,73 @@ describe('browse-dashboards DashboardsTree', () => { requestLoadMore={requestLoadMore} /> ); + expect(screen.queryByText(folder.item.title)).toBeInTheDocument(); }); + it('renders a folder link', () => { + render( + <DashboardsTree + canSelect + items={[folder]} + isSelected={isSelected} + width={WIDTH} + height={HEIGHT} + onFolderClick={noop} + onItemSelectionChange={noop} + onAllSelectionChange={noop} + isItemLoaded={allItemsAreLoaded} + requestLoadMore={requestLoadMore} + /> + ); + + expect(screen.queryByText(folder.item.title)).toHaveAttribute('href', folder.item.url); + }); + + it("doesn't link to the sharedwithme pseudo-folder", () => { + const sharedWithMe = sharedWithMeFolder(2); + + render( + <DashboardsTree + canSelect + items={[sharedWithMe, folder]} + isSelected={isSelected} + width={WIDTH} + height={HEIGHT} + onFolderClick={noop} + onItemSelectionChange={noop} + onAllSelectionChange={noop} + isItemLoaded={allItemsAreLoaded} + requestLoadMore={requestLoadMore} + /> + ); + + expect(screen.queryByText(sharedWithMe.item.title)).not.toHaveAttribute('href'); + }); + + it("doesn't render a checkbox for the sharedwithme pseudo-folder", () => { + const sharedWithMe = sharedWithMeFolder(2); + + render( + <DashboardsTree + canSelect + items={[sharedWithMe, folder]} + isSelected={isSelected} + width={WIDTH} + height={HEIGHT} + onFolderClick={noop} + onItemSelectionChange={noop} + onAllSelectionChange={noop} + isItemLoaded={allItemsAreLoaded} + requestLoadMore={requestLoadMore} + /> + ); + + expect( + screen.queryByTestId(selectors.pages.BrowseDashboards.table.checkbox(sharedWithMe.item.uid)) + ).not.toBeInTheDocument(); + }); + it('calls onFolderClick when a folder button is clicked', async () => { const handler = jest.fn(); render( diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.tsx index be3b5bee1c766..94d16c5ba77ba 100644 --- a/public/app/features/browse-dashboards/components/DashboardsTree.tsx +++ b/public/app/features/browse-dashboards/components/DashboardsTree.tsx @@ -1,7 +1,7 @@ import { css, cx } from '@emotion/css'; import React, { useCallback, useEffect, useId, useMemo, useRef } from 'react'; import { TableInstance, useTable } from 'react-table'; -import { FixedSizeList as List } from 'react-window'; +import { VariableSizeList as List } from 'react-window'; import InfiniteLoader from 'react-window-infinite-loader'; import { GrafanaTheme2, isTruthy } from '@grafana/data'; @@ -35,6 +35,7 @@ interface DashboardsTreeProps { const HEADER_HEIGHT = 36; const ROW_HEIGHT = 36; +const DIVIDER_HEIGHT = 0; // Yes - make it appear as a border on the row rather than a row itself export function DashboardsTree({ items, @@ -51,6 +52,7 @@ export function DashboardsTree({ const treeID = useId(); const infiniteLoaderRef = useRef<InfiniteLoader>(null); + const listRef = useRef<List | null>(null); const styles = useStyles2(getStyles); useEffect(() => { @@ -60,6 +62,10 @@ export function DashboardsTree({ if (infiniteLoaderRef.current) { infiniteLoaderRef.current.resetloadMoreItemsCache(true); } + + if (listRef.current) { + listRef.current.resetAfterIndex(0); + } }, [items]); const tableColumns = useMemo(() => { @@ -123,6 +129,18 @@ export function DashboardsTree({ [requestLoadMore, items] ); + const getRowHeight = useCallback( + (rowIndex: number) => { + const row = items[rowIndex]; + if (row.item.kind === 'ui' && row.item.uiKind === 'divider') { + return DIVIDER_HEIGHT; + } + + return ROW_HEIGHT; + }, + [items] + ); + return ( <div {...getTableProps()} role="table"> {headerGroups.map((headerGroup) => { @@ -154,12 +172,16 @@ export function DashboardsTree({ > {({ onItemsRendered, ref }) => ( <List - ref={ref} + ref={(elem) => { + ref(elem); + listRef.current = elem; + }} height={height - HEADER_HEIGHT} width={width} itemCount={items.length} itemData={virtualData} - itemSize={ROW_HEIGHT} + estimatedItemSize={ROW_HEIGHT} + itemSize={getRowHeight} onItemsRendered={onItemsRendered} > {VirtualListRow} @@ -191,13 +213,23 @@ function VirtualListRow({ index, style, data }: VirtualListRowProps) { const row = rows[index]; prepareRow(row); + const dashboardItem = row.original.item; + + if (dashboardItem.kind === 'ui' && dashboardItem.uiKind === 'divider') { + return ( + <div {...row.getRowProps({ style })}> + <hr className={styles.divider} /> + </div> + ); + } + return ( <div {...row.getRowProps({ style })} className={cx(styles.row, styles.bodyRow)} - aria-labelledby={makeRowID(treeID, row.original.item)} + aria-labelledby={makeRowID(treeID, dashboardItem)} data-testid={selectors.pages.BrowseDashboards.table.row( - 'title' in row.original.item ? row.original.item.title : row.original.item.uid + 'title' in dashboardItem ? dashboardItem.title : dashboardItem.uid )} > {row.cells.map((cell) => { @@ -221,6 +253,12 @@ const getStyles = (theme: GrafanaTheme2) => { gap: theme.spacing(1), }), + divider: css({ + borderTop: `1px solid ${theme.colors.border.weak}`, + width: '100%', + margin: 0, + }), + headerRow: css({ backgroundColor: theme.colors.background.secondary, height: HEADER_HEIGHT, diff --git a/public/app/features/browse-dashboards/components/NameCell.tsx b/public/app/features/browse-dashboards/components/NameCell.tsx index 11bbd5af23194..086d3a4d5cc17 100644 --- a/public/app/features/browse-dashboards/components/NameCell.tsx +++ b/public/app/features/browse-dashboards/components/NameCell.tsx @@ -7,7 +7,7 @@ import { reportInteraction } from '@grafana/runtime'; import { Icon, IconButton, Link, Spinner, useStyles2, Text } from '@grafana/ui'; import { getSvgSize } from '@grafana/ui/src/components/Icon/utils'; import { t } from 'app/core/internationalization'; -import { getIconForKind } from 'app/features/search/service/utils'; +import { getIconForItem } from 'app/features/search/service/utils'; import { Indent } from '../../../core/components/Indent/Indent'; import { useChildrenByParentUIDState } from '../state'; @@ -27,7 +27,7 @@ export function NameCell({ row: { original: data }, onFolderClick, treeID }: Nam const { item, level, isOpen } = data; const childrenByParentUID = useChildrenByParentUIDState(); const isLoading = isOpen && !childrenByParentUID[item.uid]; - const iconName = getIconForKind(data.item.kind, isOpen); + const iconName = getIconForItem(data.item, isOpen); if (item.kind === 'ui') { return ( diff --git a/public/app/features/browse-dashboards/components/NewFolderForm.tsx b/public/app/features/browse-dashboards/components/NewFolderForm.tsx index d201f8a4e0b58..a31594be7f351 100644 --- a/public/app/features/browse-dashboards/components/NewFolderForm.tsx +++ b/public/app/features/browse-dashboards/components/NewFolderForm.tsx @@ -1,7 +1,8 @@ import React from 'react'; +import { useForm } from 'react-hook-form'; import { selectors } from '@grafana/e2e-selectors'; -import { Button, Input, Form, Field, HorizontalGroup } from '@grafana/ui'; +import { Button, Input, Field, Stack } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import { validationSrv } from '../../manage-dashboards/services/ValidationSrv'; @@ -18,6 +19,12 @@ interface FormModel { const initialFormModel: FormModel = { folderName: '' }; export function NewFolderForm({ onCancel, onConfirm }: Props) { + const { + handleSubmit, + register, + formState: { errors }, + } = useForm<FormModel>({ defaultValues: initialFormModel }); + const translatedFolderNameRequiredPhrase = t( 'browse-dashboards.action.new-folder-name-required-phrase', 'Folder name is required.' @@ -38,37 +45,34 @@ export function NewFolderForm({ onCancel, onConfirm }: Props) { const fieldNameLabel = t('browse-dashboards.new-folder-form.name-label', 'Folder name'); return ( - <Form - defaultValues={initialFormModel} - onSubmit={(form: FormModel) => onConfirm(form.folderName)} + <form + name="addFolder" + onSubmit={handleSubmit((form) => onConfirm(form.folderName))} data-testid={selectors.pages.BrowseDashboards.NewFolderForm.form} > - {({ register, errors }) => ( - <> - <Field - label={fieldNameLabel} - invalid={!!errors.folderName} - error={errors.folderName && errors.folderName.message} - > - <Input - data-testid={selectors.pages.BrowseDashboards.NewFolderForm.nameInput} - id="folder-name-input" - {...register('folderName', { - required: translatedFolderNameRequiredPhrase, - validate: async (v) => await validateFolderName(v), - })} - /> - </Field> - <HorizontalGroup> - <Button variant="secondary" fill="outline" onClick={onCancel}> - <Trans i18nKey="browse-dashboards.new-folder-form.cancel-label">Cancel</Trans> - </Button> - <Button type="submit"> - <Trans i18nKey="browse-dashboards.new-folder-form.create-label">Create</Trans> - </Button> - </HorizontalGroup> - </> - )} - </Form> + <Field + label={fieldNameLabel} + invalid={!!errors.folderName} + error={errors.folderName && errors.folderName.message} + > + <Input + data-testid={selectors.pages.BrowseDashboards.NewFolderForm.nameInput} + id="folder-name-input" + defaultValue={initialFormModel.folderName} + {...register('folderName', { + required: translatedFolderNameRequiredPhrase, + validate: async (v) => await validateFolderName(v), + })} + /> + </Field> + <Stack> + <Button variant="secondary" fill="outline" onClick={onCancel}> + <Trans i18nKey="browse-dashboards.new-folder-form.cancel-label">Cancel</Trans> + </Button> + <Button type="submit"> + <Trans i18nKey="browse-dashboards.new-folder-form.create-label">Create</Trans> + </Button> + </Stack> + </form> ); } diff --git a/public/app/features/browse-dashboards/components/utils.ts b/public/app/features/browse-dashboards/components/utils.ts index 56613b82e14b4..cc4485b06b881 100644 --- a/public/app/features/browse-dashboards/components/utils.ts +++ b/public/app/features/browse-dashboards/components/utils.ts @@ -1,5 +1,11 @@ +import { config } from '@grafana/runtime'; + import { DashboardViewItemWithUIItems } from '../types'; export function makeRowID(baseId: string, item: DashboardViewItemWithUIItems) { return baseId + item.uid; } + +export function isSharedWithMe(uid: string) { + return uid === config.sharedWithMeFolderUID; +} diff --git a/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts b/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts index 4cb15098aa3ea..9de6e59c06a3c 100644 --- a/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts +++ b/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts @@ -65,6 +65,14 @@ export function wellFormedFolder( }; } +export function sharedWithMeFolder(seed = 1): DashboardsTreeItem<DashboardViewItem> { + const folder = wellFormedFolder(seed, undefined, { + uid: 'sharedwithme', + url: undefined, + }); + return folder; +} + export function wellFormedTree() { let seed = 1; diff --git a/public/app/features/browse-dashboards/state/hooks.ts b/public/app/features/browse-dashboards/state/hooks.ts index 9d04081091920..d46f0b44708ad 100644 --- a/public/app/features/browse-dashboards/state/hooks.ts +++ b/public/app/features/browse-dashboards/state/hooks.ts @@ -5,6 +5,7 @@ import { DashboardViewItem } from 'app/features/search/types'; import { useSelector, StoreState, useDispatch } from 'app/types'; import { PAGE_SIZE } from '../api/services'; +import { isSharedWithMe } from '../components/utils'; import { BrowseDashboardsState, DashboardsTreeItem, @@ -130,7 +131,7 @@ export function useLoadNextChildrenPage( } /** - * Creates a list of items, with level indicating it's 'nested' in the tree structure + * Creates a list of items, with level indicating it's nesting in the tree structure * * @param folderUID The UID of the folder being viewed, or undefined if at root Browse Dashboards page * @param rootItems Array of loaded items at the root level (without a parent). If viewing a folder, we expect this to be empty and unused @@ -180,7 +181,22 @@ export function createFlatTree( isOpen, }; - return [thisItem, ...mappedChildren]; + const items = [thisItem, ...mappedChildren]; + + if (isSharedWithMe(thisItem.item.uid)) { + items.push({ + item: { + kind: 'ui', + uiKind: 'divider', + uid: 'shared-with-me-divider', + }, + parentUID, + level: level + 1, + isOpen: false, + }); + } + + return items; } const isOpen = (folderUID && openFolders[folderUID]) || level === 0; diff --git a/public/app/features/browse-dashboards/state/reducers.test.ts b/public/app/features/browse-dashboards/state/reducers.test.ts index e90b484c1293e..5df508e0852f2 100644 --- a/public/app/features/browse-dashboards/state/reducers.test.ts +++ b/public/app/features/browse-dashboards/state/reducers.test.ts @@ -1,4 +1,6 @@ -import { wellFormedDashboard, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture'; +import { config } from '@grafana/runtime'; + +import { sharedWithMeFolder, wellFormedDashboard, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture'; import { fullyLoadedViewItemCollection } from '../fixtures/state.fixtures'; import { BrowseDashboardsState } from '../types'; @@ -19,6 +21,10 @@ function createInitialState(): BrowseDashboardsState { } describe('browse-dashboards reducers', () => { + beforeAll(() => { + config.sharedWithMeFolderUID = 'sharedwithme'; + }); + describe('fetchNextChildrenPageFulfilled', () => { it('loads first page of root items', () => { const pageSize = 50; @@ -321,11 +327,34 @@ describe('browse-dashboards reducers', () => { expect(state.selectedItems.$all).toBeFalsy(); }); + + it('does not allow the sharedwithme folder to be selected', () => { + let seed = 1; + const folder = wellFormedFolder(seed++).item; + const dashboard = wellFormedDashboard(seed++).item; + const sharedWithMe = sharedWithMeFolder(seed++).item; + const sharedWithMeDashboard = wellFormedDashboard(seed++, {}, { parentUID: sharedWithMe.uid }).item; + + const state = createInitialState(); + state.rootItems = fullyLoadedViewItemCollection([sharedWithMe, folder, dashboard]); + state.childrenByParentUID[sharedWithMe.uid] = fullyLoadedViewItemCollection([sharedWithMeDashboard]); + + setItemSelectionState(state, { + type: 'setItemSelectionState', + payload: { item: sharedWithMe, isSelected: true }, + }); + + expect(state.selectedItems.folder[sharedWithMe.uid]).toBeFalsy(); + }); }); describe('setAllSelection', () => { let seed = 1; const topLevelDashboard = wellFormedDashboard(seed++).item; + + const sharedWithMe = sharedWithMeFolder(seed++).item; + const sharedWithMeDashboard = wellFormedDashboard(seed++, {}, { parentUID: sharedWithMe.uid }).item; + const topLevelFolder = wellFormedFolder(seed++).item; const childDashboard = wellFormedDashboard(seed++, {}, { parentUID: topLevelFolder.uid }).item; const childFolder = wellFormedFolder(seed++, {}, { parentUID: topLevelFolder.uid }).item; @@ -407,5 +436,35 @@ describe('browse-dashboards reducers', () => { panel: {}, }); }); + + it("doesn't select the sharedwithme folder when selecting all", () => { + const state = createInitialState(); + + state.rootItems = fullyLoadedViewItemCollection([topLevelFolder, sharedWithMe]); + state.childrenByParentUID[sharedWithMe.uid] = fullyLoadedViewItemCollection([sharedWithMeDashboard]); + state.childrenByParentUID[topLevelFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]); + + setAllSelection(state, { type: 'setAllSelection', payload: { isSelected: true, folderUID: undefined } }); + + expect(state.selectedItems.folder[sharedWithMe.uid]).toBeFalsy(); + expect(state.selectedItems.dashboard[sharedWithMeDashboard.uid]).toBeFalsy(); + }); + + it("doesn't select anything when on the sharedwithme folder page", () => { + const state = createInitialState(); + + state.rootItems = fullyLoadedViewItemCollection([topLevelFolder, topLevelDashboard]); + state.childrenByParentUID[topLevelFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]); + state.childrenByParentUID[childFolder.uid] = fullyLoadedViewItemCollection([grandchildDashboard]); + + setAllSelection(state, { type: 'setAllSelection', payload: { isSelected: true, folderUID: sharedWithMe.uid } }); + + expect(state.selectedItems).toEqual({ + $all: false, + dashboard: {}, + folder: {}, + panel: {}, + }); + }); }); }); diff --git a/public/app/features/browse-dashboards/state/reducers.ts b/public/app/features/browse-dashboards/state/reducers.ts index 0eafcb939beaa..e506748dcd679 100644 --- a/public/app/features/browse-dashboards/state/reducers.ts +++ b/public/app/features/browse-dashboards/state/reducers.ts @@ -2,6 +2,7 @@ import { PayloadAction } from '@reduxjs/toolkit'; import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types'; +import { isSharedWithMe } from '../components/utils'; import { BrowseDashboardsState } from '../types'; import { fetchNextChildrenPage, refetchChildren } from './actions'; @@ -86,6 +87,11 @@ export function setItemSelectionState( ) { const { item, isSelected } = action.payload; + // UI shouldn't allow it, but also prevent sharedwithme from being selected + if (isSharedWithMe(item.uid)) { + return; + } + // Selecting a folder selects all children, and unselecting a folder deselects all children // so propagate the new selection state to all descendants function markChildren(kind: DashboardViewItemKind, uid: string) { @@ -103,26 +109,24 @@ export function setItemSelectionState( markChildren(item.kind, item.uid); - // If all children of a folder are selected, then the folder is also selected. - // If *any* child of a folder is unselelected, then the folder is alo unselected. - // Reconcile all ancestors to make sure they're in the correct state. - let nextParentUID = item.parentUID; + // If we're unselecting a child, we also need to unselect all ancestors. + if (!isSelected) { + let nextParentUID = item.parentUID; - while (nextParentUID) { - const parent = findItem(state.rootItems?.items ?? [], state.childrenByParentUID, nextParentUID); + while (nextParentUID) { + const parent = findItem(state.rootItems?.items ?? [], state.childrenByParentUID, nextParentUID); - // This case should not happen, but a find can theortically return undefined, and it - // helps limit infinite loops - if (!parent) { - break; - } + // This case should not happen, but a find can theortically return undefined, and it + // helps limit infinite loops + if (!parent) { + break; + } - if (!isSelected) { // A folder cannot be selected if any of it's children are unselected state.selectedItems[parent.kind][parent.uid] = false; - } - nextParentUID = parent.parentUID; + nextParentUID = parent.parentUID; + } } // Check to see if we should mark the header checkbox selected if all root items are selected @@ -135,6 +139,12 @@ export function setAllSelection( ) { const { isSelected, folderUID: folderUIDArg } = action.payload; + // If we're in the folder view for sharedwith me (currently not supported) + // bail and don't select anything + if (folderUIDArg && isSharedWithMe(folderUIDArg)) { + return; + } + state.selectedItems.$all = isSelected; // Search works a bit differently so the state here does different things... @@ -146,6 +156,11 @@ export function setAllSelection( if (isSelected) { // Recursively select the children of the folder in view function selectChildrenOfFolder(folderUID: string | undefined) { + // Don't descend into the sharedwithme folder + if (folderUID && isSharedWithMe(folderUID)) { + return; + } + const collection = folderUID ? state.childrenByParentUID[folderUID] : state.rootItems; // Bail early if the collection isn't found (not loaded yet) @@ -154,6 +169,11 @@ export function setAllSelection( } for (const child of collection.items) { + // Don't traverse into the sharedwithme folder + if (isSharedWithMe(child.uid)) { + continue; + } + state.selectedItems[child.kind][child.uid] = isSelected; if (child.kind !== 'folder') { diff --git a/public/app/features/browse-dashboards/types.ts b/public/app/features/browse-dashboards/types.ts index 8b0c0dc1ed5f3..db797d0b0f61f 100644 --- a/public/app/features/browse-dashboards/types.ts +++ b/public/app/features/browse-dashboards/types.ts @@ -29,7 +29,7 @@ export interface BrowseDashboardsState { export interface UIDashboardViewItem { kind: 'ui'; - uiKind: 'empty-folder' | 'pagination-placeholder'; + uiKind: 'empty-folder' | 'pagination-placeholder' | 'divider'; uid: string; } diff --git a/public/app/features/canvas/element.ts b/public/app/features/canvas/element.ts index 0e51e4a881534..99b51e4df3760 100644 --- a/public/app/features/canvas/element.ts +++ b/public/app/features/canvas/element.ts @@ -49,6 +49,7 @@ export interface CanvasConnection { path: ConnectionPath; color?: ColorDimensionConfig; size?: ScaleDimensionConfig; + vertices?: ConnectionCoordinates[]; // See https://github.com/anseki/leader-line#options for more examples of more properties } @@ -72,7 +73,7 @@ export interface CanvasElementItem<TConfig = any, TData = any> extends RegistryI /** The default width/height to use when adding */ defaultSize?: Placement; - prepareData?: (ctx: DimensionContext, cfg: TConfig) => TData; + prepareData?: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<TConfig>) => TData; /** Component used to draw */ display: ComponentType<CanvasElementProps<TConfig, TData>>; diff --git a/public/app/features/canvas/elements/button.tsx b/public/app/features/canvas/elements/button.tsx index 18cf97713a458..a6e8980e7c3b8 100644 --- a/public/app/features/canvas/elements/button.tsx +++ b/public/app/features/canvas/elements/button.tsx @@ -13,7 +13,7 @@ import { ButtonStyleConfig, ButtonStyleEditor } from 'app/plugins/panel/canvas/e import { callApi } from 'app/plugins/panel/canvas/editor/element/utils'; import { HttpRequestMethod } from 'app/plugins/panel/canvas/panelcfg.gen'; -import { CanvasElementItem, CanvasElementProps, defaultLightTextColor } from '../element'; +import { CanvasElementItem, CanvasElementOptions, CanvasElementProps, defaultLightTextColor } from '../element'; import { Align, TextConfig, TextData } from '../types'; interface ButtonData extends Omit<TextData, 'valign'> { @@ -130,30 +130,32 @@ export const buttonItem: CanvasElementItem<ButtonConfig, ButtonData> = { }), // Called when data changes - prepareData: (ctx: DimensionContext, cfg: ButtonConfig) => { - const getCfgApi = () => { - if (cfg?.api) { - cfg.api = { - ...cfg.api, - method: cfg.api.method ?? defaultApiConfig.method, - contentType: cfg.api.contentType ?? defaultApiConfig.contentType, + prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<ButtonConfig>) => { + const buttonConfig = elementOptions.config; + + const getAPIConfig = () => { + if (buttonConfig?.api) { + buttonConfig.api = { + ...buttonConfig.api, + method: buttonConfig.api.method ?? defaultApiConfig.method, + contentType: buttonConfig.api.contentType ?? defaultApiConfig.contentType, }; - return cfg.api; + return buttonConfig.api; } return undefined; }; const data: ButtonData = { - text: cfg?.text ? ctx.getText(cfg.text).value() : '', - align: cfg.align ?? Align.Center, - size: cfg.size ?? 14, - api: getCfgApi(), - style: cfg?.style ?? defaultStyleConfig, + text: buttonConfig?.text ? dimensionContext.getText(buttonConfig.text).value() : '', + align: buttonConfig?.align ?? Align.Center, + size: buttonConfig?.size ?? 14, + api: getAPIConfig(), + style: buttonConfig?.style ?? defaultStyleConfig, }; - if (cfg.color) { - data.color = ctx.getColor(cfg.color).value(); + if (buttonConfig?.color) { + data.color = dimensionContext.getColor(buttonConfig.color).value(); } return data; diff --git a/public/app/features/canvas/elements/droneFront.tsx b/public/app/features/canvas/elements/droneFront.tsx index 9ce31d8f8ace5..d96ce3899e755 100644 --- a/public/app/features/canvas/elements/droneFront.tsx +++ b/public/app/features/canvas/elements/droneFront.tsx @@ -7,7 +7,7 @@ import { useStyles2 } from '@grafana/ui'; import { DimensionContext } from 'app/features/dimensions'; import { ScalarDimensionEditor } from 'app/features/dimensions/editors'; -import { CanvasElementItem, CanvasElementProps, defaultBgColor } from '../element'; +import { CanvasElementItem, CanvasElementOptions, CanvasElementProps, defaultBgColor } from '../element'; interface DroneFrontData { rollAngle?: number; @@ -97,9 +97,11 @@ export const droneFrontItem: CanvasElementItem = { }), // Called when data changes - prepareData: (ctx: DimensionContext, cfg: DroneFrontConfig) => { + prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<DroneFrontConfig>) => { + const droneFrontConfig = elementOptions.config; + const data: DroneFrontData = { - rollAngle: cfg?.rollAngle ? ctx.getScalar(cfg.rollAngle).value() : 0, + rollAngle: droneFrontConfig?.rollAngle ? dimensionContext.getScalar(droneFrontConfig.rollAngle).value() : 0, }; return data; diff --git a/public/app/features/canvas/elements/droneSide.tsx b/public/app/features/canvas/elements/droneSide.tsx index 8ae6fcc6a218b..cc1821ad63fbd 100644 --- a/public/app/features/canvas/elements/droneSide.tsx +++ b/public/app/features/canvas/elements/droneSide.tsx @@ -7,7 +7,7 @@ import { useStyles2 } from '@grafana/ui'; import { DimensionContext } from 'app/features/dimensions'; import { ScalarDimensionEditor } from 'app/features/dimensions/editors'; -import { CanvasElementItem, CanvasElementProps, defaultBgColor } from '../element'; +import { CanvasElementItem, CanvasElementOptions, CanvasElementProps, defaultBgColor } from '../element'; interface DroneSideData { pitchAngle?: number; @@ -96,9 +96,11 @@ export const droneSideItem: CanvasElementItem = { }), // Called when data changes - prepareData: (ctx: DimensionContext, cfg: DroneSideConfig) => { + prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<DroneSideConfig>) => { + const droneSideConfig = elementOptions.config; + const data: DroneSideData = { - pitchAngle: cfg?.pitchAngle ? ctx.getScalar(cfg.pitchAngle).value() : 0, + pitchAngle: droneSideConfig?.pitchAngle ? dimensionContext.getScalar(droneSideConfig.pitchAngle).value() : 0, }; return data; diff --git a/public/app/features/canvas/elements/droneTop.tsx b/public/app/features/canvas/elements/droneTop.tsx index d1fb4174cbbbe..f4c2bff38f7c9 100644 --- a/public/app/features/canvas/elements/droneTop.tsx +++ b/public/app/features/canvas/elements/droneTop.tsx @@ -7,7 +7,7 @@ import { useStyles2 } from '@grafana/ui'; import { DimensionContext } from 'app/features/dimensions'; import { ScalarDimensionEditor } from 'app/features/dimensions/editors'; -import { CanvasElementItem, CanvasElementProps, defaultBgColor } from '../element'; +import { CanvasElementItem, CanvasElementOptions, CanvasElementProps, defaultBgColor } from '../element'; interface DroneTopData { bRightRotorRPM?: number; @@ -102,13 +102,23 @@ export const droneTopItem: CanvasElementItem = { }), // Called when data changes - prepareData: (ctx: DimensionContext, cfg: DroneTopConfig) => { + prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<DroneTopConfig>) => { + const droneTopConfig = elementOptions.config; + const data: DroneTopData = { - bRightRotorRPM: cfg?.bRightRotorRPM ? ctx.getScalar(cfg.bRightRotorRPM).value() : 0, - bLeftRotorRPM: cfg?.bLeftRotorRPM ? ctx.getScalar(cfg.bLeftRotorRPM).value() : 0, - fRightRotorRPM: cfg?.fRightRotorRPM ? ctx.getScalar(cfg.fRightRotorRPM).value() : 0, - fLeftRotorRPM: cfg?.fLeftRotorRPM ? ctx.getScalar(cfg.fLeftRotorRPM).value() : 0, - yawAngle: cfg?.yawAngle ? ctx.getScalar(cfg.yawAngle).value() : 0, + bRightRotorRPM: droneTopConfig?.bRightRotorRPM + ? dimensionContext.getScalar(droneTopConfig.bRightRotorRPM).value() + : 0, + bLeftRotorRPM: droneTopConfig?.bLeftRotorRPM + ? dimensionContext.getScalar(droneTopConfig.bLeftRotorRPM).value() + : 0, + fRightRotorRPM: droneTopConfig?.fRightRotorRPM + ? dimensionContext.getScalar(droneTopConfig.fRightRotorRPM).value() + : 0, + fLeftRotorRPM: droneTopConfig?.fLeftRotorRPM + ? dimensionContext.getScalar(droneTopConfig.fLeftRotorRPM).value() + : 0, + yawAngle: droneTopConfig?.yawAngle ? dimensionContext.getScalar(droneTopConfig.yawAngle).value() : 0, }; return data; diff --git a/public/app/features/canvas/elements/ellipse.tsx b/public/app/features/canvas/elements/ellipse.tsx index 2cac9ba1de4d8..02663bb344ec3 100644 --- a/public/app/features/canvas/elements/ellipse.tsx +++ b/public/app/features/canvas/elements/ellipse.tsx @@ -6,8 +6,15 @@ import { config } from 'app/core/config'; import { DimensionContext } from 'app/features/dimensions/context'; import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor'; +import { getDataLinks } from 'app/plugins/panel/canvas/utils'; -import { CanvasElementItem, CanvasElementProps, defaultBgColor, defaultTextColor } from '../element'; +import { + CanvasElementItem, + CanvasElementOptions, + CanvasElementProps, + defaultBgColor, + defaultTextColor, +} from '../element'; import { Align, VAlign, EllipseConfig, EllipseData } from '../types'; class EllipseDisplay extends PureComponent<CanvasElementProps<EllipseConfig, EllipseData>> { @@ -79,25 +86,29 @@ export const ellipseItem: CanvasElementItem<EllipseConfig, EllipseData> = { }, }), - prepareData: (ctx: DimensionContext, cfg: EllipseConfig) => { + prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<EllipseConfig>) => { + const ellipseConfig = elementOptions.config; + const data: EllipseData = { - width: cfg.width, - text: cfg.text ? ctx.getText(cfg.text).value() : '', - align: cfg.align ?? Align.Center, - valign: cfg.valign ?? VAlign.Middle, - size: cfg.size, + width: ellipseConfig?.width, + text: ellipseConfig?.text ? dimensionContext.getText(ellipseConfig.text).value() : '', + align: ellipseConfig?.align ?? Align.Center, + valign: ellipseConfig?.valign ?? VAlign.Middle, + size: ellipseConfig?.size, }; - if (cfg.backgroundColor) { - data.backgroundColor = ctx.getColor(cfg.backgroundColor).value(); + if (ellipseConfig?.backgroundColor) { + data.backgroundColor = dimensionContext.getColor(ellipseConfig.backgroundColor).value(); } - if (cfg.borderColor) { - data.borderColor = ctx.getColor(cfg.borderColor).value(); + if (ellipseConfig?.borderColor) { + data.borderColor = dimensionContext.getColor(ellipseConfig.borderColor).value(); } - if (cfg.color) { - data.color = ctx.getColor(cfg.color).value(); + if (ellipseConfig?.color) { + data.color = dimensionContext.getColor(ellipseConfig.color).value(); } + data.links = getDataLinks(dimensionContext, elementOptions, data.text); + return data; }, diff --git a/public/app/features/canvas/elements/icon.tsx b/public/app/features/canvas/elements/icon.tsx index 2f5eec04394ec..b64996b197a66 100644 --- a/public/app/features/canvas/elements/icon.tsx +++ b/public/app/features/canvas/elements/icon.tsx @@ -2,13 +2,15 @@ import { css } from '@emotion/css'; import { isString } from 'lodash'; import React, { CSSProperties } from 'react'; +import { LinkModel } from '@grafana/data'; import { ColorDimensionConfig, ResourceDimensionConfig, ResourceDimensionMode } from '@grafana/schema'; import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG'; import { getPublicOrAbsoluteUrl } from 'app/features/dimensions'; import { DimensionContext } from 'app/features/dimensions/context'; import { ColorDimensionEditor, ResourceDimensionEditor } from 'app/features/dimensions/editors'; +import { getDataLinks } from 'app/plugins/panel/canvas/utils'; -import { CanvasElementItem, CanvasElementProps, defaultBgColor } from '../element'; +import { CanvasElementItem, CanvasElementOptions, CanvasElementProps, defaultBgColor } from '../element'; import { LineConfig } from '../types'; export interface IconConfig { @@ -22,6 +24,7 @@ interface IconData { fill: string; strokeColor?: string; stroke?: number; + links?: LinkModel[]; } // When a stoke is defined, we want the path to be in page units @@ -80,10 +83,12 @@ export const iconItem: CanvasElementItem<IconConfig, IconData> = { }), // Called when data changes - prepareData: (ctx: DimensionContext, cfg: IconConfig) => { + prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<IconConfig>) => { + const iconConfig = elementOptions.config; + let path: string | undefined = undefined; - if (cfg.path) { - path = ctx.getResource(cfg.path).value(); + if (iconConfig?.path) { + path = dimensionContext.getResource(iconConfig.path).value(); } if (!path || !isString(path)) { path = getPublicOrAbsoluteUrl('img/icons/unicons/question-circle.svg'); @@ -91,15 +96,18 @@ export const iconItem: CanvasElementItem<IconConfig, IconData> = { const data: IconData = { path, - fill: cfg.fill ? ctx.getColor(cfg.fill).value() : defaultBgColor, + fill: iconConfig?.fill ? dimensionContext.getColor(iconConfig.fill).value() : defaultBgColor, }; - if (cfg.stroke?.width && cfg.stroke.color) { - if (cfg.stroke.width > 0) { - data.stroke = cfg.stroke?.width; - data.strokeColor = ctx.getColor(cfg.stroke.color).value(); + if (iconConfig?.stroke?.width && iconConfig?.stroke.color) { + if (iconConfig.stroke.width > 0) { + data.stroke = iconConfig.stroke?.width; + data.strokeColor = dimensionContext.getColor(iconConfig.stroke.color).value(); } } + + data.links = getDataLinks(dimensionContext, elementOptions, data.path); + return data; }, diff --git a/public/app/features/canvas/elements/metricValue.tsx b/public/app/features/canvas/elements/metricValue.tsx index 76ed7a01f1a40..1bcc39f28089c 100644 --- a/public/app/features/canvas/elements/metricValue.tsx +++ b/public/app/features/canvas/elements/metricValue.tsx @@ -13,7 +13,13 @@ import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimen import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor'; import { getDataLinks } from 'app/plugins/panel/canvas/utils'; -import { CanvasElementItem, CanvasElementProps, defaultBgColor, defaultTextColor } from '../element'; +import { + CanvasElementItem, + CanvasElementOptions, + CanvasElementProps, + defaultBgColor, + defaultTextColor, +} from '../element'; import { ElementState } from '../runtime/element'; import { Align, TextConfig, TextData, VAlign } from '../types'; @@ -171,19 +177,21 @@ export const metricValueItem: CanvasElementItem<TextConfig, TextData> = { }, }), - prepareData: (ctx: DimensionContext, cfg: TextConfig) => { + prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<TextConfig>) => { + const textConfig = elementOptions.config; + const data: TextData = { - text: cfg.text ? ctx.getText(cfg.text).value() : '', - align: cfg.align ?? Align.Center, - valign: cfg.valign ?? VAlign.Middle, - size: cfg.size, + text: textConfig?.text ? dimensionContext.getText(textConfig.text).value() : '', + align: textConfig?.align ?? Align.Center, + valign: textConfig?.valign ?? VAlign.Middle, + size: textConfig?.size, }; - if (cfg.color) { - data.color = ctx.getColor(cfg.color).value(); + if (textConfig?.color) { + data.color = dimensionContext.getColor(textConfig.color).value(); } - data.links = getDataLinks(ctx, cfg, data.text); + data.links = getDataLinks(dimensionContext, elementOptions, data.text); return data; }, diff --git a/public/app/features/canvas/elements/rectangle.tsx b/public/app/features/canvas/elements/rectangle.tsx index 4f0f515dc1a54..fcadaddc8570a 100644 --- a/public/app/features/canvas/elements/rectangle.tsx +++ b/public/app/features/canvas/elements/rectangle.tsx @@ -7,8 +7,15 @@ import { config } from 'app/core/config'; import { DimensionContext } from 'app/features/dimensions/context'; import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor'; +import { getDataLinks } from 'app/plugins/panel/canvas/utils'; -import { CanvasElementItem, CanvasElementProps, defaultBgColor, defaultTextColor } from '../element'; +import { + CanvasElementItem, + CanvasElementOptions, + CanvasElementProps, + defaultBgColor, + defaultTextColor, +} from '../element'; import { Align, TextConfig, TextData, VAlign } from '../types'; class RectangleDisplay extends PureComponent<CanvasElementProps<TextConfig, TextData>> { @@ -69,18 +76,22 @@ export const rectangleItem: CanvasElementItem<TextConfig, TextData> = { }), // Called when data changes - prepareData: (ctx: DimensionContext, cfg: TextConfig) => { + prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<TextConfig>) => { + const textConfig = elementOptions.config; + const data: TextData = { - text: cfg.text ? ctx.getText(cfg.text).value() : '', - align: cfg.align ?? Align.Center, - valign: cfg.valign ?? VAlign.Middle, - size: cfg.size, + text: textConfig?.text ? dimensionContext.getText(textConfig.text).value() : '', + align: textConfig?.align ?? Align.Center, + valign: textConfig?.valign ?? VAlign.Middle, + size: textConfig?.size, }; - if (cfg.color) { - data.color = ctx.getColor(cfg.color).value(); + if (textConfig?.color) { + data.color = dimensionContext.getColor(textConfig.color).value(); } + data.links = getDataLinks(dimensionContext, elementOptions, data.text); + return data; }, diff --git a/public/app/features/canvas/elements/server/server.tsx b/public/app/features/canvas/elements/server/server.tsx index aaee1ea63362a..dcd8c1393a0a7 100644 --- a/public/app/features/canvas/elements/server/server.tsx +++ b/public/app/features/canvas/elements/server/server.tsx @@ -1,13 +1,14 @@ import { css } from '@emotion/css'; import React from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, LinkModel } from '@grafana/data'; import { ColorDimensionConfig, ScalarDimensionConfig } from '@grafana/schema'; import config from 'app/core/config'; import { DimensionContext } from 'app/features/dimensions'; import { ColorDimensionEditor, ScalarDimensionEditor } from 'app/features/dimensions/editors'; +import { getDataLinks } from 'app/plugins/panel/canvas/utils'; -import { CanvasElementItem, CanvasElementProps } from '../../element'; +import { CanvasElementItem, CanvasElementOptions, CanvasElementProps } from '../../element'; import { ServerDatabase } from './types/database'; import { ServerSingle } from './types/single'; @@ -26,6 +27,7 @@ export interface ServerData { statusColor?: string; bulbColor?: string; type: ServerType; + links?: LinkModel[]; } enum ServerType { @@ -85,14 +87,20 @@ export const serverItem: CanvasElementItem<ServerConfig, ServerData> = { }), // Called when data changes - prepareData: (ctx: DimensionContext, cfg: ServerConfig) => { + prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<ServerConfig>) => { + const serverConfig = elementOptions.config; + const data: ServerData = { - blinkRate: cfg?.blinkRate ? ctx.getScalar(cfg.blinkRate).value() : 0, - statusColor: cfg?.statusColor ? ctx.getColor(cfg.statusColor).value() : 'transparent', - bulbColor: cfg?.bulbColor ? ctx.getColor(cfg.bulbColor).value() : 'green', - type: cfg.type, + blinkRate: serverConfig?.blinkRate ? dimensionContext.getScalar(serverConfig.blinkRate).value() : 0, + statusColor: serverConfig?.statusColor + ? dimensionContext.getColor(serverConfig.statusColor).value() + : 'transparent', + bulbColor: serverConfig?.bulbColor ? dimensionContext.getColor(serverConfig.bulbColor).value() : 'green', + type: serverConfig?.type ?? ServerType.Single, }; + data.links = getDataLinks(dimensionContext, elementOptions, data.statusColor); + return data; }, diff --git a/public/app/features/canvas/elements/text.tsx b/public/app/features/canvas/elements/text.tsx index 3abd83d8d7c61..5f6fb6078f30b 100644 --- a/public/app/features/canvas/elements/text.tsx +++ b/public/app/features/canvas/elements/text.tsx @@ -10,7 +10,7 @@ import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimen import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor'; import { getDataLinks } from '../../../plugins/panel/canvas/utils'; -import { CanvasElementItem, CanvasElementProps, defaultThemeTextColor } from '../element'; +import { CanvasElementItem, CanvasElementOptions, CanvasElementProps, defaultThemeTextColor } from '../element'; import { ElementState } from '../runtime/element'; import { Align, TextConfig, TextData, VAlign } from '../types'; @@ -149,19 +149,21 @@ export const textItem: CanvasElementItem<TextConfig, TextData> = { }, }), - prepareData: (ctx: DimensionContext, cfg: TextConfig) => { + prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<TextConfig>) => { + const textConfig = elementOptions.config; + const data: TextData = { - text: cfg.text ? ctx.getText(cfg.text).value() : '', - align: cfg.align ?? Align.Center, - valign: cfg.valign ?? VAlign.Middle, - size: cfg.size, + text: textConfig?.text ? dimensionContext.getText(textConfig.text).value() : '', + align: textConfig?.align ?? Align.Center, + valign: textConfig?.valign ?? VAlign.Middle, + size: textConfig?.size, }; - if (cfg.color) { - data.color = ctx.getColor(cfg.color).value(); + if (textConfig?.color) { + data.color = dimensionContext.getColor(textConfig.color).value(); } - data.links = getDataLinks(ctx, cfg, data.text); + data.links = getDataLinks(dimensionContext, elementOptions, data.text); return data; }, diff --git a/public/app/features/canvas/elements/windTurbine.tsx b/public/app/features/canvas/elements/windTurbine.tsx index 1d90339e2043f..1ea10e13a33e9 100644 --- a/public/app/features/canvas/elements/windTurbine.tsx +++ b/public/app/features/canvas/elements/windTurbine.tsx @@ -1,16 +1,18 @@ import { css } from '@emotion/css'; import React from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, LinkModel } from '@grafana/data'; import { ScalarDimensionConfig } from '@grafana/schema'; import { useStyles2 } from '@grafana/ui'; import { DimensionContext } from 'app/features/dimensions'; import { ScalarDimensionEditor } from 'app/features/dimensions/editors'; +import { getDataLinks } from 'app/plugins/panel/canvas/utils'; -import { CanvasElementItem, CanvasElementProps, defaultBgColor } from '../element'; +import { CanvasElementItem, CanvasElementOptions, CanvasElementProps, defaultBgColor } from '../element'; interface WindTurbineData { rpm?: number; + links?: LinkModel[]; } interface WindTurbineConfig { @@ -92,11 +94,15 @@ export const windTurbineItem: CanvasElementItem = { }), // Called when data changes - prepareData: (ctx: DimensionContext, cfg: WindTurbineConfig) => { + prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<WindTurbineConfig>) => { + const windTurbineConfig = elementOptions.config; + const data: WindTurbineData = { - rpm: cfg?.rpm ? ctx.getScalar(cfg.rpm).value() : 0, + rpm: windTurbineConfig?.rpm ? dimensionContext.getScalar(windTurbineConfig.rpm).value() : 0, }; + data.links = getDataLinks(dimensionContext, elementOptions, `${data.rpm}`); + return data; }, diff --git a/public/app/features/canvas/runtime/SceneTransformWrapper.tsx b/public/app/features/canvas/runtime/SceneTransformWrapper.tsx index eaa8cdad8608c..c868cd3d497ff 100644 --- a/public/app/features/canvas/runtime/SceneTransformWrapper.tsx +++ b/public/app/features/canvas/runtime/SceneTransformWrapper.tsx @@ -13,10 +13,51 @@ type SceneTransformWrapperProps = { export const SceneTransformWrapper = ({ scene, children: sceneDiv }: SceneTransformWrapperProps) => { const onZoom = (zoomPanPinchRef: ReactZoomPanPinchRef) => { const scale = zoomPanPinchRef.state.scale; + scene.scale = scale; + }; + + const onZoomStop = (zoomPanPinchRef: ReactZoomPanPinchRef) => { + const scale = zoomPanPinchRef.state.scale; + scene.scale = scale; + updateMoveable(scale); + }; + + const onTransformed = ( + _: ReactZoomPanPinchRef, + state: { + scale: number; + positionX: number; + positionY: number; + } + ) => { + const scale = state.scale; + scene.scale = scale; + updateMoveable(scale); + }; + + const updateMoveable = (scale: number) => { if (scene.moveable && scale > 0) { scene.moveable.zoom = 1 / scale; + if (scale === 1) { + scene.moveable.snappable = true; + } else { + scene.moveable.snappable = false; + } + } + }; + + const onSceneContainerMouseDown = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => { + // If pan and zoom is disabled or context menu is visible, don't pan + if ((!scene.shouldPanZoom || scene.contextMenuVisible) && (e.button === 1 || (e.button === 2 && e.ctrlKey))) { + e.preventDefault(); + e.stopPropagation(); + } + + // If context menu is hidden, ignore left mouse or non-ctrl right mouse for pan + if (!scene.contextMenuVisible && !scene.isPanelEditing && e.button === 2 && !e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); } - scene.scale = scale; }; return ( @@ -24,14 +65,17 @@ export const SceneTransformWrapper = ({ scene, children: sceneDiv }: SceneTransf doubleClick={{ mode: 'reset' }} ref={scene.transformComponentRef} onZoom={onZoom} - onTransformed={(_, state) => { - scene.scale = state.scale; - }} + onZoomStop={onZoomStop} + onTransformed={onTransformed} limitToBounds={true} disabled={!config.featureToggles.canvasPanelPanZoom || !scene.shouldPanZoom} panning={{ allowLeftClickPan: false }} > - <TransformComponent>{sceneDiv}</TransformComponent> + <TransformComponent> + {/* The <div> element has child elements that allow for mouse events, so we need to disable the linter rule */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} + <div onMouseDown={onSceneContainerMouseDown}>{sceneDiv}</div> + </TransformComponent> </TransformWrapper> ); }; diff --git a/public/app/features/canvas/runtime/ables.tsx b/public/app/features/canvas/runtime/ables.tsx index 8de918c412dd4..6130f0eadb97c 100644 --- a/public/app/features/canvas/runtime/ables.tsx +++ b/public/app/features/canvas/runtime/ables.tsx @@ -6,8 +6,8 @@ import { Scene } from './scene'; export const settingsViewable = (scene: Scene) => ({ name: 'settingsViewable', - props: {}, - events: {}, + props: [], + events: [], render(moveable: MoveableManagerInterface<unknown, unknown>, React: Renderer) { // If selection is more than 1 element don't display settings button if (scene.selecto?.getSelectedTargets() && scene.selecto?.getSelectedTargets().length > 1) { @@ -63,8 +63,8 @@ export const settingsViewable = (scene: Scene) => ({ export const dimensionViewable = { name: 'dimensionViewable', - props: {}, - events: {}, + props: [], + events: [], render(moveable: MoveableManagerInterface<unknown, unknown>, React: Renderer) { const rect = moveable.getRect(); return ( @@ -95,8 +95,8 @@ export const dimensionViewable = { export const constraintViewable = (scene: Scene) => ({ name: 'constraintViewable', - props: {}, - events: {}, + props: [], + events: [], render(moveable: MoveableManagerInterface<unknown, unknown>, React: Renderer) { const rect = moveable.getRect(); const targetElement = scene.findElementByTarget(moveable.state.target!); diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index 03f5214916d46..77d6158a47c56 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -296,7 +296,7 @@ export class ElementState implements LayerElement { updateData(ctx: DimensionContext) { if (this.item.prepareData) { - this.data = this.item.prepareData(ctx, this.options.config); + this.data = this.item.prepareData(ctx, this.options); this.revId++; // rerender } @@ -459,7 +459,7 @@ export class ElementState implements LayerElement { handleMouseEnter = (event: React.MouseEvent, isSelected: boolean | undefined) => { const scene = this.getScene(); - if (!scene?.isEditingEnabled) { + if (!scene?.isEditingEnabled && !scene?.tooltip?.isOpen) { this.handleTooltip(event); } else if (!isSelected) { scene?.connections.handleMouseEnter(event); @@ -486,6 +486,7 @@ export class ElementState implements LayerElement { }; onElementClick = (event: React.MouseEvent) => { + this.handleTooltip(event); this.onTooltipCallback(); }; diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx index 1d2aaa63fed91..526f2a245a0a2 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -28,7 +28,11 @@ import { } from 'app/features/dimensions/utils'; import { CanvasContextMenu } from 'app/plugins/panel/canvas/components/CanvasContextMenu'; import { CanvasTooltip } from 'app/plugins/panel/canvas/components/CanvasTooltip'; -import { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/components/connections/ConnectionAnchors'; +import { + CONNECTION_ANCHOR_DIV_ID, + CONNECTION_VERTEX_ADD_ID, + CONNECTION_VERTEX_ID, +} from 'app/plugins/panel/canvas/components/connections/ConnectionAnchors'; import { Connections } from 'app/plugins/panel/canvas/components/connections/Connections'; import { AnchorPoint, CanvasTooltipPayload, LayerActionID } from 'app/plugins/panel/canvas/types'; import { getParent, getTransformInstance } from 'app/plugins/panel/canvas/utils'; @@ -397,9 +401,19 @@ export class Scene { hitRate: 0, }); + const snapDirections = { top: true, left: true, bottom: true, right: true, center: true, middle: true }; + const elementSnapDirections = { top: true, left: true, bottom: true, right: true, center: true, middle: true }; + this.moveable = new Moveable(this.div!, { draggable: allowChanges && !this.editModeEnabled.getValue(), resizable: allowChanges, + + // Setup snappable + snappable: allowChanges, + snapDirections: snapDirections, + elementSnapDirections: elementSnapDirections, + elementGuidelines: targetElements, + ables: [dimensionViewable, constraintViewable(this), settingsViewable(this)], props: { dimensionViewable: allowChanges, @@ -426,9 +440,27 @@ export class Scene { .on('dragStart', (event) => { this.ignoreDataUpdate = true; this.setNonTargetPointerEvents(event.target, true); + + // Remove the selected element from the snappable guidelines + if (this.moveable && this.moveable.elementGuidelines) { + const targetIndex = this.moveable.elementGuidelines.indexOf(event.target); + if (targetIndex > -1) { + this.moveable.elementGuidelines.splice(targetIndex, 1); + } + } }) - .on('dragGroupStart', (event) => { + .on('dragGroupStart', (e) => { this.ignoreDataUpdate = true; + + // Remove the selected elements from the snappable guidelines + if (this.moveable && this.moveable.elementGuidelines) { + for (let event of e.events) { + const targetIndex = this.moveable.elementGuidelines.indexOf(event.target); + if (targetIndex > -1) { + this.moveable.elementGuidelines.splice(targetIndex, 1); + } + } + } }) .on('drag', (event) => { const targetedElement = this.findElementByTarget(event.target); @@ -463,6 +495,11 @@ export class Scene { if (targetedElement) { targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale); } + + // re-add the selected elements to the snappable guidelines + if (this.moveable && this.moveable.elementGuidelines) { + this.moveable.elementGuidelines.push(event.target); + } } }); @@ -478,11 +515,24 @@ export class Scene { this.moved.next(Date.now()); this.ignoreDataUpdate = false; this.setNonTargetPointerEvents(event.target, false); + + // re-add the selected element to the snappable guidelines + if (this.moveable && this.moveable.elementGuidelines) { + this.moveable.elementGuidelines.push(event.target); + } }) .on('resizeStart', (event) => { const targetedElement = this.findElementByTarget(event.target); if (targetedElement) { + // Remove the selected element from the snappable guidelines + if (this.moveable && this.moveable.elementGuidelines) { + const targetIndex = this.moveable.elementGuidelines.indexOf(event.target); + if (targetIndex > -1) { + this.moveable.elementGuidelines.splice(targetIndex, 1); + } + } + targetedElement.tempConstraint = { ...targetedElement.options.constraint }; targetedElement.options.constraint = { vertical: VerticalConstraint.Top, @@ -491,6 +541,17 @@ export class Scene { targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale); } }) + .on('resizeGroupStart', (e) => { + // Remove the selected elements from the snappable guidelines + if (this.moveable && this.moveable.elementGuidelines) { + for (let event of e.events) { + const targetIndex = this.moveable.elementGuidelines.indexOf(event.target); + if (targetIndex > -1) { + this.moveable.elementGuidelines.splice(targetIndex, 1); + } + } + } + }) .on('resize', (event) => { const targetedElement = this.findElementByTarget(event.target); if (targetedElement) { @@ -531,6 +592,19 @@ export class Scene { } targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale); + + // re-add the selected element to the snappable guidelines + if (this.moveable && this.moveable.elementGuidelines) { + this.moveable.elementGuidelines.push(event.target); + } + } + }) + .on('resizeGroupEnd', (e) => { + // re-add the selected elements to the snappable guidelines + if (this.moveable && this.moveable.elementGuidelines) { + for (let event of e.events) { + this.moveable.elementGuidelines.push(event.target); + } } }); @@ -545,6 +619,20 @@ export class Scene { return; } + // If selected target is a vertex, eject to handle vertex event + if (selectedTarget.id === CONNECTION_VERTEX_ID) { + this.connections.handleVertexDragStart(selectedTarget); + event.stop(); + return; + } + + // If selected target is an add vertex point, eject to handle add vertex event + if (selectedTarget.id === CONNECTION_VERTEX_ADD_ID) { + this.connections.handleVertexAddDragStart(selectedTarget); + event.stop(); + return; + } + const isTargetMoveableElement = this.moveable!.isMoveableElement(selectedTarget) || targets.some((target) => target === selectedTarget || target.contains(selectedTarget)); @@ -665,34 +753,14 @@ export class Scene { }; render() { - const canShowContextMenu = this.isPanelEditing || (!this.isPanelEditing && this.isEditingEnabled); const isTooltipValid = (this.tooltip?.element?.data?.links?.length ?? 0) > 0; const canShowElementTooltip = !this.isEditingEnabled && isTooltipValid; const sceneDiv = ( - // TODO: Address this eslint error - // eslint-disable-next-line jsx-a11y/no-static-element-interactions - <div - key={this.revId} - className={this.styles.wrap} - style={this.style} - ref={this.setRef} - onMouseDown={(e) => { - // If pan and zoom is disabled and middle mouse or ctrl + right mouse, don't pan - if ((!this.shouldPanZoom || this.contextMenuVisible) && (e.button === 1 || (e.button === 2 && e.ctrlKey))) { - e.preventDefault(); - e.stopPropagation(); - } - // If context menu is hidden, ignore left mouse or non-ctrl right mouse for pan - if (!this.contextMenuVisible && e.button === 2 && !e.ctrlKey) { - e.preventDefault(); - e.stopPropagation(); - } - }} - > + <div key={this.revId} className={this.styles.wrap} style={this.style} ref={this.setRef}> {this.connections.render()} {this.root.render()} - {canShowContextMenu && ( + {this.isEditingEnabled && ( <Portal> <CanvasContextMenu scene={this} diff --git a/public/app/features/commandPalette/CommandPalette.tsx b/public/app/features/commandPalette/CommandPalette.tsx index 0ccb405da39eb..9e2222bedcde5 100644 --- a/public/app/features/commandPalette/CommandPalette.tsx +++ b/public/app/features/commandPalette/CommandPalette.tsx @@ -19,6 +19,7 @@ import { reportInteraction } from '@grafana/runtime'; import { Icon, LoadingBar, useStyles2 } from '@grafana/ui'; import { t } from 'app/core/internationalization'; +import { EmptyState } from './EmptyState'; import { KBarResults } from './KBarResults'; import { ResultItem } from './ResultItem'; import { useSearchResults } from './actions/dashboardActions'; @@ -68,7 +69,7 @@ export function CommandPalette() { </div> </div> <div className={styles.resultsContainer}> - <RenderResults searchResults={searchResults} /> + <RenderResults isFetchingSearchResults={isFetchingSearchResults} searchResults={searchResults} /> </div> </div> </FocusScope> @@ -79,10 +80,11 @@ export function CommandPalette() { } interface RenderResultsProps { + isFetchingSearchResults: boolean; searchResults: CommandPaletteAction[]; } -const RenderResults = ({ searchResults }: RenderResultsProps) => { +const RenderResults = ({ isFetchingSearchResults, searchResults }: RenderResultsProps) => { const { results: kbarResults, rootActionId } = useMatches(); const styles = useStyles2(getSearchStyles); const dashboardsSectionTitle = t('command-palette.section.dashboard-search-results', 'Dashboards'); @@ -117,7 +119,11 @@ const RenderResults = ({ searchResults }: RenderResultsProps) => { return results; }, [kbarResults, dashboardsSectionTitle, dashboardResultItems, foldersSectionTitle, folderResultItems]); - return ( + const showEmptyState = !isFetchingSearchResults && items.length === 0; + + return showEmptyState ? ( + <EmptyState /> + ) : ( <KBarResults items={items} maxHeight={650} diff --git a/public/app/features/commandPalette/EmptyState.tsx b/public/app/features/commandPalette/EmptyState.tsx new file mode 100644 index 0000000000000..b7843840e0406 --- /dev/null +++ b/public/app/features/commandPalette/EmptyState.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { Box, Stack, Text } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; + +import { GrotNotFound } from '../../core/components/GrotNotFound/GrotNotFound'; + +export interface Props {} + +export const EmptyState = ({}: Props) => { + return ( + <Box paddingY={8}> + <Stack direction="column" alignItems="center" gap={3}> + <GrotNotFound width={300} /> + <Text variant="h5"> + <Trans i18nKey="command-palette.empty-state.title">No results found</Trans> + </Text> + </Stack> + </Box> + ); +}; + +EmptyState.displayName = 'EmptyState'; diff --git a/public/app/features/commandPalette/KBarResults.tsx b/public/app/features/commandPalette/KBarResults.tsx index 7dc0fa1409a2c..af01165f0b4ba 100644 --- a/public/app/features/commandPalette/KBarResults.tsx +++ b/public/app/features/commandPalette/KBarResults.tsx @@ -3,6 +3,8 @@ import { usePointerMovedSinceMount } from 'kbar/lib/utils'; import * as React from 'react'; import { useVirtual } from 'react-virtual'; +import { URLCallback } from './types'; + // From https://github.com/timc1/kbar/blob/main/src/KBarResults.tsx // TODO: Go back to KBarResults from kbar when https://github.com/timc1/kbar/issues/281 is fixed // Remember to remove dependency on react-virtual when removing this file @@ -157,7 +159,7 @@ export const KBarResults = (props: KBarResultsProps) => { {rowVirtualizer.virtualItems.map((virtualRow) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const item = itemsRef.current[virtualRow.index] as ActionImpl & { - url?: string; + url?: string | URLCallback; target?: React.HTMLAttributeAnchorTarget; }; @@ -202,7 +204,7 @@ export const KBarResults = (props: KBarResultsProps) => { return ( <a key={virtualRow.index} - href={url} + href={typeof url === 'function' ? url(search) : url} target={target} // eslint-disable-next-line @typescript-eslint/consistent-type-assertions ref={active ? (activeRef as React.RefObject<HTMLAnchorElement>) : null} diff --git a/public/app/features/commandPalette/actions/dashboardActions.ts b/public/app/features/commandPalette/actions/dashboardActions.ts index be10a89d11c05..342d78e973018 100644 --- a/public/app/features/commandPalette/actions/dashboardActions.ts +++ b/public/app/features/commandPalette/actions/dashboardActions.ts @@ -8,7 +8,7 @@ import impressionSrv from 'app/core/services/impression_srv'; import { getGrafanaSearcher } from 'app/features/search/service'; import { CommandPaletteAction } from '../types'; -import { RECENT_DASHBOARDS_PRORITY, SEARCH_RESULTS_PRORITY } from '../values'; +import { RECENT_DASHBOARDS_PRIORITY, SEARCH_RESULTS_PRIORITY } from '../values'; const MAX_SEARCH_RESULTS = 100; const MAX_RECENT_DASHBOARDS = 5; @@ -41,7 +41,7 @@ export async function getRecentDashboardActions(): Promise<CommandPaletteAction[ id: `recent-dashboards${url}`, name: `${name}`, section: t('command-palette.section.recent-dashboards', 'Recent dashboards'), - priority: RECENT_DASHBOARDS_PRORITY, + priority: RECENT_DASHBOARDS_PRIORITY, url, }; }); @@ -70,7 +70,7 @@ export async function getSearchResultActions(searchQuery: string): Promise<Comma kind === 'dashboard' ? t('command-palette.section.dashboard-search-results', 'Dashboards') : t('command-palette.section.folder-search-results', 'Folders'), - priority: SEARCH_RESULTS_PRORITY, + priority: SEARCH_RESULTS_PRIORITY, url, subtitle: data.view.dataFrame.meta?.custom?.locationInfo[location]?.name, }; diff --git a/public/app/features/commandPalette/actions/staticActions.ts b/public/app/features/commandPalette/actions/staticActions.ts index 9d4e9dbd4ff0c..b9bc77118b81d 100644 --- a/public/app/features/commandPalette/actions/staticActions.ts +++ b/public/app/features/commandPalette/actions/staticActions.ts @@ -22,13 +22,25 @@ function navTreeToActions(navTree: NavModelItem[], parents: NavModelItem[] = []) navItem = enrichHelpItem({ ...navItem }); delete navItem.url; } - const { url, target, text, isCreateAction, children, onClick } = navItem; + const { url, target, text, isCreateAction, children, onClick, keywords } = navItem; const hasChildren = Boolean(children?.length); if (!(url || onClick || hasChildren)) { continue; } + let urlOrCallback: CommandPaletteAction['url'] = url; + if ( + url && + (navItem.id === 'connections-add-new-connection' || + navItem.id === 'standalone-plugin-page-/connections/add-new-connection') + ) { + urlOrCallback = (searchQuery: string) => { + const matchingKeyword = keywords?.find((keyword) => keyword.toLowerCase().includes(searchQuery.toLowerCase())); + return matchingKeyword ? `${url}?search=${matchingKeyword}` : url; + }; + } + const section = isCreateAction ? t('command-palette.section.actions', 'Actions') : t('command-palette.section.pages', 'Pages'); @@ -39,12 +51,13 @@ function navTreeToActions(navTree: NavModelItem[], parents: NavModelItem[] = []) const action: CommandPaletteAction = { id: idForNavItem(navItem), name: text, - section: section, - url, + section, + url: urlOrCallback, target, parent: parents.length > 0 && !isCreateAction ? idForNavItem(parents[parents.length - 1]) : undefined, perform: onClick, - priority: priority, + keywords: keywords?.join(' '), + priority, subtitle: isCreateAction ? undefined : subtitle, }; diff --git a/public/app/features/commandPalette/types.ts b/public/app/features/commandPalette/types.ts index 564d691928df3..20f66a8d9f285 100644 --- a/public/app/features/commandPalette/types.ts +++ b/public/app/features/commandPalette/types.ts @@ -6,16 +6,18 @@ type NotNullable<T> = Exclude<T, null | undefined>; // Parent actions require a section, but not child actions export type CommandPaletteAction = RootCommandPaletteAction | ChildCommandPaletteAction; +export type URLCallback = (searchQuery: string) => string; + type RootCommandPaletteAction = Omit<Action, 'parent'> & { section: NotNullable<Action['section']>; priority: NotNullable<Action['priority']>; target?: React.HTMLAttributeAnchorTarget; - url?: string; + url?: string | URLCallback; }; type ChildCommandPaletteAction = Action & { parent: NotNullable<Action['parent']>; priority: NotNullable<Action['priority']>; target?: React.HTMLAttributeAnchorTarget; - url?: string; + url?: string | URLCallback; }; diff --git a/public/app/features/commandPalette/values.ts b/public/app/features/commandPalette/values.ts index b0af672ea433f..88abd798f199b 100644 --- a/public/app/features/commandPalette/values.ts +++ b/public/app/features/commandPalette/values.ts @@ -1,6 +1,6 @@ -export const RECENT_DASHBOARDS_PRORITY = 6; -export const EXTENSIONS_PRIORITY = 5; -export const ACTIONS_PRIORITY = 4; -export const DEFAULT_PRIORITY = 3; -export const PREFERENCES_PRIORITY = 2; -export const SEARCH_RESULTS_PRORITY = 1; // Dynamic actions should be below static ones so the list doesn't 'jump' when they come in +export const RECENT_DASHBOARDS_PRIORITY = 6; +export const ACTIONS_PRIORITY = 5; +export const DEFAULT_PRIORITY = 4; +export const PREFERENCES_PRIORITY = 3; +export const EXTENSIONS_PRIORITY = 2; +export const SEARCH_RESULTS_PRIORITY = 1; // Dynamic actions should be below static ones so the list doesn't 'jump' when they come in diff --git a/public/app/features/connections/tabs/ConnectData/ConnectData.test.tsx b/public/app/features/connections/tabs/ConnectData/ConnectData.test.tsx index 4d8b2ad70123b..44e8ff024d949 100644 --- a/public/app/features/connections/tabs/ConnectData/ConnectData.test.tsx +++ b/public/app/features/connections/tabs/ConnectData/ConnectData.test.tsx @@ -1,12 +1,12 @@ -import { fireEvent, render, RenderResult, screen, waitFor } from '@testing-library/react'; +import { render, RenderResult, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; -import { Provider } from 'react-redux'; +import { TestProvider } from 'test/helpers/TestProvider'; import { PluginType } from '@grafana/data'; import { contextSrv } from 'app/core/core'; import { getCatalogPluginMock, getPluginsStateMock } from 'app/features/plugins/admin/__mocks__'; import { CatalogPlugin } from 'app/features/plugins/admin/types'; -import { configureStore } from 'app/store/configureStore'; import { AccessControlAction } from 'app/types'; import { AddNewConnection } from './ConnectData'; @@ -14,13 +14,10 @@ import { AddNewConnection } from './ConnectData'; jest.mock('app/features/datasources/api'); const renderPage = (plugins: CatalogPlugin[] = []): RenderResult => { - // @ts-ignore - const store = configureStore({ plugins: getPluginsStateMock(plugins) }); - return render( - <Provider store={store}> + <TestProvider storeState={{ plugins: getPluginsStateMock(plugins) }}> <AddNewConnection /> - </Provider> + </TestProvider> ); }; @@ -30,8 +27,6 @@ const mockCatalogDataSourcePlugin = getCatalogPluginMock({ id: 'sample-data-source', }); -const originalHasPermission = contextSrv.hasPermission; - describe('Angular badge', () => { test('does not show angular badge for non-angular plugins', async () => { renderPage([ @@ -65,10 +60,6 @@ describe('Angular badge', () => { }); describe('Add new connection', () => { - beforeEach(() => { - contextSrv.hasPermission = originalHasPermission; - }); - test('renders no results if the plugins list is empty', async () => { renderPage(); @@ -91,15 +82,19 @@ describe('Add new connection', () => { renderPage([getCatalogPluginMock(), mockCatalogDataSourcePlugin]); const searchField = await screen.findByRole('textbox'); - fireEvent.change(searchField, { target: { value: 'ampl' } }); + await userEvent.type(searchField, 'ampl'); expect(await screen.findByText('Sample data source')).toBeVisible(); - fireEvent.change(searchField, { target: { value: 'cramp' } }); + await userEvent.clear(searchField); + await userEvent.type(searchField, 'cramp'); expect(screen.queryByText('No results matching your query were found.')).toBeInTheDocument(); + + await userEvent.clear(searchField); + expect(await screen.findByText('Sample data source')).toBeVisible(); }); test('shows a "No access" modal if the user does not have permissions to create datasources', async () => { - (contextSrv.hasPermission as jest.Mock) = jest.fn().mockImplementation((permission: string) => { + jest.spyOn(contextSrv, 'hasPermission').mockImplementation((permission: string) => { if (permission === AccessControlAction.DataSourcesCreate) { return false; } @@ -114,21 +109,7 @@ describe('Add new connection', () => { expect(screen.queryByText(new RegExp(exampleSentenceInModal))).not.toBeInTheDocument(); // Should show the modal if the user has no permissions - fireEvent.click(await screen.findByText('Sample data source')); + await userEvent.click(await screen.findByText('Sample data source')); expect(screen.queryByText(new RegExp(exampleSentenceInModal))).toBeInTheDocument(); }); - - test('does not show a "No access" modal but displays the details page if the user has the right permissions', async () => { - (contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(true); - - renderPage([getCatalogPluginMock(), mockCatalogDataSourcePlugin]); - const exampleSentenceInModal = 'Editors cannot add new connections.'; - - // Should not show the modal by default - expect(screen.queryByText(new RegExp(exampleSentenceInModal))).not.toBeInTheDocument(); - - // Should not show the modal when clicking a card - fireEvent.click(await screen.findByText('Sample data source')); - expect(screen.queryByText(new RegExp(exampleSentenceInModal))).not.toBeInTheDocument(); - }); }); diff --git a/public/app/features/connections/tabs/ConnectData/ConnectData.tsx b/public/app/features/connections/tabs/ConnectData/ConnectData.tsx index a9335668c6c18..edd64e78f5e4f 100644 --- a/public/app/features/connections/tabs/ConnectData/ConnectData.tsx +++ b/public/app/features/connections/tabs/ConnectData/ConnectData.tsx @@ -1,9 +1,10 @@ import { css } from '@emotion/css'; import React, { useMemo, useState } from 'react'; -import { PluginType } from '@grafana/data'; +import { GrafanaTheme2, PluginType } from '@grafana/data'; import { useStyles2, LoadingPlaceholder } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; +import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { t } from 'app/core/internationalization'; import { useGetAll } from 'app/features/plugins/admin/state/hooks'; import { AccessControlAction } from 'app/types'; @@ -16,27 +17,30 @@ import { NoAccessModal } from './NoAccessModal'; import { NoResults } from './NoResults'; import { Search } from './Search'; -const getStyles = () => ({ - spacer: css` - height: 16px; - `, - modal: css` - width: 500px; - `, - modalContent: css` - overflow: visible; - `, +const getStyles = (theme: GrafanaTheme2) => ({ + spacer: css({ + height: theme.spacing(2), + }), + modal: css({ + width: '500px', + }), + modalContent: css({ + overflow: 'visible', + }), }); export function AddNewConnection() { - const [searchTerm, setSearchTerm] = useState(''); + const [queryParams, setQueryParams] = useQueryParams(); + const searchTerm = queryParams.search ? String(queryParams.search) : ''; const [isNoAccessModalOpen, setIsNoAccessModalOpen] = useState(false); const [focusedItem, setFocusedItem] = useState<CardGridItem | null>(null); const styles = useStyles2(getStyles); const canCreateDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate); const handleSearchChange = (e: React.FormEvent<HTMLInputElement>) => { - setSearchTerm(e.currentTarget.value.toLowerCase()); + setQueryParams({ + search: e.currentTarget.value.toLowerCase(), + }); }; const { error, plugins, isLoading } = useGetAll({ @@ -82,7 +86,7 @@ export function AddNewConnection() { return ( <> {focusedItem && <NoAccessModal item={focusedItem} isOpen={isNoAccessModalOpen} onDismiss={closeModal} />} - <Search onChange={handleSearchChange} /> + <Search onChange={handleSearchChange} value={searchTerm} /> {/* We need this extra spacing when there are no filters */} <div className={styles.spacer} /> <CategoryHeader iconName="database" label={categoryHeaderLabel} /> diff --git a/public/app/features/connections/tabs/ConnectData/Search/Search.tsx b/public/app/features/connections/tabs/ConnectData/Search/Search.tsx index 626f3b1435027..bddb9f0579f33 100644 --- a/public/app/features/connections/tabs/ConnectData/Search/Search.tsx +++ b/public/app/features/connections/tabs/ConnectData/Search/Search.tsx @@ -1,33 +1,42 @@ import { css } from '@emotion/css'; -import React, { FC } from 'react'; +import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Icon, Input, useStyles2 } from '@grafana/ui'; import { t } from 'app/core/internationalization'; const getStyles = (theme: GrafanaTheme2) => ({ - searchContainer: css` - display: flex; - margin: 16px 0; - justify-content: space-between; + searchContainer: css({ + display: 'flex', + justifyContent: 'space-between', - position: sticky; - top: 0; - background-color: ${theme.colors.background.primary}; - z-index: 2; - padding: ${theme.spacing(2)}; - margin: 0 -${theme.spacing(2)}; - `, + position: 'sticky', + top: 0, + backgroundColor: theme.colors.background.primary, + zIndex: 2, + padding: theme.spacing(2, 0), + }), }); const placeholder = t('connections.search.placeholder', 'Search all'); -export const Search: FC<{ onChange: (e: React.FormEvent<HTMLInputElement>) => void }> = ({ onChange }) => { +export interface Props { + onChange: (e: React.FormEvent<HTMLInputElement>) => void; + value: string | undefined; +} + +export const Search = ({ onChange, value }: Props) => { const styles = useStyles2(getStyles); return ( <div className={styles.searchContainer}> - <Input onChange={onChange} prefix={<Icon name="search" />} placeholder={placeholder} aria-label="Search all" /> + <Input + value={value} + onChange={onChange} + prefix={<Icon name="search" />} + placeholder={placeholder} + aria-label="Search all" + /> </div> ); }; diff --git a/public/app/features/correlations/CorrelationsPage.test.tsx b/public/app/features/correlations/CorrelationsPage.test.tsx index 478b13ee1b77a..f127acf5b0a9d 100644 --- a/public/app/features/correlations/CorrelationsPage.test.tsx +++ b/public/app/features/correlations/CorrelationsPage.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor, screen, fireEvent, within, Matcher, getByRole } from '@testing-library/react'; +import { render, waitFor, screen, within, Matcher, getByRole } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { merge, uniqueId } from 'lodash'; import React from 'react'; @@ -9,6 +9,7 @@ import { MockDataSourceApi } from 'test/mocks/datasource_srv'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { DataSourcePluginMeta, SupportedTransformationType } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { BackendSrv, setDataSourceSrv, BackendSrvRequest, reportInteraction } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { configureStore } from 'app/store/configureStore'; @@ -270,13 +271,13 @@ describe('CorrelationsPage', () => { // step 2: // set target datasource picker value - fireEvent.keyDown(screen.getByLabelText(/^target/i), { keyCode: 40 }); + await userEvent.click(screen.getByLabelText(/^target/i)); await userEvent.click(screen.getByText('prometheus')); await userEvent.click(await screen.findByRole('button', { name: /next$/i })); // step 3: // set source datasource picker value - fireEvent.keyDown(screen.getByLabelText(/^source/i), { keyCode: 40 }); + await userEvent.click(screen.getByLabelText(/^source/i)); await userEvent.click(screen.getByText('loki')); await userEvent.click(await screen.findByRole('button', { name: /add$/i })); @@ -427,14 +428,16 @@ describe('CorrelationsPage', () => { // step 2: // set target datasource picker value - fireEvent.keyDown(screen.getByLabelText(/^target/i), { keyCode: 40 }); + await userEvent.click(screen.getByLabelText(/^target/i)); await userEvent.click(screen.getByText('elastic')); await userEvent.click(await screen.findByRole('button', { name: /next$/i })); // step 3: // set source datasource picker value - fireEvent.keyDown(screen.getByLabelText(/^source/i), { keyCode: 40 }); - await userEvent.click(within(screen.getByLabelText('Select options menu')).getByText('prometheus')); + await userEvent.click(screen.getByLabelText(/^source/i)); + await userEvent.click( + within(screen.getByTestId(selectors.components.DataSourcePicker.dataSourceList)).getByText('prometheus') + ); await userEvent.clear(screen.getByRole('textbox', { name: /results field/i })); await userEvent.type(screen.getByRole('textbox', { name: /results field/i }), 'Line'); @@ -538,7 +541,7 @@ describe('CorrelationsPage', () => { // select Regex, be sure expression field is not disabled and contains the former expression openMenu(typeFilterSelect[0]); - await userEvent.click(screen.getByText('Regular expression', { selector: 'span' })); + await userEvent.click(screen.getByText('Regular expression')); expressionInput = screen.queryByLabelText(/expression/i); expect(expressionInput).toBeInTheDocument(); expect(expressionInput).toBeEnabled(); @@ -554,7 +557,8 @@ describe('CorrelationsPage', () => { await userEvent.click(screen.getByRole('button', { name: /add transformation/i })); typeFilterSelect = screen.getAllByLabelText('Type'); openMenu(typeFilterSelect[0]); - await userEvent.click(screen.getByText('Regular expression')); + const menu = await screen.findByLabelText('Select options menu'); + await userEvent.click(within(menu).getByText('Regular expression')); expressionInput = screen.queryByLabelText(/expression/i); expect(expressionInput).toBeInTheDocument(); expect(expressionInput).toBeEnabled(); diff --git a/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx b/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx index 4fad48a231086..f62050a187436 100644 --- a/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx +++ b/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx @@ -12,6 +12,7 @@ import { getVariableUsageInfo } from '../../explore/utils/links'; import { TransformationsEditor } from './TransformationsEditor'; import { useCorrelationsFormContext } from './correlationsFormContext'; +import { FormDTO } from './types'; import { getInputId } from './utils'; const getStyles = (theme: GrafanaTheme2) => ({ @@ -25,7 +26,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ }); export const ConfigureCorrelationSourceForm = () => { - const { control, formState, register, getValues } = useFormContext(); + const { control, formState, register, getValues } = useFormContext<FormDTO>(); const styles = useStyles2(getStyles); const withDsUID = (fn: Function) => (ds: DataSourceInstanceSettings) => fn(ds.uid); diff --git a/public/app/features/correlations/Forms/ConfigureCorrelationTargetForm.tsx b/public/app/features/correlations/Forms/ConfigureCorrelationTargetForm.tsx index 2b5651d1bc380..f8f4c59104f62 100644 --- a/public/app/features/correlations/Forms/ConfigureCorrelationTargetForm.tsx +++ b/public/app/features/correlations/Forms/ConfigureCorrelationTargetForm.tsx @@ -8,9 +8,10 @@ import { DataSourcePicker } from 'app/features/datasources/components/picker/Dat import { QueryEditorField } from './QueryEditorField'; import { useCorrelationsFormContext } from './correlationsFormContext'; +import { FormDTO } from './types'; export const ConfigureCorrelationTargetForm = () => { - const { control, formState } = useFormContext(); + const { control, formState } = useFormContext<FormDTO>(); const withDsUID = (fn: Function) => (ds: DataSourceInstanceSettings) => fn(ds.uid); const { correlation } = useCorrelationsFormContext(); const targetUID: string | undefined = useWatch({ name: 'targetUID' }) || correlation?.targetUID; diff --git a/public/app/features/correlations/Forms/TransformationEditorRow.tsx b/public/app/features/correlations/Forms/TransformationEditorRow.tsx index f4e467651d85f..8c549c8709f7e 100644 --- a/public/app/features/correlations/Forms/TransformationEditorRow.tsx +++ b/public/app/features/correlations/Forms/TransformationEditorRow.tsx @@ -5,7 +5,7 @@ import { useFormContext, useWatch } from 'react-hook-form'; import { Field, Icon, IconButton, Input, Label, Select, Stack, Tooltip, useStyles2 } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; -import { getSupportedTransTypeDetails, getTransformOptions } from './types'; +import { FormDTO, getSupportedTransTypeDetails, getTransformOptions } from './types'; type Props = { index: number; value: Record<string, string>; @@ -23,7 +23,7 @@ const getStyles = () => ({ const TransformationEditorRow = (props: Props) => { const { index, value: defaultValue, readOnly, remove } = props; - const { control, formState, register, setValue, watch, getValues } = useFormContext(); + const { control, formState, register, setValue, watch, getValues } = useFormContext<FormDTO>(); const [keptVals, setKeptVals] = useState<{ expression?: string; mapValue?: string }>({}); @@ -63,34 +63,37 @@ const TransformationEditorRow = (props: Props) => { </Stack> } invalid={!!formState.errors?.config?.transformations?.[index]?.type} - error={formState.errors?.config?.transformations?.[index]?.type?.message} + error={formState.errors?.config?.transformations?.[index]?.message} validationMessageHorizontalOverflow={true} > <Select value={typeValue} onChange={(value) => { if (!readOnly) { - const currentValues = getValues().config.transformations[index]; - setKeptVals({ - expression: currentValues.expression, - mapValue: currentValues.mapValue, - }); + const currentValues = getValues()?.config?.transformations?.[index]; + if (currentValues) { + setKeptVals({ + expression: currentValues.expression, + mapValue: currentValues.mapValue, + }); + } + if (value.value) { + const newValueDetails = getSupportedTransTypeDetails(value.value); - const newValueDetails = getSupportedTransTypeDetails(value.value); + if (newValueDetails.expressionDetails.show) { + setValue(`config.transformations.${index}.expression`, keptVals?.expression || ''); + } else { + setValue(`config.transformations.${index}.expression`, ''); + } - if (newValueDetails.expressionDetails.show) { - setValue(`config.transformations.${index}.expression`, keptVals?.expression || ''); - } else { - setValue(`config.transformations.${index}.expression`, ''); - } + if (newValueDetails.mapValueDetails.show) { + setValue(`config.transformations.${index}.mapValue`, keptVals?.mapValue || ''); + } else { + setValue(`config.transformations.${index}.mapValue`, ''); + } - if (newValueDetails.mapValueDetails.show) { - setValue(`config.transformations.${index}.mapValue`, keptVals?.mapValue || ''); - } else { - setValue(`config.transformations.${index}.mapValue`, ''); + setValue(`config.transformations.${index}.type`, value.value); } - - setValue(`config.transformations.${index}.type`, value.value); } }} options={transformOptions} diff --git a/public/app/features/correlations/Forms/TransformationsEditor.tsx b/public/app/features/correlations/Forms/TransformationsEditor.tsx index f043731ddac3e..3d83c43126dec 100644 --- a/public/app/features/correlations/Forms/TransformationsEditor.tsx +++ b/public/app/features/correlations/Forms/TransformationsEditor.tsx @@ -1,72 +1,56 @@ -import { css } from '@emotion/css'; import React from 'react'; -import { useFormContext } from 'react-hook-form'; +import { useFormContext, useFieldArray } from 'react-hook-form'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Button, FieldArray, Stack, useStyles2 } from '@grafana/ui'; +import { Button, Stack, Text } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; import TransformationsEditorRow from './TransformationEditorRow'; type Props = { readOnly: boolean }; -const getStyles = (theme: GrafanaTheme2) => ({ - heading: css({ - fontSize: theme.typography.h5.fontSize, - fontWeight: theme.typography.fontWeightRegular, - }), -}); - export const TransformationsEditor = (props: Props) => { const { control, register } = useFormContext(); + const { fields, append, remove } = useFieldArray({ control, name: 'config.transformations' }); const { readOnly } = props; - const styles = useStyles2(getStyles); - return ( <> <input type="hidden" {...register('id')} /> - <FieldArray name="config.transformations" control={control}> - {({ fields, append, remove }) => ( - <> - <Stack direction="column" alignItems="flex-start"> - <div className={styles.heading}> - <Trans i18nKey="correlations.transform.heading">Transformations</Trans> - </div> - {fields.length === 0 && ( - <div> - <Trans i18nKey="correlations.transform.no-transform">No transformations defined.</Trans> - </div> - )} - {fields.length > 0 && ( - <div> - {fields.map((fieldVal, index) => { - return ( - <TransformationsEditorRow - key={index} - value={fieldVal} - index={index} - readOnly={readOnly} - remove={remove} - /> - ); - })} - </div> - )} - {!readOnly && ( - <Button - icon="plus" - onClick={() => append({ type: undefined }, { shouldFocus: false })} - variant="secondary" - type="button" - > - <Trans i18nKey="correlations.transform.add-button">Add transformation</Trans> - </Button> - )} - </Stack> - </> + <Stack direction="column" alignItems="flex-start"> + <Text variant={'h5'}> + <Trans i18nKey="correlations.transform.heading">Transformations</Trans> + </Text> + {fields.length === 0 && ( + <div> + <Trans i18nKey="correlations.transform.no-transform">No transformations defined.</Trans> + </div> + )} + {fields.length > 0 && ( + <div> + {fields.map((fieldVal, index) => { + return ( + <TransformationsEditorRow + key={index} + value={fieldVal} + index={index} + readOnly={readOnly} + remove={remove} + /> + ); + })} + </div> + )} + {!readOnly && ( + <Button + icon="plus" + onClick={() => append({ type: undefined }, { shouldFocus: false })} + variant="secondary" + type="button" + > + <Trans i18nKey="correlations.transform.add-button">Add transformation</Trans> + </Button> )} - </FieldArray> + </Stack> </> ); }; diff --git a/public/app/features/correlations/components/Wizard/types.ts b/public/app/features/correlations/components/Wizard/types.ts index 11962faba9c9e..eec5094fd2f22 100644 --- a/public/app/features/correlations/components/Wizard/types.ts +++ b/public/app/features/correlations/components/Wizard/types.ts @@ -1,11 +1,11 @@ import { ComponentType } from 'react'; -import { DeepPartial, UnpackNestedValue } from 'react-hook-form'; +import { DefaultValues } from 'react-hook-form'; export type WizardProps<T> = { /** * Initial values for the form */ - defaultValues?: UnpackNestedValue<DeepPartial<T>>; + defaultValues?: DefaultValues<T>; /** * List of steps/pages in the wizard. diff --git a/public/app/features/correlations/types.ts b/public/app/features/correlations/types.ts index 8e673beb9ff64..061f94204496b 100644 --- a/public/app/features/correlations/types.ts +++ b/public/app/features/correlations/types.ts @@ -30,7 +30,7 @@ type CorrelationConfigType = 'query'; export interface CorrelationConfig { field: string; - target: object; + target: object; // this contains anything that would go in the query editor, so any extension off DataQuery a datasource would have, and needs to be generic type: CorrelationConfigType; transformations?: DataLinkTransformationConfig[]; } diff --git a/public/app/features/correlations/useCorrelations.ts b/public/app/features/correlations/useCorrelations.ts index 7ed2dfde64448..7b1a9c83c8a6c 100644 --- a/public/app/features/correlations/useCorrelations.ts +++ b/public/app/features/correlations/useCorrelations.ts @@ -2,7 +2,7 @@ import { useAsyncFn } from 'react-use'; import { lastValueFrom } from 'rxjs'; import { DataSourceInstanceSettings } from '@grafana/data'; -import { getDataSourceSrv, FetchResponse, logWarning } from '@grafana/runtime'; +import { getDataSourceSrv, FetchResponse } from '@grafana/runtime'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { @@ -15,6 +15,7 @@ import { UpdateCorrelationParams, UpdateCorrelationResponse, } from './types'; +import { correlationsLogger } from './utils'; export interface CorrelationsResponse { correlations: Correlation[]; @@ -47,7 +48,7 @@ const toEnrichedCorrelationData = ({ // This logging is to check if there are any customers who did not migrate existing correlations. // See Deprecation Notice in https://github.com/grafana/grafana/pull/72258 for more details if (correlation?.orgId === undefined || correlation?.orgId === null || correlation?.orgId === 0) { - logWarning('Invalid correlation config: Missing org id.', { module: 'Explore' }); + correlationsLogger.logWarning('Invalid correlation config: Missing org id.'); } if ( @@ -62,8 +63,7 @@ const toEnrichedCorrelationData = ({ target: targetDatasource, }; } else { - logWarning(`Invalid correlation config: Missing source or target.`, { - module: 'Explore', + correlationsLogger.logWarning(`Invalid correlation config: Missing source or target.`, { source: JSON.stringify(sourceDatasource), target: JSON.stringify(targetDatasource), }); diff --git a/public/app/features/correlations/utils.ts b/public/app/features/correlations/utils.ts index 09cba67f87e71..64d4cf386b972 100644 --- a/public/app/features/correlations/utils.ts +++ b/public/app/features/correlations/utils.ts @@ -1,7 +1,7 @@ import { lastValueFrom } from 'rxjs'; import { DataFrame, DataLinkConfigOrigin } from '@grafana/data'; -import { getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; +import { createMonitoringLogger, getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; import { ExploreItemState } from 'app/types'; import { formatValueName } from '../explore/PrometheusListView/ItemLabels'; @@ -108,3 +108,5 @@ export const generateDefaultLabel = async (sourcePane: ExploreItemState, targetP : ''; }); }; + +export const correlationsLogger = createMonitoringLogger('features.correlations'); diff --git a/public/app/features/dashboard-scene/embedding/EmbeddedDashboard.tsx b/public/app/features/dashboard-scene/embedding/EmbeddedDashboard.tsx new file mode 100644 index 0000000000000..a0e9854173e57 --- /dev/null +++ b/public/app/features/dashboard-scene/embedding/EmbeddedDashboard.tsx @@ -0,0 +1,125 @@ +import { css } from '@emotion/css'; +import React, { useEffect, useState } from 'react'; + +import { GrafanaTheme2, urlUtil } from '@grafana/data'; +import { EmbeddedDashboardProps } from '@grafana/runtime'; +import { SceneObjectStateChangedEvent, sceneUtils } from '@grafana/scenes'; +import { Spinner, Alert, useStyles2 } from '@grafana/ui'; +import { DashboardRoutes } from 'app/types'; + +import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager'; +import { DashboardScene } from '../scene/DashboardScene'; + +export function EmbeddedDashboard(props: EmbeddedDashboardProps) { + const stateManager = getDashboardScenePageStateManager(); + const { dashboard, loadError } = stateManager.useState(); + + useEffect(() => { + stateManager.loadDashboard({ uid: props.uid!, route: DashboardRoutes.Embedded }); + return () => { + stateManager.clearState(); + }; + }, [stateManager, props.uid]); + + if (loadError) { + return ( + <Alert severity="error" title="Failed to load dashboard"> + {loadError} + </Alert> + ); + } + + if (!dashboard) { + return <Spinner />; + } + + return <EmbeddedDashboardRenderer model={dashboard} {...props} />; +} + +interface RendererProps extends EmbeddedDashboardProps { + model: DashboardScene; +} + +function EmbeddedDashboardRenderer({ model, initialState, onStateChange }: RendererProps) { + const [isActive, setIsActive] = useState(false); + const { controls, body } = model.useState(); + const styles = useStyles2(getStyles); + + useEffect(() => { + setIsActive(true); + + if (initialState) { + const searchParms = new URLSearchParams(initialState); + sceneUtils.syncStateFromSearchParams(model, searchParms); + } + + return model.activate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [model]); + + useSubscribeToEmbeddedUrlState(onStateChange, model); + + if (!isActive) { + return null; + } + + return ( + <div className={styles.canvas}> + {controls && <controls.Component model={controls} />} + <div className={styles.body}> + <body.Component model={body} /> + </div> + </div> + ); +} + +function useSubscribeToEmbeddedUrlState(onStateChange: ((state: string) => void) | undefined, model: DashboardScene) { + useEffect(() => { + if (!onStateChange) { + return; + } + + let lastState = ''; + const sub = model.subscribeToEvent(SceneObjectStateChangedEvent, (evt) => { + if (evt.payload.changedObject.urlSync) { + const state = sceneUtils.getUrlState(model); + const stateAsString = urlUtil.renderUrl('', state); + + if (lastState !== stateAsString) { + lastState = stateAsString; + onStateChange(stateAsString); + } + } + }); + + return () => sub.unsubscribe(); + }, [model, onStateChange]); +} + +function getStyles(theme: GrafanaTheme2) { + return { + canvas: css({ + label: 'canvas-content', + display: 'flex', + flexDirection: 'column', + flexBasis: '100%', + flexGrow: 1, + }), + body: css({ + label: 'body', + flexGrow: 1, + display: 'flex', + gap: '8px', + marginBottom: theme.spacing(2), + }), + controls: css({ + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: theme.spacing(1), + top: 0, + zIndex: theme.zIndex.navbarFixed, + padding: theme.spacing(0, 0, 2, 0), + }), + }; +} diff --git a/public/app/features/dashboard-scene/embedding/EmbeddedDashboardLazy.tsx b/public/app/features/dashboard-scene/embedding/EmbeddedDashboardLazy.tsx new file mode 100644 index 0000000000000..b52299cb9bcd2 --- /dev/null +++ b/public/app/features/dashboard-scene/embedding/EmbeddedDashboardLazy.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { EmbeddedDashboardProps } from '@grafana/runtime'; + +export function EmbeddedDashboardLazy(props: EmbeddedDashboardProps) { + return <Component {...props} />; +} + +const Component = React.lazy(async () => { + const { EmbeddedDashboard } = await import(/* webpackChunkName: "EmbeddedDashboard" */ './EmbeddedDashboard'); + return { default: EmbeddedDashboard }; +}); diff --git a/public/app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage.tsx b/public/app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage.tsx new file mode 100644 index 0000000000000..065469b08c62e --- /dev/null +++ b/public/app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage.tsx @@ -0,0 +1,22 @@ +import React, { useState } from 'react'; + +import { Box } from '@grafana/ui'; +import { Page } from 'app/core/components/Page/Page'; + +import { EmbeddedDashboard } from './EmbeddedDashboard'; + +export function EmbeddedDashboardTestPage() { + const [state, setState] = useState('?from=now-5m&to=now'); + + return ( + <Page + navId="dashboards/browse" + pageNav={{ text: 'Embedding dashboard', subTitle: 'Showing dashboard: Panel Tests - Pie chart' }} + > + <Box paddingY={2}>Internal url state: {state}</Box> + <EmbeddedDashboard uid="lVE-2YFMz" initialState={state} onStateChange={setState} /> + </Page> + ); +} + +export default EmbeddedDashboardTestPage; diff --git a/public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.test.tsx b/public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.test.tsx new file mode 100644 index 0000000000000..1b42b2dad6686 --- /dev/null +++ b/public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { FieldType, getDefaultTimeRange, LoadingState, toDataFrame } from '@grafana/data'; +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; +import { + SceneGridItem, + SceneGridLayout, + SceneQueryRunner, + SceneTimeRange, + VizPanel, + VizPanelMenu, +} from '@grafana/scenes'; + +import { DashboardScene } from '../../scene/DashboardScene'; +import { VizPanelLinks, VizPanelLinksMenu } from '../../scene/PanelLinks'; +import { panelMenuBehavior } from '../../scene/PanelMenuBehavior'; + +import { HelpWizard } from './HelpWizard'; + +async function setup() { + const { panel } = await buildTestScene(); + panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false }); + + return render(<HelpWizard panel={panel} onClose={() => {}} />); +} +describe('SupportSnapshot', () => { + it('Can render', async () => { + setup(); + expect(await screen.findByRole('button', { name: 'Dashboard (3.50 KiB)' })).toBeInTheDocument(); + }); +}); + +async function buildTestScene() { + const menu = new VizPanelMenu({ + $behaviors: [panelMenuBehavior], + }); + + const panel = new VizPanel({ + title: 'Panel A', + pluginId: 'timeseries', + key: 'panel-12', + menu, + titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })], + $data: new SceneQueryRunner({ + data: { + state: LoadingState.Done, + series: [ + toDataFrame({ + name: 'http_requests_total', + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2, 3] }, + { name: 'Value', type: FieldType.number, values: [11, 22, 33] }, + ], + }), + ], + timeRange: getDefaultTimeRange(), + }, + datasource: { uid: 'my-uid' }, + queries: [{ query: 'QueryA', refId: 'A' }], + }), + }); + + const scene = new DashboardScene({ + title: 'My dashboard', + uid: 'dash-1', + tags: ['database', 'panel'], + $timeRange: new SceneTimeRange({ + from: 'now-5m', + to: 'now', + timeZone: 'Africa/Abidjan', + }), + meta: { + canEdit: true, + isEmbedded: false, + }, + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + y: 0, + width: 10, + height: 12, + body: panel, + }), + ], + }), + }); + + await new Promise((r) => setTimeout(r, 1)); + + return { scene, panel, menu }; +} diff --git a/public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.tsx b/public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.tsx new file mode 100644 index 0000000000000..ca08356354a71 --- /dev/null +++ b/public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.tsx @@ -0,0 +1,229 @@ +import { css } from '@emotion/css'; +import React, { useMemo, useEffect } from 'react'; +import AutoSizer from 'react-virtualized-auto-sizer'; + +import { GrafanaTheme2, FeatureState } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { VizPanel } from '@grafana/scenes'; +import { + Drawer, + Tab, + TabsBar, + CodeEditor, + useStyles2, + Field, + HorizontalGroup, + InlineSwitch, + Button, + Spinner, + Alert, + FeatureBadge, + Select, + ClipboardButton, + Icon, + Stack, +} from '@grafana/ui'; +import { contextSrv } from 'app/core/services/context_srv'; +import { AccessControlAction } from 'app/types'; + +import { ShowMessage, SnapshotTab, SupportSnapshotService } from './SupportSnapshotService'; + +interface Props { + panel: VizPanel; + onClose: () => void; +} + +export function HelpWizard({ panel, onClose }: Props) { + const styles = useStyles2(getStyles); + const service = useMemo(() => new SupportSnapshotService(panel), [panel]); + const plugin = panel.getPlugin(); + + const { + currentTab, + loading, + error, + options, + showMessage, + snapshotSize, + markdownText, + snapshotText, + randomize, + panelTitle, + scene, + } = service.useState(); + + useEffect(() => { + service.buildDebugDashboard(); + }, [service, plugin, randomize]); + + if (!plugin) { + return null; + } + + const tabs = [ + { label: 'Snapshot', value: SnapshotTab.Support }, + { label: 'Data', value: SnapshotTab.Data }, + ]; + + const hasSupportBundleAccess = + config.supportBundlesEnabled && contextSrv.hasPermission(AccessControlAction.ActionSupportBundlesCreate); + + return ( + <Drawer + title={`Get help with this panel`} + size="lg" + onClose={onClose} + subtitle={ + <Stack direction="column" gap={1}> + <Stack direction="row" gap={1}> + <FeatureBadge featureState={FeatureState.beta} /> + <a + href="https://grafana.com/docs/grafana/latest/troubleshooting/" + target="blank" + className="external-link" + rel="noopener noreferrer" + > + Troubleshooting docs <Icon name="external-link-alt" /> + </a> + </Stack> + <span className="muted"> + To request troubleshooting help, send a snapshot of this panel to Grafana Labs Technical Support. The + snapshot contains query response data and panel settings. + </span> + {hasSupportBundleAccess && ( + <span className="muted"> + You can also retrieve a support bundle containing information concerning your Grafana instance and + configured datasources in the <a href="/support-bundles">support bundles section</a>. + </span> + )} + </Stack> + } + tabs={ + <TabsBar> + {tabs.map((t, index) => ( + <Tab + key={`${t.value}-${index}`} + label={t.label} + active={t.value === currentTab} + onChangeTab={() => service.onCurrentTabChange(t.value!)} + /> + ))} + </TabsBar> + } + > + {loading && <Spinner />} + {error && <Alert title={error.title}>{error.message}</Alert>} + + {currentTab === SnapshotTab.Data && ( + <div className={styles.code}> + <div className={styles.opts}> + <Field label="Template" className={styles.field}> + <Select options={options} value={showMessage} onChange={service.onShowMessageChange} /> + </Field> + + {showMessage === ShowMessage.GithubComment ? ( + <ClipboardButton icon="copy" getText={service.onGetMarkdownForClipboard}> + Copy to clipboard + </ClipboardButton> + ) : ( + <Button icon="download-alt" onClick={service.onDownloadDashboard}> + Download ({snapshotSize}) + </Button> + )} + </div> + <AutoSizer disableWidth> + {({ height }) => ( + <CodeEditor + width="100%" + height={height} + language={showMessage === ShowMessage.GithubComment ? 'markdown' : 'json'} + showLineNumbers={true} + showMiniMap={true} + value={showMessage === ShowMessage.GithubComment ? markdownText : snapshotText} + readOnly={false} + onBlur={service.onSetSnapshotText} + /> + )} + </AutoSizer> + </div> + )} + {currentTab === SnapshotTab.Support && ( + <> + <Field + label="Randomize data" + description="Modify the original data to hide sensitve information. Note the lengths will stay the same, and duplicate values will be equal." + > + <HorizontalGroup> + <InlineSwitch + label="Labels" + id="randomize-labels" + showLabel={true} + value={Boolean(randomize.labels)} + onChange={() => service.onToggleRandomize('labels')} + /> + <InlineSwitch + label="Field names" + id="randomize-field-names" + showLabel={true} + value={Boolean(randomize.names)} + onChange={() => service.onToggleRandomize('names')} + /> + <InlineSwitch + label="String values" + id="randomize-string-values" + showLabel={true} + value={Boolean(randomize.values)} + onChange={() => service.onToggleRandomize('values')} + /> + </HorizontalGroup> + </Field> + + <Field label="Support snapshot" description={`Panel: ${panelTitle}`}> + <Stack> + <Button icon="download-alt" onClick={service.onDownloadDashboard}> + Dashboard ({snapshotSize}) + </Button> + <ClipboardButton + icon="github" + getText={service.onGetMarkdownForClipboard} + title="Copy a complete GitHub comment to the clipboard" + > + Copy to clipboard + </ClipboardButton> + </Stack> + </Field> + + <AutoSizer disableWidth> + {({ height }) => ( + <div style={{ height, overflow: 'auto' }}>{scene && <scene.Component model={scene} />}</div> + )} + </AutoSizer> + </> + )} + </Drawer> + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + code: css({ + flexGrow: 1, + height: '100%', + overflow: 'scroll', + }), + field: css({ + width: '100%', + }), + opts: css({ + display: 'flex', + width: '100%', + flexGrow: 0, + alignItems: 'center', + justifyContent: 'flex-end', + + '& button': { + marginLeft: theme.spacing(1), + }, + }), + }; +}; diff --git a/public/app/features/dashboard-scene/inspect/HelpWizard/SupportSnapshotService.test.ts b/public/app/features/dashboard-scene/inspect/HelpWizard/SupportSnapshotService.test.ts new file mode 100644 index 0000000000000..f8cc6fba85797 --- /dev/null +++ b/public/app/features/dashboard-scene/inspect/HelpWizard/SupportSnapshotService.test.ts @@ -0,0 +1,140 @@ +import { FieldType, getDefaultTimeRange, LoadingState, toDataFrame } from '@grafana/data'; +import { + SceneGridItem, + SceneGridLayout, + SceneQueryRunner, + SceneTimeRange, + VizPanel, + VizPanelMenu, +} from '@grafana/scenes'; + +import { DashboardScene } from '../../scene/DashboardScene'; +import { VizPanelLinks, VizPanelLinksMenu } from '../../scene/PanelLinks'; +import { panelMenuBehavior } from '../../scene/PanelMenuBehavior'; + +import { SnapshotTab, SupportSnapshotService } from './SupportSnapshotService'; + +async function setup() { + const { panel } = await buildTestScene(); + + return new SupportSnapshotService(panel); +} + +describe('SupportSnapshotService', () => { + it('Can create it with default state', async () => { + const service = await setup(); + expect(service.state.currentTab).toBe(SnapshotTab.Support); + }); + + it('Can can build support snapshot dashboard', async () => { + const service = await setup(); + await service.buildDebugDashboard(); + expect(service.state.snapshot.panels[0].targets[0]).toMatchInlineSnapshot(` + { + "datasource": { + "type": "grafana", + "uid": "grafana", + }, + "queryType": "snapshot", + "refId": "A", + "snapshot": [ + { + "data": { + "values": [ + [ + 1, + 2, + 3, + ], + [ + 11, + 22, + 33, + ], + ], + }, + "schema": { + "fields": [ + { + "config": {}, + "name": "Time", + "type": "time", + }, + { + "config": {}, + "name": "Value", + "type": "number", + }, + ], + "meta": undefined, + "name": "http_requests_total", + "refId": undefined, + }, + }, + ], + } + `); + }); +}); + +async function buildTestScene() { + const menu = new VizPanelMenu({ + $behaviors: [panelMenuBehavior], + }); + + const panel = new VizPanel({ + title: 'Panel A', + pluginId: 'timeseries', + key: 'panel-12', + menu, + titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })], + $data: new SceneQueryRunner({ + data: { + state: LoadingState.Done, + series: [ + toDataFrame({ + name: 'http_requests_total', + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2, 3] }, + { name: 'Value', type: FieldType.number, values: [11, 22, 33] }, + ], + }), + ], + timeRange: getDefaultTimeRange(), + }, + datasource: { uid: 'my-uid' }, + queries: [{ query: 'QueryA', refId: 'A' }], + }), + }); + + const scene = new DashboardScene({ + title: 'My dashboard', + uid: 'dash-1', + tags: ['database', 'panel'], + $timeRange: new SceneTimeRange({ + from: 'now-5m', + to: 'now', + timeZone: 'Africa/Abidjan', + }), + meta: { + canEdit: true, + isEmbedded: false, + }, + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + y: 0, + width: 10, + height: 12, + body: panel, + }), + ], + }), + }); + + await new Promise((r) => setTimeout(r, 1)); + + return { scene, panel, menu }; +} diff --git a/public/app/features/dashboard-scene/inspect/HelpWizard/SupportSnapshotService.ts b/public/app/features/dashboard-scene/inspect/HelpWizard/SupportSnapshotService.ts new file mode 100644 index 0000000000000..c58e00c6667c3 --- /dev/null +++ b/public/app/features/dashboard-scene/inspect/HelpWizard/SupportSnapshotService.ts @@ -0,0 +1,133 @@ +import saveAs from 'file-saver'; + +import { dateTimeFormat, formattedValueToString, getValueFormat, SelectableValue } from '@grafana/data'; +import { sceneGraph, SceneObject, VizPanel } from '@grafana/scenes'; +import { StateManagerBase } from 'app/core/services/StateManagerBase'; + +import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene'; + +import { Randomize } from './randomizer'; +import { getDebugDashboard, getGithubMarkdown } from './utils'; + +interface SupportSnapshotState { + currentTab: SnapshotTab; + showMessage: ShowMessage; + options: Array<SelectableValue<ShowMessage>>; + snapshotText: string; + markdownText: string; + snapshotSize?: string; + randomize: Randomize; + loading?: boolean; + error?: { + title: string; + message: string; + }; + panel: VizPanel; + panelTitle: string; + + // eslint-disable-next-line + snapshot?: any; + snapshotUpdate: number; + scene?: SceneObject; +} + +export enum SnapshotTab { + Support, + Data, +} + +export enum ShowMessage { + PanelSnapshot, + GithubComment, +} + +export class SupportSnapshotService extends StateManagerBase<SupportSnapshotState> { + constructor(panel: VizPanel) { + super({ + panel, + panelTitle: sceneGraph.interpolate(panel, panel.state.title, {}, 'text'), + currentTab: SnapshotTab.Support, + showMessage: ShowMessage.GithubComment, + snapshotText: '', + markdownText: '', + randomize: {}, + snapshotUpdate: 0, + options: [ + { + label: 'GitHub comment', + description: 'Copy and paste this message into a GitHub issue or comment', + value: ShowMessage.GithubComment, + }, + { + label: 'Panel support snapshot', + description: 'Dashboard JSON used to help troubleshoot visualization issues', + value: ShowMessage.PanelSnapshot, + }, + ], + }); + } + + async buildDebugDashboard() { + const { panel, randomize, snapshotUpdate } = this.state; + const snapshot = await getDebugDashboard(panel, randomize, sceneGraph.getTimeRange(panel).state.value); + const snapshotText = JSON.stringify(snapshot, null, 2); + const markdownText = getGithubMarkdown(panel, snapshotText); + const snapshotSize = formattedValueToString(getValueFormat('bytes')(snapshotText?.length ?? 0)); + + let scene: SceneObject | undefined = undefined; + if (snapshot) { + try { + const dash = transformSaveModelToScene({ dashboard: snapshot, meta: { isEmbedded: true } }); + scene = dash.state.body; // skip the wrappers + } catch (ex) { + console.log('Error creating scene:', ex); + } + } + + this.setState({ snapshot, snapshotText, markdownText, snapshotSize, snapshotUpdate: snapshotUpdate + 1, scene }); + } + + onCurrentTabChange = (value: SnapshotTab) => { + this.setState({ currentTab: value }); + }; + + onShowMessageChange = (value: SelectableValue<ShowMessage>) => { + this.setState({ showMessage: value.value! }); + }; + + onGetMarkdownForClipboard = () => { + const { markdownText } = this.state; + const maxLen = Math.pow(1024, 2) * 1.5; // 1.5MB + + if (markdownText.length > maxLen) { + this.setState({ + error: { + title: 'Copy to clipboard failed', + message: 'Snapshot is too large, consider download and attaching a file instead', + }, + }); + + return ''; + } + + return markdownText; + }; + + onDownloadDashboard = () => { + const { snapshotText, panelTitle } = this.state; + const blob = new Blob([snapshotText], { + type: 'text/plain', + }); + const fileName = `debug-${panelTitle}-${dateTimeFormat(new Date())}.json.txt`; + saveAs(blob, fileName); + }; + + onSetSnapshotText = (snapshotText: string) => { + this.setState({ snapshotText }); + }; + + onToggleRandomize = (k: keyof Randomize) => { + const { randomize } = this.state; + this.setState({ randomize: { ...randomize, [k]: !randomize[k] } }); + }; +} diff --git a/public/app/features/dashboard/components/HelpWizard/randomizer.test.ts b/public/app/features/dashboard-scene/inspect/HelpWizard/randomizer.test.ts similarity index 100% rename from public/app/features/dashboard/components/HelpWizard/randomizer.test.ts rename to public/app/features/dashboard-scene/inspect/HelpWizard/randomizer.test.ts diff --git a/public/app/features/dashboard/components/HelpWizard/randomizer.ts b/public/app/features/dashboard-scene/inspect/HelpWizard/randomizer.ts similarity index 100% rename from public/app/features/dashboard/components/HelpWizard/randomizer.ts rename to public/app/features/dashboard-scene/inspect/HelpWizard/randomizer.ts diff --git a/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts b/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts new file mode 100644 index 0000000000000..bf8dce6eac160 --- /dev/null +++ b/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts @@ -0,0 +1,323 @@ +import { cloneDeep } from 'lodash'; + +import { + dateTimeFormat, + TimeRange, + PanelData, + DataTransformerConfig, + DataFrameJSON, + LoadingState, + dataFrameToJSON, + DataTopic, +} from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { SceneGridItem, VizPanel } from '@grafana/scenes'; +import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; + +import { LibraryVizPanel } from '../../scene/LibraryVizPanel'; +import { gridItemToPanel, vizPanelToPanel } from '../../serialization/transformSceneToSaveModel'; +import { getQueryRunnerFor } from '../../utils/utils'; + +import { Randomize, randomizeData } from './randomizer'; + +export function getPanelDataFrames(data?: PanelData): DataFrameJSON[] { + const frames: DataFrameJSON[] = []; + if (data?.series) { + for (const f of data.series) { + frames.push(dataFrameToJSON(f)); + } + } + if (data?.annotations) { + for (const f of data.annotations) { + const json = dataFrameToJSON(f); + if (!json.schema?.meta) { + json.schema!.meta = {}; + } + json.schema!.meta.dataTopic = DataTopic.Annotations; + frames.push(json); + } + } + + return frames; +} + +export function getGithubMarkdown(panel: VizPanel, snapshot: string): string { + const info = { + panelType: panel.state.pluginId, + datasource: '??', + }; + const grafanaVersion = config.buildInfo.versionString; + + let md = `| Key | Value | +|--|--| +| Panel | ${info.panelType} @ ${panel.state.pluginVersion ?? grafanaVersion} | +| Grafana | ${grafanaVersion} // ${config.buildInfo.edition} | +`; + + if (snapshot) { + md += '<details><summary>Panel debug snapshot dashboard</summary>\n\n```json\n' + snapshot + '\n```\n</details>'; + } + return md; +} + +export async function getDebugDashboard(panel: VizPanel, rand: Randomize, timeRange: TimeRange) { + let saveModel; + + if (panel.parent instanceof LibraryVizPanel && panel.parent.parent instanceof SceneGridItem) { + saveModel = { + ...gridItemToPanel(panel.parent.parent), + ...vizPanelToPanel(panel), + }; + } else { + saveModel = gridItemToPanel(panel.parent as SceneGridItem); + } + + const dashboard = cloneDeep(embeddedDataTemplate); + const info = { + panelType: panel.state.pluginId, + datasource: '??', + }; + + // reproducable + const queryRunner = getQueryRunnerFor(panel)!; + + if (!queryRunner.state.data) { + return; + } + + const data = queryRunner.state.data; + + const dsref = queryRunner?.state.datasource; + const frames = randomizeData(getPanelDataFrames(data), rand); + const grafanaVersion = config.buildInfo.versionString; + const queries = queryRunner.state.queries ?? []; + const annotationsCount = data.annotations ? data.annotations.reduce((acc, c) => c.length + acc, 0) : 0; + const html = `<table width="100%"> + <tr> + <th width="2%">Panel</th> + <td >${info.panelType} @ ${saveModel.pluginVersion ?? grafanaVersion}</td> + </tr> + <tr> + <th>Queries</th> + <td>${queries + .map((t) => { + const ds = t.datasource ?? dsref; + return `${t.refId}[${ds?.type}]`; + }) + .join(', ')}</td> + </tr> + ${getTransformsRow(saveModel)} + ${getDataRow(data, frames)} + ${getAnnotationsRow(data)} + <tr> + <th>Grafana</th> + <td>${grafanaVersion} // ${config.buildInfo.edition}</td> + </tr> + </table>`.trim(); + + // Replace the panel with embedded data + dashboard.panels[0] = { + ...saveModel, + ...dashboard.panels[0], + targets: [ + { + refId: 'A', + datasource: { + type: 'grafana', + uid: 'grafana', + }, + queryType: GrafanaQueryType.Snapshot, + snapshot: frames, + }, + ], + }; + + // delete library panel not to load the panel from the db + delete dashboard.panels[0].libraryPanel; + + if (saveModel.transformations?.length) { + const last = dashboard.panels[dashboard.panels.length - 1]; + last.title = last.title + ' (after transformations)'; + + const before = cloneDeep(last); + before.id = 100; + before.title = 'Data (before transformations)'; + before.gridPos.w = 24; // full width + before.targets[0].withTransforms = false; + dashboard.panels.push(before); + } + + if (annotationsCount > 0) { + dashboard.panels.push({ + id: 7, + gridPos: { + h: 6, + w: 24, + x: 0, + y: 20, + }, + type: 'table', + title: 'Annotations', + datasource: { + type: 'datasource', + uid: '-- Dashboard --', + }, + options: { + showTypeIcons: true, + }, + targets: [ + { + datasource: { + type: 'datasource', + uid: '-- Dashboard --', + }, + panelId: 2, + withTransforms: true, + topic: DataTopic.Annotations, + refId: 'A', + }, + ], + }); + } + + dashboard.panels[1].options.content = html; + dashboard.panels[2].options.content = JSON.stringify(saveModel, null, 2); + + dashboard.title = `Debug: ${saveModel.title} // ${dateTimeFormat(new Date())}`; + dashboard.tags = ['debug', `debug-${info.panelType}`]; + dashboard.time = { + from: timeRange.from.toISOString(), + to: timeRange.to.toISOString(), + }; + + return dashboard; +} + +// eslint-disable-next-line +function getTransformsRow(saveModel: any): string { + if (!saveModel.transformations) { + return ''; + } + return `<tr> + <th>Transform</th> + <td>${saveModel.transformations.map((t: DataTransformerConfig) => t.id).join(', ')}</td> + </tr>`; +} + +function getDataRow(data: PanelData, frames: DataFrameJSON[]): string { + let frameCount = data.series.length ?? 0; + let fieldCount = 0; + let rowCount = 0; + for (const frame of data.series) { + fieldCount += frame.fields.length; + rowCount += frame.length; + } + return ( + '<tr>' + + '<th>Data</th>' + + '<td>' + + `${data.state !== LoadingState.Done ? data.state : ''} ` + + `${frameCount} frames, ${fieldCount} fields, ` + + `${rowCount} rows ` + + // `(${formattedValueToString(getValueFormat('decbytes')(raw?.length))} JSON)` + + '</td>' + + '</tr>' + ); +} + +function getAnnotationsRow(data: PanelData): string { + if (!data.annotations?.length) { + return ''; + } + + return `<tr> + <th>Annotations</th> + <td>${data.annotations.reduce((acc, c) => c.length + acc, 0)}</td> +</tr>`; +} + +// eslint-disable-next-line +const embeddedDataTemplate: any = { + // should be dashboard model when that is accurate enough + panels: [ + { + id: 2, + title: 'Reproduced with embedded data', + datasource: { + type: 'grafana', + uid: 'grafana', + }, + gridPos: { + h: 13, + w: 15, + x: 0, + y: 0, + }, + }, + { + gridPos: { + h: 7, + w: 9, + x: 15, + y: 0, + }, + id: 5, + options: { + content: '...', + mode: 'html', + }, + title: 'Debug info', + type: 'text', + }, + { + id: 6, + title: 'Original Panel JSON', + type: 'text', + gridPos: { + h: 13, + w: 9, + x: 15, + y: 7, + }, + options: { + content: '...', + mode: 'code', + code: { + language: 'json', + showLineNumbers: true, + showMiniMap: true, + }, + }, + }, + { + id: 3, + title: 'Data from panel above', + type: 'table', + datasource: { + type: 'datasource', + uid: '-- Dashboard --', + }, + gridPos: { + h: 7, + w: 15, + x: 0, + y: 13, + }, + options: { + showTypeIcons: true, + }, + targets: [ + { + datasource: { + type: 'datasource', + uid: '-- Dashboard --', + }, + panelId: 2, + withTransforms: true, + refId: 'A', + }, + ], + }, + ], + schemaVersion: 37, +}; diff --git a/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx b/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx index b1b91bc6ec18a..264e92daab92b 100644 --- a/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx +++ b/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx @@ -1,17 +1,30 @@ -import { FieldType, getDefaultTimeRange, LoadingState, standardTransformersRegistry, toDataFrame } from '@grafana/data'; +import { of } from 'rxjs'; + +import { + FieldType, + getDefaultTimeRange, + LoadingState, + PanelData, + standardTransformersRegistry, + toDataFrame, +} from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; -import { setPluginImportUtils } from '@grafana/runtime'; +import { setPluginImportUtils, setRunRequest } from '@grafana/runtime'; import { SceneCanvasText, - SceneDataNode, SceneDataTransformer, SceneGridItem, SceneGridLayout, + SceneQueryRunner, VizPanel, } from '@grafana/scenes'; +import * as libpanels from 'app/features/library-panels/state/api'; import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; import { DashboardScene } from '../scene/DashboardScene'; +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; +import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; +import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; import { activateFullSceneTree } from '../utils/test-utils'; import { findVizPanelByKey } from '../utils/utils'; @@ -24,6 +37,51 @@ setPluginImportUtils({ getPanelPluginFromCache: (id: string) => undefined, }); +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + setPluginExtensionGetter: jest.fn(), + getPluginLinkExtensions: jest.fn(() => ({ + extensions: [], + })), + getDataSourceSrv: () => { + return { + get: jest.fn().mockResolvedValue({ + getRef: () => ({ uid: 'ds1' }), + }), + getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds1' }), + }; + }, +})); + +const runRequestMock = jest.fn().mockReturnValue( + of<PanelData>({ + state: LoadingState.Done, + timeRange: getDefaultTimeRange(), + series: [ + toDataFrame({ + fields: [{ name: 'value', type: FieldType.number, values: [1, 2, 3] }], + }), + ], + request: { + app: 'dashboard', + requestId: 'request-id', + dashboardUID: 'asd', + interval: '1s', + panelId: 1, + range: getDefaultTimeRange(), + targets: [], + timezone: 'utc', + intervalMs: 1000, + startTime: 1, + scopedVars: { + __sceneObject: { value: new SceneCanvasText({ text: 'asd' }) }, + }, + }, + }) +); + +setRunRequest(runRequestMock); + describe('InspectJsonTab', () => { it('Can show panel json', async () => { const { tab } = await buildTestScene(); @@ -33,6 +91,15 @@ describe('InspectJsonTab', () => { expect(tab.isEditable()).toBe(true); }); + it('Can show panel json for library panels', async () => { + const { tab } = await buildTestSceneWithLibraryPanel(); + + const obj = JSON.parse(tab.state.jsonText); + expect(obj.gridPos).toEqual({ x: 0, y: 0, w: 10, h: 12 }); + expect(obj.type).toEqual('table'); + expect(tab.isEditable()).toBe(false); + }); + it('Can show panel data with field config', async () => { const { tab } = await buildTestScene(); tab.onChangeSource({ value: 'panel-data' }); @@ -85,11 +152,12 @@ describe('InspectJsonTab', () => { }); }); -async function buildTestScene() { - const panel = new VizPanel({ +function buildTestPanel() { + return new VizPanel({ title: 'Panel A', pluginId: 'table', key: 'panel-12', + titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })], $data: new SceneDataTransformer({ transformations: [ { @@ -99,35 +167,16 @@ async function buildTestScene() { }, }, ], - $data: new SceneDataNode({ - data: { - state: LoadingState.Done, - series: [ - toDataFrame({ - fields: [{ name: 'value', type: FieldType.number, values: [1, 2, 3] }], - }), - ], - timeRange: getDefaultTimeRange(), - request: { - app: 'dashboard', - requestId: 'request-id', - dashboardUID: 'asd', - interval: '1s', - panelId: 1, - range: getDefaultTimeRange(), - targets: [], - timezone: 'utc', - intervalMs: 1000, - startTime: 1, - scopedVars: { - __sceneObject: { value: new SceneCanvasText({ text: 'asd' }) }, - }, - }, - }, + $data: new SceneQueryRunner({ + datasource: { uid: 'abcdef' }, + queries: [{ refId: 'A' }], }), }), }); +} +async function buildTestScene() { + const panel = buildTestPanel(); const scene = new DashboardScene({ title: 'hello', uid: 'dash-1', @@ -159,3 +208,50 @@ async function buildTestScene() { return { scene, tab, panel }; } + +async function buildTestSceneWithLibraryPanel() { + const panel = vizPanelToPanel(buildTestPanel()); + + const libraryPanelState = { + name: 'LibraryPanel A', + title: 'LibraryPanel A title', + uid: '111', + panelKey: 'panel-22', + model: panel, + version: 1, + }; + + jest.spyOn(libpanels, 'getLibraryPanel').mockResolvedValue({ ...libraryPanelState, ...panel }); + const libraryPanel = new LibraryVizPanel(libraryPanelState); + + const scene = new DashboardScene({ + title: 'hello', + uid: 'dash-1', + meta: { + canEdit: true, + }, + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + y: 0, + width: 10, + height: 12, + body: libraryPanel, + }), + ], + }), + }); + + activateFullSceneTree(scene); + + await new Promise((r) => setTimeout(r, 1)); + + const tab = new InspectJsonTab({ + panelRef: libraryPanel.state.panel!.getRef(), + onClose: jest.fn(), + }); + + return { scene, tab, panel }; +} diff --git a/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx b/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx index 0b3d2ac23c261..8710778da53bf 100644 --- a/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx +++ b/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx @@ -17,6 +17,7 @@ import { sceneUtils, VizPanel, } from '@grafana/scenes'; +import { LibraryPanel } from '@grafana/schema/'; import { Button, CodeEditor, Field, Select, useStyles2 } from '@grafana/ui'; import { t } from 'app/core/internationalization'; import { getPanelDataFrames } from 'app/features/dashboard/components/HelpWizard/utils'; @@ -26,9 +27,11 @@ import { InspectTab } from 'app/features/inspector/types'; import { getPrettyJSON } from 'app/features/inspector/utils/utils'; import { reportPanelInspectInteraction } from 'app/features/search/page/reporting'; +import { VizPanelManager } from '../panel-edit/VizPanelManager'; +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem'; import { buildGridItemForPanel } from '../serialization/transformSaveModelToScene'; -import { gridItemToPanel } from '../serialization/transformSceneToSaveModel'; +import { gridItemToPanel, vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils'; export type ShowContent = 'panel-json' | 'panel-data' | 'data-frames'; @@ -59,7 +62,7 @@ export class InspectJsonTab extends SceneObjectBase<InspectJsonTabState> { public getOptions(): Array<SelectableValue<ShowContent>> { const panel = this.state.panelRef.resolve(); - const dataProvider = panel.state.$data; + const dataProvider = panel.state.$data ?? panel.parent?.state.$data; const options: Array<SelectableValue<ShowContent>> = [ { @@ -200,8 +203,14 @@ function getJsonText(show: ShowContent, panel: VizPanel): string { case 'panel-json': { reportPanelInspectInteraction(InspectTab.JSON, 'panelData'); - if (panel.parent instanceof SceneGridItem || panel.parent instanceof PanelRepeaterGridItem) { - objToStringify = gridItemToPanel(panel.parent); + const parent = panel.parent!; + + if (parent instanceof SceneGridItem || parent instanceof PanelRepeaterGridItem) { + objToStringify = gridItemToPanel(parent); + } else if (parent instanceof LibraryVizPanel) { + objToStringify = libraryPanelChildToLegacyRepresentation(panel); + } else if (parent instanceof VizPanelManager) { + objToStringify = parent.getPanelSaveModel(); } break; } @@ -234,6 +243,43 @@ function getJsonText(show: ShowContent, panel: VizPanel): string { return getPrettyJSON(objToStringify); } +/** + * + * @param panel Must be child of a LibraryVizPanel that is in turn the child of a SceneGridItem + * @returns object representation of the legacy library panel structure. + */ +function libraryPanelChildToLegacyRepresentation(panel: VizPanel<{}, {}>) { + if (!(panel.parent instanceof LibraryVizPanel)) { + throw 'Panel not child of LibraryVizPanel'; + } + + if (!(panel.parent.parent instanceof SceneGridItem)) { + throw 'LibraryPanel not child of SceneGridItem'; + } + + const gridItem = panel.parent.parent; + const gridPos = { + x: gridItem.state.x || 0, + y: gridItem.state.y || 0, + h: gridItem.state.height || 0, + w: gridItem.state.width || 0, + }; + const libraryPanelObj = vizPanelToLibraryPanel(panel); + const panelObj = vizPanelToPanel(panel, gridPos, false, gridItem); + + return { libraryPanel: { ...libraryPanelObj }, ...panelObj }; +} + +function vizPanelToLibraryPanel(panel: VizPanel): LibraryPanel { + if (!(panel.parent instanceof LibraryVizPanel)) { + throw new Error('Panel not a child of LibraryVizPanel'); + } + if (!panel.parent.state._loadedPanel) { + throw new Error('Library panel not loaded'); + } + return panel.parent.state._loadedPanel; +} + function hasGridPosChanged(a: SceneGridItemStateLike, b: SceneGridItemStateLike) { return a.x !== b.x || a.y !== b.y || a.width !== b.width || a.height !== b.height; } diff --git a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx index 30f4f93292ebd..319798559ec4b 100644 --- a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx +++ b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx @@ -14,7 +14,12 @@ import { import { Alert, Drawer, Tab, TabsBar } from '@grafana/ui'; import { getDataSourceWithInspector } from 'app/features/dashboard/components/Inspector/hooks'; import { supportsDataQuery } from 'app/features/dashboard/components/PanelEditor/utils'; +import { InspectTab } from 'app/features/inspector/types'; +import { getDashboardUrl } from '../utils/urlBuilders'; +import { getDashboardSceneFor } from '../utils/utils'; + +import { HelpWizard } from './HelpWizard/HelpWizard'; import { InspectDataTab } from './InspectDataTab'; import { InspectJsonTab } from './InspectJsonTab'; import { InspectMetaDataTab } from './InspectMetaDataTab'; @@ -24,7 +29,7 @@ import { SceneInspectTab } from './types'; interface PanelInspectDrawerState extends SceneObjectState { tabs?: SceneInspectTab[]; - panelRef: SceneObjectRef<VizPanel>; + panelRef?: SceneObjectRef<VizPanel>; pluginNotLoaded?: boolean; canEdit?: boolean; } @@ -47,8 +52,7 @@ export class PanelInspectDrawer extends SceneObjectBase<PanelInspectDrawerState> */ async buildTabs(retry: number) { const panelRef = this.state.panelRef; - const panel = panelRef.resolve(); - const plugin = panel.getPlugin(); + const plugin = panelRef?.resolve()?.getPlugin(); const tabs: SceneInspectTab[] = []; if (!plugin) { @@ -59,36 +63,52 @@ export class PanelInspectDrawer extends SceneObjectBase<PanelInspectDrawerState> } } - if (supportsDataQuery(plugin)) { - const data = sceneGraph.getData(panel); + if (panelRef) { + if (supportsDataQuery(plugin)) { + const data = sceneGraph.getData(panelRef.resolve()); - tabs.push(new InspectDataTab({ panelRef })); - tabs.push(new InspectStatsTab({ panelRef })); - tabs.push(new InspectQueryTab({ panelRef })); + tabs.push(new InspectDataTab({ panelRef })); + tabs.push(new InspectStatsTab({ panelRef })); + tabs.push(new InspectQueryTab({ panelRef })); - const dsWithInspector = await getDataSourceWithInspector(data.state.data); - if (dsWithInspector) { - tabs.push(new InspectMetaDataTab({ panelRef, dataSource: dsWithInspector })); + const dsWithInspector = await getDataSourceWithInspector(data.state.data); + if (dsWithInspector) { + tabs.push(new InspectMetaDataTab({ panelRef, dataSource: dsWithInspector })); + } } - } - tabs.push(new InspectJsonTab({ panelRef, onClose: this.onClose })); + tabs.push(new InspectJsonTab({ panelRef, onClose: this.onClose })); + } this.setState({ tabs }); } getDrawerTitle() { - const panel = this.state.panelRef.resolve(); - return sceneGraph.interpolate(panel, `Inspect: ${panel.state.title}`); + const panel = this.state.panelRef?.resolve(); + if (panel) { + return sceneGraph.interpolate(panel, `Inspect: ${panel.state.title}`); + } + return `Inspect panel`; } onClose = () => { - locationService.partial({ inspect: null, inspectTab: null }); + const dashboard = getDashboardSceneFor(this); + locationService.push( + getDashboardUrl({ + uid: dashboard.state.uid, + slug: dashboard.state.meta.slug, + currentQueryParams: locationService.getLocation().search, + updateQuery: { + inspect: null, + inspectTab: null, + }, + }) + ); }; } function PanelInspectRenderer({ model }: SceneComponentProps<PanelInspectDrawer>) { - const { tabs, pluginNotLoaded } = model.useState(); + const { tabs, pluginNotLoaded, panelRef } = model.useState(); const location = useLocation(); const queryParams = new URLSearchParams(location.search); @@ -99,6 +119,12 @@ function PanelInspectRenderer({ model }: SceneComponentProps<PanelInspectDrawer> const urlTab = queryParams.get('inspectTab'); const currentTab = tabs.find((tab) => tab.getTabValue() === urlTab) ?? tabs[0]; + const vizPanel = panelRef!.resolve(); + + if (urlTab === InspectTab.Help) { + return <HelpWizard panel={vizPanel} onClose={model.onClose} />; + } + return ( <Drawer title={model.getDrawerTitle()} diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx index dcd13745076a6..3033bd6390ce1 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx @@ -1,5 +1,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { cloneDeep } from 'lodash'; import React from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; @@ -7,11 +8,12 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { PanelProps } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { config, getPluginLinkExtensions, locationService, setPluginImportUtils } from '@grafana/runtime'; +import { Dashboard } from '@grafana/schema'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; - -import { setupLoadDashboardMock } from '../utils/test-utils'; +import { DashboardLoaderSrv, setDashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { DashboardScenePage, Props } from './DashboardScenePage'; +import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), @@ -32,7 +34,7 @@ function setup() { const props: Props = { ...getRouteComponentProps(), }; - props.match.params.uid = 'd10'; + props.match.params.uid = 'my-dash-uid'; const renderResult = render( <TestProvider grafanaContext={context}> @@ -40,12 +42,22 @@ function setup() { </TestProvider> ); - return { renderResult, context }; + const rerender = (newProps: Props) => { + renderResult.rerender( + <TestProvider grafanaContext={context}> + <DashboardScenePage {...newProps} /> + </TestProvider> + ); + }; + + return { rerender, context, props }; } -const simpleDashboard = { +const simpleDashboard: Dashboard = { title: 'My cool dashboard', - uid: '10d', + uid: 'my-dash-uid', + schemaVersion: 30, + version: 1, panels: [ { id: 1, @@ -94,10 +106,20 @@ setPluginImportUtils({ getPanelPluginFromCache: (id: string) => undefined, }); +const loadDashboardMock = jest.fn(); + +setDashboardLoaderSrv({ + loadDashboard: loadDashboardMock, + // disabling type checks since this is a test util + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions +} as unknown as DashboardLoaderSrv); + describe('DashboardScenePage', () => { beforeEach(() => { locationService.push('/'); - setupLoadDashboardMock({ dashboard: simpleDashboard, meta: {} }); + getDashboardScenePageStateManager().clearDashboardCache(); + loadDashboardMock.mockClear(); + loadDashboardMock.mockResolvedValue({ dashboard: simpleDashboard, meta: {} }); // hacky way because mocking autosizer does not work Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 1000 }); Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 1000 }); @@ -117,6 +139,27 @@ describe('DashboardScenePage', () => { expect(await screen.findByText('Content B')).toBeInTheDocument(); }); + it('routeReloadCounter should trigger reload', async () => { + const { rerender, props } = setup(); + + await waitForDashbordToRender(); + + expect(await screen.findByTitle('Panel A')).toBeInTheDocument(); + + const updatedDashboard = cloneDeep(simpleDashboard); + updatedDashboard.version = 11; + updatedDashboard.panels![0].title = 'Updated title'; + + getDashboardScenePageStateManager().clearDashboardCache(); + loadDashboardMock.mockResolvedValue({ dashboard: updatedDashboard, meta: {} }); + + props.history.location.state = { routeReloadCounter: 1 }; + + rerender(props); + + expect(await screen.findByTitle('Updated title')).toBeInTheDocument(); + }); + it('Can inspect panel', async () => { setup(); @@ -151,6 +194,13 @@ describe('DashboardScenePage', () => { expect(screen.queryByTitle('Panel A')).not.toBeInTheDocument(); expect(await screen.findByTitle('Panel B')).toBeInTheDocument(); }); + + it('Shows empty state when dashboard is empty', async () => { + loadDashboardMock.mockResolvedValue({ dashboard: { panels: [] }, meta: {} }); + setup(); + + expect(await screen.findByText('Start your new dashboard by adding a visualization')).toBeInTheDocument(); + }); }); interface VizOptions { diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index 9b04f12959151..e1f90a24ea3cd 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -5,28 +5,44 @@ import { PageLayoutType } from '@grafana/data'; import { Page } from 'app/core/components/Page/Page'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { DashboardPageRouteParams } from 'app/features/dashboard/containers/types'; +import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from 'app/features/dashboard/containers/types'; import { DashboardRoutes } from 'app/types'; +import { DashboardPrompt } from '../saving/DashboardPrompt'; + import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager'; -export interface Props extends GrafanaRouteComponentProps<DashboardPageRouteParams> {} +export interface Props extends GrafanaRouteComponentProps<DashboardPageRouteParams, DashboardPageRouteSearchParams> {} -export function DashboardScenePage({ match, route }: Props) { +export function DashboardScenePage({ match, route, queryParams, history }: Props) { const stateManager = getDashboardScenePageStateManager(); const { dashboard, isLoading, loadError } = stateManager.useState(); + // After scene migration is complete and we get rid of old dashboard we should refactor dashboardWatcher so this route reload is not need + const routeReloadCounter = (history.location.state as any)?.routeReloadCounter; useEffect(() => { - if (route.routeName === DashboardRoutes.Home) { - stateManager.loadDashboard(route.routeName); + if (route.routeName === DashboardRoutes.Normal && match.params.type === 'snapshot') { + stateManager.loadSnapshot(match.params.slug!); } else { - stateManager.loadDashboard(match.params.uid!); + stateManager.loadDashboard({ + uid: match.params.uid ?? '', + route: route.routeName as DashboardRoutes, + urlFolderUid: queryParams.folderUid, + }); } return () => { stateManager.clearState(); }; - }, [stateManager, match.params.uid, route.routeName]); + }, [ + stateManager, + match.params.uid, + route.routeName, + queryParams.folderUid, + routeReloadCounter, + match.params.slug, + match.params.type, + ]); if (!dashboard) { return ( @@ -37,7 +53,12 @@ export function DashboardScenePage({ match, route }: Props) { ); } - return <dashboard.Component model={dashboard} />; + return ( + <> + <dashboard.Component model={dashboard} /> + <DashboardPrompt dashboard={dashboard} /> + </> + ); } export default DashboardScenePage; diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index dce180fa0dfd4..9c64f4251cc1e 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -2,6 +2,7 @@ import { advanceBy } from 'jest-date-mock'; import { locationService } from '@grafana/runtime'; import { getUrlSyncManager } from '@grafana/scenes'; +import { DashboardRoutes } from 'app/types'; import { DashboardScene } from '../scene/DashboardScene'; import { setupLoadDashboardMock } from '../utils/test-utils'; @@ -14,12 +15,12 @@ describe('DashboardScenePageStateManager', () => { const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash', editable: true }, meta: {} }); const loader = new DashboardScenePageStateManager({}); - await loader.loadDashboard('fake-dash'); + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash'); // should use cache second time - await loader.loadDashboard('fake-dash'); + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); expect(loadDashboardMock.mock.calls.length).toBe(1); }); @@ -27,7 +28,7 @@ describe('DashboardScenePageStateManager', () => { setupLoadDashboardMock({ dashboard: undefined, meta: {} }); const loader = new DashboardScenePageStateManager({}); - await loader.loadDashboard('fake-dash'); + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); expect(loader.state.dashboard).toBeUndefined(); expect(loader.state.isLoading).toBe(false); @@ -38,7 +39,7 @@ describe('DashboardScenePageStateManager', () => { setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); const loader = new DashboardScenePageStateManager({}); - await loader.loadDashboard('fake-dash'); + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); expect(loader.state.dashboard?.state.uid).toBe('fake-dash'); expect(loader.state.loadError).toBe(undefined); @@ -49,7 +50,17 @@ describe('DashboardScenePageStateManager', () => { setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); const loader = new DashboardScenePageStateManager({}); - await loader.loadDashboard('fake-dash'); + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); + + expect(loader.state.dashboard).toBeInstanceOf(DashboardScene); + expect(loader.state.isLoading).toBe(false); + }); + + it('should use DashboardScene creator to initialize the snapshot scene', async () => { + setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadSnapshot('fake-slug'); expect(loader.state.dashboard).toBeInstanceOf(DashboardScene); expect(loader.state.isLoading).toBe(false); @@ -61,7 +72,7 @@ describe('DashboardScenePageStateManager', () => { locationService.partial({ from: 'now-5m', to: 'now' }); const loader = new DashboardScenePageStateManager({}); - await loader.loadDashboard('fake-dash'); + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); const dash = loader.state.dashboard; expect(dash!.state.$timeRange?.state.from).toEqual('now-5m'); @@ -71,13 +82,79 @@ describe('DashboardScenePageStateManager', () => { // try loading again (and hitting cache) locationService.partial({ from: 'now-10m', to: 'now' }); - await loader.loadDashboard('fake-dash'); + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); const dash2 = loader.state.dashboard; expect(dash2!.state.$timeRange?.state.from).toEqual('now-10m'); }); + it('should not initialize url sync for embedded dashboards', async () => { + setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); + + locationService.partial({ from: 'now-5m', to: 'now' }); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Embedded }); + const dash = loader.state.dashboard; + + expect(dash!.state.$timeRange?.state.from).toEqual('now-6h'); + }); + + describe('New dashboards', () => { + it('Should have new empty model with meta.isNew and should not be cached', async () => { + const loader = new DashboardScenePageStateManager({}); + + await loader.loadDashboard({ uid: '', route: DashboardRoutes.New }); + const dashboard = loader.state.dashboard!; + + expect(dashboard.state.meta.isNew).toBe(true); + expect(dashboard.state.isEditing).toBe(undefined); + expect(dashboard.state.isDirty).toBe(false); + + dashboard.setState({ title: 'Changed' }); + + await loader.loadDashboard({ uid: '', route: DashboardRoutes.New }); + const dashboard2 = loader.state.dashboard!; + + expect(dashboard2.state.title).toBe('New dashboard'); + }); + }); + describe('caching', () => { + it('should take scene from cache if it exists', async () => { + setupLoadDashboardMock({ dashboard: { uid: 'fake-dash', version: 10 }, meta: {} }); + + const loader = new DashboardScenePageStateManager({}); + + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); + + loader.state.dashboard?.onEnterEditMode(); + + expect(loader.state.dashboard?.state.isEditing).toBe(true); + + loader.clearState(); + + // now load it again + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); + + // should still be editing + expect(loader.state.dashboard?.state.isEditing).toBe(true); + expect(loader.state.dashboard?.state.version).toBe(10); + + loader.clearState(); + + loader.setDashboardCache('fake-dash', { + dashboard: { title: 'new version', uid: 'fake-dash', version: 11, schemaVersion: 30 }, + meta: {}, + }); + + // now load a third time + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); + + expect(loader.state.dashboard!.state.isEditing).toBe(undefined); + expect(loader.state.dashboard!.state.version).toBe(11); + }); + it('should cache the dashboard DTO', async () => { setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); @@ -85,7 +162,7 @@ describe('DashboardScenePageStateManager', () => { expect(loader.getFromCache('fake-dash')).toBeNull(); - await loader.loadDashboard('fake-dash'); + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); expect(loader.getFromCache('fake-dash')).toBeDefined(); }); @@ -98,15 +175,15 @@ describe('DashboardScenePageStateManager', () => { expect(loader.getFromCache('fake-dash')).toBeNull(); - await loader.fetchDashboard('fake-dash'); + await loader.fetchDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); expect(loadDashSpy).toHaveBeenCalledTimes(1); advanceBy(DASHBOARD_CACHE_TTL / 2); - await loader.fetchDashboard('fake-dash'); + await loader.fetchDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); expect(loadDashSpy).toHaveBeenCalledTimes(1); advanceBy(DASHBOARD_CACHE_TTL / 2 + 1); - await loader.fetchDashboard('fake-dash'); + await loader.fetchDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); expect(loadDashSpy).toHaveBeenCalledTimes(2); }); }); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 971d0599a00aa..6fc3934e65e41 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -7,12 +7,12 @@ import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoa import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { buildNavModel } from 'app/features/folders/state/navModel'; import { store } from 'app/store/store'; -import { DashboardDTO, DashboardMeta, DashboardRoutes } from 'app/types'; +import { DashboardDTO, DashboardRoutes } from 'app/types'; -import { buildPanelEditScene, PanelEditor } from '../panel-edit/PanelEditor'; +import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardScene } from '../scene/DashboardScene'; +import { buildNewDashboardSaveModel } from '../serialization/buildNewDashboardSaveModel'; import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; -import { getVizPanelKeyForPanelId, findVizPanelByKey } from '../utils/utils'; export interface DashboardScenePageState { dashboard?: DashboardScene; @@ -21,57 +21,88 @@ export interface DashboardScenePageState { loadError?: string; } -export const DASHBOARD_CACHE_TTL = 2000; +export const DASHBOARD_CACHE_TTL = 500; + +/** Only used by cache in loading home in DashboardPageProxy and initDashboard (Old arch), can remove this after old dashboard arch is gone */ +export const HOME_DASHBOARD_CACHE_KEY = '__grafana_home_uid__'; interface DashboardCacheEntry { dashboard: DashboardDTO; ts: number; + cacheKey: string; +} + +export interface LoadDashboardOptions { + uid: string; + route: DashboardRoutes; + urlFolderUid?: string; } + export class DashboardScenePageStateManager extends StateManagerBase<DashboardScenePageState> { private cache: Record<string, DashboardScene> = {}; + // This is a simplistic, short-term cache for DashboardDTOs to avoid fetching the same dashboard multiple times across a short time span. - private dashboardCache: Map<string, DashboardCacheEntry> = new Map(); + private dashboardCache?: DashboardCacheEntry; // To eventualy replace the fetchDashboard function from Dashboard redux state management. // For now it's a simplistic version to support Home and Normal dashboard routes. - public async fetchDashboard(uid: string) { - const cachedDashboard = this.getFromCache(uid); + public async fetchDashboard({ uid, route, urlFolderUid }: LoadDashboardOptions): Promise<DashboardDTO | null> { + const cacheKey = route === DashboardRoutes.Home ? HOME_DASHBOARD_CACHE_KEY : uid; + const cachedDashboard = this.getFromCache(cacheKey); if (cachedDashboard) { return cachedDashboard; } - let rsp: DashboardDTO | undefined; + let rsp: DashboardDTO; try { - if (uid === DashboardRoutes.Home) { - rsp = await getBackendSrv().get('/api/dashboards/home'); - - // If user specified a custom home dashboard redirect to that - if (rsp?.redirectUri) { - const newUrl = locationUtil.stripBaseFromUrl(rsp.redirectUri); - locationService.replace(newUrl); - return null; - } + switch (route) { + case DashboardRoutes.New: + rsp = buildNewDashboardSaveModel(urlFolderUid); + break; + case DashboardRoutes.Home: + rsp = await getBackendSrv().get('/api/dashboards/home'); + + // If user specified a custom home dashboard redirect to that + if (rsp?.redirectUri) { + const newUrl = locationUtil.stripBaseFromUrl(rsp.redirectUri); + locationService.replace(newUrl); + return null; + } + + if (rsp?.meta) { + rsp.meta.canSave = false; + rsp.meta.canShare = false; + rsp.meta.canStar = false; + } + + break; + default: + rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid); + if (route === DashboardRoutes.Embedded) { + rsp.meta.isEmbedded = true; + } + } - if (rsp?.meta) { - rsp.meta.canSave = false; - rsp.meta.canShare = false; - rsp.meta.canStar = false; + if (rsp.meta.url && route !== DashboardRoutes.Embedded) { + const dashboardUrl = locationUtil.stripBaseFromUrl(rsp.meta.url); + const currentPath = locationService.getLocation().pathname; + if (dashboardUrl !== currentPath) { + // Spread current location to persist search params used for navigation + locationService.replace({ + ...locationService.getLocation(), + pathname: dashboardUrl, + }); + console.log('not correct url correcting', dashboardUrl, currentPath); } - } else { - rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid); } - if (rsp) { - // Fill in meta fields - const dashboard = this.initDashboardMeta(rsp); - - // Populate nav model in global store according to the folder - await this.initNavModel(dashboard); + // Populate nav model in global store according to the folder + await this.initNavModel(rsp); - this.dashboardCache.set(uid, { dashboard, ts: Date.now() }); - } + // Do not cache new dashboards + this.dashboardCache = { dashboard: rsp, ts: Date.now(), cacheKey }; } catch (e) { // Ignore cancelled errors if (isFetchError(e) && e.cancelled) { @@ -85,10 +116,9 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc return rsp; } - public async loadDashboard(uid: string) { + public async loadSnapshot(slug: string) { try { - const dashboard = await this.loadScene(uid); - dashboard.startUrlSync(); + const dashboard = await this.loadSnapshotScene(slug); this.setState({ dashboard: dashboard, isLoading: false }); } catch (err) { @@ -96,66 +126,66 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc } } - public async loadPanelEdit(uid: string, panelId: string) { - try { - const dashboard = await this.loadScene(uid); - const panel = findVizPanelByKey(dashboard, getVizPanelKeyForPanelId(parseInt(panelId, 10))); + private async loadSnapshotScene(slug: string): Promise<DashboardScene> { + const rsp = await dashboardLoaderSrv.loadDashboard('snapshot', slug, ''); - if (!panel) { - this.setState({ isLoading: false, loadError: 'Panel not found' }); - return; - } + if (rsp?.dashboard) { + const scene = transformSaveModelToScene(rsp); + return scene; + } + + throw new Error('Snapshot not found'); + } - const panelEditor = buildPanelEditScene(dashboard, panel); - panelEditor.startUrlSync(); + public async loadDashboard(options: LoadDashboardOptions) { + try { + const dashboard = await this.loadScene(options); + dashboard.startUrlSync(); - this.setState({ isLoading: false, panelEditor }); + this.setState({ dashboard: dashboard, isLoading: false }); } catch (err) { this.setState({ isLoading: false, loadError: String(err) }); } } - private async loadScene(uid: string): Promise<DashboardScene> { - const fromCache = this.cache[uid]; - if (fromCache) { + private async loadScene(options: LoadDashboardOptions): Promise<DashboardScene> { + const rsp = await this.fetchDashboard(options); + + const fromCache = this.cache[options.uid]; + + if (fromCache && fromCache.state.version === rsp?.dashboard.version) { return fromCache; } this.setState({ isLoading: true }); - const rsp = await this.fetchDashboard(uid); - if (rsp?.dashboard) { const scene = transformSaveModelToScene(rsp); - this.cache[uid] = scene; + if (options.uid) { + this.cache[options.uid] = scene; + } + return scene; } throw new Error('Dashboard not found'); } - public getFromCache(uid: string) { - const cachedDashboard = this.dashboardCache.get(uid); + public getFromCache(cacheKey: string) { + const cachedDashboard = this.dashboardCache; - if (cachedDashboard && !this.hasExpired(cachedDashboard)) { + if ( + cachedDashboard && + cachedDashboard.cacheKey === cacheKey && + Date.now() - cachedDashboard?.ts < DASHBOARD_CACHE_TTL + ) { return cachedDashboard.dashboard; } return null; } - private hasExpired(entry: DashboardCacheEntry) { - return Date.now() - entry.ts > DASHBOARD_CACHE_TTL; - } - - private initDashboardMeta(dashboard: DashboardDTO): DashboardDTO { - return { - ...dashboard, - meta: initDashboardMeta(dashboard.meta, Boolean(dashboard.dashboard?.editable)), - }; - } - private async initNavModel(dashboard: DashboardDTO) { // only the folder API has information about ancestors // get parent folder (if it exists) and put it in the store @@ -172,11 +202,21 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc public clearState() { getDashboardSrv().setCurrent(undefined); - this.setState({ dashboard: undefined, loadError: undefined, isLoading: false, panelEditor: undefined }); + + this.setState({ + dashboard: undefined, + loadError: undefined, + isLoading: false, + panelEditor: undefined, + }); + } + + public setDashboardCache(cacheKey: string, dashboard: DashboardDTO) { + this.dashboardCache = { dashboard, ts: Date.now(), cacheKey }; } - public setDashboardCache(uid: string, dashboard: DashboardDTO) { - this.dashboardCache.set(uid, { dashboard, ts: Date.now() }); + public clearDashboardCache() { + this.dashboardCache = undefined; } } @@ -189,25 +229,3 @@ export function getDashboardScenePageStateManager(): DashboardScenePageStateMana return stateManager; } - -function initDashboardMeta(source: DashboardMeta, isEditable: boolean) { - const result = source ? { ...source } : {}; - - result.canShare = source.canShare !== false; - result.canSave = source.canSave !== false; - result.canStar = source.canStar !== false; - result.canEdit = source.canEdit !== false; - result.canDelete = source.canDelete !== false; - - result.showSettings = source.canEdit; - result.canMakeEditable = source.canSave && !isEditable; - result.hasUnsavedFolderChange = false; - - if (!isEditable) { - result.canEdit = false; - result.canDelete = false; - result.canSave = false; - } - - return result; -} diff --git a/public/app/features/dashboard-scene/pages/PanelEditPage.tsx b/public/app/features/dashboard-scene/pages/PanelEditPage.tsx deleted file mode 100644 index dc35c4fd807d6..0000000000000 --- a/public/app/features/dashboard-scene/pages/PanelEditPage.tsx +++ /dev/null @@ -1,36 +0,0 @@ -// Libraries -import React, { useEffect } from 'react'; - -import { PageLayoutType } from '@grafana/data'; -import { Page } from 'app/core/components/Page/Page'; -import PageLoader from 'app/core/components/PageLoader/PageLoader'; -import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; - -import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager'; - -export interface Props extends GrafanaRouteComponentProps<{ uid: string; panelId: string }> {} - -export function PanelEditPage({ match }: Props) { - const stateManager = getDashboardScenePageStateManager(); - const { panelEditor, isLoading, loadError } = stateManager.useState(); - - useEffect(() => { - stateManager.loadPanelEdit(match.params.uid, match.params.panelId); - return () => { - stateManager.clearState(); - }; - }, [stateManager, match.params.uid, match.params.panelId]); - - if (!panelEditor) { - return ( - <Page layout={PageLayoutType.Canvas}> - {isLoading && <PageLoader />} - {loadError && <h2>{loadError}</h2>} - </Page> - ); - } - - return <panelEditor.Component model={panelEditor} />; -} - -export default PanelEditPage; diff --git a/public/app/features/dashboard-scene/panel-edit/LibraryVizPanelInfo.tsx b/public/app/features/dashboard-scene/panel-edit/LibraryVizPanelInfo.tsx new file mode 100644 index 0000000000000..bd741ee48fd56 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/LibraryVizPanelInfo.tsx @@ -0,0 +1,58 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2, dateTimeFormat } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; + +interface Props { + libraryPanel: LibraryVizPanel; +} + +export const LibraryVizPanelInfo = ({ libraryPanel }: Props) => { + const styles = useStyles2(getStyles); + + const libraryPanelState = libraryPanel.useState(); + const tz = libraryPanelState.$timeRange?.getTimeZone(); + const meta = libraryPanelState._loadedPanel?.meta; + if (!meta) { + return null; + } + + return ( + <div className={styles.info}> + <div className={styles.libraryPanelInfo}> + {`Used on ${meta.connectedDashboards} `} + {meta.connectedDashboards === 1 ? 'dashboard' : 'dashboards'} + </div> + <div className={styles.libraryPanelInfo}> + {dateTimeFormat(meta.updated, { format: 'L', timeZone: tz })} by + {meta.updatedBy.avatarUrl && ( + <img className={styles.userAvatar} src={meta.updatedBy.avatarUrl} alt={`Avatar for ${meta.updatedBy.name}`} /> + )} + {meta.updatedBy.name} + </div> + </div> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + info: css({ + lineHeight: 1, + }), + libraryPanelInfo: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + }), + userAvatar: css({ + borderRadius: theme.shape.radius.circle, + boxSizing: 'content-box', + width: '22px', + height: '22px', + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), + }), + }; +}; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx new file mode 100644 index 0000000000000..c452a9cfd1186 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { Box, Button, Stack, Text } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; + +interface EmptyTransformationsProps { + onShowPicker: () => void; +} +export function EmptyTransformationsMessage(props: EmptyTransformationsProps) { + return ( + <Box alignItems="center" padding={4}> + <Stack direction="column" alignItems="center" gap={2}> + <Text element="h3" textAlignment="center"> + <Trans key="transformations.empty.add-transformation-header">Start transforming data</Trans> + </Text> + <Text element="p" textAlignment="center" data-testid={selectors.components.Transforms.noTransformationsMessage}> + <Trans key="transformations.empty.add-transformation-body"> + Transformations allow data to be changed in various ways before your visualization is shown. + <br /> + This includes joining data together, renaming fields, making calculations, formatting data for display, and + more. + </Trans> + </Text> + <Button + icon="plus" + variant="primary" + size="md" + onClick={props.onShowPicker} + data-testid={selectors.components.Transforms.addTransformationButton} + > + Add transformation + </Button> + </Stack> + </Box> + ); +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/NewAlertRuleButton.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/NewAlertRuleButton.tsx new file mode 100644 index 0000000000000..1c52f7f262ff3 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/NewAlertRuleButton.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { useLocation } from 'react-router-dom'; +import { useAsync } from 'react-use'; + +import { urlUtil } from '@grafana/data'; +import { locationService, logInfo } from '@grafana/runtime'; +import { VizPanel } from '@grafana/scenes'; +import { Alert, Button } from '@grafana/ui'; +import { LogMessages } from 'app/features/alerting/unified/Analytics'; +import { scenesPanelToRuleFormValues } from 'app/features/alerting/unified/utils/rule-form'; + +interface ScenesNewRuleFromPanelButtonProps { + panel: VizPanel; + className?: string; +} +export const ScenesNewRuleFromPanelButton = ({ panel, className }: ScenesNewRuleFromPanelButtonProps) => { + const location = useLocation(); + + const { loading, value: formValues } = useAsync(() => scenesPanelToRuleFormValues(panel), [panel]); + + if (loading) { + return <Button disabled={true}>New alert rule</Button>; + } + + if (!formValues) { + return ( + <Alert severity="info" title="No alerting capable query found"> + Cannot create alerts from this panel because no query to an alerting capable datasource is found. + </Alert> + ); + } + + const onClick = async () => { + logInfo(LogMessages.alertRuleFromPanel); + + const updateToDateFormValues = await scenesPanelToRuleFormValues(panel); + + const ruleFormUrl = urlUtil.renderUrl('/alerting/new', { + defaults: JSON.stringify(updateToDateFormValues), + returnTo: location.pathname + location.search, + }); + + locationService.push(ruleFormUrl); + }; + + return ( + <Button icon="bell" onClick={onClick} className={className} data-testid="create-alert-rule-button"> + New alert rule + </Button> + ); +}; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx new file mode 100644 index 0000000000000..a261687ea1c26 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx @@ -0,0 +1,357 @@ +import { act, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; +import { byTestId } from 'testing-library-selector'; + +import { DataSourceApi } from '@grafana/data'; +import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { fetchRules } from 'app/features/alerting/unified/api/prometheus'; +import { fetchRulerRules } from 'app/features/alerting/unified/api/ruler'; +import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons'; +import { + MockDataSourceSrv, + grantUserPermissions, + mockDataSource, + mockPromAlertingRule, + mockPromRuleGroup, + mockPromRuleNamespace, + mockRulerGrafanaRule, +} from 'app/features/alerting/unified/mocks'; +import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; +import * as config from 'app/features/alerting/unified/utils/config'; +import { Annotation } from 'app/features/alerting/unified/utils/constants'; +import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { PrometheusDatasource } from 'app/plugins/datasource/prometheus/datasource'; +import { PromOptions } from 'app/plugins/datasource/prometheus/types'; +import { configureStore } from 'app/store/configureStore'; +import { AccessControlAction } from 'app/types'; +import { AlertQuery } from 'app/types/unified-alerting-dto'; + +import { createDashboardSceneFromDashboardModel } from '../../serialization/transformSaveModelToScene'; +import { findVizPanelByKey, getVizPanelKeyForPanelId } from '../../utils/utils'; +import * as utils from '../../utils/utils'; +import { VizPanelManager } from '../VizPanelManager'; + +import { PanelDataAlertingTab, PanelDataAlertingTabRendered } from './PanelDataAlertingTab'; + +/** + * These tests has been copied from public/app/features/alerting/unified/PanelAlertTabContent.test.tsx and been slightly modified to make sure the scenes alert edit tab is as close to the old alert edit tab as possible + */ + +jest.mock('app/features/alerting/unified/api/prometheus'); +jest.mock('app/features/alerting/unified/api/ruler'); + +jest.spyOn(config, 'getAllDataSources'); +jest.spyOn(ruleActionButtons, 'matchesWidth').mockReturnValue(false); + +const dataSources = { + prometheus: mockDataSource<PromOptions>({ + name: 'Prometheus', + type: DataSourceType.Prometheus, + isDefault: false, + }), + default: mockDataSource<PromOptions>({ + name: 'Default', + type: DataSourceType.Prometheus, + isDefault: true, + }), +}; +dataSources.prometheus.meta.alerting = true; +dataSources.default.meta.alerting = true; + +const mocks = { + getAllDataSources: jest.mocked(config.getAllDataSources), + api: { + fetchRules: jest.mocked(fetchRules), + fetchRulerRules: jest.mocked(fetchRulerRules), + }, +}; + +const renderAlertTabContent = (model: PanelDataAlertingTab, initialStore?: ReturnType<typeof configureStore>) => { + render( + <TestProvider store={initialStore}> + <PanelDataAlertingTabRendered model={model}></PanelDataAlertingTabRendered> + </TestProvider> + ); +}; + +const rules = [ + mockPromRuleNamespace({ + name: 'default', + groups: [ + mockPromRuleGroup({ + name: 'mygroup', + rules: [ + mockPromAlertingRule({ + name: 'dashboardrule1', + annotations: { + [Annotation.dashboardUID]: '12', + [Annotation.panelID]: '34', + }, + }), + ], + }), + mockPromRuleGroup({ + name: 'othergroup', + rules: [ + mockPromAlertingRule({ + name: 'dashboardrule2', + annotations: { + [Annotation.dashboardUID]: '121', + [Annotation.panelID]: '341', + }, + }), + ], + }), + ], + }), +]; + +const rulerRules = { + default: [ + { + name: 'mygroup', + rules: [ + mockRulerGrafanaRule( + { + annotations: { + [Annotation.dashboardUID]: '12', + [Annotation.panelID]: '34', + }, + }, + { + title: 'dashboardrule1', + } + ), + ], + }, + { + name: 'othergroup', + rules: [ + mockRulerGrafanaRule( + { + annotations: { + [Annotation.dashboardUID]: '121', + [Annotation.panelID]: '341', + }, + }, + { + title: 'dashboardrule2', + } + ), + ], + }, + ], +}; + +const dashboard = { + uid: '12', + time: { + from: 'now-6h', + to: 'now', + }, + timepicker: { refresh_intervals: 5 }, + meta: { + canSave: true, + folderId: 1, + folderTitle: 'super folder', + }, + isSnapshot: () => false, +} as unknown as DashboardModel; + +const panel = new PanelModel({ + datasource: { + type: 'prometheus', + uid: dataSources.prometheus.uid, + }, + title: 'mypanel', + id: 34, + targets: [ + { + expr: 'sum(some_metric [$__interval])) by (app)', + refId: 'A', + }, + ], +}); + +const ui = { + row: byTestId('row'), + createButton: byTestId<HTMLButtonElement>('create-alert-rule-button'), +}; + +describe('PanelAlertTabContent', () => { + beforeEach(() => { + jest.resetAllMocks(); + grantUserPermissions([ + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleUpdate, + AccessControlAction.AlertingRuleDelete, + AccessControlAction.AlertingRuleCreate, + AccessControlAction.AlertingRuleExternalRead, + AccessControlAction.AlertingRuleExternalWrite, + ]); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + const dsService = new MockDataSourceSrv(dataSources); + dsService.datasources[dataSources.prometheus.uid] = new PrometheusDatasource( + dataSources.prometheus + ) as DataSourceApi; + dsService.datasources[dataSources.default.uid] = new PrometheusDatasource(dataSources.default) as DataSourceApi; + setDataSourceSrv(dsService); + }); + + it('Will take into account panel maxDataPoints', async () => { + dashboard.panels = [ + new PanelModel({ + ...panel, + maxDataPoints: 100, + interval: '10s', + }), + ]; + + renderAlertTab(dashboard); + + const defaults = await clickNewButton(); + + expect(defaults.queries[0].model).toEqual({ + expr: 'sum(some_metric [5m])) by (app)', + refId: 'A', + datasource: { + type: 'prometheus', + uid: 'mock-ds-2', + }, + interval: '', + intervalMs: 300000, + maxDataPoints: 100, + }); + }); + + it('Will work with default datasource', async () => { + dashboard.panels = [ + new PanelModel({ + ...panel, + datasource: undefined, + maxDataPoints: 100, + interval: '10s', + }), + ]; + + renderAlertTab(dashboard); + const defaults = await clickNewButton(); + + expect(defaults.queries[0].model).toEqual({ + expr: 'sum(some_metric [5m])) by (app)', + refId: 'A', + datasource: { + type: 'prometheus', + uid: 'mock-ds-3', + }, + interval: '', + intervalMs: 300000, + maxDataPoints: 100, + }); + }); + + it('Will take into account datasource minInterval', async () => { + (getDatasourceSrv() as unknown as MockDataSourceSrv).datasources[dataSources.prometheus.uid].interval = '7m'; + + dashboard.panels = [ + new PanelModel({ + ...panel, + maxDataPoints: 100, + }), + ]; + + renderAlertTab(dashboard); + const defaults = await clickNewButton(); + + expect(defaults.queries[0].model).toEqual({ + expr: 'sum(some_metric [7m])) by (app)', + refId: 'A', + datasource: { + type: 'prometheus', + uid: 'mock-ds-2', + }, + interval: '', + intervalMs: 420000, + maxDataPoints: 100, + }); + }); + + it('Will render alerts belonging to panel and a button to create alert from panel queries', async () => { + mocks.api.fetchRules.mockResolvedValue(rules); + mocks.api.fetchRulerRules.mockResolvedValue(rulerRules); + + dashboard.panels = [panel]; + + renderAlertTab(dashboard); + + const rows = await ui.row.findAll(); + expect(rows).toHaveLength(1); + expect(rows[0]).toHaveTextContent(/dashboardrule1/); + expect(rows[0]).not.toHaveTextContent(/dashboardrule2/); + + const defaults = await clickNewButton(); + + const defaultsWithDeterministicTime: Partial<RuleFormValues> = { + ...defaults, + queries: defaults.queries.map((q: AlertQuery) => { + return { + ...q, + // Fix computed time stamp to avoid assertion flakiness + ...(q.relativeTimeRange ? { relativeTimeRange: { from: 21600, to: 0 } } : {}), + }; + }), + }; + + expect(defaultsWithDeterministicTime).toMatchSnapshot(); + + expect(mocks.api.fetchRulerRules).toHaveBeenCalledWith( + { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, + { + dashboardUID: dashboard.uid, + panelId: panel.id, + } + ); + expect(mocks.api.fetchRules).toHaveBeenCalledWith( + GRAFANA_RULES_SOURCE_NAME, + { + dashboardUID: dashboard.uid, + panelId: panel.id, + }, + undefined, + undefined, + undefined, + undefined + ); + }); +}); + +function renderAlertTab(dashboard: DashboardModel) { + const model = createModel(dashboard); + renderAlertTabContent(model); +} + +async function clickNewButton() { + const pushMock = jest.fn(); + const oldPush = locationService.push; + locationService.push = pushMock; + const button = await ui.createButton.find(); + await act(async () => { + await userEvent.click(button); + }); + const match = pushMock.mock.lastCall[0].match(/alerting\/new\?defaults=(.*)&returnTo=/); + const defaults = JSON.parse(decodeURIComponent(match![1])); + locationService.push = oldPush; + return defaults; +} + +function createModel(dashboard: DashboardModel) { + const scene = createDashboardSceneFromDashboardModel(dashboard); + const vizPanel = findVizPanelByKey(scene, getVizPanelKeyForPanelId(34))!; + const model = new PanelDataAlertingTab(VizPanelManager.createFor(vizPanel)); + jest.spyOn(utils, 'getDashboardSceneFor').mockReturnValue(scene); + return model; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx index e7d147c19c0ed..754929a73f87c 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx @@ -1,36 +1,142 @@ +import { css } from '@emotion/css'; import React from 'react'; -import { IconName } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { SceneObjectBase, SceneComponentProps } from '@grafana/scenes'; +import { Alert, LoadingPlaceholder, Tab, useStyles2 } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; +import { RulesTable } from 'app/features/alerting/unified/components/rules/RulesTable'; +import { usePanelCombinedRules } from 'app/features/alerting/unified/hooks/usePanelCombinedRules'; +import { getRulesPermissions } from 'app/features/alerting/unified/utils/access-control'; +import { getDashboardSceneFor, getPanelIdForVizPanel } from '../../utils/utils'; import { VizPanelManager } from '../VizPanelManager'; -import { PanelDataPaneTabState, PanelDataPaneTab } from './types'; +import { ScenesNewRuleFromPanelButton } from './NewAlertRuleButton'; +import { PanelDataPaneTabState, PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; export class PanelDataAlertingTab extends SceneObjectBase<PanelDataPaneTabState> implements PanelDataPaneTab { static Component = PanelDataAlertingTabRendered; - tabId = 'alert'; - icon: IconName = 'bell'; + TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; + + tabId = TabId.Alert; private _panelManager: VizPanelManager; constructor(panelManager: VizPanelManager) { super({}); - + this.TabComponent = (props: PanelDataTabHeaderProps) => AlertingTab({ ...props, model: this }); this._panelManager = panelManager; } + getTabLabel() { return 'Alert'; } - getItemsCount() { - return 0; + getDashboardUID() { + const dashboard = this.getDashboard(); + return dashboard.state.uid!; + } + + getDashboard() { + return getDashboardSceneFor(this._panelManager); + } + + getLegacyPanelId() { + return getPanelIdForVizPanel(this._panelManager.state.panel); + } + + getCanCreateRules() { + const rulesPermissions = getRulesPermissions('grafana'); + return this.getDashboard().state.meta.canSave && contextSrv.hasPermission(rulesPermissions.create); } get panelManager() { return this._panelManager; } + + get panel() { + return this._panelManager.state.panel; + } +} + +export function PanelDataAlertingTabRendered(props: SceneComponentProps<PanelDataAlertingTab>) { + const { model } = props; + + const styles = useStyles2(getStyles); + + const { errors, loading, rules } = usePanelCombinedRules({ + dashboardUID: model.getDashboardUID(), + panelId: model.getLegacyPanelId(), + }); + + const alert = errors.length ? ( + <Alert title="Errors loading rules" severity="error"> + {errors.map((error, index) => ( + <div key={index}>Failed to load Grafana rules state: {error.message || 'Unknown error.'}</div> + ))} + </Alert> + ) : null; + + if (loading && !rules.length) { + return ( + <> + {alert} + <LoadingPlaceholder text="Loading rules..." /> + </> + ); + } + + const { panel } = model; + const canCreateRules = model.getCanCreateRules(); + + if (rules.length) { + return ( + <> + <RulesTable rules={rules} /> + {canCreateRules && <ScenesNewRuleFromPanelButton className={styles.newButton} panel={panel} />} + </> + ); + } + + return ( + <div className={styles.noRulesWrapper}> + <p>There are no alert rules linked to this panel.</p> + {canCreateRules && <ScenesNewRuleFromPanelButton panel={panel}></ScenesNewRuleFromPanelButton>} + </div> + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + newButton: css({ + marginTop: theme.spacing(3), + }), + noRulesWrapper: css({ + margin: theme.spacing(2), + backgroundColor: theme.colors.background.secondary, + padding: theme.spacing(3), + }), +}); +interface PanelDataAlertingTabHeaderProps extends PanelDataTabHeaderProps { + model: PanelDataAlertingTab; } -function PanelDataAlertingTabRendered(props: SceneComponentProps<PanelDataAlertingTab>) { - return <div>TODO Alerting</div>; +function AlertingTab(props: PanelDataAlertingTabHeaderProps) { + const { model } = props; + + const { rules } = usePanelCombinedRules({ + dashboardUID: model.getDashboardUID(), + panelId: model.getLegacyPanelId(), + poll: false, + }); + + return ( + <Tab + key={props.key} + label={model.getTabLabel()} + icon="bell" + counter={rules.length} + active={props.active} + onChangeTab={props.onChangeTab} + /> + ); } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx index 24976dcbc0b42..66a5c9816f543 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx @@ -1,31 +1,29 @@ +import { css } from '@emotion/css'; import React from 'react'; import { Unsubscribable } from 'rxjs'; +import { GrafanaTheme2 } from '@grafana/data'; import { SceneComponentProps, - SceneDataTransformer, SceneObjectBase, SceneObjectState, SceneObjectUrlSyncConfig, SceneObjectUrlValues, - SceneQueryRunner, VizPanel, - sceneGraph, } from '@grafana/scenes'; -import { Tab, TabContent, TabsBar } from '@grafana/ui'; +import { Container, CustomScrollbar, TabContent, TabsBar, useStyles2 } from '@grafana/ui'; import { shouldShowAlertingTab } from 'app/features/dashboard/components/PanelEditor/state/selectors'; -import { ShareQueryDataProvider } from '../../scene/ShareQueryDataProvider'; import { VizPanelManager } from '../VizPanelManager'; import { PanelDataAlertingTab } from './PanelDataAlertingTab'; import { PanelDataQueriesTab } from './PanelDataQueriesTab'; import { PanelDataTransformationsTab } from './PanelDataTransformationsTab'; -import { PanelDataPaneTab } from './types'; +import { PanelDataPaneTab, TabId } from './types'; export interface PanelDataPaneState extends SceneObjectState { tabs?: PanelDataPaneTab[]; - tab?: string; + tab?: TabId; } export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> { @@ -46,13 +44,14 @@ export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> { return; } if (typeof values.tab === 'string') { - this.setState({ tab: values.tab }); + this.setState({ tab: values.tab as TabId }); } } constructor(panelMgr: VizPanelManager) { super({ - tab: 'queries', + tab: TabId.Queries, + tabs: [], }); this.panelManager = panelMgr; @@ -96,39 +95,20 @@ export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> { }); } - private getDataObjects(): [SceneQueryRunner | ShareQueryDataProvider | undefined, SceneDataTransformer | undefined] { - const dataObj = sceneGraph.getData(this.panelManager.state.panel); - - let runner: SceneQueryRunner | ShareQueryDataProvider | undefined; - let transformer: SceneDataTransformer | undefined; - - if (dataObj instanceof SceneQueryRunner || dataObj instanceof ShareQueryDataProvider) { - runner = dataObj; - } - - if (dataObj instanceof SceneDataTransformer) { - transformer = dataObj; - if (transformer.state.$data instanceof SceneQueryRunner) { - runner = transformer.state.$data; - } - } - - return [runner, transformer]; - } - private buildTabs() { const panelManager = this.panelManager; const panel = panelManager.state.panel; - const [runner] = this.getDataObjects(); + + const runner = this.panelManager.queryRunner; const tabs: PanelDataPaneTab[] = []; if (panel) { const plugin = panel.getPlugin(); if (!plugin) { - this.setState({ tabs }); return; } + if (plugin.meta.skipDataQuery) { this.setState({ tabs }); return; @@ -155,6 +135,7 @@ export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> { function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) { const { tab, tabs } = model.useState(); + const styles = useStyles2(getStyles); if (!tabs) { return; @@ -163,22 +144,50 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) { const currentTab = tabs.find((t) => t.tabId === tab); return ( - <div> - <TabsBar hideBorder={true}> + <div className={styles.dataPane}> + <TabsBar hideBorder={true} className={styles.tabsBar}> {tabs.map((t, index) => { return ( - <Tab + <t.TabComponent key={`${t.getTabLabel()}-${index}`} - label={t.getTabLabel()} - icon={t.icon} - counter={t.getItemsCount?.()} active={t.tabId === tab} onChangeTab={() => model.onChangeTab(t)} - /> + ></t.TabComponent> ); })} </TabsBar> - <TabContent>{currentTab && <currentTab.Component model={currentTab} />}</TabContent> + <CustomScrollbar className={styles.scroll}> + <TabContent className={styles.tabContent}> + <Container>{currentTab && <currentTab.Component model={currentTab} />}</Container> + </TabContent> + </CustomScrollbar> </div> ); } + +function getStyles(theme: GrafanaTheme2) { + return { + dataPane: css({ + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + minHeight: 0, + height: '100%', + }), + tabContent: css({ + padding: theme.spacing(2), + border: `1px solid ${theme.colors.border.weak}`, + borderLeft: 'none', + borderBottom: 'none', + borderTopRightRadius: theme.shape.radius.default, + flexGrow: 1, + }), + tabsBar: css({ + flexShrink: 0, + paddingLeft: theme.spacing(2), + }), + scroll: css({ + background: theme.colors.background.primary, + }), + }; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx index 6a8f082824c37..1f47fdabe5af9 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx @@ -1,27 +1,34 @@ import React from 'react'; -import { DataSourceApi, DataSourceInstanceSettings, IconName } from '@grafana/data'; -import { SceneObjectBase, SceneComponentProps, SceneQueryRunner, sceneGraph } from '@grafana/scenes'; +import { CoreApp, DataSourceApi, DataSourceInstanceSettings, IconName } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; +import { SceneObjectBase, SceneComponentProps, sceneGraph, SceneQueryRunner } from '@grafana/scenes'; import { DataQuery } from '@grafana/schema'; +import { Button, HorizontalGroup, Tab } from '@grafana/ui'; +import { addQuery } from 'app/core/utils/query'; +import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource'; +import { GroupActionComponents } from 'app/features/query/components/QueryActionComponent'; import { QueryEditorRows } from 'app/features/query/components/QueryEditorRows'; import { QueryGroupTopSection } from 'app/features/query/components/QueryGroup'; +import { isSharedDashboardQuery } from 'app/plugins/datasource/dashboard'; import { GrafanaQuery } from 'app/plugins/datasource/grafana/types'; import { QueryGroupOptions } from 'app/types'; import { PanelTimeRange } from '../../scene/PanelTimeRange'; -import { ShareQueryDataProvider } from '../../scene/ShareQueryDataProvider'; import { VizPanelManager } from '../VizPanelManager'; -import { PanelDataPaneTabState, PanelDataPaneTab } from './types'; +import { PanelDataPaneTabState, PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; interface PanelDataQueriesTabState extends PanelDataPaneTabState { - // dataRef: SceneObjectRef<SceneQueryRunner | ShareQueryDataProvider>; datasource?: DataSourceApi; dsSettings?: DataSourceInstanceSettings; } export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabState> implements PanelDataPaneTab { static Component = PanelDataQueriesTabRendered; - tabId = 'queries'; + TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; + + tabId = TabId.Queries; icon: IconName = 'database'; private _panelManager: VizPanelManager; @@ -30,21 +37,14 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat } getItemsCount() { - const dataObj = this._panelManager.state.panel.state.$data!; - - if (dataObj instanceof ShareQueryDataProvider) { - return 1; - } - - if (dataObj instanceof SceneQueryRunner) { - return dataObj.state.queries.length; - } - - return null; + return this.getQueries().length; } constructor(panelManager: VizPanelManager) { super({}); + this.TabComponent = (props: PanelDataTabHeaderProps) => { + return QueriesTab({ ...props, model: this }); + }; this._panelManager = panelManager; } @@ -52,9 +52,7 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat buildQueryOptions(): QueryGroupOptions { const panelManager = this._panelManager; const panelObj = this._panelManager.state.panel; - const dataObj = panelObj.state.$data!; const queryRunner = this._panelManager.queryRunner; - const timeRangeObj = sceneGraph.getTimeRange(panelObj); let timeRangeOpts: QueryGroupOptions['timeRange'] = { @@ -71,19 +69,15 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat }; } - let queries: QueryGroupOptions['queries'] = []; - if (dataObj instanceof ShareQueryDataProvider) { - queries = [dataObj.state.query]; - } - - if (dataObj instanceof SceneQueryRunner) { - queries = dataObj.state.queries; - } + let queries: QueryGroupOptions['queries'] = queryRunner.state.queries; return { - // TODO - // cacheTimeout: dsSettings?.meta.queryOptions?.cacheTimeout ? panel.cacheTimeout : undefined, - // queryCachingTTL: dsSettings?.cachingConfig?.enabled ? panel.queryCachingTTL : undefined, + cacheTimeout: panelManager.state.dsSettings?.meta.queryOptions?.cacheTimeout + ? queryRunner.state.cacheTimeout + : undefined, + queryCachingTTL: panelManager.state.dsSettings?.cachingConfig?.enabled + ? queryRunner.state.queryCachingTTL + : undefined, dataSource: { default: panelManager.state.dsSettings?.isDefault, type: panelManager.state.dsSettings?.type, @@ -115,34 +109,76 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat this._panelManager.changeQueries(queries); }; - getQueries() { - const dataObj = this._panelManager.state.panel.state.$data!; + onRunQueries = () => { + this._panelManager.queryRunner.runQueries(); + }; - if (dataObj instanceof ShareQueryDataProvider) { - return [dataObj.state.query]; - } + getQueries() { return this._panelManager.queryRunner.state.queries; } + newQuery(): Partial<DataQuery> { + const { dsSettings, datasource } = this._panelManager.state; + + const ds = !dsSettings?.meta.mixed ? dsSettings : datasource; + + return { + ...datasource?.getDefaultQuery?.(CoreApp.PanelEditor), + datasource: { uid: ds?.uid, type: ds?.type }, + }; + } + + addQueryClick = () => { + const queries = this.getQueries(); + this.onQueriesChange(addQuery(queries, this.newQuery())); + }; + + onAddQuery = (query: Partial<DataQuery>) => { + const queries = this.getQueries(); + const dsSettings = this._panelManager.state.dsSettings; + this.onQueriesChange(addQuery(queries, query, { type: dsSettings?.type, uid: dsSettings?.uid })); + }; + + isExpressionsSupported(dsSettings: DataSourceInstanceSettings): boolean { + return (dsSettings.meta.alerting || dsSettings.meta.mixed) === true; + } + + onAddExpressionClick = () => { + const queries = this.getQueries(); + this.onQueriesChange(addQuery(queries, expressionDatasource.newQuery())); + }; + + renderExtraActions() { + return GroupActionComponents.getAllExtraRenderAction() + .map((action, index) => + action({ + onAddQuery: this.onAddQuery, + onChangeDataSource: this.onChangeDataSource, + key: index, + }) + ) + .filter(Boolean); + } + + get queryRunner(): SceneQueryRunner { + return this._panelManager.queryRunner; + } + get panelManager() { return this._panelManager; } } function PanelDataQueriesTabRendered({ model }: SceneComponentProps<PanelDataQueriesTab>) { - const { panel, datasource, dsSettings } = model.panelManager.useState(); - const { $data: dataObj } = panel.useState(); - - if (!dataObj) { - return; - } - - const { data } = dataObj!.useState(); + const { datasource, dsSettings } = model.panelManager.useState(); + const { data } = model.panelManager.queryRunner.useState(); if (!datasource || !dsSettings || !data) { return null; } + const showAddButton = !isSharedDashboardQuery(dsSettings.name); + return ( <> <QueryGroupTopSection @@ -155,18 +191,59 @@ function PanelDataQueriesTabRendered({ model }: SceneComponentProps<PanelDataQue onOpenQueryInspector={model.onOpenInspector} /> - {dataObj instanceof ShareQueryDataProvider ? ( - <h1>TODO: DashboardQueryEditor</h1> - ) : ( - <QueryEditorRows - data={data} - queries={model.getQueries()} - dsSettings={dsSettings} - onAddQuery={() => {}} - onQueriesChange={model.onQueriesChange} - onRunQueries={() => {}} - /> - )} + <QueryEditorRows + data={data} + queries={model.getQueries()} + dsSettings={dsSettings} + onAddQuery={model.onAddQuery} + onQueriesChange={model.onQueriesChange} + onRunQueries={model.onRunQueries} + /> + + <HorizontalGroup spacing="md" align="flex-start"> + {showAddButton && ( + <Button + icon="plus" + onClick={model.addQueryClick} + variant="secondary" + data-testid={selectors.components.QueryTab.addQuery} + > + Add query + </Button> + )} + {config.expressionsEnabled && model.isExpressionsSupported(dsSettings) && ( + <Button + icon="plus" + onClick={model.onAddExpressionClick} + variant="secondary" + data-testid="query-tab-add-expression" + > + <span>Expression </span> + </Button> + )} + {model.renderExtraActions()} + </HorizontalGroup> </> ); } + +interface QueriesTabProps extends PanelDataTabHeaderProps { + model: PanelDataQueriesTab; +} + +function QueriesTab(props: QueriesTabProps) { + const { model } = props; + + const queryRunnerState = model.queryRunner.useState(); + + return ( + <Tab + key={props.key} + label={model.getTabLabel()} + icon="database" + counter={queryRunnerState.queries.length} + active={props.active} + onChangeTab={props.onChangeTab} + /> + ); +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.test.tsx new file mode 100644 index 0000000000000..2b9766c54d191 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.test.tsx @@ -0,0 +1,184 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { + DataTransformerConfig, + FieldType, + LoadingState, + PanelData, + TimeRange, + standardTransformersRegistry, + toDataFrame, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { SceneDataTransformer, SceneQueryRunner } from '@grafana/scenes'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; +import { DashboardDataDTO } from 'app/types'; + +import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene'; +import { DashboardModelCompatibilityWrapper } from '../../utils/DashboardModelCompatibilityWrapper'; +import { findVizPanelByKey } from '../../utils/utils'; +import { VizPanelManager } from '../VizPanelManager'; +import { testDashboard } from '../testfiles/testDashboard'; + +import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab'; + +function createModelMock( + panelData: PanelData, + transformations?: DataTransformerConfig[], + onChangeTransformationsMock?: Function +) { + return { + getDataTransformer: () => new SceneDataTransformer({ data: panelData, transformations: transformations || [] }), + getQueryRunner: () => new SceneQueryRunner({ queries: [], data: panelData }), + onChangeTransformations: onChangeTransformationsMock, + } as unknown as PanelDataTransformationsTab; +} + +const mockData = { + timeRange: {} as unknown as TimeRange, + state: {} as unknown as LoadingState, + series: [ + toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'values', type: FieldType.number, values: [1, 2, 3] }, + ], + }), + ], +}; + +describe('PanelDataTransformationsModel', () => { + it('can change transformations', () => { + const vizPanelManager = setupVizPanelManger('panel-1'); + const model = new PanelDataTransformationsTab(vizPanelManager); + model.onChangeTransformations([{ id: 'calculateField', options: {} }]); + expect(model.getDataTransformer().state.transformations).toEqual([{ id: 'calculateField', options: {} }]); + }); +}); + +describe('PanelDataTransformationsTab', () => { + standardTransformersRegistry.setInit(getStandardTransformers); + + it('renders empty message when there are no transformations', async () => { + const modelMock = createModelMock({} as PanelData); + render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>); + + await screen.findByTestId(selectors.components.Transforms.noTransformationsMessage); + }); + + it('renders transformations when there are transformations', async () => { + const modelMock = createModelMock(mockData, [ + { + id: 'calculateField', + options: {}, + }, + ]); + render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>); + + await screen.findByText('1 - Add field from calculation'); + }); + + it('shows show the transformation selection drawer', async () => { + const modelMock = createModelMock(mockData); + render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>); + const addButton = await screen.findByTestId(selectors.components.Transforms.addTransformationButton); + await userEvent.click(addButton); + await screen.findByTestId(selectors.components.Transforms.searchInput); + }); + + it('adds a transformation when a transformation is clicked in the drawer and there are no previous transformations', async () => { + const onChangeTransformation = jest.fn(); + const modelMock = createModelMock(mockData, [], onChangeTransformation); + render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>); + const addButton = await screen.findByTestId(selectors.components.Transforms.addTransformationButton); + await userEvent.click(addButton); + const transformationCard = await screen.findByTestId( + selectors.components.TransformTab.newTransform('Add field from calculation') + ); + const button = transformationCard.getElementsByTagName('button').item(0); + await userEvent.click(button!); + + expect(onChangeTransformation).toHaveBeenCalledWith([{ id: 'calculateField', options: {} }]); + }); + + it('adds a transformation when a transformation is clicked in the drawer and there are transformations', async () => { + const onChangeTransformation = jest.fn(); + const modelMock = createModelMock( + mockData, + [ + { + id: 'calculateField', + options: {}, + }, + ], + onChangeTransformation + ); + render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>); + const addButton = await screen.findByTestId(selectors.components.Transforms.addTransformationButton); + await userEvent.click(addButton); + const transformationCard = await screen.findByTestId( + selectors.components.TransformTab.newTransform('Add field from calculation') + ); + const button = transformationCard.getElementsByTagName('button').item(0); + await userEvent.click(button!); + expect(onChangeTransformation).toHaveBeenCalledWith([ + { id: 'calculateField', options: {} }, + { id: 'calculateField', options: {} }, + ]); + }); + + it('deletes all transformations', async () => { + const onChangeTransformation = jest.fn(); + const modelMock = createModelMock( + mockData, + [ + { + id: 'calculateField', + options: {}, + }, + ], + onChangeTransformation + ); + render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>); + const removeButton = await screen.findByTestId(selectors.components.Transforms.removeAllTransformationsButton); + await userEvent.click(removeButton); + const confirmButton = await screen.findByTestId(selectors.pages.ConfirmModal.delete); + await userEvent.click(confirmButton); + + expect(onChangeTransformation).toHaveBeenCalledWith([]); + }); + + it('can filter transformations in the drawer', async () => { + const modelMock = createModelMock(mockData); + render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>); + const addButton = await screen.findByTestId(selectors.components.Transforms.addTransformationButton); + await userEvent.click(addButton); + + const searchInput = await screen.findByTestId(selectors.components.Transforms.searchInput); + + await screen.findByTestId(selectors.components.TransformTab.newTransform('Reduce')); + + await userEvent.type(searchInput, 'add field'); + + await screen.findByTestId(selectors.components.TransformTab.newTransform('Add field from calculation')); + const reduce = screen.queryByTestId(selectors.components.TransformTab.newTransform('Reduce')); + expect(reduce).toBeNull(); + }); +}); + +const setupVizPanelManger = (panelId: string) => { + const scene = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} }); + const panel = findVizPanelByKey(scene, panelId)!; + + const vizPanelManager = VizPanelManager.createFor(panel); + + // The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it + // @ts-expect-error + getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene)); + + return vizPanelManager; +}; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx index c0025078ed7b7..f10b53be30ffe 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx @@ -1,11 +1,18 @@ -import React from 'react'; +import { css } from '@emotion/css'; +import React, { useState } from 'react'; +import { DragDropContext, DropResult, Droppable } from 'react-beautiful-dnd'; -import { IconName } from '@grafana/data'; -import { SceneObjectBase, SceneComponentProps } from '@grafana/scenes'; +import { DataTransformerConfig, GrafanaTheme2, IconName, PanelData } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { SceneObjectBase, SceneComponentProps, SceneDataTransformer, SceneQueryRunner } from '@grafana/scenes'; +import { Button, ButtonGroup, ConfirmModal, Tab, useStyles2 } from '@grafana/ui'; +import { TransformationOperationRows } from 'app/features/dashboard/components/TransformationsEditor/TransformationOperationRows'; import { VizPanelManager } from '../VizPanelManager'; -import { PanelDataPaneTabState, PanelDataPaneTab } from './types'; +import { EmptyTransformationsMessage } from './EmptyTransformationsMessage'; +import { TransformationsDrawer } from './TransformationsDrawer'; +import { PanelDataPaneTabState, PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; interface PanelDataTransformationsTabState extends PanelDataPaneTabState {} @@ -14,7 +21,9 @@ export class PanelDataTransformationsTab implements PanelDataPaneTab { static Component = PanelDataTransformationsTabRendered; - tabId = 'transformations'; + TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; + + tabId = TabId.Transformations; icon: IconName = 'process'; private _panelManager: VizPanelManager; @@ -22,25 +31,185 @@ export class PanelDataTransformationsTab return 'Transformations'; } - getItemsCount() { - return 0; - } - constructor(panelManager: VizPanelManager) { super({}); + this.TabComponent = (props: PanelDataTabHeaderProps) => TransformationsTab({ ...props, model: this }); this._panelManager = panelManager; } + public getQueryRunner(): SceneQueryRunner { + return this._panelManager.queryRunner; + } + + public getDataTransformer(): SceneDataTransformer { + return this._panelManager.dataTransformer; + } + + public onChangeTransformations(transformations: DataTransformerConfig[]) { + this._panelManager.changeTransformations(transformations); + } + get panelManager() { return this._panelManager; } } -function PanelDataTransformationsTabRendered({ model }: SceneComponentProps<PanelDataTransformationsTab>) { - // const { dataRef } = model.useState(); - // const dataObj = dataRef.resolve(); - // // const { transformations } = dataObj.useState(); +export function PanelDataTransformationsTabRendered({ model }: SceneComponentProps<PanelDataTransformationsTab>) { + const styles = useStyles2(getStyles); + const sourceData = model.getQueryRunner().useState(); + const { data, transformations: transformsWrongType } = model.getDataTransformer().useState(); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const transformations: DataTransformerConfig[] = transformsWrongType as unknown as DataTransformerConfig[]; + + const [drawerOpen, setDrawerOpen] = useState<boolean>(false); + const [confirmModalOpen, setConfirmModalOpen] = useState<boolean>(false); + + const openDrawer = () => setDrawerOpen(true); + const closeDrawer = () => setDrawerOpen(false); + + if (!data || !sourceData.data) { + return; + } + + const transformationsDrawer = ( + <TransformationsDrawer + onClose={closeDrawer} + onTransformationAdd={(selected) => { + if (selected.value === undefined) { + return; + } + model.onChangeTransformations([...transformations, { id: selected.value, options: {} }]); + closeDrawer(); + }} + isOpen={drawerOpen} + series={data.series} + ></TransformationsDrawer> + ); + + if (transformations.length < 1) { + return ( + <> + <EmptyTransformationsMessage onShowPicker={openDrawer}></EmptyTransformationsMessage> + {transformationsDrawer} + </> + ); + } + + return ( + <> + <TransformationsEditor data={sourceData.data} transformations={transformations} model={model} /> + <ButtonGroup> + <Button + icon="plus" + variant="secondary" + onClick={openDrawer} + data-testid={selectors.components.Transforms.addTransformationButton} + > + Add another transformation + </Button> + <Button + data-testid={selectors.components.Transforms.removeAllTransformationsButton} + className={styles.removeAll} + icon="times" + variant="secondary" + onClick={() => setConfirmModalOpen(true)} + > + Delete all transformations + </Button> + </ButtonGroup> + <ConfirmModal + isOpen={confirmModalOpen} + title="Delete all transformations?" + body="By deleting all transformations, you will go back to the main selection screen." + confirmText="Delete all" + onConfirm={() => { + model.onChangeTransformations([]); + setConfirmModalOpen(false); + }} + onDismiss={() => setConfirmModalOpen(false)} + /> + {transformationsDrawer} + </> + ); +} + +interface TransformationEditorProps { + transformations: DataTransformerConfig[]; + model: PanelDataTransformationsTab; + data: PanelData; +} + +function TransformationsEditor({ transformations, model, data }: TransformationEditorProps) { + const transformationEditorRows = transformations.map((t, i) => ({ id: `${i} - ${t.id}`, transformation: t })); + + const onDragEnd = (result: DropResult) => { + if (!result || !result.destination) { + return; + } + + const startIndex = result.source.index; + const endIndex = result.destination.index; + if (startIndex === endIndex) { + return; + } + const update = Array.from(transformationEditorRows); + const [removed] = update.splice(startIndex, 1); + update.splice(endIndex, 0, removed); + model.onChangeTransformations(update.map((t) => t.transformation)); + }; + + return ( + <DragDropContext onDragEnd={onDragEnd}> + <Droppable droppableId="transformations-list" direction="vertical"> + {(provided) => { + return ( + <div ref={provided.innerRef} {...provided.droppableProps}> + <TransformationOperationRows + onChange={(index, transformation) => { + const newTransformations = transformations.slice(); + newTransformations[index] = transformation; + model.onChangeTransformations(newTransformations); + }} + onRemove={(index) => { + const newTransformations = transformations.slice(); + newTransformations.splice(index, 1); + model.onChangeTransformations(newTransformations); + }} + configs={transformationEditorRows} + data={data} + ></TransformationOperationRows> + {provided.placeholder} + </div> + ); + }} + </Droppable> + </DragDropContext> + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + removeAll: css({ + marginLeft: theme.spacing(2), + }), +}); + +interface TransformationsTabProps extends PanelDataTabHeaderProps { + model: PanelDataTransformationsTab; +} + +function TransformationsTab(props: TransformationsTabProps) { + const { model } = props; - return <div>TODO Transformations</div>; + const transformerState = model.getDataTransformer().useState(); + return ( + <Tab + key={props.key} + label={model.getTabLabel()} + icon="process" + counter={transformerState.transformations.length} + active={props.active} + onChangeTab={props.onChangeTab} + /> + ); } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/TransformationsDrawer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/TransformationsDrawer.tsx new file mode 100644 index 0000000000000..5e9e75dd394ed --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/TransformationsDrawer.tsx @@ -0,0 +1,91 @@ +import React, { FormEvent, useMemo, useState } from 'react'; + +import { DataFrame, SelectableValue, standardTransformersRegistry } from '@grafana/data'; +import { IconButton } from '@grafana/ui'; +import { TransformationPickerNg } from 'app/features/dashboard/components/TransformationsEditor/TransformationPickerNg'; +import { + FilterCategory, + VIEW_ALL_VALUE, +} from 'app/features/dashboard/components/TransformationsEditor/TransformationsEditor'; + +interface DrawerState { + search: string; + showIllustrations: boolean; + selectedFilter?: FilterCategory; +} + +interface TransformationsDrawerProps { + series: DataFrame[]; + isOpen: boolean; + onClose: () => void; + onTransformationAdd: (selectedItem: SelectableValue<string>) => void; +} + +export function TransformationsDrawer(props: TransformationsDrawerProps) { + const { isOpen, series, onClose, onTransformationAdd } = props; + + const [drawerState, setDrawerState] = useState<DrawerState>({ + search: '', + showIllustrations: true, + }); + + const onSearchChange = (e: FormEvent<HTMLInputElement>) => + setDrawerState({ ...drawerState, ...{ search: e.currentTarget.value } }); + + const onShowIllustrationsChange = (showIllustrations: boolean): void => + setDrawerState({ ...drawerState, ...{ showIllustrations } }); + + const onSelectedFilterChange = (selectedFilter: FilterCategory): void => + setDrawerState({ ...drawerState, ...{ selectedFilter } }); + + const allTransformations = useMemo( + () => standardTransformersRegistry.list().sort((a, b) => (a.name > b.name ? 1 : b.name > a.name ? -1 : 0)), + [] + ); + + const transformations = allTransformations.filter((t) => { + if ( + drawerState.selectedFilter && + drawerState.selectedFilter !== VIEW_ALL_VALUE && + !t.categories?.has(drawerState.selectedFilter) + ) { + return false; + } + return t.name.toLocaleLowerCase().includes(drawerState.search.toLocaleLowerCase()); + }); + + const searchBoxSuffix = ( + <> + {transformations.length} / {allTransformations.length}    + <IconButton + name="times" + onClick={() => { + setDrawerState({ ...drawerState, ...{ search: '' } }); + }} + tooltip="Clear search" + /> + </> + ); + + if (!isOpen) { + return; + } + + return ( + <TransformationPickerNg + data={series} + onTransformationAdd={onTransformationAdd} + xforms={transformations} + search={drawerState.search} + noTransforms={false} + suffix={drawerState.search !== '' ? searchBoxSuffix : <></>} + selectedFilter={drawerState.selectedFilter} + onSearchChange={onSearchChange} + onSearchKeyDown={() => {}} + showIllustrations={drawerState.showIllustrations} + onShowIllustrationsChange={onShowIllustrationsChange} + onSelectedFilterChange={onSelectedFilterChange} + onClose={onClose} + /> + ); +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/__snapshots__/PanelDataAlertingTab.test.tsx.snap b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/__snapshots__/PanelDataAlertingTab.test.tsx.snap new file mode 100644 index 0000000000000..c6d28162c50bb --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/__snapshots__/PanelDataAlertingTab.test.tsx.snap @@ -0,0 +1,113 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PanelAlertTabContent Will render alerts belonging to panel and a button to create alert from panel queries 1`] = ` +{ + "annotations": [ + { + "key": "__dashboardUid__", + "value": "12", + }, + { + "key": "__panelId__", + "value": "34", + }, + ], + "condition": "C", + "name": "mypanel", + "queries": [ + { + "datasourceUid": "mock-ds-2", + "model": { + "datasource": { + "type": "prometheus", + "uid": "mock-ds-2", + }, + "expr": "sum(some_metric [15s])) by (app)", + "interval": "", + "intervalMs": 15000, + "refId": "A", + }, + "queryType": "", + "refId": "A", + "relativeTimeRange": { + "from": 21600, + "to": 0, + }, + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "B", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "A", + "reducer": "last", + "refId": "B", + "type": "reduce", + }, + "queryType": "", + "refId": "B", + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [ + 0, + ], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "C", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "B", + "refId": "C", + "type": "threshold", + }, + "queryType": "", + "refId": "C", + }, + ], + "type": "grafana", +} +`; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/types.ts b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/types.ts index 973251b628724..5edff739a1669 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/types.ts +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/types.ts @@ -1,11 +1,21 @@ -import { IconName } from '@grafana/data'; import { SceneObject, SceneObjectState } from '@grafana/scenes'; export interface PanelDataPaneTabState extends SceneObjectState {} +export enum TabId { + Queries = 'queries', + Transformations = 'transformations', + Alert = 'alert', +} + +export interface PanelDataTabHeaderProps { + key: string; + active: boolean; + onChangeTab?: (event: React.MouseEvent<HTMLElement>) => void; +} + export interface PanelDataPaneTab extends SceneObject { + TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; getTabLabel(): string; - getItemsCount?(): number | null; - tabId: string; - icon: IconName; + tabId: TabId; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx new file mode 100644 index 0000000000000..6a2478a0ac291 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; +import { InlineSwitch } from '@grafana/ui'; + +import { PanelEditor } from './PanelEditor'; + +export interface Props { + panelEditor: PanelEditor; +} + +export function PanelEditControls({ panelEditor }: Props) { + const vizManager = panelEditor.state.vizManager; + const { panel, tableView } = vizManager.useState(); + const skipDataQuery = config.panels[panel.state.pluginId].skipDataQuery; + + return ( + <> + {!skipDataQuery && ( + <InlineSwitch + label="Table view" + showLabel={true} + id="table-view" + value={tableView ? true : false} + onClick={() => vizManager.toggleTableView()} + aria-label="toggle-table-view" + data-testid={selectors.components.PanelEditor.toggleTableView} + /> + )} + </> + ); +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts new file mode 100644 index 0000000000000..ff8c44c21560b --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts @@ -0,0 +1,183 @@ +import { PanelPlugin, PanelPluginMeta, PluginType } from '@grafana/data'; +import { SceneGridItem, SceneGridLayout, VizPanel } from '@grafana/scenes'; +import * as libAPI from 'app/features/library-panels/state/api'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; +import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; +import { activateFullSceneTree } from '../utils/test-utils'; + +import { buildPanelEditScene } from './PanelEditor'; + +let pluginToLoad: PanelPlugin | undefined; +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getPluginImportUtils: () => ({ + getPanelPluginFromCache: jest.fn(() => pluginToLoad), + }), + config: { + ...jest.requireActual('@grafana/runtime').config, + panels: { + text: { + skipDataQuery: true, + }, + timeseries: { + skipDataQuery: false, + }, + }, + }, +})); + +describe('PanelEditor', () => { + describe('When closing editor', () => { + it('should apply changes automatically', () => { + pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); + + const panel = new VizPanel({ + key: 'panel-1', + pluginId: 'text', + }); + + const editScene = buildPanelEditScene(panel); + const gridItem = new SceneGridItem({ body: panel }); + const scene = new DashboardScene({ + editPanel: editScene, + isEditing: true, + body: new SceneGridLayout({ + children: [gridItem], + }), + }); + + const deactivate = activateFullSceneTree(scene); + + editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); + + deactivate(); + + const updatedPanel = gridItem.state.body as VizPanel; + expect(updatedPanel?.state.title).toBe('changed title'); + }); + }); + + describe('Handling library panels', () => { + it('should call the api with the updated panel', async () => { + pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); + const panel = new VizPanel({ + key: 'panel-1', + pluginId: 'text', + }); + + const libraryPanelModel = { + title: 'title', + uid: 'uid', + name: 'libraryPanelName', + model: vizPanelToPanel(panel), + type: 'panel', + version: 1, + }; + + const libraryPanel = new LibraryVizPanel({ + isLoaded: true, + title: libraryPanelModel.title, + uid: libraryPanelModel.uid, + name: libraryPanelModel.name, + panelKey: panel.state.key!, + panel: panel, + _loadedPanel: libraryPanelModel, + }); + + const apiCall = jest + .spyOn(libAPI, 'updateLibraryVizPanel') + .mockResolvedValue({ type: 'panel', ...libAPI.libraryVizPanelToSaveModel(libraryPanel), version: 2 }); + + const editScene = buildPanelEditScene(panel); + const gridItem = new SceneGridItem({ body: libraryPanel }); + const scene = new DashboardScene({ + editPanel: editScene, + isEditing: true, + body: new SceneGridLayout({ + children: [gridItem], + }), + }); + + activateFullSceneTree(scene); + + editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); + (editScene.state.vizManager.state.sourcePanel.resolve().parent as LibraryVizPanel).setState({ + name: 'changed name', + }); + editScene.state.vizManager.commitChanges(); + + const calledWith = apiCall.mock.calls[0][0].state; + expect(calledWith.panel?.state.title).toBe('changed title'); + expect(calledWith.name).toBe('changed name'); + + await new Promise(process.nextTick); // Wait for mock api to return and update the library panel + expect((gridItem.state.body as LibraryVizPanel).state._loadedPanel?.version).toBe(2); + }); + }); + + describe('PanelDataPane', () => { + it('should not exist if panel is skipDataQuery', () => { + pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); + + const panel = new VizPanel({ + key: 'panel-1', + pluginId: 'text', + }); + const editScene = buildPanelEditScene(panel); + const scene = new DashboardScene({ + editPanel: editScene, + }); + + activateFullSceneTree(scene); + + expect(editScene.state.dataPane).toBeUndefined(); + }); + + it('should exist if panel is supporting querying', () => { + pluginToLoad = getTestPanelPlugin({ id: 'timeseries' }); + + const panel = new VizPanel({ + key: 'panel-1', + pluginId: 'timeseries', + }); + const editScene = buildPanelEditScene(panel); + const scene = new DashboardScene({ + editPanel: editScene, + }); + + activateFullSceneTree(scene); + expect(editScene.state.dataPane).toBeDefined(); + }); + }); +}); + +export function getTestPanelPlugin(options: Partial<PanelPluginMeta>): PanelPlugin { + const plugin = new PanelPlugin(() => null); + plugin.meta = { + id: options.id!, + type: PluginType.panel, + name: options.id!, + sort: options.sort || 1, + info: { + author: { + name: options.id + 'name', + }, + description: '', + links: [], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '', + version: '1.0.', + }, + hideFromList: options.hideFromList === true, + module: options.module ?? '', + baseUrl: '', + skipDataQuery: options.skipDataQuery ?? false, + }; + return plugin; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index 1392b03b0253d..cb1a6eb4d8dec 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -1,161 +1,240 @@ import * as H from 'history'; import { NavIndex } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; -import { - getUrlSyncManager, - SceneFlexItem, - SceneFlexLayout, - SceneObject, - SceneObjectBase, - SceneObjectRef, - SceneObjectState, - sceneUtils, - SplitLayout, - VizPanel, -} from '@grafana/scenes'; -import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; - -import { DashboardScene } from '../scene/DashboardScene'; -import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; -import { getDashboardUrl } from '../utils/urlBuilders'; +import { config, locationService } from '@grafana/runtime'; +import { SceneGridItem, SceneGridLayout, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; + +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; +import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem'; +import { getDashboardSceneFor, getPanelIdForVizPanel } from '../utils/utils'; import { PanelDataPane } from './PanelDataPane/PanelDataPane'; import { PanelEditorRenderer } from './PanelEditorRenderer'; -import { PanelEditorUrlSync } from './PanelEditorUrlSync'; import { PanelOptionsPane } from './PanelOptionsPane'; -import { PanelVizTypePicker } from './PanelVizTypePicker'; -import { VizPanelManager } from './VizPanelManager'; +import { VizPanelManager, VizPanelManagerState } from './VizPanelManager'; export interface PanelEditorState extends SceneObjectState { - body: SceneObject; - controls?: SceneObject[]; isDirty?: boolean; - /** Panel to inspect */ - inspectPanelKey?: string; - /** Scene object that handles the current drawer */ - overlay?: SceneObject; - - dashboardRef: SceneObjectRef<DashboardScene>; - sourcePanelRef: SceneObjectRef<VizPanel>; - panelRef: SceneObjectRef<VizPanelManager>; + panelId: number; + optionsPane: PanelOptionsPane; + dataPane?: PanelDataPane; + vizManager: VizPanelManager; + showLibraryPanelSaveModal?: boolean; + showLibraryPanelUnlinkModal?: boolean; } export class PanelEditor extends SceneObjectBase<PanelEditorState> { + private _initialRepeatOptions: Pick<VizPanelManagerState, 'repeat' | 'repeatDirection' | 'maxPerRow'> = {}; static Component = PanelEditorRenderer; - /** - * Handles url sync - */ - protected _urlSync = new PanelEditorUrlSync(this); + private _discardChanges = false; public constructor(state: PanelEditorState) { super(state); - this.addActivationHandler(() => this._activationHandler()); + const { repeat, repeatDirection, maxPerRow } = state.vizManager.state; + this._initialRepeatOptions = { + repeat, + repeatDirection, + maxPerRow, + }; + + this.addActivationHandler(this._activationHandler.bind(this)); } private _activationHandler() { - const oldDashboardWrapper = new DashboardModelCompatibilityWrapper(this.state.dashboardRef.resolve()); - // @ts-expect-error - getDashboardSrv().setCurrent(oldDashboardWrapper); + const panelManager = this.state.vizManager; + const panel = panelManager.state.panel; + + this._subs.add( + panelManager.subscribeToState((n, p) => { + if (n.panel.state.pluginId !== p.panel.state.pluginId) { + this._initDataPane(n.panel.state.pluginId); + } + }) + ); + + this._initDataPane(panel.state.pluginId); - // Deactivation logic return () => { - getUrlSyncManager().cleanUp(this); + if (!this._discardChanges) { + this.commitChanges(); + } }; } - public startUrlSync() { - getUrlSyncManager().initSync(this); + private _initDataPane(pluginId: string) { + const skipDataQuery = config.panels[pluginId].skipDataQuery; + + if (skipDataQuery && this.state.dataPane) { + locationService.partial({ tab: null }, true); + this.setState({ dataPane: undefined }); + } + + if (!skipDataQuery && !this.state.dataPane) { + this.setState({ dataPane: new PanelDataPane(this.state.vizManager) }); + } + } + + public getUrlKey() { + return this.state.panelId.toString(); } public getPageNav(location: H.Location, navIndex: NavIndex) { + const dashboard = getDashboardSceneFor(this); + return { text: 'Edit panel', - parentItem: this.state.dashboardRef.resolve().getPageNav(location, navIndex), + parentItem: dashboard.getPageNav(location, navIndex), }; } public onDiscard = () => { - // Open question on what to preserve when going back - // Preserve time range, and variables state (that might have been changed while in panel edit) - // Preserve current panel data? (say if you just changed the time range and have new data) - this._navigateBackToDashboard(); - }; - - public onApply = () => { - this._commitChanges(); - this._navigateBackToDashboard(); + this._discardChanges = true; + locationService.partial({ editPanel: null }); }; - public onSave = () => { - this._commitChanges(); - // Open dashboard save drawer - }; - - private _commitChanges() { - const dashboard = this.state.dashboardRef.resolve(); - const sourcePanel = this.state.sourcePanelRef.resolve(); - - const panelMngr = this.state.panelRef.resolve(); + public commitChanges() { + const dashboard = getDashboardSceneFor(this); if (!dashboard.state.isEditing) { dashboard.onEnterEditMode(); } - const newState = sceneUtils.cloneSceneObjectState(panelMngr.state.panel.state); + const panelManager = this.state.vizManager; + const sourcePanel = panelManager.state.sourcePanel.resolve(); + const sourcePanelParent = sourcePanel!.parent; + + const normalToRepeat = !this._initialRepeatOptions.repeat && panelManager.state.repeat; + const repeatToNormal = this._initialRepeatOptions.repeat && !panelManager.state.repeat; + + if (sourcePanelParent instanceof LibraryVizPanel) { + // Library panels handled separately + return; + } else if (sourcePanelParent instanceof SceneGridItem) { + if (normalToRepeat) { + this.replaceSceneGridItemWithPanelRepeater(sourcePanelParent); + } else { + panelManager.commitChanges(); + } + } else if (sourcePanelParent instanceof PanelRepeaterGridItem) { + if (repeatToNormal) { + this.replacePanelRepeaterWithGridItem(sourcePanelParent); + } else { + this.handleRepeatOptionChanges(sourcePanelParent); + } + } else { + console.error('Unsupported scene object type'); + } + } - sourcePanel.setState(newState); + private replaceSceneGridItemWithPanelRepeater(gridItem: SceneGridItem) { + const gridLayout = gridItem.parent; + if (!(gridLayout instanceof SceneGridLayout)) { + console.error('Expected grandparent to be SceneGridLayout!'); + return; + } - // preserve time range and variables state - dashboard.setState({ - $timeRange: this.state.$timeRange?.clone(), - $variables: this.state.$variables?.clone(), - isDirty: true, + const panelManager = this.state.vizManager; + const repeatDirection = panelManager.state.repeatDirection ?? 'h'; + const repeater = new PanelRepeaterGridItem({ + key: gridItem.state.key, + x: gridItem.state.x, + y: gridItem.state.y, + width: repeatDirection === 'h' ? 24 : gridItem.state.width, + height: gridItem.state.height, + itemHeight: gridItem.state.height, + source: panelManager.getPanelCloneWithData(), + variableName: panelManager.state.repeat!, + repeatedPanels: [], + repeatDirection: panelManager.state.repeatDirection, + maxPerRow: panelManager.state.maxPerRow, + }); + gridLayout.setState({ + children: gridLayout.state.children.map((child) => (child.state.key === gridItem.state.key ? repeater : child)), }); } - private _navigateBackToDashboard() { - locationService.push( - getDashboardUrl({ - uid: this.state.dashboardRef.resolve().state.uid, - currentQueryParams: locationService.getLocation().search, - useExperimentalURL: true, - }) - ); + private replacePanelRepeaterWithGridItem(panelRepeater: PanelRepeaterGridItem) { + const gridLayout = panelRepeater.parent; + if (!(gridLayout instanceof SceneGridLayout)) { + console.error('Expected grandparent to be SceneGridLayout!'); + return; + } + + const panelManager = this.state.vizManager; + const panelClone = panelManager.getPanelCloneWithData(); + const gridItem = new SceneGridItem({ + key: panelRepeater.state.key, + x: panelRepeater.state.x, + y: panelRepeater.state.y, + width: this._initialRepeatOptions.repeatDirection === 'h' ? 8 : panelRepeater.state.width, + height: this._initialRepeatOptions.repeatDirection === 'v' ? 8 : panelRepeater.state.height, + body: panelClone, + }); + gridLayout.setState({ + children: gridLayout.state.children.map((child) => + child.state.key === panelRepeater.state.key ? gridItem : child + ), + }); + } + + private handleRepeatOptionChanges(panelRepeater: PanelRepeaterGridItem) { + let width = panelRepeater.state.width ?? 1; + let height = panelRepeater.state.height; + + const panelManager = this.state.vizManager; + const horizontalToVertical = + this._initialRepeatOptions.repeatDirection === 'h' && panelManager.state.repeatDirection === 'v'; + const verticalToHorizontal = + this._initialRepeatOptions.repeatDirection === 'v' && panelManager.state.repeatDirection === 'h'; + if (horizontalToVertical) { + width = Math.floor(width / (panelRepeater.state.maxPerRow ?? 1)); + } else if (verticalToHorizontal) { + width = 24; + } + + panelRepeater.setState({ + source: panelManager.getPanelCloneWithData(), + repeatDirection: panelManager.state.repeatDirection, + variableName: panelManager.state.repeat, + maxPerRow: panelManager.state.maxPerRow, + width, + height, + }); } -} -export function buildPanelEditScene(dashboard: DashboardScene, panel: VizPanel): PanelEditor { - const panelClone = panel.clone(); + public onSaveLibraryPanel = () => { + this.setState({ showLibraryPanelSaveModal: true }); + }; + + public onConfirmSaveLibraryPanel = () => { + this.state.vizManager.commitChanges(); + locationService.partial({ editPanel: null }); + }; - const vizPanelMgr = new VizPanelManager(panelClone, dashboard.getRef()); - const dashboardStateCloned = sceneUtils.cloneSceneObjectState(dashboard.state); + public onDismissLibraryPanelSaveModal = () => { + this.setState({ showLibraryPanelSaveModal: false }); + }; + + public onUnlinkLibraryPanel = () => { + this.setState({ showLibraryPanelUnlinkModal: true }); + }; + + public onDismissUnlinkLibraryPanelModal = () => { + this.setState({ showLibraryPanelUnlinkModal: false }); + }; + + public onConfirmUnlinkLibraryPanel = () => { + this.state.vizManager.unlinkLibraryPanel(); + this.setState({ showLibraryPanelUnlinkModal: false }); + }; +} +export function buildPanelEditScene(panel: VizPanel): PanelEditor { return new PanelEditor({ - dashboardRef: dashboard.getRef(), - sourcePanelRef: panel.getRef(), - panelRef: vizPanelMgr.getRef(), - controls: dashboardStateCloned.controls, - $variables: dashboardStateCloned.$variables, - $timeRange: dashboardStateCloned.$timeRange, - body: new SplitLayout({ - direction: 'row', - primary: new SplitLayout({ - direction: 'column', - primary: new SceneFlexLayout({ - direction: 'column', - children: [vizPanelMgr], - }), - secondary: new SceneFlexItem({ - body: new PanelDataPane(vizPanelMgr), - }), - }), - secondary: new SceneFlexLayout({ - direction: 'column', - children: [new PanelOptionsPane(vizPanelMgr), new PanelVizTypePicker(vizPanelMgr)], - }), - }), + panelId: getPanelIdForVizPanel(panel), + optionsPane: new PanelOptionsPane({}), + vizManager: VizPanelManager.createFor(panel), }); } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx index 109b3db72de2e..6a1b852557eb0 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx @@ -1,63 +1,128 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import React from 'react'; -import { useLocation } from 'react-router-dom'; -import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { SceneComponentProps } from '@grafana/scenes'; -import { Button, useStyles2 } from '@grafana/ui'; -import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; -import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; -import { Page } from 'app/core/components/Page/Page'; -import { useSelector } from 'app/types/store'; +import { Button, ToolbarButton, useStyles2 } from '@grafana/ui'; + +import { NavToolbarActions } from '../scene/NavToolbarActions'; +import { UnlinkModal } from '../scene/UnlinkModal'; +import { getDashboardSceneFor, getLibraryPanel } from '../utils/utils'; import { PanelEditor } from './PanelEditor'; +import { SaveLibraryVizPanelModal } from './SaveLibraryVizPanelModal'; +import { useSnappingSplitter } from './splitter/useSnappingSplitter'; export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>) { - const { body, controls, overlay } = model.useState(); + const dashboard = getDashboardSceneFor(model); + const { optionsPane } = model.useState(); const styles = useStyles2(getStyles); - const location = useLocation(); - const navIndex = useSelector((state) => state.navIndex); - const pageNav = model.getPageNav(location, navIndex); + + const { containerProps, primaryProps, secondaryProps, splitterProps, splitterState, onToggleCollapse } = + useSnappingSplitter({ + direction: 'row', + dragPosition: 'end', + initialSize: 0.75, + paneOptions: { + collapseBelowPixels: 250, + snapOpenToPixels: 400, + }, + }); return ( - <Page navId="scenes" pageNav={pageNav} layout={PageLayoutType.Custom}> - <AppChromeUpdate actions={getToolbarActions(model)} /> - <div className={styles.canvasContent}> - {controls && ( - <div className={styles.controls}> - {controls.map((control) => ( - <control.Component key={control.state.key} model={control} /> - ))} - </div> - )} - <div className={styles.body}> - <body.Component model={body} /> + <> + <NavToolbarActions dashboard={dashboard} /> + <div {...containerProps}> + <div {...primaryProps} className={cx(primaryProps.className, styles.body)}> + <VizAndDataPane model={model} /> + </div> + <div {...splitterProps} /> + <div {...secondaryProps} className={cx(secondaryProps.className, styles.optionsPane)}> + {splitterState.collapsed && ( + <div className={styles.expandOptionsWrapper}> + <ToolbarButton + tooltip={'Open options pane'} + icon={'arrow-to-right'} + onClick={onToggleCollapse} + variant="canvas" + className={styles.rotate180} + aria-label={'Open options pane'} + /> + </div> + )} + {!splitterState.collapsed && <optionsPane.Component model={optionsPane} />} </div> </div> - {overlay && <overlay.Component model={overlay} />} - </Page> + </> ); } -function getToolbarActions(editor: PanelEditor) { - return ( - <> - <NavToolbarSeparator leftActionsSeparator key="separator" /> +function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) { + const dashboard = getDashboardSceneFor(model); + const { vizManager, dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal } = model.useState(); + const { sourcePanel } = vizManager.useState(); + const libraryPanel = getLibraryPanel(sourcePanel.resolve()); + const { controls } = dashboard.useState(); + const styles = useStyles2(getStyles); + + const { containerProps, primaryProps, secondaryProps, splitterProps, splitterState, onToggleCollapse } = + useSnappingSplitter({ + direction: 'column', + dragPosition: 'start', + initialSize: 0.5, + paneOptions: { + collapseBelowPixels: 150, + }, + }); - <Button - onClick={editor.onDiscard} - tooltip="" - key="panel-edit-discard" - variant="destructive" - fill="outline" - size="sm" - > - Discard - </Button> + if (!dataPane) { + primaryProps.style.flexGrow = 1; + } - <Button onClick={editor.onApply} tooltip="" key="panel-edit-apply" variant="primary" size="sm"> - Apply - </Button> + return ( + <> + <div className={styles.controlsWrapper}>{controls && <controls.Component model={controls} />}</div> + <div {...containerProps}> + <div {...primaryProps}> + <vizManager.Component model={vizManager} /> + </div> + {showLibraryPanelSaveModal && libraryPanel && ( + <SaveLibraryVizPanelModal + libraryPanel={libraryPanel} + onDismiss={model.onDismissLibraryPanelSaveModal} + onConfirm={model.onConfirmSaveLibraryPanel} + onDiscard={model.onDiscard} + ></SaveLibraryVizPanelModal> + )} + {showLibraryPanelUnlinkModal && libraryPanel && ( + <UnlinkModal + onDismiss={model.onDismissUnlinkLibraryPanelModal} + onConfirm={model.onConfirmUnlinkLibraryPanel} + isOpen + /> + )} + {dataPane && ( + <> + <div {...splitterProps} /> + <div {...secondaryProps}> + {splitterState.collapsed && ( + <div className={styles.expandDataPane}> + <Button + tooltip={'Open query pane'} + icon={'arrow-to-right'} + onClick={onToggleCollapse} + variant="secondary" + size="sm" + className={styles.openDataPaneButton} + aria-label={'Open query pane'} + /> + </div> + )} + {!splitterState.collapsed && <dataPane.Component model={dataPane} />} + </div> + </> + )} + </div> </> ); } @@ -68,7 +133,6 @@ function getStyles(theme: GrafanaTheme2) { label: 'canvas-content', display: 'flex', flexDirection: 'column', - padding: theme.spacing(0, 2), flexBasis: '100%', flexGrow: 1, minHeight: 0, @@ -78,17 +142,44 @@ function getStyles(theme: GrafanaTheme2) { label: 'body', flexGrow: 1, display: 'flex', - position: 'relative', + flexDirection: 'column', minHeight: 0, - gap: '8px', - marginBottom: theme.spacing(2), }), - controls: css({ + optionsPane: css({ + flexDirection: 'column', + borderLeft: `1px solid ${theme.colors.border.weak}`, + background: theme.colors.background.primary, + }), + expandOptionsWrapper: css({ + display: 'flex', + flexDirection: 'column', + padding: theme.spacing(2, 1), + }), + expandDataPane: css({ display: 'flex', - flexWrap: 'wrap', - alignItems: 'center', - gap: theme.spacing(1), - padding: theme.spacing(2, 0), + flexDirection: 'row', + padding: theme.spacing(1), + borderTop: `1px solid ${theme.colors.border.weak}`, + borderRight: `1px solid ${theme.colors.border.weak}`, + background: theme.colors.background.primary, + flexGrow: 1, + justifyContent: 'space-around', + }), + rotate180: css({ + rotate: '180deg', + }), + controlsWrapper: css({ + display: 'flex', + flexDirection: 'column', + flexGrow: 0, + paddingLeft: theme.spacing(2), + }), + openDataPaneButton: css({ + width: theme.spacing(8), + justifyContent: 'center', + svg: { + rotate: '-90deg', + }, }), }; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditorUrlSync.ts b/public/app/features/dashboard-scene/panel-edit/PanelEditorUrlSync.ts deleted file mode 100644 index d6b5d51e4ebea..0000000000000 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditorUrlSync.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { AppEvents } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; -import { SceneObjectUrlSyncHandler, SceneObjectUrlValues } from '@grafana/scenes'; -import appEvents from 'app/core/app_events'; - -import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer'; -import { findVizPanelByKey } from '../utils/utils'; - -import { PanelEditor, PanelEditorState } from './PanelEditor'; - -export class PanelEditorUrlSync implements SceneObjectUrlSyncHandler { - constructor(private _scene: PanelEditor) {} - - getKeys(): string[] { - return ['inspect']; - } - - getUrlState(): SceneObjectUrlValues { - const state = this._scene.state; - return { - inspect: state.inspectPanelKey, - }; - } - - updateFromUrl(values: SceneObjectUrlValues): void { - const { inspectPanelKey } = this._scene.state; - const update: Partial<PanelEditorState> = {}; - - // Handle inspect object state - if (typeof values.inspect === 'string') { - const panel = findVizPanelByKey(this._scene, values.inspect); - if (!panel) { - appEvents.emit(AppEvents.alertError, ['Panel not found']); - locationService.partial({ inspect: null }); - return; - } - - update.inspectPanelKey = values.inspect; - update.overlay = new PanelInspectDrawer({ panelRef: panel.getRef() }); - } else if (inspectPanelKey) { - update.inspectPanelKey = undefined; - update.overlay = undefined; - } - - if (Object.keys(update).length > 0) { - this._scene.setState(update); - } - } -} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx new file mode 100644 index 0000000000000..3b03ce0e0931a --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx @@ -0,0 +1,63 @@ +import { act, fireEvent, render } from '@testing-library/react'; +import React from 'react'; + +import { SceneGridItem, VizPanel } from '@grafana/scenes'; +import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions'; + +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; +import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; + +import { PanelOptions } from './PanelOptions'; +import { VizPanelManager } from './VizPanelManager'; + +jest.mock('react-router-dom', () => ({ + useLocation: () => ({ + pathname: '', + }), +})); + +describe('PanelOptions', () => { + it('gets library panel options when the editing a library panel', async () => { + const panel = new VizPanel({ + key: 'panel-1', + pluginId: 'text', + }); + + const libraryPanelModel = { + title: 'title', + uid: 'uid', + name: 'libraryPanelName', + model: vizPanelToPanel(panel), + type: 'panel', + version: 1, + }; + + const libraryPanel = new LibraryVizPanel({ + isLoaded: true, + title: libraryPanelModel.title, + uid: libraryPanelModel.uid, + name: libraryPanelModel.name, + panelKey: panel.state.key!, + panel: panel, + _loadedPanel: libraryPanelModel, + }); + + new SceneGridItem({ body: libraryPanel }); + + const panelManger = VizPanelManager.createFor(panel); + + const panelOptions = ( + <PanelOptions vizManager={panelManger} searchQuery="" listMode={OptionFilter.All}></PanelOptions> + ); + + const r = render(panelOptions); + const input = await r.findByTestId('library panel name input'); + await act(async () => { + fireEvent.blur(input, { target: { value: 'new library panel name' } }); + }); + + expect((panelManger.state.sourcePanel.resolve().parent as LibraryVizPanel).state.name).toBe( + 'new library panel name' + ); + }); +}); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx new file mode 100644 index 0000000000000..e9088b34535bd --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx @@ -0,0 +1,113 @@ +import React, { useMemo } from 'react'; + +import { sceneGraph } from '@grafana/scenes'; +import { OptionFilter, renderSearchHits } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions'; +import { getFieldOverrideCategories } from 'app/features/dashboard/components/PanelEditor/getFieldOverrideElements'; +import { getPanelFrameCategory2 } from 'app/features/dashboard/components/PanelEditor/getPanelFrameOptions'; +import { + getLibraryVizPanelOptionsCategory, + getVisualizationOptions2, +} from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions'; + +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; + +import { VizPanelManager } from './VizPanelManager'; + +interface Props { + vizManager: VizPanelManager; + searchQuery: string; + listMode: OptionFilter; +} + +export const PanelOptions = React.memo<Props>(({ vizManager, searchQuery, listMode }) => { + const { panel, sourcePanel, repeat } = vizManager.useState(); + const parent = sourcePanel.resolve().parent; + const { data } = sceneGraph.getData(panel).useState(); + const { options, fieldConfig } = panel.useState(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const panelFrameOptions = useMemo( + () => getPanelFrameCategory2(vizManager, panel, repeat), + [vizManager, panel, repeat] + ); + + const visualizationOptions = useMemo(() => { + const plugin = panel.getPlugin(); + if (!plugin) { + return undefined; + } + + return getVisualizationOptions2({ + panel, + plugin: plugin, + eventBus: panel.getPanelContext().eventBus, + instanceState: panel.getPanelContext().instanceState!, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [panel, options, fieldConfig]); + + const libraryPanelOptions = useMemo(() => { + if (parent instanceof LibraryVizPanel) { + return getLibraryVizPanelOptionsCategory(parent); + } + return; + }, [parent]); + + const justOverrides = useMemo( + () => + getFieldOverrideCategories( + fieldConfig, + panel.getPlugin()?.fieldConfigRegistry!, + data?.series ?? [], + searchQuery, + (newConfig) => { + panel.setState({ + fieldConfig: newConfig, + }); + } + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [searchQuery, panel, fieldConfig] + ); + + const isSearching = searchQuery.length > 0; + const mainBoxElements: React.ReactNode[] = []; + + if (isSearching) { + mainBoxElements.push( + renderSearchHits( + [panelFrameOptions, ...(libraryPanelOptions ? [libraryPanelOptions] : []), ...(visualizationOptions ?? [])], + justOverrides, + searchQuery + ) + ); + } else { + switch (listMode) { + case OptionFilter.All: + if (libraryPanelOptions) { + // Library Panel options first + mainBoxElements.push(libraryPanelOptions.render()); + } + mainBoxElements.push(panelFrameOptions.render()); + + for (const item of visualizationOptions ?? []) { + mainBoxElements.push(item.render()); + } + + for (const item of justOverrides) { + mainBoxElements.push(item.render()); + } + break; + case OptionFilter.Overrides: + for (const item of justOverrides) { + mainBoxElements.push(item.render()); + } + default: + break; + } + } + + return mainBoxElements; +}); + +PanelOptions.displayName = 'PanelOptions'; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx index e76751a141461..0678f10734efc 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx @@ -1,45 +1,135 @@ import { css } from '@emotion/css'; -import React from 'react'; +import React, { useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; -import { Field, Input, useStyles2 } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; +import { SceneComponentProps, SceneObjectBase, SceneObjectState, sceneGraph } from '@grafana/scenes'; +import { FilterInput, Stack, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions'; +import { getAllPanelPluginMeta } from 'app/features/panel/state/util'; -import { VizPanelManager } from './VizPanelManager'; +import { PanelEditor } from './PanelEditor'; +import { PanelOptions } from './PanelOptions'; +import { PanelVizTypePicker } from './PanelVizTypePicker'; -export interface PanelOptionsPaneState extends SceneObjectState {} +export interface PanelOptionsPaneState extends SceneObjectState { + isVizPickerOpen?: boolean; + searchQuery: string; + listMode: OptionFilter; +} export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> { - public panelManager: VizPanelManager; + public constructor(state: Partial<PanelOptionsPaneState>) { + super({ + searchQuery: '', + listMode: OptionFilter.All, + ...state, + }); + } + + onToggleVizPicker = () => { + this.setState({ isVizPickerOpen: !this.state.isVizPickerOpen }); + }; - public constructor(panelMgr: VizPanelManager) { - super({}); + onSetSearchQuery = (searchQuery: string) => { + this.setState({ searchQuery }); + }; - this.panelManager = panelMgr; - } + onSetListMode = (listMode: OptionFilter) => { + this.setState({ listMode }); + }; static Component = ({ model }: SceneComponentProps<PanelOptionsPane>) => { - const { panelManager } = model; - const { panel } = panelManager.state; - const { title } = panel.useState(); + const { isVizPickerOpen, searchQuery, listMode } = model.useState(); + const vizManager = sceneGraph.getAncestor(model, PanelEditor).state.vizManager; + const { pluginId } = vizManager.state.panel.useState(); const styles = useStyles2(getStyles); return ( - <div className={styles.box}> - <Field label="Title"> - <Input value={title} onChange={(evt) => panel.setState({ title: evt.currentTarget.value })} /> - </Field> - </div> + <> + {!isVizPickerOpen && ( + <> + <div className={styles.top}> + <VisualizationButton pluginId={pluginId} onOpen={model.onToggleVizPicker} /> + <FilterInput + className={styles.searchOptions} + value={searchQuery} + placeholder="Search options" + onChange={model.onSetSearchQuery} + /> + </div> + <div className={styles.listOfOptions}> + <PanelOptions vizManager={vizManager} searchQuery={searchQuery} listMode={listMode} /> + </div> + </> + )} + {isVizPickerOpen && <PanelVizTypePicker vizManager={vizManager} onChange={model.onToggleVizPicker} />} + </> ); }; } function getStyles(theme: GrafanaTheme2) { return { - box: css({ + top: css({ + display: 'flex', + flexDirection: 'column', + padding: theme.spacing(2, 1), + gap: theme.spacing(2), + }), + listOfOptions: css({ display: 'flex', flexDirection: 'column', - padding: theme.spacing(2), + flexGrow: '1', + overflow: 'auto', + }), + searchOptions: css({ + minHeight: theme.spacing(4), + }), + searchWrapper: css({ + padding: theme.spacing(2, 2, 2, 0), + }), + vizField: css({ + marginBottom: theme.spacing(1), + }), + rotateIcon: css({ + rotate: '180deg', + }), + }; +} + +interface VisualizationButtonProps { + pluginId: string; + onOpen: () => void; +} + +export function VisualizationButton({ pluginId, onOpen }: VisualizationButtonProps) { + const styles = useStyles2(getVizButtonStyles); + const pluginMeta = useMemo(() => getAllPanelPluginMeta().filter((p) => p.id === pluginId)[0], [pluginId]); + + return ( + <Stack gap={1}> + <ToolbarButton + className={styles.vizButton} + tooltip="Click to change visualization" + imgSrc={pluginMeta.info.logos.small} + onClick={onOpen} + data-testid={selectors.components.PanelEditor.toggleVizPicker} + aria-label="Change Visualization" + variant="canvas" + isOpen={false} + fullWidth + > + {pluginMeta.name} + </ToolbarButton> + </Stack> + ); +} + +function getVizButtonStyles(theme: GrafanaTheme2) { + return { + vizButton: css({ + textAlign: 'left', }), }; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx index 0899d3df1e255..7a8cf670d9ec1 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx @@ -1,47 +1,101 @@ import { css } from '@emotion/css'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; +import { useLocalStorage } from 'react-use'; -import { GrafanaTheme2 } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; -import { CustomScrollbar, FilterInput, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2, PanelData, SelectableValue } from '@grafana/data'; +import { CustomScrollbar, Field, FilterInput, RadioButtonGroup, useStyles2 } from '@grafana/ui'; +import { LS_VISUALIZATION_SELECT_TAB_KEY, LS_WIDGET_SELECT_TAB_KEY } from 'app/core/constants'; +import { VisualizationSelectPaneTab } from 'app/features/dashboard/components/PanelEditor/types'; +import { VisualizationSuggestions } from 'app/features/panel/components/VizTypePicker/VisualizationSuggestions'; import { VizTypePicker } from 'app/features/panel/components/VizTypePicker/VizTypePicker'; +import { VizTypeChangeDetails } from 'app/features/panel/components/VizTypePicker/types'; + +import { PanelModelCompatibilityWrapper } from '../utils/PanelModelCompatibilityWrapper'; import { VizPanelManager } from './VizPanelManager'; -export interface PanelVizTypePickerState extends SceneObjectState {} +export interface Props { + data?: PanelData; + vizManager: VizPanelManager; + onChange: () => void; +} + +export function PanelVizTypePicker({ vizManager, data, onChange }: Props) { + const { panel } = vizManager.useState(); + const styles = useStyles2(getStyles); + const [searchQuery, setSearchQuery] = useState(''); -export class PanelVizTypePicker extends SceneObjectBase<PanelVizTypePickerState> { - public constructor(public panelManager: VizPanelManager) { - super({}); - } + const isWidgetEnabled = false; + const tabKey = isWidgetEnabled ? LS_WIDGET_SELECT_TAB_KEY : LS_VISUALIZATION_SELECT_TAB_KEY; + const defaultTab = isWidgetEnabled ? VisualizationSelectPaneTab.Widgets : VisualizationSelectPaneTab.Visualizations; + const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]); - static Component = ({ model }: SceneComponentProps<PanelVizTypePicker>) => { - const { panelManager } = model; - const { panel } = panelManager.useState(); - const styles = useStyles2(getStyles); - const [searchQuery, setSearchQuery] = useState(''); + const [listMode, setListMode] = useLocalStorage(tabKey, defaultTab); - return ( + const radioOptions: Array<SelectableValue<VisualizationSelectPaneTab>> = [ + { label: 'Visualizations', value: VisualizationSelectPaneTab.Visualizations }, + { label: 'Suggestions', value: VisualizationSelectPaneTab.Suggestions }, + // { + // label: 'Library panels', + // value: VisualizationSelectPaneTab.LibraryPanels, + // description: 'Reusable panels you can share between multiple dashboards.', + // }, + ]; + + const onVizTypeChange = (options: VizTypeChangeDetails) => { + vizManager.changePluginType(options.pluginId); + onChange(); + }; + + return ( + <div className={styles.wrapper}> + <FilterInput + className={styles.filter} + value={searchQuery} + onChange={setSearchQuery} + autoFocus={true} + placeholder="Search for..." + /> + <Field className={styles.customFieldMargin}> + <RadioButtonGroup options={radioOptions} value={listMode} onChange={setListMode} fullWidth /> + </Field> <CustomScrollbar autoHeightMin="100%"> - <div className={styles.wrapper}> - <FilterInput value={searchQuery} onChange={setSearchQuery} autoFocus={true} placeholder="Search for..." /> - <VizTypePicker - pluginId={panel.state.pluginId} + {listMode === VisualizationSelectPaneTab.Visualizations && ( + <VizTypePicker pluginId={panel.state.pluginId} searchQuery={searchQuery} onChange={onVizTypeChange} /> + )} + {/* {listMode === VisualizationSelectPaneTab.Widgets && ( + <VizTypePicker pluginId={plugin.meta.id} onChange={onVizChange} searchQuery={searchQuery} isWidget /> + )} */} + {listMode === VisualizationSelectPaneTab.Suggestions && ( + <VisualizationSuggestions + onChange={onVizTypeChange} searchQuery={searchQuery} - onChange={(options) => { - panelManager.changePluginType(options.pluginId); - }} + panel={panelModel} + data={data} /> - </div> + )} </CustomScrollbar> - ); - }; + </div> + ); } const getStyles = (theme: GrafanaTheme2) => ({ wrapper: css({ display: 'flex', flexDirection: 'column', - gap: theme.spacing(1), + flexGrow: 1, + padding: theme.spacing(2, 1), + height: '100%', + gap: theme.spacing(2), + border: `1px solid ${theme.colors.border.weak}`, + borderRight: 'none', + borderBottom: 'none', + borderTopLeftRadius: theme.shape.radius.default, + }), + customFieldMargin: css({ + marginBottom: theme.spacing(1), + }), + filter: css({ + minHeight: theme.spacing(4), }), }); diff --git a/public/app/features/dashboard-scene/panel-edit/SaveLibraryVizPanelModal.tsx b/public/app/features/dashboard-scene/panel-edit/SaveLibraryVizPanelModal.tsx new file mode 100644 index 0000000000000..d1178a76aa5e0 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/SaveLibraryVizPanelModal.tsx @@ -0,0 +1,107 @@ +import React, { useCallback, useState } from 'react'; +import { useAsync, useDebounce } from 'react-use'; + +import { Button, Icon, Input, Modal, useStyles2 } from '@grafana/ui'; +import { getConnectedDashboards } from 'app/features/library-panels/state/api'; +import { getModalStyles } from 'app/features/library-panels/styles'; + +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; + +interface Props { + libraryPanel: LibraryVizPanel; + isUnsavedPrompt?: boolean; + onConfirm: () => void; + onDismiss: () => void; + onDiscard: () => void; +} + +export const SaveLibraryVizPanelModal = ({ libraryPanel, isUnsavedPrompt, onDismiss, onConfirm, onDiscard }: Props) => { + const [searchString, setSearchString] = useState(''); + const dashState = useAsync(async () => { + const searchHits = await getConnectedDashboards(libraryPanel.state.uid); + if (searchHits.length > 0) { + return searchHits.map((dash) => dash.title); + } + + return []; + }, [libraryPanel.state.uid]); + + const [filteredDashboards, setFilteredDashboards] = useState<string[]>([]); + useDebounce( + () => { + if (!dashState.value) { + return setFilteredDashboards([]); + } + + return setFilteredDashboards( + dashState.value.filter((dashName) => dashName.toLowerCase().includes(searchString.toLowerCase())) + ); + }, + 300, + [dashState.value, searchString] + ); + + const styles = useStyles2(getModalStyles); + const discardAndClose = useCallback(() => { + onDiscard(); + }, [onDiscard]); + + const title = isUnsavedPrompt ? 'Unsaved library panel changes' : 'Save library panel'; + + return ( + <Modal title={title} icon="save" onDismiss={onDismiss} isOpen={true}> + <div> + <p className={styles.textInfo}> + {'This update will affect '} + <strong> + {libraryPanel.state._loadedPanel?.meta?.connectedDashboards}{' '} + {libraryPanel.state._loadedPanel?.meta?.connectedDashboards === 1 ? 'dashboard' : 'dashboards'}. + </strong> + The following dashboards using the panel will be affected: + </p> + <Input + className={styles.dashboardSearch} + prefix={<Icon name="search" />} + placeholder="Search affected dashboards" + value={searchString} + onChange={(e) => setSearchString(e.currentTarget.value)} + /> + {dashState.loading ? ( + <p>Loading connected dashboards...</p> + ) : ( + <table className={styles.myTable}> + <thead> + <tr> + <th>Dashboard name</th> + </tr> + </thead> + <tbody> + {filteredDashboards.map((dashName, i) => ( + <tr key={`dashrow-${i}`}> + <td>{dashName}</td> + </tr> + ))} + </tbody> + </table> + )} + <Modal.ButtonRow> + <Button variant="secondary" onClick={onDismiss} fill="outline"> + Cancel + </Button> + {isUnsavedPrompt && ( + <Button variant="destructive" onClick={discardAndClose}> + Discard + </Button> + )} + <Button + onClick={() => { + onConfirm(); + }} + > + Update all + </Button> + </Modal.ButtonRow> + </div> + </Modal> + ); +}; diff --git a/public/app/features/dashboard-scene/panel-edit/ShareDataProvider.tsx b/public/app/features/dashboard-scene/panel-edit/ShareDataProvider.tsx new file mode 100644 index 0000000000000..dcf9ca08a9dae --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/ShareDataProvider.tsx @@ -0,0 +1,35 @@ +import { SceneDataProvider, SceneDataState, SceneObjectBase } from '@grafana/scenes'; + +export class ShareDataProvider extends SceneObjectBase<SceneDataState> implements SceneDataProvider { + public constructor(private _source: SceneDataProvider) { + super(_source.state); + this.addActivationHandler(() => this.activationHandler()); + } + + private activationHandler() { + this._subs.add(this._source.subscribeToState((state) => this.setState({ data: state.data }))); + this.setState(this._source.state); + } + + public setContainerWidth(width: number) { + if (this.state.$data && this.state.$data.setContainerWidth) { + this.state.$data.setContainerWidth(width); + } + } + + public isDataReadyToDisplay() { + if (!this._source.isDataReadyToDisplay) { + return true; + } + + return this._source.isDataReadyToDisplay?.(); + } + + public cancelQuery() { + this._source.cancelQuery?.(); + } + + public getResultsStream() { + return this._source.getResultsStream!(); + } +} diff --git a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx index d0a7c50bc3cac..98f4a7143c75b 100644 --- a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx @@ -1,23 +1,25 @@ import { map, of } from 'rxjs'; -import { DataQueryRequest, DataSourceApi, LoadingState, PanelData } from '@grafana/data'; +import { DataQueryRequest, DataSourceApi, DataSourceInstanceSettings, LoadingState, PanelData } from '@grafana/data'; import { locationService } from '@grafana/runtime'; -import { SceneDataTransformer, SceneQueryRunner, VizPanel } from '@grafana/scenes'; +import { SceneGridItem, SceneQueryRunner, VizPanel } from '@grafana/scenes'; import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { InspectTab } from 'app/features/inspector/types'; +import * as libAPI from 'app/features/library-panels/state/api'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; -import { DashboardScene } from '../scene/DashboardScene'; +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; import { PanelTimeRange, PanelTimeRangeState } from '../scene/PanelTimeRange'; -import { ShareQueryDataProvider } from '../scene/ShareQueryDataProvider'; import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; +import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; import { findVizPanelByKey } from '../utils/utils'; +import { buildPanelEditScene } from './PanelEditor'; import { VizPanelManager } from './VizPanelManager'; -import testDashboard from './testfiles/testDashboard.json'; +import { panelWithQueriesOnly, panelWithTransformations, testDashboard } from './testfiles/testDashboard'; const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => { const result: PanelData = { @@ -96,7 +98,7 @@ const instance2SettingsMock = { jest.mock('app/core/store', () => ({ exists: jest.fn(), get: jest.fn(), - getObject: jest.fn(), + getObject: jest.fn((_a, b) => b), setObject: jest.fn(), })); @@ -141,16 +143,15 @@ jest.mock('@grafana/runtime', () => ({ })); describe('VizPanelManager', () => { - describe('changePluginType', () => { + describe('When changing plugin', () => { it('Should successfully change from one viz type to another', () => { - const vizPanelManager = setupTest('panel-1'); + const { vizPanelManager } = setupTest('panel-1'); expect(vizPanelManager.state.panel.state.pluginId).toBe('timeseries'); vizPanelManager.changePluginType('table'); expect(vizPanelManager.state.panel.state.pluginId).toBe('table'); }); it('Should clear custom options', () => { - const dashboardSceneMock = new DashboardScene({}); const overrides = [ { matcher: { id: 'matcherOne' }, @@ -171,7 +172,7 @@ describe('VizPanelManager', () => { }, }); - const vizPanelManager = new VizPanelManager(vizPanel, dashboardSceneMock.getRef()); + const vizPanelManager = VizPanelManager.createFor(vizPanel); expect(vizPanelManager.state.panel.state.fieldConfig.defaults.custom).toBe('Custom'); expect(vizPanelManager.state.panel.state.fieldConfig.overrides).toBe(overrides); @@ -184,7 +185,6 @@ describe('VizPanelManager', () => { }); it('Should restore cached options/fieldConfig if they exist', () => { - const dashboardSceneMock = new DashboardScene({}); const vizPanel = new VizPanel({ title: 'Panel A', key: 'panel-1', @@ -196,9 +196,9 @@ describe('VizPanelManager', () => { fieldConfig: { defaults: { custom: 'Custom' }, overrides: [] }, }); - const vizPanelManager = new VizPanelManager(vizPanel, dashboardSceneMock.getRef()); + const vizPanelManager = VizPanelManager.createFor(vizPanel); - vizPanelManager.changePluginType('timeseties'); + vizPanelManager.changePluginType('timeseries'); //@ts-ignore expect(vizPanelManager.state.panel.state.options['customOption']).toBeUndefined(); expect(vizPanelManager.state.panel.state.fieldConfig.defaults.custom).toStrictEqual({}); @@ -211,6 +211,81 @@ describe('VizPanelManager', () => { }); }); + describe('library panels', () => { + it('saves library panels on commit', () => { + const panel = new VizPanel({ + key: 'panel-1', + pluginId: 'text', + }); + + const libraryPanelModel = { + title: 'title', + uid: 'uid', + name: 'libraryPanelName', + model: vizPanelToPanel(panel), + type: 'panel', + version: 1, + }; + + const libraryPanel = new LibraryVizPanel({ + isLoaded: true, + title: libraryPanelModel.title, + uid: libraryPanelModel.uid, + name: libraryPanelModel.name, + panelKey: panel.state.key!, + panel: panel, + _loadedPanel: libraryPanelModel, + }); + + new SceneGridItem({ body: libraryPanel }); + + const panelManager = VizPanelManager.createFor(panel); + + const apiCall = jest + .spyOn(libAPI, 'updateLibraryVizPanel') + .mockResolvedValue({ type: 'panel', ...libAPI.libraryVizPanelToSaveModel(libraryPanel) }); + + panelManager.state.panel.setState({ title: 'new title' }); + panelManager.commitChanges(); + + expect(apiCall.mock.calls[0][0].state.panel?.state.title).toBe('new title'); + }); + + it('unlinks library panel', () => { + const panel = new VizPanel({ + key: 'panel-1', + pluginId: 'text', + }); + + const libraryPanelModel = { + title: 'title', + uid: 'uid', + name: 'libraryPanelName', + model: vizPanelToPanel(panel), + type: 'panel', + version: 1, + }; + + const libraryPanel = new LibraryVizPanel({ + isLoaded: true, + title: libraryPanelModel.title, + uid: libraryPanelModel.uid, + name: libraryPanelModel.name, + panelKey: panel.state.key!, + panel: panel, + _loadedPanel: libraryPanelModel, + }); + + const gridItem = new SceneGridItem({ body: libraryPanel }); + + const panelManager = VizPanelManager.createFor(panel); + panelManager.unlinkLibraryPanel(); + + const sourcePanel = panelManager.state.sourcePanel.resolve(); + expect(sourcePanel.parent?.state.key).toBe(gridItem.state.key); + }); + }); + describe('query options', () => { beforeEach(() => { store.setObject.mockClear(); @@ -218,7 +293,7 @@ describe('VizPanelManager', () => { describe('activation', () => { it('should load data source', async () => { - const vizPanelManager = setupTest('panel-1'); + const { vizPanelManager } = setupTest('panel-1'); vizPanelManager.activate(); await Promise.resolve(); @@ -227,7 +302,7 @@ describe('VizPanelManager', () => { }); it('should store loaded data source in local storage', async () => { - const vizPanelManager = setupTest('panel-1'); + const { vizPanelManager } = setupTest('panel-1'); vizPanelManager.activate(); await Promise.resolve(); @@ -240,20 +315,17 @@ describe('VizPanelManager', () => { describe('data source change', () => { it('should load new data source', async () => { - const vizPanelManager = setupTest('panel-1'); + const { vizPanelManager } = setupTest('panel-1'); vizPanelManager.activate(); - await Promise.resolve(); - - const dataObj = vizPanelManager.queryRunner; - dataObj.setState({ - datasource: { - type: 'grafana-prometheus-datasource', - uid: 'gdev-prometheus', - }, - }); + vizPanelManager.state.panel.state.$data?.activate(); await Promise.resolve(); + await vizPanelManager.changePanelDataSource( + { type: 'grafana-prometheus-datasource', uid: 'gdev-prometheus' } as DataSourceInstanceSettings, + [] + ); + expect(store.setObject).toHaveBeenCalledTimes(2); expect(store.setObject).toHaveBeenLastCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', { dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', @@ -268,8 +340,9 @@ describe('VizPanelManager', () => { describe('query options change', () => { describe('time overrides', () => { it('should create PanelTimeRange object', async () => { - const vizPanelManager = setupTest('panel-1'); + const { vizPanelManager } = setupTest('panel-1'); vizPanelManager.activate(); + vizPanelManager.state.panel.state.$data?.activate(); await Promise.resolve(); const panel = vizPanelManager.state.panel; @@ -291,7 +364,7 @@ describe('VizPanelManager', () => { expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange); }); it('should update PanelTimeRange object on time options update', async () => { - const vizPanelManager = setupTest('panel-1'); + const { vizPanelManager } = setupTest('panel-1'); vizPanelManager.activate(); await Promise.resolve(); @@ -330,7 +403,7 @@ describe('VizPanelManager', () => { }); it('should remove PanelTimeRange object on time options cleared', async () => { - const vizPanelManager = setupTest('panel-1'); + const { vizPanelManager } = setupTest('panel-1'); vizPanelManager.activate(); await Promise.resolve(); @@ -370,7 +443,7 @@ describe('VizPanelManager', () => { describe('max data points and interval', () => { it('max data points', async () => { - const vizPanelManager = setupTest('panel-1'); + const { vizPanelManager } = setupTest('panel-1'); vizPanelManager.activate(); await Promise.resolve(); @@ -392,7 +465,7 @@ describe('VizPanelManager', () => { }); it('max data points', async () => { - const vizPanelManager = setupTest('panel-1'); + const { vizPanelManager } = setupTest('panel-1'); vizPanelManager.activate(); await Promise.resolve(); @@ -413,11 +486,35 @@ describe('VizPanelManager', () => { expect(dataObj.state.minInterval).toBe('1s'); }); }); + + describe('query caching', () => { + it('updates cacheTimeout and queryCachingTTL', async () => { + const { vizPanelManager } = setupTest('panel-1'); + vizPanelManager.activate(); + await Promise.resolve(); + + const dataObj = vizPanelManager.queryRunner; + + vizPanelManager.changeQueryOptions({ + cacheTimeout: '60', + queryCachingTTL: 200000, + dataSource: { + name: 'grafana-testdata', + type: 'grafana-testdata-datasource', + default: true, + }, + queries: [], + }); + + expect(dataObj.state.cacheTimeout).toBe('60'); + expect(dataObj.state.queryCachingTTL).toBe(200000); + }); + }); }); describe('query inspection', () => { it('allows query inspection from the tab', async () => { - const vizPanelManager = setupTest('panel-1'); + const { vizPanelManager } = setupTest('panel-1'); vizPanelManager.inspectPanel(); expect(locationService.partial).toHaveBeenCalledWith({ inspect: 1, inspectTab: InspectTab.Query }); @@ -426,15 +523,11 @@ describe('VizPanelManager', () => { describe('data source change', () => { it('changing from one plugin to another', async () => { - const vizPanelManager = setupTest('panel-1'); + const { vizPanelManager } = setupTest('panel-1'); vizPanelManager.activate(); await Promise.resolve(); - const panel = vizPanelManager.state.panel; - - expect(panel.state.$data).toBeInstanceOf(SceneQueryRunner); - - expect((panel.state.$data as SceneQueryRunner).state.datasource).toEqual({ + expect(vizPanelManager.queryRunner.state.datasource).toEqual({ uid: 'gdev-testdata', type: 'grafana-testdata-datasource', }); @@ -448,24 +541,20 @@ describe('VizPanelManager', () => { module: 'prometheus', id: 'grafana-prometheus-datasource', }, - } as any); + } as DataSourceInstanceSettings); - expect((panel.state.$data as SceneQueryRunner).state.datasource).toEqual({ + expect(vizPanelManager.queryRunner.state.datasource).toEqual({ uid: 'gdev-prometheus', type: 'grafana-prometheus-datasource', }); }); it('changing from a plugin to a dashboard data source', async () => { - const vizPanelManager = setupTest('panel-1'); + const { vizPanelManager } = setupTest('panel-1'); vizPanelManager.activate(); await Promise.resolve(); - const panel = vizPanelManager.state.panel; - - expect(panel.state.$data).toBeInstanceOf(SceneQueryRunner); - - expect((panel.state.$data as SceneQueryRunner).state.datasource).toEqual({ + expect(vizPanelManager.queryRunner.state.datasource).toEqual({ uid: 'gdev-testdata', type: 'grafana-testdata-datasource', }); @@ -479,19 +568,23 @@ describe('VizPanelManager', () => { module: 'prometheus', id: DASHBOARD_DATASOURCE_PLUGIN_ID, }, - } as any); + } as DataSourceInstanceSettings); - expect(panel.state.$data).toBeInstanceOf(ShareQueryDataProvider); + expect(vizPanelManager.queryRunner.state.datasource).toEqual({ + uid: SHARED_DASHBOARD_QUERY, + type: 'datasource', + }); }); it('changing from dashboard data source to a plugin', async () => { - const vizPanelManager = setupTest('panel-3'); + const { vizPanelManager } = setupTest('panel-3'); vizPanelManager.activate(); await Promise.resolve(); - const panel = vizPanelManager.state.panel; - - expect(panel.state.$data).toBeInstanceOf(ShareQueryDataProvider); + expect(vizPanelManager.queryRunner.state.datasource).toEqual({ + uid: SHARED_DASHBOARD_QUERY, + type: 'datasource', + }); await vizPanelManager.changePanelDataSource({ name: 'grafana-prometheus', @@ -502,120 +595,115 @@ describe('VizPanelManager', () => { module: 'prometheus', id: 'grafana-prometheus-datasource', }, - } as any); + } as DataSourceInstanceSettings); - expect(panel.state.$data).toBeInstanceOf(SceneQueryRunner); - expect((panel.state.$data as SceneQueryRunner).state.datasource).toEqual({ + expect(vizPanelManager.queryRunner.state.datasource).toEqual({ uid: 'gdev-prometheus', type: 'grafana-prometheus-datasource', }); }); + }); + }); - describe('with transformations', () => { - it('changing from one plugin to another', async () => { - const vizPanelManager = setupTest('panel-2'); - vizPanelManager.activate(); - await Promise.resolve(); - - const panel = vizPanelManager.state.panel; + describe('change transformations', () => { + it('should update and reprocess transformations', () => { + const { scene, panel } = setupTest('panel-3'); + scene.setState({ editPanel: buildPanelEditScene(panel) }); - expect(panel.state.$data).toBeInstanceOf(SceneDataTransformer); + const vizPanelManager = scene.state.editPanel!.state.vizManager; + vizPanelManager.activate(); + vizPanelManager.state.panel.state.$data?.activate(); - expect((panel.state.$data?.state.$data as SceneQueryRunner).state.datasource).toEqual({ - uid: 'gdev-testdata', - type: 'grafana-testdata-datasource', - }); + const reprocessMock = jest.fn(); + vizPanelManager.dataTransformer.reprocessTransformations = reprocessMock; + vizPanelManager.changeTransformations([{ id: 'calculateField', options: {} }]); - await vizPanelManager.changePanelDataSource({ - name: 'grafana-prometheus', - type: 'grafana-prometheus-datasource', - uid: 'gdev-prometheus', - meta: { - name: 'Prometheus', - module: 'prometheus', - id: 'grafana-prometheus-datasource', - }, - } as any); + expect(reprocessMock).toHaveBeenCalledTimes(1); + expect(vizPanelManager.dataTransformer.state.transformations).toEqual([{ id: 'calculateField', options: {} }]); + }); + }); - expect(panel.state.$data).toBeInstanceOf(SceneDataTransformer); - expect((panel.state.$data?.state.$data as SceneQueryRunner).state.datasource).toEqual({ - uid: 'gdev-prometheus', - type: 'grafana-prometheus-datasource', - }); - }); - }); + describe('change queries', () => { + describe('plugin queries', () => { + it('should update queries', () => { + const { vizPanelManager } = setupTest('panel-1'); - it('changing from a plugin to dashboard data source', async () => { - const vizPanelManager = setupTest('panel-2'); vizPanelManager.activate(); - await Promise.resolve(); + vizPanelManager.state.panel.state.$data?.activate(); - const panel = vizPanelManager.state.panel; - - expect(panel.state.$data).toBeInstanceOf(SceneDataTransformer); - - expect((panel.state.$data?.state.$data as SceneQueryRunner).state.datasource).toEqual({ - uid: 'gdev-testdata', - type: 'grafana-testdata-datasource', - }); - - await vizPanelManager.changePanelDataSource({ - name: SHARED_DASHBOARD_QUERY, - type: 'datasource', - uid: SHARED_DASHBOARD_QUERY, - meta: { - name: 'Prometheus', - module: 'prometheus', - id: DASHBOARD_DATASOURCE_PLUGIN_ID, + vizPanelManager.changeQueries([ + { + datasource: { + type: 'grafana-testdata-datasource', + uid: 'gdev-testdata', + }, + refId: 'A', + scenarioId: 'random_walk', + seriesCount: 5, }, - } as any); + ]); - expect(panel.state.$data).toBeInstanceOf(SceneDataTransformer); - expect(panel.state.$data?.state.$data).toBeInstanceOf(ShareQueryDataProvider); + expect(vizPanelManager.queryRunner.state.queries).toEqual([ + { + datasource: { + type: 'grafana-testdata-datasource', + uid: 'gdev-testdata', + }, + refId: 'A', + scenarioId: 'random_walk', + seriesCount: 5, + }, + ]); }); + }); - it('changing from a dashboard data source to a plugin', async () => { - const vizPanelManager = setupTest('panel-4'); - vizPanelManager.activate(); - await Promise.resolve(); + describe('dashboard queries', () => { + it('should update queries', () => { + const { scene, panel } = setupTest('panel-3'); + scene.setState({ editPanel: buildPanelEditScene(panel) }); - const panel = vizPanelManager.state.panel; + const vizPanelManager = scene.state.editPanel!.state.vizManager; + vizPanelManager.activate(); + vizPanelManager.state.panel.state.$data?.activate(); + + // Changing dashboard query to a panel with transformations + vizPanelManager.changeQueries([ + { + refId: 'A', + datasource: { + type: DASHBOARD_DATASOURCE_PLUGIN_ID, + }, + panelId: panelWithTransformations.id, + }, + ]); - expect(panel.state.$data).toBeInstanceOf(SceneDataTransformer); - expect(panel.state.$data?.state.$data).toBeInstanceOf(ShareQueryDataProvider); + expect(vizPanelManager.queryRunner.state.queries[0].panelId).toEqual(panelWithTransformations.id); - await vizPanelManager.changePanelDataSource({ - name: 'grafana-prometheus', - type: 'grafana-prometheus-datasource', - uid: 'gdev-prometheus', - meta: { - name: 'Prometheus', - module: 'prometheus', - id: 'grafana-prometheus-datasource', + // Changing dashboard query to a panel with queries only + vizPanelManager.changeQueries([ + { + refId: 'A', + datasource: { + type: DASHBOARD_DATASOURCE_PLUGIN_ID, + }, + panelId: panelWithQueriesOnly.id, }, - } as any); + ]); - expect(panel.state.$data).toBeInstanceOf(SceneDataTransformer); - expect(panel.state.$data?.state.$data).toBeInstanceOf(SceneQueryRunner); - expect((panel.state.$data?.state.$data as SceneQueryRunner).state.datasource).toEqual({ - uid: 'gdev-prometheus', - type: 'grafana-prometheus-datasource', - }); + expect(vizPanelManager.queryRunner.state.queries[0].panelId).toBe(panelWithQueriesOnly.id); }); }); }); }); const setupTest = (panelId: string) => { - const scene = transformSaveModelToScene({ dashboard: testDashboard as any, meta: {} }); + const scene = transformSaveModelToScene({ dashboard: testDashboard, meta: {} }); + const panel = findVizPanelByKey(scene, panelId)!; + const vizPanelManager = VizPanelManager.createFor(panel); // The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it // @ts-expect-error getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene)); - const panel = findVizPanelByKey(scene, panelId)!; - - const vizPanelManager = new VizPanelManager(panel.clone(), scene.getRef()); - - return vizPanelManager; + return { vizPanelManager, scene, panel }; }; diff --git a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx index e3ce3700aa9d5..94d9e62fce064 100644 --- a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx +++ b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx @@ -1,135 +1,107 @@ +import { css } from '@emotion/css'; import React from 'react'; -import { Unsubscribable } from 'rxjs'; import { DataSourceApi, DataSourceInstanceSettings, FieldConfigSource, - LoadingState, + GrafanaTheme2, PanelModel, filterFieldConfigOverrides, - getDefaultTimeRange, isStandardFieldProp, restoreCustomOverrideRules, } from '@grafana/data'; -import { getDataSourceSrv, locationService } from '@grafana/runtime'; +import { config, getDataSourceSrv, locationService } from '@grafana/runtime'; import { - SceneObjectState, - VizPanel, - SceneObjectBase, - SceneComponentProps, - sceneUtils, DeepPartial, + PanelBuilders, + SceneComponentProps, + SceneDataTransformer, + SceneGridItem, + SceneObjectBase, SceneObjectRef, - SceneObject, + SceneObjectState, SceneQueryRunner, + VizPanel, sceneGraph, - SceneDataTransformer, - SceneDataProvider, + sceneUtils, } from '@grafana/scenes'; -import { DataQuery, DataSourceRef } from '@grafana/schema'; +import { DataQuery, DataTransformerConfig, Panel } from '@grafana/schema'; +import { useStyles2 } from '@grafana/ui'; import { getPluginVersion } from 'app/features/dashboard/state/PanelModel'; +import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard'; import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils'; +import { updateLibraryVizPanel } from 'app/features/library-panels/state/api'; import { updateQueries } from 'app/features/query/state/updateQueries'; -import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; -import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; import { GrafanaQuery } from 'app/plugins/datasource/grafana/types'; import { QueryGroupOptions } from 'app/types'; -import { DashboardScene } from '../scene/DashboardScene'; +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; +import { PanelRepeaterGridItem, RepeatDirection } from '../scene/PanelRepeaterGridItem'; import { PanelTimeRange, PanelTimeRangeState } from '../scene/PanelTimeRange'; -import { ShareQueryDataProvider, findObjectInScene } from '../scene/ShareQueryDataProvider'; -import { getPanelIdForVizPanel, getVizPanelKeyForPanelId } from '../utils/utils'; +import { gridItemToPanel } from '../serialization/transformSceneToSaveModel'; +import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils'; -interface VizPanelManagerState extends SceneObjectState { +export interface VizPanelManagerState extends SceneObjectState { panel: VizPanel; + sourcePanel: SceneObjectRef<VizPanel>; datasource?: DataSourceApi; dsSettings?: DataSourceInstanceSettings; + tableView?: VizPanel; + repeat?: string; + repeatDirection?: RepeatDirection; + maxPerRow?: number; } -// VizPanelManager serves as an API to manipulate VizPanel state from the outside. It allows panel type, options and data maniulation. -export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> { - public static Component = ({ model }: SceneComponentProps<VizPanelManager>) => { - const { panel } = model.useState(); - - return <panel.Component model={panel} />; - }; +export enum DisplayMode { + Fill = 0, + Fit = 1, + Exact = 2, +} +// VizPanelManager serves as an API to manipulate VizPanel state from the outside. It allows panel type, options and data manipulation. +export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> { private _cachedPluginOptions: Record< string, { options: DeepPartial<{}>; fieldConfig: FieldConfigSource<DeepPartial<{}>> } | undefined > = {}; - private _dataObjectSubscription: Unsubscribable | undefined; - - public constructor(panel: VizPanel, dashboardRef: SceneObjectRef<DashboardScene>) { - super({ panel }); - - /** - * If the panel uses a shared query, we clone the source runner and attach it as a data provider for the shared one. - * This way the source panel does not to be present in the edit scene hierarchy. - */ - if (panel.state.$data instanceof ShareQueryDataProvider) { - const sharedProvider = panel.state.$data; - if (sharedProvider.state.query.panelId) { - const keyToFind = getVizPanelKeyForPanelId(sharedProvider.state.query.panelId); - const source = findObjectInScene(dashboardRef.resolve(), (scene: SceneObject) => scene.state.key === keyToFind); - if (source) { - sharedProvider.setState({ - $data: source.state.$data!.clone(), - }); - } - } - } - + public constructor(state: VizPanelManagerState) { + super(state); this.addActivationHandler(() => this._onActivate()); } - private _onActivate() { - this.setupDataObjectSubscription(); - - this.loadDataSource(); - - return () => { - this._dataObjectSubscription?.unsubscribe(); - }; - } - /** - * The subscription is updated whenever the data source type is changed so that we can update manager's stored - * data source and data source instance settings, which are needed for the query options and editors + * Will clone the source panel and move the data provider to + * live on the VizPanelManager level instead of the VizPanel level */ - private setupDataObjectSubscription() { - const runner = this.queryRunner; - - if (this._dataObjectSubscription) { - this._dataObjectSubscription.unsubscribe(); + public static createFor(sourcePanel: VizPanel) { + let repeatOptions: Pick<VizPanelManagerState, 'repeat' | 'repeatDirection' | 'maxPerRow'> = {}; + if (sourcePanel.parent instanceof PanelRepeaterGridItem) { + const { variableName: repeat, repeatDirection, maxPerRow } = sourcePanel.parent.state; + repeatOptions = { repeat, repeatDirection, maxPerRow }; } - this._dataObjectSubscription = runner.subscribeToState((n, p) => { - if (n.datasource !== p.datasource) { - this.loadDataSource(); - } + return new VizPanelManager({ + panel: sourcePanel.clone({ $data: undefined }), + $data: sourcePanel.state.$data?.clone(), + sourcePanel: sourcePanel.getRef(), + ...repeatOptions, }); } + private _onActivate() { + this.loadDataSource(); + } + private async loadDataSource() { - const dataObj = this.state.panel.state.$data; + const dataObj = this.state.$data; if (!dataObj) { return; } - let datasourceToLoad: DataSourceRef | undefined; - - if (dataObj instanceof ShareQueryDataProvider) { - datasourceToLoad = { - uid: SHARED_DASHBOARD_QUERY, - type: DASHBOARD_DATASOURCE_PLUGIN_ID, - }; - } else { - datasourceToLoad = this.queryRunner.state.datasource; - } + let datasourceToLoad = this.queryRunner.state.datasource; if (!datasourceToLoad) { return; @@ -159,7 +131,7 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> { } } - public changePluginType(pluginType: string) { + public changePluginType(pluginId: string) { const { options: prevOptions, fieldConfig: prevFieldConfig, @@ -168,16 +140,19 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> { } = sceneUtils.cloneSceneObjectState(this.state.panel.state); // clear custom options - let newFieldConfig = { ...prevFieldConfig }; - newFieldConfig.defaults = { - ...newFieldConfig.defaults, - custom: {}, + let newFieldConfig: FieldConfigSource = { + defaults: { + ...prevFieldConfig.defaults, + custom: {}, + }, + overrides: filterFieldConfigOverrides(prevFieldConfig.overrides, isStandardFieldProp), }; - newFieldConfig.overrides = filterFieldConfigOverrides(newFieldConfig.overrides, isStandardFieldProp); this._cachedPluginOptions[prevPluginId] = { options: prevOptions, fieldConfig: prevFieldConfig }; - const cachedOptions = this._cachedPluginOptions[pluginType]?.options; - const cachedFieldConfig = this._cachedPluginOptions[pluginType]?.fieldConfig; + + const cachedOptions = this._cachedPluginOptions[pluginId]?.options; + const cachedFieldConfig = this._cachedPluginOptions[pluginId]?.fieldConfig; + if (cachedFieldConfig) { newFieldConfig = restoreCustomOverrideRules(newFieldConfig, cachedFieldConfig); } @@ -185,18 +160,40 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> { const newPanel = new VizPanel({ options: cachedOptions ?? {}, fieldConfig: newFieldConfig, - pluginId: pluginType, + pluginId: pluginId, ...restOfOldState, }); + // When changing from non-data to data panel, we need to add a new data provider + if (!this.state.$data && !config.panels[pluginId].skipDataQuery) { + let ds = getLastUsedDatasourceFromStorage(getDashboardSceneFor(this).state.uid!)?.datasourceUid; + + if (!ds) { + ds = config.defaultDatasource; + } + + this.setState({ + $data: new SceneDataTransformer({ + $data: new SceneQueryRunner({ + datasource: { + uid: ds, + }, + queries: [{ refId: 'A' }], + }), + transformations: [], + }), + }); + } + const newPlugin = newPanel.getPlugin(); const panel: PanelModel = { title: newPanel.state.title, options: newPanel.state.options, fieldConfig: newPanel.state.fieldConfig, id: 1, - type: pluginType, + type: pluginId, }; + const newOptions = newPlugin?.onPanelTypeChanged?.(panel, prevPluginId, prevOptions, prevFieldConfig); if (newOptions) { newPanel.onOptionsChange(newOptions, true); @@ -207,107 +204,36 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> { } this.setState({ panel: newPanel }); - this.setupDataObjectSubscription(); + this.loadDataSource(); } public async changePanelDataSource( newSettings: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[] ) { - const { panel, dsSettings } = this.state; - const dataObj = panel.state.$data; - if (!dataObj) { - return; - } + const { dsSettings } = this.state; + const queryRunner = this.queryRunner; const currentDS = dsSettings ? await getDataSourceSrv().get({ uid: dsSettings.uid }) : undefined; const nextDS = await getDataSourceSrv().get({ uid: newSettings.uid }); - const currentQueries = []; - if (dataObj instanceof SceneQueryRunner) { - currentQueries.push(...dataObj.state.queries); - } else if (dataObj instanceof ShareQueryDataProvider) { - currentQueries.push(dataObj.state.query); - } + const currentQueries = queryRunner.state.queries; // We need to pass in newSettings.uid as well here as that can be a variable expression and we want to store that in the query model not the current ds variable value const queries = defaultQueries || (await updateQueries(nextDS, newSettings.uid, currentQueries, currentDS)); - if (dataObj instanceof SceneQueryRunner) { - // Changing to Dashboard data source - if (newSettings.uid === SHARED_DASHBOARD_QUERY) { - // Changing from one plugin to another - const sharedProvider = new ShareQueryDataProvider({ - query: queries[0], - $data: new SceneQueryRunner({ - queries: [], - }), - data: { - series: [], - state: LoadingState.NotStarted, - timeRange: getDefaultTimeRange(), - }, - }); - panel.setState({ $data: sharedProvider }); - this.setupDataObjectSubscription(); - this.loadDataSource(); - } else { - dataObj.setState({ - datasource: { - type: newSettings.type, - uid: newSettings.uid, - }, - queries, - }); - if (defaultQueries) { - dataObj.runQueries(); - } - } - } else if (dataObj instanceof ShareQueryDataProvider && newSettings.uid !== SHARED_DASHBOARD_QUERY) { - const dataProvider = new SceneQueryRunner({ - datasource: { - type: newSettings.type, - uid: newSettings.uid, - }, - queries, - }); - panel.setState({ $data: dataProvider }); - this.setupDataObjectSubscription(); - this.loadDataSource(); - } else if (dataObj instanceof SceneDataTransformer) { - const data = dataObj.clone(); - - let provider: SceneDataProvider = new SceneQueryRunner({ - datasource: { - type: newSettings.type, - uid: newSettings.uid, - }, - queries, - }); - - if (newSettings.uid === SHARED_DASHBOARD_QUERY) { - provider = new ShareQueryDataProvider({ - query: queries[0], - $data: new SceneQueryRunner({ - queries: [], - }), - data: { - series: [], - state: LoadingState.NotStarted, - timeRange: getDefaultTimeRange(), - }, - }); - } - - data.setState({ - $data: provider, - }); - - panel.setState({ $data: data }); - - this.setupDataObjectSubscription(); - this.loadDataSource(); + queryRunner.setState({ + datasource: { + type: newSettings.type, + uid: newSettings.uid, + }, + queries, + }); + if (defaultQueries) { + queryRunner.runQueries(); } + + this.loadDataSource(); } public changeQueryOptions(options: QueryGroupOptions) { @@ -321,14 +247,17 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> { if (options.maxDataPoints !== dataObj.state.maxDataPoints) { dataObjStateUpdate.maxDataPoints = options.maxDataPoints ?? undefined; } + if (options.minInterval !== dataObj.state.minInterval && options.minInterval !== null) { dataObjStateUpdate.minInterval = options.minInterval; } + if (options.timeRange) { timeRangeObjStateUpdate.timeFrom = options.timeRange.from ?? undefined; timeRangeObjStateUpdate.timeShift = options.timeRange.shift ?? undefined; timeRangeObjStateUpdate.hideTimeOverride = options.timeRange.hide; } + if (timeRangeObj instanceof PanelTimeRange) { if (timeRangeObjStateUpdate.timeFrom !== undefined || timeRangeObjStateUpdate.timeShift !== undefined) { // update time override @@ -342,14 +271,27 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> { panelObj.setState({ $timeRange: new PanelTimeRange(timeRangeObjStateUpdate) }); } + if (options.cacheTimeout !== dataObj?.state.cacheTimeout) { + dataObjStateUpdate.cacheTimeout = options.cacheTimeout; + } + + if (options.queryCachingTTL !== dataObj?.state.queryCachingTTL) { + dataObjStateUpdate.queryCachingTTL = options.queryCachingTTL; + } + dataObj.setState(dataObjStateUpdate); dataObj.runQueries(); } - public changeQueries(queries: DataQuery[]) { - const dataObj = this.queryRunner; - dataObj.setState({ queries }); - // TODO: Handle dashboard query + public changeQueries<T extends DataQuery>(queries: T[]) { + const runner = this.queryRunner; + runner.setState({ queries }); + } + + public changeTransformations(transformations: DataTransformerConfig[]) { + const dataprovider = this.dataTransformer; + dataprovider.setState({ transformations }); + dataprovider.reprocessTransformations(); } public inspectPanel() { @@ -363,16 +305,126 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> { } get queryRunner(): SceneQueryRunner { - const dataObj = this.state.panel.state.$data; + // Panel data object is always SceneQueryRunner wrapped in a SceneDataTransformer + const runner = getQueryRunnerFor(this); + + if (!runner) { + throw new Error('Query runner not found'); + } + + return runner; + } + + get dataTransformer(): SceneDataTransformer { + const provider = this.state.$data; + if (!provider || !(provider instanceof SceneDataTransformer)) { + throw new Error('Could not find SceneDataTransformer for panel'); + } + return provider; + } + + public toggleTableView() { + if (this.state.tableView) { + this.setState({ tableView: undefined }); + return; + } + + this.setState({ + tableView: PanelBuilders.table() + .setTitle('') + .setOption('showTypeIcons', true) + .setOption('showHeader', true) + .build(), + }); + } + + public unlinkLibraryPanel() { + const sourcePanel = this.state.sourcePanel.resolve(); + if (!(sourcePanel.parent instanceof LibraryVizPanel)) { + throw new Error('VizPanel is not a child of a library panel'); + } + + const gridItem = sourcePanel.parent.parent; + if (!(gridItem instanceof SceneGridItem)) { + throw new Error('Library panel not a child of a grid item'); + } + + const newSourcePanel = this.state.panel.clone({ $data: this.state.$data?.clone() }); + gridItem.setState({ + body: newSourcePanel, + }); + this.setState({ sourcePanel: newSourcePanel.getRef() }); + } - if (dataObj instanceof ShareQueryDataProvider) { - return dataObj.state.$data as SceneQueryRunner; + public commitChanges() { + const sourcePanel = this.state.sourcePanel.resolve(); + + if (sourcePanel.parent instanceof SceneGridItem) { + sourcePanel.parent.setState({ + body: this.state.panel.clone({ + $data: this.state.$data?.clone(), + }), + }); + } + + if (sourcePanel.parent instanceof LibraryVizPanel) { + if (sourcePanel.parent.parent instanceof SceneGridItem) { + const newLibPanel = sourcePanel.parent.clone({ + panel: this.state.panel.clone({ + $data: this.state.$data?.clone(), + }), + }); + sourcePanel.parent.parent.setState({ + body: newLibPanel, + }); + updateLibraryVizPanel(newLibPanel!).then((p) => { + if (sourcePanel.parent instanceof LibraryVizPanel) { + newLibPanel.setPanelFromLibPanel(p); + } + }); + } } + } + + /** + * Used from inspect json tab to view the current persisted model + */ + public getPanelSaveModel(): Panel | object { + const sourcePanel = this.state.sourcePanel.resolve(); + + if (sourcePanel.parent instanceof SceneGridItem) { + const parentClone = sourcePanel.parent.clone({ + body: this.state.panel.clone({ + $data: this.state.$data?.clone(), + }), + }); - if (dataObj instanceof SceneDataTransformer) { - return dataObj.state.$data as SceneQueryRunner; + return gridItemToPanel(parentClone); } - return dataObj as SceneQueryRunner; + return { error: 'Unsupported panel parent' }; + } + + public getPanelCloneWithData(): VizPanel { + return this.state.panel.clone({ $data: this.state.$data?.clone() }); } + + public static Component = ({ model }: SceneComponentProps<VizPanelManager>) => { + const { panel, tableView } = model.useState(); + const styles = useStyles2(getStyles); + + const panelToShow = tableView ?? panel; + + return <div className={styles.wrapper}>{<panelToShow.Component model={panelToShow} />}</div>; + }; +} + +function getStyles(theme: GrafanaTheme2) { + return { + wrapper: css({ + height: '100%', + width: '100%', + paddingLeft: theme.spacing(2), + }), + }; } diff --git a/public/app/features/dashboard-scene/panel-edit/splitter/useSnappingSplitter.ts b/public/app/features/dashboard-scene/panel-edit/splitter/useSnappingSplitter.ts new file mode 100644 index 0000000000000..95e78cac99c7a --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/splitter/useSnappingSplitter.ts @@ -0,0 +1,104 @@ +import React, { useCallback } from 'react'; + +import { DragHandlePosition, useSplitter } from '@grafana/ui'; + +export interface UseSnappingSplitterOptions { + /** + * The initial size of the primary pane between 0-1, defaults to 0.5 + */ + initialSize?: number; + direction: 'row' | 'column'; + dragPosition?: DragHandlePosition; + paneOptions: PaneOptions; +} + +interface PaneOptions { + collapseBelowPixels: number; + snapOpenToPixels?: number; +} + +interface PaneState { + collapsed: boolean; + snapSize?: number; +} + +export function useSnappingSplitter(options: UseSnappingSplitterOptions) { + const { paneOptions } = options; + + const [state, setState] = React.useState<PaneState>({ collapsed: false }); + + const onResizing = useCallback( + (flexSize: number, pixelSize: number) => { + if (flexSize <= 0 && pixelSize <= 0) { + return; + } + + const optionsPixelSize = (pixelSize / flexSize) * (1 - flexSize); + + if (state.collapsed && optionsPixelSize > paneOptions.collapseBelowPixels) { + setState({ collapsed: false }); + } + + if (!state.collapsed && optionsPixelSize < paneOptions.collapseBelowPixels) { + setState({ collapsed: true }); + } + }, + [state, paneOptions.collapseBelowPixels] + ); + + const onSizeChanged = useCallback( + (flexSize: number, pixelSize: number) => { + if (flexSize <= 0 && pixelSize <= 0) { + return; + } + + const newSecondPaneSize = 1 - flexSize; + const isSnappedClosed = state.snapSize === 0; + const sizeOfBothPanes = pixelSize / flexSize; + const snapOpenToPixels = paneOptions.snapOpenToPixels ?? sizeOfBothPanes / 2; + const snapSize = snapOpenToPixels / sizeOfBothPanes; + + if (state.collapsed) { + if (isSnappedClosed) { + setState({ snapSize: Math.max(newSecondPaneSize, snapSize), collapsed: false }); + } else { + setState({ snapSize: 0, collapsed: true }); + } + } else if (isSnappedClosed) { + setState({ snapSize: newSecondPaneSize, collapsed: false }); + } + }, + [state, paneOptions.snapOpenToPixels] + ); + + const onToggleCollapse = useCallback(() => { + setState({ collapsed: !state.collapsed }); + }, [state.collapsed]); + + const { containerProps, primaryProps, secondaryProps, splitterProps } = useSplitter({ + ...options, + onResizing, + onSizeChanged, + }); + + // This is to allow resizing it beyond the content dimensions + secondaryProps.style.overflow = 'hidden'; + secondaryProps.style.minWidth = 'unset'; + secondaryProps.style.minHeight = 'unset'; + + if (state.snapSize) { + primaryProps.style = { + ...primaryProps.style, + flexGrow: 1 - state.snapSize, + }; + secondaryProps.style.flexGrow = state.snapSize; + } else if (state.snapSize === 0) { + primaryProps.style.flexGrow = 1; + secondaryProps.style.flexGrow = 0; + secondaryProps.style.minWidth = 'unset'; + secondaryProps.style.minHeight = 'unset'; + secondaryProps.style.overflow = 'unset'; + } + + return { containerProps, primaryProps, secondaryProps, splitterProps, splitterState: state, onToggleCollapse }; +} diff --git a/public/app/features/dashboard-scene/panel-edit/testfiles/testDashboard.json b/public/app/features/dashboard-scene/panel-edit/testfiles/testDashboard.json deleted file mode 100644 index 09b3a39febdce..0000000000000 --- a/public/app/features/dashboard-scene/panel-edit/testfiles/testDashboard.json +++ /dev/null @@ -1,368 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": 2378, - "links": [], - "liveNow": false, - "panels": [ - { - "datasource": { - "type": "grafana-testdata-datasource", - "uid": "gdev-testdata" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "grafana-testdata-datasource", - "uid": "gdev-testdata" - }, - "refId": "A", - "scenarioId": "random_walk", - "seriesCount": 1 - } - ], - "title": "Source panel", - "type": "timeseries" - }, - { - "datasource": { - "type": "grafana-testdata-datasource", - "uid": "gdev-testdata" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 0 - }, - "id": 2, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": ["sum"], - "show": false - }, - "showHeader": true - }, - "pluginVersion": "10.3.0-pre", - "targets": [ - { - "datasource": { - "type": "grafana-testdata-datasource", - "uid": "gdev-testdata" - }, - "refId": "A", - "scenarioId": "random_walk", - "seriesCount": 1 - } - ], - "title": "Panel with transforms", - "transformations": [ - { - "id": "reduce", - "options": {} - } - ], - "type": "table" - }, - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 8 - }, - "id": 3, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 1, - "refId": "A" - } - ], - "title": "Dashboard query", - "type": "timeseries" - }, - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 8 - }, - "id": 4, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": ["sum"], - "show": false - }, - "showHeader": true - }, - "pluginVersion": "10.3.0-pre", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 1, - "refId": "A" - } - ], - "title": "Dashboard query with transformations", - "transformations": [ - { - "id": "reduce", - "options": {} - } - ], - "type": "table" - } - ], - "refresh": "", - "schemaVersion": 39, - "tags": [], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Scenes/PanelEdit/Queries: Edit", - "uid": "ffbe00e2-803c-4d49-adb7-41aad336234f", - "version": 6, - "weekStart": "" -} diff --git a/public/app/features/dashboard-scene/panel-edit/testfiles/testDashboard.ts b/public/app/features/dashboard-scene/panel-edit/testfiles/testDashboard.ts new file mode 100644 index 0000000000000..e5ab40384442e --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/testfiles/testDashboard.ts @@ -0,0 +1,375 @@ +export const panelWithQueriesOnly = { + datasource: { + type: 'grafana-testdata-datasource', + uid: 'gdev-testdata', + }, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + custom: { + axisBorderShow: false, + axisCenteredZero: false, + axisColorMode: 'text', + axisLabel: '', + axisPlacement: 'auto', + barAlignment: 0, + drawStyle: 'line', + fillOpacity: 0, + gradientMode: 'none', + hideFrom: { + legend: false, + tooltip: false, + viz: false, + }, + insertNulls: false, + lineInterpolation: 'linear', + lineWidth: 1, + pointSize: 5, + scaleDistribution: { + type: 'linear', + }, + showPoints: 'auto', + spanNulls: false, + stacking: { + group: 'A', + mode: 'none', + }, + thresholdsStyle: { + mode: 'off', + }, + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 8, + w: 12, + x: 0, + y: 0, + }, + id: 1, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + showLegend: true, + }, + tooltip: { + mode: 'single', + sort: 'none', + }, + }, + targets: [ + { + datasource: { + type: 'grafana-testdata-datasource', + uid: 'gdev-testdata', + }, + refId: 'A', + scenarioId: 'random_walk', + seriesCount: 1, + }, + ], + title: 'Panel with just queries', + type: 'timeseries', +}; + +export const panelWithTransformations = { + datasource: { + type: 'grafana-testdata-datasource', + uid: 'gdev-testdata', + }, + fieldConfig: { + defaults: { + color: { + mode: 'thresholds', + }, + custom: { + align: 'auto', + cellOptions: { + type: 'auto', + }, + inspect: false, + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 8, + w: 12, + x: 12, + y: 0, + }, + id: 2, + options: { + cellHeight: 'sm', + footer: { + countRows: false, + fields: '', + reducer: ['sum'], + show: false, + }, + showHeader: true, + }, + pluginVersion: '10.3.0-pre', + targets: [ + { + datasource: { + type: 'grafana-testdata-datasource', + uid: 'gdev-testdata', + }, + refId: 'A', + scenarioId: 'random_walk', + seriesCount: 1, + }, + ], + title: 'Panel with transforms', + transformations: [ + { + id: 'reduce', + options: {}, + }, + ], + type: 'table', +}; + +export const panelWithDashboardQuery = { + datasource: { + type: 'datasource', + uid: '-- Dashboard --', + }, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + custom: { + axisBorderShow: false, + axisCenteredZero: false, + axisColorMode: 'text', + axisLabel: '', + axisPlacement: 'auto', + barAlignment: 0, + drawStyle: 'line', + fillOpacity: 0, + gradientMode: 'none', + hideFrom: { + legend: false, + tooltip: false, + viz: false, + }, + insertNulls: false, + lineInterpolation: 'linear', + lineWidth: 1, + pointSize: 5, + scaleDistribution: { + type: 'linear', + }, + showPoints: 'auto', + spanNulls: false, + stacking: { + group: 'A', + mode: 'none', + }, + thresholdsStyle: { + mode: 'off', + }, + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 8, + w: 12, + x: 0, + y: 8, + }, + id: 3, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + showLegend: true, + }, + tooltip: { + mode: 'single', + sort: 'none', + }, + }, + targets: [ + { + datasource: { + type: 'datasource', + uid: '-- Dashboard --', + }, + panelId: 1, + refId: 'A', + }, + ], + title: 'Panel with a Dashboard query', + type: 'timeseries', +}; + +export const panelWithDashboardQueryAndTransformations = { + datasource: { + type: 'datasource', + uid: '-- Dashboard --', + }, + fieldConfig: { + defaults: { + color: { + mode: 'thresholds', + }, + custom: { + align: 'auto', + cellOptions: { + type: 'auto', + }, + inspect: false, + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 8, + w: 12, + x: 12, + y: 8, + }, + id: 4, + options: { + cellHeight: 'sm', + footer: { + countRows: false, + fields: '', + reducer: ['sum'], + show: false, + }, + showHeader: true, + }, + pluginVersion: '10.3.0-pre', + targets: [ + { + datasource: { + type: 'datasource', + uid: '-- Dashboard --', + }, + panelId: 1, + refId: 'A', + }, + ], + title: 'Panel with a dashboard query with transformations', + transformations: [ + { + id: 'reduce', + options: {}, + }, + ], + type: 'table', +}; +export const testDashboard = { + annotations: { + list: [ + { + builtIn: 1, + datasource: { + type: 'grafana', + uid: '-- Grafana --', + }, + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + type: 'dashboard', + }, + ], + }, + editable: true, + fiscalYearStartMonth: 0, + graphTooltip: 0, + id: 2378, + links: [], + liveNow: false, + panels: [ + panelWithQueriesOnly, + panelWithTransformations, + panelWithDashboardQuery, + panelWithDashboardQueryAndTransformations, + ], + refresh: '', + schemaVersion: 39, + tags: [], + templating: { + list: [], + }, + time: { + from: 'now-6h', + to: 'now', + }, + timepicker: {}, + timezone: '', + title: 'Scenes/PanelEdit/Queries: Edit', + uid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', + version: 6, + weekStart: '', +}; diff --git a/public/app/features/dashboard-scene/saving/DashboardPrompt.test.tsx b/public/app/features/dashboard-scene/saving/DashboardPrompt.test.tsx new file mode 100644 index 0000000000000..81b77a80f4bce --- /dev/null +++ b/public/app/features/dashboard-scene/saving/DashboardPrompt.test.tsx @@ -0,0 +1,156 @@ +import { SceneGridItem, SceneGridLayout, SceneQueryRunner, SceneTimeRange, VizPanel, behaviors } from '@grafana/scenes'; +import { ContextSrv, setContextSrv } from 'app/core/services/context_srv'; + +import { DashboardControls } from '../scene/DashboardControls'; +import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; +import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; + +import { ignoreChanges } from './DashboardPrompt'; + +function getTestContext() { + const contextSrv = { isSignedIn: true, isEditor: true } as ContextSrv; + setContextSrv(contextSrv); + + return { contextSrv }; +} + +describe('DashboardPrompt', () => { + describe('ignoreChanges', () => { + beforeEach(() => { + getTestContext(); + }); + + describe('when called without original dashboard', () => { + it('then it should return true', () => { + const scene = buildTestScene(); + expect(ignoreChanges(scene, undefined)).toBe(true); + }); + }); + + describe('when called without current dashboard', () => { + it('then it should return true', () => { + const scene = buildTestScene(); + expect(ignoreChanges(null, scene.getInitialSaveModel())).toBe(true); + }); + }); + + describe('when called for a viewer without save permissions', () => { + it('then it should return true', () => { + const { contextSrv } = getTestContext(); + const scene = buildTestScene({ + meta: { + canSave: false, + }, + }); + contextSrv.isEditor = false; + + expect(ignoreChanges(scene, scene.getInitialSaveModel())).toBe(true); + }); + }); + + describe('when called for a viewer with save permissions', () => { + it('then it should return undefined', () => { + const { contextSrv } = getTestContext(); + + const scene = buildTestScene({ + meta: { + canSave: true, + }, + }); + const initialSaveModel = transformSceneToSaveModel(scene); + + contextSrv.isEditor = false; + + expect(ignoreChanges(scene, initialSaveModel)).toBe(undefined); + }); + }); + + describe('when called for an user that is not signed in', () => { + it('then it should return true', () => { + const { contextSrv } = getTestContext(); + const scene = buildTestScene({ + meta: { + canSave: true, + }, + }); + const initialSaveModel = transformSceneToSaveModel(scene); + + contextSrv.isSignedIn = false; + expect(ignoreChanges(scene, initialSaveModel)).toBe(true); + }); + }); + + describe('when called with fromScript', () => { + it('then it should return true', () => { + const scene = buildTestScene({ + meta: { + canSave: true, + fromScript: true, + }, + }); + const initialSaveModel = transformSceneToSaveModel(scene); + expect(ignoreChanges(scene, initialSaveModel)).toBe(true); + }); + }); + + describe('when called with fromFile', () => { + it('then it should return true', () => { + const scene = buildTestScene({ + meta: { + canSave: true, + fromScript: undefined, + fromFile: true, + }, + }); + const initialSaveModel = transformSceneToSaveModel(scene); + expect(ignoreChanges(scene, initialSaveModel)).toBe(true); + }); + }); + + describe('when called with canSave but without fromScript and fromFile', () => { + it('then it should return false', () => { + const scene = buildTestScene({ + meta: { + canSave: true, + fromScript: undefined, + fromFile: undefined, + }, + }); + const initialSaveModel = transformSceneToSaveModel(scene); + expect(ignoreChanges(scene, initialSaveModel)).toBe(undefined); + }); + }); + }); +}); + +function buildTestScene(overrides?: Partial<DashboardSceneState>) { + const scene = new DashboardScene({ + title: 'hello', + uid: 'dash-1', + description: 'hello description', + tags: ['tag1', 'tag2'], + editable: true, + $timeRange: new SceneTimeRange({ + timeZone: 'browser', + }), + controls: new DashboardControls({}), + $behaviors: [new behaviors.CursorSync({})], + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + body: new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), + }), + }), + ], + }), + ...overrides, + }); + + return scene; +} diff --git a/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx b/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx new file mode 100644 index 0000000000000..dd8a9702f35fb --- /dev/null +++ b/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx @@ -0,0 +1,188 @@ +import { css } from '@emotion/css'; +import * as H from 'history'; +import React, { useState, useContext, useEffect } from 'react'; +import { Prompt } from 'react-router'; + +import { locationService } from '@grafana/runtime'; +import { Dashboard } from '@grafana/schema/dist/esm/index.gen'; +import { ModalsContext, Modal, Button } from '@grafana/ui'; +import { contextSrv } from 'app/core/services/context_srv'; + +import { DashboardScene } from '../scene/DashboardScene'; + +interface DashboardPromptProps { + dashboard: DashboardScene; +} + +interface DashboardPromptState { + originalPath?: string; +} +export const DashboardPrompt = React.memo(({ dashboard }: DashboardPromptProps) => { + const [state, setState] = useState<DashboardPromptState>({ originalPath: undefined }); + const { originalPath } = state; + const { showModal, hideModal } = useContext(ModalsContext); + + useEffect(() => { + // This timeout delay is to wait for panels to load and migrate scheme before capturing the original state + // This is to minimize unsaved changes warnings due to automatic schema migrations + const timeoutId = setTimeout(() => { + const originalPath = locationService.getLocation().pathname; + setState({ originalPath }); + }, 1000); + + return () => { + clearTimeout(timeoutId); + }; + }, [dashboard, originalPath]); + + useEffect(() => { + const handleUnload = (event: BeforeUnloadEvent) => { + if (ignoreChanges(dashboard, dashboard.getInitialSaveModel())) { + return; + } + if (dashboard.state.isDirty) { + event.preventDefault(); + // No browser actually displays this message anymore. + // But Chrome requires it to be defined else the popup won't show. + event.returnValue = ''; + } + }; + window.addEventListener('beforeunload', handleUnload); + return () => window.removeEventListener('beforeunload', handleUnload); + }, [dashboard]); + + const onHistoryBlock = (location: H.Location) => { + // const panelInEdit = dashboard.state.editPanel; + // const search = new URLSearchParams(location.search); + + // TODO: Are we leaving panel edit & library panel? + + // if (panelInEdit && panelInEdit.libraryPanel && panelInEdit.hasChanged && !search.has('editPanel')) { + // showModal(SaveLibraryPanelModal, { + // isUnsavedPrompt: true, + // panel: dashboard.panelInEdit as PanelModelWithLibraryPanel, + // folderUid: dashboard.meta.folderUid ?? '', + // onConfirm: () => { + // hideModal(); + // moveToBlockedLocationAfterReactStateUpdate(location); + // }, + // onDiscard: () => { + // dispatch(discardPanelChanges()); + // moveToBlockedLocationAfterReactStateUpdate(location); + // hideModal(); + // }, + // onDismiss: hideModal, + // }); + // return false; + // } + + // Are we still on the same dashboard? + if (originalPath === location.pathname) { + return true; + } + + if (ignoreChanges(dashboard, dashboard.getInitialSaveModel())) { + return true; + } + + if (!dashboard.state.isDirty) { + return true; + } + + showModal(UnsavedChangesModal, { + dashboard, + onSaveDashboardClick: () => { + hideModal(); + dashboard.openSaveDrawer({ + onSaveSuccess: () => { + moveToBlockedLocationAfterReactStateUpdate(location); + }, + }); + }, + + onDiscard: () => { + dashboard.exitEditMode({ skipConfirm: true }); + hideModal(); + moveToBlockedLocationAfterReactStateUpdate(location); + }, + onDismiss: hideModal, + }); + + return false; + }; + + return <Prompt when={true} message={onHistoryBlock} />; +}); + +DashboardPrompt.displayName = 'DashboardPrompt'; + +function moveToBlockedLocationAfterReactStateUpdate(location?: H.Location | null) { + if (location) { + setTimeout(() => locationService.push(location), 10); + } +} +interface UnsavedChangesModalProps { + onDiscard: () => void; + onDismiss: () => void; + onSaveDashboardClick?: () => void; +} + +export const UnsavedChangesModal = ({ onDiscard, onDismiss, onSaveDashboardClick }: UnsavedChangesModalProps) => { + const styles = getStyles(); + return ( + <Modal + isOpen={true} + title="Unsaved changes" + onDismiss={onDismiss} + icon="exclamation-triangle" + className={styles.modal} + > + <h5>Do you want to save your changes?</h5> + <Modal.ButtonRow> + <Button variant="secondary" onClick={onDismiss} fill="outline"> + Cancel + </Button> + <Button variant="destructive" onClick={onDiscard}> + Discard + </Button> + <Button onClick={onSaveDashboardClick}>Save dashboard</Button> + </Modal.ButtonRow> + </Modal> + ); +}; + +const getStyles = () => ({ + modal: css({ + width: '500px', + }), +}); + +/** + * For some dashboards and users changes should be ignored * + */ +export function ignoreChanges(current: DashboardScene | null, original?: Dashboard) { + if (!original) { + return true; + } + + // Ignore changes if original is unsaved + if ((original as Dashboard).version === 0) { + return true; + } + + // Ignore changes if the user has been signed out + if (!contextSrv.isSignedIn) { + return true; + } + + if (!current) { + return true; + } + + const { canSave, fromScript, fromFile } = current.state.meta; + if (!contextSrv.isEditor && !canSave) { + return true; + } + + return !canSave || fromScript || fromFile; +} diff --git a/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts new file mode 100644 index 0000000000000..50879fe173bb2 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts @@ -0,0 +1,123 @@ +import { Unsubscribable } from 'rxjs'; + +import { + SceneDataLayers, + SceneGridItem, + SceneGridLayout, + SceneObjectStateChangedEvent, + SceneRefreshPicker, + SceneTimeRange, + SceneVariableSet, + behaviors, +} from '@grafana/scenes'; +import { createWorker } from 'app/features/dashboard-scene/saving/createDetectChangesWorker'; + +import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; +import { DashboardControls } from '../scene/DashboardControls'; +import { DashboardScene, PERSISTED_PROPS } from '../scene/DashboardScene'; +import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; +import { isSceneVariableInstance } from '../settings/variables/utils'; + +import { DashboardChangeInfo } from './shared'; + +export class DashboardSceneChangeTracker { + private _changeTrackerSub: Unsubscribable | undefined; + private _changesWorker?: Worker; + private _dashboard: DashboardScene; + + constructor(dashboard: DashboardScene) { + this._dashboard = dashboard; + } + + private onStateChanged({ payload }: SceneObjectStateChangedEvent) { + if (payload.changedObject instanceof SceneRefreshPicker) { + if (Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'intervals')) { + this.detectChanges(); + } + } + if (payload.changedObject instanceof behaviors.CursorSync) { + this.detectChanges(); + } + if (payload.changedObject instanceof SceneDataLayers) { + this.detectChanges(); + } + if (payload.changedObject instanceof SceneGridItem) { + this.detectChanges(); + } + if (payload.changedObject instanceof SceneGridLayout) { + this.detectChanges(); + } + if (payload.changedObject instanceof DashboardScene) { + if (Object.keys(payload.partialUpdate).some((key) => PERSISTED_PROPS.includes(key))) { + this.detectChanges(); + } + } + if (payload.changedObject instanceof SceneTimeRange) { + this.detectChanges(); + } + if (payload.changedObject instanceof DashboardControls) { + if (Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'hideTimeControls')) { + this.detectChanges(); + } + } + if (payload.changedObject instanceof SceneVariableSet) { + this.detectChanges(); + } + if (payload.changedObject instanceof DashboardAnnotationsDataLayer) { + if (!Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'data')) { + this.detectChanges(); + } + } + if (isSceneVariableInstance(payload.changedObject)) { + this.detectChanges(); + } + } + + private detectChanges() { + this._changesWorker?.postMessage({ + changed: transformSceneToSaveModel(this._dashboard), + initial: this._dashboard.getInitialSaveModel(), + }); + } + + private updateIsDirty(result: DashboardChangeInfo) { + const { hasChanges } = result; + + if (hasChanges) { + if (!this._dashboard.state.isDirty) { + this._dashboard.setState({ isDirty: true }); + } + } else { + if (this._dashboard.state.isDirty) { + this._dashboard.setState({ isDirty: false }); + } + } + } + + private init() { + this._changesWorker = createWorker(); + } + + public startTrackingChanges() { + if (!this._changesWorker) { + this.init(); + } + this._changesWorker!.onmessage = (e: MessageEvent<DashboardChangeInfo>) => { + this.updateIsDirty(e.data); + }; + + this._changeTrackerSub = this._dashboard.subscribeToEvent( + SceneObjectStateChangedEvent, + this.onStateChanged.bind(this) + ); + } + + public stopTrackingChanges() { + this._changeTrackerSub?.unsubscribe(); + } + + public terminate() { + this.stopTrackingChanges(); + this._changesWorker?.terminate(); + } +} diff --git a/public/app/features/dashboard-scene/saving/DetectChangesWorker.ts b/public/app/features/dashboard-scene/saving/DetectChangesWorker.ts new file mode 100644 index 0000000000000..4c29daef47f83 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/DetectChangesWorker.ts @@ -0,0 +1,10 @@ +// Worker is not three shakable, so we should not import the whole loadash library +// eslint-disable-next-line lodash/import-scope +import debounce from 'lodash/debounce'; + +import { getDashboardChanges } from './getDashboardChanges'; + +self.onmessage = debounce((e: MessageEvent<{ initial: any; changed: any }>) => { + const result = getDashboardChanges(e.data.initial, e.data.changed, false, false); + self.postMessage(result); +}, 500); diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx new file mode 100644 index 0000000000000..5f201c9f6a1b2 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx @@ -0,0 +1,201 @@ +import debounce from 'debounce-promise'; +import React from 'react'; +import { UseFormSetValue, useForm } from 'react-hook-form'; + +import { Dashboard } from '@grafana/schema'; +import { Button, Input, Switch, Field, Label, TextArea, Stack, Alert, Box } from '@grafana/ui'; +import { FolderPicker } from 'app/core/components/Select/FolderPicker'; +import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv'; + +import { DashboardScene } from '../scene/DashboardScene'; + +import { SaveDashboardDrawer } from './SaveDashboardDrawer'; +import { DashboardChangeInfo, NameAlreadyExistsError, SaveButton, isNameExistsError } from './shared'; +import { useSaveDashboard } from './useSaveDashboard'; + +interface SaveDashboardAsFormDTO { + firstName?: string; + title: string; + description: string; + folder: { uid?: string; title?: string }; + copyTags: boolean; +} + +export interface Props { + dashboard: DashboardScene; + drawer: SaveDashboardDrawer; + changeInfo: DashboardChangeInfo; +} + +export function SaveDashboardAsForm({ dashboard, drawer, changeInfo }: Props) { + const { changedSaveModel } = changeInfo; + + const { register, handleSubmit, setValue, formState, getValues, watch, trigger } = useForm<SaveDashboardAsFormDTO>({ + mode: 'onBlur', + defaultValues: { + title: changeInfo.isNew ? changedSaveModel.title! : `${changedSaveModel.title} Copy`, + description: changedSaveModel.description ?? '', + folder: { + uid: dashboard.state.meta.folderUid, + title: dashboard.state.meta.folderTitle, + }, + copyTags: false, + }, + }); + + const { errors, isValid, defaultValues } = formState; + const formValues = watch(); + + const { state, onSaveDashboard } = useSaveDashboard(false); + + const onSave = async (overwrite: boolean) => { + const data = getValues(); + + const dashboardToSave: Dashboard = getSaveAsDashboardSaveModel(changedSaveModel, data, changeInfo.isNew); + const result = await onSaveDashboard(dashboard, dashboardToSave, { overwrite, folderUid: data.folder.uid }); + + if (result.status === 'success') { + dashboard.closeModal(); + } + }; + + const cancelButton = ( + <Button variant="secondary" onClick={() => dashboard.closeModal()} fill="outline"> + Cancel + </Button> + ); + + const saveButton = (overwrite: boolean) => ( + <SaveButton isValid={isValid} isLoading={state.loading} onSave={onSave} overwrite={overwrite} /> + ); + + function renderFooter(error?: Error) { + if (isNameExistsError(error)) { + return <NameAlreadyExistsError cancelButton={cancelButton} saveButton={saveButton} />; + } + + return ( + <> + {error && ( + <Alert title="Failed to save dashboard" severity="error"> + <p>{error.message}</p> + </Alert> + )} + <Stack alignItems="center"> + {cancelButton} + {saveButton(false)} + </Stack> + </> + ); + } + + return ( + <form onSubmit={handleSubmit(() => onSave(false))}> + <Field + label={<TitleFieldLabel dashboard={changedSaveModel} onChange={setValue} />} + invalid={!!errors.title} + error={errors.title?.message} + > + <Input + {...register('title', { required: 'Required', validate: validateDashboardName })} + aria-label="Save dashboard title field" + onChange={debounce(async () => { + trigger('title'); + }, 400)} + autoFocus + /> + </Field> + <Field + label={<DescriptionLabel dashboard={changedSaveModel} onChange={setValue} />} + invalid={!!errors.description} + error={errors.description?.message} + > + <TextArea + {...register('description', { required: false })} + aria-label="Save dashboard description field" + autoFocus + /> + </Field> + + <Field label="Folder"> + <FolderPicker + onChange={(uid: string | undefined, title: string | undefined) => setValue('folder', { uid, title })} + // Old folder picker fields + value={formValues.folder?.uid} + initialTitle={defaultValues!.folder!.title} + dashboardId={changedSaveModel.id ?? undefined} + enableCreateNew + /> + </Field> + {!changeInfo.isNew && ( + <Field label="Copy tags"> + <Switch {...register('copyTags')} /> + </Field> + )} + <Box paddingTop={2}>{renderFooter(state.error)}</Box> + </form> + ); +} + +export interface TitleLabelProps { + dashboard: Dashboard; + onChange: UseFormSetValue<SaveDashboardAsFormDTO>; +} + +export function TitleFieldLabel(props: TitleLabelProps) { + return ( + <Stack justifyContent="space-between"> + <Label htmlFor="description">Title</Label> + {/* {config.featureToggles.dashgpt && isNew && ( + <GenAIDashDescriptionButton + onGenerate={(description) => field.onChange(description)} + dashboard={dashboard} + /> + )} */} + </Stack> + ); +} + +export interface DescriptionLabelProps { + dashboard: Dashboard; + onChange: UseFormSetValue<SaveDashboardAsFormDTO>; +} + +export function DescriptionLabel(props: DescriptionLabelProps) { + return ( + <Stack justifyContent="space-between"> + <Label htmlFor="description">Description</Label> + {/* {config.featureToggles.dashgpt && isNew && ( + <GenAIDashDescriptionButton + onGenerate={(description) => field.onChange(description)} + dashboard={dashboard} + /> + )} */} + </Stack> + ); +} + +async function validateDashboardName(title: string, formValues: SaveDashboardAsFormDTO) { + if (title === formValues.folder.title?.trim()) { + return 'Dashboard name cannot be the same as folder name'; + } + + try { + await validationSrv.validateNewDashboardName(formValues.folder.uid ?? 'general', title); + return true; + } catch (e) { + return e instanceof Error ? e.message : 'Dashboard name is invalid'; + } +} + +function getSaveAsDashboardSaveModel(source: Dashboard, form: SaveDashboardAsFormDTO, isNew?: boolean): Dashboard { + // TODO remove old alerts and thresholds when copying (See getSaveAsDashboardClone) + return { + ...source, + id: null, + uid: '', + title: form.title, + description: form.description, + tags: isNew || form.copyTags ? source.tags : [], + }; +} diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.test.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.test.tsx new file mode 100644 index 0000000000000..a015b2a8964c7 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.test.tsx @@ -0,0 +1,207 @@ +import { screen, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; + +import { selectors } from '@grafana/e2e-selectors'; +import { sceneGraph } from '@grafana/scenes'; +import { SaveDashboardResponseDTO } from 'app/types'; + +import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; +import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; + +import { SaveDashboardDrawer } from './SaveDashboardDrawer'; + +jest.mock('app/features/manage-dashboards/services/ValidationSrv', () => ({ + validationSrv: { + validateNewDashboardName: () => true, + }, +})); + +const saveDashboardMutationMock = jest.fn(); + +jest.mock('app/features/browse-dashboards/api/browseDashboardsAPI', () => ({ + ...jest.requireActual('app/features/browse-dashboards/api/browseDashboardsAPI'), + useSaveDashboardMutation: () => [saveDashboardMutationMock], +})); + +describe('SaveDashboardDrawer', () => { + describe('Given an already saved dashboard', () => { + it('should render save drawer with only message textarea', async () => { + setup().openAndRender(); + + expect(await screen.findByText('Save dashboard')).toBeInTheDocument(); + expect(screen.queryByLabelText(selectors.pages.SaveDashboardModal.saveTimerange)).not.toBeInTheDocument(); + expect(screen.getByText('No changes to save')).toBeInTheDocument(); + expect(screen.queryByLabelText('Tab Changes')).not.toBeInTheDocument(); + }); + + it('When there are no changes', async () => { + setup().openAndRender(); + expect(screen.getByText('No changes to save')).toBeInTheDocument(); + }); + + it('When time range changed show save time range option', async () => { + const { dashboard, openAndRender } = setup(); + + sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' }); + + openAndRender(); + + expect(await screen.findByText('Save dashboard')).toBeInTheDocument(); + expect(screen.queryByLabelText(selectors.pages.SaveDashboardModal.saveTimerange)).toBeInTheDocument(); + }); + + it('Should update diff when including time range is', async () => { + const { dashboard, openAndRender } = setup(); + + sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' }); + + openAndRender(); + + expect(await screen.findByText('Save dashboard')).toBeInTheDocument(); + expect(screen.queryByLabelText(selectors.pages.SaveDashboardModal.saveTimerange)).toBeInTheDocument(); + expect(screen.queryByLabelText('Tab Changes')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText(selectors.pages.SaveDashboardModal.saveTimerange)); + + expect(await screen.findByLabelText('Tab Changes')).toBeInTheDocument(); + }); + + it('Can show changes', async () => { + const { dashboard, openAndRender } = setup(); + + dashboard.setState({ title: 'New title' }); + + openAndRender(); + + await userEvent.click(await screen.findByLabelText('Tab Changes')); + + expect(await screen.findByText('Full JSON diff')).toBeInTheDocument(); + }); + + it('Can save', async () => { + const { dashboard, openAndRender } = setup(); + + dashboard.setState({ title: 'New title' }); + + openAndRender(); + + mockSaveDashboard(); + + await userEvent.click(await screen.findByLabelText(selectors.pages.SaveDashboardModal.save)); + + const dataSent = saveDashboardMutationMock.mock.calls[0][0]; + expect(dataSent.dashboard.title).toEqual('New title'); + expect(dashboard.state.version).toEqual(11); + expect(dashboard.state.uid).toEqual('my-uid-from-resp'); + expect(dashboard.state.isDirty).toEqual(false); + }); + + it('Can handle save errors and overwrite', async () => { + const { dashboard, openAndRender } = setup(); + + dashboard.setState({ title: 'New title' }); + + openAndRender(); + + mockSaveDashboard({ saveError: 'version-mismatch' }); + + await userEvent.click(await screen.findByLabelText(selectors.pages.SaveDashboardModal.save)); + + expect(await screen.findByText('Someone else has updated this dashboard')).toBeInTheDocument(); + expect(await screen.findByText('Save and overwrite')).toBeInTheDocument(); + + // Now save and overwrite + await userEvent.click(await screen.findByLabelText(selectors.pages.SaveDashboardModal.save)); + + const dataSent = saveDashboardMutationMock.mock.calls[1][0]; + expect(dataSent.overwrite).toEqual(true); + }); + }); + + describe('Save as copy', () => { + it('Should show save as form', async () => { + const { openAndRender } = setup(); + openAndRender(true); + + expect(await screen.findByText('Save dashboard copy')).toBeInTheDocument(); + + mockSaveDashboard(); + + await userEvent.click(await screen.findByLabelText(selectors.pages.SaveDashboardModal.save)); + + const dataSent = saveDashboardMutationMock.mock.calls[0][0]; + expect(dataSent.dashboard.uid).toEqual(''); + }); + }); +}); + +interface MockBackendApiOptions { + saveError: 'version-mismatch' | 'name-exists' | 'plugin-dashboard'; +} + +function mockSaveDashboard(options: Partial<MockBackendApiOptions> = {}) { + saveDashboardMutationMock.mockClear(); + + if (options.saveError) { + saveDashboardMutationMock.mockResolvedValue({ + error: { status: 412, data: { status: 'version-mismatch', message: 'sad face' } }, + }); + + return; + } + + saveDashboardMutationMock.mockResolvedValue({ + data: { + id: 10, + uid: 'my-uid-from-resp', + slug: 'my-slug-from-resp', + status: 'success', + url: 'my-url', + version: 11, + ...options, + } as SaveDashboardResponseDTO, + }); +} + +let cleanUp = () => {}; + +function setup() { + const dashboard = transformSaveModelToScene({ + dashboard: { + title: 'hello', + uid: 'my-uid', + schemaVersion: 30, + panels: [], + version: 10, + }, + meta: {}, + }); + + // Clear any data layers + dashboard.setState({ $data: undefined }); + + const initialSaveModel = transformSceneToSaveModel(dashboard); + dashboard.setInitialSaveModel(initialSaveModel); + + cleanUp(); + cleanUp = dashboard.activate(); + + dashboard.onEnterEditMode(); + + const openAndRender = (saveAsCopy?: boolean) => { + dashboard.openSaveDrawer({ saveAsCopy }); + const drawer = dashboard.state.overlay as SaveDashboardDrawer; + render( + <TestProvider> + <drawer.Component model={drawer} /> + </TestProvider> + ); + + return drawer; + }; + + // await act(() => Promise.resolve()); + return { dashboard, openAndRender }; +} diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx new file mode 100644 index 0000000000000..e52aa49698e3f --- /dev/null +++ b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx @@ -0,0 +1,86 @@ +import React from 'react'; + +import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneObjectRef } from '@grafana/scenes'; +import { Drawer, Tab, TabsBar } from '@grafana/ui'; +import { SaveDashboardDiff } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDiff'; + +import { DashboardScene } from '../scene/DashboardScene'; + +import { SaveDashboardAsForm } from './SaveDashboardAsForm'; +import { SaveDashboardForm } from './SaveDashboardForm'; +import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm'; +import { getDashboardChangesFromScene } from './getDashboardChangesFromScene'; + +interface SaveDashboardDrawerState extends SceneObjectState { + dashboardRef: SceneObjectRef<DashboardScene>; + showDiff?: boolean; + saveTimeRange?: boolean; + saveVariables?: boolean; + saveAsCopy?: boolean; + onSaveSuccess?: () => void; +} + +export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> { + public onClose = () => { + this.state.dashboardRef.resolve().setState({ overlay: undefined }); + }; + + public onToggleSaveTimeRange = () => { + this.setState({ saveTimeRange: !this.state.saveTimeRange }); + }; + + public onToggleSaveVariables = () => { + this.setState({ saveVariables: !this.state.saveVariables }); + }; + + static Component = ({ model }: SceneComponentProps<SaveDashboardDrawer>) => { + const { showDiff, saveAsCopy, saveTimeRange, saveVariables } = model.useState(); + const changeInfo = getDashboardChangesFromScene(model.state.dashboardRef.resolve(), saveTimeRange, saveVariables); + const { changedSaveModel, initialSaveModel, diffs, diffCount } = changeInfo; + const dashboard = model.state.dashboardRef.resolve(); + const isProvisioned = dashboard.state.meta.provisioned; + + const tabs = ( + <TabsBar> + <Tab label={'Details'} active={!showDiff} onChangeTab={() => model.setState({ showDiff: false })} /> + {diffCount > 0 && ( + <Tab + label={'Changes'} + active={showDiff} + onChangeTab={() => model.setState({ showDiff: true })} + counter={diffCount} + /> + )} + </TabsBar> + ); + + let title = 'Save dashboard'; + if (saveAsCopy) { + title = 'Save dashboard copy'; + } else if (isProvisioned) { + title = 'Provisioned dashboard'; + } + + const renderBody = () => { + if (showDiff) { + return <SaveDashboardDiff diff={diffs} oldValue={initialSaveModel} newValue={changedSaveModel} />; + } + + if (saveAsCopy || changeInfo.isNew) { + return <SaveDashboardAsForm dashboard={dashboard} changeInfo={changeInfo} drawer={model} />; + } + + if (isProvisioned) { + return <SaveProvisionedDashboardForm dashboard={dashboard} changeInfo={changeInfo} drawer={model} />; + } + + return <SaveDashboardForm dashboard={dashboard} changeInfo={changeInfo} drawer={model} />; + }; + + return ( + <Drawer title={title} subtitle={dashboard.state.title} onClose={model.onClose} tabs={tabs}> + {renderBody()} + </Drawer> + ); + }; +} diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx new file mode 100644 index 0000000000000..128d7f07a35a7 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx @@ -0,0 +1,160 @@ +import React, { useState } from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { Button, Checkbox, TextArea, Stack, Alert, Box, Field } from '@grafana/ui'; +import { SaveDashboardOptions } from 'app/features/dashboard/components/SaveDashboard/types'; + +import { DashboardScene } from '../scene/DashboardScene'; + +import { SaveDashboardDrawer } from './SaveDashboardDrawer'; +import { + DashboardChangeInfo, + NameAlreadyExistsError, + SaveButton, + isNameExistsError, + isPluginDashboardError, + isVersionMismatchError, +} from './shared'; +import { useSaveDashboard } from './useSaveDashboard'; + +export interface Props { + dashboard: DashboardScene; + drawer: SaveDashboardDrawer; + changeInfo: DashboardChangeInfo; +} + +export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) { + const { changedSaveModel, hasChanges } = changeInfo; + + const { state, onSaveDashboard } = useSaveDashboard(false); + const [options, setOptions] = useState<SaveDashboardOptions>({ + folderUid: dashboard.state.meta.folderUid, + }); + + const onSave = async (overwrite: boolean) => { + const result = await onSaveDashboard(dashboard, changedSaveModel, { ...options, overwrite }); + if (result.status === 'success') { + dashboard.closeModal(); + drawer.state.onSaveSuccess?.(); + } + }; + + const cancelButton = ( + <Button variant="secondary" onClick={() => dashboard.closeModal()} fill="outline"> + Cancel + </Button> + ); + + const saveButton = (overwrite: boolean) => ( + <SaveButton isValid={hasChanges} isLoading={state.loading} onSave={onSave} overwrite={overwrite} /> + ); + + function renderFooter(error?: Error) { + if (isVersionMismatchError(error)) { + return ( + <Alert title="Someone else has updated this dashboard" severity="error"> + <p>Would you still like to save this dashboard?</p> + <Box paddingTop={2}> + <Stack alignItems="center"> + {cancelButton} + {saveButton(true)} + </Stack> + </Box> + </Alert> + ); + } + + if (isNameExistsError(error)) { + return <NameAlreadyExistsError cancelButton={cancelButton} saveButton={saveButton} />; + } + + if (isPluginDashboardError(error)) { + return ( + <Alert title="Plugin dashboard" severity="error"> + <p> + Your changes will be lost when you update the plugin. Use <strong>Save As</strong> to create custom version. + </p> + <Box paddingTop={2}> + <Stack alignItems="center"> + {cancelButton} + {saveButton(true)} + </Stack> + </Box> + </Alert> + ); + } + + return ( + <> + {error && ( + <Alert title="Failed to save dashboard" severity="error"> + <p>{error.message}</p> + </Alert> + )} + <Stack alignItems="center"> + {cancelButton} + {saveButton(false)} + {!hasChanges && <div>No changes to save</div>} + </Stack> + </> + ); + } + + return ( + <Stack gap={0} direction="column"> + <SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} /> + <Field label="Message"> + {/* config.featureToggles.dashgpt * TOOD GenAIDashboardChangesButton */} + <TextArea + aria-label="message" + value={options.message ?? ''} + onChange={(e) => { + setOptions({ + ...options, + message: e.currentTarget.value, + }); + }} + placeholder="Add a note to describe your changes (optional)." + autoFocus + rows={5} + /> + </Field> + <Box paddingTop={2}>{renderFooter(state.error)}</Box> + </Stack> + ); +} + +export interface SaveDashboardFormCommonOptionsProps { + drawer: SaveDashboardDrawer; + changeInfo: DashboardChangeInfo; +} + +export function SaveDashboardFormCommonOptions({ drawer, changeInfo }: SaveDashboardFormCommonOptionsProps) { + const { saveVariables = false, saveTimeRange = false } = drawer.useState(); + const { hasTimeChanges, hasVariableValueChanges } = changeInfo; + + return ( + <> + {hasTimeChanges && ( + <Field label="Update default time range" description="Will make current time range the new default"> + <Checkbox + id="save-timerange" + checked={saveTimeRange} + onChange={drawer.onToggleSaveTimeRange} + aria-label={selectors.pages.SaveDashboardModal.saveTimerange} + /> + </Field> + )} + {hasVariableValueChanges && ( + <Field label="Update default variable values" description="Will make the current values the new default"> + <Checkbox + id="save-variables" + checked={saveVariables} + onChange={drawer.onToggleSaveVariables} + aria-label={selectors.pages.SaveDashboardModal.saveVariables} + /> + </Field> + )} + </> + ); +} diff --git a/public/app/features/dashboard-scene/saving/SaveProvisionedDashboardForm.tsx b/public/app/features/dashboard-scene/saving/SaveProvisionedDashboardForm.tsx new file mode 100644 index 0000000000000..ded8752360007 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/SaveProvisionedDashboardForm.tsx @@ -0,0 +1,97 @@ +import { css } from '@emotion/css'; +import { saveAs } from 'file-saver'; +import React, { useCallback, useMemo } from 'react'; +import AutoSizer from 'react-virtualized-auto-sizer'; + +import { Button, ClipboardButton, Stack, CodeEditor, Box } from '@grafana/ui'; + +import { DashboardScene } from '../scene/DashboardScene'; + +import { SaveDashboardDrawer } from './SaveDashboardDrawer'; +import { SaveDashboardFormCommonOptions } from './SaveDashboardForm'; +import { DashboardChangeInfo } from './shared'; + +export interface Props { + dashboard: DashboardScene; + drawer: SaveDashboardDrawer; + changeInfo: DashboardChangeInfo; +} + +export function SaveProvisionedDashboardForm({ dashboard, drawer, changeInfo }: Props) { + const dashboardJSON = useMemo(() => JSON.stringify(changeInfo.changedSaveModel, null, 2), [changeInfo]); + + const saveToFile = useCallback(() => { + const blob = new Blob([dashboardJSON], { + type: 'application/json;charset=utf-8', + }); + saveAs(blob, changeInfo.changedSaveModel.title + '-' + new Date().getTime() + '.json'); + }, [changeInfo.changedSaveModel, dashboardJSON]); + + return ( + <div className={styles.container}> + <Stack direction="column" gap={2} grow={1}> + <div> + This dashboard cannot be saved from the Grafana UI because it has been provisioned from another source. Copy + the JSON or save it to a file below, then you can update your dashboard in the provisioning source. + <br /> + <i> + See{' '} + <a + className="external-link" + href="https://grafana.com/docs/grafana/latest/administration/provisioning/#dashboards" + target="_blank" + rel="noreferrer" + > + documentation + </a>{' '} + for more information about provisioning. + </i> + <br /> <br /> + <strong>File path: </strong> {dashboard.state.meta.provisionedExternalId} + </div> + <Stack direction="column" gap={0}> + <SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} /> + </Stack> + <div className={styles.json}> + <AutoSizer disableWidth> + {({ height }) => ( + <CodeEditor + width="100%" + height={height} + language="json" + showLineNumbers={true} + showMiniMap={dashboardJSON.length > 100} + value={dashboardJSON} + readOnly={true} + /> + )} + </AutoSizer> + </div> + <Box paddingTop={2}> + <Stack gap={2}> + <Button variant="secondary" onClick={drawer.onClose} fill="outline"> + Cancel + </Button> + <ClipboardButton icon="copy" getText={() => dashboardJSON}> + Copy JSON to clipboard + </ClipboardButton> + <Button type="submit" onClick={saveToFile}> + Save JSON to file + </Button> + </Stack> + </Box> + </Stack> + </div> + ); +} + +const styles = { + container: css({ + height: '100%', + display: 'flex', + }), + json: css({ + flexGrow: 1, + maxHeight: '800px', + }), +}; diff --git a/public/app/features/dashboard-scene/saving/__mocks__/createDetectChangesWorker.ts b/public/app/features/dashboard-scene/saving/__mocks__/createDetectChangesWorker.ts new file mode 100644 index 0000000000000..746a759c558d1 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/__mocks__/createDetectChangesWorker.ts @@ -0,0 +1,19 @@ +const worker = { + postMessage: jest.fn(), + onmessage: jest.fn(), + terminate: jest.fn(), +}; + +jest.mocked(worker.postMessage).mockImplementation(() => { + worker.onmessage?.({ + data: { + hasChanges: true, + hasTimeChanges: true, + hasVariableValueChanges: true, + }, + } as unknown as MessageEvent); +}); + +const createWorker = () => worker; + +export { createWorker }; diff --git a/public/app/features/dashboard-scene/saving/createDetectChangesWorker.ts b/public/app/features/dashboard-scene/saving/createDetectChangesWorker.ts new file mode 100644 index 0000000000000..f2a835fbf5ec7 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/createDetectChangesWorker.ts @@ -0,0 +1,3 @@ +import { CorsWorker as Worker } from 'app/core/utils/CorsWorker'; + +export const createWorker = () => new Worker(new URL('./DetectChangesWorker.ts', import.meta.url)); diff --git a/public/app/features/dashboard-scene/saving/getDashboardChanges.ts b/public/app/features/dashboard-scene/saving/getDashboardChanges.ts new file mode 100644 index 0000000000000..c9d2dc7e4b73b --- /dev/null +++ b/public/app/features/dashboard-scene/saving/getDashboardChanges.ts @@ -0,0 +1,145 @@ +import { compare, Operation } from 'fast-json-patch'; +// @ts-ignore +import jsonMap from 'json-source-map'; +import { flow, get, isEqual, sortBy, tail } from 'lodash'; + +import { AdHocVariableModel, TypedVariableModel } from '@grafana/data'; +import { Dashboard } from '@grafana/schema'; + +export function getDashboardChanges( + initial: Dashboard, + changed: Dashboard, + saveTimeRange?: boolean, + saveVariables?: boolean +) { + const initialSaveModel = initial; + const changedSaveModel = changed; + const hasTimeChanged = getHasTimeChanged(changedSaveModel, initialSaveModel); + const hasVariableValueChanges = applyVariableChanges(changedSaveModel, initialSaveModel, saveVariables); + + if (!saveTimeRange) { + changedSaveModel.time = initialSaveModel.time; + } + + const diff = jsonDiff(initialSaveModel, changedSaveModel); + + let diffCount = 0; + for (const d of Object.values(diff)) { + diffCount += d.length; + } + return { + changedSaveModel, + initialSaveModel, + diffs: diff, + diffCount, + hasChanges: diffCount > 0, + hasTimeChanges: hasTimeChanged, + isNew: changedSaveModel.version === 0, + hasVariableValueChanges, + }; +} + +export function getHasTimeChanged(saveModel: Dashboard, originalSaveModel: Dashboard) { + return saveModel.time?.from !== originalSaveModel.time?.from || saveModel.time?.to !== originalSaveModel.time?.to; +} + +export function applyVariableChanges(saveModel: Dashboard, originalSaveModel: Dashboard, saveVariables?: boolean) { + const originalVariables = originalSaveModel.templating?.list ?? []; + const variablesToSave = saveModel.templating?.list ?? []; + let hasVariableValueChanges = false; + + for (const variable of variablesToSave) { + const original = originalVariables.find(({ name, type }) => name === variable.name && type === variable.type); + + if (!original) { + continue; + } + + // Old schema property that never should be in persisted model + if (original.current && Object.hasOwn(original.current, 'selected')) { + delete original.current.selected; + } + + if (!isEqual(variable.current, original.current)) { + hasVariableValueChanges = true; + } + + if (!saveVariables) { + const typed = variable as TypedVariableModel; + if (typed.type === 'adhoc') { + typed.filters = (original as AdHocVariableModel).filters; + } else { + variable.current = original.current; + variable.options = original.options; + } + } + } + + return hasVariableValueChanges; +} + +export type Diff = { + op: 'add' | 'replace' | 'remove' | 'copy' | 'test' | '_get' | 'move'; + value: unknown; + originalValue: unknown; + path: string[]; + startLineNumber: number; +}; + +export type Diffs = { + [key: string]: Diff[]; +}; + +export type JSONValue = string | Dashboard; + +export const jsonDiff = (lhs: JSONValue, rhs: JSONValue): Diffs => { + const diffs = compare(lhs, rhs); + const lhsMap = jsonMap.stringify(lhs, null, 2); + const rhsMap = jsonMap.stringify(rhs, null, 2); + + const getDiffInformation = (diffs: Operation[]): Diff[] => { + return diffs.map((diff) => { + let originalValue = undefined; + let value = undefined; + let startLineNumber = 0; + + const path = tail(diff.path.split('/')); + + if (diff.op === 'replace' && rhsMap.pointers[diff.path]) { + originalValue = get(lhs, path); + value = diff.value; + startLineNumber = rhsMap.pointers[diff.path].value.line; + } + if (diff.op === 'add' && rhsMap.pointers[diff.path]) { + value = diff.value; + startLineNumber = rhsMap.pointers[diff.path].value.line; + } + if (diff.op === 'remove' && lhsMap.pointers[diff.path]) { + originalValue = get(lhs, path); + startLineNumber = lhsMap.pointers[diff.path].value.line; + } + + return { + op: diff.op, + value, + path, + originalValue, + startLineNumber, + }; + }); + }; + + const sortByLineNumber = (diffs: Diff[]) => sortBy(diffs, 'startLineNumber'); + const groupByPath = (diffs: Diff[]) => + diffs.reduce<Record<string, Diff[]>>((acc, value) => { + const groupKey: string = value.path[0]; + if (!acc[groupKey]) { + acc[groupKey] = []; + } + acc[groupKey].push(value); + return acc; + }, {}); + + // return 1; + return flow([getDiffInformation, sortByLineNumber, groupByPath])(diffs); +}; diff --git a/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts b/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts new file mode 100644 index 0000000000000..b1d1b62d6b1cb --- /dev/null +++ b/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts @@ -0,0 +1,122 @@ +import { MultiValueVariable, sceneGraph } from '@grafana/scenes'; + +import { buildPanelEditScene } from '../panel-edit/PanelEditor'; +import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; +import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; +import { findVizPanelByKey } from '../utils/utils'; + +import { getDashboardChangesFromScene } from './getDashboardChangesFromScene'; + +describe('getDashboardChangesFromScene', () => { + it('Can detect no changes', () => { + const dashboard = setup(); + const result = getDashboardChangesFromScene(dashboard, false); + expect(result.hasChanges).toBe(false); + expect(result.diffCount).toBe(0); + }); + + it('Can detect time changed', () => { + const dashboard = setup(); + + sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' }); + + const result = getDashboardChangesFromScene(dashboard, false); + expect(result.hasChanges).toBe(false); + expect(result.diffCount).toBe(0); + expect(result.hasTimeChanges).toBe(true); + }); + + it('Can save time change', () => { + const dashboard = setup(); + + sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' }); + + const result = getDashboardChangesFromScene(dashboard, true); + expect(result.hasChanges).toBe(true); + expect(result.diffCount).toBe(1); + }); + + it('Can detect variable change', () => { + const dashboard = setup(); + + const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable; + appVar.changeValueTo('app2'); + + const result = getDashboardChangesFromScene(dashboard, false, false); + + expect(result.hasVariableValueChanges).toBe(true); + expect(result.hasChanges).toBe(false); + expect(result.diffCount).toBe(0); + }); + + it('Can save variable value change', () => { + const dashboard = setup(); + + const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable; + appVar.changeValueTo('app2'); + + const result = getDashboardChangesFromScene(dashboard, false, true); + + expect(result.hasVariableValueChanges).toBe(true); + expect(result.hasChanges).toBe(true); + expect(result.diffCount).toBe(2); + }); + + describe('Saving from panel edit', () => { + it('Should commit panel edit changes', () => { + const dashboard = setup(); + const panel = findVizPanelByKey(dashboard, 'panel-1')!; + const editScene = buildPanelEditScene(panel); + + dashboard.onEnterEditMode(); + dashboard.setState({ editPanel: editScene }); + + editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); + editScene.commitChanges(); + + const result = getDashboardChangesFromScene(dashboard, false, true); + const panelSaveModel = result.changedSaveModel.panels![0]; + expect(panelSaveModel.title).toBe('changed title'); + }); + }); +}); + +interface ScenarioOptions { + fromPanelEdit?: boolean; +} + +function setup(options: ScenarioOptions = {}) { + const dashboard = transformSaveModelToScene({ + dashboard: { + title: 'hello', + uid: 'my-uid', + schemaVersion: 30, + panels: [ + { + id: 1, + title: 'Panel 1', + type: 'text', + }, + ], + version: 10, + templating: { + list: [ + { + name: 'app', + type: 'custom', + current: { + text: 'app1', + value: 'app1', + }, + }, + ], + }, + }, + meta: {}, + }); + + const initialSaveModel = transformSceneToSaveModel(dashboard); + dashboard.setInitialSaveModel(initialSaveModel); + + return dashboard; +} diff --git a/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.ts b/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.ts new file mode 100644 index 0000000000000..4859fe77f4b3c --- /dev/null +++ b/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.ts @@ -0,0 +1,14 @@ +import { DashboardScene } from '../scene/DashboardScene'; +import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; + +import { getDashboardChanges } from './getDashboardChanges'; + +export function getDashboardChangesFromScene(scene: DashboardScene, saveTimeRange?: boolean, saveVariables?: boolean) { + const changeInfo = getDashboardChanges( + scene.getInitialSaveModel()!, + transformSceneToSaveModel(scene), + saveTimeRange, + saveVariables + ); + return changeInfo; +} diff --git a/public/app/features/dashboard-scene/saving/getSaveDashboardChange.ts b/public/app/features/dashboard-scene/saving/getSaveDashboardChange.ts new file mode 100644 index 0000000000000..f9f9ab943cbf0 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/getSaveDashboardChange.ts @@ -0,0 +1,88 @@ +import { isEqual } from 'lodash'; + +import { AdHocVariableModel, TypedVariableModel } from '@grafana/data'; +import { Dashboard } from '@grafana/schema'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; +import { jsonDiff } from '../settings/version-history/utils'; + +import { DashboardChangeInfo } from './shared'; + +export function getSaveDashboardChange( + dashboard: DashboardScene, + saveTimeRange?: boolean, + saveVariables?: boolean +): DashboardChangeInfo { + const initialSaveModel = dashboard.getInitialSaveModel()!; + + if (dashboard.state.editPanel) { + dashboard.state.editPanel.commitChanges(); + } + + const changedSaveModel = transformSceneToSaveModel(dashboard); + const hasTimeChanged = getHasTimeChanged(changedSaveModel, initialSaveModel); + + const hasVariableValueChanges = applyVariableChanges(changedSaveModel, initialSaveModel, saveVariables); + + if (!saveTimeRange) { + changedSaveModel.time = initialSaveModel.time; + } + + const diff = jsonDiff(initialSaveModel, changedSaveModel); + + let diffCount = 0; + for (const d of Object.values(diff)) { + diffCount += d.length; + } + + return { + changedSaveModel, + initialSaveModel, + diffs: diff, + diffCount, + hasChanges: diffCount > 0, + hasTimeChanges: hasTimeChanged, + isNew: changedSaveModel.version === 0, + hasVariableValueChanges, + }; +} + +export function getHasTimeChanged(saveModel: Dashboard, originalSaveModel: Dashboard) { + return saveModel.time?.from !== originalSaveModel.time?.from || saveModel.time?.to !== originalSaveModel.time?.to; +} + +export function applyVariableChanges(saveModel: Dashboard, originalSaveModel: Dashboard, saveVariables?: boolean) { + const originalVariables = originalSaveModel.templating?.list ?? []; + const variablesToSave = saveModel.templating?.list ?? []; + let hasVariableValueChanges = false; + + for (const variable of variablesToSave) { + const original = originalVariables.find(({ name, type }) => name === variable.name && type === variable.type); + + if (!original) { + continue; + } + + // Old schema property that never should be in persisted model + if (original.current && Object.hasOwn(original.current, 'selected')) { + delete original.current.selected; + } + + if (!isEqual(variable.current, original.current)) { + hasVariableValueChanges = true; + } + + if (!saveVariables) { + const typed = variable as TypedVariableModel; + if (typed.type === 'adhoc') { + typed.filters = (original as AdHocVariableModel).filters; + } else { + variable.current = original.current; + variable.options = original.options; + } + } + } + + return hasVariableValueChanges; +} diff --git a/public/app/features/dashboard-scene/saving/shared.tsx b/public/app/features/dashboard-scene/saving/shared.tsx new file mode 100644 index 0000000000000..ad52947cb4413 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/shared.tsx @@ -0,0 +1,73 @@ +import React from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { isFetchError } from '@grafana/runtime'; +import { Dashboard } from '@grafana/schema'; +import { Alert, Box, Button, Stack } from '@grafana/ui'; + +import { Diffs } from '../settings/version-history/utils'; + +export interface DashboardChangeInfo { + changedSaveModel: Dashboard; + initialSaveModel: Dashboard; + diffs: Diffs; + diffCount: number; + hasChanges: boolean; + hasTimeChanges: boolean; + hasVariableValueChanges: boolean; + isNew?: boolean; +} + +export function isVersionMismatchError(error?: Error) { + return isFetchError(error) && error.data && error.data.status === 'version-mismatch'; +} + +export function isNameExistsError(error?: Error) { + return isFetchError(error) && error.data && error.data.status === 'name-exists'; +} + +export function isPluginDashboardError(error?: Error) { + return isFetchError(error) && error.data && error.data.status === 'plugin-dashboard'; +} + +export interface NameAlreadyExistsErrorProps { + cancelButton: React.ReactNode; + saveButton: (overwrite: boolean) => React.ReactNode; +} + +export function NameAlreadyExistsError({ cancelButton, saveButton }: NameAlreadyExistsErrorProps) { + return ( + <Alert title="Name already exists" severity="error"> + <p> + A dashboard with the same name in selected folder already exists. Would you still like to save this dashboard? + </p> + <Box paddingTop={2}> + <Stack alignItems="center"> + {cancelButton} + {saveButton(true)} + </Stack> + </Box> + </Alert> + ); +} + +export interface SaveButtonProps { + overwrite: boolean; + onSave: (overwrite: boolean) => void; + isLoading: boolean; + isValid?: boolean; +} + +export function SaveButton({ overwrite, isLoading, isValid, onSave }: SaveButtonProps) { + return ( + <Button + disabled={!isValid || isLoading} + icon={isLoading ? 'spinner' : undefined} + aria-label={selectors.pages.SaveDashboardModal.save} + onClick={() => onSave(overwrite)} + variant={overwrite ? 'destructive' : 'primary'} + > + {isLoading ? 'Saving...' : overwrite ? 'Save and overwrite' : 'Save'} + </Button> + ); +} diff --git a/public/app/features/dashboard-scene/saving/useSaveDashboard.ts b/public/app/features/dashboard-scene/saving/useSaveDashboard.ts new file mode 100644 index 0000000000000..c961e4a44db82 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/useSaveDashboard.ts @@ -0,0 +1,88 @@ +import { useAsyncFn } from 'react-use'; + +import { locationUtil } from '@grafana/data'; +import { locationService, reportInteraction } from '@grafana/runtime'; +import { Dashboard } from '@grafana/schema'; +import appEvents from 'app/core/app_events'; +import { useAppNotification } from 'app/core/copy/appNotification'; +import { updateDashboardName } from 'app/core/reducers/navBarTree'; +import { useSaveDashboardMutation } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; +import { SaveDashboardOptions } from 'app/features/dashboard/components/SaveDashboard/types'; +import { useDispatch } from 'app/types'; +import { DashboardSavedEvent } from 'app/types/events'; + +import { updateDashboardUidLastUsedDatasource } from '../../dashboard/utils/dashboard'; +import { DashboardScene } from '../scene/DashboardScene'; + +export function useSaveDashboard(isCopy = false) { + const dispatch = useDispatch(); + const notifyApp = useAppNotification(); + const [saveDashboardRtkQuery] = useSaveDashboardMutation(); + + const [state, onSaveDashboard] = useAsyncFn( + async (scene: DashboardScene, saveModel: Dashboard, options: SaveDashboardOptions) => { + { + const result = await saveDashboardRtkQuery({ + dashboard: saveModel, + folderUid: options.folderUid, + message: options.message, + overwrite: options.overwrite, + showErrorAlert: false, + }); + + if ('error' in result) { + throw result.error; + } + + const resultData = result.data; + scene.saveCompleted(saveModel, resultData, options.folderUid); + + // important that these happen before location redirect below + appEvents.publish(new DashboardSavedEvent()); + notifyApp.success('Dashboard saved'); + + //Update local storage dashboard to handle things like last used datasource + updateDashboardUidLastUsedDatasource(resultData.uid); + + if (isCopy) { + reportInteraction('grafana_dashboard_copied', { + name: saveModel.title, + url: resultData.url, + }); + } else { + reportInteraction(`grafana_dashboard_${resultData.uid ? 'saved' : 'created'}`, { + name: saveModel.title, + url: resultData.url, + }); + } + + const currentLocation = locationService.getLocation(); + const newUrl = locationUtil.stripBaseFromUrl(resultData.url); + + if (newUrl !== currentLocation.pathname) { + setTimeout(() => { + // Because the path changes we need to stop and restart url sync + scene.stopUrlSync(); + locationService.push({ pathname: newUrl, search: currentLocation.search }); + scene.startUrlSync(); + }); + } + + if (scene.state.meta.isStarred) { + dispatch( + updateDashboardName({ + id: resultData.uid, + title: scene.state.title, + url: newUrl, + }) + ); + } + + return result.data; + } + }, + [dispatch, notifyApp] + ); + + return { state, onSaveDashboard }; +} diff --git a/public/app/features/dashboard-scene/scene/AddLibraryPanelWidget.test.tsx b/public/app/features/dashboard-scene/scene/AddLibraryPanelWidget.test.tsx new file mode 100644 index 0000000000000..b982e2813ce32 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/AddLibraryPanelWidget.test.tsx @@ -0,0 +1,252 @@ +import { SceneGridItem, SceneGridLayout, SceneGridRow, SceneTimeRange } from '@grafana/scenes'; +import { LibraryPanel } from '@grafana/schema/dist/esm/index.gen'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { activateFullSceneTree } from '../utils/test-utils'; + +import { AddLibraryPanelWidget } from './AddLibraryPanelWidget'; +import { LibraryVizPanel } from './LibraryVizPanel'; + +describe('AddLibraryPanelWidget', () => { + let dashboard: DashboardScene; + let addLibPanelWidget: AddLibraryPanelWidget; + const mockEvent = { + preventDefault: jest.fn(), + } as unknown as React.MouseEvent<HTMLButtonElement>; + + beforeEach(async () => { + const result = await buildTestScene(); + dashboard = result.dashboard; + addLibPanelWidget = result.addLibPanelWidget; + }); + + it('should return the dashboard', () => { + expect(addLibPanelWidget.getDashboard()).toBe(dashboard); + }); + + it('should cancel adding a lib panel', () => { + addLibPanelWidget.onCancelAddPanel(mockEvent); + + const body = dashboard.state.body as SceneGridLayout; + + expect(body.state.children.length).toBe(0); + }); + + it('should cancel lib panel at correct position', () => { + const anotherLibPanelWidget = new AddLibraryPanelWidget({ key: 'panel-2' }); + const body = dashboard.state.body as SceneGridLayout; + + body.setState({ + children: [ + ...body.state.children, + new SceneGridItem({ + key: 'griditem-2', + x: 0, + y: 0, + width: 10, + height: 12, + body: anotherLibPanelWidget, + }), + ], + }); + dashboard.setState({ body }); + + anotherLibPanelWidget.onCancelAddPanel(mockEvent); + + const gridItem = body.state.children[0] as SceneGridItem; + + expect(body.state.children.length).toBe(1); + expect(gridItem.state.body!.state.key).toBe(addLibPanelWidget.state.key); + }); + + it('should cancel lib panel inside a row child', () => { + const anotherLibPanelWidget = new AddLibraryPanelWidget({ key: 'panel-2' }); + dashboard.setState({ + body: new SceneGridLayout({ + children: [ + new SceneGridRow({ + key: 'panel-2', + children: [ + new SceneGridItem({ + key: 'griditem-2', + x: 0, + y: 0, + width: 10, + height: 12, + body: anotherLibPanelWidget, + }), + ], + }), + ], + }), + }); + + const body = dashboard.state.body as SceneGridLayout; + + anotherLibPanelWidget.onCancelAddPanel(mockEvent); + + const gridRow = body.state.children[0] as SceneGridRow; + + expect(body.state.children.length).toBe(1); + expect(gridRow.state.children.length).toBe(0); + }); + + it('should add library panel from menu', () => { + const panelInfo: LibraryPanel = { + uid: 'uid', + model: { + type: 'timeseries', + }, + name: 'name', + version: 1, + type: 'timeseries', + }; + + const body = dashboard.state.body as SceneGridLayout; + const gridItem = body.state.children[0] as SceneGridItem; + + expect(gridItem.state.body!).toBeInstanceOf(AddLibraryPanelWidget); + + addLibPanelWidget.onAddLibraryPanel(panelInfo); + + expect(body.state.children.length).toBe(1); + expect(gridItem.state.body!).toBeInstanceOf(LibraryVizPanel); + expect((gridItem.state.body! as LibraryVizPanel).state.panelKey).toBe(addLibPanelWidget.state.key); + }); + + it('should add a lib panel at correct position', () => { + const anotherLibPanelWidget = new AddLibraryPanelWidget({ key: 'panel-2' }); + const body = dashboard.state.body as SceneGridLayout; + + body.setState({ + children: [ + ...body.state.children, + new SceneGridItem({ + key: 'griditem-2', + x: 0, + y: 0, + width: 10, + height: 12, + body: anotherLibPanelWidget, + }), + ], + }); + dashboard.setState({ body }); + + const panelInfo: LibraryPanel = { + uid: 'uid', + model: { + type: 'timeseries', + }, + name: 'name', + version: 1, + type: 'timeseries', + }; + + anotherLibPanelWidget.onAddLibraryPanel(panelInfo); + + const gridItemOne = body.state.children[0] as SceneGridItem; + const gridItemTwo = body.state.children[1] as SceneGridItem; + + expect(body.state.children.length).toBe(2); + expect(gridItemOne.state.body!).toBeInstanceOf(AddLibraryPanelWidget); + expect((gridItemTwo.state.body! as LibraryVizPanel).state.panelKey).toBe(anotherLibPanelWidget.state.key); + }); + + it('should add library panel from menu to a row child', () => { + const anotherLibPanelWidget = new AddLibraryPanelWidget({ key: 'panel-2' }); + dashboard.setState({ + body: new SceneGridLayout({ + children: [ + new SceneGridRow({ + key: 'panel-2', + children: [ + new SceneGridItem({ + key: 'griditem-2', + x: 0, + y: 0, + width: 10, + height: 12, + body: anotherLibPanelWidget, + }), + ], + }), + ], + }), + }); + + const panelInfo: LibraryPanel = { + uid: 'uid', + model: { + type: 'timeseries', + }, + name: 'name', + version: 1, + type: 'timeseries', + }; + + const body = dashboard.state.body as SceneGridLayout; + + anotherLibPanelWidget.onAddLibraryPanel(panelInfo); + + const gridRow = body.state.children[0] as SceneGridRow; + const gridItem = gridRow.state.children[0] as SceneGridItem; + + expect(body.state.children.length).toBe(1); + expect(gridItem.state.body!).toBeInstanceOf(LibraryVizPanel); + expect((gridItem.state.body! as LibraryVizPanel).state.panelKey).toBe(anotherLibPanelWidget.state.key); + }); + + it('should throw error if adding lib panel in a layout that is not SceneGridLayout', () => { + dashboard.setState({ + body: undefined, + }); + + expect(() => addLibPanelWidget.onAddLibraryPanel({} as LibraryPanel)).toThrow( + 'Trying to add a library panel in a layout that is not SceneGridLayout' + ); + }); + + it('should throw error if removing the library panel widget in a layout that is not SceneGridLayout', () => { + dashboard.setState({ + body: undefined, + }); + + expect(() => addLibPanelWidget.onCancelAddPanel(mockEvent)).toThrow( + 'Trying to remove the library panel widget in a layout that is not SceneGridLayout' + ); + }); +}); + +async function buildTestScene() { + const addLibPanelWidget = new AddLibraryPanelWidget({ key: 'panel-1' }); + const dashboard = new DashboardScene({ + $timeRange: new SceneTimeRange({}), + title: 'hello', + uid: 'dash-1', + version: 4, + meta: { + canEdit: true, + }, + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + y: 0, + width: 10, + height: 12, + body: addLibPanelWidget, + }), + ], + }), + }); + + activateFullSceneTree(dashboard); + + await new Promise((r) => setTimeout(r, 1)); + + dashboard.onEnterEditMode(); + + return { dashboard, addLibPanelWidget }; +} diff --git a/public/app/features/dashboard-scene/scene/AddLibraryPanelWidget.tsx b/public/app/features/dashboard-scene/scene/AddLibraryPanelWidget.tsx new file mode 100644 index 0000000000000..3ff23786d25c6 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/AddLibraryPanelWidget.tsx @@ -0,0 +1,169 @@ +import { css, cx, keyframes } from '@emotion/css'; +import React from 'react'; +import tinycolor from 'tinycolor2'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { + SceneComponentProps, + SceneGridItem, + SceneGridLayout, + SceneGridRow, + SceneObjectBase, + SceneObjectState, +} from '@grafana/scenes'; +import { LibraryPanel } from '@grafana/schema'; +import { IconButton, useStyles2 } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; +import { + LibraryPanelsSearch, + LibraryPanelsSearchVariant, +} from 'app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch'; + +import { getDashboardSceneFor } from '../utils/utils'; + +import { DashboardScene } from './DashboardScene'; +import { LibraryVizPanel } from './LibraryVizPanel'; + +export interface AddLibraryPanelWidgetState extends SceneObjectState { + key: string; +} + +export class AddLibraryPanelWidget extends SceneObjectBase<AddLibraryPanelWidgetState> { + public constructor(state: AddLibraryPanelWidgetState) { + super({ + ...state, + }); + } + + private get _dashboard(): DashboardScene { + return getDashboardSceneFor(this); + } + + public getDashboard(): DashboardScene { + return this._dashboard; + } + + public onCancelAddPanel = (evt: React.MouseEvent<HTMLButtonElement>) => { + evt.preventDefault(); + + if (!(this._dashboard.state.body instanceof SceneGridLayout)) { + throw new Error('Trying to remove the library panel widget in a layout that is not SceneGridLayout'); + } + + const sceneGridLayout = this._dashboard.state.body; + const children = []; + + for (const child of sceneGridLayout.state.children) { + if (child.state.key !== this.parent?.state.key) { + children.push(child); + } + + if (child instanceof SceneGridRow) { + const rowChildren = []; + + for (const rowChild of child.state.children) { + if (rowChild instanceof SceneGridItem && rowChild.state.key !== this.parent?.state.key) { + rowChildren.push(rowChild); + } + } + + child.setState({ children: rowChildren }); + } + } + + sceneGridLayout.setState({ children }); + }; + + public onAddLibraryPanel = (panelInfo: LibraryPanel) => { + if (!(this._dashboard.state.body instanceof SceneGridLayout)) { + throw new Error('Trying to add a library panel in a layout that is not SceneGridLayout'); + } + + const body = new LibraryVizPanel({ + title: 'Panel Title', + uid: panelInfo.uid, + name: panelInfo.name, + panelKey: this.state.key, + }); + + if (this.parent instanceof SceneGridItem) { + this.parent.setState({ body }); + } + }; + + static Component = ({ model }: SceneComponentProps<AddLibraryPanelWidget>) => { + const dashboard = model.getDashboard(); + const styles = useStyles2(getStyles); + + return ( + <div className={styles.wrapper}> + <div className={cx('panel-container', styles.callToAction)}> + <div className={cx(styles.headerRow, `grid-drag-handle-${dashboard.state.body.state.key}`)}> + <span> + <Trans i18nKey="library-panel.add-widget.title">Add panel from panel library</Trans> + </span> + <div className="flex-grow-1" /> + <IconButton + aria-label="Close 'Add Panel' widget" + name="times" + onClick={model.onCancelAddPanel} + tooltip="Close widget" + /> + </div> + <LibraryPanelsSearch + onClick={model.onAddLibraryPanel} + variant={LibraryPanelsSearchVariant.Tight} + showPanelFilter + /> + </div> + </div> + ); + }; +} + +const getStyles = (theme: GrafanaTheme2) => { + const pulsate = keyframes({ + '0%': { + boxShadow: `0 0 0 2px ${theme.colors.background.canvas}, 0 0 0px 4px ${theme.colors.primary.main}`, + }, + '50%': { + boxShadow: `0 0 0 2px ${theme.components.dashboard.background}, 0 0 0px 4px ${tinycolor(theme.colors.primary.main) + .darken(20) + .toHexString()}`, + }, + '100%': { + boxShadow: `0 0 0 2px ${theme.components.dashboard.background}, 0 0 0px 4px ${theme.colors.primary.main}`, + }, + }); + + return { + // wrapper is used to make sure box-shadow animation isn't cut off in dashboard page + wrapper: css({ + height: '100%', + paddingTop: `${theme.spacing(0.5)}`, + }), + headerRow: css({ + display: 'flex', + alignItems: 'center', + height: '38px', + flexShrink: 0, + width: '100%', + fontSize: theme.typography.fontSize, + fontWeight: theme.typography.fontWeightMedium, + paddingLeft: `${theme.spacing(1)}`, + transition: 'background-color 0.1s ease-in-out', + cursor: 'move', + + '&:hover': { + background: `${theme.colors.background.secondary}`, + }, + }), + callToAction: css({ + overflow: 'hidden', + outline: '2px dotted transparent', + outlineOffset: '2px', + boxShadow: '0 0 0 2px black, 0 0 0px 4px #1f60c4', + animation: `${pulsate} 2s ease infinite`, + }), + }; +}; diff --git a/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts b/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts index b7e3f768dacb5..ec64e60a499e7 100644 --- a/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts +++ b/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts @@ -1,4 +1,4 @@ -import { from, map, Unsubscribable, Observable } from 'rxjs'; +import { from, map, Unsubscribable } from 'rxjs'; import { AlertState, AlertStateInfo, DataTopic, LoadingState, toDataFrame } from '@grafana/data'; import { config, getBackendSrv } from '@grafana/runtime'; @@ -68,71 +68,58 @@ export class AlertStatesDataLayer return; } - let alerStatesExecution: Observable<AlertStateInfo[]> | undefined; - - if (this.isUsingLegacyAlerting()) { - alerStatesExecution = from( - getBackendSrv().get( - '/api/alerts/states-for-dashboard', - { - dashboardId: id, - }, - `dashboard-query-runner-alert-states-${id}` - ) - ).pipe(map((alertStates) => alertStates)); - } else { - alerStatesExecution = from( - getBackendSrv().get( - '/api/prometheus/grafana/api/v1/rules', - { - dashboard_uid: uid!, - }, - `dashboard-query-runner-unified-alert-states-${id}` - ) - ).pipe( - map((result: PromRulesResponse) => { - if (result.status === 'success') { - this.hasAlertRules = false; - const panelIdToAlertState: Record<number, AlertStateInfo> = {}; - - result.data.groups.forEach((group) => - group.rules.forEach((rule) => { - if (isAlertingRule(rule) && rule.annotations && rule.annotations[Annotation.panelID]) { - this.hasAlertRules = true; - const panelId = Number(rule.annotations[Annotation.panelID]); - const state = promAlertStateToAlertState(rule.state); - - // there can be multiple alerts per panel, so we make sure we get the most severe state: - // alerting > pending > ok - if (!panelIdToAlertState[panelId]) { - panelIdToAlertState[panelId] = { - state, - id: Object.keys(panelIdToAlertState).length, - panelId, - dashboardId: id!, - }; - } else if ( - state === AlertState.Alerting && - panelIdToAlertState[panelId].state !== AlertState.Alerting - ) { - panelIdToAlertState[panelId].state = AlertState.Alerting; - } else if ( - state === AlertState.Pending && - panelIdToAlertState[panelId].state !== AlertState.Alerting && - panelIdToAlertState[panelId].state !== AlertState.Pending - ) { - panelIdToAlertState[panelId].state = AlertState.Pending; - } + const alerStatesExecution = from( + getBackendSrv().get( + '/api/prometheus/grafana/api/v1/rules', + { + dashboard_uid: uid!, + }, + `dashboard-query-runner-unified-alert-states-${id}` + ) + ).pipe( + map((result: PromRulesResponse) => { + if (result.status === 'success') { + this.hasAlertRules = false; + const panelIdToAlertState: Record<number, AlertStateInfo> = {}; + + result.data.groups.forEach((group) => + group.rules.forEach((rule) => { + if (isAlertingRule(rule) && rule.annotations && rule.annotations[Annotation.panelID]) { + this.hasAlertRules = true; + const panelId = Number(rule.annotations[Annotation.panelID]); + const state = promAlertStateToAlertState(rule.state); + + // there can be multiple alerts per panel, so we make sure we get the most severe state: + // alerting > pending > ok + if (!panelIdToAlertState[panelId]) { + panelIdToAlertState[panelId] = { + state, + id: Object.keys(panelIdToAlertState).length, + panelId, + dashboardId: id!, + }; + } else if ( + state === AlertState.Alerting && + panelIdToAlertState[panelId].state !== AlertState.Alerting + ) { + panelIdToAlertState[panelId].state = AlertState.Alerting; + } else if ( + state === AlertState.Pending && + panelIdToAlertState[panelId].state !== AlertState.Alerting && + panelIdToAlertState[panelId].state !== AlertState.Pending + ) { + panelIdToAlertState[panelId].state = AlertState.Pending; } - }) - ); - return Object.values(panelIdToAlertState); - } - - throw new Error(`Unexpected alert rules response.`); - }) - ); - } + } + }) + ); + return Object.values(panelIdToAlertState); + } + + throw new Error(`Unexpected alert rules response.`); + }) + ); + this.querySub = alerStatesExecution.subscribe({ next: (stateUpdate) => { this.publishResults( @@ -165,50 +152,34 @@ export class AlertStatesDataLayer private canWork(timeRange: SceneTimeRangeLike): boolean { const dashboard = getDashboardSceneFor(this); - const { uid, id } = dashboard.state; + const { uid } = dashboard.state; - if (this.isUsingLegacyAlerting()) { - if (!id) { - return false; - } - - if (timeRange.state.value.raw.to !== 'now') { - return false; - } - - return true; - } else { - if (!uid) { - return false; - } - - // Cannot fetch rules while on a public dashboard since it's unauthenticated - if (config.publicDashboardAccessToken) { - return false; - } + if (!uid) { + return false; + } - if (timeRange.state.value.raw.to !== 'now') { - return false; - } + // Cannot fetch rules while on a public dashboard since it's unauthenticated + if (config.publicDashboardAccessToken) { + return false; + } - if (this.hasAlertRules === false) { - return false; - } + if (timeRange.state.value.raw.to !== 'now') { + return false; + } - const hasRuleReadPermission = - contextSrv.hasPermission(AccessControlAction.AlertingRuleRead) && - contextSrv.hasPermission(AccessControlAction.AlertingRuleExternalRead); + if (this.hasAlertRules === false) { + return false; + } - if (!hasRuleReadPermission) { - return false; - } + const hasRuleReadPermission = + contextSrv.hasPermission(AccessControlAction.AlertingRuleRead) && + contextSrv.hasPermission(AccessControlAction.AlertingRuleExternalRead); - return true; + if (!hasRuleReadPermission) { + return false; } - } - private isUsingLegacyAlerting(): boolean { - return !config.unifiedAlertingEnabled; + return true; } private handleError = (err: unknown) => { diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.tsx index 509e1bf65c7de..0ad76b3bff11e 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControls.tsx @@ -1,48 +1,91 @@ +import { css, cx } from '@emotion/css'; import React from 'react'; -import { SceneObjectState, SceneObject, SceneObjectBase, SceneComponentProps } from '@grafana/scenes'; -import { Box, Stack, ToolbarButton } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { + SceneObjectState, + SceneObject, + SceneObjectBase, + SceneComponentProps, + SceneTimePicker, + SceneRefreshPicker, + SceneDebugger, +} from '@grafana/scenes'; +import { Box, Stack, useStyles2 } from '@grafana/ui'; +import { PanelEditControls } from '../panel-edit/PanelEditControls'; import { getDashboardSceneFor } from '../utils/utils'; import { DashboardLinksControls } from './DashboardLinksControls'; interface DashboardControlsState extends SceneObjectState { variableControls: SceneObject[]; - timeControls: SceneObject[]; - linkControls: DashboardLinksControls; + timePicker: SceneTimePicker; + refreshPicker: SceneRefreshPicker; hideTimeControls?: boolean; } export class DashboardControls extends SceneObjectBase<DashboardControlsState> { static Component = DashboardControlsRenderer; + + public constructor(state: Partial<DashboardControlsState>) { + super({ + variableControls: [], + timePicker: state.timePicker ?? new SceneTimePicker({}), + refreshPicker: state.refreshPicker ?? new SceneRefreshPicker({}), + ...state, + }); + } } function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardControls>) { + const { variableControls, refreshPicker, timePicker, hideTimeControls } = model.useState(); const dashboard = getDashboardSceneFor(model); - const { variableControls, linkControls, timeControls, hideTimeControls } = model.useState(); - const { isEditing } = dashboard.useState(); + const { links, meta, editPanel } = dashboard.useState(); + const styles = useStyles2(getStyles); + const showDebugger = location.search.includes('scene-debugger'); return ( - <Stack - grow={1} - direction={{ - md: 'row', - xs: 'column', - }} - > + <div className={cx(styles.controls, meta.isEmbedded && styles.embedded)}> <Stack grow={1} wrap={'wrap'}> {variableControls.map((c) => ( <c.Component model={c} key={c.state.key} /> ))} <Box grow={1} /> - <linkControls.Component model={linkControls} /> - </Stack> - <Stack justifyContent={'flex-end'}> - {isEditing && ( - <ToolbarButton variant="canvas" icon="cog" tooltip="Dashboard settings" onClick={dashboard.onOpenSettings} /> - )} - {!hideTimeControls && timeControls.map((c) => <c.Component model={c} key={c.state.key} />)} + {!editPanel && <DashboardLinksControls links={links} uid={dashboard.state.uid} />} + {editPanel && <PanelEditControls panelEditor={editPanel} />} </Stack> - </Stack> + {!hideTimeControls && ( + <Stack justifyContent={'flex-end'}> + <timePicker.Component model={timePicker} /> + <refreshPicker.Component model={refreshPicker} /> + </Stack> + )} + {showDebugger && <SceneDebugger scene={model} key={'scene-debugger'} />} + </div> ); } + +function getStyles(theme: GrafanaTheme2) { + return { + controls: css({ + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(1), + position: 'sticky', + top: 0, + background: theme.colors.background.canvas, + zIndex: theme.zIndex.navbarFixed, + padding: theme.spacing(2, 0), + width: '100%', + marginLeft: 'auto', + [theme.breakpoints.down('sm')]: { + flexDirection: 'column-reverse', + alignItems: 'stretch', + }, + }), + embedded: css({ + background: 'unset', + position: 'unset', + }), + }; +} diff --git a/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx b/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx index fcffa81a1701e..a535b3517fef5 100644 --- a/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx @@ -2,27 +2,22 @@ import React from 'react'; import { sanitizeUrl } from '@grafana/data/src/text/sanitize'; import { selectors } from '@grafana/e2e-selectors'; -import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; import { DashboardLink } from '@grafana/schema'; import { Tooltip } from '@grafana/ui'; -import { linkIconMap } from 'app/features/dashboard/components/LinksSettings/LinkSettingsEdit'; import { DashboardLinkButton, DashboardLinksDashboard, } from 'app/features/dashboard/components/SubMenu/DashboardLinksDashboard'; import { getLinkSrv } from 'app/features/panel/panellinks/link_srv'; -import { getDashboardSceneFor } from '../utils/utils'; +import { LINK_ICON_MAP } from '../settings/links/utils'; -interface DashboardLinksControlsState extends SceneObjectState {} - -export class DashboardLinksControls extends SceneObjectBase<DashboardLinksControlsState> { - static Component = DashboardLinksControlsRenderer; +export interface Props { + links: DashboardLink[]; + uid?: string; } -function DashboardLinksControlsRenderer({ model }: SceneComponentProps<DashboardLinksControls>) { - const { links, uid } = getDashboardSceneFor(model).useState(); - +export function DashboardLinksControls({ links, uid }: Props) { if (!links || !uid) { return null; } @@ -37,7 +32,7 @@ function DashboardLinksControlsRenderer({ model }: SceneComponentProps<Dashboard return <DashboardLinksDashboard key={key} link={link} linkInfo={linkInfo} dashboardUID={uid} />; } - const icon = linkIconMap[link.icon]; + const icon = LINK_ICON_MAP[link.icon]; const linkElement = ( <DashboardLinkButton diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index 58132f4dc941a..f08e544e1a4a2 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -3,23 +3,72 @@ import { sceneGraph, SceneGridItem, SceneGridLayout, - SceneRefreshPicker, SceneTimeRange, SceneQueryRunner, SceneVariableSet, TestVariable, VizPanel, + SceneGridRow, + behaviors, } from '@grafana/scenes'; +import { Dashboard, DashboardCursorSync } from '@grafana/schema'; import appEvents from 'app/core/app_events'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { VariablesChanged } from 'app/features/variables/types'; +import { createWorker } from '../saving/createDetectChangesWorker'; +import { + buildGridItemForLibPanel, + buildGridItemForPanel, + transformSaveModelToScene, +} from '../serialization/transformSaveModelToScene'; +import { DecoratedRevisionModel } from '../settings/VersionsEditView'; +import { historySrv } from '../settings/version-history/HistorySrv'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { djb2Hash } from '../utils/djb2Hash'; import { DashboardControls } from './DashboardControls'; -import { DashboardLinksControls } from './DashboardLinksControls'; import { DashboardScene, DashboardSceneState } from './DashboardScene'; +import { LibraryVizPanel } from './LibraryVizPanel'; +import { PanelRepeaterGridItem } from './PanelRepeaterGridItem'; + +jest.mock('../settings/version-history/HistorySrv'); +jest.mock('../serialization/transformSaveModelToScene'); +jest.mock('../saving/getDashboardChangesFromScene', () => ({ + // It compares the initial and changed save models and returns the differences + // By default we assume there are differences to have the dirty state test logic tested + getDashboardChangesFromScene: jest.fn(() => ({ + changedSaveModel: {}, + initialSaveModel: {}, + diffs: [], + diffCount: 0, + hasChanges: true, + hasTimeChanges: false, + isNew: false, + hasVariableValueChanges: false, + })), +})); +jest.mock('../serialization/transformSceneToSaveModel'); +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => { + return { + getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds1' }), + }; + }, +})); + +jest.mock('app/features/playlist/PlaylistSrv', () => ({ + ...jest.requireActual('app/features/playlist/PlaylistSrv'), + playlistSrv: { + isPlaying: false, + next: jest.fn(), + prev: jest.fn(), + stop: jest.fn(), + }, +})); +const worker = createWorker(); +mockResultsOfDetectChangesWorker({ hasChanges: true, hasTimeChanges: false, hasVariableValueChanges: false }); describe('DashboardScene', () => { describe('DashboardSrv.getCurrent compatibility', () => { @@ -32,44 +81,110 @@ describe('DashboardScene', () => { }); describe('Editing and discarding', () => { + describe('Given scene in view mode', () => { + it('Should set isEditing to false', () => { + const scene = buildTestScene(); + scene.activate(); + + expect(scene.state.isEditing).toBeFalsy(); + }); + + it('Should not start the detect changes worker', () => { + const scene = buildTestScene(); + scene.activate(); + + // @ts-expect-error it is a private property + expect(scene._changesWorker).toBeUndefined(); + }); + }); + describe('Given scene in edit mode', () => { let scene: DashboardScene; + let deactivateScene: () => void; beforeEach(() => { scene = buildTestScene(); + deactivateScene = scene.activate(); scene.onEnterEditMode(); + jest.clearAllMocks(); }); it('Should set isEditing to true', () => { expect(scene.state.isEditing).toBe(true); }); + it('Exiting already saved dashboard should not restore initial state', () => { + scene.setState({ title: 'Updated title' }); + expect(scene.state.isDirty).toBe(true); + + scene.saveCompleted({} as Dashboard, { + id: 1, + slug: 'slug', + uid: 'dash-1', + url: 'sss', + version: 2, + status: 'aaa', + }); + + expect(scene.state.isDirty).toBe(false); + scene.exitEditMode({ skipConfirm: true }); + expect(scene.state.title).toEqual('Updated title'); + }); + + it('Should start the detect changes worker', () => { + expect(worker.onmessage).toBeDefined(); + }); + + it('Should terminate the detect changes worker when deactivate', () => { + expect(worker.terminate).toHaveBeenCalledTimes(0); + deactivateScene(); + expect(worker.terminate).toHaveBeenCalledTimes(1); + }); + it('A change to griditem pos should set isDirty true', () => { const gridItem = sceneGraph.findObject(scene, (p) => p.state.key === 'griditem-1') as SceneGridItem; gridItem.setState({ x: 10, y: 0, width: 10, height: 10 }); expect(scene.state.isDirty).toBe(true); - scene.onDiscard(); + scene.exitEditMode({ skipConfirm: true }); const gridItem2 = sceneGraph.findObject(scene, (p) => p.state.key === 'griditem-1') as SceneGridItem; expect(gridItem2.state.x).toBe(0); }); + it('A change to gridlayout children order should set isDirty true', () => { + const layout = sceneGraph.findObject(scene, (p) => p instanceof SceneGridLayout) as SceneGridLayout; + const originalPanelOrder = layout.state.children.map((c) => c.state.key); + + // Change the order of the children. This happen when panels move around, then the children are re-ordered + layout.setState({ + children: [layout.state.children[1], layout.state.children[0], layout.state.children[2]], + }); + + expect(scene.state.isDirty).toBe(true); + + scene.exitEditMode({ skipConfirm: true }); + const resoredLayout = sceneGraph.findObject(scene, (p) => p instanceof SceneGridLayout) as SceneGridLayout; + expect(resoredLayout.state.children.map((c) => c.state.key)).toEqual(originalPanelOrder); + }); + it.each` prop | value ${'title'} | ${'new title'} ${'description'} | ${'new description'} ${'tags'} | ${['tag3', 'tag4']} ${'editable'} | ${false} + ${'links'} | ${[]} + ${'meta'} | ${{ folderUid: 'new-folder-uid', folderTitle: 'new-folder-title', hasUnsavedFolderChange: true }} `( 'A change to $prop should set isDirty true', - ({ prop, value }: { prop: keyof DashboardSceneState; value: any }) => { + ({ prop, value }: { prop: keyof DashboardSceneState; value: unknown }) => { const prevState = scene.state[prop]; scene.setState({ [prop]: value }); expect(scene.state.isDirty).toBe(true); - scene.onDiscard(); + scene.exitEditMode({ skipConfirm: true }); expect(scene.state[prop]).toEqual(prevState); } ); @@ -81,19 +196,19 @@ describe('DashboardScene', () => { expect(scene.state.isDirty).toBe(true); - scene.onDiscard(); + scene.exitEditMode({ skipConfirm: true }); expect(dashboardSceneGraph.getRefreshPicker(scene)!.state.intervals).toEqual(prevState); }); it('A change to time picker visibility settings should set isDirty true', () => { - const dashboardControls = dashboardSceneGraph.getDashboardControls(scene)!; + const dashboardControls = scene.state.controls!; const prevState = dashboardControls.state.hideTimeControls; dashboardControls.setState({ hideTimeControls: true }); expect(scene.state.isDirty).toBe(true); - scene.onDiscard(); - expect(dashboardSceneGraph.getDashboardControls(scene)!.state.hideTimeControls).toEqual(prevState); + scene.exitEditMode({ skipConfirm: true }); + expect(scene.state.controls!.state.hideTimeControls).toEqual(prevState); }); it('A change to time zone should set isDirty true', () => { @@ -103,9 +218,492 @@ describe('DashboardScene', () => { expect(scene.state.isDirty).toBe(true); - scene.onDiscard(); + scene.exitEditMode({ skipConfirm: true }); expect(sceneGraph.getTimeRange(scene)!.state.timeZone).toBe(prevState); }); + + it('A change to a cursor sync config should set isDirty true', () => { + const cursorSync = dashboardSceneGraph.getCursorSync(scene)!; + const initialState = cursorSync.state; + + cursorSync.setState({ + sync: DashboardCursorSync.Tooltip, + }); + + expect(scene.state.isDirty).toBe(true); + + scene.exitEditMode({ skipConfirm: true }); + expect(dashboardSceneGraph.getCursorSync(scene)!.state).toEqual(initialState); + }); + + it.each([ + { hasChanges: true, hasTimeChanges: false, hasVariableValueChanges: false }, + { hasChanges: true, hasTimeChanges: true, hasVariableValueChanges: false }, + { hasChanges: true, hasTimeChanges: false, hasVariableValueChanges: true }, + ])('should set the state to true if there are changes detected in the saving model', (diffResults) => { + mockResultsOfDetectChangesWorker(diffResults); + scene.setState({ title: 'hello' }); + expect(scene.state.isDirty).toBeTruthy(); + }); + + it.each([ + { hasChanges: false, hasTimeChanges: false, hasVariableValueChanges: false }, + { hasChanges: false, hasTimeChanges: true, hasVariableValueChanges: false }, + { hasChanges: false, hasTimeChanges: false, hasVariableValueChanges: true }, + ])('should not set the state to true if there are no change detected in the dashboard', (diffResults) => { + mockResultsOfDetectChangesWorker(diffResults); + scene.setState({ title: 'hello' }); + expect(scene.state.isDirty).toBeFalsy(); + }); + + it('Should throw an error when adding a panel to a layout that is not SceneGridLayout', () => { + const scene = buildTestScene({ body: undefined }); + + expect(() => { + scene.addPanel(new VizPanel({ title: 'Panel Title', key: 'panel-4', pluginId: 'timeseries' })); + }).toThrow('Trying to add a panel in a layout that is not SceneGridLayout'); + }); + + it('Should add a new panel to the dashboard', () => { + const vizPanel = new VizPanel({ + title: 'Panel Title', + key: 'panel-5', + pluginId: 'timeseries', + $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), + }); + + scene.addPanel(vizPanel); + + const body = scene.state.body as SceneGridLayout; + const gridItem = body.state.children[0] as SceneGridItem; + + expect(body.state.children.length).toBe(6); + expect(gridItem.state.body!.state.key).toBe('panel-5'); + expect(gridItem.state.y).toBe(0); + }); + + it('Should create and add a new panel to the dashboard', () => { + scene.onCreateNewPanel(); + + const body = scene.state.body as SceneGridLayout; + const gridItem = body.state.children[0] as SceneGridItem; + + expect(body.state.children.length).toBe(6); + expect(gridItem.state.body!.state.key).toBe('panel-7'); + }); + + it('Should create and add a new row to the dashboard', () => { + scene.onCreateNewRow(); + + const body = scene.state.body as SceneGridLayout; + const gridRow = body.state.children[0] as SceneGridRow; + + expect(body.state.children.length).toBe(4); + expect(gridRow.state.key).toBe('panel-7'); + expect(gridRow.state.children[0].state.key).toBe('griditem-1'); + expect(gridRow.state.children[1].state.key).toBe('griditem-2'); + }); + + it('Should create a row and add all panels in the dashboard under it', () => { + const scene = buildTestScene({ + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + body: new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), + }), + }), + new SceneGridItem({ + key: 'griditem-2', + body: new VizPanel({ + title: 'Panel B', + key: 'panel-2', + pluginId: 'table', + }), + }), + ], + }), + }); + + scene.onCreateNewRow(); + + const body = scene.state.body as SceneGridLayout; + const gridRow = body.state.children[0] as SceneGridRow; + + expect(body.state.children.length).toBe(1); + expect(gridRow.state.children.length).toBe(2); + }); + + it('Should create and add two new rows, but the second has no children', () => { + scene.onCreateNewRow(); + scene.onCreateNewRow(); + + const body = scene.state.body as SceneGridLayout; + const gridRow = body.state.children[0] as SceneGridRow; + + expect(body.state.children.length).toBe(5); + expect(gridRow.state.children.length).toBe(0); + }); + + it('Should create an empty row when nothing else in dashboard', () => { + const scene = buildTestScene({ + body: new SceneGridLayout({ + children: [], + }), + }); + + scene.onCreateNewRow(); + + const body = scene.state.body as SceneGridLayout; + const gridRow = body.state.children[0] as SceneGridRow; + + expect(body.state.children.length).toBe(1); + expect(gridRow.state.children.length).toBe(0); + }); + + it('Should remove a row and move its children to the grid layout', () => { + const body = scene.state.body as SceneGridLayout; + const row = body.state.children[2] as SceneGridRow; + + scene.removeRow(row); + + const vizPanel = (body.state.children[2] as SceneGridItem).state.body as VizPanel; + + expect(body.state.children.length).toBe(6); + expect(vizPanel.state.key).toBe('panel-4'); + }); + + it('Should remove a row and its children', () => { + const body = scene.state.body as SceneGridLayout; + const row = body.state.children[2] as SceneGridRow; + + scene.removeRow(row, true); + + expect(body.state.children.length).toBe(4); + }); + + it('Should remove an empty row from the layout', () => { + const row = new SceneGridRow({ + key: 'panel-1', + }); + + const scene = buildTestScene({ + body: new SceneGridLayout({ + children: [row], + }), + }); + + const body = scene.state.body as SceneGridLayout; + + expect(body.state.children.length).toBe(1); + + scene.removeRow(row); + + expect(body.state.children.length).toBe(0); + }); + + it('Should fail to copy a panel if it does not have a grid item parent', () => { + const vizPanel = new VizPanel({ + title: 'Panel Title', + key: 'panel-5', + pluginId: 'timeseries', + }); + + scene.copyPanel(vizPanel); + + expect(scene.state.hasCopiedPanel).toBe(false); + }); + + it('Should fail to copy a library panel if it does not have a grid item parent', () => { + const libVizPanel = new LibraryVizPanel({ + uid: 'uid', + name: 'libraryPanel', + panelKey: 'panel-4', + title: 'Library Panel', + panel: new VizPanel({ + title: 'Library Panel', + key: 'panel-4', + pluginId: 'table', + }), + }); + + scene.copyPanel(libVizPanel.state.panel as VizPanel); + + expect(scene.state.hasCopiedPanel).toBe(false); + }); + + it('Should copy a panel', () => { + const vizPanel = ((scene.state.body as SceneGridLayout).state.children[0] as SceneGridItem).state.body; + scene.copyPanel(vizPanel as VizPanel); + + expect(scene.state.hasCopiedPanel).toBe(true); + }); + + it('Should copy a library viz panel', () => { + const libVizPanel = ((scene.state.body as SceneGridLayout).state.children[4] as SceneGridItem).state + .body as LibraryVizPanel; + + scene.copyPanel(libVizPanel.state.panel as VizPanel); + + expect(scene.state.hasCopiedPanel).toBe(true); + }); + + it('Should paste a panel', () => { + scene.setState({ hasCopiedPanel: true }); + jest.spyOn(JSON, 'parse').mockReturnThis(); + jest.mocked(buildGridItemForPanel).mockReturnValue( + new SceneGridItem({ + key: 'griditem-9', + body: new VizPanel({ + title: 'Panel A', + key: 'panel-9', + pluginId: 'table', + }), + }) + ); + + scene.pastePanel(); + + const body = scene.state.body as SceneGridLayout; + const gridItem = body.state.children[0] as SceneGridItem; + + expect(buildGridItemForPanel).toHaveBeenCalledTimes(1); + expect(body.state.children.length).toBe(6); + expect(gridItem.state.body!.state.key).toBe('panel-7'); + expect(gridItem.state.y).toBe(0); + expect(scene.state.hasCopiedPanel).toBe(false); + }); + + it('Should paste a library viz panel', () => { + scene.setState({ hasCopiedPanel: true }); + jest.spyOn(JSON, 'parse').mockReturnValue({ libraryPanel: { uid: 'uid', name: 'libraryPanel' } }); + jest.mocked(buildGridItemForLibPanel).mockReturnValue( + new SceneGridItem({ + body: new LibraryVizPanel({ + title: 'Library Panel', + uid: 'uid', + name: 'libraryPanel', + panelKey: 'panel-4', + }), + }) + ); + + scene.pastePanel(); + + const body = scene.state.body as SceneGridLayout; + const gridItem = body.state.children[0] as SceneGridItem; + + const libVizPanel = gridItem.state.body as LibraryVizPanel; + + expect(buildGridItemForLibPanel).toHaveBeenCalledTimes(1); + expect(body.state.children.length).toBe(6); + expect(libVizPanel.state.panelKey).toBe('panel-7'); + expect(libVizPanel.state.panel?.state.key).toBe('panel-7'); + expect(gridItem.state.y).toBe(0); + expect(scene.state.hasCopiedPanel).toBe(false); + }); + + it('Should create a new add library panel widget', () => { + scene.onCreateLibPanelWidget(); + + const body = scene.state.body as SceneGridLayout; + const gridItem = body.state.children[0] as SceneGridItem; + + expect(body.state.children.length).toBe(6); + expect(gridItem.state.body!.state.key).toBe('panel-7'); + expect(gridItem.state.y).toBe(0); + }); + + it('Should remove a panel', () => { + const vizPanel = ((scene.state.body as SceneGridLayout).state.children[0] as SceneGridItem).state.body; + scene.removePanel(vizPanel as VizPanel); + + const body = scene.state.body as SceneGridLayout; + expect(body.state.children.length).toBe(4); + }); + + it('Should remove a panel within a row', () => { + const vizPanel = ( + ((scene.state.body as SceneGridLayout).state.children[2] as SceneGridRow).state.children[0] as SceneGridItem + ).state.body; + scene.removePanel(vizPanel as VizPanel); + + const body = scene.state.body as SceneGridLayout; + const gridRow = body.state.children[2] as SceneGridRow; + expect(gridRow.state.children.length).toBe(1); + }); + + it('Should remove a library panel', () => { + const libraryPanel = ((scene.state.body as SceneGridLayout).state.children[4] as SceneGridItem).state.body; + const vizPanel = (libraryPanel as LibraryVizPanel).state.panel; + scene.removePanel(vizPanel as VizPanel); + + const body = scene.state.body as SceneGridLayout; + expect(body.state.children.length).toBe(4); + }); + + it('Should remove a library panel within a row', () => { + const libraryPanel = ( + ((scene.state.body as SceneGridLayout).state.children[2] as SceneGridRow).state.children[1] as SceneGridItem + ).state.body; + const vizPanel = (libraryPanel as LibraryVizPanel).state.panel; + + scene.removePanel(vizPanel as VizPanel); + + const body = scene.state.body as SceneGridLayout; + const gridRow = body.state.children[2] as SceneGridRow; + expect(gridRow.state.children.length).toBe(1); + }); + + it('Should duplicate a panel', () => { + const vizPanel = ((scene.state.body as SceneGridLayout).state.children[0] as SceneGridItem).state.body; + scene.duplicatePanel(vizPanel as VizPanel); + + const body = scene.state.body as SceneGridLayout; + const gridItem = body.state.children[5] as SceneGridItem; + + expect(body.state.children.length).toBe(6); + expect(gridItem.state.body!.state.key).toBe('panel-7'); + }); + + it('Should duplicate a library panel', () => { + const libraryPanel = ((scene.state.body as SceneGridLayout).state.children[4] as SceneGridItem).state.body; + const vizPanel = (libraryPanel as LibraryVizPanel).state.panel; + scene.duplicatePanel(vizPanel as VizPanel); + + const body = scene.state.body as SceneGridLayout; + const gridItem = body.state.children[5] as SceneGridItem; + + const libVizPanel = gridItem.state.body as LibraryVizPanel; + + expect(body.state.children.length).toBe(6); + expect(libVizPanel.state.panelKey).toBe('panel-7'); + expect(libVizPanel.state.panel?.state.key).toBe('panel-7'); + }); + + it('Should duplicate a repeated panel', () => { + const scene = buildTestScene({ + body: new SceneGridLayout({ + children: [ + new PanelRepeaterGridItem({ + key: `grid-item-1`, + width: 24, + height: 8, + repeatedPanels: [ + new VizPanel({ + title: 'Library Panel', + key: 'panel-1', + pluginId: 'table', + }), + ], + source: new VizPanel({ + title: 'Library Panel', + key: 'panel-1', + pluginId: 'table', + }), + variableName: 'custom', + }), + ], + }), + }); + + const vizPanel = ((scene.state.body as SceneGridLayout).state.children[0] as PanelRepeaterGridItem).state + .repeatedPanels![0]; + + scene.duplicatePanel(vizPanel as VizPanel); + + const body = scene.state.body as SceneGridLayout; + const gridItem = body.state.children[1] as SceneGridItem; + + expect(body.state.children.length).toBe(2); + expect(gridItem.state.body!.state.key).toBe('panel-2'); + }); + + it('Should duplicate a panel in a row', () => { + const vizPanel = ( + ((scene.state.body as SceneGridLayout).state.children[2] as SceneGridRow).state.children[0] as SceneGridItem + ).state.body; + scene.duplicatePanel(vizPanel as VizPanel); + + const body = scene.state.body as SceneGridLayout; + const gridRow = body.state.children[2] as SceneGridRow; + const gridItem = gridRow.state.children[2] as SceneGridItem; + + expect(gridRow.state.children.length).toBe(3); + expect(gridItem.state.body!.state.key).toBe('panel-7'); + }); + + it('Should duplicate a library panel in a row', () => { + const libraryPanel = ( + ((scene.state.body as SceneGridLayout).state.children[2] as SceneGridRow).state.children[1] as SceneGridItem + ).state.body; + const vizPanel = (libraryPanel as LibraryVizPanel).state.panel; + + scene.duplicatePanel(vizPanel as VizPanel); + + const body = scene.state.body as SceneGridLayout; + const gridRow = body.state.children[2] as SceneGridRow; + const gridItem = gridRow.state.children[2] as SceneGridItem; + + const libVizPanel = gridItem.state.body as LibraryVizPanel; + + expect(gridRow.state.children.length).toBe(3); + expect(libVizPanel.state.panelKey).toBe('panel-7'); + expect(libVizPanel.state.panel?.state.key).toBe('panel-7'); + }); + + it('Should fail to duplicate a panel if it does not have a grid item parent', () => { + const vizPanel = new VizPanel({ + title: 'Panel Title', + key: 'panel-5', + pluginId: 'timeseries', + }); + + scene.duplicatePanel(vizPanel); + + const body = scene.state.body as SceneGridLayout; + + // length remains unchanged + expect(body.state.children.length).toBe(5); + }); + + it('Should unlink a library panel', () => { + const libPanel = new LibraryVizPanel({ + title: 'title', + uid: 'abc', + name: 'lib panel', + panelKey: 'panel-1', + isLoaded: true, + panel: new VizPanel({ + title: 'Panel B', + pluginId: 'table', + }), + }); + + const scene = buildTestScene({ + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-2', + body: libPanel, + }), + ], + }), + }); + + scene.unlinkLibraryPanel(libPanel); + + const body = scene.state.body as SceneGridLayout; + const gridItem = body.state.children[0] as SceneGridItem; + + expect(body.state.children.length).toBe(1); + expect(gridItem.state.body).toBeInstanceOf(VizPanel); + }); }); }); @@ -117,12 +715,13 @@ describe('DashboardScene', () => { scene.onEnterEditMode(); }); - it('Should add app, uid, and panelId', () => { + it('Should add app, uid, panelId and panelPluginId', () => { const queryRunner = sceneGraph.findObject(scene, (o) => o.state.key === 'data-query-runner')!; expect(scene.enrichDataRequest(queryRunner)).toEqual({ app: CoreApp.Dashboard, dashboardUID: 'dash-1', panelId: 1, + panelPluginId: 'table', }); }); @@ -134,7 +733,11 @@ describe('DashboardScene', () => { }); describe('When variables change', () => { - it('A change to griditem pos should set isDirty true', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('A change to variable values should trigger VariablesChanged event', () => { const varA = new TestVariable({ name: 'A', query: 'A.*', value: 'A.AA', text: '', options: [], delayMs: 0 }); const scene = buildTestScene({ $variables: new SceneVariableSet({ variables: [varA] }), @@ -149,6 +752,102 @@ describe('DashboardScene', () => { expect(eventHandler).toHaveBeenCalledTimes(1); }); + + it('A change to the variable set should set isDirty true', () => { + const varA = new TestVariable({ name: 'A', query: 'A.*', value: 'A.AA', text: '', options: [], delayMs: 0 }); + const scene = buildTestScene({ + $variables: new SceneVariableSet({ variables: [varA] }), + }); + + scene.activate(); + scene.onEnterEditMode(); + + const variableSet = sceneGraph.getVariables(scene); + variableSet.setState({ variables: [] }); + + expect(scene.state.isDirty).toBe(true); + }); + + it('A change to a variable state should set isDirty true', () => { + mockResultsOfDetectChangesWorker({ hasChanges: true, hasTimeChanges: false, hasVariableValueChanges: true }); + const variable = new TestVariable({ name: 'A' }); + const scene = buildTestScene({ + $variables: new SceneVariableSet({ variables: [variable] }), + }); + + scene.activate(); + scene.onEnterEditMode(); + + variable.setState({ name: 'new-name' }); + + expect(variable.state.name).toBe('new-name'); + expect(scene.state.isDirty).toBe(true); + }); + + it('A change to variable name is restored to original name should set isDirty back to false', () => { + const variable = new TestVariable({ name: 'A' }); + const scene = buildTestScene({ + $variables: new SceneVariableSet({ variables: [variable] }), + }); + + scene.activate(); + scene.onEnterEditMode(); + + mockResultsOfDetectChangesWorker({ hasChanges: true, hasTimeChanges: false, hasVariableValueChanges: false }); + variable.setState({ name: 'B' }); + expect(scene.state.isDirty).toBe(true); + mockResultsOfDetectChangesWorker( + // No changes, it is the same name than before comparing saving models + { hasChanges: false, hasTimeChanges: false, hasVariableValueChanges: false } + ); + variable.setState({ name: 'A' }); + expect(scene.state.isDirty).toBe(false); + }); + }); + + describe('When a dashboard is restored', () => { + let scene: DashboardScene; + + beforeEach(async () => { + scene = buildTestScene(); + scene.onEnterEditMode(); + }); + + it('should restore the dashboard to the selected version and exit edit mode', () => { + const newVersion = 3; + + const mockScene = new DashboardScene({ + title: 'new name', + uid: 'dash-1', + version: 4, + }); + + jest.mocked(historySrv.restoreDashboard).mockResolvedValue({ version: newVersion }); + jest.mocked(transformSaveModelToScene).mockReturnValue(mockScene); + + return scene.onRestore(getVersionMock()).then((res) => { + expect(res).toBe(true); + + expect(scene.state.version).toBe(newVersion); + expect(scene.state.isEditing).toBe(false); + }); + }); + + it('should return early if historySrv does not return a valid version number', () => { + jest + .mocked(historySrv.restoreDashboard) + .mockResolvedValueOnce({ version: null }) + .mockResolvedValueOnce({ version: undefined }) + .mockResolvedValueOnce({ version: Infinity }) + .mockResolvedValueOnce({ version: NaN }) + .mockResolvedValue({ version: '10' }); + + for (let i = 0; i < 5; i++) { + scene.onRestore(getVersionMock()).then((res) => { + expect(res).toBe(false); + }); + } + }); }); }); @@ -162,17 +861,8 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) { $timeRange: new SceneTimeRange({ timeZone: 'browser', }), - controls: [ - new DashboardControls({ - variableControls: [], - linkControls: new DashboardLinksControls({}), - timeControls: [ - new SceneRefreshPicker({ - intervals: ['1s'], - }), - ], - }), - ], + controls: new DashboardControls({}), + $behaviors: [new behaviors.CursorSync({})], body: new SceneGridLayout({ children: [ new SceneGridItem({ @@ -186,12 +876,38 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) { }), }), new SceneGridItem({ + key: 'griditem-2', body: new VizPanel({ title: 'Panel B', key: 'panel-2', pluginId: 'table', }), }), + new SceneGridRow({ + key: 'panel-3', + children: [ + new SceneGridItem({ + body: new VizPanel({ + title: 'Panel C', + key: 'panel-4', + pluginId: 'table', + }), + }), + new SceneGridItem({ + body: new LibraryVizPanel({ + uid: 'uid', + name: 'libraryPanel', + panelKey: 'panel-5', + title: 'Library Panel', + panel: new VizPanel({ + title: 'Library Panel', + key: 'panel-5', + pluginId: 'table', + }), + }), + }), + ], + }), new SceneGridItem({ body: new VizPanel({ title: 'Panel B', @@ -200,6 +916,19 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) { $data: new SceneQueryRunner({ key: 'data-query-runner2', queries: [{ refId: 'A' }] }), }), }), + new SceneGridItem({ + body: new LibraryVizPanel({ + uid: 'uid', + name: 'libraryPanel', + panelKey: 'panel-6', + title: 'Library Panel', + panel: new VizPanel({ + title: 'Library Panel', + key: 'panel-6', + pluginId: 'table', + }), + }), + }), ], }), ...overrides, @@ -207,3 +936,45 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) { return scene; } + +function mockResultsOfDetectChangesWorker({ + hasChanges, + hasTimeChanges, + hasVariableValueChanges, +}: { + hasChanges: boolean; + hasTimeChanges: boolean; + hasVariableValueChanges: boolean; +}) { + jest.mocked(worker.postMessage).mockImplementationOnce(() => { + worker.onmessage?.({ + data: { + hasChanges: hasChanges ?? true, + hasTimeChanges: hasTimeChanges ?? true, + hasVariableValueChanges: hasVariableValueChanges ?? true, + }, + } as unknown as MessageEvent); + }); +} + +function getVersionMock(): DecoratedRevisionModel { + const dash: Dashboard = { + title: 'new name', + id: 5, + schemaVersion: 30, + }; + + return { + id: 2, + checked: false, + uid: 'uid', + parentVersion: 1, + version: 2, + created: new Date(), + createdBy: 'admin', + message: '', + data: dash, + createdDateString: '2017-02-22 20:43:01', + ageString: '7 years ago', + }; +} diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 75fdf7afe9132..6f81fe05b1915 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -1,44 +1,72 @@ import * as H from 'history'; -import { Unsubscribable } from 'rxjs'; -import { CoreApp, DataQueryRequest, NavIndex, NavModelItem } from '@grafana/data'; -import { config, locationService } from '@grafana/runtime'; +import { AppEvents, CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; import { getUrlSyncManager, SceneFlexLayout, + sceneGraph, SceneGridItem, SceneGridLayout, + SceneGridRow, SceneObject, SceneObjectBase, SceneObjectState, - SceneObjectStateChangedEvent, - SceneRefreshPicker, - SceneTimeRange, sceneUtils, SceneVariable, SceneVariableDependencyConfigLike, + VizPanel, } from '@grafana/scenes'; -import { DashboardLink } from '@grafana/schema'; +import { Dashboard, DashboardLink } from '@grafana/schema'; import appEvents from 'app/core/app_events'; +import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { getNavModel } from 'app/core/selectors/navModel'; +import store from 'app/core/store'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { VariablesChanged } from 'app/features/variables/types'; -import { DashboardMeta } from 'app/types'; +import { DashboardDTO, DashboardMeta, SaveDashboardResponseDTO } from 'app/types'; +import { ShowConfirmModalEvent } from 'app/types/events'; +import { PanelEditor } from '../panel-edit/PanelEditor'; +import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; +import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer'; import { DashboardSceneRenderer } from '../scene/DashboardSceneRenderer'; -import { SaveDashboardDrawer } from '../serialization/SaveDashboardDrawer'; +import { + buildGridItemForLibPanel, + buildGridItemForPanel, + transformSaveModelToScene, +} from '../serialization/transformSaveModelToScene'; +import { gridItemToPanel } from '../serialization/transformSceneToSaveModel'; +import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { DashboardEditView } from '../settings/utils'; +import { historySrv } from '../settings/version-history'; import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; +import { dashboardSceneGraph, getLibraryVizPanelFromVizPanel } from '../utils/dashboardSceneGraph'; import { djb2Hash } from '../utils/djb2Hash'; import { getDashboardUrl } from '../utils/urlBuilders'; -import { forceRenderChildren, getClosestVizPanel, getPanelIdForVizPanel, isPanelClone } from '../utils/utils'; - +import { + NEW_PANEL_HEIGHT, + NEW_PANEL_WIDTH, + forceRenderChildren, + getClosestVizPanel, + getDefaultRow, + getDefaultVizPanel, + getPanelIdForVizPanel, + getVizPanelKeyForPanelId, + isPanelClone, +} from '../utils/utils'; + +import { AddLibraryPanelWidget } from './AddLibraryPanelWidget'; import { DashboardControls } from './DashboardControls'; import { DashboardSceneUrlSync } from './DashboardSceneUrlSync'; +import { LibraryVizPanel } from './LibraryVizPanel'; +import { PanelRepeaterGridItem } from './PanelRepeaterGridItem'; import { ViewPanelScene } from './ViewPanelScene'; import { setupKeyboardShortcuts } from './keyboardShortcuts'; -export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'graphTooltip']; +export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'graphTooltip', 'links', 'meta']; export interface DashboardSceneState extends SceneObjectState { /** The title */ @@ -48,7 +76,7 @@ export interface DashboardSceneState extends SceneObjectState { /** Tags */ tags?: string[]; /** Links */ - links?: DashboardLink[]; + links: DashboardLink[]; /** Is editable */ editable?: boolean; /** A uid when saved */ @@ -60,21 +88,29 @@ export interface DashboardSceneState extends SceneObjectState { /** NavToolbar actions */ actions?: SceneObject[]; /** Fixed row at the top of the canvas with for example variables and time range controls */ - controls?: SceneObject[]; + controls?: DashboardControls; /** True when editing */ isEditing?: boolean; /** True when user made a change */ isDirty?: boolean; /** meta flags */ meta: DashboardMeta; + /** Version of the dashboard */ + version?: number; /** Panel to inspect */ inspectPanelKey?: string; /** Panel to view in fullscreen */ viewPanelScene?: ViewPanelScene; /** Edit view */ editview?: DashboardEditView; + /** Edit panel */ + editPanel?: PanelEditor; /** Scene object that handles the current drawer or modal */ overlay?: SceneObject; + /** True when a user copies a panel in the dashboard */ + hasCopiedPanel?: boolean; + /** The dashboard doesn't have panels */ + isEmpty?: boolean; } export class DashboardScene extends SceneObjectBase<DashboardSceneState> { @@ -94,14 +130,18 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> { * State before editing started */ private _initialState?: DashboardSceneState; + /** + * The save model which the scene was originally created from + */ + private _initialSaveModel?: Dashboard; /** * Url state before editing started */ private _initialUrlState?: H.Location; /** - * change tracking subscription + * Dashboard changes tracker */ - private _changeTrackerSub?: Unsubscribable; + private _changeTracker: DashboardSceneChangeTracker; public constructor(state: Partial<DashboardSceneState>) { super({ @@ -109,17 +149,33 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> { meta: {}, editable: true, body: state.body ?? new SceneFlexLayout({ children: [] }), + links: state.links ?? [], + hasCopiedPanel: store.exists(LS_PANEL_COPY_KEY), ...state, }); + this._changeTracker = new DashboardSceneChangeTracker(this); + this.addActivationHandler(() => this._activationHandler()); } private _activationHandler() { + let prevSceneContext = window.__grafanaSceneContext; + window.__grafanaSceneContext = this; if (this.state.isEditing) { - this.startTrackingChanges(); + this._initialUrlState = locationService.getLocation(); + this._changeTracker.startTrackingChanges(); + } + + if (this.state.meta.isNew) { + this.onEnterEditMode(); + this.setState({ isDirty: true }); + } + + if (!this.state.meta.isEmbedded && this.state.uid) { + dashboardWatcher.watch(this.state.uid); } const clearKeyBindings = setupKeyboardShortcuts(this); @@ -130,16 +186,19 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> { // Deactivation logic return () => { - window.__grafanaSceneContext = undefined; + window.__grafanaSceneContext = prevSceneContext; clearKeyBindings(); - this.stopTrackingChanges(); + this._changeTracker.terminate(); this.stopUrlSync(); oldDashboardWrapper.destroy(); + dashboardWatcher.leave(); }; } public startUrlSync() { - getUrlSyncManager().initSync(this); + if (!this.state.meta.isEmbedded) { + getUrlSyncManager().initSync(this); + } } public stopUrlSync() { @@ -155,47 +214,155 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> { this.setState({ isEditing: true }); // Propagate change edit mode change to children + this.propagateEditModeChange(); + + this._changeTracker.startTrackingChanges(); + }; + + public saveCompleted(saveModel: Dashboard, result: SaveDashboardResponseDTO, folderUid?: string) { + this._initialSaveModel = { + ...saveModel, + id: result.id, + uid: result.uid, + version: result.version, + }; + + this._changeTracker.stopTrackingChanges(); + this.setState({ + version: result.version, + isDirty: false, + uid: result.uid, + id: result.id, + meta: { + ...this.state.meta, + uid: result.uid, + url: result.url, + slug: result.slug, + folderUid: folderUid, + }, + }); + + this._changeTracker.startTrackingChanges(); + } + + private propagateEditModeChange() { if (this.state.body instanceof SceneGridLayout) { - this.state.body.setState({ isDraggable: true, isResizable: true }); + this.state.body.setState({ isDraggable: this.state.isEditing, isResizable: this.state.isEditing }); forceRenderChildren(this.state.body, true); } + } - this.startTrackingChanges(); - }; + public exitEditMode({ skipConfirm, restoreInitialState }: { skipConfirm: boolean; restoreInitialState?: boolean }) { + if (!this.canDiscard()) { + console.error('Trying to discard back to a state that does not exist, initialState undefined'); + return; + } - public onDiscard = () => { + if (!this.state.isDirty || skipConfirm) { + this.exitEditModeConfirmed(restoreInitialState || this.state.isDirty); + return; + } + + appEvents.publish( + new ShowConfirmModalEvent({ + title: 'Discard changes to dashboard?', + text: `You have unsaved changes to this dashboard. Are you sure you want to discard them?`, + icon: 'trash-alt', + yesText: 'Discard', + onConfirm: this.exitEditModeConfirmed.bind(this), + }) + ); + } + + private exitEditModeConfirmed(restoreInitialState = true) { // No need to listen to changes anymore - this.stopTrackingChanges(); + this._changeTracker.stopTrackingChanges(); // Stop url sync before updating url this.stopUrlSync(); - // Now we can update url - locationService.replace({ pathname: this._initialUrlState?.pathname, search: this._initialUrlState?.search }); - // Update state and disable editing - this.setState({ ...this._initialState, isEditing: false }); + + // Now we can update urls + // We are updating url and removing editview and editPanel. + // The initial url may be including edit view, edit panel or inspect query params if the user pasted the url, + // hence we need to cleanup those query params to get back to the dashboard view. Otherwise url sync can trigger overlays. + locationService.replace( + locationUtil.getUrlForPartial(this._initialUrlState!, { + editPanel: null, + editview: null, + inspect: null, + inspectTab: null, + }) + ); + + if (restoreInitialState) { + // Restore initial state and disable editing + this.setState({ ...this._initialState, isEditing: false }); + } else { + // Do not restore + this.setState({ isEditing: false }); + } // and start url sync again this.startUrlSync(); - // Disable grid dragging - if (this.state.body instanceof SceneGridLayout) { - this.state.body.setState({ isDraggable: false, isResizable: false }); - forceRenderChildren(this.state.body, true); + this.propagateEditModeChange(); + } + + public canDiscard() { + return this._initialState !== undefined; + } + + public pauseTrackingChanges() { + this._changeTracker.stopTrackingChanges(); + } + + public resumeTrackingChanges() { + this._changeTracker.startTrackingChanges(); + } + + public onRestore = async (version: DecoratedRevisionModel): Promise<boolean> => { + const versionRsp = await historySrv.restoreDashboard(version.uid, version.version); + + if (!Number.isInteger(versionRsp.version)) { + return false; } - }; - public onSave = () => { - this.setState({ overlay: new SaveDashboardDrawer({ dashboardRef: this.getRef() }) }); + const dashboardDTO: DashboardDTO = { + dashboard: new DashboardModel(version.data), + meta: this.state.meta, + }; + const dashScene = transformSaveModelToScene(dashboardDTO); + const newState = sceneUtils.cloneSceneObjectState(dashScene.state); + newState.version = versionRsp.version; + + this.setState(newState); + this.exitEditMode({ skipConfirm: true, restoreInitialState: false }); + + return true; }; + public openSaveDrawer({ saveAsCopy, onSaveSuccess }: { saveAsCopy?: boolean; onSaveSuccess?: () => void }) { + if (!this.state.isEditing) { + return; + } + + this.setState({ + overlay: new SaveDashboardDrawer({ + dashboardRef: this.getRef(), + saveAsCopy, + onSaveSuccess, + }), + }); + } + public getPageNav(location: H.Location, navIndex: NavIndex) { - const { meta, viewPanelScene } = this.state; + const { meta, viewPanelScene, editPanel } = this.state; let pageNav: NavModelItem = { text: this.state.title, url: getDashboardUrl({ uid: this.state.uid, + slug: meta.slug, currentQueryParams: location.search, - updateQuery: { viewPanel: null, inspect: null, editview: null }, - useExperimentalURL: Boolean(config.featureToggles.dashboardSceneForViewers && meta.canEdit), + updateQuery: { viewPanel: null, inspect: null, editview: null, editPanel: null, tab: null }, }), }; @@ -220,6 +387,13 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> { }; } + if (editPanel) { + pageNav = { + text: 'Edit panel', + parentItem: pageNav, + }; + } + return pageNav; } @@ -230,47 +404,293 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> { return this.state.viewPanelScene ?? this.state.body; } - private startTrackingChanges() { - this._changeTrackerSub = this.subscribeToEvent( - SceneObjectStateChangedEvent, - (event: SceneObjectStateChangedEvent) => { - if (event.payload.changedObject instanceof SceneRefreshPicker) { - if (Object.prototype.hasOwnProperty.call(event.payload.partialUpdate, 'intervals')) { - this.setIsDirty(); - } - } - if (event.payload.changedObject instanceof SceneGridItem) { - this.setIsDirty(); - } - if (event.payload.changedObject instanceof DashboardScene) { - if (Object.keys(event.payload.partialUpdate).some((key) => PERSISTED_PROPS.includes(key))) { - this.setIsDirty(); - } - } - if (event.payload.changedObject instanceof SceneTimeRange) { - this.setIsDirty(); - } - if (event.payload.changedObject instanceof DashboardControls) { - if (Object.prototype.hasOwnProperty.call(event.payload.partialUpdate, 'hideTimeControls')) { - this.setIsDirty(); - } - } + public getInitialState(): DashboardSceneState | undefined { + return this._initialState; + } + + public addRow(row: SceneGridRow) { + if (!(this.state.body instanceof SceneGridLayout)) { + throw new Error('Trying to add a panel in a layout that is not SceneGridLayout'); + } + + const sceneGridLayout = this.state.body; + + // find all panels until the first row and put them into the newly created row. If there are no other rows, + // add all panels to the row. If there are no panels just create an empty row + const indexTillNextRow = sceneGridLayout.state.children.findIndex((child) => child instanceof SceneGridRow); + const rowChildren = sceneGridLayout.state.children + .splice(0, indexTillNextRow === -1 ? sceneGridLayout.state.children.length : indexTillNextRow) + .map((child) => child.clone()); + + if (rowChildren) { + row.setState({ + children: rowChildren, + }); + } + + sceneGridLayout.setState({ + children: [row, ...sceneGridLayout.state.children], + }); + } + + public removeRow(row: SceneGridRow, removePanels = false) { + if (!(this.state.body instanceof SceneGridLayout)) { + throw new Error('Trying to add a panel in a layout that is not SceneGridLayout'); + } + + const sceneGridLayout = this.state.body; + + const children = sceneGridLayout.state.children.filter((child) => child.state.key !== row.state.key); + + if (!removePanels) { + const rowChildren = row.state.children.map((child) => child.clone()); + const indexOfRow = sceneGridLayout.state.children.findIndex((child) => child.state.key === row.state.key); + + children.splice(indexOfRow, 0, ...rowChildren); + } + + sceneGridLayout.setState({ children }); + } + + public addPanel(vizPanel: VizPanel): void { + if (!(this.state.body instanceof SceneGridLayout)) { + throw new Error('Trying to add a panel in a layout that is not SceneGridLayout'); + } + + const sceneGridLayout = this.state.body; + + const panelId = getPanelIdForVizPanel(vizPanel); + const newGridItem = new SceneGridItem({ + height: NEW_PANEL_HEIGHT, + width: NEW_PANEL_WIDTH, + x: 0, + y: 0, + body: vizPanel, + key: `grid-item-${panelId}`, + }); + + sceneGridLayout.setState({ + children: [newGridItem, ...sceneGridLayout.state.children], + }); + } + + public duplicatePanel(vizPanel: VizPanel) { + if (!vizPanel.parent) { + return; + } + + const libraryPanel = getLibraryVizPanelFromVizPanel(vizPanel); + + const gridItem = libraryPanel ? libraryPanel.parent : vizPanel.parent; + + if (!(gridItem instanceof SceneGridItem || gridItem instanceof PanelRepeaterGridItem)) { + console.error('Trying to duplicate a panel in a layout that is not SceneGridItem or PanelRepeaterGridItem'); + return; + } + + let panelState; + let panelData; + let newGridItem; + const newPanelId = dashboardSceneGraph.getNextPanelId(this); + + if (libraryPanel) { + const gridItemToDuplicateState = sceneUtils.cloneSceneObjectState(gridItem.state); + + newGridItem = new SceneGridItem({ + x: gridItemToDuplicateState.x, + y: gridItemToDuplicateState.y, + width: gridItemToDuplicateState.width, + height: gridItemToDuplicateState.height, + body: new LibraryVizPanel({ + title: libraryPanel.state.title, + uid: libraryPanel.state.uid, + name: libraryPanel.state.name, + panelKey: getVizPanelKeyForPanelId(newPanelId), + }), + }); + } else { + if (gridItem instanceof PanelRepeaterGridItem) { + panelState = sceneUtils.cloneSceneObjectState(gridItem.state.source.state); + panelData = sceneGraph.getData(gridItem.state.source).clone(); + } else { + panelState = sceneUtils.cloneSceneObjectState(vizPanel.state); + panelData = sceneGraph.getData(vizPanel).clone(); } - ); + + // when we duplicate a panel we don't want to clone the alert state + delete panelData.state.data?.alertState; + + newGridItem = new SceneGridItem({ + x: gridItem.state.x, + y: gridItem.state.y, + height: NEW_PANEL_HEIGHT, + width: NEW_PANEL_WIDTH, + body: new VizPanel({ ...panelState, $data: panelData, key: getVizPanelKeyForPanelId(newPanelId) }), + }); + } + + if (!(this.state.body instanceof SceneGridLayout)) { + console.error('Trying to duplicate a panel in a layout that is not SceneGridLayout '); + return; + } + + const sceneGridLayout = this.state.body; + + if (gridItem.parent instanceof SceneGridRow) { + const row = gridItem.parent; + + row.setState({ + children: [...row.state.children, newGridItem], + }); + + sceneGridLayout.forceRender(); + + return; + } + + sceneGridLayout.setState({ + children: [...sceneGridLayout.state.children, newGridItem], + }); } - private setIsDirty() { - if (!this.state.isDirty) { - this.setState({ isDirty: true }); + public copyPanel(vizPanel: VizPanel) { + if (!vizPanel.parent) { + return; } + + let gridItem = vizPanel.parent; + + if (vizPanel.parent instanceof LibraryVizPanel) { + const libraryVizPanel = vizPanel.parent; + + if (!libraryVizPanel.parent) { + return; + } + + gridItem = libraryVizPanel.parent; + } + + const jsonData = gridItemToPanel(gridItem); + + store.set(LS_PANEL_COPY_KEY, JSON.stringify(jsonData)); + appEvents.emit(AppEvents.alertSuccess, ['Panel copied. Use **Paste panel** toolbar action to paste.']); + this.setState({ hasCopiedPanel: true }); } - private stopTrackingChanges() { - this._changeTrackerSub?.unsubscribe(); + public pastePanel() { + if (!(this.state.body instanceof SceneGridLayout)) { + throw new Error('Trying to add a panel in a layout that is not SceneGridLayout'); + } + + const jsonData = store.get(LS_PANEL_COPY_KEY); + const jsonObj = JSON.parse(jsonData); + const panelModel = new PanelModel(jsonObj); + const gridItem = !panelModel.libraryPanel + ? buildGridItemForPanel(panelModel) + : buildGridItemForLibPanel(panelModel); + + const sceneGridLayout = this.state.body; + + if (!(gridItem instanceof SceneGridItem) && !(gridItem instanceof PanelRepeaterGridItem)) { + throw new Error('Cannot paste invalid grid item'); + } + + const panelId = dashboardSceneGraph.getNextPanelId(this); + + if (gridItem instanceof SceneGridItem && gridItem.state.body instanceof LibraryVizPanel) { + const panelKey = getVizPanelKeyForPanelId(panelId); + + gridItem.state.body.setState({ panelKey }); + + const vizPanel = gridItem.state.body.state.panel; + + if (vizPanel instanceof VizPanel) { + vizPanel.setState({ key: panelKey }); + } + } else if (gridItem instanceof SceneGridItem && gridItem.state.body) { + gridItem.state.body.setState({ + key: getVizPanelKeyForPanelId(panelId), + }); + } else if (gridItem instanceof PanelRepeaterGridItem) { + gridItem.state.source.setState({ + key: getVizPanelKeyForPanelId(panelId), + }); + } + + gridItem.setState({ + height: NEW_PANEL_HEIGHT, + width: NEW_PANEL_WIDTH, + x: 0, + y: 0, + key: `grid-item-${panelId}`, + }); + + sceneGridLayout.setState({ + children: [gridItem, ...sceneGridLayout.state.children], + }); + + this.setState({ hasCopiedPanel: false }); + store.delete(LS_PANEL_COPY_KEY); } - public getInitialState(): DashboardSceneState | undefined { - return this._initialState; + public removePanel(panel: VizPanel) { + const panels: SceneObject[] = []; + const key = panel.parent instanceof LibraryVizPanel ? panel.parent.parent?.state.key : panel.parent?.state.key; + + if (!key) { + return; + } + + let row: SceneGridRow | undefined; + + try { + row = sceneGraph.getAncestor(panel, SceneGridRow); + } catch { + row = undefined; + } + + if (row) { + row.forEachChild((child: SceneObject) => { + if (child.state.key !== key) { + panels.push(child); + } + }); + + row.setState({ children: panels }); + + this.state.body.forceRender(); + + return; + } + + this.state.body.forEachChild((child: SceneObject) => { + if (child.state.key !== key) { + panels.push(child); + } + }); + + const layout = this.state.body; + + if (layout instanceof SceneGridLayout || layout instanceof SceneFlexLayout) { + layout.setState({ children: panels }); + } + } + + public unlinkLibraryPanel(panel: LibraryVizPanel) { + if (!panel.parent) { + return; + } + + const gridItem = panel.parent; + + if (!(gridItem instanceof SceneGridItem || gridItem instanceof PanelRepeaterGridItem)) { + console.error('Trying to duplicate a panel in a layout that is not SceneGridItem or PanelRepeaterGridItem'); + return; + } + + gridItem?.setState({ + body: panel.state.panel?.clone(), + }); } public showModal(modal: SceneObject) { @@ -304,6 +724,45 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> { locationService.partial({ editview: 'settings' }); }; + public onCreateLibPanelWidget() { + if (!(this.state.body instanceof SceneGridLayout)) { + throw new Error('Trying to add a panel in a layout that is not SceneGridLayout'); + } + + const sceneGridLayout = this.state.body; + + const panelId = dashboardSceneGraph.getNextPanelId(this); + + const newGridItem = new SceneGridItem({ + height: NEW_PANEL_HEIGHT, + width: NEW_PANEL_WIDTH, + x: 0, + y: 0, + body: new AddLibraryPanelWidget({ key: getVizPanelKeyForPanelId(panelId) }), + key: `grid-item-${panelId}`, + }); + + sceneGridLayout.setState({ + children: [newGridItem, ...sceneGridLayout.state.children], + }); + } + + public onCreateNewRow() { + const row = getDefaultRow(this); + + this.addRow(row); + + return getPanelIdForVizPanel(row); + } + + public onCreateNewPanel(): number { + const vizPanel = getDefaultVizPanel(this); + + this.addPanel(vizPanel); + + return getPanelIdForVizPanel(vizPanel); + } + /** * Called by the SceneQueryRunner to privide contextural parameters (tracking) props for the request */ @@ -323,6 +782,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> { app: CoreApp.Dashboard, dashboardUID: this.state.uid, panelId, + panelPluginId: panel?.state.pluginId, }; } @@ -331,6 +791,15 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> { return Boolean(meta.canEdit || meta.canMakeEditable); } + + public getInitialSaveModel() { + return this._initialSaveModel; + } + + /** Hacky temp function until we refactor transformSaveModelToScene a bit */ + public setInitialSaveModel(saveModel: Dashboard) { + this._initialSaveModel = saveModel; + } } export class DashboardVariableDependency implements SceneVariableDependencyConfigLike { @@ -344,8 +813,8 @@ export class DashboardVariableDependency implements SceneVariableDependencyConfi return false; } - public variableUpdatesCompleted(changedVars: Set<SceneVariable>) { - if (changedVars.size > 0) { + public variableUpdateCompleted(variable: SceneVariable, hasChanged: boolean) { + if (hasChanged) { // Temp solution for some core panels (like dashlist) to know that variables have changed appEvents.publish(new VariablesChanged({ refreshAll: true, panelIds: [] })); } diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx index 08f39caa97c67..17915583be91b 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx @@ -3,17 +3,18 @@ import React from 'react'; import { useLocation } from 'react-router-dom'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; -import { SceneComponentProps, SceneDebugger } from '@grafana/scenes'; +import { SceneComponentProps } from '@grafana/scenes'; import { CustomScrollbar, useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { getNavModel } from 'app/core/selectors/navModel'; +import DashboardEmpty from 'app/features/dashboard/dashgrid/DashboardEmpty'; import { useSelector } from 'app/types'; import { DashboardScene } from './DashboardScene'; import { NavToolbarActions } from './NavToolbarActions'; export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) { - const { controls, overlay, editview } = model.useState(); + const { controls, overlay, editview, editPanel, isEmpty } = model.useState(); const styles = useStyles2(getStyles); const location = useLocation(); const navIndex = useSelector((state) => state.navIndex); @@ -22,28 +23,34 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS const navModel = getNavModel(navIndex, 'dashboards/browse'); if (editview) { - return <editview.Component model={editview} />; + return ( + <> + <editview.Component model={editview} /> + {overlay && <overlay.Component model={overlay} />} + </> + ); } + const emptyState = <DashboardEmpty dashboard={model} canCreate={!!model.state.meta.canEdit} />; + + const withPanels = ( + <div className={cx(styles.body)}> + <bodyToRender.Component model={bodyToRender} /> + </div> + ); + return ( <Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Custom}> - <CustomScrollbar autoHeightMin={'100%'}> - <div className={styles.canvasContent}> - <NavToolbarActions dashboard={model} /> - - {controls && ( - <div className={styles.controls}> - {controls.map((control) => ( - <control.Component key={control.state.key} model={control} /> - ))} - <SceneDebugger scene={model} key={'scene-debugger'} /> - </div> - )} - <div className={cx(styles.body)}> - <bodyToRender.Component model={bodyToRender} /> + {editPanel && <editPanel.Component model={editPanel} />} + {!editPanel && ( + <CustomScrollbar autoHeightMin={'100%'}> + <div className={styles.canvasContent}> + <NavToolbarActions dashboard={model} /> + {controls && <controls.Component model={controls} />} + {isEmpty ? emptyState : withPanels} </div> - </div> - </CustomScrollbar> + </CustomScrollbar> + )} {overlay && <overlay.Component model={overlay} />} </Page> ); @@ -66,17 +73,5 @@ function getStyles(theme: GrafanaTheme2) { gap: '8px', marginBottom: theme.spacing(2), }), - - controls: css({ - display: 'flex', - flexWrap: 'wrap', - alignItems: 'center', - gap: theme.spacing(1), - position: 'sticky', - top: 0, - background: theme.colors.background.canvas, - zIndex: theme.zIndex.navbarFixed, - padding: theme.spacing(2, 0), - }), }; } diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index abd5539e57f47..d46bf382f613a 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -2,14 +2,22 @@ import { Unsubscribable } from 'rxjs'; import { AppEvents } from '@grafana/data'; import { locationService } from '@grafana/runtime'; -import { SceneObjectUrlSyncHandler, SceneObjectUrlValues } from '@grafana/scenes'; +import { + SceneObjectBase, + SceneObjectState, + SceneObjectUrlSyncHandler, + SceneObjectUrlValues, + VizPanel, +} from '@grafana/scenes'; import appEvents from 'app/core/app_events'; import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer'; +import { buildPanelEditScene } from '../panel-edit/PanelEditor'; import { createDashboardEditViewFor } from '../settings/utils'; -import { findVizPanelByKey, isPanelClone } from '../utils/utils'; +import { findVizPanelByKey, getDashboardSceneFor, getLibraryPanel, isPanelClone } from '../utils/utils'; import { DashboardScene, DashboardSceneState } from './DashboardScene'; +import { LibraryVizPanel } from './LibraryVizPanel'; import { ViewPanelScene } from './ViewPanelScene'; import { DashboardRepeatsProcessedEvent } from './types'; @@ -19,7 +27,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { constructor(private _scene: DashboardScene) {} getKeys(): string[] { - return ['inspect', 'viewPanel', 'editview']; + return ['inspect', 'viewPanel', 'editPanel', 'editview']; } getUrlState(): SceneObjectUrlValues { @@ -28,14 +36,15 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { inspect: state.inspectPanelKey, viewPanel: state.viewPanelScene?.getUrlKey(), editview: state.editview?.getUrlKey(), + editPanel: state.editPanel?.getUrlKey() || undefined, }; } updateFromUrl(values: SceneObjectUrlValues): void { - const { inspectPanelKey, viewPanelScene, meta, isEditing } = this._scene.state; + const { inspectPanelKey, viewPanelScene, isEditing, editPanel } = this._scene.state; const update: Partial<DashboardSceneState> = {}; - if (typeof values.editview === 'string' && meta.canEdit) { + if (typeof values.editview === 'string' && this._scene.canEditDashboard()) { update.editview = createDashboardEditViewFor(values.editview); // If we are not in editing (for example after full page reload) @@ -50,15 +59,31 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { // Handle inspect object state if (typeof values.inspect === 'string') { - const panel = findVizPanelByKey(this._scene, values.inspect); + let panel = findVizPanelByKey(this._scene, values.inspect); if (!panel) { appEvents.emit(AppEvents.alertError, ['Panel not found']); locationService.partial({ inspect: null }); return; } + if (getLibraryPanel(panel)) { + this._handleLibraryPanel(panel, (p) => { + if (p.state.key === undefined) { + // Inspect drawer require a panel key to be set + throw new Error('library panel key is undefined'); + } + const drawer = new PanelInspectDrawer({ + $behaviors: [new ResolveInspectPanelByKey({ panelKey: p.state.key })], + }); + this._scene.setState({ overlay: drawer, inspectPanelKey: p.state.key }); + }); + return; + } + update.inspectPanelKey = values.inspect; - update.overlay = new PanelInspectDrawer({ panelRef: panel.getRef() }); + update.overlay = new PanelInspectDrawer({ + $behaviors: [new ResolveInspectPanelByKey({ panelKey: values.inspect })], + }); } else if (inspectPanelKey) { update.inspectPanelKey = undefined; update.overlay = undefined; @@ -67,6 +92,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { // Handle view panel state if (typeof values.viewPanel === 'string') { const panel = findVizPanelByKey(this._scene, values.viewPanel); + if (!panel) { // // If we are trying to view a repeat clone that can't be found it might be that the repeats have not been processed yet if (isPanelClone(values.viewPanel)) { @@ -79,16 +105,63 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { return; } + if (getLibraryPanel(panel)) { + this._handleLibraryPanel(panel, (p) => this._buildLibraryPanelViewScene(p)); + return; + } + update.viewPanelScene = new ViewPanelScene({ panelRef: panel.getRef() }); - } else if (viewPanelScene) { + } else if (viewPanelScene && values.viewPanel === null) { update.viewPanelScene = undefined; } + // Handle edit panel state + if (typeof values.editPanel === 'string') { + const panel = findVizPanelByKey(this._scene, values.editPanel); + if (!panel) { + console.warn(`Panel ${values.editPanel} not found`); + return; + } + + // If we are not in editing (for example after full page reload) + if (!isEditing) { + this._scene.onEnterEditMode(); + } + if (getLibraryPanel(panel)) { + this._handleLibraryPanel(panel, (p) => { + this._scene.setState({ editPanel: buildPanelEditScene(p) }); + }); + return; + } + update.editPanel = buildPanelEditScene(panel); + } else if (editPanel && values.editPanel === null) { + update.editPanel = undefined; + } + if (Object.keys(update).length > 0) { this._scene.setState(update); } } + private _buildLibraryPanelViewScene(vizPanel: VizPanel) { + this._scene.setState({ viewPanelScene: new ViewPanelScene({ panelRef: vizPanel.getRef() }) }); + } + + private _handleLibraryPanel(vizPanel: VizPanel, cb: (p: VizPanel) => void): void { + if (!(vizPanel.parent instanceof LibraryVizPanel)) { + throw new Error('Panel is not a child of a LibraryVizPanel'); + } + const libraryPanel = vizPanel.parent; + if (libraryPanel.state.isLoaded) { + cb(vizPanel); + } else { + libraryPanel.subscribeToState((n) => { + cb(n.panel!); + }); + libraryPanel.activate(); + } + } + private _handleViewRepeatClone(viewPanel: string) { if (!this._eventSub) { this._eventSub = this._scene.subscribeToEvent(DashboardRepeatsProcessedEvent, () => { @@ -101,3 +174,41 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { } } } + +interface ResolveInspectPanelByKeyState extends SceneObjectState { + panelKey: string; +} + +class ResolveInspectPanelByKey extends SceneObjectBase<ResolveInspectPanelByKeyState> { + constructor(state: ResolveInspectPanelByKeyState) { + super(state); + this.addActivationHandler(this._onActivate); + } + + private _onActivate = () => { + const parent = this.parent; + + if (!parent || !(parent instanceof PanelInspectDrawer)) { + throw new Error('ResolveInspectPanelByKey must be attached to a PanelInspectDrawer'); + } + + const dashboard = getDashboardSceneFor(parent); + if (!dashboard) { + return; + } + const panelId = this.state.panelKey; + let panel = findVizPanelByKey(dashboard, panelId); + + if (dashboard.state.editPanel) { + panel = dashboard.state.editPanel.state.vizManager.state.panel; + } + + if (dashboard.state.viewPanelScene && dashboard.state.viewPanelScene.state.body) { + panel = dashboard.state.viewPanelScene.state.body; + } + + if (panel) { + parent.setState({ panelRef: panel.getRef() }); + } + }; +} diff --git a/public/app/features/dashboard-scene/scene/GoToSnapshotOriginButton.test.tsx b/public/app/features/dashboard-scene/scene/GoToSnapshotOriginButton.test.tsx new file mode 100644 index 0000000000000..6e55b4d0ab0f7 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/GoToSnapshotOriginButton.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { config, locationService } from '@grafana/runtime'; +import { ConfirmModal } from '@grafana/ui'; + +import appEvents from '../../../core/app_events'; +import { ShowModalReactEvent } from '../../../types/events'; + +import { GoToSnapshotOriginButton } from './GoToSnapshotOriginButton'; + +describe('GoToSnapshotOriginButton component', () => { + beforeEach(async () => { + locationService.push('/'); + const location = window.location; + //@ts-ignore + delete window.location; + window.location = { + ...location, + href: 'http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash', + }; + jest.spyOn(appEvents, 'publish'); + }); + config.appUrl = 'http://snapshots.grafana.com/'; + + it('renders button and triggers onClick redirects to the original dashboard', () => { + render(<GoToSnapshotOriginButton originalURL={'/d/c0d2742f-b827-466d-9269-fb34d6af24ff'} />); + + // Check if the button renders with the correct testid + expect(screen.getByTestId('button-snapshot')).toBeInTheDocument(); + + // Simulate a button click + fireEvent.click(screen.getByTestId('button-snapshot')); + + expect(appEvents.publish).toHaveBeenCalledTimes(0); + expect(locationService.getLocation().pathname).toEqual('/d/c0d2742f-b827-466d-9269-fb34d6af24ff'); + expect(window.location.href).toBe('http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash'); + }); + + it('renders button and triggers onClick opens a confirmation modal', () => { + render(<GoToSnapshotOriginButton originalURL={'http://www.anotherdomain.com/'} />); + + // Check if the button renders with the correct testid + expect(screen.getByTestId('button-snapshot')).toBeInTheDocument(); + + // Simulate a button click + fireEvent.click(screen.getByTestId('button-snapshot')); + + expect(appEvents.publish).toHaveBeenCalledTimes(1); + expect(appEvents.publish).toHaveBeenCalledWith( + new ShowModalReactEvent( + expect.objectContaining({ + component: ConfirmModal, + }) + ) + ); + expect(locationService.getLocation().pathname).toEqual('/'); + expect(window.location.href).toBe('http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash'); + }); +}); diff --git a/public/app/features/dashboard-scene/scene/GoToSnapshotOriginButton.tsx b/public/app/features/dashboard-scene/scene/GoToSnapshotOriginButton.tsx new file mode 100644 index 0000000000000..28c5c802f4753 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/GoToSnapshotOriginButton.tsx @@ -0,0 +1,62 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { textUtil } from '@grafana/data'; +import { config, locationService } from '@grafana/runtime'; +import { ConfirmModal, ToolbarButton } from '@grafana/ui'; + +import appEvents from '../../../core/app_events'; +import { t } from '../../../core/internationalization'; +import { ShowModalReactEvent } from '../../../types/events'; + +export function GoToSnapshotOriginButton(props: { originalURL: string }) { + return ( + <ToolbarButton + key="button-snapshot" + data-testid="button-snapshot" + tooltip={t('dashboard.toolbar.open-original', 'Open original dashboard')} + icon="link" + onClick={() => onOpenSnapshotOriginalDashboard(props.originalURL)} + /> + ); +} + +const onOpenSnapshotOriginalDashboard = (originalUrl: string) => { + const relativeURL = originalUrl ?? ''; + const sanitizedRelativeURL = textUtil.sanitizeUrl(relativeURL); + try { + const sanitizedAppUrl = new URL(sanitizedRelativeURL, config.appUrl); + const appUrl = new URL(config.appUrl); + if (sanitizedAppUrl.host !== appUrl.host) { + appEvents.publish( + new ShowModalReactEvent({ + component: ConfirmModal, + props: { + title: 'Proceed to external site?', + modalClass: css({ + width: 'max-content', + maxWidth: '80vw', + }), + body: ( + <> + <p> + {`This link connects to an external website at`} <code>{relativeURL}</code> + </p> + <p>{"Are you sure you'd like to proceed?"}</p> + </> + ), + confirmVariant: 'primary', + confirmText: 'Proceed', + onConfirm: () => { + window.location.href = sanitizedAppUrl.href; + }, + }, + }) + ); + } else { + locationService.push(sanitizedRelativeURL); + } + } catch (err) { + console.error('Failed to open original dashboard', err); + } +}; diff --git a/public/app/features/dashboard-scene/scene/LibraryVizPanel.test.ts b/public/app/features/dashboard-scene/scene/LibraryVizPanel.test.ts new file mode 100644 index 0000000000000..39968af070297 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/LibraryVizPanel.test.ts @@ -0,0 +1,136 @@ +import 'whatwg-fetch'; +import { waitFor } from '@testing-library/dom'; +import { merge } from 'lodash'; +import { http, HttpResponse } from 'msw'; +import { setupServer, SetupServerApi } from 'msw/node'; + +import { setBackendSrv } from '@grafana/runtime'; +import { SceneGridItem, SceneGridLayout, VizPanel } from '@grafana/scenes'; +import { LibraryPanel } from '@grafana/schema'; +import { backendSrv } from 'app/core/services/backend_srv'; + +import { LibraryVizPanel } from './LibraryVizPanel'; +import { PanelRepeaterGridItem } from './PanelRepeaterGridItem'; + +describe('LibraryVizPanel', () => { + const server = setupServer(); + + beforeAll(() => { + setBackendSrv(backendSrv); + server.listen(); + }); + + afterAll(() => { + server.close(); + }); + + beforeEach(() => { + server.resetHandlers(); + }); + + it('should fetch and init', async () => { + setUpApiMock(server); + const libVizPanel = new LibraryVizPanel({ + name: 'My Library Panel', + title: 'Panel title', + uid: 'fdcvggvfy2qdca', + panelKey: 'lib-panel', + }); + libVizPanel.activate(); + await waitFor(() => { + expect(libVizPanel.state.panel).toBeInstanceOf(VizPanel); + }); + }); + + it('should change parent from SceneGridItem to PanelRepeaterGridItem if repeat is set', async () => { + setUpApiMock(server, { model: { repeat: 'query0', repeatDirection: 'h' } }); + const libVizPanel = new LibraryVizPanel({ + name: 'My Library Panel', + title: 'Panel title', + uid: 'fdcvggvfy2qdca', + panelKey: 'lib-panel', + }); + + const layout = new SceneGridLayout({ + children: [new SceneGridItem({ body: libVizPanel })], + }); + layout.activate(); + libVizPanel.activate(); + await waitFor(() => { + expect(layout.state.children[0]).toBeInstanceOf(PanelRepeaterGridItem); + }); + }); +}); + +function setUpApiMock( + server: SetupServerApi, + overrides: Omit<Partial<LibraryPanel>, 'model'> & { model?: Partial<LibraryPanel['model']> } = {} +) { + const libPanel: LibraryPanel = merge( + { + folderUid: 'general', + uid: 'fdcvggvfy2qdca', + name: 'My Library Panel', + type: 'timeseries', + description: '', + model: { + datasource: { + type: 'grafana-testdata-datasource', + uid: 'PD8C576611E62080A', + }, + description: '', + + maxPerRow: 4, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + showLegend: true, + }, + tooltip: { + maxHeight: 600, + mode: 'single', + sort: 'none', + }, + }, + targets: [ + { + datasource: { + type: 'grafana-testdata-datasource', + uid: 'PD8C576611E62080A', + }, + refId: 'A', + }, + ], + title: 'Panel Title', + type: 'timeseries', + }, + version: 6, + meta: { + folderName: 'General', + folderUid: '', + connectedDashboards: 1, + created: '2024-02-15T15:26:46Z', + updated: '2024-02-28T15:54:22Z', + createdBy: { + avatarUrl: '/avatar/46d229b033af06a191ff2267bca9ae56', + id: 1, + name: 'admin', + }, + updatedBy: { + avatarUrl: '/avatar/46d229b033af06a191ff2267bca9ae56', + id: 1, + name: 'admin', + }, + }, + }, + overrides + ); + + const libPanelMock: { result: LibraryPanel } = { + result: libPanel, + }; + + server.use(http.get('/api/library-elements/:uid', () => HttpResponse.json(libPanelMock))); +} diff --git a/public/app/features/dashboard-scene/scene/LibraryVizPanel.tsx b/public/app/features/dashboard-scene/scene/LibraryVizPanel.tsx index 5b0a02079dceb..6efd02644effc 100644 --- a/public/app/features/dashboard-scene/scene/LibraryVizPanel.tsx +++ b/public/app/features/dashboard-scene/scene/LibraryVizPanel.tsx @@ -1,68 +1,135 @@ import React from 'react'; -import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel, VizPanelMenu } from '@grafana/scenes'; +import { + SceneComponentProps, + SceneGridItem, + SceneGridLayout, + SceneObjectBase, + SceneObjectState, + VizPanel, + VizPanelMenu, + VizPanelState, +} from '@grafana/scenes'; +import { LibraryPanel } from '@grafana/schema'; import { PanelModel } from 'app/features/dashboard/state'; import { getLibraryPanel } from 'app/features/library-panels/state/api'; import { createPanelDataProvider } from '../utils/createPanelDataProvider'; -import { panelMenuBehavior } from './PanelMenuBehavior'; +import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks'; +import { panelLinksBehavior, panelMenuBehavior } from './PanelMenuBehavior'; +import { PanelNotices } from './PanelNotices'; +import { PanelRepeaterGridItem } from './PanelRepeaterGridItem'; interface LibraryVizPanelState extends SceneObjectState { // Library panels use title from dashboard JSON's panel model, not from library panel definition, hence we pass it. title: string; uid: string; name: string; - panel: VizPanel; + panel?: VizPanel; + isLoaded?: boolean; + panelKey: string; + _loadedPanel?: LibraryPanel; } export class LibraryVizPanel extends SceneObjectBase<LibraryVizPanelState> { static Component = LibraryPanelRenderer; - constructor({ uid, title, key, name }: Pick<LibraryVizPanelState, 'uid' | 'title' | 'key' | 'name'>) { + constructor(state: LibraryVizPanelState) { super({ - uid, - title, - key, - name, - panel: new VizPanel({ - title, - menu: new VizPanelMenu({ - $behaviors: [panelMenuBehavior], - }), - }), + panel: state.panel ?? getLoadingPanel(state.title, state.panelKey), + isLoaded: state.isLoaded ?? false, + ...state, }); this.addActivationHandler(this._onActivate); } private _onActivate = () => { - this.loadLibraryPanelFromPanelModel(); + if (!this.state.isLoaded) { + this.loadLibraryPanelFromPanelModel(); + } }; + public setPanelFromLibPanel(libPanel: LibraryPanel) { + if (this.state._loadedPanel?.version === libPanel.version) { + return; + } + + const libPanelModel = new PanelModel(libPanel.model); + + const vizPanelState: VizPanelState = { + title: libPanelModel.title, + key: this.state.panelKey, + options: libPanelModel.options ?? {}, + fieldConfig: libPanelModel.fieldConfig, + pluginId: libPanelModel.type, + pluginVersion: libPanelModel.pluginVersion, + displayMode: libPanelModel.transparent ? 'transparent' : undefined, + description: libPanelModel.description, + $data: createPanelDataProvider(libPanelModel), + menu: new VizPanelMenu({ $behaviors: [panelMenuBehavior] }), + titleItems: [ + new VizPanelLinks({ + rawLinks: libPanelModel.links, + menu: new VizPanelLinksMenu({ $behaviors: [panelLinksBehavior] }), + }), + new PanelNotices(), + ], + }; + + const panel = new VizPanel(vizPanelState); + const gridItem = this.parent; + + if (libPanelModel.repeat && gridItem instanceof SceneGridItem && gridItem.parent instanceof SceneGridLayout) { + this._parent = undefined; + const repeater = new PanelRepeaterGridItem({ + key: gridItem.state.key, + x: gridItem.state.x, + y: gridItem.state.y, + width: libPanelModel.repeatDirection === 'h' ? 24 : gridItem.state.width, + height: gridItem.state.height, + itemHeight: gridItem.state.height, + source: this, + variableName: libPanelModel.repeat, + repeatedPanels: [], + repeatDirection: libPanelModel.repeatDirection === 'h' ? 'h' : 'v', + maxPerRow: libPanelModel.maxPerRow, + }); + gridItem.parent.setState({ + children: gridItem.parent.state.children.map((child) => + child.state.key === gridItem.state.key ? repeater : child + ), + }); + } + + this.setState({ panel, _loadedPanel: libPanel, isLoaded: true, name: libPanel.name }); + } + private async loadLibraryPanelFromPanelModel() { - const vizPanel = this.state.panel; + let vizPanel = this.state.panel!; + try { const libPanel = await getLibraryPanel(this.state.uid, true); - const libPanelModel = new PanelModel(libPanel.model); - vizPanel.setState({ - options: libPanelModel.options ?? {}, - fieldConfig: libPanelModel.fieldConfig, - pluginVersion: libPanelModel.pluginVersion, - displayMode: libPanelModel.transparent ? 'transparent' : undefined, - description: libPanelModel.description, - $data: createPanelDataProvider(libPanelModel), - }); + this.setPanelFromLibPanel(libPanel); } catch (err) { vizPanel.setState({ - _pluginLoadError: 'Unable to load library panel: ' + this.state.uid, + _pluginLoadError: `Unable to load library panel: ${this.state.uid}`, }); } - - this.setState({ panel: vizPanel }); } } +function getLoadingPanel(title: string, panelKey: string) { + return new VizPanel({ + key: panelKey, + title, + menu: new VizPanelMenu({ + $behaviors: [panelMenuBehavior], + }), + }); +} + function LibraryPanelRenderer({ model }: SceneComponentProps<LibraryVizPanel>) { const { panel } = model.useState(); diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx new file mode 100644 index 0000000000000..ce001bb53bb07 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx @@ -0,0 +1,150 @@ +import { screen, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; +import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; + +import { selectors } from '@grafana/e2e-selectors'; +import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; + +import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; +import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; + +import { ToolbarActions } from './NavToolbarActions'; + +jest.mock('app/features/playlist/PlaylistSrv', () => ({ + playlistSrv: { + useState: jest.fn().mockReturnValue({ isPlaying: false }), + setState: jest.fn(), + isPlaying: true, + start: jest.fn(), + next: jest.fn(), + prev: jest.fn(), + stop: jest.fn(), + }, +})); + +describe('NavToolbarActions', () => { + describe('Give an already saved dashboard', () => { + it('Should show correct buttons when not in editing', async () => { + setup(); + + expect(screen.queryByText('Save dashboard')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Add visualization')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Add row')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Paste panel')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Add library panel')).not.toBeInTheDocument(); + expect(await screen.findByText('Edit')).toBeInTheDocument(); + expect(await screen.findByText('Share')).toBeInTheDocument(); + }); + + it('Should the correct buttons when playing a playlist', async () => { + jest.mocked(playlistSrv).useState.mockReturnValueOnce({ isPlaying: true }); + setup(); + + expect(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.prev)).toBeInTheDocument(); + expect(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.stop)).toBeInTheDocument(); + expect(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.next)).toBeInTheDocument(); + expect(screen.queryByText('Edit')).not.toBeInTheDocument(); + expect(screen.queryByText('Share')).not.toBeInTheDocument(); + }); + + it('Should call the playlist srv when using playlist controls', async () => { + jest.mocked(playlistSrv).useState.mockReturnValueOnce({ isPlaying: true }); + setup(); + + // Previous dashboard + expect(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.prev)).toBeInTheDocument(); + await userEvent.click(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.prev)); + expect(playlistSrv.prev).toHaveBeenCalledTimes(1); + + // Next dashboard + expect(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.next)).toBeInTheDocument(); + await userEvent.click(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.next)); + expect(playlistSrv.next).toHaveBeenCalledTimes(1); + + // Stop playlist + expect(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.stop)).toBeInTheDocument(); + await userEvent.click(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.stop)); + expect(playlistSrv.stop).toHaveBeenCalledTimes(1); + }); + + it('Should hide the playlist controls when it is not playing', async () => { + setup(); + expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.prev)).not.toBeInTheDocument(); + expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.stop)).not.toBeInTheDocument(); + expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.next)).not.toBeInTheDocument(); + }); + + it('Should show correct buttons when editing', async () => { + setup(); + + await userEvent.click(await screen.findByText('Edit')); + + expect(await screen.findByText('Save dashboard')).toBeInTheDocument(); + expect(await screen.findByText('Exit edit')).toBeInTheDocument(); + expect(await screen.findByLabelText('Add visualization')).toBeInTheDocument(); + expect(await screen.findByLabelText('Add row')).toBeInTheDocument(); + expect(await screen.findByLabelText('Paste panel')).toBeInTheDocument(); + expect(await screen.findByLabelText('Add library panel')).toBeInTheDocument(); + expect(screen.queryByText('Edit')).not.toBeInTheDocument(); + expect(screen.queryByText('Share')).not.toBeInTheDocument(); + expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.prev)).not.toBeInTheDocument(); + expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.stop)).not.toBeInTheDocument(); + expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.next)).not.toBeInTheDocument(); + }); + + it('Should show correct buttons when in settings menu', async () => { + setup(); + + await userEvent.click(await screen.findByText('Edit')); + await userEvent.click(await screen.findByText('Settings')); + + expect(await screen.findByText('Save dashboard')).toBeInTheDocument(); + expect(await screen.findByText('Back to dashboard')).toBeInTheDocument(); + expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.prev)).not.toBeInTheDocument(); + expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.stop)).not.toBeInTheDocument(); + expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.next)).not.toBeInTheDocument(); + }); + }); +}); + +let cleanUp = () => {}; + +function setup() { + const dashboard = transformSaveModelToScene({ + dashboard: { + title: 'hello', + uid: 'my-uid', + schemaVersion: 30, + panels: [], + version: 10, + }, + meta: { + canSave: true, + }, + }); + + // Clear any data layers + dashboard.setState({ $data: undefined }); + + const initialSaveModel = transformSceneToSaveModel(dashboard); + dashboard.setInitialSaveModel(initialSaveModel); + + dashboard.startUrlSync(); + + cleanUp(); + cleanUp = dashboard.activate(); + + const context = getGrafanaContextMock(); + + render( + <TestProvider grafanaContext={context}> + <ToolbarActions dashboard={dashboard} /> + </TestProvider> + ); + + const actions = context.chrome.state.getValue().actions; + + return { dashboard, actions }; +} diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 70068fe5cff08..ff9709f52e653 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -1,174 +1,551 @@ -import React from 'react'; +import { css } from '@emotion/css'; +import React, { useEffect, useState } from 'react'; -import { locationService } from '@grafana/runtime'; -import { Button } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { config, locationService } from '@grafana/runtime'; +import { Button, ButtonGroup, Dropdown, Icon, Menu, ToolbarButton, ToolbarButtonRow, useStyles2 } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; -import { t } from 'app/core/internationalization'; -import { DashNavButton } from 'app/features/dashboard/components/DashNav/DashNavButton'; +import { contextSrv } from 'app/core/core'; +import { Trans, t } from 'app/core/internationalization'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; +import { PanelEditor } from '../panel-edit/PanelEditor'; import { ShareModal } from '../sharing/ShareModal'; import { DashboardInteractions } from '../utils/interactions'; import { dynamicDashNavActions } from '../utils/registerDynamicDashNavAction'; import { DashboardScene } from './DashboardScene'; +import { GoToSnapshotOriginButton } from './GoToSnapshotOriginButton'; +import { LibraryVizPanel } from './LibraryVizPanel'; interface Props { dashboard: DashboardScene; } export const NavToolbarActions = React.memo<Props>(({ dashboard }) => { - const { actions = [], isEditing, viewPanelScene, isDirty, uid, meta, editview } = dashboard.useState(); - const toolbarActions = (actions ?? []).map((action) => <action.Component key={action.state.key} model={action} />); - const rightToolbarActions: JSX.Element[] = []; - const _legacyDashboardModel = getDashboardSrv().getCurrent(); + const actions = ( + <ToolbarButtonRow alignment="right"> + <ToolbarActions dashboard={dashboard} /> + </ToolbarButtonRow> + ); - if (uid && !editview) { - if (meta.canStar) { + return <AppChromeUpdate actions={actions} />; +}); + +NavToolbarActions.displayName = 'NavToolbarActions'; + +/** + * This part is split into a separate component to help test this + */ +export function ToolbarActions({ dashboard }: Props) { + const { + isEditing, + viewPanelScene, + isDirty, + uid, + meta, + editview, + editPanel, + hasCopiedPanel: copiedPanel, + } = dashboard.useState(); + const { isPlaying } = playlistSrv.useState(); + + const canSaveAs = contextSrv.hasEditPermissionInFolders; + const toolbarActions: ToolbarAction[] = []; + const buttonWithExtraMargin = useStyles2(getStyles); + const isEditingPanel = Boolean(editPanel); + const isViewingPanel = Boolean(viewPanelScene); + const isEditingLibraryPanel = useEditingLibraryPanel(editPanel); + const hasCopiedPanel = Boolean(copiedPanel); + // Means we are not in settings view, fullscreen panel or edit panel + const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel; + const isEditingAndShowingDashboard = isEditing && isShowingDashboard; + + toolbarActions.push({ + group: 'icon-actions', + condition: isEditingAndShowingDashboard, + render: () => ( + <ToolbarButton + key="add-visualization" + tooltip={'Add visualization'} + icon="graph-bar" + onClick={() => { + const id = dashboard.onCreateNewPanel(); + DashboardInteractions.toolbarAddButtonClicked({ item: 'add_visualization' }); + locationService.partial({ editPanel: id }); + }} + /> + ), + }); + + toolbarActions.push({ + group: 'icon-actions', + condition: isEditingAndShowingDashboard, + render: () => ( + <ToolbarButton + key="add-library-panel" + tooltip={'Add library panel'} + icon="library-panel" + onClick={() => { + dashboard.onCreateLibPanelWidget(); + DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' }); + }} + /> + ), + }); + + toolbarActions.push({ + group: 'icon-actions', + condition: isEditingAndShowingDashboard, + render: () => ( + <ToolbarButton + key="add-row" + tooltip={'Add row'} + icon="wrap-text" + onClick={() => { + dashboard.onCreateNewRow(); + DashboardInteractions.toolbarAddButtonClicked({ item: 'add_row' }); + }} + /> + ), + }); + + toolbarActions.push({ + group: 'icon-actions', + condition: isEditingAndShowingDashboard, + render: () => ( + <ToolbarButton + key="paste-panel" + disabled={!hasCopiedPanel} + tooltip={'Paste panel'} + icon="copy" + onClick={() => { + dashboard.pastePanel(); + DashboardInteractions.toolbarAddButtonClicked({ item: 'paste_panel' }); + }} + /> + ), + }); + + toolbarActions.push({ + group: 'icon-actions', + condition: uid && Boolean(meta.canStar) && isShowingDashboard, + render: () => { let desc = meta.isStarred ? t('dashboard.toolbar.unmark-favorite', 'Unmark as favorite') : t('dashboard.toolbar.mark-favorite', 'Mark as favorite'); - - toolbarActions.push( - <DashNavButton - key="star-dashboard-button" + return ( + <ToolbarButton tooltip={desc} - icon={meta.isStarred ? 'favorite' : 'star'} - iconType={meta.isStarred ? 'mono' : 'default'} - iconSize="lg" + icon={ + <Icon name={meta.isStarred ? 'favorite' : 'star'} size="lg" type={meta.isStarred ? 'mono' : 'default'} /> + } + key="star-dashboard-button" onClick={() => { DashboardInteractions.toolbarFavoritesClick(); dashboard.onStarDashboard(); }} /> ); - } - toolbarActions.push( - <DashNavButton - key="share-dashboard-button" - tooltip={t('dashboard.toolbar.share', 'Share dashboard')} - icon="share-alt" - iconSize="lg" + }, + }); + + const isDevEnv = config.buildInfo.env === 'development'; + + toolbarActions.push({ + group: 'icon-actions', + condition: isDevEnv && uid && isShowingDashboard, + render: () => ( + <ToolbarButton + key="view-in-old-dashboard-button" + tooltip={'Switch to old dashboard page'} + icon="apps" onClick={() => { - DashboardInteractions.toolbarShareClick(); - dashboard.showModal(new ShareModal({ dashboardRef: dashboard.getRef() })); + locationService.partial({ scenes: false }); }} /> - ); + ), + }); - toolbarActions.push( - <DashNavButton - key="view-in-old-dashboard-button" - tooltip={'View as dashboard'} - icon="apps" - onClick={() => locationService.push(`/d/${uid}`)} + toolbarActions.push({ + group: 'icon-actions', + condition: meta.isSnapshot && !isEditing, + render: () => ( + <GoToSnapshotOriginButton originalURL={dashboard.getInitialSaveModel()?.snapshot?.originalUrl ?? ''} /> + ), + }); + + toolbarActions.push({ + group: 'playlist-actions', + condition: isPlaying && isShowingDashboard && !isEditing, + render: () => ( + <ToolbarButton + key="play-list-prev" + data-testid={selectors.pages.Dashboard.DashNav.playlistControls.prev} + tooltip={t('dashboard.toolbar.playlist-previous', 'Go to previous dashboard')} + icon="backward" + onClick={() => playlistSrv.prev()} /> - ); - if (dynamicDashNavActions.left.length > 0) { - dynamicDashNavActions.left.map((action, index) => { - const Component = action.component; - const element = <Component dashboard={_legacyDashboardModel} />; - typeof action.index === 'number' - ? toolbarActions.splice(action.index, 0, element) - : toolbarActions.push(element); - }); - } - } + ), + }); - toolbarActions.push(<NavToolbarSeparator leftActionsSeparator key="separator" />); + toolbarActions.push({ + group: 'playlist-actions', + condition: isPlaying && isShowingDashboard && !isEditing, + render: () => ( + <ToolbarButton + key="play-list-stop" + onClick={() => playlistSrv.stop()} + data-testid={selectors.pages.Dashboard.DashNav.playlistControls.stop} + > + <Trans i18nKey="dashboard.toolbar.playlist-stop">Stop playlist</Trans> + </ToolbarButton> + ), + }); - if (dynamicDashNavActions.right.length > 0) { - dynamicDashNavActions.right.map((action, index) => { - const Component = action.component; - const element = <Component dashboard={_legacyDashboardModel} key={`button-custom-${index}`} />; - typeof action.index === 'number' - ? rightToolbarActions.splice(action.index, 0, element) - : rightToolbarActions.push(element); - }); + toolbarActions.push({ + group: 'playlist-actions', + condition: isPlaying && isShowingDashboard && !isEditing, + render: () => ( + <ToolbarButton + key="play-list-next" + data-testid={selectors.pages.Dashboard.DashNav.playlistControls.next} + tooltip={t('dashboard.toolbar.playlist-next', 'Go to next dashboard')} + icon="forward" + onClick={() => playlistSrv.next()} + narrow + /> + ), + }); - toolbarActions.push(...rightToolbarActions); + if (dynamicDashNavActions.left.length > 0 && !isEditingPanel) { + dynamicDashNavActions.left.map((action, index) => { + const props = { dashboard: getDashboardSrv().getCurrent()! }; + if (action.show(props)) { + const Component = action.component; + toolbarActions.push({ + group: 'icon-actions', + condition: true, + render: () => <Component {...props} />, + }); + } + }); } - if (viewPanelScene) { - toolbarActions.push( + toolbarActions.push({ + group: 'back-button', + condition: (isViewingPanel || isEditingPanel) && !isEditingLibraryPanel, + render: () => ( <Button onClick={() => { - locationService.partial({ viewPanel: null }); + locationService.partial({ viewPanel: null, editPanel: null }); + }} + tooltip="" + key="back" + variant="secondary" + size="sm" + icon="arrow-left" + > + Back to dashboard + </Button> + ), + }); + + toolbarActions.push({ + group: 'back-button', + condition: Boolean(editview), + render: () => ( + <Button + onClick={() => { + locationService.partial({ editview: null }); }} tooltip="" key="back" - variant="primary" fill="text" + variant="secondary" + size="sm" + icon="arrow-left" > Back to dashboard </Button> - ); + ), + }); - return <AppChromeUpdate actions={toolbarActions} />; - } + toolbarActions.push({ + group: 'main-buttons', + condition: uid && !isEditing && !meta.isSnapshot && !isPlaying, + render: () => ( + <Button + key="share-dashboard-button" + tooltip={t('dashboard.toolbar.share', 'Share dashboard')} + size="sm" + className={buttonWithExtraMargin} + fill="outline" + onClick={() => { + DashboardInteractions.toolbarShareClick(); + dashboard.showModal(new ShareModal({ dashboardRef: dashboard.getRef() })); + }} + > + Share + </Button> + ), + }); - if (!isEditing) { - if (dashboard.canEditDashboard()) { - toolbarActions.push( - <Button - onClick={() => { - dashboard.onEnterEditMode(); - }} - tooltip="Enter edit mode" - key="edit" - variant="primary" - icon="pen" - fill="text" - > - Edit - </Button> - ); - } - } else { - if (dashboard.canEditDashboard()) { - toolbarActions.push( - <Button - onClick={() => { - dashboard.onSave(); - }} - tooltip="Save as copy" - fill="text" - key="save-as" - > - Save as - </Button> - ); - toolbarActions.push( - <Button - onClick={() => { - dashboard.onDiscard(); - }} - tooltip="Discard changes" - fill="text" - key="discard" - variant="destructive" - > - Discard - </Button> + toolbarActions.push({ + group: 'main-buttons', + condition: !isEditing && dashboard.canEditDashboard() && !isViewingPanel && !isPlaying, + render: () => ( + <Button + onClick={() => { + dashboard.onEnterEditMode(); + }} + tooltip="Enter edit mode" + key="edit" + className={buttonWithExtraMargin} + variant="primary" + size="sm" + > + Edit + </Button> + ), + }); + + toolbarActions.push({ + group: 'settings', + condition: isEditing && dashboard.canEditDashboard() && isShowingDashboard, + render: () => ( + <Button + onClick={() => { + dashboard.onOpenSettings(); + }} + tooltip="Dashboard settings" + fill="text" + size="sm" + key="settings" + variant="secondary" + > + Settings + </Button> + ), + }); + + toolbarActions.push({ + group: 'main-buttons', + condition: isEditing && !meta.isNew && isShowingDashboard, + render: () => ( + <Button + onClick={() => dashboard.exitEditMode({ skipConfirm: false })} + tooltip="Exits edit mode and discards unsaved changes" + size="sm" + key="discard" + fill="text" + variant="primary" + > + Exit edit + </Button> + ), + }); + + toolbarActions.push({ + group: 'main-buttons', + condition: isEditingPanel && !isEditingLibraryPanel && !editview && !meta.isNew && !isViewingPanel, + render: () => ( + <Button + onClick={editPanel?.onDiscard} + tooltip="Discard panel changes" + size="sm" + key="discard" + fill="outline" + variant="destructive" + > + Discard panel changes + </Button> + ), + }); + + toolbarActions.push({ + group: 'main-buttons', + condition: isEditingPanel && isEditingLibraryPanel && !editview && !isViewingPanel, + render: () => ( + <Button + onClick={editPanel?.onDiscard} + tooltip="Discard library panel changes" + size="sm" + key="discardLibraryPanel" + fill="outline" + variant="destructive" + > + Discard library panel changes + </Button> + ), + }); + + toolbarActions.push({ + group: 'main-buttons', + condition: isEditingPanel && isEditingLibraryPanel && !editview && !isViewingPanel, + render: () => ( + <Button + onClick={editPanel?.onUnlinkLibraryPanel} + tooltip="Unlink library panel" + size="sm" + key="unlinkLibraryPanel" + fill="outline" + variant="secondary" + > + Unlink library panel + </Button> + ), + }); + + toolbarActions.push({ + group: 'main-buttons', + condition: isEditingPanel && isEditingLibraryPanel && !editview && !isViewingPanel, + render: () => ( + <Button + onClick={editPanel?.onSaveLibraryPanel} + tooltip="Save library panel" + size="sm" + key="saveLibraryPanel" + fill="outline" + variant="primary" + > + Save library panel + </Button> + ), + }); + + toolbarActions.push({ + group: 'main-buttons', + condition: isEditing && !isEditingLibraryPanel && (meta.canSave || canSaveAs), + render: () => { + // if we only can save + if (meta.isNew) { + return ( + <Button + onClick={() => { + DashboardInteractions.toolbarSaveClick(); + dashboard.openSaveDrawer({}); + }} + className={buttonWithExtraMargin} + tooltip="Save changes" + key="save" + size="sm" + variant={'primary'} + > + Save dashboard + </Button> + ); + } + + // If we only can save as copy + if (canSaveAs && !meta.canSave) { + return ( + <Button + onClick={() => { + DashboardInteractions.toolbarSaveClick(); + dashboard.openSaveDrawer({ saveAsCopy: true }); + }} + className={buttonWithExtraMargin} + tooltip="Save as copy" + key="save" + size="sm" + variant={isDirty ? 'primary' : 'secondary'} + > + Save as copy + </Button> + ); + } + + // If we can do both save and save as copy we show a button group with dropdown menu + const menu = ( + <Menu> + <Menu.Item + label="Save" + icon="save" + onClick={() => { + DashboardInteractions.toolbarSaveClick(); + dashboard.openSaveDrawer({}); + }} + /> + <Menu.Item + label="Save as copy" + icon="copy" + onClick={() => { + DashboardInteractions.toolbarSaveAsClick(); + dashboard.openSaveDrawer({ saveAsCopy: true }); + }} + /> + </Menu> ); - toolbarActions.push( - <Button - onClick={() => { - DashboardInteractions.toolbarSaveClick(); - dashboard.onSave(); - }} - tooltip="Save changes" - key="save" - disabled={!isDirty} - > - Save - </Button> + + return ( + <ButtonGroup className={buttonWithExtraMargin} key="save"> + <Button + onClick={() => { + DashboardInteractions.toolbarSaveClick(); + dashboard.openSaveDrawer({}); + }} + tooltip="Save changes" + size="sm" + variant={isDirty ? 'primary' : 'secondary'} + > + Save dashboard + </Button> + <Dropdown overlay={menu}> + <Button icon="angle-down" variant={isDirty ? 'primary' : 'secondary'} size="sm" /> + </Dropdown> + </ButtonGroup> ); + }, + }); + + const actionElements: React.ReactNode[] = []; + let lastGroup = ''; + + for (const action of toolbarActions) { + if (!action.condition) { + continue; + } + + if (lastGroup && lastGroup !== action.group) { + lastGroup && actionElements.push(<NavToolbarSeparator key={`${action.group}-separator`} />); } + + actionElements.push(action.render()); + lastGroup = action.group; } - return <AppChromeUpdate actions={toolbarActions} />; -}); + return actionElements; +} -NavToolbarActions.displayName = 'NavToolbarActions'; +function useEditingLibraryPanel(panelEditor?: PanelEditor) { + const [isEditingLibraryPanel, setEditingLibraryPanel] = useState<Boolean>(false); + + useEffect(() => { + if (panelEditor) { + const unsub = panelEditor.state.vizManager.subscribeToState((vizManagerState) => + setEditingLibraryPanel(vizManagerState.sourcePanel.resolve().parent instanceof LibraryVizPanel) + ); + return () => { + unsub.unsubscribe(); + }; + } + setEditingLibraryPanel(false); + return; + }, [panelEditor]); + + return isEditingLibraryPanel; +} + +interface ToolbarAction { + group: string; + condition?: boolean | string; + render: () => React.ReactNode; +} + +function getStyles(theme: GrafanaTheme2) { + return css({ margin: theme.spacing(0, 0.5) }); +} diff --git a/public/app/features/dashboard-scene/scene/PanelLinks.tsx b/public/app/features/dashboard-scene/scene/PanelLinks.tsx index 2a0bcd7501e5a..62643f15e38ef 100644 --- a/public/app/features/dashboard-scene/scene/PanelLinks.tsx +++ b/public/app/features/dashboard-scene/scene/PanelLinks.tsx @@ -1,10 +1,13 @@ import React from 'react'; -import { LinkModel } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; -import { Dropdown, Menu, ToolbarButton } from '@grafana/ui'; +import { DataLink, LinkModel } from '@grafana/data'; +import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; +import { Dropdown, Icon, Menu, PanelChrome, ToolbarButton } from '@grafana/ui'; + +import { getPanelLinks } from './PanelMenuBehavior'; interface VizPanelLinksState extends SceneObjectState { + rawLinks?: DataLink[]; links?: LinkModel[]; menu: VizPanelLinksMenu; } @@ -14,7 +17,24 @@ export class VizPanelLinks extends SceneObjectBase<VizPanelLinksState> { } function VizPanelLinksRenderer({ model }: SceneComponentProps<VizPanelLinks>) { - const { menu } = model.useState(); + const { menu, rawLinks } = model.useState(); + + if (!(model.parent instanceof VizPanel)) { + throw new Error('VizPanelLinks must be a child of VizPanel'); + } + + if (!rawLinks || rawLinks.length === 0) { + return null; + } + + if (rawLinks.length === 1) { + const link = getPanelLinks(model.parent)[0]; + return ( + <PanelChrome.TitleItem href={link.href} onClick={link.onClick} target={link.target} title={link.title}> + <Icon name="external-link-alt" size="md" /> + </PanelChrome.TitleItem> + ); + } return ( <Dropdown @@ -27,7 +47,7 @@ function VizPanelLinksRenderer({ model }: SceneComponentProps<VizPanelLinks>) { ); } -export class VizPanelLinksMenu extends SceneObjectBase<Omit<VizPanelLinksState, 'menu'>> { +export class VizPanelLinksMenu extends SceneObjectBase<Omit<VizPanelLinksState, 'menu' | 'rawLinks'>> { static Component = VizPanelLinksMenuRenderer; } diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx index 4e5a40c5fdb2d..d6080eed26150 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx @@ -22,7 +22,10 @@ import { import { contextSrv } from 'app/core/services/context_srv'; import { GetExploreUrlArguments } from 'app/core/utils/explore'; +import { buildPanelEditScene } from '../panel-edit/PanelEditor'; + import { DashboardScene } from './DashboardScene'; +import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks'; import { panelMenuBehavior } from './PanelMenuBehavior'; const mocks = { @@ -54,7 +57,7 @@ describe('panelMenuBehavior', () => { }); beforeAll(() => { - locationService.push('/scenes/dashboard/dash-1?from=now-5m&to=now'); + locationService.push('/d/dash-1?from=now-5m&to=now'); }); it('Given standard panel', async () => { @@ -71,9 +74,9 @@ describe('panelMenuBehavior', () => { expect(menu.state.items?.length).toBe(6); // verify view panel url keeps url params and adds viewPanel=<panel-key> - expect(menu.state.items?.[0].href).toBe('/scenes/dashboard/dash-1?from=now-5m&to=now&viewPanel=panel-12'); + expect(menu.state.items?.[0].href).toBe('/d/dash-1?from=now-5m&to=now&viewPanel=panel-12'); // verify edit url keeps url time range - expect(menu.state.items?.[1].href).toBe('/scenes/dashboard/dash-1/panel-edit/12?from=now-5m&to=now'); + expect(menu.state.items?.[1].href).toBe('/d/dash-1?from=now-5m&to=now&editPanel=12'); // verify share expect(menu.state.items?.[2].text).toBe('Share'); // verify explore url @@ -86,12 +89,36 @@ describe('panelMenuBehavior', () => { expect(getExploreArgs.scopedVars?.__sceneObject?.value).toBe(panel); // verify inspect url keeps url params and adds inspect=<panel-key> - expect(menu.state.items?.[4].href).toBe('/scenes/dashboard/dash-1?from=now-5m&to=now&inspect=panel-12'); + expect(menu.state.items?.[4].href).toBe('/d/dash-1?from=now-5m&to=now&inspect=panel-12'); expect(menu.state.items?.[4].subMenu).toBeDefined(); expect(menu.state.items?.[4].subMenu?.length).toBe(3); }); + it('should have reduced menu options when panel editor is open', async () => { + const { scene, menu, panel } = await buildTestScene({}); + scene.setState({ editPanel: buildPanelEditScene(panel) }); + panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false }); + + mocks.contextSrv.hasAccessToExplore.mockReturnValue(true); + mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore')); + + menu.activate(); + + await new Promise((r) => setTimeout(r, 1)); + + expect(menu.state.items?.length).toBe(4); + expect(menu.state.items?.[0].text).toBe('Share'); + expect(menu.state.items?.[1].text).toBe('Explore'); + expect(menu.state.items?.[2].text).toBe('Inspect'); + expect(menu.state.items?.[3].text).toBe('More...'); + expect(menu.state.items?.[3].subMenu).toBeDefined(); + + expect(menu.state.items?.[3].subMenu?.length).toBe(2); + expect(menu.state.items?.[3].subMenu?.[0].text).toBe('New alert rule'); + expect(menu.state.items?.[3].subMenu?.[1].text).toBe('Get help'); + }); + describe('when extending panel menu from plugins', () => { it('should contain menu item from link extension', async () => { getPluginLinkExtensionsMock.mockReturnValue({ @@ -468,10 +495,65 @@ describe('panelMenuBehavior', () => { ]) ); }); + + it('it should not contain remove and duplicate menu items when not in edit mode', async () => { + const { menu, panel } = await buildTestScene({}); + + panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false }); + + mocks.contextSrv.hasAccessToExplore.mockReturnValue(true); + mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore')); + + menu.activate(); + + await new Promise((r) => setTimeout(r, 1)); + + expect(menu.state.items?.find((i) => i.text === 'Remove')).toBeUndefined(); + const moreMenu = menu.state.items?.find((i) => i.text === 'More...')?.subMenu; + expect(moreMenu?.find((i) => i.text === 'Duplicate')).toBeUndefined(); + expect(moreMenu?.find((i) => i.text === 'Create library panel')).toBeUndefined(); + }); + + it('it should contain remove and duplicate menu items when in edit mode', async () => { + const { scene, menu, panel } = await buildTestScene({}); + scene.setState({ isEditing: true }); + + panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false }); + + mocks.contextSrv.hasAccessToExplore.mockReturnValue(true); + mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore')); + + menu.activate(); + + await new Promise((r) => setTimeout(r, 1)); + + expect(menu.state.items?.find((i) => i.text === 'Remove')).toBeDefined(); + const moreMenu = menu.state.items?.find((i) => i.text === 'More...')?.subMenu; + expect(moreMenu?.find((i) => i.text === 'Duplicate')).toBeDefined(); + expect(moreMenu?.find((i) => i.text === 'Create library panel')).toBeDefined(); + }); + + it('should only contain explore when embedded', async () => { + const { menu, panel } = await buildTestScene({ isEmbedded: true }); + + panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false }); + + mocks.contextSrv.hasAccessToExplore.mockReturnValue(true); + mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore')); + + menu.activate(); + + await new Promise((r) => setTimeout(r, 1)); + + expect(menu.state.items?.length).toBe(1); + expect(menu.state.items?.[0].text).toBe('Explore'); + }); }); }); -interface SceneOptions {} +interface SceneOptions { + isEmbedded?: boolean; +} async function buildTestScene(options: SceneOptions) { const menu = new VizPanelMenu({ @@ -483,6 +565,7 @@ async function buildTestScene(options: SceneOptions) { pluginId: 'table', key: 'panel-12', menu, + titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })], $variables: new SceneVariableSet({ variables: [new LocalValueVariable({ name: 'a', value: 'a', text: 'a' })], }), @@ -503,6 +586,7 @@ async function buildTestScene(options: SceneOptions) { }), meta: { canEdit: true, + isEmbedded: options.isEmbedded ?? false, }, body: new SceneGridLayout({ children: [ diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index caab523d9369b..55e23353af9be 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -1,56 +1,62 @@ import { InterpolateFunction, PanelMenuItem, + PanelPlugin, PluginExtensionPanelContext, PluginExtensionPoints, getTimeZone, + urlUtil, } from '@grafana/data'; import { config, getPluginLinkExtensions, locationService } from '@grafana/runtime'; -import { - LocalValueVariable, - SceneDataTransformer, - SceneGridRow, - SceneQueryRunner, - VizPanel, - VizPanelMenu, - sceneGraph, -} from '@grafana/scenes'; -import { DataQuery } from '@grafana/schema'; +import { LocalValueVariable, SceneGridRow, VizPanel, VizPanelMenu, sceneGraph } from '@grafana/scenes'; +import { DataQuery, OptionsWithLegend } from '@grafana/schema'; +import appEvents from 'app/core/app_events'; import { t } from 'app/core/internationalization'; -import { PanelModel } from 'app/features/dashboard/state'; +import { scenesPanelToRuleFormValues } from 'app/features/alerting/unified/utils/rule-form'; +import { shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; import { InspectTab } from 'app/features/inspector/types'; -import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; +import { getScenePanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils'; -import { addDataTrailPanelAction } from 'app/features/trails/dashboardIntegration'; +import { addDataTrailPanelAction } from 'app/features/trails/Integrations/dashboardIntegration'; +import { ShowConfirmModalEvent } from 'app/types/events'; import { ShareModal } from '../sharing/ShareModal'; import { DashboardInteractions } from '../utils/interactions'; -import { getDashboardUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; -import { getPanelIdForVizPanel } from '../utils/utils'; +import { getEditPanelUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; +import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils'; import { DashboardScene } from './DashboardScene'; import { LibraryVizPanel } from './LibraryVizPanel'; -import { VizPanelLinks } from './PanelLinks'; -import { ShareQueryDataProvider } from './ShareQueryDataProvider'; +import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks'; +import { UnlinkLibraryPanelModal } from './UnlinkLibraryPanelModal'; /** * Behavior is called when VizPanelMenu is activated (ie when it's opened). */ -export function panelMenuBehavior(menu: VizPanelMenu) { +export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) { const asyncFunc = async () => { // hm.. add another generic param to SceneObject to specify parent type? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const panel = menu.parent as VizPanel; + const parent = panel.parent; const plugin = panel.getPlugin(); - const location = locationService.getLocation(); const items: PanelMenuItem[] = []; const moreSubMenu: PanelMenuItem[] = []; - const inspectSubMenu: PanelMenuItem[] = []; - const panelId = getPanelIdForVizPanel(panel); - const dashboard = panel.getRoot(); + const dashboard = getDashboardSceneFor(panel); + const { isEmbedded } = dashboard.state.meta; + const exploreMenuItem = await getExploreMenuItem(panel); + + // For embedded dashboards we only have explore action for now + if (isEmbedded) { + if (exploreMenuItem) { + menu.setState({ items: [exploreMenuItem] }); + } + return; + } - if (dashboard instanceof DashboardScene) { + const isEditingPanel = Boolean(dashboard.state.editPanel); + if (!isEditingPanel) { items.push({ text: t('panel.header-menu.view', `View`), iconClassName: 'eye', @@ -58,132 +64,132 @@ export function panelMenuBehavior(menu: VizPanelMenu) { onClick: () => DashboardInteractions.panelMenuItemClicked('view'), href: getViewPanelUrl(panel), }); + } - if (dashboard.canEditDashboard()) { - // We could check isEditing here but I kind of think this should always be in the menu, - // and going into panel edit should make the dashboard go into edit mode is it's not already - items.push({ - text: t('panel.header-menu.edit', `Edit`), - iconClassName: 'eye', - shortcut: 'e', - onClick: () => () => DashboardInteractions.panelMenuItemClicked('edit'), - href: getDashboardUrl({ - uid: dashboard.state.uid, - subPath: `/panel-edit/${panelId}`, - currentQueryParams: location.search, - useExperimentalURL: true, - }), - }); - } - + if (dashboard.canEditDashboard() && !isRepeat && !isEditingPanel) { + // We could check isEditing here but I kind of think this should always be in the menu, + // and going into panel edit should make the dashboard go into edit mode is it's not already items.push({ - text: t('panel.header-menu.share', `Share`), - iconClassName: 'share-alt', + text: t('panel.header-menu.edit', `Edit`), + iconClassName: 'eye', + shortcut: 'e', + onClick: () => DashboardInteractions.panelMenuItemClicked('edit'), + href: getEditPanelUrl(getPanelIdForVizPanel(panel)), + }); + } + + items.push({ + text: t('panel.header-menu.share', `Share`), + iconClassName: 'share-alt', + onClick: () => { + DashboardInteractions.panelMenuItemClicked('share'); + dashboard.showModal(new ShareModal({ panelRef: panel.getRef(), dashboardRef: dashboard.getRef() })); + }, + shortcut: 'p s', + }); + + if (dashboard.state.isEditing && !isRepeat && !isEditingPanel) { + moreSubMenu.push({ + text: t('panel.header-menu.duplicate', `Duplicate`), onClick: () => { - DashboardInteractions.panelMenuItemClicked('share'); - dashboard.showModal(new ShareModal({ panelRef: panel.getRef(), dashboardRef: dashboard.getRef() })); + DashboardInteractions.panelMenuItemClicked('duplicate'); + dashboard.duplicatePanel(panel); }, - shortcut: 'p s', + shortcut: 'p d', }); + } - if (panel.parent instanceof LibraryVizPanel) { - // TODO: Implement lib panel unlinking + if (!isEditingPanel) { + moreSubMenu.push({ + text: t('panel.header-menu.copy', `Copy`), + onClick: () => { + DashboardInteractions.panelMenuItemClicked('copy'); + dashboard.copyPanel(panel); + }, + }); + } + + if (dashboard.state.isEditing && !isRepeat && !isEditingPanel) { + if (parent instanceof LibraryVizPanel) { + moreSubMenu.push({ + text: t('panel.header-menu.unlink-library-panel', `Unlink library panel`), + onClick: () => { + DashboardInteractions.panelMenuItemClicked('unlinkLibraryPanel'); + dashboard.showModal( + new UnlinkLibraryPanelModal({ + panelRef: parent.getRef(), + }) + ); + }, + }); } else { moreSubMenu.push({ text: t('panel.header-menu.create-library-panel', `Create library panel`), - iconClassName: 'share-alt', onClick: () => { DashboardInteractions.panelMenuItemClicked('createLibraryPanel'); dashboard.showModal( new ShareModal({ panelRef: panel.getRef(), dashboardRef: dashboard.getRef(), - activeTab: 'Library panel', + activeTab: shareDashboardType.libraryPanel, }) ); }, }); } - - if (config.featureToggles.datatrails) { - addDataTrailPanelAction(dashboard, panel, items); - } } - const exploreUrl = await tryGetExploreUrlForPanel(panel); - if (exploreUrl) { - items.push({ - text: t('panel.header-menu.explore', `Explore`), - iconClassName: 'compass', - shortcut: 'p x', - onClick: () => DashboardInteractions.panelMenuItemClicked('explore'), - href: exploreUrl, + moreSubMenu.push({ + text: t('panel.header-menu.new-alert-rule', `New alert rule`), + onClick: (e) => onCreateAlert(panel), + }); + + if (hasLegendOptions(panel.state.options) && !isEditingPanel) { + moreSubMenu.push({ + text: panel.state.options.legend.showLegend + ? t('panel.header-menu.hide-legend', 'Hide legend') + : t('panel.header-menu.show-legend', 'Show legend'), + onClick: (e) => { + e.preventDefault(); + toggleVizPanelLegend(panel); + }, + shortcut: 'p l', }); } - if (plugin && !plugin.meta.skipDataQuery) { - inspectSubMenu.push({ - text: t('panel.header-menu.inspect-data', `Data`), - href: getInspectUrl(panel, InspectTab.Data), - onClick: (e) => { + if (dashboard.canEditDashboard() && plugin && !plugin.meta.skipDataQuery && !isRepeat) { + moreSubMenu.push({ + text: t('panel.header-menu.get-help', 'Get help'), + onClick: (e: React.MouseEvent) => { e.preventDefault(); - locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.Data }); - DashboardInteractions.panelMenuInspectClicked(InspectTab.Data); + onInspectPanel(panel, InspectTab.Help); }, }); + } - if (dashboard instanceof DashboardScene && dashboard.state.meta.canEdit) { - inspectSubMenu.push({ - text: t('panel.header-menu.query', `Query`), - href: getInspectUrl(panel, InspectTab.Query), - onClick: (e) => { - e.preventDefault(); - locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.Query }); - DashboardInteractions.panelMenuInspectClicked(InspectTab.Query); - }, - }); - } + if (config.featureToggles.datatrails) { + addDataTrailPanelAction(dashboard, panel, items); } - inspectSubMenu.push({ - text: t('panel.header-menu.inspect-json', `Panel JSON`), - href: getInspectUrl(panel, InspectTab.JSON), - onClick: (e) => { - e.preventDefault(); - locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.JSON }); - DashboardInteractions.panelMenuInspectClicked(InspectTab.JSON); - }, - }); + if (exploreMenuItem) { + items.push(exploreMenuItem); + } - items.push({ - text: t('panel.header-menu.inspect', `Inspect`), - iconClassName: 'info-circle', - shortcut: 'i', - href: getInspectUrl(panel), - onClick: (e) => { - if (!e.isDefaultPrevented()) { - locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.Data }); - DashboardInteractions.panelMenuInspectClicked(InspectTab.Data); - } - }, - subMenu: inspectSubMenu.length > 0 ? inspectSubMenu : undefined, + items.push(getInspectMenuItem(plugin, panel, dashboard)); + + const { extensions } = getPluginLinkExtensions({ + extensionPointId: PluginExtensionPoints.DashboardPanelMenu, + context: createExtensionContext(panel, dashboard), + limitPerPlugin: 3, }); - if (dashboard instanceof DashboardScene) { - const { extensions } = getPluginLinkExtensions({ - extensionPointId: PluginExtensionPoints.DashboardPanelMenu, - context: createExtensionContext(panel, dashboard), - limitPerPlugin: 3, + if (extensions.length > 0 && !dashboard.state.isEditing) { + items.push({ + text: 'Extensions', + iconClassName: 'plug', + type: 'submenu', + subMenu: createExtensionSubMenu(extensions), }); - - if (extensions.length > 0 && !dashboard.state.isEditing) { - items.push({ - text: 'Extensions', - iconClassName: 'plug', - type: 'submenu', - subMenu: createExtensionSubMenu(extensions), - }); - } } if (moreSubMenu.length) { @@ -198,58 +204,146 @@ export function panelMenuBehavior(menu: VizPanelMenu) { }); } + if (dashboard.state.isEditing && !isRepeat && !isEditingPanel) { + items.push({ + text: '', + type: 'divider', + }); + + items.push({ + text: t('panel.header-menu.remove', `Remove`), + iconClassName: 'trash-alt', + onClick: () => { + DashboardInteractions.panelMenuItemClicked('remove'); + onRemovePanel(dashboard, panel); + }, + shortcut: 'p r', + }); + } + menu.setState({ items }); }; asyncFunc(); } -/** - * Behavior is called when VizPanelLinksMenu is activated (when it's opened). - */ -export function getPanelLinksBehavior(panel: PanelModel) { - return (panelLinksMenu: VizPanelLinks) => { - const interpolate: InterpolateFunction = (v, scopedVars) => { - return sceneGraph.interpolate(panelLinksMenu, v, scopedVars); - }; +export const repeatPanelMenuBehavior = (menu: VizPanelMenu) => panelMenuBehavior(menu, true); - const linkSupplier = getPanelLinksSupplier(panel, interpolate); +async function getExploreMenuItem(panel: VizPanel): Promise<PanelMenuItem | undefined> { + const exploreUrl = await tryGetExploreUrlForPanel(panel); + if (!exploreUrl) { + return undefined; + } - if (!linkSupplier) { - return; - } + return { + text: t('panel.header-menu.explore', `Explore`), + iconClassName: 'compass', + shortcut: 'p x', + onClick: () => DashboardInteractions.panelMenuItemClicked('explore'), + href: exploreUrl, + }; +} - const panelLinks = linkSupplier && linkSupplier.getLinks(interpolate); +function getInspectMenuItem( + plugin: PanelPlugin | undefined, + panel: VizPanel, + dashboard: DashboardScene +): PanelMenuItem { + const inspectSubMenu: PanelMenuItem[] = []; - const links = panelLinks.map((panelLink) => ({ - ...panelLink, - onClick: (e: any, origin: any) => { - DashboardInteractions.panelLinkClicked({ has_multiple_links: panelLinks.length > 1 }); - panelLink.onClick?.(e, origin); + if (plugin && !plugin.meta.skipDataQuery) { + inspectSubMenu.push({ + text: t('panel.header-menu.inspect-data', `Data`), + href: getInspectUrl(panel, InspectTab.Data), + onClick: (e) => { + e.preventDefault(); + locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.Data }); + DashboardInteractions.panelMenuInspectClicked(InspectTab.Data); }, - })); - panelLinksMenu.setState({ links }); + }); + + if (dashboard instanceof DashboardScene && dashboard.state.meta.canEdit) { + inspectSubMenu.push({ + text: t('panel.header-menu.query', `Query`), + href: getInspectUrl(panel, InspectTab.Query), + onClick: (e) => { + e.preventDefault(); + locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.Query }); + DashboardInteractions.panelMenuInspectClicked(InspectTab.Query); + }, + }); + } + } + + inspectSubMenu.push({ + text: t('panel.header-menu.inspect-json', `Panel JSON`), + href: getInspectUrl(panel, InspectTab.JSON), + onClick: (e) => { + e.preventDefault(); + locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.JSON }); + DashboardInteractions.panelMenuInspectClicked(InspectTab.JSON); + }, + }); + + return { + text: t('panel.header-menu.inspect', `Inspect`), + iconClassName: 'info-circle', + shortcut: 'i', + href: getInspectUrl(panel), + onClick: (e) => { + if (!e.isDefaultPrevented()) { + locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.Data }); + DashboardInteractions.panelMenuInspectClicked(InspectTab.Data); + } + }, + subMenu: inspectSubMenu.length > 0 ? inspectSubMenu : undefined, }; } -function createExtensionContext(panel: VizPanel, dashboard: DashboardScene): PluginExtensionPanelContext { - const timeRange = sceneGraph.getTimeRange(panel); - let queryRunner = panel.state.$data; - let targets: DataQuery[] = []; - const id = getPanelIdForVizPanel(panel); - - if (queryRunner instanceof SceneDataTransformer) { - queryRunner = queryRunner.state.$data; +/** + * Behavior is called when VizPanelLinksMenu is activated (when it's opened). + */ +export function panelLinksBehavior(panelLinksMenu: VizPanelLinksMenu) { + if (!(panelLinksMenu.parent instanceof VizPanelLinks)) { + throw new Error('parent of VizPanelLinksMenu must be VizPanelLinks'); } + const panel = panelLinksMenu.parent.parent; - if (queryRunner instanceof SceneQueryRunner) { - targets = queryRunner.state.queries; + if (!(panel instanceof VizPanel)) { + throw new Error('parent of VizPanelLinks must be VizPanel'); } - if (queryRunner instanceof ShareQueryDataProvider) { - targets = [queryRunner.state.query]; + panelLinksMenu.setState({ links: getPanelLinks(panel) }); +} + +export function getPanelLinks(panel: VizPanel) { + const interpolate: InterpolateFunction = (v, scopedVars) => { + return sceneGraph.interpolate(panel, v, scopedVars); + }; + + const linkSupplier = getScenePanelLinksSupplier(panel, interpolate); + + if (!linkSupplier) { + return []; } + const panelLinks = linkSupplier.getLinks(interpolate); + + return panelLinks.map((panelLink) => ({ + ...panelLink, + onClick: (e: any, origin: any) => { + DashboardInteractions.panelLinkClicked({ has_multiple_links: panelLinks.length > 1 }); + panelLink.onClick?.(e, origin); + }, + })); +} + +function createExtensionContext(panel: VizPanel, dashboard: DashboardScene): PluginExtensionPanelContext { + const timeRange = sceneGraph.getTimeRange(panel); + let queryRunner = getQueryRunnerFor(panel); + const targets: DataQuery[] = queryRunner?.state.queries as DataQuery[]; + const id = getPanelIdForVizPanel(panel); + let scopedVars = {}; // Handle panel repeats scenario @@ -297,3 +391,54 @@ function createExtensionContext(panel: VizPanel, dashboard: DashboardScene): Plu data: queryRunner?.state.data, }; } + +export function onRemovePanel(dashboard: DashboardScene, panel: VizPanel) { + appEvents.publish( + new ShowConfirmModalEvent({ + title: 'Remove panel', + text: 'Are you sure you want to remove this panel?', + icon: 'trash-alt', + yesText: 'Remove', + onConfirm: () => dashboard.removePanel(panel), + }) + ); +} + +const onCreateAlert = async (panel: VizPanel) => { + DashboardInteractions.panelMenuItemClicked('create-alert'); + + const formValues = await scenesPanelToRuleFormValues(panel); + const ruleFormUrl = urlUtil.renderUrl('/alerting/new', { + defaults: JSON.stringify(formValues), + returnTo: location.pathname + location.search, + }); + + locationService.push(ruleFormUrl); + + DashboardInteractions.panelMenuItemClicked('create-alert'); +}; + +export function toggleVizPanelLegend(vizPanel: VizPanel): void { + const options = vizPanel.state.options; + if (hasLegendOptions(options) && typeof options.legend.showLegend === 'boolean') { + vizPanel.onOptionsChange({ + legend: { + showLegend: options.legend.showLegend ? false : true, + }, + }); + } + + DashboardInteractions.panelMenuItemClicked('toggleLegend'); +} + +function hasLegendOptions(optionsWithLegend: unknown): optionsWithLegend is OptionsWithLegend { + return optionsWithLegend != null && typeof optionsWithLegend === 'object' && 'legend' in optionsWithLegend; +} + +const onInspectPanel = (vizPanel: VizPanel, tab?: InspectTab) => { + locationService.partial({ + inspect: vizPanel.state.key, + inspectTab: tab, + }); + DashboardInteractions.panelMenuInspectClicked(tab ?? InspectTab.Data); +}; diff --git a/public/app/features/dashboard-scene/scene/PanelRepeaterGridItem.test.tsx b/public/app/features/dashboard-scene/scene/PanelRepeaterGridItem.test.tsx index ae74628a711f2..ea1a364790813 100644 --- a/public/app/features/dashboard-scene/scene/PanelRepeaterGridItem.test.tsx +++ b/public/app/features/dashboard-scene/scene/PanelRepeaterGridItem.test.tsx @@ -98,7 +98,7 @@ describe('PanelRepeaterGridItem', () => { expect(repeater.state.itemHeight).toBe(5); }); - it('When updating variable should update repeats', async () => { + it('Should update repeats when updating variable', async () => { const { scene, repeater, variable } = buildPanelRepeaterScene({ variableQueryTime: 0 }); activateFullSceneTree(scene); @@ -107,4 +107,13 @@ describe('PanelRepeaterGridItem', () => { expect(repeater.state.repeatedPanels?.length).toBe(2); }); + + it('Should fall back to default variable if specified variable cannot be found', () => { + const { scene, repeater } = buildPanelRepeaterScene({ variableQueryTime: 0 }); + scene.setState({ $variables: undefined }); + activateFullSceneTree(scene); + expect(repeater.state.repeatedPanels?.[0].state.$variables?.state.variables[0].state.name).toBe( + '_____default_sys_repeat_var_____' + ); + }); }); diff --git a/public/app/features/dashboard-scene/scene/PanelRepeaterGridItem.tsx b/public/app/features/dashboard-scene/scene/PanelRepeaterGridItem.tsx index fc0140997db18..1ce4054f67e58 100644 --- a/public/app/features/dashboard-scene/scene/PanelRepeaterGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/PanelRepeaterGridItem.tsx @@ -6,7 +6,6 @@ import { VizPanel, SceneObjectBase, VariableDependencyConfig, - SceneVariable, SceneGridLayout, SceneVariableSet, SceneComponentProps, @@ -15,19 +14,24 @@ import { sceneGraph, MultiValueVariable, LocalValueVariable, + CustomVariable, + VizPanelMenu, + VizPanelState, } from '@grafana/scenes'; import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants'; import { getMultiVariableValues } from '../utils/utils'; +import { LibraryVizPanel } from './LibraryVizPanel'; +import { repeatPanelMenuBehavior } from './PanelMenuBehavior'; import { DashboardRepeatsProcessedEvent } from './types'; interface PanelRepeaterGridItemState extends SceneGridItemStateLike { - source: VizPanel; + source: VizPanel | LibraryVizPanel; repeatedPanels?: VizPanel[]; variableName: string; itemHeight?: number; - repeatDirection?: RepeatDirection | string; + repeatDirection?: RepeatDirection; maxPerRow?: number; } @@ -36,11 +40,9 @@ export type RepeatDirection = 'v' | 'h'; export class PanelRepeaterGridItem extends SceneObjectBase<PanelRepeaterGridItemState> implements SceneGridItemLike { protected _variableDependency = new VariableDependencyConfig(this, { variableNames: [this.state.variableName], - onVariableUpdatesCompleted: this._onVariableChanged.bind(this), + onVariableUpdateCompleted: this._onVariableUpdateCompleted.bind(this), }); - private _isWaitingForVariables = false; - public constructor(state: PanelRepeaterGridItemState) { super(state); @@ -49,26 +51,11 @@ export class PanelRepeaterGridItem extends SceneObjectBase<PanelRepeaterGridItem private _activationHandler() { this._subs.add(this.subscribeToState((newState, prevState) => this._handleGridResize(newState, prevState))); - - // If we our variable is ready we can process repeats on activation - if (sceneGraph.hasVariableDependencyInLoadingState(this)) { - this._isWaitingForVariables = true; - } else { - this._performRepeat(); - } + this._performRepeat(); } - private _onVariableChanged(changedVariables: Set<SceneVariable>, dependencyChanged: boolean): void { - if (dependencyChanged) { - this._performRepeat(); - return; - } - - // If we are waiting for variables and the variable is no longer loading then we are ready to repeat as well - if (this._isWaitingForVariables && !sceneGraph.hasVariableDependencyInLoadingState(this)) { - this._isWaitingForVariables = false; - this._performRepeat(); - } + private _onVariableUpdateCompleted(): void { + this._performRepeat(); } /** @@ -97,32 +84,46 @@ export class PanelRepeaterGridItem extends SceneObjectBase<PanelRepeaterGridItem } private _performRepeat() { - const variable = sceneGraph.lookupVariable(this.state.variableName, this); - if (!variable) { - console.error('SceneGridItemRepeater: Variable not found'); + if (this._variableDependency.hasDependencyInLoadingState()) { return; } + const variable = + sceneGraph.lookupVariable(this.state.variableName, this) ?? + new CustomVariable({ + name: '_____default_sys_repeat_var_____', + options: [], + value: '', + text: '', + query: 'A', + }); + if (!(variable instanceof MultiValueVariable)) { console.error('PanelRepeaterGridItem: Variable is not a MultiValueVariable'); return; } - const panelToRepeat = this.state.source; + let panelToRepeat = + this.state.source instanceof LibraryVizPanel ? this.state.source.state.panel! : this.state.source; const { values, texts } = getMultiVariableValues(variable); const repeatedPanels: VizPanel[] = []; - // Loop through variable values and create repeates + // Loop through variable values and create repeats for (let index = 0; index < values.length; index++) { - const clone = panelToRepeat.clone({ + const cloneState: Partial<VizPanelState> = { $variables: new SceneVariableSet({ variables: [ new LocalValueVariable({ name: variable.state.name, value: values[index], text: String(texts[index]) }), ], }), key: `${panelToRepeat.state.key}-clone-${index}`, - }); - + }; + if (index > 0) { + cloneState.menu = new VizPanelMenu({ + $behaviors: [repeatPanelMenuBehavior], + }); + } + const clone = panelToRepeat.clone(cloneState); repeatedPanels.push(clone); } @@ -195,7 +196,7 @@ function useLayoutStyle(direction: RepeatDirection, itemCount: number, maxPerRow if (direction === 'h') { const rowCount = Math.ceil(itemCount / maxPerRow); - const columnCount = Math.ceil(itemCount / rowCount); + const columnCount = Math.min(itemCount, maxPerRow); return css({ display: 'grid', diff --git a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx index 204394c372f78..e6d18e149b773 100644 --- a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx @@ -50,6 +50,19 @@ describe('RowRepeaterBehavior', () => { expect(rowAtTheBottom.state.y).toBe(40); }); + it('Should push row at the bottom down and also offset its children', () => { + const rowAtTheBottom = grid.state.children[6] as SceneGridRow; + const rowChildOne = rowAtTheBottom.state.children[0] as SceneGridItem; + const rowChildTwo = rowAtTheBottom.state.children[1] as SceneGridItem; + + expect(rowAtTheBottom.state.title).toBe('Row at the bottom'); + + // Panel at the top is 10, each row is (1+5)*5 = 30, so the grid item below it should be 40 + expect(rowAtTheBottom.state.y).toBe(40); + expect(rowChildOne.state.y).toBe(41); + expect(rowChildTwo.state.y).toBe(49); + }); + it('Should handle second repeat cycle and update remove old repeats', async () => { // trigger another repeat cycle by changing the variable const variable = scene.state.$variables!.state.variables[0] as TestVariable; @@ -111,6 +124,26 @@ function buildScene(options: SceneOptions) { width: 24, height: 5, title: 'Row at the bottom', + children: [ + new SceneGridItem({ + key: 'griditem-2', + x: 0, + y: 17, + body: new SceneCanvasText({ + key: 'canvas-2', + text: 'Panel inside row, server = $server', + }), + }), + new SceneGridItem({ + key: 'griditem-3', + x: 0, + y: 25, + body: new SceneCanvasText({ + key: 'canvas-3', + text: 'Panel inside row, server = $server', + }), + }), + ], }), ], }); diff --git a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts index 70ada7a69cce8..476fc31d6370f 100644 --- a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts +++ b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts @@ -7,7 +7,6 @@ import { SceneGridRow, SceneObjectBase, SceneObjectState, - SceneVariable, SceneVariableSet, VariableDependencyConfig, VariableValueSingle, @@ -29,11 +28,9 @@ interface RowRepeaterBehaviorState extends SceneObjectState { export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorState> { protected _variableDependency = new VariableDependencyConfig(this, { variableNames: [this.state.variableName], - onVariableUpdatesCompleted: this._onVariableChanged.bind(this), + onVariableUpdateCompleted: this._onVariableUpdateCompleted.bind(this), }); - private _isWaitingForVariables = false; - public constructor(state: RowRepeaterBehaviorState) { super(state); @@ -41,28 +38,18 @@ export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorStat } private _activationHandler() { - // If we our variable is ready we can process repeats on activation - if (sceneGraph.hasVariableDependencyInLoadingState(this)) { - this._isWaitingForVariables = true; - } else { - this._performRepeat(); - } + this._performRepeat(); } - private _onVariableChanged(changedVariables: Set<SceneVariable>, dependencyChanged: boolean): void { - if (dependencyChanged) { - this._performRepeat(); - return; - } - - // If we are waiting for variables and the variable is no longer loading then we are ready to repeat as well - if (this._isWaitingForVariables && !sceneGraph.hasVariableDependencyInLoadingState(this)) { - this._isWaitingForVariables = false; - this._performRepeat(); - } + private _onVariableUpdateCompleted(): void { + this._performRepeat(); } private _performRepeat() { + if (this._variableDependency.hasDependencyInLoadingState()) { + return; + } + const variable = sceneGraph.lookupVariable(this.state.variableName, this.parent?.parent!); if (!variable) { @@ -193,8 +180,12 @@ function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows: const diff = maxYOfRows - firstChildAfterY; for (const child of childrenAfter) { - if (child.state.y! < maxYOfRows) { - child.setState({ y: child.state.y! + diff }); + child.setState({ y: child.state.y! + diff }); + + if (child instanceof SceneGridRow) { + for (const rowChild of child.state.children) { + rowChild.setState({ y: rowChild.state.y! + diff }); + } } } } diff --git a/public/app/features/dashboard-scene/scene/ShareQueryDataProvider.test.ts b/public/app/features/dashboard-scene/scene/ShareQueryDataProvider.test.ts deleted file mode 100644 index b1b4bbab8323e..0000000000000 --- a/public/app/features/dashboard-scene/scene/ShareQueryDataProvider.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { getDefaultTimeRange, LoadingState } from '@grafana/data'; -import { - SceneDataNode, - SceneFlexItem, - SceneFlexLayout, - sceneGraph, - SceneObjectBase, - SceneObjectState, -} from '@grafana/scenes'; - -import { activateFullSceneTree } from '../utils/test-utils'; -import { getVizPanelKeyForPanelId } from '../utils/utils'; - -import { ShareQueryDataProvider } from './ShareQueryDataProvider'; - -export class SceneDummyPanel extends SceneObjectBase<SceneObjectState> {} - -describe('ShareQueryDataProvider', () => { - it('Should find and subscribe to another VizPanels data provider', () => { - const panel = new SceneDummyPanel({ - key: getVizPanelKeyForPanelId(2), - $data: new ShareQueryDataProvider({ - query: { refId: 'A', panelId: 1 }, - }), - }); - - const sourceData = new SceneDataNode({ - data: { - series: [], - state: LoadingState.Done, - timeRange: getDefaultTimeRange(), - structureRev: 11, - }, - }); - - const scene = new SceneFlexLayout({ - children: [ - new SceneFlexItem({ - body: new SceneDummyPanel({ - key: getVizPanelKeyForPanelId(1), - $data: sourceData, - }), - }), - new SceneFlexItem({ body: panel }), - ], - }); - - activateFullSceneTree(scene); - - expect(sceneGraph.getData(panel).state.data?.structureRev).toBe(11); - - sourceData.setState({ data: { ...sourceData.state.data!, structureRev: 12 } }); - - expect(sceneGraph.getData(panel).state.data?.structureRev).toBe(12); - }); -}); diff --git a/public/app/features/dashboard-scene/scene/ShareQueryDataProvider.ts b/public/app/features/dashboard-scene/scene/ShareQueryDataProvider.ts deleted file mode 100644 index 854f616c79580..0000000000000 --- a/public/app/features/dashboard-scene/scene/ShareQueryDataProvider.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { Observable, ReplaySubject, Unsubscribable } from 'rxjs'; - -import { getDefaultTimeRange, LoadingState } from '@grafana/data'; -import { - SceneDataProvider, - SceneDataProviderResult, - SceneDataState, - SceneDataTransformer, - SceneDeactivationHandler, - SceneObject, - SceneObjectBase, -} from '@grafana/scenes'; -import { DashboardQuery } from 'app/plugins/datasource/dashboard/types'; - -import { getVizPanelKeyForPanelId } from '../utils/utils'; - -export interface ShareQueryDataProviderState extends SceneDataState { - query: DashboardQuery; -} - -export class ShareQueryDataProvider extends SceneObjectBase<ShareQueryDataProviderState> implements SceneDataProvider { - private _querySub: Unsubscribable | undefined; - private _sourceDataDeactivationHandler?: SceneDeactivationHandler; - private _results = new ReplaySubject<SceneDataProviderResult>(); - private _sourceProvider?: SceneDataProvider; - private _passContainerWidth = false; - - constructor(state: ShareQueryDataProviderState) { - super(state); - - this.addActivationHandler(() => { - // TODO handle changes to query model (changed panelId / withTransforms) - //this.subscribeToState(this._onStateChanged); - - this._subscribeToSource(); - - return () => { - if (this._querySub) { - this._querySub.unsubscribe(); - } - if (this._sourceDataDeactivationHandler) { - this._sourceDataDeactivationHandler(); - } - }; - }); - } - - public getResultsStream(): Observable<SceneDataProviderResult> { - return this._results; - } - - private _subscribeToSource() { - const { query } = this.state; - - if (this._querySub) { - this._querySub.unsubscribe(); - } - - if (this.state.$data) { - this._sourceProvider = this.state.$data; - this._passContainerWidth = true; - } else { - if (!query.panelId) { - return; - } - - const keyToFind = getVizPanelKeyForPanelId(query.panelId); - const source = findObjectInScene(this.getRoot(), (scene: SceneObject) => scene.state.key === keyToFind); - - if (!source) { - console.log('Shared dashboard query refers to a panel that does not exist in the scene'); - return; - } - - this._sourceProvider = source.state.$data; - if (!this._sourceProvider) { - console.log('No source data found for shared dashboard query'); - return; - } - } - - // If the source is not active we need to pass the container width - if (!this._sourceProvider.isActive) { - this._passContainerWidth = true; - } - - // This will activate if sourceData is part of hidden panel - // Also make sure the sourceData is not deactivated if hidden later - this._sourceDataDeactivationHandler = this._sourceProvider.activate(); - - // If source is a data transformer we might need to get the inner query runner instead depending on withTransforms option - if (this._sourceProvider instanceof SceneDataTransformer && !query.withTransforms) { - if (!this._sourceProvider.state.$data) { - throw new Error('No source inner query runner found in data transformer'); - } - this._sourceProvider = this._sourceProvider.state.$data; - } - - this._querySub = this._sourceProvider.subscribeToState((state) => { - this._results.next({ - origin: this, - data: state.data || { - state: LoadingState.Done, - series: [], - timeRange: getDefaultTimeRange(), - }, - }); - - this.setState({ data: state.data }); - }); - - // Copy the initial state - this.setState({ data: this._sourceProvider.state.data }); - } - - public setContainerWidth(width: number) { - if (this._passContainerWidth && this._sourceProvider) { - this._sourceProvider.setContainerWidth?.(width); - } - } - - public isDataReadyToDisplay() { - if (this._sourceProvider && this._sourceProvider.isDataReadyToDisplay) { - return this._sourceProvider.isDataReadyToDisplay(); - } - return false; - } -} - -export function findObjectInScene(scene: SceneObject, check: (scene: SceneObject) => boolean): SceneObject | null { - if (check(scene)) { - return scene; - } - - let found: SceneObject | null = null; - - scene.forEachChild((child) => { - let maybe = findObjectInScene(child, check); - if (maybe) { - found = maybe; - } - }); - - return found; -} diff --git a/public/app/features/dashboard-scene/scene/UnlinkLibraryPanelModal.tsx b/public/app/features/dashboard-scene/scene/UnlinkLibraryPanelModal.tsx new file mode 100644 index 0000000000000..8dc2696a71d83 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/UnlinkLibraryPanelModal.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import { SceneComponentProps, SceneObjectBase, SceneObjectRef, SceneObjectState } from '@grafana/scenes'; + +import { ModalSceneObjectLike } from '../sharing/types'; +import { getDashboardSceneFor } from '../utils/utils'; + +import { LibraryVizPanel } from './LibraryVizPanel'; +import { UnlinkModal } from './UnlinkModal'; + +interface UnlinkLibraryPanelModalState extends SceneObjectState { + panelRef?: SceneObjectRef<LibraryVizPanel>; +} + +export class UnlinkLibraryPanelModal + extends SceneObjectBase<UnlinkLibraryPanelModalState> + implements ModalSceneObjectLike +{ + static Component = UnlinkLibraryPanelModalRenderer; + + public onDismiss = () => { + const dashboard = getDashboardSceneFor(this); + dashboard.closeModal(); + }; + + public onConfirm = () => { + const dashboard = getDashboardSceneFor(this); + dashboard.unlinkLibraryPanel(this.state.panelRef!.resolve()); + dashboard.closeModal(); + }; +} + +function UnlinkLibraryPanelModalRenderer({ model }: SceneComponentProps<UnlinkLibraryPanelModal>) { + return ( + <UnlinkModal + isOpen={true} + onConfirm={() => { + model.onConfirm(); + model.onDismiss(); + }} + onDismiss={model.onDismiss} + /> + ); +} diff --git a/public/app/features/library-panels/components/UnlinkModal/UnlinkModal.tsx b/public/app/features/dashboard-scene/scene/UnlinkModal.tsx similarity index 100% rename from public/app/features/library-panels/components/UnlinkModal/UnlinkModal.tsx rename to public/app/features/dashboard-scene/scene/UnlinkModal.tsx diff --git a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts index c3f0c6f18062d..99123eaf9ca80 100644 --- a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts +++ b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts @@ -1,14 +1,14 @@ import { locationService } from '@grafana/runtime'; import { sceneGraph, VizPanel } from '@grafana/scenes'; -import { OptionsWithLegend } from '@grafana/schema'; import { KeybindingSet } from 'app/core/services/KeybindingSet'; import { ShareModal } from '../sharing/ShareModal'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; -import { getDashboardUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; +import { getEditPanelUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; import { getPanelIdForVizPanel } from '../utils/utils'; import { DashboardScene } from './DashboardScene'; +import { onRemovePanel, toggleVizPanelLegend } from './PanelMenuBehavior'; export function setupKeyboardShortcuts(scene: DashboardScene) { const keybindings = new KeybindingSet(); @@ -30,13 +30,9 @@ export function setupKeyboardShortcuts(scene: DashboardScene) { const sceneRoot = vizPanel.getRoot(); if (sceneRoot instanceof DashboardScene) { const panelId = getPanelIdForVizPanel(vizPanel); - locationService.push( - getDashboardUrl({ - uid: sceneRoot.state.uid, - subPath: `/panel-edit/${panelId}`, - currentQueryParams: location.search, - }) - ); + if (!scene.state.editPanel) { + locationService.push(getEditPanelUrl(panelId)); + } } }), }); @@ -114,8 +110,32 @@ export function setupKeyboardShortcuts(scene: DashboardScene) { onTrigger: scene.onOpenSettings, }); + keybindings.addBinding({ + key: 'mod+s', + onTrigger: () => scene.openSaveDrawer({}), + }); + // toggle all panel legends (TODO) - // delete panel (TODO when we work on editing) + // delete panel + keybindings.addBinding({ + key: 'p r', + onTrigger: withFocusedPanel(scene, (vizPanel: VizPanel) => { + if (scene.state.isEditing) { + onRemovePanel(scene, vizPanel); + } + }), + }); + + // duplicate panel + keybindings.addBinding({ + key: 'p d', + onTrigger: withFocusedPanel(scene, (vizPanel: VizPanel) => { + if (scene.state.isEditing) { + scene.duplicatePanel(vizPanel); + } + }), + }); + // toggle all exemplars (TODO) // collapse all rows (TODO) // expand all rows (TODO) @@ -143,21 +163,6 @@ export function withFocusedPanel(scene: DashboardScene, fn: (vizPanel: VizPanel) }; } -export function toggleVizPanelLegend(vizPanel: VizPanel) { - const options = vizPanel.state.options; - if (hasLegendOptions(options) && typeof options.legend.showLegend === 'boolean') { - vizPanel.onOptionsChange({ - legend: { - showLegend: options.legend.showLegend ? false : true, - }, - }); - } -} - -function hasLegendOptions(optionsWithLegend: unknown): optionsWithLegend is OptionsWithLegend { - return optionsWithLegend != null && typeof optionsWithLegend === 'object' && 'legend' in optionsWithLegend; -} - function handleZoomOut(scene: DashboardScene) { const timePicker = dashboardSceneGraph.getTimePicker(scene); timePicker?.onZoom(); diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx b/public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx new file mode 100644 index 0000000000000..ec9b123be47aa --- /dev/null +++ b/public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx @@ -0,0 +1,208 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { + SceneComponentProps, + SceneGridItem, + SceneGridLayout, + SceneGridRow, + SceneObjectBase, + SceneObjectState, + SceneQueryRunner, + VizPanel, +} from '@grafana/scenes'; +import { Icon, TextLink, useStyles2 } from '@grafana/ui'; +import appEvents from 'app/core/app_events'; +import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; +import { ShowConfirmModalEvent } from 'app/types/events'; + +import { getDashboardSceneFor } from '../../utils/utils'; +import { DashboardScene } from '../DashboardScene'; +import { RowRepeaterBehavior } from '../RowRepeaterBehavior'; + +import { RowOptionsButton } from './RowOptionsButton'; + +export interface RowActionsState extends SceneObjectState {} + +export class RowActions extends SceneObjectBase<RowActionsState> { + private updateLayout(rowClone: SceneGridRow): void { + const row = this.getParent(); + + const layout = this.getDashboard().state.body; + + if (!(layout instanceof SceneGridLayout)) { + throw new Error('Layout is not a SceneGridLayout'); + } + + // remove the repeated rows + const children = layout.state.children.filter((child) => !child.state.key?.startsWith(`${row.state.key}-clone-`)); + + // get the index to replace later + const index = children.indexOf(row); + + if (index === -1) { + throw new Error('Parent row not found in layout children'); + } + + // replace the row with the clone + layout.setState({ + children: [...children.slice(0, index), rowClone, ...children.slice(index + 1)], + }); + } + + public getParent(): SceneGridRow { + if (!(this.parent instanceof SceneGridRow)) { + throw new Error('RowActions must have a SceneGridRow parent'); + } + + return this.parent; + } + + public getDashboard(): DashboardScene { + return getDashboardSceneFor(this); + } + + public onUpdate = (title: string, repeat?: string | null): void => { + const row = this.getParent(); + + // return early if there is no repeat + if (!repeat) { + const clone = row.clone(); + + // remove the row repeater behaviour, leave the rest + clone.setState({ + title, + $behaviors: row.state.$behaviors?.filter((b) => !(b instanceof RowRepeaterBehavior)) ?? [], + }); + + this.updateLayout(clone); + + return; + } + + const children = row.state.children.map((child) => child.clone()); + + const newBehaviour = new RowRepeaterBehavior({ + variableName: repeat, + sources: children, + }); + + // get rest of behaviors except the old row repeater, if any, and push new one + const behaviors = row.state.$behaviors?.filter((b) => !(b instanceof RowRepeaterBehavior)) ?? []; + behaviors.push(newBehaviour); + + row.setState({ + title, + $behaviors: behaviors, + }); + + newBehaviour.activate(); + }; + + public onDelete = () => { + appEvents.publish( + new ShowConfirmModalEvent({ + title: 'Delete row', + text: 'Are you sure you want to remove this row and all its panels?', + altActionText: 'Delete row only', + icon: 'trash-alt', + onConfirm: () => { + this.getDashboard().removeRow(this.getParent(), true); + }, + onAltAction: () => { + this.getDashboard().removeRow(this.getParent()); + }, + }) + ); + }; + + public getWarning = () => { + const row = this.getParent(); + const gridItems = row.state.children; + + const isAnyPanelUsingDashboardDS = gridItems.some((gridItem) => { + if (!(gridItem instanceof SceneGridItem)) { + return false; + } + + if (gridItem.state.body instanceof VizPanel && gridItem.state.body.state.$data instanceof SceneQueryRunner) { + return gridItem.state.body.state.$data?.state.datasource?.uid === SHARED_DASHBOARD_QUERY; + } + + return false; + }); + + if (isAnyPanelUsingDashboardDS) { + return ( + <div> + <p> + Panels in this row use the {SHARED_DASHBOARD_QUERY} data source. These panels will reference the panel in + the original row, not the ones in the repeated rows. + </p> + <TextLink + external + href={ + 'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows' + } + > + Learn more + </TextLink> + </div> + ); + } + + return undefined; + }; + + static Component = ({ model }: SceneComponentProps<RowActions>) => { + const dashboard = model.getDashboard(); + const row = model.getParent(); + const { title } = row.useState(); + const { meta, isEditing } = dashboard.useState(); + const styles = useStyles2(getStyles); + + const behaviour = row.state.$behaviors?.find((b) => b instanceof RowRepeaterBehavior); + + return ( + <> + {meta.canEdit && isEditing && ( + <> + <div className={styles.rowActions}> + <RowOptionsButton + title={title} + repeat={behaviour instanceof RowRepeaterBehavior ? behaviour.state.variableName : undefined} + parent={dashboard} + onUpdate={model.onUpdate} + warning={model.getWarning()} + /> + <button type="button" onClick={model.onDelete} aria-label="Delete row"> + <Icon name="trash-alt" /> + </button> + </div> + </> + )} + </> + ); + }; +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + rowActions: css({ + color: theme.colors.text.secondary, + lineHeight: '27px', + + button: { + color: theme.colors.text.secondary, + paddingLeft: theme.spacing(2), + background: 'transparent', + border: 'none', + + '&:hover': { + color: theme.colors.text.maxContrast, + }, + }, + }), + }; +}; diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx new file mode 100644 index 0000000000000..ef10894cf36e3 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + +import { SceneObject } from '@grafana/scenes'; +import { Icon, ModalsController } from '@grafana/ui'; + +import { OnRowOptionsUpdate } from './RowOptionsForm'; +import { RowOptionsModal } from './RowOptionsModal'; + +export interface RowOptionsButtonProps { + title: string; + repeat?: string; + parent: SceneObject; + onUpdate: OnRowOptionsUpdate; + warning?: React.ReactNode; +} + +export const RowOptionsButton = ({ repeat, title, parent, onUpdate, warning }: RowOptionsButtonProps) => { + const onUpdateChange = (hideModal: () => void) => (title: string, repeat?: string | null) => { + onUpdate(title, repeat); + hideModal(); + }; + + return ( + <ModalsController> + {({ showModal, hideModal }) => { + return ( + <button + type="button" + className="pointer" + aria-label="Row options" + onClick={() => { + showModal(RowOptionsModal, { + title, + repeat, + parent, + onDismiss: hideModal, + onUpdate: onUpdateChange(hideModal), + warning, + }); + }} + > + <Icon name="cog" /> + </button> + ); + }} + </ModalsController> + ); +}; + +RowOptionsButton.displayName = 'RowOptionsButton'; diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx new file mode 100644 index 0000000000000..c6bcf17fb937d --- /dev/null +++ b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; + +import { selectors } from '@grafana/e2e-selectors'; + +import { DashboardScene } from '../DashboardScene'; + +import { RowOptionsForm } from './RowOptionsForm'; + +jest.mock('app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect', () => ({ + RepeatRowSelect2: () => <div />, +})); +describe('DashboardRow', () => { + const scene = new DashboardScene({ + title: 'hello', + uid: 'dash-1', + meta: { + canEdit: true, + }, + }); + + it('Should show warning component when has warningMessage prop', () => { + render( + <TestProvider> + <RowOptionsForm + repeat={'3'} + parent={scene} + title="" + onCancel={jest.fn()} + onUpdate={jest.fn()} + warning="a warning message" + /> + </TestProvider> + ); + expect( + screen.getByTestId(selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage) + ).toBeInTheDocument(); + }); + + it('Should not show warning component when does not have warningMessage prop', () => { + render( + <TestProvider> + <RowOptionsForm repeat={'3'} parent={scene} title="" onCancel={jest.fn()} onUpdate={jest.fn()} /> + </TestProvider> + ); + expect( + screen.queryByTestId(selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage) + ).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx new file mode 100644 index 0000000000000..538a35382e7ee --- /dev/null +++ b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx @@ -0,0 +1,61 @@ +import React, { useCallback, useState } from 'react'; +import { useForm } from 'react-hook-form'; + +import { selectors } from '@grafana/e2e-selectors'; +import { SceneObject } from '@grafana/scenes'; +import { Button, Field, Modal, Input, Alert } from '@grafana/ui'; +import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect'; + +export type OnRowOptionsUpdate = (title: string, repeat?: string | null) => void; + +export interface Props { + title: string; + repeat?: string; + parent: SceneObject; + onUpdate: OnRowOptionsUpdate; + onCancel: () => void; + warning?: React.ReactNode; +} + +export const RowOptionsForm = ({ repeat, title, parent, warning, onUpdate, onCancel }: Props) => { + const [newRepeat, setNewRepeat] = useState<string | undefined>(repeat); + const onChangeRepeat = useCallback((name?: string) => setNewRepeat(name), [setNewRepeat]); + + const { handleSubmit, register } = useForm({ + defaultValues: { + title, + }, + }); + + const submit = (formData: { title: string }) => { + onUpdate(formData.title, newRepeat); + }; + + return ( + <form onSubmit={handleSubmit(submit)}> + <Field label="Title"> + <Input {...register('title')} type="text" /> + </Field> + <Field label="Repeat for"> + <RepeatRowSelect2 parent={parent} repeat={newRepeat} onChange={onChangeRepeat} /> + </Field> + {warning && ( + <Alert + data-testid={selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage} + severity="warning" + title="" + topSpacing={3} + bottomSpacing={0} + > + {warning} + </Alert> + )} + <Modal.ButtonRow> + <Button type="button" variant="secondary" onClick={onCancel} fill="outline"> + Cancel + </Button> + <Button type="submit">Update</Button> + </Modal.ButtonRow> + </form> + ); +}; diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx new file mode 100644 index 0000000000000..2f23db9add73e --- /dev/null +++ b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx @@ -0,0 +1,40 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { SceneObject } from '@grafana/scenes'; +import { Modal, useStyles2 } from '@grafana/ui'; + +import { OnRowOptionsUpdate, RowOptionsForm } from './RowOptionsForm'; + +export interface RowOptionsModalProps { + title: string; + repeat?: string; + parent: SceneObject; + warning?: React.ReactNode; + onDismiss: () => void; + onUpdate: OnRowOptionsUpdate; +} + +export const RowOptionsModal = ({ repeat, title, parent, onDismiss, onUpdate, warning }: RowOptionsModalProps) => { + const styles = useStyles2(getStyles); + + return ( + <Modal isOpen={true} title="Row options" onDismiss={onDismiss} className={styles.modal}> + <RowOptionsForm + parent={parent} + repeat={repeat} + title={title} + onCancel={onDismiss} + onUpdate={onUpdate} + warning={warning} + /> + </Modal> + ); +}; + +const getStyles = () => ({ + modal: css({ + label: 'RowOptionsModal', + width: '500px', + }), +}); diff --git a/public/app/features/dashboard-scene/scene/setDashboardPanelContext.test.ts b/public/app/features/dashboard-scene/scene/setDashboardPanelContext.test.ts index 149dce0d11958..36eb42d3a05d9 100644 --- a/public/app/features/dashboard-scene/scene/setDashboardPanelContext.test.ts +++ b/public/app/features/dashboard-scene/scene/setDashboardPanelContext.test.ts @@ -5,7 +5,7 @@ import { PanelContext } from '@grafana/ui'; import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { findVizPanelByKey } from '../utils/utils'; -import { getAdHocFilterSetFor, setDashboardPanelContext } from './setDashboardPanelContext'; +import { getAdHocFilterVariableFor, setDashboardPanelContext } from './setDashboardPanelContext'; const postFn = jest.fn(); const putFn = jest.fn(); @@ -15,7 +15,7 @@ setBackendSrv({ post: postFn, put: putFn, delete: deleteFn, -} as any as BackendSrv); +} as unknown as BackendSrv); describe('setDashboardPanelContext', () => { describe('canAddAnnotations', () => { @@ -132,26 +132,26 @@ describe('setDashboardPanelContext', () => { context.onAddAdHocFilter!({ key: 'hello', value: 'world', operator: '=' }); - const set = getAdHocFilterSetFor(scene, { uid: 'my-ds-uid' }); + const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' }); - expect(set.state.filters).toEqual([{ key: 'hello', value: 'world', operator: '=' }]); + expect(variable.state.filters).toEqual([{ key: 'hello', value: 'world', operator: '=' }]); }); it('Should update and add filter to existing set', () => { - const { scene, context } = buildTestScene({ existingFilterSet: true }); + const { scene, context } = buildTestScene({ existingFilterVariable: true }); - const set = getAdHocFilterSetFor(scene, { uid: 'my-ds-uid' }); + const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' }); - set.setState({ filters: [{ key: 'existing', value: 'world', operator: '=' }] }); + variable.setState({ filters: [{ key: 'existing', value: 'world', operator: '=' }] }); context.onAddAdHocFilter!({ key: 'hello', value: 'world', operator: '=' }); - expect(set.state.filters.length).toBe(2); + expect(variable.state.filters.length).toBe(2); // Can update existing filter value without adding a new filter context.onAddAdHocFilter!({ key: 'hello', value: 'world2', operator: '=' }); // Verify existing filter value updated - expect(set.state.filters[1].value).toBe('world2'); + expect(variable.state.filters[1].value).toBe('world2'); }); }); }); @@ -163,7 +163,7 @@ interface SceneOptions { canEdit?: boolean; canDelete?: boolean; orgCanEdit?: boolean; - existingFilterSet?: boolean; + existingFilterVariable?: boolean; } function buildTestScene(options: SceneOptions) { @@ -198,7 +198,7 @@ function buildTestScene(options: SceneOptions) { }, ], templating: { - list: options.existingFilterSet + list: options.existingFilterVariable ? [ { type: 'adhoc', diff --git a/public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts b/public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts index 5b988c550a84e..bb10455fd72ed 100644 --- a/public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts +++ b/public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts @@ -1,5 +1,5 @@ import { AnnotationChangeEvent, AnnotationEventUIModel, CoreApp, DataFrame } from '@grafana/data'; -import { AdHocFilterSet, dataLayers, SceneDataLayers, VizPanel } from '@grafana/scenes'; +import { AdHocFiltersVariable, dataLayers, SceneDataLayers, sceneGraph, sceneUtils, VizPanel } from '@grafana/scenes'; import { DataSourceRef } from '@grafana/schema'; import { AdHocFilterItem, PanelContext } from '@grafana/ui'; import { deleteAnnotation, saveAnnotation, updateAnnotation } from 'app/features/annotations/api'; @@ -111,8 +111,8 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte return; } - const filterSet = getAdHocFilterSetFor(dashboard, queryRunner.state.datasource); - updateAdHocFilterSet(filterSet, newFilter); + const filterVar = getAdHocFilterVariableFor(dashboard, queryRunner.state.datasource); + updateAdHocFilterVariable(filterVar, newFilter); }; context.onUpdateData = (frames: DataFrame[]): Promise<boolean> => { @@ -149,33 +149,37 @@ function reRunBuiltInAnnotationsLayer(scene: DashboardScene) { } } -export function getAdHocFilterSetFor(scene: DashboardScene, ds: DataSourceRef | null | undefined) { - const controls = scene.state.controls ?? []; +export function getAdHocFilterVariableFor(scene: DashboardScene, ds: DataSourceRef | null | undefined) { + const variables = sceneGraph.getVariables(scene); - for (const control of controls) { - if (control instanceof AdHocFilterSet) { - if (control.state.datasource === ds || control.state.datasource?.uid === ds?.uid) { - return control; + for (const variable of variables.state.variables) { + if (sceneUtils.isAdHocVariable(variable)) { + const filtersDs = variable.state.datasource; + if (filtersDs === ds || filtersDs?.uid === ds?.uid) { + return variable; } } } - const newSet = new AdHocFilterSet({ datasource: ds }); + const newVariable = new AdHocFiltersVariable({ + name: 'Filters', + datasource: ds, + }); // Add it to the scene - scene.setState({ - controls: [controls[0], newSet, ...controls.slice(1)], + variables.setState({ + variables: [...variables.state.variables, newVariable], }); - return newSet; + return newVariable; } -function updateAdHocFilterSet(filterSet: AdHocFilterSet, newFilter: AdHocFilterItem) { +function updateAdHocFilterVariable(filterVar: AdHocFiltersVariable, newFilter: AdHocFilterItem) { // Check if we need to update an existing filter - for (const filter of filterSet.state.filters) { + for (const filter of filterVar.state.filters) { if (filter.key === newFilter.key) { - filterSet.setState({ - filters: filterSet.state.filters.map((f) => { + filterVar.setState({ + filters: filterVar.state.filters.map((f) => { if (f.key === newFilter.key) { return newFilter; } @@ -187,7 +191,7 @@ function updateAdHocFilterSet(filterSet: AdHocFilterSet, newFilter: AdHocFilterI } // Add new filter - filterSet.setState({ - filters: [...filterSet.state.filters, newFilter], + filterVar.setState({ + filters: [...filterVar.state.filters, newFilter], }); } diff --git a/public/app/features/dashboard-scene/serialization/SaveDashboardDrawer.tsx b/public/app/features/dashboard-scene/serialization/SaveDashboardDrawer.tsx deleted file mode 100644 index 7106a9ce3cd4b..0000000000000 --- a/public/app/features/dashboard-scene/serialization/SaveDashboardDrawer.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; - -import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneObjectRef } from '@grafana/scenes'; -import { Drawer } from '@grafana/ui'; -import { SaveDashboardDiff } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDiff'; -import { jsonDiff } from 'app/features/dashboard/components/VersionHistory/utils'; - -import { DashboardScene } from '../scene/DashboardScene'; - -import { transformSceneToSaveModel } from './transformSceneToSaveModel'; - -interface SaveDashboardDrawerState extends SceneObjectState { - dashboardRef: SceneObjectRef<DashboardScene>; -} - -export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> { - onClose = () => { - this.state.dashboardRef.resolve().setState({ overlay: undefined }); - }; - - static Component = ({ model }: SceneComponentProps<SaveDashboardDrawer>) => { - const dashboard = model.state.dashboardRef.resolve(); - const initialState = dashboard.getInitialState(); - const initialScene = new DashboardScene(initialState!); - const initialSaveModel = transformSceneToSaveModel(initialScene); - const changedSaveModel = transformSceneToSaveModel(dashboard); - - const diff = jsonDiff(initialSaveModel, changedSaveModel); - - // let diffCount = 0; - // for (const d of Object.values(diff)) { - // diffCount += d.length; - // } - - return ( - <Drawer title="Save dashboard" subtitle={dashboard.state.title} onClose={model.onClose}> - <SaveDashboardDiff diff={diff} oldValue={initialSaveModel} newValue={changedSaveModel} /> - </Drawer> - ); - }; -} diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap index f3114324f74fa..f43d073b626fc 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap @@ -123,9 +123,8 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back </div>", "mode": "markdown", }, + "pluginVersion": "10.2.0-pre", "title": "", - "transformations": [], - "transparent": false, "type": "text", }, { @@ -175,9 +174,8 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back </div>", "mode": "markdown", }, + "pluginVersion": "10.2.0-pre", "title": "Text panel in collapsed row", - "transformations": [], - "transparent": false, "type": "text", }, ], @@ -185,7 +183,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back "type": "row", }, ], - "schemaVersion": 36, + "schemaVersion": 39, "tags": [ "templating", "gdev", @@ -234,35 +232,11 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back "from": "now-6h", "to": "now", }, - "timepicker": { - "hidden": false, - "refresh_intervals": [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d", - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d", - ], - }, + "timepicker": {}, "timezone": "", "title": "Repeating rows", "uid": "Repeating-rows-uid", + "version": 1, "weekStart": "", } `; @@ -333,9 +307,22 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho "description": "My custom description", "editable": false, "fiscalYearStartMonth": 1, - "graphTooltip": 0, + "graphTooltip": 1, "id": 1351, - "links": [], + "links": [ + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": false, + "tags": [], + "targetBlank": false, + "title": "Link 1", + "tooltip": "", + "type": "dashboards", + "url": "", + }, + ], "panels": [ { "datasource": { @@ -387,8 +374,6 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho }, ], "title": "Simple time series graph ", - "transformations": [], - "transparent": false, "type": "timeseries", }, { @@ -434,8 +419,6 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho }, ], "title": "panel inside row", - "transformations": [], - "transparent": false, "type": "timeseries", }, { @@ -459,19 +442,29 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho "content": "content", "mode": "markdown", }, + "pluginVersion": "10.2.0-pre", "title": "Transparent text panel", - "transformations": [], "transparent": true, "type": "text", }, ], - "schemaVersion": 36, + "schemaVersion": 39, "tags": [ "tag1", "tag2", ], "templating": { "list": [ + { + "baseFilters": [], + "datasource": { + "type": "prometheus", + "uid": "wc2AL7L7k", + }, + "filters": [], + "name": "Filters", + "type": "adhoc", + }, { "auto": true, "auto_count": 30, @@ -480,7 +473,6 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho "text": "1m", "value": "1m", }, - "hide": 2, "name": "intervalVar", "query": "1m,10m,30m,1h,6h,12h,1d,7d,14d,30d", "refresh": 2, @@ -543,14 +535,6 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho "skipUrlSync": true, "type": "constant", }, - { - "datasource": { - "type": "prometheus", - "uid": "wc2AL7L7k", - }, - "name": "Filters", - "type": "adhoc", - }, ], }, "time": { @@ -565,21 +549,11 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho "30m", "1h", ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d", - ], }, "timezone": "America/New_York", "title": "My custom title", "uid": "nP8rcffGkasd", + "version": 2, "weekStart": "monday", } `; @@ -649,7 +623,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr }, "editable": true, "fiscalYearStartMonth": 1, - "graphTooltip": 0, + "graphTooltip": 1, "id": 1351, "links": [], "panels": [ @@ -703,8 +677,6 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr }, ], "title": "Simple time series graph ", - "transformations": [], - "transparent": false, "type": "timeseries", }, { @@ -750,8 +722,6 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr }, ], "title": "panel inside row", - "transformations": [], - "transparent": false, "type": "timeseries", }, { @@ -775,13 +745,13 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr "content": "content", "mode": "markdown", }, + "pluginVersion": "10.2.0-pre", "title": "Transparent text panel", - "transformations": [], "transparent": true, "type": "text", }, ], - "schemaVersion": 36, + "schemaVersion": 39, "tags": [ "gdev", "graph-ng", @@ -789,6 +759,16 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr ], "templating": { "list": [ + { + "baseFilters": [], + "datasource": { + "type": "prometheus", + "uid": "wc2AL7L7k", + }, + "filters": [], + "name": "Filters", + "type": "adhoc", + }, { "auto": true, "auto_count": 30, @@ -797,7 +777,6 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr "text": "1m", "value": "1m", }, - "hide": 2, "name": "intervalVar", "query": "1m,10m,30m,1h,6h,12h,1d,7d,14d,30d", "refresh": 2, @@ -860,14 +839,6 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr "skipUrlSync": true, "type": "constant", }, - { - "datasource": { - "type": "prometheus", - "uid": "wc2AL7L7k", - }, - "name": "Filters", - "type": "adhoc", - }, ], }, "time": { @@ -875,7 +846,6 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr "to": "now", }, "timepicker": { - "hidden": false, "refresh_intervals": [ "10s", "30s", @@ -887,21 +857,11 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr "2h", "1d", ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d", - ], }, "timezone": "America/New_York", "title": "Dashboard to load1", "uid": "nP8rcffGkasd", + "version": 2, "weekStart": "saturday", } `; diff --git a/public/app/features/dashboard-scene/serialization/angularMigration.test.ts b/public/app/features/dashboard-scene/serialization/angularMigration.test.ts index c8a8d8908898c..51f53592fc5db 100644 --- a/public/app/features/dashboard-scene/serialization/angularMigration.test.ts +++ b/public/app/features/dashboard-scene/serialization/angularMigration.test.ts @@ -1,3 +1,4 @@ +import { PanelTypeChangedHandler } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { PanelModel } from 'app/features/dashboard/state'; @@ -6,12 +7,12 @@ import { getAngularPanelMigrationHandler } from './angularMigration'; describe('getAngularPanelMigrationHandler', () => { describe('Given an old angular panel', () => { it('Should call migration handler', () => { - const onPanelTypeChanged = (panel: PanelModel, prevPluginId: string, prevOptions: Record<string, any>) => { + const onPanelTypeChanged: PanelTypeChangedHandler = (panel, prevPluginId, prevOptions) => { panel.fieldConfig = { defaults: { unit: 'bytes' }, overrides: [] }; return { name: prevOptions.angular.oldOptionProp }; }; - const reactPlugin = getPanelPlugin({ id: 'timeseries' }).setPanelChangeHandler(onPanelTypeChanged as any); + const reactPlugin = getPanelPlugin({ id: 'timeseries' }).setPanelChangeHandler(onPanelTypeChanged); const oldModel = new PanelModel({ autoMigrateFrom: 'graph', diff --git a/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts b/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts new file mode 100644 index 0000000000000..dcb7460f78859 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts @@ -0,0 +1,26 @@ +import { defaultDashboard } from '@grafana/schema'; +import { DashboardDTO } from 'app/types'; + +export function buildNewDashboardSaveModel(urlFolderUid?: string): DashboardDTO { + const data: DashboardDTO = { + meta: { + canStar: false, + canShare: false, + canDelete: false, + isNew: true, + folderUid: '', + }, + dashboard: { + ...defaultDashboard, + uid: '', + title: 'New dashboard', + panels: [], + }, + }; + + if (urlFolderUid) { + data.meta.folderUid = urlFolderUid; + } + + return data; +} diff --git a/public/app/features/dashboard-scene/serialization/dataLayersToAnnotations.ts b/public/app/features/dashboard-scene/serialization/dataLayersToAnnotations.ts index 1261ec4157a4d..dd16c946c0ec6 100644 --- a/public/app/features/dashboard-scene/serialization/dataLayersToAnnotations.ts +++ b/public/app/features/dashboard-scene/serialization/dataLayersToAnnotations.ts @@ -7,6 +7,7 @@ export function dataLayersToAnnotations(layers: SceneDataLayerProvider[]) { if (!(layer instanceof dataLayers.AnnotationsDataLayer)) { continue; } + const result = { ...layer.state.query, enable: Boolean(layer.state.isEnabled), diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts index eba7dd50d8ec6..1d3c6c2b333f0 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts @@ -11,11 +11,13 @@ import { toDataFrame, VariableSupportType, } from '@grafana/data'; -import { setRunRequest } from '@grafana/runtime'; +import { config, setRunRequest } from '@grafana/runtime'; import { + AdHocFiltersVariable, ConstantVariable, CustomVariable, DataSourceVariable, + GroupByVariable, QueryVariable, SceneVariableSet, TextBoxVariable, @@ -98,6 +100,7 @@ describe('sceneVariablesSetToVariables', () => { allValue: 'test-all', isMulti: true, }); + const set = new SceneVariableSet({ variables: [variable], }); @@ -335,7 +338,6 @@ describe('sceneVariablesSetToVariables', () => { "value": "text value", }, "description": "test-desc", - "hide": 2, "label": "test-label", "name": "test", "query": "text value", @@ -344,4 +346,233 @@ describe('sceneVariablesSetToVariables', () => { } `); }); + + it('should handle AdHocFiltersVariable', () => { + const variable = new AdHocFiltersVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + datasource: { uid: 'fake-std', type: 'fake-std' }, + filters: [ + { + key: 'filterTest', + operator: '=', + value: 'test', + }, + ], + baseFilters: [ + { + key: 'baseFilterTest', + operator: '=', + value: 'test', + }, + ], + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToVariables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "baseFilters": [ + { + "key": "baseFilterTest", + "operator": "=", + "value": "test", + }, + ], + "datasource": { + "type": "fake-std", + "uid": "fake-std", + }, + "defaultKeys": undefined, + "description": "test-desc", + "filters": [ + { + "key": "filterTest", + "operator": "=", + "value": "test", + }, + ], + "label": "test-label", + "name": "test", + "type": "adhoc", + } + `); + }); + + it('should handle AdHocFiltersVariable with defaultKeys', () => { + const variable = new AdHocFiltersVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + datasource: { uid: 'fake-std', type: 'fake-std' }, + defaultKeys: [ + { + text: 'some', + value: '1', + }, + { + text: 'static', + value: '2', + }, + { + text: 'keys', + value: '3', + }, + ], + filters: [ + { + key: 'filterTest', + operator: '=', + value: 'test', + }, + ], + baseFilters: [ + { + key: 'baseFilterTest', + operator: '=', + value: 'test', + }, + ], + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToVariables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "baseFilters": [ + { + "key": "baseFilterTest", + "operator": "=", + "value": "test", + }, + ], + "datasource": { + "type": "fake-std", + "uid": "fake-std", + }, + "defaultKeys": [ + { + "text": "some", + "value": "1", + }, + { + "text": "static", + "value": "2", + }, + { + "text": "keys", + "value": "3", + }, + ], + "description": "test-desc", + "filters": [ + { + "key": "filterTest", + "operator": "=", + "value": "test", + }, + ], + "label": "test-label", + "name": "test", + "type": "adhoc", + } + `); + }); + + describe('when the groupByVariable feature toggle is enabled', () => { + beforeAll(() => { + config.featureToggles.groupByVariable = true; + }); + + afterAll(() => { + config.featureToggles.groupByVariable = false; + }); + + it('should handle GroupByVariable', () => { + const variable = new GroupByVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + datasource: { uid: 'fake-std', type: 'fake-std' }, + defaultOptions: [ + { + text: 'Foo', + value: 'foo', + }, + { + text: 'Bar', + value: 'bar', + }, + ], + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToVariables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "current": { + "text": [], + "value": [], + }, + "datasource": { + "type": "fake-std", + "uid": "fake-std", + }, + "description": "test-desc", + "label": "test-label", + "name": "test", + "options": [ + { + "text": "Foo", + "value": "foo", + }, + { + "text": "Bar", + "value": "bar", + }, + ], + "type": "groupby", + } + `); + }); + }); + + describe('when the groupByVariable feature toggle is disabled', () => { + it('should not handle GroupByVariable and throw an error', () => { + const variable = new GroupByVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + datasource: { uid: 'fake-std', type: 'fake-std' }, + defaultOptions: [ + { + text: 'Foo', + value: 'foo', + }, + { + text: 'Bar', + value: 'bar', + }, + ], + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + expect(() => sceneVariablesSetToVariables(set)).toThrow('Unsupported variable type'); + }); + }); }); diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts index 535541827cbc2..5f3813c27056f 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts @@ -1,3 +1,4 @@ +import { config } from '@grafana/runtime'; import { SceneVariables, sceneUtils } from '@grafana/scenes'; import { VariableHide, VariableModel, VariableRefresh, VariableSort } from '@grafana/schema'; @@ -34,7 +35,6 @@ export function sceneVariablesSetToVariables(set: SceneVariables) { includeAll: variable.state.includeAll, multi: variable.state.isMulti, skipUrlSync: variable.state.skipUrlSync, - hide: variable.state.hide || VariableHide.dontHide, }); } else if (sceneUtils.isCustomVariable(variable)) { variables.push({ @@ -90,7 +90,6 @@ export function sceneVariablesSetToVariables(set: SceneVariables) { value: variable.state.value, }, query: intervals, - hide: VariableHide.hideVariable, refresh: variable.state.refresh, // @ts-expect-error ?? how to fix this without adding the ts-expect-error auto: variable.state.autoEnabled, @@ -105,7 +104,33 @@ export function sceneVariablesSetToVariables(set: SceneVariables) { value: variable.state.value, }, query: variable.state.value, - hide: VariableHide.hideVariable, + }); + } else if (sceneUtils.isGroupByVariable(variable) && config.featureToggles.groupByVariable) { + variables.push({ + ...commonProperties, + datasource: variable.state.datasource, + // Only persist the statically defined options + options: variable.state.defaultOptions?.map((option) => ({ + text: option.text, + value: String(option.value), + })), + current: { + // @ts-expect-error + text: variable.state.text, + // @ts-expect-error + value: variable.state.value, + }, + }); + } else if (sceneUtils.isAdHocVariable(variable)) { + variables.push({ + ...commonProperties, + name: variable.state.name, + type: 'adhoc', + datasource: variable.state.datasource, + // @ts-expect-error + baseFilters: variable.state.baseFilters, + filters: variable.state.filters, + defaultKeys: variable.state.defaultKeys, }); } else { throw new Error('Unsupported variable type'); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts index 2afc8afe5d5a6..2d937285f1c41 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts @@ -7,14 +7,17 @@ import { IntervalVariableModel, TypedVariableModel, TextBoxVariableModel, + GroupByVariableModel, } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { config } from '@grafana/runtime'; import { - AdHocFilterSet, + AdHocFiltersVariable, behaviors, + ConstantVariable, CustomVariable, DataSourceVariable, + GroupByVariable, QueryVariable, SceneDataLayerControls, SceneDataLayers, @@ -22,8 +25,7 @@ import { SceneGridItem, SceneGridLayout, SceneGridRow, - SceneRefreshPicker, - SceneTimePicker, + SceneQueryRunner, VizPanel, } from '@grafana/scenes'; import { @@ -40,13 +42,16 @@ import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; import { DashboardDataDTO } from 'app/types'; -import { DashboardControls } from '../scene/DashboardControls'; +import { AddLibraryPanelWidget } from '../scene/AddLibraryPanelWidget'; +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem'; import { PanelTimeRange } from '../scene/PanelTimeRange'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; -import { ShareQueryDataProvider } from '../scene/ShareQueryDataProvider'; -import { getQueryRunnerFor } from '../utils/utils'; +import { NEW_LINK } from '../settings/links/utils'; +import { getQueryRunnerFor, getVizPanelKeyForPanelId } from '../utils/utils'; +import { buildNewDashboardSaveModel } from './buildNewDashboardSaveModel'; +import { GRAFANA_DATASOURCE_REF } from './const'; import dashboard_to_load1 from './testfiles/dashboard_to_load1.json'; import repeatingRowsAndPanelsDashboardJson from './testfiles/repeating_rows_and_panels.json'; import { @@ -54,6 +59,9 @@ import { buildGridItemForPanel, createSceneVariableFromVariableModel, transformSaveModelToScene, + convertOldSnapshotToScenesSnapshot, + buildGridItemForLibPanel, + buildGridItemForLibraryPanelWidget, } from './transformSaveModelToScene'; describe('transformSaveModelToScene', () => { @@ -71,6 +79,7 @@ describe('transformSaveModelToScene', () => { ...defaultTimePickerConfig, hidden: true, }, + links: [{ ...NEW_LINK, title: 'Link 1' }], templating: { list: [ { @@ -106,26 +115,23 @@ describe('transformSaveModelToScene', () => { const oldModel = new DashboardModel(dash); const scene = createDashboardSceneFromDashboardModel(oldModel); - const dashboardControls = scene.state.controls![0] as DashboardControls; + const dashboardControls = scene.state.controls!; expect(scene.state.title).toBe('test'); expect(scene.state.uid).toBe('test-uid'); + expect(scene.state.links).toHaveLength(1); + expect(scene.state.links![0].title).toBe('Link 1'); expect(scene.state?.$timeRange?.state.value.raw).toEqual(dash.time); expect(scene.state?.$timeRange?.state.fiscalYearStartMonth).toEqual(2); expect(scene.state?.$timeRange?.state.timeZone).toEqual('America/New_York'); expect(scene.state?.$timeRange?.state.weekStart).toEqual('saturday'); - expect(scene.state?.$variables?.state.variables).toHaveLength(1); + expect(scene.state?.$variables?.state.variables).toHaveLength(2); + expect(scene.state?.$variables?.getByName('constant')).toBeInstanceOf(ConstantVariable); + expect(scene.state?.$variables?.getByName('CoolFilters')).toBeInstanceOf(AdHocFiltersVariable); expect(dashboardControls).toBeDefined(); - expect(dashboardControls).toBeInstanceOf(DashboardControls); - expect(dashboardControls.state.variableControls[1]).toBeInstanceOf(AdHocFilterSet); - expect((dashboardControls.state.variableControls[1] as AdHocFilterSet).state.name).toBe('CoolFilters'); - expect(dashboardControls.state.timeControls).toHaveLength(2); - expect(dashboardControls.state.timeControls[0]).toBeInstanceOf(SceneTimePicker); - expect(dashboardControls.state.timeControls[1]).toBeInstanceOf(SceneRefreshPicker); - expect((dashboardControls.state.timeControls[1] as SceneRefreshPicker).state.intervals).toEqual( - defaultTimePickerConfig.refresh_intervals - ); + + expect(dashboardControls.state.refreshPicker.state.intervals).toEqual(defaultTimePickerConfig.refresh_intervals); expect(dashboardControls.state.hideTimeControls).toBe(true); }); @@ -138,9 +144,9 @@ describe('transformSaveModelToScene', () => { const scene = createDashboardSceneFromDashboardModel(oldModel); - expect(scene.state.$behaviors).toHaveLength(3); - expect(scene.state.$behaviors![1]).toBeInstanceOf(behaviors.CursorSync); - expect((scene.state.$behaviors![1] as behaviors.CursorSync).state.sync).toEqual(DashboardCursorSync.Crosshair); + expect(scene.state.$behaviors).toHaveLength(6); + expect(scene.state.$behaviors![0]).toBeInstanceOf(behaviors.CursorSync); + expect((scene.state.$behaviors![0] as behaviors.CursorSync).state.sync).toEqual(DashboardCursorSync.Crosshair); }); it('should initialize the Dashboard Scene with empty template variables', () => { @@ -163,6 +169,15 @@ describe('transformSaveModelToScene', () => { }); }); + describe('When creating a new dashboard', () => { + it('should initialize the DashboardScene in edit mode and dirty', () => { + const rsp = buildNewDashboardSaveModel(); + const scene = transformSaveModelToScene(rsp); + expect(scene.state.isEditing).toBe(undefined); + expect(scene.state.isDirty).toBe(false); + }); + }); + describe('when organizing panels as scene children', () => { it('should create panels within collapsed rows', () => { const panel = createPanelSaveModel({ @@ -170,12 +185,26 @@ describe('transformSaveModelToScene', () => { gridPos: { x: 1, y: 0, w: 12, h: 8 }, }) as Panel; + const widgetLibPanel = { + title: 'Widget Panel', + type: 'add-library-panel', + }; + + const libPanel = createPanelSaveModel({ + title: 'Library Panel', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + libraryPanel: { + uid: '123', + name: 'My Panel', + }, + }); + const row = createPanelSaveModel({ title: 'test', type: 'row', gridPos: { x: 0, y: 0, w: 12, h: 1 }, collapsed: true, - panels: [panel], + panels: [panel, widgetLibPanel, libPanel], }) as unknown as RowPanel; const dashboard = { @@ -194,8 +223,12 @@ describe('transformSaveModelToScene', () => { expect(rowScene.state.title).toEqual(row.title); expect(rowScene.state.y).toEqual(row.gridPos!.y); expect(rowScene.state.isCollapsed).toEqual(row.collapsed); - expect(rowScene.state.children).toHaveLength(1); + expect(rowScene.state.children).toHaveLength(3); expect(rowScene.state.children[0]).toBeInstanceOf(SceneGridItem); + expect(rowScene.state.children[1]).toBeInstanceOf(SceneGridItem); + expect(rowScene.state.children[2]).toBeInstanceOf(SceneGridItem); + expect((rowScene.state.children[1] as SceneGridItem).state.body!).toBeInstanceOf(AddLibraryPanelWidget); + expect((rowScene.state.children[2] as SceneGridItem).state.body!).toBeInstanceOf(LibraryVizPanel); }); it('should create panels within expanded row', () => { @@ -208,6 +241,24 @@ describe('transformSaveModelToScene', () => { y: 0, }, }); + const widgetLibPanelOutOfRow = { + title: 'Widget Panel', + type: 'add-library-panel', + gridPos: { + h: 8, + w: 12, + x: 12, + y: 0, + }, + }; + const libPanelOutOfRow = createPanelSaveModel({ + title: 'Library Panel', + gridPos: { x: 0, y: 8, w: 12, h: 8 }, + libraryPanel: { + uid: '123', + name: 'My Panel', + }, + }); const rowWithPanel = createPanelSaveModel({ title: 'Row with panel', type: 'row', @@ -217,7 +268,7 @@ describe('transformSaveModelToScene', () => { h: 1, w: 24, x: 0, - y: 8, + y: 16, }, // This panels array is not used if the row is not collapsed panels: [], @@ -227,17 +278,35 @@ describe('transformSaveModelToScene', () => { h: 8, w: 12, x: 0, - y: 9, + y: 17, }, title: 'In row 1', }); + const widgetLibPanelInRow = { + title: 'Widget Panel', + type: 'add-library-panel', + gridPos: { + h: 8, + w: 12, + x: 12, + y: 17, + }, + }; + const libPanelInRow = createPanelSaveModel({ + title: 'Library Panel', + gridPos: { x: 0, y: 25, w: 12, h: 8 }, + libraryPanel: { + uid: '123', + name: 'My Panel', + }, + }); const emptyRow = createPanelSaveModel({ collapsed: false, gridPos: { h: 1, w: 24, x: 0, - y: 17, + y: 26, }, // This panels array is not used if the row is not collapsed panels: [], @@ -246,7 +315,16 @@ describe('transformSaveModelToScene', () => { }); const dashboard = { ...defaultDashboard, - panels: [panelOutOfRow, rowWithPanel, panelInRow, emptyRow], + panels: [ + panelOutOfRow, + widgetLibPanelOutOfRow, + libPanelOutOfRow, + rowWithPanel, + panelInRow, + widgetLibPanelInRow, + libPanelInRow, + emptyRow, + ], }; const oldModel = new DashboardModel(dashboard); @@ -254,25 +332,37 @@ describe('transformSaveModelToScene', () => { const scene = createDashboardSceneFromDashboardModel(oldModel); const body = scene.state.body as SceneGridLayout; - expect(body.state.children).toHaveLength(3); + expect(body.state.children).toHaveLength(5); expect(body).toBeInstanceOf(SceneGridLayout); // Panel out of row expect(body.state.children[0]).toBeInstanceOf(SceneGridItem); const panelOutOfRowVizPanel = body.state.children[0] as SceneGridItem; expect((panelOutOfRowVizPanel.state.body as VizPanel)?.state.title).toBe(panelOutOfRow.title); - // Row with panel - expect(body.state.children[1]).toBeInstanceOf(SceneGridRow); - const rowWithPanelsScene = body.state.children[1] as SceneGridRow; + // widget lib panel out of row + expect(body.state.children[1]).toBeInstanceOf(SceneGridItem); + const panelOutOfRowWidget = body.state.children[1] as SceneGridItem; + expect(panelOutOfRowWidget.state.body!).toBeInstanceOf(AddLibraryPanelWidget); + // lib panel out of row + expect(body.state.children[2]).toBeInstanceOf(SceneGridItem); + const panelOutOfRowLibVizPanel = body.state.children[2] as SceneGridItem; + expect(panelOutOfRowLibVizPanel.state.body!).toBeInstanceOf(LibraryVizPanel); + // Row with panels + expect(body.state.children[3]).toBeInstanceOf(SceneGridRow); + const rowWithPanelsScene = body.state.children[3] as SceneGridRow; expect(rowWithPanelsScene.state.title).toBe(rowWithPanel.title); expect(rowWithPanelsScene.state.key).toBe('panel-10'); - expect(rowWithPanelsScene.state.children).toHaveLength(1); + expect(rowWithPanelsScene.state.children).toHaveLength(3); + const widget = rowWithPanelsScene.state.children[1] as SceneGridItem; + expect(widget.state.body!).toBeInstanceOf(AddLibraryPanelWidget); + const libPanel = rowWithPanelsScene.state.children[2] as SceneGridItem; + expect(libPanel.state.body!).toBeInstanceOf(LibraryVizPanel); // Panel within row expect(rowWithPanelsScene.state.children[0]).toBeInstanceOf(SceneGridItem); const panelInRowVizPanel = rowWithPanelsScene.state.children[0] as SceneGridItem; expect((panelInRowVizPanel.state.body as VizPanel).state.title).toBe(panelInRow.title); // Empty row - expect(body.state.children[2]).toBeInstanceOf(SceneGridRow); - const emptyRowScene = body.state.children[2] as SceneGridRow; + expect(body.state.children[4]).toBeInstanceOf(SceneGridRow); + const emptyRowScene = body.state.children[4] as SceneGridRow; expect(emptyRowScene.state.title).toBe(emptyRow.title); expect(emptyRowScene.state.children).toHaveLength(0); }); @@ -382,7 +472,9 @@ describe('transformSaveModelToScene', () => { }; const { vizPanel } = buildGridItemForTest(panel); - expect(vizPanel.state.$data).toBeInstanceOf(ShareQueryDataProvider); + expect(vizPanel.state.$data).toBeInstanceOf(SceneDataTransformer); + expect(vizPanel.state.$data?.state.$data).toBeInstanceOf(SceneQueryRunner); + expect((vizPanel.state.$data?.state.$data as SceneQueryRunner).state.queries).toEqual(panel.targets); }); it('should not set SceneQueryRunner for plugins with skipDataQuery', () => { @@ -423,6 +515,53 @@ describe('transformSaveModelToScene', () => { expect(repeater.state.repeatDirection).toBe('v'); expect(repeater.state.maxPerRow).toBe(8); }); + + it('should apply query caching options to SceneQueryRunner', () => { + const panel = { + title: '', + type: 'test-plugin', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + transparent: true, + cacheTimeout: '10', + queryCachingTTL: 200000, + }; + + const { vizPanel } = buildGridItemForTest(panel); + const runner = getQueryRunnerFor(vizPanel)!; + expect(runner.state.cacheTimeout).toBe('10'); + expect(runner.state.queryCachingTTL).toBe(200000); + }); + it('should convert saved lib widget to AddLibraryPanelWidget', () => { + const panel = { + id: 10, + type: 'add-library-panel', + }; + + const gridItem = buildGridItemForLibraryPanelWidget(new PanelModel(panel))!; + const libPanelWidget = gridItem.state.body as AddLibraryPanelWidget; + + expect(libPanelWidget.state.key).toEqual(getVizPanelKeyForPanelId(panel.id)); + }); + + it('should convert saved lib panel to LibraryVizPanel', () => { + const panel = { + title: 'Panel', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + transparent: true, + libraryPanel: { + uid: '123', + name: 'My Panel', + folderUid: '456', + }, + }; + + const gridItem = buildGridItemForLibPanel(new PanelModel(panel))!; + const libVizPanel = gridItem.state.body as LibraryVizPanel; + + expect(libVizPanel.state.uid).toEqual(panel.libraryPanel.uid); + expect(libVizPanel.state.name).toEqual(panel.libraryPanel.name); + expect(libVizPanel.state.title).toEqual(panel.title); + }); }); describe('when creating variables objects', () => { @@ -772,6 +911,252 @@ describe('transformSaveModelToScene', () => { }); }); + it('should migrate adhoc variable', () => { + const variable: TypedVariableModel = { + id: 'adhoc', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'adhoc', + label: 'Adhoc Label', + description: 'Adhoc Description', + type: 'adhoc', + rootStateKey: 'N4XLmH5Vz', + datasource: { + uid: 'gdev-prometheus', + type: 'prometheus', + }, + filters: [ + { + key: 'filterTest', + operator: '=', + value: 'test', + }, + ], + baseFilters: [ + { + key: 'baseFilterTest', + operator: '=', + value: 'test', + }, + ], + hide: 0, + skipUrlSync: false, + }; + + const migrated = createSceneVariableFromVariableModel(variable) as AdHocFiltersVariable; + const filterVarState = migrated.state; + + expect(migrated).toBeInstanceOf(AdHocFiltersVariable); + expect(filterVarState).toEqual({ + key: expect.any(String), + description: 'Adhoc Description', + hide: 0, + label: 'Adhoc Label', + name: 'adhoc', + skipUrlSync: false, + type: 'adhoc', + filterExpression: 'filterTest="test"', + filters: [{ key: 'filterTest', operator: '=', value: 'test' }], + baseFilters: [{ key: 'baseFilterTest', operator: '=', value: 'test' }], + datasource: { uid: 'gdev-prometheus', type: 'prometheus' }, + applyMode: 'auto', + }); + }); + + it('should migrate adhoc variable with default keys', () => { + const variable: TypedVariableModel = { + id: 'adhoc', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'adhoc', + label: 'Adhoc Label', + description: 'Adhoc Description', + type: 'adhoc', + rootStateKey: 'N4XLmH5Vz', + datasource: { + uid: 'gdev-prometheus', + type: 'prometheus', + }, + filters: [ + { + key: 'filterTest', + operator: '=', + value: 'test', + }, + ], + baseFilters: [ + { + key: 'baseFilterTest', + operator: '=', + value: 'test', + }, + ], + defaultKeys: [ + { + text: 'some', + value: '1', + }, + { + text: 'static', + value: '2', + }, + { + text: 'keys', + value: '3', + }, + ], + hide: 0, + skipUrlSync: false, + }; + + const migrated = createSceneVariableFromVariableModel(variable) as AdHocFiltersVariable; + const filterVarState = migrated.state; + + expect(migrated).toBeInstanceOf(AdHocFiltersVariable); + expect(filterVarState).toEqual({ + key: expect.any(String), + description: 'Adhoc Description', + hide: 0, + label: 'Adhoc Label', + name: 'adhoc', + skipUrlSync: false, + type: 'adhoc', + filterExpression: 'filterTest="test"', + filters: [{ key: 'filterTest', operator: '=', value: 'test' }], + baseFilters: [{ key: 'baseFilterTest', operator: '=', value: 'test' }], + datasource: { uid: 'gdev-prometheus', type: 'prometheus' }, + applyMode: 'auto', + defaultKeys: [ + { + text: 'some', + value: '1', + }, + { + text: 'static', + value: '2', + }, + { + text: 'keys', + value: '3', + }, + ], + }); + }); + + describe('when groupByVariable feature toggle is enabled', () => { + beforeAll(() => { + config.featureToggles.groupByVariable = true; + }); + + afterAll(() => { + config.featureToggles.groupByVariable = false; + }); + + it('should migrate groupby variable', () => { + const variable: GroupByVariableModel = { + id: 'groupby', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'groupby', + label: 'GroupBy Label', + description: 'GroupBy Description', + type: 'groupby', + rootStateKey: 'N4XLmH5Vz', + datasource: { + uid: 'gdev-prometheus', + type: 'prometheus', + }, + multi: true, + options: [ + { + selected: false, + text: 'Foo', + value: 'foo', + }, + { + selected: false, + text: 'Bar', + value: 'bar', + }, + ], + current: {}, + query: '', + hide: 0, + skipUrlSync: false, + }; + + const migrated = createSceneVariableFromVariableModel(variable) as GroupByVariable; + const groupbyVarState = migrated.state; + + expect(migrated).toBeInstanceOf(GroupByVariable); + expect(groupbyVarState).toEqual({ + key: expect.any(String), + description: 'GroupBy Description', + hide: 0, + defaultOptions: [ + { + selected: false, + text: 'Foo', + value: 'foo', + }, + { + selected: false, + text: 'Bar', + value: 'bar', + }, + ], + isMulti: true, + layout: 'horizontal', + noValueOnClear: true, + label: 'GroupBy Label', + name: 'groupby', + skipUrlSync: false, + type: 'groupby', + baseFilters: [], + options: [], + text: [], + value: [], + datasource: { uid: 'gdev-prometheus', type: 'prometheus' }, + applyMode: 'auto', + }); + }); + }); + + describe('when groupByVariable feature toggle is disabled', () => { + it('should not migrate groupby variable and throw an error instead', () => { + const variable: GroupByVariableModel = { + id: 'groupby', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'groupby', + label: 'GroupBy Label', + description: 'GroupBy Description', + type: 'groupby', + rootStateKey: 'N4XLmH5Vz', + datasource: { + uid: 'gdev-prometheus', + type: 'prometheus', + }, + multi: true, + options: [], + current: {}, + query: '', + hide: 0, + skipUrlSync: false, + }; + + expect(() => createSceneVariableFromVariableModel(variable)).toThrow('Scenes: Unsupported variable type'); + }); + }); + it.each(['system'])('should throw for unsupported (yet) variables', (type) => { const variable = { name: 'query0', @@ -839,9 +1224,7 @@ describe('transformSaveModelToScene', () => { const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} }); expect(scene.state.$data).toBeInstanceOf(SceneDataLayers); - expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[2]).toBeInstanceOf( - SceneDataLayerControls - ); + expect(scene.state.controls!.state.variableControls[1]).toBeInstanceOf(SceneDataLayerControls); const dataLayers = scene.state.$data as SceneDataLayers; expect(dataLayers.state.layers).toHaveLength(4); @@ -869,9 +1252,7 @@ describe('transformSaveModelToScene', () => { const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} }); expect(scene.state.$data).toBeInstanceOf(SceneDataLayers); - expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[2]).toBeInstanceOf( - SceneDataLayerControls - ); + expect(scene.state.controls!.state.variableControls[1]).toBeInstanceOf(SceneDataLayerControls); const dataLayers = scene.state.$data as SceneDataLayers; expect(dataLayers.state.layers).toHaveLength(5); @@ -885,15 +1266,59 @@ describe('transformSaveModelToScene', () => { const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} }); expect(scene.state.$data).toBeInstanceOf(SceneDataLayers); - expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[2]).toBeInstanceOf( - SceneDataLayerControls - ); + expect(scene.state.controls!.state.variableControls[1]).toBeInstanceOf(SceneDataLayerControls); const dataLayers = scene.state.$data as SceneDataLayers; expect(dataLayers.state.layers).toHaveLength(5); expect(dataLayers.state.layers[4].state.name).toBe('Alert States'); }); }); + + describe('when rendering a legacy snapshot as scene', () => { + it('should convert snapshotData to snapshot inside targets', () => { + const panel = createPanelSaveModel({ + title: 'test', + gridPos: { x: 1, y: 0, w: 12, h: 8 }, + // @ts-ignore + snapshotData: [ + { + fields: [ + { + name: 'Field 1', + type: 'time', + values: ['value1', 'value2'], + config: {}, + }, + { + name: 'Field 2', + type: 'number', + values: [1], + config: {}, + }, + ], + }, + ], + }) as Panel; + + const oldPanelModel = new PanelModel(panel); + convertOldSnapshotToScenesSnapshot(oldPanelModel); + + expect(oldPanelModel.snapshotData?.length).toStrictEqual(0); + expect(oldPanelModel.targets.length).toStrictEqual(1); + expect(oldPanelModel.datasource).toStrictEqual(GRAFANA_DATASOURCE_REF); + expect(oldPanelModel.targets[0].datasource).toStrictEqual(GRAFANA_DATASOURCE_REF); + expect(oldPanelModel.targets[0].queryType).toStrictEqual('snapshot'); + // @ts-ignore + expect(oldPanelModel.targets[0].snapshot.length).toBe(1); + // @ts-ignore + expect(oldPanelModel.targets[0].snapshot[0].data.values).toStrictEqual([['value1', 'value2'], [1]]); + // @ts-ignore + expect(oldPanelModel.targets[0].snapshot[0].schema.fields).toStrictEqual([ + { config: {}, name: 'Field 1', type: 'time' }, + { config: {}, name: 'Field 2', type: 'number' }, + ]); + }); + }); }); function buildGridItemForTest(saveModel: Partial<Panel>): { gridItem: SceneGridItem; vizPanel: VizPanel } { diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 77ee443707a9e..015146d7b5d93 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -1,4 +1,4 @@ -import { AdHocVariableModel, TypedVariableModel, VariableModel } from '@grafana/data'; +import { DataFrameDTO, DataFrameJSON, TypedVariableModel } from '@grafana/data'; import { config } from '@grafana/runtime'; import { VizPanel, @@ -24,36 +24,41 @@ import { SceneDataLayers, SceneDataLayerProvider, SceneDataLayerControls, - AdHocFilterSet, TextBoxVariable, UserActionEvent, + GroupByVariable, + AdHocFiltersVariable, + SceneFlexLayout, } from '@grafana/scenes'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { trackDashboardLoaded } from 'app/features/dashboard/utils/tracking'; import { DashboardDTO } from 'app/types'; +import { AddLibraryPanelWidget } from '../scene/AddLibraryPanelWidget'; import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardControls } from '../scene/DashboardControls'; -import { DashboardLinksControls } from '../scene/DashboardLinksControls'; import { registerDashboardMacro } from '../scene/DashboardMacro'; import { DashboardScene } from '../scene/DashboardScene'; import { LibraryVizPanel } from '../scene/LibraryVizPanel'; import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; -import { getPanelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior'; +import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior'; import { PanelNotices } from '../scene/PanelNotices'; import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem'; import { PanelTimeRange } from '../scene/PanelTimeRange'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; +import { RowActions } from '../scene/row-actions/RowActions'; import { setDashboardPanelContext } from '../scene/setDashboardPanelContext'; import { createPanelDataProvider } from '../utils/createPanelDataProvider'; import { DashboardInteractions } from '../utils/interactions'; import { getCurrentValueForOldIntervalModel, - getIntervalsFromOldIntervalModel, + getIntervalsFromQueryString, getVizPanelKeyForPanelId, } from '../utils/utils'; import { getAngularPanelMigrationHandler } from './angularMigration'; +import { GRAFANA_DATASOURCE_REF } from './const'; export interface DashboardLoaderState { dashboard?: DashboardScene; @@ -61,13 +66,19 @@ export interface DashboardLoaderState { loadError?: string; } +export interface SaveModelToSceneOptions { + isEmbedded?: boolean; +} + export function transformSaveModelToScene(rsp: DashboardDTO): DashboardScene { // Just to have migrations run - const oldModel = new DashboardModel(rsp.dashboard, rsp.meta, { - autoMigrateOldPanels: false, - }); + const oldModel = new DashboardModel(rsp.dashboard, rsp.meta); + + const scene = createDashboardSceneFromDashboardModel(oldModel); + // TODO: refactor createDashboardSceneFromDashboardModel to work on Dashboard schema model + scene.setInitialSaveModel(rsp.dashboard); - return createDashboardSceneFromDashboardModel(oldModel); + return scene; } export function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridItemLike[] { @@ -99,12 +110,36 @@ export function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridI currentRowPanels = []; } } + } else if (panel.type === 'add-library-panel') { + const gridItem = buildGridItemForLibraryPanelWidget(panel); + + if (!gridItem) { + continue; + } + + if (currentRow) { + currentRowPanels.push(gridItem); + } else { + panels.push(gridItem); + } } else if (panel.libraryPanel?.uid && !('model' in panel.libraryPanel)) { const gridItem = buildGridItemForLibPanel(panel); - if (gridItem) { + + if (!gridItem) { + continue; + } + + if (currentRow) { + currentRowPanels.push(gridItem); + } else { panels.push(gridItem); } } else { + // when rendering a snapshot created with the legacy Dashboards convert data to new snapshot format to be compatible with Scenes + if (panel.snapshotData) { + convertOldSnapshotToScenesSnapshot(panel); + } + const panelObject = buildGridItemForPanel(panel); // when processing an expanded row, collect its panels @@ -132,6 +167,25 @@ function createRowFromPanelModel(row: PanelModel, content: SceneGridItemLike[]): if (!(saveModel instanceof PanelModel)) { saveModel = new PanelModel(saveModel); } + + if (saveModel.type === 'add-library-panel') { + const gridItem = buildGridItemForLibraryPanelWidget(saveModel); + + if (!gridItem) { + throw new Error('Failed to build grid item for library panel widget'); + } + + return gridItem; + } else if (saveModel.libraryPanel?.uid && !('model' in saveModel.libraryPanel)) { + const gridItem = buildGridItemForLibPanel(saveModel); + + if (!gridItem) { + throw new Error('Failed to build grid item for library panel'); + } + + return gridItem; + } + return buildGridItemForPanel(saveModel); }); } @@ -158,30 +212,18 @@ function createRowFromPanelModel(row: PanelModel, content: SceneGridItemLike[]): isCollapsed: row.collapsed, children: children, $behaviors: behaviors, + actions: new RowActions({}), }); } export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel) { let variables: SceneVariableSet | undefined = undefined; let layers: SceneDataLayerProvider[] = []; - let filtersSets: AdHocFilterSet[] = []; if (oldModel.templating?.list?.length) { const variableObjects = oldModel.templating.list .map((v) => { try { - if (isAdhocVariable(v)) { - filtersSets.push( - new AdHocFilterSet({ - name: v.name, - datasource: v.datasource, - filters: v.filters ?? [], - baseFilters: v.baseFilters ?? [], - }) - ); - return null; - } - return createSceneVariableFromVariableModel(v); } catch (err) { console.error(err); @@ -202,11 +244,11 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel) }); } - if (oldModel.annotations?.list?.length) { + if (oldModel.annotations?.list?.length && !oldModel.isSnapshot()) { layers = oldModel.annotations?.list.map((a) => { // Each annotation query is an individual data layer return new DashboardAnnotationsDataLayer({ - key: `annnotations-${a.name}`, + key: `annotations-${a.name}`, query: a, name: a.name, isEnabled: Boolean(a.enable), @@ -232,14 +274,16 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel) } const dashboardScene = new DashboardScene({ - title: oldModel.title, - tags: oldModel.tags || [], - links: oldModel.links || [], - uid: oldModel.uid, - id: oldModel.id, description: oldModel.description, editable: oldModel.editable, + id: oldModel.id, + isDirty: false, + links: oldModel.links || [], meta: oldModel.meta, + tags: oldModel.tags || [], + title: oldModel.title, + uid: oldModel.uid, + version: oldModel.version, body: new SceneGridLayout({ isLazy: true, children: createSceneObjectsForPanels(oldModel.panels), @@ -250,14 +294,18 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel) fiscalYearStartMonth: oldModel.fiscalYearStartMonth, timeZone: oldModel.timezone, weekStart: oldModel.weekStart, + UNSAFE_nowDelay: oldModel.timepicker?.nowDelay, }), $variables: variables, $behaviors: [ - registerDashboardMacro, new behaviors.CursorSync({ sync: oldModel.graphTooltip, }), + new behaviors.SceneQueryController(), + registerDashboardMacro, + registerDashboardSceneTracking(oldModel), registerPanelInteractionsReporter, + trackIfIsEmpty, ], $data: layers.length > 0 @@ -265,20 +313,16 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel) layers, }) : undefined, - controls: [ - new DashboardControls({ - variableControls: [new VariableValueSelectors({}), ...filtersSets, new SceneDataLayerControls()], - timeControls: [ - new SceneTimePicker({}), - new SceneRefreshPicker({ - refresh: oldModel.refresh, - intervals: oldModel.timepicker.refresh_intervals, - }), - ], - linkControls: new DashboardLinksControls({}), - hideTimeControls: oldModel.timepicker.hidden, + controls: new DashboardControls({ + variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()], + timePicker: new SceneTimePicker({}), + refreshPicker: new SceneRefreshPicker({ + refresh: oldModel.refresh, + intervals: oldModel.timepicker.refresh_intervals, + withText: true, }), - ], + hideTimeControls: oldModel.timepicker.hidden, + }), }); return dashboardScene; @@ -288,13 +332,27 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode const commonProperties = { name: variable.name, label: variable.label, + description: variable.description, }; + if (variable.type === 'adhoc') { + return new AdHocFiltersVariable({ + ...commonProperties, + description: variable.description, + skipUrlSync: variable.skipUrlSync, + hide: variable.hide, + datasource: variable.datasource, + applyMode: 'auto', + filters: variable.filters ?? [], + baseFilters: variable.baseFilters ?? [], + defaultKeys: variable.defaultKeys, + }); + } if (variable.type === 'custom') { return new CustomVariable({ ...commonProperties, value: variable.current?.value ?? '', text: variable.current?.text ?? '', - description: variable.description, + query: variable.query, isMulti: variable.multi, allValue: variable.allValue || undefined, @@ -308,7 +366,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode ...commonProperties, value: variable.current?.value ?? '', text: variable.current?.text ?? '', - description: variable.description, + query: variable.query, datasource: variable.datasource, sort: variable.sort, @@ -327,7 +385,6 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode ...commonProperties, value: variable.current?.value ?? '', text: variable.current?.text ?? '', - description: variable.description, regex: variable.regex, pluginId: variable.query, allValue: variable.allValue || undefined, @@ -338,12 +395,11 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode hide: variable.hide, }); } else if (variable.type === 'interval') { - const intervals = getIntervalsFromOldIntervalModel(variable); + const intervals = getIntervalsFromQueryString(variable.query); const currentInterval = getCurrentValueForOldIntervalModel(variable, intervals); return new IntervalVariable({ ...commonProperties, value: currentInterval, - description: variable.description, intervals: intervals, autoEnabled: variable.auto, autoStepCount: variable.auto_count, @@ -355,7 +411,6 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode } else if (variable.type === 'constant') { return new ConstantVariable({ ...commonProperties, - description: variable.description, value: variable.query, skipUrlSync: variable.skipUrlSync, hide: variable.hide, @@ -363,16 +418,44 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode } else if (variable.type === 'textbox') { return new TextBoxVariable({ ...commonProperties, - description: variable.description, - value: variable.query, + value: variable?.current?.value?.[0] ?? variable.query, skipUrlSync: variable.skipUrlSync, hide: variable.hide, }); + } else if (config.featureToggles.groupByVariable && variable.type === 'groupby') { + return new GroupByVariable({ + ...commonProperties, + datasource: variable.datasource, + value: variable.current?.value || [], + text: variable.current?.text || [], + skipUrlSync: variable.skipUrlSync, + hide: variable.hide, + // @ts-expect-error + defaultOptions: variable.options, + }); } else { throw new Error(`Scenes: Unsupported variable type ${variable.type}`); } } +export function buildGridItemForLibraryPanelWidget(panel: PanelModel) { + if (panel.type !== 'add-library-panel') { + return null; + } + + const body = new AddLibraryPanelWidget({ + key: getVizPanelKeyForPanelId(panel.id), + }); + + return new SceneGridItem({ + body, + y: panel.gridPos.y, + x: panel.gridPos.x, + width: panel.gridPos.w, + height: panel.gridPos.h, + }); +} + export function buildGridItemForLibPanel(panel: PanelModel) { if (!panel.libraryPanel) { return null; @@ -382,7 +465,7 @@ export function buildGridItemForLibPanel(panel: PanelModel) { title: panel.title, uid: panel.libraryPanel.uid, name: panel.libraryPanel.name, - key: getVizPanelKeyForPanelId(panel.id), + panelKey: getVizPanelKeyForPanelId(panel.id), }); return new SceneGridItem({ @@ -395,16 +478,14 @@ export function buildGridItemForLibPanel(panel: PanelModel) { } export function buildGridItemForPanel(panel: PanelModel): SceneGridItemLike { - const hasPanelLinks = panel.links && panel.links.length > 0; const titleItems: SceneObject[] = []; - let panelLinks; - if (hasPanelLinks) { - panelLinks = new VizPanelLinks({ - menu: new VizPanelLinksMenu({ $behaviors: [getPanelLinksBehavior(panel)] }), - }); - titleItems.push(panelLinks); - } + titleItems.push( + new VizPanelLinks({ + rawLinks: panel.links, + menu: new VizPanelLinksMenu({ $behaviors: [panelLinksBehavior] }), + }) + ); titleItems.push(new PanelNotices()); @@ -438,7 +519,8 @@ export function buildGridItemForPanel(panel: PanelModel): SceneGridItemLike { } if (panel.repeat) { - const repeatDirection = panel.repeatDirection ?? 'h'; + const repeatDirection = panel.repeatDirection === 'h' ? 'h' : 'v'; + return new PanelRepeaterGridItem({ key: `grid-item-${panel.id}`, x: panel.gridPos.x, @@ -449,7 +531,7 @@ export function buildGridItemForPanel(panel: PanelModel): SceneGridItemLike { source: new VizPanel(vizPanelState), variableName: panel.repeat, repeatedPanels: [], - repeatDirection: panel.repeatDirection, + repeatDirection: repeatDirection, maxPerRow: panel.maxPerRow, }); } @@ -466,8 +548,6 @@ export function buildGridItemForPanel(panel: PanelModel): SceneGridItemLike { }); } -const isAdhocVariable = (v: VariableModel): v is AdHocVariableModel => v.type === 'adhoc'; - const getLimitedDescriptionReporter = () => { const reportedPanels: string[] = []; @@ -480,6 +560,18 @@ const getLimitedDescriptionReporter = () => { }; }; +function registerDashboardSceneTracking(model: DashboardModel) { + return () => { + const unsetDashboardInteractionsScenesContext = DashboardInteractions.setScenesContext(); + + trackDashboardLoaded(model, model.version); + + return () => { + unsetDashboardInteractionsScenesContext(); + }; + }; +} + function registerPanelInteractionsReporter(scene: DashboardScene) { const descriptionReporter = getLimitedDescriptionReporter(); @@ -502,3 +594,53 @@ function registerPanelInteractionsReporter(scene: DashboardScene) { } }); } + +export function trackIfIsEmpty(parent: DashboardScene) { + updateIsEmpty(parent); + + parent.state.body.subscribeToState(() => { + updateIsEmpty(parent); + }); +} + +function updateIsEmpty(parent: DashboardScene) { + const { body } = parent.state; + if (body instanceof SceneFlexLayout || body instanceof SceneGridLayout) { + parent.setState({ isEmpty: body.state.children.length === 0 }); + } +} + +const convertSnapshotData = (snapshotData: DataFrameDTO[]): DataFrameJSON[] => { + return snapshotData.map((data) => { + return { + data: { + values: data.fields.map((field) => field.values).filter((values): values is unknown[] => values !== undefined), + }, + schema: { + fields: data.fields.map((field) => ({ + name: field.name, + type: field.type, + config: field.config, + })), + }, + }; + }); +}; + +// override panel datasource and targets with snapshot data using the Grafana datasource +export const convertOldSnapshotToScenesSnapshot = (panel: PanelModel) => { + // only old snapshots created with old dashboards contains snapshotData + if (panel.snapshotData) { + panel.datasource = GRAFANA_DATASOURCE_REF; + panel.targets = [ + { + refId: panel.snapshotData[0]?.refId ?? '', + datasource: panel.datasource, + queryType: 'snapshot', + // @ts-ignore + snapshot: convertSnapshotData(panel.snapshotData), + }, + ]; + panel.snapshotData = []; + } +}; diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts index 4f78d9de36b94..7c3dadd80c974 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts @@ -1,3 +1,4 @@ +import 'whatwg-fetch'; import { advanceTo } from 'jest-date-mock'; import { map, of } from 'rxjs'; @@ -18,10 +19,10 @@ import { getPluginLinkExtensions, setPluginImportUtils } from '@grafana/runtime' import { MultiValueVariable, SceneDataLayers, + SceneGridItem, SceneGridItemLike, SceneGridLayout, SceneGridRow, - SceneVariable, VizPanel, } from '@grafana/scenes'; import { Dashboard, LoadingState, Panel, RowPanel, VariableRefresh } from '@grafana/schema'; @@ -30,7 +31,9 @@ import { getTimeRange } from 'app/features/dashboard/utils/timeRange'; import { reduceTransformRegistryItem } from 'app/features/transformers/editors/ReduceTransformerEditor'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; +import { NEW_LINK } from '../settings/links/utils'; import { activateFullSceneTree, buildPanelRepeaterScene } from '../utils/test-utils'; import { getVizPanelKeyForPanelId } from '../utils/utils'; @@ -41,6 +44,7 @@ import snapshotableDashboardJson from './testfiles/snapshotable_dashboard.json'; import snapshotableWithRowsDashboardJson from './testfiles/snapshotable_with_rows.json'; import { buildGridItemForLibPanel, + buildGridItemForLibraryPanelWidget, buildGridItemForPanel, transformSaveModelToScene, } from './transformSaveModelToScene'; @@ -185,6 +189,7 @@ describe('transformSceneToSaveModel', () => { time_options: ['5m', '15m', '30m'], hidden: true, }, + links: [{ ...NEW_LINK, title: 'Link 1' }], }; const scene = transformSaveModelToScene({ dashboard: dashboardWithCustomSettings as any, meta: {} }); const saveModel = transformSceneToSaveModel(scene); @@ -224,7 +229,7 @@ describe('transformSceneToSaveModel', () => { const rowRepeater = rowWithRepeat.state.$behaviors![0] as RowRepeaterBehavior; // trigger row repeater - rowRepeater.variableDependency?.variableUpdatesCompleted(new Set<SceneVariable>([variable])); + rowRepeater.variableDependency?.variableUpdateCompleted(variable, true); // Make sure the repeated rows have been added to runtime scene model expect(grid.state.children.length).toBe(5); @@ -276,28 +281,78 @@ describe('transformSceneToSaveModel', () => { expect(saveModel.gridPos?.w).toBe(12); expect(saveModel.gridPos?.h).toBe(8); }); + it('Given panel with links', () => { + const gridItem = buildGridItemFromPanelSchema({ + title: '', + type: 'text-plugin-34', + gridPos: { x: 1, y: 2, w: 12, h: 8 }, + links: [ + // @ts-expect-error Panel link is wrongly typed as DashboardLink + { + title: 'Link 1', + url: 'http://some.test.link1', + }, + // @ts-expect-error Panel link is wrongly typed as DashboardLink + { + targetBlank: true, + title: 'Link 2', + url: 'http://some.test.link2', + }, + ], + }); + + const saveModel = gridItemToPanel(gridItem); + expect(saveModel.links).toEqual([ + { + title: 'Link 1', + url: 'http://some.test.link1', + }, + { + targetBlank: true, + title: 'Link 2', + url: 'http://some.test.link2', + }, + ]); + }); }); describe('Library panels', () => { it('given a library panel', () => { - const panel = buildGridItemFromPanelSchema({ - id: 4, - gridPos: { - h: 8, - w: 12, - x: 0, - y: 0, - }, - libraryPanel: { - name: 'Some lib panel panel', - uid: 'lib-panel-uid', - }, + // Not using buildGridItemFromPanelSchema since it strips options/fieldConfig + const libVizPanel = new LibraryVizPanel({ + name: 'Some lib panel panel', title: 'A panel', - transformations: [], - fieldConfig: { - defaults: {}, - overrides: [], - }, + uid: 'lib-panel-uid', + panelKey: 'lib-panel', + panel: new VizPanel({ + key: 'panel-4', + title: 'Panel blahh blah', + fieldConfig: { + defaults: {}, + overrides: [], + }, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + showLegend: true, + }, + tooltip: { + maxHeight: 600, + mode: 'single', + sort: 'none', + }, + }, + }), + }); + + const panel = new SceneGridItem({ + body: libVizPanel, + y: 0, + x: 0, + width: 12, + height: 8, }); const result = gridItemToPanel(panel); @@ -316,6 +371,31 @@ describe('transformSceneToSaveModel', () => { expect(result.title).toBe('A panel'); expect(result.transformations).toBeUndefined(); expect(result.fieldConfig).toBeUndefined(); + expect(result.options).toBeUndefined(); + }); + + it('given a library panel widget', () => { + const panel = buildGridItemFromPanelSchema({ + id: 4, + gridPos: { + h: 8, + w: 12, + x: 0, + y: 0, + }, + type: 'add-library-panel', + }); + + const result = gridItemToPanel(panel); + + expect(result.id).toBe(4); + expect(result.gridPos).toEqual({ + h: 8, + w: 12, + x: 0, + y: 0, + }); + expect(result.type).toBe('add-library-panel'); }); }); @@ -530,6 +610,37 @@ describe('transformSceneToSaveModel', () => { uid: SHARED_DASHBOARD_QUERY, }); }); + + it('Given panel with query caching options', () => { + const panel = buildGridItemFromPanelSchema({ + datasource: { + type: 'grafana-testdata', + uid: 'abc', + }, + cacheTimeout: '10', + queryCachingTTL: 200000, + maxDataPoints: 100, + targets: [ + { + refId: 'A', + expr: 'A', + datasource: { + type: 'grafana-testdata', + uid: 'abc', + }, + }, + { + refId: 'B', + expr: 'B', + }, + ], + }); + + const result = gridItemToPanel(panel); + + expect(result.cacheTimeout).toBe('10'); + expect(result.queryCachingTTL).toBe(200000); + }); }); describe('Snapshots', () => { @@ -679,6 +790,53 @@ describe('transformSceneToSaveModel', () => { expect(result[1].title).toEqual('Panel $server'); }); + it('handles repeated library panels', () => { + const { scene, repeater } = buildPanelRepeaterScene( + { variableQueryTime: 0, numberOfOptions: 2 }, + new LibraryVizPanel({ + name: 'Some lib panel panel', + title: 'A panel', + uid: 'lib-panel-uid', + panelKey: 'lib-panel', + panel: new VizPanel({ + key: 'panel-4', + title: 'Panel blahh blah', + fieldConfig: { + defaults: {}, + overrides: [], + }, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + showLegend: true, + }, + tooltip: { + maxHeight: 600, + mode: 'single', + sort: 'none', + }, + }, + }), + }) + ); + + activateFullSceneTree(scene); + const result = panelRepeaterToPanels(repeater, true); + + expect(result).toHaveLength(1); + + expect(result[0]).toMatchObject({ + id: 4, + title: 'A panel', + libraryPanel: { + name: 'Some lib panel panel', + uid: 'lib-panel-uid', + }, + }); + }); + it('handles row repeats ', () => { const { scene, row } = buildPanelRepeaterScene({ variableQueryTime: 0, @@ -817,15 +975,14 @@ describe('transformSceneToSaveModel', () => { expect(result.panels?.[0].gridPos).toEqual({ w: 24, x: 0, y: 0, h: 20 }); }); - // TODO: Uncomment when we support links - // it('should remove links', async () => { - // const scene = transformSaveModelToScene({ dashboard: snapshotableDashboardJson as any, meta: {} }); - // activateFullSceneTree(scene); - // const snapshot = transformSceneToSaveModel(scene, true); - // expect(snapshot.links?.length).toBe(1); - // const result = trimDashboardForSnapshot('Snap title', getTimeRange({ from: 'now-6h', to: 'now' }), snapshot); - // expect(result.links?.length).toBe(0); - // }); + it('should remove links', async () => { + const scene = transformSaveModelToScene({ dashboard: snapshotableDashboardJson as any, meta: {} }); + activateFullSceneTree(scene); + const snapshot = transformSceneToSaveModel(scene, true); + expect(snapshot.links?.length).toBe(1); + const result = trimDashboardForSnapshot('Snap title', getTimeRange({ from: 'now-6h', to: 'now' }), snapshot); + expect(result.links?.length).toBe(0); + }); }); }); }); @@ -833,6 +990,9 @@ describe('transformSceneToSaveModel', () => { export function buildGridItemFromPanelSchema(panel: Partial<Panel>): SceneGridItemLike { if (panel.libraryPanel) { return buildGridItemForLibPanel(new PanelModel(panel))!; + } else if (panel.type === 'add-library-panel') { + return buildGridItemForLibraryPanelWidget(new PanelModel(panel))!; } + return buildGridItemForPanel(new PanelModel(panel)); } diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index 2cd2edc882817..b1329a4828c21 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -1,3 +1,5 @@ +import { isEqual } from 'lodash'; + import { isEmptyObject, ScopedVars, TimeRange } from '@grafana/data'; import { behaviors, @@ -7,38 +9,38 @@ import { SceneGridLayout, SceneGridRow, VizPanel, - SceneQueryRunner, SceneDataTransformer, SceneVariableSet, - AdHocFilterSet, LocalValueVariable, - SceneRefreshPicker, } from '@grafana/scenes'; import { AnnotationQuery, Dashboard, + DashboardLink, DataTransformerConfig, defaultDashboard, defaultTimePickerConfig, FieldConfigSource, + GridPos, Panel, RowPanel, + TimePickerConfig, VariableModel, VariableRefresh, } from '@grafana/schema'; import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object'; import { getPanelDataFrames } from 'app/features/dashboard/components/HelpWizard/utils'; -import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; +import { DASHBOARD_SCHEMA_VERSION } from 'app/features/dashboard/state/DashboardMigrator'; import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; -import { DashboardControls } from '../scene/DashboardControls'; +import { AddLibraryPanelWidget } from '../scene/AddLibraryPanelWidget'; import { DashboardScene } from '../scene/DashboardScene'; import { LibraryVizPanel } from '../scene/LibraryVizPanel'; import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem'; import { PanelTimeRange } from '../scene/PanelTimeRange'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; -import { ShareQueryDataProvider } from '../scene/ShareQueryDataProvider'; -import { getPanelIdForVizPanel } from '../utils/utils'; +import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; +import { getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils'; import { GRAFANA_DATASOURCE_REF } from './const'; import { dataLayersToAnnotations } from './dataLayersToAnnotations'; @@ -50,8 +52,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa const data = state.$data; const variablesSet = state.$variables; const body = state.body; - let refresh_intervals = defaultTimePickerConfig.refresh_intervals; - let hideTimePicker: boolean = defaultTimePickerConfig.hidden; + let panels: Panel[] = []; let graphTooltip = defaultDashboard.graphTooltip; let variables: VariableModel[] = []; @@ -87,30 +88,24 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa variables = sceneVariablesSetToVariables(variablesSet); } - if (state.controls && state.controls[0] instanceof DashboardControls) { - hideTimePicker = state.controls[0].state.hideTimeControls ?? hideTimePicker; + const controlsState = state.controls?.state; - const timeControls = state.controls[0].state.timeControls; - for (const control of timeControls) { - if (control instanceof SceneRefreshPicker && control.state.intervals) { - refresh_intervals = control.state.intervals; - } - } - const variableControls = state.controls[0].state.variableControls; - for (const control of variableControls) { - if (control instanceof AdHocFilterSet) { - variables.push({ - name: control.state.name!, - type: 'adhoc', - datasource: control.state.datasource, - }); + if (state.$behaviors) { + for (const behavior of state.$behaviors!) { + if (behavior instanceof behaviors.CursorSync) { + graphTooltip = behavior.state.sync; } } } - if (state.$behaviors && state.$behaviors[0] instanceof behaviors.CursorSync) { - graphTooltip = state.$behaviors[0].state.sync; - } + const timePickerWithoutDefaults = removeDefaults<TimePickerConfig>( + { + refresh_intervals: controlsState?.refreshPicker.state.intervals, + hidden: controlsState?.hideTimeControls, + nowDelay: timeRange.UNSAFE_nowDelay, + }, + defaultTimePickerConfig + ); const dashboard: Dashboard = { ...defaultDashboard, @@ -123,11 +118,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa from: timeRange.from, to: timeRange.to, }, - timepicker: { - ...defaultTimePickerConfig, - refresh_intervals, - hidden: hideTimePicker, - }, + timepicker: timePickerWithoutDefaults, panels, annotations: { list: annotations, @@ -135,16 +126,35 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa templating: { list: variables, }, + version: state.version, timezone: timeRange.timeZone, fiscalYearStartMonth: timeRange.fiscalYearStartMonth, weekStart: timeRange.weekStart, tags: state.tags, + links: state.links, graphTooltip, + schemaVersion: DASHBOARD_SCHEMA_VERSION, }; return sortedDeepCloneWithoutNulls(dashboard); } +export function libraryVizPanelToPanel(libPanel: LibraryVizPanel, gridPos: GridPos): Panel { + if (!libPanel.state.panel) { + throw new Error('Library panel has no panel'); + } + + return { + id: getPanelIdForVizPanel(libPanel.state.panel), + title: libPanel.state.title, + gridPos: gridPos, + libraryPanel: { + name: libPanel.state.name, + uid: libPanel.state.uid, + }, + } as Panel; +} + export function gridItemToPanel(gridItem: SceneGridItemLike, isSnapshot = false): Panel { let vizPanel: VizPanel | undefined; let x = 0, @@ -160,15 +170,21 @@ export function gridItemToPanel(gridItem: SceneGridItemLike, isSnapshot = false) w = gridItem.state.width ?? 0; h = gridItem.state.height ?? 0; + return libraryVizPanelToPanel(gridItem.state.body, { x, y, w, h }); + } + + // Handle library panel widget as well and exit early + if (gridItem.state.body instanceof AddLibraryPanelWidget) { + x = gridItem.state.x ?? 0; + y = gridItem.state.y ?? 0; + w = gridItem.state.width ?? 0; + h = gridItem.state.height ?? 0; + return { id: getPanelIdForVizPanel(gridItem.state.body), - title: gridItem.state.body.state.title, + type: 'add-library-panel', gridPos: { x, y, w, h }, - libraryPanel: { - name: gridItem.state.body.state.name, - uid: gridItem.state.body.state.uid, - }, - } as Panel; + }; } if (!(gridItem.state.body instanceof VizPanel)) { @@ -183,26 +199,43 @@ export function gridItemToPanel(gridItem: SceneGridItemLike, isSnapshot = false) } if (gridItem instanceof PanelRepeaterGridItem) { - vizPanel = gridItem.state.source; x = gridItem.state.x ?? 0; y = gridItem.state.y ?? 0; w = gridItem.state.width ?? 0; h = gridItem.state.height ?? 0; + + if (gridItem.state.source instanceof LibraryVizPanel) { + return libraryVizPanelToPanel(gridItem.state.source, { x, y, w, h }); + } else { + vizPanel = gridItem.state.source; + } } if (!vizPanel) { throw new Error('Unsupported grid item type'); } + const panel: Panel = vizPanelToPanel(vizPanel, { x, y, h, w }, isSnapshot, gridItem); + + return panel; +} + +export function vizPanelToPanel( + vizPanel: VizPanel, + gridPos?: GridPos, + isSnapshot = false, + gridItem?: SceneGridItemLike +) { const panel: Panel = { id: getPanelIdForVizPanel(vizPanel), type: vizPanel.state.pluginId, title: vizPanel.state.title, - gridPos: { x, y, w, h }, + gridPos, options: vizPanel.state.options, fieldConfig: (vizPanel.state.fieldConfig as FieldConfigSource) ?? { defaults: {}, overrides: [] }, transformations: [], transparent: vizPanel.state.displayMode === 'transparent', + pluginVersion: vizPanel.state.pluginVersion, ...vizPanelDataToPanel(vizPanel, isSnapshot), }; @@ -220,6 +253,20 @@ export function gridItemToPanel(gridItem: SceneGridItemLike, isSnapshot = false) panel.repeatDirection = gridItem.getRepeatDirection(); } + const panelLinks = dashboardSceneGraph.getPanelLinks(vizPanel); + panel.links = (panelLinks?.state.rawLinks as DashboardLink[]) ?? []; + + if (panel.links.length === 0) { + delete panel.links; + } + + if (panel.transformations?.length === 0) { + delete panel.transformations; + } + + if (!panel.transparent) { + delete panel.transparent; + } return panel; } @@ -229,54 +276,27 @@ function vizPanelDataToPanel( ): Pick<Panel, 'datasource' | 'targets' | 'maxDataPoints' | 'transformations'> { const dataProvider = vizPanel.state.$data; - const panel: Pick<Panel, 'datasource' | 'targets' | 'maxDataPoints' | 'transformations'> = {}; - // Dashboard datasource handling - if (dataProvider instanceof ShareQueryDataProvider) { - panel.datasource = { - type: 'datasource', - uid: SHARED_DASHBOARD_QUERY, - }; - panel.targets = [ - { - datasource: { ...panel.datasource }, - refId: 'A', - panelId: dataProvider.state.query.panelId, - topic: dataProvider.state.query.topic, - }, - ]; - } + const panel: Pick< + Panel, + 'datasource' | 'targets' | 'maxDataPoints' | 'transformations' | 'cacheTimeout' | 'queryCachingTTL' + > = {}; + const queryRunner = getQueryRunnerFor(vizPanel); - // Regular queries handling - if (dataProvider instanceof SceneQueryRunner) { - panel.targets = dataProvider.state.queries; - panel.maxDataPoints = dataProvider.state.maxDataPoints; - panel.datasource = dataProvider.state.datasource; - } + if (queryRunner) { + panel.targets = queryRunner.state.queries; + panel.maxDataPoints = queryRunner.state.maxDataPoints; + panel.datasource = queryRunner.state.datasource; - // Transformations handling - if (dataProvider instanceof SceneDataTransformer) { - const panelData = dataProvider.state.$data; - if (panelData instanceof ShareQueryDataProvider) { - panel.datasource = { - type: 'datasource', - uid: SHARED_DASHBOARD_QUERY, - }; - panel.targets = [ - { - datasource: { ...panel.datasource }, - refId: 'A', - panelId: panelData.state.query.panelId, - topic: panelData.state.query.topic, - }, - ]; + if (queryRunner.state.cacheTimeout) { + panel.cacheTimeout = queryRunner.state.cacheTimeout; } - if (panelData instanceof SceneQueryRunner) { - panel.targets = panelData.state.queries; - panel.maxDataPoints = panelData.state.maxDataPoints; - panel.datasource = panelData.state.datasource; + if (queryRunner.state.queryCachingTTL) { + panel.queryCachingTTL = queryRunner.state.queryCachingTTL; } + } + if (dataProvider instanceof SceneDataTransformer) { panel.transformations = dataProvider.state.transformations as DataTransformerConfig[]; } @@ -306,6 +326,11 @@ export function panelRepeaterToPanels(repeater: PanelRepeaterGridItem, isSnapsho if (!isSnapshot) { return [gridItemToPanel(repeater)]; } else { + if (repeater.state.source instanceof LibraryVizPanel) { + const { x = 0, y = 0, width: w = 0, height: h = 0 } = repeater.state; + return [libraryVizPanelToPanel(repeater.state.source, { x, y, w, h })]; + } + if (repeater.state.repeatedPanels) { const itemHeight = repeater.state.itemHeight ?? 10; const rowCount = Math.ceil(repeater.state.repeatedPanels!.length / repeater.getMaxPerRow()); @@ -490,3 +515,14 @@ export function trimDashboardForSnapshot(title: string, time: TimeRange, dash: D return result; } + +function removeDefaults<T>(object: T, defaults: T): T { + const newObj = { ...object }; + for (const key in defaults) { + if (isEqual(newObj[key], defaults[key])) { + delete newObj[key]; + } + } + + return newObj; +} diff --git a/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx b/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx new file mode 100644 index 0000000000000..a895ec2a678de --- /dev/null +++ b/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx @@ -0,0 +1,218 @@ +import { map, of } from 'rxjs'; + +import { AnnotationQuery, DataQueryRequest, DataSourceApi, LoadingState, PanelData } from '@grafana/data'; +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; +import { setPluginImportUtils } from '@grafana/runtime'; +import { SceneDataLayers, SceneGridItem, SceneGridLayout, SceneTimeRange, VizPanel, dataLayers } from '@grafana/scenes'; + +import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer'; +import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; +import { DashboardScene } from '../scene/DashboardScene'; +import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; +import { activateFullSceneTree } from '../utils/test-utils'; + +import { AnnotationsEditView, MoveDirection } from './AnnotationsEditView'; +import { newAnnotationName } from './annotations/AnnotationSettingsEdit'; + +const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => { + const result: PanelData = { + state: LoadingState.Loading, + series: [], + timeRange: request.range, + }; + + return of([]).pipe( + map(() => { + result.state = LoadingState.Done; + result.series = []; + + return result; + }) + ); +}); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => { + return { + getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds1' }), + }; + }, + getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { + return runRequestMock(ds, request); + }, + config: { + ...jest.requireActual('@grafana/runtime').config, + publicDashboardAccessToken: 'ac123', + }, +})); + +setPluginImportUtils({ + importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})), + getPanelPluginFromCache: (id: string) => undefined, +}); + +describe('AnnotationsEditView', () => { + describe('Dashboard annotations state', () => { + let annotationsView: AnnotationsEditView; + let dashboardScene: DashboardScene; + + beforeEach(async () => { + const result = await buildTestScene(); + annotationsView = result.annotationsView; + dashboardScene = result.dashboard; + }); + + it('should return the correct urlKey', () => { + expect(annotationsView.getUrlKey()).toBe('annotations'); + }); + + it('should return the annotations length', () => { + expect(annotationsView.getAnnotationsLength()).toBe(1); + }); + + it('should return 0 if no annotations', () => { + dashboardScene.setState({ + $data: new SceneDataLayers({ layers: [] }), + }); + + expect(annotationsView.getAnnotationsLength()).toBe(0); + }); + + it('should add a new annotation and group it with the other annotations', () => { + const dataLayers = dashboardSceneGraph.getDataLayers(annotationsView.getDashboard()); + + expect(dataLayers?.state.layers.length).toBe(2); + + annotationsView.onNew(); + + expect(dataLayers?.state.layers.length).toBe(3); + expect(dataLayers?.state.layers[1].state.name).toBe(newAnnotationName); + expect(dataLayers?.state.layers[1].isActive).toBe(true); + }); + + it('should move an annotation up one position', () => { + const dataLayers = dashboardSceneGraph.getDataLayers(annotationsView.getDashboard()); + + annotationsView.onNew(); + + expect(dataLayers?.state.layers.length).toBe(3); + expect(dataLayers?.state.layers[0].state.name).toBe('test'); + + annotationsView.onMove(1, MoveDirection.UP); + + expect(dataLayers?.state.layers.length).toBe(3); + expect(dataLayers?.state.layers[0].state.name).toBe(newAnnotationName); + }); + + it('should move an annotation down one position', () => { + const dataLayers = dashboardSceneGraph.getDataLayers(annotationsView.getDashboard()); + + annotationsView.onNew(); + + expect(dataLayers?.state.layers.length).toBe(3); + expect(dataLayers?.state.layers[0].state.name).toBe('test'); + + annotationsView.onMove(0, MoveDirection.DOWN); + + expect(dataLayers?.state.layers.length).toBe(3); + expect(dataLayers?.state.layers[0].state.name).toBe(newAnnotationName); + }); + + it('should delete annotation at index', () => { + const dataLayers = dashboardSceneGraph.getDataLayers(annotationsView.getDashboard()); + + expect(dataLayers?.state.layers.length).toBe(2); + + annotationsView.onDelete(0); + + expect(dataLayers?.state.layers.length).toBe(1); + expect(dataLayers?.state.layers[0].state.name).toBe('Alert States'); + }); + + it('should update an annotation at index', () => { + const dataLayers = dashboardSceneGraph.getDataLayers(annotationsView.getDashboard()); + + expect(dataLayers?.state.layers[0].state.name).toBe('test'); + + const annotation: AnnotationQuery = { + ...(dataLayers?.state.layers[0] as dataLayers.AnnotationsDataLayer).state.query, + }; + + annotation.name = 'new name'; + annotation.hide = true; + annotation.enable = false; + annotation.iconColor = 'blue'; + annotationsView.onUpdate(annotation, 0); + + expect(dataLayers?.state.layers.length).toBe(2); + expect(dataLayers?.state.layers[0].state.name).toBe('new name'); + expect((dataLayers?.state.layers[0] as dataLayers.AnnotationsDataLayer).state.query.name).toBe('new name'); + expect((dataLayers?.state.layers[0] as dataLayers.AnnotationsDataLayer).state.query.hide).toBe(true); + expect((dataLayers?.state.layers[0] as dataLayers.AnnotationsDataLayer).state.query.enable).toBe(false); + expect((dataLayers?.state.layers[0] as dataLayers.AnnotationsDataLayer).state.query.iconColor).toBe('blue'); + }); + }); +}); + +async function buildTestScene() { + const annotationsView = new AnnotationsEditView({}); + const dashboard = new DashboardScene({ + $timeRange: new SceneTimeRange({}), + title: 'hello', + uid: 'dash-1', + version: 4, + meta: { + canEdit: true, + }, + $data: new SceneDataLayers({ + layers: [ + new DashboardAnnotationsDataLayer({ + key: `annotations-test`, + query: { + enable: true, + iconColor: 'red', + name: 'test', + datasource: { + type: 'grafana', + uid: '-- Grafana --', + }, + }, + name: 'test', + isEnabled: true, + isHidden: false, + }), + new AlertStatesDataLayer({ + key: 'alert-states', + name: 'Alert States', + }), + ], + }), + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + y: 0, + width: 10, + height: 12, + body: new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + }), + }), + ], + }), + editview: annotationsView, + }); + + activateFullSceneTree(dashboard); + + await new Promise((r) => setTimeout(r, 1)); + + dashboard.onEnterEditMode(); + annotationsView.activate(); + + return { dashboard, annotationsView }; +} diff --git a/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx b/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx index 89c2af02cea71..131b72c98bb2b 100644 --- a/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx +++ b/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx @@ -1,30 +1,226 @@ import React from 'react'; -import { PageLayoutType } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes'; +import { AnnotationQuery, DataTopic, NavModel, NavModelItem, PageLayoutType, getDataSourceRef } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { SceneComponentProps, SceneObjectBase, VizPanel, dataLayers } from '@grafana/scenes'; import { Page } from 'app/core/components/Page/Page'; +import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; +import { DashboardScene } from '../scene/DashboardScene'; import { NavToolbarActions } from '../scene/NavToolbarActions'; +import { dataLayersToAnnotations } from '../serialization/dataLayersToAnnotations'; +import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { getDashboardSceneFor } from '../utils/utils'; +import { EditListViewSceneUrlSync } from './EditListViewSceneUrlSync'; +import { AnnotationSettingsEdit, AnnotationSettingsList, newAnnotationName } from './annotations'; import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils'; -export interface AnnotationsEditViewState extends DashboardEditViewState {} +export enum MoveDirection { + UP = -1, + DOWN = 1, +} + +export interface AnnotationsEditViewState extends DashboardEditViewState { + editIndex?: number | undefined; +} export class AnnotationsEditView extends SceneObjectBase<AnnotationsEditViewState> implements DashboardEditView { + static Component = AnnotationsSettingsView; + public getUrlKey(): string { return 'annotations'; } - static Component = ({ model }: SceneComponentProps<AnnotationsEditView>) => { - const dashboard = getDashboardSceneFor(model); - const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey()); + protected _urlSync = new EditListViewSceneUrlSync(this); + + private get _dashboard(): DashboardScene { + return getDashboardSceneFor(this); + } + + public getDataLayer(editIndex: number): dataLayers.AnnotationsDataLayer { + const data = dashboardSceneGraph.getDataLayers(this._dashboard); + const layer = data.state.layers[editIndex]; + + if (!(layer instanceof dataLayers.AnnotationsDataLayer)) { + throw new Error('AnnotationsDataLayer not found at index ' + editIndex); + } + + return layer; + } + + public getAnnotationsLength(): number { + return dashboardSceneGraph + .getDataLayers(this._dashboard) + .state.layers.filter((layer) => layer.topic === DataTopic.Annotations).length; + } + + public getDashboard(): DashboardScene { + return this._dashboard; + } + + public onNew = () => { + const newAnnotationQuery: AnnotationQuery = { + name: newAnnotationName, + enable: true, + datasource: getDataSourceRef(getDataSourceSrv().getInstanceSettings(null)!), + iconColor: 'red', + }; + + const newAnnotation = new DashboardAnnotationsDataLayer({ + key: `annotations-${newAnnotationQuery.name}`, + query: newAnnotationQuery, + name: newAnnotationQuery.name, + isEnabled: Boolean(newAnnotationQuery.enable), + isHidden: Boolean(newAnnotationQuery.hide), + }); + + const data = dashboardSceneGraph.getDataLayers(this._dashboard); + + const layers = [...data.state.layers]; + + //keep annotation layers together + layers.splice(this.getAnnotationsLength(), 0, newAnnotation); + + data.setState({ + layers, + }); + + newAnnotation.activate(); + + this.setState({ editIndex: this.getAnnotationsLength() - 1 }); + }; + + public onEdit = (idx: number) => { + this.setState({ editIndex: idx }); + }; + + public onBackToList = () => { + this.setState({ editIndex: undefined }); + }; + + public onMove = (idx: number, direction: MoveDirection) => { + const data = dashboardSceneGraph.getDataLayers(this._dashboard); + + const layers = [...data.state.layers]; + const [layer] = layers.splice(idx, 1); + layers.splice(idx + direction, 0, layer); + + data.setState({ + layers, + }); + }; + + public onDelete = (idx: number) => { + const data = dashboardSceneGraph.getDataLayers(this._dashboard); + const layers = [...data.state.layers]; + layers.splice(idx, 1); + + data.setState({ + layers, + }); + }; + + public onUpdate = (annotation: AnnotationQuery, editIndex: number) => { + const layer = this.getDataLayer(editIndex); + + layer.setState({ + key: `annotations-${annotation.name}`, + name: annotation.name, + isEnabled: Boolean(annotation.enable), + isHidden: Boolean(annotation.hide), + query: annotation, + }); + + //need to rerun the layer to update the query and + //see the annotation on the panel + layer.runLayer(); + }; +} + +function AnnotationsSettingsView({ model }: SceneComponentProps<AnnotationsEditView>) { + const dashboard = model.getDashboard(); + const { layers } = dashboardSceneGraph.getDataLayers(dashboard).useState(); + const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey()); + const { editIndex } = model.useState(); + const panels = dashboardSceneGraph.getVizPanels(dashboard); + + const annotations: AnnotationQuery[] = dataLayersToAnnotations(layers); + + if (editIndex != null && editIndex < model.getAnnotationsLength()) { return ( - <Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}> - <NavToolbarActions dashboard={dashboard} /> - <div>Annotations todo</div> - </Page> + <AnnotationsSettingsEditView + annotationLayer={model.getDataLayer(editIndex)} + pageNav={pageNav} + panels={panels} + editIndex={editIndex} + navModel={navModel} + dashboard={dashboard} + onUpdate={model.onUpdate} + onBackToList={model.onBackToList} + onDelete={model.onDelete} + /> ); + } + + return ( + <Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}> + <NavToolbarActions dashboard={dashboard} /> + <AnnotationSettingsList + annotations={annotations} + onNew={model.onNew} + onEdit={model.onEdit} + onDelete={model.onDelete} + onMove={model.onMove} + /> + </Page> + ); +} + +interface AnnotationsSettingsEditViewProps { + annotationLayer: dataLayers.AnnotationsDataLayer; + pageNav: NavModelItem; + panels: VizPanel[]; + editIndex: number; + navModel: NavModel; + dashboard: DashboardScene; + onUpdate: (annotation: AnnotationQuery, editIndex: number) => void; + onBackToList: () => void; + onDelete: (idx: number) => void; +} + +function AnnotationsSettingsEditView({ + annotationLayer, + pageNav, + navModel, + panels, + editIndex, + dashboard, + onUpdate, + onBackToList, + onDelete, +}: AnnotationsSettingsEditViewProps) { + const parentTab = pageNav.children!.find((p) => p.active)!; + parentTab.parentItem = pageNav; + const { name, query } = annotationLayer.useState(); + + const editAnnotationPageNav = { + text: name, + parentItem: parentTab, }; + + return ( + <Page navModel={navModel} pageNav={editAnnotationPageNav} layout={PageLayoutType.Standard}> + <NavToolbarActions dashboard={dashboard} /> + <AnnotationSettingsEdit + annotation={query} + editIndex={editIndex} + panels={panels} + onUpdate={onUpdate} + onBackToList={onBackToList} + onDelete={onDelete} + /> + </Page> + ); } diff --git a/public/app/features/dashboard-scene/settings/DashboardLinksEditView.test.tsx b/public/app/features/dashboard-scene/settings/DashboardLinksEditView.test.tsx new file mode 100644 index 0000000000000..508630a256113 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/DashboardLinksEditView.test.tsx @@ -0,0 +1,260 @@ +import { render as RTLRender } from '@testing-library/react'; +import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; + +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; +import { setPluginImportUtils } from '@grafana/runtime'; +import { SceneGridItem, SceneGridLayout, SceneTimeRange, VizPanel, behaviors } from '@grafana/scenes'; +import { DashboardCursorSync } from '@grafana/schema'; + +import { DashboardControls } from '../scene/DashboardControls'; +import { DashboardScene } from '../scene/DashboardScene'; +import { activateFullSceneTree } from '../utils/test-utils'; + +import { DashboardLinksEditView } from './DashboardLinksEditView'; +import { NEW_LINK } from './links/utils'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: jest.fn().mockReturnValue({ + pathname: '/d/dash-1/settings/links', + search: '', + hash: '', + state: null, + key: '5nvxpbdafa', + }), +})); + +setPluginImportUtils({ + importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})), + getPanelPluginFromCache: (id: string) => undefined, +}); + +function render(component: React.ReactNode) { + return RTLRender(<TestProvider>{component}</TestProvider>); +} + +describe('DashboardLinksEditView', () => { + describe('Url state', () => { + let settings: DashboardLinksEditView; + + beforeEach(async () => { + const result = await buildTestScene(); + settings = result.settings; + }); + + it('should return the correct urlKey', () => { + expect(settings.getUrlKey()).toBe('links'); + }); + }); + + describe('Dashboard updates', () => { + let dashboard: DashboardScene; + let settings: DashboardLinksEditView; + + beforeEach(async () => { + const result = await buildTestScene(); + dashboard = result.dashboard; + settings = result.settings; + }); + + it('should have isDirty false', () => { + expect(dashboard.state.isDirty).toBeFalsy(); + }); + + it('should update dashboard state when adding a link', () => { + settings.onNewLink(); + + expect(dashboard.state.links[0]).toEqual(NEW_LINK); + }); + + it('should update dashboard state when deleting a link', () => { + dashboard.setState({ links: [NEW_LINK] }); + settings.onDelete(0); + + expect(dashboard.state.links).toEqual([]); + }); + + it('should update dashboard state when duplicating a link', () => { + dashboard.setState({ links: [NEW_LINK] }); + settings.onDuplicate(NEW_LINK); + + expect(dashboard.state.links).toEqual([NEW_LINK, NEW_LINK]); + }); + + it('should update dashboard state when reordering a link', () => { + dashboard.setState({ + links: [ + { ...NEW_LINK, title: 'link-1' }, + { ...NEW_LINK, title: 'link-2' }, + ], + }); + settings.onOrderChange(0, 1); + + expect(dashboard.state.links).toEqual([ + { ...NEW_LINK, title: 'link-2' }, + { ...NEW_LINK, title: 'link-1' }, + ]); + }); + + it('should update dashboard state when editing a link', () => { + dashboard.setState({ links: [{ ...NEW_LINK, title: 'old title' }] }); + settings.setState({ editIndex: 0 }); + settings.onUpdateLink({ ...NEW_LINK, title: 'new title' }); + + expect(dashboard.state.links[0].title).toEqual('new title'); + }); + }); + + describe('Edit a link', () => { + let dashboard: DashboardScene; + let settings: DashboardLinksEditView; + + beforeEach(async () => { + const result = await buildTestScene(); + dashboard = result.dashboard; + settings = result.settings; + }); + + it('should set editIndex when editing a link', () => { + dashboard.setState({ links: [{ ...NEW_LINK, title: 'old title' }] }); + settings.onEdit(0); + + expect(settings.state.editIndex).toEqual(0); + }); + + it('should set editIndex when editing a link that does not exist', () => { + dashboard.setState({ links: [{ ...NEW_LINK, title: 'old title' }] }); + settings.onEdit(1); + + expect(settings.state.editIndex).toBe(1); + }); + + it('should update dashboard state when editing a link', () => { + dashboard.setState({ links: [{ ...NEW_LINK, title: 'old title' }] }); + settings.setState({ editIndex: 0 }); + settings.onUpdateLink({ ...NEW_LINK, title: 'new title' }); + + expect(dashboard.state.links[0].title).toEqual('new title'); + }); + + it('should update dashboard state when going back', () => { + settings.setState({ editIndex: 0 }); + settings.onGoBack(); + + expect(settings.state.editIndex).toBeUndefined(); + }); + }); + + describe('Render the views', () => { + let dashboard: DashboardScene; + let settings: DashboardLinksEditView; + + beforeEach(async () => { + const result = await buildTestScene(); + dashboard = result.dashboard; + settings = result.settings; + }); + + it('should render with no errors', () => { + expect(() => render(<settings.Component model={settings} />)).not.toThrow(); + }); + + it('should render the empty state when no links', () => { + dashboard.setState({ links: [] }); + const { getByText } = render(<settings.Component model={settings} />); + + expect(getByText('Add dashboard link')).toBeInTheDocument(); + }); + + it('should render the empty state when no links', () => { + dashboard.setState({ links: [] }); + const { getByText } = render(<settings.Component model={settings} />); + + expect(getByText('Add dashboard link')).toBeInTheDocument(); + }); + + it('should render the list of link when there are links', () => { + dashboard.setState({ + links: [ + { ...NEW_LINK, title: 'link-1' }, + { ...NEW_LINK, title: 'link-2' }, + ], + }); + const { getByText } = render(<settings.Component model={settings} />); + + expect(getByText('link-1')).toBeInTheDocument(); + expect(getByText('link-2')).toBeInTheDocument(); + expect(getByText('New link')).toBeInTheDocument(); + }); + + it('should render the list of link when the editing link does not exist', () => { + dashboard.setState({ + links: [ + { ...NEW_LINK, title: 'link-1' }, + { ...NEW_LINK, title: 'link-2' }, + ], + }); + settings.setState({ editIndex: 2 }); + const { getByText } = render(<settings.Component model={settings} />); + + expect(getByText('link-1')).toBeInTheDocument(); + expect(getByText('link-2')).toBeInTheDocument(); + expect(getByText('New link')).toBeInTheDocument(); + }); + + it('should render the link form when the editing link does exist', () => { + dashboard.setState({ + links: [ + { ...NEW_LINK, title: 'link-1' }, + { ...NEW_LINK, title: 'link-2' }, + ], + }); + settings.setState({ editIndex: 1 }); + const { getByText } = render(<settings.Component model={settings} />); + + expect(getByText('Edit link')).toBeInTheDocument(); + expect(getByText('Back to list')).toBeInTheDocument(); + }); + }); +}); + +async function buildTestScene() { + const settings = new DashboardLinksEditView({}); + const dashboard = new DashboardScene({ + $timeRange: new SceneTimeRange({}), + $behaviors: [new behaviors.CursorSync({ sync: DashboardCursorSync.Off })], + controls: new DashboardControls({}), + title: 'hello', + uid: 'dash-1', + meta: { + canEdit: true, + }, + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + y: 0, + width: 10, + height: 12, + body: new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + }), + }), + ], + }), + editview: settings, + }); + + activateFullSceneTree(dashboard); + + await new Promise((r) => setTimeout(r, 1)); + + dashboard.onEnterEditMode(); + settings.activate(); + + return { dashboard, settings }; +} diff --git a/public/app/features/dashboard-scene/settings/DashboardLinksEditView.tsx b/public/app/features/dashboard-scene/settings/DashboardLinksEditView.tsx index 5be269c60924b..91d8fef4c8ee6 100644 --- a/public/app/features/dashboard-scene/settings/DashboardLinksEditView.tsx +++ b/public/app/features/dashboard-scene/settings/DashboardLinksEditView.tsx @@ -1,14 +1,15 @@ import React from 'react'; -import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; +import { NavModel, NavModelItem, PageLayoutType, arrayUtils } from '@grafana/data'; import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes'; import { DashboardLink } from '@grafana/schema'; -import { Link } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { DashboardScene } from '../scene/DashboardScene'; import { NavToolbarActions } from '../scene/NavToolbarActions'; +import { DashboardLinkForm } from '../settings/links/DashboardLinkForm'; +import { DashboardLinkList } from '../settings/links/DashboardLinkList'; +import { NEW_LINK } from '../settings/links/utils'; import { getDashboardSceneFor } from '../utils/utils'; import { EditListViewSceneUrlSync } from './EditListViewSceneUrlSync'; @@ -24,47 +25,102 @@ export class DashboardLinksEditView extends SceneObjectBase<DashboardLinksEditVi public getUrlKey(): string { return 'links'; } + + private get dashboard(): DashboardScene { + return getDashboardSceneFor(this); + } + + private get links(): DashboardLink[] { + return this.dashboard.state.links; + } + + private set links(links: DashboardLink[]) { + this.dashboard.setState({ links }); + } + + public onNewLink = () => { + this.links = [...this.links, NEW_LINK]; + this.setState({ editIndex: this.links.length - 1 }); + }; + + public onDelete = (idx: number) => { + this.links = [...this.links.slice(0, idx), ...this.links.slice(idx + 1)]; + + this.setState({ editIndex: undefined }); + }; + + public onDuplicate = (link: DashboardLink) => { + this.links = [...this.links, { ...link }]; + }; + + public onOrderChange = (idx: number, direction: number) => { + this.links = arrayUtils.moveItemImmutably(this.links, idx, idx + direction); + }; + + public onEdit = (editIndex: number) => { + this.setState({ editIndex }); + }; + + public onUpdateLink = (link: DashboardLink) => { + const idx = this.state.editIndex; + + if (idx === undefined) { + return; + } + + this.links = [...this.links.slice(0, idx), link, ...this.links.slice(idx + 1)]; + }; + + public onGoBack = () => { + this.setState({ editIndex: undefined }); + }; } function DashboardLinksEditViewRenderer({ model }: SceneComponentProps<DashboardLinksEditView>) { const { editIndex } = model.useState(); const dashboard = getDashboardSceneFor(model); - const links = dashboard.state.links || []; + const { links } = dashboard.useState(); const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey()); + const linkToEdit = editIndex !== undefined ? links[editIndex] : undefined; - if (editIndex !== undefined) { - const link = links[editIndex]; - if (link) { - return <EditLinkView pageNav={pageNav} navModel={navModel} link={link} dashboard={dashboard} />; - } + if (linkToEdit) { + return ( + <EditLinkView + pageNav={pageNav} + navModel={navModel} + link={linkToEdit} + dashboard={dashboard} + onChange={model.onUpdateLink} + onGoBack={model.onGoBack} + /> + ); } return ( <Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}> <NavToolbarActions dashboard={dashboard} /> - {links.map((link, i) => ( - <Link - key={`${link.title}-${i}`} - onClick={(e) => { - e.preventDefault(); - locationService.partial({ editIndex: i }); - }} - > - {link.title} - </Link> - ))} + <DashboardLinkList + links={links} + onNew={model.onNewLink} + onEdit={model.onEdit} + onDelete={model.onDelete} + onDuplicate={model.onDuplicate} + onOrderChange={model.onOrderChange} + /> </Page> ); } interface EditLinkViewProps { - link: DashboardLink; + link?: DashboardLink; pageNav: NavModelItem; navModel: NavModel; dashboard: DashboardScene; + onChange: (link: DashboardLink) => void; + onGoBack: () => void; } -function EditLinkView({ pageNav, link, navModel, dashboard }: EditLinkViewProps) { +function EditLinkView({ pageNav, link, navModel, dashboard, onChange, onGoBack }: EditLinkViewProps) { const parentTab = pageNav.children!.find((p) => p.active)!; parentTab.parentItem = pageNav; @@ -76,7 +132,7 @@ function EditLinkView({ pageNav, link, navModel, dashboard }: EditLinkViewProps) return ( <Page navModel={navModel} pageNav={editLinkPageNav} layout={PageLayoutType.Standard}> <NavToolbarActions dashboard={dashboard} /> - {JSON.stringify(link)} + <DashboardLinkForm link={link!} onUpdate={onChange} onGoBack={onGoBack} /> </Page> ); } diff --git a/public/app/features/dashboard-scene/settings/EditListViewSceneUrlSync.ts b/public/app/features/dashboard-scene/settings/EditListViewSceneUrlSync.ts index 0184e4b645fe1..9821bcf13c5ff 100644 --- a/public/app/features/dashboard-scene/settings/EditListViewSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/settings/EditListViewSceneUrlSync.ts @@ -1,9 +1,13 @@ import { SceneObjectUrlSyncHandler, SceneObjectUrlValues } from '@grafana/scenes'; +import { AnnotationsEditView, AnnotationsEditViewState } from './AnnotationsEditView'; import { DashboardLinksEditView, DashboardLinksEditViewState } from './DashboardLinksEditView'; +import { VariablesEditView, VariablesEditViewState } from './VariablesEditView'; +type EditListViewUrlSync = DashboardLinksEditView | VariablesEditView | AnnotationsEditView; +type EditListViewState = DashboardLinksEditViewState | VariablesEditViewState | AnnotationsEditViewState; export class EditListViewSceneUrlSync implements SceneObjectUrlSyncHandler { - constructor(private _scene: DashboardLinksEditView) {} + constructor(private _scene: EditListViewUrlSync) {} getKeys(): string[] { return ['editIndex']; @@ -17,7 +21,7 @@ export class EditListViewSceneUrlSync implements SceneObjectUrlSyncHandler { } updateFromUrl(values: SceneObjectUrlValues): void { - let update: Partial<DashboardLinksEditViewState> = {}; + let update: Partial<EditListViewState> = {}; if (typeof values.editIndex === 'string') { update = { editIndex: Number(values.editIndex) }; } else { diff --git a/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.test.tsx b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.test.tsx index 245f9334fddd8..d438483300ae2 100644 --- a/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.test.tsx +++ b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.test.tsx @@ -1,20 +1,19 @@ -import { - behaviors, - SceneGridLayout, - SceneGridItem, - SceneRefreshPicker, - SceneTimeRange, - SceneTimePicker, -} from '@grafana/scenes'; +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; +import { setPluginImportUtils } from '@grafana/runtime'; +import { behaviors, SceneGridLayout, SceneGridItem, SceneTimeRange, VizPanel } from '@grafana/scenes'; import { DashboardCursorSync } from '@grafana/schema'; import { DashboardControls } from '../scene/DashboardControls'; -import { DashboardLinksControls } from '../scene/DashboardLinksControls'; import { DashboardScene } from '../scene/DashboardScene'; import { activateFullSceneTree } from '../utils/test-utils'; import { GeneralSettingsEditView } from './GeneralSettingsEditView'; +setPluginImportUtils({ + importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})), + getPanelPluginFromCache: (id: string) => undefined, +}); + describe('GeneralSettingsEditView', () => { describe('Dashboard state', () => { let dashboard: DashboardScene; @@ -38,19 +37,9 @@ describe('GeneralSettingsEditView', () => { expect(settings.getTimeRange()).toBe(dashboard.state.$timeRange); }); - it('should return the dashboard refresh picker', () => { - expect(settings.getRefreshPicker()).toBe( - (dashboard.state?.controls?.[0] as DashboardControls)?.state?.timeControls?.[1] - ); - }); - it('should return the cursor sync', () => { expect(settings.getCursorSync()).toBe(dashboard.state.$behaviors?.[0]); }); - - it('should return the dashboard controls', () => { - expect(settings.getDashboardControls()).toBe(dashboard.state.controls?.[0]); - }); }); describe('Dashboard updates', () => { @@ -133,18 +122,7 @@ async function buildTestScene() { const dashboard = new DashboardScene({ $timeRange: new SceneTimeRange({}), $behaviors: [new behaviors.CursorSync({ sync: DashboardCursorSync.Off })], - controls: [ - new DashboardControls({ - variableControls: [], - linkControls: new DashboardLinksControls({}), - timeControls: [ - new SceneTimePicker({}), - new SceneRefreshPicker({ - intervals: ['1s'], - }), - ], - }), - ], + controls: new DashboardControls({}), title: 'hello', uid: 'dash-1', meta: { @@ -158,7 +136,11 @@ async function buildTestScene() { y: 0, width: 10, height: 12, - body: undefined, + body: new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + }), }), ], }), diff --git a/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx index 75185f94a23f5..1599ae3dd4b3f 100644 --- a/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx +++ b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx @@ -1,7 +1,7 @@ import React, { ChangeEvent } from 'react'; import { PageLayoutType } from '@grafana/data'; -import { behaviors, SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes'; +import { SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes'; import { TimeZone } from '@grafana/schema'; import { Box, @@ -61,21 +61,15 @@ export class GeneralSettingsEditView } public getRefreshPicker() { - return dashboardSceneGraph.getRefreshPicker(this._dashboard); + return this.getDashboardControls().state.refreshPicker; } public getCursorSync() { - const cursorSync = this._dashboard.state.$behaviors?.find((b) => b instanceof behaviors.CursorSync); - - if (cursorSync instanceof behaviors.CursorSync) { - return cursorSync; - } - - return; + return dashboardSceneGraph.getCursorSync(this._dashboard); } public getDashboardControls() { - return dashboardSceneGraph.getDashboardControls(this._dashboard); + return this._dashboard.state.controls!; } public onTitleChange = (value: string) => { @@ -90,7 +84,7 @@ export class GeneralSettingsEditView this._dashboard.setState({ tags: value }); }; - public onFolderChange = (newUID: string, newTitle: string) => { + public onFolderChange = (newUID: string | undefined, newTitle: string | undefined) => { const newMeta = { ...this._dashboard.state.meta, folderUid: newUID || this._dashboard.state.meta.folderUid, @@ -125,7 +119,11 @@ export class GeneralSettingsEditView }; public onNowDelayChange = (value: string) => { - // TODO: Figure out how to store nowDelay in Dashboard Scene + const timeRange = this.getTimeRange(); + + timeRange?.setState({ + UNSAFE_nowDelay: value, + }); }; public onHideTimePickerChange = (value: boolean) => { @@ -144,11 +142,11 @@ export class GeneralSettingsEditView static Component = ({ model }: SceneComponentProps<GeneralSettingsEditView>) => { const { navModel, pageNav } = useDashboardEditPageNav(model.getDashboard(), model.getUrlKey()); - const { title, description, tags, meta, editable, overlay } = model.getDashboard().useState(); + const { title, description, tags, meta, editable } = model.getDashboard().useState(); const { sync: graphTooltip } = model.getCursorSync()?.useState() || {}; - const { timeZone, weekStart } = model.getTimeRange().useState(); - const { intervals } = model.getRefreshPicker()?.useState() || {}; - const { hideTimeControls } = model.getDashboardControls()?.useState() || {}; + const { timeZone, weekStart, UNSAFE_nowDelay: nowDelay } = model.getTimeRange().useState(); + const { intervals } = model.getRefreshPicker().useState(); + const { hideTimeControls } = model.getDashboardControls().useState(); return ( <Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}> @@ -230,8 +228,7 @@ export class GeneralSettingsEditView onLiveNowChange={model.onLiveNowChange} refreshIntervals={intervals} timePickerHidden={hideTimeControls} - // TODO: Implement this in dashboard scene - // nowDelay={timepicker.nowDelay || ''} + nowDelay={nowDelay || ''} // TODO: Implement this in dashboard scene // liveNow={liveNow} liveNow={false} @@ -257,7 +254,6 @@ export class GeneralSettingsEditView <Box marginTop={3}>{meta.canDelete && <DeleteDashboardButton />}</Box> </div> - {overlay && <overlay.Component model={overlay} />} </Page> ); }; diff --git a/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx b/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx new file mode 100644 index 0000000000000..070dcf781c202 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx @@ -0,0 +1,210 @@ +import { css } from '@emotion/css'; +import React, { useState } from 'react'; + +import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; +import { SceneComponentProps, SceneObjectBase, sceneUtils } from '@grafana/scenes'; +import { Dashboard } from '@grafana/schema'; +import { Alert, Box, Button, CodeEditor, Stack, useStyles2 } from '@grafana/ui'; +import { Page } from 'app/core/components/Page/Page'; +import { Trans } from 'app/core/internationalization'; +import { getPrettyJSON } from 'app/features/inspector/utils/utils'; +import { DashboardDTO, SaveDashboardResponseDTO } from 'app/types'; + +import { + NameAlreadyExistsError, + isNameExistsError, + isPluginDashboardError, + isVersionMismatchError, +} from '../saving/shared'; +import { useSaveDashboard } from '../saving/useSaveDashboard'; +import { DashboardScene } from '../scene/DashboardScene'; +import { NavToolbarActions } from '../scene/NavToolbarActions'; +import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; +import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; +import { getDashboardSceneFor } from '../utils/utils'; + +import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils'; + +export interface JsonModelEditViewState extends DashboardEditViewState { + jsonText: string; +} + +export class JsonModelEditView extends SceneObjectBase<JsonModelEditViewState> implements DashboardEditView { + constructor(state: Omit<JsonModelEditViewState, 'jsonText' | 'initialJsonText'>) { + super({ + ...state, + jsonText: '', + }); + + this.addActivationHandler(() => this.setState({ jsonText: this.getJsonText() })); + } + public getUrlKey(): string { + return 'json-model'; + } + + public getDashboard(): DashboardScene { + return getDashboardSceneFor(this); + } + + public getSaveModel(): Dashboard { + const dashboard = this.getDashboard(); + return transformSceneToSaveModel(dashboard); + } + + public getJsonText(): string { + const jsonData = this.getSaveModel(); + return getPrettyJSON(jsonData); + } + + public onCodeEditorBlur = (value: string) => { + this.setState({ jsonText: value }); + }; + + public onSaveSuccess = (result: SaveDashboardResponseDTO) => { + const jsonModel = JSON.parse(this.state.jsonText); + const dashboard = this.getDashboard(); + jsonModel.version = result.version; + + const rsp: DashboardDTO = { + dashboard: jsonModel, + meta: dashboard.state.meta, + }; + const newDashboardScene = transformSaveModelToScene(rsp); + const newState = sceneUtils.cloneSceneObjectState(newDashboardScene.state); + + dashboard.pauseTrackingChanges(); + dashboard.setInitialSaveModel(rsp.dashboard); + dashboard.setState(newState); + + this.setState({ jsonText: this.getJsonText() }); + }; + + static Component = ({ model }: SceneComponentProps<JsonModelEditView>) => { + const { state, onSaveDashboard } = useSaveDashboard(false); + const [isSaving, setIsSaving] = useState(false); + + const dashboard = model.getDashboard(); + + const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey()); + const canSave = dashboard.useState().meta.canSave; + const { jsonText } = model.useState(); + + const onSave = async (overwrite: boolean) => { + const result = await onSaveDashboard(dashboard, JSON.parse(model.state.jsonText), { + folderUid: dashboard.state.meta.folderUid, + overwrite, + }); + + setIsSaving(true); + if (result.status === 'success') { + model.onSaveSuccess(result); + setIsSaving(false); + } else { + setIsSaving(true); + } + }; + + const saveButton = (overwrite: boolean) => ( + <Button + type="submit" + onClick={() => { + onSave(overwrite); + }} + variant={overwrite ? 'destructive' : 'primary'} + > + {overwrite ? ( + 'Save and overwrite' + ) : ( + <Trans i18nKey="dashboard-settings.json-editor.save-button">Save changes</Trans> + )} + </Button> + ); + + const cancelButton = ( + <Button variant="secondary" onClick={() => setIsSaving(false)} fill="outline"> + Cancel + </Button> + ); + const styles = useStyles2(getStyles); + + function renderSaveButtonAndError(error?: Error) { + if (error && isSaving) { + if (isVersionMismatchError(error)) { + return ( + <Alert title="Someone else has updated this dashboard" severity="error"> + <p>Would you still like to save this dashboard?</p> + <Box paddingTop={2}> + <Stack alignItems="center"> + {cancelButton} + {saveButton(true)} + </Stack> + </Box> + </Alert> + ); + } + + if (isNameExistsError(error)) { + return <NameAlreadyExistsError saveButton={saveButton} cancelButton={cancelButton} />; + } + + if (isPluginDashboardError(error)) { + return ( + <Alert title="Plugin dashboard" severity="error"> + <p> + Your changes will be lost when you update the plugin. Use <strong>Save As</strong> to create custom + version. + </p> + <Box paddingTop={2}> + <Stack alignItems="center">{saveButton(true)}</Stack> + </Box> + </Alert> + ); + } + } + + return ( + <> + {error && isSaving && ( + <Alert title="Failed to save dashboard" severity="error"> + <p>{error.message}</p> + </Alert> + )} + <Stack alignItems="center">{saveButton(false)}</Stack> + </> + ); + } + return ( + <Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}> + <NavToolbarActions dashboard={dashboard} /> + <div className={styles.wrapper}> + <Trans i18nKey="dashboard-settings.json-editor.subtitle"> + The JSON model below is the data structure that defines the dashboard. This includes dashboard settings, + panel settings, layout, queries, and so on. + </Trans> + <CodeEditor + width="100%" + value={jsonText} + language="json" + showLineNumbers={true} + showMiniMap={true} + containerStyles={styles.codeEditor} + onBlur={model.onCodeEditorBlur} + /> + {canSave && <Box paddingTop={2}>{renderSaveButtonAndError(state.error)}</Box>} + </div> + </Page> + ); + }; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + wrapper: css({ + display: 'flex', + height: '100%', + flexDirection: 'column', + gap: theme.spacing(2), + }), + codeEditor: css({ + flexGrow: 1, + }), +}); diff --git a/public/app/features/dashboard-scene/settings/PermissionsEditView.test.tsx b/public/app/features/dashboard-scene/settings/PermissionsEditView.test.tsx new file mode 100644 index 0000000000000..45bee212ab552 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/PermissionsEditView.test.tsx @@ -0,0 +1,73 @@ +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; +import { setPluginImportUtils } from '@grafana/runtime'; +import { SceneGridItem, SceneGridLayout, SceneTimeRange, VizPanel } from '@grafana/scenes'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { activateFullSceneTree } from '../utils/test-utils'; + +import { PermissionsEditView } from './PermissionsEditView'; + +setPluginImportUtils({ + importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})), + getPanelPluginFromCache: (id: string) => undefined, +}); + +describe('PermissionsEditView', () => { + describe('Dashboard permissions state', () => { + let dashboard: DashboardScene; + let permissionsView: PermissionsEditView; + + beforeEach(async () => { + const result = await buildTestScene(); + dashboard = result.dashboard; + permissionsView = result.permissionsView; + }); + + it('should return the correct urlKey', () => { + expect(permissionsView.getUrlKey()).toBe('permissions'); + }); + + it('should return the dashboard', () => { + expect(permissionsView.getDashboard()).toBe(dashboard); + }); + }); +}); + +async function buildTestScene() { + const permissionsView = new PermissionsEditView({}); + const dashboard = new DashboardScene({ + $timeRange: new SceneTimeRange({}), + title: 'hello', + uid: 'dash-1', + version: 4, + meta: { + canEdit: true, + }, + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + y: 0, + width: 10, + height: 12, + body: new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + }), + }), + ], + }), + editview: permissionsView, + }); + + activateFullSceneTree(dashboard); + + await new Promise((r) => setTimeout(r, 1)); + + dashboard.onEnterEditMode(); + permissionsView.activate(); + + return { dashboard, permissionsView }; +} diff --git a/public/app/features/dashboard-scene/settings/PermissionsEditView.tsx b/public/app/features/dashboard-scene/settings/PermissionsEditView.tsx new file mode 100644 index 0000000000000..b36e4b1ff7042 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/PermissionsEditView.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import { PageLayoutType } from '@grafana/data'; +import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes'; +import { Permissions } from 'app/core/components/AccessControl'; +import { Page } from 'app/core/components/Page/Page'; +import { contextSrv } from 'app/core/core'; +import { AccessControlAction } from 'app/types'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { NavToolbarActions } from '../scene/NavToolbarActions'; +import { getDashboardSceneFor } from '../utils/utils'; + +import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils'; + +interface PermissionsEditViewState extends DashboardEditViewState {} + +export class PermissionsEditView extends SceneObjectBase<PermissionsEditViewState> implements DashboardEditView { + public static Component = PermissionsEditorSettings; + + private get _dashboard(): DashboardScene { + return getDashboardSceneFor(this); + } + + public getUrlKey(): string { + return 'permissions'; + } + + public getDashboard(): DashboardScene { + return this._dashboard; + } +} + +function PermissionsEditorSettings({ model }: SceneComponentProps<PermissionsEditView>) { + const dashboard = model.getDashboard(); + const { uid } = dashboard.useState(); + const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey()); + const canSetPermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPermissionsWrite); + + return ( + <Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}> + <NavToolbarActions dashboard={dashboard} /> + <Permissions resource={'dashboards'} resourceId={uid ?? ''} canSetPermissions={canSetPermissions} /> + </Page> + ); +} diff --git a/public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx b/public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx index 1e71389e8a3bf..ac39ac3c60373 100644 --- a/public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx +++ b/public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx @@ -1,10 +1,78 @@ -import { SceneVariableSet, CustomVariable, SceneGridItem, SceneGridLayout } from '@grafana/scenes'; +import { of } from 'rxjs'; + +import { + FieldType, + LoadingState, + PanelData, + VariableSupportType, + getDefaultTimeRange, + toDataFrame, +} from '@grafana/data'; +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; +import { setPluginImportUtils, setRunRequest } from '@grafana/runtime'; +import { + SceneVariableSet, + CustomVariable, + SceneGridItem, + SceneGridLayout, + VizPanel, + AdHocFiltersVariable, + SceneVariableState, + SceneTimeRange, +} from '@grafana/scenes'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; +import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor'; import { DashboardScene } from '../scene/DashboardScene'; import { activateFullSceneTree } from '../utils/test-utils'; import { VariablesEditView } from './VariablesEditView'; +setPluginImportUtils({ + importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})), + getPanelPluginFromCache: (id: string) => undefined, +}); + +const defaultDatasource = mockDataSource({ + name: 'Default Test Data Source', + type: 'test', +}); + +const promDatasource = mockDataSource({ + name: 'Prometheus', + type: 'prometheus', +}); + +jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({ + ...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'), + getDataSourceSrv: () => ({ + get: async () => ({ + ...defaultDatasource, + variables: { + getType: () => VariableSupportType.Custom, + query: jest.fn(), + editor: jest.fn().mockImplementation(LegacyVariableQueryEditor), + }, + }), + getList: () => [defaultDatasource, promDatasource], + getInstanceSettings: () => ({ ...defaultDatasource }), + }), +})); + +const runRequestMock = jest.fn().mockReturnValue( + of<PanelData>({ + state: LoadingState.Done, + series: [ + toDataFrame({ + fields: [{ name: 'text', type: FieldType.string, values: ['val1', 'val2', 'val11'] }], + }), + ], + timeRange: getDefaultTimeRange(), + }) +); + +setRunRequest(runRequestMock); + describe('VariablesEditView', () => { describe('Dashboard Variables state', () => { let dashboard: DashboardScene; @@ -35,14 +103,19 @@ describe('VariablesEditView', () => { { type: 'custom', name: 'customVar2', - query: 'test3, test4', + query: 'test3, test4, $customVar', value: 'test3', }, + { + type: 'adhoc', + name: 'adhoc', + }, ]; const variables = variableView.getVariables(); - expect(variables).toHaveLength(2); + expect(variables).toHaveLength(3); expect(variables[0].state).toMatchObject(expectedVariables[0]); expect(variables[1].state).toMatchObject(expectedVariables[1]); + expect(variables[2].state).toMatchObject(expectedVariables[2]); }); }); @@ -58,7 +131,7 @@ describe('VariablesEditView', () => { const variables = variableView.getVariables(); const variable = variables[0]; variableView.onDuplicated(variable.state.name); - expect(variableView.getVariables()).toHaveLength(3); + expect(variableView.getVariables()).toHaveLength(4); expect(variableView.getVariables()[1].state.name).toBe('copy_of_customVar'); }); @@ -66,16 +139,21 @@ describe('VariablesEditView', () => { const variableIdentifier = 'customVar'; variableView.onDuplicated(variableIdentifier); variableView.onDuplicated(variableIdentifier); - expect(variableView.getVariables()).toHaveLength(4); + expect(variableView.getVariables()).toHaveLength(5); expect(variableView.getVariables()[1].state.name).toBe('copy_of_customVar_1'); expect(variableView.getVariables()[2].state.name).toBe('copy_of_customVar'); }); it('should delete a variable', () => { const variableIdentifier = 'customVar'; + + variableView.onEdit(variableIdentifier); + expect(variableView.state.editIndex).toBe(0); + variableView.onDelete(variableIdentifier); - expect(variableView.getVariables()).toHaveLength(1); + expect(variableView.getVariables()).toHaveLength(2); expect(variableView.getVariables()[0].state.name).toBe('customVar2'); + expect(variableView.state.editIndex).toBeUndefined(); }); it('should change order of variables', () => { @@ -88,7 +166,7 @@ describe('VariablesEditView', () => { it('should keep the same order of variables with invalid indexes', () => { const fromIndex = 0; - const toIndex = 2; + const toIndex = 3; const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -99,6 +177,132 @@ describe('VariablesEditView', () => { errorSpy.mockRestore(); }); + + it('should change the variable type creating a new variable object', () => { + const previousVariable = variableView.getVariables()[1] as CustomVariable; + variableView.onEdit('customVar2'); + + variableView.onTypeChange('adhoc'); + expect(variableView.getVariables()).toHaveLength(3); + const variable = variableView.getVariables()[1]; + expect(variable).not.toBe(previousVariable); + expect(variable.state.type).toBe('adhoc'); + + // Values to be kept between the old and new variable + expect(variable.state.name).toEqual(previousVariable.state.name); + expect(variable.state.label).toEqual(previousVariable.state.label); + }); + + it('should reset editing variable when going back', () => { + variableView.onEdit('customVar2'); + expect(variableView.state.editIndex).toBe(1); + + variableView.onGoBack(); + expect(variableView.state.editIndex).toBeUndefined(); + }); + + it('should add default new query variable when onAdd is called', () => { + variableView.onAdd(); + expect(variableView.getVariables()).toHaveLength(4); + expect(variableView.getVariables()[3].state.name).toBe('query0'); + expect(variableView.getVariables()[3].state.type).toBe('query'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + }); + + describe('Variables name validation', () => { + let variableView: VariablesEditView; + let variable1: SceneVariableState; + let variable2: SceneVariableState; + + beforeAll(async () => { + const result = await buildTestScene(); + variableView = result.variableView; + + const variables = variableView.getVariables(); + variable1 = variables[0].state; + variable2 = variables[1].state; + }); + + it('should not return error on same name and key', () => { + expect(variableView.onValidateVariableName(variable1.name, variable1.key)[0]).toBe(false); + }); + + it('should not return error if name is unique', () => { + expect(variableView.onValidateVariableName('unique_variable_name', variable1.key)[0]).toBe(false); + }); + + it('should return error if global variable name is used', () => { + expect(variableView.onValidateVariableName('__', variable1.key)[0]).toBe(true); + }); + + it('should not return error if global variable name is used not at the beginning ', () => { + expect(variableView.onValidateVariableName('test__', variable1.key)[0]).toBe(false); + }); + + it('should return error if name is empty', () => { + expect(variableView.onValidateVariableName('', variable1.key)[0]).toBe(true); + }); + + it('should return error if non word characters are used', () => { + expect(variableView.onValidateVariableName('-', variable1.key)[0]).toBe(true); + }); + + it('should return error if variable name is taken', () => { + expect(variableView.onValidateVariableName(variable2.name, variable1.key)[0]).toBe(true); + }); + }); + + describe('Dashboard Variables dependencies', () => { + let variableView: VariablesEditView; + let dashboard: DashboardScene; + + beforeEach(async () => { + const result = await buildTestScene(); + variableView = result.variableView; + dashboard = result.dashboard; + }); + + // FIXME: This is not working because the variable is replaced or it is not resolved yet + it.skip('should keep dependencies between variables the type is changed so the variable is replaced', () => { + // Uses function to avoid store reference to previous existing variables + const getSourceVariable = () => variableView.getVariables()[0] as CustomVariable; + const getDependantVariable = () => variableView.getVariables()[1] as CustomVariable; + + expect(getSourceVariable().getValue()).toBe('test'); + // Using getOptionsForSelect to get the interpolated values + expect(getDependantVariable().getOptionsForSelect()[2].label).toBe('test'); + + variableView.onEdit(getSourceVariable().state.name); + // Simulating changing the type and update the value + variableView.onTypeChange('constant'); + getSourceVariable().setState({ value: 'newValue' }); + + expect(getSourceVariable().getValue()).toBe('newValue'); + expect(getDependantVariable().getOptionsForSelect()[2].label).toBe('newValue'); + }); + + it('should keep dependencies with panels when the type is changed so the variable is replaced', async () => { + // Uses function to avoid store reference to previous existing variables + const getSourceVariable = () => variableView.getVariables()[0] as CustomVariable; + const getDependantPanel = () => + ((dashboard.state.body as SceneGridLayout).state.children[0] as SceneGridItem).state.body as VizPanel; + + expect(getSourceVariable().getValue()).toBe('test'); + // Using description to get the interpolated value + expect(getDependantPanel().getDescription()).toContain('Panel A depends on customVar with current value test'); + + variableView.onEdit(getSourceVariable().state.name); + // Simulating changing the type and update the value + variableView.onTypeChange('constant'); + getSourceVariable().setState({ value: 'newValue' }); + + expect(getSourceVariable().getValue()).toBe('newValue'); + expect(getDependantPanel().getDescription()).toContain('newValue'); + }); }); }); @@ -110,15 +314,31 @@ async function buildTestScene() { meta: { canEdit: true, }, + $timeRange: new SceneTimeRange({}), $variables: new SceneVariableSet({ variables: [ new CustomVariable({ name: 'customVar', query: 'test, test2', + value: 'test', + text: 'test', }), new CustomVariable({ name: 'customVar2', - query: 'test3, test4', + query: 'test3, test4, $customVar', + value: '$customVar', + text: '$customVar', + }), + new AdHocFiltersVariable({ + type: 'adhoc', + name: 'adhoc', + filters: [ + { + key: 'test', + operator: '=', + value: 'testValue', + }, + ], }), ], }), @@ -127,10 +347,12 @@ async function buildTestScene() { new SceneGridItem({ key: 'griditem-1', x: 0, - y: 0, - width: 10, - height: 12, - body: undefined, + body: new VizPanel({ + title: 'Panel A', + description: 'Panel A depends on customVar with current value $customVar', + key: 'panel-1', + pluginId: 'table', + }), }), ], }), diff --git a/public/app/features/dashboard-scene/settings/VariablesEditView.tsx b/public/app/features/dashboard-scene/settings/VariablesEditView.tsx index 7f9999ad1b7ae..be8dea55b3ca1 100644 --- a/public/app/features/dashboard-scene/settings/VariablesEditView.tsx +++ b/public/app/features/dashboard-scene/settings/VariablesEditView.tsx @@ -1,16 +1,27 @@ import React from 'react'; -import { PageLayoutType } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase, SceneVariables, sceneGraph } from '@grafana/scenes'; +import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data'; +import { SceneComponentProps, SceneObjectBase, SceneVariable, SceneVariables, sceneGraph } from '@grafana/scenes'; import { Page } from 'app/core/components/Page/Page'; import { DashboardScene } from '../scene/DashboardScene'; import { NavToolbarActions } from '../scene/NavToolbarActions'; import { getDashboardSceneFor } from '../utils/utils'; +import { EditListViewSceneUrlSync } from './EditListViewSceneUrlSync'; import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils'; +import { VariableEditorForm } from './variables/VariableEditorForm'; import { VariableEditorList } from './variables/VariableEditorList'; -export interface VariablesEditViewState extends DashboardEditViewState {} +import { + EditableVariableType, + RESERVED_GLOBAL_VARIABLE_NAME_REGEX, + WORD_CHARACTERS_REGEX, + getVariableDefault, + getVariableScene, +} from './variables/utils'; +export interface VariablesEditViewState extends DashboardEditViewState { + editIndex?: number | undefined; +} export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> implements DashboardEditView { public static Component = VariableEditorSettingsListView; @@ -19,6 +30,8 @@ export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> i return 'variables'; } + protected _urlSync = new EditListViewSceneUrlSync(this); + public getDashboard(): DashboardScene { return getDashboardSceneFor(this); } @@ -32,6 +45,24 @@ export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> i return variables.findIndex((variable) => variable.state.name === identifier); }; + private replaceEditVariable = (newVariable: SceneVariable) => { + // Find the index of the variable to be deleted + const variableIndex = this.state.editIndex ?? -1; + const { variables } = this.getVariableSet().state; + const variable = variables[variableIndex]; + + if (!variable) { + // Handle the case where the variable is not found + console.error('Variable not found'); + return; + } + + const updatedVariables = [...variables.slice(0, variableIndex), newVariable, ...variables.slice(variableIndex + 1)]; + + // Update the state or the variables array + this.getVariableSet().setState({ variables: updatedVariables }); + }; + public onDelete = (identifier: string) => { // Find the index of the variable to be deleted const variableIndex = this.getVariableIndex(identifier); @@ -47,6 +78,8 @@ export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> i // Update the state or the variables array this.getVariableSet().setState({ variables: updatedVariables }); + // Remove editIndex otherwise switches to next variable in list + this.setState({ editIndex: undefined }); }; public getVariables() { @@ -62,19 +95,18 @@ export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> i return; } - const originalVariable = variables[variableIndex]; + const variableToUpdate = variables[variableIndex]; let copyNumber = 0; - let newName = `copy_of_${originalVariable.state.name}`; + let newName = `copy_of_${variableToUpdate.state.name}`; // Check if the name is unique, if not, increment the copy number while (variables.some((v) => v.state.name === newName)) { copyNumber++; - newName = `copy_of_${originalVariable.state.name}_${copyNumber}`; + newName = `copy_of_${variableToUpdate.state.name}_${copyNumber}`; } //clone the original variable - - const newVariable = originalVariable.clone(originalVariable.state); + const newVariable = variableToUpdate.clone(variableToUpdate.state); // update state name of the new variable newVariable.setState({ name: newName }); @@ -106,7 +138,66 @@ export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> i }; public onEdit = (identifier: string) => { - return 'not implemented'; + const variableIndex = this.getVariableIndex(identifier); + if (variableIndex === -1) { + console.error('Variable not found'); + return; + } + this.setState({ editIndex: variableIndex }); + }; + + public onAdd = () => { + const variables = this.getVariables(); + const variableIndex = variables.length; + //add the new variable to the end of the array + const defaultNewVariable = getVariableDefault(variables); + + this.getVariableSet().setState({ variables: [...this.getVariables(), defaultNewVariable] }); + this.setState({ editIndex: variableIndex }); + }; + + public onTypeChange = (type: EditableVariableType) => { + // Find the index of the variable to be deleted + const variableIndex = this.state.editIndex ?? -1; + const { variables } = this.getVariableSet().state; + const variable = variables[variableIndex]; + + if (!variable) { + // Handle the case where the variable is not found + console.error('Variable not found'); + return; + } + + const { name, label } = variable.state; + const newVariable = getVariableScene(type, { name, label }); + this.replaceEditVariable(newVariable); + }; + + public onGoBack = () => { + this.setState({ editIndex: undefined }); + }; + + public onValidateVariableName = (name: string, key: string | undefined): [true, string] | [false, null] => { + let errorText = null; + if (!RESERVED_GLOBAL_VARIABLE_NAME_REGEX.test(name)) { + errorText = "Template names cannot begin with '__', that's reserved for Grafana's global variables"; + } + + if (!WORD_CHARACTERS_REGEX.test(name)) { + errorText = 'Only word characters are allowed in variable names'; + } + + const variable = this.getVariableSet().getByName(name)?.state; + + if (variable && variable.key !== key) { + errorText = 'Variable with the same name already exists'; + } + + if (errorText) { + return [true, errorText]; + } + + return [false, null]; }; } @@ -114,8 +205,27 @@ function VariableEditorSettingsListView({ model }: SceneComponentProps<Variables const dashboard = model.getDashboard(); const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey()); // get variables from dashboard state - const { onDelete, onDuplicated, onOrderChanged, onEdit } = model; + const { onDelete, onDuplicated, onOrderChanged, onEdit, onTypeChange, onGoBack, onAdd } = model; const { variables } = model.getVariableSet().useState(); + const { editIndex } = model.useState(); + + if (editIndex !== undefined && variables[editIndex]) { + const variable = variables[editIndex]; + if (variable) { + return ( + <VariableEditorSettingsView + variable={variable} + onTypeChange={onTypeChange} + onGoBack={onGoBack} + pageNav={pageNav} + navModel={navModel} + dashboard={dashboard} + onDelete={onDelete} + onValidateVariableName={model.onValidateVariableName} + /> + ); + } + } return ( <Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}> @@ -125,9 +235,54 @@ function VariableEditorSettingsListView({ model }: SceneComponentProps<Variables onDelete={onDelete} onDuplicate={onDuplicated} onChangeOrder={onOrderChanged} - onAdd={() => {}} + onAdd={onAdd} onEdit={onEdit} /> </Page> ); } + +interface VariableEditorSettingsEditViewProps { + variable: SceneVariable; + pageNav: NavModelItem; + navModel: NavModel; + dashboard: DashboardScene; + onTypeChange: (variableType: EditableVariableType) => void; + onGoBack: () => void; + onDelete: (variableName: string) => void; + onValidateVariableName: (name: string, key: string | undefined) => [true, string] | [false, null]; +} + +function VariableEditorSettingsView({ + variable, + pageNav, + navModel, + dashboard, + onTypeChange, + onGoBack, + onDelete, + onValidateVariableName, +}: VariableEditorSettingsEditViewProps) { + const parentTab = pageNav.children!.find((p) => p.active)!; + parentTab.parentItem = pageNav; + const { name } = variable.useState(); + + const editVariablePageNav = { + text: name, + parentItem: parentTab, + }; + return ( + <Page navModel={navModel} pageNav={editVariablePageNav} layout={PageLayoutType.Standard}> + <NavToolbarActions dashboard={dashboard} /> + <VariableEditorForm + variable={variable} + onTypeChange={onTypeChange} + onGoBack={onGoBack} + onDelete={onDelete} + onValidateVariableName={onValidateVariableName} + // force refresh when navigating using back/forward between variables + key={variable.state.key} + /> + </Page> + ); +} diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx new file mode 100644 index 0000000000000..ee702bd8d6adc --- /dev/null +++ b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx @@ -0,0 +1,199 @@ +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; +import { setPluginImportUtils } from '@grafana/runtime'; +import { SceneGridItem, SceneGridLayout, SceneTimeRange, VizPanel } from '@grafana/scenes'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { activateFullSceneTree } from '../utils/test-utils'; + +import { VERSIONS_FETCH_LIMIT, VersionsEditView } from './VersionsEditView'; +import { historySrv } from './version-history'; + +jest.mock('./version-history/HistorySrv'); + +setPluginImportUtils({ + importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})), + getPanelPluginFromCache: (id: string) => undefined, +}); + +describe('VersionsEditView', () => { + describe('Dashboard versions state', () => { + let dashboard: DashboardScene; + let versionsView: VersionsEditView; + const mockEvent = { + preventDefault: jest.fn(), + currentTarget: { + checked: true, + }, + } as unknown as React.FormEvent<HTMLInputElement>; + + beforeEach(async () => { + jest.mocked(historySrv.getHistoryList).mockResolvedValue(getVersions()); + + const result = await buildTestScene(); + dashboard = result.dashboard; + versionsView = result.versionsView; + }); + + it('should return the correct urlKey', () => { + expect(versionsView.getUrlKey()).toBe('versions'); + }); + + it('should return the dashboard', () => { + expect(versionsView.getDashboard()).toBe(dashboard); + }); + + it('should return the decorated list of versions', () => { + const versions = versionsView.versions; + + expect(versions).toHaveLength(3); + expect(versions[0].createdDateString).toBe('2017-02-22 20:43:01'); + expect(versions[0].ageString).toBe('7 years ago'); + expect(versions[1].createdDateString).toBe('2017-02-22 20:43:01'); + expect(versions[1].ageString).toBe('7 years ago'); + expect(versions[2].createdDateString).toBe('2017-02-23 20:43:01'); + expect(versions[2].ageString).toBe('7 years ago'); + }); + + it('should bump the start threshold when fetching more versions', async () => { + expect(versionsView.start).toBe(VERSIONS_FETCH_LIMIT); + + versionsView.fetchVersions(true); + await new Promise(process.nextTick); + + expect(versionsView.start).toBe(VERSIONS_FETCH_LIMIT * 2); + }); + + it('should set the state of a version as checked when onCheck is called', () => { + versionsView.onCheck(mockEvent, 3); + + expect(versionsView.versions[0].checked).toBe(false); + expect(versionsView.versions[1].checked).toBe(true); + expect(versionsView.versions[2].checked).toBe(false); + }); + + it('should reset the state of all versions when reset is called', () => { + versionsView.onCheck(mockEvent, 3); + + expect(versionsView.versions[1].checked).toBe(true); + + versionsView.reset(); + + expect(versionsView.versions[0].checked).toBe(false); + expect(versionsView.versions[1].checked).toBe(false); + expect(versionsView.versions[2].checked).toBe(false); + }); + + it('should set the diffData', async () => { + versionsView.onCheck(mockEvent, 3); + versionsView.onCheck(mockEvent, 4); + + jest + .mocked(historySrv.getDashboardVersion) + .mockResolvedValueOnce({ data: 'lhs' }) + .mockResolvedValue({ data: 'rhs' }); + + await versionsView.getDiff(); + + expect(versionsView.diffData).toEqual({ + lhs: 'lhs', + rhs: 'rhs', + }); + expect(versionsView.state.baseInfo).toHaveProperty('version', 3); + expect(versionsView.state.newInfo).toHaveProperty('version', 4); + }); + + it('should set the isNewLatest flag if the new selected version is latest', async () => { + versionsView.onCheck(mockEvent, 4); + versionsView.onCheck(mockEvent, 2); + + jest + .mocked(historySrv.getDashboardVersion) + .mockResolvedValueOnce({ data: 'lhs' }) + .mockResolvedValue({ data: 'rhs' }); + + await versionsView.getDiff(); + + expect(versionsView.state.isNewLatest).toBe(true); + }); + }); +}); + +function getVersions() { + return [ + { + id: 4, + dashboardId: 1, + dashboardUID: '_U4zObQMz', + parentVersion: 3, + restoredFrom: 0, + version: 4, + created: '2017-02-22T17:43:01-08:00', + createdBy: 'admin', + message: '', + checked: false, + }, + { + id: 3, + dashboardId: 1, + dashboardUID: '_U4zObQMz', + parentVersion: 1, + restoredFrom: 1, + version: 3, + created: '2017-02-22T17:43:01-08:00', + createdBy: 'admin', + message: '', + checked: false, + }, + { + id: 2, + dashboardId: 1, + dashboardUID: '_U4zObQMz', + parentVersion: 1, + restoredFrom: 1, + version: 2, + created: '2017-02-23T17:43:01-08:00', + createdBy: 'admin', + message: '', + checked: false, + }, + ]; +} + +async function buildTestScene() { + const versionsView = new VersionsEditView({}); + const dashboard = new DashboardScene({ + $timeRange: new SceneTimeRange({}), + title: 'hello', + uid: 'dash-1', + version: 4, + meta: { + canEdit: true, + }, + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + y: 0, + width: 10, + height: 12, + body: new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + }), + }), + ], + }), + editview: versionsView, + }); + + activateFullSceneTree(dashboard); + + await new Promise((r) => setTimeout(r, 1)); + + dashboard.onEnterEditMode(); + versionsView.activate(); + + return { dashboard, versionsView }; +} diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.tsx new file mode 100644 index 0000000000000..ea44185e9a8de --- /dev/null +++ b/public/app/features/dashboard-scene/settings/VersionsEditView.tsx @@ -0,0 +1,252 @@ +import React from 'react'; + +import { PageLayoutType, dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data'; +import { SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes'; +import { HorizontalGroup, Spinner } from '@grafana/ui'; +import { Page } from 'app/core/components/Page/Page'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { NavToolbarActions } from '../scene/NavToolbarActions'; +import { getDashboardSceneFor } from '../utils/utils'; + +import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils'; +import { + RevisionsModel, + VersionHistoryComparison, + VersionHistoryHeader, + VersionHistoryTable, + VersionsHistoryButtons, + historySrv, +} from './version-history'; + +export const VERSIONS_FETCH_LIMIT = 10; + +export type DecoratedRevisionModel = RevisionsModel & { + createdDateString: string; + ageString: string; +}; + +export interface VersionsEditViewState extends DashboardEditViewState { + versions?: DecoratedRevisionModel[]; + isLoading?: boolean; + isAppending?: boolean; + viewMode?: 'list' | 'compare'; + diffData?: { lhs: string; rhs: string }; + newInfo?: DecoratedRevisionModel; + baseInfo?: DecoratedRevisionModel; + isNewLatest?: boolean; +} + +export class VersionsEditView extends SceneObjectBase<VersionsEditViewState> implements DashboardEditView { + public static Component = VersionsEditorSettingsListView; + private _limit: number = VERSIONS_FETCH_LIMIT; + private _start = 0; + + constructor(state: VersionsEditViewState) { + super({ + ...state, + versions: [], + isLoading: true, + isAppending: true, + viewMode: 'list', + isNewLatest: false, + diffData: { + lhs: '', + rhs: '', + }, + }); + + this.addActivationHandler(() => { + this.fetchVersions(); + }); + } + + private get _dashboard(): DashboardScene { + return getDashboardSceneFor(this); + } + + public get diffData(): { lhs: string; rhs: string } { + return this.state.diffData ?? { lhs: '', rhs: '' }; + } + + public get versions(): DecoratedRevisionModel[] { + return this.state.versions ?? []; + } + + public get limit(): number { + return this._limit; + } + + public get start(): number { + return this._start; + } + + public getUrlKey(): string { + return 'versions'; + } + + public getDashboard(): DashboardScene { + return this._dashboard; + } + + public getTimeRange() { + return sceneGraph.getTimeRange(this._dashboard); + } + + public fetchVersions = (append = false): void => { + const uid = this._dashboard.state.uid; + + if (!uid) { + return; + } + + this.setState({ isAppending: append }); + + historySrv + .getHistoryList(uid, { limit: this._limit, start: this._start }) + .then((result) => { + this.setState({ + isLoading: false, + versions: [...(this.state.versions ?? []), ...this.decorateVersions(result)], + }); + this._start += this._limit; + }) + .catch((err) => console.log(err)) + .finally(() => this.setState({ isAppending: false })); + }; + + public getDiff = async () => { + const selectedVersions = this.versions.filter((version) => version.checked); + const [newInfo, baseInfo] = selectedVersions; + const isNewLatest = newInfo.version === this._dashboard.state.version; + + this.setState({ + isLoading: true, + }); + + if (!this._dashboard.state.uid) { + return; + } + + const lhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, baseInfo.version); + const rhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, newInfo.version); + + this.setState({ + baseInfo, + isLoading: false, + isNewLatest, + newInfo, + viewMode: 'compare', + diffData: { + lhs: lhs.data, + rhs: rhs.data, + }, + }); + }; + + public reset = () => { + this.setState({ + baseInfo: undefined, + diffData: { + lhs: '', + rhs: '', + }, + isNewLatest: false, + newInfo: undefined, + versions: this.versions.map((version) => ({ ...version, checked: false })), + viewMode: 'list', + }); + }; + + public onCheck = (ev: React.FormEvent<HTMLInputElement>, versionId: number) => { + this.setState({ + versions: this.versions.map((version) => + version.id === versionId ? { ...version, checked: ev.currentTarget.checked } : version + ), + }); + }; + + private decorateVersions(versions: RevisionsModel[]): DecoratedRevisionModel[] { + const timeZone = this.getTimeRange().getTimeZone(); + + return versions.map((version) => { + return { + ...version, + createdDateString: dateTimeFormat(version.created, { timeZone: timeZone }), + ageString: dateTimeFormatTimeAgo(version.created, { timeZone: timeZone }), + checked: false, + }; + }); + } +} + +function VersionsEditorSettingsListView({ model }: SceneComponentProps<VersionsEditView>) { + const dashboard = model.getDashboard(); + const { isLoading, isAppending, viewMode, baseInfo, newInfo, isNewLatest } = model.useState(); + const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey()); + const canCompare = model.versions.filter((version) => version.checked).length === 2; + const showButtons = model.versions.length > 1; + const hasMore = model.versions.length >= model.limit; + const isLastPage = model.versions.find((rev) => rev.version === 1); + + const viewModeCompare = ( + <> + <VersionHistoryHeader + onClick={model.reset} + baseVersion={baseInfo?.version} + newVersion={newInfo?.version} + isNewLatest={isNewLatest} + /> + {isLoading ? ( + <VersionsHistorySpinner msg="Fetching changes…" /> + ) : ( + <VersionHistoryComparison + newInfo={newInfo!} + baseInfo={baseInfo!} + isNewLatest={isNewLatest!} + diffData={model.diffData} + onRestore={dashboard.onRestore} + /> + )} + </> + ); + + const viewModeList = ( + <> + {isLoading ? ( + <VersionsHistorySpinner msg="Fetching history list…" /> + ) : ( + <VersionHistoryTable + versions={model.versions} + onCheck={model.onCheck} + canCompare={canCompare} + onRestore={dashboard.onRestore} + /> + )} + {isAppending && <VersionsHistorySpinner msg="Fetching more entries…" />} + {showButtons && ( + <VersionsHistoryButtons + hasMore={hasMore} + canCompare={canCompare} + getVersions={model.fetchVersions} + getDiff={model.getDiff} + isLastPage={!!isLastPage} + /> + )} + </> + ); + + return ( + <Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}> + <NavToolbarActions dashboard={dashboard} /> + {viewMode === 'compare' ? viewModeCompare : viewModeList} + </Page> + ); +} + +const VersionsHistorySpinner = ({ msg }: { msg: string }) => ( + <HorizontalGroup> + <Spinner /> + <em>{msg}</em> + </HorizontalGroup> +); diff --git a/public/app/features/dashboard/components/AnnotationSettings/AngularEditorLoader.tsx b/public/app/features/dashboard-scene/settings/annotations/AngularEditorLoader.tsx similarity index 100% rename from public/app/features/dashboard/components/AnnotationSettings/AngularEditorLoader.tsx rename to public/app/features/dashboard-scene/settings/annotations/AngularEditorLoader.tsx diff --git a/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsEdit.test.tsx b/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsEdit.test.tsx new file mode 100644 index 0000000000000..f2ce2c54bf375 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsEdit.test.tsx @@ -0,0 +1,226 @@ +import { act, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { of } from 'rxjs'; + +import { + AnnotationQuery, + FieldType, + LoadingState, + PanelData, + VariableSupportType, + getDefaultTimeRange, + toDataFrame, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { setRunRequest } from '@grafana/runtime'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; +import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor'; + +import { AnnotationSettingsEdit } from './AnnotationSettingsEdit'; + +const defaultDatasource = mockDataSource({ + name: 'Default Test Data Source', + type: 'test', +}); + +const promDatasource = mockDataSource({ + name: 'Prometheus', + type: 'prometheus', +}); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getAngularLoader: () => ({ + load: () => ({ + destroy: jest.fn(), + digest: jest.fn(), + getScope: () => ({ + $watch: jest.fn(), + }), + }), + }), +})); + +jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({ + ...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'), + getDataSourceSrv: () => ({ + get: async () => ({ + ...defaultDatasource, + variables: { + getType: () => VariableSupportType.Custom, + query: jest.fn(), + editor: jest.fn().mockImplementation(LegacyVariableQueryEditor), + }, + }), + getList: () => [defaultDatasource, promDatasource], + getInstanceSettings: () => ({ ...defaultDatasource }), + }), +})); + +jest.mock('./AngularEditorLoader', () => ({ AngularEditorLoader: () => 'mocked AngularEditorLoader' })); + +const runRequestMock = jest.fn().mockReturnValue( + of<PanelData>({ + state: LoadingState.Done, + series: [ + toDataFrame({ + fields: [{ name: 'text', type: FieldType.string, values: ['val1', 'val2', 'val11'] }], + }), + ], + timeRange: getDefaultTimeRange(), + }) +); + +setRunRequest(runRequestMock); + +describe('AnnotationSettingsEdit', () => { + const mockOnUpdate = jest.fn(); + const mockGoBackToList = jest.fn(); + const mockOnDelete = jest.fn(); + + async function setup() { + const annotationQuery: AnnotationQuery = { + name: 'test', + datasource: defaultDatasource, + enable: true, + hide: false, + iconColor: 'blue', + }; + + const props = { + annotation: annotationQuery, + onUpdate: mockOnUpdate, + editIndex: 1, + panels: [], + onBackToList: mockGoBackToList, + onDelete: mockOnDelete, + }; + + return { + anno: annotationQuery, + renderer: await act(async () => render(<AnnotationSettingsEdit {...props} />)), + user: userEvent.setup(), + }; + } + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render', async () => { + const { + renderer: { getByTestId }, + } = await setup(); + + const nameInput = getByTestId(selectors.pages.Dashboard.Settings.Annotations.Settings.name); + const dataSourceSelect = getByTestId(selectors.components.DataSourcePicker.container); + const enableToggle = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.enable); + const hideToggle = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.hide); + const iconColorToggle = getByTestId(selectors.components.ColorSwatch.name); + const panelSelect = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.showInLabel); + const deleteAnno = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.delete); + const apply = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.apply); + + expect(nameInput).toBeInTheDocument(); + expect(dataSourceSelect).toBeInTheDocument(); + expect(enableToggle).toBeInTheDocument(); + expect(hideToggle).toBeInTheDocument(); + expect(iconColorToggle).toBeInTheDocument(); + expect(panelSelect).toBeInTheDocument(); + expect(deleteAnno).toBeInTheDocument(); + expect(apply).toBeInTheDocument(); + }); + + it('should update annotation name on change', async () => { + const { + renderer: { getByTestId }, + user, + } = await setup(); + + await user.type(getByTestId(selectors.pages.Dashboard.Settings.Annotations.Settings.name), 'new name'); + + expect(mockOnUpdate).toHaveBeenCalled(); + }); + + it('should toggle annotation enabled on change', async () => { + const { + renderer: { getByTestId }, + user, + anno, + } = await setup(); + + const annoArg = { + ...anno, + enable: !anno.enable, + }; + + const enableToggle = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.enable); + + await user.click(enableToggle); + + expect(mockOnUpdate).toHaveBeenCalledTimes(1); + expect(mockOnUpdate).toHaveBeenCalledWith(annoArg, 1); + }); + + it('should toggle annotation hide on change', async () => { + const { + renderer: { getByTestId }, + user, + anno, + } = await setup(); + + const annoArg = { + ...anno, + hide: !anno.hide, + }; + + const hideToggle = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.hide); + + await user.click(hideToggle); + + expect(mockOnUpdate).toHaveBeenCalledTimes(1); + expect(mockOnUpdate).toHaveBeenCalledWith(annoArg, 1); + }); + + it('should set annotation filter', async () => { + const { + renderer: { getByTestId }, + user, + } = await setup(); + + const panelSelect = getByTestId(selectors.components.Annotations.annotationsTypeInput); + + await user.click(panelSelect); + await user.tab(); + + expect(mockOnUpdate).toHaveBeenCalledTimes(1); + expect(mockOnUpdate).toHaveBeenCalledWith(expect.objectContaining({ filter: undefined }), 1); + }); + + it('should delete annotation', async () => { + const { + renderer: { getByTestId }, + user, + } = await setup(); + + const deleteAnno = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.delete); + + await user.click(deleteAnno); + + expect(mockOnDelete).toHaveBeenCalledTimes(1); + }); + + it('should go back to list annotation', async () => { + const { + renderer: { getByTestId }, + user, + } = await setup(); + + const goBack = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.apply); + + await user.click(goBack); + + expect(mockGoBackToList).toHaveBeenCalledTimes(1); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsEdit.tsx b/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsEdit.tsx new file mode 100644 index 0000000000000..59f88bad3f8ec --- /dev/null +++ b/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsEdit.tsx @@ -0,0 +1,321 @@ +import { css } from '@emotion/css'; +import React, { useMemo } from 'react'; +import { useAsync } from 'react-use'; + +import { + AnnotationQuery, + DataSourceInstanceSettings, + getDataSourceRef, + GrafanaTheme2, + SelectableValue, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { config, getDataSourceSrv } from '@grafana/runtime'; +import { VizPanel } from '@grafana/scenes'; +import { AnnotationPanelFilter } from '@grafana/schema/src/raw/dashboard/x/dashboard_types.gen'; +import { + Button, + Checkbox, + Field, + FieldSet, + HorizontalGroup, + Input, + MultiSelect, + Select, + useStyles2, + Stack, +} from '@grafana/ui'; +import { ColorValueEditor } from 'app/core/components/OptionsUI/color'; +import StandardAnnotationQueryEditor from 'app/features/annotations/components/StandardAnnotationQueryEditor'; +import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; + +import { getPanelIdForVizPanel } from '../../utils/utils'; + +import { AngularEditorLoader } from './AngularEditorLoader'; + +type Props = { + annotation: AnnotationQuery; + editIndex: number; + panels: VizPanel[]; + onUpdate: (annotation: AnnotationQuery, editIndex: number) => void; + onBackToList: () => void; + onDelete: (index: number) => void; +}; + +export const newAnnotationName = 'New annotation'; + +export const AnnotationSettingsEdit = ({ annotation, editIndex, panels, onUpdate, onBackToList, onDelete }: Props) => { + const styles = useStyles2(getStyles); + + const panelFilter = useMemo(() => { + if (!annotation.filter) { + return PanelFilterType.AllPanels; + } + return annotation.filter.exclude ? PanelFilterType.ExcludePanels : PanelFilterType.IncludePanels; + }, [annotation.filter]); + + const { value: ds } = useAsync(() => { + return getDataSourceSrv().get(annotation.datasource); + }, [annotation.datasource]); + + const dsi = getDataSourceSrv().getInstanceSettings(annotation.datasource); + + const onNameChange = (ev: React.FocusEvent<HTMLInputElement>) => { + onUpdate( + { + ...annotation, + name: ev.currentTarget.value, + }, + editIndex + ); + }; + + const onDataSourceChange = (ds: DataSourceInstanceSettings) => { + const dsRef = getDataSourceRef(ds); + + if (annotation.datasource?.type !== dsRef.type) { + onUpdate( + { + datasource: dsRef, + builtIn: annotation.builtIn, + enable: annotation.enable, + iconColor: annotation.iconColor, + name: annotation.name, + hide: annotation.hide, + filter: annotation.filter, + mappings: annotation.mappings, + type: annotation.type, + }, + editIndex + ); + } else { + onUpdate( + { + ...annotation, + datasource: dsRef, + }, + editIndex + ); + } + }; + + const onChange = (ev: React.FocusEvent<HTMLInputElement>) => { + const target = ev.currentTarget; + onUpdate( + { + ...annotation, + [target.name]: target.type === 'checkbox' ? target.checked : target.value, + }, + editIndex + ); + }; + + const onColorChange = (color?: string) => { + onUpdate( + { + ...annotation, + iconColor: color!, + }, + editIndex + ); + }; + + const onFilterTypeChange = (v: SelectableValue<PanelFilterType>) => { + let filter = + v.value === PanelFilterType.AllPanels + ? undefined + : { + exclude: v.value === PanelFilterType.ExcludePanels, + ids: annotation.filter?.ids ?? [], + }; + onUpdate({ ...annotation, filter }, editIndex); + }; + + const onAddFilterPanelID = (selections: Array<SelectableValue<number>>) => { + if (!Array.isArray(selections)) { + return; + } + + const filter: AnnotationPanelFilter = { + exclude: panelFilter === PanelFilterType.ExcludePanels, + ids: [], + }; + + selections.forEach((selection) => selection.value && filter.ids.push(selection.value)); + onUpdate({ ...annotation, filter }, editIndex); + }; + + const onDeleteAndLeavePage = () => { + onDelete(editIndex); + onBackToList(); + }; + + const isNewAnnotation = annotation.name === newAnnotationName; + + const sortFn = (a: SelectableValue<number>, b: SelectableValue<number>) => { + if (a.label && b.label) { + return a.label.toLowerCase().localeCompare(b.label.toLowerCase()); + } + + return -1; + }; + + const selectablePanels: Array<SelectableValue<number>> = useMemo( + () => + panels + // Filtering out rows at the moment, revisit to only include panels that support annotations + // However the information to know if a panel supports annotations requires it to be already loaded + // panel.plugin?.dataSupport?.annotations + .filter((panel) => config.panels[panel.state.pluginId]) + .map((panel) => ({ + value: getPanelIdForVizPanel(panel), + label: panel.state.title ?? `Panel ${getPanelIdForVizPanel(panel)}`, + description: panel.state.description, + imgUrl: config.panels[panel.state.pluginId].info.logos.small, + })) + .sort(sortFn) ?? [], + [panels] + ); + + return ( + <div> + <FieldSet className={styles.settingsForm}> + <Field label="Name"> + <Input + data-testid={selectors.pages.Dashboard.Settings.Annotations.Settings.name} + name="name" + id="name" + autoFocus={isNewAnnotation} + value={annotation.name} + onChange={onNameChange} + /> + </Field> + <Field label="Data source" htmlFor="data-source-picker"> + <DataSourcePicker annotations variables current={annotation.datasource} onChange={onDataSourceChange} /> + </Field> + <Field label="Enabled" description="When enabled the annotation query is issued every dashboard refresh"> + <Checkbox + name="enable" + id="enable" + value={annotation.enable} + onChange={onChange} + data-testid={selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.enable} + /> + </Field> + <Field + label="Hidden" + description="Annotation queries can be toggled on or off at the top of the dashboard. With this option checked this toggle will be hidden." + > + <Checkbox + name="hide" + id="hide" + value={annotation.hide} + onChange={onChange} + data-testid={selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.hide} + /> + </Field> + <Field label="Color" description="Color to use for the annotation event markers"> + <HorizontalGroup> + <ColorValueEditor value={annotation?.iconColor} onChange={onColorChange} /> + </HorizontalGroup> + </Field> + <Field label="Show in" data-testid={selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.showInLabel}> + <> + <Select + options={panelFilters} + value={panelFilter} + onChange={onFilterTypeChange} + data-testid={selectors.components.Annotations.annotationsTypeInput} + /> + {panelFilter !== PanelFilterType.AllPanels && ( + <MultiSelect + options={selectablePanels} + value={selectablePanels.filter((panel) => annotation.filter?.ids.includes(panel.value!))} + onChange={onAddFilterPanelID} + isClearable={true} + placeholder="Choose panels" + width={100} + closeMenuOnSelect={false} + className={styles.select} + data-testid={selectors.components.Annotations.annotationsChoosePanelInput} + /> + )} + </> + </Field> + </FieldSet> + <FieldSet> + <h3 className="page-heading">Query</h3> + {ds?.annotations && dsi && ( + <StandardAnnotationQueryEditor + datasource={ds} + datasourceInstanceSettings={dsi} + annotation={annotation} + onChange={(annotation) => onUpdate(annotation, editIndex)} + /> + )} + {ds && !ds.annotations && ( + <AngularEditorLoader + datasource={ds} + annotation={annotation} + onChange={(annotation) => onUpdate(annotation, editIndex)} + /> + )} + </FieldSet> + <Stack> + {!annotation.builtIn && ( + <Button + variant="destructive" + onClick={onDeleteAndLeavePage} + data-testid={selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.delete} + > + Delete + </Button> + )} + <Button + variant="secondary" + onClick={onBackToList} + data-testid={selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.apply} + > + Back to list + </Button> + </Stack> + </div> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + settingsForm: css({ + maxWidth: theme.spacing(60), + marginBottom: theme.spacing(2), + }), + select: css({ + marginTop: '8px', + }), + }; +}; + +// Synthetic type +enum PanelFilterType { + AllPanels, + IncludePanels, + ExcludePanels, +} + +const panelFilters = [ + { + label: 'All panels', + value: PanelFilterType.AllPanels, + description: 'Send the annotation data to all panels that support annotations', + }, + { + label: 'Selected panels', + value: PanelFilterType.IncludePanels, + description: 'Send the annotations to the explicitly listed panels', + }, + { + label: 'All panels except', + value: PanelFilterType.ExcludePanels, + description: 'Do not send annotation data to the following panels', + }, +]; diff --git a/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsList.test.tsx b/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsList.test.tsx new file mode 100644 index 0000000000000..3a9d6fc28bf83 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsList.test.tsx @@ -0,0 +1,139 @@ +import { act, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { AnnotationQuery } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; + +import { MoveDirection } from '../AnnotationsEditView'; + +import { AnnotationSettingsList, BUTTON_TITLE } from './AnnotationSettingsList'; + +const defaultDatasource = mockDataSource({ + name: 'Default Test Data Source', + type: 'test', +}); + +jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({ + ...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'), + getDataSourceSrv: () => ({ + getInstanceSettings: () => ({ ...defaultDatasource }), + }), +})); + +describe('AnnotationSettingsEdit', () => { + const mockOnNew = jest.fn(); + const mockOnEdit = jest.fn(); + const mockOnMove = jest.fn(); + const mockOnDelete = jest.fn(); + + async function setup(emptyList = false) { + const annotationQuery1: AnnotationQuery = { + name: 'test1', + datasource: defaultDatasource, + enable: true, + hide: false, + iconColor: 'blue', + }; + + const annotationQuery2: AnnotationQuery = { + name: 'test2', + datasource: defaultDatasource, + enable: true, + hide: false, + iconColor: 'red', + }; + + const props = { + annotations: emptyList ? [] : [annotationQuery1, annotationQuery2], + onNew: mockOnNew, + onEdit: mockOnEdit, + onMove: mockOnMove, + onDelete: mockOnDelete, + }; + + return { + renderer: await act(async () => render(<AnnotationSettingsList {...props} />)), + user: userEvent.setup(), + }; + } + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render with empty list message', async () => { + const { + renderer: { getByTestId }, + } = await setup(true); + + const emptyListBtn = getByTestId(selectors.components.CallToActionCard.buttonV2(BUTTON_TITLE)); + + expect(emptyListBtn).toBeInTheDocument(); + }); + + it('should create new annotation when empty list button is pressed', async () => { + const { + renderer: { getByTestId }, + user, + } = await setup(true); + + const emptyListBtn = getByTestId(selectors.components.CallToActionCard.buttonV2(BUTTON_TITLE)); + + await user.click(emptyListBtn); + + expect(mockOnNew).toHaveBeenCalledTimes(1); + }); + + it('should render annotation list', async () => { + const { + renderer: { getByTestId }, + } = await setup(); + + const list = getByTestId(selectors.pages.Dashboard.Settings.Annotations.List.annotations); + + expect(list.children.length).toBe(2); + }); + + it('should edit annotation', async () => { + const { + renderer: { getAllByRole }, + user, + } = await setup(); + + const gridCells = getAllByRole('gridcell'); + + await user.click(gridCells[0]); + + expect(mockOnEdit).toHaveBeenCalledTimes(1); + }); + + it('should move annotation up', async () => { + const { + renderer: { getAllByLabelText }, + user, + } = await setup(); + + const moveBtns = getAllByLabelText('Move up'); + + await user.click(moveBtns[0]); + + expect(mockOnMove).toHaveBeenCalledTimes(1); + expect(mockOnMove).toHaveBeenCalledWith(expect.anything(), MoveDirection.UP); + }); + + it('should move annotation down', async () => { + const { + renderer: { getAllByLabelText }, + user, + } = await setup(); + + const moveBtns = getAllByLabelText('Move down'); + + await user.click(moveBtns[0]); + + expect(mockOnMove).toHaveBeenCalledTimes(1); + expect(mockOnMove).toHaveBeenCalledWith(expect.anything(), MoveDirection.DOWN); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsList.tsx b/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsList.tsx new file mode 100644 index 0000000000000..3ceb2393f2e3a --- /dev/null +++ b/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsList.tsx @@ -0,0 +1,139 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { AnnotationQuery } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { Button, DeleteButton, IconButton, useStyles2, VerticalGroup } from '@grafana/ui'; +import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; +import { ListNewButton } from 'app/features/dashboard/components/DashboardSettings/ListNewButton'; + +import { MoveDirection } from '../AnnotationsEditView'; + +type Props = { + annotations: AnnotationQuery[]; + onNew: () => void; + onEdit: (idx: number) => void; + onMove: (idx: number, dir: MoveDirection) => void; + onDelete: (idx: number) => void; +}; + +export const BUTTON_TITLE = 'Add annotation query'; + +export const AnnotationSettingsList = ({ annotations, onNew, onEdit, onMove, onDelete }: Props) => { + const styles = useStyles2(getStyles); + + const showEmptyListCTA = annotations.length === 0 || (annotations.length === 1 && annotations[0].builtIn); + + const getAnnotationName = (anno: AnnotationQuery) => { + if (anno.enable === false) { + return <em className="muted">(Disabled)   {anno.name}</em>; + } + + if (anno.builtIn) { + return <em className="muted">{anno.name}   (Built-in)</em>; + } + + return <>{anno.name}</>; + }; + + const dataSourceSrv = getDataSourceSrv(); + return ( + <VerticalGroup> + {annotations.length > 0 && ( + <div className={styles.table}> + <table role="grid" className="filter-table filter-table--hover"> + <thead> + <tr> + <th>Query name</th> + <th>Data source</th> + <th colSpan={3}></th> + </tr> + </thead> + <tbody data-testid={selectors.pages.Dashboard.Settings.Annotations.List.annotations}> + {annotations.map((annotation, idx) => ( + <tr key={`${annotation.name}-${idx}`}> + {annotation.builtIn ? ( + <td role="gridcell" style={{ width: '90%' }} className="pointer" onClick={() => onEdit(idx)}> + <Button size="sm" fill="text" variant="secondary"> + {getAnnotationName(annotation)} + </Button> + </td> + ) : ( + <td role="gridcell" className="pointer" onClick={() => onEdit(idx)}> + <Button size="sm" fill="text" variant="secondary"> + {getAnnotationName(annotation)} + </Button> + </td> + )} + <td role="gridcell" className="pointer" onClick={() => onEdit(idx)}> + {dataSourceSrv.getInstanceSettings(annotation.datasource)?.name || annotation.datasource?.uid} + </td> + <td role="gridcell" style={{ width: '1%' }}> + {idx !== 0 && ( + <IconButton name="arrow-up" onClick={() => onMove(idx, MoveDirection.UP)} tooltip="Move up" /> + )} + </td> + <td role="gridcell" style={{ width: '1%' }}> + {annotations.length > 1 && idx !== annotations.length - 1 ? ( + <IconButton + name="arrow-down" + onClick={() => onMove(idx, MoveDirection.DOWN)} + tooltip="Move down" + /> + ) : null} + </td> + <td role="gridcell" style={{ width: '1%' }}> + {!annotation.builtIn && ( + <DeleteButton + size="sm" + onConfirm={() => onDelete(idx)} + aria-label={`Delete query with title "${annotation.name}"`} + /> + )} + </td> + </tr> + ))} + </tbody> + </table> + </div> + )} + {showEmptyListCTA && ( + <EmptyListCTA + onClick={onNew} + title="There are no custom annotation queries added yet" + buttonIcon="comment-alt" + buttonTitle={BUTTON_TITLE} + infoBoxTitle="What are annotation queries?" + infoBox={{ + __html: `<p>Annotations provide a way to integrate event data into your graphs. They are visualized as vertical lines + and icons on all graph panels. When you hover over an annotation icon you can get event text & tags for + the event. You can add annotation events directly from grafana by holding CTRL or CMD + click on graph (or + drag region). These will be stored in Grafana's annotation database. + </p> + Checkout the + <a class='external-link' target='_blank' href='http://docs.grafana.org/reference/annotations/' + >Annotations documentation</a + > + for more information.`, + }} + /> + )} + {!showEmptyListCTA && ( + <ListNewButton + data-testid={selectors.pages.Dashboard.Settings.Annotations.List.addAnnotationCTAV2} + onClick={onNew} + > + New query + </ListNewButton> + )} + </VerticalGroup> + ); +}; + +const getStyles = () => ({ + table: css({ + width: '100%', + overflowX: 'scroll', + }), +}); diff --git a/public/app/features/dashboard-scene/settings/annotations/index.tsx b/public/app/features/dashboard-scene/settings/annotations/index.tsx new file mode 100644 index 0000000000000..5c5ebb6cb18f7 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/annotations/index.tsx @@ -0,0 +1,2 @@ +export { AnnotationSettingsEdit, newAnnotationName } from './AnnotationSettingsEdit'; +export { AnnotationSettingsList } from './AnnotationSettingsList'; diff --git a/public/app/features/dashboard-scene/settings/links/DashboardLinkForm.tsx b/public/app/features/dashboard-scene/settings/links/DashboardLinkForm.tsx new file mode 100644 index 0000000000000..eb4cd33700e2b --- /dev/null +++ b/public/app/features/dashboard-scene/settings/links/DashboardLinkForm.tsx @@ -0,0 +1,109 @@ +import React from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { DashboardLink } from '@grafana/schema'; +import { CollapsableSection, TagsInput, Select, Field, Input, Checkbox, Button } from '@grafana/ui'; + +import { LINK_ICON_MAP, NEW_LINK } from './utils'; + +const linkTypeOptions = [ + { value: 'dashboards', label: 'Dashboards' }, + { value: 'link', label: 'Link' }, +]; + +const linkIconOptions = Object.keys(LINK_ICON_MAP).map((key) => ({ label: key, value: key })); + +interface DashboardLinkFormProps { + link: DashboardLink; + onUpdate: (link: DashboardLink) => void; + onGoBack: () => void; +} + +export function DashboardLinkForm({ link, onUpdate, onGoBack }: DashboardLinkFormProps) { + const onTagsChange = (tags: string[]) => { + onUpdate({ ...link, tags: tags }); + }; + + const onTypeChange = (selectedItem: SelectableValue) => { + const update = { ...link, type: selectedItem.value }; + + // clear props that are no longe revant for this type + if (update.type === 'dashboards') { + update.url = ''; + update.tooltip = ''; + } else { + update.tags = []; + } + + onUpdate(update); + }; + + const onIconChange = (selectedItem: SelectableValue) => { + onUpdate({ ...link, icon: selectedItem.value }); + }; + + const onChange = (ev: React.FocusEvent<HTMLInputElement>) => { + const target = ev.currentTarget; + onUpdate({ + ...link, + [target.name]: target.type === 'checkbox' ? target.checked : target.value, + }); + }; + + const isNew = link.title === NEW_LINK.title; + + return ( + <div style={{ maxWidth: '600px' }}> + <Field label="Title"> + <Input name="title" id="title" value={link.title} onChange={onChange} autoFocus={isNew} /> + </Field> + <Field label="Type"> + <Select inputId="link-type-input" value={link.type} options={linkTypeOptions} onChange={onTypeChange} /> + </Field> + {link.type === 'dashboards' && ( + <> + <Field label="With tags"> + <TagsInput tags={link.tags} onChange={onTagsChange} /> + </Field> + </> + )} + {link.type === 'link' && ( + <> + <Field label="URL"> + <Input name="url" value={link.url} onChange={onChange} /> + </Field> + <Field label="Tooltip"> + <Input name="tooltip" value={link.tooltip} onChange={onChange} placeholder="Open dashboard" /> + </Field> + <Field label="Icon"> + <Select value={link.icon} options={linkIconOptions} onChange={onIconChange} /> + </Field> + </> + )} + <CollapsableSection label="Options" isOpen={true}> + {link.type === 'dashboards' && ( + <Field> + <Checkbox label="Show as dropdown" name="asDropdown" value={link.asDropdown} onChange={onChange} /> + </Field> + )} + <Field> + <Checkbox label="Include current time range" name="keepTime" value={link.keepTime} onChange={onChange} /> + </Field> + <Field> + <Checkbox + label="Include current template variable values" + name="includeVars" + value={link.includeVars} + onChange={onChange} + /> + </Field> + <Field> + <Checkbox label="Open link in new tab" name="targetBlank" value={link.targetBlank} onChange={onChange} /> + </Field> + </CollapsableSection> + <Button variant="secondary" onClick={onGoBack}> + Back to list + </Button> + </div> + ); +} diff --git a/public/app/features/dashboard-scene/settings/links/DashboardLinkList.tsx b/public/app/features/dashboard-scene/settings/links/DashboardLinkList.tsx new file mode 100644 index 0000000000000..9e9f76d3d1565 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/links/DashboardLinkList.tsx @@ -0,0 +1,115 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { DashboardLink } from '@grafana/schema'; +import { Button, DeleteButton, HorizontalGroup, Icon, IconButton, TagList, useStyles2 } from '@grafana/ui'; +import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; + +interface DashboardLinkListProps { + links: DashboardLink[]; + onNew: () => void; + onEdit: (idx: number) => void; + onDuplicate: (link: DashboardLink) => void; + onDelete: (idx: number) => void; + onOrderChange: (idx: number, direction: number) => void; +} + +export function DashboardLinkList({ + links, + onNew, + onOrderChange, + onEdit, + onDuplicate, + onDelete, +}: DashboardLinkListProps) { + const styles = useStyles2(getStyles); + const isEmptyList = links.length === 0; + + if (isEmptyList) { + return ( + <div> + <EmptyListCTA + onClick={onNew} + title="There are no dashboard links added yet" + buttonIcon="link" + buttonTitle="Add dashboard link" + infoBoxTitle="What are dashboard links?" + infoBox={{ + __html: + '<p>Dashboard Links allow you to place links to other dashboards and web sites directly below the dashboard header.</p>', + }} + /> + </div> + ); + } + + return ( + <> + <table role="grid" className="filter-table filter-table--hover"> + <thead> + <tr> + <th>Type</th> + <th>Info</th> + <th colSpan={3} /> + </tr> + </thead> + <tbody> + {links.map((link, idx) => ( + <tr key={`${link.title}-${idx}`}> + <td role="gridcell" className="pointer" onClick={() => onEdit(idx)}> + <Icon name="external-link-alt" />   {link.type} + </td> + <td role="gridcell"> + <HorizontalGroup> + {link.title && <span className={styles.titleWrapper}>{link.title}</span>} + {link.type === 'link' && <span className={styles.urlWrapper}>{link.url}</span>} + {link.type === 'dashboards' && <TagList tags={link.tags ?? []} />} + </HorizontalGroup> + </td> + <td style={{ width: '1%' }} role="gridcell"> + {idx !== 0 && ( + <IconButton name="arrow-up" onClick={() => onOrderChange(idx, -1)} tooltip="Move link up" /> + )} + </td> + <td style={{ width: '1%' }} role="gridcell"> + {links.length > 1 && idx !== links.length - 1 ? ( + <IconButton name="arrow-down" onClick={() => onOrderChange(idx, 1)} tooltip="Move link down" /> + ) : null} + </td> + <td style={{ width: '1%' }} role="gridcell"> + <IconButton name="copy" onClick={() => onDuplicate(link)} tooltip="Copy link" /> + </td> + <td style={{ width: '1%' }} role="gridcell"> + <DeleteButton + aria-label={`Delete link with title "${link.title}"`} + size="sm" + onConfirm={() => onDelete(idx)} + /> + </td> + </tr> + ))} + </tbody> + </table> + <Button className={styles.newLinkButton} icon="plus" onClick={onNew}> + New link + </Button> + </> + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + titleWrapper: css({ + width: '20vw', + textOverflow: 'ellipsis', + overflow: 'hidden', + }), + urlWrapper: css({ + width: '40vw', + textOverflow: 'ellipsis', + overflow: 'hidden', + }), + newLinkButton: css({ + marginTop: theme.spacing(3), + }), +}); diff --git a/public/app/features/dashboard-scene/settings/links/utils.ts b/public/app/features/dashboard-scene/settings/links/utils.ts new file mode 100644 index 0000000000000..717a2bdaf1c79 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/links/utils.ts @@ -0,0 +1,25 @@ +import { IconName } from '@grafana/data'; +import { DashboardLink } from '@grafana/schema'; + +export const NEW_LINK: DashboardLink = { + icon: 'external link', + title: 'New link', + tooltip: '', + type: 'dashboards', + url: '', + asDropdown: false, + tags: [], + targetBlank: false, + keepTime: false, + includeVars: false, +}; + +export const LINK_ICON_MAP: Record<string, IconName | undefined> = { + 'external link': 'external-link-alt', + dashboard: 'apps', + question: 'question-circle', + info: 'info-circle', + bolt: 'bolt', + doc: 'file-alt', + cloud: 'cloud', +}; diff --git a/public/app/features/dashboard-scene/settings/utils.ts b/public/app/features/dashboard-scene/settings/utils.ts index 55956d5b5455b..88f16b6e08f75 100644 --- a/public/app/features/dashboard-scene/settings/utils.ts +++ b/public/app/features/dashboard-scene/settings/utils.ts @@ -11,7 +11,10 @@ import { DashboardScene } from '../scene/DashboardScene'; import { AnnotationsEditView } from './AnnotationsEditView'; import { DashboardLinksEditView } from './DashboardLinksEditView'; import { GeneralSettingsEditView } from './GeneralSettingsEditView'; +import { JsonModelEditView } from './JsonModelEditView'; +import { PermissionsEditView } from './PermissionsEditView'; import { VariablesEditView } from './VariablesEditView'; +import { VersionsEditView } from './VersionsEditView'; export interface DashboardEditViewState extends SceneObjectState {} @@ -54,6 +57,21 @@ export function useDashboardEditPageNav(dashboard: DashboardScene, currentEditVi url: locationUtil.getUrlForPartial(location, { editview: 'links', editIndex: null }), active: currentEditView === 'links', }, + { + text: t('dashboard-settings.versions.title', 'Versions'), + url: locationUtil.getUrlForPartial(location, { editview: 'versions', editIndex: null }), + active: currentEditView === 'versions', + }, + { + text: t('dashboard-settings.permissions.title', 'Permissions'), + url: locationUtil.getUrlForPartial(location, { editview: 'permissions', editIndex: null }), + active: currentEditView === 'permissions', + }, + { + text: t('dashboard-settings.json-editor.title', 'JSON Model'), + url: locationUtil.getUrlForPartial(location, { editview: 'json-model', editIndex: null }), + active: currentEditView === 'json-model', + }, ], parentItem: dashboardPageNav, }; @@ -69,6 +87,12 @@ export function createDashboardEditViewFor(editview: string): DashboardEditView return new VariablesEditView({}); case 'links': return new DashboardLinksEditView({}); + case 'versions': + return new VersionsEditView({}); + case 'json-model': + return new JsonModelEditView({}); + case 'permissions': + return new PermissionsEditView({}); case 'settings': default: return new GeneralSettingsEditView({}); diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx new file mode 100644 index 0000000000000..d04e228acc46e --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx @@ -0,0 +1,168 @@ +import { css } from '@emotion/css'; +import React, { FormEvent, useCallback, useState } from 'react'; +import { useAsyncFn } from 'react-use'; +import { lastValueFrom } from 'rxjs'; + +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { reportInteraction } from '@grafana/runtime'; +import { SceneVariable } from '@grafana/scenes'; +import { VariableHide, defaultVariableModel } from '@grafana/schema'; +import { Button, LoadingPlaceholder, ConfirmModal, ModalsController, Stack, useStyles2 } from '@grafana/ui'; +import { VariableHideSelect } from 'app/features/dashboard-scene/settings/variables/components/VariableHideSelect'; +import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend'; +import { VariableTextAreaField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextAreaField'; +import { VariableTextField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextField'; +import { VariableValuesPreview } from 'app/features/dashboard-scene/settings/variables/components/VariableValuesPreview'; +import { VariableNameConstraints } from 'app/features/variables/editor/types'; + +import { VariableTypeSelect } from './components/VariableTypeSelect'; +import { EditableVariableType, getVariableEditor, hasVariableOptions, isEditableVariableType } from './utils'; + +interface VariableEditorFormProps { + variable: SceneVariable; + onTypeChange: (type: EditableVariableType) => void; + onGoBack: () => void; + onDelete: (variableName: string) => void; + onValidateVariableName: (name: string, key: string | undefined) => [true, string] | [false, null]; +} +export function VariableEditorForm({ + variable, + onTypeChange, + onGoBack, + onDelete, + onValidateVariableName, +}: VariableEditorFormProps) { + const styles = useStyles2(getStyles); + const [nameError, setNameError] = useState<string | null>(null); + const { name, type, label, description, hide, key } = variable.useState(); + const EditorToRender = isEditableVariableType(type) ? getVariableEditor(type) : undefined; + const [runQueryState, onRunQuery] = useAsyncFn(async () => { + await lastValueFrom(variable.validateAndUpdate!()); + }, [variable]); + const onVariableTypeChange = (option: SelectableValue<EditableVariableType>) => { + if (option.value) { + onTypeChange(option.value); + } + }; + + const onNameChange = useCallback( + (e: FormEvent<HTMLInputElement>) => { + const [, errorMessage] = onValidateVariableName(e.currentTarget.value, key); + if (nameError !== errorMessage) { + setNameError(errorMessage); + } + }, + [key, nameError, onValidateVariableName] + ); + + const onNameBlur = (e: FormEvent<HTMLInputElement>) => { + if (!nameError) { + variable.setState({ name: e.currentTarget.value }); + } + }; + + const onLabelBlur = (e: FormEvent<HTMLInputElement>) => variable.setState({ label: e.currentTarget.value }); + const onDescriptionBlur = (e: FormEvent<HTMLTextAreaElement>) => + variable.setState({ description: e.currentTarget.value }); + const onHideChange = (hide: VariableHide) => variable.setState({ hide }); + + const isHasVariableOptions = hasVariableOptions(variable); + + const onDeleteVariable = (hideModal: () => void) => () => { + reportInteraction('Delete variable'); + onDelete(name); + hideModal(); + }; + + return ( + <form aria-label="Variable editor Form"> + <VariableTypeSelect onChange={onVariableTypeChange} type={type} /> + + <VariableLegend>General</VariableLegend> + <VariableTextField + name="Name" + description="The name of the template variable. (Max. 50 characters)" + placeholder="Variable name" + defaultValue={name ?? ''} + onChange={onNameChange} + onBlur={onNameBlur} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2} + maxLength={VariableNameConstraints.MaxSize} + required + invalid={!!nameError} + error={nameError} + /> + <VariableTextField + name="Label" + description="Optional display name" + placeholder="Label name" + defaultValue={label ?? ''} + onBlur={onLabelBlur} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInputV2} + /> + <VariableTextAreaField + name="Description" + defaultValue={description ?? ''} + placeholder="Descriptive text" + onBlur={onDescriptionBlur} + width={52} + /> + + <VariableHideSelect onChange={onHideChange} hide={hide || defaultVariableModel.hide!} type={type} /> + + {EditorToRender && <EditorToRender variable={variable} onRunQuery={onRunQuery} />} + + {isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect()} />} + + <div className={styles.buttonContainer}> + <Stack gap={2}> + <ModalsController> + {({ showModal, hideModal }) => ( + <Button + variant="destructive" + fill="outline" + onClick={() => { + showModal(ConfirmModal, { + title: 'Delete variable', + body: `Are you sure you want to delete: ${name}?`, + confirmText: 'Delete variable', + onConfirm: onDeleteVariable(hideModal), + onDismiss: hideModal, + isOpen: true, + }); + }} + > + Delete + </Button> + )} + </ModalsController> + <Button + variant="secondary" + data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.General.applyButton} + onClick={onGoBack} + > + Back to list + </Button> + + {isHasVariableOptions && ( + <Button + disabled={runQueryState.loading} + variant="secondary" + data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.General.submitButton} + onClick={onRunQuery} + > + {runQueryState.loading ? <LoadingPlaceholder text="Running query..." /> : `Run query`} + </Button> + )} + </Stack> + </div> + </form> + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + buttonContainer: css({ + marginTop: theme.spacing(2), + }), +}); diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditorList.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditorList.tsx index 8cbe42568efe3..b844531febb50 100644 --- a/public/app/features/dashboard-scene/settings/variables/VariableEditorList.tsx +++ b/public/app/features/dashboard-scene/settings/variables/VariableEditorList.tsx @@ -5,7 +5,7 @@ import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd'; import { selectors } from '@grafana/e2e-selectors'; import { reportInteraction } from '@grafana/runtime'; import { SceneVariable, SceneVariableState } from '@grafana/scenes'; -import { useStyles2, Stack } from '@grafana/ui'; +import { useStyles2, Stack, Button } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import { VariableEditorListRow } from './VariableEditorListRow'; @@ -42,7 +42,7 @@ export function VariableEditorList({ {variables.length === 0 && <EmptyVariablesList onAdd={onAdd} />} {variables.length > 0 && ( - <Stack direction="column" gap={4}> + <Stack direction="column" gap={3}> <div className={styles.tableContainer}> <table className="filter-table filter-table--hover" @@ -80,6 +80,15 @@ export function VariableEditorList({ </DragDropContext> </table> </div> + <Stack> + <Button + data-testid={selectors.pages.Dashboard.Settings.Variables.List.newButton} + onClick={onAdd} + icon="plus" + > + New variable + </Button> + </Stack> </Stack> )} </div> @@ -94,7 +103,6 @@ function EmptyVariablesList({ onAdd }: { onAdd: () => void }): ReactElement { title="There are no variables yet" buttonIcon="calculator-alt" buttonTitle="Add variable" - buttonDisabled infoBox={{ __html: ` <p> Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditorListRow.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditorListRow.tsx index 3850e071e88bb..0a2305e78a03c 100644 --- a/public/app/features/dashboard-scene/settings/variables/VariableEditorListRow.tsx +++ b/public/app/features/dashboard-scene/settings/variables/VariableEditorListRow.tsx @@ -5,9 +5,10 @@ import { Draggable } from 'react-beautiful-dnd'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { reportInteraction } from '@grafana/runtime'; -import { QueryVariable, SceneVariable } from '@grafana/scenes'; +import { SceneVariable } from '@grafana/scenes'; import { Button, ConfirmModal, Icon, IconButton, useStyles2, useTheme2 } from '@grafana/ui'; -import { hasOptions } from 'app/features/variables/guard'; + +import { getDefinition } from './utils'; export interface VariableEditorListRowProps { index: number; @@ -121,20 +122,6 @@ export function VariableEditorListRow({ ); } -function getDefinition(model: SceneVariable): string { - let definition = ''; - if (model instanceof QueryVariable) { - if (model.state.definition) { - definition = model.state.definition; - } else if (typeof model.state.query === 'string') { - definition = model.state.query; - } - } else if (hasOptions(model.state)) { - definition = model.state.query; - } - return definition; -} - function getStyles(theme: GrafanaTheme2) { return { dragHandle: css({ diff --git a/public/app/features/dashboard-scene/settings/variables/components/AdHocVariableForm.test.tsx b/public/app/features/dashboard-scene/settings/variables/components/AdHocVariableForm.test.tsx new file mode 100644 index 0000000000000..610a2a9eea96e --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/AdHocVariableForm.test.tsx @@ -0,0 +1,118 @@ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; + +import { AdHocVariableForm, AdHocVariableFormProps } from './AdHocVariableForm'; + +const defaultDatasource = mockDataSource({ + name: 'Default Test Data Source', + uid: 'test-ds', + type: 'test', +}); + +const promDatasource = mockDataSource({ + name: 'Prometheus', + uid: 'prometheus', + type: 'prometheus', +}); + +jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({ + ...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'), + getDataSourceSrv: () => ({ + get: async () => defaultDatasource, + getList: () => [defaultDatasource, promDatasource], + getInstanceSettings: () => ({ ...defaultDatasource }), + }), +})); + +describe('AdHocVariableForm', () => { + const onDataSourceChange = jest.fn(); + const defaultProps: AdHocVariableFormProps = { + datasource: defaultDatasource, + onDataSourceChange, + infoText: 'Test Info', + }; + + it('should render the form with the provided data source', async () => { + const { renderer } = await setup(defaultProps); + + const dataSourcePicker = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.datasourceSelect + ); + const infoText = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.infoText + ); + + expect(dataSourcePicker).toBeInTheDocument(); + expect(dataSourcePicker.getAttribute('placeholder')).toBe('Default Test Data Source'); + expect(infoText).toBeInTheDocument(); + expect(infoText).toHaveTextContent('Test Info'); + }); + + it('should call the onDataSourceChange callback when the data source is changed', async () => { + const { renderer, user } = await setup(defaultProps); + + // Simulate changing the data source + await user.click(renderer.getByTestId(selectors.components.DataSourcePicker.inputV2)); + await user.click(renderer.getByText(/prom/i)); + + expect(onDataSourceChange).toHaveBeenCalledTimes(1); + expect(onDataSourceChange).toHaveBeenCalledWith(promDatasource, undefined); + }); + + it('should not render code editor when no default keys provided', async () => { + await setup(defaultProps); + + expect(screen.queryByTestId(selectors.components.CodeEditor.container)).not.toBeInTheDocument(); + }); + + it('should render code editor when defaultKeys and onDefaultKeysChange are provided', async () => { + const mockOnStaticKeysChange = jest.fn(); + await setup({ + ...defaultProps, + defaultKeys: [{ text: 'test', value: 'test' }], + onDefaultKeysChange: mockOnStaticKeysChange, + }); + + expect(await screen.findByTestId(selectors.components.CodeEditor.container)).toBeInTheDocument(); + }); + + it('should call onDefaultKeysChange when toggling on default options', async () => { + const mockOnStaticKeysChange = jest.fn(); + await setup({ + ...defaultProps, + onDefaultKeysChange: mockOnStaticKeysChange, + }); + + await userEvent.click( + screen.getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.modeToggle) + ); + expect(mockOnStaticKeysChange).toHaveBeenCalledTimes(1); + expect(mockOnStaticKeysChange).toHaveBeenCalledWith([]); + }); + + it('should call onDefaultKeysChange when toggling off default options', async () => { + const mockOnStaticKeysChange = jest.fn(); + await setup({ + ...defaultProps, + defaultKeys: [{ text: 'test', value: 'test' }], + onDefaultKeysChange: mockOnStaticKeysChange, + }); + + await userEvent.click( + screen.getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.modeToggle) + ); + expect(mockOnStaticKeysChange).toHaveBeenCalledTimes(1); + expect(mockOnStaticKeysChange).toHaveBeenCalledWith(undefined); + }); +}); + +async function setup(props?: React.ComponentProps<typeof AdHocVariableForm>) { + return { + renderer: await act(() => render(<AdHocVariableForm onDataSourceChange={jest.fn()} {...props} />)), + user: userEvent.setup(), + }; +} diff --git a/public/app/features/dashboard-scene/settings/variables/components/AdHocVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/AdHocVariableForm.tsx new file mode 100644 index 0000000000000..415e9f6cc211a --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/AdHocVariableForm.tsx @@ -0,0 +1,85 @@ +import React, { useCallback } from 'react'; + +import { DataSourceInstanceSettings, MetricFindValue, readCSV } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { DataSourceRef } from '@grafana/schema'; +import { Alert, CodeEditor, Field, Switch } from '@grafana/ui'; +import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; + +import { VariableLegend } from './VariableLegend'; + +export interface AdHocVariableFormProps { + datasource?: DataSourceRef; + onDataSourceChange: (dsSettings: DataSourceInstanceSettings) => void; + infoText?: string; + defaultKeys?: MetricFindValue[]; + onDefaultKeysChange?: (keys?: MetricFindValue[]) => void; +} + +export function AdHocVariableForm({ + datasource, + infoText, + onDataSourceChange, + onDefaultKeysChange, + defaultKeys, +}: AdHocVariableFormProps) { + const updateStaticKeys = useCallback( + (csvContent: string) => { + const df = readCSV('key,value\n' + csvContent)[0]; + const options = []; + for (let i = 0; i < df.length; i++) { + options.push({ text: df.fields[0].values[i], value: df.fields[1].values[i] }); + } + + onDefaultKeysChange?.(options); + }, + [onDefaultKeysChange] + ); + + return ( + <> + <VariableLegend>Ad-hoc options</VariableLegend> + <Field label="Data source" htmlFor="data-source-picker"> + <DataSourcePicker current={datasource} onChange={onDataSourceChange} width={30} variables={true} noDefault /> + </Field> + + {infoText ? ( + <Alert + title={infoText} + severity="info" + data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.infoText} + /> + ) : null} + + {onDefaultKeysChange && ( + <> + <Field label="Use static key dimensions" description="Provide dimensions as CSV: dimensionName, dimensionId"> + <Switch + data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.modeToggle} + value={defaultKeys !== undefined} + onChange={(e) => { + if (defaultKeys === undefined) { + onDefaultKeysChange([]); + } else { + onDefaultKeysChange(undefined); + } + }} + /> + </Field> + + {defaultKeys !== undefined && ( + <CodeEditor + height={300} + language="csv" + value={defaultKeys.map((o) => `${o.text},${o.value}`).join('\n')} + onBlur={updateStaticKeys} + onSave={updateStaticKeys} + showMiniMap={false} + showLineNumbers={true} + /> + )} + </> + )} + </> + ); +} diff --git a/public/app/features/dashboard-scene/settings/variables/components/ConstantVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/ConstantVariableForm.tsx new file mode 100644 index 0000000000000..262429fc62b0f --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/ConstantVariableForm.tsx @@ -0,0 +1,27 @@ +import React, { FormEvent } from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; + +import { VariableLegend } from './VariableLegend'; +import { VariableTextField } from './VariableTextField'; + +interface ConstantVariableFormProps { + constantValue: string; + onChange: (event: FormEvent<HTMLInputElement>) => void; +} + +export function ConstantVariableForm({ onChange, constantValue }: ConstantVariableFormProps) { + return ( + <> + <VariableLegend>Constant options</VariableLegend> + <VariableTextField + defaultValue={constantValue} + name="Value" + placeholder="your metric prefix" + onBlur={onChange} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.ConstantVariable.constantOptionsQueryInputV2} + width={30} + /> + </> + ); +} diff --git a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.test.tsx b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.test.tsx new file mode 100644 index 0000000000000..ec55c94b26dab --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.test.tsx @@ -0,0 +1,116 @@ +import { render, fireEvent } from '@testing-library/react'; +import React from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; + +import { CustomVariableForm } from './CustomVariableForm'; + +describe('CustomVariableForm', () => { + const onQueryChange = jest.fn(); + const onMultiChange = jest.fn(); + const onIncludeAllChange = jest.fn(); + const onAllValueChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the form fields correctly', () => { + const { getByTestId } = render( + <CustomVariableForm + query="query" + multi={true} + allValue="custom value" + includeAll={true} + onQueryChange={onQueryChange} + onMultiChange={onMultiChange} + onIncludeAllChange={onIncludeAllChange} + onAllValueChange={onAllValueChange} + /> + ); + + const queryInput = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput); + const multiCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + const includeAllCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + const allValueInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ); + + expect(queryInput).toBeInTheDocument(); + expect(queryInput).toHaveValue('query'); + expect(multiCheckbox).toBeInTheDocument(); + expect(multiCheckbox).toBeChecked(); + expect(includeAllCheckbox).toBeInTheDocument(); + expect(includeAllCheckbox).toBeChecked(); + expect(allValueInput).toBeInTheDocument(); + expect(allValueInput).toHaveValue('custom value'); + }); + + it('should call the correct event handlers on input change', () => { + const { getByTestId } = render( + <CustomVariableForm + query="" + multi={true} + allValue="" + includeAll={true} + onQueryChange={onQueryChange} + onMultiChange={onMultiChange} + onIncludeAllChange={onIncludeAllChange} + onAllValueChange={onAllValueChange} + /> + ); + + const queryInput = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput); + const multiCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + const includeAllCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + const allValueInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ); + + fireEvent.click(multiCheckbox); + fireEvent.click(includeAllCheckbox); + fireEvent.change(queryInput, { currentTarget: { value: 'test query' } }); + fireEvent.change(allValueInput, { currentTarget: { value: 'test value' } }); + + expect(onMultiChange).toHaveBeenCalledTimes(1); + expect(onIncludeAllChange).toHaveBeenCalledTimes(1); + expect(onQueryChange).not.toHaveBeenCalledTimes(1); + expect(onAllValueChange).not.toHaveBeenCalledTimes(1); + }); + + it('should call the correct event handlers on input blur', () => { + const { getByTestId } = render( + <CustomVariableForm + query="query value" + multi={true} + allValue="custom all value" + includeAll={true} + onQueryChange={onQueryChange} + onMultiChange={onMultiChange} + onIncludeAllChange={onIncludeAllChange} + onAllValueChange={onAllValueChange} + /> + ); + + const queryInput = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput); + const allValueInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ); + + fireEvent.blur(queryInput); + fireEvent.blur(allValueInput); + + expect(onQueryChange).toHaveBeenCalled(); + expect(onAllValueChange).toHaveBeenCalled(); + expect(onMultiChange).not.toHaveBeenCalled(); + expect(onIncludeAllChange).not.toHaveBeenCalled(); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx new file mode 100644 index 0000000000000..1850b1c5774f8 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx @@ -0,0 +1,57 @@ +import React, { FormEvent } from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; + +import { VariableLegend } from '../components/VariableLegend'; +import { VariableTextAreaField } from '../components/VariableTextAreaField'; + +import { SelectionOptionsForm } from './SelectionOptionsForm'; + +interface CustomVariableFormProps { + query: string; + multi: boolean; + allValue?: string | null; + includeAll: boolean; + onQueryChange: (event: FormEvent<HTMLTextAreaElement>) => void; + onMultiChange: (event: FormEvent<HTMLInputElement>) => void; + onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void; + onAllValueChange: (event: FormEvent<HTMLInputElement>) => void; + onQueryBlur?: (event: FormEvent<HTMLTextAreaElement>) => void; + onAllValueBlur?: (event: FormEvent<HTMLInputElement>) => void; +} + +export function CustomVariableForm({ + query, + multi, + allValue, + includeAll, + onQueryChange, + onMultiChange, + onIncludeAllChange, + onAllValueChange, +}: CustomVariableFormProps) { + return ( + <> + <VariableLegend>Custom options</VariableLegend> + + <VariableTextAreaField + name="Values separated by comma" + defaultValue={query} + placeholder="1, 10, mykey : myvalue, myvalue, escaped\,value" + onBlur={onQueryChange} + required + width={52} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput} + /> + <VariableLegend>Selection options</VariableLegend> + <SelectionOptionsForm + multi={multi} + includeAll={includeAll} + allValue={allValue} + onMultiChange={onMultiChange} + onIncludeAllChange={onIncludeAllChange} + onAllValueChange={onAllValueChange} + /> + </> + ); +} diff --git a/public/app/features/dashboard-scene/settings/variables/components/DataSourceVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/DataSourceVariableForm.tsx new file mode 100644 index 0000000000000..ac77b8c747cb0 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/DataSourceVariableForm.tsx @@ -0,0 +1,79 @@ +import React, { FormEvent } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; + +import { SelectionOptionsForm } from './SelectionOptionsForm'; +import { VariableLegend } from './VariableLegend'; +import { VariableSelectField } from './VariableSelectField'; +import { VariableTextField } from './VariableTextField'; + +interface DataSourceVariableFormProps { + query: string; + regex: string; + multi: boolean; + allValue?: string | null; + includeAll: boolean; + onChange: (option: SelectableValue) => void; + optionTypes: Array<{ value: string; label: string }>; + onRegExBlur: (event: FormEvent<HTMLInputElement>) => void; + onMultiChange: (event: FormEvent<HTMLInputElement>) => void; + onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void; + onAllValueChange: (event: FormEvent<HTMLInputElement>) => void; + onQueryBlur?: (event: FormEvent<HTMLTextAreaElement>) => void; + onAllValueBlur?: (event: FormEvent<HTMLInputElement>) => void; +} + +export function DataSourceVariableForm({ + query, + regex, + optionTypes, + onChange, + onRegExBlur, + multi, + includeAll, + allValue, + onMultiChange, + onIncludeAllChange, + onAllValueChange, +}: DataSourceVariableFormProps) { + const typeValue = optionTypes.find((o) => o.value === query) ?? optionTypes[0]; + + return ( + <> + <VariableLegend>Data source options</VariableLegend> + <VariableSelectField + name="Type" + value={typeValue} + options={optionTypes} + onChange={onChange} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.datasourceSelect} + /> + + <VariableTextField + defaultValue={regex} + name="Instance name filter" + placeholder="/.*-(.*)-.*/" + onBlur={onRegExBlur} + description={ + <div> + Regex filter for which data source instances to choose from in the variable value list. Leave empty for all. + <br /> + <br /> + Example: <code>/^prod/</code> + </div> + } + /> + + <VariableLegend>Selection options</VariableLegend> + <SelectionOptionsForm + multi={multi} + includeAll={includeAll} + allValue={allValue} + onMultiChange={onMultiChange} + onIncludeAllChange={onIncludeAllChange} + onAllValueChange={onAllValueChange} + /> + </> + ); +} diff --git a/public/app/features/dashboard-scene/settings/variables/components/GroupByVariableForm.test.tsx b/public/app/features/dashboard-scene/settings/variables/components/GroupByVariableForm.test.tsx new file mode 100644 index 0000000000000..e59b0e4f3e06f --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/GroupByVariableForm.test.tsx @@ -0,0 +1,114 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { byTestId } from 'testing-library-selector'; + +import { VariableSupportType } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; +import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor'; + +import { GroupByVariableForm, GroupByVariableFormProps } from './GroupByVariableForm'; + +const defaultDatasource = mockDataSource({ + name: 'Default Test Data Source', + type: 'test', +}); + +const promDatasource = mockDataSource({ + name: 'Prometheus', + type: 'prometheus', +}); + +jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({ + ...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'), + getDataSourceSrv: () => ({ + get: async () => ({ + ...defaultDatasource, + variables: { + getType: () => VariableSupportType.Custom, + query: jest.fn(), + editor: jest.fn().mockImplementation(LegacyVariableQueryEditor), + }, + }), + getList: () => [defaultDatasource, promDatasource], + getInstanceSettings: () => ({ ...defaultDatasource }), + }), +})); + +describe('GroupByVariableForm', () => { + const onDataSourceChangeMock = jest.fn(); + const onDefaultOptionsChangeMock = jest.fn(); + + const defaultProps: GroupByVariableFormProps = { + onDataSourceChange: onDataSourceChangeMock, + onDefaultOptionsChange: onDefaultOptionsChangeMock, + }; + + function setup(props?: Partial<GroupByVariableFormProps>) { + return { + renderer: render(<GroupByVariableForm {...defaultProps} {...props} />), + user: userEvent.setup(), + }; + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call onDataSourceChange when changing the datasource', async () => { + const { + renderer: { getByTestId }, + } = setup(); + const dataSourcePicker = getByTestId(selectors.components.DataSourcePicker.inputV2); + await userEvent.click(dataSourcePicker); + await userEvent.click(screen.getByText(/prometheus/i)); + + expect(onDataSourceChangeMock).toHaveBeenCalledTimes(1); + expect(onDataSourceChangeMock).toHaveBeenCalledWith(promDatasource, undefined); + }); + + it('should not render code editor when no default options provided', async () => { + const { + renderer: { queryByTestId }, + } = setup(); + const codeEditor = queryByTestId(selectors.components.CodeEditor.container); + + expect(codeEditor).not.toBeInTheDocument(); + }); + + it('should render code editor when default options provided', async () => { + const { + renderer: { getByTestId }, + } = setup({ defaultOptions: [{ text: 'test', value: 'test' }] }); + const codeEditor = getByTestId(selectors.components.CodeEditor.container); + + await byTestId(selectors.components.CodeEditor.container).find(); + + expect(codeEditor).toBeInTheDocument(); + }); + + it('should call onDefaultOptionsChange when providing static options', async () => { + const { + renderer: { getByTestId }, + } = setup(); + + const toggle = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.GroupByVariable.modeToggle); + + await userEvent.click(toggle); + expect(onDefaultOptionsChangeMock).toHaveBeenCalledTimes(1); + expect(onDefaultOptionsChangeMock).toHaveBeenCalledWith([]); + }); + + it('should call onDefaultOptionsChange when toggling off static options', async () => { + const { + renderer: { getByTestId }, + } = setup({ defaultOptions: [{ text: 'test', value: 'test' }] }); + + const toggle = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.GroupByVariable.modeToggle); + + await userEvent.click(toggle); + expect(onDefaultOptionsChangeMock).toHaveBeenCalledTimes(1); + expect(onDefaultOptionsChangeMock).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/variables/components/GroupByVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/GroupByVariableForm.tsx new file mode 100644 index 0000000000000..f982ab9236105 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/GroupByVariableForm.tsx @@ -0,0 +1,81 @@ +import React, { useCallback } from 'react'; + +import { DataSourceInstanceSettings, MetricFindValue, readCSV } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { DataSourceRef } from '@grafana/schema'; +import { Alert, CodeEditor, Field, Switch } from '@grafana/ui'; +import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; + +import { VariableLegend } from './VariableLegend'; + +export interface GroupByVariableFormProps { + datasource?: DataSourceRef; + onDataSourceChange: (dsSettings: DataSourceInstanceSettings) => void; + onDefaultOptionsChange: (options?: MetricFindValue[]) => void; + infoText?: string; + defaultOptions?: MetricFindValue[]; +} + +export function GroupByVariableForm({ + datasource, + defaultOptions, + infoText, + onDataSourceChange, + onDefaultOptionsChange, +}: GroupByVariableFormProps) { + const updateDefaultOptions = useCallback( + (csvContent: string) => { + const df = readCSV('key,value\n' + csvContent)[0]; + const options = []; + for (let i = 0; i < df.length; i++) { + options.push({ text: df.fields[0].values[i], value: df.fields[1].values[i] }); + } + + onDefaultOptionsChange(options); + }, + [onDefaultOptionsChange] + ); + + return ( + <> + <VariableLegend>Group by options</VariableLegend> + <Field label="Data source" htmlFor="data-source-picker"> + <DataSourcePicker current={datasource} onChange={onDataSourceChange} width={30} variables={true} noDefault /> + </Field> + + {infoText ? ( + <Alert + title={infoText} + severity="info" + data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.GroupByVariable.infoText} + /> + ) : null} + + <Field label="Use static Group By dimensions" description="Provide dimensions as CSV: dimensionName, dimensionId"> + <Switch + data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.GroupByVariable.modeToggle} + value={defaultOptions !== undefined} + onChange={(e) => { + if (defaultOptions === undefined) { + onDefaultOptionsChange([]); + } else { + onDefaultOptionsChange(undefined); + } + }} + /> + </Field> + + {defaultOptions !== undefined && ( + <CodeEditor + height={300} + language="csv" + value={defaultOptions.map((o) => `${o.text},${o.value}`).join('\n')} + onBlur={updateDefaultOptions} + onSave={updateDefaultOptions} + showMiniMap={false} + showLineNumbers={true} + /> + )} + </> + ); +} diff --git a/public/app/features/dashboard-scene/settings/variables/components/IntervalVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/IntervalVariableForm.tsx new file mode 100644 index 0000000000000..3f525e2d8fe41 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/IntervalVariableForm.tsx @@ -0,0 +1,96 @@ +import { css } from '@emotion/css'; +import React, { ChangeEvent, FormEvent } from 'react'; + +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { useStyles2 } from '@grafana/ui'; + +import { VariableCheckboxField } from './VariableCheckboxField'; +import { VariableLegend } from './VariableLegend'; +import { VariableSelectField } from './VariableSelectField'; +import { VariableTextField } from './VariableTextField'; + +interface IntervalVariableFormProps { + intervals: string; + onIntervalsChange: (event: FormEvent<HTMLInputElement>) => void; + onAutoEnabledChange: (event: ChangeEvent<HTMLInputElement>) => void; + onAutoMinIntervalChanged: (event: FormEvent<HTMLInputElement>) => void; + onAutoCountChanged: (option: SelectableValue) => void; + autoEnabled: boolean; + autoMinInterval: string; + autoStepCount: number; +} + +export function IntervalVariableForm({ + intervals, + onIntervalsChange, + onAutoEnabledChange, + onAutoMinIntervalChanged, + onAutoCountChanged, + autoEnabled, + autoMinInterval, + autoStepCount, +}: IntervalVariableFormProps) { + const STEP_OPTIONS = [1, 2, 3, 4, 5, 10, 20, 30, 40, 50, 100, 200, 300, 400, 500].map((count) => ({ + label: `${count}`, + value: count, + })); + const styles = useStyles2(getStyles); + + const stepCount = STEP_OPTIONS.find((option) => option.value === autoStepCount) ?? STEP_OPTIONS[0]; + + return ( + <> + <VariableLegend>Interval options</VariableLegend> + <VariableTextField + defaultValue={intervals} + name="Values" + placeholder="1m,10m,1h,6h,1d,7d" + onBlur={onIntervalsChange} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.IntervalVariable.intervalsValueInput} + width={32} + required + /> + + <VariableCheckboxField + value={autoEnabled} + name="Auto option" + description="Dynamically calculates interval by dividing time range by the count specified" + onChange={onAutoEnabledChange} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.IntervalVariable.autoEnabledCheckbox} + /> + {autoEnabled && ( + <div className={styles.autoFields}> + <VariableSelectField + name="Step count" + description="How many times the current time range should be divided to calculate the value" + value={stepCount} + options={STEP_OPTIONS} + onChange={onAutoCountChanged} + width={9} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.IntervalVariable.stepCountIntervalSelect} + /> + <VariableTextField + value={autoMinInterval} + name="Min interval" + description="The calculated value will not go below this threshold" + placeholder="10s" + onChange={onAutoMinIntervalChanged} + width={11} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.IntervalVariable.minIntervalInput} + /> + </div> + )} + </> + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + autoFields: css({ + marginTop: theme.spacing(2), + display: 'flex', + flexDirection: 'column', + }), + }; +}; diff --git a/public/app/features/dashboard-scene/settings/variables/components/QueryEditor.tsx b/public/app/features/dashboard-scene/settings/variables/components/QueryEditor.tsx new file mode 100644 index 0000000000000..18436c85c2b6f --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/QueryEditor.tsx @@ -0,0 +1,78 @@ +import React from 'react'; + +import { DataSourceApi, LoadingState, TimeRange } from '@grafana/data'; +import { getTemplateSrv } from '@grafana/runtime'; +import { QueryVariable } from '@grafana/scenes'; +import { Text, Box } from '@grafana/ui'; +import { isLegacyQueryEditor, isQueryEditor } from 'app/features/variables/guard'; +import { VariableQueryEditorType } from 'app/features/variables/types'; + +type VariableQueryType = QueryVariable['state']['query']; + +interface QueryEditorProps { + query: VariableQueryType; + datasource: DataSourceApi; + VariableQueryEditor: VariableQueryEditorType; + timeRange: TimeRange; + onLegacyQueryChange: (query: VariableQueryType, definition: string) => void; + onQueryChange: (query: VariableQueryType) => void; +} + +export function QueryEditor({ + query, + datasource, + VariableQueryEditor, + onLegacyQueryChange, + onQueryChange, + timeRange, +}: QueryEditorProps) { + let queryWithDefaults; + if (typeof query === 'string') { + queryWithDefaults = query || (datasource.variables?.getDefaultQuery?.() ?? ''); + } else { + queryWithDefaults = { + ...datasource.variables?.getDefaultQuery?.(), + ...query, + }; + } + + if (VariableQueryEditor && isLegacyQueryEditor(VariableQueryEditor, datasource)) { + return ( + <Box marginBottom={2}> + <Text element={'h4'}>Query</Text> + <Box marginTop={1}> + <VariableQueryEditor + key={datasource.uid} + datasource={datasource} + query={queryWithDefaults} + templateSrv={getTemplateSrv()} + onChange={onLegacyQueryChange} + /> + </Box> + </Box> + ); + } + + if (VariableQueryEditor && isQueryEditor(VariableQueryEditor, datasource)) { + return ( + <Box marginBottom={2}> + <Text element={'h4'}>Query</Text> + <Box marginTop={1}> + <VariableQueryEditor + key={datasource.uid} + datasource={datasource} + query={queryWithDefaults} + onChange={onQueryChange} + onRunQuery={() => {}} + data={{ series: [], state: LoadingState.Done, timeRange }} + range={timeRange} + onBlur={() => {}} + history={[]} + /> + </Box> + </Box> + ); + } + + return null; +} diff --git a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx new file mode 100644 index 0000000000000..0e154ed447d9b --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx @@ -0,0 +1,273 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { FormEvent } from 'react'; +import { of } from 'rxjs'; + +import { + LoadingState, + PanelData, + getDefaultTimeRange, + toDataFrame, + FieldType, + VariableSupportType, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { setRunRequest } from '@grafana/runtime'; +import { VariableRefresh, VariableSort } from '@grafana/schema'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; +import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor'; +import { getVariableQueryEditor } from 'app/features/variables/editor/getVariableQueryEditor'; + +import { QueryVariableEditorForm } from './QueryVariableForm'; + +const defaultDatasource = mockDataSource({ + name: 'Default Test Data Source', + type: 'test', +}); + +const promDatasource = mockDataSource({ + name: 'Prometheus', + type: 'prometheus', +}); + +jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({ + ...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'), + getDataSourceSrv: () => ({ + get: async () => ({ + ...defaultDatasource, + variables: { + getType: () => VariableSupportType.Custom, + query: jest.fn(), + editor: jest.fn().mockImplementation(LegacyVariableQueryEditor), + }, + }), + getList: () => [defaultDatasource, promDatasource], + getInstanceSettings: (uid: string) => (uid === promDatasource.uid ? promDatasource : defaultDatasource), + }), +})); + +const runRequestMock = jest.fn().mockReturnValue( + of<PanelData>({ + state: LoadingState.Done, + series: [ + toDataFrame({ + fields: [{ name: 'text', type: FieldType.string, values: ['val1', 'val2', 'val11'] }], + }), + ], + timeRange: getDefaultTimeRange(), + }) +); + +setRunRequest(runRequestMock); + +jest.mock('app/features/variables/editor/getVariableQueryEditor', () => ({ + ...jest.requireActual('app/features/variables/editor/getVariableQueryEditor'), + getVariableQueryEditor: jest.fn(), +})); + +describe('QueryVariableEditorForm', () => { + const mockOnDataSourceChange = jest.fn(); + const mockOnQueryChange = jest.fn(); + const mockOnLegacyQueryChange = jest.fn(); + const mockOnRegExChange = jest.fn(); + const mockOnSortChange = jest.fn(); + const mockOnRefreshChange = jest.fn(); + const mockOnMultiChange = jest.fn(); + const mockOnIncludeAllChange = jest.fn(); + const mockOnAllValueChange = jest.fn(); + + const defaultProps: React.ComponentProps<typeof QueryVariableEditorForm> = { + datasource: { uid: defaultDatasource.uid, type: defaultDatasource.type }, + onDataSourceChange: mockOnDataSourceChange, + query: 'my-query', + onQueryChange: mockOnQueryChange, + onLegacyQueryChange: mockOnLegacyQueryChange, + timeRange: getDefaultTimeRange(), + regex: '.*', + onRegExChange: mockOnRegExChange, + sort: VariableSort.alphabeticalAsc, + onSortChange: mockOnSortChange, + refresh: VariableRefresh.onDashboardLoad, + onRefreshChange: mockOnRefreshChange, + isMulti: true, + onMultiChange: mockOnMultiChange, + includeAll: true, + onIncludeAllChange: mockOnIncludeAllChange, + allValue: 'custom all value', + onAllValueChange: mockOnAllValueChange, + }; + + async function setup(props?: React.ComponentProps<typeof QueryVariableEditorForm>) { + jest.mocked(getVariableQueryEditor).mockResolvedValue(LegacyVariableQueryEditor); + return { + renderer: await act(() => render(<QueryVariableEditorForm {...defaultProps} {...props} />)), + user: userEvent.setup(), + }; + } + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the component with initializing the components correctly', async () => { + const { + renderer: { getByTestId, getByRole }, + } = await setup(); + const dataSourcePicker = getByTestId(selectors.components.DataSourcePicker.inputV2); + //const queryEditor = getByTestId('query-editor'); + const regexInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2 + ); + const sortSelect = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2 + ); + const refreshSelect = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelectV2 + ); + + const multiSwitch = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + const includeAllSwitch = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + const allValueInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ); + + expect(dataSourcePicker).toBeInTheDocument(); + expect(dataSourcePicker.getAttribute('placeholder')).toBe('Default Test Data Source'); + expect(regexInput).toBeInTheDocument(); + expect(regexInput).toHaveValue('.*'); + expect(sortSelect).toBeInTheDocument(); + expect(sortSelect).toHaveTextContent('Alphabetical (asc)'); + expect(refreshSelect).toBeInTheDocument(); + expect(getByRole('radio', { name: 'On dashboard load' })).toBeChecked(); + expect(multiSwitch).toBeInTheDocument(); + expect(multiSwitch).toBeChecked(); + expect(includeAllSwitch).toBeInTheDocument(); + expect(includeAllSwitch).toBeChecked(); + expect(allValueInput).toBeInTheDocument(); + expect(allValueInput).toHaveValue('custom all value'); + }); + + it('should call onDataSourceChange when changing the datasource', async () => { + const { + renderer: { getByTestId }, + } = await setup(); + const dataSourcePicker = getByTestId(selectors.components.DataSourcePicker.inputV2); + await userEvent.click(dataSourcePicker); + await userEvent.click(screen.getByText(/prometheus/i)); + + expect(mockOnDataSourceChange).toHaveBeenCalledTimes(1); + expect(mockOnDataSourceChange).toHaveBeenCalledWith(promDatasource, undefined); + }); + + it('should call onQueryChange when changing the query', async () => { + const { + renderer: { getByTestId }, + } = await setup(); + const queryEditor = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput + ); + + await waitFor(async () => { + await userEvent.type(queryEditor, '-new'); + await userEvent.tab(); + }); + + expect(mockOnLegacyQueryChange).toHaveBeenCalledTimes(1); + expect(mockOnLegacyQueryChange).toHaveBeenCalledWith('my-query-new', expect.anything()); + }); + + it('should call onRegExChange when changing the regex', async () => { + const { + renderer: { getByTestId }, + } = await setup(); + const regexInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2 + ); + await userEvent.type(regexInput, '{backspace}?'); + await userEvent.tab(); + expect(mockOnRegExChange).toHaveBeenCalledTimes(1); + expect( + ((mockOnRegExChange.mock.calls[0][0] as FormEvent<HTMLTextAreaElement>).target as HTMLTextAreaElement).value + ).toBe('.?'); + }); + + it('should call onSortChange when changing the sort', async () => { + const { + renderer: { getByTestId }, + } = await setup(); + const sortSelect = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2 + ); + await userEvent.click(sortSelect); // open the select + const anotherOption = await screen.getByText('Alphabetical (desc)'); + await userEvent.click(anotherOption); + + expect(mockOnSortChange).toHaveBeenCalledTimes(1); + expect(mockOnSortChange).toHaveBeenCalledWith( + expect.objectContaining({ value: VariableSort.alphabeticalDesc }), + expect.anything() + ); + }); + + it('should call onRefreshChange when changing the refresh', async () => { + const { + renderer: { getByTestId }, + } = await setup(); + const refreshSelect = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelectV2 + ); + await userEvent.click(refreshSelect); // open the select + const anotherOption = await screen.getByText('On time range change'); + await userEvent.click(anotherOption); + + expect(mockOnRefreshChange).toHaveBeenCalledTimes(1); + expect(mockOnRefreshChange).toHaveBeenCalledWith(VariableRefresh.onTimeRangeChanged); + }); + + it('should call onMultiChange when changing the multi switch', async () => { + const { + renderer: { getByTestId }, + } = await setup(); + const multiSwitch = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + await userEvent.click(multiSwitch); + expect(mockOnMultiChange).toHaveBeenCalledTimes(1); + expect( + (mockOnMultiChange.mock.calls[0][0] as FormEvent<HTMLInputElement>).target as HTMLInputElement + ).toBeChecked(); + }); + + it('should call onIncludeAllChange when changing the include all switch', async () => { + const { + renderer: { getByTestId }, + } = await setup(); + const includeAllSwitch = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + await userEvent.click(includeAllSwitch); + expect(mockOnIncludeAllChange).toHaveBeenCalledTimes(1); + expect( + (mockOnIncludeAllChange.mock.calls[0][0] as FormEvent<HTMLInputElement>).target as HTMLInputElement + ).toBeChecked(); + }); + + it('should call onAllValueChange when changing the all value', async () => { + const { + renderer: { getByTestId }, + } = await setup(); + const allValueInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ); + await userEvent.type(allValueInput, ' and another value'); + await userEvent.tab(); + expect(mockOnAllValueChange).toHaveBeenCalledTimes(1); + expect( + ((mockOnAllValueChange.mock.calls[0][0] as FormEvent<HTMLInputElement>).target as HTMLInputElement).value + ).toBe('custom all value and another value'); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx new file mode 100644 index 0000000000000..a1a9cfbc3f485 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx @@ -0,0 +1,136 @@ +import React, { FormEvent } from 'react'; +import { useAsync } from 'react-use'; + +import { DataSourceInstanceSettings, SelectableValue, TimeRange } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { QueryVariable } from '@grafana/scenes'; +import { DataSourceRef, VariableRefresh, VariableSort } from '@grafana/schema'; +import { Field } from '@grafana/ui'; +import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor'; +import { SelectionOptionsForm } from 'app/features/dashboard-scene/settings/variables/components/SelectionOptionsForm'; +import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; +import { getVariableQueryEditor } from 'app/features/variables/editor/getVariableQueryEditor'; +import { QueryVariableRefreshSelect } from 'app/features/variables/query/QueryVariableRefreshSelect'; +import { QueryVariableSortSelect } from 'app/features/variables/query/QueryVariableSortSelect'; + +import { VariableLegend } from './VariableLegend'; +import { VariableTextAreaField } from './VariableTextAreaField'; + +type VariableQueryType = QueryVariable['state']['query']; + +interface QueryVariableEditorFormProps { + datasource?: DataSourceRef; + onDataSourceChange: (dsSettings: DataSourceInstanceSettings) => void; + query: VariableQueryType; + onQueryChange: (query: VariableQueryType) => void; + onLegacyQueryChange: (query: VariableQueryType, definition: string) => void; + timeRange: TimeRange; + regex: string | null; + onRegExChange: (event: FormEvent<HTMLTextAreaElement>) => void; + sort: VariableSort; + onSortChange: (option: SelectableValue<VariableSort>) => void; + refresh: VariableRefresh; + onRefreshChange: (option: VariableRefresh) => void; + isMulti: boolean; + onMultiChange: (event: FormEvent<HTMLInputElement>) => void; + includeAll: boolean; + onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void; + allValue: string; + onAllValueChange: (event: FormEvent<HTMLInputElement>) => void; +} + +export function QueryVariableEditorForm({ + datasource: datasourceRef, + onDataSourceChange, + query, + onQueryChange, + onLegacyQueryChange, + timeRange, + regex, + onRegExChange, + sort, + onSortChange, + refresh, + onRefreshChange, + isMulti, + onMultiChange, + includeAll, + onIncludeAllChange, + allValue, + onAllValueChange, +}: QueryVariableEditorFormProps) { + const { value: dsConfig } = useAsync(async () => { + const datasource = await getDataSourceSrv().get(datasourceRef ?? ''); + const VariableQueryEditor = await getVariableQueryEditor(datasource); + + return { datasource, VariableQueryEditor }; + }, [datasourceRef]); + const { datasource, VariableQueryEditor } = dsConfig ?? {}; + + return ( + <> + <VariableLegend>Query options</VariableLegend> + <Field label="Data source" htmlFor="data-source-picker"> + <DataSourcePicker current={datasourceRef} onChange={onDataSourceChange} variables={true} width={30} /> + </Field> + + {datasource && VariableQueryEditor && ( + <QueryEditor + onQueryChange={onQueryChange} + onLegacyQueryChange={onLegacyQueryChange} + datasource={datasource} + query={query} + VariableQueryEditor={VariableQueryEditor} + timeRange={timeRange} + /> + )} + + <VariableTextAreaField + defaultValue={regex ?? ''} + name="Regex" + description={ + <div> + Optional, if you want to extract part of a series name or metric node segment. + <br /> + Named capture groups can be used to separate the display text and value ( + <a + className="external-link" + href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups" + target="__blank" + > + see examples + </a> + ). + </div> + } + placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/" + onBlur={onRegExChange} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2} + width={52} + /> + + <QueryVariableSortSelect + testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2} + onChange={onSortChange} + sort={sort} + /> + + <QueryVariableRefreshSelect + testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelectV2} + onChange={onRefreshChange} + refresh={refresh} + /> + + <VariableLegend>Selection options</VariableLegend> + <SelectionOptionsForm + multi={!!isMulti} + includeAll={!!includeAll} + allValue={allValue} + onMultiChange={onMultiChange} + onIncludeAllChange={onIncludeAllChange} + onAllValueChange={onAllValueChange} + /> + </> + ); +} diff --git a/public/app/features/dashboard-scene/settings/variables/components/SelectionOptionsForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/SelectionOptionsForm.tsx new file mode 100644 index 0000000000000..10ed2a83c277f --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/SelectionOptionsForm.tsx @@ -0,0 +1,52 @@ +import React, { ChangeEvent, FormEvent } from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { VerticalGroup } from '@grafana/ui'; +import { VariableCheckboxField } from 'app/features/dashboard-scene/settings/variables/components/VariableCheckboxField'; +import { VariableTextField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextField'; + +interface SelectionOptionsFormProps { + multi: boolean; + includeAll: boolean; + allValue?: string | null; + onMultiChange: (event: ChangeEvent<HTMLInputElement>) => void; + onIncludeAllChange: (event: ChangeEvent<HTMLInputElement>) => void; + onAllValueChange: (event: FormEvent<HTMLInputElement>) => void; +} + +export function SelectionOptionsForm({ + multi, + includeAll, + allValue, + onMultiChange, + onIncludeAllChange, + onAllValueChange, +}: SelectionOptionsFormProps) { + return ( + <VerticalGroup spacing="md" height="inherit"> + <VariableCheckboxField + value={multi} + name="Multi-value" + description="Enables multiple values to be selected at the same time" + onChange={onMultiChange} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch} + /> + <VariableCheckboxField + value={includeAll} + name="Include All option" + description="Enables an option to include all variables" + onChange={onIncludeAllChange} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch} + /> + {includeAll && ( + <VariableTextField + defaultValue={allValue ?? ''} + onBlur={onAllValueChange} + name="Custom all value" + placeholder="blank = auto" + testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput} + /> + )} + </VerticalGroup> + ); +} diff --git a/public/app/features/dashboard-scene/settings/variables/components/TextBoxVariableForm.test.tsx b/public/app/features/dashboard-scene/settings/variables/components/TextBoxVariableForm.test.tsx new file mode 100644 index 0000000000000..a1551b0028fae --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/TextBoxVariableForm.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { TextBoxVariableForm } from './TextBoxVariableForm'; + +describe('TextBoxVariableForm', () => { + it('renders correctly', () => { + const onChange = jest.fn(); + const onBlur = jest.fn(); + const value = 'test value'; + + render(<TextBoxVariableForm value={value} onChange={onChange} onBlur={onBlur} />); + + expect(screen.getByText('Text options')).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Default value' })).toBeInTheDocument(); + }); + + it('calls onChange when input value changes', async () => { + const onChange = jest.fn(); + const onBlur = jest.fn(); + const value = 'test value'; + + render(<TextBoxVariableForm value={value} onChange={onChange} onBlur={onBlur} />); + + const input = screen.getByRole('textbox', { name: 'Default value' }); + expect(input).toHaveValue(value); + + // change input value + const newValue = 'new value'; + await userEvent.type(input, newValue); + expect(onChange).toHaveBeenCalledTimes(newValue.length); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/variables/components/TextBoxVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/TextBoxVariableForm.tsx new file mode 100644 index 0000000000000..9715fac4a174b --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/TextBoxVariableForm.tsx @@ -0,0 +1,30 @@ +import React, { FormEvent } from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend'; +import { VariableTextField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextField'; + +interface TextBoxVariableFormProps { + value?: string; + defaultValue?: string; + onChange?: (event: FormEvent<HTMLInputElement>) => void; + onBlur?: (event: FormEvent<HTMLInputElement>) => void; +} + +export function TextBoxVariableForm({ defaultValue, value, onChange, onBlur }: TextBoxVariableFormProps) { + return ( + <> + <VariableLegend>Text options</VariableLegend> + <VariableTextField + value={value} + defaultValue={defaultValue} + name="Default value" + placeholder="default value, if any" + onChange={onChange} + onBlur={onBlur} + width={30} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.TextBoxVariable.textBoxOptionsQueryInputV2} + /> + </> + ); +} diff --git a/public/app/features/variables/editor/VariableCheckboxField.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableCheckboxField.tsx similarity index 82% rename from public/app/features/variables/editor/VariableCheckboxField.tsx rename to public/app/features/dashboard-scene/settings/variables/components/VariableCheckboxField.tsx index 1fd6b78825230..730cee62336e6 100644 --- a/public/app/features/variables/editor/VariableCheckboxField.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/VariableCheckboxField.tsx @@ -3,12 +3,13 @@ import React, { ChangeEvent, PropsWithChildren, ReactElement } from 'react'; import { Checkbox } from '@grafana/ui'; -interface VariableCheckboxFieldProps { +interface VariableCheckboxFieldProps extends React.HTMLAttributes<HTMLInputElement> { value: boolean; name: string; onChange: (event: ChangeEvent<HTMLInputElement>) => void; description?: string; ariaLabel?: string; + testId?: string; } export function VariableCheckboxField({ @@ -17,6 +18,7 @@ export function VariableCheckboxField({ description, onChange, ariaLabel, + testId, }: PropsWithChildren<VariableCheckboxFieldProps>): ReactElement { const uniqueId = useId(); @@ -28,6 +30,7 @@ export function VariableCheckboxField({ value={value} onChange={onChange} aria-label={ariaLabel} + data-testid={testId} /> ); } diff --git a/public/app/features/variables/editor/VariableHideSelect.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableHideSelect.tsx similarity index 100% rename from public/app/features/variables/editor/VariableHideSelect.tsx rename to public/app/features/dashboard-scene/settings/variables/components/VariableHideSelect.tsx diff --git a/public/app/features/variables/editor/VariableLegend.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableLegend.tsx similarity index 100% rename from public/app/features/variables/editor/VariableLegend.tsx rename to public/app/features/dashboard-scene/settings/variables/components/VariableLegend.tsx diff --git a/public/app/features/variables/editor/VariableSelectField.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx similarity index 63% rename from public/app/features/variables/editor/VariableSelectField.tsx rename to public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx index d1cc5bf017bd9..9f0024a7ab118 100644 --- a/public/app/features/variables/editor/VariableSelectField.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import React, { PropsWithChildren, ReactElement, useId } from 'react'; +import React, { PropsWithChildren, useId } from 'react'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Field, Select, useStyles2 } from '@grafana/ui'; @@ -22,31 +22,30 @@ export function VariableSelectField({ onChange, testId, width, -}: PropsWithChildren<VariableSelectFieldProps<any>>): ReactElement { +}: PropsWithChildren<VariableSelectFieldProps<any>>) { const styles = useStyles2(getStyles); const uniqueId = useId(); const inputId = `variable-select-input-${name}-${uniqueId}`; return ( <Field label={name} description={description} htmlFor={inputId}> - <div data-testid={testId}> - <Select - inputId={inputId} - onChange={onChange} - value={value} - width={width ?? 30} - options={options} - className={styles.selectContainer} - /> - </div> + <Select + data-testid={testId} + inputId={inputId} + onChange={onChange} + value={value} + width={width ?? 30} + options={options} + className={styles.selectContainer} + /> </Field> ); } function getStyles(theme: GrafanaTheme2) { return { - selectContainer: css` - margin-right: ${theme.spacing(0.5)}; - `, + selectContainer: css({ + marginRight: theme.spacing(0.5), + }), }; } diff --git a/public/app/features/variables/editor/VariableTextAreaField.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableTextAreaField.tsx similarity index 73% rename from public/app/features/variables/editor/VariableTextAreaField.tsx rename to public/app/features/dashboard-scene/settings/variables/components/VariableTextAreaField.tsx index 73027027c555d..b7382c9239ada 100644 --- a/public/app/features/variables/editor/VariableTextAreaField.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/VariableTextAreaField.tsx @@ -7,9 +7,10 @@ import { Field, TextArea, useStyles2 } from '@grafana/ui'; interface VariableTextAreaFieldProps { name: string; - value: string; + value?: string; + defaultValue?: string; placeholder: string; - onChange: (event: FormEvent<HTMLTextAreaElement>) => void; + onChange?: (event: FormEvent<HTMLTextAreaElement>) => void; width: number; ariaLabel?: string; required?: boolean; @@ -20,6 +21,7 @@ interface VariableTextAreaFieldProps { export function VariableTextAreaField({ value, + defaultValue, name, description, placeholder, @@ -39,6 +41,7 @@ export function VariableTextAreaField({ id={id} rows={2} value={value} + defaultValue={defaultValue} onChange={onChange} onBlur={onBlur} placeholder={placeholder} @@ -54,17 +57,17 @@ export function VariableTextAreaField({ export function getStyles(theme: GrafanaTheme2) { return { - textarea: css` - white-space: pre-wrap; - min-height: ${theme.spacing(4)}; - height: auto; - overflow: auto; - padding: ${theme.spacing(0.75, 1)}; - width: inherit; + textarea: css({ + whiteSpace: 'pre-wrap', + minHeight: theme.spacing(4), + height: 'auto', + overflow: 'auto', + padding: `${theme.spacing(0.75)} ${theme.spacing(1)}`, + width: 'inherit', - ${theme.breakpoints.down('sm')} { - width: 100%; - } - `, + [theme.breakpoints.down('sm')]: { + width: '100%', + }, + }), }; } diff --git a/public/app/features/variables/editor/VariableTextField.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableTextField.tsx similarity index 88% rename from public/app/features/variables/editor/VariableTextField.tsx rename to public/app/features/dashboard-scene/settings/variables/components/VariableTextField.tsx index 86890acf7c9c7..8b6ccf05d4815 100644 --- a/public/app/features/variables/editor/VariableTextField.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/VariableTextField.tsx @@ -4,10 +4,11 @@ import React, { FormEvent, PropsWithChildren } from 'react'; import { Field, Input } from '@grafana/ui'; interface VariableTextFieldProps { - value: string; + value?: string; + defaultValue?: string; name: string; placeholder?: string; - onChange: (event: FormEvent<HTMLInputElement>) => void; + onChange?: (event: FormEvent<HTMLInputElement>) => void; testId?: string; required?: boolean; width?: number; @@ -21,6 +22,7 @@ interface VariableTextFieldProps { export function VariableTextField({ value, + defaultValue, name, placeholder = '', onChange, @@ -43,6 +45,7 @@ export function VariableTextField({ id={id} placeholder={placeholder} value={value} + defaultValue={defaultValue} onChange={onChange} onBlur={onBlur} width={grow ? undefined : width ?? 30} diff --git a/public/app/features/dashboard-scene/settings/variables/components/VariableTypeSelect.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableTypeSelect.tsx new file mode 100644 index 0000000000000..0623559738846 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/VariableTypeSelect.tsx @@ -0,0 +1,30 @@ +import React, { PropsWithChildren, useMemo } from 'react'; + +import { SelectableValue, VariableType } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { VariableSelectField } from 'app/features/dashboard-scene/settings/variables/components/VariableSelectField'; + +import { EditableVariableType, getVariableTypeSelectOptions } from '../utils'; + +interface Props { + onChange: (option: SelectableValue<EditableVariableType>) => void; + type: VariableType; +} + +export function VariableTypeSelect({ onChange, type }: PropsWithChildren<Props>) { + const options = useMemo(() => getVariableTypeSelectOptions(), []); + const value = useMemo( + () => options.find((o: SelectableValue<EditableVariableType>) => o.value === type) ?? options[0], + [options, type] + ); + + return ( + <VariableSelectField + name="Select variable type" + value={value} + options={options} + onChange={onChange} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelectV2} + /> + ); +} diff --git a/public/app/features/variables/editor/VariableValuesPreview.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableValuesPreview.tsx similarity index 81% rename from public/app/features/variables/editor/VariableValuesPreview.tsx rename to public/app/features/dashboard-scene/settings/variables/components/VariableValuesPreview.tsx index 2c04e68198d8f..0265144ba5346 100644 --- a/public/app/features/variables/editor/VariableValuesPreview.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/VariableValuesPreview.tsx @@ -3,17 +3,16 @@ import React, { MouseEvent, useCallback, useEffect, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { VariableValueOption } from '@grafana/scenes'; import { Button, InlineFieldRow, InlineLabel, useStyles2 } from '@grafana/ui'; -import { VariableOption, VariableWithOptions } from '../types'; - export interface VariableValuesPreviewProps { - variable: VariableWithOptions; + options: VariableValueOption[]; } -export const VariableValuesPreview = ({ variable: { options } }: VariableValuesPreviewProps) => { +export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) => { const [previewLimit, setPreviewLimit] = useState(20); - const [previewOptions, setPreviewOptions] = useState<VariableOption[]>([]); + const [previewOptions, setPreviewOptions] = useState<VariableValueOption[]>([]); const showMoreOptions = useCallback( (event: MouseEvent) => { event.preventDefault(); @@ -34,8 +33,8 @@ export const VariableValuesPreview = ({ variable: { options } }: VariableValuesP <InlineFieldRow> {previewOptions.map((o, index) => ( <InlineFieldRow key={`${o.value}-${index}`} className={styles.optionContainer}> - <InlineLabel aria-label={selectors.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption}> - <div className={styles.label}>{o.text}</div> + <InlineLabel data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption}> + <div className={styles.label}>{o.label}</div> </InlineLabel> </InlineFieldRow> ))} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/AdHocFiltersVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/AdHocFiltersVariableEditor.test.tsx new file mode 100644 index 0000000000000..b80caf607e97d --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/AdHocFiltersVariableEditor.test.tsx @@ -0,0 +1,145 @@ +import { render, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { of } from 'rxjs'; + +import { + FieldType, + LoadingState, + PanelData, + VariableSupportType, + getDefaultTimeRange, + toDataFrame, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { setRunRequest } from '@grafana/runtime/src'; +import { AdHocFiltersVariable } from '@grafana/scenes'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; +import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor'; + +import { AdHocFiltersVariableEditor } from './AdHocFiltersVariableEditor'; + +const defaultDatasource = mockDataSource({ + name: 'Default Test Data Source', + uid: 'test-ds', + type: 'test', +}); + +const promDatasource = mockDataSource({ + name: 'Prometheus', + uid: 'prometheus', + type: 'prometheus', +}); + +jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({ + ...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'), + getDataSourceSrv: () => ({ + get: async () => ({ + ...defaultDatasource, + variables: { + getType: () => VariableSupportType.Custom, + query: jest.fn(), + editor: jest.fn().mockImplementation(LegacyVariableQueryEditor), + }, + }), + getList: () => [defaultDatasource, promDatasource], + getInstanceSettings: () => ({ ...defaultDatasource }), + }), +})); + +const runRequestMock = jest.fn().mockReturnValue( + of<PanelData>({ + state: LoadingState.Done, + series: [ + toDataFrame({ + fields: [{ name: 'text', type: FieldType.string, values: ['val1', 'val2', 'val11'] }], + }), + ], + timeRange: getDefaultTimeRange(), + }) +); + +setRunRequest(runRequestMock); + +describe('AdHocFiltersVariableEditor', () => { + it('renders AdHocVariableForm with correct props', async () => { + const { renderer } = await setup(); + const dataSourcePicker = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.datasourceSelect + ); + const infoText = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.infoText + ); + + expect(dataSourcePicker).toBeInTheDocument(); + expect(dataSourcePicker.getAttribute('placeholder')).toBe('Default Test Data Source'); + expect(infoText).toBeInTheDocument(); + expect(infoText).toHaveTextContent('This data source does not support ad hoc filters yet.'); + }); + + it('should update the variable data source when data source picker is changed', async () => { + const { renderer, variable, user } = await setup(); + + // Simulate changing the data source + await user.click(renderer.getByTestId(selectors.components.DataSourcePicker.inputV2)); + await user.click(renderer.getByText(/prom/i)); + + expect(variable.state.datasource).toEqual({ uid: 'prometheus', type: 'prometheus' }); + }); + + it('should update the variable default keys when the default keys options is enabled', async () => { + const { renderer, variable, user } = await setup(); + + // Simulate toggling default options on + await user.click( + renderer.getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.modeToggle) + ); + + expect(variable.state.defaultKeys).toEqual([]); + }); + + it('should update the variable default keys when the default keys option is disabled', async () => { + const { renderer, variable, user } = await setup(undefined, true); + + // Simulate toggling default options off + await user.click( + renderer.getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.modeToggle) + ); + + expect(variable.state.defaultKeys).toEqual(undefined); + }); +}); + +async function setup(props?: React.ComponentProps<typeof AdHocFiltersVariableEditor>, withDefaultKeys = false) { + const onRunQuery = jest.fn(); + const variable = new AdHocFiltersVariable({ + name: 'adhocVariable', + type: 'adhoc', + label: 'Ad hoc filters', + description: 'Ad hoc filters are applied automatically to all queries that target this data source', + datasource: { uid: defaultDatasource.uid, type: defaultDatasource.type }, + filters: [ + { + key: 'test', + operator: '=', + value: 'testValue', + }, + ], + baseFilters: [ + { + key: 'baseTest', + operator: '=', + value: 'baseTestValue', + }, + ], + defaultKeys: withDefaultKeys ? [{ text: 'A', value: 'A' }] : undefined, + }); + return { + renderer: await act(() => + render(<AdHocFiltersVariableEditor variable={variable} onRunQuery={onRunQuery} {...props} />) + ), + variable, + user: userEvent.setup(), + mocks: { onRunQuery }, + }; +} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/AdHocFiltersVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/AdHocFiltersVariableEditor.tsx new file mode 100644 index 0000000000000..8aa2a2a6dafff --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/AdHocFiltersVariableEditor.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { useAsync } from 'react-use'; + +import { DataSourceInstanceSettings, MetricFindValue } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { AdHocFiltersVariable } from '@grafana/scenes'; +import { DataSourceRef } from '@grafana/schema'; + +import { AdHocVariableForm } from '../components/AdHocVariableForm'; + +interface AdHocFiltersVariableEditorProps { + variable: AdHocFiltersVariable; + onRunQuery: (variable: AdHocFiltersVariable) => void; +} + +export function AdHocFiltersVariableEditor(props: AdHocFiltersVariableEditorProps) { + const { variable } = props; + const { datasource: datasourceRef, defaultKeys } = variable.useState(); + + const { value: datasourceSettings } = useAsync(async () => { + return await getDataSourceSrv().get(datasourceRef); + }, [datasourceRef]); + + const message = datasourceSettings?.getTagKeys + ? 'Ad hoc filters are applied automatically to all queries that target this data source' + : 'This data source does not support ad hoc filters yet.'; + + const onDataSourceChange = (ds: DataSourceInstanceSettings) => { + const dsRef: DataSourceRef = { + uid: ds.uid, + type: ds.type, + }; + + variable.setState({ + datasource: dsRef, + }); + }; + + const onDefaultKeysChange = (defaultKeys?: MetricFindValue[]) => { + variable.setState({ + defaultKeys, + }); + }; + + return ( + <AdHocVariableForm + datasource={datasourceRef ?? undefined} + infoText={message} + onDataSourceChange={onDataSourceChange} + defaultKeys={defaultKeys} + onDefaultKeysChange={onDefaultKeysChange} + /> + ); +} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/ConstantVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/ConstantVariableEditor.test.tsx new file mode 100644 index 0000000000000..dbb1b8f7ab388 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/ConstantVariableEditor.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { ConstantVariable } from '@grafana/scenes'; + +import { ConstantVariableEditor } from './ConstantVariableEditor'; + +describe('ConstantVariableEditor', () => { + let constantVar: ConstantVariable; + beforeEach(async () => { + const result = await buildTestScene(); + constantVar = result.constantVar; + }); + + it('renders constant value', () => { + render(<ConstantVariableEditor variable={constantVar} />); + const input = screen.getByRole('textbox', { name: 'Value' }); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue('constant value'); + }); + + it('changes the value', async () => { + render(<ConstantVariableEditor variable={constantVar} />); + + const input = screen.getByRole('textbox', { name: 'Value' }); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue('constant value'); + + // change input value + const newValue = 'new constant value'; + await userEvent.clear(input); + await userEvent.type(input, newValue); + + expect(input).toHaveValue(newValue); + + await userEvent.tab(); + expect(constantVar.state.value).toBe(newValue); + }); +}); + +async function buildTestScene() { + const constantVar = new ConstantVariable({ + name: 'constantVar', + type: 'constant', + value: 'constant value', + }); + + return { constantVar }; +} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/ConstantVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/ConstantVariableEditor.tsx new file mode 100644 index 0000000000000..de4d3203dbdab --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/ConstantVariableEditor.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import { ConstantVariable } from '@grafana/scenes'; + +import { ConstantVariableForm } from '../components/ConstantVariableForm'; + +interface ConstantVariableEditorProps { + variable: ConstantVariable; +} + +export function ConstantVariableEditor({ variable }: ConstantVariableEditorProps) { + const { value } = variable.useState(); + + const onConstantValueChange = (event: React.FormEvent<HTMLInputElement>) => { + variable.setState({ value: event.currentTarget.value }); + }; + + return <ConstantVariableForm constantValue={String(value)} onChange={onConstantValueChange} />; +} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor.test.tsx new file mode 100644 index 0000000000000..3a63f900ceae6 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor.test.tsx @@ -0,0 +1,115 @@ +import { render, fireEvent } from '@testing-library/react'; +import React from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { CustomVariable } from '@grafana/scenes'; + +import { CustomVariableEditor } from './CustomVariableEditor'; + +describe('CustomVariableEditor', () => { + it('should render the CustomVariableForm with correct initial values', () => { + const variable = new CustomVariable({ + name: 'customVar', + query: 'test, test2', + value: 'test', + isMulti: true, + includeAll: true, + allValue: 'test', + }); + const onRunQuery = jest.fn(); + + const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />); + + const queryInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput + ) as HTMLInputElement; + const allValueInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ) as HTMLInputElement; + const multiCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ) as HTMLInputElement; + const includeAllCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ) as HTMLInputElement; + + expect(queryInput.value).toBe('test, test2'); + expect(allValueInput.value).toBe('test'); + expect(multiCheckbox.checked).toBe(true); + expect(includeAllCheckbox.checked).toBe(true); + }); + + it('should update the variable state when input values change', () => { + const variable = new CustomVariable({ + name: 'customVar', + query: 'test, test2', + value: 'test', + }); + const onRunQuery = jest.fn(); + + const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />); + + const multiCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + const includeAllCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + + // It include-all-custom input appears after include-all checkbox is checked only + expect(() => + getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput) + ).toThrow('Unable to find an element'); + + fireEvent.click(multiCheckbox); + + fireEvent.click(includeAllCheckbox); + const allValueInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ); + + expect(variable.state.isMulti).toBe(true); + expect(variable.state.includeAll).toBe(true); + expect(allValueInput).toBeInTheDocument(); + }); + + it('should call update query and re-run query when input loses focus', async () => { + const variable = new CustomVariable({ + name: 'customVar', + query: 'test, test2', + value: 'test', + }); + const onRunQuery = jest.fn(); + + const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />); + + const queryInput = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput); + fireEvent.change(queryInput, { target: { value: 'test3, test4' } }); + fireEvent.blur(queryInput); + + expect(onRunQuery).toHaveBeenCalled(); + expect(variable.state.query).toBe('test3, test4'); + }); + + it('should update the variable state when all-custom-value input loses focus', () => { + const variable = new CustomVariable({ + name: 'customVar', + query: 'test, test2', + value: 'test', + isMulti: true, + includeAll: true, + }); + const onRunQuery = jest.fn(); + + const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />); + + const allValueInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ) as HTMLInputElement; + + fireEvent.change(allValueInput, { target: { value: 'new custom all' } }); + fireEvent.blur(allValueInput); + + expect(variable.state.allValue).toBe('new custom all'); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor.tsx new file mode 100644 index 0000000000000..724218276b2d7 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor.tsx @@ -0,0 +1,41 @@ +import React, { FormEvent } from 'react'; + +import { CustomVariable } from '@grafana/scenes'; + +import { CustomVariableForm } from '../components/CustomVariableForm'; + +interface CustomVariableEditorProps { + variable: CustomVariable; + onRunQuery: () => void; +} + +export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEditorProps) { + const { query, isMulti, allValue, includeAll } = variable.useState(); + + const onMultiChange = (event: FormEvent<HTMLInputElement>) => { + variable.setState({ isMulti: event.currentTarget.checked }); + }; + const onIncludeAllChange = (event: FormEvent<HTMLInputElement>) => { + variable.setState({ includeAll: event.currentTarget.checked }); + }; + const onQueryChange = (event: FormEvent<HTMLTextAreaElement>) => { + variable.setState({ query: event.currentTarget.value }); + onRunQuery(); + }; + const onAllValueChange = (event: FormEvent<HTMLInputElement>) => { + variable.setState({ allValue: event.currentTarget.value }); + }; + + return ( + <CustomVariableForm + query={query ?? ''} + multi={!!isMulti} + allValue={allValue ?? ''} + includeAll={!!includeAll} + onMultiChange={onMultiChange} + onIncludeAllChange={onIncludeAllChange} + onQueryChange={onQueryChange} + onAllValueChange={onAllValueChange} + /> + ); +} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/DataSourceVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/DataSourceVariableEditor.test.tsx new file mode 100644 index 0000000000000..0b1e4c333a921 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/DataSourceVariableEditor.test.tsx @@ -0,0 +1,157 @@ +// add unit test for the DataSourceVariableEditor component + +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { DataSourceVariable } from '@grafana/scenes'; + +import { DataSourceVariableEditor } from './DataSourceVariableEditor'; + +//mock getDataSorceSrv.getList() to return a list of datasources +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + getList: () => { + return [ + { + name: 'DataSourceInstance1', + uid: 'ds1', + meta: { + name: 'ds1', + id: 'dsTestDataSource', + }, + }, + { + name: 'DataSourceInstance2', + uid: 'ds2', + meta: { + name: 'ds1', + id: 'dsTestDataSource', + }, + }, + { + name: 'ABCDataSourceInstance', + uid: 'ds3', + meta: { + name: 'abDS', + id: 'ABCDS', + }, + }, + ]; + }, + }), +})); + +describe('DataSourceVariableEditor', () => { + it('shoud render correctly with multi and all not checked', () => { + const variable = new DataSourceVariable({ + name: 'dsVariable', + type: 'datasource', + label: 'Datasource', + pluginId: 'dsTestDataSource', + }); + const onRunQuery = jest.fn(); + + const { getByTestId } = render(<DataSourceVariableEditor variable={variable} onRunQuery={onRunQuery} />); + + const multiCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + const includeAllCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + + const typeSelect = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.datasourceSelect + ); + expect(typeSelect).toBeInTheDocument(); + expect(typeSelect.textContent).toBe('ds1'); + expect(multiCheckbox).toBeInTheDocument(); + expect(multiCheckbox).not.toBeChecked(); + expect(includeAllCheckbox).toBeInTheDocument(); + expect(includeAllCheckbox).not.toBeChecked(); + }); + + it('shoud render correctly with multi and includeAll checked', () => { + const variable = new DataSourceVariable({ + name: 'dsVariable', + type: 'datasource', + label: 'Datasource', + pluginId: 'dsTestDataSource', + isMulti: true, + includeAll: true, + }); + const onRunQuery = jest.fn(); + + const { getByTestId } = render(<DataSourceVariableEditor variable={variable} onRunQuery={onRunQuery} />); + + const multiCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + const includeAllCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + + const typeSelect = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.datasourceSelect + ); + expect(typeSelect).toBeInTheDocument(); + expect(typeSelect.textContent).toBe('ds1'); + expect(multiCheckbox).toBeInTheDocument(); + expect(multiCheckbox).toBeChecked(); + expect(includeAllCheckbox).toBeInTheDocument(); + expect(includeAllCheckbox).toBeChecked(); + }); + + it('Should change type option when users select a different datasource type', async () => { + const variable = new DataSourceVariable({ + name: 'dsVariable', + type: 'datasource', + label: 'Datasource', + pluginId: 'dsTestDataSource', + isMulti: false, + includeAll: false, + }); + const onRunQuery = jest.fn(); + + const { getByTestId, user } = setup(<DataSourceVariableEditor variable={variable} onRunQuery={onRunQuery} />); + + const typeSelect = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.datasourceSelect + ); + // when user change type datasource + await user.click(typeSelect); + await user.type(typeSelect, 'abDS'); + await user.keyboard('{enter}'); + expect(typeSelect).toBeInTheDocument(); + expect(typeSelect.textContent).toBe('abDS'); + expect(onRunQuery).toHaveBeenCalledTimes(1); + + // when user change checkbox multi + + const multiCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + const includeAllCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + + await user.click(multiCheckbox); + expect(multiCheckbox).toBeChecked(); + + // when user include all there is a new call to onRunQuery + await user.click(includeAllCheckbox); + expect(includeAllCheckbox).toBeChecked(); + expect(onRunQuery).toHaveBeenCalledTimes(1); + }); +}); + +// based on styleguide recomendation +function setup(jsx: JSX.Element) { + return { + user: userEvent.setup(), + ...render(jsx), + }; +} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/DataSourceVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/DataSourceVariableEditor.tsx new file mode 100644 index 0000000000000..6c36f94e3ef71 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/DataSourceVariableEditor.tsx @@ -0,0 +1,62 @@ +import React, { FormEvent } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { DataSourceVariable } from '@grafana/scenes'; + +import { DataSourceVariableForm } from '../components/DataSourceVariableForm'; +import { getOptionDataSourceTypes } from '../utils'; + +interface DataSourceVariableEditorProps { + variable: DataSourceVariable; + onRunQuery: () => void; +} + +export function DataSourceVariableEditor({ variable, onRunQuery }: DataSourceVariableEditorProps) { + const { pluginId, regex, isMulti, allValue, includeAll } = variable.useState(); + + const optionTypes = getOptionDataSourceTypes(); + + const onChangeType = (option: SelectableValue) => { + variable.setState({ + pluginId: option.value, + }); + onRunQuery(); + }; + + const onRegExChange = (event: FormEvent<HTMLInputElement>) => { + variable.setState({ + regex: event.currentTarget.value, + }); + onRunQuery(); + }; + + const onMultiChange = (event: FormEvent<HTMLInputElement>) => { + variable.setState({ + isMulti: event.currentTarget.checked, + }); + }; + + const onIncludeAllChange = (event: FormEvent<HTMLInputElement>) => { + variable.setState({ includeAll: event.currentTarget.checked }); + }; + + const onAllValueChange = (event: FormEvent<HTMLInputElement>) => { + variable.setState({ allValue: event.currentTarget.value }); + }; + + return ( + <DataSourceVariableForm + query={pluginId} + regex={regex} + multi={isMulti || false} + allValue={allValue} + includeAll={includeAll || false} + optionTypes={optionTypes} + onChange={onChangeType} + onRegExBlur={onRegExChange} + onMultiChange={onMultiChange} + onIncludeAllChange={onIncludeAllChange} + onAllValueChange={onAllValueChange} + /> + ); +} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/GroupByVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/GroupByVariableEditor.test.tsx new file mode 100644 index 0000000000000..48b01d8f08f6e --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/GroupByVariableEditor.test.tsx @@ -0,0 +1,103 @@ +import { act, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { MetricFindValue, VariableSupportType } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { GroupByVariable } from '@grafana/scenes'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; +import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor'; + +import { GroupByVariableEditor } from './GroupByVariableEditor'; + +const defaultDatasource = mockDataSource({ + name: 'Default Test Data Source', + uid: 'test-ds', + type: 'test', +}); + +const promDatasource = mockDataSource({ + name: 'Prometheus', + uid: 'prometheus', + type: 'prometheus', +}); + +jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({ + ...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'), + getDataSourceSrv: () => ({ + get: async () => ({ + ...defaultDatasource, + variables: { + getType: () => VariableSupportType.Custom, + query: jest.fn(), + editor: jest.fn().mockImplementation(LegacyVariableQueryEditor), + }, + }), + getList: () => [defaultDatasource, promDatasource], + getInstanceSettings: () => ({ ...defaultDatasource }), + }), +})); + +describe('GroupByVariableEditor', () => { + it('renders AdHocVariableForm with correct props', async () => { + const { renderer } = await setup(); + const dataSourcePicker = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.GroupByVariable.dataSourceSelect + ); + const infoText = renderer.getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.GroupByVariable.infoText); + + expect(dataSourcePicker).toBeInTheDocument(); + expect(dataSourcePicker.getAttribute('placeholder')).toBe('Default Test Data Source'); + expect(infoText).toBeInTheDocument(); + expect(infoText).toHaveTextContent('This data source does not support group by variable yet.'); + }); + + it('should update the variable data source when data source picker is changed', async () => { + const { renderer, variable, user } = await setup(); + + // Simulate changing the data source + await user.click(renderer.getByTestId(selectors.components.DataSourcePicker.inputV2)); + await user.click(renderer.getByText(/prom/i)); + + expect(variable.state.datasource).toEqual({ uid: 'prometheus', type: 'prometheus' }); + }); + + it('should update the variable default options when static options are enabled', async () => { + const { renderer, variable, user } = await setup(); + + // Simulate toggling static options on + await user.click( + renderer.getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.GroupByVariable.modeToggle) + ); + + expect(variable.state.defaultOptions).toEqual([]); + }); + + it('should update the variable default options when static options are disabled', async () => { + const { renderer, variable, user } = await setup([{ text: 'A', value: 'A' }]); + + // Simulate toggling static options off + await user.click( + renderer.getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.GroupByVariable.modeToggle) + ); + + expect(variable.state.defaultOptions).toEqual(undefined); + }); +}); + +async function setup(defaultOptions?: MetricFindValue[]) { + const onRunQuery = jest.fn(); + const variable = new GroupByVariable({ + name: 'groupByVariable', + type: 'groupby', + label: 'Group By', + datasource: { uid: defaultDatasource.uid, type: defaultDatasource.type }, + defaultOptions, + }); + return { + renderer: await act(() => render(<GroupByVariableEditor variable={variable} onRunQuery={onRunQuery} />)), + variable, + user: userEvent.setup(), + mocks: { onRunQuery }, + }; +} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/GroupByVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/GroupByVariableEditor.tsx new file mode 100644 index 0000000000000..01cffb31c84e6 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/GroupByVariableEditor.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { useAsync } from 'react-use'; + +import { DataSourceInstanceSettings, DataSourceRef, MetricFindValue } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { GroupByVariable } from '@grafana/scenes'; + +import { GroupByVariableForm } from '../components/GroupByVariableForm'; + +interface GroupByVariableEditorProps { + variable: GroupByVariable; + onRunQuery: () => void; +} + +export function GroupByVariableEditor(props: GroupByVariableEditorProps) { + const { variable, onRunQuery } = props; + const { datasource: datasourceRef, defaultOptions } = variable.useState(); + + const { value: datasource } = useAsync(async () => { + return await getDataSourceSrv().get(datasourceRef); + }, [variable.state]); + + const message = datasource?.getTagKeys + ? 'Group by dimensions are applied automatically to all queries that target this data source' + : 'This data source does not support group by variable yet.'; + + const onDataSourceChange = async (ds: DataSourceInstanceSettings) => { + const dsRef: DataSourceRef = { + uid: ds.uid, + type: ds.type, + }; + + variable.setState({ datasource: dsRef }); + onRunQuery(); + }; + + const onDefaultOptionsChange = async (defaultOptions?: MetricFindValue[]) => { + variable.setState({ defaultOptions }); + onRunQuery(); + }; + + return ( + <GroupByVariableForm + defaultOptions={defaultOptions} + datasource={datasourceRef ?? undefined} + infoText={datasourceRef ? message : undefined} + onDataSourceChange={onDataSourceChange} + onDefaultOptionsChange={onDefaultOptionsChange} + /> + ); +} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/IntervalVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/IntervalVariableEditor.test.tsx new file mode 100644 index 0000000000000..9e2e16c44eca6 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/IntervalVariableEditor.test.tsx @@ -0,0 +1,115 @@ +// unit test for IntervalVariableEditor component + +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { IntervalVariable } from '@grafana/scenes'; + +import { IntervalVariableEditor } from './IntervalVariableEditor'; + +describe('IntervalVariableEditor', () => { + it('should render correctly', () => { + const variable = new IntervalVariable({ + name: 'test', + type: 'interval', + intervals: ['1m', '10m', '1h', '6h', '1d', '7d'], + }); + + const onRunQuery = jest.fn(); + + const { getByTestId, queryByTestId } = render( + <IntervalVariableEditor variable={variable} onRunQuery={onRunQuery} /> + ); + const intervalsInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.IntervalVariable.intervalsValueInput + ); + const autoEnabledCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.IntervalVariable.autoEnabledCheckbox + ); + + expect(intervalsInput).toBeInTheDocument(); + expect(intervalsInput).toHaveValue('1m,10m,1h,6h,1d,7d'); + expect(autoEnabledCheckbox).toBeInTheDocument(); + expect(autoEnabledCheckbox).not.toBeChecked(); + expect( + queryByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.IntervalVariable.minIntervalInput) + ).toBeNull(); + expect( + queryByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.IntervalVariable.stepCountIntervalSelect) + ).toBeNull(); + }); + + it('should update intervals correctly', async () => { + const variable = new IntervalVariable({ + name: 'test', + type: 'interval', + intervals: ['1m', '10m', '1h', '6h', '1d', '7d'], + }); + + const onRunQuery = jest.fn(); + + const { user, getByTestId } = setup(<IntervalVariableEditor variable={variable} onRunQuery={onRunQuery} />); + const intervalsInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.IntervalVariable.intervalsValueInput + ); + + await user.clear(intervalsInput); + await user.type(intervalsInput, '7d,30d, 1y, 5y, 10y'); + await user.tab(); + + expect(intervalsInput).toBeInTheDocument(); + expect(intervalsInput).toHaveValue('7d,30d, 1y, 5y, 10y'); + expect(onRunQuery).toHaveBeenCalledTimes(1); + }); + + it('should handle auto enabled option correctly', async () => { + const variable = new IntervalVariable({ + name: 'test', + type: 'interval', + intervals: ['1m', '10m', '1h', '6h', '1d', '7d'], + autoEnabled: false, + }); + + const onRunQuery = jest.fn(); + + const { user, getByTestId, queryByTestId } = setup( + <IntervalVariableEditor variable={variable} onRunQuery={onRunQuery} /> + ); + + const autoEnabledCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.IntervalVariable.autoEnabledCheckbox + ); + + await user.click(autoEnabledCheckbox); + + const minIntervalInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.IntervalVariable.minIntervalInput + ); + + const stepCountIntervalSelect = queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.IntervalVariable.stepCountIntervalSelect + ); + + await waitFor(() => { + expect(autoEnabledCheckbox).toBeInTheDocument(); + expect(autoEnabledCheckbox).toBeChecked(); + expect(minIntervalInput).toBeInTheDocument(); + expect(stepCountIntervalSelect).toBeInTheDocument(); + expect(minIntervalInput).toHaveValue('10s'); + }); + + await user.clear(minIntervalInput); + await user.type(minIntervalInput, '10m'); + await user.tab(); + expect(minIntervalInput).toHaveValue('10m'); + }); +}); + +function setup(jsx: JSX.Element) { + return { + user: userEvent.setup(), + ...render(jsx), + }; +} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/IntervalVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/IntervalVariableEditor.tsx new file mode 100644 index 0000000000000..6ff69c2b0c528 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/IntervalVariableEditor.tsx @@ -0,0 +1,53 @@ +import React, { ChangeEvent, FormEvent } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { IntervalVariable } from '@grafana/scenes'; +import { + getIntervalsFromQueryString, + getIntervalsQueryFromNewIntervalModel, +} from 'app/features/dashboard-scene/utils/utils'; + +import { IntervalVariableForm } from '../components/IntervalVariableForm'; + +interface IntervalVariableEditorProps { + variable: IntervalVariable; + onRunQuery: () => void; +} + +export function IntervalVariableEditor({ variable, onRunQuery }: IntervalVariableEditorProps) { + const { intervals, autoStepCount, autoEnabled, autoMinInterval } = variable.useState(); + + //transform intervals array into string + const intervalsCombined = getIntervalsQueryFromNewIntervalModel(intervals); + + const onIntervalsChange = (event: FormEvent<HTMLInputElement>) => { + const intervalsArray = getIntervalsFromQueryString(event.currentTarget.value); + variable.setState({ intervals: intervalsArray }); + onRunQuery(); + }; + + const onAutoCountChanged = (option: SelectableValue<number>) => { + variable.setState({ autoStepCount: option.value }); + }; + + const onAutoEnabledChange = (event: ChangeEvent<HTMLInputElement>) => { + variable.setState({ autoEnabled: event.target.checked }); + }; + + const onAutoMinIntervalChanged = (event: FormEvent<HTMLInputElement>) => { + variable.setState({ autoMinInterval: event.currentTarget.value }); + }; + + return ( + <IntervalVariableForm + intervals={intervalsCombined} + autoStepCount={autoStepCount} + autoEnabled={autoEnabled} + onAutoCountChanged={onAutoCountChanged} + onIntervalsChange={onIntervalsChange} + onAutoEnabledChange={onAutoEnabledChange} + onAutoMinIntervalChanged={onAutoMinIntervalChanged} + autoMinInterval={autoMinInterval} + /> + ); +} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx new file mode 100644 index 0000000000000..10b7832ff0571 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx @@ -0,0 +1,332 @@ +import { getByRole, render, screen, act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { lastValueFrom, of } from 'rxjs'; + +import { + VariableSupportType, + PanelData, + LoadingState, + toDataFrame, + getDefaultTimeRange, + FieldType, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { setRunRequest } from '@grafana/runtime'; +import { QueryVariable } from '@grafana/scenes'; +import { VariableRefresh, VariableSort } from '@grafana/schema'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; +import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor'; + +import { QueryVariableEditor } from './QueryVariableEditor'; + +const defaultDatasource = mockDataSource({ + name: 'Default Test Data Source', + type: 'test', +}); + +const promDatasource = mockDataSource({ + name: 'Prometheus', + type: 'prometheus', +}); + +jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({ + ...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'), + getDataSourceSrv: () => ({ + get: async () => ({ + ...defaultDatasource, + variables: { + getType: () => VariableSupportType.Custom, + query: jest.fn(), + editor: jest.fn().mockImplementation(LegacyVariableQueryEditor), + }, + }), + getList: () => [defaultDatasource, promDatasource], + getInstanceSettings: () => ({ ...defaultDatasource }), + }), +})); + +const runRequestMock = jest.fn().mockReturnValue( + of<PanelData>({ + state: LoadingState.Done, + series: [ + toDataFrame({ + fields: [{ name: 'text', type: FieldType.string, values: ['val1', 'val2', 'val11'] }], + }), + ], + timeRange: getDefaultTimeRange(), + }) +); + +setRunRequest(runRequestMock); + +describe('QueryVariableEditor', () => { + const onRunQueryMock = jest.fn(); + + async function setup(props?: React.ComponentProps<typeof QueryVariableEditor>) { + const variable = new QueryVariable({ + datasource: { + uid: defaultDatasource.uid, + type: defaultDatasource.type, + }, + query: 'my-query', + regex: '.*', + sort: VariableSort.alphabeticalAsc, + refresh: VariableRefresh.onDashboardLoad, + isMulti: true, + includeAll: true, + allValue: 'custom all value', + }); + + return { + renderer: await act(() => { + return render(<QueryVariableEditor variable={variable} onRunQuery={onRunQueryMock} />); + }), + variable, + user: userEvent.setup(), + }; + } + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the component with initializing the components correctly', async () => { + const { renderer } = await setup(); + const dataSourcePicker = renderer.getByTestId(selectors.components.DataSourcePicker.inputV2); + const queryEditor = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput + ); + const regexInput = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2 + ); + const sortSelect = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2 + ); + const refreshSelect = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelectV2 + ); + + const multiSwitch = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + const includeAllSwitch = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + const allValueInput = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ); + + expect(dataSourcePicker).toBeInTheDocument(); + expect(dataSourcePicker.getAttribute('placeholder')).toBe('Default Test Data Source'); + expect(queryEditor).toBeInTheDocument(); + expect(queryEditor).toHaveValue('my-query'); + expect(regexInput).toBeInTheDocument(); + expect(regexInput).toHaveValue('.*'); + expect(sortSelect).toBeInTheDocument(); + expect(sortSelect).toHaveTextContent('Alphabetical (asc)'); + expect(refreshSelect).toBeInTheDocument(); + expect(getByRole(refreshSelect, 'radio', { name: 'On dashboard load' })).toBeChecked(); + expect(multiSwitch).toBeInTheDocument(); + expect(multiSwitch).toBeChecked(); + expect(includeAllSwitch).toBeInTheDocument(); + expect(includeAllSwitch).toBeChecked(); + expect(allValueInput).toBeInTheDocument(); + expect(allValueInput).toHaveValue('custom all value'); + }); + + it('should update variable state when changing the datasource', async () => { + const { + variable, + renderer: { getByTestId, getByText }, + user, + } = await setup(); + + expect(variable.state.datasource).toEqual({ uid: 'mock-ds-2', type: 'test' }); + + await user.click(getByTestId(selectors.components.DataSourcePicker.inputV2)); + await user.click(getByText(/prom/i)); + + await waitFor(async () => { + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.datasource).toEqual({ uid: 'mock-ds-3', type: 'prometheus' }); + expect(variable.state.query).toBe(''); + expect(variable.state.definition).toBe(''); + }); + + it('should update the variable state when changing the query', async () => { + const { + variable, + renderer: { getByTestId }, + user, + } = await setup(); + const queryEditor = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput + ); + + await waitFor(async () => { + await user.type(queryEditor, '-new'); + await user.tab(); + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.query).toEqual('my-query-new'); + expect(onRunQueryMock).toHaveBeenCalledTimes(1); + }); + + it('should update the variable state when changing the regex', async () => { + const { + variable, + renderer: { getByTestId }, + user, + } = await setup(); + const regexInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2 + ); + + await waitFor(async () => { + await user.type(regexInput, '{backspace}?'); + await user.tab(); + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.regex).toBe('.?'); + }); + + it('should update the variable state when changing the sort', async () => { + const { + variable, + renderer: { getByTestId }, + user, + } = await setup(); + const sortSelect = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2 + ); + + await waitFor(async () => { + await user.click(sortSelect); + const anotherOption = await screen.getByText('Alphabetical (desc)'); + await user.click(anotherOption); + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.sort).toBe(VariableSort.alphabeticalDesc); + }); + + it('should update the variable query definition when changing the query', async () => { + const { + variable, + renderer: { getByTestId }, + user, + } = await setup(); + const queryEditor = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput + ); + + await user.type(queryEditor, '-new'); + await user.tab(); + + await waitFor(async () => { + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.definition).toEqual('my-query-new'); + + await user.clear(queryEditor); + + await user.type(queryEditor, 'new definition'); + await user.tab(); + + await waitFor(async () => { + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.definition).toEqual('new definition'); + + await user.clear(queryEditor); + await user.tab(); + + await waitFor(async () => { + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.definition).toEqual(''); + }); + + it('should update the variable state when changing the refresh', async () => { + const { + variable, + renderer: { getByTestId }, + user, + } = await setup(); + const refreshSelect = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelectV2 + ); + + await waitFor(async () => { + await user.click(refreshSelect); + const anotherOption = await screen.getByText('On time range change'); + await user.click(anotherOption); + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.refresh).toBe(VariableRefresh.onTimeRangeChanged); + }); + + it('should update the variable state when changing the multi switch', async () => { + const { + variable, + renderer: { getByTestId }, + user, + } = await setup(); + const multiSwitch = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + + await waitFor(async () => { + await user.click(multiSwitch); + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.isMulti).toBe(false); + }); + + it('should update the variable state when changing the include all switch', async () => { + const { + variable, + renderer: { getByTestId }, + user, + } = await setup(); + const includeAllSwitch = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + + await waitFor(async () => { + await user.click(includeAllSwitch); + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.includeAll).toBe(false); + }); + + it('should update the variable state when changing the all value', async () => { + const { + variable, + renderer: { getByTestId }, + user, + } = await setup(); + const allValueInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ); + + await waitFor(async () => { + await user.type(allValueInput, ' and another value'); + await user.tab(); + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.allValue).toBe('custom all value and another value'); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx new file mode 100644 index 0000000000000..c0f64a0cb523f --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx @@ -0,0 +1,82 @@ +import React, { FormEvent } from 'react'; + +import { SelectableValue, DataSourceInstanceSettings } from '@grafana/data'; +import { QueryVariable, sceneGraph } from '@grafana/scenes'; +import { DataSourceRef, VariableRefresh, VariableSort } from '@grafana/schema'; + +import { QueryVariableEditorForm } from '../components/QueryVariableForm'; + +interface QueryVariableEditorProps { + variable: QueryVariable; + onRunQuery: () => void; +} +type VariableQueryType = QueryVariable['state']['query']; + +export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEditorProps) { + const { datasource, regex, sort, refresh, isMulti, includeAll, allValue, query } = variable.useState(); + const { value: timeRange } = sceneGraph.getTimeRange(variable).useState(); + + const onRegExChange = (event: React.FormEvent<HTMLTextAreaElement>) => { + variable.setState({ regex: event.currentTarget.value }); + }; + const onSortChange = (sort: SelectableValue<VariableSort>) => { + variable.setState({ sort: sort.value }); + }; + const onRefreshChange = (refresh: VariableRefresh) => { + variable.setState({ refresh: refresh }); + }; + const onMultiChange = (event: FormEvent<HTMLInputElement>) => { + variable.setState({ isMulti: event.currentTarget.checked }); + }; + const onIncludeAllChange = (event: FormEvent<HTMLInputElement>) => { + variable.setState({ includeAll: event.currentTarget.checked }); + }; + const onAllValueChange = (event: FormEvent<HTMLInputElement>) => { + variable.setState({ allValue: event.currentTarget.value }); + }; + const onDataSourceChange = (dsInstanceSettings: DataSourceInstanceSettings) => { + const datasource: DataSourceRef = { uid: dsInstanceSettings.uid, type: dsInstanceSettings.type }; + + if (variable.state.datasource && variable.state.datasource.type !== datasource.type) { + variable.setState({ datasource, query: '', definition: '' }); + return; + } + + variable.setState({ datasource }); + }; + const onQueryChange = (query: VariableQueryType) => { + let definition: string; + if (typeof query === 'string') { + definition = query; + } else if (query.hasOwnProperty('query') && typeof query.query === 'string') { + definition = query.query; + } else { + definition = ''; + } + variable.setState({ query, definition }); + onRunQuery(); + }; + + return ( + <QueryVariableEditorForm + datasource={datasource ?? undefined} + onDataSourceChange={onDataSourceChange} + query={query} + onQueryChange={onQueryChange} + onLegacyQueryChange={onQueryChange} + timeRange={timeRange} + regex={regex} + onRegExChange={onRegExChange} + sort={sort} + onSortChange={onSortChange} + refresh={refresh} + onRefreshChange={onRefreshChange} + isMulti={!!isMulti} + onMultiChange={onMultiChange} + includeAll={!!includeAll} + onIncludeAllChange={onIncludeAllChange} + allValue={allValue ?? ''} + onAllValueChange={onAllValueChange} + /> + ); +} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/TextBoxVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/TextBoxVariableEditor.test.tsx new file mode 100644 index 0000000000000..063a1d033f3a6 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/TextBoxVariableEditor.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { TextBoxVariable } from '@grafana/scenes'; + +import { TextBoxVariableEditor } from './TextBoxVariableEditor'; + +describe('TextBoxVariableEditor', () => { + let textBoxVar: TextBoxVariable; + beforeEach(async () => { + const result = await buildTestScene(); + textBoxVar = result.textBoxVar; + }); + + it('renders default value if any', () => { + const onChange = jest.fn(); + render(<TextBoxVariableEditor variable={textBoxVar} onChange={onChange} />); + + const input = screen.getByRole('textbox', { name: 'Default value' }); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue('initial value test'); + }); + + it('changes the value', async () => { + const onChange = jest.fn(); + render(<TextBoxVariableEditor variable={textBoxVar} onChange={onChange} />); + + const input = screen.getByRole('textbox', { name: 'Default value' }); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue('initial value test'); + + // change input value + const newValue = 'new textbox value'; + await userEvent.clear(input); + await userEvent.type(input, newValue); + + expect(input).toHaveValue(newValue); + + await userEvent.tab(); + expect(textBoxVar.state.value).toBe(newValue); + }); +}); + +async function buildTestScene() { + const textBoxVar = new TextBoxVariable({ + name: 'textBoxVar', + label: 'textBoxVar', + type: 'textbox', + value: 'initial value test', + }); + + return { textBoxVar }; +} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/TextBoxVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/TextBoxVariableEditor.tsx new file mode 100644 index 0000000000000..2f7bbe256efad --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/TextBoxVariableEditor.tsx @@ -0,0 +1,20 @@ +import React, { FormEvent } from 'react'; + +import { TextBoxVariable } from '@grafana/scenes'; + +import { TextBoxVariableForm } from '../components/TextBoxVariableForm'; + +interface TextBoxVariableEditorProps { + variable: TextBoxVariable; + onChange: (variable: TextBoxVariable) => void; +} + +export function TextBoxVariableEditor({ variable }: TextBoxVariableEditorProps) { + const { value } = variable.useState(); + + const onTextValueChange = (e: FormEvent<HTMLInputElement>) => { + variable.setState({ value: e.currentTarget.value }); + }; + + return <TextBoxVariableForm defaultValue={value} onBlur={onTextValueChange} />; +} diff --git a/public/app/features/dashboard-scene/settings/variables/utils.test.ts b/public/app/features/dashboard-scene/settings/variables/utils.test.ts new file mode 100644 index 0000000000000..74c19faacd908 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/utils.test.ts @@ -0,0 +1,371 @@ +import { DataSourceApi } from '@grafana/data'; +import { config, setTemplateSrv, TemplateSrv } from '@grafana/runtime'; +import { + CustomVariable, + ConstantVariable, + IntervalVariable, + QueryVariable, + DataSourceVariable, + AdHocFiltersVariable, + GroupByVariable, + TextBoxVariable, + SceneVariableSet, +} from '@grafana/scenes'; +import { DataQuery, DataSourceJsonData, VariableType } from '@grafana/schema'; +import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; +import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; + +import { AdHocFiltersVariableEditor } from './editors/AdHocFiltersVariableEditor'; +import { ConstantVariableEditor } from './editors/ConstantVariableEditor'; +import { CustomVariableEditor } from './editors/CustomVariableEditor'; +import { DataSourceVariableEditor } from './editors/DataSourceVariableEditor'; +import { GroupByVariableEditor } from './editors/GroupByVariableEditor'; +import { IntervalVariableEditor } from './editors/IntervalVariableEditor'; +import { QueryVariableEditor } from './editors/QueryVariableEditor'; +import { TextBoxVariableEditor } from './editors/TextBoxVariableEditor'; +import { + isEditableVariableType, + EDITABLE_VARIABLES, + EDITABLE_VARIABLES_SELECT_ORDER, + getVariableTypeSelectOptions, + getVariableEditor, + getVariableScene, + hasVariableOptions, + EditableVariableType, + getDefinition, + getOptionDataSourceTypes, + getNextAvailableId, + getVariableDefault, + isSceneVariableInstance, +} from './utils'; + +const templateSrv = { + getAdhocFilters: jest.fn().mockReturnValue([{ key: 'origKey', operator: '=', value: '' }]), +} as unknown as TemplateSrv; + +const dsMock: DataSourceApi = { + meta: { + id: DASHBOARD_DATASOURCE_PLUGIN_ID, + }, + name: SHARED_DASHBOARD_QUERY, + type: SHARED_DASHBOARD_QUERY, + uid: SHARED_DASHBOARD_QUERY, + getRef: () => { + return { type: SHARED_DASHBOARD_QUERY, uid: SHARED_DASHBOARD_QUERY }; + }, +} as DataSourceApi<DataQuery, DataSourceJsonData, {}>; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + get: async () => dsMock, + getList: () => { + return [ + { + name: 'DataSourceInstance1', + uid: 'ds1', + meta: { + name: 'ds1', + id: 'dsTestDataSource', + }, + }, + ]; + }, + }), +})); + +describe('isEditableVariableType', () => { + it('should return true for editable variable types', () => { + const editableTypes: VariableType[] = [ + 'custom', + 'query', + 'constant', + 'interval', + 'datasource', + 'adhoc', + 'groupby', + 'textbox', + ]; + editableTypes.forEach((type) => { + expect(isEditableVariableType(type)).toBe(true); + }); + }); + + it('should return false for non-editable variable types', () => { + const nonEditableTypes: VariableType[] = ['system']; + nonEditableTypes.forEach((type) => { + expect(isEditableVariableType(type)).toBe(false); + }); + }); +}); + +describe('isSceneVariableInstance', () => { + it.each([ + CustomVariable, + QueryVariable, + ConstantVariable, + IntervalVariable, + DataSourceVariable, + AdHocFiltersVariable, + GroupByVariable, + TextBoxVariable, + ])('should return true for scene variable instances %s', (instanceType) => { + const variable = new instanceType({ name: 'MyVariable' }); + expect(isSceneVariableInstance(variable)).toBe(true); + }); + + it('should return false for non-scene variable instances', () => { + const variable = { + name: 'MyVariable', + type: 'query', + }; + expect(variable).not.toBeInstanceOf(QueryVariable); + }); +}); + +describe('getVariableTypeSelectOptions', () => { + describe('when groupByVariable is enabled', () => { + beforeAll(() => { + config.featureToggles.groupByVariable = true; + }); + + afterAll(() => { + config.featureToggles.groupByVariable = false; + }); + + it('should contain all editable variable types', () => { + const options = getVariableTypeSelectOptions(); + expect(options).toHaveLength(Object.keys(EDITABLE_VARIABLES).length); + + EDITABLE_VARIABLES_SELECT_ORDER.forEach((type) => { + expect(EDITABLE_VARIABLES).toHaveProperty(type); + }); + }); + + it('should return an array of selectable values for editable variable types', () => { + const options = getVariableTypeSelectOptions(); + expect(options).toHaveLength(8); + + options.forEach((option, index) => { + const editableType = EDITABLE_VARIABLES_SELECT_ORDER[index]; + const variableTypeConfig = EDITABLE_VARIABLES[editableType]; + + expect(option.value).toBe(editableType); + expect(option.label).toBe(variableTypeConfig.name); + expect(option.description).toBe(variableTypeConfig.description); + }); + }); + }); + + describe('when groupByVariable is disabled', () => { + it('should contain all editable variable types except groupby', () => { + const options = getVariableTypeSelectOptions(); + expect(options).toHaveLength(Object.keys(EDITABLE_VARIABLES).length - 1); + + EDITABLE_VARIABLES_SELECT_ORDER.forEach((type) => { + expect(EDITABLE_VARIABLES).toHaveProperty(type); + }); + }); + + it('should return an array of selectable values for editable variable types', () => { + const options = getVariableTypeSelectOptions(); + expect(options).toHaveLength(7); + + options.forEach((option, index) => { + const editableType = EDITABLE_VARIABLES_SELECT_ORDER[index]; + const variableTypeConfig = EDITABLE_VARIABLES[editableType]; + + expect(option.value).toBe(editableType); + expect(option.label).toBe(variableTypeConfig.name); + expect(option.description).toBe(variableTypeConfig.description); + }); + }); + }); +}); + +describe('getVariableEditor', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each(Object.keys(EDITABLE_VARIABLES) as EditableVariableType[])( + 'should define an editor for variable type "%s"', + (type) => { + const editor = getVariableEditor(type); + expect(editor).toBeDefined(); + } + ); + + it.each([ + ['custom', CustomVariableEditor], + ['query', QueryVariableEditor], + ['constant', ConstantVariableEditor], + ['interval', IntervalVariableEditor], + ['datasource', DataSourceVariableEditor], + ['adhoc', AdHocFiltersVariableEditor], + ['groupby', GroupByVariableEditor], + ['textbox', TextBoxVariableEditor], + ])('should return the correct editor for variable type "%s"', (type, ExpectedVariableEditor) => { + expect(getVariableEditor(type as EditableVariableType)).toBe(ExpectedVariableEditor); + }); +}); + +describe('getVariableScene', () => { + beforeAll(() => { + setTemplateSrv(templateSrv); + }); + + it.each(Object.keys(EDITABLE_VARIABLES) as EditableVariableType[])( + 'should define a scene object for every variable type', + (type) => { + const variable = getVariableScene(type, { name: 'foo' }); + expect(variable).toBeDefined(); + } + ); + + it.each([ + ['custom', CustomVariable], + ['query', QueryVariable], + ['constant', ConstantVariable], + ['interval', IntervalVariable], + ['datasource', DataSourceVariable], + ['adhoc', AdHocFiltersVariable], + ['groupby', GroupByVariable], + ['textbox', TextBoxVariable], + ])('should return the scene variable instance for the given editable variable type', (type, instanceType) => { + const initialState = { name: 'MyVariable' }; + const sceneVariable = getVariableScene(type as EditableVariableType, initialState); + expect(sceneVariable).toBeInstanceOf(instanceType); + expect(sceneVariable.state.name).toBe(initialState.name); + }); +}); + +describe('hasVariableOptions', () => { + it('should return true for scene variables with options property', () => { + const variableWithOptions = new CustomVariable({ + name: 'MyVariable', + options: [{ value: 'option1', label: 'Option 1' }], + }); + expect(hasVariableOptions(variableWithOptions)).toBe(true); + }); + + it('should return false for scene variables without options property', () => { + const variableWithoutOptions = new ConstantVariable({ name: 'MyVariable' }); + expect(hasVariableOptions(variableWithoutOptions)).toBe(false); + }); +}); + +describe('getDefinition', () => { + it('returns the correct definition for QueryVariable when definition is defined', () => { + const model = new QueryVariable({ + name: 'custom0', + query: '', + definition: 'legacy ABC query definition', + }); + expect(getDefinition(model)).toBe('legacy ABC query definition'); + }); + + it('returns the correct definition for QueryVariable when definition is not defined', () => { + const model = new QueryVariable({ + name: 'custom0', + query: 'ABC query', + definition: '', + }); + expect(getDefinition(model)).toBe('ABC query'); + }); + + it('returns the correct definition for DataSourceVariable', () => { + const model = new DataSourceVariable({ + name: 'ds0', + pluginId: 'datasource-plugin', + value: 'datasource-value', + }); + expect(getDefinition(model)).toBe('datasource-plugin'); + }); + + it('returns the correct definition for CustomVariable', () => { + const model = new CustomVariable({ + name: 'custom0', + query: 'Custom, A, B, C', + }); + expect(getDefinition(model)).toBe('Custom, A, B, C'); + }); + + it('returns the correct definition for IntervalVariable', () => { + const model = new IntervalVariable({ + name: 'interval0', + intervals: ['1m', '5m', '15m', '30m', '1h', '6h', '12h', '1d'], + }); + expect(getDefinition(model)).toBe('1m,5m,15m,30m,1h,6h,12h,1d'); + }); + + it('returns the correct definition for TextBoxVariable', () => { + const model = new TextBoxVariable({ + name: 'textbox0', + value: 'TextBox Value', + }); + expect(getDefinition(model)).toBe('TextBox Value'); + }); + + it('returns the correct definition for ConstantVariable', () => { + const model = new ConstantVariable({ + name: 'constant0', + value: 'Constant Value', + }); + expect(getDefinition(model)).toBe('Constant Value'); + }); +}); + +describe('getOptionDataSourceTypes', () => { + it('should return all data source types when no data source types are specified', () => { + const optionTypes = getOptionDataSourceTypes(); + expect(optionTypes).toHaveLength(2); + // in the old code we always had an empty option + expect(optionTypes[0].value).toBe(''); + expect(optionTypes[1].label).toBe('ds1'); + }); +}); + +describe('getNextAvailableId', () => { + it('should return the initial ID for an empty array', () => { + const sceneVariables = new SceneVariableSet({ + variables: [], + }); + + expect(getNextAvailableId('query', sceneVariables.state.variables)).toBe('query0'); + }); + + it('should return a non-conflicting ID for a non-empty array', () => { + const variable = new QueryVariable({ + name: 'query0', + label: 'test-label', + description: 'test-desc', + value: ['selected-value'], + text: ['selected-value-text'], + datasource: { uid: 'fake-std', type: 'fake-std' }, + query: 'query', + includeAll: true, + allValue: 'test-all', + isMulti: true, + }); + + const sceneVariables = new SceneVariableSet({ + variables: [variable], + }); + + expect(getNextAvailableId('query', sceneVariables.state.variables)).toBe('query1'); + }); +}); + +describe('getVariableDefault', () => { + it('should return a QueryVariable instance with the correct name', () => { + const sceneVariables = new SceneVariableSet({ + variables: [], + }); + + const defaultVariable = getVariableDefault(sceneVariables.state.variables); + + expect(defaultVariable).toBeInstanceOf(QueryVariable); + expect(defaultVariable.state.name).toBe('query0'); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/variables/utils.ts b/public/app/features/dashboard-scene/settings/variables/utils.ts new file mode 100644 index 0000000000000..221387b87d16d --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/utils.ts @@ -0,0 +1,223 @@ +import { chain } from 'lodash'; + +import { DataSourceInstanceSettings, SelectableValue } from '@grafana/data'; +import { config, getDataSourceSrv } from '@grafana/runtime'; +import { + ConstantVariable, + CustomVariable, + DataSourceVariable, + IntervalVariable, + TextBoxVariable, + QueryVariable, + GroupByVariable, + SceneVariable, + MultiValueVariable, + sceneUtils, + SceneObject, + AdHocFiltersVariable, + SceneVariableState, +} from '@grafana/scenes'; +import { VariableType } from '@grafana/schema'; + +import { getIntervalsQueryFromNewIntervalModel } from '../../utils/utils'; + +import { AdHocFiltersVariableEditor } from './editors/AdHocFiltersVariableEditor'; +import { ConstantVariableEditor } from './editors/ConstantVariableEditor'; +import { CustomVariableEditor } from './editors/CustomVariableEditor'; +import { DataSourceVariableEditor } from './editors/DataSourceVariableEditor'; +import { GroupByVariableEditor } from './editors/GroupByVariableEditor'; +import { IntervalVariableEditor } from './editors/IntervalVariableEditor'; +import { QueryVariableEditor } from './editors/QueryVariableEditor'; +import { TextBoxVariableEditor } from './editors/TextBoxVariableEditor'; + +interface EditableVariableConfig { + name: string; + description: string; + editor: React.ComponentType<any>; +} + +export type EditableVariableType = Exclude<VariableType, 'system'>; + +export function isEditableVariableType(type: VariableType): type is EditableVariableType { + return type !== 'system'; +} + +export const EDITABLE_VARIABLES: Record<EditableVariableType, EditableVariableConfig> = { + custom: { + name: 'Custom', + description: 'Define variable values manually', + editor: CustomVariableEditor, + }, + query: { + name: 'Query', + description: 'Variable values are fetched from a datasource query', + editor: QueryVariableEditor, + }, + constant: { + name: 'Constant', + description: 'Define a hidden constant variable, useful for metric prefixes in dashboards you want to share', + editor: ConstantVariableEditor, + }, + interval: { + name: 'Interval', + description: 'Define a timespan interval (ex 1m, 1h, 1d)', + editor: IntervalVariableEditor, + }, + datasource: { + name: 'Data source', + description: 'Enables you to dynamically switch the data source for multiple panels', + editor: DataSourceVariableEditor, + }, + adhoc: { + name: 'Ad hoc filters', + description: 'Add key/value filters on the fly', + editor: AdHocFiltersVariableEditor, + }, + groupby: { + name: 'Group by', + description: 'Add keys to group by on the fly', + editor: GroupByVariableEditor, + }, + textbox: { + name: 'Textbox', + description: 'Define a textbox variable, where users can enter any arbitrary string', + editor: TextBoxVariableEditor, + }, +}; + +export const EDITABLE_VARIABLES_SELECT_ORDER: EditableVariableType[] = [ + 'query', + 'custom', + 'textbox', + 'constant', + 'datasource', + 'interval', + 'adhoc', + 'groupby', +]; + +export function getVariableTypeSelectOptions(): Array<SelectableValue<EditableVariableType>> { + const results = EDITABLE_VARIABLES_SELECT_ORDER.map((variableType) => ({ + label: EDITABLE_VARIABLES[variableType].name, + value: variableType, + description: EDITABLE_VARIABLES[variableType].description, + })); + + if (!config.featureToggles.groupByVariable) { + // Remove group by variable type if feature toggle is off + return results.filter((option) => option.value !== 'groupby'); + } + + return results; +} + +export function getVariableEditor(type: EditableVariableType) { + return EDITABLE_VARIABLES[type].editor; +} + +interface CommonVariableProperties { + name: string; + label?: string; +} + +export function getVariableScene(type: EditableVariableType, initialState: CommonVariableProperties) { + switch (type) { + case 'custom': + return new CustomVariable(initialState); + case 'query': + return new QueryVariable(initialState); + case 'constant': + return new ConstantVariable(initialState); + case 'interval': + return new IntervalVariable(initialState); + case 'datasource': + return new DataSourceVariable(initialState); + case 'adhoc': + return new AdHocFiltersVariable(initialState); + case 'groupby': + return new GroupByVariable(initialState); + case 'textbox': + return new TextBoxVariable(initialState); + } +} + +export function getVariableDefault(variables: Array<SceneVariable<SceneVariableState>>) { + const defaultVariableType = 'query'; + const nextVariableIdName = getNextAvailableId(defaultVariableType, variables); + return new QueryVariable({ + name: nextVariableIdName, + }); +} + +export function getNextAvailableId(type: VariableType, variables: Array<SceneVariable<SceneVariableState>>): string { + let counter = 0; + let nextId = `${type}${counter}`; + + while (variables.find((variable) => variable.state.name === nextId)) { + nextId = `${type}${++counter}`; + } + + return nextId; +} + +export function hasVariableOptions(variable: SceneVariable): variable is MultiValueVariable { + // variable options can be defined by state.options or state.intervals in case of interval variable + return 'options' in variable.state || 'intervals' in variable.state; +} + +export function getDefinition(model: SceneVariable): string { + let definition = ''; + + if (model instanceof QueryVariable) { + definition = model.state.definition || (typeof model.state.query === 'string' ? model.state.query : ''); + } else if (model instanceof DataSourceVariable) { + definition = String(model.state.pluginId); + } else if (model instanceof CustomVariable) { + definition = model.state.query; + } else if (model instanceof IntervalVariable) { + definition = getIntervalsQueryFromNewIntervalModel(model.state.intervals); + } else if (model instanceof TextBoxVariable || model instanceof ConstantVariable) { + definition = String(model.state.value); + } + + return definition; +} + +export function getOptionDataSourceTypes() { + const datasources = getDataSourceSrv().getList({ metrics: true, variables: true }); + + const optionTypes = chain(datasources) + .uniqBy('meta.id') + .map((ds: DataSourceInstanceSettings) => { + return { label: ds.meta.name, value: ds.meta.id }; + }) + .value(); + + optionTypes.unshift({ label: '', value: '' }); + + return optionTypes; +} + +function isSceneVariable(sceneObject: SceneObject): sceneObject is SceneVariable { + return 'type' in sceneObject.state && 'getValue' in sceneObject; +} + +export function isSceneVariableInstance(sceneObject: SceneObject): sceneObject is SceneVariable { + if (!isSceneVariable(sceneObject)) { + return false; + } + + return ( + sceneUtils.isAdHocVariable(sceneObject) || + sceneUtils.isConstantVariable(sceneObject) || + sceneUtils.isCustomVariable(sceneObject) || + sceneUtils.isDataSourceVariable(sceneObject) || + sceneUtils.isIntervalVariable(sceneObject) || + sceneUtils.isQueryVariable(sceneObject) || + sceneUtils.isTextBoxVariable(sceneObject) || + sceneUtils.isGroupByVariable(sceneObject) + ); +} + +export const RESERVED_GLOBAL_VARIABLE_NAME_REGEX = /^(?!__).*$/; +export const WORD_CHARACTERS_REGEX = /^\w+$/; diff --git a/public/app/features/dashboard/components/VersionHistory/DiffGroup.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffGroup.tsx similarity index 78% rename from public/app/features/dashboard/components/VersionHistory/DiffGroup.tsx rename to public/app/features/dashboard-scene/settings/version-history/DiffGroup.tsx index 5e5896e0ca0c3..cbded5fd9ce3e 100644 --- a/public/app/features/dashboard/components/VersionHistory/DiffGroup.tsx +++ b/public/app/features/dashboard-scene/settings/version-history/DiffGroup.tsx @@ -42,16 +42,14 @@ export const DiffGroup = ({ diffs, title }: DiffGroupProps) => { }; const getStyles = (theme: GrafanaTheme2) => ({ - container: css` - background-color: ${theme.colors.background.secondary}; - font-size: ${theme.typography.h6.fontSize}; - margin-bottom: ${theme.spacing(2)}; - padding: ${theme.spacing(2)}; - `, - list: css` - margin-left: ${theme.spacing(4)}; - `, - listItem: css` - margin-bottom: ${theme.spacing(1)}; - `, + container: css({}), + list: css({ + marginLeft: theme.spacing(4), + }), + listItem: css({ + marginBottom: theme.spacing(1), + '&:last-child': { + marginBottom: 0, + }, + }), }); diff --git a/public/app/features/dashboard/components/VersionHistory/DiffTitle.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx similarity index 50% rename from public/app/features/dashboard/components/VersionHistory/DiffTitle.tsx rename to public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx index c25c78c736107..3716d3534e1aa 100644 --- a/public/app/features/dashboard/components/VersionHistory/DiffTitle.tsx +++ b/public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx @@ -19,43 +19,44 @@ export const DiffTitle = ({ diff, title }: DiffTitleProps) => { return diff ? ( <> - <Icon type="mono" name="circle" className={styles[diff.op]} /> <span className={styles.embolden}>{title}</span>{' '} - <span>{getDiffText(diff, diff.path.length > 1)}</span> <DiffValues diff={diff} /> + <Icon type="mono" name="circle" className={styles[diff.op]} size="xs" />{' '} + <span className={styles.embolden}>{title}</span> <span>{getDiffText(diff, diff.path.length > 1)}</span>{' '} + <DiffValues diff={diff} /> </> ) : ( <div className={styles.withoutDiff}> - <Icon type="mono" name="circle" className={styles.replace} /> <span className={styles.embolden}>{title}</span>{' '} - <span>{getDiffText(replaceDiff, false)}</span> + <Icon type="mono" name="circle" className={styles.replace} size="xs" />{' '} + <span className={styles.embolden}>{title}</span> <span>{getDiffText(replaceDiff, false)}</span> </div> ); }; const getDiffTitleStyles = (theme: GrafanaTheme2) => ({ - embolden: css` - font-weight: ${theme.typography.fontWeightBold}; - `, - add: css` - color: ${theme.colors.success.main}; - `, - replace: css` - color: ${theme.colors.success.main}; - `, - move: css` - color: ${theme.colors.success.main}; - `, - copy: css` - color: ${theme.colors.success.main}; - `, - _get: css` - color: ${theme.colors.success.main}; - `, - test: css` - color: ${theme.colors.success.main}; - `, - remove: css` - color: ${theme.colors.success.main}; - `, - withoutDiff: css` - margin-bottom: ${theme.spacing(2)}; - `, + embolden: css({ + fontWeight: theme.typography.fontWeightBold, + }), + add: css({ + color: theme.colors.success.main, + }), + replace: css({ + color: theme.colors.success.main, + }), + move: css({ + color: theme.colors.success.main, + }), + copy: css({ + color: theme.colors.success.main, + }), + _get: css({ + color: theme.colors.success.main, + }), + test: css({ + color: theme.colors.success.main, + }), + remove: css({ + color: theme.colors.success.main, + }), + withoutDiff: css({ + marginBottom: theme.spacing(1), + }), }); diff --git a/public/app/features/dashboard/components/VersionHistory/DiffValues.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffValues.tsx similarity index 73% rename from public/app/features/dashboard/components/VersionHistory/DiffValues.tsx rename to public/app/features/dashboard-scene/settings/version-history/DiffValues.tsx index cff84ade26d68..c9ff1247f9020 100644 --- a/public/app/features/dashboard/components/VersionHistory/DiffValues.tsx +++ b/public/app/features/dashboard-scene/settings/version-history/DiffValues.tsx @@ -26,11 +26,12 @@ export const DiffValues = ({ diff }: DiffProps) => { ); }; -const getStyles = (theme: GrafanaTheme2) => css` - background-color: ${theme.colors.action.hover}; - border-radius: ${theme.shape.radius.default}; - color: ${theme.colors.text.primary}; - font-size: ${theme.typography.body.fontSize}; - margin: 0 ${theme.spacing(0.5)}; - padding: ${theme.spacing(0.5, 1)}; -`; +const getStyles = (theme: GrafanaTheme2) => + css({ + backgroundColor: theme.colors.action.hover, + borderRadius: theme.shape.radius.default, + color: theme.colors.text.primary, + fontSize: theme.typography.body.fontSize, + margin: theme.spacing(0, 0.5), + padding: theme.spacing(0.25, 0.5), + }); diff --git a/public/app/features/dashboard/components/VersionHistory/DiffViewer.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx similarity index 90% rename from public/app/features/dashboard/components/VersionHistory/DiffViewer.tsx rename to public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx index b12014aa44814..27ce79b8c824d 100644 --- a/public/app/features/dashboard/components/VersionHistory/DiffViewer.tsx +++ b/public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx @@ -42,23 +42,23 @@ export const DiffViewer = ({ oldValue, newValue }: ReactDiffViewerProps) => { codeFold: { fontSize: theme.typography.bodySmall.fontSize, }, - gutter: ` - pre { - color: ${tinycolor(theme.colors.text.disabled).setAlpha(1).toString()}; - opacity: 0.61; - } - `, + gutter: { + pre: { + color: tinycolor(theme.colors.text.disabled).setAlpha(1).toString(), + opacity: 0.61, + }, + }, }; return ( <div - className={css` - font-size: ${theme.typography.bodySmall.fontSize}; + className={css({ + fontSize: theme.typography.bodySmall.fontSize, // prevent global styles interfering with diff viewer - pre { - all: revert; - } - `} + pre: { + all: 'revert', + }, + })} > <ReactDiffViewer styles={styles} diff --git a/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts similarity index 56% rename from public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts rename to public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts index 8571795c054eb..c2caee31a0e3f 100644 --- a/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts +++ b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts @@ -1,5 +1,4 @@ -import { DashboardModel } from '../../state/DashboardModel'; -import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures'; +import { createDashboardModelFixture } from 'app/features/dashboard/state/__fixtures__/dashboardFixtures'; import { HistorySrv } from './HistorySrv'; import { restore, versions } from './__mocks__/dashboardHistoryMocks'; @@ -7,7 +6,11 @@ import { restore, versions } from './__mocks__/dashboardHistoryMocks'; const getMock = jest.fn().mockResolvedValue({}); const postMock = jest.fn().mockResolvedValue({}); -jest.mock('app/core/store'); +jest.mock('app/core/store', () => ({ + get: jest.fn(), + getObject: jest.fn((_a, b) => b), +})); + jest.mock('@grafana/runtime', () => { const original = jest.requireActual('@grafana/runtime'); @@ -39,37 +42,55 @@ describe('historySrv', () => { getMock.mockImplementation(() => Promise.resolve(versionsResponse)); historySrv = new HistorySrv(); - return historySrv.getHistoryList(dash, historyListOpts).then((versions) => { + return historySrv.getHistoryList(dash.uid, historyListOpts).then((versions) => { expect(versions).toEqual(versionsResponse); }); }); it('should return an empty array when not given an id', () => { - return historySrv.getHistoryList(emptyDash, historyListOpts).then((versions) => { + return historySrv.getHistoryList(emptyDash.uid, historyListOpts).then((versions) => { expect(versions).toEqual([]); }); }); - it('should return an empty array when not given a dashboard', () => { - return historySrv.getHistoryList(null as unknown as DashboardModel, historyListOpts).then((versions) => { + it('should return an empty array when not given a dashboard id', () => { + return historySrv.getHistoryList(null as unknown as string, historyListOpts).then((versions) => { expect(versions).toEqual([]); }); }); }); + describe('getDashboardVersion', () => { + it('should return a version object for the given dashboard id and version', () => { + getMock.mockImplementation(() => Promise.resolve(versionsResponse[0])); + historySrv = new HistorySrv(); + + return historySrv.getDashboardVersion(dash.uid, 4).then((version) => { + expect(version).toEqual(versionsResponse[0]); + }); + }); + + it('should return an empty object when not given an id', async () => { + historySrv = new HistorySrv(); + + const rsp = await historySrv.getDashboardVersion(emptyDash.uid, 6); + expect(rsp).toEqual({}); + }); + }); + describe('restoreDashboard', () => { it('should return a success response given valid parameters', () => { const version = 6; postMock.mockImplementation(() => Promise.resolve(restoreResponse(version))); historySrv = new HistorySrv(); - return historySrv.restoreDashboard(dash, version).then((response) => { + return historySrv.restoreDashboard(dash.uid, version).then((response) => { expect(response).toEqual(restoreResponse(version)); }); }); it('should return an empty object when not given an id', async () => { historySrv = new HistorySrv(); - const rsp = await historySrv.restoreDashboard(emptyDash, 6); + const rsp = await historySrv.restoreDashboard(emptyDash.uid, 6); expect(rsp).toEqual({}); }); }); diff --git a/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts new file mode 100644 index 0000000000000..61312b8ad45e4 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts @@ -0,0 +1,50 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { Dashboard } from '@grafana/schema'; + +export interface HistoryListOpts { + limit: number; + start: number; +} + +export interface RevisionsModel { + id: number; + checked: boolean; + uid: string; + parentVersion: number; + version: number; + created: Date; + createdBy: string; + message: string; + data: Dashboard; +} + +export class HistorySrv { + getHistoryList(dashboardUID: string, options: HistoryListOpts) { + if (typeof dashboardUID !== 'string') { + return Promise.resolve([]); + } + + return getBackendSrv().get(`api/dashboards/uid/${dashboardUID}/versions`, options); + } + + getDashboardVersion(dashboardUID: string, version: number) { + if (typeof dashboardUID !== 'string') { + return Promise.resolve({}); + } + + return getBackendSrv().get(`api/dashboards/uid/${dashboardUID}/versions/${version}`); + } + + restoreDashboard(dashboardUID: string, version: number) { + if (typeof dashboardUID !== 'string') { + return Promise.resolve({}); + } + + const url = `api/dashboards/uid/${dashboardUID}/restore`; + + return getBackendSrv().post(url, { version }); + } +} + +const historySrv = new HistorySrv(); +export { historySrv }; diff --git a/public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx b/public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx new file mode 100644 index 0000000000000..6d27baf3f173f --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import { ConfirmModal } from '@grafana/ui'; +import { useAppNotification } from 'app/core/copy/appNotification'; + +import { DecoratedRevisionModel } from '../VersionsEditView'; + +export interface RevertDashboardModalProps { + hideModal: () => void; + onRestore: (version: DecoratedRevisionModel) => Promise<boolean>; + version: DecoratedRevisionModel; +} + +export const RevertDashboardModal = ({ hideModal, onRestore, version }: RevertDashboardModalProps) => { + const notifyApp = useAppNotification(); + + const onRestoreDashboard = async () => { + const success = await onRestore(version); + + if (success) { + notifyApp.success('Dashboard restored', `Restored from version ${version.version}`); + } else { + notifyApp.error('Dashboard restore failed', `Failed to restore from version ${version.version}`); + } + + hideModal(); + }; + + return ( + <ConfirmModal + isOpen={true} + title="Restore Version" + icon="history" + onDismiss={hideModal} + onConfirm={onRestoreDashboard} + body={ + <p> + Are you sure you want to restore the dashboard to version {version.version}? All unsaved changes will be lost. + </p> + } + confirmText={`Yes, restore to version ${version.version}`} + /> + ); +}; diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryButtons.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryButtons.tsx similarity index 100% rename from public/app/features/dashboard/components/VersionHistory/VersionHistoryButtons.tsx rename to public/app/features/dashboard-scene/settings/version-history/VersionHistoryButtons.tsx diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryComparison.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryComparison.tsx new file mode 100644 index 0000000000000..7faaaa587b50c --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryComparison.tsx @@ -0,0 +1,85 @@ +import { css, cx } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, ModalsController, CollapsableSection, useStyles2, Stack, Icon, Box } from '@grafana/ui'; + +import { DecoratedRevisionModel } from '../VersionsEditView'; + +import { DiffGroup } from './DiffGroup'; +import { DiffViewer } from './DiffViewer'; +import { RevertDashboardModal } from './RevertDashboardModal'; +import { jsonDiff } from './utils'; + +type DiffViewProps = { + isNewLatest: boolean; + newInfo: DecoratedRevisionModel; + baseInfo: DecoratedRevisionModel; + diffData: { lhs: string; rhs: string }; + onRestore: (version: DecoratedRevisionModel) => Promise<boolean>; +}; + +export const VersionHistoryComparison = ({ baseInfo, newInfo, diffData, isNewLatest, onRestore }: DiffViewProps) => { + const diff = jsonDiff(diffData.lhs, diffData.rhs); + const styles = useStyles2(getStyles); + + return ( + <Stack direction="column" gap={1}> + <Stack justifyContent="space-between" alignItems="center"> + <Stack alignItems="center"> + <span className={cx(styles.versionInfo, styles.noMarginBottom)}> + <strong>Version {baseInfo.version}</strong> updated by {baseInfo.createdBy} {baseInfo.ageString} + {baseInfo.message} + </span> + <Icon name="arrow-right" size="sm" /> + <span className={styles.versionInfo}> + <strong>Version {newInfo.version}</strong> updated by {newInfo.createdBy} {newInfo.ageString} + {newInfo.message} + </span> + </Stack> + {isNewLatest && ( + <ModalsController> + {({ showModal, hideModal }) => ( + <Button + variant="destructive" + icon="history" + onClick={() => { + showModal(RevertDashboardModal, { + version: baseInfo, + onRestore, + hideModal, + }); + }} + > + Restore to version {baseInfo.version} + </Button> + )} + </ModalsController> + )} + </Stack> + + {Object.entries(diff).map(([key, diffs]) => ( + <DiffGroup diffs={diffs} key={key} title={key} /> + ))} + + <Box paddingTop={2}> + <CollapsableSection isOpen={false} label="View JSON Diff"> + <DiffViewer + oldValue={JSON.stringify(diffData.lhs, null, 2)} + newValue={JSON.stringify(diffData.rhs, null, 2)} + /> + </CollapsableSection> + </Box> + </Stack> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + versionInfo: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + }), + noMarginBottom: css({ + marginBottom: 0, + }), +}); diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx similarity index 85% rename from public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx rename to public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx index 1205e084f9d9b..6c3738f9057bf 100644 --- a/public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx +++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx @@ -32,10 +32,10 @@ export const VersionHistoryHeader = ({ }; const getStyles = (theme: GrafanaTheme2) => ({ - header: css` - font-size: ${theme.typography.h3.fontSize}; - display: flex; - gap: ${theme.spacing(2)}; - margin-bottom: ${theme.spacing(3)}; - `, + header: css({ + fontSize: theme.typography.h3.fontSize, + display: 'flex', + gap: theme.spacing(2), + marginBottom: theme.spacing(2), + }), }); diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx new file mode 100644 index 0000000000000..a347c72eed52b --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx @@ -0,0 +1,90 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Checkbox, Button, Tag, ModalsController, useStyles2 } from '@grafana/ui'; + +import { DecoratedRevisionModel } from '../VersionsEditView'; + +import { RevertDashboardModal } from './RevertDashboardModal'; + +type VersionsTableProps = { + versions: DecoratedRevisionModel[]; + canCompare: boolean; + onCheck: (ev: React.FormEvent<HTMLInputElement>, versionId: number) => void; + onRestore: (version: DecoratedRevisionModel) => Promise<boolean>; +}; + +export const VersionHistoryTable = ({ versions, canCompare, onCheck, onRestore }: VersionsTableProps) => { + const styles = useStyles2(getStyles); + + return ( + <div className={styles.margin}> + <table className="filter-table"> + <thead> + <tr> + <th className="width-4"></th> + <th className="width-4">Version</th> + <th className="width-14">Date</th> + <th className="width-10">Updated by</th> + <th>Notes</th> + <th></th> + </tr> + </thead> + <tbody> + {versions.map((version, idx) => ( + <tr key={version.id}> + <td> + <Checkbox + aria-label={`Toggle selection of version ${version.version}`} + className={css({ + display: 'inline', + })} + checked={version.checked} + onChange={(ev) => onCheck(ev, version.id)} + disabled={!version.checked && canCompare} + /> + </td> + <td>{version.version}</td> + <td>{version.createdDateString}</td> + <td>{version.createdBy}</td> + <td>{version.message}</td> + <td className="text-right"> + {idx === 0 ? ( + <Tag name="Latest" colorIndex={17} /> + ) : ( + <ModalsController> + {({ showModal, hideModal }) => ( + <Button + variant="secondary" + size="sm" + icon="history" + onClick={() => { + showModal(RevertDashboardModal, { + version, + hideModal, + onRestore, + }); + }} + > + Restore + </Button> + )} + </ModalsController> + )} + </td> + </tr> + ))} + </tbody> + </table> + </div> + ); +}; + +function getStyles(theme: GrafanaTheme2) { + return { + margin: css({ + marginBottom: theme.spacing(4), + }), + }; +} diff --git a/public/app/features/dashboard/components/VersionHistory/__mocks__/dashboardHistoryMocks.ts b/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts similarity index 100% rename from public/app/features/dashboard/components/VersionHistory/__mocks__/dashboardHistoryMocks.ts rename to public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts diff --git a/public/app/features/dashboard/components/VersionHistory/index.ts b/public/app/features/dashboard-scene/settings/version-history/index.ts similarity index 100% rename from public/app/features/dashboard/components/VersionHistory/index.ts rename to public/app/features/dashboard-scene/settings/version-history/index.ts diff --git a/public/app/features/dashboard/components/VersionHistory/utils.test.ts b/public/app/features/dashboard-scene/settings/version-history/utils.test.ts similarity index 97% rename from public/app/features/dashboard/components/VersionHistory/utils.test.ts rename to public/app/features/dashboard-scene/settings/version-history/utils.test.ts index 5523229b7dc36..8d38c5cf6e97b 100644 --- a/public/app/features/dashboard/components/VersionHistory/utils.test.ts +++ b/public/app/features/dashboard-scene/settings/version-history/utils.test.ts @@ -1,3 +1,5 @@ +import { Dashboard } from '@grafana/schema'; + import { Diff, getDiffOperationText, getDiffText, jsonDiff } from './utils'; describe('getDiffOperationText', () => { @@ -288,6 +290,6 @@ describe('jsonDiff', () => { ], }; - expect(jsonDiff(lhs, rhs)).toStrictEqual(expected); + expect(jsonDiff(lhs as unknown as Dashboard, rhs as unknown as Dashboard)).toStrictEqual(expected); }); }); diff --git a/public/app/features/dashboard/components/VersionHistory/utils.ts b/public/app/features/dashboard-scene/settings/version-history/utils.ts similarity index 94% rename from public/app/features/dashboard/components/VersionHistory/utils.ts rename to public/app/features/dashboard-scene/settings/version-history/utils.ts index 9700c8a524c57..ac036e2acaeeb 100644 --- a/public/app/features/dashboard/components/VersionHistory/utils.ts +++ b/public/app/features/dashboard-scene/settings/version-history/utils.ts @@ -3,6 +3,8 @@ import { compare, Operation } from 'fast-json-patch'; import jsonMap from 'json-source-map'; import { flow, get, isArray, isEmpty, last, sortBy, tail, toNumber, isNaN } from 'lodash'; +import { Dashboard } from '@grafana/schema'; + export type Diff = { op: 'add' | 'replace' | 'remove' | 'copy' | 'test' | '_get' | 'move'; value: unknown; @@ -15,7 +17,9 @@ export type Diffs = { [key: string]: Diff[]; }; -export const jsonDiff = (lhs: any, rhs: any): Diffs => { +export type JSONValue = string | Dashboard; + +export const jsonDiff = (lhs: JSONValue, rhs: JSONValue): Diffs => { const diffs = compare(lhs, rhs); const lhsMap = jsonMap.stringify(lhs, null, 2); const rhsMap = jsonMap.stringify(rhs, null, 2); diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx index e9e2e35a7ab05..62d7f5e737c04 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx @@ -30,7 +30,7 @@ describe('ShareLinkTab', () => { config.rendererAvailable = true; config.bootData.user.orgId = 1; config.featureToggles.dashboardSceneForViewers = true; - locationService.push('/scenes/dashboard/dash-1?from=now-6h&to=now'); + locationService.push('/d/dash-1?from=now-6h&to=now'); }); describe('with locked time range (absolute) range', () => { @@ -38,7 +38,7 @@ describe('ShareLinkTab', () => { buildAndRenderScenario({}); expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue( - 'http://dashboards.grafana.com/grafana/scenes/dashboard/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=panel-12' + 'http://dashboards.grafana.com/grafana/d/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=panel-12' ); }); }); @@ -49,7 +49,7 @@ describe('ShareLinkTab', () => { act(() => tab.onToggleLockedTime()); expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue( - 'http://dashboards.grafana.com/grafana/scenes/dashboard/dash-1?from=now-6h&to=now&viewPanel=panel-12' + 'http://dashboards.grafana.com/grafana/d/dash-1?from=now-6h&to=now&viewPanel=panel-12' ); }); }); @@ -59,7 +59,7 @@ describe('ShareLinkTab', () => { act(() => tab.onThemeChange('light')); expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue( - 'http://dashboards.grafana.com/grafana/scenes/dashboard/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=panel-12&theme=light' + 'http://dashboards.grafana.com/grafana/d/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=panel-12&theme=light' ); }); @@ -80,7 +80,7 @@ describe('ShareLinkTab', () => { await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage }) ).toHaveAttribute( 'href', - 'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=panel-12&width=1000&height=500&tz=Pacific%2FEaster' + 'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&panelId=panel-12&__feature.dashboardSceneSolo&width=1000&height=500&tz=Pacific%2FEaster' ); }); }); diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx index 0366ad8b8380a..a41f4dc280818 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx @@ -73,22 +73,30 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> { let shareUrl = getDashboardUrl({ uid: dashboard.state.uid, + slug: dashboard.state.meta.slug, currentQueryParams: location.search, updateQuery: urlParamsUpdate, absolute: true, - useExperimentalURL: Boolean(config.featureToggles.dashboardSceneForViewers && dashboard.state.meta.canEdit), }); if (useShortUrl) { shareUrl = await createShortLink(shareUrl); } + // the image panel solo route uses panelId instead of viewPanel + let imageQueryParams = urlParamsUpdate; + if (panel) { + delete imageQueryParams.viewPanel; + imageQueryParams.panelId = panel.state.key; + // force solo route to use scenes + imageQueryParams['__feature.dashboardSceneSolo'] = true; + } + const imageUrl = getDashboardUrl({ uid: dashboard.state.uid, currentQueryParams: location.search, - updateQuery: urlParamsUpdate, + updateQuery: { ...urlParamsUpdate, panelId: panel?.state.key }, absolute: true, - soloRoute: true, render: true, timeZone: getRenderTimeZone(timeRange.getTimeZone()), diff --git a/public/app/features/dashboard-scene/sharing/SharePanelEmbedTab.tsx b/public/app/features/dashboard-scene/sharing/SharePanelEmbedTab.tsx index fd66cd910b434..ce6e7391eb5f4 100644 --- a/public/app/features/dashboard-scene/sharing/SharePanelEmbedTab.tsx +++ b/public/app/features/dashboard-scene/sharing/SharePanelEmbedTab.tsx @@ -50,29 +50,33 @@ function SharePanelEmbedTabRenderer({ model }: SceneComponentProps<SharePanelEmb }} range={timeRangeState.state.value} dashboard={{ uid: dashUid ?? '', time: timeRangeState.state.value }} - buildIframe={buildIframe} + buildIframe={getIframeBuilder(dash)} /> ); } -function buildIframe( - useCurrentTimeRange: boolean, - dashboardUid: string, - selectedTheme?: string, - panel?: { timeFrom?: string; id: number }, - range?: TimeRange -) { - const params = buildParams({ useCurrentTimeRange, selectedTheme, panel, range }); - const panelId = params.get('editPanel') ?? params.get('viewPanel') ?? ''; - params.set('panelId', panelId); - params.delete('editPanel'); - params.delete('viewPanel'); +const getIframeBuilder = + (dashboard: DashboardScene) => + ( + useCurrentTimeRange: boolean, + _dashboardUid: string, + selectedTheme?: string, + panel?: { timeFrom?: string; id: number }, + range?: TimeRange + ) => { + const params = buildParams({ useCurrentTimeRange, selectedTheme, panel, range }); + const panelId = params.get('editPanel') ?? params.get('viewPanel') ?? ''; + params.set('panelId', panelId); + params.delete('editPanel'); + params.delete('viewPanel'); + params.set('__feature.dashboardSceneSolo', 'true'); - const soloUrl = getDashboardUrl({ - absolute: true, - soloRoute: true, - uid: dashboardUid, - currentQueryParams: params.toString(), - }); - return `<iframe src="${soloUrl}" width="450" height="200" frameborder="0"></iframe>`; -} + const soloUrl = getDashboardUrl({ + absolute: true, + soloRoute: true, + uid: dashboard.state.uid, + slug: dashboard.state.meta.slug, + currentQueryParams: params.toString(), + }); + return `<iframe src="${soloUrl}" width="450" height="200" frameborder="0"></iframe>`; + }; diff --git a/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx b/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx index c65584657f9ea..7fae7cf163561 100644 --- a/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx @@ -15,16 +15,13 @@ import { DashboardInteractions } from '../utils/interactions'; import { SceneShareTabState } from './types'; -const SNAPSHOTS_API_ENDPOINT = '/api/snapshots'; - const getExpireOptions = () => { const DEFAULT_EXPIRE_OPTION: SelectableValue<number> = { - label: t('share-modal.snapshot.expire-never', `Never`), - value: 0, + label: t('share-modal.snapshot.expire-week', '1 Week'), + value: 60 * 60 * 24 * 7, }; return [ - DEFAULT_EXPIRE_OPTION, { label: t('share-modal.snapshot.expire-hour', '1 Hour'), value: 60 * 60, @@ -33,13 +30,18 @@ const getExpireOptions = () => { label: t('share-modal.snapshot.expire-day', '1 Day'), value: 60 * 60 * 24, }, + DEFAULT_EXPIRE_OPTION, { - label: t('share-modal.snapshot.expire-week', '7 Days'), - value: 60 * 60 * 24 * 7, + label: t('share-modal.snapshot.expire-never', `Never`), + value: 0, }, ]; }; +const getDefaultExpireOption = () => { + return getExpireOptions()[2]; +}; + export interface ShareSnapshotTabState extends SceneShareTabState { panelRef?: SceneObjectRef<VizPanel>; dashboardRef: SceneObjectRef<DashboardScene>; @@ -57,7 +59,7 @@ export class ShareSnapshotTab extends SceneObjectBase<ShareSnapshotTabState> { super({ ...state, snapshotName: state.dashboardRef.resolve().state.title, - selectedExpireOption: getExpireOptions()[0], + selectedExpireOption: getDefaultExpireOption(), }); this.addActivationHandler(() => { @@ -121,8 +123,7 @@ export class ShareSnapshotTab extends SceneObjectBase<ShareSnapshotTabState> { }; try { - const results: { deleteUrl: string; url: string } = await getBackendSrv().post(SNAPSHOTS_API_ENDPOINT, cmdData); - return results; + return await getDashboardSnapshotSrv().create(cmdData); } finally { if (external) { DashboardInteractions.publishSnapshotClicked({ expires: cmdData.expires }); @@ -210,7 +211,7 @@ function ShareSnapshoTabRenderer({ model }: SceneComponentProps<ShareSnapshotTab </Button> )} <Button variant="primary" disabled={snapshotResult.loading} onClick={() => createSnapshot()}> - <Trans i18nKey="share-modal.snapshot.local-button">Local Snapshot</Trans> + <Trans i18nKey="share-modal.snapshot.local-button">Publish Snapshot</Trans> </Button> </Modal.ButtonRow> </> diff --git a/public/app/features/dashboard-scene/sharing/public-dashboards/utils.ts b/public/app/features/dashboard-scene/sharing/public-dashboards/utils.ts index 763469c786cbe..5bd7a46b80652 100644 --- a/public/app/features/dashboard-scene/sharing/public-dashboards/utils.ts +++ b/public/app/features/dashboard-scene/sharing/public-dashboards/utils.ts @@ -65,7 +65,7 @@ function rowTypes(gridRow: SceneGridRow) { } function panelDatasourceTypes(gridItem: SceneGridItemLike) { - let vizPanel: VizPanel | undefined; + let vizPanel: VizPanel | LibraryVizPanel | undefined; if (gridItem instanceof SceneGridItem) { if (gridItem.state.body instanceof LibraryVizPanel) { vizPanel = gridItem.state.body.state.panel; diff --git a/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx b/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx new file mode 100644 index 0000000000000..e875ee2f06adb --- /dev/null +++ b/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx @@ -0,0 +1,63 @@ +// Libraries +import React, { useEffect } from 'react'; + +import { Alert, Spinner } from '@grafana/ui'; +import PageLoader from 'app/core/components/PageLoader/PageLoader'; +import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { DashboardPageRouteParams } from 'app/features/dashboard/containers/types'; +import { DashboardRoutes } from 'app/types'; + +import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager'; +import { DashboardScene } from '../scene/DashboardScene'; + +import { useSoloPanel } from './useSoloPanel'; + +export interface Props extends GrafanaRouteComponentProps<DashboardPageRouteParams, { panelId: string }> {} + +/** + * Used for iframe embedding and image rendering of single panels + */ +export function SoloPanelPage({ match, queryParams }: Props) { + const stateManager = getDashboardScenePageStateManager(); + const { dashboard } = stateManager.useState(); + + useEffect(() => { + stateManager.loadDashboard({ uid: match.params.uid!, route: DashboardRoutes.Embedded }); + return () => stateManager.clearState(); + }, [stateManager, match, queryParams]); + + if (!queryParams.panelId) { + return <EntityNotFound entity="Panel" />; + } + + if (!dashboard) { + return <PageLoader />; + } + + return <SoloPanelRenderer dashboard={dashboard} panelId={queryParams.panelId} />; +} + +export default SoloPanelPage; + +export function SoloPanelRenderer({ dashboard, panelId }: { dashboard: DashboardScene; panelId: string }) { + const [panel, error] = useSoloPanel(dashboard, panelId); + + if (error) { + return <Alert title={error} />; + } + + if (!panel) { + return ( + <span> + Loading <Spinner /> + </span> + ); + } + + return ( + <div className="panel-solo"> + <panel.Component model={panel} /> + </div> + ); +} diff --git a/public/app/features/dashboard-scene/solo/useSoloPanel.ts b/public/app/features/dashboard-scene/solo/useSoloPanel.ts new file mode 100644 index 0000000000000..9ca7d2139bd0a --- /dev/null +++ b/public/app/features/dashboard-scene/solo/useSoloPanel.ts @@ -0,0 +1,84 @@ +import { useState, useEffect } from 'react'; + +import { VizPanel, SceneObject, SceneGridRow, getUrlSyncManager } from '@grafana/scenes'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem'; +import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; +import { DashboardRepeatsProcessedEvent } from '../scene/types'; +import { findVizPanelByKey, isPanelClone } from '../utils/utils'; + +export function useSoloPanel(dashboard: DashboardScene, panelId: string): [VizPanel | undefined, string | undefined] { + const [panel, setPanel] = useState<VizPanel>(); + const [error, setError] = useState<string | undefined>(); + + useEffect(() => { + getUrlSyncManager().initSync(dashboard); + + const cleanUp = dashboard.activate(); + + const panel = findVizPanelByKey(dashboard, panelId); + if (panel) { + activateParents(panel); + setPanel(panel); + } else if (isPanelClone(panelId)) { + findRepeatClone(dashboard, panelId).then((panel) => { + if (panel) { + setPanel(panel); + } else { + setError('Panel not found'); + } + }); + } + + return cleanUp; + }, [dashboard, panelId]); + + return [panel, error]; +} + +function activateParents(panel: VizPanel) { + let parent = panel.parent; + + while (parent && !parent.isActive) { + parent.activate(); + parent = parent.parent; + } +} + +function findRepeatClone(dashboard: DashboardScene, panelId: string): Promise<VizPanel | undefined> { + return new Promise((resolve) => { + dashboard.subscribeToEvent(DashboardRepeatsProcessedEvent, () => { + const panel = findVizPanelByKey(dashboard, panelId); + if (panel) { + resolve(panel); + } else { + // If rows are repeated they could add new panel repeaters that needs to be activated + activateAllRepeaters(dashboard.state.body); + } + }); + + activateAllRepeaters(dashboard.state.body); + }); +} + +function activateAllRepeaters(layout: SceneObject) { + layout.forEachChild((child) => { + if (child instanceof PanelRepeaterGridItem && !child.isActive) { + child.activate(); + return; + } + + if (child instanceof SceneGridRow && child.state.$behaviors) { + for (const behavior of child.state.$behaviors) { + if (behavior instanceof RowRepeaterBehavior && !child.isActive) { + child.activate(); + break; + } + } + + // Activate any panel PanelRepeaterGridItem inside the row + activateAllRepeaters(child); + } + }); +} diff --git a/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.test.ts b/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.test.ts index 2cf0bf38ddf02..be5d199260a53 100644 --- a/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.test.ts +++ b/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.test.ts @@ -3,17 +3,20 @@ import { behaviors, SceneGridItem, SceneGridLayout, - SceneRefreshPicker, SceneQueryRunner, SceneTimeRange, VizPanel, - SceneTimePicker, + SceneDataTransformer, + SceneDataLayers, } from '@grafana/scenes'; import { DashboardCursorSync } from '@grafana/schema'; +import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; +import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer'; +import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardControls } from '../scene/DashboardControls'; -import { DashboardLinksControls } from '../scene/DashboardLinksControls'; import { DashboardScene } from '../scene/DashboardScene'; +import { NEW_LINK } from '../settings/links/utils'; import { DashboardModelCompatibilityWrapper } from './DashboardModelCompatibilityWrapper'; @@ -27,15 +30,34 @@ describe('DashboardModelCompatibilityWrapper', () => { expect(wrapper.editable).toBe(false); expect(wrapper.graphTooltip).toBe(DashboardCursorSync.Off); expect(wrapper.tags).toEqual(['hello-tag']); + expect(wrapper.links).toEqual([NEW_LINK]); expect(wrapper.time.from).toBe('now-6h'); expect(wrapper.timezone).toBe('America/New_York'); expect(wrapper.weekStart).toBe('friday'); - expect(wrapper.timepicker.refresh_intervals).toEqual(['1s']); + expect(wrapper.timepicker.refresh_intervals![0]).toEqual('5s'); expect(wrapper.timepicker.hidden).toEqual(true); + expect(wrapper.panels).toHaveLength(5); - (scene.state.controls![0] as DashboardControls).setState({ - hideTimeControls: false, - }); + expect(wrapper.annotations.list).toHaveLength(1); + expect(wrapper.annotations.list[0].name).toBe('test'); + + expect(wrapper.panels[0].targets).toHaveLength(1); + expect(wrapper.panels[0].targets[0]).toEqual({ refId: 'A' }); + expect(wrapper.panels[1].targets).toHaveLength(0); + expect(wrapper.panels[2].targets).toHaveLength(1); + expect(wrapper.panels[2].targets).toEqual([{ refId: 'A', panelId: 1 }]); + expect(wrapper.panels[3].targets).toHaveLength(1); + expect(wrapper.panels[3].targets[0]).toEqual({ refId: 'A' }); + expect(wrapper.panels[4].targets).toHaveLength(1); + expect(wrapper.panels[4].targets).toEqual([{ refId: 'A', panelId: 1 }]); + + expect(wrapper.panels[0].datasource).toEqual({ uid: 'gdev-testdata', type: 'grafana-testdata-datasource' }); + expect(wrapper.panels[1].datasource).toEqual(undefined); + expect(wrapper.panels[2].datasource).toEqual({ uid: SHARED_DASHBOARD_QUERY, type: 'datasource' }); + expect(wrapper.panels[3].datasource).toEqual({ uid: 'gdev-testdata', type: 'grafana-testdata-datasource' }); + expect(wrapper.panels[4].datasource).toEqual({ uid: SHARED_DASHBOARD_QUERY, type: 'datasource' }); + + scene.state.controls!.setState({ hideTimeControls: false }); const wrapper2 = new DashboardModelCompatibilityWrapper(scene); expect(wrapper2.timepicker.hidden).toEqual(false); @@ -70,16 +92,34 @@ describe('DashboardModelCompatibilityWrapper', () => { it('Can get fake panel with getPanelById', () => { const { wrapper } = setup(); - expect(wrapper.getPanelById(1)!.title).toBe('Panel A'); - expect(wrapper.getPanelById(2)!.title).toBe('Panel B'); + expect(wrapper.getPanelById(1)!.title).toBe('Panel with a regular data source query'); + expect(wrapper.getPanelById(2)!.title).toBe('Panel with no queries'); }); it('Can remove panel', () => { const { wrapper, scene } = setup(); + expect((scene.state.body as SceneGridLayout).state.children.length).toBe(5); + wrapper.removePanel(wrapper.getPanelById(1)!); - expect((scene.state.body as SceneGridLayout).state.children.length).toBe(1); + expect((scene.state.body as SceneGridLayout).state.children.length).toBe(4); + }); + + it('Checks if annotations are editable', () => { + const { wrapper, scene } = setup(); + + expect(wrapper.canEditAnnotations()).toBe(true); + expect(wrapper.canEditAnnotations(scene.state.uid)).toBe(false); + + scene.setState({ + meta: { + canEdit: false, + canMakeEditable: false, + }, + }); + + expect(wrapper.canEditAnnotations()).toBe(false); }); }); @@ -88,44 +128,118 @@ function setup() { title: 'hello', description: 'hello description', tags: ['hello-tag'], + links: [NEW_LINK], uid: 'dash-1', editable: false, + meta: { + canEdit: true, + canMakeEditable: true, + annotationsPermissions: { + organization: { + canEdit: true, + canAdd: true, + canDelete: true, + }, + dashboard: { + canEdit: false, + canAdd: false, + canDelete: false, + }, + }, + }, $timeRange: new SceneTimeRange({ weekStart: 'friday', timeZone: 'America/New_York', }), - controls: [ - new DashboardControls({ - variableControls: [], - linkControls: new DashboardLinksControls({}), - timeControls: [ - new SceneTimePicker({}), - new SceneRefreshPicker({ - intervals: ['1s'], - }), - ], - hideTimeControls: true, - }), - ], + $data: new SceneDataLayers({ + layers: [ + new DashboardAnnotationsDataLayer({ + key: `annotations-test`, + query: { + enable: true, + iconColor: 'red', + name: 'test', + }, + name: 'test', + isEnabled: true, + isHidden: false, + }), + new AlertStatesDataLayer({ + key: 'alert-states', + name: 'Alert States', + }), + ], + }), + controls: new DashboardControls({ + hideTimeControls: true, + }), body: new SceneGridLayout({ children: [ new SceneGridItem({ key: 'griditem-1', x: 0, body: new VizPanel({ - title: 'Panel A', + title: 'Panel with a regular data source query', key: 'panel-1', pluginId: 'table', - $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), + $data: new SceneQueryRunner({ + key: 'data-query-runner', + queries: [{ refId: 'A' }], + datasource: { uid: 'gdev-testdata', type: 'grafana-testdata-datasource' }, + }), }), }), new SceneGridItem({ body: new VizPanel({ - title: 'Panel B', + title: 'Panel with no queries', key: 'panel-2', pluginId: 'table', }), }), + + new SceneGridItem({ + body: new VizPanel({ + title: 'Panel with a shared query', + key: 'panel-3', + pluginId: 'table', + $data: new SceneQueryRunner({ + key: 'data-query-runner', + queries: [{ refId: 'A', panelId: 1 }], + datasource: { uid: SHARED_DASHBOARD_QUERY, type: 'datasource' }, + }), + }), + }), + + new SceneGridItem({ + body: new VizPanel({ + title: 'Panel with a regular data source query and transformations', + key: 'panel-4', + pluginId: 'table', + $data: new SceneDataTransformer({ + $data: new SceneQueryRunner({ + key: 'data-query-runner', + queries: [{ refId: 'A' }], + datasource: { uid: 'gdev-testdata', type: 'grafana-testdata-datasource' }, + }), + transformations: [], + }), + }), + }), + new SceneGridItem({ + body: new VizPanel({ + title: 'Panel with a shared query and transformations', + key: 'panel-4', + pluginId: 'table', + $data: new SceneDataTransformer({ + $data: new SceneQueryRunner({ + key: 'data-query-runner', + queries: [{ refId: 'A', panelId: 1 }], + datasource: { uid: SHARED_DASHBOARD_QUERY, type: 'datasource' }, + }), + transformations: [], + }), + }), + }), ], }), }); diff --git a/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts b/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts index eb17a92a148ba..21b83ce910bf9 100644 --- a/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts +++ b/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts @@ -4,18 +4,20 @@ import { AnnotationQuery, DashboardCursorSync, dateTimeFormat, DateTimeInput, Ev import { TimeRangeUpdatedEvent } from '@grafana/runtime'; import { behaviors, - SceneDataTransformer, + SceneDataLayers, sceneGraph, SceneGridItem, SceneGridLayout, SceneGridRow, + SceneObject, VizPanel, } from '@grafana/scenes'; import { DashboardScene } from '../scene/DashboardScene'; +import { dataLayersToAnnotations } from '../serialization/dataLayersToAnnotations'; -import { dashboardSceneGraph } from './dashboardSceneGraph'; -import { findVizPanelByKey, getPanelIdForVizPanel, getVizPanelKeyForPanelId } from './utils'; +import { PanelModelCompatibilityWrapper } from './PanelModelCompatibilityWrapper'; +import { findVizPanelByKey, getVizPanelKeyForPanelId } from './utils'; /** * Will move this to make it the main way we remain somewhat compatible with getDashboardSrv().getCurrent @@ -27,6 +29,9 @@ export class DashboardModelCompatibilityWrapper { public constructor(private _scene: DashboardScene) { const timeRange = sceneGraph.getTimeRange(_scene); + // Copied from DashboardModel, as this function is passed around + this.formatDate = this.formatDate.bind(this); + this._subs.add( timeRange.subscribeToState((state, prev) => { if (state.value !== prev.value) { @@ -62,8 +67,8 @@ export class DashboardModelCompatibilityWrapper { public get timepicker() { return { - refresh_intervals: dashboardSceneGraph.getRefreshPicker(this._scene)?.state.intervals, - hidden: dashboardSceneGraph.getDashboardControls(this._scene)?.state.hideTimeControls ?? false, + refresh_intervals: this._scene.state.controls!.state.refreshPicker.state.intervals, + hidden: this._scene.state.controls!.state.hideTimeControls ?? false, }; } @@ -79,6 +84,10 @@ export class DashboardModelCompatibilityWrapper { return this._scene.state.tags; } + public get links() { + return this._scene.state.links; + } + public get meta() { return this._scene.state.meta; } @@ -91,12 +100,24 @@ export class DashboardModelCompatibilityWrapper { }; } + public get panels() { + const panels = findAllObjects(this._scene, (o) => { + return Boolean(o instanceof VizPanel); + }); + return panels.map((p) => new PanelModelCompatibilityWrapper(p as VizPanel)); + } + /** * Used from from timeseries migration handler to migrate time regions to dashboard annotations */ public get annotations(): { list: AnnotationQuery[] } { - console.error('Scenes DashboardModelCompatibilityWrapper.annotations not implemented (yet)'); - return { list: [] }; + const annotations: { list: AnnotationQuery[] } = { list: [] }; + + if (this._scene.state.$data instanceof SceneDataLayers) { + annotations.list = dataLayersToAnnotations(this._scene.state.$data.state.layers); + } + + return annotations; } public getTimezone() { @@ -135,10 +156,10 @@ export class DashboardModelCompatibilityWrapper { }); } - public getPanelById(id: number): PanelCompatibilityWrapper | null { + public getPanelById(id: number): PanelModelCompatibilityWrapper | null { const vizPanel = findVizPanelByKey(this._scene, getVizPanelKeyForPanelId(id)); if (vizPanel) { - return new PanelCompatibilityWrapper(vizPanel); + return new PanelModelCompatibilityWrapper(vizPanel); } return null; @@ -147,7 +168,7 @@ export class DashboardModelCompatibilityWrapper { /** * Mainly implemented to support Getting started panel's dissmis button. */ - public removePanel(panel: PanelCompatibilityWrapper) { + public removePanel(panel: PanelModelCompatibilityWrapper) { const vizPanel = findVizPanelByKey(this._scene, getVizPanelKeyForPanelId(panel.id)); if (!vizPanel) { console.error('Trying to remove a panel that was not found in scene', panel); @@ -190,8 +211,15 @@ export class DashboardModelCompatibilityWrapper { } public canEditAnnotations(dashboardUID?: string) { - // TOOD - return false; + if (!this._scene.canEditDashboard()) { + return false; + } + + if (dashboardUID) { + return Boolean(this._scene.state.meta.annotationsPermissions?.dashboard.canEdit); + } + + return Boolean(this._scene.state.meta.annotationsPermissions?.organization.canEdit); } public panelInitialized() {} @@ -200,47 +228,21 @@ export class DashboardModelCompatibilityWrapper { this.events.removeAllListeners(); this._subs.unsubscribe(); } -} - -class PanelCompatibilityWrapper { - constructor(private _vizPanel: VizPanel) {} - - public get id() { - const id = getPanelIdForVizPanel(this._vizPanel); - - if (isNaN(id)) { - console.error('VizPanel key could not be translated to a legacy numeric panel id', this._vizPanel); - return 0; - } - - return id; - } - - public get type() { - return this._vizPanel.state.pluginId; - } - public get title() { - return this._vizPanel.state.title; + public hasUnsavedChanges() { + return this._scene.state.isDirty; } +} - public get transformations() { - if (this._vizPanel.state.$data instanceof SceneDataTransformer) { - return this._vizPanel.state.$data.state.transformations; +function findAllObjects(root: SceneObject, check: (o: SceneObject) => boolean) { + let result: SceneObject[] = []; + root.forEachChild((child) => { + if (check(child)) { + result.push(child); + } else { + result = result.concat(findAllObjects(child, check)); } + }); - return []; - } - - public refresh() { - console.error('Scenes PanelCompatibilityWrapper.refresh no implemented (yet)'); - } - - public render() { - console.error('Scenes PanelCompatibilityWrapper.render no implemented (yet)'); - } - - public getQueryRunner() { - console.error('Scenes PanelCompatibilityWrapper.getQueryRunner no implemented (yet)'); - } + return result; } diff --git a/public/app/features/dashboard-scene/utils/PanelModelCompatibilityWrapper.ts b/public/app/features/dashboard-scene/utils/PanelModelCompatibilityWrapper.ts new file mode 100644 index 0000000000000..a23b3081a517c --- /dev/null +++ b/public/app/features/dashboard-scene/utils/PanelModelCompatibilityWrapper.ts @@ -0,0 +1,70 @@ +import { PanelModel } from '@grafana/data'; +import { SceneDataTransformer, VizPanel } from '@grafana/scenes'; +import { DataSourceRef, DataTransformerConfig } from '@grafana/schema'; + +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; + +import { getPanelIdForVizPanel, getQueryRunnerFor } from './utils'; + +export class PanelModelCompatibilityWrapper implements PanelModel { + constructor(private _vizPanel: VizPanel) {} + + public get id() { + const id = getPanelIdForVizPanel( + this._vizPanel.parent instanceof LibraryVizPanel ? this._vizPanel.parent : this._vizPanel + ); + + if (isNaN(id)) { + console.error('VizPanel key could not be translated to a legacy numeric panel id', this._vizPanel); + return 0; + } + + return id; + } + + public get description() { + return this._vizPanel.state.description; + } + + public get type() { + return this._vizPanel.state.pluginId; + } + + public get title() { + return this._vizPanel.state.title; + } + + public get transformations() { + if (this._vizPanel.state.$data instanceof SceneDataTransformer) { + return this._vizPanel.state.$data.state.transformations as DataTransformerConfig[]; + } + + return []; + } + + public get targets() { + const queryRunner = getQueryRunnerFor(this._vizPanel); + if (!queryRunner) { + return []; + } + + return queryRunner.state.queries; + } + + public get datasource(): DataSourceRef | null | undefined { + const queryRunner = getQueryRunnerFor(this._vizPanel); + return queryRunner?.state.datasource; + } + + public get options() { + return this._vizPanel.state.options; + } + + public get fieldConfig() { + return this._vizPanel.state.fieldConfig; + } + + public get pluginVersion() { + return this._vizPanel.state.pluginVersion; + } +} diff --git a/public/app/features/dashboard-scene/utils/createPanelDataProvider.ts b/public/app/features/dashboard-scene/utils/createPanelDataProvider.ts index 12a6d721db2ac..774ff4c31c38e 100644 --- a/public/app/features/dashboard-scene/utils/createPanelDataProvider.ts +++ b/public/app/features/dashboard-scene/utils/createPanelDataProvider.ts @@ -1,9 +1,6 @@ import { config } from '@grafana/runtime'; import { SceneDataProvider, SceneDataTransformer, SceneQueryRunner } from '@grafana/scenes'; import { PanelModel } from 'app/features/dashboard/state'; -import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; - -import { ShareQueryDataProvider } from '../scene/ShareQueryDataProvider'; export function createPanelDataProvider(panel: PanelModel): SceneDataProvider | undefined { // Skip setting query runner for panels without queries @@ -18,27 +15,21 @@ export function createPanelDataProvider(panel: PanelModel): SceneDataProvider | let dataProvider: SceneDataProvider | undefined = undefined; - if (panel.datasource?.uid === SHARED_DASHBOARD_QUERY) { - dataProvider = new ShareQueryDataProvider({ query: panel.targets[0] }); - } else { - dataProvider = new SceneQueryRunner({ - datasource: panel.datasource ?? undefined, - queries: panel.targets, - maxDataPoints: panel.maxDataPoints ?? undefined, - maxDataPointsFromWidth: true, - dataLayerFilter: { - panelId: panel.id, - }, - }); - } + dataProvider = new SceneQueryRunner({ + datasource: panel.datasource ?? undefined, + queries: panel.targets, + maxDataPoints: panel.maxDataPoints ?? undefined, + maxDataPointsFromWidth: true, + cacheTimeout: panel.cacheTimeout, + queryCachingTTL: panel.queryCachingTTL, + dataLayerFilter: { + panelId: panel.id, + }, + }); // Wrap inner data provider in a data transformer - if (panel.transformations?.length) { - dataProvider = new SceneDataTransformer({ - $data: dataProvider, - transformations: panel.transformations, - }); - } - - return dataProvider; + return new SceneDataTransformer({ + $data: dataProvider, + transformations: panel.transformations || [], + }); } diff --git a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts index 44f045df7a88f..bc50b5d889f75 100644 --- a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts +++ b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts @@ -1,78 +1,232 @@ import { + SceneDataLayers, SceneGridItem, SceneGridLayout, + SceneGridRow, SceneQueryRunner, - SceneRefreshPicker, - SceneTimePicker, SceneTimeRange, VizPanel, + behaviors, } from '@grafana/scenes'; +import { DashboardCursorSync } from '@grafana/schema'; +import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer'; +import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardControls } from '../scene/DashboardControls'; -import { DashboardLinksControls } from '../scene/DashboardLinksControls'; import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; +import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; +import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem'; -import { dashboardSceneGraph } from './dashboardSceneGraph'; +import { dashboardSceneGraph, getNextPanelId } from './dashboardSceneGraph'; +import { findVizPanelByKey } from './utils'; describe('dashboardSceneGraph', () => { - describe('getTimePicker', () => { - it('should return null if no time picker', () => { - const scene = buildTestScene({ - controls: [ - new DashboardControls({ - variableControls: [], - linkControls: new DashboardLinksControls({}), - timeControls: [], - }), - ], + describe('getPanelLinks', () => { + it('should return null if no links object defined', () => { + const scene = buildTestScene(); + const panelWithNoLinks = findVizPanelByKey(scene, 'panel-1')!; + expect(dashboardSceneGraph.getPanelLinks(panelWithNoLinks)).toBeNull(); + }); + + it('should resolve VizPanelLinks object', () => { + const scene = buildTestScene(); + const panelWithNoLinks = findVizPanelByKey(scene, 'panel-with-links')!; + expect(dashboardSceneGraph.getPanelLinks(panelWithNoLinks)).toBeInstanceOf(VizPanelLinks); + }); + }); + + describe('getVizPanels', () => { + let scene: DashboardScene; + + beforeEach(async () => { + scene = buildTestScene(); + }); + + it('Should return all panels', () => { + const vizPanels = dashboardSceneGraph.getVizPanels(scene); + + expect(vizPanels.length).toBe(6); + expect(vizPanels[0].state.title).toBe('Panel A'); + expect(vizPanels[1].state.title).toBe('Panel B'); + expect(vizPanels[2].state.title).toBe('Panel C'); + expect(vizPanels[3].state.title).toBe('Panel D'); + expect(vizPanels[4].state.title).toBe('Panel E'); + expect(vizPanels[5].state.title).toBe('Panel F'); + }); + + it('Should return an empty array when scene has no panels', () => { + scene.setState({ + body: new SceneGridLayout({ children: [] }), }); - const timePicker = dashboardSceneGraph.getTimePicker(scene); - expect(timePicker).toBeNull(); + const vizPanels = dashboardSceneGraph.getVizPanels(scene); + + expect(vizPanels.length).toBe(0); }); + }); - it('should return time picker', () => { - const scene = buildTestScene(); - const timePicker = dashboardSceneGraph.getTimePicker(scene); - expect(timePicker).not.toBeNull(); + describe('getDataLayers', () => { + let scene: DashboardScene; + + beforeEach(async () => { + scene = buildTestScene(); + }); + + it('should return the scene data layers', () => { + const dataLayers = dashboardSceneGraph.getDataLayers(scene); + + expect(dataLayers).toBeInstanceOf(SceneDataLayers); + expect(dataLayers?.state.layers.length).toBe(2); + }); + + it('should throw if there are no scene data layers', () => { + scene.setState({ + $data: undefined, + }); + + expect(() => dashboardSceneGraph.getDataLayers(scene)).toThrow('SceneDataLayers not found'); }); }); - describe('getRefreshPicker', () => { - it('should return null if no refresh picker', () => { + describe('getNextPanelId', () => { + it('should get next panel id in a simple 3 panel layout', () => { const scene = buildTestScene({ - controls: [ - new DashboardControls({ - variableControls: [], - linkControls: new DashboardLinksControls({}), - timeControls: [], - }), - ], + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + body: new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + }), + }), + new SceneGridItem({ + body: new VizPanel({ + title: 'Panel B', + key: 'panel-2', + pluginId: 'table', + }), + }), + new SceneGridItem({ + body: new VizPanel({ + title: 'Panel C', + key: 'panel-3', + pluginId: 'table', + }), + }), + ], + }), + }); + + const id = getNextPanelId(scene); + + expect(id).toBe(4); + }); + + it('should take library panels, panels in rows and panel repeaters into account', () => { + const scene = buildTestScene({ + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + body: new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + }), + }), + new SceneGridItem({ + body: new LibraryVizPanel({ + uid: 'uid', + name: 'LibPanel', + title: 'Library Panel', + panelKey: 'panel-2', + }), + }), + new SceneGridItem({ + body: new VizPanel({ + title: 'Panel C', + key: 'panel-2-clone-1', + pluginId: 'table', + }), + }), + new PanelRepeaterGridItem({ + source: new VizPanel({ + title: 'Panel C', + key: 'panel-4', + pluginId: 'table', + }), + variableName: 'repeat', + repeatedPanels: [], + repeatDirection: 'h', + maxPerRow: 1, + }), + new SceneGridRow({ + key: 'key', + title: 'row', + children: [ + new SceneGridItem({ + body: new VizPanel({ + title: 'Panel E', + key: 'panel-2-clone-2', + pluginId: 'table', + }), + }), + new SceneGridItem({ + body: new LibraryVizPanel({ + uid: 'uid', + name: 'LibPanel', + title: 'Library Panel', + panelKey: 'panel-3', + }), + }), + ], + }), + ], + }), }); - const refreshPicker = dashboardSceneGraph.getRefreshPicker(scene); - expect(refreshPicker).toBeNull(); + const id = getNextPanelId(scene); + + expect(id).toBe(5); }); - it('should return refresh picker', () => { + it('should get next panel id in a layout with rows', () => { const scene = buildTestScene(); - const refreshPicker = dashboardSceneGraph.getRefreshPicker(scene); - expect(refreshPicker).not.toBeNull(); + const id = getNextPanelId(scene); + + expect(id).toBe(3); }); - }); - describe('getDashboardControls', () => { - it('should return null if no dashboard controls', () => { - const scene = buildTestScene({ controls: [] }); + it('should return 1 if no panels are found', () => { + const scene = buildTestScene({ body: new SceneGridLayout({ children: [] }) }); + const id = getNextPanelId(scene); - const dashboardControls = dashboardSceneGraph.getDashboardControls(scene); - expect(dashboardControls).toBeNull(); + expect(id).toBe(1); }); - it('should return dashboard controls', () => { + it('should throw an error if body is not SceneGridLayout', () => { + const scene = buildTestScene({ body: undefined }); + + expect(() => getNextPanelId(scene)).toThrow('Dashboard body is not a SceneGridLayout'); + }); + }); + + describe('getCursorSync', () => { + it('should return cursor sync behavior', () => { const scene = buildTestScene(); - const dashboardControls = dashboardSceneGraph.getDashboardControls(scene); - expect(dashboardControls).not.toBeNull(); + const cursorSync = dashboardSceneGraph.getCursorSync(scene); + + expect(cursorSync).toBeInstanceOf(behaviors.CursorSync); + }); + + it('should return undefined if no cursor sync behavior', () => { + const scene = buildTestScene({ $behaviors: [] }); + const cursorSync = dashboardSceneGraph.getCursorSync(scene); + + expect(cursorSync).toBeUndefined(); }); }); }); @@ -82,18 +236,32 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) { title: 'hello', uid: 'dash-1', $timeRange: new SceneTimeRange({}), - controls: [ - new DashboardControls({ - variableControls: [], - linkControls: new DashboardLinksControls({}), - timeControls: [ - new SceneTimePicker({}), - new SceneRefreshPicker({ - intervals: ['1s'], - }), - ], + controls: new DashboardControls({}), + $behaviors: [ + new behaviors.CursorSync({ + sync: DashboardCursorSync.Crosshair, }), ], + $data: new SceneDataLayers({ + layers: [ + new DashboardAnnotationsDataLayer({ + key: `annotation`, + query: { + enable: true, + hide: false, + iconColor: 'red', + name: 'a', + }, + name: 'a', + isEnabled: true, + isHidden: false, + }), + new AlertStatesDataLayer({ + key: 'alert-states', + name: 'Alert States', + }), + ], + }), body: new SceneGridLayout({ children: [ new SceneGridItem({ @@ -115,12 +283,41 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) { }), new SceneGridItem({ body: new VizPanel({ - title: 'Panel B', + title: 'Panel C', key: 'panel-2-clone-1', pluginId: 'table', $data: new SceneQueryRunner({ key: 'data-query-runner2', queries: [{ refId: 'A' }] }), }), }), + new SceneGridItem({ + body: new VizPanel({ + title: 'Panel D', + key: 'panel-with-links', + pluginId: 'table', + $data: new SceneQueryRunner({ key: 'data-query-runner3', queries: [{ refId: 'A' }] }), + titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })], + }), + }), + new SceneGridRow({ + key: 'key', + title: 'row', + children: [ + new SceneGridItem({ + body: new VizPanel({ + title: 'Panel E', + key: 'panel-2-clone-2', + pluginId: 'table', + }), + }), + new SceneGridItem({ + body: new VizPanel({ + title: 'Panel F', + key: 'panel-2-clone-2', + pluginId: 'table', + }), + }), + ], + }), ], }), ...overrides, diff --git a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts index 666b764b00129..6980a1c525359 100644 --- a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts +++ b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts @@ -1,43 +1,170 @@ -import { SceneTimePicker, SceneRefreshPicker } from '@grafana/scenes'; +import { + VizPanel, + SceneGridItem, + SceneGridRow, + SceneDataLayers, + sceneGraph, + SceneGridLayout, + behaviors, +} from '@grafana/scenes'; -import { DashboardControls } from '../scene/DashboardControls'; import { DashboardScene } from '../scene/DashboardScene'; +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; +import { VizPanelLinks } from '../scene/PanelLinks'; +import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem'; + +import { getPanelIdForLibraryVizPanel, getPanelIdForVizPanel } from './utils'; function getTimePicker(scene: DashboardScene) { - const dashboardControls = getDashboardControls(scene); + return scene.state.controls?.state.timePicker; +} - if (dashboardControls) { - const timePicker = dashboardControls.state.timeControls.find((c) => c instanceof SceneTimePicker); - if (timePicker && timePicker instanceof SceneTimePicker) { - return timePicker; - } +function getRefreshPicker(scene: DashboardScene) { + return scene.state.controls?.state.refreshPicker; +} + +function getPanelLinks(panel: VizPanel) { + if ( + panel.state.titleItems && + Array.isArray(panel.state.titleItems) && + panel.state.titleItems[0] instanceof VizPanelLinks + ) { + return panel.state.titleItems[0]; } return null; } -function getRefreshPicker(scene: DashboardScene) { - const dashboardControls = getDashboardControls(scene); +function getVizPanels(scene: DashboardScene): VizPanel[] { + const panels: VizPanel[] = []; - if (dashboardControls) { - for (const control of dashboardControls.state.timeControls) { - if (control instanceof SceneRefreshPicker) { - return control; + scene.state.body.forEachChild((child) => { + if (child instanceof SceneGridItem) { + if (child.state.body instanceof VizPanel) { + panels.push(child.state.body); } + } else if (child instanceof SceneGridRow) { + child.forEachChild((child) => { + if (child instanceof SceneGridItem) { + if (child.state.body instanceof VizPanel) { + panels.push(child.state.body); + } + } + }); } + }); + + return panels; +} + +function getDataLayers(scene: DashboardScene): SceneDataLayers { + const data = sceneGraph.getData(scene); + + if (!(data instanceof SceneDataLayers)) { + throw new Error('SceneDataLayers not found'); } - return null; + + return data; } -function getDashboardControls(scene: DashboardScene) { - if (scene.state.controls?.[0] instanceof DashboardControls) { - return scene.state.controls[0]; +export function getCursorSync(scene: DashboardScene) { + const cursorSync = scene.state.$behaviors?.find((b) => b instanceof behaviors.CursorSync); + + if (cursorSync instanceof behaviors.CursorSync) { + return cursorSync; } - return null; + + return; } +export function getNextPanelId(dashboard: DashboardScene): number { + let max = 0; + const body = dashboard.state.body; + + if (!(body instanceof SceneGridLayout)) { + throw new Error('Dashboard body is not a SceneGridLayout'); + } + + for (const child of body.state.children) { + if (child instanceof PanelRepeaterGridItem) { + const vizPanel = child.state.source; + + if (vizPanel) { + const panelId = + vizPanel instanceof LibraryVizPanel + ? getPanelIdForLibraryVizPanel(vizPanel) + : getPanelIdForVizPanel(vizPanel); + + if (panelId > max) { + max = panelId; + } + } + } + + if (child instanceof SceneGridItem) { + const vizPanel = child.state.body; + + if (vizPanel) { + const panelId = + vizPanel instanceof LibraryVizPanel + ? getPanelIdForLibraryVizPanel(vizPanel) + : getPanelIdForVizPanel(vizPanel); + + if (panelId > max) { + max = panelId; + } + } + } + + if (child instanceof SceneGridRow) { + //rows follow the same key pattern --- e.g.: `panel-6` + const panelId = getPanelIdForVizPanel(child); + + if (panelId > max) { + max = panelId; + } + + for (const rowChild of child.state.children) { + if (rowChild instanceof SceneGridItem) { + const vizPanel = rowChild.state.body; + + if (vizPanel) { + const panelId = + vizPanel instanceof LibraryVizPanel + ? getPanelIdForLibraryVizPanel(vizPanel) + : getPanelIdForVizPanel(vizPanel); + + if (panelId > max) { + max = panelId; + } + } + } + } + } + } + + return max + 1; +} + +// Returns the LibraryVizPanel that corresponds to the given VizPanel if it exists +export const getLibraryVizPanelFromVizPanel = (vizPanel: VizPanel): LibraryVizPanel | null => { + if (vizPanel.parent instanceof LibraryVizPanel) { + return vizPanel.parent; + } + + if (vizPanel.parent instanceof PanelRepeaterGridItem && vizPanel.parent.state.source instanceof LibraryVizPanel) { + return vizPanel.parent.state.source; + } + + return null; +}; + export const dashboardSceneGraph = { getTimePicker, getRefreshPicker, - getDashboardControls, + getPanelLinks, + getVizPanels, + getDataLayers, + getNextPanelId, + getCursorSync, }; diff --git a/public/app/features/dashboard-scene/utils/getVariablesCompatibility.ts b/public/app/features/dashboard-scene/utils/getVariablesCompatibility.ts index ed26220201f1e..54c47284639ca 100644 --- a/public/app/features/dashboard-scene/utils/getVariablesCompatibility.ts +++ b/public/app/features/dashboard-scene/utils/getVariablesCompatibility.ts @@ -10,7 +10,7 @@ export function getVariablesCompatibility(sceneObject: SceneObject): TypedVariab // Sadly templateSrv.getVariables returns TypedVariableModel but sceneVariablesSetToVariables return persisted schema model // They look close to identical (differ in what is optional in some places). // The way templateSrv.getVariables is used it should not matter. it is mostly used to get names of all variables (for query editors). - // So type and name are important. Maybe some external data sourcess also check current value so that is also important. + // So type and name are important. Maybe some external data sources also check current value so that is also important. // @ts-expect-error return legacyModels; } diff --git a/public/app/features/dashboard-scene/utils/interactions.ts b/public/app/features/dashboard-scene/utils/interactions.ts index 97abdf3d4735f..a4da3b1b0becc 100644 --- a/public/app/features/dashboard-scene/utils/interactions.ts +++ b/public/app/features/dashboard-scene/utils/interactions.ts @@ -1,6 +1,8 @@ import { reportInteraction } from '@grafana/runtime'; import { InspectTab } from 'app/features/inspector/types'; +let isScenesContextSet = false; + export const DashboardInteractions = { // Dashboard interactions: dashboardInitialized: (properties?: Record<string, unknown>) => { @@ -133,15 +135,27 @@ export const DashboardInteractions = { toolbarSaveClick: () => { reportDashboardInteraction('toolbar_actions_clicked', { item: 'save' }); }, - + toolbarSaveAsClick: () => { + reportDashboardInteraction('toolbar_actions_clicked', { item: 'save_as' }); + }, toolbarAddClick: () => { reportDashboardInteraction('toolbar_actions_clicked', { item: 'add' }); }, + + setScenesContext: () => { + isScenesContextSet = true; + + return () => { + isScenesContextSet = false; + }; + }, }; const reportDashboardInteraction: typeof reportInteraction = (name, properties) => { + const meta = isScenesContextSet ? { scenesView: true } : {}; + if (properties) { - reportInteraction(`dashboards_${name}`, properties); + reportInteraction(`dashboards_${name}`, { ...properties, ...meta }); } else { reportInteraction(`dashboards_${name}`); } diff --git a/public/app/features/dashboard-scene/utils/test-utils.ts b/public/app/features/dashboard-scene/utils/test-utils.ts index aa8a8afe29f15..b807efcdc6920 100644 --- a/public/app/features/dashboard-scene/utils/test-utils.ts +++ b/public/app/features/dashboard-scene/utils/test-utils.ts @@ -15,6 +15,8 @@ import { DashboardLoaderSrv, setDashboardLoaderSrv } from 'app/features/dashboar import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; import { DashboardDTO } from 'app/types'; +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; +import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; import { PanelRepeaterGridItem, RepeatDirection } from '../scene/PanelRepeaterGridItem'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; @@ -98,7 +100,7 @@ interface SceneOptions { useRowRepeater?: boolean; } -export function buildPanelRepeaterScene(options: SceneOptions) { +export function buildPanelRepeaterScene(options: SceneOptions, source?: VizPanel | LibraryVizPanel) { const defaults = { usePanelRepeater: true, ...options }; const repeater = new PanelRepeaterGridItem({ @@ -107,10 +109,12 @@ export function buildPanelRepeaterScene(options: SceneOptions) { repeatDirection: options.repeatDirection, maxPerRow: options.maxPerRow, itemHeight: options.itemHeight, - source: new VizPanel({ - title: 'Panel $server', - pluginId: 'timeseries', - }), + source: + source ?? + new VizPanel({ + title: 'Panel $server', + pluginId: 'timeseries', + }), x: options.x || 0, y: options.y || 0, }); @@ -120,7 +124,11 @@ export function buildPanelRepeaterScene(options: SceneOptions) { y: 0, width: 10, height: 10, - body: new VizPanel({ title: 'Panel $server', pluginId: 'timeseries' }), + body: new VizPanel({ + title: 'Panel $server', + pluginId: 'timeseries', + titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })], + }), }); const rowChildren = defaults.usePanelRepeater ? repeater : gridItem; diff --git a/public/app/features/dashboard-scene/utils/urlBuilders.test.ts b/public/app/features/dashboard-scene/utils/urlBuilders.test.ts index 192f6e8f78d9a..16a683cace6b3 100644 --- a/public/app/features/dashboard-scene/utils/urlBuilders.test.ts +++ b/public/app/features/dashboard-scene/utils/urlBuilders.test.ts @@ -2,9 +2,9 @@ import { getDashboardUrl } from './urlBuilders'; describe('dashboard utils', () => { it('Can getUrl', () => { - const url = getDashboardUrl({ uid: 'dash-1', currentQueryParams: '?orgId=1&filter=A', useExperimentalURL: true }); + const url = getDashboardUrl({ uid: 'dash-1', currentQueryParams: '?orgId=1&filter=A' }); - expect(url).toBe('/scenes/dashboard/dash-1?orgId=1&filter=A'); + expect(url).toBe('/d/dash-1?orgId=1&filter=A'); }); it('Can getUrl with subpath', () => { @@ -12,10 +12,20 @@ describe('dashboard utils', () => { uid: 'dash-1', subPath: '/panel-edit/2', currentQueryParams: '?orgId=1&filter=A', - useExperimentalURL: true, }); - expect(url).toBe('/scenes/dashboard/dash-1/panel-edit/2?orgId=1&filter=A'); + expect(url).toBe('/d/dash-1/panel-edit/2?orgId=1&filter=A'); + }); + + it('Can getUrl with slug', () => { + const url = getDashboardUrl({ + uid: 'dash-1', + slug: 'dash-1-slug', + subPath: '/panel-edit/2', + currentQueryParams: '?orgId=1&filter=A', + }); + + expect(url).toBe('/d/dash-1/dash-1-slug/panel-edit/2?orgId=1&filter=A'); }); it('Can getUrl with params removed and addded', () => { @@ -23,9 +33,17 @@ describe('dashboard utils', () => { uid: 'dash-1', currentQueryParams: '?orgId=1&filter=A', updateQuery: { filter: null, new: 'A' }, - useExperimentalURL: true, }); - expect(url).toBe('/scenes/dashboard/dash-1?orgId=1&new=A'); + expect(url).toBe('/d/dash-1?orgId=1&new=A'); + }); + + it('Empty uid should be treated as a new dashboard', () => { + const url = getDashboardUrl({ + uid: '', + currentQueryParams: '?orgId=1&filter=A', + }); + + expect(url).toBe('/dashboard/new?orgId=1&filter=A'); }); }); diff --git a/public/app/features/dashboard-scene/utils/urlBuilders.ts b/public/app/features/dashboard-scene/utils/urlBuilders.ts index 6c67abbc33d9d..7ece19aae8344 100644 --- a/public/app/features/dashboard-scene/utils/urlBuilders.ts +++ b/public/app/features/dashboard-scene/utils/urlBuilders.ts @@ -9,6 +9,7 @@ import { getQueryRunnerFor } from './utils'; export interface DashboardUrlOptions { uid?: string; + slug?: string; subPath?: string; updateQuery?: UrlQueryMap; /** Set to location.search to preserve current params */ @@ -21,18 +22,25 @@ export interface DashboardUrlOptions { absolute?: boolean; // Add tz to query params timeZone?: string; - - // Add tz to query params - useExperimentalURL?: boolean; } export function getDashboardUrl(options: DashboardUrlOptions) { - let path = options.useExperimentalURL - ? `/scenes/dashboard/${options.uid}${options.subPath ?? ''}` - : `/d/${options.uid}${options.subPath ?? ''}`; + let path = `/d/${options.uid}`; + + if (!options.uid) { + path = '/dashboard/new'; + } if (options.soloRoute) { - path = `/d-solo/${options.uid}${options.subPath ?? ''}`; + path = `/d-solo/${options.uid}`; + } + + if (options.slug) { + path += `/${options.slug}`; + } + + if (options.subPath) { + path += options.subPath; } if (options.render) { @@ -72,8 +80,14 @@ export function getViewPanelUrl(vizPanel: VizPanel) { return locationUtil.getUrlForPartial(locationService.getLocation(), { viewPanel: vizPanel.state.key }); } +export function getEditPanelUrl(panelId: number) { + return locationUtil.getUrlForPartial(locationService.getLocation(), { editPanel: panelId }); +} + export function getInspectUrl(vizPanel: VizPanel, inspectTab?: InspectTab) { - return locationUtil.getUrlForPartial(locationService.getLocation(), { inspect: vizPanel.state.key, inspectTab }); + const inspect = vizPanel.state.key?.replace('-view', ''); + + return locationUtil.getUrlForPartial(locationService.getLocation(), { inspect, inspectTab }); } export function tryGetExploreUrlForPanel(vizPanel: VizPanel): Promise<string | undefined> { diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index b30d0a5875270..780c4b0445c94 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -1,16 +1,28 @@ -import { IntervalVariableModel } from '@grafana/data'; +import { getDataSourceRef, IntervalVariableModel } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; import { + CustomVariable, MultiValueVariable, SceneDataTransformer, sceneGraph, + SceneGridRow, SceneObject, SceneQueryRunner, VizPanel, + VizPanelMenu, } from '@grafana/scenes'; import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer'; -import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardScene } from '../scene/DashboardScene'; +import { LibraryVizPanel } from '../scene/LibraryVizPanel'; +import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; +import { panelMenuBehavior } from '../scene/PanelMenuBehavior'; +import { RowActions } from '../scene/row-actions/RowActions'; + +import { dashboardSceneGraph } from './dashboardSceneGraph'; + +export const NEW_PANEL_HEIGHT = 8; +export const NEW_PANEL_WIDTH = 12; export function getVizPanelKeyForPanelId(panelId: number) { return `panel-${panelId}`; @@ -20,8 +32,12 @@ export function getPanelIdForVizPanel(panel: SceneObject): number { return parseInt(panel.state.key!.replace('panel-', ''), 10); } +export function getPanelIdForLibraryVizPanel(panel: LibraryVizPanel): number { + return parseInt(panel.state.panelKey!.replace('panel-', ''), 10); +} + /** - * This will also try lookup based on panelId + * This will also try lookup based on panelId */ export function findVizPanelByKey(scene: SceneObject, key: string | undefined): VizPanel | null { if (!key) { @@ -79,7 +95,7 @@ export function forceRenderChildren(model: SceneObject, recursive?: boolean) { }); } -export function getMultiVariableValues(variable: MultiValueVariable) { +export function getMultiVariableValues(variable: MultiValueVariable | CustomVariable) { const { value, text, options } = variable.state; if (variable.hasAllValue()) { @@ -95,10 +111,10 @@ export function getMultiVariableValues(variable: MultiValueVariable) { }; } -// Transform old interval model to new interval model from scenes -export function getIntervalsFromOldIntervalModel(variable: IntervalVariableModel): string[] { +// used to transform old interval model to new interval model from scenes +export function getIntervalsFromQueryString(query: string): string[] { // separate intervals by quotes either single or double - const matchIntervals = variable.query.match(/(["'])(.*?)\1|\w+/g); + const matchIntervals = query.match(/(["'])(.*?)\1|\w+/g); // If no intervals are found in query, return the initial state of the interval reducer. if (!matchIntervals) { @@ -152,12 +168,14 @@ export function getQueryRunnerFor(sceneObject: SceneObject | undefined): SceneQu return undefined; } - if (sceneObject.state.$data instanceof SceneQueryRunner) { - return sceneObject.state.$data; + const dataProvider = sceneObject.state.$data ?? sceneObject.parent?.state.$data; + + if (dataProvider instanceof SceneQueryRunner) { + return dataProvider; } - if (sceneObject.state.$data instanceof SceneDataTransformer) { - return getQueryRunnerFor(sceneObject.state.$data); + if (dataProvider instanceof SceneDataTransformer) { + return getQueryRunnerFor(dataProvider); } return undefined; @@ -166,10 +184,6 @@ export function getQueryRunnerFor(sceneObject: SceneObject | undefined): SceneQu export function getDashboardSceneFor(sceneObject: SceneObject): DashboardScene { const root = sceneObject.getRoot(); - if (root instanceof PanelEditor) { - return root.state.dashboardRef.resolve(); - } - if (root instanceof DashboardScene) { return root; } @@ -192,3 +206,42 @@ export function getClosestVizPanel(sceneObject: SceneObject): VizPanel | null { export function isPanelClone(key: string) { return key.includes('clone'); } + +export function getDefaultVizPanel(dashboard: DashboardScene): VizPanel { + const panelId = dashboardSceneGraph.getNextPanelId(dashboard); + + return new VizPanel({ + title: 'Panel Title', + key: getVizPanelKeyForPanelId(panelId), + pluginId: 'timeseries', + titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })], + menu: new VizPanelMenu({ + $behaviors: [panelMenuBehavior], + }), + $data: new SceneDataTransformer({ + $data: new SceneQueryRunner({ + queries: [{ refId: 'A' }], + datasource: getDataSourceRef(getDataSourceSrv().getInstanceSettings(null)!), + }), + transformations: [], + }), + }); +} + +export function getDefaultRow(dashboard: DashboardScene): SceneGridRow { + const id = dashboardSceneGraph.getNextPanelId(dashboard); + + return new SceneGridRow({ + key: getVizPanelKeyForPanelId(id), + title: 'Row title', + actions: new RowActions({}), + y: 0, + }); +} + +export function getLibraryPanel(vizPanel: VizPanel): LibraryVizPanel | undefined { + if (vizPanel.parent instanceof LibraryVizPanel) { + return vizPanel.parent; + } + return; +} diff --git a/public/app/features/dashboard/components/AddPanelButton/AddPanelButton.tsx b/public/app/features/dashboard/components/AddPanelButton/AddPanelButton.tsx index ce2c6fc7b0910..197bd4e98172e 100644 --- a/public/app/features/dashboard/components/AddPanelButton/AddPanelButton.tsx +++ b/public/app/features/dashboard/components/AddPanelButton/AddPanelButton.tsx @@ -1,9 +1,7 @@ -import { css, cx } from '@emotion/css'; import React, { useState, useEffect } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { Dropdown, Button, useTheme2, Icon } from '@grafana/ui'; +import { Dropdown, Button, Icon } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; import { DashboardModel } from 'app/features/dashboard/state'; @@ -15,7 +13,6 @@ export interface Props { } const AddPanelButton = ({ dashboard, onToolbarAddMenuOpen }: Props) => { - const styles = getStyles(useTheme2()); const [isMenuOpen, setIsMenuOpen] = useState(false); useEffect(() => { @@ -32,10 +29,9 @@ const AddPanelButton = ({ dashboard, onToolbarAddMenuOpen }: Props) => { onVisibleChange={setIsMenuOpen} > <Button - icon="panel-add" - size="lg" - fill="text" - className={cx(styles.button, styles.buttonIcon, styles.buttonText)} + variant="secondary" + size="sm" + fill="outline" data-testid={selectors.components.PageToolbar.itemButton('Add button')} > <Trans i18nKey="dashboard.toolbar.add">Add</Trans> @@ -46,26 +42,3 @@ const AddPanelButton = ({ dashboard, onToolbarAddMenuOpen }: Props) => { }; export default AddPanelButton; - -function getStyles(theme: GrafanaTheme2) { - return { - button: css({ - label: 'add-panel-button', - padding: theme.spacing(0.5, 0.5, 0.5, 0.75), - height: theme.spacing((theme.components.height.sm + theme.components.height.md) / 2), - borderRadius: theme.shape.radius.default, - }), - buttonIcon: css({ - svg: { - margin: 0, - }, - }), - buttonText: css({ - label: 'add-panel-button-text', - fontSize: theme.typography.body.fontSize, - span: { - marginLeft: theme.spacing(0.67), - }, - }), - }; -} diff --git a/public/app/features/dashboard/components/AddPanelButton/AddPanelMenu.test.tsx b/public/app/features/dashboard/components/AddPanelButton/AddPanelMenu.test.tsx index ecd44d27f03c0..3f82b9d7ca898 100644 --- a/public/app/features/dashboard/components/AddPanelButton/AddPanelMenu.test.tsx +++ b/public/app/features/dashboard/components/AddPanelButton/AddPanelMenu.test.tsx @@ -27,7 +27,6 @@ jest.mock('@grafana/runtime', () => ({ partial: jest.fn(), }, reportInteraction: jest.fn(), - config: {}, })); jest.mock('app/features/dashboard/utils/dashboard', () => ({ diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx deleted file mode 100644 index d0f0690cd4ab3..0000000000000 --- a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; - -import { PanelModel } from '../../state'; -import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures'; - -import { AddPanelWidgetUnconnected as AddPanelWidget, Props } from './AddPanelWidget'; - -const getTestContext = (propOverrides?: object) => { - const props: Props = { - dashboard: createDashboardModelFixture(), - panel: new PanelModel({}), - addPanel: jest.fn() as any, - }; - Object.assign(props, propOverrides); - return render(<AddPanelWidget {...props} />); -}; - -describe('AddPanelWidget', () => { - it('should render component without error', () => { - expect(() => { - getTestContext(); - }); - }); - - it('should render the add panel actions', () => { - getTestContext(); - expect(screen.getByText(/Add a new panel/i)).toBeInTheDocument(); - expect(screen.getByText(/Add a new row/i)).toBeInTheDocument(); - expect(screen.getByText(/Add a panel from the panel library/i)).toBeInTheDocument(); - }); -}); diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx deleted file mode 100644 index a1020e1a61379..0000000000000 --- a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import { css, cx, keyframes } from '@emotion/css'; -import { chain, cloneDeep, defaults, find, sortBy } from 'lodash'; -import React, { useMemo, useState } from 'react'; -import { connect, MapDispatchToProps } from 'react-redux'; -import tinycolor from 'tinycolor2'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { locationService, reportInteraction } from '@grafana/runtime'; -import { Icon, IconButton, useStyles2 } from '@grafana/ui'; -import { CardButton } from 'app/core/components/CardButton'; -import config from 'app/core/config'; -import { LS_PANEL_COPY_KEY } from 'app/core/constants'; -import store from 'app/core/store'; -import { addPanel } from 'app/features/dashboard/state/reducers'; - -import { - LibraryPanelsSearch, - LibraryPanelsSearchVariant, -} from '../../../library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch'; -import { LibraryElementDTO } from '../../../library-panels/types'; -import { DashboardModel, PanelModel } from '../../state'; - -export type PanelPluginInfo = { id: number; defaults: { gridPos: { w: number; h: number }; title: string } }; - -export interface OwnProps { - panel: PanelModel; - dashboard: DashboardModel; -} - -export interface DispatchProps { - addPanel: typeof addPanel; -} - -export type Props = OwnProps & DispatchProps; - -const getCopiedPanelPlugins = () => { - const panels = chain(config.panels) - .filter({ hideFromList: false }) - .map((item) => item) - .value(); - const copiedPanels = []; - - const copiedPanelJson = store.get(LS_PANEL_COPY_KEY); - if (copiedPanelJson) { - const copiedPanel = JSON.parse(copiedPanelJson); - const pluginInfo: any = find(panels, { id: copiedPanel.type }); - if (pluginInfo) { - const pluginCopy = cloneDeep(pluginInfo); - pluginCopy.name = copiedPanel.title; - pluginCopy.sort = -1; - pluginCopy.defaults = copiedPanel; - copiedPanels.push(pluginCopy); - } - } - - return sortBy(copiedPanels, 'sort'); -}; - -export const AddPanelWidgetUnconnected = ({ panel, dashboard }: Props) => { - const [addPanelView, setAddPanelView] = useState(false); - - const onCancelAddPanel = (evt: React.MouseEvent<HTMLButtonElement>) => { - evt.preventDefault(); - dashboard.removePanel(panel); - }; - - const onBack = () => { - setAddPanelView(false); - }; - - const onCreateNewPanel = () => { - const { gridPos } = panel; - - const newPanel: Partial<PanelModel> = { - type: 'timeseries', - title: 'Panel Title', - datasource: panel.datasource, - gridPos: { x: gridPos.x, y: gridPos.y, w: gridPos.w, h: gridPos.h }, - isNew: true, - }; - - dashboard.addPanel(newPanel); - dashboard.removePanel(panel); - - locationService.partial({ editPanel: newPanel.id }); - }; - - const onPasteCopiedPanel = (panelPluginInfo: PanelPluginInfo) => { - const { gridPos } = panel; - - const newPanel = { - type: panelPluginInfo.id, - title: 'Panel Title', - gridPos: { - x: gridPos.x, - y: gridPos.y, - w: panelPluginInfo.defaults.gridPos.w, - h: panelPluginInfo.defaults.gridPos.h, - }, - }; - - // apply panel template / defaults - if (panelPluginInfo.defaults) { - defaults(newPanel, panelPluginInfo.defaults); - newPanel.title = panelPluginInfo.defaults.title; - store.delete(LS_PANEL_COPY_KEY); - } - - dashboard.addPanel(newPanel); - dashboard.removePanel(panel); - }; - - const onAddLibraryPanel = (panelInfo: LibraryElementDTO) => { - const { gridPos } = panel; - - const newPanel = { - ...panelInfo.model, - gridPos, - libraryPanel: panelInfo, - }; - - dashboard.addPanel(newPanel); - dashboard.removePanel(panel); - }; - - const onCreateNewRow = () => { - const newRow = { - type: 'row', - title: 'Row title', - gridPos: { x: 0, y: 0 }, - }; - - dashboard.addPanel(newRow); - dashboard.removePanel(panel); - }; - - const styles = useStyles2(getStyles); - const copiedPanelPlugins = useMemo(() => getCopiedPanelPlugins(), []); - - return ( - <div className={styles.wrapper}> - <div className={cx('panel-container', styles.callToAction)}> - <AddPanelWidgetHandle onCancel={onCancelAddPanel} onBack={addPanelView ? onBack : undefined} styles={styles}> - {addPanelView ? 'Add panel from panel library' : 'Add panel'} - </AddPanelWidgetHandle> - {addPanelView ? ( - <LibraryPanelsSearch onClick={onAddLibraryPanel} variant={LibraryPanelsSearchVariant.Tight} showPanelFilter /> - ) : ( - <div className={styles.actionsWrapper}> - <CardButton - icon="file-blank" - aria-label={selectors.pages.AddDashboard.addNewPanel} - onClick={() => { - reportInteraction('Create new panel'); - onCreateNewPanel(); - }} - > - Add a new panel - </CardButton> - <CardButton - icon="wrap-text" - aria-label={selectors.pages.AddDashboard.addNewRow} - onClick={() => { - reportInteraction('Create new row'); - onCreateNewRow(); - }} - > - Add a new row - </CardButton> - <CardButton - icon="book-open" - aria-label={selectors.pages.AddDashboard.addNewPanelLibrary} - onClick={() => { - reportInteraction('Add a panel from the panel library'); - setAddPanelView(true); - }} - > - Add a panel from the panel library - </CardButton> - {copiedPanelPlugins.length === 1 && ( - <CardButton - icon="clipboard-alt" - aria-label={selectors.pages.AddDashboard.addNewPanelLibrary} - onClick={() => { - reportInteraction('Paste panel from clipboard'); - onPasteCopiedPanel(copiedPanelPlugins[0]); - }} - > - Paste panel from clipboard - </CardButton> - )} - </div> - )} - </div> - </div> - ); -}; - -const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { addPanel }; - -export const AddPanelWidget = connect(undefined, mapDispatchToProps)(AddPanelWidgetUnconnected); - -interface AddPanelWidgetHandleProps { - onCancel: (e: React.MouseEvent<HTMLButtonElement>) => void; - onBack?: () => void; - children?: string; - styles: AddPanelStyles; -} - -const AddPanelWidgetHandle = ({ children, onBack, onCancel, styles }: AddPanelWidgetHandleProps) => { - return ( - <div className={cx(styles.headerRow, 'grid-drag-handle')}> - {onBack && ( - <div className={styles.backButton}> - <IconButton name="arrow-left" onClick={onBack} size="xl" tooltip="Go back" /> - </div> - )} - {!onBack && ( - <div className={styles.backButton}> - <Icon name="panel-add" size="xl" /> - </div> - )} - {children && <span>{children}</span>} - <div className="flex-grow-1" /> - <IconButton aria-label="Close 'Add Panel' widget" name="times" onClick={onCancel} tooltip="Close widget" /> - </div> - ); -}; - -const getStyles = (theme: GrafanaTheme2) => { - const pulsate = keyframes` - 0% {box-shadow: 0 0 0 2px ${theme.colors.background.canvas}, 0 0 0px 4px ${theme.colors.primary.main};} - 50% {box-shadow: 0 0 0 2px ${theme.components.dashboard.background}, 0 0 0px 4px ${tinycolor( - theme.colors.primary.main - ) - .darken(20) - .toHexString()};} - 100% {box-shadow: 0 0 0 2px ${theme.components.dashboard.background}, 0 0 0px 4px ${theme.colors.primary.main};} - `; - - return { - // wrapper is used to make sure box-shadow animation isn't cut off in dashboard page - wrapper: css` - height: 100%; - padding-top: ${theme.spacing(0.5)}; - `, - callToAction: css` - overflow: hidden; - outline: 2px dotted transparent; - outline-offset: 2px; - box-shadow: - 0 0 0 2px black, - 0 0 0px 4px #1f60c4; - animation: ${pulsate} 2s ease infinite; - `, - actionsWrapper: css` - height: 100%; - display: grid; - grid-template-columns: repeat(2, 1fr); - column-gap: ${theme.spacing(1)}; - row-gap: ${theme.spacing(1)}; - padding: ${theme.spacing(0, 1, 1, 1)}; - - // This is to make the last action full width (if by itself) - & > div:nth-child(2n-1):nth-last-of-type(1) { - grid-column: span 2; - } - `, - headerRow: css` - display: flex; - align-items: center; - height: 38px; - flex-shrink: 0; - width: 100%; - font-size: ${theme.typography.fontSize}; - font-weight: ${theme.typography.fontWeightMedium}; - padding-left: ${theme.spacing(1)}; - transition: background-color 0.1s ease-in-out; - cursor: move; - - &:hover { - background: ${theme.colors.background.secondary}; - } - `, - backButton: css` - display: flex; - align-items: center; - cursor: pointer; - padding-left: ${theme.spacing(0.5)}; - width: ${theme.spacing(4)}; - `, - noMargin: css` - margin: 0; - `, - }; -}; - -type AddPanelStyles = ReturnType<typeof getStyles>; diff --git a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss deleted file mode 100644 index ccb5d7fe66774..0000000000000 --- a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss +++ /dev/null @@ -1,78 +0,0 @@ -.add-panel-widget-container { - height: 100%; -} - -.add-panel-widget { - height: 100%; -} - -.add-panel-widget__header { - top: 0; - position: absolute; - padding: 0 8px; - display: flex; - align-items: center; - width: 100%; - cursor: move; - background: $page-header-bg; - box-shadow: $page-header-shadow; - border-bottom: 1px solid $page-header-border-color; - - .gicon { - font-size: 30px; - margin-right: $space-md; - } - - &:hover { - transition: background-color 0.1s ease-in-out; - background-color: $panel-header-hover-bg; - } -} - -.add-panel-widget__title { - font-size: $font-size-md; - font-weight: $font-weight-semi-bold; - margin-right: $space-xl; -} - -.add-panel-widget__link { - margin: 0 $space-sm; - width: 170px; - height: 88px !important; - flex-direction: column !important; -} - -.add-panel-widget__icon { - margin-bottom: $space-sm; - - .gicon { - color: white; - height: 44px; - width: 53px; - position: relative; - left: 5px; - } -} - -.add-panel-widget__create { - display: inherit; - margin-bottom: $space-lg; - // this is to have the big button appear centered - margin-top: 55px; -} - -.add-panel-widget__actions { - display: inherit; -} - -.add-panel-widget__action { - margin: 0 $space-xs; -} - -.add-panel-widget__btn-container { - height: 100%; - display: flex; - justify-content: center; - align-items: center; - flex-direction: column; -} diff --git a/public/app/features/dashboard/components/AddPanelWidget/index.ts b/public/app/features/dashboard/components/AddPanelWidget/index.ts deleted file mode 100644 index b96948ab1c05f..0000000000000 --- a/public/app/features/dashboard/components/AddPanelWidget/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AddPanelWidget } from './AddPanelWidget'; diff --git a/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx b/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx index adc52f64b2134..52d79b51e9972 100644 --- a/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx +++ b/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx @@ -27,12 +27,11 @@ import { import { ColorValueEditor } from 'app/core/components/OptionsUI/color'; import config from 'app/core/config'; import StandardAnnotationQueryEditor from 'app/features/annotations/components/StandardAnnotationQueryEditor'; +import { AngularEditorLoader } from 'app/features/dashboard-scene/settings/annotations/AngularEditorLoader'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; import { DashboardModel } from '../../state/DashboardModel'; -import { AngularEditorLoader } from './AngularEditorLoader'; - type Props = { editIdx: number; dashboard: DashboardModel; diff --git a/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsList.tsx b/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsList.tsx index 21de6545af27e..10b5e49de3db7 100644 --- a/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsList.tsx +++ b/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsList.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import React, { useState } from 'react'; import { arrayUtils, AnnotationQuery } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { getDataSourceSrv } from '@grafana/runtime'; import { Button, DeleteButton, IconButton, useStyles2, VerticalGroup } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; @@ -127,7 +128,14 @@ export const AnnotationSettingsList = ({ dashboard, onNew, onEdit }: Props) => { }} /> )} - {!showEmptyListCTA && <ListNewButton onClick={onNew}>New query</ListNewButton>} + {!showEmptyListCTA && ( + <ListNewButton + data-testid={selectors.pages.Dashboard.Settings.Annotations.List.addAnnotationCTAV2} + onClick={onNew} + > + New query + </ListNewButton> + )} </VerticalGroup> ); }; diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts index ca9e3a93a7049..9917955f6f6ec 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts @@ -16,7 +16,7 @@ import { DashboardExporter, LibraryElementExport } from './DashboardExporter'; jest.mock('app/core/store', () => { return { getBool: jest.fn(), - getObject: jest.fn(), + getObject: jest.fn((_a, b) => b), get: jest.fn(), }; }); @@ -25,9 +25,8 @@ jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getDataSourceSrv: () => { return { - get: (v: any) => { + get: (v: string | DataSourceRef) => { const s = getStubInstanceSettings(v); - // console.log('GET', v, s); return Promise.resolve(s); }, getInstanceSettings: getStubInstanceSettings, @@ -169,7 +168,7 @@ it('replaces datasource ref in library panel', async () => { }); it('If a panel queries has no datasource prop ignore it', async () => { - const dashboard: any = { + const dashboard = { panels: [ { id: 1, @@ -181,7 +180,7 @@ it('If a panel queries has no datasource prop ignore it', async () => { targets: [{ refId: 'A', a: 'A' }], }, ], - }; + } as unknown as Dashboard; const dashboardModel = new DashboardModel(dashboard, undefined, { getVariablesFromState: () => [], }); @@ -348,9 +347,9 @@ describe('given dashboard with repeated panels', () => { }); it('should not include default datasource in __inputs unnecessarily', async () => { - const testJson: any = { + const testJson = { panels: [{ id: 1, datasource: { uid: 'other', type: 'other' }, type: 'graph' }], - }; + } as unknown as Dashboard; const testDash = new DashboardModel(testJson); const exporter = new DashboardExporter(); const exportedJson: any = await exporter.makeExportable(testDash); @@ -380,7 +379,7 @@ describe('given dashboard with repeated panels', () => { }); it('should add datasource to required', () => { - const require: any = find(exported.__requires, { name: 'TestDB' }); + const require = find(exported.__requires, { name: 'TestDB' }); expect(require.name).toBe('TestDB'); expect(require.id).toBe('testdb'); expect(require.type).toBe('datasource'); @@ -388,52 +387,52 @@ describe('given dashboard with repeated panels', () => { }); it('should not add built in datasources to required', () => { - const require: any = find(exported.__requires, { name: 'Mixed' }); + const require = find(exported.__requires, { name: 'Mixed' }); expect(require).toBe(undefined); }); it('should add datasources used in mixed mode', () => { - const require: any = find(exported.__requires, { name: 'OtherDB' }); + const require = find(exported.__requires, { name: 'OtherDB' }); expect(require).not.toBe(undefined); }); it('should add graph panel to required', () => { - const require: any = find(exported.__requires, { name: 'Graph' }); + const require = find(exported.__requires, { name: 'Graph' }); expect(require.name).toBe('Graph'); expect(require.id).toBe('graph'); expect(require.version).toBe('1.1.0'); }); it('should add table panel to required', () => { - const require: any = find(exported.__requires, { name: 'Table' }); + const require = find(exported.__requires, { name: 'Table' }); expect(require.name).toBe('Table'); expect(require.id).toBe('table'); expect(require.version).toBe('1.1.1'); }); it('should add heatmap panel to required', () => { - const require: any = find(exported.__requires, { name: 'Heatmap' }); + const require = find(exported.__requires, { name: 'Heatmap' }); expect(require.name).toBe('Heatmap'); expect(require.id).toBe('heatmap'); expect(require.version).toBe('1.1.2'); }); it('should add grafana version', () => { - const require: any = find(exported.__requires, { name: 'Grafana' }); + const require = find(exported.__requires, { name: 'Grafana' }); expect(require.type).toBe('grafana'); expect(require.id).toBe('grafana'); expect(require.version).toBe('3.0.2'); }); it('should add constant template variables as inputs', () => { - const input: any = find(exported.__inputs, { name: 'VAR_PREFIX' }); + const input = find(exported.__inputs, { name: 'VAR_PREFIX' }); expect(input.type).toBe('constant'); expect(input.label).toBe('prefix'); expect(input.value).toBe('collectd'); }); it('should templatize constant variables', () => { - const variable: any = find(exported.templating.list, { name: 'prefix' }); + const variable = find(exported.templating.list, { name: 'prefix' }); expect(variable.query).toBe('${VAR_PREFIX}'); expect(variable.current.text).toBe('${VAR_PREFIX}'); expect(variable.current.value).toBe('${VAR_PREFIX}'); @@ -442,7 +441,7 @@ describe('given dashboard with repeated panels', () => { }); it('should add datasources only use via datasource variable to requires', () => { - const require: any = find(exported.__requires, { name: 'OtherDB_2' }); + const require = find(exported.__requires, { name: 'OtherDB_2' }); expect(require.id).toBe('other2'); }); @@ -472,25 +471,25 @@ describe('given dashboard with repeated panels', () => { function getStubInstanceSettings(v: string | DataSourceRef): DataSourceInstanceSettings { let key = (v as DataSourceRef)?.type ?? v; - return (stubs[(key as any) ?? 'gfdb'] ?? stubs['gfdb']) as any; + return stubs[(key as string) ?? 'gfdb'] ?? stubs['gfdb']; } // Stub responses -const stubs: { [key: string]: {} } = {}; +const stubs: { [key: string]: DataSourceInstanceSettings } = {}; stubs['gfdb'] = { name: 'gfdb', meta: { id: 'testdb', info: { version: '1.2.1' }, name: 'TestDB' }, -}; +} as DataSourceInstanceSettings; stubs['other'] = { name: 'other', meta: { id: 'other', info: { version: '1.2.1' }, name: 'OtherDB' }, -}; +} as DataSourceInstanceSettings; stubs['other2'] = { name: 'other2', meta: { id: 'other2', info: { version: '1.2.1' }, name: 'OtherDB_2' }, -}; +} as DataSourceInstanceSettings; stubs['mixed'] = { name: 'mixed', @@ -500,7 +499,7 @@ stubs['mixed'] = { name: 'Mixed', builtIn: true, }, -}; +} as DataSourceInstanceSettings; stubs['grafana'] = { name: '-- Grafana --', @@ -510,4 +509,4 @@ stubs['grafana'] = { name: 'grafana', builtIn: true, }, -}; +} as DataSourceInstanceSettings; diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index 830699e787c6f..ff50667bc3b95 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import React, { ReactNode } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { useLocation } from 'react-router-dom'; +import { createStateContext } from 'react-use'; import { textUtil } from '@grafana/data'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; @@ -11,9 +12,9 @@ import { ModalsController, ToolbarButton, useForceUpdate, - Tag, ToolbarButtonRow, ConfirmModal, + Badge, } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; @@ -48,6 +49,25 @@ const mapDispatchToProps = { updateTimeZoneForSession, }; +const [useDashNavModelContext, DashNavModalContextProvider] = createStateContext<{ component: React.ReactNode }>({ + component: null, +}); + +export function useDashNavModalController() { + const [_, setContextState] = useDashNavModelContext(); + + return { + showModal: (component: React.ReactNode) => setContextState({ component }), + hideModal: () => setContextState({ component: null }), + }; +} + +function DashNavModalRoot() { + const [contextState] = useDashNavModelContext(); + + return <>{contextState.component}</>; +} + const connector = connect(null, mapDispatchToProps); const selectors = e2eSelectors.pages.Dashboard.DashNav; @@ -59,7 +79,6 @@ export interface OwnProps { hideTimePicker: boolean; folderTitle?: string; title: string; - onAddPanel: () => void; } export function addCustomLeftAction(content: DynamicDashNavButtonModel) { @@ -158,12 +177,12 @@ export const DashNav = React.memo<Props>((props) => { }; const isPlaylistRunning = () => { - return playlistSrv.isPlaying; + return playlistSrv.state.isPlaying; }; const renderLeftActions = () => { const { dashboard, kioskMode } = props; - const { canStar, canShare, isStarred } = dashboard.meta; + const { canStar, isStarred } = dashboard.meta; const buttons: ReactNode[] = []; if (kioskMode || isPlaylistRunning()) { @@ -186,25 +205,27 @@ export const DashNav = React.memo<Props>((props) => { ); } - if (canShare) { - buttons.push(<ShareButton key="button-share" dashboard={dashboard} />); - } - if (dashboard.meta.publicDashboardEnabled) { + // TODO: This will be replaced with the new badge component. Color is required but gets override by css buttons.push( - <Tag key="public-dashboard" name="Public" colorIndex={5} data-testid={selectors.publicDashboardTag}></Tag> + <Badge + color="blue" + text="Public" + key="public-dashboard-button-badge" + className={publicBadgeStyle} + data-testid={selectors.publicDashboardTag} + /> ); } - if (config.featureToggles.scenes && !dashboard.isSnapshot()) { + if (config.featureToggles.scenes) { buttons.push( <DashNavButton key="button-scenes" tooltip={'View as Scene'} icon="apps" onClick={() => { - const location = locationService.getLocation(); - locationService.push(`/scenes/dashboard/${dashboard.uid}${location.search}`); + locationService.partial({ scenes: true }); }} /> ); @@ -255,8 +276,8 @@ export const DashNav = React.memo<Props>((props) => { }; const renderRightActions = () => { - const { dashboard, onAddPanel, isFullscreen, kioskMode } = props; - const { canSave, canEdit, showSettings } = dashboard.meta; + const { dashboard, isFullscreen, kioskMode, hideTimePicker } = props; + const { canSave, canEdit, showSettings, canShare } = dashboard.meta; const { snapshot } = dashboard; const snapshotUrl = snapshot && snapshot.originalUrl; const buttons: ReactNode[] = []; @@ -269,26 +290,15 @@ export const DashNav = React.memo<Props>((props) => { return [renderTimeControls()]; } - if (canEdit && !isFullscreen) { - if (config.featureToggles.emptyDashboardPage) { - buttons.push( - <AddPanelButton - dashboard={dashboard} - onToolbarAddMenuOpen={DashboardInteractions.toolbarAddClick} - key="panel-add-dropdown" - /> - ); - } else { - buttons.push( - <ToolbarButton - tooltip={t('dashboard.toolbar.add-panel', 'Add panel')} - icon="panel-add" - iconSize="xl" - onClick={onAddPanel} - key="button-panel-add" - /> - ); - } + if (snapshotUrl) { + buttons.push( + <ToolbarButton + tooltip={t('dashboard.toolbar.open-original', 'Open original dashboard')} + onClick={onOpenSnapshotOriginal} + icon="link" + key="button-snapshot" + /> + ); } if (canSave && !isFullscreen) { @@ -311,16 +321,7 @@ export const DashNav = React.memo<Props>((props) => { ); } - if (snapshotUrl) { - buttons.push( - <ToolbarButton - tooltip={t('dashboard.toolbar.open-original', 'Open original dashboard')} - onClick={onOpenSnapshotOriginal} - icon="link" - key="button-snapshot" - /> - ); - } + addCustomContent(dynamicDashNavActions.right, buttons); if (showSettings) { buttons.push( @@ -333,7 +334,24 @@ export const DashNav = React.memo<Props>((props) => { ); } - addCustomContent(dynamicDashNavActions.right, buttons); + if (canEdit && !isFullscreen) { + buttons.push( + <AddPanelButton + dashboard={dashboard} + onToolbarAddMenuOpen={DashboardInteractions.toolbarAddClick} + key="panel-add-dropdown" + /> + ); + } + + if (canShare) { + buttons.push(<ShareButton key="button-share" dashboard={dashboard} />); + } + + // if the timepicker is hidden, we don't need to add this separator + if (!hideTimePicker) { + buttons.push(<NavToolbarSeparator key="toolbar-separator" />); + } buttons.push(renderTimeControls()); @@ -343,11 +361,12 @@ export const DashNav = React.memo<Props>((props) => { return ( <AppChromeUpdate actions={ - <> + <DashNavModalContextProvider> {renderLeftActions()} <NavToolbarSeparator leftActionsSeparator /> <ToolbarButtonRow alignment="right">{renderRightActions()}</ToolbarButtonRow> - </> + <DashNavModalRoot /> + </DashNavModalContextProvider> } /> ); @@ -361,3 +380,9 @@ const modalStyles = css({ width: 'max-content', maxWidth: '80vw', }); + +const publicBadgeStyle = css({ + color: 'grey', + backgroundColor: 'transparent', + border: '1px solid', +}); diff --git a/public/app/features/dashboard/components/DashNav/ShareButton.tsx b/public/app/features/dashboard/components/DashNav/ShareButton.tsx index 9650f668c2480..2ff6ac53448c7 100644 --- a/public/app/features/dashboard/components/DashNav/ShareButton.tsx +++ b/public/app/features/dashboard/components/DashNav/ShareButton.tsx @@ -1,44 +1,24 @@ -import React, { useContext, useEffect } from 'react'; +import React from 'react'; -import { ModalsContext } from '@grafana/ui'; -import { useQueryParams } from 'app/core/hooks/useQueryParams'; -import { t } from 'app/core/internationalization'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { locationService } from '@grafana/runtime'; +import { Button } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; import { DashboardModel } from 'app/features/dashboard/state'; import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; -import { ShareModal } from '../ShareModal'; - -import { DashNavButton } from './DashNavButton'; - export const ShareButton = ({ dashboard }: { dashboard: DashboardModel }) => { - const [queryParams] = useQueryParams(); - const { showModal, hideModal } = useContext(ModalsContext); - - useEffect(() => { - if (!!queryParams.shareView) { - showModal(ShareModal, { - dashboard, - onDismiss: hideModal, - activeTab: String(queryParams.shareView), - }); - } - return () => { - hideModal(); - }; - }, [showModal, hideModal, dashboard, queryParams.shareView]); - return ( - <DashNavButton - tooltip={t('dashboard.toolbar.share', 'Share dashboard')} - icon="share-alt" - iconSize="lg" + <Button + data-testid={e2eSelectors.pages.Dashboard.DashNav.shareButton} + variant="primary" + size="sm" onClick={() => { DashboardInteractions.toolbarShareClick(); - showModal(ShareModal, { - dashboard, - onDismiss: hideModal, - }); + locationService.partial({ shareView: 'link' }); }} - /> + > + <Trans i18nKey="dashboard.toolbar.share-button">Share</Trans> + </Button> ); }; diff --git a/public/app/features/dashboard/components/DashboardPermissions/AccessControlDashboardPermissions.tsx b/public/app/features/dashboard/components/DashboardPermissions/AccessControlDashboardPermissions.tsx index 0506a77f665a1..212ba319048a9 100644 --- a/public/app/features/dashboard/components/DashboardPermissions/AccessControlDashboardPermissions.tsx +++ b/public/app/features/dashboard/components/DashboardPermissions/AccessControlDashboardPermissions.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { config } from '@grafana/runtime'; import { Permissions } from 'app/core/components/AccessControl'; import { Page } from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; @@ -10,7 +9,7 @@ import { SettingsPageProps } from '../DashboardSettings/types'; export const AccessControlDashboardPermissions = ({ dashboard, sectionNav }: SettingsPageProps) => { const canSetPermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPermissionsWrite); - const pageNav = config.featureToggles.dockedMegaMenu ? sectionNav.node.parentItem : undefined; + const pageNav = sectionNav.node.parentItem; return ( <Page navModel={sectionNav} pageNav={pageNav}> diff --git a/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx b/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx index b10c291649cb7..6a1c8544a71fb 100644 --- a/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx +++ b/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx @@ -183,7 +183,7 @@ function cleanDashboardFromIgnoredChanges(dashData: Dashboard) { // ignore time and refresh delete dash.time; - dash.refresh = ''; + delete dash.refresh; dash.schemaVersion = 0; delete dash.timezone; diff --git a/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx index 733f48f9c05af..a5f0962c7998d 100644 --- a/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx @@ -205,10 +205,10 @@ describe('AnnotationsSettings', () => { await userEvent.clear(nameInput); await userEvent.type(nameInput, 'My Prometheus Annotation'); - await userEvent.click(screen.getByText(/testdata/i)); + await userEvent.click(screen.getByPlaceholderText(/testdata/i)); expect(await screen.findByText(/Prometheus/i)).toBeVisible(); - expect(screen.queryAllByText(/testdata/i)).toHaveLength(2); + expect(screen.queryAllByText(/testdata/i)).toHaveLength(1); await userEvent.click(screen.getByText(/prometheus/i)); diff --git a/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx index b4e0487291dac..005c067ee4990 100644 --- a/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { AnnotationQuery, getDataSourceRef, NavModelItem } from '@grafana/data'; -import { config, getDataSourceSrv, locationService } from '@grafana/runtime'; +import { getDataSourceSrv, locationService } from '@grafana/runtime'; import { Page } from 'app/core/components/Page/Page'; import { DashboardModel } from '../../state'; @@ -41,7 +41,7 @@ function getSubPageNav( editIndex: number | undefined, node: NavModelItem ): NavModelItem | undefined { - const parentItem = config.featureToggles.dockedMegaMenu ? node.parentItem : undefined; + const parentItem = node.parentItem; if (editIndex == null) { return parentItem; } diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx index 1f42955fe5b23..23f24987ca324 100644 --- a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx @@ -8,7 +8,6 @@ import { locationService } from '@grafana/runtime'; import { Button, ToolbarButtonRow } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { Page } from 'app/core/components/Page/Page'; -import config from 'app/core/config'; import { t } from 'app/core/internationalization'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types'; @@ -185,14 +184,10 @@ function getSectionNav( text: t('dashboard-settings.settings.title', 'Settings'), children: [], icon: 'apps', - hideFromBreadcrumbs: true, + hideFromBreadcrumbs: false, + url: locationUtil.getUrlForPartial(location, { editview: 'settings', editIndex: null }), }; - if (config.featureToggles.dockedMegaMenu) { - main.hideFromBreadcrumbs = false; - main.url = locationUtil.getUrlForPartial(location, { editview: 'settings', editIndex: null }); - } - main.children = pages.map((page) => ({ text: page.title, icon: page.icon, diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx index 331d65c5dfe53..b3af73b81bd41 100644 --- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx @@ -35,7 +35,7 @@ const setupTestContext = (options: Partial<Props>) => { timezone: 'utc', }, { - folderId: 1, + folderUid: 'abc', folderTitle: 'test', } ), @@ -77,7 +77,7 @@ describe('General Settings', () => { }); describe('when timezone is changed', () => { - it('should call update function', async () => { + it.skip('should call update function', async () => { const { props } = setupTestContext({}); await userEvent.click(screen.getByTestId(selectors.components.TimeZonePicker.containerV2)); const timeZonePicker = screen.getByTestId(selectors.components.TimeZonePicker.containerV2); diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx index c2025c81754b4..267c66af9eb3c 100644 --- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx @@ -43,9 +43,9 @@ export function GeneralSettingsUnconnected({ const [renderCounter, setRenderCounter] = useState(0); const [dashboardTitle, setDashboardTitle] = useState(dashboard.title); const [dashboardDescription, setDashboardDescription] = useState(dashboard.description); - const pageNav = config.featureToggles.dockedMegaMenu ? sectionNav.node.parentItem : undefined; + const pageNav = sectionNav.node.parentItem; - const onFolderChange = (newUID: string, newTitle: string) => { + const onFolderChange = (newUID: string | undefined, newTitle: string | undefined) => { dashboard.meta.folderUid = newUID; dashboard.meta.folderTitle = newTitle; dashboard.meta.hasUnsavedFolderChange = true; diff --git a/public/app/features/dashboard/components/DashboardSettings/JsonEditorSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/JsonEditorSettings.tsx index 44ebe1484c70e..95dd2d3d3f7fa 100644 --- a/public/app/features/dashboard/components/DashboardSettings/JsonEditorSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/JsonEditorSettings.tsx @@ -2,7 +2,6 @@ import { css } from '@emotion/css'; import React, { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { Button, CodeEditor, useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { Trans } from 'app/core/internationalization'; @@ -13,8 +12,9 @@ import { getDashboardSrv } from '../../services/DashboardSrv'; import { SettingsPageProps } from './types'; export function JsonEditorSettings({ dashboard, sectionNav }: SettingsPageProps) { - const [dashboardJson, setDashboardJson] = useState<string>(JSON.stringify(dashboard.getSaveModelClone(), null, 2)); - const pageNav = config.featureToggles.dockedMegaMenu ? sectionNav.node.parentItem : undefined; + const dashboardSaveModel = dashboard.getSaveModelClone(); + const [dashboardJson, setDashboardJson] = useState<string>(JSON.stringify(dashboardSaveModel, null, 2)); + const pageNav = sectionNav.node.parentItem; const onClick = async () => { await getDashboardSrv().saveJSONDashboard(dashboardJson); diff --git a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx index 7c049c86ebc76..f454752307524 100644 --- a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx @@ -90,7 +90,9 @@ describe('LinksSettings', () => { const linklessDashboard = createDashboardModelFixture({ links: [] }); setup(linklessDashboard); - expect(screen.getByRole('heading', { name: 'Links' })).toBeInTheDocument(); + const linksTab = screen.getByRole('tab', { name: 'Tab Links' }); + expect(linksTab).toBeInTheDocument(); + expect(linksTab).toHaveAttribute('aria-selected', 'true'); expect( screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add dashboard link')) ).toBeInTheDocument(); @@ -173,7 +175,7 @@ describe('LinksSettings', () => { expect(screen.queryByText('Type')).toBeInTheDocument(); expect(screen.queryByText('Title')).toBeInTheDocument(); expect(screen.queryByText('With tags')).toBeInTheDocument(); - expect(screen.queryByText('Apply')).toBeInTheDocument(); + expect(screen.queryByText('Back to list')).toBeInTheDocument(); expect(screen.queryByText('Url')).not.toBeInTheDocument(); expect(screen.queryByText('Tooltip')).not.toBeInTheDocument(); @@ -192,7 +194,7 @@ describe('LinksSettings', () => { await userEvent.clear(screen.getByRole('textbox', { name: /title/i })); await userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'New Dashboard Link'); - await userEvent.click(screen.getByRole('button', { name: /Apply/i })); + await userEvent.click(screen.getByRole('button', { name: /Back to list/i })); expect(getTableBodyRows().length).toBe(4); expect(within(getTableBody()).queryByText('New Dashboard Link')).toBeInTheDocument(); @@ -201,7 +203,7 @@ describe('LinksSettings', () => { await userEvent.clear(screen.getByRole('textbox', { name: /title/i })); await userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'The first dashboard link'); - await userEvent.click(screen.getByRole('button', { name: /Apply/i })); + await userEvent.click(screen.getByRole('button', { name: /Back to list/i })); expect(within(getTableBody()).queryByText(originalLinks[0].title)).not.toBeInTheDocument(); expect(within(getTableBody()).queryByText('The first dashboard link')).toBeInTheDocument(); diff --git a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.tsx index f58ec7db4732c..edef4baf5d4d3 100644 --- a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.tsx @@ -1,11 +1,10 @@ import React, { useState } from 'react'; -import { NavModelItem } from '@grafana/data'; -import { config, locationService } from '@grafana/runtime'; +import { locationService } from '@grafana/runtime'; import { Page } from 'app/core/components/Page/Page'; +import { NEW_LINK } from 'app/features/dashboard-scene/settings/links/utils'; import { LinkSettingsEdit, LinkSettingsList } from '../LinksSettings'; -import { newLink } from '../LinksSettings/LinkSettingsEdit'; import { SettingsPageProps } from './types'; @@ -20,7 +19,7 @@ export function LinksSettings({ dashboard, sectionNav, editIndex }: SettingsPage }; const onNew = () => { - dashboard.links = [...dashboard.links, { ...newLink }]; + dashboard.links = [...dashboard.links, { ...NEW_LINK }]; setIsNew(true); locationService.partial({ editIndex: dashboard.links.length - 1 }); }; @@ -32,11 +31,7 @@ export function LinksSettings({ dashboard, sectionNav, editIndex }: SettingsPage const isEditing = editIndex !== undefined; - let pageNav: NavModelItem | undefined; - - if (config.featureToggles.dockedMegaMenu) { - pageNav = sectionNav.node.parentItem; - } + let pageNav = sectionNav.node.parentItem; if (isEditing) { const title = isNew ? 'New link' : 'Edit link'; @@ -46,13 +41,11 @@ export function LinksSettings({ dashboard, sectionNav, editIndex }: SettingsPage subTitle: description, }; - if (config.featureToggles.dockedMegaMenu) { - const parentUrl = sectionNav.node.url; - pageNav.parentItem = sectionNav.node.parentItem && { - ...sectionNav.node.parentItem, - url: parentUrl, - }; - } + const parentUrl = sectionNav.node.url; + pageNav.parentItem = sectionNav.node.parentItem && { + ...sectionNav.node.parentItem, + url: parentUrl, + }; } return ( diff --git a/public/app/features/dashboard/components/DashboardSettings/ListNewButton.tsx b/public/app/features/dashboard/components/DashboardSettings/ListNewButton.tsx index 24ad47e2a7fa4..eafdcb04e2ec6 100644 --- a/public/app/features/dashboard/components/DashboardSettings/ListNewButton.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/ListNewButton.tsx @@ -10,7 +10,7 @@ export const ListNewButton = ({ children, ...restProps }: Props) => { const styles = useStyles2(getStyles); return ( <div className={styles.buttonWrapper}> - <Button icon="plus" variant="secondary" {...restProps}> + <Button icon="plus" {...restProps}> {children} </Button> </div> diff --git a/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx index 1147f67006d57..0c0845b79950b 100644 --- a/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx @@ -20,7 +20,7 @@ interface Props { nowDelay?: string; timezone: TimeZone; weekStart: string; - liveNow: boolean; + liveNow?: boolean; } interface State { diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx index 2ca3aae4e0013..a9b3405345801 100644 --- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx @@ -6,15 +6,15 @@ import { BrowserRouter } from 'react-router-dom'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; +import { historySrv } from 'app/features/dashboard-scene/settings/version-history/HistorySrv'; import { configureStore } from '../../../../store/configureStore'; import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures'; -import { historySrv } from '../VersionHistory/HistorySrv'; import { VersionsSettings, VERSIONS_FETCH_LIMIT } from './VersionsSettings'; import { versions, diffs } from './__mocks__/versions'; -jest.mock('../VersionHistory/HistorySrv'); +jest.mock('app/features/dashboard-scene/settings/version-history/HistorySrv'); const queryByFullText = (text: string) => screen.queryByText((_, node: Element | undefined | null) => { diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx index d2b13040688b0..a741bd51a7184 100644 --- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx @@ -1,17 +1,16 @@ import React, { PureComponent } from 'react'; -import { config } from '@grafana/runtime'; import { Spinner, HorizontalGroup } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; - import { historySrv, RevisionsModel, - VersionHistoryTable, VersionHistoryHeader, VersionsHistoryButtons, - VersionHistoryComparison, -} from '../VersionHistory'; +} from 'app/features/dashboard-scene/settings/version-history'; + +import { VersionHistoryComparison } from '../VersionHistory/VersionHistoryComparison'; +import { VersionHistoryTable } from '../VersionHistory/VersionHistoryTable'; import { SettingsPageProps } from './types'; @@ -22,7 +21,7 @@ type State = { isAppending: boolean; versions: DecoratedRevisionModel[]; viewMode: 'list' | 'compare'; - diffData: { lhs: unknown; rhs: unknown }; + diffData: { lhs: string; rhs: string }; newInfo?: DecoratedRevisionModel; baseInfo?: DecoratedRevisionModel; isNewLatest: boolean; @@ -50,8 +49,8 @@ export class VersionsSettings extends PureComponent<Props, State> { viewMode: 'list', isNewLatest: false, diffData: { - lhs: {}, - rhs: {}, + lhs: '', + rhs: '', }, }; } @@ -63,7 +62,7 @@ export class VersionsSettings extends PureComponent<Props, State> { getVersions = (append = false) => { this.setState({ isAppending: append }); historySrv - .getHistoryList(this.props.dashboard, { limit: this.limit, start: this.start }) + .getHistoryList(this.props.dashboard.uid, { limit: this.limit, start: this.start }) .then((res) => { this.setState({ isLoading: false, @@ -124,8 +123,8 @@ export class VersionsSettings extends PureComponent<Props, State> { this.setState({ baseInfo: undefined, diffData: { - lhs: {}, - rhs: {}, + lhs: '', + rhs: '', }, isNewLatest: false, newInfo: undefined, @@ -139,7 +138,7 @@ export class VersionsSettings extends PureComponent<Props, State> { const canCompare = versions.filter((version) => version.checked).length === 2; const showButtons = versions.length > 1; const hasMore = versions.length >= this.limit; - const pageNav = config.featureToggles.dockedMegaMenu ? this.props.sectionNav.node.parentItem : undefined; + const pageNav = this.props.sectionNav.node.parentItem; if (viewMode === 'compare') { return ( @@ -186,7 +185,7 @@ export class VersionsSettings extends PureComponent<Props, State> { } } -const VersionsHistorySpinner = ({ msg }: { msg: string }) => ( +export const VersionsHistorySpinner = ({ msg }: { msg: string }) => ( <HorizontalGroup> <Spinner /> <em>{msg}</em> diff --git a/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx index 82ae09f6c110f..1b9fb0088d4d0 100644 --- a/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx +++ b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx @@ -1,13 +1,11 @@ import { css } from '@emotion/css'; -import { sumBy } from 'lodash'; import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; import useAsyncFn from 'react-use/lib/useAsyncFn'; import { locationService } from '@grafana/runtime'; import { Modal, ConfirmModal, Button } from '@grafana/ui'; -import { config } from 'app/core/config'; -import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { DashboardModel } from 'app/features/dashboard/state'; import { cleanUpDashboardAndVariables } from 'app/features/dashboard/state/actions'; import { deleteDashboard } from 'app/features/manage-dashboards/state/actions'; @@ -34,8 +32,6 @@ const DeleteDashboardModalUnconnected = ({ hideModal, cleanUpDashboardAndVariabl locationService.replace('/'); }, [hideModal]); - const modalBody = getModalBody(dashboard.panels, dashboard.title); - if (isProvisioned) { return <ProvisionedDeleteModal hideModal={hideModal} provisionedId={dashboard.meta.provisionedExternalId!} />; } @@ -43,7 +39,12 @@ const DeleteDashboardModalUnconnected = ({ hideModal, cleanUpDashboardAndVariabl return ( <ConfirmModal isOpen={true} - body={modalBody} + body={ + <> + <p>Do you want to delete this dashboard?</p> + <p>{dashboard.title}</p> + </> + } onConfirm={onConfirm} onDismiss={hideModal} title="Delete" @@ -53,24 +54,6 @@ const DeleteDashboardModalUnconnected = ({ hideModal, cleanUpDashboardAndVariabl ); }; -const getModalBody = (panels: PanelModel[], title: string) => { - const totalAlerts = sumBy(panels, (panel) => (panel.alert ? 1 : 0)); - return totalAlerts > 0 && !config.unifiedAlertingEnabled ? ( - <> - <p>Do you want to delete this dashboard?</p> - <p> - This dashboard contains {totalAlerts} alert{totalAlerts > 1 ? 's' : ''}. Deleting this dashboard also deletes - those alerts. - </p> - </> - ) : ( - <> - <p>Do you want to delete this dashboard?</p> - <p>{title}</p> - </> - ); -}; - const ProvisionedDeleteModal = ({ hideModal, provisionedId }: { hideModal(): void; provisionedId: string }) => ( <Modal isOpen={true} diff --git a/public/app/features/dashboard/components/GenAI/GenAIButton.test.tsx b/public/app/features/dashboard/components/GenAI/GenAIButton.test.tsx index 79d4773138219..fc76cf269d085 100644 --- a/public/app/features/dashboard/components/GenAI/GenAIButton.test.tsx +++ b/public/app/features/dashboard/components/GenAI/GenAIButton.test.tsx @@ -173,13 +173,11 @@ describe('GenAIButton', () => { await waitFor(() => expect(getByRole('button')).toBeEnabled()); }); - it('should call onGenerate when the text is generating', async () => { + it('should not call onGenerate when the text is generating', async () => { const onGenerate = jest.fn(); setup({ onGenerate, messages: [], eventTrackingSrc: eventTrackingSrc }); - await waitFor(() => expect(onGenerate).toHaveBeenCalledTimes(1)); - - expect(onGenerate).toHaveBeenCalledWith('Some incomplete generated text'); + await waitFor(() => expect(onGenerate).not.toHaveBeenCalledTimes(1)); }); it('should stop generating when clicking the button', async () => { @@ -191,6 +189,45 @@ describe('GenAIButton', () => { expect(setShouldStopMock).toHaveBeenCalledTimes(1); expect(setShouldStopMock).toHaveBeenCalledWith(true); + expect(onGenerate).not.toHaveBeenCalled(); + }); + }); + + describe('when it is completed from generating data', () => { + const setShouldStopMock = jest.fn(); + + beforeEach(() => { + jest.mocked(useOpenAIStream).mockReturnValue({ + messages: [], + error: undefined, + streamStatus: StreamStatus.COMPLETED, + reply: 'Some completed generated text', + setMessages: jest.fn(), + setStopGeneration: setShouldStopMock, + value: { + enabled: true, + stream: new Observable().subscribe(), + }, + }); + }); + + it('should render improve text ', async () => { + setup(); + + waitFor(async () => expect(await screen.findByText('Improve')).toBeInTheDocument()); + }); + + it('should enable the button', async () => { + setup(); + waitFor(async () => expect(await screen.findByRole('button')).toBeEnabled()); + }); + + it('should call onGenerate when the text is completed', async () => { + const onGenerate = jest.fn(); + setup({ onGenerate, messages: [], eventTrackingSrc: eventTrackingSrc }); + + await waitFor(() => expect(onGenerate).toHaveBeenCalledTimes(1)); + expect(onGenerate).toHaveBeenCalledWith('Some completed generated text'); }); }); diff --git a/public/app/features/dashboard/components/GenAI/GenAIButton.tsx b/public/app/features/dashboard/components/GenAI/GenAIButton.tsx index 6a01451bad0d9..c8e43b17c6e04 100644 --- a/public/app/features/dashboard/components/GenAI/GenAIButton.tsx +++ b/public/app/features/dashboard/components/GenAI/GenAIButton.tsx @@ -54,10 +54,11 @@ export const GenAIButton = ({ } = useOpenAIStream(model, temperature); const [history, setHistory] = useState<string[]>([]); - const [showHistory, setShowHistory] = useState(true); + const [showHistory, setShowHistory] = useState(false); const hasHistory = history.length > 0; - const isFirstHistoryEntry = streamStatus === StreamStatus.GENERATING && !hasHistory; + const isGenerating = streamStatus === StreamStatus.GENERATING; + const isFirstHistoryEntry = !hasHistory; const isButtonDisabled = disabled || (value && !value.enabled && !error); const reportInteraction = (item: AutoGenerateItem) => reportAutoGenerateInteraction(eventTrackingSrc, item); @@ -69,19 +70,17 @@ export const GenAIButton = ({ onClickProp?.(e); setMessages(typeof messages === 'function' ? messages() : messages); } else { - if (setShowHistory) { - setShowHistory(true); - } + setShowHistory(true); } } const buttonItem = error ? AutoGenerateItem.erroredRetryButton - : isFirstHistoryEntry - ? AutoGenerateItem.stopGenerationButton - : hasHistory - ? AutoGenerateItem.improveButton - : AutoGenerateItem.autoGenerateButton; + : isGenerating + ? AutoGenerateItem.stopGenerationButton + : isFirstHistoryEntry + ? AutoGenerateItem.autoGenerateButton + : AutoGenerateItem.improveButton; reportInteraction(buttonItem); }; @@ -96,10 +95,10 @@ export const GenAIButton = ({ useEffect(() => { // Todo: Consider other options for `"` sanitation - if (isFirstHistoryEntry && reply) { + if (streamStatus === StreamStatus.COMPLETED && reply) { onGenerate(sanitizeReply(reply)); } - }, [streamStatus, reply, onGenerate, isFirstHistoryEntry]); + }, [streamStatus, reply, onGenerate]); useEffect(() => { if (streamStatus === StreamStatus.COMPLETED) { @@ -119,7 +118,7 @@ export const GenAIButton = ({ }; const getIcon = () => { - if (isFirstHistoryEntry) { + if (isGenerating) { return undefined; } if (error || (value && !value?.enabled)) { @@ -135,7 +134,7 @@ export const GenAIButton = ({ buttonText = 'Retry'; } - if (isFirstHistoryEntry) { + if (isGenerating) { buttonText = STOP_GENERATION_TEXT; } @@ -175,9 +174,11 @@ export const GenAIButton = ({ eventTrackingSrc={eventTrackingSrc} /> } - placement="bottom-start" + placement="left-start" fitContent={true} - show={showHistory ? undefined : false} + show={showHistory} + onClose={() => setShowHistory(false)} + onOpen={() => setShowHistory(true)} > {button} </Toggletip> @@ -189,8 +190,8 @@ export const GenAIButton = ({ return ( <div className={styles.wrapper}> - {isFirstHistoryEntry && <Spinner size="sm" className={styles.spinner} />} - {!hasHistory && ( + {isGenerating && <Spinner size="sm" className={styles.spinner} />} + {isFirstHistoryEntry ? ( <Tooltip show={error ? undefined : false} interactive @@ -200,8 +201,9 @@ export const GenAIButton = ({ > {button} </Tooltip> + ) : ( + renderButtonWithToggletip() )} - {hasHistory && renderButtonWithToggletip()} </div> ); }; diff --git a/public/app/features/dashboard/components/GenAI/GenAIHistory.tsx b/public/app/features/dashboard/components/GenAI/GenAIHistory.tsx index 8b688f6992b6d..3d1780d828649 100644 --- a/public/app/features/dashboard/components/GenAI/GenAIHistory.tsx +++ b/public/app/features/dashboard/components/GenAI/GenAIHistory.tsx @@ -2,18 +2,7 @@ import { css } from '@emotion/css'; import React, { useEffect, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { - Alert, - Button, - HorizontalGroup, - Icon, - IconButton, - Input, - Text, - TextLink, - useStyles2, - VerticalGroup, -} from '@grafana/ui'; +import { Alert, Button, Icon, IconButton, Input, Stack, Text, TextLink, useStyles2 } from '@grafana/ui'; import { STOP_GENERATION_TEXT } from './GenAIButton'; import { GenerationHistoryCarousel } from './GenerationHistoryCarousel'; @@ -100,7 +89,9 @@ export const GenAIHistory = ({ const onGenerateWithFeedback = (suggestion: string | QuickFeedbackType) => { if (suggestion !== QuickFeedbackType.Regenerate) { - messages = [...messages, ...getFeedbackMessage(history[currentIndex], suggestion)]; + messages = [...messages, ...getFeedbackMessage(history[currentIndex - 1], suggestion)]; + } else { + messages = [...messages, ...getFeedbackMessage(history[currentIndex - 1], 'Please, regenerate')]; } setMessages(messages); @@ -122,13 +113,11 @@ export const GenAIHistory = ({ return ( <div className={styles.container}> {showError && ( - <div> - <Alert title=""> - <VerticalGroup> - <div>Sorry, I was unable to complete your request. Please try again.</div> - </VerticalGroup> - </Alert> - </div> + <Alert title=""> + <Stack direction={'column'}> + <p>Sorry, I was unable to complete your request. Please try again.</p> + </Stack> + </Alert> )} <Input @@ -157,11 +146,11 @@ export const GenAIHistory = ({ /> </div> <div className={styles.applySuggestion}> - <HorizontalGroup justify={'flex-end'}> + <Stack justifyContent={'flex-end'} direction={'row'}> <Button icon={!isStreamGenerating ? 'check' : 'fa fa-spinner'} onClick={onApply}> {isStreamGenerating ? STOP_GENERATION_TEXT : 'Apply'} </Button> - </HorizontalGroup> + </Stack> </div> <div className={styles.footer}> <Icon name="exclamation-circle" aria-label="exclamation-circle" className={styles.infoColor} /> @@ -186,7 +175,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: 'flex', flexDirection: 'column', width: 520, - height: 250, + maxHeight: 350, // This is the space the footer height paddingBottom: 35, }), diff --git a/public/app/features/dashboard/components/GenAI/hooks.ts b/public/app/features/dashboard/components/GenAI/hooks.ts index 93579660bfd98..80bcc806d3fcb 100644 --- a/public/app/features/dashboard/components/GenAI/hooks.ts +++ b/public/app/features/dashboard/components/GenAI/hooks.ts @@ -3,7 +3,7 @@ import { useAsync } from 'react-use'; import { Subscription } from 'rxjs'; import { llms } from '@grafana/experimental'; -import { logError } from '@grafana/runtime'; +import { createMonitoringLogger } from '@grafana/runtime'; import { useAppNotification } from 'app/core/copy/appNotification'; import { isLLMPluginEnabled, DEFAULT_OAI_MODEL } from './utils'; @@ -12,6 +12,8 @@ import { isLLMPluginEnabled, DEFAULT_OAI_MODEL } from './utils'; // Ideally we will want to move the hook itself to a different scope later. type Message = llms.openai.Message; +const genAILogger = createMonitoringLogger('features.dashboards.genai'); + export enum StreamStatus { IDLE = 'idle', GENERATING = 'generating', @@ -50,6 +52,8 @@ export function useOpenAIStream( const [streamStatus, setStreamStatus] = useState<StreamStatus>(StreamStatus.IDLE); const [error, setError] = useState<Error>(); const { error: notifyError } = useAppNotification(); + // Accumulate response and it will only update the state of the attatched component when the stream is completed. + let partialReply = ''; const onError = useCallback( (e: Error) => { @@ -62,11 +66,17 @@ export function useOpenAIStream( `Please try again or if the problem persists, contact your organization admin.` ); console.error(e); - logError(e, { messages: JSON.stringify(messages), model, temperature: String(temperature) }); + genAILogger.logError(e, { messages: JSON.stringify(messages), model, temperature: String(temperature) }); }, [messages, model, temperature, notifyError] ); + useEffect(() => { + if (messages.length > 0) { + setReply(''); + } + }, [messages]); + const { error: enabledError, value: enabled } = useAsync( async () => await isLLMPluginEnabled(), [isLLMPluginEnabled] @@ -100,9 +110,12 @@ export function useOpenAIStream( return { enabled, stream: stream.subscribe({ - next: setReply, + next: (reply) => { + partialReply = reply; + }, error: onError, complete: () => { + setReply(partialReply); setStreamStatus(StreamStatus.COMPLETED); setTimeout(() => { setStreamStatus(StreamStatus.IDLE); diff --git a/public/app/features/dashboard/components/GenAI/utils.test.ts b/public/app/features/dashboard/components/GenAI/utils.test.ts index 1f35a51d2794f..48d6dfafc089a 100644 --- a/public/app/features/dashboard/components/GenAI/utils.test.ts +++ b/public/app/features/dashboard/components/GenAI/utils.test.ts @@ -11,7 +11,7 @@ jest.mock('@grafana/experimental', () => ({ openai: { streamChatCompletions: jest.fn(), accumulateContent: jest.fn(), - enabled: jest.fn(), + health: jest.fn(), }, }, })); @@ -89,8 +89,8 @@ describe('getDashboardChanges', () => { describe('isLLMPluginEnabled', () => { it('should return true if LLM plugin is enabled', async () => { - // Mock llms.openai.enabled to return true - jest.mocked(llms.openai.enabled).mockResolvedValue({ ok: true, configured: false }); + // Mock llms.openai.health to return true + jest.mocked(llms.openai.health).mockResolvedValue({ ok: true, configured: false }); const enabled = await isLLMPluginEnabled(); @@ -98,8 +98,8 @@ describe('isLLMPluginEnabled', () => { }); it('should return false if LLM plugin is not enabled', async () => { - // Mock llms.openai.enabled to return false - jest.mocked(llms.openai.enabled).mockResolvedValue({ ok: false, configured: false }); + // Mock llms.openai.health to return false + jest.mocked(llms.openai.health).mockResolvedValue({ ok: false, configured: false }); const enabled = await isLLMPluginEnabled(); diff --git a/public/app/features/dashboard/components/GenAI/utils.ts b/public/app/features/dashboard/components/GenAI/utils.ts index 3304a63029efa..4692236177797 100644 --- a/public/app/features/dashboard/components/GenAI/utils.ts +++ b/public/app/features/dashboard/components/GenAI/utils.ts @@ -62,7 +62,7 @@ export function getDashboardChanges(dashboard: DashboardModel): { export async function isLLMPluginEnabled() { // Check if the LLM plugin is enabled. // If not, we won't be able to make requests, so return early. - return llms.openai.enabled().then((response) => response.ok); + return llms.openai.health().then((response) => response.ok); } /** diff --git a/public/app/features/dashboard/components/HelpWizard/HelpWizard.test.tsx b/public/app/features/dashboard/components/HelpWizard/HelpWizard.test.tsx index c2a059ba31e0d..15a2a62e5784b 100644 --- a/public/app/features/dashboard/components/HelpWizard/HelpWizard.test.tsx +++ b/public/app/features/dashboard/components/HelpWizard/HelpWizard.test.tsx @@ -31,6 +31,6 @@ function setup() { describe('SupportSnapshot', () => { it('Can render', async () => { setup(); - expect(await screen.findByRole('button', { name: 'Dashboard (2.97 KiB)' })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /Dashboard \([\d\.]+ KiB\)/ })).toBeInTheDocument(); }); }); diff --git a/public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.ts b/public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.ts index f771602d0a2f1..0a1f9b39f2cff 100644 --- a/public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.ts +++ b/public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.ts @@ -4,13 +4,13 @@ import { dateTimeFormat, formattedValueToString, getValueFormat, SelectableValue import { config } from '@grafana/runtime'; import { SceneObject } from '@grafana/scenes'; import { StateManagerBase } from 'app/core/services/StateManagerBase'; +import { Randomize } from 'app/features/dashboard-scene/inspect/HelpWizard/randomizer'; import { createDashboardSceneFromDashboardModel } from 'app/features/dashboard-scene/serialization/transformSaveModelToScene'; import { getTimeSrv } from '../../services/TimeSrv'; import { DashboardModel, PanelModel } from '../../state'; import { setDashboardToFetchFromLocalStorage } from '../../state/initDashboard'; -import { Randomize } from './randomizer'; import { getDebugDashboard, getGithubMarkdown } from './utils'; interface SupportSnapshotState { @@ -82,7 +82,7 @@ export class SupportSnapshotService extends StateManagerBase<SupportSnapshotStat if (!panel.isAngularPlugin()) { try { - const oldModel = new DashboardModel(snapshot); + const oldModel = new DashboardModel(snapshot, { isEmbedded: true }); const dash = createDashboardSceneFromDashboardModel(oldModel); scene = dash.state.body; // skip the wrappers } catch (ex) { diff --git a/public/app/features/dashboard/components/HelpWizard/utils.ts b/public/app/features/dashboard/components/HelpWizard/utils.ts index 75fc65c320238..2260a4b0315d4 100644 --- a/public/app/features/dashboard/components/HelpWizard/utils.ts +++ b/public/app/features/dashboard/components/HelpWizard/utils.ts @@ -14,10 +14,9 @@ import { } from '@grafana/data'; import { config } from '@grafana/runtime'; import { PanelModel } from 'app/features/dashboard/state'; +import { Randomize, randomizeData } from 'app/features/dashboard-scene/inspect/HelpWizard/randomizer'; import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; -import { Randomize, randomizeData } from './randomizer'; - export function getPanelDataFrames(data?: PanelData): DataFrameJSON[] { const frames: DataFrameJSON[] = []; if (data?.series) { @@ -44,7 +43,7 @@ export function getGithubMarkdown(panel: PanelModel, snapshot: string): string { panelType: saveModel.type, datasource: '??', }; - const grafanaVersion = `${config.buildInfo.version} (${config.buildInfo.commit})`; + const grafanaVersion = config.buildInfo.versionString; let md = `| Key | Value | |--|--| @@ -76,7 +75,7 @@ export async function getDebugDashboard(panel: PanelModel, rand: Randomize, time const dsref = panel.datasource; const frames = randomizeData(getPanelDataFrames(data), rand); - const grafanaVersion = `${config.buildInfo.version} (${config.buildInfo.commit})`; + const grafanaVersion = config.buildInfo.versionString; const queries = saveModel?.targets ?? []; const html = `<table width="100%"> <tr> diff --git a/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx b/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx index 182c2be49aee4..309d296aa3070 100644 --- a/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx +++ b/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx @@ -1,41 +1,11 @@ import React, { useState } from 'react'; -import { SelectableValue } from '@grafana/data'; import { DashboardLink } from '@grafana/schema'; -import { CollapsableSection, TagsInput, Select, Field, Input, Checkbox, Button, IconName } from '@grafana/ui'; +import { DashboardLinkForm } from 'app/features/dashboard-scene/settings/links/DashboardLinkForm'; +import { NEW_LINK } from 'app/features/dashboard-scene/settings/links/utils'; import { DashboardModel } from '../../state/DashboardModel'; -export const newLink: DashboardLink = { - icon: 'external link', - title: 'New link', - tooltip: '', - type: 'dashboards', - url: '', - asDropdown: false, - tags: [], - targetBlank: false, - keepTime: false, - includeVars: false, -}; - -const linkTypeOptions = [ - { value: 'dashboards', label: 'Dashboards' }, - { value: 'link', label: 'Link' }, -]; - -export const linkIconMap: Record<string, IconName | undefined> = { - 'external link': 'external-link-alt', - dashboard: 'apps', - question: 'question-circle', - info: 'info-circle', - bolt: 'bolt', - doc: 'file-alt', - cloud: 'cloud', -}; - -const linkIconOptions = Object.keys(linkIconMap).map((key) => ({ label: key, value: key })); - type LinkSettingsEditProps = { editLinkIdx: number; dashboard: DashboardModel; @@ -43,7 +13,7 @@ type LinkSettingsEditProps = { }; export const LinkSettingsEdit = ({ editLinkIdx, dashboard, onGoBack }: LinkSettingsEditProps) => { - const [linkSettings, setLinkSettings] = useState(editLinkIdx !== null ? dashboard.links[editLinkIdx] : newLink); + const [linkSettings, setLinkSettings] = useState(editLinkIdx !== null ? dashboard.links[editLinkIdx] : NEW_LINK); const onUpdate = (link: DashboardLink) => { const links = [...dashboard.links]; @@ -52,98 +22,5 @@ export const LinkSettingsEdit = ({ editLinkIdx, dashboard, onGoBack }: LinkSetti setLinkSettings(link); }; - const onTagsChange = (tags: string[]) => { - onUpdate({ ...linkSettings, tags: tags }); - }; - - const onTypeChange = (selectedItem: SelectableValue) => { - const update = { ...linkSettings, type: selectedItem.value }; - - // clear props that are no longe revant for this type - if (update.type === 'dashboards') { - update.url = ''; - update.tooltip = ''; - } else { - update.tags = []; - } - - onUpdate(update); - }; - - const onIconChange = (selectedItem: SelectableValue) => { - onUpdate({ ...linkSettings, icon: selectedItem.value }); - }; - - const onChange = (ev: React.FocusEvent<HTMLInputElement>) => { - const target = ev.currentTarget; - onUpdate({ - ...linkSettings, - [target.name]: target.type === 'checkbox' ? target.checked : target.value, - }); - }; - - const isNew = linkSettings.title === newLink.title; - - return ( - <div style={{ maxWidth: '600px' }}> - <Field label="Title"> - <Input name="title" id="title" value={linkSettings.title} onChange={onChange} autoFocus={isNew} /> - </Field> - <Field label="Type"> - <Select inputId="link-type-input" value={linkSettings.type} options={linkTypeOptions} onChange={onTypeChange} /> - </Field> - {linkSettings.type === 'dashboards' && ( - <> - <Field label="With tags"> - <TagsInput tags={linkSettings.tags} onChange={onTagsChange} /> - </Field> - </> - )} - {linkSettings.type === 'link' && ( - <> - <Field label="URL"> - <Input name="url" value={linkSettings.url} onChange={onChange} /> - </Field> - <Field label="Tooltip"> - <Input name="tooltip" value={linkSettings.tooltip} onChange={onChange} placeholder="Open dashboard" /> - </Field> - <Field label="Icon"> - <Select value={linkSettings.icon} options={linkIconOptions} onChange={onIconChange} /> - </Field> - </> - )} - <CollapsableSection label="Options" isOpen={true}> - {linkSettings.type === 'dashboards' && ( - <Field> - <Checkbox label="Show as dropdown" name="asDropdown" value={linkSettings.asDropdown} onChange={onChange} /> - </Field> - )} - <Field> - <Checkbox - label="Include current time range" - name="keepTime" - value={linkSettings.keepTime} - onChange={onChange} - /> - </Field> - <Field> - <Checkbox - label="Include current template variable values" - name="includeVars" - value={linkSettings.includeVars} - onChange={onChange} - /> - </Field> - <Field> - <Checkbox - label="Open link in new tab" - name="targetBlank" - value={linkSettings.targetBlank} - onChange={onChange} - /> - </Field> - </CollapsableSection> - <Button onClick={onGoBack}>Apply</Button> - </div> - ); + return <DashboardLinkForm link={linkSettings} onUpdate={onUpdate} onGoBack={onGoBack} />; }; diff --git a/public/app/features/dashboard/components/LinksSettings/LinkSettingsList.tsx b/public/app/features/dashboard/components/LinksSettings/LinkSettingsList.tsx index f2726abf06d66..09076805f5b50 100644 --- a/public/app/features/dashboard/components/LinksSettings/LinkSettingsList.tsx +++ b/public/app/features/dashboard/components/LinksSettings/LinkSettingsList.tsx @@ -1,13 +1,10 @@ -import { css } from '@emotion/css'; import React, { useState } from 'react'; import { arrayUtils } from '@grafana/data'; import { DashboardLink } from '@grafana/schema'; -import { DeleteButton, HorizontalGroup, Icon, IconButton, TagList, useStyles2 } from '@grafana/ui'; -import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; +import { DashboardLinkList } from 'app/features/dashboard-scene/settings/links/DashboardLinkList'; import { DashboardModel } from '../../state/DashboardModel'; -import { ListNewButton } from '../DashboardSettings/ListNewButton'; type LinkSettingsListProps = { dashboard: DashboardModel; @@ -15,9 +12,11 @@ type LinkSettingsListProps = { onEdit: (idx: number) => void; }; +/** + * Used in DashboardSettings to display the list of links. + * It updates the DashboardModel instance when links are added, edited, duplicated or deleted. + */ export const LinkSettingsList = ({ dashboard, onNew, onEdit }: LinkSettingsListProps) => { - const styles = useStyles2(getStyles); - const [links, setLinks] = useState(dashboard.links); const moveLink = (idx: number, direction: number) => { @@ -25,7 +24,7 @@ export const LinkSettingsList = ({ dashboard, onNew, onEdit }: LinkSettingsListP setLinks(dashboard.links); }; - const duplicateLink = (link: DashboardLink, idx: number) => { + const duplicateLink = (link: DashboardLink) => { dashboard.links = [...links, { ...link }]; setLinks(dashboard.links); }; @@ -35,85 +34,14 @@ export const LinkSettingsList = ({ dashboard, onNew, onEdit }: LinkSettingsListP setLinks(dashboard.links); }; - const isEmptyList = dashboard.links.length === 0; - - if (isEmptyList) { - return ( - <div> - <EmptyListCTA - onClick={onNew} - title="There are no dashboard links added yet" - buttonIcon="link" - buttonTitle="Add dashboard link" - infoBoxTitle="What are dashboard links?" - infoBox={{ - __html: - '<p>Dashboard Links allow you to place links to other dashboards and web sites directly below the dashboard header.</p>', - }} - /> - </div> - ); - } - return ( - <> - <table role="grid" className="filter-table filter-table--hover"> - <thead> - <tr> - <th>Type</th> - <th>Info</th> - <th colSpan={3} /> - </tr> - </thead> - <tbody> - {links.map((link, idx) => ( - <tr key={`${link.title}-${idx}`}> - <td role="gridcell" className="pointer" onClick={() => onEdit(idx)}> - <Icon name="external-link-alt" />   {link.type} - </td> - <td role="gridcell"> - <HorizontalGroup> - {link.title && <span className={styles.titleWrapper}>{link.title}</span>} - {link.type === 'link' && <span className={styles.urlWrapper}>{link.url}</span>} - {link.type === 'dashboards' && <TagList tags={link.tags ?? []} />} - </HorizontalGroup> - </td> - <td style={{ width: '1%' }} role="gridcell"> - {idx !== 0 && <IconButton name="arrow-up" onClick={() => moveLink(idx, -1)} tooltip="Move link up" />} - </td> - <td style={{ width: '1%' }} role="gridcell"> - {links.length > 1 && idx !== links.length - 1 ? ( - <IconButton name="arrow-down" onClick={() => moveLink(idx, 1)} tooltip="Move link down" /> - ) : null} - </td> - <td style={{ width: '1%' }} role="gridcell"> - <IconButton name="copy" onClick={() => duplicateLink(link, idx)} tooltip="Copy link" /> - </td> - <td style={{ width: '1%' }} role="gridcell"> - <DeleteButton - aria-label={`Delete link with title "${link.title}"`} - size="sm" - onConfirm={() => deleteLink(idx)} - /> - </td> - </tr> - ))} - </tbody> - </table> - <ListNewButton onClick={onNew}>New link</ListNewButton> - </> + <DashboardLinkList + links={links} + onNew={onNew} + onEdit={onEdit} + onDuplicate={duplicateLink} + onDelete={deleteLink} + onOrderChange={moveLink} + /> ); }; - -const getStyles = () => ({ - titleWrapper: css` - width: 20vw; - text-overflow: ellipsis; - overflow: hidden; - `, - urlWrapper: css` - width: 40vw; - text-overflow: ellipsis; - overflow: hidden; - `, -}); diff --git a/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategory.tsx b/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategory.tsx index 9b57326a05f5c..2b224805c6dec 100644 --- a/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategory.tsx +++ b/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategory.tsx @@ -22,7 +22,7 @@ export interface OptionsPaneCategoryProps { sandboxId?: string; } -const CATEGORY_PARAM_NAME = 'showCategory'; +const CATEGORY_PARAM_NAME = 'showCategory' as const; export const OptionsPaneCategory = React.memo( ({ @@ -30,23 +30,21 @@ export const OptionsPaneCategory = React.memo( title, children, forceOpen, - isOpenDefault, + isOpenDefault = true, renderTitle, className, itemsCount, isNested = false, sandboxId, }: OptionsPaneCategoryProps) => { - const initialIsExpanded = isOpenDefault !== false; const [savedState, setSavedState] = useLocalStorage(getOptionGroupStorageKey(id), { - isExpanded: initialIsExpanded, + isExpanded: isOpenDefault, }); - const styles = useStyles2(getStyles); - const [queryParams, updateQueryParams] = useQueryParams(); - const [isExpanded, setIsExpanded] = useState(savedState?.isExpanded ?? initialIsExpanded); + const [isExpanded, setIsExpanded] = useState(savedState?.isExpanded ?? isOpenDefault); const manualClickTime = useRef(0); const ref = useRef<HTMLDivElement>(null); + const [queryParams, updateQueryParams] = useQueryParams(); const isOpenFromUrl = queryParams[CATEGORY_PARAM_NAME] === id; useEffect(() => { @@ -92,13 +90,13 @@ export const OptionsPaneCategory = React.memo( }; } + const styles = useStyles2(getStyles); const boxStyles = cx( { [styles.box]: true, [styles.boxNestedExpanded]: isNested && isExpanded, }, - className, - 'options-group' + className ); const headerStyles = cx(styles.header, { @@ -149,61 +147,60 @@ export const OptionsPaneCategory = React.memo( OptionsPaneCategory.displayName = 'OptionsPaneCategory'; -const getStyles = (theme: GrafanaTheme2) => { - return { - box: css` - border-top: 1px solid ${theme.colors.border.weak}; - `, - boxNestedExpanded: css` - margin-bottom: ${theme.spacing(2)}; - `, - title: css` - flex-grow: 1; - overflow: hidden; - line-height: 1.5; - font-size: 1rem; - padding-left: 6px; - font-weight: ${theme.typography.fontWeightMedium}; - margin: 0; - `, - header: css` - display: flex; - cursor: pointer; - align-items: center; - padding: ${theme.spacing(0.5)}; - color: ${theme.colors.text.primary}; - font-weight: ${theme.typography.fontWeightMedium}; - - &:hover { - background: ${theme.colors.emphasize(theme.colors.background.primary, 0.03)}; - } - `, - toggleButton: css` - align-self: baseline; - `, - headerExpanded: css` - color: ${theme.colors.text.primary}; - `, - headerNested: css` - padding: ${theme.spacing(0.5, 0, 0.5, 0)}; - `, - body: css` - padding: ${theme.spacing(1, 2, 1, 4)}; - `, - bodyNested: css` - position: relative; - padding-right: 0; - &:before { - content: ''; - position: absolute; - top: 0; - left: 8px; - width: 1px; - height: 100%; - background: ${theme.colors.border.weak}; - } - `, - }; -}; - -const getOptionGroupStorageKey = (id: string): string => `${PANEL_EDITOR_UI_STATE_STORAGE_KEY}.optionGroup[${id}]`; +const getStyles = (theme: GrafanaTheme2) => ({ + box: css({ + borderTop: `1px solid ${theme.colors.border.weak}`, + }), + boxNestedExpanded: css({ + marginBottom: theme.spacing(2), + }), + title: css({ + flexGrow: 1, + overflow: 'hidden', + lineHeight: 1.5, + fontSize: '1rem', + paddingLeft: '6px', + fontWeight: theme.typography.fontWeightMedium, + margin: 0, + }), + header: css({ + display: 'flex', + cursor: 'pointer', + alignItems: 'center', + padding: theme.spacing(0.5), + color: theme.colors.text.primary, + fontWeight: theme.typography.fontWeightMedium, + + '&:hover': { + background: theme.colors.emphasize(theme.colors.background.primary, 0.03), + }, + }), + toggleButton: css({ + alignSelf: 'baseline', + }), + headerExpanded: css({ + color: theme.colors.text.primary, + }), + headerNested: css({ + padding: theme.spacing(0.5, 0, 0.5, 0), + }), + body: css({ + padding: theme.spacing(1, 2, 1, 4), + }), + bodyNested: css({ + position: 'relative', + paddingRight: 0, + + '&:before': { + content: "''", + position: 'absolute', + top: 0, + left: '8px', + width: '1px', + height: '100%', + background: theme.colors.border.weak, + }, + }), +}); + +const getOptionGroupStorageKey = (id: string) => `${PANEL_EDITOR_UI_STATE_STORAGE_KEY}.optionGroup[${id}]`; diff --git a/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor.tsx b/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor.tsx index 71b5a5e648be9..bc65bee8eb1f1 100644 --- a/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor.tsx @@ -15,10 +15,10 @@ export interface OptionsPaneCategoryDescriptorProps { customRender?: () => React.ReactNode; sandboxId?: string; } + /** * This is not a real React component but an intermediary to enable deep option search without traversing a React node tree. */ - export class OptionsPaneCategoryDescriptor { items: OptionsPaneItemDescriptor[] = []; categories: OptionsPaneCategoryDescriptor[] = []; @@ -41,14 +41,14 @@ export class OptionsPaneCategoryDescriptor { getCategory(name: string): OptionsPaneCategoryDescriptor { let sub = this.categories.find((c) => c.props.id === name); - if (sub) { - return sub; + if (!sub) { + sub = new OptionsPaneCategoryDescriptor({ + title: name, + id: name, + }); + this.addCategory(sub); } - sub = new OptionsPaneCategoryDescriptor({ - title: name, - id: name, - }); - this.addCategory(sub); + return sub; } diff --git a/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemOverrides.tsx b/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemOverrides.tsx index 1e3fff28ca8a5..83e0c93c7a856 100644 --- a/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemOverrides.tsx +++ b/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemOverrides.tsx @@ -1,4 +1,4 @@ -import { css, CSSObject } from '@emotion/css'; +import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; @@ -25,12 +25,11 @@ export function OptionsPaneItemOverrides({ overrides }: Props) { } const getStyles = (theme: GrafanaTheme2) => { - const common: CSSObject = { + const common = { width: 8, height: 8, borderRadius: theme.shape.radius.circle, marginLeft: theme.spacing(1), - position: 'relative', top: '-1px', }; @@ -40,10 +39,12 @@ const getStyles = (theme: GrafanaTheme2) => { }), rule: css({ ...common, + position: 'relative', backgroundColor: theme.colors.primary.main, }), data: css({ ...common, + position: 'relative', backgroundColor: theme.colors.warning.main, }), }; diff --git a/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx b/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx index b8e8d152ee2c7..db9039bb24d03 100644 --- a/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx @@ -33,7 +33,14 @@ export const OptionsPaneOptions = (props: OptionPaneRenderProps) => { ); const justOverrides = useMemo( - () => getFieldOverrideCategories(props, searchQuery), + () => + getFieldOverrideCategories( + props.panel.fieldConfig, + props.plugin.fieldConfigRegistry, + props.data?.series ?? [], + searchQuery, + props.onFieldConfigsChange + ), // eslint-disable-next-line react-hooks/exhaustive-deps [panel.configRev, props.data, props.instanceState, searchQuery] ); @@ -143,7 +150,7 @@ export enum OptionFilter { Recent = 'Recent', } -function renderSearchHits( +export function renderSearchHits( allOptions: OptionsPaneCategoryDescriptor[], overrides: OptionsPaneCategoryDescriptor[], searchQuery: string diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx index eb31dae7c041e..1577591e4bc13 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx @@ -33,7 +33,7 @@ import { StoreState } from 'app/types'; import { PanelOptionsChangedEvent, ShowModalReactEvent } from 'app/types/events'; import { notifyApp } from '../../../../core/actions'; -import { UnlinkModal } from '../../../library-panels/components/UnlinkModal/UnlinkModal'; +import { UnlinkModal } from '../../../dashboard-scene/scene/UnlinkModal'; import { isPanelModelLibraryPanel } from '../../../library-panels/guard'; import { getVariablesByKey } from '../../../variables/state/selectors'; import { DashboardPanel } from '../../dashgrid/DashboardPanel'; diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx index 01d124c602ab5..a60f5bd5483da 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx @@ -1,12 +1,12 @@ import { css } from '@emotion/css'; -import React, { useEffect, useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { Subscription } from 'rxjs'; import { GrafanaTheme2 } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; import { Tab, TabContent, TabsBar, toIconName, useForceUpdate, useStyles2 } from '@grafana/ui'; -import AlertTabIndex from 'app/features/alerting/AlertTabIndex'; import { PanelAlertTab } from 'app/features/alerting/unified/PanelAlertTab'; +import { PanelAlertTabContent } from 'app/features/alerting/unified/PanelAlertTabContent'; import { PanelQueriesChangedEvent, PanelTransformationsChangedEvent } from 'app/types/events'; import { DashboardModel, PanelModel } from '../../state'; @@ -55,12 +55,24 @@ export const PanelEditorTabs = React.memo(({ panel, dashboard, tabs, onChangeTab return null; } + const alertingEnabled = config.unifiedAlertingEnabled; + return ( <div className={styles.wrapper}> <TabsBar className={styles.tabBar} hideBorder> {tabs.map((tab) => { - if (tab.id === PanelEditorTabId.Alert) { - return renderAlertTab(tab, panel, dashboard, instrumentedOnChangeTab); + if (tab.id === PanelEditorTabId.Alert && alertingEnabled) { + return ( + <PanelAlertTab + key={tab.id} + label={tab.text} + active={tab.active} + onChangeTab={() => onChangeTab(tab)} + icon={toIconName(tab.icon)} + panel={panel} + dashboard={dashboard} + /> + ); } return ( <Tab @@ -76,7 +88,7 @@ export const PanelEditorTabs = React.memo(({ panel, dashboard, tabs, onChangeTab </TabsBar> <TabContent className={styles.tabContent}> {activeTab.id === PanelEditorTabId.Query && <PanelEditorQueries panel={panel} queries={panel.targets} />} - {activeTab.id === PanelEditorTabId.Alert && <AlertTabIndex panel={panel} dashboard={dashboard} />} + {activeTab.id === PanelEditorTabId.Alert && <PanelAlertTabContent panel={panel} dashboard={dashboard} />} {activeTab.id === PanelEditorTabId.Transform && <TransformationsEditor panel={panel} />} </TabContent> </div> @@ -99,48 +111,6 @@ function getCounter(panel: PanelModel, tab: PanelEditorTab) { return null; } -function renderAlertTab( - tab: PanelEditorTab, - panel: PanelModel, - dashboard: DashboardModel, - onChangeTab: (tab: PanelEditorTab) => void -) { - const alertingDisabled = !config.alertingEnabled && !config.unifiedAlertingEnabled; - - if (alertingDisabled) { - return null; - } - - if (config.unifiedAlertingEnabled) { - return ( - <PanelAlertTab - key={tab.id} - label={tab.text} - active={tab.active} - onChangeTab={() => onChangeTab(tab)} - icon={toIconName(tab.icon)} - panel={panel} - dashboard={dashboard} - /> - ); - } - - if (config.alertingEnabled) { - return ( - <Tab - key={tab.id} - label={tab.text} - active={tab.active} - onChangeTab={() => onChangeTab(tab)} - icon={toIconName(tab.icon)} - counter={getCounter(panel, tab)} - /> - ); - } - - return null; -} - const getStyles = (theme: GrafanaTheme2) => { return { wrapper: css` diff --git a/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx b/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx index b4c63f426f92e..eca2153ba774c 100644 --- a/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx @@ -11,6 +11,8 @@ import { ConfigOverrideRule, GrafanaTheme2, fieldMatchers, + FieldConfigSource, + DataFrame, } from '@grafana/data'; import { fieldMatchersUI, useStyles2, ValuePicker } from '@grafana/ui'; import { getDataLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; @@ -19,31 +21,31 @@ import { DynamicConfigValueEditor } from './DynamicConfigValueEditor'; import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from './OptionsPaneItemDescriptor'; import { OverrideCategoryTitle } from './OverrideCategoryTitle'; -import { OptionPaneRenderProps } from './types'; export function getFieldOverrideCategories( - props: OptionPaneRenderProps, - searchQuery: string + fieldConfig: FieldConfigSource, + registry: FieldConfigOptionsRegistry, + data: DataFrame[], + searchQuery: string, + onFieldConfigsChange: (config: FieldConfigSource) => void ): OptionsPaneCategoryDescriptor[] { const categories: OptionsPaneCategoryDescriptor[] = []; - const currentFieldConfig = props.panel.fieldConfig; - const registry = props.plugin.fieldConfigRegistry; - const data = props.data?.series ?? []; + const currentFieldConfig = fieldConfig; - if (registry.isEmpty()) { + if (!registry || registry.isEmpty()) { return []; } const onOverrideChange = (index: number, override: ConfigOverrideRule) => { let overrides = cloneDeep(currentFieldConfig.overrides); overrides[index] = override; - props.onFieldConfigsChange({ ...currentFieldConfig, overrides }); + onFieldConfigsChange({ ...currentFieldConfig, overrides }); }; const onOverrideRemove = (overrideIndex: number) => { let overrides = cloneDeep(currentFieldConfig.overrides); overrides.splice(overrideIndex, 1); - props.onFieldConfigsChange({ ...currentFieldConfig, overrides }); + onFieldConfigsChange({ ...currentFieldConfig, overrides }); }; const onOverrideAdd = (value: SelectableValue<string>) => { @@ -52,7 +54,7 @@ export function getFieldOverrideCategories( return; } - props.onFieldConfigsChange({ + onFieldConfigsChange({ ...currentFieldConfig, overrides: [ ...currentFieldConfig.overrides, @@ -135,7 +137,7 @@ export function getFieldOverrideCategories( <matcherUi.component id={`${matcherUi.matcher.id}-${idx}`} matcher={matcherUi.matcher} - data={props.data?.series ?? []} + data={data ?? []} options={override.matcher.options} onChange={onMatcherConfigChange} /> diff --git a/public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx b/public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx index b651492c21534..3f9793daae3d2 100644 --- a/public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx @@ -1,12 +1,18 @@ import React from 'react'; +import { SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { config } from '@grafana/runtime'; +import { VizPanel } from '@grafana/scenes'; import { DataLinksInlineEditor, Input, RadioButtonGroup, Select, Switch, TextArea } from '@grafana/ui'; +import { VizPanelManager, VizPanelManagerState } from 'app/features/dashboard-scene/panel-edit/VizPanelManager'; +import { VizPanelLinks } from 'app/features/dashboard-scene/scene/PanelLinks'; +import { dashboardSceneGraph } from 'app/features/dashboard-scene/utils/dashboardSceneGraph'; import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; import { GenAIPanelDescriptionButton } from '../GenAI/GenAIPanelDescriptionButton'; import { GenAIPanelTitleButton } from '../GenAI/GenAIPanelTitleButton'; -import { RepeatRowSelect } from '../RepeatRowSelect/RepeatRowSelect'; +import { RepeatRowSelect, RepeatRowSelect2 } from '../RepeatRowSelect/RepeatRowSelect'; import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from './OptionsPaneItemDescriptor'; @@ -45,6 +51,7 @@ export function getPanelFrameCategory(props: OptionPaneRenderProps): OptionsPane render: function renderTitle() { return ( <Input + data-testid={selectors.components.PanelEditor.OptionsPane.fieldInput('Title')} id="PanelFrameTitle" defaultValue={panel.title} onBlur={(e) => onPanelConfigChange('title', e.currentTarget.value)} @@ -62,6 +69,7 @@ export function getPanelFrameCategory(props: OptionPaneRenderProps): OptionsPane render: function renderDescription() { return ( <TextArea + data-testid={selectors.components.PanelEditor.OptionsPane.fieldInput('Description')} id="description-text-area" defaultValue={panel.description} onBlur={(e) => onPanelConfigChange('description', e.currentTarget.value)} @@ -79,6 +87,7 @@ export function getPanelFrameCategory(props: OptionPaneRenderProps): OptionsPane render: function renderTransparent() { return ( <Switch + data-testid={selectors.components.PanelEditor.OptionsPane.fieldInput('Transparent background')} value={panel.transparent} id="transparent-background" onChange={(e) => onPanelConfigChange('transparent', e.currentTarget.checked)} @@ -125,7 +134,7 @@ export function getPanelFrameCategory(props: OptionPaneRenderProps): OptionsPane <RepeatRowSelect id="repeat-by-variable-select" repeat={panel.repeat} - onChange={(value?: string | null) => { + onChange={(value?: string) => { onPanelConfigChange('repeat', value); }} /> @@ -171,3 +180,170 @@ export function getPanelFrameCategory(props: OptionPaneRenderProps): OptionsPane ) ); } + +export function getPanelFrameCategory2( + panelManager: VizPanelManager, + panel: VizPanel, + repeat?: string +): OptionsPaneCategoryDescriptor { + const descriptor = new OptionsPaneCategoryDescriptor({ + title: 'Panel options', + id: 'Panel options', + isOpenDefault: true, + }); + + const panelLinksObject = dashboardSceneGraph.getPanelLinks(panel); + const links = panelLinksObject?.state.rawLinks ?? []; + + return descriptor + .addItem( + new OptionsPaneItemDescriptor({ + title: 'Title', + value: panel.state.title, + popularRank: 1, + render: function renderTitle() { + return ( + <Input + id="PanelFrameTitle" + defaultValue={panel.state.title} + onBlur={(e) => panel.setState({ title: e.currentTarget.value })} + /> + ); + }, + // addon: config.featureToggles.dashgpt && <GenAIPanelTitleButton onGenerate={setPanelTitle} panel={panel} />, + }) + ) + .addItem( + new OptionsPaneItemDescriptor({ + title: 'Description', + description: panel.state.description, + value: panel.state.description, + render: function renderDescription() { + return ( + <TextArea + id="description-text-area" + defaultValue={panel.state.description} + onBlur={(e) => panel.setState({ description: e.currentTarget.value })} + /> + ); + }, + // addon: config.featureToggles.dashgpt && ( + // <GenAIPanelDescriptionButton onGenerate={setPanelDescription} panel={panel} /> + // ), + }) + ) + .addItem( + new OptionsPaneItemDescriptor({ + title: 'Transparent background', + render: function renderTransparent() { + return ( + <Switch + value={panel.state.displayMode === 'transparent'} + id="transparent-background" + onChange={() => { + panel.setState({ + displayMode: panel.state.displayMode === 'transparent' ? 'default' : 'transparent', + }); + }} + /> + ); + }, + }) + ) + .addCategory( + new OptionsPaneCategoryDescriptor({ + title: 'Panel links', + id: 'Panel links', + isOpenDefault: false, + itemsCount: links?.length, + }).addItem( + new OptionsPaneItemDescriptor({ + title: 'Panel links', + render: () => <ScenePanelLinksEditor panelLinks={panelLinksObject ?? undefined} />, + }) + ) + ) + .addCategory( + new OptionsPaneCategoryDescriptor({ + title: 'Repeat options', + id: 'Repeat options', + isOpenDefault: false, + }) + .addItem( + new OptionsPaneItemDescriptor({ + title: 'Repeat by variable', + description: + 'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.', + render: function renderRepeatOptions() { + return ( + <RepeatRowSelect2 + id="repeat-by-variable-select" + parent={panel} + repeat={repeat} + onChange={(value?: string) => { + const stateUpdate: Partial<VizPanelManagerState> = { repeat: value }; + if (value && !panelManager.state.repeatDirection) { + stateUpdate.repeatDirection = 'h'; + } + panelManager.setState(stateUpdate); + }} + /> + ); + }, + }) + ) + .addItem( + new OptionsPaneItemDescriptor({ + title: 'Repeat direction', + showIf: () => !!panelManager.state.repeat, + render: function renderRepeatOptions() { + const directionOptions: Array<SelectableValue<'h' | 'v'>> = [ + { label: 'Horizontal', value: 'h' }, + { label: 'Vertical', value: 'v' }, + ]; + + return ( + <RadioButtonGroup + options={directionOptions} + value={panelManager.state.repeatDirection ?? 'h'} + onChange={(value) => panelManager.setState({ repeatDirection: value })} + /> + ); + }, + }) + ) + .addItem( + new OptionsPaneItemDescriptor({ + title: 'Max per row', + showIf: () => Boolean(panelManager.state.repeat && panelManager.state.repeatDirection === 'h'), + render: function renderOption() { + const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value })); + return ( + <Select + options={maxPerRowOptions} + value={panelManager.state.maxPerRow} + onChange={(value) => panelManager.setState({ maxPerRow: value.value })} + /> + ); + }, + }) + ) + ); +} + +interface ScenePanelLinksEditorProps { + panelLinks?: VizPanelLinks; +} + +function ScenePanelLinksEditor({ panelLinks }: ScenePanelLinksEditorProps) { + const { rawLinks: links } = panelLinks ? panelLinks.useState() : { rawLinks: [] }; + + return ( + <DataLinksInlineEditor + links={links} + onChange={(links) => panelLinks?.setState({ rawLinks: links })} + getSuggestions={getPanelLinksVariableSuggestions} + data={[]} + /> + ); +} diff --git a/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx index 5a33695a3e6ce..ebcd9658a9fa5 100644 --- a/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx @@ -5,15 +5,20 @@ import { EventBus, InterpolateFunction, PanelData, + PanelPlugin, StandardEditorContext, VariableSuggestionsScope, } from '@grafana/data'; import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin'; import { - isNestedPanelOptions, NestedValueAccess, PanelOptionsEditorBuilder, + isNestedPanelOptions, } from '@grafana/data/src/utils/OptionsUIBuilders'; +import { VizPanel } from '@grafana/scenes'; +import { Input } from '@grafana/ui'; +import { LibraryVizPanelInfo } from 'app/features/dashboard-scene/panel-edit/LibraryVizPanelInfo'; +import { LibraryVizPanel } from 'app/features/dashboard-scene/scene/LibraryVizPanel'; import { getDataLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor'; @@ -146,6 +151,138 @@ export function getVisualizationOptions(props: OptionPaneRenderProps): OptionsPa return Object.values(categoryIndex); } +export function getLibraryVizPanelOptionsCategory(libraryPanel: LibraryVizPanel): OptionsPaneCategoryDescriptor { + const descriptor = new OptionsPaneCategoryDescriptor({ + title: 'Library panel options', + id: 'Library panel options', + isOpenDefault: true, + }); + + descriptor + .addItem( + new OptionsPaneItemDescriptor({ + title: 'Name', + value: libraryPanel, + popularRank: 1, + render: function renderName() { + return ( + <Input + id="LibraryPanelFrameName" + data-testid="library panel name input" + defaultValue={libraryPanel.state.name} + onBlur={(e) => libraryPanel.setState({ name: e.currentTarget.value })} + /> + ); + }, + }) + ) + .addItem( + new OptionsPaneItemDescriptor({ + title: 'Information', + render: function renderLibraryPanelInformation() { + return <LibraryVizPanelInfo libraryPanel={libraryPanel} />; + }, + }) + ); + + return descriptor; +} + +export interface OptionPaneRenderProps2 { + panel: VizPanel; + eventBus: EventBus; + plugin: PanelPlugin; + data?: PanelData; + instanceState: unknown; +} + +export function getVisualizationOptions2(props: OptionPaneRenderProps2): OptionsPaneCategoryDescriptor[] { + const { plugin, panel, data, eventBus, instanceState } = props; + + const categoryIndex: Record<string, OptionsPaneCategoryDescriptor> = {}; + const getOptionsPaneCategory = (categoryNames?: string[]): OptionsPaneCategoryDescriptor => { + const categoryName = categoryNames?.[0] ?? plugin.meta.name; + const category = categoryIndex[categoryName]; + + if (category) { + return category; + } + + return (categoryIndex[categoryName] = new OptionsPaneCategoryDescriptor({ + title: categoryName, + id: categoryName, + sandboxId: plugin.meta.id, + })); + }; + + const currentOptions = panel.state.options; + const access: NestedValueAccess = { + getValue: (path) => lodashGet(currentOptions, path), + onChange: (path, value) => { + const newOptions = setOptionImmutably(currentOptions, path, value); + panel.onOptionsChange(newOptions); + }, + }; + + const context = getStandardEditorContext({ + data, + replaceVariables: panel.interpolate, + options: currentOptions, + eventBus: eventBus, + instanceState, + }); + + // Load the options into categories + fillOptionsPaneItems(plugin.getPanelOptionsSupplier(), access, getOptionsPaneCategory, context); + + // Field options + const currentFieldConfig = panel.state.fieldConfig; + for (const fieldOption of plugin.fieldConfigRegistry.list()) { + const hideOption = + fieldOption.showIf && + (fieldOption.isCustom + ? !fieldOption.showIf(currentFieldConfig.defaults.custom, data?.series) + : !fieldOption.showIf(currentFieldConfig.defaults, data?.series)); + if (fieldOption.hideFromDefaults || hideOption) { + continue; + } + + const category = getOptionsPaneCategory(fieldOption.category); + const Editor = fieldOption.editor; + + const defaults = currentFieldConfig.defaults; + const value = fieldOption.isCustom + ? defaults.custom + ? lodashGet(defaults.custom, fieldOption.path) + : undefined + : lodashGet(defaults, fieldOption.path); + + if (fieldOption.getItemsCount) { + category.props.itemsCount = fieldOption.getItemsCount(value); + } + + category.addItem( + new OptionsPaneItemDescriptor({ + title: fieldOption.name, + description: fieldOption.description, + overrides: getOptionOverrides(fieldOption, currentFieldConfig, data?.series), + render: function renderEditor() { + const onChange = (v: unknown) => { + panel.onFieldConfigChange( + updateDefaultFieldConfigValue(currentFieldConfig, fieldOption.path, v, fieldOption.isCustom), + true + ); + }; + + return <Editor value={value} onChange={onChange} item={fieldOption} context={context} id={fieldOption.id} />; + }, + }) + ); + } + + return Object.values(categoryIndex); +} + /** * This will iterate all options panes and add register them with the configured categories * diff --git a/public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts b/public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts index 03e1685a997a4..2c2ecd6e6c0d4 100644 --- a/public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts +++ b/public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts @@ -41,54 +41,6 @@ describe('getPanelEditorTabs selector', () => { }); describe('alerts tab', () => { - describe('when alerting enabled', () => { - beforeAll(() => { - updateConfig({ - alertingEnabled: true, - }); - }); - - it('returns Alerts tab for graph panel', () => { - const tabs = getPanelEditorTabs(undefined, { - meta: { - id: 'graph', - }, - } as PanelPlugin); - - expect(tabs.length).toEqual(3); - expect(tabs[2].id).toEqual(PanelEditorTabId.Alert); - }); - - it('does not returns tab for panel other than graph', () => { - const tabs = getPanelEditorTabs(undefined, { - meta: { - id: 'table', - }, - } as PanelPlugin); - expect(tabs.length).toEqual(2); - expect(tabs[1].id).toEqual(PanelEditorTabId.Transform); - }); - }); - - describe('when alerting disabled', () => { - beforeAll(() => { - updateConfig({ - alertingEnabled: false, - }); - }); - - it('does not return Alerts tab', () => { - const tabs = getPanelEditorTabs(undefined, { - meta: { - id: 'graph', - }, - } as PanelPlugin); - - expect(tabs.length).toEqual(2); - expect(tabs[1].id).toEqual(PanelEditorTabId.Transform); - }); - }); - describe('with unified alerting enabled', () => { beforeAll(() => { updateConfig({ unifiedAlertingEnabled: true }); diff --git a/public/app/features/dashboard/components/PanelEditor/state/selectors.ts b/public/app/features/dashboard/components/PanelEditor/state/selectors.ts index a8f71b34ca686..7a7da8ee00413 100644 --- a/public/app/features/dashboard/components/PanelEditor/state/selectors.ts +++ b/public/app/features/dashboard/components/PanelEditor/state/selectors.ts @@ -55,12 +55,15 @@ export const getPanelEditorTabs = memoizeOne((tab?: string, plugin?: PanelPlugin }); export function shouldShowAlertingTab(plugin: PanelPlugin) { - const { alertingEnabled, unifiedAlertingEnabled } = getConfig(); + const { unifiedAlertingEnabled = false } = getConfig(); const hasRuleReadPermissions = contextSrv.hasPermission(getRulesPermissions(GRAFANA_RULES_SOURCE_NAME).read); - const isAlertingAvailable = alertingEnabled || (unifiedAlertingEnabled && hasRuleReadPermissions); + const isAlertingAvailable = unifiedAlertingEnabled && hasRuleReadPermissions; + if (!isAlertingAvailable) { + return false; + } const isGraph = plugin.meta.id === 'graph'; const isTimeseries = plugin.meta.id === 'timeseries'; - return (isAlertingAvailable && isGraph) || isTimeseries; + return isGraph || isTimeseries; } diff --git a/public/app/features/dashboard/components/PanelEditor/types.ts b/public/app/features/dashboard/components/PanelEditor/types.ts index 59c917bf4d71e..e95d7b1cc5f82 100644 --- a/public/app/features/dashboard/components/PanelEditor/types.ts +++ b/public/app/features/dashboard/components/PanelEditor/types.ts @@ -56,7 +56,7 @@ export interface OptionPaneRenderProps { data?: PanelData; dashboard: DashboardModel; instanceState: unknown; - onPanelConfigChange: (configKey: keyof PanelModel, value: unknown) => void; + onPanelConfigChange: <T extends keyof PanelModel>(configKey: T, value: PanelModel[T]) => void; onPanelOptionsChanged: (options: PanelModel['options']) => void; onFieldConfigsChange: (config: FieldConfigSource) => void; } diff --git a/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx b/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx index 1e79885c8a52a..de9ce1c1977f6 100644 --- a/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx +++ b/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useMemo } from 'react'; import { SelectableValue } from '@grafana/data'; +import { SceneObject, sceneGraph } from '@grafana/scenes'; import { Select } from '@grafana/ui'; import { useSelector } from 'app/types'; @@ -8,8 +9,8 @@ import { getLastKey, getVariablesByKey } from '../../../variables/state/selector export interface Props { id?: string; - repeat?: string | null; - onChange: (name: string | null) => void; + repeat?: string; + onChange: (name?: string) => void; } export const RepeatRowSelect = ({ repeat, onChange, id }: Props) => { @@ -41,3 +42,40 @@ export const RepeatRowSelect = ({ repeat, onChange, id }: Props) => { return <Select inputId={id} value={repeat} onChange={onSelectChange} options={variableOptions} />; }; + +interface Props2 { + parent: SceneObject; + repeat: string | undefined; + id?: string; + onChange: (name?: string) => void; +} + +export const RepeatRowSelect2 = ({ parent, repeat, id, onChange }: Props2) => { + const sceneVars = useMemo(() => sceneGraph.getVariables(parent), [parent]); + const variables = sceneVars.useState().variables; + + const variableOptions = useMemo(() => { + const options: Array<SelectableValue<string | null>> = variables.map((item) => ({ + label: item.state.name, + value: item.state.name, + })); + + if (options.length === 0) { + options.unshift({ + label: 'No template variables found', + value: null, + }); + } + + options.unshift({ + label: 'Disable repeating', + value: null, + }); + + return options; + }, [variables]); + + const onSelectChange = useCallback((option: SelectableValue<string | null>) => onChange(option.value!), [onChange]); + + return <Select inputId={id} value={repeat} onChange={onSelectChange} options={variableOptions} />; +}; diff --git a/public/app/features/dashboard/components/RowOptions/RowOptionsButton.tsx b/public/app/features/dashboard/components/RowOptions/RowOptionsButton.tsx index 3cae211487e19..f4d820a652b73 100644 --- a/public/app/features/dashboard/components/RowOptions/RowOptionsButton.tsx +++ b/public/app/features/dashboard/components/RowOptions/RowOptionsButton.tsx @@ -7,7 +7,7 @@ import { RowOptionsModal } from './RowOptionsModal'; export interface RowOptionsButtonProps { title: string; - repeat?: string | null; + repeat?: string; onUpdate: OnRowOptionsUpdate; warning?: React.ReactNode; } diff --git a/public/app/features/dashboard/components/RowOptions/RowOptionsForm.tsx b/public/app/features/dashboard/components/RowOptions/RowOptionsForm.tsx index 1558225ee4e01..b486aabf5b8a1 100644 --- a/public/app/features/dashboard/components/RowOptions/RowOptionsForm.tsx +++ b/public/app/features/dashboard/components/RowOptions/RowOptionsForm.tsx @@ -9,15 +9,15 @@ export type OnRowOptionsUpdate = (title: string, repeat?: string | null) => void export interface Props { title: string; - repeat?: string | null; + repeat?: string; onUpdate: OnRowOptionsUpdate; onCancel: () => void; warning?: React.ReactNode; } export const RowOptionsForm = ({ repeat, title, warning, onUpdate, onCancel }: Props) => { - const [newRepeat, setNewRepeat] = useState<string | null | undefined>(repeat); - const onChangeRepeat = useCallback((name?: string | null) => setNewRepeat(name), [setNewRepeat]); + const [newRepeat, setNewRepeat] = useState<string | undefined>(repeat); + const onChangeRepeat = useCallback((name?: string) => setNewRepeat(name), [setNewRepeat]); return ( <Form diff --git a/public/app/features/dashboard/components/RowOptions/RowOptionsModal.tsx b/public/app/features/dashboard/components/RowOptions/RowOptionsModal.tsx index ae1d0b502f4e6..d4c3979663f89 100644 --- a/public/app/features/dashboard/components/RowOptions/RowOptionsModal.tsx +++ b/public/app/features/dashboard/components/RowOptions/RowOptionsModal.tsx @@ -7,7 +7,7 @@ import { OnRowOptionsUpdate, RowOptionsForm } from './RowOptionsForm'; export interface RowOptionsModalProps { title: string; - repeat?: string | null; + repeat?: string; warning?: React.ReactNode; onDismiss: () => void; onUpdate: OnRowOptionsUpdate; diff --git a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDiff.tsx b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDiff.tsx index 1b3746d87c541..97c0462df76df 100644 --- a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDiff.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDiff.tsx @@ -1,13 +1,11 @@ -import { css } from '@emotion/css'; import React, { ReactElement } from 'react'; import { useAsync } from 'react-use'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Spinner, useStyles2 } from '@grafana/ui'; +import { Box, Spinner, Stack } from '@grafana/ui'; +import { Diffs } from 'app/features/dashboard-scene/settings/version-history/utils'; -import { DiffGroup } from '../VersionHistory/DiffGroup'; -import { DiffViewer } from '../VersionHistory/DiffViewer'; -import { Diffs } from '../VersionHistory/utils'; +import { DiffGroup } from '../../../dashboard-scene/settings/version-history/DiffGroup'; +import { DiffViewer } from '../../../dashboard-scene/settings/version-history/DiffViewer'; interface SaveDashboardDiffProps { oldValue?: unknown; @@ -18,7 +16,6 @@ interface SaveDashboardDiffProps { } export const SaveDashboardDiff = ({ diff, oldValue, newValue }: SaveDashboardDiffProps) => { - const styles = useStyles2(getStyles); const loader = useAsync(async () => { const oldJSON = JSON.stringify(oldValue ?? {}, null, 2); const newJSON = JSON.stringify(newValue ?? {}, null, 2); @@ -27,6 +24,7 @@ export const SaveDashboardDiff = ({ diff, oldValue, newValue }: SaveDashboardDif let schemaChange: ReactElement | undefined = undefined; const diffs: ReactElement[] = []; let count = 0; + if (diff) { for (const [key, changes] of Object.entries(diff)) { // this takes a long time for large diffs (so this is async) @@ -39,6 +37,7 @@ export const SaveDashboardDiff = ({ diff, oldValue, newValue }: SaveDashboardDif count += changes.length; } } + return { schemaChange, diffs, @@ -58,19 +57,14 @@ export const SaveDashboardDiff = ({ diff, oldValue, newValue }: SaveDashboardDif } return ( - <div> - {value.schemaChange && <div className={styles.spacer}>{value.schemaChange}</div>} + <Stack direction="column" gap={1}> + {value.schemaChange && value.schemaChange} + {value.showDiffs && value.diffs} - {value.showDiffs && <div className={styles.spacer}>{value.diffs}</div>} - - <h4>JSON Model</h4> - {value.jsonView} - </div> + <Box paddingTop={2}> + <h4>Full JSON diff</h4> + {value.jsonView} + </Box> + </Stack> ); }; - -const getStyles = (theme: GrafanaTheme2) => ({ - spacer: css` - margin-bottom: ${theme.v1.spacing.xl}; - `, -}); diff --git a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx index 1ee7e8aa335cc..1e5c0d5dfdf5c 100644 --- a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx @@ -2,8 +2,7 @@ import React, { useMemo, useState } from 'react'; import { config, isFetchError } from '@grafana/runtime'; import { Drawer, Tab, TabsBar } from '@grafana/ui'; - -import { jsonDiff } from '../VersionHistory/utils'; +import { jsonDiff } from 'app/features/dashboard-scene/settings/version-history/utils'; import DashboardValidation from './DashboardValidation'; import { SaveDashboardDiff } from './SaveDashboardDiff'; diff --git a/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.tsx b/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.tsx index 0e86fa849d34f..8e435c158b8a6 100644 --- a/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.tsx @@ -167,7 +167,7 @@ export const SaveDashboardAsForm = ({ render={({ field: { ref, ...field } }) => ( <FolderPicker {...field} - onChange={(uid: string, title: string) => field.onChange({ uid, title })} + onChange={(uid: string | undefined, title: string | undefined) => field.onChange({ uid, title })} value={field.value?.uid} // Old folder picker fields initialTitle={dashboard.meta.folderTitle} diff --git a/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.tsx b/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.tsx index f2faf077c86e4..3f4b172b5a43b 100644 --- a/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.tsx @@ -89,7 +89,7 @@ export const SaveDashboardForm = ({ /> )} <div className={styles.message}> - {config.featureToggles.dashgpt && ( + {config.featureToggles.aiGeneratedDashboardChanges && ( <GenAIDashboardChangesButton dashboard={dashboard} onGenerate={(text) => { diff --git a/public/app/features/dashboard/components/SaveDashboard/types.ts b/public/app/features/dashboard/components/SaveDashboard/types.ts index 0baff9a2d8975..a8af17cb80667 100644 --- a/public/app/features/dashboard/components/SaveDashboard/types.ts +++ b/public/app/features/dashboard/components/SaveDashboard/types.ts @@ -1,8 +1,6 @@ import { Dashboard } from '@grafana/schema'; import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel'; -import { DashboardDataDTO } from 'app/types'; - -import { Diffs } from '../VersionHistory/utils'; +import { Diffs } from 'app/features/dashboard-scene/settings/version-history/utils'; export interface SaveDashboardData { clone: Dashboard; // cloned copy @@ -19,10 +17,11 @@ export interface SaveDashboardOptions extends CloneOptions { } export interface SaveDashboardCommand { - dashboard: DashboardDataDTO; + dashboard: Dashboard; message?: string; folderUid?: string; overwrite?: boolean; + showErrorAlert?: boolean; } export interface SaveDashboardFormProps { diff --git a/public/app/features/dashboard/components/ShareModal/ShareExport.tsx b/public/app/features/dashboard/components/ShareModal/ShareExport.tsx index 40ea80ebb8c93..6bf1bad1b8417 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareExport.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareExport.tsx @@ -39,7 +39,7 @@ export class ShareExport extends PureComponent<Props, State> { const { dashboard } = this.props; const { shareExternally } = this.state; - DashboardInteractions.exportSaveJsonClicked(); + DashboardInteractions.exportSaveJsonClicked({ externally: shareExternally }); if (shareExternally) { this.exporter.makeExportable(dashboard).then((dashboardJson) => { @@ -53,7 +53,7 @@ export class ShareExport extends PureComponent<Props, State> { onViewJson = () => { const { dashboard } = this.props; const { shareExternally } = this.state; - DashboardInteractions.exportViewJsonClicked(); + DashboardInteractions.exportViewJsonClicked({ externally: shareExternally }); if (shareExternally) { this.exporter.makeExportable(dashboard).then((dashboardJson) => { diff --git a/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx index 87d8072f153db..205e09e8a658c 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx @@ -104,8 +104,8 @@ describe('ShareModal', () => { mockLocationHref('http://dashboards.grafana.com/d/abcdefghi/my-dash'); render(<ShareLink {...props} />); - const base = '/render/d-solo/abcdefghi/my-dash'; - const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC'; + const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash'; + const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&scale=1&tz=UTC'; expect( await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage }) ).toHaveAttribute('href', base + params); @@ -115,8 +115,8 @@ describe('ShareModal', () => { mockLocationHref('http://dashboards.grafana.com/dashboard/script/my-dash.js'); render(<ShareLink {...props} />); - const base = '/render/dashboard-solo/script/my-dash.js'; - const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC'; + const base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js'; + const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&scale=1&tz=UTC'; expect( await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage }) ).toHaveAttribute('href', base + params); @@ -151,7 +151,10 @@ describe('ShareModal', () => { ); expect( await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage }) - ).toHaveAttribute('href', path + '?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC'); + ).toHaveAttribute( + 'href', + base + path + '?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&scale=1&tz=UTC' + ); }); it('should shorten url', async () => { @@ -162,6 +165,17 @@ describe('ShareModal', () => { `http://localhost:3000/goto/${mockUid}` ); }); + + it('should generate render url without shareView param', async () => { + mockLocationHref('http://dashboards.grafana.com/d/abcdefghi/my-dash?shareView=link'); + render(<ShareLink {...props} />); + + const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash'; + const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&scale=1&tz=UTC'; + expect( + await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage }) + ).toHaveAttribute('href', base + params); + }); }); }); @@ -198,7 +212,7 @@ describe('when appUrl is set in the grafana config', () => { await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage }) ).toHaveAttribute( 'href', - `/render/d-solo/${mockDashboard.uid}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}&width=1000&height=500&tz=UTC` + `http://dashboards.grafana.com/render/d-solo/${mockDashboard.uid}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}&width=1000&height=500&scale=1&tz=UTC` ); }); }); diff --git a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx index fa00c6c99c429..18be25f38896a 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx @@ -59,7 +59,7 @@ function getTabs(panel?: PanelModel, activeTab?: string) { if (isPublicDashboardsEnabled()) { tabs.push({ - label: 'Public dashboard', + label: t('share-modal.tab-title.public-dashboard-title', 'Public dashboard'), value: shareDashboardType.publicDashboard, component: SharePublicDashboard, }); @@ -77,7 +77,6 @@ interface Props extends Themeable2 { dashboard: DashboardModel; panel?: PanelModel; activeTab?: string; - onDismiss(): void; } diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx index ce45205fe095d..06b824a5e94bc 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx @@ -17,6 +17,7 @@ import { useStyles2, } from '@grafana/ui/src'; import { Layout } from '@grafana/ui/src/components/Layout/Layout'; +import { Trans, t } from 'app/core/internationalization'; import { useDeletePublicDashboardMutation, useUpdatePublicDashboardMutation, @@ -125,7 +126,10 @@ export function ConfigPublicDashboardBase({ {hasEmailSharingEnabled && <EmailSharingConfiguration />} - <Field label="Dashboard URL" className={styles.fieldSpace}> + <Field + label={t('public-dashboard.config.dashboard-url-field-label', 'Dashboard URL')} + className={styles.fieldSpace} + > <Input value={generatePublicDashboardUrl(publicDashboard!.accessToken!)} readOnly @@ -139,7 +143,7 @@ export function ConfigPublicDashboardBase({ getText={() => generatePublicDashboardUrl(publicDashboard!.accessToken!)} onClipboardCopy={onCopyURL} > - Copy + <Trans i18nKey="public-dashboard.config.copy-button">Copy</Trans> </ClipboardButton> } /> @@ -163,14 +167,14 @@ export function ConfigPublicDashboardBase({ margin-bottom: 0; `} > - Pause sharing dashboard + <Trans i18nKey="public-dashboard.config.pause-sharing-dashboard-label">Pause sharing dashboard</Trans> </Label> </Layout> </Field> <Field className={styles.fieldSpace}> <SettingsBar - title="Settings" + title={t('public-dashboard.config.settings-title', 'Settings')} headerElement={({ className }) => ( <SettingsSummary className={className} @@ -193,8 +197,7 @@ export function ConfigPublicDashboardBase({ > <HorizontalGroup justify="flex-end"> <Button - aria-label="Revoke public URL" - title="Revoke public URL" + title={t('public-dashboard.config.revoke-public-URL-button-title', 'Revoke public URL')} onClick={onRevoke} type="button" disabled={disableInputs} @@ -202,7 +205,7 @@ export function ConfigPublicDashboardBase({ variant="destructive" fill="outline" > - Revoke public URL + <Trans i18nKey="public-dashboard.config.revoke-public-URL-button">Revoke public URL</Trans> </Button> </HorizontalGroup> </Layout> diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/Configuration.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/Configuration.tsx index 7a77ec0f0c551..8a7eeb3e511f2 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/Configuration.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/Configuration.tsx @@ -5,6 +5,7 @@ import { TimeRange } from '@grafana/data/src'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; import { FieldSet, Label, Switch, TimeRangeInput, VerticalGroup } from '@grafana/ui/src'; import { Layout } from '@grafana/ui/src/components/Layout/Layout'; +import { Trans, t } from 'app/core/internationalization'; import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; import { ConfigPublicDashboardForm } from './ConfigPublicDashboard'; @@ -27,8 +28,15 @@ export const Configuration = ({ <FieldSet disabled={disabled}> <VerticalGroup spacing="md"> <Layout orientation={1} spacing="xs" justify="space-between"> - <Label description="The public dashboard uses the default time range settings of the dashboard"> - Default time range + <Label + description={t( + 'public-dashboard.settings-configuration.default-time-range-label-desc', + 'The public dashboard uses the default time range settings of the dashboard' + )} + > + <Trans i18nKey="public-dashboard.settings-configuration.default-time-range-label"> + Default time range + </Trans> </Label> <TimeRangeInput value={timeRange} disabled onChange={() => {}} /> </Layout> @@ -43,7 +51,16 @@ export const Configuration = ({ }); }} /> - <Label description="Allow viewers to change time range">Time range picker enabled</Label> + <Label + description={t( + 'public-dashboard.settings-configuration.time-range-picker-label-desc', + 'Allow viewers to change time range' + )} + > + <Trans i18nKey="public-dashboard.settings-configuration.time-range-picker-label"> + Time range picker enabled + </Trans> + </Label> </Layout> <Layout orientation={0} spacing="sm"> <Switch @@ -56,7 +73,14 @@ export const Configuration = ({ }} data-testid={selectors.EnableAnnotationsSwitch} /> - <Label description="Show annotations on public dashboard">Show annotations</Label> + <Label + description={t( + 'public-dashboard.settings-configuration.show-annotations-label-desc', + 'Show annotations on public dashboard' + )} + > + <Trans i18nKey="public-dashboard.settings-configuration.show-annotations-label">Show annotations</Trans> + </Label> </Layout> </VerticalGroup> </FieldSet> diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/EmailSharingConfiguration.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/EmailSharingConfiguration.tsx index f29e831386349..e58ffdffc7fe5 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/EmailSharingConfiguration.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/EmailSharingConfiguration.tsx @@ -1,21 +1,12 @@ import { css } from '@emotion/css'; import React from 'react'; -import { useForm } from 'react-hook-form'; +import { useForm, Controller } from 'react-hook-form'; import { useWindowSize } from 'react-use'; -import { GrafanaTheme2, SelectableValue } from '@grafana/data/src'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; -import { FieldSet } from '@grafana/ui'; -import { - Button, - ButtonGroup, - Field, - Input, - InputControl, - RadioButtonGroup, - Spinner, - useStyles2, -} from '@grafana/ui/src'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { FieldSet, Button, ButtonGroup, Field, Input, RadioButtonGroup, Spinner, useStyles2 } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; import { contextSrv } from 'app/core/services/context_srv'; import { useAddRecipientMutation, @@ -34,11 +25,6 @@ interface EmailSharingConfigurationForm { email: string; } -const options: Array<SelectableValue<PublicDashboardShareType>> = [ - { label: 'Anyone with a link', value: PublicDashboardShareType.PUBLIC }, - { label: 'Only specified people', value: PublicDashboardShareType.EMAIL }, -]; - const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard.EmailSharingConfiguration; const EmailList = ({ @@ -78,27 +64,25 @@ const EmailList = ({ type="button" variant="destructive" fill="text" - aria-label="Revoke" - title="Revoke" + title={t('public-dashboard.email-sharing.revoke-button-title', 'Revoke')} size="sm" disabled={isLoading} onClick={() => onDeleteEmail(recipient.uid, recipient.recipient)} data-testid={`${selectors.DeleteEmail}-${idx}`} > - Revoke + <Trans i18nKey="public-dashboard.email-sharing.revoke-button">Revoke</Trans> </Button> <Button type="button" variant="primary" fill="text" - aria-label="Resend" - title="Resend" + title={t('public-dashboard.email-sharing.resend-button-title', 'Resend')} size="sm" disabled={isLoading} onClick={() => onReshare(recipient.uid)} data-testid={`${selectors.ReshareLink}-${idx}`} > - Resend + <Trans i18nKey="public-dashboard.email-sharing.resend-button">Resend</Trans> </Button> </ButtonGroup> </td> @@ -158,12 +142,25 @@ export const EmailSharingConfiguration = () => { return ( <form onSubmit={handleSubmit(onSubmit)}> <FieldSet disabled={!hasWritePermissions} data-testid={selectors.Container} className={styles.container}> - <Field label="Can view dashboard" className={styles.field}> - <InputControl + <Field + label={t('public-dashboard.config.can-view-dashboard-radio-button-label', 'Can view dashboard')} + className={styles.field} + > + <Controller name="shareType" control={control} render={({ field }) => { const { ref, ...rest } = field; + const options: Array<SelectableValue<PublicDashboardShareType>> = [ + { + label: t('public-dashboard.config.public-share-type-option-label', 'Anyone with a link'), + value: PublicDashboardShareType.PUBLIC, + }, + { + label: t('public-dashboard.config.email-share-type-option-label', 'Only specified people'), + value: PublicDashboardShareType.EMAIL, + }, + ]; return ( <RadioButtonGroup {...rest} @@ -184,8 +181,8 @@ export const EmailSharingConfiguration = () => { {watch('shareType') === PublicDashboardShareType.EMAIL && ( <> <Field - label="Invite" - description="Invite people by email" + label={t('public-dashboard.email-sharing.invite-field-label', 'Invite')} + description={t('public-dashboard.email-sharing.invite-field-desc', 'Invite people by email')} error={errors.email?.message} invalid={!!errors.email?.message || undefined} className={styles.field} @@ -196,8 +193,11 @@ export const EmailSharingConfiguration = () => { placeholder="email" autoCapitalize="none" {...register('email', { - required: 'Email is required', - pattern: { value: validEmailRegex, message: 'Invalid email' }, + required: t('public-dashboard.email-sharing.input-required-email-text', 'Email is required'), + pattern: { + value: validEmailRegex, + message: t('public-dashboard.email-sharing.input-invalid-email-text', 'Invalid email'), + }, })} data-testid={selectors.EmailSharingInput} /> @@ -207,7 +207,8 @@ export const EmailSharingConfiguration = () => { disabled={isAddEmailLoading} data-testid={selectors.EmailSharingInviteButton} > - Invite {isAddEmailLoading && <Spinner />} + <Trans i18nKey="public-dashboard.email-sharing.invite-button">Invite</Trans> + {isAddEmailLoading && <Spinner />} </Button> </div> </Field> diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/SettingsBarHeader.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/SettingsBarHeader.tsx index 66150a33b9925..cb74d7bc546ef 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/SettingsBarHeader.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/SettingsBarHeader.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { IconButton, ReactUtils, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; export interface Props { onRowToggle: () => void; @@ -22,7 +23,11 @@ export function SettingsBarHeader({ headerElement, isContentVisible = false, onR <div className={styles.header}> <IconButton name={isContentVisible ? 'angle-down' : 'angle-right'} - tooltip={isContentVisible ? 'Collapse settings' : 'Expand settings'} + tooltip={ + isContentVisible + ? t('public-dashboard.settings-bar-header.collapse-settings-tooltip', 'Collapse settings') + : t('public-dashboard.settings-bar-header.expand-settings-tooltip', 'Expand settings') + } className={styles.collapseIcon} onClick={onRowToggle} aria-expanded={isContentVisible} diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/SettingsSummary.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/SettingsSummary.tsx index cd1ab210692a5..f8fb436d04219 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/SettingsSummary.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/SettingsSummary.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { GrafanaTheme2, TimeRange } from '@grafana/data'; import { Spinner, TimeRangeLabel, useStyles2 } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; export interface Props { timeRange: TimeRange; @@ -21,6 +22,23 @@ export function SettingsSummary({ }: Props) { const styles = useStyles2(getStyles); + const translatedTimeRangePickerEnabledStatus = t( + 'public-dashboard.settings-summary.time-range-picker-enabled-text', + 'Time range picker = enabled' + ); + const translatedTimeRangePickerDisabledStatus = t( + 'public-dashboard.settings-summary.time-range-picker-disabled-text', + 'Time range picker = disabled' + ); + const translatedAnnotationShownStatus = t( + 'public-dashboard.settings-summary.annotations-show-text', + 'Annotations = show' + ); + const translatedAnnotationHiddenStatus = t( + 'public-dashboard.settings-summary.annotations-hide-text', + 'Annotations = hide' + ); + return isDataLoading ? ( <div className={cx(styles.summaryWrapper, className)}> <Spinner className={styles.summary} inline={true} size="sm" /> @@ -28,11 +46,15 @@ export function SettingsSummary({ ) : ( <div className={cx(styles.summaryWrapper, className)}> <span className={styles.summary}> - {'Time range = '} + <Trans i18nKey="public-dashboard.settings-summary.time-range-text">Time range = </Trans> <TimeRangeLabel className={styles.timeRange} value={timeRange} /> </span> - <span className={styles.summary}>{`Time range picker = ${timeSelectionEnabled ? 'enabled' : 'disabled'}`}</span> - <span className={styles.summary}>{`Annotations = ${annotationsEnabled ? 'show' : 'hide'}`}</span> + <span className={styles.summary}> + {timeSelectionEnabled ? translatedTimeRangePickerEnabledStatus : translatedTimeRangePickerDisabledStatus} + </span> + <span className={styles.summary}> + {annotationsEnabled ? translatedAnnotationShownStatus : translatedAnnotationHiddenStatus} + </span> </div> ); } diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/AcknowledgeCheckboxes.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/AcknowledgeCheckboxes.tsx index 22cdb240c85bb..a00e20809e19c 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/AcknowledgeCheckboxes.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/AcknowledgeCheckboxes.tsx @@ -5,6 +5,7 @@ import { UseFormRegister } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data/src'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; import { Checkbox, FieldSet, HorizontalGroup, LinkButton, useStyles2, VerticalGroup } from '@grafana/ui/src'; +import { t, Trans } from 'app/core/internationalization'; import { SharePublicDashboardAcknowledgmentInputs } from './CreatePublicDashboard'; @@ -19,37 +20,6 @@ type Acknowledge = { }; const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard; -const ACKNOWLEDGES: Acknowledge[] = [ - { - type: 'publicAcknowledgment', - description: 'Your entire dashboard will be public*', - testId: selectors.WillBePublicCheckbox, - info: { - href: 'https://grafana.com/docs/grafana/latest/dashboards/dashboard-public/', - tooltip: 'Learn more about public dashboards', - }, - }, - { - type: 'dataSourcesAcknowledgment', - description: 'Publishing currently only works with a subset of data sources*', - testId: selectors.LimitedDSCheckbox, - info: { - href: 'https://grafana.com/docs/grafana/latest/datasources/', - tooltip: 'Learn more about public datasources', - }, - }, - { - type: 'usageAcknowledgment', - description: - 'Making a dashboard public will cause queries to run each time it is viewed, which may increase costs*', - testId: selectors.CostIncreaseCheckbox, - info: { - href: 'https://grafana.com/docs/grafana/latest/enterprise/query-caching/', - tooltip: 'Learn more about query caching', - }, - }, -]; - export const AcknowledgeCheckboxes = ({ disabled, register, @@ -58,10 +28,61 @@ export const AcknowledgeCheckboxes = ({ register: UseFormRegister<SharePublicDashboardAcknowledgmentInputs>; }) => { const styles = useStyles2(getStyles); + const ACKNOWLEDGES: Acknowledge[] = [ + { + type: 'publicAcknowledgment', + description: t( + 'public-dashboard.acknowledgment-checkboxes.public-ack-desc', + 'Your entire dashboard will be public*' + ), + testId: selectors.WillBePublicCheckbox, + info: { + href: 'https://grafana.com/docs/grafana/latest/dashboards/dashboard-public/', + tooltip: t( + 'public-dashboard.acknowledgment-checkboxes.public-ack-tooltip', + 'Learn more about public dashboards' + ), + }, + }, + { + type: 'dataSourcesAcknowledgment', + description: t( + 'public-dashboard.acknowledgment-checkboxes.data-src-ack-desc', + 'Publishing currently only works with a subset of data sources*' + ), + testId: selectors.LimitedDSCheckbox, + info: { + href: 'https://grafana.com/docs/grafana/latest/datasources/', + tooltip: t( + 'public-dashboard.acknowledgment-checkboxes.data-src-ack-tooltip', + 'Learn more about public datasources' + ), + }, + }, + { + type: 'usageAcknowledgment', + description: t( + 'public-dashboard.acknowledgment-checkboxes.usage-ack-desc', + 'Making a dashboard public will cause queries to run each time it is viewed, which may increase costs*' + ), + testId: selectors.CostIncreaseCheckbox, + info: { + href: 'https://grafana.com/docs/grafana/latest/enterprise/query-caching/', + tooltip: t( + 'public-dashboard.acknowledgment-checkboxes.usage-ack-desc-tooltip', + 'Learn more about query caching' + ), + }, + }, + ]; return ( <> - <p className={styles.title}>Before you make the dashboard public, acknowledge the following:</p> + <p className={styles.title}> + <Trans i18nKey="public-dashboard.acknowledgment-checkboxes.ack-title"> + Before you make the dashboard public, acknowledge the following: + </Trans> + </p> <FieldSet disabled={disabled}> <VerticalGroup spacing="md"> {ACKNOWLEDGES.map((acknowledge) => ( diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/CreatePublicDashboard.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/CreatePublicDashboard.tsx index 73506e3074302..241949ae6ea88 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/CreatePublicDashboard.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/CreatePublicDashboard.tsx @@ -1,17 +1,18 @@ import { css } from '@emotion/css'; import React from 'react'; -import { FormState, UseFormRegister } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; -import { GrafanaTheme2 } from '@grafana/data/src'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; -import { Button, Form, Spinner, useStyles2 } from '@grafana/ui/src'; +import { GrafanaTheme2 } from '@grafana/data'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { Button, Spinner, useStyles2 } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; +import { contextSrv } from 'app/core/services/context_srv'; import { useCreatePublicDashboardMutation } from 'app/features/dashboard/api/publicDashboardApi'; import { DashboardModel } from 'app/features/dashboard/state'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; +import { AccessControlAction, useSelector } from 'app/types'; -import { contextSrv } from '../../../../../../core/services/context_srv'; -import { AccessControlAction, useSelector } from '../../../../../../types'; import { NoUpsertPermissionsAlert } from '../ModalAlerts/NoUpsertPermissionsAlert'; import { UnsupportedDataSourcesAlert } from '../ModalAlerts/UnsupportedDataSourcesAlert'; import { UnsupportedTemplateVariablesAlert } from '../ModalAlerts/UnsupportedTemplateVariablesAlert'; @@ -48,14 +49,25 @@ export const CreatePublicDashboardBase = ({ createPublicDashboard({ dashboard, payload: { isEnabled: true } }); DashboardInteractions.generatePublicDashboardUrlClicked({}); }; + const { + handleSubmit, + register, + formState: { isValid }, + } = useForm<SharePublicDashboardAcknowledgmentInputs>({ mode: 'onChange' }); const disableInputs = !hasWritePermissions || isLoading || isError || hasError; return ( <div className={styles.container}> <div> - <p className={styles.title}>Welcome to public dashboards!</p> - <p className={styles.description}>Currently, we don’t support template variables or frontend data sources</p> + <p className={styles.title}> + <Trans i18nKey="public-dashboard.create-page.welcome-title">Welcome to public dashboards!</Trans> + </p> + <p className={styles.description}> + <Trans i18nKey="public-dashboard.create-page.unsupported-features-desc"> + Currently, we don’t support template variables or frontend data sources + </Trans> + </p> </div> {!hasWritePermissions && <NoUpsertPermissionsAlert mode="create" />} @@ -66,26 +78,17 @@ export const CreatePublicDashboardBase = ({ <UnsupportedDataSourcesAlert unsupportedDataSources={unsupportedDatasources.join(', ')} /> )} - <Form onSubmit={onCreate} validateOn="onChange" maxWidth="none"> - {({ - register, - formState: { isValid }, - }: { - register: UseFormRegister<SharePublicDashboardAcknowledgmentInputs>; - formState: FormState<SharePublicDashboardAcknowledgmentInputs>; - }) => ( - <> - <div className={styles.checkboxes}> - <AcknowledgeCheckboxes disabled={disableInputs} register={register} /> - </div> - <div className={styles.buttonContainer}> - <Button type="submit" disabled={disableInputs || !isValid} data-testid={selectors.CreateButton}> - Generate public URL {isLoading && <Spinner className={styles.loadingSpinner} />} - </Button> - </div> - </> - )} - </Form> + <form onSubmit={handleSubmit(onCreate)}> + <div className={styles.checkboxes}> + <AcknowledgeCheckboxes disabled={disableInputs} register={register} /> + </div> + <div className={styles.buttonContainer}> + <Button type="submit" disabled={disableInputs || !isValid} data-testid={selectors.CreateButton}> + <Trans i18nKey="public-dashboard.create-page.generate-public-url-button">Generate public URL</Trans> + {isLoading && <Spinner className={styles.loadingSpinner} />} + </Button> + </div> + </form> </div> ); }; diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/NoUpsertPermissionsAlert.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/NoUpsertPermissionsAlert.tsx index 4b56dbdd27285..198a0fdbe6377 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/NoUpsertPermissionsAlert.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/NoUpsertPermissionsAlert.tsx @@ -2,16 +2,23 @@ import React from 'react'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; import { Alert } from '@grafana/ui/src'; +import { Trans, t } from 'app/core/internationalization'; const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard; export const NoUpsertPermissionsAlert = ({ mode }: { mode: 'create' | 'edit' }) => ( <Alert severity="info" - title={`You don’t have permission to ${mode} a public dashboard`} + title={t( + 'public-dashboard.modal-alerts.no-upsert-perm-alert-title', + 'You don’t have permission to {{ mode }} a public dashboard', + { mode } + )} data-testid={selectors.NoUpsertPermissionsWarningAlert} bottomSpacing={0} > - Contact your admin to get permission to {mode} public dashboards + <Trans i18nKey="public-dashboard.modal-alerts.no-upsert-perm-alert-desc"> + Contact your admin to get permission to {{ mode }} public dashboards + </Trans> </Alert> ); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/SaveDashboardChangesAlert.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/SaveDashboardChangesAlert.tsx index 3eb5d681d8ada..4534754c8dd63 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/SaveDashboardChangesAlert.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/SaveDashboardChangesAlert.tsx @@ -1,10 +1,14 @@ import React from 'react'; import { Alert } from '@grafana/ui/src'; +import { t } from 'app/core/internationalization'; export const SaveDashboardChangesAlert = () => ( <Alert - title="Please save your dashboard changes before updating the public configuration" + title={t( + 'public-dashboard.modal-alerts.save-dashboard-changes-alert-title', + 'Please save your dashboard changes before updating the public configuration' + )} severity="warning" bottomSpacing={0} /> diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert.tsx index 5a81e84764c22..20edf3983166f 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data/src'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; import { Alert, useStyles2 } from '@grafana/ui/src'; +import { Trans, t } from 'app/core/internationalization'; const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard; @@ -14,19 +15,23 @@ export const UnsupportedDataSourcesAlert = ({ unsupportedDataSources }: { unsupp return ( <Alert severity="warning" - title="Unsupported data sources" + title={t('public-dashboard.modal-alerts.unsupported-data-source-alert-title', 'Unsupported data sources')} data-testid={selectors.UnsupportedDataSourcesWarningAlert} bottomSpacing={0} > <p className={styles.unsupportedDataSourceDescription}> - There are data sources in this dashboard that are unsupported for public dashboards. Panels that use these data - sources may not function properly: {unsupportedDataSources}. + <Trans i18nKey="public-dashboard.modal-alerts.unsupported-data-source-alert-desc"> + There are data sources in this dashboard that are unsupported for public dashboards. Panels that use these + data sources may not function properly: {{ unsupportedDataSources }}. + </Trans> </p> <a href="https://grafana.com/docs/grafana/next/dashboards/dashboard-public/" className={cx('text-link', styles.unsupportedDataSourceDescription)} > - Read more about supported data sources + <Trans i18nKey="public-dashboard.modal-alerts.unsupport-data-source-alert-readmore-link"> + Read more about supported data sources + </Trans> </a> </Alert> ); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedTemplateVariablesAlert.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedTemplateVariablesAlert.tsx index 74fe2f910da4b..a8cc47ccc1f6e 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedTemplateVariablesAlert.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedTemplateVariablesAlert.tsx @@ -2,16 +2,22 @@ import React from 'react'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; import { Alert } from '@grafana/ui/src'; +import { Trans, t } from 'app/core/internationalization'; const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard; export const UnsupportedTemplateVariablesAlert = () => ( <Alert severity="warning" - title="Template variables are not supported" + title={t( + 'public-dashboard.modal-alerts.unsupported-template-variable-alert-title', + 'Template variables are not supported' + )} data-testid={selectors.TemplateVariablesWarningAlert} bottomSpacing={0} > - This public dashboard may not work since it uses template variables + <Trans i18nKey="public-dashboard.modal-alerts.unsupported-template-variable-alert-desc"> + This public dashboard may not work since it uses template variables + </Trans> </Alert> ); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx index e5d26f503a8f6..56970d1487fe8 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx @@ -1,9 +1,9 @@ +import 'whatwg-fetch'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { rest } from 'msw'; +import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; -import 'whatwg-fetch'; import { BootData, DataQuery } from '@grafana/data/src'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; import { reportInteraction, setEchoSrv } from '@grafana/runtime'; @@ -87,20 +87,27 @@ afterEach(() => { }); const getNonExistentPublicDashboardResponse = () => - rest.get('/api/dashboards/uid/:dashboardUid/public-dashboards', (req, res, ctx) => { - return res( - ctx.status(404), - ctx.json({ + http.get('/api/dashboards/uid/:dashboardUid/public-dashboards', () => { + return HttpResponse.json( + { message: 'Public dashboard not found', messageId: 'publicdashboards.notFound', statusCode: 404, traceID: '', - }) + }, + { + status: 404, + } ); }); const getErrorPublicDashboardResponse = () => - rest.get('/api/dashboards/uid/:dashboardUid/public-dashboards', (req, res, ctx) => { - return res(ctx.status(500)); + http.get('/api/dashboards/uid/:dashboardUid/public-dashboards', () => { + return HttpResponse.json( + {}, + { + status: 500, + } + ); }); const alertTests = () => { @@ -277,14 +284,11 @@ describe('SharePublic - Already persisted', () => { }); it('when modal is opened, then time range switch is enabled and not checked when its not checked in the db', async () => { server.use( - rest.get('/api/dashboards/uid/:dashboardUid/public-dashboards', (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - ...pubdashResponse, - timeSelectionEnabled: false, - }) - ); + http.get('/api/dashboards/uid/:dashboardUid/public-dashboards', () => { + return HttpResponse.json({ + ...pubdashResponse, + timeSelectionEnabled: false, + }); }) ); @@ -303,17 +307,16 @@ describe('SharePublic - Already persisted', () => { }); it('when pubdash is disabled in the db, then link url is not copyable and switch is checked', async () => { server.use( - rest.get('/api/dashboards/uid/:dashboardUid/public-dashboards', (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - isEnabled: false, - annotationsEnabled: false, - uid: 'a-uid', - dashboardUid: req.params.dashboardUid, - accessToken: 'an-access-token', - }) - ); + http.get('/api/dashboards/uid/:dashboardUid/public-dashboards', ({ request }) => { + const url = new URL(request.url); + const dashboardUid = url.searchParams.get('dashboardUid'); + return HttpResponse.json({ + isEnabled: false, + annotationsEnabled: false, + uid: 'a-uid', + dashboardUid, + accessToken: 'an-access-token', + }); }) ); @@ -339,15 +342,14 @@ describe('SharePublic - Report interactions', () => { jest.clearAllMocks(); server.use(getExistentPublicDashboardResponse()); server.use( - rest.patch('/api/dashboards/uid/:dashboardUid/public-dashboards/:uid', (req, res, ctx) => - res( - ctx.status(200), - ctx.json({ - ...pubdashResponse, - dashboardUid: req.params.dashboardUid, - }) - ) - ) + http.patch('/api/dashboards/uid/:dashboardUid/public-dashboards/:uid', ({ request }) => { + const url = new URL(request.url); + const dashboardUid = url.searchParams.get('dashboardUid'); + return HttpResponse.json({ + ...pubdashResponse, + dashboardUid, + }); + }) ); }); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/utilsTest.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/utilsTest.tsx index 3d2bc135502f5..382c499e3a567 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/utilsTest.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/utilsTest.tsx @@ -1,5 +1,6 @@ +import 'whatwg-fetch'; import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; -import { rest } from 'msw'; +import { http, HttpResponse } from 'msw'; import React from 'react'; import { Provider } from 'react-redux'; @@ -32,15 +33,14 @@ export const pubdashResponse: sharePublicDashboardUtils.PublicDashboard = { }; export const getExistentPublicDashboardResponse = (publicDashboard?: Partial<PublicDashboard>) => - rest.get('/api/dashboards/uid/:dashboardUid/public-dashboards', (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - ...pubdashResponse, - ...publicDashboard, - dashboardUid: req.params.dashboardUid, - }) - ); + http.get('/api/dashboards/uid/:dashboardUid/public-dashboards', ({ request }) => { + const url = new URL(request.url); + const dashboardUid = url.searchParams.get('dashboardUid'); + return HttpResponse.json({ + ...pubdashResponse, + ...publicDashboard, + dashboardUid, + }); }); export const renderSharePublicDashboard = async ( diff --git a/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx index 18130b09b57a0..aa449768aa70a 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx @@ -13,8 +13,6 @@ import { getDashboardSnapshotSrv } from '../../services/SnapshotSrv'; import { ShareModalTabProps } from './types'; -const snapshotApiUrl = '/api/snapshots'; - interface Props extends ShareModalTabProps {} interface State { @@ -38,10 +36,6 @@ export class ShareSnapshot extends PureComponent<Props, State> { super(props); this.dashboard = props.dashboard; this.expireOptions = [ - { - label: t('share-modal.snapshot.expire-never', `Never`), - value: 0, - }, { label: t('share-modal.snapshot.expire-hour', `1 Hour`), value: 60 * 60, @@ -51,15 +45,19 @@ export class ShareSnapshot extends PureComponent<Props, State> { value: 60 * 60 * 24, }, { - label: t('share-modal.snapshot.expire-week', `7 Days`), + label: t('share-modal.snapshot.expire-week', `1 Week`), value: 60 * 60 * 24 * 7, }, + { + label: t('share-modal.snapshot.expire-never', `Never`), + value: 0, + }, ]; this.state = { isLoading: false, step: 1, - selectedExpireOption: this.expireOptions[0], - snapshotExpires: this.expireOptions[0].value, + selectedExpireOption: this.expireOptions[2], + snapshotExpires: this.expireOptions[2].value, snapshotName: props.dashboard.title, timeoutSeconds: 4, snapshotUrl: '', @@ -109,7 +107,7 @@ export class ShareSnapshot extends PureComponent<Props, State> { }; try { - const results: { deleteUrl: string; url: string } = await getBackendSrv().post(snapshotApiUrl, cmdData); + const results = await getDashboardSnapshotSrv().create(cmdData); this.setState({ deleteUrl: results.deleteUrl, snapshotUrl: results.url, @@ -279,7 +277,7 @@ export class ShareSnapshot extends PureComponent<Props, State> { </Button> )} <Button variant="primary" disabled={isLoading} onClick={this.createSnapshot()}> - <Trans i18nKey="share-modal.snapshot.local-button">Local Snapshot</Trans> + <Trans i18nKey="share-modal.snapshot.local-button">Publish Snapshot</Trans> </Button> </Modal.ButtonRow> </> diff --git a/public/app/features/dashboard/components/ShareModal/utils.ts b/public/app/features/dashboard/components/ShareModal/utils.ts index 1d760b40052fb..90b1664c4926e 100644 --- a/public/app/features/dashboard/components/ShareModal/utils.ts +++ b/public/app/features/dashboard/components/ShareModal/utils.ts @@ -52,6 +52,10 @@ export function buildParams({ // Token is unique to the authenticated identity and should not be shared with the URL, // so we are stripping it from the query params as a safety measure. searchParams.delete('auth_token'); + + // The shareView param is used to indicate that the sharing modal is open and should never be included in the URL + searchParams.delete('shareView'); + return searchParams; } @@ -117,7 +121,12 @@ export function buildImageUrl( let soloUrl = buildSoloUrl(useCurrentTimeRange, dashboardUid, selectedTheme, panel); let imageUrl = soloUrl.replace(config.appSubUrl + '/dashboard-solo/', config.appSubUrl + '/render/dashboard-solo/'); imageUrl = imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/'); - imageUrl += '&width=1000&height=500' + getLocalTimeZone(); + imageUrl += + `&width=${config.rendererDefaultImageWidth}` + + `&height=${config.rendererDefaultImageHeight}` + + `&scale=${config.rendererDefaultImageScale}` + + getLocalTimeZone(); + return imageUrl; } diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx index dcd270521c04b..b45ca71a03f44 100644 --- a/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx @@ -6,10 +6,10 @@ import { selectors } from '@grafana/e2e-selectors'; import { TimeRangeUpdatedEvent } from '@grafana/runtime'; import { DashboardLink } from '@grafana/schema'; import { Tooltip, useForceUpdate } from '@grafana/ui'; +import { LINK_ICON_MAP } from 'app/features/dashboard-scene/settings/links/utils'; import { getLinkSrv } from '../../../panel/panellinks/link_srv'; import { DashboardModel } from '../../state'; -import { linkIconMap } from '../LinksSettings/LinkSettingsEdit'; import { DashboardLinkButton, DashboardLinksDashboard } from './DashboardLinksDashboard'; @@ -40,7 +40,7 @@ export const DashboardLinks = ({ dashboard, links }: Props) => { return <DashboardLinksDashboard key={key} link={link} linkInfo={linkInfo} dashboardUID={dashboard.uid} />; } - const icon = linkIconMap[link.icon]; + const icon = LINK_ICON_MAP[link.icon]; const linkElement = ( <DashboardLinkButton @@ -55,7 +55,7 @@ export const DashboardLinks = ({ dashboard, links }: Props) => { ); return ( - <div key={key} className="gf-form" data-testid={selectors.components.DashboardLinks.container}> + <div key={key} data-testid={selectors.components.DashboardLinks.container}> {link.tooltip ? <Tooltip content={linkInfo.tooltip}>{linkElement}</Tooltip> : linkElement} </div> ); diff --git a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx index 16a5742e9d481..85e77fc787a15 100644 --- a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx +++ b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx @@ -51,10 +51,6 @@ class SubMenuUnConnected extends PureComponent<Props> { const styles = getStyles(theme); - if (!dashboard.isSubMenuVisible()) { - return null; - } - const readOnlyVariables = dashboard.meta.isSnapshot ?? false; return ( diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationEditorHelperModal.test.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationEditorHelpDisplay.test.tsx similarity index 77% rename from public/app/features/dashboard/components/TransformationsEditor/TransformationEditorHelperModal.test.tsx rename to public/app/features/dashboard/components/TransformationsEditor/TransformationEditorHelpDisplay.test.tsx index 0d6cd74f32294..b365b8d050432 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationEditorHelperModal.test.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationEditorHelpDisplay.test.tsx @@ -2,9 +2,10 @@ import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { TransformerRegistryItem } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; -import { TransformationEditorHelperModal } from './TransformationEditorHelperModal'; +import { TransformationEditorHelpDisplay } from './TransformationEditorHelpDisplay'; // Mock the onCloseClick function const mockOnCloseClick = jest.fn(); @@ -13,16 +14,17 @@ const standardTransformers: Array<TransformerRegistryItem<null>> = getStandardTr const singleTestTransformer: TransformerRegistryItem<null> = standardTransformers[0]; -describe('TransformationEditorHelperModal', () => { +describe('TransformationEditorHelpDisplay', () => { it('renders the modal with the correct title and content', () => { // Test each transformer standardTransformers.forEach((transformer) => { const { unmount } = render( - <TransformationEditorHelperModal isOpen={true} onCloseClick={mockOnCloseClick} transformer={transformer} /> + <TransformationEditorHelpDisplay isOpen={true} onCloseClick={mockOnCloseClick} transformer={transformer} /> ); // Check if the modal title is rendered with the correct text - expect(screen.getByText(`Transformation help - ${transformer.transformation.name}`)).toBeInTheDocument(); + expect(screen.getByText(`Transformation help`)).toBeInTheDocument(); + expect(screen.getByTestId(selectors.components.Drawer.General.subtitle)).toBeInTheDocument(); // Unmount the component to clean up unmount(); @@ -31,7 +33,7 @@ describe('TransformationEditorHelperModal', () => { it('calls onCloseClick when the modal is dismissed', () => { render( - <TransformationEditorHelperModal + <TransformationEditorHelpDisplay isOpen={true} onCloseClick={mockOnCloseClick} transformer={singleTestTransformer} @@ -39,7 +41,7 @@ describe('TransformationEditorHelperModal', () => { ); // Find and click the modal's close button - const closeButton = screen.getByRole('button', { name: 'Close' }); + const closeButton = screen.getByTestId('data-testid Drawer close'); fireEvent.click(closeButton); // Ensure that the onCloseClick function was called with the correct argument @@ -48,7 +50,7 @@ describe('TransformationEditorHelperModal', () => { it('does not render when isOpen is false', () => { render( - <TransformationEditorHelperModal + <TransformationEditorHelpDisplay isOpen={false} onCloseClick={mockOnCloseClick} transformer={singleTestTransformer} @@ -56,14 +58,14 @@ describe('TransformationEditorHelperModal', () => { ); // Ensure that the modal is not rendered - expect(screen.queryByText(`Transformation help - ${singleTestTransformer.name}`)).toBeNull(); + expect(screen.queryByText(`Transformation help`)).toBeNull(); }); it('renders a default message when help content is not provided', () => { const transformerWithoutHelp = { ...singleTestTransformer, help: undefined }; render( - <TransformationEditorHelperModal + <TransformationEditorHelpDisplay isOpen={true} onCloseClick={mockOnCloseClick} transformer={transformerWithoutHelp} @@ -80,7 +82,7 @@ describe('TransformationEditorHelperModal', () => { const transformerWithCustomHelp = { ...singleTestTransformer, help: customHelpContent }; render( - <TransformationEditorHelperModal + <TransformationEditorHelpDisplay isOpen={true} onCloseClick={mockOnCloseClick} transformer={transformerWithCustomHelp} diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationEditorHelperModal.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationEditorHelpDisplay.tsx similarity index 59% rename from public/app/features/dashboard/components/TransformationsEditor/TransformationEditorHelperModal.tsx rename to public/app/features/dashboard/components/TransformationsEditor/TransformationEditorHelpDisplay.tsx index 3d39e19853a8f..47fce758daa46 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationEditorHelperModal.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationEditorHelpDisplay.tsx @@ -1,39 +1,33 @@ import React from 'react'; import { TransformerRegistryItem } from '@grafana/data'; -import { Modal } from '@grafana/ui'; +import { Drawer } from '@grafana/ui'; import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp'; import { getLinkToDocs } from '../../../transformers/docs/content'; -interface TransformationEditorHelperModalProps { +interface TransformationEditorHelpDisplayProps { isOpen: boolean; onCloseClick: (value: boolean) => void; transformer: TransformerRegistryItem<null>; } -export const TransformationEditorHelperModal = ({ +export const TransformationEditorHelpDisplay = ({ isOpen, onCloseClick, transformer, -}: TransformationEditorHelperModalProps) => { +}: TransformationEditorHelpDisplayProps) => { const { transformation: { name }, help, } = transformer; const helpContent = help ? help : getLinkToDocs(); - - const helpTitle = `Transformation help - ${name}`; - - return ( - <Modal - title={helpTitle} - isOpen={isOpen} - onClickBackdrop={() => onCloseClick(false)} - onDismiss={() => onCloseClick(false)} - > + const helpElement = ( + <Drawer title={name} subtitle="Transformation help" onClose={() => onCloseClick(false)}> <OperationRowHelp markdown={helpContent} styleOverrides={{ borderTop: '2px solid' }} /> - </Modal> + </Drawer> ); + + return isOpen ? helpElement : null; }; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx index af96c50c98e7d..dce12d1178138 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx @@ -2,39 +2,69 @@ import { css } from '@emotion/css'; import React, { useMemo } from 'react'; import { - DataFrame, DataTransformerConfig, GrafanaTheme2, StandardEditorContext, StandardEditorsRegistryItem, } from '@grafana/data'; -import { Field, useStyles2 } from '@grafana/ui'; +import { DataTopic } from '@grafana/schema'; +import { Field, Select, useStyles2 } from '@grafana/ui'; import { FrameSelectionEditor } from 'app/plugins/panel/geomap/editor/FrameSelectionEditor'; +import { TransformationData } from './TransformationsEditor'; + interface TransformationFilterProps { index: number; config: DataTransformerConfig; - data: DataFrame[]; + data: TransformationData; onChange: (index: number, config: DataTransformerConfig) => void; } export const TransformationFilter = ({ index, data, config, onChange }: TransformationFilterProps) => { const styles = useStyles2(getStyles); - const context = useMemo(() => { - // eslint-disable-next-line - return { data } as StandardEditorContext<unknown>; - }, [data]); + + const opts = useMemo(() => { + return { + // eslint-disable-next-line + context: { data: data.series } as StandardEditorContext<unknown>, + showTopic: true || data.annotations?.length || config.topic?.length, + showFilter: config.topic !== DataTopic.Annotations, + source: [ + { value: DataTopic.Series, label: `Query results` }, + { value: DataTopic.Annotations, label: `Annotation data` }, + ], + }; + }, [data, config.topic]); return ( <div className={styles.wrapper}> <Field label="Apply transformation to"> - <FrameSelectionEditor - value={config.filter!} - context={context} - // eslint-disable-next-line - item={{} as StandardEditorsRegistryItem} - onChange={(filter) => onChange(index, { ...config, filter })} - /> + <> + {opts.showTopic && ( + <Select + isClearable={true} + options={opts.source} + value={opts.source.find((v) => v.value === config.topic)} + placeholder={opts.source[0].label} + className={styles.padded} + onChange={(option) => { + onChange(index, { + ...config, + topic: option?.value, + }); + }} + /> + )} + {opts.showFilter && ( + <FrameSelectionEditor + value={config.filter!} + context={opts.context} + // eslint-disable-next-line + item={{} as StandardEditorsRegistryItem} + onChange={(filter) => onChange(index, { ...config, filter })} + /> + )} + </> </Field> </div> ); @@ -44,13 +74,16 @@ const getStyles = (theme: GrafanaTheme2) => { const borderRadius = theme.shape.radius.default; return { - wrapper: css` - padding: ${theme.spacing(2)}; - border: 2px solid ${theme.colors.background.secondary}; - border-top: none; - border-radius: 0 0 ${borderRadius} ${borderRadius}; - position: relative; - top: -4px; - `, + wrapper: css({ + padding: theme.spacing(2), + border: `2px solid ${theme.colors.background.secondary}`, + borderTop: `none`, + borderRadius: `0 0 ${borderRadius} ${borderRadius}`, + position: `relative`, + top: `-4px`, + }), + padded: css({ + marginBottom: theme.spacing(1), + }), }; }; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx index 4c4c85e31a3b5..aa5f6df86f566 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx @@ -1,7 +1,8 @@ import React, { useCallback } from 'react'; import { useToggle } from 'react-use'; -import { DataFrame, DataTransformerConfig, TransformerRegistryItem, FrameMatcherID } from '@grafana/data'; +import { DataTransformerConfig, TransformerRegistryItem, FrameMatcherID, DataTopic } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { reportInteraction } from '@grafana/runtime'; import { ConfirmModal } from '@grafana/ui'; import { @@ -13,14 +14,15 @@ import config from 'app/core/config'; import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo'; import { TransformationEditor } from './TransformationEditor'; -import { TransformationEditorHelperModal } from './TransformationEditorHelperModal'; +import { TransformationEditorHelpDisplay } from './TransformationEditorHelpDisplay'; import { TransformationFilter } from './TransformationFilter'; +import { TransformationData } from './TransformationsEditor'; import { TransformationsEditorTransformation } from './types'; interface TransformationOperationRowProps { id: string; index: number; - data: DataFrame[]; + data: TransformationData; uiConfig: TransformerRegistryItem<null>; configs: TransformationsEditorTransformation[]; onRemove: (index: number) => void; @@ -40,8 +42,9 @@ export const TransformationOperationRow = ({ const [showDebug, toggleShowDebug] = useToggle(false); const [showHelp, toggleShowHelp] = useToggle(false); const disabled = !!configs[index].transformation.disabled; - const filter = configs[index].transformation.filter != null; - const showFilter = filter || data.length > 1; + const topic = configs[index].transformation.topic; + const showFilterEditor = configs[index].transformation.filter != null || topic != null; + const showFilterToggle = showFilterEditor || data.series.length > 1 || (data.annotations?.length ?? 0) > 0; const onDisableToggle = useCallback( (index: number) => { @@ -99,12 +102,12 @@ export const TransformationOperationRow = ({ onClick={instrumentToggleCallback(toggleShowHelp, 'help', showHelp)} active={showHelp} /> - {showFilter && ( + {showFilterToggle && ( <QueryOperationToggleAction title="Filter" icon="filter" - onClick={instrumentToggleCallback(toggleFilter, 'filter', filter)} - active={filter} + onClick={instrumentToggleCallback(toggleFilter, 'filter', showFilterEditor)} + active={showFilterEditor} /> )} <QueryOperationToggleAction @@ -118,6 +121,7 @@ export const TransformationOperationRow = ({ icon={disabled ? 'eye-slash' : 'eye'} onClick={instrumentToggleCallback(() => onDisableToggle(index), 'disabled', disabled)} active={disabled} + dataTestId={selectors.components.Transforms.disableTransformationButton} /> <QueryOperationAction title="Remove" @@ -156,20 +160,21 @@ export const TransformationOperationRow = ({ open: 'Expand transformation row', }} > - {filter && ( + {showFilterEditor && ( <TransformationFilter index={index} config={configs[index].transformation} data={data} onChange={onChange} /> )} + <TransformationEditor debugMode={showDebug} index={index} - data={data} + data={topic === DataTopic.Annotations ? data.annotations ?? [] : data.series} configs={configs} uiConfig={uiConfig} onChange={onChange} toggleShowDebug={toggleShowDebug} /> </QueryOperationRow> - <TransformationEditorHelperModal transformer={uiConfig} isOpen={showHelp} onCloseClick={toggleShowHelp} /> + <TransformationEditorHelpDisplay transformer={uiConfig} isOpen={showHelp} onCloseClick={toggleShowHelp} /> </> ); }; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRows.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRows.tsx index 6b845a1a682bf..6a3ba81a21411 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRows.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRows.tsx @@ -1,12 +1,13 @@ import React from 'react'; -import { DataFrame, DataTransformerConfig, standardTransformersRegistry } from '@grafana/data'; +import { DataTransformerConfig, standardTransformersRegistry } from '@grafana/data'; import { TransformationOperationRow } from './TransformationOperationRow'; +import { TransformationData } from './TransformationsEditor'; import { TransformationsEditorTransformation } from './types'; interface TransformationOperationRowsProps { - data: DataFrame[]; + data: TransformationData; configs: TransformationsEditorTransformation[]; onRemove: (index: number) => void; onChange: (index: number, config: DataTransformerConfig) => void; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx index 1866f294e7381..09304c25133a6 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx @@ -1,5 +1,5 @@ import { cx, css } from '@emotion/css'; -import React, { FormEventHandler, KeyboardEventHandler, ReactNode } from 'react'; +import React, { FormEventHandler, KeyboardEventHandler, ReactNode, useCallback } from 'react'; import { DataFrame, @@ -8,6 +8,7 @@ import { TransformationApplicabilityLevels, GrafanaTheme2, standardTransformersRegistry, + SelectableValue, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Card, Drawer, FilterPill, IconButton, Input, Switch, useStyles2 } from '@grafana/ui'; @@ -26,10 +27,10 @@ const filterCategoriesLabels: Array<[FilterCategory, string]> = [ ]; interface TransformationPickerNgProps { - onTransformationAdd: Function; - setState: Function; + onTransformationAdd: (selectedItem: SelectableValue<string>) => void; onSearchChange: FormEventHandler<HTMLInputElement>; onSearchKeyDown: KeyboardEventHandler<HTMLInputElement>; + onClose?: () => void; noTransforms: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any xforms: Array<TransformerRegistryItem<any>>; @@ -37,15 +38,15 @@ interface TransformationPickerNgProps { suffix: ReactNode; data: DataFrame[]; showIllustrations?: boolean; + onShowIllustrationsChange?: (showIllustrations: boolean) => void; + onSelectedFilterChange?: (category: FilterCategory) => void; selectedFilter?: FilterCategory; } export function TransformationPickerNg(props: TransformationPickerNgProps) { const styles = useStyles2(getTransformationPickerStyles); const { - noTransforms, suffix, - setState, xforms, search, onSearchChange, @@ -54,24 +55,42 @@ export function TransformationPickerNg(props: TransformationPickerNgProps) { onTransformationAdd, selectedFilter, data, + onClose, + onShowIllustrationsChange, + onSelectedFilterChange, } = props; + // Use a callback ref to call "click" on the search input + // This will focus it when it's opened + const searchInputRef = useCallback((input: HTMLInputElement) => { + input?.click(); + }, []); + return ( - <Drawer size="md" onClose={() => setState({ showPicker: false })} title="Add another transformation"> + <Drawer + size="md" + onClose={() => { + onClose && onClose(); + }} + title="Add another transformation" + > <div className={styles.searchWrapper}> <Input data-testid={selectors.components.Transforms.searchInput} className={styles.searchInput} value={search ?? ''} - autoFocus={!noTransforms} placeholder="Search for transformation" onChange={onSearchChange} onKeyDown={onSearchKeyDown} suffix={suffix} + ref={searchInputRef} /> <div className={styles.showImages}> <span className={styles.illustationSwitchLabel}>Show images</span>{' '} - <Switch value={showIllustrations} onChange={() => setState({ showIllustrations: !showIllustrations })} /> + <Switch + value={showIllustrations} + onChange={() => onShowIllustrationsChange && onShowIllustrationsChange(!showIllustrations)} + /> </div> </div> @@ -80,7 +99,7 @@ export function TransformationPickerNg(props: TransformationPickerNgProps) { return ( <FilterPill key={slug} - onClick={() => setState({ selectedFilter: slug })} + onClick={() => onSelectedFilterChange && onSelectedFilterChange(slug)} label={label} selected={selectedFilter === slug} /> diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx index 225ded8d755e7..e272b3bf3d475 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx @@ -93,7 +93,7 @@ describe('TransformationsEditor', () => { expect(screen.queryByTestId(debuggerSelector)).toBeNull(); - const debugButton = screen.getByLabelText(selectors.components.QueryEditorRow.actionButton('Debug')); + const debugButton = screen.getByTestId(selectors.components.QueryEditorRow.actionButton('Debug')); await userEvent.click(debugButton); expect(screen.getByTestId(debuggerSelector)).toBeInTheDocument(); diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx index e65cd6c7c0819..b1efc16b8d347 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx @@ -21,12 +21,9 @@ import { withTheme, IconButton, ButtonGroup, - Box, - Text, - Stack, } from '@grafana/ui'; import config from 'app/core/config'; -import { Trans } from 'app/core/internationalization'; +import { EmptyTransformationsMessage } from 'app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage'; import { PanelModel } from '../../state'; import { PanelNotSupported } from '../PanelEditor/PanelNotSupported'; @@ -40,12 +37,17 @@ interface TransformationsEditorProps extends Themeable { panel: PanelModel; } -const VIEW_ALL_VALUE = 'viewAll'; +export const VIEW_ALL_VALUE = 'viewAll'; export type viewAllType = 'viewAll'; export type FilterCategory = TransformerCategory | viewAllType; +export interface TransformationData { + series: DataFrame[]; + annotations?: DataFrame[]; +} + interface State { - data: DataFrame[]; + data: TransformationData; transformations: TransformationsEditorTransformation[]; search: string; showPicker?: boolean; @@ -68,7 +70,9 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE transformation: t, id: ids[i], })), - data: [], + data: { + series: [], + }, search: '', selectedFilter: VIEW_ALL_VALUE, showIllustrations: true, @@ -120,7 +124,7 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE .getQueryRunner() .getData({ withTransforms: false, withFieldConfig: false }) .subscribe({ - next: (panelData: PanelData) => this.setState({ data: panelData.series }), + next: (panelData: PanelData) => this.setState({ data: panelData }), }); } @@ -251,36 +255,11 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE renderEmptyMessage = () => { return ( - <Box alignItems="center" padding={4}> - <Stack direction="column" alignItems="center" gap={2}> - <Text element="h3" textAlignment="center"> - <Trans key="transformations.empty.add-transformation-header">Start transforming data</Trans> - </Text> - <Text - element="p" - textAlignment="center" - data-testid={selectors.components.Transforms.noTransformationsMessage} - > - <Trans key="transformations.empty.add-transformation-body"> - Transformations allow data to be changed in various ways before your visualization is shown. - <br /> - This includes joining data together, renaming fields, making calculations, formatting data for display, - and more. - </Trans> - </Text> - <Button - icon="plus" - variant="primary" - size="md" - onClick={() => { - this.setState({ showPicker: true }); - }} - data-testid={selectors.components.Transforms.addTransformationButton} - > - Add transformation - </Button> - </Stack> - </Box> + <EmptyTransformationsMessage + onShowPicker={() => { + this.setState({ showPicker: true }); + }} + ></EmptyTransformationsMessage> ); }; @@ -380,11 +359,13 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE search={search} suffix={suffix} xforms={xforms} - setState={this.setState.bind(this)} + onClose={() => this.setState({ showPicker: false })} + onSelectedFilterChange={(filter) => this.setState({ selectedFilter: filter })} + onShowIllustrationsChange={(showIllustrations) => this.setState({ showIllustrations })} onSearchChange={this.onSearchChange} onSearchKeyDown={this.onSearchKeyDown} onTransformationAdd={this.onTransformationAdd} - data={this.state.data} + data={this.state.data.series} selectedFilter={this.state.selectedFilter} showIllustrations={this.state.showIllustrations} /> diff --git a/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts deleted file mode 100644 index 351882ac6dc99..0000000000000 --- a/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { isNumber } from 'lodash'; - -import { getBackendSrv } from '@grafana/runtime'; - -import { DashboardModel } from '../../state/DashboardModel'; - -export interface HistoryListOpts { - limit: number; - start: number; -} - -export interface RevisionsModel { - id: number; - checked: boolean; - dashboardUID: string; - parentVersion: number; - version: number; - created: Date; - createdBy: string; - message: string; -} - -export interface DiffTarget { - dashboardUID: string; - version: number; - unsavedDashboard?: DashboardModel; // when doing diffs against unsaved dashboard version -} - -export class HistorySrv { - getHistoryList(dashboard: DashboardModel, options: HistoryListOpts) { - const uid = dashboard && dashboard.uid ? dashboard.uid : void 0; - return uid ? getBackendSrv().get(`api/dashboards/uid/${uid}/versions`, options) : Promise.resolve([]); - } - - getDashboardVersion(uid: string, version: number) { - return getBackendSrv().get(`api/dashboards/uid/${uid}/versions/${version}`); - } - - restoreDashboard(dashboard: DashboardModel, version: number) { - const uid = dashboard && dashboard.uid ? dashboard.uid : void 0; - const url = `api/dashboards/uid/${uid}/restore`; - - return uid && isNumber(version) ? getBackendSrv().post(url, { version }) : Promise.resolve({}); - } -} - -const historySrv = new HistorySrv(); -export { historySrv }; diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx index 1690197d87d35..0dab8cccf2cc4 100644 --- a/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx +++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx @@ -2,20 +2,20 @@ import { css, cx } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Button, ModalsController, CollapsableSection, HorizontalGroup, useStyles2 } from '@grafana/ui'; +import { Button, ModalsController, CollapsableSection, useStyles2, Stack, Icon, Box } from '@grafana/ui'; +import { DiffGroup } from 'app/features/dashboard-scene/settings/version-history/DiffGroup'; +import { DiffViewer } from 'app/features/dashboard-scene/settings/version-history/DiffViewer'; +import { jsonDiff } from 'app/features/dashboard-scene/settings/version-history/utils'; import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings'; -import { DiffGroup } from './DiffGroup'; -import { DiffViewer } from './DiffViewer'; import { RevertDashboardModal } from './RevertDashboardModal'; -import { jsonDiff } from './utils'; type DiffViewProps = { isNewLatest: boolean; newInfo: DecoratedRevisionModel; baseInfo: DecoratedRevisionModel; - diffData: { lhs: unknown; rhs: unknown }; + diffData: { lhs: string; rhs: string }; }; export const VersionHistoryComparison = ({ baseInfo, newInfo, diffData, isNewLatest }: DiffViewProps) => { @@ -23,60 +23,61 @@ export const VersionHistoryComparison = ({ baseInfo, newInfo, diffData, isNewLat const styles = useStyles2(getStyles); return ( - <div> - <div className={styles.spacer}> - <HorizontalGroup justify="space-between" align="center"> - <div> - <p className={styles.versionInfo}> - <strong>Version {newInfo.version}</strong> updated by {newInfo.createdBy} {newInfo.ageString} -{' '} - {newInfo.message} - </p> - <p className={cx(styles.versionInfo, styles.noMarginBottom)}> - <strong>Version {baseInfo.version}</strong> updated by {baseInfo.createdBy} {baseInfo.ageString} -{' '} - {baseInfo.message} - </p> - </div> - {isNewLatest && ( - <ModalsController> - {({ showModal, hideModal }) => ( - <Button - variant="destructive" - icon="history" - onClick={() => { - showModal(RevertDashboardModal, { - version: baseInfo.version, - hideModal, - }); - }} - > - Restore to version {baseInfo.version} - </Button> - )} - </ModalsController> - )} - </HorizontalGroup> - </div> - <div className={styles.spacer}> - {Object.entries(diff).map(([key, diffs]) => ( - <DiffGroup diffs={diffs} key={key} title={key} /> - ))} - </div> - <CollapsableSection isOpen={false} label="View JSON Diff"> - <DiffViewer oldValue={JSON.stringify(diffData.lhs, null, 2)} newValue={JSON.stringify(diffData.rhs, null, 2)} /> - </CollapsableSection> - </div> + <Stack direction="column" gap={1}> + <Stack justifyContent="space-between" alignItems="center"> + <Stack alignItems="center"> + <span className={cx(styles.versionInfo, styles.noMarginBottom)}> + <strong>Version {baseInfo.version}</strong> updated by {baseInfo.createdBy} {baseInfo.ageString} + {baseInfo.message} + </span> + <Icon name="arrow-right" size="sm" /> + <span className={styles.versionInfo}> + <strong>Version {newInfo.version}</strong> updated by {newInfo.createdBy} {newInfo.ageString} + {newInfo.message} + </span> + </Stack> + {isNewLatest && ( + <ModalsController> + {({ showModal, hideModal }) => ( + <Button + variant="destructive" + icon="history" + onClick={() => { + showModal(RevertDashboardModal, { + version: baseInfo.version, + hideModal, + }); + }} + > + Restore to version {baseInfo.version} + </Button> + )} + </ModalsController> + )} + </Stack> + + {Object.entries(diff).map(([key, diffs]) => ( + <DiffGroup diffs={diffs} key={key} title={key} /> + ))} + + <Box paddingTop={2}> + <CollapsableSection isOpen={false} label="View JSON Diff"> + <DiffViewer + oldValue={JSON.stringify(diffData.lhs, null, 2)} + newValue={JSON.stringify(diffData.rhs, null, 2)} + /> + </CollapsableSection> + </Box> + </Stack> ); }; const getStyles = (theme: GrafanaTheme2) => ({ - spacer: css` - margin-bottom: ${theme.spacing(4)}; - `, - versionInfo: css` - color: ${theme.colors.text.secondary}; - font-size: ${theme.typography.bodySmall.fontSize}; - `, - noMarginBottom: css` - margin-bottom: 0; - `, + versionInfo: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + }), + noMarginBottom: css({ + marginBottom: 0, + }), }); diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx index 61f4c34878d3d..2f698bdc67c60 100644 --- a/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx +++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx @@ -1,7 +1,8 @@ import { css } from '@emotion/css'; import React from 'react'; -import { Checkbox, Button, Tag, ModalsController } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Checkbox, Button, Tag, ModalsController, useStyles2 } from '@grafana/ui'; import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings'; @@ -13,61 +14,75 @@ type VersionsTableProps = { onCheck: (ev: React.FormEvent<HTMLInputElement>, versionId: number) => void; }; -export const VersionHistoryTable = ({ versions, canCompare, onCheck }: VersionsTableProps) => ( - <table className="filter-table gf-form-group"> - <thead> - <tr> - <th className="width-4"></th> - <th className="width-4">Version</th> - <th className="width-14">Date</th> - <th className="width-10">Updated by</th> - <th>Notes</th> - <th></th> - </tr> - </thead> - <tbody> - {versions.map((version, idx) => ( - <tr key={version.id}> - <td> - <Checkbox - aria-label={`Toggle selection of version ${version.version}`} - className={css` - display: inline; - `} - checked={version.checked} - onChange={(ev) => onCheck(ev, version.id)} - disabled={!version.checked && canCompare} - /> - </td> - <td>{version.version}</td> - <td>{version.createdDateString}</td> - <td>{version.createdBy}</td> - <td>{version.message}</td> - <td className="text-right"> - {idx === 0 ? ( - <Tag name="Latest" colorIndex={17} /> - ) : ( - <ModalsController> - {({ showModal, hideModal }) => ( - <Button - variant="secondary" - size="sm" - icon="history" - onClick={() => { - showModal(RevertDashboardModal, { - version: version.version, - hideModal, - }); - }} - > - Restore - </Button> +export const VersionHistoryTable = ({ versions, canCompare, onCheck }: VersionsTableProps) => { + const styles = useStyles2(getStyles); + + return ( + <div className={styles.margin}> + <table className="filter-table"> + <thead> + <tr> + <th className="width-4"></th> + <th className="width-4">Version</th> + <th className="width-14">Date</th> + <th className="width-10">Updated by</th> + <th>Notes</th> + <th></th> + </tr> + </thead> + <tbody> + {versions.map((version, idx) => ( + <tr key={version.id}> + <td> + <Checkbox + aria-label={`Toggle selection of version ${version.version}`} + className={css({ + display: 'inline', + })} + checked={version.checked} + onChange={(ev) => onCheck(ev, version.id)} + disabled={!version.checked && canCompare} + /> + </td> + <td>{version.version}</td> + <td>{version.createdDateString}</td> + <td>{version.createdBy}</td> + <td>{version.message}</td> + <td className="text-right"> + {idx === 0 ? ( + <Tag name="Latest" colorIndex={17} /> + ) : ( + <ModalsController> + {({ showModal, hideModal }) => ( + <Button + variant="secondary" + size="sm" + icon="history" + onClick={() => { + showModal(RevertDashboardModal, { + version: version.version, + hideModal, + }); + }} + > + Restore + </Button> + )} + </ModalsController> )} - </ModalsController> - )} - </td> - </tr> - ))} - </tbody> - </table> -); + </td> + </tr> + ))} + </tbody> + </table> + </div> + ); +}; + +function getStyles(theme: GrafanaTheme2) { + return { + margin: css({ + marginBottom: theme.spacing(4), + }), + }; +} diff --git a/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx b/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx index b5f22045ad50e..af1f894d3f2f9 100644 --- a/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx +++ b/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx @@ -4,17 +4,16 @@ import { useAsyncFn } from 'react-use'; import { locationUtil } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { useAppNotification } from 'app/core/copy/appNotification'; +import { historySrv } from 'app/features/dashboard-scene/settings/version-history'; import { useSelector } from 'app/types'; import { dashboardWatcher } from '../../../live/dashboard/dashboardWatcher'; import { DashboardModel } from '../../state'; -import { historySrv } from './HistorySrv'; - const restoreDashboard = async (version: number, dashboard: DashboardModel) => { // Skip the watcher logic for this save since it's handled by the hook dashboardWatcher.ignoreNextSave(); - return await historySrv.restoreDashboard(dashboard, version); + return await historySrv.restoreDashboard(dashboard.uid, version); }; export const useDashboardRestore = (version: number) => { diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 4ced917fe73e6..004f1d951aa74 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -4,7 +4,6 @@ import React from 'react'; import { Provider } from 'react-redux'; import { match, Router } from 'react-router-dom'; import { useEffectOnce } from 'react-use'; -import { AutoSizerProps } from 'react-virtualized-auto-sizer'; import { mockToolkitActionCreator } from 'test/core/redux/mocks'; import { TestProvider } from 'test/helpers/TestProvider'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; @@ -71,12 +70,6 @@ jest.mock('@grafana/runtime', () => ({ getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }), })); -jest.mock('react-virtualized-auto-sizer', () => { - // The size of the children need to be small enough to be outside the view. - // So it does not trigger the query to be run by the PanelQueryRunner. - return ({ children }: AutoSizerProps) => children({ height: 1, width: 1 }); -}); - function getTestDashboard(overrides?: Partial<Dashboard>, metaOverrides?: Partial<DashboardMeta>): DashboardModel { const data = Object.assign( { @@ -313,10 +306,12 @@ describe('DashboardPage', () => { }); describe('No kiosk mode tv', () => { - it('should render dashboard page toolbar and submenu', async () => { - setup({ dashboard: getTestDashboard() }); + it('should render dashboard page toolbar with no submenu', async () => { + setup({ + dashboard: getTestDashboard(), + }); expect(await screen.findAllByTestId(selectors.pages.Dashboard.DashNav.navV2)).toHaveLength(1); - expect(screen.getAllByLabelText(selectors.pages.Dashboard.SubMenu.submenu)).toHaveLength(1); + expect(screen.queryAllByLabelText(selectors.pages.Dashboard.SubMenu.submenu)).toHaveLength(0); }); }); diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 8770bdec767e0..188c10d9d46ea 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -31,13 +31,13 @@ import { DashboardPrompt } from '../components/DashboardPrompt/DashboardPrompt'; import { DashboardSettings } from '../components/DashboardSettings'; import { PanelInspector } from '../components/Inspector/PanelInspector'; import { PanelEditor } from '../components/PanelEditor/PanelEditor'; +import { ShareModal } from '../components/ShareModal'; import { SubMenu } from '../components/SubMenu/SubMenu'; import { DashboardGrid } from '../dashgrid/DashboardGrid'; import { liveTimer } from '../dashgrid/liveTimer'; import { getTimeSrv } from '../services/TimeSrv'; import { cleanUpDashboardAndVariables } from '../state/actions'; import { initDashboard } from '../state/initDashboard'; -import { calculateNewPanelGridPos } from '../utils/panel'; import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from './types'; @@ -265,29 +265,6 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> { return updateStatePageNavFromProps(props, updatedState); } - // Todo: Remove this when we remove the emptyDashboardPage toggle - onAddPanel = () => { - const { dashboard } = this.props; - - if (!dashboard) { - return; - } - - // Return if the "Add panel" exists already - if (dashboard.panels.length > 0 && dashboard.panels[0].type === 'add-panel') { - return; - } - - dashboard.addPanel({ - type: 'add-panel', - gridPos: calculateNewPanelGridPos(dashboard), - title: 'Panel Title', - }); - - // scroll to top after adding panel - this.setState({ updateScrollTop: 0 }); - }; - setScrollRef = (scrollElement: HTMLDivElement): void => { this.setState({ scrollElement }); }; @@ -311,6 +288,10 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> { return inspectPanel; } + onCloseShareModal = () => { + locationService.partial({ shareView: null }); + }; + render() { const { dashboard, initError, queryParams } = this.props; const { editPanel, viewPanel, updateScrollTop, pageNav, sectionNav } = this.state; @@ -321,7 +302,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> { } const inspectPanel = this.getInspectPanel(); - const showSubMenu = !editPanel && !kioskMode && !this.props.queryParams.editview; + const showSubMenu = !editPanel && !kioskMode && !this.props.queryParams.editview && dashboard.isSubMenuVisible(); const showToolbar = kioskMode !== KioskMode.Full && !queryParams.editview; @@ -355,7 +336,6 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> { title={dashboard.title} folderTitle={dashboard.meta.folderTitle} isFullscreen={!!viewPanel} - onAddPanel={this.onAddPanel} kioskMode={kioskMode} hideTimePicker={dashboard.timepicker.hidden} /> @@ -379,6 +359,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> { /> {inspectPanel && <PanelInspector dashboard={dashboard} panel={inspectPanel} />} + {queryParams.shareView && <ShareModal dashboard={dashboard} onDismiss={this.onCloseShareModal} />} </Page> {editPanel && ( <PanelEditor diff --git a/public/app/features/dashboard/containers/DashboardPageProxy.test.tsx b/public/app/features/dashboard/containers/DashboardPageProxy.test.tsx index 17bd3adb68be9..93477ca891f19 100644 --- a/public/app/features/dashboard/containers/DashboardPageProxy.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPageProxy.test.tsx @@ -2,11 +2,15 @@ import { render, screen, act, waitFor } from '@testing-library/react'; import React from 'react'; import { Provider } from 'react-redux'; import { Router } from 'react-router-dom'; +import { Props } from 'react-virtualized-auto-sizer'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { config, locationService } from '@grafana/runtime'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; -import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; +import { + HOME_DASHBOARD_CACHE_KEY, + getDashboardScenePageStateManager, +} from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; import { configureStore } from 'app/store/configureStore'; import { DashboardDTO, DashboardRoutes } from 'app/types'; @@ -47,6 +51,16 @@ jest.mock('@grafana/runtime', () => ({ }), })); +jest.mock('react-virtualized-auto-sizer', () => { + return ({ children }: Props) => + children({ + height: 1, + scaledHeight: 1, + scaledWidth: 1, + width: 1, + }); +}); + function setup(props: Partial<DashboardPageProxyProps>) { const context = getGrafanaContextMock(); const store = configureStore({}); @@ -76,7 +90,7 @@ describe('DashboardPageProxy', () => { }); it('home dashboard', async () => { - getDashboardScenePageStateManager().setDashboardCache(DashboardRoutes.Home, dashMock); + getDashboardScenePageStateManager().setDashboardCache(HOME_DASHBOARD_CACHE_KEY, dashMock); act(() => { setup({ route: { routeName: DashboardRoutes.Home, component: () => null, path: '/' }, @@ -94,8 +108,8 @@ describe('DashboardPageProxy', () => { act(() => { setup({ - route: { routeName: DashboardRoutes.Home, component: () => null, path: '/' }, - match: { params: {}, isExact: true, path: '/', url: '/' }, + route: { routeName: DashboardRoutes.Normal, component: () => null, path: '/' }, + match: { params: { uid: 'abc-def' }, isExact: true, path: '/', url: '/' }, }); }); @@ -105,14 +119,14 @@ describe('DashboardPageProxy', () => { }); }); - describe('when dashboardSceneForViewers feature toggle enabled', () => { + describe('when dashboardSceneForViewers feature toggle enabled', () => { beforeEach(() => { config.featureToggles.dashboardSceneForViewers = true; }); describe('when user can edit a dashboard ', () => { it('should not render DashboardScenePage if route is Home', async () => { - getDashboardScenePageStateManager().setDashboardCache(DashboardRoutes.Home, dashMockEditable); + getDashboardScenePageStateManager().setDashboardCache(HOME_DASHBOARD_CACHE_KEY, dashMockEditable); act(() => { setup({ route: { routeName: DashboardRoutes.Home, component: () => null, path: '/' }, @@ -141,7 +155,7 @@ describe('DashboardPageProxy', () => { describe('when user can only view a dashboard ', () => { it('should render DashboardScenePage if route is Home', async () => { - getDashboardScenePageStateManager().setDashboardCache(DashboardRoutes.Home, dashMock); + getDashboardScenePageStateManager().setDashboardCache(HOME_DASHBOARD_CACHE_KEY, dashMock); act(() => { setup({ route: { routeName: DashboardRoutes.Home, component: () => null, path: '/' }, diff --git a/public/app/features/dashboard/containers/DashboardPageProxy.tsx b/public/app/features/dashboard/containers/DashboardPageProxy.tsx index 5d895b96c9078..8f65896116021 100644 --- a/public/app/features/dashboard/containers/DashboardPageProxy.tsx +++ b/public/app/features/dashboard/containers/DashboardPageProxy.tsx @@ -18,7 +18,10 @@ export type DashboardPageProxyProps = GrafanaRouteComponentProps< // This proxy component is used for Dashboard -> Scenes migration. // It will render DashboardScenePage if the user is only allowed to view the dashboard. function DashboardPageProxy(props: DashboardPageProxyProps) { - if (config.featureToggles.dashboardScene) { + const forceScenes = props.queryParams.scenes === true; + const forceOld = props.queryParams.scenes === false; + + if (forceScenes || (config.featureToggles.dashboardScene && !forceOld)) { return <DashboardScenePage {...props} />; } @@ -32,13 +35,14 @@ function DashboardPageProxy(props: DashboardPageProxyProps) { // To avoid querying single dashboard multiple times, stateManager.fetchDashboard uses a simple, short-lived cache. // eslint-disable-next-line react-hooks/rules-of-hooks const dashboard = useAsync(async () => { - const dashToFetch = props.route.routeName === DashboardRoutes.Home ? props.route.routeName : props.match.params.uid; - - if (!dashToFetch) { + if (props.match.params.type === 'snapshot') { return null; } - return stateManager.fetchDashboard(dashToFetch); + return stateManager.fetchDashboard({ + route: props.route.routeName as DashboardRoutes, + uid: props.match.params.uid ?? '', + }); }, [props.match.params.uid, props.route.routeName]); if (!config.featureToggles.dashboardSceneForViewers) { @@ -49,7 +53,11 @@ function DashboardPageProxy(props: DashboardPageProxyProps) { return null; } - if (dashboard.value && !dashboard.value.meta.canEdit && isScenesSupportedRoute) { + if ( + dashboard.value && + !(dashboard.value.meta.canEdit || dashboard.value.meta.canMakeEditable) && + isScenesSupportedRoute + ) { return <DashboardScenePage {...props} />; } else { return <DashboardPage {...props} />; diff --git a/public/app/features/dashboard/containers/NewDashboardWithDS.tsx b/public/app/features/dashboard/containers/NewDashboardWithDS.tsx index 56eddbcea300a..b1a7aa041e4c5 100644 --- a/public/app/features/dashboard/containers/NewDashboardWithDS.tsx +++ b/public/app/features/dashboard/containers/NewDashboardWithDS.tsx @@ -1,11 +1,10 @@ import React, { useEffect, useState } from 'react'; -import { config, getDataSourceSrv, locationService } from '@grafana/runtime'; +import { getDataSourceSrv, locationService } from '@grafana/runtime'; import { Page } from 'app/core/components/Page/Page'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { useDispatch } from 'app/types'; -import { getNewDashboardModelData, setDashboardToFetchFromLocalStorage } from '../state/initDashboard'; import { setInitialDatasource } from '../state/reducers'; export default function NewDashboardWithDS(props: GrafanaRouteComponentProps<{ datasourceUid: string }>) { @@ -20,21 +19,7 @@ export default function NewDashboardWithDS(props: GrafanaRouteComponentProps<{ d return; } - if (!config.featureToggles.emptyDashboardPage) { - const newDashboard = getNewDashboardModelData(); - const { dashboard } = newDashboard; - dashboard.panels[0] = { - ...dashboard.panels[0], - datasource: { - uid: ds.uid, - type: ds.type, - }, - }; - - setDashboardToFetchFromLocalStorage(newDashboard); - } else { - dispatch(setInitialDatasource(datasourceUid)); - } + dispatch(setInitialDatasource(datasourceUid)); locationService.replace('/dashboard/new'); }, [datasourceUid, dispatch]); diff --git a/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx b/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx index 597a9263afc48..da7f4a7bbb367 100644 --- a/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { Provider } from 'react-redux'; import { Router } from 'react-router-dom'; import { useEffectOnce } from 'react-use'; -import { AutoSizerProps } from 'react-virtualized-auto-sizer'; +import { Props as AutoSizerProps } from 'react-virtualized-auto-sizer'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; @@ -37,7 +37,13 @@ jest.mock('app/features/dashboard/dashgrid/LazyLoader', () => { jest.mock('react-virtualized-auto-sizer', () => { // // // The size of the children need to be small enough to be outside the view. // // // So it does not trigger the query to be run by the PanelQueryRunner. - return ({ children }: AutoSizerProps) => children({ height: 1, width: 1 }); + return ({ children }: AutoSizerProps) => + children({ + height: 1, + scaledHeight: 1, + scaledWidth: 1, + width: 1, + }); }); jest.mock('app/features/dashboard/state/initDashboard', () => ({ diff --git a/public/app/features/dashboard/containers/types.ts b/public/app/features/dashboard/containers/types.ts index d7416f6f104bd..35c8411516b29 100644 --- a/public/app/features/dashboard/containers/types.ts +++ b/public/app/features/dashboard/containers/types.ts @@ -18,4 +18,6 @@ export type DashboardPageRouteSearchParams = { to?: string; refresh?: string; kiosk?: string | true; + scenes?: boolean; + shareView?: string; }; diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty.test.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty.test.tsx index 341a67ba10ef4..bef047baa75b5 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty.test.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty.test.tsx @@ -22,7 +22,6 @@ jest.mock('@grafana/runtime', () => ({ partial: jest.fn(), }, reportInteraction: jest.fn(), - config: {}, })); jest.mock('app/features/dashboard/utils/dashboard', () => ({ diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx index 341ef3c447fe1..9de79fbc1de21 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx @@ -8,13 +8,14 @@ import { Button, useStyles2, Text, Box, Stack } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; import { DashboardModel } from 'app/features/dashboard/state'; import { onAddLibraryPanel, onCreateNewPanel, onImportDashboard } from 'app/features/dashboard/utils/dashboard'; +import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; import { useDispatch, useSelector } from 'app/types'; import { setInitialDatasource } from '../state/reducers'; export interface Props { - dashboard: DashboardModel; + dashboard: DashboardModel | DashboardScene; canCreate: boolean; } @@ -23,6 +24,19 @@ const DashboardEmpty = ({ dashboard, canCreate }: Props) => { const dispatch = useDispatch(); const initialDatasource = useSelector((state) => state.dashboard.initialDatasource); + const onAddVisualization = () => { + let id; + if (dashboard instanceof DashboardScene) { + id = dashboard.onCreateNewPanel(); + } else { + id = onCreateNewPanel(dashboard, initialDatasource); + dispatch(setInitialDatasource(undefined)); + } + + locationService.partial({ editPanel: id, firstPanel: true }); + DashboardInteractions.emptyDashboardButtonClicked({ item: 'add_visualization' }); + }; + return ( <Stack alignItems="center" justifyContent="center"> <div className={styles.wrapper}> @@ -46,13 +60,7 @@ const DashboardEmpty = ({ dashboard, canCreate }: Props) => { size="lg" icon="plus" data-testid={selectors.pages.AddDashboard.itemButton('Create new panel button')} - onClick={() => { - const id = onCreateNewPanel(dashboard, initialDatasource); - DashboardInteractions.emptyDashboardButtonClicked({ item: 'add_visualization' }); - - locationService.partial({ editPanel: id, firstPanel: true }); - dispatch(setInitialDatasource(undefined)); - }} + onClick={onAddVisualization} disabled={!canCreate} > <Trans i18nKey="dashboard.empty.add-visualization-button">Add visualization</Trans> @@ -104,7 +112,11 @@ const DashboardEmpty = ({ dashboard, canCreate }: Props) => { data-testid={selectors.pages.AddDashboard.itemButton('Add a panel from the panel library button')} onClick={() => { DashboardInteractions.emptyDashboardButtonClicked({ item: 'import_from_library' }); - onAddLibraryPanel(dashboard); + if (dashboard instanceof DashboardScene) { + dashboard.onCreateLibPanelWidget(); + } else { + onAddLibraryPanel(dashboard); + } }} disabled={!canCreate} > diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx index c8c439eacb9a0..818f0fd05d0af 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { Provider } from 'react-redux'; import { Router } from 'react-router-dom'; import { useEffectOnce } from 'react-use'; -import { AutoSizerProps } from 'react-virtualized-auto-sizer'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { TextBoxVariableModel } from '@grafana/data'; @@ -42,12 +41,6 @@ jest.mock('app/features/dashboard/dashgrid/LazyLoader', () => { return { LazyLoader }; }); -jest.mock('react-virtualized-auto-sizer', () => { - // The size of the children need to be small enough to be outside the view. - // So it does not trigger the query to be run by the PanelQueryRunner. - return ({ children }: AutoSizerProps) => children({ height: 1, width: 1 }); -}); - function setup(props: Props) { const context = getGrafanaContextMock(); const store = configureStore({}); diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 532ebd7321768..52a08bb7a995d 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -1,7 +1,6 @@ import classNames from 'classnames'; import React, { PureComponent, CSSProperties } from 'react'; import ReactGridLayout, { ItemCallback } from 'react-grid-layout'; -import AutoSizer from 'react-virtualized-auto-sizer'; import { Subscription } from 'rxjs'; import { config } from '@grafana/runtime'; @@ -12,7 +11,6 @@ import { VariablesChanged } from 'app/features/variables/types'; import { DashboardPanelsChangedEvent } from 'app/types/events'; import { AddLibraryPanelWidget } from '../components/AddLibraryPanelWidget'; -import { AddPanelWidget } from '../components/AddPanelWidget'; import { DashboardRow } from '../components/DashboardRow'; import { DashboardModel, PanelModel } from '../state'; import { GridPos } from '../state/PanelModel'; @@ -32,6 +30,7 @@ export interface Props { interface State { panelFilter?: RegExp; + width: number; } export class DashboardGrid extends PureComponent<Props, State> { @@ -48,6 +47,7 @@ export class DashboardGrid extends PureComponent<Props, State> { super(props); this.state = { panelFilter: undefined, + width: document.body.clientWidth, // initial very rough estimate }; } @@ -260,11 +260,6 @@ export class DashboardGrid extends PureComponent<Props, State> { return <DashboardRow key={panel.key} panel={panel} dashboard={this.props.dashboard} />; } - // Todo: Remove this when we remove the emptyDashboardPage toggle - if (panel.type === 'add-panel') { - return <AddPanelWidget key={panel.key} panel={panel} dashboard={this.props.dashboard} />; - } - if (panel.type === 'add-library-panel') { return <AddLibraryPanelWidget key={panel.key} panel={panel} dashboard={this.props.dashboard} />; } @@ -297,22 +292,41 @@ export class DashboardGrid extends PureComponent<Props, State> { } }; + private resizeObserver?: ResizeObserver; + private rootEl: HTMLDivElement | null = null; + onMeasureRef = (rootEl: HTMLDivElement | null) => { + if (!rootEl) { + if (this.rootEl && this.resizeObserver) { + this.resizeObserver.unobserve(this.rootEl); + } + return; + } + + this.rootEl = rootEl; + this.resizeObserver = new ResizeObserver((entries) => { + entries.forEach((entry) => { + this.setState({ width: entry.contentRect.width }); + }); + }); + + this.resizeObserver.observe(rootEl); + }; + render() { const { isEditable, dashboard } = this.props; + const { width } = this.state; - if (config.featureToggles.emptyDashboardPage && dashboard.panels.length === 0) { + if (dashboard.panels.length === 0) { return <DashboardEmpty dashboard={dashboard} canCreate={isEditable} />; } - /** - * We have a parent with "flex: 1 1 0" we need to reset it to "flex: 1 1 auto" to have the AutoSizer - * properly working. For more information go here: - * https://github.com/bvaughn/react-virtualized/blob/master/docs/usingAutoSizer.md#can-i-use-autosizer-within-a-flex-container - * - * pos: rel + z-index is required to create a new stacking context to contain the escalating z-indexes of the panels - */ + const draggable = width <= config.theme2.breakpoints.values.md ? false : isEditable; + + // pos: rel + z-index is required to create a new stacking context to contain + // the escalating z-indexes of the panels return ( <div + ref={this.onMeasureRef} style={{ flex: '1 1 auto', position: 'relative', @@ -320,46 +334,27 @@ export class DashboardGrid extends PureComponent<Props, State> { display: this.props.editPanel ? 'none' : undefined, }} > - <AutoSizer disableHeight> - {({ width }) => { - if (width === 0) { - return null; - } - - // Disable draggable if mobile device, solving an issue with unintentionally - // moving panels. https://github.com/grafana/grafana/issues/18497 - const draggable = width <= config.theme2.breakpoints.values.md ? false : isEditable; - - return ( - /** - * The children is using a width of 100% so we need to guarantee that it is wrapped - * in an element that has the calculated size given by the AutoSizer. The AutoSizer - * has a width of 0 and will let its content overflow its div. - */ - <div style={{ width: width, height: '100%' }} ref={this.onGetWrapperDivRef}> - <ReactGridLayout - width={width} - isDraggable={draggable} - isResizable={isEditable} - containerPadding={[0, 0]} - useCSSTransforms={true} - margin={[GRID_CELL_VMARGIN, GRID_CELL_VMARGIN]} - cols={GRID_COLUMN_COUNT} - rowHeight={GRID_CELL_HEIGHT} - draggableHandle=".grid-drag-handle" - draggableCancel=".grid-drag-cancel" - layout={this.buildLayout()} - onDragStop={this.onDragStop} - onResize={this.onResize} - onResizeStop={this.onResizeStop} - onLayoutChange={this.onLayoutChange} - > - {this.renderPanels(width, draggable)} - </ReactGridLayout> - </div> - ); - }} - </AutoSizer> + <div style={{ width: width, height: '100%' }} ref={this.onGetWrapperDivRef}> + <ReactGridLayout + width={width} + isDraggable={draggable} + isResizable={isEditable} + containerPadding={[0, 0]} + useCSSTransforms={true} + margin={[GRID_CELL_VMARGIN, GRID_CELL_VMARGIN]} + cols={GRID_COLUMN_COUNT} + rowHeight={GRID_CELL_HEIGHT} + draggableHandle=".grid-drag-handle" + draggableCancel=".grid-drag-cancel" + layout={this.buildLayout()} + onDragStop={this.onDragStop} + onResize={this.onResize} + onResizeStop={this.onResizeStop} + onLayoutChange={this.onLayoutChange} + > + {this.renderPanels(width, draggable)} + </ReactGridLayout> + </div> </div> ); } diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.test.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.test.tsx index 8b6be6c4b9877..c46d74b3728d2 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.test.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.test.tsx @@ -74,6 +74,8 @@ function setupTestContext(options: Partial<Props>) { </Provider> ); + // Needed so mocks work + props.panel.refreshWhenInView = false; return { rerender, props, subject, store }; } diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx index 0697af78451d1..32cd4ba0fffe9 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx @@ -340,7 +340,7 @@ export class PanelStateWrapper extends PureComponent<Props, State> { onRefresh = () => { const { dashboard, panel, isInView, width } = this.props; - if (!isInView) { + if (!dashboard.snapshot && !isInView) { panel.refreshWhenInView = true; return; } @@ -460,7 +460,12 @@ export class PanelStateWrapper extends PureComponent<Props, State> { }; shouldSignalRenderingCompleted(loadingState: LoadingState, pluginMeta: PanelPluginMeta) { - return loadingState === LoadingState.Done || loadingState === LoadingState.Error || pluginMeta.skipDataQuery; + return ( + loadingState === LoadingState.Done || + loadingState === LoadingState.Streaming || + loadingState === LoadingState.Error || + pluginMeta.skipDataQuery + ); } skipFirstRender(loadingState: LoadingState) { diff --git a/public/app/features/dashboard/index.ts b/public/app/features/dashboard/index.ts index 1a39f7495ec95..12c86883b58a2 100644 --- a/public/app/features/dashboard/index.ts +++ b/public/app/features/dashboard/index.ts @@ -4,5 +4,4 @@ import './services/DashboardSrv'; // Components import './components/DashExportModal'; import './components/DashNav'; -import './components/VersionHistory'; import './components/DashboardSettings'; diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts index c1168e310992e..d79ebf2253c0d 100644 --- a/public/app/features/dashboard/services/DashboardSrv.ts +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -18,8 +18,6 @@ export interface SaveDashboardOptions { dashboard: DashboardModel; /** Set a commit message for the version history. */ message?: string; - /** The id of the folder to save the dashboard in. */ - folderId?: number; /** The UID of the folder to save the dashboard in. Overrides `folderId`. */ folderUid?: string; /** Set to `true` if you want to overwrite existing dashboard with newer version, diff --git a/public/app/features/dashboard/services/SnapshotSrv.ts b/public/app/features/dashboard/services/SnapshotSrv.ts index 293d5245fa348..e3e0e9db637b4 100644 --- a/public/app/features/dashboard/services/SnapshotSrv.ts +++ b/public/app/features/dashboard/services/SnapshotSrv.ts @@ -1,5 +1,8 @@ -import { getBackendSrv } from '@grafana/runtime'; -import { DashboardDTO } from 'app/types'; +import { lastValueFrom, map } from 'rxjs'; + +import { config, getBackendSrv, FetchResponse } from '@grafana/runtime'; +import { contextSrv } from 'app/core/core'; +import { DashboardDataDTO, DashboardDTO } from 'app/types'; // Used in the snapshot list export interface Snapshot { @@ -17,7 +20,21 @@ export interface SnapshotSharingOptions { snapshotEnabled: boolean; } +export interface SnapshotCreateCommand { + dashboard: object; + name: string; + expires?: number; + external?: boolean; +} + +export interface SnapshotCreateResponse { + key: string; + url: string; + deleteUrl: string; +} + export interface DashboardSnapshotSrv { + create: (cmd: SnapshotCreateCommand) => Promise<SnapshotCreateResponse>; getSnapshots: () => Promise<Snapshot[]>; getSharingOptions: () => Promise<SnapshotSharingOptions>; deleteSnapshot: (key: string) => Promise<void>; @@ -25,6 +42,7 @@ export interface DashboardSnapshotSrv { } const legacyDashboardSnapshotSrv: DashboardSnapshotSrv = { + create: (cmd: SnapshotCreateCommand) => getBackendSrv().post<SnapshotCreateResponse>('/api/snapshots', cmd), getSnapshots: () => getBackendSrv().get<Snapshot[]>('/api/dashboard/snapshots'), getSharingOptions: () => getBackendSrv().get<SnapshotSharingOptions>('/api/snapshot/shared-options'), deleteSnapshot: (key: string) => getBackendSrv().delete('/api/snapshots/' + key), @@ -35,6 +53,109 @@ const legacyDashboardSnapshotSrv: DashboardSnapshotSrv = { }, }; +interface K8sMetadata { + name: string; + namespace: string; + resourceVersion: string; + creationTimestamp: string; +} + +interface K8sSnapshotInfo { + title: string; + externalUrl?: string; + expires?: number; +} + +interface K8sSnapshotResource { + metadata: K8sMetadata; + spec: K8sSnapshotInfo; +} + +interface DashboardSnapshotList { + items: K8sSnapshotResource[]; +} + +interface K8sDashboardSnapshot { + apiVersion: string; + kind: 'DashboardSnapshot'; + metadata: K8sMetadata; + dashboard: DashboardDataDTO; +} + +class K8sAPI implements DashboardSnapshotSrv { + readonly apiVersion = 'dashboardsnapshot.grafana.app/v0alpha1'; + readonly url: string; + + constructor() { + this.url = `/apis/${this.apiVersion}/namespaces/${config.namespace}/dashboardsnapshots`; + } + + async create(cmd: SnapshotCreateCommand) { + return getBackendSrv().post<SnapshotCreateResponse>(this.url + '/create', cmd); + } + + async getSnapshots(): Promise<Snapshot[]> { + const result = await getBackendSrv().get<DashboardSnapshotList>(this.url); + return result.items.map((r) => { + return { + key: r.metadata.name, + name: r.spec.title, + external: r.spec.externalUrl != null, + externalUrl: r.spec.externalUrl, + }; + }); + } + + deleteSnapshot(uid: string) { + return getBackendSrv().delete<void>(this.url + '/' + uid); + } + + async getSharingOptions() { + // TODO? should this be in a config service, or in the same service? + // we have http://localhost:3000/apis/dashboardsnapshot.grafana.app/v0alpha1/namespaces/default/options + // BUT that has an unclear user mapping story still, so lets stick with the existing shared-options endpoint + return getBackendSrv().get<SnapshotSharingOptions>('/api/snapshot/shared-options'); + } + + async getSnapshot(uid: string): Promise<DashboardDTO> { + const headers: Record<string, string> = {}; + if (!contextSrv.isSignedIn) { + alert('TODO... need a barer token for anonymous use case'); + const token = `??? TODO, get anon token for snapshots (${contextSrv.user?.name}) ???`; + headers['Authorization'] = `Bearer ${token}`; + } + return lastValueFrom( + getBackendSrv() + .fetch<K8sDashboardSnapshot>({ + url: this.url + '/' + uid + '/body', + method: 'GET', + headers: headers, + }) + .pipe( + map((response: FetchResponse<K8sDashboardSnapshot>) => { + return { + dashboard: response.data.dashboard, + meta: { + isSnapshot: true, + canSave: false, + canEdit: false, + canAdmin: false, + canStar: false, + canShare: false, + canDelete: false, + isFolder: false, + provisioned: false, + }, + }; + }) + ) + ); + } +} + export function getDashboardSnapshotSrv(): DashboardSnapshotSrv { + if (config.featureToggles.kubernetesSnapshots) { + return new K8sAPI(); + } return legacyDashboardSnapshotSrv; } diff --git a/public/app/features/dashboard/services/TimeSrv.test.ts b/public/app/features/dashboard/services/TimeSrv.test.ts index 0fc4ba613ac52..e604828080ee6 100644 --- a/public/app/features/dashboard/services/TimeSrv.test.ts +++ b/public/app/features/dashboard/services/TimeSrv.test.ts @@ -24,7 +24,7 @@ describe('timeSrv', () => { _dashboard = { time: { from: 'now-6h', to: 'now' }, getTimezone: jest.fn(() => 'browser'), - refresh: false, + refresh: '', timeRangeUpdated: jest.fn(() => {}), timepicker: {}, }; @@ -94,7 +94,7 @@ describe('timeSrv', () => { _dashboard = { time: { from: 'now-6h', to: 'now' }, getTimezone: jest.fn(() => 'browser'), - refresh: false, + refresh: '', timeRangeUpdated: jest.fn(() => {}), timepicker: {}, }; @@ -236,10 +236,10 @@ describe('timeSrv', () => { describe('setTime', () => { it('should return disable refresh if refresh is disabled for any range', () => { - _dashboard.refresh = false; + _dashboard.refresh = ''; timeSrv.setTime({ from: '2011-01-01', to: '2015-01-01' }); - expect(_dashboard.refresh).toBe(false); + expect(_dashboard.refresh).toBe(''); }); it('should restore refresh for absolute time range', () => { @@ -255,7 +255,7 @@ describe('timeSrv', () => { from: dateTime([2011, 1, 1]), to: dateTime([2015, 1, 1]), }); - expect(_dashboard.refresh).toBe(false); + expect(_dashboard.refresh).toBe(''); timeSrv.setTime({ from: '2011-01-01', to: 'now' }); expect(_dashboard.refresh).toBe('10s'); }); diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts index ff53e2ca1899a..297757650bbf4 100644 --- a/public/app/features/dashboard/services/TimeSrv.ts +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -10,16 +10,25 @@ import { TimeRange, toUtc, IntervalValues, + AppEvents, } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { sceneGraph } from '@grafana/scenes'; +import { t } from '@grafana/ui/src/utils/i18n'; import appEvents from 'app/core/app_events'; import { config } from 'app/core/config'; import { AutoRefreshInterval, contextSrv, ContextSrv } from 'app/core/services/context_srv'; -import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; +import { getCopiedTimeRange, getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; import { getTimeRange } from 'app/features/dashboard/utils/timeRange'; -import { AbsoluteTimeEvent, ShiftTimeEvent, ShiftTimeEventDirection, ZoomOutEvent } from '../../../types/events'; +import { + AbsoluteTimeEvent, + CopyTimeEvent, + PasteTimeEvent, + ShiftTimeEvent, + ShiftTimeEventDirection, + ZoomOutEvent, +} from '../../../types/events'; import { TimeModel } from '../state/TimeModel'; import { getRefreshFromUrl } from '../utils/getRefreshFromUrl'; @@ -51,6 +60,14 @@ export class TimeSrv { this.makeAbsoluteTime(e.payload.updateUrl); }); + appEvents.subscribe(CopyTimeEvent, () => { + this.copyTimeRangeToClipboard(); + }); + + appEvents.subscribe(PasteTimeEvent, (e) => { + this.pasteTimeRangeFromClipboard(e.payload.updateUrl); + }); + document.addEventListener('visibilitychange', () => { if (this.autoRefreshBlocked && document.visibilityState === 'visible') { this.autoRefreshBlocked = false; @@ -153,7 +170,7 @@ export class TimeSrv { if (params.get('to') && params.get('to')!.indexOf('now') === -1) { this.refresh = false; if (this.timeModel) { - this.timeModel.refresh = false; + this.timeModel.refresh = undefined; } } @@ -197,7 +214,7 @@ export class TimeSrv { return this.timeAtLoad && (this.timeAtLoad.from !== this.time.from || this.timeAtLoad.to !== this.time.to); } - setAutoRefresh(interval: string | false) { + setAutoRefresh(interval: string) { if (this.timeModel) { this.timeModel.refresh = interval; } @@ -278,7 +295,7 @@ export class TimeSrv { // disable refresh if zoom in or zoom out if (isDateTime(time.to)) { this.oldRefresh = this.timeModel?.refresh || this.oldRefresh; - this.setAutoRefresh(false); + this.setAutoRefresh(''); } else if (this.oldRefresh && this.oldRefresh !== this.timeModel?.refresh) { this.setAutoRefresh(this.oldRefresh); this.oldRefresh = undefined; @@ -357,6 +374,30 @@ export class TimeSrv { this.setTime({ from, to }, updateUrl); } + copyTimeRangeToClipboard() { + const { raw } = this.timeRange(); + navigator.clipboard.writeText(JSON.stringify({ from: raw.from, to: raw.to })); + appEvents.emit(AppEvents.alertSuccess, [ + t('time-picker.copy-paste.copy-success-message', 'Time range copied to clipboard'), + ]); + } + + async pasteTimeRangeFromClipboard(updateUrl = true) { + const { range, isError } = await getCopiedTimeRange(); + + if (isError === true) { + appEvents.emit(AppEvents.alertError, [ + t('time-picker.copy-paste.default-error-title', 'Invalid time range'), + t('time-picker.copy-paste.default-error-message', '{{error}} is not a valid time range', { error: range }), + ]); + return; + } + + const { from, to } = range; + + this.setTime({ from, to }, updateUrl); + } + // isRefreshOutsideThreshold function calculates the difference between last refresh and now // if the difference is outside 5% of the current set time range then the function will return true // if the difference is within 5% of the current set time range then the function will return false diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts index af94f219d970b..9c5c6d5dfd858 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.test.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -46,7 +46,7 @@ setDataSourceSrv(new MockDataSourceSrv(dataSources)); describe('DashboardModel', () => { describe('when creating dashboard with old schema', () => { - let model: any; + let model: DashboardModel; let graph: any; let singlestat: any; let table: any; @@ -517,7 +517,7 @@ describe('DashboardModel', () => { }); describe('when migrating panel links', () => { - let model: any; + let model: DashboardModel; beforeEach(() => { model = new DashboardModel({ @@ -564,24 +564,26 @@ describe('DashboardModel', () => { }); it('should add keepTime as variable', () => { - expect(model.panels[0].links[0].url).toBe(`http://mylink.com?$${DataLinkBuiltInVars.keepTime}`); + expect(model.panels[0].links?.[0].url).toBe(`http://mylink.com?$${DataLinkBuiltInVars.keepTime}`); }); it('should add params to url', () => { - expect(model.panels[0].links[1].url).toBe('http://mylink.com?existingParam&customParam'); + expect(model.panels[0].links?.[1].url).toBe('http://mylink.com?existingParam&customParam'); }); it('should add includeVars to url', () => { - expect(model.panels[0].links[2].url).toBe(`http://mylink.com?existingParam&$${DataLinkBuiltInVars.includeVars}`); + expect(model.panels[0].links?.[2].url).toBe( + `http://mylink.com?existingParam&$${DataLinkBuiltInVars.includeVars}` + ); }); it('should slugify dashboard name', () => { - expect(model.panels[0].links[3].url).toBe(`dashboard/db/my-other-dashboard`); + expect(model.panels[0].links?.[3].url).toBe(`dashboard/db/my-other-dashboard`); }); }); describe('when migrating variables', () => { - let model: any; + let model: DashboardModel; beforeEach(() => { model = new DashboardModel({ panels: [ @@ -648,7 +650,7 @@ describe('DashboardModel', () => { }); describe('when migrating labels from DataFrame to Field', () => { - let model: any; + let model: DashboardModel; beforeEach(() => { model = new DashboardModel({ panels: [ @@ -892,7 +894,7 @@ describe('DashboardModel', () => { }); it('should migrate panels with new Text Panel id', () => { - const reactPanel: any = model.panels[1]; + const reactPanel = model.panels[1]; expect(reactPanel.id).toEqual(3); expect(reactPanel.type).toEqual('text'); expect(reactPanel.title).toEqual('React Text Panel from scratch'); @@ -903,7 +905,7 @@ describe('DashboardModel', () => { }); it('should clean up old angular options for panels with new Text Panel id', () => { - const reactPanel: any = model.panels[2]; + const reactPanel = model.panels[2]; expect(reactPanel.id).toEqual(4); expect(reactPanel.type).toEqual('text'); expect(reactPanel.title).toEqual('React Text Panel from Angular Panel'); @@ -1564,7 +1566,7 @@ describe('DashboardModel', () => { }); describe('migrating legacy CloudWatch queries', () => { - let model: any; + let model: DashboardModel; let panelTargets: any; beforeEach(() => { diff --git a/public/app/features/dashboard/state/DashboardModel.repeat.test.ts b/public/app/features/dashboard/state/DashboardModel.repeat.test.ts index 1c6e871985619..421e8fa68558e 100644 --- a/public/app/features/dashboard/state/DashboardModel.repeat.test.ts +++ b/public/app/features/dashboard/state/DashboardModel.repeat.test.ts @@ -194,7 +194,7 @@ describe('given dashboard with panel repeat in horizontal direction', () => { }); describe('given dashboard with panel repeat in vertical direction', () => { - let dashboard: any; + let dashboard: DashboardModel; beforeEach(() => { const dashboardJSON = { @@ -238,7 +238,7 @@ describe('given dashboard with panel repeat in vertical direction', () => { }); describe('given dashboard with row repeat and panel repeat in horizontal direction', () => { - let dashboard: any, dashboardJSON: any; + let dashboard: DashboardModel, dashboardJSON: any; beforeEach(() => { dashboardJSON = { @@ -280,7 +280,7 @@ describe('given dashboard with row repeat and panel repeat in horizontal directi }, }; dashboard = getDashboardModel(dashboardJSON); - dashboard.processRepeats(false); + dashboard.processRepeats(); }); it('should panels in self row', () => { @@ -325,7 +325,7 @@ describe('given dashboard with row repeat and panel repeat in horizontal directi }); describe('given dashboard with row repeat', () => { - let dashboard: any, dashboardJSON: any; + let dashboard: DashboardModel, dashboardJSON: any; beforeEach(() => { dashboardJSON = { @@ -382,7 +382,7 @@ describe('given dashboard with row repeat', () => { const scopedVars = compact( map(dashboard.panels, (panel) => { - return panel.scopedVars ? panel.scopedVars.apps.value : null; + return panel.scopedVars ? panel.scopedVars.apps?.value : null; }) ); @@ -474,14 +474,14 @@ describe('given dashboard with row repeat', () => { 'graph', ]); - expect(dashboard.panels[0].scopedVars['apps'].value).toBe('se1'); - expect(dashboard.panels[1].scopedVars['apps'].value).toBe('se1'); - expect(dashboard.panels[3].scopedVars['apps'].value).toBe('se2'); - expect(dashboard.panels[4].scopedVars['apps'].value).toBe('se2'); - expect(dashboard.panels[8].scopedVars['hosts'].value).toBe('backend01'); - expect(dashboard.panels[9].scopedVars['hosts'].value).toBe('backend01'); - expect(dashboard.panels[11].scopedVars['hosts'].value).toBe('backend02'); - expect(dashboard.panels[12].scopedVars['hosts'].value).toBe('backend02'); + expect(dashboard.panels[0].scopedVars?.['apps']?.value).toBe('se1'); + expect(dashboard.panels[1].scopedVars?.['apps']?.value).toBe('se1'); + expect(dashboard.panels[3].scopedVars?.['apps']?.value).toBe('se2'); + expect(dashboard.panels[4].scopedVars?.['apps']?.value).toBe('se2'); + expect(dashboard.panels[8].scopedVars?.['hosts']?.value).toBe('backend01'); + expect(dashboard.panels[9].scopedVars?.['hosts']?.value).toBe('backend01'); + expect(dashboard.panels[11].scopedVars?.['hosts']?.value).toBe('backend02'); + expect(dashboard.panels[12].scopedVars?.['hosts']?.value).toBe('backend02'); }); it('should assign unique ids for repeated panels', () => { @@ -505,7 +505,7 @@ describe('given dashboard with row repeat', () => { const panelIds = flattenDeep( map(dashboard.panels, (panel) => { - let ids = []; + let ids: number[] = []; if (panel.panels && panel.panels.length) { ids = map(panel.panels, 'id'); } diff --git a/public/app/features/dashboard/state/DashboardModel.test.ts b/public/app/features/dashboard/state/DashboardModel.test.ts index 99a1df997dd24..3f52eda79afb1 100644 --- a/public/app/features/dashboard/state/DashboardModel.test.ts +++ b/public/app/features/dashboard/state/DashboardModel.test.ts @@ -116,17 +116,11 @@ describe('DashboardModel', () => { expect(keys[1]).toBe('editable'); }); - it('should remove add panel panels', () => { + it('should have only 1 panel after adding panel to a new dashboard', () => { const model = createDashboardModelFixture(); - model.addPanel({ - type: 'add-panel', - }); model.addPanel({ type: 'graph', }); - model.addPanel({ - type: 'add-panel', - }); const saveModel = model.getSaveModelClone(); const panels = saveModel.panels; diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index b974f0f11e7e9..b1cbf4a502a4d 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -44,7 +44,7 @@ import { getTimeSrv } from '../services/TimeSrv'; import { mergePanels, PanelMergeInfo } from '../utils/panelMerge'; import { DashboardMigrator } from './DashboardMigrator'; -import { PanelModel, autoMigrateAngular } from './PanelModel'; +import { PanelModel } from './PanelModel'; import { TimeModel } from './TimeModel'; import { deleteScopeVars, isOnTheSameGridRow } from './utils'; @@ -70,13 +70,13 @@ export class DashboardModel implements TimeModel { editable: any; graphTooltip: DashboardCursorSync; time: any; - liveNow: boolean; + liveNow?: boolean; private originalTime: any; timepicker: any; templating: { list: any[] }; private originalTemplating: any; annotations: { list: AnnotationQuery[] }; - refresh: string; + refresh?: string; snapshot: any; schemaVersion: number; version: number; @@ -90,7 +90,7 @@ export class DashboardModel implements TimeModel { private panelsAffectedByVariableChange: number[] | null; private appEventsSubscription: Subscription; private lastRefresh: number; - private timeRangeUpdatedDuringEdit = false; + private timeRangeUpdatedDuringEditOrView = false; private originalDashboard: Dashboard | null = null; // ------------------ @@ -127,9 +127,6 @@ export class DashboardModel implements TimeModel { options?: { // By default this uses variables from redux state getVariablesFromState?: GetVariables; - - // Force the loader to migrate panels - autoMigrateOldPanels?: boolean; } ) { this.getVariablesFromState = options?.getVariablesFromState ?? getVariablesByKey; @@ -147,10 +144,10 @@ export class DashboardModel implements TimeModel { this.graphTooltip = data.graphTooltip || 0; this.time = data.time ?? { from: 'now-6h', to: 'now' }; this.timepicker = data.timepicker ?? {}; - this.liveNow = Boolean(data.liveNow); + this.liveNow = data.liveNow; this.templating = this.ensureListExist(data.templating); this.annotations = this.ensureListExist(data.annotations); - this.refresh = data.refresh || ''; + this.refresh = data.refresh; this.snapshot = data.snapshot; this.schemaVersion = data.schemaVersion ?? 0; this.fiscalYearStartMonth = data.fiscalYearStartMonth ?? 0; @@ -169,17 +166,6 @@ export class DashboardModel implements TimeModel { this.initMeta(meta); this.updateSchema(data); - // Auto-migrate old angular panels - if (options?.autoMigrateOldPanels || !config.angularSupportEnabled || config.featureToggles.autoMigrateOldPanels) { - for (const p of this.panelIterator()) { - const newType = autoMigrateAngular[p.type]; - if (!p.autoMigrateFrom && newType) { - p.autoMigrateFrom = p.type; - p.type = newType; - } - } - } - this.addBuiltInAnnotationQuery(); this.sortPanelsByGridPos(); this.panelsAffectedByVariableChange = null; @@ -302,12 +288,8 @@ export class DashboardModel implements TimeModel { } private getPanelSaveModels() { - // Todo: Remove panel.type === 'add-panel' when we remove the emptyDashboardPage toggle return this.panels - .filter( - (panel) => - this.isSnapshotTruthy() || !(panel.type === 'add-panel' || panel.repeatPanelId || panel.repeatedByRow) - ) + .filter((panel) => this.isSnapshotTruthy() || !(panel.repeatPanelId || panel.repeatedByRow)) .map((panel) => { // Clean libarary panels on save if (panel.libraryPanel) { @@ -388,8 +370,8 @@ export class DashboardModel implements TimeModel { this.events.publish(new TimeRangeUpdatedEvent(timeRange)); dispatch(onTimeRangeUpdated(this.uid, timeRange)); - if (this.panelInEdit) { - this.timeRangeUpdatedDuringEdit = true; + if (this.panelInEdit || this.panelInView) { + this.timeRangeUpdatedDuringEditOrView = true; } } @@ -402,11 +384,22 @@ export class DashboardModel implements TimeModel { return; } - for (const panel of this.panels) { - if (!this.otherPanelInFullscreen(panel) && (event.refreshAll || event.panelIds.includes(panel.id))) { - panel.refresh(); + const panelsToRefresh = this.panels.filter( + (panel) => !this.otherPanelInFullscreen(panel) && (event.refreshAll || event.panelIds.includes(panel.id)) + ); + + // We have to mark every panel as refreshWhenInView /before/ we actually refresh any + // in case there is a shared query, as otherwise that might refresh before the source panel is + // marked for refresh, preventing the panel from updating + if (!this.isSnapshot()) { + for (const panel of panelsToRefresh) { + panel.refreshWhenInView = true; } } + + for (const panel of panelsToRefresh) { + panel.refresh(); + } } render() { @@ -431,7 +424,7 @@ export class DashboardModel implements TimeModel { initEditPanel(sourcePanel: PanelModel): PanelModel { getTimeSrv().stopAutoRefresh(); this.panelInEdit = sourcePanel.getEditClone(); - this.timeRangeUpdatedDuringEdit = false; + this.timeRangeUpdatedDuringEditOrView = false; return this.panelInEdit; } @@ -441,34 +434,30 @@ export class DashboardModel implements TimeModel { getTimeSrv().resumeAutoRefresh(); - if (this.panelsAffectedByVariableChange || this.timeRangeUpdatedDuringEdit) { - this.startRefresh({ - panelIds: this.panelsAffectedByVariableChange ?? [], - refreshAll: this.timeRangeUpdatedDuringEdit, - }); - this.panelsAffectedByVariableChange = null; - this.timeRangeUpdatedDuringEdit = false; - } + this.refreshIfPanelsAffectedByVariableChangeOrTimeRangeChanged(); } initViewPanel(panel: PanelModel) { this.panelInView = panel; + this.timeRangeUpdatedDuringEditOrView = false; panel.setIsViewing(true); } exitViewPanel(panel: PanelModel) { this.panelInView = undefined; panel.setIsViewing(false); - this.refreshIfPanelsAffectedByVariableChange(); + this.refreshIfPanelsAffectedByVariableChangeOrTimeRangeChanged(); } - private refreshIfPanelsAffectedByVariableChange() { - if (!this.panelsAffectedByVariableChange) { - return; + private refreshIfPanelsAffectedByVariableChangeOrTimeRangeChanged() { + if (this.panelsAffectedByVariableChange || this.timeRangeUpdatedDuringEditOrView) { + this.startRefresh({ + panelIds: this.panelsAffectedByVariableChange ?? [], + refreshAll: this.timeRangeUpdatedDuringEditOrView, + }); + this.panelsAffectedByVariableChange = null; + this.timeRangeUpdatedDuringEditOrView = false; } - - this.startRefresh({ panelIds: this.panelsAffectedByVariableChange, refreshAll: false }); - this.panelsAffectedByVariableChange = null; } private ensurePanelsHaveUniqueIds() { @@ -1304,7 +1293,9 @@ export class DashboardModel implements TimeModel { hasAngularPlugins(): boolean { return this.panels.some((panel) => { // Return false for plugins that are angular but have angular.hideDeprecation = false - const isAngularPanel = panel.isAngularPlugin() && !panel.plugin?.meta.angular?.hideDeprecation; + // We cannot use panel.plugin.isAngularPlugin() because panel.plugin may not be initialized at this stage. + const isAngularPanel = + config.panels[panel.type]?.angular?.detected && !config.panels[panel.type]?.angular?.hideDeprecation; let isAngularDs = false; if (panel.datasource?.uid) { isAngularDs = isAngularDatasourcePluginAndNotHidden(panel.datasource?.uid); diff --git a/public/app/features/dashboard/state/PanelModel.test.ts b/public/app/features/dashboard/state/PanelModel.test.ts index 22612a0951a57..a8a7aaae420b5 100644 --- a/public/app/features/dashboard/state/PanelModel.test.ts +++ b/public/app/features/dashboard/state/PanelModel.test.ts @@ -9,6 +9,7 @@ import { dateTime, TimeRange, PanelMigrationHandler, + PanelTypeChangedHandler, } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { mockStandardFieldConfigOptions } from '@grafana/data/test/helpers/fieldConfig'; @@ -43,7 +44,7 @@ variableAdapters.setInit(() => [createQueryVariableAdapter()]); describe('PanelModel', () => { describe('when creating new panel model', () => { let model: any; - let modelJson: any; + let modelJson: Record<string, unknown>; let persistedOptionsMock; const tablePlugin = getPanelPlugin( @@ -388,10 +389,12 @@ describe('PanelModel', () => { }); describe('when changing to react panel from angular panel', () => { - let panelQueryRunner: any; + let panelQueryRunner: PanelQueryRunner; const onPanelTypeChanged = jest.fn(); - const reactPlugin = getPanelPlugin({ id: 'react' }).setPanelChangeHandler(onPanelTypeChanged as any); + const reactPlugin = getPanelPlugin({ id: 'react' }).setPanelChangeHandler( + onPanelTypeChanged as PanelTypeChangedHandler + ); beforeEach(() => { model.changePlugin(reactPlugin); @@ -412,13 +415,13 @@ describe('PanelModel', () => { }); describe('when autoMigrateFrom angular to react', () => { - const onPanelTypeChanged = (panel: PanelModel, prevPluginId: string, prevOptions: Record<string, any>) => { + const onPanelTypeChanged: PanelTypeChangedHandler = (panel, prevPluginId, prevOptions) => { panel.fieldConfig = { defaults: { unit: 'bytes' }, overrides: [] }; return { name: prevOptions.angular.oldName }; }; const reactPlugin = getPanelPlugin({ id: 'timeseries' }) - .setPanelChangeHandler(onPanelTypeChanged as any) + .setPanelChangeHandler(onPanelTypeChanged) .useFieldConfig({ disableStandardOptions: [FieldConfigProperty.Thresholds], }) @@ -450,10 +453,12 @@ describe('PanelModel', () => { }); describe('variables interpolation', () => { - let panelQueryRunner: any; + let panelQueryRunner: PanelQueryRunner; const onPanelTypeChanged = jest.fn(); - const reactPlugin = getPanelPlugin({ id: 'react' }).setPanelChangeHandler(onPanelTypeChanged as any); + const reactPlugin = getPanelPlugin({ id: 'react' }).setPanelChangeHandler( + onPanelTypeChanged as PanelTypeChangedHandler + ); beforeEach(() => { model.changePlugin(reactPlugin); diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 617e4133acae4..08d6862897275 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -36,6 +36,8 @@ import { import { PanelQueryRunner } from '../../query/state/PanelQueryRunner'; import { TimeOverrideResult } from '../utils/panel'; +import { getPanelPluginToMigrateTo } from './getPanelPluginToMigrateTo'; + export interface GridPos { x: number; y: number; @@ -121,6 +123,8 @@ const defaults: any = { cachedPluginOptions: {}, transparent: false, options: {}, + links: [], + transformations: [], fieldConfig: { defaults: {}, overrides: [], @@ -128,6 +132,15 @@ const defaults: any = { title: '', }; +export const explicitlyControlledMigrationPanels = [ + 'graph', + 'table-old', + 'grafana-piechart-panel', + 'grafana-worldmap-panel', + 'singlestat', + 'grafana-singlestat-panel', +]; + export const autoMigrateAngular: Record<string, string> = { graph: 'timeseries', 'table-old': 'table', @@ -137,7 +150,7 @@ export const autoMigrateAngular: Record<string, string> = { 'grafana-worldmap-panel': 'geomap', }; -const autoMigratePanelType: Record<string, string> = { +export const autoMigrateRemovedPanelPlugins: Record<string, string> = { 'heatmap-new': 'heatmap', // this was a temporary development panel that is now standard }; @@ -192,7 +205,7 @@ export class PanelModel implements DataConfigSource, IPanelModel { cacheTimeout?: string | null; queryCachingTTL?: number | null; isNew?: boolean; - refreshWhenInView = false; + refreshWhenInView = true; cachedPluginOptions: Record<string, PanelOptionsCache> = {}; legend?: { show: boolean; sort?: string; sortDesc?: boolean }; @@ -246,7 +259,7 @@ export class PanelModel implements DataConfigSource, IPanelModel { (this as any)[property] = model[property]; } - const newType = autoMigratePanelType[this.type]; + const newType = getPanelPluginToMigrateTo(this); if (newType) { this.autoMigrateFrom = this.type; this.type = newType; @@ -366,6 +379,7 @@ export class PanelModel implements DataConfigSource, IPanelModel { datasource: this.datasource, queries: this.targets, panelId: this.id, + panelPluginId: this.type, dashboardUID: dashboardUID, timezone: dashboardTimezone, timeRange: timeData.timeRange, diff --git a/public/app/features/dashboard/state/TimeModel.ts b/public/app/features/dashboard/state/TimeModel.ts index 539f4299886fe..870e150d174cf 100644 --- a/public/app/features/dashboard/state/TimeModel.ts +++ b/public/app/features/dashboard/state/TimeModel.ts @@ -3,7 +3,7 @@ import { TimeRange, TimeZone } from '@grafana/data'; export interface TimeModel { time: any; fiscalYearStartMonth?: number; - refresh?: string | false; + refresh?: string; timepicker: any; getTimezone(): TimeZone; timeRangeUpdated(timeRange: TimeRange): void; diff --git a/public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts b/public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts new file mode 100644 index 0000000000000..bfc5c32b13bc4 --- /dev/null +++ b/public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts @@ -0,0 +1,51 @@ +import config from 'app/core/config'; + +import { autoMigrateRemovedPanelPlugins, autoMigrateAngular } from './PanelModel'; + +export function getPanelPluginToMigrateTo(panel: any, forceMigration?: boolean): string | undefined { + if (autoMigrateRemovedPanelPlugins[panel.type]) { + return autoMigrateRemovedPanelPlugins[panel.type]; + } + + // Auto-migrate old angular panels + const shouldMigrateAllAngularPanels = + forceMigration || !config.angularSupportEnabled || config.featureToggles.autoMigrateOldPanels; + + // Graph needs special logic as it can be migrated to multiple panels + if (panel.type === 'graph' && (shouldMigrateAllAngularPanels || config.featureToggles.autoMigrateGraphPanel)) { + if (panel.xaxis?.mode === 'series') { + return 'barchart'; + } + + if (panel.xaxis?.mode === 'histogram') { + return 'histogram'; + } + + return 'timeseries'; + } + + if (shouldMigrateAllAngularPanels) { + return autoMigrateAngular[panel.type]; + } + + if (panel.type === 'table-old' && config.featureToggles.autoMigrateTablePanel) { + return 'table'; + } + + if (panel.type === 'grafana-piechart-panel' && config.featureToggles.autoMigratePiechartPanel) { + return 'piechart'; + } + + if (panel.type === 'grafana-worldmap-panel' && config.featureToggles.autoMigrateWorldmapPanel) { + return 'geomap'; + } + + if ( + (panel.type === 'singlestat' || panel.type === 'grafana-singlestat-panel') && + config.featureToggles.autoMigrateStatPanel + ) { + return 'stat'; + } + + return undefined; +} diff --git a/public/app/features/dashboard/state/initDashboard.test.ts b/public/app/features/dashboard/state/initDashboard.test.ts index 381562b4c2ab2..8b54addac7bef 100644 --- a/public/app/features/dashboard/state/initDashboard.test.ts +++ b/public/app/features/dashboard/state/initDashboard.test.ts @@ -1,8 +1,8 @@ -import configureMockStore from 'redux-mock-store'; +import configureMockStore, { MockStore } from 'redux-mock-store'; import thunk from 'redux-thunk'; import { Subject } from 'rxjs'; -import { FetchError, locationService, setEchoSrv } from '@grafana/runtime'; +import { BackendSrv, FetchError, locationService, setEchoSrv } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; import { getBackendSrv } from 'app/core/services/backend_srv'; import { KeybindingSrv } from 'app/core/services/keybindingSrv'; @@ -21,8 +21,8 @@ import { getPreloadedState } from '../../variables/state/helpers'; import { initialTransactionState, variablesInitTransaction } from '../../variables/state/transactionReducer'; import { TransactionStatus } from '../../variables/types'; import { DashboardLoaderSrv, setDashboardLoaderSrv } from '../services/DashboardLoaderSrv'; -import { getDashboardSrv, setDashboardSrv } from '../services/DashboardSrv'; -import { getTimeSrv, setTimeSrv } from '../services/TimeSrv'; +import { DashboardSrv, getDashboardSrv, setDashboardSrv } from '../services/DashboardSrv'; +import { getTimeSrv, setTimeSrv, TimeSrv } from '../services/TimeSrv'; import { initDashboard, InitDashboardArgs } from './initDashboard'; import { dashboardInitCompleted, dashboardInitFetching, dashboardInitServices } from './reducers'; @@ -68,10 +68,10 @@ const mockStore = configureMockStore([thunk]); interface ScenarioContext { args: InitDashboardArgs; - loaderSrv: any; - backendSrv: any; + loaderSrv: DashboardLoaderSrv; + backendSrv: jest.Mocked<BackendSrv>; setup: (fn: () => void) => void; - actions: any[]; + actions: ReturnType<MockStore['getActions']>; storeState: any; } @@ -91,7 +91,7 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) { title: 'My cool dashboard', panels: [ { - type: 'add-panel', + type: 'stat', gridPos: { x: 0, y: 0, w: 12, h: 9 }, title: 'Panel Title', id: 2, @@ -175,14 +175,14 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) { uid: DASH_UID, }, })), - }; + } as unknown as DashboardLoaderSrv; - setDashboardLoaderSrv(loaderSrv as unknown as DashboardLoaderSrv); + setDashboardLoaderSrv(loaderSrv); setDashboardQueryRunnerFactory(() => ({ getResult: emptyResult, run: jest.fn(), cancel: () => undefined, - cancellations: () => new Subject<any>(), + cancellations: () => new Subject(), destroy: () => undefined, })); @@ -197,7 +197,7 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) { setupDashboardBindings: jest.fn(), } as unknown as KeybindingSrv, }, - backendSrv: getBackendSrv(), + backendSrv: getBackendSrv() as unknown as jest.Mocked<BackendSrv>, loaderSrv, actions: [], storeState: { @@ -226,11 +226,11 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) { beforeEach(async () => { setDashboardSrv({ setCurrent: jest.fn(), - } as any); + } as unknown as DashboardSrv); setTimeSrv({ init: jest.fn(), - } as any); + } as unknown as TimeSrv); setupFn(); setEchoSrv(new Echo()); diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index e657299c6812e..6d1e0c7031286 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -9,20 +9,16 @@ import store from 'app/core/store'; import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { DashboardSrv, getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; -import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; +import { + HOME_DASHBOARD_CACHE_KEY, + getDashboardScenePageStateManager, +} from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; +import { buildNewDashboardSaveModel } from 'app/features/dashboard-scene/serialization/buildNewDashboardSaveModel'; import { getFolderByUid } from 'app/features/folders/state/actions'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; import { toStateKey } from 'app/features/variables/utils'; -import { - DashboardDTO, - DashboardInitPhase, - DashboardMeta, - DashboardRoutes, - StoreState, - ThunkDispatch, - ThunkResult, -} from 'app/types'; +import { DashboardDTO, DashboardInitPhase, DashboardRoutes, StoreState, ThunkDispatch, ThunkResult } from 'app/types'; import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; import { initVariablesTransaction } from '../../variables/state/actions'; @@ -44,7 +40,6 @@ export interface InitDashboardArgs { routeName?: string; fixUrl: boolean; keybindingSrv: KeybindingSrv; - dashboardDto?: DashboardDTO; } async function fetchDashboard( @@ -63,7 +58,7 @@ async function fetchDashboard( switch (args.routeName) { case DashboardRoutes.Home: { const stateManager = getDashboardScenePageStateManager(); - const cachedDashboard = stateManager.getFromCache(DashboardRoutes.Home); + const cachedDashboard = stateManager.getFromCache(HOME_DASHBOARD_CACHE_KEY); if (cachedDashboard) { return cachedDashboard; @@ -88,11 +83,6 @@ async function fetchDashboard( case DashboardRoutes.Public: { return await dashboardLoaderSrv.loadDashboard('public', args.urlSlug, args.accessToken); } - case DashboardRoutes.Embedded: { - if (args.dashboardDto) { - return args.dashboardDto; - } - } case DashboardRoutes.Normal: { const dashDTO: DashboardDTO = await dashboardLoaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid); @@ -107,7 +97,7 @@ async function fetchDashboard( } } - if (args.fixUrl && dashDTO.meta.url && !playlistSrv.isPlaying) { + if (args.fixUrl && dashDTO.meta.url && !playlistSrv.state.isPlaying) { // check if the current url is correct (might be old slug) const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url); const currentPath = locationService.getLocation().pathname; @@ -130,7 +120,7 @@ async function fetchDashboard( if (args.urlFolderUid) { await dispatch(getFolderByUid(args.urlFolderUid)); } - return getNewDashboardModelData(args.urlFolderUid, args.panelType); + return buildNewDashboardSaveModel(args.urlFolderUid); } case DashboardRoutes.Path: { const path = args.urlSlug ?? ''; @@ -184,6 +174,8 @@ const getQueriesByDatasource = ( */ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> { return async (dispatch, getState) => { + const initStart = performance.now(); + // set fetching state dispatch(dashboardInitFetching()); @@ -293,48 +285,14 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> { }) ); - trackDashboardLoaded(dashboard, versionBeforeMigration); + const duration = performance.now() - initStart; + trackDashboardLoaded(dashboard, duration, versionBeforeMigration); // yay we are done dispatch(dashboardInitCompleted(dashboard)); }; } -export function getNewDashboardModelData( - urlFolderUid?: string, - panelType?: string -): { dashboard: any; meta: DashboardMeta } { - const panels = config.featureToggles.emptyDashboardPage - ? [] - : [ - { - type: panelType ?? 'add-panel', - gridPos: { x: 0, y: 0, w: 12, h: 9 }, - title: 'Panel Title', - }, - ]; - - const data = { - meta: { - canStar: false, - canShare: false, - canDelete: false, - isNew: true, - folderUid: '', - }, - dashboard: { - title: 'New dashboard', - panels, - }, - }; - - if (urlFolderUid) { - data.meta.folderUid = urlFolderUid; - } - - return data; -} - const DASHBOARD_FROM_LS_KEY = 'DASHBOARD_FROM_LS_KEY'; export function setDashboardToFetchFromLocalStorage(model: DashboardDTO) { diff --git a/public/app/features/dashboard/utils/dashboard.test.ts b/public/app/features/dashboard/utils/dashboard.test.ts index 93ca6ece84a7a..c6b0299d2c394 100644 --- a/public/app/features/dashboard/utils/dashboard.test.ts +++ b/public/app/features/dashboard/utils/dashboard.test.ts @@ -8,7 +8,7 @@ import { // Mock the store module jest.mock('app/core/store', () => ({ exists: jest.fn(), - getObject: jest.fn(), + getObject: jest.fn((_a, b) => b), setObject: jest.fn(), get: jest.fn(), })); diff --git a/public/app/features/dashboard/utils/getPanelChromeProps.tsx b/public/app/features/dashboard/utils/getPanelChromeProps.tsx index 1197f5b68763b..73209b8653679 100644 --- a/public/app/features/dashboard/utils/getPanelChromeProps.tsx +++ b/public/app/features/dashboard/utils/getPanelChromeProps.tsx @@ -79,7 +79,7 @@ export function getPanelChromeProps(props: CommonProps) { const onCancelQuery = () => { props.panel.getQueryRunner().cancelQuery(); - DashboardInteractions.panelCancelQueryClicked(); + DashboardInteractions.panelCancelQueryClicked({ data_state: props.data.state }); }; const padding: PanelPadding = props.plugin.noPadding ? 'none' : 'md'; diff --git a/public/app/features/dashboard/utils/getPanelMenu.ts b/public/app/features/dashboard/utils/getPanelMenu.ts index e1b30e22b2163..adeb45331dac7 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.ts @@ -1,16 +1,20 @@ import { - getTimeZone, PanelMenuItem, PluginExtensionPoints, + getTimeZone, urlUtil, type PluginExtensionPanelContext, } from '@grafana/data'; import { AngularComponent, getPluginLinkExtensions, locationService } from '@grafana/runtime'; import { PanelCtrl } from 'app/angular/panel/panel_ctrl'; import config from 'app/core/config'; +import { createErrorNotification } from 'app/core/copy/appNotification'; import { t } from 'app/core/internationalization'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { contextSrv } from 'app/core/services/context_srv'; +import { getMessageFromError } from 'app/core/utils/errors'; import { getExploreUrl } from 'app/core/utils/explore'; +import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; import { panelToRuleFormValues } from 'app/features/alerting/unified/utils/rule-form'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; @@ -27,8 +31,9 @@ import { DashboardInteractions } from 'app/features/dashboard-scene/utils/intera import { InspectTab } from 'app/features/inspector/types'; import { isPanelModelLibraryPanel } from 'app/features/library-panels/guard'; import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils'; +import { addDataTrailPanelAction } from 'app/features/trails/Integrations/dashboardIntegration'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; -import { store } from 'app/store/store'; +import { dispatch, store } from 'app/store/store'; import { getCreateAlertInMenuAvailability } from '../../alerting/unified/utils/access-control'; import { navigateToExplore } from '../../explore/state/main'; @@ -164,6 +169,10 @@ export function getPanelMenu( }); } + if (config.featureToggles.datatrails) { + addDataTrailPanelAction(dashboard, panel, menu); + } + const inspectMenu: PanelMenuItem[] = []; // Only show these inspect actions for data plugins @@ -206,8 +215,14 @@ export function getPanelMenu( }); const createAlert = async () => { - const formValues = await panelToRuleFormValues(panel, dashboard); - + let formValues: Partial<RuleFormValues> | undefined; + try { + formValues = await panelToRuleFormValues(panel, dashboard); + } catch (err) { + const message = `Error getting rule values from the panel: ${getMessageFromError(err)}`; + dispatch(notifyApp(createErrorNotification(message))); + return; + } const ruleFormUrl = urlUtil.renderUrl('/alerting/new', { defaults: JSON.stringify(formValues), returnTo: location.pathname + location.search, diff --git a/public/app/features/dashboard/utils/panel.ts b/public/app/features/dashboard/utils/panel.ts index cd178de9a4d03..688451bc7301d 100644 --- a/public/app/features/dashboard/utils/panel.ts +++ b/public/app/features/dashboard/utils/panel.ts @@ -9,8 +9,8 @@ import store from 'app/core/store'; import { ShareModal } from 'app/features/dashboard/components/ShareModal'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; +import { UnlinkModal } from 'app/features/dashboard-scene/scene/UnlinkModal'; import { AddLibraryPanelModal } from 'app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal'; -import { UnlinkModal } from 'app/features/library-panels/components/UnlinkModal/UnlinkModal'; import { cleanUpPanelState } from 'app/features/panel/state/actions'; import { dispatch } from 'app/store/store'; @@ -19,17 +19,12 @@ import { ShowConfirmModalEvent, ShowModalReactEvent } from '../../../types/event export const removePanel = (dashboard: DashboardModel, panel: PanelModel, ask: boolean) => { // confirm deletion if (ask !== false) { - const text2 = - panel.alert && !config.unifiedAlertingEnabled - ? 'Panel includes an alert rule. removing the panel will also remove the alert rule' - : undefined; const confirmText = panel.alert ? 'YES' : undefined; appEvents.publish( new ShowConfirmModalEvent({ title: 'Remove panel', text: 'Are you sure you want to remove this panel?', - text2: text2, icon: 'trash-alt', confirmText: confirmText, yesText: 'Remove', diff --git a/public/app/features/dashboard/utils/tracking.test.ts b/public/app/features/dashboard/utils/tracking.test.ts index b92aa5a87d510..79fc467fba990 100644 --- a/public/app/features/dashboard/utils/tracking.test.ts +++ b/public/app/features/dashboard/utils/tracking.test.ts @@ -33,9 +33,10 @@ describe('trackDashboardLoaded', () => { const model = getDashboardModel(dashboardJSON); const reportInteractionSpy = jest.spyOn(runtime, 'reportInteraction'); - trackDashboardLoaded(model, 16); + trackDashboardLoaded(model, 200, 16); expect(reportInteractionSpy).toHaveBeenCalledWith('dashboards_init_dashboard_completed', { + duration: 200, uid: 'dashboard-123', title: 'Test Dashboard', schemaVersion: model.schemaVersion, // This value is based on public/app/features/dashboard/state/DashboardMigrator.ts#L81 diff --git a/public/app/features/dashboard/utils/tracking.ts b/public/app/features/dashboard/utils/tracking.ts index 383e6412523fb..5031a39607253 100644 --- a/public/app/features/dashboard/utils/tracking.ts +++ b/public/app/features/dashboard/utils/tracking.ts @@ -2,7 +2,7 @@ import { DashboardInteractions } from 'app/features/dashboard-scene/utils/intera import { DashboardModel } from '../state'; -export function trackDashboardLoaded(dashboard: DashboardModel, versionBeforeMigration?: number) { +export function trackDashboardLoaded(dashboard: DashboardModel, duration: number, versionBeforeMigration?: number) { // Count the different types of variables const variables = dashboard.templating.list .map((v) => v.type) @@ -30,6 +30,7 @@ export function trackDashboardLoaded(dashboard: DashboardModel, versionBeforeMig ...variables, settings_nowdelay: dashboard.timepicker.nowDelay, settings_livenow: !!dashboard.liveNow, + duration, }); } diff --git a/public/app/features/datasources/components/DashboardsTable.test.tsx b/public/app/features/datasources/components/DashboardsTable.test.tsx index 8b463ad43439b..8ce9bcad3882d 100644 --- a/public/app/features/datasources/components/DashboardsTable.test.tsx +++ b/public/app/features/datasources/components/DashboardsTable.test.tsx @@ -25,7 +25,6 @@ describe('DashboardsTable', () => { mockDashboard = { dashboardId: 0, description: '', - folderId: 0, imported: false, importedRevision: 0, importedUri: '', diff --git a/public/app/features/datasources/components/EditDataSource.tsx b/public/app/features/datasources/components/EditDataSource.tsx index a0104d828f750..e4802f21b491a 100644 --- a/public/app/features/datasources/components/EditDataSource.tsx +++ b/public/app/features/datasources/components/EditDataSource.tsx @@ -129,7 +129,7 @@ export function EditDataSourceView({ trackDsConfigUpdated({ item: 'success' }); appEvents.publish(new DataSourceUpdatedSuccessfully()); } catch (error) { - trackDsConfigUpdated({ item: 'fail', error }); + trackDsConfigUpdated({ item: 'fail' }); return; } @@ -139,7 +139,9 @@ export function EditDataSourceView({ const extensions = useMemo(() => { const allowedPluginIds = ['grafana-pdc-app', 'grafana-auth-app']; const extensionPointId = PluginExtensionPoints.DataSourceConfig; - const { extensions } = getPluginComponentExtensions({ extensionPointId }); + const { extensions } = getPluginComponentExtensions<{ + context: PluginExtensionDataSourceConfigContext<DataSourceJsonData>; + }>({ extensionPointId }); return extensions.filter((e) => allowedPluginIds.includes(e.pluginId)); }, []); @@ -202,9 +204,7 @@ export function EditDataSourceView({ {/* Extension point */} {extensions.map((extension) => { - const Component = extension.component as React.ComponentType<{ - context: PluginExtensionDataSourceConfigContext<DataSourceJsonData>; - }>; + const Component = extension.component; return ( <div key={extension.id}> diff --git a/public/app/features/datasources/components/picker/BuiltInDataSourceList.tsx b/public/app/features/datasources/components/picker/BuiltInDataSourceList.tsx index ee08899e4a281..44c90bf819c71 100644 --- a/public/app/features/datasources/components/picker/BuiltInDataSourceList.tsx +++ b/public/app/features/datasources/components/picker/BuiltInDataSourceList.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { DataSourceInstanceSettings } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { DataSourceRef } from '@grafana/schema'; import { t } from 'app/core/internationalization'; @@ -69,7 +70,7 @@ export function BuiltInDataSourceList({ const filteredResults = grafanaDataSources.filter((ds) => (filter ? filter?.(ds) : true) && !!ds.meta.builtIn); return ( - <div className={className} data-testid="built-in-data-sources-list"> + <div className={className} data-testid={selectors.components.DataSourcePicker.advancedModal.builtInDataSourceList}> {filteredResults.map((ds) => { return ( <DataSourceCard diff --git a/public/app/features/datasources/components/picker/DataSourceDropdown.tsx b/public/app/features/datasources/components/picker/DataSourceDropdown.tsx deleted file mode 100644 index b109b63cf3405..0000000000000 --- a/public/app/features/datasources/components/picker/DataSourceDropdown.tsx +++ /dev/null @@ -1,437 +0,0 @@ -import { css } from '@emotion/css'; -import { useDialog } from '@react-aria/dialog'; -import { FocusScope } from '@react-aria/focus'; -import { useOverlay } from '@react-aria/overlays'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { usePopper } from 'react-popper'; -import { Observable } from 'rxjs'; - -import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { reportInteraction } from '@grafana/runtime'; -import { DataQuery, DataSourceRef } from '@grafana/schema'; -import { Button, CustomScrollbar, Icon, Input, ModalsController, Portal, useStyles2 } from '@grafana/ui'; -import config from 'app/core/config'; -import { Trans } from 'app/core/internationalization'; -import { useKeyNavigationListener } from 'app/features/search/hooks/useSearchKeyboardSelection'; -import { defaultFileUploadQuery, GrafanaQuery } from 'app/plugins/datasource/grafana/types'; - -import { useDatasource } from '../../hooks'; - -import { DataSourceList } from './DataSourceList'; -import { DataSourceLogo, DataSourceLogoPlaceHolder } from './DataSourceLogo'; -import { DataSourceModal } from './DataSourceModal'; -import { applyMaxSize, maxSize } from './popperModifiers'; -import { dataSourceLabel, matchDataSourceWithSearch } from './utils'; - -const INTERACTION_EVENT_NAME = 'dashboards_dspicker_clicked'; -const INTERACTION_ITEM = { - OPEN_DROPDOWN: 'open_dspicker', - SELECT_DS: 'select_ds', - ADD_FILE: 'add_file', - OPEN_ADVANCED_DS_PICKER: 'open_advanced_ds_picker', - CONFIG_NEW_DS_EMPTY_STATE: 'config_new_ds_empty_state', -}; - -export interface DataSourceDropdownProps { - onChange: (ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => void; - current?: DataSourceInstanceSettings | string | DataSourceRef | null; - recentlyUsed?: string[]; - hideTextValue?: boolean; - width?: number; - inputId?: string; - noDefault?: boolean; - disabled?: boolean; - placeholder?: string; - - // DS filters - tracing?: boolean; - mixed?: boolean; - dashboard?: boolean; - metrics?: boolean; - type?: string | string[]; - annotations?: boolean; - variables?: boolean; - alerting?: boolean; - pluginId?: string; - logs?: boolean; - uploadFile?: boolean; - filter?: (ds: DataSourceInstanceSettings) => boolean; -} - -export function DataSourceDropdown(props: DataSourceDropdownProps) { - const { - current, - onChange, - hideTextValue = false, - width, - inputId, - noDefault = false, - disabled = false, - placeholder = 'Select data source', - ...restProps - } = props; - - const styles = useStyles2(getStylesDropdown, props); - const [isOpen, setOpen] = useState(false); - const [inputHasFocus, setInputHasFocus] = useState(false); - const [filterTerm, setFilterTerm] = useState<string>(''); - const { onKeyDown, keyboardEvents } = useKeyNavigationListener(); - const ref = useRef<HTMLDivElement>(null); - - // Used to position the popper correctly and to bring back the focus when navigating from footer to input - const [markerElement, setMarkerElement] = useState<HTMLInputElement | null>(); - // Used to position the popper correctly - const [selectorElement, setSelectorElement] = useState<HTMLDivElement | null>(); - // Used to move the focus to the footer when tabbing from the input - const [footerRef, setFooterRef] = useState<HTMLElement | null>(); - const currentDataSourceInstanceSettings = useDatasource(current); - const grafanaDS = useDatasource('-- Grafana --'); - const currentValue = Boolean(!current && noDefault) ? undefined : currentDataSourceInstanceSettings; - const prefixIcon = - filterTerm && isOpen ? <DataSourceLogoPlaceHolder /> : <DataSourceLogo dataSource={currentValue} />; - - const popper = usePopper(markerElement, selectorElement, { - placement: 'bottom-start', - modifiers: [ - { - name: 'offset', - options: { - offset: [0, 4], - }, - }, - maxSize, - applyMaxSize, - ], - }); - - const onClose = useCallback(() => { - setFilterTerm(''); - setOpen(false); - markerElement?.focus(); - }, [setOpen, markerElement]); - - const { overlayProps, underlayProps } = useOverlay( - { - onClose: onClose, - isDismissable: true, - isOpen, - shouldCloseOnInteractOutside: (element) => { - return markerElement ? !markerElement.isSameNode(element) : false; - }, - }, - ref - ); - const { dialogProps } = useDialog( - { - 'aria-label': 'Opened data source picker list', - }, - ref - ); - - function openDropdown() { - reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_DROPDOWN }); - setOpen(true); - markerElement?.focus(); - } - - function onClickAddCSV() { - if (!grafanaDS) { - return; - } - - onChange(grafanaDS, [defaultFileUploadQuery]); - } - - function onKeyDownInput(keyEvent: React.KeyboardEvent<HTMLInputElement>) { - // From the input, it navigates to the footer - if (keyEvent.key === 'Tab' && !keyEvent.shiftKey && isOpen) { - keyEvent.preventDefault(); - footerRef?.focus(); - } - // From the input, if we navigate back, it closes the dropdown - if (keyEvent.key === 'Tab' && keyEvent.shiftKey && isOpen) { - onClose(); - } - onKeyDown(keyEvent); - } - - function onNavigateOutsiteFooter(e: React.KeyboardEvent<HTMLButtonElement>) { - // When navigating back, the dropdown keeps open and the input element is focused. - if (e.shiftKey) { - e.preventDefault(); - markerElement?.focus(); - // When navigating forward, the dropdown closes and and the element next to the input element is focused. - } else { - onClose(); - } - } - - useEffect(() => { - const sub = keyboardEvents.subscribe({ - next: (keyEvent) => { - switch (keyEvent?.code) { - case 'ArrowDown': - openDropdown(); - keyEvent.preventDefault(); - break; - case 'ArrowUp': - openDropdown(); - keyEvent.preventDefault(); - break; - case 'Escape': - onClose(); - keyEvent.preventDefault(); - break; - } - }, - }); - return () => sub.unsubscribe(); - }); - - return ( - <div className={styles.container} data-testid={selectors.components.DataSourcePicker.container}> - {/* This clickable div is just extending the clickable area on the input element to include the prefix and suffix. */} - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} - <div className={styles.trigger} onClick={openDropdown}> - <Input - id={inputId || 'data-source-picker'} - className={inputHasFocus ? undefined : styles.input} - data-testid={selectors.components.DataSourcePicker.inputV2} - aria-label="Select a data source" - autoComplete="off" - prefix={currentValue ? prefixIcon : undefined} - suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />} - placeholder={hideTextValue ? '' : dataSourceLabel(currentValue) || placeholder} - onFocus={() => { - setInputHasFocus(true); - }} - onBlur={() => { - setInputHasFocus(false); - }} - onKeyDown={onKeyDownInput} - value={filterTerm} - onChange={(e) => { - openDropdown(); - setFilterTerm(e.currentTarget.value); - }} - ref={setMarkerElement} - disabled={disabled} - ></Input> - </div> - {isOpen ? ( - <Portal> - <div {...underlayProps} /> - <div ref={ref} {...overlayProps} {...dialogProps}> - <PickerContent - {...restProps} - {...popper.attributes.popper} - style={popper.styles.popper} - ref={setSelectorElement} - footerRef={setFooterRef} - current={currentValue} - filterTerm={filterTerm} - keyboardEvents={keyboardEvents} - onChange={(ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => { - onClose(); - if (ds.uid !== currentValue?.uid) { - onChange(ds, defaultQueries); - reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.SELECT_DS, ds_type: ds.type }); - } - }} - onClose={onClose} - onClickAddCSV={onClickAddCSV} - onDismiss={onClose} - onNavigateOutsiteFooter={onNavigateOutsiteFooter} - /> - </div> - </Portal> - ) : null} - </div> - ); -} - -function getStylesDropdown(theme: GrafanaTheme2, props: DataSourceDropdownProps) { - return { - container: css` - position: relative; - cursor: ${props.disabled ? 'not-allowed' : 'pointer'}; - width: ${theme.spacing(props.width || 'auto')}; - `, - trigger: css` - cursor: pointer; - ${props.disabled && `pointer-events: none;`} - `, - input: css` - input::placeholder { - color: ${props.disabled ? theme.colors.action.disabledText : theme.colors.text.primary}; - } - `, - }; -} - -export interface PickerContentProps extends DataSourceDropdownProps { - onClickAddCSV?: () => void; - keyboardEvents: Observable<React.KeyboardEvent>; - style: React.CSSProperties; - filterTerm?: string; - onClose: () => void; - onDismiss: () => void; - footerRef: (element: HTMLElement | null) => void; - onNavigateOutsiteFooter: (e: React.KeyboardEvent<HTMLButtonElement>) => void; -} - -const PickerContent = React.forwardRef<HTMLDivElement, PickerContentProps>((props, ref) => { - const { filterTerm, onChange, onClose, onClickAddCSV, current, filter } = props; - - const changeCallback = useCallback( - (ds: DataSourceInstanceSettings) => { - onChange(ds); - }, - [onChange] - ); - - const clickAddCSVCallback = useCallback(() => { - onClickAddCSV?.(); - onClose(); - reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.ADD_FILE }); - }, [onClickAddCSV, onClose]); - - const styles = useStyles2(getStylesPickerContent); - - return ( - <div style={props.style} ref={ref} className={styles.container}> - <CustomScrollbar> - <DataSourceList - {...props} - enableKeyboardNavigation - className={styles.dataSourceList} - current={current} - onChange={changeCallback} - filter={(ds) => (filter ? filter?.(ds) : true) && matchDataSourceWithSearch(ds, filterTerm)} - onClickEmptyStateCTA={() => - reportInteraction(INTERACTION_EVENT_NAME, { - item: INTERACTION_ITEM.CONFIG_NEW_DS_EMPTY_STATE, - }) - } - ></DataSourceList> - </CustomScrollbar> - <FocusScope> - <Footer - {...props} - onClickAddCSV={clickAddCSVCallback} - onChange={changeCallback} - onNavigateOutsiteFooter={props.onNavigateOutsiteFooter} - /> - </FocusScope> - </div> - ); -}); -PickerContent.displayName = 'PickerContent'; - -function getStylesPickerContent(theme: GrafanaTheme2) { - return { - container: css` - display: flex; - flex-direction: column; - background: ${theme.colors.background.primary}; - box-shadow: ${theme.shadows.z3}; - `, - picker: css` - background: ${theme.colors.background.secondary}; - `, - dataSourceList: css` - flex: 1; - `, - footer: css` - flex: 0; - display: flex; - flex-direction: row-reverse; - justify-content: space-between; - padding: ${theme.spacing(1.5)}; - border-top: 1px solid ${theme.colors.border.weak}; - background-color: ${theme.colors.background.secondary}; - `, - }; -} - -export interface FooterProps extends PickerContentProps {} - -function Footer({ onClose, onChange, onClickAddCSV, ...props }: FooterProps) { - const styles = useStyles2(getStylesFooter); - const isUploadFileEnabled = props.uploadFile && config.featureToggles.editPanelCSVDragAndDrop; - - const onKeyDownLastButton = (e: React.KeyboardEvent<HTMLButtonElement>) => { - if (e.key === 'Tab') { - props.onNavigateOutsiteFooter(e); - } - }; - const onKeyDownFirstButton = (e: React.KeyboardEvent<HTMLButtonElement>) => { - if (e.key === 'Tab' && e.shiftKey) { - props.onNavigateOutsiteFooter(e); - } - }; - - return ( - <div className={styles.footer}> - <ModalsController> - {({ showModal, hideModal }) => ( - <Button - size="sm" - variant="secondary" - fill="text" - onClick={() => { - onClose(); - showModal(DataSourceModal, { - reportedInteractionFrom: 'ds_picker', - tracing: props.tracing, - dashboard: props.dashboard, - mixed: props.mixed, - metrics: props.metrics, - type: props.type, - annotations: props.annotations, - variables: props.variables, - alerting: props.alerting, - pluginId: props.pluginId, - logs: props.logs, - filter: props.filter, - uploadFile: props.uploadFile, - current: props.current, - onDismiss: hideModal, - onChange: (ds, defaultQueries) => { - onChange(ds, defaultQueries); - hideModal(); - }, - }); - reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_ADVANCED_DS_PICKER }); - }} - ref={props.footerRef} - onKeyDown={isUploadFileEnabled ? onKeyDownFirstButton : onKeyDownLastButton} - > - <Trans i18nKey="data-source-picker.open-advanced-button">Open advanced data source picker</Trans> - <Icon name="arrow-right" /> - </Button> - )} - </ModalsController> - {isUploadFileEnabled && ( - <Button variant="secondary" size="sm" onClick={onClickAddCSV} onKeyDown={onKeyDownLastButton}> - Add csv or spreadsheet - </Button> - )} - </div> - ); -} - -function getStylesFooter(theme: GrafanaTheme2) { - return { - footer: css` - flex: 0; - display: flex; - flex-direction: row-reverse; - justify-content: space-between; - padding: ${theme.spacing(1.5)}; - border-top: 1px solid ${theme.colors.border.weak}; - background-color: ${theme.colors.background.secondary}; - `, - }; -} diff --git a/public/app/features/datasources/components/picker/DataSourceList.tsx b/public/app/features/datasources/components/picker/DataSourceList.tsx index df286934acb0e..0d2238a882019 100644 --- a/public/app/features/datasources/components/picker/DataSourceList.tsx +++ b/public/app/features/datasources/components/picker/DataSourceList.tsx @@ -3,6 +3,7 @@ import React, { useRef } from 'react'; import { Observable } from 'rxjs'; import { DataSourceInstanceSettings, DataSourceRef, GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { getTemplateSrv } from '@grafana/runtime'; import { useStyles2, useTheme2 } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; @@ -72,7 +73,11 @@ export function DataSourceList(props: DataSourceListProps) { const filteredDataSources = props.filter ? dataSources.filter(props.filter) : dataSources; return ( - <div ref={containerRef} className={cx(className, styles.container)} data-testid="data-sources-list"> + <div + ref={containerRef} + className={cx(className, styles.container)} + data-testid={selectors.components.DataSourcePicker.dataSourceList} + > {filteredDataSources.length === 0 && ( <EmptyState className={styles.emptyState} onClickCTA={onClickEmptyStateCTA} /> )} diff --git a/public/app/features/datasources/components/picker/DataSourceDropdown.test.tsx b/public/app/features/datasources/components/picker/DataSourcePicker.test.tsx similarity index 92% rename from public/app/features/datasources/components/picker/DataSourceDropdown.test.tsx rename to public/app/features/datasources/components/picker/DataSourcePicker.test.tsx index 5b9ba51f77893..c13140a43c3e8 100644 --- a/public/app/features/datasources/components/picker/DataSourceDropdown.test.tsx +++ b/public/app/features/datasources/components/picker/DataSourcePicker.test.tsx @@ -9,7 +9,7 @@ import { ModalRoot, ModalsProvider } from '@grafana/ui'; import config from 'app/core/config'; import { defaultFileUploadQuery } from 'app/plugins/datasource/grafana/types'; -import { DataSourceDropdown, DataSourceDropdownProps } from './DataSourceDropdown'; +import { DataSourcePicker, DataSourcePickerProps } from './DataSourcePicker'; import * as utils from './utils'; const pluginMetaInfo: PluginMetaInfo = { @@ -45,8 +45,8 @@ const MockDSBuiltIn = createDS('mock.datasource.builtin', 3, true); const mockDSList = [mockDS1, mockDS2, MockDSBuiltIn]; -async function setupOpenDropdown(user: UserEvent, props: DataSourceDropdownProps) { - const dropdown = render(<DataSourceDropdown {...props}></DataSourceDropdown>); +async function setupOpenDropdown(user: UserEvent, props: DataSourcePickerProps) { + const dropdown = render(<DataSourcePicker {...props}></DataSourcePicker>); const searchBox = dropdown.container.querySelector('input'); expect(searchBox).toBeInTheDocument(); await user.click(searchBox!); @@ -93,9 +93,9 @@ beforeEach(() => { getInstanceSettingsMock.mockReturnValue(mockDS1); }); -describe('DataSourceDropdown', () => { +describe('DataSourcePicker', () => { it('should render', () => { - expect(() => render(<DataSourceDropdown onChange={jest.fn()}></DataSourceDropdown>)).not.toThrow(); + expect(() => render(<DataSourcePicker onChange={jest.fn()}></DataSourcePicker>)).not.toThrow(); }); describe('configuration', () => { @@ -123,7 +123,7 @@ describe('DataSourceDropdown', () => { render( <ModalsProvider> - <DataSourceDropdown {...props}></DataSourceDropdown> + <DataSourcePicker {...props}></DataSourcePicker> <ModalRoot /> </ModalsProvider> ); @@ -145,7 +145,7 @@ describe('DataSourceDropdown', () => { it('should display the current selected DS in the selector', async () => { getInstanceSettingsMock.mockReturnValue(mockDS2); - render(<DataSourceDropdown onChange={jest.fn()} current={mockDS2}></DataSourceDropdown>); + render(<DataSourcePicker onChange={jest.fn()} current={mockDS2}></DataSourcePicker>); expect(screen.getByTestId(selectors.components.DataSourcePicker.inputV2)).toHaveAttribute( 'placeholder', mockDS2.name @@ -169,7 +169,7 @@ describe('DataSourceDropdown', () => { it('should display the default DS as selected when `current` is not set', async () => { getInstanceSettingsMock.mockReturnValue(mockDS2); - render(<DataSourceDropdown onChange={jest.fn()} current={undefined}></DataSourceDropdown>); + render(<DataSourcePicker onChange={jest.fn()} current={undefined}></DataSourcePicker>); expect(screen.getByTestId(selectors.components.DataSourcePicker.inputV2)).toHaveAttribute( 'placeholder', mockDS2.name @@ -186,12 +186,12 @@ describe('DataSourceDropdown', () => { }); it('should disable the dropdown when `disabled` is true', () => { - render(<DataSourceDropdown onChange={jest.fn()} disabled></DataSourceDropdown>); + render(<DataSourcePicker onChange={jest.fn()} disabled></DataSourcePicker>); expect(screen.getByTestId(selectors.components.DataSourcePicker.inputV2)).toBeDisabled(); }); it('should assign the correct `id` to the input element to pair it with a label', () => { - render(<DataSourceDropdown onChange={jest.fn()} inputId={'custom.input.id'}></DataSourceDropdown>); + render(<DataSourcePicker onChange={jest.fn()} inputId={'custom.input.id'}></DataSourcePicker>); expect(screen.getByTestId(selectors.components.DataSourcePicker.inputV2)).toHaveAttribute( 'id', 'custom.input.id' @@ -199,7 +199,7 @@ describe('DataSourceDropdown', () => { }); it('should not set the default DS when setting `noDefault` to true and `current` is not provided', () => { - render(<DataSourceDropdown onChange={jest.fn()} current={null} noDefault></DataSourceDropdown>); + render(<DataSourcePicker onChange={jest.fn()} current={null} noDefault></DataSourcePicker>); getListMock.mockClear(); getInstanceSettingsMock.mockClear(); // Doesn't try to get the default DS @@ -300,7 +300,7 @@ describe('DataSourceDropdown', () => { const props = { onChange: jest.fn(), current: mockDS1.name }; render( <ModalsProvider> - <DataSourceDropdown {...props}></DataSourceDropdown> + <DataSourcePicker {...props}></DataSourcePicker> <ModalRoot /> </ModalsProvider> ); diff --git a/public/app/features/datasources/components/picker/DataSourcePicker.tsx b/public/app/features/datasources/components/picker/DataSourcePicker.tsx index 4c75756d49708..0441eeb2ab75a 100644 --- a/public/app/features/datasources/components/picker/DataSourcePicker.tsx +++ b/public/app/features/datasources/components/picker/DataSourcePicker.tsx @@ -1,24 +1,456 @@ -import React from 'react'; +import { css } from '@emotion/css'; +import { autoUpdate, flip, offset, shift, size, useFloating } from '@floating-ui/react'; +import { useDialog } from '@react-aria/dialog'; +import { FocusScope } from '@react-aria/focus'; +import { useOverlay } from '@react-aria/overlays'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Observable } from 'rxjs'; -import { - DataSourcePicker as DeprecatedDataSourcePicker, - DataSourcePickerProps as DeprecatedDataSourcePickerProps, -} from '@grafana/runtime'; -import { config } from 'app/core/config'; +import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { reportInteraction } from '@grafana/runtime'; +import { DataQuery, DataSourceRef } from '@grafana/schema'; +import { Button, CustomScrollbar, Icon, Input, ModalsController, Portal, useStyles2 } from '@grafana/ui'; +import config from 'app/core/config'; +import { Trans } from 'app/core/internationalization'; +import { useKeyNavigationListener } from 'app/features/search/hooks/useSearchKeyboardSelection'; +import { defaultFileUploadQuery, GrafanaQuery } from 'app/plugins/datasource/grafana/types'; -import { DataSourceDropdown, DataSourceDropdownProps } from './DataSourceDropdown'; +import { useDatasource } from '../../hooks'; -type DataSourcePickerProps = DeprecatedDataSourcePickerProps | DataSourceDropdownProps; +import { DataSourceList } from './DataSourceList'; +import { DataSourceLogo, DataSourceLogoPlaceHolder } from './DataSourceLogo'; +import { DataSourceModal } from './DataSourceModal'; +import { dataSourceLabel, matchDataSourceWithSearch } from './utils'; + +const INTERACTION_EVENT_NAME = 'dashboards_dspicker_clicked'; +const INTERACTION_ITEM = { + OPEN_DROPDOWN: 'open_dspicker', + SELECT_DS: 'select_ds', + ADD_FILE: 'add_file', + OPEN_ADVANCED_DS_PICKER: 'open_advanced_ds_picker', + CONFIG_NEW_DS_EMPTY_STATE: 'config_new_ds_empty_state', +}; + +export interface DataSourcePickerProps { + onChange: (ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => void; + current?: DataSourceInstanceSettings | string | DataSourceRef | null; + recentlyUsed?: string[]; + hideTextValue?: boolean; + width?: number; + inputId?: string; + noDefault?: boolean; + disabled?: boolean; + placeholder?: string; + + // DS filters + tracing?: boolean; + mixed?: boolean; + dashboard?: boolean; + metrics?: boolean; + type?: string | string[]; + annotations?: boolean; + variables?: boolean; + alerting?: boolean; + pluginId?: string; + logs?: boolean; + uploadFile?: boolean; + filter?: (ds: DataSourceInstanceSettings) => boolean; +} -/** - * DataSourcePicker is a wrapper around the old DataSourcePicker and the new one. - * Depending on the feature toggle, it will render the old or the new one. - * Feature toggle: advancedDataSourcePicker - */ export function DataSourcePicker(props: DataSourcePickerProps) { - return !config.featureToggles.advancedDataSourcePicker ? ( - <DeprecatedDataSourcePicker {...props} /> - ) : ( - <DataSourceDropdown {...props} /> + const { + current, + onChange, + hideTextValue = false, + width, + inputId, + noDefault = false, + disabled = false, + placeholder = 'Select data source', + ...restProps + } = props; + + const styles = useStyles2(getStylesDropdown, props); + const [isOpen, setOpen] = useState(false); + const [inputHasFocus, setInputHasFocus] = useState(false); + const [filterTerm, setFilterTerm] = useState<string>(''); + const { onKeyDown, keyboardEvents } = useKeyNavigationListener(); + const ref = useRef<HTMLDivElement>(null); + + // Used to position the popper correctly and to bring back the focus when navigating from footer to input + const [markerElement, setMarkerElement] = useState<HTMLInputElement | null>(); + // Used to move the focus to the footer when tabbing from the input + const [footerRef, setFooterRef] = useState<HTMLElement | null>(); + const currentDataSourceInstanceSettings = useDatasource(current); + const grafanaDS = useDatasource('-- Grafana --'); + const currentValue = Boolean(!current && noDefault) ? undefined : currentDataSourceInstanceSettings; + const prefixIcon = + filterTerm && isOpen ? <DataSourceLogoPlaceHolder /> : <DataSourceLogo dataSource={currentValue} />; + + // the order of middleware is important! + const middleware = [ + offset(4), + size({ + apply({ availableHeight, elements }) { + const margin = 20; + const minSize = 200; + elements.floating.style.maxHeight = `${Math.max(minSize, availableHeight - margin)}px`; + elements.floating.style.minHeight = `${minSize}px`; + }, + }), + flip({ + fallbackStrategy: 'initialPlacement', + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ]; + + const { refs, floatingStyles } = useFloating({ + open: isOpen, + placement: 'bottom-start', + onOpenChange: setOpen, + middleware, + whileElementsMounted: autoUpdate, + strategy: 'fixed', + }); + + const handleReference = useCallback( + (element: HTMLInputElement | null) => { + refs.setReference(element); + setMarkerElement(element); + }, + [refs] + ); + + const onClose = useCallback(() => { + setFilterTerm(''); + setOpen(false); + markerElement?.focus(); + }, [setOpen, markerElement]); + + const { overlayProps, underlayProps } = useOverlay( + { + onClose: onClose, + isDismissable: true, + isOpen, + shouldCloseOnInteractOutside: (element) => { + return markerElement ? !markerElement.isSameNode(element) : false; + }, + }, + ref + ); + const { dialogProps } = useDialog( + { + 'aria-label': 'Opened data source picker list', + }, + ref + ); + + function openDropdown() { + reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_DROPDOWN }); + setOpen(true); + markerElement?.focus(); + } + + function onClickAddCSV() { + if (!grafanaDS) { + return; + } + + onChange(grafanaDS, [defaultFileUploadQuery]); + } + + function onKeyDownInput(keyEvent: React.KeyboardEvent<HTMLInputElement>) { + // From the input, it navigates to the footer + if (keyEvent.key === 'Tab' && !keyEvent.shiftKey && isOpen) { + keyEvent.preventDefault(); + footerRef?.focus(); + } + // From the input, if we navigate back, it closes the dropdown + if (keyEvent.key === 'Tab' && keyEvent.shiftKey && isOpen) { + onClose(); + } + onKeyDown(keyEvent); + } + + function onNavigateOutsiteFooter(e: React.KeyboardEvent<HTMLButtonElement>) { + // When navigating back, the dropdown keeps open and the input element is focused. + if (e.shiftKey) { + e.preventDefault(); + markerElement?.focus(); + // When navigating forward, the dropdown closes and and the element next to the input element is focused. + } else { + onClose(); + } + } + + useEffect(() => { + const sub = keyboardEvents.subscribe({ + next: (keyEvent) => { + switch (keyEvent?.code) { + case 'ArrowDown': + openDropdown(); + keyEvent.preventDefault(); + break; + case 'ArrowUp': + openDropdown(); + keyEvent.preventDefault(); + break; + case 'Escape': + onClose(); + keyEvent.preventDefault(); + break; + } + }, + }); + return () => sub.unsubscribe(); + }); + + return ( + <div className={styles.container} data-testid={selectors.components.DataSourcePicker.container}> + {/* This clickable div is just extending the clickable area on the input element to include the prefix and suffix. */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} + <div className={styles.trigger} onClick={openDropdown}> + <Input + id={inputId || 'data-source-picker'} + className={inputHasFocus ? undefined : styles.input} + data-testid={selectors.components.DataSourcePicker.inputV2} + aria-label="Select a data source" + autoComplete="off" + prefix={currentValue ? prefixIcon : undefined} + suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />} + placeholder={hideTextValue ? '' : dataSourceLabel(currentValue) || placeholder} + onFocus={() => { + setInputHasFocus(true); + }} + onBlur={() => { + setInputHasFocus(false); + }} + onKeyDown={onKeyDownInput} + value={filterTerm} + onChange={(e) => { + openDropdown(); + setFilterTerm(e.currentTarget.value); + }} + ref={handleReference} + disabled={disabled} + ></Input> + </div> + {isOpen ? ( + <Portal> + <div {...underlayProps} /> + <div ref={ref} {...overlayProps} {...dialogProps}> + <PickerContent + {...restProps} + style={floatingStyles} + ref={refs.setFloating} + footerRef={setFooterRef} + current={currentValue} + filterTerm={filterTerm} + keyboardEvents={keyboardEvents} + onChange={(ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => { + onClose(); + if (ds.uid !== currentValue?.uid) { + onChange(ds, defaultQueries); + reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.SELECT_DS, ds_type: ds.type }); + } + }} + onClose={onClose} + onClickAddCSV={onClickAddCSV} + onDismiss={onClose} + onNavigateOutsiteFooter={onNavigateOutsiteFooter} + /> + </div> + </Portal> + ) : null} + </div> ); } + +function getStylesDropdown(theme: GrafanaTheme2, props: DataSourcePickerProps) { + return { + container: css({ + position: 'relative', + cursor: props.disabled ? 'not-allowed' : 'pointer', + width: theme.spacing(props.width || 'auto'), + }), + trigger: css({ + cursor: 'pointer', + pointerEvents: props.disabled ? 'none' : 'auto', + }), + input: css({ + 'input::placeholder': { + color: props.disabled ? theme.colors.action.disabledText : theme.colors.text.primary, + }, + }), + }; +} + +export interface PickerContentProps extends DataSourcePickerProps { + onClickAddCSV?: () => void; + keyboardEvents: Observable<React.KeyboardEvent>; + style: React.CSSProperties; + filterTerm?: string; + onClose: () => void; + onDismiss: () => void; + footerRef: (element: HTMLElement | null) => void; + onNavigateOutsiteFooter: (e: React.KeyboardEvent<HTMLButtonElement>) => void; +} + +const PickerContent = React.forwardRef<HTMLDivElement, PickerContentProps>((props, ref) => { + const { filterTerm, onChange, onClose, onClickAddCSV, current, filter } = props; + + const changeCallback = useCallback( + (ds: DataSourceInstanceSettings) => { + onChange(ds); + }, + [onChange] + ); + + const clickAddCSVCallback = useCallback(() => { + onClickAddCSV?.(); + onClose(); + reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.ADD_FILE }); + }, [onClickAddCSV, onClose]); + + const styles = useStyles2(getStylesPickerContent); + + return ( + <div style={props.style} ref={ref} className={styles.container}> + <CustomScrollbar> + <DataSourceList + {...props} + enableKeyboardNavigation + className={styles.dataSourceList} + current={current} + onChange={changeCallback} + filter={(ds) => (filter ? filter?.(ds) : true) && matchDataSourceWithSearch(ds, filterTerm)} + onClickEmptyStateCTA={() => + reportInteraction(INTERACTION_EVENT_NAME, { + item: INTERACTION_ITEM.CONFIG_NEW_DS_EMPTY_STATE, + }) + } + ></DataSourceList> + </CustomScrollbar> + <FocusScope> + <Footer + {...props} + onClickAddCSV={clickAddCSVCallback} + onChange={changeCallback} + onNavigateOutsiteFooter={props.onNavigateOutsiteFooter} + /> + </FocusScope> + </div> + ); +}); +PickerContent.displayName = 'PickerContent'; + +function getStylesPickerContent(theme: GrafanaTheme2) { + return { + container: css({ + display: 'flex', + flexDirection: 'column', + background: theme.colors.background.primary, + boxShadow: theme.shadows.z3, + }), + picker: css({ + background: theme.colors.background.secondary, + }), + dataSourceList: css({ + flex: 1, + }), + footer: css({ + flex: 0, + display: 'flex', + flexDirection: 'row-reverse', + justifyContent: 'space-between', + padding: theme.spacing(1.5), + borderTop: `1px solid ${theme.colors.border.weak}`, + backgroundColor: theme.colors.background.secondary, + }), + }; +} + +export interface FooterProps extends PickerContentProps {} + +function Footer({ onClose, onChange, onClickAddCSV, ...props }: FooterProps) { + const styles = useStyles2(getStylesFooter); + const isUploadFileEnabled = props.uploadFile && config.featureToggles.editPanelCSVDragAndDrop; + + const onKeyDownLastButton = (e: React.KeyboardEvent<HTMLButtonElement>) => { + if (e.key === 'Tab') { + props.onNavigateOutsiteFooter(e); + } + }; + const onKeyDownFirstButton = (e: React.KeyboardEvent<HTMLButtonElement>) => { + if (e.key === 'Tab' && e.shiftKey) { + props.onNavigateOutsiteFooter(e); + } + }; + + return ( + <div className={styles.footer}> + <ModalsController> + {({ showModal, hideModal }) => ( + <Button + size="sm" + variant="secondary" + fill="text" + onClick={() => { + onClose(); + showModal(DataSourceModal, { + reportedInteractionFrom: 'ds_picker', + tracing: props.tracing, + dashboard: props.dashboard, + mixed: props.mixed, + metrics: props.metrics, + type: props.type, + annotations: props.annotations, + variables: props.variables, + alerting: props.alerting, + pluginId: props.pluginId, + logs: props.logs, + filter: props.filter, + uploadFile: props.uploadFile, + current: props.current, + onDismiss: hideModal, + onChange: (ds, defaultQueries) => { + onChange(ds, defaultQueries); + hideModal(); + }, + }); + reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_ADVANCED_DS_PICKER }); + }} + ref={props.footerRef} + onKeyDown={isUploadFileEnabled ? onKeyDownFirstButton : onKeyDownLastButton} + > + <Trans i18nKey="data-source-picker.open-advanced-button">Open advanced data source picker</Trans> + <Icon name="arrow-right" /> + </Button> + )} + </ModalsController> + {isUploadFileEnabled && ( + <Button variant="secondary" size="sm" onClick={onClickAddCSV} onKeyDown={onKeyDownLastButton}> + Add csv or spreadsheet + </Button> + )} + </div> + ); +} + +function getStylesFooter(theme: GrafanaTheme2) { + return { + footer: css({ + flex: 0, + display: 'flex', + flexDirection: 'row-reverse', + justifyContent: 'space-between', + padding: theme.spacing(1.5), + borderTop: `1px solid ${theme.colors.border.weak}`, + backgroundColor: theme.colors.background.secondary, + }), + }; +} diff --git a/public/app/features/datasources/components/picker/popperModifiers.ts b/public/app/features/datasources/components/picker/popperModifiers.ts deleted file mode 100644 index 733ae14649e63..0000000000000 --- a/public/app/features/datasources/components/picker/popperModifiers.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { detectOverflow, Modifier, ModifierArguments } from '@popperjs/core'; - -const MODAL_MARGIN = 20; -const FLIP_THRESHOLD = 200; - -export const maxSize: Modifier<'maxSize', {}> = { - name: 'maxSize', - enabled: true, - phase: 'main', - requires: ['offset', 'preventOverflow', 'flip'], - fn({ state, name, options }: ModifierArguments<{}>) { - const overflow = detectOverflow(state, options); - const { x, y } = state.modifiersData.preventOverflow || { x: 0, y: 0 }; - const { width: contentW, height: contentH } = state.rects.popper; - const { width: triggerW } = state.rects.reference; - const [basePlacement] = state.placement.split('-'); - - const widthProp = basePlacement === 'left' ? 'left' : 'right'; - const heightProp = basePlacement === 'top' ? 'top' : 'bottom'; - - state.modifiersData[name] = { - maxWidth: contentW - overflow[widthProp] - x, - maxHeight: contentH - overflow[heightProp] - y, - minWidth: triggerW, - }; - }, -}; - -export const applyMaxSize: Modifier<'applyMaxSize', {}> = { - name: 'applyMaxSize', - enabled: true, - phase: 'beforeWrite', - requires: ['maxSize'], - fn({ state }: ModifierArguments<{}>) { - const { maxHeight, maxWidth, minWidth } = state.modifiersData.maxSize; - - state.styles.popper.maxHeight ??= `${maxHeight - MODAL_MARGIN}px`; - state.styles.popper.minHeight ??= `${FLIP_THRESHOLD}px`; - state.styles.popper.maxWidth ??= maxWidth; - state.styles.popper.minWidth ??= minWidth; - }, -}; diff --git a/public/app/features/datasources/state/actions.test.ts b/public/app/features/datasources/state/actions.test.ts index 7f6b93b9208cb..eb67937e089a0 100644 --- a/public/app/features/datasources/state/actions.test.ts +++ b/public/app/features/datasources/state/actions.test.ts @@ -1,8 +1,9 @@ import { thunkTester } from 'test/core/thunk/thunkTester'; import { AppPluginMeta, DataSourceSettings, PluginMetaInfo, PluginType } from '@grafana/data'; -import { FetchError } from '@grafana/runtime'; +import { DataSourceSrv, FetchError } from '@grafana/runtime'; import { appEvents } from 'app/core/core'; +import { getBackendSrv } from 'app/core/services/backend_srv'; import { ThunkResult, ThunkDispatch } from 'app/types'; import { getMockDataSource } from '../__mocks__'; @@ -55,7 +56,7 @@ const getBackendSrvMock = () => }), }), withNoBackendCache: jest.fn().mockImplementationOnce((cb) => cb()), - }) as any; + }) as unknown as ReturnType<typeof getBackendSrv>; const failDataSourceTest = async (error: object) => { const dependencies: TestDataSourceDependencies = { @@ -66,7 +67,7 @@ const failDataSourceTest = async (error: object) => { throw error; }), }), - }) as any, + }) as Pick<DataSourceSrv, 'get'>, getBackendSrv: getBackendSrvMock, }; const state = { @@ -228,7 +229,7 @@ describe('testDataSource', () => { type: 'cloudwatch', uid: 'CW1234', }), - }) as any, + }) as Pick<DataSourceSrv, 'get'>, getBackendSrv: getBackendSrvMock, }; const state = { @@ -263,7 +264,7 @@ describe('testDataSource', () => { type: 'azure-monitor', uid: 'azM0nit0R', }), - }) as any, + }) as Pick<DataSourceSrv, 'get'>, getBackendSrv: getBackendSrvMock, }; const result = { diff --git a/public/app/features/datasources/state/buildCategories.test.ts b/public/app/features/datasources/state/buildCategories.test.ts index d29e5839d727c..d3e3d741c39d3 100644 --- a/public/app/features/datasources/state/buildCategories.test.ts +++ b/public/app/features/datasources/state/buildCategories.test.ts @@ -53,7 +53,7 @@ describe('buildCategories', () => { it('should add enterprise phantom plugins', () => { const enterprisePluginsCategory = categories[3]; expect(enterprisePluginsCategory.title).toBe('Enterprise plugins'); - expect(enterprisePluginsCategory.plugins.length).toBe(18); + expect(enterprisePluginsCategory.plugins.length).toBe(19); expect(enterprisePluginsCategory.plugins[0].name).toBe('AppDynamics'); expect(enterprisePluginsCategory.plugins[enterprisePluginsCategory.plugins.length - 1].name).toBe('Wavefront'); }); diff --git a/public/app/features/datasources/state/buildCategories.ts b/public/app/features/datasources/state/buildCategories.ts index bab9d9011a79e..2629f9226d67e 100644 --- a/public/app/features/datasources/state/buildCategories.ts +++ b/public/app/features/datasources/state/buildCategories.ts @@ -203,6 +203,12 @@ function getEnterprisePhantomPlugins(): DataSourcePluginMeta[] { name: 'SumoLogic', imgUrl: 'public/img/plugins/sumo.svg', }), + getPhantomPlugin({ + id: 'grafana-pagerduty-datasource', + description: 'PagerDuty datasource', + name: 'PagerDuty', + imgUrl: 'public/img/plugins/pagerduty.svg', + }), ]; } diff --git a/public/app/features/explore/ContentOutline/ContentOutline.tsx b/public/app/features/explore/ContentOutline/ContentOutline.tsx index 01b9be893ae20..aa604d6d79286 100644 --- a/public/app/features/explore/ContentOutline/ContentOutline.tsx +++ b/public/app/features/explore/ContentOutline/ContentOutline.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; -import React from 'react'; -import { useToggle } from 'react-use'; +import React, { useEffect, useRef, useState } from 'react'; +import { useToggle, useScroll } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; @@ -33,9 +33,12 @@ const getStyles = (theme: GrafanaTheme2) => { }; export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement | undefined; panelId: string }) { + const { outlineItems } = useContentOutlineContext(); const [expanded, toggleExpanded] = useToggle(false); + const [activeItemId, setActiveItemId] = useState<string | undefined>(outlineItems[0]?.id); const styles = useStyles2((theme) => getStyles(theme)); - const { outlineItems } = useContentOutlineContext(); + const scrollerRef = useRef(scroller || null); + const { y: verticalScroll } = useScroll(scrollerRef); const scrollIntoView = (ref: HTMLElement | null, buttonTitle: string) => { let scrollValue = 0; @@ -50,6 +53,7 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement | top: scrollValue, behavior: 'smooth', }); + reportInteraction('explore_toolbar_contentoutline_clicked', { item: 'select_section', type: buttonTitle, @@ -64,6 +68,24 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement | }); }; + useEffect(() => { + const activeItem = outlineItems.find((item) => { + const top = item?.ref?.getBoundingClientRect().top; + + if (!top) { + return false; + } + + return top >= 0; + }); + + if (!activeItem) { + return; + } + + setActiveItemId(activeItem.id); + }, [outlineItems, verticalScroll]); + return ( <PanelContainer className={styles.wrapper} id={panelId}> <CustomScrollbar> @@ -77,16 +99,19 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement | aria-expanded={expanded} /> - {outlineItems.map((item) => ( - <ContentOutlineItemButton - key={item.id} - title={expanded ? item.title : undefined} - className={styles.buttonStyles} - icon={item.icon} - onClick={() => scrollIntoView(item.ref, item.title)} - tooltip={!expanded ? item.title : undefined} - /> - ))} + {outlineItems.map((item) => { + return ( + <ContentOutlineItemButton + key={item.id} + title={expanded ? item.title : undefined} + className={styles.buttonStyles} + icon={item.icon} + onClick={() => scrollIntoView(item.ref, item.title)} + tooltip={!expanded ? item.title : undefined} + isActive={activeItemId === item.id} + /> + ); + })} </div> </CustomScrollbar> </PanelContainer> diff --git a/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx b/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx index 5ca319431e1d5..f5a145c69655a 100644 --- a/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx +++ b/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx @@ -10,7 +10,7 @@ export interface ContentOutlineItemContextProps extends ContentOutlineItemBasePr type RegisterFunction = ({ title, icon, ref }: Omit<ContentOutlineItemContextProps, 'id'>) => string; -interface ContentOutlineContextProps { +export interface ContentOutlineContextProps { outlineItems: ContentOutlineItemContextProps[]; register: RegisterFunction; unregister: (id: string) => void; diff --git a/public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx b/public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx index 7f887a76899fd..febd0e6d0091d 100644 --- a/public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx +++ b/public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx @@ -9,17 +9,31 @@ type CommonProps = { icon: string; tooltip?: string; className?: string; + isActive?: boolean; }; export type ContentOutlineItemButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>; -export function ContentOutlineItemButton({ title, icon, tooltip, className, ...rest }: ContentOutlineItemButtonProps) { +export function ContentOutlineItemButton({ + title, + icon, + tooltip, + className, + isActive, + ...rest +}: ContentOutlineItemButtonProps) { const styles = useStyles2(getStyles); const buttonStyles = cx(styles.button, className); const body = ( - <button className={buttonStyles} aria-label={tooltip} {...rest}> + <button + className={cx(buttonStyles, { + [styles.active]: isActive, + })} + aria-label={tooltip} + {...rest} + > {renderIcon(icon)} {title} </button> @@ -68,5 +82,23 @@ const getStyles = (theme: GrafanaTheme2) => { textDecoration: 'underline', }, }), + active: css({ + backgroundColor: theme.colors.background.secondary, + borderTopRightRadius: theme.shape.radius.default, + borderBottomRightRadius: theme.shape.radius.default, + position: 'relative', + + '&::before': { + backgroundImage: theme.colors.gradients.brandVertical, + borderRadius: theme.shape.radius.default, + content: '" "', + display: 'block', + height: '100%', + position: 'absolute', + transform: 'translateX(-50%)', + width: theme.spacing(0.5), + left: '2px', + }, + }), }; }; diff --git a/public/app/features/explore/CorrelationEditorModeBar.tsx b/public/app/features/explore/CorrelationEditorModeBar.tsx index c714b951c07ff..5df1b7ef04c99 100644 --- a/public/app/features/explore/CorrelationEditorModeBar.tsx +++ b/public/app/features/explore/CorrelationEditorModeBar.tsx @@ -57,7 +57,9 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl ) { const { exploreId, changeDatasourceUid } = correlationDetails?.postConfirmAction; if (exploreId && changeDatasourceUid) { - dispatch(changeDatasource(exploreId, changeDatasourceUid, { importQueries: true })); + dispatch( + changeDatasource({ exploreId, datasource: changeDatasourceUid, options: { importQueries: true } }) + ); dispatch( changeCorrelationEditorDetails({ isExiting: false, @@ -143,7 +145,7 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl const changeDatasourcePostAction = (exploreId: string, datasourceUid: string) => { setSaveMessage(undefined); - dispatch(changeDatasource(exploreId, datasourceUid, { importQueries: true })); + dispatch(changeDatasource({ exploreId, datasource: datasourceUid, options: { importQueries: true } })); }; const saveCorrelationPostAction = (skipPostConfirmAction: boolean) => { @@ -163,7 +165,7 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE && changeDatasourceUid !== undefined ) { - changeDatasource(exploreId, changeDatasourceUid); + changeDatasource({ exploreId, datasource: changeDatasourceUid }); resetEditor(); } } else { diff --git a/public/app/features/explore/CorrelationHelper.tsx b/public/app/features/explore/CorrelationHelper.tsx index 0df3d3d17d7ef..c352410ddb596 100644 --- a/public/app/features/explore/CorrelationHelper.tsx +++ b/public/app/features/explore/CorrelationHelper.tsx @@ -84,10 +84,10 @@ export const CorrelationHelper = ({ exploreId, correlations }: Props) => { useEffect(() => { const subscription = watch((value) => { let dirty = correlationDetails?.correlationDirty || false; - - if (!dirty && (value.label !== defaultLabel || value.description !== '')) { + let description = value.description || ''; + if (!dirty && (value.label !== defaultLabel || description !== '')) { dirty = true; - } else if (dirty && value.label === defaultLabel && value.description.trim() === '') { + } else if (dirty && value.label === defaultLabel && description.trim() === '') { dirty = false; } dispatch( diff --git a/public/app/features/explore/CorrelationTransformationAddModal.tsx b/public/app/features/explore/CorrelationTransformationAddModal.tsx index 63388852e3e79..f99414d589035 100644 --- a/public/app/features/explore/CorrelationTransformationAddModal.tsx +++ b/public/app/features/explore/CorrelationTransformationAddModal.tsx @@ -1,10 +1,10 @@ import { css } from '@emotion/css'; import React, { useId, useState, useMemo, useEffect } from 'react'; import Highlighter from 'react-highlight-words'; -import { useForm } from 'react-hook-form'; +import { useForm, Controller } from 'react-hook-form'; import { DataLinkTransformationConfig, ScopedVars } from '@grafana/data'; -import { Button, Field, Icon, Input, InputControl, Label, Modal, Select, Tooltip, Stack } from '@grafana/ui'; +import { Button, Field, Icon, Input, Label, Modal, Select, Tooltip, Stack } from '@grafana/ui'; import { getSupportedTransTypeDetails, @@ -101,18 +101,21 @@ export const CorrelationTransformationAddModal = ({ isExpressionValid = !formFieldsVis.expressionDetails.show; } setIsExpValid(isExpressionValid); - const transformationVars = getTransformationVars( - { - type: formValues.type, - expression: isExpressionValid ? expression : '', - mapValue: formValues.mapValue, - }, - fieldList[formValues.field!] || '', - formValues.field! - ); + let transKeys = []; + if (formValues.type) { + const transformationVars = getTransformationVars( + { + type: formValues.type, + expression: isExpressionValid ? expression : '', + mapValue: formValues.mapValue, + }, + fieldList[formValues.field!] || '', + formValues.field! + ); - const transKeys = Object.keys(transformationVars); - setTransformationVars(transKeys.length > 0 ? { ...transformationVars } : {}); + transKeys = Object.keys(transformationVars); + setTransformationVars(transKeys.length > 0 ? { ...transformationVars } : {}); + } if (transKeys.length === 0 || !isExpressionValid) { setValidToSave(false); @@ -135,7 +138,7 @@ export const CorrelationTransformationAddModal = ({ field variables. </p> <Field label="Field"> - <InputControl + <Controller control={control} render={({ field: { onChange, ref, ...field } }) => ( <Select @@ -166,7 +169,7 @@ export const CorrelationTransformationAddModal = ({ /> </pre> <Field label="Type"> - <InputControl + <Controller control={control} render={({ field: { onChange, ref, ...field } }) => ( <Select diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index 68de610f7c43e..420fd5fd7105a 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; -import { AutoSizerProps } from 'react-virtualized-auto-sizer'; +import { Props as AutoSizerProps } from 'react-virtualized-auto-sizer'; import { TestProvider } from 'test/helpers/TestProvider'; import { CoreApp, createTheme, DataSourceApi, EventBusSrv, LoadingState, PluginExtensionTypes } from '@grafana/data'; @@ -124,7 +124,13 @@ jest.mock('@grafana/runtime', () => ({ // for the AutoSizer component to have a width jest.mock('react-virtualized-auto-sizer', () => { - return ({ children }: AutoSizerProps) => children({ height: 1, width: 1 }); + return ({ children }: AutoSizerProps) => + children({ + height: 1, + scaledHeight: 1, + scaledWidth: 1, + width: 1, + }); }); const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index b3ebd9e0f3e4f..4d27d12d865d4 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -3,7 +3,7 @@ import { get, groupBy } from 'lodash'; import memoizeOne from 'memoize-one'; import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import AutoSizer from 'react-virtualized-auto-sizer'; +import AutoSizer, { HorizontalSize } from 'react-virtualized-auto-sizer'; import { AbsoluteTimeRange, @@ -205,13 +205,13 @@ export class Explore extends React.PureComponent<Props, ExploreState> { * TODO: In the future, we would like to return active filters based the query that produced the log line. * @alpha */ - isFilterLabelActive = async (key: string, value: string, refId?: string) => { + isFilterLabelActive = async (key: string, value: string | number, refId?: string) => { const query = this.props.queries.find((q) => q.refId === refId); if (!query) { return false; } const ds = await getDataSourceSrv().get(query.datasource); - if (hasToggleableQueryFiltersSupport(ds) && ds.queryHasFilter(query, { key, value })) { + if (hasToggleableQueryFiltersSupport(ds) && ds.queryHasFilter(query, { key, value: value.toString() })) { return true; } return false; @@ -220,11 +220,11 @@ export class Explore extends React.PureComponent<Props, ExploreState> { /** * Used by Logs details. */ - onClickFilterLabel = (key: string, value: string, frame?: DataFrame) => { + onClickFilterLabel = (key: string, value: string | number, frame?: DataFrame) => { this.onModifyQueries( { type: 'ADD_FILTER', - options: { key, value }, + options: { key, value: value.toString() }, frame, }, frame?.refId @@ -234,11 +234,11 @@ export class Explore extends React.PureComponent<Props, ExploreState> { /** * Used by Logs details. */ - onClickFilterOutLabel = (key: string, value: string, frame?: DataFrame) => { + onClickFilterOutLabel = (key: string, value: string | number, frame?: DataFrame) => { this.onModifyQueries( { type: 'ADD_FILTER_OUT', - options: { key, value }, + options: { key, value: value.toString() }, frame, }, frame?.refId @@ -248,15 +248,15 @@ export class Explore extends React.PureComponent<Props, ExploreState> { /** * Used by Logs Popover Menu. */ - onClickFilterValue = (value: string, refId?: string) => { - this.onModifyQueries({ type: 'ADD_STRING_FILTER', options: { value } }, refId); + onClickFilterValue = (value: string | number, refId?: string) => { + this.onModifyQueries({ type: 'ADD_STRING_FILTER', options: { value: value.toString() } }, refId); }; /** * Used by Logs Popover Menu. */ - onClickFilterOutValue = (value: string, refId?: string) => { - this.onModifyQueries({ type: 'ADD_STRING_FILTER_OUT', options: { value } }, refId); + onClickFilterOutValue = (value: string | number, refId?: string) => { + this.onModifyQueries({ type: 'ADD_STRING_FILTER_OUT', options: { value: value.toString() } }, refId); }; onClickAddQueryRowButton = () => { @@ -296,7 +296,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> { this.props.modifyQueries(this.props.exploreId, action, modifier); }; - onResize = (size: { height: number; width: number }) => { + onResize = (size: HorizontalSize) => { this.props.changeSize(this.props.exploreId, size); }; @@ -682,6 +682,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> { width={width} onClose={this.toggleShowQueryInspector} timeZone={timeZone} + isMixed={datasourceInstance.meta.mixed || false} /> )} </ErrorBoundaryAlert> diff --git a/public/app/features/explore/ExploreDrawer.tsx b/public/app/features/explore/ExploreDrawer.tsx index a58ae9415a631..3e6fac2735939 100644 --- a/public/app/features/explore/ExploreDrawer.tsx +++ b/public/app/features/explore/ExploreDrawer.tsx @@ -5,7 +5,7 @@ import React from 'react'; // Services & Utils import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2, useTheme2 } from '@grafana/ui'; +import { getDragStyles, useStyles2, useTheme2 } from '@grafana/ui'; export interface Props { width: number; @@ -17,13 +17,14 @@ export function ExploreDrawer(props: Props) { const { width, children, onResize } = props; const theme = useTheme2(); const styles = useStyles2(getStyles); + const dragStyles = getDragStyles(theme); const drawerWidth = `${width + 31.5}px`; return ( <Resizable - className={cx(styles.container, styles.drawerActive)} + className={cx(styles.fixed, styles.container, styles.drawerActive)} defaultSize={{ width: drawerWidth, height: `${theme.components.horizontalDrawer.defaultHeight}px` }} - handleClasses={{ top: styles.rzHandle }} + handleClasses={{ top: dragStyles.dragHandleHorizontal }} enable={{ top: true, right: false, @@ -55,31 +56,20 @@ const drawerSlide = (theme: GrafanaTheme2) => keyframes` `; const getStyles = (theme: GrafanaTheme2) => ({ - container: css` - position: fixed !important; - bottom: 0; - background: ${theme.colors.background.primary}; - border-top: 1px solid ${theme.colors.border.weak}; - margin: ${theme.spacing(0, -2, 0, -2)}; - box-shadow: ${theme.shadows.z3}; - z-index: ${theme.zIndex.navbarFixed}; - `, - drawerActive: css` - opacity: 1; - animation: 0.5s ease-out ${drawerSlide(theme)}; - `, - rzHandle: css` - background: ${theme.colors.secondary.main}; - transition: 0.3s background ease-in-out; - position: relative; - width: 200px !important; - height: 7px !important; - left: calc(50% - 100px) !important; - top: -4px !important; - cursor: grab; - border-radius: ${theme.shape.radius.pill}; - &:hover { - background: ${theme.colors.secondary.shade}; - } - `, + // @ts-expect-error csstype doesn't allow !important. see https://github.com/frenic/csstype/issues/114 + fixed: css({ + position: 'fixed !important', + }), + container: css({ + bottom: 0, + background: theme.colors.background.primary, + borderTop: `1px solid ${theme.colors.border.weak}`, + margin: theme.spacing(0, -2, 0, -2), + boxShadow: theme.shadows.z3, + zIndex: theme.zIndex.navbarFixed, + }), + drawerActive: css({ + opacity: 1, + animation: `0.5s ease-out ${drawerSlide(theme)}`, + }), }); diff --git a/public/app/features/explore/ExplorePaneContainer.tsx b/public/app/features/explore/ExplorePaneContainer.tsx index 2c5f73917df08..7eec0b39ed43d 100644 --- a/public/app/features/explore/ExplorePaneContainer.tsx +++ b/public/app/features/explore/ExplorePaneContainer.tsx @@ -2,26 +2,22 @@ import { css } from '@emotion/css'; import React, { useEffect, useMemo, useRef } from 'react'; import { connect } from 'react-redux'; -import { EventBusSrv, GrafanaTheme2 } from '@grafana/data'; +import { EventBusSrv } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { useStyles2, CustomScrollbar } from '@grafana/ui'; +import { CustomScrollbar } from '@grafana/ui'; import { stopQueryState } from 'app/core/utils/explore'; import { StoreState, useSelector } from 'app/types'; import Explore from './Explore'; import { getExploreItemSelector } from './state/selectors'; -const getStyles = (theme: GrafanaTheme2) => { - return { - explore: css` - label: explorePaneContainer; - display: flex; - flex-direction: column; - min-width: 600px; - height: 100%; - `, - }; -}; +const containerStyles = css({ + label: 'explorePaneContainer', + display: 'flex', + flexDirection: 'column', + minWidth: '600px', + height: '100%', +}); interface Props { exploreId: string; @@ -38,7 +34,6 @@ interface Props { */ function ExplorePaneContainerUnconnected({ exploreId }: Props) { useStopQueries(exploreId); - const styles = useStyles2(getStyles); const eventBus = useRef(new EventBusSrv()); const ref = useRef(null); @@ -49,7 +44,7 @@ function ExplorePaneContainerUnconnected({ exploreId }: Props) { return ( <CustomScrollbar hideVerticalTrack> - <div className={styles.explore} ref={ref} data-testid={selectors.pages.Explore.General.container}> + <div className={containerStyles} ref={ref} data-testid={selectors.pages.Explore.General.container}> <Explore exploreId={exploreId} eventBus={eventBus.current} /> </div> </CustomScrollbar> diff --git a/public/app/features/explore/ExploreQueryInspector.test.tsx b/public/app/features/explore/ExploreQueryInspector.test.tsx index 23f3f227c0a23..c52d0533a8815 100644 --- a/public/app/features/explore/ExploreQueryInspector.test.tsx +++ b/public/app/features/explore/ExploreQueryInspector.test.tsx @@ -1,8 +1,10 @@ import { render, screen, fireEvent } from '@testing-library/react'; import React, { ComponentProps } from 'react'; +import AutoSizer from 'react-virtualized-auto-sizer'; import { Observable } from 'rxjs'; import { LoadingState, InternalTimeZones, getDefaultTimeRange } from '@grafana/data'; +import { InspectorStream } from 'app/core/services/backend_srv'; import { ExploreQueryInspector } from './ExploreQueryInspector'; @@ -10,6 +12,7 @@ type ExploreQueryInspectorProps = ComponentProps<typeof ExploreQueryInspector>; jest.mock('../inspector/styles', () => ({ getPanelInspectorStyles: () => ({}), + getPanelInspectorStyles2: () => ({}), })); jest.mock('app/core/services/backend_srv', () => ({ @@ -33,12 +36,22 @@ jest.mock('@grafana/runtime', () => ({ reportInteraction: () => null, })); +jest.mock('react-virtualized-auto-sizer', () => { + return { + __esModule: true, + default(props: ComponentProps<typeof AutoSizer>) { + return <div>{props.children({ height: 1000, width: 1000, scaledHeight: 1000, scaledWidth: 1000 })}</div>; + }, + }; +}); + const setup = (propOverrides = {}) => { const props: ExploreQueryInspectorProps = { width: 100, exploreId: 'left', onClose: jest.fn(), timeZone: InternalTimeZones.utc, + isMixed: false, queryResponse: { state: LoadingState.Done, series: [], @@ -82,24 +95,76 @@ describe('ExploreQueryInspector', () => { fireEvent.click(screen.getByText(/expand all/i)); expect(screen.getByText(/very unique test value/i)).toBeInTheDocument(); }); + it('should display formatted data', () => { + setup({ + queryResponse: { + state: LoadingState.Done, + series: [ + { + refId: 'A', + fields: [ + { + name: 'time', + type: 'time', + typeInfo: { + frame: 'time.Time', + nullable: true, + }, + config: { + interval: 30000, + }, + values: [1704285124682, 1704285154682], + entities: {}, + }, + { + name: 'A-series', + type: 'number', + typeInfo: { + frame: 'float64', + nullable: true, + }, + labels: {}, + config: {}, + values: [71.202732378676928, 72.348839082431916], + entities: {}, + }, + ], + length: 2, + }, + ], + }, + }); + + fireEvent.click(screen.getByLabelText(/tab data/i)); + // assert series values are formatted to 3 digits (xx.x or x.xx) + expect(screen.getByText(/71.2/i)).toBeInTheDocument(); + expect(screen.getByText(/72.3/i)).toBeInTheDocument(); + // assert timestamps are formatted + expect(screen.getByText(/2024-01-03 12:32:04.682/i)).toBeInTheDocument(); + expect(screen.getByText(/2024-01-03 12:32:34.682/i)).toBeInTheDocument(); + }); }); -const response = (hideFromInspector = false) => ({ - status: 1, - statusText: '', - ok: true, - headers: {}, - redirected: false, - type: 'basic', - url: '', - request: {}, - data: { - test: { - testKey: 'Very unique test value', +const response = (hideFromInspector = false): InspectorStream => { + return { + response: { + status: 1, + statusText: '', + ok: true, + headers: new Headers(), + redirected: false, + type: 'basic', + url: '', + data: { + test: { + testKey: 'Very unique test value', + }, + }, + config: { + url: '', + hideFromInspector, + }, }, - }, - config: { - url: '', - hideFromInspector, - }, -}); + requestId: 'explore_left', + }; +}; diff --git a/public/app/features/explore/ExploreQueryInspector.tsx b/public/app/features/explore/ExploreQueryInspector.tsx index 1af76715975db..4b611fae705e3 100644 --- a/public/app/features/explore/ExploreQueryInspector.tsx +++ b/public/app/features/explore/ExploreQueryInspector.tsx @@ -1,18 +1,22 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { CoreApp, LoadingState, TimeZone } from '@grafana/data'; +import { CoreApp, LoadingState } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime/src'; -import { defaultTimeZone } from '@grafana/schema'; +import { defaultTimeZone, TimeZone } from '@grafana/schema'; import { TabbedContainer, TabConfig } from '@grafana/ui'; +import { requestIdGenerator } from 'app/core/utils/explore'; import { ExploreDrawer } from 'app/features/explore/ExploreDrawer'; import { InspectDataTab } from 'app/features/inspector/InspectDataTab'; import { InspectErrorTab } from 'app/features/inspector/InspectErrorTab'; import { InspectJSONTab } from 'app/features/inspector/InspectJSONTab'; import { InspectStatsTab } from 'app/features/inspector/InspectStatsTab'; import { QueryInspector } from 'app/features/inspector/QueryInspector'; +import { mixedRequestId } from 'app/plugins/datasource/mixed/MixedDataSource'; import { StoreState, ExploreItemState } from 'app/types'; +import { GetDataOptions } from '../query/state/PanelQueryRunner'; + import { runQueries } from './state/query'; interface DispatchProps { @@ -20,12 +24,17 @@ interface DispatchProps { exploreId: string; timeZone: TimeZone; onClose: () => void; + isMixed: boolean; } type Props = DispatchProps & ConnectedProps<typeof connector>; export function ExploreQueryInspector(props: Props) { - const { width, onClose, queryResponse, timeZone } = props; + const { width, onClose, queryResponse, timeZone, isMixed, exploreId } = props; + const [dataOptions, setDataOptions] = useState<GetDataOptions>({ + withTransforms: false, + withFieldConfig: true, + }); const dataFrames = queryResponse?.series || []; let errors = queryResponse?.errors; if (!errors?.length && queryResponse?.error) { @@ -59,9 +68,11 @@ export function ExploreQueryInspector(props: Props) { data={dataFrames} dataName={'Explore'} isLoading={queryResponse.state === LoadingState.Loading} - options={{ withTransforms: false, withFieldConfig: false }} + options={dataOptions} timeZone={timeZone} app={CoreApp.Explore} + formattedDataDescription="Matches the format in the panel" + onOptionsChange={setDataOptions} /> ), }; @@ -71,7 +82,11 @@ export function ExploreQueryInspector(props: Props) { value: 'query', icon: 'info-circle', content: ( - <QueryInspector data={queryResponse} onRefreshQuery={() => props.runQueries({ exploreId: props.exploreId })} /> + <QueryInspector + instanceId={isMixed ? mixedRequestId(0, requestIdGenerator(exploreId)) : requestIdGenerator(exploreId)} + data={queryResponse} + onRefreshQuery={() => props.runQueries({ exploreId })} + /> ), }; diff --git a/public/app/features/explore/ExploreTimeControls.tsx b/public/app/features/explore/ExploreTimeControls.tsx index e5cfc67fe2cee..5401395b84224 100644 --- a/public/app/features/explore/ExploreTimeControls.tsx +++ b/public/app/features/explore/ExploreTimeControls.tsx @@ -1,7 +1,8 @@ import React, { Component } from 'react'; -import { TimeRange, TimeZone, RawTimeRange, dateTimeForTimeZone, dateMath } from '@grafana/data'; +import { TimeRange, RawTimeRange, dateTimeForTimeZone, dateMath } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; +import { TimeZone } from '@grafana/schema'; import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePickerWithHistory'; import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 0cbb25e78aa95..f88010d9d6ba2 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -100,7 +100,7 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle const onChangeDatasource = async (dsSettings: DataSourceInstanceSettings) => { if (!isCorrelationsEditorMode) { - dispatch(changeDatasource(exploreId, dsSettings.uid, { importQueries: true })); + dispatch(changeDatasource({ exploreId, datasource: dsSettings.uid, options: { importQueries: true } })); } else { if (correlationDetails?.correlationDirty || correlationDetails?.queryEditorDirty) { // prompt will handle datasource change if needed @@ -128,7 +128,7 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle }); } - dispatch(changeDatasource(exploreId, dsSettings.uid, { importQueries: true })); + dispatch(changeDatasource({ exploreId, datasource: dsSettings.uid, options: { importQueries: true } })); } } }; @@ -273,12 +273,7 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle </ToolbarButton> </ButtonGroup> ), - <ToolbarExtensionPoint - splitted={splitted} - key="toolbar-extension-point" - exploreId={exploreId} - timeZone={timeZone} - />, + <ToolbarExtensionPoint key="toolbar-extension-point" exploreId={exploreId} timeZone={timeZone} />, !isLive && ( <ExploreTimeControls key="timeControls" diff --git a/public/app/features/explore/FeatureTogglePage.tsx b/public/app/features/explore/FeatureTogglePage.tsx index 254da695d9397..f7ca69bd6d236 100644 --- a/public/app/features/explore/FeatureTogglePage.tsx +++ b/public/app/features/explore/FeatureTogglePage.tsx @@ -5,12 +5,14 @@ import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; +function getStyles(theme: GrafanaTheme2) { + return css({ + marginTop: theme.spacing(2), + }); +} + export default function FeatureTogglePage() { - const styles = useStyles2( - (theme: GrafanaTheme2) => css` - margin-top: ${theme.spacing(2)}; - ` - ); + const styles = useStyles2(getStyles); return ( <Page className={styles}> diff --git a/public/app/features/explore/FlameGraph/FlameGraphExploreContainer.tsx b/public/app/features/explore/FlameGraph/FlameGraphExploreContainer.tsx index 6458cb0b69d97..43d7ceb7e5470 100644 --- a/public/app/features/explore/FlameGraph/FlameGraphExploreContainer.tsx +++ b/public/app/features/explore/FlameGraph/FlameGraphExploreContainer.tsx @@ -38,11 +38,11 @@ export const FlameGraphExploreContainer = (props: Props) => { }; const getStyles = (theme: GrafanaTheme2) => ({ - container: css` - background: ${theme.colors.background.primary}; - display: flow-root; - padding: 0 ${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(1)}; - border: 1px solid ${theme.components.panel.borderColor}; - border-radius: ${theme.shape.radius.default}; - `, + container: css({ + background: theme.colors.background.primary, + display: 'flow-root', + padding: theme.spacing(0, 1, 1, 1), + border: `1px solid ${theme.components.panel.borderColor}`, + borderRadius: theme.shape.radius.default, + }), }); diff --git a/public/app/features/explore/Graph/ExploreGraph.tsx b/public/app/features/explore/Graph/ExploreGraph.tsx index 8846ba8c85fc6..2d3c8a4748915 100644 --- a/public/app/features/explore/Graph/ExploreGraph.tsx +++ b/public/app/features/explore/Graph/ExploreGraph.tsx @@ -13,7 +13,6 @@ import { getFrameDisplayName, LoadingState, SplitOpen, - TimeZone, ThresholdsConfig, DashboardCursorSync, EventBus, @@ -25,6 +24,8 @@ import { TooltipDisplayMode, SortOrder, GraphThresholdsStyleConfig, + TimeZone, + VizLegendOptions, } from '@grafana/schema'; import { PanelContext, PanelContextProvider, SeriesVisibilityChangeMode, useTheme2 } from '@grafana/ui'; import { GraphFieldConfig } from 'app/plugins/panel/graph/types'; @@ -56,6 +57,7 @@ interface Props { thresholdsConfig?: ThresholdsConfig; thresholdsStyle?: GraphThresholdsStyleConfig; eventBus: EventBus; + vizLegendOverrides?: Partial<VizLegendOptions>; } export function ExploreGraph({ @@ -76,6 +78,7 @@ export function ExploreGraph({ thresholdsConfig, thresholdsStyle, eventBus, + vizLegendOverrides, }: Props) { const theme = useTheme2(); const previousTimeRange = usePrevious(absoluteRange); @@ -165,7 +168,8 @@ export function ExploreGraph({ const panelContext: PanelContext = { eventsScope: 'explore', eventBus, - sync: () => DashboardCursorSync.Crosshair, + // TODO: Re-enable DashboardCursorSync.Crosshair when #81505 is fixed + sync: () => DashboardCursorSync.Off, onToggleSeriesVisibility(label: string, mode: SeriesVisibilityChangeMode) { setFieldConfig(seriesVisibilityConfigFactory(label, mode, fieldConfig, data)); }, @@ -180,9 +184,10 @@ export function ExploreGraph({ showLegend: true, placement: 'bottom', calcs: [], + ...vizLegendOverrides, }, }), - [tooltipDisplayMode] + [tooltipDisplayMode, vizLegendOverrides] ); return ( diff --git a/public/app/features/explore/LiveTailButton.tsx b/public/app/features/explore/LiveTailButton.tsx index e062dc0d6f768..8d6decc3e576e 100644 --- a/public/app/features/explore/LiveTailButton.tsx +++ b/public/app/features/explore/LiveTailButton.tsx @@ -56,26 +56,26 @@ export function LiveTailButton(props: LiveTailButtonProps) { } const styles = { - stopButtonEnter: css` - label: stopButtonEnter; - width: 0; - opacity: 0; - overflow: hidden; - `, - stopButtonEnterActive: css` - label: stopButtonEnterActive; - opacity: 1; - width: 32px; - `, - stopButtonExit: css` - label: stopButtonExit; - width: 32px; - opacity: 1; - overflow: hidden; - `, - stopButtonExitActive: css` - label: stopButtonExitActive; - opacity: 0; - width: 0; - `, + stopButtonEnter: css({ + label: 'stopButtonEnter', + width: 0, + opacity: 0, + overflow: 'hidden', + }), + stopButtonEnterActive: css({ + label: 'stopButtonEnterActive', + opacity: 1, + width: '32px', + }), + stopButtonExit: css({ + label: 'stopButtonExit', + width: '32px', + opacity: 1, + overflow: 'hidden', + }), + stopButtonExitActive: css({ + label: 'stopButtonExitActive', + opacity: 0, + width: 0, + }), }; diff --git a/public/app/features/explore/Logs/LiveLogs.tsx b/public/app/features/explore/Logs/LiveLogs.tsx index 3439db19eae97..0a269840e2ffd 100644 --- a/public/app/features/explore/Logs/LiveLogs.tsx +++ b/public/app/features/explore/Logs/LiveLogs.tsx @@ -2,7 +2,8 @@ import { css, cx } from '@emotion/css'; import React, { PureComponent } from 'react'; import tinycolor from 'tinycolor2'; -import { LogRowModel, TimeZone, dateTimeFormat, GrafanaTheme2, LogsSortOrder } from '@grafana/data'; +import { LogRowModel, dateTimeFormat, GrafanaTheme2, LogsSortOrder } from '@grafana/data'; +import { TimeZone } from '@grafana/schema'; import { Button, Themeable2, withTheme2 } from '@grafana/ui'; import { LogMessageAnsi } from '../../logs/components/LogMessageAnsi'; diff --git a/public/app/features/explore/Logs/Logs.test.tsx b/public/app/features/explore/Logs/Logs.test.tsx index 6f5f4eee7bec2..a8f70e472700f 100644 --- a/public/app/features/explore/Logs/Logs.test.tsx +++ b/public/app/features/explore/Logs/Logs.test.tsx @@ -10,17 +10,28 @@ import { LoadingState, LogLevel, LogRowModel, - MutableDataFrame, standardTransformersRegistry, toUtc, + createDataFrame, } from '@grafana/data'; import { organizeFieldsTransformer } from '@grafana/data/src/transformations/transformers/organize'; import { config } from '@grafana/runtime'; +import store from 'app/core/store'; import { extractFieldsTransformer } from 'app/features/transformers/extractFields/extractFields'; -import { Logs, visualisationTypeKey } from './Logs'; +import { Logs } from './Logs'; +import { visualisationTypeKey } from './utils/logs'; import { getMockElasticFrame, getMockLokiFrame } from './utils/testMocks.test'; +jest.mock('app/core/store', () => { + return { + getBool: jest.fn(), + getObject: jest.fn((_a, b) => b), + get: jest.fn(), + set: jest.fn(), + }; +}); + const reportInteraction = jest.fn(); jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), @@ -162,65 +173,24 @@ describe('Logs', () => { window.innerHeight = originalInnerHeight; }); - describe('when `exploreScrollableLogsContainer` is set', () => { - let featureToggle: boolean | undefined; - beforeEach(() => { - featureToggle = config.featureToggles.exploreScrollableLogsContainer; - config.featureToggles.exploreScrollableLogsContainer = true; - }); - afterEach(() => { - config.featureToggles.exploreScrollableLogsContainer = featureToggle; - jest.clearAllMocks(); - }); - - it('should call `this.state.logsContainer.scroll`', () => { - const scrollIntoViewSpy = jest.spyOn(window.HTMLElement.prototype, 'scrollIntoView'); - jest.spyOn(window.HTMLElement.prototype, 'scrollTop', 'get').mockReturnValue(920); - const scrollSpy = jest.spyOn(window.HTMLElement.prototype, 'scroll'); - - const logs = []; - for (let i = 0; i < 50; i++) { - logs.push(makeLog({ uid: `uid${i}`, rowId: `id${i}`, timeEpochMs: i })); - } - - setup({ panelState: { logs: { id: 'uid47' } } }, undefined, logs); + it('should call `scrollElement.scroll`', () => { + const logs = []; + for (let i = 0; i < 50; i++) { + logs.push(makeLog({ uid: `uid${i}`, rowId: `id${i}`, timeEpochMs: i })); + } + const scrollElementMock = { + scroll: jest.fn(), + scrollTop: 920, + }; + setup( + { scrollElement: scrollElementMock as unknown as HTMLDivElement, panelState: { logs: { id: 'uid47' } } }, + undefined, + logs + ); - expect(scrollIntoViewSpy).toBeCalledTimes(1); - // element.getBoundingClientRect().top will always be 0 for jsdom - // calc will be `this.state.logsContainer.scrollTop - window.innerHeight / 2` -> 920 - 500 = 420 - expect(scrollSpy).toBeCalledWith({ behavior: 'smooth', top: 420 }); - }); - }); - - describe('when `exploreScrollableLogsContainer` is not set', () => { - let featureToggle: boolean | undefined; - beforeEach(() => { - featureToggle = config.featureToggles.exploreScrollableLogsContainer; - config.featureToggles.exploreScrollableLogsContainer = false; - }); - afterEach(() => { - config.featureToggles.exploreScrollableLogsContainer = featureToggle; - }); - - it('should call `scrollElement.scroll`', () => { - const logs = []; - for (let i = 0; i < 50; i++) { - logs.push(makeLog({ uid: `uid${i}`, rowId: `id${i}`, timeEpochMs: i })); - } - const scrollElementMock = { - scroll: jest.fn(), - scrollTop: 920, - }; - setup( - { scrollElement: scrollElementMock as unknown as HTMLDivElement, panelState: { logs: { id: 'uid47' } } }, - undefined, - logs - ); - - // element.getBoundingClientRect().top will always be 0 for jsdom - // calc will be `scrollElement.scrollTop - window.innerHeight / 2` -> 920 - 500 = 420 - expect(scrollElementMock.scroll).toBeCalledWith({ behavior: 'smooth', top: 420 }); - }); + // element.getBoundingClientRect().top will always be 0 for jsdom + // calc will be `scrollElement.scrollTop - window.innerHeight / 2` -> 920 - 500 = 420 + expect(scrollElementMock.scroll).toBeCalledWith({ behavior: 'smooth', top: 420 }); }); }); @@ -419,7 +389,13 @@ describe('Logs', () => { it('should call reportInteraction on permalinkClick', async () => { const panelState = { logs: { id: 'not-included' } }; - setup({ loading: false, panelState }); + const rows = [ + makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 4 }), + makeLog({ uid: '2', rowId: 'id2', timeEpochMs: 3 }), + makeLog({ uid: '3', rowId: 'id3', timeEpochMs: 2 }), + makeLog({ uid: '4', rowId: 'id3', timeEpochMs: 1 }), + ]; + setup({ loading: false, panelState, logRows: rows }); const row = screen.getAllByRole('row'); await userEvent.hover(row[0]); @@ -436,7 +412,13 @@ describe('Logs', () => { it('should call createAndCopyShortLink on permalinkClick - logs', async () => { const panelState: Partial<ExplorePanelsState> = { logs: { id: 'not-included', visualisationType: 'logs' } }; - setup({ loading: false, panelState }); + const rows = [ + makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1 }), + makeLog({ uid: '2', rowId: 'id2', timeEpochMs: 1 }), + makeLog({ uid: '3', rowId: 'id3', timeEpochMs: 2 }), + makeLog({ uid: '4', rowId: 'id3', timeEpochMs: 2 }), + ]; + setup({ loading: false, panelState, logRows: rows }); const row = screen.getAllByRole('row'); await userEvent.hover(row[0]); @@ -451,6 +433,34 @@ describe('Logs', () => { ); expect(createAndCopyShortLink).toHaveBeenCalledWith(expect.stringMatching('visualisationType%22:%22logs')); }); + + it('should call createAndCopyShortLink on permalinkClick - with infinite scrolling', async () => { + const featureToggleValue = config.featureToggles.logsInfiniteScrolling; + config.featureToggles.logsInfiniteScrolling = true; + const rows = [ + makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1 }), + makeLog({ uid: '2', rowId: 'id2', timeEpochMs: 1 }), + makeLog({ uid: '3', rowId: 'id3', timeEpochMs: 2 }), + makeLog({ uid: '4', rowId: 'id3', timeEpochMs: 2 }), + ]; + + const panelState: Partial<ExplorePanelsState> = { logs: { id: 'not-included', visualisationType: 'logs' } }; + setup({ loading: false, panelState, logRows: rows }); + + const row = screen.getAllByRole('row'); + await userEvent.hover(row[3]); + + const linkButton = screen.getByLabelText('Copy shortlink'); + await userEvent.click(linkButton); + + expect(createAndCopyShortLink).toHaveBeenCalledWith( + expect.stringMatching( + 'range%22:%7B%22from%22:%222019-01-01T10:00:00.000Z%22,%22to%22:%221970-01-01T00:00:00.002Z%22%7D' + ) + ); + expect(createAndCopyShortLink).toHaveBeenCalledWith(expect.stringMatching('visualisationType%22:%22logs')); + config.featureToggles.logsInfiniteScrolling = featureToggleValue; + }); }); describe('with table visualisation', () => { @@ -481,17 +491,23 @@ describe('Logs', () => { }); it('should use default state from localstorage - table', async () => { + const oldGet = store.get; + store.get = jest.fn().mockReturnValue('table'); localStorage.setItem(visualisationTypeKey, 'table'); setup({}); const table = await screen.findByTestId('logRowsTable'); expect(table).toBeInTheDocument(); + store.get = oldGet; }); it('should use default state from localstorage - logs', async () => { + const oldGet = store.get; + store.get = jest.fn().mockReturnValue('logs'); localStorage.setItem(visualisationTypeKey, 'logs'); setup({}); const table = await screen.findByTestId('logRows'); expect(table).toBeInTheDocument(); + store.get = oldGet; }); it('should change visualisation to table on toggle (elastic)', async () => { @@ -512,7 +528,7 @@ const makeLog = (overrides: Partial<LogRowModel>): LogRowModel => { uid, entryFieldIndex: 0, rowIndex: 0, - dataFrame: new MutableDataFrame(), + dataFrame: createDataFrame({ fields: [] }), logLevel: LogLevel.debug, entry, hasAnsi: false, diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 122763cc5f9c5..c5faf08dc5a76 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -13,7 +13,6 @@ import { EventBus, ExploreLogsPanelState, ExplorePanelsState, - FeatureState, Field, GrafanaTheme2, LinkModel, @@ -30,14 +29,12 @@ import { serializeStateToUrlParam, SplitOpen, TimeRange, - TimeZone, urlUtil, } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; -import { DataQuery } from '@grafana/schema'; +import { DataQuery, TimeZone } from '@grafana/schema'; import { Button, - FeatureBadge, InlineField, InlineFieldRow, InlineSwitch, @@ -48,6 +45,8 @@ import { } from '@grafana/ui'; import store from 'app/core/store'; import { createAndCopyShortLink } from 'app/core/utils/shortLinks'; +import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll'; +import { getLogLevelFromKey } from 'app/features/logs/utils'; import { dispatch, getState } from 'app/store/store'; import { ExploreItemState } from '../../../types'; @@ -62,7 +61,7 @@ import { LogsMetaRow } from './LogsMetaRow'; import LogsNavigation from './LogsNavigation'; import { getLogsTableHeight, LogsTableWrap } from './LogsTableWrap'; import { LogsVolumePanelList } from './LogsVolumePanelList'; -import { SETTINGS_KEYS } from './utils/logs'; +import { SETTINGS_KEYS, visualisationTypeKey } from './utils/logs'; interface Props extends Themeable2 { width: number; @@ -109,6 +108,7 @@ interface Props extends Themeable2 { range: TimeRange; onClickFilterValue?: (value: string, refId?: string) => void; onClickFilterOutValue?: (value: string, refId?: string) => void; + loadMoreLogs?(range: AbsoluteTimeRange): void; } export type LogsVisualisationType = 'table' | 'logs'; @@ -131,8 +131,6 @@ interface State { logsContainer?: HTMLDivElement; } -const scrollableLogsContainer = config.featureToggles.exploreScrollableLogsContainer; - // we need to define the order of these explicitly const DEDUP_OPTIONS = [ LogsDedupStrategy.none, @@ -141,8 +139,6 @@ const DEDUP_OPTIONS = [ LogsDedupStrategy.signature, ]; -export const visualisationTypeKey = 'grafana.explore.logs.visualisationType'; - const getDefaultVisualisationType = (): LogsVisualisationType => { const visualisationType = store.get(visualisationTypeKey); if (visualisationType === 'table') { @@ -206,6 +202,7 @@ class UnthemedLogs extends PureComponent<Props, State> { ); } } + updatePanelState = (logsPanelState: Partial<ExploreLogsPanelState>) => { const state: ExploreItemState | undefined = getState().explore.panes[this.props.exploreId]; if (state?.panelsState) { @@ -349,7 +346,7 @@ class UnthemedLogs extends PureComponent<Props, State> { }; onToggleLogLevel = (hiddenRawLevels: string[]) => { - const hiddenLogLevels = hiddenRawLevels.map((level) => LogLevel[level as LogLevel]); + const hiddenLogLevels = hiddenRawLevels.map((level) => getLogLevelFromKey(level)); this.setState({ hiddenLogLevels }); }; @@ -440,6 +437,46 @@ class UnthemedLogs extends PureComponent<Props, State> { }; }; + getPreviousLog(row: LogRowModel, allLogs: LogRowModel[]): LogRowModel | null { + for (let i = allLogs.indexOf(row) - 1; i >= 0; i--) { + if (allLogs[i].timeEpochMs > row.timeEpochMs) { + return allLogs[i]; + } + } + + return null; + } + + getPermalinkRange(row: LogRowModel) { + const range = { + from: new Date(this.props.absoluteRange.from).toISOString(), + to: new Date(this.props.absoluteRange.to).toISOString(), + }; + if (!config.featureToggles.logsInfiniteScrolling) { + return range; + } + + // With infinite scrolling, the time range of the log line can be after the absolute range or beyond the request line limit, so we need to adjust + // Look for the previous sibling log, and use its timestamp + const allLogs = this.props.logRows.filter((logRow) => logRow.dataFrame.refId === row.dataFrame.refId); + const prevLog = this.getPreviousLog(row, allLogs); + + if (row.timeEpochMs > this.props.absoluteRange.to && !prevLog) { + // Because there's no sibling and the current `to` is oldest than the log, we have no reference we can use for the interval + // This only happens when you scroll into the future and you want to share the first log of the list + return { + from: new Date(this.props.absoluteRange.from).toISOString(), + // Slide 1ms otherwise it's very likely to be omitted in the results + to: new Date(row.timeEpochMs + 1).toISOString(), + }; + } + + return { + from: new Date(this.props.absoluteRange.from).toISOString(), + to: new Date(prevLog ? prevLog.timeEpochMs : this.props.absoluteRange.to).toISOString(), + }; + } + onPermalinkClick = async (row: LogRowModel) => { // this is an extra check, to be sure that we are not // creating permalinks for logs without an id-field. @@ -455,10 +492,7 @@ class UnthemedLogs extends PureComponent<Props, State> { ...this.props.panelState, logs: { id: row.uid, visualisationType: this.state.visualisationType ?? getDefaultVisualisationType() }, }; - urlState.range = { - from: new Date(this.props.absoluteRange.from).toISOString(), - to: new Date(this.props.absoluteRange.to).toISOString(), - }; + urlState.range = this.getPermalinkRange(row); // append changed urlState to baseUrl const serializedState = serializeStateToUrlParam(urlState); @@ -474,7 +508,7 @@ class UnthemedLogs extends PureComponent<Props, State> { }; scrollIntoView = (element: HTMLElement) => { - if (config.featureToggles.exploreScrollableLogsContainer) { + if (config.featureToggles.logsInfiniteScrolling) { if (this.state.logsContainer) { this.topLogsRef.current?.scrollIntoView(); this.state.logsContainer.scroll({ @@ -524,16 +558,15 @@ class UnthemedLogs extends PureComponent<Props, State> { }); scrollToTopLogs = () => { - if (config.featureToggles.exploreScrollableLogsContainer) { + if (config.featureToggles.logsInfiniteScrolling) { if (this.state.logsContainer) { this.state.logsContainer.scroll({ behavior: 'auto', top: 0, }); } - } else { - this.topLogsRef.current?.scrollIntoView(); } + this.topLogsRef.current?.scrollIntoView(); }; render() { @@ -563,6 +596,7 @@ class UnthemedLogs extends PureComponent<Props, State> { getRowContext, getLogRowContextUi, getRowContextQuery, + loadMoreLogs, } = this.props; const { @@ -628,21 +662,13 @@ class UnthemedLogs extends PureComponent<Props, State> { </PanelChrome> <PanelChrome titleItems={[ - config.featureToggles.logsExploreTableVisualisation - ? this.state.visualisationType === 'logs' - ? null - : [ - <PanelChrome.TitleItem title="Experimental" key="A"> - <FeatureBadge - featureState={FeatureState.beta} - tooltip="Table view is experimental and may change in future versions" - /> - </PanelChrome.TitleItem>, - <PanelChrome.TitleItem title="Feedback" key="B"> - <LogsFeedback feedbackUrl="https://forms.gle/5YyKdRQJ5hzq4c289" /> - </PanelChrome.TitleItem>, - ] - : null, + config.featureToggles.logsExploreTableVisualisation ? ( + this.state.visualisationType === 'logs' ? null : ( + <PanelChrome.TitleItem title="Feedback" key="A"> + <LogsFeedback feedbackUrl="https://forms.gle/5YyKdRQJ5hzq4c289" /> + </PanelChrome.TitleItem> + ) + ) : null, ]} title={'Logs'} actions={ @@ -728,9 +754,13 @@ class UnthemedLogs extends PureComponent<Props, State> { </InlineFieldRow> <div> - <InlineField label="Display results" className={styles.horizontalInlineLabel} transparent> + <InlineField + label="Display results" + className={styles.horizontalInlineLabel} + transparent + disabled={isFlipping || loading} + > <RadioButtonGroup - disabled={isFlipping} options={[ { label: 'Newest first', @@ -787,38 +817,52 @@ class UnthemedLogs extends PureComponent<Props, State> { </div> )} {this.state.visualisationType === 'logs' && hasData && ( - <div className={styles.logRows} data-testid="logRows" ref={this.onLogsContainerRef}> - <LogRows - logRows={logRows} - deduplicatedRows={dedupedRows} - dedupStrategy={dedupStrategy} - onClickFilterLabel={onClickFilterLabel} - onClickFilterOutLabel={onClickFilterOutLabel} - showContextToggle={showContextToggle} - getRowContextQuery={getRowContextQuery} - showLabels={showLabels} - showTime={showTime} - enableLogDetails={true} - forceEscape={forceEscape} - wrapLogMessage={wrapLogMessage} - prettifyLogMessage={prettifyLogMessage} + <div + className={config.featureToggles.logsInfiniteScrolling ? styles.scrollableLogRows : styles.logRows} + data-testid="logRows" + ref={this.onLogsContainerRef} + > + <InfiniteScroll + loading={loading} + loadMoreLogs={loadMoreLogs} + range={this.props.range} timeZone={timeZone} - getFieldLinks={getFieldLinks} - logsSortOrder={logsSortOrder} - displayedFields={displayedFields} - onClickShowField={this.showField} - onClickHideField={this.hideField} - app={CoreApp.Explore} - onLogRowHover={this.onLogRowHover} - onOpenContext={this.onOpenContext} - onPermalinkClick={this.onPermalinkClick} - permalinkedRowId={this.props.panelState?.logs?.id} - scrollIntoView={this.scrollIntoView} - isFilterLabelActive={this.props.isFilterLabelActive} - containerRendered={!!this.state.logsContainer} - onClickFilterValue={this.props.onClickFilterValue} - onClickFilterOutValue={this.props.onClickFilterOutValue} - /> + rows={logRows} + scrollElement={this.state.logsContainer} + sortOrder={logsSortOrder} + > + <LogRows + logRows={logRows} + deduplicatedRows={dedupedRows} + dedupStrategy={dedupStrategy} + onClickFilterLabel={onClickFilterLabel} + onClickFilterOutLabel={onClickFilterOutLabel} + showContextToggle={showContextToggle} + getRowContextQuery={getRowContextQuery} + showLabels={showLabels} + showTime={showTime} + enableLogDetails={true} + forceEscape={forceEscape} + wrapLogMessage={wrapLogMessage} + prettifyLogMessage={prettifyLogMessage} + timeZone={timeZone} + getFieldLinks={getFieldLinks} + logsSortOrder={logsSortOrder} + displayedFields={displayedFields} + onClickShowField={this.showField} + onClickHideField={this.hideField} + app={CoreApp.Explore} + onLogRowHover={this.onLogRowHover} + onOpenContext={this.onOpenContext} + onPermalinkClick={this.onPermalinkClick} + permalinkedRowId={this.props.panelState?.logs?.id} + scrollIntoView={this.scrollIntoView} + isFilterLabelActive={this.props.isFilterLabelActive} + containerRendered={!!this.state.logsContainer} + onClickFilterValue={this.props.onClickFilterValue} + onClickFilterOutValue={this.props.onClickFilterOutValue} + /> + </InfiniteScroll> </div> )} {!loading && !hasData && !scanning && ( @@ -864,61 +908,65 @@ export const Logs = withTheme2(UnthemedLogs); const getStyles = (theme: GrafanaTheme2, wrapLogMessage: boolean, tableHeight: number) => { return { - noData: css` - > * { - margin-left: 0.5em; - } - `, - logOptions: css` - display: flex; - justify-content: space-between; - align-items: baseline; - flex-wrap: wrap; - background-color: ${theme.colors.background.primary}; - padding: ${theme.spacing(1, 2)}; - border-radius: ${theme.shape.radius.default}; - margin: ${theme.spacing(0, 0, 1)}; - border: 1px solid ${theme.colors.border.medium}; - `, - headerButton: css` - margin: ${theme.spacing(0.5, 0, 0, 1)}; - `, - horizontalInlineLabel: css` - > label { - margin-right: 0; - } - `, - horizontalInlineSwitch: css` - padding: 0 ${theme.spacing(1)} 0 0; - `, - radioButtons: css` - margin: 0; - `, - logsSection: css` - display: flex; - flex-direction: row; - justify-content: space-between; - `, + noData: css({ + '& > *': { + marginLeft: '0.5em', + }, + }), + logOptions: css({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'baseline', + flexWrap: 'wrap', + backgroundColor: theme.colors.background.primary, + padding: `${theme.spacing(1)} ${theme.spacing(2)}`, + borderRadius: theme.shape.radius.default, + margin: `${theme.spacing(0, 0, 1)}`, + border: `1px solid ${theme.colors.border.medium}`, + }), + headerButton: css({ + margin: `${theme.spacing(0.5, 0, 0, 1)}`, + }), + horizontalInlineLabel: css({ + '& > label': { + marginRight: '0', + }, + }), + horizontalInlineSwitch: css({ + padding: `0 ${theme.spacing(1)} 0 0`, + }), + radioButtons: css({ + margin: '0', + }), + logsSection: css({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + }), logsTable: css({ maxHeight: `${tableHeight}px`, }), - logRows: css` - overflow-x: ${scrollableLogsContainer ? 'scroll;' : `${wrapLogMessage ? 'unset' : 'scroll'};`} - overflow-y: visible; - width: 100%; - ${scrollableLogsContainer && 'max-height: calc(100vh - 170px);'} - `, - visualisationType: css` - display: flex; - flex: 1; - justify-content: space-between; - `, - visualisationTypeRadio: css` - margin: 0 0 0 ${theme.spacing(1)}; - `, - stickyNavigation: css` - ${scrollableLogsContainer && 'margin-bottom: 0px'} - overflow: visible; - `, + scrollableLogRows: css({ + overflowY: 'scroll', + width: '100%', + maxHeight: '75vh', + }), + logRows: css({ + overflowX: `${wrapLogMessage ? 'unset' : 'scroll'}`, + overflowY: 'visible', + width: '100%', + }), + visualisationType: css({ + display: 'flex', + flex: '1', + justifyContent: 'space-between', + }), + visualisationTypeRadio: css({ + margin: `0 0 0 ${theme.spacing(1)}`, + }), + stickyNavigation: css({ + overflow: 'visible', + ...(config.featureToggles.logsInfiniteScrolling && { marginBottom: '0px' }), + }), }; }; diff --git a/public/app/features/explore/Logs/LogsColumnSearch.tsx b/public/app/features/explore/Logs/LogsColumnSearch.tsx index 1d82f11d0399f..33faae16435d9 100644 --- a/public/app/features/explore/Logs/LogsColumnSearch.tsx +++ b/public/app/features/explore/Logs/LogsColumnSearch.tsx @@ -7,7 +7,7 @@ import { Field, Input, useTheme2 } from '@grafana/ui/src'; function getStyles(theme: GrafanaTheme2) { return { searchWrap: css({ - padding: theme.spacing(0.4), + padding: `${theme.spacing(0.4)} 0 ${theme.spacing(0.4)} ${theme.spacing(0.4)}`, }), }; } diff --git a/public/app/features/explore/Logs/LogsContainer.tsx b/public/app/features/explore/Logs/LogsContainer.tsx index 732ec2fe76611..48a173cd064e2 100644 --- a/public/app/features/explore/Logs/LogsContainer.tsx +++ b/public/app/features/explore/Logs/LogsContainer.tsx @@ -36,7 +36,7 @@ import { selectIsWaitingForData, setSupplementaryQueryEnabled, } from '../state/query'; -import { updateTimeRange } from '../state/time'; +import { updateTimeRange, loadMoreLogs } from '../state/time'; import { LiveTailControls } from '../useLiveTailControls'; import { getFieldLinksForExplore } from '../utils/links'; @@ -140,6 +140,11 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState updateTimeRange({ exploreId, absoluteRange }); }; + loadMoreLogs = (absoluteRange: AbsoluteTimeRange) => { + const { exploreId, loadMoreLogs } = this.props; + loadMoreLogs({ exploreId, absoluteRange }); + }; + private getQuery( logsQueries: DataQuery[] | undefined, row: LogRowModel, @@ -244,6 +249,14 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState ); }; + addResultsToCache = () => { + this.props.addResultsToCache(this.props.exploreId); + }; + + clearCache = () => { + this.props.clearCache(this.props.exploreId); + }; + render() { const { loading, @@ -267,8 +280,6 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState splitOpenFn, isLive, exploreId, - addResultsToCache, - clearCache, logsVolume, scrollElement, } = this.props; @@ -316,6 +327,7 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState loadingState={loadingState} loadLogsVolumeData={() => loadSupplementaryQueryData(exploreId, SupplementaryQueryType.LogsVolume)} onChangeTime={this.onChangeTime} + loadMoreLogs={this.loadMoreLogs} onClickFilterLabel={this.logDetailsFilterAvailable() ? onClickFilterLabel : undefined} onClickFilterOutLabel={this.logDetailsFilterAvailable() ? onClickFilterOutLabel : undefined} onStartScanning={onStartScanning} @@ -330,8 +342,8 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState getRowContextQuery={this.getLogRowContextQuery} getLogRowContextUi={this.getLogRowContextUi} getFieldLinks={this.getFieldLinks} - addResultsToCache={() => addResultsToCache(exploreId)} - clearCache={() => clearCache(exploreId)} + addResultsToCache={this.addResultsToCache} + clearCache={this.clearCache} eventBus={this.props.eventBus} panelState={this.props.panelState} logsFrames={this.props.logsFrames} @@ -389,6 +401,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string } const mapDispatchToProps = { updateTimeRange, + loadMoreLogs, addResultsToCache, clearCache, loadSupplementaryQueryData, diff --git a/public/app/features/explore/Logs/LogsMetaRow.test.tsx b/public/app/features/explore/Logs/LogsMetaRow.test.tsx index 9e506d6b61f7c..83755343cdbba 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.test.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.test.tsx @@ -3,10 +3,12 @@ import userEvent from '@testing-library/user-event'; import saveAs from 'file-saver'; import React, { ComponentProps } from 'react'; -import { FieldType, LogLevel, LogsDedupStrategy, toDataFrame } from '@grafana/data'; +import { FieldType, LogLevel, LogsDedupStrategy, standardTransformersRegistry, toDataFrame } from '@grafana/data'; +import { organizeFieldsTransformer } from '@grafana/data/src/transformations/transformers/organize'; import { MAX_CHARACTERS } from '../../logs/components/LogRowMessage'; import { logRowsToReadableJson } from '../../logs/utils'; +import { extractFieldsTransformer } from '../../transformers/extractFields/extractFields'; import { LogsMetaRow } from './LogsMetaRow'; @@ -200,4 +202,112 @@ describe('LogsMetaRow', () => { const text = await blob.text(); expect(text).toBe(JSON.stringify(logRowsToReadableJson(rows))); }); + + it('renders a button to download CSV', async () => { + const transformers = [extractFieldsTransformer, organizeFieldsTransformer]; + standardTransformersRegistry.setInit(() => { + return transformers.map((t) => { + return { + id: t.id, + aliasIds: t.aliasIds, + name: t.name, + transformation: t, + description: t.description, + editor: () => null, + }; + }); + }); + + const rows = [ + { + rowIndex: 1, + entryFieldIndex: 0, + dataFrame: toDataFrame({ + name: 'logs', + refId: 'A', + fields: [ + { + name: 'time', + type: FieldType.time, + values: ['1970-01-01T00:00:00Z'], + }, + { + name: 'message', + type: FieldType.string, + values: ['INFO 1'], + labels: { + foo: 'bar', + }, + }, + ], + }), + entry: 'test entry', + hasAnsi: false, + hasUnescapedContent: false, + labels: { + foo: 'bar', + }, + logLevel: LogLevel.info, + raw: '', + timeEpochMs: 10, + timeEpochNs: '123456789', + timeFromNow: '', + timeLocal: '', + timeUtc: '', + uid: '2', + }, + { + rowIndex: 2, + entryFieldIndex: 1, + dataFrame: toDataFrame({ + name: 'logs', + refId: 'B', + fields: [ + { + name: 'time', + type: FieldType.time, + values: ['1970-01-02T00:00:00Z'], + }, + { + name: 'message', + type: FieldType.string, + values: ['INFO 1'], + labels: { + foo: 'bar', + }, + }, + ], + }), + entry: 'test entry', + hasAnsi: false, + hasUnescapedContent: false, + labels: { + foo: 'bar', + }, + logLevel: LogLevel.info, + raw: '', + timeEpochMs: 10, + timeEpochNs: '123456789', + timeFromNow: '', + timeLocal: '', + timeUtc: '', + uid: '2', + }, + ]; + setup({ logRows: rows }); + + await userEvent.click(screen.getByText('Download').closest('button')!); + + await userEvent.click( + screen.getByRole('menuitem', { + name: 'csv', + }) + ); + expect(saveAs).toBeCalled(); + + const blob = (saveAs as unknown as jest.Mock).mock.lastCall[0]; + expect(blob.type).toBe('text/csv;charset=utf-8'); + const text = await blob.text(); + expect(text).toBe(`"time","message bar"\r\n1970-01-02T00:00:00Z,INFO 1`); + }); }); diff --git a/public/app/features/explore/Logs/LogsMetaRow.tsx b/public/app/features/explore/Logs/LogsMetaRow.tsx index 5bf8cf7a0341d..55f5bdacc028f 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.tsx @@ -1,17 +1,31 @@ import { css } from '@emotion/css'; import saveAs from 'file-saver'; import React from 'react'; +import { lastValueFrom } from 'rxjs'; -import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, dateTimeFormat } from '@grafana/data'; +import { + LogsDedupStrategy, + LogsMetaItem, + LogsMetaKind, + LogRowModel, + CoreApp, + dateTimeFormat, + transformDataFrame, + DataTransformerConfig, + CustomTransformOperator, +} from '@grafana/data'; +import { DataFrame } from '@grafana/data/'; import { reportInteraction } from '@grafana/runtime'; import { Button, Dropdown, Menu, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui'; -import { downloadLogsModelAsTxt } from '../../inspector/utils/download'; +import { downloadDataFrameAsCsv, downloadLogsModelAsTxt } from '../../inspector/utils/download'; import { LogLabels } from '../../logs/components/LogLabels'; import { MAX_CHARACTERS } from '../../logs/components/LogRowMessage'; import { logRowsToReadableJson } from '../../logs/utils'; import { MetaInfoText, MetaItemProps } from '../MetaInfoText'; +import { getLogsExtractFields } from './LogsTable'; + const getStyles = () => ({ metaContainer: css` flex: 1; @@ -35,6 +49,7 @@ export type Props = { enum DownloadFormat { Text = 'text', Json = 'json', + CSV = 'csv', } export const LogsMetaRow = React.memo( @@ -51,7 +66,7 @@ export const LogsMetaRow = React.memo( }: Props) => { const style = useStyles2(getStyles); - const downloadLogs = (format: DownloadFormat) => { + const downloadLogs = async (format: DownloadFormat) => { reportInteraction('grafana_logs_download_logs_clicked', { app: CoreApp.Explore, format, @@ -67,10 +82,30 @@ export const LogsMetaRow = React.memo( const blob = new Blob([JSON.stringify(jsonLogs)], { type: 'application/json;charset=utf-8', }); - const fileName = `Explore-logs-${dateTimeFormat(new Date())}.json`; saveAs(blob, fileName); break; + case DownloadFormat.CSV: + const dataFrameMap = new Map<string, DataFrame>(); + logRows.forEach((row) => { + if (row.dataFrame?.refId && !dataFrameMap.has(row.dataFrame?.refId)) { + dataFrameMap.set(row.dataFrame?.refId, row.dataFrame); + } + }); + dataFrameMap.forEach(async (dataFrame) => { + const transforms: Array<DataTransformerConfig | CustomTransformOperator> = getLogsExtractFields(dataFrame); + transforms.push({ + id: 'organize', + options: { + excludeByName: { + ['labels']: true, + ['labelTypes']: true, + }, + }, + }); + const transformedDataFrame = await lastValueFrom(transformDataFrame(transforms, [dataFrame])); + downloadDataFrameAsCsv(transformedDataFrame[0], `Explore-logs-${dataFrame.refId}`); + }); } }; @@ -131,6 +166,7 @@ export const LogsMetaRow = React.memo( <Menu> <Menu.Item label="txt" onClick={() => downloadLogs(DownloadFormat.Text)} /> <Menu.Item label="json" onClick={() => downloadLogs(DownloadFormat.Json)} /> + <Menu.Item label="csv" onClick={() => downloadLogs(DownloadFormat.CSV)} /> </Menu> ); return ( diff --git a/public/app/features/explore/Logs/LogsNavigation.test.tsx b/public/app/features/explore/Logs/LogsNavigation.test.tsx index d045191b0603f..6c2a5153bd465 100644 --- a/public/app/features/explore/Logs/LogsNavigation.test.tsx +++ b/public/app/features/explore/Logs/LogsNavigation.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import React, { ComponentProps } from 'react'; import { LogsSortOrder } from '@grafana/data'; +import { DataQuery } from '@grafana/schema'; import LogsNavigation from './LogsNavigation'; @@ -93,6 +94,42 @@ describe('LogsNavigation', () => { expect(onChangeTimeMock).toHaveBeenCalledWith({ from: 1637319338000, to: 1637322938000 }); }); + it('should correctly display the active page', async () => { + const queries: DataQuery[] = []; + const { rerender } = setup({ + absoluteRange: { from: 1704737384139, to: 1704737684139 }, + visibleRange: { from: 1704737384207, to: 1704737683316 }, + queries, + logsSortOrder: LogsSortOrder.Descending, + }); + + expect(await screen.findByTestId('page1')).toBeInTheDocument(); + expect(screen.getByTestId('page1').firstChild).toHaveClass('selectedBg'); + + expect(screen.queryByTestId('page2')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('olderLogsButton')); + + rerender( + <LogsNavigation + {...defaultProps} + absoluteRange={{ from: 1704737084207, to: 1704737384207 }} + visibleRange={{ from: 1704737084627, to: 1704737383765 }} + onChangeTime={jest.fn()} + logsSortOrder={LogsSortOrder.Descending} + queries={queries} + /> + ); + + expect(await screen.findByTestId('page1')).toBeInTheDocument(); + expect(screen.getByTestId('page1').firstChild).not.toHaveClass('selectedBg'); + + expect(await screen.findByTestId('page2')).toBeInTheDocument(); + expect(screen.getByTestId('page2').firstChild).toHaveClass('selectedBg'); + + expect(screen.queryByTestId('page3')).not.toBeInTheDocument(); + }); + it('should reset the scroll when pagination is clicked', async () => { const scrollToTopLogsMock = jest.fn(); setup({ scrollToTopLogs: scrollToTopLogsMock }); @@ -101,4 +138,31 @@ describe('LogsNavigation', () => { await userEvent.click(screen.getByTestId('olderLogsButton')); expect(scrollToTopLogsMock).toHaveBeenCalled(); }); + + it('should not trigger actions while loading', async () => { + const scrollToTopLogs = jest.fn(); + const changeTimeMock = jest.fn(); + setup({ scrollToTopLogs, onChangeTime: changeTimeMock, loading: true }); + + expect(scrollToTopLogs).not.toHaveBeenCalled(); + expect(changeTimeMock).not.toHaveBeenCalled(); + await userEvent.click(screen.getByTestId('olderLogsButton')); + await userEvent.click(screen.getByTestId('newerLogsButton')); + expect(scrollToTopLogs).not.toHaveBeenCalled(); + expect(changeTimeMock).not.toHaveBeenCalled(); + }); + + it('should not add results to cache unless pagination is used', async () => { + const addResultsToCache = jest.fn(); + setup({ addResultsToCache }); + + expect(addResultsToCache).not.toHaveBeenCalled(); + expect(screen.getByTestId('olderLogsButton')).not.toBeDisabled(); + expect(screen.getByTestId('newerLogsButton')).toBeDisabled(); + + await userEvent.click(screen.getByTestId('olderLogsButton')); + await userEvent.click(screen.getByTestId('newerLogsButton')); + + expect(addResultsToCache).toHaveBeenCalledTimes(1); + }); }); diff --git a/public/app/features/explore/Logs/LogsNavigation.tsx b/public/app/features/explore/Logs/LogsNavigation.tsx index ffd62a5c407dd..b605f284a990c 100644 --- a/public/app/features/explore/Logs/LogsNavigation.tsx +++ b/public/app/features/explore/Logs/LogsNavigation.tsx @@ -1,10 +1,10 @@ import { css } from '@emotion/css'; import { isEqual } from 'lodash'; -import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { AbsoluteTimeRange, GrafanaTheme2, LogsSortOrder, TimeZone } from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; -import { DataQuery } from '@grafana/schema'; +import { AbsoluteTimeRange, GrafanaTheme2, LogsSortOrder } from '@grafana/data'; +import { config, reportInteraction } from '@grafana/runtime'; +import { DataQuery, TimeZone } from '@grafana/schema'; import { Button, Icon, Spinner, useTheme2 } from '@grafana/ui'; import { TOP_BAR_LEVEL_HEIGHT } from 'app/core/components/AppChrome/types'; @@ -41,7 +41,6 @@ function LogsNavigation({ addResultsToCache, }: Props) { const [pages, setPages] = useState<LogsPage[]>([]); - const [currentPageIndex, setCurrentPageIndex] = useState(0); // These refs are to determine, if we want to clear up logs navigation when totally new query is run const expectedQueriesRef = useRef<DataQuery[]>(); @@ -50,6 +49,14 @@ function LogsNavigation({ // e.g. if last 5 min selected, always run 5 min range const rangeSpanRef = useRef(0); + const currentPageIndex = useMemo( + () => + pages.findIndex((page) => { + return page.queryRange.to === absoluteRange.to; + }), + [absoluteRange.to, pages] + ); + const oldestLogsFirst = logsSortOrder === LogsSortOrder.Ascending; const onFirstPage = oldestLogsFirst ? currentPageIndex === pages.length - 1 : currentPageIndex === 0; const onLastPage = oldestLogsFirst ? currentPageIndex === 0 : currentPageIndex === pages.length - 1; @@ -64,7 +71,6 @@ function LogsNavigation({ if (!isEqual(expectedRangeRef.current, absoluteRange) || !isEqual(expectedQueriesRef.current, queries)) { clearCache(); setPages([newPage]); - setCurrentPageIndex(0); expectedQueriesRef.current = queries; rangeSpanRef.current = absoluteRange.to - absoluteRange.from; } else { @@ -73,30 +79,18 @@ function LogsNavigation({ newPages = pages.filter((page) => !isEqual(newPage.queryRange, page.queryRange)); // Sort pages based on logsOrder so they visually align with displayed logs newPages = [...newPages, newPage].sort((a, b) => sortPages(a, b, logsSortOrder)); - // Set new pages - return newPages; }); - - // Set current page index - const index = newPages.findIndex((page) => page.queryRange.to === absoluteRange.to); - setCurrentPageIndex(index); } - addResultsToCache(); }, [visibleRange, absoluteRange, logsSortOrder, queries, clearCache, addResultsToCache]); - useEffect(() => { - clearCache(); - // We can't enforce the eslint rule here because we only want to run when component is mounted. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const changeTime = useCallback( ({ from, to }: AbsoluteTimeRange) => { + addResultsToCache(); expectedRangeRef.current = { from, to }; onChangeTime({ from, to }); }, - [onChangeTime] + [onChangeTime, addResultsToCache] ); const sortPages = (a: LogsPage, b: LogsPage, logsSortOrder?: LogsSortOrder | null) => { @@ -173,29 +167,38 @@ function LogsNavigation({ pageType: 'page', pageNumber, }); - !loading && changeTime({ from: page.queryRange.from, to: page.queryRange.to }); + changeTime({ from: page.queryRange.from, to: page.queryRange.to }); scrollToTopLogs(); }, - [changeTime, loading, scrollToTopLogs] + [changeTime, scrollToTopLogs] ); + const onScrollToTopClick = useCallback(() => { + reportInteraction('grafana_explore_logs_scroll_top_clicked'); + scrollToTopLogs(); + }, [scrollToTopLogs]); + return ( <div className={styles.navContainer}> - {oldestLogsFirst ? olderLogsButton : newerLogsButton} - <LogsNavigationPages - pages={pages} - currentPageIndex={currentPageIndex} - oldestLogsFirst={oldestLogsFirst} - timeZone={timeZone} - loading={loading} - onClick={onPageClick} - /> - {oldestLogsFirst ? newerLogsButton : olderLogsButton} + {!config.featureToggles.logsInfiniteScrolling && ( + <> + {oldestLogsFirst ? olderLogsButton : newerLogsButton} + <LogsNavigationPages + pages={pages} + currentPageIndex={currentPageIndex} + oldestLogsFirst={oldestLogsFirst} + timeZone={timeZone} + loading={loading} + onClick={onPageClick} + /> + {oldestLogsFirst ? newerLogsButton : olderLogsButton} + </> + )} <Button data-testid="scrollToTop" className={styles.scrollToTopButton} variant="secondary" - onClick={scrollToTopLogs} + onClick={onScrollToTopClick} title="Scroll to top" > <Icon name="arrow-up" size="lg" /> @@ -213,7 +216,9 @@ const getStyles = (theme: GrafanaTheme2, oldestLogsFirst: boolean) => { max-height: ${navContainerHeight}; display: flex; flex-direction: column; - justify-content: ${oldestLogsFirst ? 'flex-start' : 'space-between'}; + ${config.featureToggles.logsInfiniteScrolling + ? `justify-content: flex-end;` + : `justify-content: ${oldestLogsFirst ? 'flex-start' : 'space-between'};`} position: sticky; top: ${theme.spacing(2)}; right: 0; diff --git a/public/app/features/explore/Logs/LogsNavigationPages.tsx b/public/app/features/explore/Logs/LogsNavigationPages.tsx index 2f8de5c7d58f6..a6b1fd20108f9 100644 --- a/public/app/features/explore/Logs/LogsNavigationPages.tsx +++ b/public/app/features/explore/Logs/LogsNavigationPages.tsx @@ -1,7 +1,8 @@ import { css, cx } from '@emotion/css'; import React from 'react'; -import { dateTimeFormat, systemDateFormats, TimeZone, GrafanaTheme2 } from '@grafana/data'; +import { dateTimeFormat, systemDateFormats, GrafanaTheme2 } from '@grafana/data'; +import { TimeZone } from '@grafana/schema'; import { CustomScrollbar, Spinner, useTheme2, clearButtonStyles } from '@grafana/ui'; import { LogsPage } from './LogsNavigation'; @@ -48,6 +49,7 @@ export function LogsNavigationPages({ pages, currentPageIndex, oldestLogsFirst, onClick={() => { onClick(page, index + 1); }} + disabled={loading} > <div className={cx(styles.line, { selectedBg: currentPageIndex === index })} /> <div className={cx(styles.time, { selectedText: currentPageIndex === index })}> diff --git a/public/app/features/explore/Logs/LogsSamplePanel.tsx b/public/app/features/explore/Logs/LogsSamplePanel.tsx index c3afeb696e9a3..5e628b38c4b50 100644 --- a/public/app/features/explore/Logs/LogsSamplePanel.tsx +++ b/public/app/features/explore/Logs/LogsSamplePanel.tsx @@ -11,7 +11,7 @@ import { SplitOpen, SupplementaryQueryType, } from '@grafana/data'; -import { config, reportInteraction } from '@grafana/runtime'; +import { reportInteraction } from '@grafana/runtime'; import { DataQuery, TimeZone } from '@grafana/schema'; import { Button, Collapse, Icon, Tooltip, useStyles2 } from '@grafana/ui'; import store from 'app/core/store'; @@ -45,6 +45,9 @@ export function LogsSamplePanel(props: Props) { }; const OpenInSplitViewButton = () => { + if (!datasourceInstance) { + return null; + } if (!hasSupplementaryQuerySupport(datasourceInstance, SupplementaryQueryType.LogsSample)) { return null; } @@ -107,7 +110,6 @@ export function LogsSamplePanel(props: Props) { return queryResponse?.state !== LoadingState.NotStarted ? ( <Collapse - className={styles.logsSamplePanel} label={ <div> Logs sample @@ -126,20 +128,13 @@ export function LogsSamplePanel(props: Props) { } const getStyles = (theme: GrafanaTheme2) => { - const scrollableLogsContainer = config.featureToggles.exploreScrollableLogsContainer; - return { - logsSamplePanel: css` - ${scrollableLogsContainer && 'max-height: calc(100vh - 115px);'} - `, logSamplesButton: css` position: absolute; top: ${theme.spacing(1)}; right: ${theme.spacing(1)}; `, logContainer: css` - ${scrollableLogsContainer && 'position: relative;'} - ${scrollableLogsContainer && 'height: 100%;'} overflow: scroll; `, infoTooltip: css` diff --git a/public/app/features/explore/Logs/LogsTable.test.tsx b/public/app/features/explore/Logs/LogsTable.test.tsx index 06ba89a83eac4..b22a227739555 100644 --- a/public/app/features/explore/Logs/LogsTable.test.tsx +++ b/public/app/features/explore/Logs/LogsTable.test.tsx @@ -6,6 +6,8 @@ import { organizeFieldsTransformer } from '@grafana/data/src/transformations/tra import { config } from '@grafana/runtime'; import { extractFieldsTransformer } from 'app/features/transformers/extractFields/extractFields'; +import { parseLogsFrame } from '../../logs/logsFrame'; + import { LogsTable } from './LogsTable'; import { getMockElasticFrame, getMockLokiFrame, getMockLokiFrameDataPlane } from './utils/testMocks.test'; @@ -52,10 +54,15 @@ const getComponent = (partialProps?: Partial<ComponentProps<typeof LogsTable>>, ], length: 3, }; + const logsFrame = parseLogsFrame(testDataFrame); return ( <LogsTable + logsFrame={logsFrame} height={400} - columnsWithMeta={{}} + columnsWithMeta={{ + Time: { active: true, percentOfLinesWithLabel: 3, index: 0 }, + line: { active: true, percentOfLinesWithLabel: 3, index: 1 }, + }} logsSortOrder={LogsSortOrder.Descending} splitOpen={() => undefined} timeZone={'utc'} @@ -123,10 +130,10 @@ describe('LogsTable', () => { setup({ dataFrame: getMockElasticFrame(), columnsWithMeta: { - counter: { active: true, percentOfLinesWithLabel: 3 }, - level: { active: true, percentOfLinesWithLabel: 3 }, - line: { active: true, percentOfLinesWithLabel: 3 }, - '@timestamp': { active: true, percentOfLinesWithLabel: 3 }, + level: { active: true, percentOfLinesWithLabel: 3, index: 3 }, + counter: { active: true, percentOfLinesWithLabel: 3, index: 2 }, + line: { active: true, percentOfLinesWithLabel: 3, index: 1 }, + '@timestamp': { active: true, percentOfLinesWithLabel: 3, index: 0 }, }, }); @@ -142,9 +149,9 @@ describe('LogsTable', () => { it('should render extracted labels as columns (loki)', async () => { setup({ columnsWithMeta: { - foo: { active: true, percentOfLinesWithLabel: 3 }, - Time: { active: true, percentOfLinesWithLabel: 3 }, - line: { active: true, percentOfLinesWithLabel: 3 }, + Time: { active: true, percentOfLinesWithLabel: 3, index: 0 }, + line: { active: true, percentOfLinesWithLabel: 3, index: 1 }, + foo: { active: true, percentOfLinesWithLabel: 3, index: 2 }, }, }); @@ -157,7 +164,7 @@ describe('LogsTable', () => { }); }); - it('should not render `tsNs`', async () => { + it('should not render `tsNs` column', async () => { setup(undefined, getMockLokiFrame()); await waitFor(() => { @@ -167,6 +174,28 @@ describe('LogsTable', () => { }); }); + it('should render numeric field aligned right', async () => { + setup( + { + columnsWithMeta: { + Time: { active: true, percentOfLinesWithLabel: 100, index: 0 }, + line: { active: true, percentOfLinesWithLabel: 100, index: 1 }, + tsNs: { active: true, percentOfLinesWithLabel: 100, index: 2 }, + }, + }, + getMockLokiFrame() + ); + + await waitFor(() => { + const columns = screen.queryAllByRole('columnheader', { name: 'tsNs' }); + expect(columns.length).toBe(1); + }); + + const cells = screen.queryAllByRole('cell'); + + expect(cells[cells.length - 1].style.textAlign).toBe('right'); + }); + it('should not render `labels`', async () => { setup(); @@ -194,7 +223,15 @@ describe('LogsTable', () => { }); it('should render 4 table rows', async () => { - setup(undefined, getMockLokiFrameDataPlane()); + setup( + { + columnsWithMeta: { + timestamp: { active: true, percentOfLinesWithLabel: 3, index: 0 }, + body: { active: true, percentOfLinesWithLabel: 3, index: 1 }, + }, + }, + getMockLokiFrameDataPlane() + ); await waitFor(() => { const rows = screen.getAllByRole('row'); @@ -208,7 +245,7 @@ describe('LogsTable', () => { getComponent( { columnsWithMeta: { - traceID: { active: true, percentOfLinesWithLabel: 3 }, + traceID: { active: true, percentOfLinesWithLabel: 3, index: 0 }, }, }, getMockLokiFrameDataPlane() @@ -223,7 +260,15 @@ describe('LogsTable', () => { }); it('should not render `labels`', async () => { - setup(undefined, getMockLokiFrameDataPlane()); + setup( + { + columnsWithMeta: { + timestamp: { active: true, percentOfLinesWithLabel: 100, index: 0 }, + body: { active: true, percentOfLinesWithLabel: 100, index: 1 }, + }, + }, + getMockLokiFrameDataPlane() + ); await waitFor(() => { const columns = screen.queryAllByRole('columnheader', { name: 'labels' }); @@ -233,7 +278,15 @@ describe('LogsTable', () => { }); it('should not render `tsNs`', async () => { - setup(undefined, getMockLokiFrameDataPlane()); + setup( + { + columnsWithMeta: { + timestamp: { active: true, percentOfLinesWithLabel: 100, index: 0 }, + body: { active: true, percentOfLinesWithLabel: 100, index: 1 }, + }, + }, + getMockLokiFrameDataPlane() + ); await waitFor(() => { const columns = screen.queryAllByRole('columnheader', { name: 'tsNs' }); @@ -245,9 +298,9 @@ describe('LogsTable', () => { it('should render extracted labels as columns (loki dataplane)', async () => { setup({ columnsWithMeta: { - foo: { active: true, percentOfLinesWithLabel: 3 }, - line: { active: true, percentOfLinesWithLabel: 3 }, - Time: { active: true, percentOfLinesWithLabel: 3 }, + foo: { active: true, percentOfLinesWithLabel: 3, index: 2 }, + line: { active: true, percentOfLinesWithLabel: 3, index: 1 }, + Time: { active: true, percentOfLinesWithLabel: 3, index: 0 }, }, }); diff --git a/public/app/features/explore/Logs/LogsTable.tsx b/public/app/features/explore/Logs/LogsTable.tsx index 48719cce8ce94..8f50a8d8ba8de 100644 --- a/public/app/features/explore/Logs/LogsTable.tsx +++ b/public/app/features/explore/Logs/LogsTable.tsx @@ -9,6 +9,7 @@ import { DataTransformerConfig, Field, FieldType, + guessFieldTypeForField, LogsSortOrder, sortDataFrame, SplitOpen, @@ -19,11 +20,11 @@ import { import { config } from '@grafana/runtime'; import { AdHocFilterItem, Table } from '@grafana/ui'; import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/src/components/Table/types'; -import { LogsFrame, parseLogsFrame } from 'app/features/logs/logsFrame'; +import { LogsFrame } from 'app/features/logs/logsFrame'; import { getFieldLinksForExplore } from '../utils/links'; -import { fieldNameMeta } from './LogsTableWrap'; +import { FieldNameMeta } from './LogsTableWrap'; interface Props { dataFrame: DataFrame; @@ -32,24 +33,23 @@ interface Props { splitOpen: SplitOpen; range: TimeRange; logsSortOrder: LogsSortOrder; - columnsWithMeta: Record<string, fieldNameMeta>; + columnsWithMeta: Record<string, FieldNameMeta>; height: number; onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void; onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void; + logsFrame: LogsFrame | null; } export function LogsTable(props: Props) { - const { timeZone, splitOpen, range, logsSortOrder, width, dataFrame, columnsWithMeta } = props; + const { timeZone, splitOpen, range, logsSortOrder, width, dataFrame, columnsWithMeta, logsFrame } = props; const [tableFrame, setTableFrame] = useState<DataFrame | undefined>(undefined); + const timeIndex = logsFrame?.timeField.index; const prepareTableFrame = useCallback( (frame: DataFrame): DataFrame => { if (!frame.length) { return frame; } - // Parse the dataframe to a logFrame - const logsFrame = parseLogsFrame(frame); - const timeIndex = logsFrame?.timeField.index; const sortedFrame = sortDataFrame(frame, timeIndex, logsSortOrder === LogsSortOrder.Descending); @@ -81,30 +81,31 @@ export function LogsTable(props: Props) { custom: { inspect: true, filterable: true, // This sets the columns to be filterable + width: getInitialFieldWidth(field), ...field.config.custom, }, // This sets the individual field value as filterable - filterable: isFieldFilterable(field, logsFrame ?? undefined), + filterable: isFieldFilterable(field, logsFrame?.bodyField.name ?? '', logsFrame?.timeField.name ?? ''), }; + + // If it's a string, then try to guess for a better type for numeric support in viz + field.type = field.type === FieldType.string ? guessFieldTypeForField(field) ?? FieldType.string : field.type; } return frameWithOverrides; }, - [logsSortOrder, timeZone, splitOpen, range] + [logsSortOrder, timeZone, splitOpen, range, logsFrame?.bodyField.name, logsFrame?.timeField.name, timeIndex] ); useEffect(() => { const prepare = async () => { - // Parse the dataframe to a logFrame - const logsFrame = dataFrame ? parseLogsFrame(dataFrame) : undefined; - - if (!logsFrame) { + if (!logsFrame?.timeField.name || !logsFrame?.bodyField.name) { setTableFrame(undefined); return; } // create extract JSON transformation for every field that is `json.RawMessage` - const transformations: Array<DataTransformerConfig | CustomTransformOperator> = extractFields(dataFrame); + const transformations: Array<DataTransformerConfig | CustomTransformOperator> = getLogsExtractFields(dataFrame); let labelFilters = buildLabelFilters(columnsWithMeta); @@ -117,6 +118,10 @@ export function LogsTable(props: Props) { transformations.push({ id: 'organize', options: { + indexByName: { + [logsFrame.bodyField.name]: 0, + [logsFrame.timeField.name]: 1, + }, includeByName: { [logsFrame.bodyField.name]: true, [logsFrame.timeField.name]: true, @@ -134,7 +139,14 @@ export function LogsTable(props: Props) { } }; prepare(); - }, [columnsWithMeta, dataFrame, logsSortOrder, prepareTableFrame]); + }, [ + columnsWithMeta, + dataFrame, + logsSortOrder, + prepareTableFrame, + logsFrame?.bodyField.name, + logsFrame?.timeField.name, + ]); if (!tableFrame) { return null; @@ -166,14 +178,14 @@ export function LogsTable(props: Props) { ); } -const isFieldFilterable = (field: Field, logsFrame?: LogsFrame | undefined) => { - if (!logsFrame) { +const isFieldFilterable = (field: Field, bodyName: string, timeName: string) => { + if (!bodyName || !timeName) { return false; } - if (logsFrame.bodyField.name === field.name) { + if (bodyName === field.name) { return false; } - if (logsFrame.timeField.name === field.name) { + if (timeName === field.name) { return false; } if (field.config.links?.length) { @@ -185,7 +197,7 @@ const isFieldFilterable = (field: Field, logsFrame?: LogsFrame | undefined) => { // TODO: explore if `logsFrame.ts` can help us with getting the right fields // TODO Why is typeInfo not defined on the Field interface? -function extractFields(dataFrame: DataFrame) { +export function getLogsExtractFields(dataFrame: DataFrame) { return dataFrame.fields .filter((field: Field & { typeInfo?: { frame: string } }) => { const isFieldLokiLabels = @@ -211,26 +223,44 @@ function extractFields(dataFrame: DataFrame) { }); } -function buildLabelFilters(columnsWithMeta: Record<string, fieldNameMeta>) { +function buildLabelFilters(columnsWithMeta: Record<string, FieldNameMeta>) { // Create object of label filters to include columns selected by the user - let labelFilters: Record<string, true> = {}; + let labelFilters: Record<string, number> = {}; Object.keys(columnsWithMeta) .filter((key) => columnsWithMeta[key].active) .forEach((key) => { - labelFilters[key] = true; + const index = columnsWithMeta[key].index; + // Index should always be defined for any active column + if (index !== undefined) { + labelFilters[key] = index; + } }); return labelFilters; } -function getLabelFiltersTransform(labelFilters: Record<string, true>) { +function getLabelFiltersTransform(labelFilters: Record<string, number>) { + let labelFiltersInclude: Record<string, boolean> = {}; + + for (const key in labelFilters) { + labelFiltersInclude[key] = true; + } + if (Object.keys(labelFilters).length > 0) { return { id: 'organize', options: { - includeByName: labelFilters, + indexByName: labelFilters, + includeByName: labelFiltersInclude, }, }; } return null; } + +function getInitialFieldWidth(field: Field): number | undefined { + if (field.type === FieldType.time) { + return 200; + } + return undefined; +} diff --git a/public/app/features/explore/Logs/LogsTableActiveFields.tsx b/public/app/features/explore/Logs/LogsTableActiveFields.tsx new file mode 100644 index 0000000000000..802c59e6f9912 --- /dev/null +++ b/public/app/features/explore/Logs/LogsTableActiveFields.tsx @@ -0,0 +1,109 @@ +import { css, cx } from '@emotion/css'; +import React from 'react'; +import { DragDropContext, Draggable, DraggableProvided, Droppable, DropResult } from 'react-beautiful-dnd'; + +import { GrafanaTheme2 } from '@grafana/data/src'; +import { useTheme2 } from '@grafana/ui/src'; + +import { LogsTableEmptyFields } from './LogsTableEmptyFields'; +import { LogsTableNavField } from './LogsTableNavField'; +import { FieldNameMeta } from './LogsTableWrap'; + +export function getLogsFieldsStyles(theme: GrafanaTheme2) { + return { + wrap: css({ + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + display: 'flex', + background: theme.colors.background.primary, + }), + dragging: css({ + background: theme.colors.background.secondary, + }), + columnWrapper: css({ + marginBottom: theme.spacing(1.5), + // need some space or the outline of the checkbox is cut off + paddingLeft: theme.spacing(0.5), + }), + }; +} + +function sortLabels(labels: Record<string, FieldNameMeta>) { + return (a: string, b: string) => { + const la = labels[a]; + const lb = labels[b]; + + // Sort by index + if (la.index != null && lb.index != null) { + return la.index - lb.index; + } + + // otherwise do not sort + return 0; + }; +} + +export const LogsTableActiveFields = (props: { + labels: Record<string, FieldNameMeta>; + valueFilter: (value: string) => boolean; + toggleColumn: (columnName: string) => void; + reorderColumn: (sourceIndex: number, destinationIndex: number) => void; + id: string; +}): JSX.Element => { + const { reorderColumn, labels, valueFilter, toggleColumn } = props; + const theme = useTheme2(); + const styles = getLogsFieldsStyles(theme); + const labelKeys = Object.keys(labels).filter((labelName) => valueFilter(labelName)); + + const onDragEnd = (result: DropResult) => { + if (!result.destination) { + return; + } + reorderColumn(result.source.index, result.destination.index); + }; + + const renderTitle = (labelName: string) => { + const label = labels[labelName]; + if (label) { + return `${labelName} appears in ${label?.percentOfLinesWithLabel}% of log lines`; + } + + return undefined; + }; + + if (labelKeys.length) { + return ( + <DragDropContext onDragEnd={onDragEnd}> + <Droppable droppableId="order-fields" direction="vertical"> + {(provided) => ( + <div className={styles.columnWrapper} {...provided.droppableProps} ref={provided.innerRef}> + {labelKeys.sort(sortLabels(labels)).map((labelName, index) => ( + <Draggable draggableId={labelName} key={labelName} index={index}> + {(provided: DraggableProvided, snapshot) => ( + <div + className={cx(styles.wrap, snapshot.isDragging ? styles.dragging : undefined)} + ref={provided.innerRef} + {...provided.draggableProps} + {...provided.dragHandleProps} + title={renderTitle(labelName)} + > + <LogsTableNavField + label={labelName} + onChange={() => toggleColumn(labelName)} + labels={labels} + draggable={true} + /> + </div> + )} + </Draggable> + ))} + {provided.placeholder} + </div> + )} + </Droppable> + </DragDropContext> + ); + } + + return <LogsTableEmptyFields />; +}; diff --git a/public/app/features/explore/Logs/LogsTableAvailableFields.tsx b/public/app/features/explore/Logs/LogsTableAvailableFields.tsx new file mode 100644 index 0000000000000..8ab7d480508a3 --- /dev/null +++ b/public/app/features/explore/Logs/LogsTableAvailableFields.tsx @@ -0,0 +1,63 @@ +import React from 'react'; + +import { useTheme2 } from '@grafana/ui/src'; + +import { getLogsFieldsStyles } from './LogsTableActiveFields'; +import { LogsTableEmptyFields } from './LogsTableEmptyFields'; +import { LogsTableNavField } from './LogsTableNavField'; +import { FieldNameMeta } from './LogsTableWrap'; + +const collator = new Intl.Collator(undefined, { sensitivity: 'base' }); + +function sortLabels(labels: Record<string, FieldNameMeta>) { + return (a: string, b: string) => { + const la = labels[a]; + const lb = labels[b]; + + // ...sort by type and alphabetically + if (la != null && lb != null) { + return ( + Number(lb.type === 'TIME_FIELD') - Number(la.type === 'TIME_FIELD') || + Number(lb.type === 'BODY_FIELD') - Number(la.type === 'BODY_FIELD') || + collator.compare(a, b) + ); + } + + // otherwise do not sort + return 0; + }; +} + +export const LogsTableAvailableFields = (props: { + labels: Record<string, FieldNameMeta>; + valueFilter: (value: string) => boolean; + toggleColumn: (columnName: string) => void; +}): JSX.Element => { + const { labels, valueFilter, toggleColumn } = props; + const theme = useTheme2(); + const styles = getLogsFieldsStyles(theme); + const labelKeys = Object.keys(labels).filter((labelName) => valueFilter(labelName)); + if (labelKeys.length) { + // Otherwise show list with a hardcoded order + return ( + <div className={styles.columnWrapper}> + {labelKeys.sort(sortLabels(labels)).map((labelName, index) => ( + <div + key={labelName} + className={styles.wrap} + title={`${labelName} appears in ${labels[labelName]?.percentOfLinesWithLabel}% of log lines`} + > + <LogsTableNavField + showCount={true} + label={labelName} + onChange={() => toggleColumn(labelName)} + labels={labels} + /> + </div> + ))} + </div> + ); + } + + return <LogsTableEmptyFields />; +}; diff --git a/public/app/features/explore/Logs/LogsTableEmptyFields.tsx b/public/app/features/explore/Logs/LogsTableEmptyFields.tsx new file mode 100644 index 0000000000000..a4a2c05426bd1 --- /dev/null +++ b/public/app/features/explore/Logs/LogsTableEmptyFields.tsx @@ -0,0 +1,21 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useTheme2 } from '@grafana/ui'; + +function getStyles(theme: GrafanaTheme2) { + return { + empty: css({ + marginBottom: theme.spacing(2), + marginLeft: theme.spacing(1.75), + fontSize: theme.typography.fontSize, + }), + }; +} + +export function LogsTableEmptyFields() { + const theme = useTheme2(); + const styles = getStyles(theme); + return <div className={styles.empty}>No fields</div>; +} diff --git a/public/app/features/explore/Logs/LogsTableMultiSelect.tsx b/public/app/features/explore/Logs/LogsTableMultiSelect.tsx index 77431c0980390..31b2d7c0b4036 100644 --- a/public/app/features/explore/Logs/LogsTableMultiSelect.tsx +++ b/public/app/features/explore/Logs/LogsTableMultiSelect.tsx @@ -4,14 +4,21 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data/src'; import { useTheme2 } from '@grafana/ui/src'; -import { LogsTableNavColumn } from './LogsTableNavColumn'; -import { fieldNameMeta } from './LogsTableWrap'; +import { LogsTableActiveFields } from './LogsTableActiveFields'; +import { LogsTableAvailableFields } from './LogsTableAvailableFields'; +import { FieldNameMeta } from './LogsTableWrap'; function getStyles(theme: GrafanaTheme2) { return { sidebarWrap: css({ overflowY: 'scroll', height: 'calc(100% - 50px)', + /* Hide scrollbar for Chrome, Safari, and Opera */ + '&::-webkit-scrollbar': { + display: 'none', + }, + /* Hide scrollbar for Firefox */ + scrollbarWidth: 'none', }), columnHeaderButton: css({ appearance: 'none', @@ -39,9 +46,10 @@ function getStyles(theme: GrafanaTheme2) { export const LogsTableMultiSelect = (props: { toggleColumn: (columnName: string) => void; - filteredColumnsWithMeta: Record<string, fieldNameMeta> | undefined; - columnsWithMeta: Record<string, fieldNameMeta>; + filteredColumnsWithMeta: Record<string, FieldNameMeta> | undefined; + columnsWithMeta: Record<string, FieldNameMeta>; clear: () => void; + reorderColumn: (oldIndex: number, newIndex: number) => void; }) => { const theme = useTheme2(); const styles = getStyles(theme); @@ -56,14 +64,16 @@ export const LogsTableMultiSelect = (props: { Reset </button> </div> - <LogsTableNavColumn + <LogsTableActiveFields + reorderColumn={props.reorderColumn} toggleColumn={props.toggleColumn} labels={props.filteredColumnsWithMeta ?? props.columnsWithMeta} valueFilter={(value) => props.columnsWithMeta[value]?.active ?? false} + id={'selected-fields'} /> <div className={styles.columnHeader}>Fields</div> - <LogsTableNavColumn + <LogsTableAvailableFields toggleColumn={props.toggleColumn} labels={props.filteredColumnsWithMeta ?? props.columnsWithMeta} valueFilter={(value) => !props.columnsWithMeta[value]?.active} diff --git a/public/app/features/explore/Logs/LogsTableNavColumn.tsx b/public/app/features/explore/Logs/LogsTableNavColumn.tsx deleted file mode 100644 index 5dfa2e9fe25b8..0000000000000 --- a/public/app/features/explore/Logs/LogsTableNavColumn.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { css } from '@emotion/css'; -import React from 'react'; - -import { GrafanaTheme2 } from '@grafana/data/src'; -import { Checkbox, useTheme2 } from '@grafana/ui/src'; - -import { fieldNameMeta } from './LogsTableWrap'; - -function getStyles(theme: GrafanaTheme2) { - return { - labelCount: css({ - marginLeft: theme.spacing(0.5), - marginRight: theme.spacing(0.5), - appearance: 'none', - background: 'none', - border: 'none', - fontSize: theme.typography.pxToRem(11), - }), - wrap: css({ - display: 'flex', - alignItems: 'center', - marginTop: theme.spacing(1), - marginBottom: theme.spacing(1), - justifyContent: 'space-between', - }), - // Making the checkbox sticky and label scrollable for labels that are wider then the container - // However, the checkbox component does not support this, so we need to do some css hackery for now until the API of that component is updated. - checkboxLabel: css({ - '> :first-child': { - position: 'sticky', - left: 0, - bottom: 0, - top: 0, - }, - '> span': { - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - display: 'block', - maxWidth: '100%', - }, - }), - columnWrapper: css({ - marginBottom: theme.spacing(1.5), - // need some space or the outline of the checkbox is cut off - paddingLeft: theme.spacing(0.5), - }), - empty: css({ - marginBottom: theme.spacing(2), - marginLeft: theme.spacing(1.75), - fontSize: theme.typography.fontSize, - }), - }; -} - -const collator = new Intl.Collator(undefined, { sensitivity: 'base' }); -function sortLabels(labels: Record<string, fieldNameMeta>) { - return (a: string, b: string) => { - const la = labels[a]; - const lb = labels[b]; - - if (la != null && lb != null) { - return ( - Number(lb.type === 'TIME_FIELD') - Number(la.type === 'TIME_FIELD') || - Number(lb.type === 'BODY_FIELD') - Number(la.type === 'BODY_FIELD') || - collator.compare(a, b) - ); - } - // otherwise do not sort - return 0; - }; -} - -export const LogsTableNavColumn = (props: { - labels: Record<string, fieldNameMeta>; - valueFilter: (value: string) => boolean; - toggleColumn: (columnName: string) => void; -}): JSX.Element => { - const { labels, valueFilter, toggleColumn } = props; - const theme = useTheme2(); - const styles = getStyles(theme); - const labelKeys = Object.keys(labels).filter((labelName) => valueFilter(labelName)); - if (labelKeys.length) { - return ( - <div className={styles.columnWrapper}> - {labelKeys.sort(sortLabels(labels)).map((labelName) => ( - <div - title={`${labelName} appears in ${labels[labelName]?.percentOfLinesWithLabel}% of log lines`} - className={styles.wrap} - key={labelName} - > - <Checkbox - className={styles.checkboxLabel} - label={labelName} - onChange={() => toggleColumn(labelName)} - checked={labels[labelName]?.active ?? false} - /> - <button className={styles.labelCount} onClick={() => toggleColumn(labelName)}> - {labels[labelName]?.percentOfLinesWithLabel}% - </button> - </div> - ))} - </div> - ); - } - - return <div className={styles.empty}>No fields</div>; -}; diff --git a/public/app/features/explore/Logs/LogsTableNavField.tsx b/public/app/features/explore/Logs/LogsTableNavField.tsx new file mode 100644 index 0000000000000..d883ae8ed3004 --- /dev/null +++ b/public/app/features/explore/Logs/LogsTableNavField.tsx @@ -0,0 +1,83 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Checkbox, Icon, useTheme2 } from '@grafana/ui'; + +import { FieldNameMeta } from './LogsTableWrap'; + +function getStyles(theme: GrafanaTheme2) { + return { + dragIcon: css({ + cursor: 'drag', + marginLeft: theme.spacing(1), + opacity: 0.4, + }), + labelCount: css({ + marginLeft: theme.spacing(0.5), + marginRight: theme.spacing(0.5), + appearance: 'none', + background: 'none', + border: 'none', + fontSize: theme.typography.pxToRem(11), + opacity: 0.6, + }), + contentWrap: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + }), + // Hide text that overflows, had to select elements within the Checkbox component, so this is a bit fragile + checkboxLabel: css({ + '> span': { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + display: 'block', + maxWidth: '100%', + }, + }), + }; +} + +export function LogsTableNavField(props: { + label: string; + onChange: () => void; + labels: Record<string, FieldNameMeta>; + draggable?: boolean; + showCount?: boolean; +}): React.JSX.Element | undefined { + const theme = useTheme2(); + const styles = getStyles(theme); + + if (props.labels[props.label]) { + return ( + <> + <div className={styles.contentWrap}> + <Checkbox + className={styles.checkboxLabel} + label={props.label} + onChange={props.onChange} + checked={props.labels[props.label]?.active ?? false} + /> + {props.showCount && ( + <button className={styles.labelCount} onClick={props.onChange}> + {props.labels[props.label]?.percentOfLinesWithLabel}% + </button> + )} + </div> + {props.draggable && ( + <Icon + aria-label="Drag and drop icon" + title="Drag and drop to reorder" + name="draggabledots" + size="lg" + className={styles.dragIcon} + /> + )} + </> + ); + } + return undefined; +} diff --git a/public/app/features/explore/Logs/LogsTableWrap.tsx b/public/app/features/explore/Logs/LogsTableWrap.tsx index 74a3e79b87dc7..9580773368a81 100644 --- a/public/app/features/explore/Logs/LogsTableWrap.tsx +++ b/public/app/features/explore/Logs/LogsTableWrap.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import { Resizable, ResizeCallback } from 're-resizable'; import React, { useCallback, useEffect, useState } from 'react'; import { @@ -35,22 +36,34 @@ interface Props extends Themeable2 { datasourceType?: string; } -export type fieldNameMeta = { +type ActiveFieldMeta = { + active: false; + index: undefined; // if undefined the column is not selected +}; + +type InactiveFieldMeta = { + active: true; + index: number; // if undefined the column is not selected +}; + +type GenericMeta = { percentOfLinesWithLabel: number; - active: boolean | undefined; type?: 'BODY_FIELD' | 'TIME_FIELD'; }; -type fieldName = string; -type fieldNameMetaStore = Record<fieldName, fieldNameMeta>; + +export type FieldNameMeta = (InactiveFieldMeta | ActiveFieldMeta) & GenericMeta; + +type FieldName = string; +type FieldNameMetaStore = Record<FieldName, FieldNameMeta>; export function LogsTableWrap(props: Props) { const { logsFrames, updatePanelState, panelState } = props; const propsColumns = panelState?.columns; // Save the normalized cardinality of each label - const [columnsWithMeta, setColumnsWithMeta] = useState<fieldNameMetaStore | undefined>(undefined); + const [columnsWithMeta, setColumnsWithMeta] = useState<FieldNameMetaStore | undefined>(undefined); // Filtered copy of columnsWithMeta that only includes matching results - const [filteredColumnsWithMeta, setFilteredColumnsWithMeta] = useState<fieldNameMetaStore | undefined>(undefined); + const [filteredColumnsWithMeta, setFilteredColumnsWithMeta] = useState<FieldNameMetaStore | undefined>(undefined); const [searchValue, setSearchValue] = useState<string>(''); const height = getLogsTableHeight(); @@ -62,12 +75,13 @@ export function LogsTableWrap(props: Props) { ); const getColumnsFromProps = useCallback( - (fieldNames: fieldNameMetaStore) => { + (fieldNames: FieldNameMetaStore) => { const previouslySelected = props.panelState?.columns; if (previouslySelected) { - Object.values(previouslySelected).forEach((key) => { + Object.values(previouslySelected).forEach((key, index) => { if (fieldNames[key]) { fieldNames[key].active = true; + fieldNames[key].index = index; } }); } @@ -153,10 +167,10 @@ export function LogsTableWrap(props: Props) { } // Use a map to dedupe labels and count their occurrences in the logs - const labelCardinality = new Map<fieldName, fieldNameMeta>(); + const labelCardinality = new Map<FieldName, FieldNameMeta>(); // What the label state will look like - let pendingLabelState: fieldNameMetaStore = {}; + let pendingLabelState: FieldNameMetaStore = {}; // If we have labels and log lines if (labels?.length && numberOfLogLines) { @@ -169,14 +183,23 @@ export function LogsTableWrap(props: Props) { if (labelCardinality.has(label)) { const value = labelCardinality.get(label); if (value) { - labelCardinality.set(label, { - percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1, - active: value?.active, - }); + if (value?.active) { + labelCardinality.set(label, { + percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1, + active: true, + index: value.index, + }); + } else { + labelCardinality.set(label, { + percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1, + active: false, + index: undefined, + }); + } } // Otherwise add it } else { - labelCardinality.set(label, { percentOfLinesWithLabel: 1, active: undefined }); + labelCardinality.set(label, { percentOfLinesWithLabel: 1, active: false, index: undefined }); } }); }); @@ -195,13 +218,27 @@ export function LogsTableWrap(props: Props) { // Normalize the other fields otherFields.forEach((field) => { - pendingLabelState[field.name] = { - percentOfLinesWithLabel: normalize( - field.values.filter((value) => value !== null && value !== undefined).length, - numberOfLogLines - ), - active: pendingLabelState[field.name]?.active, - }; + const isActive = pendingLabelState[field.name]?.active; + const index = pendingLabelState[field.name]?.index; + if (isActive && index !== undefined) { + pendingLabelState[field.name] = { + percentOfLinesWithLabel: normalize( + field.values.filter((value) => value !== null && value !== undefined).length, + numberOfLogLines + ), + active: true, + index: index, + }; + } else { + pendingLabelState[field.name] = { + percentOfLinesWithLabel: normalize( + field.values.filter((value) => value !== null && value !== undefined).length, + numberOfLogLines + ), + active: false, + index: undefined, + }; + } }); pendingLabelState = getColumnsFromProps(pendingLabelState); @@ -229,6 +266,9 @@ export function LogsTableWrap(props: Props) { // The panel state is updated when the user interacts with the multi-select sidebar }, [currentDataFrame, getColumnsFromProps]); + const [sidebarWidth, setSidebarWidth] = useState(220); + const tableWidth = props.width - sidebarWidth; + if (!columnsWithMeta) { return null; } @@ -255,45 +295,64 @@ export function LogsTableWrap(props: Props) { const clearSelection = () => { const pendingLabelState = { ...columnsWithMeta }; + let index = 0; Object.keys(pendingLabelState).forEach((key) => { - pendingLabelState[key].active = !!pendingLabelState[key].type; + const isDefaultField = !!pendingLabelState[key].type; + // after reset the only active fields are the special time and body fields + pendingLabelState[key].active = isDefaultField; + // reset the index + pendingLabelState[key].index = isDefaultField ? index++ : undefined; }); setColumnsWithMeta(pendingLabelState); }; - // Toggle a column on or off when the user interacts with an element in the multi-select sidebar - const toggleColumn = (columnName: fieldName) => { - if (!columnsWithMeta || !(columnName in columnsWithMeta)) { - console.warn('failed to get column', columnsWithMeta); + const reorderColumn = (sourceIndex: number, destinationIndex: number) => { + if (sourceIndex === destinationIndex) { return; } - const pendingLabelState = { - ...columnsWithMeta, - [columnName]: { ...columnsWithMeta[columnName], active: !columnsWithMeta[columnName]?.active }, - }; + const pendingLabelState = { ...columnsWithMeta }; - // Analytics - columnFilterEvent(columnName); + const keys = Object.keys(pendingLabelState) + .filter((key) => pendingLabelState[key].active) + .map((key) => ({ + fieldName: key, + index: pendingLabelState[key].index ?? 0, + })) + .sort((a, b) => a.index - b.index); + + const [source] = keys.splice(sourceIndex, 1); + keys.splice(destinationIndex, 0, source); + + keys.forEach((key, index) => { + pendingLabelState[key.fieldName].index = index; + }); // Set local state setColumnsWithMeta(pendingLabelState); - // If user is currently filtering, update filtered state - if (filteredColumnsWithMeta) { - const pendingFilteredLabelState = { - ...filteredColumnsWithMeta, - [columnName]: { ...filteredColumnsWithMeta[columnName], active: !filteredColumnsWithMeta[columnName]?.active }, - }; - setFilteredColumnsWithMeta(pendingFilteredLabelState); - } + // Sync the explore state + updateExploreState(pendingLabelState); + }; + + function updateExploreState(pendingLabelState: FieldNameMetaStore) { + // Get all active columns and sort by index + const newColumnsArray = Object.keys(pendingLabelState) + // Only include active filters + .filter((key) => pendingLabelState[key]?.active) + .sort((a, b) => { + const pa = pendingLabelState[a]; + const pb = pendingLabelState[b]; + if (pa.index !== undefined && pb.index !== undefined) { + return pa.index - pb.index; // sort by index + } + return 0; + }); const newColumns: Record<number, string> = Object.assign( {}, // Get the keys of the object as an array - Object.keys(pendingLabelState) - // Only include active filters - .filter((key) => pendingLabelState[key]?.active) + newColumnsArray ); const defaultColumns = { 0: logsFrame?.timeField.name ?? '', 1: logsFrame?.bodyField.name ?? '' }; @@ -308,12 +367,79 @@ export function LogsTableWrap(props: Props) { // Update url state updatePanelState(newPanelState); + } + + // Toggle a column on or off when the user interacts with an element in the multi-select sidebar + const toggleColumn = (columnName: FieldName) => { + if (!columnsWithMeta || !(columnName in columnsWithMeta)) { + console.warn('failed to get column', columnsWithMeta); + return; + } + + const length = Object.keys(columnsWithMeta).filter((c) => columnsWithMeta[c].active).length; + const isActive = !columnsWithMeta[columnName].active ? true : undefined; + + let pendingLabelState: FieldNameMetaStore; + if (isActive) { + pendingLabelState = { + ...columnsWithMeta, + [columnName]: { + ...columnsWithMeta[columnName], + active: isActive, + index: length, + }, + }; + } else { + pendingLabelState = { + ...columnsWithMeta, + [columnName]: { + ...columnsWithMeta[columnName], + active: false, + index: undefined, + }, + }; + } + + // Analytics + columnFilterEvent(columnName); + + // Set local state + setColumnsWithMeta(pendingLabelState); + + // If user is currently filtering, update filtered state + if (filteredColumnsWithMeta) { + const active = !filteredColumnsWithMeta[columnName]?.active; + let pendingFilteredLabelState: FieldNameMetaStore; + if (active) { + pendingFilteredLabelState = { + ...filteredColumnsWithMeta, + [columnName]: { + ...filteredColumnsWithMeta[columnName], + active: active, + index: length, + }, + }; + } else { + pendingFilteredLabelState = { + ...filteredColumnsWithMeta, + [columnName]: { + ...filteredColumnsWithMeta[columnName], + active: false, + index: undefined, + }, + }; + } + + setFilteredColumnsWithMeta(pendingFilteredLabelState); + } + + updateExploreState(pendingLabelState); }; // uFuzzy search dispatcher, adds any matches to the local state const dispatcher = (data: string[][]) => { const matches = data[0]; - let newColumnsWithMeta: fieldNameMetaStore = {}; + let newColumnsWithMeta: FieldNameMetaStore = {}; let numberOfResults = 0; matches.forEach((match) => { if (match in columnsWithMeta) { @@ -350,11 +476,15 @@ export function LogsTableWrap(props: Props) { props.updatePanelState({ refId: value.value, labelFieldName: logsFrame?.getLabelFieldName() ?? undefined }); }; - const sidebarWidth = 220; - const totalWidth = props.width; - const tableWidth = totalWidth - sidebarWidth; const styles = getStyles(props.theme, height, sidebarWidth); + const getOnResize: ResizeCallback = (event, direction, ref) => { + const newSidebarWidth = Number(ref.style.width.slice(0, -2)); + if (!isNaN(newSidebarWidth)) { + setSidebarWidth(newSidebarWidth); + } + }; + return ( <> <div> @@ -383,16 +513,26 @@ export function LogsTableWrap(props: Props) { )} </div> <div className={styles.wrapper}> - <section className={styles.sidebar}> - <LogsColumnSearch value={searchValue} onChange={onSearchInputChange} /> - <LogsTableMultiSelect - toggleColumn={toggleColumn} - filteredColumnsWithMeta={filteredColumnsWithMeta} - columnsWithMeta={columnsWithMeta} - clear={clearSelection} - /> - </section> + <Resizable + enable={{ + right: true, + }} + handleClasses={{ right: styles.rzHandle }} + onResize={getOnResize} + > + <section className={styles.sidebar}> + <LogsColumnSearch value={searchValue} onChange={onSearchInputChange} /> + <LogsTableMultiSelect + reorderColumn={reorderColumn} + toggleColumn={toggleColumn} + filteredColumnsWithMeta={filteredColumnsWithMeta} + columnsWithMeta={columnsWithMeta} + clear={clearSelection} + /> + </section> + </Resizable> <LogsTable + logsFrame={logsFrame} onClickFilterLabel={props.onClickFilterLabel} onClickFilterOutLabel={props.onClickFilterOutLabel} logsSortOrder={props.logsSortOrder} @@ -423,7 +563,21 @@ function getStyles(theme: GrafanaTheme2, height: number, width: number) { fontSize: theme.typography.pxToRem(11), overflowY: 'hidden', width: width, - paddingRight: theme.spacing(1.5), + paddingRight: theme.spacing(3), + }), + rzHandle: css({ + background: theme.colors.secondary.main, + transition: '0.3s background ease-in-out', + position: 'relative', + height: '50% !important', + width: `${theme.spacing(1)} !important`, + top: '25% !important', + right: `${theme.spacing(1)} !important`, + cursor: 'grab', + borderRadius: theme.shape.radius.pill, + ['&:hover']: { + background: theme.colors.secondary.shade, + }, }), }; } diff --git a/public/app/features/explore/Logs/LogsVolumePanel.tsx b/public/app/features/explore/Logs/LogsVolumePanel.tsx index 7e42f7dd46036..4681cd580711f 100644 --- a/public/app/features/explore/Logs/LogsVolumePanel.tsx +++ b/public/app/features/explore/Logs/LogsVolumePanel.tsx @@ -7,11 +7,11 @@ import { DataQueryResponse, LoadingState, SplitOpen, - TimeZone, EventBus, GrafanaTheme2, DataFrame, } from '@grafana/data'; +import { TimeZone } from '@grafana/schema'; import { Icon, Tooltip, TooltipDisplayMode, useStyles2, useTheme2 } from '@grafana/ui'; import { getLogsVolumeDataSourceInfo, isLogsVolumeLimited } from '../../logs/utils'; @@ -68,6 +68,9 @@ export function LogsVolumePanel(props: Props) { return ( <div style={{ height }} className={styles.contentContainer}> <ExploreGraph + vizLegendOverrides={{ + calcs: ['sum'], + }} graphStyle="lines" loadingState={logsVolumeData.state ?? LoadingState.Done} data={logsVolumeData.data} diff --git a/public/app/features/explore/Logs/PopoverMenu.tsx b/public/app/features/explore/Logs/PopoverMenu.tsx index 5d2fb53f0e1d8..fe62668307cc7 100644 --- a/public/app/features/explore/Logs/PopoverMenu.tsx +++ b/public/app/features/explore/Logs/PopoverMenu.tsx @@ -94,7 +94,7 @@ function track(action: string, selectionLength: number, dataSourceType: string | const getStyles = (theme: GrafanaTheme2) => ({ menu: css({ - position: 'absolute', + position: 'fixed', zIndex: theme.zIndex.modal, }), }); diff --git a/public/app/features/explore/Logs/utils/logs.ts b/public/app/features/explore/Logs/utils/logs.ts index d156da0c08067..2aec91da980a3 100644 --- a/public/app/features/explore/Logs/utils/logs.ts +++ b/public/app/features/explore/Logs/utils/logs.ts @@ -6,3 +6,5 @@ export const SETTINGS_KEYS = { logsSortOrder: 'grafana.explore.logs.sortOrder', logContextWrapLogMessage: 'grafana.explore.logs.logContext.wrapLogMessage', }; + +export const visualisationTypeKey = 'grafana.explore.logs.visualisationType'; diff --git a/public/app/features/explore/MetaInfoText.tsx b/public/app/features/explore/MetaInfoText.tsx index 3392f6c796931..29c8528d355b8 100644 --- a/public/app/features/explore/MetaInfoText.tsx +++ b/public/app/features/explore/MetaInfoText.tsx @@ -5,33 +5,32 @@ import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; const getStyles = (theme: GrafanaTheme2) => ({ - metaContainer: css` - flex: 1; - color: ${theme.colors.text.secondary}; - margin-bottom: ${theme.spacing(2)}; - min-width: 30%; - display: flex; - flex-wrap: wrap; - `, - metaItem: css` - margin-right: ${theme.spacing(2)}; - margin-top: ${theme.spacing(0.5)}; - display: flex; - align-items: center; - - .logs-meta-item__error { - color: ${theme.colors.error.text}; - } - `, - metaLabel: css` - margin-right: calc(${theme.spacing(2)} / 2); - font-size: ${theme.typography.bodySmall.fontSize}; - font-weight: ${theme.typography.fontWeightMedium}; - `, - metaValue: css` - font-family: ${theme.typography.fontFamilyMonospace}; - font-size: ${theme.typography.bodySmall.fontSize}; - `, + metaContainer: css({ + flex: 1, + color: theme.colors.text.secondary, + marginBottom: theme.spacing(2), + minWidth: '30%', + display: 'flex', + flexWrap: 'wrap', + }), + metaItem: css({ + marginRight: theme.spacing(2), + marginTop: theme.spacing(0.5), + display: 'flex', + alignItems: 'center', + ['.logs-meta-item__error']: { + color: theme.colors.error.text, + }, + }), + metaLabel: css({ + marginRight: `calc(${theme.spacing(2)} / 2)`, + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.fontWeightMedium, + }), + metaValue: css({ + fontFamily: theme.typography.fontFamilyMonospace, + fontSize: theme.typography.bodySmall.fontSize, + }), }); export interface MetaItemProps { diff --git a/public/app/features/explore/NoData.tsx b/public/app/features/explore/NoData.tsx index 7c5182851dd37..ed819de9ffe41 100644 --- a/public/app/features/explore/NoData.tsx +++ b/public/app/features/explore/NoData.tsx @@ -16,20 +16,20 @@ export const NoData = () => { }; const getStyles = (theme: GrafanaTheme2) => ({ - wrapper: css` - label: no-data-card; - padding: ${theme.spacing(3)}; - background: ${theme.colors.background.primary}; - border-radius: ${theme.shape.radius.default}; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - flex-grow: 1; - `, - message: css` - font-size: ${theme.typography.h2.fontSize}; - padding: ${theme.spacing(4)}; - color: ${theme.colors.text.disabled}; - `, + wrapper: css({ + label: 'no-data-card', + padding: theme.spacing(3), + background: theme.colors.background.primary, + borderRadius: theme.shape.radius.default, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + flexGrow: 1, + }), + message: css({ + fontSize: theme.typography.h2.fontSize, + padding: theme.spacing(4), + color: theme.colors.text.disabled, + }), }); diff --git a/public/app/features/explore/NoDataSourceCallToAction.tsx b/public/app/features/explore/NoDataSourceCallToAction.tsx index 1e390cf7bdbdf..a3e773f3a57da 100644 --- a/public/app/features/explore/NoDataSourceCallToAction.tsx +++ b/public/app/features/explore/NoDataSourceCallToAction.tsx @@ -1,12 +1,21 @@ import { css } from '@emotion/css'; import React from 'react'; -import { LinkButton, CallToActionCard, Icon, useTheme2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { LinkButton, CallToActionCard, Icon, useStyles2 } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; import { AccessControlAction } from 'app/types'; +function getCardStyles(theme: GrafanaTheme2) { + return css({ + maxWidth: `${theme.breakpoints.values.lg}px`, + marginTop: theme.spacing(2), + alignSelf: 'center', + }); +} + export const NoDataSourceCallToAction = () => { - const theme = useTheme2(); + const cardStyles = useStyles2(getCardStyles); const canCreateDataSource = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate) && @@ -35,13 +44,5 @@ export const NoDataSourceCallToAction = () => { </LinkButton> ); - const cardClassName = css` - max-width: ${theme.breakpoints.values.lg}px; - margin-top: ${theme.spacing(2)}; - align-self: center; - `; - - return ( - <CallToActionCard callToActionElement={ctaElement} className={cardClassName} footer={footer} message={message} /> - ); + return <CallToActionCard callToActionElement={ctaElement} className={cardStyles} footer={footer} message={message} />; }; diff --git a/public/app/features/explore/PrometheusListView/ItemValues.test.tsx b/public/app/features/explore/PrometheusListView/ItemValues.test.tsx index 7a5a1e965d20d..f753896dbcabf 100644 --- a/public/app/features/explore/PrometheusListView/ItemValues.test.tsx +++ b/public/app/features/explore/PrometheusListView/ItemValues.test.tsx @@ -1,10 +1,9 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; -import { RawPrometheusListItemEmptyValue } from '../utils/getRawPrometheusListItemsFromDataFrame'; - import { ItemValues } from './ItemValues'; import { RawListValue } from './RawListItem'; +import { RawPrometheusListItemEmptyValue } from './utils/getRawPrometheusListItemsFromDataFrame'; const value1 = 'value 1'; const value2 = 'value 2'; diff --git a/public/app/features/explore/PrometheusListView/ItemValues.tsx b/public/app/features/explore/PrometheusListView/ItemValues.tsx index c0c33146d8e09..d874f806a0145 100644 --- a/public/app/features/explore/PrometheusListView/ItemValues.tsx +++ b/public/app/features/explore/PrometheusListView/ItemValues.tsx @@ -4,9 +4,8 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data/'; import { useStyles2 } from '@grafana/ui/'; -import { RawPrometheusListItemEmptyValue } from '../utils/getRawPrometheusListItemsFromDataFrame'; - import { rawListItemColumnWidth, rawListPaddingToHoldSpaceForCopyIcon, RawListValue } from './RawListItem'; +import { RawPrometheusListItemEmptyValue } from './utils/getRawPrometheusListItemsFromDataFrame'; const getStyles = (theme: GrafanaTheme2, totalNumberOfValues: number) => ({ rowWrapper: css` diff --git a/public/app/features/explore/PrometheusListView/RawListContainer.tsx b/public/app/features/explore/PrometheusListView/RawListContainer.tsx index f3f368cd89b35..92fb50b904084 100644 --- a/public/app/features/explore/PrometheusListView/RawListContainer.tsx +++ b/public/app/features/explore/PrometheusListView/RawListContainer.tsx @@ -8,13 +8,12 @@ import { DataFrame, Field as DataFrameField } from '@grafana/data/'; import { reportInteraction } from '@grafana/runtime/src'; import { Field, Switch } from '@grafana/ui/'; +import { ItemLabels } from './ItemLabels'; +import RawListItem from './RawListItem'; import { getRawPrometheusListItemsFromDataFrame, RawPrometheusListItemEmptyValue, -} from '../utils/getRawPrometheusListItemsFromDataFrame'; - -import { ItemLabels } from './ItemLabels'; -import RawListItem from './RawListItem'; +} from './utils/getRawPrometheusListItemsFromDataFrame'; export type instantQueryRawVirtualizedListData = { Value: string; __name__: string; [index: string]: string }; diff --git a/public/app/features/explore/utils/getRawPrometheusListItemsFromDataFrame.test.ts b/public/app/features/explore/PrometheusListView/utils/getRawPrometheusListItemsFromDataFrame.test.ts similarity index 98% rename from public/app/features/explore/utils/getRawPrometheusListItemsFromDataFrame.test.ts rename to public/app/features/explore/PrometheusListView/utils/getRawPrometheusListItemsFromDataFrame.test.ts index 3b475b2ca8703..354ea2407b682 100644 --- a/public/app/features/explore/utils/getRawPrometheusListItemsFromDataFrame.test.ts +++ b/public/app/features/explore/PrometheusListView/utils/getRawPrometheusListItemsFromDataFrame.test.ts @@ -1,4 +1,4 @@ -import { DataFrame, FieldType, FormattedValue, toDataFrame } from '@grafana/data/src'; +import { DataFrame, FieldType, FormattedValue, toDataFrame } from '@grafana/data'; import { getRawPrometheusListItemsFromDataFrame } from './getRawPrometheusListItemsFromDataFrame'; diff --git a/public/app/features/explore/utils/getRawPrometheusListItemsFromDataFrame.ts b/public/app/features/explore/PrometheusListView/utils/getRawPrometheusListItemsFromDataFrame.ts similarity index 95% rename from public/app/features/explore/utils/getRawPrometheusListItemsFromDataFrame.ts rename to public/app/features/explore/PrometheusListView/utils/getRawPrometheusListItemsFromDataFrame.ts index 79a015182f7b1..d4210eada7e69 100644 --- a/public/app/features/explore/utils/getRawPrometheusListItemsFromDataFrame.ts +++ b/public/app/features/explore/PrometheusListView/utils/getRawPrometheusListItemsFromDataFrame.ts @@ -1,6 +1,6 @@ -import { DataFrame, formattedValueToString } from '@grafana/data/src'; +import { DataFrame, formattedValueToString } from '@grafana/data'; -import { instantQueryRawVirtualizedListData } from '../PrometheusListView/RawListContainer'; +import { instantQueryRawVirtualizedListData } from '../RawListContainer'; type instantQueryMetricList = { [index: string]: { [index: string]: instantQueryRawVirtualizedListData } }; diff --git a/public/app/features/explore/QueryRows.test.tsx b/public/app/features/explore/QueryRows.test.tsx index 2f6e0ce0228fb..1bcb5111fd6dd 100644 --- a/public/app/features/explore/QueryRows.test.tsx +++ b/public/app/features/explore/QueryRows.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { Provider } from 'react-redux'; +import { DataSourceApi } from '@grafana/data'; import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { configureStore } from 'app/store/configureStore'; @@ -22,9 +23,9 @@ function setup(queries: DataQuery[]) { name: 'newDs', uid: 'newDs-uid', meta: { id: 'newDs' }, - }; + } as DataSourceApi; - const datasources: Record<string, any> = { + const datasources: Record<string, DataSourceApi> = { 'newDs-uid': defaultDs, 'someDs-uid': { name: 'someDs', @@ -33,7 +34,7 @@ function setup(queries: DataQuery[]) { components: { QueryEditor: () => 'someDs query editor', }, - }, + } as unknown as DataSourceApi, }; setDataSourceSrv({ @@ -46,7 +47,7 @@ function setup(queries: DataQuery[]) { get(uid?: string) { return Promise.resolve(uid ? datasources[uid] || defaultDs : defaultDs); }, - } as DataSourceSrv); + } as unknown as DataSourceSrv); const leftState = makeExplorePaneState(); const initialState: ExploreState = { diff --git a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx b/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx index 3cf4090147519..71910d544babc 100644 --- a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx +++ b/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx @@ -2,8 +2,9 @@ import { css } from '@emotion/css'; import React, { PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { applyFieldOverrides, DataFrame, SelectableValue, SplitOpen, TimeZone } from '@grafana/data'; +import { applyFieldOverrides, DataFrame, SelectableValue, SplitOpen } from '@grafana/data'; import { getTemplateSrv, reportInteraction } from '@grafana/runtime'; +import { TimeZone } from '@grafana/schema'; import { RadioButtonGroup, Table, AdHocFilterItem, PanelChrome } from '@grafana/ui'; import { config } from 'app/core/config'; import { PANEL_BORDER } from 'app/core/constants'; diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx index 048444080e7ac..38650bb3f2cec 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx @@ -374,7 +374,7 @@ describe('RichHistoryCard', () => { await waitFor(() => { expect(setQueries).toHaveBeenCalledWith(expect.any(String), queries); - expect(changeDatasource).toHaveBeenCalledWith(expect.any(String), 'mixed'); + expect(changeDatasource).toHaveBeenCalledWith({ datasource: 'mixed', exploreId: 'left' }); }); }); }); diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.tsx index 43b70b5f22cb1..5823340f9d00a 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.tsx @@ -188,7 +188,7 @@ export function RichHistoryCard(props: Props) { const queriesToRun = queryHistoryItem.queries; const differentDataSource = queryHistoryItem.datasourceUid !== datasourceInstance?.uid; if (differentDataSource) { - await changeDatasource(exploreId, queryHistoryItem.datasourceUid); + await changeDatasource({ exploreId, datasource: queryHistoryItem.datasourceUid }); } setQueries(exploreId, queriesToRun); diff --git a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx index cdafb00162013..7a7b5cc9d0e98 100644 --- a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx @@ -94,9 +94,9 @@ const getStyles = (theme: GrafanaTheme2, height: number) => { `, footer: css` height: 60px; - margin: ${theme.spacing(3)} auto; display: flex; justify-content: center; + align-items: center; font-weight: ${theme.typography.fontWeightLight}; font-size: ${theme.typography.bodySmall.fontSize}; a { @@ -166,6 +166,10 @@ export function RichHistoryQueriesTab(props: RichHistoryQueriesTabProps) { const mappedQueriesToHeadings = mapQueriesToHeadings(queries, richHistorySearchFilters.sortOrder); const sortOrderOptions = getSortOrderOptions(); const partialResults = queries.length && queries.length !== totalQueries; + const timeFilter = [ + richHistorySearchFilters.from || 0, + richHistorySearchFilters.to || richHistorySettings.retentionPeriod, + ]; return ( <div className={styles.container}> @@ -174,13 +178,13 @@ export function RichHistoryQueriesTab(props: RichHistoryQueriesTabProps) { <div className={styles.labelSlider}> <Trans i18nKey="explore.rich-history-queries-tab.filter-history">Filter history</Trans> </div> - <div className={styles.labelSlider}>{mapNumbertoTimeInSlider(richHistorySearchFilters.from)}</div> + <div className={styles.labelSlider}>{mapNumbertoTimeInSlider(timeFilter[0])}</div> <div className={styles.slider}> <RangeSlider tooltipAlwaysVisible={false} min={0} max={richHistorySettings.retentionPeriod} - value={[richHistorySearchFilters.from, richHistorySearchFilters.to]} + value={timeFilter} orientation="vertical" formatTooltipResult={mapNumbertoTimeInSlider} reverse={true} @@ -189,7 +193,7 @@ export function RichHistoryQueriesTab(props: RichHistoryQueriesTabProps) { }} /> </div> - <div className={styles.labelSlider}>{mapNumbertoTimeInSlider(richHistorySearchFilters.to)}</div> + <div className={styles.labelSlider}>{mapNumbertoTimeInSlider(timeFilter[1])}</div> </div> </div> diff --git a/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx b/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx index 7005ae94c90f4..c8b824da52dc2 100644 --- a/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx @@ -54,9 +54,9 @@ const getStyles = (theme: GrafanaTheme2) => { `, footer: css` height: 60px; - margin-top: ${theme.spacing(3)}; display: flex; justify-content: center; + align-items: center; font-weight: ${theme.typography.fontWeightLight}; font-size: ${theme.typography.bodySmall.fontSize}; a { diff --git a/public/app/features/explore/SecondaryActions.tsx b/public/app/features/explore/SecondaryActions.tsx index 5c10ed236648d..d5c29d43e2ece 100644 --- a/public/app/features/explore/SecondaryActions.tsx +++ b/public/app/features/explore/SecondaryActions.tsx @@ -20,12 +20,12 @@ type Props = { const getStyles = (theme: GrafanaTheme2) => { return { - containerMargin: css` - display: flex; - flex-wrap: wrap; - gap: ${theme.spacing(1)}; - margin-top: ${theme.spacing(2)}; - `, + containerMargin: css({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(1), + marginTop: theme.spacing(2), + }), }; }; diff --git a/public/app/features/explore/SupplementaryResultError.tsx b/public/app/features/explore/SupplementaryResultError.tsx index 6193dc1af67c2..0ad91832057c2 100644 --- a/public/app/features/explore/SupplementaryResultError.tsx +++ b/public/app/features/explore/SupplementaryResultError.tsx @@ -55,18 +55,18 @@ export function SupplementaryResultError(props: Props) { const getStyles = (theme: GrafanaTheme2) => { return { - supplementaryErrorContainer: css` - width: 50%; - min-width: ${theme.breakpoints.values.sm}px; - margin: 0 auto; - `, - suggestedActionWrapper: css` - height: ${theme.spacing(6)}; - button { - position: absolute; - right: ${theme.spacing(2)}; - top: ${theme.spacing(7)}; - } - `, + supplementaryErrorContainer: css({ + width: '50%', + minWidth: `${theme.breakpoints.values.sm}px`, + margin: '0 auto', + }), + suggestedActionWrapper: css({ + height: theme.spacing(6), + ['button']: { + position: 'absolute', + right: theme.spacing(2), + top: theme.spacing(7), + }, + }), }; }; diff --git a/public/app/features/explore/Table/TableContainer.test.tsx b/public/app/features/explore/Table/TableContainer.test.tsx index 7c0e5adbd94f0..d373617ace438 100644 --- a/public/app/features/explore/Table/TableContainer.test.tsx +++ b/public/app/features/explore/Table/TableContainer.test.tsx @@ -105,7 +105,15 @@ describe('TableContainerWithTheme', () => { { time: '2020-12-31 21:00:00', text: 'test_string_4' }, ]); }); + + it('should render table title with Prometheus query', () => { + const dataFrames = [{ ...dataFrame, name: 'metric{label="value"}' }]; + const tableProps = { ...defaultProps, tableResult: dataFrames }; + render(<TableContainerWithTheme {...tableProps} />); + expect(screen.getByText('Table - metric{label="value"}')).toBeInTheDocument(); + }); }); + describe('With multiple main frames', () => { it('should render multiple tables for multiple frames', () => { const dataFrames = [dataFrame, dataFrame]; diff --git a/public/app/features/explore/Table/TableContainer.tsx b/public/app/features/explore/Table/TableContainer.tsx index 0c83c9b78aeb1..6626977824b3d 100644 --- a/public/app/features/explore/Table/TableContainer.tsx +++ b/public/app/features/explore/Table/TableContainer.tsx @@ -2,8 +2,9 @@ import { css } from '@emotion/css'; import React, { PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { applyFieldOverrides, TimeZone, SplitOpen, DataFrame, LoadingState, FieldType } from '@grafana/data'; +import { applyFieldOverrides, SplitOpen, DataFrame, LoadingState, FieldType } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; +import { TimeZone } from '@grafana/schema'; import { Table, AdHocFilterItem, PanelChrome, withTheme2, Themeable2 } from '@grafana/ui'; import { config } from 'app/core/config'; import { t } from 'app/core/internationalization'; @@ -58,7 +59,9 @@ export class TableContainer extends PureComponent<Props> { name = data.refId || `${i}`; } - return name ? t('explore.table.title-with-name', 'Table - {{name}}', { name }) : t('explore.table.title', 'Table'); + return name + ? t('explore.table.title-with-name', 'Table - {{name}}', { name, interpolation: { escapeValue: false } }) + : t('explore.table.title', 'Table'); } render() { diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index f925a11f7538c..f44534301dd85 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -14,15 +14,13 @@ import { mapInternalLinkToExplore, SplitOpen, } from '@grafana/data'; +import { getTraceToLogsOptions, TraceToMetricsData, TraceToProfilesData } from '@grafana/o11y-ds-frontend'; import { getTemplateSrv } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { useStyles2 } from '@grafana/ui'; -import { getTraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; -import { TraceToMetricsData } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; -import { TraceToProfilesData } from 'app/core/components/TraceToProfiles/TraceToProfilesSettings'; +import { TempoQuery } from '@grafana-plugins/tempo/types'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { getTimeZone } from 'app/features/profile/state/selectors'; -import { TempoQuery } from 'app/plugins/datasource/tempo/types'; import { useDispatch, useSelector } from 'app/types'; import { changePanelState } from '../state/explorePane'; @@ -66,10 +64,20 @@ type Props = { datasource: DataSourceApi<DataQuery, DataSourceJsonData, {}> | undefined; topOfViewRef?: RefObject<HTMLDivElement>; createSpanLink?: SpanLinkFunc; + focusedSpanId?: string; + createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel<Field>; }; export function TraceView(props: Props) { - const { traceProp, datasource, topOfViewRef, exploreId, createSpanLink: createSpanLinkFromProps } = props; + const { + traceProp, + datasource, + topOfViewRef, + exploreId, + createSpanLink: createSpanLinkFromProps, + focusedSpanId: focusedSpanIdFromProps, + createFocusSpanLink: createFocusSpanLinkFromProps, + } = props; const { detailStates, @@ -103,13 +111,16 @@ export function TraceView(props: Props) { */ const [spanNameColumnWidth, setSpanNameColumnWidth] = useState(0.4); - const [focusedSpanId, createFocusSpanLink] = useFocusSpanLink({ + const [focusedSpanIdExplore, createFocusSpanLinkExplore] = useFocusSpanLink({ refId: props.dataFrames[0]?.refId, exploreId: props.exploreId!, datasource, splitOpenFn: props.splitOpenFn!, }); + const focusedSpanId = focusedSpanIdFromProps ?? focusedSpanIdExplore; + const createFocusSpanLink = createFocusSpanLinkFromProps ?? createFocusSpanLinkExplore; + const traceTimeline: TTraceTimeline = useMemo( () => ({ childrenHiddenIDs, @@ -263,8 +274,8 @@ function useFocusSpanLink(options: { }) ); - const query = useSelector( - (state) => state.explore.panes[options.exploreId]?.queries.find((query) => query.refId === options.refId) + const query = useSelector((state) => + state.explore.panes[options.exploreId]?.queries.find((query) => query.refId === options.refId) ); const createFocusSpanLink = (traceId: string, spanId: string) => { @@ -299,22 +310,22 @@ function useFocusSpanLink(options: { onClickFn: sameTrace ? () => setFocusedSpanId(focusedSpanId === spanId ? undefined : spanId) : options.splitOpenFn - ? () => - options.splitOpenFn({ - datasourceUid: options.datasource?.uid!, - queries: [ - { - ...query!, - query: traceId, - }, - ], - panelsState: { - trace: { - spanId, + ? () => + options.splitOpenFn({ + datasourceUid: options.datasource?.uid!, + queries: [ + { + ...query!, + query: traceId, + }, + ], + panelsState: { + trace: { + spanId, + }, }, - }, - }) - : undefined, + }) + : undefined, replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()), }); }; diff --git a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx index bfe300b3f3799..dcb0347ee2317 100644 --- a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx +++ b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx @@ -33,14 +33,9 @@ function renderTraceViewContainer(frames = [frameOld]) { describe('TraceViewContainer', () => { let user: ReturnType<typeof userEvent.setup>; + beforeEach(() => { - jest.useFakeTimers(); - // Need to use delay: null here to work with fakeTimers - // see https://github.com/testing-library/user-event/issues/833 - user = userEvent.setup({ delay: null }); - }); - afterEach(() => { - jest.useRealTimers(); + user = userEvent.setup(); }); it('toggles children visibility', async () => { diff --git a/public/app/features/explore/TraceView/TraceViewContainer.tsx b/public/app/features/explore/TraceView/TraceViewContainer.tsx index 9056d8658ad91..d36aa60f68e94 100644 --- a/public/app/features/explore/TraceView/TraceViewContainer.tsx +++ b/public/app/features/explore/TraceView/TraceViewContainer.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import { DataFrame, SplitOpen } from '@grafana/data'; -import { PanelChrome } from '@grafana/ui/src/components/PanelChrome/PanelChrome'; +import { PanelChrome } from '@grafana/ui'; import { StoreState, useSelector } from 'app/types'; import { TraceView } from './TraceView'; diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx index d71b4977ce0da..5595caf1e9a86 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx @@ -12,21 +12,22 @@ import ActionButton from './ActionButton'; export const getStyles = (theme: GrafanaTheme2) => { return { - TracePageActions: css` - label: TracePageActions; - display: flex; - align-items: center; - justify-content: center; - gap: 4px; - `, - feedback: css` - margin: 6px; - color: ${theme.colors.text.secondary}; - font-size: ${theme.typography.bodySmall.fontSize}; - &:hover { - color: ${theme.colors.text.link}; - } - `, + TracePageActions: css({ + label: 'TracePageActions', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '4px', + marginBottom: '10px', + }), + feedback: css({ + margin: '6px 6px 6px 0', + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + '&:hover': { + color: theme.colors.text.link, + }, + }), }; }; diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.test.tsx index c045f618e2994..31883a9a7ca1d 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.test.tsx @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React, { useState } from 'react'; @@ -109,22 +109,26 @@ describe('<NextPrevResult>', () => { const theme = createTheme(); const tooltip = container.querySelector('.' + getStyles(theme, true).tooltip); expect(screen.getByText('0 matches')).toBeDefined(); - userEvent.hover(tooltip!); - jest.advanceTimersByTime(1000); - await waitFor(() => { - expect(screen.getByText(/0 span matches for the filters selected/)).toBeDefined(); + + await user.hover(tooltip!); + await act(async () => { + jest.advanceTimersByTime(1000); }); + + expect(await screen.findByText(/0 span matches for the filters selected/)).toBeDefined(); }); it('renders services, depth correctly', async () => { const { container } = render(<NextPrevResultWithProps matches={['264afda25df92413', '364afda25df92413']} />); const theme = createTheme(); const tooltip = container.querySelector('.' + getStyles(theme, true).tooltip); - userEvent.hover(tooltip!); - jest.advanceTimersByTime(1000); - await waitFor(() => { - expect(screen.getByText(/Services: 2\/3/)).toBeDefined(); - expect(screen.getByText(/Depth: 1/)).toBeDefined(); + + await user.hover(tooltip!); + await act(async () => { + jest.advanceTimersByTime(1000); }); + + expect(await screen.findByText(/Services: 2\/3/)).toBeDefined(); + expect(await screen.findByText(/Depth: 1/)).toBeDefined(); }); }); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.test.tsx index dd7318d880c70..0f4f697978db8 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.test.tsx @@ -100,8 +100,6 @@ describe('SpanFilters', () => { const tagKey = screen.getByLabelText('Select tag key'); const tagOperator = screen.getByLabelText('Select tag operator'); const tagValue = screen.getByLabelText('Select tag value'); - const addTag = screen.getByLabelText('Add tag'); - const removeTag = screen.getByLabelText('Remove tag'); expect(serviceOperator).toBeInTheDocument(); expect(getElemText(serviceOperator)).toBe('='); @@ -119,8 +117,6 @@ describe('SpanFilters', () => { expect(tagOperator).toBeInTheDocument(); expect(getElemText(tagOperator)).toBe('='); expect(tagValue).toBeInTheDocument(); - expect(addTag).toBeInTheDocument(); - expect(removeTag).toBeInTheDocument(); await user.click(serviceValue); jest.advanceTimersByTime(1000); @@ -194,9 +190,41 @@ describe('SpanFilters', () => { }); }); + it('should only show add/remove tag when necessary', async () => { + render(<SpanFiltersWithProps />); + expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the default tag, so no need to add another one + expect(screen.queryAllByLabelText('Remove tag').length).toBe(0); // mot filled in the default tag, so no values to remove + expect(screen.getAllByLabelText('Select tag key').length).toBe(1); + + await selectAndCheckValue(user, screen.getByLabelText('Select tag key'), 'TagKey0'); + expect(screen.getAllByLabelText('Add tag').length).toBe(1); + expect(screen.getAllByLabelText('Remove tag').length).toBe(1); + + await user.click(screen.getByLabelText('Add tag')); + jest.advanceTimersByTime(1000); + expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the new tag, so no need to add another one + expect(screen.getAllByLabelText('Remove tag').length).toBe(2); // one for each tag + expect(screen.getAllByLabelText('Select tag key').length).toBe(2); + + await user.click(screen.getAllByLabelText('Remove tag')[1]); + jest.advanceTimersByTime(1000); + expect(screen.queryAllByLabelText('Add tag').length).toBe(1); // filled in the default tag, so can add another one + expect(screen.queryAllByLabelText('Remove tag').length).toBe(1); // filled in the default tag, so can remove values + expect(screen.getAllByLabelText('Select tag key').length).toBe(1); + + await user.click(screen.getAllByLabelText('Remove tag')[0]); + jest.advanceTimersByTime(1000); + expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the default tag, so no need to add another one + expect(screen.queryAllByLabelText('Remove tag').length).toBe(0); // mot filled in the default tag, so no values to remove + expect(screen.getAllByLabelText('Select tag key').length).toBe(1); + }); + it('should allow adding/removing tags', async () => { render(<SpanFiltersWithProps />); expect(screen.getAllByLabelText('Select tag key').length).toBe(1); + const tagKey = screen.getByLabelText('Select tag key'); + await selectAndCheckValue(user, tagKey, 'TagKey0'); + await user.click(screen.getByLabelText('Add tag')); jest.advanceTimersByTime(1000); expect(screen.getAllByLabelText('Select tag key').length).toBe(2); @@ -232,6 +260,8 @@ describe('SpanFilters', () => { expect(screen.queryByText('Span0')).not.toBeInTheDocument(); expect(screen.queryByText('TagKey0')).not.toBeInTheDocument(); expect(screen.queryByText('TagValue0')).not.toBeInTheDocument(); + expect(screen.queryByText('Add tag')).not.toBeInTheDocument(); + expect(screen.queryByText('Remove tag')).not.toBeInTheDocument(); expect(matchesSwitch).not.toBeChecked(); }); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx index e052ad598b7ab..6de82f34043ed 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx @@ -19,8 +19,8 @@ import React, { useState, useEffect, memo, useCallback } from 'react'; import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data'; import { AccessoryButton } from '@grafana/experimental'; +import { IntervalInput } from '@grafana/o11y-ds-frontend'; import { Collapse, HorizontalGroup, Icon, InlineField, InlineFieldRow, Select, Tooltip, useStyles2 } from '@grafana/ui'; -import { IntervalInput } from 'app/core/components/IntervalInput/IntervalInput'; import { defaultFilters, randomId, SearchProps, Tag } from '../../../useSearch'; import SearchBarInput from '../../common/SearchBarInput'; @@ -439,24 +439,26 @@ export const SpanFilters = memo((props: SpanFilterProps) => { value={tag.value} /> </span> - <AccessoryButton - aria-label="Remove tag" - variant="secondary" - icon="times" - onClick={() => removeTag(tag.id)} - title="Remove tag" - /> - <span className={styles.addTag}> - {search?.tags?.length && i === search.tags.length - 1 && ( + {(tag.key || tag.value || search.tags.length > 1) && ( + <AccessoryButton + aria-label="Remove tag" + variant="secondary" + icon="times" + onClick={() => removeTag(tag.id)} + tooltip="Remove tag" + /> + )} + {(tag.key || tag.value) && i === search.tags.length - 1 && ( + <span className={styles.addTag}> <AccessoryButton aria-label="Add tag" variant="secondary" icon="plus" onClick={addTag} - title="Add tag" + tooltip="Add tag" /> - )} - </span> + </span> + )} </HorizontalGroup> </div> ))} @@ -508,9 +510,9 @@ const getStyles = (theme: GrafanaTheme2) => { display: 'flex', justifyContent: 'space-between', }), - addTag: css` - margin: 0 0 0 10px; - `, + addTag: css({ + marginLeft: theme.spacing(1), + }), intervalInput: css` margin: 0 -4px 0 0; `, diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/index.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/index.tsx index f7c05fa723a34..64595920cf5c4 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/index.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/index.tsx @@ -12,18 +12,28 @@ // See the License for the specific language governing permissions and // limitations under the License. -import cx from 'classnames'; +import { css } from '@emotion/css'; import memoizeOne from 'memoize-one'; import * as React from 'react'; import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from '../../index'; import { TraceSpan, Trace } from '../../types'; -import { ubPb2, ubPx2, ubRelative } from '../../uberUtilityStyles'; import CanvasSpanGraph from './CanvasSpanGraph'; import TickLabels from './TickLabels'; import ViewingLayer from './ViewingLayer'; +const getStyles = () => { + return { + container: css({ + padding: '0 0.5rem 0.5rem 0.5rem', + }), + canvasContainer: css({ + position: 'relative', + }), + }; +}; + const DEFAULT_HEIGHT = 60; export const TIMELINE_TICK_INTERVAL = 4; @@ -62,15 +72,17 @@ export default class SpanGraph extends React.PureComponent<SpanGraphProps> { render() { const { height, trace, viewRange, updateNextViewRangeTime, updateViewRangeTime } = this.props; + const styles = getStyles(); + if (!trace) { return <div />; } const items = memoizedGetitems(trace); return ( - <div className={cx(ubPb2, ubPx2)}> + <div className={styles.container}> <TickLabels numTicks={TIMELINE_TICK_INTERVAL} duration={trace.duration} /> - <div className={ubRelative}> + <div className={styles.canvasContainer}> <CanvasSpanGraph valueWidth={trace.duration} items={items} /> <ViewingLayer viewRange={viewRange} diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx index dc310cc4bcac7..a39672f965264 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx @@ -202,30 +202,34 @@ const getNewStyles = (theme: GrafanaTheme2) => { color: unset; } `, - header: css` - label: TracePageHeader; - background-color: ${theme.colors.background.primary}; - padding: 0.5em 0 0 0; - position: sticky; - top: 0; - z-index: 5; - `, - titleRow: css` - align-items: flex-start; - display: flex; - padding: 0 8px; - `, - title: css` - color: inherit; - flex: 1; - font-size: 1.7em; - line-height: 1em; - `, - subtitle: css` - flex: 1; - line-height: 1em; - margin: -0.5em 0.5em 0.75em 0.5em; - `, + header: css({ + label: 'TracePageHeader', + backgroundColor: theme.colors.background.primary, + padding: '0.5em 0 0 0', + position: 'sticky', + top: 0, + zIndex: 5, + textAlign: 'left', + }), + titleRow: css({ + alignItems: 'flex-start', + display: 'flex', + padding: '0 8px', + flexWrap: 'wrap', + }), + title: css({ + color: 'inherit', + flex: 1, + fontSize: '1.7em', + lineHeight: '1em', + marginBottom: 0, + minWidth: '200px', + }), + subtitle: css({ + flex: 1, + lineHeight: '1em', + margin: '-0.5em 0.5em 0.75em 0.5em', + }), tag: css` margin: 0 0.5em 0 0; `, diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBar.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBar.tsx index a04f95c3006be..4f7e4707a421f 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBar.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBar.tsx @@ -215,7 +215,7 @@ function SpanBar({ placement="top" content={ <div> - A segment on the <em>critical path</em> of the overall trace/request/workflow. + A segment on the <em>critical path</em> of the overall trace / request / workflow. </div> } > diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.test.tsx index 6d43873cadf50..d4a76ca054b17 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.test.tsx @@ -16,7 +16,8 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { NONE, DURATION, TAG } from '../settings/SpanBarSettings'; +import { DURATION, NONE, TAG } from '@grafana/o11y-ds-frontend'; + import { SpanLinkDef, TraceSpan } from '../types'; import SpanBarRow, { SpanBarRowProps } from './SpanBarRow'; diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx index cd4e3cea4296a..cc864bd6ac8cf 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx @@ -17,10 +17,10 @@ import cx from 'classnames'; import * as React from 'react'; import { GrafanaTheme2, TraceKeyValuePair } from '@grafana/data'; +import { DURATION, NONE, TAG } from '@grafana/o11y-ds-frontend'; import { Icon, stylesFactory, withTheme2 } from '@grafana/ui'; import { autoColor } from '../Theme'; -import { DURATION, NONE, TAG } from '../settings/SpanBarSettings'; import { SpanBarOptions, SpanLinkFunc, TraceSpan, TNil, CriticalPathSection } from '../types'; import SpanBar from './SpanBar'; diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx index dfc31d55f8d8b..d03bbb0fdb04d 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx @@ -21,13 +21,17 @@ import { Icon, useStyles2 } from '@grafana/ui'; import { autoColor } from '../../Theme'; import { TraceKeyValuePair, TraceLink, TNil } from '../../types'; -import { uAlignIcon, uTxEllipsis } from '../../uberUtilityStyles'; import * as markers from './AccordianKeyValues.markers'; import KeyValuesTable from './KeyValuesTable'; +import { alignIcon } from '.'; + export const getStyles = (theme: GrafanaTheme2) => { return { + container: css({ + textOverflow: 'ellipsis', + }), header: css` label: header; cursor: pointer; @@ -125,7 +129,7 @@ export default function AccordianKeyValues(props: AccordianKeyValuesProps) { const { className, data, highContrast, interactive, isOpen, label, linksGetter, onToggle } = props; const isEmpty = !Array.isArray(data) || !data.length; const styles = useStyles2(getStyles); - const iconCls = cx(uAlignIcon, { [styles.emptyIcon]: isEmpty }); + const iconCls = cx(alignIcon, { [styles.emptyIcon]: isEmpty }); let arrow: React.ReactNode | null = null; let headerProps: {} | null = null; if (interactive) { @@ -142,7 +146,7 @@ export default function AccordianKeyValues(props: AccordianKeyValuesProps) { } return ( - <div className={cx(className, uTxEllipsis)}> + <div className={cx(className, styles.container)}> <div className={cx(styles.header, { [styles.headerEmpty]: isEmpty, diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx index 2eb8abefad3fd..e627d199be2eb 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx @@ -22,11 +22,12 @@ import { Icon, useStyles2 } from '@grafana/ui'; import { autoColor } from '../../Theme'; import { TNil } from '../../types'; import { TraceLog, TraceKeyValuePair, TraceLink } from '../../types/trace'; -import { uAlignIcon, ubMb1 } from '../../uberUtilityStyles'; import { formatDuration } from '../utils'; import AccordianKeyValues from './AccordianKeyValues'; +import { alignIcon } from '.'; + const getStyles = (theme: GrafanaTheme2) => { return { AccordianLogs: css` @@ -55,6 +56,9 @@ const getStyles = (theme: GrafanaTheme2) => { label: AccordianLogsFooter; color: ${autoColor(theme, '#999')}; `, + AccordianKeyValuesItem: css({ + marginBottom: theme.spacing(0.5), + }), }; }; @@ -76,7 +80,7 @@ export default function AccordianLogs(props: AccordianLogsProps) { let headerProps: {} | null = null; if (interactive) { arrow = isOpen ? ( - <Icon name={'angle-down'} className={uAlignIcon} /> + <Icon name={'angle-down'} className={alignIcon} /> ) : ( <Icon name={'angle-right'} className="u-align-icon" /> ); @@ -100,7 +104,7 @@ export default function AccordianLogs(props: AccordianLogsProps) { <AccordianKeyValues // `i` is necessary in the key because timestamps can repeat key={`${log.timestamp}-${i}`} - className={i < logs.length - 1 ? ubMb1 : null} + className={i < logs.length - 1 ? styles.AccordianKeyValuesItem : null} data={log.fields || []} highContrast interactive={interactive} diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx index 66bcaceece605..dfc7ac11f09c9 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx @@ -20,11 +20,12 @@ import { Icon, useStyles2 } from '@grafana/ui'; import { autoColor } from '../../Theme'; import { TraceSpanReference } from '../../types/trace'; -import { uAlignIcon, ubMb1 } from '../../uberUtilityStyles'; import ReferenceLink from '../../url/ReferenceLink'; import AccordianKeyValues from './AccordianKeyValues'; +import { alignIcon } from '.'; + const getStyles = (theme: GrafanaTheme2) => { return { AccordianReferenceItem: css` @@ -59,6 +60,9 @@ const getStyles = (theme: GrafanaTheme2) => { label: AccordianReferencesFooter; color: ${autoColor(theme, '#999')}; `, + AccordianKeyValuesItem: css({ + marginBottom: theme.spacing(0.5), + }), ReferencesList: css` background: #fff; border: 1px solid #ddd; @@ -166,7 +170,7 @@ export function References(props: ReferenceItemProps) { {!!reference.tags?.length && ( <div className={styles.AccordianKeyValues}> <AccordianKeyValues - className={i < data.length - 1 ? ubMb1 : null} + className={i < data.length - 1 ? styles.AccordianKeyValuesItem : null} data={reference.tags || []} highContrast interactive={interactive} @@ -198,9 +202,9 @@ const AccordianReferences = ({ let headerProps: {} | null = null; if (interactive) { arrow = isOpen ? ( - <Icon name={'angle-down'} className={uAlignIcon} /> + <Icon name={'angle-down'} className={alignIcon} /> ) : ( - <Icon name={'angle-right'} className={uAlignIcon} /> + <Icon name={'angle-right'} className={alignIcon} /> ); HeaderComponent = 'a'; headerProps = { diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianText.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianText.tsx index bcc2d6fa886d9..07ffdb0854984 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianText.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianText.tsx @@ -21,11 +21,12 @@ import { Icon, useStyles2 } from '@grafana/ui'; import { autoColor } from '../../Theme'; import { TNil } from '../../types'; -import { uAlignIcon } from '../../uberUtilityStyles'; import { getStyles as getAccordianKeyValuesStyles } from './AccordianKeyValues'; import TextList from './TextList'; +import { alignIcon } from '.'; + const getStyles = (theme: GrafanaTheme2) => { return { header: css` @@ -70,7 +71,7 @@ export default function AccordianText(props: AccordianTextProps) { } = props; const isEmpty = !Array.isArray(data) || !data.length; const accordianKeyValuesStyles = useStyles2(getAccordianKeyValuesStyles); - const iconCls = cx(uAlignIcon, { [accordianKeyValuesStyles.emptyIcon]: isEmpty }); + const iconCls = cx(alignIcon, { [accordianKeyValuesStyles.emptyIcon]: isEmpty }); let arrow: React.ReactNode | null = null; let headerProps: {} | null = null; if (interactive) { diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx index 0860b1a62e6fe..6e80b457e2755 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx @@ -22,7 +22,6 @@ import { Icon, useStyles2 } from '@grafana/ui'; import { autoColor } from '../../Theme'; import CopyIcon from '../../common/CopyIcon'; import { TraceKeyValuePair, TraceLink, TNil } from '../../types'; -import { ubInlineBlock, uWidth100 } from '../../uberUtilityStyles'; import jsonMarkup from './jsonMarkup'; @@ -38,6 +37,9 @@ export const getStyles = (theme: GrafanaTheme2) => { max-height: 450px; overflow: auto; `, + table: css({ + width: '100%', + }), body: css` label: body; vertical-align: baseline; @@ -70,6 +72,9 @@ export const getStyles = (theme: GrafanaTheme2) => { vertical-align: middle; font-weight: bold; `, + jsonTable: css({ + display: 'inline-block', + }), }; }; @@ -109,13 +114,13 @@ export default function KeyValuesTable(props: KeyValuesTableProps) { const styles = useStyles2(getStyles); return ( <div className={cx(styles.KeyValueTable)} data-testid="KeyValueTable"> - <table className={uWidth100}> + <table className={styles.table}> <tbody className={styles.body}> {data.map((row, i) => { const markup = { __html: jsonMarkup(parseIfComplexJson(row.value)), }; - const jsonTable = <div className={ubInlineBlock} dangerouslySetInnerHTML={markup} />; + const jsonTable = <div className={styles.jsonTable} dangerouslySetInnerHTML={markup} />; const links = linksGetter ? linksGetter(data, i) : null; let valueMarkup; if (links && links.length) { diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx index dbe28555c267f..b8c435175b096 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx @@ -13,15 +13,21 @@ import { TimeZone, } from '@grafana/data'; import { FlameGraph } from '@grafana/flamegraph'; -import { config } from '@grafana/runtime'; +import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend'; +import { config, DataSourceWithBackend, getTemplateSrv } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; -import { TraceToProfilesOptions } from 'app/core/components/TraceToProfiles/TraceToProfilesSettings'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { PyroscopeQueryType } from 'app/plugins/datasource/grafana-pyroscope-datasource/dataquery.gen'; -import { PyroscopeDataSource } from 'app/plugins/datasource/grafana-pyroscope-datasource/datasource'; import { Query } from 'app/plugins/datasource/grafana-pyroscope-datasource/types'; -import { defaultProfilingKeys, getFormattedTags, pyroscopeProfileIdTagKey } from '../../../createSpanLink'; +import { + defaultProfilingKeys, + getFormattedTags, + pyroscopeProfileIdTagKey, + scopedVarsFromSpan, + scopedVarsFromTags, + scopedVarsFromTrace, +} from '../../../createSpanLink'; import { TraceSpan } from '../../types/trace'; import { TraceFlameGraphs } from '.'; @@ -33,10 +39,21 @@ export type SpanFlameGraphProps = { traceFlameGraphs: TraceFlameGraphs; setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void; setRedrawListView: (redraw: {}) => void; + traceDuration: number; + traceName: string; }; export default function SpanFlameGraph(props: SpanFlameGraphProps) { - const { span, traceToProfilesOptions, timeZone, traceFlameGraphs, setTraceFlameGraphs, setRedrawListView } = props; + const { + span, + traceToProfilesOptions, + timeZone, + traceFlameGraphs, + setTraceFlameGraphs, + setRedrawListView, + traceDuration, + traceName, + } = props; const [sizeRef, { height: containerHeight }] = useMeasure<HTMLDivElement>(); const styles = useStyles2(getStyles); @@ -61,7 +78,7 @@ export default function SpanFlameGraph(props: SpanFlameGraphProps) { const getFlameGraphData = async (request: DataQueryRequest<Query>, datasourceUid: string) => { const ds = await getDatasourceSrv().get(datasourceUid); - if (ds instanceof PyroscopeDataSource) { + if (ds instanceof DataSourceWithBackend) { const result = await lastValueFrom(ds.query(request)); const frame = result.data.find((x: DataFrame) => { return x.name === 'response'; @@ -80,7 +97,12 @@ export default function SpanFlameGraph(props: SpanFlameGraphProps) { ) => { let labelSelector = '{}'; if (traceToProfilesOptions.customQuery && traceToProfilesOptions.query) { - labelSelector = traceToProfilesOptions.query; + const scopedVars = { + ...scopedVarsFromTrace(traceDuration, traceName, span.traceID), + ...scopedVarsFromSpan(span), + ...scopedVarsFromTags(span, traceToProfilesOptions), + }; + labelSelector = getTemplateSrv().replace(traceToProfilesOptions.query, scopedVars); } else { const tags = traceToProfilesOptions.tags && traceToProfilesOptions.tags.length > 0 @@ -119,11 +141,11 @@ export default function SpanFlameGraph(props: SpanFlameGraphProps) { setTraceFlameGraphs({ ...traceFlameGraphs, [profileTagValue]: flameGraph }); } }, - [getTimeRangeForProfile, profileTagValue, setTraceFlameGraphs, timeZone, traceFlameGraphs] + [getTimeRangeForProfile, profileTagValue, setTraceFlameGraphs, timeZone, traceDuration, traceFlameGraphs, traceName] ); useEffect(() => { - if (config.featureToggles.traceToProfiles && !Object.keys(traceFlameGraphs).includes(profileTagValue)) { + if (!Object.keys(traceFlameGraphs).includes(profileTagValue)) { let profilesDataSourceSettings: DataSourceInstanceSettings<DataSourceJsonData> | undefined; if (traceToProfilesOptions && traceToProfilesOptions?.datasourceUid) { profilesDataSourceSettings = getDatasourceSrv().getInstanceSettings(traceToProfilesOptions.datasourceUid); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.test.tsx index f937dcd3810d8..2594bdec00bcd 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.test.tsx @@ -20,7 +20,7 @@ import React from 'react'; import { createDataFrame, DataSourceInstanceSettings } from '@grafana/data'; import { data } from '@grafana/flamegraph'; -import { config, DataSourceSrv, setDataSourceSrv } from '@grafana/runtime'; +import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime'; import { pyroscopeProfileIdTagKey } from '../../../createSpanLink'; import traceGenerator from '../../demo/trace-generators'; @@ -242,8 +242,6 @@ describe('<SpanDetail>', () => { }); it('renders the flame graph', async () => { - config.featureToggles.tracesEmbeddedFlameGraph = true; - render(<SpanDetail {...(props as unknown as SpanDetailProps)} />); await act(async () => { expect(screen.getByText(/16.5 Bil/)).toBeInTheDocument(); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx index 691e788f77ecb..87ba15592c896 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx @@ -17,21 +17,20 @@ import { SpanStatusCode } from '@opentelemetry/api'; import cx from 'classnames'; import React from 'react'; -import { DataFrame, dateTimeFormat, GrafanaTheme2, IconName, LinkModel, TimeZone } from '@grafana/data'; +import { DataFrame, dateTimeFormat, GrafanaTheme2, IconName, LinkModel } from '@grafana/data'; +import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend'; import { config, locationService, reportInteraction } from '@grafana/runtime'; -import { DataLinkButton, Icon, TextArea, useStyles2 } from '@grafana/ui'; -import { TraceToProfilesOptions } from 'app/core/components/TraceToProfiles/TraceToProfilesSettings'; -import { RelatedProfilesTitle } from 'app/plugins/datasource/tempo/resultTransformer'; +import { TimeZone } from '@grafana/schema'; +import { DataLinkButton, Divider, Icon, TextArea, useStyles2 } from '@grafana/ui'; +import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer'; import { pyroscopeProfileIdTagKey } from '../../../createSpanLink'; import { autoColor } from '../../Theme'; -import { Divider } from '../../common/Divider'; import LabeledList from '../../common/LabeledList'; import { KIND, LIBRARY_NAME, LIBRARY_VERSION, STATUS, STATUS_MESSAGE, TRACE_STATE } from '../../constants/span'; import { SpanLinkFunc, TNil } from '../../types'; import { SpanLinkDef, SpanLinkType } from '../../types/links'; import { TraceKeyValuePair, TraceLink, TraceLog, TraceSpan, TraceSpanReference } from '../../types/trace'; -import { uAlignIcon, ubM0, ubMb1, ubMy1, ubTxRightAlign } from '../../uberUtilityStyles'; import { formatDuration } from '../utils'; import AccordianKeyValues from './AccordianKeyValues'; @@ -53,6 +52,12 @@ const getStyles = (theme: GrafanaTheme2) => { listWrapper: css` overflow: hidden; `, + list: css({ + textAlign: 'right', + }), + operationName: css({ + margin: 0, + }), debugInfo: css` label: debugInfo; display: block; @@ -99,6 +104,9 @@ const getStyles = (theme: GrafanaTheme2) => { label: AccordianWarningsLabel; color: ${autoColor(theme, '#d36c08')}; `, + AccordianKeyValuesItem: css({ + marginBottom: theme.spacing(0.5), + }), Textarea: css` word-break: break-all; white-space: pre; @@ -109,6 +117,10 @@ const getStyles = (theme: GrafanaTheme2) => { }; }; +export const alignIcon = css({ + margin: '-0.2rem 0.25rem 0 0', +}); + export type TraceFlameGraphs = { [spanID: string]: DataFrame; }; @@ -124,6 +136,8 @@ export type SpanDetailProps = { timeZone: TimeZone; tagsToggle: (spanID: string) => void; traceStartTime: number; + traceDuration: number; + traceName: string; warningsToggle: (spanID: string) => void; stackTracesToggle: (spanID: string) => void; referenceItemToggle: (spanID: string, reference: TraceSpanReference) => void; @@ -147,6 +161,8 @@ export default function SpanDetail(props: SpanDetailProps) { span, tagsToggle, traceStartTime, + traceDuration, + traceName, warningsToggle, stackTracesToggle, referencesToggle, @@ -305,14 +321,14 @@ export default function SpanDetail(props: SpanDetailProps) { return ( <div data-testid="span-detail-component"> <div className={styles.header}> - <h2 className={cx(ubM0)}>{operationName}</h2> + <h2 className={styles.operationName}>{operationName}</h2> <div className={styles.listWrapper}> - <LabeledList className={ubTxRightAlign} divider={true} items={overviewItems} /> + <LabeledList className={styles.list} divider={true} items={overviewItems} /> </div> </div> <span style={{ marginRight: '10px' }}>{logLinkButton}</span> {profileLinkButton} - <Divider className={ubMy1} type={'horizontal'} /> + <Divider spacing={1} /> <div> <div> <AccordianKeyValues @@ -324,7 +340,7 @@ export default function SpanDetail(props: SpanDetailProps) { /> {process.tags && ( <AccordianKeyValues - className={ubMb1} + className={styles.AccordianKeyValuesItem} data={process.tags} label="Resource Attributes" linksGetter={linksGetter} @@ -392,17 +408,18 @@ export default function SpanDetail(props: SpanDetailProps) { createFocusSpanLink={createFocusSpanLink} /> )} - {config.featureToggles.tracesEmbeddedFlameGraph && - span.tags.some((tag) => tag.key === pyroscopeProfileIdTagKey) && ( - <SpanFlameGraph - span={span} - timeZone={timeZone} - traceFlameGraphs={traceFlameGraphs} - setTraceFlameGraphs={setTraceFlameGraphs} - traceToProfilesOptions={traceToProfilesOptions} - setRedrawListView={setRedrawListView} - /> - )} + {span.tags.some((tag) => tag.key === pyroscopeProfileIdTagKey) && ( + <SpanFlameGraph + span={span} + timeZone={timeZone} + traceFlameGraphs={traceFlameGraphs} + setTraceFlameGraphs={setTraceFlameGraphs} + traceToProfilesOptions={traceToProfilesOptions} + setRedrawListView={setRedrawListView} + traceDuration={traceDuration} + traceName={traceName} + /> + )} <small className={styles.debugInfo}> {/* TODO: fix keyboard a11y */} {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} @@ -422,7 +439,7 @@ export default function SpanDetail(props: SpanDetailProps) { } }} > - <Icon name={'link'} className={cx(uAlignIcon, styles.LinkIcon)}></Icon> + <Icon name={'link'} className={cx(alignIcon, styles.LinkIcon)}></Icon> </a> <span className={styles.debugLabel} data-label="SpanID:" /> {spanID} </small> diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx index 0ca3064649e13..6ecfb22889167 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx @@ -16,9 +16,10 @@ import { css } from '@emotion/css'; import classNames from 'classnames'; import React from 'react'; -import { GrafanaTheme2, LinkModel, TimeZone } from '@grafana/data'; +import { GrafanaTheme2, LinkModel } from '@grafana/data'; +import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend'; +import { TimeZone } from '@grafana/schema'; import { Button, clearButtonStyles, stylesFactory, withTheme2 } from '@grafana/ui'; -import { TraceToProfilesOptions } from 'app/core/components/TraceToProfiles/TraceToProfilesSettings'; import { autoColor } from '../Theme'; import { SpanLinkFunc } from '../types'; @@ -89,6 +90,8 @@ export type SpanDetailRowProps = { timeZone: TimeZone; tagsToggle: (spanID: string) => void; traceStartTime: number; + traceDuration: number; + traceName: string; hoverIndentGuideIds: Set<string>; addHoverIndentGuideId: (spanID: string) => void; removeHoverIndentGuideId: (spanID: string) => void; @@ -130,6 +133,8 @@ export class UnthemedSpanDetailRow extends React.PureComponent<SpanDetailRowProp timeZone, tagsToggle, traceStartTime, + traceDuration, + traceName, hoverIndentGuideIds, addHoverIndentGuideId, removeHoverIndentGuideId, @@ -180,6 +185,8 @@ export class UnthemedSpanDetailRow extends React.PureComponent<SpanDetailRowProp timeZone={timeZone} tagsToggle={tagsToggle} traceStartTime={traceStartTime} + traceDuration={traceDuration} + traceName={traceName} createSpanLink={createSpanLink} focusedSpanId={focusedSpanId} createFocusSpanLink={createFocusSpanLink} diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx index 6e58221feb41c..d9d9f2c98aec3 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx @@ -16,12 +16,10 @@ import { css } from '@emotion/css'; import cx from 'classnames'; import * as React from 'react'; -import { stylesFactory } from '@grafana/ui'; - import { TNil } from '../../types'; import DraggableManager, { DraggableBounds, DraggingUpdate } from '../../utils/DraggableManager'; -export const getStyles = stylesFactory(() => { +export const getStyles = () => { return { TimelineColumnResizer: css` left: 0; @@ -95,7 +93,7 @@ export const getStyles = stylesFactory(() => { } `, }; -}); +}; export type TimelineColumnResizerProps = { min: number; diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx index 12af287f3ed66..67534003dc6b0 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx @@ -13,14 +13,12 @@ // limitations under the License. import { css } from '@emotion/css'; -import cx from 'classnames'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import { autoColor } from '../../Theme'; -import { ubFlex, ubPx2 } from '../../uberUtilityStyles'; import Ticks from '../Ticks'; import TimelineRow from '../TimelineRow'; import { TUpdateViewRangeTimeFunction, ViewRangeTime, ViewRangeTimeUpdate } from '../types'; @@ -52,6 +50,9 @@ const getStyles = (theme: GrafanaTheme2) => { TimelineHeaderWrapper: css` label: TimelineHeaderWrapper; align-items: center; + display: flex; + padding-left: ${theme.spacing(1)}; + padding-right: ${theme.spacing(1)}; `, }; }; @@ -90,7 +91,7 @@ export default function TimelineHeaderRow(props: TimelineHeaderRowProps) { const styles = useStyles2(getStyles); return ( <TimelineRow className={styles.TimelineHeaderRow} data-testid="TimelineHeaderRow"> - <TimelineRow.Cell className={cx(ubFlex, ubPx2, styles.TimelineHeaderWrapper)} width={nameColumnWidth}> + <TimelineRow.Cell className={styles.TimelineHeaderWrapper} width={nameColumnWidth}> <h4 className={styles.TimelineHeaderRowTitle}>Service & Operation</h4> <TimelineCollapser onCollapseAll={onCollapseAll} diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineRow.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineRow.tsx index 7d6deba862318..c957f08e7d6e5 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineRow.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineRow.tsx @@ -18,15 +18,16 @@ import * as React from 'react'; import { useStyles2 } from '@grafana/ui'; -import { ubRelative } from '../uberUtilityStyles'; - const getStyles = () => { return { - flexRow: css` - display: flex; - flex: 0 1 auto; - flex-direction: row; - `, + row: css({ + display: 'flex', + flex: '0 1 auto', + flexDirection: 'row', + }), + rowCell: css({ + position: 'relative', + }), }; }; @@ -46,7 +47,7 @@ export default function TimelineRow(props: TTimelineRowProps) { const { children, className = '', ...rest } = props; const styles = useStyles2(getStyles); return ( - <div className={cx(styles.flexRow, className)} {...rest}> + <div className={cx(styles.row, className)} {...rest}> {children} </div> ); @@ -60,8 +61,9 @@ export function TimelineRowCell(props: TimelineRowCellProps) { const { children, className = '', width, style, ...rest } = props; const widthPercent = `${width * 100}%`; const mergedStyle = { ...style, flexBasis: widthPercent, maxWidth: widthPercent }; + const styles = useStyles2(getStyles); return ( - <div className={cx(ubRelative, className)} style={mergedStyle} data-testid="TimelineRowCell" {...rest}> + <div className={cx(styles.rowCell, className)} style={mergedStyle} data-testid="TimelineRowCell" {...rest}> {children} </div> ); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx index 2d69ccf38d222..d1101c49ffa69 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx @@ -18,10 +18,11 @@ import memoizeOne from 'memoize-one'; import * as React from 'react'; import { RefObject } from 'react'; -import { GrafanaTheme2, LinkModel, TimeZone } from '@grafana/data'; +import { GrafanaTheme2, LinkModel } from '@grafana/data'; +import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend'; import { config, reportInteraction } from '@grafana/runtime'; +import { TimeZone } from '@grafana/schema'; import { stylesFactory, withTheme2, ToolbarButton } from '@grafana/ui'; -import { TraceToProfilesOptions } from 'app/core/components/TraceToProfiles/TraceToProfilesSettings'; import { PEER_SERVICE } from '../constants/tag-keys'; import { CriticalPathSection, SpanBarOptions, SpanLinkFunc, TNil } from '../types'; @@ -586,6 +587,8 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra timeZone={timeZone} tagsToggle={detailTagsToggle} traceStartTime={trace.startTime} + traceDuration={trace.duration} + traceName={trace.traceName} hoverIndentGuideIds={hoverIndentGuideIds} addHoverIndentGuideId={addHoverIndentGuideId} removeHoverIndentGuideId={removeHoverIndentGuideId} diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx index f67e6542965ce..495fec6f4c47b 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx @@ -15,14 +15,14 @@ import { css } from '@emotion/css'; import React, { RefObject } from 'react'; -import { GrafanaTheme2, LinkModel, TimeZone } from '@grafana/data'; +import { GrafanaTheme2, LinkModel } from '@grafana/data'; +import { SpanBarOptions, TraceToProfilesOptions } from '@grafana/o11y-ds-frontend'; import { config, reportInteraction } from '@grafana/runtime'; +import { TimeZone } from '@grafana/schema'; import { stylesFactory, withTheme2 } from '@grafana/ui'; -import { TraceToProfilesOptions } from 'app/core/components/TraceToProfiles/TraceToProfilesSettings'; import { autoColor } from '../Theme'; import { merge as mergeShortcuts } from '../keyboard-shortcuts'; -import { SpanBarOptions } from '../settings/SpanBarSettings'; import { CriticalPathSection, SpanLinkFunc, TNil } from '../types'; import TTraceTimeline from '../types/TTraceTimeline'; import { TraceSpan, Trace, TraceLog, TraceKeyValuePair, TraceLink, TraceSpanReference } from '../types/trace'; diff --git a/public/app/features/explore/TraceView/components/common/Divider.tsx b/public/app/features/explore/TraceView/components/common/Divider.tsx deleted file mode 100644 index 9eab10a61e29f..0000000000000 --- a/public/app/features/explore/TraceView/components/common/Divider.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { css, cx } from '@emotion/css'; -import React from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2 } from '@grafana/ui'; - -import { autoColor } from '../Theme'; - -const getStyles = (theme: GrafanaTheme2) => { - return { - Divider: css` - background: ${autoColor(theme, '#ddd')}; - `, - - DividerVertical: css` - label: DividerVertical; - display: inline-block; - width: 1px; - height: 0.9em; - margin: 0 8px; - vertical-align: middle; - `, - - DividerHorizontal: css` - label: DividerHorizontal; - display: block; - height: 1px; - width: 100%; - margin: 24px 0; - clear: both; - vertical-align: middle; - position: relative; - top: -0.06em; - `, - }; -}; - -interface Props { - className?: string; - style?: React.CSSProperties; - type?: 'vertical' | 'horizontal'; -} -export function Divider({ className, style, type }: Props) { - const styles = useStyles2(getStyles); - return ( - <div - style={style} - className={cx( - styles.Divider, - type === 'horizontal' ? styles.DividerHorizontal : styles.DividerVertical, - className - )} - /> - ); -} diff --git a/public/app/features/explore/TraceView/components/model/link-patterns.test.ts b/public/app/features/explore/TraceView/components/model/link-patterns.test.ts index d31063ffc7c6d..e3d5a98ffe014 100644 --- a/public/app/features/explore/TraceView/components/model/link-patterns.test.ts +++ b/public/app/features/explore/TraceView/components/model/link-patterns.test.ts @@ -12,17 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Trace, TraceLink, TraceSpan } from '../types'; +import { Trace } from '../types'; import { processTemplate, createTestFunction, - getParameterInArray, - getParameterInAncestor, processLinkPattern, ProcessedLinkPattern, - computeLinks, - createGetLinks, computeTraceLink, } from './link-patterns'; @@ -67,7 +63,7 @@ describe('processTemplate()', () => { expect(() => processTemplate( { - template: (data: { [key: string]: any }) => `a${data.b}c`, + template: (data: { [key: string]: unknown }) => `a${data.b}c`, }, (a) => a ) @@ -153,179 +149,6 @@ describe('createTestFunction()', () => { }); }); -describe('getParameterInArray()', () => { - const data = [ - { key: 'mykey', value: 'ok' }, - { key: 'otherkey', value: 'v' }, - ]; - - it('returns an entry that is present', () => { - expect(getParameterInArray('mykey', data)).toBe(data[0]); - expect(getParameterInArray('otherkey', data)).toBe(data[1]); - }); - - it('returns undefined when the entry cannot be found', () => { - expect(getParameterInArray('myotherkey', data)).toBeUndefined(); - }); - - it('returns undefined when there is no array', () => { - expect(getParameterInArray('otherkey')).toBeUndefined(); - expect(getParameterInArray('otherkey', null)).toBeUndefined(); - }); -}); - -describe('getParameterInAncestor()', () => { - const spans = [ - { - depth: 0, - process: { - tags: [ - { key: 'a', value: 'a7' }, - { key: 'b', value: 'b7' }, - { key: 'c', value: 'c7' }, - { key: 'd', value: 'd7' }, - { key: 'e', value: 'e7' }, - { key: 'f', value: 'f7' }, - { key: 'g', value: 'g7' }, - { key: 'h', value: 'h7' }, - ], - }, - tags: [ - { key: 'a', value: 'a6' }, - { key: 'b', value: 'b6' }, - { key: 'c', value: 'c6' }, - { key: 'd', value: 'd6' }, - { key: 'e', value: 'e6' }, - { key: 'f', value: 'f6' }, - { key: 'g', value: 'g6' }, - ], - }, - { - depth: 1, - process: { - tags: [ - { key: 'a', value: 'a5' }, - { key: 'b', value: 'b5' }, - { key: 'c', value: 'c5' }, - { key: 'd', value: 'd5' }, - { key: 'e', value: 'e5' }, - { key: 'f', value: 'f5' }, - ], - }, - tags: [ - { key: 'a', value: 'a4' }, - { key: 'b', value: 'b4' }, - { key: 'c', value: 'c4' }, - { key: 'd', value: 'd4' }, - { key: 'e', value: 'e4' }, - ], - }, - { - depth: 1, - process: { - tags: [ - { key: 'a', value: 'a3' }, - { key: 'b', value: 'b3' }, - { key: 'c', value: 'c3' }, - { key: 'd', value: 'd3' }, - ], - }, - tags: [ - { key: 'a', value: 'a2' }, - { key: 'b', value: 'b2' }, - { key: 'c', value: 'c2' }, - ], - }, - { - depth: 2, - process: { - tags: [ - { key: 'a', value: 'a1' }, - { key: 'b', value: 'b1' }, - ], - }, - tags: [{ key: 'a', value: 'a0' }], - }, - ] as TraceSpan[]; - - spans[1].references = [ - { - spanID: 's1', - traceID: 't2', - refType: 'CHILD_OF', - span: spans[0], - }, - ]; - spans[2].references = [ - { - spanID: 's1', - traceID: 't2', - refType: 'CHILD_OF', - span: spans[0], - }, - ]; - spans[3].references = [ - { - spanID: 's1', - traceID: 't2', - refType: 'CHILD_OF', - span: spans[2], - }, - ]; - - it('uses current span tags', () => { - expect(getParameterInAncestor('a', spans[3])).toEqual({ key: 'a', value: 'a0' }); - expect(getParameterInAncestor('a', spans[2])).toEqual({ key: 'a', value: 'a2' }); - expect(getParameterInAncestor('a', spans[1])).toEqual({ key: 'a', value: 'a4' }); - expect(getParameterInAncestor('a', spans[0])).toEqual({ key: 'a', value: 'a6' }); - }); - - it('uses current span process tags', () => { - expect(getParameterInAncestor('b', spans[3])).toEqual({ key: 'b', value: 'b1' }); - expect(getParameterInAncestor('d', spans[2])).toEqual({ key: 'd', value: 'd3' }); - expect(getParameterInAncestor('f', spans[1])).toEqual({ key: 'f', value: 'f5' }); - expect(getParameterInAncestor('h', spans[0])).toEqual({ key: 'h', value: 'h7' }); - }); - - it('uses parent span tags', () => { - expect(getParameterInAncestor('c', spans[3])).toEqual({ key: 'c', value: 'c2' }); - expect(getParameterInAncestor('e', spans[2])).toEqual({ key: 'e', value: 'e6' }); - expect(getParameterInAncestor('f', spans[2])).toEqual({ key: 'f', value: 'f6' }); - expect(getParameterInAncestor('g', spans[2])).toEqual({ key: 'g', value: 'g6' }); - expect(getParameterInAncestor('g', spans[1])).toEqual({ key: 'g', value: 'g6' }); - }); - - it('uses parent span process tags', () => { - expect(getParameterInAncestor('d', spans[3])).toEqual({ key: 'd', value: 'd3' }); - expect(getParameterInAncestor('h', spans[2])).toEqual({ key: 'h', value: 'h7' }); - expect(getParameterInAncestor('h', spans[1])).toEqual({ key: 'h', value: 'h7' }); - }); - - it('uses grand-parent span tags', () => { - expect(getParameterInAncestor('e', spans[3])).toEqual({ key: 'e', value: 'e6' }); - expect(getParameterInAncestor('f', spans[3])).toEqual({ key: 'f', value: 'f6' }); - expect(getParameterInAncestor('g', spans[3])).toEqual({ key: 'g', value: 'g6' }); - }); - - it('uses grand-parent process tags', () => { - expect(getParameterInAncestor('h', spans[3])).toEqual({ key: 'h', value: 'h7' }); - }); - - it('returns undefined when the entry cannot be found', () => { - expect(getParameterInAncestor('i', spans[3])).toBeUndefined(); - }); - - it('does not break if some tags are not defined', () => { - const spansWithUndefinedTags = [ - { - depth: 0, - process: {}, - }, - ] as TraceSpan[]; - expect(getParameterInAncestor('a', spansWithUndefinedTags[0])).toBeUndefined(); - }); -}); - describe('computeTraceLink()', () => { const linkPatterns = [ { @@ -359,95 +182,3 @@ describe('computeTraceLink()', () => { ]); }); }); - -describe('computeLinks()', () => { - const linkPatterns = [ - { - type: 'tags', - key: 'myKey', - url: 'http://example.com/?myKey=#{myKey}', - text: 'first link (#{myKey})', - }, - { - key: 'myOtherKey', - url: 'http://example.com/?myKey=#{myOtherKey}&myKey=#{myKey}', - text: 'second link (#{myOtherKey})', - }, - ].map(processLinkPattern) as ProcessedLinkPattern[]; - - const spans = [ - { depth: 0, process: {}, tags: [{ key: 'myKey', value: 'valueOfMyKey' }] }, - { depth: 1, process: {}, logs: [{ fields: [{ key: 'myOtherKey', value: 'valueOfMy+Other+Key' }] }] }, - ] as unknown as TraceSpan[]; - spans[1].references = [ - { - spanID: 's1', - traceID: 't2', - refType: 'CHILD_OF', - span: spans[0], - }, - ]; - - it('correctly computes links', () => { - expect(computeLinks(linkPatterns, spans[0], spans[0].tags, 0)).toEqual([ - { - url: 'http://example.com/?myKey=valueOfMyKey', - text: 'first link (valueOfMyKey)', - }, - ]); - expect(computeLinks(linkPatterns, spans[1], spans[1].logs[0].fields, 0)).toEqual([ - { - url: 'http://example.com/?myKey=valueOfMy%2BOther%2BKey&myKey=valueOfMyKey', - text: 'second link (valueOfMy+Other+Key)', - }, - ]); - }); -}); - -describe('getLinks()', () => { - const linkPatterns = [ - { - key: 'mySpecialKey', - url: 'http://example.com/?mySpecialKey=#{mySpecialKey}', - text: 'special key link (#{mySpecialKey})', - }, - ].map(processLinkPattern) as ProcessedLinkPattern[]; - const template = jest.spyOn(linkPatterns[0]!.url, 'template'); - - const span = { depth: 0, process: {}, tags: [{ key: 'mySpecialKey', value: 'valueOfMyKey' }] } as TraceSpan; - - let cache: WeakMap<object, any>; - - beforeEach(() => { - cache = new WeakMap(); - template.mockClear(); - }); - - it('does not access the cache if there is no link pattern', () => { - cache.get = jest.fn(); - const getLinks = createGetLinks([], cache); - expect(getLinks(span, span.tags, 0)).toEqual([]); - expect(cache.get).not.toHaveBeenCalled(); - }); - - it('returns the result from the cache', () => { - const result: TraceLink[] = []; - cache.set(span.tags[0], result); - const getLinks = createGetLinks(linkPatterns, cache); - expect(getLinks(span, span.tags, 0)).toBe(result); - expect(template).not.toHaveBeenCalled(); - }); - - it('adds the result to the cache', () => { - const getLinks = createGetLinks(linkPatterns, cache); - const result = getLinks(span, span.tags, 0); - expect(template).toHaveBeenCalledTimes(1); - expect(result).toEqual([ - { - url: 'http://example.com/?mySpecialKey=valueOfMyKey', - text: 'special key link (valueOfMyKey)', - }, - ]); - expect(cache.get(span.tags[0])).toBe(result); - }); -}); diff --git a/public/app/features/explore/TraceView/components/model/link-patterns.tsx b/public/app/features/explore/TraceView/components/model/link-patterns.tsx index 930fc54105658..cf52965244dad 100644 --- a/public/app/features/explore/TraceView/components/model/link-patterns.tsx +++ b/public/app/features/explore/TraceView/components/model/link-patterns.tsx @@ -15,11 +15,9 @@ import { uniq as _uniq } from 'lodash'; import memoize from 'lru-memoize'; -import { TraceSpan, TraceLink, TraceKeyValuePair, Trace, TNil } from '../types'; +import { Trace } from '../types'; import { getConfigValue } from '../utils/config/get-config'; -import { getParent } from './span'; - const parameterRegExp = /#\{([^{}]*)\}/g; type ProcessedTemplate = { @@ -119,25 +117,6 @@ export function processLinkPattern(pattern: any): ProcessedLinkPattern | null { } } -export function getParameterInArray(name: string, array?: TraceKeyValuePair[] | TNil) { - if (array) { - return array.find((entry) => entry.key === name); - } - return undefined; -} - -export function getParameterInAncestor(name: string, span: TraceSpan) { - let currentSpan: TraceSpan | TNil = span; - while (currentSpan) { - const result = getParameterInArray(name, currentSpan.tags) || getParameterInArray(name, currentSpan.process.tags); - if (result) { - return result; - } - currentSpan = getParent(currentSpan); - } - return undefined; -} - function callTemplate(template: ProcessedTemplate, data: any) { return template.template(data); } @@ -173,70 +152,6 @@ export function computeTraceLink(linkPatterns: ProcessedLinkPattern[], trace: Tr return result; } -export function computeLinks( - linkPatterns: ProcessedLinkPattern[], - span: TraceSpan, - items: TraceKeyValuePair[], - itemIndex: number -) { - const item = items[itemIndex]; - let type = 'logs'; - const processTags = span.process.tags === items; - if (processTags) { - type = 'process'; - } - const spanTags = span.tags === items; - if (spanTags) { - type = 'tags'; - } - const result: Array<{ url: string; text: string }> = []; - linkPatterns.forEach((pattern) => { - if (pattern.type(type) && pattern.key(item.key) && pattern.value(item.value)) { - const parameterValues: Record<string, any> = {}; - const allParameters = pattern.parameters.every((parameter) => { - let entry = getParameterInArray(parameter, items); - if (!entry && !processTags) { - // do not look in ancestors for process tags because the same object may appear in different places in the hierarchy - // and the cache in getLinks uses that object as a key - entry = getParameterInAncestor(parameter, span); - } - if (entry) { - parameterValues[parameter] = entry.value; - return true; - } - // eslint-disable-next-line no-console - console.warn( - `Skipping link pattern, missing parameter ${parameter} for key ${item.key} in ${type}.`, - pattern.object - ); - return false; - }); - if (allParameters) { - result.push({ - url: callTemplate(pattern.url, parameterValues), - text: callTemplate(pattern.text, parameterValues), - }); - } - } - }); - return result; -} - -export function createGetLinks(linkPatterns: ProcessedLinkPattern[], cache: WeakMap<TraceKeyValuePair, TraceLink[]>) { - return (span: TraceSpan, items: TraceKeyValuePair[], itemIndex: number) => { - if (linkPatterns.length === 0) { - return []; - } - const item = items[itemIndex]; - let result = cache.get(item); - if (!result) { - result = computeLinks(linkPatterns, span, items, itemIndex); - cache.set(item, result); - } - return result; - }; -} - const processedLinks = (getConfigValue('linkPatterns') || []) .map(processLinkPattern) .filter((link: ProcessedLinkPattern | null): link is ProcessedLinkPattern => Boolean(link)); @@ -248,5 +163,3 @@ export const getTraceLinks: (trace: Trace | undefined) => TLinksRV = memoize(10) } return computeTraceLink(processedLinks, trace); }); - -export default createGetLinks(processedLinks, new WeakMap()); diff --git a/public/app/features/explore/TraceView/components/model/span.tsx b/public/app/features/explore/TraceView/components/model/span.tsx deleted file mode 100644 index 07764898a2462..0000000000000 --- a/public/app/features/explore/TraceView/components/model/span.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2017 The Jaeger Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { TraceSpan } from '../types'; - -/** - * Searches the span.references to find 'CHILD_OF' reference type or returns null. - * @param {TraceSpan} span The span whose parent is to be returned. - * @returns {TraceSpan|null} The parent span if there is one, null otherwise. - */ -export function getParent(span: TraceSpan) { - const parentRef = span.references ? span.references.find((ref) => ref.refType === 'CHILD_OF') : null; - return parentRef ? parentRef.span : null; -} diff --git a/public/app/features/explore/TraceView/components/settings/SpanBarSettings.tsx b/public/app/features/explore/TraceView/components/settings/SpanBarSettings.tsx index 41670d2144202..4c8ac4a99d064 100644 --- a/public/app/features/explore/TraceView/components/settings/SpanBarSettings.tsx +++ b/public/app/features/explore/TraceView/components/settings/SpanBarSettings.tsx @@ -8,9 +8,8 @@ import { toOption, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; -import { ConfigSubSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/experimental'; import { InlineField, InlineFieldRow, Input, Select, useStyles2 } from '@grafana/ui'; -import { ConfigDescriptionLink } from 'app/core/components/ConfigDescriptionLink'; export interface SpanBarOptions { type?: string; diff --git a/public/app/features/explore/TraceView/components/types/TTraceDiffState.tsx b/public/app/features/explore/TraceView/components/types/TTraceDiffState.tsx deleted file mode 100644 index 2ef0eda4e3959..0000000000000 --- a/public/app/features/explore/TraceView/components/types/TTraceDiffState.tsx +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2017 Uber Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import TNil from './TNil'; - -type TTraceDiffState = { - a?: string | TNil; - b?: string | TNil; - cohort: string[]; -}; - -// eslint-disable-next-line no-undef -export default TTraceDiffState; diff --git a/public/app/features/explore/TraceView/components/types/config.tsx b/public/app/features/explore/TraceView/components/types/config.tsx deleted file mode 100644 index 944a2a278de82..0000000000000 --- a/public/app/features/explore/TraceView/components/types/config.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) 2017 Uber Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { TNil } from './index'; - -export type ConfigMenuItem = { - label: string; - url: string; - anchorTarget?: '_self' | '_blank' | '_parent' | '_top'; -}; - -export type ConfigMenuGroup = { - label: string; - items: ConfigMenuItem[]; -}; - -export type TScript = { - text: string; - type: 'inline'; -}; - -export type LinkPatternsConfig = { - type: 'process' | 'tags' | 'logs' | 'traces'; - key?: string; - url: string; - text: string; -}; - -export type Config = { - archiveEnabled?: boolean; - deepDependencies?: { menuEnabled?: boolean }; - dependencies?: { dagMaxServicesLen?: number; menuEnabled?: boolean }; - menu: Array<ConfigMenuGroup | ConfigMenuItem>; - search?: { maxLookback: { label: string; value: string }; maxLimit: number }; - scripts?: TScript[]; - topTagPrefixes?: string[]; - tracking?: { - cookieToDimension?: Array<{ - cookie: string; - dimension: string; - }>; - gaID: string | TNil; - trackErrors: boolean | TNil; - }; - linkPatterns?: LinkPatternsConfig; -}; diff --git a/public/app/features/explore/TraceView/components/uberUtilityStyles.ts b/public/app/features/explore/TraceView/components/uberUtilityStyles.ts deleted file mode 100644 index d95db6e9c8ca8..0000000000000 --- a/public/app/features/explore/TraceView/components/uberUtilityStyles.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { css } from '@emotion/css'; - -export const ubRelative = css` - position: relative; -`; - -export const ubMb1 = css` - margin-bottom: 0.25rem; -`; - -export const ubMy1 = css` - margin-top: 0.25rem; - margin-bottom: 0.25rem; -`; - -export const ubM0 = css` - margin: 0; -`; - -export const ubPx2 = css` - padding-left: 0.5rem; - padding-right: 0.5rem; -`; - -export const ubPb2 = css` - padding-bottom: 0.5rem; -`; - -export const ubFlex = css` - display: flex; -`; - -export const ubItemsCenter = css` - align-items: center; -`; - -export const ubItemsStart = css` - align-items: start; -`; - -export const ubFlexAuto = css` - flex: 1 1 auto; - min-width: 0; /* 1 */ - min-height: 0; /* 1 */ -`; - -export const ubTxRightAlign = css` - text-align: right; -`; - -export const ubInlineBlock = css` - display: inline-block; -`; - -export const uAlignIcon = css` - margin: -0.2rem 0.25rem 0 0; -`; - -export const uTxEllipsis = css` - text-overflow: ellipsis; -`; - -export const uWidth100 = css` - width: 100%; -`; - -export const uTxMuted = css` - color: #aaa; -`; - -export const ubJustifyEnd = css` - justify-content: flex-end; -`; diff --git a/public/app/features/explore/TraceView/createSpanLink.test.ts b/public/app/features/explore/TraceView/createSpanLink.test.ts index 8f37213d2274b..9c0c81fa9fe0c 100644 --- a/public/app/features/explore/TraceView/createSpanLink.test.ts +++ b/public/app/features/explore/TraceView/createSpanLink.test.ts @@ -7,11 +7,10 @@ import { FieldType, DataFrame, } from '@grafana/data'; -import { config, DataSourceSrv, setDataSourceSrv, setTemplateSrv } from '@grafana/runtime'; -import { TraceToMetricsOptions } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; +import { TraceToLogsOptionsV2, TraceToMetricsOptions } from '@grafana/o11y-ds-frontend'; +import { DataSourceSrv, setDataSourceSrv, setTemplateSrv } from '@grafana/runtime'; import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { TraceToLogsOptionsV2 } from '../../../core/components/TraceToLogs/TraceToLogsSettings'; import { LinkSrv, setLinkSrv } from '../../panel/panellinks/link_srv'; import { TemplateSrv } from '../../templating/template_srv'; @@ -1281,7 +1280,6 @@ describe('createSpanLinkFactory', () => { setLinkSrv(new LinkSrv()); setTemplateSrv(new TemplateSrv()); - config.featureToggles.traceToProfiles = true; }); it('with default keys when tags not configured', () => { diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx index 80704a0fb7b7f..7b9cdd06c7684 100644 --- a/public/app/features/explore/TraceView/createSpanLink.tsx +++ b/public/app/features/explore/TraceView/createSpanLink.tsx @@ -14,12 +14,15 @@ import { SplitOpen, TimeRange, } from '@grafana/data'; +import { + TraceToProfilesOptions, + TraceToMetricsOptions, + TraceToLogsOptionsV2, + TraceToLogsTag, +} from '@grafana/o11y-ds-frontend'; import { getTemplateSrv } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { Icon } from '@grafana/ui'; -import { TraceToLogsOptionsV2, TraceToLogsTag } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; -import { TraceToMetricQuery, TraceToMetricsOptions } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; -import { TraceToProfilesOptions } from 'app/core/components/TraceToProfiles/TraceToProfilesSettings'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { PromQuery } from 'app/plugins/datasource/prometheus/types'; @@ -55,7 +58,7 @@ export function createSpanLinkFactory({ return undefined; } - let scopedVars = scopedVarsFromTrace(trace); + let scopedVars = scopedVarsFromTrace(trace.duration, trace.traceName, trace.traceID); const hasLinks = dataFrame.fields.some((f) => Boolean(f.config.links?.length)); const createSpanLinks = legacyCreateSpanLinkFactory( @@ -254,7 +257,9 @@ function legacyCreateSpanLinkFactory( // Get metrics links if (metricsDataSourceSettings && traceToMetricsOptions?.queries) { for (const query of traceToMetricsOptions.queries) { - const expr = buildMetricsQuery(query, traceToMetricsOptions?.tags || [], span); + const expr = + query.query || + `histogram_quantile(0.5, sum(rate(traces_spanmetrics_latency_bucket{service="${span.process.serviceName}"}[5m])) by (le))`; const dataLink: DataLink<PromQuery> = { title: metricsDataSourceSettings.name, url: '', @@ -268,10 +273,23 @@ function legacyCreateSpanLinkFactory( }, }; + const tagsToUse = + traceToMetricsOptions.tags && traceToMetricsOptions.tags.length > 0 + ? traceToMetricsOptions.tags + : defaultKeys; + + scopedVars = { + ...scopedVars, + __tags: { + text: 'Tags', + value: getFormattedTags(span, tagsToUse), + }, + }; + const link = mapInternalLinkToExplore({ link: dataLink, internalLink: dataLink.internal!, - scopedVars: {}, + scopedVars, range: getTimeRangeFromSpan(span, { startMs: traceToMetricsOptions.spanStartTimeShift ? rangeUtil.intervalToMs(traceToMetricsOptions.spanStartTimeShift) @@ -564,46 +582,18 @@ function getTimeRangeFromSpan( }; } -// Interpolates span attributes into trace to metric query, or returns default query -function buildMetricsQuery( - query: TraceToMetricQuery, - tags: Array<{ key: string; value?: string }> = [], - span: TraceSpan -): string { - if (!query.query) { - return `histogram_quantile(0.5, sum(rate(traces_spanmetrics_latency_bucket{service="${span.process.serviceName}"}[5m])) by (le))`; - } - - let expr = query.query; - if (tags.length && expr.indexOf('$__tags') !== -1) { - const spanTags = [...span.process.tags, ...span.tags]; - const labels = tags.reduce<string[]>((acc, tag) => { - const tagValue = spanTags.find((t) => t.key === tag.key)?.value; - if (tagValue) { - acc.push(`${tag.value ? tag.value : tag.key}="${tagValue}"`); - } - return acc; - }, []); - - const labelsQuery = labels?.join(', '); - expr = expr.replace(/\$__tags/g, labelsQuery); - } - - return expr; -} - /** * Variables from trace that can be used in the query * @param trace */ -function scopedVarsFromTrace(trace: Trace): ScopedVars { +export function scopedVarsFromTrace(duration: number, name: string, traceId: string): ScopedVars { return { __trace: { text: 'Trace', value: { - duration: trace.duration, - name: trace.traceName, - traceId: trace.traceID, + duration, + name, + traceId, }, }, }; @@ -613,7 +603,7 @@ function scopedVarsFromTrace(trace: Trace): ScopedVars { * Variables from span that can be used in the query * @param span */ -function scopedVarsFromSpan(span: TraceSpan): ScopedVars { +export function scopedVarsFromSpan(span: TraceSpan): ScopedVars { const tags: ScopedVars = {}; // We put all these tags together similar way we do for the __tags variable. This means there can be some overriding @@ -643,7 +633,10 @@ function scopedVarsFromSpan(span: TraceSpan): ScopedVars { * Variables from tags that can be used in the query * @param span */ -function scopedVarsFromTags(span: TraceSpan, traceToProfilesOptions: TraceToProfilesOptions | undefined): ScopedVars { +export function scopedVarsFromTags( + span: TraceSpan, + traceToProfilesOptions: TraceToProfilesOptions | undefined +): ScopedVars { let tags: ScopedVars = {}; if (traceToProfilesOptions) { diff --git a/public/app/features/explore/__mocks__/makeLogs.ts b/public/app/features/explore/__mocks__/makeLogs.ts index 0aaf4d9f25431..af2206681b605 100644 --- a/public/app/features/explore/__mocks__/makeLogs.ts +++ b/public/app/features/explore/__mocks__/makeLogs.ts @@ -1,4 +1,4 @@ -import { MutableDataFrame, LogLevel, LogRowModel, LogsSortOrder } from '@grafana/data'; +import { createDataFrame, LogLevel, LogRowModel, LogsSortOrder } from '@grafana/data'; import { sortLogRows } from 'app/features/logs/utils'; export const makeLogs = (numberOfLogsToCreate: number, overrides?: Partial<LogRowModel>): LogRowModel[] => { const array: LogRowModel[] = []; @@ -12,7 +12,7 @@ export const makeLogs = (numberOfLogsToCreate: number, overrides?: Partial<LogRo uid: uuid, entryFieldIndex: 0, rowIndex: 0, - dataFrame: new MutableDataFrame(), + dataFrame: createDataFrame({ fields: [] }), logLevel: LogLevel.debug, entry, hasAnsi: false, diff --git a/public/app/features/explore/extensions/AddToDashboard/AddToDashboardForm.tsx b/public/app/features/explore/extensions/AddToDashboard/AddToDashboardForm.tsx index 9ac895b189f73..b3bf2013a478f 100644 --- a/public/app/features/explore/extensions/AddToDashboard/AddToDashboardForm.tsx +++ b/public/app/features/explore/extensions/AddToDashboard/AddToDashboardForm.tsx @@ -1,10 +1,10 @@ import { partial } from 'lodash'; import React, { type ReactElement, useEffect, useState } from 'react'; -import { DeepMap, FieldError, useForm } from 'react-hook-form'; +import { DeepMap, FieldError, FieldErrors, useForm, Controller } from 'react-hook-form'; import { locationUtil, SelectableValue } from '@grafana/data'; import { config, locationService, reportInteraction } from '@grafana/runtime'; -import { Alert, Button, Field, InputControl, Modal, RadioButtonGroup } from '@grafana/ui'; +import { Alert, Button, Field, Modal, RadioButtonGroup } from '@grafana/ui'; import { DashboardPicker } from 'app/core/components/Select/DashboardPicker'; import { contextSrv } from 'app/core/services/context_srv'; import { removeDashboardToFetchFromLocalStorage } from 'app/features/dashboard/state/initDashboard'; @@ -34,7 +34,7 @@ interface SaveToExistingDashboard extends SaveTargetDTO { type FormDTO = SaveToNewDashboardDTO | SaveToExistingDashboard; function assertIsSaveToExistingDashboardError( - errors: DeepMap<FormDTO, FieldError> + errors: FieldErrors<FormDTO> ): asserts errors is DeepMap<SaveToExistingDashboard, FieldError> { // the shape of the errors object is always compatible with the type above, but we need to // explicitly assert its type so that TS can narrow down FormDTO to SaveToExistingDashboard @@ -102,6 +102,8 @@ export function AddToDashboardForm(props: Props): ReactElement { queries: exploreItem.queries.length, }); + const { from, to } = exploreItem.range.raw; + try { await setDashboardInLocalStorage({ dashboardUid, @@ -109,6 +111,10 @@ export function AddToDashboardForm(props: Props): ReactElement { queries: exploreItem.queries, queryResponse: exploreItem.queryResponse, panelState: exploreItem?.panelsState, + time: { + from: typeof from === 'string' ? from : from.toISOString(), + to: typeof to === 'string' ? to : to.toISOString(), + }, }); } catch (error) { switch (error) { @@ -150,7 +156,7 @@ export function AddToDashboardForm(props: Props): ReactElement { return ( <form> {saveTargets.length > 1 && ( - <InputControl + <Controller control={control} render={({ field: { ref, ...field } }) => ( <Field label="Target dashboard" description="Choose where to add the panel."> @@ -165,7 +171,7 @@ export function AddToDashboardForm(props: Props): ReactElement { (() => { assertIsSaveToExistingDashboardError(errors); return ( - <InputControl + <Controller render={({ field: { ref, value, onChange, ...field } }) => ( <Field label="Dashboard" diff --git a/public/app/features/explore/extensions/AddToDashboard/addToDashboard.test.ts b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.test.ts index da76ee80d7ccc..c5e081b33d502 100644 --- a/public/app/features/explore/extensions/AddToDashboard/addToDashboard.test.ts +++ b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.test.ts @@ -23,6 +23,7 @@ describe('addPanelToDashboard', () => { queries: [], queryResponse: createEmptyQueryResponse(), datasource: { type: 'loki', uid: 'someUid' }, + time: { from: 'now-1h', to: 'now' }, }); expect(spy).toHaveBeenCalledWith( expect.objectContaining({ @@ -33,12 +34,30 @@ describe('addPanelToDashboard', () => { ); }); + it('Correct time range is used', async () => { + await setDashboardInLocalStorage({ + queries: [], + queryResponse: createEmptyQueryResponse(), + datasource: { type: 'loki', uid: 'someUid' }, + time: { from: 'now-10h', to: 'now' }, + }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + dashboard: expect.objectContaining({ + time: expect.objectContaining({ from: 'now-10h', to: 'now' }), + }), + }) + ); + }); + it('All queries are correctly passed through', async () => { const queries: DataQuery[] = [{ refId: 'A' }, { refId: 'B', hide: true }]; await setDashboardInLocalStorage({ queries, queryResponse: createEmptyQueryResponse(), + time: { from: 'now-1h', to: 'now' }, }); expect(spy).toHaveBeenCalledWith( expect.objectContaining({ @@ -68,6 +87,7 @@ describe('addPanelToDashboard', () => { queryResponse: createEmptyQueryResponse(), dashboardUid: 'someUid', datasource: { type: '' }, + time: { from: 'now-1h', to: 'now' }, }); expect(spy).toHaveBeenCalledWith( @@ -95,7 +115,7 @@ describe('addPanelToDashboard', () => { ]; it.each(cases)('%s', async (_, queries, queryResponse) => { - await setDashboardInLocalStorage({ queries, queryResponse }); + await setDashboardInLocalStorage({ queries, queryResponse, time: { from: 'now-1h', to: 'now' } }); expect(spy).toHaveBeenCalledWith( expect.objectContaining({ dashboard: expect.objectContaining({ @@ -127,7 +147,7 @@ describe('addPanelToDashboard', () => { [framesType]: [new MutableDataFrame({ refId: 'A', fields: [] })], }; - await setDashboardInLocalStorage({ queries, queryResponse }); + await setDashboardInLocalStorage({ queries, queryResponse, time: { from: 'now-1h', to: 'now' } }); expect(spy).toHaveBeenCalledWith( expect.objectContaining({ dashboard: expect.objectContaining({ @@ -151,7 +171,7 @@ describe('addPanelToDashboard', () => { ], }; - await setDashboardInLocalStorage({ queries, queryResponse }); + await setDashboardInLocalStorage({ queries, queryResponse, time: { from: 'now-1h', to: 'now' } }); expect(spy).toHaveBeenCalledWith( expect.objectContaining({ dashboard: expect.objectContaining({ diff --git a/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts index bb780a01da526..58bf7336df4b5 100644 --- a/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts +++ b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts @@ -1,11 +1,9 @@ import { DataFrame, ExplorePanelsState } from '@grafana/data'; -import { DataQuery, DataSourceRef } from '@grafana/schema'; +import { Dashboard, DataQuery, DataSourceRef } from '@grafana/schema'; import { DataTransformerConfig } from '@grafana/schema/dist/esm/raw/dashboard/x/dashboard_types.gen'; import { backendSrv } from 'app/core/services/backend_srv'; -import { - getNewDashboardModelData, - setDashboardToFetchFromLocalStorage, -} from 'app/features/dashboard/state/initDashboard'; +import { setDashboardToFetchFromLocalStorage } from 'app/features/dashboard/state/initDashboard'; +import { buildNewDashboardSaveModel } from 'app/features/dashboard-scene/serialization/buildNewDashboardSaveModel'; import { DashboardDTO, ExplorePanelData } from 'app/types'; export enum AddToDashboardError { @@ -19,15 +17,7 @@ interface AddPanelToDashboardOptions { datasource?: DataSourceRef; dashboardUid?: string; panelState?: ExplorePanelsState; -} - -function createDashboard(): DashboardDTO { - const dto = getNewDashboardModelData(); - - // getNewDashboardModelData adds by default the "add-panel" panel. We don't want that. - dto.dashboard.panels = []; - - return dto; + time: Dashboard['time']; } /** @@ -54,6 +44,13 @@ function getLogsTableTransformations(panelType: string, options: AddPanelToDashb transformations.push({ id: 'organize', options: { + indexByName: Object.values(options.panelState.logs.columns).reduce( + (acc: Record<string, number>, value: string, idx) => ({ + ...acc, + [value]: idx, + }), + {} + ), includeByName: Object.values(options.panelState.logs.columns).reduce( (acc: Record<string, boolean>, value: string) => ({ ...acc, @@ -88,11 +85,13 @@ export async function setDashboardInLocalStorage(options: AddPanelToDashboardOpt throw AddToDashboardError.FETCH_DASHBOARD; } } else { - dto = createDashboard(); + dto = buildNewDashboardSaveModel(); } dto.dashboard.panels = [panel, ...(dto.dashboard.panels ?? [])]; + dto.dashboard.time = options.time; + try { setDashboardToFetchFromLocalStorage(dto); } catch { diff --git a/public/app/features/explore/extensions/AddToDashboard/index.test.tsx b/public/app/features/explore/extensions/AddToDashboard/index.test.tsx index fec15005448bf..4780b59492d69 100644 --- a/public/app/features/explore/extensions/AddToDashboard/index.test.tsx +++ b/public/app/features/explore/extensions/AddToDashboard/index.test.tsx @@ -24,6 +24,11 @@ const setup = (children: ReactNode, queries: DataQuery[] = [{ refId: 'A' }]) => explore: { panes: { left: { + range: { + from: 'now-6h', + to: 'now', + raw: { from: 'now-6h', to: 'now' }, + }, queries, queryResponse: createEmptyQueryResponse(), }, diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx index 4d4106126750c..7d4e6c95c935f 100644 --- a/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx +++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx @@ -78,23 +78,13 @@ describe('ToolbarExtensionPoint', () => { }); it('should render "Add" extension point menu button', () => { - renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />); + renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />); expect(screen.getByRole('button', { name: 'Add' })).toBeVisible(); }); - it('should render menu with extensions when "Add" is clicked in split mode', async () => { - renderWithExploreStore(<ToolbarExtensionPoint exploreId={'left'} timeZone="browser" splitted={true} />); - - await userEvent.click(screen.getByRole('button', { name: 'Add' })); - - expect(screen.getByRole('group', { name: 'Dashboards' })).toBeVisible(); - expect(screen.getByRole('menuitem', { name: 'Add to dashboard' })).toBeVisible(); - expect(screen.getByRole('menuitem', { name: 'ML: Forecast' })).toBeVisible(); - }); - it('should render menu with extensions when "Add" is clicked', async () => { - renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />); + renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />); await userEvent.click(screen.getByRole('button', { name: 'Add' })); @@ -104,7 +94,7 @@ describe('ToolbarExtensionPoint', () => { }); it('should call onClick from extension when menu item is clicked', async () => { - renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />); + renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />); await userEvent.click(screen.getByRole('button', { name: 'Add' })); await userEvent.click(screen.getByRole('menuitem', { name: 'Add to dashboard' })); @@ -116,7 +106,7 @@ describe('ToolbarExtensionPoint', () => { }); it('should render confirm navigation modal when extension with path is clicked', async () => { - renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />); + renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />); await userEvent.click(screen.getByRole('button', { name: 'Add' })); await userEvent.click(screen.getByRole('menuitem', { name: 'ML: Forecast' })); @@ -130,7 +120,7 @@ describe('ToolbarExtensionPoint', () => { const targets = [{ refId: 'A' }]; const data = createEmptyQueryResponse(); - renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />, { + renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />, { targets, data, }); @@ -155,7 +145,7 @@ describe('ToolbarExtensionPoint', () => { const targets = [{ refId: 'A' }]; const data = createEmptyQueryResponse(); - renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="" splitted={false} />, { + renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="" />, { targets, data, }); @@ -167,7 +157,7 @@ describe('ToolbarExtensionPoint', () => { }); it('should correct extension point id when fetching extensions', async () => { - renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />); + renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />); const [options] = getPluginLinkExtensionsMock.mock.calls[0]; const { extensionPointId } = options; @@ -201,24 +191,13 @@ describe('ToolbarExtensionPoint', () => { }); it('should render "Add" extension point menu button', () => { - renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />); + renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />); expect(screen.getByRole('button', { name: 'Add' })).toBeVisible(); }); - it('should render "Add" extension point menu button in split mode', async () => { - renderWithExploreStore(<ToolbarExtensionPoint exploreId={'left'} timeZone="browser" splitted={true} />); - - await userEvent.click(screen.getByRole('button', { name: 'Add' })); - - // Make sure we don't have anything related to categories rendered - expect(screen.queryAllByRole('group').length).toBe(0); - expect(screen.getByRole('menuitem', { name: 'Dashboard' })).toBeVisible(); - expect(screen.getByRole('menuitem', { name: 'ML: Forecast' })).toBeVisible(); - }); - it('should render menu with extensions when "Add" is clicked', async () => { - renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />); + renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />); await userEvent.click(screen.getByRole('button', { name: 'Add' })); @@ -236,7 +215,7 @@ describe('ToolbarExtensionPoint', () => { }); it('should render "add to dashboard" action button if one pane is visible', async () => { - renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />); + renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />); await waitFor(() => { const button = screen.getByRole('button', { name: /add to dashboard/i }); @@ -254,7 +233,7 @@ describe('ToolbarExtensionPoint', () => { }); it('should not render "add to dashboard" action button', async () => { - renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />); + renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />); expect(screen.queryByRole('button', { name: /add to dashboard/i })).not.toBeInTheDocument(); }); diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx index 5e45b4977b536..1a46d5385471d 100644 --- a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx +++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx @@ -19,11 +19,10 @@ const AddToDashboard = lazy(() => type Props = { exploreId: string; timeZone: TimeZone; - splitted: boolean; }; export function ToolbarExtensionPoint(props: Props): ReactElement | null { - const { exploreId, splitted } = props; + const { exploreId } = props; const [selectedExtension, setSelectedExtension] = useState<PluginExtensionLink | undefined>(); const [isOpen, setIsOpen] = useState<boolean>(false); const context = useExtensionPointContext(props); @@ -54,14 +53,8 @@ export function ToolbarExtensionPoint(props: Props): ReactElement | null { return ( <> <Dropdown onVisibleChange={setIsOpen} placement="bottom-start" overlay={menu}> - <ToolbarButton - aria-label="Add" - icon="plus" - disabled={!Boolean(noQueriesInPane)} - variant="canvas" - isOpen={isOpen} - > - {splitted ? ' ' : 'Add'} + <ToolbarButton aria-label="Add" disabled={!Boolean(noQueriesInPane)} variant="canvas" isOpen={isOpen}> + Add </ToolbarButton> </Dropdown> {!!selectedExtension && !!selectedExtension.path && ( diff --git a/public/app/features/explore/hooks/useKeyboardShortcuts.test.tsx b/public/app/features/explore/hooks/useKeyboardShortcuts.test.tsx index da9d1b9969f6b..c58336af1c8b6 100644 --- a/public/app/features/explore/hooks/useKeyboardShortcuts.test.tsx +++ b/public/app/features/explore/hooks/useKeyboardShortcuts.test.tsx @@ -1,9 +1,16 @@ -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import React from 'react'; import { dateTime, EventBusSrv } from '@grafana/data'; import { getAppEvents } from '@grafana/runtime'; -import { AbsoluteTimeEvent, ShiftTimeEvent, ShiftTimeEventDirection, ZoomOutEvent } from 'app/types/events'; +import { + AbsoluteTimeEvent, + CopyTimeEvent, + PasteTimeEvent, + ShiftTimeEvent, + ShiftTimeEventDirection, + ZoomOutEvent, +} from 'app/types/events'; import { TestProvider } from '../../../../test/helpers/TestProvider'; import { configureStore } from '../../../store/configureStore'; @@ -22,6 +29,15 @@ jest.mock('@grafana/runtime', () => { }; }); +const mockClipboard = { + writeText: jest.fn(), + readText: jest.fn(), +}; + +Object.defineProperty(global.navigator, 'clipboard', { + value: mockClipboard, +}); + const NOW = new Date('2020-10-10T00:00:00.000Z'); function daysFromNow(daysDiff: number) { return new Date(NOW.getTime() + daysDiff * 86400000); @@ -111,4 +127,31 @@ describe('useKeyboardShortcuts', () => { expect(panes[1]!.absoluteRange.from).toBe(daysFromNow(-3).getTime()); expect(panes[1]!.absoluteRange.to).toBe(daysFromNow(1).getTime()); }); + + it('copies the time range from the left pane', () => { + const store = setup(); + + getAppEvents().publish(new CopyTimeEvent()); + + const fromValue = store.getState().explore.panes.left!.range.raw.from; + const toValue = store.getState().explore.panes.left!.range.raw.to; + + expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith(JSON.stringify({ from: fromValue, to: toValue })); + }); + + it('pastes the time range to left pane', async () => { + const store = setup(); + + const fromValue = 'now-3d'; + const toValue = 'now'; + + mockClipboard.readText.mockResolvedValue(JSON.stringify({ from: fromValue, to: toValue })); + getAppEvents().publish(new PasteTimeEvent({ updateUrl: false })); + + await waitFor(() => { + const raw = store.getState().explore.panes.left!.range.raw; + expect(raw.from).toBe(fromValue); + expect(raw.to).toBe(toValue); + }); + }); }); diff --git a/public/app/features/explore/hooks/useKeyboardShortcuts.ts b/public/app/features/explore/hooks/useKeyboardShortcuts.ts index 48c5bc43208e3..bd90b13e3f27b 100644 --- a/public/app/features/explore/hooks/useKeyboardShortcuts.ts +++ b/public/app/features/explore/hooks/useKeyboardShortcuts.ts @@ -4,9 +4,15 @@ import { Unsubscribable } from 'rxjs'; import { getAppEvents } from '@grafana/runtime'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { useDispatch } from 'app/types'; -import { AbsoluteTimeEvent, ShiftTimeEvent, ZoomOutEvent } from 'app/types/events'; +import { AbsoluteTimeEvent, CopyTimeEvent, PasteTimeEvent, ShiftTimeEvent, ZoomOutEvent } from 'app/types/events'; -import { makeAbsoluteTime, shiftTime, zoomOut } from '../state/time'; +import { + copyTimeRangeToClipboard, + makeAbsoluteTime, + pasteTimeRangeFromClipboard, + shiftTime, + zoomOut, +} from '../state/time'; export function useKeyboardShortcuts() { const { keybindings } = useGrafana(); @@ -35,6 +41,18 @@ export function useKeyboardShortcuts() { }) ); + tearDown.push( + getAppEvents().subscribe(CopyTimeEvent, () => { + dispatch(copyTimeRangeToClipboard()); + }) + ); + + tearDown.push( + getAppEvents().subscribe(PasteTimeEvent, () => { + dispatch(pasteTimeRangeFromClipboard()); + }) + ); + return () => { tearDown.forEach((u) => u.unsubscribe()); }; diff --git a/public/app/features/explore/hooks/useStateSync/external.utils.ts b/public/app/features/explore/hooks/useStateSync/external.utils.ts new file mode 100644 index 0000000000000..e691ba13b335f --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/external.utils.ts @@ -0,0 +1,30 @@ +import { isEmpty, isObject, mapValues, omitBy } from 'lodash'; + +import { ExploreUrlState, toURLRange } from '@grafana/data'; +import { clearQueryKeys } from 'app/core/utils/explore'; +import { ExploreItemState } from 'app/types'; + +export function getUrlStateFromPaneState(pane: ExploreItemState): ExploreUrlState { + return { + // datasourceInstance should not be undefined anymore here but in case there is some path for it to be undefined + // lets just fallback instead of crashing. + datasource: pane.datasourceInstance?.uid || '', + queries: pane.queries.map(clearQueryKeys), + range: toURLRange(pane.range.raw), + // don't include panelsState in the url unless a piece of state is actually set + panelsState: pruneObject(pane.panelsState), + }; +} + +/** + * recursively walks an object, removing keys where the value is undefined + * if the resulting object is empty, returns undefined + **/ +function pruneObject(obj: object): object | undefined { + let pruned = mapValues(obj, (value) => (isObject(value) ? pruneObject(value) : value)); + pruned = omitBy<typeof pruned>(pruned, isEmpty); + if (isEmpty(pruned)) { + return undefined; + } + return pruned; +} diff --git a/public/app/features/explore/hooks/useStateSync/index.test.tsx b/public/app/features/explore/hooks/useStateSync/index.test.tsx index 1a9daf675c1fb..19a8741c0ed02 100644 --- a/public/app/features/explore/hooks/useStateSync/index.test.tsx +++ b/public/app/features/explore/hooks/useStateSync/index.test.tsx @@ -213,6 +213,69 @@ describe('useStateSync', () => { expect(queries?.[0].datasource?.uid).toBe('loki-uid'); }); + it('inits with mixed datasource if there are multiple datasources in queries and no root level datasource is defined', async () => { + const { location, waitForNextUpdate, store } = setup({ + queryParams: { + panes: JSON.stringify({ + one: { + queries: [ + { datasource: { name: 'loki', uid: 'loki-uid' } }, + { datasource: { name: 'elastic', uid: 'elastic-uid' } }, + ], + }, + }), + schemaVersion: 1, + }, + }); + + const initialHistoryLength = location.getHistory().length; + + await waitForNextUpdate(); + + expect(location.getHistory().length).toBe(initialHistoryLength); + + const search = location.getSearchObject(); + expect(search.panes).toBeDefined(); + + const paneState = store.getState().explore.panes['one']; + expect(paneState?.datasourceInstance?.name).toBe(MIXED_DATASOURCE_NAME); + + expect(paneState?.queries).toHaveLength(2); + expect(paneState?.queries?.[0].datasource?.uid).toBe('loki-uid'); + expect(paneState?.queries?.[1].datasource?.uid).toBe('elastic-uid'); + }); + + it("inits with a query's datasource if there are multiple datasources in queries, no root level datasource, and only one query has a valid datsource", async () => { + const { location, waitForNextUpdate, store } = setup({ + queryParams: { + panes: JSON.stringify({ + one: { + queries: [ + { datasource: { name: 'loki', uid: 'loki-uid' } }, + { datasource: { name: 'UNKNOWN', uid: 'UNKNOWN-UID' } }, + ], + }, + }), + schemaVersion: 1, + }, + }); + + const initialHistoryLength = location.getHistory().length; + + await waitForNextUpdate(); + + expect(location.getHistory().length).toBe(initialHistoryLength); + + const search = location.getSearchObject(); + expect(search.panes).toBeDefined(); + + const paneState = store.getState().explore.panes['one']; + expect(paneState?.datasourceInstance?.getRef().uid).toBe('loki-uid'); + + expect(paneState?.queries).toHaveLength(1); + expect(paneState?.queries?.[0].datasource?.uid).toBe('loki-uid'); + }); + it('inits with the last used datasource from localStorage', async () => { setLastUsedDatasourceUID(1, 'elastic-uid'); const { waitForNextUpdate, store } = setup({ diff --git a/public/app/features/explore/hooks/useStateSync/index.ts b/public/app/features/explore/hooks/useStateSync/index.ts index b2933697bcc21..1056addadbf22 100644 --- a/public/app/features/explore/hooks/useStateSync/index.ts +++ b/public/app/features/explore/hooks/useStateSync/index.ts @@ -1,26 +1,17 @@ -import { identity, isEmpty, isEqual, isObject, mapValues, omitBy } from 'lodash'; import { useEffect, useRef } from 'react'; -import { CoreApp, ExploreUrlState, DataSourceApi, toURLRange, EventBusSrv } from '@grafana/data'; -import { DataQuery, DataSourceRef } from '@grafana/schema'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { useAppNotification } from 'app/core/copy/appNotification'; -import { clearQueryKeys, getLastUsedDatasourceUID } from 'app/core/utils/explore'; -import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; -import { addListener, ExploreItemState, ExploreQueryParams, useDispatch, useSelector } from 'app/types'; +import { addListener, ExploreQueryParams, useDispatch, useSelector } from 'app/types'; -import { changeDatasource } from '../../state/datasource'; -import { changePanelsStateAction, initializeExplore } from '../../state/explorePane'; -import { clearPanes, splitClose, splitOpen, syncTimesAction } from '../../state/main'; -import { runQueries, setQueriesAction } from '../../state/query'; import { selectPanes } from '../../state/selectors'; -import { changeRangeAction, updateTime } from '../../state/time'; -import { DEFAULT_RANGE, fromURLRange } from '../../state/utils'; -import { withUniqueRefIds } from '../../utils/queries'; -import { isFulfilled } from '../utils'; import { parseURL } from './parseURL'; +import { syncFromURL } from './synchronizer/fromURL'; +import { initializeFromURL } from './synchronizer/init'; +import { syncToURL, syncToURLPredicate } from './synchronizer/toURL'; + +export { getUrlStateFromPaneState } from './external.utils'; /** * Bi-directionally syncs URL changes with Explore's state. @@ -32,6 +23,7 @@ export function useStateSync(params: ExploreQueryParams) { const orgId = useSelector((state) => state.user.orgId); const prevParams = useRef(params); const initState = useRef<'notstarted' | 'pending' | 'done'>('notstarted'); + const paused = useRef(false); const { warning } = useAppNotification(); useEffect(() => { @@ -46,50 +38,13 @@ export function useStateSync(params: ExploreQueryParams) { useEffect(() => { const unsubscribe = dispatch( addListener({ - predicate: (action) => - // We want to update the URL when: - // - a pane is opened or closed - // - a query is run - // - range is changed - // - panel state is updated - [ - splitClose.type, - splitOpen.fulfilled.type, - runQueries.pending.type, - changeRangeAction.type, - changePanelsStateAction.type, - ].includes(action.type), + predicate: (action) => syncToURLPredicate(paused, action), effect: async (_, { cancelActiveListeners, delay, getState }) => { - // The following 2 lines will throttle updates to avoid creating history entries when rapid changes + // The following 2 lines will debounce updates to avoid creating history entries when rapid changes // are committed to the store. cancelActiveListeners(); await delay(200); - - const panesQueryParams = Object.entries(getState().explore.panes).reduce((acc, [id, paneState]) => { - if (!paneState) { - return acc; - } - return { - ...acc, - [id]: getUrlStateFromPaneState(paneState), - }; - }, {}); - - if (!isEqual(prevParams.current.panes, JSON.stringify(panesQueryParams))) { - // If there's no previous state it means we are mounting explore for the first time, - // in this case we want to replace the URL instead of pushing a new entry to the history. - // If the init state is 'pending' it means explore still hasn't finished initializing. in that case we skip - // pushing a new entry in the history as the first entry will be pushed after initialization. - const replace = - (!!prevParams.current.panes && Object.values(prevParams.current.panes).filter(Boolean).length === 0) || - initState.current === 'pending'; - - prevParams.current = { - panes: JSON.stringify(panesQueryParams), - }; - - location.partial({ panes: prevParams.current.panes }, replace); - } + syncToURL(getState().explore, prevParams, initState, location); }, }) ); @@ -108,321 +63,18 @@ export function useStateSync(params: ExploreQueryParams) { 'The requested URL contains invalid parameters, a default Explore state has been loaded.' ); - async function sync() { - // if navigating the history causes one of the time range to not being equal to all the other ones, - // we set syncedTimes to false to avoid inconsistent UI state. - // Ideally `syncedTimes` should be saved in the URL. - const paneArray = Object.values(urlState.panes); - if (paneArray.length > 1) { - const paneTimesUnequal = paneArray.some(({ range }, _, [{ range: firstRange }]) => !isEqual(range, firstRange)); - dispatch(syncTimesAction({ syncedTimes: !paneTimesUnequal })); // if all time ranges are equal, keep them synced - } - - Object.entries(urlState.panes).forEach(([exploreId, urlPane], i) => { - const { datasource, queries, range, panelsState } = urlPane; - - const paneState = panesState[exploreId]; - - if (paneState !== undefined) { - const update = urlDiff(urlPane, getUrlStateFromPaneState(paneState)); - - Promise.resolve() - .then(async () => { - if (update.datasource && datasource) { - await dispatch(changeDatasource(exploreId, datasource)); - } - return; - }) - .then(() => { - if (update.range) { - dispatch(updateTime({ exploreId, rawRange: fromURLRange(range) })); - } - - if (update.queries) { - dispatch(setQueriesAction({ exploreId, queries: withUniqueRefIds(queries) })); - } - - if (update.queries || update.range) { - dispatch(runQueries({ exploreId })); - } - - if (update.panelsState && panelsState) { - dispatch(changePanelsStateAction({ exploreId, panelsState })); - } - }); - } else { - // This happens when browser history is used to navigate. - // In this case we want to initialize the pane with the data from the URL - // if it's not present in the store. This may happen if the user has navigated - // from split view to non-split view and then back to split view. - dispatch( - initializeExplore({ - exploreId, - datasource: datasource || '', - queries: withUniqueRefIds(queries), - range: fromURLRange(range), - panelsState, - position: i, - eventBridge: new EventBusSrv(), - }) - ); - } - }); - - // Close all the panes that are not in the URL but are still in the store - // ie. because the user has navigated back after opening the split view. - Object.keys(panesState) - .filter((keyInStore) => !Object.keys(urlState.panes).includes(keyInStore)) - .forEach((paneId) => dispatch(splitClose(paneId))); - } - // This happens when the user first navigates to explore. // Here we want to initialize each pane initial data, wether it comes // from the url or as a result of migrations. if (!isURLOutOfSync && initState.current === 'notstarted') { initState.current = 'pending'; - - // Clear all the panes in the store first to avoid stale data. - dispatch(clearPanes()); - - Promise.all( - Object.entries(urlState.panes).map(([exploreId, { datasource, queries, range, panelsState }]) => { - return getPaneDatasource(datasource, queries, orgId).then((paneDatasource) => { - return Promise.resolve( - // Given the Grafana datasource will always be present, this should always be defined. - paneDatasource - ? queries.length - ? // if we have queries in the URL, we use them - withUniqueRefIds(queries) - // but filter out the ones that are not compatible with the pane datasource - .filter(getQueryFilter(paneDatasource)) - .map( - isMixedDatasource(paneDatasource) - ? identity<DataQuery> - : (query) => ({ ...query, datasource: paneDatasource.getRef() }) - ) - : getDatasourceSrv() - // otherwise we get a default query from the pane datasource or from the default datasource if the pane datasource is mixed - .get(isMixedDatasource(paneDatasource) ? undefined : paneDatasource.getRef()) - .then((ds) => [getDefaultQuery(ds)]) - : [] - ).then(async (queries) => { - // we remove queries that have an invalid datasources - let validQueries = await removeQueriesWithInvalidDatasource(queries); - - if (!validQueries.length && paneDatasource) { - // and in case there's no query left we add a default one. - validQueries = [ - getDefaultQuery(isMixedDatasource(paneDatasource) ? await getDatasourceSrv().get() : paneDatasource), - ]; - } - - return { exploreId, range, panelsState, queries: validQueries, datasource: paneDatasource }; - }); - }); - }) - ).then(async (panes) => { - const initializedPanes = await Promise.all( - panes.map(({ exploreId, range, panelsState, queries, datasource }) => { - return dispatch( - initializeExplore({ - exploreId, - datasource, - queries, - range: fromURLRange(range), - panelsState, - eventBridge: new EventBusSrv(), - }) - ).unwrap(); - }) - ); - - if (initializedPanes.length > 1) { - const paneTimesUnequal = initializedPanes.some( - ({ state }, _, [{ state: firstState }]) => !isEqual(state.range.raw, firstState.range.raw) - ); - dispatch(syncTimesAction({ syncedTimes: !paneTimesUnequal })); // if all time ranges are equal, keep them synced - } - - const panesObj = initializedPanes.reduce((acc, { exploreId, state }) => { - return { - ...acc, - [exploreId]: getUrlStateFromPaneState(state), - }; - }, {}); - - // we need to use partial here beacuse replace doesn't encode the query params. - const oldQuery = location.getSearchObject(); - - // we create the default query params from the current URL, omitting all the properties we know should be in the final url. - // This includes params from previous schema versions and 'schemaVersion', 'panes', 'orgId' as we want to replace those. - let defaults: Record<string, unknown> = {}; - for (const [key, value] of Object.entries(oldQuery).filter( - ([key]) => !['schemaVersion', 'panes', 'orgId', 'left', 'right'].includes(key) - )) { - defaults[key] = value; - } - - const searchParams = new URLSearchParams({ - // we set the schemaVersion as the first parameter so that when URLs are truncated the schemaVersion is more likely to be present. - schemaVersion: `${urlState.schemaVersion}`, - panes: JSON.stringify(panesObj), - orgId: `${orgId}`, - ...defaults, - }); - - location.replace({ - pathname: location.getLocation().pathname, - search: searchParams.toString(), - }); - initState.current = 'done'; - }); + initializeFromURL(urlState, initState, orgId, dispatch, location); } prevParams.current = params; - isURLOutOfSync && initState.current === 'done' && sync(); + if (isURLOutOfSync && initState.current === 'done') { + syncFromURL(urlState, panesState, dispatch); + } }, [dispatch, panesState, orgId, location, params, warning]); } - -function getDefaultQuery(ds: DataSourceApi) { - return { ...ds.getDefaultQuery?.(CoreApp.Explore), refId: 'A', datasource: ds.getRef() }; -} - -function isMixedDatasource(datasource: DataSourceApi) { - return datasource.name === MIXED_DATASOURCE_NAME; -} - -function getQueryFilter(datasource?: DataSourceApi) { - // if the root datasource is mixed, filter out queries that don't have a datasource. - if (datasource && isMixedDatasource(datasource)) { - return (q: DataQuery) => !!q.datasource; - } else { - // else filter out queries that have a datasource different from the root one. - // Queries may not have a datasource, if so, it's assumed they are using the root datasource - return (q: DataQuery) => { - if (!q.datasource) { - return true; - } - // Due to legacy URLs, `datasource` in queries may be a string. This logic should probably be in the migration - if (typeof q.datasource === 'string') { - return q.datasource === datasource?.uid; - } - - return q.datasource.uid === datasource?.uid; - }; - } -} - -async function removeQueriesWithInvalidDatasource(queries: DataQuery[]) { - const results = await Promise.allSettled( - queries.map((query) => { - return getDatasourceSrv() - .get(query.datasource) - .then((ds) => ({ - query, - ds, - })); - }) - ); - - return results.filter(isFulfilled).map(({ value }) => value.query); -} - -/** - * Returns the datasource that an explore pane should be using. - * If the URL specifies a datasource and that datasource exists, it will be used unless said datasource is mixed. - * Otherwise the datasource will be extracetd from the the first query specifying a valid datasource. - * - * If there's no datasource in the queries, the last used datasource will be used. - * if there's no last used datasource, the default datasource will be used. - * - * @param rootDatasource the top-level datasource specified in the URL - * @param queries the queries in the pane - * @param orgId the orgId of the user - * - * @returns the datasource UID that the pane should use, undefined if no suitable datasource is found - */ -async function getPaneDatasource( - rootDatasource: DataSourceRef | string | null | undefined, - queries: DataQuery[], - orgId: number -) { - // If there's a root datasource, use it unless it's unavailable - if (rootDatasource) { - try { - return await getDatasourceSrv().get(rootDatasource); - } catch (_) {} - } - - // TODO: if queries have multiple datasources we should return mixed datasource - // Else we try to find a datasource in the queries, returning the first one that exists - const queriesWithDS = queries.filter((q) => q.datasource); - for (const query of queriesWithDS) { - try { - return await getDatasourceSrv().get(query.datasource); - } catch (_) {} - } - - // If none of the queries specify a avalid datasource, we use the last used one - const lastUsedDSUID = getLastUsedDatasourceUID(orgId); - - return ( - getDatasourceSrv() - .get(lastUsedDSUID) - // Or the default one - .catch(() => getDatasourceSrv().get()) - .catch(() => undefined) - ); -} - -/** - * Compare 2 explore urls and return a map of what changed. Used to update the local state with all the - * side effects needed. - */ -const urlDiff = ( - oldUrlState: ExploreUrlState | undefined, - currentUrlState: ExploreUrlState | undefined -): { - datasource: boolean; - queries: boolean; - range: boolean; - panelsState: boolean; -} => { - const datasource = !isEqual(currentUrlState?.datasource, oldUrlState?.datasource); - const queries = !isEqual(currentUrlState?.queries, oldUrlState?.queries); - const range = !isEqual(currentUrlState?.range || DEFAULT_RANGE, oldUrlState?.range || DEFAULT_RANGE); - const panelsState = !isEqual(currentUrlState?.panelsState, oldUrlState?.panelsState); - - return { - datasource, - queries, - range, - panelsState, - }; -}; - -export function getUrlStateFromPaneState(pane: ExploreItemState): ExploreUrlState { - return { - // datasourceInstance should not be undefined anymore here but in case there is some path for it to be undefined - // lets just fallback instead of crashing. - datasource: pane.datasourceInstance?.uid || '', - queries: pane.queries.map(clearQueryKeys), - range: toURLRange(pane.range.raw), - // don't include panelsState in the url unless a piece of state is actually set - panelsState: pruneObject(pane.panelsState), - }; -} - -/** - * recursively walks an object, removing keys where the value is undefined - * if the resulting object is empty, returns undefined - **/ -function pruneObject(obj: object): object | undefined { - let pruned = mapValues(obj, (value) => (isObject(value) ? pruneObject(value) : value)); - pruned = omitBy<typeof pruned>(pruned, isEmpty); - if (isEmpty(pruned)) { - return undefined; - } - return pruned; -} diff --git a/public/app/features/explore/hooks/useStateSync/internal.utils.ts b/public/app/features/explore/hooks/useStateSync/internal.utils.ts new file mode 100644 index 0000000000000..a600de5a03a3b --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/internal.utils.ts @@ -0,0 +1,146 @@ +import { isEqual } from 'lodash'; + +import { CoreApp, DataSourceApi, ExploreUrlState, isTruthy } from '@grafana/data'; +import { DataQuery, DataSourceRef } from '@grafana/schema'; +import { getLastUsedDatasourceUID } from 'app/core/utils/explore'; +import { DEFAULT_RANGE } from 'app/features/explore/state/utils'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; + +import { isFulfilled } from '../utils'; + +export type InitState = 'pending' | 'done' | 'notstarted'; + +/** + * Compare 2 explore urls and return a map of what changed. Used to update the local state with all the + * side effects needed. + */ +export const urlDiff = ( + oldUrlState: ExploreUrlState | undefined, + currentUrlState: ExploreUrlState | undefined +): { + datasource: boolean; + queries: boolean; + range: boolean; + panelsState: boolean; +} => { + const datasource = !isEqual(currentUrlState?.datasource, oldUrlState?.datasource); + const queries = !isEqual(currentUrlState?.queries, oldUrlState?.queries); + const range = !isEqual(currentUrlState?.range || DEFAULT_RANGE, oldUrlState?.range || DEFAULT_RANGE); + const panelsState = !isEqual(currentUrlState?.panelsState, oldUrlState?.panelsState); + + return { + datasource, + queries, + range, + panelsState, + }; +}; + +/** + * Returns the datasource that an explore pane should be using. + * If the URL specifies a datasource and that datasource exists, it will be used unless said datasource is mixed. + * Otherwise the datasource will be extracted from the the first query specifying a valid datasource. + * + * If there's no datasource in the queries, the last used datasource will be used. + * if there's no last used datasource, the default datasource will be used. + * + * @param rootDatasource the top-level datasource specified in the URL + * @param queries the queries in the pane + * @param orgId the orgId of the user + * + * @returns the datasource UID that the pane should use, undefined if no suitable datasource is found + */ +export async function getPaneDatasource( + rootDatasource: DataSourceRef | string | null | undefined, + queries: DataQuery[], + orgId: number +) { + // If there's a root datasource, use it unless it's unavailable + if (rootDatasource) { + try { + return await getDatasourceSrv().get(rootDatasource); + } catch (_) {} + } + + // Else we try to find a datasource in the queries + const queriesDatasources = [ + ...new Set( + queries + .map((q) => q.datasource) + .filter(isTruthy) + .map((ds) => (typeof ds === 'string' ? ds : ds.uid)) + ), + ]; + + try { + if (queriesDatasources.length >= 1) { + const datasources = (await Promise.allSettled(queriesDatasources.map((ds) => getDatasourceSrv().get(ds)))).filter( + isFulfilled + ); + + // if queries have multiple (valid) datasources, we return the mixed datasource + if (datasources.length > 1) { + return await getDatasourceSrv().get(MIXED_DATASOURCE_NAME); + } + + // otherwise we return the first datasource. + if (datasources.length === 1) { + return await getDatasourceSrv().get(queriesDatasources[0]); + } + } + } catch (_) {} + + // If none of the queries specify a valid datasource, we use the last used one + return ( + getDatasourceSrv() + .get(getLastUsedDatasourceUID(orgId)) + // Or the default one + .catch(() => getDatasourceSrv().get()) + .catch(() => undefined) + ); +} + +export function getDefaultQuery(ds: DataSourceApi) { + return { ...ds.getDefaultQuery?.(CoreApp.Explore), refId: 'A', datasource: ds.getRef() }; +} + +export function isMixedDatasource(datasource: DataSourceApi) { + return datasource.name === MIXED_DATASOURCE_NAME; +} + +export function getQueryFilter(datasource?: DataSourceApi) { + // if the root datasource is mixed, filter out queries that don't have a datasource. + if (datasource && isMixedDatasource(datasource)) { + return (q: DataQuery) => !!q.datasource; + } else { + // else filter out queries that have a datasource different from the root one. + // Queries may not have a datasource, if so, it's assumed they are using the root datasource + return (q: DataQuery) => { + if (!q.datasource) { + return true; + } + // Due to legacy URLs, `datasource` in queries may be a string. This logic should probably be in the migration + if (typeof q.datasource === 'string') { + return q.datasource === datasource?.uid; + } + + return q.datasource.uid === datasource?.uid; + }; + } +} + +export async function removeQueriesWithInvalidDatasource(queries: DataQuery[]) { + const results = await Promise.allSettled( + queries.map((query) => { + return getDatasourceSrv() + .get(query.datasource) + .then((ds) => ({ + query, + ds, + })); + }) + ); + + return results.filter(isFulfilled).map(({ value }) => value.query); +} diff --git a/public/app/features/explore/hooks/useStateSync/migrators/v0.ts b/public/app/features/explore/hooks/useStateSync/migrators/v0.ts index d1dceeccc46dc..a2948163f009d 100644 --- a/public/app/features/explore/hooks/useStateSync/migrators/v0.ts +++ b/public/app/features/explore/hooks/useStateSync/migrators/v0.ts @@ -20,8 +20,8 @@ export const v0Migrator: MigrationHandler<never, ExploreURLV0> = { datasource: null, queries: [], range: { - from: 'now-6h', - to: 'now', + from: DEFAULT_RANGE.from, + to: DEFAULT_RANGE.to, }, }, schemaVersion: 0, diff --git a/public/app/features/explore/hooks/useStateSync/migrators/v1.ts b/public/app/features/explore/hooks/useStateSync/migrators/v1.ts index 683c57f220226..57b23ce4076f3 100644 --- a/public/app/features/explore/hooks/useStateSync/migrators/v1.ts +++ b/public/app/features/explore/hooks/useStateSync/migrators/v1.ts @@ -1,5 +1,5 @@ import { ExploreUrlState } from '@grafana/data'; -import { generateExploreId } from 'app/core/utils/explore'; +import { ID_ALPHABET, generateExploreId } from 'app/core/utils/explore'; import { DEFAULT_RANGE } from 'app/features/explore/state/utils'; import { hasKey } from '../../utils'; @@ -48,9 +48,21 @@ export const v1Migrator: MigrationHandler<ExploreURLV0, ExploreURLV1> = { const panes = Object.entries(rawPanes) .map(([key, value]) => [key, applyDefaults(value)] as const) .reduce<Record<string, ExploreUrlState>>((acc, [key, value]) => { + let newKey = key; + // Panes IDs must be 3 characters long and contain at least one letter + if ( + newKey.length !== 3 || + /^\d+$/.test(newKey) || + newKey.split('').some((ch) => { + return ID_ALPHABET.indexOf(ch) === -1; + }) + ) { + newKey = generateExploreId(); + } + return { ...acc, - [key]: value, + [newKey]: value, }; }, {}); diff --git a/public/app/features/explore/hooks/useStateSync/synchronizer/fromURL.ts b/public/app/features/explore/hooks/useStateSync/synchronizer/fromURL.ts new file mode 100644 index 0000000000000..5497f4814078a --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/synchronizer/fromURL.ts @@ -0,0 +1,88 @@ +import { isEqual } from 'lodash'; + +import { EventBusSrv } from '@grafana/data'; +import { changeDatasource } from 'app/features/explore/state/datasource'; +import { changePanelsStateAction, initializeExplore } from 'app/features/explore/state/explorePane'; +import { splitClose, syncTimesAction } from 'app/features/explore/state/main'; +import { cancelQueries, runQueries, setQueriesAction } from 'app/features/explore/state/query'; +import { updateTime } from 'app/features/explore/state/time'; +import { fromURLRange } from 'app/features/explore/state/utils'; +import { withUniqueRefIds } from 'app/features/explore/utils/queries'; +import { ExploreItemState, ThunkDispatch } from 'app/types'; + +import { getUrlStateFromPaneState } from '../index'; +import { urlDiff } from '../internal.utils'; +import { ExploreURLV1 } from '../migrators/v1'; + +export function syncFromURL( + urlState: ExploreURLV1, + panesState: Record<string, undefined | ExploreItemState>, + dispatch: ThunkDispatch +) { + // if navigating the history causes one of the time range to not being equal to all the other ones, + // we set syncedTimes to false to avoid inconsistent UI state. + // Ideally `syncedTimes` should be saved in the URL. + const paneArray = Object.values(urlState.panes); + if (paneArray.length > 1) { + const paneTimesUnequal = paneArray.some(({ range }, _, [{ range: firstRange }]) => !isEqual(range, firstRange)); + dispatch(syncTimesAction({ syncedTimes: !paneTimesUnequal })); // if all time ranges are equal, keep them synced + } + + Object.entries(urlState.panes).forEach(async ([exploreId, urlPane], i) => { + const { datasource, queries, range, panelsState } = urlPane; + + const paneState = panesState[exploreId]; + + if (paneState !== undefined) { + const update = urlDiff(urlPane, getUrlStateFromPaneState(paneState)); + + Promise.resolve() + .then(async () => { + if (update.datasource && datasource) { + await dispatch(changeDatasource({ exploreId, datasource })); + } + return; + }) + .then(async () => { + if (update.range) { + dispatch(updateTime({ exploreId, rawRange: fromURLRange(range) })); + } + + if (update.queries) { + dispatch(setQueriesAction({ exploreId, queries: withUniqueRefIds(queries) })); + } + + if (update.queries || update.range) { + await dispatch(cancelQueries(exploreId)); + dispatch(runQueries({ exploreId })); + } + + if (update.panelsState && panelsState) { + dispatch(changePanelsStateAction({ exploreId, panelsState })); + } + }); + } else { + // This happens when browser history is used to navigate. + // In this case we want to initialize the pane with the data from the URL + // if it's not present in the store. This may happen if the user has navigated + // from split view to non-split view and then back to split view. + dispatch( + initializeExplore({ + exploreId, + datasource: datasource || '', + queries: withUniqueRefIds(queries), + range: fromURLRange(range), + panelsState, + position: i, + eventBridge: new EventBusSrv(), + }) + ); + } + }); + + // Close all the panes that are not in the URL but are still in the store + // ie. because the user has navigated back after opening the split view. + Object.keys(panesState) + .filter((keyInStore) => !Object.keys(urlState.panes).includes(keyInStore)) + .forEach((paneId) => dispatch(splitClose(paneId))); +} diff --git a/public/app/features/explore/hooks/useStateSync/synchronizer/init.ts b/public/app/features/explore/hooks/useStateSync/synchronizer/init.ts new file mode 100644 index 0000000000000..dae7cd105d3fe --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/synchronizer/init.ts @@ -0,0 +1,127 @@ +import { identity, isEqual } from 'lodash'; +import { MutableRefObject } from 'react'; + +import { EventBusSrv } from '@grafana/data'; +import { LocationService } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; +import { initializeExplore } from 'app/features/explore/state/explorePane'; +import { clearPanes, syncTimesAction } from 'app/features/explore/state/main'; +import { fromURLRange } from 'app/features/explore/state/utils'; +import { withUniqueRefIds } from 'app/features/explore/utils/queries'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { ThunkDispatch } from 'app/types'; + +import { getUrlStateFromPaneState } from '../index'; +import { + getDefaultQuery, + getPaneDatasource, + getQueryFilter, + InitState, + isMixedDatasource, + removeQueriesWithInvalidDatasource, +} from '../internal.utils'; +import { ExploreURLV1 } from '../migrators/v1'; + +export function initializeFromURL( + urlState: ExploreURLV1, + initState: MutableRefObject<InitState>, + orgId: number, + dispatch: ThunkDispatch, + location: LocationService +) { + // Clear all the panes in the store first to avoid stale data. + dispatch(clearPanes()); + + Promise.all( + Object.entries(urlState.panes).map(([exploreId, { datasource, queries, range, panelsState }]) => { + return getPaneDatasource(datasource, queries, orgId).then((paneDatasource) => { + return Promise.resolve( + // Given the Grafana datasource will always be present, this should always be defined. + paneDatasource + ? queries.length + ? // if we have queries in the URL, we use them + withUniqueRefIds(queries) + // but filter out the ones that are not compatible with the pane datasource + .filter(getQueryFilter(paneDatasource)) + .map( + isMixedDatasource(paneDatasource) + ? identity<DataQuery> + : (query) => ({ ...query, datasource: paneDatasource.getRef() }) + ) + : getDatasourceSrv() + // otherwise we get a default query from the pane datasource or from the default datasource if the pane datasource is mixed + .get(isMixedDatasource(paneDatasource) ? undefined : paneDatasource.getRef()) + .then((ds) => [getDefaultQuery(ds)]) + : [] + ).then(async (queries) => { + // we remove queries that have an invalid datasources + let validQueries = await removeQueriesWithInvalidDatasource(queries); + + if (!validQueries.length && paneDatasource) { + // and in case there's no query left we add a default one. + validQueries = [ + getDefaultQuery(isMixedDatasource(paneDatasource) ? await getDatasourceSrv().get() : paneDatasource), + ]; + } + + return { exploreId, range, panelsState, queries: validQueries, datasource: paneDatasource }; + }); + }); + }) + ).then(async (panes) => { + const initializedPanes = await Promise.all( + panes.map(({ exploreId, range, panelsState, queries, datasource }) => { + return dispatch( + initializeExplore({ + exploreId, + datasource, + queries, + range: fromURLRange(range), + panelsState, + eventBridge: new EventBusSrv(), + }) + ).unwrap(); + }) + ); + + if (initializedPanes.length > 1) { + const paneTimesUnequal = initializedPanes.some( + ({ state }, _, [{ state: firstState }]) => !isEqual(state.range.raw, firstState.range.raw) + ); + dispatch(syncTimesAction({ syncedTimes: !paneTimesUnequal })); // if all time ranges are equal, keep them synced + } + + const panesObj = initializedPanes.reduce((acc, { exploreId, state }) => { + return { + ...acc, + [exploreId]: getUrlStateFromPaneState(state), + }; + }, {}); + + // we need to use partial here beacuse replace doesn't encode the query params. + const oldQuery = location.getSearchObject(); + + // we create the default query params from the current URL, omitting all the properties we know should be in the final url. + // This includes params from previous schema versions and 'schemaVersion', 'panes', 'orgId' as we want to replace those. + let defaults: Record<string, unknown> = {}; + for (const [key, value] of Object.entries(oldQuery).filter( + ([key]) => !['schemaVersion', 'panes', 'orgId', 'left', 'right'].includes(key) + )) { + defaults[key] = value; + } + + const searchParams = new URLSearchParams({ + // we set the schemaVersion as the first parameter so that when URLs are truncated the schemaVersion is more likely to be present. + schemaVersion: `${urlState.schemaVersion}`, + panes: JSON.stringify(panesObj), + orgId: `${orgId}`, + ...defaults, + }); + + location.replace({ + pathname: location.getLocation().pathname, + search: searchParams.toString(), + }); + initState.current = 'done'; + }); +} diff --git a/public/app/features/explore/hooks/useStateSync/synchronizer/toURL.ts b/public/app/features/explore/hooks/useStateSync/synchronizer/toURL.ts new file mode 100644 index 0000000000000..ba164419822e0 --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/synchronizer/toURL.ts @@ -0,0 +1,75 @@ +import { Action } from '@reduxjs/toolkit'; +import { isEqual } from 'lodash'; +import { MutableRefObject } from 'react'; + +import { UrlQueryMap } from '@grafana/data'; +import { LocationService } from '@grafana/runtime'; +import { changeDatasource } from 'app/features/explore/state/datasource'; +import { changePanelsStateAction } from 'app/features/explore/state/explorePane'; +import { splitClose, splitOpen } from 'app/features/explore/state/main'; +import { runQueries } from 'app/features/explore/state/query'; +import { changeRangeAction } from 'app/features/explore/state/time'; +import { ExploreState } from 'app/types'; + +import { getUrlStateFromPaneState } from '../index'; +import { InitState } from '../internal.utils'; + +/* +We want to update the URL when: + - a pane is opened or closed + - a query is run + - range is changed + - panel state is updated + - a datasource change has completed. + +Note: Changing datasource causes a bunch of actions to be dispatched, we want to update the URL +only when the change set has completed. This is done by checking if the changeDatasource.pending action +has been dispatched and pausing the listener until the changeDatasource.fulfilled action is dispatched. +*/ +export function syncToURLPredicate(paused: MutableRefObject<boolean>, action: Action) { + paused.current = changeDatasource.pending.type === action.type; + + return ( + [ + splitClose.type, + splitOpen.fulfilled.type, + runQueries.pending.type, + changeRangeAction.type, + changePanelsStateAction.type, + changeDatasource.fulfilled.type, + ].includes(action.type) && !paused.current + ); +} + +export function syncToURL( + exploreState: ExploreState, + prevParams: MutableRefObject<UrlQueryMap>, + initState: MutableRefObject<InitState>, + location: LocationService +) { + const panesQueryParams = Object.entries(exploreState.panes).reduce((acc, [id, paneState]) => { + if (!paneState) { + return acc; + } + return { + ...acc, + [id]: getUrlStateFromPaneState(paneState), + }; + }, {}); + + if (!isEqual(prevParams.current.panes, JSON.stringify(panesQueryParams))) { + // If there's no previous state it means we are mounting explore for the first time, + // in this case we want to replace the URL instead of pushing a new entry to the history. + // If the init state is 'pending' it means explore still hasn't finished initializing. in that case we skip + // pushing a new entry in the history as the first entry will be pushed after initialization. + const replace = + (!!prevParams.current.panes && Object.values(prevParams.current.panes).filter(Boolean).length === 0) || + initState.current === 'pending'; + + prevParams.current = { + panes: JSON.stringify(panesQueryParams), + }; + + location.partial({ panes: prevParams.current.panes }, replace); + } +} diff --git a/public/app/features/explore/spec/datasourceState.test.tsx b/public/app/features/explore/spec/datasourceState.test.tsx index 158af770bd8d9..6a8c16b743337 100644 --- a/public/app/features/explore/spec/datasourceState.test.tsx +++ b/public/app/features/explore/spec/datasourceState.test.tsx @@ -1,4 +1,5 @@ import { screen, waitFor } from '@testing-library/react'; +import { Props } from 'react-virtualized-auto-sizer'; import { EventBusSrv } from '@grafana/data'; @@ -13,6 +14,16 @@ jest.mock('@grafana/runtime', () => ({ getAppEvents: () => testEventBus, })); +jest.mock('react-virtualized-auto-sizer', () => { + return ({ children }: Props) => + children({ + height: 1, + scaledHeight: 1, + scaledWidth: 1, + width: 1, + }); +}); + jest.mock('app/core/core', () => ({ contextSrv: { hasPermission: () => true, diff --git a/public/app/features/explore/spec/helper/interactions.ts b/public/app/features/explore/spec/helper/interactions.ts index 523a48db1aea7..3afd11fef18ea 100644 --- a/public/app/features/explore/spec/helper/interactions.ts +++ b/public/app/features/explore/spec/helper/interactions.ts @@ -1,4 +1,4 @@ -import { fireEvent, screen, within } from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { selectors } from '@grafana/e2e-selectors'; @@ -7,9 +7,9 @@ import { getAllByRoleInQueryHistoryTab, withinExplore } from './setup'; export const changeDatasource = async (name: string) => { const datasourcePicker = (await screen.findByTestId(selectors.components.DataSourcePicker.container)).children[0]; - fireEvent.keyDown(datasourcePicker, { keyCode: 40 }); - const option = screen.getByText(name); - fireEvent.click(option); + await userEvent.click(datasourcePicker); + const option = within(screen.getByTestId(selectors.components.DataSourcePicker.dataSourceList)).getAllByText(name)[0]; + await userEvent.click(option); }; export const inputQuery = async (query: string, exploreId = 'left') => { diff --git a/public/app/features/explore/spec/helper/query.ts b/public/app/features/explore/spec/helper/query.ts index a1ec1216b6484..6fc8b313bcd4b 100644 --- a/public/app/features/explore/spec/helper/query.ts +++ b/public/app/features/explore/spec/helper/query.ts @@ -1,9 +1,9 @@ import { from, Observable } from 'rxjs'; -import { ArrayDataFrame, DataQueryResponse, FieldType } from '@grafana/data'; +import { arrayToDataFrame, DataQueryResponse, FieldType } from '@grafana/data'; export function makeLogsQueryResponse(marker = ''): Observable<DataQueryResponse> { - const df = new ArrayDataFrame([{ ts: Date.now(), line: `custom log line ${marker}` }]); + const df = arrayToDataFrame([{ ts: Date.now(), line: `custom log line ${marker}` }]); df.meta = { preferredVisualisationType: 'logs', }; @@ -12,7 +12,7 @@ export function makeLogsQueryResponse(marker = ''): Observable<DataQueryResponse } export function makeMetricsQueryResponse(): Observable<DataQueryResponse> { - const df = new ArrayDataFrame([{ ts: Date.now(), val: 1 }]); + const df = arrayToDataFrame([{ ts: Date.now(), val: 1 }]); df.fields[0].type = FieldType.time; return from([{ data: [df] }]); } diff --git a/public/app/features/explore/spec/interpolation.test.tsx b/public/app/features/explore/spec/interpolation.test.tsx index 969f41f424aad..9efdf2868e3b5 100644 --- a/public/app/features/explore/spec/interpolation.test.tsx +++ b/public/app/features/explore/spec/interpolation.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Props } from 'react-virtualized-auto-sizer'; import { DataQueryRequest, EventBusSrv, serializeStateToUrlParam } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; @@ -25,8 +26,8 @@ jest.mock('app/core/core', () => ({ jest.mock('react-virtualized-auto-sizer', () => { return { __esModule: true, - default(props: any) { - return <div>{props.children({ width: 1000 })}</div>; + default(props: Props) { + return <div>{props.children({ height: 1, scaledHeight: 1, scaledWidth: 1000, width: 1000 })}</div>; }, }; }); diff --git a/public/app/features/explore/spec/query.test.tsx b/public/app/features/explore/spec/query.test.tsx index 492b9dcde2a66..185d4340cca49 100644 --- a/public/app/features/explore/spec/query.test.tsx +++ b/public/app/features/explore/spec/query.test.tsx @@ -1,4 +1,5 @@ import { screen } from '@testing-library/react'; +import { Props } from 'react-virtualized-auto-sizer'; import { EventBusSrv, serializeStateToUrlParam } from '@grafana/data'; @@ -12,6 +13,16 @@ jest.mock('@grafana/runtime', () => ({ getAppEvents: () => testEventBus, })); +jest.mock('react-virtualized-auto-sizer', () => { + return ({ children }: Props) => + children({ + height: 1, + scaledHeight: 1, + scaledWidth: 1, + width: 1, + }); +}); + describe('Explore: handle running/not running query', () => { afterEach(() => { tearDown(); diff --git a/public/app/features/explore/spec/queryHistory.test.tsx b/public/app/features/explore/spec/queryHistory.test.tsx index b4c80a13a8e68..7aed7e67ba3a9 100644 --- a/public/app/features/explore/spec/queryHistory.test.tsx +++ b/public/app/features/explore/spec/queryHistory.test.tsx @@ -1,7 +1,9 @@ import React from 'react'; +import { Props } from 'react-virtualized-auto-sizer'; -import { DataQuery, EventBusSrv, serializeStateToUrlParam } from '@grafana/data'; +import { EventBusSrv, serializeStateToUrlParam } from '@grafana/data'; import { config } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; @@ -72,8 +74,8 @@ jest.mock('app/core/services/PreferencesService', () => ({ jest.mock('react-virtualized-auto-sizer', () => { return { __esModule: true, - default(props: any) { - return <div>{props.children({ width: 1000 })}</div>; + default(props: Props) { + return <div>{props.children({ height: 1, scaledHeight: 1, scaledWidth: 1000, width: 1000 })}</div>; }, }; }); diff --git a/public/app/features/explore/spec/split.test.tsx b/public/app/features/explore/spec/split.test.tsx index fc39d8eba9631..d31e02b486c97 100644 --- a/public/app/features/explore/spec/split.test.tsx +++ b/public/app/features/explore/spec/split.test.tsx @@ -25,7 +25,16 @@ jest.mock('react-virtualized-auto-sizer', () => { return { __esModule: true, default(props: ComponentProps<typeof AutoSizer>) { - return <div>{props.children({ width: 1000, height: 1000 })}</div>; + return ( + <div> + {props.children({ + width: 1000, + scaledWidth: 1000, + scaledHeight: 1000, + height: 1000, + })} + </div> + ); }, }; }); diff --git a/public/app/features/explore/state/datasource.ts b/public/app/features/explore/state/datasource.ts index f2585d4609e13..762aebe3ee8f5 100644 --- a/public/app/features/explore/state/datasource.ts +++ b/public/app/features/explore/state/datasource.ts @@ -7,7 +7,7 @@ import { DataSourceRef } from '@grafana/schema'; import { RefreshPicker } from '@grafana/ui'; import { stopQueryState } from 'app/core/utils/explore'; import { getCorrelationsBySourceUIDs } from 'app/features/correlations/utils'; -import { ExploreItemState, ThunkResult } from 'app/types'; +import { ExploreItemState, createAsyncThunk } from 'app/types'; import { loadSupplementaryQueries } from '../utils/supplementaryQueries'; @@ -39,12 +39,15 @@ export const updateDatasourceInstanceAction = createAction<UpdateDatasourceInsta /** * Loads a new datasource identified by the given name. */ -export function changeDatasource( - exploreId: string, - datasource: string | DataSourceRef, - options?: { importQueries: boolean } -): ThunkResult<Promise<void>> { - return async (dispatch, getState) => { + +interface ChangeDatasourcePayload { + exploreId: string; + datasource: string | DataSourceRef; + options?: { importQueries: boolean }; +} +export const changeDatasource = createAsyncThunk( + 'explore/changeDatasource', + async ({ datasource, exploreId, options }: ChangeDatasourcePayload, { getState, dispatch }) => { const orgId = getState().user.orgId; const { history, instance } = await loadAndInitDatasource(orgId, datasource); const currentDataSourceInstance = getState().explore.panes[exploreId]!.datasourceInstance; @@ -80,8 +83,8 @@ export function changeDatasource( if (options?.importQueries) { dispatch(runQueries({ exploreId })); } - }; -} + } +); // // Reducer diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts index c426aaf690f3d..e6feeda299570 100644 --- a/public/app/features/explore/state/explorePane.ts +++ b/public/app/features/explore/state/explorePane.ts @@ -20,7 +20,6 @@ import { createAsyncThunk, ThunkResult } from 'app/types'; import { ExploreItemState } from 'app/types/explore'; import { datasourceReducer } from './datasource'; -import { historyReducer } from './history'; import { richHistorySearchFiltersUpdatedAction, richHistoryUpdatedAction } from './main'; import { queryReducer, runQueries } from './query'; import { timeReducer, updateTime } from './time'; @@ -214,7 +213,6 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac state = queryReducer(state, action); state = datasourceReducer(state, action); state = timeReducer(state, action); - state = historyReducer(state, action); if (richHistoryUpdatedAction.match(action)) { const { richHistory, total } = action.payload.richHistoryResults; diff --git a/public/app/features/explore/state/helpers.ts b/public/app/features/explore/state/helpers.ts index 1437bb0a84c9b..3d4ae375043fb 100644 --- a/public/app/features/explore/state/helpers.ts +++ b/public/app/features/explore/state/helpers.ts @@ -1,4 +1,5 @@ -import { DefaultTimeZone, TimeRange, toUtc, SupplementaryQueryType } from '@grafana/data'; +import { TimeRange, toUtc, SupplementaryQueryType } from '@grafana/data'; +import { defaultTimeZone } from '@grafana/schema'; export const createDefaultInitialState = () => { const t = toUtc(); @@ -14,7 +15,7 @@ export const createDefaultInitialState = () => { const defaultInitialState = { user: { orgId: '1', - timeZone: DefaultTimeZone, + timeZone: defaultTimeZone, }, explore: { panes: { diff --git a/public/app/features/explore/state/history.ts b/public/app/features/explore/state/history.ts index 8a02945ceb101..fa8348fd8dcd2 100644 --- a/public/app/features/explore/state/history.ts +++ b/public/app/features/explore/state/history.ts @@ -1,6 +1,3 @@ -import { AnyAction, createAction } from '@reduxjs/toolkit'; - -import { HistoryItem } from '@grafana/data'; import { DataQuery } from '@grafana/schema'; import { addToRichHistory, @@ -26,16 +23,6 @@ import { } from './main'; import { selectPanesEntries } from './selectors'; -// -// Actions and Payloads -// - -export interface HistoryUpdatedPayload { - exploreId: string; - history: HistoryItem[]; -} -export const historyUpdatedAction = createAction<HistoryUpdatedPayload>('explore/historyUpdated'); - // // Action creators // @@ -74,25 +61,33 @@ const forEachExplorePane = (state: ExploreState, callback: (item: ExploreItemSta }; export const addHistoryItem = ( + localOverride: boolean, datasourceUid: string, datasourceName: string, - queries: DataQuery[] + queries: DataQuery[], + hideAllErrorsAndWarnings: boolean ): ThunkResult<void> => { return async (dispatch, getState) => { - const { richHistoryStorageFull, limitExceeded } = await addToRichHistory( - datasourceUid, - datasourceName, + const showNotif = hideAllErrorsAndWarnings + ? { quotaExceededError: false, limitExceededWarning: false, otherErrors: false } + : { + quotaExceededError: !getState().explore.richHistoryStorageFull, + limitExceededWarning: !getState().explore.richHistoryLimitExceededWarningShown, + }; + const { richHistoryStorageFull, limitExceeded } = await addToRichHistory({ + localOverride, + datasource: { uid: datasourceUid, name: datasourceName }, queries, - false, - '', - !getState().explore.richHistoryStorageFull, - !getState().explore.richHistoryLimitExceededWarningShown - ); - if (richHistoryStorageFull) { - dispatch(richHistoryStorageFullAction()); - } - if (limitExceeded) { - dispatch(richHistoryLimitExceededAction()); + starred: false, + showNotif, + }); + if (!hideAllErrorsAndWarnings) { + if (richHistoryStorageFull) { + dispatch(richHistoryStorageFullAction()); + } + if (limitExceeded) { + dispatch(richHistoryLimitExceededAction()); + } } }; }; @@ -199,13 +194,3 @@ export const updateHistorySearchFilters = (exploreId: string, filters: RichHisto } }; }; - -export const historyReducer = (state: ExploreItemState, action: AnyAction): ExploreItemState => { - if (historyUpdatedAction.match(action)) { - return { - ...state, - history: action.payload.history, - }; - } - return state; -}; diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts index ebe59018553fd..7fb7f02622f5b 100644 --- a/public/app/features/explore/state/main.ts +++ b/public/app/features/explore/state/main.ts @@ -61,15 +61,16 @@ export const setPaneState = createAction<SetPaneStateActionPayload>('explore/set export const clearPanes = createAction('explore/clearPanes'); /** - * Ensure Explore doesn't exceed supported number of panes and initializes the new pane. + * Creates a new Explore pane. + * If 2 panes already exist, the last one (right) is closed before creating a new one. */ export const splitOpen = createAsyncThunk( 'explore/splitOpen', - async (options: SplitOpenOptions | undefined, { getState, dispatch, requestId }) => { + async (options: SplitOpenOptions | undefined, { getState, dispatch }) => { // we currently support showing only 2 panes in explore, so if this action is dispatched we know it has been dispatched from the "first" pane. const originState = Object.values(getState().explore.panes)[0]; - const queries = options?.queries ?? (options?.query ? [options?.query] : originState?.queries || []); + const queries = options?.queries ?? originState?.queries ?? []; Object.keys(getState().explore.panes).forEach((paneId, index) => { // Only 2 panes are supported. Remove panes before create a new one. @@ -80,9 +81,15 @@ export const splitOpen = createAsyncThunk( const splitRange = options?.range || originState?.range.raw || DEFAULT_RANGE; + let newPaneId = generateExploreId(); + // in case we have a duplicate id, generate a new one + while (getState().explore.panes[newPaneId]) { + newPaneId = generateExploreId(); + } + await dispatch( createNewSplitOpenPane({ - exploreId: requestId, + exploreId: newPaneId, datasource: options?.datasourceUid || originState?.datasourceInstance?.getRef(), queries: withUniqueRefIds(queries), range: splitRange, @@ -95,9 +102,6 @@ export const splitOpen = createAsyncThunk( if (originState?.range) { await dispatch(syncTimesAction({ syncedTimes: isEqual(originState.range.raw, splitRange) })); // if time ranges are equal, mark times as synced } - }, - { - idGenerator: generateExploreId, } ); diff --git a/public/app/features/explore/state/query.test.ts b/public/app/features/explore/state/query.test.ts index e9aca401bdde2..334270c8f681a 100644 --- a/public/app/features/explore/state/query.test.ts +++ b/public/app/features/explore/state/query.test.ts @@ -16,9 +16,12 @@ import { SupplementaryQueryType, } from '@grafana/data'; import { DataQuery, DataSourceRef } from '@grafana/schema'; +import config from 'app/core/config'; +import { queryLogsSample, queryLogsVolume } from 'app/features/logs/logsModel'; import { createAsyncThunk, ExploreItemState, StoreState, ThunkDispatch } from 'app/types'; import { reducerTester } from '../../../../test/core/redux/reducerTester'; +import * as richHistory from '../../../core/utils/richHistory'; import { configureStore } from '../../../store/configureStore'; import { setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv'; import { makeLogs } from '../__mocks__/makeLogs'; @@ -49,6 +52,8 @@ import { import * as actions from './query'; import { makeExplorePaneState } from './utils'; +jest.mock('app/features/logs/logsModel'); + const { testRange, defaultInitialState } = createDefaultInitialState(); const exploreId = 'left'; @@ -152,6 +157,11 @@ describe('runQueries', () => { } as unknown as Partial<StoreState>); }; + beforeEach(() => { + config.queryHistoryEnabled = false; + jest.clearAllMocks(); + }); + it('should pass dataFrames to state even if there is error in response', async () => { const { dispatch, getState } = setupTests(); setupQueryResponse(getState()); @@ -162,11 +172,11 @@ describe('runQueries', () => { expect(getState().explore.panes.left!.graphResult).toBeDefined(); }); - it('should modify the request-id for all supplementary queries', () => { + it('should modify the request-id for all supplementary queries', async () => { const { dispatch, getState } = setupTests(); setupQueryResponse(getState()); dispatch(saveCorrelationsAction({ exploreId: 'left', correlations: [] })); - dispatch(runQueries({ exploreId: 'left' })); + await dispatch(runQueries({ exploreId: 'left' })); const state = getState().explore.panes.left!; expect(state.queryResponse.request?.requestId).toBe('explore_left'); @@ -199,6 +209,24 @@ describe('runQueries', () => { await dispatch(saveCorrelationsAction({ exploreId: 'left', correlations: [] })); expect(getState().explore.panes.left!.graphResult).toBeDefined(); }); + + it('should add history items to both local and remote storage with the flag enabled', async () => { + config.queryHistoryEnabled = true; + const { dispatch } = setupTests(); + jest.spyOn(richHistory, 'addToRichHistory'); + await dispatch(runQueries({ exploreId: 'left' })); + expect((richHistory.addToRichHistory as jest.Mock).mock.calls).toHaveLength(2); + expect((richHistory.addToRichHistory as jest.Mock).mock.calls[0][0].localOverride).toBeTruthy(); + expect((richHistory.addToRichHistory as jest.Mock).mock.calls[1][0].localOverride).toBeFalsy(); + }); + + it('should add history items to local storage only with the flag disabled', async () => { + const { dispatch } = setupTests(); + jest.spyOn(richHistory, 'addToRichHistory'); + await dispatch(runQueries({ exploreId: 'left' })); + expect((richHistory.addToRichHistory as jest.Mock).mock.calls).toHaveLength(1); + expect((richHistory.addToRichHistory as jest.Mock).mock.calls[0][0].localOverride).toBeTruthy(); + }); }); describe('running queries', () => { @@ -846,7 +874,7 @@ describe('reducer', () => { }); }); - describe('supplementary queries', () => { + describe('legacy supplementary queries', () => { let dispatch: ThunkDispatch, getState: () => StoreState, unsubscribes: Function[], @@ -878,7 +906,7 @@ describe('reducer', () => { meta: { id: 'something', }, - getDataProvider: () => { + getDataProvider: (_: SupplementaryQueryType, request: DataQueryRequest<DataQuery>) => { return mockDataProvider(); }, getSupportedSupplementaryQueryTypes: () => [ @@ -898,8 +926,69 @@ describe('reducer', () => { setupQueryResponse(getState()); }); + it('should load supplementary queries after running the query', async () => { + await dispatch(runQueries({ exploreId: 'left' })); + expect(unsubscribes).toHaveLength(2); + }); + }); + + describe('supplementary queries', () => { + let dispatch: ThunkDispatch, + getState: () => StoreState, + unsubscribes: Function[], + mockDataProvider: () => Observable<DataQueryResponse>; + + beforeEach(() => { + unsubscribes = []; + mockDataProvider = () => { + return { + subscribe: () => { + const unsubscribe = jest.fn(); + unsubscribes.push(unsubscribe); + return { + unsubscribe, + }; + }, + } as unknown as Observable<DataQueryResponse>; + }; + + jest.mocked(queryLogsVolume).mockImplementation(() => mockDataProvider()); + jest.mocked(queryLogsSample).mockImplementation(() => mockDataProvider()); + + const store: { dispatch: ThunkDispatch; getState: () => StoreState } = configureStore({ + ...defaultInitialState, + explore: { + panes: { + left: { + ...defaultInitialState.explore.panes.left, + datasourceInstance: { + query: jest.fn(), + getRef: jest.fn(), + meta: { + id: 'something', + }, + getSupplementaryRequest: (_: SupplementaryQueryType, request: DataQueryRequest<DataQuery>) => { + return request; + }, + getSupportedSupplementaryQueryTypes: () => [ + SupplementaryQueryType.LogsVolume, + SupplementaryQueryType.LogsSample, + ], + getSupplementaryQuery: jest.fn(), + }, + }, + }, + }, + } as unknown as Partial<StoreState>); + + dispatch = store.dispatch; + getState = store.getState; + + setupQueryResponse(getState()); + }); + it('should cancel any unfinished supplementary queries when a new query is run', async () => { - dispatch(runQueries({ exploreId: 'left' })); + await dispatch(runQueries({ exploreId: 'left' })); // first query is run automatically // loading in progress - subscriptions for both supplementary queries are created, not cleaned up yet expect(unsubscribes).toHaveLength(2); @@ -907,7 +996,7 @@ describe('reducer', () => { expect(unsubscribes[1]).not.toBeCalled(); setupQueryResponse(getState()); - dispatch(runQueries({ exploreId: 'left' })); + await dispatch(runQueries({ exploreId: 'left' })); // a new query is run while supplementary queries are not resolve yet... expect(unsubscribes[0]).toBeCalled(); expect(unsubscribes[1]).toBeCalled(); @@ -917,8 +1006,8 @@ describe('reducer', () => { expect(unsubscribes[3]).not.toBeCalled(); }); - it('should cancel all supported supplementary queries when the main query is canceled', () => { - dispatch(runQueries({ exploreId: 'left' })); + it('should cancel all supported supplementary queries when the main query is canceled', async () => { + await dispatch(runQueries({ exploreId: 'left' })); expect(unsubscribes).toHaveLength(2); expect(unsubscribes[0]).not.toBeCalled(); expect(unsubscribes[1]).not.toBeCalled(); @@ -934,16 +1023,16 @@ describe('reducer', () => { } }); - it('should load supplementary queries after running the query', () => { - dispatch(runQueries({ exploreId: 'left' })); + it('should load supplementary queries after running the query', async () => { + await dispatch(runQueries({ exploreId: 'left' })); expect(unsubscribes).toHaveLength(2); }); - it('should clean any incomplete supplementary queries data when main query is canceled', () => { + it('should clean any incomplete supplementary queries data when main query is canceled', async () => { mockDataProvider = () => { return of({ state: LoadingState.Loading, error: undefined, data: [] }); }; - dispatch(runQueries({ exploreId: 'left' })); + await dispatch(runQueries({ exploreId: 'left' })); for (const type of supplementaryQueryTypes) { expect(getState().explore.panes.left!.supplementaryQueries[type].data).toBeDefined(); @@ -970,7 +1059,7 @@ describe('reducer', () => { { state: LoadingState.Done, error: undefined, data: [{}] } ); }; - dispatch(runQueries({ exploreId: 'left' })); + await dispatch(runQueries({ exploreId: 'left' })); for (const types of supplementaryQueryTypes) { expect(getState().explore.panes.left!.supplementaryQueries[types].data).toBeDefined(); @@ -987,7 +1076,7 @@ describe('reducer', () => { } }); - it('do not load disabled supplementary query data', () => { + it('do not load disabled supplementary query data', async () => { mockDataProvider = () => { return of({ state: LoadingState.Done, error: undefined, data: [{}] }); }; @@ -999,7 +1088,7 @@ describe('reducer', () => { expect(getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsSample].enabled).toBe(true); // verify that if we run a query, it will: 1) not do logs volume, 2) do logs sample 3) provider will still be set for both - dispatch(runQueries({ exploreId: 'left' })); + await dispatch(runQueries({ exploreId: 'left' })); expect( getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].data @@ -1026,7 +1115,7 @@ describe('reducer', () => { dispatch(setSupplementaryQueryEnabled('left', false, SupplementaryQueryType.LogsSample)); // runQueries sets up providers, but does not run queries - dispatch(runQueries({ exploreId: 'left' })); + await dispatch(runQueries({ exploreId: 'left' })); expect( getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].dataProvider ).toBeDefined(); diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 95924ac85275f..320f1b47108bc 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -10,9 +10,9 @@ import { DataQueryErrorType, DataQueryResponse, DataSourceApi, + dateTimeForTimeZone, hasQueryExportSupport, hasQueryImportSupport, - HistoryItem, LoadingState, LogsVolumeType, PanelEvents, @@ -21,21 +21,25 @@ import { SupplementaryQueryType, toLegacyResponseData, } from '@grafana/data'; +import { combinePanelData } from '@grafana/o11y-ds-frontend'; import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; +import store from 'app/core/store'; import { buildQueryTransaction, ensureQueries, generateEmptyQuery, generateNewKeyAndAddRefIdIfMissing, getQueryKeys, + getTimeRange, hasNonEmptyQuery, stopQueryState, - updateHistory, } from 'app/core/utils/explore'; import { getShiftedTimeRange } from 'app/core/utils/timePicker'; import { getCorrelationsBySourceUIDs } from 'app/features/correlations/utils'; -import { getTimeZone } from 'app/features/profile/state/selectors'; +import { infiniteScrollRefId } from 'app/features/logs/logsModel'; +import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors'; +import { SupportingQueryType } from 'app/plugins/datasource/loki/types'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { createAsyncThunk, @@ -51,6 +55,7 @@ import { ExploreState, QueryOptions, SupplementaryQueries } from 'app/types/expl import { notifyApp } from '../../../core/actions'; import { createErrorNotification } from '../../../core/copy/appNotification'; import { runRequest } from '../../query/state/runRequest'; +import { visualisationTypeKey } from '../Logs/utils/logs'; import { decorateData } from '../utils/decorators'; import { getSupplementaryQueryProvider, @@ -60,10 +65,16 @@ import { import { getCorrelations } from './correlations'; import { saveCorrelationsAction } from './explorePane'; -import { addHistoryItem, historyUpdatedAction, loadRichHistory } from './history'; +import { addHistoryItem, loadRichHistory } from './history'; import { changeCorrelationEditorDetails } from './main'; import { updateTime } from './time'; -import { createCacheKey, filterLogRowsByIndex, getDatasourceUIDs, getResultsFromCache } from './utils'; +import { + createCacheKey, + filterLogRowsByIndex, + getCorrelationsData, + getDatasourceUIDs, + getResultsFromCache, +} from './utils'; /** * Derives from explore state if a given Explore pane is waiting for more data to be received @@ -468,16 +479,18 @@ export function modifyQueries( async function handleHistory( dispatch: ThunkDispatch, state: ExploreState, - history: Array<HistoryItem<DataQuery>>, datasource: DataSourceApi, - queries: DataQuery[], - exploreId: string + queries: DataQuery[] ) { - const datasourceId = datasource.meta.id; - const nextHistory = updateHistory(history, datasourceId, queries); - dispatch(historyUpdatedAction({ exploreId, history: nextHistory })); - - dispatch(addHistoryItem(datasource.uid, datasource.name, queries)); + /* + Always write to local storage. If query history is enabled, we will use local storage for autocomplete only (and want to hide errors) + If query history is disabled, we will use local storage for query history as well, and will want to show errors + */ + dispatch(addHistoryItem(true, datasource.uid, datasource.name, queries, config.queryHistoryEnabled)); + if (config.queryHistoryEnabled) { + // write to remote if flag enabled + dispatch(addHistoryItem(false, datasource.uid, datasource.name, queries, false)); + } // Because filtering happens in the backend we cannot add a new entry without checking if it matches currently // used filters. Instead, we refresh the query history list. @@ -499,17 +512,21 @@ export const runQueries = createAsyncThunk<void, RunQueriesOptions>( async ({ exploreId, preserveCache }, { dispatch, getState }) => { dispatch(cancelQueries(exploreId)); - dispatch(updateTime({ exploreId })); - + const { defaultCorrelationEditorDatasource, scopedVars, showCorrelationEditorLinks } = await getCorrelationsData( + getState(), + exploreId + ); const correlations$ = getCorrelations(exploreId); + dispatch(updateTime({ exploreId })); + // We always want to clear cache unless we explicitly pass preserveCache parameter if (preserveCache !== true) { dispatch(clearCache(exploreId)); } - const exploreItemState = getState().explore.panes[exploreId]!; - + const exploreState = getState(); + const exploreItemState = exploreState.explore.panes[exploreId]!; const { datasourceInstance, containerWidth, @@ -522,14 +539,8 @@ export const runQueries = createAsyncThunk<void, RunQueriesOptions>( absoluteRange, cache, supplementaryQueries, - correlationEditorHelperData, } = exploreItemState; - const isCorrelationEditorMode = getState().explore.correlationEditorDetails?.editorMode || false; - const isLeftPane = Object.keys(getState().explore.panes)[0] === exploreId; - const showCorrelationEditorLinks = isCorrelationEditorMode && isLeftPane; - const defaultCorrelationEditorDatasource = showCorrelationEditorLinks ? await getDataSourceSrv().get() : undefined; - const interpolateCorrelationHelperVars = - isCorrelationEditorMode && !isLeftPane && correlationEditorHelperData !== undefined; + let newQuerySource: Observable<ExplorePanelData>; let newQuerySubscription: SubscriptionLike; @@ -539,7 +550,7 @@ export const runQueries = createAsyncThunk<void, RunQueriesOptions>( })); if (datasourceInstance != null) { - handleHistory(dispatch, getState().explore, exploreItemState.history, datasourceInstance, queries, exploreId); + handleHistory(dispatch, getState().explore, datasourceInstance, queries); } const cachedValue = getResultsFromCache(cache, absoluteRange); @@ -589,13 +600,6 @@ export const runQueries = createAsyncThunk<void, RunQueriesOptions>( liveStreaming: live, }; - let scopedVars: ScopedVars = {}; - if (interpolateCorrelationHelperVars && correlationEditorHelperData !== undefined) { - Object.entries(correlationEditorHelperData?.vars).forEach((variable) => { - scopedVars[variable[0]] = { value: variable[1] }; - }); - } - const timeZone = getTimeZone(getState().user); const transaction = buildQueryTransaction( exploreId, @@ -633,17 +637,21 @@ export const runQueries = createAsyncThunk<void, RunQueriesOptions>( newQuerySubscription = newQuerySource.subscribe({ next(data) { + const exploreState = getState().explore.panes[exploreId]; if (data.logsResult !== null && data.state === LoadingState.Done) { reportInteraction('grafana_explore_logs_result_displayed', { datasourceType: datasourceInstance.type, + visualisationType: + exploreState?.panelsState?.logs?.visualisationType ?? store.get(visualisationTypeKey) ?? 'N/A', + length: data.logsResult.rows.length, }); } dispatch(queryStreamUpdatedAction({ exploreId, response: data })); // Keep scanning for results if this was the last scanning transaction - if (getState().explore.panes[exploreId]!.scanning) { + if (exploreState!.scanning) { if (data.state === LoadingState.Done && data.series.length === 0) { - const range = getShiftedTimeRange(-1, getState().explore.panes[exploreId]!.range); + const range = getShiftedTimeRange(-1, exploreState!.range); dispatch(updateTime({ exploreId, absoluteRange: range })); dispatch(runQueries({ exploreId })); } else { @@ -696,6 +704,97 @@ export const runQueries = createAsyncThunk<void, RunQueriesOptions>( } ); +interface RunLoadMoreLogsQueriesOptions { + exploreId: string; + absoluteRange: AbsoluteTimeRange; +} +/** + * Dedicated action to run log queries requesting more results. + */ +export const runLoadMoreLogsQueries = createAsyncThunk<void, RunLoadMoreLogsQueriesOptions>( + 'explore/runLoadMoreQueries', + async ({ exploreId, absoluteRange }, { dispatch, getState }) => { + dispatch(cancelQueries(exploreId)); + + const { datasourceInstance, containerWidth, queryResponse } = getState().explore.panes[exploreId]!; + const { defaultCorrelationEditorDatasource, scopedVars, showCorrelationEditorLinks } = await getCorrelationsData( + getState(), + exploreId + ); + const correlations$ = getCorrelations(exploreId); + + let newQuerySource: Observable<ExplorePanelData>; + + const queries = queryResponse.logsResult?.queries || []; + const logRefIds = queryResponse.logsFrames.map((frame) => frame.refId); + const logQueries = queries + .filter((query) => logRefIds.includes(query.refId)) + .map((query: DataQuery) => ({ + ...query, + datasource: query.datasource || datasourceInstance?.getRef(), + refId: `${infiniteScrollRefId}${query.refId}`, + supportingQueryType: SupportingQueryType.InfiniteScroll, + })); + + if (!hasNonEmptyQuery(logQueries) || !datasourceInstance) { + return; + } + + const queryOptions: QueryOptions = { + minInterval: datasourceInstance?.interval, + maxDataPoints: containerWidth, + }; + + const timeZone = getTimeZone(getState().user); + const range = getTimeRange( + timeZone, + { + from: dateTimeForTimeZone(timeZone, absoluteRange.from), + to: dateTimeForTimeZone(timeZone, absoluteRange.to), + }, + getFiscalYearStartMonth(getState().user) + ); + const transaction = buildQueryTransaction(exploreId, logQueries, queryOptions, range, false, timeZone, scopedVars); + + dispatch(changeLoadingStateAction({ exploreId, loadingState: LoadingState.Loading })); + + newQuerySource = combineLatest([runRequest(datasourceInstance, transaction.request), correlations$]).pipe( + mergeMap(([data, correlations]) => { + // For query splitting, otherwise duplicates results + if (data.state !== LoadingState.Done) { + // While loading, return the previous response and override state, otherwise it's set to Done + return of({ ...queryResponse, state: LoadingState.Loading }); + } + return decorateData( + // This shouldn't be needed after https://github.com/grafana/grafana/issues/57327 is fixed + combinePanelData(queryResponse, data), + queryResponse, + absoluteRange, + undefined, + logQueries, + correlations, + showCorrelationEditorLinks, + defaultCorrelationEditorDatasource + ); + }) + ); + + newQuerySource.subscribe({ + next(data) { + dispatch(queryStreamUpdatedAction({ exploreId, response: data })); + }, + error(error) { + dispatch(notifyApp(createErrorNotification('Query processing error', error))); + dispatch(changeLoadingStateAction({ exploreId, loadingState: LoadingState.Error })); + console.error(error); + }, + complete() { + dispatch(changeLoadingStateAction({ exploreId, loadingState: LoadingState.Done })); + }, + }); + } +); + const groupDataQueries = async (datasources: DataQuery[], scopedVars: ScopedVars) => { const nonMixedDataSources = datasources.filter((t) => { return t.datasource?.uid !== MIXED_DATASOURCE_NAME; diff --git a/public/app/features/explore/state/time.ts b/public/app/features/explore/state/time.ts index ddffb459ac86c..38683f45bf43c 100644 --- a/public/app/features/explore/state/time.ts +++ b/public/app/features/explore/state/time.ts @@ -1,17 +1,26 @@ import { AnyAction, createAction } from '@reduxjs/toolkit'; -import { AbsoluteTimeRange, dateTimeForTimeZone, LoadingState, RawTimeRange, TimeRange } from '@grafana/data'; +import { + AbsoluteTimeRange, + AppEvents, + dateTimeForTimeZone, + LoadingState, + RawTimeRange, + TimeRange, +} from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; import { RefreshPicker } from '@grafana/ui'; +import { t } from '@grafana/ui/src/utils/i18n'; +import appEvents from 'app/core/app_events'; import { getTimeRange, refreshIntervalToSortOrder, stopQueryState } from 'app/core/utils/explore'; -import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; +import { getCopiedTimeRange, getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { sortLogsResult } from 'app/features/logs/utils'; import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors'; import { ExploreItemState, ThunkDispatch, ThunkResult } from 'app/types'; import { syncTimesAction } from './main'; -import { runQueries } from './query'; +import { runLoadMoreLogsQueries, runQueries } from './query'; // // Actions and Payloads @@ -54,6 +63,12 @@ export const updateTimeRange = (options: { }; }; +export const loadMoreLogs = (options: { exploreId: string; absoluteRange: AbsoluteTimeRange }): ThunkResult<void> => { + return (dispatch) => { + dispatch(runLoadMoreLogsQueries({ ...options })); + }; +}; + export const updateTime = (config: { exploreId: string; rawRange?: RawTimeRange; @@ -160,6 +175,41 @@ export function zoomOut(scale: number): ThunkResult<void> { }); } +export function copyTimeRangeToClipboard(): ThunkResult<void> { + return (dispatch, getState) => { + const range = getState().explore.panes[Object.keys(getState().explore.panes)[0]]!.range.raw; + navigator.clipboard.writeText(JSON.stringify(range)); + + appEvents.emit(AppEvents.alertSuccess, [ + t('time-picker.copy-paste.copy-success-message', 'Time range copied to clipboard'), + ]); + }; +} + +export function pasteTimeRangeFromClipboard(): ThunkResult<void> { + return async (dispatch, getState) => { + const { range, isError } = await getCopiedTimeRange(); + + if (isError === true) { + appEvents.emit(AppEvents.alertError, [ + t('time-picker.copy-paste.default-error-title', 'Invalid time range'), + t('time-picker.copy-paste.default-error-message', `{{error}} is not a valid time range`, { error: range }), + ]); + return; + } + + const panesSynced = getState().explore.syncedTimes; + + if (panesSynced) { + dispatch(updateTimeRange({ exploreId: Object.keys(getState().explore.panes)[0], rawRange: range })); + dispatch(updateTimeRange({ exploreId: Object.keys(getState().explore.panes)[1], rawRange: range })); + return; + } + + dispatch(updateTimeRange({ exploreId: Object.keys(getState().explore.panes)[0], rawRange: range })); + }; +} + /** * Reducer for an Explore area, to be used by the global Explore reducer. */ diff --git a/public/app/features/explore/state/utils.test.ts b/public/app/features/explore/state/utils.test.ts index e58c1fe4b43cb..e044c64728797 100644 --- a/public/app/features/explore/state/utils.test.ts +++ b/public/app/features/explore/state/utils.test.ts @@ -1,6 +1,9 @@ import { dateTime } from '@grafana/data'; +import { getLocalRichHistoryStorage } from 'app/core/history/richHistoryStorageProvider'; import * as exploreUtils from 'app/core/utils/explore'; +import { loadAndInitDatasource, getRange, fromURLRange, MAX_HISTORY_AUTOCOMPLETE_ITEMS } from './utils'; + const dataSourceMock = { get: jest.fn(), }; @@ -8,7 +11,15 @@ jest.mock('app/features/plugins/datasource_srv', () => ({ getDatasourceSrv: jest.fn(() => dataSourceMock), })); -import { loadAndInitDatasource, getRange, fromURLRange } from './utils'; +const mockLocalDataStorage = { + getRichHistory: jest.fn(), +}; + +jest.mock('app/core/history/richHistoryStorageProvider', () => ({ + getLocalRichHistoryStorage: jest.fn(() => { + return mockLocalDataStorage; + }), +})); const DEFAULT_DATASOURCE = { uid: 'abc123', name: 'Default' }; const TEST_DATASOURCE = { uid: 'def789', name: 'Test' }; @@ -28,6 +39,7 @@ describe('loadAndInitDatasource', () => { setLastUsedDatasourceUIDSpy = jest.spyOn(exploreUtils, 'setLastUsedDatasourceUID'); dataSourceMock.get.mockRejectedValueOnce(new Error('Datasource not found')); dataSourceMock.get.mockResolvedValue(DEFAULT_DATASOURCE); + mockLocalDataStorage.getRichHistory.mockResolvedValue({ total: 0, richHistory: [] }); const { instance } = await loadAndInitDatasource(1, { uid: 'Unknown' }); @@ -41,14 +53,111 @@ describe('loadAndInitDatasource', () => { it('saves last loaded data source uid', async () => { setLastUsedDatasourceUIDSpy = jest.spyOn(exploreUtils, 'setLastUsedDatasourceUID'); dataSourceMock.get.mockResolvedValue(TEST_DATASOURCE); + mockLocalDataStorage.getRichHistory.mockResolvedValue({ + total: 0, + richHistory: [], + }); const { instance } = await loadAndInitDatasource(1, { uid: 'Test' }); - expect(dataSourceMock.get).toBeCalledTimes(1); - expect(dataSourceMock.get).toBeCalledWith({ uid: 'Test' }); + expect(dataSourceMock.get).toHaveBeenCalledTimes(1); + expect(dataSourceMock.get).toHaveBeenCalledWith({ uid: 'Test' }); + expect(getLocalRichHistoryStorage).toHaveBeenCalledTimes(1); + expect(instance).toMatchObject(TEST_DATASOURCE); expect(setLastUsedDatasourceUIDSpy).toBeCalledWith(1, TEST_DATASOURCE.uid); }); + + it('pulls history data and returns the history by query', async () => { + setLastUsedDatasourceUIDSpy = jest.spyOn(exploreUtils, 'setLastUsedDatasourceUID'); + dataSourceMock.get.mockResolvedValue(TEST_DATASOURCE); + mockLocalDataStorage.getRichHistory.mockResolvedValueOnce({ + total: 1, + richHistory: [ + { + id: '0', + createdAt: 0, + datasourceUid: 'Test', + datasourceName: 'Test', + starred: false, + comment: '', + queries: [{ refId: 'A' }, { refId: 'B' }], + }, + ], + }); + + const { history } = await loadAndInitDatasource(1, { uid: 'Test' }); + expect(getLocalRichHistoryStorage).toHaveBeenCalledTimes(1); + expect(history.length).toEqual(2); + }); + + it('pulls history data and returns the history by query with Mixed results', async () => { + setLastUsedDatasourceUIDSpy = jest.spyOn(exploreUtils, 'setLastUsedDatasourceUID'); + dataSourceMock.get.mockResolvedValue(TEST_DATASOURCE); + mockLocalDataStorage.getRichHistory.mockResolvedValueOnce({ + total: 1, + richHistory: [ + { + id: '0', + createdAt: 0, + datasourceUid: 'Test', + datasourceName: 'Test', + starred: false, + comment: '', + queries: [{ refId: 'A' }, { refId: 'B' }], + }, + ], + }); + + mockLocalDataStorage.getRichHistory.mockResolvedValueOnce({ + total: 1, + richHistory: [ + { + id: '0', + createdAt: 0, + datasourceUid: 'Mixed', + datasourceName: 'Mixed', + starred: false, + comment: '', + queries: [ + { refId: 'A', datasource: { uid: 'def789' } }, + { refId: 'B', datasource: { uid: 'def789' } }, + ], + }, + ], + }); + + const { history } = await loadAndInitDatasource(1, { uid: 'Test' }); + expect(getLocalRichHistoryStorage).toHaveBeenCalledTimes(1); + expect(history.length).toEqual(4); + }); + + it('pulls history data and returns only a max of MAX_HISTORY_AUTOCOMPLETE_ITEMS items', async () => { + const queryList = [...Array(MAX_HISTORY_AUTOCOMPLETE_ITEMS + 50).keys()].map((i) => { + return { refId: `ref-${i}` }; + }); + + setLastUsedDatasourceUIDSpy = jest.spyOn(exploreUtils, 'setLastUsedDatasourceUID'); + dataSourceMock.get.mockResolvedValue(TEST_DATASOURCE); + mockLocalDataStorage.getRichHistory.mockResolvedValueOnce({ + total: 1, + richHistory: [ + { + id: '0', + createdAt: 0, + datasourceUid: 'Test', + datasourceName: 'Test', + starred: false, + comment: '', + queries: queryList, + }, + ], + }); + + const { history } = await loadAndInitDatasource(1, { uid: 'Test' }); + expect(getLocalRichHistoryStorage).toHaveBeenCalledTimes(1); + expect(history.length).toEqual(MAX_HISTORY_AUTOCOMPLETE_ITEMS); + }); }); describe('getRange', () => { diff --git a/public/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts index db1d0d28f485d..0918049c03e3e 100644 --- a/public/app/features/explore/state/utils.ts +++ b/public/app/features/explore/state/utils.ts @@ -13,24 +13,30 @@ import { LogRowModel, PanelData, RawTimeRange, + ScopedVars, TimeFragment, TimeRange, toUtc, URLRange, URLRangeValue, } from '@grafana/data'; -import { DataQuery, DataSourceRef, TimeZone } from '@grafana/schema'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { DataQuery, DataSourceJsonData, DataSourceRef, TimeZone } from '@grafana/schema'; +import { getLocalRichHistoryStorage } from 'app/core/history/richHistoryStorageProvider'; +import { SortOrder } from 'app/core/utils/richHistory'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; -import { ExplorePanelData } from 'app/types'; -import { ExploreItemState } from 'app/types/explore'; +import { ExplorePanelData, StoreState } from 'app/types'; +import { ExploreItemState, RichHistoryQuery } from 'app/types/explore'; import store from '../../../core/store'; import { setLastUsedDatasourceUID } from '../../../core/utils/explore'; import { getDatasourceSrv } from '../../plugins/datasource_srv'; import { loadSupplementaryQueries } from '../utils/supplementaryQueries'; +export const MAX_HISTORY_AUTOCOMPLETE_ITEMS = 100; + export const DEFAULT_RANGE = { - from: 'now-6h', + from: 'now-1h', to: 'now', }; @@ -98,7 +104,7 @@ export async function loadAndInitDatasource( orgId: number, datasource: DataSourceRef | string ): Promise<{ history: HistoryItem[]; instance: DataSourceApi }> { - let instance; + let instance: DataSourceApi<DataQuery, DataSourceJsonData, {}>; try { // let datasource be a ref if we have the info, otherwise a name or uid will do for lookup instance = await getDatasourceSrv().get(datasource); @@ -117,12 +123,60 @@ export async function loadAndInitDatasource( } } - const historyKey = `grafana.explore.history.${instance.meta?.id}`; - const history = store.getObject<HistoryItem[]>(historyKey, []); - // Save last-used datasource + let history: HistoryItem[] = []; + + const localStorageHistory = getLocalRichHistoryStorage(); + + const historyResults = await localStorageHistory.getRichHistory({ + search: '', + sortOrder: SortOrder.Ascending, + datasourceFilters: [instance.name], + starred: false, + }); + + // first, fill autocomplete with query history for that datasource + if ((historyResults.total || 0) > 0) { + historyResults.richHistory.forEach((historyResult: RichHistoryQuery) => { + historyResult.queries.forEach((q) => { + history.push({ ts: parseInt(historyResult.id, 10), query: q }); + }); + }); + } + if (history.length < MAX_HISTORY_AUTOCOMPLETE_ITEMS) { + // check the last 100 mixed history results seperately + const historyMixedResults = await localStorageHistory.getRichHistory({ + search: '', + sortOrder: SortOrder.Ascending, + datasourceFilters: [MIXED_DATASOURCE_NAME], + starred: false, + }); + + if ((historyMixedResults.total || 0) > 0) { + // second, fill autocomplete with queries for that datasource used in Mixed scenarios + historyMixedResults.richHistory.forEach((historyResult: RichHistoryQuery) => { + historyResult.queries.forEach((q) => { + if (q?.datasource?.uid === instance.uid) { + history.push({ ts: parseInt(historyResult.id, 10), query: q }); + } + }); + }); + } + } + + // finally, add any legacy local storage history that might exist. To be removed in Grafana 12 #83309 + if (history.length < MAX_HISTORY_AUTOCOMPLETE_ITEMS) { + const historyKey = `grafana.explore.history.${instance.meta?.id}`; + history = [...history, ...store.getObject<HistoryItem[]>(historyKey, [])]; + } + + if (history.length > MAX_HISTORY_AUTOCOMPLETE_ITEMS) { + history.length = MAX_HISTORY_AUTOCOMPLETE_ITEMS; + } + + // Save last-used datasource setLastUsedDatasourceUID(orgId, instance.uid); - return { history, instance }; + return { history: history, instance }; } export function createCacheKey(absRange: AbsoluteTimeRange) { @@ -235,3 +289,23 @@ export const getDatasourceUIDs = (datasourceUID: string, queries: DataQuery[]): return [datasourceUID]; } }; + +export async function getCorrelationsData(state: StoreState, exploreId: string) { + const correlationEditorHelperData = state.explore.panes[exploreId]!.correlationEditorHelperData; + + const isCorrelationEditorMode = state.explore.correlationEditorDetails?.editorMode || false; + const isLeftPane = Object.keys(state.explore.panes)[0] === exploreId; + const showCorrelationEditorLinks = isCorrelationEditorMode && isLeftPane; + const defaultCorrelationEditorDatasource = showCorrelationEditorLinks ? await getDataSourceSrv().get() : undefined; + const interpolateCorrelationHelperVars = + isCorrelationEditorMode && !isLeftPane && correlationEditorHelperData !== undefined; + + let scopedVars: ScopedVars = {}; + if (interpolateCorrelationHelperVars && correlationEditorHelperData !== undefined) { + Object.entries(correlationEditorHelperData?.vars).forEach((variable) => { + scopedVars[variable[0]] = { value: variable[1] }; + }); + } + + return { defaultCorrelationEditorDatasource, scopedVars, showCorrelationEditorLinks }; +} diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index 0c95005ca91e0..bc530234b244f 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -299,8 +299,8 @@ const builtInVariables = [ * @param query * @param scopedVars */ -export function getVariableUsageInfo<T extends DataLink>( - query: T, +export function getVariableUsageInfo( + query: object, scopedVars: ScopedVars ): { variables: VariableInterpolation[]; allVariablesDefined: boolean } { let variables: VariableInterpolation[] = []; diff --git a/public/app/features/explore/utils/supplementaryQueries.test.ts b/public/app/features/explore/utils/supplementaryQueries.test.ts index 3f13e0d2f511d..6c231f4d20661 100644 --- a/public/app/features/explore/utils/supplementaryQueries.test.ts +++ b/public/app/features/explore/utils/supplementaryQueries.test.ts @@ -1,10 +1,9 @@ import { flatten } from 'lodash'; -import { from, Observable } from 'rxjs'; +import { Observable, from } from 'rxjs'; import { DataFrame, DataQueryRequest, - DataQueryResponse, DataSourceApi, DataSourceWithSupplementaryQueriesSupport, FieldType, @@ -15,8 +14,8 @@ import { SupplementaryQueryType, SupplementaryQueryOptions, toDataFrame, + DataQueryResponse, } from '@grafana/data'; -import { getDataSourceSrv } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { MockDataSourceApi } from '../../../../test/mocks/datasource_srv'; @@ -40,21 +39,26 @@ class MockDataSourceWithSupplementaryQuerySupport return this; } - getDataProvider( + query(_: DataQueryRequest): Observable<DataQueryResponse> { + const data = + this.supplementaryQueriesResults[SupplementaryQueryType.LogsVolume] || + this.supplementaryQueriesResults[SupplementaryQueryType.LogsSample] || + []; + return from([{ state: LoadingState.Done, data }]); + } + + getSupplementaryRequest( type: SupplementaryQueryType, request: DataQueryRequest<DataQuery> - ): Observable<DataQueryResponse> | undefined { + ): DataQueryRequest<DataQuery> | undefined { const data = this.supplementaryQueriesResults[type]; if (data) { - return from([ - { state: LoadingState.Loading, data: [] }, - { state: LoadingState.Done, data }, - ]); + return request; } return undefined; } - getSupplementaryQuery(options: SupplementaryQueryOptions, query: DataQuery): DataQuery | undefined { + getSupplementaryQuery(_: SupplementaryQueryOptions, query: DataQuery): DataQuery | undefined { return query; } @@ -136,38 +140,27 @@ const datasources: DataSourceApi[] = [ new MockDataSourceApi('no-data-providers-2'), ]; -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getDataSourceSrv: () => { - return { - get: async ({ uid }: { uid: string }) => datasources.find((ds) => ds.name === uid) || undefined, - }; - }, -})); - -const setup = async (targetSources: string[], type: SupplementaryQueryType) => { +const setup = (targetSources: string[], type: SupplementaryQueryType) => { const requestMock = new MockDataQueryRequest({ targets: targetSources.map((source, i) => new MockQuery(`${i}`, 'a', { uid: source })), }); const explorePanelDataMock: Observable<ExplorePanelData> = mockExploreDataWithLogs(); - const datasources = await Promise.all( - targetSources.map(async (source, i) => { - const datasource = await getDataSourceSrv().get({ uid: source }); - return { - datasource, - targets: [new MockQuery(`${i}`, 'a', { uid: source })], - }; - }) - ); + const groupedQueries = targetSources.map((source, i) => { + const datasource = datasources.find((datasource) => datasource.name === source) || datasources[0]; + return { + datasource, + targets: [new MockQuery(`${i}`, 'a', { uid: datasource.name })], + }; + }); - return getSupplementaryQueryProvider(datasources, type, requestMock, explorePanelDataMock); + return getSupplementaryQueryProvider(groupedQueries, type, requestMock, explorePanelDataMock); }; const assertDataFrom = (type: SupplementaryQueryType, ...datasources: string[]) => { return flatten( datasources.map((name: string) => { - return [{ refId: `1-${type}-${name}` }, { refId: `2-${type}-${name}` }]; + return createSupplementaryQueryResponse(type, name); }) ); }; @@ -179,7 +172,7 @@ const assertDataFromLogsResults = () => { describe('SupplementaryQueries utils', function () { describe('Non-mixed data source', function () { it('Returns result from the provider', async () => { - const testProvider = await setup(['logs-volume-a'], SupplementaryQueryType.LogsVolume); + const testProvider = setup(['logs-volume-a'], SupplementaryQueryType.LogsVolume); await expect(testProvider).toEmitValuesWith((received) => { expect(received).toMatchObject([ @@ -192,7 +185,7 @@ describe('SupplementaryQueries utils', function () { }); }); it('Uses fallback for logs volume', async () => { - const testProvider = await setup(['no-data-providers'], SupplementaryQueryType.LogsVolume); + const testProvider = setup(['no-data-providers'], SupplementaryQueryType.LogsVolume); await expect(testProvider).toEmitValuesWith((received) => { expect(received).toMatchObject([ @@ -204,11 +197,11 @@ describe('SupplementaryQueries utils', function () { }); }); it('Returns undefined for logs sample', async () => { - const testProvider = await setup(['no-data-providers'], SupplementaryQueryType.LogsSample); + const testProvider = setup(['no-data-providers'], SupplementaryQueryType.LogsSample); await expect(testProvider).toBe(undefined); }); it('Creates single fallback result', async () => { - const testProvider = await setup(['no-data-providers', 'no-data-providers-2'], SupplementaryQueryType.LogsVolume); + const testProvider = setup(['no-data-providers', 'no-data-providers-2'], SupplementaryQueryType.LogsVolume); await expect(testProvider).toEmitValuesWith((received) => { expect(received).toMatchObject([ @@ -229,7 +222,7 @@ describe('SupplementaryQueries utils', function () { describe('Logs volume', function () { describe('All data sources support full range logs volume', function () { it('Merges all data frames into a single response', async () => { - const testProvider = await setup(['logs-volume-a', 'logs-volume-b'], SupplementaryQueryType.LogsVolume); + const testProvider = setup(['logs-volume-a', 'logs-volume-b'], SupplementaryQueryType.LogsVolume); await expect(testProvider).toEmitValuesWith((received) => { expect(received).toMatchObject([ { data: [], state: LoadingState.Loading }, @@ -248,10 +241,7 @@ describe('SupplementaryQueries utils', function () { describe('All data sources do not support full range logs volume', function () { it('Creates single fallback result', async () => { - const testProvider = await setup( - ['no-data-providers', 'no-data-providers-2'], - SupplementaryQueryType.LogsVolume - ); + const testProvider = setup(['no-data-providers', 'no-data-providers-2'], SupplementaryQueryType.LogsVolume); await expect(testProvider).toEmitValuesWith((received) => { expect(received).toMatchObject([ @@ -270,7 +260,7 @@ describe('SupplementaryQueries utils', function () { describe('Some data sources support full range logs volume, while others do not', function () { it('Creates merged result containing full range and limited logs volume', async () => { - const testProvider = await setup( + const testProvider = setup( ['logs-volume-a', 'no-data-providers', 'logs-volume-b', 'no-data-providers-2'], SupplementaryQueryType.LogsVolume ); @@ -308,7 +298,7 @@ describe('SupplementaryQueries utils', function () { describe('Logs sample', function () { describe('All data sources support logs sample', function () { it('Merges all responses into single result', async () => { - const testProvider = await setup(['logs-sample-a', 'logs-sample-b'], SupplementaryQueryType.LogsSample); + const testProvider = setup(['logs-sample-a', 'logs-sample-b'], SupplementaryQueryType.LogsSample); await expect(testProvider).toEmitValuesWith((received) => { expect(received).toMatchObject([ { data: [], state: LoadingState.Loading }, @@ -327,17 +317,14 @@ describe('SupplementaryQueries utils', function () { describe('All data sources do not support full range logs volume', function () { it('Does not provide fallback result', async () => { - const testProvider = await setup( - ['no-data-providers', 'no-data-providers-2'], - SupplementaryQueryType.LogsSample - ); + const testProvider = setup(['no-data-providers', 'no-data-providers-2'], SupplementaryQueryType.LogsSample); await expect(testProvider).toBeUndefined(); }); }); describe('Some data sources support full range logs volume, while others do not', function () { it('Returns results only for data sources supporting logs sample', async () => { - const testProvider = await setup( + const testProvider = setup( ['logs-sample-a', 'no-data-providers', 'logs-sample-b', 'no-data-providers-2'], SupplementaryQueryType.LogsSample ); diff --git a/public/app/features/explore/utils/supplementaryQueries.ts b/public/app/features/explore/utils/supplementaryQueries.ts index 9deaca32e20c1..ae25b8902b400 100644 --- a/public/app/features/explore/utils/supplementaryQueries.ts +++ b/public/app/features/explore/utils/supplementaryQueries.ts @@ -18,7 +18,7 @@ import { import store from 'app/core/store'; import { ExplorePanelData, SupplementaryQueries } from 'app/types'; -import { makeDataFramesForLogs } from '../../logs/logsModel'; +import { makeDataFramesForLogs, queryLogsSample, queryLogsVolume } from '../../logs/logsModel'; export const supplementaryQueryTypes: SupplementaryQueryType[] = [ SupplementaryQueryType.LogsVolume, @@ -130,7 +130,19 @@ export const getSupplementaryQueryProvider = ( dsRequest.targets = targets; if (hasSupplementaryQuerySupport(datasource, type)) { - return datasource.getDataProvider(type, dsRequest); + if (datasource.getDataProvider) { + return datasource.getDataProvider(type, dsRequest); + } else if (datasource.getSupplementaryRequest) { + const request = datasource.getSupplementaryRequest(type, dsRequest); + if (!request) { + return undefined; + } + return type === SupplementaryQueryType.LogsVolume + ? queryLogsVolume(datasource, request, { targets: dsRequest.targets }) + : queryLogsSample(datasource, request); + } else { + return undefined; + } } else { return getSupplementaryQueryFallback(type, explorePanelData, targets, datasource.name); } diff --git a/public/app/features/explore/utils/supplementaryQueries_legacy.test.ts b/public/app/features/explore/utils/supplementaryQueries_legacy.test.ts new file mode 100644 index 0000000000000..fbb79119bf831 --- /dev/null +++ b/public/app/features/explore/utils/supplementaryQueries_legacy.test.ts @@ -0,0 +1,365 @@ +/** + * Test file to be removed when `getDataProvider` is removed from DataSourceWithSupplementaryQueriesSupport + * in packages/grafana-data/src/types/logs.ts + */ +import { flatten } from 'lodash'; +import { from, Observable } from 'rxjs'; + +import { + DataFrame, + DataQueryRequest, + DataQueryResponse, + DataSourceApi, + DataSourceWithSupplementaryQueriesSupport, + FieldType, + LoadingState, + LogLevel, + LogsVolumeType, + MutableDataFrame, + SupplementaryQueryType, + SupplementaryQueryOptions, + toDataFrame, +} from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; + +import { MockDataSourceApi } from '../../../../test/mocks/datasource_srv'; +import { MockDataQueryRequest, MockQuery } from '../../../../test/mocks/query'; +import { ExplorePanelData } from '../../../types'; +import { mockExplorePanelData } from '../__mocks__/data'; + +import { getSupplementaryQueryProvider } from './supplementaryQueries'; + +class MockDataSourceWithSupplementaryQuerySupport + extends MockDataSourceApi + implements DataSourceWithSupplementaryQueriesSupport<DataQuery> +{ + private supplementaryQueriesResults: Record<SupplementaryQueryType, DataFrame[] | undefined> = { + [SupplementaryQueryType.LogsVolume]: undefined, + [SupplementaryQueryType.LogsSample]: undefined, + }; + + withSupplementaryQuerySupport(type: SupplementaryQueryType, data: DataFrame[]) { + this.supplementaryQueriesResults[type] = data; + return this; + } + + getDataProvider( + type: SupplementaryQueryType, + request: DataQueryRequest<DataQuery> + ): Observable<DataQueryResponse> | undefined { + const data = this.supplementaryQueriesResults[type]; + if (data) { + return from([ + { state: LoadingState.Loading, data: [] }, + { state: LoadingState.Done, data }, + ]); + } + return undefined; + } + + getSupplementaryQuery(options: SupplementaryQueryOptions, query: DataQuery): DataQuery | undefined { + return query; + } + + getSupportedSupplementaryQueryTypes(): SupplementaryQueryType[] { + return Object.values(SupplementaryQueryType).filter((type) => this.supplementaryQueriesResults[type]); + } +} + +const createSupplementaryQueryResponse = (type: SupplementaryQueryType, id: string) => { + return [ + toDataFrame({ + refId: `1-${type}-${id}`, + fields: [{ name: 'value', type: FieldType.string, values: [1] }], + meta: { + custom: { + logsVolumeType: LogsVolumeType.FullRange, + }, + }, + }), + toDataFrame({ + refId: `2-${type}-${id}`, + fields: [{ name: 'value', type: FieldType.string, values: [2] }], + meta: { + custom: { + logsVolumeType: LogsVolumeType.FullRange, + }, + }, + }), + ]; +}; + +const mockRow = (refId: string) => { + return { + rowIndex: 0, + entryFieldIndex: 0, + dataFrame: new MutableDataFrame({ refId, fields: [{ name: 'A', values: [] }] }), + entry: '', + hasAnsi: false, + hasUnescapedContent: false, + labels: {}, + logLevel: LogLevel.info, + raw: '', + timeEpochMs: 0, + timeEpochNs: '0', + timeFromNow: '', + timeLocal: '', + timeUtc: '', + uid: '1', + }; +}; + +const mockExploreDataWithLogs = () => + mockExplorePanelData({ + logsResult: { + rows: [mockRow('0'), mockRow('1')], + visibleRange: { from: 0, to: 1 }, + bucketSize: 1000, + }, + }); + +const datasources: DataSourceApi[] = [ + new MockDataSourceWithSupplementaryQuerySupport('logs-volume-a').withSupplementaryQuerySupport( + SupplementaryQueryType.LogsVolume, + createSupplementaryQueryResponse(SupplementaryQueryType.LogsVolume, 'logs-volume-a') + ), + new MockDataSourceWithSupplementaryQuerySupport('logs-volume-b').withSupplementaryQuerySupport( + SupplementaryQueryType.LogsVolume, + createSupplementaryQueryResponse(SupplementaryQueryType.LogsVolume, 'logs-volume-b') + ), + new MockDataSourceWithSupplementaryQuerySupport('logs-sample-a').withSupplementaryQuerySupport( + SupplementaryQueryType.LogsSample, + createSupplementaryQueryResponse(SupplementaryQueryType.LogsSample, 'logs-sample-a') + ), + new MockDataSourceWithSupplementaryQuerySupport('logs-sample-b').withSupplementaryQuerySupport( + SupplementaryQueryType.LogsSample, + createSupplementaryQueryResponse(SupplementaryQueryType.LogsSample, 'logs-sample-b') + ), + new MockDataSourceApi('no-data-providers'), + new MockDataSourceApi('no-data-providers-2'), +]; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => { + return { + get: async ({ uid }: { uid: string }) => datasources.find((ds) => ds.name === uid) || undefined, + }; + }, +})); + +const setup = async (targetSources: string[], type: SupplementaryQueryType) => { + const requestMock = new MockDataQueryRequest({ + targets: targetSources.map((source, i) => new MockQuery(`${i}`, 'a', { uid: source })), + }); + const explorePanelDataMock: Observable<ExplorePanelData> = mockExploreDataWithLogs(); + + const datasources = await Promise.all( + targetSources.map(async (source, i) => { + const datasource = await getDataSourceSrv().get({ uid: source }); + return { + datasource, + targets: [new MockQuery(`${i}`, 'a', { uid: source })], + }; + }) + ); + + return getSupplementaryQueryProvider(datasources, type, requestMock, explorePanelDataMock); +}; + +const assertDataFrom = (type: SupplementaryQueryType, ...datasources: string[]) => { + return flatten( + datasources.map((name: string) => { + return [{ refId: `1-${type}-${name}` }, { refId: `2-${type}-${name}` }]; + }) + ); +}; + +const assertDataFromLogsResults = () => { + return [{ meta: { custom: { logsVolumeType: LogsVolumeType.Limited } } }]; +}; + +describe('SupplementaryQueries utils', function () { + describe('Non-mixed data source', function () { + it('Returns result from the provider', async () => { + const testProvider = await setup(['logs-volume-a'], SupplementaryQueryType.LogsVolume); + + await expect(testProvider).toEmitValuesWith((received) => { + expect(received).toMatchObject([ + { data: [], state: LoadingState.Loading }, + { + data: assertDataFrom(SupplementaryQueryType.LogsVolume, 'logs-volume-a'), + state: LoadingState.Done, + }, + ]); + }); + }); + it('Uses fallback for logs volume', async () => { + const testProvider = await setup(['no-data-providers'], SupplementaryQueryType.LogsVolume); + + await expect(testProvider).toEmitValuesWith((received) => { + expect(received).toMatchObject([ + { + data: assertDataFromLogsResults(), + state: LoadingState.Done, + }, + ]); + }); + }); + it('Returns undefined for logs sample', async () => { + const testProvider = await setup(['no-data-providers'], SupplementaryQueryType.LogsSample); + await expect(testProvider).toBe(undefined); + }); + it('Creates single fallback result', async () => { + const testProvider = await setup(['no-data-providers', 'no-data-providers-2'], SupplementaryQueryType.LogsVolume); + + await expect(testProvider).toEmitValuesWith((received) => { + expect(received).toMatchObject([ + { + data: assertDataFromLogsResults(), + state: LoadingState.Done, + }, + { + data: [...assertDataFromLogsResults(), ...assertDataFromLogsResults()], + state: LoadingState.Done, + }, + ]); + }); + }); + }); + + describe('Mixed data source', function () { + describe('Logs volume', function () { + describe('All data sources support full range logs volume', function () { + it('Merges all data frames into a single response', async () => { + const testProvider = await setup(['logs-volume-a', 'logs-volume-b'], SupplementaryQueryType.LogsVolume); + await expect(testProvider).toEmitValuesWith((received) => { + expect(received).toMatchObject([ + { data: [], state: LoadingState.Loading }, + { + data: assertDataFrom(SupplementaryQueryType.LogsVolume, 'logs-volume-a'), + state: LoadingState.Done, + }, + { + data: assertDataFrom(SupplementaryQueryType.LogsVolume, 'logs-volume-a', 'logs-volume-b'), + state: LoadingState.Done, + }, + ]); + }); + }); + }); + + describe('All data sources do not support full range logs volume', function () { + it('Creates single fallback result', async () => { + const testProvider = await setup( + ['no-data-providers', 'no-data-providers-2'], + SupplementaryQueryType.LogsVolume + ); + + await expect(testProvider).toEmitValuesWith((received) => { + expect(received).toMatchObject([ + { + data: assertDataFromLogsResults(), + state: LoadingState.Done, + }, + { + data: [...assertDataFromLogsResults(), ...assertDataFromLogsResults()], + state: LoadingState.Done, + }, + ]); + }); + }); + }); + + describe('Some data sources support full range logs volume, while others do not', function () { + it('Creates merged result containing full range and limited logs volume', async () => { + const testProvider = await setup( + ['logs-volume-a', 'no-data-providers', 'logs-volume-b', 'no-data-providers-2'], + SupplementaryQueryType.LogsVolume + ); + await expect(testProvider).toEmitValuesWith((received) => { + expect(received).toMatchObject([ + { + data: [], + state: LoadingState.Loading, + }, + { + data: assertDataFrom(SupplementaryQueryType.LogsVolume, 'logs-volume-a'), + state: LoadingState.Done, + }, + { + data: [ + ...assertDataFrom(SupplementaryQueryType.LogsVolume, 'logs-volume-a'), + ...assertDataFromLogsResults(), + ], + state: LoadingState.Done, + }, + { + data: [ + ...assertDataFrom(SupplementaryQueryType.LogsVolume, 'logs-volume-a'), + ...assertDataFromLogsResults(), + ...assertDataFrom(SupplementaryQueryType.LogsVolume, 'logs-volume-b'), + ], + state: LoadingState.Done, + }, + ]); + }); + }); + }); + }); + + describe('Logs sample', function () { + describe('All data sources support logs sample', function () { + it('Merges all responses into single result', async () => { + const testProvider = await setup(['logs-sample-a', 'logs-sample-b'], SupplementaryQueryType.LogsSample); + await expect(testProvider).toEmitValuesWith((received) => { + expect(received).toMatchObject([ + { data: [], state: LoadingState.Loading }, + { + data: assertDataFrom(SupplementaryQueryType.LogsSample, 'logs-sample-a'), + state: LoadingState.Done, + }, + { + data: assertDataFrom(SupplementaryQueryType.LogsSample, 'logs-sample-a', 'logs-sample-b'), + state: LoadingState.Done, + }, + ]); + }); + }); + }); + + describe('All data sources do not support full range logs volume', function () { + it('Does not provide fallback result', async () => { + const testProvider = await setup( + ['no-data-providers', 'no-data-providers-2'], + SupplementaryQueryType.LogsSample + ); + await expect(testProvider).toBeUndefined(); + }); + }); + + describe('Some data sources support full range logs volume, while others do not', function () { + it('Returns results only for data sources supporting logs sample', async () => { + const testProvider = await setup( + ['logs-sample-a', 'no-data-providers', 'logs-sample-b', 'no-data-providers-2'], + SupplementaryQueryType.LogsSample + ); + await expect(testProvider).toEmitValuesWith((received) => { + expect(received).toMatchObject([ + { data: [], state: LoadingState.Loading }, + { + data: assertDataFrom(SupplementaryQueryType.LogsSample, 'logs-sample-a'), + state: LoadingState.Done, + }, + { + data: assertDataFrom(SupplementaryQueryType.LogsSample, 'logs-sample-a', 'logs-sample-b'), + state: LoadingState.Done, + }, + ]); + }); + }); + }); + }); + }); +}); diff --git a/public/app/features/expressions/ExpressionQueryEditor.tsx b/public/app/features/expressions/ExpressionQueryEditor.tsx index 90c6ba95de0cf..419fbe067fdba 100644 --- a/public/app/features/expressions/ExpressionQueryEditor.tsx +++ b/public/app/features/expressions/ExpressionQueryEditor.tsx @@ -7,6 +7,7 @@ import { ClassicConditions } from './components/ClassicConditions'; import { Math } from './components/Math'; import { Reduce } from './components/Reduce'; import { Resample } from './components/Resample'; +import { SqlExpr } from './components/SqlExpr'; import { Threshold } from './components/Threshold'; import { ExpressionQuery, ExpressionQueryType, expressionTypes } from './types'; import { getDefaults } from './utils/expressionTypes'; @@ -27,6 +28,7 @@ function useExpressionsCache() { case ExpressionQueryType.reduce: case ExpressionQueryType.resample: case ExpressionQueryType.threshold: + case ExpressionQueryType.sql: return expressionCache.current[queryType]; case ExpressionQueryType.classic: return undefined; @@ -47,6 +49,8 @@ function useExpressionsCache() { expressionCache.current.resample = value; expressionCache.current.threshold = value; break; + case ExpressionQueryType.sql: + expressionCache.current.sql = value; } }, []); @@ -89,6 +93,9 @@ export function ExpressionQueryEditor(props: Props) { case ExpressionQueryType.threshold: return <Threshold onChange={onChange} query={query} labelWidth={labelWidth} refIds={refIds} />; + + case ExpressionQueryType.sql: + return <SqlExpr onChange={onChange} query={query} refIds={refIds} />; } }; diff --git a/public/app/features/expressions/components/SqlExpr.tsx b/public/app/features/expressions/components/SqlExpr.tsx new file mode 100644 index 0000000000000..f5857f8892922 --- /dev/null +++ b/public/app/features/expressions/components/SqlExpr.tsx @@ -0,0 +1,27 @@ +import React, { useMemo } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { SQLEditor } from '@grafana/experimental'; + +import { ExpressionQuery } from '../types'; + +interface Props { + refIds: Array<SelectableValue<string>>; + query: ExpressionQuery; + onChange: (query: ExpressionQuery) => void; +} + +export const SqlExpr = ({ onChange, refIds, query }: Props) => { + const vars = useMemo(() => refIds.map((v) => v.value!), [refIds]); + + const initialQuery = `select * from ${vars[0]} limit 1`; + + const onEditorChange = (expression: string) => { + onChange({ + ...query, + expression, + }); + }; + + return <SQLEditor query={query.expression || initialQuery} onChange={onEditorChange}></SQLEditor>; +}; diff --git a/public/app/features/expressions/components/Threshold.tsx b/public/app/features/expressions/components/Threshold.tsx index 11bc65da84b14..77d0bce512306 100644 --- a/public/app/features/expressions/components/Threshold.tsx +++ b/public/app/features/expressions/components/Threshold.tsx @@ -1,70 +1,86 @@ import { css } from '@emotion/css'; -import React, { FormEvent } from 'react'; +import { AnyAction } from '@reduxjs/toolkit'; +import React, { FormEvent, useEffect, useReducer } from 'react'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { ButtonSelect, InlineField, InlineFieldRow, Input, Select, useStyles2 } from '@grafana/ui'; +import { Stack } from '@grafana/experimental'; +import { ButtonSelect, InlineField, InlineFieldRow, InlineSwitch, Input, Select, useStyles2 } from '@grafana/ui'; +import { config } from 'app/core/config'; import { EvalFunction } from 'app/features/alerting/state/alertDef'; import { ClassicCondition, ExpressionQuery, thresholdFunctions } from '../types'; +import { + isInvalid, + thresholdReducer, + updateHysteresisChecked, + updateRefId, + updateThresholdParams, + updateThresholdType, + updateUnloadParams, +} from './thresholdReducer'; + interface Props { labelWidth: number | 'auto'; refIds: Array<SelectableValue<string>>; query: ExpressionQuery; onChange: (query: ExpressionQuery) => void; + onError?: (error: string | undefined) => void; + useHysteresis?: boolean; } const defaultThresholdFunction = EvalFunction.IsAbove; -export const Threshold = ({ labelWidth, onChange, refIds, query }: Props) => { +const defaultEvaluator: ClassicCondition = { + type: 'query', + evaluator: { + type: defaultThresholdFunction, + params: [0, 0], + }, + query: { + params: [], + }, + reducer: { + params: [], + type: 'last', + }, +}; + +export const Threshold = ({ labelWidth, onChange, refIds, query, onError, useHysteresis = false }: Props) => { const styles = useStyles2(getStyles); - const defaultEvaluator: ClassicCondition = { - type: 'query', - evaluator: { - type: defaultThresholdFunction, - params: [0, 0], - }, - query: { - params: [], - }, - reducer: { - params: [], - type: 'last', - }, - }; + const initialExpression = { ...query, conditions: query.conditions?.length ? query.conditions : [defaultEvaluator] }; - const conditions = query.conditions?.length ? query.conditions : [defaultEvaluator]; - const condition = conditions[0]; + // this queryState is the source of truth for the threshold component. + // All the changes are made to this object through the dispatch function with the thresholdReducer. + const [queryState, dispatch] = useReducer(thresholdReducer, initialExpression); + const conditionInState = queryState.conditions[0]; - const thresholdFunction = thresholdFunctions.find((fn) => fn.value === conditions[0].evaluator?.type); + const thresholdFunction = thresholdFunctions.find((fn) => fn.value === queryState.conditions[0].evaluator?.type); const onRefIdChange = (value: SelectableValue<string>) => { - onChange({ ...query, expression: value.value }); + dispatch(updateRefId(value.value)); }; - const onEvalFunctionChange = (value: SelectableValue<EvalFunction>) => { - const type = value.value ?? defaultThresholdFunction; + // any change in the queryState will trigger the onChange function. + useEffect(() => { + queryState && onChange(queryState); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queryState]); - onChange({ - ...query, - conditions: updateConditions(conditions, { type }), - }); + const onEvalFunctionChange = (value: SelectableValue<EvalFunction>) => { + dispatch(updateThresholdType({ evalFunction: value.value ?? defaultThresholdFunction, onError })); }; const onEvaluateValueChange = (event: FormEvent<HTMLInputElement>, index: number) => { - const newValue = parseFloat(event.currentTarget.value); - const newParams = [...condition.evaluator.params]; - newParams[index] = newValue; - - onChange({ - ...query, - conditions: updateConditions(conditions, { params: newParams }), - }); + dispatch(updateThresholdParams({ param: parseFloat(event.currentTarget.value), index })); }; const isRange = - condition.evaluator.type === EvalFunction.IsWithinRange || condition.evaluator.type === EvalFunction.IsOutsideRange; + conditionInState.evaluator.type === EvalFunction.IsWithinRange || + conditionInState.evaluator.type === EvalFunction.IsOutsideRange; + + const hysteresisEnabled = Boolean(config.featureToggles?.recoveryThreshold) && useHysteresis; return ( <> @@ -86,14 +102,14 @@ export const Threshold = ({ labelWidth, onChange, refIds, query }: Props) => { type="number" width={10} onChange={(event) => onEvaluateValueChange(event, 0)} - defaultValue={condition.evaluator.params[0]} + defaultValue={conditionInState.evaluator.params[0]} /> <div className={styles.button}>TO</div> <Input type="number" width={10} onChange={(event) => onEvaluateValueChange(event, 1)} - defaultValue={condition.evaluator.params[1]} + defaultValue={conditionInState.evaluator.params[1]} /> </> ) : ( @@ -101,52 +117,239 @@ export const Threshold = ({ labelWidth, onChange, refIds, query }: Props) => { type="number" width={10} onChange={(event) => onEvaluateValueChange(event, 0)} - defaultValue={conditions[0].evaluator.params[0] || 0} + defaultValue={conditionInState.evaluator.params[0] || 0} /> )} </InlineFieldRow> + {hysteresisEnabled && <HysteresisSection isRange={isRange} onError={onError} />} </> ); + interface HysteresisSectionProps { + isRange: boolean; + onError?: (error: string | undefined) => void; + } + + function HysteresisSection({ isRange, onError }: HysteresisSectionProps) { + const hasHysteresis = Boolean(conditionInState.unloadEvaluator); + + const onHysteresisCheckChange = (event: FormEvent<HTMLInputElement>) => { + dispatch(updateHysteresisChecked({ hysteresisChecked: event.currentTarget.checked, onError })); + allowOnblurFromUnload.current = true; + }; + const allowOnblurFromUnload = React.useRef(true); + const onHysteresisCheckDown: React.MouseEventHandler<HTMLDivElement> | undefined = () => { + allowOnblurFromUnload.current = false; + }; + + return ( + <div className={styles.hysteresis}> + {/* This is to enhance the user experience for mouse users. + The onBlur event in RecoveryThresholdRow inputs triggers validations, + but we want to skip them when the switch is clicked as this click should inmount this component. + To achieve this, we use the onMouseDown event to set a flag, which is later utilized in the onBlur event to bypass validations. + The onMouseDown event precedes the onBlur event, unlike onchange. */} + + {/*Disabling the a11y rules here as the InlineSwitch handles keyboard interactions */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} + <div onMouseDown={onHysteresisCheckDown}> + <InlineSwitch + showLabel={true} + label="Custom recovery threshold" + value={hasHysteresis} + onChange={onHysteresisCheckChange} + className={styles.switch} + /> + </div> + + {hasHysteresis && ( + <RecoveryThresholdRow + isRange={isRange} + condition={conditionInState} + onError={onError} + dispatch={dispatch} + allowOnblur={allowOnblurFromUnload} + /> + )} + </div> + ); + } }; -function updateConditions( - conditions: ClassicCondition[], - update: Partial<{ - params: number[]; - type: EvalFunction; - }> -): ClassicCondition[] { - return [ - { - ...conditions[0], - evaluator: { - ...conditions[0].evaluator, - ...update, - }, - }, - ]; +interface RecoveryThresholdRowProps { + isRange: boolean; + condition: ClassicCondition; + onError?: (error: string | undefined) => void; + dispatch: React.Dispatch<AnyAction>; + allowOnblur: React.MutableRefObject<boolean>; +} + +function RecoveryThresholdRow({ isRange, condition, onError, dispatch, allowOnblur }: RecoveryThresholdRowProps) { + const styles = useStyles2(getStyles); + + const onUnloadValueChange = (event: FormEvent<HTMLInputElement>, paramIndex: number) => { + const newValue = parseFloat(event.currentTarget.value); + dispatch(updateUnloadParams({ param: newValue, index: paramIndex, onError })); + }; + + // check if is valid for the current unload evaluator params + const error = isInvalid(condition); + // get the error message depending on the unload evaluator type + const { errorMsg: invalidErrorMsg, errorMsgFrom, errorMsgTo } = error ?? {}; + + if (isRange) { + return <RecoveryForRange allowOnblur={allowOnblur} />; + } else { + return <RecoveryForSingleValue allowOnblur={allowOnblur} />; + } + + /* We prioritize the onMouseDown event over the onBlur event. This is because the onBlur event is executed before the onChange event that we have + in the hysteresis checkbox, and because of that, we were validating when unchecking the switch. + We need to uncheck the switch before the onBlur event is executed.*/ + interface RecoveryProps { + allowOnblur: React.MutableRefObject<boolean>; + } + + function RecoveryForRange({ allowOnblur }: RecoveryProps) { + if (condition.evaluator.type === EvalFunction.IsWithinRange) { + return ( + <InlineFieldRow className={styles.hysteresis}> + <InlineField label="Stop alerting when outside range" labelWidth={'auto'}> + <Stack direction="row" gap={0}> + <div className={styles.range}> + <InlineField invalid={Boolean(errorMsgFrom)} error={errorMsgFrom} className={styles.noMargin}> + <Input + type="number" + width={10} + onBlur={(event) => allowOnblur.current && onUnloadValueChange(event, 0)} + defaultValue={condition.unloadEvaluator?.params[0]} + /> + </InlineField> + </div> + <div className={styles.button}>TO</div> + <div className={styles.range}> + <InlineField invalid={Boolean(errorMsgTo)} error={errorMsgTo}> + <Input + type="number" + width={10} + onBlur={(event) => allowOnblur.current && onUnloadValueChange(event, 1)} + defaultValue={condition.unloadEvaluator?.params[1]} + /> + </InlineField> + </div> + </Stack> + </InlineField> + </InlineFieldRow> + ); + } else { + return ( + <InlineFieldRow className={styles.hysteresis}> + <InlineField label="Stop alerting when inside range" labelWidth={'auto'}> + <Stack direction="row" gap={0}> + <div className={styles.range}> + <InlineField invalid={Boolean(errorMsgFrom)} error={errorMsgFrom}> + <Input + type="number" + width={10} + onBlur={(event) => allowOnblur.current && onUnloadValueChange(event, 0)} + defaultValue={condition.unloadEvaluator?.params[0]} + /> + </InlineField> + </div> + + <div className={styles.button}>TO</div> + <div className={styles.range}> + <InlineField invalid={Boolean(errorMsgTo)} error={errorMsgTo}> + <Input + type="number" + width={10} + onBlur={(event) => allowOnblur.current && onUnloadValueChange(event, 1)} + defaultValue={condition.unloadEvaluator?.params[1]} + /> + </InlineField> + </div> + </Stack> + </InlineField> + </InlineFieldRow> + ); + } + } + + function RecoveryForSingleValue({ allowOnblur }: RecoveryProps) { + if (condition.evaluator.type === EvalFunction.IsAbove) { + return ( + <InlineFieldRow className={styles.hysteresis}> + <InlineField + label="Stop alerting when below" + labelWidth={'auto'} + invalid={Boolean(invalidErrorMsg)} + error={invalidErrorMsg} + > + <Input + type="number" + width={10} + onBlur={(event) => { + allowOnblur.current && onUnloadValueChange(event, 0); + }} + defaultValue={condition.unloadEvaluator?.params[0]} + /> + </InlineField> + </InlineFieldRow> + ); + } else { + return ( + <InlineFieldRow className={styles.hysteresis}> + <InlineField + label="Stop alerting when above" + labelWidth={'auto'} + invalid={Boolean(invalidErrorMsg)} + error={invalidErrorMsg} + > + <Input + type="number" + width={10} + onBlur={(event) => { + allowOnblur.current && onUnloadValueChange(event, 0); + }} + defaultValue={condition.unloadEvaluator?.params[0]} + /> + </InlineField> + </InlineFieldRow> + ); + } + } } const getStyles = (theme: GrafanaTheme2) => ({ - buttonSelectText: css` - color: ${theme.colors.primary.text}; - font-size: ${theme.typography.bodySmall.fontSize}; - text-transform: uppercase; - `, - button: css` - height: 32px; - - color: ${theme.colors.primary.text}; - font-size: ${theme.typography.bodySmall.fontSize}; - text-transform: uppercase; - - display: flex; - align-items: center; - border-radius: ${theme.shape.radius.default}; - font-weight: ${theme.typography.fontWeightBold}; - border: 1px solid ${theme.colors.border.medium}; - white-space: nowrap; - padding: 0 ${theme.spacing(1)}; - background-color: ${theme.colors.background.primary}; - `, + buttonSelectText: css({ + color: theme.colors.primary.text, + fontSize: theme.typography.bodySmall.fontSize, + textTransform: 'uppercase', + padding: `0 ${theme.spacing(1)}`, + }), + button: css({ + height: '32px', + color: theme.colors.primary.text, + fontSize: theme.typography.bodySmall.fontSize, + textTransform: 'uppercase', + display: 'flex', + alignItems: 'center', + borderRadius: theme.shape.radius.default, + fontWeight: theme.typography.fontWeightBold, + border: `1px solid ${theme.colors.border.medium}`, + whiteSpace: 'nowrap', + padding: `0 ${theme.spacing(1)}`, + backgroundColor: theme.colors.background.primary, + }), + range: css({ + width: 'min-content', + }), + hysteresis: css({ + marginTop: theme.spacing(2), + }), + switch: css({ + paddingLeft: theme.spacing(1), + }), + noMargin: css({ + margin: 0, + }), }); diff --git a/public/app/features/expressions/components/__snapshots__/hysteresis.test.ts.snap b/public/app/features/expressions/components/__snapshots__/hysteresis.test.ts.snap new file mode 100644 index 0000000000000..452fcd7305a6a --- /dev/null +++ b/public/app/features/expressions/components/__snapshots__/hysteresis.test.ts.snap @@ -0,0 +1,212 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`thresholdReducer Should update unlooadEvaluator when checking hysteresis 1`] = ` +{ + "conditions": [ + { + "evaluator": { + "params": [ + 10, + 0, + ], + "type": "gt", + }, + "query": { + "params": [ + "A", + "B", + ], + }, + "reducer": { + "params": [], + "type": "avg", + }, + "type": "query", + "unloadEvaluator": { + "params": [ + 10, + 0, + ], + "type": "lt", + }, + }, + ], + "refId": "A", + "type": "threshold", +} +`; + +exports[`thresholdReducer Should update unlooadEvaluator when unchecking hysteresis 1`] = ` +{ + "conditions": [ + { + "evaluator": { + "params": [ + 10, + 0, + ], + "type": "gt", + }, + "query": { + "params": [ + "A", + "B", + ], + }, + "reducer": { + "params": [], + "type": "avg", + }, + "type": "query", + "unloadEvaluator": undefined, + }, + ], + "refId": "A", + "type": "threshold", +} +`; + +exports[`thresholdReducer should update Threshold Type, and unloadEvaluator params and type 1`] = ` +{ + "conditions": [ + { + "evaluator": { + "params": [ + 10, + 0, + ], + "type": "lt", + }, + "query": { + "params": [ + "A", + "B", + ], + }, + "reducer": { + "params": [], + "type": "avg", + }, + "type": "query", + "unloadEvaluator": { + "params": [ + 10, + 0, + ], + "type": "gt", + }, + }, + ], + "refId": "A", + "type": "threshold", +} +`; + +exports[`thresholdReducer should update expression with RefId 1`] = ` +{ + "conditions": [ + { + "evaluator": { + "params": [ + 10, + 0, + ], + "type": "gt", + }, + "query": { + "params": [ + "A", + "B", + ], + }, + "reducer": { + "params": [], + "type": "avg", + }, + "type": "query", + "unloadEvaluator": { + "params": [ + 10, + 0, + ], + "type": "lt", + }, + }, + ], + "expression": "B", + "refId": "A", + "type": "threshold", +} +`; + +exports[`thresholdReducer should update unloadParams no error when are invalid 1`] = ` +{ + "conditions": [ + { + "evaluator": { + "params": [ + 10, + 0, + ], + "type": "gt", + }, + "query": { + "params": [ + "A", + "B", + ], + }, + "reducer": { + "params": [], + "type": "avg", + }, + "type": "query", + "unloadEvaluator": { + "params": [ + 20, + 0, + ], + "type": "lt", + }, + }, + ], + "refId": "A", + "type": "threshold", +} +`; + +exports[`thresholdReducer should update unloadParams with no error when are valid 1`] = ` +{ + "conditions": [ + { + "evaluator": { + "params": [ + 10, + 0, + ], + "type": "gt", + }, + "query": { + "params": [ + "A", + "B", + ], + }, + "reducer": { + "params": [], + "type": "avg", + }, + "type": "query", + "unloadEvaluator": { + "params": [ + 9, + 0, + ], + "type": "lt", + }, + }, + ], + "refId": "A", + "type": "threshold", +} +`; diff --git a/public/app/features/expressions/components/hysteresis.test.ts b/public/app/features/expressions/components/hysteresis.test.ts new file mode 100644 index 0000000000000..656e44adf5432 --- /dev/null +++ b/public/app/features/expressions/components/hysteresis.test.ts @@ -0,0 +1,217 @@ +import { EvalFunction } from 'app/features/alerting/state/alertDef'; + +import { ClassicCondition, ExpressionQueryType, ThresholdExpressionQuery } from '../types'; + +import { + isInvalid, + thresholdReducer, + updateHysteresisChecked, + updateRefId, + updateThresholdType, + updateUnloadParams, +} from './thresholdReducer'; + +describe('isInvalid', () => { + it('returns an error message if unloadEvaluator.params[0] is undefined', () => { + const condition: ClassicCondition = { + unloadEvaluator: { + type: EvalFunction.IsAbove, + params: [], + }, + evaluator: { type: EvalFunction.IsAbove, params: [10] }, + query: { params: ['A', 'B'] }, + reducer: { type: 'avg', params: [] }, + type: 'query', + }; + expect(isInvalid(condition)).toEqual({ errorMsg: 'This value cannot be empty' }); + }); + + it('When using is above, returns an error message if the value in unloadevaluator is above the threshold', () => { + const condition: ClassicCondition = { + unloadEvaluator: { + type: EvalFunction.IsAbove, + params: [15], + }, + evaluator: { type: EvalFunction.IsAbove, params: [10] }, + query: { params: ['A', 'B'] }, + reducer: { type: 'avg', params: [] }, + type: 'query', + }; + expect(isInvalid(condition)).toEqual({ errorMsg: 'Enter a number less than or equal to 10' }); + }); + + it('When using is below, returns an error message if the value in unloadevaluator is below the threshold', () => { + const condition: ClassicCondition = { + unloadEvaluator: { + type: EvalFunction.IsAbove, + params: [9], + }, + evaluator: { type: EvalFunction.IsBelow, params: [10] }, + query: { params: ['A', 'B'] }, + reducer: { type: 'avg', params: [] }, + type: 'query', + }; + expect(isInvalid(condition)).toEqual({ errorMsg: 'Enter a number more than or equal to 10' }); + }); + + it('When using is within range, returns an error message if the value in unloadevaluator is within the range', () => { + // first parameter is wrong + const condition: ClassicCondition = { + unloadEvaluator: { + type: EvalFunction.IsOutsideRange, + params: [11, 21], + }, + evaluator: { type: EvalFunction.IsWithinRange, params: [10, 20] }, + query: { params: ['A', 'B'] }, + reducer: { type: 'avg', params: [] }, + type: 'query', + }; + expect(isInvalid(condition)).toEqual({ errorMsgFrom: 'Enter a number less than or equal to 10' }); + // second parameter is wrong + const condition2: ClassicCondition = { + unloadEvaluator: { + type: EvalFunction.IsOutsideRange, + params: [9, 19], + }, + evaluator: { type: EvalFunction.IsWithinRange, params: [10, 20] }, + query: { params: ['A', 'B'] }, + reducer: { type: 'avg', params: [] }, + type: 'query', + }; + expect(isInvalid(condition2)).toEqual({ errorMsgTo: 'Enter a number be more than or equal to 20' }); + }); + it('When using is outside range, returns an error message if the value in unloadevaluator is outside the range', () => { + // first parameter is wrong + const condition: ClassicCondition = { + unloadEvaluator: { + type: EvalFunction.IsWithinRange, + params: [8, 19], + }, + evaluator: { type: EvalFunction.IsOutsideRange, params: [10, 20] }, + query: { params: ['A', 'B'] }, + reducer: { type: 'avg', params: [] }, + type: 'query', + }; + expect(isInvalid(condition)).toEqual({ errorMsgFrom: 'Enter a number more than or equal to 10' }); + // second parameter is wrong + const condition2: ClassicCondition = { + unloadEvaluator: { + type: EvalFunction.IsWithinRange, + params: [11, 21], + }, + evaluator: { type: EvalFunction.IsOutsideRange, params: [10, 20] }, + query: { params: ['A', 'B'] }, + reducer: { type: 'avg', params: [] }, + type: 'query', + }; + expect(isInvalid(condition2)).toEqual({ errorMsgTo: 'Enter a number less than or equal to 20' }); + }); +}); + +describe('thresholdReducer', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const onError = jest.fn(); + const thresholdCondition: ClassicCondition = { + evaluator: { type: EvalFunction.IsAbove, params: [10, 0] }, + unloadEvaluator: { + type: EvalFunction.IsBelow, + params: [10, 0], + }, + query: { params: ['A', 'B'] }, + reducer: { type: 'avg', params: [] }, + type: 'query', + }; + + it('should return initial state', () => { + expect(thresholdReducer(undefined, { type: undefined })).toEqual({ + type: ExpressionQueryType.threshold, + conditions: [], + refId: '', + }); + }); + it('should update expression with RefId', () => { + const initialState: ThresholdExpressionQuery = { + type: ExpressionQueryType.threshold, + refId: 'A', + conditions: [thresholdCondition], + }; + + const newState = thresholdReducer(initialState, updateRefId('B')); + + expect(newState).toMatchSnapshot(); + expect(newState.expression).toEqual('B'); + }); + it('should update Threshold Type, and unloadEvaluator params and type ', () => { + const initialState: ThresholdExpressionQuery = { + type: ExpressionQueryType.threshold, + refId: 'A', + conditions: [thresholdCondition], + }; + + const newState = thresholdReducer( + initialState, + updateThresholdType({ evalFunction: EvalFunction.IsBelow, onError }) + ); + + expect(newState).toMatchSnapshot(); + expect(newState.conditions[0].evaluator.type).toEqual(EvalFunction.IsBelow); + expect(newState.conditions[0].unloadEvaluator?.type).toEqual(EvalFunction.IsAbove); + expect(onError).toHaveBeenCalledWith(undefined); + expect(newState.conditions[0].unloadEvaluator?.params[0]).toEqual(10); + }); + it('Should update unlooadEvaluator when checking hysteresis', () => { + const initialState: ThresholdExpressionQuery = { + type: ExpressionQueryType.threshold, + refId: 'A', + conditions: [thresholdCondition], + }; + + const newState = thresholdReducer(initialState, updateHysteresisChecked({ hysteresisChecked: true, onError })); + + expect(newState).toMatchSnapshot(); + expect(newState.conditions[0].unloadEvaluator?.type).toEqual(EvalFunction.IsBelow); + expect(newState.conditions[0].unloadEvaluator?.params[0]).toEqual(10); + }); + it('Should update unlooadEvaluator when unchecking hysteresis', () => { + const initialState: ThresholdExpressionQuery = { + type: ExpressionQueryType.threshold, + refId: 'A', + conditions: [thresholdCondition], + }; + + const newState = thresholdReducer(initialState, updateHysteresisChecked({ hysteresisChecked: false, onError })); + + expect(newState).toMatchSnapshot(); + expect(newState.conditions[0].unloadEvaluator).toEqual(undefined); + expect(onError).toHaveBeenCalledWith(undefined); + }); + + it('should update unloadParams with no error when are valid', () => { + const initialState: ThresholdExpressionQuery = { + type: ExpressionQueryType.threshold, + refId: 'A', + conditions: [thresholdCondition], + }; + + const newState = thresholdReducer(initialState, updateUnloadParams({ param: 9, index: 0, onError })); + + expect(newState).toMatchSnapshot(); + expect(newState.conditions[0].unloadEvaluator?.params[0]).toEqual(9); + expect(onError).toHaveBeenCalledWith(undefined); + }); + it('should update unloadParams no error when are invalid', () => { + const initialState: ThresholdExpressionQuery = { + type: ExpressionQueryType.threshold, + refId: 'A', + conditions: [thresholdCondition], + }; + + const newState = thresholdReducer(initialState, updateUnloadParams({ param: 20, index: 0, onError })); + + expect(newState).toMatchSnapshot(); + expect(newState.conditions[0].unloadEvaluator?.params[0]).toEqual(20); + expect(onError).toHaveBeenCalledWith('Enter a number less than or equal to 10'); + }); +}); diff --git a/public/app/features/expressions/components/thresholdReducer.ts b/public/app/features/expressions/components/thresholdReducer.ts new file mode 100644 index 0000000000000..7fa1e428a9867 --- /dev/null +++ b/public/app/features/expressions/components/thresholdReducer.ts @@ -0,0 +1,172 @@ +import { createAction, createReducer } from '@reduxjs/toolkit'; + +import { EvalFunction } from 'app/features/alerting/state/alertDef'; + +import { ClassicCondition, ExpressionQueryType, ThresholdExpressionQuery } from '../types'; + +export const updateRefId = createAction<string | undefined>('thresold/updateRefId'); +export const updateThresholdType = createAction<{ + evalFunction: EvalFunction; + onError: ((error: string | undefined) => void) | undefined; +}>('thresold/updateThresholdType'); +export const updateThresholdParams = createAction<{ param: number; index: number }>('thresold/updateThresholdParams'); +export const updateHysteresisChecked = createAction<{ + hysteresisChecked: boolean; + onError: ((error: string | undefined) => void) | undefined; +}>('thresold/updateHysteresis'); +export const updateUnloadParams = createAction<{ + param: number; + index: number; + onError: ((error: string | undefined) => void) | undefined; +}>('thresold/updateUnloadParams'); + +export const thresholdReducer = createReducer<ThresholdExpressionQuery>( + { type: ExpressionQueryType.threshold, refId: '', conditions: [] }, + (builder) => { + builder.addCase(updateRefId, (state, action) => { + state.expression = action.payload; + }); + builder.addCase(updateThresholdType, (state, action) => { + const typeInPayload = action.payload.evalFunction; + const onError = action.payload.onError; + + //set new type in evaluator + state.conditions[0].evaluator.type = typeInPayload; + + // check if hysteresis is checked + const hsyteresisIsChecked = Boolean(state.conditions[0].unloadEvaluator); + + if (hsyteresisIsChecked) { + // when type whas changed and hsyteresIsChecked, we need to update the type for the unload evaluator with the opposite type + const updatedUnloadType = getUnloadEvaluatorTypeFromEvaluatorType(state.conditions[0].evaluator.type); + + // set error to undefined when type is changed as we default to the new type that is valid + if (onError) { + onError(undefined); //clear error + } + // set newtype in evaluator + state.conditions[0].evaluator.type = typeInPayload; + // set new type and params in unload evaluator + const defaultUnloadEvaluator = { + type: updatedUnloadType, + params: state.conditions[0].evaluator?.params ?? [0, 0], + }; + state.conditions[0].unloadEvaluator = defaultUnloadEvaluator; + } + }); + builder.addCase(updateThresholdParams, (state, action) => { + const { param, index } = action.payload; + state.conditions[0].evaluator.params[index] = param; + }); + builder.addCase(updateHysteresisChecked, (state, action) => { + const { hysteresisChecked, onError } = action.payload; + if (!hysteresisChecked) { + state.conditions[0].unloadEvaluator = undefined; + if (onError) { + onError(undefined); // clear error + } + } else { + state.conditions[0].unloadEvaluator = { + type: getUnloadEvaluatorTypeFromEvaluatorType(state.conditions[0].evaluator.type), + params: state.conditions[0].evaluator?.params ?? [0, 0], + }; + } + }); + builder.addCase(updateUnloadParams, (state, action) => { + const { param, index, onError } = action.payload; + // if there is no unload evaluator, we use the default evaluator params + if (!state.conditions[0].unloadEvaluator) { + state.conditions[0].unloadEvaluator = { + type: getUnloadEvaluatorTypeFromEvaluatorType(state.conditions[0].evaluator.type), + params: state.conditions[0].evaluator?.params ?? [0, 0], + }; + } else { + // only update the param + state.conditions[0].unloadEvaluator!.params[index] = param; + } + // check if is valid for the new unload evaluator params + const error = isInvalid(state.conditions[0]); + const { errorMsg: invalidErrorMsg, errorMsgFrom, errorMsgTo } = error ?? {}; + const errorMsg = invalidErrorMsg || errorMsgFrom || errorMsgTo; + // set error in form manually as we don't have a field for the unload evaluator + if (onError) { + onError(errorMsg); + } + }); + } +); + +function getUnloadEvaluatorTypeFromEvaluatorType(type: EvalFunction) { + // we don't let the user change the unload evaluator type. We just change it to the opposite of the evaluator type + if (type === EvalFunction.IsAbove) { + return EvalFunction.IsBelow; + } + if (type === EvalFunction.IsBelow) { + return EvalFunction.IsAbove; + } + if (type === EvalFunction.IsWithinRange) { + return EvalFunction.IsOutsideRange; + } + if (type === EvalFunction.IsOutsideRange) { + return EvalFunction.IsWithinRange; + } + return EvalFunction.IsBelow; +} + +export function isInvalid(condition: ClassicCondition) { + // first check if the unload evaluator values are not empty + const { unloadEvaluator, evaluator } = condition; + if (!evaluator) { + return; + } + if (unloadEvaluator?.params[0] === undefined || Number.isNaN(unloadEvaluator?.params[0])) { + return { errorMsg: 'This value cannot be empty' }; + } + + const { type, params: loadParams } = evaluator; + const { params: unloadParams } = unloadEvaluator; + + if (type === EvalFunction.IsWithinRange || type === EvalFunction.IsOutsideRange) { + if (unloadParams[0] === undefined || Number.isNaN(unloadParams[0])) { + return { errorMsgFrom: 'This value cannot be empty' }; + } + if (unloadParams[1] === undefined || Number.isNaN(unloadParams[1])) { + return { errorMsgTo: 'This value cannot be empty' }; + } + } + // check if the unload evaluator values are valid for the current load evaluator values + const [firstParamInUnloadEvaluator, secondParamInUnloadEvaluator] = unloadEvaluator.params; + const [firstParamInEvaluator, secondParamInEvaluator] = loadParams; + + switch (type) { + case EvalFunction.IsAbove: + if (firstParamInUnloadEvaluator > firstParamInEvaluator) { + return { errorMsg: `Enter a number less than or equal to ${firstParamInEvaluator}` }; + } + break; + case EvalFunction.IsBelow: + if (firstParamInUnloadEvaluator < firstParamInEvaluator) { + return { errorMsg: `Enter a number more than or equal to ${firstParamInEvaluator}` }; + } + break; + case EvalFunction.IsOutsideRange: + if (firstParamInUnloadEvaluator < firstParamInEvaluator) { + return { errorMsgFrom: `Enter a number more than or equal to ${firstParamInEvaluator}` }; + } + if (secondParamInUnloadEvaluator > secondParamInEvaluator) { + return { errorMsgTo: `Enter a number less than or equal to ${secondParamInEvaluator}` }; + } + break; + case EvalFunction.IsWithinRange: + if (firstParamInUnloadEvaluator > firstParamInEvaluator) { + return { errorMsgFrom: `Enter a number less than or equal to ${firstParamInEvaluator}` }; + } + if (secondParamInUnloadEvaluator < secondParamInEvaluator) { + return { errorMsgTo: `Enter a number be more than or equal to ${secondParamInEvaluator}` }; + } + break; + default: + throw new Error(`evaluator function type ${type} not supported.`); + } + return; +} diff --git a/public/app/features/expressions/types.ts b/public/app/features/expressions/types.ts index e9336353c18da..ad4091d608dcb 100644 --- a/public/app/features/expressions/types.ts +++ b/public/app/features/expressions/types.ts @@ -1,4 +1,5 @@ import { DataQuery, ReducerID, SelectableValue } from '@grafana/data'; +import { config } from 'app/core/config'; import { EvalFunction } from '../alerting/state/alertDef'; @@ -13,6 +14,7 @@ export enum ExpressionQueryType { resample = 'resample', classic = 'classic_conditions', threshold = 'threshold', + sql = 'sql', } export const getExpressionLabel = (type: ExpressionQueryType) => { @@ -27,6 +29,8 @@ export const getExpressionLabel = (type: ExpressionQueryType) => { return 'Classic condition'; case ExpressionQueryType.threshold: return 'Threshold'; + case ExpressionQueryType.sql: + return 'SQL'; } }; @@ -59,7 +63,17 @@ export const expressionTypes: Array<SelectableValue<ExpressionQueryType>> = [ description: 'Takes one or more time series returned from a query or an expression and checks if any of the series match the threshold condition.', }, -]; + { + value: ExpressionQueryType.sql, + label: 'SQL', + description: 'Transform data using SQL. Supports Aggregate/Analytics functions from DuckDB', + }, +].filter((expr) => { + if (expr.value === ExpressionQueryType.sql) { + return config.featureToggles?.sqlExpressions; + } + return true; +}); export const reducerTypes: Array<SelectableValue<string>> = [ { value: ReducerID.min, label: 'Min', description: 'Get the minimum value' }, @@ -130,6 +144,9 @@ export interface ExpressionQuery extends DataQuery { settings?: ExpressionQuerySettings; } +export interface ThresholdExpressionQuery extends ExpressionQuery { + conditions: ClassicCondition[]; +} export interface ExpressionQuerySettings { mode?: ReducerMode; replaceWithValue?: number; @@ -140,6 +157,10 @@ export interface ClassicCondition { params: number[]; type: EvalFunction; }; + unloadEvaluator?: { + params: number[]; + type: EvalFunction; + }; operator?: { type: string; }; diff --git a/public/app/features/geo/gazetteer/worldmap.test.ts b/public/app/features/geo/gazetteer/worldmap.test.ts index daaff61191b4d..127f63a40b06d 100644 --- a/public/app/features/geo/gazetteer/worldmap.test.ts +++ b/public/app/features/geo/gazetteer/worldmap.test.ts @@ -4,7 +4,7 @@ import countriesJSON from '../../../../gazetteer/countries.json'; import { getGazetteer } from './gazetteer'; -let backendResults: any = { hello: 'world' }; +let backendResults: Record<string, string> | Array<Record<string, unknown>> = { hello: 'world' }; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), diff --git a/public/app/features/inspector/InspectDataOptions.tsx b/public/app/features/inspector/InspectDataOptions.tsx index 830a1e1c3bb74..910569f2c0fee 100644 --- a/public/app/features/inspector/InspectDataOptions.tsx +++ b/public/app/features/inspector/InspectDataOptions.tsx @@ -19,6 +19,7 @@ interface Props { toggleDownloadForExcel: () => void; data?: DataFrame[]; hasTransformations?: boolean; + formattedDataDescription?: string; onOptionsChange?: (options: GetDataOptions) => void; actions?: React.ReactNode; } @@ -26,6 +27,7 @@ interface Props { export const InspectDataOptions = ({ options, actions, + formattedDataDescription, onOptionsChange, hasTransformations, data, @@ -129,10 +131,13 @@ export const InspectDataOptions = ({ {onOptionsChange && ( <Field label={t('dashboard.inspect-data.formatted-data-label', 'Formatted data')} - description={t( - 'dashboard.inspect-data.formatted-data-description', - 'Table data is formatted with options defined in the Field and Override tabs.' - )} + description={ + formattedDataDescription || + t( + 'dashboard.inspect-data.formatted-data-description', + 'Table data is formatted with options defined in the Field and Override tabs.' + ) + } > <Switch id="formatted-data-toggle" diff --git a/public/app/features/inspector/InspectDataTab.test.tsx b/public/app/features/inspector/InspectDataTab.test.tsx index 0dc821cb758c4..37284933ce028 100644 --- a/public/app/features/inspector/InspectDataTab.test.tsx +++ b/public/app/features/inspector/InspectDataTab.test.tsx @@ -1,11 +1,22 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React, { ComponentProps } from 'react'; +import { Props } from 'react-virtualized-auto-sizer'; import { DataFrame, FieldType } from '@grafana/data'; import { InspectDataTab } from './InspectDataTab'; +jest.mock('react-virtualized-auto-sizer', () => { + return ({ children }: Props) => + children({ + height: 1, + scaledHeight: 1, + scaledWidth: 1, + width: 1, + }); +}); + const createProps = (propsOverride?: Partial<ComponentProps<typeof InspectDataTab>>) => { const defaultProps = { isLoading: false, @@ -17,18 +28,18 @@ const createProps = (propsOverride?: Partial<ComponentProps<typeof InspectDataTa { name: 'First data frame', fields: [ - { name: 'time', type: FieldType.time, values: [100, 200, 300] }, - { name: 'name', type: FieldType.string, values: ['uniqueA', 'b', 'c'] }, - { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + { name: 'time', type: FieldType.time, values: [100, 200, 300], config: {} }, + { name: 'name', type: FieldType.string, values: ['uniqueA', 'b', 'c'], config: {} }, + { name: 'value', type: FieldType.number, values: [1, 2, 3], config: {} }, ], length: 3, }, { name: 'Second data frame', fields: [ - { name: 'time', type: FieldType.time, values: [400, 500, 600] }, - { name: 'name', type: FieldType.string, values: ['d', 'e', 'g'] }, - { name: 'value', type: FieldType.number, values: [4, 5, 6] }, + { name: 'time', type: FieldType.time, values: [400, 500, 600], config: {} }, + { name: 'name', type: FieldType.string, values: ['d', 'e', 'g'], config: {} }, + { name: 'value', type: FieldType.number, values: [4, 5, 6], config: {} }, ], length: 3, }, @@ -68,9 +79,9 @@ describe('InspectDataTab', () => { { name: 'Data frame with logs', fields: [ - { name: 'time', type: FieldType.time, values: [100, 200, 300] }, - { name: 'name', type: FieldType.string, values: ['uniqueA', 'b', 'c'] }, - { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + { name: 'time', type: FieldType.time, values: [100, 200, 300], config: {} }, + { name: 'name', type: FieldType.string, values: ['uniqueA', 'b', 'c'], config: {} }, + { name: 'value', type: FieldType.number, values: [1, 2, 3], config: {} }, ], length: 3, meta: { @@ -90,11 +101,15 @@ describe('InspectDataTab', () => { { name: 'Data frame with traces', fields: [ - { name: 'traceID', values: ['3fa414edcef6ad90', '3fa414edcef6ad90'] }, - { name: 'spanID', values: ['3fa414edcef6ad90', '0f5c1808567e4403'] }, - { name: 'parentSpanID', values: [undefined, '3fa414edcef6ad90'] }, - { name: 'operationName', values: ['HTTP GET - api_traces_traceid', '/tempopb.Querier/FindTraceByID'] }, - { name: 'serviceName', values: ['tempo-querier', 'tempo-querier'] }, + { name: 'traceID', values: ['3fa414edcef6ad90', '3fa414edcef6ad90'], config: {} }, + { name: 'spanID', values: ['3fa414edcef6ad90', '0f5c1808567e4403'], config: {} }, + { name: 'parentSpanID', values: [undefined, '3fa414edcef6ad90'], config: {} }, + { + name: 'operationName', + values: ['HTTP GET - api_traces_traceid', '/tempopb.Querier/FindTraceByID'], + config: {}, + }, + { name: 'serviceName', values: ['tempo-querier', 'tempo-querier'], config: {} }, { name: 'serviceTags', values: [ @@ -107,10 +122,11 @@ describe('InspectDataTab', () => { { key: 'container', type: 'string', value: 'tempo-query' }, ], ], + config: {}, }, - { name: 'startTime', values: [1605873894680.409, 1605873894680.587] }, - { name: 'duration', values: [1049.141, 1.847] }, - { name: 'logs', values: [[], []] }, + { name: 'startTime', values: [1605873894680.409, 1605873894680.587], config: {} }, + { name: 'duration', values: [1049.141, 1.847], config: {} }, + { name: 'logs', values: [[], []], config: {} }, { name: 'tags', values: [ @@ -123,9 +139,10 @@ describe('InspectDataTab', () => { { key: 'span.kind', type: 'string', value: 'client' }, ], ], + config: {}, }, - { name: 'warnings', values: [undefined, undefined] }, - { name: 'stackTraces', values: [undefined, undefined] }, + { name: 'warnings', values: [undefined, undefined], config: {} }, + { name: 'stackTraces', values: [undefined, undefined], config: {} }, ], length: 2, meta: { @@ -151,6 +168,7 @@ describe('InspectDataTab', () => { meta: { preferredVisualisationType: 'nodeGraph', }, + config: {}, }, { name: 'Edges', @@ -158,6 +176,7 @@ describe('InspectDataTab', () => { meta: { preferredVisualisationType: 'nodeGraph', }, + config: {}, }, ] as unknown as DataFrame[]; render( diff --git a/public/app/features/inspector/InspectDataTab.tsx b/public/app/features/inspector/InspectDataTab.tsx index dd077aad3720c..2fa49dacaf056 100644 --- a/public/app/features/inspector/InspectDataTab.tsx +++ b/public/app/features/inspector/InspectDataTab.tsx @@ -37,6 +37,7 @@ interface Props { panelPluginId?: string; fieldConfig?: FieldConfigSource; hasTransformations?: boolean; + formattedDataDescription?: string; onOptionsChange?: (options: GetDataOptions) => void; } @@ -176,11 +177,15 @@ export class InspectDataTab extends PureComponent<Props, State> { const { options, panelPluginId, fieldConfig, timeZone } = this.props; const data = this.state.transformedData; - if (!options.withFieldConfig || !panelPluginId || !fieldConfig) { + if (!options.withFieldConfig) { return applyRawFieldOverrides(data); } - const fieldConfigCleaned = this.cleanTableConfigFromFieldConfig(panelPluginId, fieldConfig); + let fieldConfigCleaned = fieldConfig ?? { defaults: {}, overrides: [] }; + // Because we visualize this data in a table we have to remove any custom table display settings + if (panelPluginId === 'table' && fieldConfig) { + fieldConfigCleaned = this.cleanTableConfigFromFieldConfig(fieldConfig); + } // We need to apply field config as it's not done by PanelQueryRunner (even when withFieldConfig is true). // It's because transformers create new fields and data frames, and we need to clean field config of any table settings. @@ -194,11 +199,7 @@ export class InspectDataTab extends PureComponent<Props, State> { } // Because we visualize this data in a table we have to remove any custom table display settings - cleanTableConfigFromFieldConfig(panelPluginId: string, fieldConfig: FieldConfigSource): FieldConfigSource { - if (panelPluginId !== 'table') { - return fieldConfig; - } - + cleanTableConfigFromFieldConfig(fieldConfig: FieldConfigSource): FieldConfigSource { fieldConfig = cloneDeep(fieldConfig); // clear all table specific options fieldConfig.defaults.custom = {}; @@ -242,7 +243,7 @@ export class InspectDataTab extends PureComponent<Props, State> { } render() { - const { isLoading, options, data, onOptionsChange, hasTransformations } = this.props; + const { isLoading, options, data, formattedDataDescription, onOptionsChange, hasTransformations } = this.props; const { dataFrameIndex, transformationOptions, selectedDataFrame, downloadForExcel } = this.state; const styles = getPanelInspectorStyles(); @@ -278,6 +279,7 @@ export class InspectDataTab extends PureComponent<Props, State> { transformationOptions={transformationOptions} selectedDataFrame={selectedDataFrame} downloadForExcel={downloadForExcel} + formattedDataDescription={formattedDataDescription} onOptionsChange={onOptionsChange} onDataFrameChange={this.onDataFrameChange} toggleDownloadForExcel={this.onToggleDownloadForExcel} diff --git a/public/app/features/inspector/QueryInspector.tsx b/public/app/features/inspector/QueryInspector.tsx index ad85759917355..fb1e0cd823826 100644 --- a/public/app/features/inspector/QueryInspector.tsx +++ b/public/app/features/inspector/QueryInspector.tsx @@ -9,7 +9,7 @@ import { Button, ClipboardButton, JSONFormatter, LoadingPlaceholder, Stack } fro import { Trans } from 'app/core/internationalization'; import { backendSrv } from 'app/core/services/backend_srv'; -import { getPanelInspectorStyles } from './styles'; +import { getPanelInspectorStyles2 } from './styles'; interface ExecutedQueryInfo { refId: string; @@ -19,6 +19,7 @@ interface ExecutedQueryInfo { } interface Props { + instanceId?: string; // Must match the prefix of the requestId of the query being inspected. For updating only one instance of the inspector in case of multiple instances, ie Explore split view data: PanelData; onRefreshQuery: () => void; } @@ -49,7 +50,15 @@ export class QueryInspector extends PureComponent<Props, State> { componentDidMount() { this.subs.add( backendSrv.getInspectorStream().subscribe({ - next: (response) => this.onDataSourceResponse(response), + next: (response) => { + let update = true; + if (this.props.instanceId && response?.requestId) { + update = response.requestId.startsWith(this.props.instanceId); + } + if (update) { + return this.onDataSourceResponse(response.response); + } + }, }) ); } @@ -212,7 +221,7 @@ export class QueryInspector extends PureComponent<Props, State> { const { allNodesExpanded, executedQueries, response } = this.state; const { onRefreshQuery, data } = this.props; const openNodes = this.getNrOfOpenNodes(); - const styles = getPanelInspectorStyles(); + const styles = getPanelInspectorStyles2(config.theme2); const haveData = Object.keys(response).length > 0; const isLoading = data.state === LoadingState.Loading; diff --git a/public/app/features/inspector/utils/download.ts b/public/app/features/inspector/utils/download.ts index 92f5f80150e46..47c52a47f3a1c 100644 --- a/public/app/features/inspector/utils/download.ts +++ b/public/app/features/inspector/utils/download.ts @@ -9,9 +9,9 @@ import { MutableDataFrame, toCSV, } from '@grafana/data'; +import { transformToOTLP } from '@grafana-plugins/tempo/resultTransformer'; import { transformToJaeger } from '../../../plugins/datasource/jaeger/responseTransform'; -import { transformToOTLP } from '../../../plugins/datasource/tempo/resultTransformer'; import { transformToZipkin } from '../../../plugins/datasource/zipkin/utils/transforms'; /** diff --git a/public/app/features/invites/SignupInvited.tsx b/public/app/features/invites/SignupInvited.tsx index 53ffa453c72db..bea01970007cb 100644 --- a/public/app/features/invites/SignupInvited.tsx +++ b/public/app/features/invites/SignupInvited.tsx @@ -2,7 +2,8 @@ import React, { useState } from 'react'; import { useAsync } from 'react-use'; import { getBackendSrv } from '@grafana/runtime'; -import { Button, Field, Form, Input } from '@grafana/ui'; +import { Button, Field, Input } from '@grafana/ui'; +import { Form } from 'app/core/components/Form/Form'; import { Page } from 'app/core/components/Page/Page'; import { getConfig } from 'app/core/config'; import { contextSrv } from 'app/core/core'; diff --git a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx index b35ea5810b3e9..382d77867343b 100644 --- a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx +++ b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx @@ -17,6 +17,7 @@ import { LibraryPanelsSearch, LibraryPanelsSearchProps } from './LibraryPanelsSe jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), config: { + ...jest.requireActual('@grafana/runtime').config, panels: { timeseries: { info: { logos: { small: '' } }, diff --git a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx index 104e5feda14eb..7c015f60b6afa 100644 --- a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx +++ b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx @@ -1,4 +1,4 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import React, { useCallback, useState } from 'react'; import { useDebounce } from 'react-use'; @@ -57,7 +57,11 @@ export const LibraryPanelsSearch = ({ return ( <div className={styles.container}> <VerticalGroup spacing={verticalGroupSpacing}> - <div className={styles.gridContainer}> + <div + className={cx(styles.gridContainer, { + [styles.tightLayout]: variant === LibraryPanelsSearchVariant.Tight, + })} + > <div className={styles.filterInputWrapper}> <FilterInput value={searchQuery} @@ -99,31 +103,30 @@ export const LibraryPanelsSearch = ({ }; function getStyles(theme: GrafanaTheme2, variant: LibraryPanelsSearchVariant) { - const tightLayout = css` - flex-direction: row; - row-gap: ${theme.spacing(1)}; - `; return { - filterInputWrapper: css` - flex-grow: ${variant === LibraryPanelsSearchVariant.Tight ? 1 : 'initial'}; - `, - container: css` - width: 100%; - overflow-y: auto; - padding: ${theme.spacing(1)}; - `, - libraryPanelsView: css` - width: 100%; - `, - gridContainer: css` - ${variant === LibraryPanelsSearchVariant.Tight ? tightLayout : ''}; - display: flex; - flex-direction: column; - width: 100%; - column-gap: ${theme.spacing(1)}; - row-gap: ${theme.spacing(1)}; - padding-bottom: ${theme.spacing(2)}; - `, + filterInputWrapper: css({ + flexGrow: variant === LibraryPanelsSearchVariant.Tight ? 1 : 'initial', + }), + container: css({ + width: '100%', + overflowY: 'auto', + padding: theme.spacing(1), + }), + libraryPanelsView: css({ + width: '100%', + }), + gridContainer: css({ + display: 'flex', + flexDirection: 'column', + width: '100%', + columnGap: theme.spacing(1), + rowGap: theme.spacing(1), + paddingBottom: theme.spacing(2), + }), + tightLayout: css({ + flexDirection: 'row', + rowGap: theme.spacing(1), + }), }; } @@ -149,7 +152,7 @@ const SearchControls = React.memo( onFolderFilterChange, onPanelFilterChange, }: SearchControlsProps) => { - const styles = useStyles2(getRowStyles, variant); + const styles = useStyles2(getRowStyles); const panelFilterChanged = useCallback( (plugins: PanelPluginMeta[]) => onPanelFilterChange(plugins.map((p) => p.id)), [onPanelFilterChange] @@ -160,10 +163,18 @@ const SearchControls = React.memo( ); return ( - <div className={styles.container}> + <div + className={cx(styles.container, { + [styles.containerTight]: variant === LibraryPanelsSearchVariant.Tight, + })} + > {showSort && <SortPicker value={sortDirection} onChange={onSortChange} filter={['alpha-asc', 'alpha-desc']} />} {(showFolderFilter || showPanelFilter) && ( - <div className={styles.filterContainer}> + <div + className={cx(styles.filterContainer, { + [styles.filterContainerTight]: variant === LibraryPanelsSearchVariant.Tight, + })} + > {showFolderFilter && <FolderFilter onChange={folderFilterChanged} />} {showPanelFilter && <PanelTypeFilter onChange={panelFilterChanged} />} </div> @@ -174,42 +185,29 @@ const SearchControls = React.memo( ); SearchControls.displayName = 'SearchControls'; -function getRowStyles(theme: GrafanaTheme2, variant = LibraryPanelsSearchVariant.Spacious) { - const searchRowContainer = css` - display: flex; - gap: ${theme.spacing(1)}; - flex-grow: 1; - flex-direction: row; - justify-content: end; - `; - const searchRowContainerTight = css` - ${searchRowContainer}; - flex-grow: initial; - flex-direction: column; - justify-content: normal; - `; - const filterContainer = css` - display: flex; - flex-direction: row; - margin-left: auto; - gap: 4px; - `; - const filterContainerTight = css` - ${filterContainer}; - flex-direction: column; - margin-left: initial; - `; - - switch (variant) { - case LibraryPanelsSearchVariant.Spacious: - return { - container: searchRowContainer, - filterContainer: filterContainer, - }; - case LibraryPanelsSearchVariant.Tight: - return { - container: searchRowContainerTight, - filterContainer: filterContainerTight, - }; - } +function getRowStyles(theme: GrafanaTheme2) { + return { + container: css({ + display: 'flex', + gap: theme.spacing(1), + flexGrow: 1, + flexDirection: 'row', + justifyContent: 'space-between', + flexWrap: 'wrap', + }), + containerTight: css({ + flexGrow: 'initial', + flexDirection: 'column', + justifyContent: 'normal', + }), + filterContainer: css({ + display: 'flex', + flexDirection: 'row', + gap: theme.spacing(1), + }), + filterContainerTight: css({ + flexDirection: 'column', + marginLeft: 'initial', + }), + }; } diff --git a/public/app/features/library-panels/state/api.ts b/public/app/features/library-panels/state/api.ts index 27fca15ef047a..cf224b52bc7d9 100644 --- a/public/app/features/library-panels/state/api.ts +++ b/public/app/features/library-panels/state/api.ts @@ -2,6 +2,8 @@ import { lastValueFrom } from 'rxjs'; import { defaultDashboard } from '@grafana/schema'; import { DashboardModel } from 'app/features/dashboard/state'; +import { LibraryVizPanel } from 'app/features/dashboard-scene/scene/LibraryVizPanel'; +import { vizPanelToPanel } from 'app/features/dashboard-scene/serialization/transformSceneToSaveModel'; import { getBackendSrv } from '../../../core/services/backend_srv'; import { DashboardSearchItem } from '../../search/types'; @@ -65,6 +67,12 @@ export async function getLibraryPanel(uid: string, isHandled = false): Promise<L panels: [result.model], }); const { scopedVars, ...model } = dash.panels[0].getSaveModel(); // migrated panel + + //These properties should not exist on LibraryPanel.model which is of type Omit<Panel, 'gridPos' | 'id' | 'libraryPanel'> + delete model.gridPos; + delete model.id; + delete model.libraryPanel; + dash.destroy(); // kill event listeners return { ...result, @@ -127,3 +135,28 @@ export async function getConnectedDashboards(uid: string): Promise<DashboardSear return searchHits; } + +export function libraryVizPanelToSaveModel(libraryPanel: LibraryVizPanel) { + const { panel, uid, name, _loadedPanel } = libraryPanel.state; + const saveModel = { + uid, + folderUID: _loadedPanel?.folderUid, + name, + version: _loadedPanel?.version || 0, + model: vizPanelToPanel(panel!), + kind: LibraryElementKind.Panel, + }; + return saveModel; +} + +export async function updateLibraryVizPanel(libraryPanel: LibraryVizPanel): Promise<LibraryElementDTO> { + const { uid, folderUID, name, model, version, kind } = libraryVizPanelToSaveModel(libraryPanel); + const { result } = await getBackendSrv().patch(`/api/library-elements/${uid}`, { + folderUID, + name, + model, + version, + kind, + }); + return result; +} diff --git a/public/app/features/live/centrifuge/LiveDataStream.test.ts b/public/app/features/live/centrifuge/LiveDataStream.test.ts index f8010aac6477c..1870d59ee7934 100644 --- a/public/app/features/live/centrifuge/LiveDataStream.test.ts +++ b/public/app/features/live/centrifuge/LiveDataStream.test.ts @@ -898,8 +898,8 @@ describe('LiveDataStream', () => { return isStreamingResponseData(data, StreamingResponseDataType.FullFrame) ? fieldsOf(data) : isStreamingResponseData(data, StreamingResponseDataType.NewValuesSameSchema) - ? data.values - : response; + ? data.values + : response; }) ) ).toEqual({ diff --git a/public/app/features/live/info.ts b/public/app/features/live/info.ts new file mode 100644 index 0000000000000..19bab8db1688d --- /dev/null +++ b/public/app/features/live/info.ts @@ -0,0 +1,44 @@ +import { SelectableValue, dataFrameFromJSON } from '@grafana/data'; +import { getBackendSrv } from '@grafana/runtime'; + +interface ChannelInfo { + channel: string; + minute_rate: number; // + data: unknown; // the last payload +} + +interface ManagedChannels { + channels: ChannelInfo[]; +} + +interface ChannelSelectionInfo { + channels: Array<SelectableValue<string>>; + channelFields: Record<string, Array<SelectableValue<string>>>; +} + +export async function getManagedChannelInfo(): Promise<ChannelSelectionInfo> { + return getBackendSrv() + .get<ManagedChannels>('api/live/list') + .then((v) => { + const channelInfo = v.channels ?? []; + const channelFields: Record<string, Array<SelectableValue<string>>> = {}; + const channels: Array<SelectableValue<string>> = channelInfo.map((c) => { + if (c.data) { + const distinctFields = new Set<string>(); + const frame = dataFrameFromJSON(c.data); + for (const f of frame.fields) { + distinctFields.add(f.name); + } + channelFields[c.channel] = Array.from(distinctFields).map((n) => ({ + value: n, + label: n, + })); + } + return { + value: c.channel, + label: c.channel + ' [' + c.minute_rate + ' msg/min]', + }; + }); + return { channelFields, channels }; + }); +} diff --git a/public/app/features/logs/components/InfiniteScroll.test.tsx b/public/app/features/logs/components/InfiniteScroll.test.tsx new file mode 100644 index 0000000000000..47429764b3b58 --- /dev/null +++ b/public/app/features/logs/components/InfiniteScroll.test.tsx @@ -0,0 +1,298 @@ +import { act, render, screen } from '@testing-library/react'; +import React, { useEffect, useRef, useState } from 'react'; + +import { LogRowModel, dateTimeForTimeZone } from '@grafana/data'; +import { convertRawToRange } from '@grafana/data/src/datetime/rangeutil'; +import { config } from '@grafana/runtime'; +import { LogsSortOrder } from '@grafana/schema'; + +import { InfiniteScroll, Props, SCROLLING_THRESHOLD } from './InfiniteScroll'; +import { createLogRow } from './__mocks__/logRow'; + +const defaultTz = 'browser'; + +const absoluteRange = { + from: 1702578600000, + to: 1702578900000, +}; +const defaultRange = convertRawToRange({ + from: dateTimeForTimeZone(defaultTz, absoluteRange.from), + to: dateTimeForTimeZone(defaultTz, absoluteRange.to), +}); + +const defaultProps: Omit<Props, 'children'> = { + loading: false, + loadMoreLogs: jest.fn(), + range: defaultRange, + rows: [], + sortOrder: LogsSortOrder.Descending, + timeZone: 'browser', +}; + +function ScrollWithWrapper({ children, ...props }: Props) { + const [initialized, setInitialized] = useState(false); + const scrollRef = useRef<HTMLDivElement | null>(null); + + useEffect(() => { + // Required to get the ref + if (scrollRef.current && !initialized) { + setInitialized(true); + } + }, [initialized]); + + return ( + <div style={{ height: 40, overflowY: 'scroll' }} ref={scrollRef} data-testid="scroll-element"> + {initialized && ( + <InfiniteScroll {...props} scrollElement={scrollRef.current!} topScrollEnabled> + {children} + </InfiniteScroll> + )} + </div> + ); +} + +function setup(loadMoreMock: () => void, startPosition: number, rows: LogRowModel[], order: LogsSortOrder) { + const { element, events } = getMockElement(startPosition); + + function scrollTo(position: number) { + element.scrollTop = position; + + act(() => { + events['scroll'](new Event('scroll')); + }); + + // When scrolling top, we wait for the user to reach the top, and then for a new scrolling event + // in the same direction before triggering a new query. + if (position === 0) { + wheel(-1); + } + } + function wheel(deltaY: number) { + element.scrollTop += deltaY; + + act(() => { + const event = new WheelEvent('wheel', { deltaY }); + events['wheel'](event); + }); + } + + render( + <InfiniteScroll + {...defaultProps} + sortOrder={order} + rows={rows} + scrollElement={element as unknown as HTMLDivElement} + loadMoreLogs={loadMoreMock} + topScrollEnabled + > + <div data-testid="contents" style={{ height: 100 }} /> + </InfiniteScroll> + ); + + return { element, events, scrollTo, wheel }; +} + +beforeAll(() => { + config.featureToggles.logsInfiniteScrolling = true; +}); +afterAll(() => { + config.featureToggles.logsInfiniteScrolling = false; +}); + +describe('InfiniteScroll', () => { + test('Wraps components without adding DOM elements', async () => { + const { container } = render( + <ScrollWithWrapper {...defaultProps}> + <div data-testid="contents" /> + </ScrollWithWrapper> + ); + + expect(await screen.findByTestId('contents')).toBeInTheDocument(); + expect(container).toMatchInlineSnapshot(` + <div> + <div + data-testid="scroll-element" + style="height: 40px; overflow-y: scroll;" + > + <div + data-testid="contents" + /> + </div> + </div> +`); + }); + + describe.each([LogsSortOrder.Descending, LogsSortOrder.Ascending])( + 'When the sort order is %s', + (order: LogsSortOrder) => { + let rows: LogRowModel[]; + beforeEach(() => { + rows = createLogRows(absoluteRange.from + 2 * SCROLLING_THRESHOLD, absoluteRange.to - 2 * SCROLLING_THRESHOLD); + }); + + test.each([ + ['top', 10, 0], + ['bottom', 50, 60], + ])( + 'Requests more logs when scrolling %s', + async (direction: string, startPosition: number, endPosition: number) => { + const loadMoreMock = jest.fn(); + const { scrollTo, element } = setup(loadMoreMock, startPosition, rows, order); + + expect(await screen.findByTestId('contents')).toBeInTheDocument(); + + scrollTo(endPosition); + + expect(loadMoreMock).toHaveBeenCalled(); + expect(await screen.findByTestId('Spinner')).toBeInTheDocument(); + if (direction === 'bottom') { + // Bottom loader visibility trick + expect(element.scrollTo).toHaveBeenCalled(); + } else { + expect(element.scrollTo).not.toHaveBeenCalled(); + } + } + ); + + test.each([ + ['up', -5, 0], + ['down', 5, 100], + ])( + 'Requests more logs when moving the mousewheel %s', + async (_: string, deltaY: number, startPosition: number) => { + const loadMoreMock = jest.fn(); + const { wheel } = setup(loadMoreMock, startPosition, rows, order); + + expect(await screen.findByTestId('contents')).toBeInTheDocument(); + + wheel(deltaY); + + expect(loadMoreMock).toHaveBeenCalled(); + expect(await screen.findByTestId('Spinner')).toBeInTheDocument(); + } + ); + + test('Does not request more logs when there is no scroll', async () => { + const loadMoreMock = jest.fn(); + const { scrollTo, element } = setup(loadMoreMock, 0, rows, order); + + expect(await screen.findByTestId('contents')).toBeInTheDocument(); + element.clientHeight = 40; + element.scrollHeight = element.clientHeight; + + scrollTo(40); + + expect(loadMoreMock).not.toHaveBeenCalled(); + expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); + }); + + test('Requests newer logs from the most recent timestamp', async () => { + const startPosition = order === LogsSortOrder.Descending ? 10 : 50; // Scroll top + const endPosition = order === LogsSortOrder.Descending ? 0 : 60; // Scroll bottom + + const loadMoreMock = jest.fn(); + const { scrollTo } = setup(loadMoreMock, startPosition, rows, order); + + expect(await screen.findByTestId('contents')).toBeInTheDocument(); + + scrollTo(endPosition); + + expect(loadMoreMock).toHaveBeenCalledWith({ + from: rows[rows.length - 1].timeEpochMs, + to: absoluteRange.to, + }); + }); + + test('Requests older logs from the oldest timestamp', async () => { + const startPosition = order === LogsSortOrder.Ascending ? 10 : 50; // Scroll top + const endPosition = order === LogsSortOrder.Ascending ? 0 : 60; // Scroll bottom + + const loadMoreMock = jest.fn(); + const { scrollTo } = setup(loadMoreMock, startPosition, rows, order); + + expect(await screen.findByTestId('contents')).toBeInTheDocument(); + + scrollTo(endPosition); + + expect(loadMoreMock).toHaveBeenCalledWith({ + from: absoluteRange.from, + to: rows[0].timeEpochMs, + }); + }); + + describe('With absolute range matching visible range', () => { + test.each([ + ['top', 10, 0], + ['bottom', 50, 60], + ])( + 'It does not request more when scrolling %s', + async (_: string, startPosition: number, endPosition: number) => { + // Visible range matches the current range + const rows = createLogRows(absoluteRange.from, absoluteRange.to); + const loadMoreMock = jest.fn(); + const { scrollTo } = setup(loadMoreMock, startPosition, rows, order); + + expect(await screen.findByTestId('contents')).toBeInTheDocument(); + + scrollTo(endPosition); + + expect(loadMoreMock).not.toHaveBeenCalled(); + expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); + expect(await screen.findByTestId('end-of-range')).toBeInTheDocument(); + } + ); + }); + + describe('With relative range matching visible range', () => { + test.each([ + ['top', 10, 0], + ['bottom', 50, 60], + ])( + 'It does not request more when scrolling %s', + async (_: string, startPosition: number, endPosition: number) => { + // Visible range matches the current range + const rows = createLogRows(absoluteRange.from, absoluteRange.to); + const loadMoreMock = jest.fn(); + const { scrollTo } = setup(loadMoreMock, startPosition, rows, order); + + expect(await screen.findByTestId('contents')).toBeInTheDocument(); + + scrollTo(endPosition); + + expect(loadMoreMock).not.toHaveBeenCalled(); + expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); + expect(await screen.findByTestId('end-of-range')).toBeInTheDocument(); + } + ); + }); + } + ); +}); + +function createLogRows(from: number, to: number) { + const rows = [createLogRow({ entry: 'line1' }), createLogRow({ entry: 'line2' })]; + // Time field + rows[0].dataFrame.fields[0].values = [from, to]; + rows[0].timeEpochMs = from; + rows[1].dataFrame.fields[0].values = [from, to]; + rows[1].timeEpochMs = to; + return rows; +} + +// JSDOM doesn't support layout, so we will mock the expected attribute values for the test cases. +function getMockElement(scrollTop: number) { + const events: Record<string, (e: Event | WheelEvent) => void> = {}; + const element = { + addEventListener: (event: string, callback: (e: Event | WheelEvent) => void) => { + events[event] = callback; + }, + removeEventListener: jest.fn(), + stopImmediatePropagation: jest.fn(), + scrollHeight: 100, + clientHeight: 40, + scrollTop, + scrollTo: jest.fn(), + }; + + return { element, events }; +} diff --git a/public/app/features/logs/components/InfiniteScroll.tsx b/public/app/features/logs/components/InfiniteScroll.tsx new file mode 100644 index 0000000000000..744584735db07 --- /dev/null +++ b/public/app/features/logs/components/InfiniteScroll.tsx @@ -0,0 +1,246 @@ +import { css } from '@emotion/css'; +import React, { ReactNode, useEffect, useRef, useState } from 'react'; + +import { AbsoluteTimeRange, LogRowModel, TimeRange } from '@grafana/data'; +import { convertRawToRange, isRelativeTime, isRelativeTimeRange } from '@grafana/data/src/datetime/rangeutil'; +import { config, reportInteraction } from '@grafana/runtime'; +import { LogsSortOrder, TimeZone } from '@grafana/schema'; + +import { LoadingIndicator } from './LoadingIndicator'; + +export type Props = { + children: ReactNode; + loading: boolean; + loadMoreLogs?: (range: AbsoluteTimeRange) => void; + range: TimeRange; + rows: LogRowModel[]; + scrollElement?: HTMLDivElement; + sortOrder: LogsSortOrder; + timeZone: TimeZone; + topScrollEnabled?: boolean; +}; + +export const InfiniteScroll = ({ + children, + loading, + loadMoreLogs, + range, + rows, + scrollElement, + sortOrder, + timeZone, + topScrollEnabled = false, +}: Props) => { + const [upperOutOfRange, setUpperOutOfRange] = useState(false); + const [lowerOutOfRange, setLowerOutOfRange] = useState(false); + const [upperLoading, setUpperLoading] = useState(false); + const [lowerLoading, setLowerLoading] = useState(false); + const rowsRef = useRef<LogRowModel[]>(rows); + const lastScroll = useRef<number>(scrollElement?.scrollTop || 0); + + // Reset messages when range/order/rows change + useEffect(() => { + setUpperOutOfRange(false); + setLowerOutOfRange(false); + }, [range, rows, sortOrder]); + + // Reset loading messages when loading stops + useEffect(() => { + if (!loading) { + setUpperLoading(false); + setLowerLoading(false); + } + }, [loading]); + + // Ensure bottom loader visibility + useEffect(() => { + if (lowerLoading && scrollElement) { + scrollElement.scrollTo(0, scrollElement.scrollHeight - scrollElement.clientHeight); + } + }, [lowerLoading, scrollElement]); + + // Request came back with no new past rows + useEffect(() => { + if (rows !== rowsRef.current && rows.length === rowsRef.current.length && (upperLoading || lowerLoading)) { + if (sortOrder === LogsSortOrder.Descending && lowerLoading) { + setLowerOutOfRange(true); + } else if (sortOrder === LogsSortOrder.Ascending && upperLoading) { + setUpperOutOfRange(true); + } + } + rowsRef.current = rows; + }, [lowerLoading, rows, sortOrder, upperLoading]); + + useEffect(() => { + if (!scrollElement || !loadMoreLogs) { + return; + } + + function handleScroll(event: Event | WheelEvent) { + if (!scrollElement || !loadMoreLogs || !rows.length || loading || !config.featureToggles.logsInfiniteScrolling) { + return; + } + event.stopImmediatePropagation(); + const scrollDirection = shouldLoadMore(event, scrollElement, lastScroll.current); + lastScroll.current = scrollElement.scrollTop; + if (scrollDirection === ScrollDirection.NoScroll) { + return; + } else if (scrollDirection === ScrollDirection.Top && topScrollEnabled) { + scrollTop(); + } else if (scrollDirection === ScrollDirection.Bottom) { + scrollBottom(); + } + } + + function scrollTop() { + const newRange = canScrollTop(getVisibleRange(rows), range, timeZone, sortOrder); + if (!newRange) { + setUpperOutOfRange(true); + return; + } + setUpperOutOfRange(false); + loadMoreLogs?.(newRange); + setUpperLoading(true); + reportInteraction('grafana_logs_infinite_scrolling', { + direction: 'top', + sort_order: sortOrder, + }); + } + + function scrollBottom() { + const newRange = canScrollBottom(getVisibleRange(rows), range, timeZone, sortOrder); + if (!newRange) { + setLowerOutOfRange(true); + return; + } + setLowerOutOfRange(false); + loadMoreLogs?.(newRange); + setLowerLoading(true); + reportInteraction('grafana_logs_infinite_scrolling', { + direction: 'bottom', + sort_order: sortOrder, + }); + } + + scrollElement.addEventListener('scroll', handleScroll); + scrollElement.addEventListener('wheel', handleScroll); + + return () => { + scrollElement.removeEventListener('scroll', handleScroll); + scrollElement.removeEventListener('wheel', handleScroll); + }; + }, [loadMoreLogs, loading, range, rows, scrollElement, sortOrder, timeZone, topScrollEnabled]); + + // We allow "now" to move when using relative time, so we hide the message so it doesn't flash. + const hideTopMessage = sortOrder === LogsSortOrder.Descending && isRelativeTime(range.raw.to); + const hideBottomMessage = sortOrder === LogsSortOrder.Ascending && isRelativeTime(range.raw.to); + + return ( + <> + {upperLoading && <LoadingIndicator adjective={sortOrder === LogsSortOrder.Descending ? 'newer' : 'older'} />} + {!hideTopMessage && upperOutOfRange && outOfRangeMessage} + {children} + {!hideBottomMessage && lowerOutOfRange && outOfRangeMessage} + {lowerLoading && <LoadingIndicator adjective={sortOrder === LogsSortOrder.Descending ? 'older' : 'newer'} />} + </> + ); +}; + +const styles = { + messageContainer: css({ + textAlign: 'center', + padding: 0.25, + }), +}; + +const outOfRangeMessage = ( + <div className={styles.messageContainer} data-testid="end-of-range"> + End of the selected time range. + </div> +); + +enum ScrollDirection { + Top = -1, + Bottom = 1, + NoScroll = 0, +} +function shouldLoadMore(event: Event | WheelEvent, element: HTMLDivElement, lastScroll: number): ScrollDirection { + // Disable behavior if there is no scroll + if (element.scrollHeight <= element.clientHeight) { + return ScrollDirection.NoScroll; + } + const delta = event instanceof WheelEvent ? event.deltaY : element.scrollTop - lastScroll; + if (delta === 0) { + return ScrollDirection.NoScroll; + } + const scrollDirection = delta < 0 ? ScrollDirection.Top : ScrollDirection.Bottom; + const diff = + scrollDirection === ScrollDirection.Top + ? element.scrollTop + : element.scrollHeight - element.scrollTop - element.clientHeight; + + return diff <= 1 ? scrollDirection : ScrollDirection.NoScroll; +} + +function getVisibleRange(rows: LogRowModel[]) { + const firstTimeStamp = rows[0].timeEpochMs; + const lastTimeStamp = rows[rows.length - 1].timeEpochMs; + + const visibleRange = + lastTimeStamp < firstTimeStamp + ? { from: lastTimeStamp, to: firstTimeStamp } + : { from: firstTimeStamp, to: lastTimeStamp }; + + return visibleRange; +} + +function getPrevRange(visibleRange: AbsoluteTimeRange, currentRange: TimeRange) { + return { from: currentRange.from.valueOf(), to: visibleRange.from }; +} + +function getNextRange(visibleRange: AbsoluteTimeRange, currentRange: TimeRange, timeZone: TimeZone) { + // When requesting new logs, update the current range if using relative time ranges. + currentRange = updateCurrentRange(currentRange, timeZone); + return { from: visibleRange.to, to: currentRange.to.valueOf() }; +} + +export const SCROLLING_THRESHOLD = 1e3; + +// To get more logs, the difference between the visible range and the current range should be 1 second or more. +function canScrollTop( + visibleRange: AbsoluteTimeRange, + currentRange: TimeRange, + timeZone: TimeZone, + sortOrder: LogsSortOrder +): AbsoluteTimeRange | undefined { + if (sortOrder === LogsSortOrder.Descending) { + // When requesting new logs, update the current range if using relative time ranges. + currentRange = updateCurrentRange(currentRange, timeZone); + const canScroll = currentRange.to.valueOf() - visibleRange.to > SCROLLING_THRESHOLD; + return canScroll ? getNextRange(visibleRange, currentRange, timeZone) : undefined; + } + + const canScroll = Math.abs(currentRange.from.valueOf() - visibleRange.from) > SCROLLING_THRESHOLD; + return canScroll ? getPrevRange(visibleRange, currentRange) : undefined; +} + +function canScrollBottom( + visibleRange: AbsoluteTimeRange, + currentRange: TimeRange, + timeZone: TimeZone, + sortOrder: LogsSortOrder +): AbsoluteTimeRange | undefined { + if (sortOrder === LogsSortOrder.Descending) { + const canScroll = Math.abs(currentRange.from.valueOf() - visibleRange.from) > SCROLLING_THRESHOLD; + return canScroll ? getPrevRange(visibleRange, currentRange) : undefined; + } + // When requesting new logs, update the current range if using relative time ranges. + currentRange = updateCurrentRange(currentRange, timeZone); + const canScroll = currentRange.to.valueOf() - visibleRange.to > SCROLLING_THRESHOLD; + return canScroll ? getNextRange(visibleRange, currentRange, timeZone) : undefined; +} + +// Given a TimeRange, returns a new instance if using relative time, or else the same. +function updateCurrentRange(timeRange: TimeRange, timeZone: TimeZone) { + return isRelativeTimeRange(timeRange.raw) ? convertRawToRange(timeRange.raw, timeZone) : timeRange; +} diff --git a/public/app/features/logs/components/log-context/LoadingIndicator.tsx b/public/app/features/logs/components/LoadingIndicator.tsx similarity index 69% rename from public/app/features/logs/components/log-context/LoadingIndicator.tsx rename to public/app/features/logs/components/LoadingIndicator.tsx index 06a72775e9c14..c483b18c92fcd 100644 --- a/public/app/features/logs/components/log-context/LoadingIndicator.tsx +++ b/public/app/features/logs/components/LoadingIndicator.tsx @@ -3,17 +3,14 @@ import React from 'react'; import { Spinner } from '@grafana/ui'; -import { Place } from './types'; - // ideally we'd use `@grafana/ui/LoadingPlaceholder`, but that // one has a large margin-bottom. - type Props = { - place: Place; + adjective?: string; }; -export const LoadingIndicator = ({ place }: Props) => { - const text = place === 'above' ? 'Loading newer logs...' : 'Loading older logs...'; +export const LoadingIndicator = ({ adjective = 'newer' }: Props) => { + const text = `Loading ${adjective} logs...`; return ( <div className={loadingIndicatorStyles}> <div> diff --git a/public/app/features/logs/components/LogDetailsRow.tsx b/public/app/features/logs/components/LogDetailsRow.tsx index ac1ef650e5ecf..37fab48ce9b8a 100644 --- a/public/app/features/logs/components/LogDetailsRow.tsx +++ b/public/app/features/logs/components/LogDetailsRow.tsx @@ -1,7 +1,7 @@ import { css, cx } from '@emotion/css'; import { isEqual } from 'lodash'; import memoizeOne from 'memoize-one'; -import React, { PureComponent, useState } from 'react'; +import React, { PureComponent, useEffect, useState } from 'react'; import { CoreApp, @@ -290,7 +290,8 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> { <AsyncIconButton name="search-plus" onClick={this.filterLabel} - isActive={this.isFilterLabelActive} + // We purposely want to pass a new function on every render to allow the active state to be updated when log details remains open between updates. + isActive={() => this.isFilterLabelActive()} tooltipSuffix={refIdTooltip} /> <IconButton @@ -368,11 +369,9 @@ const AsyncIconButton = ({ isActive, tooltipSuffix, ...rest }: AsyncIconButtonPr const [active, setActive] = useState(false); const tooltip = active ? 'Remove filter' : 'Filter for value'; - /** - * We purposely want to run this on every render to allow the active state to be updated - * when log details remains open between updates. - */ - isActive().then(setActive); + useEffect(() => { + isActive().then(setActive); + }, [isActive]); return <IconButton {...rest} variant={active ? 'primary' : undefined} tooltip={tooltip + tooltipSuffix} />; }; diff --git a/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx b/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx index 2a9d154b1e8c0..18837d9292936 100644 --- a/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx +++ b/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx @@ -25,32 +25,28 @@ export interface Props { export const LogRowMessageDisplayedFields = React.memo((props: Props) => { const { row, detectedFields, getFieldLinks, wrapLogMessage, styles, mouseIsOver, pinned, ...rest } = props; - const fields = getAllFields(row, getFieldLinks); const wrapClassName = wrapLogMessage ? '' : displayedFieldsStyles.noWrap; + const fields = useMemo(() => getAllFields(row, getFieldLinks), [getFieldLinks, row]); // only single key/value rows are filterable, so we only need the first field key for filtering - const line = useMemo( - () => - detectedFields - .map((parsedKey) => { - const field = fields.find((field) => { - const { keys } = field; - return keys[0] === parsedKey; - }); + const line = useMemo(() => { + let line = ''; + for (let i = 0; i < detectedFields.length; i++) { + const parsedKey = detectedFields[i]; + const field = fields.find((field) => { + const { keys } = field; + return keys[0] === parsedKey; + }); - if (field !== undefined && field !== null) { - return `${parsedKey}=${field.values}`; - } + if (field) { + line += ` ${parsedKey}=${field.values}`; + } - if (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) { - return `${parsedKey}=${row.labels[parsedKey]}`; - } - - return null; - }) - .filter((s) => s !== null) - .join(' '), - [detectedFields, fields, row.labels] - ); + if (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) { + line += ` ${parsedKey}=${row.labels[parsedKey]}`; + } + } + return line.trimStart(); + }, [detectedFields, fields, row.labels]); const shouldShowMenu = useMemo(() => mouseIsOver || pinned, [mouseIsOver, pinned]); diff --git a/public/app/features/logs/components/LogRows.tsx b/public/app/features/logs/components/LogRows.tsx index 84c47088effbb..03110895b3574 100644 --- a/public/app/features/logs/components/LogRows.tsx +++ b/public/app/features/logs/components/LogRows.tsx @@ -121,14 +121,20 @@ class UnThemedLogRows extends PureComponent<Props, State> { if (!this.logRowsRef.current) { return false; } - const parentBounds = this.logRowsRef.current?.getBoundingClientRect(); + + const MENU_WIDTH = 270; + const MENU_HEIGHT = 105; + const x = e.clientX + MENU_WIDTH > window.innerWidth ? window.innerWidth - MENU_WIDTH : e.clientX; + const y = e.clientY + MENU_HEIGHT > window.innerHeight ? window.innerHeight - MENU_HEIGHT : e.clientY; + this.setState({ selection, - popoverMenuCoordinates: { x: e.clientX - parentBounds.left, y: e.clientY - parentBounds.top }, + popoverMenuCoordinates: { x, y }, selectedRow: row, }); document.addEventListener('click', this.handleDeselection); document.addEventListener('contextmenu', this.handleDeselection); + document.addEventListener('selectionchange', this.handleDeselection); return true; }; @@ -141,12 +147,17 @@ class UnThemedLogRows extends PureComponent<Props, State> { if (document.getSelection()?.toString()) { return; } - this.closePopoverMenu(); + // Give time to the browser to process click events originating from the menu before closing it. + // Otherwise selectionchange fires before other click listeners, potentially skipping user actions. + setTimeout(() => { + this.closePopoverMenu(); + }, 100); }; closePopoverMenu = () => { document.removeEventListener('click', this.handleDeselection); document.removeEventListener('contextmenu', this.handleDeselection); + document.removeEventListener('selectionchange', this.handleDeselection); this.setState({ selection: '', popoverMenuCoordinates: { x: 0, y: 0 }, @@ -170,6 +181,7 @@ class UnThemedLogRows extends PureComponent<Props, State> { componentWillUnmount() { document.removeEventListener('click', this.handleDeselection); document.removeEventListener('contextmenu', this.handleDeselection); + document.removeEventListener('selectionchange', this.handleDeselection); if (this.renderAllTimer) { clearTimeout(this.renderAllTimer); } diff --git a/public/app/features/logs/components/getLogRowStyles.ts b/public/app/features/logs/components/getLogRowStyles.ts index 4583138ece881..447c9b98b3751 100644 --- a/public/app/features/logs/components/getLogRowStyles.ts +++ b/public/app/features/logs/components/getLogRowStyles.ts @@ -3,7 +3,6 @@ import memoizeOne from 'memoize-one'; import tinycolor from 'tinycolor2'; import { colorManipulator, GrafanaTheme2, LogLevel } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { styleMixins } from '@grafana/ui'; export const getLogLevelStyles = (theme: GrafanaTheme2, logLevel?: LogLevel) => { @@ -44,7 +43,6 @@ export const getLogLevelStyles = (theme: GrafanaTheme2, logLevel?: LogLevel) => export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => { const hoverBgColor = styleMixins.hoverColor(theme.colors.background.secondary, theme); const contextOutlineColor = tinycolor(theme.components.dashboard.background).setAlpha(0.7).toRgbString(); - const scrollableLogsContainer = config.featureToggles.exploreScrollableLogsContainer; return { logsRowLevel: css` label: logs-row__level; @@ -75,7 +73,6 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => { font-family: ${theme.typography.fontFamilyMonospace}; font-size: ${theme.typography.bodySmall.fontSize}; width: 100%; - ${!scrollableLogsContainer && `margin-bottom: ${theme.spacing(2.25)};`} position: relative; `, logsRowsTableContain: css` diff --git a/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx b/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx index f36f51b8ad367..a799584812969 100644 --- a/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx +++ b/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx @@ -292,6 +292,117 @@ describe('LogRowContextModal', () => { }); }); + it('should highlight the same `foo123` searchwords', async () => { + const dfBeforeNs = createDataFrame({ + fields: [ + { + name: 'time', + type: FieldType.time, + values: [1, 1], + }, + { + name: 'message', + type: FieldType.string, + values: ['this contains foo123', 'this contains foo123'], + }, + { + name: 'tsNs', + type: FieldType.string, + values: ['1', '2'], + }, + ], + }); + const dfNowNs = createDataFrame({ + fields: [ + { + name: 'time', + type: FieldType.time, + values: [1], + }, + { + name: 'message', + type: FieldType.string, + values: ['this contains foo123'], + }, + { + name: 'tsNs', + type: FieldType.string, + values: ['2'], + }, + ], + }); + const dfAfterNs = createDataFrame({ + fields: [ + { + name: 'time', + type: FieldType.time, + values: [1, 1], + }, + { + name: 'message', + type: FieldType.string, + values: ['this contains foo123', 'this contains foo123'], + }, + { + name: 'tsNs', + type: FieldType.string, + values: ['2', '3'], + }, + ], + }); + + let uniqueRefIdCounter = 1; + const logs = dataFrameToLogsModel([dfNowNs]); + const row = logs.rows[0]; + row.searchWords = ['foo123']; + const getRowContext = jest.fn().mockImplementation(async (_, options) => { + uniqueRefIdCounter += 1; + const refId = `refid_${uniqueRefIdCounter}`; + if (uniqueRefIdCounter === 2) { + return { + data: [ + { + refId, + ...dfBeforeNs, + }, + ], + }; + } else if (uniqueRefIdCounter === 3) { + return { + data: [ + { + refId, + ...dfAfterNs, + }, + ], + }; + } + return { data: [] }; + }); + + render( + <LogRowContextModal + row={row} + open={true} + onClose={() => {}} + getRowContext={getRowContext} + timeZone={timeZone} + logsSortOrder={LogsSortOrder.Descending} + /> + ); + + // there need to be 3 lines with that message, all `foo123` should be highlighted + await waitFor(() => { + expect(screen.getAllByText('foo123').length).toBe(3); + for (const el of screen.getAllByText('foo123')) { + // highlights are done in `<mark>` tags + expect(el.tagName).toBe('MARK'); + } + // test that the rest is not in a MARK + expect(screen.getAllByText('this contains')[0].tagName).not.toBe('MARK'); + }); + }); + it('should show a split view button', async () => { const getRowContextQuery = jest.fn().mockResolvedValue({ datasource: { uid: 'test-uid' } }); diff --git a/public/app/features/logs/components/log-context/LogRowContextModal.tsx b/public/app/features/logs/components/log-context/LogRowContextModal.tsx index 0759c8ac287e6..684b83700ee15 100644 --- a/public/app/features/logs/components/log-context/LogRowContextModal.tsx +++ b/public/app/features/logs/components/log-context/LogRowContextModal.tsx @@ -26,11 +26,10 @@ import { useDispatch } from 'app/types'; import { dataFrameToLogsModel } from '../../logsModel'; import { sortLogRows } from '../../utils'; +import { LoadingIndicator } from '../LoadingIndicator'; import { LogRows } from '../LogRows'; -import { LoadingIndicator } from './LoadingIndicator'; import { LogContextButtons } from './LogContextButtons'; -import { Place } from './types'; const getStyles = (theme: GrafanaTheme2) => { return { @@ -143,6 +142,7 @@ type Section = { loadingState: LoadingState; rows: LogRowModel[]; }; +type Place = 'above' | 'below'; type Context = Record<Place, Section>; const makeEmptyContext = (): Context => ({ @@ -363,7 +363,10 @@ export const LogRowContextModal: React.FunctionComponent<LogRowContextModalProps // this way this array of rows will never be empty const allRows = [...above.rows, row, ...below.rows]; - const newRows = await loadMore(place, allRows); + const newRows = (await loadMore(place, allRows)).map((r) => + // apply the original row's searchWords to all the rows for highlighting + !r.searchWords || !r.searchWords?.length ? { ...r, searchWords: row.searchWords } : r + ); const [older, newer] = partition(newRows, (newRow) => newRow.timeEpochNs > row.timeEpochNs); const newAbove = logsSortOrder === LogsSortOrder.Ascending ? newer : older; const newBelow = logsSortOrder === LogsSortOrder.Ascending ? older : newer; @@ -515,7 +518,7 @@ export const LogRowContextModal: React.FunctionComponent<LogRowContextModalProps <td className={styles.loadingCell}> {loadingStateAbove !== LoadingState.Done && loadingStateAbove !== LoadingState.Error && ( <div ref={aboveLoadingElement}> - <LoadingIndicator place="above" /> + <LoadingIndicator adjective="newer" /> </div> )} {loadingStateAbove === LoadingState.Error && <div>Error loading log more logs.</div>} @@ -584,7 +587,7 @@ export const LogRowContextModal: React.FunctionComponent<LogRowContextModalProps <td className={styles.loadingCell}> {loadingStateBelow !== LoadingState.Done && loadingStateBelow !== LoadingState.Error && ( <div ref={belowLoadingElement}> - <LoadingIndicator place="below" /> + <LoadingIndicator adjective="older" /> </div> )} {loadingStateBelow === LoadingState.Error && <div>Error loading log more logs.</div>} diff --git a/public/app/features/logs/components/log-context/types.ts b/public/app/features/logs/components/log-context/types.ts deleted file mode 100644 index 9f625102e423f..0000000000000 --- a/public/app/features/logs/components/log-context/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type Place = 'above' | 'below'; diff --git a/public/app/features/logs/components/logParser.ts b/public/app/features/logs/components/logParser.ts index 7e541ca3e535a..6f412f070f133 100644 --- a/public/app/features/logs/components/logParser.ts +++ b/public/app/features/logs/components/logParser.ts @@ -1,5 +1,4 @@ import { partition } from 'lodash'; -import memoizeOne from 'memoize-one'; import { DataFrame, Field, FieldWithIndex, LinkModel, LogRowModel } from '@grafana/data'; import { safeStringifyValue } from 'app/core/utils/explore'; @@ -18,26 +17,22 @@ export type FieldDef = { * Returns all fields for log row which consists of fields we parse from the message itself and additional fields * found in the dataframe (they may contain links). */ -export const getAllFields = memoizeOne( - ( - row: LogRowModel, - getFieldLinks?: ( - field: Field, - rowIndex: number, - dataFrame: DataFrame - ) => Array<LinkModel<Field>> | ExploreFieldLinkModel[] - ) => { - const dataframeFields = getDataframeFields(row, getFieldLinks); - - return Object.values(dataframeFields); - } -); +export const getAllFields = ( + row: LogRowModel, + getFieldLinks?: ( + field: Field, + rowIndex: number, + dataFrame: DataFrame + ) => Array<LinkModel<Field>> | ExploreFieldLinkModel[] +) => { + return getDataframeFields(row, getFieldLinks); +}; /** * A log line may contain many links that would all need to go on their own logs detail row * This iterates through and creates a FieldDef (row) per link. */ -export const createLogLineLinks = memoizeOne((hiddenFieldsWithLinks: FieldDef[]): FieldDef[] => { +export const createLogLineLinks = (hiddenFieldsWithLinks: FieldDef[]): FieldDef[] => { let fieldsWithLinksFromVariableMap: FieldDef[] = []; hiddenFieldsWithLinks.forEach((linkField) => { linkField.links?.forEach((link: ExploreFieldLinkModel) => { @@ -58,34 +53,29 @@ export const createLogLineLinks = memoizeOne((hiddenFieldsWithLinks: FieldDef[]) }); }); return fieldsWithLinksFromVariableMap; -}); +}; /** * creates fields from the dataframe-fields, adding data-links, when field.config.links exists */ -export const getDataframeFields = memoizeOne( - ( - row: LogRowModel, - getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>> - ): FieldDef[] => { - const visibleFields = separateVisibleFields(row.dataFrame).visible; - const nonEmptyVisibleFields = visibleFields.filter((f) => f.values[row.rowIndex] != null); - return nonEmptyVisibleFields.map((field) => { - const links = getFieldLinks ? getFieldLinks(field, row.rowIndex, row.dataFrame) : []; - const fieldVal = field.values[row.rowIndex]; - const outputVal = - typeof fieldVal === 'string' || typeof fieldVal === 'number' - ? fieldVal.toString() - : safeStringifyValue(fieldVal); - return { - keys: [field.name], - values: [outputVal], - links: links, - fieldIndex: field.index, - }; - }); - } -); +export const getDataframeFields = ( + row: LogRowModel, + getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>> +): FieldDef[] => { + const nonEmptyVisibleFields = getNonEmptyVisibleFields(row); + return nonEmptyVisibleFields.map((field) => { + const links = getFieldLinks ? getFieldLinks(field, row.rowIndex, row.dataFrame) : []; + const fieldVal = field.values[row.rowIndex]; + const outputVal = + typeof fieldVal === 'string' || typeof fieldVal === 'number' ? fieldVal.toString() : safeStringifyValue(fieldVal); + return { + keys: [field.name], + values: [outputVal], + links: links, + fieldIndex: field.index, + }; + }); +}; type VisOptions = { keepTimestamp?: boolean; @@ -148,3 +138,27 @@ export function separateVisibleFields( return { visible, hidden }; } + +// Optimized version of separateVisibleFields() to only return visible fields for getAllFields() +function getNonEmptyVisibleFields(row: LogRowModel, opts?: VisOptions): FieldWithIndex[] { + const frame = row.dataFrame; + const visibleFieldIndices = getVisibleFieldIndices(frame, opts ?? {}); + const visibleFields: FieldWithIndex[] = []; + for (let index = 0; index < frame.fields.length; index++) { + const field = frame.fields[index]; + // ignore empty fields + if (field.values[row.rowIndex] == null) { + continue; + } + // hidden fields are always hidden + if (field.config.custom?.hidden) { + continue; + } + + // fields with data-links are visible + if ((field.config.links && field.config.links.length > 0) || visibleFieldIndices.has(index)) { + visibleFields.push({ ...field, index }); + } + } + return visibleFields; +} diff --git a/public/app/features/logs/logsModel.test.ts b/public/app/features/logs/logsModel.test.ts index 58fa2ec512d7d..05883b148493a 100644 --- a/public/app/features/logs/logsModel.test.ts +++ b/public/app/features/logs/logsModel.test.ts @@ -2,6 +2,7 @@ import { Observable } from 'rxjs'; import { arrayToDataFrame, + createDataFrame, DataFrame, DataQuery, DataQueryRequest, @@ -16,10 +17,11 @@ import { LogsMetaKind, LogsVolumeCustomMetaData, LogsVolumeType, - MutableDataFrame, sortDataFrame, toDataFrame, } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { getMockFrames } from 'app/plugins/datasource/loki/__mocks__/frames'; import { MockObservableDataSourceApi } from '../../../test/mocks/datasource_srv'; @@ -29,6 +31,7 @@ import { dedupLogRows, filterLogLevels, getSeriesProperties, + infiniteScrollRefId, LIMIT_LABEL, logRowToSingleRowDataFrame, logSeriesToLogsModel, @@ -230,7 +233,7 @@ const emptyLogsModel = { describe('dataFrameToLogsModel', () => { it('given empty series should return empty logs model', () => { - expect(dataFrameToLogsModel([] as DataFrame[], 0)).toMatchObject(emptyLogsModel); + expect(dataFrameToLogsModel([], 0)).toMatchObject(emptyLogsModel); }); it('given series without correct series name should return empty logs model', () => { @@ -244,7 +247,7 @@ describe('dataFrameToLogsModel', () => { it('given series without a time field should return empty logs model', () => { const series: DataFrame[] = [ - new MutableDataFrame({ + createDataFrame({ fields: [ { name: 'message', @@ -259,7 +262,7 @@ describe('dataFrameToLogsModel', () => { it('given series without a string field should return empty logs model', () => { const series: DataFrame[] = [ - new MutableDataFrame({ + createDataFrame({ fields: [ { name: 'time', @@ -274,7 +277,7 @@ describe('dataFrameToLogsModel', () => { it('given one series should return expected logs model', () => { const series: DataFrame[] = [ - new MutableDataFrame({ + createDataFrame({ fields: [ { name: 'time', @@ -358,9 +361,48 @@ describe('dataFrameToLogsModel', () => { }); }); + it('with infinite scrolling enabled it should return expected logs model', () => { + config.featureToggles.logsInfiniteScrolling = true; + + const series: DataFrame[] = [ + createDataFrame({ + fields: [ + { + name: 'time', + type: FieldType.time, + values: ['2019-04-26T09:28:11.352440161Z'], + }, + { + name: 'message', + type: FieldType.string, + values: ['t=2019-04-26T11:05:28+0200 lvl=info msg="Initializing DatasourceCacheService" logger=server'], + labels: {}, + }, + { + name: 'id', + type: FieldType.string, + values: ['foo'], + }, + ], + meta: { + limit: 1000, + }, + refId: 'A', + }), + ]; + const logsModel = dataFrameToLogsModel(series, 1); + expect(logsModel.meta![0]).toMatchObject({ + label: LIMIT_LABEL, + value: `1000 (1 displayed)`, + kind: LogsMetaKind.String, + }); + + config.featureToggles.logsInfiniteScrolling = false; + }); + it('given one series with limit as custom meta property should return correct limit', () => { const series: DataFrame[] = [ - new MutableDataFrame({ + createDataFrame({ fields: [ { name: 'time', @@ -402,7 +444,7 @@ describe('dataFrameToLogsModel', () => { it('given one series with labels-field should return expected logs model', () => { const series: DataFrame[] = [ - new MutableDataFrame({ + createDataFrame({ fields: [ { name: 'labels', @@ -516,15 +558,15 @@ describe('dataFrameToLogsModel', () => { type: FieldType.string, values: ['line1'], }; - const frame1 = new MutableDataFrame({ + const frame1 = createDataFrame({ fields: [labels, time, line], }); - const frame2 = new MutableDataFrame({ + const frame2 = createDataFrame({ fields: [time, labels, line], }); - const frame3 = new MutableDataFrame({ + const frame3 = createDataFrame({ fields: [time, line, labels], }); @@ -543,7 +585,7 @@ describe('dataFrameToLogsModel', () => { it('given one series with error should return expected logs model', () => { const series: DataFrame[] = [ - new MutableDataFrame({ + createDataFrame({ fields: [ { name: 'time', @@ -619,7 +661,7 @@ describe('dataFrameToLogsModel', () => { it('given one series without labels should return expected logs model', () => { const series: DataFrame[] = [ - new MutableDataFrame({ + createDataFrame({ fields: [ { name: 'time', @@ -910,7 +952,7 @@ describe('dataFrameToLogsModel', () => { it('should return expected line limit meta info when returned number of series equal the log limit', () => { const series: DataFrame[] = [ - new MutableDataFrame({ + createDataFrame({ fields: [ { name: 'time', @@ -976,6 +1018,51 @@ describe('dataFrameToLogsModel', () => { const logsModel = dataFrameToLogsModel(series, 1); expect(logsModel.rows[0].uid).toBe('A_0'); }); + + describe('infinite scrolling', () => { + let frameA: DataFrame, frameB: DataFrame; + beforeEach(() => { + const { logFrameA, logFrameB } = getMockFrames(); + logFrameA.refId = `${infiniteScrollRefId}-A`; + logFrameA.fields[0].values = [1, 1]; + logFrameA.fields[1].values = ['line', 'line']; + logFrameA.fields[3].values = ['3000000', '3000000']; + logFrameA.fields[4].values = ['id', 'id']; + logFrameB.refId = `${infiniteScrollRefId}-B`; + logFrameB.fields[0].values = [2, 2]; + logFrameB.fields[1].values = ['line 2', 'line 2']; + logFrameB.fields[3].values = ['4000000', '4000000']; + logFrameB.fields[4].values = ['id2', 'id2']; + frameA = logFrameA; + frameB = logFrameB; + }); + + it('deduplicates repeated log frames when invoked from infinite scrolling results', () => { + const logsModel = dataFrameToLogsModel([frameA, frameB], 1, { from: 1556270591353, to: 1556289770991 }, [ + { refId: `${infiniteScrollRefId}-A` }, + { refId: `${infiniteScrollRefId}-B` }, + ]); + + expect(logsModel.rows).toHaveLength(2); + expect(logsModel.rows[0].entry).toBe(frameA.fields[1].values[0]); + expect(logsModel.rows[1].entry).toBe(frameB.fields[1].values[0]); + }); + + it('does not remove repeated log frames when invoked from other contexts', () => { + frameA.refId = 'A'; + frameB.refId = 'B'; + const logsModel = dataFrameToLogsModel([frameA, frameB], 1, { from: 1556270591353, to: 1556289770991 }, [ + { refId: 'A' }, + { refId: 'B' }, + ]); + + expect(logsModel.rows).toHaveLength(4); + expect(logsModel.rows[0].entry).toBe(frameA.fields[1].values[0]); + expect(logsModel.rows[1].entry).toBe(frameA.fields[1].values[1]); + expect(logsModel.rows[2].entry).toBe(frameB.fields[1].values[0]); + expect(logsModel.rows[3].entry).toBe(frameB.fields[1].values[1]); + }); + }); }); describe('logSeriesToLogsModel', () => { @@ -1230,16 +1317,22 @@ describe('logs volume', () => { { refId: 'B', target: 'volume query 2' }, ], scopedVars: {}, - } as unknown as DataQueryRequest<TestDataQuery>; - volumeProvider = queryLogsVolume(datasource, request, { - extractLevel: (dataFrame: DataFrame) => { - return dataFrame.fields[1]!.labels!.level === 'error' ? LogLevel.error : LogLevel.unknown; - }, + requestId: '', + interval: '', + intervalMs: 0, range: { from: FROM, to: TO, - raw: { from: '0', to: '1' }, + raw: { + from: FROM, + to: TO, + }, }, + timezone: '', + app: '', + startTime: 0, + }; + volumeProvider = queryLogsVolume(datasource, request, { targets: request.targets, }); } @@ -1549,6 +1642,7 @@ const mockLogRow = { }, { name: 'labels', type: FieldType.other, values: [{ app: 'app01' }, { app: 'app02' }] }, ], + refId: 'Z', }), rowIndex: 0, } as unknown as LogRowModel; @@ -1587,4 +1681,9 @@ describe('logRowToDataFrame', () => { expect(result).toBe(null); }); + + it('should use refId from original DataFrame', () => { + const result = logRowToSingleRowDataFrame(mockLogRow); + expect(result?.refId).toBe(mockLogRow.dataFrame.refId); + }); }); diff --git a/public/app/features/logs/logsModel.ts b/public/app/features/logs/logsModel.ts index d49dfd9a41473..396553207dc28 100644 --- a/public/app/features/logs/logsModel.ts +++ b/public/app/features/logs/logsModel.ts @@ -35,17 +35,17 @@ import { ScopedVars, sortDataFrame, textUtil, - TimeRange, toDataFrame, toUtc, } from '@grafana/data'; import { SIPrefix } from '@grafana/data/src/valueFormats/symbolFormatters'; +import { config } from '@grafana/runtime'; import { BarAlignment, GraphDrawStyle, StackingMode } from '@grafana/schema'; import { ansicolor, colors } from '@grafana/ui'; import { getThemeColor } from 'app/core/utils/colors'; import { LogsFrame, parseLogsFrame } from './logsFrame'; -import { getLogLevel, getLogLevelFromKey, sortInAscendingOrder } from './utils'; +import { createLogRowsMap, getLogLevel, getLogLevelFromKey, sortInAscendingOrder } from './utils'; export const LIMIT_LABEL = 'Line limit'; export const COMMON_LABELS = 'Common labels'; @@ -201,6 +201,8 @@ function isLogsData(series: DataFrame) { return series.fields.some((f) => f.type === FieldType.time) && series.fields.some((f) => f.type === FieldType.string); } +export const infiniteScrollRefId = 'infinite-scroll-'; + /** * Convert dataFrame into LogsModel which consists of creating separate array of log rows and metrics series. Metrics * series can be either already included in the dataFrame or will be computed from the log rows. @@ -215,8 +217,27 @@ export function dataFrameToLogsModel( absoluteRange?: AbsoluteTimeRange, queries?: DataQuery[] ): LogsModel { + // Until nanosecond precision for requests is supported, we need to account for possible duplicate rows. + let infiniteScrollingResults = false; + queries = queries?.map((query) => { + if (query.refId.includes(infiniteScrollRefId)) { + infiniteScrollingResults = true; + return { + ...query, + refId: query.refId.replace(infiniteScrollRefId, ''), + }; + } + return query; + }); + if (infiniteScrollingResults) { + dataFrame = dataFrame.map((frame) => ({ + ...frame, + refId: frame.refId?.replace(infiniteScrollRefId, ''), + })); + } + const { logSeries } = separateLogsAndMetrics(dataFrame); - const logsModel = logSeriesToLogsModel(logSeries, queries); + const logsModel = logSeriesToLogsModel(logSeries, queries, infiniteScrollingResults); if (logsModel) { // Create histogram metrics from logs using the interval as bucket size for the line count @@ -350,7 +371,11 @@ function parseTime( * Converts dataFrames into LogsModel. This involves merging them into one list, sorting them and computing metadata * like common labels. */ -export function logSeriesToLogsModel(logSeries: DataFrame[], queries: DataQuery[] = []): LogsModel | undefined { +export function logSeriesToLogsModel( + logSeries: DataFrame[], + queries: DataQuery[] = [], + filterDuplicateRows = false +): LogsModel | undefined { if (logSeries.length === 0) { return undefined; } @@ -386,9 +411,10 @@ export function logSeriesToLogsModel(logSeries: DataFrame[], queries: DataQuery[ const flatAllLabels = allLabels.flat(); const commonLabels = flatAllLabels.length > 0 ? findCommonLabels(flatAllLabels) : {}; - const rows: LogRowModel[] = []; + let rows: LogRowModel[] = []; let hasUniqueLabels = false; + const findMatchingRow = createLogRowsMap(); for (const info of allSeries) { const { logsFrame, rawFrame: series, frameLabels } = info; const { timeField, timeNanosecondField, bodyField: stringField, severityField: logLevelField, idField } = logsFrame; @@ -452,6 +478,10 @@ export function logSeriesToLogsModel(logSeries: DataFrame[], queries: DataQuery[ row.rowId = idField.values[j]; } + if (filterDuplicateRows && findMatchingRow(row)) { + continue; + } + rows.push(row); } } @@ -532,7 +562,10 @@ export function logSeriesToLogsModel(logSeries: DataFrame[], queries: DataQuery[ // Used to add additional information to Line limit meta info function adjustMetaInfo(logsModel: LogsModel, visibleRangeMs?: number, requestedRangeMs?: number): LogsMetaItem[] { - let logsModelMeta = [...logsModel.meta!]; + if (!logsModel.meta) { + return []; + } + let logsModelMeta = [...logsModel.meta]; const limitIndex = logsModelMeta.findIndex((meta) => meta.label === LIMIT_LABEL); const limit = limitIndex >= 0 && logsModelMeta[limitIndex]?.value; @@ -547,7 +580,8 @@ function adjustMetaInfo(logsModel: LogsModel, visibleRangeMs?: number, requested visibleRangeMs )}) of your selected time range (${rangeUtil.msRangeToTimeString(requestedRangeMs)})`; } else { - metaLimitValue = `${limit} (${logsModel.rows.length} returned)`; + const description = config.featureToggles.logsInfiniteScrolling ? 'displayed' : 'returned'; + metaLimitValue = `${limit} (${logsModel.rows.length} ${description})`; } logsModelMeta[limitIndex] = { @@ -606,11 +640,22 @@ const updateLogsVolumeConfig = ( }; type LogsVolumeQueryOptions<T extends DataQuery> = { - extractLevel: (dataFrame: DataFrame) => LogLevel; targets: T[]; - range: TimeRange; }; +function defaultExtractLevel(dataFrame: DataFrame): LogLevel { + let valueField; + try { + valueField = new FieldCache(dataFrame).getFirstFieldOfType(FieldType.number); + } catch {} + return valueField?.labels ? getLogLevelFromLabels(valueField.labels) : LogLevel.unknown; +} + +function getLogLevelFromLabels(labels: Labels): LogLevel { + const level = labels['level'] ?? labels['lvl'] ?? labels['loglevel'] ?? ''; + return level ? getLogLevelFromKey(level) : LogLevel.unknown; +} + /** * Creates an observable, which makes requests to get logs volume and aggregates results. */ @@ -619,7 +664,10 @@ export function queryLogsVolume<TQuery extends DataQuery, TOptions extends DataS logsVolumeRequest: DataQueryRequest<TQuery>, options: LogsVolumeQueryOptions<TQuery> ): Observable<DataQueryResponse> { - const timespan = options.range.to.valueOf() - options.range.from.valueOf(); + const range = logsVolumeRequest.range; + const targets = options.targets; + const extractLevel = defaultExtractLevel; + const timespan = range.to.valueOf() - range.from.valueOf(); const intervalInfo = getIntervalInfo(logsVolumeRequest.scopedVars, timespan); logsVolumeRequest.interval = intervalInfo.interval; @@ -670,9 +718,9 @@ export function queryLogsVolume<TQuery extends DataQuery, TOptions extends DataS const logsVolumeCustomMetaData: LogsVolumeCustomMetaData = { logsVolumeType: LogsVolumeType.FullRange, - absoluteRange: { from: options.range.from.valueOf(), to: options.range.to.valueOf() }, + absoluteRange: { from: range.from.valueOf(), to: range.to.valueOf() }, datasourceName: datasource.name, - sourceQuery: options.targets.find((dataQuery) => dataQuery.refId === sourceRefId)!, + sourceQuery: targets.find((dataQuery) => dataQuery.refId === sourceRefId)!, }; dataFrame.meta = { @@ -682,7 +730,7 @@ export function queryLogsVolume<TQuery extends DataQuery, TOptions extends DataS ...logsVolumeCustomMetaData, }, }; - return updateLogsVolumeConfig(dataFrame, options.extractLevel, framesByRefId[dataFrame.refId].length === 1); + return updateLogsVolumeConfig(dataFrame, extractLevel, framesByRefId[dataFrame.refId].length === 1); }); observer.next({ @@ -809,6 +857,7 @@ export function logRowToSingleRowDataFrame(logRow: LogRowModel): DataFrame | nul // create a new data frame containing only the single row from `logRow` const frame = createDataFrame({ fields: originFrame.fields.map((field) => ({ ...field, values: [field.values[logRow.rowIndex]] })), + refId: originFrame.refId, }); return frame; diff --git a/public/app/features/logs/utils.test.ts b/public/app/features/logs/utils.test.ts index f73a0d43f6ea7..184a594acb92f 100644 --- a/public/app/features/logs/utils.test.ts +++ b/public/app/features/logs/utils.test.ts @@ -9,12 +9,15 @@ import { MutableDataFrame, DataFrame, } from '@grafana/data'; +import { getMockFrames } from 'app/plugins/datasource/loki/__mocks__/frames'; +import { logSeriesToLogsModel } from './logsModel'; import { calculateLogsLabelStats, calculateStats, checkLogsError, escapeUnescapedString, + createLogRowsMap, getLogLevel, getLogLevelFromKey, getLogsVolumeMaximumRange, @@ -479,3 +482,49 @@ describe('escapeUnescapedString', () => { expect(escapeUnescapedString(`\\r\\n|\\n|\\t|\\r`)).toBe(`\n|\n|\t|\n`); }); }); + +describe('findMatchingRow', () => { + function setup(frames: DataFrame[]) { + const logsModel = logSeriesToLogsModel(frames); + const rows = logsModel?.rows || []; + const findMatchingRow = createLogRowsMap(); + for (const row of rows) { + expect(findMatchingRow(row)).toBeFalsy(); + } + return { rows, findMatchingRow }; + } + + it('ignores rows from different queries', () => { + const { logFrameA, logFrameB } = getMockFrames(); + logFrameA.refId = 'A'; + logFrameB.refId = 'B'; + const { rows, findMatchingRow } = setup([logFrameA, logFrameB]); + + for (const row of rows) { + const targetRow = { ...row, dataFrame: { ...logFrameA, refId: 'Z' } }; + expect(findMatchingRow(targetRow)).toBeFalsy(); + } + }); + + it('matches rows by rowId', () => { + const { logFrameA, logFrameB } = getMockFrames(); + const { rows, findMatchingRow } = setup([logFrameA, logFrameB]); + + for (const row of rows) { + const targetRow = { ...row, entry: `${Math.random()}`, timeEpochNs: `${Math.ceil(Math.random() * 1000000)}` }; + expect(findMatchingRow(targetRow)).toBeTruthy(); + } + }); + + it('matches rows by entry and nanosecond time', () => { + const { logFrameA, logFrameB } = getMockFrames(); + logFrameA.fields[4].values = []; + logFrameB.fields[4].values = []; + const { rows, findMatchingRow } = setup([logFrameA, logFrameB]); + + for (const row of rows) { + const targetRow = { ...row, rowId: undefined }; + expect(findMatchingRow(targetRow)).toBeTruthy(); + } + }); +}); diff --git a/public/app/features/logs/utils.ts b/public/app/features/logs/utils.ts index 9f7cce6fab36b..56b138920fa2b 100644 --- a/public/app/features/logs/utils.ts +++ b/public/app/features/logs/utils.ts @@ -297,3 +297,15 @@ export const copyText = async (text: string, buttonRef: React.MutableRefObject<E export function targetIsElement(target: EventTarget | null): target is Element { return target instanceof Element; } + +export function createLogRowsMap() { + const logRowsSet = new Set(); + return function (target: LogRowModel): boolean { + let id = `${target.dataFrame.refId}_${target.rowId ? target.rowId : `${target.timeEpochNs}_${target.entry}`}`; + if (logRowsSet.has(id)) { + return true; + } + logRowsSet.add(id); + return false; + }; +} diff --git a/public/app/features/manage-dashboards/DashboardImportPage.tsx b/public/app/features/manage-dashboards/DashboardImportPage.tsx index 16266303ca33a..2a60019e4adfd 100644 --- a/public/app/features/manage-dashboards/DashboardImportPage.tsx +++ b/public/app/features/manage-dashboards/DashboardImportPage.tsx @@ -8,14 +8,11 @@ import { config, reportInteraction } from '@grafana/runtime'; import { Button, Field, - Form, - HorizontalGroup, Input, Spinner, stylesFactory, TextArea, Themeable2, - VerticalGroup, FileDropzone, withTheme2, DropzoneFile, @@ -23,8 +20,10 @@ import { LinkButton, TextLink, Label, + Stack, } from '@grafana/ui'; import appEvents from 'app/core/app_events'; +import { Form } from 'app/core/components/Form/Form'; import { Page } from 'app/core/components/Page/Page'; import { t, Trans } from 'app/core/internationalization'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; @@ -208,14 +207,14 @@ class UnthemedDashboardImport extends PureComponent<Props> { placeholder={JSON_PLACEHOLDER} /> </Field> - <HorizontalGroup> + <Stack> <Button type="submit" data-testid={selectors.components.DashboardImportPage.submit}> <Trans i18nKey="dashboard-import.form-actions.load">Load</Trans> </Button> <LinkButton variant="secondary" href={`${config.appSubUrl}/dashboards`}> <Trans i18nKey="dashboard-import.form-actions.cancel">Cancel</Trans> </LinkButton> - </HorizontalGroup> + </Stack> </> )} </Form> @@ -236,11 +235,11 @@ class UnthemedDashboardImport extends PureComponent<Props> { <Page navId="dashboards/browse" pageNav={this.pageNav}> <Page.Contents> {loadingState === LoadingState.Loading && ( - <VerticalGroup justify="center"> - <HorizontalGroup justify="center"> + <Stack direction={'column'} justifyContent="center"> + <Stack justifyContent="center"> <Spinner size="xxl" /> - </HorizontalGroup> - </VerticalGroup> + </Stack> + </Stack> )} {[LoadingState.Error, LoadingState.NotStarted].includes(loadingState) && this.renderImportForm()} {loadingState === LoadingState.Done && <ImportDashboardOverview />} diff --git a/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx b/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx index b49f9c1ca4e76..8cb50e12a576b 100644 --- a/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx +++ b/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx @@ -1,18 +1,9 @@ import React, { useEffect, useState } from 'react'; +import { Controller, FieldErrors, UseFormReturn } from 'react-hook-form'; import { selectors } from '@grafana/e2e-selectors'; import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; -import { - Button, - Field, - FormAPI, - FormFieldErrors, - FormsOnSubmit, - HorizontalGroup, - Input, - InputControl, - Legend, -} from '@grafana/ui'; +import { Button, Field, FormFieldErrors, FormsOnSubmit, Stack, Input, Legend } from '@grafana/ui'; import { OldFolderPicker } from 'app/core/components/Select/OldFolderPicker'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; @@ -27,11 +18,11 @@ import { validateTitle, validateUid } from '../utils/validation'; import { ImportDashboardLibraryPanelsList } from './ImportDashboardLibraryPanelsList'; -interface Props extends Pick<FormAPI<ImportDashboardDTO>, 'register' | 'errors' | 'control' | 'getValues' | 'watch'> { +interface Props extends Pick<UseFormReturn<ImportDashboardDTO>, 'register' | 'control' | 'getValues' | 'watch'> { uidReset: boolean; inputs: DashboardInputs; initialFolderUid: string; - + errors: FieldErrors<ImportDashboardDTO>; onCancel: () => void; onUidReset: () => void; onSubmit: FormsOnSubmit<ImportDashboardDTO>; @@ -80,7 +71,7 @@ export const ImportDashboardForm = ({ /> </Field> <Field label="Folder"> - <InputControl + <Controller render={({ field: { ref, ...field } }) => ( <OldFolderPicker {...field} enableCreateNew initialFolderUid={initialFolderUid} /> )} @@ -123,7 +114,7 @@ export const ImportDashboardForm = ({ invalid={errors.dataSources && !!errors.dataSources[index]} error={errors.dataSources && errors.dataSources[index] && 'A data source is required'} > - <InputControl + <Controller name={dataSourceOption} render={({ field: { ref, ...field } }) => ( <DataSourcePicker @@ -166,7 +157,7 @@ export const ImportDashboardForm = ({ description="List of existing library panels. These panels are not affected by the import." folderName={watchFolder.title} /> - <HorizontalGroup> + <Stack> <Button type="submit" data-testid={selectors.components.ImportDashboardForm.submit} @@ -180,7 +171,7 @@ export const ImportDashboardForm = ({ <Button type="reset" variant="secondary" onClick={onCancel}> Cancel </Button> - </HorizontalGroup> + </Stack> </> ); }; diff --git a/public/app/features/manage-dashboards/components/ImportDashboardOverview.tsx b/public/app/features/manage-dashboards/components/ImportDashboardOverview.tsx index 5b9273cbe4ed4..7ead582344700 100644 --- a/public/app/features/manage-dashboards/components/ImportDashboardOverview.tsx +++ b/public/app/features/manage-dashboards/components/ImportDashboardOverview.tsx @@ -3,7 +3,8 @@ import { connect, ConnectedProps } from 'react-redux'; import { dateTimeFormat } from '@grafana/data'; import { locationService, reportInteraction } from '@grafana/runtime'; -import { Form, Legend } from '@grafana/ui'; +import { Box, Legend } from '@grafana/ui'; +import { Form } from 'app/core/components/Form/Form'; import { StoreState } from 'app/types'; import { clearLoadedDashboard, importDashboard } from '../state/actions'; @@ -64,7 +65,7 @@ class ImportDashboardOverviewUnConnected extends PureComponent<Props, State> { return ( <> {source === DashboardSource.Gcom && ( - <div style={{ marginBottom: '24px' }}> + <Box marginBottom={3}> <div> <Legend> Importing dashboard from{' '} @@ -90,7 +91,7 @@ class ImportDashboardOverviewUnConnected extends PureComponent<Props, State> { </tr> </tbody> </table> - </div> + </Box> )} <Form onSubmit={this.onSubmit} diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton.tsx index 69a3e93643a98..14ece574fdb5f 100644 --- a/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton.tsx +++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Button, ModalsController, ButtonProps } from '@grafana/ui/src'; +import { t } from 'app/core/internationalization'; import { useDeletePublicDashboardMutation } from 'app/features/dashboard/api/publicDashboardApi'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; @@ -39,24 +40,30 @@ export const DeletePublicDashboardButton = ({ return ( <ModalsController> - {({ showModal, hideModal }) => ( - <Button - aria-label="Revoke public URL" - title="Revoke public URL" - onClick={() => - showModal(DeletePublicDashboardModal, { - dashboardTitle: publicDashboard.title, - onConfirm: () => onDeletePublicDashboardClick(publicDashboard, hideModal), - onDismiss: () => { - onDismiss ? onDismiss() : hideModal(); - }, - }) - } - {...rest} - > - {isLoading && loader ? loader : children} - </Button> - )} + {({ showModal, hideModal }) => { + const translatedRevocationButtonText = t( + 'public-dashboard-list.button.revoke-button-text', + 'Revoke public URL' + ); + return ( + <Button + aria-label={translatedRevocationButtonText} + title={translatedRevocationButtonText} + onClick={() => + showModal(DeletePublicDashboardModal, { + dashboardTitle: publicDashboard.title, + onConfirm: () => onDeletePublicDashboardClick(publicDashboard, hideModal), + onDismiss: () => { + onDismiss ? onDismiss() : hideModal(); + }, + }) + } + {...rest} + > + {isLoading && loader ? loader : children} + </Button> + ); + }} </ModalsController> ); }; diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal.tsx index bcdfc8ec6c8d7..cdcd2639fa7a7 100644 --- a/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal.tsx +++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data/src'; import { ConfirmModal, useStyles2 } from '@grafana/ui/src'; +import { t } from 'app/core/internationalization'; const Body = ({ title }: { title?: string }) => { const styles = useStyles2(getStyles); @@ -10,8 +11,14 @@ const Body = ({ title }: { title?: string }) => { return ( <p className={styles.description}> {title - ? 'Are you sure you want to revoke this URL? The dashboard will no longer be public.' - : 'Orphaned public dashboard will no longer be public.'} + ? t( + 'public-dashboard.delete-modal.revoke-nonorphaned-body-text', + 'Are you sure you want to revoke this URL? The dashboard will no longer be public.' + ) + : t( + 'public-dashboard.delete-modal.revoke-orphaned-body-text', + 'Orphaned public dashboard will no longer be public.' + )} </p> ); }; @@ -24,17 +31,20 @@ export const DeletePublicDashboardModal = ({ dashboardTitle?: string; onConfirm: () => void; onDismiss: () => void; -}) => ( - <ConfirmModal - isOpen - body={<Body title={dashboardTitle} />} - onConfirm={onConfirm} - onDismiss={onDismiss} - title="Revoke public URL" - icon="trash-alt" - confirmText="Revoke public URL" - /> -); +}) => { + const translatedRevocationModalText = t('public-dashboard.delete-modal.revoke-title', 'Revoke public URL'); + return ( + <ConfirmModal + isOpen + body={<Body title={dashboardTitle} />} + onConfirm={onConfirm} + onDismiss={onDismiss} + title={translatedRevocationModalText} + icon="trash-alt" + confirmText={translatedRevocationModalText} + /> + ); +}; const getStyles = (theme: GrafanaTheme2) => ({ title: css` diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.test.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.test.tsx index 5c69c16bc2cc7..f98ecb38cdab7 100644 --- a/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.test.tsx +++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.test.tsx @@ -1,8 +1,8 @@ +import 'whatwg-fetch'; import { render, screen, waitForElementToBeRemoved, within } from '@testing-library/react'; -import { rest } from 'msw'; +import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import React from 'react'; -import 'whatwg-fetch'; import { BrowserRouter } from 'react-router-dom'; import { TestProvider } from 'test/helpers/TestProvider'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; @@ -60,10 +60,13 @@ const paginationResponse: Omit<PublicDashboardListWithPaginationResponse, 'publi }; const server = setupServer( - rest.get('/api/dashboards/public-dashboards', (_, res, ctx) => - res(ctx.status(200), ctx.json({ ...paginationResponse, publicDashboards: publicDashboardListResponse })) + http.get('/api/dashboards/public-dashboards', () => + HttpResponse.json({ + ...paginationResponse, + publicDashboards: publicDashboardListResponse, + }) ), - rest.delete('/api/dashboards/uid/:dashboardUid/public-dashboards/:uid', (_, res, ctx) => res(ctx.status(200))) + http.delete('/api/dashboards/uid/:dashboardUid/public-dashboards/:uid', () => HttpResponse.json({})) ); jest.mock('@grafana/runtime', () => ({ @@ -122,8 +125,8 @@ describe('Show table', () => { }; server.use( - rest.get('/api/dashboards/public-dashboards', (req, res, ctx) => { - return res(ctx.status(200), ctx.json(emptyListRS)); + http.get('/api/dashboards/public-dashboards', () => { + return HttpResponse.json(emptyListRS); }) ); @@ -171,8 +174,8 @@ describe('Orphaned public dashboard', () => { publicDashboards: [...publicDashboardListResponse, ...orphanedDashboardListResponse], }; server.use( - rest.get('/api/dashboards/public-dashboards', (req, res, ctx) => { - return res(ctx.status(200), ctx.json(response)); + http.get('/api/dashboards/public-dashboards', () => { + return HttpResponse.json(response); }) ); jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true); diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx index 909b7c6f8b892..5486f77cf7547 100644 --- a/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx +++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx @@ -18,6 +18,7 @@ import { HorizontalGroup, } from '@grafana/ui/src'; import { Page } from 'app/core/components/Page/Page'; +import { Trans, t } from 'app/core/internationalization'; import { contextSrv } from 'app/core/services/context_srv'; import { useListPublicDashboardsQuery, @@ -57,6 +58,7 @@ const PublicDashboardCard = ({ pd }: { pd: PublicDashboardListResponse }) => { }; const CardActions = useMemo(() => (isMobile ? Card.Actions : Card.SecondaryActions), [isMobile]); + const translatedPauseSharingText = t('public-dashboard-list.toggle.pause-sharing-toggle-text', 'Pause sharing'); return ( <Card className={styles.card} href={!isOrphaned ? `/d/${pd.dashboardUid}` : undefined}> @@ -64,9 +66,17 @@ const PublicDashboardCard = ({ pd }: { pd: PublicDashboardListResponse }) => { {!isOrphaned ? ( <span>{pd.title}</span> ) : ( - <Tooltip content="The linked dashboard has already been deleted" placement="top"> + <Tooltip + content={t( + 'public-dashboard-list.dashboard-title.orphaned-tooltip', + 'The linked dashboard has already been deleted' + )} + placement="top" + > <div className={styles.orphanedTitle}> - <span>Orphaned public dashboard</span> + <Trans i18nKey="public-dashboard-list.dashboard-title.orphaned-title"> + <span>Orphaned public dashboard</span> + </Trans> <Icon name="info-circle" /> </div> </Tooltip> @@ -76,7 +86,7 @@ const PublicDashboardCard = ({ pd }: { pd: PublicDashboardListResponse }) => { <div className={styles.pauseSwitch}> <Switch value={!pd.isEnabled} - label="Pause sharing" + label={translatedPauseSharingText} disabled={isUpdateLoading} onChange={(e) => { reportInteraction('grafana_dashboards_public_enable_clicked', { @@ -86,7 +96,7 @@ const PublicDashboardCard = ({ pd }: { pd: PublicDashboardListResponse }) => { }} data-testid={selectors.ListItem.pauseSwitch} /> - <span>Pause sharing</span> + <span>{translatedPauseSharingText}</span> </div> <LinkButton disabled={isOrphaned} @@ -97,7 +107,7 @@ const PublicDashboardCard = ({ pd }: { pd: PublicDashboardListResponse }) => { color={theme.colors.warning.text} href={generatePublicDashboardUrl(pd.accessToken)} key="public-dashboard-url" - tooltip="View public dashboard" + tooltip={t('public-dashboard-list.button.view-button-tooltip', 'View public dashboard')} data-testid={selectors.ListItem.linkButton} /> <LinkButton @@ -108,7 +118,7 @@ const PublicDashboardCard = ({ pd }: { pd: PublicDashboardListResponse }) => { color={theme.colors.warning.text} href={generatePublicDashboardConfigUrl(pd.dashboardUid, pd.slug)} key="public-dashboard-config-url" - tooltip="Configure public dashboard" + tooltip={t('public-dashboard-list.button.config-button-tooltip', 'Configure public dashboard')} data-testid={selectors.ListItem.configButton} /> {hasWritePermissions && ( @@ -117,7 +127,7 @@ const PublicDashboardCard = ({ pd }: { pd: PublicDashboardListResponse }) => { icon="trash-alt" variant="secondary" publicDashboard={pd} - tooltip="Revoke public dashboard url" + tooltip={t('public-dashboard-list.button.revoke-button-tooltip', 'Revoke public dashboard URL')} loader={<Spinner />} data-testid={selectors.ListItem.trashcanButton} /> diff --git a/public/app/features/manage-dashboards/state/actions.test.ts b/public/app/features/manage-dashboards/state/actions.test.ts index 1b9f123473ffe..209bae2ec9e05 100644 --- a/public/app/features/manage-dashboards/state/actions.test.ts +++ b/public/app/features/manage-dashboards/state/actions.test.ts @@ -1,8 +1,8 @@ import { thunkTester } from 'test/core/thunk/thunkTester'; import { DataSourceInstanceSettings, ThresholdsMode } from '@grafana/data'; -import { BackendSrv, setBackendSrv } from '@grafana/runtime'; import { defaultDashboard, FieldColorModeId } from '@grafana/schema'; +import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; import { getLibraryPanel } from 'app/features/library-panels/state/api'; import { PanelModel } from '../../dashboard/state'; @@ -20,6 +20,15 @@ const mocks = { describe('importDashboard', () => { it('Should send data source uid', async () => { + // note: the actual action returned is more complicated + // but we don't really care about the return type in this test + // we're only testing that the correct data is passed to initiate + const mockAction = jest.fn().mockImplementation(() => ({ + type: 'foo', + })); + const importDashboardRtkQueryMock = jest + .spyOn(browseDashboardsAPI.endpoints.importDashboard, 'initiate') + .mockImplementation(mockAction); const form: ImportDashboardDTO = { title: 'Asda', uid: '12', @@ -40,17 +49,6 @@ describe('importDashboard', () => { }, }; - let postArgs: any; - - setBackendSrv({ - post: (url, args) => { - postArgs = args; - return Promise.resolve({ - importedUrl: '/my/dashboard', - }); - }, - } as BackendSrv); - await thunkTester({ importDashboard: { ...initialImportDashboardState, @@ -70,7 +68,7 @@ describe('importDashboard', () => { .givenThunk(importDashboard) .whenThunkIsDispatched(form); - expect(postArgs).toEqual({ + expect(importDashboardRtkQueryMock).toHaveBeenCalledWith({ dashboard: { title: 'Asda', uid: '12', diff --git a/public/app/features/manage-dashboards/state/actions.ts b/public/app/features/manage-dashboards/state/actions.ts index 15193dc2f2321..8d0cae653769f 100644 --- a/public/app/features/manage-dashboards/state/actions.ts +++ b/public/app/features/manage-dashboards/state/actions.ts @@ -1,7 +1,8 @@ -import { DataSourceInstanceSettings, locationUtil } from '@grafana/data'; -import { getBackendSrv, getDataSourceSrv, isFetchError, locationService } from '@grafana/runtime'; +import { DataSourceInstanceSettings } from '@grafana/data'; +import { getBackendSrv, getDataSourceSrv, isFetchError } from '@grafana/runtime'; import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { browseDashboardsAPI, ImportInputs } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { FolderInfo, PermissionLevelString, SearchQueryType, ThunkResult } from 'app/types'; @@ -200,7 +201,7 @@ export function importDashboard(importDashboardForm: ImportDashboardDTO): ThunkR const dashboard = getState().importDashboard.dashboard; const inputs = getState().importDashboard.inputs; - let inputsToPersist = [] as any[]; + const inputsToPersist: ImportInputs[] = []; importDashboardForm.dataSources?.forEach((dataSource: DataSourceInstanceSettings, index: number) => { const input = inputs.dataSources[index]; inputsToPersist.push({ @@ -221,18 +222,17 @@ export function importDashboard(importDashboardForm: ImportDashboardDTO): ThunkR }); }); - const result = await getBackendSrv().post('api/dashboards/import', { - // uid: if user changed it, take the new uid from importDashboardForm, - // else read it from original dashboard - // by default the uid input is disabled, onSubmit ignores values from disabled inputs - dashboard: { ...dashboard, title: importDashboardForm.title, uid: importDashboardForm.uid || dashboard.uid }, - overwrite: true, - inputs: inputsToPersist, - folderUid: importDashboardForm.folder.uid, - }); - - const dashboardUrl = locationUtil.stripBaseFromUrl(result.importedUrl); - locationService.push(dashboardUrl); + dispatch( + browseDashboardsAPI.endpoints.importDashboard.initiate({ + // uid: if user changed it, take the new uid from importDashboardForm, + // else read it from original dashboard + // by default the uid input is disabled, onSubmit ignores values from disabled inputs + dashboard: { ...dashboard, title: importDashboardForm.title, uid: importDashboardForm.uid || dashboard.uid }, + overwrite: true, + inputs: inputsToPersist, + folderUid: importDashboardForm.folder.uid, + }) + ); }; } diff --git a/public/app/features/org/OrgProfile.tsx b/public/app/features/org/OrgProfile.tsx index 8988d9cbd1c67..cf65e9c35326f 100644 --- a/public/app/features/org/OrgProfile.tsx +++ b/public/app/features/org/OrgProfile.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { Input, Field, FieldSet, Button, Form } from '@grafana/ui'; +import { Input, Field, FieldSet, Button } from '@grafana/ui'; +import { Form } from 'app/core/components/Form/Form'; import { contextSrv } from 'app/core/core'; import { AccessControlAction } from 'app/types'; diff --git a/public/app/features/org/UserInviteForm.tsx b/public/app/features/org/UserInviteForm.tsx index c2b14b4cb9bd5..30762cb8816be 100644 --- a/public/app/features/org/UserInviteForm.tsx +++ b/public/app/features/org/UserInviteForm.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Controller } from 'react-hook-form'; import { locationUtil, SelectableValue } from '@grafana/data'; import { locationService } from '@grafana/runtime'; @@ -8,9 +9,7 @@ import { Input, Switch, RadioButtonGroup, - Form, Field, - InputControl, FieldSet, Icon, TextLink, @@ -21,6 +20,7 @@ import { import { getConfig } from 'app/core/config'; import { OrgRole, useDispatch } from 'app/types'; +import { Form } from '../../core/components/Form/Form'; import { addInvitee } from '../invites/state/actions'; const tooltipMessage = ( @@ -97,7 +97,7 @@ export const UserInviteForm = () => { </Label> } > - <InputControl + <Controller render={({ field: { ref, ...field } }) => <RadioButtonGroup {...field} options={roles} />} control={control} name="role" diff --git a/public/app/features/panel/components/PanelDataErrorView.tsx b/public/app/features/panel/components/PanelDataErrorView.tsx index 93c7344d18459..b9cc3ef21b934 100644 --- a/public/app/features/panel/components/PanelDataErrorView.tsx +++ b/public/app/features/panel/components/PanelDataErrorView.tsx @@ -8,6 +8,7 @@ import { VisualizationSuggestionsBuilder, VisualizationSuggestion, } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { PanelDataErrorViewProps, locationService } from '@grafana/runtime'; import { usePanelContext, useStyles2 } from '@grafana/ui'; import { CardButton } from 'app/core/components/CardButton'; @@ -66,7 +67,9 @@ export function PanelDataErrorView(props: PanelDataErrorViewProps) { return ( <div className={styles.wrapper}> - <div className={styles.message}>{message}</div> + <div className={styles.message} data-testid={selectors.components.Panels.Panel.PanelDataErrorMessage}> + {message} + </div> {context.app === CoreApp.PanelEditor && dataSummary.hasData && panel && ( <div className={styles.actions}> {props.suggestions && ( diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index 0a64a096ab1ad..94e8a9aaa9fa0 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -24,39 +24,43 @@ export function VisualizationSuggestions({ searchQuery, onChange, data, panel }: const filteredSuggestions = filterSuggestionsBySearch(searchQuery, suggestions); return ( - <AutoSizer disableHeight style={{ width: '100%', height: '100%' }}> - {({ width }) => { - if (!width) { - return null; - } + // This div is needed in some places to make AutoSizer work + <div> + <AutoSizer disableHeight style={{ width: '100%', height: '100%' }}> + {({ width }) => { + if (!width) { + return null; + } - const columnCount = Math.floor(width / 170); - const spaceBetween = 8 * (columnCount! - 1); - const previewWidth = (width - spaceBetween) / columnCount!; + width = width - 1; + const columnCount = Math.floor(width / 200); + const spaceBetween = 8 * (columnCount! - 1); + const previewWidth = Math.floor((width - spaceBetween) / columnCount!); - return ( - <div> - <div className={styles.filterRow}> - <div className={styles.infoText}>Based on current data</div> + return ( + <div> + <div className={styles.filterRow}> + <div className={styles.infoText}>Based on current data</div> + </div> + <div className={styles.grid} style={{ gridTemplateColumns: `repeat(auto-fill, ${previewWidth}px)` }}> + {filteredSuggestions.map((suggestion, index) => ( + <VisualizationSuggestionCard + key={index} + data={data!} + suggestion={suggestion} + onChange={onChange} + width={previewWidth - 1} + /> + ))} + {searchQuery && filteredSuggestions.length === 0 && ( + <div className={styles.infoText}>No results matched your query</div> + )} + </div> </div> - <div className={styles.grid} style={{ gridTemplateColumns: `repeat(auto-fill, ${previewWidth - 1}px)` }}> - {filteredSuggestions.map((suggestion, index) => ( - <VisualizationSuggestionCard - key={index} - data={data!} - suggestion={suggestion} - onChange={onChange} - width={previewWidth} - /> - ))} - {searchQuery && filteredSuggestions.length === 0 && ( - <div className={styles.infoText}>No results matched your query</div> - )} - </div> - </div> - ); - }} - </AutoSizer> + ); + }} + </AutoSizer> + </div> ); } diff --git a/public/app/features/panel/panellinks/linkSuppliers.ts b/public/app/features/panel/panellinks/linkSuppliers.ts index 0fa5cf47fb092..c2810f7dabe6b 100644 --- a/public/app/features/panel/panellinks/linkSuppliers.ts +++ b/public/app/features/panel/panellinks/linkSuppliers.ts @@ -11,7 +11,9 @@ import { ScopedVar, ScopedVars, } from '@grafana/data'; +import { VizPanel } from '@grafana/scenes'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; +import { dashboardSceneGraph } from 'app/features/dashboard-scene/utils/dashboardSceneGraph'; import { getLinkSrv } from './link_srv'; @@ -157,3 +159,22 @@ export const getPanelLinksSupplier = ( }, }; }; + +export const getScenePanelLinksSupplier = ( + panel: VizPanel, + replaceVariables: InterpolateFunction +): LinkModelSupplier<VizPanel> | undefined => { + const links = dashboardSceneGraph.getPanelLinks(panel)?.state.rawLinks; + + if (!links || links.length === 0) { + return undefined; + } + + return { + getLinks: () => { + return links.map((link) => { + return getLinkSrv().getDataLinkUIModel(link, replaceVariables, panel); + }); + }, + }; +}; diff --git a/public/app/features/panel/panellinks/specs/link_srv.test.ts b/public/app/features/panel/panellinks/specs/link_srv.test.ts index 7075fe98f99e0..cd5dd3b11ef19 100644 --- a/public/app/features/panel/panellinks/specs/link_srv.test.ts +++ b/public/app/features/panel/panellinks/specs/link_srv.test.ts @@ -33,7 +33,7 @@ describe('linkSrv', () => { const timeSrv = new TimeSrv({} as ContextSrv); timeSrv.init(_dashboard); timeSrv.setTime({ from: 'now-1h', to: 'now' }); - _dashboard.refresh = false; + _dashboard.refresh = undefined; setTimeSrv(timeSrv); templateSrv = initTemplateSrv('key', [ diff --git a/public/app/features/panel/state/actions.ts b/public/app/features/panel/state/actions.ts index 2e01dd5a73290..c326cf812ae56 100644 --- a/public/app/features/panel/state/actions.ts +++ b/public/app/features/panel/state/actions.ts @@ -147,15 +147,16 @@ export function loadLibraryPanelAndUpdate(panel: PanelModel): ThunkResult<void> try { const libPanel = await getLibraryPanel(uid, true); panel.initLibraryPanel(libPanel); - await dispatch(initPanelState(panel)); - const dashboard = getStore().dashboard.getModel(); + const dashboard = getStore().dashboard.getModel(); if (panel.repeat && dashboard) { const panelIndex = dashboard.panels.findIndex((p) => p.id === panel.id); dashboard.repeatPanel(panel, panelIndex); dashboard.sortPanelsByGridPos(); dashboard.events.publish(new DashboardPanelsChangedEvent()); } + + await dispatch(initPanelState(panel)); } catch (ex) { console.log('ERROR: ', ex); dispatch( diff --git a/public/app/features/panel/state/getAllSuggestions.ts b/public/app/features/panel/state/getAllSuggestions.ts index a9d8efdb85fcf..873e2166da825 100644 --- a/public/app/features/panel/state/getAllSuggestions.ts +++ b/public/app/features/panel/state/getAllSuggestions.ts @@ -21,6 +21,8 @@ export const panelsToCheckFirst = [ 'logs', 'candlestick', 'flamegraph', + 'traces', + 'nodeGraph', ]; export async function getAllSuggestions(data?: PanelData, panel?: PanelModel): Promise<VisualizationSuggestion[]> { diff --git a/public/app/features/playlist/PlaylistForm.tsx b/public/app/features/playlist/PlaylistForm.tsx index faa468eb144be..cb6d6d7551946 100644 --- a/public/app/features/playlist/PlaylistForm.tsx +++ b/public/app/features/playlist/PlaylistForm.tsx @@ -2,7 +2,8 @@ import React, { useMemo, useState } from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { config } from '@grafana/runtime'; -import { Button, Field, FieldSet, Form, HorizontalGroup, Input, LinkButton } from '@grafana/ui'; +import { Button, Field, FieldSet, Input, LinkButton, Stack } from '@grafana/ui'; +import { Form } from 'app/core/components/Form/Form'; import { DashboardPicker } from 'app/core/components/Select/DashboardPicker'; import { TagFilter } from 'app/core/components/TagFilter/TagFilter'; import { Trans, t } from 'app/core/internationalization'; @@ -33,72 +34,70 @@ export const PlaylistForm = ({ onSubmit, playlist }: Props) => { }; return ( - <div> - <Form onSubmit={doSubmit} validateOn={'onBlur'}> - {({ register, errors }) => { - const isDisabled = items.length === 0 || Object.keys(errors).length > 0; - return ( - <> - <Field - label={t('playlist-edit.form.name-label', 'Name')} - invalid={!!errors.name} - error={errors?.name?.message} - > - <Input - type="text" - {...register('name', { required: t('playlist-edit.form.name-required', 'Name is required') })} - placeholder={t('playlist-edit.form.name-placeholder', 'Name')} - defaultValue={name} - aria-label={selectors.pages.PlaylistForm.name} - /> - </Field> - <Field - label={t('playlist-edit.form.interval-label', 'Interval')} - invalid={!!errors.interval} - error={errors?.interval?.message} - > - <Input - type="text" - {...register('interval', { - required: t('playlist-edit.form.interval-required', 'Interval is required'), - })} - placeholder={t('playlist-edit.form.interval-placeholder', '5m')} - defaultValue={interval ?? '5m'} - aria-label={selectors.pages.PlaylistForm.interval} - /> - </Field> + <Form onSubmit={doSubmit} validateOn={'onBlur'}> + {({ register, errors }) => { + const isDisabled = items.length === 0 || Object.keys(errors).length > 0; + return ( + <> + <Field + label={t('playlist-edit.form.name-label', 'Name')} + invalid={!!errors.name} + error={errors?.name?.message} + > + <Input + type="text" + {...register('name', { required: t('playlist-edit.form.name-required', 'Name is required') })} + placeholder={t('playlist-edit.form.name-placeholder', 'Name')} + defaultValue={name} + aria-label={selectors.pages.PlaylistForm.name} + /> + </Field> + <Field + label={t('playlist-edit.form.interval-label', 'Interval')} + invalid={!!errors.interval} + error={errors?.interval?.message} + > + <Input + type="text" + {...register('interval', { + required: t('playlist-edit.form.interval-required', 'Interval is required'), + })} + placeholder={t('playlist-edit.form.interval-placeholder', '5m')} + defaultValue={interval ?? '5m'} + aria-label={selectors.pages.PlaylistForm.interval} + /> + </Field> - <PlaylistTable items={items} deleteItem={deleteItem} moveItem={moveItem} /> + <PlaylistTable items={items} deleteItem={deleteItem} moveItem={moveItem} /> - <FieldSet label={t('playlist-edit.form.heading', 'Add dashboards')}> - <Field label={t('playlist-edit.form.add-title-label', 'Add by title')}> - <DashboardPicker id="dashboard-picker" onChange={addByUID} key={items.length} /> - </Field> + <FieldSet label={t('playlist-edit.form.heading', 'Add dashboards')}> + <Field label={t('playlist-edit.form.add-title-label', 'Add by title')}> + <DashboardPicker id="dashboard-picker" onChange={addByUID} key={items.length} /> + </Field> - <Field label={t('playlist-edit.form.add-tag-label', 'Add by tag')}> - <TagFilter - isClearable - tags={[]} - hideValues - tagOptions={tagOptions} - onChange={addByTag} - placeholder={t('playlist-edit.form.add-tag-placeholder', 'Select a tag')} - /> - </Field> - </FieldSet> + <Field label={t('playlist-edit.form.add-tag-label', 'Add by tag')}> + <TagFilter + isClearable + tags={[]} + hideValues + tagOptions={tagOptions} + onChange={addByTag} + placeholder={t('playlist-edit.form.add-tag-placeholder', 'Select a tag')} + /> + </Field> + </FieldSet> - <HorizontalGroup> - <Button type="submit" variant="primary" disabled={isDisabled} icon={saving ? 'spinner' : undefined}> - <Trans i18nKey="playlist-edit.form.save">Save</Trans> - </Button> - <LinkButton variant="secondary" href={`${config.appSubUrl}/playlists`}> - <Trans i18nKey="playlist-edit.form.cancel">Cancel</Trans> - </LinkButton> - </HorizontalGroup> - </> - ); - }} - </Form> - </div> + <Stack> + <Button type="submit" variant="primary" disabled={isDisabled} icon={saving ? 'spinner' : undefined}> + <Trans i18nKey="playlist-edit.form.save">Save</Trans> + </Button> + <LinkButton variant="secondary" href={`${config.appSubUrl}/playlists`}> + <Trans i18nKey="playlist-edit.form.cancel">Cancel</Trans> + </LinkButton> + </Stack> + </> + ); + }} + </Form> ); }; diff --git a/public/app/features/playlist/PlaylistSrv.test.ts b/public/app/features/playlist/PlaylistSrv.test.ts index b5ccdc6d411b4..68d279d9d1714 100644 --- a/public/app/features/playlist/PlaylistSrv.test.ts +++ b/public/app/features/playlist/PlaylistSrv.test.ts @@ -124,7 +124,7 @@ describe('PlaylistSrv', () => { locationService.push('/datasources'); - expect(srv.isPlaying).toBe(false); + expect(srv.state.isPlaying).toBe(false); }); it('storeUpdated should not stop playlist when navigating to next dashboard', async () => { @@ -137,6 +137,6 @@ describe('PlaylistSrv', () => { // eslint-disable-next-line expect((srv as any).validPlaylistUrl).toBe('/url/to/bbb'); - expect(srv.isPlaying).toBe(true); + expect(srv.state.isPlaying).toBe(true); }); }); diff --git a/public/app/features/playlist/PlaylistSrv.ts b/public/app/features/playlist/PlaylistSrv.ts index b74b620ab3d0f..8fd715dd8f4a4 100644 --- a/public/app/features/playlist/PlaylistSrv.ts +++ b/public/app/features/playlist/PlaylistSrv.ts @@ -3,6 +3,7 @@ import { pickBy } from 'lodash'; import { locationUtil, urlUtil, rangeUtil } from '@grafana/data'; import { locationService } from '@grafana/runtime'; +import { StateManagerBase } from 'app/core/services/StateManagerBase'; import { getPlaylistAPI, loadDashboards } from './api'; import { PlaylistAPI } from './types'; @@ -13,7 +14,11 @@ export const queryParamsToPreserve: { [key: string]: boolean } = { orgId: true, }; -export class PlaylistSrv { +export interface PlaylistSrvState { + isPlaying: boolean; +} + +export class PlaylistSrv extends StateManagerBase<PlaylistSrvState> { private nextTimeoutId: ReturnType<typeof setTimeout> | undefined; private urls: string[] = []; // the URLs we need to load private index = 0; @@ -24,9 +29,9 @@ export class PlaylistSrv { private locationListenerUnsub?: () => void; private api: PlaylistAPI; - isPlaying = false; + public constructor() { + super({ isPlaying: false }); - constructor() { this.locationUpdated = this.locationUpdated.bind(this); this.api = getPlaylistAPI(); } @@ -76,17 +81,19 @@ export class PlaylistSrv { this.startUrl = window.location.href; this.index = 0; - this.isPlaying = true; + + this.setState({ isPlaying: true }); // setup location tracking this.locationListenerUnsub = locationService.getHistory().listen(this.locationUpdated); - const urls: string[] = []; + let playlist = await this.api.getPlaylist(playlistUid); if (!playlist.items?.length) { // alert return; } + this.interval = rangeUtil.intervalToMs(playlist.interval); const items = await loadDashboards(playlist.items); @@ -102,19 +109,21 @@ export class PlaylistSrv { // alert... not found, etc return; } + this.urls = urls; - this.isPlaying = true; + this.setState({ isPlaying: true }); this.next(); return; } stop() { - if (!this.isPlaying) { + if (!this.state.isPlaying) { return; } this.index = 0; - this.isPlaying = false; + + this.setState({ isPlaying: false }); if (this.locationListenerUnsub) { this.locationListenerUnsub(); diff --git a/public/app/features/plugins/admin/__mocks__/catalogPlugin.mock.ts b/public/app/features/plugins/admin/__mocks__/catalogPlugin.mock.ts index e4c8ffb510fdd..e4c0bf11c3d85 100644 --- a/public/app/features/plugins/admin/__mocks__/catalogPlugin.mock.ts +++ b/public/app/features/plugins/admin/__mocks__/catalogPlugin.mock.ts @@ -11,6 +11,7 @@ export default { small: 'https://grafana.com/api/plugins/alexanderzobnin-zabbix-app/versions/4.2.2/logos/small', large: 'https://grafana.com/api/plugins/alexanderzobnin-zabbix-app/versions/4.2.2/logos/large', }, + keywords: ['zabbix', 'monitoring', 'dashboard'], }, isCore: false, isDev: false, diff --git a/public/app/features/plugins/admin/__mocks__/remotePlugin.mock.ts b/public/app/features/plugins/admin/__mocks__/remotePlugin.mock.ts index 1ea0ed50654e2..d716ae4df5b8a 100644 --- a/public/app/features/plugins/admin/__mocks__/remotePlugin.mock.ts +++ b/public/app/features/plugins/admin/__mocks__/remotePlugin.mock.ts @@ -9,6 +9,7 @@ export default { downloads: 33645089, featured: 180, id: 74, + keywords: ['zabbix', 'monitoring', 'dashboard'], typeId: 1, typeName: 'Application', internal: false, diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index f90d1c581b944..a86e32d6a1a28 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -4,7 +4,15 @@ import { accessControlQueryParam } from 'app/core/utils/accessControl'; import { API_ROOT, GCOM_API_ROOT, INSTANCE_API_ROOT } from './constants'; import { isLocalPluginVisibleByConfig, isRemotePluginVisibleByConfig } from './helpers'; -import { LocalPlugin, RemotePlugin, CatalogPluginDetails, Version, PluginVersion, InstancePlugin } from './types'; +import { + LocalPlugin, + RemotePlugin, + CatalogPluginDetails, + Version, + PluginVersion, + InstancePlugin, + ProvisionedPlugin, +} from './types'; export async function getPluginDetails(id: string): Promise<CatalogPluginDetails> { const remote = await getRemotePlugin(id); @@ -125,6 +133,14 @@ export async function getInstancePlugins(): Promise<InstancePlugin[]> { return instancePlugins; } +export async function getProvisionedPlugins(): Promise<ProvisionedPlugin[]> { + const { items: provisionedPlugins }: { items: Array<{ type: string }> } = await getBackendSrv().get( + `${INSTANCE_API_ROOT}/provisioned-plugins` + ); + + return provisionedPlugins.map((plugin) => ({ slug: plugin.type })); +} + export async function installPlugin(id: string) { // This will install the latest compatible version based on the logic // on the backend. diff --git a/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithApp.tsx b/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithApp.tsx index ea95689b71f1b..92a8356b697f8 100644 --- a/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithApp.tsx +++ b/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithApp.tsx @@ -19,9 +19,8 @@ export function GetStartedWithApp({ plugin }: Props): React.ReactElement | null if (!pluginConfig) { return null; } - // Enforce RBAC - if (!contextSrv.hasPermissionInMetadata(AccessControlAction.PluginsWrite, plugin)) { + if (!contextSrv.hasPermission(AccessControlAction.PluginsWrite)) { return null; } diff --git a/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithDataSource.test.tsx b/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithDataSource.test.tsx new file mode 100644 index 0000000000000..512ff968ea05c --- /dev/null +++ b/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithDataSource.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; + +import { PluginSignatureStatus } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { ContextSrv, setContextSrv } from 'app/core/services/context_srv'; +import { AccessControlAction } from 'app/types'; + +import { CatalogPlugin } from '../../types'; + +import { GetStartedWithDataSource } from './GetStartedWithDataSource'; + +const plugin: CatalogPlugin = { + description: 'The test plugin', + downloads: 5, + id: 'test-plugin', + info: { + logos: { small: '', large: '' }, + keywords: ['test', 'plugin'], + }, + name: 'Testing Plugin', + orgName: 'Test', + popularity: 0, + signature: PluginSignatureStatus.valid, + publishedAt: '2020-09-01', + updatedAt: '2021-06-28', + hasUpdate: false, + isInstalled: false, + isCore: false, + isDev: false, + isEnterprise: false, + isDisabled: false, + isDeprecated: false, + isPublished: true, +}; + +describe('GetStartedWithDataSource', () => { + const oldFeatureTogglesManagedPluginsInstall = config.featureToggles.managedPluginsInstall; + const oldPluginAdminExternalManageEnabled = config.pluginAdminExternalManageEnabled; + + config.featureToggles.managedPluginsInstall = true; + config.pluginAdminExternalManageEnabled = true; + + const contextSrv = new ContextSrv(); + contextSrv.user.permissions = { + [AccessControlAction.DataSourcesCreate]: true, + [AccessControlAction.DataSourcesWrite]: true, + }; + setContextSrv(contextSrv); + + afterAll(() => { + config.featureToggles.managedPluginsInstall = oldFeatureTogglesManagedPluginsInstall; + config.pluginAdminExternalManageEnabled = oldPluginAdminExternalManageEnabled; + }); + + it('should disable button when managedPluginsInstall and pluginAdminExternalManaged are enabled, but plugin.isFullyInstalled is false', () => { + render( + <TestProvider> + <GetStartedWithDataSource plugin={{ ...plugin, isFullyInstalled: false }} /> + </TestProvider> + ); + + const el = screen.getByRole('button', { hidden: true }); + expect(el).toHaveTextContent(/Add new data source/i); + expect(el).toBeDisabled(); + }); + + it('should disable button when managedPluginsInstall and pluginAdminExternalManaged are enabled, but plugin.isFullyInstalled is true', () => { + render( + <TestProvider> + <GetStartedWithDataSource plugin={{ ...plugin, isFullyInstalled: true }} /> + </TestProvider> + ); + + const el = screen.getByRole('button', { hidden: true }); + expect(el).toHaveTextContent(/Add new data source/i); + expect(el).toBeEnabled(); + }); +}); diff --git a/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithDataSource.tsx b/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithDataSource.tsx index 4650a2c885dae..aad3e99d2188c 100644 --- a/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithDataSource.tsx +++ b/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithDataSource.tsx @@ -3,7 +3,6 @@ import React, { useCallback } from 'react'; import { DataSourcePluginMeta } from '@grafana/data'; import { config } from '@grafana/runtime'; import { Button } from '@grafana/ui'; -import configCore from 'app/core/config'; import { useDataSourcesRoutes, addDataSource } from 'app/features/datasources/state'; import { useDispatch } from 'app/types'; @@ -31,9 +30,7 @@ export function GetStartedWithDataSource({ plugin }: Props): React.ReactElement } const disabledButton = - configCore.featureToggles.managedPluginsInstall && - config.pluginAdminExternalManageEnabled && - !plugin.isFullyInstalled; + config.featureToggles.managedPluginsInstall && config.pluginAdminExternalManageEnabled && !plugin.isFullyInstalled; return ( <Button diff --git a/public/app/features/plugins/admin/components/InstallControls/InstallControlsButton.test.tsx b/public/app/features/plugins/admin/components/InstallControls/InstallControlsButton.test.tsx index d06268248d717..1da1da10d29e8 100644 --- a/public/app/features/plugins/admin/components/InstallControls/InstallControlsButton.test.tsx +++ b/public/app/features/plugins/admin/components/InstallControls/InstallControlsButton.test.tsx @@ -4,7 +4,9 @@ import { TestProvider } from 'test/helpers/TestProvider'; import { PluginSignatureStatus } from '@grafana/data'; import { config } from '@grafana/runtime'; +import { configureStore } from 'app/store/configureStore'; +import { getPluginsStateMock } from '../../__mocks__'; import { CatalogPlugin, PluginStatus } from '../../types'; import { InstallControlsButton } from './InstallControlsButton'; @@ -15,6 +17,7 @@ const plugin: CatalogPlugin = { id: 'test-plugin', info: { logos: { small: '', large: '' }, + keywords: ['test', 'plugin'], }, name: 'Testing Plugin', orgName: 'Test', @@ -90,4 +93,154 @@ describe('InstallControlsButton', () => { ); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); + + describe('update button on prem', () => { + const store = configureStore({ + plugins: getPluginsStateMock([]), + }); + + it('should be disabled when is Installing', () => { + store.dispatch({ type: 'plugins/install/pending' }); + render( + <TestProvider store={store}> + <InstallControlsButton plugin={{ ...plugin }} pluginStatus={PluginStatus.UPDATE} /> + </TestProvider> + ); + const button = screen.getByText('Updating').closest('button'); + expect(button).toBeDisabled(); + }); + + it('should be enabled when it is not Installing', () => { + store.dispatch({ type: 'plugins/install/fulfilled', payload: { id: '', changes: {} } }); + render( + <TestProvider store={store}> + <InstallControlsButton plugin={{ ...plugin }} pluginStatus={PluginStatus.UPDATE} /> + </TestProvider> + ); + const button = screen.getByText('Update').closest('button'); + expect(button).toBeEnabled(); + }); + }); + + describe('update button on managed instance', () => { + const oldFeatureTogglesManagedPluginsInstall = config.featureToggles.managedPluginsInstall; + const oldPluginAdminExternalManageEnabled = config.pluginAdminExternalManageEnabled; + + beforeAll(() => { + config.featureToggles.managedPluginsInstall = true; + config.pluginAdminExternalManageEnabled = true; + }); + + afterAll(() => { + config.featureToggles.managedPluginsInstall = oldFeatureTogglesManagedPluginsInstall; + config.pluginAdminExternalManageEnabled = oldPluginAdminExternalManageEnabled; + }); + + const store = configureStore({ + plugins: getPluginsStateMock([]), + }); + + it('should be disabled when isInstalling=false but isUpdatingFromInstance=true', () => { + store.dispatch({ type: 'plugins/install/fulfilled', payload: { id: '', changes: {} } }); + render( + <TestProvider store={store}> + <InstallControlsButton + plugin={{ ...plugin, isUpdatingFromInstance: true }} + pluginStatus={PluginStatus.UPDATE} + /> + </TestProvider> + ); + const button = screen.getByText('Update').closest('button'); + expect(button).toBeDisabled(); + }); + + it('should be enabled when isInstalling=false and isUpdatingFromInstance=false', () => { + store.dispatch({ type: 'plugins/install/fulfilled', payload: { id: '', changes: {} } }); + render( + <TestProvider store={store}> + <InstallControlsButton + plugin={{ ...plugin, isUpdatingFromInstance: false }} + pluginStatus={PluginStatus.UPDATE} + /> + </TestProvider> + ); + const button = screen.getByText('Update').closest('button'); + expect(button).toBeEnabled(); + }); + }); + + describe('uninstall button on prem', () => { + const store = configureStore({ + plugins: getPluginsStateMock([]), + }); + + it('should be disabled when is Installing', () => { + store.dispatch({ type: 'plugins/uninstall/pending' }); + render( + <TestProvider store={store}> + <InstallControlsButton plugin={{ ...plugin }} pluginStatus={PluginStatus.UNINSTALL} /> + </TestProvider> + ); + const button = screen.getByText('Uninstalling').closest('button'); + expect(button).toBeDisabled(); + }); + + it('should be enabled when it is not Installing', () => { + store.dispatch({ type: 'plugins/uninstall/fulfilled', payload: { id: '', changes: {} } }); + render( + <TestProvider store={store}> + <InstallControlsButton plugin={{ ...plugin }} pluginStatus={PluginStatus.UNINSTALL} /> + </TestProvider> + ); + const button = screen.getByText('Uninstall').closest('button'); + expect(button).toBeEnabled(); + }); + }); + + describe('uninstall button on managed instance', () => { + const oldFeatureTogglesManagedPluginsInstall = config.featureToggles.managedPluginsInstall; + const oldPluginAdminExternalManageEnabled = config.pluginAdminExternalManageEnabled; + + beforeAll(() => { + config.featureToggles.managedPluginsInstall = true; + config.pluginAdminExternalManageEnabled = true; + }); + + afterAll(() => { + config.featureToggles.managedPluginsInstall = oldFeatureTogglesManagedPluginsInstall; + config.pluginAdminExternalManageEnabled = oldPluginAdminExternalManageEnabled; + }); + + const store = configureStore({ + plugins: getPluginsStateMock([]), + }); + + it('should be disabled when isInstalling=false but isUninstallingFromInstance=true', () => { + store.dispatch({ type: 'plugins/uninstall/fulfilled', payload: { id: '', changes: {} } }); + render( + <TestProvider store={store}> + <InstallControlsButton + plugin={{ ...plugin, isUninstallingFromInstance: true }} + pluginStatus={PluginStatus.UNINSTALL} + /> + </TestProvider> + ); + const button = screen.getByText('Uninstall').closest('button'); + expect(button).toBeDisabled(); + }); + + it('should be enabled when isInstalling=false and isUninstallingFromInstance=false', () => { + store.dispatch({ type: 'plugins/uninstall/fulfilled', payload: { id: '', changes: {} } }); + render( + <TestProvider store={store}> + <InstallControlsButton + plugin={{ ...plugin, isUninstallingFromInstance: false }} + pluginStatus={PluginStatus.UNINSTALL} + /> + </TestProvider> + ); + const button = screen.getByText('Uninstall').closest('button'); + expect(button).toBeEnabled(); + }); + }); }); diff --git a/public/app/features/plugins/admin/components/InstallControls/InstallControlsButton.tsx b/public/app/features/plugins/admin/components/InstallControls/InstallControlsButton.tsx index 74689dabd8489..5ea2afbc7db28 100644 --- a/public/app/features/plugins/admin/components/InstallControls/InstallControlsButton.tsx +++ b/public/app/features/plugins/admin/components/InstallControls/InstallControlsButton.tsx @@ -114,6 +114,11 @@ export function InstallControlsButton({ }; if (pluginStatus === PluginStatus.UNINSTALL) { + const disableUninstall = + config.pluginAdminExternalManageEnabled && configCore.featureToggles.managedPluginsInstall + ? plugin.isUninstallingFromInstance + : isUninstalling; + return ( <> <ConfirmModal @@ -126,7 +131,7 @@ export function InstallControlsButton({ onDismiss={hideConfirmModal} /> <HorizontalGroup align="flex-start" width="auto" height="auto"> - <Button variant="destructive" disabled={isUninstalling} onClick={showConfirmModal}> + <Button variant="destructive" disabled={disableUninstall} onClick={showConfirmModal}> {uninstallBtnText} </Button> </HorizontalGroup> @@ -140,9 +145,14 @@ export function InstallControlsButton({ } if (pluginStatus === PluginStatus.UPDATE) { + const disableUpdate = + config.pluginAdminExternalManageEnabled && configCore.featureToggles.managedPluginsInstall + ? plugin.isUpdatingFromInstance + : isInstalling; + return ( <HorizontalGroup align="flex-start" width="auto" height="auto"> - <Button disabled={isInstalling} onClick={onUpdate}> + <Button disabled={disableUpdate} onClick={onUpdate}> {isInstalling ? 'Updating' : 'Update'} </Button> <Button variant="destructive" disabled={isUninstalling} onClick={onUninstall}> diff --git a/public/app/features/plugins/admin/components/PluginDashboards.tsx b/public/app/features/plugins/admin/components/PluginDashboards.tsx index 10e39ef7bd347..1cd035cebd137 100644 --- a/public/app/features/plugins/admin/components/PluginDashboards.tsx +++ b/public/app/features/plugins/admin/components/PluginDashboards.tsx @@ -102,10 +102,6 @@ export class PluginDashboards extends PureComponent<Props, State> { return <div>No dashboards are included with this plugin</div>; } - return ( - <div className="gf-form-group"> - <DashboardsTable dashboards={dashboards} onImport={this.import} onRemove={this.remove} /> - </div> - ); + return <DashboardsTable dashboards={dashboards} onImport={this.import} onRemove={this.remove} />; } } diff --git a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx index 27dd07748c017..54420db174e6f 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx @@ -131,55 +131,44 @@ export function PluginDetailsBody({ plugin, queryParams, pageId }: Props): JSX.E } export const getStyles = (theme: GrafanaTheme2) => ({ - container: css``, - readme: css` - & img { - max-width: 100%; - } - - h1, - h2, - h3 { - margin-top: ${theme.spacing(3)}; - margin-bottom: ${theme.spacing(2)}; - } - - *:first-child { - margin-top: 0; - } - - li { - margin-left: ${theme.spacing(2)}; - & > p { - margin: ${theme.spacing()} 0; - } - } - - a { - color: ${theme.colors.text.link}; - - &:hover { - color: ${theme.colors.text.link}; - text-decoration: underline; - } - } - - table { - table-layout: fixed; - width: 100%; - - td, - th { - overflow-x: auto; - padding: ${theme.spacing(0.5)} ${theme.spacing(1)}; - } - - table, - th, - td { - border: 1px solid ${theme.colors.border.medium}; - border-collapse: collapse; - } - } - `, + container: css({ + height: '100%', + }), + readme: css({ + '& img': { + maxWidth: '100%', + }, + 'h1, h2, h3': { + marginTop: theme.spacing(3), + marginBottom: theme.spacing(2), + }, + '*:first-child': { + marginTop: 0, + }, + li: { + marginLeft: theme.spacing(2), + '& > p': { + margin: theme.spacing(1, 0), + }, + }, + a: { + color: theme.colors.text.link, + '&:hover': { + color: theme.colors.text.link, + textDecoration: 'underline', + }, + }, + table: { + tableLayout: 'fixed', + width: '100%', + 'td, th': { + overflowX: 'auto', + padding: theme.spacing(0.5, 1), + }, + 'table, th, td': { + border: `1px solid ${theme.colors.border.medium}`, + borderCollapse: 'collapse', + }, + }, + }), }); diff --git a/public/app/features/plugins/admin/components/PluginList.test.tsx b/public/app/features/plugins/admin/components/PluginList.test.tsx index 45f153a71a442..72218c02660b2 100644 --- a/public/app/features/plugins/admin/components/PluginList.test.tsx +++ b/public/app/features/plugins/admin/components/PluginList.test.tsx @@ -32,6 +32,7 @@ const getMockPlugin = (id: string): CatalogPlugin => { small: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/small', large: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/large', }, + keywords: ['test', 'plugin'], }, name: 'Testing Plugin', orgName: 'Test', diff --git a/public/app/features/plugins/admin/components/PluginListItem.test.tsx b/public/app/features/plugins/admin/components/PluginListItem.test.tsx index dbb5bbb50a808..c4d42d615abe4 100644 --- a/public/app/features/plugins/admin/components/PluginListItem.test.tsx +++ b/public/app/features/plugins/admin/components/PluginListItem.test.tsx @@ -42,6 +42,7 @@ describe('PluginListItem', () => { small: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/small', large: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/large', }, + keywords: ['test', 'plugin'], }, name: 'Testing Plugin', orgName: 'Test', diff --git a/public/app/features/plugins/admin/components/PluginListItem.tsx b/public/app/features/plugins/admin/components/PluginListItem.tsx index 8dbfdd21b6adc..a512b23be16b9 100644 --- a/public/app/features/plugins/admin/components/PluginListItem.tsx +++ b/public/app/features/plugins/admin/components/PluginListItem.tsx @@ -3,6 +3,7 @@ import React from 'react'; import Skeleton from 'react-loading-skeleton'; import { GrafanaTheme2 } from '@grafana/data'; +import { locationService, reportInteraction } from '@grafana/runtime'; import { Badge, Icon, Stack, useStyles2 } from '@grafana/ui'; import { SkeletonComponent, attachSkeleton } from '@grafana/ui/src/unstable'; @@ -23,8 +24,17 @@ function PluginListItemComponent({ plugin, pathName, displayMode = PluginListDis const styles = useStyles2(getStyles); const isList = displayMode === PluginListDisplayMode.List; + const reportUserClickInteraction = () => { + if (locationService.getSearchObject()?.q) { + reportInteraction('plugins_search_user_click', {}); + } + }; return ( - <a href={`${pathName}/${plugin.id}`} className={cx(styles.container, { [styles.list]: isList })}> + <a + href={`${pathName}/${plugin.id}`} + className={cx(styles.container, { [styles.list]: isList })} + onClick={reportUserClickInteraction} + > <PluginLogo src={plugin.info.logos.small} className={styles.pluginLogo} height={LOGO_SIZE} alt="" /> <h2 className={cx(styles.name, 'plugin-name')}>{plugin.name}</h2> <div className={cx(styles.content, 'plugin-content')}> diff --git a/public/app/features/plugins/admin/components/PluginListItemBadges.test.tsx b/public/app/features/plugins/admin/components/PluginListItemBadges.test.tsx index e6906d728741b..f1a86adc69142 100644 --- a/public/app/features/plugins/admin/components/PluginListItemBadges.test.tsx +++ b/public/app/features/plugins/admin/components/PluginListItemBadges.test.tsx @@ -18,6 +18,7 @@ describe('PluginListItemBadges', () => { small: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/small', large: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/large', }, + keywords: ['test', 'plugin'], }, name: 'Testing Plugin', orgName: 'Test', diff --git a/public/app/features/plugins/admin/components/PluginUsage.tsx b/public/app/features/plugins/admin/components/PluginUsage.tsx index 1b82008378213..692d1fe3b2330 100644 --- a/public/app/features/plugins/admin/components/PluginUsage.tsx +++ b/public/app/features/plugins/admin/components/PluginUsage.tsx @@ -4,7 +4,7 @@ import { useAsync } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; import { of } from 'rxjs'; -import { GrafanaTheme2, PluginMeta } from '@grafana/data'; +import { GrafanaTheme2, PluginMeta, PluginType } from '@grafana/data'; import { config } from '@grafana/runtime'; import { Alert, Spinner, useStyles2 } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; @@ -19,6 +19,13 @@ export function PluginUsage({ plugin }: Props) { const styles = useStyles2(getStyles); const searchQuery = useMemo<SearchQuery>(() => { + if (plugin.type === PluginType.datasource) { + return { + query: '*', + ds_type: plugin.id, + kind: ['dashboard'], + }; + } return { query: '*', panel_type: plugin.id, diff --git a/public/app/features/plugins/admin/helpers.test.ts b/public/app/features/plugins/admin/helpers.test.ts index 0002f85e4650d..27887a93d1d11 100644 --- a/public/app/features/plugins/admin/helpers.test.ts +++ b/public/app/features/plugins/admin/helpers.test.ts @@ -88,6 +88,54 @@ describe('Plugins/Helpers', () => { expect(findMerged('plugin-5')).not.toBeUndefined(); expect(findMerged('plugin-5')?.isDeprecated).toBe(true); }); + + test('core plugins should be fullyInstalled in cloud', () => { + const corePluginId = 'plugin-core'; + + const oldFeatureTogglesManagedPluginsInstall = config.featureToggles.managedPluginsInstall; + const oldPluginAdminExternalManageEnabled = config.pluginAdminExternalManageEnabled; + + config.featureToggles.managedPluginsInstall = true; + config.pluginAdminExternalManageEnabled = true; + + const merged = mergeLocalsAndRemotes({ + local: [...localPlugins, getLocalPluginMock({ id: corePluginId, signature: PluginSignatureStatus.internal })], + remote: [...remotePlugins, getRemotePluginMock({ slug: corePluginId })], + }); + const findMerged = (mergedId: string) => merged.find(({ id }) => id === mergedId); + + expect(merged).toHaveLength(5); + expect(findMerged(corePluginId)).not.toBeUndefined(); + expect(findMerged(corePluginId)?.isCore).toBe(true); + expect(findMerged(corePluginId)?.isFullyInstalled).toBe(true); + + config.featureToggles.managedPluginsInstall = oldFeatureTogglesManagedPluginsInstall; + config.pluginAdminExternalManageEnabled = oldPluginAdminExternalManageEnabled; + }); + + test('plugins should be fully installed if they are installed and it is provisioned', () => { + const pluginId = 'plugin-1'; + + const oldFeatureTogglesManagedPluginsInstall = config.featureToggles.managedPluginsInstall; + const oldPluginAdminExternalManageEnabled = config.pluginAdminExternalManageEnabled; + + config.featureToggles.managedPluginsInstall = true; + config.pluginAdminExternalManageEnabled = true; + + const merged = mergeLocalsAndRemotes({ + local: [...localPlugins, getLocalPluginMock({ id: pluginId })], + remote: [...remotePlugins, getRemotePluginMock({ slug: pluginId })], + provisioned: [{ slug: pluginId }], + }); + const findMerged = (mergedId: string) => merged.find(({ id }) => id === mergedId); + + expect(merged).toHaveLength(5); + expect(findMerged(pluginId)).not.toBeUndefined(); + expect(findMerged(pluginId)?.isFullyInstalled).toBe(true); + + config.featureToggles.managedPluginsInstall = oldFeatureTogglesManagedPluginsInstall; + config.pluginAdminExternalManageEnabled = oldPluginAdminExternalManageEnabled; + }); }); describe('mergeLocalAndRemote()', () => { @@ -116,6 +164,7 @@ describe('Plugins/Helpers', () => { large: 'https://grafana.com/api/plugins/alexanderzobnin-zabbix-app/versions/4.1.5/logos/large', small: 'https://grafana.com/api/plugins/alexanderzobnin-zabbix-app/versions/4.1.5/logos/small', }, + keywords: ['zabbix', 'monitoring', 'dashboard'], }, error: undefined, isCore: false, @@ -242,6 +291,7 @@ describe('Plugins/Helpers', () => { small: 'https://grafana.com/api/plugins/alexanderzobnin-zabbix-app/versions/4.1.5/logos/small', large: 'https://grafana.com/api/plugins/alexanderzobnin-zabbix-app/versions/4.1.5/logos/large', }, + keywords: ['zabbix', 'monitoring', 'dashboard'], }, error: undefined, isCore: false, diff --git a/public/app/features/plugins/admin/helpers.ts b/public/app/features/plugins/admin/helpers.ts index 2a4b644690058..c373e440dc626 100644 --- a/public/app/features/plugins/admin/helpers.ts +++ b/public/app/features/plugins/admin/helpers.ts @@ -1,3 +1,5 @@ +import uFuzzy from '@leeoniya/ufuzzy'; + import { PluginSignatureStatus, dateTimeParse, PluginError, PluginType, PluginErrorCode } from '@grafana/data'; import { config, featureEnabled } from '@grafana/runtime'; import configCore, { Settings } from 'app/core/config'; @@ -5,25 +7,40 @@ import { contextSrv } from 'app/core/core'; import { getBackendSrv } from 'app/core/services/backend_srv'; import { AccessControlAction } from 'app/types'; -import { CatalogPlugin, InstancePlugin, LocalPlugin, RemotePlugin, RemotePluginStatus, Version } from './types'; +import { + CatalogPlugin, + InstancePlugin, + LocalPlugin, + ProvisionedPlugin, + RemotePlugin, + RemotePluginStatus, + Version, +} from './types'; export function mergeLocalsAndRemotes({ local = [], remote = [], instance = [], + provisioned = [], pluginErrors: errors, }: { local: LocalPlugin[]; remote?: RemotePlugin[]; instance?: InstancePlugin[]; + provisioned?: ProvisionedPlugin[]; pluginErrors?: PluginError[]; }): CatalogPlugin[] { const catalogPlugins: CatalogPlugin[] = []; const errorByPluginId = groupErrorsByPluginId(errors); - const instancesSet = instance.reduce((set, instancePlugin) => { - set.add(instancePlugin.pluginSlug); - return set; + const instancesMap = instance.reduce((map, instancePlugin) => { + map.set(instancePlugin.pluginSlug, instancePlugin); + return map; + }, new Map<string, InstancePlugin>()); + + const provisionedSet = provisioned.reduce((map, provisionedPlugin) => { + map.add(provisionedPlugin.slug); + return map; }, new Set<string>()); // add locals @@ -47,8 +64,19 @@ export function mergeLocalsAndRemotes({ // for managed instances, check if plugin is installed, but not yet present in the current instance if (configCore.featureToggles.managedPluginsInstall && config.pluginAdminExternalManageEnabled) { - catalogPlugin.isFullyInstalled = instancesSet.has(remotePlugin.slug) && catalogPlugin.isInstalled; - catalogPlugin.isInstalled = instancesSet.has(remotePlugin.slug) || catalogPlugin.isInstalled; + catalogPlugin.isFullyInstalled = catalogPlugin.isCore + ? true + : (instancesMap.has(remotePlugin.slug) || provisionedSet.has(remotePlugin.slug)) && catalogPlugin.isInstalled; + + catalogPlugin.isInstalled = instancesMap.has(remotePlugin.slug) || catalogPlugin.isInstalled; + + const instancePlugin = instancesMap.get(remotePlugin.slug); + catalogPlugin.isUpdatingFromInstance = + instancesMap.has(remotePlugin.slug) && + catalogPlugin.hasUpdate && + catalogPlugin.installedVersion !== instancePlugin?.version; + + catalogPlugin.isUninstallingFromInstance = Boolean(localCounterpart) && !instancesMap.has(remotePlugin.slug); } catalogPlugins.push(catalogPlugin); @@ -84,6 +112,7 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C createdAt: publishedAt, status, angularDetected, + keywords, } = plugin; const isDisabled = !!error || isDisabledSecretsPlugin(typeCode); @@ -96,6 +125,7 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C small: `https://grafana.com/api/plugins/${id}/versions/${version}/logos/small`, large: `https://grafana.com/api/plugins/${id}/versions/${version}/logos/large`, }, + keywords, }, name, orgName, @@ -121,7 +151,7 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): CatalogPlugin { const { name, - info: { description, version, logos, updated, author }, + info: { description, version, logos, updated, author, keywords }, id, dev, type, @@ -138,7 +168,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat description, downloads: 0, id, - info: { logos }, + info: { logos, keywords }, name, orgName: author.name, popularity: 0, @@ -171,6 +201,7 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin, e const id = remote?.slug || local?.id || ''; const type = local?.type || remote?.typeCode; const isDisabled = !!error || isDisabledSecretsPlugin(type); + const keywords = remote?.keywords || local?.info.keywords || []; let logos = { small: `/public/img/icn-${type}.svg`, @@ -193,6 +224,7 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin, e id, info: { logos, + keywords, }, isCore: Boolean(remote?.internal || local?.signature === PluginSignatureStatus.internal), isDev: Boolean(local?.dev), @@ -343,3 +375,26 @@ function isDisabledSecretsPlugin(type?: PluginType): boolean { export function isLocalCorePlugin(local?: LocalPlugin): boolean { return Boolean(local?.signature === 'internal'); } + +function getId(inputString: string): string { + const parts = inputString.split(' - '); + return parts[0]; +} + +function getPluginDetailsForFuzzySearch(plugins: CatalogPlugin[]): string[] { + return plugins.reduce((result: string[], { id, name, type, orgName, info }: CatalogPlugin) => { + const keywordsForSearch = info.keywords?.join(' ').toLowerCase(); + const pluginString = `${id} - ${name} - ${type} - ${orgName} - ${keywordsForSearch}`; + result.push(pluginString); + return result; + }, []); +} +export function filterByKeyword(plugins: CatalogPlugin[], query: string) { + const dataArray = getPluginDetailsForFuzzySearch(plugins); + let uf = new uFuzzy({ intraMode: 1, intraSub: 0 }); + let idxs = uf.filter(dataArray, query); + if (idxs === null) { + return null; + } + return idxs.map((id) => getId(dataArray[id])); +} diff --git a/public/app/features/plugins/admin/hooks/usePluginConfig.tsx b/public/app/features/plugins/admin/hooks/usePluginConfig.tsx index 4511b2dc39654..bf28f69ecf9a6 100644 --- a/public/app/features/plugins/admin/hooks/usePluginConfig.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginConfig.tsx @@ -1,5 +1,7 @@ import { useAsync } from 'react-use'; +import { config } from '@grafana/runtime'; + import { loadPlugin } from '../../utils'; import { CatalogPlugin } from '../types'; @@ -9,7 +11,12 @@ export const usePluginConfig = (plugin?: CatalogPlugin) => { return null; } - if (plugin.isFullyInstalled && !plugin.isDisabled) { + const isPluginInstalled = + config.pluginAdminExternalManageEnabled && config.featureToggles.managedPluginsInstall + ? plugin.isFullyInstalled + : plugin.isInstalled; + + if (isPluginInstalled && !plugin.isDisabled) { return loadPlugin(plugin.id); } return null; diff --git a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx index 63f3008a5ab65..5ff258b59169d 100644 --- a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx @@ -51,7 +51,10 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, pageId?: PluginTabI }); } - if (config.featureToggles.panelTitleSearch && pluginConfig.meta.type === PluginType.panel) { + if ( + config.featureToggles.panelTitleSearch && + (pluginConfig.meta.type === PluginType.panel || pluginConfig.meta.type === PluginType.datasource) + ) { navModelChildren.push({ text: PluginTabLabels.USAGE, icon: 'list-ul', diff --git a/public/app/features/plugins/admin/pages/Browse.test.tsx b/public/app/features/plugins/admin/pages/Browse.test.tsx index a10d57508ab26..a22f98e6c2c14 100644 --- a/public/app/features/plugins/admin/pages/Browse.test.tsx +++ b/public/app/features/plugins/admin/pages/Browse.test.tsx @@ -183,13 +183,19 @@ describe('Browse list of plugins', () => { describe('when searching', () => { it('should only list plugins matching search', async () => { - const { queryByText } = renderBrowse('/plugins?filterBy=all&q=zabbix', [ - getCatalogPluginMock({ id: 'zabbix', name: 'Zabbix' }), - getCatalogPluginMock({ id: 'plugin-2', name: 'Plugin 2' }), - getCatalogPluginMock({ id: 'plugin-3', name: 'Plugin 3' }), + const { queryByText } = renderBrowse('/plugins?filterBy=all&q=matches', [ + getCatalogPluginMock({ id: 'matches-the-search', name: 'Matches the search' }), + getCatalogPluginMock({ + id: 'plugin-2', + name: 'Plugin 2', + }), + getCatalogPluginMock({ + id: 'plugin-3', + name: 'Plugin 3', + }), ]); - await waitFor(() => expect(queryByText('Zabbix')).toBeInTheDocument()); + await waitFor(() => expect(queryByText('Matches the search')).toBeInTheDocument()); // Other plugin types shouldn't be shown expect(queryByText('Plugin 2')).not.toBeInTheDocument(); diff --git a/public/app/features/plugins/admin/pages/Browse.tsx b/public/app/features/plugins/admin/pages/Browse.tsx index 9ec3179e8347f..195ec0e7b5855 100644 --- a/public/app/features/plugins/admin/pages/Browse.tsx +++ b/public/app/features/plugins/admin/pages/Browse.tsx @@ -39,6 +39,7 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem }, sortBy ); + const filterByOptions = [ { value: 'all', label: 'All' }, { value: 'installed', label: 'Installed' }, diff --git a/public/app/features/plugins/admin/state/actions.ts b/public/app/features/plugins/admin/state/actions.ts index 0afc9560ebd83..bff4b094b51b6 100644 --- a/public/app/features/plugins/admin/state/actions.ts +++ b/public/app/features/plugins/admin/state/actions.ts @@ -16,10 +16,11 @@ import { installPlugin, uninstallPlugin, getInstancePlugins, + getProvisionedPlugins, } from '../api'; import { STATE_PREFIX } from '../constants'; import { mapLocalToCatalog, mergeLocalsAndRemotes, updatePanels } from '../helpers'; -import { CatalogPlugin, RemotePlugin, LocalPlugin, InstancePlugin } from '../types'; +import { CatalogPlugin, RemotePlugin, LocalPlugin, InstancePlugin, ProvisionedPlugin } from '../types'; // Fetches export const fetchAll = createAsyncThunk(`${STATE_PREFIX}/fetchAll`, async (_, thunkApi) => { @@ -31,6 +32,10 @@ export const fetchAll = createAsyncThunk(`${STATE_PREFIX}/fetchAll`, async (_, t config.pluginAdminExternalManageEnabled && configCore.featureToggles.managedPluginsInstall ? from(getInstancePlugins()) : of(undefined); + const provisioned$ = + config.pluginAdminExternalManageEnabled && configCore.featureToggles.managedPluginsInstall + ? from(getProvisionedPlugins()) + : of(undefined); const TIMEOUT = 500; const pluginErrors$ = from(getPluginErrors()); const local$ = from(getLocalPlugins()); @@ -48,6 +53,7 @@ export const fetchAll = createAsyncThunk(`${STATE_PREFIX}/fetchAll`, async (_, t local: local$, remote: remote$, instance: instance$, + provisioned: provisioned$, pluginErrors: pluginErrors$, }) .pipe( @@ -66,13 +72,21 @@ export const fetchAll = createAsyncThunk(`${STATE_PREFIX}/fetchAll`, async (_, t if (remote.length > 0) { const local = await lastValueFrom(local$); const instance = await lastValueFrom(instance$); + const provisioned = await lastValueFrom(provisioned$); const pluginErrors = await lastValueFrom(pluginErrors$); - thunkApi.dispatch(addPlugins(mergeLocalsAndRemotes({ local, remote, instance, pluginErrors }))); + thunkApi.dispatch( + addPlugins(mergeLocalsAndRemotes({ local, remote, instance, provisioned, pluginErrors })) + ); } }); - return forkJoin({ local: local$, instance: instance$, pluginErrors: pluginErrors$ }); + return forkJoin({ + local: local$, + instance: instance$, + provisioned: provisioned$, + pluginErrors: pluginErrors$, + }); }, }) ) @@ -81,18 +95,22 @@ export const fetchAll = createAsyncThunk(`${STATE_PREFIX}/fetchAll`, async (_, t local, remote, instance, + provisioned, pluginErrors, }: { local: LocalPlugin[]; remote?: RemotePlugin[]; instance?: InstancePlugin[]; + provisioned?: ProvisionedPlugin[]; pluginErrors: PluginError[]; }) => { // Both local and remote plugins are loaded if (local && remote) { thunkApi.dispatch({ type: `${STATE_PREFIX}/fetchLocal/fulfilled` }); thunkApi.dispatch({ type: `${STATE_PREFIX}/fetchRemote/fulfilled` }); - thunkApi.dispatch(addPlugins(mergeLocalsAndRemotes({ local, remote, instance, pluginErrors }))); + thunkApi.dispatch( + addPlugins(mergeLocalsAndRemotes({ local, remote, instance, provisioned, pluginErrors })) + ); // Only remote plugins are loaded (remote timed out) } else if (local) { diff --git a/public/app/features/plugins/admin/state/hooks.ts b/public/app/features/plugins/admin/state/hooks.ts index f6592a9e1283a..c50d95483487c 100644 --- a/public/app/features/plugins/admin/state/hooks.ts +++ b/public/app/features/plugins/admin/state/hooks.ts @@ -84,7 +84,10 @@ export const useLocalFetchStatus = () => { }; export const useFetchStatus = () => { - const isLoading = useSelector(selectIsRequestPending(fetchAll.typePrefix)); + const isAllLoading = useSelector(selectIsRequestPending(fetchAll.typePrefix)); + const isLocalLoading = useSelector(selectIsRequestPending('plugins/fetchLocal')); + const isRemoteLoading = useSelector(selectIsRequestPending('plugins/fetchRemote')); + const isLoading = isAllLoading || isLocalLoading || isRemoteLoading; const error = useSelector(selectRequestError(fetchAll.typePrefix)); return { isLoading, error }; diff --git a/public/app/features/plugins/admin/state/selectors.ts b/public/app/features/plugins/admin/state/selectors.ts index 2bf3f32de3138..e56f6057ce29e 100644 --- a/public/app/features/plugins/admin/state/selectors.ts +++ b/public/app/features/plugins/admin/state/selectors.ts @@ -1,7 +1,9 @@ import { createSelector } from '@reduxjs/toolkit'; import { PluginError, PluginType, unEscapeStringFromRegex } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { filterByKeyword } from '../helpers'; import { RequestStatus, PluginCatalogStoreState } from '../types'; import { pluginsAdapter } from './reducer'; @@ -32,11 +34,17 @@ export type PluginFilters = { export const selectPlugins = (filters: PluginFilters) => createSelector(selectAll, (plugins) => { const keyword = filters.keyword ? unEscapeStringFromRegex(filters.keyword.toLowerCase()) : ''; + const filteredPluginIds = keyword !== '' ? filterByKeyword(plugins, keyword) : null; + if (keyword) { + reportInteraction('plugins_search', { resultsCount: filteredPluginIds?.length }); + } return plugins.filter((plugin) => { - const fieldsToSearchIn = [plugin.name, plugin.orgName].filter(Boolean).map((f) => f.toLowerCase()); + if (keyword && filteredPluginIds == null) { + return false; + } - if (keyword && !fieldsToSearchIn.some((f) => f.includes(keyword))) { + if (keyword && !filteredPluginIds?.includes(plugin.id)) { return false; } diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index d359d1ffaf137..5cc0089fb9669 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -62,6 +62,8 @@ export interface CatalogPlugin extends WithAccessControlMetadata { // instance plugins may not be fully installed, which means a new instance // running the plugin didn't started yet isFullyInstalled?: boolean; + isUninstallingFromInstance?: boolean; + isUpdatingFromInstance?: boolean; iam?: IdentityAccessManagement; } @@ -83,6 +85,7 @@ export interface CatalogPluginInfo { large: string; small: string; }; + keywords: string[]; } export type RemotePlugin = { @@ -93,6 +96,7 @@ export type RemotePlugin = { featured: number; id: number; internal: boolean; + keywords: string[]; json?: { dependencies: PluginDependencies; iam?: IdentityAccessManagement; @@ -161,6 +165,7 @@ export type LocalPlugin = WithAccessControlMetadata & { small: string; large: string; }; + keywords: string[]; build: Build; screenshots?: Array<{ path: string; @@ -315,4 +320,9 @@ export type PluginVersion = { export type InstancePlugin = { pluginSlug: string; + version: string; +}; + +export type ProvisionedPlugin = { + slug: string; }; diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 90b023d14dafd..38dee1b8072e0 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -15,8 +15,6 @@ const influxdbPlugin = async () => const lokiPlugin = async () => await import(/* webpackChunkName: "lokiPlugin" */ 'app/plugins/datasource/loki/module'); const jaegerPlugin = async () => await import(/* webpackChunkName: "jaegerPlugin" */ 'app/plugins/datasource/jaeger/module'); -const zipkinPlugin = async () => - await import(/* webpackChunkName: "zipkinPlugin" */ 'app/plugins/datasource/zipkin/module'); const mixedPlugin = async () => await import(/* webpackChunkName: "mixedPlugin" */ 'app/plugins/datasource/mixed/module'); const mysqlPlugin = async () => @@ -27,21 +25,9 @@ const prometheusPlugin = async () => await import(/* webpackChunkName: "prometheusPlugin" */ 'app/plugins/datasource/prometheus/module'); const mssqlPlugin = async () => await import(/* webpackChunkName: "mssqlPlugin" */ 'app/plugins/datasource/mssql/module'); -const testDataDSPlugin = async () => - await import(/* webpackChunkName: "testDataDSPlugin" */ '@grafana-plugins/grafana-testdata-datasource/module'); -const cloudMonitoringPlugin = async () => - await import(/* webpackChunkName: "cloudMonitoringPlugin" */ 'app/plugins/datasource/cloud-monitoring/module'); -const azureMonitorPlugin = async () => - await import(/* webpackChunkName: "azureMonitorPlugin" */ 'app/plugins/datasource/azuremonitor/module'); -const tempoPlugin = async () => - await import(/* webpackChunkName: "tempoPlugin" */ 'app/plugins/datasource/tempo/module'); const alertmanagerPlugin = async () => await import(/* webpackChunkName: "alertmanagerPlugin" */ 'app/plugins/datasource/alertmanager/module'); -const pyroscopePlugin = async () => - await import(/* webpackChunkName: "pyroscopePlugin" */ 'app/plugins/datasource/grafana-pyroscope-datasource/module'); -const parcaPlugin = async () => await import(/* webpackChunkName: "parcaPlugin" */ '@grafana-plugins/parca/module'); -import * as alertGroupsPanel from 'app/plugins/panel/alertGroups/module'; import * as alertListPanel from 'app/plugins/panel/alertlist/module'; import * as annoListPanel from 'app/plugins/panel/annolist/module'; import * as barChartPanel from 'app/plugins/panel/barchart/module'; @@ -57,7 +43,6 @@ import * as histogramPanel from 'app/plugins/panel/histogram/module'; import * as livePanel from 'app/plugins/panel/live/module'; import * as logsPanel from 'app/plugins/panel/logs/module'; import * as newsPanel from 'app/plugins/panel/news/module'; -import * as nodeGraph from 'app/plugins/panel/nodeGraph/module'; import * as pieChartPanel from 'app/plugins/panel/piechart/module'; import * as statPanel from 'app/plugins/panel/stat/module'; import * as stateTimelinePanel from 'app/plugins/panel/state-timeline/module'; @@ -79,6 +64,9 @@ const heatmapPanel = async () => const tableOldPanel = async () => await import(/* webpackChunkName: "tableOldPlugin" */ 'app/plugins/panel/table-old/module'); +const nodeGraph = async () => + await import(/* webpackChunkName: "nodeGraphPanel" */ 'app/plugins/panel/nodeGraph/module'); + const builtInPlugins: Record<string, System.Module | (() => Promise<System.Module>)> = { // datasources 'core:plugin/graphite': graphitePlugin, @@ -90,19 +78,12 @@ const builtInPlugins: Record<string, System.Module | (() => Promise<System.Modul 'core:plugin/influxdb': influxdbPlugin, 'core:plugin/loki': lokiPlugin, 'core:plugin/jaeger': jaegerPlugin, - 'core:plugin/zipkin': zipkinPlugin, 'core:plugin/mixed': mixedPlugin, 'core:plugin/mysql': mysqlPlugin, 'core:plugin/grafana-postgresql-datasource': postgresPlugin, 'core:plugin/mssql': mssqlPlugin, 'core:plugin/prometheus': prometheusPlugin, - 'core:plugin/grafana-testdata-datasource': testDataDSPlugin, - 'core:plugin/cloud-monitoring': cloudMonitoringPlugin, - 'core:plugin/azuremonitor': azureMonitorPlugin, - 'core:plugin/tempo': tempoPlugin, 'core:plugin/alertmanager': alertmanagerPlugin, - 'core:plugin/grafana-pyroscope-datasource': pyroscopePlugin, - 'core:plugin/parca': parcaPlugin, // panels 'core:plugin/text': textPanel, 'core:plugin/timeseries': timeseriesPanel, @@ -136,7 +117,6 @@ const builtInPlugins: Record<string, System.Module | (() => Promise<System.Modul 'core:plugin/welcome': welcomeBanner, 'core:plugin/nodeGraph': nodeGraph, 'core:plugin/histogram': histogramPanel, - 'core:plugin/alertGroups': alertGroupsPanel, }; export default builtInPlugins; diff --git a/public/app/features/plugins/components/AppRootPage.test.tsx b/public/app/features/plugins/components/AppRootPage.test.tsx index 69f01e9dc37a1..e9009ff59d0d3 100644 --- a/public/app/features/plugins/components/AppRootPage.test.tsx +++ b/public/app/features/plugins/components/AppRootPage.test.tsx @@ -26,6 +26,25 @@ jest.mock('../plugin_loader', () => ({ importAppPlugin: jest.fn(), })); +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + config: { + featureToggles: { + accessControlOnCall: true, + }, + theme2: { + breakpoints: { + values: { + sm: 576, + md: 768, + lg: 992, + xl: 1200, + }, + }, + }, + }, +})); + const importAppPluginMock = importAppPlugin as jest.Mock< ReturnType<typeof importAppPlugin>, Parameters<typeof importAppPlugin> @@ -103,9 +122,12 @@ describe('AppRootPage', () => { }); it("should show a not found page if the plugin settings can't load", async () => { + jest.spyOn(console, 'error').mockImplementation(); getPluginSettingsMock.mockRejectedValue(new Error('Unknown Plugin')); // Renders once for the first time - await renderUnderRouter(); + await act(async () => { + await renderUnderRouter(); + }); expect(await screen.findByText('App not found')).toBeVisible(); }); @@ -172,6 +194,19 @@ describe('AppRootPage', () => { name: 'Awesome page 2', path: '/a/my-awesome-plugin/page-without-role', }, + { + type: PluginIncludeType.page, + name: 'Awesome page 3', + path: '/a/my-awesome-plugin/page-with-action-no-role', + action: 'grafana-awesomeapp.user-settings:read', + }, + { + type: PluginIncludeType.page, + name: 'Awesome page 4', + path: '/a/my-awesome-plugin/page-with-action-and-role', + role: 'Viewer', + action: 'grafana-awesomeapp.user-settings:read', + }, ], }); @@ -191,13 +226,58 @@ describe('AppRootPage', () => { expect(await screen.findByText('Access denied')).toBeVisible(); }); + it('a None role user should only have access to pages with actions defined or undefined', async () => { + contextSrv.user.orgRole = OrgRole.None; + + // has access to a plugin entry page by default + await renderUnderRouter(''); + expect(await screen.findByText('my great component')).toBeVisible(); + + // does not have access to a page with an action but no role + await renderUnderRouter('page-with-action-no-role'); + expect(await screen.findByText('Access denied')).toBeVisible(); + + // does not have access to a page with an action and role + await renderUnderRouter('page-with-action-and-role'); + expect(await screen.findByText('Access denied')).toBeVisible(); + + // has access to a page without roles + await renderUnderRouter('page-without-role'); + expect(await screen.findByText('my great component')).toBeVisible(); + + // has access to Viewer page + await renderUnderRouter('viewer-page'); + expect(await screen.findByText('Access denied')).toBeVisible(); + + contextSrv.user.permissions = { + 'grafana-awesomeapp.user-settings:read': true, + }; + + // has access to a page with an action but no role + await renderUnderRouter('page-with-action-no-role'); + expect(await screen.findByText('my great component')).toBeVisible(); + + // has access to a page with an action and role + await renderUnderRouter('page-with-action-and-role'); + expect(await screen.findByText('my great component')).toBeVisible(); + }); + it('a Viewer should only have access to pages with "Viewer" roles', async () => { contextSrv.user.orgRole = OrgRole.Viewer; + contextSrv.user.permissions = {}; // Viewer has access to a plugin entry page by default await renderUnderRouter(''); expect(await screen.findByText('my great component')).toBeVisible(); + // Viewer does not have access to a page with an action but no role + await renderUnderRouter('page-with-action-no-role'); + expect(await screen.findByText('Access denied')).toBeVisible(); + + // Viewer does not have access to a page with an action and role + await renderUnderRouter('page-with-action-and-role'); + expect(await screen.findByText('Access denied')).toBeVisible(); + // Viewer has access to a page without roles await renderUnderRouter('page-without-role'); expect(await screen.findByText('my great component')).toBeVisible(); @@ -218,35 +298,97 @@ describe('AppRootPage', () => { it('an Editor should have access to pages with both "Viewer" and "Editor" roles', async () => { contextSrv.user.orgRole = OrgRole.Editor; contextSrv.isEditor = true; + contextSrv.user.permissions = {}; - // Viewer has access to a plugin entry page by default + // does not have access to a page with an action but no role + await renderUnderRouter('page-with-action-no-role'); + expect(await screen.findByText('Access denied')).toBeVisible(); + + // does not have access to a page with an action and role + await renderUnderRouter('page-with-action-and-role'); + expect(await screen.findByText('Access denied')).toBeVisible(); + + // has access to a plugin entry page by default await renderUnderRouter(''); expect(await screen.findByText('my great component')).toBeVisible(); - // Editor has access to a page without roles + // has access to a page without roles await renderUnderRouter('page-without-role'); expect(await screen.findByText('my great component')).toBeVisible(); - // Editor has access to Viewer page + // has access to Viewer page await renderUnderRouter('viewer-page'); expect(await screen.findByText('my great component')).toBeVisible(); - // Editor has access to Editor page + // has access to Editor page await renderUnderRouter('editor-page'); expect(await screen.findByText('my great component')).toBeVisible(); - // Editor does not have access to a Admin page + // does not have access to a Admin page await renderUnderRouter('admin-page'); expect(await screen.findByText('Access denied')).toBeVisible(); + + contextSrv.user.permissions = { + 'grafana-awesomeapp.user-settings:read': true, + }; + + // has access to a page with an action but no role + await renderUnderRouter('page-with-action-no-role'); + expect(await screen.findByText('my great component')).toBeVisible(); + + // has access to a page with an action and role + await renderUnderRouter('page-with-action-and-role'); + expect(await screen.findByText('my great component')).toBeVisible(); + }); + + it('an Admin should have access to pages with both "Viewer" and "Editor" roles', async () => { + contextSrv.user.orgRole = OrgRole.Admin; + contextSrv.user.permissions = {}; + + // does not have access to a page with an action but no role + await renderUnderRouter('page-with-action-no-role'); + expect(await screen.findByText('Access denied')).toBeVisible(); + + // does not have access to a page with an action and role + await renderUnderRouter('page-with-action-and-role'); + expect(await screen.findByText('Access denied')).toBeVisible(); + + // has access to a plugin entry page by default + await renderUnderRouter(''); + expect(await screen.findByText('my great component')).toBeVisible(); + + // has access to a page without roles + await renderUnderRouter('page-without-role'); + expect(await screen.findByText('my great component')).toBeVisible(); + + // has access to Viewer page + await renderUnderRouter('viewer-page'); + expect(await screen.findByText('my great component')).toBeVisible(); + + // has access to Editor page + await renderUnderRouter('editor-page'); + expect(await screen.findByText('my great component')).toBeVisible(); + + // has access to a Admin page + await renderUnderRouter('admin-page'); + expect(await screen.findByText('my great component')).toBeVisible(); }); - it('a Grafana Admin should be able to see any page', async () => { + it('a Grafana Admin should be able to see any page without action specifier', async () => { contextSrv.isGrafanaAdmin = true; // Viewer has access to a plugin entry page await renderUnderRouter(''); expect(await screen.findByText('my great component')).toBeVisible(); + // Viewer does not have access to a page with an action but no role + await renderUnderRouter('page-with-action-no-role'); + expect(await screen.findByText('Access denied')).toBeVisible(); + + // Viewer does not have access to a page with an action and role + await renderUnderRouter('page-with-action-and-role'); + expect(await screen.findByText('Access denied')).toBeVisible(); + // Admin has access to a page without roles await renderUnderRouter('page-without-role'); expect(await screen.findByText('my great component')).toBeVisible(); diff --git a/public/app/features/plugins/components/AppRootPage.tsx b/public/app/features/plugins/components/AppRootPage.tsx index bd22739aa1a69..33b6f905f55bb 100644 --- a/public/app/features/plugins/components/AppRootPage.tsx +++ b/public/app/features/plugins/components/AppRootPage.tsx @@ -21,10 +21,11 @@ import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound' import { useGrafana } from 'app/core/context/GrafanaContext'; import { appEvents, contextSrv } from 'app/core/core'; import { getNotFoundNav, getWarningNav, getExceptionNav } from 'app/core/navigation/errorModels'; +import { getMessageFromError } from 'app/core/utils/errors'; import { getPluginSettings } from '../pluginSettings'; import { importAppPlugin } from '../plugin_loader'; -import { buildPluginSectionNav } from '../utils'; +import { buildPluginSectionNav, pluginsLogger } from '../utils'; import { buildPluginPageContext, PluginPageContext } from './PluginPageContext'; @@ -100,7 +101,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) { // if the user has permissions to see the plugin page. const userHasPermissionsToPluginPage = () => { // Check if plugin does not have any configurations or the user is Grafana Admin - if (!plugin.meta?.includes || contextSrv.isGrafanaAdmin || contextSrv.user.orgRole === OrgRole.Admin) { + if (!plugin.meta?.includes) { return true; } @@ -109,6 +110,16 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) { if (!pluginInclude) { return true; } + + // Check if action exists and give access if user has the required permission. + if (pluginInclude?.action && config.featureToggles.accessControlOnCall) { + return contextSrv.hasPermission(pluginInclude.action); + } + + if (contextSrv.isGrafanaAdmin || contextSrv.user.orgRole === OrgRole.Admin) { + return true; + } + const pathRole: string = pluginInclude?.role || ''; // Check if role exists and give access to Editor to be able to see Viewer pages if (!pathRole || (contextSrv.isEditor && pathRole === OrgRole.Viewer)) { @@ -191,6 +202,9 @@ async function loadAppPlugin(pluginId: string, dispatch: React.Dispatch<AnyActio pluginNav: process.env.NODE_ENV === 'development' ? getExceptionNav(err) : getNotFoundNav(), }) ); + const error = err instanceof Error ? err : new Error(getMessageFromError(err)); + pluginsLogger.logError(error); + console.error(error); } } diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index 16f4c08f54145..7e9b6affd1d42 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -251,8 +251,10 @@ export class DatasourceSrv implements DataSourceService { continue; } let dsValue = variable.current.value === 'default' ? this.defaultName : variable.current.value; - if (Array.isArray(dsValue) && dsValue.length === 1) { - // Support for multi-value variables with only one selected datasource + // Support for multi-value DataSource (ds) variables + if (Array.isArray(dsValue)) { + // If the ds variable have multiple selected datasources + // We will use the first one dsValue = dsValue[0]; } const dsSettings = diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index ee4b742f5f42b..93958e05c9027 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -445,12 +445,18 @@ describe('Plugin Extensions / Utils', () => { }); describe('wrapExtensionComponentWithContext()', () => { - const ExampleComponent = () => { + type ExampleComponentProps = { + audience?: string; + }; + + const ExampleComponent = (props: ExampleComponentProps) => { const { meta } = usePluginContext(); + const audience = props.audience || 'Grafana'; + return ( <div> - <h1>Hello Grafana!</h1> Version: {meta.info.version} + <h1>Hello {audience}!</h1> Version: {meta.info.version} </div> ); }; @@ -464,5 +470,15 @@ describe('Plugin Extensions / Utils', () => { expect(await screen.findByText('Hello Grafana!')).toBeVisible(); expect(screen.getByText('Version: 1.0.0')).toBeVisible(); }); + + it('should pass the properties into the wrapped component', async () => { + const pluginId = 'grafana-worldmap-panel'; + const Component = wrapWithPluginContext(pluginId, ExampleComponent); + + render(<Component audience="folks" />); + + expect(await screen.findByText('Hello folks!')).toBeVisible(); + expect(screen.getByText('Version: 1.0.0')).toBeVisible(); + }); }); }); diff --git a/public/app/features/plugins/loader/cache.ts b/public/app/features/plugins/loader/cache.ts index 739c4c5807204..3db74fd2a7089 100644 --- a/public/app/features/plugins/loader/cache.ts +++ b/public/app/features/plugins/loader/cache.ts @@ -35,7 +35,7 @@ export function resolveWithCache(url: string, defaultBust = initializedAt): stri } function extractPath(address: string): string | undefined { - const match = /\/.+\/(plugins\/.+\/module)\.js/i.exec(address); + const match = /\/?.+\/(plugins\/.+\/module)\.js/i.exec(address); if (!match) { return; } diff --git a/public/app/features/plugins/loader/constants.ts b/public/app/features/plugins/loader/constants.ts index daf840dff2c79..0bcc4f9737e19 100644 --- a/public/app/features/plugins/loader/constants.ts +++ b/public/app/features/plugins/loader/constants.ts @@ -1,5 +1,3 @@ export const SHARED_DEPENDENCY_PREFIX = 'package'; export const LOAD_PLUGIN_CSS_REGEX = /^plugins.+\.css$/i; export const JS_CONTENT_TYPE_REGEX = /^(text|application)\/(x-)?javascript(;|$)/; -export const AMD_MODULE_REGEX = - /(?:^\uFEFF?|[^$_a-zA-Z\xA0-\uFFFF.])define\s*\(\s*("[^"]+"\s*,\s*|'[^']+'\s*,\s*)?\s*(\[(\s*(("[^"]+"|'[^']+')\s*,|\/\/.*\r?\n))*(\s*("[^"]+"|'[^']+')\s*,?)?(\s*(\/\/.*\r?\n|\/\*\/))*\s*\]|function\s*|{|[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*\))/; diff --git a/public/app/features/plugins/loader/pluginLoader.mock.ts b/public/app/features/plugins/loader/pluginLoader.mock.ts index 530b245a5927c..b7433716563a5 100644 --- a/public/app/features/plugins/loader/pluginLoader.mock.ts +++ b/public/app/features/plugins/loader/pluginLoader.mock.ts @@ -1,4 +1,5 @@ -import { rest } from 'msw'; +import 'whatwg-fetch'; +import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; export const mockSystemModule = `System.register(['./dependencyA'], function (_export, _context) { @@ -22,101 +23,33 @@ export const mockAmdModule = `define([], function() { } });`; -export const mockAmdModuleNamedNoDeps = `define("named", function() { - return function() { - console.log('AMD module loaded'); - var pluginPath = "/public/plugins/"; - } -});`; - -export const mockAmdModuleNamedWithDeps = `define("named", ["dep"], function(dep) { - return function() { - console.log('AMD module loaded'); - var pluginPath = "/public/plugins/"; - } -});`; - -export const mockAmdModuleNamedWithDeps2 = `define("named", ["dep", "dep2"], function(dep, dep2) { - return function() { - console.log('AMD module loaded'); - var pluginPath = "/public/plugins/"; - } -});`; - -export const mockAmdModuleNamedWithDeps3 = `define("named", ["dep", -"dep2" -], function(dep, dep2) { - return function() { - console.log('AMD module loaded'); - var pluginPath = "/public/plugins/"; - } -});`; - -export const mockAmdModuleOnlyFunction = `define(function() { - return function() { - console.log('AMD module loaded'); - var pluginPath = "/public/plugins/"; - } -});`; - -export const mockAmdModuleWithComments = `/*! For license information please see module.js.LICENSE.txt */ -define(function(react) { - return function() { - console.log('AMD module loaded'); - var pluginPath = "/public/plugins/"; - } -});`; - -export const mockAmdModuleWithComments2 = `/*! This is a commment */ -define(["dep"], - /*! This is a commment */ - function(dep) { - return function() { - console.log('AMD module loaded'); - var pluginPath = "/public/plugins/"; - } -});`; - -export const mockModuleWithDefineMethod = `ace.define(function() { - return function() { - console.log('AMD module loaded'); - var pluginPath = "/public/plugins/"; - } -});`; - const server = setupServer( - rest.get('/public/plugins/mockAmdModule/module.js', async (_req, res, ctx) => - res(ctx.status(200), ctx.set('Content-Type', 'text/javascript'), ctx.body(mockAmdModule)) - ), - rest.get('/public/plugins/mockSystemModule/module.js', async (_req, res, ctx) => - res(ctx.status(200), ctx.set('Content-Type', 'text/javascript'), ctx.body(mockSystemModule)) - ), - rest.get('http://my-cdn.com/plugins/my-plugin/v1.0.0/public/plugins/my-plugin/module.js', async (_req, res, ctx) => - res(ctx.status(200), ctx.set('Content-Type', 'text/javascript'), ctx.body(mockAmdModule)) - ), - rest.get('/public/plugins/mockAmdModuleNamedNoDeps/module.js', async (_req, res, ctx) => - res(ctx.status(200), ctx.set('Content-Type', 'text/javascript'), ctx.body(mockAmdModuleNamedNoDeps)) - ), - rest.get('/public/plugins/mockAmdModuleNamedWithDeps/module.js', async (_req, res, ctx) => - res(ctx.status(200), ctx.set('Content-Type', 'text/javascript'), ctx.body(mockAmdModuleNamedWithDeps)) - ), - rest.get('/public/plugins/mockAmdModuleNamedWithDeps2/module.js', async (_req, res, ctx) => - res(ctx.status(200), ctx.set('Content-Type', 'text/javascript'), ctx.body(mockAmdModuleNamedWithDeps2)) - ), - rest.get('/public/plugins/mockAmdModuleNamedWithDeps3/module.js', async (_req, res, ctx) => - res(ctx.status(200), ctx.set('Content-Type', 'text/javascript'), ctx.body(mockAmdModuleNamedWithDeps3)) - ), - rest.get('/public/plugins/mockAmdModuleOnlyFunction/module.js', async (_req, res, ctx) => - res(ctx.status(200), ctx.set('Content-Type', 'text/javascript'), ctx.body(mockAmdModuleOnlyFunction)) - ), - rest.get('/public/plugins/mockAmdModuleWithComments/module.js', async (_req, res, ctx) => - res(ctx.status(200), ctx.set('Content-Type', 'text/javascript'), ctx.body(mockAmdModuleWithComments)) - ), - rest.get('/public/plugins/mockAmdModuleWithComments2/module.js', async (_req, res, ctx) => - res(ctx.status(200), ctx.set('Content-Type', 'text/javascript'), ctx.body(mockAmdModuleWithComments2)) - ), - rest.get('/public/plugins/mockModuleWithDefineMethod/module.js', async (_req, res, ctx) => - res(ctx.status(200), ctx.set('Content-Type', 'text/javascript'), ctx.body(mockModuleWithDefineMethod)) + http.get( + '/public/plugins/mockAmdModule/module.js', + () => + new HttpResponse(mockAmdModule, { + headers: { + 'Content-Type': 'text/javascript', + }, + }) + ), + http.get( + '/public/plugins/mockSystemModule/module.js', + () => + new HttpResponse(mockSystemModule, { + headers: { + 'Content-Type': 'text/javascript', + }, + }) + ), + http.get( + 'http://my-cdn.com/plugins/my-plugin/v1.0.0/public/plugins/my-plugin/module.js', + () => + new HttpResponse(mockAmdModule, { + headers: { + 'Content-Type': 'text/javascript', + }, + }) ) ); diff --git a/public/app/features/plugins/loader/sharedDependencies.ts b/public/app/features/plugins/loader/sharedDependencies.ts index d1c29ba9b3fc6..3f9c3d10349da 100644 --- a/public/app/features/plugins/loader/sharedDependencies.ts +++ b/public/app/features/plugins/loader/sharedDependencies.ts @@ -1,5 +1,6 @@ import * as emotion from '@emotion/css'; import * as emotionReact from '@emotion/react'; +import * as kusto from '@kusto/monaco-kusto'; import * as d3 from 'd3'; import * as i18next from 'i18next'; import jquery from 'jquery'; @@ -71,6 +72,7 @@ export const sharedDependenciesMap: Record<string, System.Module> = { '@grafana/runtime': grafanaRuntime, '@grafana/slate-react': slateReact, // for backwards compatibility with older plugins '@grafana/ui': grafanaUI, + '@kusto/monaco-kusto': kusto, 'app/core/app_events': { default: appEvents, __useDefault: true, diff --git a/public/app/features/plugins/loader/systemjsHooks.test.ts b/public/app/features/plugins/loader/systemjsHooks.test.ts index 08d33e2c644a9..a0da31252abbe 100644 --- a/public/app/features/plugins/loader/systemjsHooks.test.ts +++ b/public/app/features/plugins/loader/systemjsHooks.test.ts @@ -7,19 +7,7 @@ jest.mock('./cache', () => ({ resolveWithCache: (url: string) => `${url}?_cache=1234`, })); -import { - server, - mockAmdModule, - mockSystemModule, - mockAmdModuleNamedNoDeps, - mockAmdModuleNamedWithDeps, - mockAmdModuleNamedWithDeps2, - mockAmdModuleNamedWithDeps3, - mockAmdModuleOnlyFunction, - mockAmdModuleWithComments, - mockModuleWithDefineMethod, - mockAmdModuleWithComments2, -} from './pluginLoader.mock'; +import { server } from './pluginLoader.mock'; import { decorateSystemJSFetch, decorateSystemJSResolve } from './systemjsHooks'; import { SystemJSWithLoaderHooks } from './types'; @@ -28,11 +16,13 @@ describe('SystemJS Loader Hooks', () => { const originalFetch = systemJSPrototype.fetch; const originalResolve = systemJSPrototype.resolve; - systemJSPrototype.resolve = (moduleId: string) => moduleId; - systemJSPrototype.shouldFetch = () => true; - beforeAll(() => { server.listen(); + systemJSPrototype.resolve = (moduleId: string) => moduleId; + systemJSPrototype.shouldFetch = () => true; + // because server.listen() patches fetch, we need to reassign this to the systemJSPrototype + // this is identical to what happens in the original code: https://github.com/systemjs/systemjs/blob/main/src/features/fetch-load.js#L12 + systemJSPrototype.fetch = window.fetch; }); afterEach(() => server.resetHandlers()); afterAll(() => { @@ -42,122 +32,16 @@ describe('SystemJS Loader Hooks', () => { }); describe('decorateSystemJSFetch', () => { - it('wraps AMD modules in an AMD iife', async () => { - const basicResult = await decorateSystemJSFetch(originalFetch, '/public/plugins/mockAmdModule/module.js', {}); - const basicSource = await basicResult.text(); - const basicExpected = `(function(define) { - ${mockAmdModule} -})(window.__grafana_amd_define);`; - expect(basicSource).toBe(basicExpected); - - const mockAmdModuleNamedNoDepsResult = await decorateSystemJSFetch( - originalFetch, - '/public/plugins/mockAmdModuleNamedNoDeps/module.js', - {} - ); - const mockAmdModuleNamedNoDepsSource = await mockAmdModuleNamedNoDepsResult.text(); - const mockAmdModuleNamedNoDepsExpected = `(function(define) { - ${mockAmdModuleNamedNoDeps} -})(window.__grafana_amd_define);`; - - expect(mockAmdModuleNamedNoDepsSource).toBe(mockAmdModuleNamedNoDepsExpected); - - const mockAmdModuleNamedWithDepsResult = await decorateSystemJSFetch( - originalFetch, - '/public/plugins/mockAmdModuleNamedWithDeps/module.js', - {} - ); - const mockAmdModuleNamedWithDepsSource = await mockAmdModuleNamedWithDepsResult.text(); - const mockAmdModuleNamedWithDepsExpected = `(function(define) { - ${mockAmdModuleNamedWithDeps} -})(window.__grafana_amd_define);`; - - expect(mockAmdModuleNamedWithDepsSource).toBe(mockAmdModuleNamedWithDepsExpected); - - const mockAmdModuleNamedWithDeps2Result = await decorateSystemJSFetch( - originalFetch, - '/public/plugins/mockAmdModuleNamedWithDeps2/module.js', - {} - ); - const mockAmdModuleNamedWithDeps2Source = await mockAmdModuleNamedWithDeps2Result.text(); - const mockAmdModuleNamedWithDeps2Expected = `(function(define) { - ${mockAmdModuleNamedWithDeps2} -})(window.__grafana_amd_define);`; - - expect(mockAmdModuleNamedWithDeps2Source).toBe(mockAmdModuleNamedWithDeps2Expected); - - const mockAmdModuleNamedWithDeps3Result = await decorateSystemJSFetch( - originalFetch, - '/public/plugins/mockAmdModuleNamedWithDeps3/module.js', - {} - ); - const mockAmdModuleNamedWithDeps3Source = await mockAmdModuleNamedWithDeps3Result.text(); - const mockAmdModuleNamedWithDeps3Expected = `(function(define) { - ${mockAmdModuleNamedWithDeps3} -})(window.__grafana_amd_define);`; - - expect(mockAmdModuleNamedWithDeps3Source).toBe(mockAmdModuleNamedWithDeps3Expected); - - const mockAmdModuleOnlyFunctionResult = await decorateSystemJSFetch( - originalFetch, - '/public/plugins/mockAmdModuleOnlyFunction/module.js', - {} - ); - const mockAmdModuleOnlyFunctionSource = await mockAmdModuleOnlyFunctionResult.text(); - const mockAmdModuleOnlyFunctionExpected = `(function(define) { - ${mockAmdModuleOnlyFunction} -})(window.__grafana_amd_define);`; - - expect(mockAmdModuleOnlyFunctionSource).toBe(mockAmdModuleOnlyFunctionExpected); - - const mockAmdModuleWithCommentsResult = await decorateSystemJSFetch( - originalFetch, - '/public/plugins/mockAmdModuleWithComments/module.js', - {} - ); - const mockAmdModuleWithCommentsSource = await mockAmdModuleWithCommentsResult.text(); - const mockAmdModuleWithCommentsExpected = `(function(define) { - ${mockAmdModuleWithComments} -})(window.__grafana_amd_define);`; - - expect(mockAmdModuleWithCommentsSource).toBe(mockAmdModuleWithCommentsExpected); - - const mockAmdModuleWithComments2Result = await decorateSystemJSFetch( - originalFetch, - '/public/plugins/mockAmdModuleWithComments2/module.js', - {} - ); - const mockAmdModuleWithComments2Source = await mockAmdModuleWithComments2Result.text(); - const mockAmdModuleWithComments2Expected = `(function(define) { - ${mockAmdModuleWithComments2} -})(window.__grafana_amd_define);`; - - expect(mockAmdModuleWithComments2Source).toBe(mockAmdModuleWithComments2Expected); - }); - it("doesn't wrap system modules in an AMD iife", async () => { - const url = '/public/plugins/mockSystemModule/module.js'; - const result = await decorateSystemJSFetch(originalFetch, url, {}); - const source = await result.text(); - - expect(source).toBe(mockSystemModule); - }); - it("doesn't wrap modules with a define method in an AMD iife", async () => { - const url = '/public/plugins/mockModuleWithDefineMethod/module.js'; - const result = await decorateSystemJSFetch(originalFetch, url, {}); - const source = await result.text(); - - expect(source).toBe(mockModuleWithDefineMethod); - }); it('only transforms plugin source code hosted on cdn with cdn paths', async () => { config.pluginsCDNBaseURL = 'http://my-cdn.com/plugins'; const cdnUrl = 'http://my-cdn.com/plugins/my-plugin/v1.0.0/public/plugins/my-plugin/module.js'; - const cdnResult = await decorateSystemJSFetch(originalFetch, cdnUrl, {}); + const cdnResult = await decorateSystemJSFetch(systemJSPrototype.fetch, cdnUrl, {}); const cdnSource = await cdnResult.text(); expect(cdnSource).toContain('var pluginPath = "http://my-cdn.com/plugins/my-plugin/v1.0.0/public/plugins/";'); const url = '/public/plugins/mockAmdModule/module.js'; - const result = await decorateSystemJSFetch(originalFetch, url, {}); + const result = await decorateSystemJSFetch(systemJSPrototype.fetch, url, {}); const source = await result.text(); expect(source).toContain('var pluginPath = "/public/plugins/";'); }); diff --git a/public/app/features/plugins/loader/systemjsHooks.ts b/public/app/features/plugins/loader/systemjsHooks.ts index 8bdbd723b118d..fd8ac2e10ee86 100644 --- a/public/app/features/plugins/loader/systemjsHooks.ts +++ b/public/app/features/plugins/loader/systemjsHooks.ts @@ -3,7 +3,7 @@ import { config, SystemJS } from '@grafana/runtime'; import { transformPluginSourceForCDN } from '../cdn/utils'; import { resolveWithCache } from './cache'; -import { LOAD_PLUGIN_CSS_REGEX, JS_CONTENT_TYPE_REGEX, AMD_MODULE_REGEX, SHARED_DEPENDENCY_PREFIX } from './constants'; +import { LOAD_PLUGIN_CSS_REGEX, JS_CONTENT_TYPE_REGEX, SHARED_DEPENDENCY_PREFIX } from './constants'; import { SystemJSWithLoaderHooks } from './types'; import { isHostedOnCDN } from './utils'; @@ -19,10 +19,6 @@ export async function decorateSystemJSFetch( const source = await res.text(); let transformedSrc = source; - if (AMD_MODULE_REGEX.test(transformedSrc)) { - transformedSrc = preventAMDLoaderCollision(source); - } - // JS files on the CDN need their asset paths transformed in the source if (isHostedOnCDN(res.url)) { const cdnTransformedSrc = transformPluginSourceForCDN({ url: res.url, source: transformedSrc }); @@ -84,11 +80,3 @@ function getBackWardsCompatibleUrl(url: string) { return hasValidFileExtension ? url : url + '.js'; } - -// This transform prevents a conflict between systemjs and requirejs which Monaco Editor -// depends on. See packages/grafana-runtime/src/utils/plugin.ts for more. -function preventAMDLoaderCollision(source: string) { - return `(function(define) { - ${source} -})(window.__grafana_amd_define);`; -} diff --git a/public/app/features/plugins/loader/utils.test.ts b/public/app/features/plugins/loader/utils.test.ts new file mode 100644 index 0000000000000..719e67d6d588c --- /dev/null +++ b/public/app/features/plugins/loader/utils.test.ts @@ -0,0 +1,31 @@ +import { config } from '@grafana/runtime'; + +import { resolveModulePath } from './utils'; + +describe('resolveModulePath', () => { + it.each` + value | expected + ${'http://localhost:3000/public/plugins/my-app-plugin/module.js'} | ${'http://localhost:3000/public/plugins/my-app-plugin/module.js'} + ${'/public/plugins/my-app-plugin/module.js'} | ${'/public/plugins/my-app-plugin/module.js'} + ${'public/plugins/my-app-plugin/module.js'} | ${'/public/plugins/my-app-plugin/module.js'} + `( + "Url correct formatting, when calling the rule with correct formatted value: '$value' then result should be '$expected'", + ({ value, expected }) => { + expect(resolveModulePath(value)).toBe(expected); + } + ); + + it.each` + value | expected + ${'http://localhost:3000/public/plugins/my-app-plugin/module.js'} | ${'http://localhost:3000/public/plugins/my-app-plugin/module.js'} + ${'/public/plugins/my-app-plugin/module.js'} | ${'/public/plugins/my-app-plugin/module.js'} + ${'public/plugins/my-app-plugin/module.js'} | ${'/grafana/public/plugins/my-app-plugin/module.js'} + `( + "Url correct formatting, when calling the rule with correct formatted value: '$value' then result should be '$expected'", + ({ value, expected }) => { + config.appSubUrl = '/grafana'; + + expect(resolveModulePath(value)).toBe(expected); + } + ); +}); diff --git a/public/app/features/plugins/loader/utils.ts b/public/app/features/plugins/loader/utils.ts index 470bc48733159..bfc44bd44d8fd 100644 --- a/public/app/features/plugins/loader/utils.ts +++ b/public/app/features/plugins/loader/utils.ts @@ -28,3 +28,15 @@ export function buildImportMap(importMap: Record<string, System.Module>) { export function isHostedOnCDN(path: string) { return Boolean(config.pluginsCDNBaseURL) && path.startsWith(config.pluginsCDNBaseURL); } + +// This function is used to dynamically prepend the appSubUrl in the frontend. +// This is required because if serve_from_sub_path is false the Image Renderer sets the subpath +// to an empty string and sets appurl to localhost which causes plugins to fail to load. +// https://github.com/grafana/grafana/issues/76180 +export function resolveModulePath(path: string) { + if (path.startsWith('http') || path.startsWith('/')) { + return path; + } + + return `${config.appSubUrl ?? ''}/${path}`; +} diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index b4599eca1bce5..0a2172f0b9df5 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -16,7 +16,7 @@ import { registerPluginInCache } from './loader/cache'; import { sharedDependenciesMap } from './loader/sharedDependencies'; import { decorateSystemJSFetch, decorateSystemJSResolve, decorateSystemJsOnload } from './loader/systemjsHooks'; import { SystemJSWithLoaderHooks } from './loader/types'; -import { buildImportMap } from './loader/utils'; +import { buildImportMap, resolveModulePath } from './loader/utils'; import { importPluginModuleInSandbox } from './sandbox/sandbox_plugin_loader'; import { isFrontendSandboxSupported } from './sandbox/utils'; @@ -29,6 +29,17 @@ const systemJSPrototype: SystemJSWithLoaderHooks = SystemJS.constructor.prototyp // the content of the plugin code at runtime which can only be done with fetch/eval. systemJSPrototype.shouldFetch = () => true; +const originalImport = systemJSPrototype.import; +// Hook Systemjs import to support plugins that only have a default export. +systemJSPrototype.import = function (...args: Parameters<typeof originalImport>) { + return originalImport.apply(this, args).then((module) => { + if (module && module.__useDefault) { + return module.default; + } + return module; + }); +}; + const systemJSFetch = systemJSPrototype.fetch; systemJSPrototype.fetch = function (url: string, options?: Record<string, unknown>) { return decorateSystemJSFetch(systemJSFetch, url, options); @@ -67,12 +78,14 @@ export async function importPluginModule({ } } + let modulePath = resolveModulePath(path); + // the sandboxing environment code cannot work in nodejs and requires a real browser if (await isFrontendSandboxSupported({ isAngular, pluginId })) { return importPluginModuleInSandbox({ pluginId }); } - return SystemJS.import(path); + return SystemJS.import(modulePath); } export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise<GenericDataSourcePlugin> { diff --git a/public/app/features/plugins/sandbox/README.md b/public/app/features/plugins/sandbox/README.md index d2fb03e068479..cff6aa0236b13 100644 --- a/public/app/features/plugins/sandbox/README.md +++ b/public/app/features/plugins/sandbox/README.md @@ -22,7 +22,7 @@ When a plugin is marked for loading, grafana decides if it should load it in the - If a plugin is marked to load in a sandbox, first the source code is downloaded with `fetch`, then pre-processed to adjust sourceMaps and CDNs and finally evaluated inside a new near-membrane virtual environment. In either case, Grafana receives a pluginExport object that later uses to initialize plugins. For Grafana's core, this -pluginExport is idential in functionality and properties regardless of the loading method. +pluginExport is identical in functionality and properties regardless of the loading method. # Plugin execution @@ -64,7 +64,7 @@ those plugins that use web workers. Performance is still under tests when this w Distortions is the mechanism to intercept calls from the child realm code to JS APIS and DOM APIs. e.g: `Array.map` or `document.getElement`. -Distortions allow to replace the function that will execute inside the child realm wnen the function is invoked. +Distortions allow to replace the function that will execute inside the child realm when the function is invoked. Distortions also allow to intercept the exchange of objects between the child realm and the incubator realm, we can, for example, inspect all DOM elements access and generally speaking all objects that go to the child realm. diff --git a/public/app/features/plugins/sandbox/code_loader.ts b/public/app/features/plugins/sandbox/code_loader.ts index 7f22aafbeb283..3f2d614bf4196 100644 --- a/public/app/features/plugins/sandbox/code_loader.ts +++ b/public/app/features/plugins/sandbox/code_loader.ts @@ -2,7 +2,7 @@ import { PluginMeta, patchArrayVectorProrotypeMethods } from '@grafana/data'; import { transformPluginSourceForCDN } from '../cdn/utils'; import { resolveWithCache } from '../loader/cache'; -import { isHostedOnCDN } from '../loader/utils'; +import { isHostedOnCDN, resolveModulePath } from '../loader/utils'; import { SandboxEnvironment } from './types'; @@ -60,9 +60,10 @@ export async function getPluginCode(meta: PluginMeta): Promise<string> { }); return pluginCode; } else { - // local plugin. resolveWithCache will append a query parameter with its version - // to ensure correct cached version is served - const pluginCodeUrl = resolveWithCache(meta.module); + let modulePath = resolveModulePath(meta.module); + // resolveWithCache will append a query parameter with its version + // to ensure correct cached version is served for local plugins + const pluginCodeUrl = resolveWithCache(modulePath); const response = await fetch(pluginCodeUrl); let pluginCode = await response.text(); pluginCode = transformPluginSourceForCDN({ diff --git a/public/app/features/plugins/sandbox/distortion_map.ts b/public/app/features/plugins/sandbox/distortion_map.ts index 11b4392a37a81..a883b9a763f38 100644 --- a/public/app/features/plugins/sandbox/distortion_map.ts +++ b/public/app/features/plugins/sandbox/distortion_map.ts @@ -7,6 +7,7 @@ import { Monaco } from '@grafana/ui'; import { loadScriptIntoSandbox } from './code_loader'; import { forbiddenElements } from './constants'; +import { recursivePatchObjectAsLiveTarget } from './document_sandbox'; import { SandboxEnvironment } from './types'; import { logWarning, unboxRegexesFromMembraneProxy } from './utils'; @@ -85,6 +86,7 @@ export function getGeneralSandboxDistortionMap() { distortDocument(generalDistortionMap); distortMonacoEditor(generalDistortionMap); distortPostMessage(generalDistortionMap); + distortLodash(generalDistortionMap); } return generalDistortionMap; } @@ -526,7 +528,32 @@ async function distortPostMessage(distortions: DistortionMap) { * or because the libraries we want to patch are lazy-loaded and we don't have access to their definitions. * We put here only distortions that can't be static because they are dynamicly loaded */ -export function distortLiveApis(originalValue: ProxyTarget): ProxyTarget | undefined { +export function distortLiveApis(_originalValue: ProxyTarget): ProxyTarget | undefined { distortMonacoEditor(generalDistortionMap); return; } + +export function distortLodash(distortions: DistortionMap) { + /** + * This is a distortion for lodash clone Deep function + * because lodash deep clones execute in the blue realm + * it returns objects that plugins can't modify because they are not + * lived tracked. + * + * We need to patch it so that plugins can modify the cloned object + * in places such as query editors. + * + */ + function cloneDeepDistortion(originalValue: unknown) { + // here to please typescript, this if is never true + if (!isFunction(originalValue)) { + return originalValue; + } + return function (this: unknown, ...args: unknown[]) { + const cloned = originalValue.apply(this, args); + recursivePatchObjectAsLiveTarget(cloned); + return cloned; + }; + } + distortions.set(cloneDeep, cloneDeepDistortion); +} diff --git a/public/app/features/plugins/sandbox/document_sandbox.ts b/public/app/features/plugins/sandbox/document_sandbox.ts index 6a61407adedd0..9dcb1a3a1aa47 100644 --- a/public/app/features/plugins/sandbox/document_sandbox.ts +++ b/public/app/features/plugins/sandbox/document_sandbox.ts @@ -2,7 +2,7 @@ import { isNearMembraneProxy, ProxyTarget } from '@locker/near-membrane-shared'; import { cloneDeep } from 'lodash'; import Prism from 'prismjs'; -import { DataSourceApi } from '@grafana/data'; +import { CustomVariableSupport, DataSourceApi } from '@grafana/data'; import { config } from '@grafana/runtime'; import { forbiddenElements } from './constants'; @@ -87,6 +87,32 @@ export function markDomElementStyleAsALiveTarget(el: Element) { } } +export function recursivePatchObjectAsLiveTarget(obj: unknown) { + if (!obj) { + return; + } + if (Array.isArray(obj)) { + obj.forEach(recursivePatchObjectAsLiveTarget); + unconditionallyPatchObjectAsLiveTarget(obj); + } else if (typeof obj === 'object') { + Object.values(obj).forEach(recursivePatchObjectAsLiveTarget); + unconditionallyPatchObjectAsLiveTarget(obj); + } +} + +function unconditionallyPatchObjectAsLiveTarget(obj: unknown) { + if (!obj) { + return; + } + // do not patch it twice + if (Object.hasOwn(obj, SANDBOX_LIVE_VALUE)) { + return obj; + } + + Reflect.defineProperty(obj, SANDBOX_LIVE_VALUE, {}); + return obj; +} + /** * Some specific near membrane proxies interfere with plugins * an example of this is React class components state and their fast life cycles @@ -112,7 +138,7 @@ export function patchObjectAsLiveTarget(obj: unknown) { !(obj instanceof Function) && // conditions for allowed objects // react class components - (isReactClassComponent(obj) || obj instanceof DataSourceApi) + (isReactClassComponent(obj) || obj instanceof DataSourceApi || obj instanceof CustomVariableSupport) ) { Reflect.defineProperty(obj, SANDBOX_LIVE_VALUE, {}); } else { diff --git a/public/app/features/plugins/sandbox/utils.ts b/public/app/features/plugins/sandbox/utils.ts index 485b219539f31..30aab3418812d 100644 --- a/public/app/features/plugins/sandbox/utils.ts +++ b/public/app/features/plugins/sandbox/utils.ts @@ -3,12 +3,7 @@ import React from 'react'; import { PluginSignatureType, PluginType } from '@grafana/data'; import { LogContext } from '@grafana/faro-web-sdk'; -import { - logWarning as logWarningRuntime, - logError as logErrorRuntime, - logInfo as logInfoRuntime, - config, -} from '@grafana/runtime'; +import { config, createMonitoringLogger } from '@grafana/runtime'; import { getPluginSettings } from '../pluginSettings'; @@ -24,35 +19,22 @@ export function assertNever(x: never): never { throw new Error(`Unexpected object: ${x}. This should never happen.`); } +const sandboxLogger = createMonitoringLogger('sandbox', { monitorOnly: String(monitorOnly) }); + export function isReactClassComponent(obj: unknown): obj is React.Component { return obj instanceof React.Component; } export function logWarning(message: string, context?: LogContext) { - context = { - ...context, - source: 'sandbox', - monitorOnly: String(monitorOnly), - }; - logWarningRuntime(message, context); + sandboxLogger.logWarning(message, context); } export function logError(error: Error, context?: LogContext) { - context = { - ...context, - source: 'sandbox', - monitorOnly: String(monitorOnly), - }; - logErrorRuntime(error, context); + sandboxLogger.logError(error, context); } export function logInfo(message: string, context?: LogContext) { - context = { - ...context, - source: 'sandbox', - monitorOnly: String(monitorOnly), - }; - logInfoRuntime(message, context); + sandboxLogger.logInfo(message, context); } export async function isFrontendSandboxSupported({ diff --git a/public/app/features/plugins/sql/.eslintrc b/public/app/features/plugins/sql/.eslintrc deleted file mode 100644 index f0bce9b7b6336..0000000000000 --- a/public/app/features/plugins/sql/.eslintrc +++ /dev/null @@ -1,10 +0,0 @@ -{ - "rules": { - "no-restricted-imports": [ - "error", - { - "patterns": ["app/*"] - } - ] - } -} diff --git a/public/app/features/plugins/sql/index.ts b/public/app/features/plugins/sql/index.ts deleted file mode 100644 index 195d95f598d5a..0000000000000 --- a/public/app/features/plugins/sql/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './components'; -export * from './types'; diff --git a/public/app/features/plugins/tests/datasource_srv.test.ts b/public/app/features/plugins/tests/datasource_srv.test.ts index 6c348c672c7a5..f9ec6eba97e0d 100644 --- a/public/app/features/plugins/tests/datasource_srv.test.ts +++ b/public/app/features/plugins/tests/datasource_srv.test.ts @@ -5,11 +5,12 @@ import { DataSourcePluginMeta, ScopedVars, } from '@grafana/data'; +import { TemplateSrv } from '@grafana/runtime'; import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; import { DatasourceSrv, getNameOrUid } from 'app/features/plugins/datasource_srv'; // Datasource variable $datasource with current value 'BBB' -const templateSrv: any = { +const templateSrv = { getVariables: () => [ { type: 'datasource', @@ -43,7 +44,7 @@ const templateSrv: any = { result = result.replace('${datasourceDefault}', 'default'); return result; }, -}; +} as TemplateSrv; class TestDataSource { constructor(public instanceSettings: DataSourceInstanceSettings) {} diff --git a/public/app/features/plugins/utils.ts b/public/app/features/plugins/utils.ts index c6e1ba8bf5ccf..075fc3c254236 100644 --- a/public/app/features/plugins/utils.ts +++ b/public/app/features/plugins/utils.ts @@ -1,4 +1,5 @@ import { GrafanaPlugin, NavModel, NavModelItem, PanelPluginMeta, PluginType } from '@grafana/data'; +import { createMonitoringLogger } from '@grafana/runtime'; import { importPanelPluginFromMeta } from './importPanelPlugin'; import { getPluginSettings } from './pluginSettings'; @@ -83,3 +84,5 @@ export function buildPluginSectionNav( return { main: copiedPluginNavSection, node: activePage ?? copiedPluginNavSection }; } + +export const pluginsLogger = createMonitoringLogger('features.plugins'); diff --git a/public/app/features/profile/ChangePasswordForm.tsx b/public/app/features/profile/ChangePasswordForm.tsx index 7f63b646ddb1d..a4aa7cd308a8b 100644 --- a/public/app/features/profile/ChangePasswordForm.tsx +++ b/public/app/features/profile/ChangePasswordForm.tsx @@ -1,7 +1,12 @@ -import { css } from '@emotion/css'; -import React from 'react'; +import React, { useState } from 'react'; -import { Button, Field, Form, HorizontalGroup, LinkButton } from '@grafana/ui'; +import { Button, Field, HorizontalGroup, LinkButton } from '@grafana/ui'; +import { Form } from 'app/core/components/Form/Form'; +import { + ValidationLabels, + strongPasswordValidations, + strongPasswordValidationRegister, +} from 'app/core/components/ValidationLabels/ValidationLabels'; import config from 'app/core/config'; import { t, Trans } from 'app/core/internationalization'; import { UserDTO } from 'app/types'; @@ -17,6 +22,9 @@ export interface Props { } export const ChangePasswordForm = ({ user, onChangePassword, isSaving }: Props) => { + const [displayValidationLabels, setDisplayValidationLabels] = useState(false); + const [pristine, setPristine] = useState(true); + const { disableLoginForm } = config; const authSource = user.authLabels?.length && user.authLabels[0]; @@ -38,85 +46,89 @@ export const ChangePasswordForm = ({ user, onChangePassword, isSaving }: Props) } return ( - <div - className={css` - max-width: 400px; - `} - > - <Form onSubmit={onChangePassword}> - {({ register, errors, getValues }) => { - return ( - <> - <Field - label={t('profile.change-password.old-password-label', 'Old password')} - invalid={!!errors.oldPassword} - error={errors?.oldPassword?.message} - > - <PasswordField - id="current-password" - autoComplete="current-password" - {...register('oldPassword', { - required: t('profile.change-password.old-password-required', 'Old password is required'), - })} - /> - </Field> - - <Field - label={t('profile.change-password.new-password-label', 'New password')} - invalid={!!errors.newPassword} - error={errors?.newPassword?.message} - > - <PasswordField - id="new-password" - autoComplete="new-password" - {...register('newPassword', { - required: t('profile.change-password.new-password-required', 'New password is required'), - validate: { - confirm: (v) => - v === getValues().confirmNew || - t('profile.change-password.passwords-must-match', 'Passwords must match'), - old: (v) => - v !== getValues().oldPassword || - t( - 'profile.change-password.new-password-same-as-old', - "New password can't be the same as the old one." - ), - }, - })} - /> - </Field> + <Form onSubmit={onChangePassword} maxWidth={400}> + {({ register, errors, getValues, watch }) => { + const newPassword = watch('newPassword'); + return ( + <> + <Field + label={t('profile.change-password.old-password-label', 'Old password')} + invalid={!!errors.oldPassword} + error={errors?.oldPassword?.message} + > + <PasswordField + id="current-password" + autoComplete="current-password" + {...register('oldPassword', { + required: t('profile.change-password.old-password-required', 'Old password is required'), + })} + /> + </Field> - <Field - label={t('profile.change-password.confirm-password-label', 'Confirm password')} - invalid={!!errors.confirmNew} - error={errors?.confirmNew?.message} - > - <PasswordField - id="confirm-new-password" - autoComplete="new-password" - {...register('confirmNew', { - required: t( - 'profile.change-password.confirm-password-required', - 'New password confirmation is required' - ), - validate: (v) => - v === getValues().newPassword || + <Field + label={t('profile.change-password.new-password-label', 'New password')} + invalid={!!errors.newPassword} + error={errors?.newPassword?.message} + > + <PasswordField + id="new-password" + autoComplete="new-password" + onFocus={() => setDisplayValidationLabels(true)} + {...register('newPassword', { + onBlur: () => setPristine(false), + required: t('profile.change-password.new-password-required', 'New password is required'), + validate: { + strongPasswordValidationRegister, + confirm: (v) => + v === getValues().confirmNew || t('profile.change-password.passwords-must-match', 'Passwords must match'), - })} - /> - </Field> - <HorizontalGroup> - <Button variant="primary" disabled={isSaving} type="submit"> - <Trans i18nKey="profile.change-password.change-password-button">Change Password</Trans> - </Button> - <LinkButton variant="secondary" href={`${config.appSubUrl}/profile`} fill="outline"> - <Trans i18nKey="profile.change-password.cancel-button">Cancel</Trans> - </LinkButton> - </HorizontalGroup> - </> - ); - }} - </Form> - </div> + old: (v) => + v !== getValues().oldPassword || + t( + 'profile.change-password.new-password-same-as-old', + "New password can't be the same as the old one." + ), + }, + })} + /> + </Field> + {displayValidationLabels && ( + <ValidationLabels + pristine={pristine} + password={newPassword} + strongPasswordValidations={strongPasswordValidations} + /> + )} + <Field + label={t('profile.change-password.confirm-password-label', 'Confirm password')} + invalid={!!errors.confirmNew} + error={errors?.confirmNew?.message} + > + <PasswordField + id="confirm-new-password" + autoComplete="new-password" + {...register('confirmNew', { + required: t( + 'profile.change-password.confirm-password-required', + 'New password confirmation is required' + ), + validate: (v) => + v === getValues().newPassword || + t('profile.change-password.passwords-must-match', 'Passwords must match'), + })} + /> + </Field> + <HorizontalGroup> + <Button variant="primary" disabled={isSaving} type="submit"> + <Trans i18nKey="profile.change-password.change-password-button">Change Password</Trans> + </Button> + <LinkButton variant="secondary" href={`${config.appSubUrl}/profile`} fill="outline"> + <Trans i18nKey="profile.change-password.cancel-button">Cancel</Trans> + </LinkButton> + </HorizontalGroup> + </> + ); + }} + </Form> ); }; diff --git a/public/app/features/profile/UserProfileEditForm.tsx b/public/app/features/profile/UserProfileEditForm.tsx index 371a0a0f57854..dcde61b119906 100644 --- a/public/app/features/profile/UserProfileEditForm.tsx +++ b/public/app/features/profile/UserProfileEditForm.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { selectors } from '@grafana/e2e-selectors'; -import { Button, Field, FieldSet, Form, Icon, Input, Tooltip } from '@grafana/ui'; +import { Button, Field, FieldSet, Icon, Input, Tooltip } from '@grafana/ui'; +import { Form } from 'app/core/components/Form/Form'; import config from 'app/core/config'; import { t, Trans } from 'app/core/internationalization'; import { UserDTO } from 'app/types'; diff --git a/public/app/features/profile/UserProfileEditPage.tsx b/public/app/features/profile/UserProfileEditPage.tsx index 0b9fbe7d75f74..ffb9c64b00c8f 100644 --- a/public/app/features/profile/UserProfileEditPage.tsx +++ b/public/app/features/profile/UserProfileEditPage.tsx @@ -79,7 +79,6 @@ export function UserProfileEditPage({ const extensionComponents = useMemo(() => { const { extensions } = getPluginComponentExtensions({ extensionPointId: PluginExtensionPoints.UserProfileTab, - context: {}, }); return extensions; @@ -126,7 +125,7 @@ export function UserProfileEditPage({ const UserProfileWithTabs = () => ( <div data-testid={selectors.components.UserProfile.extensionPointTabs}> - <VerticalGroup spacing="md"> + <Stack direction="column" gap={2}> <TabsBar> {tabs.map(({ id, title }) => { return ( @@ -160,7 +159,7 @@ export function UserProfileEditPage({ return null; })} </TabContent> - </VerticalGroup> + </Stack> </div> ); diff --git a/public/app/features/query/components/QueryEditorRow.test.ts b/public/app/features/query/components/QueryEditorRow.test.tsx similarity index 72% rename from public/app/features/query/components/QueryEditorRow.test.ts rename to public/app/features/query/components/QueryEditorRow.test.tsx index 1c7eb61f3932a..42e98252b9a30 100644 --- a/public/app/features/query/components/QueryEditorRow.test.ts +++ b/public/app/features/query/components/QueryEditorRow.test.tsx @@ -1,8 +1,31 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import React, { PropsWithChildren } from 'react'; import { DataQueryRequest, dateTime, LoadingState, PanelData, toDataFrame } from '@grafana/data'; +import { DataQuery } from '@grafana/schema'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; -import { filterPanelDataToQuery, QueryEditorRow } from './QueryEditorRow'; +import { DataSourceType } from '../../alerting/unified/utils/datasource'; + +import { filterPanelDataToQuery, Props, QueryEditorRow } from './QueryEditorRow'; + +const mockDS = mockDataSource({ + name: 'test', + type: DataSourceType.Alertmanager, +}); +jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => { + return { + getDataSourceSrv: () => ({ + get: () => Promise.resolve(mockDS), + getList: () => {}, + getInstanceSettings: () => mockDS, + }), + }; +}); +// Draggable fails to render in tests, so we mock it out +jest.mock('app/core/components/QueryOperationRow/QueryOperationRow', () => ({ + QueryOperationRow: (props: PropsWithChildren) => <div>{props.children}</div>, +})); function makePretendRequest(requestId: string, subRequests?: DataQueryRequest[]): DataQueryRequest { return { @@ -219,3 +242,50 @@ describe('frame results with warnings', () => { expect(warningsComponent).toBe(null); }); }); +describe('QueryEditorRow', () => { + const props = (data: PanelData): Props<DataQuery> => ({ + dataSource: mockDS, + query: { refId: 'B' }, + data, + queries: [{ refId: 'B' }], + id: 'test', + onAddQuery: jest.fn(), + onRunQuery: jest.fn(), + onChange: jest.fn(), + onRemoveQuery: jest.fn(), + index: 0, + }); + it('should display error message in corresponding panel', async () => { + const data = { + state: LoadingState.Error, + series: [], + errors: [{ message: 'Error!!', refId: 'B' }], + timeRange: { from: dateTime(), to: dateTime(), raw: { from: 'now-1d', to: 'now' } }, + }; + render(<QueryEditorRow {...props(data)} />); + expect(await screen.findByText('Error!!')).toBeInTheDocument(); + }); + it('should display error message in corresponding panel if only error field is provided', async () => { + const data = { + state: LoadingState.Error, + series: [], + error: { message: 'Error!!', refId: 'B' }, + errors: [], + timeRange: { from: dateTime(), to: dateTime(), raw: { from: 'now-1d', to: 'now' } }, + }; + render(<QueryEditorRow {...props(data)} />); + expect(await screen.findByText('Error!!')).toBeInTheDocument(); + }); + it('should not display error message if error.refId doesnt match', async () => { + const data = { + state: LoadingState.Error, + series: [], + errors: [{ message: 'Error!!', refId: 'A' }], + timeRange: { from: dateTime(), to: dateTime(), raw: { from: 'now-1d', to: 'now' } }, + }; + render(<QueryEditorRow {...props(data)} />); + await waitFor(() => { + expect(screen.queryByText('Error!!')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index 8a7c5877e4e59..9f3489dedb4c0 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -42,7 +42,7 @@ import { RowActionComponents } from './QueryActionComponent'; import { QueryEditorRowHeader } from './QueryEditorRowHeader'; import { QueryErrorAlert } from './QueryErrorAlert'; -interface Props<TQuery extends DataQuery> { +export interface Props<TQuery extends DataQuery> { data: PanelData; query: TQuery; queries: TQuery[]; @@ -405,6 +405,7 @@ export class QueryEditorRow<TQuery extends DataQuery> extends PureComponent<Prop return ( <Badge + key="query-warning" color="orange" icon="exclamation-triangle" text={ @@ -510,7 +511,8 @@ export class QueryEditorRow<TQuery extends DataQuery> extends PureComponent<Prop const { query, index, visualization, collapsable } = this.props; const { datasource, showingHelp, data } = this.state; const isDisabled = query.hide; - + const error = + data?.error && data.error.refId === query.refId ? data.error : data?.errors?.find((e) => e.refId === query.refId); const rowClasses = classNames('query-editor-row', { 'query-editor-row--disabled': isDisabled, 'gf-form-disabled': isDisabled, @@ -547,7 +549,7 @@ export class QueryEditorRow<TQuery extends DataQuery> extends PureComponent<Prop )} {editor} </ErrorBoundaryAlert> - {data?.error && data.error.refId === query.refId && <QueryErrorAlert error={data.error} />} + {error && <QueryErrorAlert error={error} />} {visualization} </div> </QueryOperationRow> diff --git a/public/app/features/query/components/QueryEditorRowHeader.test.tsx b/public/app/features/query/components/QueryEditorRowHeader.test.tsx index a3fdae682bfa5..fce8da86b98fa 100644 --- a/public/app/features/query/components/QueryEditorRowHeader.test.tsx +++ b/public/app/features/query/components/QueryEditorRowHeader.test.tsx @@ -1,7 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { openMenu } from 'react-select-event'; import { DataSourceInstanceSettings } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -84,7 +83,7 @@ describe('QueryEditorRowHeader', () => { renderScenario({ onChangeDataSource: () => {} }); const dsSelect = screen.getByTestId(selectors.components.DataSourcePicker.container).querySelector('input')!; - openMenu(dsSelect); + await userEvent.click(dsSelect); expect(await screen.findByText('${dsVariable}')).toBeInTheDocument(); }); }); diff --git a/public/app/features/query/components/QueryGroup.tsx b/public/app/features/query/components/QueryGroup.tsx index 8b8aab4222c9e..1ec9dbad3451a 100644 --- a/public/app/features/query/components/QueryGroup.tsx +++ b/public/app/features/query/components/QueryGroup.tsx @@ -23,7 +23,7 @@ import { DataSourceModal } from 'app/features/datasources/components/picker/Data import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource'; import { AngularDeprecationPluginNotice } from 'app/features/plugins/angularDeprecation/AngularDeprecationPluginNotice'; -import { DashboardQueryEditor, isSharedDashboardQuery } from 'app/plugins/datasource/dashboard'; +import { isSharedDashboardQuery } from 'app/plugins/datasource/dashboard'; import { GrafanaQuery } from 'app/plugins/datasource/grafana/types'; import { QueryGroupOptions } from 'app/types'; @@ -122,7 +122,7 @@ export class QueryGroup extends PureComponent<Props, State> { defaultDataSource, }); } catch (error) { - console.log('failed to load data source', error); + console.error('failed to load data source', error); } } @@ -252,16 +252,6 @@ export class QueryGroup extends PureComponent<Props, State> { renderQueries(dsSettings: DataSourceInstanceSettings) { const { onRunQueries } = this.props; const { data, queries } = this.state; - if (isSharedDashboardQuery(dsSettings.name)) { - return ( - <DashboardQueryEditor - queries={queries} - panelData={data} - onChange={this.onQueriesChange} - onRunQueries={onRunQueries} - /> - ); - } return ( <div aria-label={selectors.components.QueryTab.content}> @@ -507,7 +497,7 @@ function DataSourcePickerWithPrompt({ options, onChange, ...otherProps }: DataSo return ( <> - {isDataSourceModalOpen && config.featureToggles.advancedDataSourcePicker && ( + {isDataSourceModalOpen && ( <DataSourceModal {...commonProps} onDismiss={() => setIsDataSourceModalOpen(false)}></DataSourceModal> )} diff --git a/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.test.ts b/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.test.ts deleted file mode 100644 index 1caa4b12a4f9e..0000000000000 --- a/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { AlertState, AlertStateInfo, getDefaultTimeRange, TimeRange } from '@grafana/data'; -import { backendSrv } from 'app/core/services/backend_srv'; - -import { silenceConsoleOutput } from '../../../../../test/core/utils/silenceConsoleOutput'; -import * as store from '../../../../store/store'; - -import { AlertStatesWorker } from './AlertStatesWorker'; -import { DashboardQueryRunnerOptions } from './types'; - -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getBackendSrv: () => backendSrv, -})); - -function getDefaultOptions(): DashboardQueryRunnerOptions { - const dashboard: any = { id: 'an id', panels: [{ alert: {} }] }; - const range = getDefaultTimeRange(); - - return { dashboard, range }; -} - -function getTestContext() { - jest.clearAllMocks(); - const dispatchMock = jest.spyOn(store, 'dispatch'); - const options = getDefaultOptions(); - const getMock = jest.spyOn(backendSrv, 'get'); - - return { getMock, options, dispatchMock }; -} - -describe('AlertStatesWorker', () => { - const worker = new AlertStatesWorker(); - - describe('when canWork is called with correct props', () => { - it('then it should return true', () => { - const options = getDefaultOptions(); - - expect(worker.canWork(options)).toBe(true); - }); - }); - - describe('when canWork is called with no dashboard id', () => { - it('then it should return false', () => { - const dashboard: any = {}; - const options = { ...getDefaultOptions(), dashboard }; - - expect(worker.canWork(options)).toBe(false); - }); - }); - - describe('when canWork is called with wrong range', () => { - it('then it should return false', () => { - const defaultRange = getDefaultTimeRange(); - const range: TimeRange = { ...defaultRange, raw: { ...defaultRange.raw, to: 'now-6h' } }; - const options = { ...getDefaultOptions(), range }; - - expect(worker.canWork(options)).toBe(false); - }); - }); - - describe('when canWork is called for dashboard with no alert panels', () => { - it('then it should return false', () => { - const options = getDefaultOptions(); - options.dashboard.panels.forEach((panel) => delete panel.alert); - expect(worker.canWork(options)).toBe(false); - }); - }); - - describe('when run is called with incorrect props', () => { - it('then it should return the correct results', async () => { - const { getMock, options } = getTestContext(); - const dashboard: any = {}; - - await expect(worker.work({ ...options, dashboard })).toEmitValuesWith((received) => { - expect(received).toHaveLength(1); - const results = received[0]; - expect(results).toEqual({ alertStates: [], annotations: [] }); - expect(getMock).not.toHaveBeenCalled(); - }); - }); - }); - - describe('when run is called with correct props and request is successful', () => { - it('then it should return the correct results', async () => { - const getResults: AlertStateInfo[] = [ - { id: 1, state: AlertState.Alerting, dashboardId: 1, panelId: 1 }, - { id: 2, state: AlertState.Alerting, dashboardId: 1, panelId: 2 }, - ]; - const { getMock, options } = getTestContext(); - getMock.mockResolvedValue(getResults); - - await expect(worker.work(options)).toEmitValuesWith((received) => { - expect(received).toHaveLength(1); - const results = received[0]; - expect(results).toEqual({ alertStates: getResults, annotations: [] }); - expect(getMock).toHaveBeenCalledTimes(1); - }); - }); - }); - - describe('when run is called with correct props and request fails', () => { - silenceConsoleOutput(); - it('then it should return the correct results', async () => { - const { getMock, options, dispatchMock } = getTestContext(); - getMock.mockRejectedValue({ message: 'An error' }); - - await expect(worker.work(options)).toEmitValuesWith((received) => { - expect(received).toHaveLength(1); - const results = received[0]; - expect(results).toEqual({ alertStates: [], annotations: [] }); - expect(getMock).toHaveBeenCalledTimes(1); - expect(dispatchMock).toHaveBeenCalledTimes(1); - }); - }); - }); - - describe('when run is called with correct props and request is cancelled', () => { - silenceConsoleOutput(); - it('then it should return the correct results', async () => { - const { getMock, options, dispatchMock } = getTestContext(); - getMock.mockRejectedValue({ cancelled: true }); - - await expect(worker.work(options)).toEmitValuesWith((received) => { - expect(received).toHaveLength(1); - const results = received[0]; - expect(results).toEqual({ alertStates: [], annotations: [] }); - expect(getMock).toHaveBeenCalledTimes(1); - expect(dispatchMock).not.toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.ts b/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.ts deleted file mode 100644 index edefd0710e01f..0000000000000 --- a/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { from, Observable } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; - -import { getBackendSrv } from '@grafana/runtime'; - -import { DashboardQueryRunnerOptions, DashboardQueryRunnerWorker, DashboardQueryRunnerWorkerResult } from './types'; -import { emptyResult, handleDashboardQueryRunnerWorkerError } from './utils'; - -export class AlertStatesWorker implements DashboardQueryRunnerWorker { - canWork({ dashboard, range }: DashboardQueryRunnerOptions): boolean { - if (!dashboard.id) { - return false; - } - - if (range.raw.to !== 'now') { - return false; - } - - // if dashboard has no alerts, no point to query alert states - if (!dashboard.panels.find((panel) => !!panel.alert)) { - return false; - } - - return true; - } - - work(options: DashboardQueryRunnerOptions): Observable<DashboardQueryRunnerWorkerResult> { - if (!this.canWork(options)) { - return emptyResult(); - } - - const { dashboard } = options; - return from( - getBackendSrv().get( - '/api/alerts/states-for-dashboard', - { - dashboardId: dashboard.id, - }, - `dashboard-query-runner-alert-states-${dashboard.id}` - ) - ).pipe( - map((alertStates) => { - return { alertStates, annotations: [] }; - }), - catchError(handleDashboardQueryRunnerWorkerError) - ); - } -} diff --git a/public/app/features/query/state/DashboardQueryRunner/AnnotationsQueryRunner.test.ts b/public/app/features/query/state/DashboardQueryRunner/AnnotationsQueryRunner.test.ts index 264f431e4d887..b18734610917e 100644 --- a/public/app/features/query/state/DashboardQueryRunner/AnnotationsQueryRunner.test.ts +++ b/public/app/features/query/state/DashboardQueryRunner/AnnotationsQueryRunner.test.ts @@ -1,6 +1,7 @@ import { Observable, of, throwError } from 'rxjs'; import { AnnotationQuery, DataSourceApi, getDefaultTimeRange } from '@grafana/data'; +import { AnnotationQueryResponse } from 'app/features/annotations/types'; import { createDashboardModelFixture } from 'app/features/dashboard/state/__fixtures__/dashboardFixtures'; import { silenceConsoleOutput } from '../../../../../test/core/utils/silenceConsoleOutput'; @@ -23,7 +24,7 @@ function getDefaultOptions(): AnnotationQueryRunnerOptions { return { annotation, datasource, dashboard, range }; } -function getTestContext(result: Observable<any> = toAsyncOfResult({ events: [{ id: '1' }] })) { +function getTestContext(result: Observable<AnnotationQueryResponse> = toAsyncOfResult({ events: [{ id: '1' }] })) { jest.clearAllMocks(); const dispatchMock = jest.spyOn(store, 'dispatch'); const options = getDefaultOptions(); @@ -94,7 +95,7 @@ describe('AnnotationsQueryRunner', () => { describe('but result is missing events prop', () => { it('then it should return the correct results', async () => { - const { options, executeAnnotationQueryMock } = getTestContext(of({ id: '1' })); + const { options, executeAnnotationQueryMock } = getTestContext(of({ id: '1' } as AnnotationQueryResponse)); await expect(runner.run(options)).toEmitValuesWith((received) => { expect(received).toHaveLength(1); diff --git a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts index 3604fb574d050..e88b7a5a9b3c9 100644 --- a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts +++ b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts @@ -1,9 +1,13 @@ import { throwError } from 'rxjs'; import { delay, first } from 'rxjs/operators'; -import { AlertState, AlertStateInfo } from '@grafana/data'; +import { AlertState } from '@grafana/data'; import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime'; +import { grantUserPermissions } from 'app/features/alerting/unified/mocks'; +import { Annotation } from 'app/features/alerting/unified/utils/constants'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { AccessControlAction } from 'app/types'; +import { PromAlertingRuleState, PromRulesResponse, PromRuleType } from 'app/types/unified-alerting-dto'; import { silenceConsoleOutput } from '../../../../../test/core/utils/silenceConsoleOutput'; import { backendSrv } from '../../../../core/services/backend_srv'; @@ -18,6 +22,10 @@ jest.mock('@grafana/runtime', () => ({ getBackendSrv: () => backendSrv, })); +beforeEach(() => { + grantUserPermissions([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]); +}); + function getTestContext() { jest.clearAllMocks(); const timeSrvMock = { timeRange: jest.fn() } as unknown as TimeSrv; @@ -25,10 +33,55 @@ function getTestContext() { // These tests are setup so all the workers and runners are invoked once, this wouldn't be the case in real life const runner = createDashboardQueryRunner({ dashboard: options.dashboard, timeSrv: timeSrvMock }); - const getResults: AlertStateInfo[] = [ - { id: 1, state: AlertState.Alerting, dashboardId: 1, panelId: 1 }, - { id: 2, state: AlertState.Alerting, dashboardId: 1, panelId: 2 }, - ]; + const getResults: PromRulesResponse = { + status: 'success', + data: { + groups: [ + { + name: 'my-group', + rules: [ + { + name: 'my alert', + state: PromAlertingRuleState.Firing, + query: 'foo > 1', + type: PromRuleType.Alerting, + annotations: { + [Annotation.dashboardUID]: '1', + [Annotation.panelID]: '1', + }, + health: 'ok', + labels: {}, + }, + ], + interval: 300, + file: 'my-namespace', + }, + { + name: 'another-group', + rules: [ + { + name: 'another alert', + query: 'foo > 1', + state: PromAlertingRuleState.Firing, + type: PromRuleType.Alerting, + annotations: { + [Annotation.dashboardUID]: '1', + [Annotation.panelID]: '2', + }, + health: 'ok', + labels: {}, + }, + ], + interval: 300, + file: 'my-namespace', + }, + ], + totals: { + alerting: 2, + }, + }, + }; + const getMock = jest.spyOn(backendSrv, 'get').mockResolvedValue(getResults); const executeAnnotationQueryMock = jest .spyOn(annotationsSrv, 'executeAnnotationQuery') @@ -294,7 +347,7 @@ function getExpectedForAllResult(): DashboardQueryRunnerResult { return { alertState: { dashboardId: 1, - id: 1, + id: 0, panelId: 1, state: AlertState.Alerting, }, diff --git a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts index 42538db0392e8..ddd605865e770 100644 --- a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts +++ b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts @@ -3,13 +3,11 @@ import { finalize, map, mapTo, mergeAll, reduce, share, takeUntil } from 'rxjs/o import { AnnotationQuery } from '@grafana/data'; import { RefreshEvent } from '@grafana/runtime'; -import { config } from 'app/core/config'; import { dedupAnnotations } from 'app/features/annotations/events_processing'; import { getTimeSrv, TimeSrv } from '../../../dashboard/services/TimeSrv'; import { DashboardModel } from '../../../dashboard/state'; -import { AlertStatesWorker } from './AlertStatesWorker'; import { AnnotationsWorker } from './AnnotationsWorker'; import { SnapshotWorker } from './SnapshotWorker'; import { UnifiedAlertStatesWorker } from './UnifiedAlertStatesWorker'; @@ -33,7 +31,7 @@ class DashboardQueryRunnerImpl implements DashboardQueryRunner { private readonly dashboard: DashboardModel, private readonly timeSrv: TimeSrv = getTimeSrv(), private readonly workers: DashboardQueryRunnerWorker[] = [ - config.unifiedAlertingEnabled ? new UnifiedAlertStatesWorker() : new AlertStatesWorker(), + new UnifiedAlertStatesWorker(), new SnapshotWorker(), new AnnotationsWorker(), ] diff --git a/public/app/features/query/state/DashboardQueryRunner/testHelpers.ts b/public/app/features/query/state/DashboardQueryRunner/testHelpers.ts index 027f02514dd41..87345417ef9f0 100644 --- a/public/app/features/query/state/DashboardQueryRunner/testHelpers.ts +++ b/public/app/features/query/state/DashboardQueryRunner/testHelpers.ts @@ -41,6 +41,7 @@ export function getDefaultOptions(): DashboardQueryRunnerOptions { const nextGen = getAnnotation({ datasource: NEXT_GEN_DS_NAME }); const dashboard: any = { id: 1, + uid: '1', annotations: { list: [ legacy, diff --git a/public/app/features/query/state/PanelQueryRunner.test.ts b/public/app/features/query/state/PanelQueryRunner.test.ts index 1d5e61cba4bac..e57cc8ea7a193 100644 --- a/public/app/features/query/state/PanelQueryRunner.test.ts +++ b/public/app/features/query/state/PanelQueryRunner.test.ts @@ -3,8 +3,9 @@ const applyFieldOverridesMock = jest.fn(); // needs to be first in this file import { Subject } from 'rxjs'; // Importing this way to be able to spy on grafana/data + import * as grafanaData from '@grafana/data'; -import { DataSourceApi } from '@grafana/data'; +import { DataSourceApi, TypedVariableModel } from '@grafana/data'; import { DataSourceSrv, setDataSourceSrv, setEchoSrv } from '@grafana/runtime'; import { TemplateSrvMock } from 'app/features/templating/template_srv.mock'; @@ -46,7 +47,27 @@ jest.mock('app/features/dashboard/services/DashboardSrv', () => ({ })); jest.mock('app/features/templating/template_srv', () => ({ - getTemplateSrv: () => new TemplateSrvMock({}), + ...jest.requireActual('app/features/templating/template_srv'), + getTemplateSrv: () => + new TemplateSrvMock([ + { + name: 'server', + type: 'datasource', + current: { text: 'Server1', value: 'server' }, + options: [{ text: 'Server1', value: 'server1' }], + }, + //multi value variable + { + name: 'multi', + type: 'datasource', + multi: true, + current: { text: 'Server1,Server2', value: ['server-1', 'server-2'] }, + options: [ + { text: 'Server1', value: 'server1' }, + { text: 'Server2', value: 'server2' }, + ], + }, + ] as TypedVariableModel[]), })); interface ScenarioContext { @@ -405,4 +426,53 @@ describe('PanelQueryRunner', () => { snapshotData, } ); + + describeQueryRunnerScenario( + 'shouldAddErrorwhenDatasourceVariableIsMultiple', + (ctx) => { + it('should add error when datasource variable is multiple and not repeated', async () => { + // scopedVars is an object that represent the variables repeated in a panel + const scopedVars = { + server: { text: 'Server1', value: 'server-1' }, + }; + + // We are spying on the replace method of the TemplateSrvMock to check if the custom format function is being called + const spyReplace = jest.spyOn(TemplateSrvMock.prototype, 'replace'); + + const response = { + data: [ + { + target: 'hello', + datapoints: [ + [1, 1000], + [2, 2000], + ], + }, + ], + }; + + const datasource = { + name: '${multi}', + uid: '${multi}', + interval: ctx.dsInterval, + query: (options: grafanaData.DataQueryRequest) => { + ctx.queryCalledWith = options; + return Promise.resolve(response); + }, + getRef: () => ({ type: 'test', uid: 'TestDB-uid' }), + testDatasource: jest.fn(), + } as unknown as DataSourceApi; + + ctx.runner.shouldAddErrorWhenDatasourceVariableIsMultiple(datasource, scopedVars); + + // the test is checking implementation details :(, but it is the only way to check if the error will be added + // if the getTemplateSrv.replace is called with the custom format function,it means we will check + // if the error should be added + expect(spyReplace.mock.calls[0][2]).toBeInstanceOf(Function); + }); + }, + { + ...defaultPanelConfig, + } + ); }); diff --git a/public/app/features/query/state/PanelQueryRunner.ts b/public/app/features/query/state/PanelQueryRunner.ts index 8dbcfa849a40b..87bb9bf93f511 100644 --- a/public/app/features/query/state/PanelQueryRunner.ts +++ b/public/app/features/query/state/PanelQueryRunner.ts @@ -1,4 +1,4 @@ -import { cloneDeep } from 'lodash'; +import { cloneDeep, merge } from 'lodash'; import { Observable, of, ReplaySubject, Unsubscribable } from 'rxjs'; import { map, mergeMap, catchError } from 'rxjs/operators'; @@ -28,6 +28,7 @@ import { preProcessPanelData, ApplyFieldOverrideOptions, StreamingDataFrame, + DataTopic, } from '@grafana/data'; import { toDataQueryError } from '@grafana/runtime'; import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; @@ -49,6 +50,7 @@ export interface QueryRunnerOptions< datasource: DataSourceRef | DataSourceApi<TQuery, TOptions> | null; queries: TQuery[]; panelId?: number; + panelPluginId?: string; dashboardUID?: string; timezone: TimeZone; timeRange: TimeRange; @@ -138,9 +140,14 @@ export class PanelQueryRunner { if (withFieldConfig && data.series?.length) { if (lastConfigRev === this.dataConfigSource.configRev) { - const streamingDataFrame = data.series.find((data) => isStreamingDataFrame(data)) as - | StreamingDataFrame - | undefined; + let streamingDataFrame: StreamingDataFrame | undefined; + + for (const frame of data.series) { + if (isStreamingDataFrame(frame)) { + streamingDataFrame = frame; + break; + } + } if ( streamingDataFrame && @@ -213,7 +220,8 @@ export class PanelQueryRunner { private applyTransformations(data: PanelData): Observable<PanelData> { const transformations = this.dataConfigSource.getTransformations(); - if (!transformations || transformations.length === 0) { + const allTransformationsDisabled = transformations && transformations.every((t) => t.disabled); + if (allTransformationsDisabled || !transformations || transformations.length === 0) { return of(data); } @@ -221,8 +229,18 @@ export class PanelQueryRunner { interpolate: (v: string) => this.templateSrv.replace(v, data?.request?.scopedVars), }; - return transformDataFrame(transformations, data.series, ctx).pipe( - map((series) => ({ ...data, series })), + let seriesTransformations = transformations.filter((t) => t.topic == null || t.topic === DataTopic.Series); + let annotationsTransformations = transformations.filter((t) => t.topic === DataTopic.Annotations); + + let seriesStream = transformDataFrame(seriesTransformations, data.series, ctx); + let annotationsStream = transformDataFrame(annotationsTransformations, data.annotations ?? [], ctx); + + return merge(seriesStream, annotationsStream).pipe( + map((frames) => { + let isAnnotations = frames.some((f) => f.meta?.dataTopic === DataTopic.Annotations); + let transformed = isAnnotations ? { annotations: frames } : { series: frames }; + return { ...data, ...transformed }; + }), catchError((err) => { console.warn('Error running transformation:', err); return of({ @@ -240,6 +258,7 @@ export class PanelQueryRunner { timezone, datasource, panelId, + panelPluginId, dashboardUID, timeRange, timeInfo, @@ -256,11 +275,15 @@ export class PanelQueryRunner { return; } + //check if datasource is a variable datasource and if that variable has multiple values + const addErroDSVariable = this.shouldAddErrorWhenDatasourceVariableIsMultiple(datasource, scopedVars); + const request: DataQueryRequest = { app: app ?? CoreApp.Dashboard, requestId: getNextRequestId(), timezone, panelId, + panelPluginId, dashboardUID, range: timeRange, timeInfo, @@ -307,7 +330,7 @@ export class PanelQueryRunner { this.lastRequest = request; - this.pipeToSubject(runRequest(ds, request), panelId); + this.pipeToSubject(runRequest(ds, request), panelId, false, addErroDSVariable); } catch (err) { this.pipeToSubject( of({ @@ -321,7 +344,12 @@ export class PanelQueryRunner { } } - private pipeToSubject(observable: Observable<PanelData>, panelId?: number, skipPreProcess = false) { + private pipeToSubject( + observable: Observable<PanelData>, + panelId?: number, + skipPreProcess = false, + addErroDSVariable = false + ) { if (this.subscription) { this.subscription.unsubscribe(); } @@ -359,6 +387,17 @@ export class PanelQueryRunner { this.lastResult = next; + //add error message if datasource is a variable and has multiple values + if (addErroDSVariable) { + next.errors = [ + { + message: + 'Panel is using a variable datasource with multiple values without repeat option. Please configure the panel to be repeated by the same datasource variable.', + }, + ]; + next.state = LoadingState.Error; + } + // Store preprocessed query results for applying overrides later on in the pipeline this.subject.next(next); }, @@ -431,6 +470,28 @@ export class PanelQueryRunner { getLastRequest(): DataQueryRequest | undefined { return this.lastRequest; } + + shouldAddErrorWhenDatasourceVariableIsMultiple( + datasource: DataSourceRef | DataSourceApi | null, + scopedVars: ScopedVars | undefined + ): boolean { + let addWarningMessageMultipleDatasourceVariable = false; + + //If datasource is a variable + if (datasource?.uid?.startsWith('${')) { + // we can access the raw datasource variable values inside the replace function if we pass a custom format function + this.templateSrv.replace(datasource.uid, scopedVars, (value: string | string[]) => { + // if the variable has multiple values it means it's not being repeated + if (Array.isArray(value) && value.length > 1) { + addWarningMessageMultipleDatasourceVariable = true; + } + // return empty string to avoid replacing the variable + return ''; + }); + } + + return addWarningMessageMultipleDatasourceVariable; + } } async function getDataSource( diff --git a/public/app/features/query/state/mergePanelAndDashData.test.ts b/public/app/features/query/state/mergePanelAndDashData.test.ts index 06c3fff4ea166..173de51a8faa1 100644 --- a/public/app/features/query/state/mergePanelAndDashData.test.ts +++ b/public/app/features/query/state/mergePanelAndDashData.test.ts @@ -1,9 +1,15 @@ import { TestScheduler } from 'rxjs/testing'; -import { AlertState, getDefaultTimeRange, LoadingState, PanelData, toDataFrame } from '@grafana/data'; +import { AlertState, DataTopic, getDefaultTimeRange, LoadingState, PanelData, toDataFrame } from '@grafana/data'; import { mergePanelAndDashData } from './mergePanelAndDashData'; +function toAnnotationFrame(data: Array<Record<string, string>>) { + let frame = toDataFrame(data); + frame.meta = { dataTopic: DataTopic.Annotations }; + return frame; +} + function getTestContext() { const timeRange = getDefaultTimeRange(); const panelData: PanelData = { @@ -34,7 +40,7 @@ describe('mergePanelAndDashboardData', () => { a: { state: LoadingState.Done, series: [], - annotations: [toDataFrame([{ id: 'panelData' }]), toDataFrame([{ id: 'dashData' }])], + annotations: [toAnnotationFrame([{ id: 'panelData' }]), toAnnotationFrame([{ id: 'dashData' }])], timeRange, }, }); @@ -63,7 +69,7 @@ describe('mergePanelAndDashboardData', () => { a: { state: LoadingState.Done, series: [], - annotations: [toDataFrame([{ id: 'panelData' }]), toDataFrame([])], + annotations: [toAnnotationFrame([{ id: 'panelData' }]), toAnnotationFrame([])], alertState: { id: 1, state: AlertState.OK, dashboardId: 1, panelId: 1, newStateDate: '' }, timeRange, }, @@ -92,7 +98,7 @@ describe('mergePanelAndDashboardData', () => { a: { state: LoadingState.Done, series: [], - annotations: [toDataFrame([{ id: 'panelData' }])], + annotations: [toAnnotationFrame([{ id: 'panelData' }])], timeRange, }, }); diff --git a/public/app/features/query/state/mergePanelAndDashData.ts b/public/app/features/query/state/mergePanelAndDashData.ts index f661b7dd64936..ed81f48c2d009 100644 --- a/public/app/features/query/state/mergePanelAndDashData.ts +++ b/public/app/features/query/state/mergePanelAndDashData.ts @@ -1,10 +1,19 @@ import { combineLatest, Observable, of } from 'rxjs'; import { mergeMap } from 'rxjs/operators'; -import { ArrayDataFrame, PanelData } from '@grafana/data'; +import { arrayToDataFrame, DataFrame, DataTopic, PanelData } from '@grafana/data'; import { DashboardQueryRunnerResult } from './DashboardQueryRunner/types'; +function addAnnoDataTopic(annotations: DataFrame[] = []) { + annotations.forEach((f) => { + f.meta = { + ...f.meta, + dataTopic: DataTopic.Annotations, + }; + }); +} + export function mergePanelAndDashData( panelObservable: Observable<PanelData>, dashObservable: Observable<DashboardQueryRunnerResult> @@ -18,11 +27,16 @@ export function mergePanelAndDashData( panelData.annotations = []; } - const annotations = panelData.annotations.concat(new ArrayDataFrame(dashData.annotations)); + const annotations = panelData.annotations.concat(arrayToDataFrame(dashData.annotations)); + + addAnnoDataTopic(annotations); + const alertState = dashData.alertState; return of({ ...panelData, annotations, alertState }); } + addAnnoDataTopic(panelData.annotations); + return of(panelData); }) ); diff --git a/public/app/features/query/state/queryAnalytics.test.ts b/public/app/features/query/state/queryAnalytics.test.ts index cfaefbb3cea25..2d0dadf99d8b9 100644 --- a/public/app/features/query/state/queryAnalytics.test.ts +++ b/public/app/features/query/state/queryAnalytics.test.ts @@ -103,6 +103,7 @@ function getTestData( scopedVars: {}, targets: [], timezone: 'utc', + panelPluginId: 'timeseries', ...overrides, }, series: series || [], @@ -134,6 +135,7 @@ describe('emitDataRequestEvent', () => { duration: 1, totalQueries: 0, cachedQueries: 0, + panelPluginId: 'timeseries', }) ); }); @@ -161,6 +163,7 @@ describe('emitDataRequestEvent', () => { duration: 1, totalQueries: 2, cachedQueries: 1, + panelPluginId: 'timeseries', }) ); }); @@ -188,6 +191,7 @@ describe('emitDataRequestEvent', () => { duration: 1, totalQueries: 1, cachedQueries: 1, + panelPluginId: 'timeseries', }) ); }); @@ -234,6 +238,7 @@ describe('emitDataRequestEvent', () => { dataSize: 0, duration: 1, totalQueries: 0, + panelPluginId: 'timeseries', }) ); }); @@ -270,6 +275,7 @@ describe('emitDataRequestEvent', () => { dataSize: 0, duration: 1, totalQueries: 0, + panelPluginId: 'timeseries', }) ); }); diff --git a/public/app/features/query/state/queryAnalytics.ts b/public/app/features/query/state/queryAnalytics.ts index 51b25443db78f..337fe6d4b00aa 100644 --- a/public/app/features/query/state/queryAnalytics.ts +++ b/public/app/features/query/state/queryAnalytics.ts @@ -28,6 +28,8 @@ export function emitDataRequestEvent(datasource: DataSourceApi) { datasourceUid: datasource.uid, datasourceType: datasource.type, dataSize: 0, + panelId: 0, + panelPluginId: data.request?.panelPluginId, duration: data.request.endTime! - data.request.startTime, }; @@ -60,7 +62,9 @@ export function emitDataRequestEvent(datasource: DataSourceApi) { eventData.totalQueries = Object.keys(queryCacheStatus).length; eventData.cachedQueries = Object.values(queryCacheStatus).filter((val) => val === true).length; - eventData.panelId = data.request!.panelId; + if (data.request && Number.isInteger(data.request.panelId)) { + eventData.panelId = data.request.panelId; + } const dashboard = getDashboardSrv().getCurrent(); if (dashboard) { diff --git a/public/app/features/query/state/runRequest.ts b/public/app/features/query/state/runRequest.ts index c9195b45475db..572851388cdb0 100644 --- a/public/app/features/query/state/runRequest.ts +++ b/public/app/features/query/state/runRequest.ts @@ -18,13 +18,15 @@ import { PanelData, TimeRange, } from '@grafana/data'; -import { config, toDataQueryError, logError } from '@grafana/runtime'; +import { config, toDataQueryError } from '@grafana/runtime'; import { isExpressionReference } from '@grafana/runtime/src/utils/DataSourceWithBackend'; import { backendSrv } from 'app/core/services/backend_srv'; import { queryIsEmpty } from 'app/core/utils/query'; import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource'; import { ExpressionQuery } from 'app/features/expressions/types'; +import { queryLogger } from '../utils'; + import { cancelNetworkRequestsOnUnsubscribe } from './processing/canceler'; import { emitDataRequestEvent } from './queryAnalytics'; @@ -157,7 +159,7 @@ export function runRequest( // handle errors catchError((err) => { console.error('runRequest.catchError', err); - logError(err); + queryLogger.logError(err); return of({ ...state.panelData, state: LoadingState.Error, diff --git a/public/app/features/query/state/updateQueries.test.ts b/public/app/features/query/state/updateQueries.test.ts index 231a29822f7b2..de991e2b1f43b 100644 --- a/public/app/features/query/state/updateQueries.test.ts +++ b/public/app/features/query/state/updateQueries.test.ts @@ -577,7 +577,7 @@ describe('updateQueries with import', () => { importQueries: (queries, origin) => { return Promise.resolve([] as DataQuery[]); }, - } as DataSourceApi<any>; + } as DataSourceApi; const oldUidDS = { uid: 'old-uid', diff --git a/public/app/features/query/utils.ts b/public/app/features/query/utils.ts new file mode 100644 index 0000000000000..32cd897d8ecf3 --- /dev/null +++ b/public/app/features/query/utils.ts @@ -0,0 +1,3 @@ +import { createMonitoringLogger } from '@grafana/runtime'; + +export const queryLogger = createMonitoringLogger('features.query'); diff --git a/public/app/features/sandbox/TestStuffPage.tsx b/public/app/features/sandbox/TestStuffPage.tsx index 4cdb3a5b7ecdc..c1e20ed8c87df 100644 --- a/public/app/features/sandbox/TestStuffPage.tsx +++ b/public/app/features/sandbox/TestStuffPage.tsx @@ -2,9 +2,15 @@ import React, { useMemo, useState } from 'react'; import { useObservable } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { ApplyFieldOverrideOptions, dateMath, FieldColorModeId, NavModelItem, PanelData } from '@grafana/data'; +import { + ApplyFieldOverrideOptions, + DataConfigSource, + dateMath, + FieldColorModeId, + NavModelItem, + PanelData, +} from '@grafana/data'; import { getPluginExtensions, isPluginExtensionLink } from '@grafana/runtime'; -import { DataTransformerConfig } from '@grafana/schema'; import { Button, HorizontalGroup, LinkButton, Table } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { config } from 'app/core/config'; @@ -129,8 +135,8 @@ export function getDefaultState(): State { theme: config.theme2, }; - const dataConfig = { - getTransformations: () => [] as DataTransformerConfig[], + const dataConfig: DataConfigSource = { + getTransformations: () => [], getFieldOverrideOptions: () => options, getDataSupport: () => ({ annotations: false, alertStates: false }), }; diff --git a/public/app/features/scenes/SceneListPage.tsx b/public/app/features/scenes/SceneListPage.tsx deleted file mode 100644 index 643a38a31c9b0..0000000000000 --- a/public/app/features/scenes/SceneListPage.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// Libraries -import React from 'react'; -import { useAsync } from 'react-use'; - -import { Card, Stack } from '@grafana/ui'; -import { Page } from 'app/core/components/Page/Page'; - -// Types -import { getGrafanaSearcher } from '../search/service'; - -import { getScenes } from './scenes'; - -export interface Props {} - -export const SceneListPage = ({}: Props) => { - const scenes = getScenes(); - const results = useAsync(() => { - return getGrafanaSearcher().starred({ starred: true }); - }, []); - - return ( - <Page navId="scenes" subTitle="Experimental new runtime and state model for dashboards"> - <Page.Contents> - <Stack direction="column" gap={1}> - <h5>Apps</h5> - <Stack direction="column" gap={0}> - <Card href={`/scenes/grafana-monitoring`}> - <Card.Heading>Grafana monitoring</Card.Heading> - </Card> - </Stack> - <h5>Test scenes</h5> - <Stack direction="column" gap={0}> - {scenes.map((scene) => ( - <Card key={scene.title} href={`/scenes/${scene.title}`}> - <Card.Heading>{scene.title}</Card.Heading> - </Card> - ))} - </Stack> - {results.value && ( - <> - <h5>Starred dashboards</h5> - <Stack direction="column" gap={0}> - {results.value!.view.map((dash) => ( - <Card href={`/scenes/dashboard/${dash.uid}`} key={dash.uid}> - <Card.Heading>{dash.name}</Card.Heading> - </Card> - ))} - </Stack> - </> - )} - </Stack> - </Page.Contents> - </Page> - ); -}; - -export default SceneListPage; diff --git a/public/app/features/scenes/ScenePage.tsx b/public/app/features/scenes/ScenePage.tsx deleted file mode 100644 index eacdd2ae720a8..0000000000000 --- a/public/app/features/scenes/ScenePage.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// Libraries -import React, { useEffect, useState } from 'react'; - -import { getUrlSyncManager } from '@grafana/scenes'; -import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; - -import { getSceneByTitle } from './scenes'; - -export interface Props extends GrafanaRouteComponentProps<{ name: string }> {} - -export const ScenePage = (props: Props) => { - const scene = getSceneByTitle(props.match.params.name); - const [isInitialized, setInitialized] = useState(false); - - useEffect(() => { - if (scene && !isInitialized) { - getUrlSyncManager().initSync(scene); - setInitialized(true); - } - }, [isInitialized, scene]); - - if (!scene) { - return <h2>Scene not found</h2>; - } - - if (!isInitialized) { - return null; - } - - return <scene.Component model={scene} />; -}; - -export default ScenePage; diff --git a/public/app/features/scenes/apps/GrafanaMonitoringApp.tsx b/public/app/features/scenes/apps/GrafanaMonitoringApp.tsx deleted file mode 100644 index a19e41bbdc75f..0000000000000 --- a/public/app/features/scenes/apps/GrafanaMonitoringApp.tsx +++ /dev/null @@ -1,110 +0,0 @@ -// Libraries -import React, { useMemo, useState } from 'react'; - -import { SceneApp, SceneAppPage, SceneRouteMatch, SceneAppPageLike } from '@grafana/scenes'; -import { usePageNav } from 'app/core/components/Page/usePageNav'; -import { PluginPageContext, PluginPageContextType } from 'app/features/plugins/components/PluginPageContext'; - -import { - getOverviewScene, - getHttpHandlerListScene, - getOverviewLogsScene, - getHandlerDetailsScene, - getHandlerLogsScene, -} from './scenes'; -import { getTrafficScene } from './traffic'; - -export function GrafanaMonitoringApp() { - const appScene = useMemo( - () => - new SceneApp({ - pages: [getMainPageScene()], - }), - [] - ); - - const sectionNav = usePageNav('scenes')!; - const [pluginContext] = useState<PluginPageContextType>({ sectionNav }); - - return ( - <PluginPageContext.Provider value={pluginContext}> - <appScene.Component model={appScene} /> - </PluginPageContext.Provider> - ); -} - -export function getMainPageScene() { - return new SceneAppPage({ - title: 'Grafana Monitoring', - subTitle: 'A custom app with embedded scenes to monitor your Grafana server', - url: '/scenes/grafana-monitoring', - hideFromBreadcrumbs: false, - getScene: getOverviewScene, - tabs: [ - new SceneAppPage({ - title: 'Overview', - url: '/scenes/grafana-monitoring', - getScene: getOverviewScene, - preserveUrlKeys: ['from', 'to', 'var-instance'], - }), - new SceneAppPage({ - title: 'HTTP handlers', - url: '/scenes/grafana-monitoring/handlers', - getScene: getHttpHandlerListScene, - preserveUrlKeys: ['from', 'to', 'var-instance'], - drilldowns: [ - { - routePath: '/scenes/grafana-monitoring/handlers/:handler', - getPage: getHandlerDrilldownPage, - }, - ], - }), - new SceneAppPage({ - title: 'Traffic', - url: '/scenes/grafana-monitoring/traffic', - getScene: getTrafficScene, - preserveUrlKeys: ['from', 'to', 'var-instance'], - }), - new SceneAppPage({ - title: 'Logs', - url: '/scenes/grafana-monitoring/logs', - getScene: getOverviewLogsScene, - preserveUrlKeys: ['from', 'to', 'var-instance'], - }), - ], - }); -} - -export function getHandlerDrilldownPage( - match: SceneRouteMatch<{ handler: string; tab?: string }>, - parent: SceneAppPageLike -) { - const handler = decodeURIComponent(match.params.handler); - const baseUrl = `/scenes/grafana-monitoring/handlers/${encodeURIComponent(handler)}`; - - return new SceneAppPage({ - title: handler, - subTitle: 'A grafana http handler is responsible for service a specific API request', - url: baseUrl, - getParentPage: () => parent, - getScene: () => getHandlerDetailsScene(handler), - tabs: [ - new SceneAppPage({ - title: 'Metrics', - url: baseUrl, - routePath: '/scenes/grafana-monitoring/handlers/:handler', - getScene: () => getHandlerDetailsScene(handler), - preserveUrlKeys: ['from', 'to', 'var-instance'], - }), - new SceneAppPage({ - title: 'Logs', - url: baseUrl + '/logs', - routePath: '/scenes/grafana-monitoring/handlers/:handler/logs', - getScene: () => getHandlerLogsScene(handler), - preserveUrlKeys: ['from', 'to', 'var-instance'], - }), - ], - }); -} - -export default GrafanaMonitoringApp; diff --git a/public/app/features/scenes/apps/SceneRadioToggle.tsx b/public/app/features/scenes/apps/SceneRadioToggle.tsx deleted file mode 100644 index 7834ac0b2b9c9..0000000000000 --- a/public/app/features/scenes/apps/SceneRadioToggle.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; - -import { SelectableValue } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; -import { RadioButtonGroup } from '@grafana/ui'; - -export interface SceneRadioToggleState extends SceneObjectState { - options: Array<SelectableValue<string>>; - value: string; - onChange: (value: string) => void; -} - -export class SceneRadioToggle extends SceneObjectBase<SceneRadioToggleState> { - onChange = (value: string) => { - this.setState({ value }); - this.state.onChange(value); - }; - - static Component = ({ model }: SceneComponentProps<SceneRadioToggle>) => { - const { options, value } = model.useState(); - - return <RadioButtonGroup options={options} value={value} onChange={model.onChange} />; - }; -} diff --git a/public/app/features/scenes/apps/SceneSearchBox.tsx b/public/app/features/scenes/apps/SceneSearchBox.tsx deleted file mode 100644 index 760799ec637ad..0000000000000 --- a/public/app/features/scenes/apps/SceneSearchBox.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; - -import { SceneComponentProps, SceneObjectState, SceneObjectBase } from '@grafana/scenes'; -import { Input } from '@grafana/ui'; - -export interface SceneSearchBoxState extends SceneObjectState { - value: string; -} - -export class SceneSearchBox extends SceneObjectBase<SceneSearchBoxState> { - onChange = (evt: React.FormEvent<HTMLInputElement>) => { - this.setState({ value: evt.currentTarget.value }); - }; - - static Component = ({ model }: SceneComponentProps<SceneSearchBox>) => { - const { value } = model.useState(); - - return <Input width={25} placeholder="Search..." value={value} onChange={model.onChange} />; - }; -} diff --git a/public/app/features/scenes/apps/scenes.tsx b/public/app/features/scenes/apps/scenes.tsx deleted file mode 100644 index a3fe07142680c..0000000000000 --- a/public/app/features/scenes/apps/scenes.tsx +++ /dev/null @@ -1,428 +0,0 @@ -import React from 'react'; - -import { FieldColorModeId, getFrameDisplayName } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; -import { - SceneFlexLayout, - SceneByFrameRepeater, - SceneTimePicker, - EmbeddedScene, - SceneDataNode, - SceneTimeRange, - VariableValueSelectors, - SceneQueryRunner, - SceneControlsSpacer, - SceneDataTransformer, - SceneRefreshPicker, - SceneFlexItem, - PanelBuilders, -} from '@grafana/scenes'; -import { BigValueGraphMode, BigValueTextMode, LogsDedupStrategy, LogsSortOrder } from '@grafana/schema'; -import { LinkButton } from '@grafana/ui'; - -import { SceneRadioToggle } from './SceneRadioToggle'; -import { SceneSearchBox } from './SceneSearchBox'; -import { getTableFilterTransform, getTimeSeriesFilterTransform } from './transforms'; -import { getInstantQuery, getLinkUrlWithAppUrlState, getTimeSeriesQuery, getVariablesDefinitions } from './utils'; - -export function getHttpHandlerListScene(): EmbeddedScene { - const searchBox = new SceneSearchBox({ value: '' }); - - const httpHandlerQueries = getInstantQuery({ - expr: 'sort_desc(avg without(job, instance) (rate(grafana_http_request_duration_seconds_sum[$__rate_interval]) * 1e3)) ', - }); - - const httpHandlerQueriesFiltered = new SceneDataTransformer({ - $data: httpHandlerQueries, - transformations: [getTableFilterTransform('')], - }); - - httpHandlerQueriesFiltered.addActivationHandler(() => { - const sub = searchBox.subscribeToState((state) => { - // Update transform and re-process them - httpHandlerQueriesFiltered.setState({ transformations: [getTableFilterTransform(state.value)] }); - httpHandlerQueriesFiltered.reprocessTransformations(); - }); - - return () => sub.unsubscribe(); - }); - - const httpHandlersTable = PanelBuilders.table() - .setTitle('Handlers') - .setData(httpHandlerQueriesFiltered) - .setOption('footer', { - enablePagination: true, - }) - .setOverrides((b) => - b - .matchFieldsWithNameByRegex('.*') - .overrideFilterable(false) - .matchFieldsWithName('Time') - .overrideCustomFieldConfig('hidden', true) - .matchFieldsWithName('Value') - .overrideDisplayName('Duration (Avg)') - .matchFieldsWithName('handler') - .overrideLinks([ - { - title: 'Go to handler drilldown view', - url: '', - onBuildUrl: () => { - const params = locationService.getSearchObject(); - return getLinkUrlWithAppUrlState( - '/scenes/grafana-monitoring/handlers/${__value.text:percentencode}', - params - ); - }, - }, - ]) - ) - .build(); - - const reqDurationTimeSeries = new SceneQueryRunner({ - datasource: { uid: 'gdev-prometheus' }, - queries: [ - { - refId: 'A', - //expr: ``, - expr: 'topk(20, avg without(job, instance) (rate(grafana_http_request_duration_seconds_sum[$__rate_interval])) * 1e3)', - range: true, - format: 'time_series', - legendFormat: '{{method}} {{handler}} (status = {{status_code}})', - maxDataPoints: 500, - }, - ], - }); - - const reqDurationTimeSeriesFiltered = new SceneDataTransformer({ - $data: reqDurationTimeSeries, - transformations: [getTimeSeriesFilterTransform('')], - }); - - reqDurationTimeSeriesFiltered.addActivationHandler(() => { - const sub = searchBox.subscribeToState((state) => { - // Update transform and re-process them - reqDurationTimeSeriesFiltered.setState({ transformations: [getTimeSeriesFilterTransform(state.value)] }); - reqDurationTimeSeriesFiltered.reprocessTransformations(); - }); - - return () => sub.unsubscribe(); - }); - - const graphsScene = new SceneByFrameRepeater({ - $data: reqDurationTimeSeriesFiltered, - body: new SceneFlexLayout({ - direction: 'column', - children: [], - }), - getLayoutChild: (data, frame, frameIndex) => { - return new SceneFlexItem({ - key: `panel-${frameIndex}`, - minHeight: 200, - $data: new SceneDataNode({ - data: { - ...data, - series: [frame], - }, - }), - body: new SceneFlexLayout({ - direction: 'row', - key: `row-${frameIndex}`, - children: [ - new SceneFlexItem({ - key: `flex1-${frameIndex}`, - body: PanelBuilders.timeseries() - .setTitle(getFrameDisplayName(frame)) - .setOption('legend', { showLegend: false }) - .setHeaderActions( - <LinkButton - fill="text" - size="sm" - icon="arrow-right" - href={getHandlerDrilldownUrl(frame.fields[1]!.labels!.handler)} - > - Details - </LinkButton> - ) - .build(), - }), - - new SceneFlexItem({ - key: `flex2-${frameIndex}`, - width: 200, - body: PanelBuilders.stat() - .setTitle('Last') - .setOption('graphMode', BigValueGraphMode.None) - .setOption('textMode', BigValueTextMode.Value) - .setDisplayName('Last') - .build(), - }), - ], - }), - }); - }, - }); - - const layout = new SceneFlexLayout({ - children: [new SceneFlexItem({ body: httpHandlersTable })], - }); - - const sceneToggle = new SceneRadioToggle({ - options: [ - { value: 'table', label: 'Table' }, - { value: 'graphs', label: 'Graphs' }, - ], - value: 'table', - onChange: (value) => { - if (value === 'table') { - layout.setState({ children: [new SceneFlexItem({ body: httpHandlersTable })] }); - } else { - layout.setState({ children: [graphsScene] }); - } - }, - }); - - const scene = new EmbeddedScene({ - $variables: getVariablesDefinitions(), - $data: httpHandlerQueries, - $timeRange: new SceneTimeRange({ from: 'now-1h', to: 'now' }), - controls: [ - new VariableValueSelectors({}), - searchBox, - new SceneControlsSpacer(), - sceneToggle, - new SceneTimePicker({ isOnCanvas: true }), - new SceneRefreshPicker({ isOnCanvas: true }), - ], - body: layout, - }); - - return scene; -} - -function getHandlerDrilldownUrl(handler: string) { - const params = locationService.getSearchObject(); - return getLinkUrlWithAppUrlState(`/scenes/grafana-monitoring/handlers/${encodeURIComponent(handler)}`, params); -} - -export function getHandlerDetailsScene(handler: string): EmbeddedScene { - const reqDurationTimeSeries = getTimeSeriesQuery({ - expr: `avg without(job, instance) (rate(grafana_http_request_duration_seconds_sum{handler="${handler}"}[$__rate_interval])) * 1e3`, - legendFormat: '{{method}} {{handler}} (status = {{status_code}})', - }); - - const reqCountTimeSeries = getTimeSeriesQuery({ - expr: `sum without(job, instance) (rate(grafana_http_request_duration_seconds_count{handler="${handler}"}[$__rate_interval])) `, - legendFormat: '{{method}} {{handler}} (status = {{status_code}})', - }); - - const scene = new EmbeddedScene({ - $variables: getVariablesDefinitions(), - $timeRange: new SceneTimeRange({ from: 'now-1h', to: 'now' }), - controls: [ - new VariableValueSelectors({}), - new SceneControlsSpacer(), - new SceneTimePicker({ isOnCanvas: true }), - new SceneRefreshPicker({ isOnCanvas: true }), - ], - body: new SceneFlexLayout({ - direction: 'column', - children: [ - new SceneFlexItem({ - body: PanelBuilders.timeseries().setData(reqDurationTimeSeries).setTitle('Request duration avg (ms)').build(), - }), - new SceneFlexItem({ - body: PanelBuilders.timeseries().setData(reqCountTimeSeries).setTitle('Request count/s').build(), - }), - ], - }), - }); - - return scene; -} - -export function getOverviewScene(): EmbeddedScene { - const scene = new EmbeddedScene({ - $variables: getVariablesDefinitions(), - $timeRange: new SceneTimeRange({ from: 'now-1h', to: 'now' }), - controls: [ - new VariableValueSelectors({}), - new SceneControlsSpacer(), - new SceneTimePicker({ isOnCanvas: true }), - new SceneRefreshPicker({ isOnCanvas: true }), - ], - body: new SceneFlexLayout({ - direction: 'column', - children: [ - new SceneFlexItem({ - height: 150, - body: new SceneFlexLayout({ - children: [ - new SceneFlexItem({ - body: getInstantStatPanel('grafana_stat_totals_dashboard', 'Dashboards'), - }), - new SceneFlexItem({ - body: getInstantStatPanel('grafana_stat_total_users', 'Users'), - }), - new SceneFlexItem({ - body: getInstantStatPanel('sum(grafana_stat_totals_datasource)', 'Data sources'), - }), - new SceneFlexItem({ - body: getInstantStatPanel('grafana_stat_total_service_account_tokens', 'Service account tokens'), - }), - ], - }), - }), - - new SceneFlexItem({ - body: PanelBuilders.timeseries() - .setData( - new SceneQueryRunner({ - datasource: { uid: 'gdev-prometheus' }, - queries: [ - { - refId: 'A', - expr: `sum(process_resident_memory_bytes{job="grafana", instance=~"$instance"})`, - range: true, - format: 'time_series', - maxDataPoints: 500, - }, - ], - }) - ) - .setTitle('Memory usage') - .setOption('legend', { showLegend: false }) - .setUnit('bytes') - .setMin(0) - .setCustomFieldConfig('lineWidth', 2) - .setCustomFieldConfig('fillOpacity', 6) - .build(), - }), - new SceneFlexItem({ - body: PanelBuilders.timeseries() - .setData( - new SceneQueryRunner({ - datasource: { uid: 'gdev-prometheus' }, - queries: [ - { - refId: 'A', - expr: `sum(go_goroutines{job="grafana", instance=~"$instance"})`, - range: true, - format: 'time_series', - maxDataPoints: 500, - }, - ], - }) - ) - .setOption('legend', { showLegend: false }) - .setMin(0) - .setCustomFieldConfig('lineWidth', 2) - .setCustomFieldConfig('fillOpacity', 6) - .setTitle('Go routines') - .build(), - }), - ], - }), - }); - - return scene; -} - -function getInstantStatPanel(query: string, title: string) { - return PanelBuilders.stat() - .setData(getInstantQuery({ expr: query })) - .setTitle(title) - .setColor({ fixedColor: 'text', mode: FieldColorModeId.Fixed }) - .build(); -} - -export function getHandlerLogsScene(handler: string): EmbeddedScene { - const logsQuery = new SceneQueryRunner({ - datasource: { uid: 'gdev-loki' }, - queries: [ - { - refId: 'A', - expr: `{job="grafana"} | logfmt | handler=\`${handler}\` | __error__=\`\``, - queryType: 'range', - maxDataPoints: 5000, - }, - ], - }); - - const scene = new EmbeddedScene({ - $variables: getVariablesDefinitions(), - $timeRange: new SceneTimeRange({ from: 'now-1h', to: 'now' }), - controls: [ - new VariableValueSelectors({}), - new SceneControlsSpacer(), - new SceneTimePicker({ isOnCanvas: true }), - new SceneRefreshPicker({ isOnCanvas: true }), - ], - body: new SceneFlexLayout({ - direction: 'column', - children: [ - new SceneFlexItem({ - body: PanelBuilders.logs() - .setData(logsQuery) - .setTitle('') - .setOption('showTime', true) - .setOption('showLabels', false) - .setOption('showCommonLabels', false) - .setOption('wrapLogMessage', true) - .setOption('prettifyLogMessage', false) - .setOption('enableLogDetails', true) - .setOption('dedupStrategy', LogsDedupStrategy.none) - .setOption('sortOrder', LogsSortOrder.Descending) - .build(), - }), - ], - }), - }); - - return scene; -} - -export function getOverviewLogsScene(): EmbeddedScene { - const logsQuery = new SceneQueryRunner({ - datasource: { uid: 'gdev-loki' }, - queries: [ - { - refId: 'A', - expr: `{job="grafana"} | logfmt | __error__=\`\``, - queryType: 'range', - maxDataPoints: 5000, - }, - ], - }); - - const scene = new EmbeddedScene({ - $variables: getVariablesDefinitions(), - $timeRange: new SceneTimeRange({ from: 'now-1h', to: 'now' }), - controls: [ - new VariableValueSelectors({}), - new SceneControlsSpacer(), - new SceneTimePicker({ isOnCanvas: true }), - new SceneRefreshPicker({ isOnCanvas: true }), - ], - body: new SceneFlexLayout({ - direction: 'column', - children: [ - new SceneFlexItem({ - body: PanelBuilders.logs() - .setTitle('') - .setData(logsQuery) - .setOption('showTime', true) - .setOption('showLabels', false) - .setOption('showCommonLabels', false) - .setOption('wrapLogMessage', true) - .setOption('prettifyLogMessage', false) - .setOption('enableLogDetails', true) - .setOption('dedupStrategy', LogsDedupStrategy.none) - .setOption('sortOrder', LogsSortOrder.Descending) - .build(), - }), - ], - }), - }); - - return scene; -} diff --git a/public/app/features/scenes/apps/traffic.tsx b/public/app/features/scenes/apps/traffic.tsx deleted file mode 100644 index 8e561e40addb5..0000000000000 --- a/public/app/features/scenes/apps/traffic.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React from 'react'; - -import { - SceneFlexLayout, - SceneTimePicker, - EmbeddedScene, - SceneTimeRange, - VariableValueSelectors, - SceneControlsSpacer, - SceneRefreshPicker, - SceneFlexItem, - SceneObjectState, - SceneObjectBase, - SceneObjectUrlSyncConfig, - SceneObjectUrlValues, - PanelBuilders, -} from '@grafana/scenes'; -import { Button } from '@grafana/ui'; - -import { getInstantQuery, getTimeSeriesQuery, getVariablesDefinitions } from './utils'; - -export function getTrafficScene(): EmbeddedScene { - const httpHandlersTable = PanelBuilders.table() - .setData( - getInstantQuery({ - expr: 'sort_desc(avg without(job, instance) (rate(grafana_http_request_duration_seconds_sum[$__rate_interval]) * 1e3)) ', - }) - ) - .setTitle('Handlers') - .setOption('footer', { enablePagination: true }) - .setOverrides((b) => - b - .matchFieldsWithNameByRegex('.*') - .overrideFilterable(false) - .matchFieldsWithName('Time') - .overrideCustomFieldConfig('hidden', true) - .matchFieldsWithName('Value') - .overrideDisplayName('Duration (Avg)') - .matchFieldsWithName('handler') - .overrideLinks([ - { - title: 'Go to handler drilldown view', - url: '/scenes/grafana-monitoring/traffic?handler=${__value.text:percentencode}', - }, - ]) - ) - .build(); - - const scene = new EmbeddedScene({ - $variables: getVariablesDefinitions(), - $timeRange: new SceneTimeRange({ from: 'now-1h', to: 'now' }), - controls: [ - new VariableValueSelectors({}), - new SceneControlsSpacer(), - new SceneTimePicker({ isOnCanvas: true }), - new SceneRefreshPicker({ isOnCanvas: true }), - ], - body: new SceneFlexLayout({ - $behaviors: [new HandlerDrilldownViewBehavior()], - children: [new SceneFlexItem({ body: httpHandlersTable })], - }), - }); - - return scene; -} - -export interface HandlerDrilldownViewBehaviorState extends SceneObjectState { - handler?: string; -} - -export class HandlerDrilldownViewBehavior extends SceneObjectBase<HandlerDrilldownViewBehaviorState> { - protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['handler'] }); - - constructor() { - super({}); - - this.addActivationHandler(() => { - this._subs.add(this.subscribeToState((state) => this.onHandlerChanged(state.handler))); - this.onHandlerChanged(this.state.handler); - }); - } - - private onHandlerChanged(handler: string | undefined) { - const layout = this.getLayout(); - - if (handler == null) { - layout.setState({ children: layout.state.children.slice(0, 1) }); - } else { - layout.setState({ children: [layout.state.children[0], this.getDrilldownView(handler)] }); - } - } - - private getDrilldownView(handler: string): SceneFlexItem { - return new SceneFlexItem({ - key: 'drilldown-flex', - body: PanelBuilders.timeseries() - .setData( - getTimeSeriesQuery({ - expr: `rate(grafana_http_request_duration_seconds_sum{handler="${handler}"}[$__rate_interval]) * 1e3`, - }) - ) - .setTitle(`Handler: ${handler} details`) - .setHeaderActions( - <Button size="sm" variant="secondary" icon="times" onClick={() => this.setState({ handler: undefined })} /> - ) - .build(), - }); - } - - getUrlState() { - return { handler: this.state.handler }; - } - - updateFromUrl(values: SceneObjectUrlValues) { - if (typeof values.handler === 'string' || values.handler === undefined) { - this.setState({ handler: values.handler }); - } - } - - private getLayout() { - if (this.parent instanceof SceneFlexLayout) { - return this.parent; - } - - throw new Error('Invalid parent'); - } -} diff --git a/public/app/features/scenes/apps/transforms.ts b/public/app/features/scenes/apps/transforms.ts deleted file mode 100644 index 3bbdca4a3945c..0000000000000 --- a/public/app/features/scenes/apps/transforms.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { map } from 'rxjs'; - -import { - BasicValueMatcherOptions, - CustomTransformOperator, - DataTransformerID, - getFrameDisplayName, - ValueMatcherID, -} from '@grafana/data'; -import { FilterByValueMatch, FilterByValueType } from '@grafana/data/src/transformations/transformers/filterByValue'; -import { DataTransformerConfig, MatcherConfig } from '@grafana/schema'; - -export function getTableFilterTransform(query: string): DataTransformerConfig { - const regex: MatcherConfig<BasicValueMatcherOptions<string>> = { - id: ValueMatcherID.regex, - options: { value: query }, - }; - - return { - id: DataTransformerID.filterByValue, - options: { - type: FilterByValueType.include, - match: FilterByValueMatch.all, - filters: [ - { - fieldName: 'handler', - config: regex, - }, - ], - }, - }; -} - -export function getTimeSeriesFilterTransform(query: string): CustomTransformOperator { - return () => (source) => { - return source.pipe( - map((data) => { - return data.filter((frame) => getFrameDisplayName(frame).toLowerCase().includes(query.toLowerCase())); - }) - ); - }; -} diff --git a/public/app/features/scenes/apps/utils.ts b/public/app/features/scenes/apps/utils.ts deleted file mode 100644 index 48afdf1efee89..0000000000000 --- a/public/app/features/scenes/apps/utils.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useLocation } from 'react-router-dom'; - -import { UrlQueryMap, urlUtil } from '@grafana/data'; -import { locationSearchToObject } from '@grafana/runtime'; -import { QueryVariable, SceneQueryRunner, SceneVariableSet } from '@grafana/scenes'; -import { PromQuery } from 'app/plugins/datasource/prometheus/types'; - -export function useAppQueryParams() { - const location = useLocation(); - return locationSearchToObject(location.search || ''); -} - -export function getLinkUrlWithAppUrlState(path: string, params: UrlQueryMap): string { - return urlUtil.renderUrl(path, params); -} - -export function getInstantQuery(query: Partial<PromQuery>): SceneQueryRunner { - return new SceneQueryRunner({ - datasource: { uid: 'gdev-prometheus' }, - queries: [ - { - refId: 'A', - instant: true, - format: 'table', - maxDataPoints: 500, - ...query, - }, - ], - }); -} - -export function getTimeSeriesQuery(query: Partial<PromQuery>): SceneQueryRunner { - return new SceneQueryRunner({ - datasource: { uid: 'gdev-prometheus' }, - queries: [ - { - refId: 'A', - range: true, - format: 'time_series', - maxDataPoints: 500, - ...query, - }, - ], - }); -} - -export function getVariablesDefinitions() { - return new SceneVariableSet({ - variables: [ - new QueryVariable({ - name: 'instance', - datasource: { uid: 'gdev-prometheus' }, - query: { query: 'label_values(grafana_http_request_duration_seconds_sum, instance)', refId: 'A' }, - }), - ], - }); -} diff --git a/public/app/features/scenes/scenes/gridMultiTimeRange.tsx b/public/app/features/scenes/scenes/gridMultiTimeRange.tsx deleted file mode 100644 index c10d6847a225f..0000000000000 --- a/public/app/features/scenes/scenes/gridMultiTimeRange.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { - SceneGridRow, - SceneTimePicker, - SceneGridLayout, - SceneTimeRange, - SceneRefreshPicker, - SceneGridItem, - PanelBuilders, -} from '@grafana/scenes'; -import { TestDataQueryType } from '@grafana-plugins/grafana-testdata-datasource/dataquery.gen'; - -import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene'; - -import { getQueryRunnerWithRandomWalkQuery } from './queries'; - -export function getGridWithMultipleTimeRanges(): DashboardScene { - const globalTimeRange = new SceneTimeRange(); - const row1TimeRange = new SceneTimeRange({ - from: 'now-1y', - to: 'now', - }); - - return new DashboardScene({ - title: 'Grid with rows and different queries and time ranges', - body: new SceneGridLayout({ - children: [ - new SceneGridRow({ - $timeRange: row1TimeRange, - $data: getQueryRunnerWithRandomWalkQuery({ scenarioId: TestDataQueryType.RandomWalkTable }), - title: 'Row A - has its own query, last year time range', - key: 'Row A', - isCollapsed: true, - y: 0, - children: [ - new SceneGridItem({ - x: 0, - y: 1, - width: 12, - height: 5, - isResizable: true, - isDraggable: true, - body: PanelBuilders.timeseries().setTitle('Row A Child1').build(), - }), - new SceneGridItem({ - x: 0, - y: 5, - width: 6, - height: 5, - isResizable: true, - isDraggable: true, - body: PanelBuilders.timeseries().setTitle('Row A Child2').build(), - }), - ], - }), - new SceneGridItem({ - x: 0, - y: 12, - width: 6, - height: 10, - isResizable: true, - isDraggable: true, - body: PanelBuilders.timeseries() - .setTitle('Outsider, has its own query') - .setData(getQueryRunnerWithRandomWalkQuery()) - .build(), - }), - ], - }), - $timeRange: globalTimeRange, - $data: getQueryRunnerWithRandomWalkQuery(), - actions: [new SceneTimePicker({}), new SceneRefreshPicker({})], - }); -} diff --git a/public/app/features/scenes/scenes/gridMultiple.tsx b/public/app/features/scenes/scenes/gridMultiple.tsx deleted file mode 100644 index 73633d618734a..0000000000000 --- a/public/app/features/scenes/scenes/gridMultiple.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { - SceneTimePicker, - SceneFlexLayout, - SceneGridLayout, - SceneTimeRange, - SceneRefreshPicker, - SceneGridItem, - SceneFlexItem, - PanelBuilders, -} from '@grafana/scenes'; - -import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene'; - -import { getQueryRunnerWithRandomWalkQuery } from './queries'; - -export function getMultipleGridLayoutTest(): DashboardScene { - return new DashboardScene({ - title: 'Multiple grid layouts test', - body: new SceneFlexLayout({ - children: [ - new SceneFlexItem({ - body: new SceneGridLayout({ - children: [ - new SceneGridItem({ - x: 0, - y: 0, - width: 12, - height: 10, - isDraggable: true, - isResizable: true, - body: PanelBuilders.timeseries().setTitle('Dragabble and resizable').build(), - }), - new SceneGridItem({ - x: 12, - y: 0, - width: 12, - height: 10, - isResizable: false, - isDraggable: true, - body: PanelBuilders.timeseries().setTitle('Draggable only').build(), - }), - new SceneGridItem({ - x: 6, - y: 11, - width: 12, - height: 10, - isResizable: false, - isDraggable: true, - body: new SceneFlexLayout({ - direction: 'column', - children: [ - new SceneFlexItem({ - ySizing: 'fill', - body: PanelBuilders.timeseries().setTitle('Fill height').build(), - }), - new SceneFlexItem({ - ySizing: 'fill', - body: PanelBuilders.timeseries().setTitle('Fill height').build(), - }), - ], - }), - }), - ], - }), - }), - new SceneFlexItem({ - body: new SceneGridLayout({ - children: [ - new SceneGridItem({ - x: 0, - y: 0, - width: 12, - height: 10, - isDraggable: true, - isResizable: true, - body: PanelBuilders.timeseries().setTitle('Dragabble and resizable').build(), - }), - new SceneGridItem({ - x: 12, - y: 0, - width: 12, - height: 10, - isResizable: false, - isDraggable: true, - body: PanelBuilders.timeseries().setTitle('Draggable only').build(), - }), - new SceneGridItem({ - x: 6, - y: 11, - width: 12, - height: 10, - isResizable: false, - isDraggable: true, - body: new SceneFlexLayout({ - direction: 'column', - children: [ - new SceneFlexItem({ - ySizing: 'fill', - body: PanelBuilders.timeseries().setTitle('Fill height').build(), - }), - new SceneFlexItem({ - ySizing: 'fill', - body: PanelBuilders.timeseries().setTitle('Fill height').build(), - }), - ], - }), - }), - ], - }), - }), - ], - }), - $timeRange: new SceneTimeRange(), - $data: getQueryRunnerWithRandomWalkQuery(), - actions: [new SceneTimePicker({}), new SceneRefreshPicker({})], - }); -} diff --git a/public/app/features/scenes/scenes/gridWithMultipleData.tsx b/public/app/features/scenes/scenes/gridWithMultipleData.tsx deleted file mode 100644 index 3d382b53a058d..0000000000000 --- a/public/app/features/scenes/scenes/gridWithMultipleData.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { - SceneGridRow, - SceneTimePicker, - SceneGridLayout, - SceneTimeRange, - SceneRefreshPicker, - SceneGridItem, - PanelBuilders, -} from '@grafana/scenes'; -import { TestDataQueryType } from '@grafana-plugins/grafana-testdata-datasource/dataquery.gen'; - -import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene'; - -import { getQueryRunnerWithRandomWalkQuery } from './queries'; - -export function getGridWithMultipleData(): DashboardScene { - return new DashboardScene({ - title: 'Grid with rows and different queries', - body: new SceneGridLayout({ - children: [ - new SceneGridRow({ - $timeRange: new SceneTimeRange(), - $data: getQueryRunnerWithRandomWalkQuery({ scenarioId: TestDataQueryType.RandomWalkTable }), - title: 'Row A - has its own query', - key: 'Row A', - isCollapsed: true, - y: 0, - children: [ - new SceneGridItem({ - x: 0, - y: 1, - width: 12, - height: 5, - isResizable: true, - isDraggable: true, - body: PanelBuilders.timeseries().setTitle('Row A Child1').build(), - }), - new SceneGridItem({ - x: 0, - y: 5, - width: 6, - height: 5, - isResizable: true, - isDraggable: true, - body: PanelBuilders.timeseries().setTitle('Row A Child2').build(), - }), - ], - }), - new SceneGridRow({ - title: 'Row B - uses global query', - key: 'Row B', - isCollapsed: true, - y: 1, - children: [ - new SceneGridItem({ - x: 0, - y: 2, - width: 12, - height: 5, - isResizable: false, - isDraggable: true, - body: PanelBuilders.timeseries().setTitle('Row B Child1').build(), - }), - new SceneGridItem({ - x: 0, - y: 7, - width: 6, - height: 5, - isResizable: false, - isDraggable: true, - body: PanelBuilders.timeseries() - .setTitle('Row B Child2 with data') - .setData(getQueryRunnerWithRandomWalkQuery({ seriesCount: 10 })) - .build(), - }), - ], - }), - new SceneGridItem({ - x: 0, - y: 12, - width: 6, - height: 10, - isResizable: true, - isDraggable: true, - body: PanelBuilders.timeseries() - .setTitle('Outsider, has its own query') - .setData(getQueryRunnerWithRandomWalkQuery({ seriesCount: 10 })) - .build(), - }), - new SceneGridItem({ - x: 6, - y: 12, - width: 12, - height: 10, - isResizable: true, - isDraggable: true, - body: PanelBuilders.timeseries().setTitle('Outsider, uses global query').build(), - }), - ], - }), - $timeRange: new SceneTimeRange(), - $data: getQueryRunnerWithRandomWalkQuery(), - actions: [new SceneTimePicker({}), new SceneRefreshPicker({})], - }); -} diff --git a/public/app/features/scenes/scenes/index.tsx b/public/app/features/scenes/scenes/index.tsx deleted file mode 100644 index b5bcf608dc94b..0000000000000 --- a/public/app/features/scenes/scenes/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene'; - -import { getGridWithMultipleTimeRanges } from './gridMultiTimeRange'; -import { getMultipleGridLayoutTest } from './gridMultiple'; -import { getGridWithMultipleData } from './gridWithMultipleData'; -import { getQueryVariableDemo } from './queryVariableDemo'; -import { getRepeatingPanelsDemo, getRepeatingRowsDemo } from './repeatingPanels'; -import { getSceneWithRows } from './sceneWithRows'; -import { getTransformationsDemo } from './transformations'; -import { getVariablesDemo, getVariablesDemoWithAll } from './variablesDemo'; - -interface SceneDef { - title: string; - getScene: () => DashboardScene; -} -export function getScenes(): SceneDef[] { - return [ - { title: 'Scene with rows', getScene: getSceneWithRows }, - { title: 'Grid with rows and different queries', getScene: getGridWithMultipleData }, - { title: 'Grid with rows and different queries and time ranges', getScene: getGridWithMultipleTimeRanges }, - { title: 'Multiple grid layouts test', getScene: getMultipleGridLayoutTest }, - { title: 'Variables', getScene: getVariablesDemo }, - { title: 'Variables with All values', getScene: getVariablesDemoWithAll }, - { title: 'Variables - Repeating panels', getScene: getRepeatingPanelsDemo }, - { title: 'Variables - Repeating rows', getScene: getRepeatingRowsDemo }, - { title: 'Query variable', getScene: getQueryVariableDemo }, - { title: 'Transformations demo', getScene: getTransformationsDemo }, - ]; -} - -const cache: Record<string, DashboardScene> = {}; - -export function getSceneByTitle(title: string) { - if (cache[title]) { - return cache[title]; - } - - const scene = getScenes().find((x) => x.title === title); - - if (scene) { - cache[title] = scene.getScene(); - } - - return cache[title]; -} diff --git a/public/app/features/scenes/scenes/queries.ts b/public/app/features/scenes/scenes/queries.ts deleted file mode 100644 index a87957a9a11ea..0000000000000 --- a/public/app/features/scenes/scenes/queries.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { QueryRunnerState, SceneQueryRunner } from '@grafana/scenes'; -import { TestData } from '@grafana-plugins/grafana-testdata-datasource/dataquery.gen'; - -export function getQueryRunnerWithRandomWalkQuery( - overrides?: Partial<TestData>, - queryRunnerOverrides?: Partial<QueryRunnerState> -) { - return new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { - uid: 'gdev-testdata', - type: 'testdata', - }, - scenarioId: 'random_walk', - ...overrides, - }, - ], - ...queryRunnerOverrides, - }); -} diff --git a/public/app/features/scenes/scenes/queryVariableDemo.tsx b/public/app/features/scenes/scenes/queryVariableDemo.tsx deleted file mode 100644 index d1f1e025426e7..0000000000000 --- a/public/app/features/scenes/scenes/queryVariableDemo.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { VariableRefresh } from '@grafana/data'; -import { - SceneCanvasText, - SceneTimePicker, - SceneFlexLayout, - SceneTimeRange, - VariableValueSelectors, - SceneVariableSet, - CustomVariable, - DataSourceVariable, - QueryVariable, - SceneRefreshPicker, - SceneFlexItem, -} from '@grafana/scenes'; - -import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene'; - -export function getQueryVariableDemo(): DashboardScene { - return new DashboardScene({ - title: 'Query variable', - $variables: new SceneVariableSet({ - variables: [ - new CustomVariable({ - name: 'metric', - query: 'job : job, instance : instance', - }), - new DataSourceVariable({ - name: 'datasource', - pluginId: 'prometheus', - }), - new QueryVariable({ - name: 'instance (using datasource variable)', - refresh: VariableRefresh.onTimeRangeChanged, - query: { query: 'label_values(go_gc_duration_seconds, ${metric})', refId: 'A' }, - datasource: { uid: '${datasource}' }, - }), - new QueryVariable({ - name: 'label values (on time range refresh)', - refresh: VariableRefresh.onTimeRangeChanged, - query: { query: 'label_values(go_gc_duration_seconds, ${metric})', refId: 'B' }, - datasource: { uid: 'gdev-prometheus', type: 'prometheus' }, - }), - new QueryVariable({ - name: 'legacy (graphite)', - refresh: VariableRefresh.onTimeRangeChanged, - query: { queryType: 'Default', target: 'stats.response.*', refId: 'C' }, - datasource: { uid: 'gdev-graphite', type: 'graphite' }, - }), - ], - }), - body: new SceneFlexLayout({ - direction: 'row', - children: [ - new SceneFlexItem({ - width: '40%', - body: new SceneCanvasText({ - text: 'metric: ${metric}', - fontSize: 20, - align: 'center', - }), - }), - ], - }), - $timeRange: new SceneTimeRange(), - actions: [new SceneTimePicker({}), new SceneRefreshPicker({})], - controls: [new VariableValueSelectors({})], - }); -} diff --git a/public/app/features/scenes/scenes/repeatingPanels.tsx b/public/app/features/scenes/scenes/repeatingPanels.tsx deleted file mode 100644 index 8286f8cfc71ea..0000000000000 --- a/public/app/features/scenes/scenes/repeatingPanels.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { - SceneTimePicker, - SceneTimeRange, - VariableValueSelectors, - SceneVariableSet, - TestVariable, - SceneRefreshPicker, - PanelBuilders, - SceneGridLayout, - SceneControlsSpacer, - SceneGridRow, -} from '@grafana/scenes'; -import { VariableRefresh } from '@grafana/schema'; -import { PanelRepeaterGridItem } from 'app/features/dashboard-scene/scene/PanelRepeaterGridItem'; -import { RowRepeaterBehavior } from 'app/features/dashboard-scene/scene/RowRepeaterBehavior'; - -import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene'; - -import { getQueryRunnerWithRandomWalkQuery } from './queries'; - -/** - * Repeat panels by variable that changes with time refresh. This tries to setup a very specific scenario - * where a variable that is slow (2s) and constantly changing it's result is used to repeat panels. This - * can be used to verify that when the time range change the repeated panels with locally scoped variable value - * still wait for the top level variable to finish loading and the repeat process to complete. - */ -export function getRepeatingPanelsDemo(): DashboardScene { - return new DashboardScene({ - title: 'Variables - Repeating panels', - $variables: new SceneVariableSet({ - variables: [ - new TestVariable({ - name: 'server', - query: 'AB', - value: 'server', - text: '', - delayMs: 2000, - isMulti: true, - includeAll: true, - refresh: VariableRefresh.onTimeRangeChanged, - optionsToReturn: [ - { label: 'A', value: 'A' }, - { label: 'B', value: 'B' }, - ], - options: [], - $behaviors: [changeVariable], - }), - ], - }), - body: new SceneGridLayout({ - isDraggable: true, - isResizable: true, - children: [ - new PanelRepeaterGridItem({ - variableName: 'server', - x: 0, - y: 0, - width: 24, - height: 8, - itemHeight: 8, - source: PanelBuilders.timeseries() - .setTitle('server = $server') - .setData(getQueryRunnerWithRandomWalkQuery({ alias: 'server = $server' })) - .build(), - }), - ], - }), - $timeRange: new SceneTimeRange(), - actions: [], - controls: [ - new VariableValueSelectors({}), - new SceneControlsSpacer(), - new SceneTimePicker({}), - new SceneRefreshPicker({}), - ], - }); -} - -function changeVariable(variable: TestVariable) { - const sub = variable.subscribeToState((state, old) => { - if (!state.loading && old.loading) { - if (variable.state.optionsToReturn?.length === 2) { - variable.setState({ - query: 'ABC', - optionsToReturn: [ - { label: 'A', value: 'A' }, - { label: 'B', value: 'B' }, - { label: 'C', value: 'C' }, - ], - }); - } else { - variable.setState({ - query: 'AB', - optionsToReturn: [ - { label: 'A', value: 'A' }, - { label: 'B', value: 'B' }, - ], - }); - } - } - }); - - return () => { - sub.unsubscribe(); - }; -} - -export function getRepeatingRowsDemo(): DashboardScene { - return new DashboardScene({ - title: 'Variables - Repeating rows', - $variables: new SceneVariableSet({ - variables: [ - new TestVariable({ - name: 'server', - query: 'AB', - value: ['A', 'B', 'C'], - text: ['A', 'B', 'C'], - delayMs: 2000, - isMulti: true, - includeAll: true, - refresh: VariableRefresh.onTimeRangeChanged, - optionsToReturn: [ - { label: 'A', value: 'A' }, - { label: 'B', value: 'B' }, - { label: 'C', value: 'C' }, - ], - options: [], - //$behaviors: [changeVariable], - }), - new TestVariable({ - name: 'pod', - query: 'AB', - value: ['Mu', 'Ma', 'Mi'], - text: ['Mu', 'Ma', 'Mi'], - delayMs: 2000, - isMulti: true, - includeAll: true, - refresh: VariableRefresh.onTimeRangeChanged, - optionsToReturn: [ - { label: 'Mu', value: 'Mu' }, - { label: 'Ma', value: 'Ma' }, - { label: 'Mi', value: 'Mi' }, - ], - options: [], - }), - ], - }), - body: new SceneGridLayout({ - isDraggable: true, - isResizable: true, - children: [ - new SceneGridRow({ - title: 'Row $server', - key: 'Row A', - isCollapsed: false, - y: 0, - x: 0, - $behaviors: [ - new RowRepeaterBehavior({ - variableName: 'server', - sources: [ - new PanelRepeaterGridItem({ - variableName: 'pod', - x: 0, - y: 0, - width: 24, - height: 5, - itemHeight: 5, - source: PanelBuilders.timeseries() - .setTitle('server = $server, pod = $pod') - .setData(getQueryRunnerWithRandomWalkQuery({ alias: 'server = $server, pod = $pod' })) - .build(), - }), - ], - }), - ], - }), - ], - }), - $timeRange: new SceneTimeRange(), - actions: [], - controls: [ - new VariableValueSelectors({}), - new SceneControlsSpacer(), - new SceneTimePicker({}), - new SceneRefreshPicker({}), - ], - }); -} diff --git a/public/app/features/scenes/scenes/sceneWithRows.tsx b/public/app/features/scenes/scenes/sceneWithRows.tsx deleted file mode 100644 index 2657294c940fd..0000000000000 --- a/public/app/features/scenes/scenes/sceneWithRows.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { - NestedScene, - SceneTimePicker, - SceneFlexLayout, - SceneTimeRange, - SceneRefreshPicker, - SceneFlexItem, - PanelBuilders, -} from '@grafana/scenes'; - -import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene'; - -import { getQueryRunnerWithRandomWalkQuery } from './queries'; - -export function getSceneWithRows(): DashboardScene { - return new DashboardScene({ - title: 'Scene with rows', - body: new SceneFlexLayout({ - direction: 'column', - children: [ - new NestedScene({ - title: 'Overview', - canCollapse: true, - body: new SceneFlexLayout({ - direction: 'row', - children: [ - new SceneFlexItem({ - body: PanelBuilders.timeseries().setTitle('Fill height').build(), - }), - - new SceneFlexItem({ - body: PanelBuilders.timeseries().setTitle('Fill height').build(), - }), - ], - }), - }), - new NestedScene({ - title: 'More server details', - canCollapse: true, - body: new SceneFlexLayout({ - direction: 'row', - children: [ - new SceneFlexItem({ - body: PanelBuilders.timeseries().setTitle('Fill height').build(), - }), - new SceneFlexItem({ - body: PanelBuilders.timeseries().setTitle('Fill height').build(), - }), - ], - }), - }), - ], - }), - $timeRange: new SceneTimeRange(), - $data: getQueryRunnerWithRandomWalkQuery(), - actions: [new SceneTimePicker({}), new SceneRefreshPicker({})], - }); -} diff --git a/public/app/features/scenes/scenes/transformations.tsx b/public/app/features/scenes/scenes/transformations.tsx deleted file mode 100644 index 1532591008520..0000000000000 --- a/public/app/features/scenes/scenes/transformations.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { - SceneTimePicker, - SceneFlexLayout, - SceneDataTransformer, - SceneTimeRange, - SceneRefreshPicker, - SceneFlexItem, - PanelBuilders, -} from '@grafana/scenes'; - -import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene'; - -import { getQueryRunnerWithRandomWalkQuery } from './queries'; - -export function getTransformationsDemo(): DashboardScene { - return new DashboardScene({ - title: 'Transformations demo', - body: new SceneFlexLayout({ - direction: 'row', - children: [ - new SceneFlexItem({ - body: new SceneFlexLayout({ - direction: 'column', - children: [ - new SceneFlexItem({ - body: new SceneFlexLayout({ - direction: 'row', - children: [ - new SceneFlexItem({ - body: PanelBuilders.timeseries().setTitle('Source data (global query)').build(), - }), - new SceneFlexItem({ - body: PanelBuilders.stat() - .setTitle('Transformed data') - .setData( - new SceneDataTransformer({ - transformations: [ - { - id: 'reduce', - options: { - reducers: ['last', 'mean'], - }, - }, - ], - }) - ) - .build(), - }), - ], - }), - }), - new SceneFlexItem({ - body: PanelBuilders.stat() - .setTitle('Query with predefined transformations') - .setData( - new SceneDataTransformer({ - $data: getQueryRunnerWithRandomWalkQuery(), - transformations: [ - { - id: 'reduce', - options: { - reducers: ['mean'], - }, - }, - ], - }) - ) - .build(), - }), - ], - }), - }), - ], - }), - $timeRange: new SceneTimeRange(), - $data: getQueryRunnerWithRandomWalkQuery(), - actions: [new SceneTimePicker({}), new SceneRefreshPicker({})], - }); -} diff --git a/public/app/features/scenes/scenes/variablesDemo.tsx b/public/app/features/scenes/scenes/variablesDemo.tsx deleted file mode 100644 index 1b63739b602dc..0000000000000 --- a/public/app/features/scenes/scenes/variablesDemo.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { - SceneCanvasText, - SceneTimePicker, - SceneFlexLayout, - SceneTimeRange, - VariableValueSelectors, - SceneVariableSet, - CustomVariable, - DataSourceVariable, - TestVariable, - NestedScene, - SceneRefreshPicker, - TextBoxVariable, - SceneFlexItem, - PanelBuilders, -} from '@grafana/scenes'; - -import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene'; - -import { getQueryRunnerWithRandomWalkQuery } from './queries'; - -export function getVariablesDemo(): DashboardScene { - return new DashboardScene({ - title: 'Variables', - $variables: new SceneVariableSet({ - variables: [ - new TestVariable({ - name: 'server', - query: 'A.*', - value: 'server', - text: '', - delayMs: 1000, - options: [], - }), - new TestVariable({ - name: 'pod', - query: 'A.$server.*', - value: 'pod', - delayMs: 1000, - isMulti: true, - text: '', - options: [], - }), - new TestVariable({ - name: 'handler', - query: 'A.$server.$pod.*', - value: 'handler', - delayMs: 1000, - //isMulti: true, - text: '', - options: [], - }), - new CustomVariable({ - name: 'custom', - query: 'A : 10,B : 20', - }), - new DataSourceVariable({ - name: 'ds', - pluginId: 'testdata', - }), - new TextBoxVariable({ - name: 'textbox', - value: 'default value', - }), - ], - }), - body: new SceneFlexLayout({ - direction: 'row', - children: [ - new SceneFlexItem({ - body: new SceneFlexLayout({ - direction: 'column', - children: [ - new SceneFlexItem({ - body: new SceneFlexLayout({ - children: [ - new SceneFlexItem({ - body: PanelBuilders.timeseries() - .setTitle('handler: $handler') - .setData( - getQueryRunnerWithRandomWalkQuery({ - alias: 'handler: $handler', - }) - ) - .build(), - }), - new SceneFlexItem({ - body: new SceneCanvasText({ - text: 'Text: ${textbox}', - fontSize: 20, - align: 'center', - }), - }), - new SceneFlexItem({ - width: '40%', - body: new SceneCanvasText({ - text: 'server: ${server} pod:${pod}', - fontSize: 20, - align: 'center', - }), - }), - ], - }), - }), - new SceneFlexItem({ - body: new NestedScene({ - title: 'Collapsable inner scene', - canCollapse: true, - body: new SceneFlexLayout({ - direction: 'row', - children: [ - new SceneFlexItem({ - body: PanelBuilders.timeseries() - .setTitle('handler: $handler') - .setData( - getQueryRunnerWithRandomWalkQuery({ - alias: 'handler: $handler', - }) - ) - .build(), - }), - ], - }), - }), - }), - ], - }), - }), - ], - }), - $timeRange: new SceneTimeRange(), - actions: [new SceneTimePicker({}), new SceneRefreshPicker({})], - controls: [new VariableValueSelectors({})], - }); -} - -export function getVariablesDemoWithAll(): DashboardScene { - return new DashboardScene({ - title: 'Variables with All values', - $variables: new SceneVariableSet({ - variables: [ - new TestVariable({ - name: 'server', - query: 'A.*', - value: 'AA', - text: 'AA', - includeAll: true, - defaultToAll: true, - delayMs: 1000, - options: [], - }), - new TestVariable({ - name: 'pod', - query: 'A.$server.*', - value: [], - delayMs: 1000, - isMulti: true, - includeAll: true, - defaultToAll: true, - text: '', - options: [], - }), - new TestVariable({ - name: 'handler', - query: 'A.$server.$pod.*', - value: [], - delayMs: 1000, - includeAll: true, - defaultToAll: false, - isMulti: true, - text: '', - options: [], - }), - ], - }), - body: new SceneFlexLayout({ - direction: 'row', - children: [ - new SceneFlexItem({ - body: PanelBuilders.timeseries() - .setTitle('handler: $handler') - .setData( - getQueryRunnerWithRandomWalkQuery({ - alias: 'handler: $handler', - }) - ) - .build(), - }), - new SceneFlexItem({ - width: '40%', - body: new SceneCanvasText({ - text: 'server: ${server} pod:${pod}', - fontSize: 20, - align: 'center', - }), - }), - ], - }), - $timeRange: new SceneTimeRange(), - actions: [new SceneTimePicker({}), new SceneRefreshPicker({})], - controls: [new VariableValueSelectors({})], - }); -} diff --git a/public/app/features/search/page/components/columns.tsx b/public/app/features/search/page/components/columns.tsx index 34625204aa12a..912b02fe029d6 100644 --- a/public/app/features/search/page/components/columns.tsx +++ b/public/app/features/search/page/components/columns.tsx @@ -178,6 +178,7 @@ export const generateColumns = ( return info ? ( <a key={p} href={info.url} className={styles.locationItem}> <Icon name={getIconForKind(info.kind)} /> + <Text variant="body" truncate> {info.name} </Text> diff --git a/public/app/features/search/service/sql.ts b/public/app/features/search/service/sql.ts index 372883537ccee..af240ca2c100c 100644 --- a/public/app/features/search/service/sql.ts +++ b/public/app/features/search/service/sql.ts @@ -17,9 +17,7 @@ interface APIQuery { limit?: number; page?: number; type?: DashboardSearchItemType; - // DashboardIds []int64 dashboardUID?: string[]; - folderIds?: number[]; folderUIDs?: string[]; sort?: string; starred?: boolean; @@ -27,7 +25,7 @@ interface APIQuery { // Internal object to hold folderId interface LocationInfoEXT extends LocationInfo { - folderId?: number; + folderUid?: string; } export class SQLSearcher implements GrafanaSearcher { @@ -36,7 +34,6 @@ export class SQLSearcher implements GrafanaSearcher { kind: 'folder', name: 'General', url: '/dashboards', - folderId: 0, }, }; // share location info with everyone @@ -182,14 +179,14 @@ export class SQLSearcher implements GrafanaSearcher { kind: 'folder', name: hit.folderTitle, url: hit.folderUrl!, - folderId: hit.folderId, + folderUid: hit.folderUid, }; } else if (k === 'folder') { this.locationInfo[hit.uid] = { kind: k, name: hit.title!, url: hit.url, - folderId: hit.id, + folderUid: hit.folderUid, }; } } diff --git a/public/app/features/search/service/utils.ts b/public/app/features/search/service/utils.ts index b4cb61756b882..6aeeaefba928f 100644 --- a/public/app/features/search/service/utils.ts +++ b/public/app/features/search/service/utils.ts @@ -1,4 +1,6 @@ import { DataFrameView, IconName } from '@grafana/data'; +import { isSharedWithMe } from 'app/features/browse-dashboards/components/utils'; +import { DashboardViewItemWithUIItems } from 'app/features/browse-dashboards/types'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { DashboardViewItem, DashboardViewItemKind } from '../types'; @@ -50,6 +52,33 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName { return 'question-circle'; } +export function getIconForItem(item: DashboardViewItemWithUIItems, isOpen?: boolean): IconName { + if (item && isSharedWithMe(item.uid)) { + return 'users-alt'; + } else { + return getIconForKind(item.kind, isOpen); + } +} + +// export function getIconForItem(itemOrKind: string | DashboardViewItemWithUIItems, isOpen?: boolean): IconName { +// const kind = typeof itemOrKind === 'string' ? itemOrKind : itemOrKind.kind; +// const item = typeof itemOrKind === 'string' ? undefined : itemOrKind; + +// if (kind === 'dashboard') { +// return 'apps'; +// } + +// if (item && isSharedWithMe(item.uid)) { +// return 'users-alt'; +// } + +// if (kind === 'folder') { +// return isOpen ? 'folder-open' : 'folder'; +// } + +// return 'question-circle'; +// } + function parseKindString(kind: string): DashboardViewItemKind { switch (kind) { case 'dashboard': diff --git a/public/app/features/search/types.ts b/public/app/features/search/types.ts index 21cf30fd4565b..46cd8734b4d0d 100644 --- a/public/app/features/search/types.ts +++ b/public/app/features/search/types.ts @@ -17,6 +17,7 @@ export enum DashboardSearchItemType { * extraneous properties */ export interface DashboardSearchHit extends WithAccessControlMetadata { + /** @deprecated use folderUid */ folderId?: number; folderTitle?: string; folderUid?: string; diff --git a/public/app/features/serviceaccounts/ServiceAccountCreatePage.test.tsx b/public/app/features/serviceaccounts/ServiceAccountCreatePage.test.tsx index 7d53a87ffa2d3..780b45295148a 100644 --- a/public/app/features/serviceaccounts/ServiceAccountCreatePage.test.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountCreatePage.test.tsx @@ -17,6 +17,7 @@ jest.mock('@grafana/runtime', () => ({ put: putMock, }), config: { + ...jest.requireActual('@grafana/runtime').config, loginError: false, buildInfo: { version: 'v1.0', @@ -39,6 +40,7 @@ jest.mock('app/core/core', () => ({ hasPermission: () => true, hasPermissionInMetadata: () => true, user: { orgId: 1 }, + fetchUserPermissions: () => Promise.resolve(), }, })); diff --git a/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx b/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx index 725969145f096..3d3f0ef3a74b7 100644 --- a/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx @@ -1,7 +1,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import { getBackendSrv, locationService } from '@grafana/runtime'; -import { Form, Button, Input, Field, FieldSet } from '@grafana/ui'; +import { Button, Input, Field, FieldSet } from '@grafana/ui'; +import { Form } from 'app/core/components/Form/Form'; import { Page } from 'app/core/components/Page/Page'; import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; import { fetchRoleOptions, updateUserRoles } from 'app/core/components/RolePicker/api'; diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx index 46737866d5081..38096370c7815 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx @@ -62,7 +62,7 @@ const availableFilters = [ { label: 'Disabled', value: ServiceAccountStateFilter.Disabled }, ]; -if (config.featureToggles.externalServiceAccounts || config.featureToggles.externalServiceAuth) { +if (config.featureToggles.externalServiceAccounts) { availableFilters.push({ label: 'Managed', value: ServiceAccountStateFilter.External }); } diff --git a/public/app/features/serviceaccounts/components/CreateTokenModal.tsx b/public/app/features/serviceaccounts/components/CreateTokenModal.tsx index a86e578c27734..92484a0cdbdca 100644 --- a/public/app/features/serviceaccounts/components/CreateTokenModal.tsx +++ b/public/app/features/serviceaccounts/components/CreateTokenModal.tsx @@ -84,13 +84,7 @@ export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateT const modalTitle = !token ? 'Add service account token' : 'Service account token created'; return ( - <Modal - isOpen={isOpen} - title={modalTitle} - onDismiss={onCloseInternal} - className={styles.modal} - contentClassName={styles.modalContent} - > + <Modal isOpen={isOpen} title={modalTitle} onDismiss={onCloseInternal} className={styles.modal}> {!token ? ( <div> <Field @@ -176,17 +170,14 @@ const getSecondsToLive = (date: Date | string) => { const getStyles = (theme: GrafanaTheme2) => { return { - modal: css` - width: 550px; - `, - modalContent: css` - overflow: visible; - `, - modalTokenRow: css` - display: flex; - `, - modalCopyToClipboardButton: css` - margin-left: ${theme.spacing(0.5)}; - `, + modal: css({ + width: '550px', + }), + modalTokenRow: css({ + display: 'flex', + }), + modalCopyToClipboardButton: css({ + marginLeft: theme.spacing(0.5), + }), }; }; diff --git a/public/app/features/storage/CreateNewFolderModal.tsx b/public/app/features/storage/CreateNewFolderModal.tsx index 152e7cf55ca5e..2ff4a887823fd 100644 --- a/public/app/features/storage/CreateNewFolderModal.tsx +++ b/public/app/features/storage/CreateNewFolderModal.tsx @@ -1,14 +1,15 @@ import React from 'react'; import { SubmitHandler, Validate } from 'react-hook-form'; -import { Button, Field, Form, Input, Modal } from '@grafana/ui'; +import { Button, Field, Input, Modal } from '@grafana/ui'; +import { Form } from 'app/core/components/Form/Form'; type FormModel = { folderName: string }; interface Props { onSubmit: SubmitHandler<FormModel>; onDismiss: () => void; - validate: Validate<string>; + validate: Validate<string, FormModel>; } const initialFormModel = { folderName: '' }; diff --git a/public/app/features/storage/storage.ts b/public/app/features/storage/storage.ts index 41b0527d71fea..38c05e9ec382e 100644 --- a/public/app/features/storage/storage.ts +++ b/public/app/features/storage/storage.ts @@ -102,9 +102,9 @@ class SimpleStorage implements GrafanaStorage { body: formData, }); - let body: UploadResponse = await res.json(); + let body = await res.json(); if (!body) { - body = {} as any; + body = {}; } body.status = res.status; body.statusText = res.statusText; diff --git a/public/app/features/support-bundles/SupportBundlesCreate.tsx b/public/app/features/support-bundles/SupportBundlesCreate.tsx index 74c87f68053a2..1174698171489 100644 --- a/public/app/features/support-bundles/SupportBundlesCreate.tsx +++ b/public/app/features/support-bundles/SupportBundlesCreate.tsx @@ -1,7 +1,8 @@ import React, { useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { Form, Button, Field, Checkbox, LinkButton, HorizontalGroup, Alert } from '@grafana/ui'; +import { Button, Field, Checkbox, LinkButton, Stack, Alert } from '@grafana/ui'; +import { Form } from 'app/core/components/Form/Form'; import { Page } from 'app/core/components/Page/Page'; import { StoreState } from 'app/types'; @@ -60,7 +61,7 @@ export const SupportBundlesCreateUnconnected = ({ {createBundleError && <Alert title={createBundleError} severity="error" />} {!!collectors.length && ( <Form defaultValues={values} onSubmit={onSubmit} validateOn="onSubmit"> - {({ register, errors }) => { + {({ register }) => { return ( <> {[...collectors] @@ -79,12 +80,12 @@ export const SupportBundlesCreateUnconnected = ({ </Field> ); })} - <HorizontalGroup> + <Stack> <Button type="submit">Create</Button> <LinkButton href="/support-bundles" variant="secondary"> Cancel </LinkButton> - </HorizontalGroup> + </Stack> </> ); }} diff --git a/public/app/features/teams/CreateTeam.tsx b/public/app/features/teams/CreateTeam.tsx index 5136e92abf03d..6e1a604f5ee16 100644 --- a/public/app/features/teams/CreateTeam.tsx +++ b/public/app/features/teams/CreateTeam.tsx @@ -1,8 +1,9 @@ -import React, { useState } from 'react'; +import React, { JSX, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { NavModelItem } from '@grafana/data'; import { getBackendSrv, locationService } from '@grafana/runtime'; -import { Button, Form, Field, Input, FieldSet } from '@grafana/ui'; +import { Button, Field, Input, FieldSet } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker'; import { updateTeamRoles } from 'app/core/components/RolePicker/api'; @@ -21,7 +22,11 @@ export const CreateTeam = (): JSX.Element => { const currentOrgId = contextSrv.user.orgId; const [pendingRoles, setPendingRoles] = useState<Role[]>([]); const [{ roleOptions }] = useRoleOptions(currentOrgId); - + const { + handleSubmit, + register, + formState: { errors }, + } = useForm<TeamDTO>(); const canUpdateRoles = contextSrv.hasPermission(AccessControlAction.ActionUserRolesAdd) && contextSrv.hasPermission(AccessControlAction.ActionUserRolesRemove); @@ -44,40 +49,36 @@ export const CreateTeam = (): JSX.Element => { return ( <Page navId="teams" pageNav={pageNav}> <Page.Contents> - <Form onSubmit={createTeam}> - {({ register, errors }) => ( - <> - <FieldSet> - <Field label="Name" required invalid={!!errors.name} error="Team name is required"> - <Input {...register('name', { required: true })} id="team-name" /> - </Field> - {contextSrv.licensedAccessControlEnabled() && ( - <Field label="Role"> - <TeamRolePicker - teamId={0} - roleOptions={roleOptions} - disabled={false} - apply={true} - onApplyRoles={setPendingRoles} - pendingRoles={pendingRoles} - maxWidth="100%" - /> - </Field> - )} - <Field - label={'Email'} - description={'This is optional and is primarily used for allowing custom team avatars.'} - > - <Input {...register('email')} type="email" id="team-email" placeholder="email@test.com" /> - </Field> - </FieldSet> + <form onSubmit={handleSubmit(createTeam)} style={{ maxWidth: '600px' }}> + <FieldSet> + <Field label="Name" required invalid={!!errors.name} error="Team name is required"> + <Input {...register('name', { required: true })} id="team-name" /> + </Field> + {contextSrv.licensedAccessControlEnabled() && ( + <Field label="Role"> + <TeamRolePicker + teamId={0} + roleOptions={roleOptions} + disabled={false} + apply={true} + onApplyRoles={setPendingRoles} + pendingRoles={pendingRoles} + maxWidth="100%" + /> + </Field> + )} + <Field + label={'Email'} + description={'This is optional and is primarily used for allowing custom team avatars.'} + > + <Input {...register('email')} type="email" id="team-email" placeholder="email@test.com" /> + </Field> + </FieldSet> - <Button type="submit" variant="primary"> - Create - </Button> - </> - )} - </Form> + <Button type="submit" variant="primary"> + Create + </Button> + </form> </Page.Contents> </Page> ); diff --git a/public/app/features/teams/TeamPages.test.tsx b/public/app/features/teams/TeamPages.test.tsx index afafe17ee74dc..9aa5ca4af2886 100644 --- a/public/app/features/teams/TeamPages.test.tsx +++ b/public/app/features/teams/TeamPages.test.tsx @@ -29,6 +29,7 @@ jest.mock('@grafana/runtime', () => ({ get: jest.fn().mockResolvedValue([{ userId: 1, login: 'Test' }]), }), config: { + ...jest.requireActual('@grafana/runtime').config, licenseInfo: { enabledFeatures: { teamsync: true }, stateInfo: '', diff --git a/public/app/features/teams/TeamSettings.tsx b/public/app/features/teams/TeamSettings.tsx index 3b09298d72bd5..0916535727930 100644 --- a/public/app/features/teams/TeamSettings.tsx +++ b/public/app/features/teams/TeamSettings.tsx @@ -1,7 +1,8 @@ import React, { useState } from 'react'; +import { useForm } from 'react-hook-form'; import { connect, ConnectedProps } from 'react-redux'; -import { Input, Field, Form, Button, FieldSet, VerticalGroup } from '@grafana/ui'; +import { Input, Field, Button, FieldSet, Stack } from '@grafana/ui'; import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker'; import { updateTeamRoles } from 'app/core/components/RolePicker/api'; import { useRoleOptions } from 'app/core/components/RolePicker/hooks'; @@ -28,6 +29,11 @@ export const TeamSettings = ({ team, updateTeam }: Props) => { const [{ roleOptions }] = useRoleOptions(currentOrgId); const [pendingRoles, setPendingRoles] = useState<Role[]>([]); + const { + handleSubmit, + register, + formState: { errors }, + } = useForm<Team>({ defaultValues: team }); const canUpdateRoles = contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesAdd) && @@ -37,59 +43,55 @@ export const TeamSettings = ({ team, updateTeam }: Props) => { contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsRolesList, team) && contextSrv.hasPermission(AccessControlAction.ActionRolesList); - return ( - <VerticalGroup spacing="lg"> - <Form - defaultValues={{ ...team }} - onSubmit={async (formTeam: Team) => { - if (contextSrv.licensedAccessControlEnabled() && canUpdateRoles) { - await updateTeamRoles(pendingRoles, team.id); - } - updateTeam(formTeam.name, formTeam.email || ''); - }} - disabled={!canWriteTeamSettings} - > - {({ register, errors }) => ( - <FieldSet label="Team details"> - <Field - label="Name" - disabled={!canWriteTeamSettings} - required - invalid={!!errors.name} - error="Name is required" - > - <Input {...register('name', { required: true })} id="name-input" /> - </Field> + const onSubmit = async (formTeam: Team) => { + if (contextSrv.licensedAccessControlEnabled() && canUpdateRoles) { + await updateTeamRoles(pendingRoles, team.id); + } + updateTeam(formTeam.name, formTeam.email || ''); + }; - {contextSrv.licensedAccessControlEnabled() && canListRoles && ( - <Field label="Role"> - <TeamRolePicker - teamId={team.id} - roleOptions={roleOptions} - disabled={!canUpdateRoles} - apply={true} - onApplyRoles={setPendingRoles} - pendingRoles={pendingRoles} - maxWidth="100%" - /> - </Field> - )} + return ( + <Stack direction={'column'} gap={3}> + <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '600px' }}> + <FieldSet label="Team details"> + <Field + label="Name" + disabled={!canWriteTeamSettings} + required + invalid={!!errors.name} + error="Name is required" + > + <Input {...register('name', { required: true })} id="name-input" /> + </Field> - <Field - label="Email" - description="This is optional and is primarily used to set the team profile avatar (via gravatar service)." - disabled={!canWriteTeamSettings} - > - <Input {...register('email')} placeholder="team@email.com" type="email" id="email-input" /> + {contextSrv.licensedAccessControlEnabled() && canListRoles && ( + <Field label="Role"> + <TeamRolePicker + teamId={team.id} + roleOptions={roleOptions} + disabled={!canUpdateRoles} + apply={true} + onApplyRoles={setPendingRoles} + pendingRoles={pendingRoles} + maxWidth="100%" + /> </Field> - <Button type="submit" disabled={!canWriteTeamSettings}> - Update - </Button> - </FieldSet> - )} - </Form> + )} + + <Field + label="Email" + description="This is optional and is primarily used to set the team profile avatar (via gravatar service)." + disabled={!canWriteTeamSettings} + > + <Input {...register('email')} placeholder="team@email.com" type="email" id="email-input" /> + </Field> + <Button type="submit" disabled={!canWriteTeamSettings}> + Update + </Button> + </FieldSet> + </form> <SharedPreferences resourceUri={`teams/${team.id}`} disabled={!canWriteTeamSettings} preferenceType="team" /> - </VerticalGroup> + </Stack> ); }; diff --git a/public/app/features/templating/macroRegistry.test.ts b/public/app/features/templating/macroRegistry.test.ts index 5b5a25e7687db..64ac17c33866b 100644 --- a/public/app/features/templating/macroRegistry.test.ts +++ b/public/app/features/templating/macroRegistry.test.ts @@ -45,7 +45,7 @@ describe('__url_time_range', () => { from: 1607687293000, to: 1607687293100, }), - } as any); + } as unknown as TimeSrv); }); it('should interpolate to url params', () => { diff --git a/public/app/features/templating/template_srv.mock.ts b/public/app/features/templating/template_srv.mock.ts index 53dad5ead9361..2ff7c0abdaf42 100644 --- a/public/app/features/templating/template_srv.mock.ts +++ b/public/app/features/templating/template_srv.mock.ts @@ -1,4 +1,4 @@ -import { ScopedVars, TimeRange, TypedVariableModel } from '@grafana/data'; +import { ScopedVars, TimeRange, TypedVariableModel, VariableOption } from '@grafana/data'; import { TemplateSrv } from '@grafana/runtime'; import { variableRegex } from '../variables/utils'; @@ -13,18 +13,37 @@ import { variableRegex } from '../variables/utils'; */ export class TemplateSrvMock implements TemplateSrv { private regex = variableRegex; - constructor(private variables: Record<string, string>) {} + constructor(private variables: TypedVariableModel[]) {} getVariables(): TypedVariableModel[] { - return Object.keys(this.variables).map((key) => { - return { - type: 'custom', - name: key, - label: key, + if (!this.variables) { + return []; + } + + return this.variables.reduce((acc: TypedVariableModel[], variable) => { + const commonProps = { + type: variable.type ?? 'custom', + name: variable.name ?? 'test', + label: variable.label ?? 'test', }; - // TODO: we remove this type assertion in a later PR - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - }) as TypedVariableModel[]; + if (variable.type === 'datasource') { + acc.push({ + ...commonProps, + current: { + text: variable.current?.text, + value: variable.current?.value, + } as VariableOption, + options: variable.options ?? [], + multi: variable.multi ?? false, + includeAll: variable.includeAll ?? false, + } as TypedVariableModel); + } else { + acc.push({ + ...commonProps, + } as TypedVariableModel); + } + return acc as TypedVariableModel[]; + }, []); } replace(target?: string, scopedVars?: ScopedVars, format?: string | Function): string { @@ -35,8 +54,7 @@ export class TemplateSrvMock implements TemplateSrv { this.regex.lastIndex = 0; return target.replace(this.regex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { - const variableName = var1 || var2 || var3; - return this.variables[variableName]; + return var1 || var2 || var3; }); } diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index a255e9384ae25..9aa3133080c5f 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -69,7 +69,7 @@ export class TemplateSrv implements BaseTemplateSrv { * * Use getVariables function instead */ - get variables(): any[] { + get variables(): TypedVariableModel[] { deprecationWarning('template_srv.ts', 'variables', 'getVariables'); return this.getVariables(); } @@ -84,7 +84,7 @@ export class TemplateSrv implements BaseTemplateSrv { } updateIndex() { - const existsOrEmpty = (value: any) => value || value === ''; + const existsOrEmpty = (value: unknown) => value || value === ''; this.index = this._variables.reduce((acc, currentValue) => { if (currentValue.current && (currentValue.current.isNone || existsOrEmpty(currentValue.current.value))) { @@ -360,7 +360,7 @@ export class TemplateSrv implements BaseTemplateSrv { }); } - isAllValue(value: any) { + isAllValue(value: unknown) { return value === ALL_VARIABLE_VALUE || (Array.isArray(value) && value[0] === ALL_VARIABLE_VALUE); } diff --git a/public/app/features/trails/AddToFiltersGraphAction.tsx b/public/app/features/trails/ActionTabs/AddToFiltersGraphAction.tsx similarity index 77% rename from public/app/features/trails/AddToFiltersGraphAction.tsx rename to public/app/features/trails/ActionTabs/AddToFiltersGraphAction.tsx index d05806c4b9479..b41e81f347340 100644 --- a/public/app/features/trails/AddToFiltersGraphAction.tsx +++ b/public/app/features/trails/ActionTabs/AddToFiltersGraphAction.tsx @@ -10,8 +10,6 @@ import { } from '@grafana/scenes'; import { Button } from '@grafana/ui'; -import { getMetricSceneFor } from './utils'; - export interface AddToFiltersGraphActionState extends SceneObjectState { frame: DataFrame; } @@ -28,15 +26,11 @@ export class AddToFiltersGraphAction extends SceneObjectBase<AddToFiltersGraphAc return; } - // close action view - const metricScene = getMetricSceneFor(this); - metricScene.setActionView(undefined); - const labelName = Object.keys(labels)[0]; - variable.state.set.setState({ + variable.setState({ filters: [ - ...variable.state.set.state.filters, + ...variable.state.filters, { key: labelName, operator: '=', @@ -48,7 +42,7 @@ export class AddToFiltersGraphAction extends SceneObjectBase<AddToFiltersGraphAc public static Component = ({ model }: SceneComponentProps<AddToFiltersGraphAction>) => { return ( - <Button variant="primary" size="sm" fill="text" onClick={model.onClick}> + <Button variant="secondary" size="sm" fill="solid" onClick={model.onClick}> Add to filters </Button> ); diff --git a/public/app/features/trails/BreakdownScene.tsx b/public/app/features/trails/ActionTabs/BreakdownScene.tsx similarity index 70% rename from public/app/features/trails/BreakdownScene.tsx rename to public/app/features/trails/ActionTabs/BreakdownScene.tsx index 7e63de042c7cb..848c808117ad2 100644 --- a/public/app/features/trails/BreakdownScene.tsx +++ b/public/app/features/trails/ActionTabs/BreakdownScene.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { DataFrame, GrafanaTheme2, SelectableValue } from '@grafana/data'; import { - AdHocFiltersVariable, PanelBuilders, QueryVariable, SceneComponentProps, @@ -18,33 +17,41 @@ import { SceneObjectBase, SceneObjectState, SceneQueryRunner, - SceneVariableSet, + VariableDependencyConfig, } from '@grafana/scenes'; -import { Button, Field, RadioButtonGroup, useStyles2 } from '@grafana/ui'; +import { Button, Field, useStyles2 } from '@grafana/ui'; import { ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; +import { getAutoQueriesForMetric } from '../AutomaticMetricQueries/AutoQueryEngine'; +import { AutoQueryDef } from '../AutomaticMetricQueries/types'; +import { BreakdownLabelSelector } from '../BreakdownLabelSelector'; +import { MetricScene } from '../MetricScene'; +import { StatusWrapper } from '../StatusWrapper'; +import { trailDS, VAR_FILTERS, VAR_GROUP_BY, VAR_GROUP_BY_EXP } from '../shared'; +import { getColorByIndex } from '../utils'; + import { AddToFiltersGraphAction } from './AddToFiltersGraphAction'; -import { AutoQueryDef, getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine'; import { ByFrameRepeater } from './ByFrameRepeater'; import { LayoutSwitcher } from './LayoutSwitcher'; -import { MetricScene } from './MetricScene'; -import { trailDS, VAR_FILTERS, VAR_GROUP_BY, VAR_GROUP_BY_EXP, VAR_METRIC_EXPR } from './shared'; -import { getColorByIndex } from './utils'; +import { getLabelOptions } from './utils'; export interface BreakdownSceneState extends SceneObjectState { body?: SceneObject; labels: Array<SelectableValue<string>>; value?: string; loading?: boolean; + error?: string; + blockingMessage?: string; } -/** - * Just a proof of concept example of a behavior - */ export class BreakdownScene extends SceneObjectBase<BreakdownSceneState> { + protected _variableDependency = new VariableDependencyConfig(this, { + variableNames: [VAR_FILTERS], + onReferencedVariableValueChanged: this.onReferencedVariableValueChanged.bind(this), + }); + constructor(state: Partial<BreakdownSceneState>) { super({ - $variables: state.$variables ?? getVariableSet(), labels: state.labels ?? [], ...state, }); @@ -82,74 +89,68 @@ export class BreakdownScene extends SceneObjectBase<BreakdownSceneState> { return variable; } + private onReferencedVariableValueChanged() { + const variable = this.getVariable(); + variable.changeValueTo(ALL_VARIABLE_VALUE); + this.updateBody(variable); + } + private updateBody(variable: QueryVariable) { - const options = this.getLabelOptions(variable); + const options = getLabelOptions(this, variable); const stateUpdate: Partial<BreakdownSceneState> = { loading: variable.state.loading, value: String(variable.state.value), labels: options, + error: variable.state.error, + blockingMessage: undefined, }; - if (!this.state.body && !variable.state.loading) { + if (!variable.state.loading && variable.state.options.length) { stateUpdate.body = variable.hasAllValue() ? buildAllLayout(options, this._query!) : buildNormalLayout(this._query!); + } else if (!variable.state.loading) { + stateUpdate.body = undefined; + stateUpdate.blockingMessage = 'Unable to retrieve label options for currently selected metric.'; } this.setState(stateUpdate); } - private getLabelOptions(variable: QueryVariable) { - const labelFilters = sceneGraph.lookupVariable(VAR_FILTERS, this); - const labelOptions: Array<SelectableValue<string>> = []; - - if (!(labelFilters instanceof AdHocFiltersVariable)) { - return []; + public onChange = (value?: string) => { + if (!value) { + return; } - const filters = labelFilters.state.set.state.filters; - - for (const option of variable.getOptionsForSelect()) { - const filterExists = filters.find((f) => f.key === option.value); - if (!filterExists) { - labelOptions.push({ label: option.label, value: String(option.value) }); - } - } - - return labelOptions; - } - - public onChange = (value: string) => { const variable = this.getVariable(); - if (value === ALL_VARIABLE_VALUE) { - this.setState({ body: buildAllLayout(this.getLabelOptions(variable), this._query!) }); - } else if (variable.hasAllValue()) { - this.setState({ body: buildNormalLayout(this._query!) }); - } - variable.changeValueTo(value); }; public static Component = ({ model }: SceneComponentProps<BreakdownScene>) => { - const { labels, body, loading, value } = model.useState(); + const { labels, body, loading, value, blockingMessage } = model.useState(); const styles = useStyles2(getStyles); return ( <div className={styles.container}> - {loading && <div>Loading...</div>} - <div className={styles.controls}> - <Field label="By label"> - <RadioButtonGroup options={labels} value={value} onChange={model.onChange} /> - </Field> - {body instanceof LayoutSwitcher && ( - <div className={styles.controlsRight}> - <body.Selector model={body} /> - </div> - )} - </div> - <div className={styles.content}>{body && <body.Component model={body} />}</div> + <StatusWrapper {...{ isLoading: loading, blockingMessage }}> + <div className={styles.controls}> + {!loading && labels.length && ( + <div className={styles.controlsLeft}> + <Field label="By label"> + <BreakdownLabelSelector options={labels} value={value} onChange={model.onChange} /> + </Field> + </div> + )} + {body instanceof LayoutSwitcher && ( + <div className={styles.controlsRight}> + <body.Selector model={body} /> + </div> + )} + </div> + <div className={styles.content}>{body && <body.Component model={body} />}</div> + </StatusWrapper> </div> ); }; @@ -168,10 +169,6 @@ function getStyles(theme: GrafanaTheme2) { display: 'flex', paddingTop: theme.spacing(0), }), - tabHeading: css({ - paddingRight: theme.spacing(2), - fontWeight: theme.typography.fontWeightMedium, - }), controls: css({ flexGrow: 0, display: 'flex', @@ -179,10 +176,17 @@ function getStyles(theme: GrafanaTheme2) { gap: theme.spacing(2), }), controlsRight: css({ - flexGrow: 1, + flexGrow: 0, display: 'flex', justifyContent: 'flex-end', }), + controlsLeft: css({ + display: 'flex', + justifyContent: 'flex-left', + justifyItems: 'left', + width: '100%', + flexDirection: 'column', + }), }; } @@ -194,13 +198,13 @@ export function buildAllLayout(options: Array<SelectableValue<string>>, queryDef continue; } - const expr = queryDef.queries[0].expr.replace(VAR_GROUP_BY_EXP, String(option.value)); + const expr = queryDef.queries[0].expr.replaceAll(VAR_GROUP_BY_EXP, String(option.value)); + const unit = queryDef.unit; children.push( new SceneCSSGridItem({ body: PanelBuilders.timeseries() .setTitle(option.label!) - .setUnit(queryDef.unit) .setData( new SceneQueryRunner({ maxDataPoints: 300, @@ -215,6 +219,7 @@ export function buildAllLayout(options: Array<SelectableValue<string>>, queryDef }) ) .setHeaderActions(new SelectLabelAction({ labelName: String(option.value) })) + .setUnit(unit) .build(), }) ); @@ -235,7 +240,8 @@ export function buildAllLayout(options: Array<SelectableValue<string>>, queryDef new SceneCSSGridLayout({ templateColumns: '1fr', autoRows: '200px', - children: children, + // Clone children since a scene object can only have one parent at a time + children: children.map((c) => c.clone()), }), ], }); @@ -243,23 +249,6 @@ export function buildAllLayout(options: Array<SelectableValue<string>>, queryDef const GRID_TEMPLATE_COLUMNS = 'repeat(auto-fit, minmax(400px, 1fr))'; -function getVariableSet() { - return new SceneVariableSet({ - variables: [ - new QueryVariable({ - name: VAR_GROUP_BY, - label: 'Group by', - datasource: trailDS, - includeAll: true, - defaultToAll: true, - query: { query: `label_names(${VAR_METRIC_EXPR})`, refId: 'A' }, - value: '', - text: '', - }), - ], - }); -} - function buildNormalLayout(queryDef: AutoQueryDef) { return new LayoutSwitcher({ $data: new SceneQueryRunner({ @@ -292,7 +281,7 @@ function buildNormalLayout(queryDef: AutoQueryDef) { getLayoutChild: (data, frame, frameIndex) => { return new SceneCSSGridItem({ body: queryDef - .vizBuilder(queryDef) + .vizBuilder() .setTitle(getLabelValue(frame)) .setData(new SceneDataNode({ data: { ...data, series: [frame] } })) .setColor({ mode: 'fixed', fixedColor: getColorByIndex(frameIndex) }) @@ -310,7 +299,7 @@ function buildNormalLayout(queryDef: AutoQueryDef) { getLayoutChild: (data, frame, frameIndex) => { return new SceneCSSGridItem({ body: queryDef - .vizBuilder(queryDef) + .vizBuilder() .setTitle(getLabelValue(frame)) .setData(new SceneDataNode({ data: { ...data, series: [frame] } })) .setColor({ mode: 'fixed', fixedColor: getColorByIndex(frameIndex) }) @@ -324,24 +313,18 @@ function buildNormalLayout(queryDef: AutoQueryDef) { } function getLabelValue(frame: DataFrame) { - const labels = frame.fields[1]?.labels; - - if (!labels) { - return 'No labels'; - } + const labels = frame.fields[1]?.labels || {}; const keys = Object.keys(labels); if (keys.length === 0) { - return 'No labels'; + return '<unspecified>'; } return labels[keys[0]]; } export function buildBreakdownActionScene() { - return new SceneFlexItem({ - body: new BreakdownScene({}), - }); + return new BreakdownScene({}); } interface SelectLabelActionState extends SceneObjectState { @@ -354,7 +337,7 @@ export class SelectLabelAction extends SceneObjectBase<SelectLabelActionState> { public static Component = ({ model }: SceneComponentProps<AddToFiltersGraphAction>) => { return ( - <Button variant="primary" size="sm" fill="text" onClick={model.onClick}> + <Button variant="secondary" size="sm" fill="solid" onClick={model.onClick}> Select </Button> ); diff --git a/public/app/features/trails/ByFrameRepeater.tsx b/public/app/features/trails/ActionTabs/ByFrameRepeater.tsx similarity index 100% rename from public/app/features/trails/ByFrameRepeater.tsx rename to public/app/features/trails/ActionTabs/ByFrameRepeater.tsx diff --git a/public/app/features/trails/LayoutSwitcher.tsx b/public/app/features/trails/ActionTabs/LayoutSwitcher.tsx similarity index 100% rename from public/app/features/trails/LayoutSwitcher.tsx rename to public/app/features/trails/ActionTabs/LayoutSwitcher.tsx diff --git a/public/app/features/trails/ActionTabs/LogsScene.tsx b/public/app/features/trails/ActionTabs/LogsScene.tsx new file mode 100644 index 0000000000000..d223cca222298 --- /dev/null +++ b/public/app/features/trails/ActionTabs/LogsScene.tsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import { + DataSourceVariable, + PanelBuilders, + SceneComponentProps, + SceneFlexItem, + SceneFlexLayout, + SceneObject, + SceneObjectBase, + SceneObjectState, + SceneQueryRunner, + SceneVariableSet, + VariableValueSelectors, +} from '@grafana/scenes'; +import { Stack } from '@grafana/ui'; + +import { SelectMetricAction } from '../SelectMetricAction'; +import { LOGS_METRIC, VAR_LOGS_DATASOURCE, VAR_LOGS_DATASOURCE_EXPR } from '../shared'; + +interface LogsSceneState extends SceneObjectState { + initialDS?: string; + controls: SceneObject[]; + body: SceneFlexLayout; +} + +export class LogsScene extends SceneObjectBase<LogsSceneState> { + public constructor(state: Partial<LogsSceneState>) { + const logsQuery = new SceneQueryRunner({ + datasource: { uid: VAR_LOGS_DATASOURCE_EXPR }, + queries: [ + { + refId: 'A', + expr: '{${filters}} | logfmt', + }, + ], + }); + + super({ + $variables: state.$variables ?? getVariableSet(state.initialDS), + controls: state.controls ?? [new VariableValueSelectors({ layout: 'vertical' })], + body: + state.body ?? + new SceneFlexLayout({ + direction: 'column', + children: [ + new SceneFlexItem({ + body: PanelBuilders.logs() + .setTitle('Logs') + .setData(logsQuery) + .setHeaderActions(new SelectMetricAction({ metric: LOGS_METRIC, title: 'Open' })) + .build(), + }), + ], + }), + ...state, + }); + } + + static Component = ({ model }: SceneComponentProps<LogsScene>) => { + const { controls, body } = model.useState(); + + return ( + <Stack gap={1} direction={'column'} grow={1}> + {controls && ( + <Stack gap={1}> + {controls.map((control) => ( + <control.Component key={control.state.key} model={control} /> + ))} + </Stack> + )} + <body.Component model={body} /> + </Stack> + ); + }; +} + +function getVariableSet(initialDS?: string) { + return new SceneVariableSet({ + variables: [ + new DataSourceVariable({ + name: VAR_LOGS_DATASOURCE, + label: 'Logs data source', + value: initialDS, + pluginId: 'loki', + }), + ], + }); +} + +export function buildLogsScene() { + return new SceneFlexItem({ + body: new LogsScene({}), + }); +} diff --git a/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx b/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx new file mode 100644 index 0000000000000..3c5eef35f143d --- /dev/null +++ b/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx @@ -0,0 +1,139 @@ +import React from 'react'; + +import { + QueryVariable, + SceneComponentProps, + sceneGraph, + SceneObjectBase, + SceneObjectState, + VariableDependencyConfig, +} from '@grafana/scenes'; +import { Stack, Text, TextLink } from '@grafana/ui'; + +import PrometheusLanguageProvider from '../../../plugins/datasource/prometheus/language_provider'; +import { PromMetricsMetadataItem } from '../../../plugins/datasource/prometheus/types'; +import { getDatasourceSrv } from '../../plugins/datasource_srv'; +import { ALL_VARIABLE_VALUE } from '../../variables/constants'; +import { StatusWrapper } from '../StatusWrapper'; +import { TRAILS_ROUTE, VAR_DATASOURCE_EXPR, VAR_GROUP_BY } from '../shared'; +import { getMetricSceneFor, getTrailFor } from '../utils'; + +import { getLabelOptions } from './utils'; + +export interface MetricOverviewSceneState extends SceneObjectState { + metadata?: PromMetricsMetadataItem; + metadataLoading?: boolean; +} + +export class MetricOverviewScene extends SceneObjectBase<MetricOverviewSceneState> { + protected _variableDependency = new VariableDependencyConfig(this, { + variableNames: [VAR_DATASOURCE_EXPR], + onReferencedVariableValueChanged: this.onReferencedVariableValueChanged.bind(this), + }); + + constructor(state: Partial<MetricOverviewSceneState>) { + super({ + ...state, + }); + + this.addActivationHandler(this._onActivate.bind(this)); + } + + private getVariable(): QueryVariable { + const variable = sceneGraph.lookupVariable(VAR_GROUP_BY, this)!; + if (!(variable instanceof QueryVariable)) { + throw new Error('Group by variable not found'); + } + + return variable; + } + + private _onActivate() { + this.updateMetadata(); + } + + private onReferencedVariableValueChanged() { + this.updateMetadata(); + } + + private async updateMetadata() { + this.setState({ metadataLoading: true, metadata: undefined }); + const ds = await getDatasourceSrv().get(VAR_DATASOURCE_EXPR, { __sceneObject: { value: this } }); + + const languageProvider: PrometheusLanguageProvider = ds.languageProvider; + + if (!languageProvider) { + return; + } + + const metricScene = getMetricSceneFor(this); + const metric = metricScene.state.metric; + + if (languageProvider.metricsMetadata) { + this.setState({ metadata: languageProvider.metricsMetadata[metric], metadataLoading: false }); + return; + } + + await languageProvider.start(); + + this.setState({ metadata: languageProvider.metricsMetadata?.[metric], metadataLoading: false }); + } + + public static Component = ({ model }: SceneComponentProps<MetricOverviewScene>) => { + const { metadata, metadataLoading } = model.useState(); + const variable = model.getVariable(); + const { loading: labelsLoading } = variable.useState(); + const labelOptions = getLabelOptions(model, variable).filter((l) => l.value !== ALL_VARIABLE_VALUE); + + return ( + <StatusWrapper isLoading={labelsLoading || metadataLoading}> + <Stack gap={6}> + <> + <Stack direction="column" gap={0.5}> + <Text weight={'medium'}>Description</Text> + <div style={{ maxWidth: 360 }}> + {metadata?.help ? <div>{metadata?.help}</div> : <i>No description available</i>} + </div> + </Stack> + <Stack direction="column" gap={0.5}> + <Text weight={'medium'}>Type</Text> + {metadata?.type ? <div>{metadata?.type}</div> : <i>Unknown</i>} + </Stack> + <Stack direction="column" gap={0.5}> + <Text weight={'medium'}>Unit</Text> + {metadata?.unit ? <div>{metadata?.unit}</div> : <i>Unknown</i>} + </Stack> + <Stack direction="column" gap={0.5}> + <Text weight={'medium'}>Labels</Text> + {labelOptions.length === 0 && 'Unable to fetch labels.'} + {labelOptions.map((l) => + getTrailFor(model).state.embedded ? ( + // Do not render as TextLink when in embedded mode, as any direct URL + // manipulation will take the browser out out of the current page. + <div key={l.label}>{l.label}</div> + ) : ( + <TextLink + key={l.label} + href={sceneGraph.interpolate( + model, + `${TRAILS_ROUTE}$\{__url.params:exclude:actionView,var-groupby}&actionView=breakdown&var-groupby=${encodeURIComponent( + l.value! + )}` + )} + title="View breakdown" + > + {l.label!} + </TextLink> + ) + )} + </Stack> + </> + </Stack> + </StatusWrapper> + ); + }; +} + +export function buildMetricOverviewScene() { + return new MetricOverviewScene({}); +} diff --git a/public/app/features/trails/ActionTabs/RelatedMetricsScene.tsx b/public/app/features/trails/ActionTabs/RelatedMetricsScene.tsx new file mode 100644 index 0000000000000..42c00db75f9b6 --- /dev/null +++ b/public/app/features/trails/ActionTabs/RelatedMetricsScene.tsx @@ -0,0 +1,5 @@ +import { MetricSelectScene } from '../MetricSelectScene'; + +export function buildRelatedMetricsScene() { + return new MetricSelectScene({}); +} diff --git a/public/app/features/trails/ActionTabs/utils.ts b/public/app/features/trails/ActionTabs/utils.ts new file mode 100644 index 0000000000000..b606f6763705c --- /dev/null +++ b/public/app/features/trails/ActionTabs/utils.ts @@ -0,0 +1,24 @@ +import { SelectableValue } from '@grafana/data'; +import { AdHocFiltersVariable, QueryVariable, sceneGraph, SceneObject } from '@grafana/scenes'; + +import { VAR_FILTERS } from '../shared'; + +export function getLabelOptions(scenObject: SceneObject, variable: QueryVariable) { + const labelFilters = sceneGraph.lookupVariable(VAR_FILTERS, scenObject); + const labelOptions: Array<SelectableValue<string>> = []; + + if (!(labelFilters instanceof AdHocFiltersVariable)) { + return []; + } + + const filters = labelFilters.state.filters; + + for (const option of variable.getOptionsForSelect()) { + const filterExists = filters.find((f) => f.key === option.value); + if (!filterExists) { + labelOptions.push({ label: option.label, value: String(option.value) }); + } + } + + return labelOptions; +} diff --git a/public/app/features/trails/AutomaticMetricQueries/AutoQueryEngine.test.ts b/public/app/features/trails/AutomaticMetricQueries/AutoQueryEngine.test.ts new file mode 100644 index 0000000000000..b8176b51c37a0 --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/AutoQueryEngine.test.ts @@ -0,0 +1,355 @@ +import { getAutoQueriesForMetric } from './AutoQueryEngine'; + +function expandExpr(shortenedExpr: string) { + return shortenedExpr.replace('...', '${metric}{${filters}}'); +} + +describe('getAutoQueriesForMetric', () => { + describe('for the summary/histogram types', () => { + const etc = '{${filters}}[$__rate_interval]'; + const byGroup = 'by(${groupby})'; + + describe('metrics with _sum suffix', () => { + const result = getAutoQueriesForMetric('SUM_OR_HIST_sum'); + + test('main query is the mean', () => { + const [{ expr }] = result.main.queries; + const mean = `sum(rate(SUM_OR_HIST_sum${etc}))/sum(rate(SUM_OR_HIST_count${etc}))`; + expect(expr).toBe(mean); + }); + + test('preview query is the mean', () => { + const [{ expr }] = result.preview.queries; + const mean = `sum(rate(SUM_OR_HIST_sum${etc}))/sum(rate(SUM_OR_HIST_count${etc}))`; + expect(expr).toBe(mean); + }); + + test('breakdown query is the mean by group', () => { + const [{ expr }] = result.breakdown.queries; + const meanBreakdown = `sum(rate(SUM_OR_HIST_sum${etc}))${byGroup}/sum(rate(SUM_OR_HIST_count${etc}))${byGroup}`; + expect(expr).toBe(meanBreakdown); + }); + + test('there are no variants', () => { + expect(result.variants.length).toBe(0); + }); + }); + + describe('metrics with _count suffix', () => { + const result = getAutoQueriesForMetric('SUM_OR_HIST_count'); + + test('main query is an overall rate', () => { + const [{ expr }] = result.main.queries; + const overallRate = `sum(rate(\${metric}${etc}))`; + expect(expr).toBe(overallRate); + }); + + test('preview query is an overall rate', () => { + const [{ expr }] = result.preview.queries; + const overallRate = `sum(rate(\${metric}${etc}))`; + expect(expr).toBe(overallRate); + }); + + test('breakdown query is an overall rate by group', () => { + const [{ expr }] = result.breakdown.queries; + const overallRateBreakdown = `sum(rate(\${metric}${etc}))${byGroup}`; + expect(expr).toBe(overallRateBreakdown); + }); + + test('there are no variants', () => { + expect(result.variants.length).toBe(0); + }); + }); + + describe('metrics with _bucket suffix', () => { + const result = getAutoQueriesForMetric('HIST_bucket'); + + const percentileQueries = new Map<number, string>(); + percentileQueries.set(99, expandExpr('histogram_quantile(0.99, sum by(le) (rate(...[$__rate_interval])))')); + percentileQueries.set(90, expandExpr('histogram_quantile(0.9, sum by(le) (rate(...[$__rate_interval])))')); + percentileQueries.set(50, expandExpr('histogram_quantile(0.5, sum by(le) (rate(...[$__rate_interval])))')); + + test('there are 2 variants', () => { + expect(result.variants.length).toBe(2); + }); + + const percentilesVariant = result.variants.find((variant) => variant.variant === 'percentiles'); + test('there is a percentiles variant', () => { + expect(percentilesVariant).not.toBeNull(); + }); + + const heatmap = result.variants.find((variant) => variant.variant === 'heatmap'); + test('there is a heatmap variant', () => { + expect(heatmap).not.toBeNull(); + }); + + [99, 90, 50].forEach((percentile) => { + const percentileQuery = percentileQueries.get(percentile); + test(`main panel has ${percentile}th percentile query`, () => { + const found = result.main.queries.find((query) => query.expr === percentileQuery); + expect(found).not.toBeNull(); + }); + }); + + [99, 90, 50].forEach((percentile) => { + const percentileQuery = percentileQueries.get(percentile); + test(`percentiles variant panel has ${percentile}th percentile query`, () => { + const found = percentilesVariant?.queries.find((query) => query.expr === percentileQuery); + expect(found).not.toBeNull(); + }); + }); + + test('preview panel has 50th percentile query', () => { + const [{ expr }] = result.preview.queries; + expect(expr).toBe(percentileQueries.get(50)); + }); + + const percentileGroupedQueries = new Map<number, string>(); + percentileGroupedQueries.set( + 99, + expandExpr('histogram_quantile(0.99, sum by(le, ${groupby}) (rate(...[$__rate_interval])))') + ); + percentileGroupedQueries.set( + 90, + expandExpr('histogram_quantile(0.9, sum by(le, ${groupby}) (rate(...[$__rate_interval])))') + ); + percentileGroupedQueries.set( + 50, + expandExpr('histogram_quantile(0.5, sum by(le, ${groupby}) (rate(...[$__rate_interval])))') + ); + + [99, 90, 50].forEach((percentile) => { + const percentileGroupedQuery = percentileGroupedQueries.get(percentile); + test(`breakdown panel has ${percentile}th query with \${groupby} appended`, () => { + const found = result.breakdown.queries.find((query) => query.expr === percentileGroupedQuery); + expect(found).not.toBeNull(); + }); + }); + }); + }); + describe('Consider result.main query (only first)', () => { + it.each([ + // no rate + ['PREFIX_general', 'avg(...)', 'short', 1], + ['PREFIX_bytes', 'avg(...)', 'bytes', 1], + ['PREFIX_seconds', 'avg(...)', 's', 1], + // rate with counts per second + ['PREFIX_count', 'sum(rate(...[$__rate_interval]))', 'cps', 1], // cps = counts per second + ['PREFIX_total', 'sum(rate(...[$__rate_interval]))', 'cps', 1], + ['PREFIX_seconds_count', 'sum(rate(...[$__rate_interval]))', 'cps', 1], + // rate with seconds per second + ['PREFIX_seconds_total', 'sum(rate(...[$__rate_interval]))', 'short', 1], // s/s + // rate with bytes per second + ['PREFIX_bytes_total', 'sum(rate(...[$__rate_interval]))', 'Bps', 1], // bytes/s + // mean with non-rated units + [ + 'PREFIX_seconds_sum', + 'sum(rate(PREFIX_seconds_sum{${filters}}[$__rate_interval]))/sum(rate(PREFIX_seconds_count{${filters}}[$__rate_interval]))', + 's', + 1, + ], + [ + 'PREFIX_bytes_sum', + 'sum(rate(PREFIX_bytes_sum{${filters}}[$__rate_interval]))/sum(rate(PREFIX_bytes_count{${filters}}[$__rate_interval]))', + 'bytes', + 1, + ], + // Bucket + ['PREFIX_bucket', 'histogram_quantile(0.99, sum by(le) (rate(...[$__rate_interval])))', 'short', 3], + ['PREFIX_seconds_bucket', 'histogram_quantile(0.99, sum by(le) (rate(...[$__rate_interval])))', 's', 3], + ['PREFIX_bytes_bucket', 'histogram_quantile(0.99, sum by(le) (rate(...[$__rate_interval])))', 'bytes', 3], + ])('Given metric %p expect %p with unit %p', (metric, expr, unit, queryCount) => { + const result = getAutoQueriesForMetric(metric); + + const queryDef = result.main; + + const expected = { expr: expandExpr(expr), unit, queryCount }; + const actual = { expr: queryDef.queries[0].expr, unit: queryDef.unit, queryCount: queryDef.queries.length }; + + expect(actual).toStrictEqual(expected); + }); + }); + + describe('Consider result.preview query (only first)', () => { + it.each([ + // no rate + ['PREFIX_general', 'avg(...)', 'short'], + ['PREFIX_bytes', 'avg(...)', 'bytes'], + ['PREFIX_seconds', 'avg(...)', 's'], + // rate with counts per second + ['PREFIX_count', 'sum(rate(...[$__rate_interval]))', 'cps'], // cps = counts per second + ['PREFIX_total', 'sum(rate(...[$__rate_interval]))', 'cps'], + ['PREFIX_seconds_count', 'sum(rate(...[$__rate_interval]))', 'cps'], + // rate with seconds per second + ['PREFIX_seconds_total', 'sum(rate(...[$__rate_interval]))', 'short'], // s/s + // rate with bytes per second + ['PREFIX_bytes_total', 'sum(rate(...[$__rate_interval]))', 'Bps'], // bytes/s + // mean with non-rated units + [ + 'PREFIX_seconds_sum', + 'sum(rate(PREFIX_seconds_sum{${filters}}[$__rate_interval]))/sum(rate(PREFIX_seconds_count{${filters}}[$__rate_interval]))', + 's', + ], + [ + 'PREFIX_bytes_sum', + 'sum(rate(PREFIX_bytes_sum{${filters}}[$__rate_interval]))/sum(rate(PREFIX_bytes_count{${filters}}[$__rate_interval]))', + 'bytes', + ], + // Bucket + ['PREFIX_bucket', 'histogram_quantile(0.5, sum by(le) (rate(...[$__rate_interval])))', 'short'], + ['PREFIX_seconds_bucket', 'histogram_quantile(0.5, sum by(le) (rate(...[$__rate_interval])))', 's'], + ['PREFIX_bytes_bucket', 'histogram_quantile(0.5, sum by(le) (rate(...[$__rate_interval])))', 'bytes'], + ])('Given metric %p expect %p with unit %p', (metric, expr, unit) => { + const result = getAutoQueriesForMetric(metric); + + const queryDef = result.preview; + + const queryCount = 1; + + const expected = { expr: expandExpr(expr), unit, queryCount }; + const actual = { expr: queryDef.queries[0].expr, unit: queryDef.unit, queryCount: queryDef.queries.length }; + + expect(actual).toStrictEqual(expected); + }); + }); + + describe('Consider result.breakdown query (only first)', () => { + it.each([ + // no rate + ['PREFIX_general', 'avg(...)by(${groupby})', 'short'], + ['PREFIX_bytes', 'avg(...)by(${groupby})', 'bytes'], + ['PREFIX_seconds', 'avg(...)by(${groupby})', 's'], + // rate with counts per second + ['PREFIX_count', 'sum(rate(...[$__rate_interval]))by(${groupby})', 'cps'], // cps = counts per second + ['PREFIX_total', 'sum(rate(...[$__rate_interval]))by(${groupby})', 'cps'], + ['PREFIX_seconds_count', 'sum(rate(...[$__rate_interval]))by(${groupby})', 'cps'], + // rate with seconds per second + ['PREFIX_seconds_total', 'sum(rate(...[$__rate_interval]))by(${groupby})', 'short'], // s/s + // rate with bytes per second + ['PREFIX_bytes_total', 'sum(rate(...[$__rate_interval]))by(${groupby})', 'Bps'], // bytes/s + // mean with non-rated units + [ + 'PREFIX_seconds_sum', + 'sum(rate(PREFIX_seconds_sum{${filters}}[$__rate_interval]))by(${groupby})/sum(rate(PREFIX_seconds_count{${filters}}[$__rate_interval]))by(${groupby})', + 's', + ], + [ + 'PREFIX_bytes_sum', + 'sum(rate(PREFIX_bytes_sum{${filters}}[$__rate_interval]))by(${groupby})/sum(rate(PREFIX_bytes_count{${filters}}[$__rate_interval]))by(${groupby})', + 'bytes', + ], + // Bucket + ['PREFIX_bucket', 'histogram_quantile(0.5, sum by(le, ${groupby}) (rate(...[$__rate_interval])))', 'short'], + ['PREFIX_seconds_bucket', 'histogram_quantile(0.5, sum by(le, ${groupby}) (rate(...[$__rate_interval])))', 's'], + ['PREFIX_bytes_bucket', 'histogram_quantile(0.5, sum by(le, ${groupby}) (rate(...[$__rate_interval])))', 'bytes'], + ])('Given metric %p expect %p with unit %p', (metric, expr, unit) => { + const result = getAutoQueriesForMetric(metric); + + const queryDef = result.breakdown; + + const queryCount = 1; + + const expected = { expr: expandExpr(expr), unit, queryCount }; + const actual = { expr: queryDef.queries[0].expr, unit: queryDef.unit, queryCount: queryDef.queries.length }; + + expect(actual).toStrictEqual(expected); + }); + }); + + describe('Consider result.variant', () => { + it.each([ + // No variants + ['PREFIX_count', []], + ['PREFIX_seconds_count', []], + ['PREFIX_bytes', []], + ['PREFIX_seconds', []], + ['PREFIX_general', []], + ['PREFIX_seconds_total', []], + ['PREFIX_seconds_sum', []], + // Bucket variants + [ + 'PREFIX_bucket', + [ + { + variant: 'percentiles', + unit: 'short', + exprs: [ + 'histogram_quantile(0.99, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))', + 'histogram_quantile(0.9, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))', + 'histogram_quantile(0.5, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))', + ], + }, + { + variant: 'heatmap', + unit: 'short', + exprs: ['sum by(le) (rate(${metric}{${filters}}[$__rate_interval]))'], + }, + ], + ], + [ + 'PREFIX_seconds_bucket', + [ + { + variant: 'percentiles', + unit: 's', + exprs: [ + 'histogram_quantile(0.99, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))', + 'histogram_quantile(0.9, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))', + 'histogram_quantile(0.5, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))', + ], + }, + { + variant: 'heatmap', + unit: 's', + exprs: ['sum by(le) (rate(${metric}{${filters}}[$__rate_interval]))'], + }, + ], + ], + [ + 'PREFIX_bytes_bucket', + [ + { + variant: 'percentiles', + unit: 'bytes', + exprs: [ + 'histogram_quantile(0.99, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))', + 'histogram_quantile(0.9, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))', + 'histogram_quantile(0.5, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))', + ], + }, + { + variant: 'heatmap', + unit: 'bytes', + exprs: ['sum by(le) (rate(${metric}{${filters}}[$__rate_interval]))'], + }, + ], + ], + ])('Given metric %p should generate expected variants', (metric, expectedVariants) => { + const defs = getAutoQueriesForMetric(metric); + + const received = defs.variants.map((variant) => ({ + variant: variant.variant, + unit: variant.unit, + exprs: variant.queries.map((query) => query.expr), + })); + + expect(received).toStrictEqual(expectedVariants); + }); + }); + + describe('Able to handle unconventional metric names', () => { + it.each([['PRODUCT_High_Priority_items_', 'avg(...)', 'short', 1]])( + 'Given metric %p expect %p with unit %p', + (metric, expr, unit, queryCount) => { + const result = getAutoQueriesForMetric(metric); + + const queryDef = result.main; + + const expected = { expr: expandExpr(expr), unit, queryCount }; + const actual = { expr: queryDef.queries[0].expr, unit: queryDef.unit, queryCount: queryDef.queries.length }; + + expect(actual).toStrictEqual(expected); + } + ); + }); +}); diff --git a/public/app/features/trails/AutomaticMetricQueries/AutoQueryEngine.ts b/public/app/features/trails/AutomaticMetricQueries/AutoQueryEngine.ts index fc192b6803b22..822c388c92553 100644 --- a/public/app/features/trails/AutomaticMetricQueries/AutoQueryEngine.ts +++ b/public/app/features/trails/AutomaticMetricQueries/AutoQueryEngine.ts @@ -1,178 +1,16 @@ -import { PanelBuilders, VizPanelBuilder } from '@grafana/scenes'; -import { PromQuery } from 'app/plugins/datasource/prometheus/types'; -import { HeatmapColorMode } from 'app/plugins/panel/heatmap/types'; - -import { VAR_FILTERS_EXPR, VAR_GROUP_BY_EXP, VAR_METRIC_EXPR } from '../shared'; - -export interface AutoQueryDef { - variant: string; - title: string; - unit: string; - queries: PromQuery[]; - vizBuilder: (def: AutoQueryDef) => VizPanelBuilder<{}, {}>; -} - -export interface AutoQueryInfo { - preview: AutoQueryDef; - main: AutoQueryDef; - variants: AutoQueryDef[]; - breakdown: AutoQueryDef; -} +import { getQueryGeneratorFor } from './query-generators'; +import { AutoQueryInfo } from './types'; export function getAutoQueriesForMetric(metric: string): AutoQueryInfo { - let unit = 'short'; - let agg = 'avg'; - let rate = false; - let title = metric; - - if (metric.endsWith('seconds_sum')) { - unit = 's'; - agg = 'avg'; - rate = true; - } else if (metric.endsWith('seconds')) { - unit = 's'; - agg = 'avg'; - rate = false; - } else if (metric.endsWith('bytes')) { - unit = 'bytes'; - agg = 'avg'; - rate = false; - } else if (metric.endsWith('seconds_count') || metric.endsWith('seconds_total')) { - agg = 'sum'; - rate = true; - } else if (metric.endsWith('bucket')) { - return getQueriesForBucketMetric(metric); - } else if (metric.endsWith('count') || metric.endsWith('total')) { - agg = 'sum'; - rate = true; - } - - let query = `${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}`; - if (rate) { - query = `rate(${query}[$__rate_interval])`; - } - - const main: AutoQueryDef = { - title: `${title}`, - variant: 'graph', - unit, - queries: [{ refId: 'A', expr: `${agg}(${query})` }], - vizBuilder: simpleGraphBuilder, - }; - - const breakdown: AutoQueryDef = { - title: `${title}`, - variant: 'graph', - unit, - queries: [ - { - refId: 'A', - expr: `${agg}(${query}) by(${VAR_GROUP_BY_EXP})`, - legendFormat: `{{${VAR_GROUP_BY_EXP}}}`, - }, - ], - vizBuilder: simpleGraphBuilder, - }; + const metricParts = metric.split('_'); - return { preview: main, main: main, breakdown: breakdown, variants: [] }; -} + const suffix = metricParts.at(-1); -function getQueriesForBucketMetric(metric: string): AutoQueryInfo { - let unit = 'short'; + const generator = getQueryGeneratorFor(suffix); - if (metric.endsWith('seconds_bucket')) { - unit = 's'; + if (!generator) { + throw new Error(`Unable to generate queries for metric "${metric}" due to issues with derived suffix "${suffix}"`); } - const p50: AutoQueryDef = { - title: metric, - variant: 'p50', - unit, - queries: [ - { - refId: 'A', - expr: `histogram_quantile(0.50, sum by(le) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])))`, - }, - ], - vizBuilder: simpleGraphBuilder, - }; - - const breakdown: AutoQueryDef = { - title: metric, - variant: 'p50', - unit, - queries: [ - { - refId: 'A', - expr: `histogram_quantile(0.50, sum by(le, ${VAR_GROUP_BY_EXP}) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])))`, - }, - ], - vizBuilder: simpleGraphBuilder, - }; - - const percentiles: AutoQueryDef = { - title: metric, - variant: 'percentiles', - unit, - queries: [ - { - refId: 'A', - expr: `histogram_quantile(0.99, sum by(le) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])))`, - legendFormat: '99th Percentile', - }, - { - refId: 'B', - expr: `histogram_quantile(0.90, sum by(le) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])))`, - legendFormat: '90th Percentile', - }, - { - refId: 'C', - expr: `histogram_quantile(0.50, sum by(le) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])))`, - legendFormat: '50th Percentile', - }, - ], - vizBuilder: percentilesGraphBuilder, - }; - - const heatmap: AutoQueryDef = { - title: metric, - variant: 'heatmap', - unit, - queries: [ - { - refId: 'A', - expr: `sum by(le) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval]))`, - format: 'heatmap', - }, - ], - vizBuilder: heatmapGraphBuilder, - }; - - return { preview: p50, main: percentiles, variants: [percentiles, heatmap], breakdown: breakdown }; -} - -function simpleGraphBuilder(def: AutoQueryDef) { - return PanelBuilders.timeseries() - .setTitle(def.title) - .setUnit(def.unit) - .setOption('legend', { showLegend: false }) - .setCustomFieldConfig('fillOpacity', 9); -} - -function percentilesGraphBuilder(def: AutoQueryDef) { - return PanelBuilders.timeseries().setTitle(def.title).setUnit(def.unit).setCustomFieldConfig('fillOpacity', 9); -} - -function heatmapGraphBuilder(def: AutoQueryDef) { - return PanelBuilders.heatmap() - .setTitle(def.title) - .setUnit(def.unit) - .setOption('calculate', false) - .setOption('color', { - mode: HeatmapColorMode.Scheme, - exponent: 0.5, - scheme: 'Spectral', - steps: 32, - reverse: false, - }); + return generator(metricParts); } diff --git a/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx b/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx index d2cc5a682d7be..7559301862367 100644 --- a/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx +++ b/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx @@ -1,57 +1,57 @@ -import { css } from '@emotion/css'; import React from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; import { SceneObjectState, SceneObjectBase, SceneComponentProps, VizPanel, SceneQueryRunner } from '@grafana/scenes'; -import { Field, RadioButtonGroup, useStyles2, Stack } from '@grafana/ui'; +import { RadioButtonGroup } from '@grafana/ui'; import { trailDS } from '../shared'; -import { getTrailSettings } from '../utils'; +import { getMetricSceneFor } from '../utils'; -import { AutoQueryDef, AutoQueryInfo } from './AutoQueryEngine'; +import { AutoQueryDef } from './types'; export interface AutoVizPanelState extends SceneObjectState { panel?: VizPanel; - autoQuery: AutoQueryInfo; - queryDef?: AutoQueryDef; } export class AutoVizPanel extends SceneObjectBase<AutoVizPanelState> { constructor(state: AutoVizPanelState) { super(state); - if (!state.panel) { - this.setState({ - panel: this.getVizPanelFor(state.autoQuery.main), - queryDef: state.autoQuery.main, - }); - } + this.addActivationHandler(this.onActivate.bind(this)); + } + + public onActivate() { + const { autoQuery } = getMetricSceneFor(this).state; + this.setState({ + panel: this.getVizPanelFor(autoQuery.main), + }); } private getQuerySelector(def: AutoQueryDef) { - const variants = this.state.autoQuery.variants; + const { autoQuery } = getMetricSceneFor(this).state; - if (variants.length === 0) { + if (autoQuery.variants.length === 0) { return; } - const options = variants.map((q) => ({ label: q.variant, value: q.variant })); + const options = autoQuery.variants.map((q) => ({ label: q.variant, value: q.variant })); return <RadioButtonGroup size="sm" options={options} value={def.variant} onChange={this.onChangeQuery} />; } public onChangeQuery = (variant: string) => { - const def = this.state.autoQuery.variants.find((q) => q.variant === variant)!; + const metricScene = getMetricSceneFor(this); + + const def = metricScene.state.autoQuery.variants.find((q) => q.variant === variant)!; this.setState({ panel: this.getVizPanelFor(def), - queryDef: def, }); + metricScene.setState({ queryDef: def }); }; private getVizPanelFor(def: AutoQueryDef) { return def - .vizBuilder(def) + .vizBuilder() .setData( new SceneQueryRunner({ datasource: trailDS, @@ -64,43 +64,11 @@ export class AutoVizPanel extends SceneObjectBase<AutoVizPanelState> { } public static Component = ({ model }: SceneComponentProps<AutoVizPanel>) => { - const { panel, queryDef } = model.useState(); - const { showQuery } = getTrailSettings(model).useState(); - const styles = useStyles2(getStyles); + const { panel } = model.useState(); if (!panel) { return; } - - if (!showQuery) { - return <panel.Component model={panel} />; - } - - return ( - <div className={styles.wrapper}> - <Stack gap={2}> - <Field label="Query"> - <div>{queryDef && queryDef.queries.map((query, index) => <div key={index}>{query.expr}</div>)}</div> - </Field> - </Stack> - <div className={styles.panel}> - <panel.Component model={panel} /> - </div> - </div> - ); - }; -} - -function getStyles(theme: GrafanaTheme2) { - return { - wrapper: css({ - display: 'flex', - flexDirection: 'column', - flexGrow: 1, - }), - panel: css({ - position: 'relative', - flexGrow: 1, - }), + return <panel.Component model={panel} />; }; } diff --git a/public/app/features/trails/AutomaticMetricQueries/graph-builders/heatmap.ts b/public/app/features/trails/AutomaticMetricQueries/graph-builders/heatmap.ts new file mode 100644 index 0000000000000..252dba7470154 --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/graph-builders/heatmap.ts @@ -0,0 +1,18 @@ +import { PanelBuilders } from '@grafana/scenes'; +import { HeatmapColorMode } from 'app/plugins/panel/heatmap/types'; + +import { CommonVizParams } from './types'; + +export function heatmapGraphBuilder({ title, unit }: CommonVizParams) { + return PanelBuilders.heatmap() // + .setTitle(title) + .setUnit(unit) + .setOption('calculate', false) + .setOption('color', { + mode: HeatmapColorMode.Scheme, + exponent: 0.5, + scheme: 'Spectral', + steps: 32, + reverse: false, + }); +} diff --git a/public/app/features/trails/AutomaticMetricQueries/graph-builders/percentiles.ts b/public/app/features/trails/AutomaticMetricQueries/graph-builders/percentiles.ts new file mode 100644 index 0000000000000..9f40180d68d32 --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/graph-builders/percentiles.ts @@ -0,0 +1,10 @@ +import { PanelBuilders } from '@grafana/scenes'; + +import { CommonVizParams } from './types'; + +export function percentilesGraphBuilder({ title, unit }: CommonVizParams) { + return PanelBuilders.timeseries() // + .setTitle(title) + .setUnit(unit) + .setCustomFieldConfig('fillOpacity', 9); +} diff --git a/public/app/features/trails/AutomaticMetricQueries/graph-builders/simple.ts b/public/app/features/trails/AutomaticMetricQueries/graph-builders/simple.ts new file mode 100644 index 0000000000000..5eab7d70bfdd8 --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/graph-builders/simple.ts @@ -0,0 +1,11 @@ +import { PanelBuilders } from '@grafana/scenes'; + +import { CommonVizParams } from './types'; + +export function simpleGraphBuilder({ title, unit }: CommonVizParams) { + return PanelBuilders.timeseries() // + .setTitle(title) + .setUnit(unit) + .setOption('legend', { showLegend: false }) + .setCustomFieldConfig('fillOpacity', 9); +} diff --git a/public/app/features/trails/AutomaticMetricQueries/graph-builders/types.ts b/public/app/features/trails/AutomaticMetricQueries/graph-builders/types.ts new file mode 100644 index 0000000000000..5167b71f0034a --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/graph-builders/types.ts @@ -0,0 +1,4 @@ +export type CommonVizParams = { + title: string; + unit: string; +}; diff --git a/public/app/features/trails/AutomaticMetricQueries/previewPanel.test.ts b/public/app/features/trails/AutomaticMetricQueries/previewPanel.test.ts new file mode 100644 index 0000000000000..a0c3c9d620e3b --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/previewPanel.test.ts @@ -0,0 +1,31 @@ +import { SceneQueryRunner } from '@grafana/scenes'; + +import { getPreviewPanelFor } from './previewPanel'; + +describe('getPreviewPanelFor', () => { + describe('includes __ignore_usage__ indicator', () => { + function callAndGetExpr(filterCount: number) { + const result = getPreviewPanelFor('METRIC', 0, filterCount); + const runner = result.state.$data as SceneQueryRunner; + expect(runner).toBeInstanceOf(SceneQueryRunner); + const query = runner.state.queries[0]; + const expr = query.expr as string; + return expr; + } + + test('When there are no filters, replace the ${filters} variable', () => { + const expected = 'avg(${metric}{__ignore_usage__=""})'; + const expr = callAndGetExpr(0); + expect(expr).toStrictEqual(expected); + }); + + test('When there are 1 or more filters, append to the ${filters} variable', () => { + const expected = 'avg(${metric}{${filters},__ignore_usage__=""})'; + + for (let i = 1; i < 10; ++i) { + const expr = callAndGetExpr(1); + expect(expr).toStrictEqual(expected); + } + }); + }); +}); diff --git a/public/app/features/trails/AutomaticMetricQueries/previewPanel.ts b/public/app/features/trails/AutomaticMetricQueries/previewPanel.ts new file mode 100644 index 0000000000000..ee23944b4c2bb --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/previewPanel.ts @@ -0,0 +1,48 @@ +import { SceneCSSGridItem, SceneQueryRunner, SceneVariableSet } from '@grafana/scenes'; +import { PromQuery } from 'app/plugins/datasource/prometheus/types'; + +import { SelectMetricAction } from '../SelectMetricAction'; +import { hideEmptyPreviews } from '../hideEmptyPreviews'; +import { getVariablesWithMetricConstant, trailDS } from '../shared'; +import { getColorByIndex } from '../utils'; + +import { getAutoQueriesForMetric } from './AutoQueryEngine'; + +export function getPreviewPanelFor(metric: string, index: number, currentFilterCount: number) { + const autoQuery = getAutoQueriesForMetric(metric); + + const vizPanel = autoQuery.preview + .vizBuilder() + .setColor({ mode: 'fixed', fixedColor: getColorByIndex(index) }) + .setHeaderActions(new SelectMetricAction({ metric, title: 'Select' })) + .build(); + + const queries = autoQuery.preview.queries.map((query) => + convertPreviewQueriesToIgnoreUsage(query, currentFilterCount) + ); + + return new SceneCSSGridItem({ + $variables: new SceneVariableSet({ + variables: getVariablesWithMetricConstant(metric), + }), + $behaviors: [hideEmptyPreviews(metric)], + $data: new SceneQueryRunner({ + datasource: trailDS, + maxDataPoints: 200, + queries, + }), + body: vizPanel, + }); +} + +function convertPreviewQueriesToIgnoreUsage(query: PromQuery, currentFilterCount: number) { + // If there are filters, we append to the list. Otherwise, we replace the empty list. + const replacement = currentFilterCount > 0 ? '${filters},__ignore_usage__=""' : '__ignore_usage__=""'; + + const expr = query.expr?.replace('${filters}', replacement); + + return { + ...query, + expr, + }; +} diff --git a/public/app/features/trails/AutomaticMetricQueries/query-generators/common/generator.ts b/public/app/features/trails/AutomaticMetricQueries/query-generators/common/generator.ts new file mode 100644 index 0000000000000..fbc7ff5d3cb3d --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/query-generators/common/generator.ts @@ -0,0 +1,58 @@ +import { VAR_GROUP_BY_EXP, VAR_METRIC_EXPR } from '../../../shared'; +import { simpleGraphBuilder } from '../../graph-builders/simple'; + +export type CommonQueryInfoParams = { + description: string; + mainQueryExpr: string; + breakdownQueryExpr: string; + unit: string; +}; + +export function generateCommonAutoQueryInfo({ + description, + mainQueryExpr, + breakdownQueryExpr, + unit, +}: CommonQueryInfoParams) { + const common = { + title: `${VAR_METRIC_EXPR}`, + unit, + }; + + const mainQuery = { + refId: 'A', + expr: mainQueryExpr, + legendFormat: description, + }; + + const main = { + ...common, + title: description, + queries: [mainQuery], + variant: 'main', + vizBuilder: () => simpleGraphBuilder({ ...main }), + }; + + const preview = { + ...main, + title: `${VAR_METRIC_EXPR}`, + queries: [{ ...mainQuery, legendFormat: description }], + vizBuilder: () => simpleGraphBuilder(preview), + variant: 'preview', + }; + + const breakdown = { + ...common, + queries: [ + { + refId: 'A', + expr: breakdownQueryExpr, + legendFormat: `{{${VAR_GROUP_BY_EXP}}}`, + }, + ], + vizBuilder: () => simpleGraphBuilder(breakdown), + variant: 'breakdown', + }; + + return { preview, main, breakdown, variants: [] }; +} diff --git a/public/app/features/trails/AutomaticMetricQueries/query-generators/common/index.ts b/public/app/features/trails/AutomaticMetricQueries/query-generators/common/index.ts new file mode 100644 index 0000000000000..4257e212ec743 --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/query-generators/common/index.ts @@ -0,0 +1,9 @@ +import { generateQueries } from './queries'; +import { getGeneratorParameters } from './rules'; + +function generator(metricParts: string[]) { + const params = getGeneratorParameters(metricParts); + return generateQueries(params); +} + +export default { generator }; diff --git a/public/app/features/trails/AutomaticMetricQueries/query-generators/common/queries.test.ts b/public/app/features/trails/AutomaticMetricQueries/query-generators/common/queries.test.ts new file mode 100644 index 0000000000000..bb8650338edf8 --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/query-generators/common/queries.test.ts @@ -0,0 +1,80 @@ +import { VAR_GROUP_BY_EXP } from '../../../shared'; +import { AutoQueryDef, AutoQueryInfo } from '../../types'; + +import { generateQueries, getGeneralBaseQuery } from './queries'; + +describe('generateQueries', () => { + const agg = 'sum'; + const unit = 'mockunit'; + + type QueryInfoKey = keyof AutoQueryInfo; + + function testRateIndependentAssertions(queryDef: AutoQueryDef, key: QueryInfoKey) { + describe('regardless of rate', () => { + test(`specified unit must be propagated`, () => expect(queryDef.unit).toBe(unit)); + test(`only one query is expected`, () => expect(queryDef.queries.length).toBe(1)); + const query = queryDef.queries[0]; + test(`specified agg function must be propagated in the query expr`, () => { + const queryAggFunction = query.expr.split('(', 2)[0]; + expect(queryAggFunction).toBe(agg); + }); + if (key === 'breakdown') { + const expectedSuffix = `by(${VAR_GROUP_BY_EXP})`; + test(`breakdown query must end with "${expectedSuffix}"`, () => { + const suffix = query.expr.substring(query.expr.length - expectedSuffix.length); + expect(suffix).toBe(expectedSuffix); + }); + } + }); + } + + function testRateSpecificAssertions(queryDef: AutoQueryDef, rate: boolean, key: QueryInfoKey) { + const query = queryDef.queries[0]; + const firstParen = query.expr.indexOf('('); + const expectedBaseQuery = getGeneralBaseQuery(rate); + const detectedBaseQuery = query.expr.substring(firstParen + 1, firstParen + 1 + expectedBaseQuery.length); + + const inParentheses = rate ? 'overall per-second rate' : 'overall'; + const description = `\${metric} (${inParentheses})`; + + describe(`since rate is ${rate}`, () => { + test(`base query must be "${expectedBaseQuery}"`, () => expect(detectedBaseQuery).toBe(expectedBaseQuery)); + if (key === 'main') { + test(`main panel title contains expected description "${description}"`, () => + expect(queryDef.title).toContain(description)); + } else { + test(`${key} panel title is just "\${metric}"`, () => expect(queryDef.title).toBe('${metric}')); + test(`${key} panel title does not contain description "${description}"`, () => + expect(queryDef.title).not.toContain(description)); + } + + if (key === 'breakdown') { + test(`breakdown query uses "{{\${groupby}}}" as legend`, () => + expect(query.legendFormat).toBe('{{${groupby}}}')); + } else { + test(`preview query uses "${description}" as legend`, () => expect(query.legendFormat).toBe(description)); + } + }); + } + + for (const rate of [true, false]) { + describe(`when rate is ${rate}`, () => { + const queryInfo = generateQueries({ agg, unit, rate }); + + let key: QueryInfoKey; + for (key in queryInfo) { + if (key !== 'variants') { + const queryDef = queryInfo[key]; + describe(`queryInfo.${key}`, () => testRateIndependentAssertions(queryDef, key)); + describe(`queryInfo.${key}`, () => testRateSpecificAssertions(queryDef, rate, key)); + continue; + } + + queryInfo[key].forEach((queryDef, index) => { + describe(`queryInfo.${key}[${index}]`, () => testRateIndependentAssertions(queryDef, key)); + describe(`queryInfo.${key}[${index}]`, () => testRateSpecificAssertions(queryDef, rate, key)); + }); + } + }); + } +}); diff --git a/public/app/features/trails/AutomaticMetricQueries/query-generators/common/queries.ts b/public/app/features/trails/AutomaticMetricQueries/query-generators/common/queries.ts new file mode 100644 index 0000000000000..eb43d8537cee7 --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/query-generators/common/queries.ts @@ -0,0 +1,39 @@ +import { VAR_FILTERS_EXPR, VAR_GROUP_BY_EXP, VAR_METRIC_EXPR } from '../../../shared'; +import { AutoQueryInfo } from '../../types'; +import { generateCommonAutoQueryInfo } from '../common/generator'; + +import { AutoQueryParameters } from './types'; + +const GENERAL_BASE_QUERY = `${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}`; +const GENERAL_RATE_BASE_QUERY = `rate(${GENERAL_BASE_QUERY}[$__rate_interval])`; + +export function getGeneralBaseQuery(rate: boolean) { + return rate ? GENERAL_RATE_BASE_QUERY : GENERAL_BASE_QUERY; +} + +const aggLabels: Record<string, string> = { + avg: 'average', + sum: 'overall', +}; + +function getAggLabel(agg: string) { + return aggLabels[agg] || agg; +} + +export function generateQueries({ agg, rate, unit }: AutoQueryParameters): AutoQueryInfo { + const baseQuery = getGeneralBaseQuery(rate); + + const aggregationDescription = rate ? `${getAggLabel(agg)} per-second rate` : `${getAggLabel(agg)}`; + + const description = `${VAR_METRIC_EXPR} (${aggregationDescription})`; + + const mainQueryExpr = `${agg}(${baseQuery})`; + const breakdownQueryExpr = `${agg}(${baseQuery})by(${VAR_GROUP_BY_EXP})`; + + return generateCommonAutoQueryInfo({ + description, + mainQueryExpr, + breakdownQueryExpr, + unit, + }); +} diff --git a/public/app/features/trails/AutomaticMetricQueries/query-generators/common/rules.ts b/public/app/features/trails/AutomaticMetricQueries/query-generators/common/rules.ts new file mode 100644 index 0000000000000..96e0302089dce --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/query-generators/common/rules.ts @@ -0,0 +1,39 @@ +import { getUnit, getPerSecondRateUnit } from '../../units'; + +import { AutoQueryParameters } from './types'; + +/** These suffixes will set rate to true */ +const RATE_SUFFIXES = new Set(['count', 'total']); + +const UNSUPPORTED_SUFFIXES = new Set(['sum', 'bucket']); + +/** Non-default aggregattion keyed by suffix */ +const SPECIFIC_AGGREGATIONS_FOR_SUFFIX: Record<string, string> = { + count: 'sum', + total: 'sum', +}; + +function checkPreviousForUnit(suffix: string) { + return suffix === 'total'; +} + +export function getGeneratorParameters(metricParts: string[]): AutoQueryParameters { + const suffix = metricParts.at(-1); + + if (suffix == null || UNSUPPORTED_SUFFIXES.has(suffix)) { + throw new Error(`This function does not support a metric suffix of "${suffix}"`); + } + + const rate = RATE_SUFFIXES.has(suffix); + const agg = SPECIFIC_AGGREGATIONS_FOR_SUFFIX[suffix] || 'avg'; + + const unitSuffix = checkPreviousForUnit(suffix) ? metricParts.at(-2) : suffix; + + const unit = rate ? getPerSecondRateUnit(unitSuffix) : getUnit(unitSuffix); + + return { + agg, + unit, + rate, + }; +} diff --git a/public/app/features/trails/AutomaticMetricQueries/query-generators/common/types.ts b/public/app/features/trails/AutomaticMetricQueries/query-generators/common/types.ts new file mode 100644 index 0000000000000..cf49ce8e526e7 --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/query-generators/common/types.ts @@ -0,0 +1,5 @@ +export type AutoQueryParameters = { + agg: string; + unit: string; + rate: boolean; +}; diff --git a/public/app/features/trails/AutomaticMetricQueries/query-generators/histogram.ts b/public/app/features/trails/AutomaticMetricQueries/query-generators/histogram.ts new file mode 100644 index 0000000000000..ad51a4769b36f --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/query-generators/histogram.ts @@ -0,0 +1,85 @@ +import { PromQuery } from 'app/plugins/datasource/prometheus/types'; + +import { VAR_FILTERS_EXPR, VAR_GROUP_BY_EXP, VAR_METRIC_EXPR } from '../../shared'; +import { heatmapGraphBuilder } from '../graph-builders/heatmap'; +import { percentilesGraphBuilder } from '../graph-builders/percentiles'; +import { simpleGraphBuilder } from '../graph-builders/simple'; +import { AutoQueryDef } from '../types'; +import { getUnit } from '../units'; + +export function createHistogramQueryDefs(metricParts: string[]) { + const title = `${VAR_METRIC_EXPR}`; + + const unitSuffix = metricParts.at(-2); + + const unit = getUnit(unitSuffix); + + const common = { + title, + unit, + }; + + const p50: AutoQueryDef = { + ...common, + variant: 'p50', + queries: [percentileQuery(50)], + vizBuilder: () => simpleGraphBuilder(p50), + }; + + const breakdown: AutoQueryDef = { + ...common, + variant: 'p50', + queries: [percentileQuery(50, [VAR_GROUP_BY_EXP])], + vizBuilder: () => simpleGraphBuilder(breakdown), + }; + + const percentiles: AutoQueryDef = { + ...common, + variant: 'percentiles', + queries: [99, 90, 50].map((p) => percentileQuery(p)).map(fixRefIds), + vizBuilder: () => percentilesGraphBuilder(percentiles), + }; + + const heatmap: AutoQueryDef = { + ...common, + variant: 'heatmap', + queries: [heatMapQuery()], + vizBuilder: () => heatmapGraphBuilder(heatmap), + }; + + return { preview: p50, main: percentiles, variants: [percentiles, heatmap], breakdown: breakdown }; +} + +function fixRefIds(queryDef: PromQuery, index: number): PromQuery { + // By default refIds are `"A"` + // This method will reassign based on `A + index` -- A, B, C, etc + return { + ...queryDef, + refId: String.fromCharCode('A'.charCodeAt(0) + index), + }; +} + +const BASE_QUERY = `rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])`; + +function baseQuery(groupings: string[] = []) { + const sumByList = ['le', ...groupings]; + return `sum by(${sumByList.join(', ')}) (${BASE_QUERY})`; +} + +function heatMapQuery(groupings: string[] = []): PromQuery { + return { + refId: 'A', + expr: baseQuery(groupings), + format: 'heatmap', + }; +} + +function percentileQuery(percentile: number, groupings: string[] = []) { + const percent = percentile / 100; + + return { + refId: 'A', + expr: `histogram_quantile(${percent}, ${baseQuery(groupings)})`, + legendFormat: `${percentile}th Percentile`, + }; +} diff --git a/public/app/features/trails/AutomaticMetricQueries/query-generators/index.ts b/public/app/features/trails/AutomaticMetricQueries/query-generators/index.ts new file mode 100644 index 0000000000000..569f4e8e0bf39 --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/query-generators/index.ts @@ -0,0 +1,13 @@ +import general from './common'; +import { createHistogramQueryDefs } from './histogram'; +import { createSummaryQueryDefs } from './summary'; +import { MetricQueriesGenerator } from './types'; + +const SUFFIX_TO_ALTERNATIVE_GENERATOR: Record<string, MetricQueriesGenerator> = { + sum: createSummaryQueryDefs, + bucket: createHistogramQueryDefs, +}; + +export function getQueryGeneratorFor(suffix?: string) { + return (suffix && SUFFIX_TO_ALTERNATIVE_GENERATOR[suffix]) || general.generator; +} diff --git a/public/app/features/trails/AutomaticMetricQueries/query-generators/summary.ts b/public/app/features/trails/AutomaticMetricQueries/query-generators/summary.ts new file mode 100644 index 0000000000000..b6a625b08983f --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/query-generators/summary.ts @@ -0,0 +1,39 @@ +import { VAR_GROUP_BY_EXP, VAR_METRIC_EXPR } from '../../shared'; +import { AutoQueryInfo } from '../types'; +import { getUnit } from '../units'; + +import { generateCommonAutoQueryInfo } from './common/generator'; +import { getGeneralBaseQuery } from './common/queries'; + +export function createSummaryQueryDefs(metricParts: string[]): AutoQueryInfo { + const suffix = metricParts.at(-1); + if (suffix !== 'sum') { + throw new Error('createSummaryQueryDefs is only to be used for metrics that end in "_sum"'); + } + + const unitSuffix = metricParts.at(-2); + const unit = getUnit(unitSuffix); + + const rate = true; + const baseQuery = getGeneralBaseQuery(rate); + + const subMetric = metricParts.slice(0, -1).join('_'); + const mainQueryExpr = createMeanExpr(`sum(${baseQuery})`); + const breakdownQueryExpr = createMeanExpr(`sum(${baseQuery})by(${VAR_GROUP_BY_EXP})`); + + const operationDescription = `average`; + const description = `${subMetric} (${operationDescription})`; + + function createMeanExpr(expr: string) { + const numerator = expr.replace(VAR_METRIC_EXPR, `${subMetric}_sum`); + const denominator = expr.replace(VAR_METRIC_EXPR, `${subMetric}_count`); + return `${numerator}/${denominator}`; + } + + return generateCommonAutoQueryInfo({ + description, + mainQueryExpr, + breakdownQueryExpr, + unit, + }); +} diff --git a/public/app/features/trails/AutomaticMetricQueries/query-generators/types.ts b/public/app/features/trails/AutomaticMetricQueries/query-generators/types.ts new file mode 100644 index 0000000000000..ddf30ccc27c86 --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/query-generators/types.ts @@ -0,0 +1,3 @@ +import { AutoQueryInfo } from '../types'; + +export type MetricQueriesGenerator = (metricParts: string[]) => AutoQueryInfo; diff --git a/public/app/features/trails/AutomaticMetricQueries/types.ts b/public/app/features/trails/AutomaticMetricQueries/types.ts new file mode 100644 index 0000000000000..0737f3bcbe0aa --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/types.ts @@ -0,0 +1,19 @@ +import { VizPanelBuilder } from '@grafana/scenes'; +import { PromQuery } from 'app/plugins/datasource/prometheus/types'; + +export interface AutoQueryDef { + variant: string; + title: string; + unit: string; + queries: PromQuery[]; + vizBuilder: VizBuilder; +} + +export interface AutoQueryInfo { + preview: AutoQueryDef; + main: AutoQueryDef; + variants: AutoQueryDef[]; + breakdown: AutoQueryDef; +} + +export type VizBuilder = () => VizPanelBuilder<{}, {}>; diff --git a/public/app/features/trails/AutomaticMetricQueries/units.ts b/public/app/features/trails/AutomaticMetricQueries/units.ts new file mode 100644 index 0000000000000..5a150809b1e81 --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/units.ts @@ -0,0 +1,21 @@ +const DEFAULT_UNIT = 'short'; + +export function getUnit(metricPart: string | undefined) { + return (metricPart && UNIT_MAP[metricPart]) || DEFAULT_UNIT; +} + +const UNIT_MAP: Record<string, string> = { + bytes: 'bytes', + seconds: 's', +}; + +const RATE_UNIT_MAP: Record<string, string> = { + bytes: 'Bps', // bytes per second + seconds: 'short', // seconds per second is unitless -- this may indicate a count of some resource that is active +}; + +const DEFAULT_RATE_UNIT = 'cps'; // Count per second + +export function getPerSecondRateUnit(metricPart: string | undefined) { + return (metricPart && RATE_UNIT_MAP[metricPart]) || DEFAULT_RATE_UNIT; +} diff --git a/public/app/features/trails/BreakdownLabelSelector.tsx b/public/app/features/trails/BreakdownLabelSelector.tsx new file mode 100644 index 0000000000000..25191aaa53a54 --- /dev/null +++ b/public/app/features/trails/BreakdownLabelSelector.tsx @@ -0,0 +1,60 @@ +import { css } from '@emotion/css'; +import { useResizeObserver } from '@react-aria/utils'; +import React, { useEffect, useRef, useState } from 'react'; + +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { Select, RadioButtonGroup, useStyles2, useTheme2, measureText } from '@grafana/ui'; + +type Props = { + options: Array<SelectableValue<string>>; + value?: string; + onChange: (label: string | undefined) => void; +}; + +export function BreakdownLabelSelector({ options, value, onChange }: Props) { + const styles = useStyles2(getStyles); + const theme = useTheme2(); + + const [labelSelectorRequiredWidth, setLabelSelectorRequiredWidth] = useState<number>(0); + const [availableWidth, setAvailableWidth] = useState<number>(0); + + const useHorizontalLabelSelector = availableWidth > labelSelectorRequiredWidth; + + const controlsContainer = useRef<HTMLDivElement>(null); + + useResizeObserver({ + ref: controlsContainer, + onResize: () => { + const element = controlsContainer.current; + if (element) { + setAvailableWidth(element.clientWidth); + } + }, + }); + + useEffect(() => { + const { fontSize } = theme.typography; + const text = options.map((option) => option.label || option.value || '').join(' '); + const textWidth = measureText(text, fontSize).width; + const additionalWidthPerItem = 32; + setLabelSelectorRequiredWidth(textWidth + additionalWidthPerItem * options.length); + }, [options, theme]); + + return ( + <div ref={controlsContainer}> + {useHorizontalLabelSelector ? ( + <RadioButtonGroup {...{ options, value, onChange }} /> + ) : ( + <Select {...{ options, value }} onChange={(selected) => onChange(selected.value)} className={styles.select} /> + )} + </div> + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + select: css({ + maxWidth: theme.spacing(16), + }), + }; +} diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx index 91c40dab47b9e..43685a32c6339 100644 --- a/public/app/features/trails/DataTrail.tsx +++ b/public/app/features/trails/DataTrail.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import React from 'react'; -import { AdHocVariableFilter, GrafanaTheme2 } from '@grafana/data'; +import { AdHocVariableFilter, GrafanaTheme2, VariableHide } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { AdHocFiltersVariable, @@ -27,8 +27,9 @@ import { DataTrailSettings } from './DataTrailSettings'; import { DataTrailHistory, DataTrailHistoryStep } from './DataTrailsHistory'; import { MetricScene } from './MetricScene'; import { MetricSelectScene } from './MetricSelectScene'; +import { MetricsHeader } from './MetricsHeader'; import { getTrailStore } from './TrailStore/TrailStore'; -import { MetricSelectedEvent, trailDS, LOGS_METRIC, VAR_DATASOURCE, VAR_FILTERS } from './shared'; +import { MetricSelectedEvent, trailDS, VAR_DATASOURCE, VAR_FILTERS } from './shared'; import { getUrlForTrail } from './utils'; export interface DataTrailState extends SceneObjectState { @@ -37,6 +38,7 @@ export interface DataTrailState extends SceneObjectState { controls: SceneObject[]; history: DataTrailHistory; settings: DataTrailSettings; + createdAt: number; // just for for the starting data source initialDS?: string; @@ -61,6 +63,7 @@ export class DataTrail extends SceneObjectBase<DataTrailState> { ], history: state.history ?? new DataTrailHistory({}), settings: state.settings ?? new DataTrailSettings({}), + createdAt: state.createdAt ?? new Date().getTime(), ...state, }); @@ -83,6 +86,8 @@ export class DataTrail extends SceneObjectBase<DataTrailState> { const newStepWasAppended = newNumberOfSteps > oldNumberOfSteps; if (newStepWasAppended) { + // In order for the `useBookmarkState` to re-evaluate after a new step was made: + this.forceRender(); // Do nothing because the state is already up to date -- it created a new step! return; } @@ -134,7 +139,7 @@ export class DataTrail extends SceneObjectBase<DataTrailState> { // Add metric to adhoc filters baseFilter const filterVar = sceneGraph.lookupVariable(VAR_FILTERS, this); if (filterVar instanceof AdHocFiltersVariable) { - filterVar.state.set.setState({ + filterVar.setState({ baseFilters: getBaseFiltersForMetric(evt.payload), }); } @@ -160,7 +165,7 @@ export class DataTrail extends SceneObjectBase<DataTrailState> { } } else if (values.metric === null) { stateUpdate.metric = undefined; - stateUpdate.topScene = new MetricSelectScene({ showHeading: true }); + stateUpdate.topScene = new MetricSelectScene({}); } this.setState(stateUpdate); @@ -169,9 +174,11 @@ export class DataTrail extends SceneObjectBase<DataTrailState> { static Component = ({ model }: SceneComponentProps<DataTrail>) => { const { controls, topScene, history, settings } = model.useState(); const styles = useStyles2(getStyles); + const showHeaderForFirstTimeUsers = getTrailStore().recent.length < 2; return ( <div className={styles.container}> + {showHeaderForFirstTimeUsers && <MetricsHeader />} <history.Component model={history} /> {controls && ( <div className={styles.controls}> @@ -187,11 +194,11 @@ export class DataTrail extends SceneObjectBase<DataTrailState> { }; } -function getTopSceneFor(metric?: string) { +export function getTopSceneFor(metric?: string) { if (metric) { return new MetricScene({ metric: metric }); } else { - return new MetricSelectScene({ showHeading: true }); + return new MetricSelectScene({}); } } @@ -201,12 +208,15 @@ function getVariableSet(initialDS?: string, metric?: string, initialFilters?: Ad new DataSourceVariable({ name: VAR_DATASOURCE, label: 'Data source', + description: 'Only prometheus data sources are supported', value: initialDS, - pluginId: metric === LOGS_METRIC ? 'loki' : 'prometheus', + pluginId: 'prometheus', }), - AdHocFiltersVariable.create({ - name: 'filters', + new AdHocFiltersVariable({ + name: VAR_FILTERS, + addFilterButtonText: 'Add label', datasource: trailDS, + hide: VariableHide.hideLabel, layout: 'vertical', filters: initialFilters ?? [], baseFilters: getBaseFiltersForMetric(metric), @@ -220,7 +230,7 @@ function getStyles(theme: GrafanaTheme2) { container: css({ flexGrow: 1, display: 'flex', - gap: theme.spacing(2), + gap: theme.spacing(1), minHeight: '100%', flexDirection: 'column', }), @@ -228,13 +238,17 @@ function getStyles(theme: GrafanaTheme2) { flexGrow: 1, display: 'flex', flexDirection: 'column', - gap: theme.spacing(1), }), controls: css({ display: 'flex', - gap: theme.spacing(2), + gap: theme.spacing(1), + padding: theme.spacing(1, 0), alignItems: 'flex-end', flexWrap: 'wrap', + position: 'sticky', + background: theme.isDark ? theme.colors.background.canvas : theme.colors.background.primary, + zIndex: theme.zIndex.navbarFixed, + top: 0, }), }; } diff --git a/public/app/features/trails/DataTrailCard.tsx b/public/app/features/trails/DataTrailCard.tsx index efc682bcfc018..a0e35dc870188 100644 --- a/public/app/features/trails/DataTrailCard.tsx +++ b/public/app/features/trails/DataTrailCard.tsx @@ -1,12 +1,13 @@ import { css } from '@emotion/css'; import React from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { dateTimeFormat, GrafanaTheme2 } from '@grafana/data'; import { AdHocFiltersVariable, sceneGraph } from '@grafana/scenes'; -import { useStyles2, Stack, Tooltip, Button } from '@grafana/ui'; +import { Card, IconButton, Stack, Tag, useStyles2 } from '@grafana/ui'; import { DataTrail } from './DataTrail'; -import { LOGS_METRIC, VAR_DATASOURCE_EXPR, VAR_FILTERS } from './shared'; +import { VAR_FILTERS } from './shared'; +import { getDataSource, getDataSourceName, getMetricName } from './utils'; export interface Props { trail: DataTrail; @@ -22,95 +23,71 @@ export function DataTrailCard({ trail, onSelect, onDelete }: Props) { return null; } - const filters = filtersVariable.state.set.state.filters; + const filters = filtersVariable.state.filters; const dsValue = getDataSource(trail); + const onClick = () => onSelect(trail); + return ( - <button className={styles.container} onClick={() => onSelect(trail)}> - <div className={styles.wrapper}> - <div className={styles.heading}>{getMetricName(trail.state.metric)}</div> - {onDelete && ( - <Tooltip content={'Remove bookmark'}> - <Button size="sm" icon="trash-alt" variant="destructive" fill="text" onClick={onDelete} /> - </Tooltip> - )} + <Card onClick={onClick} className={styles.card}> + <Card.Heading>{getMetricName(trail.state.metric)}</Card.Heading> + <div className={styles.description}> + <Stack gap={1.5}> + {filters.map((f) => ( + <Tag key={f.key} name={`${f.key}: ${f.value}`} colorIndex={12} /> + ))} + </Stack> </div> - - <Stack gap={1.5}> - {dsValue && ( - <Stack direction="column" gap={0.5}> - <div className={styles.label}>Datasource</div> - <div className={styles.value}>{getDataSource(trail)}</div> - </Stack> - )} - {filters.map((filter, index) => ( - <Stack key={index} direction="column" gap={0.5}> - <div className={styles.label}>{filter.key}</div> - <div className={styles.value}>{filter.value}</div> - </Stack> - ))} - </Stack> - </button> + <Card.Actions className={styles.actions}> + <Stack gap={1} justifyContent={'space-between'} grow={1}> + <div className={styles.secondary}> + <b>Datasource:</b> {getDataSourceName(dsValue)} + </div> + {trail.state.createdAt && ( + <i className={styles.secondary}> + <b>Created:</b> {dateTimeFormat(trail.state.createdAt, { format: 'LL' })} + </i> + )} + </Stack> + </Card.Actions> + {onDelete && ( + <Card.SecondaryActions> + <IconButton + key="delete" + name="trash-alt" + className={styles.secondary} + tooltip="Remove bookmark" + onClick={onDelete} + /> + </Card.SecondaryActions> + )} + </Card> ); } -function getMetricName(metric?: string) { - if (!metric) { - return 'Select metric'; - } - - if (metric === LOGS_METRIC) { - return 'Logs'; - } - - return metric; -} - -function getDataSource(trail: DataTrail) { - return sceneGraph.interpolate(trail, VAR_DATASOURCE_EXPR); -} - function getStyles(theme: GrafanaTheme2) { return { - container: css({ - padding: theme.spacing(1), - flexGrow: 1, - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(2), - width: '100%', - border: `1px solid ${theme.colors.border.weak}`, - borderRadius: theme.shape.radius.default, - cursor: 'pointer', - boxShadow: 'none', - background: 'transparent', - textAlign: 'left', - '&:hover': { - background: theme.colors.emphasize(theme.colors.background.primary, 0.03), - }, + tag: css({ + maxWidth: '260px', + overflow: 'hidden', + textOverflow: 'ellipsis', }), - label: css({ - fontWeight: theme.typography.fontWeightMedium, - fontSize: theme.typography.bodySmall.fontSize, - }), - value: css({ - fontSize: theme.typography.bodySmall.fontSize, - }), - heading: css({ - padding: theme.spacing(0), - display: 'flex', - fontWeight: theme.typography.fontWeightMedium, - overflowX: 'hidden', + card: css({ + padding: theme.spacing(1), }), - body: css({ - padding: theme.spacing(0), + secondary: css({ + color: theme.colors.text.secondary, + fontSize: '12px', }), - wrapper: css({ - position: 'relative', - display: 'flex', - gap: theme.spacing.x1, - justifyContent: 'space-between', + description: css({ width: '100%', + gridArea: 'Description', + margin: theme.spacing(1, 0, 0), + color: theme.colors.text.secondary, + lineHeight: theme.typography.body.lineHeight, + }), + actions: css({ + marginRight: theme.spacing(1), }), }; } diff --git a/public/app/features/trails/DataTrailDrawer.tsx b/public/app/features/trails/DataTrailDrawer.tsx deleted file mode 100644 index fa2357c43189e..0000000000000 --- a/public/app/features/trails/DataTrailDrawer.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; - -import { getDataSourceSrv } from '@grafana/runtime'; -import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneTimeRangeLike } from '@grafana/scenes'; -import { DataSourceRef } from '@grafana/schema'; -import { Drawer } from '@grafana/ui'; -import { PromVisualQuery } from 'app/plugins/datasource/prometheus/querybuilder/types'; - -import { getDashboardSceneFor } from '../dashboard-scene/utils/utils'; - -import { DataTrail } from './DataTrail'; -import { getDataTrailsApp } from './DataTrailsApp'; -import { OpenEmbeddedTrailEvent } from './shared'; - -interface DataTrailDrawerState extends SceneObjectState { - timeRange: SceneTimeRangeLike; - query: PromVisualQuery; - dsRef: DataSourceRef; -} - -export class DataTrailDrawer extends SceneObjectBase<DataTrailDrawerState> { - static Component = DataTrailDrawerRenderer; - - public trail: DataTrail; - - constructor(state: DataTrailDrawerState) { - super(state); - - this.trail = buildDataTrailFromQuery(state); - this.trail.addActivationHandler(() => { - this.trail.subscribeToEvent(OpenEmbeddedTrailEvent, this.onOpenTrail); - }); - } - - onOpenTrail = () => { - getDataTrailsApp().goToUrlForTrail(this.trail.clone({ embedded: false })); - }; - - onClose = () => { - const dashboard = getDashboardSceneFor(this); - dashboard.closeModal(); - }; -} - -function DataTrailDrawerRenderer({ model }: SceneComponentProps<DataTrailDrawer>) { - return ( - <Drawer title={'Data trail'} onClose={model.onClose} size="lg"> - <div style={{ display: 'flex', height: '100%' }}> - <model.trail.Component model={model.trail} /> - </div> - </Drawer> - ); -} - -export function buildDataTrailFromQuery({ query, dsRef, timeRange }: DataTrailDrawerState) { - const filters = query.labels.map((label) => ({ key: label.label, value: label.value, operator: label.op })); - - const ds = getDataSourceSrv().getInstanceSettings(dsRef); - - return new DataTrail({ - $timeRange: timeRange, - metric: query.metric, - initialDS: ds?.name, - initialFilters: filters, - embedded: true, - }); -} diff --git a/public/app/features/trails/DataTrailSettings.tsx b/public/app/features/trails/DataTrailSettings.tsx index 6450a9fed98b1..60056bf40d63c 100644 --- a/public/app/features/trails/DataTrailSettings.tsx +++ b/public/app/features/trails/DataTrailSettings.tsx @@ -6,31 +6,20 @@ import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana import { Dropdown, Switch, ToolbarButton, useStyles2 } from '@grafana/ui'; export interface DataTrailSettingsState extends SceneObjectState { - showQuery?: boolean; - showAdvanced?: boolean; - multiValueVars?: boolean; + stickyMainGraph?: boolean; isOpen?: boolean; } export class DataTrailSettings extends SceneObjectBase<DataTrailSettingsState> { constructor(state: Partial<DataTrailSettingsState>) { super({ - showQuery: state.showQuery ?? false, - showAdvanced: state.showAdvanced ?? false, + stickyMainGraph: state.stickyMainGraph ?? true, isOpen: state.isOpen ?? false, }); } - public onToggleShowQuery = () => { - this.setState({ showQuery: !this.state.showQuery }); - }; - - public onToggleAdvanced = () => { - this.setState({ showAdvanced: !this.state.showAdvanced }); - }; - - public onToggleMultiValue = () => { - this.setState({ multiValueVars: !this.state.multiValueVars }); + public onToggleStickyMainGraph = () => { + this.setState({ stickyMainGraph: !this.state.stickyMainGraph }); }; public onToggleOpen = (isOpen: boolean) => { @@ -38,7 +27,7 @@ export class DataTrailSettings extends SceneObjectBase<DataTrailSettingsState> { }; static Component = ({ model }: SceneComponentProps<DataTrailSettings>) => { - const { showQuery, showAdvanced, multiValueVars, isOpen } = model.useState(); + const { stickyMainGraph, isOpen } = model.useState(); const styles = useStyles2(getStyles); const renderPopover = () => { @@ -47,12 +36,8 @@ export class DataTrailSettings extends SceneObjectBase<DataTrailSettingsState> { <div className={styles.popover} onClick={(evt) => evt.stopPropagation()}> <div className={styles.heading}>Settings</div> <div className={styles.options}> - <div>Multi value variables</div> - <Switch value={multiValueVars} onChange={model.onToggleMultiValue} /> - <div>Advanced options</div> - <Switch value={showAdvanced} onChange={model.onToggleAdvanced} /> - <div>Show query</div> - <Switch value={showQuery} onChange={model.onToggleShowQuery} /> + <div>Always keep selected metric graph in-view</div> + <Switch value={stickyMainGraph} onChange={model.onToggleStickyMainGraph} /> </div> </div> ); diff --git a/public/app/features/trails/DataTrailsApp.tsx b/public/app/features/trails/DataTrailsApp.tsx index 79743f573f9ed..f8fe2d33b5bd7 100644 --- a/public/app/features/trails/DataTrailsApp.tsx +++ b/public/app/features/trails/DataTrailsApp.tsx @@ -11,7 +11,7 @@ import { Page } from 'app/core/components/Page/Page'; import { DataTrail } from './DataTrail'; import { DataTrailsHome } from './DataTrailsHome'; import { getTrailStore } from './TrailStore/TrailStore'; -import { getUrlForTrail, newMetricsTrail } from './utils'; +import { getMetricName, getUrlForTrail, newMetricsTrail } from './utils'; export interface DataTrailsAppState extends SceneObjectState { trail: DataTrail; @@ -36,9 +36,9 @@ export class DataTrailsApp extends SceneObjectBase<DataTrailsAppState> { <Switch> <Route exact={true} - path="/data-trails" + path="/explore/metrics" render={() => ( - <Page navId="data-trails" layout={PageLayoutType.Custom}> + <Page navId="explore/metrics" layout={PageLayoutType.Custom}> <div className={styles.customPage}> <home.Component model={home} /> </div> @@ -47,9 +47,13 @@ export class DataTrailsApp extends SceneObjectBase<DataTrailsAppState> { /> <Route exact={true} - path="/data-trails/trail" + path="/explore/metrics/trail" render={() => ( - <Page navId="data-trails" pageNav={{ text: 'Trail' }} layout={PageLayoutType.Custom}> + <Page + navId="explore/metrics" + pageNav={{ text: getMetricName(trail.state.metric) }} + layout={PageLayoutType.Custom} + > <div className={styles.customPage}> <DataTrailView trail={trail} /> </div> diff --git a/public/app/features/trails/DataTrailsHistory.tsx b/public/app/features/trails/DataTrailsHistory.tsx index b5bdbb6eeae3c..c562b85cd302e 100644 --- a/public/app/features/trails/DataTrailsHistory.tsx +++ b/public/app/features/trails/DataTrailsHistory.tsx @@ -13,9 +13,9 @@ import { } from '@grafana/scenes'; import { useStyles2, Tooltip, Stack } from '@grafana/ui'; -import { DataTrail, DataTrailState } from './DataTrail'; +import { DataTrail, DataTrailState, getTopSceneFor } from './DataTrail'; import { VAR_FILTERS } from './shared'; -import { getTrailFor } from './utils'; +import { getTrailFor, isSceneTimeRangeState } from './utils'; export interface DataTrailsHistoryState extends SceneObjectState { currentStep: number; @@ -44,7 +44,22 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> { const trail = getTrailFor(this); if (this.state.steps.length === 0) { + // We always want to ensure in initial 'start' step this.addTrailStep(trail, 'start'); + + if (trail.state.metric) { + // But if our current trail has a metric, we want to remove it and the topScene, + // so that the "start" step always displays a metric select screen. + + // So we remove the metric and update the topscene for the "start" step + const { metric, ...startState } = trail.state; + startState.topScene = getTopSceneFor(undefined); + this.state.steps[0].trailState = startState; + + // But must add a secondary step to represent the selection of the metric + // for this restored trail state + this.addTrailStep(trail, 'metric'); + } } trail.subscribeToState((newState, oldState) => { @@ -68,6 +83,14 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> { trail.subscribeToEvent(SceneObjectStateChangedEvent, (evt) => { if (evt.payload.changedObject instanceof SceneTimeRange) { + const { prevState, newState } = evt.payload; + + if (isSceneTimeRangeState(prevState) && isSceneTimeRangeState(newState)) { + if (prevState.from === newState.from && prevState.to === newState.to) { + return; + } + } + this.addTrailStep(trail, 'time'); } }); @@ -139,7 +162,7 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> { return ( <div className={styles.container}> - <div className={styles.heading}>Trail</div> + <div className={styles.heading}>History</div> {steps.map((step, index) => ( <Tooltip content={() => model.renderStepTooltip(step)} key={index}> <button diff --git a/public/app/features/trails/DataTrailsHome.tsx b/public/app/features/trails/DataTrailsHome.tsx index 1d16c6ad43b83..1bd1d6ca2c88c 100644 --- a/public/app/features/trails/DataTrailsHome.tsx +++ b/public/app/features/trails/DataTrailsHome.tsx @@ -1,16 +1,18 @@ import { css } from '@emotion/css'; import React, { useState } from 'react'; +import { Redirect } from 'react-router-dom'; import { GrafanaTheme2 } from '@grafana/data'; import { SceneComponentProps, sceneGraph, SceneObject, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; -import { Button, useStyles2, Stack } from '@grafana/ui'; +import { Button, Stack, useStyles2 } from '@grafana/ui'; import { Text } from '@grafana/ui/src/components/Text/Text'; import { DataTrail } from './DataTrail'; import { DataTrailCard } from './DataTrailCard'; import { DataTrailsApp } from './DataTrailsApp'; +import { MetricsHeader } from './MetricsHeader'; import { getTrailStore } from './TrailStore/TrailStore'; -import { newMetricsTrail } from './utils'; +import { getDatasourceForNewTrail, getUrlForTrail, newMetricsTrail } from './utils'; export interface DataTrailsHomeState extends SceneObjectState {} @@ -21,7 +23,7 @@ export class DataTrailsHome extends SceneObjectBase<DataTrailsHomeState> { public onNewMetricsTrail = () => { const app = getAppFor(this); - const trail = newMetricsTrail(); + const trail = newMetricsTrail(getDatasourceForNewTrail()); getTrailStore().setRecentTrail(trail); app.goToUrlForTrail(trail); @@ -43,20 +45,23 @@ export class DataTrailsHome extends SceneObjectBase<DataTrailsHomeState> { setLastDelete(Date.now()); // trigger re-render }; + // If there are no recent trails, don't show home page and create a new trail + if (!getTrailStore().recent.length) { + const trail = newMetricsTrail(getDatasourceForNewTrail()); + return <Redirect to={getUrlForTrail(trail)} />; + } + return ( <div className={styles.container}> - <Stack direction="column" gap={1}> - <Text variant="h2">Data trails</Text> - <Text color="secondary">Automatically query, explore and navigate your observability data</Text> - </Stack> - <Stack gap={2}> - <Button icon="plus" size="lg" variant="secondary" onClick={model.onNewMetricsTrail}> - New metric trail + <Stack gap={2} justifyContent={'space-between'} alignItems={'center'}> + <MetricsHeader /> + <Button icon="plus" size="md" variant="primary" onClick={model.onNewMetricsTrail}> + New metric exploration </Button> </Stack> - <Stack gap={4}> + <Stack gap={5}> <div className={styles.column}> - <Text variant="h4">Recent trails</Text> + <Text variant="h4">Recent metrics</Text> <div className={styles.trailList}> {getTrailStore().recent.map((trail, index) => { const resolvedTrail = trail.resolve(); @@ -70,6 +75,7 @@ export class DataTrailsHome extends SceneObjectBase<DataTrailsHomeState> { })} </div> </div> + <div className={styles.verticalLine} /> <div className={styles.column}> <Text variant="h4">Bookmarks</Text> <div className={styles.trailList}> @@ -106,8 +112,8 @@ function getStyles(theme: GrafanaTheme2) { gap: theme.spacing(3), }), column: css({ - width: 500, display: 'flex', + flexGrow: 1, flexDirection: 'column', gap: theme.spacing(2), }), @@ -122,5 +128,8 @@ function getStyles(theme: GrafanaTheme2) { flexDirection: 'column', gap: theme.spacing(2), }), + verticalLine: css({ + borderLeft: `1px solid ${theme.colors.border.weak}`, + }), }; } diff --git a/public/app/features/trails/Integrations/DataTrailEmbedded.tsx b/public/app/features/trails/Integrations/DataTrailEmbedded.tsx new file mode 100644 index 0000000000000..67b4ae6312cbb --- /dev/null +++ b/public/app/features/trails/Integrations/DataTrailEmbedded.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { AdHocVariableFilter } from '@grafana/data'; +import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneTimeRangeLike } from '@grafana/scenes'; + +import { DataTrail } from '../DataTrail'; + +export interface DataTrailEmbeddedState extends SceneObjectState { + timeRange: SceneTimeRangeLike; + metric?: string; + filters?: AdHocVariableFilter[]; + dataSourceUid?: string; +} +export class DataTrailEmbedded extends SceneObjectBase<DataTrailEmbeddedState> { + static Component = DataTrailEmbeddedRenderer; + + public trail: DataTrail; + + constructor(state: DataTrailEmbeddedState) { + super(state); + this.trail = buildDataTrailFromState(state); + } +} + +function DataTrailEmbeddedRenderer({ model }: SceneComponentProps<DataTrailEmbedded>) { + return <model.trail.Component model={model.trail} />; +} + +export function buildDataTrailFromState({ metric, filters, dataSourceUid, timeRange }: DataTrailEmbeddedState) { + return new DataTrail({ + $timeRange: timeRange, + metric, + initialDS: dataSourceUid, + initialFilters: filters, + embedded: true, + }); +} diff --git a/public/app/features/trails/Integrations/SceneDrawer.tsx b/public/app/features/trails/Integrations/SceneDrawer.tsx new file mode 100644 index 0000000000000..b48fe048ef051 --- /dev/null +++ b/public/app/features/trails/Integrations/SceneDrawer.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import { SceneComponentProps, SceneObjectBase, SceneObject, SceneObjectState } from '@grafana/scenes'; +import { Drawer } from '@grafana/ui'; +import appEvents from 'app/core/app_events'; +import { ShowModalReactEvent } from 'app/types/events'; + +export type SceneDrawerProps = { + scene: SceneObject; + title: string; + onDismiss: () => void; +}; + +export function SceneDrawer(props: SceneDrawerProps) { + const { scene, title, onDismiss } = props; + return ( + <Drawer title={title} onClose={onDismiss} size="lg"> + <div style={{ display: 'flex', height: '100%' }}> + <scene.Component model={scene} /> + </div> + </Drawer> + ); +} + +interface SceneDrawerAsSceneState extends SceneObjectState, SceneDrawerProps {} + +export class SceneDrawerAsScene extends SceneObjectBase<SceneDrawerAsSceneState> { + constructor(state: SceneDrawerProps) { + super(state); + } + + static Component({ model }: SceneComponentProps<SceneDrawerAsScene>) { + const state = model.useState(); + + return <SceneDrawer {...state} />; + } +} + +export function launchSceneDrawerInGlobalModal(props: Omit<SceneDrawerProps, 'onDismiss'>) { + const payload = { + component: SceneDrawer, + props, + }; + + appEvents.publish(new ShowModalReactEvent(payload)); +} diff --git a/public/app/features/trails/Integrations/dashboardIntegration.ts b/public/app/features/trails/Integrations/dashboardIntegration.ts new file mode 100644 index 0000000000000..09a8227d2905f --- /dev/null +++ b/public/app/features/trails/Integrations/dashboardIntegration.ts @@ -0,0 +1,115 @@ +import { isString } from 'lodash'; + +import { PanelMenuItem, PanelModel } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; +import { DataSourceRef } from '@grafana/schema'; + +import { DashboardModel } from '../../dashboard/state'; +import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene'; +import { MetricScene } from '../MetricScene'; + +import { DataTrailEmbedded, DataTrailEmbeddedState } from './DataTrailEmbedded'; +import { SceneDrawerAsScene, launchSceneDrawerInGlobalModal } from './SceneDrawer'; +import { QueryMetric, getQueryMetrics } from './getQueryMetrics'; +import { createAdHocFilters, getQueryMetricLabel, getQueryRunner, getTimeRangeFromDashboard } from './utils'; + +export function addDataTrailPanelAction( + dashboard: DashboardScene | DashboardModel, + panel: VizPanel | PanelModel, + items: PanelMenuItem[] +) { + const queryRunner = getQueryRunner(panel); + if (!queryRunner) { + return; + } + + const ds = getDataSourceSrv().getInstanceSettings(queryRunner.state.datasource); + + if (ds?.meta.id !== 'prometheus') { + return; + } + + const queries = queryRunner.state.queries.map((q) => q.expr).filter(isString); + + const queryMetrics = getQueryMetrics(queries); + + const subMenu: PanelMenuItem[] = queryMetrics.map((item) => { + return { + text: getQueryMetricLabel(item), + onClick: createClickHandler(item, dashboard, ds), + }; + }); + + if (subMenu.length > 0) { + items.push({ + text: 'Explore metrics', + iconClassName: 'code-branch', + subMenu: getUnique(subMenu), + }); + } +} + +function getUnique<T extends { text: string }>(items: T[]) { + const uniqueMenuTexts = new Set<string>(); + function isUnique({ text }: { text: string }) { + const before = uniqueMenuTexts.size; + uniqueMenuTexts.add(text); + const after = uniqueMenuTexts.size; + return after > before; + } + return items.filter(isUnique); +} + +function getEmbeddedTrailsState( + { metric, labelFilters, query }: QueryMetric, + timeRange: SceneTimeRangeLike, + dataSourceUid: string | undefined +) { + const state: DataTrailEmbeddedState = { + metric, + filters: createAdHocFilters(labelFilters), + dataSourceUid, + timeRange, + }; + + return state; +} + +function createCommonEmbeddedTrailStateProps( + item: QueryMetric, + dashboard: DashboardScene | DashboardModel, + ds: DataSourceRef +) { + const timeRange = getTimeRangeFromDashboard(dashboard); + const trailState = getEmbeddedTrailsState(item, timeRange, ds.uid); + const embeddedTrail: DataTrailEmbedded = new DataTrailEmbedded(trailState); + + embeddedTrail.trail.addActivationHandler(() => { + if (embeddedTrail.trail.state.topScene instanceof MetricScene) { + embeddedTrail.trail.state.topScene.setActionView('breakdown'); + } + }); + + const commonProps = { + scene: embeddedTrail, + title: 'Explore metrics', + }; + + return commonProps; +} + +function createClickHandler(item: QueryMetric, dashboard: DashboardScene | DashboardModel, ds: DataSourceRef) { + if (dashboard instanceof DashboardScene) { + return () => { + const commonProps = createCommonEmbeddedTrailStateProps(item, dashboard, ds); + const drawerScene = new SceneDrawerAsScene({ + ...commonProps, + onDismiss: () => dashboard.closeModal(), + }); + dashboard.showModal(drawerScene); + }; + } else { + return () => launchSceneDrawerInGlobalModal(createCommonEmbeddedTrailStateProps(item, dashboard, ds)); + } +} diff --git a/public/app/features/trails/Integrations/getQueryMetrics.ts b/public/app/features/trails/Integrations/getQueryMetrics.ts new file mode 100644 index 0000000000000..5273756dc226e --- /dev/null +++ b/public/app/features/trails/Integrations/getQueryMetrics.ts @@ -0,0 +1,31 @@ +import { buildVisualQueryFromString } from '@grafana/prometheus/src/querybuilder/parsing'; +import { QueryBuilderLabelFilter } from '@grafana/prometheus/src/querybuilder/shared/types'; + +import { isEquals } from './utils'; + +/** An identified metric and its label for a query */ +export type QueryMetric = { + metric: string; + labelFilters: QueryBuilderLabelFilter[]; + query: string; +}; + +export function getQueryMetrics(queries: string[]) { + const queryMetrics: QueryMetric[] = []; + + queries.forEach((query) => { + const struct = buildVisualQueryFromString(query); + if (struct.errors.length > 0) { + return; + } + + const { metric, labels } = struct.query; + + queryMetrics.push({ metric, labelFilters: labels.filter(isEquals), query }); + struct.query.binaryQueries?.forEach(({ query: { metric, labels } }) => { + queryMetrics.push({ metric, labelFilters: labels.filter(isEquals), query }); + }); + }); + + return queryMetrics; +} diff --git a/public/app/features/trails/Integrations/utils.ts b/public/app/features/trails/Integrations/utils.ts new file mode 100644 index 0000000000000..e6f518283d56a --- /dev/null +++ b/public/app/features/trails/Integrations/utils.ts @@ -0,0 +1,45 @@ +import { PanelModel } from '@grafana/data'; +import { QueryBuilderLabelFilter } from '@grafana/prometheus/src/querybuilder/shared/types'; +import { SceneQueryRunner, SceneTimeRange, VizPanel } from '@grafana/scenes'; +import { DashboardModel } from 'app/features/dashboard/state'; +import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; +import { getQueryRunnerFor } from 'app/features/dashboard-scene/utils/utils'; + +import { QueryMetric } from './getQueryMetrics'; + +// We only support label filters with the '=' operator +export function isEquals(labelFilter: QueryBuilderLabelFilter) { + return labelFilter.op === '='; +} + +export function getQueryRunner(panel: VizPanel | PanelModel) { + if (panel instanceof VizPanel) { + return getQueryRunnerFor(panel); + } + + return new SceneQueryRunner({ datasource: panel.datasource || undefined, queries: panel.targets || [] }); +} + +export function getTimeRangeFromDashboard(dashboard: DashboardScene | DashboardModel) { + if (dashboard instanceof DashboardScene) { + return dashboard.state.$timeRange!.clone(); + } + if (dashboard instanceof DashboardModel) { + return new SceneTimeRange({ ...dashboard.time }); + } + return new SceneTimeRange(); +} + +export function getQueryMetricLabel({ metric, labelFilters }: QueryMetric) { + // Don't show the filter unless there is more than one entry + if (labelFilters.length === 0) { + return metric; + } + + const filter = `{${labelFilters.map(({ label, op, value }) => `${label}${op}"${value}"`)}}`; + return `${metric}${filter}`; +} + +export function createAdHocFilters(labels: QueryBuilderLabelFilter[]) { + return labels?.map((label) => ({ key: label.label, value: label.value, operator: label.op })); +} diff --git a/public/app/features/trails/MetricCategory/MetricCategoryCascader.tsx b/public/app/features/trails/MetricCategory/MetricCategoryCascader.tsx new file mode 100644 index 0000000000000..7dd6fa4c440e0 --- /dev/null +++ b/public/app/features/trails/MetricCategory/MetricCategoryCascader.tsx @@ -0,0 +1,62 @@ +import React, { useMemo } from 'react'; + +import { Cascader, CascaderOption } from '@grafana/ui'; + +import { useMetricCategories } from './useMetricCategories'; + +type Props = { + metricNames: string[]; + onSelect: (prefix: string | undefined) => void; + disabled?: boolean; + initialValue?: string; +}; + +export function MetricCategoryCascader({ metricNames, onSelect, disabled, initialValue }: Props) { + const categoryTree = useMetricCategories(metricNames); + const options = useMemo(() => createCasaderOptions(categoryTree), [categoryTree]); + + return ( + <Cascader + displayAllSelectedLevels={true} + width={40} + separator="_" + hideActiveLevelLabel={false} + placeholder={'No filter'} + isClearable + onSelect={(prefix) => { + onSelect(prefix); + }} + {...{ options, disabled, initialValue }} + /> + ); +} + +function createCasaderOptions(tree: ReturnType<typeof useMetricCategories>, currentPrefix = '') { + const categories = Object.entries(tree.children); + + const options = categories.map(([metricPart, node]) => { + let subcategoryEntries = Object.entries(node.children); + + while (subcategoryEntries.length === 1 && !node.isMetric) { + // There is only one subcategory, so we will join it with the current metricPart to reduce depth + const [subMetricPart, subNode] = subcategoryEntries[0]; + metricPart = `${metricPart}_${subMetricPart}`; + // Extend the metric part name, because there is only one subcategory + node = subNode; + subcategoryEntries = Object.entries(node.children); + } + + const value = currentPrefix + metricPart; + const subOptions = createCasaderOptions(node, value + '_'); + + const option: CascaderOption = { + value: value, + label: metricPart, + items: subOptions, + }; + + return option; + }); + + return options; +} diff --git a/public/app/features/trails/MetricCategory/useMetricCategories.ts b/public/app/features/trails/MetricCategory/useMetricCategories.ts new file mode 100644 index 0000000000000..8e000a858c97a --- /dev/null +++ b/public/app/features/trails/MetricCategory/useMetricCategories.ts @@ -0,0 +1,43 @@ +import { useMemo } from 'react'; + +export function useMetricCategories(metrics: string[]) { + return useMemo(() => processMetrics(metrics), [metrics]); +} + +interface MetricPartNode { + isMetric?: boolean; + children: Record<string, MetricPartNode>; +} + +function processMetrics(metrics: string[]) { + const categoryTree: MetricPartNode = { children: {} }; + + function insertMetric(metric: string) { + if (metric.indexOf(':') !== -1) { + // Ignore recording rules. + return; + } + + const metricParts = metric.split('_'); + + let cursor = categoryTree; + for (const metricPart of metricParts) { + let node = cursor.children[metricPart]; + if (!node) { + // Create new node + node = { + children: {}, + }; + // Insert it + cursor.children[metricPart] = node; + } + cursor = node; + } + // We know this node is a metric because it was for the last metricPart + cursor.isMetric = true; + } + + metrics.forEach((metric) => insertMetric(metric)); + + return categoryTree; +} diff --git a/public/app/features/trails/MetricGraphScene.tsx b/public/app/features/trails/MetricGraphScene.tsx new file mode 100644 index 0000000000000..7cb03d369acee --- /dev/null +++ b/public/app/features/trails/MetricGraphScene.tsx @@ -0,0 +1,92 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { DashboardCursorSync, GrafanaTheme2 } from '@grafana/data'; +import { + behaviors, + SceneComponentProps, + SceneFlexItem, + SceneFlexLayout, + SceneObject, + SceneObjectBase, + SceneObjectState, +} from '@grafana/scenes'; +import { useStyles2 } from '@grafana/ui'; + +import { AutoVizPanel } from './AutomaticMetricQueries/AutoVizPanel'; +import { MetricActionBar } from './MetricScene'; +import { getTrailSettings } from './utils'; + +export const MAIN_PANEL_MIN_HEIGHT = 280; +export const MAIN_PANEL_MAX_HEIGHT = '40%'; + +export interface MetricGraphSceneState extends SceneObjectState { + topView: SceneFlexLayout; + selectedTab?: SceneObject; +} + +export class MetricGraphScene extends SceneObjectBase<MetricGraphSceneState> { + public constructor(state: Partial<MetricGraphSceneState>) { + super({ + topView: state.topView ?? buildGraphTopView(), + ...state, + }); + } + + public static Component = ({ model }: SceneComponentProps<MetricGraphScene>) => { + const { topView, selectedTab } = model.useState(); + const { stickyMainGraph } = getTrailSettings(model).useState(); + const styles = useStyles2(getStyles); + + return ( + <div className={styles.container}> + <div className={stickyMainGraph ? styles.sticky : styles.nonSticky}> + <topView.Component model={topView} /> + </div> + {selectedTab && <selectedTab.Component model={selectedTab} />} + </div> + ); + }; +} + +function getStyles(theme: GrafanaTheme2) { + return { + container: css({ + display: 'flex', + flexDirection: 'column', + position: 'relative', + }), + sticky: css({ + display: 'flex', + flexDirection: 'row', + background: theme.isLight ? theme.colors.background.primary : theme.colors.background.canvas, + position: 'sticky', + top: '70px', + zIndex: 10, + }), + nonSticky: css({ + display: 'flex', + flexDirection: 'row', + }), + }; +} + +function buildGraphTopView() { + const bodyAutoVizPanel = new AutoVizPanel({}); + + return new SceneFlexLayout({ + direction: 'column', + $behaviors: [new behaviors.CursorSync({ key: 'metricCrosshairSync', sync: DashboardCursorSync.Crosshair })], + children: [ + new SceneFlexItem({ + minHeight: MAIN_PANEL_MIN_HEIGHT, + maxHeight: MAIN_PANEL_MAX_HEIGHT, + body: bodyAutoVizPanel, + }), + new SceneFlexItem({ + ySizing: 'content', + body: new MetricActionBar({}), + }), + ], + }); +} diff --git a/public/app/features/trails/MetricScene.tsx b/public/app/features/trails/MetricScene.tsx index 992fd00377868..e3b889fe513b3 100644 --- a/public/app/features/trails/MetricScene.tsx +++ b/public/app/features/trails/MetricScene.tsx @@ -1,49 +1,69 @@ -import React, { useState } from 'react'; +import { css } from '@emotion/css'; +import React from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; import { SceneObjectState, SceneObjectBase, SceneComponentProps, - SceneFlexLayout, - SceneFlexItem, - SceneQueryRunner, SceneObjectUrlSyncConfig, SceneObjectUrlValues, - PanelBuilders, sceneGraph, + SceneVariableSet, + QueryVariable, } from '@grafana/scenes'; -import { ToolbarButton, Box, Stack, Icon } from '@grafana/ui'; +import { ToolbarButton, Box, Stack, Icon, TabsBar, Tab, useStyles2, LinkButton } from '@grafana/ui'; +import { getExploreUrl } from '../../core/utils/explore'; + +import { buildBreakdownActionScene } from './ActionTabs/BreakdownScene'; +import { buildMetricOverviewScene } from './ActionTabs/MetricOverviewScene'; +import { buildRelatedMetricsScene } from './ActionTabs/RelatedMetricsScene'; import { getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine'; -import { AutoVizPanel } from './AutomaticMetricQueries/AutoVizPanel'; -import { buildBreakdownActionScene } from './BreakdownScene'; -import { MetricSelectScene } from './MetricSelectScene'; -import { SelectMetricAction } from './SelectMetricAction'; -import { getTrailStore } from './TrailStore/TrailStore'; +import { AutoQueryDef, AutoQueryInfo } from './AutomaticMetricQueries/types'; +import { MAIN_PANEL_MAX_HEIGHT, MAIN_PANEL_MIN_HEIGHT, MetricGraphScene } from './MetricGraphScene'; +import { ShareTrailButton } from './ShareTrailButton'; +import { useBookmarkState } from './TrailStore/useBookmarkState'; import { ActionViewDefinition, + ActionViewType, getVariablesWithMetricConstant, - LOGS_METRIC, MakeOptional, - OpenEmbeddedTrailEvent, + trailDS, + VAR_GROUP_BY, + VAR_METRIC_EXPR, } from './shared'; -import { getTrailFor } from './utils'; +import { getDataSource, getTrailFor, getUrlForTrail } from './utils'; export interface MetricSceneState extends SceneObjectState { - body: SceneFlexLayout; + body: MetricGraphScene; metric: string; actionView?: string; + + autoQuery: AutoQueryInfo; + queryDef?: AutoQueryDef; } export class MetricScene extends SceneObjectBase<MetricSceneState> { protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['actionView'] }); - public constructor(state: MakeOptional<MetricSceneState, 'body'>) { + public constructor(state: MakeOptional<MetricSceneState, 'body' | 'autoQuery'>) { + const autoQuery = state.autoQuery ?? getAutoQueriesForMetric(state.metric); super({ - $variables: state.$variables ?? getVariablesWithMetricConstant(state.metric), - body: state.body ?? buildGraphScene(state.metric), + $variables: state.$variables ?? getVariableSet(state.metric), + body: state.body ?? new MetricGraphScene({}), + autoQuery, + queryDef: state.queryDef ?? autoQuery.main, ...state, }); + + this.addActivationHandler(this._onActivate.bind(this)); + } + + private _onActivate() { + if (this.state.actionView === undefined) { + this.setActionView('overview'); + } } getUrlState() { @@ -55,7 +75,7 @@ export class MetricScene extends SceneObjectBase<MetricSceneState> { if (this.state.actionView !== values.actionView) { const actionViewDef = actionViewsDefinitions.find((v) => v.value === values.actionView); if (actionViewDef) { - this.setActionView(actionViewDef); + this.setActionView(actionViewDef.value); } } } else if (values.actionView === null) { @@ -63,18 +83,19 @@ export class MetricScene extends SceneObjectBase<MetricSceneState> { } } - public setActionView(actionViewDef?: ActionViewDefinition) { + public setActionView(actionView?: ActionViewType) { const { body } = this.state; + const actionViewDef = actionViewsDefinitions.find((v) => v.value === actionView); if (actionViewDef && actionViewDef.value !== this.state.actionView) { // reduce max height for main panel to reduce height flicker - body.state.children[0].setState({ maxHeight: MAIN_PANEL_MIN_HEIGHT }); - body.setState({ children: [...body.state.children.slice(0, 2), actionViewDef.getScene()] }); + body.state.topView.state.children[0].setState({ maxHeight: MAIN_PANEL_MIN_HEIGHT }); + body.setState({ selectedTab: actionViewDef.getScene() }); this.setState({ actionView: actionViewDef.value }); } else { // restore max height - body.state.children[0].setState({ maxHeight: MAIN_PANEL_MAX_HEIGHT }); - body.setState({ children: body.state.children.slice(0, 2) }); + body.state.topView.state.children[0].setState({ maxHeight: MAIN_PANEL_MAX_HEIGHT }); + body.setState({ selectedTab: undefined }); this.setState({ actionView: undefined }); } } @@ -86,113 +107,120 @@ export class MetricScene extends SceneObjectBase<MetricSceneState> { } const actionViewsDefinitions: ActionViewDefinition[] = [ + { displayName: 'Overview', value: 'overview', getScene: buildMetricOverviewScene }, { displayName: 'Breakdown', value: 'breakdown', getScene: buildBreakdownActionScene }, - { displayName: 'Logs', value: 'logs', getScene: buildLogsScene }, { displayName: 'Related metrics', value: 'related', getScene: buildRelatedMetricsScene }, ]; export interface MetricActionBarState extends SceneObjectState {} export class MetricActionBar extends SceneObjectBase<MetricActionBarState> { - public getButtonVariant(actionViewName: string, currentView: string | undefined) { - return currentView === actionViewName ? 'active' : 'canvas'; - } + public getLinkToExplore = async () => { + const metricScene = sceneGraph.getAncestor(this, MetricScene); + const trail = getTrailFor(this); + const dsValue = getDataSource(trail); + + const queries = metricScene.state.queryDef?.queries || []; + const timeRange = sceneGraph.getTimeRange(this); + + return getExploreUrl({ + queries, + dsRef: { uid: dsValue }, + timeRange: timeRange.state.value, + scopedVars: { __sceneObject: { value: metricScene } }, + }); + }; - public onOpenTrail = () => { - this.publishEvent(new OpenEmbeddedTrailEvent(), true); + public openExploreLink = async () => { + this.getLinkToExplore().then((link) => { + // We use window.open instead of a Link or <a> because we want to compute the explore link when clicking, + // if we precompute it we have to keep track of a lot of dependencies + window.open(link, '_blank'); + }); }; public static Component = ({ model }: SceneComponentProps<MetricActionBar>) => { const metricScene = sceneGraph.getAncestor(model, MetricScene); + const styles = useStyles2(getStyles); const trail = getTrailFor(model); - const [isBookmarked, setBookmarked] = useState(false); + const [isBookmarked, toggleBookmark] = useBookmarkState(trail); const { actionView } = metricScene.useState(); - const onBookmarkTrail = () => { - getTrailStore().addBookmark(trail); - setBookmarked(!isBookmarked); - }; - return ( <Box paddingY={1}> - <Stack gap={2}> - {actionViewsDefinitions.map((viewDef) => ( + <div className={styles.actions}> + <Stack gap={1}> + <ToolbarButton + variant={'canvas'} + icon="compass" + tooltip="Open in explore" + onClick={model.openExploreLink} + ></ToolbarButton> + <ShareTrailButton trail={trail} /> <ToolbarButton - key={viewDef.value} - variant={viewDef.value === actionView ? 'active' : 'canvas'} - onClick={() => metricScene.setActionView(viewDef)} - > - {viewDef.displayName} - </ToolbarButton> - ))} - <ToolbarButton variant={'canvas'}>Add to dashboard</ToolbarButton> - <ToolbarButton variant={'canvas'} icon="compass" tooltip="Open in explore (todo)" disabled /> - <ToolbarButton - variant={'canvas'} - icon={ - isBookmarked ? ( - <Icon name={'favorite'} type={'mono'} size={'lg'} /> - ) : ( - <Icon name={'star'} type={'default'} size={'lg'} /> - ) - } - tooltip={'Bookmark'} - onClick={onBookmarkTrail} - /> - <ToolbarButton variant={'canvas'} icon="share-alt" tooltip="Copy url (todo)" disabled /> - {trail.state.embedded && ( - <ToolbarButton variant={'canvas'} onClick={model.onOpenTrail}> - Open - </ToolbarButton> - )} - </Stack> + variant={'canvas'} + icon={ + isBookmarked ? ( + <Icon name={'favorite'} type={'mono'} size={'lg'} /> + ) : ( + <Icon name={'star'} type={'default'} size={'lg'} /> + ) + } + tooltip={'Bookmark'} + onClick={toggleBookmark} + /> + {trail.state.embedded && ( + <LinkButton href={getUrlForTrail(trail)} variant={'secondary'}> + Open + </LinkButton> + )} + </Stack> + </div> + + <TabsBar> + {actionViewsDefinitions.map((tab, index) => { + return ( + <Tab + key={index} + label={tab.displayName} + active={actionView === tab.value} + onChangeTab={() => metricScene.setActionView(tab.value)} + /> + ); + })} + </TabsBar> </Box> ); }; } -const MAIN_PANEL_MIN_HEIGHT = 280; -const MAIN_PANEL_MAX_HEIGHT = '40%'; - -function buildGraphScene(metric: string) { - const autoQuery = getAutoQueriesForMetric(metric); - - return new SceneFlexLayout({ - direction: 'column', - children: [ - new SceneFlexItem({ - minHeight: MAIN_PANEL_MIN_HEIGHT, - maxHeight: MAIN_PANEL_MAX_HEIGHT, - body: new AutoVizPanel({ autoQuery }), - }), - new SceneFlexItem({ - ySizing: 'content', - body: new MetricActionBar({}), - }), - ], - }); -} - -function buildLogsScene() { - return new SceneFlexItem({ - $data: new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { uid: 'gdev-loki' }, - expr: '{${filters}} | logfmt', - }, - ], +function getStyles(theme: GrafanaTheme2) { + return { + actions: css({ + [theme.breakpoints.up(theme.breakpoints.values.md)]: { + position: 'absolute', + right: 0, + top: 16, + zIndex: 2, + }, }), - body: PanelBuilders.logs() - .setTitle('Logs') - .setHeaderActions(new SelectMetricAction({ metric: LOGS_METRIC, title: 'Open' })) - .build(), - }); + }; } -function buildRelatedMetricsScene() { - return new SceneFlexItem({ - body: new MetricSelectScene({}), +function getVariableSet(metric: string) { + return new SceneVariableSet({ + variables: [ + ...getVariablesWithMetricConstant(metric), + new QueryVariable({ + name: VAR_GROUP_BY, + label: 'Group by', + datasource: trailDS, + includeAll: true, + defaultToAll: true, + query: { query: `label_names(${VAR_METRIC_EXPR})`, refId: 'A' }, + value: '', + text: '', + }), + ], }); } diff --git a/public/app/features/trails/MetricSelectScene.tsx b/public/app/features/trails/MetricSelectScene.tsx index 16811daf2808e..0c2c467806bbb 100644 --- a/public/app/features/trails/MetricSelectScene.tsx +++ b/public/app/features/trails/MetricSelectScene.tsx @@ -1,33 +1,34 @@ import { css } from '@emotion/css'; -import leven from 'leven'; -import React from 'react'; +import { debounce } from 'lodash'; +import React, { useCallback } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, VariableRefresh } from '@grafana/data'; import { - SceneObjectState, - SceneObjectBase, - SceneComponentProps, PanelBuilders, - SceneFlexItem, - SceneVariableSet, QueryVariable, - sceneGraph, - VariableDependencyConfig, - SceneVariable, - SceneCSSGridLayout, + SceneComponentProps, SceneCSSGridItem, + SceneCSSGridLayout, + SceneFlexItem, + sceneGraph, + SceneObject, + SceneObjectBase, SceneObjectRef, - SceneQueryRunner, - VariableValueOption, + SceneObjectState, + SceneVariable, + SceneVariableSet, + VariableDependencyConfig, } from '@grafana/scenes'; import { VariableHide } from '@grafana/schema'; -import { Input, Text, useStyles2, InlineSwitch } from '@grafana/ui'; +import { Input, InlineSwitch, Field, Alert, Icon, useStyles2 } from '@grafana/ui'; -import { getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine'; +import { getPreviewPanelFor } from './AutomaticMetricQueries/previewPanel'; +import { MetricScene } from './MetricScene'; import { SelectMetricAction } from './SelectMetricAction'; -import { hideEmptyPreviews } from './hideEmptyPreviews'; -import { getVariablesWithMetricConstant, trailDS, VAR_FILTERS_EXPR, VAR_METRIC_NAMES } from './shared'; -import { getColorByIndex, getTrailFor } from './utils'; +import { StatusWrapper } from './StatusWrapper'; +import { sortRelatedMetrics } from './relatedMetrics'; +import { getVariablesWithMetricConstant, trailDS, VAR_DATASOURCE, VAR_FILTERS_EXPR, VAR_METRIC_NAMES } from './shared'; +import { getFilters, getTrailFor } from './utils'; interface MetricPanel { name: string; @@ -40,9 +41,9 @@ interface MetricPanel { export interface MetricSelectSceneState extends SceneObjectState { body: SceneCSSGridLayout; - showHeading?: boolean; searchQuery?: string; showPreviews?: boolean; + metricsAfterSearch?: string[]; } const ROW_PREVIEW_HEIGHT = '175px'; @@ -50,6 +51,7 @@ const ROW_CARD_HEIGHT = '64px'; export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> { private previewCache: Record<string, MetricPanel> = {}; + private ignoreNextUpdate = false; constructor(state: Partial<MetricSelectSceneState>) { super({ @@ -70,18 +72,22 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> { } protected _variableDependency = new VariableDependencyConfig(this, { - variableNames: [VAR_METRIC_NAMES], - onVariableUpdatesCompleted: this._onVariableChanged.bind(this), + variableNames: [VAR_METRIC_NAMES, VAR_DATASOURCE], + onReferencedVariableValueChanged: (variable: SceneVariable) => { + const { name } = variable.state; + + if (name === VAR_DATASOURCE) { + // Clear all panels for the previous data source + this.state.body.setState({ children: [] }); + } else if (name === VAR_METRIC_NAMES) { + this.onMetricNamesChange(); + // Entire pipeline must be performed + this.updateMetrics(); + this.buildLayout(); + } + }, }); - private _onVariableChanged(changedVariables: Set<SceneVariable>, dependencyChanged: boolean): void { - if (dependencyChanged) { - this.updateMetrics(); - this.buildLayout(); - } - } - - private ignoreNextUpdate = false; private _onActivate() { if (this.state.body.state.children.length === 0) { this.buildLayout(); @@ -106,8 +112,10 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> { }); } - private updateMetrics() { - const trail = getTrailFor(this); + private currentMetricNames = new Set<string>(); + + private onMetricNamesChange() { + // Get the datasource metrics list from the VAR_METRIC_NAMES variable const variable = sceneGraph.lookupVariable(VAR_METRIC_NAMES, this); if (!(variable instanceof QueryVariable)) { @@ -118,26 +126,77 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> { return; } - const searchRegex = new RegExp(this.state.searchQuery ?? '.*'); - const metricNames = variable.state.options; + const nameList = variable.state.options.map((option) => option.value.toString()); + const nameSet = new Set(nameList); + + Object.values(this.previewCache).forEach((panel) => { + if (!nameSet.has(panel.name)) { + panel.isEmpty = true; + } + }); + + this.currentMetricNames = nameSet; + this.buildLayout(); + } + + private applyMetricSearch() { + // This should only occur when the `searchQuery` changes, of if the `metricNames` change + const metricNames = Array.from(this.currentMetricNames); + if (metricNames == null) { + return; + } + const searchRegex = createSearchRegExp(this.state.searchQuery); + + if (!searchRegex) { + this.setState({ metricsAfterSearch: metricNames }); + } else { + const metricsAfterSearch = metricNames.filter((metric) => !searchRegex || searchRegex.test(metric)); + this.setState({ metricsAfterSearch }); + } + } + + private updateMetrics(applySearchAndFilter = true) { + if (applySearchAndFilter) { + // Set to false if these are not required (because they can be assumed to have been suitably called). + this.applyMetricSearch(); + } + + const { metricsAfterSearch } = this.state; + + const metricNames = metricsAfterSearch || []; + const trail = getTrailFor(this); const sortedMetricNames = trail.state.metric !== undefined ? sortRelatedMetrics(metricNames, trail.state.metric) : metricNames; const metricsMap: Record<string, MetricPanel> = {}; const metricsLimit = 120; - for (let index = 0; index < sortedMetricNames.length; index++) { - const metric = sortedMetricNames[index]; - - const metricName = String(metric.value); - if (!metricName.match(searchRegex)) { - continue; + // Clear absent metrics from cache + Object.keys(this.previewCache).forEach((metric) => { + if (!this.currentMetricNames.has(metric)) { + delete this.previewCache[metric]; } + }); + + for (let index = 0; index < sortedMetricNames.length; index++) { + const metricName = sortedMetricNames[index]; if (Object.keys(metricsMap).length > metricsLimit) { break; } - metricsMap[metricName] = { name: metricName, index, loaded: false }; + const oldPanel = this.previewCache[metricName]; + + const panel = oldPanel || { name: metricName, index, loaded: false }; + + metricsMap[metricName] = panel; + } + + try { + // If there is a current metric, do not present it + const currentMetric = sceneGraph.getAncestor(this, MetricScene).state.metric; + delete metricsMap[currentMetric]; + } catch (err) { + // There is no current metric } this.previewCache = metricsMap; @@ -167,21 +226,29 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> { const children: SceneFlexItem[] = []; const metricsList = this.sortedPreviewMetrics(); + + // Get the current filters to determine the count of them + // Which is required for `getPreviewPanelFor` + const filters = getFilters(this); + const currentFilterCount = filters?.length || 0; + for (let index = 0; index < metricsList.length; index++) { const metric = metricsList[index]; - if (metric.itemRef && metric.isPanel) { - children.push(metric.itemRef.resolve()); - continue; - } if (this.state.showPreviews) { - const panel = getPreviewPanelFor(metric.name, index); + if (metric.itemRef && metric.isPanel) { + children.push(metric.itemRef.resolve()); + continue; + } + const panel = getPreviewPanelFor(metric.name, index, currentFilterCount); metric.itemRef = panel.getRef(); metric.isPanel = true; children.push(panel); } else { const panel = new SceneCSSGridItem({ - $variables: getVariablesWithMetricConstant(metric.name), + $variables: new SceneVariableSet({ + variables: getVariablesWithMetricConstant(metric.name), + }), body: getCardPanelFor(metric.name), }); metric.itemRef = panel.getRef(); @@ -205,33 +272,69 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> { } }; - public onSearchChange = (evt: React.SyntheticEvent<HTMLInputElement>) => { + public onSearchQueryChange = (evt: React.SyntheticEvent<HTMLInputElement>) => { this.setState({ searchQuery: evt.currentTarget.value }); - this.updateMetrics(); - this.buildLayout(); + this.searchQueryChangedDebounced(); }; + private searchQueryChangedDebounced = debounce(() => { + this.updateMetrics(); // Need to repeat entire pipeline + this.buildLayout(); + }, 500); + public onTogglePreviews = () => { this.setState({ showPreviews: !this.state.showPreviews }); this.buildLayout(); }; public static Component = ({ model }: SceneComponentProps<MetricSelectScene>) => { - const { showHeading, searchQuery, showPreviews } = model.useState(); + const { searchQuery, showPreviews, body } = model.useState(); + const { children } = body.useState(); const styles = useStyles2(getStyles); + const metricNamesStatus = useVariableStatus(VAR_METRIC_NAMES, model); + const tooStrict = children.length === 0 && searchQuery; + const noMetrics = !metricNamesStatus.isLoading && model.currentMetricNames.size === 0; + + const isLoading = metricNamesStatus.isLoading && children.length === 0; + + const blockingMessage = isLoading + ? undefined + : (noMetrics && 'There are no results found. Try a different time range or a different data source.') || + (tooStrict && 'There are no results found. Try adjusting your search or filters.') || + undefined; + + const disableSearch = metricNamesStatus.error || metricNamesStatus.isLoading; + return ( <div className={styles.container}> - {showHeading && ( - <div className={styles.headingWrapper}> - <Text variant="h4">Select a metric</Text> - </div> - )} <div className={styles.header}> - <Input placeholder="Search metrics" value={searchQuery} onChange={model.onSearchChange} /> - <InlineSwitch showLabel={true} label="Show previews" value={showPreviews} onChange={model.onTogglePreviews} /> + <Field label={'Search metrics'} className={styles.searchField}> + <Input + placeholder="Search metrics" + prefix={<Icon name={'search'} />} + value={searchQuery} + onChange={model.onSearchQueryChange} + disabled={disableSearch} + /> + </Field> + <InlineSwitch + showLabel={true} + label="Show previews" + value={showPreviews} + onChange={model.onTogglePreviews} + disabled={disableSearch} + /> </div> - <model.state.body.Component model={model.state.body} /> + {metricNamesStatus.error && ( + <Alert title="Unable to retrieve metric names" severity="error"> + <div>We are unable to connect to your data source. Double check your data source URL and credentials.</div> + <div>({metricNamesStatus.error})</div> + </Alert> + )} + <StatusWrapper {...{ isLoading, blockingMessage }}> + <model.state.body.Component model={model.state.body} /> + </StatusWrapper> </div> ); }; @@ -247,33 +350,13 @@ function getMetricNamesVariableSet() { includeAll: true, defaultToAll: true, skipUrlSync: true, + refresh: VariableRefresh.onTimeRangeChanged, query: { query: `label_values(${VAR_FILTERS_EXPR},__name__)`, refId: 'A' }, }), ], }); } -function getPreviewPanelFor(metric: string, index: number) { - const autoQuery = getAutoQueriesForMetric(metric); - - const vizPanel = autoQuery.preview - .vizBuilder(autoQuery.preview) - .setColor({ mode: 'fixed', fixedColor: getColorByIndex(index) }) - .setHeaderActions(new SelectMetricAction({ metric, title: 'Select' })) - .build(); - - return new SceneCSSGridItem({ - $variables: getVariablesWithMetricConstant(metric), - $behaviors: [hideEmptyPreviews(metric)], - $data: new SceneQueryRunner({ - datasource: trailDS, - maxDataPoints: 200, - queries: autoQuery.preview.queries, - }), - body: vizPanel, - }); -} - function getCardPanelFor(metric: string) { return PanelBuilders.text() .setTitle(metric) @@ -282,24 +365,6 @@ function getCardPanelFor(metric: string) { .build(); } -// Computes the Levenshtein distance between two strings, twice, once for the first half and once for the whole string. -function sortRelatedMetrics(metricList: VariableValueOption[], metric: string) { - return metricList.sort((a, b) => { - const aValue = String(a.value); - const aSplit = aValue.split('_'); - const aHalf = aSplit.slice(0, aSplit.length / 2).join('_'); - - const bValue = String(b.value); - const bSplit = bValue.split('_'); - const bHalf = bSplit.slice(0, bSplit.length / 2).join('_'); - - return ( - (leven(aHalf, metric!) || 0 + (leven(aValue, metric!) || 0)) - - (leven(bHalf, metric!) || 0 + (leven(bValue, metric!) || 0)) - ); - }); -} - function getStyles(theme: GrafanaTheme2) { return { container: css({ @@ -308,13 +373,56 @@ function getStyles(theme: GrafanaTheme2) { flexGrow: 1, }), headingWrapper: css({ - marginTop: theme.spacing(1), + marginBottom: theme.spacing(0.5), }), header: css({ flexGrow: 0, display: 'flex', gap: theme.spacing(2), - marginBottom: theme.spacing(1), + marginBottom: theme.spacing(2), + alignItems: 'flex-end', + }), + searchField: css({ + flexGrow: 1, + marginBottom: 0, }), }; } + +// Consider any sequence of characters not permitted for metric names as a sepratator +const splitSeparator = /[^a-z0-9_:]+/; + +function createSearchRegExp(spaceSeparatedMetricNames?: string) { + if (!spaceSeparatedMetricNames) { + return null; + } + const searchParts = spaceSeparatedMetricNames + ?.toLowerCase() + .split(splitSeparator) + .filter((part) => part.length > 0) + .map((part) => `(?=(.*${part}.*))`); + + if (searchParts.length === 0) { + return null; + } + + const regex = searchParts.join(''); + // (?=(.*expr1.*))(?=().*expr2.*))... + // The ?=(...) lookahead allows us to match these in any order. + return new RegExp(regex, 'igy'); +} + +function useVariableStatus(name: string, sceneObject: SceneObject) { + const variable = sceneGraph.lookupVariable(name, sceneObject); + + const useVariableState = useCallback(() => { + if (variable) { + return variable.useState(); + } + return undefined; + }, [variable]); + + const { error, loading } = useVariableState() || {}; + + return { isLoading: !!loading, error }; +} diff --git a/public/app/features/trails/MetricsHeader.tsx b/public/app/features/trails/MetricsHeader.tsx new file mode 100644 index 0000000000000..f40edb4be8987 --- /dev/null +++ b/public/app/features/trails/MetricsHeader.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +import { Stack, Text } from '@grafana/ui'; + +export const MetricsHeader = () => ( + <Stack direction="column" gap={1}> + <Text variant="h1">Metrics</Text> + <Text color="secondary">Explore your Prometheus-compatible metrics without writing a query</Text> + </Stack> +); diff --git a/public/app/features/trails/SelectMetricAction.tsx b/public/app/features/trails/SelectMetricAction.tsx index a41e8d0d1215d..134c651482aba 100644 --- a/public/app/features/trails/SelectMetricAction.tsx +++ b/public/app/features/trails/SelectMetricAction.tsx @@ -17,7 +17,7 @@ export class SelectMetricAction extends SceneObjectBase<SelectMetricActionState> public static Component = ({ model }: SceneComponentProps<SelectMetricAction>) => { return ( - <Button variant="primary" size="sm" fill="text" onClick={model.onClick}> + <Button variant="secondary" size="sm" fill="solid" onClick={model.onClick}> {model.state.title} </Button> ); diff --git a/public/app/features/trails/SelectMetricTrailView.tsx b/public/app/features/trails/SelectMetricTrailView.tsx deleted file mode 100644 index bea975cec52f3..0000000000000 --- a/public/app/features/trails/SelectMetricTrailView.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; - -import { - SceneObjectState, - SceneObjectBase, - VariableDependencyConfig, - sceneGraph, - SceneComponentProps, - SceneVariableSet, - SceneVariable, - QueryVariable, - VariableValueOption, -} from '@grafana/scenes'; -import { VariableHide } from '@grafana/schema'; -import { Input, Card, Stack } from '@grafana/ui'; - -import { trailDS } from './shared'; - -export interface SelectMetricTrailViewState extends SceneObjectState { - metricNames: VariableValueOption[]; -} - -export class SelectMetricTrailView extends SceneObjectBase<SelectMetricTrailViewState> { - public constructor(state: Partial<SelectMetricTrailViewState>) { - super({ - $variables: new SceneVariableSet({ - variables: [ - new QueryVariable({ - name: 'metricNames', - datasource: trailDS, - hide: VariableHide.hideVariable, - includeAll: true, - defaultToAll: true, - skipUrlSync: true, - query: { query: 'label_values({$filters},__name__)', refId: 'A' }, - }), - ], - }), - metricNames: [], - ...state, - }); - } - - protected _variableDependency = new VariableDependencyConfig(this, { - variableNames: ['filters', 'metricNames'], - onVariableUpdatesCompleted: this._onVariableChanged.bind(this), - }); - - private _onVariableChanged(changedVariables: Set<SceneVariable>, dependencyChanged: boolean): void { - for (const variable of changedVariables) { - if (variable.state.name === 'filters') { - const variable = sceneGraph.lookupVariable('filters', this)!; - // Temp hack - (this.state.$variables as any)._handleVariableValueChanged(variable); - } - - if (variable.state.name === 'metricNames' && variable instanceof QueryVariable) { - this.setState({ metricNames: variable.state.options }); - } - } - } - - static Component = ({ model }: SceneComponentProps<SelectMetricTrailView>) => { - const { metricNames } = model.useState(); - - return ( - <Stack direction="column" gap={0}> - <Stack direction="column" gap={2}> - <Input placeholder="Search metrics" /> - <div></div> - </Stack> - {metricNames.map((option, index) => ( - <Card - key={index} - href={sceneGraph.interpolate(model, `\${__url.path}\${__url.params}&metric=${option.value}`)} - > - <Card.Heading>{String(option.value)}</Card.Heading> - </Card> - ))} - </Stack> - ); - }; -} diff --git a/public/app/features/trails/ShareTrailButton.tsx b/public/app/features/trails/ShareTrailButton.tsx new file mode 100644 index 0000000000000..35d00639430bb --- /dev/null +++ b/public/app/features/trails/ShareTrailButton.tsx @@ -0,0 +1,28 @@ +import React, { useState } from 'react'; +import { useLocation } from 'react-use'; + +import { ToolbarButton } from '@grafana/ui'; + +import { DataTrail } from './DataTrail'; +import { getUrlForTrail } from './utils'; + +interface ShareTrailButtonState { + trail: DataTrail; +} + +export const ShareTrailButton = ({ trail }: ShareTrailButtonState) => { + const { origin } = useLocation(); + const [tooltip, setTooltip] = useState('Copy url'); + + const onShare = () => { + if (navigator.clipboard) { + navigator.clipboard.writeText(origin + getUrlForTrail(trail)); + setTooltip('Copied!'); + setTimeout(() => { + setTooltip('Copy url'); + }, 2000); + } + }; + + return <ToolbarButton variant={'canvas'} icon={'share-alt'} tooltip={tooltip} onClick={onShare} />; +}; diff --git a/public/app/features/trails/StatusWrapper.tsx b/public/app/features/trails/StatusWrapper.tsx new file mode 100644 index 0000000000000..372a558bed178 --- /dev/null +++ b/public/app/features/trails/StatusWrapper.tsx @@ -0,0 +1,39 @@ +import { css } from '@emotion/css'; +import React, { ReactNode } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { LoadingPlaceholder, useStyles2 } from '@grafana/ui'; + +type Props = { + blockingMessage?: string; + isLoading?: boolean; + children?: ReactNode; +}; + +export function StatusWrapper({ blockingMessage, isLoading, children }: Props) { + const styles = useStyles2(getStyles); + + if (isLoading && !blockingMessage) { + blockingMessage = 'Loading...'; + } + + if (isLoading) { + return <LoadingPlaceholder className={styles.statusMessage} text={blockingMessage} />; + } + + if (!blockingMessage) { + return children; + } + + return <div className={styles.statusMessage}>{blockingMessage}</div>; +} + +function getStyles(theme: GrafanaTheme2) { + return { + statusMessage: css({ + fontStyle: 'italic', + marginTop: theme.spacing(7), + textAlign: 'center', + }), + }; +} diff --git a/public/app/features/trails/TrailStore/TrailStore.test.ts b/public/app/features/trails/TrailStore/TrailStore.test.ts index c7b443f8bfa71..2aa5d1671e661 100644 --- a/public/app/features/trails/TrailStore/TrailStore.test.ts +++ b/public/app/features/trails/TrailStore/TrailStore.test.ts @@ -1,6 +1,13 @@ import { BOOKMARKED_TRAILS_KEY, RECENT_TRAILS_KEY } from '../shared'; -import { getTrailStore } from './TrailStore'; +import { SerializedTrail, getTrailStore } from './TrailStore'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getTemplateSrv: () => ({ + getAdhocFilters: jest.fn().mockReturnValue([{ key: 'origKey', operator: '=', value: '' }]), + }), +})); describe('TrailStore', () => { beforeAll(() => { @@ -29,40 +36,37 @@ describe('TrailStore', () => { }); describe('Initialize store with one recent trail', () => { - beforeAll(() => { + const history: SerializedTrail['history'] = [ + { + urlValues: { + from: 'now-1h', + to: 'now', + 'var-ds': 'cb3a3391-700f-4cc6-81be-a122488e93e6', + 'var-filters': [], + refresh: '', + }, + type: 'start', + description: 'Test', + parentIndex: -1, + }, + { + urlValues: { + metric: 'access_permissions_duration_count', + from: 'now-1h', + to: 'now', + 'var-ds': 'cb3a3391-700f-4cc6-81be-a122488e93e6', + 'var-filters': [], + refresh: '', + }, + type: 'metric', + description: 'Test', + parentIndex: 0, + }, + ]; + + beforeEach(() => { localStorage.clear(); - localStorage.setItem( - RECENT_TRAILS_KEY, - JSON.stringify([ - { - history: [ - { - urlValues: { - from: 'now-1h', - to: 'now', - 'var-ds': 'cb3a3391-700f-4cc6-81be-a122488e93e6', - 'var-filters': [], - refresh: '', - }, - type: 'start', - description: 'Test', - }, - { - urlValues: { - metric: 'access_permissions_duration_count', - from: 'now-1h', - to: 'now', - 'var-ds': 'cb3a3391-700f-4cc6-81be-a122488e93e6', - 'var-filters': [], - refresh: '', - }, - type: 'metric', - description: 'Test', - }, - ], - }, - ]) - ); + localStorage.setItem(RECENT_TRAILS_KEY, JSON.stringify([{ history }])); getTrailStore().load(); }); @@ -79,9 +83,91 @@ describe('TrailStore', () => { const store = getTrailStore(); expect(store.bookmarks.length).toBe(0); }); + + describe('Add a new recent trail with equivalent current step state', () => { + const store = getTrailStore(); + + const duplicateTrailSerialized: SerializedTrail = { + history: [ + history[0], + history[1], + { + ...history[1], + urlValues: { + ...history[1].urlValues, + metric: 'different_metric_in_the_middle', + }, + }, + { + ...history[1], + }, + ], + currentStep: 3, + }; + + beforeEach(() => { + // We expect the initialized trail to be there + expect(store.recent.length).toBe(1); + expect(store.recent[0].resolve().state.history.state.steps.length).toBe(2); + + // @ts-ignore #2341 -- deliberately access private method to construct trail object for testing purposes + const duplicateTrail = store._deserializeTrail(duplicateTrailSerialized); + store.setRecentTrail(duplicateTrail); + }); + + it('should still be only one recent trail', () => { + expect(store.recent.length).toBe(1); + }); + + it('it should only contain the new trail', () => { + const newRecentTrail = store.recent[0].resolve(); + expect(newRecentTrail.state.history.state.steps.length).toBe(duplicateTrailSerialized.history.length); + + // @ts-ignore #2341 -- deliberately access private method to construct trail object for testing purposes + const newRecent = store._serializeTrail(newRecentTrail); + expect(newRecent.currentStep).toBe(duplicateTrailSerialized.currentStep); + expect(newRecent.history.length).toBe(duplicateTrailSerialized.history.length); + }); + }); + + it.each([ + ['metric', 'different_metric'], + ['from', 'now-1y'], + ['to', 'now-30m'], + ['var-ds', '1234'], + ['var-groupby', 'job'], + ['var-filters', 'test'], + ])(`new recent trails with a different '%p' value should insert new entry`, (key, differentValue) => { + const store = getTrailStore(); + // We expect the initialized trail to be there + expect(store.recent.length).toBe(1); + + const differentTrailSerialized: SerializedTrail = { + history: [ + history[0], + history[1], + { + ...history[1], + urlValues: { + ...history[1].urlValues, + [key]: differentValue, + }, + parentIndex: 1, + }, + ], + currentStep: 2, + }; + + // @ts-ignore #2341 -- deliberately access private method to construct trail object for testing purposes + const differentTrail = store._deserializeTrail(differentTrailSerialized); + store.setRecentTrail(differentTrail); + + // There should now be two trails + expect(store.recent.length).toBe(2); + }); }); describe('Initialize store with one bookmark trail', () => { - beforeAll(() => { + beforeEach(() => { localStorage.clear(); localStorage.setItem( BOOKMARKED_TRAILS_KEY, @@ -139,6 +225,35 @@ describe('TrailStore', () => { expect(store.recent.length).toBe(1); }); + it('should be able to obtain index of bookmark', () => { + const trail = store.bookmarks[0].resolve(); + const index = store.getBookmarkIndex(trail); + expect(index).toBe(0); + }); + + it('index should be undefined for removed bookmarks', () => { + const trail = store.bookmarks[0].resolve(); + store.removeBookmark(0); + const index = store.getBookmarkIndex(trail); + expect(index).toBe(undefined); + }); + + it('index should be undefined for a trail that has changed since it was bookmarked', () => { + const trail = store.bookmarks[0].resolve(); + trail.setState({ metric: 'something_completely_different' }); + const index = store.getBookmarkIndex(trail); + expect(index).toBe(undefined); + }); + + it('should be able to obtain index of a bookmark for a trail that changed back to bookmarked state', () => { + const trail = store.bookmarks[0].resolve(); + const bookmarkedMetric = trail.state.metric; + trail.setState({ metric: 'something_completely_different' }); + trail.setState({ metric: bookmarkedMetric }); + const index = store.getBookmarkIndex(trail); + expect(index).toBe(0); + }); + it('should remove a bookmark', () => { expect(store.bookmarks.length).toBe(1); store.removeBookmark(0); diff --git a/public/app/features/trails/TrailStore/TrailStore.ts b/public/app/features/trails/TrailStore/TrailStore.ts index e7ea4e4c06bc2..e11a786c35315 100644 --- a/public/app/features/trails/TrailStore/TrailStore.ts +++ b/public/app/features/trails/TrailStore/TrailStore.ts @@ -1,4 +1,4 @@ -import { debounce } from 'lodash'; +import { debounce, isEqual } from 'lodash'; import { SceneObject, SceneObjectRef, SceneObjectUrlValues, getUrlSyncManager, sceneUtils } from '@grafana/scenes'; @@ -16,6 +16,7 @@ export interface SerializedTrail { parentIndex: number; }>; currentStep: number; + createdAt?: number; } export class TrailStore { @@ -53,7 +54,7 @@ export class TrailStore { private _deserializeTrail(t: SerializedTrail): DataTrail { // reconstruct the trail based on the the serialized history - const trail = new DataTrail({}); + const trail = new DataTrail({ createdAt: t.createdAt }); t.history.map((step) => { this._loadFromUrl(trail, step.urlValues); @@ -82,6 +83,7 @@ export class TrailStore { return { history, currentStep: trail.state.history.state.currentStep, + createdAt: trail.state.createdAt, }; } @@ -98,10 +100,21 @@ export class TrailStore { load() { this._recent = this._loadFromStorage(RECENT_TRAILS_KEY); this._bookmarks = this._loadFromStorage(BOOKMARKED_TRAILS_KEY); + this._refreshBookmarkIndexMap(); } setRecentTrail(trail: DataTrail) { this._recent = this._recent.filter((t) => t !== trail.getRef()); + + // Check if any existing "recent" entries have equivalent 'current' urlValue to the new trail + const newTrailUrlValues = getCurrentUrlValues(this._serializeTrail(trail)) || {}; + this._recent = this._recent.filter((t) => { + // Use the current step urlValues to filter out equivalent states + const urlValues = getCurrentUrlValues(this._serializeTrail(t.resolve())); + // Only keep trails with sufficiently unique urlValues on their current step + return !isEqual(newTrailUrlValues, urlValues); + }); + this._recent.unshift(trail.getRef()); this._save(); } @@ -113,15 +126,47 @@ export class TrailStore { addBookmark(trail: DataTrail) { this._bookmarks.unshift(trail.getRef()); + this._refreshBookmarkIndexMap(); this._save(); } removeBookmark(index: number) { if (index < this._bookmarks.length) { this._bookmarks.splice(index, 1); + this._refreshBookmarkIndexMap(); this._save(); } } + + getBookmarkIndex(trail: DataTrail) { + const bookmarkKey = getBookmarkKey(trail); + const bookmarkIndex = this._bookmarkIndexMap.get(bookmarkKey); + return bookmarkIndex; + } + + private _bookmarkIndexMap = new Map<string, number>(); + + private _refreshBookmarkIndexMap() { + this._bookmarkIndexMap.clear(); + this._bookmarks.forEach((bookmarked, index) => { + const trail = bookmarked.resolve(); + const key = getBookmarkKey(trail); + // If there are duplicate bookmarks, the latest index will be kept + this._bookmarkIndexMap.set(key, index); + }); + } +} + +function getBookmarkKey(trail: DataTrail) { + const urlState = getUrlSyncManager().getUrlState(trail); + // Not part of state + delete urlState.actionView; + // Populate defaults + if (urlState['var-groupby'] === '') { + urlState['var-groupby'] = '$__all'; + } + const key = JSON.stringify(urlState); + return key; } let store: TrailStore | undefined; @@ -132,3 +177,7 @@ export function getTrailStore(): TrailStore { return store; } + +function getCurrentUrlValues({ history, currentStep }: SerializedTrail) { + return history[currentStep]?.urlValues || history.at(-1)?.urlValues; +} diff --git a/public/app/features/trails/TrailStore/useBookmarkState.ts b/public/app/features/trails/TrailStore/useBookmarkState.ts new file mode 100644 index 0000000000000..18336b01489ef --- /dev/null +++ b/public/app/features/trails/TrailStore/useBookmarkState.ts @@ -0,0 +1,39 @@ +import { useState } from 'react'; + +import { DataTrail } from '../DataTrail'; + +import { getTrailStore } from './TrailStore'; + +export function useBookmarkState(trail: DataTrail) { + // Note that trail object may stay the same, but the state used by `getBookmarkIndex` result may + // differ for each re-render of this hook + const getBookmarkIndex = () => getTrailStore().getBookmarkIndex(trail); + + const indexOnRender = getBookmarkIndex(); + + const [bookmarkIndex, setBookmarkIndex] = useState(indexOnRender); + + // Check if index changed and force a re-render + if (indexOnRender !== bookmarkIndex) { + setBookmarkIndex(indexOnRender); + } + + const isBookmarked = bookmarkIndex != null; + + const toggleBookmark = () => { + if (isBookmarked) { + let indexToRemove = getBookmarkIndex(); + while (indexToRemove != null) { + // This loop will remove all indices that have an equivalent bookmark key + getTrailStore().removeBookmark(indexToRemove); + indexToRemove = getBookmarkIndex(); + } + } else { + getTrailStore().addBookmark(trail); + } + setBookmarkIndex(getBookmarkIndex()); + }; + + const result: [typeof isBookmarked, typeof toggleBookmark] = [isBookmarked, toggleBookmark]; + return result; +} diff --git a/public/app/features/trails/dashboardIntegration.ts b/public/app/features/trails/dashboardIntegration.ts deleted file mode 100644 index 98b43feeaf481..0000000000000 --- a/public/app/features/trails/dashboardIntegration.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { PanelMenuItem } from '@grafana/data'; -import { getDataSourceSrv } from '@grafana/runtime'; -import { VizPanel } from '@grafana/scenes'; -import { buildVisualQueryFromString } from 'app/plugins/datasource/prometheus/querybuilder/parsing'; - -import { DashboardScene } from '../dashboard-scene/scene/DashboardScene'; -import { getQueryRunnerFor } from '../dashboard-scene/utils/utils'; - -import { DataTrailDrawer } from './DataTrailDrawer'; - -export function addDataTrailPanelAction(dashboard: DashboardScene, vizPanel: VizPanel, items: PanelMenuItem[]) { - const queryRunner = getQueryRunnerFor(vizPanel); - if (!queryRunner) { - return; - } - - const ds = getDataSourceSrv().getInstanceSettings(queryRunner.state.datasource); - if (!ds || ds.meta.id !== 'prometheus' || queryRunner.state.queries.length > 1) { - return; - } - - const query = queryRunner.state.queries[0]; - const parsedResult = buildVisualQueryFromString(query.expr); - if (parsedResult.errors.length > 0) { - return; - } - - items.push({ - text: 'Data trail', - iconClassName: 'code-branch', - onClick: () => { - dashboard.showModal( - new DataTrailDrawer({ query: parsedResult.query, dsRef: ds, timeRange: dashboard.state.$timeRange!.clone() }) - ); - }, - shortcut: 'p s', - }); -} diff --git a/public/app/features/trails/hideEmptyPreviews.ts b/public/app/features/trails/hideEmptyPreviews.ts index e02b2aa61f003..44c467f48a6af 100644 --- a/public/app/features/trails/hideEmptyPreviews.ts +++ b/public/app/features/trails/hideEmptyPreviews.ts @@ -11,7 +11,7 @@ export function hideEmptyPreviews(metric: string) { } data.subscribeToState((state) => { - if (state.data?.state === LoadingState.Loading) { + if (state.data?.state === LoadingState.Loading || state.data?.state === LoadingState.Error) { return; } const scene = sceneGraph.getAncestor(gridItem, MetricSelectScene); diff --git a/public/app/features/trails/relatedMetrics.ts b/public/app/features/trails/relatedMetrics.ts new file mode 100644 index 0000000000000..9aaf24745e1db --- /dev/null +++ b/public/app/features/trails/relatedMetrics.ts @@ -0,0 +1,42 @@ +import leven from 'leven'; + +export function sortRelatedMetrics(metricList: string[], metric: string) { + return metricList.sort((aValue, bValue) => { + const a = getLevenDistances(aValue, metric); + const b = getLevenDistances(bValue, metric); + + return a.halfLeven + a.wholeLeven - (b.halfLeven + b.wholeLeven); + }); +} + +type LevenDistances = { halfLeven: number; wholeLeven: number }; +type TargetToLevenDistances = Map<string, LevenDistances>; + +const metricToTargetLevenDistances = new Map<string, TargetToLevenDistances>(); + +// Provides the Levenshtein distance between a metric to be sorted +// and a targetMetric compared to which all other metrics are being sorted +// There are two distances: once for the first half and once for the whole string. +// This operation is not expected to be symmetric; order of parameters matters +// since only `metric` is split. +function getLevenDistances(metric: string, targetMetric: string) { + let targetToDistances: TargetToLevenDistances | undefined = metricToTargetLevenDistances.get(metric); + if (!targetToDistances) { + targetToDistances = new Map<string, LevenDistances>(); + metricToTargetLevenDistances.set(metric, targetToDistances); + } + + let distances: LevenDistances | undefined = targetToDistances.get(targetMetric); + if (!distances) { + const metricSplit = metric.split('_'); + const metricHalf = metricSplit.slice(0, metricSplit.length / 2).join('_'); + + const halfLeven = leven(metricHalf, targetMetric!) || 0; + const wholeLeven = leven(metric, targetMetric!) || 0; + + distances = { halfLeven, wholeLeven }; + targetToDistances.set(targetMetric, distances); + } + + return distances; +} diff --git a/public/app/features/trails/shared.ts b/public/app/features/trails/shared.ts index a33cc4254dc5a..55960310c9c63 100644 --- a/public/app/features/trails/shared.ts +++ b/public/app/features/trails/shared.ts @@ -1,14 +1,15 @@ -import { BusEventBase, BusEventWithPayload } from '@grafana/data'; -import { ConstantVariable, SceneObject, SceneVariableSet } from '@grafana/scenes'; +import { BusEventWithPayload } from '@grafana/data'; +import { ConstantVariable, SceneObject } from '@grafana/scenes'; import { VariableHide } from '@grafana/schema'; +export type ActionViewType = 'overview' | 'breakdown' | 'logs' | 'related'; export interface ActionViewDefinition { displayName: string; - value: string; + value: ActionViewType; getScene: () => SceneObject; } -export const TRAILS_ROUTE = '/data-trails/trail'; +export const TRAILS_ROUTE = '/explore/metrics/trail'; export const VAR_METRIC_NAMES = 'metricNames'; export const VAR_FILTERS = 'filters'; @@ -19,6 +20,8 @@ export const VAR_GROUP_BY = 'groupby'; export const VAR_GROUP_BY_EXP = '${groupby}'; export const VAR_DATASOURCE = 'ds'; export const VAR_DATASOURCE_EXPR = '${ds}'; +export const VAR_LOGS_DATASOURCE = 'logsDs'; +export const VAR_LOGS_DATASOURCE_EXPR = '${logsDs}'; export const LOGS_METRIC = '$__logs__'; export const KEY_SQR_METRIC_VIZ_QUERY = 'sqr-metric-viz-query'; @@ -32,21 +35,15 @@ export const BOOKMARKED_TRAILS_KEY = 'grafana.trails.bookmarks'; export type MakeOptional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>; export function getVariablesWithMetricConstant(metric: string) { - return new SceneVariableSet({ - variables: [ - new ConstantVariable({ - name: VAR_METRIC, - value: metric, - hide: VariableHide.hideVariable, - }), - ], - }); + return [ + new ConstantVariable({ + name: VAR_METRIC, + value: metric, + hide: VariableHide.hideVariable, + }), + ]; } export class MetricSelectedEvent extends BusEventWithPayload<string> { public static type = 'metric-selected-event'; } - -export class OpenEmbeddedTrailEvent extends BusEventBase { - public static type = 'open-embedded-trail-event'; -} diff --git a/public/app/features/trails/utils.ts b/public/app/features/trails/utils.ts index 463b6752dec8c..2b18245a6dc77 100644 --- a/public/app/features/trails/utils.ts +++ b/public/app/features/trails/utils.ts @@ -1,11 +1,22 @@ import { urlUtil } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { getUrlSyncManager, sceneGraph, SceneObject, SceneObjectUrlValues, SceneTimeRange } from '@grafana/scenes'; +import { config, getDataSourceSrv } from '@grafana/runtime'; +import { + AdHocFiltersVariable, + getUrlSyncManager, + sceneGraph, + SceneObject, + SceneObjectState, + SceneObjectUrlValues, + SceneTimeRange, +} from '@grafana/scenes'; + +import { getDatasourceSrv } from '../plugins/datasource_srv'; import { DataTrail } from './DataTrail'; import { DataTrailSettings } from './DataTrailSettings'; import { MetricScene } from './MetricScene'; -import { TRAILS_ROUTE } from './shared'; +import { getTrailStore } from './TrailStore/TrailStore'; +import { LOGS_METRIC, TRAILS_ROUTE, VAR_DATASOURCE_EXPR } from './shared'; export function getTrailFor(model: SceneObject): DataTrail { return sceneGraph.getAncestor(model, DataTrail); @@ -15,9 +26,9 @@ export function getTrailSettings(model: SceneObject): DataTrailSettings { return sceneGraph.getAncestor(model, DataTrail).state.settings; } -export function newMetricsTrail(): DataTrail { +export function newMetricsTrail(initialDS?: string): DataTrail { return new DataTrail({ - //initialDS: 'gdev-prometheus', + initialDS, $timeRange: new SceneTimeRange({ from: 'now-1h', to: 'now' }), //initialFilters: [{ key: 'job', operator: '=', value: 'grafana' }], embedded: false, @@ -47,7 +58,59 @@ export function getMetricSceneFor(model: SceneObject): MetricScene { throw new Error('Unable to find trail'); } +export function getDataSource(trail: DataTrail) { + return sceneGraph.interpolate(trail, VAR_DATASOURCE_EXPR); +} + +export function getDataSourceName(dataSourceUid: string) { + return getDataSourceSrv().getInstanceSettings(dataSourceUid)?.name || dataSourceUid; +} + +export function getMetricName(metric?: string) { + if (!metric) { + return 'Select metric'; + } + + if (metric === LOGS_METRIC) { + return 'Logs'; + } + + return metric; +} + +export function getDatasourceForNewTrail(): string | undefined { + const prevTrail = getTrailStore().recent[0]; + if (prevTrail) { + const prevDataSource = sceneGraph.interpolate(prevTrail.resolve(), VAR_DATASOURCE_EXPR); + if (typeof prevDataSource === 'string' && prevDataSource.length > 0) { + return prevDataSource; + } + } + const promDatasources = getDatasourceSrv().getList({ type: 'prometheus' }); + if (promDatasources.length > 0) { + return promDatasources.find((mds) => mds.uid === config.defaultDatasource)?.uid ?? promDatasources[0].uid; + } + return undefined; +} + export function getColorByIndex(index: number) { const visTheme = config.theme2.visualization; return visTheme.getColorByName(visTheme.palette[index % 8]); } + +export type SceneTimeRangeState = SceneObjectState & { + from: string; + to: string; +}; +export function isSceneTimeRangeState(state: SceneObjectState): state is SceneTimeRangeState { + const keys = Object.keys(state); + return keys.includes('from') && keys.includes('to'); +} + +export function getFilters(scene: SceneObject) { + const filters = sceneGraph.lookupVariable('filters', scene); + if (filters instanceof AdHocFiltersVariable) { + return filters.state.filters; + } + return null; +} diff --git a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx index e80b2cbe2be1d..eadbc0b50e992 100644 --- a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx +++ b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx @@ -127,5 +127,19 @@ export const getBasicValueMatchersUI = (): Array<ValueMatcherUIRegistryItem<Basi validator: () => true, }), }, + { + name: 'Is Substring', + id: ValueMatcherID.substring, + component: basicMatcherEditor<string | number | boolean>({ + validator: () => true, + }), + }, + { + name: 'Is not substring', + id: ValueMatcherID.notSubstring, + component: basicMatcherEditor<string | number | boolean>({ + validator: () => true, + }), + }, ]; }; diff --git a/public/app/features/transformers/calculateHeatmap/heatmap.ts b/public/app/features/transformers/calculateHeatmap/heatmap.ts index a5c770fe9e8f0..1de1d63836119 100644 --- a/public/app/features/transformers/calculateHeatmap/heatmap.ts +++ b/public/app/features/transformers/calculateHeatmap/heatmap.ts @@ -323,11 +323,12 @@ export function calculateHeatmapFromData(frames: DataFrame[], options: HeatmapCa xBucketsCfg.mode === HeatmapCalculationMode.Size ? durationToMilliseconds(parseDuration(xBucketsCfg.value ?? '')) : xBucketsCfg.value - ? +xBucketsCfg.value - : undefined, + ? +xBucketsCfg.value + : undefined, yMode: yBucketsCfg.mode, ySize: yBucketsCfg.value ? +yBucketsCfg.value : undefined, - yLog: scaleDistribution?.type === ScaleDistribution.Log ? (scaleDistribution?.log as any) : undefined, + yLog: + scaleDistribution?.type === ScaleDistribution.Log ? (scaleDistribution?.log as 2 | 10 | undefined) : undefined, }); const frame = { diff --git a/public/app/features/transformers/docs/content.ts b/public/app/features/transformers/docs/content.ts index 1f21dc8c39c33..6adb4fefe64ff 100644 --- a/public/app/features/transformers/docs/content.ts +++ b/public/app/features/transformers/docs/content.ts @@ -39,44 +39,44 @@ export const transformationDocsContent: TransformationDocsContentType = { */ getHelperDocs: function (imageRenderType: ImageRenderType = ImageRenderType.ShortcodeFigure) { return ` - Use this transformation to add a new field calculated from two other fields. Each transformation allows you to add one new field. - - - **Mode** - Select a mode: - - **Reduce row** - Apply selected calculation on each row of selected fields independently. - - **Binary operation** - Apply basic binary operations (for example, sum or multiply) on values in a single row from two selected fields. - - **Unary operation** - Apply basic unary operations on values in a single row from a selected field. The available operations are: - - **Absolute value (abs)** - Returns the absolute value of a given expression. It represents its distance from zero as a positive number. - - **Natural exponential (exp)** - Returns _e_ raised to the power of a given expression. - - **Natural logarithm (ln)** - Returns the natural logarithm of a given expression. - - **Floor (floor)** - Returns the largest integer less than or equal to a given expression. - - **Ceiling (ceil)** - Returns the smallest integer greater than or equal to a given expression. - - **Cumulative functions** - Apply functions on the current row and all preceding rows. - - **Total** - Calculates the cumulative total up to and including the current row. - - **Mean** - Calculates the mean up to and including the current row. - - **Window functions** - Apply window functions. The window can either be **trailing** or **centered**. - With a trailing window the current row will be the last row in the window. - With a centered window the window will be centered on the current row. - For even window sizes, the window will be centered between the current row, and the previous row. - - **Mean** - Calculates the moving mean or running average. - - **Stddev** - Calculates the moving standard deviation. - - **Variance** - Calculates the moving variance. - - **Row index** - Insert a field with the row index. - - **Field name** - Select the names of fields you want to use in the calculation for the new field. - - **Calculation** - If you select **Reduce row** mode, then the **Calculation** field appears. Click in the field to see a list of calculation choices you can use to create the new field. For information about available calculations, refer to [Calculation types][]. - - **Operation** - If you select **Binary operation** or **Unary operation** mode, then the **Operation** fields appear. These fields allow you to apply basic math operations on values in a single row from selected fields. You can also use numerical values for binary operations. - - **As percentile** - If you select **Row index** mode, then the **As percentile** switch appears. This switch allows you to transform the row index as a percentage of the total number of rows. - - **Alias** - (Optional) Enter the name of your new field. If you leave this blank, then the field will be named to match the calculation. - - **Replace all fields** - (Optional) Select this option if you want to hide all other fields and display only your calculated field in the visualization. - - > **Note:** **Cumulative functions** and **Window functions** modes are currently in private preview. Grafana Labs offers support on a best-effort basis, and breaking changes might occur prior to the feature being made generally available. - - In the example below, we added two fields together and named them Sum. +Use this transformation to add a new field calculated from two other fields. Each transformation allows you to add one new field. + +- **Mode** - Select a mode: + - **Reduce row** - Apply selected calculation on each row of selected fields independently. + - **Binary operation** - Apply basic binary operations (for example, sum or multiply) on values in a single row from two selected fields. + - **Unary operation** - Apply basic unary operations on values in a single row from a selected field. The available operations are: + - **Absolute value (abs)** - Returns the absolute value of a given expression. It represents its distance from zero as a positive number. + - **Natural exponential (exp)** - Returns _e_ raised to the power of a given expression. + - **Natural logarithm (ln)** - Returns the natural logarithm of a given expression. + - **Floor (floor)** - Returns the largest integer less than or equal to a given expression. + - **Ceiling (ceil)** - Returns the smallest integer greater than or equal to a given expression. + - **Cumulative functions** - Apply functions on the current row and all preceding rows. + - **Total** - Calculates the cumulative total up to and including the current row. + - **Mean** - Calculates the mean up to and including the current row. + - **Window functions** - Apply window functions. The window can either be **trailing** or **centered**. + With a trailing window the current row will be the last row in the window. + With a centered window the window will be centered on the current row. + For even window sizes, the window will be centered between the current row, and the previous row. + - **Mean** - Calculates the moving mean or running average. + - **Stddev** - Calculates the moving standard deviation. + - **Variance** - Calculates the moving variance. + - **Row index** - Insert a field with the row index. +- **Field name** - Select the names of fields you want to use in the calculation for the new field. +- **Calculation** - If you select **Reduce row** mode, then the **Calculation** field appears. Click in the field to see a list of calculation choices you can use to create the new field. For information about available calculations, refer to [Calculation types][]. +- **Operation** - If you select **Binary operation** or **Unary operation** mode, then the **Operation** fields appear. These fields allow you to apply basic math operations on values in a single row from selected fields. You can also use numerical values for binary operations. +- **As percentile** - If you select **Row index** mode, then the **As percentile** switch appears. This switch allows you to transform the row index as a percentage of the total number of rows. +- **Alias** - (Optional) Enter the name of your new field. If you leave this blank, then the field will be named to match the calculation. +- **Replace all fields** - (Optional) Select this option if you want to hide all other fields and display only your calculated field in the visualization. + +> **Note:** **Cumulative functions** and **Window functions** modes are currently in public preview. Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. Enable the \`addFieldFromCalculationStatFunctions\` feature toggle in Grafana to use this feature. Contact Grafana Support to enable this feature in Grafana Cloud. + +In the example below, we added two fields together and named them Sum. - ${buildImageContent( - '/static/img/docs/transformations/add-field-from-calc-stat-example-7-0.png', - imageRenderType, - 'A stat visualization including one field called Sum' - )} +${buildImageContent( + '/static/img/docs/transformations/add-field-from-calc-stat-example-7-0.png', + imageRenderType, + 'A stat visualization including one field called Sum' +)} `; }, links: [ @@ -90,31 +90,31 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Concatenate fields', getHelperDocs: function () { return ` - Use this transformation to combine all fields from all frames into one result. - - For example, if you have separate queries retrieving temperature and uptime data (Query A) and air quality index and error information (Query B), applying the concatenate transformation yields a consolidated data frame with all relevant information in one view. +Use this transformation to combine all fields from all frames into one result. - Consider the following: +For example, if you have separate queries retrieving temperature and uptime data (Query A) and air quality index and error information (Query B), applying the concatenate transformation yields a consolidated data frame with all relevant information in one view. - **Query A:** +Consider the following: - | Temp | Uptime | - | ----- | --------- | - | 15.4 | 1230233 | +**Query A:** - **Query B:** +| Temp | Uptime | +| ----- | ------- | +| 15.4 | 1230233 | - | AQI | Errors | - | ----- | ------ | - | 3.2 | 5 | +**Query B:** - After you concatenate the fields, the data frame would be: +| AQI | Errors | +| ----- | ------ | +| 3.2 | 5 | - | Temp | Uptime | AQI | Errors | - | ----- | -------- | ----- | ------ | - | 15.4 | 1230233 | 3.2 | 5 | +After you concatenate the fields, the data frame would be: - This transformation simplifies the process of merging data from different sources, providing a comprehensive view for analysis and visualization. +| Temp | Uptime | AQI | Errors | +| ---- | ------- | --- | ------ | +| 15.4 | 1230233 | 3.2 | 5 | + +This transformation simplifies the process of merging data from different sources, providing a comprehensive view for analysis and visualization. `; }, }, @@ -122,64 +122,64 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Config from query results', getHelperDocs: function () { return ` - Use this transformation to select a query and extract standard options, such as **Min**, **Max**, **Unit**, and **Thresholds**, and apply them to other query results. This feature enables dynamic visualization configuration based on the data returned by a specific query. +Use this transformation to select a query and extract standard options, such as **Min**, **Max**, **Unit**, and **Thresholds**, and apply them to other query results. This feature enables dynamic visualization configuration based on the data returned by a specific query. - #### Options +#### Options - - **Config query** - Select the query that returns the data you want to use as configuration. - - **Apply to** - Select the fields or series to which the configuration should be applied. - - **Apply to options** - Specify a field type or use a field name regex, depending on your selection in **Apply to**. +- **Config query** - Select the query that returns the data you want to use as configuration. +- **Apply to** - Select the fields or series to which the configuration should be applied. +- **Apply to options** - Specify a field type or use a field name regex, depending on your selection in **Apply to**. - #### Field mapping table +#### Field mapping table - Below the configuration options, you'll find the field mapping table. This table lists all fields found in the data returned by the config query, along with **Use as** and **Select** options. It provides control over mapping fields to config properties, and for multiple rows, it allows you to choose which value to select. +Below the configuration options, you'll find the field mapping table. This table lists all fields found in the data returned by the config query, along with **Use as** and **Select** options. It provides control over mapping fields to config properties, and for multiple rows, it allows you to choose which value to select. - #### Example +#### Example - Input[0] (From query: A, name: ServerA) +Input[0] (From query: A, name: ServerA) - | Time | Value | - | ------------- | ----- | - | 1626178119127 | 10 | - | 1626178119129 | 30 | +| Time | Value | +| ------------- | ----- | +| 1626178119127 | 10 | +| 1626178119129 | 30 | - Input[1] (From query: B) +Input[1] (From query: B) - | Time | Value | - | ------------- | ----- | - | 1626178119127 | 100 | - | 1626178119129 | 100 | +| Time | Value | +| ------------- | ----- | +| 1626178119127 | 100 | +| 1626178119129 | 100 | - Output (Same as Input[0] but now with config on the Value field) +Output (Same as Input[0] but now with config on the Value field) - | Time | Value (config: Max=100) | - | ------------- | ----------------------- | - | 1626178119127 | 10 | - | 1626178119129 | 30 | +| Time | Value (config: Max=100) | +| ------------- | ----------------------- | +| 1626178119127 | 10 | +| 1626178119129 | 30 | - Each row in the source data becomes a separate field. Each field now has a maximum configuration option set. Options such as **Min**, **Max**, **Unit**, and **Thresholds** are part of the field configuration. If set, they are used by the visualization instead of any options manually configured in the panel editor options pane. +Each row in the source data becomes a separate field. Each field now has a maximum configuration option set. Options such as **Min**, **Max**, **Unit**, and **Thresholds** are part of the field configuration. If set, they are used by the visualization instead of any options manually configured in the panel editor options pane. - #### Value mappings +#### Value mappings - You can also transform a query result into value mappings. With this option, every row in the configuration query result defines a single value mapping row. See the following example. +You can also transform a query result into value mappings. With this option, every row in the configuration query result defines a single value mapping row. See the following example. - Config query result: +Config query result: - | Value | Text | Color | - | ----- | ------ | ----- | - | L | Low | blue | - | M | Medium | green | - | H | High | red | +| Value | Text | Color | +| ----- | ------ | ----- | +| L | Low | blue | +| M | Medium | green | +| H | High | red | - In the field mapping specify: +In the field mapping specify: - | Field | Use as | Select | - | ----- | ----------------------- | ---------- | - | Value | Value mappings / Value | All values | - | Text | Value mappings / Text | All values | - | Color | Value mappings / Ciolor | All values | +| Field | Use as | Select | +| ----- | ----------------------- | ---------- | +| Value | Value mappings / Value | All values | +| Text | Value mappings / Text | All values | +| Color | Value mappings / Ciolor | All values | - Grafana builds value mappings from your query result and applies them to the real data query results. You should see values being mapped and colored according to the config query results. +Grafana builds value mappings from your query result and applies them to the real data query results. You should see values being mapped and colored according to the config query results. `; }, }, @@ -187,44 +187,44 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Convert field type', getHelperDocs: function () { return ` - Use this transformation to modify the field type of a specified field. - - This transformation has the following options: - - - **Field** - Select from available fields - - **as** - Select the FieldType to convert to - - **Numeric** - attempts to make the values numbers - - **String** - will make the values strings - - **Time** - attempts to parse the values as time - - Will show an option to specify a DateFormat as input by a string like yyyy-mm-dd or DD MM YYYY hh:mm:ss - - **Boolean** - will make the values booleans - - **Enum** - will make the values enums - - Will show a table to manage the enums - - **Other** - attempts to parse the values as JSON - - For example, consider the following query that could be modified by selecting the time field as Time and specifying Date Format as YYYY. - - #### Sample Query - - | Time | Mark | Value | - |------------|-----------|-------| - | 2017-07-01 | above | 25 | - | 2018-08-02 | below | 22 | - | 2019-09-02 | below | 29 | - | 2020-10-04 | above | 22 | - - The result: - - #### Transformed Query - - | Time | Mark | Value | - |---------------------|-----------|-------| - | 2017-01-01 00:00:00 | above | 25 | - | 2018-01-01 00:00:00 | below | 22 | - | 2019-01-01 00:00:00 | below | 29 | - | 2020-01-01 00:00:00 | above | 22 | - - This transformation allows you to flexibly adapt your data types, ensuring compatibility and consistency in your visualizations. +Use this transformation to modify the field type of a specified field. + +This transformation has the following options: + +- **Field** - Select from available fields +- **as** - Select the FieldType to convert to + - **Numeric** - attempts to make the values numbers + - **String** - will make the values strings + - **Time** - attempts to parse the values as time + - Will show an option to specify a DateFormat as input by a string like yyyy-mm-dd or DD MM YYYY hh:mm:ss + - **Boolean** - will make the values booleans + - **Enum** - will make the values enums + - Will show a table to manage the enums + - **Other** - attempts to parse the values as JSON + +For example, consider the following query that could be modified by selecting the time field as Time and specifying Date Format as YYYY. + +#### Sample Query + +| Time | Mark | Value | +|------------|-------|-------| +| 2017-07-01 | above | 25 | +| 2018-08-02 | below | 22 | +| 2019-09-02 | below | 29 | +| 2020-10-04 | above | 22 | + +The result: + +#### Transformed Query + +| Time | Mark | Value | +|---------------------|-------|-------| +| 2017-01-01 00:00:00 | above | 25 | +| 2018-01-01 00:00:00 | below | 22 | +| 2019-01-01 00:00:00 | below | 29 | +| 2020-01-01 00:00:00 | above | 22 | + +This transformation allows you to flexibly adapt your data types, ensuring compatibility and consistency in your visualizations. `; }, }, @@ -232,46 +232,46 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Extract fields', getHelperDocs: function () { return ` - Use this transformation to select a source of data and extract content from it in different formats. This transformation has the following fields: +Use this transformation to select a source of data and extract content from it in different formats. This transformation has the following fields: - - **Source** - Select the field for the source of data. - - **Format** - Choose one of the following: - - **JSON** - Parse JSON content from the source. - - **Key+value pairs** - Parse content in the format 'a=b' or 'c:d' from the source. - - **Auto** - Discover fields automatically. - - **Replace All Fields** - (Optional) Select this option to hide all other fields and display only your calculated field in the visualization. - - **Keep Time** - (Optional) Available only if **Replace All Fields** is true. Keeps the time field in the output. +- **Source** - Select the field for the source of data. +- **Format** - Choose one of the following: + - **JSON** - Parse JSON content from the source. + - **Key+value pairs** - Parse content in the format 'a=b' or 'c:d' from the source. + - **Auto** - Discover fields automatically. +- **Replace All Fields** - (Optional) Select this option to hide all other fields and display only your calculated field in the visualization. +- **Keep Time** - (Optional) Available only if **Replace All Fields** is true. Keeps the time field in the output. - Consider the following dataset: +Consider the following dataset: - #### Dataset Example +#### Dataset Example - | Timestamp | json_data | - |-------------------|-----------| - | 1636678740000000000 | {"value": 1} | - | 1636678680000000000 | {"value": 5} | - | 1636678620000000000 | {"value": 12} | +| Timestamp | json_data | +|---------------------|---------------| +| 1636678740000000000 | {"value": 1} | +| 1636678680000000000 | {"value": 5} | +| 1636678620000000000 | {"value": 12} | - You could prepare the data to be used by a [Time series panel][] with this configuration: +You could prepare the data to be used by a [Time series panel][] with this configuration: - - Source: json_data - - Format: JSON - - Field: value - - Alias: my_value - - Replace all fields: true - - Keep time: true +- Source: json_data +- Format: JSON + - Field: value + - Alias: my_value +- Replace all fields: true +- Keep time: true - This will generate the following output: +This will generate the following output: - #### Transformed Data +#### Transformed Data - | Timestamp | my_value | - |-------------------|----------| - | 1636678740000000000 | 1 | - | 1636678680000000000 | 5 | - | 1636678620000000000 | 12 | +| Timestamp | my_value | +|---------------------|----------| +| 1636678740000000000 | 1 | +| 1636678680000000000 | 5 | +| 1636678620000000000 | 12 | - This transformation allows you to extract and format data in various ways. You can customize the extraction format based on your specific data needs. +This transformation allows you to extract and format data in various ways. You can customize the extraction format based on your specific data needs. `; }, links: [ @@ -285,45 +285,45 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Lookup fields from resource', getHelperDocs: function () { return ` - Use this transformation to enrich a field value by looking up additional fields from an external source. +Use this transformation to enrich a field value by looking up additional fields from an external source. - This transformation has the following fields: +This transformation has the following fields: - - **Field** - Select a text field from your dataset. - - **Lookup** - Choose from **Countries**, **USA States**, and **Airports**. +- **Field** - Select a text field from your dataset. +- **Lookup** - Choose from **Countries**, **USA States**, and **Airports**. - This transformation currently supports spatial data. +This transformation currently supports spatial data. - For example, if you have this data: +For example, if you have this data: - #### Dataset Example +#### Dataset Example - | Location | Values | - |-----------|--------| - | AL | 0 | - | AK | 10 | - | Arizona | 5 | - | Arkansas | 1 | - | Somewhere | 5 | +| Location | Values | +|-----------|--------| +| AL | 0 | +| AK | 10 | +| Arizona | 5 | +| Arkansas | 1 | +| Somewhere | 5 | - With this configuration: +With this configuration: - - Field: location - - Lookup: USA States +- Field: location +- Lookup: USA States - You'll get the following output: +You'll get the following output: - #### Transformed Data +#### Transformed Data - | Location | ID | Name | Lng | Lat | Values | - |-----------|----|-----------|------------|------------|--------| - | AL | AL | Alabama | -80.891064 | 12.448457 | 0 | - | AK | AK | Arkansas | -100.891064| 24.448457 | 10 | - | Arizona | | | | | 5 | - | Arkansas | | | | | 1 | - | Somewhere | | | | | 5 | +| Location | ID | Name | Lng | Lat | Values | +|-----------|----|----------|-------------|-----------|--------| +| AL | AL | Alabama | -80.891064 | 12.448457 | 0 | +| AK | AK | Arkansas | -100.891064 | 24.448457 | 10 | +| Arizona | | | | | 5 | +| Arkansas | | | | | 1 | +| Somewhere | | | | | 5 | - This transformation lets you augment your data by fetching additional information from external sources, providing a more comprehensive dataset for analysis and visualization. +This transformation lets you augment your data by fetching additional information from external sources, providing a more comprehensive dataset for analysis and visualization. `; }, }, @@ -331,19 +331,19 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Filter data by query refId', getHelperDocs: function (imageRenderType: ImageRenderType = ImageRenderType.ShortcodeFigure) { return ` - Use this transformation to hide one or more queries in panels that have multiple queries. +Use this transformation to hide one or more queries in panels that have multiple queries. - Grafana displays the query identification letters in dark gray text. Click a query identifier to toggle filtering. If the query letter is white, then the results are displayed. If the query letter is dark, then the results are hidden. +Grafana displays the query identification letters in dark gray text. Click a query identifier to toggle filtering. If the query letter is white, then the results are displayed. If the query letter is dark, then the results are hidden. - > **Note:** This transformation is not available for Graphite because this data source does not support correlating returned data with queries. +> **Note:** This transformation is not available for Graphite because this data source does not support correlating returned data with queries. - In the example below, the panel has three queries (A, B, C). We removed the B query from the visualization. +In the example below, the panel has three queries (A, B, C). We removed the B query from the visualization. - ${buildImageContent( - '/static/img/docs/transformations/filter-by-query-stat-example-7-0.png', - imageRenderType, - 'A stat visualization with results from two queries, A and C' - )} +${buildImageContent( + '/static/img/docs/transformations/filter-by-query-stat-example-7-0.png', + imageRenderType, + 'A stat visualization with results from two queries, A and C' +)} `; }, }, @@ -351,70 +351,70 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Filter data by values', getHelperDocs: function () { return ` - Use this transformation to selectively filter data points directly within your visualization. This transformation provides options to include or exclude data based on one or more conditions applied to a selected field. +Use this transformation to selectively filter data points directly within your visualization. This transformation provides options to include or exclude data based on one or more conditions applied to a selected field. - This transformation is very useful if your data source does not natively filter by values. You might also use this to narrow values to display if you are using a shared query. +This transformation is very useful if your data source does not natively filter by values. You might also use this to narrow values to display if you are using a shared query. - The available conditions for all fields are: +The available conditions for all fields are: - - **Regex** - Match a regex expression. - - **Is Null** - Match if the value is null. - - **Is Not Null** - Match if the value is not null. - - **Equal** - Match if the value is equal to the specified value. - - **Different** - Match if the value is different than the specified value. +- **Regex** - Match a regex expression. +- **Is Null** - Match if the value is null. +- **Is Not Null** - Match if the value is not null. +- **Equal** - Match if the value is equal to the specified value. +- **Different** - Match if the value is different than the specified value. - The available conditions for number fields are: +The available conditions for number fields are: - - **Greater** - Match if the value is greater than the specified value. - - **Lower** - Match if the value is lower than the specified value. - - **Greater or equal** - Match if the value is greater or equal. - - **Lower or equal** - Match if the value is lower or equal. - - **Range** - Match a range between a specified minimum and maximum, min and max included. +- **Greater** - Match if the value is greater than the specified value. +- **Lower** - Match if the value is lower than the specified value. +- **Greater or equal** - Match if the value is greater or equal. +- **Lower or equal** - Match if the value is lower or equal. +- **Range** - Match a range between a specified minimum and maximum, min and max included. - Consider the following dataset: +Consider the following dataset: - #### Dataset Example +#### Dataset Example - | Time | Temperature | Altitude | - |---------------------|-------------|----------| - | 2020-07-07 11:34:23 | 32 | 101 | - | 2020-07-07 11:34:22 | 28 | 125 | - | 2020-07-07 11:34:21 | 26 | 110 | - | 2020-07-07 11:34:20 | 23 | 98 | - | 2020-07-07 10:32:24 | 31 | 95 | - | 2020-07-07 10:31:22 | 20 | 85 | - | 2020-07-07 09:30:57 | 19 | 101 | +| Time | Temperature | Altitude | +|---------------------|-------------|----------| +| 2020-07-07 11:34:23 | 32 | 101 | +| 2020-07-07 11:34:22 | 28 | 125 | +| 2020-07-07 11:34:21 | 26 | 110 | +| 2020-07-07 11:34:20 | 23 | 98 | +| 2020-07-07 10:32:24 | 31 | 95 | +| 2020-07-07 10:31:22 | 20 | 85 | +| 2020-07-07 09:30:57 | 19 | 101 | - If you **Include** the data points that have a temperature below 30°C, the configuration will look as follows: +If you **Include** the data points that have a temperature below 30°C, the configuration will look as follows: - - Filter Type: 'Include' - - Condition: Rows where 'Temperature' matches 'Lower Than' '30' +- Filter Type: 'Include' +- Condition: Rows where 'Temperature' matches 'Lower Than' '30' - And you will get the following result, where only the temperatures below 30°C are included: +And you will get the following result, where only the temperatures below 30°C are included: - #### Transformed Data +#### Transformed Data - | Time | Temperature | Altitude | - |---------------------|-------------|----------| - | 2020-07-07 11:34:22 | 28 | 125 | - | 2020-07-07 11:34:21 | 26 | 110 | - | 2020-07-07 11:34:20 | 23 | 98 | - | 2020-07-07 10:31:22 | 20 | 85 | - | 2020-07-07 09:30:57 | 19 | 101 | +| Time | Temperature | Altitude | +|---------------------|-------------|----------| +| 2020-07-07 11:34:22 | 28 | 125 | +| 2020-07-07 11:34:21 | 26 | 110 | +| 2020-07-07 11:34:20 | 23 | 98 | +| 2020-07-07 10:31:22 | 20 | 85 | +| 2020-07-07 09:30:57 | 19 | 101 | - You can add more than one condition to the filter. For example, you might want to include the data only if the altitude is greater than 100. To do so, add that condition to the following configuration: +You can add more than one condition to the filter. For example, you might want to include the data only if the altitude is greater than 100. To do so, add that condition to the following configuration: - - Filter type: 'Include' rows that 'Match All' conditions - - Condition 1: Rows where 'Temperature' matches 'Lower' than '30' - - Condition 2: Rows where 'Altitude' matches 'Greater' than '100' +- Filter type: 'Include' rows that 'Match All' conditions +- Condition 1: Rows where 'Temperature' matches 'Lower' than '30' +- Condition 2: Rows where 'Altitude' matches 'Greater' than '100' - When you have more than one condition, you can choose if you want the action (include/exclude) to be applied on rows that **Match all** conditions or **Match any** of the conditions you added. +When you have more than one condition, you can choose if you want the action (include/exclude) to be applied on rows that **Match all** conditions or **Match any** of the conditions you added. - In the example above, we chose **Match all** because we wanted to include the rows that have a temperature lower than 30°C *AND* an altitude higher than 100. If we wanted to include the rows that have a temperature lower than 30°C *OR* an altitude higher than 100 instead, then we would select **Match any**. This would include the first row in the original data, which has a temperature of 32°C (does not match the first condition) but an altitude of 101 (which matches the second condition), so it is included. +In the example above, we chose **Match all** because we wanted to include the rows that have a temperature lower than 30°C *AND* an altitude higher than 100. If we wanted to include the rows that have a temperature lower than 30°C *OR* an altitude higher than 100 instead, then we would select **Match any**. This would include the first row in the original data, which has a temperature of 32°C (does not match the first condition) but an altitude of 101 (which matches the second condition), so it is included. - Conditions that are invalid or incompletely configured are ignored. +Conditions that are invalid or incompletely configured are ignored. - This versatile data filtering transformation lets you to selectively include or exclude data points based on specific conditions. Customize the criteria to tailor your data presentation to meet your unique analytical needs. +This versatile data filtering transformation lets you to selectively include or exclude data points based on specific conditions. Customize the criteria to tailor your data presentation to meet your unique analytical needs. `; }, }, @@ -422,63 +422,63 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Filter fields by name', getHelperDocs: function (imageRenderType: ImageRenderType = ImageRenderType.ShortcodeFigure) { return ` - Use this transformation to selectively remove parts of your query results. There are three ways to filter field names: - - - [Using a regular expression](#use-a-regular-expression) - - [Manually selecting included fields](#manually-select-included-fields) - - [Using a dashboard variable](#use-a-dashboard-variable) - - #### Use a regular expression - - When you filter using a regular expression, field names that match the regular expression are included. - - For example, from the input data: +Use this transformation to selectively remove parts of your query results. There are three ways to filter field names: - | Time | dev-eu-west | dev-eu-north | prod-eu-west | prod-eu-north | - | ------------------- | ----------- | ------------ | ------------ | ------------- | - | 2023-03-04 23:56:23 | 23.5 | 24.5 | 22.2 | 20.2 | - | 2023-03-04 23:56:23 | 23.6 | 24.4 | 22.1 | 20.1 | - - The result from using the regular expression 'prod.*' would be: - - | Time | prod-eu-west | prod-eu-north | - | ------------------- | ------------ | ------------- | - | 2023-03-04 23:56:23 | 22.2 | 20.2 | - | 2023-03-04 23:56:23 | 22.1 | 20.1 | - - The regular expression can include an interpolated dashboard variable by using the \${${'variableName'}} syntax. - - #### Manually select included fields - - Click and uncheck the field names to remove them from the result. Fields that are matched by the regular expression are still included, even if they're unchecked. - - #### Use a dashboard variable - - Enable 'From variable' to let you select a dashboard variable that's used to include fields. By setting up a [dashboard variable][] with multiple choices, the same fields can be displayed across multiple visualizations. +- [Using a regular expression](#use-a-regular-expression) +- [Manually selecting included fields](#manually-select-included-fields) +- [Using a dashboard variable](#use-a-dashboard-variable) - ${buildImageContent( - '/static/img/docs/transformations/filter-name-table-before-7-0.png', - imageRenderType, - 'A table visualization with time, value, Min, and Max columns' - )} +#### Use a regular expression - Here's the table after we applied the transformation to remove the Min field. +When you filter using a regular expression, field names that match the regular expression are included. - ${buildImageContent( - '/static/img/docs/transformations/filter-name-table-after-7-0.png', - imageRenderType, - 'A table visualization with time, value, and Max columns' - )} +For example, from the input data: - Here is the same query using a Stat visualization. +| Time | dev-eu-west | dev-eu-north | prod-eu-west | prod-eu-north | +| ------------------- | ----------- | ------------ | ------------ | ------------- | +| 2023-03-04 23:56:23 | 23.5 | 24.5 | 22.2 | 20.2 | +| 2023-03-04 23:56:23 | 23.6 | 24.4 | 22.1 | 20.1 | - ${buildImageContent( - '/static/img/docs/transformations/filter-name-stat-after-7-0.png', - imageRenderType, - 'A stat visualization with value and Max fields' - )} +The result from using the regular expression 'prod.*' would be: + +| Time | prod-eu-west | prod-eu-north | +| ------------------- | ------------ | ------------- | +| 2023-03-04 23:56:23 | 22.2 | 20.2 | +| 2023-03-04 23:56:23 | 22.1 | 20.1 | + +The regular expression can include an interpolated dashboard variable by using the \${${'variableName'}} syntax. + +#### Manually select included fields - This transformation provides flexibility in tailoring your query results to focus on the specific fields you need for effective analysis and visualization. +Click and uncheck the field names to remove them from the result. Fields that are matched by the regular expression are still included, even if they're unchecked. + +#### Use a dashboard variable + +Enable 'From variable' to let you select a dashboard variable that's used to include fields. By setting up a [dashboard variable][] with multiple choices, the same fields can be displayed across multiple visualizations. + +${buildImageContent( + '/static/img/docs/transformations/filter-name-table-before-7-0.png', + imageRenderType, + 'A table visualization with time, value, Min, and Max columns' +)} + +Here's the table after we applied the transformation to remove the Min field. + +${buildImageContent( + '/static/img/docs/transformations/filter-name-table-after-7-0.png', + imageRenderType, + 'A table visualization with time, value, and Max columns' +)} + +Here is the same query using a Stat visualization. + +${buildImageContent( + '/static/img/docs/transformations/filter-name-stat-after-7-0.png', + imageRenderType, + 'A stat visualization with value and Max fields' +)} + +This transformation provides flexibility in tailoring your query results to focus on the specific fields you need for effective analysis and visualization. `; }, }, @@ -486,49 +486,49 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Format string', getHelperDocs: function () { return ` - Use this transformation to customize the output of a string field. This transformation has the following fields: - - - **Upper case** - Formats the entire string in uppercase characters. - - **Lower case** - Formats the entire string in lowercase characters. - - **Sentence case** - Formats the first character of the string in uppercase. - - **Title case** - Formats the first character of each word in the string in uppercase. - - **Pascal case** - Formats the first character of each word in the string in uppercase and doesn't include spaces between words. - - **Camel case** - Formats the first character of each word in the string in uppercase, except the first word, and doesn't include spaces between words. - - **Snake case** - Formats all characters in the string in lowercase and uses underscores instead of spaces between words. - - **Kebab case** - Formats all characters in the string in lowercase and uses dashes instead of spaces between words. - - **Trim** - Removes all leading and trailing spaces from the string. - - **Substring** - Returns a substring of the string, using the specified start and end positions. - - This transformation provides a convenient way to standardize and tailor the presentation of string data for better visualization and analysis. - - > **Note:** This transformation is currently in private preview. Grafana Labs offers support on a best-effort basis, and breaking changes might occur prior to the feature being made generally available.`; +Use this transformation to customize the output of a string field. This transformation has the following fields: + +- **Upper case** - Formats the entire string in uppercase characters. +- **Lower case** - Formats the entire string in lowercase characters. +- **Sentence case** - Formats the first character of the string in uppercase. +- **Title case** - Formats the first character of each word in the string in uppercase. +- **Pascal case** - Formats the first character of each word in the string in uppercase and doesn't include spaces between words. +- **Camel case** - Formats the first character of each word in the string in uppercase, except the first word, and doesn't include spaces between words. +- **Snake case** - Formats all characters in the string in lowercase and uses underscores instead of spaces between words. +- **Kebab case** - Formats all characters in the string in lowercase and uses dashes instead of spaces between words. +- **Trim** - Removes all leading and trailing spaces from the string. +- **Substring** - Returns a substring of the string, using the specified start and end positions. + +This transformation provides a convenient way to standardize and tailor the presentation of string data for better visualization and analysis. + +> **Note:** This transformation is currently in public preview. Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. Enable the \`formatString\` feature toggle in Grafana to use this feature. Contact Grafana Support to enable this feature in Grafana Cloud.`; }, }, formatTime: { name: 'Format time', getHelperDocs: function () { return ` - Use this transformation to customize the output of a time field. Output can be formatted using [Moment.js format strings](https://momentjs.com/docs/#/displaying/). For example, if you want to display only the year of a time field, the format string 'YYYY' can be used to show the calendar year (for example, 1999 or 2012). +Use this transformation to customize the output of a time field. Output can be formatted using [Moment.js format strings](https://momentjs.com/docs/#/displaying/). For example, if you want to display only the year of a time field, the format string 'YYYY' can be used to show the calendar year (for example, 1999 or 2012). - **Before Transformation:** +**Before Transformation:** - | Timestamp | Event | - | ------------------- | -------------- | - | 1636678740000000000 | System Start | - | 1636678680000000000 | User Login | - | 1636678620000000000 | Data Updated | +| Timestamp | Event | +| ------------------- | ------------ | +| 1636678740000000000 | System Start | +| 1636678680000000000 | User Login | +| 1636678620000000000 | Data Updated | - **After applying 'YYYY-MM-DD HH:mm:ss':** +**After applying 'YYYY-MM-DD HH:mm:ss':** - | Timestamp | Event | - | ------------------- | -------------- | - | 2021-11-12 14:25:40 | System Start | - | 2021-11-12 14:24:40 | User Login | - | 2021-11-12 14:23:40 | Data Updated | +| Timestamp | Event | +| ------------------- | ------------ | +| 2021-11-12 14:25:40 | System Start | +| 2021-11-12 14:24:40 | User Login | +| 2021-11-12 14:23:40 | Data Updated | - This transformation lets you tailor the time representation in your visualizations, providing flexibility and precision in displaying temporal data. +This transformation lets you tailor the time representation in your visualizations, providing flexibility and precision in displaying temporal data. - > **Note:** This transformation is available in Grafana 10.1+ as an alpha feature. +> **Note:** This transformation is available in Grafana 10.1+ as an alpha feature. `; }, }, @@ -536,9 +536,123 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Group by', getHelperDocs: function () { return ` - Use this transformation to group the data by a specified field (column) value and process calculations on each group. Click to see a list of calculation choices. For information about available calculations, refer to [Calculation types][]. +Use this transformation to group the data by a specified field (column) value and process calculations on each group. Click to see a list of calculation choices. For information about available calculations, refer to [Calculation types][]. + +Here's an example of original data. + +| Time | Server ID | CPU Temperature | Server Status | +| ------------------- | --------- | --------------- | ------------- | +| 2020-07-07 11:34:20 | server 1 | 80 | Shutdown | +| 2020-07-07 11:34:20 | server 3 | 62 | OK | +| 2020-07-07 10:32:20 | server 2 | 90 | Overload | +| 2020-07-07 10:31:22 | server 3 | 55 | OK | +| 2020-07-07 09:30:57 | server 3 | 62 | Rebooting | +| 2020-07-07 09:30:05 | server 2 | 88 | OK | +| 2020-07-07 09:28:06 | server 1 | 80 | OK | +| 2020-07-07 09:25:05 | server 2 | 88 | OK | +| 2020-07-07 09:23:07 | server 1 | 86 | OK | + +This transformation goes in two steps. First you specify one or multiple fields to group the data by. This will group all the same values of those fields together, as if you sorted them. For instance if we group by the Server ID field, then it would group the data this way: + +| Time | Server ID | CPU Temperature | Server Status | +| ------------------- | -------------- | --------------- | ------------- | +| 2020-07-07 11:34:20 | **server 1** | 80 | Shutdown | +| 2020-07-07 09:28:06 | **server 1** | 80 | OK | +| 2020-07-07 09:23:07 | **server 1** | 86 | OK | +| 2020-07-07 10:32:20 | server 2 | 90 | Overload | +| 2020-07-07 09:30:05 | server 2 | 88 | OK | +| 2020-07-07 09:25:05 | server 2 | 88 | OK | +| 2020-07-07 11:34:20 | **_server 3_** | 62 | OK | +| 2020-07-07 10:31:22 | **_server 3_** | 55 | OK | +| 2020-07-07 09:30:57 | **_server 3_** | 62 | Rebooting | + +All rows with the same value of Server ID are grouped together. + +After choosing which field you want to group your data by, you can add various calculations on the other fields, and apply the calculation to each group of rows. For instance, we could want to calculate the average CPU temperature for each of those servers. So we can add the _mean_ calculation applied on the CPU Temperature field to get the following: + +| Server ID | CPU Temperature (mean) | +| --------- | ---------------------- | +| server 1 | 82 | +| server 2 | 88.6 | +| server 3 | 59.6 | + +And we can add more than one calculation. For instance: + +- For field Time, we can calculate the _Last_ value, to know when the last data point was received for each server +- For field Server Status, we can calculate the _Last_ value to know what is the last state value for each server +- For field Temperature, we can also calculate the _Last_ value to know what is the latest monitored temperature for each server + +We would then get: + +| Server ID | CPU Temperature (mean) | CPU Temperature (last) | Time (last) | Server Status (last) | +| --------- | ---------------------- | ---------------------- | ------------------- | -------------------- | +| server 1 | 82 | 80 | 2020-07-07 11:34:20 | Shutdown | +| server 2 | 88.6 | 90 | 2020-07-07 10:32:20 | Overload | +| server 3 | 59.6 | 62 | 2020-07-07 11:34:20 | OK | + +This transformation allows you to extract essential information from your time series and present it conveniently. + `; + }, + links: [ + { + title: 'Calculation types', + url: 'https://grafana.com/docs/grafana/latest/panels-visualizations/calculation-types/', + }, + ], + }, + groupingToMatrix: { + name: 'Grouping to matrix', + getHelperDocs: function () { + return ` +Use this transformation to combine three fields—which are used as input for the **Column**, **Row**, and **Cell value** fields from the query output—and generate a matrix. The matrix is calculated as follows: + +**Original data** + +| Server ID | CPU Temperature | Server Status | +| --------- | --------------- | ------------- | +| server 1 | 82 | OK | +| server 2 | 88.6 | OK | +| server 3 | 59.6 | Shutdown | + +We can generate a matrix using the values of 'Server Status' as column names, the 'Server ID' values as row names, and the 'CPU Temperature' as content of each cell. The content of each cell will appear for the existing column ('Server Status') and row combination ('Server ID'). For the rest of the cells, you can select which value to display between: **Null**, **True**, **False**, or **Empty**. + +**Output** + +| Server ID\Server Status | OK | Shutdown | +| ----------------------- | ---- | -------- | +| server 1 | 82 | | +| server 2 | 88.6 | | +| server 3 | | 59.6 | + +Use this transformation to construct a matrix by specifying fields from your query results. The matrix output reflects the relationships between the unique values in these fields. This helps you present complex relationships in a clear and structured matrix format. + `; + }, + }, + groupToNestedTable: { + name: 'Group to nested table', + getHelperDocs: function (imageRenderType: ImageRenderType = ImageRenderType.ShortcodeFigure) { + return ` + Use this transformation to group the data by a specified field (column) value and process calculations on each group. Records are generated that share the same grouped field value, to be displayed in a nested table. + + To calculate a statistic for a field, click the selection box next to it and select the **Calculate** option: - Here's an example of original data. + ${buildImageContent( + '/static/img/docs/transformations/nested-table-select-calculation.png', + imageRenderType, + 'A select box showing the Group and Calculate options for the transformation.' + )} + + Once **Calculate** has been selected, another selection box will appear next to the respective field which will allow statistics to be selected: + + ${buildImageContent( + '/static/img/docs/transformations/nested-table-select-stat.png', + imageRenderType, + 'A select box showing available statistic calculations once the calculate option for the field has been selected.' + )} + + For information about available calculations, refer to [Calculation types][]. + + Here's an example of original data: | Time | Server ID | CPU Temperature | Server Status | | ------------------- | --------- | --------------- | ------------- | @@ -552,46 +666,22 @@ export const transformationDocsContent: TransformationDocsContentType = { | 2020-07-07 09:25:05 | server 2 | 88 | OK | | 2020-07-07 09:23:07 | server 1 | 86 | OK | - This transformation goes in two steps. First you specify one or multiple fields to group the data by. This will group all the same values of those fields together, as if you sorted them. For instance if we group by the Server ID field, then it would group the data this way: - - | Time | Server ID | CPU Temperature | Server Status | - | ------------------- | -------------- | --------------- | ------------- | - | 2020-07-07 11:34:20 | **server 1** | 80 | Shutdown | - | 2020-07-07 09:28:06 | **server 1** | 80 | OK | - | 2020-07-07 09:23:07 | **server 1** | 86 | OK | - | 2020-07-07 10:32:20 | server 2 | 90 | Overload | - | 2020-07-07 09:30:05 | server 2 | 88 | OK | - | 2020-07-07 09:25:05 | server 2 | 88 | OK | - | 2020-07-07 11:34:20 | **_server 3_** | 62 | OK | - | 2020-07-07 10:31:22 | **_server 3_** | 55 | OK | - | 2020-07-07 09:30:57 | **_server 3_** | 62 | Rebooting | - - All rows with the same value of Server ID are grouped together. + This transformation has two steps. First, specify one or more fields by which to group the data. This groups all the same values of those fields together, as if you sorted them. For instance, if you group by the Server ID field, Grafana groups the data this way: - After choosing which field you want to group your data by, you can add various calculations on the other fields, and apply the calculation to each group of rows. For instance, we could want to calculate the average CPU temperature for each of those servers. So we can add the _mean_ calculation applied on the CPU Temperature field to get the following: + | Server ID | | + | -------------- | ------------- | + | server 1 | <table><th><tr><td>Time</td><td>CPU Temperature</td><td>Server Status</td></tr></th><tbody><tr><td>2020-07-07 11:34:20</td><td>80</td><td>Shutdown</td></tr><tr><td>2020-07-07 09:28:06</td><td>80</td><td>OK</td></tr><tr><td>2020-07-07 09:23:07</td><td>86</td><td>OK</td></tr></tbody></table> | + | server 2 | <table><th><tr><td>Time</td><td>CPU Temperature</td><td>Server Status</td></tr></th><tbody><tr><td>2020-07-07 10:32:20</td><td>90</td><td>Overload</td></tr><tr><td>2020-07-07 09:30:05</td><td>88</td><td>OK</td></tr><tr><td>2020-07-07 09:25:05</td><td>88</td><td>OK</td></tr></tbody></table> | + | server 3 | <table><th><tr><td>Time</td><td>CPU Temperature</td><td>Server Status</td></tr></th><tbody><tr><td>2020-07-07 11:34:20</td><td>62</td><td>OK</td></tr><tr><td>2020-07-07 10:31:22</td><td>55</td><td>OK</td></tr><tr><td>2020-07-07 09:30:57</td><td>62</td><td>Rebooting</td></tr></tbody></table> | - | Server ID | CPU Temperature (mean) | - | --------- | ---------------------- | - | server 1 | 82 | - | server 2 | 88.6 | - | server 3 | 59.6 | + After choosing the field by which you want to group your data, you can add various calculations on the other fields and apply the calculation to each group of rows. For instance, you might want to calculate the average CPU temperature for each of those servers. To do so, add the **mean calculation** applied on the CPU Temperature field to get the following result: - And we can add more than one calculation. For instance: - - - For field Time, we can calculate the _Last_ value, to know when the last data point was received for each server - - For field Server Status, we can calculate the _Last_ value to know what is the last state value for each server - - For field Temperature, we can also calculate the _Last_ value to know what is the latest monitored temperature for each server - - We would then get: - - | Server ID | CPU Temperature (mean) | CPU Temperature (last) | Time (last) | Server Status (last) | - | --------- | ---------------------- | ---------------------- | ------------------- | -------------------- | - | server 1 | 82 | 80 | 2020-07-07 11:34:20 | Shutdown | - | server 2 | 88.6 | 90 | 2020-07-07 10:32:20 | Overload | - | server 3 | 59.6 | 62 | 2020-07-07 11:34:20 | OK | - - This transformation allows you to extract essential information from your time series and present it conveniently. - `; + | Server ID | CPU Temperatute (mean) | | + | -------------- | ------------- | ------------- | + | server 1 | 82 | <table><th><tr><td>Time</td><td>Server Status</td></tr></th><tbody><tr><td>2020-07-07 11:34:20</td><td>Shutdown</td></tr><tr><td>2020-07-07 09:28:06</td><td>OK</td></tr><tr><td>2020-07-07 09:23:07</td><td>OK</td></tr></tbody></table> | + | server 2 | 88.6 | <table><th><tr><td>Time</td><td>Server Status</td></tr></th><tbody><tr><td>2020-07-07 10:32:20</td><td>Overload</td></tr><tr><td>2020-07-07 09:30:05</td><td>OK</td></tr><tr><td>2020-07-07 09:25:05</td><td>OK</td></tr></tbody></table> | + | server 3 | 59.6 | <table><th><tr><td>Time</td><td>Server Status</td></tr></th><tbody><tr><td>2020-07-07 11:34:20</td><td>OK</td></tr><tr><td>2020-07-07 10:31:22</td><td>OK</td></tr><tr><td>2020-07-07 09:30:57</td><td>Rebooting</td></tr></tbody></table> | + `; }, links: [ { @@ -600,66 +690,38 @@ export const transformationDocsContent: TransformationDocsContentType = { }, ], }, - groupingToMatrix: { - name: 'Grouping to matrix', - getHelperDocs: function () { - return ` - Use this transformation to combine three fields—which are used as input for the **Column**, **Row**, and **Cell value** fields from the query output—and generate a matrix. The matrix is calculated as follows: - - **Original data** - - | Server ID | CPU Temperature | Server Status | - | --------- | --------------- | ------------- | - | server 1 | 82 | OK | - | server 2 | 88.6 | OK | - | server 3 | 59.6 | Shutdown | - - We can generate a matrix using the values of 'Server Status' as column names, the 'Server ID' values as row names, and the 'CPU Temperature' as content of each cell. The content of each cell will appear for the existing column ('Server Status') and row combination ('Server ID'). For the rest of the cells, you can select which value to display between: **Null**, **True**, **False**, or **Empty**. - - **Output** - - | Server ID\Server Status | OK | Shutdown | - | ----------------------- | ---- | -------- | - | server 1 | 82 | | - | server 2 | 88.6 | | - | server 3 | | 59.6 | - - Use this transformation to construct a matrix by specifying fields from your query results. The matrix output reflects the relationships between the unique values in these fields. This helps you present complex relationships in a clear and structured matrix format. - `; - }, - }, heatmap: { name: 'Create heatmap', getHelperDocs: function () { return ` - Use this transformation to prepare histogram data for visualizing trends over time. Similar to the heatmap visualization, this transformation converts histogram metrics into temporal buckets. +Use this transformation to prepare histogram data for visualizing trends over time. Similar to the heatmap visualization, this transformation converts histogram metrics into temporal buckets. - #### X Bucket +#### X Bucket - This setting determines how the x-axis is split into buckets. +This setting determines how the x-axis is split into buckets. - - **Size** - Specify a time interval in the input field. For example, a time range of '1h' creates cells one hour wide on the x-axis. - - **Count** - For non-time-related series, use this option to define the number of elements in a bucket. +- **Size** - Specify a time interval in the input field. For example, a time range of '1h' creates cells one hour wide on the x-axis. +- **Count** - For non-time-related series, use this option to define the number of elements in a bucket. - #### Y Bucket +#### Y Bucket - This setting determines how the y-axis is split into buckets. +This setting determines how the y-axis is split into buckets. - - **Linear** - - **Logarithmic** - Choose between log base 2 or log base 10. - - **Symlog** - Uses a symmetrical logarithmic scale. Choose between log base 2 or log base 10, allowing for negative values. +- **Linear** +- **Logarithmic** - Choose between log base 2 or log base 10. +- **Symlog** - Uses a symmetrical logarithmic scale. Choose between log base 2 or log base 10, allowing for negative values. - Assume you have the following dataset: +Assume you have the following dataset: - | Timestamp | Value | - |-------------------- |-------| - | 2023-01-01 12:00:00 | 5 | - | 2023-01-01 12:15:00 | 10 | - | 2023-01-01 12:30:00 | 15 | - | 2023-01-01 12:45:00 | 8 | +| Timestamp | Value | +|-------------------- |-------| +| 2023-01-01 12:00:00 | 5 | +| 2023-01-01 12:15:00 | 10 | +| 2023-01-01 12:30:00 | 15 | +| 2023-01-01 12:45:00 | 8 | - - With X Bucket set to 'Size: 15m' and Y Bucket as 'Linear', the histogram organizes values into time intervals of 15 minutes on the x-axis and linearly on the y-axis. - - For X Bucket as 'Count: 2' and Y Bucket as 'Logarithmic (base 10)', the histogram groups values into buckets of two on the x-axis and use a logarithmic scale on the y-axis. +- With X Bucket set to 'Size: 15m' and Y Bucket as 'Linear', the histogram organizes values into time intervals of 15 minutes on the x-axis and linearly on the y-axis. +- For X Bucket as 'Count: 2' and Y Bucket as 'Logarithmic (base 10)', the histogram groups values into buckets of two on the x-axis and use a logarithmic scale on the y-axis. `; }, }, @@ -667,49 +729,49 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Histogram', getHelperDocs: function () { return ` - Use this transformation to generate a histogram based on input data, allowing you to visualize the distribution of values. - - - **Bucket size** - The range between the lowest and highest items in a bucket (xMin to xMax). - - **Bucket offset** - The offset for non-zero-based buckets. - - **Combine series** - Create a unified histogram using all available series. - - **Original data** - - Series 1: - - | A | B | C | - | --- | --- | --- | - | 1 | 3 | 5 | - | 2 | 4 | 6 | - | 3 | 5 | 7 | - | 4 | 6 | 8 | - | 5 | 7 | 9 | - - Series 2: - - | C | - | --- | - | 5 | - | 6 | - | 7 | - | 8 | - | 9 | - - **Output** - - | xMin | xMax | A | B | C | C | - | ---- | ---- | --- | --- | --- | --- | - | 1 | 2 | 1 | 0 | 0 | 0 | - | 2 | 3 | 1 | 0 | 0 | 0 | - | 3 | 4 | 1 | 1 | 0 | 0 | - | 4 | 5 | 1 | 1 | 0 | 0 | - | 5 | 6 | 1 | 1 | 1 | 1 | - | 6 | 7 | 0 | 1 | 1 | 1 | - | 7 | 8 | 0 | 1 | 1 | 1 | - | 8 | 9 | 0 | 0 | 1 | 1 | - | 9 | 10 | 0 | 0 | 1 | 1 | - - Visualize the distribution of values using the generated histogram, providing insights into the data's spread and density. +Use this transformation to generate a histogram based on input data, allowing you to visualize the distribution of values. + +- **Bucket size** - The range between the lowest and highest items in a bucket (xMin to xMax). +- **Bucket offset** - The offset for non-zero-based buckets. +- **Combine series** - Create a unified histogram using all available series. + +**Original data** + +Series 1: + +| A | B | C | +| - | - | - | +| 1 | 3 | 5 | +| 2 | 4 | 6 | +| 3 | 5 | 7 | +| 4 | 6 | 8 | +| 5 | 7 | 9 | + +Series 2: + +| C | +| - | +| 5 | +| 6 | +| 7 | +| 8 | +| 9 | + +**Output** + +| xMin | xMax | A | B | C | C | +| ---- | ---- | --| --| --| --| +| 1 | 2 | 1 | 0 | 0 | 0 | +| 2 | 3 | 1 | 0 | 0 | 0 | +| 3 | 4 | 1 | 1 | 0 | 0 | +| 4 | 5 | 1 | 1 | 0 | 0 | +| 5 | 6 | 1 | 1 | 1 | 1 | +| 6 | 7 | 0 | 1 | 1 | 1 | +| 7 | 8 | 0 | 1 | 1 | 1 | +| 8 | 9 | 0 | 0 | 1 | 1 | +| 9 | 10 | 0 | 0 | 1 | 1 | + +Visualize the distribution of values using the generated histogram, providing insights into the data's spread and density. `; }, }, @@ -717,89 +779,89 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Join by field', getHelperDocs: function (imageRenderType: ImageRenderType = ImageRenderType.ShortcodeFigure) { return ` - Use this transformation to merge multiple results into a single table, enabling the consolidation of data from different queries. +Use this transformation to merge multiple results into a single table, enabling the consolidation of data from different queries. - This is especially useful for converting multiple time series results into a single wide table with a shared time field. +This is especially useful for converting multiple time series results into a single wide table with a shared time field. - #### Inner join +#### Inner join - An inner join merges data from multiple tables where all tables share the same value from the selected field. This type of join excludes data where values do not match in every result. +An inner join merges data from multiple tables where all tables share the same value from the selected field. This type of join excludes data where values do not match in every result. - Use this transformation to combine the results from multiple queries (combining on a passed join field or the first time column) into one result, and drop rows where a successful join cannot occur. +Use this transformation to combine the results from multiple queries (combining on a passed join field or the first time column) into one result, and drop rows where a successful join cannot occur. - In the following example, two queries return table data. It is visualized as two separate tables before applying the inner join transformation. +In the following example, two queries return table data. It is visualized as two separate tables before applying the inner join transformation. - **Query A:** +**Query A:** - | Time | Job | Uptime | - | ------------------- | ------- | --------- | - | 2020-07-07 11:34:20 | node | 25260122 | - | 2020-07-07 11:24:20 | postgre | 123001233 | - | 2020-07-07 11:14:20 | postgre | 345001233 | +| Time | Job | Uptime | +| ------------------- | ------- | --------- | +| 2020-07-07 11:34:20 | node | 25260122 | +| 2020-07-07 11:24:20 | postgre | 123001233 | +| 2020-07-07 11:14:20 | postgre | 345001233 | - **Query B:** +**Query B:** - | Time | Server | Errors | - | ------------------- | -------- | ------ | - | 2020-07-07 11:34:20 | server 1 | 15 | - | 2020-07-07 11:24:20 | server 2 | 5 | - | 2020-07-07 11:04:20 | server 3 | 10 | +| Time | Server | Errors | +| ------------------- | -------- | ------ | +| 2020-07-07 11:34:20 | server 1 | 15 | +| 2020-07-07 11:24:20 | server 2 | 5 | +| 2020-07-07 11:04:20 | server 3 | 10 | - The result after applying the inner join transformation looks like the following: +The result after applying the inner join transformation looks like the following: - | Time | Job | Uptime | Server | Errors | - | ------------------- | ------- | --------- | -------- | ------ | - | 2020-07-07 11:34:20 | node | 25260122 | server 1 | 15 | - | 2020-07-07 11:24:20 | postgre | 123001233 | server 2 | 5 | +| Time | Job | Uptime | Server | Errors | +| ------------------- | ------- | --------- | -------- | ------ | +| 2020-07-07 11:34:20 | node | 25260122 | server 1 | 15 | +| 2020-07-07 11:24:20 | postgre | 123001233 | server 2 | 5 | - #### Outer join +#### Outer join - An outer join includes all data from an inner join and rows where values do not match in every input. While the inner join joins Query A and Query B on the time field, the outer join includes all rows that don't match on the time field. +An outer join includes all data from an inner join and rows where values do not match in every input. While the inner join joins Query A and Query B on the time field, the outer join includes all rows that don't match on the time field. - In the following example, two queries return table data. It is visualized as two tables before applying the outer join transformation. +In the following example, two queries return table data. It is visualized as two tables before applying the outer join transformation. - **Query A:** +**Query A:** - | Time | Job | Uptime | - | ------------------- | ------- | --------- | - | 2020-07-07 11:34:20 | node | 25260122 | - | 2020-07-07 11:24:20 | postgre | 123001233 | - | 2020-07-07 11:14:20 | postgre | 345001233 | +| Time | Job | Uptime | +| ------------------- | ------- | --------- | +| 2020-07-07 11:34:20 | node | 25260122 | +| 2020-07-07 11:24:20 | postgre | 123001233 | +| 2020-07-07 11:14:20 | postgre | 345001233 | - **Query B:** +**Query B:** - | Time | Server | Errors | - | ------------------- | -------- | ------ | - | 2020-07-07 11:34:20 | server 1 | 15 | - | 2020-07-07 11:24:20 | server 2 | 5 | - | 2020-07-07 11:04:20 | server 3 | 10 | +| Time | Server | Errors | +| ------------------- | -------- | ------ | +| 2020-07-07 11:34:20 | server 1 | 15 | +| 2020-07-07 11:24:20 | server 2 | 5 | +| 2020-07-07 11:04:20 | server 3 | 10 | - The result after applying the outer join transformation looks like the following: +The result after applying the outer join transformation looks like the following: - | Time | Job | Uptime | Server | Errors | - | ------------------- | ------- | --------- | -------- | ------ | - | 2020-07-07 11:04:20 | | | server 3 | 10 | - | 2020-07-07 11:14:20 | postgre | 345001233 | | | - | 2020-07-07 11:34:20 | node | 25260122 | server 1 | 15 | - | 2020-07-07 11:24:20 | postgre | 123001233 | server 2 | 5 | +| Time | Job | Uptime | Server | Errors | +| ------------------- | ------- | --------- | -------- | ------ | +| 2020-07-07 11:04:20 | | | server 3 | 10 | +| 2020-07-07 11:14:20 | postgre | 345001233 | | | +| 2020-07-07 11:34:20 | node | 25260122 | server 1 | 15 | +| 2020-07-07 11:24:20 | postgre | 123001233 | server 2 | 5 | - In the following example, a template query displays time series data from multiple servers in a table visualization. The results of only one query can be viewed at a time. +In the following example, a template query displays time series data from multiple servers in a table visualization. The results of only one query can be viewed at a time. - ${buildImageContent( - '/static/img/docs/transformations/join-fields-before-7-0.png', - imageRenderType, - 'A table visualization showing results for one server' - )} +${buildImageContent( + '/static/img/docs/transformations/join-fields-before-7-0.png', + imageRenderType, + 'A table visualization showing results for one server' +)} - I applied a transformation to join the query results using the time field. Now I can run calculations, combine, and organize the results in this new table. +I applied a transformation to join the query results using the time field. Now I can run calculations, combine, and organize the results in this new table. - ${buildImageContent( - '/static/img/docs/transformations/join-fields-after-7-0.png', - imageRenderType, - 'A table visualization showing results for multiple servers' - )} +${buildImageContent( + '/static/img/docs/transformations/join-fields-after-7-0.png', + imageRenderType, + 'A table visualization showing results for multiple servers' +)} - Combine and analyze data from various queries with table joining for a comprehensive view of your information. +Combine and analyze data from various queries with table joining for a comprehensive view of your information. `; }, }, @@ -807,52 +869,52 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Join by labels', getHelperDocs: function () { return ` - Use this transformation to join multiple results into a single table. - - This is especially useful for converting multiple time series results into a single wide table with a shared **Label** field. +Use this transformation to join multiple results into a single table. + +This is especially useful for converting multiple time series results into a single wide table with a shared **Label** field. - - **Join** - Select the label to join by between the labels available or common across all time series. - - **Value** - The name for the output result. +- **Join** - Select the label to join by between the labels available or common across all time series. +- **Value** - The name for the output result. - #### Example +#### Example - ##### Input +##### Input - series1{what="Temp", cluster="A", job="J1"} +series1{what="Temp", cluster="A", job="J1"} - | Time | Value | - | ---- | ----- | - | 1 | 10 | - | 2 | 200 | +| Time | Value | +| ---- | ----- | +| 1 | 10 | +| 2 | 200 | - series2{what="Temp", cluster="B", job="J1"} +series2{what="Temp", cluster="B", job="J1"} - | Time | Value | - | ---- | ----- | - | 1 | 10 | - | 2 | 200 | +| Time | Value | +| ---- | ----- | +| 1 | 10 | +| 2 | 200 | - series3{what="Speed", cluster="B", job="J1"} +series3{what="Speed", cluster="B", job="J1"} - | Time | Value | - | ---- | ----- | - | 22 | 22 | - | 28 | 77 | +| Time | Value | +| ---- | ----- | +| 22 | 22 | +| 28 | 77 | - ##### Config +##### Config - value: "what" +value: "what" - ##### Output +##### Output - | cluster | job | Temp | Speed | - | ------- | --- | ---- | ----- | - | A | J1 | 10 | | - | A | J1 | 200 | | - | B | J1 | 10 | 22 | - | B | J1 | 200 | 77 | +| cluster | job | Temp | Speed | +| ------- | --- | ---- | ----- | +| A | J1 | 10 | | +| A | J1 | 200 | | +| B | J1 | 10 | 22 | +| B | J1 | 200 | 77 | - Combine and organize time series data effectively with this transformation for comprehensive insights. +Combine and organize time series data effectively with this transformation for comprehensive insights. `; }, }, @@ -860,67 +922,67 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Labels to fields', getHelperDocs: function () { return ` - Use this transformation to convert time series results with labels or tags into a table, including each label's keys and values in the result. Display labels as either columns or row values for enhanced data visualization. +Use this transformation to convert time series results with labels or tags into a table, including each label's keys and values in the result. Display labels as either columns or row values for enhanced data visualization. - Given a query result of two time series: +Given a query result of two time series: - - Series 1: labels Server=Server A, Datacenter=EU - - Series 2: labels Server=Server B, Datacenter=EU +- Series 1: labels Server=Server A, Datacenter=EU +- Series 2: labels Server=Server B, Datacenter=EU - In "Columns" mode, the result looks like this: +In "Columns" mode, the result looks like this: - | Time | Server | Datacenter | Value | - | ------------------- | -------- | ---------- | ----- | - | 2020-07-07 11:34:20 | Server A | EU | 1 | - | 2020-07-07 11:34:20 | Server B | EU | 2 | +| Time | Server | Datacenter | Value | +| ------------------- | -------- | ---------- | ----- | +| 2020-07-07 11:34:20 | Server A | EU | 1 | +| 2020-07-07 11:34:20 | Server B | EU | 2 | - In "Rows" mode, the result has a table for each series and show each label value like this: +In "Rows" mode, the result has a table for each series and show each label value like this: - | label | value | - | ---------- | -------- | - | Server | Server A | - | Datacenter | EU | +| label | value | +| ---------- | -------- | +| Server | Server A | +| Datacenter | EU | - | label | value | - | ---------- | -------- | - | Server | Server B | - | Datacenter | EU | +| label | value | +| ---------- | -------- | +| Server | Server B | +| Datacenter | EU | - #### Value field name +#### Value field name - If you selected Server as the **Value field name**, then you would get one field for every value of the Server label. +If you selected Server as the **Value field name**, then you would get one field for every value of the Server label. - | Time | Datacenter | Server A | Server B | - | ------------------- | ---------- | -------- | -------- | - | 2020-07-07 11:34:20 | EU | 1 | 2 | +| Time | Datacenter | Server A | Server B | +| ------------------- | ---------- | -------- | -------- | +| 2020-07-07 11:34:20 | EU | 1 | 2 | - #### Merging behavior +#### Merging behavior - The labels to fields transformer is internally two separate transformations. The first acts on single series and extracts labels to fields. The second is the [merge](#merge) transformation that joins all the results into a single table. The merge transformation tries to join on all matching fields. This merge step is required and cannot be turned off. +The labels to fields transformer is internally two separate transformations. The first acts on single series and extracts labels to fields. The second is the [merge](#merge) transformation that joins all the results into a single table. The merge transformation tries to join on all matching fields. This merge step is required and cannot be turned off. - To illustrate this, here is an example where you have two queries that return time series with no overlapping labels. +To illustrate this, here is an example where you have two queries that return time series with no overlapping labels. - - Series 1: labels Server=ServerA - - Series 2: labels Datacenter=EU +- Series 1: labels Server=ServerA +- Series 2: labels Datacenter=EU - This will first result in these two tables: +This will first result in these two tables: - | Time | Server | Value | - | ------------------- | ------- | ----- | - | 2020-07-07 11:34:20 | ServerA | 10 | +| Time | Server | Value | +| ------------------- | ------- | ----- | +| 2020-07-07 11:34:20 | ServerA | 10 | - | Time | Datacenter | Value | - | ------------------- | ---------- | ----- | - | 2020-07-07 11:34:20 | EU | 20 | +| Time | Datacenter | Value | +| ------------------- | ---------- | ----- | +| 2020-07-07 11:34:20 | EU | 20 | - After merge: +After merge: - | Time | Server | Value | Datacenter | - | ------------------- | ------- | ----- | ---------- | - | 2020-07-07 11:34:20 | ServerA | 10 | | - | 2020-07-07 11:34:20 | | 20 | EU | +| Time | Server | Value | Datacenter | +| ------------------- | ------- | ----- | ---------- | +| 2020-07-07 11:34:20 | ServerA | 10 | | +| 2020-07-07 11:34:20 | | 20 | EU | - Convert your time series data into a structured table format for a clearer and more organized representation. +Convert your time series data into a structured table format for a clearer and more organized representation. `; }, }, @@ -928,28 +990,28 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Limit', getHelperDocs: function () { return ` - Use this transformation to restrict the number of rows displayed, providing a more focused view of your data. This is particularly useful when dealing with large datasets. +Use this transformation to restrict the number of rows displayed, providing a more focused view of your data. This is particularly useful when dealing with large datasets. - Below is an example illustrating the impact of the **Limit** transformation on a response from a data source: +Below is an example illustrating the impact of the **Limit** transformation on a response from a data source: - | Time | Metric | Value | - | ------------------- | ----------- | ----- | - | 2020-07-07 11:34:20 | Temperature | 25 | - | 2020-07-07 11:34:20 | Humidity | 22 | - | 2020-07-07 10:32:20 | Humidity | 29 | - | 2020-07-07 10:31:22 | Temperature | 22 | - | 2020-07-07 09:30:57 | Humidity | 33 | - | 2020-07-07 09:30:05 | Temperature | 19 | +| Time | Metric | Value | +| ------------------- | ----------- | ----- | +| 2020-07-07 11:34:20 | Temperature | 25 | +| 2020-07-07 11:34:20 | Humidity | 22 | +| 2020-07-07 10:32:20 | Humidity | 29 | +| 2020-07-07 10:31:22 | Temperature | 22 | +| 2020-07-07 09:30:57 | Humidity | 33 | +| 2020-07-07 09:30:05 | Temperature | 19 | - Here is the result after adding a Limit transformation with a value of '3': +Here is the result after adding a Limit transformation with a value of '3': - | Time | Metric | Value | - | ------------------- | ----------- | ----- | - | 2020-07-07 11:34:20 | Temperature | 25 | - | 2020-07-07 11:34:20 | Humidity | 22 | - | 2020-07-07 10:32:20 | Humidity | 29 | +| Time | Metric | Value | +| ------------------- | ----------- | ----- | +| 2020-07-07 11:34:20 | Temperature | 25 | +| 2020-07-07 11:34:20 | Humidity | 22 | +| 2020-07-07 10:32:20 | Humidity | 29 | - This transformation helps you tailor the visual presentation of your data to focus on the most relevant information. +This transformation helps you tailor the visual presentation of your data to focus on the most relevant information. `; }, }, @@ -957,32 +1019,32 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Merge series/tables', getHelperDocs: function () { return ` - Use this transformation to combine the results from multiple queries into a single result, which is particularly useful when using the table panel visualization. This transformation merges values into the same row if the shared fields contain the same data. - - Here's an example illustrating the impact of the **Merge series/tables** transformation on two queries returning table data: +Use this transformation to combine the results from multiple queries into a single result, which is particularly useful when using the table panel visualization. This transformation merges values into the same row if the shared fields contain the same data. - **Query A:** +Here's an example illustrating the impact of the **Merge series/tables** transformation on two queries returning table data: - | Time | Job | Uptime | - | ------------------- | ------- | --------- | - | 2020-07-07 11:34:20 | node | 25260122 | - | 2020-07-07 11:24:20 | postgre | 123001233 | +**Query A:** - **Query B:** +| Time | Job | Uptime | +| ------------------- | ------- | --------- | +| 2020-07-07 11:34:20 | node | 25260122 | +| 2020-07-07 11:24:20 | postgre | 123001233 | - | Time | Job | Errors | - | ------------------- | ------- | ------ | - | 2020-07-07 11:34:20 | node | 15 | - | 2020-07-07 11:24:20 | postgre | 5 | +**Query B:** - Here is the result after applying the Merge transformation. +| Time | Job | Errors | +| ------------------- | ------- | ------ | +| 2020-07-07 11:34:20 | node | 15 | +| 2020-07-07 11:24:20 | postgre | 5 | - | Time | Job | Errors | Uptime | - | ------------------- | ------- | ------ | --------- | - | 2020-07-07 11:34:20 | node | 15 | 25260122 | - | 2020-07-07 11:24:20 | postgre | 5 | 123001233 | +Here is the result after applying the Merge transformation. - This transformation combines values from Query A and Query B into a unified table, enhancing the presentation of data for better insights. +| Time | Job | Errors | Uptime | +| ------------------- | ------- | ------ | --------- | +| 2020-07-07 11:34:20 | node | 15 | 25260122 | +| 2020-07-07 11:24:20 | postgre | 5 | 123001233 | + +This transformation combines values from Query A and Query B into a unified table, enhancing the presentation of data for better insights. `; }, links: [ @@ -996,35 +1058,35 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Organize fields by name', getHelperDocs: function () { return ` - Use this transformation to provide the flexibility to rename, reorder, or hide fields returned by a single query in your panel. This transformation is applicable only to panels with a single query. If your panel has multiple queries, consider using an "Outer join" transformation or removing extra queries. +Use this transformation to provide the flexibility to rename, reorder, or hide fields returned by a single query in your panel. This transformation is applicable only to panels with a single query. If your panel has multiple queries, consider using an "Outer join" transformation or removing extra queries. - #### Transforming fields - - Grafana displays a list of fields returned by the query, allowing you to perform the following actions: - - - **Change field order** - Hover over a field, and when your cursor turns into a hand, drag the field to its new position. - - **Hide or show a field** - Use the eye icon next to the field name to toggle the visibility of a specific field. - - **Rename fields** - Type a new name in the "Rename <field>" box to customize field names. - - #### Example: - - ##### Original Query Result - - | Time | Metric | Value | - | ------------------- | ----------- | ----- | - | 2020-07-07 11:34:20 | Temperature | 25 | - | 2020-07-07 11:34:20 | Humidity | 22 | - | 2020-07-07 10:32:20 | Humidity | 29 | - - ##### After Applying Field Overrides - - | Time | Sensor | Reading | - | ------------------- | ----------- | ------- | - | 2020-07-07 11:34:20 | Temperature | 25 | - | 2020-07-07 11:34:20 | Humidity | 22 | - | 2020-07-07 10:32:20 | Humidity | 29 | +#### Transforming fields + +Grafana displays a list of fields returned by the query, allowing you to perform the following actions: + +- **Change field order** - Hover over a field, and when your cursor turns into a hand, drag the field to its new position. +- **Hide or show a field** - Use the eye icon next to the field name to toggle the visibility of a specific field. +- **Rename fields** - Type a new name in the "Rename <field>" box to customize field names. + +#### Example: + +##### Original Query Result + +| Time | Metric | Value | +| ------------------- | ----------- | ----- | +| 2020-07-07 11:34:20 | Temperature | 25 | +| 2020-07-07 11:34:20 | Humidity | 22 | +| 2020-07-07 10:32:20 | Humidity | 29 | + +##### After Applying Field Overrides - This transformation lets you to tailor the display of query results, ensuring a clear and insightful representation of your data in Grafana. +| Time | Sensor | Reading | +| ------------------- | ----------- | ------- | +| 2020-07-07 11:34:20 | Temperature | 25 | +| 2020-07-07 11:34:20 | Humidity | 22 | +| 2020-07-07 10:32:20 | Humidity | 29 | + +This transformation lets you to tailor the display of query results, ensuring a clear and insightful representation of your data in Grafana. `; }, }, @@ -1032,32 +1094,32 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Partition by values', getHelperDocs: function () { return ` - Use this transformation to streamline the process of graphing multiple series without the need for multiple queries with different 'WHERE' clauses. - - This is particularly useful when dealing with a metrics SQL table, as illustrated below: - - | Time | Region | Value | - | ------------------- | ------ | ----- | - | 2022-10-20 12:00:00 | US | 1520 | - | 2022-10-20 12:00:00 | EU | 2936 | - | 2022-10-20 01:00:00 | US | 1327 | - | 2022-10-20 01:00:00 | EU | 912 | - - With the **Partition by values** transformation, you can issue a single query and split the results by unique values in one or more columns (fields) of your choosing. The following example uses 'Region': +Use this transformation to streamline the process of graphing multiple series without the need for multiple queries with different 'WHERE' clauses. + +This is particularly useful when dealing with a metrics SQL table, as illustrated below: - 'SELECT Time, Region, Value FROM metrics WHERE Time > "2022-10-20"' +| Time | Region | Value | +| ------------------- | ------ | ----- | +| 2022-10-20 12:00:00 | US | 1520 | +| 2022-10-20 12:00:00 | EU | 2936 | +| 2022-10-20 01:00:00 | US | 1327 | +| 2022-10-20 01:00:00 | EU | 912 | - | Time | Region | Value | - | ------------------- | ------ | ----- | - | 2022-10-20 12:00:00 | US | 1520 | - | 2022-10-20 01:00:00 | US | 1327 | +With the **Partition by values** transformation, you can issue a single query and split the results by unique values in one or more columns (fields) of your choosing. The following example uses 'Region': - | Time | Region | Value | - | ------------------- | ------ | ----- | - | 2022-10-20 12:00:00 | EU | 2936 | - | 2022-10-20 01:00:00 | EU | 912 | +'SELECT Time, Region, Value FROM metrics WHERE Time > "2022-10-20"' - This transformation simplifies the process and enhances the flexibility of visualizing multiple series within the same time series visualization. +| Time | Region | Value | +| ------------------- | ------ | ----- | +| 2022-10-20 12:00:00 | US | 1520 | +| 2022-10-20 01:00:00 | US | 1327 | + +| Time | Region | Value | +| ------------------- | ------ | ----- | +| 2022-10-20 12:00:00 | EU | 2936 | +| 2022-10-20 01:00:00 | EU | 912 | + +This transformation simplifies the process and enhances the flexibility of visualizing multiple series within the same time series visualization. `; }, }, @@ -1065,52 +1127,52 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Prepare time series', getHelperDocs: function () { return ` - Use this transformation to address issues when a data source returns time series data in a format that isn't compatible with the desired visualization. This transformation allows you to convert time series data between wide and long formats, providing flexibility in data frame structures. +Use this transformation to address issues when a data source returns time series data in a format that isn't compatible with the desired visualization. This transformation allows you to convert time series data between wide and long formats, providing flexibility in data frame structures. - #### Available options - - ##### Multi-frame time series - - Use this option to transform the time series data frame from the wide format to the long format. This is particularly helpful when your data source delivers time series information in a format that needs to be reshaped for optimal compatibility with your visualization. - - **Example: Converting from wide to long format** - - | Timestamp | Value1 | Value2 | - |---------------------|--------|--------| - | 2023-01-01 00:00:00 | 10 | 20 | - | 2023-01-01 01:00:00 | 15 | 25 | - - **Transformed to:** - - | Timestamp | Variable | Value | - |---------------------|----------|-------| - | 2023-01-01 00:00:00 | Value1 | 10 | - | 2023-01-01 00:00:00 | Value2 | 20 | - | 2023-01-01 01:00:00 | Value1 | 15 | - | 2023-01-01 01:00:00 | Value2 | 25 | - - - ##### Wide time series - - Select this option to transform the time series data frame from the long format to the wide format. If your data source returns time series data in a long format and your visualization requires a wide format, this transformation simplifies the process. - - **Example: Converting from long to wide format** - - | Timestamp | Variable | Value | - |---------------------|----------|-------| - | 2023-01-01 00:00:00 | Value1 | 10 | - | 2023-01-01 00:00:00 | Value2 | 20 | - | 2023-01-01 01:00:00 | Value1 | 15 | - | 2023-01-01 01:00:00 | Value2 | 25 | - - **Transformed to:** - - | Timestamp | Value1 | Value2 | - |---------------------|--------|--------| - | 2023-01-01 00:00:00 | 10 | 20 | - | 2023-01-01 01:00:00 | 15 | 25 | +#### Available options + +##### Multi-frame time series + +Use this option to transform the time series data frame from the wide format to the long format. This is particularly helpful when your data source delivers time series information in a format that needs to be reshaped for optimal compatibility with your visualization. + +**Example: Converting from wide to long format** + +| Timestamp | Value1 | Value2 | +|---------------------|--------|--------| +| 2023-01-01 00:00:00 | 10 | 20 | +| 2023-01-01 01:00:00 | 15 | 25 | + +**Transformed to:** + +| Timestamp | Variable | Value | +|---------------------|----------|-------| +| 2023-01-01 00:00:00 | Value1 | 10 | +| 2023-01-01 00:00:00 | Value2 | 20 | +| 2023-01-01 01:00:00 | Value1 | 15 | +| 2023-01-01 01:00:00 | Value2 | 25 | + + +##### Wide time series - > **Note:** This transformation is available in Grafana 7.5.10+ and Grafana 8.0.6+. +Select this option to transform the time series data frame from the long format to the wide format. If your data source returns time series data in a long format and your visualization requires a wide format, this transformation simplifies the process. + +**Example: Converting from long to wide format** + +| Timestamp | Variable | Value | +|---------------------|----------|-------| +| 2023-01-01 00:00:00 | Value1 | 10 | +| 2023-01-01 00:00:00 | Value2 | 20 | +| 2023-01-01 01:00:00 | Value1 | 15 | +| 2023-01-01 01:00:00 | Value2 | 25 | + +**Transformed to:** + +| Timestamp | Value1 | Value2 | +|---------------------|--------|--------| +| 2023-01-01 00:00:00 | 10 | 20 | +| 2023-01-01 01:00:00 | 15 | 25 | + +> **Note:** This transformation is available in Grafana 7.5.10+ and Grafana 8.0.6+. `; }, links: [ @@ -1124,55 +1186,55 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Reduce', getHelperDocs: function () { return ` - Use this transformation to apply a calculation to each field in the data frame and return a single value. This transformation is particularly useful for consolidating multiple time series data into a more compact, summarized format. Time fields are removed when applying this transformation. +Use this transformation to apply a calculation to each field in the data frame and return a single value. This transformation is particularly useful for consolidating multiple time series data into a more compact, summarized format. Time fields are removed when applying this transformation. - Consider the input: +Consider the input: - **Query A:** +**Query A:** - | Time | Temp | Uptime | - | ------------------- | ---- | ------- | - | 2020-07-07 11:34:20 | 12.3 | 256122 | - | 2020-07-07 11:24:20 | 15.4 | 1230233 | +| Time | Temp | Uptime | +| ------------------- | ---- | ------- | +| 2020-07-07 11:34:20 | 12.3 | 256122 | +| 2020-07-07 11:24:20 | 15.4 | 1230233 | - **Query B:** +**Query B:** - | Time | AQI | Errors | - | ------------------- | --- | ------ | - | 2020-07-07 11:34:20 | 6.5 | 15 | - | 2020-07-07 11:24:20 | 3.2 | 5 | +| Time | AQI | Errors | +| ------------------- | --- | ------ | +| 2020-07-07 11:34:20 | 6.5 | 15 | +| 2020-07-07 11:24:20 | 3.2 | 5 | - The reduce transformer has two modes: +The reduce transformer has two modes: - - **Series to rows** - Creates a row for each field and a column for each calculation. - - **Reduce fields** - Keeps the existing frame structure, but collapses each field into a single value. +- **Series to rows** - Creates a row for each field and a column for each calculation. +- **Reduce fields** - Keeps the existing frame structure, but collapses each field into a single value. - For example, if you used the **First** and **Last** calculation with a **Series to rows** transformation, then - the result would be: +For example, if you used the **First** and **Last** calculation with a **Series to rows** transformation, then +the result would be: - | Field | First | Last | - | ------ | ------ | ------- | - | Temp | 12.3 | 15.4 | - | Uptime | 256122 | 1230233 | - | AQI | 6.5 | 3.2 | - | Errors | 15 | 5 | +| Field | First | Last | +| ------ | ------ | ------- | +| Temp | 12.3 | 15.4 | +| Uptime | 256122 | 1230233 | +| AQI | 6.5 | 3.2 | +| Errors | 15 | 5 | - The **Reduce fields** with the **Last** calculation, - results in two frames, each with one row: +The **Reduce fields** with the **Last** calculation, +results in two frames, each with one row: - **Query A:** +**Query A:** - | Temp | Uptime | - | ---- | ------- | - | 15.4 | 1230233 | +| Temp | Uptime | +| ---- | ------- | +| 15.4 | 1230233 | - **Query B:** +**Query B:** - | AQI | Errors | - | --- | ------ | - | 3.2 | 5 | +| AQI | Errors | +| --- | ------ | +| 3.2 | 5 | - This flexible transformation simplifies the process of consolidating and summarizing data from multiple time series into a more manageable and organized format. +This flexible transformation simplifies the process of consolidating and summarizing data from multiple time series into a more manageable and organized format. `; }, }, @@ -1180,27 +1242,27 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Rename by regex', getHelperDocs: function (imageRenderType: ImageRenderType = ImageRenderType.ShortcodeFigure) { return ` - Use this transformation to rename parts of the query results using a regular expression and replacement pattern. +Use this transformation to rename parts of the query results using a regular expression and replacement pattern. - You can specify a regular expression, which is only applied to matches, along with a replacement pattern that support back references. For example, let's imagine you're visualizing CPU usage per host and you want to remove the domain name. You could set the regex to '([^\.]+)\..+' and the replacement pattern to '$1', 'web-01.example.com' would become 'web-01'. - - In the following example, we are stripping the prefix from event types. In the before image, you can see everything is prefixed with 'system.' +You can specify a regular expression, which is only applied to matches, along with a replacement pattern that support back references. For example, let's imagine you're visualizing CPU usage per host and you want to remove the domain name. You could set the regex to '([^\.]+)\..+' and the replacement pattern to '$1', 'web-01.example.com' would become 'web-01'. - ${buildImageContent( - '/static/img/docs/transformations/rename-by-regex-before-7-3.png', - imageRenderType, - 'A bar chart with long series names' - )} +In the following example, we are stripping the prefix from event types. In the before image, you can see everything is prefixed with 'system.' - With the transformation applied, you can see we are left with just the remainder of the string. +${buildImageContent( + '/static/img/docs/transformations/rename-by-regex-before-7-3.png', + imageRenderType, + 'A bar chart with long series names' +)} - ${buildImageContent( - '/static/img/docs/transformations/rename-by-regex-after-7-3.png', - imageRenderType, - 'A bar chart with shortened series names' - )} +With the transformation applied, you can see we are left with just the remainder of the string. - This transformation lets you to tailor your data to meet your visualization needs, making your dashboards more informative and user-friendly. +${buildImageContent( + '/static/img/docs/transformations/rename-by-regex-after-7-3.png', + imageRenderType, + 'A bar chart with shortened series names' +)} + +This transformation lets you to tailor your data to meet your visualization needs, making your dashboards more informative and user-friendly. `; }, }, @@ -1208,66 +1270,66 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Rows to fields', getHelperDocs: function () { return ` - Use this transformation to convert rows into separate fields. This can be useful because fields can be styled and configured individually. It can also use additional fields as sources for dynamic field configuration or map them to field labels. The additional labels can then be used to define better display names for the resulting fields. +Use this transformation to convert rows into separate fields. This can be useful because fields can be styled and configured individually. It can also use additional fields as sources for dynamic field configuration or map them to field labels. The additional labels can then be used to define better display names for the resulting fields. - This transformation includes a field table which lists all fields in the data returned by the configuration query. This table gives you control over what field should be mapped to each configuration property (the **Use as** option). You can also choose which value to select if there are multiple rows in the returned data. +This transformation includes a field table which lists all fields in the data returned by the configuration query. This table gives you control over what field should be mapped to each configuration property (the **Use as** option). You can also choose which value to select if there are multiple rows in the returned data. - This transformation requires: +This transformation requires: - - One field to use as the source of field names. +- One field to use as the source of field names. - By default, the transform uses the first string field as the source. You can override this default setting by selecting **Field name** in the **Use as** column for the field you want to use instead. + By default, the transform uses the first string field as the source. You can override this default setting by selecting **Field name** in the **Use as** column for the field you want to use instead. - - One field to use as the source of values. +- One field to use as the source of values. - By default, the transform uses the first number field as the source. But you can override this default setting by selecting **Field value** in the **Use as** column for the field you want to use instead. + By default, the transform uses the first number field as the source. But you can override this default setting by selecting **Field value** in the **Use as** column for the field you want to use instead. - Useful when visualizing data in: +Useful when visualizing data in: - - Gauge - - Stat - - Pie chart +- Gauge +- Stat +- Pie chart - #### Map extra fields to labels +#### Map extra fields to labels - If a field does not map to config property Grafana will automatically use it as source for a label on the output field- +If a field does not map to config property Grafana will automatically use it as source for a label on the output field- - **Example:** +**Example:** - | Name | DataCenter | Value | - | ------- | ---------- | ----- | - | ServerA | US | 100 | - | ServerB | EU | 200 | +| Name | DataCenter | Value | +| ------- | ---------- | ----- | +| ServerA | US | 100 | +| ServerB | EU | 200 | - **Output:** +**Output:** - | ServerA (labels: DataCenter: US) | ServerB (labels: DataCenter: EU) | - | -------------------------------- | -------------------------------- | - | 10 | 20 | +| ServerA (labels: DataCenter: US) | ServerB (labels: DataCenter: EU) | +| -------------------------------- | -------------------------------- | +| 10 | 20 | - The extra labels can now be used in the field display name provide more complete field names. +The extra labels can now be used in the field display name provide more complete field names. - If you want to extract config from one query and apply it to another you should use the config from query results transformation. +If you want to extract config from one query and apply it to another you should use the config from query results transformation. - #### Example +#### Example - **Input:** +**Input:** - | Name | Value | Max | - | ------- | ----- | --- | - | ServerA | 10 | 100 | - | ServerB | 20 | 200 | - | ServerC | 30 | 300 | +| Name | Value | Max | +| ------- | ----- | --- | +| ServerA | 10 | 100 | +| ServerB | 20 | 200 | +| ServerC | 30 | 300 | - **Output:** +**Output:** - | ServerA (config: max=100) | ServerB (config: max=200) | ServerC (config: max=300) | - | ------------------------- | ------------------------- | ------------------------- | - | 10 | 20 | 30 | +| ServerA (config: max=100) | ServerB (config: max=200) | ServerC (config: max=300) | +| ------------------------- | ------------------------- | ------------------------- | +| 10 | 20 | 30 | - As you can see each row in the source data becomes a separate field. Each field now also has a max config option set. Options like **Min**, **Max**, **Unit** and **Thresholds** are all part of field configuration and if set like this will be used by the visualization instead of any options manually configured in the panel editor options pane. +As you can see each row in the source data becomes a separate field. Each field now also has a max config option set. Options like **Min**, **Max**, **Unit** and **Thresholds** are all part of field configuration and if set like this will be used by the visualization instead of any options manually configured in the panel editor options pane. - This transformation enables the conversion of rows into individual fields, facilitates dynamic field configuration, and maps additional fields to labels. +This transformation enables the conversion of rows into individual fields, facilitates dynamic field configuration, and maps additional fields to labels. `; }, }, @@ -1275,42 +1337,42 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Series to rows', getHelperDocs: function () { return ` - Use this transformation to combine the result from multiple time series data queries into one single result. This is helpful when using the table panel visualization. +Use this transformation to combine the result from multiple time series data queries into one single result. This is helpful when using the table panel visualization. - The result from this transformation will contain three columns: Time, Metric, and Value. The Metric column is added so you easily can see from which query the metric originates from. Customize this value by defining Label on the source query. +The result from this transformation will contain three columns: Time, Metric, and Value. The Metric column is added so you easily can see from which query the metric originates from. Customize this value by defining Label on the source query. - In the example below, we have two queries returning time series data. It is visualized as two separate tables before applying the transformation. +In the example below, we have two queries returning time series data. It is visualized as two separate tables before applying the transformation. - **Query A:** +**Query A:** - | Time | Temperature | - | ------------------- | ----------- | - | 2020-07-07 11:34:20 | 25 | - | 2020-07-07 10:31:22 | 22 | - | 2020-07-07 09:30:05 | 19 | +| Time | Temperature | +| ------------------- | ----------- | +| 2020-07-07 11:34:20 | 25 | +| 2020-07-07 10:31:22 | 22 | +| 2020-07-07 09:30:05 | 19 | - **Query B:** +**Query B:** - | Time | Humidity | - | ------------------- | -------- | - | 2020-07-07 11:34:20 | 24 | - | 2020-07-07 10:32:20 | 29 | - | 2020-07-07 09:30:57 | 33 | +| Time | Humidity | +| ------------------- | -------- | +| 2020-07-07 11:34:20 | 24 | +| 2020-07-07 10:32:20 | 29 | +| 2020-07-07 09:30:57 | 33 | - Here is the result after applying the Series to rows transformation. +Here is the result after applying the Series to rows transformation. - | Time | Metric | Value | - | ------------------- | ----------- | ----- | - | 2020-07-07 11:34:20 | Temperature | 25 | - | 2020-07-07 11:34:20 | Humidity | 22 | - | 2020-07-07 10:32:20 | Humidity | 29 | - | 2020-07-07 10:31:22 | Temperature | 22 | - | 2020-07-07 09:30:57 | Humidity | 33 | - | 2020-07-07 09:30:05 | Temperature | 19 | +| Time | Metric | Value | +| ------------------- | ----------- | ----- | +| 2020-07-07 11:34:20 | Temperature | 25 | +| 2020-07-07 11:34:20 | Humidity | 22 | +| 2020-07-07 10:32:20 | Humidity | 29 | +| 2020-07-07 10:31:22 | Temperature | 22 | +| 2020-07-07 09:30:57 | Humidity | 33 | +| 2020-07-07 09:30:05 | Temperature | 19 | - This transformation facilitates the consolidation of results from multiple time series queries, providing a streamlined and unified dataset for efficient analysis and visualization in a tabular format. +This transformation facilitates the consolidation of results from multiple time series queries, providing a streamlined and unified dataset for efficient analysis and visualization in a tabular format. - > **Note:** This transformation is available in Grafana 7.1+. +> **Note:** This transformation is available in Grafana 7.1+. `; }, }, @@ -1318,11 +1380,11 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Sort by', getHelperDocs: function () { return ` - Use this transformation to sort each frame within a query result based on a specified field, making your data easier to understand and analyze. By configuring the desired field for sorting, you can control the order in which the data is presented in the table or visualization. +Use this transformation to sort each frame within a query result based on a specified field, making your data easier to understand and analyze. By configuring the desired field for sorting, you can control the order in which the data is presented in the table or visualization. - Use the **Reverse** switch to inversely order the values within the specified field. This functionality is particularly useful when you want to quickly toggle between ascending and descending order to suit your analytical needs. - - For example, in a scenario where time-series data is retrieved from a data source, the **Sort by** transformation can be applied to arrange the data frames based on the timestamp, either in ascending or descending order, depending on the analytical requirements. This capability ensures that you can easily navigate and interpret time-series data, gaining valuable insights from the organized and visually coherent presentation. +Use the **Reverse** switch to inversely order the values within the specified field. This functionality is particularly useful when you want to quickly toggle between ascending and descending order to suit your analytical needs. + +For example, in a scenario where time-series data is retrieved from a data source, the **Sort by** transformation can be applied to arrange the data frames based on the timestamp, either in ascending or descending order, depending on the analytical requirements. This capability ensures that you can easily navigate and interpret time-series data, gaining valuable insights from the organized and visually coherent presentation. `; }, }, @@ -1330,38 +1392,51 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Spatial', getHelperDocs: function () { return ` - Use this transformation to apply spatial operations to query results. - - - **Action** - Select an action: - - **Prepare spatial field** - Set a geometry field based on the results of other fields. - - **Location mode** - Select a location mode (these options are shared by the **Calculate value** and **Transform** modes): - - **Auto** - Automatically identify location data based on default field names. - - **Coords** - Specify latitude and longitude fields. - - **Geohash** - Specify a geohash field. - - **Lookup** - Specify Gazetteer location fields. - - **Calculate value** - Use the geometry to define a new field (heading/distance/area). - - **Function** - Choose a mathematical operation to apply to the geometry: - - **Heading** - Calculate the heading (direction) between two points. - - **Area** - Calculate the area enclosed by a polygon defined by the geometry. - - **Distance** - Calculate the distance between two points. - - **Transform** - Apply spatial operations to the geometry. - - **Operation** - Choose an operation to apply to the geometry: - - **As line** - Create a single line feature with a vertex at each row. - - **Line builder** - Create a line between two points. - - This transformation allows you to manipulate and analyze geospatial data, enabling operations such as creating lines between points, calculating spatial properties, and more. +Use this transformation to apply spatial operations to query results. + +- **Action** - Select an action: + - **Prepare spatial field** - Set a geometry field based on the results of other fields. + - **Location mode** - Select a location mode (these options are shared by the **Calculate value** and **Transform** modes): + - **Auto** - Automatically identify location data based on default field names. + - **Coords** - Specify latitude and longitude fields. + - **Geohash** - Specify a geohash field. + - **Lookup** - Specify Gazetteer location fields. + - **Calculate value** - Use the geometry to define a new field (heading/distance/area). + - **Function** - Choose a mathematical operation to apply to the geometry: + - **Heading** - Calculate the heading (direction) between two points. + - **Area** - Calculate the area enclosed by a polygon defined by the geometry. + - **Distance** - Calculate the distance between two points. + - **Transform** - Apply spatial operations to the geometry. + - **Operation** - Choose an operation to apply to the geometry: + - **As line** - Create a single line feature with a vertex at each row. + - **Line builder** - Create a line between two points. + +This transformation allows you to manipulate and analyze geospatial data, enabling operations such as creating lines between points, calculating spatial properties, and more. `; }, }, timeSeriesTable: { name: 'Time series to table transform', - getHelperDocs: function () { + getHelperDocs: function (imageRenderType: ImageRenderType = ImageRenderType.ShortcodeFigure) { return ` - Use this transformation to convert time series results into a table, transforming a time series data frame into a **Trend** field. The **Trend** field can then be rendered using the [sparkline cell type][], generating an inline sparkline for each table row. If there are multiple time series queries, each will result in a separate table data frame. These can be joined using join or merge transforms to produce a single table with multiple sparklines per row. +Use this transformation to convert time series results into a table, transforming a time series data frame into a **Trend** field which can then be used with the [sparkline cell type][]. If there are multiple time series queries, each will result in a separate table data frame. These can be joined using join or merge transforms to produce a single table with multiple sparklines per row. + +${buildImageContent( + '/static/img/docs/transformations/table-sparklines.png', + imageRenderType, + 'A table panel showing multiple values and their corresponding sparklines.' +)} + +For each generated **Trend** field value, a calculation function can be selected. This value is displayed next to the sparkline and will be used for sorting table rows. + +${buildImageContent( + '/static/img/docs/transformations/timeseries-table-select-stat.png', + imageRenderType, + 'A select box showing available statistics that can be calculated.' +)} - For each generated **Trend** field value, a calculation function can be selected. The default is **Last non-null value**. This value is displayed next to the sparkline and used for sorting table rows. - > **Note:** This transformation is available in Grafana 9.5+ as an opt-in beta feature. Modify the Grafana [configuration file][] to use it. +> **Note:** This transformation is available in Grafana 9.5+ as an opt-in beta feature. Modify the Grafana [configuration file][] to use it. `; }, links: [ @@ -1379,24 +1454,24 @@ export const transformationDocsContent: TransformationDocsContentType = { name: 'Regression analysis', getHelperDocs: function (imageRenderType: ImageRenderType = ImageRenderType.ShortcodeFigure) { return ` - Use this transformation to create a new data frame containing values predicted by a statistical model. This is useful for finding a trend in chaotic data. It works by fitting a mathematical function to the data, using either linear or polynomial regression. The data frame can then be used in a visualization to display a trendline. +Use this transformation to create a new data frame containing values predicted by a statistical model. This is useful for finding a trend in chaotic data. It works by fitting a mathematical function to the data, using either linear or polynomial regression. The data frame can then be used in a visualization to display a trendline. - There are two different models: +There are two different models: - - **Linear regression** - Fits a linear function to the data. +- **Linear regression** - Fits a linear function to the data. ${buildImageContent( '/static/img/docs/transformations/linear-regression.png', imageRenderType, 'A time series visualization with a straight line representing the linear function' )} - - **Polynomial regression** - Fits a polynomial function to the data. - ${buildImageContent( - '/static/img/docs/transformations/polynomial-regression.png', - imageRenderType, - 'A time series visualization with a curved line representing the polynomial function' - )} +- **Polynomial regression** - Fits a polynomial function to the data. +${buildImageContent( + '/static/img/docs/transformations/polynomial-regression.png', + imageRenderType, + 'A time series visualization with a curved line representing the polynomial function' +)} - > **Note:** This transformation is currently in private preview. Grafana Labs offers support on a best-effort basis, and breaking changes might occur prior to the feature being made generally available. +> **Note:** This transformation is currently in public preview. Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. Enable the \`regressionTransformation\` feature toggle in Grafana to use this feature. Contact Grafana Support to enable this feature in Grafana Cloud. `; }, }, diff --git a/public/app/features/transformers/editors/CalculateFieldTransformerEditor/WindowOptionsEditor.tsx b/public/app/features/transformers/editors/CalculateFieldTransformerEditor/WindowOptionsEditor.tsx index ff5090d3e4b83..05b10b6e36c30 100644 --- a/public/app/features/transformers/editors/CalculateFieldTransformerEditor/WindowOptionsEditor.tsx +++ b/public/app/features/transformers/editors/CalculateFieldTransformerEditor/WindowOptionsEditor.tsx @@ -52,16 +52,15 @@ export const WindowOptionsEditor = (props: { }); }; - const onWindowSizeModeChange = (val: string) => { - const mode = val as WindowSizeMode; + const onWindowSizeModeChange = (val: WindowSizeMode) => { updateWindowOptions({ ...window!, windowSize: window?.windowSize - ? mode === WindowSizeMode.Percentage + ? val === WindowSizeMode.Percentage ? window!.windowSize! / 100 : window!.windowSize! * 100 : undefined, - windowSizeMode: mode, + windowSizeMode: val, }); }; @@ -71,10 +70,10 @@ export const WindowOptionsEditor = (props: { updateWindowOptions({ ...window, reducer }); }; - const onTypeChange = (val: string) => { + const onTypeChange = (val: WindowAlignment) => { updateWindowOptions({ ...window!, - windowAlignment: val as WindowAlignment, + windowAlignment: val, }); }; diff --git a/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx b/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx index f19570031919f..ba6793ecd7655 100644 --- a/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx +++ b/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx @@ -82,6 +82,18 @@ export const ConvertFieldTypeTransformerEditor = ({ [onChange, options] ); + const onJoinWithChange = useCallback( + (idx: number) => (e: ChangeEvent<HTMLInputElement>) => { + const conversions = options.conversions; + conversions[idx] = { ...conversions[idx], joinWith: e.currentTarget.value }; + onChange({ + ...options, + conversions: conversions, + }); + }, + [onChange, options] + ); + const onAddConvertFieldType = useCallback(() => { onChange({ ...options, @@ -119,6 +131,7 @@ export const ConvertFieldTypeTransformerEditor = ({ return ( <> {options.conversions.map((c: ConvertFieldTypeOptions, idx: number) => { + const targetField = findField(input?.[0], c.targetField); return ( <div key={`${c.targetField}-${idx}`}> <InlineFieldRow> @@ -152,22 +165,31 @@ export const ConvertFieldTypeTransformerEditor = ({ /> </InlineField> )} - {c.destinationType === FieldType.string && - (c.dateFormat || findField(input?.[0], c.targetField)?.type === FieldType.time) && ( - <> - <InlineField label="Date format" tooltip="Specify the output format."> - <Input - value={c.dateFormat} - placeholder={'e.g. YYYY-MM-DD'} - onChange={onInputFormat(idx)} - width={24} - /> - </InlineField> - <InlineField label="Set timezone" tooltip="Set the timezone of the date manually"> - <Select options={timeZoneOptions} value={c.timezone} onChange={onTzChange(idx)} isClearable /> + {c.destinationType === FieldType.string && ( + <> + {(c.joinWith?.length || targetField?.type === FieldType.other) && ( + <InlineField label="Join with" tooltip="Use an explicit separator when joining array values"> + <Input value={c.joinWith} placeholder={'JSON'} onChange={onJoinWithChange(idx)} width={9} /> </InlineField> - </> - )} + )} + {c.dateFormat || + (targetField?.type === FieldType.time && ( + <> + <InlineField label="Date format" tooltip="Specify the output format."> + <Input + value={c.dateFormat} + placeholder={'e.g. YYYY-MM-DD'} + onChange={onInputFormat(idx)} + width={24} + /> + </InlineField> + <InlineField label="Set timezone" tooltip="Set the timezone of the date manually"> + <Select options={timeZoneOptions} value={c.timezone} onChange={onTzChange(idx)} isClearable /> + </InlineField> + </> + ))} + </> + )} <Button size="md" icon="trash-alt" diff --git a/public/app/features/transformers/editors/GroupByTransformerEditor.tsx b/public/app/features/transformers/editors/GroupByTransformerEditor.tsx index 0966edf266e4d..50177ea8912c2 100644 --- a/public/app/features/transformers/editors/GroupByTransformerEditor.tsx +++ b/public/app/features/transformers/editors/GroupByTransformerEditor.tsx @@ -16,7 +16,7 @@ import { GroupByOperationID, GroupByTransformerOptions, } from '@grafana/data/src/transformations/transformers/groupBy'; -import { useTheme2, Select, StatsPicker, InlineField, Stack } from '@grafana/ui'; +import { useTheme2, Select, StatsPicker, InlineField, Stack, Alert } from '@grafana/ui'; import { getTransformationContent } from '../docs/getTransformationContent'; import { useAllFieldNamesFromDataFrames } from '../utils'; @@ -49,8 +49,28 @@ export const GroupByTransformerEditor = ({ [onChange] ); + // See if there's both an aggregation and grouping field configured + // for calculations. If not we display a warning because there + // needs to be a grouping for the calculation to have effect + let hasGrouping, + hasAggregation = false; + + for (const field of Object.values(options.fields)) { + if (field.aggregations.length > 0 && field.operation !== null) { + hasAggregation = true; + } + if (field.operation === GroupByOperationID.groupBy) { + hasGrouping = true; + } + } + + const showCalcAlert = hasAggregation && !hasGrouping; + return ( - <div> + <Stack direction="column"> + {showCalcAlert && ( + <Alert title="Calculations will not have an effect if no fields are being grouped on." severity="warning" /> + )} {fieldNames.map((key) => ( <GroupByFieldConfiguration onConfigChange={onConfigChange(key)} @@ -59,7 +79,7 @@ export const GroupByTransformerEditor = ({ key={key} /> ))} - </div> + </Stack> ); }; diff --git a/public/app/features/transformers/editors/GroupToNestedTableTransformerEditor.tsx b/public/app/features/transformers/editors/GroupToNestedTableTransformerEditor.tsx new file mode 100644 index 0000000000000..2811235c4609d --- /dev/null +++ b/public/app/features/transformers/editors/GroupToNestedTableTransformerEditor.tsx @@ -0,0 +1,185 @@ +import { css } from '@emotion/css'; +import React, { useCallback } from 'react'; + +import { + DataTransformerID, + ReducerID, + SelectableValue, + standardTransformers, + TransformerRegistryItem, + TransformerUIProps, + TransformerCategory, + GrafanaTheme2, +} from '@grafana/data'; +import { + GroupByFieldOptions, + GroupByOperationID, + GroupByTransformerOptions, +} from '@grafana/data/src/transformations/transformers/groupBy'; +import { + GroupToNestedTableTransformerOptions, + SHOW_NESTED_HEADERS_DEFAULT, +} from '@grafana/data/src/transformations/transformers/groupToNestedTable'; +import { Stack } from '@grafana/experimental'; +import { useTheme2, Select, StatsPicker, InlineField, Field, Switch, Alert } from '@grafana/ui'; + +import { useAllFieldNamesFromDataFrames } from '../utils'; + +interface FieldProps { + fieldName: string; + config?: GroupByFieldOptions; + onConfigChange: (config: GroupByFieldOptions) => void; +} + +export const GroupToNestedTableTransformerEditor = ({ + input, + options, + onChange, +}: TransformerUIProps<GroupToNestedTableTransformerOptions>) => { + const fieldNames = useAllFieldNamesFromDataFrames(input); + const showHeaders = + options.showSubframeHeaders === undefined ? SHOW_NESTED_HEADERS_DEFAULT : options.showSubframeHeaders; + + const onConfigChange = useCallback( + (fieldName: string) => (config: GroupByFieldOptions) => { + onChange({ + ...options, + fields: { + ...options.fields, + [fieldName]: config, + }, + }); + }, + // Adding options to the dependency array causes infinite loop here. + // eslint-disable-next-line react-hooks/exhaustive-deps + [onChange] + ); + + const onShowFieldNamesChange = useCallback( + () => { + const showSubframeHeaders = + options.showSubframeHeaders === undefined ? !SHOW_NESTED_HEADERS_DEFAULT : !options.showSubframeHeaders; + + onChange({ + showSubframeHeaders, + fields: { + ...options.fields, + }, + }); + }, + // Adding options to the dependency array causes infinite loop here. + // eslint-disable-next-line react-hooks/exhaustive-deps + [onChange] + ); + + // See if there's both an aggregation and grouping field configured + // for calculations. If not we display a warning because there + // needs to be a grouping for the calculation to have effect + let hasGrouping, + hasAggregation = false; + for (const field of Object.values(options.fields)) { + if (field.aggregations.length > 0 && field.operation !== null) { + hasAggregation = true; + } + if (field.operation === GroupByOperationID.groupBy) { + hasGrouping = true; + } + } + const showCalcAlert = hasAggregation && !hasGrouping; + + return ( + <Stack direction="column"> + {showCalcAlert && ( + <Alert title="Calculations will not have an effect if no fields are being grouped on." severity="warning" /> + )} + <div> + {fieldNames.map((key) => ( + <GroupByFieldConfiguration + onConfigChange={onConfigChange(key)} + fieldName={key} + config={options.fields[key]} + key={key} + /> + ))} + </div> + <Field + label="Show field names in nested tables" + description="If enabled nested tables will show field names as a table header" + > + <Switch value={showHeaders} onChange={onShowFieldNamesChange} /> + </Field> + </Stack> + ); +}; + +const options = [ + { label: 'Group by', value: GroupByOperationID.groupBy }, + { label: 'Calculate', value: GroupByOperationID.aggregate }, +]; + +export const GroupByFieldConfiguration = ({ fieldName, config, onConfigChange }: FieldProps) => { + const theme = useTheme2(); + const styles = getStyles(theme); + + const onChange = useCallback( + (value: SelectableValue<GroupByOperationID | null>) => { + onConfigChange({ + aggregations: config?.aggregations ?? [], + operation: value?.value ?? null, + }); + }, + [config, onConfigChange] + ); + + return ( + <InlineField className={styles.label} label={fieldName} grow shrink> + <Stack gap={0.5} direction="row" wrap={false}> + <div className={styles.operation}> + <Select options={options} value={config?.operation} placeholder="Ignored" onChange={onChange} isClearable /> + </div> + + {config?.operation === GroupByOperationID.aggregate && ( + <StatsPicker + className={styles.aggregations} + placeholder="Select Stats" + allowMultiple + stats={config.aggregations} + onChange={(stats) => { + // eslint-disable-next-line + onConfigChange({ ...config, aggregations: stats as ReducerID[] }); + }} + /> + )} + </Stack> + </InlineField> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + label: css({ + minWidth: theme.spacing(32), + }), + operation: css({ + flexShrink: 0, + height: '100%', + width: theme.spacing(24), + }), + aggregations: css({ + flexGrow: 1, + }), + }; +}; + +export const groupToNestedTableTransformRegistryItem: TransformerRegistryItem<GroupByTransformerOptions> = { + id: DataTransformerID.groupToNestedTable, + editor: GroupToNestedTableTransformerEditor, + transformation: standardTransformers.groupToNestedTable, + name: standardTransformers.groupToNestedTable.name, + description: standardTransformers.groupToNestedTable.description, + categories: new Set([ + TransformerCategory.Combine, + TransformerCategory.CalculateNewFields, + TransformerCategory.Reformat, + ]), +}; diff --git a/public/app/features/transformers/editors/HistogramTransformerEditor.tsx b/public/app/features/transformers/editors/HistogramTransformerEditor.tsx index 9c042b68e1de0..93afbfd9bc088 100644 --- a/public/app/features/transformers/editors/HistogramTransformerEditor.tsx +++ b/public/app/features/transformers/editors/HistogramTransformerEditor.tsx @@ -28,10 +28,21 @@ export const HistogramTransformerEditor = ({ const labelWidth = 18; const [isInvalid, setInvalid] = useState({ + bucketCount: !numberOrVariableValidator(options.bucketCount || ''), bucketSize: !numberOrVariableValidator(options.bucketSize || ''), bucketOffset: !numberOrVariableValidator(options.bucketOffset || ''), }); + const onBucketCountChanged = useCallback( + (val?: number) => { + onChange({ + ...options, + bucketCount: val, + }); + }, + [onChange, options] + ); + const onBucketSizeChanged = useCallback( (val?: number) => { onChange({ @@ -52,6 +63,18 @@ export const HistogramTransformerEditor = ({ [onChange, options] ); + const onVariableBucketCountChanged = useCallback( + (value: string) => { + setInvalid({ ...isInvalid, bucketCount: !numberOrVariableValidator(value) }); + + onChange({ + ...options, + bucketCount: Number(value) === 0 ? undefined : Number(value), + }); + }, + [onChange, options, isInvalid] + ); + const onVariableBucketSizeChanged = useCallback( (value: string) => { setInvalid({ ...isInvalid, bucketSize: !numberOrVariableValidator(value) }); @@ -105,6 +128,20 @@ export const HistogramTransformerEditor = ({ return ( <div> + <InlineFieldRow> + <InlineField + labelWidth={labelWidth} + label={histogramFieldInfo.bucketCount.name} + tooltip={histogramFieldInfo.bucketCount.description} + > + <NumberInput + value={options.bucketCount} + placeholder="Default: 30" + onChange={onBucketCountChanged} + min={0} + /> + </InlineField> + </InlineFieldRow> <InlineFieldRow> <InlineField labelWidth={labelWidth} @@ -138,6 +175,22 @@ export const HistogramTransformerEditor = ({ return ( <div> + <InlineFieldRow> + <InlineField + invalid={isInvalid.bucketCount} + error={'Value needs to be an integer or a variable'} + labelWidth={labelWidth} + label={histogramFieldInfo.bucketCount.name} + tooltip={histogramFieldInfo.bucketCount.description} + > + <SuggestionsInput + suggestions={variables} + value={options.bucketCount} + placeholder="Default: 30" + onChange={onVariableBucketCountChanged} + /> + </InlineField> + </InlineFieldRow> <InlineFieldRow> <InlineField invalid={isInvalid.bucketSize} diff --git a/public/app/features/transformers/extractFields/extractFields.test.ts b/public/app/features/transformers/extractFields/extractFields.test.ts index 053a2802ba719..a6617aecea93f 100644 --- a/public/app/features/transformers/extractFields/extractFields.test.ts +++ b/public/app/features/transformers/extractFields/extractFields.test.ts @@ -313,9 +313,6 @@ describe('Fields from JSON', () => { }, ], length: 2, - meta: { - transformations: ['sortBy'], - }, }; const frames = extractFieldsTransformer.transformer(extractConfig, ctx)([testDataFrame]); diff --git a/public/app/features/transformers/partitionByValues/PartitionByValuesEditor.tsx b/public/app/features/transformers/partitionByValues/PartitionByValuesEditor.tsx index 874516506e0ef..167ba484b6e48 100644 --- a/public/app/features/transformers/partitionByValues/PartitionByValuesEditor.tsx +++ b/public/app/features/transformers/partitionByValues/PartitionByValuesEditor.tsx @@ -67,6 +67,11 @@ export function PartitionByValuesEditor({ { label: 'As frame name', value: namingModes.frameName }, ]; + const KeepFieldsOptions = [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ]; + const removeField = useCallback( (v: string) => { if (!v) { @@ -135,6 +140,15 @@ export function PartitionByValuesEditor({ /> </InlineField> </InlineFieldRow> + <InlineFieldRow> + <InlineField tooltip={'Keeps the partition fields in the frames.'} label={'Keep fields'} labelWidth={16}> + <RadioButtonGroup + options={KeepFieldsOptions} + value={options.keepFields} + onChange={(v) => onChange({ ...options, keepFields: v })} + /> + </InlineField> + </InlineFieldRow> </div> ); } diff --git a/public/app/features/transformers/partitionByValues/partitionByValues.ts b/public/app/features/transformers/partitionByValues/partitionByValues.ts index d33d00f04c7a6..8368d24806db4 100644 --- a/public/app/features/transformers/partitionByValues/partitionByValues.ts +++ b/public/app/features/transformers/partitionByValues/partitionByValues.ts @@ -67,7 +67,9 @@ export const partitionByValuesTransformer: SynchronousDataTransformerInfo<Partit id: DataTransformerID.partitionByValues, name: 'Partition by values', description: `Splits a one-frame dataset into multiple series discriminated by unique/enum values in one or more fields.`, - defaultOptions: {}, + defaultOptions: { + keepFields: false, + }, operator: (options, ctx) => (source) => source.pipe(map((data) => partitionByValuesTransformer.transformer(options, ctx)(data))), diff --git a/public/app/features/transformers/prepareTimeSeries/prepareTimeSeries.test.ts b/public/app/features/transformers/prepareTimeSeries/prepareTimeSeries.test.ts index c27ea9c866155..a9def1abca6b0 100644 --- a/public/app/features/transformers/prepareTimeSeries/prepareTimeSeries.test.ts +++ b/public/app/features/transformers/prepareTimeSeries/prepareTimeSeries.test.ts @@ -39,7 +39,7 @@ describe('Prepare time series transformer', () => { fields: [ { name: 'time', type: FieldType.time, values: [1, 2, 3, 4, 5, 6] }, { name: 'count', type: FieldType.number, values: [10, 20, 30, 40, 50, 60] }, - ], + ] as DataFrame['fields'], meta: { type: DataFrameType.TimeSeriesMulti, }, @@ -51,7 +51,7 @@ describe('Prepare time series transformer', () => { fields: [ { name: 'time', type: FieldType.time, values: [1, 2, 3, 4, 5, 6] }, { name: 'more', type: FieldType.number, values: [2, 3, 4, 5, 6, 7] }, - ], + ] as DataFrame['fields'], meta: { type: DataFrameType.TimeSeriesMulti, }, @@ -181,7 +181,7 @@ describe('Prepare time series transformer', () => { fields: [ { name: 'time', type: FieldType.time, values: [0, 1, 2, 3, 4, 5] }, { name: 'another', type: FieldType.number, values: [2, 3, 4, 5, 6, 7] }, - ], + ] as DataFrame['fields'], length: 6, meta: { type: DataFrameType.TimeSeriesMulti, @@ -193,7 +193,7 @@ describe('Prepare time series transformer', () => { fields: [ { name: 'time', type: FieldType.time, values: [0, 1, 2, 3, 4, 5] }, { name: 'count', type: FieldType.number, values: [10, 20, 30, 40, 50, 60] }, - ], + ] as DataFrame['fields'], length: 6, meta: { type: DataFrameType.TimeSeriesMulti, @@ -205,7 +205,7 @@ describe('Prepare time series transformer', () => { fields: [ { name: 'time', type: FieldType.time, values: [4, 5, 6, 7, 8, 9] }, { name: 'value', type: FieldType.number, values: [2, 3, 4, 5, 6, 7] }, - ], + ] as DataFrame['fields'], length: 6, meta: { type: DataFrameType.TimeSeriesMulti, @@ -304,7 +304,7 @@ describe('Prepare time series transformer', () => { fields: [ { name: 'time', type: FieldType.time, values: [1, 2, 3] }, { name: 'value', labels: { region: 'a' }, type: FieldType.number, values: [10, 30, 50] }, - ], + ] as DataFrame['fields'], length: 3, meta: { type: DataFrameType.TimeSeriesMulti, @@ -316,7 +316,7 @@ describe('Prepare time series transformer', () => { fields: [ { name: 'time', type: FieldType.time, values: [1, 2, 3] }, { name: 'value', labels: { region: 'b' }, type: FieldType.number, values: [20, 40, 60] }, - ], + ] as DataFrame['fields'], length: 3, meta: { type: DataFrameType.TimeSeriesMulti, @@ -351,7 +351,7 @@ describe('Prepare time series transformer', () => { fields: [ { name: 'time', type: FieldType.time, values: [1, 2, 3] }, { name: 'value', labels: { region: 'a' }, type: FieldType.number, values: [10, 30, 50] }, - ], + ] as DataFrame['fields'], length: 3, meta: { type: DataFrameType.TimeSeriesMulti, @@ -363,7 +363,7 @@ describe('Prepare time series transformer', () => { fields: [ { name: 'time', type: FieldType.time, values: [1, 2, 3] }, { name: 'value', labels: { region: 'b' }, type: FieldType.number, values: [20, 40, 60] }, - ], + ] as DataFrame['fields'], length: 3, meta: { type: DataFrameType.TimeSeriesMulti, @@ -415,7 +415,7 @@ describe('Prepare time series transformer', () => { }); }); -function toEquableDataFrame(source: any): DataFrame { +function toEquableDataFrame(source: DataFrame): DataFrame { return toDataFrame({ meta: undefined, ...source, diff --git a/public/app/features/transformers/regression/regression.test.ts b/public/app/features/transformers/regression/regression.test.ts index 16767a0283240..00dd836f06453 100644 --- a/public/app/features/transformers/regression/regression.test.ts +++ b/public/app/features/transformers/regression/regression.test.ts @@ -152,6 +152,34 @@ describe('Regression transformation', () => { expect(result[1].fields[0].values[8]).toBeCloseTo(3.55, 1); expect(result[1].fields[0].values[9]).toBe(4); }); + + it('should filter NaNs', () => { + const source = [ + toDataFrame({ + name: 'data', + refId: 'A', + fields: [ + { name: 'y', type: FieldType.number, values: [0, 1, 2, 3, NaN] }, + { name: 'x', type: FieldType.number, values: [0, 1, 2, 3, 4] }, + ], + }), + ]; + + const config: RegressionTransformerOptions = { + modelType: ModelType.linear, + predictionCount: 5, + xFieldName: 'x', + yFieldName: 'y', + }; + + const result = RegressionTransformer.transformer(config, {} as DataTransformContext)(source); + + expect(result[1].fields[1].values[0]).toBe(0); + expect(result[1].fields[1].values[1]).toBe(1); + expect(result[1].fields[1].values[2]).toBe(2); + expect(result[1].fields[1].values[3]).toBe(3); + expect(result[1].fields[1].values[4]).toBe(4); + }); }); function toEquableDataFrame(source: DataFrame): DataFrame { diff --git a/public/app/features/transformers/regression/regression.ts b/public/app/features/transformers/regression/regression.ts index 2408e1224540a..80e7036728481 100644 --- a/public/app/features/transformers/regression/regression.ts +++ b/public/app/features/transformers/regression/regression.ts @@ -9,6 +9,7 @@ import { FieldType, SynchronousDataTransformerInfo, fieldMatchers, + getFieldDisplayName, } from '@grafana/data'; export enum ModelType { @@ -42,6 +43,7 @@ export const RegressionTransformer: SynchronousDataTransformerInfo<RegressionTra let xField; let yField; + let predictFromFrame; for (const frame of frames) { const fy = frame.fields.find((f) => matchesY(f, frame, frames)); if (fy) { @@ -49,6 +51,7 @@ export const RegressionTransformer: SynchronousDataTransformerInfo<RegressionTra const fx = frame.fields.find((f) => matchesX(f, frame, frames)); if (fx) { xField = fx; + predictFromFrame = frame; break; } else { throw 'X and Y fields must be part of the same frame'; @@ -84,7 +87,7 @@ export const RegressionTransformer: SynchronousDataTransformerInfo<RegressionTra const xValues = []; for (let i = 0; i < xField.values.length; i++) { - if (yField.values[i] !== null) { + if (yField.values[i] !== null && !isNaN(yField.values[i])) { xValues.push(xField.values[i] - normalizationSubtrahend); yValues.push(yField.values[i]); } @@ -108,7 +111,7 @@ export const RegressionTransformer: SynchronousDataTransformerInfo<RegressionTra fields: [ { name: xField.name, type: xField.type, values: predictionPoints, config: {} }, { - name: `${yField.name} predicted`, + name: `${getFieldDisplayName(yField, predictFromFrame, frames)} predicted`, type: yField.type, values: predictionPoints.map((x) => result.predict(x - normalizationSubtrahend)), config: {}, diff --git a/public/app/features/transformers/standardTransformers.ts b/public/app/features/transformers/standardTransformers.ts index 31d03807011b8..04f45d20e2195 100644 --- a/public/app/features/transformers/standardTransformers.ts +++ b/public/app/features/transformers/standardTransformers.ts @@ -12,6 +12,7 @@ import { filterFramesByRefIdTransformRegistryItem } from './editors/FilterByRefI import { formatStringTransformerRegistryItem } from './editors/FormatStringTransformerEditor'; import { formatTimeTransformerRegistryItem } from './editors/FormatTimeTransformerEditor'; import { groupByTransformRegistryItem } from './editors/GroupByTransformerEditor'; +import { groupToNestedTableTransformRegistryItem } from './editors/GroupToNestedTableTransformerEditor'; import { groupingToMatrixTransformRegistryItem } from './editors/GroupingToMatrixTransformerEditor'; import { histogramTransformRegistryItem } from './editors/HistogramTransformerEditor'; import { joinByFieldTransformerRegistryItem } from './editors/JoinByFieldTransformerEditor'; @@ -64,6 +65,7 @@ export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> = partitionByValuesTransformRegistryItem, ...(config.featureToggles.formatString ? [formatStringTransformerRegistryItem] : []), ...(config.featureToggles.regressionTransformation ? [regressionTransformerRegistryItem] : []), + ...(config.featureToggles.groupToNestedTableTransformation ? [groupToNestedTableTransformRegistryItem] : []), formatTimeTransformerRegistryItem, timeSeriesTableTransformRegistryItem, ]; diff --git a/public/app/features/transformers/suggestionsInput/SuggestionsInput.tsx b/public/app/features/transformers/suggestionsInput/SuggestionsInput.tsx index fb0ef1f759272..81fa439d02d59 100644 --- a/public/app/features/transformers/suggestionsInput/SuggestionsInput.tsx +++ b/public/app/features/transformers/suggestionsInput/SuggestionsInput.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; -import React, { FormEvent, useEffect, useRef, useState } from 'react'; -import { Popper as ReactPopper } from 'react-popper'; +import { autoUpdate, flip, shift, useFloating } from '@floating-ui/react'; +import React, { FormEvent, useCallback, useEffect, useRef, useState } from 'react'; import { GrafanaTheme2, VariableSuggestion } from '@grafana/data'; import { CustomScrollbar, FieldValidationMessage, Input, Portal, useTheme2 } from '@grafana/ui'; @@ -55,7 +55,36 @@ export const SuggestionsInput = ({ const theme = useTheme2(); const styles = getStyles(theme, inputHeight); - const inputRef = useRef<HTMLInputElement>(null); + const inputRef = useRef<HTMLInputElement>(); + + // the order of middleware is important! + const middleware = [ + flip({ + fallbackAxisSideDirection: 'start', + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ]; + + const { refs, floatingStyles } = useFloating({ + open: showingSuggestions, + placement: 'bottom-start', + onOpenChange: setShowingSuggestions, + middleware, + whileElementsMounted: autoUpdate, + strategy: 'fixed', + }); + + const handleRef = useCallback( + (ref: HTMLInputElement) => { + refs.setReference(ref); + + inputRef.current = ref; + }, + [refs] + ); // Used to get the height of the suggestion elements in order to scroll to them. const activeRef = useRef<HTMLDivElement>(null); @@ -137,53 +166,25 @@ export const SuggestionsInput = ({ <div className={styles.inputWrapper}> {showingSuggestions && ( <Portal> - <ReactPopper - referenceElement={inputRef.current!} - placement="bottom-start" - modifiers={[ - { - name: 'preventOverflow', - enabled: true, - options: { - rootBoundary: 'viewport', - }, - }, - { - name: 'arrow', - enabled: false, - }, - { - name: 'offset', - options: { - offset: [0, 0], - }, - }, - ]} - > - {({ ref, style, placement }) => { - return ( - <div ref={ref} style={style} data-placement={placement} className={styles.suggestionsWrapper}> - <CustomScrollbar - scrollTop={scrollTop} - autoHeightMax="300px" - setScrollTop={({ scrollTop }) => setScrollTop(scrollTop)} - > - {/* This suggestion component has a specialized name, - but is rather generalistic in implementation, - so we're using it in transformations also. - We should probably rename this to something more general. */} - <DataLinkSuggestions - activeRef={activeRef} - suggestions={suggestions} - onSuggestionSelect={onVariableSelect} - onClose={() => setShowingSuggestions(false)} - activeIndex={suggestionsIndex} - /> - </CustomScrollbar> - </div> - ); - }} - </ReactPopper> + <div ref={refs.setFloating} style={floatingStyles} className={styles.suggestionsWrapper}> + <CustomScrollbar + scrollTop={scrollTop} + autoHeightMax="300px" + setScrollTop={({ scrollTop }) => setScrollTop(scrollTop)} + > + {/* This suggestion component has a specialized name, + but is rather generalistic in implementation, + so we're using it in transformations also. + We should probably rename this to something more general. */} + <DataLinkSuggestions + activeRef={activeRef} + suggestions={suggestions} + onSuggestionSelect={onVariableSelect} + onClose={() => setShowingSuggestions(false)} + activeIndex={suggestionsIndex} + /> + </CustomScrollbar> + </div> </Portal> )} {invalid && error && ( @@ -194,7 +195,7 @@ export const SuggestionsInput = ({ <Input placeholder={placeholder} invalid={invalid} - ref={inputRef} + ref={handleRef} value={variableValue} onChange={onValueChanged} onBlur={onBlur} diff --git a/public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx b/public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx deleted file mode 100644 index 3d5607e92ed4b..0000000000000 --- a/public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import React, { ComponentProps } from 'react'; -import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; - -import { selectors } from '@grafana/e2e-selectors'; -import { mockDataSource } from 'app/features/alerting/unified/mocks'; -import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; - -import { adHocBuilder } from '../shared/testing/builders'; - -import { AdHocVariableEditorUnConnected as AdHocVariableEditor } from './AdHocVariableEditor'; - -const promDsMock = mockDataSource({ - name: 'Prometheus', - type: DataSourceType.Prometheus, -}); - -const lokiDsMock = mockDataSource({ - name: 'Loki', - type: DataSourceType.Loki, -}); - -jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => { - return { - getDataSourceSrv: () => ({ - get: () => { - return Promise.resolve(promDsMock); - }, - getList: () => [promDsMock, lokiDsMock], - getInstanceSettings: (v: string) => { - if (v === 'Prometheus') { - return promDsMock; - } - return lokiDsMock; - }, - }), - }; -}); - -const props = { - extended: { - dataSources: [ - { text: 'Prometheus', value: null }, // default datasource - { text: 'Loki', value: { type: 'loki-ds', uid: 'abc' } }, - ], - } as ComponentProps<typeof AdHocVariableEditor>['extended'], - variable: adHocBuilder().withId('adhoc').withRootStateKey('key').withName('adhoc').build(), - onPropChange: jest.fn(), - - // connected actions - initAdHocVariableEditor: jest.fn(), - changeVariableDatasource: jest.fn(), -}; - -describe('AdHocVariableEditor', () => { - beforeEach(() => { - props.changeVariableDatasource.mockReset(); - }); - - it('has a datasource select menu', async () => { - render(<AdHocVariableEditor {...props} />); - - expect(await screen.getByTestId(selectors.components.DataSourcePicker.container)).toBeInTheDocument(); - }); - - it('calls the callback when changing the datasource', async () => { - render(<AdHocVariableEditor {...props} />); - const selectEl = screen - .getByTestId(selectors.components.DataSourcePicker.container) - .getElementsByTagName('input')[0]; - await selectOptionInTest(selectEl, 'Loki'); - - expect(props.changeVariableDatasource).toBeCalledWith( - { type: 'adhoc', id: 'adhoc', rootStateKey: 'key' }, - { type: 'loki', uid: 'mock-ds-3' } - ); - }); - - it('renders informational text', () => { - const extended = { - ...props.extended, - infoText: "Here's a message that should help you", - }; - render(<AdHocVariableEditor {...props} extended={extended} />); - - const alert = screen.getByText("Here's a message that should help you"); - expect(alert).toBeInTheDocument(); - }); -}); diff --git a/public/app/features/variables/adhoc/AdHocVariableEditor.tsx b/public/app/features/variables/adhoc/AdHocVariableEditor.tsx index 1b466c935918e..8bd63d443096d 100644 --- a/public/app/features/variables/adhoc/AdHocVariableEditor.tsx +++ b/public/app/features/variables/adhoc/AdHocVariableEditor.tsx @@ -2,11 +2,9 @@ import React, { PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data'; -import { Alert, Field } from '@grafana/ui'; -import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; +import { AdHocVariableForm } from 'app/features/dashboard-scene/settings/variables/components/AdHocVariableForm'; import { StoreState } from 'app/types'; -import { VariableLegend } from '../editor/VariableLegend'; import { initialVariableEditorState } from '../editor/reducer'; import { getAdhocVariableEditorState } from '../editor/selectors'; import { VariableEditorProps } from '../editor/types'; @@ -58,23 +56,13 @@ export class AdHocVariableEditorUnConnected extends PureComponent<Props> { render() { const { variable, extended } = this.props; - const infoText = extended?.infoText ?? null; return ( - <> - <VariableLegend>Ad-hoc options</VariableLegend> - <Field label="Data source" htmlFor="data-source-picker"> - <DataSourcePicker - current={variable.datasource} - onChange={this.onDatasourceChanged} - width={30} - variables={true} - noDefault - /> - </Field> - - {infoText ? <Alert title={infoText} severity="info" /> : null} - </> + <AdHocVariableForm + datasource={variable.datasource ?? undefined} + onDataSourceChange={this.onDatasourceChanged} + infoText={extended?.infoText} + /> ); } } diff --git a/public/app/features/variables/adhoc/actions.ts b/public/app/features/variables/adhoc/actions.ts index d0dfd6b631d6e..91d1ed8ef9c04 100644 --- a/public/app/features/variables/adhoc/actions.ts +++ b/public/app/features/variables/adhoc/actions.ts @@ -149,7 +149,12 @@ const createAdHocVariable = (options: AdHocTableOptions): ThunkResult<void> => { const getVariableByOptions = (options: AdHocTableOptions, state: StoreState): AdHocVariableModel | undefined => { const key = getLastKey(state); const templatingState = getVariablesState(key, state); - return Object.values(templatingState.variables).find( - (v) => isAdHoc(v) && v.datasource?.uid === options.datasource.uid - ) as AdHocVariableModel; + let result: AdHocVariableModel | undefined; + for (const v of Object.values(templatingState.variables)) { + if (isAdHoc(v) && v.datasource?.uid === options.datasource.uid) { + result = v; + break; + } + } + return result; }; diff --git a/public/app/features/variables/constant/ConstantVariableEditor.tsx b/public/app/features/variables/constant/ConstantVariableEditor.tsx index abb49bbd8b80f..bcabde359ad57 100644 --- a/public/app/features/variables/constant/ConstantVariableEditor.tsx +++ b/public/app/features/variables/constant/ConstantVariableEditor.tsx @@ -1,9 +1,7 @@ import React, { FormEvent, PureComponent } from 'react'; -import { selectors } from '@grafana/e2e-selectors'; +import { ConstantVariableForm } from 'app/features/dashboard-scene/settings/variables/components/ConstantVariableForm'; -import { VariableLegend } from '../editor/VariableLegend'; -import { VariableTextField } from '../editor/VariableTextField'; import { VariableEditorProps } from '../editor/types'; import { ConstantVariableModel } from '../types'; @@ -11,13 +9,6 @@ export interface Props extends VariableEditorProps<ConstantVariableModel> {} export class ConstantVariableEditor extends PureComponent<Props> { onChange = (event: FormEvent<HTMLInputElement>) => { - this.props.onPropChange({ - propName: 'query', - propValue: event.currentTarget.value, - }); - }; - - onBlur = (event: FormEvent<HTMLInputElement>) => { this.props.onPropChange({ propName: 'query', propValue: event.currentTarget.value, @@ -26,19 +17,6 @@ export class ConstantVariableEditor extends PureComponent<Props> { }; render() { - return ( - <> - <VariableLegend>Constant options</VariableLegend> - <VariableTextField - value={this.props.variable.query} - name="Value" - placeholder="your metric prefix" - onChange={this.onChange} - onBlur={this.onBlur} - testId={selectors.pages.Dashboard.Settings.Variables.Edit.ConstantVariable.constantOptionsQueryInputV2} - width={30} - /> - </> - ); + return <ConstantVariableForm constantValue={this.props.variable.query} onChange={this.onChange} />; } } diff --git a/public/app/features/variables/custom/CustomVariableEditor.tsx b/public/app/features/variables/custom/CustomVariableEditor.tsx index c64c5a29fe1f5..6cdd6d5eb2d03 100644 --- a/public/app/features/variables/custom/CustomVariableEditor.tsx +++ b/public/app/features/variables/custom/CustomVariableEditor.tsx @@ -1,13 +1,10 @@ import React, { FormEvent, PureComponent } from 'react'; import { MapDispatchToProps, MapStateToProps } from 'react-redux'; -import { selectors } from '@grafana/e2e-selectors'; import { connectWithStore } from 'app/core/utils/connectWithReduxStore'; +import { CustomVariableForm } from 'app/features/dashboard-scene/settings/variables/components/CustomVariableForm'; import { StoreState } from 'app/types'; -import { SelectionOptionsEditor } from '../editor/SelectionOptionsEditor'; -import { VariableLegend } from '../editor/VariableLegend'; -import { VariableTextAreaField } from '../editor/VariableTextAreaField'; import { OnPropChangeArguments, VariableEditorProps } from '../editor/types'; import { changeVariableMultiValue } from '../state/actions'; import { CustomVariableModel, VariableWithMultiSupport } from '../types'; @@ -23,18 +20,11 @@ interface DispatchProps { export type Props = OwnProps & ConnectedProps & DispatchProps; class CustomVariableEditorUnconnected extends PureComponent<Props> { - onChange = (event: FormEvent<HTMLTextAreaElement>) => { - this.props.onPropChange({ - propName: 'query', - propValue: event.currentTarget.value, - }); - }; - onSelectionOptionsChange = async ({ propName, propValue }: OnPropChangeArguments<VariableWithMultiSupport>) => { this.props.onPropChange({ propName, propValue, updateOptions: true }); }; - onBlur = (event: FormEvent<HTMLTextAreaElement>) => { + onQueryChange = (event: FormEvent<HTMLTextAreaElement>) => { this.props.onPropChange({ propName: 'query', propValue: event.currentTarget.value, @@ -44,26 +34,22 @@ class CustomVariableEditorUnconnected extends PureComponent<Props> { render() { return ( - <> - <VariableLegend>Custom options</VariableLegend> - - <VariableTextAreaField - name="Values separated by comma" - value={this.props.variable.query} - placeholder="1, 10, mykey : myvalue, myvalue, escaped\,value" - onChange={this.onChange} - onBlur={this.onBlur} - required - width={52} - testId={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput} - /> - <VariableLegend>Selection options</VariableLegend> - <SelectionOptionsEditor - variable={this.props.variable} - onPropChange={this.onSelectionOptionsChange} - onMultiChanged={this.props.changeVariableMultiValue} - /> - </> + <CustomVariableForm + query={this.props.variable.query} + multi={this.props.variable.multi} + allValue={this.props.variable.allValue} + includeAll={this.props.variable.includeAll} + onQueryChange={this.onQueryChange} + onMultiChange={(event) => + this.onSelectionOptionsChange({ propName: 'multi', propValue: event.currentTarget.checked }) + } + onIncludeAllChange={(event) => + this.onSelectionOptionsChange({ propName: 'includeAll', propValue: event.currentTarget.checked }) + } + onAllValueChange={(event) => + this.onSelectionOptionsChange({ propName: 'allValue', propValue: event.currentTarget.value }) + } + /> ); } } diff --git a/public/app/features/variables/datasource/DataSourceVariableEditor.test.tsx b/public/app/features/variables/datasource/DataSourceVariableEditor.test.tsx index 661a86716695c..ec4c34245ea7d 100644 --- a/public/app/features/variables/datasource/DataSourceVariableEditor.test.tsx +++ b/public/app/features/variables/datasource/DataSourceVariableEditor.test.tsx @@ -1,4 +1,5 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { selectOptionInTest, getSelectParent } from 'test/helpers/selectOptionInTest'; @@ -45,10 +46,21 @@ describe('DataSourceVariableEditor', () => { expect(field).toBeInTheDocument(); }); - it('calls the handler when the regex filter is changed', () => { - render(<DataSourceVariableEditor {...props} />); + it('calls the handler when the regex filter is changed in onBlur', async () => { + const { user } = setup(<DataSourceVariableEditor {...props} />); const field = screen.getByLabelText(/Instance name filter/); - fireEvent.change(field, { target: { value: '/prod/' } }); - expect(props.onPropChange).toBeCalledWith({ propName: 'regex', propValue: '/prod/' }); + await user.click(field); + await user.type(field, '/prod/'); + expect(field).toHaveValue('/prod/'); + await user.tab(); + expect(props.onPropChange).toHaveBeenCalledWith({ propName: 'regex', propValue: '/prod/', updateOptions: true }); }); }); + +// based on styleguide recomendation +function setup(jsx: JSX.Element) { + return { + user: userEvent.setup(), + ...render(jsx), + }; +} diff --git a/public/app/features/variables/datasource/DataSourceVariableEditor.tsx b/public/app/features/variables/datasource/DataSourceVariableEditor.tsx index 6185d257e9fef..3fb6996aed398 100644 --- a/public/app/features/variables/datasource/DataSourceVariableEditor.tsx +++ b/public/app/features/variables/datasource/DataSourceVariableEditor.tsx @@ -2,19 +2,16 @@ import React, { FormEvent, PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { SelectableValue } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; +import { DataSourceVariableForm } from 'app/features/dashboard-scene/settings/variables/components/DataSourceVariableForm'; import { StoreState } from '../../../types'; -import { SelectionOptionsEditor } from '../editor/SelectionOptionsEditor'; -import { VariableLegend } from '../editor/VariableLegend'; -import { VariableSelectField } from '../editor/VariableSelectField'; -import { VariableTextField } from '../editor/VariableTextField'; import { initialVariableEditorState } from '../editor/reducer'; import { getDatasourceVariableEditorState } from '../editor/selectors'; import { OnPropChangeArguments, VariableEditorProps } from '../editor/types'; import { changeVariableMultiValue } from '../state/actions'; import { getVariablesState } from '../state/selectors'; import { DataSourceVariableModel, VariableWithMultiSupport } from '../types'; +import { toKeyedVariableIdentifier } from '../utils'; import { initDataSourceVariableEditor } from './actions'; @@ -57,13 +54,6 @@ export class DataSourceVariableEditorUnConnected extends PureComponent<Props> { this.props.initDataSourceVariableEditor(rootStateKey); } - onRegExChange = (event: FormEvent<HTMLInputElement>) => { - this.props.onPropChange({ - propName: 'regex', - propValue: event.currentTarget.value, - }); - }; - onRegExBlur = (event: FormEvent<HTMLInputElement>) => { this.props.onPropChange({ propName: 'regex', @@ -76,6 +66,18 @@ export class DataSourceVariableEditorUnConnected extends PureComponent<Props> { this.props.onPropChange({ propName, propValue, updateOptions: true }); }; + onMultiChanged = (event: FormEvent<HTMLInputElement>) => { + this.props.changeVariableMultiValue(toKeyedVariableIdentifier(this.props.variable), event.currentTarget.checked); + }; + + onIncludeAllChanged = (event: FormEvent<HTMLInputElement>) => { + this.onSelectionOptionsChange({ propName: 'includeAll', propValue: event.currentTarget.checked }); + }; + + onAllValueChanged = (event: FormEvent<HTMLInputElement>) => { + this.onSelectionOptionsChange({ propName: 'allValue', propValue: event.currentTarget.value }); + }; + getSelectedDataSourceTypeValue = (): string => { const { extended } = this.props; @@ -93,49 +95,25 @@ export class DataSourceVariableEditorUnConnected extends PureComponent<Props> { }; render() { - const { variable, extended, changeVariableMultiValue } = this.props; + const { variable, extended } = this.props; const typeOptions = extended?.dataSourceTypes?.length ? extended.dataSourceTypes?.map((ds) => ({ value: ds.value ?? '', label: ds.text })) : []; - const typeValue = typeOptions.find((o) => o.value === variable.query) ?? typeOptions[0]; - return ( - <> - <VariableLegend>Data source options</VariableLegend> - <VariableSelectField - name="Type" - value={typeValue} - options={typeOptions} - onChange={this.onDataSourceTypeChanged} - testId={selectors.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.datasourceSelect} - /> - - <VariableTextField - value={this.props.variable.regex} - name="Instance name filter" - placeholder="/.*-(.*)-.*/" - onChange={this.onRegExChange} - onBlur={this.onRegExBlur} - description={ - <div> - Regex filter for which data source instances to choose from in the variable value list. Leave empty for - all. - <br /> - <br /> - Example: <code>/^prod/</code> - </div> - } - /> - - <VariableLegend>Selection options</VariableLegend> - <SelectionOptionsEditor - variable={variable} - onPropChange={this.onSelectionOptionsChange} - onMultiChanged={changeVariableMultiValue} - /> - </> + <DataSourceVariableForm + query={variable.query} + regex={variable.regex} + multi={variable.multi} + includeAll={variable.includeAll} + optionTypes={typeOptions} + onChange={this.onDataSourceTypeChanged} + onRegExBlur={this.onRegExBlur} + onMultiChange={this.onMultiChanged} + onIncludeAllChange={this.onIncludeAllChanged} + onAllValueChange={this.onAllValueChanged} + /> ); } } diff --git a/public/app/features/variables/editor/LegacyVariableQueryEditor.tsx b/public/app/features/variables/editor/LegacyVariableQueryEditor.tsx index 53e6b2226e96c..2b5f3e24317a3 100644 --- a/public/app/features/variables/editor/LegacyVariableQueryEditor.tsx +++ b/public/app/features/variables/editor/LegacyVariableQueryEditor.tsx @@ -4,10 +4,9 @@ import React, { useCallback, useState } from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { TextArea, useStyles2 } from '@grafana/ui'; +import { getStyles } from '../../dashboard-scene/settings/variables/components/VariableTextAreaField'; import { VariableQueryEditorProps } from '../types'; -import { getStyles } from './VariableTextAreaField'; - export const LEGACY_VARIABLE_QUERY_EDITOR_NAME = 'Grafana-LegacyVariableQueryEditor'; export const LegacyVariableQueryEditor = ({ onChange, query }: VariableQueryEditorProps) => { @@ -35,7 +34,7 @@ export const LegacyVariableQueryEditor = ({ onChange, query }: VariableQueryEdit onBlur={onBlur} placeholder="Metric name or tags query" required - aria-label={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput} + data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput} cols={52} className={styles.textarea} /> diff --git a/public/app/features/variables/editor/SelectionOptionsEditor.tsx b/public/app/features/variables/editor/SelectionOptionsEditor.tsx index 06cf1525b71fe..75b38b68e6789 100644 --- a/public/app/features/variables/editor/SelectionOptionsEditor.tsx +++ b/public/app/features/variables/editor/SelectionOptionsEditor.tsx @@ -1,14 +1,11 @@ import React, { ChangeEvent, FormEvent, useCallback } from 'react'; -import { selectors } from '@grafana/e2e-selectors'; -import { VerticalGroup } from '@grafana/ui'; +import { SelectionOptionsForm } from 'app/features/dashboard-scene/settings/variables/components/SelectionOptionsForm'; import { KeyedVariableIdentifier } from '../state/types'; import { VariableWithMultiSupport } from '../types'; import { toKeyedVariableIdentifier } from '../utils'; -import { VariableCheckboxField } from './VariableCheckboxField'; -import { VariableTextField } from './VariableTextField'; import { VariableEditorProps } from './types'; export interface SelectionOptionsEditorProps<Model extends VariableWithMultiSupport = VariableWithMultiSupport> @@ -43,29 +40,14 @@ export const SelectionOptionsEditor = ({ ); return ( - <VerticalGroup spacing="md" height="inherit"> - <VariableCheckboxField - value={variable.multi} - name="Multi-value" - description="Enables multiple values to be selected at the same time" - onChange={onMultiChanged} - /> - <VariableCheckboxField - value={variable.includeAll} - name="Include All option" - description="Enables an option to include all variables" - onChange={onIncludeAllChanged} - /> - {variable.includeAll && ( - <VariableTextField - value={variable.allValue ?? ''} - onChange={onAllValueChanged} - name="Custom all value" - placeholder="blank = auto" - testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInputV2} - /> - )} - </VerticalGroup> + <SelectionOptionsForm + multi={variable.multi} + includeAll={variable.includeAll} + allValue={variable.allValue} + onMultiChange={onMultiChanged} + onIncludeAllChange={onIncludeAllChanged} + onAllValueChange={onAllValueChanged} + /> ); }; SelectionOptionsEditor.displayName = 'SelectionOptionsEditor'; diff --git a/public/app/features/variables/editor/VariableEditorContainer.tsx b/public/app/features/variables/editor/VariableEditorContainer.tsx index 166e4082767fd..41ccf9daf765f 100644 --- a/public/app/features/variables/editor/VariableEditorContainer.tsx +++ b/public/app/features/variables/editor/VariableEditorContainer.tsx @@ -2,7 +2,7 @@ import React, { PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { config, locationService } from '@grafana/runtime'; +import { locationService } from '@grafana/runtime'; import { Page } from 'app/core/components/Page/Page'; import { SettingsPageProps } from 'app/features/dashboard/components/DashboardSettings/types'; @@ -108,13 +108,12 @@ class VariableEditorContainerUnconnected extends PureComponent<Props, State> { const { editIndex, variables, sectionNav } = this.props; const variableToEdit = editIndex != null ? variables[editIndex] : undefined; const node = sectionNav.node; - const parentItem = - config.featureToggles.dockedMegaMenu && node.parentItem - ? { - ...node.parentItem, - url: node.url, - } - : undefined; + const parentItem = node.parentItem + ? { + ...node.parentItem, + url: node.url, + } + : undefined; const subPageNav = variableToEdit ? { text: variableToEdit.name, parentItem } : parentItem; return ( diff --git a/public/app/features/variables/editor/VariableEditorEditor.tsx b/public/app/features/variables/editor/VariableEditorEditor.tsx index 5ebb4ce7ebdc3..d36433531ef51 100644 --- a/public/app/features/variables/editor/VariableEditorEditor.tsx +++ b/public/app/features/variables/editor/VariableEditorEditor.tsx @@ -8,6 +8,11 @@ import { locationService } from '@grafana/runtime'; import { Button, HorizontalGroup, Icon } from '@grafana/ui'; import { StoreState, ThunkDispatch } from '../../../types'; +import { VariableHideSelect } from '../../dashboard-scene/settings/variables/components/VariableHideSelect'; +import { VariableLegend } from '../../dashboard-scene/settings/variables/components/VariableLegend'; +import { VariableTextAreaField } from '../../dashboard-scene/settings/variables/components/VariableTextAreaField'; +import { VariableTextField } from '../../dashboard-scene/settings/variables/components/VariableTextField'; +import { VariableValuesPreview } from '../../dashboard-scene/settings/variables/components/VariableValuesPreview'; import { variableAdapters } from '../adapters'; import { hasOptions } from '../guard'; import { updateOptions } from '../state/actions'; @@ -19,12 +24,7 @@ import { VariableHide } from '../types'; import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; import { ConfirmDeleteModal } from './ConfirmDeleteModal'; -import { VariableHideSelect } from './VariableHideSelect'; -import { VariableLegend } from './VariableLegend'; -import { VariableTextAreaField } from './VariableTextAreaField'; -import { VariableTextField } from './VariableTextField'; import { VariableTypeSelect } from './VariableTypeSelect'; -import { VariableValuesPreview } from './VariableValuesPreview'; import { changeVariableName, variableEditorMount, variableEditorUnMount } from './actions'; import { OnPropChangeArguments, VariableNameConstraints } from './types'; @@ -138,6 +138,14 @@ export class VariableEditorEditorUnConnected extends PureComponent<Props, State> locationService.partial({ editIndex: null }); }; + getVariableOptions = () => { + const { variable } = this.props; + if (!hasOptions(variable)) { + return []; + } + return variable.options.map((option) => ({ label: String(option.text), value: String(option.value) })); + }; + render() { const { variable } = this.props; const EditorToRender = variableAdapters.get(this.props.variable.type).editor; @@ -188,7 +196,7 @@ export class VariableEditorEditorUnConnected extends PureComponent<Props, State> {EditorToRender && <EditorToRender variable={this.props.variable} onPropChange={this.onPropChanged} />} - {hasOptions(this.props.variable) ? <VariableValuesPreview variable={this.props.variable} /> : null} + {hasOptions(this.props.variable) ? <VariableValuesPreview options={this.getVariableOptions()} /> : null} <div style={{ marginTop: '16px' }}> <HorizontalGroup spacing="md" height="inherit"> @@ -197,7 +205,7 @@ export class VariableEditorEditorUnConnected extends PureComponent<Props, State> </Button> <Button type="submit" - aria-label={selectors.pages.Dashboard.Settings.Variables.Edit.General.submitButton} + data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.General.submitButton} disabled={loading} variant="secondary" > diff --git a/public/app/features/variables/editor/VariableTypeSelect.tsx b/public/app/features/variables/editor/VariableTypeSelect.tsx index 1963e23dc5e2b..65d80f7d83e8d 100644 --- a/public/app/features/variables/editor/VariableTypeSelect.tsx +++ b/public/app/features/variables/editor/VariableTypeSelect.tsx @@ -3,7 +3,7 @@ import React, { PropsWithChildren, useMemo } from 'react'; import { SelectableValue, VariableType } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { VariableSelectField } from '../editor/VariableSelectField'; +import { VariableSelectField } from '../../dashboard-scene/settings/variables/components/VariableSelectField'; import { getVariableTypes } from '../utils'; interface Props { diff --git a/public/app/features/variables/guard.test.ts b/public/app/features/variables/guard.test.ts index 1afcf66bbef3a..cd65f21e621ca 100644 --- a/public/app/features/variables/guard.test.ts +++ b/public/app/features/variables/guard.test.ts @@ -19,6 +19,7 @@ import { createCustomVariable, createDashboardVariable, createDatasourceVariable, + createGroupByVariable, createIntervalVariable, createOrgVariable, createQueryVariable, @@ -164,6 +165,7 @@ describe('type guards', () => { const variableFactsObj: Record<VariableType | ExtraVariableTypes, VariableFacts> = { query: { variable: createQueryVariable(), isMulti: true, hasOptions: true, hasCurrent: true }, adhoc: { variable: createAdhocVariable(), isMulti: false, hasOptions: false, hasCurrent: false }, + groupby: { variable: createGroupByVariable(), isMulti: true, hasOptions: true, hasCurrent: true }, constant: { variable: createConstantVariable(), isMulti: false, hasOptions: true, hasCurrent: true }, datasource: { variable: createDatasourceVariable(), isMulti: true, hasOptions: true, hasCurrent: true }, interval: { variable: createIntervalVariable(), isMulti: false, hasOptions: true, hasCurrent: true }, diff --git a/public/app/features/variables/interval/IntervalVariableEditor.tsx b/public/app/features/variables/interval/IntervalVariableEditor.tsx index 0aae4badf2f48..88504924769d4 100644 --- a/public/app/features/variables/interval/IntervalVariableEditor.tsx +++ b/public/app/features/variables/interval/IntervalVariableEditor.tsx @@ -1,21 +1,10 @@ -import { css } from '@emotion/css'; import React, { ChangeEvent, FormEvent } from 'react'; -import { GrafanaTheme2, IntervalVariableModel, SelectableValue } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { useStyles2 } from '@grafana/ui'; +import { IntervalVariableModel, SelectableValue } from '@grafana/data'; +import { IntervalVariableForm } from 'app/features/dashboard-scene/settings/variables/components/IntervalVariableForm'; -import { VariableCheckboxField } from '../editor/VariableCheckboxField'; -import { VariableLegend } from '../editor/VariableLegend'; -import { VariableSelectField } from '../editor/VariableSelectField'; -import { VariableTextField } from '../editor/VariableTextField'; import { VariableEditorProps } from '../editor/types'; -const STEP_OPTIONS = [1, 2, 3, 4, 5, 10, 20, 30, 40, 50, 100, 200, 300, 400, 500].map((count) => ({ - label: `${count}`, - value: count, -})); - export interface Props extends VariableEditorProps<IntervalVariableModel> {} export const IntervalVariableEditor = React.memo(({ onPropChange, variable }: Props) => { @@ -27,13 +16,6 @@ export const IntervalVariableEditor = React.memo(({ onPropChange, variable }: Pr }); }; - const onQueryChanged = (event: FormEvent<HTMLInputElement>) => { - onPropChange({ - propName: 'query', - propValue: event.currentTarget.value, - }); - }; - const onQueryBlur = (event: FormEvent<HTMLInputElement>) => { onPropChange({ propName: 'query', @@ -58,62 +40,18 @@ export const IntervalVariableEditor = React.memo(({ onPropChange, variable }: Pr }); }; - const stepValue = STEP_OPTIONS.find((o) => o.value === variable.auto_count) ?? STEP_OPTIONS[0]; - - const styles = useStyles2(getStyles); - return ( - <> - <VariableLegend>Interval options</VariableLegend> - <VariableTextField - value={variable.query} - name="Values" - placeholder="1m,10m,1h,6h,1d,7d" - onChange={onQueryChanged} - onBlur={onQueryBlur} - testId={selectors.pages.Dashboard.Settings.Variables.Edit.IntervalVariable.intervalsValueInput} - width={32} - required - /> - - <VariableCheckboxField - value={variable.auto} - name="Auto option" - description="Dynamically calculates interval by dividing time range by the count specified" - onChange={onAutoChange} - /> - {variable.auto && ( - <div className={styles.autoFields}> - <VariableSelectField - name="Step count" - description="How many times the current time range should be divided to calculate the value" - value={stepValue} - options={STEP_OPTIONS} - onChange={onAutoCountChanged} - width={9} - /> - <VariableTextField - value={variable.auto_min} - name="Min interval" - description="The calculated value will not go below this threshold" - placeholder="10s" - onChange={onAutoMinChanged} - width={11} - /> - </div> - )} - </> + <IntervalVariableForm + intervals={variable.query} + autoStepCount={variable.auto_count} + autoEnabled={variable.auto} + onAutoCountChanged={onAutoCountChanged} + onIntervalsChange={onQueryBlur} + onAutoEnabledChange={onAutoChange} + onAutoMinIntervalChanged={onAutoMinChanged} + autoMinInterval={variable.auto_min} + /> ); }); IntervalVariableEditor.displayName = 'IntervalVariableEditor'; - -function getStyles(theme: GrafanaTheme2) { - return { - autoFields: css({ - marginTop: theme.spacing(2), - display: 'flex', - flexDirection: 'column', - }), - }; -} diff --git a/public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx b/public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx index bb3a51b9edefe..316daba5c47e3 100644 --- a/public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx +++ b/public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx @@ -111,7 +111,7 @@ describe('OptionPicker', () => { variable: { ...defaultVariable, state: LoadingState.Loading }, }); expect(getSubMenu('A + C')).toBeInTheDocument(); - expect(screen.getByLabelText(selectors.components.LoadingIndicator.icon)).toBeInTheDocument(); + expect(screen.getByTestId(selectors.components.LoadingIndicator.icon)).toBeInTheDocument(); }); it('link text should not be clickable', async () => { diff --git a/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts b/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts index 28579740f0ff3..e5d1fc4678dc5 100644 --- a/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts +++ b/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts @@ -48,24 +48,24 @@ describe('optionsPickerReducer', () => { ]; const expectToggleOptionState = (args: { - options: any; - multi: any; - forceSelect: any; - clearOthers: any; - option: any; - expectSelected: any; + options: typeof opsAll; + multi: boolean; + forceSelect: boolean; + clearOthers: boolean; + option: string; + expectSelected: string[]; }) => { const { initialState } = getVariableTestContext({ options: args.options, multi: args.multi, - selectedValues: args.options.filter((o: any) => o.selected), + selectedValues: args.options.filter((o) => o.selected), }); const payload = { forceSelect: args.forceSelect, clearOthers: args.clearOthers, option: { text: args.option, value: args.option, selected: true }, }; - const expectedAsRecord = args.expectSelected.reduce((all: any, current: any) => { + const expectedAsRecord = args.expectSelected.reduce<Record<string, string>>((all, current) => { all[current] = current; return all; }, {}); @@ -75,8 +75,8 @@ describe('optionsPickerReducer', () => { .whenActionIsDispatched(toggleOption(payload)) .thenStateShouldEqual({ ...initialState, - selectedValues: args.expectSelected.map((value: any) => ({ value, text: value, selected: true })), - options: args.options.map((option: any) => { + selectedValues: args.expectSelected.map((value) => ({ value, text: value, selected: true })), + options: args.options.map((option) => { return { ...option, selected: !!expectedAsRecord[option.value] }; }), }); @@ -124,7 +124,17 @@ describe('optionsPickerReducer', () => { ${'$__all'} | ${false} | ${true} | ${['$__all']} `( 'and we toggle $option with options: { forceSelect: $forceSelect, clearOthers: $clearOthers } we expect $expectSelected to be selected', - ({ option, forceSelect, clearOthers, expectSelected }) => + ({ + option, + forceSelect, + clearOthers, + expectSelected, + }: { + option: string; + forceSelect: boolean; + clearOthers: boolean; + expectSelected: string[]; + }) => expectToggleOptionState({ options, multi, @@ -309,7 +319,7 @@ describe('optionsPickerReducer', () => { describe('when showOptions is dispatched and queryValue and variable has no searchFilter', () => { it('then state should be correct', () => { const query = '*.'; - const queryValue: any = null; + const queryValue = null; const current = { text: ALL_VARIABLE_TEXT, selected: true, value: [ALL_VARIABLE_VALUE] }; const options = [ { text: 'All', value: '$__all', selected: true }, @@ -317,7 +327,7 @@ describe('optionsPickerReducer', () => { { text: 'B', value: 'B', selected: false }, ]; const { initialState } = getVariableTestContext({}); - const payload = { type: 'query', id: '0', current, query, options, queryValue } as QueryVariableModel; + const payload = { type: 'query', id: '0', current, query, options, queryValue } as unknown as QueryVariableModel; reducerTester<OptionsPickerState>() .givenReducer(optionsPickerReducer, cloneDeep(initialState)) @@ -546,11 +556,11 @@ describe('optionsPickerReducer', () => { it('then state should be correct', () => { const queryValue = 'A'; - const options: any = [ + const options = [ { text: 'All', value: '$__all', selected: true }, { text: null, value: null, selected: false }, { text: [null], value: [null], selected: false }, - ]; + ] as VariableOption[]; const { initialState } = getVariableTestContext({ queryValue }); diff --git a/public/app/features/variables/pickers/shared/VariableLink.tsx b/public/app/features/variables/pickers/shared/VariableLink.tsx index 246a7a9cd1f78..206056e750c1b 100644 --- a/public/app/features/variables/pickers/shared/VariableLink.tsx +++ b/public/app/features/variables/pickers/shared/VariableLink.tsx @@ -3,7 +3,8 @@ import React, { MouseEvent, useCallback } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { Icon, Tooltip, useStyles2 } from '@grafana/ui'; +import { Icon, useStyles2 } from '@grafana/ui'; +import { LoadingIndicator } from '@grafana/ui/src/components/PanelChrome/LoadingIndicator'; import { t } from 'app/core/internationalization'; import { ALL_VARIABLE_TEXT } from '../../constants'; @@ -40,7 +41,7 @@ export const VariableLink = ({ loading, disabled, onClick: propsOnClick, text, o id={id} > <VariableLinkText text={text} /> - <LoadingIndicator onCancel={onCancel} /> + <LoadingIndicator loading onCancel={onCancel} /> </div> ); } @@ -75,28 +76,6 @@ const VariableLinkText = ({ text }: VariableLinkTextProps) => { ); }; -const LoadingIndicator = ({ onCancel }: Pick<Props, 'onCancel'>) => { - const onClick = useCallback( - (event: MouseEvent) => { - event.preventDefault(); - onCancel(); - }, - [onCancel] - ); - - return ( - <Tooltip content="Cancel query"> - <Icon - className="spin-clockwise" - name="sync" - size="sm" - onClick={onClick} - aria-label={selectors.components.LoadingIndicator.icon} - /> - </Tooltip> - ); -}; - const getStyles = (theme: GrafanaTheme2) => ({ container: css` max-width: 500px; diff --git a/public/app/features/variables/query/QueryVariableEditor.test.tsx b/public/app/features/variables/query/QueryVariableEditor.test.tsx index 03a1884428e18..8709c8763e45d 100644 --- a/public/app/features/variables/query/QueryVariableEditor.test.tsx +++ b/public/app/features/variables/query/QueryVariableEditor.test.tsx @@ -1,8 +1,10 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { MockDataSourceApi } from 'test/mocks/datasource_srv'; -import { DataSourceApi, VariableSupportType } from '@grafana/data'; +import { VariableSupportType } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { mockDataSource } from 'app/features/alerting/unified/mocks'; import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; @@ -14,11 +16,25 @@ import { QueryVariableModel } from '../types'; import { Props, QueryVariableEditorUnConnected } from './QueryVariableEditor'; import { initialQueryVariableModelState } from './reducer'; -const setupTestContext = (options: Partial<Props>) => { +const mockDS = mockDataSource({ + name: 'CloudManager', + type: DataSourceType.Alertmanager, +}); +const ds = new MockDataSourceApi(mockDS); +const editor = jest.fn().mockImplementation(LegacyVariableQueryEditor); + +ds.variables = { + getType: () => VariableSupportType.Custom, + query: jest.fn(), + editor: editor, + getDefaultQuery: jest.fn(), +}; + +const setupTestContext = async (options: Partial<Props>) => { const variableDefaults: Partial<QueryVariableModel> = { rootStateKey: 'key' }; const extended = { VariableQueryEditor: LegacyVariableQueryEditor, - dataSource: {} as unknown as DataSourceApi, + dataSource: ds, }; const defaults: Props = { @@ -31,21 +47,16 @@ const setupTestContext = (options: Partial<Props>) => { onPropChange: jest.fn(), }; - const props: Props & Record<string, any> = { ...defaults, ...options }; - const { rerender } = render(<QueryVariableEditorUnConnected {...props} />); + const props: Props & Record<string, unknown> = { ...defaults, ...options }; + const { rerender } = await act(() => render(<QueryVariableEditorUnConnected {...props} />)); return { rerender, props }; }; -const mockDS = mockDataSource({ - name: 'CloudManager', - type: DataSourceType.Alertmanager, -}); - jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => { return { getDataSourceSrv: () => ({ - get: () => Promise.resolve(mockDS), + get: async () => ds, getList: () => [mockDS], getInstanceSettings: () => mockDS, }), @@ -56,8 +67,8 @@ const defaultIdentifier: KeyedVariableIdentifier = { type: 'query', rootStateKey describe('QueryVariableEditor', () => { describe('when the component is mounted', () => { - it('then it should call initQueryVariableEditor', () => { - const { props } = setupTestContext({}); + it('then it should call initQueryVariableEditor', async () => { + const { props } = await setupTestContext({}); expect(props.initQueryVariableEditor).toHaveBeenCalledTimes(1); expect(props.initQueryVariableEditor).toHaveBeenCalledWith(defaultIdentifier); @@ -65,50 +76,29 @@ describe('QueryVariableEditor', () => { }); describe('when the editor is rendered', () => { - const extendedCustom = { - extended: { - VariableQueryEditor: jest.fn().mockImplementation(LegacyVariableQueryEditor), - dataSource: { - variables: { - getType: () => VariableSupportType.Custom, - query: jest.fn(), - editor: jest.fn(), - }, - } as unknown as DataSourceApi, - }, - }; - it('should pass down the query with default values if the datasource config defines it', () => { - const extended = { ...extendedCustom }; - extended.extended.dataSource.variables!.getDefaultQuery = jest - .fn() - .mockImplementation(() => 'some default query'); - const { props } = setupTestContext(extended); - expect(props.extended?.dataSource?.variables?.getDefaultQuery).toBeDefined(); - expect(props.extended?.dataSource?.variables?.getDefaultQuery).toHaveBeenCalledTimes(1); - expect(props.extended?.VariableQueryEditor).toHaveBeenCalledWith( - expect.objectContaining({ query: 'some default query' }), - expect.anything() - ); + beforeEach(() => { + jest.clearAllMocks(); }); - it('should not pass down a default query if the datasource config doesnt define it', () => { - extendedCustom.extended.dataSource.variables!.getDefaultQuery = undefined; - const { props } = setupTestContext(extendedCustom); - expect(props.extended?.dataSource?.variables?.getDefaultQuery).not.toBeDefined(); - expect(props.extended?.VariableQueryEditor).toHaveBeenCalledWith( - expect.objectContaining({ query: '' }), - expect.anything() - ); + + it('should pass down the query with default values if the datasource config defines it', async () => { + ds.variables!.getDefaultQuery = jest.fn().mockImplementationOnce(() => 'some default query'); + + await setupTestContext({}); + expect(ds.variables?.getDefaultQuery).toBeDefined(); + expect(ds.variables?.getDefaultQuery).toHaveBeenCalledTimes(1); + expect(editor.mock.calls[0][0].query).toBe('some default query'); }); }); + describe('when the user changes', () => { it.each` fieldName | propName | expectedArgs - ${'query'} | ${'changeQueryVariableQuery'} | ${[defaultIdentifier, 't', 't']} + ${'query'} | ${'changeQueryVariableQuery'} | ${[defaultIdentifier, 't', '']} ${'regex'} | ${'onPropChange'} | ${[{ propName: 'regex', propValue: 't', updateOptions: true }]} `( '$fieldName field and tabs away then $propName should be called with correct args', async ({ fieldName, propName, expectedArgs }) => { - const { props } = setupTestContext({}); + const { props } = await setupTestContext({}); const propUnderTest = props[propName]; const fieldAccessor = fieldAccessors[fieldName]; @@ -129,7 +119,7 @@ describe('QueryVariableEditor', () => { `( '$fieldName field but reverts the change and tabs away then $propName should not be called', async ({ fieldName, propName }) => { - const { props } = setupTestContext({}); + const { props } = await setupTestContext({}); const propUnderTest = props[propName]; const fieldAccessor = fieldAccessors[fieldName]; @@ -144,7 +134,7 @@ describe('QueryVariableEditor', () => { }); const getQueryField = () => - screen.getByRole('textbox', { name: /variable editor form default variable query editor textarea/i }); + screen.getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput); const getRegExField = () => screen.getByLabelText(/Regex/); diff --git a/public/app/features/variables/query/QueryVariableEditor.tsx b/public/app/features/variables/query/QueryVariableEditor.tsx index d3a3d6fe3501d..24ad37c2a6419 100644 --- a/public/app/features/variables/query/QueryVariableEditor.tsx +++ b/public/app/features/variables/query/QueryVariableEditor.tsx @@ -1,28 +1,19 @@ import React, { FormEvent, PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { DataSourceInstanceSettings, getDataSourceRef, LoadingState, SelectableValue } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { getTemplateSrv } from '@grafana/runtime'; -import { Field, Text, Box } from '@grafana/ui'; -import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; +import { DataSourceInstanceSettings, getDataSourceRef, SelectableValue } from '@grafana/data'; +import { QueryVariableEditorForm } from 'app/features/dashboard-scene/settings/variables/components/QueryVariableForm'; import { StoreState } from '../../../types'; import { getTimeSrv } from '../../dashboard/services/TimeSrv'; -import { SelectionOptionsEditor } from '../editor/SelectionOptionsEditor'; -import { VariableLegend } from '../editor/VariableLegend'; -import { VariableTextAreaField } from '../editor/VariableTextAreaField'; import { initialVariableEditorState } from '../editor/reducer'; import { getQueryVariableEditorState } from '../editor/selectors'; -import { OnPropChangeArguments, VariableEditorProps } from '../editor/types'; -import { isLegacyQueryEditor, isQueryEditor } from '../guard'; +import { VariableEditorProps } from '../editor/types'; import { changeVariableMultiValue } from '../state/actions'; import { getVariablesState } from '../state/selectors'; -import { QueryVariableModel, VariableRefresh, VariableSort, VariableWithMultiSupport } from '../types'; +import { QueryVariableModel, VariableRefresh, VariableSort } from '../types'; import { toKeyedVariableIdentifier } from '../utils'; -import { QueryVariableRefreshSelect } from './QueryVariableRefreshSelect'; -import { QueryVariableSortSelect } from './QueryVariableSortSelect'; import { changeQueryVariableDataSource, changeQueryVariableQuery, initQueryVariableEditor } from './actions'; const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { @@ -105,10 +96,6 @@ export class QueryVariableEditorUnConnected extends PureComponent<Props, State> } }; - onRegExChange = (event: FormEvent<HTMLTextAreaElement>) => { - this.setState({ regex: event.currentTarget.value }); - }; - onRegExBlur = async (event: FormEvent<HTMLTextAreaElement>) => { const regex = event.currentTarget.value; if (this.props.variable.regex !== regex) { @@ -124,125 +111,47 @@ export class QueryVariableEditorUnConnected extends PureComponent<Props, State> this.props.onPropChange({ propName: 'sort', propValue: option.value, updateOptions: true }); }; - onSelectionOptionsChange = async ({ propValue, propName }: OnPropChangeArguments<VariableWithMultiSupport>) => { - this.props.onPropChange({ propName, propValue, updateOptions: true }); + onMultiChange = (event: FormEvent<HTMLInputElement>) => { + this.props.onPropChange({ propName: 'multi', propValue: event.currentTarget.checked }); }; - renderQueryEditor = () => { - const { extended, variable } = this.props; + onIncludeAllChange = (event: FormEvent<HTMLInputElement>) => { + this.props.onPropChange({ propName: 'includeAll', propValue: event.currentTarget.checked }); + }; + + onAllValueChange = (event: FormEvent<HTMLInputElement>) => { + this.props.onPropChange({ propName: 'allValue', propValue: event.currentTarget.value }); + }; + render() { + const { extended, variable } = this.props; if (!extended || !extended.dataSource || !extended.VariableQueryEditor) { return null; } - const datasource = extended.dataSource; - const VariableQueryEditor = extended.VariableQueryEditor; - - let query = variable.query; - - if (typeof query === 'string') { - query = query || (datasource.variables?.getDefaultQuery?.() ?? ''); - } else { - query = { - ...datasource.variables?.getDefaultQuery?.(), - ...variable.query, - }; - } - - if (isLegacyQueryEditor(VariableQueryEditor, datasource)) { - return ( - <Box marginBottom={2}> - <Text element={'h4'}>Query</Text> - <Box marginTop={1}> - <VariableQueryEditor - key={datasource.uid} - datasource={datasource} - query={query} - templateSrv={getTemplateSrv()} - onChange={this.onLegacyQueryChange} - /> - </Box> - </Box> - ); - } - - const range = getTimeSrv().timeRange(); - - if (isQueryEditor(VariableQueryEditor, datasource)) { - return ( - <Box marginBottom={2}> - <Text element={'h4'}>Query</Text> - <Box marginTop={1}> - <VariableQueryEditor - key={datasource.uid} - datasource={datasource} - query={query} - onChange={this.onQueryChange} - onRunQuery={() => {}} - data={{ series: [], state: LoadingState.Done, timeRange: range }} - range={range} - onBlur={() => {}} - history={[]} - /> - </Box> - </Box> - ); - } + const timeRange = getTimeSrv().timeRange(); - return null; - }; - - render() { return ( - <> - <VariableLegend>Query options</VariableLegend> - <Field label="Data source" htmlFor="data-source-picker"> - <DataSourcePicker - current={this.props.variable.datasource} - onChange={this.onDataSourceChange} - variables={true} - width={30} - /> - </Field> - - {this.renderQueryEditor()} - - <VariableTextAreaField - value={this.state.regex ?? this.props.variable.regex} - name="Regex" - description={ - <div> - Optional, if you want to extract part of a series name or metric node segment. - <br /> - Named capture groups can be used to separate the display text and value ( - <a - className="external-link" - href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups" - target="__blank" - > - see examples - </a> - ). - </div> - } - placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/" - onChange={this.onRegExChange} - onBlur={this.onRegExBlur} - testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2} - width={52} - /> - - <QueryVariableSortSelect onChange={this.onSortChange} sort={this.props.variable.sort} /> - - <QueryVariableRefreshSelect onChange={this.onRefreshChange} refresh={this.props.variable.refresh} /> - - <VariableLegend>Selection options</VariableLegend> - <SelectionOptionsEditor - variable={this.props.variable} - onPropChange={this.onSelectionOptionsChange} - onMultiChanged={this.props.changeVariableMultiValue} - /> - </> + <QueryVariableEditorForm + datasource={variable.datasource ?? undefined} + onDataSourceChange={this.onDataSourceChange} + query={variable.query} + onQueryChange={this.onQueryChange} + onLegacyQueryChange={this.onLegacyQueryChange} + timeRange={timeRange} + regex={variable.regex} + onRegExChange={this.onRegExBlur} + sort={variable.sort} + onSortChange={this.onSortChange} + refresh={variable.refresh} + onRefreshChange={this.onRefreshChange} + isMulti={variable.multi} + includeAll={variable.includeAll} + allValue={variable.allValue ?? ''} + onMultiChange={this.onMultiChange} + onIncludeAllChange={this.onIncludeAllChange} + onAllValueChange={this.onAllValueChange} + /> ); } } diff --git a/public/app/features/variables/query/QueryVariableRefreshSelect.tsx b/public/app/features/variables/query/QueryVariableRefreshSelect.tsx index 36c5500959df8..88475bcc75e84 100644 --- a/public/app/features/variables/query/QueryVariableRefreshSelect.tsx +++ b/public/app/features/variables/query/QueryVariableRefreshSelect.tsx @@ -7,6 +7,7 @@ import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; interface Props { onChange: (option: VariableRefresh) => void; refresh: VariableRefresh; + testId?: string; } const REFRESH_OPTIONS = [ @@ -14,7 +15,7 @@ const REFRESH_OPTIONS = [ { label: 'On time range change', value: VariableRefresh.onTimeRangeChanged }, ]; -export function QueryVariableRefreshSelect({ onChange, refresh }: PropsWithChildren<Props>) { +export function QueryVariableRefreshSelect({ onChange, refresh, testId }: PropsWithChildren<Props>) { const theme = useTheme2(); const [isSmallScreen, setIsSmallScreen] = useState(false); @@ -31,7 +32,7 @@ export function QueryVariableRefreshSelect({ onChange, refresh }: PropsWithChild ); return ( - <Field label="Refresh" description="When to update the values of this variable"> + <Field label="Refresh" description="When to update the values of this variable" data-testid={testId}> <RadioButtonGroup options={REFRESH_OPTIONS} onChange={onChange} diff --git a/public/app/features/variables/query/QueryVariableSortSelect.tsx b/public/app/features/variables/query/QueryVariableSortSelect.tsx index c6d0bc38cdbbc..e28cbb320b87c 100644 --- a/public/app/features/variables/query/QueryVariableSortSelect.tsx +++ b/public/app/features/variables/query/QueryVariableSortSelect.tsx @@ -1,14 +1,14 @@ import React, { PropsWithChildren, useMemo } from 'react'; import { SelectableValue } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { VariableSelectField } from '../editor/VariableSelectField'; +import { VariableSelectField } from '../../dashboard-scene/settings/variables/components/VariableSelectField'; import { VariableSort } from '../types'; interface Props { onChange: (option: SelectableValue<VariableSort>) => void; sort: VariableSort; + testId?: string; } const SORT_OPTIONS = [ @@ -23,7 +23,7 @@ const SORT_OPTIONS = [ { label: 'Natural (desc)', value: VariableSort.naturalDesc }, ]; -export function QueryVariableSortSelect({ onChange, sort }: PropsWithChildren<Props>) { +export function QueryVariableSortSelect({ onChange, sort, testId }: PropsWithChildren<Props>) { const value = useMemo(() => SORT_OPTIONS.find((o) => o.value === sort) ?? SORT_OPTIONS[0], [sort]); return ( @@ -33,7 +33,7 @@ export function QueryVariableSortSelect({ onChange, sort }: PropsWithChildren<Pr value={value} options={SORT_OPTIONS} onChange={onChange} - testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2} + testId={testId} width={25} /> ); diff --git a/public/app/features/variables/query/actions.test.tsx b/public/app/features/variables/query/actions.test.tsx index fffed16f9071e..fa70a480a696c 100644 --- a/public/app/features/variables/query/actions.test.tsx +++ b/public/app/features/variables/query/actions.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { DataSourceRef, getDefaultTimeRange, LoadingState } from '@grafana/data'; -import { setDataSourceSrv } from '@grafana/runtime'; +import { DataSourceApi, DataSourceRef, getDefaultTimeRange, LoadingState } from '@grafana/data'; +import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; @@ -60,7 +60,7 @@ const mocks: Record<string, any> = { }, }; -setDataSourceSrv(mocks.dataSourceSrv as any); +setDataSourceSrv(mocks.dataSourceSrv as DataSourceSrv); jest.mock('../../plugins/plugin_loader', () => ({ importDataSourcePlugin: () => mocks.pluginLoader.importDataSourcePlugin(), @@ -353,7 +353,7 @@ describe('query actions', () => { it('then correct actions are dispatched', async () => { const variable = createVariable({ datasource: { uid: 'other' } }); const editor = mocks.VariableQueryEditor; - const previousDataSource: any = { type: 'previous' }; + const previousDataSource = { type: 'previous' } as DataSourceApi; const templatingState = { editor: { ...initialVariableEditorState, @@ -843,8 +843,8 @@ describe('query actions', () => { }); }); -function mockDatasourceMetrics(variable: QueryVariableModel, optionsMetrics: any[]) { - const metrics: Record<string, any[]> = { +function mockDatasourceMetrics(variable: QueryVariableModel, optionsMetrics: unknown[]) { + const metrics: Record<string, unknown[]> = { [variable.query]: optionsMetrics, }; diff --git a/public/app/features/variables/query/queryRunners.test.ts b/public/app/features/variables/query/queryRunners.test.ts index ffbaa9cc019ef..7a921f01f67be 100644 --- a/public/app/features/variables/query/queryRunners.test.ts +++ b/public/app/features/variables/query/queryRunners.test.ts @@ -1,24 +1,38 @@ import { of } from 'rxjs'; -import { getDefaultTimeRange, VariableSupportType } from '@grafana/data'; +import { + DataQueryRequest, + DataSourceApi, + getDefaultTimeRange, + QueryVariableModel, + VariableSupportType, +} from '@grafana/data'; import { VariableRefresh } from '../types'; -import { QueryRunners, variableDummyRefId } from './queryRunners'; +import { QueryRunners, RunnerArgs, variableDummyRefId } from './queryRunners'; describe('QueryRunners', () => { describe('when using a legacy data source', () => { - const getLegacyTestContext = (variable?: any) => { + const getLegacyTestContext = (variable?: QueryVariableModel) => { const defaultTimeRange = getDefaultTimeRange(); - variable = variable ?? { query: 'A query' }; + variable = variable ?? ({ query: 'A query' } as QueryVariableModel); const timeSrv = { timeRange: jest.fn().mockReturnValue(defaultTimeRange), }; - const datasource: any = { metricFindQuery: jest.fn().mockResolvedValue([{ text: 'A', value: 'A' }]) }; + const datasource = { + metricFindQuery: jest.fn().mockResolvedValue([{ text: 'A', value: 'A' }]), + } as unknown as DataSourceApi; const runner = new QueryRunners().getRunnerForDatasource(datasource); const runRequest = jest.fn().mockReturnValue(of({})); - const runnerArgs: any = { datasource, variable, searchFilter: 'A searchFilter', timeSrv, runRequest }; - const request: any = {}; + const runnerArgs = { + datasource, + variable, + searchFilter: 'A searchFilter', + timeSrv, + runRequest, + } as unknown as RunnerArgs; + const request = {} as DataQueryRequest; return { timeSrv, datasource, runner, variable, runnerArgs, request, defaultTimeRange }; }; @@ -42,7 +56,7 @@ describe('QueryRunners', () => { const { datasource, runner, runnerArgs, request, timeSrv, defaultTimeRange } = getLegacyTestContext({ query: 'A query', refresh: VariableRefresh.onTimeRangeChanged, - }); + } as QueryVariableModel); const observable = runner.runRequest(runnerArgs, request); it('then it should return correct observable', async () => { @@ -77,7 +91,7 @@ describe('QueryRunners', () => { const { datasource, runner, runnerArgs, request, timeSrv, defaultTimeRange } = getLegacyTestContext({ query: 'A query', refresh: VariableRefresh.onDashboardLoad, - }); + } as QueryVariableModel); const observable = runner.runRequest(runnerArgs, request); it('then it should return correct observable', async () => { @@ -112,7 +126,7 @@ describe('QueryRunners', () => { const { datasource, runner, runnerArgs, request, timeSrv } = getLegacyTestContext({ query: 'A query', refresh: VariableRefresh.never, - }); + } as QueryVariableModel); const observable = runner.runRequest(runnerArgs, request); it('then it should return correct observable', async () => { @@ -145,19 +159,27 @@ describe('QueryRunners', () => { }); describe('when using a data source with standard variable support', () => { - const getStandardTestContext = (datasource?: any) => { - const variable: any = { query: { refId: 'A', query: 'A query' } }; + const getStandardTestContext = (datasource?: DataSourceApi) => { + const variable = { query: { refId: 'A', query: 'A query' } } as QueryVariableModel; const timeSrv = {}; - datasource = datasource ?? { - variables: { - getType: () => VariableSupportType.Standard, - toDataQuery: (query: any) => ({ ...query, extra: 'extra' }), - }, - }; + datasource = + datasource ?? + ({ + variables: { + getType: () => VariableSupportType.Standard, + toDataQuery: (query: any) => ({ ...query, extra: 'extra' }), + }, + } as DataSourceApi); const runner = new QueryRunners().getRunnerForDatasource(datasource); const runRequest = jest.fn().mockReturnValue(of({})); - const runnerArgs: any = { datasource, variable, searchFilter: 'A searchFilter', timeSrv, runRequest }; - const request: any = {}; + const runnerArgs = { + datasource, + variable, + searchFilter: 'A searchFilter', + timeSrv, + runRequest, + } as unknown as RunnerArgs; + const request = {} as DataQueryRequest; return { timeSrv, datasource, runner, variable, runnerArgs, request, runRequest }; }; @@ -184,7 +206,7 @@ describe('QueryRunners', () => { toDataQuery: () => undefined, query: () => undefined, }, - }); + } as unknown as DataSourceApi); const observable = runner.runRequest(runnerArgs, request); it('then it should return correct observable', async () => { @@ -203,7 +225,7 @@ describe('QueryRunners', () => { describe('and calling runRequest with a datasource that has no custom query', () => { const { runner, request, runnerArgs, runRequest, datasource } = getStandardTestContext({ variables: { getType: () => VariableSupportType.Standard, toDataQuery: () => undefined }, - }); + } as unknown as DataSourceApi); const observable = runner.runRequest(runnerArgs, request); it('then it should return correct observable', async () => { @@ -222,15 +244,21 @@ describe('QueryRunners', () => { describe('when using a data source with custom variable support', () => { const getCustomTestContext = () => { - const variable: any = { query: { refId: 'A', query: 'A query' } }; + const variable = { query: { refId: 'A', query: 'A query' } } as QueryVariableModel; const timeSrv = {}; - const datasource: any = { + const datasource = { variables: { getType: () => VariableSupportType.Custom, query: () => undefined, editor: {} }, - }; + } as unknown as DataSourceApi; const runner = new QueryRunners().getRunnerForDatasource(datasource); const runRequest = jest.fn().mockReturnValue(of({})); - const runnerArgs: any = { datasource, variable, searchFilter: 'A searchFilter', timeSrv, runRequest }; - const request: any = {}; + const runnerArgs = { + datasource, + variable, + searchFilter: 'A searchFilter', + timeSrv, + runRequest, + } as unknown as RunnerArgs; + const request = {} as DataQueryRequest; return { timeSrv, datasource, runner, variable, runnerArgs, request, runRequest }; }; @@ -270,15 +298,21 @@ describe('QueryRunners', () => { describe('when using a data source with datasource variable support', () => { const getDatasourceTestContext = () => { - const variable: any = { query: { refId: 'A', query: 'A query' } }; + const variable = { query: { refId: 'A', query: 'A query' } } as QueryVariableModel; const timeSrv = {}; - const datasource: any = { + const datasource = { variables: { getType: () => VariableSupportType.Datasource }, - }; + } as unknown as DataSourceApi; const runner = new QueryRunners().getRunnerForDatasource(datasource); const runRequest = jest.fn().mockReturnValue(of({})); - const runnerArgs: any = { datasource, variable, searchFilter: 'A searchFilter', timeSrv, runRequest }; - const request: any = {}; + const runnerArgs = { + datasource, + variable, + searchFilter: 'A searchFilter', + timeSrv, + runRequest, + } as unknown as RunnerArgs; + const request = {} as DataQueryRequest; return { timeSrv, datasource, runner, variable, runnerArgs, request, runRequest }; }; @@ -328,9 +362,9 @@ describe('QueryRunners', () => { describe('when using a data source with unknown variable support', () => { describe('and calling getRunnerForDatasource', () => { it('then it should throw', () => { - const datasource: any = { + const datasource = { variables: {}, - }; + } as unknown as DataSourceApi; expect(() => new QueryRunners().getRunnerForDatasource(datasource)).toThrow(); }); diff --git a/public/app/features/variables/state/__tests__/fixtures.ts b/public/app/features/variables/state/__tests__/fixtures.ts index 8f7ae7cc9f2e4..d30052bc8864e 100644 --- a/public/app/features/variables/state/__tests__/fixtures.ts +++ b/public/app/features/variables/state/__tests__/fixtures.ts @@ -5,6 +5,7 @@ import { CustomVariableModel, DashboardVariableModel, DataSourceVariableModel, + GroupByVariableModel, IntervalVariableModel, LoadingState, OrgVariableModel, @@ -78,6 +79,21 @@ export function createAdhocVariable(input?: Partial<AdHocVariableModel>): AdHocV }; } +export function createGroupByVariable(input?: Partial<GroupByVariableModel>): GroupByVariableModel { + return { + ...createBaseVariableModel('groupby'), + query: '', + datasource: { + uid: 'abc-123', + type: 'prometheus', + }, + multi: true, + current: createVariableOption('job'), + options: [createVariableOption('job'), createVariableOption('instance')], + ...input, + }; +} + export function createConstantVariable(input: Partial<ConstantVariableModel> = {}): ConstantVariableModel { return { ...createBaseVariableModel('constant'), diff --git a/public/app/features/variables/state/reducers.test.ts b/public/app/features/variables/state/reducers.test.ts index 2ab57e974e363..08c13a3a48c75 100644 --- a/public/app/features/variables/state/reducers.test.ts +++ b/public/app/features/variables/state/reducers.test.ts @@ -1,9 +1,12 @@ import { createAction } from '@reduxjs/toolkit'; +import { ComponentType } from 'react'; import { VariableType } from '@grafana/data'; import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { VariableAdapter, variableAdapters } from '../adapters'; +import { VariableEditorProps } from '../editor/types'; +import { VariablePickerProps } from '../pickers/types'; import { QueryVariableModel } from '../types'; import { toVariablePayload } from '../utils'; @@ -21,8 +24,8 @@ const variableAdapter: VariableAdapter<QueryVariableModel> = { reducer: jest.fn().mockReturnValue({}), getValueForUrl: jest.fn(), getSaveModel: jest.fn(), - picker: null as any, - editor: null as any, + picker: null as unknown as ComponentType<VariablePickerProps<QueryVariableModel>>, + editor: null as unknown as ComponentType<VariableEditorProps<QueryVariableModel>>, setValue: jest.fn(), setValueFromUrl: jest.fn(), }; diff --git a/public/app/features/variables/state/transactionReducer.test.ts b/public/app/features/variables/state/transactionReducer.test.ts index c769c10636ab7..8eaa8474c5efc 100644 --- a/public/app/features/variables/state/transactionReducer.test.ts +++ b/public/app/features/variables/state/transactionReducer.test.ts @@ -10,6 +10,7 @@ import { variablesCompleteTransaction, variablesInitTransaction, } from './transactionReducer'; +import { VariablePayload } from './types'; describe('transactionReducer', () => { describe('when variablesInitTransaction is dispatched', () => { @@ -71,7 +72,7 @@ describe('transactionReducer', () => { ...initialTransactionState, status: TransactionStatus.Fetching, }) - .whenActionIsDispatched(removeVariable({} as any)) + .whenActionIsDispatched(removeVariable({} as VariablePayload<{ reIndex: boolean }>)) .thenStateShouldEqual({ uid: null, status: TransactionStatus.Fetching, isDirty: false }); }); }); @@ -83,7 +84,7 @@ describe('transactionReducer', () => { ...initialTransactionState, status: TransactionStatus.NotStarted, }) - .whenActionIsDispatched(removeVariable({} as any)) + .whenActionIsDispatched(removeVariable({} as VariablePayload<{ reIndex: boolean }>)) .thenStateShouldEqual({ uid: null, status: TransactionStatus.NotStarted, isDirty: false }); }); }); @@ -95,7 +96,7 @@ describe('transactionReducer', () => { ...initialTransactionState, status: TransactionStatus.Completed, }) - .whenActionIsDispatched(removeVariable({} as any)) + .whenActionIsDispatched(removeVariable({} as VariablePayload<{ reIndex: boolean }>)) .thenStateShouldEqual({ uid: null, status: TransactionStatus.Completed, isDirty: true }); }); }); @@ -107,7 +108,7 @@ describe('transactionReducer', () => { ...initialTransactionState, status: TransactionStatus.Completed, }) - .whenActionIsDispatched(variableStateNotStarted({} as any)) + .whenActionIsDispatched(variableStateNotStarted({} as VariablePayload)) .thenStateShouldEqual({ uid: null, status: TransactionStatus.Completed, isDirty: false }); }); }); diff --git a/public/app/features/variables/textbox/TextBoxVariableEditor.tsx b/public/app/features/variables/textbox/TextBoxVariableEditor.tsx index 7c5b262fe4f35..4ec385d370ca0 100644 --- a/public/app/features/variables/textbox/TextBoxVariableEditor.tsx +++ b/public/app/features/variables/textbox/TextBoxVariableEditor.tsx @@ -2,8 +2,8 @@ import React, { FormEvent, ReactElement, useCallback } from 'react'; import { selectors } from '@grafana/e2e-selectors'; -import { VariableLegend } from '../editor/VariableLegend'; -import { VariableTextField } from '../editor/VariableTextField'; +import { VariableLegend } from '../../dashboard-scene/settings/variables/components/VariableLegend'; +import { VariableTextField } from '../../dashboard-scene/settings/variables/components/VariableTextField'; import { VariableEditorProps } from '../editor/types'; import { TextBoxVariableModel } from '../types'; diff --git a/public/app/features/visualization/data-hover/DataHoverView.tsx b/public/app/features/visualization/data-hover/DataHoverView.tsx index 7a212631f1e4e..b395aedc9762f 100644 --- a/public/app/features/visualization/data-hover/DataHoverView.tsx +++ b/public/app/features/visualization/data-hover/DataHoverView.tsx @@ -10,10 +10,13 @@ import { GrafanaTheme2, LinkModel, } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { SortOrder, TooltipDisplayMode } from '@grafana/schema'; import { TextLink, useStyles2 } from '@grafana/ui'; import { renderValue } from 'app/plugins/panel/geomap/utils/uiUtils'; +import { ExemplarHoverView } from './ExemplarHoverView'; + export interface Props { data?: DataFrame; // source data rowIndex?: number | null; // the hover row @@ -38,9 +41,8 @@ export function getDisplayValuesAndLinks( sortOrder?: SortOrder, mode?: TooltipDisplayMode | null ) { - const fields = data.fields.map((f, idx) => { - return { ...f, hovered: idx === columnIndex }; - }); + const fields = data.fields; + const hoveredField = columnIndex != null ? fields[columnIndex] : null; const visibleFields = fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.tooltip)); const traceIDField = visibleFields.find((field) => field.name === 'traceID') || fields[0]; @@ -60,7 +62,7 @@ export function getDisplayValuesAndLinks( const linkLookup = new Set<string>(); for (const field of orderedVisibleFields) { - if (mode === TooltipDisplayMode.Single && columnIndex != null && !field.hovered) { + if (mode === TooltipDisplayMode.Single && field !== hoveredField) { continue; } @@ -77,14 +79,11 @@ export function getDisplayValuesAndLinks( }); } - // Sanitize field by removing hovered property to fix unique display name issue - const { hovered, ...sanitizedField } = field; - displayValues.push({ - name: getFieldDisplayName(sanitizedField, data), + name: getFieldDisplayName(field, data), value, valueString: formattedValueToString(fieldDisplay), - highlight: field.hovered, + highlight: field === hoveredField, }); } @@ -110,6 +109,10 @@ export const DataHoverView = ({ data, rowIndex, columnIndex, sortOrder, mode, he const { displayValues, links } = dispValuesAndLinks; + if (config.featureToggles.newVizTooltips && header === 'Exemplar') { + return <ExemplarHoverView displayValues={displayValues} links={links} header={header} />; + } + return ( <div className={styles.wrapper}> {header && ( @@ -142,45 +145,44 @@ export const DataHoverView = ({ data, rowIndex, columnIndex, sortOrder, mode, he }; const getStyles = (theme: GrafanaTheme2, padding = 0) => { return { - wrapper: css` - padding: ${padding}px; - background: ${theme.components.tooltip.background}; - border-radius: ${theme.shape.borderRadius(2)}; - `, - header: css` - background: ${theme.colors.background.secondary}; - align-items: center; - align-content: center; - display: flex; - padding-bottom: ${theme.spacing(1)}; - `, - title: css` - font-weight: ${theme.typography.fontWeightMedium}; - overflow: hidden; - display: inline-block; - white-space: nowrap; - text-overflow: ellipsis; - flex-grow: 1; - `, - infoWrap: css` - padding: ${theme.spacing(1)}; - background: transparent; - border: none; - th { - font-weight: ${theme.typography.fontWeightMedium}; - padding: ${theme.spacing(0.25, 2, 0.25, 0)}; - } - - tr { - border-bottom: 1px solid ${theme.colors.border.weak}; - &:last-child { - border-bottom: none; - } - } - `, - highlight: css``, - link: css` - color: ${theme.colors.text.link}; - `, + wrapper: css({ + padding: `${padding}px`, + background: theme.components.tooltip.background, + borderRadius: theme.shape.borderRadius(2), + }), + header: css({ + background: theme.colors.background.secondary, + alignItems: 'center', + alignContent: 'center', + display: 'flex', + paddingBottom: theme.spacing(1), + }), + title: css({ + fontWeight: theme.typography.fontWeightMedium, + overflow: 'hidden', + display: 'inline-block', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + flexGrow: 1, + }), + infoWrap: css({ + padding: theme.spacing(1), + background: 'transparent', + border: 'none', + th: { + fontWeight: theme.typography.fontWeightMedium, + padding: theme.spacing(0.25, 2, 0.25, 0), + }, + + tr: { + borderBottom: `1px solid ${theme.colors.border.weak}`, + '&:last-child': { + borderBottom: 'none', + }, + }, + }), + link: css({ + color: theme.colors.text.link, + }), }; }; diff --git a/public/app/features/visualization/data-hover/ExemplarHoverView.tsx b/public/app/features/visualization/data-hover/ExemplarHoverView.tsx new file mode 100644 index 0000000000000..c7f4fcc1a5c43 --- /dev/null +++ b/public/app/features/visualization/data-hover/ExemplarHoverView.tsx @@ -0,0 +1,122 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2, LinkModel } from '@grafana/data'; +import { LinkButton, useStyles2 } from '@grafana/ui'; +import { VizTooltipRow } from '@grafana/ui/src/components/VizTooltip/VizTooltipRow'; +import { renderValue } from 'app/plugins/panel/geomap/utils/uiUtils'; + +import { DisplayValue } from './DataHoverView'; + +export interface Props { + displayValues: DisplayValue[]; + links?: LinkModel[]; + header?: string; +} + +export const ExemplarHoverView = ({ displayValues, links, header = 'Exemplar' }: Props) => { + const styles = useStyles2(getStyles); + + const time = displayValues.find((val) => val.name === 'Time'); + displayValues = displayValues.filter((val) => val.name !== 'Time'); // time? + + return ( + <div className={styles.exemplarWrapper}> + <div className={styles.exemplarHeader}> + <span className={styles.title}>{header}</span> + {time && <span className={styles.time}>{renderValue(time.valueString)}</span>} + </div> + <div className={styles.exemplarContent}> + {displayValues.map((displayValue, i) => { + return ( + <VizTooltipRow + key={i} + label={displayValue.name} + value={renderValue(displayValue.valueString)} + justify={'space-between'} + isPinned={false} + /> + ); + })} + </div> + {links && links.length > 0 && ( + <div className={styles.exemplarFooter}> + {links.map((link, i) => ( + <LinkButton key={i} href={link.href} className={styles.linkButton}> + {link.title} + </LinkButton> + ))} + </div> + )} + </div> + ); +}; + +const getStyles = (theme: GrafanaTheme2, padding = 0) => { + return { + exemplarWrapper: css({ + display: 'flex', + flexDirection: 'column', + flex: 1, + gap: 4, + whiteSpace: 'pre', + borderRadius: theme.shape.radius.default, + background: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.weak}`, + boxShadow: `0 4px 8px ${theme.colors.background.primary}`, + }), + exemplarHeader: css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: theme.spacing(0.5), + color: theme.colors.text.secondary, + padding: theme.spacing(1), + }), + time: css({ + color: theme.colors.text.primary, + }), + exemplarContent: css({ + display: 'flex', + flexDirection: 'column', + flex: 1, + gap: 4, + borderTop: `1px solid ${theme.colors.border.medium}`, + padding: theme.spacing(1), + }), + exemplarFooter: css({ + display: 'flex', + flexDirection: 'column', + flex: 1, + borderTop: `1px solid ${theme.colors.border.medium}`, + padding: theme.spacing(1), + + overflowX: 'auto', + overflowY: 'hidden', + whiteSpace: 'nowrap', + }), + linkButton: css({ + width: 'fit-content', + }), + label: css({ + color: theme.colors.text.secondary, + fontWeight: 400, + textOverflow: 'ellipsis', + overflow: 'hidden', + marginRight: theme.spacing(0.5), + }), + value: css({ + fontWeight: 500, + textOverflow: 'ellipsis', + overflow: 'hidden', + }), + title: css({ + fontWeight: theme.typography.fontWeightMedium, + overflow: 'hidden', + display: 'inline-block', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + flexGrow: 1, + }), + }; +}; diff --git a/public/app/plugins/datasource/alertmanager/plugin.json b/public/app/plugins/datasource/alertmanager/plugin.json index 33a087f53659f..9e51ce18e5c5a 100644 --- a/public/app/plugins/datasource/alertmanager/plugin.json +++ b/public/app/plugins/datasource/alertmanager/plugin.json @@ -43,7 +43,8 @@ } ], "info": { - "description": "", + "description": "Add external Alertmanagers (supports Prometheus and Mimir implementations) so you can use the Grafana Alerting UI to manage silences, contact points, and notification policies.", + "keywords": ["alerts", "alerting", "prometheus", "alertmanager", "mimir", "cortex"], "author": { "name": "Prometheus alertmanager", "url": "https://grafana.com" diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index 8f47c418c21cb..059773ac0d0e5 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -71,7 +71,7 @@ export type GrafanaManagedReceiverConfig = { disableResolveMessage: boolean; secureFields?: Record<string, boolean>; secureSettings?: Record<string, any>; - settings: Record<string, any>; + settings?: Record<string, any>; // sometimes settings are optional for security reasons (RBAC) type: string; name: string; updated?: string; @@ -99,7 +99,7 @@ export type Receiver = GrafanaManagedContactPoint | AlertmanagerReceiver; export type ObjectMatcher = [name: string, operator: MatcherOperator, value: string]; export type Route = { - receiver?: string; + receiver?: string | null; group_by?: string[]; continue?: boolean; object_matchers?: ObjectMatcher[]; @@ -157,6 +157,7 @@ export type AlertmanagerConfig = { inhibit_rules?: InhibitRule[]; receivers?: Receiver[]; mute_time_intervals?: MuteTimeInterval[]; + time_intervals?: MuteTimeInterval[]; /** { [name]: provenance } */ muteTimeProvenances?: Record<string, string>; last_applied?: boolean; diff --git a/metadata.md b/public/app/plugins/datasource/azuremonitor/CHANGELOG.md similarity index 100% rename from metadata.md rename to public/app/plugins/datasource/azuremonitor/CHANGELOG.md diff --git a/public/app/plugins/datasource/azuremonitor/__mocks__/variables.ts b/public/app/plugins/datasource/azuremonitor/__mocks__/variables.ts index 54c0c46ef84c7..a7a90aefdcb51 100644 --- a/public/app/plugins/datasource/azuremonitor/__mocks__/variables.ts +++ b/public/app/plugins/datasource/azuremonitor/__mocks__/variables.ts @@ -1,4 +1,4 @@ -import { BaseVariableModel, CustomVariableModel, LoadingState, VariableHide } from '@grafana/data'; +import { BaseVariableModel, CustomVariableModel, LoadingState, VariableHide, VariableOption } from '@grafana/data'; const initialVariableModelState: BaseVariableModel = { id: '00000000-0000-0000-0000-000000000000', @@ -63,3 +63,14 @@ export const multiVariable: CustomVariableModel = { hide: VariableHide.dontHide, type: 'custom', }; + +export const initialCustomVariableModelState: CustomVariableModel = { + ...initialVariableModelState, + type: 'custom', + multi: false, + includeAll: false, + allValue: null, + query: '', + options: [], + current: {} as VariableOption, +}; diff --git a/public/app/plugins/datasource/azuremonitor/components/ArgQueryEditor/ArgQueryEditor.test.tsx b/public/app/plugins/datasource/azuremonitor/components/ArgQueryEditor/ArgQueryEditor.test.tsx index 3f5f31a19667b..92564c4c7dcc1 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ArgQueryEditor/ArgQueryEditor.test.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ArgQueryEditor/ArgQueryEditor.test.tsx @@ -182,4 +182,55 @@ describe('ArgQueryEditor', () => { ); expect(await waitFor(() => screen.getByText('At least one subscription must be chosen.'))).toBeInTheDocument(); }); + + it('should select all subscriptions if select all is chosen from the dropdown', async () => { + const onChange = jest.fn(); + const datasource = createMockDatasource({ + getSubscriptions: jest.fn().mockResolvedValue([ + { text: 'foo', value: 'test-subscription-value1' }, + { text: 'bar', value: 'test-subscription-value2' }, + { text: 'Select all subscriptions', value: 'Select all' }, + ]), + }); + const query = createMockQuery({ + subscription: undefined, + subscriptions: ['test-subscription-value1', 'test-subscription-value2', 'Select all'], + }); + const { rerender } = render( + <ArgQueryEditor + {...defaultProps} + query={query} + datasource={datasource} + onChange={onChange} + variableOptionGroup={{ label: 'Template Variables', options: [] }} + /> + ); + + expect(datasource.getSubscriptions).toHaveBeenCalled(); + expect(await waitFor(() => onChange)).toHaveBeenCalledWith( + expect.objectContaining({ subscriptions: ['test-subscription-value1', 'test-subscription-value2', 'Select all'] }) + ); + expect(await waitFor(() => screen.findByText('foo'))).toBeInTheDocument(); + expect(await waitFor(() => screen.findByText('bar'))).toBeInTheDocument(); + expect(await waitFor(() => screen.findByText('Select all subscriptions'))).toBeInTheDocument(); + + const selectAll = screen.getByText('Select all subscriptions'); + await userEvent.click(selectAll); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ subscriptions: ['test-subscription-value1', 'test-subscription-value2', 'Select all'] }) + ); + + rerender( + <ArgQueryEditor + {...defaultProps} + datasource={datasource} + onChange={onChange} + query={{ ...query, subscriptions: ['test-subscription-value1', 'test-subscription-value2', 'Select all'] }} + variableOptionGroup={{ label: 'Template Variables', options: [] }} + /> + ); + expect(await waitFor(() => screen.getByText('foo'))).toBeInTheDocument(); + expect(await waitFor(() => screen.getByText('bar'))).toBeInTheDocument(); + }); }); diff --git a/public/app/plugins/datasource/azuremonitor/components/ArgQueryEditor/ArgQueryEditor.tsx b/public/app/plugins/datasource/azuremonitor/components/ArgQueryEditor/ArgQueryEditor.tsx index 78b685d491bf6..cc0d1ce82d9e8 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ArgQueryEditor/ArgQueryEditor.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ArgQueryEditor/ArgQueryEditor.tsx @@ -57,8 +57,11 @@ const ArgQueryEditor = ({ datasource .getSubscriptions() .then((results) => { + const selectAllSubscriptionOption = [ + { label: 'Select all subscriptions', value: 'Select all subscriptions', description: 'Select all' }, + ]; const fetchedSubscriptions = results.map((v) => ({ label: v.text, value: v.value, description: v.value })); - setSubscriptions(fetchedSubscriptions); + setSubscriptions(selectAllSubscriptionOption.concat(fetchedSubscriptions)); setError(ERROR_SOURCE, undefined); onChange({ diff --git a/public/app/plugins/datasource/azuremonitor/components/ArgQueryEditor/SubscriptionField.tsx b/public/app/plugins/datasource/azuremonitor/components/ArgQueryEditor/SubscriptionField.tsx index 27b3a95003454..7b03af248bc58 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ArgQueryEditor/SubscriptionField.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ArgQueryEditor/SubscriptionField.tsx @@ -28,6 +28,7 @@ const SubscriptionField = ({ query, subscriptions, variableOptionGroup, onQueryC }, [query.subscriptions, subscriptions, variableOptionGroup.options]); const onChange = (change: Array<SelectableValue<string>>) => { + const containsSelectAll = change.filter((c) => c.value === 'Select all subscriptions'); if (!change || change.length === 0) { setValues([]); onQueryChange({ @@ -35,6 +36,12 @@ const SubscriptionField = ({ query, subscriptions, variableOptionGroup, onQueryC subscriptions: [], }); setError(true); + } else if (containsSelectAll.length > 0) { + const allSubs = subscriptions.map((c) => c.value ?? '').filter((c) => c !== 'Select all subscriptions'); + onQueryChange({ + ...query, + subscriptions: allSubs, + }); } else { const newSubs = change.map((c) => c.value ?? ''); onQueryChange({ diff --git a/public/app/plugins/datasource/azuremonitor/components/AzureCheatSheet.tsx b/public/app/plugins/datasource/azuremonitor/components/AzureCheatSheet.tsx index 7611ad31c924f..d517a4854e253 100644 --- a/public/app/plugins/datasource/azuremonitor/components/AzureCheatSheet.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/AzureCheatSheet.tsx @@ -60,8 +60,8 @@ const AzureCheatSheet = (props: AzureCheatSheetProps) => { return a.displayName.toLowerCase() === b.displayName.toLowerCase() ? 0 : a.displayName.toLowerCase() < b.displayName.toLowerCase() - ? -1 - : 1; + ? -1 + : 1; }); const alphabetizedQueries = result.categories.reduce( (queriesByCategory: CheatsheetQueries, category: Category) => { diff --git a/public/app/plugins/datasource/azuremonitor/components/AzureCredentialsForm.tsx b/public/app/plugins/datasource/azuremonitor/components/AzureCredentialsForm.tsx index 4b3c32b3e299b..80fe46c830270 100644 --- a/public/app/plugins/datasource/azuremonitor/components/AzureCredentialsForm.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/AzureCredentialsForm.tsx @@ -56,8 +56,8 @@ export const AzureCredentialsForm = (props: Props) => { const defaultAuthType = managedIdentityEnabled ? 'msi' : workloadIdentityEnabled - ? 'workloadidentity' - : 'clientsecret'; + ? 'workloadidentity' + : 'clientsecret'; const updated: AzureCredentials = { ...credentials, authType: selected.value || defaultAuthType, diff --git a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/QueryField.tsx b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/QueryField.tsx index 2311c73342260..2c978f0a8f5ac 100644 --- a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/QueryField.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/QueryField.tsx @@ -1,5 +1,4 @@ -import { EngineSchema, Schema } from '@kusto/monaco-kusto'; -import { Uri } from 'monaco-editor'; +import { EngineSchema, getKustoWorker } from '@kusto/monaco-kusto'; import React, { useCallback, useEffect, useState } from 'react'; import { CodeEditor, Monaco, MonacoEditor } from '@grafana/ui'; @@ -13,16 +12,6 @@ interface MonacoEditorValues { monaco: Monaco; } -interface MonacoLanguages { - kusto: { - getKustoWorker: () => Promise< - (url: Uri) => Promise<{ - setSchema: (schema: Schema) => void; - }> - >; - }; -} - const QueryField = ({ query, onQueryChange, schema }: AzureQueryEditorFieldProps) => { const [monaco, setMonaco] = useState<MonacoEditorValues | undefined>(); @@ -33,10 +22,9 @@ const QueryField = ({ query, onQueryChange, schema }: AzureQueryEditorFieldProps const setupEditor = async ({ monaco, editor }: MonacoEditorValues, schema: EngineSchema) => { try { - const languages = monaco.languages as unknown as MonacoLanguages; const model = editor.getModel(); if (model) { - const kustoWorker = await languages.kusto.getKustoWorker(); + const kustoWorker = await getKustoWorker(); const kustoMode = await kustoWorker(model?.uri); await kustoMode.setSchema(schema); } diff --git a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/TimeManagement.tsx b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/TimeManagement.tsx index f4432719ace8e..a97bea313bd60 100644 --- a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/TimeManagement.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/TimeManagement.tsx @@ -123,10 +123,10 @@ export function TimeManagement({ query, onQueryChange: onChange, schema }: Azure query.azureLogAnalytics?.timeColumn ? query.azureLogAnalytics?.timeColumn : defaultTimeColumns - ? defaultTimeColumns[0] - : timeColumns - ? timeColumns[0] - : { value: 'TimeGenerated', label: 'TimeGenerated' } + ? defaultTimeColumns[0] + : timeColumns + ? timeColumns[0] + : { value: 'TimeGenerated', label: 'TimeGenerated' } } allowCustomValue /> diff --git a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/DimensionFields.test.tsx b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/DimensionFields.test.tsx index 913a37be6f686..f0ea59df1eefc 100644 --- a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/DimensionFields.test.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/DimensionFields.test.tsx @@ -2,11 +2,11 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { openMenu } from 'react-select-event'; -import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; import createMockDatasource from '../../__mocks__/datasource'; import createMockPanelData from '../../__mocks__/panelData'; import createMockQuery from '../../__mocks__/query'; +import { selectOptionInTest } from '../../utils/testUtils'; import DimensionFields from './DimensionFields'; import { appendDimensionFilter, setDimensionFilterValue } from './setQueryValue'; diff --git a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx index 03c0d4dddf2d9..b5bfa0f27b1f8 100644 --- a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx @@ -1,7 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; import createMockDatasource from '../../__mocks__/datasource'; import { createMockInstanceSetttings } from '../../__mocks__/instanceSettings'; @@ -14,6 +13,7 @@ import { } from '../../__mocks__/resourcePickerRows'; import { selectors } from '../../e2e/selectors'; import ResourcePickerData from '../../resourcePicker/resourcePickerData'; +import { selectOptionInTest } from '../../utils/testUtils'; import MetricsQueryEditor from './MetricsQueryEditor'; diff --git a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.test.tsx b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.test.tsx index 18621f8bbf4b7..629634f8e6e5a 100644 --- a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.test.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.test.tsx @@ -1,6 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; -import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; import * as ui from '@grafana/ui'; @@ -9,6 +8,7 @@ import { invalidNamespaceError } from '../../__mocks__/errors'; import createMockQuery from '../../__mocks__/query'; import { selectors } from '../../e2e/selectors'; import { AzureQueryType } from '../../types'; +import { selectOptionInTest } from '../../utils/testUtils'; import QueryEditor from './QueryEditor'; diff --git a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.tsx b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.tsx index 3e9b85d3fd46f..8813d33f02667 100644 --- a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { QueryEditorProps } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; -import { Alert, Button, CodeEditor } from '@grafana/ui'; +import { Alert, Button, CodeEditor, Space } from '@grafana/ui'; import AzureMonitorDatasource from '../../datasource'; import { @@ -20,7 +20,6 @@ import LogsQueryEditor from '../LogsQueryEditor'; import { AzureCheatSheetModal } from '../LogsQueryEditor/AzureCheatSheetModal'; import NewMetricsQueryEditor from '../MetricsQueryEditor/MetricsQueryEditor'; import { QueryHeader } from '../QueryHeader'; -import { Space } from '../Space'; import TracesQueryEditor from '../TracesQueryEditor'; import usePreparedQuery from './usePreparedQuery'; diff --git a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/AdvancedMulti.tsx b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/AdvancedMulti.tsx index baa5389d05e13..fa4d7f68deffc 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/AdvancedMulti.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/AdvancedMulti.tsx @@ -1,10 +1,9 @@ import React, { useState } from 'react'; -import { Collapse } from '@grafana/ui'; +import { Collapse, Space } from '@grafana/ui'; import { selectors } from '../../e2e/selectors'; import { AzureMonitorResource } from '../../types'; -import { Space } from '../Space'; export interface ResourcePickerProps<T> { resources: T[]; diff --git a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/NestedEntry.tsx b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/NestedEntry.tsx index 6d843e2f18b54..5b0bea5e2e1cf 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/NestedEntry.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/NestedEntry.tsx @@ -1,9 +1,7 @@ import { cx } from '@emotion/css'; import React, { useCallback, useEffect } from 'react'; -import { Checkbox, IconButton, useStyles2, useTheme2 } from '@grafana/ui'; - -import { Space } from '../Space'; +import { Checkbox, IconButton, useStyles2, useTheme2, Space } from '@grafana/ui'; import { EntryIcon } from './EntryIcon'; import getStyles from './styles'; diff --git a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/ResourcePicker.tsx b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/ResourcePicker.tsx index e4771a40216ab..0afb25e760f2c 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/ResourcePicker.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/ResourcePicker.tsx @@ -2,13 +2,12 @@ import { cx } from '@emotion/css'; import React, { useCallback, useEffect, useState } from 'react'; import { useEffectOnce } from 'react-use'; -import { Alert, Button, LoadingPlaceholder, Modal, useStyles2 } from '@grafana/ui'; +import { Alert, Button, LoadingPlaceholder, Modal, useStyles2, Space } from '@grafana/ui'; import { selectors } from '../../e2e/selectors'; import ResourcePickerData, { ResourcePickerQueryType } from '../../resourcePicker/resourcePickerData'; import { AzureMonitorResource } from '../../types'; import messageFromError from '../../utils/messageFromError'; -import { Space } from '../Space'; import AdvancedMulti from './AdvancedMulti'; import NestedRow from './NestedRow'; diff --git a/public/app/plugins/datasource/azuremonitor/components/Space.tsx b/public/app/plugins/datasource/azuremonitor/components/Space.tsx deleted file mode 100644 index d8fac61c838dc..0000000000000 --- a/public/app/plugins/datasource/azuremonitor/components/Space.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { css, cx } from '@emotion/css'; -import React from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2 } from '@grafana/ui'; - -export interface SpaceProps { - v?: number; - h?: number; - layout?: 'block' | 'inline'; -} - -export const Space = (props: SpaceProps) => { - const styles = useStyles2(getStyles, props); - - return <span className={cx(styles.wrapper)} />; -}; - -Space.defaultProps = { - v: 0, - h: 0, - layout: 'block', -}; - -const getStyles = (theme: GrafanaTheme2, props: SpaceProps) => ({ - wrapper: css([ - { - paddingRight: theme.spacing(props.h ?? 0), - paddingBottom: theme.spacing(props.v ?? 0), - }, - props.layout === 'inline' && { - display: 'inline-block', - }, - props.layout === 'block' && { - display: 'block', - }, - ]), -}); diff --git a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filter.tsx b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filter.tsx index 3707f29a8ec6d..eaa0c47e5acbf 100644 --- a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filter.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filter.tsx @@ -248,7 +248,6 @@ const Filter = ( width={25} /> <ButtonSelect<string> - placeholder="Operator" value={item.operation ? { label: item.operation === 'eq' ? '=' : '!=', value: item.operation } : undefined} options={[ { label: '=', value: 'eq' }, diff --git a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filters.test.tsx b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filters.test.tsx index 9c420d2a1580e..f8900303b67ce 100644 --- a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filters.test.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filters.test.tsx @@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event'; import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; import React from 'react'; import { of } from 'rxjs'; -import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; import { CoreApp } from '@grafana/data'; @@ -12,6 +11,7 @@ import createMockQuery from '../../__mocks__/query'; import { AzureQueryType } from '../../dataquery.gen'; import Datasource from '../../datasource'; import { AzureMonitorQuery } from '../../types'; +import { selectOptionInTest } from '../../utils/testUtils'; import Filters from './Filters'; import { setFilters } from './setQueryValue'; @@ -96,12 +96,10 @@ const addFilter = async ( const operationLabel = operation === 'eq' ? '=' : '!='; const addFilter = await screen.findByLabelText('Add'); - await act(() => { - userEvent.click(addFilter); - if (mockQuery.azureTraces?.filters && mockQuery.azureTraces.filters.length < 1) { - expect(onQueryChange).not.toHaveBeenCalled(); - } - }); + await userEvent.click(addFilter); + if (mockQuery.azureTraces?.filters && mockQuery.azureTraces.filters.length < 1) { + expect(onQueryChange).not.toHaveBeenCalled(); + } await waitFor(() => expect(screen.getByText('Property')).toBeInTheDocument()); const propertySelect = await screen.findByText('Property'); @@ -109,8 +107,9 @@ const addFilter = async ( await waitFor(() => expect(screen.getByText(property)).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Property')).toBeInTheDocument()); - const operationSelect = await screen.getAllByText('='); - selectOptionInTest(operationSelect[index], operationLabel); + const operationSelect = await screen.findAllByText('='); + await userEvent.click(operationSelect[index]); + await userEvent.click(screen.getByRole('menuitemradio', { name: operationLabel })); await waitFor(() => expect(screen.getByText(operationLabel)).toBeInTheDocument()); const valueSelect = await screen.findByText('Value'); @@ -161,8 +160,8 @@ const addFilter = async ( ...(mockQuery.azureTraces?.filters ?? []), { property, operation, filters: values }, ]); + await userEvent.type(valueSelect, '{Escape}'); await waitFor(() => { - userEvent.type(valueSelect, '{Escape}'); expect(onQueryChange).toHaveBeenCalledWith(newQuery); }); diff --git a/public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.tsx b/public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.tsx index 34b8141bcfde7..94a2e1b281a68 100644 --- a/public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react'; import { useEffectOnce } from 'react-use'; import { SelectableValue } from '@grafana/data'; -import { Alert, Field, Select } from '@grafana/ui'; +import { Alert, Field, Select, Space } from '@grafana/ui'; import DataSource from '../../datasource'; import { selectors } from '../../e2e/selectors'; @@ -12,7 +12,6 @@ import { AzureMonitorOption, AzureMonitorQuery, AzureQueryType } from '../../typ import useLastError from '../../utils/useLastError'; import ArgQueryEditor from '../ArgQueryEditor'; import LogsQueryEditor from '../LogsQueryEditor'; -import { Space } from '../Space'; import GrafanaTemplateVariableFnInput from './GrafanaTemplateVariableFn'; diff --git a/public/app/plugins/datasource/azuremonitor/dataquery.gen.ts b/public/app/plugins/datasource/azuremonitor/dataquery.gen.ts index b9f234a9a3c0d..8616ca3ac596c 100644 --- a/public/app/plugins/datasource/azuremonitor/dataquery.gen.ts +++ b/public/app/plugins/datasource/azuremonitor/dataquery.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -372,4 +372,4 @@ export interface WorkspacesQuery extends BaseGrafanaTemplateVariableQuery { export type GrafanaTemplateVariableQuery = (AppInsightsMetricNameQuery | AppInsightsGroupByQuery | SubscriptionsQuery | ResourceGroupsQuery | ResourceNamesQuery | MetricNamespaceQuery | MetricDefinitionsQuery | MetricNamesQuery | WorkspacesQuery | UnknownQuery); -export interface AzureMonitor {} +export interface AzureMonitorDataQuery {} diff --git a/public/app/plugins/datasource/azuremonitor/package.json b/public/app/plugins/datasource/azuremonitor/package.json new file mode 100644 index 0000000000000..3c627d75409d5 --- /dev/null +++ b/public/app/plugins/datasource/azuremonitor/package.json @@ -0,0 +1,50 @@ +{ + "name": "@grafana-plugins/grafana-azure-monitor-datasource", + "description": "Grafana data source for Azure Monitor", + "private": true, + "version": "11.0.0-pre", + "dependencies": { + "@emotion/css": "11.11.2", + "@grafana/data": "11.0.0-pre", + "@grafana/experimental": "1.7.10", + "@grafana/runtime": "11.0.0-pre", + "@grafana/schema": "11.0.0-pre", + "@grafana/ui": "11.0.0-pre", + "@kusto/monaco-kusto": "^7.4.0", + "fast-deep-equal": "^3.1.3", + "i18next": "^23.0.0", + "immer": "10.0.4", + "lodash": "4.17.21", + "monaco-editor": "0.34.0", + "prismjs": "1.29.0", + "react": "18.2.0", + "react-use": "17.5.0", + "rxjs": "7.8.1", + "tslib": "2.6.2" + }, + "devDependencies": { + "@grafana/e2e-selectors": "11.0.0-pre", + "@grafana/plugin-configs": "11.0.0-pre", + "@testing-library/react": "14.2.1", + "@testing-library/user-event": "14.5.2", + "@types/jest": "29.5.12", + "@types/lodash": "4.17.0", + "@types/node": "20.11.28", + "@types/prismjs": "1.26.3", + "@types/react": "18.2.66", + "@types/testing-library__jest-dom": "5.14.9", + "react-select-event": "5.5.1", + "ts-node": "10.9.2", + "typescript": "5.3.3", + "webpack": "5.90.3" + }, + "peerDependencies": { + "@grafana/runtime": "*" + }, + "scripts": { + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development" + }, + "packageManager": "yarn@4.1.0" +} diff --git a/public/app/plugins/datasource/azuremonitor/plugin.json b/public/app/plugins/datasource/azuremonitor/plugin.json index 04c933839f00e..9e1d8c49be308 100644 --- a/public/app/plugins/datasource/azuremonitor/plugin.json +++ b/public/app/plugins/datasource/azuremonitor/plugin.json @@ -77,7 +77,7 @@ }, { "type": "dashboard", "name": "Azure / Resources Overview", "path": "dashboards/arg.json" } ], - + "executable": "gpx_azuremonitor", "info": { "description": "Data source for Microsoft Azure Monitor & Application Insights", "author": { @@ -98,11 +98,11 @@ { "name": "Azure Monitor Network", "path": "img/azure_monitor_network.png" }, { "name": "Azure Monitor CPU", "path": "img/azure_monitor_cpu.png" } ], - "version": "1.0.0" + "version": "%VERSION%" }, "dependencies": { - "grafanaVersion": "5.2.x", + "grafanaDependency": ">=10.3.0", "plugins": [] }, diff --git a/public/app/plugins/datasource/azuremonitor/tsconfig.json b/public/app/plugins/datasource/azuremonitor/tsconfig.json new file mode 100644 index 0000000000000..7daf2ee8abab6 --- /dev/null +++ b/public/app/plugins/datasource/azuremonitor/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@grafana/plugin-configs/tsconfig.json", + "include": ["."] +} diff --git a/public/app/plugins/datasource/azuremonitor/types/query.ts b/public/app/plugins/datasource/azuremonitor/types/query.ts index 0c4287928cdb1..c9098ef91c700 100644 --- a/public/app/plugins/datasource/azuremonitor/types/query.ts +++ b/public/app/plugins/datasource/azuremonitor/types/query.ts @@ -1,7 +1,7 @@ import { AzureMonitorQuery as AzureMonitorQueryBase, AzureQueryType } from '../dataquery.gen'; export { AzureQueryType }; -export { +export type { AzureMetricQuery, AzureLogsQuery, AzureResourceGraphQuery, @@ -9,8 +9,8 @@ export { AzureMonitorResource, AzureMetricDimension, AzureTracesFilter, - ResultFormat, } from '../dataquery.gen'; +export { ResultFormat } from '../dataquery.gen'; /** * Represents the query as it moves through the frontend query editor and datasource files. diff --git a/public/app/plugins/datasource/azuremonitor/utils/common.test.ts b/public/app/plugins/datasource/azuremonitor/utils/common.test.ts index 066d63a81231f..0c55057fbfbd0 100644 --- a/public/app/plugins/datasource/azuremonitor/utils/common.test.ts +++ b/public/app/plugins/datasource/azuremonitor/utils/common.test.ts @@ -1,4 +1,4 @@ -import { initialCustomVariableModelState } from 'app/features/variables/custom/reducer'; +import { initialCustomVariableModelState } from '../__mocks__/variables'; import { hasOption, interpolateVariable } from './common'; diff --git a/public/app/plugins/datasource/azuremonitor/utils/testUtils.ts b/public/app/plugins/datasource/azuremonitor/utils/testUtils.ts new file mode 100644 index 0000000000000..62bd8c169491c --- /dev/null +++ b/public/app/plugins/datasource/azuremonitor/utils/testUtils.ts @@ -0,0 +1,8 @@ +import { waitFor } from '@testing-library/react'; +import { select } from 'react-select-event'; + +// Used to select an option or options from a Select in unit tests +export const selectOptionInTest = async ( + input: HTMLElement, + optionOrOptions: string | RegExp | Array<string | RegExp> +) => await waitFor(() => select(input, optionOrOptions, { container: document.body })); diff --git a/public/app/plugins/datasource/azuremonitor/webpack.config.ts b/public/app/plugins/datasource/azuremonitor/webpack.config.ts new file mode 100644 index 0000000000000..4da5a990cfa57 --- /dev/null +++ b/public/app/plugins/datasource/azuremonitor/webpack.config.ts @@ -0,0 +1,3 @@ +import config from '@grafana/plugin-configs/webpack.config'; + +export default config; diff --git a/public/app/plugins/datasource/cloud-monitoring/.eslintignore b/public/app/plugins/datasource/cloud-monitoring/.eslintignore new file mode 100644 index 0000000000000..59ac0834c7a79 --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/.eslintignore @@ -0,0 +1,2 @@ +# TS generate from cue by cuetsy +**/*.gen.ts diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/empty/empty.yaml b/public/app/plugins/datasource/cloud-monitoring/CHANGELOG.md similarity index 100% rename from pkg/services/provisioning/notifiers/testdata/test-configs/empty/empty.yaml rename to public/app/plugins/datasource/cloud-monitoring/CHANGELOG.md diff --git a/public/app/plugins/datasource/cloud-monitoring/components/Aggregation.test.tsx b/public/app/plugins/datasource/cloud-monitoring/components/Aggregation.test.tsx index 917ecb70d12ce..59dc781fb24e1 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/Aggregation.test.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/Aggregation.test.tsx @@ -1,7 +1,6 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { openMenu } from 'react-select-event'; -import { TemplateSrvStub } from 'test/specs/helpers'; import { MetricKind, ValueTypes } from '../types/query'; import { MetricDescriptor } from '../types/types'; @@ -10,8 +9,6 @@ import { Aggregation, Props } from './Aggregation'; const props: Props = { onChange: () => {}, - // @ts-ignore - templateSrv: new TemplateSrvStub(), metricDescriptor: { valueType: '', metricKind: '', @@ -19,6 +16,7 @@ const props: Props = { crossSeriesReducer: '', groupBys: [], templateVariableOptions: [], + refId: 'A', }; describe('Aggregation', () => { diff --git a/public/app/plugins/datasource/cloud-monitoring/components/AnnotationsHelp.tsx b/public/app/plugins/datasource/cloud-monitoring/components/AnnotationsHelp.tsx index a9ba642e39763..8970adbe0a2f0 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/AnnotationsHelp.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/AnnotationsHelp.tsx @@ -2,7 +2,7 @@ import React from 'react'; export const AnnotationsHelp = () => { return ( - <div className="gf-form grafana-info-box alert-info"> + <div className="grafana-info-box alert-info"> <div> <h5>Annotation Query Format</h5> <p> diff --git a/public/app/plugins/datasource/cloud-monitoring/components/__snapshots__/VariableQueryEditor.test.tsx.snap b/public/app/plugins/datasource/cloud-monitoring/components/__snapshots__/VariableQueryEditor.test.tsx.snap index dd402db330206..b52a8a3d64bbd 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/__snapshots__/VariableQueryEditor.test.tsx.snap +++ b/public/app/plugins/datasource/cloud-monitoring/components/__snapshots__/VariableQueryEditor.test.tsx.snap @@ -30,6 +30,7 @@ exports[`VariableQueryEditor renders correctly 1`] = ` aria-live="polite" aria-relevant="additions text" className="css-1f43avz-a11yText-A11yText" + role="log" /> <div className="css-1i88p6p" @@ -49,6 +50,7 @@ exports[`VariableQueryEditor renders correctly 1`] = ` data-value="" > <input + aria-activedescendant="" aria-autocomplete="list" aria-expanded={false} aria-haspopup={true} diff --git a/public/app/plugins/datasource/cloud-monitoring/dataquery.gen.ts b/public/app/plugins/datasource/cloud-monitoring/dataquery.gen.ts index 8fe1f6b79d997..a4d0755cb72e3 100644 --- a/public/app/plugins/datasource/cloud-monitoring/dataquery.gen.ts +++ b/public/app/plugins/datasource/cloud-monitoring/dataquery.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -375,4 +375,4 @@ export enum MetricFindQueryTypes { Services = 'services', } -export interface GoogleCloudMonitoring {} +export interface GoogleCloudMonitoringDataQuery {} diff --git a/public/app/plugins/datasource/cloud-monitoring/package.json b/public/app/plugins/datasource/cloud-monitoring/package.json new file mode 100644 index 0000000000000..1e178269a227c --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/package.json @@ -0,0 +1,55 @@ +{ + "name": "@grafana-plugins/stackdriver", + "description": "Grafana data source for Google Cloud Monitoring", + "private": true, + "version": "11.0.0-pre", + "dependencies": { + "@emotion/css": "11.11.2", + "@grafana/data": "11.0.0-pre", + "@grafana/experimental": "1.7.10", + "@grafana/google-sdk": "0.1.2", + "@grafana/runtime": "11.0.0-pre", + "@grafana/schema": "11.0.0-pre", + "@grafana/ui": "11.0.0-pre", + "@kusto/monaco-kusto": "^7.4.0", + "debounce-promise": "3.1.2", + "fast-deep-equal": "^3.1.3", + "i18next": "^23.0.0", + "immer": "10.0.4", + "lodash": "4.17.21", + "monaco-editor": "0.34.0", + "prismjs": "1.29.0", + "react": "18.2.0", + "react-use": "17.5.0", + "rxjs": "7.8.1", + "tslib": "2.6.2" + }, + "devDependencies": { + "@grafana/e2e-selectors": "11.0.0-pre", + "@grafana/plugin-configs": "11.0.0-pre", + "@testing-library/react": "14.2.1", + "@testing-library/user-event": "14.5.2", + "@types/debounce-promise": "3.1.9", + "@types/jest": "29.5.12", + "@types/lodash": "4.17.0", + "@types/node": "20.11.28", + "@types/prismjs": "1.26.3", + "@types/react": "18.2.66", + "@types/react-test-renderer": "18.0.7", + "@types/testing-library__jest-dom": "5.14.9", + "react-select-event": "5.5.1", + "react-test-renderer": "18.2.0", + "ts-node": "10.9.2", + "typescript": "5.3.3", + "webpack": "5.90.3" + }, + "peerDependencies": { + "@grafana/runtime": "*" + }, + "scripts": { + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development" + }, + "packageManager": "yarn@4.1.0" +} diff --git a/public/app/plugins/datasource/cloud-monitoring/plugin.json b/public/app/plugins/datasource/cloud-monitoring/plugin.json index fb7e2bd8f189a..e2b47c008e06c 100644 --- a/public/app/plugins/datasource/cloud-monitoring/plugin.json +++ b/public/app/plugins/datasource/cloud-monitoring/plugin.json @@ -1,12 +1,13 @@ { - "name": "Google Cloud Monitoring", "type": "datasource", + "name": "Google Cloud Monitoring", "id": "stackdriver", "category": "cloud", "metrics": true, "alerting": true, "annotations": true, + "logs": true, "backend": true, "includes": [ { @@ -79,10 +80,10 @@ "maxDataPoints": true, "cacheTimeout": true }, - + "executable": "gpx_cloudmonitoring", "info": { "description": "Data source for Google's monitoring service (formerly named Stackdriver)", - "version": "1.0.0", + "version": "%VERSION%", "logos": { "small": "img/cloud_monitoring_logo.svg", "large": "img/cloud_monitoring_logo.svg" diff --git a/public/app/plugins/datasource/cloud-monitoring/specs/datasource.test.ts b/public/app/plugins/datasource/cloud-monitoring/specs/datasource.test.ts index e5875f4be1480..6b8590fb01f71 100644 --- a/public/app/plugins/datasource/cloud-monitoring/specs/datasource.test.ts +++ b/public/app/plugins/datasource/cloud-monitoring/specs/datasource.test.ts @@ -1,10 +1,9 @@ import { DataQueryRequest, DataSourceInstanceSettings, toUtc } from '@grafana/data'; import { getTemplateSrv, TemplateSrv } from '@grafana/runtime'; // will use the version in __mocks__ -import { CustomVariableModel } from '../../../../features/variables/types'; import CloudMonitoringDataSource from '../datasource'; import { CloudMonitoringQuery } from '../types/query'; -import { CloudMonitoringOptions } from '../types/types'; +import { CloudMonitoringOptions, CustomVariableModel } from '../types/types'; let getTempVars = () => [] as CustomVariableModel[]; let replace = () => ''; diff --git a/public/app/plugins/datasource/cloud-monitoring/tsconfig.json b/public/app/plugins/datasource/cloud-monitoring/tsconfig.json new file mode 100644 index 0000000000000..7daf2ee8abab6 --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@grafana/plugin-configs/tsconfig.json", + "include": ["."] +} diff --git a/public/app/plugins/datasource/cloud-monitoring/types/query.ts b/public/app/plugins/datasource/cloud-monitoring/types/query.ts index a1e5a2abe299a..a202c85bd3e35 100644 --- a/public/app/plugins/datasource/cloud-monitoring/types/query.ts +++ b/public/app/plugins/datasource/cloud-monitoring/types/query.ts @@ -1,19 +1,15 @@ import { CloudMonitoringQuery as CloudMonitoringQueryBase, QueryType } from '../dataquery.gen'; export { QueryType }; -export { - TimeSeriesList, - PreprocessorType, +export { PreprocessorType, MetricKind, AlignmentTypes, ValueTypes, MetricFindQueryTypes } from '../dataquery.gen'; +export type { TimeSeriesQuery, SLOQuery, + TimeSeriesList, MetricQuery, - MetricKind, + PromQLQuery, LegacyCloudMonitoringAnnotationQuery, Filter, - AlignmentTypes, - ValueTypes, - MetricFindQueryTypes, - PromQLQuery, } from '../dataquery.gen'; /** diff --git a/public/app/plugins/datasource/cloud-monitoring/types/types.ts b/public/app/plugins/datasource/cloud-monitoring/types/types.ts index a97a1f913520e..c7eb809c739d9 100644 --- a/public/app/plugins/datasource/cloud-monitoring/types/types.ts +++ b/public/app/plugins/datasource/cloud-monitoring/types/types.ts @@ -1,4 +1,4 @@ -import { DataQuery, SelectableValue } from '@grafana/data'; +import { DataQuery, SelectableValue, VariableWithMultiSupport } from '@grafana/data'; import { DataSourceOptions, DataSourceSecureJsonData } from '@grafana/google-sdk'; import { MetricKind } from './query'; @@ -61,3 +61,7 @@ export interface CustomMetaData { export interface PostResponse { results: Record<string, any>; } + +export interface CustomVariableModel extends VariableWithMultiSupport { + type: 'custom'; +} diff --git a/public/app/plugins/datasource/cloud-monitoring/webpack.config.ts b/public/app/plugins/datasource/cloud-monitoring/webpack.config.ts new file mode 100644 index 0000000000000..4da5a990cfa57 --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/webpack.config.ts @@ -0,0 +1,3 @@ +import config from '@grafana/plugin-configs/webpack.config'; + +export default config; diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/AnnotationQueryRunner.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/AnnotationQueryRunner.ts index bae51a44dec4c..919059465d4b7 100644 --- a/public/app/plugins/datasource/cloudwatch/__mocks__/AnnotationQueryRunner.ts +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/AnnotationQueryRunner.ts @@ -1,7 +1,6 @@ import { of } from 'rxjs'; import { CustomVariableModel, DataQueryRequest } from '@grafana/data'; -import { TemplateSrv } from 'app/features/templating/template_srv'; import { CloudWatchAnnotationQueryRunner } from '../query-runner/CloudWatchAnnotationQueryRunner'; import { CloudWatchQuery } from '../types'; @@ -10,10 +9,7 @@ import { CloudWatchSettings, setupMockedTemplateService } from './CloudWatchData import { TimeRangeMock } from './timeRange'; export function setupMockedAnnotationQueryRunner({ variables }: { variables?: CustomVariableModel[] }) { - let templateService = new TemplateSrv(); - if (variables) { - templateService = setupMockedTemplateService(variables); - } + const templateService = setupMockedTemplateService(variables); const queryMock = jest.fn().mockReturnValue(of({})); const runner = new CloudWatchAnnotationQueryRunner(CloudWatchSettings, templateService); diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts index 8ad1ce877dbb1..e698004926aeb 100644 --- a/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts @@ -6,22 +6,70 @@ import { DataSourcePluginMeta, PluginMetaInfo, PluginType, + ScopedVars, VariableHide, } from '@grafana/data'; -import { getBackendSrv, setBackendSrv, DataSourceWithBackend } from '@grafana/runtime'; -import { TemplateSrv } from 'app/features/templating/template_srv'; +import { getBackendSrv, setBackendSrv, DataSourceWithBackend, TemplateSrv } from '@grafana/runtime'; -import { initialCustomVariableModelState } from '../__mocks__/variables'; +import { initialCustomVariableModelState } from '../__mocks__/CloudWatchVariables'; import { CloudWatchDatasource } from '../datasource'; import { CloudWatchJsonData } from '../types'; +import { getVariableName } from '../utils/templateVariableUtils'; const queryMock = jest.fn().mockReturnValue(of({ data: [] })); jest.spyOn(DataSourceWithBackend.prototype, 'query').mockImplementation((args) => queryMock(args)); +const separatorMap = new Map<string, string>([ + ['pipe', '|'], + ['raw', ','], + ['text', ' + '], +]); -export function setupMockedTemplateService(variables: CustomVariableModel[]) { - const templateService = new TemplateSrv(); - templateService.init(variables); - templateService.getVariables = jest.fn().mockReturnValue(variables); +export function setupMockedTemplateService(variables?: CustomVariableModel[]): TemplateSrv { + const templateService = { + replace: jest.fn().mockImplementation((input: string, scopedVars?: ScopedVars, format?: string) => { + if (!input) { + return ''; + } + let output = input; + ['datasource', 'dimension'].forEach((name) => { + const variable = scopedVars ? scopedVars[name] : undefined; + if (variable) { + output = output.replace('$' + name, variable.value); + } + }); + + if (variables) { + variables.forEach((variable) => { + let repVal = ''; + let value = format === 'text' ? variable.current.text : variable.current.value; + let separator = separatorMap.get(format ?? 'raw'); + if (Array.isArray(value)) { + repVal = value.join(separator); + } else { + repVal = value; + } + output = output.replace('$' + variable.name, repVal); + output = output.replace('[[' + variable.name + ']]', repVal); + }); + } + return output; + }), + getVariables: jest.fn().mockReturnValue(variables ?? []), + containsTemplate: jest.fn().mockImplementation((name) => { + const varName = getVariableName(name); + if (!varName || !variables) { + return false; + } + let found = false; + variables.forEach((variable) => { + if (varName === variable.name) { + found = true; + } + }); + return found; + }), + updateTimeRange: jest.fn(), + }; return templateService; } @@ -62,22 +110,14 @@ export const CloudWatchSettings: DataSourceInstanceSettings<CloudWatchJsonData> export function setupMockedDataSource({ variables, - mockGetVariableName = true, getMock = jest.fn(), customInstanceSettings = CloudWatchSettings, }: { getMock?: jest.Func; variables?: CustomVariableModel[]; - mockGetVariableName?: boolean; customInstanceSettings?: DataSourceInstanceSettings<CloudWatchJsonData>; } = {}) { - let templateService = new TemplateSrv(); - if (variables) { - templateService = setupMockedTemplateService(variables); - if (mockGetVariableName) { - templateService.getVariableName = (name: string) => name.replace('$', ''); - } - } + const templateService = setupMockedTemplateService(variables); const datasource = new CloudWatchDatasource(customInstanceSettings, templateService); datasource.getVariables = () => ['test']; diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/variables.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchVariables.ts similarity index 100% rename from public/app/plugins/datasource/cloudwatch/__mocks__/variables.ts rename to public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchVariables.ts diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/LogsQueryRunner.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/LogsQueryRunner.ts index 94ff8a5f3ffb1..9c689d1a9007f 100644 --- a/public/app/plugins/datasource/cloudwatch/__mocks__/LogsQueryRunner.ts +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/LogsQueryRunner.ts @@ -2,7 +2,6 @@ import { of } from 'rxjs'; import { CustomVariableModel, DataFrame, DataSourceInstanceSettings } from '@grafana/data'; import { BackendDataSourceResponse, toDataQueryResponse } from '@grafana/runtime'; -import { TemplateSrv } from 'app/features/templating/template_srv'; import { CloudWatchLogsQueryRunner } from '../query-runner/CloudWatchLogsQueryRunner'; import { CloudWatchJsonData, CloudWatchLogsQueryStatus, CloudWatchLogsRequest } from '../types'; @@ -22,13 +21,7 @@ export function setupMockedLogsQueryRunner({ mockGetVariableName?: boolean; settings?: DataSourceInstanceSettings<CloudWatchJsonData>; } = {}) { - let templateService = new TemplateSrv(); - if (variables) { - templateService = setupMockedTemplateService(variables); - if (mockGetVariableName) { - templateService.getVariableName = (name: string) => name; - } - } + let templateService = setupMockedTemplateService(variables); const queryMock = jest.fn().mockReturnValue(of(toDataQueryResponse({ data }))); const runner = new CloudWatchLogsQueryRunner(settings, templateService); diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/MetricsQueryRunner.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/MetricsQueryRunner.ts index 362983b0a2cf7..ace86da0ec5c6 100644 --- a/public/app/plugins/datasource/cloudwatch/__mocks__/MetricsQueryRunner.ts +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/MetricsQueryRunner.ts @@ -1,8 +1,6 @@ -import { of, throwError } from 'rxjs'; +import { of } from 'rxjs'; -import { CustomVariableModel, DataQueryError, DataQueryRequest, DataSourceInstanceSettings } from '@grafana/data'; -import { BackendDataSourceResponse, toDataQueryResponse } from '@grafana/runtime'; -import { TemplateSrv } from 'app/features/templating/template_srv'; +import { CustomVariableModel, DataQueryRequest, DataQueryResponse, DataSourceInstanceSettings } from '@grafana/data'; import { CloudWatchMetricsQueryRunner } from '../query-runner/CloudWatchMetricsQueryRunner'; import { CloudWatchJsonData, CloudWatchQuery } from '../types'; @@ -11,31 +9,17 @@ import { CloudWatchSettings, setupMockedTemplateService } from './CloudWatchData import { TimeRangeMock } from './timeRange'; export function setupMockedMetricsQueryRunner({ - data = { - results: {}, - }, + response = { data: [] }, variables, - mockGetVariableName = true, - errorResponse, instanceSettings = CloudWatchSettings, }: { - data?: BackendDataSourceResponse; + response?: DataQueryResponse; variables?: CustomVariableModel[]; - mockGetVariableName?: boolean; - errorResponse?: DataQueryError; instanceSettings?: DataSourceInstanceSettings<CloudWatchJsonData>; } = {}) { - let templateService = new TemplateSrv(); - if (variables) { - templateService = setupMockedTemplateService(variables); - if (mockGetVariableName) { - templateService.getVariableName = (name: string) => name.replace('$', ''); - } - } + const templateService = setupMockedTemplateService(variables); - const queryMock = errorResponse - ? jest.fn().mockImplementation(() => throwError(errorResponse)) - : jest.fn().mockReturnValue(of(toDataQueryResponse({ data }))); + const queryMock = jest.fn().mockImplementation(() => of(response)); const runner = new CloudWatchMetricsQueryRunner(instanceSettings, templateService); const request: DataQueryRequest<CloudWatchQuery> = { diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/ResourcesAPI.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/ResourcesAPI.ts index 920cea52cf7d2..6da47f3c6766f 100644 --- a/public/app/plugins/datasource/cloudwatch/__mocks__/ResourcesAPI.ts +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/ResourcesAPI.ts @@ -1,6 +1,5 @@ import { CustomVariableModel } from '@grafana/data'; import { getBackendSrv, setBackendSrv } from '@grafana/runtime'; -import { TemplateSrv } from 'app/features/templating/template_srv'; import { ResourcesAPI } from '../resources/ResourcesAPI'; @@ -16,7 +15,7 @@ export function setupMockedResourcesAPI({ variables?: CustomVariableModel[]; mockGetVariableName?: boolean; } = {}) { - let templateService = variables ? setupMockedTemplateService(variables) : new TemplateSrv(); + let templateService = setupMockedTemplateService(variables); const api = new ResourcesAPI(CloudWatchSettings, templateService); let resourceRequestMock = getMock ? getMock : jest.fn().mockReturnValue(response); diff --git a/public/app/plugins/datasource/cloudwatch/components/AnnotationQueryEditor/AnnotationQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/AnnotationQueryEditor/AnnotationQueryEditor.tsx index a88bdf61eb4c8..cd8c3517e527f 100644 --- a/public/app/plugins/datasource/cloudwatch/components/AnnotationQueryEditor/AnnotationQueryEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/AnnotationQueryEditor/AnnotationQueryEditor.tsx @@ -1,8 +1,8 @@ import React, { ChangeEvent } from 'react'; import { QueryEditorProps } from '@grafana/data'; -import { EditorField, EditorHeader, EditorRow, EditorSwitch, InlineSelect, Space } from '@grafana/experimental'; -import { Alert, Input } from '@grafana/ui'; +import { EditorField, EditorHeader, EditorRow, EditorSwitch, InlineSelect } from '@grafana/experimental'; +import { Alert, Input, Space } from '@grafana/ui'; import { CloudWatchDatasource } from '../../datasource'; import { isCloudWatchAnnotationQuery } from '../../guards'; diff --git a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/ConfigEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/ConfigEditor.test.tsx index 31dc0f986463f..a83e5d70d5d65 100644 --- a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/ConfigEditor.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/ConfigEditor.test.tsx @@ -1,25 +1,27 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { Provider } from 'react-redux'; import { AwsAuthType } from '@grafana/aws-sdk'; import { PluginContextProvider, PluginMeta, PluginMetaInfo, PluginType } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { configureStore } from 'app/store/configureStore'; -import { CloudWatchSettings, setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource'; +import { + CloudWatchSettings, + setupMockedDataSource, + setupMockedTemplateService, +} from '../../__mocks__/CloudWatchDataSource'; import { CloudWatchDatasource } from '../../datasource'; -import { ConfigEditor, Props } from './ConfigEditor'; +import { + ConfigEditor, + Props, + ARN_DEPRECATION_WARNING_MESSAGE, + CREDENTIALS_AUTHENTICATION_WARNING_MESSAGE, +} from './ConfigEditor'; -const datasource = new CloudWatchDatasource(CloudWatchSettings); +const datasource = new CloudWatchDatasource(CloudWatchSettings, setupMockedTemplateService()); const loadDataSourceMock = jest.fn(); -jest.mock('app/features/plugins/datasource_srv', () => ({ - getDatasourceSrv: () => ({ - loadDatasource: loadDataSourceMock, - }), -})); jest.mock('./XrayLinkConfig', () => ({ XrayLinkConfig: () => <></>, @@ -36,6 +38,9 @@ jest.mock('@grafana/runtime', () => ({ put: putMock, get: getMock, }), + getDataSourceSrv: () => ({ + get: loadDataSourceMock, + }), getAppEvents: () => mockAppEvents, config: { ...jest.requireActual('@grafana/runtime').config, @@ -87,7 +92,6 @@ const props: Props = { }; const setup = (optionOverrides?: Partial<Props['options']>) => { - const store = configureStore(); const newProps = { ...props, options: { @@ -106,9 +110,7 @@ const setup = (optionOverrides?: Partial<Props['options']>) => { return render( <PluginContextProvider meta={meta}> - <Provider store={store}> - <ConfigEditor {...newProps} /> - </Provider> + <ConfigEditor {...newProps} /> </PluginContextProvider> ); }; @@ -154,6 +156,45 @@ describe('Render', () => { await waitFor(async () => expect(screen.getByText('Credentials Profile Name')).toBeInTheDocument()); }); + it('should show a warning if `credentials` auth type is used without a profile or database configured', async () => { + setup({ + jsonData: { + authType: AwsAuthType.Credentials, + profile: undefined, + database: undefined, + }, + }); + await waitFor(async () => + expect(screen.getByText(CREDENTIALS_AUTHENTICATION_WARNING_MESSAGE)).toBeInTheDocument() + ); + }); + + it('should not show a warning if `credentials` auth type is used and a profile is configured', async () => { + setup({ + jsonData: { + authType: AwsAuthType.Credentials, + profile: 'profile', + database: undefined, + }, + }); + await waitFor(async () => + expect(screen.queryByText(CREDENTIALS_AUTHENTICATION_WARNING_MESSAGE)).not.toBeInTheDocument() + ); + }); + + it('should not show a warning if `credentials` auth type is used and a database is configured', async () => { + setup({ + jsonData: { + authType: AwsAuthType.Credentials, + profile: undefined, + database: 'database', + }, + }); + await waitFor(async () => + expect(screen.queryByText(CREDENTIALS_AUTHENTICATION_WARNING_MESSAGE)).not.toBeInTheDocument() + ); + }); + it('should show access key and secret access key fields when the datasource has not been configured before', async () => { setup({ jsonData: { @@ -175,6 +216,20 @@ describe('Render', () => { await waitFor(async () => expect(screen.getByText('Assume Role ARN')).toBeInTheDocument()); }); + it('should display namespace field', async () => { + setup(); + await waitFor(async () => expect(screen.getByText('Namespaces of Custom Metrics')).toBeInTheDocument()); + }); + + it('should show a deprecation warning if `arn` auth type is used', async () => { + setup({ + jsonData: { + authType: AwsAuthType.ARN, + }, + }); + await waitFor(async () => expect(screen.getByText(ARN_DEPRECATION_WARNING_MESSAGE)).toBeInTheDocument()); + }); + it('should display log group selector field', async () => { setup(); await waitFor(async () => expect(screen.getByText('Select log groups')).toBeInTheDocument()); diff --git a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/ConfigEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/ConfigEditor.tsx index 6c102817dc45f..f6b551955da27 100644 --- a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/ConfigEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/ConfigEditor.tsx @@ -11,13 +11,8 @@ import { DataSourceTestFailed, } from '@grafana/data'; import { ConfigSection } from '@grafana/experimental'; -import { getAppEvents, usePluginInteractionReporter } from '@grafana/runtime'; -import { Input, InlineField, FieldProps, SecureSocksProxySettings, Field, Divider } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; -import { config } from 'app/core/config'; -import { createWarningNotification } from 'app/core/copy/appNotification'; -import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { store } from 'app/store/store'; +import { getAppEvents, usePluginInteractionReporter, getDataSourceSrv, config } from '@grafana/runtime'; +import { Alert, Input, InlineField, FieldProps, SecureSocksProxySettings, Field, Divider } from '@grafana/ui'; import { CloudWatchDatasource } from '../../datasource'; import { SelectableResourceValue } from '../../resources/types'; @@ -31,11 +26,17 @@ export type Props = DataSourcePluginOptionsEditorProps<CloudWatchJsonData, Cloud type LogGroupFieldState = Pick<FieldProps, 'invalid'> & { error?: string | null }; +export const ARN_DEPRECATION_WARNING_MESSAGE = + 'Since grafana 7.3 authentication type "arn" is deprecated, falling back to default SDK provider'; +export const CREDENTIALS_AUTHENTICATION_WARNING_MESSAGE = + 'As of grafana 7.3 authentication type "credentials" should be used only for shared file credentials. \ +If you don\'t have a credentials file, switch to the default SDK provider for extracting credentials \ +from environment variables or IAM roles'; + export const ConfigEditor = (props: Props) => { const { options, onOptionsChange } = props; const { defaultLogGroups, logsTimeout, defaultRegion, logGroups } = options.jsonData; const datasource = useDatasource(props); - useAuthenticationWarning(options.jsonData); const logsTimeoutError = useTimoutValidation(logsTimeout); const saved = useDataSourceSavedState(props); const [logGroupFieldState, setLogGroupFieldState] = useState<LogGroupFieldState>({ @@ -70,8 +71,25 @@ export const ConfigEditor = (props: Props) => { } }, [datasource, externalId]); + const [warning, setWarning] = useState<string | null>(null); + const dismissWarning = () => { + setWarning(null); + }; + useEffect(() => { + if (options.jsonData.authType === 'arn') { + setWarning(ARN_DEPRECATION_WARNING_MESSAGE); + } else if (options.jsonData.authType === 'credentials' && !options.jsonData.profile && !options.jsonData.database) { + setWarning(CREDENTIALS_AUTHENTICATION_WARNING_MESSAGE); + } + }, [options.jsonData.authType, options.jsonData.database, options.jsonData.profile]); + return newFormStylingEnabled ? ( <div className="width-30"> + {warning && ( + <Alert title="CloudWatch Authentication" severity="warning" onRemove={dismissWarning}> + {warning} + </Alert> + )} <ConnectionConfig {...props} newFormStylingEnabled={true} @@ -89,7 +107,15 @@ export const ConfigEditor = (props: Props) => { }) } externalId={externalId} - /> + > + <Field label="Namespaces of Custom Metrics"> + <Input + placeholder="Namespace1,Namespace2" + value={options.jsonData.customMetricsNamespaces || ''} + onChange={onUpdateDatasourceJsonDataOption(props, 'customMetricsNamespaces')} + /> + </Field> + </ConnectionConfig> {config.secureSocksDSProxyEnabled && ( <SecureSocksProxySettingsNewStyling options={options} onOptionsChange={onOptionsChange} /> )} @@ -168,6 +194,11 @@ export const ConfigEditor = (props: Props) => { </div> ) : ( <> + {warning && ( + <Alert title="CloudWatch Authentication" severity="warning" onRemove={dismissWarning}> + {warning} + </Alert> + )} <ConnectionConfig {...props} labelWidth={29} @@ -274,31 +305,13 @@ export const ConfigEditor = (props: Props) => { ); }; -function useAuthenticationWarning(jsonData: CloudWatchJsonData) { - const addWarning = (message: string) => { - store.dispatch(notifyApp(createWarningNotification('CloudWatch Authentication', message))); - }; - - useEffect(() => { - if (jsonData.authType === 'arn') { - addWarning('Since grafana 7.3 authentication type "arn" is deprecated, falling back to default SDK provider'); - } else if (jsonData.authType === 'credentials' && !jsonData.profile && !jsonData.database) { - addWarning( - 'As of grafana 7.3 authentication type "credentials" should be used only for shared file credentials. \ - If you don\'t have a credentials file, switch to the default SDK provider for extracting credentials \ - from environment variables or IAM roles' - ); - } - }, [jsonData.authType, jsonData.database, jsonData.profile]); -} - function useDatasource(props: Props) { const [datasource, setDatasource] = useState<CloudWatchDatasource>(); useEffect(() => { if (props.options.version) { - getDatasourceSrv() - .loadDatasource(props.options.name) + getDataSourceSrv() + .get(props.options.name) .then((datasource) => { if (datasource instanceof CloudWatchDatasource) { setDatasource(datasource); diff --git a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/XrayLinkConfig.tsx b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/XrayLinkConfig.tsx index 619621253b68e..f963a0547e322 100644 --- a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/XrayLinkConfig.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/XrayLinkConfig.tsx @@ -3,9 +3,8 @@ import React from 'react'; import { GrafanaTheme2, DataSourceInstanceSettings } from '@grafana/data'; import { ConfigSection } from '@grafana/experimental'; +import { DataSourcePicker, getDataSourceSrv } from '@grafana/runtime'; import { Alert, Field, InlineField, useStyles2 } from '@grafana/ui'; -import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; -import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; const getStyles = (theme: GrafanaTheme2) => ({ infoText: css` @@ -23,7 +22,7 @@ interface Props { const xRayDsId = 'grafana-x-ray-datasource'; export function XrayLinkConfig({ newFormStyling, datasourceUid, onChange }: Props) { - const hasXrayDatasource = Boolean(getDatasourceSrv().getList({ pluginId: xRayDsId }).length); + const hasXrayDatasource = Boolean(getDataSourceSrv().getList({ pluginId: xRayDsId }).length); const styles = useStyles2(getStyles); diff --git a/public/app/plugins/datasource/cloudwatch/components/Errors/ThrottlingErrorMessage.tsx b/public/app/plugins/datasource/cloudwatch/components/Errors/ThrottlingErrorMessage.tsx index 2dcdba5176523..1e03ad21c9084 100644 --- a/public/app/plugins/datasource/cloudwatch/components/Errors/ThrottlingErrorMessage.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/Errors/ThrottlingErrorMessage.tsx @@ -20,7 +20,7 @@ export const ThrottlingErrorMessage = ({ region }: Props) => ( target="_blank" rel="noreferrer" className="text-link" - href="https://grafana.com/docs/grafana/latest/datasources/cloudwatch/#service-quotas" + href="https://grafana.com/docs/grafana/latest/datasources/cloudwatch/#manage-service-quotas" > documentation </a> diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryEditor.tsx index 14222b9af46c3..fd57a6f45877e 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryEditor.tsx @@ -1,16 +1,14 @@ import { css } from '@emotion/css'; import React, { memo } from 'react'; -import { AbsoluteTimeRange, QueryEditorProps } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { QueryEditorProps } from '@grafana/data'; import { InlineFormLabel } from '@grafana/ui'; import { CloudWatchDatasource } from '../../../datasource'; import { CloudWatchJsonData, CloudWatchLogsQuery, CloudWatchQuery } from '../../../types'; import { CloudWatchLink } from './CloudWatchLink'; -import CloudWatchLogsQueryFieldMonaco from './LogsQueryField'; -import CloudWatchLogsQueryField from './LogsQueryFieldOld'; +import CloudWatchLogsQueryField from './LogsQueryField'; type Props = QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData> & { query: CloudWatchLogsQuery; @@ -24,34 +22,9 @@ const labelClass = css` export const CloudWatchLogsQueryEditor = memo(function CloudWatchLogsQueryEditor(props: Props) { const { query, data, datasource } = props; - let absolute: AbsoluteTimeRange; - if (data?.request?.range?.from) { - const { range } = data.request; - absolute = { - from: range.from.valueOf(), - to: range.to.valueOf(), - }; - } else { - absolute = { - from: Date.now() - 10000, - to: Date.now(), - }; - } - - return config.featureToggles.cloudWatchLogsMonacoEditor ? ( - <CloudWatchLogsQueryFieldMonaco - {...props} - ExtraFieldElement={ - <InlineFormLabel className={`gf-form-label--btn ${labelClass}`} width="auto" tooltip="Link to Graph in AWS"> - <CloudWatchLink query={query} panelData={data} datasource={datasource} /> - </InlineFormLabel> - } - /> - ) : ( + return ( <CloudWatchLogsQueryField {...props} - history={[]} - absoluteRange={absolute} ExtraFieldElement={ <InlineFormLabel className={`gf-form-label--btn ${labelClass}`} width="auto" tooltip="Link to Graph in AWS"> <CloudWatchLink query={query} panelData={data} datasource={datasource} /> diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryField.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryField.tsx index 52c44d1d252aa..cf30ff6665eec 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryField.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryField.tsx @@ -18,7 +18,7 @@ export interface CloudWatchLogsQueryFieldProps ExtraFieldElement?: ReactNode; query: CloudWatchLogsQuery; } -export const CloudWatchLogsQueryFieldMonaco = (props: CloudWatchLogsQueryFieldProps) => { +export const CloudWatchLogsQueryField = (props: CloudWatchLogsQueryFieldProps) => { const { query, datasource, onChange, ExtraFieldElement, data } = props; const showError = data?.error?.refId === query.refId; @@ -141,4 +141,4 @@ export const CloudWatchLogsQueryFieldMonaco = (props: CloudWatchLogsQueryFieldPr ); }; -export default withTheme2(CloudWatchLogsQueryFieldMonaco); +export default withTheme2(CloudWatchLogsQueryField); diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryFieldOld.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryFieldOld.tsx deleted file mode 100644 index 925ddabf69b60..0000000000000 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryFieldOld.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { LanguageMap, languages as prismLanguages } from 'prismjs'; -import React, { ReactNode } from 'react'; -import { Node, Plugin } from 'slate'; -import { Editor } from 'slate-react'; - -import { AbsoluteTimeRange, QueryEditorProps } from '@grafana/data'; -import { - BracesPlugin, - QueryField, - SlatePrism, - Themeable2, - TypeaheadInput, - TypeaheadOutput, - withTheme2, -} from '@grafana/ui'; - -// Utils & Services -// dom also includes Element polyfills -import { CloudWatchDatasource } from '../../../datasource'; -import syntax from '../../../language/cloudwatch-logs/syntax'; -import { CloudWatchJsonData, CloudWatchLogsQuery, CloudWatchQuery, LogGroup } from '../../../types'; -import { getStatsGroups } from '../../../utils/query/getStatsGroups'; -import { LogGroupsFieldWrapper } from '../../shared/LogGroups/LogGroupsField'; - -export interface CloudWatchLogsQueryFieldProps - extends QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData>, - Themeable2 { - absoluteRange: AbsoluteTimeRange; - onLabelsRefresh?: () => void; - ExtraFieldElement?: ReactNode; - query: CloudWatchLogsQuery; -} -const plugins: Array<Plugin<Editor>> = [ - BracesPlugin(), - SlatePrism( - { - onlyIn: (node: Node) => node.object === 'block' && node.type === 'code_block', - getSyntax: (node: Node) => 'cloudwatch', - }, - { ...(prismLanguages as LanguageMap), cloudwatch: syntax } - ), -]; -export const CloudWatchLogsQueryField = (props: CloudWatchLogsQueryFieldProps) => { - const { query, datasource, onChange, ExtraFieldElement, data } = props; - - const showError = data?.error?.refId === query.refId; - const cleanText = datasource.languageProvider.cleanText; - - const onChangeQuery = (value: string) => { - // Send text change to parent - const nextQuery = { - ...query, - expression: value, - statsGroups: getStatsGroups(value), - }; - onChange(nextQuery); - }; - - const onTypeahead = async (typeahead: TypeaheadInput): Promise<TypeaheadOutput> => { - const { datasource, query } = props; - const { logGroups } = query; - - if (!datasource.languageProvider) { - return { suggestions: [] }; - } - - const { history, absoluteRange } = props; - const { prefix, text, value, wrapperClasses, labelKey, editor } = typeahead; - - return await datasource.languageProvider.provideCompletionItems( - { text, value, prefix, wrapperClasses, labelKey, editor }, - { - history, - absoluteRange, - logGroups: logGroups, - region: query.region, - } - ); - }; - - return ( - <> - <LogGroupsFieldWrapper - region={query.region} - datasource={datasource} - legacyLogGroupNames={query.logGroupNames} - logGroups={query.logGroups} - onChange={(logGroups: LogGroup[]) => { - onChange({ ...query, logGroups, logGroupNames: undefined }); - }} - //legacy props can be removed once we remove support for Legacy Log Group Selector - legacyOnChange={(logGroups: string[]) => { - onChange({ ...query, logGroupNames: logGroups }); - }} - /> - <div className="gf-form-inline gf-form-inline--nowrap flex-grow-1"> - <div className="gf-form gf-form--grow flex-shrink-1"> - <QueryField - additionalPlugins={plugins} - query={query.expression ?? ''} - onChange={onChangeQuery} - onTypeahead={onTypeahead} - cleanText={cleanText} - placeholder="Enter a CloudWatch Logs Insights query (run with Shift+Enter)" - portalOrigin="cloudwatch" - /> - </div> - {ExtraFieldElement} - </div> - {showError ? ( - <div className="query-row-break"> - <div className="prom-query-field-info text-error">{data?.error?.message}</div> - </div> - ) : null} - </> - ); -}; - -export default withTheme2(CloudWatchLogsQueryField); diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MathExpressionQueryField.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MathExpressionQueryField.tsx index 110d8171c1023..5ac7ca8e22679 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MathExpressionQueryField.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MathExpressionQueryField.tsx @@ -29,8 +29,9 @@ export function MathExpressionQueryField({ expression: query, onChange, datasour // We may wish to consider abstracting it into the grafana/ui repo in the future const updateElementHeight = () => { const containerDiv = containerRef.current; - if (containerDiv !== null && editor.getContentHeight() < 200) { - const pixelHeight = Math.max(32, editor.getContentHeight()); + if (containerDiv !== null) { + const maxPixelHeight = Math.min(200, editor.getContentHeight()); + const pixelHeight = Math.max(32, maxPixelHeight); containerDiv.style.height = `${pixelHeight}px`; containerDiv.style.width = '100%'; const pixelWidth = containerDiv.clientWidth; diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MetricsQueryEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MetricsQueryEditor.test.tsx index d5519dbbc2ff6..0be9d041cf203 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MetricsQueryEditor.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MetricsQueryEditor.test.tsx @@ -4,9 +4,9 @@ import selectEvent from 'react-select-event'; import { CustomVariableModel, DataSourceInstanceSettings } from '@grafana/data'; import * as ui from '@grafana/ui'; -import { TemplateSrv } from 'app/features/templating/template_srv'; -import { initialVariableModelState } from '../../../__mocks__/variables'; +import { setupMockedTemplateService } from '../../../__mocks__/CloudWatchDataSource'; +import { initialVariableModelState } from '../../../__mocks__/CloudWatchVariables'; import { CloudWatchDatasource } from '../../../datasource'; import { CloudWatchJsonData, MetricEditorMode, MetricQueryType } from '../../../types'; @@ -24,7 +24,6 @@ const setup = () => { jsonData: { defaultRegion: 'us-east-1' }, } as DataSourceInstanceSettings<CloudWatchJsonData>; - const templateSrv = new TemplateSrv(); const variable: CustomVariableModel = { ...initialVariableModelState, id: 'var3', @@ -41,7 +40,7 @@ const setup = () => { query: '', type: 'custom', }; - templateSrv.init([variable]); + const templateSrv = setupMockedTemplateService([variable]); const datasource = new CloudWatchDatasource(instanceSettings, templateSrv); datasource.metricFindQuery = async () => [{ value: 'test', label: 'test', text: 'test' }]; diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MetricsQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MetricsQueryEditor.tsx index 83c0a2f6ef762..884f9d373fd0f 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MetricsQueryEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MetricsQueryEditor.tsx @@ -1,8 +1,8 @@ import React, { ChangeEvent, useCallback, useEffect, useState } from 'react'; import { QueryEditorProps, SelectableValue } from '@grafana/data'; -import { EditorField, EditorRow, InlineSelect, Space } from '@grafana/experimental'; -import { ConfirmModal, Input, RadioButtonGroup } from '@grafana/ui'; +import { EditorField, EditorRow, InlineSelect } from '@grafana/experimental'; +import { ConfirmModal, Input, RadioButtonGroup, Space } from '@grafana/ui'; import { CloudWatchDatasource } from '../../../datasource'; import useMigratedMetricsQuery from '../../../migrations/useMigratedMetricsQuery'; diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLBuilderEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLBuilderEditor.test.tsx index 13bf64016d46d..bfd11f2bc85e1 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLBuilderEditor.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLBuilderEditor.test.tsx @@ -95,12 +95,15 @@ describe('Cloudwatch SQLBuilderEditor', () => { render(<SQLBuilderEditor {...baseProps} query={query} />); await waitFor(() => - expect(datasource.resources.getDimensionKeys).toHaveBeenCalledWith({ - namespace: 'AWS/EC2', - region: query.region, - dimensionFilters: { InstanceId: null }, - metricName: undefined, - }) + expect(datasource.resources.getDimensionKeys).toHaveBeenCalledWith( + { + namespace: 'AWS/EC2', + region: query.region, + dimensionFilters: { InstanceId: null }, + metricName: undefined, + }, + false + ) ); expect(screen.getByText('AWS/EC2')).toBeInTheDocument(); expect(screen.getByLabelText('With schema')).toBeChecked(); diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLFilter.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLFilter.tsx index 079c67d0efe1a..6c4cfc48706de 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLFilter.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLFilter.tsx @@ -1,9 +1,10 @@ +import { css } from '@emotion/css'; import React, { useMemo, useState } from 'react'; import { useAsyncFn } from 'react-use'; import { SelectableValue, toOption } from '@grafana/data'; import { AccessoryButton, EditorList, InputGroup } from '@grafana/experimental'; -import { Select } from '@grafana/ui'; +import { Alert, Select, useStyles2 } from '@grafana/ui'; import { CloudWatchDatasource } from '../../../../datasource'; import { @@ -11,7 +12,7 @@ import { QueryEditorOperatorExpression, QueryEditorPropertyType, } from '../../../../expressions'; -import { useDimensionKeys } from '../../../../hooks'; +import { useDimensionKeys, useEnsureVariableHasSingleSelection } from '../../../../hooks'; import { COMPARISON_OPERATORS, EQUALS } from '../../../../language/cloudwatch-sql/language'; import { CloudWatchMetricsQuery } from '../../../../types'; import { appendTemplateVariables } from '../../../../utils/utils'; @@ -101,6 +102,7 @@ interface FilterItemProps { const FilterItem = (props: FilterItemProps) => { const { datasource, query, filter, onChange, onDelete } = props; + const styles = useStyles2(getStyles); const sql = query.sql ?? {}; const namespace = getNamespaceFromExpression(sql.from); @@ -127,36 +129,58 @@ const FilterItem = (props: FilterItemProps) => { filter.property?.name, ]); + const propertyNameError = useEnsureVariableHasSingleSelection(datasource, filter.property?.name); + const operatorValueError = useEnsureVariableHasSingleSelection( + datasource, + typeof filter.operator?.value === 'string' ? filter.operator?.value : undefined + ); + return ( - <InputGroup> - <Select - width="auto" - value={filter.property?.name ? toOption(filter.property?.name) : null} - options={dimensionKeys} - allowCustomValue - onChange={({ value }) => value && onChange(setOperatorExpressionProperty(filter, value))} - /> - - <Select - width="auto" - value={filter.operator?.name && toOption(filter.operator.name)} - options={OPERATORS} - onChange={({ value }) => value && onChange(setOperatorExpressionName(filter, value))} - /> - - <Select - width="auto" - isLoading={state.loading} - value={ - filter.operator?.value && typeof filter.operator?.value === 'string' ? toOption(filter.operator?.value) : null - } - options={state.value} - allowCustomValue - onOpenMenu={loadOptions} - onChange={({ value }) => value && onChange(setOperatorExpressionValue(filter, value))} - /> - - <AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} /> - </InputGroup> + <div className={styles.container}> + <InputGroup> + <Select + width="auto" + value={filter.property?.name ? toOption(filter.property?.name) : null} + options={dimensionKeys} + allowCustomValue + onChange={({ value }) => value && onChange(setOperatorExpressionProperty(filter, value))} + /> + + <Select + width="auto" + value={filter.operator?.name && toOption(filter.operator.name)} + options={OPERATORS} + onChange={({ value }) => value && onChange(setOperatorExpressionName(filter, value))} + /> + + <Select + width="auto" + isLoading={state.loading} + value={ + filter.operator?.value && typeof filter.operator?.value === 'string' + ? toOption(filter.operator?.value) + : null + } + options={state.value} + allowCustomValue + onOpenMenu={loadOptions} + onChange={({ value }) => value && onChange(setOperatorExpressionValue(filter, value))} + /> + + <AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} /> + </InputGroup> + + {propertyNameError && ( + <Alert className={styles.alert} title={propertyNameError} severity="error" topSpacing={1} /> + )} + {operatorValueError && ( + <Alert className={styles.alert} title={operatorValueError} severity="error" topSpacing={1} /> + )} + </div> ); }; + +const getStyles = () => ({ + container: css({ display: 'inline-block' }), + alert: css({ minWidth: '100%', width: 'min-content' }), +}); diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.test.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.test.tsx index b1050541aec69..9f93308e59516 100644 --- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.test.tsx @@ -2,8 +2,14 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource'; + import { MultiFilter } from './MultiFilter'; +const ds = setupMockedDataSource({ + variables: [], +}); + describe('MultiFilters', () => { describe('when rendered with two existing multifilters', () => { it('should render two filter items', async () => { @@ -12,7 +18,7 @@ describe('MultiFilters', () => { InstanceGroup: ['Group1'], }; const onChange = jest.fn(); - render(<MultiFilter filters={filters} onChange={onChange} />); + render(<MultiFilter filters={filters} onChange={onChange} datasource={ds.datasource} />); const filterItems = screen.getAllByTestId('cloudwatch-multifilter-item'); expect(filterItems.length).toBe(2); @@ -28,7 +34,7 @@ describe('MultiFilters', () => { it('it should add the new item but not call onChange', async () => { const filters = {}; const onChange = jest.fn(); - render(<MultiFilter filters={filters} onChange={onChange} />); + render(<MultiFilter filters={filters} onChange={onChange} datasource={ds.datasource} />); await userEvent.click(screen.getByLabelText('Add')); expect(screen.getByTestId('cloudwatch-multifilter-item')).toBeInTheDocument(); @@ -40,7 +46,7 @@ describe('MultiFilters', () => { it('it should add the new item but not call onChange', async () => { const filters = {}; const onChange = jest.fn(); - render(<MultiFilter filters={filters} onChange={onChange} />); + render(<MultiFilter filters={filters} onChange={onChange} datasource={ds.datasource} />); await userEvent.click(screen.getByLabelText('Add')); const filterItemElement = screen.getByTestId('cloudwatch-multifilter-item'); @@ -60,7 +66,7 @@ describe('MultiFilters', () => { it('it should add the new item and trigger onChange', async () => { const filters = {}; const onChange = jest.fn(); - render(<MultiFilter filters={filters} onChange={onChange} />); + render(<MultiFilter filters={filters} onChange={onChange} datasource={ds.datasource} />); const label = await screen.findByLabelText('Add'); await userEvent.click(label); @@ -88,7 +94,7 @@ describe('MultiFilters', () => { it('it should change the key and call onChange', async () => { const filters = { 'my-key': ['my-value'] }; const onChange = jest.fn(); - render(<MultiFilter filters={filters} onChange={onChange} />); + render(<MultiFilter filters={filters} onChange={onChange} datasource={ds.datasource} />); const filterItemElement = screen.getByTestId('cloudwatch-multifilter-item'); expect(filterItemElement).toBeInTheDocument(); diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.tsx index 863c6e56ea054..94668e1f9cf4c 100644 --- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react'; import { EditorList } from '@grafana/experimental'; +import { type CloudWatchDatasource } from '../../datasource'; import { MultiFilters } from '../../types'; import { MultiFilterItem } from './MultiFilterItem'; @@ -11,6 +12,7 @@ export interface Props { filters?: MultiFilters; onChange: (filters: MultiFilters) => void; keyPlaceholder?: string; + datasource: CloudWatchDatasource; } export interface MultiFilterCondition { @@ -32,7 +34,7 @@ const filterConditionsToMultiFilters = (filters: MultiFilterCondition[]) => { return res; }; -export const MultiFilter = ({ filters, onChange, keyPlaceholder }: Props) => { +export const MultiFilter = ({ filters, onChange, keyPlaceholder, datasource }: Props) => { const [items, setItems] = useState<MultiFilterCondition[]>([]); useEffect(() => setItems(filters ? multiFiltersToFilterConditions(filters) : []), [filters]); const onFiltersChange = (newItems: Array<Partial<MultiFilterCondition>>) => { @@ -46,10 +48,12 @@ export const MultiFilter = ({ filters, onChange, keyPlaceholder }: Props) => { } }; - return <EditorList items={items} onChange={onFiltersChange} renderItem={makeRenderFilter(keyPlaceholder)} />; + return ( + <EditorList items={items} onChange={onFiltersChange} renderItem={makeRenderFilter(datasource, keyPlaceholder)} /> + ); }; -function makeRenderFilter(keyPlaceholder?: string) { +function makeRenderFilter(datasource: CloudWatchDatasource, keyPlaceholder?: string) { function renderFilter( item: MultiFilterCondition, onChange: (item: MultiFilterCondition) => void, @@ -61,6 +65,7 @@ function makeRenderFilter(keyPlaceholder?: string) { onChange={(item) => onChange(item)} onDelete={onDelete} keyPlaceholder={keyPlaceholder} + datasource={datasource} /> ); } diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilterItem.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilterItem.tsx index 40cfd9f413443..f9480b4802cba 100644 --- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilterItem.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilterItem.tsx @@ -3,7 +3,10 @@ import React, { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { AccessoryButton, InputGroup } from '@grafana/experimental'; -import { Input, useStyles2 } from '@grafana/ui'; +import { Alert, Input, useStyles2 } from '@grafana/ui'; + +import { type CloudWatchDatasource } from '../../datasource'; +import { useEnsureVariableHasSingleSelection } from '../../hooks'; import { MultiFilterCondition } from './MultiFilter'; @@ -12,11 +15,13 @@ export interface Props { onChange: (value: MultiFilterCondition) => void; onDelete: () => void; keyPlaceholder?: string; + datasource: CloudWatchDatasource; } -export const MultiFilterItem = ({ filter, onChange, onDelete, keyPlaceholder }: Props) => { +export const MultiFilterItem = ({ filter, onChange, onDelete, keyPlaceholder, datasource }: Props) => { const [localKey, setLocalKey] = useState(filter.key || ''); const [localValue, setLocalValue] = useState(filter.value?.join(', ') || ''); + const error = useEnsureVariableHasSingleSelection(datasource, filter.key); const styles = useStyles2(getOperatorStyles); return ( @@ -54,6 +59,7 @@ export const MultiFilterItem = ({ filter, onChange, onDelete, keyPlaceholder }: <AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} type="button" /> </InputGroup> + {error && <Alert title={error} severity="error" topSpacing={1} />} </div> ); }; diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.test.tsx index ae31f7b755b0c..d91dca40a29a3 100644 --- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.test.tsx @@ -152,12 +152,15 @@ describe('VariableEditor', () => { select(keySelect, 'v4', { container: document.body, }); - expect(ds.datasource.resources.getDimensionKeys).toHaveBeenCalledWith({ - namespace: 'z2', - region: 'a1', - metricName: 'i3', - dimensionFilters: undefined, - }); + expect(ds.datasource.resources.getDimensionKeys).toHaveBeenCalledWith( + { + namespace: 'z2', + region: 'a1', + metricName: 'i3', + dimensionFilters: undefined, + }, + false + ); await waitFor(() => { expect(onChange).toHaveBeenCalledWith({ ...defaultQuery, diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx index 262b57c9a7744..7d4eb75a70aad 100644 --- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx @@ -6,7 +6,14 @@ import { config } from '@grafana/runtime'; import { InlineField } from '@grafana/ui'; import { CloudWatchDatasource } from '../../datasource'; -import { useAccountOptions, useDimensionKeys, useMetrics, useNamespaces, useRegions } from '../../hooks'; +import { + useAccountOptions, + useDimensionKeys, + useMetrics, + useNamespaces, + useRegions, + useEnsureVariableHasSingleSelection, +} from '../../hooks'; import { migrateVariableQuery } from '../../migrations/variableQueryMigrations'; import { CloudWatchJsonData, CloudWatchQuery, VariableQuery, VariableQueryType } from '../../types'; import { ALL_ACCOUNTS_OPTION } from '../shared/Account'; @@ -43,6 +50,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { const metrics = useMetrics(datasource, { region, namespace }); const dimensionKeys = useDimensionKeys(datasource, { region, namespace, metricName }); const accountState = useAccountOptions(datasource.resources, query.region); + const dimensionKeyError = useEnsureVariableHasSingleSelection(datasource, dimensionKey); const newFormStylingEnabled = config.featureToggles.awsDatasourcesNewFormStyling; const onRegionChange = async (region: string) => { @@ -114,7 +122,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { VariableQueryType.DimensionValues, ].includes(parsedQuery.queryType); return ( - <> + <div className={newFormStylingEnabled ? 'width-15' : ''}> <VariableQueryField value={parsedQuery.queryType} options={queryTypes} @@ -179,6 +187,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { inputId={`variable-query-dimension-key-${query.refId}`} allowCustomValue newFormStylingEnabled={newFormStylingEnabled} + error={dimensionKeyError} /> {newFormStylingEnabled ? ( <EditorField label="Dimensions" className="width-30" tooltip="Dimensions to filter the returned values on"> @@ -258,11 +267,12 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { } > <MultiFilter - filters={parsedQuery.ec2Filters} + filters={parsedQuery.ec2Filters ?? {}} onChange={(filters) => { onChange({ ...parsedQuery, ec2Filters: filters }); }} keyPlaceholder="filter/tag" + datasource={datasource} /> </EditorField> ) : ( @@ -284,11 +294,12 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { } > <MultiFilter - filters={parsedQuery.ec2Filters} + filters={parsedQuery.ec2Filters ?? {}} onChange={(filters) => { onChange({ ...parsedQuery, ec2Filters: filters }); }} keyPlaceholder="filter/tag" + datasource={datasource} /> </InlineField> )} @@ -310,6 +321,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { onChange({ ...parsedQuery, tags: filters }); }} keyPlaceholder="tag" + datasource={datasource} /> </EditorField> ) : ( @@ -320,6 +332,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { onChange({ ...parsedQuery, tags: filters }); }} keyPlaceholder="tag" + datasource={datasource} /> </InlineField> )} @@ -333,6 +346,6 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { newFormStylingEnabled={newFormStylingEnabled} /> )} - </> + </div> ); }; diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryField.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryField.tsx index ae4c94d27034a..6ab887cefbacd 100644 --- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryField.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryField.tsx @@ -1,8 +1,9 @@ +import { css } from '@emotion/css'; import React from 'react'; -import { SelectableValue } from '@grafana/data'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { EditorField } from '@grafana/experimental'; -import { InlineField, Select } from '@grafana/ui'; +import { Alert, InlineField, Select, useStyles2 } from '@grafana/ui'; import { VariableQueryType } from '../../types'; import { removeMarginBottom } from '../styles'; @@ -18,6 +19,7 @@ interface VariableQueryFieldProps<T> { allowCustomValue?: boolean; isLoading?: boolean; newFormStylingEnabled?: boolean; + error?: string; } export const VariableQueryField = <T extends string | VariableQueryType>({ @@ -29,31 +31,44 @@ export const VariableQueryField = <T extends string | VariableQueryType>({ isLoading = false, inputId = label, newFormStylingEnabled, + error, }: VariableQueryFieldProps<T>) => { + const styles = useStyles2(getStyles); return newFormStylingEnabled ? ( - <EditorField label={label} htmlFor={inputId} className={removeMarginBottom}> - <Select - aria-label={label} - allowCustomValue={allowCustomValue} - value={value} - onChange={({ value }) => onChange(value!)} - options={options} - isLoading={isLoading} - inputId={inputId} - /> - </EditorField> + <> + <EditorField label={label} htmlFor={inputId} className={removeMarginBottom}> + <Select + aria-label={label} + allowCustomValue={allowCustomValue} + value={value} + onChange={({ value }) => onChange(value!)} + options={options} + isLoading={isLoading} + inputId={inputId} + /> + </EditorField> + {error && <Alert title={error} severity="error" topSpacing={1} />} + </> ) : ( - <InlineField label={label} labelWidth={LABEL_WIDTH} htmlFor={inputId}> - <Select - aria-label={label} - width={25} - allowCustomValue={allowCustomValue} - value={value} - onChange={({ value }) => onChange(value!)} - options={options} - isLoading={isLoading} - inputId={inputId} - /> - </InlineField> + <> + <InlineField label={label} labelWidth={LABEL_WIDTH} htmlFor={inputId}> + <Select + aria-label={label} + width={25} + allowCustomValue={allowCustomValue} + value={value} + onChange={({ value }) => onChange(value!)} + options={options} + isLoading={isLoading} + inputId={inputId} + /> + </InlineField> + {error && <Alert className={styles.inlineFieldAlert} title={error} severity="error" topSpacing={1} />} + </> ); }; + +const getStyles = (theme: GrafanaTheme2) => ({ + // width set to InlineField labelWidth + Select width + 0.5 for margin on the label + inlineFieldAlert: css({ maxWidth: theme.spacing(LABEL_WIDTH + 25 + 0.5) }), +}); diff --git a/public/app/plugins/datasource/cloudwatch/components/shared/Dimensions/FilterItem.test.tsx b/public/app/plugins/datasource/cloudwatch/components/shared/Dimensions/FilterItem.test.tsx index 64c658249c249..1ab3a16677413 100644 --- a/public/app/plugins/datasource/cloudwatch/components/shared/Dimensions/FilterItem.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/shared/Dimensions/FilterItem.test.tsx @@ -42,12 +42,15 @@ describe('Dimensions', () => { /> ); await userEvent.click(screen.getByLabelText('Dimensions filter key')); - expect(getDimensionKeys).toHaveBeenCalledWith({ - namespace: q.namespace, - region: q.region, - metricName: q.metricName, - accountId: q.accountId, - dimensionFilters: { abc: ['xyz'] }, - }); + expect(getDimensionKeys).toHaveBeenCalledWith( + { + namespace: q.namespace, + region: q.region, + metricName: q.metricName, + accountId: q.accountId, + dimensionFilters: { abc: ['xyz'] }, + }, + false + ); }); }); diff --git a/public/app/plugins/datasource/cloudwatch/components/shared/Dimensions/FilterItem.tsx b/public/app/plugins/datasource/cloudwatch/components/shared/Dimensions/FilterItem.tsx index 7c5e65064c361..0610240aef858 100644 --- a/public/app/plugins/datasource/cloudwatch/components/shared/Dimensions/FilterItem.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/shared/Dimensions/FilterItem.tsx @@ -4,10 +4,10 @@ import { useAsyncFn } from 'react-use'; import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data'; import { AccessoryButton, InputGroup } from '@grafana/experimental'; -import { Select, useStyles2 } from '@grafana/ui'; +import { Alert, Select, useStyles2 } from '@grafana/ui'; import { CloudWatchDatasource } from '../../../datasource'; -import { useDimensionKeys } from '../../../hooks'; +import { useDimensionKeys, useEnsureVariableHasSingleSelection } from '../../../hooks'; import { Dimensions, MetricStat } from '../../../types'; import { appendTemplateVariables } from '../../../utils/utils'; @@ -34,6 +34,7 @@ const excludeCurrentKey = (dimensions: Dimensions, currentKey: string | undefine export const FilterItem = ({ filter, metricStat, datasource, disableExpressions, onChange, onDelete }: Props) => { const { region, namespace, metricName, dimensions, accountId } = metricStat; + const error = useEnsureVariableHasSingleSelection(datasource, filter.key); const dimensionsExcludingCurrentKey = useMemo( () => excludeCurrentKey(dimensions ?? {}, filter.key), [dimensions, filter] @@ -76,7 +77,7 @@ export const FilterItem = ({ filter, metricStat, datasource, disableExpressions, const styles = useStyles2(getOperatorStyles); return ( - <div data-testid="cloudwatch-dimensions-filter-item"> + <div className={styles.container} data-testid="cloudwatch-dimensions-filter-item"> <InputGroup> <Select aria-label="Dimensions filter key" @@ -111,6 +112,7 @@ export const FilterItem = ({ filter, metricStat, datasource, disableExpressions, /> <AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} type="button" /> </InputGroup> + {error && <Alert className={styles.alert} title={error} severity="error" topSpacing={1} />} </div> ); }; @@ -120,4 +122,6 @@ const getOperatorStyles = (theme: GrafanaTheme2) => ({ padding: theme.spacing(0, 1), alignSelf: 'center', }), + container: css({ display: 'inline-block' }), + alert: css({ minWidth: '100%', width: 'min-content' }), }); diff --git a/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LegacyLogGroupSelector.tsx b/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LegacyLogGroupSelector.tsx index 3b3048c0125bd..8204ef25ca182 100644 --- a/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LegacyLogGroupSelector.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LegacyLogGroupSelector.tsx @@ -1,12 +1,10 @@ import { debounce, unionBy } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { SelectableValue, toOption } from '@grafana/data'; +import { AppEvents, SelectableValue, toOption } from '@grafana/data'; +import { getAppEvents } from '@grafana/runtime'; import { MultiSelect } from '@grafana/ui'; import { InputActionMeta } from '@grafana/ui/src/components/Select/types'; -import { notifyApp } from 'app/core/actions'; -import { createErrorNotification } from 'app/core/copy/appNotification'; -import { dispatch } from 'app/store/store'; import { CloudWatchDatasource } from '../../../datasource'; import { appendTemplateVariables } from '../../../utils/utils'; @@ -51,7 +49,10 @@ export const LogGroupSelector: React.FC<LogGroupSelectorProps> = ({ const logGroups = await datasource.resources.legacyDescribeLogGroups(region, logGroupNamePrefix); return logGroups; } catch (err) { - dispatch(notifyApp(createErrorNotification(typeof err === 'string' ? err : JSON.stringify(err)))); + getAppEvents().publish({ + type: AppEvents.alertError.name, + payload: [typeof err === 'string' ? err : JSON.stringify(err)], + }); return []; } }, @@ -69,7 +70,10 @@ export const LogGroupSelector: React.FC<LogGroupSelectorProps> = ({ const logGroupNamePattern = /^[\.\-_/#A-Za-z0-9]+$/; if (!logGroupNamePattern.test(searchTerm)) { if (searchTerm !== '') { - dispatch(notifyApp(createErrorNotification('Invalid Log Group name: ' + searchTerm))); + getAppEvents().publish({ + type: AppEvents.alertError.name, + payload: ['Invalid Log Group name: ' + searchTerm], + }); } return; } diff --git a/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LogGroupsField.test.tsx b/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LogGroupsField.test.tsx index 9425f9b0d92b4..b3cf4e2a489fa 100644 --- a/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LogGroupsField.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LogGroupsField.test.tsx @@ -5,7 +5,11 @@ import React from 'react'; import { config } from '@grafana/runtime'; -import { logGroupNamesVariable, setupMockedDataSource } from '../../../__mocks__/CloudWatchDataSource'; +import { + logGroupNamesVariable, + setupMockedDataSource, + setupMockedTemplateService, +} from '../../../__mocks__/CloudWatchDataSource'; import { LogGroupsField } from './LogGroupsField'; @@ -17,6 +21,7 @@ const defaultProps = { region: '', onChange: jest.fn(), }; + describe('LogGroupSelection', () => { beforeEach(() => { jest.resetAllMocks(); @@ -35,6 +40,7 @@ describe('LogGroupSelection', () => { defaultProps.datasource.resources.getLogGroups = jest .fn() .mockResolvedValue([{ value: { arn: 'arn', name: 'loggroupname' } }]); + defaultProps.datasource.resources.templateSrv = setupMockedTemplateService(); render(<LogGroupsField {...defaultProps} legacyLogGroupNames={['loggroupname']} />); await waitFor(async () => expect(screen.getByText('Select log groups')).toBeInTheDocument()); @@ -51,7 +57,8 @@ describe('LogGroupSelection', () => { defaultProps.datasource.resources.getLogGroups = jest .fn() .mockResolvedValue([{ value: { arn: 'arn', name: 'loggroupname' } }]); - render(<LogGroupsField {...defaultProps} legacyLogGroupNames={['loggroupname', logGroupNamesVariable.name]} />); + const varName = '$' + logGroupNamesVariable.name; + render(<LogGroupsField {...defaultProps} legacyLogGroupNames={['loggroupname', varName]} />); await waitFor(async () => expect(screen.getByText('Select log groups')).toBeInTheDocument()); expect(defaultProps.datasource.resources.getLogGroups).toHaveBeenCalledTimes(1); @@ -61,7 +68,7 @@ describe('LogGroupSelection', () => { }); expect(defaultProps.onChange).toHaveBeenCalledWith([ { arn: 'arn', name: 'loggroupname' }, - { arn: logGroupNamesVariable.name, name: logGroupNamesVariable.name }, + { arn: varName, name: varName }, ]); }); @@ -70,6 +77,7 @@ describe('LogGroupSelection', () => { defaultProps.datasource.resources.getLogGroups = jest .fn() .mockResolvedValue([{ value: { arn: 'arn', name: 'loggroupname' } }]); + defaultProps.datasource.resources.templateSrv = setupMockedTemplateService(); render(<LogGroupsField {...defaultProps} logGroups={[{ arn: 'arn', name: 'loggroupname' }]} />); await waitFor(() => expect(screen.getByText('Select log groups')).toBeInTheDocument()); expect(defaultProps.datasource.resources.getLogGroups).not.toHaveBeenCalled(); diff --git a/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LogGroupsSelector.tsx b/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LogGroupsSelector.tsx index 225f56e84329c..d1720065dddb9 100644 --- a/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LogGroupsSelector.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LogGroupsSelector.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useMemo, useState } from 'react'; import { SelectableValue } from '@grafana/data'; -import { EditorField, Space } from '@grafana/experimental'; -import { Button, Checkbox, Icon, Label, LoadingPlaceholder, Modal, Select, useStyles2 } from '@grafana/ui'; +import { EditorField } from '@grafana/experimental'; +import { Button, Checkbox, Icon, Label, LoadingPlaceholder, Modal, Select, Space, useStyles2 } from '@grafana/ui'; import { DescribeLogGroupsRequest, ResourceResponse, LogGroupResponse } from '../../../resources/types'; import { LogGroup } from '../../../types'; diff --git a/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/SelectedLogGroups.test.tsx b/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/SelectedLogGroups.test.tsx index 9666d1d6186ea..540a6545c9f74 100644 --- a/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/SelectedLogGroups.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/SelectedLogGroups.test.tsx @@ -64,11 +64,11 @@ describe('SelectedLogsGroups', () => { name: `logGroup${i}`, })); render(<SelectedLogGroups {...defaultProps} selectedLogGroups={selectedLogGroups} />); - await waitFor(() => userEvent.click(screen.getByText('Clear selection'))); + await userEvent.click(screen.getByText('Clear selection')); await waitFor(() => expect(screen.getByText('Are you sure you want to clear all log groups?')).toBeInTheDocument() ); - await waitFor(() => userEvent.click(screen.getByRole('button', { name: 'Yes' }))); + await userEvent.click(screen.getByRole('button', { name: 'Yes' })); expect(defaultProps.onChange).toHaveBeenCalledWith([]); }); }); diff --git a/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts b/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts index ba62f4b1297c8..9cf62e4b58803 100644 --- a/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts +++ b/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -304,4 +304,4 @@ export interface CloudWatchAnnotationQuery extends common.DataQuery, MetricStat queryMode: CloudWatchQueryMode; } -export interface CloudWatch {} +export interface CloudWatchDataQuery {} diff --git a/public/app/plugins/datasource/cloudwatch/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/datasource.test.ts index edcab78c13741..096c3ff5c5b20 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.test.ts @@ -21,6 +21,7 @@ import { MetricEditorMode, MetricQueryType, } from './types'; +import * as templateUtils from './utils/templateVariableUtils'; describe('datasource', () => { beforeEach(() => { @@ -178,7 +179,6 @@ describe('datasource', () => { it('should interpolate multi-value template variable for log group names in the query', async () => { const { datasource, queryMock } = setupMockedDataSource({ variables: [fieldsVariable, logGroupNamesVariable, regionVariable], - mockGetVariableName: false, }); await lastValueFrom( datasource @@ -304,7 +304,9 @@ describe('datasource', () => { it('should replace correct variables in CloudWatchMetricsQuery', () => { const { datasource, templateService } = setupMockedDataSource(); templateService.replace = jest.fn(); - templateService.getVariableName = jest.fn(); + const mockGetVariableName = jest + .spyOn(templateUtils, 'getVariableName') + .mockImplementation((name: string) => name.replace('$', '')); const variableName = 'someVar'; const metricsQuery: CloudWatchMetricsQuery = { queryMode: 'Metrics', @@ -330,8 +332,8 @@ describe('datasource', () => { expect(templateService.replace).toHaveBeenCalledWith(`$${variableName}`, {}); expect(templateService.replace).toHaveBeenCalledTimes(8); - expect(templateService.getVariableName).toHaveBeenCalledWith(`$${variableName}`); - expect(templateService.getVariableName).toHaveBeenCalledTimes(1); + expect(mockGetVariableName).toHaveBeenCalledWith(`$${variableName}`); + expect(mockGetVariableName).toHaveBeenCalledTimes(1); }); }); diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index 01783555ebf81..9864f161123d1 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -13,8 +13,7 @@ import { LogRowModel, ScopedVars, } from '@grafana/data'; -import { DataSourceWithBackend } from '@grafana/runtime'; -import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; +import { DataSourceWithBackend, TemplateSrv, getTemplateSrv } from '@grafana/runtime'; import { CloudWatchAnnotationSupport } from './annotationSupport'; import { DEFAULT_METRICS_QUERY, getDefaultLogsQuery } from './defaultQueries'; diff --git a/public/app/plugins/datasource/cloudwatch/hooks.test.ts b/public/app/plugins/datasource/cloudwatch/hooks.test.ts index 79a8a49cf051e..326a56b4b6dea 100644 --- a/public/app/plugins/datasource/cloudwatch/hooks.test.ts +++ b/public/app/plugins/datasource/cloudwatch/hooks.test.ts @@ -11,7 +11,13 @@ import { setupMockedDataSource, } from './__mocks__/CloudWatchDataSource'; import { setupMockedResourcesAPI } from './__mocks__/ResourcesAPI'; -import { useAccountOptions, useDimensionKeys, useIsMonitoringAccount, useMetrics } from './hooks'; +import { + useAccountOptions, + useDimensionKeys, + useIsMonitoringAccount, + useMetrics, + useEnsureVariableHasSingleSelection, +} from './hooks'; const originalFeatureToggleValue = config.featureToggles.cloudWatchCrossAccountQuerying; @@ -64,7 +70,6 @@ describe('hooks', () => { describe('useDimensionKeys', () => { it('should interpolate variables before calling api', async () => { const { datasource } = setupMockedDataSource({ - mockGetVariableName: true, variables: [regionVariable, namespaceVariable, accountIdVariable, metricVariable, dimensionVariable], }); const getDimensionKeysMock = jest.fn().mockResolvedValue([]); @@ -83,15 +88,18 @@ describe('hooks', () => { ); await waitFor(() => { expect(getDimensionKeysMock).toHaveBeenCalledTimes(1); - expect(getDimensionKeysMock).toHaveBeenCalledWith({ - region: regionVariable.current.value, - namespace: namespaceVariable.current.value, - metricName: metricVariable.current.value, - accountId: accountIdVariable.current.value, - dimensionFilters: { - environment: [dimensionVariable.current.value], + expect(getDimensionKeysMock).toHaveBeenCalledWith( + { + region: regionVariable.current.value, + namespace: namespaceVariable.current.value, + metricName: metricVariable.current.value, + accountId: accountIdVariable.current.value, + dimensionFilters: { + environment: [dimensionVariable.current.value], + }, }, - }); + false + ); }); }); }); @@ -140,4 +148,35 @@ describe('hooks', () => { }); }); }); + + describe('useEnsureVariableHasSingleSelection', () => { + it('should return an error if a variable has multiple options selected', () => { + const { datasource } = setupMockedDataSource(); + datasource.resources.isVariableWithMultipleOptionsSelected = jest.fn().mockReturnValue(true); + + const variable = '$variable'; + const { result } = renderHook(() => useEnsureVariableHasSingleSelection(datasource, variable)); + expect(result.current).toEqual( + `Template variables with multiple selected options are not supported for ${variable}` + ); + }); + + it('should not return an error if a variable is a multi-variable but does not have multiple options selected', () => { + const { datasource } = setupMockedDataSource(); + datasource.resources.isVariableWithMultipleOptionsSelected = jest.fn().mockReturnValue(false); + + const variable = '$variable'; + const { result } = renderHook(() => useEnsureVariableHasSingleSelection(datasource, variable)); + expect(result.current).toEqual(''); + }); + + it('should not return an error if a variable is not a multi-variable', () => { + const { datasource } = setupMockedDataSource(); + datasource.resources.isMultiVariable = jest.fn().mockReturnValue(false); + + const variable = '$variable'; + const { result } = renderHook(() => useEnsureVariableHasSingleSelection(datasource, variable)); + expect(result.current).toEqual(''); + }); + }); }); diff --git a/public/app/plugins/datasource/cloudwatch/hooks.ts b/public/app/plugins/datasource/cloudwatch/hooks.ts index a4a6738580a1c..8fe4c3540e5dd 100644 --- a/public/app/plugins/datasource/cloudwatch/hooks.ts +++ b/public/app/plugins/datasource/cloudwatch/hooks.ts @@ -87,13 +87,13 @@ export const useDimensionKeys = ( } if (dimensionFilters) { - dimensionFilters = datasource.resources.convertDimensionFormat(dimensionFilters, {}); + dimensionFilters = datasource.resources.convertDimensionFormat(dimensionFilters, {}, false); } // doing deep comparison to avoid making new api calls to list metrics unless dimension filter object props changes useDeepCompareEffect(() => { datasource.resources - .getDimensionKeys({ namespace, region, metricName, accountId, dimensionFilters }) + .getDimensionKeys({ namespace, region, metricName, accountId, dimensionFilters }, false) .then((result: Array<SelectableValue<string>>) => { setDimensionKeys(appendTemplateVariables(datasource, result)); }); @@ -102,6 +102,28 @@ export const useDimensionKeys = ( return dimensionKeys; }; +export const useEnsureVariableHasSingleSelection = (datasource: CloudWatchDatasource, target?: string) => { + const [error, setError] = useState(''); + // interpolate the target to ensure the check in useEffect runs when the variable selection is changed + const interpolatedTarget = datasource.templateSrv.replace(target); + + useEffect(() => { + if (datasource.resources.isVariableWithMultipleOptionsSelected(target)) { + const newErrorMessage = `Template variables with multiple selected options are not supported for ${target}`; + if (error !== newErrorMessage) { + setError(newErrorMessage); + } + return; + } + + if (error) { + setError(''); + } + }, [datasource.resources, target, interpolatedTarget, error]); + + return error; +}; + export const useIsMonitoringAccount = (resources: ResourcesAPI, region: string) => { const [isMonitoringAccount, setIsMonitoringAccount] = useState(false); // we call this before the use effect to ensure dependency array below diff --git a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.test.ts b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.test.ts index 620ac3575e4d3..77a59d1fa2147 100644 --- a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.test.ts +++ b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.test.ts @@ -3,6 +3,7 @@ import { Value } from 'slate'; import { TypeaheadOutput } from '@grafana/ui'; +import { setupMockedTemplateService } from '../../__mocks__/CloudWatchDataSource'; import { CloudWatchDatasource } from '../../datasource'; import { ResourceResponse } from '../../resources/types'; import { LogGroupField } from '../../types'; @@ -124,7 +125,7 @@ function makeDatasource(): CloudWatchDatasource { * Get suggestion items based on query. Use `^` to mark position of the cursor. */ function getProvideCompletionItems(query: string): Promise<TypeaheadOutput> { - const provider = new CloudWatchLogsLanguageProvider(makeDatasource()); + const provider = new CloudWatchLogsLanguageProvider(makeDatasource(), setupMockedTemplateService()); const cursorOffset = query.indexOf('^'); const queryWithoutCursor = query.replace('^', ''); let tokens: Token[] = Prism.tokenize(queryWithoutCursor, provider.getSyntax()) as any; diff --git a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.ts b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.ts index f2dfa350b8e26..f2e889f9c229f 100644 --- a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.ts +++ b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.ts @@ -2,9 +2,8 @@ import Prism, { Grammar } from 'prismjs'; import { lastValueFrom } from 'rxjs'; import { AbsoluteTimeRange, HistoryItem, LanguageProvider } from '@grafana/data'; -import { BackendDataSourceResponse, FetchResponse } from '@grafana/runtime'; +import { BackendDataSourceResponse, FetchResponse, TemplateSrv, getTemplateSrv } from '@grafana/runtime'; import { CompletionItemGroup, SearchFunctionType, Token, TypeaheadInput, TypeaheadOutput } from '@grafana/ui'; -import { getTemplateSrv } from 'app/features/templating/template_srv'; import { CloudWatchDatasource } from '../../datasource'; import { CloudWatchQuery, LogGroup } from '../../types'; @@ -34,11 +33,13 @@ export class CloudWatchLogsLanguageProvider extends LanguageProvider { started = false; declare initialRange: AbsoluteTimeRange; datasource: CloudWatchDatasource; + templateSrv: TemplateSrv; - constructor(datasource: CloudWatchDatasource, initialValues?: any) { + constructor(datasource: CloudWatchDatasource, templateSrv?: TemplateSrv, initialValues?: any) { super(); this.datasource = datasource; + this.templateSrv = templateSrv ?? getTemplateSrv(); Object.assign(this, initialValues); } @@ -132,7 +133,7 @@ export class CloudWatchLogsLanguageProvider extends LanguageProvider { private fetchFields = async (logGroups: LogGroup[], region: string): Promise<string[]> => { const interpolatedLogGroups = interpolateStringArrayUsingSingleOrMultiValuedVariable( - getTemplateSrv(), + this.templateSrv, logGroups.map((lg) => lg.name), {}, 'text' diff --git a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-sql/SQLGenerator.test.ts b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-sql/SQLGenerator.test.ts index 93c68c2bf0b5f..9a63cee6bd819 100644 --- a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-sql/SQLGenerator.test.ts +++ b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-sql/SQLGenerator.test.ts @@ -1,10 +1,9 @@ -import { TemplateSrv } from 'app/features/templating/template_srv'; - import { aggregationvariable, labelsVariable, metricVariable, namespaceVariable, + setupMockedTemplateService, } from '../../__mocks__/CloudWatchDataSource'; import { createFunctionWithParameter, @@ -25,11 +24,12 @@ describe('SQLGenerator', () => { from: createFunctionWithParameter('SCHEMA', ['AWS/EC2']), orderByDirection: 'DESC', }; + let mockTemplateSrv = setupMockedTemplateService(); describe('mandatory fields check', () => { it('should return undefined if metric and aggregation is missing', () => { expect( - new SQLGenerator().expressionToSqlQuery({ + new SQLGenerator(mockTemplateSrv).expressionToSqlQuery({ from: createFunctionWithParameter('SCHEMA', ['AWS/EC2']), }) ).toBeUndefined(); @@ -37,7 +37,7 @@ describe('SQLGenerator', () => { it('should return undefined if aggregation is missing', () => { expect( - new SQLGenerator().expressionToSqlQuery({ + new SQLGenerator(mockTemplateSrv).expressionToSqlQuery({ from: createFunctionWithParameter('SCHEMA', []), }) ).toBeUndefined(); @@ -45,27 +45,27 @@ describe('SQLGenerator', () => { }); it('should return query if mandatory fields are provided', () => { - expect(new SQLGenerator().expressionToSqlQuery(baseQuery)).not.toBeUndefined(); + expect(new SQLGenerator(mockTemplateSrv).expressionToSqlQuery(baseQuery)).not.toBeUndefined(); }); describe('select', () => { it('should use statistic and metric name', () => { const select = createFunctionWithParameter('COUNT', ['BytesPerSecond']); - expect(new SQLGenerator().expressionToSqlQuery({ ...baseQuery, select })).toEqual( + expect(new SQLGenerator(mockTemplateSrv).expressionToSqlQuery({ ...baseQuery, select })).toEqual( `SELECT COUNT(BytesPerSecond) FROM SCHEMA("AWS/EC2")` ); }); it('should wrap in double quotes if metric name contains illegal characters ', () => { const select = createFunctionWithParameter('COUNT', ['Bytes-Per-Second']); - expect(new SQLGenerator().expressionToSqlQuery({ ...baseQuery, select })).toEqual( + expect(new SQLGenerator(mockTemplateSrv).expressionToSqlQuery({ ...baseQuery, select })).toEqual( `SELECT COUNT("Bytes-Per-Second") FROM SCHEMA("AWS/EC2")` ); }); it('should wrap in double quotes if metric name starts with a number ', () => { const select = createFunctionWithParameter('COUNT', ['4xxErrorRate']); - expect(new SQLGenerator().expressionToSqlQuery({ ...baseQuery, select })).toEqual( + expect(new SQLGenerator(mockTemplateSrv).expressionToSqlQuery({ ...baseQuery, select })).toEqual( `SELECT COUNT("4xxErrorRate") FROM SCHEMA("AWS/EC2")` ); }); @@ -75,14 +75,14 @@ describe('SQLGenerator', () => { describe('with schema contraint', () => { it('should handle schema without dimensions', () => { const from = createFunctionWithParameter('SCHEMA', ['AWS/MQ']); - expect(new SQLGenerator().expressionToSqlQuery({ ...baseQuery, from })).toEqual( + expect(new SQLGenerator(mockTemplateSrv).expressionToSqlQuery({ ...baseQuery, from })).toEqual( `SELECT SUM(CPUUtilization) FROM SCHEMA("AWS/MQ")` ); }); it('should handle schema with dimensions', () => { const from = createFunctionWithParameter('SCHEMA', ['AWS/MQ', 'InstanceId', 'InstanceType']); - expect(new SQLGenerator().expressionToSqlQuery({ ...baseQuery, from })).toEqual( + expect(new SQLGenerator(mockTemplateSrv).expressionToSqlQuery({ ...baseQuery, from })).toEqual( `SELECT SUM(CPUUtilization) FROM SCHEMA("AWS/MQ", InstanceId, InstanceType)` ); }); @@ -94,7 +94,7 @@ describe('SQLGenerator', () => { 'Instance.Type', 'Instance-Group', ]); - expect(new SQLGenerator().expressionToSqlQuery({ ...baseQuery, from })).toEqual( + expect(new SQLGenerator(mockTemplateSrv).expressionToSqlQuery({ ...baseQuery, from })).toEqual( `SELECT SUM(CPUUtilization) FROM SCHEMA("AWS/MQ", "Instance Id", "Instance.Type", "Instance-Group")` ); }); @@ -103,7 +103,7 @@ describe('SQLGenerator', () => { describe('without schema', () => { it('should use the specified namespace', () => { const from = createProperty('AWS/MQ'); - expect(new SQLGenerator().expressionToSqlQuery({ ...baseQuery, from })).toEqual( + expect(new SQLGenerator(mockTemplateSrv).expressionToSqlQuery({ ...baseQuery, from })).toEqual( `SELECT SUM(CPUUtilization) FROM "AWS/MQ"` ); }); @@ -111,25 +111,25 @@ describe('SQLGenerator', () => { }); function assertQueryEndsWith(rest: Partial<SQLExpression>, expectedFilter: string) { - expect(new SQLGenerator().expressionToSqlQuery({ ...baseQuery, ...rest })).toEqual( + expect(new SQLGenerator(mockTemplateSrv).expressionToSqlQuery({ ...baseQuery, ...rest })).toEqual( `SELECT SUM(CPUUtilization) FROM SCHEMA("AWS/EC2") ${expectedFilter}` ); } describe('filter', () => { it('should not add WHERE clause in case its empty', () => { - expect(new SQLGenerator().expressionToSqlQuery({ ...baseQuery })).not.toContain('WHERE'); + expect(new SQLGenerator(mockTemplateSrv).expressionToSqlQuery({ ...baseQuery })).not.toContain('WHERE'); }); it('should not add WHERE clause when there is no filter conditions', () => { const where = createArray([]); - expect(new SQLGenerator().expressionToSqlQuery({ ...baseQuery, where })).not.toContain('WHERE'); + expect(new SQLGenerator(mockTemplateSrv).expressionToSqlQuery({ ...baseQuery, where })).not.toContain('WHERE'); }); // TODO: We should handle this scenario it.skip('should not add WHERE clause when the operator is incomplete', () => { const where = createArray([createOperator('Instance-Id', '=')]); - expect(new SQLGenerator().expressionToSqlQuery({ ...baseQuery, where })).not.toContain('WHERE'); + expect(new SQLGenerator(mockTemplateSrv).expressionToSqlQuery({ ...baseQuery, where })).not.toContain('WHERE'); }); it('should handle one top level filter with AND', () => { @@ -280,7 +280,7 @@ describe('SQLGenerator', () => { describe('group by', () => { it('should not add GROUP BY clause in case its empty', () => { - expect(new SQLGenerator().expressionToSqlQuery({ ...baseQuery })).not.toContain('GROUP BY'); + expect(new SQLGenerator(mockTemplateSrv).expressionToSqlQuery({ ...baseQuery })).not.toContain('GROUP BY'); }); it('should handle single label', () => { const groupBy = createArray([createGroupBy('InstanceId')], QueryEditorExpressionType.And); @@ -297,7 +297,7 @@ describe('SQLGenerator', () => { describe('order by', () => { it('should not add ORDER BY clause in case its empty', () => { - expect(new SQLGenerator().expressionToSqlQuery({ ...baseQuery })).not.toContain('ORDER BY'); + expect(new SQLGenerator(mockTemplateSrv).expressionToSqlQuery({ ...baseQuery })).not.toContain('ORDER BY'); }); it('should handle SUM ASC', () => { const orderBy = createFunction('SUM'); @@ -315,7 +315,7 @@ describe('SQLGenerator', () => { }); describe('limit', () => { it('should not add LIMIT clause in case its empty', () => { - expect(new SQLGenerator().expressionToSqlQuery({ ...baseQuery })).not.toContain('LIMIT'); + expect(new SQLGenerator(mockTemplateSrv).expressionToSqlQuery({ ...baseQuery })).not.toContain('LIMIT'); }); it('should be added in case its specified', () => { @@ -346,15 +346,19 @@ describe('SQLGenerator', () => { orderByDirection: 'DESC', limit: 100, }; - expect(new SQLGenerator().expressionToSqlQuery(query)).toEqual( + expect(new SQLGenerator(mockTemplateSrv).expressionToSqlQuery(query)).toEqual( `SELECT COUNT(DroppedBytes) FROM SCHEMA("AWS/MQ", InstanceId, "Instance-Group") WHERE (InstanceId = 'I-123' OR Type != 'some-type') AND (InstanceId != 'I-456' OR Type != 'some-type') GROUP BY InstanceId, InstanceType ORDER BY COUNT() DESC LIMIT 100` ); }); }); describe('using variables', () => { - const templateService = new TemplateSrv(); - templateService.init([metricVariable, namespaceVariable, labelsVariable, aggregationvariable]); + const templateService = setupMockedTemplateService([ + metricVariable, + namespaceVariable, + labelsVariable, + aggregationvariable, + ]); it('should interpolate variables correctly', () => { let query: SQLExpression = { diff --git a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-sql/SQLGenerator.ts b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-sql/SQLGenerator.ts index b98136e1f8900..105dd5a3da61f 100644 --- a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-sql/SQLGenerator.ts +++ b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-sql/SQLGenerator.ts @@ -1,4 +1,4 @@ -import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; +import { getTemplateSrv, TemplateSrv } from '@grafana/runtime'; import { QueryEditorArrayExpression, diff --git a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-sql/completion/CompletionItemProvider.ts b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-sql/completion/CompletionItemProvider.ts index 80539dc4f5783..1b2f030eba7ea 100644 --- a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-sql/completion/CompletionItemProvider.ts +++ b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-sql/completion/CompletionItemProvider.ts @@ -179,12 +179,15 @@ export class SQLCompletionItemProvider extends CompletionItemProvider { dimensionFilters = (labelKeyTokens || []).reduce((acc, curr) => { return { ...acc, [curr.value]: null }; }, {}); - const keys = await this.resources.getDimensionKeys({ - namespace: this.templateSrv.replace(namespaceToken.value.replace(/\"/g, '')), - region: this.templateSrv.replace(this.region), - metricName: metricNameToken?.value, - dimensionFilters, - }); + const keys = await this.resources.getDimensionKeys( + { + namespace: this.templateSrv.replace(namespaceToken.value.replace(/\"/g, '')), + region: this.templateSrv.replace(this.region), + metricName: metricNameToken?.value, + dimensionFilters, + }, + false + ); keys.map((m) => { const key = /[\s\.-]/.test(m.value ?? '') ? `"${m.value}"` : m.value; key && addSuggestion(key); diff --git a/public/app/plugins/datasource/cloudwatch/migrations/dashboardMigrations.ts b/public/app/plugins/datasource/cloudwatch/migrations/dashboardMigrations.ts index 01c532ab9390e..03da65ed5260e 100644 --- a/public/app/plugins/datasource/cloudwatch/migrations/dashboardMigrations.ts +++ b/public/app/plugins/datasource/cloudwatch/migrations/dashboardMigrations.ts @@ -2,8 +2,8 @@ // Migrations applied by the DashboardMigrator are performed before the plugin is loaded. // DashboardMigrator migrations are tied to a certain minimum version of a dashboard which means they will only be ran once. -import { DataQuery, AnnotationQuery } from '@grafana/data'; -import { getNextRefIdChar } from 'app/core/utils/query'; +import { AnnotationQuery, getNextRefId } from '@grafana/data'; +import { DataQuery } from '@grafana/schema'; import { CloudWatchMetricsQuery, LegacyAnnotationQuery, MetricQueryType, MetricEditorMode } from '../types'; @@ -20,7 +20,7 @@ export function migrateMultipleStatsMetricsQuery( } } for (const newTarget of newQueries) { - newTarget.refId = getNextRefIdChar(panelQueries); + newTarget.refId = getNextRefId(panelQueries); delete newTarget.statistics; panelQueries.push(newTarget); } diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchAnnotationQueryRunner.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchAnnotationQueryRunner.ts index ec01b0f3aa1a5..013d14bbd6fc6 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchAnnotationQueryRunner.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchAnnotationQueryRunner.ts @@ -1,7 +1,7 @@ import { Observable } from 'rxjs'; import { DataQueryRequest, DataQueryResponse, DataSourceInstanceSettings } from '@grafana/data'; -import { TemplateSrv } from 'app/features/templating/template_srv'; +import { TemplateSrv } from '@grafana/runtime'; import { CloudWatchAnnotationQuery, CloudWatchJsonData, CloudWatchQuery } from '../types'; diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts index dd098e59ddc55..47f683a214aba 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts @@ -87,8 +87,9 @@ describe('CloudWatchLogsQueryRunner', () => { it('should stop querying when timed out', async () => { const { runner, queryMock } = setupMockedLogsQueryRunner(); const fakeFrames = genMockFrames(20); - const initialRecordsMatched = fakeFrames[0].meta!.stats!.find((stat) => stat.displayName === 'Records scanned')! - .value!; + const initialRecordsMatched = fakeFrames[0].meta!.stats!.find( + (stat) => stat.displayName === 'Records scanned' + )!.value!; for (let i = 1; i < 4; i++) { fakeFrames[i].meta!.stats = [ { @@ -98,8 +99,9 @@ describe('CloudWatchLogsQueryRunner', () => { ]; } - const finalRecordsMatched = fakeFrames[9].meta!.stats!.find((stat) => stat.displayName === 'Records scanned')! - .value!; + const finalRecordsMatched = fakeFrames[9].meta!.stats!.find( + (stat) => stat.displayName === 'Records scanned' + )!.value!; for (let i = 10; i < fakeFrames.length; i++) { fakeFrames[i].meta!.stats = [ { @@ -244,7 +246,7 @@ describe('CloudWatchLogsQueryRunner', () => { id: '', region: '$' + regionVariable.name, refId: 'A', - expression: `stats count(*) by queryType, bin($__interval)`, + expression: `stats count(*) by queryType, bin(20s)`, }; describe('handleLogQueries', () => { diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts index a9b28b587659b..2107bc02269d2 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts @@ -31,8 +31,7 @@ import { getDefaultTimeRange, rangeUtil, } from '@grafana/data'; -import { config, FetchError } from '@grafana/runtime'; -import { TemplateSrv } from 'app/features/templating/template_srv'; +import { config, FetchError, TemplateSrv } from '@grafana/runtime'; import { CloudWatchJsonData, diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.test.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.test.ts index 3b73fa18fdde7..0ac0bedbc3ec5 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.test.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.test.ts @@ -2,8 +2,7 @@ import { of } from 'rxjs'; import { CustomVariableModel, getFrameDisplayName, VariableHide } from '@grafana/data'; import { dateTime } from '@grafana/data/src/datetime/moment_wrapper'; -import { BackendDataSourceResponse } from '@grafana/runtime'; -import * as redux from 'app/store/store'; +import { toDataQueryResponse } from '@grafana/runtime'; import { namespaceVariable, @@ -14,21 +13,38 @@ import { periodIntervalVariable, accountIdVariable, } from '../__mocks__/CloudWatchDataSource'; +import { initialVariableModelState } from '../__mocks__/CloudWatchVariables'; import { setupMockedMetricsQueryRunner } from '../__mocks__/MetricsQueryRunner'; import { validMetricSearchBuilderQuery, validMetricSearchCodeQuery } from '../__mocks__/queries'; -import { initialVariableModelState } from '../__mocks__/variables'; -import { MetricQueryType, MetricEditorMode, CloudWatchMetricsQuery, DataQueryError } from '../types'; +import { MetricQueryType, MetricEditorMode, CloudWatchMetricsQuery } from '../types'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getAppEvents: () => ({ + publish: jest.fn(), + }), +})); describe('CloudWatchMetricsQueryRunner', () => { describe('performTimeSeriesQuery', () => { it('should return the same length of data as result', async () => { - const { runner, timeRange, request, queryMock } = setupMockedMetricsQueryRunner({ + const resultsFromBEQuery = { data: { results: { - a: { refId: 'a', series: [{ target: 'cpu', datapoints: [[1, 1]] }] }, - b: { refId: 'b', series: [{ target: 'memory', datapoints: [[2, 2]] }] }, + a: { + refId: 'a', + series: [{ target: 'cpu', datapoints: [[1, 2]], meta: { custom: { period: 60 } } }], + }, + b: { + refId: 'b', + series: [{ target: 'cpu', datapoints: [[1, 2]], meta: { custom: { period: 120 } } }], + }, }, }, + }; + const { runner, timeRange, request, queryMock } = setupMockedMetricsQueryRunner({ + // DataSourceWithBackend runs toDataQueryResponse({response from CW backend}) + response: toDataQueryResponse(resultsFromBEQuery), }); const observable = runner.performTimeSeriesQuery( @@ -47,7 +63,7 @@ describe('CloudWatchMetricsQueryRunner', () => { }); it('sets fields.config.interval based on period', async () => { - const { runner, timeRange, request, queryMock } = setupMockedMetricsQueryRunner({ + const resultsFromBEQuery = { data: { results: { a: { @@ -60,6 +76,10 @@ describe('CloudWatchMetricsQueryRunner', () => { }, }, }, + }; + const { runner, timeRange, request, queryMock } = setupMockedMetricsQueryRunner({ + // DataSourceWithBackend runs toDataQueryResponse({response from CW backend}) + response: toDataQueryResponse(resultsFromBEQuery), }); const observable = runner.performTimeSeriesQuery( @@ -78,6 +98,68 @@ describe('CloudWatchMetricsQueryRunner', () => { }); }); + it('should enrich the error message for throttling errors', async () => { + const partialQuery: CloudWatchMetricsQuery = { + metricQueryType: MetricQueryType.Search, + metricEditorMode: MetricEditorMode.Builder, + queryMode: 'Metrics', + namespace: 'AWS/EC2', + metricName: 'CPUUtilization', + dimensions: { + InstanceId: 'i-12345678', + }, + statistic: 'Average', + period: '300', + expression: '', + id: '', + region: '', + refId: '', + }; + + const queries: CloudWatchMetricsQuery[] = [ + { ...partialQuery, refId: 'A', region: 'us-east-1' }, + { ...partialQuery, refId: 'B', region: 'us-east-2' }, + ]; + + const dataWithThrottlingError = { + data: { + message: 'Throttling: exception', + results: { + A: { + frames: [], + series: [], + tables: [], + error: 'Throttling: exception', + refId: 'A', + meta: {}, + }, + B: { + frames: [], + series: [], + tables: [], + error: 'Throttling: exception', + refId: 'B', + meta: {}, + }, + }, + }, + }; + const expectedUsEast1Message = + 'Please visit the AWS Service Quotas console at https://us-east-1.console.aws.amazon.com/servicequotas/home?region=us-east-1#!/services/monitoring/quotas/L-5E141212 to request a quota increase or see our documentation at https://grafana.com/docs/grafana/latest/datasources/cloudwatch/#manage-service-quotas to learn more. Throttling: exception'; + const expectedUsEast2Message = + 'Please visit the AWS Service Quotas console at https://us-east-2.console.aws.amazon.com/servicequotas/home?region=us-east-2#!/services/monitoring/quotas/L-5E141212 to request a quota increase or see our documentation at https://grafana.com/docs/grafana/latest/datasources/cloudwatch/#manage-service-quotas to learn more. Throttling: exception'; + + const { runner, request, queryMock } = setupMockedMetricsQueryRunner({ + response: toDataQueryResponse(dataWithThrottlingError), + }); + + await expect(runner.handleMetricQueries(queries, request, queryMock)).toEmitValuesWith((received) => { + expect(received[0].errors).toHaveLength(2); + expect(received[0]?.errors?.[0].message).toEqual(expectedUsEast1Message); + expect(received[0]?.errors?.[1].message).toEqual(expectedUsEast2Message); + }); + }); + describe('When performing CloudWatch metrics query', () => { const queries: CloudWatchMetricsQuery[] = [ { @@ -98,31 +180,36 @@ describe('CloudWatchMetricsQueryRunner', () => { }, ]; - const data: BackendDataSourceResponse = { - results: { - A: { - tables: [], - error: '', - refId: 'A', - series: [ - { - target: 'CPUUtilization_Average', - datapoints: [ - [1, 1483228800000], - [2, 1483229100000], - [5, 1483229700000], - ], - tags: { - InstanceId: 'i-12345678', + const resultsFromBEQuery = { + data: { + results: { + A: { + tables: [], + error: '', + refId: 'A', + series: [ + { + target: 'CPUUtilization_Average', + datapoints: [ + [1, 1483228800000], + [2, 1483229100000], + [5, 1483229700000], + ], + tags: { + InstanceId: 'i-12345678', + }, }, - }, - ], + ], + }, }, }, }; it('should generate the correct query', async () => { - const { runner, queryMock, request } = setupMockedMetricsQueryRunner({ data }); + const { runner, queryMock, request } = setupMockedMetricsQueryRunner({ + // DataSourceWithBackend runs toDataQueryResponse({response from CW backend}) + response: toDataQueryResponse(resultsFromBEQuery), + }); await expect(runner.handleMetricQueries(queries, request, queryMock)).toEmitValuesWith(() => { expect(queryMock.mock.calls[0][0].targets).toMatchObject( @@ -159,7 +246,8 @@ describe('CloudWatchMetricsQueryRunner', () => { ]; const { runner, queryMock, request } = setupMockedMetricsQueryRunner({ - data, + // DataSourceWithBackend runs toDataQueryResponse({response from CW backend}) + response: toDataQueryResponse(resultsFromBEQuery), variables: [periodIntervalVariable], }); @@ -169,110 +257,104 @@ describe('CloudWatchMetricsQueryRunner', () => { }); it('should return series list', async () => { - const { runner, request, queryMock } = setupMockedMetricsQueryRunner({ data }); + const { runner, request, queryMock } = setupMockedMetricsQueryRunner({ + // DataSourceWithBackend runs toDataQueryResponse({response from CW backend}) + response: toDataQueryResponse(resultsFromBEQuery), + }); await expect(runner.handleMetricQueries(queries, request, queryMock)).toEmitValuesWith((received) => { const result = received[0]; - expect(getFrameDisplayName(result.data[0])).toBe( - data.results.A.series?.length && data.results.A.series[0].target - ); - expect(result.data[0].fields[1].values[0]).toBe( - data.results.A.series?.length && data.results.A.series[0].datapoints[0][0] - ); + expect(getFrameDisplayName(result.data[0])).toBe('CPUUtilization_Average'); + expect(result.data[0].fields[1].values[0]).toBe(1); }); }); + }); + describe('and throttling exception is thrown', () => { + const partialQuery: CloudWatchMetricsQuery = { + metricQueryType: MetricQueryType.Search, + metricEditorMode: MetricEditorMode.Builder, + queryMode: 'Metrics', + namespace: 'AWS/EC2', + metricName: 'CPUUtilization', + dimensions: { + InstanceId: 'i-12345678', + }, + statistic: 'Average', + period: '300', + expression: '', + id: '', + region: '', + refId: '', + }; - describe('and throttling exception is thrown', () => { - const partialQuery: CloudWatchMetricsQuery = { - metricQueryType: MetricQueryType.Search, - metricEditorMode: MetricEditorMode.Builder, - queryMode: 'Metrics', - namespace: 'AWS/EC2', - metricName: 'CPUUtilization', - dimensions: { - InstanceId: 'i-12345678', - }, - statistic: 'Average', - period: '300', - expression: '', - id: '', - region: '', - refId: '', - }; - - const queries: CloudWatchMetricsQuery[] = [ - { ...partialQuery, refId: 'A', region: 'us-east-1' }, - { ...partialQuery, refId: 'B', region: 'us-east-2' }, - { ...partialQuery, refId: 'C', region: 'us-east-1' }, - { ...partialQuery, refId: 'D', region: 'us-east-2' }, - { ...partialQuery, refId: 'E', region: 'eu-north-1' }, - ]; + const queries: CloudWatchMetricsQuery[] = [ + { ...partialQuery, refId: 'A', region: 'us-east-1' }, + { ...partialQuery, refId: 'B', region: 'us-east-2' }, + { ...partialQuery, refId: 'C', region: 'us-east-1' }, + { ...partialQuery, refId: 'D', region: 'us-east-2' }, + { ...partialQuery, refId: 'E', region: 'eu-north-1' }, + ]; - const backendErrorResponse: DataQueryError<CloudWatchMetricsQuery> = { - data: { - message: 'Throttling: exception', - results: { - A: { - frames: [], - series: [], - tables: [], - error: 'Throttling: exception', - refId: 'A', - meta: {}, - }, - B: { - frames: [], - series: [], - tables: [], - error: 'Throttling: exception', - refId: 'B', - meta: {}, - }, - C: { - frames: [], - series: [], - tables: [], - error: 'Throttling: exception', - refId: 'C', - meta: {}, - }, - D: { - frames: [], - series: [], - tables: [], - error: 'Throttling: exception', - refId: 'D', - meta: {}, - }, - E: { - frames: [], - series: [], - tables: [], - error: 'Throttling: exception', - refId: 'E', - meta: {}, - }, + const dataWithThrottlingError = { + data: { + message: 'Throttling: exception', + results: { + A: { + frames: [], + series: [], + tables: [], + error: 'Throttling: exception', + refId: 'A', + meta: {}, + }, + B: { + frames: [], + series: [], + tables: [], + error: 'Throttling: exception', + refId: 'B', + meta: {}, + }, + C: { + frames: [], + series: [], + tables: [], + error: 'Throttling: exception', + refId: 'C', + meta: {}, + }, + D: { + frames: [], + series: [], + tables: [], + error: 'Throttling: exception', + refId: 'D', + meta: {}, + }, + E: { + frames: [], + series: [], + tables: [], + error: 'Throttling: exception', + refId: 'E', + meta: {}, }, }, - }; + }, + }; - beforeEach(() => { - redux.setStore({ - ...redux.store, - dispatch: jest.fn(), - }); + it('should display one alert error message per region+datasource combination', async () => { + const { runner, request, queryMock } = setupMockedMetricsQueryRunner({ + response: toDataQueryResponse(dataWithThrottlingError), }); + const memoizedDebounceSpy = jest.spyOn(runner, 'debouncedThrottlingAlert'); - it('should display one alert error message per region+datasource combination', async () => { - const { runner, request, queryMock } = setupMockedMetricsQueryRunner({ errorResponse: backendErrorResponse }); - const memoizedDebounceSpy = jest.spyOn(runner, 'debouncedAlert'); - - await expect(runner.handleMetricQueries(queries, request, queryMock)).toEmitValuesWith(() => { - expect(memoizedDebounceSpy).toHaveBeenCalledWith('CloudWatch Test Datasource', 'us-east-1'); - expect(memoizedDebounceSpy).toHaveBeenCalledWith('CloudWatch Test Datasource', 'us-east-2'); - expect(memoizedDebounceSpy).toHaveBeenCalledWith('CloudWatch Test Datasource', 'eu-north-1'); - expect(memoizedDebounceSpy).toBeCalledTimes(3); - }); + await expect(runner.handleMetricQueries(queries, request, queryMock)).toEmitValuesWith((received) => { + expect(received[0].errors).toHaveLength(5); + expect(memoizedDebounceSpy).toHaveBeenCalledWith('CloudWatch Test Datasource', 'us-east-1'); + expect(memoizedDebounceSpy).toHaveBeenCalledWith('CloudWatch Test Datasource', 'us-east-2'); + expect(memoizedDebounceSpy).toHaveBeenCalledWith('CloudWatch Test Datasource', 'eu-north-1'); + expect(memoizedDebounceSpy).toBeCalledTimes(3); }); }); }); @@ -298,43 +380,80 @@ describe('CloudWatchMetricsQueryRunner', () => { }, ]; - const data: BackendDataSourceResponse = { - results: { - A: { - tables: [], - error: '', - refId: 'A', - series: [ - { - target: 'TargetResponseTime_p90.00', - datapoints: [ - [1, 1483228800000], - [2, 1483229100000], - [5, 1483229700000], - ], - tags: { - LoadBalancer: 'lb', - TargetGroup: 'tg', + const responseFromBEQuery = { + data: { + results: { + A: { + tables: [], + error: '', + refId: 'A', + series: [ + { + target: 'TargetResponseTime_p90.00', + datapoints: [ + [1, 1483228800000], + [2, 1483229100000], + [5, 1483229700000], + ], + tags: { + LoadBalancer: 'lb', + TargetGroup: 'tg', + }, }, - }, - ], + ], + }, }, }, }; it('should return series list', async () => { - const { runner, request, queryMock } = setupMockedMetricsQueryRunner({ data }); + const { runner, request, queryMock } = setupMockedMetricsQueryRunner({ + // DataSourceWithBackend runs toDataQueryResponse({response from CW backend}) + response: toDataQueryResponse(responseFromBEQuery), + }); await expect(runner.handleMetricQueries(queries, request, queryMock)).toEmitValuesWith((received) => { const result = received[0]; expect(getFrameDisplayName(result.data[0])).toBe( - data.results.A.series?.length && data.results.A.series[0].target + responseFromBEQuery.data.results.A.series?.length && responseFromBEQuery.data.results.A.series[0].target ); expect(result.data[0].fields[1].values[0]).toBe( - data.results.A.series?.length && data.results.A.series[0].datapoints[0][0] + responseFromBEQuery.data.results.A.series?.length && + responseFromBEQuery.data.results.A.series[0].datapoints[0][0] ); }); }); + it('should pass the error list from DatasourceWithBackend srv', async () => { + const dataWithError = { + data: { + results: { + A: { + error: + "metric request error: \"ValidationError: Error in expression 'query': Invalid syntax\\n\\tstatus code: 400", + status: 500, + }, + }, + }, + status: 500, + statusText: 'Internal Server Error', + }; + const { runner, request, queryMock } = setupMockedMetricsQueryRunner({ + // DataSourceWithBackend runs toDataQueryResponse({response from CW backend}) + response: toDataQueryResponse(dataWithError), + }); + await expect(runner.handleMetricQueries(queries, request, queryMock)).toEmitValuesWith((received) => { + const result = received[0]; + expect(result.data).toEqual([]); + expect(result.errors).toEqual([ + { + message: + "metric request error: \"ValidationError: Error in expression 'query': Invalid syntax\\n\\tstatus code: 400", + status: 500, + refId: 'A', + }, + ]); + }); + }); }); describe('template variable interpolation', () => { @@ -358,7 +477,7 @@ describe('CloudWatchMetricsQueryRunner', () => { it('interpolates variables correctly', async () => { const { runner, queryMock, request } = setupMockedMetricsQueryRunner({ - variables: [namespaceVariable, metricVariable, labelsVariable, limitVariable], + variables: [namespaceVariable, metricVariable, limitVariable], }); runner.handleMetricQueries( [ @@ -376,7 +495,7 @@ describe('CloudWatchMetricsQueryRunner', () => { expression: '', metricQueryType: MetricQueryType.Query, metricEditorMode: MetricEditorMode.Code, - sqlExpression: 'SELECT SUM($metric) FROM "$namespace" GROUP BY ${labels:raw} LIMIT $limit', + sqlExpression: 'SELECT SUM($metric) FROM "$namespace" GROUP BY InstanceId,InstanceType LIMIT $limit', }, ], request, @@ -654,8 +773,28 @@ describe('CloudWatchMetricsQueryRunner', () => { beforeEach(() => { const { runner, request, queryMock } = setupMockedMetricsQueryRunner({ variables: [ - { ...namespaceVariable, multi: true }, - { ...metricVariable, multi: true }, + { + ...namespaceVariable, + current: { + value: ['AWS/Redshift', 'AWS/EC2'], + text: ['AWS/Redshift', 'AWS/EC2'].toString(), + selected: true, + }, + multi: true, + }, + { + ...metricVariable, + current: { + value: ['CPUUtilization', 'DroppedBytes'], + text: ['CPUUtilization', 'DroppedBytes'].toString(), + selected: true, + }, + multi: true, + }, + { + ...dimensionVariable, + multi: true, + }, ], }); runner.debouncedCustomAlert = debouncedAlert; @@ -666,11 +805,11 @@ describe('CloudWatchMetricsQueryRunner', () => { queryMode: 'Metrics', id: '', region: 'us-east-2', - namespace: namespaceVariable.id, - metricName: metricVariable.id, + namespace: '$' + namespaceVariable.name, + metricName: '$' + metricVariable.name, period: '', alias: '', - dimensions: {}, + dimensions: { [`$${dimensionVariable.name}`]: '' }, matchExact: true, statistic: '', refId: '', @@ -683,7 +822,7 @@ describe('CloudWatchMetricsQueryRunner', () => { queryMock ); }); - it('should show debounced alert for namespace and metric name', async () => { + it('should show debounced alert for namespace and metric name when multiple options are selected', async () => { expect(debouncedAlert).toHaveBeenCalledWith( 'CloudWatch templating error', 'Multi template variables are not supported for namespace' @@ -694,6 +833,13 @@ describe('CloudWatchMetricsQueryRunner', () => { ); }); + it('should not show debounced alert for a multi-variable if it only has one option selected', async () => { + expect(debouncedAlert).not.toHaveBeenCalledWith( + 'CloudWatch templating error', + `Multi template variables are not supported for dimension keys` + ); + }); + it('should not show debounced alert for region', async () => { expect(debouncedAlert).not.toHaveBeenCalledWith( 'CloudWatch templating error', @@ -712,7 +858,7 @@ describe('CloudWatchMetricsQueryRunner', () => { sqlExpression: 'select SUM(CPUUtilization) from $datasource', dimensions: { InstanceId: '$dimension' }, }; - const { runner } = setupMockedMetricsQueryRunner({ variables: [dimensionVariable], mockGetVariableName: false }); + const { runner } = setupMockedMetricsQueryRunner({ variables: [dimensionVariable] }); const result = runner.interpolateMetricsQueryVariables(testQuery, { datasource: { text: 'foo', value: 'foo' }, dimension: { text: 'foo', value: 'foo' }, @@ -732,7 +878,6 @@ describe('CloudWatchMetricsQueryRunner', () => { describe('convertMultiFiltersFormat', () => { const { runner } = setupMockedMetricsQueryRunner({ variables: [labelsVariable, dimensionVariable], - mockGetVariableName: false, }); it('converts keys and values correctly', () => { const filters = { $dimension: ['b'], a: ['$labels', 'bar'] }; diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.ts index c6966eadc918f..44088ce2868c4 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.ts @@ -1,9 +1,11 @@ -import { findLast, isEmpty } from 'lodash'; +import { isEmpty } from 'lodash'; import React from 'react'; -import { catchError, map, Observable, of, throwError } from 'rxjs'; +import { catchError, map, Observable, of } from 'rxjs'; import { + AppEvents, DataFrame, + DataQueryError, DataQueryRequest, DataQueryResponse, DataSourceInstanceSettings, @@ -12,37 +14,33 @@ import { rangeUtil, ScopedVars, } from '@grafana/data'; -import { notifyApp } from 'app/core/actions'; -import { createErrorNotification } from 'app/core/copy/appNotification'; -import { TemplateSrv } from 'app/features/templating/template_srv'; -import { store } from 'app/store/store'; -import { AppNotificationTimeout } from 'app/types'; +import { TemplateSrv, getAppEvents } from '@grafana/runtime'; import { ThrottlingErrorMessage } from '../components/Errors/ThrottlingErrorMessage'; import memoizedDebounce from '../memoizedDebounce'; import { migrateMetricQuery } from '../migrations/metricQueryMigrations'; -import { CloudWatchJsonData, CloudWatchMetricsQuery, CloudWatchQuery, DataQueryError } from '../types'; +import { CloudWatchJsonData, CloudWatchMetricsQuery, CloudWatchQuery } from '../types'; import { filterMetricsQuery } from '../utils/utils'; import { CloudWatchRequest } from './CloudWatchRequest'; +const getThrottlingErrorMessage = (region: string, message: string) => + `Please visit the AWS Service Quotas console at https://${region}.console.aws.amazon.com/servicequotas/home?region=${region}#!/services/monitoring/quotas/L-5E141212 to request a quota increase or see our documentation at https://grafana.com/docs/grafana/latest/datasources/cloudwatch/#manage-service-quotas to learn more. ${message}`; + const displayAlert = (datasourceName: string, region: string) => - store.dispatch( - notifyApp( - createErrorNotification( - `CloudWatch request limit reached in ${region} for data source ${datasourceName}`, - '', - undefined, - React.createElement(ThrottlingErrorMessage, { region }, null) - ) - ) - ); + getAppEvents().publish({ + type: AppEvents.alertError.name, + payload: [ + `CloudWatch request limit reached in ${region} for data source ${datasourceName}`, + '', + undefined, + React.createElement(ThrottlingErrorMessage, { region }, null), + ], + }); + // This class handles execution of CloudWatch metrics query data queries export class CloudWatchMetricsQueryRunner extends CloudWatchRequest { - debouncedAlert: (datasourceName: string, region: string) => void = memoizedDebounce( - displayAlert, - AppNotificationTimeout.Error - ); + debouncedThrottlingAlert: (datasourceName: string, region: string) => void = memoizedDebounce(displayAlert); constructor(instanceSettings: DataSourceInstanceSettings<CloudWatchJsonData>, templateSrv: TemplateSrv) { super(instanceSettings, templateSrv); @@ -109,12 +107,7 @@ export class CloudWatchMetricsQueryRunner extends CloudWatchRequest { ): Observable<DataQueryResponse> { return queryFn(request).pipe( map((res) => { - const dataframes: DataFrame[] = res.data; - if (!dataframes || dataframes.length <= 0) { - return { data: [] }; - } - - const lastError = findLast(res.data, (v) => !!v.error); + const dataframes: DataFrame[] = res.data || []; dataframes.forEach((frame) => { frame.fields.forEach((field) => { @@ -125,45 +118,62 @@ export class CloudWatchMetricsQueryRunner extends CloudWatchRequest { }); }); + if (res.errors?.length) { + this.alertOnThrottlingErrors(res.errors, request); + } + return { data: dataframes, - error: lastError ? { message: lastError.error } : undefined, + // DataSourceWithBackend will not throw an error, instead it will return "errors" field along with the response + errors: this.enrichThrottlingErrorMessages(request, res.errors), }; }), - catchError((err: DataQueryError<CloudWatchMetricsQuery>) => { - const isFrameError = err.data?.results; - - // Error is not frame specific - if (!isFrameError && err.data && err.data.message === 'Metric request error' && err.data.error) { - err.message = err.data.error; - return throwError(() => err); - } - - // The error is either for a specific frame or for all the frames - const results: Array<{ error?: string }> = Object.values(err.data?.results ?? {}); - const firstErrorResult = results.find((r) => r.error); - if (firstErrorResult) { - err.message = firstErrorResult.error; + catchError((err: unknown) => { + if (Array.isArray(err)) { + return of({ data: [], errors: err }); + } else { + return of({ data: [], errors: [{ message: err }] }); } + }) + ); + } - if (results.some((r) => r.error && /^Throttling:.*/.test(r.error))) { - const failedRedIds = Object.keys(err.data?.results ?? {}); - const regionsAffected = Object.values(request.targets).reduce( - (res: string[], { refId, region }) => - (refId && !failedRedIds.includes(refId)) || res.includes(region) ? res : [...res, region], - [] - ); - regionsAffected.forEach((region) => { - const actualRegion = this.getActualRegion(region); - if (actualRegion) { - this.debouncedAlert(this.instanceSettings.name, actualRegion); - } - }); - } + enrichThrottlingErrorMessages(request: DataQueryRequest<CloudWatchQuery>, errors?: DataQueryError[]) { + if (!errors || errors.length === 0) { + return errors; + } + const result: DataQueryError[] = []; + errors.forEach((error) => { + if (error.message && (/^Throttling:.*/.test(error.message) || /^Rate exceeded.*/.test(error.message))) { + const region = this.getActualRegion(request.targets.find((target) => target.refId === error.refId)?.region); + result.push({ ...error, message: getThrottlingErrorMessage(region, error.message) }); + } else { + result.push(error); + } + }); + return result; + } - return throwError(() => err); - }) + alertOnThrottlingErrors(errors: DataQueryError[], request: DataQueryRequest<CloudWatchQuery>) { + const hasThrottlingError = errors.some( + (err) => err.message && (/^Throttling:.*/.test(err.message) || /^Rate exceeded.*/.test(err.message)) ); + if (hasThrottlingError) { + const failedRefIds = errors.map((error) => error.refId).filter((refId) => refId); + if (failedRefIds.length > 0) { + const regionsAffected = Object.values(request.targets).reduce( + (res: string[], { refId, region }) => + (refId && !failedRefIds.includes(refId)) || res.includes(region) ? res : [...res, region], + [] + ); + regionsAffected.forEach((region) => { + const actualRegion = this.getActualRegion(region); + if (actualRegion) { + this.debouncedThrottlingAlert(this.instanceSettings.name, actualRegion); + } + }); + } + } } filterMetricQuery(query: CloudWatchMetricsQuery): boolean { diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchRequest.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchRequest.ts index 8570f18393d62..460a2e7ee49c2 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchRequest.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchRequest.ts @@ -1,24 +1,17 @@ import { Observable } from 'rxjs'; -import { DataSourceInstanceSettings, DataSourceRef, getDataSourceRef, ScopedVars } from '@grafana/data'; -import { BackendDataSourceResponse, FetchResponse, getBackendSrv } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; -import { createErrorNotification } from 'app/core/copy/appNotification'; -import { TemplateSrv } from 'app/features/templating/template_srv'; -import { store } from 'app/store/store'; -import { AppNotificationTimeout } from 'app/types'; +import { DataSourceInstanceSettings, DataSourceRef, getDataSourceRef, ScopedVars, AppEvents } from '@grafana/data'; +import { BackendDataSourceResponse, FetchResponse, getBackendSrv, TemplateSrv, getAppEvents } from '@grafana/runtime'; import memoizedDebounce from '../memoizedDebounce'; import { CloudWatchJsonData, Dimensions, MetricRequest, MultiFilters } from '../types'; +import { getVariableName } from '../utils/templateVariableUtils'; export abstract class CloudWatchRequest { templateSrv: TemplateSrv; ref: DataSourceRef; dsQueryEndpoint = '/api/ds/query'; - debouncedCustomAlert: (title: string, message: string) => void = memoizedDebounce( - displayCustomError, - AppNotificationTimeout.Error - ); + debouncedCustomAlert: (title: string, message: string) => void = memoizedDebounce(displayCustomError); constructor( public instanceSettings: DataSourceInstanceSettings<CloudWatchJsonData>, @@ -43,9 +36,18 @@ export abstract class CloudWatchRequest { return getBackendSrv().fetch<BackendDataSourceResponse>(options); } - convertDimensionFormat(dimensions: Dimensions, scopedVars: ScopedVars): Dimensions { + convertDimensionFormat( + dimensions: Dimensions, + scopedVars: ScopedVars, + displayErrorIfIsMultiTemplateVariable = true + ): Dimensions { return Object.entries(dimensions).reduce((result, [key, value]) => { - key = this.replaceVariableAndDisplayWarningIfMulti(key, scopedVars, true, 'dimension keys'); + key = this.replaceVariableAndDisplayWarningIfMulti( + key, + scopedVars, + displayErrorIfIsMultiTemplateVariable, + 'dimension keys' + ); if (Array.isArray(value)) { return { ...result, [key]: value }; @@ -62,7 +64,7 @@ export abstract class CloudWatchRequest { // get the value for a given template variable expandVariableToArray(value: string, scopedVars: ScopedVars): string[] { - const variableName = this.templateSrv.getVariableName(value); + const variableName = getVariableName(value); const valueVar = this.templateSrv.getVariables().find(({ name }) => { return name === variableName; }); @@ -93,23 +95,35 @@ export abstract class CloudWatchRequest { }, {}); } + isMultiVariable(target?: string) { + if (target) { + const variables = this.templateSrv.getVariables(); + const variable = variables.find(({ name }) => name === getVariableName(target)); + const type = variable?.type; + return (type === 'custom' || type === 'query' || type === 'datasource') && variable?.multi; + } + + return false; + } + + isVariableWithMultipleOptionsSelected(target?: string, scopedVars?: ScopedVars) { + if (!target || !this.isMultiVariable(target)) { + return false; + } + return this.expandVariableToArray(target, scopedVars || {}).length > 1; + } + replaceVariableAndDisplayWarningIfMulti( target?: string, scopedVars?: ScopedVars, displayErrorIfIsMultiTemplateVariable?: boolean, fieldName?: string ) { - if (displayErrorIfIsMultiTemplateVariable && !!target) { - const variables = this.templateSrv.getVariables(); - const variable = variables.find(({ name }) => name === this.templateSrv.getVariableName(target)); - const isMultiVariable = - variable?.type === 'custom' || variable?.type === 'query' || variable?.type === 'datasource'; - if (isMultiVariable && variable.multi) { - this.debouncedCustomAlert( - 'CloudWatch templating error', - `Multi template variables are not supported for ${fieldName || target}` - ); - } + if (displayErrorIfIsMultiTemplateVariable && this.isVariableWithMultipleOptionsSelected(target)) { + this.debouncedCustomAlert( + 'CloudWatch templating error', + `Multi template variables are not supported for ${fieldName || target}` + ); } return this.templateSrv.replace(target, scopedVars); @@ -128,4 +142,7 @@ export abstract class CloudWatchRequest { } const displayCustomError = (title: string, message: string) => - store.dispatch(notifyApp(createErrorNotification(title, message))); + getAppEvents().publish({ + type: AppEvents.alertError.name, + payload: [title, message], + }); diff --git a/public/app/plugins/datasource/cloudwatch/resources/ResourceAPI.test.ts b/public/app/plugins/datasource/cloudwatch/resources/ResourceAPI.test.ts index ec7d1167289fc..eeefcf2074597 100644 --- a/public/app/plugins/datasource/cloudwatch/resources/ResourceAPI.test.ts +++ b/public/app/plugins/datasource/cloudwatch/resources/ResourceAPI.test.ts @@ -1,5 +1,3 @@ -import { config } from '@grafana/runtime'; - import { setupMockedResourcesAPI } from '../__mocks__/ResourcesAPI'; describe('ResourcesAPI', () => { @@ -121,63 +119,8 @@ describe('ResourcesAPI', () => { }); }); - const originalFeatureToggleValue = config.featureToggles.cloudwatchNewRegionsHandler; - describe('getRegions', () => { - afterEach(() => { - config.featureToggles.cloudwatchNewRegionsHandler = originalFeatureToggleValue; - }); - it('should return regions as an array of options when using legacy regions route', async () => { - config.featureToggles.cloudwatchNewRegionsHandler = false; - const response = Promise.resolve([ - { - text: 'US East (Ohio)', - value: 'us-east-2', - label: 'US East (Ohio)', - }, - { - text: 'US East (N. Virginia)', - value: 'us-east-1', - label: 'US East (N. Virginia)', - }, - { - text: 'US West (N. California)', - value: 'us-west-1', - label: 'US West (N. California)', - }, - ]); - - const { api } = setupMockedResourcesAPI({ response }); - const expectedRegions = [ - { - text: 'default', - value: 'default', - label: 'default', - }, - { - text: 'US East (Ohio)', - value: 'us-east-2', - label: 'US East (Ohio)', - }, - { - text: 'US East (N. Virginia)', - value: 'us-east-1', - label: 'US East (N. Virginia)', - }, - { - text: 'US West (N. California)', - value: 'us-west-1', - label: 'US West (N. California)', - }, - ]; - - const regions = await api.getRegions(); - - expect(regions).toEqual(expectedRegions); - }); - - it('should return regions as an array of options when using new regions route', async () => { - config.featureToggles.cloudwatchNewRegionsHandler = true; + it('should always return regions as an array of options', async () => { const response = Promise.resolve([ { value: { diff --git a/public/app/plugins/datasource/cloudwatch/resources/ResourcesAPI.ts b/public/app/plugins/datasource/cloudwatch/resources/ResourcesAPI.ts index d3b6fa0979a9e..b518ce9d29f27 100644 --- a/public/app/plugins/datasource/cloudwatch/resources/ResourcesAPI.ts +++ b/public/app/plugins/datasource/cloudwatch/resources/ResourcesAPI.ts @@ -1,8 +1,7 @@ import { memoize } from 'lodash'; import { DataSourceInstanceSettings, SelectableValue } from '@grafana/data'; -import { getBackendSrv, config } from '@grafana/runtime'; -import { TemplateSrv } from 'app/features/templating/template_srv'; +import { getBackendSrv, TemplateSrv } from '@grafana/runtime'; import { CloudWatchRequest } from '../query-runner/CloudWatchRequest'; import { CloudWatchJsonData, LogGroupField, MultiFilters } from '../types'; @@ -53,12 +52,6 @@ export class ResourcesAPI extends CloudWatchRequest { } getRegions(): Promise<SelectableResourceValue[]> { - if (!config.featureToggles.cloudwatchNewRegionsHandler) { - return this.memoizedGetRequest<SelectableResourceValue[]>('regions').then((regions) => [ - { label: 'default', value: 'default', text: 'default' }, - ...regions.filter((r) => r.value), - ]); - } return this.memoizedGetRequest<Array<ResourceResponse<RegionResponse>>>('regions').then((regions) => { return [ { label: 'default', value: 'default', text: 'default' }, @@ -117,19 +110,18 @@ export class ResourcesAPI extends CloudWatchRequest { }).then((metrics) => metrics.map((m) => ({ metricName: m.value.name, namespace: m.value.namespace }))); } - getDimensionKeys({ - region, - namespace = '', - dimensionFilters = {}, - metricName = '', - accountId, - }: GetDimensionKeysRequest): Promise<Array<SelectableValue<string>>> { + getDimensionKeys( + { region, namespace = '', dimensionFilters = {}, metricName = '', accountId }: GetDimensionKeysRequest, + displayErrorIfIsMultiTemplateVariable?: boolean + ): Promise<Array<SelectableValue<string>>> { return this.memoizedGetRequest<Array<ResourceResponse<string>>>('dimension-keys', { region: this.templateSrv.replace(this.getActualRegion(region)), namespace: this.templateSrv.replace(namespace), accountId: this.templateSrv.replace(accountId), metricName: this.templateSrv.replace(metricName), - dimensionFilters: JSON.stringify(this.convertDimensionFormat(dimensionFilters, {})), + dimensionFilters: JSON.stringify( + this.convertDimensionFormat(dimensionFilters, {}, displayErrorIfIsMultiTemplateVariable) + ), }).then((r) => r.map((r) => ({ label: r.value, value: r.value }))); } @@ -149,7 +141,7 @@ export class ResourcesAPI extends CloudWatchRequest { region: this.templateSrv.replace(this.getActualRegion(region)), namespace: this.templateSrv.replace(namespace), metricName: this.templateSrv.replace(metricName.trim()), - dimensionKey: this.templateSrv.replace(dimensionKey), + dimensionKey: this.replaceVariableAndDisplayWarningIfMulti(dimensionKey, {}, true), dimensionFilters: JSON.stringify(this.convertDimensionFormat(dimensionFilters, {})), accountId: this.templateSrv.replace(accountId), }).then((r) => r.map((r) => ({ label: r.value, value: r.value }))); diff --git a/public/app/plugins/datasource/cloudwatch/utils/templateVariableUtils.ts b/public/app/plugins/datasource/cloudwatch/utils/templateVariableUtils.ts index be1e204375479..1cad2dfdb7a17 100644 --- a/public/app/plugins/datasource/cloudwatch/utils/templateVariableUtils.ts +++ b/public/app/plugins/datasource/cloudwatch/utils/templateVariableUtils.ts @@ -1,5 +1,29 @@ import { VariableOption, UserProps, OrgProps, DashboardProps, ScopedVars } from '@grafana/data'; -import { TemplateSrv } from 'app/features/templating/template_srv'; +import { TemplateSrv } from '@grafana/runtime'; + +/* + * This regex matches 3 types of variable reference with an optional format specifier + * There are 6 capture groups that replace will return + * \$(\w+) $var1 + * \[\[(\w+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]] + * \${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?} ${var3} or ${var3.fieldPath} or ${var3:fmt3} (or ${var3.fieldPath:fmt3} but that is not a separate capture group) + */ +const variableRegex = /\$(\w+)|\[\[(\w+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g; + +// Helper function since lastIndex is not reset +const variableRegexExec = (variableString: string) => { + variableRegex.lastIndex = 0; + return variableRegex.exec(variableString); +}; + +export const getVariableName = (expression: string) => { + const match = variableRegexExec(expression); + if (!match) { + return null; + } + const variableName = match.slice(1).find((match) => match !== undefined); + return variableName; +}; /** * @remarks @@ -22,7 +46,7 @@ export const interpolateStringArrayUsingSingleOrMultiValuedVariable = ( const format = key === 'value' ? 'pipe' : 'text'; let result: string[] = []; for (const string of strings) { - const variableName = templateSrv.getVariableName(string); + const variableName = getVariableName(string); const valueVar = templateSrv.getVariables().find(({ name }) => name === variableName); if (valueVar && 'current' in valueVar && isVariableOption(valueVar.current)) { @@ -43,7 +67,7 @@ export const interpolateStringArrayUsingSingleOrMultiValuedVariable = ( }; export const isTemplateVariable = (templateSrv: TemplateSrv, string: string) => { - const variableName = templateSrv.getVariableName(string); + const variableName = getVariableName(string); return templateSrv.getVariables().some(({ name }) => name === variableName); }; diff --git a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.test.tsx b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.test.tsx index c165132ac6332..ca9d18c71c19d 100644 --- a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.test.tsx +++ b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.test.tsx @@ -14,6 +14,7 @@ import { } from '../../../features/dashboard/state/__fixtures__/dashboardFixtures'; import { DashboardQueryEditor } from './DashboardQueryEditor'; +import { DashboardDatasource } from './datasource'; import { SHARED_DASHBOARD_QUERY } from './types'; jest.mock('app/core/config', () => ({ @@ -78,10 +79,11 @@ describe('DashboardQueryEditor', () => { it('does not show a panel with the SHARED_DASHBOARD_QUERY datasource as an option in the dropdown', async () => { render( <DashboardQueryEditor - queries={mockQueries} - panelData={mockPanelData} + datasource={{} as DashboardDatasource} + query={mockQueries[0]} + data={mockPanelData} onChange={mockOnChange} - onRunQueries={mockOnRunQueries} + onRunQuery={mockOnRunQueries} /> ); const select = screen.getByText('Choose panel'); @@ -101,10 +103,11 @@ describe('DashboardQueryEditor', () => { mockDashboard.initEditPanel(mockDashboard.panels[0]); render( <DashboardQueryEditor - queries={mockQueries} - panelData={mockPanelData} + datasource={{} as DashboardDatasource} + query={mockQueries[0]} + data={mockPanelData} onChange={mockOnChange} - onRunQueries={mockOnRunQueries} + onRunQuery={mockOnRunQueries} /> ); const select = screen.getByText('Choose panel'); diff --git a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx index e2c8360519b08..b43d03631690e 100644 --- a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx +++ b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx @@ -4,45 +4,32 @@ import pluralize from 'pluralize'; import React, { useCallback, useMemo } from 'react'; import { useAsync } from 'react-use'; -import { DataQuery, GrafanaTheme2, PanelData, SelectableValue, DataTopic } from '@grafana/data'; -import { - Card, - Field, - Select, - useStyles2, - VerticalGroup, - HorizontalGroup, - Spinner, - Switch, - RadioButtonGroup, -} from '@grafana/ui'; +import { DataQuery, GrafanaTheme2, SelectableValue, DataTopic, QueryEditorProps } from '@grafana/data'; +import { Field, Select, useStyles2, Spinner, RadioButtonGroup, Stack, InlineSwitch } from '@grafana/ui'; import config from 'app/core/config'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { PanelModel } from 'app/features/dashboard/state'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { filterPanelDataToQuery } from 'app/features/query/components/QueryEditorRow'; +import { OperationsEditorRow } from '../prometheus/querybuilder/shared/OperationsEditorRow'; + +import { DashboardDatasource } from './datasource'; import { DashboardQuery, ResultInfo, SHARED_DASHBOARD_QUERY } from './types'; function getQueryDisplayText(query: DataQuery): string { return JSON.stringify(query); } -interface Props { - queries: DataQuery[]; - panelData: PanelData; - onChange: (queries: DataQuery[]) => void; - onRunQueries: () => void; -} +interface Props extends QueryEditorProps<DashboardDatasource, DashboardQuery> {} const topics = [ { label: 'All data', value: false }, { label: 'Annotations', value: true, description: 'Include annotations as regular data' }, ]; -export function DashboardQueryEditor({ panelData, queries, onChange, onRunQueries }: Props) { +export function DashboardQueryEditor({ data, query, onChange, onRunQuery }: Props) { const { value: defaultDatasource } = useAsync(() => getDatasourceSrv().get()); - const query = queries[0] as DashboardQuery; const panel = useMemo(() => { const dashboard = getDashboardSrv().getCurrent(); @@ -50,7 +37,7 @@ export function DashboardQueryEditor({ panelData, queries, onChange, onRunQuerie }, [query.panelId]); const { value: results, loading: loadingResults } = useAsync(async (): Promise<ResultInfo[]> => { - if (!panel) { + if (!panel || !data) { return []; } const mainDS = await getDatasourceSrv().get(panel.datasource); @@ -58,7 +45,7 @@ export function DashboardQueryEditor({ panelData, queries, onChange, onRunQuerie panel.targets.map(async (query) => { const ds = query.datasource ? await getDatasourceSrv().get(query.datasource) : mainDS; const fmt = ds.getQueryDisplayText || getQueryDisplayText; - const queryData = filterPanelDataToQuery(panelData, query.refId) ?? panelData; + const queryData = filterPanelDataToQuery(data, query.refId) ?? data; return { refId: query.refId, query: fmt(query), @@ -69,14 +56,14 @@ export function DashboardQueryEditor({ panelData, queries, onChange, onRunQuerie }; }) ); - }, [panelData, panel]); + }, [data, panel]); const onUpdateQuery = useCallback( (query: DashboardQuery) => { - onChange([query]); - onRunQueries(); + onChange(query); + onRunQuery(); }, - [onChange, onRunQueries] + [onChange, onRunQuery] ); const onPanelChanged = useCallback( @@ -155,64 +142,61 @@ export function DashboardQueryEditor({ panelData, queries, onChange, onRunQuerie const selected = panels.find((panel) => panel.value === query.panelId); return ( - <> - <Field label="Source" description="Use the same results as panel"> - <Select - inputId={selectId} - placeholder="Choose panel" - isSearchable={true} - options={panels} - value={selected} - onChange={(item) => onPanelChanged(item.value!)} - /> - </Field> - - <HorizontalGroup height="auto" wrap={true} align="flex-start"> - <Field - label="Data Source" - description="Use data or annotations from the panel" - className={styles.horizontalField} - > - <RadioButtonGroup options={topics} value={query.topic === DataTopic.Annotations} onChange={onTopicChanged} /> - </Field> - - {showTransforms && ( - <Field label="Transform" description="Apply panel transformations from the source panel"> - <Switch value={Boolean(query.withTransforms)} onChange={onTransformToggle} /> + <OperationsEditorRow> + <Stack direction="column"> + <Stack gap={3}> + <Field label="Source panel" description="Use query results from another panel"> + <Select + inputId={selectId} + placeholder="Choose panel" + isSearchable={true} + options={panels} + value={selected} + onChange={(item) => onPanelChanged(item.value!)} + /> </Field> - )} - </HorizontalGroup> - - {loadingResults ? ( - <Spinner /> - ) : ( - <> - {results && Boolean(results.length) && ( - <Field label="Queries from panel"> - <VerticalGroup spacing="sm"> - {results.map((target, i) => ( - <Card key={`DashboardQueryRow-${i}`}> - <Card.Heading>{target.refId}</Card.Heading> - <Card.Figure> - <img src={target.img} alt={target.name} title={target.name} width={40} /> - </Card.Figure> - <Card.Meta>{target.query}</Card.Meta> - </Card> - ))} - </VerticalGroup> + + <Field label="Data" description="Use data or annotations from the panel"> + <RadioButtonGroup + options={topics} + value={query.topic === DataTopic.Annotations} + onChange={onTopicChanged} + /> + </Field> + + {showTransforms && ( + <Field label="Transform" description="Apply transformations from the source panel"> + <InlineSwitch value={Boolean(query.withTransforms)} onChange={onTransformToggle} /> </Field> )} - </> - )} - </> + </Stack> + + {loadingResults ? ( + <Spinner /> + ) : ( + <> + {results && Boolean(results.length) && ( + <Field label="Queries from panel"> + <Stack direction="column"> + {results.map((target, i) => ( + <Stack key={i} alignItems="center" gap={1}> + <div>{target.refId}</div> + <img src={target.img} alt={target.name} title={target.name} width={16} /> + <div>{target.query}</div> + </Stack> + ))} + </Stack> + </Field> + )} + </> + )} + </Stack> + </OperationsEditorRow> ); } function getStyles(theme: GrafanaTheme2) { return { - horizontalField: css({ - marginRight: theme.spacing(2), - }), noQueriesText: css({ padding: theme.spacing(1.25), }), diff --git a/public/app/plugins/datasource/dashboard/datasource.test.ts b/public/app/plugins/datasource/dashboard/datasource.test.ts new file mode 100644 index 0000000000000..58edb8dc8ddc7 --- /dev/null +++ b/public/app/plugins/datasource/dashboard/datasource.test.ts @@ -0,0 +1,93 @@ +import { + arrayToDataFrame, + DataQueryResponse, + DataSourceInstanceSettings, + getDefaultTimeRange, + LoadingState, + standardTransformersRegistry, +} from '@grafana/data'; +import { SceneDataNode, SceneDataTransformer, SceneFlexItem, SceneFlexLayout, VizPanel } from '@grafana/scenes'; +import { getVizPanelKeyForPanelId } from 'app/features/dashboard-scene/utils/utils'; +import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; + +import { DashboardDatasource } from './datasource'; +import { DashboardQuery } from './types'; + +standardTransformersRegistry.setInit(getStandardTransformers); + +describe('DashboardDatasource', () => { + it("should look up the other panel and subscribe to it's data", async () => { + const { observable } = setup({ refId: 'A', panelId: 1 }); + + let rsp: DataQueryResponse | undefined; + + observable.subscribe({ next: (data) => (rsp = data) }); + + expect(rsp?.data[0].fields[0].values).toEqual([1, 2, 3]); + }); + + it('Can subscribe to panel data + transforms', async () => { + const { observable } = setup({ refId: 'A', panelId: 1, withTransforms: true }); + + let rsp: DataQueryResponse | undefined; + + observable.subscribe({ next: (data) => (rsp = data) }); + + expect(rsp?.data[0].fields[1].values).toEqual([3]); + }); + + it('Should activate source provder on observable subscribe and and deactivate when completed (if only activator)', async () => { + const { observable, sourceData } = setup({ refId: 'A', panelId: 1, withTransforms: true }); + + const test = observable.subscribe({ next: () => {} }); + + expect(sourceData.isActive).toBe(true); + + test.unsubscribe(); + + expect(sourceData.isActive).toBe(false); + }); +}); + +function setup(query: DashboardQuery) { + const sourceData = new SceneDataTransformer({ + $data: new SceneDataNode({ + data: { + series: [arrayToDataFrame([1, 2, 3])], + state: LoadingState.Done, + timeRange: getDefaultTimeRange(), + structureRev: 11, + }, + }), + transformations: [{ id: 'reduce', options: {} }], + }); + + const scene = new SceneFlexLayout({ + children: [ + new SceneFlexItem({ + body: new VizPanel({ + key: getVizPanelKeyForPanelId(1), + $data: sourceData, + }), + }), + ], + }); + + const ds = new DashboardDatasource({} as DataSourceInstanceSettings); + + const observable = ds.query({ + timezone: 'utc', + targets: [query], + requestId: '', + interval: '', + intervalMs: 0, + range: getDefaultTimeRange(), + scopedVars: { + __sceneObject: { value: scene }, + }, + app: '', + startTime: 0, + }); + + return { observable, sourceData }; +} diff --git a/public/app/plugins/datasource/dashboard/datasource.ts b/public/app/plugins/datasource/dashboard/datasource.ts index 96fcc13c6f650..c90c36d6963b9 100644 --- a/public/app/plugins/datasource/dashboard/datasource.ts +++ b/public/app/plugins/datasource/dashboard/datasource.ts @@ -1,3 +1,5 @@ +import { Observable, defer, finalize, map, of } from 'rxjs'; + import { DataSourceApi, DataQueryRequest, @@ -5,6 +7,8 @@ import { DataSourceInstanceSettings, TestDataSourceResponse, } from '@grafana/data'; +import { SceneDataProvider, SceneDataTransformer, SceneObject } from '@grafana/scenes'; +import { findVizPanelByKey, getVizPanelKeyForPanelId } from 'app/features/dashboard-scene/utils/utils'; import { DashboardQuery } from './types'; @@ -20,8 +24,63 @@ export class DashboardDatasource extends DataSourceApi<DashboardQuery> { return `Dashboard Reference: ${query.panelId}`; } - query(options: DataQueryRequest<DashboardQuery>): Promise<DataQueryResponse> { - return Promise.reject('This should not be called directly'); + query(options: DataQueryRequest<DashboardQuery>): Observable<DataQueryResponse> { + const scene: SceneObject | undefined = options.scopedVars?.__sceneObject?.value; + + if (!scene) { + throw new Error('Can only be called from a scene'); + } + + const query = options.targets[0]; + if (!query) { + return of({ data: [] }); + } + + const panelId = query.panelId; + + if (!panelId) { + return of({ data: [] }); + } + + let sourcePanel = this.findSourcePanel(scene, panelId); + + if (!sourcePanel) { + return of({ data: [], error: { message: 'Could not find source panel' } }); + } + + let sourceDataProvider: SceneDataProvider | undefined = sourcePanel.state.$data; + + if (!query.withTransforms && sourceDataProvider instanceof SceneDataTransformer) { + sourceDataProvider = sourceDataProvider.state.$data; + } + + if (!sourceDataProvider || !sourceDataProvider.getResultsStream) { + return of({ data: [] }); + } + + return defer(() => { + if (!sourceDataProvider!.isActive && sourceDataProvider?.setContainerWidth) { + sourceDataProvider?.setContainerWidth(500); + } + + const cleanUp = sourceDataProvider!.activate(); + + return sourceDataProvider!.getResultsStream!().pipe( + map((result) => { + return { + data: result.data.series, + state: result.data.state, + errors: result.data.errors, + error: result.data.error, + }; + }), + finalize(cleanUp) + ); + }); + } + + private findSourcePanel(scene: SceneObject, panelId: number) { + return findVizPanelByKey(scene, getVizPanelKeyForPanelId(panelId)); } testDatasource(): Promise<TestDataSourceResponse> { diff --git a/public/app/plugins/datasource/dashboard/module.ts b/public/app/plugins/datasource/dashboard/module.ts index 1fa8553046e00..c9f0cc591ecb0 100644 --- a/public/app/plugins/datasource/dashboard/module.ts +++ b/public/app/plugins/datasource/dashboard/module.ts @@ -1,5 +1,6 @@ import { DataSourcePlugin } from '@grafana/data'; +import { DashboardQueryEditor } from './DashboardQueryEditor'; import { DashboardDatasource } from './datasource'; -export const plugin = new DataSourcePlugin(DashboardDatasource); +export const plugin = new DataSourcePlugin(DashboardDatasource).setQueryEditor(DashboardQueryEditor); diff --git a/public/app/plugins/datasource/elasticsearch/ElasticResponse.test.ts b/public/app/plugins/datasource/elasticsearch/ElasticResponse.test.ts index e72e6b87a8cad..0f8a9aa059d04 100644 --- a/public/app/plugins/datasource/elasticsearch/ElasticResponse.test.ts +++ b/public/app/plugins/datasource/elasticsearch/ElasticResponse.test.ts @@ -1,9 +1,9 @@ import { DataFrame, DataFrameView, Field, FieldCache, FieldType, KeyValue, MutableDataFrame } from '@grafana/data'; -import flatten from 'app/core/utils/flatten'; import { ElasticResponse } from './ElasticResponse'; import { highlightTags } from './queryDef'; import { ElasticsearchQuery } from './types'; +import { flattenObject } from './utils'; function getTimeField(frame: DataFrame): Field { const field = frame.fields[0]; @@ -1445,7 +1445,7 @@ describe('ElasticResponse', () => { expect(r._id).toEqual(response.responses[0].hits.hits[i]._id); expect(r._type).toEqual(response.responses[0].hits.hits[i]._type); expect(r._index).toEqual(response.responses[0].hits.hits[i]._index); - expect(r._source).toEqual(flatten(response.responses[0].hits.hits[i]._source)); + expect(r._source).toEqual(flattenObject(response.responses[0].hits.hits[i]._source)); } // Make a map from the histogram results diff --git a/public/app/plugins/datasource/elasticsearch/ElasticResponse.ts b/public/app/plugins/datasource/elasticsearch/ElasticResponse.ts index 46994b11ba669..07ef4a2645a15 100644 --- a/public/app/plugins/datasource/elasticsearch/ElasticResponse.ts +++ b/public/app/plugins/datasource/elasticsearch/ElasticResponse.ts @@ -10,13 +10,12 @@ import { } from '@grafana/data'; import { convertFieldType } from '@grafana/data/src/transformations/transformers/convertFieldType'; import TableModel from 'app/core/TableModel'; -import flatten from 'app/core/utils/flatten'; import { isMetricAggregationWithField } from './components/QueryEditor/MetricAggregationsEditor/aggregations'; import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils'; import * as queryDef from './queryDef'; import { ElasticsearchAggregation, ElasticsearchQuery, TopMetrics, ExtendedStatMetaType } from './types'; -import { describeMetric, getScriptValue } from './utils'; +import { describeMetric, flattenObject, getScriptValue } from './utils'; const HIGHLIGHT_TAGS_EXP = `${queryDef.highlightTags.pre}([^@]+)${queryDef.highlightTags.post}`; type TopMetricMetric = Record<string, number>; @@ -678,7 +677,7 @@ const flattenHits = (hits: Doc[]): { docs: Array<Record<string, any>>; propNames let propNames: string[] = []; for (const hit of hits) { - const flattened = hit._source ? flatten(hit._source) : {}; + const flattened = hit._source ? flattenObject(hit._source) : {}; const doc = { _id: hit._id, _type: hit._type, diff --git a/public/app/plugins/datasource/elasticsearch/IndexPattern.test.ts b/public/app/plugins/datasource/elasticsearch/IndexPattern.test.ts index 38ecf5c07fa65..30ee68f7be352 100644 --- a/public/app/plugins/datasource/elasticsearch/IndexPattern.test.ts +++ b/public/app/plugins/datasource/elasticsearch/IndexPattern.test.ts @@ -1,5 +1,3 @@ -///<amd-dependency path="test/specs/helpers" name="helpers" /> - import { toUtc, getLocale, setLocale, dateTime } from '@grafana/data'; import { IndexPattern } from './IndexPattern'; diff --git a/public/app/plugins/datasource/elasticsearch/LegacyQueryRunner.ts b/public/app/plugins/datasource/elasticsearch/LegacyQueryRunner.ts index c70a6ee60b2ac..efb9d12591719 100644 --- a/public/app/plugins/datasource/elasticsearch/LegacyQueryRunner.ts +++ b/public/app/plugins/datasource/elasticsearch/LegacyQueryRunner.ts @@ -151,7 +151,11 @@ export class LegacyQueryRunner { query(request: DataQueryRequest<ElasticsearchQuery>): Observable<DataQueryResponse> { let payload = ''; - const targets = this.datasource.interpolateVariablesInQueries(cloneDeep(request.targets), request.scopedVars); + const targets = this.datasource.interpolateVariablesInQueries( + cloneDeep(request.targets), + request.scopedVars, + request.filters + ); const sentTargets: ElasticsearchQuery[] = []; let targetsContainsLogsQuery = targets.some((target) => hasMetricOfType(target, 'logs')); diff --git a/public/app/plugins/datasource/elasticsearch/QueryBuilder.test.ts b/public/app/plugins/datasource/elasticsearch/QueryBuilder.test.ts index c5cb83e50d2b8..aefa020ed310d 100644 --- a/public/app/plugins/datasource/elasticsearch/QueryBuilder.test.ts +++ b/public/app/plugins/datasource/elasticsearch/QueryBuilder.test.ts @@ -19,11 +19,11 @@ describe('ElasticQueryBuilder', () => { it('should clean settings from null values', () => { const query = builder.build({ refId: 'A', - // The following `missing: null as any` is because previous versions of the DS where + // The following `missing: null as unknown as string` is because previous versions of the DS where // storing null in the query model when inputting an empty string, // which were then removed in the query builder. // The new version doesn't store empty strings at all. This tests ensures backward compatibility. - metrics: [{ type: 'avg', id: '0', settings: { missing: null as any, script: '1' } }], + metrics: [{ type: 'avg', id: '0', settings: { missing: null as unknown as string, script: '1' } }], timeField: '@timestamp', bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '1' }], }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.test.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.test.tsx index 9389f779bf786..52b45832fbf12 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.test.tsx @@ -1,10 +1,9 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; -import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; - -import { DateHistogram } from 'app/plugins/datasource/elasticsearch/types'; +import { select } from 'react-select-event'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; +import { DateHistogram } from '../../../../types'; import { DateHistogramSettingsEditor } from './DateHistogramSettingsEditor'; @@ -63,7 +62,7 @@ describe('DateHistogramSettingsEditor', () => { expect(await screen.findByText('Calendar interval')).toBeInTheDocument(); expect(await screen.findByText('1w')).toBeInTheDocument(); - await selectOptionInTest(screen.getByLabelText('Calendar interval'), '10s'); + await select(screen.getByLabelText('Calendar interval'), '10s', { container: document.body }); expect(dispatch).toHaveBeenCalledTimes(1); }); @@ -79,7 +78,7 @@ describe('DateHistogramSettingsEditor', () => { expect(await screen.findByText('Fixed interval')).toBeInTheDocument(); expect(await screen.findByText('1m')).toBeInTheDocument(); - await selectOptionInTest(screen.getByLabelText('Fixed interval'), '1q'); + await select(screen.getByLabelText('Fixed interval'), '1q', { container: document.body }); expect(dispatch).toHaveBeenCalledTimes(1); }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx index 7fd3612178395..52a3081ac1901 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx @@ -4,8 +4,8 @@ import { GroupBase, OptionsOrGroups } from 'react-select'; import { InternalTimeZones, SelectableValue } from '@grafana/data'; import { InlineField, Input, Select, TimeZonePicker } from '@grafana/ui'; -import { calendarIntervals } from 'app/plugins/datasource/elasticsearch/QueryBuilder'; +import { calendarIntervals } from '../../../../QueryBuilder'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; import { DateHistogram } from '../../../../types'; import { useCreatableSelectPersistedBehaviour } from '../../../hooks/useCreatableSelectPersistedBehaviour'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx index 87dedea1f6dcc..97442a66f354c 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx @@ -2,10 +2,9 @@ import { screen } from '@testing-library/react'; import React from 'react'; import selectEvent from 'react-select-event'; -import { describeMetric } from 'app/plugins/datasource/elasticsearch/utils'; - import { renderWithESProvider } from '../../../../test-helpers/render'; import { ElasticsearchQuery, Terms, Average, Derivative, TopMetrics } from '../../../../types'; +import { describeMetric } from '../../../../utils'; import { TermsSettingsEditor } from './TermsSettingsEditor'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts index 5def15faa5595..282df98b144cd 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts @@ -1,5 +1,4 @@ -import { defaultGeoHashPrecisionString } from 'app/plugins/datasource/elasticsearch/queryDef'; - +import { defaultGeoHashPrecisionString } from '../../../../queryDef'; import { BucketAggregation } from '../../../../types'; import { describeMetric, convertOrderByToMetricId } from '../../../../utils'; import { useQuery } from '../../ElasticsearchQueryContext'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts index b39166c109302..bdb9616b91dcc 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts @@ -1,9 +1,6 @@ -import { reducerTester } from 'test/core/redux/reducerTester'; - -import { defaultBucketAgg } from 'app/plugins/datasource/elasticsearch/queryDef'; -import { ElasticsearchQuery } from 'app/plugins/datasource/elasticsearch/types'; - -import { BucketAggregation, DateHistogram } from '../../../../types'; +import { defaultBucketAgg } from '../../../../queryDef'; +import { BucketAggregation, DateHistogram, ElasticsearchQuery } from '../../../../types'; +import { reducerTester } from '../../../reducerTester'; import { changeMetricType } from '../../MetricAggregationsEditor/state/actions'; import { initQuery } from '../../state'; import { bucketAggregationConfig } from '../utils'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.test.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.test.tsx index 3b844138b922a..81a21af465272 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.test.tsx @@ -116,9 +116,7 @@ describe('Metric Editor', () => { render(<MetricEditor value={count} />, { wrapper }); - act(() => { - userEvent.click(screen.getByText('Count')); - }); + await userEvent.click(screen.getByText('Count')); // we check if the list-of-options is visible by // checking for an item to exist diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts index f21388ffc7f8f..b9ecf0749e7d5 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts @@ -1,6 +1,5 @@ -import { reducerTester } from 'test/core/redux/reducerTester'; - import { PipelineVariable } from '../../../../../../types'; +import { reducerTester } from '../../../../../reducerTester'; import { addPipelineVariable, diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx index 8d58b2960d1e4..5a0d48924c1f4 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx @@ -2,10 +2,10 @@ import { uniqueId } from 'lodash'; import React, { ComponentProps, useState } from 'react'; import { InlineField, Input } from '@grafana/ui'; -import { getScriptValue } from 'app/plugins/datasource/elasticsearch/utils'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; import { MetricAggregationWithInlineScript, MetricAggregationWithSettings } from '../../../../types'; +import { getScriptValue } from '../../../../utils'; import { SettingKeyOf } from '../../../types'; import { changeMetricSetting } from '../state/actions'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts index 246642b7b5eec..0719b2024c8f9 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts @@ -1,9 +1,6 @@ -import { reducerTester } from 'test/core/redux/reducerTester'; - -import { ElasticsearchQuery } from 'app/plugins/datasource/elasticsearch/types'; - import { defaultMetricAgg } from '../../../../queryDef'; -import { Derivative, ExtendedStats, MetricAggregation } from '../../../../types'; +import { Derivative, ElasticsearchQuery, ExtendedStats, MetricAggregation } from '../../../../types'; +import { reducerTester } from '../../../reducerTester'; import { initQuery } from '../../state'; import { metricAggregationConfig } from '../utils'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts index bb7ea5a161fa5..987551baf51c5 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts @@ -1,6 +1,5 @@ -import { reducerTester } from 'test/core/redux/reducerTester'; - import { ElasticsearchQuery } from '../../types'; +import { reducerTester } from '../reducerTester'; import { aliasPatternReducer, changeAliasPattern, changeQuery, initQuery, queryReducer } from './state'; diff --git a/public/app/plugins/datasource/elasticsearch/components/reducerTester.ts b/public/app/plugins/datasource/elasticsearch/components/reducerTester.ts new file mode 100644 index 0000000000000..38c466afa2b6b --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/reducerTester.ts @@ -0,0 +1,109 @@ +import { AnyAction } from '@reduxjs/toolkit'; +import { cloneDeep } from 'lodash'; +import { Action } from 'redux'; + +import { StoreState } from 'app/types'; + +type GrafanaReducer<S = StoreState, A extends Action = AnyAction> = (state: S, action: A) => S; + +export interface Given<State> { + givenReducer: ( + reducer: GrafanaReducer<State, AnyAction>, + state: State, + showDebugOutput?: boolean, + disableDeepFreeze?: boolean + ) => When<State>; +} + +export interface When<State> { + whenActionIsDispatched: (action: AnyAction) => Then<State>; +} + +export interface Then<State> { + thenStateShouldEqual: (state: State) => When<State>; + thenStatePredicateShouldEqual: (predicate: (resultingState: State) => boolean) => When<State>; + whenActionIsDispatched: (action: AnyAction) => Then<State>; +} + +const isNotException = (object: unknown, propertyName: string) => + typeof object === 'function' + ? propertyName !== 'caller' && propertyName !== 'callee' && propertyName !== 'arguments' + : true; + +export const deepFreeze = <T>(obj: T): T => { + if (typeof obj === 'object') { + for (const key in obj) { + const prop = obj[key]; + + if ( + prop && + Object.hasOwn(obj, key) && + isNotException(obj, key) && + (typeof prop === 'object' || typeof prop === 'function') && + !Object.isFrozen(prop) + ) { + deepFreeze(prop); + } + } + } + + return Object.freeze(obj); +}; + +interface ReducerTester<State> extends Given<State>, When<State>, Then<State> {} + +export const reducerTester = <State>(): Given<State> => { + let reducerUnderTest: GrafanaReducer<State, AnyAction>; + let resultingState: State; + let initialState: State; + let showDebugOutput = false; + + const givenReducer = ( + reducer: GrafanaReducer<State, AnyAction>, + state: State, + debug = false, + disableDeepFreeze = false + ): When<State> => { + reducerUnderTest = reducer; + initialState = cloneDeep(state); + if (!disableDeepFreeze && (typeof state === 'object' || typeof state === 'function')) { + deepFreeze(initialState); + } + showDebugOutput = debug; + + return instance; + }; + + const whenActionIsDispatched = (action: AnyAction): Then<State> => { + resultingState = reducerUnderTest(resultingState || initialState, action); + + return instance; + }; + + const thenStateShouldEqual = (state: State): When<State> => { + if (showDebugOutput) { + console.log(JSON.stringify(resultingState, null, 2)); + } + expect(resultingState).toEqual(state); + + return instance; + }; + + const thenStatePredicateShouldEqual = (predicate: (resultingState: State) => boolean): When<State> => { + if (showDebugOutput) { + console.log(JSON.stringify(resultingState, null, 2)); + } + expect(predicate(resultingState)).toBe(true); + + return instance; + }; + + const instance: ReducerTester<State> = { + thenStateShouldEqual, + thenStatePredicateShouldEqual, + givenReducer, + whenActionIsDispatched, + }; + + return instance; +}; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx index 850cdb470dfec..8438017aa4097 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { ConfigEditor } from './ConfigEditor'; -import { createDefaultConfigOptions } from './mocks'; +import { createDefaultConfigOptions } from './__mocks__/configOptions'; describe('ConfigEditor', () => { it('should render without error', () => { diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx index a3ca98774df84..1dc4aa84780b3 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx @@ -11,9 +11,8 @@ import { convertLegacyAuthProps, DataSourceDescription, } from '@grafana/experimental'; -import { Alert, SecureSocksProxySettings } from '@grafana/ui'; -import { Divider } from 'app/core/components/Divider'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; +import { Alert, SecureSocksProxySettings, Divider, Stack } from '@grafana/ui'; import { ElasticsearchOptions } from '../types'; @@ -61,9 +60,9 @@ export const ConfigEditor = (props: Props) => { docsLink="https://grafana.com/docs/grafana/latest/datasources/elasticsearch" hasRequiredFields={false} /> - <Divider /> + <Divider spacing={4} /> <ConnectionSettings config={options} onChange={onOptionsChange} urlPlaceholder="http://localhost:9200" /> - <Divider /> + <Divider spacing={4} /> <Auth {...authProps} onAuthMethodSelect={(method) => { @@ -79,42 +78,41 @@ export const ConfigEditor = (props: Props) => { }); }} /> - <Divider /> + <Divider spacing={4} /> <ConfigSection title="Additional settings" description="Additional settings are optional settings that can be configured for more control over your data source." isCollapsible={true} isInitiallyOpen > - <AdvancedHttpSettings config={options} onChange={onOptionsChange} /> - <Divider hideLine /> - {config.secureSocksDSProxyEnabled && ( - <SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} /> - )} - <ElasticDetails value={options} onChange={onOptionsChange} /> - <Divider hideLine /> - <LogsConfig - value={options.jsonData} - onChange={(newValue) => - onOptionsChange({ - ...options, - jsonData: newValue, - }) - } - /> - <Divider hideLine /> - <DataLinks - value={options.jsonData.dataLinks} - onChange={(newValue) => { - onOptionsChange({ - ...options, - jsonData: { - ...options.jsonData, - dataLinks: newValue, - }, - }); - }} - /> + <Stack gap={5} direction="column"> + <AdvancedHttpSettings config={options} onChange={onOptionsChange} /> + {config.secureSocksDSProxyEnabled && ( + <SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} /> + )} + <ElasticDetails value={options} onChange={onOptionsChange} /> + <LogsConfig + value={options.jsonData} + onChange={(newValue) => + onOptionsChange({ + ...options, + jsonData: newValue, + }) + } + /> + <DataLinks + value={options.jsonData.dataLinks} + onChange={(newValue) => { + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + dataLinks: newValue, + }, + }); + }} + /> + </Stack> </ConfigSection> </> ); diff --git a/public/app/plugins/datasource/elasticsearch/configuration/DataLink.tsx b/public/app/plugins/datasource/elasticsearch/configuration/DataLink.tsx index 8783582553938..da2c231ad135c 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/DataLink.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/DataLink.tsx @@ -3,6 +3,7 @@ import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; import { usePrevious } from 'react-use'; import { DataSourceInstanceSettings, VariableSuggestion } from '@grafana/data'; +import { DataSourcePicker } from '@grafana/runtime'; import { Button, DataLinkInput, @@ -13,7 +14,6 @@ import { Input, useStyles2, } from '@grafana/ui'; -import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; import { DataLinkConfig } from '../types'; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx b/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx index f6ee3b1a6d312..ed340f5c0443f 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx @@ -2,9 +2,8 @@ import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2, VariableOrigin, DataLinkBuiltInVars } from '@grafana/data'; -import { ConfigSubSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/experimental'; import { Button, useStyles2 } from '@grafana/ui'; -import { ConfigDescriptionLink } from 'app/core/components/ConfigDescriptionLink'; import { DataLinkConfig } from '../types'; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx index 42c69cd9cd9a1..0b7af77201d8a 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import selectEvent from 'react-select-event'; import { ElasticDetails } from './ElasticDetails'; -import { createDefaultConfigOptions } from './mocks'; +import { createDefaultConfigOptions } from './__mocks__/configOptions'; describe('ElasticDetails', () => { describe('Max concurrent Shard Requests', () => { diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.tsx index f0d51d209e340..cafaacf22838b 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.tsx @@ -1,9 +1,8 @@ import React from 'react'; import { DataSourceSettings, SelectableValue } from '@grafana/data'; -import { ConfigSubSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/experimental'; import { InlineField, Input, Select, InlineSwitch } from '@grafana/ui'; -import { ConfigDescriptionLink } from 'app/core/components/ConfigDescriptionLink'; import { ElasticsearchOptions, Interval } from '../types'; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.test.tsx b/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.test.tsx index 861ed7cdfc17b..97d3d130398da 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.test.tsx @@ -2,7 +2,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { LogsConfig } from './LogsConfig'; -import { createDefaultConfigOptions } from './mocks'; +import { createDefaultConfigOptions } from './__mocks__/configOptions'; describe('ElasticDetails', () => { it('should pass correct data to onChange', () => { diff --git a/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.tsx b/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.tsx index efaa9300e86dc..3289d2fd90ac1 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.tsx @@ -1,8 +1,7 @@ import React from 'react'; -import { ConfigSubSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/experimental'; import { Input, InlineField } from '@grafana/ui'; -import { ConfigDescriptionLink } from 'app/core/components/ConfigDescriptionLink'; import { ElasticsearchOptions } from '../types'; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/__mocks__/configOptions.ts b/public/app/plugins/datasource/elasticsearch/configuration/__mocks__/configOptions.ts new file mode 100644 index 0000000000000..8f7e7963cebe3 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/__mocks__/configOptions.ts @@ -0,0 +1,17 @@ +import { DataSourceSettings } from '@grafana/data'; + +import { ElasticsearchOptions } from '../../types'; + +export function createDefaultConfigOptions(): DataSourceSettings<ElasticsearchOptions> { + return { + jsonData: { + timeField: '@time', + interval: 'Hourly', + timeInterval: '10s', + maxConcurrentShardRequests: 300, + logMessageField: 'test.message', + logLevelField: 'test.level', + }, + secureJsonFields: {}, + } as DataSourceSettings<ElasticsearchOptions>; +} diff --git a/public/app/plugins/datasource/elasticsearch/configuration/mocks.ts b/public/app/plugins/datasource/elasticsearch/configuration/mocks.ts deleted file mode 100644 index c716a4d03ab63..0000000000000 --- a/public/app/plugins/datasource/elasticsearch/configuration/mocks.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { DataSourceSettings } from '@grafana/data'; -import { getMockDataSource } from 'app/features/datasources/__mocks__'; - -import { ElasticsearchOptions } from '../types'; - -export function createDefaultConfigOptions( - options?: Partial<ElasticsearchOptions> -): DataSourceSettings<ElasticsearchOptions> { - return getMockDataSource<ElasticsearchOptions>({ - jsonData: { - timeField: '@time', - interval: 'Hourly', - timeInterval: '10s', - maxConcurrentShardRequests: 300, - logMessageField: 'test.message', - logLevelField: 'test.level', - ...options, - }, - }); -} diff --git a/public/app/plugins/datasource/elasticsearch/dataquery.gen.ts b/public/app/plugins/datasource/elasticsearch/dataquery.gen.ts index f9f394294f6cc..f18c10029c05a 100644 --- a/public/app/plugins/datasource/elasticsearch/dataquery.gen.ts +++ b/public/app/plugins/datasource/elasticsearch/dataquery.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -376,7 +376,7 @@ export type PipelineMetricAggregation = (MovingAverage | Derivative | Cumulative export type MetricAggregationWithSettings = (BucketScript | CumulativeSum | Derivative | SerialDiff | RawData | RawDocument | UniqueCount | Percentiles | ExtendedStats | Min | Max | Sum | Average | MovingAverage | MovingFunction | Logs | Rate | TopMetrics); -export interface Elasticsearch extends common.DataQuery { +export interface ElasticsearchDataQuery extends common.DataQuery { /** * Alias pattern */ @@ -399,7 +399,7 @@ export interface Elasticsearch extends common.DataQuery { timeField?: string; } -export const defaultElasticsearch: Partial<Elasticsearch> = { +export const defaultElasticsearchDataQuery: Partial<ElasticsearchDataQuery> = { bucketAggs: [], metrics: [], }; diff --git a/public/app/plugins/datasource/elasticsearch/datasource.test.ts b/public/app/plugins/datasource/elasticsearch/datasource.test.ts index d59640549dab4..7fd3446dcfb15 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.test.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.test.ts @@ -1,6 +1,5 @@ import { map } from 'lodash'; import { Observable, of, throwError } from 'rxjs'; -import { getQueryOptions } from 'test/helpers/getQueryOptions'; import { CoreApp, @@ -18,9 +17,6 @@ import { toUtc, } from '@grafana/data'; import { BackendSrvRequest, FetchResponse, reportInteraction, config } from '@grafana/runtime'; -import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ - -import { createFetchResponse } from '../../../../test/helpers/createFetchResponse'; import { enhanceDataFrame } from './LegacyQueryRunner'; import { ElasticDatasource } from './datasource'; @@ -30,7 +26,9 @@ import { Filters, ElasticsearchOptions, ElasticsearchQuery } from './types'; const ELASTICSEARCH_MOCK_URL = 'http://elasticsearch.local'; const originalConsoleError = console.error; - +const backendSrv = { + fetch: jest.fn(), +}; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getBackendSrv: () => backendSrv, @@ -44,6 +42,15 @@ jest.mock('@grafana/runtime', () => ({ }, })); +const createTimeRange = (from: DateTime, to: DateTime): TimeRange => ({ + from, + to, + raw: { + from, + to, + }, +}); + const TIME_START = [2022, 8, 21, 6, 10, 10]; const TIME_END = [2022, 8, 24, 6, 10, 21]; const DATAQUERY_BASE = { @@ -56,16 +63,22 @@ const DATAQUERY_BASE = { timezone: '', app: 'test', startTime: 0, + range: createTimeRange(toUtc(TIME_START), toUtc(TIME_END)), }; -const createTimeRange = (from: DateTime, to: DateTime): TimeRange => ({ - from, - to, - raw: { - from, - to, - }, -}); +function createFetchResponse<T>(data: T): FetchResponse<T> { + return { + data, + status: 200, + url: 'http://localhost:3000/api/ds/query', + config: { url: 'http://localhost:3000/api/ds/query' }, + type: 'basic', + statusText: 'Ok', + redirected: false, + headers: {} as unknown as Headers, + ok: true, + }; +} interface TestContext { data?: Data; @@ -151,6 +164,15 @@ describe('ElasticDatasource', () => { const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; expect(lastCall[0].url).toBe(`${ELASTICSEARCH_MOCK_URL}/test-${today}/_mapping`); }); + + it('should call `/_mapping` with an empty index', async () => { + const { ds, fetchMock } = getTestContext({ jsonData: { index: '' } }); + + await ds.testDatasource(); + + const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; + expect(lastCall[0].url).toBe(`${ELASTICSEARCH_MOCK_URL}/_mapping`); + }); }); describe('When issuing metric query with interval pattern', () => { @@ -968,29 +990,31 @@ describe('ElasticDatasource', () => { }); it('does not create a logs sample provider for non time series query', () => { - const options = getQueryOptions<ElasticsearchQuery>({ + const options: DataQueryRequest<ElasticsearchQuery> = { + ...DATAQUERY_BASE, targets: [ { refId: 'A', metrics: [{ type: 'logs', id: '1', settings: { limit: '100' } }], }, ], - }); + }; - expect(ds.getDataProvider(SupplementaryQueryType.LogsSample, options)).not.toBeDefined(); + expect(ds.getSupplementaryRequest(SupplementaryQueryType.LogsSample, options)).not.toBeDefined(); }); it('does create a logs sample provider for time series query', () => { - const options = getQueryOptions<ElasticsearchQuery>({ + const options: DataQueryRequest<ElasticsearchQuery> = { + ...DATAQUERY_BASE, targets: [ { refId: 'A', bucketAggs: [{ type: 'date_histogram', id: '1' }], }, ], - }); + }; - expect(ds.getDataProvider(SupplementaryQueryType.LogsSample, options)).toBeDefined(); + expect(ds.getSupplementaryRequest(SupplementaryQueryType.LogsSample, options)).toBeDefined(); }); }); @@ -1001,29 +1025,31 @@ describe('ElasticDatasource', () => { }); it("doesn't return a logs sample provider given a non time series query", () => { - const request = getQueryOptions<ElasticsearchQuery>({ + const request: DataQueryRequest<ElasticsearchQuery> = { + ...DATAQUERY_BASE, targets: [ { refId: 'A', metrics: [{ type: 'logs', id: '1', settings: { limit: '100' } }], }, ], - }); + }; - expect(ds.getLogsSampleDataProvider(request)).not.toBeDefined(); + expect(ds.getSupplementaryRequest(SupplementaryQueryType.LogsSample, request)).not.toBeDefined(); }); it('returns a logs sample provider given a time series query', () => { - const request = getQueryOptions<ElasticsearchQuery>({ + const request: DataQueryRequest<ElasticsearchQuery> = { + ...DATAQUERY_BASE, targets: [ { refId: 'A', bucketAggs: [{ type: 'date_histogram', id: '1' }], }, ], - }); + }; - expect(ds.getLogsSampleDataProvider(request)).toBeDefined(); + expect(ds.getSupplementaryRequest(SupplementaryQueryType.LogsSample, request)).toBeDefined(); }); }); }); @@ -1579,6 +1605,9 @@ describe('ElasticDatasource using backend', () => { const annotations = await ds.annotationQuery({ annotation: {}, + dashboard: { + getVariables: () => [], + }, range: timeRange, }); @@ -1610,6 +1639,9 @@ describe('ElasticDatasource using backend', () => { tagsField: '@test_tags', textField: 'text', }, + dashboard: { + getVariables: () => [], + }, range: timeRange, }); expect(annotations).toHaveLength(2); @@ -1648,6 +1680,9 @@ describe('ElasticDatasource using backend', () => { tagsField: '@test_tags', textField: 'text', }, + dashboard: { + getVariables: () => [], + }, range: { from: dateTime(1683291160012), to: dateTime(1683291460012), @@ -1676,6 +1711,9 @@ describe('ElasticDatasource using backend', () => { await ds.annotationQuery({ annotation: {}, + dashboard: { + getVariables: () => [], + }, range: { from: dateTime(1683291160012), to: dateTime(1683291460012), @@ -1686,6 +1724,63 @@ describe('ElasticDatasource using backend', () => { '{"search_type":"query_then_fetch","ignore_unavailable":true,"index":"[test-]YYYY.MM.DD"}\n{"query":{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"from":1683291160012,"to":1683291460012,"format":"epoch_millis"}}}],"minimum_should_match":1}}]}},"size":10000}\n' ); }); + + it('should process annotation request using dashboard adhoc variables', async () => { + const { ds } = getTestContext(); + const postResourceRequestMock = jest.spyOn(ds, 'postResourceRequest').mockResolvedValue({ + responses: [ + { + hits: { + hits: [ + { _source: { '@test_time': 1, '@test_tags': 'foo', text: 'abc' } }, + { _source: { '@test_time': 3, '@test_tags': 'bar', text: 'def' } }, + ], + }, + }, + ], + }); + + await ds.annotationQuery({ + annotation: { + timeField: '@test_time', + timeEndField: '@time_end_field', + name: 'foo', + query: 'abc', + tagsField: '@test_tags', + textField: 'text', + datasource: { + type: 'elasticsearch', + uid: 'gdev-elasticsearch', + }, + }, + dashboard: { + getVariables: () => [ + { + type: 'adhoc', + datasource: { + type: 'elasticsearch', + uid: 'gdev-elasticsearch', + }, + filters: [ + { + key: 'abc_key', + operator: '=', + value: 'abc_value', + }, + ], + }, + ], + }, + range: { + from: dateTime(1683291160012), + to: dateTime(1683291460012), + }, + }); + expect(postResourceRequestMock).toHaveBeenCalledWith( + '_msearch', + '{"search_type":"query_then_fetch","ignore_unavailable":true,"index":"[test-]YYYY.MM.DD"}\n{"query":{"bool":{"filter":[{"bool":{"should":[{"range":{"@test_time":{"from":1683291160012,"to":1683291460012,"format":"epoch_millis"}}},{"range":{"@time_end_field":{"from":1683291160012,"to":1683291460012,"format":"epoch_millis"}}}],"minimum_should_match":1}},{"query_string":{"query":"abc AND abc_key:\\"abc_value\\""}}]}},"size":10000}\n' + ); + }); }); }); diff --git a/public/app/plugins/datasource/elasticsearch/datasource.ts b/public/app/plugins/datasource/elasticsearch/datasource.ts index d40bb41bd3a46..7fab51613c9c9 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.ts @@ -31,13 +31,14 @@ import { SupplementaryQueryOptions, toUtc, AnnotationEvent, - FieldType, DataSourceWithToggleableQueryFiltersSupport, QueryFilterOptions, ToggleFilterAction, DataSourceGetTagValuesOptions, AdHocVariableFilter, DataSourceWithQueryModificationSupport, + AdHocVariableModel, + TypedVariableModel, } from '@grafana/data'; import { DataSourceWithBackend, @@ -48,9 +49,6 @@ import { getTemplateSrv, } from '@grafana/runtime'; -import { queryLogsSample, queryLogsVolume } from '../../../features/logs/logsModel'; -import { getLogLevelFromKey } from '../../../features/logs/utils'; - import { IndexPattern, intervalMap } from './IndexPattern'; import LanguageProvider from './LanguageProvider'; import { LegacyQueryRunner } from './LegacyQueryRunner'; @@ -195,17 +193,24 @@ export class ElasticDatasource * * When multiple indices span the provided time range, the request is sent starting from the newest index, * and then going backwards until an index is found. - * - * @param url the url to query the index on, for example `/_mapping`. */ - - private requestAllIndices(url: string, range = getDefaultTimeRange()) { + private requestAllIndices(range = getDefaultTimeRange()) { let indexList = this.indexPattern.getIndexList(range.from, range.to); if (!Array.isArray(indexList)) { indexList = [this.indexPattern.getIndexForToday()]; } - const indexUrlList = indexList.map((index) => index + url); + const url = '_mapping'; + + const indexUrlList = indexList.map((index) => { + // make sure `index` does not end with a slash + index = index.replace(/\/$/, ''); + if (index === '') { + return url; + } + + return `${index}/${url}`; + }); const maxTraversals = 7; // do not go beyond one week (for a daily pattern) const listLen = indexUrlList.length; @@ -257,10 +262,21 @@ export class ElasticDatasource ); } - private prepareAnnotationRequest(options: { annotation: ElasticsearchAnnotationQuery; range: TimeRange }) { + private prepareAnnotationRequest(options: { + annotation: ElasticsearchAnnotationQuery; + // Should be DashboardModel but cannot import that here from the main app. This is a temporary solution as we need to move from deprecated annotations. + dashboard: { getVariables: () => TypedVariableModel[] }; + range: TimeRange; + }) { const annotation = options.annotation; const timeField = annotation.timeField || '@timestamp'; const timeEndField = annotation.timeEndField || null; + const dashboard = options.dashboard; + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const adhocVariables = dashboard.getVariables().filter((v) => v.type === 'adhoc') as AdHocVariableModel[]; + const annotationRelatedVariables = adhocVariables.filter((v) => v.datasource?.uid === annotation.datasource.uid); + const filters = annotationRelatedVariables.map((v) => v.filters).flat(); // the `target.query` is the "new" location for the query. // normally we would write this code as @@ -293,6 +309,8 @@ export class ElasticDatasource } const queryInterpolated = this.interpolateLuceneQuery(queryString); + const finalQuery = this.addAdHocFilters(queryInterpolated, filters); + const query: { bool: { filter: Array<Record<string, Record<string, string | number | Array<{ range: RangeMap }>>>> }; } = { @@ -308,10 +326,10 @@ export class ElasticDatasource }, }; - if (queryInterpolated) { + if (finalQuery) { query.bool.filter.push({ query_string: { - query: queryInterpolated, + query: finalQuery, }, }); } @@ -531,13 +549,15 @@ export class ElasticDatasource } }; - getDataProvider( + /** + * Implemented for DataSourceWithSupplementaryQueriesSupport. + * It generates a DataQueryRequest for a specific supplementary query type. + * @returns A DataQueryRequest for the supplementary queries or undefined if not supported. + */ + getSupplementaryRequest( type: SupplementaryQueryType, request: DataQueryRequest<ElasticsearchQuery> - ): Observable<DataQueryResponse> | undefined { - if (!this.getSupportedSupplementaryQueryTypes().includes(type)) { - return undefined; - } + ): DataQueryRequest<ElasticsearchQuery> | undefined { switch (type) { case SupplementaryQueryType.LogsVolume: return this.getLogsVolumeDataProvider(request); @@ -553,10 +573,6 @@ export class ElasticDatasource } getSupplementaryQuery(options: SupplementaryQueryOptions, query: ElasticsearchQuery): ElasticsearchQuery | undefined { - if (!this.getSupportedSupplementaryQueryTypes().includes(options.type)) { - return undefined; - } - let isQuerySuitable = false; switch (options.type) { @@ -628,7 +644,9 @@ export class ElasticDatasource } } - getLogsVolumeDataProvider(request: DataQueryRequest<ElasticsearchQuery>): Observable<DataQueryResponse> | undefined { + private getLogsVolumeDataProvider( + request: DataQueryRequest<ElasticsearchQuery> + ): DataQueryRequest<ElasticsearchQuery> | undefined { const logsVolumeRequest = cloneDeep(request); const targets = logsVolumeRequest.targets .map((target) => this.getSupplementaryQuery({ type: SupplementaryQueryType.LogsVolume }, target)) @@ -638,18 +656,12 @@ export class ElasticDatasource return undefined; } - return queryLogsVolume( - this, - { ...logsVolumeRequest, targets }, - { - range: request.range, - targets: request.targets, - extractLevel, - } - ); + return { ...logsVolumeRequest, targets }; } - getLogsSampleDataProvider(request: DataQueryRequest<ElasticsearchQuery>): Observable<DataQueryResponse> | undefined { + private getLogsSampleDataProvider( + request: DataQueryRequest<ElasticsearchQuery> + ): DataQueryRequest<ElasticsearchQuery> | undefined { const logsSampleRequest = cloneDeep(request); const targets = logsSampleRequest.targets; const queries = targets.map((query) => { @@ -660,7 +672,7 @@ export class ElasticDatasource if (!elasticQueries.length) { return undefined; } - return queryLogsSample(this, { ...logsSampleRequest, targets: elasticQueries }); + return { ...logsSampleRequest, targets: elasticQueries }; } query(request: DataQueryRequest<ElasticsearchQuery>): Observable<DataQueryResponse> { @@ -708,7 +720,7 @@ export class ElasticDatasource nested: 'nested', histogram: 'number', }; - return this.requestAllIndices('/_mapping', range).pipe( + return this.requestAllIndices(range).pipe( map((result) => { const shouldAddField = (obj: any, key: string) => { if (this.isMetadataField(key)) { @@ -1171,9 +1183,3 @@ function createContextTimeRange(rowTimeEpochMs: number, direction: string, inter } } } - -function extractLevel(dataFrame: DataFrame): LogLevel { - const valueField = dataFrame.fields.find((f) => f.type === FieldType.number); - const name = valueField?.labels?.['level'] ?? ''; - return getLogLevelFromKey(name); -} diff --git a/public/app/plugins/datasource/elasticsearch/mocks.ts b/public/app/plugins/datasource/elasticsearch/mocks.ts index a33e258c449ca..09470fff165f8 100644 --- a/public/app/plugins/datasource/elasticsearch/mocks.ts +++ b/public/app/plugins/datasource/elasticsearch/mocks.ts @@ -39,9 +39,9 @@ export function createElasticDatasource(settings: Partial<DataSourceInstanceSett jsonData: { timeField: '', timeInterval: '', + index: '[test-]YYYY.MM.DD', ...jsonData, }, - database: '[test-]YYYY.MM.DD', ...rest, }; diff --git a/public/app/plugins/datasource/elasticsearch/tracking.ts b/public/app/plugins/datasource/elasticsearch/tracking.ts index a4d25158ec795..01e3c106dfc9d 100644 --- a/public/app/plugins/datasource/elasticsearch/tracking.ts +++ b/public/app/plugins/datasource/elasticsearch/tracking.ts @@ -1,10 +1,10 @@ import { CoreApp, DashboardLoadedEvent, DataQueryRequest, DataQueryResponse } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; -import { variableRegex } from 'app/features/variables/utils'; import { REF_ID_STARTER_LOG_VOLUME } from './datasource'; import pluginJson from './plugin.json'; import { ElasticsearchAnnotationQuery, ElasticsearchQuery } from './types'; +import { variableRegex } from './utils'; type ElasticSearchOnDashboardLoadedTrackingEvent = { grafana_version?: string; diff --git a/public/app/plugins/datasource/elasticsearch/types.ts b/public/app/plugins/datasource/elasticsearch/types.ts index 10feccc73a3f8..9e3a610b89556 100644 --- a/public/app/plugins/datasource/elasticsearch/types.ts +++ b/public/app/plugins/datasource/elasticsearch/types.ts @@ -1,4 +1,5 @@ import { DataSourceJsonData } from '@grafana/data'; +import { DataSourceRef } from '@grafana/schema'; import { BucketAggregationType, @@ -14,11 +15,11 @@ import { MovingAverage as SchemaMovingAverage, BucketAggregation, Logs as SchemaLogs, - Elasticsearch, + ElasticsearchDataQuery, } from './dataquery.gen'; export * from './dataquery.gen'; -export { Elasticsearch as ElasticsearchQuery } from './dataquery.gen'; +export { ElasticsearchDataQuery as ElasticsearchQuery } from './dataquery.gen'; // We want to extend the settings of the Logs query with additional properties that // are not part of the schema. This is a workaround, because exporting LogsSettings @@ -126,11 +127,12 @@ export type DataLinkConfig = { }; export interface ElasticsearchAnnotationQuery { - target: Elasticsearch; + target: ElasticsearchDataQuery; timeField?: string; titleField?: string; timeEndField?: string; query?: string; + datasource: DataSourceRef; tagsField?: string; textField?: string; // @deprecated index is deprecated and will be removed in the future diff --git a/public/app/plugins/datasource/elasticsearch/utils.test.ts b/public/app/plugins/datasource/elasticsearch/utils.test.ts index dcf10900c64c1..731efa9aefe95 100644 --- a/public/app/plugins/datasource/elasticsearch/utils.test.ts +++ b/public/app/plugins/datasource/elasticsearch/utils.test.ts @@ -1,5 +1,5 @@ import { ElasticsearchQuery } from './types'; -import { isTimeSeriesQuery, removeEmpty } from './utils'; +import { flattenObject, isTimeSeriesQuery, removeEmpty } from './utils'; describe('removeEmpty', () => { it('Should remove all empty', () => { @@ -79,3 +79,39 @@ describe('isTimeSeriesQuery', () => { expect(isTimeSeriesQuery(query)).toBe(true); }); }); + +describe('flattenObject', () => { + it('flattens objects of arbitrary depth', () => { + const nestedObject = { + a: { + b: { + c: 1, + d: { + e: 2, + f: 3, + }, + }, + g: 4, + }, + h: 5, + }; + + expect(flattenObject(nestedObject)).toEqual({ + 'a.b.c': 1, + 'a.b.d.e': 2, + 'a.b.d.f': 3, + 'a.g': 4, + h: 5, + }); + }); + + it('does not alter other objects', () => { + const nestedObject = { + a: 'uno', + b: 'dos', + c: 3, + }; + + expect(flattenObject(nestedObject)).toEqual(nestedObject); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/utils.ts b/public/app/plugins/datasource/elasticsearch/utils.ts index 63e3ac25feb88..1ca679734cf88 100644 --- a/public/app/plugins/datasource/elasticsearch/utils.ts +++ b/public/app/plugins/datasource/elasticsearch/utils.ts @@ -106,3 +106,53 @@ export const unsupportedVersionMessage = export const isTimeSeriesQuery = (query: ElasticsearchQuery): boolean => { return query?.bucketAggs?.slice(-1)[0]?.type === 'date_histogram'; }; + +/* + * This regex matches 3 types of variable reference with an optional format specifier + * There are 6 capture groups that replace will return + * \$(\w+) $var1 + * \[\[(\w+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]] + * \${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?} ${var3} or ${var3.fieldPath} or ${var3:fmt3} (or ${var3.fieldPath:fmt3} but that is not a separate capture group) + */ +export const variableRegex = /\$(\w+)|\[\[(\w+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g; + +// Copyright (c) 2014, Hugh Kennedy +// Based on code from https://github.com/hughsk/flat/blob/master/index.js +// +export function flattenObject( + target: Record<string, unknown>, + opts?: { delimiter?: string; maxDepth?: number; safe?: boolean } +): Record<string, unknown> { + opts = opts || {}; + + const delimiter = opts.delimiter || '.'; + let maxDepth = opts.maxDepth || 3; + let currentDepth = 1; + const output: Record<string, unknown> = {}; + + function step(object: Record<string, unknown>, prev: string | null) { + Object.keys(object).forEach((key) => { + const value = object[key]; + const isarray = opts?.safe && Array.isArray(value); + const type = Object.prototype.toString.call(value); + const isobject = type === '[object Object]'; + + const newKey = prev ? prev + delimiter + key : key; + + if (!opts?.maxDepth) { + maxDepth = currentDepth + 1; + } + + if (!isarray && isobject && value && Object.keys(value).length && currentDepth < maxDepth) { + ++currentDepth; + return step({ ...value }, newKey); + } + + output[newKey] = value; + }); + } + + step(target, null); + + return output; +} diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/PostgresQueryEditor.tsx b/public/app/plugins/datasource/grafana-postgresql-datasource/PostgresQueryEditor.tsx index 24f173893eead..f1fa982ae93e0 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/PostgresQueryEditor.tsx +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/PostgresQueryEditor.tsx @@ -1,10 +1,7 @@ import React from 'react'; import { QueryEditorProps } from '@grafana/data'; -import { SqlQueryEditor } from 'app/features/plugins/sql/components/QueryEditor'; -import { SQLOptions, SQLQuery } from 'app/features/plugins/sql/types'; - -import { QueryHeaderProps } from '../../../features/plugins/sql/components/QueryHeader'; +import { SqlQueryEditor, SQLOptions, SQLQuery, QueryHeaderProps } from '@grafana/sql'; import { PostgresDatasource } from './datasource'; diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/PostgresQueryModel.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/PostgresQueryModel.ts index aa3c47f0a78ef..a572733f1eeeb 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/PostgresQueryModel.ts +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/PostgresQueryModel.ts @@ -1,8 +1,7 @@ import { ScopedVars } from '@grafana/data'; import { TemplateSrv } from '@grafana/runtime'; import { VariableFormatID } from '@grafana/schema'; -import { applyQueryDefaults } from 'app/features/plugins/sql/defaults'; -import { SQLQuery, SqlQueryModel } from 'app/features/plugins/sql/types'; +import { SQLQuery, SqlQueryModel, applyQueryDefaults } from '@grafana/sql'; export class PostgresQueryModel implements SqlQueryModel { target: SQLQuery; diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/configuration/ConfigurationEditor.tsx b/public/app/plugins/datasource/grafana-postgresql-datasource/configuration/ConfigurationEditor.tsx index 3c1ea733a2f8e..8acacf21633bf 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/configuration/ConfigurationEditor.tsx +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/configuration/ConfigurationEditor.tsx @@ -10,6 +10,7 @@ import { } from '@grafana/data'; import { ConfigSection, ConfigSubSection, DataSourceDescription, Stack } from '@grafana/experimental'; import { config } from '@grafana/runtime'; +import { ConnectionLimits, Divider, TLSSecretsConfig, useMigrateDatabaseFields } from '@grafana/sql'; import { Input, Select, @@ -22,10 +23,6 @@ import { SecureSocksProxySettings, Collapse, } from '@grafana/ui'; -import { ConnectionLimits } from 'app/features/plugins/sql/components/configuration/ConnectionLimits'; -import { Divider } from 'app/features/plugins/sql/components/configuration/Divider'; -import { TLSSecretsConfig } from 'app/features/plugins/sql/components/configuration/TLSSecretsConfig'; -import { useMigrateDatabaseFields } from 'app/features/plugins/sql/components/configuration/useMigrateDatabaseFields'; import { PostgresOptions, PostgresTLSMethods, PostgresTLSModes, SecureJsonData } from '../types'; @@ -178,7 +175,7 @@ export const PostgresConfigEditor = (props: DataSourcePluginOptionsEditorProps<P > <Select options={tlsModes} - value={jsonData.sslmode || PostgresTLSModes.verifyFull} + value={jsonData.sslmode || PostgresTLSModes.require} onChange={onJSONDataOptionSelected('sslmode')} width={WIDTH_LONG} /> @@ -403,7 +400,7 @@ export const PostgresConfigEditor = (props: DataSourcePluginOptionsEditorProps<P <ConnectionLimits options={options} onOptionsChange={onOptionsChange} /> {config.secureSocksDSProxyEnabled && ( - <SecureSocksProxySettings options={options} onOptionsChange={() => onOptionsChange(options)} /> + <SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} /> )} </ConfigSection> </> diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/datasource.test.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/datasource.test.ts index f5379945829e4..0ef5b7e95e3f8 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/datasource.test.ts +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/datasource.test.ts @@ -21,8 +21,7 @@ import { getDataSourceSrv, setDataSourceSrv, } from '@grafana/runtime'; -import { QueryFormat, SQLQuery } from 'app/features/plugins/sql/types'; -import { makeVariable } from 'app/features/plugins/sql/utils/testHelpers'; +import { QueryFormat, SQLQuery, makeVariable } from '@grafana/sql'; import { PostgresDatasource } from './datasource'; import { PostgresOptions } from './types'; diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/datasource.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/datasource.ts index 173c17fd7d8e2..cbaa49a8c468b 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/datasource.ts +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/datasource.ts @@ -1,9 +1,7 @@ import { DataSourceInstanceSettings, ScopedVars } from '@grafana/data'; import { LanguageDefinition } from '@grafana/experimental'; import { TemplateSrv } from '@grafana/runtime'; -import { SqlDatasource } from 'app/features/plugins/sql/datasource/SqlDatasource'; -import { DB, SQLQuery, SQLSelectableValue } from 'app/features/plugins/sql/types'; -import { formatSQL } from 'app/features/plugins/sql/utils/formatSQL'; +import { SqlDatasource, DB, SQLQuery, SQLSelectableValue, formatSQL } from '@grafana/sql'; import { PostgresQueryModel } from './PostgresQueryModel'; import { getSchema, getTimescaleDBVersion, getVersion, showTables } from './postgresMetaQuery'; @@ -67,7 +65,12 @@ export class PostgresDatasource extends SqlDatasource { } async fetchFields(query: SQLQuery): Promise<SQLSelectableValue[]> { - const schema = await this.runSql<{ column: string; type: string }>(getSchema(query.table), { refId: 'columns' }); + const { table } = query; + if (table === undefined) { + // if no table-name, we are not able to query for fields + return []; + } + const schema = await this.runSql<{ column: string; type: string }>(getSchema(table), { refId: 'columns' }); const result: SQLSelectableValue[] = []; for (let i = 0; i < schema.length; i++) { const column = schema.fields.column.values[i]; diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/module.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/module.ts index 6aa5667cfd01b..e03aa2e4d0d3d 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/module.ts +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/module.ts @@ -1,5 +1,5 @@ import { DataSourcePlugin } from '@grafana/data'; -import { SQLQuery } from 'app/features/plugins/sql/types'; +import { SQLQuery } from '@grafana/sql'; import { CheatSheet } from './CheatSheet'; import { PostgresQueryEditor } from './PostgresQueryEditor'; diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/postgresMetaQuery.test.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/postgresMetaQuery.test.ts new file mode 100644 index 0000000000000..1ba58784c7190 --- /dev/null +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/postgresMetaQuery.test.ts @@ -0,0 +1,14 @@ +import { getSchema } from './postgresMetaQuery'; + +describe('postgredsMetaQuery.getSchema', () => { + it('should handle table-names with single quote', () => { + // testing multi-line with single-quote, double-quote, backtick + const tableName = `'a''bcd'efg'h' "a""b" ` + '`x``y`z' + `\n a'b''c`; + const escapedName = `''a''''bcd''efg''h'' "a""b" ` + '`x``y`z' + `\n a''b''''c`; + + const schemaQuery = getSchema(tableName); + + expect(schemaQuery.includes(escapedName)).toBeTruthy(); + expect(schemaQuery.includes(tableName)).toBeFalsy(); + }); +}); diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/postgresMetaQuery.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/postgresMetaQuery.ts index 80b0466f91cd6..664a8e9c4dcf1 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/postgresMetaQuery.ts +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/postgresMetaQuery.ts @@ -19,10 +19,15 @@ export function showTables() { and ${buildSchemaConstraint()}`; } -export function getSchema(table?: string) { +export function getSchema(table: string) { + // we will put table-name between single-quotes, so we need to escape single-quotes + // in the table-name + const tableNamePart = "'" + table.replace(/'/g, "''") + "'"; + return `select quote_ident(column_name) as "column", data_type as "type" from information_schema.columns - where quote_ident(table_name) = '${table}'`; + where quote_ident(table_name) = ${tableNamePart}; + `; } function buildSchemaConstraint() { diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/sqlCompletionProvider.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/sqlCompletionProvider.ts index 485e972582d6b..9c1c4f9220519 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/sqlCompletionProvider.ts +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/sqlCompletionProvider.ts @@ -5,7 +5,7 @@ import { TableDefinition, TableIdentifier, } from '@grafana/experimental'; -import { DB, SQLQuery } from 'app/features/plugins/sql/types'; +import { DB, SQLQuery } from '@grafana/sql'; interface CompletionProviderGetterArgs { getColumns: React.MutableRefObject<(t: SQLQuery) => Promise<ColumnDefinition[]>>; diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/sqlUtil.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/sqlUtil.ts index 3c652fb27198f..75e77f54882e1 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/sqlUtil.ts +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/sqlUtil.ts @@ -1,7 +1,6 @@ import { isEmpty } from 'lodash'; -import { RAQBFieldTypes, SQLQuery } from 'app/features/plugins/sql/types'; -import { createSelectClause, haveColumns } from 'app/features/plugins/sql/utils/sql.utils'; +import { createSelectClause, haveColumns, RAQBFieldTypes, SQLQuery } from '@grafana/sql'; export function getFieldConfig(type: string): { raqbFieldType: RAQBFieldTypes; icon: string } { switch (type) { diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/types.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/types.ts index bc744f6c9a62f..a10a4354ad096 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/types.ts +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/types.ts @@ -1,4 +1,4 @@ -import { SQLOptions } from 'app/features/plugins/sql/types'; +import { SQLOptions } from '@grafana/sql'; export enum PostgresTLSModes { disable = 'disable', diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/.eslintignore b/public/app/plugins/datasource/grafana-pyroscope-datasource/.eslintignore new file mode 100644 index 0000000000000..59ac0834c7a79 --- /dev/null +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/.eslintignore @@ -0,0 +1,2 @@ +# TS generate from cue by cuetsy +**/*.gen.ts diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/CHANGELOG.md b/public/app/plugins/datasource/grafana-pyroscope-datasource/CHANGELOG.md new file mode 100644 index 0000000000000..825c32f0d03d9 --- /dev/null +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/ProfileTypesCascader.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/ProfileTypesCascader.tsx index f04bb77b7dec9..fd21bb7158504 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/ProfileTypesCascader.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/ProfileTypesCascader.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; +import { TimeRange } from '@grafana/data'; import { Cascader, CascaderOption } from '@grafana/ui'; import { PyroscopeDataSource } from '../datasource'; @@ -70,16 +71,22 @@ function useCascaderOptions(profileTypes?: ProfileTypeMessage[]): CascaderOption * This is exported and not used directly in the ProfileTypesCascader component because in some case we need to know * the profileTypes before rendering the cascader. * @param datasource + * @param range Time range for the profile types query. */ -export function useProfileTypes(datasource: PyroscopeDataSource) { +export function useProfileTypes(datasource: PyroscopeDataSource, range?: TimeRange) { const [profileTypes, setProfileTypes] = useState<ProfileTypeMessage[]>(); + const impreciseRange = { + to: Math.ceil((range?.to.valueOf() || 0) / 60000) * 60000, + from: Math.floor((range?.from.valueOf() || 0) / 60000) * 60000, + }; + useEffect(() => { (async () => { - const profileTypes = await datasource.getProfileTypes(); + const profileTypes = await datasource.getProfileTypes(impreciseRange.from.valueOf(), impreciseRange.to.valueOf()); setProfileTypes(profileTypes); })(); - }, [datasource]); + }, [datasource, impreciseRange.from, impreciseRange.to]); return profileTypes; } diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.tsx index 96d0de4dead25..bcc03a1a9138d 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.tsx @@ -1,6 +1,6 @@ import deepEqual from 'fast-deep-equal'; import { debounce } from 'lodash'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { CoreApp, QueryEditorProps, TimeRange } from '@grafana/data'; import { LoadingPlaceholder } from '@grafana/ui'; @@ -27,8 +27,10 @@ export function QueryEditor(props: Props) { onRunQuery(); } - const profileTypes = useProfileTypes(datasource); - const { labels, getLabelValues, onLabelSelectorChange } = useLabels(range, datasource, query, onChange); + const onLabelSelectorChange = useLabelSelector(query, onChange); + + const profileTypes = useProfileTypes(datasource, range); + const { labels, getLabelValues } = useLabels(range, datasource, query); useNormalizeQuery(query, profileTypes, onChange, app); let cascader = <LoadingPlaceholder text={'Loading'} />; @@ -108,12 +110,7 @@ function defaultProfileType(profileTypes: ProfileTypeMessage[]): string { return profileTypes[0]?.id || ''; } -function useLabels( - range: TimeRange | undefined, - datasource: PyroscopeDataSource, - query: Query, - onChange: (value: Query) => void -) { +function useLabels(range: TimeRange | undefined, datasource: PyroscopeDataSource, query: Query) { // Round to nearest 5 seconds. If the range is something like last 1h then every render the range values change slightly // and what ever has range as dependency is rerun. So this effectively debounces the queries. const unpreciseRange = { @@ -155,15 +152,25 @@ function useLabels( // Create a function with range and query already baked in, so we don't have to send those everywhere const getLabelValues = useCallback( (label: string) => { - let labelSelector = createSelector(query.labelSelector, query.profileTypeId, label); + const labelSelector = createSelector(query.labelSelector, query.profileTypeId, label); return datasource.getLabelValues(labelSelector, label, unpreciseRange.from, unpreciseRange.to); }, [datasource, query.labelSelector, query.profileTypeId, unpreciseRange.to, unpreciseRange.from] ); + return { labels, getLabelValues }; +} + +function useLabelSelector(query: Query, onChange: (value: Query) => void) { + // Need to reference the query as otherwise when the label selector is changed, only the initial value + // of the query is passed into the LabelsEditor (onChange) which renders the CodeEditor for monaco. + // The above needs to have a ref to the query so it can get the latest value. + const queryRef = useRef(query); + queryRef.current = query; + const onChangeDebounced = debounce((value: string) => { if (onChange) { - onChange({ ...query, labelSelector: value }); + onChange({ ...queryRef.current, labelSelector: value }); } }, 200); @@ -174,5 +181,5 @@ function useLabels( [onChangeDebounced] ); - return { labels, getLabelValues, onLabelSelectorChange }; + return onLabelSelectorChange; } diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptionGroup.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptionGroup.tsx new file mode 100644 index 0000000000000..dfd42c81bf543 --- /dev/null +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptionGroup.tsx @@ -0,0 +1,82 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import { useToggle } from 'react-use'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Collapse, useStyles2, Stack } from '@grafana/ui'; + +export interface Props { + title: string; + collapsedInfo: string[]; + children: React.ReactNode; +} + +export function QueryOptionGroup({ title, children, collapsedInfo }: Props) { + const [isOpen, toggleOpen] = useToggle(false); + const styles = useStyles2(getStyles); + + return ( + <div className={styles.wrapper}> + <Collapse + className={styles.collapse} + collapsible + isOpen={isOpen} + onToggle={toggleOpen} + label={ + <Stack gap={0}> + <h6 className={styles.title}>{title}</h6> + {!isOpen && ( + <div className={styles.description}> + {collapsedInfo.map((x, i) => ( + <span key={i}>{x}</span> + ))} + </div> + )} + </Stack> + } + > + <div className={styles.body}>{children}</div> + </Collapse> + </div> + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + collapse: css({ + backgroundColor: 'unset', + border: 'unset', + marginBottom: 0, + + ['> button']: { + padding: theme.spacing(0, 1), + }, + }), + wrapper: css({ + width: '100%', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'baseline', + }), + title: css({ + flexGrow: 1, + overflow: 'hidden', + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.fontWeightMedium, + margin: 0, + }), + description: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.bodySmall.fontWeight, + paddingLeft: theme.spacing(2), + gap: theme.spacing(2), + display: 'flex', + }), + body: css({ + display: 'flex', + gap: theme.spacing(2), + flexWrap: 'wrap', + }), + }; +}; diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx index 94ade2699b3b1..7db169674b87e 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx @@ -2,13 +2,12 @@ import { css } from '@emotion/css'; import React from 'react'; import { CoreApp, GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { useStyles2, RadioButtonGroup, MultiSelect, Input } from '@grafana/ui'; -import { QueryOptionGroup } from '../../prometheus/querybuilder/shared/QueryOptionGroup'; import { Query } from '../types'; import { EditorField } from './EditorField'; +import { QueryOptionGroup } from './QueryOptionGroup'; import { Stack } from './Stack'; export interface Props { @@ -48,6 +47,9 @@ export function QueryOptions({ query, onQueryChange, app, labels }: Props) { if (query.groupBy?.length) { collapsedInfo.push(`Group by: ${query.groupBy.join(', ')}`); } + if (query.spanSelector?.length) { + collapsedInfo.push(`Span ID: ${query.spanSelector.join(', ')}`); + } if (query.maxNodes) { collapsedInfo.push(`Max nodes: ${query.maxNodes}`); } @@ -84,21 +86,19 @@ export function QueryOptions({ query, onQueryChange, app, labels }: Props) { }} /> </EditorField> - {config.featureToggles.traceToProfiles && ( - <EditorField label={'Span ID'} tooltip={<>Sets the span ID from which to search for profiles.</>}> - <Input - value={query.spanSelector || ['']} - type="string" - placeholder="64f170a95f537095" - onChange={(event: React.SyntheticEvent<HTMLInputElement>) => { - onQueryChange({ - ...query, - spanSelector: event.currentTarget.value !== '' ? [event.currentTarget.value] : [], - }); - }} - /> - </EditorField> - )} + <EditorField label={'Span ID'} tooltip={<>Sets the span ID from which to search for profiles.</>}> + <Input + value={query.spanSelector || ['']} + type="string" + placeholder="64f170a95f537095" + onChange={(event: React.SyntheticEvent<HTMLInputElement>) => { + onQueryChange({ + ...query, + spanSelector: event.currentTarget.value !== '' ? [event.currentTarget.value] : [], + }); + }} + /> + </EditorField> <EditorField label={'Max Nodes'} tooltip={<>Sets the maximum number of nodes to return in the flamegraph.</>}> <Input value={query.maxNodes || ''} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableQueryEditor.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableQueryEditor.tsx index 8b14820d840c9..27f4b2d085cec 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableQueryEditor.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableQueryEditor.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; -import { QueryEditorProps, SelectableValue } from '@grafana/data'; +import { QueryEditorProps, SelectableValue, TimeRange } from '@grafana/data'; import { InlineField, InlineFieldRow, LoadingPlaceholder, Select } from '@grafana/ui'; import { ProfileTypesCascader, useProfileTypes } from './QueryEditor/ProfileTypesCascader'; @@ -15,7 +15,7 @@ export function VariableQueryEditor(props: QueryEditorProps<PyroscopeDataSource, label="Query type" labelWidth={20} tooltip={ - <div>The Prometheus data source plugin provides the following query types for template variables</div> + <div>The Pyroscope data source plugin provides the following query types for template variables</div> } > <Select @@ -30,23 +30,16 @@ export function VariableQueryEditor(props: QueryEditorProps<PyroscopeDataSource, onChange={(value) => { if (value.value! === 'profileType') { props.onChange({ - ...props.query, type: value.value!, + refId: props.query.refId, }); } - if (value.value! === 'label') { + if (value.value! === 'label' || value.value! === 'labelValue') { props.onChange({ - ...props.query, type: value.value!, - profileTypeId: '', - }); - } - if (value.value! === 'labelValue') { - props.onChange({ - ...props.query, - type: value.value!, - profileTypeId: '', - labelName: '', + refId: props.query.refId, + // Make sure we keep already selected values if they make sense for the variable type + profileTypeId: props.query.type !== 'profileType' ? props.query.profileTypeId : '', }); } }} @@ -65,6 +58,7 @@ export function VariableQueryEditor(props: QueryEditorProps<PyroscopeDataSource, props.onChange({ ...props.query, profileTypeId: val }); } }} + range={props.range} /> )} @@ -97,7 +91,13 @@ function LabelRow(props: { const [labels, setLabels] = useState<string[]>(); useEffect(() => { (async () => { - setLabels(await props.datasource.getLabelNames((props.profileTypeId || '') + '{}', props.from, props.to)); + setLabels( + await props.datasource.getLabelNames( + props.profileTypeId ? getProfileTypeLabel(props.profileTypeId) : '{}', + props.from, + props.to + ) + ); })(); }, [props.datasource, props.profileTypeId, props.to, props.from]); @@ -131,8 +131,9 @@ function ProfileTypeRow(props: { datasource: PyroscopeDataSource; onChange: (val: string) => void; initialValue?: string; + range?: TimeRange; }) { - const profileTypes = useProfileTypes(props.datasource); + const profileTypes = useProfileTypes(props.datasource, props.range); return ( <InlineFieldRow> <InlineField @@ -154,3 +155,7 @@ function ProfileTypeRow(props: { </InlineFieldRow> ); } + +export function getProfileTypeLabel(type: string) { + return `{__profile_type__="${type}"}`; +} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableSupport.test.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableSupport.test.ts index 5dcc47ab9e90d..957ca03589cde 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableSupport.test.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableSupport.test.ts @@ -24,7 +24,11 @@ describe('VariableSupport', () => { vs.query(getDefaultRequest({ type: 'label', profileTypeId: 'profile:type:3', refId: 'A' })) ); expect(resp.data).toEqual([{ text: 'foo' }, { text: 'bar' }, { text: 'baz' }]); - expect(mock.getLabelNames).toBeCalledWith('profile:type:3{}', expect.any(Number), expect.any(Number)); + expect(mock.getLabelNames).toBeCalledWith( + '{__profile_type__="profile:type:3"}', + expect.any(Number), + expect.any(Number) + ); }); it('should query label values', async function () { @@ -34,7 +38,12 @@ describe('VariableSupport', () => { vs.query(getDefaultRequest({ type: 'labelValue', labelName: 'foo', profileTypeId: 'profile:type:3', refId: 'A' })) ); expect(resp.data).toEqual([{ text: 'val1' }, { text: 'val2' }, { text: 'val3' }]); - expect(mock.getLabelValues).toBeCalledWith('profile:type:3{}', 'foo', expect.any(Number), expect.any(Number)); + expect(mock.getLabelValues).toBeCalledWith( + '{__profile_type__="profile:type:3"}', + 'foo', + expect.any(Number), + expect.any(Number) + ); }); }); diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableSupport.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableSupport.ts index 5194211518a9d..80059880c585c 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableSupport.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableSupport.ts @@ -2,23 +2,18 @@ import { from, map, Observable, of } from 'rxjs'; import { CustomVariableSupport, DataQueryRequest, DataQueryResponse, MetricFindValue } from '@grafana/data'; -import { getTimeSrv, TimeSrv } from '../../../features/dashboard/services/TimeSrv'; - -import { VariableQueryEditor } from './VariableQueryEditor'; +import { getProfileTypeLabel, VariableQueryEditor } from './VariableQueryEditor'; import { PyroscopeDataSource } from './datasource'; import { ProfileTypeMessage, VariableQuery } from './types'; export interface DataAPI { - getProfileTypes(): Promise<ProfileTypeMessage[]>; + getProfileTypes(start: number, end: number): Promise<ProfileTypeMessage[]>; getLabelNames(query: string, start: number, end: number): Promise<string[]>; getLabelValues(query: string, label: string, start: number, end: number): Promise<string[]>; } export class VariableSupport extends CustomVariableSupport<PyroscopeDataSource> { - constructor( - private readonly dataAPI: DataAPI, - private readonly timeSrv: TimeSrv = getTimeSrv() - ) { + constructor(private readonly dataAPI: DataAPI) { super(); } @@ -26,7 +21,7 @@ export class VariableSupport extends CustomVariableSupport<PyroscopeDataSource> query(request: DataQueryRequest<VariableQuery>): Observable<DataQueryResponse> { if (request.targets[0].type === 'profileType') { - return from(this.dataAPI.getProfileTypes()).pipe( + return from(this.dataAPI.getProfileTypes(request.range.from.valueOf(), request.range.to.valueOf())).pipe( map((values) => { return { data: values.map<MetricFindValue>((v) => ({ text: v.label, value: v.id })) }; }) @@ -39,9 +34,9 @@ export class VariableSupport extends CustomVariableSupport<PyroscopeDataSource> } return from( this.dataAPI.getLabelNames( - request.targets[0].profileTypeId + '{}', - this.timeSrv.timeRange().from.valueOf(), - this.timeSrv.timeRange().to.valueOf() + getProfileTypeLabel(request.targets[0].profileTypeId), + request.range.from.valueOf(), + request.range.to.valueOf() ) ).pipe( map((values) => { @@ -56,10 +51,10 @@ export class VariableSupport extends CustomVariableSupport<PyroscopeDataSource> } return from( this.dataAPI.getLabelValues( - request.targets[0].profileTypeId + '{}', + getProfileTypeLabel(request.targets[0].profileTypeId), request.targets[0].labelName, - this.timeSrv.timeRange().from.valueOf(), - this.timeSrv.timeRange().to.valueOf() + request.range.from.valueOf(), + request.range.to.valueOf() ) ).pipe( map((values) => { diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.gen.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.gen.ts index b6e39ae2c7ddf..4a3595c3a352f 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.gen.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -14,7 +14,7 @@ export type PyroscopeQueryType = ('metrics' | 'profile' | 'both'); export const defaultPyroscopeQueryType: PyroscopeQueryType = 'both'; -export interface GrafanaPyroscope extends common.DataQuery { +export interface GrafanaPyroscopeDataQuery extends common.DataQuery { /** * Allows to group the results. */ @@ -37,7 +37,7 @@ export interface GrafanaPyroscope extends common.DataQuery { spanSelector?: Array<string>; } -export const defaultGrafanaPyroscope: Partial<GrafanaPyroscope> = { +export const defaultGrafanaPyroscopeDataQuery: Partial<GrafanaPyroscopeDataQuery> = { groupBy: [], labelSelector: '{}', spanSelector: [], diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts index 34c24e8d3a296..52d50b6995a84 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts @@ -11,11 +11,10 @@ import { } from '@grafana/data'; import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; -import { extractLabelMatchers, toPromLikeExpr } from '../prometheus/language_utils'; - import { VariableSupport } from './VariableSupport'; -import { defaultGrafanaPyroscope, defaultPyroscopeQueryType } from './dataquery.gen'; +import { defaultGrafanaPyroscopeDataQuery, defaultPyroscopeQueryType } from './dataquery.gen'; import { PyroscopeDataSourceOptions, Query, ProfileTypeMessage } from './types'; +import { extractLabelMatchers, toPromLikeExpr } from './utils'; export class PyroscopeDataSource extends DataSourceWithBackend<Query, PyroscopeDataSourceOptions> { constructor( @@ -48,7 +47,14 @@ export class PyroscopeDataSource extends DataSourceWithBackend<Query, PyroscopeD }); } - async getProfileTypes(): Promise<ProfileTypeMessage[]> { + async getProfileTypes(start: number, end: number): Promise<ProfileTypeMessage[]> { + return await this.getResource('profileTypes', { + start, + end, + }); + } + + async getAllProfileTypes(): Promise<ProfileTypeMessage[]> { return await this.getResource('profileTypes'); } @@ -109,7 +115,7 @@ export class PyroscopeDataSource extends DataSourceWithBackend<Query, PyroscopeD } export const defaultQuery: Partial<Query> = { - ...defaultGrafanaPyroscope, + ...defaultGrafanaPyroscopeDataQuery, queryType: defaultPyroscopeQueryType, }; diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json b/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json new file mode 100644 index 0000000000000..05d540c381e31 --- /dev/null +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json @@ -0,0 +1,49 @@ +{ + "name": "@grafana-plugins/grafana-pyroscope-datasource", + "description": "Continuous profiling for analysis of CPU and memory usage, down to the line number and throughout time. Saving infrastructure cost, improving performance, and increasing reliability.", + "private": true, + "version": "11.0.0-pre", + "dependencies": { + "@emotion/css": "11.11.2", + "@grafana/data": "11.0.0-pre", + "@grafana/runtime": "11.0.0-pre", + "@grafana/schema": "11.0.0-pre", + "@grafana/ui": "11.0.0-pre", + "fast-deep-equal": "^3.1.3", + "lodash": "4.17.21", + "monaco-editor": "0.34.0", + "prismjs": "1.29.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-use": "17.5.0", + "rxjs": "7.8.1", + "tslib": "2.6.2" + }, + "devDependencies": { + "@grafana/plugin-configs": "11.0.0-pre", + "@testing-library/jest-dom": "6.4.2", + "@testing-library/react": "14.2.1", + "@testing-library/user-event": "14.5.2", + "@types/jest": "29.5.12", + "@types/lodash": "4.17.0", + "@types/prismjs": "1.26.3", + "@types/react": "18.2.66", + "@types/react-dom": "18.2.22", + "@types/testing-library__jest-dom": "5.14.9", + "css-loader": "6.10.0", + "jest": "29.7.0", + "style-loader": "3.3.4", + "ts-node": "10.9.2", + "typescript": "5.3.3", + "webpack": "5.90.3" + }, + "peerDependencies": { + "@grafana/runtime": "*" + }, + "scripts": { + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development" + }, + "packageManager": "yarn@4.1.0" +} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/plugin.json b/public/app/plugins/datasource/grafana-pyroscope-datasource/plugin.json index ba90841a3e80f..9aef1503eda73 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/plugin.json +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/plugin.json @@ -2,6 +2,7 @@ "type": "datasource", "name": "Grafana Pyroscope", "id": "grafana-pyroscope-datasource", + "executable": "gpx_grafana-pyroscope-datasource", "aliasIDs": ["phlare"], "category": "profiling", @@ -29,6 +30,10 @@ "name": "GitHub Project", "url": "https://github.com/grafana/pyroscope" } - ] + ], + "version": "%VERSION%" + }, + "dependencies": { + "grafanaDependency": ">=10.3.0-0" } } diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/tsconfig.json b/public/app/plugins/datasource/grafana-pyroscope-datasource/tsconfig.json new file mode 100644 index 0000000000000..7daf2ee8abab6 --- /dev/null +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@grafana/plugin-configs/tsconfig.json", + "include": ["."] +} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/types.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/types.ts index 04aa2f9a9bd78..1570edd34b842 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/types.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/types.ts @@ -1,8 +1,8 @@ import { DataSourceJsonData } from '@grafana/data'; -import { GrafanaPyroscope, PyroscopeQueryType } from './dataquery.gen'; +import { GrafanaPyroscopeDataQuery, PyroscopeQueryType } from './dataquery.gen'; -export interface Query extends GrafanaPyroscope { +export interface Query extends GrafanaPyroscopeDataQuery { queryType: PyroscopeQueryType; } diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/utils.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/utils.ts new file mode 100644 index 0000000000000..f811fbe4ebcfe --- /dev/null +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/utils.ts @@ -0,0 +1,84 @@ +import { invert } from 'lodash'; +import { Token } from 'prismjs'; + +import { AbstractLabelMatcher, AbstractLabelOperator, AbstractQuery } from '@grafana/data'; + +export function extractLabelMatchers(tokens: Array<string | Token>): AbstractLabelMatcher[] { + const labelMatchers: AbstractLabelMatcher[] = []; + + for (const token of tokens) { + if (!(token instanceof Token)) { + continue; + } + + if (token.type === 'context-labels') { + let labelKey = ''; + let labelValue = ''; + let labelOperator = ''; + + const contentTokens = Array.isArray(token.content) ? token.content : [token.content]; + + for (let currentToken of contentTokens) { + if (typeof currentToken === 'string') { + let currentStr: string; + currentStr = currentToken; + if (currentStr === '=' || currentStr === '!=' || currentStr === '=~' || currentStr === '!~') { + labelOperator = currentStr; + } + } else if (currentToken instanceof Token) { + switch (currentToken.type) { + case 'label-key': + labelKey = getMaybeTokenStringContent(currentToken); + break; + case 'label-value': + labelValue = getMaybeTokenStringContent(currentToken); + labelValue = labelValue.substring(1, labelValue.length - 1); + const labelComparator = FromPromLikeMap[labelOperator]; + if (labelComparator) { + labelMatchers.push({ name: labelKey, operator: labelComparator, value: labelValue }); + } + break; + } + } + } + } + } + + return labelMatchers; +} + +export function toPromLikeExpr(labelBasedQuery: AbstractQuery): string { + const expr = labelBasedQuery.labelMatchers + .map((selector: AbstractLabelMatcher) => { + const operator = ToPromLikeMap[selector.operator]; + if (operator) { + return `${selector.name}${operator}"${selector.value}"`; + } else { + return ''; + } + }) + .filter((e: string) => e !== '') + .join(', '); + + return expr ? `{${expr}}` : ''; +} + +function getMaybeTokenStringContent(token: Token): string { + if (typeof token.content === 'string') { + return token.content; + } + + return ''; +} + +const FromPromLikeMap: Record<string, AbstractLabelOperator> = { + '=': AbstractLabelOperator.Equal, + '!=': AbstractLabelOperator.NotEqual, + '=~': AbstractLabelOperator.EqualRegEx, + '!~': AbstractLabelOperator.NotEqualRegEx, +}; + +const ToPromLikeMap: Record<AbstractLabelOperator, string> = invert(FromPromLikeMap) as Record< + AbstractLabelOperator, + string +>; diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/webpack.config.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/webpack.config.ts new file mode 100644 index 0000000000000..8e03497ddb283 --- /dev/null +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/webpack.config.ts @@ -0,0 +1,15 @@ +import config from '@grafana/plugin-configs/webpack.config'; + +const configWithFallback = async (env: Record<string, unknown>) => { + const response = await config(env); + if (response !== undefined && response.resolve !== undefined) { + response.resolve.fallback = { + ...response.resolve.fallback, + stream: false, + string_decoder: false, + }; + } + return response; +}; + +export default configWithFallback; diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/MetaDataInspector.tsx b/public/app/plugins/datasource/grafana-testdata-datasource/MetaDataInspector.tsx index 79b2b4fb28abf..de58ecd43810f 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/MetaDataInspector.tsx +++ b/public/app/plugins/datasource/grafana-testdata-datasource/MetaDataInspector.tsx @@ -3,10 +3,10 @@ import React from 'react'; import { MetadataInspectorProps } from '@grafana/data'; import { Stack } from '@grafana/ui'; -import { TestData } from './dataquery.gen'; +import { TestDataDataQuery } from './dataquery.gen'; import { TestDataDataSource } from './datasource'; -export type Props = MetadataInspectorProps<TestDataDataSource, TestData>; +export type Props = MetadataInspectorProps<TestDataDataSource, TestDataDataQuery>; export function MetaDataInspector({ data }: Props) { return ( diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/QueryEditor.tsx b/public/app/plugins/datasource/grafana-testdata-datasource/QueryEditor.tsx index c6cf280a5be39..55b93f217a9b0 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/QueryEditor.tsx +++ b/public/app/plugins/datasource/grafana-testdata-datasource/QueryEditor.tsx @@ -17,7 +17,7 @@ import { RawFrameEditor } from './components/RawFrameEditor'; import { SimulationQueryEditor } from './components/SimulationQueryEditor'; import { USAQueryEditor, usaQueryModes } from './components/USAQueryEditor'; import { defaultCSVWaveQuery, defaultPulseQuery, defaultQuery } from './constants'; -import { CSVWave, NodesQuery, TestData, TestDataQueryType, USAQuery } from './dataquery.gen'; +import { CSVWave, NodesQuery, TestDataDataQuery, TestDataQueryType, USAQuery } from './dataquery.gen'; import { TestDataDataSource } from './datasource'; import { defaultStreamQuery } from './runStreams'; @@ -31,11 +31,11 @@ const selectors = editorSelectors.components.DataSource.TestData.QueryTab; export interface EditorProps { onChange: (value: any) => void; - query: TestData; + query: TestDataDataQuery; ds: TestDataDataSource; } -export type Props = QueryEditorProps<TestDataDataSource, TestData>; +export type Props = QueryEditorProps<TestDataDataSource, TestDataDataQuery>; export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props) => { query = { ...defaultQuery, ...query }; @@ -63,7 +63,7 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props) })); }, []); - const onUpdate = (query: TestData) => { + const onUpdate = (query: TestDataDataQuery) => { onChange(query); onRunQuery(); }; @@ -83,7 +83,7 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props) } // Clear model from existing props that belong to other scenarios - const update: TestData = { + const update: TestDataDataQuery = { scenarioId: item.value! as TestDataQueryType, refId: query.refId, alias: query.alias, diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/components/NodeGraphEditor.tsx b/public/app/plugins/datasource/grafana-testdata-datasource/components/NodeGraphEditor.tsx index 1c804b569a113..207b39fd522ac 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/components/NodeGraphEditor.tsx +++ b/public/app/plugins/datasource/grafana-testdata-datasource/components/NodeGraphEditor.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { Input, InlineFieldRow, InlineField, Select } from '@grafana/ui'; -import { NodesQuery, TestData } from '../dataquery.gen'; +import { NodesQuery, TestDataDataQuery } from '../dataquery.gen'; export interface Props { onChange: (value: NodesQuery) => void; - query: TestData; + query: TestDataDataQuery; } export function NodeGraphEditor({ query, onChange }: Props) { const type = query.nodes?.type || 'random'; @@ -24,21 +24,40 @@ export function NodeGraphEditor({ query, onChange }: Props) { /> </InlineField> {(type === 'random' || type === 'random edges') && ( - <InlineField label="Count" labelWidth={14}> - <Input - type="number" - name="count" - value={query.nodes?.count} - width={32} - onChange={(e) => - onChange({ ...query.nodes, count: e.currentTarget.value ? parseInt(e.currentTarget.value, 10) : 0 }) - } - placeholder="10" - /> - </InlineField> + <> + <InlineField label="Count" labelWidth={14}> + <Input + type="number" + name="count" + value={query.nodes?.count} + width={32} + onChange={(e) => + onChange({ ...query.nodes, count: e.currentTarget.value ? parseInt(e.currentTarget.value, 10) : 0 }) + } + placeholder="10" + /> + </InlineField> + <InlineField label="Seed" labelWidth={14}> + <Input + type="number" + name="seed" + value={query.nodes?.seed} + width={16} + onChange={(e) => + onChange({ ...query.nodes, seed: e.currentTarget.value ? parseInt(e.currentTarget.value, 10) : 0 }) + } + /> + </InlineField> + </> )} </InlineFieldRow> ); } -const options: Array<NodesQuery['type']> = ['random', 'response', 'random edges']; +const options: Array<NodesQuery['type']> = [ + 'random', + 'response_small', + 'response_medium', + 'random edges', + 'feature_showcase', +]; diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/components/RandomWalkEditor.tsx b/public/app/plugins/datasource/grafana-testdata-datasource/components/RandomWalkEditor.tsx index 2296c6c0fd04f..1c5085f7b094f 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/components/RandomWalkEditor.tsx +++ b/public/app/plugins/datasource/grafana-testdata-datasource/components/RandomWalkEditor.tsx @@ -4,7 +4,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { InlineField, InlineFieldRow, Input } from '@grafana/ui'; import { EditorProps } from '../QueryEditor'; -import { TestData } from '../dataquery.gen'; +import { TestDataDataQuery } from '../dataquery.gen'; const randomWalkFields: Array<{ label: string; @@ -49,7 +49,7 @@ export const RandomWalkEditor = ({ onChange, query }: EditorProps) => { id={`randomWalk-${id}-${query.refId}`} min={min} step={step} - value={(query as any)[id as keyof TestData] || placeholder} + value={(query as any)[id as keyof TestDataDataQuery] || placeholder} placeholder={placeholder} onChange={onChange} /> diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationQueryEditor.tsx b/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationQueryEditor.tsx index 225f3fba094d5..58af6c6b2a40a 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationQueryEditor.tsx +++ b/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationQueryEditor.tsx @@ -90,7 +90,7 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => { if (simKey.uid) { path += '/' + simKey.uid; } - ds.postResource('sim/' + path, config).then((res) => { + ds.postResource<SimInfo>('sim/' + path, config).then((res) => { setCfgValue(res.config); }); }; diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/components/StreamingClientEditor.tsx b/public/app/plugins/datasource/grafana-testdata-datasource/components/StreamingClientEditor.tsx index 8297727f4f668..67474d9515b22 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/components/StreamingClientEditor.tsx +++ b/public/app/plugins/datasource/grafana-testdata-datasource/components/StreamingClientEditor.tsx @@ -16,6 +16,7 @@ const types = [ { value: 'signal', label: 'Signal' }, { value: 'logs', label: 'Logs' }, { value: 'fetch', label: 'Fetch' }, + { value: 'traces', label: 'Traces' }, ]; export const StreamingClientEditor = ({ onChange, query }: EditorProps) => { @@ -29,29 +30,42 @@ export const StreamingClientEditor = ({ onChange, query }: EditorProps) => { onChange({ target: { name, value: Number(value) } }); }; + const streamType = query?.stream?.type || 'signal'; + const fields = + streamType === 'signal' + ? streamingClientFields + : ['logs', 'traces'].includes(streamType) + ? [streamingClientFields[0]] // speed + : []; + return ( <InlineFieldRow> <InlineField label="Type" labelWidth={14}> - <Select width={32} onChange={onSelectChange} defaultValue={types[0]} options={types} /> + <Select + width={32} + onChange={onSelectChange} + defaultValue={types[0]} + options={types} + value={query?.stream?.type} + /> </InlineField> - {query?.stream?.type === 'signal' && - streamingClientFields.map(({ label, id, min, step, placeholder }) => { - return ( - <InlineField label={label} labelWidth={14} key={id}> - <Input - width={32} - type="number" - id={`stream.${id}-${query.refId}`} - name={id} - min={min} - step={step} - value={query.stream?.[id]} - placeholder={placeholder} - onChange={onInputChange} - /> - </InlineField> - ); - })} + {fields.map(({ label, id, min, step, placeholder }) => { + return ( + <InlineField label={label} labelWidth={14} key={id}> + <Input + width={32} + type="number" + id={`stream.${id}-${query.refId}`} + name={id} + min={min} + step={step} + value={query.stream?.[id]} + placeholder={placeholder} + onChange={onInputChange} + /> + </InlineField> + ); + })} {query?.stream?.type === 'fetch' && ( <InlineField label="URL" labelWidth={14} grow> diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/constants.ts b/public/app/plugins/datasource/grafana-testdata-datasource/constants.ts index 3acb06f3c5318..a1ec6211b7aaa 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/constants.ts +++ b/public/app/plugins/datasource/grafana-testdata-datasource/constants.ts @@ -1,4 +1,4 @@ -import { CSVWave, PulseWaveQuery, TestData, TestDataQueryType } from './dataquery.gen'; +import { CSVWave, PulseWaveQuery, TestDataDataQuery, TestDataQueryType } from './dataquery.gen'; export const defaultPulseQuery: PulseWaveQuery = { timeStep: 60, @@ -15,7 +15,7 @@ export const defaultCSVWaveQuery: CSVWave[] = [ }, ]; -export const defaultQuery: TestData = { +export const defaultQuery: TestDataDataQuery = { scenarioId: TestDataQueryType.RandomWalk, refId: '', }; diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.cue b/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.cue index bc1cfe4296ab9..b8e0d75d1cc00 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.cue +++ b/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.cue @@ -55,7 +55,7 @@ composableKinds: DataQuery: { #TestDataQueryType: "random_walk" | "slow_query" | "random_walk_with_error" | "random_walk_table" | "exponential_heatmap_bucket_data" | "linear_heatmap_bucket_data" | "no_data_points" | "datapoints_outside_range" | "csv_metric_values" | "predictable_pulse" | "predictable_csv_wave" | "streaming_client" | "simulation" | "usa" | "live" | "grafana_api" | "arrow" | "annotations" | "table_static" | "server_error_500" | "logs" | "node_graph" | "flame_graph" | "raw_frame" | "csv_file" | "csv_content" | "trace" | "manual_entry" | "variables-query" @cuetsy(kind="enum", memberNames="RandomWalk|SlowQuery|RandomWalkWithError|RandomWalkTable|ExponentialHeatmapBucketData|LinearHeatmapBucketData|NoDataPoints|DataPointsOutsideRange|CSVMetricValues|PredictablePulse|PredictableCSVWave|StreamingClient|Simulation|USA|Live|GrafanaAPI|Arrow|Annotations|TableStatic|ServerError500|Logs|NodeGraph|FlameGraph|RawFrame|CSVFile|CSVContent|Trace|ManualEntry|VariablesQuery") #StreamingQuery: { - type: "signal" | "logs" | "fetch" + type: "signal" | "logs" | "fetch" | "traces" speed: int32 spread: int32 noise: int32 @@ -83,8 +83,9 @@ composableKinds: DataQuery: { } @cuetsy(kind="interface") #NodesQuery: { - type?: "random" | "response" | "random edges" + type?: "random" | "response_small" | "response_medium" | "random edges" | "feature_showcase" count?: int64 + seed?: int64 } @cuetsy(kind="interface") #USAQuery: { diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.gen.ts b/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.gen.ts index b36214b606878..0d45320c196d0 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.gen.ts +++ b/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -47,7 +47,7 @@ export interface StreamingQuery { noise: number; speed: number; spread: number; - type: ('signal' | 'logs' | 'fetch'); + type: ('signal' | 'logs' | 'fetch' | 'traces'); url?: string; } @@ -72,7 +72,8 @@ export interface SimulationQuery { export interface NodesQuery { count?: number; - type?: ('random' | 'response' | 'random edges'); + seed?: number; + type?: ('random' | 'response_small' | 'response_medium' | 'random edges' | 'feature_showcase'); } export interface USAQuery { @@ -105,7 +106,7 @@ export interface Scenario { stringInput: string; } -export interface TestData extends common.DataQuery { +export interface TestDataDataQuery extends common.DataQuery { alias?: string; channel?: string; csvContent?: string; @@ -133,7 +134,7 @@ export interface TestData extends common.DataQuery { usa?: USAQuery; } -export const defaultTestData: Partial<TestData> = { +export const defaultTestDataDataQuery: Partial<TestDataDataQuery> = { csvWave: [], points: [], scenarioId: TestDataQueryType.RandomWalk, diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/datasource.ts b/public/app/plugins/datasource/grafana-testdata-datasource/datasource.ts index 795c7388266e3..d575ad29ba9e8 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/datasource.ts +++ b/public/app/plugins/datasource/grafana-testdata-datasource/datasource.ts @@ -20,14 +20,14 @@ import { } from '@grafana/data'; import { DataSourceWithBackend, getBackendSrv, getGrafanaLiveSrv, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; -import { Scenario, TestData, TestDataQueryType } from './dataquery.gen'; +import { Scenario, TestDataDataQuery, TestDataQueryType } from './dataquery.gen'; import { queryMetricTree } from './metricTree'; -import { generateRandomEdges, generateRandomNodes, savedNodesResponse } from './nodeGraphUtils'; +import { generateRandomEdges, generateRandomNodes, generateShowcaseData, savedNodesResponse } from './nodeGraphUtils'; import { runStream } from './runStreams'; import { flameGraphData, flameGraphDataDiff } from './testData/flameGraphResponse'; import { TestDataVariableSupport } from './variables'; -export class TestDataDataSource extends DataSourceWithBackend<TestData> { +export class TestDataDataSource extends DataSourceWithBackend<TestDataDataQuery> { scenariosCache?: Promise<Scenario[]>; constructor( @@ -40,7 +40,7 @@ export class TestDataDataSource extends DataSourceWithBackend<TestData> { getDefaultQuery: () => ({ scenarioId: TestDataQueryType.Annotations, lines: 10 }), // Make sure annotations have scenarioId set - prepareAnnotation: (old: AnnotationQuery<TestData>) => { + prepareAnnotation: (old: AnnotationQuery<TestDataDataQuery>) => { if (old.target?.scenarioId?.length) { return old; } @@ -56,15 +56,15 @@ export class TestDataDataSource extends DataSourceWithBackend<TestData> { }; } - getDefaultQuery(): Partial<TestData> { + getDefaultQuery(): Partial<TestDataDataQuery> { return { scenarioId: TestDataQueryType.RandomWalk, seriesCount: 1, }; } - query(options: DataQueryRequest<TestData>): Observable<DataQueryResponse> { - const backendQueries: TestData[] = []; + query(options: DataQueryRequest<TestDataDataQuery>): Observable<DataQueryResponse> { + const backendQueries: TestDataDataQuery[] = []; const streams: Array<Observable<DataQueryResponse>> = []; // Start streams and prepare queries @@ -141,7 +141,7 @@ export class TestDataDataSource extends DataSourceWithBackend<TestData> { return merge(...streams); } - resolveTemplateVariables(query: TestData, scopedVars: ScopedVars) { + resolveTemplateVariables(query: TestDataDataQuery, scopedVars: ScopedVars) { if (query.labels) { query.labels = this.templateSrv.replace(query.labels, scopedVars); } @@ -162,12 +162,15 @@ export class TestDataDataSource extends DataSourceWithBackend<TestData> { } } - applyTemplateVariables(query: TestData, scopedVars: ScopedVars): TestData { + applyTemplateVariables(query: TestDataDataQuery, scopedVars: ScopedVars): TestDataDataQuery { this.resolveTemplateVariables(query, scopedVars); return query; } - annotationDataTopicTest(target: TestData, req: DataQueryRequest<TestData>): Observable<DataQueryResponse> { + annotationDataTopicTest( + target: TestDataDataQuery, + req: DataQueryRequest<TestDataDataQuery> + ): Observable<DataQueryResponse> { const events = this.buildFakeAnnotationEvents(req.range, target.lines ?? 10); const dataFrame = new ArrayDataFrame(events); dataFrame.meta = { dataTopic: DataTopic.Annotations }; @@ -192,7 +195,7 @@ export class TestDataDataSource extends DataSourceWithBackend<TestData> { return events; } - getQueryDisplayText(query: TestData) { + getQueryDisplayText(query: TestDataDataQuery) { const scenario = query.scenarioId ?? 'Default scenario'; if (query.alias) { @@ -217,7 +220,10 @@ export class TestDataDataSource extends DataSourceWithBackend<TestData> { return this.scenariosCache; } - variablesQuery(target: TestData, options: DataQueryRequest<TestData>): Observable<DataQueryResponse> { + variablesQuery( + target: TestDataDataQuery, + options: DataQueryRequest<TestDataDataQuery> + ): Observable<DataQueryResponse> { const query = target.stringInput ?? ''; const interpolatedQuery = this.templateSrv.replace(query, getSearchFilterScopedVar({ query, wildcardChar: '*' })); const children = queryMetricTree(interpolatedQuery); @@ -227,18 +233,24 @@ export class TestDataDataSource extends DataSourceWithBackend<TestData> { return of({ data: [dataFrame] }).pipe(delay(100)); } - nodesQuery(target: TestData, options: DataQueryRequest<TestData>): Observable<DataQueryResponse> { + nodesQuery(target: TestDataDataQuery, options: DataQueryRequest<TestDataDataQuery>): Observable<DataQueryResponse> { const type = target.nodes?.type || 'random'; let frames: DataFrame[]; switch (type) { + case 'feature_showcase': + frames = generateShowcaseData(); + break; case 'random': - frames = generateRandomNodes(target.nodes?.count); + frames = generateRandomNodes(target.nodes?.count, target.nodes?.seed); + break; + case 'response_small': + frames = savedNodesResponse('small'); break; - case 'response': - frames = savedNodesResponse(); + case 'response_medium': + frames = savedNodesResponse('medium'); break; case 'random edges': - frames = [generateRandomEdges(target.nodes?.count)]; + frames = [generateRandomEdges(target.nodes?.count, target.nodes?.seed)]; break; default: throw new Error(`Unknown node_graph sub type ${type}`); @@ -247,12 +259,12 @@ export class TestDataDataSource extends DataSourceWithBackend<TestData> { return of({ data: frames }).pipe(delay(100)); } - flameGraphQuery(target: TestData): Observable<DataQueryResponse> { + flameGraphQuery(target: TestDataDataQuery): Observable<DataQueryResponse> { const data = target.flamegraphDiff ? flameGraphDataDiff : flameGraphData; return of({ data: [{ ...data, refId: target.refId }] }).pipe(delay(100)); } - trace(options: DataQueryRequest<TestData>): Observable<DataQueryResponse> { + trace(options: DataQueryRequest<TestDataDataQuery>): Observable<DataQueryResponse> { const frame = new MutableDataFrame({ meta: { preferredVisualisationType: 'trace', @@ -314,7 +326,10 @@ export class TestDataDataSource extends DataSourceWithBackend<TestData> { return of({ data: [frame] }).pipe(delay(100)); } - rawFrameQuery(target: TestData, options: DataQueryRequest<TestData>): Observable<DataQueryResponse> { + rawFrameQuery( + target: TestDataDataQuery, + options: DataQueryRequest<TestDataDataQuery> + ): Observable<DataQueryResponse> { try { const data = JSON.parse(target.rawFrameContent ?? '[]').map((v: any) => { const f = toDataFrame(v); @@ -330,7 +345,10 @@ export class TestDataDataSource extends DataSourceWithBackend<TestData> { } } - serverErrorQuery(target: TestData, options: DataQueryRequest<TestData>): Observable<DataQueryResponse> | null { + serverErrorQuery( + target: TestDataDataQuery, + options: DataQueryRequest<TestDataDataQuery> + ): Observable<DataQueryResponse> | null { const { errorType } = target; if (errorType === 'server_panic') { @@ -350,7 +368,10 @@ export class TestDataDataSource extends DataSourceWithBackend<TestData> { } } -function runGrafanaAPI(target: TestData, req: DataQueryRequest<TestData>): Observable<DataQueryResponse> { +function runGrafanaAPI( + target: TestDataDataQuery, + req: DataQueryRequest<TestDataDataQuery> +): Observable<DataQueryResponse> { const url = `/api/${target.stringInput}`; return from( getBackendSrv() @@ -367,7 +388,10 @@ function runGrafanaAPI(target: TestData, req: DataQueryRequest<TestData>): Obser let liveQueryCounter = 1000; -function runGrafanaLiveQuery(target: TestData, req: DataQueryRequest<TestData>): Observable<DataQueryResponse> { +function runGrafanaLiveQuery( + target: TestDataDataQuery, + req: DataQueryRequest<TestDataDataQuery> +): Observable<DataQueryResponse> { if (!target.channel) { throw new Error(`Missing channel config`); } diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts b/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts index 515d3c885c264..3811b61890561 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts +++ b/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts @@ -1,3 +1,5 @@ +import { randomLcg } from 'd3-random'; + import { FieldColorModeId, FieldDTO, @@ -5,13 +7,16 @@ import { MutableDataFrame, NodeGraphDataFrameFieldNames, DataFrame, + addRow, } from '@grafana/data'; -import { nodes, edges } from './testData/serviceMapResponse'; +import * as serviceMapResponseSmall from './testData/serviceMapResponse'; +import * as serviceMapResponsMedium from './testData/serviceMapResponseMedium'; -export function generateRandomNodes(count = 10) { +export function generateRandomNodes(count = 10, seed?: number) { const nodes = []; const edges: string[] = []; + const rand = randomLcg(seed); const root = { id: 'root', @@ -31,7 +36,7 @@ export function generateRandomNodes(count = 10) { for (let i = 1; i < count; i++) { const node = makeRandomNode(i); nodes.push(node); - const sourceIndex = Math.floor(Math.random() * Math.floor(nodesWithoutMaxEdges.length - 1)); + const sourceIndex = Math.floor(rand() * Math.floor(nodesWithoutMaxEdges.length - 1)); const source = nodesWithoutMaxEdges[sourceIndex]; source.edges.push(node.id); if (source.edges.length >= maxEdges) { @@ -43,8 +48,8 @@ export function generateRandomNodes(count = 10) { // Add some random edges to create possible cycle const additionalEdges = Math.floor(count / 2); for (let i = 0; i <= additionalEdges; i++) { - const sourceIndex = Math.floor(Math.random() * Math.floor(nodes.length - 1)); - const targetIndex = Math.floor(Math.random() * Math.floor(nodes.length - 1)); + const sourceIndex = Math.floor(rand() * Math.floor(nodes.length - 1)); + const targetIndex = Math.floor(rand() * Math.floor(nodes.length - 1)); if (sourceIndex === targetIndex || nodes[sourceIndex].id === '0' || nodes[targetIndex].id === '0') { continue; } @@ -52,7 +57,74 @@ export function generateRandomNodes(count = 10) { nodes[sourceIndex].edges.push(nodes[targetIndex].id); } - const nodeFields: Record<string, Omit<FieldDTO, 'name'> & { values: any[] }> = { + const { nodesFields, nodesFrame, edgesFrame } = makeDataFrames(); + + const edgesSet = new Set(); + for (const node of nodes) { + nodesFields.id.values.push(node.id); + nodesFields.title.values.push(node.title); + nodesFields[NodeGraphDataFrameFieldNames.subTitle].values.push(node.subTitle); + nodesFields[NodeGraphDataFrameFieldNames.mainStat].values.push(node.stat1); + nodesFields[NodeGraphDataFrameFieldNames.secondaryStat].values.push(node.stat2); + nodesFields.arc__success.values.push(node.success); + nodesFields.arc__errors.values.push(node.error); + const rnd = Math.random(); + nodesFields[NodeGraphDataFrameFieldNames.icon].values.push(rnd > 0.9 ? 'database' : rnd < 0.1 ? 'cloud' : ''); + nodesFields[NodeGraphDataFrameFieldNames.nodeRadius].values.push(Math.max(rnd * 100, 30)); // ensure a minimum radius of 30 or icons will not fit well in the node + nodesFields[NodeGraphDataFrameFieldNames.highlighted].values.push(Math.random() > 0.5); + + for (const edge of node.edges) { + const id = `${node.id}--${edge}`; + // We can have duplicate edges when we added some more by random + if (edgesSet.has(id)) { + continue; + } + edgesSet.add(id); + edgesFrame.fields[0].values.push(`${node.id}--${edge}`); + edgesFrame.fields[1].values.push(node.id); + edgesFrame.fields[2].values.push(edge); + edgesFrame.fields[3].values.push(Math.random() * 100); + edgesFrame.fields[4].values.push(Math.random() > 0.5); + edgesFrame.fields[5].values.push(Math.ceil(Math.random() * 15)); + } + } + edgesFrame.length = edgesFrame.fields[0].values.length; + + return [nodesFrame, edgesFrame]; +} + +function makeRandomNode(index: number) { + const success = Math.random(); + const error = 1 - success; + return { + id: `service:${index}`, + title: `service:${index}`, + subTitle: 'service', + success, + error, + stat1: Math.random(), + stat2: Math.random(), + edges: [], + highlighted: Math.random() > 0.5, + }; +} + +export function savedNodesResponse(size: 'small' | 'medium'): [DataFrame, DataFrame] { + const response = size === 'small' ? serviceMapResponseSmall : serviceMapResponsMedium; + return [new MutableDataFrame(response.nodes), new MutableDataFrame(response.edges)]; +} + +// Generates node graph data but only returns the edges +export function generateRandomEdges(count = 10, seed = 1) { + return generateRandomNodes(count, seed)[1]; +} + +function makeDataFrames(): { + nodesFrame: DataFrame; + edgesFrame: DataFrame; + nodesFields: Record<string, Omit<FieldDTO, 'name'> & { values: unknown[] }>; +} { + const nodesFields: Record<string, Omit<FieldDTO, 'name'> & { values: unknown[] }> = { [NodeGraphDataFrameFieldNames.id]: { values: [], type: FieldType.string, @@ -110,12 +182,20 @@ export function generateRandomNodes(count = 10) { values: [], type: FieldType.boolean, }, + + [NodeGraphDataFrameFieldNames.detail + 'test_value']: { + values: [], + config: { + displayName: 'Test value', + }, + type: FieldType.number, + }, }; - const nodeFrame = new MutableDataFrame({ + const nodesFrame = new MutableDataFrame({ name: 'nodes', - fields: Object.keys(nodeFields).map((key) => ({ - ...nodeFields[key], + fields: Object.keys(nodesFields).map((key) => ({ + ...nodesFields[key], name: key, })), meta: { preferredVisualisationType: 'nodeGraph' }, @@ -130,66 +210,106 @@ export function generateRandomNodes(count = 10) { { name: NodeGraphDataFrameFieldNames.mainStat, values: [], type: FieldType.number, config: {} }, { name: NodeGraphDataFrameFieldNames.highlighted, values: [], type: FieldType.boolean, config: {} }, { name: NodeGraphDataFrameFieldNames.thickness, values: [], type: FieldType.number, config: {} }, + { name: NodeGraphDataFrameFieldNames.color, values: [], type: FieldType.string, config: {} }, + { name: NodeGraphDataFrameFieldNames.strokeDasharray, values: [], type: FieldType.string, config: {} }, ], meta: { preferredVisualisationType: 'nodeGraph' }, length: 0, }; - const edgesSet = new Set(); - for (const node of nodes) { - nodeFields.id.values.push(node.id); - nodeFields.title.values.push(node.title); - nodeFields[NodeGraphDataFrameFieldNames.subTitle].values.push(node.subTitle); - nodeFields[NodeGraphDataFrameFieldNames.mainStat].values.push(node.stat1); - nodeFields[NodeGraphDataFrameFieldNames.secondaryStat].values.push(node.stat2); - nodeFields.arc__success.values.push(node.success); - nodeFields.arc__errors.values.push(node.error); - const rnd = Math.random(); - nodeFields[NodeGraphDataFrameFieldNames.icon].values.push(rnd > 0.9 ? 'database' : rnd < 0.1 ? 'cloud' : ''); - nodeFields[NodeGraphDataFrameFieldNames.nodeRadius].values.push(Math.max(rnd * 100, 30)); // ensure a minimum radius of 30 or icons will not fit well in the node - nodeFields[NodeGraphDataFrameFieldNames.highlighted].values.push(Math.random() > 0.5); + return { nodesFrame, edgesFrame, nodesFields }; +} - for (const edge of node.edges) { - const id = `${node.id}--${edge}`; - // We can have duplicate edges when we added some more by random - if (edgesSet.has(id)) { - continue; - } - edgesSet.add(id); - edgesFrame.fields[0].values.push(`${node.id}--${edge}`); - edgesFrame.fields[1].values.push(node.id); - edgesFrame.fields[2].values.push(edge); - edgesFrame.fields[3].values.push(Math.random() * 100); - edgesFrame.fields[4].values.push(Math.random() > 0.5); - edgesFrame.fields[5].values.push(Math.ceil(Math.random() * 15)); - } - } - edgesFrame.length = edgesFrame.fields[0].values.length; +export function generateShowcaseData() { + const { nodesFrame, edgesFrame } = makeDataFrames(); - return [nodeFrame, edgesFrame]; -} + addRow(nodesFrame, { + [NodeGraphDataFrameFieldNames.id]: 'root', + [NodeGraphDataFrameFieldNames.title]: 'root', + [NodeGraphDataFrameFieldNames.subTitle]: 'client', + [NodeGraphDataFrameFieldNames.mainStat]: 1234, + [NodeGraphDataFrameFieldNames.secondaryStat]: 5678, + arc__success: 0.5, + arc__errors: 0.5, + [NodeGraphDataFrameFieldNames.icon]: '', + [NodeGraphDataFrameFieldNames.nodeRadius]: 40, + [NodeGraphDataFrameFieldNames.highlighted]: false, + [NodeGraphDataFrameFieldNames.detail + 'test_value']: 1, + }); -function makeRandomNode(index: number) { - const success = Math.random(); - const error = 1 - success; - return { - id: `service:${index}`, - title: `service:${index}`, - subTitle: 'service', - success, - error, - stat1: Math.random(), - stat2: Math.random(), - edges: [], - highlighted: Math.random() > 0.5, - }; -} + addRow(nodesFrame, { + [NodeGraphDataFrameFieldNames.id]: 'app_service', + [NodeGraphDataFrameFieldNames.title]: 'app service', + [NodeGraphDataFrameFieldNames.subTitle]: 'with icon', + [NodeGraphDataFrameFieldNames.mainStat]: 1.2, + [NodeGraphDataFrameFieldNames.secondaryStat]: 2.3, + arc__success: 1, + arc__errors: 0, + [NodeGraphDataFrameFieldNames.icon]: 'apps', + [NodeGraphDataFrameFieldNames.nodeRadius]: 40, + [NodeGraphDataFrameFieldNames.highlighted]: false, + [NodeGraphDataFrameFieldNames.detail + 'test_value']: 42, + }); -export function savedNodesResponse() { - return [new MutableDataFrame(nodes), new MutableDataFrame(edges)]; -} + addRow(edgesFrame, { + [NodeGraphDataFrameFieldNames.id]: 'root-app_service', + [NodeGraphDataFrameFieldNames.source]: 'root', + [NodeGraphDataFrameFieldNames.target]: 'app_service', + [NodeGraphDataFrameFieldNames.mainStat]: 3.4, + [NodeGraphDataFrameFieldNames.secondaryStat]: 4.5, + [NodeGraphDataFrameFieldNames.thickness]: 4, + [NodeGraphDataFrameFieldNames.color]: '', + [NodeGraphDataFrameFieldNames.strokeDasharray]: '', + }); -// Generates node graph data but only returns the edges -export function generateRandomEdges(count = 10) { - return generateRandomNodes(count)[1]; + addRow(nodesFrame, { + [NodeGraphDataFrameFieldNames.id]: 'auth_service', + [NodeGraphDataFrameFieldNames.title]: 'auth service', + [NodeGraphDataFrameFieldNames.subTitle]: 'highlighted', + [NodeGraphDataFrameFieldNames.mainStat]: 3.4, + [NodeGraphDataFrameFieldNames.secondaryStat]: 4.5, + arc__success: 0, + arc__errors: 1, + [NodeGraphDataFrameFieldNames.icon]: '', + [NodeGraphDataFrameFieldNames.nodeRadius]: 40, + [NodeGraphDataFrameFieldNames.highlighted]: true, + }); + + addRow(edgesFrame, { + [NodeGraphDataFrameFieldNames.id]: 'root-auth_service', + [NodeGraphDataFrameFieldNames.source]: 'root', + [NodeGraphDataFrameFieldNames.target]: 'auth_service', + [NodeGraphDataFrameFieldNames.mainStat]: 113.4, + [NodeGraphDataFrameFieldNames.secondaryStat]: 4.511, + [NodeGraphDataFrameFieldNames.thickness]: 8, + [NodeGraphDataFrameFieldNames.color]: 'red', + [NodeGraphDataFrameFieldNames.strokeDasharray]: '', + }); + + addRow(nodesFrame, { + [NodeGraphDataFrameFieldNames.id]: 'db', + [NodeGraphDataFrameFieldNames.title]: 'db', + [NodeGraphDataFrameFieldNames.subTitle]: 'bigger size', + [NodeGraphDataFrameFieldNames.mainStat]: 9876.123, + [NodeGraphDataFrameFieldNames.secondaryStat]: 123.9876, + arc__success: 0.9, + arc__errors: 0.1, + [NodeGraphDataFrameFieldNames.icon]: 'database', + [NodeGraphDataFrameFieldNames.nodeRadius]: 60, + [NodeGraphDataFrameFieldNames.highlighted]: false, + [NodeGraphDataFrameFieldNames.detail + 'test_value']: 1357, + }); + + addRow(edgesFrame, { + [NodeGraphDataFrameFieldNames.id]: 'auth_service-db', + [NodeGraphDataFrameFieldNames.source]: 'auth_service', + [NodeGraphDataFrameFieldNames.target]: 'db', + [NodeGraphDataFrameFieldNames.mainStat]: 1139.4, + [NodeGraphDataFrameFieldNames.secondaryStat]: 477.511, + [NodeGraphDataFrameFieldNames.thickness]: 2, + [NodeGraphDataFrameFieldNames.color]: 'blue', + [NodeGraphDataFrameFieldNames.strokeDasharray]: '2 2', + }); + + return [nodesFrame, edgesFrame]; } diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/package.json b/public/app/plugins/datasource/grafana-testdata-datasource/package.json index 4b4fa493d5714..174e9460a16d9 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/package.json +++ b/public/app/plugins/datasource/grafana-testdata-datasource/package.json @@ -2,32 +2,37 @@ "name": "@grafana-plugins/grafana-testdata-datasource", "description": "Generates test data in different forms", "private": true, - "version": "10.3.0-pre", + "version": "11.0.0-pre", "dependencies": { "@emotion/css": "11.11.2", - "@grafana/data": "10.3.0-pre", - "@grafana/experimental": "1.7.0", - "@grafana/runtime": "10.3.0-pre", - "@grafana/schema": "10.3.0-pre", - "@grafana/ui": "10.3.0-pre", + "@grafana/data": "11.0.0-pre", + "@grafana/experimental": "1.7.10", + "@grafana/runtime": "11.0.0-pre", + "@grafana/schema": "11.0.0-pre", + "@grafana/ui": "11.0.0-pre", + "d3-random": "^3.0.1", "lodash": "4.17.21", + "micro-memoize": "^4.1.2", "react": "18.2.0", - "react-use": "17.4.0", + "react-use": "17.5.0", "rxjs": "7.8.1", - "tslib": "2.6.0" + "tslib": "2.6.2", + "uuid": "9.0.1" }, "devDependencies": { - "@grafana/e2e-selectors": "10.3.0-pre", - "@grafana/plugin-configs": "10.3.0-pre", - "@testing-library/react": "14.0.0", - "@testing-library/user-event": "14.5.1", - "@types/jest": "29.5.4", - "@types/lodash": "4.14.195", - "@types/node": "20.8.10", - "@types/react": "18.2.15", - "@types/testing-library__jest-dom": "5.14.8", - "ts-node": "10.9.1", - "webpack": "5.89.0" + "@grafana/e2e-selectors": "11.0.0-pre", + "@grafana/plugin-configs": "11.0.0-pre", + "@testing-library/react": "14.2.1", + "@testing-library/user-event": "14.5.2", + "@types/d3-random": "^3.0.2", + "@types/jest": "29.5.12", + "@types/lodash": "4.17.0", + "@types/node": "20.11.28", + "@types/react": "18.2.66", + "@types/testing-library__jest-dom": "5.14.9", + "@types/uuid": "9.0.8", + "ts-node": "10.9.2", + "webpack": "5.90.3" }, "peerDependencies": { "@grafana/runtime": "*" @@ -37,5 +42,5 @@ "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", "dev": "webpack -w -c ./webpack.config.ts --env development" }, - "packageManager": "yarn@3.6.0" + "packageManager": "yarn@4.1.0" } diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/runStreams.ts b/public/app/plugins/datasource/grafana-testdata-datasource/runStreams.ts index ed773320c959a..ea98e96636ce1 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/runStreams.ts +++ b/public/app/plugins/datasource/grafana-testdata-datasource/runStreams.ts @@ -1,5 +1,6 @@ import { defaults } from 'lodash'; import { Observable } from 'rxjs'; +import { v4 as uuidv4 } from 'uuid'; import { DataQueryRequest, @@ -12,10 +13,14 @@ import { DataFrameSchema, DataFrameData, StreamingDataFrame, + createDataFrame, + addRow, + getDisplayProcessor, + createTheme, } from '@grafana/data'; import { getRandomLine } from './LogIpsum'; -import { TestData, StreamingQuery } from './dataquery.gen'; +import { TestDataDataQuery, StreamingQuery } from './dataquery.gen'; export const defaultStreamQuery: StreamingQuery = { type: 'signal', @@ -25,27 +30,31 @@ export const defaultStreamQuery: StreamingQuery = { bands: 1, }; -export function runStream(target: TestData, req: DataQueryRequest<TestData>): Observable<DataQueryResponse> { +export function runStream( + target: TestDataDataQuery, + req: DataQueryRequest<TestDataDataQuery> +): Observable<DataQueryResponse> { const query = defaults(target.stream, defaultStreamQuery); - if ('signal' === query.type) { - return runSignalStream(target, query, req); - } - if ('logs' === query.type) { - return runLogsStream(target, query, req); - } - if ('fetch' === query.type) { - return runFetchStream(target, query, req); + switch (query.type) { + case 'signal': + return runSignalStream(target, query, req); + case 'logs': + return runLogsStream(target, query, req); + case 'fetch': + return runFetchStream(target, query, req); + case 'traces': + return runTracesStream(target, query, req); } throw new Error(`Unknown Stream Type: ${query.type}`); } export function runSignalStream( - target: TestData, + target: TestDataDataQuery, query: StreamingQuery, - req: DataQueryRequest<TestData> + req: DataQueryRequest<TestDataDataQuery> ): Observable<DataQueryResponse> { return new Observable<DataQueryResponse>((subscriber) => { - const streamId = `signal-${req.panelId}-${target.refId}`; + const streamId = `signal-${req.panelId || 'explore'}-${target.refId}`; const maxDataPoints = req.maxDataPoints || 1000; const schema: DataFrameSchema = { @@ -122,12 +131,12 @@ export function runSignalStream( } export function runLogsStream( - target: TestData, + target: TestDataDataQuery, query: StreamingQuery, - req: DataQueryRequest<TestData> + req: DataQueryRequest<TestDataDataQuery> ): Observable<DataQueryResponse> { return new Observable<DataQueryResponse>((subscriber) => { - const streamId = `logs-${req.panelId}-${target.refId}`; + const streamId = `logs-${req.panelId || 'explore'}-${target.refId}`; const maxDataPoints = req.maxDataPoints || 1000; const data = new CircularDataFrame({ @@ -151,6 +160,7 @@ export function runLogsStream( subscriber.next({ data: [data], key: streamId, + state: LoadingState.Streaming, }); timeoutId = setTimeout(pushNextEvent, speed); @@ -167,12 +177,12 @@ export function runLogsStream( } export function runFetchStream( - target: TestData, + target: TestDataDataQuery, query: StreamingQuery, - req: DataQueryRequest<TestData> + req: DataQueryRequest<TestDataDataQuery> ): Observable<DataQueryResponse> { return new Observable<DataQueryResponse>((subscriber) => { - const streamId = `fetch-${req.panelId}-${target.refId}`; + const streamId = `fetch-${req.panelId || 'explore'}-${target.refId}`; const maxDataPoints = req.maxDataPoints || 1000; let data = new CircularDataFrame({ @@ -243,3 +253,77 @@ export function runFetchStream( }; }); } + +export function runTracesStream( + target: TestDataDataQuery, + query: StreamingQuery, + req: DataQueryRequest<TestDataDataQuery> +): Observable<DataQueryResponse> { + return new Observable<DataQueryResponse>((subscriber) => { + const streamId = `traces-${req.panelId || 'explore'}-${target.refId}`; + const data = createMainTraceFrame(target, req.maxDataPoints); + let timeoutId: ReturnType<typeof setTimeout>; + + const pushNextEvent = () => { + const subframe = createTraceSubFrame(); + addRow(subframe, [uuidv4(), Date.now(), 'Grafana', 1500]); + addRow(data, [uuidv4(), Date.now(), 'Grafana', 'HTTP GET /explore', 1500, [subframe]]); + + subscriber.next({ + data: [data], + key: streamId, + state: LoadingState.Streaming, + }); + + timeoutId = setTimeout(pushNextEvent, query.speed); + }; + + // Send first event in 5ms + setTimeout(pushNextEvent, 5); + + return () => { + console.log('unsubscribing to stream ' + streamId); + clearTimeout(timeoutId); + }; + }); +} + +function createMainTraceFrame(target: TestDataDataQuery, maxDataPoints = 1000) { + const data = new CircularDataFrame({ + append: 'head', + capacity: maxDataPoints, + }); + data.refId = target.refId; + data.name = target.alias || 'Traces ' + target.refId; + data.addField({ name: 'TraceID', type: FieldType.string }); + data.addField({ name: 'Start time', type: FieldType.time }); + data.addField({ name: 'Service', type: FieldType.string }); + data.addField({ name: 'Name', type: FieldType.string }); + data.addField({ name: 'Duration', type: FieldType.number, config: { unit: 'ms' } }); + data.addField({ name: 'nested', type: FieldType.nestedFrames }); + data.meta = { + preferredVisualisationType: 'table', + uniqueRowIdFields: [0], + }; + return data; +} + +function createTraceSubFrame() { + const frame = createDataFrame({ + fields: [ + { name: 'SpanID', type: FieldType.string }, + { name: 'Start time', type: FieldType.time }, + { name: 'service.name', type: FieldType.string }, + { name: 'duration', type: FieldType.number }, + ], + }); + + // TODO: this should be removed later but right now there is an issue that applyFieldOverrides does not consider + // nested frames. + for (const f of frame.fields) { + f.display = getDisplayProcessor({ field: f, theme }); + } + return frame; +} + +const theme = createTheme(); diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/testData/serviceMapResponse.ts b/public/app/plugins/datasource/grafana-testdata-datasource/testData/serviceMapResponse.ts index dabecebcfc4e5..bd0ea583d96e7 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/testData/serviceMapResponse.ts +++ b/public/app/plugins/datasource/grafana-testdata-datasource/testData/serviceMapResponse.ts @@ -57,7 +57,7 @@ export const nodes = { }, ], }, - values: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + values: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], }, { name: NodeGraphDataFrameFieldNames.title, @@ -79,6 +79,8 @@ export const nodes = { 'api', 'www', 'products', + 'test-client', + 'test-api', ], }, { @@ -101,6 +103,8 @@ export const nodes = { 'client', 'client', 'Compute', + 'ephemeral', + 'ephemeral-api', ], }, { @@ -110,7 +114,7 @@ export const nodes = { values: [ 3.5394042646735553, 15.906441318223264, 4.913011921591567, 7.4163203042094095, 1092, 22.85961441405067, 56.135855729084696, 4.45946191601527, 12.818300278280843, 4.25, 12.565442646791492, 77.63447512700567, - 40.387096774193544, 77.63447512700567, 27.648950187374872, + 40.387096774193544, 77.63447512700567, 27.648950187374872, 77.63447512700567, 27.648950187374872, ], }, { @@ -120,7 +124,7 @@ export const nodes = { values: [ 50.56317154501667, 682.4, 512.8416666666667, 125.64444444444445, 0.005585812037424941, 137.59722222222223, 300.0527777777778, 30.582348853370394, 125.77222222222223, 0.028706417080318163, 30.582348853370394, 165.675, - 0.100021510002151, 165.675, 162.33055555555555, + 0.100021510002151, 165.675, 162.33055555555555, 165.675, 162.33055555555555, ], }, { @@ -129,7 +133,7 @@ export const nodes = { config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'green' }, displayName: 'Sucesss' }, values: [ 0.9338865684765882, 1, 1, 1, 0.5, 1, 0.9901128505170387, 0.9069260134520997, 1, 0, 0.9069260134520997, - 0.9624432037288534, 0, 0.9624432037288534, 0.9824945669843769, + 0.9624432037288534, 0, 0.9624432037288534, 0.9824945669843769, 0.9624432037288534, 0.9824945669843769, ], }, { @@ -138,7 +142,7 @@ export const nodes = { config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'red' }, displayName: 'Faults' }, values: [ 0, 0, 0, 0, 0.5, 0, 0.009479813736472288, 0, 0, 0, 0, 0.017168821152524185, 0, 0.017168821152524185, - 0.01750543301562313, + 0.01750543301562313, 0.017168821152524185, 0.01750543301562313, ], }, { @@ -147,14 +151,14 @@ export const nodes = { config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'semi-dark-yellow' }, displayName: 'Errors' }, values: [ 0.06611343152341174, 0, 0, 0, 0, 0, 0.0004073357464890436, 0.09307398654790038, 0, 1, 0.09307398654790038, - 0.02038797511862247, 1, 0.02038797511862247, 0, + 0.02038797511862247, 1, 0.02038797511862247, 0, 0.02038797511862247, 0, ], }, { name: NodeGraphDataFrameFieldNames.arc + 'throttled', type: FieldType.number, config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'purple' }, displayName: 'Throttled' }, - values: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + values: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], }, ], meta: { preferredVisualisationType: 'nodeGraph' as const }, @@ -236,13 +240,14 @@ export const edges = { '14__1', '14__2', '14__10', + '15__16', ], }, { name: NodeGraphDataFrameFieldNames.source, type: FieldType.string, config: {}, - values: [0, 5, 6, 6, 6, 6, 6, 6, 8, 10, 11, 11, 12, 13, 14, 14, 14], + values: [0, 5, 6, 6, 6, 6, 6, 6, 8, 10, 11, 11, 12, 13, 14, 14, 14, 15], }, { name: 'sourceName', @@ -266,13 +271,14 @@ export const edges = { 'products', 'products', 'products', + 'test-client', ], }, { name: NodeGraphDataFrameFieldNames.target, type: FieldType.string, config: {}, - values: [2, 8, 0, 5, 9, 2, 14, 4, 3, 7, 0, 6, 6, 11, 1, 2, 10], + values: [2, 8, 0, 5, 9, 2, 14, 4, 3, 7, 0, 6, 6, 11, 1, 2, 10, 16], }, { name: 'targetName', @@ -296,6 +302,7 @@ export const edges = { 'products', 'customers', 'shipping', + 'test-api', ], }, { @@ -320,6 +327,7 @@ export const edges = { 'Success 100.00%', 'Success 100.00%', 'Faults 9.30%', + 'Faults 9.30%', ], }, { @@ -330,6 +338,7 @@ export const edges = { 50.56317154501667, 125.77222222222223, 0.03333333333333333, 137.59722222222223, 0.022222222222222223, 299.96666666666664, 162.33055555555555, 0.005555555555555556, 125.64444444444445, 30.582348853370394, 50.51111111111111, 299.9166666666667, 0.100021510002151, 165.675, 682.4, 162.33055555555555, 30.558333333333334, + 30.558333333333334, ], }, ], diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/testData/serviceMapResponseMedium.ts b/public/app/plugins/datasource/grafana-testdata-datasource/testData/serviceMapResponseMedium.ts new file mode 100644 index 0000000000000..ac0b2599c0835 --- /dev/null +++ b/public/app/plugins/datasource/grafana-testdata-datasource/testData/serviceMapResponseMedium.ts @@ -0,0 +1,1004 @@ +import { FieldType, NodeGraphDataFrameFieldNames } from '@grafana/data'; + +export const nodes = { + fields: [ + { + name: NodeGraphDataFrameFieldNames.id, + type: FieldType.string, + values: [ + '0', + 'candycon2000', + 'pantry-summary', + 'compute-splines-api', + 'send-notification', + 'popcorn-distributor', + 'caramel-gateway', + 'candy-distributor', + 'candy-query-frontend', + 'candy-ruler', + 'candy-querier', + 'candy-chocolate-querier', + 'candy-backend', + 'candy-read', + 'candy-write', + 'popcorn-backend', + 'popcorn-query-frontend', + 'proxy-reads', + 'proxy-gateway', + 'proxy-writes', + 'dentist', + 'fudge-proxy-gateway', + 'candy-admin-api', + 'candy-disposer', + 'candy-ingester', + 'candy-gateway', + 'candy-chocolate-write-proxy', + 'candy-store-gateway', + 'popcorn-querier', + 'candy-query-scheduler', + 'api', + 'controller', + 'gateway', + 'db', + 'marshmallow-popcorn-plugin', + 'eggnog-query', + 'licorice-ingester', + 'licorice-distributor', + 'licorice-gateway', + 'licorice-querier', + 'licorice-query-frontend', + 'licorice-ruler', + 'licorice-read', + 'licorice-write', + 'licorice-sugar-gateway', + 'licorice-query-scheduler', + 'popcorn-ingester', + 'popcorn-ruler', + 'peanutapi', + 'almondapi', + 'worker', + 'toffee-gateway', + 'marshmallow-gateway', + 'toffee-proxy-gateway', + 'mints-distributor', + 'mints-agent', + 'mints-ingester', + 'marshmallow-query-frontend', + 'marshmallow-ingester', + 'marshmallow-querier', + 'eating-scheduler', + 'calorie-generator', + 'candy-packaging-exporter', + 'candy-manager', + 'overeat-gateway', + 'wrappers-api', + 'overeat_api', + 'licorice-admin-api', + 'almond', + 'almondops-gateway', + ], + }, + { + name: NodeGraphDataFrameFieldNames.title, + config: { + displayName: 'Service name', + }, + type: FieldType.string, + values: [ + '0', + 'candycon2000', + 'pantry-summary', + 'compute-splines-api', + 'send-notification', + 'popcorn-distributor', + 'caramel-gateway', + 'candy-distributor', + 'candy-query-frontend', + 'candy-ruler', + 'candy-querier', + 'candy-chocolate-querier', + 'candy-backend', + 'candy-read', + 'candy-write', + 'popcorn-backend', + 'popcorn-query-frontend', + 'proxy-reads', + 'proxy-gateway', + 'proxy-writes', + 'dentist', + 'fudge-proxy-gateway', + 'candy-admin-api', + 'candy-disposer', + 'candy-ingester', + 'candy-gateway', + 'candy-chocolate-write-proxy', + 'candy-store-gateway', + 'popcorn-querier', + 'candy-query-scheduler', + 'api', + 'controller', + 'gateway', + 'db', + 'marshmallow-popcorn-plugin', + 'eggnog-query', + 'licorice-ingester', + 'licorice-distributor', + 'licorice-gateway', + 'licorice-querier', + 'licorice-query-frontend', + 'licorice-ruler', + 'licorice-read', + 'licorice-write', + 'licorice-sugar-gateway', + 'licorice-query-scheduler', + 'popcorn-ingester', + 'popcorn-ruler', + 'peanutapi', + 'almondapi', + 'worker', + 'toffee-gateway', + 'marshmallow-gateway', + 'toffee-proxy-gateway', + 'mints-distributor', + 'mints-agent', + 'mints-ingester', + 'marshmallow-query-frontend', + 'marshmallow-ingester', + 'marshmallow-querier', + 'eating-scheduler', + 'calorie-generator', + 'candy-packaging-exporter', + 'candy-manager', + 'overeat-gateway', + 'wrappers-api', + 'overeat_api', + 'licorice-admin-api', + 'almond', + 'almondops-gateway', + ], + }, + { + name: NodeGraphDataFrameFieldNames.mainStat, + config: { + unit: 'ms/r', + displayName: 'Average response time', + }, + type: FieldType.number, + values: [ + 0.3792332384891563, + 0.8672229016348518, + null, + 16.20695699632165, + 39.928285334583585, + 9.655124202033475, + 21.05268555627574, + 5.867123115073844, + 2.4860418856893776, + 355.53214166441546, + 20.55178572103791, + null, + 38.89817349596472, + 8.180751216025271, + 0.8054812870678301, + 40.77678203483556, + 85.482797395998, + 16.27026063145621, + null, + 19.87661445917064, + 0.22942839760131845, + null, + null, + null, + 0.5613453804178244, + null, + null, + 8.992145179555344, + 304.9330783211414, + null, + 10.395531440216706, + null, + null, + null, + 0.015141602590812384, + null, + 2516.1181289425012, + 1.753552009081408, + 2.18723509171844, + 2042.6033859578174, + 35.584947433232884, + 12.650689593313736, + 11469.63275426663, + 10032.233577903402, + 2.7579124143530875, + null, + 12.313124260662056, + null, + 449.2619110960284, + null, + 457.68439141665425, + 4.200223281332062, + 3.22412985358373, + null, + 16.03316977154454, + null, + 5.830408312271875, + 1460.8431543525896, + 6.379590630002257, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + ], + }, + { + name: NodeGraphDataFrameFieldNames.secondaryStat, + config: { + unit: 'r/sec', + displayName: 'Requests per second', + }, + type: FieldType.number, + values: [ + 0, + 0, + null, + 0, + 0, + 0, + 0, + 0, + 0.01, + 0, + 0.01, + null, + 0, + 0, + 0.02, + 0, + 0, + 0, + null, + 0, + 0, + null, + null, + null, + 0.1, + null, + null, + 0, + 0, + null, + 0, + null, + null, + null, + 0, + null, + 0.06, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + null, + 0, + null, + 0, + null, + 0, + 0, + 0, + null, + 0, + null, + 0, + 0, + 0, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + ], + }, + { + name: 'arc__success', + config: { + displayName: 'Success', + color: { + fixedColor: 'green', + mode: 'fixed', + }, + }, + type: FieldType.number, + values: [ + 1, 1, 1, 1, 0, 1, 0.999931271795046, 0.9999982854540072, 0.9999993106537873, 1, 0.9999988795927239, 1, + 0.9984939706365067, 0.9999936988458762, 0.9374731810143394, 0.9981691514900277, 1, 1, 1, 0.6918050349578433, 1, + 1, 1, 1, 0.9649904403274051, 1, 1, 0.996693264215713, 0.9990646675327494, 1, 0.989711501101388, 1, 1, 1, 1, 1, + 0.974376082211577, 1, 1, 0.9979501689527777, 0.9999554801847671, 0.7997517799066115, 0.998393334495106, + 0.8077280841753209, 0.9999889402832068, 1, 0.9267936098112096, 1, 0.9678190225122817, 1, 0.9664342727367723, 1, + 1, 1, 1, 1, 1, 0.49879813934581896, 0.8514429797914538, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + ], + }, + { + name: 'arc__failed', + config: { + displayName: 'Failed', + color: { + fixedColor: 'red', + mode: 'fixed', + }, + }, + type: FieldType.number, + values: [ + 0, 0, 0, 0, 1, 0, 0.00006872820495400671, 0.0000017145459928594954, 6.893462126672767e-7, 0, + 0.000001120407275990985, 0, 0.001506029363493422, 0.000006301154123861364, 0.06252681898566055, + 0.0018308485099723893, 0, 0, 0, 0.30819496504215677, 0, 0, 0, 0, 0.03500955967259495, 0, 0, + 0.0033067357842869243, 0.0009353324672505954, 0, 0.01028849889861211, 0, 0, 0, 0, 0, 0.025623917788423094, 0, 0, + 0.002049831047222383, 0.00004451981523281711, 0.20024822009338844, 0.001606665504893849, 0.19227191582467903, + 0.000011059716793143872, 0, 0.07320639018879034, 0, 0.032180977487718344, 0, 0.03356572726322771, 0, 0, 0, 0, 0, + 0, 0.501201860654181, 0.14855702020854625, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ], + }, + ], + meta: { preferredVisualisationType: 'nodeGraph' as const }, + name: 'nodes', +}; + +export const edges = { + fields: [ + { + name: NodeGraphDataFrameFieldNames.id, + type: FieldType.string, + values: [ + 'pantry-summary_candycon2000', + 'compute-splines-api_candycon2000', + 'compute-splines-api_send-notification', + 'caramel-gateway_popcorn-distributor', + 'caramel-gateway_candy-distributor', + 'caramel-gateway_candy-query-frontend', + 'caramel-gateway_candy-ruler', + 'caramel-gateway_caramel-gateway', + 'caramel-gateway_candy-querier', + 'caramel-gateway_candy-chocolate-querier', + 'caramel-gateway_candy-backend', + 'caramel-gateway_candy-read', + 'caramel-gateway_candy-write', + 'caramel-gateway_popcorn-backend', + 'caramel-gateway_popcorn-query-frontend', + 'proxy-gateway_proxy-reads', + 'proxy-gateway_proxy-writes', + 'proxy-reads_caramel-gateway', + 'proxy-writes_caramel-gateway', + 'fudge-proxy-gateway_dentist', + 'candy-admin-api_candy-distributor', + 'candy-backend_candy-read', + 'candy-backend_candy-write', + 'candy-disposer_candy-distributor', + 'candy-distributor_candy-ingester', + 'candy-distributor_candy-distributor', + 'candy-gateway_candy-admin-api', + 'candy-gateway_candy-distributor', + 'candy-gateway_candy-query-frontend', + 'candy-chocolate-write-proxy_caramel-gateway', + 'candy-ingester_candy-distributor', + 'candy-querier_candy-ingester', + 'candy-querier_candy-query-frontend', + 'candy-querier_candy-store-gateway', + 'candy-querier_candy-distributor', + 'candy-query-frontend_candy-querier', + 'candy-query-frontend_popcorn-querier', + 'candy-query-frontend_candy-distributor', + 'candy-query-scheduler_candy-distributor', + 'candy-read_candy-backend', + 'candy-read_candy-read', + 'candy-read_candy-write', + 'candy-read_popcorn-backend', + 'candy-ruler_candy-ruler', + 'candy-ruler_candy-ingester', + 'candy-ruler_candy-query-frontend', + 'candy-ruler_candy-distributor', + 'candy-write_candy-write', + 'controller_api', + 'gateway_api', + 'db_api', + 'eggnog-query_marshmallow-popcorn-plugin', + 'licorice-distributor_licorice-ingester', + 'licorice-gateway_licorice-distributor', + 'licorice-gateway_licorice-querier', + 'licorice-gateway_licorice-query-frontend', + 'licorice-gateway_licorice-ruler', + 'licorice-gateway_licorice-read', + 'licorice-gateway_licorice-write', + 'licorice-querier_licorice-sugar-gateway', + 'licorice-querier_licorice-ingester', + 'licorice-querier_licorice-query-frontend', + 'licorice-query-frontend_licorice-querier', + 'licorice-query-scheduler_licorice-query-frontend', + 'licorice-read_licorice-read', + 'licorice-read_licorice-write', + 'licorice-ruler_licorice-ingester', + 'licorice-ruler_licorice-sugar-gateway', + 'licorice-ruler_licorice-ruler', + 'licorice-write_licorice-write', + 'popcorn-backend_candy-read', + 'popcorn-backend_candy-write', + 'popcorn-distributor_candy-ingester', + 'popcorn-distributor_popcorn-ingester', + 'popcorn-querier_candy-ingester', + 'popcorn-querier_candy-query-frontend', + 'popcorn-querier_candy-store-gateway', + 'popcorn-querier_popcorn-ingester', + 'popcorn-querier_popcorn-query-frontend', + 'popcorn-query-frontend_popcorn-querier', + 'popcorn-ruler_candy-ingester', + 'popcorn-ruler_popcorn-query-frontend', + 'almondapi_peanutapi', + 'peanutapi_0', + 'peanutapi_worker', + 'toffee-gateway_licorice-gateway', + 'toffee-gateway_marshmallow-gateway', + 'toffee-proxy-gateway_toffee-gateway', + 'mints-agent_mints-distributor', + 'mints-distributor_mints-ingester', + 'marshmallow-gateway_marshmallow-query-frontend', + 'marshmallow-querier_marshmallow-ingester', + 'eating-scheduler_peanutapi', + 'calorie-generator_compute-splines-api', + 'worker_0', + 'worker_worker', + 'candy-backend_candy-backend', + 'candy-gateway_candy-ruler', + 'candy-packaging-exporter_candy-distributor', + 'caramel-gateway_candy-chocolate-write-proxy', + 'candy-manager_candy-distributor', + 'candy-backend_popcorn-backend', + 'candy-gateway_candy-manager', + 'candy-gateway_candy-querier', + 'candy-gateway_candy-store-gateway', + 'candy-gateway_candy-disposer', + 'candy-query-scheduler_candy-query-frontend', + 'candy-store-gateway_candy-distributor', + 'api_gateway', + 'gateway_overeat-gateway', + 'gateway_wrappers-api', + 'overeat-gateway_overeat_api', + 'overeat_api_gateway', + 'wrappers-api_caramel-gateway', + 'licorice-gateway_licorice-admin-api', + 'popcorn-backend_popcorn-backend', + 'popcorn-backend_candy-backend', + 'almondops-gateway_almond', + 'marshmallow-gateway_marshmallow-querier', + ], + }, + { + name: NodeGraphDataFrameFieldNames.source, + type: FieldType.string, + config: {}, + values: [ + 'pantry-summary', + 'compute-splines-api', + 'compute-splines-api', + 'caramel-gateway', + 'caramel-gateway', + 'caramel-gateway', + 'caramel-gateway', + 'caramel-gateway', + 'caramel-gateway', + 'caramel-gateway', + 'caramel-gateway', + 'caramel-gateway', + 'caramel-gateway', + 'caramel-gateway', + 'caramel-gateway', + 'proxy-gateway', + 'proxy-gateway', + 'proxy-reads', + 'proxy-writes', + 'fudge-proxy-gateway', + 'candy-admin-api', + 'candy-backend', + 'candy-backend', + 'candy-disposer', + 'candy-distributor', + 'candy-distributor', + 'candy-gateway', + 'candy-gateway', + 'candy-gateway', + 'candy-chocolate-write-proxy', + 'candy-ingester', + 'candy-querier', + 'candy-querier', + 'candy-querier', + 'candy-querier', + 'candy-query-frontend', + 'candy-query-frontend', + 'candy-query-frontend', + 'candy-query-scheduler', + 'candy-read', + 'candy-read', + 'candy-read', + 'candy-read', + 'candy-ruler', + 'candy-ruler', + 'candy-ruler', + 'candy-ruler', + 'candy-write', + 'controller', + 'gateway', + 'db', + 'eggnog-query', + 'licorice-distributor', + 'licorice-gateway', + 'licorice-gateway', + 'licorice-gateway', + 'licorice-gateway', + 'licorice-gateway', + 'licorice-gateway', + 'licorice-querier', + 'licorice-querier', + 'licorice-querier', + 'licorice-query-frontend', + 'licorice-query-scheduler', + 'licorice-read', + 'licorice-read', + 'licorice-ruler', + 'licorice-ruler', + 'licorice-ruler', + 'licorice-write', + 'popcorn-backend', + 'popcorn-backend', + 'popcorn-distributor', + 'popcorn-distributor', + 'popcorn-querier', + 'popcorn-querier', + 'popcorn-querier', + 'popcorn-querier', + 'popcorn-querier', + 'popcorn-query-frontend', + 'popcorn-ruler', + 'popcorn-ruler', + 'almondapi', + 'peanutapi', + 'peanutapi', + 'toffee-gateway', + 'toffee-gateway', + 'toffee-proxy-gateway', + 'mints-agent', + 'mints-distributor', + 'marshmallow-gateway', + 'marshmallow-querier', + 'eating-scheduler', + 'calorie-generator', + 'worker', + 'worker', + 'candy-backend', + 'candy-gateway', + 'candy-packaging-exporter', + 'caramel-gateway', + 'candy-manager', + 'candy-backend', + 'candy-gateway', + 'candy-gateway', + 'candy-gateway', + 'candy-gateway', + 'candy-query-scheduler', + 'candy-store-gateway', + 'api', + 'gateway', + 'gateway', + 'overeat-gateway', + 'overeat_api', + 'wrappers-api', + 'licorice-gateway', + 'popcorn-backend', + 'popcorn-backend', + 'almondops-gateway', + 'marshmallow-gateway', + ], + }, + { + name: NodeGraphDataFrameFieldNames.target, + type: FieldType.string, + config: {}, + values: [ + 'candycon2000', + 'candycon2000', + 'send-notification', + 'popcorn-distributor', + 'candy-distributor', + 'candy-query-frontend', + 'candy-ruler', + 'caramel-gateway', + 'candy-querier', + 'candy-chocolate-querier', + 'candy-backend', + 'candy-read', + 'candy-write', + 'popcorn-backend', + 'popcorn-query-frontend', + 'proxy-reads', + 'proxy-writes', + 'caramel-gateway', + 'caramel-gateway', + 'dentist', + 'candy-distributor', + 'candy-read', + 'candy-write', + 'candy-distributor', + 'candy-ingester', + 'candy-distributor', + 'candy-admin-api', + 'candy-distributor', + 'candy-query-frontend', + 'caramel-gateway', + 'candy-distributor', + 'candy-ingester', + 'candy-query-frontend', + 'candy-store-gateway', + 'candy-distributor', + 'candy-querier', + 'popcorn-querier', + 'candy-distributor', + 'candy-distributor', + 'candy-backend', + 'candy-read', + 'candy-write', + 'popcorn-backend', + 'candy-ruler', + 'candy-ingester', + 'candy-query-frontend', + 'candy-distributor', + 'candy-write', + 'api', + 'api', + 'api', + 'marshmallow-popcorn-plugin', + 'licorice-ingester', + 'licorice-distributor', + 'licorice-querier', + 'licorice-query-frontend', + 'licorice-ruler', + 'licorice-read', + 'licorice-write', + 'licorice-sugar-gateway', + 'licorice-ingester', + 'licorice-query-frontend', + 'licorice-querier', + 'licorice-query-frontend', + 'licorice-read', + 'licorice-write', + 'licorice-ingester', + 'licorice-sugar-gateway', + 'licorice-ruler', + 'licorice-write', + 'candy-read', + 'candy-write', + 'candy-ingester', + 'popcorn-ingester', + 'candy-ingester', + 'candy-query-frontend', + 'candy-store-gateway', + 'popcorn-ingester', + 'popcorn-query-frontend', + 'popcorn-querier', + 'candy-ingester', + 'popcorn-query-frontend', + 'peanutapi', + '0', + 'worker', + 'licorice-gateway', + 'marshmallow-gateway', + 'toffee-gateway', + 'mints-distributor', + 'mints-ingester', + 'marshmallow-query-frontend', + 'marshmallow-ingester', + 'peanutapi', + 'compute-splines-api', + '0', + 'worker', + 'candy-backend', + 'candy-ruler', + 'candy-distributor', + 'candy-chocolate-write-proxy', + 'candy-distributor', + 'popcorn-backend', + 'candy-manager', + 'candy-querier', + 'candy-store-gateway', + 'candy-disposer', + 'candy-query-frontend', + 'candy-distributor', + 'gateway', + 'overeat-gateway', + 'wrappers-api', + 'overeat_api', + 'gateway', + 'caramel-gateway', + 'licorice-admin-api', + 'popcorn-backend', + 'candy-backend', + 'almond', + 'marshmallow-querier', + ], + }, + { + name: NodeGraphDataFrameFieldNames.mainStat, + config: { + unit: 'ms/r', + displayName: 'Average response time', + }, + type: FieldType.number, + values: [ + 0.9961658833566494, + 0.6756011854064742, + 39.928285334583585, + 9.655124202033475, + 9.876837578844937, + 81.6959176729049, + 552.378420528502, + 34.02406743798451, + 2.7907819532876537, + null, + 1210.5999270997852, + 40.22305442474939, + 11.008270340421854, + 1163.33527417596, + 199.9200764328362, + 16.27026063145621, + 19.87661445917064, + 7.9747137654137195, + 20.135808118892268, + 0.22942839760131845, + 11.6566561294579, + 75.22426032984903, + 0.7326871862490554, + 10.591500091052806, + 0.43709820361586454, + 7.681333333333334, + null, + 5.743161551306568, + 22.56587650604559, + 31.38290115791745, + 11.375645375977152, + 0.5974877233841939, + 0.020271039373167783, + 8.891949445529226, + 10.293999999999995, + 20.582963262267164, + 405.7442187685195, + 8.522692621843909, + 6.924993219513745, + 34.39516266769771, + 5.952498150615941, + 0.7842759237283988, + 37.01067693570632, + 6.956706372103876, + 1.2689668998692378, + 66.66971283621828, + 13.622246129080805, + 0.9078908605310487, + 0.8179566076701271, + 1.101057295936026, + 19.887110234592154, + 0.015141602590812384, + 0.16393403903361325, + 1.753552009081408, + 1531278.5796188207, + 46.231810748849455, + 24.54774899949519, + 345061.91461907607, + 3.1331808761849147, + 2.764448605690192, + 2759.490189311903, + 0.019420568056168524, + 30.83859939288313, + 0.026999999999999993, + 2.405242467897073, + 14607.641090479348, + 11.449968675469439, + 0.33363793667426944, + 0.15786000187388566, + 0.585213874210476, + 41.39677023252635, + 0.12979907013653832, + 2.5827714948826848, + 6.868234219946023, + 21.107071160645475, + 0.053417204821597085, + 61.770970442855806, + 42.998532007278904, + 0.041043219084661645, + 217.39742114991702, + 0.21606213948511854, + 13.174790215465455, + 459.42768834463334, + 0.30539630255527017, + 447.5818111167013, + 2.18723509171844, + 3.22412985358373, + 4.200223281332062, + 16.03316977154454, + 5.830408312271875, + 1460.8431543525896, + 6.379590630002257, + 37.46723333208206, + 16.20695699632165, + 0.4593149548828589, + 2693.186775574188, + 0.034024049217002236, + 196.843, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + ], + }, + { + name: NodeGraphDataFrameFieldNames.secondaryStat, + config: { + unit: 'r/sec', + displayName: 'Requests per second', + }, + type: FieldType.number, + values: [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + null, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.03, + 0, + null, + 0, + 0, + 0, + 0, + 0.07, + 0.01, + 0, + 0, + 0.01, + 0, + 0, + 0, + 0, + 0, + 0.02, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.05, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + ], + }, + ], + meta: { preferredVisualisationType: 'nodeGraph' as const }, + name: 'edges', +}; diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/variables.ts b/public/app/plugins/datasource/grafana-testdata-datasource/variables.ts index 0668a00e6c7a5..16b604896e1bc 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/variables.ts +++ b/public/app/plugins/datasource/grafana-testdata-datasource/variables.ts @@ -1,10 +1,10 @@ import { StandardVariableQuery, StandardVariableSupport } from '@grafana/data'; -import { TestData, TestDataQueryType } from './dataquery.gen'; +import { TestDataDataQuery, TestDataQueryType } from './dataquery.gen'; import { TestDataDataSource } from './datasource'; export class TestDataVariableSupport extends StandardVariableSupport<TestDataDataSource> { - toDataQuery(query: StandardVariableQuery): TestData { + toDataQuery(query: StandardVariableQuery): TestDataDataQuery { return { refId: 'TestDataDataSource-QueryVariable', stringInput: query.query, diff --git a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx index 80722f4a65222..6ce90c7109741 100644 --- a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx +++ b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx @@ -6,7 +6,6 @@ import { DropEvent, FileRejection } from 'react-dropzone'; import { QueryEditorProps, SelectableValue, - dataFrameFromJSON, rangeUtil, DataQueryRequest, DataFrame, @@ -16,7 +15,7 @@ import { getValueFormat, formattedValueToString, } from '@grafana/data'; -import { config, getBackendSrv, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; +import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; import { InlineField, Select, @@ -33,6 +32,7 @@ import { } from '@grafana/ui'; import { hasAlphaPanels } from 'app/core/config'; import * as DFImport from 'app/features/dataframe-import'; +import { getManagedChannelInfo } from 'app/features/live/info'; import { SearchQuery } from 'app/features/search/service'; import { GrafanaDatasource } from '../datasource'; @@ -91,35 +91,9 @@ export class UnthemedQueryEditor extends PureComponent<Props, State> { } loadChannelInfo() { - getBackendSrv() - .fetch({ url: 'api/live/list' }) - .subscribe({ - next: (v: any) => { - const channelInfo = v.data?.channels as any[]; - if (channelInfo?.length) { - const channelFields: Record<string, Array<SelectableValue<string>>> = {}; - const channels: Array<SelectableValue<string>> = channelInfo.map((c) => { - if (c.data) { - const distinctFields = new Set<string>(); - const frame = dataFrameFromJSON(c.data); - for (const f of frame.fields) { - distinctFields.add(f.name); - } - channelFields[c.channel] = Array.from(distinctFields).map((n) => ({ - value: n, - label: n, - })); - } - return { - value: c.channel, - label: c.channel + ' [' + c.minute_rate + ' msg/min]', - }; - }); - - this.setState({ channelFields, channels }); - } - }, - }); + getManagedChannelInfo().then((v) => { + this.setState(v); + }); } loadFolderInfo() { diff --git a/public/app/plugins/datasource/grafana/datasource.test.ts b/public/app/plugins/datasource/grafana/datasource.test.ts index e526c0d43c984..5e9ee5a5d345b 100644 --- a/public/app/plugins/datasource/grafana/datasource.test.ts +++ b/public/app/plugins/datasource/grafana/datasource.test.ts @@ -22,7 +22,7 @@ describe('grafana data source', () => { }); describe('when executing an annotations query', () => { - let calledBackendSrvParams: any; + let calledBackendSrvParams: Parameters<(typeof backendSrv)['get']>[1]; let ds: GrafanaDatasource; beforeEach(() => { getMock.mockImplementation((url, options) => { @@ -41,7 +41,7 @@ describe('grafana data source', () => { }); it('should interpolate template variables in tags in query options', () => { - expect(calledBackendSrvParams.tags[0]).toBe('tag1:replaced'); + expect(calledBackendSrvParams?.tags[0]).toBe('tag1:replaced'); }); }); @@ -53,8 +53,8 @@ describe('grafana data source', () => { }); it('should interpolate template variables in tags in query options', () => { - expect(calledBackendSrvParams.tags[0]).toBe('replaced'); - expect(calledBackendSrvParams.tags[1]).toBe('replaced2'); + expect(calledBackendSrvParams?.tags[0]).toBe('replaced'); + expect(calledBackendSrvParams?.tags[1]).toBe('replaced2'); }); }); @@ -72,7 +72,7 @@ describe('grafana data source', () => { }); it('should remove tags from query options', () => { - expect(calledBackendSrvParams.tags).toBe(undefined); + expect(calledBackendSrvParams?.tags).toBe(undefined); }); }); }); diff --git a/public/app/plugins/datasource/grafana/datasource.ts b/public/app/plugins/datasource/grafana/datasource.ts index 934eadf8708e3..698db42459d93 100644 --- a/public/app/plugins/datasource/grafana/datasource.ts +++ b/public/app/plugins/datasource/grafana/datasource.ts @@ -216,7 +216,7 @@ export class GrafanaDatasource extends DataSourceWithBackend<GrafanaQuery> { if (target.type === GrafanaAnnotationType.Dashboard) { // if no dashboard id yet return - if (!options.dashboard.uid) { + if (!options.dashboard?.uid) { return Promise.resolve({ data: [] }); } // filter by dashboard id diff --git a/public/app/plugins/datasource/graphite/datasource.test.ts b/public/app/plugins/datasource/graphite/datasource.test.ts index bb63f70c46415..7259c6de52c04 100644 --- a/public/app/plugins/datasource/graphite/datasource.test.ts +++ b/public/app/plugins/datasource/graphite/datasource.test.ts @@ -2,7 +2,14 @@ import { isArray } from 'lodash'; import { of } from 'rxjs'; import { createFetchResponse } from 'test/helpers/createFetchResponse'; -import { AbstractLabelMatcher, AbstractLabelOperator, getFrameDisplayName, dateTime } from '@grafana/data'; +import { + AbstractLabelMatcher, + AbstractLabelOperator, + getFrameDisplayName, + dateTime, + DataQueryRequest, + MetricFindValue, +} from '@grafana/data'; import { BackendSrvRequest } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ import { TemplateSrv } from 'app/features/templating/template_srv'; @@ -120,7 +127,7 @@ describe('graphiteDatasource', () => { maxDataPoints: 500, }; - let response: any; + let response: unknown; let requestOptions: BackendSrvRequest; beforeEach(() => { @@ -139,7 +146,7 @@ describe('graphiteDatasource', () => { ); }); - response = ctx.ds.query(query as any); + response = ctx.ds.query(query as unknown as DataQueryRequest<GraphiteQuery>); }); it('X-Dashboard and X-Panel headers to be set!', () => { @@ -411,7 +418,7 @@ describe('graphiteDatasource', () => { }); describe('querying for template variables', () => { - let results: any; + let results: MetricFindValue[]; let requestOptions: BackendSrvRequest; beforeEach(() => { @@ -503,7 +510,7 @@ describe('graphiteDatasource', () => { current: { value: ['bar'] }, }, ]); - ctx.ds.metricFindQuery('[[foo]]').then((data: any) => { + ctx.ds.metricFindQuery('[[foo]]').then((data) => { results = data; }); expect(requestOptions.url).toBe('/api/datasources/proxy/1/metrics/find'); diff --git a/public/app/plugins/datasource/graphite/datasource.ts b/public/app/plugins/datasource/graphite/datasource.ts index e99e02b85d594..90ce33533e392 100644 --- a/public/app/plugins/datasource/graphite/datasource.ts +++ b/public/app/plugins/datasource/graphite/datasource.ts @@ -246,7 +246,10 @@ export class GraphiteDatasource return this.doGraphiteRequest(httpOptions).pipe(map(this.convertResponseToDataFrames)); } - addTracingHeaders(httpOptions: { headers: any }, options: { dashboardId?: number; panelId?: number }) { + addTracingHeaders( + httpOptions: { headers: any }, + options: { dashboardId?: number; panelId?: number; panelPluginId?: string } + ) { const proxyMode = !this.url.match(/^http/); if (proxyMode) { if (options.dashboardId) { @@ -255,6 +258,9 @@ export class GraphiteDatasource if (options.panelId) { httpOptions.headers['X-Panel-Id'] = options.panelId; } + if (options.panelPluginId) { + httpOptions.headers['X-Panel-Plugin-Id'] = options.panelPluginId; + } } } diff --git a/public/app/plugins/datasource/graphite/state/context.tsx b/public/app/plugins/datasource/graphite/state/context.tsx index cac739a125193..ba48d3a759d59 100644 --- a/public/app/plugins/datasource/graphite/state/context.tsx +++ b/public/app/plugins/datasource/graphite/state/context.tsx @@ -76,7 +76,7 @@ export const GraphiteQueryEditorContext = ({ () => { if (needsRefresh && state) { setNeedsRefresh(false); - onChange({ ...query, target: state.target.target }); + onChange({ ...query, target: state.target.target, targetFull: state.target.targetFull }); onRunQuery(); } }, @@ -92,8 +92,8 @@ export const GraphiteQueryEditorContext = ({ datasource: datasource, range: range, templateSrv: getTemplateSrv(), - // list of queries is passed only when the editor is in Dashboards. This is to allow interpolation - // of sub-queries which are stored in "targetFull" property used by alerting in the backend. + // list of queries is passed only when the editor is in Dashboards or Alerting. This is to allow interpolation + // of sub-queries which are stored in "targetFull" property. This is used by alerting in the backend. queries: queries || [], refresh: () => { // do not run onChange/onRunQuery straight away to ensure the internal state gets updated first diff --git a/public/app/plugins/datasource/graphite/types.ts b/public/app/plugins/datasource/graphite/types.ts index 0bdd7039ebc20..fb2bbe6363951 100644 --- a/public/app/plugins/datasource/graphite/types.ts +++ b/public/app/plugins/datasource/graphite/types.ts @@ -14,6 +14,7 @@ export interface GraphiteQuery extends DataQuery { queryType?: string; textEditor?: boolean; target?: string; + targetFull?: string; tags?: string[]; fromAnnotations?: boolean; } diff --git a/public/app/plugins/datasource/influxdb/components/editor/config/InfluxSQLConfig.tsx b/public/app/plugins/datasource/influxdb/components/editor/config/InfluxSQLConfig.tsx index b0134b21cc2ec..defdb0b0eabaa 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/config/InfluxSQLConfig.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/config/InfluxSQLConfig.tsx @@ -8,7 +8,7 @@ import { onUpdateDatasourceSecureJsonDataOption, updateDatasourcePluginResetOption, } from '@grafana/data'; -import { Field, InlineLabel, Input, SecretInput, useStyles2 } from '@grafana/ui'; +import { Field, InlineLabel, InlineSwitch, Input, SecretInput, useStyles2 } from '@grafana/ui'; import { InfluxOptions, InfluxSecureJsonData } from '../../../types'; @@ -57,6 +57,25 @@ export const InfluxSqlConfig = (props: Props) => { isConfigured={Boolean(secureJsonFields && secureJsonFields.token)} /> </Field> + <Field + horizontal + label={<InlineLabel width={WIDTH_SHORT}>Insecure Connection</InlineLabel>} + className={styles.horizontalField} + > + <InlineSwitch + id={`${htmlPrefix}-insecure-grpc`} + value={jsonData.insecureGrpc ?? false} + onChange={(event) => { + onOptionsChange({ + ...options, + jsonData: { + ...jsonData, + insecureGrpc: event.currentTarget.checked, + }, + }); + }} + /> + </Field> </div> ); }; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/fsql/FSQLEditor.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/fsql/FSQLEditor.tsx index ab16fdccbb058..90c08ff26ab19 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/fsql/FSQLEditor.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/fsql/FSQLEditor.tsx @@ -2,11 +2,9 @@ import { css, cx } from '@emotion/css'; import React, { PureComponent } from 'react'; import { GrafanaTheme2 } from '@grafana/data/src'; +import { SQLQuery, SqlQueryEditor, applyQueryDefaults } from '@grafana/sql'; import { InlineFormLabel, LinkButton, Themeable2, withTheme2 } from '@grafana/ui/src'; -import { SQLQuery } from '../../../../../../../features/plugins/sql'; -import { SqlQueryEditor } from '../../../../../../../features/plugins/sql/components/QueryEditor'; -import { applyQueryDefaults } from '../../../../../../../features/plugins/sql/defaults'; import InfluxDatasource from '../../../../datasource'; import { FlightSQLDatasource } from '../../../../fsql/datasource.flightsql'; import { InfluxQuery } from '../../../../types'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx index c300ce9c5dcad..374da6b752ae4 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx @@ -134,7 +134,7 @@ export const VisualInfluxQLEditor = (props: Props): JSX.Element => { getTagKeyOptions={getMemoizedTagKeys} getTagValueOptions={(key) => withTemplateVariableOptions( - allTagKeys.then((keys) => getTagValues(datasource, filterTags(query.tags ?? [], keys), key)), + allTagKeys.then((keys) => getTagValues(datasource, filterTags(query.tags ?? [], keys), key, measurement)), wrapRegex ) } diff --git a/public/app/plugins/datasource/influxdb/datasource.test.ts b/public/app/plugins/datasource/influxdb/datasource.test.ts index 405f6b41be5c7..01f1ab2f7e655 100644 --- a/public/app/plugins/datasource/influxdb/datasource.test.ts +++ b/public/app/plugins/datasource/influxdb/datasource.test.ts @@ -5,9 +5,10 @@ import { BackendSrvRequest } from '@grafana/runtime/'; import config from 'app/core/config'; import { TemplateSrv } from '../../../features/templating/template_srv'; +import { queryBuilder } from '../../../features/variables/shared/testing/builders'; import { BROWSER_MODE_DISABLED_MESSAGE } from './constants'; -import InfluxDatasource, { influxSpecialRegexEscape } from './datasource'; +import InfluxDatasource from './datasource'; import { getMockDSInstanceSettings, getMockInfluxDS, @@ -258,6 +259,7 @@ describe('InfluxDataSource Frontend Mode', () => { const text2 = 'interpolationText2'; const textWithoutFormatRegex = 'interpolationText,interpolationText2'; const textWithFormatRegex = 'interpolationText,interpolationText2'; + const justText = 'interpolationText'; const variableMap: Record<string, string> = { $interpolationVar: text, $interpolationVar2: text2, @@ -287,14 +289,14 @@ describe('InfluxDataSource Frontend Mode', () => { function influxChecks(query: InfluxQuery) { expect(templateSrv.replace).toBeCalledTimes(12); expect(query.alias).toBe(text); - expect(query.measurement).toBe(textWithFormatRegex); - expect(query.policy).toBe(textWithFormatRegex); - expect(query.limit).toBe(textWithFormatRegex); - expect(query.slimit).toBe(textWithFormatRegex); + expect(query.measurement).toBe(justText); + expect(query.policy).toBe(justText); + expect(query.limit).toBe(justText); + expect(query.slimit).toBe(justText); expect(query.tz).toBe(text); expect(query.tags![0].value).toBe(textWithFormatRegex); - expect(query.groupBy![0].params![0]).toBe(textWithFormatRegex); - expect(query.select![0][0].params![0]).toBe(textWithFormatRegex); + expect(query.groupBy![0].params![0]).toBe(justText); + expect(query.select![0][0].params![0]).toBe(justText); expect(query.adhocFilters?.[0].key).toBe(adhocFilters[0].key); } @@ -342,7 +344,12 @@ describe('InfluxDataSource Frontend Mode', () => { }); describe('variable interpolation with chained variables with frontend mode', () => { - const mockTemplateService = new TemplateSrv(); + const variablesMock = [queryBuilder().withId('var1').withName('var1').withCurrent('var1').build()]; + const mockTemplateService = new TemplateSrv({ + getVariables: () => variablesMock, + getVariableWithName: (name: string) => variablesMock.filter((v) => v.name === name)[0], + getFilteredVariables: jest.fn(), + }); mockTemplateService.getAdhocFilters = jest.fn((_: string) => []); let ds = getMockInfluxDS(getMockDSInstanceSettings(), mockTemplateService); const fetchMockImpl = () => @@ -410,18 +417,95 @@ describe('InfluxDataSource Frontend Mode', () => { }); }); - describe('influxSpecialRegexEscape', () => { - it('should escape the dot properly', () => { - const value = 'value.with-dot'; - const expectation = `value\.with-dot`; - const result = influxSpecialRegexEscape(value); + describe('interpolateQueryExpr', () => { + let ds = getMockInfluxDS(getMockDSInstanceSettings(), new TemplateSrv()); + it('should return the value as it is', () => { + const value = 'normalValue'; + const variableMock = queryBuilder().withId('tempVar').withName('tempVar').withMulti(false).build(); + const result = ds.interpolateQueryExpr(value, variableMock, 'my query $tempVar'); + const expectation = 'normalValue'; + expect(result).toBe(expectation); + }); + + it('should return the escaped value if the value wrapped in regex', () => { + const value = '/special/path'; + const variableMock = queryBuilder().withId('tempVar').withName('tempVar').withMulti(false).build(); + const result = ds.interpolateQueryExpr(value, variableMock, 'select that where path = /$tempVar/'); + const expectation = `\\/special\\/path`; + expect(result).toBe(expectation); + }); + + it('should return the escaped value if the value wrapped in regex 2', () => { + const value = '/special/path'; + const variableMock = queryBuilder().withId('tempVar').withName('tempVar').withMulti(false).build(); + const result = ds.interpolateQueryExpr(value, variableMock, 'select that where path = /^$tempVar$/'); + const expectation = `\\/special\\/path`; + expect(result).toBe(expectation); + }); + + it('should return the escaped value if the value wrapped in regex 3', () => { + const value = ['env', 'env2', 'env3']; + const variableMock = queryBuilder() + .withId('tempVar') + .withName('tempVar') + .withMulti(false) + .withIncludeAll(true) + .build(); + const result = ds.interpolateQueryExpr(value, variableMock, 'select from /^($tempVar)$/'); + const expectation = `(env|env2|env3)`; + expect(result).toBe(expectation); + }); + + it('should **not** return the escaped value if the value **is not** wrapped in regex', () => { + const value = '/special/path'; + const variableMock = queryBuilder().withId('tempVar').withName('tempVar').withMulti(false).build(); + const result = ds.interpolateQueryExpr(value, variableMock, `select that where path = '$tempVar'`); + const expectation = `/special/path`; + expect(result).toBe(expectation); + }); + + it('should **not** return the escaped value if the value **is not** wrapped in regex 2', () => { + const value = '12.2'; + const variableMock = queryBuilder().withId('tempVar').withName('tempVar').withMulti(false).build(); + const result = ds.interpolateQueryExpr(value, variableMock, `select that where path = '$tempVar'`); + const expectation = `12.2`; + expect(result).toBe(expectation); + }); + + it('should escape the value **always** if the variable is a multi-value variable', () => { + const value = [`/special/path`, `/some/other/path`]; + const variableMock = queryBuilder().withId('tempVar').withName('tempVar').withMulti().build(); + const result = ds.interpolateQueryExpr(value, variableMock, `select that where path = '$tempVar'`); + const expectation = `(\\/special\\/path|\\/some\\/other\\/path)`; expect(result).toBe(expectation); }); - it('should escape the url properly', () => { - const value = 'https://aaaa-aa-aaa.bbb.ccc.ddd:8443/jolokia'; - const expectation = `https:\/\/aaaa-aa-aaa\.bbb\.ccc\.ddd:8443\/jolokia`; - const result = influxSpecialRegexEscape(value); + it('should escape and join with the pipe even the variable is not multi-value', () => { + const variableMock = queryBuilder() + .withId('tempVar') + .withName('tempVar') + .withCurrent('All', '$__all') + .withMulti(false) + .withAllValue('') + .withIncludeAll() + .withOptions( + { + text: 'All', + value: '$__all', + }, + { + text: `/special/path`, + value: `/special/path`, + }, + { + text: `/some/other/path`, + value: `/some/other/path`, + } + ) + .build(); + const value = [`/special/path`, `/some/other/path`]; + const result = ds.interpolateQueryExpr(value, variableMock, `select that where path = /$tempVar/`); + const expectation = `(\\/special\\/path|\\/some\\/other\\/path)`; expect(result).toBe(expectation); }); }); diff --git a/public/app/plugins/datasource/influxdb/datasource.ts b/public/app/plugins/datasource/influxdb/datasource.ts index 3b80766e82c47..d5be06528aac6 100644 --- a/public/app/plugins/datasource/influxdb/datasource.ts +++ b/public/app/plugins/datasource/influxdb/datasource.ts @@ -17,6 +17,7 @@ import { FieldType, MetricFindValue, QueryResultMeta, + QueryVariableModel, RawTimeRange, ScopedVars, TIME_SERIES_TIME_FIELD_NAME, @@ -31,12 +32,10 @@ import { frameToMetricFindValue, getBackendSrv, } from '@grafana/runtime'; -import { CustomFormatterVariable } from '@grafana/scenes'; +import { QueryFormat, SQLQuery } from '@grafana/sql'; import config from 'app/core/config'; import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; -import { QueryFormat, SQLQuery } from '../../../features/plugins/sql'; - import { AnnotationEditor } from './components/editor/annotation/AnnotationEditor'; import { FluxQueryEditor } from './components/editor/query/flux/FluxQueryEditor'; import { BROWSER_MODE_DISABLED_MESSAGE } from './constants'; @@ -214,7 +213,12 @@ export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery, return { ...query, datasource: this.getRef(), - query: this.templateSrv.replace(query.query ?? '', scopedVars, this.interpolateQueryExpr), // The raw query text + query: this.templateSrv.replace( + query.query ?? '', + scopedVars, + (value: string | string[] = [], variable: QueryVariableModel) => + this.interpolateQueryExpr(value, variable, query.query) + ), // The raw query text }; } @@ -232,9 +236,7 @@ export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery, expandedQuery.groupBy = query.groupBy.map((groupBy) => { return { ...groupBy, - params: groupBy.params?.map((param) => { - return this.templateSrv.replace(param.toString(), undefined, this.interpolateQueryExpr); - }), + params: groupBy.params?.map((param) => this.templateSrv.replace(param.toString(), undefined)), }; }); } @@ -244,9 +246,7 @@ export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery, return selects.map((select) => { return { ...select, - params: select.params?.map((param) => { - return this.templateSrv.replace(param.toString(), undefined, this.interpolateQueryExpr); - }), + params: select.params?.map((param) => this.templateSrv.replace(param.toString(), undefined)), }; }); }); @@ -256,8 +256,13 @@ export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery, expandedQuery.tags = query.tags.map((tag) => { return { ...tag, - key: this.templateSrv.replace(tag.key, scopedVars, this.interpolateQueryExpr), - value: this.templateSrv.replace(tag.value, scopedVars, this.interpolateQueryExpr), + key: this.templateSrv.replace(tag.key, scopedVars), + value: this.templateSrv.replace( + tag.value ?? '', + scopedVars, + (value: string | string[] = [], variable: QueryVariableModel) => + this.interpolateQueryExpr(value, variable, tag.value) + ), }; }); } @@ -265,34 +270,66 @@ export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery, return { ...expandedQuery, adhocFilters: this.templateSrv.getAdhocFilters(this.name) ?? [], - query: this.templateSrv.replace(query.query ?? '', scopedVars, this.interpolateQueryExpr), // The raw query text - rawSql: this.templateSrv.replace(query.rawSql ?? '', scopedVars, this.interpolateQueryExpr), // The raw query text + query: this.templateSrv.replace( + query.query ?? '', + scopedVars, + (value: string | string[] = [], variable: QueryVariableModel) => + this.interpolateQueryExpr(value, variable, query.query) + ), // The raw sql query text + rawSql: this.templateSrv.replace( + query.rawSql ?? '', + scopedVars, + (value: string | string[] = [], variable: QueryVariableModel) => + this.interpolateQueryExpr(value, variable, query.rawSql) + ), // The raw sql query text alias: this.templateSrv.replace(query.alias ?? '', scopedVars), - limit: this.templateSrv.replace(query.limit?.toString() ?? '', scopedVars, this.interpolateQueryExpr), - measurement: this.templateSrv.replace(query.measurement ?? '', scopedVars, this.interpolateQueryExpr), - policy: this.templateSrv.replace(query.policy ?? '', scopedVars, this.interpolateQueryExpr), - slimit: this.templateSrv.replace(query.slimit?.toString() ?? '', scopedVars, this.interpolateQueryExpr), + limit: this.templateSrv.replace(query.limit?.toString() ?? '', scopedVars), + measurement: this.templateSrv.replace(query.measurement ?? '', scopedVars), + policy: this.templateSrv.replace(query.policy ?? '', scopedVars), + slimit: this.templateSrv.replace(query.slimit?.toString() ?? '', scopedVars), tz: this.templateSrv.replace(query.tz ?? '', scopedVars), }; } - interpolateQueryExpr(value: string | string[] = [], variable: Partial<CustomFormatterVariable>) { - // if no multi or include all do not regexEscape - if (!variable.multi && !variable.includeAll) { - return influxRegularEscape(value); + interpolateQueryExpr(value: string | string[] = [], variable: QueryVariableModel, query?: string) { + // If there is no query just return the value directly + if (!query) { + return value; } - if (typeof value === 'string') { - return influxSpecialRegexEscape(value); + // If template variable is a multi-value variable + // we always want to deal with special chars. + if (variable.multi) { + if (typeof value === 'string') { + // Check the value is a number. If not run to escape special characters + if (isNaN(parseFloat(value))) { + return escapeRegex(value); + } + return value; + } + + // If the value is a string array first escape them then join them with pipe + // then put inside parenthesis. + return `(${value.map((v) => escapeRegex(v)).join('|')})`; } - const escapedValues = value.map((val) => influxSpecialRegexEscape(val)); + // If the variable is not a multi-value variable + // we want to see how it's been used. If it is used in a regex expression + // we escape it. Otherwise, we return it directly. + // regex below checks if the variable inside /^...$/ (^ and $ is optional) + // i.e. /^$myVar$/ or /$myVar/ or /^($myVar)$/ + const regex = new RegExp(`\\/(?:\\^)?(.*)(\\$${variable.name})(.*)(?:\\$)?\\/`, 'gm'); + if (regex.test(query)) { + if (typeof value === 'string') { + return escapeRegex(value); + } - if (escapedValues.length === 1) { - return escapedValues[0]; + // If the value is a string array first escape them then join them with pipe + // then put inside parenthesis. + return `(${value.map((v) => escapeRegex(v)).join('|')})`; } - return escapedValues.join('|'); + return value; } async runMetadataQuery(target: InfluxQuery): Promise<MetricFindValue[]> { @@ -323,7 +360,11 @@ export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery, ).then(this.toMetricFindValue); } - const interpolated = this.templateSrv.replace(query, options?.scopedVars, this.interpolateQueryExpr); + const interpolated = this.templateSrv.replace( + query, + options?.scopedVars, + (value: string | string[] = [], variable: QueryVariableModel) => this.interpolateQueryExpr(value, variable, query) + ); return lastValueFrom(this._seriesQuery(interpolated, options)).then((resp) => { return this.responseParser.parse(query, resp); @@ -340,7 +381,7 @@ export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery, // By implementing getTagKeys and getTagValues we add ad-hoc filters functionality // Used in public/app/features/variables/adhoc/picker/AdHocFilterKey.tsx::fetchFilterKeys - getTagKeys(options?: DataSourceGetTagKeysOptions) { + getTagKeys(options?: DataSourceGetTagKeysOptions<InfluxQuery>) { const query = buildMetadataQuery({ type: 'TAG_KEYS', templateService: this.templateSrv, @@ -669,7 +710,12 @@ export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery, const target: InfluxQuery = { refId: 'metricFindQuery', datasource: this.getRef(), - query: this.templateSrv.replace(annotation.query, undefined, this.interpolateQueryExpr), + query: this.templateSrv.replace( + annotation.query, + undefined, + (value: string | string[] = [], variable: QueryVariableModel) => + this.interpolateQueryExpr(value, variable, annotation.query) + ), rawQuery: true, }; @@ -697,7 +743,9 @@ export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery, const timeFilter = this.getTimeFilter({ rangeRaw: options.range.raw, timezone: options.timezone }); let query = annotation.query.replace('$timeFilter', timeFilter); - query = this.templateSrv.replace(query, undefined, this.interpolateQueryExpr); + query = this.templateSrv.replace(query, undefined, (value: string | string[] = [], variable: QueryVariableModel) => + this.interpolateQueryExpr(value, variable, query) + ); return lastValueFrom(this._seriesQuery(query, options)).then((data) => { if (!data || !data.results || !data.results[0]) { @@ -781,23 +829,3 @@ function timeSeriesToDataFrame(timeSeries: TimeSeries): DataFrame { length: values.length, }; } - -export function influxRegularEscape(value: string | string[]) { - if (typeof value === 'string') { - // Check the value is a number. If not run to escape special characters - if (isNaN(parseFloat(value))) { - return escapeRegex(value); - } - } - - return value; -} - -export function influxSpecialRegexEscape(value: string | string[]) { - if (typeof value !== 'string') { - return value; - } - value = value.replace(/\\/g, '\\\\\\\\'); - value = value.replace(/[$^*{}\[\]\'+?.()|]/g, '$&'); - return value; -} diff --git a/public/app/plugins/datasource/influxdb/datasource_backend_mode.test.ts b/public/app/plugins/datasource/influxdb/datasource_backend_mode.test.ts index 5cad53c98ae25..5e960f4888bd4 100644 --- a/public/app/plugins/datasource/influxdb/datasource_backend_mode.test.ts +++ b/public/app/plugins/datasource/influxdb/datasource_backend_mode.test.ts @@ -5,6 +5,7 @@ import { FetchResponse } from '@grafana/runtime/src'; import config from 'app/core/config'; import { TemplateSrv } from '../../../features/templating/template_srv'; +import { queryBuilder } from '../../../features/variables/shared/testing/builders'; import InfluxDatasource from './datasource'; import { @@ -149,6 +150,7 @@ describe('InfluxDataSource Backend Mode', () => { const text2 = 'interpolationText2'; const textWithoutFormatRegex = 'interpolationText,interpolationText2'; const textWithFormatRegex = 'interpolationText,interpolationText2'; + const justText = 'interpolationText'; const variableMap: Record<string, string> = { $interpolationVar: text, $interpolationVar2: text2, @@ -178,14 +180,14 @@ describe('InfluxDataSource Backend Mode', () => { function influxChecks(query: InfluxQuery) { expect(templateSrv.replace).toBeCalledTimes(12); expect(query.alias).toBe(text); - expect(query.measurement).toBe(textWithFormatRegex); - expect(query.policy).toBe(textWithFormatRegex); - expect(query.limit).toBe(textWithFormatRegex); - expect(query.slimit).toBe(textWithFormatRegex); + expect(query.measurement).toBe(justText); + expect(query.policy).toBe(justText); + expect(query.limit).toBe(justText); + expect(query.slimit).toBe(justText); expect(query.tz).toBe(text); expect(query.tags![0].value).toBe(textWithFormatRegex); - expect(query.groupBy![0].params![0]).toBe(textWithFormatRegex); - expect(query.select![0][0].params![0]).toBe(textWithFormatRegex); + expect(query.groupBy![0].params![0]).toBe(justText); + expect(query.select![0][0].params![0]).toBe(justText); expect(query.adhocFilters?.[0].key).toBe(adhocFilters[0].key); } @@ -216,7 +218,12 @@ describe('InfluxDataSource Backend Mode', () => { }); describe('variable interpolation with chained variables with backend mode', () => { - const mockTemplateService = new TemplateSrv(); + const variablesMock = [queryBuilder().withId('var1').withName('var1').withCurrent('var1').build()]; + const mockTemplateService = new TemplateSrv({ + getVariables: () => variablesMock, + getVariableWithName: (name: string) => variablesMock.filter((v) => v.name === name)[0], + getFilteredVariables: jest.fn(), + }); mockTemplateService.getAdhocFilters = jest.fn((_: string) => []); let ds = getMockInfluxDS(getMockDSInstanceSettings(), mockTemplateService); const fetchMockImpl = () => @@ -263,6 +270,7 @@ describe('InfluxDataSource Backend Mode', () => { }, }); const qe = `SHOW TAG VALUES WITH KEY = "agent_url" WHERE agent_url =~ /^https:\\/\\/aaaa-aa-aaa\\.bbb\\.ccc\\.ddd:8443\\/ggggg$/`; + expect(fetchMock).toHaveBeenCalled(); const qData = fetchMock.mock.calls[0][0].data.queries[0].query; expect(qData).toBe(qe); }); @@ -285,6 +293,22 @@ describe('InfluxDataSource Backend Mode', () => { const qData = fetchMock.mock.calls[0][0].data.queries[0].query; expect(qData).toBe(qe); }); + + it('should interpolate variable inside a regex pattern', () => { + const query: InfluxQuery = { + refId: 'A', + tags: [ + { + key: 'key', + operator: '=~', + value: '/^.*-$var1$/', + }, + ], + }; + const res = ds.applyVariables(query, {}); + const expected = `/^.*-var1$/`; + expect(res.tags?.[0].value).toEqual(expected); + }); }); describe('metric find query', () => { diff --git a/public/app/plugins/datasource/influxdb/datasource_sql.test.ts b/public/app/plugins/datasource/influxdb/datasource_sql.test.ts index fa0af1ed35dec..705b370a26423 100644 --- a/public/app/plugins/datasource/influxdb/datasource_sql.test.ts +++ b/public/app/plugins/datasource/influxdb/datasource_sql.test.ts @@ -1,9 +1,8 @@ import { lastValueFrom } from 'rxjs'; +import { SQLQuery } from '@grafana/sql'; import config from 'app/core/config'; -import { SQLQuery } from '../../../features/plugins/sql'; - import InfluxDatasource from './datasource'; import { getMockDSInstanceSettings, diff --git a/public/app/plugins/datasource/influxdb/fsql/datasource.flightsql.ts b/public/app/plugins/datasource/influxdb/fsql/datasource.flightsql.ts index ee9ee95c28e20..18c881e2dbb0c 100644 --- a/public/app/plugins/datasource/influxdb/fsql/datasource.flightsql.ts +++ b/public/app/plugins/datasource/influxdb/fsql/datasource.flightsql.ts @@ -1,14 +1,12 @@ import { DataSourceInstanceSettings, TimeRange } from '@grafana/data'; import { CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/experimental'; import { getTemplateSrv, TemplateSrv } from '@grafana/runtime'; -import { SqlDatasource } from 'app/features/plugins/sql/datasource/SqlDatasource'; -import { DB, SQLQuery } from 'app/features/plugins/sql/types'; -import { formatSQL } from 'app/features/plugins/sql/utils/formatSQL'; +import { DB, formatSQL, SqlDatasource, SQLQuery } from '@grafana/sql'; import { mapFieldsToTypes } from './fields'; import { buildColumnQuery, buildTableQuery } from './flightsqlMetaQuery'; import { getSqlCompletionProvider } from './sqlCompletionProvider'; -import { quoteLiteral, quoteIdentifierIfNecessary, toRawSql } from './sqlUtil'; +import { quoteIdentifierIfNecessary, quoteLiteral, toRawSql } from './sqlUtil'; import { FlightSQLOptions } from './types'; export class FlightSQLDatasource extends SqlDatasource { diff --git a/public/app/plugins/datasource/influxdb/fsql/fields.ts b/public/app/plugins/datasource/influxdb/fsql/fields.ts index 6177234a3db3e..1cc2609f67620 100644 --- a/public/app/plugins/datasource/influxdb/fsql/fields.ts +++ b/public/app/plugins/datasource/influxdb/fsql/fields.ts @@ -1,4 +1,4 @@ -import { RAQBFieldTypes, SQLSelectableValue } from 'app/features/plugins/sql/types'; +import { RAQBFieldTypes, SQLSelectableValue } from '@grafana/sql'; export function mapFieldsToTypes(columns: SQLSelectableValue[]) { const fields: SQLSelectableValue[] = []; diff --git a/public/app/plugins/datasource/influxdb/fsql/sqlUtil.test.ts b/public/app/plugins/datasource/influxdb/fsql/sqlUtil.test.ts index d05aad13d4d50..4864004b8ec83 100644 --- a/public/app/plugins/datasource/influxdb/fsql/sqlUtil.test.ts +++ b/public/app/plugins/datasource/influxdb/fsql/sqlUtil.test.ts @@ -1,12 +1,10 @@ -import { SQLQuery } from 'app/features/plugins/sql/types'; - -import { QueryEditorExpressionType } from '../../../../features/plugins/sql/expressions'; +import { QueryEditorExpressionType, SQLQuery } from '@grafana/sql'; import { toRawSql } from './sqlUtil'; describe('toRawSql', () => { it('should render sql properly', () => { - const expected = 'SELECT host FROM iox.value1 WHERE time >= $__timeFrom AND time <= $__timeTo LIMIT 50'; + const expected = 'SELECT "host" FROM "value1" WHERE "time" >= $__timeFrom AND "time" <= $__timeTo LIMIT 50'; const testQuery: SQLQuery = { refId: 'A', sql: { @@ -29,4 +27,55 @@ describe('toRawSql', () => { const result = toRawSql(testQuery); expect(result).toEqual(expected); }); + + it('should wrap the identifiers with quote', () => { + const expected = 'SELECT "host" FROM "TestValue" WHERE "time" >= $__timeFrom AND "time" <= $__timeTo LIMIT 50'; + const testQuery: SQLQuery = { + refId: 'A', + sql: { + limit: 50, + columns: [ + { + parameters: [ + { + name: 'host', + type: QueryEditorExpressionType.FunctionParameter, + }, + ], + type: QueryEditorExpressionType.Function, + }, + ], + }, + dataset: 'iox', + table: 'TestValue', + }; + const result = toRawSql(testQuery); + expect(result).toEqual(expected); + }); + + it('should wrap filters in where', () => { + const expected = `SELECT "host" FROM "TestValue" WHERE "time" >= $__timeFrom AND "time" <= $__timeTo AND ("sensor_id" = '12' AND "sensor_id" = '23') LIMIT 50`; + const testQuery: SQLQuery = { + refId: 'A', + sql: { + limit: 50, + columns: [ + { + parameters: [ + { + name: 'host', + type: QueryEditorExpressionType.FunctionParameter, + }, + ], + type: QueryEditorExpressionType.Function, + }, + ], + whereString: `(sensor_id = '12' AND sensor_id = '23')`, + }, + dataset: 'iox', + table: 'TestValue', + }; + const result = toRawSql(testQuery); + expect(result).toEqual(expected); + }); }); diff --git a/public/app/plugins/datasource/influxdb/fsql/sqlUtil.ts b/public/app/plugins/datasource/influxdb/fsql/sqlUtil.ts index f4b8eea30bba7..41317454a959d 100644 --- a/public/app/plugins/datasource/influxdb/fsql/sqlUtil.ts +++ b/public/app/plugins/datasource/influxdb/fsql/sqlUtil.ts @@ -1,7 +1,6 @@ import { isEmpty } from 'lodash'; -import { SQLQuery } from 'app/features/plugins/sql/types'; -import { createSelectClause, haveColumns } from 'app/features/plugins/sql/utils/sql.utils'; +import { createSelectClause, haveColumns, SQLQuery } from '@grafana/sql'; // remove identifier quoting from identifier to use in metadata queries export function unquoteIdentifier(value: string) { @@ -18,7 +17,7 @@ export function quoteLiteral(value: string) { return "'" + value.replace(/'/g, "''") + "'"; } -export function toRawSql({ sql, dataset, table }: SQLQuery): string { +export function toRawSql({ sql, table }: SQLQuery): string { let rawQuery = ''; // Return early with empty string if there is no sql column @@ -26,25 +25,33 @@ export function toRawSql({ sql, dataset, table }: SQLQuery): string { return rawQuery; } - rawQuery += createSelectClause(sql.columns); + // wrapping the column name with quotes + const sc = sql.columns.map((c) => ({ ...c, parameters: c.parameters?.map((p) => ({ ...p, name: `"${p.name}"` })) })); + rawQuery += createSelectClause(sc); - if (dataset && table) { - rawQuery += `FROM ${dataset}.${table} `; + if (table) { + rawQuery += `FROM "${table}" `; } // $__timeFrom and $__timeTo will be interpolated on the backend - rawQuery += `WHERE time >= $__timeFrom AND time <= $__timeTo `; + rawQuery += `WHERE "time" >= $__timeFrom AND "time" <= $__timeTo `; if (sql.whereString) { - rawQuery += `AND ${sql.whereString} `; + // whereString is generated by the react-awesome-query-builder + // we use SQLWhereRow as a common component + // in order to not mess with common component here we just modify the string + const wherePattern = new RegExp('(\\s?)([^\\(]\\S+)(\\s?=)', 'g'); + const subst = `$1"$2"$3`; + const whereString = sql.whereString.replace(wherePattern, subst); + rawQuery += `AND ${whereString} `; } if (sql.groupBy?.[0]?.property.name) { - const groupBy = sql.groupBy.map((g) => g.property.name).filter((g) => !isEmpty(g)); + const groupBy = sql.groupBy.map((g) => `"${g.property.name}"`).filter((g) => !isEmpty(g)); rawQuery += `GROUP BY ${groupBy.join(', ')} `; } if (sql.orderBy?.property.name) { - rawQuery += `ORDER BY ${sql.orderBy.property.name} `; + rawQuery += `ORDER BY "${sql.orderBy.property.name}" `; } if (sql.orderBy?.property.name && sql.orderByDirection) { diff --git a/public/app/plugins/datasource/influxdb/fsql/types.ts b/public/app/plugins/datasource/influxdb/fsql/types.ts index 805fa085eb31a..787ed135bb029 100644 --- a/public/app/plugins/datasource/influxdb/fsql/types.ts +++ b/public/app/plugins/datasource/influxdb/fsql/types.ts @@ -1,4 +1,4 @@ -import { SQLOptions, SQLQuery } from 'app/features/plugins/sql/types'; +import { SQLOptions, SQLQuery } from '@grafana/sql'; export interface FlightSQLOptions extends SQLOptions { allowCleartextPasswords?: boolean; diff --git a/public/app/plugins/datasource/influxdb/mocks.ts b/public/app/plugins/datasource/influxdb/mocks.ts index 22b7b8be0195a..8aee8e79b5c6c 100644 --- a/public/app/plugins/datasource/influxdb/mocks.ts +++ b/public/app/plugins/datasource/influxdb/mocks.ts @@ -8,16 +8,16 @@ import { FieldType, PluginType, ScopedVars, -} from '@grafana/data/src'; +} from '@grafana/data'; import { BackendDataSourceResponse, FetchResponse, getBackendSrv, setBackendSrv, VariableInterpolation, -} from '@grafana/runtime/src'; +} from '@grafana/runtime'; +import { SQLQuery } from '@grafana/sql'; -import { SQLQuery } from '../../../features/plugins/sql'; import { TemplateSrv } from '../../../features/templating/template_srv'; import InfluxDatasource from './datasource'; diff --git a/public/app/plugins/datasource/influxdb/types.ts b/public/app/plugins/datasource/influxdb/types.ts index b9e257e3fe995..d81cfd0cc1530 100644 --- a/public/app/plugins/datasource/influxdb/types.ts +++ b/public/app/plugins/datasource/influxdb/types.ts @@ -23,6 +23,7 @@ export interface InfluxOptions extends DataSourceJsonData { // With SQL metadata?: Array<Record<string, string>>; + insecureGrpc?: boolean; } /** diff --git a/public/app/plugins/datasource/jaeger/components/QueryEditor.tsx b/public/app/plugins/datasource/jaeger/components/QueryEditor.tsx index 6b0a7c3a9a457..22c9835229687 100644 --- a/public/app/plugins/datasource/jaeger/components/QueryEditor.tsx +++ b/public/app/plugins/datasource/jaeger/components/QueryEditor.tsx @@ -36,6 +36,8 @@ export function QueryEditor({ datasource, query, onChange, onRunQuery }: Props) switch (query.queryType) { case 'search': return <SearchForm datasource={datasource} query={query} onChange={onChange} />; + case 'dependencyGraph': + return null; default: return ( <InlineFieldRow> @@ -79,6 +81,7 @@ export function QueryEditor({ datasource, query, onChange, onRunQuery }: Props) options={[ { value: 'search', label: 'Search' }, { value: undefined, label: 'TraceID' }, + { value: 'dependencyGraph', label: 'Dependency graph' }, ]} value={query.queryType} onChange={(v) => @@ -108,7 +111,7 @@ export function QueryEditor({ datasource, query, onChange, onRunQuery }: Props) } const getStyles = () => ({ - container: css` - width: 100%; - `, + container: css({ + width: '100%', + }), }); diff --git a/public/app/plugins/datasource/jaeger/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/jaeger/configuration/ConfigEditor.tsx index 50f3d8d5e8df3..0eb57f2a38314 100644 --- a/public/app/plugins/datasource/jaeger/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/jaeger/configuration/ConfigEditor.tsx @@ -3,13 +3,9 @@ import React from 'react'; import { DataSourcePluginOptionsEditorProps, GrafanaTheme2 } from '@grafana/data'; import { ConfigSection, DataSourceDescription } from '@grafana/experimental'; +import { NodeGraphSection, SpanBarSection, TraceToLogsSection, TraceToMetricsSection } from '@grafana/o11y-ds-frontend'; import { config } from '@grafana/runtime'; -import { DataSourceHttpSettings, useStyles2 } from '@grafana/ui'; -import { Divider } from 'app/core/components/Divider'; -import { NodeGraphSection } from 'app/core/components/NodeGraphSettings'; -import { TraceToLogsSection } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; -import { TraceToMetricsSection } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; -import { SpanBarSection } from 'app/features/explore/TraceView/components/settings/SpanBarSettings'; +import { DataSourceHttpSettings, useStyles2, Divider, Stack } from '@grafana/ui'; import { TraceIdTimeParams } from './TraceIdTimeParams'; @@ -26,7 +22,7 @@ export const ConfigEditor = ({ options, onOptionsChange }: Props) => { hasRequiredFields={false} /> - <Divider /> + <Divider spacing={4} /> <DataSourceHttpSettings defaultUrl="http://localhost:16686" @@ -37,15 +33,10 @@ export const ConfigEditor = ({ options, onOptionsChange }: Props) => { /> <TraceToLogsSection options={options} onOptionsChange={onOptionsChange} /> + <Divider spacing={4} /> - <Divider /> - - {config.featureToggles.traceToMetrics ? ( - <> - <TraceToMetricsSection options={options} onOptionsChange={onOptionsChange} /> - <Divider /> - </> - ) : null} + <TraceToMetricsSection options={options} onOptionsChange={onOptionsChange} /> + <Divider spacing={4} /> <ConfigSection title="Additional settings" @@ -53,11 +44,11 @@ export const ConfigEditor = ({ options, onOptionsChange }: Props) => { isCollapsible={true} isInitiallyOpen={false} > - <NodeGraphSection options={options} onOptionsChange={onOptionsChange} /> - <Divider hideLine={true} /> - <SpanBarSection options={options} onOptionsChange={onOptionsChange} /> - <Divider hideLine={true} /> - <TraceIdTimeParams options={options} onOptionsChange={onOptionsChange} /> + <Stack gap={5} direction="column"> + <NodeGraphSection options={options} onOptionsChange={onOptionsChange} /> + <SpanBarSection options={options} onOptionsChange={onOptionsChange} /> + <TraceIdTimeParams options={options} onOptionsChange={onOptionsChange} /> + </Stack> </ConfigSection> </div> ); diff --git a/public/app/plugins/datasource/jaeger/datasource.test.ts b/public/app/plugins/datasource/jaeger/datasource.test.ts index 86416be5eef4a..2bf2742a73771 100644 --- a/public/app/plugins/datasource/jaeger/datasource.test.ts +++ b/public/app/plugins/datasource/jaeger/datasource.test.ts @@ -319,6 +319,45 @@ describe('when performing testDataSource', () => { }); }); +describe('Test behavior with unmocked time', () => { + // Tolerance for checking timestamps. + // Using a lower number seems to cause flaky tests. + const numDigits = -4; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('getTimeRange()', async () => { + const ds = new JaegerDatasource(defaultSettings); + const timeRange = ds.getTimeRange(); + const now = Date.now(); + expect(timeRange.end).toBeCloseTo(now * 1000, numDigits); + expect(timeRange.start).toBeCloseTo((now - 6 * 3600 * 1000) * 1000, numDigits); + }); + + it("call for `query()` when `queryType === 'dependencyGraph'`", async () => { + const mock = setupFetchMock({ data: [testResponse] }); + const ds = new JaegerDatasource(defaultSettings); + + ds.query({ ...defaultQuery, targets: [{ queryType: 'dependencyGraph', refId: '1' }] }); + const now = Date.now(); + + const url = mock.mock.calls[0][0].url; + const endTsMatch = url.match(/endTs=(\d+)/); + expect(endTsMatch).not.toBeNull(); + expect(parseInt(endTsMatch![1], 10)).toBeCloseTo(now, numDigits); + + const lookbackMatch = url.match(/lookback=(\d+)/); + expect(lookbackMatch).not.toBeNull(); + expect(parseInt(lookbackMatch![1], 10)).toBeCloseTo(21600000, numDigits); + }); +}); + function setupFetchMock(response: unknown, mock?: ReturnType<typeof backendSrv.fetch>) { const defaultMock = () => mock ?? of(createFetchResponse(response)); diff --git a/public/app/plugins/datasource/jaeger/datasource.ts b/public/app/plugins/datasource/jaeger/datasource.ts index ab5c89e740745..bbdea5ea4e3e7 100644 --- a/public/app/plugins/datasource/jaeger/datasource.ts +++ b/public/app/plugins/datasource/jaeger/datasource.ts @@ -15,13 +15,13 @@ import { ScopedVars, urlUtil, } from '@grafana/data'; +import { NodeGraphOptions, SpanBarOptions } from '@grafana/o11y-ds-frontend'; import { BackendSrvRequest, getBackendSrv, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; -import { NodeGraphOptions } from 'app/core/components/NodeGraphSettings'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; -import { SpanBarOptions } from 'app/features/explore/TraceView/components'; import { ALL_OPERATIONS_KEY } from './components/SearchForm'; import { TraceIdTimeParamsOptions } from './configuration/TraceIdTimeParams'; +import { mapJaegerDependenciesResponse } from './dependencyGraphTransform'; import { createGraphFrames } from './graphTransform'; import { createTableFrame, createTraceFrame } from './responseTransform'; import { JaegerQuery } from './types'; @@ -65,6 +65,14 @@ export class JaegerDatasource extends DataSourceApi<JaegerQuery, JaegerJsonData> return of({ data: [emptyTraceDataFrame] }); } + // Use the internal Jaeger /dependencies API for rendering the dependency graph. + if (target.queryType === 'dependencyGraph') { + const timeRange = this.timeSrv.timeRange(); + const endTs = getTime(timeRange.to, true) / 1000; + const lookback = endTs - getTime(timeRange.from, false) / 1000; + return this._request('/api/dependencies', { endTs, lookback }).pipe(map(mapJaegerDependenciesResponse)); + } + if (target.queryType === 'search' && !this.isSearchFormValid(target)) { return of({ error: { message: 'You must select a service.' }, data: [] }); } diff --git a/public/app/plugins/datasource/jaeger/dependencyGraphTransform.test.ts b/public/app/plugins/datasource/jaeger/dependencyGraphTransform.test.ts new file mode 100644 index 0000000000000..e17621ba45c4f --- /dev/null +++ b/public/app/plugins/datasource/jaeger/dependencyGraphTransform.test.ts @@ -0,0 +1,106 @@ +import { mapJaegerDependenciesResponse } from './dependencyGraphTransform'; + +describe('dependencyGraphTransform', () => { + it('should transform Jaeger dependencies API response', () => { + const data = { + data: [ + { + parent: 'serviceA', + child: 'serviceB', + callCount: 1, + }, + { + parent: 'serviceA', + child: 'serviceC', + callCount: 2, + }, + { + parent: 'serviceB', + child: 'serviceC', + callCount: 3, + }, + ], + total: 0, + limit: 0, + offset: 0, + }; + + const res = mapJaegerDependenciesResponse({ data }); + expect(res).toMatchObject({ + data: [ + { + fields: [ + { + config: {}, + name: 'id', + type: 'string', + values: ['serviceA', 'serviceB', 'serviceC'], + }, + { + config: {}, + name: 'title', + type: 'string', + values: ['serviceA', 'serviceB', 'serviceC'], + }, + ], + meta: { preferredVisualisationType: 'nodeGraph' }, + }, + { + fields: [ + { + config: {}, + name: 'id', + type: 'string', + values: ['serviceA--serviceB', 'serviceA--serviceC', 'serviceB--serviceC'], + }, + { + config: {}, + name: 'target', + type: 'string', + values: ['serviceB', 'serviceC', 'serviceC'], + }, + { + config: {}, + name: 'source', + type: 'string', + values: ['serviceA', 'serviceA', 'serviceB'], + }, + { + config: { displayName: 'Call count' }, + name: 'mainstat', + type: 'string', + values: [1, 2, 3], + }, + ], + meta: { preferredVisualisationType: 'nodeGraph' }, + }, + ], + }); + }); + + it('should transform Jaeger API error', () => { + const data = { + total: 0, + limit: 0, + offset: 0, + errors: [ + { + code: 400, + msg: 'unable to parse param \'endTs\': strconv.ParseInt: parsing "foo": invalid syntax', + }, + ], + }; + + const res = mapJaegerDependenciesResponse({ data }); + + expect(res).toEqual({ + data: [], + errors: [ + { + message: 'unable to parse param \'endTs\': strconv.ParseInt: parsing "foo": invalid syntax', + status: 400, + }, + ], + }); + }); +}); diff --git a/public/app/plugins/datasource/jaeger/dependencyGraphTransform.ts b/public/app/plugins/datasource/jaeger/dependencyGraphTransform.ts new file mode 100644 index 0000000000000..0e8fe6dce462a --- /dev/null +++ b/public/app/plugins/datasource/jaeger/dependencyGraphTransform.ts @@ -0,0 +1,125 @@ +import { + DataFrame, + DataQueryResponse, + FieldType, + MutableDataFrame, + NodeGraphDataFrameFieldNames as Fields, +} from '@grafana/data'; + +import { JaegerServiceDependency } from './types'; + +interface Node { + [Fields.id]: string; + [Fields.title]: string; +} + +interface Edge { + [Fields.id]: string; + [Fields.target]: string; + [Fields.source]: string; + [Fields.mainStat]: number; +} + +/** + * Error schema used by the Jaeger dependencies API. + */ +interface JaegerDependenciesResponseError { + code: number; + msg: string; +} + +interface JaegerDependenciesResponse { + data?: { + errors?: JaegerDependenciesResponseError[]; + data?: JaegerServiceDependency[]; + }; +} + +/** + * Transforms a Jaeger dependencies API response to a Grafana {@link DataQueryResponse}. + * @param response Raw response data from the API proxy. + */ +export function mapJaegerDependenciesResponse(response: JaegerDependenciesResponse): DataQueryResponse { + const errors = response?.data?.errors; + if (errors) { + return { + data: [], + errors: errors.map((e: JaegerDependenciesResponseError) => ({ message: e.msg, status: e.code })), + }; + } + const dependencies = response?.data?.data; + if (dependencies) { + return { + data: convertDependenciesToGraph(dependencies), + }; + } + + return { data: [] }; +} + +/** + * Converts a list of Jaeger service dependencies to a Grafana {@link DataFrame} array suitable for the node graph panel. + * @param dependencies List of Jaeger service dependencies as returned by the Jaeger dependencies API. + */ +function convertDependenciesToGraph(dependencies: JaegerServiceDependency[]): DataFrame[] { + const servicesByName = new Map<string, Node>(); + const edges: Edge[] = []; + + for (const dependency of dependencies) { + addServiceNode(dependency.parent, servicesByName); + addServiceNode(dependency.child, servicesByName); + + edges.push({ + [Fields.id]: dependency.parent + '--' + dependency.child, + [Fields.target]: dependency.child, + [Fields.source]: dependency.parent, + [Fields.mainStat]: dependency.callCount, + }); + } + + const nodesFrame = new MutableDataFrame({ + fields: [ + { name: Fields.id, type: FieldType.string }, + { name: Fields.title, type: FieldType.string }, + ], + meta: { + preferredVisualisationType: 'nodeGraph', + }, + }); + + const edgesFrame = new MutableDataFrame({ + fields: [ + { name: Fields.id, type: FieldType.string }, + { name: Fields.target, type: FieldType.string }, + { name: Fields.source, type: FieldType.string }, + { name: Fields.mainStat, type: FieldType.string, config: { displayName: 'Call count' } }, + ], + meta: { + preferredVisualisationType: 'nodeGraph', + }, + }); + + for (const node of servicesByName.values()) { + nodesFrame.add(node); + } + + for (const edge of edges) { + edgesFrame.add(edge); + } + + return [nodesFrame, edgesFrame]; +} + +/** + * Convenience function to register a service node in the dependency graph. + * @param service Name of the service to register. + * @param servicesByName Map of service nodes keyed name. + */ +function addServiceNode(service: string, servicesByName: Map<string, Node>) { + if (!servicesByName.has(service)) { + servicesByName.set(service, { + [Fields.id]: service, + [Fields.title]: service, + }); + } +} diff --git a/public/app/plugins/datasource/jaeger/graphTransform.ts b/public/app/plugins/datasource/jaeger/graphTransform.ts index 357b36774ba0d..589729a8c4f4e 100644 --- a/public/app/plugins/datasource/jaeger/graphTransform.ts +++ b/public/app/plugins/datasource/jaeger/graphTransform.ts @@ -1,6 +1,5 @@ import { DataFrame, NodeGraphDataFrameFieldNames as Fields } from '@grafana/data'; - -import { getNonOverlappingDuration, getStats, makeFrames, makeSpanMap } from '../../../core/utils/tracing'; +import { getNonOverlappingDuration, getStats, makeFrames, makeSpanMap } from '@grafana/o11y-ds-frontend'; import { Span, TraceResponse } from './types'; diff --git a/public/app/plugins/datasource/jaeger/types.ts b/public/app/plugins/datasource/jaeger/types.ts index 45ee89fbeb08d..fe6778957f627 100644 --- a/public/app/plugins/datasource/jaeger/types.ts +++ b/public/app/plugins/datasource/jaeger/types.ts @@ -63,7 +63,7 @@ export type JaegerQuery = { limit?: number; } & DataQuery; -export type JaegerQueryType = 'search' | 'upload'; +export type JaegerQueryType = 'search' | 'upload' | 'dependencyGraph'; export type JaegerResponse = { data: TraceResponse[]; @@ -72,3 +72,12 @@ export type JaegerResponse = { offset: number; errors?: string[] | null; }; + +/** + * Type definition for service dependencies as returned by the Jaeger dependencies API. + */ +export type JaegerServiceDependency = { + parent: string; + child: string; + callCount: number; +}; diff --git a/public/app/plugins/datasource/loki/LanguageProvider.test.ts b/public/app/plugins/datasource/loki/LanguageProvider.test.ts index 233f18cdc564d..7702a424ee5ab 100644 --- a/public/app/plugins/datasource/loki/LanguageProvider.test.ts +++ b/public/app/plugins/datasource/loki/LanguageProvider.test.ts @@ -2,8 +2,9 @@ import { AbstractLabelOperator, DataFrame, TimeRange, dateTime, getDefaultTimeRa import { config } from '@grafana/runtime'; import LanguageProvider from './LanguageProvider'; +import { createLokiDatasource } from './__mocks__/datasource'; +import { createMetadataRequest } from './__mocks__/metadataRequest'; import { DEFAULT_MAX_LINES_SAMPLE, LokiDatasource } from './datasource'; -import { createLokiDatasource, createMetadataRequest } from './mocks'; import { extractLogParserFromDataFrame, extractLabelKeysFromDataFrame, @@ -13,18 +14,6 @@ import { LabelType, LokiQueryType } from './types'; jest.mock('./responseUtils'); -jest.mock('app/store/store', () => ({ - store: { - getState: jest.fn().mockReturnValue({ - explore: { - left: { - mode: 'Logs', - }, - }, - }), - }, -})); - const mockTimeRange = { from: dateTime(1546372800000), to: dateTime(1546380000000), @@ -149,6 +138,17 @@ describe('Language completion provider', () => { start: 1546372800000, }); }); + + it('should work if request returns undefined', async () => { + const datasource = setup({}); + datasource.getTimeRangeParams = jest + .fn() + .mockImplementation((range: TimeRange) => ({ start: range.from.valueOf(), end: range.to.valueOf() })); + const languageProvider = new LanguageProvider(datasource); + languageProvider.request = jest.fn().mockResolvedValue(undefined); + const series = await languageProvider.fetchSeriesLabels('stream', { timeRange: mockTimeRange }); + expect(series).toEqual({}); + }); }); describe('label values', () => { @@ -331,10 +331,30 @@ describe('fetchLabels', () => { describe('Query imports', () => { const datasource = setup({}); - it('returns empty queries', async () => { - const instance = new LanguageProvider(datasource); - const result = await instance.importFromAbstractQuery({ refId: 'bar', labelMatchers: [] }); - expect(result).toEqual({ refId: 'bar', expr: '', queryType: LokiQueryType.Range }); + describe('importing from abstract query', () => { + it('returns empty queries', async () => { + const instance = new LanguageProvider(datasource); + const result = await instance.importFromAbstractQuery({ refId: 'bar', labelMatchers: [] }); + expect(result).toEqual({ refId: 'bar', expr: '', queryType: LokiQueryType.Range }); + }); + + it('returns valid query', () => { + const instance = new LanguageProvider(datasource); + const result = instance.importFromAbstractQuery({ + refId: 'bar', + labelMatchers: [ + { name: 'label1', operator: AbstractLabelOperator.Equal, value: 'value1' }, + { name: 'label2', operator: AbstractLabelOperator.NotEqual, value: 'value2' }, + { name: 'label3', operator: AbstractLabelOperator.EqualRegEx, value: 'value3' }, + { name: 'label4', operator: AbstractLabelOperator.NotEqualRegEx, value: 'value4' }, + ], + }); + expect(result).toEqual({ + refId: 'bar', + expr: '{label1="value1", label2!="value2", label3=~"value3", label4!~"value4"}', + queryType: LokiQueryType.Range, + }); + }); }); describe('exporting to abstract query', () => { @@ -356,6 +376,42 @@ describe('Query imports', () => { ], }); }); + + it('exports labels in metric query', async () => { + const instance = new LanguageProvider(datasource); + const abstractQuery = instance.exportToAbstractQuery({ + refId: 'bar', + expr: 'rate({label1="value1", label2!="value2"}[5m])', + instant: true, + range: false, + }); + expect(abstractQuery).toMatchObject({ + refId: 'bar', + labelMatchers: [ + { name: 'label1', operator: AbstractLabelOperator.Equal, value: 'value1' }, + { name: 'label2', operator: AbstractLabelOperator.NotEqual, value: 'value2' }, + ], + }); + }); + + it('exports labels in query with multiple stream selectors', async () => { + const instance = new LanguageProvider(datasource); + const abstractQuery = instance.exportToAbstractQuery({ + refId: 'bar', + expr: 'rate({label1="value1", label2!="value2"}[5m]) + rate({label3=~"value3", label4!~"value4"}[5m])', + instant: true, + range: false, + }); + expect(abstractQuery).toMatchObject({ + refId: 'bar', + labelMatchers: [ + { name: 'label1', operator: AbstractLabelOperator.Equal, value: 'value1' }, + { name: 'label2', operator: AbstractLabelOperator.NotEqual, value: 'value2' }, + { name: 'label3', operator: AbstractLabelOperator.EqualRegEx, value: 'value3' }, + { name: 'label4', operator: AbstractLabelOperator.NotEqualRegEx, value: 'value4' }, + ], + }); + }); }); describe('getParserAndLabelKeys()', () => { @@ -371,6 +427,7 @@ describe('Query imports', () => { const extractLogParserFromDataFrameMock = jest.mocked(extractLogParserFromDataFrame); const extractedLabelKeys = ['extracted', 'label']; const structuredMetadataKeys = ['structured', 'metadata']; + const parsedKeys = ['parsed', 'label']; const unwrapLabelKeys = ['unwrap', 'labels']; beforeEach(() => { @@ -379,8 +436,12 @@ describe('Query imports', () => { jest.mocked(extractLabelKeysFromDataFrame).mockImplementation((_, type) => { if (type === LabelType.Indexed || !type) { return extractedLabelKeys; - } else { + } else if (type === LabelType.StructuredMetadata) { return structuredMetadataKeys; + } else if (type === LabelType.Parsed) { + return parsedKeys; + } else { + return []; } }); jest.mocked(extractUnwrapLabelKeysFromDataFrame).mockReturnValue(unwrapLabelKeys); @@ -391,7 +452,7 @@ describe('Query imports', () => { extractLogParserFromDataFrameMock.mockReturnValueOnce({ hasLogfmt: false, hasJSON: true, hasPack: false }); expect(await languageProvider.getParserAndLabelKeys('{place="luna"}')).toEqual({ - extractedLabelKeys, + extractedLabelKeys: [...extractedLabelKeys, ...parsedKeys], unwrapLabelKeys, structuredMetadataKeys, hasJSON: true, @@ -405,7 +466,7 @@ describe('Query imports', () => { extractLogParserFromDataFrameMock.mockReturnValueOnce({ hasLogfmt: true, hasJSON: false, hasPack: false }); expect(await languageProvider.getParserAndLabelKeys('{place="luna"}')).toEqual({ - extractedLabelKeys, + extractedLabelKeys: [...extractedLabelKeys, ...parsedKeys], unwrapLabelKeys, structuredMetadataKeys, hasJSON: false, diff --git a/public/app/plugins/datasource/loki/LanguageProvider.ts b/public/app/plugins/datasource/loki/LanguageProvider.ts index bc91f62ae522c..460140b35f17f 100644 --- a/public/app/plugins/datasource/loki/LanguageProvider.ts +++ b/public/app/plugins/datasource/loki/LanguageProvider.ts @@ -1,17 +1,18 @@ +import { flatten } from 'lodash'; import { LRUCache } from 'lru-cache'; -import Prism from 'prismjs'; import { LanguageProvider, AbstractQuery, KeyValue, getDefaultTimeRange, TimeRange } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { extractLabelMatchers, processLabels, toPromLikeExpr } from 'app/plugins/datasource/prometheus/language_utils'; import { DEFAULT_MAX_LINES_SAMPLE, LokiDatasource } from './datasource'; +import { abstractQueryToExpr, mapAbstractOperatorsToOp, processLabels } from './languageUtils'; +import { getStreamSelectorsFromQuery } from './queryUtils'; +import { buildVisualQueryFromString } from './querybuilder/parsing'; import { extractLabelKeysFromDataFrame, extractLogParserFromDataFrame, extractUnwrapLabelKeysFromDataFrame, } from './responseUtils'; -import syntax from './syntax'; import { ParserAndLabelKeysResult, LokiQuery, LokiQueryType, LabelType } from './types'; const NS_IN_MS = 1000000; @@ -88,20 +89,32 @@ export default class LokiLanguageProvider extends LanguageProvider { importFromAbstractQuery(labelBasedQuery: AbstractQuery): LokiQuery { return { refId: labelBasedQuery.refId, - expr: toPromLikeExpr(labelBasedQuery), + expr: abstractQueryToExpr(labelBasedQuery), queryType: LokiQueryType.Range, }; } exportToAbstractQuery(query: LokiQuery): AbstractQuery { - const lokiQuery = query.expr; - if (!lokiQuery || lokiQuery.length === 0) { + if (!query.expr || query.expr.length === 0) { return { refId: query.refId, labelMatchers: [] }; } - const tokens = Prism.tokenize(lokiQuery, syntax); + const streamSelectors = getStreamSelectorsFromQuery(query.expr); + + const labelMatchers = streamSelectors.map((streamSelector) => { + const visualQuery = buildVisualQueryFromString(streamSelector).query; + const matchers = visualQuery.labels.map((label) => { + return { + name: label.label, + value: label.value, + operator: mapAbstractOperatorsToOp[label.op], + }; + }); + return matchers; + }); + return { refId: query.refId, - labelMatchers: extractLabelMatchers(tokens), + labelMatchers: flatten(labelMatchers), }; } @@ -159,6 +172,9 @@ export default class LokiLanguageProvider extends LanguageProvider { if (!value) { const params = { 'match[]': interpolatedMatch, start, end }; const data = await this.request(url, params); + if (!Array.isArray(data)) { + return {}; + } const { values } = processLabels(data); value = values; this.seriesCache.set(cacheKey, value); @@ -297,7 +313,10 @@ export default class LokiLanguageProvider extends LanguageProvider { const { hasLogfmt, hasJSON, hasPack } = extractLogParserFromDataFrame(series[0]); return { - extractedLabelKeys: extractLabelKeysFromDataFrame(series[0]), + extractedLabelKeys: [ + ...extractLabelKeysFromDataFrame(series[0], LabelType.Indexed), + ...extractLabelKeysFromDataFrame(series[0], LabelType.Parsed), + ], structuredMetadataKeys: extractLabelKeysFromDataFrame(series[0], LabelType.StructuredMetadata), unwrapLabelKeys: extractUnwrapLabelKeysFromDataFrame(series[0]), hasJSON, diff --git a/public/app/plugins/datasource/loki/LogContextProvider.test.ts b/public/app/plugins/datasource/loki/LogContextProvider.test.ts index e4c8c85eb8814..0eb962a89df66 100644 --- a/public/app/plugins/datasource/loki/LogContextProvider.test.ts +++ b/public/app/plugins/datasource/loki/LogContextProvider.test.ts @@ -15,25 +15,9 @@ import { LOKI_LOG_CONTEXT_PRESERVED_LABELS, SHOULD_INCLUDE_PIPELINE_OPERATIONS, } from './LogContextProvider'; -import { createLokiDatasource } from './mocks'; +import { createLokiDatasource } from './__mocks__/datasource'; import { LokiQuery } from './types'; -jest.mock('app/core/store', () => { - return { - get(item: string) { - return window.localStorage.getItem(item); - }, - getBool(key: string, defaultValue?: boolean) { - const item = window.localStorage.getItem(key); - if (item === null) { - return defaultValue; - } else { - return item === 'true'; - } - }, - }; -}); - const defaultLanguageProviderMock = { start: jest.fn(), fetchSeriesLabels: jest.fn(() => ({ bar: ['baz'], xyz: ['abc'] })), @@ -72,9 +56,10 @@ describe('LogContextProvider', () => { describe('getLogRowContext', () => { it('should call getInitContextFilters if no cachedContextFilters', async () => { - logContextProvider.getInitContextFilters = jest - .fn() - .mockResolvedValue([{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }]); + logContextProvider.getInitContextFilters = jest.fn().mockResolvedValue({ + contextFilters: [{ value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }], + preservedFiltersApplied: false, + }); expect(logContextProvider.cachedContextFilters).toHaveLength(0); await logContextProvider.getLogRowContext( @@ -96,8 +81,7 @@ describe('LogContextProvider', () => { from: dateTime(defaultLogRow.timeEpochMs), to: dateTime(defaultLogRow.timeEpochMs), raw: { from: dateTime(defaultLogRow.timeEpochMs), to: dateTime(defaultLogRow.timeEpochMs) }, - }, - true + } ); expect(logContextProvider.cachedContextFilters).toHaveLength(1); }); @@ -105,11 +89,11 @@ describe('LogContextProvider', () => { it('should not call getInitContextFilters if cachedContextFilters', async () => { logContextProvider.getInitContextFilters = jest .fn() - .mockResolvedValue([{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }]); + .mockResolvedValue([{ value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }]); logContextProvider.cachedContextFilters = [ - { value: 'baz', enabled: true, fromParser: false, label: 'bar' }, - { value: 'abc', enabled: true, fromParser: false, label: 'xyz' }, + { value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }, + { value: 'abc', enabled: true, nonIndexed: false, label: 'xyz' }, ]; await logContextProvider.getLogRowContext(defaultLogRow, { limit: 10, @@ -122,9 +106,10 @@ describe('LogContextProvider', () => { describe('getLogRowContextQuery', () => { it('should call getInitContextFilters if no cachedContextFilters', async () => { - logContextProvider.getInitContextFilters = jest - .fn() - .mockResolvedValue([{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }]); + logContextProvider.getInitContextFilters = jest.fn().mockResolvedValue({ + contextFilters: [{ value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }], + preservedFiltersApplied: false, + }); const query = await logContextProvider.getLogRowContextQuery(defaultLogRow, { limit: 10, @@ -135,12 +120,13 @@ describe('LogContextProvider', () => { }); it('should also call getInitContextFilters if cacheFilters is not set', async () => { - logContextProvider.getInitContextFilters = jest - .fn() - .mockResolvedValue([{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }]); + logContextProvider.getInitContextFilters = jest.fn().mockResolvedValue({ + contextFilters: [{ value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }], + preservedFiltersApplied: false, + }); logContextProvider.cachedContextFilters = [ - { value: 'baz', enabled: true, fromParser: false, label: 'bar' }, - { value: 'abc', enabled: true, fromParser: false, label: 'xyz' }, + { value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }, + { value: 'abc', enabled: true, nonIndexed: false, label: 'xyz' }, ]; await logContextProvider.getLogRowContextQuery( defaultLogRow, @@ -172,11 +158,11 @@ describe('LogContextProvider', () => { expect(result.query.expr).toEqual('{}'); }); - it('should not apply parsed labels', async () => { + it('should apply parsed label as structured metadata', async () => { logContextProvider.cachedContextFilters = [ - { value: 'baz', enabled: true, fromParser: false, label: 'bar' }, - { value: 'abc', enabled: true, fromParser: false, label: 'xyz' }, - { value: 'uniqueParsedLabel', enabled: true, fromParser: true, label: 'foo' }, + { value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }, + { value: 'abc', enabled: true, nonIndexed: false, label: 'xyz' }, + { value: 'uniqueParsedLabel', enabled: true, nonIndexed: true, label: 'foo' }, ]; const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget( defaultLogRow, @@ -185,15 +171,15 @@ describe('LogContextProvider', () => { query ); - expect(contextQuery.query.expr).toEqual('{bar="baz",xyz="abc"}'); + expect(contextQuery.query.expr).toEqual('{bar="baz",xyz="abc"} | foo=`uniqueParsedLabel`'); }); }); describe('query with parser', () => { it('should apply parser', async () => { logContextProvider.cachedContextFilters = [ - { value: 'baz', enabled: true, fromParser: false, label: 'bar' }, - { value: 'abc', enabled: true, fromParser: false, label: 'xyz' }, + { value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }, + { value: 'abc', enabled: true, nonIndexed: false, label: 'xyz' }, ]; const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget( defaultLogRow, @@ -210,9 +196,9 @@ describe('LogContextProvider', () => { it('should apply parser and parsed labels', async () => { logContextProvider.cachedContextFilters = [ - { value: 'baz', enabled: true, fromParser: false, label: 'bar' }, - { value: 'abc', enabled: true, fromParser: false, label: 'xyz' }, - { value: 'uniqueParsedLabel', enabled: true, fromParser: true, label: 'foo' }, + { value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }, + { value: 'abc', enabled: true, nonIndexed: false, label: 'xyz' }, + { value: 'uniqueParsedLabel', enabled: true, nonIndexed: true, label: 'foo' }, ]; const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget( defaultLogRow, @@ -230,8 +216,8 @@ describe('LogContextProvider', () => { it('should not apply parser and parsed labels if more parsers in original query', async () => { logContextProvider.cachedContextFilters = [ - { value: 'baz', enabled: true, fromParser: false, label: 'bar' }, - { value: 'uniqueParsedLabel', enabled: true, fromParser: true, label: 'foo' }, + { value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }, + { value: 'uniqueParsedLabel', enabled: true, nonIndexed: true, label: 'foo' }, ]; const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget( defaultLogRow, @@ -243,11 +229,11 @@ describe('LogContextProvider', () => { } ); - expect(contextQuery.query.expr).toEqual(`{bar="baz"}`); + expect(contextQuery.query.expr).toEqual(`{bar="baz"} | foo=\`uniqueParsedLabel\``); }); it('should not apply line_format if flag is not set by default', async () => { - logContextProvider.cachedContextFilters = [{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }]; + logContextProvider.cachedContextFilters = [{ value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }]; const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget( defaultLogRow, 10, @@ -263,7 +249,7 @@ describe('LogContextProvider', () => { it('should not apply line_format if flag is not set', async () => { window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'false'); - logContextProvider.cachedContextFilters = [{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }]; + logContextProvider.cachedContextFilters = [{ value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }]; const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget( defaultLogRow, 10, @@ -279,7 +265,7 @@ describe('LogContextProvider', () => { it('should apply line_format if flag is set', async () => { window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'true'); - logContextProvider.cachedContextFilters = [{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }]; + logContextProvider.cachedContextFilters = [{ value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }]; const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget( defaultLogRow, 10, @@ -295,7 +281,7 @@ describe('LogContextProvider', () => { it('should not apply line filters if flag is set', async () => { window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'true'); - logContextProvider.cachedContextFilters = [{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }]; + logContextProvider.cachedContextFilters = [{ value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }]; let contextQuery = await logContextProvider.prepareLogRowContextQueryTarget( defaultLogRow, 10, @@ -347,7 +333,7 @@ describe('LogContextProvider', () => { it('should not apply line filters if nested between two operations', async () => { window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'true'); - logContextProvider.cachedContextFilters = [{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }]; + logContextProvider.cachedContextFilters = [{ value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }]; const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget( defaultLogRow, 10, @@ -363,7 +349,7 @@ describe('LogContextProvider', () => { it('should not apply label filters', async () => { window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'true'); - logContextProvider.cachedContextFilters = [{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }]; + logContextProvider.cachedContextFilters = [{ value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }]; const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget( defaultLogRow, 10, @@ -379,7 +365,7 @@ describe('LogContextProvider', () => { it('should not apply additional parsers', async () => { window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'true'); - logContextProvider.cachedContextFilters = [{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }]; + logContextProvider.cachedContextFilters = [{ value: 'baz', enabled: true, nonIndexed: false, label: 'bar' }]; const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget( defaultLogRow, 10, @@ -413,21 +399,23 @@ describe('LogContextProvider', () => { }; it('should correctly create contextFilters', async () => { - const filters = await logContextProvider.getInitContextFilters(defaultLogRow.labels, queryWithoutParser); - expect(filters).toEqual([ - { enabled: true, fromParser: false, label: 'bar', value: 'baz' }, - { enabled: false, fromParser: true, label: 'foo', value: 'uniqueParsedLabel' }, - { enabled: true, fromParser: false, label: 'xyz', value: 'abc' }, + const result = await logContextProvider.getInitContextFilters(defaultLogRow.labels, queryWithoutParser); + expect(result.contextFilters).toEqual([ + { enabled: true, nonIndexed: false, label: 'bar', value: 'baz' }, + { enabled: false, nonIndexed: true, label: 'foo', value: 'uniqueParsedLabel' }, + { enabled: true, nonIndexed: false, label: 'xyz', value: 'abc' }, ]); + expect(result.preservedFiltersApplied).toBe(false); }); it('should return empty contextFilters if no query', async () => { - const filters = await logContextProvider.getInitContextFilters(defaultLogRow.labels, undefined); + const filters = (await logContextProvider.getInitContextFilters(defaultLogRow.labels, undefined)) + .contextFilters; expect(filters).toEqual([]); }); it('should return empty contextFilters if no labels', async () => { - const filters = await logContextProvider.getInitContextFilters({}, queryWithoutParser); + const filters = (await logContextProvider.getInitContextFilters({}, queryWithoutParser)).contextFilters; expect(filters).toEqual([]); }); @@ -436,12 +424,12 @@ describe('LogContextProvider', () => { expect(defaultLanguageProviderMock.fetchSeriesLabels).toBeCalled(); }); - it('should call fetchSeriesLabels with given timerange', async () => { + it('should call fetchSeriesLabels with given time range', async () => { await logContextProvider.getInitContextFilters(defaultLogRow.labels, queryWithParser, timeRange); expect(defaultLanguageProviderMock.fetchSeriesLabels).toBeCalledWith(`{bar="baz"}`, { timeRange }); }); - it('should call `languageProvider.start` if no parser with given timerange', async () => { + it('should call `languageProvider.start` if no parser with given time range', async () => { await logContextProvider.getInitContextFilters(defaultLogRow.labels, queryWithoutParser, timeRange); expect(defaultLanguageProviderMock.start).toBeCalledWith(timeRange); }); @@ -454,21 +442,23 @@ describe('LogContextProvider', () => { }; it('should correctly create contextFilters', async () => { - const filters = await logContextProvider.getInitContextFilters(defaultLogRow.labels, queryWithParser); - expect(filters).toEqual([ - { enabled: true, fromParser: false, label: 'bar', value: 'baz' }, - { enabled: false, fromParser: true, label: 'foo', value: 'uniqueParsedLabel' }, - { enabled: true, fromParser: false, label: 'xyz', value: 'abc' }, + const result = await logContextProvider.getInitContextFilters(defaultLogRow.labels, queryWithParser); + expect(result.contextFilters).toEqual([ + { enabled: true, nonIndexed: false, label: 'bar', value: 'baz' }, + { enabled: false, nonIndexed: true, label: 'foo', value: 'uniqueParsedLabel' }, + { enabled: true, nonIndexed: false, label: 'xyz', value: 'abc' }, ]); + expect(result.preservedFiltersApplied).toBe(false); }); it('should return empty contextFilters if no query', async () => { - const filters = await logContextProvider.getInitContextFilters(defaultLogRow.labels, undefined); + const filters = (await logContextProvider.getInitContextFilters(defaultLogRow.labels, undefined)) + .contextFilters; expect(filters).toEqual([]); }); it('should return empty contextFilters if no labels', async () => { - const filters = await logContextProvider.getInitContextFilters({}, queryWithParser); + const filters = (await logContextProvider.getInitContextFilters({}, queryWithParser)).contextFilters; expect(filters).toEqual([]); }); }); @@ -487,12 +477,13 @@ describe('LogContextProvider', () => { selectedExtractedLabels: ['foo'], }) ); - const filters = await logContextProvider.getInitContextFilters(defaultLogRow.labels, queryWithParser); - expect(filters).toEqual([ - { enabled: false, fromParser: false, label: 'bar', value: 'baz' }, // disabled real label - { enabled: true, fromParser: true, label: 'foo', value: 'uniqueParsedLabel' }, // enabled parsed label - { enabled: true, fromParser: false, label: 'xyz', value: 'abc' }, + const result = await logContextProvider.getInitContextFilters(defaultLogRow.labels, queryWithParser); + expect(result.contextFilters).toEqual([ + { enabled: false, nonIndexed: false, label: 'bar', value: 'baz' }, // disabled real label + { enabled: true, nonIndexed: true, label: 'foo', value: 'uniqueParsedLabel' }, // enabled parsed label + { enabled: true, nonIndexed: false, label: 'xyz', value: 'abc' }, ]); + expect(result.preservedFiltersApplied).toBe(true); }); it('should use contextFilters from row labels if all real labels are disabled', async () => { @@ -503,12 +494,13 @@ describe('LogContextProvider', () => { selectedExtractedLabels: ['foo'], }) ); - const filters = await logContextProvider.getInitContextFilters(defaultLogRow.labels, queryWithParser); - expect(filters).toEqual([ - { enabled: true, fromParser: false, label: 'bar', value: 'baz' }, // enabled real label - { enabled: false, fromParser: true, label: 'foo', value: 'uniqueParsedLabel' }, - { enabled: true, fromParser: false, label: 'xyz', value: 'abc' }, // enabled real label + const result = await logContextProvider.getInitContextFilters(defaultLogRow.labels, queryWithParser); + expect(result.contextFilters).toEqual([ + { enabled: true, nonIndexed: false, label: 'bar', value: 'baz' }, // enabled real label + { enabled: false, nonIndexed: true, label: 'foo', value: 'uniqueParsedLabel' }, + { enabled: true, nonIndexed: false, label: 'xyz', value: 'abc' }, // enabled real label ]); + expect(result.preservedFiltersApplied).toBe(false); }); it('should not introduce new labels as context filters', async () => { @@ -519,12 +511,13 @@ describe('LogContextProvider', () => { selectedExtractedLabels: ['foo', 'new'], }) ); - const filters = await logContextProvider.getInitContextFilters(defaultLogRow.labels, queryWithParser); - expect(filters).toEqual([ - { enabled: false, fromParser: false, label: 'bar', value: 'baz' }, - { enabled: true, fromParser: true, label: 'foo', value: 'uniqueParsedLabel' }, - { enabled: true, fromParser: false, label: 'xyz', value: 'abc' }, + const result = await logContextProvider.getInitContextFilters(defaultLogRow.labels, queryWithParser); + expect(result.contextFilters).toEqual([ + { enabled: false, nonIndexed: false, label: 'bar', value: 'baz' }, + { enabled: true, nonIndexed: true, label: 'foo', value: 'uniqueParsedLabel' }, + { enabled: true, nonIndexed: false, label: 'xyz', value: 'abc' }, ]); + expect(result.preservedFiltersApplied).toBe(true); }); }); }); diff --git a/public/app/plugins/datasource/loki/LogContextProvider.ts b/public/app/plugins/datasource/loki/LogContextProvider.ts index 8d0f2f42f0261..e7e45999ddd7c 100644 --- a/public/app/plugins/datasource/loki/LogContextProvider.ts +++ b/public/app/plugins/datasource/loki/LogContextProvider.ts @@ -17,10 +17,6 @@ import { } from '@grafana/data'; import { LabelParser, LabelFilter, LineFilters, PipelineStage, Logfmt, Json } from '@grafana/lezer-logql'; import { Labels } from '@grafana/schema'; -import { notifyApp } from 'app/core/actions'; -import { createSuccessNotification } from 'app/core/copy/appNotification'; -import store from 'app/core/store'; -import { dispatch } from 'app/store/store'; import { LokiContextUi } from './components/LokiContextUi'; import { LokiDatasource, makeRequest, REF_ID_STARTER_LOG_ROW_CONTEXT } from './datasource'; @@ -33,7 +29,7 @@ import { isQueryWithParser, } from './queryUtils'; import { sortDataFrameByTime, SortDirection } from './sortDataFrame'; -import { ContextFilter, LokiQuery, LokiQueryDirection, LokiQueryType } from './types'; +import { ContextFilter, LabelType, LokiQuery, LokiQueryDirection, LokiQueryType } from './types'; export const LOKI_LOG_CONTEXT_PRESERVED_LABELS = 'lokiLogContextPreservedLabels'; export const SHOULD_INCLUDE_PIPELINE_OPERATIONS = 'lokiLogContextShouldIncludePipelineOperations'; @@ -65,17 +61,13 @@ export class LogContextProvider { // to use the cached filters, we need to reinitialize them. if (this.cachedContextFilters.length === 0 || !cacheFilters) { const filters = ( - await this.getInitContextFilters( - row.labels, - origQuery, - { - from: dateTime(row.timeEpochMs), - to: dateTime(row.timeEpochMs), - raw: { from: dateTime(row.timeEpochMs), to: dateTime(row.timeEpochMs) }, - }, - cacheFilters - ) - ).filter((filter) => filter.enabled); + await this.getInitContextFilters(row.labels, origQuery, { + from: dateTime(row.timeEpochMs), + to: dateTime(row.timeEpochMs), + raw: { from: dateTime(row.timeEpochMs), to: dateTime(row.timeEpochMs) }, + }) + ).contextFilters.filter((filter) => filter.enabled); + this.cachedContextFilters = filters; } @@ -222,7 +214,7 @@ export class LogContextProvider { prepareExpression(contextFilters: ContextFilter[], query: LokiQuery | undefined): string { let preparedExpression = this.processContextFiltersToExpr(contextFilters, query); - if (store.getBool(SHOULD_INCLUDE_PIPELINE_OPERATIONS, false)) { + if (window.localStorage.getItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS) === 'true') { preparedExpression = this.processPipelineStagesToExpr(preparedExpression, query); } return preparedExpression; @@ -231,7 +223,7 @@ export class LogContextProvider { processContextFiltersToExpr = (contextFilters: ContextFilter[], query: LokiQuery | undefined): string => { const labelFilters = contextFilters .map((filter) => { - if (!filter.fromParser && filter.enabled) { + if (!filter.nonIndexed && filter.enabled) { // escape backslashes in label as users can't escape them by themselves return `${filter.label}="${escapeLabelValueInExactSelector(filter.value)}"`; } @@ -245,15 +237,26 @@ export class LogContextProvider { // We need to have original query to get parser and include parsed labels // We only add parser and parsed labels if there is only one parser in query - if (query && isQueryWithParser(query.expr).parserCount === 1) { - const parser = getParserFromQuery(query.expr); - if (parser) { - expr = addParserToQuery(expr, parser); - const parsedLabels = contextFilters.filter((filter) => filter.fromParser && filter.enabled); - for (const parsedLabel of parsedLabels) { - if (parsedLabel.enabled) { - expr = addLabelToQuery(expr, parsedLabel.label, '=', parsedLabel.value); - } + if (query) { + let hasParser = false; + if (isQueryWithParser(query.expr).parserCount === 1) { + hasParser = true; + const parser = getParserFromQuery(query.expr); + if (parser) { + expr = addParserToQuery(expr, parser); + } + } + + const nonIndexedLabels = contextFilters.filter((filter) => filter.nonIndexed && filter.enabled); + for (const parsedLabel of nonIndexedLabels) { + if (parsedLabel.enabled) { + expr = addLabelToQuery( + expr, + parsedLabel.label, + '=', + parsedLabel.value, + hasParser ? LabelType.Parsed : LabelType.StructuredMetadata + ); } } } @@ -308,9 +311,14 @@ export class LogContextProvider { ); }; - getInitContextFilters = async (labels: Labels, query?: LokiQuery, timeRange?: TimeRange, cacheFilters?: boolean) => { + getInitContextFilters = async ( + labels: Labels, + query?: LokiQuery, + timeRange?: TimeRange + ): Promise<{ contextFilters: ContextFilter[]; preservedFiltersApplied: boolean }> => { + let preservedFiltersApplied = false; if (!query || isEmpty(labels)) { - return []; + return { contextFilters: [], preservedFiltersApplied }; } // 1. First we need to get all labels from the log row's label @@ -335,7 +343,7 @@ export class LogContextProvider { label, value: value, enabled: allLabels.includes(label), - fromParser: !allLabels.includes(label), + nonIndexed: !allLabels.includes(label), }; contextFilters.push(filter); @@ -343,14 +351,17 @@ export class LogContextProvider { // Secondly we check for preserved labels and update enabled state of filters based on that let preservedLabels: undefined | PreservedLabels = undefined; - try { - preservedLabels = JSON.parse(store.get(LOKI_LOG_CONTEXT_PRESERVED_LABELS)); - // Do nothing when error occurs - } catch (e) {} + const preservedLabelsString = window.localStorage.getItem(LOKI_LOG_CONTEXT_PRESERVED_LABELS); + if (preservedLabelsString) { + try { + preservedLabels = JSON.parse(preservedLabelsString); + // Do nothing when error occurs + } catch (e) {} + } if (!preservedLabels) { // If we don't have preservedLabels, we return contextFilters as they are - return contextFilters; + return { contextFilters, preservedFiltersApplied }; } else { // Otherwise, we need to update filters based on preserved labels let arePreservedLabelsUsed = false; @@ -368,18 +379,15 @@ export class LogContextProvider { return { ...contextFilter }; }); - const isAtLeastOneRealLabelEnabled = newContextFilters.some(({ enabled, fromParser }) => enabled && !fromParser); + const isAtLeastOneRealLabelEnabled = newContextFilters.some(({ enabled, nonIndexed }) => enabled && !nonIndexed); if (!isAtLeastOneRealLabelEnabled) { // If we end up with no real labels enabled, we need to reset the init filters - return contextFilters; + return { contextFilters, preservedFiltersApplied }; } else { - // Otherwise use new filters; also only show the notification if filters - // are supposed to be cached, which is currently used in the UI, not - // when tab-opened - if (arePreservedLabelsUsed && cacheFilters) { - dispatch(notifyApp(createSuccessNotification('Previously used log context filters have been applied.'))); + if (arePreservedLabelsUsed) { + preservedFiltersApplied = true; } - return newContextFilters; + return { contextFilters: newContextFilters, preservedFiltersApplied }; } } }; diff --git a/public/app/plugins/datasource/loki/LokiVariableSupport.test.ts b/public/app/plugins/datasource/loki/LokiVariableSupport.test.ts index 51219f07a6c5e..0e874d898f569 100644 --- a/public/app/plugins/datasource/loki/LokiVariableSupport.test.ts +++ b/public/app/plugins/datasource/loki/LokiVariableSupport.test.ts @@ -3,8 +3,9 @@ import { firstValueFrom } from 'rxjs'; import { dateTime, getDefaultTimeRange } from '@grafana/data'; import { LokiVariableSupport } from './LokiVariableSupport'; +import { createLokiDatasource } from './__mocks__/datasource'; +import { createMetadataRequest } from './__mocks__/metadataRequest'; import { LokiDatasource } from './datasource'; -import { createLokiDatasource, createMetadataRequest } from './mocks'; import { LokiVariableQueryType } from './types'; describe('LokiVariableSupport', () => { diff --git a/public/app/plugins/datasource/loki/__mocks__/datasource.ts b/public/app/plugins/datasource/loki/__mocks__/datasource.ts new file mode 100644 index 0000000000000..4b5a8f2003105 --- /dev/null +++ b/public/app/plugins/datasource/loki/__mocks__/datasource.ts @@ -0,0 +1,59 @@ +import { DataSourceInstanceSettings, DataSourceSettings, PluginType } from '@grafana/data'; +import { TemplateSrv } from '@grafana/runtime'; + +import { LokiDatasource } from '../datasource'; +import { LokiOptions } from '../types'; + +export function createDefaultConfigOptions() { + return { + jsonData: { maxLines: '531' }, + secureJsonFields: {}, + } as DataSourceSettings<LokiOptions>; +} + +const defaultTemplateSrvMock = { + replace: (input: string) => input, + getVariables: () => [], +}; + +export function createLokiDatasource( + templateSrvMock: Partial<TemplateSrv> = defaultTemplateSrvMock, + settings: Partial<DataSourceInstanceSettings<LokiOptions>> = {} +): LokiDatasource { + const customSettings: DataSourceInstanceSettings<LokiOptions> = { + url: 'myloggingurl', + id: 0, + uid: '', + type: '', + name: '', + meta: { + id: 'id', + name: 'name', + type: PluginType.datasource, + module: '', + baseUrl: '', + info: { + author: { + name: 'Test', + }, + description: '', + links: [], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '', + version: '', + }, + }, + readOnly: false, + jsonData: { + maxLines: '20', + }, + access: 'direct', + ...settings, + }; + + return new LokiDatasource(customSettings, templateSrvMock as TemplateSrv); +} diff --git a/public/app/plugins/datasource/loki/mocks.ts b/public/app/plugins/datasource/loki/__mocks__/frames.ts similarity index 58% rename from public/app/plugins/datasource/loki/mocks.ts rename to public/app/plugins/datasource/loki/__mocks__/frames.ts index cac99b2c22777..7c95bce3bd1b6 100644 --- a/public/app/plugins/datasource/loki/mocks.ts +++ b/public/app/plugins/datasource/loki/__mocks__/frames.ts @@ -1,113 +1,4 @@ -import { - DataFrame, - DataFrameType, - DataSourceInstanceSettings, - DataSourceSettings, - FieldType, - PluginType, - toUtc, -} from '@grafana/data'; -import { TemplateSrv } from '@grafana/runtime'; - -import { getMockDataSource } from '../../../features/datasources/__mocks__'; - -import { LokiDatasource } from './datasource'; -import { LokiOptions } from './types'; - -export function createDefaultConfigOptions(): DataSourceSettings<LokiOptions> { - return getMockDataSource<LokiOptions>({ - jsonData: { maxLines: '531' }, - }); -} - -const rawRange = { - from: toUtc('2018-04-25 10:00'), - to: toUtc('2018-04-25 11:00'), -}; - -const defaultTimeSrvMock = { - timeRange: jest.fn().mockReturnValue({ - from: rawRange.from, - to: rawRange.to, - raw: rawRange, - }), -}; - -const defaultTemplateSrvMock = { - replace: (input: string) => input, - getVariables: () => [], -}; - -export function createLokiDatasource( - templateSrvMock: Partial<TemplateSrv> = defaultTemplateSrvMock, - settings: Partial<DataSourceInstanceSettings<LokiOptions>> = {}, - timeSrvStub = defaultTimeSrvMock -): LokiDatasource { - const customSettings: DataSourceInstanceSettings<LokiOptions> = { - url: 'myloggingurl', - id: 0, - uid: '', - type: '', - name: '', - meta: { - id: 'id', - name: 'name', - type: PluginType.datasource, - module: '', - baseUrl: '', - info: { - author: { - name: 'Test', - }, - description: '', - links: [], - logos: { - large: '', - small: '', - }, - screenshots: [], - updated: '', - version: '', - }, - }, - readOnly: false, - jsonData: { - maxLines: '20', - }, - access: 'direct', - ...settings, - }; - - // @ts-expect-error - return new LokiDatasource(customSettings, templateSrvMock, timeSrvStub); -} - -export function createMetadataRequest( - labelsAndValues: Record<string, string[]>, - series?: Record<string, Array<Record<string, string>>> -) { - // added % to allow urlencoded labelKeys. Note, that this is not confirm with Loki, as loki does not allow specialcharacters in labelKeys, but needed for tests. - const lokiLabelsAndValuesEndpointRegex = /^label\/([%\w]*)\/values/; - const lokiSeriesEndpointRegex = /^series/; - const lokiLabelsEndpoint = 'labels'; - const labels = Object.keys(labelsAndValues); - - return async function metadataRequestMock(url: string, params?: Record<string, string | number>) { - if (url === lokiLabelsEndpoint) { - return labels; - } else { - const labelsMatch = url.match(lokiLabelsAndValuesEndpointRegex); - const seriesMatch = url.match(lokiSeriesEndpointRegex); - if (labelsMatch) { - return labelsAndValues[labelsMatch[1]] || []; - } else if (seriesMatch && series && params) { - return series[params['match[]']] || []; - } else { - throw new Error(`Unexpected url error, ${url}`); - } - } - }; -} +import { DataFrame, DataFrameType, FieldType } from '@grafana/data'; export function getMockFrames() { const logFrameA: DataFrame = { diff --git a/public/app/plugins/datasource/loki/__mocks__/metadataRequest.ts b/public/app/plugins/datasource/loki/__mocks__/metadataRequest.ts new file mode 100644 index 0000000000000..94e5fedcb07d4 --- /dev/null +++ b/public/app/plugins/datasource/loki/__mocks__/metadataRequest.ts @@ -0,0 +1,26 @@ +export function createMetadataRequest( + labelsAndValues: Record<string, string[]>, + series?: Record<string, Array<Record<string, string>>> +) { + // added % to allow urlencoded labelKeys. Note, that this is not confirm with Loki, as loki does not allow specialcharacters in labelKeys, but needed for tests. + const lokiLabelsAndValuesEndpointRegex = /^label\/([%\w]*)\/values/; + const lokiSeriesEndpointRegex = /^series/; + const lokiLabelsEndpoint = 'labels'; + const labels = Object.keys(labelsAndValues); + + return async function metadataRequestMock(url: string, params?: Record<string, string | number>) { + if (url === lokiLabelsEndpoint) { + return labels; + } else { + const labelsMatch = url.match(lokiLabelsAndValuesEndpointRegex); + const seriesMatch = url.match(lokiSeriesEndpointRegex); + if (labelsMatch) { + return labelsAndValues[labelsMatch[1]] || []; + } else if (seriesMatch && series && params) { + return series[params['match[]']] || []; + } else { + throw new Error(`Unexpected url error, ${url}`); + } + } + }; +} diff --git a/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx b/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx index 3970e45497a0d..670288224aab0 100644 --- a/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx +++ b/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx @@ -3,7 +3,7 @@ import React, { memo } from 'react'; import { AnnotationQuery } from '@grafana/data'; import { EditorField, EditorRow } from '@grafana/experimental'; -import { Input } from '@grafana/ui'; +import { Input, Stack } from '@grafana/ui'; // Types import { getNormalizedLokiQuery } from '../queryUtils'; @@ -49,8 +49,8 @@ export const LokiAnnotationsQueryEditor = memo(function LokiAnnotationQueryEdito queryType: annotation.queryType, }; return ( - <> - <div className="gf-form-group"> + <Stack gap={5} direction="column"> + <Stack gap={0} direction="column"> <LokiQueryField datasource={props.datasource} query={queryWithRefId} @@ -67,8 +67,7 @@ export const LokiAnnotationsQueryEditor = memo(function LokiAnnotationQueryEdito /> } /> - </div> - + </Stack> <EditorRow> <EditorField label="Title" @@ -120,6 +119,6 @@ export const LokiAnnotationsQueryEditor = memo(function LokiAnnotationQueryEdito /> </EditorField> </EditorRow> - </> + </Stack> ); }); diff --git a/public/app/plugins/datasource/loki/components/LokiContextUi.test.tsx b/public/app/plugins/datasource/loki/components/LokiContextUi.test.tsx index db1207a9b9aef..0ba89331811ad 100644 --- a/public/app/plugins/datasource/loki/components/LokiContextUi.test.tsx +++ b/public/app/plugins/datasource/loki/components/LokiContextUi.test.tsx @@ -1,7 +1,7 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; +import { select } from 'react-select-event'; import { LogRowModel, dateTime } from '@grafana/data'; @@ -16,22 +16,6 @@ jest.mock('@grafana/runtime', () => ({ reportInteraction: () => null, })); -jest.mock('app/core/store', () => { - return { - set() {}, - get() {}, - getBool(key: string, defaultValue?: boolean) { - const item = window.localStorage.getItem(key); - if (item === null) { - return defaultValue; - } else { - return item === 'true'; - } - }, - delete() {}, - }; -}); - const setupProps = (): LokiContextUiProps => { const defaults: LokiContextUiProps = { logContextProvider: Object.assign({}, mockLogContextProvider) as unknown as LogContextProvider, @@ -57,10 +41,13 @@ const setupProps = (): LokiContextUiProps => { const mockLogContextProvider = { getInitContextFilters: jest.fn().mockImplementation(() => - Promise.resolve([ - { value: 'value1', enabled: true, fromParser: false, label: 'label1' }, - { value: 'value3', enabled: false, fromParser: true, label: 'label3' }, - ]) + Promise.resolve({ + contextFilters: [ + { value: 'value1', enabled: true, nonIndexed: false, label: 'label1' }, + { value: 'value3', enabled: false, nonIndexed: true, label: 'label3' }, + ], + preservedFiltersApplied: false, + }) ), processContextFiltersToExpr: jest.fn().mockImplementation( (contextFilters: ContextFilter[], query: LokiQuery | undefined) => @@ -149,8 +136,8 @@ describe('LokiContextUi', () => { await waitFor(() => { expect(props.logContextProvider.getInitContextFilters).toHaveBeenCalled(); }); - const select = await screen.findAllByRole('combobox'); - await selectOptionInTest(select[0], 'label1="value1"'); + const selects = await screen.findAllByRole('combobox'); + await select(selects[0], 'label1="value1"', { container: document.body }); }); it('finds label3 as a parsed label', async () => { @@ -159,8 +146,8 @@ describe('LokiContextUi', () => { await waitFor(() => { expect(props.logContextProvider.getInitContextFilters).toHaveBeenCalled(); }); - const select = await screen.findAllByRole('combobox'); - await selectOptionInTest(select[1], 'label3="value3"'); + const selects = await screen.findAllByRole('combobox'); + await select(selects[1], 'label3="value3"', { container: document.body }); }); it('calls updateFilter when selecting a label', async () => { @@ -171,7 +158,7 @@ describe('LokiContextUi', () => { expect(props.logContextProvider.getInitContextFilters).toHaveBeenCalled(); expect(screen.getAllByRole('combobox')).toHaveLength(2); }); - await selectOptionInTest(screen.getAllByRole('combobox')[1], 'label3="value3"'); + await select(screen.getAllByRole('combobox')[1], 'label3="value3"', { container: document.body }); act(() => { jest.runAllTimers(); }); @@ -343,4 +330,35 @@ describe('LokiContextUi', () => { expect(screen.queryByText('label3="value3"')).not.toBeInTheDocument(); }); }); + it('shows if preserved filters are applied', async () => { + const props = setupProps(); + const newProps = { + ...props, + logContextProvider: { + ...props.logContextProvider, + getInitContextFilters: jest.fn().mockImplementation(() => + Promise.resolve({ + contextFilters: [ + { value: 'value1', enabled: true, nonIndexed: false, label: 'label1' }, + { value: 'value3', enabled: false, nonIndexed: true, label: 'label3' }, + ], + preservedFiltersApplied: true, + }) + ), + }, + } as unknown as LokiContextUiProps; + + render(<LokiContextUi {...newProps} />); + expect(await screen.findByText('Previously used filters have been applied.')).toBeInTheDocument(); + }); + + it('does not shows if preserved filters are not applied', async () => { + // setupProps() already has preservedFiltersApplied: false + const props = setupProps(); + + render(<LokiContextUi {...props} />); + await waitFor(() => { + expect(screen.queryByText('Previously used filters have been applied.')).not.toBeInTheDocument(); + }); + }); }); diff --git a/public/app/plugins/datasource/loki/components/LokiContextUi.tsx b/public/app/plugins/datasource/loki/components/LokiContextUi.tsx index 1532ef1e8f7f3..abea3b584c2a7 100644 --- a/public/app/plugins/datasource/loki/components/LokiContextUi.tsx +++ b/public/app/plugins/datasource/loki/components/LokiContextUi.tsx @@ -3,8 +3,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useAsync } from 'react-use'; import { dateTime, GrafanaTheme2, LogRowModel, renderMarkdown, SelectableValue } from '@grafana/data'; +import { RawQuery } from '@grafana/experimental'; import { reportInteraction } from '@grafana/runtime'; import { + Alert, Button, Collapse, Icon, @@ -18,9 +20,7 @@ import { Tooltip, useStyles2, } from '@grafana/ui'; -import store from 'app/core/store'; -import { RawQuery } from '../../prometheus/querybuilder/shared/RawQuery'; import { LogContextProvider, LOKI_LOG_CONTEXT_PRESERVED_LABELS, @@ -28,7 +28,6 @@ import { SHOULD_INCLUDE_PIPELINE_OPERATIONS, } from '../LogContextProvider'; import { escapeLabelValueInSelector } from '../languageUtils'; -import { isQueryWithParser } from '../queryUtils'; import { lokiGrammar } from '../syntax'; import { ContextFilter, LokiQuery } from '../types'; @@ -81,6 +80,12 @@ function getStyles(theme: GrafanaTheme2) { background-color: ${theme.colors.background.secondary}; padding: ${theme.spacing(2)}; `, + notification: css({ + position: 'absolute', + zIndex: theme.zIndex.portal, + top: 0, + right: 0, + }), rawQuery: css` display: inline; `, @@ -112,12 +117,13 @@ export function LokiContextUi(props: LokiContextUiProps) { const styles = useStyles2(getStyles); const [contextFilters, setContextFilters] = useState<ContextFilter[]>([]); + const [showPreservedFiltersAppliedNotification, setShowPreservedFiltersAppliedNotification] = useState(false); const [initialized, setInitialized] = useState(false); const [loading, setLoading] = useState(false); - const [isOpen, setIsOpen] = useState(store.getBool(IS_LOKI_LOG_CONTEXT_UI_OPEN, false)); + const [isOpen, setIsOpen] = useState(window.localStorage.getItem(IS_LOKI_LOG_CONTEXT_UI_OPEN) === 'true'); const [includePipelineOperations, setIncludePipelineOperations] = useState( - store.getBool(SHOULD_INCLUDE_PIPELINE_OPERATIONS, false) + window.localStorage.getItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS) === 'true' ); const timerHandle = React.useRef<number>(); @@ -126,7 +132,7 @@ export function LokiContextUi(props: LokiContextUiProps) { const isInitialState = useMemo(() => { // Initial query has all regular labels enabled and all parsed labels disabled - if (initialized && contextFilters.some((filter) => filter.fromParser === filter.enabled)) { + if (initialized && contextFilters.some((filter) => filter.nonIndexed === filter.enabled)) { return false; } @@ -149,7 +155,7 @@ export function LokiContextUi(props: LokiContextUiProps) { return; } - if (contextFilters.filter(({ enabled, fromParser }) => enabled && !fromParser).length === 0) { + if (contextFilters.filter(({ enabled, nonIndexed }) => enabled && !nonIndexed).length === 0) { setContextFilters(previousContextFilters.current); return; } @@ -169,18 +175,18 @@ export function LokiContextUi(props: LokiContextUiProps) { selectedExtractedLabels: [], }; - contextFilters.forEach(({ enabled, fromParser, label }) => { + contextFilters.forEach(({ enabled, nonIndexed, label }) => { // We only want to store real labels that were removed from the initial query - if (!enabled && !fromParser) { + if (!enabled && !nonIndexed) { preservedLabels.removedLabels.push(label); } // Or extracted labels that were added to the initial query - if (enabled && fromParser) { + if (enabled && nonIndexed) { preservedLabels.selectedExtractedLabels.push(label); } }); - store.set(LOKI_LOG_CONTEXT_PRESERVED_LABELS, JSON.stringify(preservedLabels)); + window.localStorage.setItem(LOKI_LOG_CONTEXT_PRESERVED_LABELS, JSON.stringify(preservedLabels)); setLoading(false); }, 1500); @@ -204,12 +210,21 @@ export function LokiContextUi(props: LokiContextUiProps) { to: dateTime(row.timeEpochMs), raw: { from: dateTime(row.timeEpochMs), to: dateTime(row.timeEpochMs) }, }); - setContextFilters(initContextFilters); - + setContextFilters(initContextFilters.contextFilters); + setShowPreservedFiltersAppliedNotification(initContextFilters.preservedFiltersApplied); setInitialized(true); setLoading(false); }); + // To hide previousContextFiltersApplied notification after 2 seconds + useEffect(() => { + if (showPreservedFiltersAppliedNotification) { + setTimeout(() => { + setShowPreservedFiltersAppliedNotification(false); + }, 2000); + } + }, [showPreservedFiltersAppliedNotification]); + useEffect(() => { reportInteraction('grafana_explore_logs_loki_log_context_loaded', { logRowUid: row.uid, @@ -224,10 +239,10 @@ export function LokiContextUi(props: LokiContextUiProps) { }; }, [row.uid]); - const realLabels = contextFilters.filter(({ fromParser }) => !fromParser); + const realLabels = contextFilters.filter(({ nonIndexed }) => !nonIndexed); const realLabelsEnabled = realLabels.filter(({ enabled }) => enabled); - const parsedLabels = contextFilters.filter(({ fromParser }) => fromParser); + const parsedLabels = contextFilters.filter(({ nonIndexed }) => nonIndexed); const parsedLabelsEnabled = parsedLabels.filter(({ enabled }) => enabled); const contextFilterToSelectFilter = useCallback((contextFilter: ContextFilter): SelectableValue<string> => { @@ -237,8 +252,8 @@ export function LokiContextUi(props: LokiContextUiProps) { }; }, []); - // Currently we support adding of parser and showing parsed labels only if there is 1 parser - const showParsedLabels = origQuery && isQueryWithParser(origQuery.expr).parserCount === 1 && parsedLabels.length > 0; + // If there's any nonIndexed labels, that includes structured metadata and parsed labels, we show the nonIndexed labels input + const showNonIndexedLabels = parsedLabels.length > 0; let queryExpr = logContextProvider.prepareExpression( contextFilters.filter(({ enabled }) => enabled), @@ -246,6 +261,14 @@ export function LokiContextUi(props: LokiContextUiProps) { ); return ( <div className={styles.wrapper}> + {showPreservedFiltersAppliedNotification && ( + <Alert + className={styles.notification} + title="Previously used filters have been applied." + severity="info" + elevated={true} + ></Alert> + )} <Tooltip content={'Revert to initial log context query.'}> <div className={styles.iconButton}> <Button @@ -261,12 +284,12 @@ export function LokiContextUi(props: LokiContextUiProps) { return contextFilters.map((contextFilter) => ({ ...contextFilter, // For revert to initial query we need to enable all labels and disable all parsed labels - enabled: !contextFilter.fromParser, + enabled: !contextFilter.nonIndexed, })); }); // We are removing the preserved labels from local storage so we can preselect the labels in the UI - store.delete(LOKI_LOG_CONTEXT_PRESERVED_LABELS); - store.delete(SHOULD_INCLUDE_PIPELINE_OPERATIONS); + window.localStorage.removeItem(LOKI_LOG_CONTEXT_PRESERVED_LABELS); + window.localStorage.removeItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS); setIncludePipelineOperations(false); }} /> @@ -277,7 +300,7 @@ export function LokiContextUi(props: LokiContextUiProps) { collapsible={true} isOpen={isOpen} onToggle={() => { - store.set(IS_LOKI_LOG_CONTEXT_UI_OPEN, !isOpen); + window.localStorage.setItem(IS_LOKI_LOG_CONTEXT_UI_OPEN, (!isOpen).toString()); setIsOpen((isOpen) => !isOpen); reportInteraction('grafana_explore_logs_loki_log_context_toggled', { logRowUid: row.uid, @@ -288,7 +311,11 @@ export function LokiContextUi(props: LokiContextUiProps) { <div className={styles.rawQueryContainer}> {initialized ? ( <> - <RawQuery lang={{ grammar: lokiGrammar, name: 'loki' }} query={queryExpr} className={styles.rawQuery} /> + <RawQuery + language={{ grammar: lokiGrammar, name: 'loki' }} + query={queryExpr} + className={styles.rawQuery} + /> <Tooltip content="The initial log context query is created from all labels defining the stream for the selected log line. Use the editor below to customize the log context query."> <Icon name="info-circle" size="sm" className={styles.queryDescription} /> </Tooltip> @@ -330,7 +357,7 @@ export function LokiContextUi(props: LokiContextUiProps) { } return setContextFilters( contextFilters.map((filter) => { - if (filter.fromParser) { + if (filter.nonIndexed) { return filter; } filter.enabled = keys.some((key) => key.value === filter.label); @@ -339,7 +366,7 @@ export function LokiContextUi(props: LokiContextUiProps) { ); }} /> - {showParsedLabels && ( + {showNonIndexedLabels && ( <> <Label className={styles.label} @@ -372,7 +399,7 @@ export function LokiContextUi(props: LokiContextUiProps) { } setContextFilters( contextFilters.map((filter) => { - if (!filter.fromParser) { + if (!filter.nonIndexed) { return filter; } filter.enabled = keys.some((key) => key.value === filter.label); @@ -404,7 +431,7 @@ export function LokiContextUi(props: LokiContextUiProps) { logRowUid: row.uid, action: e.currentTarget.checked ? 'enable' : 'disable', }); - store.set(SHOULD_INCLUDE_PIPELINE_OPERATIONS, e.currentTarget.checked); + window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, e.currentTarget.checked.toString()); setIncludePipelineOperations(e.currentTarget.checked); if (runContextQuery) { runContextQuery(); diff --git a/public/app/plugins/datasource/loki/components/LokiOptionFields.tsx b/public/app/plugins/datasource/loki/components/LokiOptionFields.tsx index 9b025f29b53fd..8bada9ae24e16 100644 --- a/public/app/plugins/datasource/loki/components/LokiOptionFields.tsx +++ b/public/app/plugins/datasource/loki/components/LokiOptionFields.tsx @@ -1,12 +1,11 @@ // Libraries -import { css, cx } from '@emotion/css'; import { map } from 'lodash'; import React, { memo } from 'react'; // Types import { SelectableValue } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { InlineFormLabel, RadioButtonGroup, InlineField, Input, Select } from '@grafana/ui'; +import { InlineFormLabel, RadioButtonGroup, InlineField, Input, Select, Stack } from '@grafana/ui'; import { getLokiQueryType } from '../queryUtils'; import { LokiQuery, LokiQueryType } from '../types'; @@ -82,18 +81,9 @@ export function LokiOptionFields(props: LokiOptionFieldsProps) { } return ( - <div aria-label="Loki extra field" className="gf-form-inline"> + <Stack alignItems="flex-start" gap={0.5} aria-label="Loki extra field"> {/*Query type field*/} - <div - data-testid="queryTypeField" - className={cx( - 'gf-form explore-input-margin', - css` - flex-wrap: nowrap; - ` - )} - aria-label="Query type field" - > + <Stack wrap="nowrap" gap={0} data-testid="queryTypeField" aria-label="Query type field"> <InlineFormLabel width="auto">Query type</InlineFormLabel> <RadioButtonGroup @@ -106,18 +96,9 @@ export function LokiOptionFields(props: LokiOptionFieldsProps) { } }} /> - </div> + </Stack> {/*Line limit field*/} - <div - data-testid="lineLimitField" - className={cx( - 'gf-form', - css` - flex-wrap: nowrap; - ` - )} - aria-label="Line limit field" - > + <Stack wrap="nowrap" gap={0} data-testid="lineLimitField" aria-label="Line limit field"> <InlineField label="Line limit" tooltip={'Upper limit for number of log lines returned by query.'}> <Input className="width-4" @@ -148,8 +129,8 @@ export function LokiOptionFields(props: LokiOptionFieldsProps) { aria-label="Select resolution" /> </InlineField> - </div> - </div> + </Stack> + </Stack> ); } diff --git a/public/app/plugins/datasource/loki/components/LokiQueryEditor.test.tsx b/public/app/plugins/datasource/loki/components/LokiQueryEditor.test.tsx index 1c9cf55ba435d..c2720a046bf6c 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryEditor.test.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryEditor.test.tsx @@ -4,9 +4,9 @@ import { cloneDeep, defaultsDeep } from 'lodash'; import React from 'react'; import { CoreApp } from '@grafana/data'; -import { QueryEditorMode } from 'app/plugins/datasource/prometheus/querybuilder/shared/types'; +import { QueryEditorMode } from '@grafana/experimental'; -import { createLokiDatasource } from '../mocks'; +import { createLokiDatasource } from '../__mocks__/datasource'; import { EXPLAIN_LABEL_FILTER_CONTENT } from '../querybuilder/components/LokiQueryBuilderExplained'; import { LokiQuery, LokiQueryType } from '../types'; @@ -29,18 +29,6 @@ jest.mock('./monaco-query-field/MonacoQueryFieldWrapper', () => { }; }); -jest.mock('app/core/store', () => { - return { - get() { - return undefined; - }, - set() {}, - getObject(key: string, defaultValue: unknown) { - return defaultValue; - }, - }; -}); - const defaultQuery = { refId: 'A', expr: '{label1="foo", label2="bar"}', @@ -59,6 +47,10 @@ const defaultProps = { }; describe('LokiQueryEditorSelector', () => { + // We need to clear local storage after each test because we are using it to store the editor mode and enabled explain + afterEach(() => { + window.localStorage.clear(); + }); it('shows code editor if expr and nothing else', async () => { // We opt for showing code editor for queries created before this feature was added render(<LokiQueryEditor {...defaultProps} />); diff --git a/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx b/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx index cabe6b9e7f009..d0c9f84529a6e 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx @@ -4,14 +4,17 @@ import { usePrevious } from 'react-use'; import { CoreApp, LoadingState } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { EditorHeader, EditorRows, FlexItem, Space, Stack } from '@grafana/experimental'; +import { + EditorHeader, + EditorRows, + FlexItem, + QueryEditorModeToggle, + QueryHeaderSwitch, + QueryEditorMode, +} from '@grafana/experimental'; import { config, reportInteraction } from '@grafana/runtime'; -import { Button, ConfirmModal } from '@grafana/ui'; -import { QueryEditorModeToggle } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle'; -import { QueryHeaderSwitch } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryHeaderSwitch'; -import { QueryEditorMode } from 'app/plugins/datasource/prometheus/querybuilder/shared/types'; +import { Button, ConfirmModal, Space, Stack } from '@grafana/ui'; -import { lokiQueryEditorExplainKey, useFlag } from '../../prometheus/querybuilder/shared/hooks/useFlag'; import { LabelBrowserModal } from '../querybuilder/components/LabelBrowserModal'; import { LokiQueryBuilderContainer } from '../querybuilder/components/LokiQueryBuilderContainer'; import { LokiQueryBuilderOptions } from '../querybuilder/components/LokiQueryBuilderOptions'; @@ -28,6 +31,8 @@ export const testIds = { editor: 'loki-editor', }; +export const lokiQueryEditorExplainKey = 'LokiQueryEditorExplainDefault'; + export const LokiQueryEditor = React.memo<LokiQueryEditorProps>((props) => { const id = useId(); const { onChange, onRunQuery, onAddQuery, data, app, queries, datasource, range: timeRange } = props; @@ -36,7 +41,7 @@ export const LokiQueryEditor = React.memo<LokiQueryEditorProps>((props) => { const [dataIsStale, setDataIsStale] = useState(false); const [labelBrowserVisible, setLabelBrowserVisible] = useState(false); const [queryStats, setQueryStats] = useState<QueryStats | null>(null); - const { flag: explain, setFlag: setExplain } = useFlag(lokiQueryEditorExplainKey); + const [explain, setExplain] = useState(window.localStorage.getItem(lokiQueryEditorExplainKey) === 'true'); const predefinedOperations = datasource.predefinedOperations; const previousTimeRange = usePrevious(timeRange); @@ -52,6 +57,7 @@ export const LokiQueryEditor = React.memo<LokiQueryEditorProps>((props) => { const editorMode = query.editorMode!; const onExplainChange = (event: SyntheticEvent<HTMLInputElement>) => { + window.localStorage.setItem(lokiQueryEditorExplainKey, event.currentTarget.checked ? 'true' : 'false'); setExplain(event.currentTarget.checked); }; diff --git a/public/app/plugins/datasource/loki/components/LokiQueryEditorByApp.test.tsx b/public/app/plugins/datasource/loki/components/LokiQueryEditorByApp.test.tsx index 062739a965506..171644d28025d 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryEditorByApp.test.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryEditorByApp.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { CoreApp } from '@grafana/data'; -import { createLokiDatasource } from '../mocks'; +import { createLokiDatasource } from '../__mocks__/datasource'; import { testIds as regularTestIds } from './LokiQueryEditor'; import { LokiQueryEditorByApp } from './LokiQueryEditorByApp'; diff --git a/public/app/plugins/datasource/loki/components/LokiQueryField.test.tsx b/public/app/plugins/datasource/loki/components/LokiQueryField.test.tsx index 2ff6f0694cdbe..5c304700ffb0a 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryField.test.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryField.test.tsx @@ -1,9 +1,9 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import React, { ComponentProps } from 'react'; import { dateTime } from '@grafana/data'; -import { createLokiDatasource } from '../mocks'; +import { createLokiDatasource } from '../__mocks__/datasource'; import { LokiQueryField } from './LokiQueryField'; @@ -33,7 +33,9 @@ describe('LokiQueryField', () => { it('refreshes metrics when time range changes over 1 minute', async () => { const { rerender } = render(<LokiQueryField {...props} />); - expect(await screen.findByText('Loading...')).toBeInTheDocument(); + await waitFor(async () => { + expect(await screen.findByText('Loading...')).toBeInTheDocument(); + }); expect(props.datasource.languageProvider.fetchLabels).not.toHaveBeenCalled(); @@ -55,7 +57,9 @@ describe('LokiQueryField', () => { it('does not refreshes metrics when time range change by less than 1 minute', async () => { const { rerender } = render(<LokiQueryField {...props} />); - expect(await screen.findByText('Loading...')).toBeInTheDocument(); + await waitFor(async () => { + expect(await screen.findByText('Loading...')).toBeInTheDocument(); + }); expect(props.datasource.languageProvider.fetchLabels).not.toHaveBeenCalled(); diff --git a/public/app/plugins/datasource/loki/components/VariableQueryEditor.test.tsx b/public/app/plugins/datasource/loki/components/VariableQueryEditor.test.tsx index 1e7c803a96b3d..758dfc7055c88 100644 --- a/public/app/plugins/datasource/loki/components/VariableQueryEditor.test.tsx +++ b/public/app/plugins/datasource/loki/components/VariableQueryEditor.test.tsx @@ -1,11 +1,12 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; +import { select } from 'react-select-event'; +import { TimeRange, dateTime } from '@grafana/data'; import { TemplateSrv } from '@grafana/runtime'; -import { createLokiDatasource } from '../mocks'; +import { createLokiDatasource } from '../__mocks__/datasource'; import { LokiVariableQueryType } from '../types'; import { LokiVariableQueryEditor, Props } from './VariableQueryEditor'; @@ -34,8 +35,7 @@ describe('LokiVariableQueryEditor', () => { render(<LokiVariableQueryEditor {...props} onChange={onChange} />); expect(onChange).not.toHaveBeenCalled(); - - await selectOptionInTest(screen.getByLabelText('Query type'), 'Label names'); + await select(screen.getByLabelText('Query type'), 'Label names', { container: document.body }); expect(onChange).toHaveBeenCalledWith({ type: LokiVariableQueryType.LabelNames, @@ -51,8 +51,8 @@ describe('LokiVariableQueryEditor', () => { expect(onChange).not.toHaveBeenCalled(); - await selectOptionInTest(screen.getByLabelText('Query type'), 'Label values'); - await selectOptionInTest(screen.getByLabelText('Label'), 'luna'); + await waitFor(() => select(screen.getByLabelText('Query type'), 'Label values', { container: document.body })); + await select(screen.getByLabelText('Label'), 'luna', { container: document.body }); await userEvent.type(screen.getByLabelText('Stream selector'), 'stream'); await waitFor(() => expect(screen.getByDisplayValue('stream')).toBeInTheDocument()); @@ -72,8 +72,8 @@ describe('LokiVariableQueryEditor', () => { render(<LokiVariableQueryEditor {...props} onChange={onChange} />); expect(onChange).not.toHaveBeenCalled(); + await waitFor(() => select(screen.getByLabelText('Query type'), 'Label values', { container: document.body })); - await selectOptionInTest(screen.getByLabelText('Query type'), 'Label values'); await userEvent.type(screen.getByLabelText('Label'), 'sol{enter}'); await userEvent.type(screen.getByLabelText('Stream selector'), 'stream'); @@ -120,9 +120,8 @@ describe('LokiVariableQueryEditor', () => { test('Label options are not lost when selecting one', async () => { const { rerender } = render(<LokiVariableQueryEditor {...props} onChange={() => {}} />); - - await selectOptionInTest(screen.getByLabelText('Query type'), 'Label values'); - await selectOptionInTest(screen.getByLabelText('Label'), 'luna'); + await waitFor(() => select(screen.getByLabelText('Query type'), 'Label values', { container: document.body })); + await select(screen.getByLabelText('Label'), 'luna', { container: document.body }); const updatedQuery = { refId: 'test', @@ -131,8 +130,136 @@ describe('LokiVariableQueryEditor', () => { }; rerender(<LokiVariableQueryEditor {...props} query={updatedQuery} onChange={() => {}} />); - await selectOptionInTest(screen.getByLabelText('Label'), 'moon'); - await selectOptionInTest(screen.getByLabelText('Label'), 'luna'); + await select(screen.getByLabelText('Label'), 'moon', { container: document.body }); + await select(screen.getByLabelText('Label'), 'luna', { container: document.body }); await screen.findByText('luna'); }); + + test('Calls language provider fetchLabels with the time range received in props', async () => { + const now = dateTime('2023-09-16T21:26:00Z'); + const range: TimeRange = { + from: dateTime(now).subtract(2, 'days'), + to: now, + raw: { + from: 'now-2d', + to: 'now', + }, + }; + props.range = range; + props.query = { + refId: 'test', + type: LokiVariableQueryType.LabelValues, + label: 'luna', + }; + + render(<LokiVariableQueryEditor {...props} />); + await waitFor(() => + expect(props.datasource.languageProvider.fetchLabels).toHaveBeenCalledWith({ timeRange: range }) + ); + }); + + test('does not re-run fetch labels when type does not change', async () => { + const now = dateTime('2023-09-16T21:26:00Z'); + const range: TimeRange = { + from: dateTime(now).subtract(2, 'days'), + to: now, + raw: { + from: 'now-2d', + to: 'now', + }, + }; + props.range = range; + props.query = { + refId: 'test', + type: LokiVariableQueryType.LabelValues, + }; + + props.datasource.languageProvider.fetchLabels = jest.fn().mockResolvedValue([]); + const { rerender } = render(<LokiVariableQueryEditor {...props} />); + rerender( + <LokiVariableQueryEditor {...props} query={{ ...props.query, type: LokiVariableQueryType.LabelValues }} /> + ); + + await waitFor(() => { + expect(props.datasource.languageProvider.fetchLabels).toHaveBeenCalledTimes(1); + }); + }); + + test('runs fetch labels when type changes to from LabelNames to LabelValues', async () => { + const now = dateTime('2023-09-16T21:26:00Z'); + const range: TimeRange = { + from: dateTime(now).subtract(2, 'days'), + to: now, + raw: { + from: 'now-2d', + to: 'now', + }, + }; + props.range = range; + props.query = { + refId: 'test', + type: LokiVariableQueryType.LabelNames, + }; + + props.datasource.languageProvider.fetchLabels = jest.fn().mockResolvedValue([]); + const { rerender } = render(<LokiVariableQueryEditor {...props} />); + rerender( + <LokiVariableQueryEditor {...props} query={{ ...props.query, type: LokiVariableQueryType.LabelValues }} /> + ); + + await waitFor(() => { + expect(props.datasource.languageProvider.fetchLabels).toHaveBeenCalledTimes(1); + }); + }); + + test('runs fetch labels when type changes to LabelValues', async () => { + const now = dateTime('2023-09-16T21:26:00Z'); + const range: TimeRange = { + from: dateTime(now).subtract(2, 'days'), + to: now, + raw: { + from: 'now-2d', + to: 'now', + }, + }; + props.range = range; + props.query = { + refId: 'test', + type: LokiVariableQueryType.LabelNames, + }; + + props.datasource.languageProvider.fetchLabels = jest.fn().mockResolvedValue([]); + // Starting with LabelNames + const { rerender } = render(<LokiVariableQueryEditor {...props} />); + + // Changing to LabelValues, should run fetchLabels + rerender( + <LokiVariableQueryEditor {...props} query={{ ...props.query, type: LokiVariableQueryType.LabelValues }} /> + ); + await waitFor(() => { + expect(props.datasource.languageProvider.fetchLabels).toHaveBeenCalledTimes(1); + }); + + // Keeping the type of LabelValues, should not run additional fetchLabels + rerender( + <LokiVariableQueryEditor {...props} query={{ ...props.query, type: LokiVariableQueryType.LabelValues }} /> + ); + await waitFor(() => { + expect(props.datasource.languageProvider.fetchLabels).toHaveBeenCalledTimes(1); + }); + + // Changing to LabelNames, should not run additional fetchLabels + rerender(<LokiVariableQueryEditor {...props} query={{ ...props.query, type: LokiVariableQueryType.LabelNames }} />); + await waitFor(() => { + expect(props.datasource.languageProvider.fetchLabels).toHaveBeenCalledTimes(1); + }); + + // Changing to LabelValues, should run additional fetchLabels + rerender( + <LokiVariableQueryEditor {...props} query={{ ...props.query, type: LokiVariableQueryType.LabelValues }} /> + ); + await waitFor(() => { + expect(props.datasource.languageProvider.fetchLabels).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/public/app/plugins/datasource/loki/components/VariableQueryEditor.tsx b/public/app/plugins/datasource/loki/components/VariableQueryEditor.tsx index 749b564b80a58..63246b7365759 100644 --- a/public/app/plugins/datasource/loki/components/VariableQueryEditor.tsx +++ b/public/app/plugins/datasource/loki/components/VariableQueryEditor.tsx @@ -1,4 +1,5 @@ import React, { FormEvent, useState, useEffect } from 'react'; +import { usePrevious } from 'react-use'; import { QueryEditorProps, SelectableValue } from '@grafana/data'; import { InlineField, InlineFieldRow, Input, Select } from '@grafana/ui'; @@ -16,11 +17,12 @@ export type Props = QueryEditorProps<LokiDatasource, LokiQuery, LokiOptions, Lok const refId = 'LokiVariableQueryEditor-VariableQuery'; -export const LokiVariableQueryEditor = ({ onChange, query, datasource }: Props) => { +export const LokiVariableQueryEditor = ({ onChange, query, datasource, range }: Props) => { const [type, setType] = useState<number | undefined>(undefined); const [label, setLabel] = useState(''); const [labelOptions, setLabelOptions] = useState<Array<SelectableValue<string>>>([]); const [stream, setStream] = useState(''); + const previousType = usePrevious(type); useEffect(() => { if (!query) { @@ -34,14 +36,15 @@ export const LokiVariableQueryEditor = ({ onChange, query, datasource }: Props) }, [query]); useEffect(() => { - if (type !== QueryType.LabelValues) { + // Fetch label names when the query type is LabelValues, and the previous type was not the same + if (type !== QueryType.LabelValues || previousType === type) { return; } - datasource.languageProvider.fetchLabels().then((labelNames: string[]) => { + datasource.languageProvider.fetchLabels({ timeRange: range }).then((labelNames) => { setLabelOptions(labelNames.map((labelName) => ({ label: labelName, value: labelName }))); }); - }, [datasource, type]); + }, [datasource, type, range, previousType]); const onQueryTypeChange = (newType: SelectableValue<QueryType>) => { setType(newType.value); diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoFieldWrapper.test.tsx b/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoFieldWrapper.test.tsx index 088c46627b4bb..7ff5671f8cdb4 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoFieldWrapper.test.tsx +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoFieldWrapper.test.tsx @@ -1,7 +1,7 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; -import { createLokiDatasource } from '../../mocks'; +import { createLokiDatasource } from '../../__mocks__/datasource'; import { MonacoQueryFieldWrapper, Props } from './MonacoQueryFieldWrapper'; @@ -24,6 +24,8 @@ describe('MonacoFieldWrapper', () => { test('Renders with no errors', async () => { renderComponent(); - expect(await screen.findByText('Loading...')).toBeInTheDocument(); + await waitFor(async () => { + expect(await screen.findByText('Loading...')).toBeInTheDocument(); + }); }); }); diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryField.test.tsx b/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryField.test.tsx index b89ab68fdc402..eb938b59597cd 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryField.test.tsx +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryField.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; -import { createLokiDatasource } from '../../mocks'; +import { createLokiDatasource } from '../../__mocks__/datasource'; import MonacoQueryField from './MonacoQueryField'; import { Props } from './MonacoQueryFieldProps'; diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.test.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.test.ts index c11caa9d44de5..5b2d1a62eb00e 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.test.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.test.ts @@ -1,8 +1,8 @@ import { HistoryItem, dateTime } from '@grafana/data'; import LokiLanguageProvider from '../../../LanguageProvider'; +import { createLokiDatasource } from '../../../__mocks__/datasource'; import { LokiDatasource } from '../../../datasource'; -import { createLokiDatasource } from '../../../mocks'; import { LokiQuery } from '../../../types'; import { CompletionDataProvider } from './CompletionDataProvider'; @@ -182,4 +182,11 @@ describe('CompletionDataProvider', () => { test('Returns the expected series labels', async () => { expect(await completionProvider.getSeriesLabels([])).toEqual(seriesLabels); }); + + test('Escapes correct characters when building stream selector in getSeriesLabels', async () => { + completionProvider.getSeriesLabels([{ name: 'job', op: '=', value: '"a\\b\n' }]); + expect(languageProvider.fetchSeriesLabels).toHaveBeenCalledWith('{job="\\"a\\\\b\\n"}', { + timeRange: mockTimeRange, + }); + }); }); diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts index 83bb111de1fc4..f982d52485516 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts @@ -1,9 +1,9 @@ import { chain } from 'lodash'; import { HistoryItem, TimeRange } from '@grafana/data'; -import { escapeLabelValueInExactSelector } from 'app/plugins/datasource/prometheus/language_utils'; import LanguageProvider from '../../../LanguageProvider'; +import { escapeLabelValueInExactSelector } from '../../../languageUtils'; import { ParserAndLabelKeysResult, LokiQuery } from '../../../types'; import { Label } from './situation'; diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts index f3c6c3f94f3e8..de76f3fc915ba 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts @@ -1,9 +1,9 @@ import { dateTime } from '@grafana/data'; -import { Monaco, monacoTypes } from '@grafana/ui/src'; +import { Monaco, monacoTypes } from '@grafana/ui'; import LokiLanguageProvider from '../../../LanguageProvider'; +import { createLokiDatasource } from '../../../__mocks__/datasource'; import { LokiDatasource } from '../../../datasource'; -import { createLokiDatasource } from '../../../mocks'; import { CompletionDataProvider } from './CompletionDataProvider'; import { calculateRange } from './completionUtils'; diff --git a/public/app/plugins/datasource/loki/configuration/AlertingSettings.test.tsx b/public/app/plugins/datasource/loki/configuration/AlertingSettings.test.tsx index 71a9d48ca7630..73686c4b3afe0 100644 --- a/public/app/plugins/datasource/loki/configuration/AlertingSettings.test.tsx +++ b/public/app/plugins/datasource/loki/configuration/AlertingSettings.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { createDefaultConfigOptions } from '../mocks'; +import { createDefaultConfigOptions } from '../__mocks__/datasource'; import { AlertingSettings } from './AlertingSettings'; diff --git a/public/app/plugins/datasource/loki/configuration/AlertingSettings.tsx b/public/app/plugins/datasource/loki/configuration/AlertingSettings.tsx index 5bc969d00f872..d9c4c439ed2d9 100644 --- a/public/app/plugins/datasource/loki/configuration/AlertingSettings.tsx +++ b/public/app/plugins/datasource/loki/configuration/AlertingSettings.tsx @@ -1,9 +1,8 @@ import React from 'react'; import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; -import { ConfigSubSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/experimental'; import { InlineField, InlineSwitch } from '@grafana/ui'; -import { ConfigDescriptionLink } from 'app/core/components/ConfigDescriptionLink'; export function AlertingSettings({ options, diff --git a/public/app/plugins/datasource/loki/configuration/ConfigEditor.test.tsx b/public/app/plugins/datasource/loki/configuration/ConfigEditor.test.tsx index 3a974a94c8509..8afc34bc0b1bc 100644 --- a/public/app/plugins/datasource/loki/configuration/ConfigEditor.test.tsx +++ b/public/app/plugins/datasource/loki/configuration/ConfigEditor.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { createDefaultConfigOptions } from '../mocks'; +import { createDefaultConfigOptions } from '../__mocks__/datasource'; import { ConfigEditor } from './ConfigEditor'; diff --git a/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx index ff2ad03372944..fe57f47d35cb7 100644 --- a/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx @@ -10,8 +10,7 @@ import { AdvancedHttpSettings, } from '@grafana/experimental'; import { config, reportInteraction } from '@grafana/runtime'; -import { SecureSocksProxySettings } from '@grafana/ui'; -import { Divider } from 'app/core/components/Divider'; +import { Divider, SecureSocksProxySettings, Stack } from '@grafana/ui'; import { LokiOptions } from '../types'; @@ -55,40 +54,39 @@ export const ConfigEditor = (props: Props) => { docsLink="https://grafana.com/docs/grafana/latest/datasources/loki/configure-loki-data-source/" hasRequiredFields={false} /> - <Divider /> + <Divider spacing={4} /> <ConnectionSettings config={options} onChange={onOptionsChange} urlPlaceholder="http://localhost:3100" /> - <Divider /> + <Divider spacing={4} /> <Auth {...convertLegacyAuthProps({ config: options, onChange: onOptionsChange, })} /> - <Divider /> + <Divider spacing={4} /> <ConfigSection title="Additional settings" description="Additional settings are optional settings that can be configured for more control over your data source." isCollapsible={true} isInitiallyOpen > - <AdvancedHttpSettings config={options} onChange={onOptionsChange} /> - <Divider hideLine /> - {config.secureSocksDSProxyEnabled && ( - <SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} /> - )} - <AlertingSettings options={options} onOptionsChange={onOptionsChange} /> - <Divider hideLine /> - <QuerySettings - maxLines={options.jsonData.maxLines || ''} - onMaxLinedChange={(value) => onOptionsChange(setMaxLines(options, value))} - predefinedOperations={options.jsonData.predefinedOperations || ''} - onPredefinedOperationsChange={updatePredefinedOperations} - /> - <Divider hideLine /> - <DerivedFields - fields={options.jsonData.derivedFields} - onChange={(value) => onOptionsChange(setDerivedFields(options, value))} - /> + <Stack gap={5} direction="column"> + <AdvancedHttpSettings config={options} onChange={onOptionsChange} /> + {config.secureSocksDSProxyEnabled && ( + <SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} /> + )} + <AlertingSettings options={options} onOptionsChange={onOptionsChange} /> + <QuerySettings + maxLines={options.jsonData.maxLines || ''} + onMaxLinedChange={(value) => onOptionsChange(setMaxLines(options, value))} + predefinedOperations={options.jsonData.predefinedOperations || ''} + onPredefinedOperationsChange={updatePredefinedOperations} + /> + <DerivedFields + fields={options.jsonData.derivedFields} + onChange={(value) => onOptionsChange(setDerivedFields(options, value))} + /> + </Stack> </ConfigSection> </> ); diff --git a/public/app/plugins/datasource/loki/configuration/DebugSection.test.tsx b/public/app/plugins/datasource/loki/configuration/DebugSection.test.tsx index 6eb14771d1017..8600c84362c86 100644 --- a/public/app/plugins/datasource/loki/configuration/DebugSection.test.tsx +++ b/public/app/plugins/datasource/loki/configuration/DebugSection.test.tsx @@ -2,53 +2,18 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { dateTime, TimeRange } from '@grafana/data'; -import { setTemplateSrv } from '@grafana/runtime'; - -import { getLinkSrv, LinkService, LinkSrv, setLinkSrv } from '../../../../features/panel/panellinks/link_srv'; - import { DebugSection } from './DebugSection'; -// We do not need more here and TimeSrv is hard to setup fully. -jest.mock('app/features/dashboard/services/TimeSrv', () => ({ - getTimeSrv: () => ({ - timeRangeForUrl() { - const from = dateTime().subtract(1, 'h'); - const to = dateTime(); - return { from, to, raw: { from, to } }; +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getTemplateSrv: () => ({ + replace: (val: string) => { + return val; }, }), })); describe('DebugSection', () => { - let originalLinkSrv: LinkService; - - // This needs to be setup so we can test interpolation in the debugger - beforeAll(() => { - const linkService = new LinkSrv(); - originalLinkSrv = getLinkSrv(); - setLinkSrv(linkService); - }); - - beforeEach(() => { - setTemplateSrv({ - replace(target, scopedVars, format) { - return target ?? ''; - }, - getVariables() { - return []; - }, - containsTemplate() { - return false; - }, - updateTimeRange(timeRange: TimeRange) {}, - }); - }); - - afterAll(() => { - setLinkSrv(originalLinkSrv); - }); - it('does not render any table rows if no debug text', () => { render(<DebugSection derivedFields={[]} />); expect(screen.queryByRole('row')).not.toBeInTheDocument(); diff --git a/public/app/plugins/datasource/loki/configuration/DebugSection.tsx b/public/app/plugins/datasource/loki/configuration/DebugSection.tsx index c4a63a2ef2fcd..15bb53aa3cb87 100644 --- a/public/app/plugins/datasource/loki/configuration/DebugSection.tsx +++ b/public/app/plugins/datasource/loki/configuration/DebugSection.tsx @@ -1,9 +1,8 @@ import React, { ReactNode, useState } from 'react'; -import { Field, FieldType, LinkModel } from '@grafana/data'; +import { getTemplateSrv } from '@grafana/runtime'; import { InlineField, TextArea } from '@grafana/ui'; -import { getFieldLinksForExplore } from '../../../../features/explore/utils/links'; import { DerivedFieldConfig } from '../types'; type Props = { @@ -24,7 +23,7 @@ export const DebugSection = (props: Props) => { <InlineField label="Debug log message" labelWidth={24} grow> <TextArea type="text" - aria-label="Prometheus Query" + aria-label="Loki query" placeholder="Paste an example log line here to test the regular expressions of your derived fields" value={debugText} onChange={(event) => setDebugText(event.currentTarget.value)} @@ -82,36 +81,30 @@ function makeDebugFields(derivedFields: DerivedFieldConfig[], debugText: string) .map((field) => { try { const testMatch = debugText.match(field.matcherRegex); + let href; const value = testMatch && testMatch[1]; - let link: LinkModel<Field> | null = null; - if (field.url && value) { - link = getFieldLinksForExplore({ - field: { - name: '', - type: FieldType.string, - values: [value], - config: { - links: [{ title: '', url: field.url }], + if (value) { + href = getTemplateSrv().replace(field.url, { + __value: { + value: { + raw: value, }, + text: 'Raw value', }, - rowIndex: 0, - range: {} as any, - })[0]; + }); } - - const result: DebugField = { + const debugFiled: DebugField = { name: field.name, value: value || '<no match>', - href: link ? link.href : undefined, + href, }; - return result; + return debugFiled; } catch (error) { - const result: DebugField = { + return { name: field.name, error, }; - return result; } }); } diff --git a/public/app/plugins/datasource/loki/configuration/DerivedField.tsx b/public/app/plugins/datasource/loki/configuration/DerivedField.tsx index 5c7a4dd1a22c7..c135c1458feab 100644 --- a/public/app/plugins/datasource/loki/configuration/DerivedField.tsx +++ b/public/app/plugins/datasource/loki/configuration/DerivedField.tsx @@ -3,8 +3,8 @@ import React, { ChangeEvent, useEffect, useState } from 'react'; import { usePrevious } from 'react-use'; import { GrafanaTheme2, DataSourceInstanceSettings, VariableSuggestion } from '@grafana/data'; +import { DataSourcePicker } from '@grafana/runtime'; import { Button, DataLinkInput, Field, Icon, Input, Label, Tooltip, useStyles2, Select, Switch } from '@grafana/ui'; -import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; import { DerivedFieldConfig } from '../types'; diff --git a/public/app/plugins/datasource/loki/configuration/DerivedFields.test.tsx b/public/app/plugins/datasource/loki/configuration/DerivedFields.test.tsx index 47a5761f38885..f8e5bc24d4fb9 100644 --- a/public/app/plugins/datasource/loki/configuration/DerivedFields.test.tsx +++ b/public/app/plugins/datasource/loki/configuration/DerivedFields.test.tsx @@ -35,20 +35,20 @@ describe('DerivedFields', () => { const onChange = jest.fn(); render(<DerivedFields onChange={onChange} />); - userEvent.click(screen.getByText('Add')); + const addButton = await screen.findByText('Add'); + await userEvent.click(addButton); await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1)); }); - // TODO: I saw this test being flaky lately, so I commented it out for now - // it('removes a field', async () => { - // const onChange = jest.fn(); - // render(<DerivedFields fields={testFields} onChange={onChange} />); + it('removes a field', async () => { + const onChange = jest.fn(); + render(<DerivedFields fields={testFields} onChange={onChange} />); - // userEvent.click((await screen.findAllByTitle('Remove field'))[0]); + await userEvent.click((await screen.findAllByTitle('Remove field'))[0]); - // await waitFor(() => expect(onChange).toHaveBeenCalledWith([testFields[1]])); - // }); + await waitFor(() => expect(onChange).toHaveBeenCalledWith([testFields[1]])); + }); it('validates duplicated field names', async () => { const repeatedFields = [ @@ -63,12 +63,13 @@ describe('DerivedFields', () => { ]; render(<DerivedFields onChange={jest.fn()} fields={repeatedFields} />); - userEvent.click(screen.getAllByPlaceholderText('Field name')[0]); + const inputs = await screen.findAllByPlaceholderText('Field name'); + await userEvent.click(inputs[0]); expect(await screen.findAllByText('The name is already in use')).toHaveLength(2); }); - it('does not validate empty names as repeated', () => { + it('does not validate empty names as repeated', async () => { const repeatedFields = [ { matcherRegex: '', @@ -81,7 +82,8 @@ describe('DerivedFields', () => { ]; render(<DerivedFields onChange={jest.fn()} fields={repeatedFields} />); - userEvent.click(screen.getAllByPlaceholderText('Field name')[0]); + const inputs = await screen.findAllByPlaceholderText('Field name'); + await userEvent.click(inputs[0]); expect(screen.queryByText('The name is already in use')).not.toBeInTheDocument(); }); diff --git a/public/app/plugins/datasource/loki/configuration/DerivedFields.tsx b/public/app/plugins/datasource/loki/configuration/DerivedFields.tsx index 77019368db1d5..a8c28adb78007 100644 --- a/public/app/plugins/datasource/loki/configuration/DerivedFields.tsx +++ b/public/app/plugins/datasource/loki/configuration/DerivedFields.tsx @@ -2,9 +2,8 @@ import { css } from '@emotion/css'; import React, { useCallback, useState } from 'react'; import { GrafanaTheme2, VariableOrigin, DataLinkBuiltInVars } from '@grafana/data'; -import { ConfigSubSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/experimental'; import { Button, useTheme2 } from '@grafana/ui'; -import { ConfigDescriptionLink } from 'app/core/components/ConfigDescriptionLink'; import { DerivedFieldConfig } from '../types'; diff --git a/public/app/plugins/datasource/loki/configuration/QuerySettings.tsx b/public/app/plugins/datasource/loki/configuration/QuerySettings.tsx index 27b2265c7c1bf..7cdbc8a2777fb 100644 --- a/public/app/plugins/datasource/loki/configuration/QuerySettings.tsx +++ b/public/app/plugins/datasource/loki/configuration/QuerySettings.tsx @@ -1,9 +1,8 @@ import React from 'react'; -import { ConfigSubSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/experimental'; import { config } from '@grafana/runtime'; import { Badge, InlineField, InlineFieldRow, Input } from '@grafana/ui'; -import { ConfigDescriptionLink } from 'app/core/components/ConfigDescriptionLink'; type Props = { maxLines: string; diff --git a/public/app/plugins/datasource/loki/dataquery.cue b/public/app/plugins/datasource/loki/dataquery.cue index 97adc14a1450c..97bc3103ef872 100644 --- a/public/app/plugins/datasource/loki/dataquery.cue +++ b/public/app/plugins/datasource/loki/dataquery.cue @@ -47,7 +47,7 @@ composableKinds: DataQuery: { #LokiQueryType: "range" | "instant" | "stream" @cuetsy(kind="enum") - #SupportingQueryType: "logsVolume" | "logsSample" | "dataSample" @cuetsy(kind="enum") + #SupportingQueryType: "logsVolume" | "logsSample" | "dataSample" | "infiniteScroll" @cuetsy(kind="enum") #LokiQueryDirection: "forward" | "backward" @cuetsy(kind="enum") } diff --git a/public/app/plugins/datasource/loki/dataquery.gen.ts b/public/app/plugins/datasource/loki/dataquery.gen.ts index 0a3ffb479e817..063deb3ee2f40 100644 --- a/public/app/plugins/datasource/loki/dataquery.gen.ts +++ b/public/app/plugins/datasource/loki/dataquery.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -23,6 +23,7 @@ export enum LokiQueryType { export enum SupportingQueryType { DataSample = 'dataSample', + InfiniteScroll = 'infiniteScroll', LogsSample = 'logsSample', LogsVolume = 'logsVolume', } @@ -32,7 +33,7 @@ export enum LokiQueryDirection { Forward = 'forward', } -export interface Loki extends common.DataQuery { +export interface LokiDataQuery extends common.DataQuery { editorMode?: QueryEditorMode; /** * The LogQL query. diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts index 599d3ec1d708e..8509269a28b97 100644 --- a/public/app/plugins/datasource/loki/datasource.test.ts +++ b/public/app/plugins/datasource/loki/datasource.test.ts @@ -1,6 +1,5 @@ import { of } from 'rxjs'; import { take } from 'rxjs/operators'; -import { getQueryOptions } from 'test/helpers/getQueryOptions'; import { AbstractLabelOperator, @@ -18,6 +17,8 @@ import { toDataFrame, TimeRange, ToggleFilterAction, + DataQueryRequest, + ScopedVars, } from '@grafana/data'; import { BackendSrv, @@ -31,10 +32,10 @@ import { } from '@grafana/runtime'; import { LokiVariableSupport } from './LokiVariableSupport'; +import { createLokiDatasource } from './__mocks__/datasource'; +import { createMetadataRequest } from './__mocks__/metadataRequest'; import { LokiDatasource, REF_ID_DATA_SAMPLES } from './datasource'; -import { createLokiDatasource, createMetadataRequest } from './mocks'; import { runSplitQuery } from './querySplitting'; -import { parseToNodeNamesArray } from './queryUtils'; import { LokiOptions, LokiQuery, LokiQueryType, LokiVariableQueryType, SupportingQueryType } from './types'; jest.mock('@grafana/runtime', () => { @@ -121,6 +122,17 @@ const mockTimeRange = { raw: { from: dateTime(0), to: dateTime(1) }, }; +const baseRequestOptions = { + requestId: '', + interval: '', + intervalMs: 1, + range: mockTimeRange, + scopedVars: {}, + timezone: '', + app: '', + startTime: 1, +}; + interface AdHocFilter { condition: string; key: string; @@ -159,10 +171,11 @@ describe('LokiDatasource', () => { // and applyTemplateVariables is a convenient place to do that. const spy = jest.spyOn(ds, 'applyTemplateVariables'); - const options = getQueryOptions<LokiQuery>({ + const options: DataQueryRequest<LokiQuery> = { + ...baseRequestOptions, targets: [{ expr: '{a="b"}', refId: 'B', maxLines: queryMaxLines }], app: app ?? CoreApp.Dashboard, - }); + }; const fetchMock = jest.fn().mockReturnValue(of({ data: testLogsResponse })); setBackendSrv({ ...origBackendSrv, fetch: fetchMock }); @@ -196,7 +209,7 @@ describe('LokiDatasource', () => { expect.objectContaining({ query_type: 'logs', line_limit: 80, - parsed_query: parseToNodeNamesArray('{a="b"}').join(','), + obfuscated_query: '{Identifier=String}', }) ); }); @@ -213,7 +226,7 @@ describe('LokiDatasource', () => { expect.objectContaining({ query_type: 'logs', line_limit: 80, - parsed_query: parseToNodeNamesArray('{a="b"}').join(','), + obfuscated_query: '{Identifier=String}', }) ); }); @@ -1238,38 +1251,42 @@ describe('LokiDatasource', () => { }); it('creates provider for logs query', () => { - const options = getQueryOptions<LokiQuery>({ + const options: DataQueryRequest<LokiQuery> = { + ...baseRequestOptions, targets: [{ expr: '{label="value"}', refId: 'A', queryType: LokiQueryType.Range }], - }); + }; - expect(ds.getDataProvider(SupplementaryQueryType.LogsVolume, options)).toBeDefined(); + expect(ds.getSupplementaryRequest(SupplementaryQueryType.LogsVolume, options)).toBeDefined(); }); it('does not create provider for metrics query', () => { - const options = getQueryOptions<LokiQuery>({ + const options: DataQueryRequest<LokiQuery> = { + ...baseRequestOptions, targets: [{ expr: 'rate({label="value"}[1m])', refId: 'A' }], - }); + }; - expect(ds.getDataProvider(SupplementaryQueryType.LogsVolume, options)).not.toBeDefined(); + expect(ds.getSupplementaryRequest(SupplementaryQueryType.LogsVolume, options)).not.toBeDefined(); }); it('creates provider if at least one query is a logs query', () => { - const options = getQueryOptions<LokiQuery>({ + const options: DataQueryRequest<LokiQuery> = { + ...baseRequestOptions, targets: [ { expr: 'rate({label="value"}[1m])', queryType: LokiQueryType.Range, refId: 'A' }, { expr: '{label="value"}', queryType: LokiQueryType.Range, refId: 'B' }, ], - }); + }; - expect(ds.getDataProvider(SupplementaryQueryType.LogsVolume, options)).toBeDefined(); + expect(ds.getSupplementaryRequest(SupplementaryQueryType.LogsVolume, options)).toBeDefined(); }); it('does not create provider if there is only an instant logs query', () => { - const options = getQueryOptions<LokiQuery>({ + const options: DataQueryRequest<LokiQuery> = { + ...baseRequestOptions, targets: [{ expr: '{label="value"', refId: 'A', queryType: LokiQueryType.Instant }], - }); + }; - expect(ds.getDataProvider(SupplementaryQueryType.LogsVolume, options)).not.toBeDefined(); + expect(ds.getSupplementaryRequest(SupplementaryQueryType.LogsVolume, options)).not.toBeDefined(); }); }); @@ -1280,30 +1297,33 @@ describe('LokiDatasource', () => { }); it('creates provider for metrics query', () => { - const options = getQueryOptions<LokiQuery>({ + const options: DataQueryRequest<LokiQuery> = { + ...baseRequestOptions, targets: [{ expr: 'rate({label="value"}[5m])', refId: 'A' }], - }); + }; - expect(ds.getDataProvider(SupplementaryQueryType.LogsSample, options)).toBeDefined(); + expect(ds.getSupplementaryRequest(SupplementaryQueryType.LogsSample, options)).toBeDefined(); }); it('does not create provider for log query', () => { - const options = getQueryOptions<LokiQuery>({ + const options: DataQueryRequest<LokiQuery> = { + ...baseRequestOptions, targets: [{ expr: '{label="value"}', refId: 'A' }], - }); + }; - expect(ds.getDataProvider(SupplementaryQueryType.LogsSample, options)).not.toBeDefined(); + expect(ds.getSupplementaryRequest(SupplementaryQueryType.LogsSample, options)).not.toBeDefined(); }); it('creates provider if at least one query is a metric query', () => { - const options = getQueryOptions<LokiQuery>({ + const options: DataQueryRequest<LokiQuery> = { + ...baseRequestOptions, targets: [ { expr: 'rate({label="value"}[1m])', refId: 'A' }, { expr: '{label="value"}', refId: 'B' }, ], - }); + }; - expect(ds.getDataProvider(SupplementaryQueryType.LogsSample, options)).toBeDefined(); + expect(ds.getSupplementaryRequest(SupplementaryQueryType.LogsSample, options)).toBeDefined(); }); }); @@ -1329,6 +1349,7 @@ describe('LokiDatasource', () => { queryType: LokiQueryType.Range, refId: 'log-volume-A', supportingQueryType: SupportingQueryType.LogsVolume, + legendFormat: '{{ level }}', }); }); @@ -1347,6 +1368,7 @@ describe('LokiDatasource', () => { queryType: LokiQueryType.Range, refId: 'log-volume-A', supportingQueryType: SupportingQueryType.LogsVolume, + legendFormat: '{{ level }}', }); }); @@ -1375,6 +1397,30 @@ describe('LokiDatasource', () => { ) ).toEqual(undefined); }); + + it('return logs volume query with defined field', () => { + const query = ds.getSupplementaryQuery( + { type: SupplementaryQueryType.LogsVolume, field: 'test' }, + { + expr: '{label="value"}', + queryType: LokiQueryType.Range, + refId: 'A', + } + ); + expect(query?.expr).toEqual('sum by (test) (count_over_time({label="value"} | drop __error__[$__auto]))'); + }); + + it('return logs volume query with level as field if no field specified', () => { + const query = ds.getSupplementaryQuery( + { type: SupplementaryQueryType.LogsVolume }, + { + expr: '{label="value"}', + queryType: LokiQueryType.Range, + refId: 'A', + } + ); + expect(query?.expr).toEqual('sum by (level) (count_over_time({label="value"} | drop __error__[$__auto]))'); + }); }); describe('logs sample', () => { @@ -1550,10 +1596,11 @@ describe('LokiDatasource', () => { ], ])('supports query splitting when the requirements are met', async (targets: LokiQuery[]) => { const ds = createLokiDatasource(templateSrvStub); - const query = getQueryOptions<LokiQuery>({ + const query: DataQueryRequest<LokiQuery> = { + ...baseRequestOptions, targets, app: CoreApp.Dashboard, - }); + }; await expect(ds.query(query)).toEmitValuesWith(() => { expect(runSplitQuery).toHaveBeenCalled(); @@ -1632,6 +1679,19 @@ describe('LokiDatasource', () => { }).rejects.toThrow('invalid metadata request url: /index'); }); }); + + describe('live tailing', () => { + it('interpolates variables with scopedVars and filters', () => { + const ds = createLokiDatasource(); + const query: LokiQuery = { expr: '{app=$app}', refId: 'A' }; + const scopedVars: ScopedVars = { app: { text: 'interpolated', value: 'interpolated' } }; + const filters: AdHocFilter[] = []; + + jest.spyOn(ds, 'applyTemplateVariables').mockImplementation((query) => query); + ds.query({ targets: [query], scopedVars, filters, liveStreaming: true } as DataQueryRequest<LokiQuery>); + expect(ds.applyTemplateVariables).toHaveBeenCalledWith(expect.objectContaining(query), scopedVars, filters); + }); + }); }); describe('applyTemplateVariables', () => { diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index 29e6fe3b73506..74c1a814c94b8 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -17,11 +17,8 @@ import { SupplementaryQueryType, DataSourceWithQueryExportSupport, DataSourceWithQueryImportSupport, - FieldCache, - FieldType, Labels, LoadingState, - LogLevel, LogRowModel, QueryFixAction, QueryHint, @@ -41,14 +38,13 @@ import { DataSourceGetTagValuesOptions, DataSourceGetTagKeysOptions, DataSourceWithQueryModificationSupport, + LogsVolumeOption, + LogsSampleOptions, } from '@grafana/data'; import { Duration } from '@grafana/lezer-logql'; import { BackendSrvRequest, config, DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; -import { queryLogsSample, queryLogsVolume } from '../../../features/logs/logsModel'; -import { getLogLevelFromKey } from '../../../features/logs/utils'; - import LanguageProvider from './LanguageProvider'; import { LiveStreams, LokiLiveTarget } from './LiveStreams'; import { LogContextProvider } from './LogContextProvider'; @@ -168,21 +164,23 @@ export class LokiDatasource /** * Implemented for DataSourceWithSupplementaryQueriesSupport. - * It retrieves a data provider for a specific supplementary query type. - * @returns An Observable of DataQueryResponse or undefined if the specified query type is not supported. + * It generates a DataQueryRequest for a specific supplementary query type. + * @returns A DataQueryRequest for the supplementary queries or undefined if not supported. */ - getDataProvider( + getSupplementaryRequest( type: SupplementaryQueryType, - request: DataQueryRequest<LokiQuery> - ): Observable<DataQueryResponse> | undefined { - if (!this.getSupportedSupplementaryQueryTypes().includes(type)) { - return undefined; - } + request: DataQueryRequest<LokiQuery>, + options?: SupplementaryQueryOptions + ): DataQueryRequest<LokiQuery> | undefined { switch (type) { case SupplementaryQueryType.LogsVolume: - return this.getLogsVolumeDataProvider(request); + const logsVolumeOption: LogsVolumeOption = + options?.type === SupplementaryQueryType.LogsVolume ? options : { type }; + return this.getLogsVolumeDataProvider(request, logsVolumeOption); case SupplementaryQueryType.LogsSample: - return this.getLogsSampleDataProvider(request); + const logsSampleOption: LogsSampleOptions = + options?.type === SupplementaryQueryType.LogsSample ? options : { type }; + return this.getLogsSampleDataProvider(request, logsSampleOption); default: return undefined; } @@ -203,10 +201,6 @@ export class LokiDatasource * @returns A supplemented Loki query or undefined if unsupported. */ getSupplementaryQuery(options: SupplementaryQueryOptions, query: LokiQuery): LokiQuery | undefined { - if (!this.getSupportedSupplementaryQueryTypes().includes(options.type)) { - return undefined; - } - const normalizedQuery = getNormalizedLokiQuery(query); let expr = removeCommentsFromQuery(normalizedQuery.expr); let isQuerySuitable = false; @@ -220,6 +214,7 @@ export class LokiDatasource } const dropErrorExpression = `${expr} | drop __error__`; + const field = options.field || 'level'; if (isQueryWithError(this.interpolateString(dropErrorExpression, placeHolderScopedVars)) === false) { expr = dropErrorExpression; } @@ -229,7 +224,8 @@ export class LokiDatasource refId: `${REF_ID_STARTER_LOG_VOLUME}${normalizedQuery.refId}`, queryType: LokiQueryType.Range, supportingQueryType: SupportingQueryType.LogsVolume, - expr: `sum by (level) (count_over_time(${expr}[$__auto]))`, + expr: `sum by (${field}) (count_over_time(${expr}[$__auto]))`, + legendFormat: `{{ ${field} }}`, }; case SupplementaryQueryType.LogsSample: @@ -255,32 +251,30 @@ export class LokiDatasource * Private method used in the `getDataProvider` for DataSourceWithSupplementaryQueriesSupport, specifically for Logs volume queries. * @returns An Observable of DataQueryResponse or undefined if no suitable queries are found. */ - private getLogsVolumeDataProvider(request: DataQueryRequest<LokiQuery>): Observable<DataQueryResponse> | undefined { + private getLogsVolumeDataProvider( + request: DataQueryRequest<LokiQuery>, + options: LogsVolumeOption + ): DataQueryRequest<LokiQuery> | undefined { const logsVolumeRequest = cloneDeep(request); const targets = logsVolumeRequest.targets - .map((query) => this.getSupplementaryQuery({ type: SupplementaryQueryType.LogsVolume }, query)) + .map((query) => this.getSupplementaryQuery(options, query)) .filter((query): query is LokiQuery => !!query); if (!targets.length) { return undefined; } - return queryLogsVolume( - this, - { ...logsVolumeRequest, targets }, - { - extractLevel, - range: request.range, - targets: request.targets, - } - ); + return { ...logsVolumeRequest, targets }; } /** * Private method used in the `getDataProvider` for DataSourceWithSupplementaryQueriesSupport, specifically for Logs sample queries. * @returns An Observable of DataQueryResponse or undefined if no suitable queries are found. */ - private getLogsSampleDataProvider(request: DataQueryRequest<LokiQuery>): Observable<DataQueryResponse> | undefined { + private getLogsSampleDataProvider( + request: DataQueryRequest<LokiQuery>, + options?: LogsSampleOptions + ): DataQueryRequest<LokiQuery> | undefined { const logsSampleRequest = cloneDeep(request); const targets = logsSampleRequest.targets .map((query) => this.getSupplementaryQuery({ type: SupplementaryQueryType.LogsSample, limit: 100 }, query)) @@ -289,7 +283,7 @@ export class LokiDatasource if (!targets.length) { return undefined; } - return queryLogsSample(this, { ...logsSampleRequest, targets }); + return { ...logsSampleRequest, targets }; } /** @@ -321,7 +315,7 @@ export class LokiDatasource return merge( ...streamQueries.map((q) => doLokiChannelStream( - this.applyTemplateVariables(q, request.scopedVars), + this.applyTemplateVariables(q, request.scopedVars, request.filters), this, // the datasource streamRequest ) @@ -361,15 +355,13 @@ export class LokiDatasource /** * Used within the `query` to execute live queries. - * It is intended for explore-mode and logs-queries, not metric queries. + * It is intended for logs-queries, not metric queries. * @returns An Observable of DataQueryResponse with live query results or an empty response if no suitable queries are found. * @todo: The name says "backend" but it's actually running the query through the frontend. We should fix this. */ private runLiveQueryThroughBackend(request: DataQueryRequest<LokiQuery>): Observable<DataQueryResponse> { - // this only works in explore-mode so variables don't need to be handled, // and only for logs-queries, not metric queries const logsQueries = request.targets.filter((query) => query.expr !== '' && isLogsQuery(query.expr)); - if (logsQueries.length === 0) { return of({ data: [], @@ -378,9 +370,10 @@ export class LokiDatasource } const subQueries = logsQueries.map((query) => { - const maxDataPoints = query.maxLines || this.maxLines; + const interpolatedQuery = this.applyTemplateVariables(query, request.scopedVars, request.filters); + const maxDataPoints = interpolatedQuery.maxLines || this.maxLines; // FIXME: currently we are running it through the frontend still. - return this.runLiveQuery(query, maxDataPoints); + return this.runLiveQuery(interpolatedQuery, maxDataPoints); }); return merge(...subQueries); @@ -753,7 +746,7 @@ export class LokiDatasource * Implemented as part of the DataSourceAPI. Retrieves tag keys that can be used for ad-hoc filtering. * @returns A Promise that resolves to an array of label names represented as MetricFindValue objects. */ - async getTagKeys(options?: DataSourceGetTagKeysOptions): Promise<MetricFindValue[]> { + async getTagKeys(options?: DataSourceGetTagKeysOptions<LokiQuery>): Promise<MetricFindValue[]> { const result = await this.languageProvider.fetchLabels({ timeRange: options?.timeRange }); return result.map((value: string) => ({ text: value })); } @@ -1171,23 +1164,3 @@ export function lokiSpecialRegexEscape(value: any) { } return value; } - -function extractLevel(dataFrame: DataFrame): LogLevel { - let valueField; - try { - valueField = new FieldCache(dataFrame).getFirstFieldOfType(FieldType.number); - } catch {} - return valueField?.labels ? getLogLevelFromLabels(valueField.labels) : LogLevel.unknown; -} - -function getLogLevelFromLabels(labels: Labels): LogLevel { - const labelNames = ['level', 'lvl', 'loglevel']; - let levelLabel; - for (let labelName of labelNames) { - if (labelName in labels) { - levelLabel = labelName; - break; - } - } - return levelLabel ? getLogLevelFromKey(labels[levelLabel]) : LogLevel.unknown; -} diff --git a/public/app/plugins/datasource/loki/getDerivedFields.test.ts b/public/app/plugins/datasource/loki/getDerivedFields.test.ts index f5d13c339f1f2..41004b16c1fe4 100644 --- a/public/app/plugins/datasource/loki/getDerivedFields.test.ts +++ b/public/app/plugins/datasource/loki/getDerivedFields.test.ts @@ -3,7 +3,6 @@ import { createDataFrame } from '@grafana/data'; import { getDerivedFields } from './getDerivedFields'; jest.mock('@grafana/runtime', () => ({ - // @ts-ignore ...jest.requireActual('@grafana/runtime'), getDataSourceSrv: () => { return { diff --git a/public/app/plugins/datasource/loki/languageUtils.test.ts b/public/app/plugins/datasource/loki/languageUtils.test.ts index f240d816a12f3..ad36b672f87bb 100644 --- a/public/app/plugins/datasource/loki/languageUtils.test.ts +++ b/public/app/plugins/datasource/loki/languageUtils.test.ts @@ -1,9 +1,11 @@ -import { toDataFrame, FieldType } from '@grafana/data'; +import { toDataFrame, FieldType, AbstractQuery, AbstractLabelOperator } from '@grafana/data'; import { + abstractQueryToExpr, escapeLabelValueInExactSelector, getLabelTypeFromFrame, isBytesString, + processLabels, unescapeLabelValue, } from './languageUtils'; import { LabelType } from './types'; @@ -90,3 +92,38 @@ describe('getLabelTypeFromFrame', () => { expect(getLabelTypeFromFrame('job', frameWithoutTypes, 0)).toBe(null); }); }); + +describe('abstractQueryToExpr', () => { + it('export abstract query to expr', () => { + const abstractQuery: AbstractQuery = { + refId: 'bar', + labelMatchers: [ + { name: 'label1', operator: AbstractLabelOperator.Equal, value: 'value1' }, + { name: 'label2', operator: AbstractLabelOperator.NotEqual, value: 'value2' }, + { name: 'label3', operator: AbstractLabelOperator.EqualRegEx, value: 'value3' }, + { name: 'label4', operator: AbstractLabelOperator.NotEqualRegEx, value: 'value4' }, + ], + }; + + expect(abstractQueryToExpr(abstractQuery)).toBe( + '{label1="value1", label2!="value2", label3=~"value3", label4!~"value4"}' + ); + }); +}); + +describe('processLabels', () => { + it('export abstract query to expr', () => { + const labels: Array<{ [key: string]: string }> = [ + { label1: 'value1' }, + { label2: 'value2' }, + { label3: 'value3' }, + { label1: 'value1' }, + { label1: 'value1b' }, + ]; + + expect(processLabels(labels)).toEqual({ + keys: ['label1', 'label2', 'label3'], + values: { label1: ['value1', 'value1b'], label2: ['value2'], label3: ['value3'] }, + }); + }); +}); diff --git a/public/app/plugins/datasource/loki/languageUtils.ts b/public/app/plugins/datasource/loki/languageUtils.ts index c79d720fbd434..04c842d1288b3 100644 --- a/public/app/plugins/datasource/loki/languageUtils.ts +++ b/public/app/plugins/datasource/loki/languageUtils.ts @@ -1,4 +1,6 @@ -import { DataFrame, TimeRange } from '@grafana/data'; +import { invert } from 'lodash'; + +import { AbstractLabelMatcher, AbstractLabelOperator, AbstractQuery, DataFrame, TimeRange } from '@grafana/data'; import { LabelType } from './types'; @@ -111,3 +113,56 @@ export function getLabelTypeFromFrame(labelKey: string, frame?: DataFrame, index return null; } } + +export const mapOpToAbstractOp: Record<AbstractLabelOperator, string> = { + [AbstractLabelOperator.Equal]: '=', + [AbstractLabelOperator.NotEqual]: '!=', + [AbstractLabelOperator.EqualRegEx]: '=~', + [AbstractLabelOperator.NotEqualRegEx]: '!~', +}; + +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +export const mapAbstractOperatorsToOp = invert(mapOpToAbstractOp) as Record<string, AbstractLabelOperator>; + +export function abstractQueryToExpr(labelBasedQuery: AbstractQuery): string { + const expr = labelBasedQuery.labelMatchers + .map((selector: AbstractLabelMatcher) => { + const operator = mapOpToAbstractOp[selector.operator]; + if (operator) { + return `${selector.name}${operator}"${selector.value}"`; + } else { + return ''; + } + }) + .filter((e: string) => e !== '') + .join(', '); + + return expr ? `{${expr}}` : ''; +} + +export function processLabels(labels: Array<{ [key: string]: string }>) { + const valueSet: { [key: string]: Set<string> } = {}; + labels.forEach((label) => { + Object.keys(label).forEach((key) => { + if (!valueSet[key]) { + valueSet[key] = new Set(); + } + if (!valueSet[key].has(label[key])) { + valueSet[key].add(label[key]); + } + }); + }); + + const valueArray: { [key: string]: string[] } = {}; + limitSuggestions(Object.keys(valueSet)).forEach((key) => { + valueArray[key] = limitSuggestions(Array.from(valueSet[key])); + }); + + return { values: valueArray, keys: Object.keys(valueArray) }; +} + +// Max number of items (metrics, labels, values) that we display as suggestions. Prevents from running out of memory. +export const SUGGESTIONS_LIMIT = 10000; +export function limitSuggestions(items: string[]) { + return items.slice(0, SUGGESTIONS_LIMIT); +} diff --git a/public/app/plugins/datasource/loki/makeTableFrames.ts b/public/app/plugins/datasource/loki/makeTableFrames.ts index 217f3e9058a35..57224a3b0bb08 100644 --- a/public/app/plugins/datasource/loki/makeTableFrames.ts +++ b/public/app/plugins/datasource/loki/makeTableFrames.ts @@ -12,8 +12,8 @@ export function makeTableFrames(instantMetricFrames: DataFrame[]): DataFrame[] { return Object.entries(framesByRefId).map(([refId, frames]) => makeTableFrame(frames, refId)); } -type NumberField = Field<number, number[]>; -type StringField = Field<string, string[]>; +type NumberField = Field<number>; +type StringField = Field<string>; function makeTableFrame(instantMetricFrames: DataFrame[], refId: string): DataFrame { const tableTimeField: NumberField = { name: 'Time', config: {}, values: [], type: FieldType.time }; diff --git a/public/app/plugins/datasource/loki/metricTimeSplitting.test.ts b/public/app/plugins/datasource/loki/metricTimeSplitting.test.ts index 52bd39e068bc0..8d836f8289464 100644 --- a/public/app/plugins/datasource/loki/metricTimeSplitting.test.ts +++ b/public/app/plugins/datasource/loki/metricTimeSplitting.test.ts @@ -1,15 +1,55 @@ import { splitTimeRange } from './metricTimeSplitting'; describe('metric splitTimeRange', () => { - it('should split time range into chunks', () => { - const start = Date.parse('2022-02-06T14:10:03'); - const end = Date.parse('2022-02-06T14:11:03'); - const step = 10 * 1000; + it('should split time range into chunks with 1day split and duration', () => { + const start = Date.parse('2022-02-06T14:10:03Z'); + const end = Date.parse('2022-02-08T14:11:03Z'); + const step = 24 * 60 * 60 * 1000; // 1 day + const rangeDuration = 24 * 60 * 60 * 1000; // 1 day + + expect(splitTimeRange(start, end, step, rangeDuration)).toStrictEqual([ + [Date.parse('2022-02-06T00:00:00Z'), Date.parse('2022-02-06T00:00:00Z')], + [Date.parse('2022-02-07T00:00:00Z'), Date.parse('2022-02-07T00:00:00Z')], + [Date.parse('2022-02-08T00:00:00Z'), Date.parse('2022-02-08T00:00:00Z')], + ]); + }); + + it('should split time range into chunks with 1day split and duration and a 5 minute duration', () => { + const start = Date.parse('2022-02-06T14:00:00Z'); + const end = Date.parse('2022-02-06T14:05:00Z'); + const step = 24 * 60 * 60 * 1000; // 1 day + const rangeDuration = 24 * 60 * 60 * 1000; // 1 day + + expect(splitTimeRange(start, end, step, rangeDuration)).toStrictEqual([ + [Date.parse('2022-02-06T00:00:00Z'), Date.parse('2022-02-06T00:00:00Z')], + ]); + }); + + it('should split time range into chunks with 1hour split and 1day duration', () => { + const start = Date.parse('2022-02-06T14:10:03Z'); + const end = Date.parse('2022-02-08T14:11:03Z'); + const step = 60 * 60 * 1000; // 1 hour + const rangeDuration = 24 * 60 * 60 * 1000; // 1 day + + expect(splitTimeRange(start, end, step, rangeDuration)).toStrictEqual([ + [Date.parse('2022-02-06T14:00:00Z'), Date.parse('2022-02-07T13:00:00Z')], + [Date.parse('2022-02-07T14:00:00Z'), Date.parse('2022-02-08T13:00:00Z')], + [Date.parse('2022-02-08T14:00:00Z'), Date.parse('2022-02-08T14:11:03Z')], + ]); + }); + + it('should split time range into chunks with 1hour split and 12h duration', () => { + const start = Date.parse('2022-02-06T14:10:03Z'); + const end = Date.parse('2022-02-08T14:11:03Z'); + const step = 60 * 60 * 1000; // 1 hour + const rangeDuration = 12 * 60 * 60 * 1000; // 12h - expect(splitTimeRange(start, end, step, 25000)).toStrictEqual([ - [Date.parse('2022-02-06T14:10:00'), Date.parse('2022-02-06T14:10:10')], - [Date.parse('2022-02-06T14:10:20'), Date.parse('2022-02-06T14:10:40')], - [Date.parse('2022-02-06T14:10:50'), Date.parse('2022-02-06T14:11:10')], + expect(splitTimeRange(start, end, step, rangeDuration)).toStrictEqual([ + [Date.parse('2022-02-06T14:00:00Z'), Date.parse('2022-02-07T01:00:00Z')], + [Date.parse('2022-02-07T02:00:00Z'), Date.parse('2022-02-07T13:00:00Z')], + [Date.parse('2022-02-07T14:00:00Z'), Date.parse('2022-02-08T01:00:00Z')], + [Date.parse('2022-02-08T02:00:00Z'), Date.parse('2022-02-08T13:00:00Z')], + [Date.parse('2022-02-08T14:00:00Z'), Date.parse('2022-02-08T14:11:03Z')], ]); }); diff --git a/public/app/plugins/datasource/loki/metricTimeSplitting.ts b/public/app/plugins/datasource/loki/metricTimeSplitting.ts index facbf19951536..0af619673ee3d 100644 --- a/public/app/plugins/datasource/loki/metricTimeSplitting.ts +++ b/public/app/plugins/datasource/loki/metricTimeSplitting.ts @@ -5,20 +5,6 @@ // we are trying to be compatible with // https://github.com/grafana/loki/blob/089ec1b05f5ec15a8851d0e8230153e0eeb4dcec/pkg/querier/queryrange/split_by_interval.go#L327-L336 -function expandTimeRange(startTime: number, endTime: number, step: number): [number, number] { - // startTime is decreased to the closes multiple-of-step, if necessary - const newStartTime = startTime - (startTime % step); - - // endTime is increased to the closed multiple-of-step, if necessary - let newEndTime = endTime; - const endStepMod = endTime % step; - if (endStepMod !== 0) { - newEndTime += step - endStepMod; - } - - return [newStartTime, newEndTime]; -} - export function splitTimeRange( startTime: number, endTime: number, @@ -33,20 +19,19 @@ export function splitTimeRange( // we make the duration a multiple of `step`, lowering it if necessary const alignedDuration = Math.trunc(idealRangeDuration / step) * step; - const [alignedStartTime, alignedEndTime] = expandTimeRange(startTime, endTime, step); + const alignedStartTime = startTime - (startTime % step); const result: Array<[number, number]> = []; - // we iterate it from the end, because we want to have the potentially smaller chunk at the end, not at the beginning - for (let chunkEndTime = alignedEndTime; chunkEndTime > alignedStartTime; chunkEndTime -= alignedDuration + step) { - // when we get close to the start of the time range, we need to be sure not - // to cross over the startTime - const chunkStartTime = Math.max(chunkEndTime - alignedDuration, alignedStartTime); + // in a previous version we started iterating from the end, to the start. + // However this is not easily possible as end timestamps are always inclusive + // for Loki. So a `2022-02-08T00:00:00Z` end time with a 1day step would mean + // to include the 08.02.2022, which we don't want. So we have to start from + // the start, always ending at the last step before the actual end, or the total end. + for (let chunkStartTime = alignedStartTime; chunkStartTime < endTime; chunkStartTime += alignedDuration) { + const chunkEndTime = Math.min(chunkStartTime + alignedDuration - step, endTime); result.push([chunkStartTime, chunkEndTime]); } - // because we walked backwards, we need to reverse the array - result.reverse(); - return result; } diff --git a/public/app/plugins/datasource/loki/modifyQuery.test.ts b/public/app/plugins/datasource/loki/modifyQuery.test.ts index f3c953816f3b5..90f6e8d876baa 100644 --- a/public/app/plugins/datasource/loki/modifyQuery.test.ts +++ b/public/app/plugins/datasource/loki/modifyQuery.test.ts @@ -98,6 +98,18 @@ describe('addLabelToQuery()', () => { '{foo="bar"} | logfmt | forcedLabel=`value`' ); }); + + it('should add label as labelFilter to multiple places if label is StructuredMetadata', () => { + expect( + addLabelToQuery( + 'rate({foo="bar"} [$__auto]) / rate({foo="bar"} [$__auto])', + 'forcedLabel', + '=', + 'value', + LabelType.StructuredMetadata + ) + ).toEqual('rate({foo="bar"} | forcedLabel=`value` [$__auto]) / rate({foo="bar"} | forcedLabel=`value` [$__auto])'); + }); }); describe('addParserToQuery', () => { diff --git a/public/app/plugins/datasource/loki/modifyQuery.ts b/public/app/plugins/datasource/loki/modifyQuery.ts index db28c7a4ba22c..130c29c0a689d 100644 --- a/public/app/plugins/datasource/loki/modifyQuery.ts +++ b/public/app/plugins/datasource/loki/modifyQuery.ts @@ -1,6 +1,7 @@ import { NodeType, SyntaxNode } from '@lezer/common'; import { sortBy } from 'lodash'; +import { QueryBuilderLabelFilter } from '@grafana/experimental'; import { Identifier, LabelFilter, @@ -22,8 +23,6 @@ import { Expr, } from '@grafana/lezer-logql'; -import { QueryBuilderLabelFilter } from '../prometheus/querybuilder/shared/types'; - import { unescapeLabelValue } from './languageUtils'; import { getNodePositionsFromQuery } from './queryUtils'; import { lokiQueryModeller as modeller } from './querybuilder/LokiQueryModeller'; @@ -172,8 +171,13 @@ export function addLabelToQuery( const filter = toLabelFilter(key, value, operator); if (labelType === LabelType.Parsed || labelType === LabelType.StructuredMetadata) { - const positionToAdd = findLastPosition([...streamSelectorPositions, ...labelFilterPositions, ...parserPositions]); - return addFilterAsLabelFilter(query, [positionToAdd], filter); + const lastPositionsPerExpression = getLastPositionPerExpression(query, [ + ...streamSelectorPositions, + ...labelFilterPositions, + ...parserPositions, + ]); + + return addFilterAsLabelFilter(query, lastPositionsPerExpression, filter); } else if (labelType === LabelType.Indexed) { return addFilterToStreamSelector(query, streamSelectorPositions, filter); } else { @@ -184,23 +188,31 @@ export function addLabelToQuery( } else { // If `labelType` is not set, it indicates a potential metric query (`labelType` is present only in log queries that came from a Loki instance supporting the `categorize-labels` API). In case we are not adding the label to stream selectors we need to find the last position to add in each expression. // E.g. in `sum(rate({foo="bar"} | logfmt [$__auto])) / sum(rate({foo="baz"} | logfmt [$__auto]))` we need to add the label at two places. - const subExpressions = findLeaves(getNodePositionsFromQuery(query, [Expr])); - const parserFilterPositions = [...parserPositions, ...labelFilterPositions]; - - // find last position for each subexpression - const lastPositionsPerExpression = subExpressions.map((subExpression) => { - return findLastPosition( - parserFilterPositions.filter((p) => { - return subExpression.contains(p); - }) - ); - }); + const lastPositionsPerExpression = getLastPositionPerExpression(query, [ + ...parserPositions, + ...labelFilterPositions, + ]); return addFilterAsLabelFilter(query, lastPositionsPerExpression, filter); } } } +function getLastPositionPerExpression(query: string, positions: NodePosition[]): NodePosition[] { + const subExpressions = findLeaves(getNodePositionsFromQuery(query, [Expr])); + const subPositions = [...positions]; + + // find last position for each subexpression + const lastPositionsPerExpression = subExpressions.map((subExpression) => { + return findLastPosition( + subPositions.filter((p) => { + return subExpression.contains(p); + }) + ); + }); + return lastPositionsPerExpression; +} + /** * Adds parser to existing query. Useful for query modification for hints. * It uses LogQL parser to find instances of stream selectors or line filters and adds parser after them. diff --git a/public/app/plugins/datasource/loki/querySplitting.test.ts b/public/app/plugins/datasource/loki/querySplitting.test.ts index 83c51e9e95a28..f6faeee5c67da 100644 --- a/public/app/plugins/datasource/loki/querySplitting.test.ts +++ b/public/app/plugins/datasource/loki/querySplitting.test.ts @@ -1,12 +1,12 @@ import { of } from 'rxjs'; -import { getQueryOptions } from 'test/helpers/getQueryOptions'; -import { dateTime, LoadingState } from '@grafana/data'; +import { DataQueryRequest, dateTime, LoadingState } from '@grafana/data'; +import { createLokiDatasource } from './__mocks__/datasource'; +import { getMockFrames } from './__mocks__/frames'; import { LokiDatasource } from './datasource'; import * as logsTimeSplit from './logsTimeSplitting'; import * as metricTimeSplit from './metricTimeSplitting'; -import { createLokiDatasource, getMockFrames } from './mocks'; import { runSplitQuery } from './querySplitting'; import { trackGroupedQueries } from './tracking'; import { LokiQuery, LokiQueryType } from './types'; @@ -26,10 +26,19 @@ describe('runSplitQuery()', () => { to: dateTime('2023-02-10T06:00:00.000Z'), }, }; - const request = getQueryOptions<LokiQuery>({ - targets: [{ expr: 'count_over_time({a="b"}[1m])', refId: 'A' }], - range, - }); + + const createRequest = (targets: LokiQuery[], overrides?: Partial<DataQueryRequest<LokiQuery>>) => { + const request = { + range, + targets, + intervalMs: 60000, + requestId: 'TEST', + } as DataQueryRequest<LokiQuery>; + + Object.assign(request, overrides); + return request; + }; + const request = createRequest([{ expr: 'count_over_time({a="b"}[1m])', refId: 'A' }]); beforeEach(() => { datasource = createLokiDatasource(); jest.spyOn(datasource, 'runQuery').mockReturnValue(of({ data: [] })); @@ -43,10 +52,7 @@ describe('runSplitQuery()', () => { }); test('Metric queries with maxLines of 0 will execute', async () => { - const request = getQueryOptions<LokiQuery>({ - targets: [{ expr: 'count_over_time({a="b"}[1m])', refId: 'A', maxLines: 0 }], - range, - }); + const request = createRequest([{ expr: 'count_over_time({a="b"}[1m])', refId: 'A', maxLines: 0 }]); await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { // 3 days, 3 chunks, 3 requests. expect(datasource.runQuery).toHaveBeenCalledTimes(3); @@ -54,10 +60,7 @@ describe('runSplitQuery()', () => { }); test('Log queries with maxLines of 0 will NOT execute', async () => { - const request = getQueryOptions<LokiQuery>({ - targets: [{ expr: '{a="b"}', refId: 'A', maxLines: 0 }], - range, - }); + const request = createRequest([{ expr: '{a="b"}', refId: 'A', maxLines: 0 }]); await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { // Will not request a log query with maxLines of 0 expect(datasource.runQuery).toHaveBeenCalledTimes(0); @@ -81,8 +84,8 @@ describe('runSplitQuery()', () => { intervalMs: 60000, range: expect.objectContaining({ from: expect.objectContaining({ - //2023-02-09T06:00:00.000Z - _i: 1675922400000, + //2023-02-10T05:00:00.000Z + _i: 1676005200000, }), to: expect.objectContaining({ // 2023-02-10T06:00:00.000Z @@ -99,12 +102,12 @@ describe('runSplitQuery()', () => { intervalMs: 60000, range: expect.objectContaining({ from: expect.objectContaining({ - //2023-02-08T05:59:00.000Z - _i: 1675835940000, + // 2023-02-09T05:00:00.000Z + _i: 1675918800000, }), to: expect.objectContaining({ - // 2023-02-09T05:59:00.000Z - _i: 1675922340000, + // 2023-02-10T04:59:00.000Z + _i: 1676005140000, }), }), }) @@ -117,12 +120,12 @@ describe('runSplitQuery()', () => { intervalMs: 60000, range: expect.objectContaining({ from: expect.objectContaining({ - //2023-02-08T05:00:00.000Z + // 2023-02-08T05:00:00.000Z _i: 1675832400000, }), to: expect.objectContaining({ - // 2023-02-08T05:58:00.000Z - _i: 1675835880000, + // 2023-02-09T04:59:00.000Z + _i: 1675918740000, }), }), }) @@ -141,8 +144,8 @@ describe('runSplitQuery()', () => { intervalMs: 60000, range: expect.objectContaining({ from: expect.objectContaining({ - //2023-02-09T06:00:00.000Z - _i: 1675922400000, + //2023-02-10T05:00:00.000Z + _i: 1676005200000, }), to: expect.objectContaining({ // 2023-02-10T06:00:00.000Z @@ -159,12 +162,12 @@ describe('runSplitQuery()', () => { intervalMs: 60000, range: expect.objectContaining({ from: expect.objectContaining({ - //2023-02-08T05:59:50.000Z - _i: 1675835990000, + // 2023-02-09T05:00:00.000Z + _i: 1675918800000, }), to: expect.objectContaining({ - // 2023-02-09T05:59:50.000Z - _i: 1675922390000, + // 2023-02-10T04:59:50.000Z + _i: 1676005190000, }), }), }) @@ -181,8 +184,8 @@ describe('runSplitQuery()', () => { _i: 1675832400000, }), to: expect.objectContaining({ - // 2023-02-08T05:59:40.000Z - _i: 1675835980000, + // 2023-02-09T04:59:50.000Z + _i: 1675918790000, }), }), }) @@ -219,13 +222,10 @@ describe('runSplitQuery()', () => { jest.useRealTimers(); }); test('Ignores hidden queries', async () => { - const request = getQueryOptions<LokiQuery>({ - targets: [ - { expr: 'count_over_time({a="b"}[1m])', refId: 'A', hide: true }, - { expr: '{a="b"}', refId: 'B' }, - ], - range, - }); + const request = createRequest([ + { expr: 'count_over_time({a="b"}[1m])', refId: 'A', hide: true }, + { expr: '{a="b"}', refId: 'B' }, + ]); await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { expect(logsTimeSplit.splitTimeRange).toHaveBeenCalled(); expect(metricTimeSplit.splitTimeRange).not.toHaveBeenCalled(); @@ -252,13 +252,10 @@ describe('runSplitQuery()', () => { }); }); test('Ignores empty queries', async () => { - const request = getQueryOptions<LokiQuery>({ - targets: [ - { expr: 'count_over_time({a="b"}[1m])', refId: 'A' }, - { expr: '', refId: 'B' }, - ], - range, - }); + const request = createRequest([ + { expr: 'count_over_time({a="b"}[1m])', refId: 'A' }, + { expr: '', refId: 'B' }, + ]); await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { expect(logsTimeSplit.splitTimeRange).not.toHaveBeenCalled(); expect(metricTimeSplit.splitTimeRange).toHaveBeenCalled(); @@ -287,10 +284,7 @@ describe('runSplitQuery()', () => { }); describe('Dynamic maxLines for logs requests', () => { - const request = getQueryOptions<LokiQuery>({ - targets: [{ expr: '{a="b"}', refId: 'A', maxLines: 4 }], - range, - }); + const request = createRequest([{ expr: '{a="b"}', refId: 'A', maxLines: 4 }]); const { logFrameA } = getMockFrames(); beforeEach(() => { jest.spyOn(datasource, 'runQuery').mockReturnValue(of({ data: [logFrameA], refId: 'A' })); @@ -323,52 +317,40 @@ describe('runSplitQuery()', () => { jest.spyOn(datasource, 'runQuery').mockReturnValue(of({ data: [], refId: 'A' })); }); test('Sends logs and metric queries individually', async () => { - const request = getQueryOptions<LokiQuery>({ - targets: [ - { expr: '{a="b"}', refId: 'A' }, - { expr: 'count_over_time({a="b"}[1m])', refId: 'B' }, - ], - range, - }); + const request = createRequest([ + { expr: '{a="b"}', refId: 'A' }, + { expr: 'count_over_time({a="b"}[1m])', refId: 'B' }, + ]); await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { // 3 days, 3 chunks, 1x Metric + 1x Log, 6 requests. expect(datasource.runQuery).toHaveBeenCalledTimes(6); }); }); test('Groups metric queries', async () => { - const request = getQueryOptions<LokiQuery>({ - targets: [ - { expr: 'count_over_time({a="b"}[1m])', refId: 'A' }, - { expr: 'count_over_time({c="d"}[1m])', refId: 'B' }, - ], - range, - }); + const request = createRequest([ + { expr: 'count_over_time({a="b"}[1m])', refId: 'A' }, + { expr: 'count_over_time({c="d"}[1m])', refId: 'B' }, + ]); await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { // 3 days, 3 chunks, 1x2 Metric, 3 requests. expect(datasource.runQuery).toHaveBeenCalledTimes(3); }); }); test('Groups logs queries', async () => { - const request = getQueryOptions<LokiQuery>({ - targets: [ - { expr: '{a="b"}', refId: 'A' }, - { expr: '{c="d"}', refId: 'B' }, - ], - range, - }); + const request = createRequest([ + { expr: '{a="b"}', refId: 'A' }, + { expr: '{c="d"}', refId: 'B' }, + ]); await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { // 3 days, 3 chunks, 1x2 Logs, 3 requests. expect(datasource.runQuery).toHaveBeenCalledTimes(3); }); }); test('Groups instant queries', async () => { - const request = getQueryOptions<LokiQuery>({ - targets: [ - { expr: 'count_over_time({a="b"}[1m])', refId: 'A', queryType: LokiQueryType.Instant }, - { expr: 'count_over_time({c="d"}[1m])', refId: 'B', queryType: LokiQueryType.Instant }, - ], - range, - }); + const request = createRequest([ + { expr: 'count_over_time({a="b"}[1m])', refId: 'A', queryType: LokiQueryType.Instant }, + { expr: 'count_over_time({c="d"}[1m])', refId: 'B', queryType: LokiQueryType.Instant }, + ]); await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { // Instant queries are omitted from splitting expect(datasource.runQuery).toHaveBeenCalledTimes(1); @@ -376,13 +358,10 @@ describe('runSplitQuery()', () => { }); test('Respects maxLines of logs queries', async () => { const { logFrameA } = getMockFrames(); - const request = getQueryOptions<LokiQuery>({ - targets: [ - { expr: '{a="b"}', refId: 'A', maxLines: logFrameA.fields[0].values.length }, - { expr: 'count_over_time({a="b"}[1m])', refId: 'B' }, - ], - range, - }); + const request = createRequest([ + { expr: '{a="b"}', refId: 'A', maxLines: logFrameA.fields[0].values.length }, + { expr: 'count_over_time({a="b"}[1m])', refId: 'B' }, + ]); jest.spyOn(datasource, 'runQuery').mockReturnValue(of({ data: [], refId: 'B' })); jest.spyOn(datasource, 'runQuery').mockReturnValueOnce(of({ data: [logFrameA], refId: 'A' })); @@ -392,14 +371,11 @@ describe('runSplitQuery()', () => { }); }); test('Groups multiple queries into logs, queries, instant', async () => { - const request = getQueryOptions<LokiQuery>({ - targets: [ - { expr: 'count_over_time({a="b"}[1m])', refId: 'A', queryType: LokiQueryType.Instant }, - { expr: '{c="d"}', refId: 'B' }, - { expr: 'count_over_time({c="d"}[1m])', refId: 'C' }, - ], - range, - }); + const request = createRequest([ + { expr: 'count_over_time({a="b"}[1m])', refId: 'A', queryType: LokiQueryType.Instant }, + { expr: '{c="d"}', refId: 'B' }, + { expr: 'count_over_time({c="d"}[1m])', refId: 'C' }, + ]); await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { // 3 days, 3 chunks, 3x Logs + 3x Metric + (1x Instant), 7 requests. expect(datasource.runQuery).toHaveBeenCalledTimes(7); @@ -420,70 +396,70 @@ describe('runSplitQuery()', () => { jest.spyOn(datasource, 'runQuery').mockReturnValue(of({ data: [], refId: 'A' })); }); test('with 30m splitDuration runs 2 queries', async () => { - const request = getQueryOptions<LokiQuery>({ + const request = { targets: [{ expr: '{a="b"}', refId: 'A', splitDuration: '30m' }], range: range1h, - }); + } as DataQueryRequest<LokiQuery>; await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { expect(datasource.runQuery).toHaveBeenCalledTimes(2); }); }); test('with 1h splitDuration runs 1 queries', async () => { - const request = getQueryOptions<LokiQuery>({ + const request = { targets: [{ expr: '{a="b"}', refId: 'A', splitDuration: '1h' }], range: range1h, - }); + } as DataQueryRequest<LokiQuery>; await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { expect(datasource.runQuery).toHaveBeenCalledTimes(1); }); }); test('with 1h splitDuration and 2 targets runs 1 queries', async () => { - const request = getQueryOptions<LokiQuery>({ + const request = { targets: [ { expr: '{a="b"}', refId: 'A', splitDuration: '1h' }, { expr: '{a="b"}', refId: 'B', splitDuration: '1h' }, ], range: range1h, - }); + } as DataQueryRequest<LokiQuery>; await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { expect(datasource.runQuery).toHaveBeenCalledTimes(1); }); }); test('with 1h/30m splitDuration and 2 targets runs 3 queries', async () => { - const request = getQueryOptions<LokiQuery>({ + const request = { targets: [ { expr: '{a="b"}', refId: 'A', splitDuration: '1h' }, { expr: '{a="b"}', refId: 'B', splitDuration: '30m' }, ], range: range1h, - }); + } as DataQueryRequest<LokiQuery>; await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { // 2 x 30m + 1 x 1h expect(datasource.runQuery).toHaveBeenCalledTimes(3); }); }); test('with mixed splitDuration runs the expected amount of queries', async () => { - const request = getQueryOptions<LokiQuery>({ - targets: [ + const request = createRequest( + [ { expr: 'count_over_time({c="d"}[1m])', refId: 'A', splitDuration: '15m' }, { expr: '{a="b"}', refId: 'B', splitDuration: '15m' }, { expr: '{a="b"}', refId: 'C', splitDuration: '1h' }, ], - range: range1h, - }); + { range: range1h } + ); await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { // 4 * 15m + 4 * 15m + 1 * 1h expect(datasource.runQuery).toHaveBeenCalledTimes(9); }); }); test('with 1h/30m splitDuration and 1 log and 2 metric target runs 3 queries', async () => { - const request = getQueryOptions<LokiQuery>({ - targets: [ + const request = createRequest( + [ { expr: '{a="b"}', refId: 'A', splitDuration: '1h' }, { expr: 'count_over_time({c="d"}[1m])', refId: 'C', splitDuration: '30m' }, ], - range: range1h, - }); + { range: range1h } + ); await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { // 2 x 30m + 1 x 1h expect(datasource.runQuery).toHaveBeenCalledTimes(3); @@ -501,26 +477,26 @@ describe('runSplitQuery()', () => { }, }; test('Groups logs queries by resolution', async () => { - const request = getQueryOptions<LokiQuery>({ - targets: [ + const request = createRequest( + [ { expr: '{a="b"}', refId: 'A', resolution: 3 }, { expr: '{a="b"}', refId: 'B', resolution: 5 }, ], - range: range1d, - }); + { range: range1d } + ); await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { // A, B expect(datasource.runQuery).toHaveBeenCalledTimes(2); }); }); test('Groups metric queries with no step by calculated stepMs', async () => { - const request = getQueryOptions<LokiQuery>({ - targets: [ + const request = createRequest( + [ { expr: 'count_over_time({a="b"}[1m])', refId: 'A', resolution: 3 }, { expr: 'count_over_time{a="b"}[1m])', refId: 'B', resolution: 5 }, ], - range: range1d, - }); + { range: range1d } + ); await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { // A, B expect(datasource.runQuery).toHaveBeenCalledTimes(2); @@ -528,21 +504,21 @@ describe('runSplitQuery()', () => { }); test('Groups metric queries with step by stepMs', async () => { - const request = getQueryOptions<LokiQuery>({ - targets: [ + const request = createRequest( + [ { expr: 'count_over_time({a="b"}[1m])', refId: 'A', resolution: 1, step: '10' }, { expr: 'count_over_time{a="b"}[1m])', refId: 'B', resolution: 1, step: '5ms' }, ], - range: range1d, - }); + { range: range1d } + ); await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { // A, B expect(datasource.runQuery).toHaveBeenCalledTimes(2); }); }); test('Groups mixed queries by stepMs', async () => { - const request = getQueryOptions<LokiQuery>({ - targets: [ + const request = createRequest( + [ { expr: '{a="b"}', refId: 'A', resolution: 3 }, { expr: '{a="b"}', refId: 'B', resolution: 5 }, { expr: 'count_over_time({a="b"}[1m])', refId: 'C', resolution: 3 }, @@ -551,26 +527,23 @@ describe('runSplitQuery()', () => { { expr: 'rate({a="b"}[5m])', refId: 'F', resolution: 5, step: '10' }, { expr: 'rate({a="b"} | logfmt[5m])', refId: 'G', resolution: 5, step: '10s' }, ], - range: range1d, - }); + { range: range1d } + ); await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { // A, B, C, D, E, F+G expect(datasource.runQuery).toHaveBeenCalledTimes(6); }); }); test('Chunked groups mixed queries by stepMs', async () => { - const request = getQueryOptions<LokiQuery>({ - targets: [ - { expr: '{a="b"}', refId: 'A', resolution: 3 }, - { expr: '{a="b"}', refId: 'B', resolution: 5 }, - { expr: 'count_over_time({a="b"}[1m])', refId: 'C', resolution: 3 }, - { expr: 'count_over_time{a="b"}[1m])', refId: 'D', resolution: 5 }, - { expr: '{a="b"}', refId: 'E', resolution: 5, queryType: LokiQueryType.Instant }, - { expr: 'rate({a="b"}[5m])', refId: 'F', resolution: 5, step: '10' }, - { expr: 'rate({a="b"} | logfmt[5m])', refId: 'G', resolution: 5, step: '10s' }, - ], - range, // 3 days - }); + const request = createRequest([ + { expr: '{a="b"}', refId: 'A', resolution: 3 }, + { expr: '{a="b"}', refId: 'B', resolution: 5 }, + { expr: 'count_over_time({a="b"}[1m])', refId: 'C', resolution: 3 }, + { expr: 'count_over_time{a="b"}[1m])', refId: 'D', resolution: 5 }, + { expr: '{a="b"}', refId: 'E', resolution: 5, queryType: LokiQueryType.Instant }, + { expr: 'rate({a="b"}[5m])', refId: 'F', resolution: 5, step: '10' }, + { expr: 'rate({a="b"} | logfmt[5m])', refId: 'G', resolution: 5, step: '10s' }, + ]); await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { // 3 * A, 3 * B, 3 * C, 3 * D, 1 * E, 3 * F+G expect(datasource.runQuery).toHaveBeenCalledTimes(16); diff --git a/public/app/plugins/datasource/loki/querySplitting.ts b/public/app/plugins/datasource/loki/querySplitting.ts index 6e00cb159c0f4..c219b966f73fe 100644 --- a/public/app/plugins/datasource/loki/querySplitting.ts +++ b/public/app/plugins/datasource/loki/querySplitting.ts @@ -14,12 +14,12 @@ import { TimeRange, LoadingState, } from '@grafana/data'; +import { combineResponses } from '@grafana/o11y-ds-frontend'; import { LokiDatasource } from './datasource'; import { splitTimeRange as splitLogsTimeRange } from './logsTimeSplitting'; import { splitTimeRange as splitMetricTimeRange } from './metricTimeSplitting'; import { isLogsQuery, isQueryWithRangeVariable } from './queryUtils'; -import { combineResponses } from './responseUtils'; import { trackGroupedQueries } from './tracking'; import { LokiGroupedRequest, LokiQuery, LokiQueryType } from './types'; diff --git a/public/app/plugins/datasource/loki/queryUtils.test.ts b/public/app/plugins/datasource/loki/queryUtils.test.ts index 772c656ca941e..8675b9e522eb3 100644 --- a/public/app/plugins/datasource/loki/queryUtils.test.ts +++ b/public/app/plugins/datasource/loki/queryUtils.test.ts @@ -1,6 +1,6 @@ import { String } from '@grafana/lezer-logql'; -import { createLokiDatasource } from './mocks'; +import { createLokiDatasource } from './__mocks__/datasource'; import { getHighlighterExpressionsFromQuery, getLokiQueryType, diff --git a/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.test.ts b/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.test.ts index fa621ee8d6a03..7c7b7ac23bcc4 100644 --- a/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.test.ts +++ b/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.test.ts @@ -254,7 +254,7 @@ describe('LokiQueryModeller', () => { operations: [], }; - const def = modeller.getOperationDef('sum')!; + const def = modeller.getOperationDefinition('sum')!; const result = def.addOperationHandler(def, query, modeller); expect(result.operations[0].id).toBe('rate'); expect(result.operations[1].id).toBe('sum'); @@ -266,7 +266,7 @@ describe('LokiQueryModeller', () => { operations: [{ id: LokiOperationId.Json, params: [] }], }; - const def = modeller.getOperationDef('sum')!; + const def = modeller.getOperationDefinition('sum')!; const result = def.addOperationHandler(def, query, modeller); expect(result.operations[0].id).toBe(LokiOperationId.Json); expect(result.operations[1].id).toBe('rate'); @@ -279,7 +279,7 @@ describe('LokiQueryModeller', () => { operations: [{ id: 'rate', params: [] }], }; - const def = modeller.getOperationDef(LokiOperationId.Json)!; + const def = modeller.getOperationDefinition(LokiOperationId.Json)!; const result = def.addOperationHandler(def, query, modeller); expect(result.operations[0].id).toBe(LokiOperationId.Json); expect(result.operations[1].id).toBe('rate'); @@ -291,7 +291,7 @@ describe('LokiQueryModeller', () => { operations: [{ id: LokiOperationId.LineContains, params: ['error'] }], }; - const def = modeller.getOperationDef(LokiOperationId.Json)!; + const def = modeller.getOperationDefinition(LokiOperationId.Json)!; const result = def.addOperationHandler(def, query, modeller); expect(result.operations[0].id).toBe(LokiOperationId.LineContains); expect(result.operations[1].id).toBe(LokiOperationId.Json); @@ -303,7 +303,7 @@ describe('LokiQueryModeller', () => { operations: [{ id: LokiOperationId.Json, params: [] }], }; - const def = modeller.getOperationDef(LokiOperationId.LineContains)!; + const def = modeller.getOperationDefinition(LokiOperationId.LineContains)!; const result = def.addOperationHandler(def, query, modeller); expect(result.operations[0].id).toBe(LokiOperationId.LineContains); expect(result.operations[1].id).toBe(LokiOperationId.Json); @@ -315,7 +315,7 @@ describe('LokiQueryModeller', () => { operations: [{ id: LokiOperationId.Rate, params: [] }], }; - const def = modeller.getOperationDef(LokiOperationId.Rate)!; + const def = modeller.getOperationDefinition(LokiOperationId.Rate)!; const result = def.addOperationHandler(def, query, modeller); expect(result.operations.length).toBe(1); }); @@ -329,7 +329,7 @@ describe('LokiQueryModeller', () => { ], }; - const def = modeller.getOperationDef(LokiOperationId.Unwrap)!; + const def = modeller.getOperationDefinition(LokiOperationId.Unwrap)!; const result = def.addOperationHandler(def, query, modeller); expect(result.operations[1].id).toBe(LokiOperationId.Unwrap); }); diff --git a/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts b/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts index 2a95304c736e4..703d4c50f4f25 100644 --- a/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts +++ b/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts @@ -1,12 +1,17 @@ -import { LokiAndPromQueryModellerBase } from '../../prometheus/querybuilder/shared/LokiAndPromQueryModellerBase'; -import { QueryBuilderLabelFilter } from '../../prometheus/querybuilder/shared/types'; +import { + QueryModellerBase, + QueryBuilderLabelFilter, + VisualQuery, + QueryBuilderOperation, + VisualQueryBinary, +} from '@grafana/experimental'; -import { getOperationDefinitions } from './operations'; +import { operationDefinitions } from './operations'; import { LokiOperationId, LokiQueryPattern, LokiQueryPatternType, LokiVisualQueryOperationCategory } from './types'; -export class LokiQueryModeller extends LokiAndPromQueryModellerBase { +export class LokiQueryModeller extends QueryModellerBase { constructor() { - super(getOperationDefinitions); + super(operationDefinitions, '<expr>'); this.setOperationCategories([ LokiVisualQueryOperationCategory.Aggregations, @@ -18,12 +23,65 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase { ]); } - renderLabels(labels: QueryBuilderLabelFilter[]) { + renderOperations(queryString: string, operations: QueryBuilderOperation[]): string { + for (const operation of operations) { + const def = this.operationsRegistry.getIfExists(operation.id); + if (!def) { + console.error(`Could not find operation ${operation.id} in the registry`); + continue; + } + queryString = def.renderer(operation, def, queryString); + } + return queryString; + } + + renderBinaryQueries(queryString: string, binaryQueries?: Array<VisualQueryBinary<VisualQuery>>) { + if (binaryQueries) { + for (const binQuery of binaryQueries) { + queryString = `${this.renderBinaryQuery(queryString, binQuery)}`; + } + } + return queryString; + } + + private renderBinaryQuery(leftOperand: string, binaryQuery: VisualQueryBinary<VisualQuery>) { + let result = leftOperand + ` ${binaryQuery.operator} `; + + if (binaryQuery.vectorMatches) { + result += `${binaryQuery.vectorMatchesType}(${binaryQuery.vectorMatches}) `; + } + + return result + this.renderQuery(binaryQuery.query, true); + } + + renderLabels(labels: QueryBuilderLabelFilter[]): string { if (labels.length === 0) { return '{}'; } - return super.renderLabels(labels); + let expr = '{'; + for (const filter of labels) { + if (expr !== '{') { + expr += ', '; + } + + expr += `${filter.label}${filter.op}"${filter.value}"`; + } + + return expr + `}`; + } + + renderQuery(query: VisualQuery, nested?: boolean): string { + let queryString = this.renderLabels(query.labels); + queryString = this.renderOperations(queryString, query.operations); + + if (!nested && this.hasBinaryOp(query) && Boolean(query.binaryQueries?.length)) { + queryString = `(${queryString})`; + } + + queryString = this.renderBinaryQueries(queryString, query.binaryQueries); + + return queryString; } getQueryPatterns(): LokiQueryPattern[] { diff --git a/public/app/plugins/datasource/loki/querybuilder/binaryScalarOperations.ts b/public/app/plugins/datasource/loki/querybuilder/binaryScalarOperations.ts index cd62b2e8f49be..a80f803eeab7d 100644 --- a/public/app/plugins/datasource/loki/querybuilder/binaryScalarOperations.ts +++ b/public/app/plugins/datasource/loki/querybuilder/binaryScalarOperations.ts @@ -1,8 +1,8 @@ import { QueryBuilderOperation, - QueryBuilderOperationDef, + QueryBuilderOperationDefinition, QueryBuilderOperationParamDef, -} from '../../prometheus/querybuilder/shared/types'; +} from '@grafana/experimental'; import { defaultAddOperationHandler } from './operationUtils'; import { LokiOperationId, LokiVisualQueryOperationCategory } from './types'; @@ -78,7 +78,7 @@ export const binaryScalarDefs = [ // Not sure about this one. It could also be a more generic 'Simple math operation' where user specifies // both the operator and the operand in a single input -export const binaryScalarOperations: QueryBuilderOperationDef[] = binaryScalarDefs.map((opDef) => { +export const binaryScalarOperations: QueryBuilderOperationDefinition[] = binaryScalarDefs.map((opDef) => { const params: QueryBuilderOperationParamDef[] = [{ name: 'Value', type: 'number' }]; const defaultParams: any[] = [2]; if (opDef.comparison) { @@ -103,7 +103,11 @@ export const binaryScalarOperations: QueryBuilderOperationDef[] = binaryScalarDe }); function getSimpleBinaryRenderer(operator: string) { - return function binaryRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + return function binaryRenderer( + model: QueryBuilderOperation, + def: QueryBuilderOperationDefinition, + innerExpr: string + ) { let param = model.params[0]; let bool = ''; if (model.params.length === 2) { diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.test.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.test.tsx index 17db08810460f..16ef915193afc 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.test.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.test.tsx @@ -1,8 +1,8 @@ import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; +import { createLokiDatasource } from '../../__mocks__/datasource'; import { LokiDatasource } from '../../datasource'; -import { createLokiDatasource } from '../../mocks'; import { LokiQuery } from '../../types'; import { LabelBrowserModal, Props } from './LabelBrowserModal'; diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.tsx index b232f75cd73d4..90983b8c25caa 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.tsx @@ -2,9 +2,9 @@ import { css } from '@emotion/css'; import React, { useState, useEffect } from 'react'; import { CoreApp, GrafanaTheme2, TimeRange } from '@grafana/data'; +import { LocalStorageValueProvider } from '@grafana/o11y-ds-frontend'; import { reportInteraction } from '@grafana/runtime'; import { LoadingPlaceholder, Modal, useStyles2 } from '@grafana/ui'; -import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider'; import { LokiLabelBrowser } from '../../components/LokiLabelBrowser'; import { LokiDatasource } from '../../datasource'; diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LabelParamEditor.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LabelParamEditor.tsx new file mode 100644 index 0000000000000..ea9186ebdd79c --- /dev/null +++ b/public/app/plugins/datasource/loki/querybuilder/components/LabelParamEditor.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; + +import { DataSourceApi, SelectableValue } from '@grafana/data'; +import { + QueryBuilderLabelFilter, + QueryBuilderOperationParamEditorProps, + QueryBuilderOperationParamValue, + VisualQuery, + VisualQueryModeller, +} from '@grafana/experimental'; +import { Select } from '@grafana/ui'; + +import { getOperationParamId } from '../operationUtils'; + +export const LabelParamEditor = ({ + onChange, + index, + operationId, + value, + query, + datasource, + queryModeller, +}: QueryBuilderOperationParamEditorProps) => { + const [state, setState] = useState<{ + options?: SelectableValue[]; + isLoading?: boolean; + }>({}); + + return ( + <Select<QueryBuilderOperationParamValue | undefined> + inputId={getOperationParamId(operationId, index)} + autoFocus={value === ''} + openMenuOnFocus + onOpenMenu={async () => { + setState({ isLoading: true }); + const options = await loadGroupByLabels(query, datasource, queryModeller); + setState({ options, isLoading: undefined }); + }} + isLoading={state.isLoading} + allowCustomValue + noOptionsMessage="No labels found" + loadingMessage="Loading labels" + options={state.options} + value={toOption(value)} + onChange={(value) => onChange(index, value.value!)} + /> + ); +}; + +async function loadGroupByLabels( + query: VisualQuery, + datasource: DataSourceApi, + queryModeller: VisualQueryModeller +): Promise<SelectableValue[]> { + let labels: QueryBuilderLabelFilter[] = query.labels; + + const queryString = queryModeller.renderLabels(labels); + const result = await datasource.languageProvider.fetchSeriesLabels(queryString); + + return Object.keys(result).map((x) => ({ + label: x, + value: x, + })); +} + +const toOption = ( + value: QueryBuilderOperationParamValue | undefined +): SelectableValue<QueryBuilderOperationParamValue | undefined> => ({ label: value?.toString(), value }); diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.test.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.test.tsx index c98cbeffc77dc..8ba6538dcdbcc 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.test.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.test.tsx @@ -1,17 +1,17 @@ import { render, screen, getAllByRole, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { getSelectParent } from 'test/helpers/selectOptionInTest'; import { dateTime } from '@grafana/data'; +import { config } from '@grafana/runtime'; -import { MISSING_LABEL_FILTER_ERROR_MESSAGE } from '../../../prometheus/querybuilder/shared/LabelFilters'; -import { createLokiDatasource } from '../../mocks'; +import { createLokiDatasource } from '../../__mocks__/datasource'; import { LokiOperationId, LokiVisualQuery } from '../types'; -import { LokiQueryBuilder } from './LokiQueryBuilder'; +import { LokiQueryBuilder, TIME_SPAN_TO_TRIGGER_SAMPLES } from './LokiQueryBuilder'; import { EXPLAIN_LABEL_FILTER_CONTENT } from './LokiQueryBuilderExplained'; +const MISSING_LABEL_FILTER_ERROR_MESSAGE = 'Select at least 1 label filter (label and value)'; const defaultQuery: LokiVisualQuery = { labels: [{ op: '=', label: 'baz', value: 'bar' }], operations: [], @@ -41,6 +41,14 @@ const createDefaultProps = () => { }; describe('LokiQueryBuilder', () => { + const originalLokiQueryHints = config.featureToggles.lokiQueryHints; + beforeEach(() => { + config.featureToggles.lokiQueryHints = true; + }); + + afterEach(() => { + config.featureToggles.lokiQueryHints = originalLokiQueryHints; + }); it('tries to load labels when no labels are selected', async () => { const props = createDefaultProps(); props.datasource.getDataSamples = jest.fn().mockResolvedValue([]); @@ -57,6 +65,112 @@ describe('LokiQueryBuilder', () => { await waitFor(() => expect(screen.getByText('job')).toBeInTheDocument()); }); + it('uses fetchLabelValues preselected labels have no equality matcher', async () => { + const props = createDefaultProps(); + props.datasource.getDataSamples = jest.fn().mockResolvedValue([]); + props.datasource.languageProvider.fetchSeriesLabels = jest.fn(); + props.datasource.languageProvider.fetchLabelValues = jest.fn().mockReturnValue(['a', 'b']); + + const query: LokiVisualQuery = { + labels: [ + { op: '!=', label: 'cluster', value: 'cluster1' }, + { op: '=', label: 'job', value: 'grafana' }, + ], + operations: [], + }; + render(<LokiQueryBuilder {...props} query={query} />); + const labels = screen.getByText(/Label filters/); + const selects = getAllByRole(getSelectParent(labels)!, 'combobox'); + await userEvent.click(selects[5]); + expect(props.datasource.languageProvider.fetchLabelValues).toBeCalledWith('job', { timeRange: mockTimeRange }); + expect(props.datasource.languageProvider.fetchSeriesLabels).not.toBeCalled(); + }); + + it('uses fetchLabelValues preselected label have regex equality matcher with match everything value (.*)', async () => { + const props = createDefaultProps(); + props.datasource.getDataSamples = jest.fn().mockResolvedValue([]); + props.datasource.languageProvider.fetchSeriesLabels = jest.fn(); + props.datasource.languageProvider.fetchLabelValues = jest.fn().mockReturnValue(['a', 'b']); + + const query: LokiVisualQuery = { + labels: [ + { op: '=~', label: 'cluster', value: '.*' }, + { op: '=', label: 'job', value: 'grafana' }, + ], + operations: [], + }; + render(<LokiQueryBuilder {...props} query={query} />); + const labels = screen.getByText(/Label filters/); + const selects = getAllByRole(getSelectParent(labels)!, 'combobox'); + await userEvent.click(selects[5]); + expect(props.datasource.languageProvider.fetchLabelValues).toBeCalledWith('job', { timeRange: mockTimeRange }); + expect(props.datasource.languageProvider.fetchSeriesLabels).not.toBeCalled(); + }); + + it('uses fetchLabels preselected label have regex equality matcher with match everything value (.*)', async () => { + const props = createDefaultProps(); + props.datasource.getDataSamples = jest.fn().mockResolvedValue([]); + props.datasource.languageProvider.fetchSeriesLabels = jest.fn(); + props.datasource.languageProvider.fetchLabels = jest.fn().mockReturnValue(['a', 'b']); + + const query: LokiVisualQuery = { + labels: [ + { op: '=~', label: 'cluster', value: '.*' }, + { op: '=', label: 'job', value: 'grafana' }, + ], + operations: [], + }; + render(<LokiQueryBuilder {...props} query={query} />); + const labels = screen.getByText(/Label filters/); + const selects = getAllByRole(getSelectParent(labels)!, 'combobox'); + await userEvent.click(selects[3]); + expect(props.datasource.languageProvider.fetchLabels).toBeCalledWith({ timeRange: mockTimeRange }); + expect(props.datasource.languageProvider.fetchSeriesLabels).not.toBeCalled(); + }); + + it('uses fetchSeriesLabels preselected label have regex equality matcher', async () => { + const props = createDefaultProps(); + props.datasource.getDataSamples = jest.fn().mockResolvedValue([]); + props.datasource.languageProvider.fetchSeriesLabels = jest.fn().mockReturnValue({ job: ['a'], instance: ['b'] }); + props.datasource.languageProvider.fetchLabelValues = jest.fn(); + + const query: LokiVisualQuery = { + labels: [ + { op: '=~', label: 'cluster', value: 'cluster1|cluster2' }, + { op: '=', label: 'job', value: 'grafana' }, + ], + operations: [], + }; + render(<LokiQueryBuilder {...props} query={query} />); + const labels = screen.getByText(/Label filters/); + const selects = getAllByRole(getSelectParent(labels)!, 'combobox'); + await userEvent.click(selects[5]); + expect(props.datasource.languageProvider.fetchSeriesLabels).toBeCalledWith('{cluster=~"cluster1|cluster2"}', { + timeRange: mockTimeRange, + }); + expect(props.datasource.languageProvider.fetchLabelValues).not.toBeCalled(); + }); + + it('does refetch label values with the correct time range', async () => { + const props = createDefaultProps(); + props.datasource.getDataSamples = jest.fn().mockResolvedValue([]); + props.datasource.languageProvider.fetchSeriesLabels = jest + .fn() + .mockReturnValue({ job: ['a'], instance: ['b'], baz: ['bar'] }); + + render(<LokiQueryBuilder {...props} query={defaultQuery} />); + await userEvent.click(screen.getByLabelText('Add')); + const labels = screen.getByText(/Label filters/); + const selects = getAllByRole(getSelectParent(labels)!, 'combobox'); + await userEvent.click(selects[3]); + await waitFor(() => expect(screen.getByText('job')).toBeInTheDocument()); + await userEvent.click(screen.getByText('job')); + await userEvent.click(selects[5]); + expect(props.datasource.languageProvider.fetchSeriesLabels).toHaveBeenNthCalledWith(2, '{baz="bar"}', { + timeRange: mockTimeRange, + }); + }); + it('does not show already existing label names as option in label filter', async () => { const props = createDefaultProps(); props.datasource.getDataSamples = jest.fn().mockResolvedValue([]); @@ -115,4 +229,96 @@ describe('LokiQueryBuilder', () => { expect(screen.queryByText(EXPLAIN_LABEL_FILTER_CONTENT)).not.toBeInTheDocument(); }); }); + + it('re-runs sample query when query changes', async () => { + const query = { + labels: [{ label: 'foo', op: '=', value: 'bar' }], + operations: [{ id: LokiOperationId.LineContains, params: ['error'] }], + }; + const props = createDefaultProps(); + props.datasource.getDataSamples = jest.fn().mockResolvedValue([]); + + const { rerender } = render(<LokiQueryBuilder {...props} query={query} />); + rerender( + <LokiQueryBuilder + {...props} + query={{ ...query, labels: [...query.labels, { label: 'xyz', op: '=', value: 'abc' }] }} + /> + ); + + await waitFor(() => { + expect(props.datasource.getDataSamples).toHaveBeenCalledTimes(2); + }); + }); + + it('does not re-run sample query when query does not change', async () => { + const query = { + labels: [{ label: 'foo', op: '=', value: 'bar' }], + operations: [{ id: LokiOperationId.LineContains, params: ['error'] }], + }; + const props = createDefaultProps(); + props.datasource.getDataSamples = jest.fn().mockResolvedValue([]); + + const { rerender } = render(<LokiQueryBuilder {...props} query={query} />); + rerender(<LokiQueryBuilder {...props} query={query} />); + + await waitFor(() => { + expect(props.datasource.getDataSamples).toHaveBeenCalledTimes(1); + }); + }); + + it('re-run sample query when time range changes over 5 minutes', async () => { + const query = { + labels: [{ label: 'foo', op: '=', value: 'bar' }], + operations: [{ id: LokiOperationId.LineContains, params: ['error'] }], + }; + const props = createDefaultProps(); + const updatedFrom = dateTime(props.timeRange.from.valueOf() + TIME_SPAN_TO_TRIGGER_SAMPLES + 1000); + const updatedTo = dateTime(props.timeRange.to.valueOf() + TIME_SPAN_TO_TRIGGER_SAMPLES + 1000); + const updatedTimeRange = { + from: updatedFrom, + to: updatedTo, + raw: { + from: updatedFrom, + to: updatedTo, + }, + }; + props.datasource.getDataSamples = jest.fn().mockResolvedValue([]); + + const { rerender } = render(<LokiQueryBuilder {...props} query={query} />); + rerender(<LokiQueryBuilder {...props} query={query} timeRange={updatedTimeRange} />); + + await waitFor(() => { + expect(props.datasource.getDataSamples).toHaveBeenCalledTimes(2); + }); + }); + + it('does not re-run sample query when time range changes less than 5 minutes', async () => { + const query = { + labels: [{ label: 'foo', op: '=', value: 'bar' }], + operations: [{ id: LokiOperationId.LineContains, params: ['error'] }], + }; + const props = createDefaultProps(); + const updatedFrom = dateTime(props.timeRange.from.valueOf() + TIME_SPAN_TO_TRIGGER_SAMPLES - 1000); + const updatedTo = dateTime(props.timeRange.to.valueOf() + TIME_SPAN_TO_TRIGGER_SAMPLES - 1000); + const updatedTimeRange = { + from: updatedFrom, + to: updatedTo, + raw: { + from: updatedFrom, + to: updatedTo, + }, + }; + props.datasource.getDataSamples = jest.fn().mockResolvedValue([]); + + const { rerender } = render(<LokiQueryBuilder {...props} query={query} />); + rerender(<LokiQueryBuilder {...props} query={query} timeRange={updatedTimeRange} />); + + await waitFor(() => { + expect(props.datasource.getDataSamples).toHaveBeenCalledTimes(1); + }); + }); }); + +const getSelectParent = (input: HTMLElement) => + input.parentElement?.parentElement?.parentElement?.parentElement?.parentElement; diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx index 6cea7c0826717..f112874988de3 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx @@ -1,31 +1,36 @@ +import { isEqual } from 'lodash'; import React, { useEffect, useMemo, useState } from 'react'; +import { usePrevious } from 'react-use'; import { DataSourceApi, getDefaultTimeRange, LoadingState, PanelData, SelectableValue, TimeRange } from '@grafana/data'; -import { EditorRow } from '@grafana/experimental'; -import { config } from '@grafana/runtime'; -import { LabelFilters } from 'app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters'; -import { OperationExplainedBox } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationExplainedBox'; -import { OperationList } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationList'; -import { OperationListExplained } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationListExplained'; -import { OperationsEditorRow } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationsEditorRow'; -import { QueryBuilderHints } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryBuilderHints'; -import { RawQuery } from 'app/plugins/datasource/prometheus/querybuilder/shared/RawQuery'; import { + EditorRow, + LabelFilters, + OperationExplainedBox, + OperationList, + OperationListExplained, + OperationsEditorRow, + QueryBuilderHints, + RawQuery, QueryBuilderLabelFilter, QueryBuilderOperation, -} from 'app/plugins/datasource/prometheus/querybuilder/shared/types'; +} from '@grafana/experimental'; +import { config } from '@grafana/runtime'; import { testIds } from '../../components/LokiQueryEditor'; import { LokiDatasource } from '../../datasource'; import { escapeLabelValueInSelector } from '../../languageUtils'; import logqlGrammar from '../../syntax'; +import { LokiQuery } from '../../types'; import { lokiQueryModeller } from '../LokiQueryModeller'; +import { isConflictingFilter } from '../operationUtils'; import { buildVisualQueryFromString } from '../parsing'; import { LokiOperationId, LokiVisualQuery } from '../types'; import { EXPLAIN_LABEL_FILTER_CONTENT } from './LokiQueryBuilderExplained'; import { NestedQueryList } from './NestedQueryList'; +export const TIME_SPAN_TO_TRIGGER_SAMPLES = 5 * 60 * 1000; export interface Props { query: LokiVisualQuery; datasource: LokiDatasource; @@ -38,6 +43,8 @@ export const LokiQueryBuilder = React.memo<Props>( ({ datasource, query, onChange, onRunQuery, showExplain, timeRange }) => { const [sampleData, setSampleData] = useState<PanelData>(); const [highlightedOp, setHighlightedOp] = useState<QueryBuilderOperation | undefined>(undefined); + const prevQuery = usePrevious(query); + const prevTimeRange = usePrevious(timeRange); const onChangeLabels = (labels: QueryBuilderLabelFilter[]) => { onChange({ ...query, labels }); @@ -51,7 +58,10 @@ export const LokiQueryBuilder = React.memo<Props>( const onGetLabelNames = async (forLabel: Partial<QueryBuilderLabelFilter>): Promise<string[]> => { const labelsToConsider = query.labels.filter((x) => x !== forLabel); - if (labelsToConsider.length === 0) { + const hasEqualityOperation = labelsToConsider.find( + (filter) => filter.op === '=' || (filter.op === '=~' && new RegExp(filter.value).test('') === false) + ); + if (labelsToConsider.length === 0 || !hasEqualityOperation) { return await datasource.languageProvider.fetchLabels({ timeRange }); } @@ -74,11 +84,15 @@ export const LokiQueryBuilder = React.memo<Props>( let values; const labelsToConsider = query.labels.filter((x) => x !== forLabel); - if (labelsToConsider.length === 0) { + // If we have no equality/regex operation with .*, we can't fetch series as it will throw an error, so we fetch label values + const hasEqualityOperation = labelsToConsider.find( + (filter) => filter.op === '=' || (filter.op === '=~' && new RegExp(filter.value).test('') === false) + ); + if (labelsToConsider.length === 0 || !hasEqualityOperation) { values = await datasource.languageProvider.fetchLabelValues(forLabel.label, { timeRange }); } else { const expr = lokiQueryModeller.renderLabels(labelsToConsider); - const result = await datasource.languageProvider.fetchSeriesLabels(expr); + const result = await datasource.languageProvider.fetchSeriesLabels(expr, { timeRange }); values = result[datasource.interpolateString(forLabel.label)]; } @@ -106,10 +120,16 @@ export const LokiQueryBuilder = React.memo<Props>( setSampleData(sampleData); }; - if (config.featureToggles.lokiQueryHints) { + const updateBasedOnChangedTimeRange = + prevTimeRange && + timeRange && + (Math.abs(timeRange.to.valueOf() - prevTimeRange.to.valueOf()) > TIME_SPAN_TO_TRIGGER_SAMPLES || + Math.abs(timeRange.from.valueOf() - prevTimeRange.from.valueOf()) > TIME_SPAN_TO_TRIGGER_SAMPLES); + const updateBasedOnChangedQuery = !isEqual(prevQuery, query); + if (config.featureToggles.lokiQueryHints && (updateBasedOnChangedTimeRange || updateBasedOnChangedQuery)) { onGetSampleData().catch(console.error); } - }, [datasource, query, timeRange]); + }, [datasource, query, timeRange, prevQuery, prevTimeRange]); const lang = { grammar: logqlGrammar, name: 'logql' }; return ( @@ -130,7 +150,7 @@ export const LokiQueryBuilder = React.memo<Props>( {showExplain && ( <OperationExplainedBox stepNumber={1} - title={<RawQuery query={`${lokiQueryModeller.renderLabels(query.labels)}`} lang={lang} />} + title={<RawQuery query={`${lokiQueryModeller.renderLabels(query.labels)}`} language={lang} />} > {EXPLAIN_LABEL_FILTER_CONTENT} </OperationExplainedBox> @@ -143,14 +163,19 @@ export const LokiQueryBuilder = React.memo<Props>( onRunQuery={onRunQuery} datasource={datasource as DataSourceApi} highlightedOp={highlightedOp} + isConflictingOperation={(operation: QueryBuilderOperation, otherOperations: QueryBuilderOperation[]) => + operation.id === LokiOperationId.LabelFilter && isConflictingFilter(operation, otherOperations) + } /> - <QueryBuilderHints<LokiVisualQuery> + <QueryBuilderHints<LokiVisualQuery, LokiQuery> datasource={datasource} query={query} onChange={onChange} data={sampleData} queryModeller={lokiQueryModeller} buildVisualQueryFromString={buildVisualQueryFromString} + buildDataQueryFromQueryString={(queryString) => ({ expr: queryString, refId: 'hints' })} + buildQueryStringFromDataQuery={(query) => query.expr} /> </OperationsEditorRow> {showExplain && ( @@ -158,7 +183,7 @@ export const LokiQueryBuilder = React.memo<Props>( stepNumber={2} queryModeller={lokiQueryModeller} query={query} - lang={lang} + language={lang} onMouseEnter={(op) => { setHighlightedOp(op); }} diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderContainer.test.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderContainer.test.tsx index 5b4c490daa2e0..8e110352ff76f 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderContainer.test.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderContainer.test.tsx @@ -1,8 +1,8 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor, findAllByRole } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { createLokiDatasource } from '../../mocks'; +import { createLokiDatasource } from '../../__mocks__/datasource'; import { LokiQueryBuilderContainer } from './LokiQueryBuilderContainer'; @@ -30,6 +30,96 @@ describe('LokiQueryBuilderContainer', () => { refId: 'A', }); }); + it('uses | to separate multiple values in label filters', async () => { + const props = { + query: { + expr: '{app="app1"}', + refId: 'A', + }, + datasource: createLokiDatasource(), + onChange: jest.fn(), + onRunQuery: () => {}, + showExplain: false, + }; + props.datasource.getDataSamples = jest.fn().mockResolvedValue([]); + props.datasource.languageProvider.fetchSeriesLabels = jest.fn().mockReturnValue({ job: ['grafana', 'loki'] }); + props.onChange = jest.fn(); + + render(<LokiQueryBuilderContainer {...props} />); + await userEvent.click(screen.getByLabelText('Add')); + const labels = screen.getByText(/Label filters/); + const selects = await findAllByRole(getSelectParent(labels)!, 'combobox'); + await userEvent.click(selects[3]); + await userEvent.click(await screen.findByText('job')); + + await userEvent.click(selects[4]); + await userEvent.click(await screen.findByText('=~')); + + await userEvent.click(selects[5]); + await userEvent.click(await screen.findByText('grafana')); + + await userEvent.click(selects[5]); + await userEvent.click(await screen.findByText('loki')); + + await waitFor(() => { + expect(props.onChange).toBeCalledWith({ expr: '{app="app1", job=~"grafana|loki"}', refId: 'A' }); + }); + }); + + it('highlights the query in preview using loki grammar', async () => { + const props = { + query: { + expr: '{app="baz"} | logfmt', + refId: 'A', + }, + datasource: createLokiDatasource(), + onChange: jest.fn(), + onRunQuery: () => {}, + showExplain: false, + }; + props.datasource.getDataSamples = jest.fn().mockResolvedValue([]); + render(<LokiQueryBuilderContainer {...props} />); + expect(screen.getByText('{')).toHaveClass('token punctuation'); + expect(screen.getByText('"baz"')).toHaveClass('token label-value attr-value'); + expect(screen.getByText('|')).toHaveClass('token pipe-operator operator'); + expect(screen.getByText('logfmt')).toHaveClass('token pipe-operations keyword'); + }); + + it('shows conflicting label expressions', async () => { + const props = { + query: { + expr: '{job="grafana"} | app!="bar" | app="bar"', + refId: 'A', + }, + datasource: createLokiDatasource(), + onChange: jest.fn(), + onRunQuery: () => {}, + showExplain: false, + }; + props.datasource.getDataSamples = jest.fn().mockResolvedValue([]); + + render(<LokiQueryBuilderContainer {...props} />); + expect(screen.getAllByText('You have conflicting label filters')).toHaveLength(2); + }); + + it('uses <expr> as placeholder for query in explain section', async () => { + const props = { + query: { + expr: '{job="grafana"} | logfmt', + refId: 'A', + }, + datasource: createLokiDatasource(), + onChange: jest.fn(), + onRunQuery: () => {}, + showExplain: true, + }; + props.datasource.getDataSamples = jest.fn().mockResolvedValue([]); + + render(<LokiQueryBuilderContainer {...props} />); + expect(screen.getByText('<')).toBeInTheDocument(); + expect(screen.getByText('expr')).toBeInTheDocument(); + expect(screen.getByText('>')).toBeInTheDocument(); + }); }); async function addOperation(section: string, op: string) { @@ -47,3 +137,6 @@ async function addOperation(section: string, op: string) { // anywhere when debugging so not sure what style is it picking up. await userEvent.click(opItem, { pointerEventsCheck: 0 }); } + +const getSelectParent = (input: HTMLElement) => + input.parentElement?.parentElement?.parentElement?.parentElement?.parentElement; diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderExplained.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderExplained.tsx index ab5ee6552be0e..68430e8c2cb9e 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderExplained.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderExplained.tsx @@ -1,9 +1,7 @@ import React from 'react'; +import { OperationExplainedBox, OperationListExplained, RawQuery } from '@grafana/experimental'; import { Stack } from '@grafana/ui'; -import { OperationExplainedBox } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationExplainedBox'; -import { OperationListExplained } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationListExplained'; -import { RawQuery } from 'app/plugins/datasource/prometheus/querybuilder/shared/RawQuery'; import { lokiGrammar } from '../../syntax'; import { lokiQueryModeller } from '../LokiQueryModeller'; @@ -24,7 +22,7 @@ export const LokiQueryBuilderExplained = React.memo<Props>(({ query }) => { <Stack gap={0} direction="column"> <OperationExplainedBox stepNumber={1} - title={<RawQuery query={`${lokiQueryModeller.renderLabels(visQuery.labels)}`} lang={lang} />} + title={<RawQuery query={`${lokiQueryModeller.renderLabels(visQuery.labels)}`} language={lang} />} > {EXPLAIN_LABEL_FILTER_CONTENT} </OperationExplainedBox> @@ -32,7 +30,7 @@ export const LokiQueryBuilderExplained = React.memo<Props>(({ query }) => { stepNumber={2} queryModeller={lokiQueryModeller} query={visQuery} - lang={lang} + language={lang} /> </Stack> ); diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx index 74934a42d1073..ad9b7ae80f129 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx @@ -2,10 +2,9 @@ import { trim } from 'lodash'; import React, { useMemo, useState } from 'react'; import { CoreApp, isValidDuration, isValidGrafanaDuration, SelectableValue } from '@grafana/data'; -import { EditorField, EditorRow } from '@grafana/experimental'; +import { EditorField, EditorRow, QueryOptionGroup } from '@grafana/experimental'; import { config, reportInteraction } from '@grafana/runtime'; import { Alert, AutoSizeInput, RadioButtonGroup, Select } from '@grafana/ui'; -import { QueryOptionGroup } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryOptionGroup'; import { preprocessMaxLines, queryTypeOptions, RESOLUTION_OPTIONS } from '../../components/LokiOptionFields'; import { getLokiQueryType, isLogsQuery } from '../../queryUtils'; diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.test.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.test.tsx index 0ea262a76d50f..2277f31a6407c 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.test.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; -import { createLokiDatasource } from '../../mocks'; +import { createLokiDatasource } from '../../__mocks__/datasource'; import { LokiQuery } from '../../types'; import { EXPLAIN_LABEL_FILTER_CONTENT } from './LokiQueryBuilderExplained'; diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.tsx index 7e4fcc2ce3ce0..5db5313f98514 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.tsx @@ -4,7 +4,6 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { config } from '@grafana/runtime'; import { useStyles2, HorizontalGroup, IconButton, Tooltip, Icon } from '@grafana/ui'; -import { getModKey } from 'app/core/utils/browser'; import { testIds } from '../../components/LokiQueryEditor'; import { LokiQueryField } from '../../components/LokiQueryField'; @@ -57,7 +56,7 @@ export function LokiQueryCodeEditor({ size="xs" tooltip="Format query" /> - <Tooltip content={`Use ${getModKey()}+z to undo`}> + <Tooltip content={`Use ctrl/cmd + z to undo`}> <Icon className={styles.hint} name="keyboard" /> </Tooltip> </HorizontalGroup> diff --git a/public/app/plugins/datasource/loki/querybuilder/components/NestedQuery.test.tsx b/public/app/plugins/datasource/loki/querybuilder/components/NestedQuery.test.tsx index ec39fff98e8a0..2c3a586fb0008 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/NestedQuery.test.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/NestedQuery.test.tsx @@ -1,8 +1,8 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { createLokiDatasource } from '../../mocks'; +import { createLokiDatasource } from '../../__mocks__/datasource'; import { LokiVisualQueryBinary } from '../types'; import { NestedQuery, Props as NestedQueryProps } from './NestedQuery'; @@ -71,7 +71,7 @@ describe('exit the nested query', () => { it('onRemove is called when clicking (x)', async () => { const props = createMockProps(); render(<NestedQuery {...props} />); - fireEvent.click(await screen.findByLabelText('Remove nested query')); + await userEvent.click(await screen.findByLabelText('Remove nested query')); await waitFor(() => expect(props.onRemove).toHaveBeenCalledTimes(1)); }); }); @@ -80,8 +80,8 @@ describe('change operator', () => { it('onChange is called with the correct args', async () => { const props = createMockProps('/', 'on'); render(<NestedQuery {...props} />); - userEvent.click(await screen.findByLabelText('Select operator')); - fireEvent.click(await screen.findByText('+')); + await userEvent.click(await screen.findByLabelText('Select operator')); + await userEvent.click(await screen.findByText('+')); await waitFor(() => expect(props.onChange).toHaveBeenCalledTimes(1)); await waitFor(() => expect(props.onChange).toHaveBeenCalledWith(0, { diff --git a/public/app/plugins/datasource/loki/querybuilder/components/NestedQueryList.test.tsx b/public/app/plugins/datasource/loki/querybuilder/components/NestedQueryList.test.tsx index d1b2f3fb4b759..623a44cbea841 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/NestedQueryList.test.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/NestedQueryList.test.tsx @@ -2,8 +2,8 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { createLokiDatasource } from '../../__mocks__/datasource'; import { LokiDatasource } from '../../datasource'; -import { createLokiDatasource } from '../../mocks'; import { LokiVisualQuery, LokiVisualQueryBinary } from '../types'; import { EXPLAIN_LABEL_FILTER_CONTENT } from './LokiQueryBuilderExplained'; diff --git a/public/app/plugins/datasource/loki/querybuilder/components/QueryPattern.tsx b/public/app/plugins/datasource/loki/querybuilder/components/QueryPattern.tsx index a1b00f4284273..c3d15c9711b23 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/QueryPattern.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/QueryPattern.tsx @@ -2,8 +2,8 @@ import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { RawQuery } from '@grafana/experimental'; import { Button, Card, useStyles2 } from '@grafana/ui'; -import { RawQuery } from 'app/plugins/datasource/prometheus/querybuilder/shared/RawQuery'; import logqlGrammar from '../../syntax'; import { lokiQueryModeller } from '../LokiQueryModeller'; @@ -31,7 +31,7 @@ export const QueryPattern = (props: Props) => { <div className={styles.rawQueryContainer}> <RawQuery query={lokiQueryModeller.renderQuery({ labels: [], operations: pattern.operations })} - lang={lang} + language={lang} className={styles.rawQuery} /> </div> diff --git a/public/app/plugins/datasource/loki/querybuilder/components/QueryPatternsModal.tsx b/public/app/plugins/datasource/loki/querybuilder/components/QueryPatternsModal.tsx index f2dbd82113318..385b032b05e25 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/QueryPatternsModal.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/QueryPatternsModal.tsx @@ -2,10 +2,9 @@ import { css } from '@emotion/css'; import { capitalize } from 'lodash'; import React, { useMemo, useState } from 'react'; -import { CoreApp, DataQuery, GrafanaTheme2 } from '@grafana/data'; +import { CoreApp, DataQuery, GrafanaTheme2, getNextRefId } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; import { Button, Collapse, Modal, useStyles2 } from '@grafana/ui'; -import { getNextRefIdChar } from 'app/core/utils/query'; import { LokiQuery } from '../../types'; import { lokiQueryModeller } from '../LokiQueryModeller'; @@ -52,7 +51,7 @@ export const QueryPatternsModal = (props: Props) => { if (hasNewQueryOption && selectAsNewQuery) { onAddQuery({ ...query, - refId: getNextRefIdChar(queries ?? [query]), + refId: getNextRefId(queries ?? [query]), expr: lokiQueryModeller.renderQuery(visualQuery.query), }); } else { diff --git a/public/app/plugins/datasource/loki/querybuilder/components/QueryPreview.tsx b/public/app/plugins/datasource/loki/querybuilder/components/QueryPreview.tsx index ad64cdc3036d0..04fffba21ae83 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/QueryPreview.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/QueryPreview.tsx @@ -1,8 +1,7 @@ import React from 'react'; -import { EditorRow, EditorFieldGroup } from '@grafana/experimental'; +import { EditorRow, EditorFieldGroup, RawQuery } from '@grafana/experimental'; -import { RawQuery } from '../../../prometheus/querybuilder/shared/RawQuery'; import { lokiGrammar } from '../../syntax'; export interface Props { @@ -13,7 +12,7 @@ export function QueryPreview({ query }: Props) { return ( <EditorRow> <EditorFieldGroup> - <RawQuery query={query} lang={{ grammar: lokiGrammar, name: 'lokiql' }} /> + <RawQuery query={query} language={{ grammar: lokiGrammar, name: 'lokiql' }} /> </EditorFieldGroup> </EditorRow> ); diff --git a/public/app/plugins/datasource/loki/querybuilder/components/UnwrapParamEditor.test.tsx b/public/app/plugins/datasource/loki/querybuilder/components/UnwrapParamEditor.test.tsx index cb43f4c3089c5..3c5f98e2cbe65 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/UnwrapParamEditor.test.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/UnwrapParamEditor.test.tsx @@ -3,14 +3,12 @@ import userEvent from '@testing-library/user-event'; import React, { ComponentProps } from 'react'; import { DataFrame, DataSourceApi, FieldType, toDataFrame } from '@grafana/data'; +import { QueryBuilderOperation, QueryBuilderOperationParamDef } from '@grafana/experimental'; import { config } from '@grafana/runtime'; -import { - QueryBuilderOperation, - QueryBuilderOperationParamDef, -} from 'app/plugins/datasource/prometheus/querybuilder/shared/types'; +import { createLokiDatasource } from '../../__mocks__/datasource'; import { LokiDatasource } from '../../datasource'; -import { createLokiDatasource } from '../../mocks'; +import { LokiQueryModeller } from '../LokiQueryModeller'; import { LokiOperationId } from '../types'; import { UnwrapParamEditor } from './UnwrapParamEditor'; @@ -96,6 +94,9 @@ const createProps = ( paramDef: {} as QueryBuilderOperationParamDef, operation: {} as QueryBuilderOperation, datasource: createLokiDatasource() as DataSourceApi, + queryModeller: { + renderQuery: jest.fn().mockReturnValue('sum_over_time({foo="bar"} | logfmt | unwrap [5m])'), + } as unknown as LokiQueryModeller, }; const props = { ...propsDefault, ...propsOverrides }; diff --git a/public/app/plugins/datasource/loki/querybuilder/components/UnwrapParamEditor.tsx b/public/app/plugins/datasource/loki/querybuilder/components/UnwrapParamEditor.tsx index f013a162fad0c..030b323566649 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/UnwrapParamEditor.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/UnwrapParamEditor.tsx @@ -1,15 +1,14 @@ import React, { useState } from 'react'; import { SelectableValue, getDefaultTimeRange, toOption } from '@grafana/data'; +import { QueryBuilderOperationParamEditorProps, VisualQueryModeller } from '@grafana/experimental'; import { config } from '@grafana/runtime'; import { Select } from '@grafana/ui'; -import { QueryBuilderOperationParamEditorProps } from '../../../prometheus/querybuilder/shared/types'; import { placeHolderScopedVars } from '../../components/monaco-query-field/monaco-completion-provider/validation'; import { LokiDatasource } from '../../datasource'; import { getLogQueryFromMetricsQuery, isQueryWithError } from '../../queryUtils'; import { extractUnwrapLabelKeysFromDataFrame } from '../../responseUtils'; -import { lokiQueryModeller } from '../LokiQueryModeller'; import { getOperationParamId } from '../operationUtils'; import { LokiVisualQuery } from '../types'; @@ -21,6 +20,7 @@ export function UnwrapParamEditor({ query, datasource, timeRange, + queryModeller, }: QueryBuilderOperationParamEditorProps) { const [state, setState] = useState<{ options?: Array<SelectableValue<string>>; @@ -34,7 +34,7 @@ export function UnwrapParamEditor({ // This check is always true, we do it to make typescript happy if (datasource instanceof LokiDatasource && config.featureToggles.lokiQueryHints) { setState({ isLoading: true }); - const options = await loadUnwrapOptions(query, datasource, timeRange); + const options = await loadUnwrapOptions(query, datasource, queryModeller, timeRange); setState({ options, isLoading: undefined }); } }} @@ -56,9 +56,10 @@ export function UnwrapParamEditor({ async function loadUnwrapOptions( query: LokiVisualQuery, datasource: LokiDatasource, + queryModeller: VisualQueryModeller, timeRange = getDefaultTimeRange() ): Promise<Array<SelectableValue<string>>> { - const queryExpr = lokiQueryModeller.renderQuery(query); + const queryExpr = queryModeller.renderQuery(query); const logExpr = getLogQueryFromMetricsQuery(queryExpr); if (isQueryWithError(datasource.interpolateString(logExpr, placeHolderScopedVars))) { return []; diff --git a/public/app/plugins/datasource/loki/querybuilder/operationUtils.test.ts b/public/app/plugins/datasource/loki/querybuilder/operationUtils.test.ts index 6c6bc4d30943c..4ea7b5fed2487 100644 --- a/public/app/plugins/datasource/loki/querybuilder/operationUtils.test.ts +++ b/public/app/plugins/datasource/loki/querybuilder/operationUtils.test.ts @@ -1,4 +1,4 @@ -import { QueryBuilderOperation, QueryBuilderOperationDef } from '../../prometheus/querybuilder/shared/types'; +import { QueryBuilderOperation, QueryBuilderOperationDefinition } from '@grafana/experimental'; import { createAggregationOperation, @@ -10,7 +10,7 @@ import { labelFilterRenderer, pipelineRenderer, } from './operationUtils'; -import { getOperationDefinitions } from './operations'; +import { operationDefinitions } from './operations'; import { LokiOperationId, LokiVisualQueryOperationCategory } from './types'; describe('createRangeOperation', () => { @@ -149,7 +149,7 @@ describe('getLineFilterRenderer', () => { params: ['`error`'], }; - const MOCK_DEF = undefined as unknown as QueryBuilderOperationDef; + const MOCK_DEF = undefined as unknown as QueryBuilderOperationDefinition; const MOCK_INNER_EXPR = '{job="grafana"}'; @@ -178,7 +178,7 @@ describe('getLineFilterRenderer', () => { describe('labelFilterRenderer', () => { const MOCK_MODEL = { id: '__label_filter', params: ['label', '', 'value'] }; - const MOCK_DEF = undefined as unknown as QueryBuilderOperationDef; + const MOCK_DEF = undefined as unknown as QueryBuilderOperationDefinition; const MOCK_INNER_EXPR = '{job="grafana"}'; it.each` @@ -220,17 +220,12 @@ describe('isConflictingFilter', () => { }); describe('pipelineRenderer', () => { - let definitions: QueryBuilderOperationDef[]; - beforeEach(() => { - definitions = getOperationDefinitions(); - }); - it('correctly renders unpack expressions', () => { const model: QueryBuilderOperation = { id: LokiOperationId.Unpack, params: [], }; - const definition = definitions.find((def) => def.id === LokiOperationId.Unpack); + const definition = operationDefinitions.find((def) => def.id === LokiOperationId.Unpack); expect(pipelineRenderer(model, definition!, '{}')).toBe('{} | unpack'); }); @@ -239,7 +234,7 @@ describe('pipelineRenderer', () => { id: LokiOperationId.Unpack, params: [], }; - const definition = definitions.find((def) => def.id === LokiOperationId.Unpack); + const definition = operationDefinitions.find((def) => def.id === LokiOperationId.Unpack); expect(pipelineRenderer(model, definition!, '{}')).toBe('{} | unpack'); }); @@ -248,7 +243,7 @@ describe('pipelineRenderer', () => { id: LokiOperationId.Logfmt, params: [], }; - const definition = definitions.find((def) => def.id === LokiOperationId.Logfmt); + const definition = operationDefinitions.find((def) => def.id === LokiOperationId.Logfmt); expect(pipelineRenderer(model, definition!, '{}')).toBe('{} | logfmt'); }); @@ -257,7 +252,7 @@ describe('pipelineRenderer', () => { id: LokiOperationId.Logfmt, params: [true, false, 'foo', ''], }; - const definition = definitions.find((def) => def.id === LokiOperationId.Logfmt); + const definition = operationDefinitions.find((def) => def.id === LokiOperationId.Logfmt); expect(pipelineRenderer(model, definition!, '{}')).toBe('{} | logfmt --strict foo'); }); @@ -266,7 +261,7 @@ describe('pipelineRenderer', () => { id: LokiOperationId.Logfmt, params: [true, false, 'foo', 'bar', 'baz'], }; - const definition = definitions.find((def) => def.id === LokiOperationId.Logfmt); + const definition = operationDefinitions.find((def) => def.id === LokiOperationId.Logfmt); expect(pipelineRenderer(model, definition!, '{}')).toBe('{} | logfmt --strict foo, bar, baz'); }); @@ -275,7 +270,7 @@ describe('pipelineRenderer', () => { id: LokiOperationId.Json, params: [], }; - const definition = definitions.find((def) => def.id === LokiOperationId.Json); + const definition = operationDefinitions.find((def) => def.id === LokiOperationId.Json); expect(pipelineRenderer(model, definition!, '{}')).toBe('{} | json'); }); @@ -284,7 +279,7 @@ describe('pipelineRenderer', () => { id: LokiOperationId.Json, params: ['foo', ''], }; - const definition = definitions.find((def) => def.id === LokiOperationId.Json); + const definition = operationDefinitions.find((def) => def.id === LokiOperationId.Json); expect(pipelineRenderer(model, definition!, '{}')).toBe('{} | json foo'); }); @@ -293,7 +288,7 @@ describe('pipelineRenderer', () => { id: LokiOperationId.Json, params: ['foo', 'bar', 'baz'], }; - const definition = definitions.find((def) => def.id === LokiOperationId.Json); + const definition = operationDefinitions.find((def) => def.id === LokiOperationId.Json); expect(pipelineRenderer(model, definition!, '{}')).toBe('{} | json foo, bar, baz'); }); @@ -302,7 +297,7 @@ describe('pipelineRenderer', () => { id: LokiOperationId.Keep, params: ['foo', ''], }; - const definition = definitions.find((def) => def.id === LokiOperationId.Keep); + const definition = operationDefinitions.find((def) => def.id === LokiOperationId.Keep); expect(pipelineRenderer(model, definition!, '{}')).toBe('{} | keep foo'); }); @@ -311,7 +306,7 @@ describe('pipelineRenderer', () => { id: LokiOperationId.Keep, params: ['foo', 'bar', 'baz'], }; - const definition = definitions.find((def) => def.id === LokiOperationId.Keep); + const definition = operationDefinitions.find((def) => def.id === LokiOperationId.Keep); expect(pipelineRenderer(model, definition!, '{}')).toBe('{} | keep foo, bar, baz'); }); @@ -320,7 +315,7 @@ describe('pipelineRenderer', () => { id: LokiOperationId.Drop, params: ['foo', ''], }; - const definition = definitions.find((def) => def.id === LokiOperationId.Drop); + const definition = operationDefinitions.find((def) => def.id === LokiOperationId.Drop); expect(pipelineRenderer(model, definition!, '{}')).toBe('{} | drop foo'); }); @@ -329,7 +324,7 @@ describe('pipelineRenderer', () => { id: LokiOperationId.Drop, params: ['foo', 'bar', 'baz'], }; - const definition = definitions.find((def) => def.id === LokiOperationId.Drop); + const definition = operationDefinitions.find((def) => def.id === LokiOperationId.Drop); expect(pipelineRenderer(model, definition!, '{}')).toBe('{} | drop foo, bar, baz'); }); }); diff --git a/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts b/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts index 14b069126178f..971ae7e3fc000 100644 --- a/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts +++ b/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts @@ -1,21 +1,25 @@ import { capitalize } from 'lodash'; import pluralize from 'pluralize'; -import { LabelParamEditor } from '../../prometheus/querybuilder/components/LabelParamEditor'; import { QueryBuilderOperation, - QueryBuilderOperationDef, + QueryBuilderOperationDefinition, QueryBuilderOperationParamDef, QueryBuilderOperationParamValue, - QueryWithOperations, + VisualQuery, VisualQueryModeller, -} from '../../prometheus/querybuilder/shared/types'; +} from '@grafana/experimental'; + import { escapeLabelValueInExactSelector } from '../languageUtils'; import { FUNCTIONS } from '../syntax'; +import { LabelParamEditor } from './components/LabelParamEditor'; import { LokiOperationId, LokiOperationOrder, LokiVisualQuery, LokiVisualQueryOperationCategory } from './types'; -export function createRangeOperation(name: string, isRangeOperationWithGrouping?: boolean): QueryBuilderOperationDef { +export function createRangeOperation( + name: string, + isRangeOperationWithGrouping?: boolean +): QueryBuilderOperationDefinition { const params = [getRangeVectorParamDef()]; const defaultParams = ['$__auto']; let paramChangedHandler = undefined; @@ -62,11 +66,11 @@ export function createRangeOperation(name: string, isRangeOperationWithGrouping? }; } -export function createRangeOperationWithGrouping(name: string): QueryBuilderOperationDef[] { +export function createRangeOperationWithGrouping(name: string): QueryBuilderOperationDefinition[] { const rangeOperation = createRangeOperation(name, true); // Copy range operation params without the last param const params = rangeOperation.params.slice(0, -1); - const operations: QueryBuilderOperationDef[] = [ + const operations: QueryBuilderOperationDefinition[] = [ rangeOperation, { id: `__${name}_by`, @@ -118,7 +122,11 @@ export function createRangeOperationWithGrouping(name: string): QueryBuilderOper } export function getRangeAggregationWithGroupingRenderer(aggregation: string, grouping: 'by' | 'without') { - return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + return function aggregationRenderer( + model: QueryBuilderOperation, + def: QueryBuilderOperationDefinition, + innerExpr: string + ) { const restParamIndex = def.params.findIndex((param) => param.restParam); const params = model.params.slice(0, restParamIndex); const restParams = model.params.slice(restParamIndex); @@ -133,7 +141,7 @@ export function getRangeAggregationWithGroupingRenderer(aggregation: string, gro function operationWithRangeVectorRenderer( model: QueryBuilderOperation, - def: QueryBuilderOperationDef, + def: QueryBuilderOperationDefinition, innerExpr: string ) { const params = model.params ?? []; @@ -147,7 +155,11 @@ function operationWithRangeVectorRenderer( return `${model.id}(${innerExpr} [${params[0] ?? '$__auto'}])`; } -export function labelFilterRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { +export function labelFilterRenderer( + model: QueryBuilderOperation, + def: QueryBuilderOperationDefinition, + innerExpr: string +) { const integerOperators = ['<', '<=', '>', '>=']; if (integerOperators.includes(String(model.params[1]))) { @@ -161,6 +173,9 @@ export function isConflictingFilter( operation: QueryBuilderOperation, queryOperations: QueryBuilderOperation[] ): boolean { + if (!operation) { + return false; + } const operationIsNegative = operation.params[1].toString().startsWith('!'); const candidates = queryOperations.filter( @@ -183,7 +198,11 @@ export function isConflictingFilter( return conflict; } -export function pipelineRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { +export function pipelineRenderer( + model: QueryBuilderOperation, + def: QueryBuilderOperationDefinition, + innerExpr: string +) { switch (model.id) { case LokiOperationId.Logfmt: const [strict = false, keepEmpty = false, ...labels] = model.params; @@ -201,17 +220,17 @@ export function pipelineRenderer(model: QueryBuilderOperation, def: QueryBuilder } } -function isRangeVectorFunction(def: QueryBuilderOperationDef) { +function isRangeVectorFunction(def: QueryBuilderOperationDefinition) { return def.category === LokiVisualQueryOperationCategory.RangeFunctions; } function getIndexOfOrLast( operations: QueryBuilderOperation[], queryModeller: VisualQueryModeller, - condition: (def: QueryBuilderOperationDef) => boolean + condition: (def: QueryBuilderOperationDefinition) => boolean ) { const index = operations.findIndex((x) => { - const opDef = queryModeller.getOperationDef(x.id); + const opDef = queryModeller.getOperationDefinition(x.id); if (!opDef) { return false; } @@ -222,7 +241,7 @@ function getIndexOfOrLast( } export function addLokiOperation( - def: QueryBuilderOperationDef, + def: QueryBuilderOperationDefinition, query: LokiVisualQuery, modeller: VisualQueryModeller ): LokiVisualQuery { @@ -234,7 +253,7 @@ export function addLokiOperation( const operations = [...query.operations]; const existingRangeVectorFunction = operations.find((x) => { - const opDef = modeller.getOperationDef(x.id); + const opDef = modeller.getOperationDefinition(x.id); if (!opDef) { return false; } @@ -280,7 +299,7 @@ export function addLokiOperation( }; } -export function addNestedQueryHandler(def: QueryBuilderOperationDef, query: LokiVisualQuery): LokiVisualQuery { +export function addNestedQueryHandler(def: QueryBuilderOperationDefinition, query: LokiVisualQuery): LokiVisualQuery { return { ...query, binaryQueries: [ @@ -294,7 +313,11 @@ export function addNestedQueryHandler(def: QueryBuilderOperationDef, query: Loki } export function getLineFilterRenderer(operation: string, caseInsensitive?: boolean) { - return function lineFilterRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + return function lineFilterRenderer( + model: QueryBuilderOperation, + def: QueryBuilderOperationDefinition, + innerExpr: string + ) { const hasBackticks = model.params.some((param) => typeof param === 'string' && param.includes('`')); const delimiter = hasBackticks ? '"' : '`'; let params; @@ -324,7 +347,7 @@ export function getOperationParamId(operationId: string, paramIndex: number) { } export function getOnLabelAddedHandler(changeToOperationId: string) { - return function onParamChanged(index: number, op: QueryBuilderOperation, def: QueryBuilderOperationDef) { + return function onParamChanged(index: number, op: QueryBuilderOperation, def: QueryBuilderOperationDefinition) { // Check if we actually have the label param. As it's optional the aggregation can have one less, which is the // case of just simple aggregation without label. When user adds the label it now has the same number of params // as its definition, and now we can change it to its `_by` variant. @@ -361,7 +384,7 @@ export function getAggregationExplainer(aggregationName: string, mode: 'by' | 'w * This function will transform operations without labels to their plan aggregation operation */ export function getLastLabelRemovedHandler(changeToOperationId: string) { - return function onParamChanged(index: number, op: QueryBuilderOperation, def: QueryBuilderOperationDef) { + return function onParamChanged(index: number, op: QueryBuilderOperation, def: QueryBuilderOperationDefinition) { // If definition has more params then is defined there are no optional rest params anymore. // We then transform this operation into a different one if (op.params.length < def.params.length) { @@ -379,7 +402,7 @@ export function getLokiOperationDisplayName(funcName: string) { return capitalize(funcName.replace(/_/g, ' ')); } -export function defaultAddOperationHandler<T extends QueryWithOperations>(def: QueryBuilderOperationDef, query: T) { +export function defaultAddOperationHandler<T extends VisualQuery>(def: QueryBuilderOperationDefinition, query: T) { const newOperation: QueryBuilderOperation = { id: def.id, params: def.defaultParams, @@ -393,9 +416,9 @@ export function defaultAddOperationHandler<T extends QueryWithOperations>(def: Q export function createAggregationOperation( name: string, - overrides: Partial<QueryBuilderOperationDef> = {} -): QueryBuilderOperationDef[] { - const operations: QueryBuilderOperationDef[] = [ + overrides: Partial<QueryBuilderOperationDefinition> = {} +): QueryBuilderOperationDefinition[] { + const operations: QueryBuilderOperationDefinition[] = [ { id: name, name: getLokiOperationDisplayName(name), @@ -466,12 +489,20 @@ export function createAggregationOperation( } function getAggregationWithoutRenderer(aggregation: string) { - return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + return function aggregationRenderer( + model: QueryBuilderOperation, + def: QueryBuilderOperationDefinition, + innerExpr: string + ) { return `${aggregation} without(${model.params.join(', ')}) (${innerExpr})`; }; } -export function functionRendererLeft(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { +export function functionRendererLeft( + model: QueryBuilderOperation, + def: QueryBuilderOperationDefinition, + innerExpr: string +) { const params = renderParams(model, def, innerExpr); const str = model.id + '('; @@ -482,7 +513,7 @@ export function functionRendererLeft(model: QueryBuilderOperation, def: QueryBui return str + params.join(', ') + ')'; } -function renderParams(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { +function renderParams(model: QueryBuilderOperation, def: QueryBuilderOperationDefinition, innerExpr: string) { return (model.params ?? []).map((value, index) => { const paramDef = def.params[index]; if (paramDef.type === 'string') { @@ -494,7 +525,11 @@ function renderParams(model: QueryBuilderOperation, def: QueryBuilderOperationDe } function getAggregationByRenderer(aggregation: string) { - return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + return function aggregationRenderer( + model: QueryBuilderOperation, + def: QueryBuilderOperationDefinition, + innerExpr: string + ) { return `${aggregation} by(${model.params.join(', ')}) (${innerExpr})`; }; } @@ -502,8 +537,8 @@ function getAggregationByRenderer(aggregation: string) { export function createAggregationOperationWithParam( name: string, paramsDef: { params: QueryBuilderOperationParamDef[]; defaultParams: QueryBuilderOperationParamValue[] }, - overrides: Partial<QueryBuilderOperationDef> = {} -): QueryBuilderOperationDef[] { + overrides: Partial<QueryBuilderOperationDefinition> = {} +): QueryBuilderOperationDefinition[] { const operations = createAggregationOperation(name, overrides); operations[0].params.unshift(...paramsDef.params); operations[1].params.unshift(...paramsDef.params); @@ -517,7 +552,11 @@ export function createAggregationOperationWithParam( } function getAggregationByRendererWithParameter(aggregation: string) { - return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + return function aggregationRenderer( + model: QueryBuilderOperation, + def: QueryBuilderOperationDefinition, + innerExpr: string + ) { const restParamIndex = def.params.findIndex((param) => param.restParam); const params = model.params.slice(0, restParamIndex); const restParams = model.params.slice(restParamIndex); diff --git a/public/app/plugins/datasource/loki/querybuilder/operations.test.ts b/public/app/plugins/datasource/loki/querybuilder/operations.test.ts index ccd1ee1254e63..7d0a66741b2d7 100644 --- a/public/app/plugins/datasource/loki/querybuilder/operations.test.ts +++ b/public/app/plugins/datasource/loki/querybuilder/operations.test.ts @@ -1,4 +1,4 @@ -import { explainOperator, getOperationDefinitions } from './operations'; +import { explainOperator, operationDefinitions } from './operations'; import { LokiOperationId } from './types'; const undocumentedOperationsIds: string[] = [ @@ -21,8 +21,7 @@ describe('explainOperator', () => { let operations = []; let undocumentedOperations = []; - const definitions = getOperationDefinitions(); - for (const definition of definitions) { + for (const definition of operationDefinitions) { if (!undocumentedOperationsIds.includes(definition.id)) { operations.push(definition.id); } else { @@ -31,7 +30,7 @@ describe('explainOperator', () => { } test('Resolves operation definitions', () => { - expect(definitions.length).toBeGreaterThan(0); + expect(operationDefinitions.length).toBeGreaterThan(0); }); test.each(operations)('Returns docs for the %s operation', (operation) => { diff --git a/public/app/plugins/datasource/loki/querybuilder/operations.ts b/public/app/plugins/datasource/loki/querybuilder/operations.ts index ddd94ac210201..1ecdec52d9e2f 100644 --- a/public/app/plugins/datasource/loki/querybuilder/operations.ts +++ b/public/app/plugins/datasource/loki/querybuilder/operations.ts @@ -1,4 +1,4 @@ -import { QueryBuilderOperationDef, QueryBuilderOperationParamValue } from '../../prometheus/querybuilder/shared/types'; +import { QueryBuilderOperationDefinition, QueryBuilderOperationParamValue } from '@grafana/experimental'; import { binaryScalarOperations } from './binaryScalarOperations'; import { UnwrapParamEditor } from './components/UnwrapParamEditor'; @@ -15,7 +15,7 @@ import { } from './operationUtils'; import { LokiOperationId, LokiOperationOrder, lokiOperators, LokiVisualQueryOperationCategory } from './types'; -export function getOperationDefinitions(): QueryBuilderOperationDef[] { +function getOperationDefinitions(): QueryBuilderOperationDefinition[] { const aggregations = [ LokiOperationId.Sum, LokiOperationId.Min, @@ -66,7 +66,7 @@ export function getOperationDefinitions(): QueryBuilderOperationDef[] { ...createRangeOperationWithGrouping(LokiOperationId.QuantileOverTime), ]; - const list: QueryBuilderOperationDef[] = [ + const list: QueryBuilderOperationDefinition[] = [ ...aggregations, ...aggregationsWithParam, ...rangeOperations, @@ -581,14 +581,14 @@ Example: \`\`error_level=\`level\` \`\` } // Keeping a local copy as an optimization measure. -const definitions = getOperationDefinitions(); +export const operationDefinitions = getOperationDefinitions(); /** * Given an operator, return the corresponding explain. * For usage within the Query Editor. */ export function explainOperator(id: LokiOperationId | string): string { - const definition = definitions.find((operation) => operation.id === id); + const definition = operationDefinitions.find((operation) => operation.id === id); const explain = definition?.explainHandler?.({ id: '', params: ['<value>'] }) || ''; @@ -596,11 +596,14 @@ export function explainOperator(id: LokiOperationId | string): string { return explain.replace(/\[(.*)\]\(.*\)/g, '$1'); } -export function getDefinitionById(id: string): QueryBuilderOperationDef | undefined { - return definitions.find((x) => x.id === id); +export function getDefinitionById(id: string): QueryBuilderOperationDefinition | undefined { + return operationDefinitions.find((x) => x.id === id); } -export function checkParamsAreValid(def: QueryBuilderOperationDef, params: QueryBuilderOperationParamValue[]): boolean { +export function checkParamsAreValid( + def: QueryBuilderOperationDefinition, + params: QueryBuilderOperationParamValue[] +): boolean { // For now we only check if the operation has all the required params. if (params.length < def.params.filter((param) => !param.optional).length) { return false; diff --git a/public/app/plugins/datasource/loki/querybuilder/parsing.test.ts b/public/app/plugins/datasource/loki/querybuilder/parsing.test.ts index 1e7d559fc4899..dd4fe45a5524e 100644 --- a/public/app/plugins/datasource/loki/querybuilder/parsing.test.ts +++ b/public/app/plugins/datasource/loki/querybuilder/parsing.test.ts @@ -673,6 +673,21 @@ describe('buildVisualQueryFromString', () => { ); }); + it('parses quantile queries with grouping', () => { + expect(buildVisualQueryFromString(`quantile_over_time(0.99, {app="frontend"} [1m]) by (host1, host2)`)).toEqual( + noErrors({ + labels: [ + { + op: '=', + value: 'frontend', + label: 'app', + }, + ], + operations: [{ id: LokiOperationId.QuantileOverTime, params: ['1m', '0.99', 'host1', 'host2'] }], + }) + ); + }); + it('parses query with line format', () => { expect(buildVisualQueryFromString('{app="frontend"} | line_format "abc"')).toEqual( noErrors({ diff --git a/public/app/plugins/datasource/loki/querybuilder/parsing.ts b/public/app/plugins/datasource/loki/querybuilder/parsing.ts index 5c0375c069777..ea2f0003e8492 100644 --- a/public/app/plugins/datasource/loki/querybuilder/parsing.ts +++ b/public/app/plugins/datasource/loki/querybuilder/parsing.ts @@ -1,5 +1,6 @@ import { SyntaxNode } from '@lezer/common'; +import { QueryBuilderLabelFilter, QueryBuilderOperation, QueryBuilderOperationParamValue } from '@grafana/experimental'; import { And, BinOpExpr, @@ -54,12 +55,6 @@ import { OrFilter, } from '@grafana/lezer-logql'; -import { - QueryBuilderLabelFilter, - QueryBuilderOperation, - QueryBuilderOperationParamValue, -} from '../../prometheus/querybuilder/shared/types'; - import { binaryScalarDefs } from './binaryScalarOperations'; import { checkParamsAreValid, getDefinitionById } from './operations'; import { @@ -501,11 +496,16 @@ function handleRangeAggregation(expr: string, node: SyntaxNode, context: Context const params = number !== null && number !== undefined ? [getString(expr, number)] : []; const range = logExpr?.getChild(Range); const rangeValue = range ? getString(expr, range) : null; + const grouping = node.getChild(Grouping); if (rangeValue) { params.unshift(rangeValue.substring(1, rangeValue.length - 1)); } + if (grouping) { + params.push(...getAllByType(expr, grouping, Identifier)); + } + const op = { id: funcName, params, diff --git a/public/app/plugins/datasource/loki/querybuilder/parsingUtils.ts b/public/app/plugins/datasource/loki/querybuilder/parsingUtils.ts index a4e7215c5979d..adc171134353d 100644 --- a/public/app/plugins/datasource/loki/querybuilder/parsingUtils.ts +++ b/public/app/plugins/datasource/loki/querybuilder/parsingUtils.ts @@ -1,6 +1,6 @@ import { SyntaxNode, TreeCursor } from '@lezer/common'; -import { QueryBuilderOperation, QueryBuilderOperationParamValue } from '../../prometheus/querybuilder/shared/types'; +import { QueryBuilderOperation, QueryBuilderOperationParamValue } from '@grafana/experimental'; // Although 0 isn't explicitly provided in the @grafana/lezer-logql library as the error node ID, it does appear to be the ID of error nodes within lezer. export const ErrorId = 0; @@ -28,7 +28,7 @@ export function makeError(expr: string, node: SyntaxNode) { * \[\[([\s\S]+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]] * \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3} */ -const variableRegex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g; +export const variableRegex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g; /** * As variables with $ are creating parsing errors, we first replace them with magic string that is parsable and at diff --git a/public/app/plugins/datasource/loki/querybuilder/state.test.ts b/public/app/plugins/datasource/loki/querybuilder/state.test.ts index 2c59c4d9598db..8335414f6e636 100644 --- a/public/app/plugins/datasource/loki/querybuilder/state.test.ts +++ b/public/app/plugins/datasource/loki/querybuilder/state.test.ts @@ -1,4 +1,4 @@ -import { QueryEditorMode } from '../../prometheus/querybuilder/shared/types'; +import { QueryEditorMode } from '@grafana/experimental'; import { changeEditorMode, getQueryWithDefaults } from './state'; diff --git a/public/app/plugins/datasource/loki/querybuilder/state.ts b/public/app/plugins/datasource/loki/querybuilder/state.ts index 759a6626d9f0d..590aeffdd8a89 100644 --- a/public/app/plugins/datasource/loki/querybuilder/state.ts +++ b/public/app/plugins/datasource/loki/querybuilder/state.ts @@ -1,6 +1,5 @@ -import store from 'app/core/store'; +import { QueryEditorMode } from '@grafana/experimental'; -import { QueryEditorMode } from '../../prometheus/querybuilder/shared/types'; import { LokiQuery, LokiQueryType } from '../types'; const queryEditorModeDefaultLocalStorageKey = 'LokiQueryEditorModeDefault'; @@ -8,7 +7,7 @@ const queryEditorModeDefaultLocalStorageKey = 'LokiQueryEditorModeDefault'; export function changeEditorMode(query: LokiQuery, editorMode: QueryEditorMode, onChange: (query: LokiQuery) => void) { // If empty query store new mode as default if (query.expr === '') { - store.set(queryEditorModeDefaultLocalStorageKey, editorMode); + window.localStorage.setItem(queryEditorModeDefaultLocalStorageKey, editorMode); } onChange({ ...query, editorMode }); @@ -20,7 +19,7 @@ export function getDefaultEditorMode(expr: string) { return QueryEditorMode.Code; } - const value: string | undefined = store.get(queryEditorModeDefaultLocalStorageKey); + const value: string | null = window.localStorage.getItem(queryEditorModeDefaultLocalStorageKey); switch (value) { case 'code': return QueryEditorMode.Code; diff --git a/public/app/plugins/datasource/loki/querybuilder/types.ts b/public/app/plugins/datasource/loki/querybuilder/types.ts index fc08ca7a487ba..c6631fcf164d4 100644 --- a/public/app/plugins/datasource/loki/querybuilder/types.ts +++ b/public/app/plugins/datasource/loki/querybuilder/types.ts @@ -1,5 +1,9 @@ -import { VisualQueryBinary } from '../../prometheus/querybuilder/shared/LokiAndPromQueryModellerBase'; -import { QueryBuilderLabelFilter, QueryBuilderOperation } from '../../prometheus/querybuilder/shared/types'; +import { + VisualQueryBinary, + QueryBuilderLabelFilter, + QueryBuilderOperation, + BINARY_OPERATIONS_KEY, +} from '@grafana/experimental'; /** * Visual query model @@ -29,7 +33,7 @@ export enum LokiVisualQueryOperationCategory { Formats = 'Formats', LineFilters = 'Line filters', LabelFilters = 'Label filters', - BinaryOps = 'Binary operations', + BinaryOps = BINARY_OPERATIONS_KEY, } export enum LokiOperationId { diff --git a/public/app/plugins/datasource/loki/responseUtils.test.ts b/public/app/plugins/datasource/loki/responseUtils.test.ts index b836b65cd71c2..f961007968aa7 100644 --- a/public/app/plugins/datasource/loki/responseUtils.test.ts +++ b/public/app/plugins/datasource/loki/responseUtils.test.ts @@ -1,8 +1,7 @@ import { cloneDeep } from 'lodash'; -import { DataQueryResponse, QueryResultMetaStat, DataFrame, FieldType } from '@grafana/data'; +import { DataFrame, FieldType } from '@grafana/data'; -import { getMockFrames } from './mocks'; import { dataFrameHasLevelLabel, dataFrameHasLokiError, @@ -10,8 +9,6 @@ import { extractLogParserFromDataFrame, extractLabelKeysFromDataFrame, extractUnwrapLabelKeysFromDataFrame, - cloneQueryResponse, - combineResponses, } from './responseUtils'; import { LabelType } from './types'; @@ -69,6 +66,34 @@ const frameWithTypes: DataFrame = { ], }; +const frameWithMultipleLabels: DataFrame = { + length: 1, + fields: [ + { + name: 'Time', + config: {}, + type: FieldType.time, + values: [1, 2, 3], + }, + { + name: 'labels', + config: {}, + type: FieldType.other, + values: [ + { level: 'info', foo: 'bar' }, + { level: 'info', foo: 'baz', new: 'yes' }, + { level: 'error', foo: 'baz' }, + ], + }, + { + name: 'Line', + config: {}, + type: FieldType.string, + values: ['line1', 'line2', 'line3'], + }, + ], +}; + describe('dataFrameHasParsingError', () => { it('handles frame with parsing error', () => { const input = cloneDeep(frame); @@ -141,6 +166,11 @@ describe('extractLabelKeysFromDataFrame', () => { expect(extractLabelKeysFromDataFrame(input)).toEqual(['level']); }); + it('extracts label keys from all logs', () => { + const input = cloneDeep(frameWithMultipleLabels); + expect(extractLabelKeysFromDataFrame(input)).toEqual(['level', 'foo', 'new']); + }); + it('extracts indexed label keys', () => { const input = cloneDeep(frameWithTypes); expect(extractLabelKeysFromDataFrame(input)).toEqual(['level']); @@ -169,410 +199,3 @@ describe('extractUnwrapLabelKeysFromDataFrame', () => { expect(extractUnwrapLabelKeysFromDataFrame(input)).toEqual(['number']); }); }); - -describe('cloneQueryResponse', () => { - const { logFrameA } = getMockFrames(); - const responseA: DataQueryResponse = { - data: [logFrameA], - }; - it('clones query responses', () => { - const clonedA = cloneQueryResponse(responseA); - expect(clonedA).not.toBe(responseA); - expect(clonedA).toEqual(clonedA); - }); -}); - -describe('combineResponses', () => { - it('combines logs frames', () => { - const { logFrameA, logFrameB } = getMockFrames(); - const responseA: DataQueryResponse = { - data: [logFrameA], - }; - const responseB: DataQueryResponse = { - data: [logFrameB], - }; - expect(combineResponses(responseA, responseB)).toEqual({ - data: [ - { - fields: [ - { - config: {}, - name: 'Time', - type: 'time', - values: [1, 2, 3, 4], - }, - { - config: {}, - name: 'Line', - type: 'string', - values: ['line3', 'line4', 'line1', 'line2'], - }, - { - config: {}, - name: 'labels', - type: 'other', - values: [ - { - otherLabel: 'other value', - }, - { - label: 'value', - }, - { - otherLabel: 'other value', - }, - ], - }, - { - config: {}, - name: 'tsNs', - type: 'string', - values: ['1000000', '2000000', '3000000', '4000000'], - }, - { - config: {}, - name: 'id', - type: 'string', - values: ['id3', 'id4', 'id1', 'id2'], - }, - ], - length: 4, - meta: { - custom: { - frameType: 'LabeledTimeValues', - }, - stats: [ - { - displayName: 'Summary: total bytes processed', - unit: 'decbytes', - value: 33, - }, - ], - }, - refId: 'A', - }, - ], - }); - }); - - it('combines metric frames', () => { - const { metricFrameA, metricFrameB } = getMockFrames(); - const responseA: DataQueryResponse = { - data: [metricFrameA], - }; - const responseB: DataQueryResponse = { - data: [metricFrameB], - }; - expect(combineResponses(responseA, responseB)).toEqual({ - data: [ - { - fields: [ - { - config: {}, - name: 'Time', - type: 'time', - values: [1000000, 2000000, 3000000, 4000000], - }, - { - config: {}, - name: 'Value', - type: 'number', - values: [6, 7, 5, 4], - labels: { - level: 'debug', - }, - }, - ], - length: 4, - meta: { - type: 'timeseries-multi', - stats: [ - { - displayName: 'Summary: total bytes processed', - unit: 'decbytes', - value: 33, - }, - ], - }, - refId: 'A', - }, - ], - }); - }); - - it('combines and identifies new frames in the response', () => { - const { metricFrameA, metricFrameB, metricFrameC } = getMockFrames(); - const responseA: DataQueryResponse = { - data: [metricFrameA], - }; - const responseB: DataQueryResponse = { - data: [metricFrameB, metricFrameC], - }; - expect(combineResponses(responseA, responseB)).toEqual({ - data: [ - { - fields: [ - { - config: {}, - name: 'Time', - type: 'time', - values: [1000000, 2000000, 3000000, 4000000], - }, - { - config: {}, - name: 'Value', - type: 'number', - values: [6, 7, 5, 4], - labels: { - level: 'debug', - }, - }, - ], - length: 4, - meta: { - type: 'timeseries-multi', - stats: [ - { - displayName: 'Summary: total bytes processed', - unit: 'decbytes', - value: 33, - }, - ], - }, - refId: 'A', - }, - metricFrameC, - ], - }); - }); - - it('combines frames prioritizing refIds over names', () => { - const { metricFrameA, metricFrameB } = getMockFrames(); - const dataFrameA = { - ...metricFrameA, - refId: 'A', - name: 'A', - }; - const dataFrameB = { - ...metricFrameB, - refId: 'B', - name: 'A', - }; - const responseA: DataQueryResponse = { - data: [dataFrameA], - }; - const responseB: DataQueryResponse = { - data: [dataFrameB], - }; - expect(combineResponses(responseA, responseB)).toEqual({ - data: [dataFrameA, dataFrameB], - }); - }); - - it('combines frames in a new response instance', () => { - const { metricFrameA, metricFrameB } = getMockFrames(); - const responseA: DataQueryResponse = { - data: [metricFrameA], - }; - const responseB: DataQueryResponse = { - data: [metricFrameB], - }; - expect(combineResponses(null, responseA)).not.toBe(responseA); - expect(combineResponses(null, responseB)).not.toBe(responseB); - }); - - it('combine when first param has errors', () => { - const { metricFrameA, metricFrameB } = getMockFrames(); - const errorA = { - message: 'errorA', - }; - const responseA: DataQueryResponse = { - data: [metricFrameA], - error: errorA, - errors: [errorA], - }; - const responseB: DataQueryResponse = { - data: [metricFrameB], - }; - - const combined = combineResponses(responseA, responseB); - expect(combined.data[0].length).toBe(4); - expect(combined.error?.message).toBe('errorA'); - expect(combined.errors).toHaveLength(1); - expect(combined.errors?.[0]?.message).toBe('errorA'); - }); - - it('combine when second param has errors', () => { - const { metricFrameA, metricFrameB } = getMockFrames(); - const responseA: DataQueryResponse = { - data: [metricFrameA], - }; - const errorB = { - message: 'errorB', - }; - const responseB: DataQueryResponse = { - data: [metricFrameB], - error: errorB, - errors: [errorB], - }; - - const combined = combineResponses(responseA, responseB); - expect(combined.data[0].length).toBe(4); - expect(combined.error?.message).toBe('errorB'); - expect(combined.errors).toHaveLength(1); - expect(combined.errors?.[0]?.message).toBe('errorB'); - }); - - it('combine when both params have errors', () => { - const { metricFrameA, metricFrameB } = getMockFrames(); - const errorA = { - message: 'errorA', - }; - const errorB = { - message: 'errorB', - }; - const responseA: DataQueryResponse = { - data: [metricFrameA], - error: errorA, - errors: [errorA], - }; - const responseB: DataQueryResponse = { - data: [metricFrameB], - error: errorB, - errors: [errorB], - }; - - const combined = combineResponses(responseA, responseB); - expect(combined.data[0].length).toBe(4); - expect(combined.error?.message).toBe('errorA'); - expect(combined.errors).toHaveLength(2); - expect(combined.errors?.[0]?.message).toBe('errorA'); - expect(combined.errors?.[1]?.message).toBe('errorB'); - }); - - it('combines frames with nanoseconds', () => { - const { logFrameA, logFrameB } = getMockFrames(); - logFrameA.fields[0].nanos = [333333, 444444]; - logFrameB.fields[0].nanos = [111111, 222222]; - const responseA: DataQueryResponse = { - data: [logFrameA], - }; - const responseB: DataQueryResponse = { - data: [logFrameB], - }; - expect(combineResponses(responseA, responseB)).toEqual({ - data: [ - { - fields: [ - { - config: {}, - name: 'Time', - type: 'time', - values: [1, 2, 3, 4], - nanos: [111111, 222222, 333333, 444444], - }, - { - config: {}, - name: 'Line', - type: 'string', - values: ['line3', 'line4', 'line1', 'line2'], - }, - { - config: {}, - name: 'labels', - type: 'other', - values: [ - { - otherLabel: 'other value', - }, - { - label: 'value', - }, - { - otherLabel: 'other value', - }, - ], - }, - { - config: {}, - name: 'tsNs', - type: 'string', - values: ['1000000', '2000000', '3000000', '4000000'], - }, - { - config: {}, - name: 'id', - type: 'string', - values: ['id3', 'id4', 'id1', 'id2'], - }, - ], - length: 4, - meta: { - custom: { - frameType: 'LabeledTimeValues', - }, - stats: [ - { - displayName: 'Summary: total bytes processed', - unit: 'decbytes', - value: 33, - }, - ], - }, - refId: 'A', - }, - ], - }); - }); - - describe('combine stats', () => { - const { metricFrameA } = getMockFrames(); - const makeResponse = (stats?: QueryResultMetaStat[]): DataQueryResponse => ({ - data: [ - { - ...metricFrameA, - meta: { - ...metricFrameA.meta, - stats, - }, - }, - ], - }); - it('two values', () => { - const responseA = makeResponse([ - { displayName: 'Ingester: total reached', value: 1 }, - { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 11 }, - ]); - const responseB = makeResponse([ - { displayName: 'Ingester: total reached', value: 2 }, - { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 22 }, - ]); - - expect(combineResponses(responseA, responseB).data[0].meta.stats).toStrictEqual([ - { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 33 }, - ]); - }); - - it('one value', () => { - const responseA = makeResponse([ - { displayName: 'Ingester: total reached', value: 1 }, - { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 11 }, - ]); - const responseB = makeResponse(); - - expect(combineResponses(responseA, responseB).data[0].meta.stats).toStrictEqual([ - { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 11 }, - ]); - - expect(combineResponses(responseB, responseA).data[0].meta.stats).toStrictEqual([ - { displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 11 }, - ]); - }); - - it('no value', () => { - const responseA = makeResponse(); - const responseB = makeResponse(); - expect(combineResponses(responseA, responseB).data[0].meta.stats).toHaveLength(0); - }); - }); -}); diff --git a/public/app/plugins/datasource/loki/responseUtils.ts b/public/app/plugins/datasource/loki/responseUtils.ts index 4c535a97de458..fd18c8f6c88a5 100644 --- a/public/app/plugins/datasource/loki/responseUtils.ts +++ b/public/app/plugins/datasource/loki/responseUtils.ts @@ -1,17 +1,6 @@ -import { - DataFrame, - DataFrameType, - DataQueryResponse, - DataQueryResponseData, - Field, - FieldType, - isValidGoDuration, - Labels, - QueryResultMetaStat, - shallowCompare, -} from '@grafana/data'; +import { DataFrame, FieldType, isValidGoDuration, Labels } from '@grafana/data'; -import { isBytesString } from './languageUtils'; +import { isBytesString, processLabels } from './languageUtils'; import { isLogLineJSON, isLogLineLogfmt, isLogLinePacked } from './lineParser'; import { LabelType } from './types'; @@ -65,19 +54,26 @@ export function extractLabelKeysFromDataFrame(frame: DataFrame, type: LabelType return []; } - // if there are no label types, only return indexed labels if requested + // if there are no label types and type is LabelType.Indexed return all label keys if (!labelTypeArray?.length) { if (type === LabelType.Indexed) { - return Object.keys(labelsArray[0]); + const { keys: labelKeys } = processLabels(labelsArray); + return labelKeys; } return []; } - const labelTypes = labelTypeArray[0]; + // If we have label types, we can return only label keys that match type + let labelsSet = new Set<string>(); + for (let i = 0; i < labelsArray.length; i++) { + const labels = labelsArray[i]; + const labelsType = labelTypeArray[i]; - const allLabelKeys = Object.keys(labelsArray[0]).filter((k) => labelTypes[k] === type); + const allLabelKeys = Object.keys(labels).filter((key) => labelsType[key] === type); + labelsSet = new Set([...labelsSet, ...allLabelKeys]); + } - return allLabelKeys; + return Array.from(labelsSet); } export function extractUnwrapLabelKeysFromDataFrame(frame: DataFrame): string[] { @@ -133,143 +129,3 @@ export function extractLevelLikeLabelFromDataFrame(frame: DataFrame): string | n } return levelLikeLabel; } - -function shouldCombine(frame1: DataFrame, frame2: DataFrame): boolean { - if (frame1.refId !== frame2.refId) { - return false; - } - - const frameType1 = frame1.meta?.type; - const frameType2 = frame2.meta?.type; - - if (frameType1 !== frameType2) { - // we do not join things that have a different type - return false; - } - - // metric range query data - if (frameType1 === DataFrameType.TimeSeriesMulti) { - const field1 = frame1.fields.find((f) => f.type === FieldType.number); - const field2 = frame2.fields.find((f) => f.type === FieldType.number); - if (field1 === undefined || field2 === undefined) { - // should never happen - return false; - } - - return shallowCompare(field1.labels ?? {}, field2.labels ?? {}); - } - - // logs query data - // logs use a special attribute in the dataframe's "custom" section - // because we do not have a good "frametype" value for them yet. - const customType1 = frame1.meta?.custom?.frameType; - const customType2 = frame2.meta?.custom?.frameType; - - if (customType1 === 'LabeledTimeValues' && customType2 === 'LabeledTimeValues') { - return true; - } - - // should never reach here - return false; -} - -export function combineResponses(currentResult: DataQueryResponse | null, newResult: DataQueryResponse) { - if (!currentResult) { - return cloneQueryResponse(newResult); - } - - newResult.data.forEach((newFrame) => { - const currentFrame = currentResult.data.find((frame) => shouldCombine(frame, newFrame)); - if (!currentFrame) { - currentResult.data.push(cloneDataFrame(newFrame)); - return; - } - combineFrames(currentFrame, newFrame); - }); - - const mergedErrors = [...(currentResult.errors ?? []), ...(newResult.errors ?? [])]; - - // we make sure to have `.errors` as undefined, instead of empty-array - // when no errors. - - if (mergedErrors.length > 0) { - currentResult.errors = mergedErrors; - } - - // the `.error` attribute is obsolete now, - // but we have to maintain it, otherwise - // some grafana parts do not behave well. - // we just choose the old error, if it exists, - // otherwise the new error, if it exists. - const mergedError = currentResult.error ?? newResult.error; - if (mergedError != null) { - currentResult.error = mergedError; - } - - const mergedTraceIds = [...(currentResult.traceIds ?? []), ...(newResult.traceIds ?? [])]; - if (mergedTraceIds.length > 0) { - currentResult.traceIds = mergedTraceIds; - } - - return currentResult; -} - -function combineFrames(dest: DataFrame, source: DataFrame) { - const totalFields = dest.fields.length; - for (let i = 0; i < totalFields; i++) { - dest.fields[i].values = [].concat.apply(source.fields[i].values, dest.fields[i].values); - if (source.fields[i].nanos) { - const nanos: number[] = dest.fields[i].nanos?.slice() || []; - dest.fields[i].nanos = source.fields[i].nanos?.concat(nanos); - } - } - dest.length += source.length; - dest.meta = { - ...dest.meta, - stats: getCombinedMetadataStats(dest.meta?.stats ?? [], source.meta?.stats ?? []), - }; -} - -const TOTAL_BYTES_STAT = 'Summary: total bytes processed'; - -function getCombinedMetadataStats( - destStats: QueryResultMetaStat[], - sourceStats: QueryResultMetaStat[] -): QueryResultMetaStat[] { - // in the current approach, we only handle a single stat - const destStat = destStats.find((s) => s.displayName === TOTAL_BYTES_STAT); - const sourceStat = sourceStats.find((s) => s.displayName === TOTAL_BYTES_STAT); - - if (sourceStat != null && destStat != null) { - return [{ value: sourceStat.value + destStat.value, displayName: TOTAL_BYTES_STAT, unit: destStat.unit }]; - } - - // maybe one of them exist - const eitherStat = sourceStat ?? destStat; - if (eitherStat != null) { - return [eitherStat]; - } - - return []; -} - -/** - * Deep clones a DataQueryResponse - */ -export function cloneQueryResponse(response: DataQueryResponse): DataQueryResponse { - const newResponse = { - ...response, - data: response.data.map(cloneDataFrame), - }; - return newResponse; -} - -function cloneDataFrame(frame: DataQueryResponseData): DataQueryResponseData { - return { - ...frame, - fields: frame.fields.map((field: Field) => ({ - ...field, - values: field.values, - })), - }; -} diff --git a/public/app/plugins/datasource/loki/sortDataFrame.ts b/public/app/plugins/datasource/loki/sortDataFrame.ts index 0d05531ed85ba..13f8e036cc903 100644 --- a/public/app/plugins/datasource/loki/sortDataFrame.ts +++ b/public/app/plugins/datasource/loki/sortDataFrame.ts @@ -1,4 +1,4 @@ -import { DataFrame, Field, FieldType, SortedVector } from '@grafana/data'; +import { DataFrame, Field, FieldType } from '@grafana/data'; export enum SortDirection { Ascending, @@ -85,10 +85,12 @@ export function sortDataFrameByTime(frame: DataFrame, dir: SortDirection): DataF ...rest, fields: fields.map((field) => ({ ...field, - values: new SortedVector(field.values, index).toArray(), - nanos: field.nanos === undefined ? undefined : new SortedVector(field.nanos, index).toArray(), + values: sorted(field.values, index), + nanos: field.nanos === undefined ? undefined : sorted(field.nanos, index), })), }; +} - return frame; +function sorted<T>(vals: T[], index: number[]): T[] { + return vals.map((_, idx) => vals[index[idx]]); } diff --git a/public/app/plugins/datasource/loki/tracking.test.ts b/public/app/plugins/datasource/loki/tracking.test.ts index 26af7b38541f5..6dbd998592b1b 100644 --- a/public/app/plugins/datasource/loki/tracking.test.ts +++ b/public/app/plugins/datasource/loki/tracking.test.ts @@ -1,10 +1,7 @@ -import { getQueryOptions } from 'test/helpers/getQueryOptions'; - -import { DashboardLoadedEvent, dateTime } from '@grafana/data'; +import { DashboardLoadedEvent, DataQueryRequest, dateTime } from '@grafana/data'; +import { QueryEditorMode } from '@grafana/experimental'; import { reportInteraction } from '@grafana/runtime'; -import { QueryEditorMode } from '../prometheus/querybuilder/shared/types'; - import pluginJson from './plugin.json'; import { partitionTimeRange } from './querySplitting'; import { onDashboardLoadedHandler, trackGroupedQueries, trackQuery } from './tracking'; @@ -27,7 +24,7 @@ const range = { to: dateTime('2023-02-10T06:00:00.000Z'), }, }; -const originalRequest = getQueryOptions<LokiQuery>({ +const originalRequest = { targets: [ { expr: 'count_over_time({a="b"}[1m])', refId: 'A', ...baseTarget }, { expr: '{a="b"}', refId: 'B', maxLines: 10, ...baseTarget }, @@ -35,26 +32,23 @@ const originalRequest = getQueryOptions<LokiQuery>({ ], range, app: 'explore', -}); +} as DataQueryRequest<LokiQuery>; + const requests: LokiGroupedRequest[] = [ { request: { - ...getQueryOptions<LokiQuery>({ - targets: [{ expr: 'count_over_time({a="b"}[1m])', refId: 'A', ...baseTarget }], - range, - }), + targets: [{ expr: 'count_over_time({a="b"}[1m])', refId: 'A', ...baseTarget }], + range, app: 'explore', - }, + } as DataQueryRequest<LokiQuery>, partition: partitionTimeRange(true, range, 60000, 24 * 60 * 60 * 1000), }, { request: { - ...getQueryOptions<LokiQuery>({ - targets: [{ expr: '{a="b"}', refId: 'B', maxLines: 10, ...baseTarget }], - range, - }), + targets: [{ expr: '{a="b"}', refId: 'B', maxLines: 10, ...baseTarget }], + range, app: 'explore', - }, + } as DataQueryRequest<LokiQuery>, partition: partitionTimeRange(false, range, 60000, 24 * 60 * 60 * 1000), }, ]; @@ -84,8 +78,6 @@ test('Tracks queries', () => { legend: undefined, line_limit: undefined, obfuscated_query: 'count_over_time({Identifier=String}[1m])', - parsed_query: - 'LogQL,Expr,MetricExpr,RangeAggregationExpr,RangeOp,CountOverTime,LogRangeExpr,Selector,Matchers,Matcher,Identifier,Eq,String,Range,Duration', query_type: 'metric', query_vector_type: undefined, resolution: 1, @@ -112,8 +104,6 @@ test('Tracks predefined operations', () => { legend: undefined, line_limit: undefined, obfuscated_query: 'count_over_time({Identifier=String}[1m])', - parsed_query: - 'LogQL,Expr,MetricExpr,RangeAggregationExpr,RangeOp,CountOverTime,LogRangeExpr,Selector,Matchers,Matcher,Identifier,Eq,String,Range,Duration', query_type: 'metric', query_vector_type: undefined, resolution: 1, @@ -140,8 +130,6 @@ test('Tracks grouped queries', () => { legend: undefined, line_limit: undefined, obfuscated_query: 'count_over_time({Identifier=String}[1m])', - parsed_query: - 'LogQL,Expr,MetricExpr,RangeAggregationExpr,RangeOp,CountOverTime,LogRangeExpr,Selector,Matchers,Matcher,Identifier,Eq,String,Range,Duration', query_type: 'metric', query_vector_type: undefined, resolution: 1, @@ -168,7 +156,6 @@ test('Tracks grouped queries', () => { legend: undefined, line_limit: 10, obfuscated_query: '{Identifier=String}', - parsed_query: 'LogQL,Expr,LogExpr,Selector,Matchers,Matcher,Identifier,Eq,String', query_type: 'logs', query_vector_type: undefined, resolution: 1, diff --git a/public/app/plugins/datasource/loki/tracking.ts b/public/app/plugins/datasource/loki/tracking.ts index f2707b0d72e97..b48f636bc40fe 100644 --- a/public/app/plugins/datasource/loki/tracking.ts +++ b/public/app/plugins/datasource/loki/tracking.ts @@ -1,8 +1,6 @@ import { CoreApp, DashboardLoadedEvent, DataQueryRequest, DataQueryResponse } from '@grafana/data'; +import { QueryEditorMode } from '@grafana/experimental'; import { reportInteraction, config } from '@grafana/runtime'; -import { variableRegex } from 'app/features/variables/utils'; - -import { QueryEditorMode } from '../prometheus/querybuilder/shared/types'; import { REF_ID_STARTER_ANNOTATION, @@ -11,7 +9,8 @@ import { REF_ID_STARTER_LOG_VOLUME, } from './datasource'; import pluginJson from './plugin.json'; -import { getNormalizedLokiQuery, isLogsQuery, obfuscate, parseToNodeNamesArray } from './queryUtils'; +import { getNormalizedLokiQuery, isLogsQuery, obfuscate } from './queryUtils'; +import { variableRegex } from './querybuilder/parsingUtils'; import { LokiGroupedRequest, LokiQuery, LokiQueryType } from './types'; type LokiOnDashboardLoadedTrackingEvent = { @@ -177,7 +176,6 @@ export function trackQuery( has_error: response.error !== undefined, legend: query.legendFormat, line_limit: query.maxLines, - parsed_query: parseToNodeNamesArray(query.expr).join(','), obfuscated_query: obfuscate(query.expr), query_type: isLogsQuery(query.expr) ? 'logs' : 'metric', query_vector_type: query.queryType, diff --git a/public/app/plugins/datasource/loki/types.ts b/public/app/plugins/datasource/loki/types.ts index 40526bf3d3926..806e7e8143443 100644 --- a/public/app/plugins/datasource/loki/types.ts +++ b/public/app/plugins/datasource/loki/types.ts @@ -1,6 +1,11 @@ import { DataQuery, DataQueryRequest, DataSourceJsonData, TimeRange } from '@grafana/data'; -import { Loki as LokiQueryFromSchema, LokiQueryType, SupportingQueryType, LokiQueryDirection } from './dataquery.gen'; +import { + LokiDataQuery as LokiQueryFromSchema, + LokiQueryType, + SupportingQueryType, + LokiQueryDirection, +} from './dataquery.gen'; export { LokiQueryDirection, LokiQueryType, SupportingQueryType }; @@ -87,8 +92,7 @@ export interface ContextFilter { enabled: boolean; label: string; value: string; - fromParser: boolean; - description?: string; + nonIndexed: boolean; } export interface ParserAndLabelKeysResult { diff --git a/public/app/plugins/datasource/mixed/MixedDataSource.ts b/public/app/plugins/datasource/mixed/MixedDataSource.ts index d0296261f9956..2466cdcbda9c9 100644 --- a/public/app/plugins/datasource/mixed/MixedDataSource.ts +++ b/public/app/plugins/datasource/mixed/MixedDataSource.ts @@ -15,6 +15,8 @@ import { getDataSourceSrv, toDataQueryError } from '@grafana/runtime'; export const MIXED_DATASOURCE_NAME = '-- Mixed --'; +export const mixedRequestId = (queryIdx: number, requestId?: string) => `mixed-${queryIdx}-${requestId || ''}`; + export interface BatchedQueries { datasource: Promise<DataSourceApi>; targets: DataQuery[]; @@ -61,7 +63,7 @@ export class MixedDatasource extends DataSourceApi<DataQuery> { from(query.datasource).pipe( mergeMap((api: DataSourceApi) => { const dsRequest = cloneDeep(request); - dsRequest.requestId = `mixed-${i}-${dsRequest.requestId || ''}`; + dsRequest.requestId = mixedRequestId(i, dsRequest.requestId); dsRequest.targets = query.targets; return from(api.query(dsRequest)).pipe( @@ -70,7 +72,7 @@ export class MixedDatasource extends DataSourceApi<DataQuery> { ...response, data: response.data || [], state: LoadingState.Loading, - key: `mixed-${i}-${response.key || ''}`, + key: mixedRequestId(i, response.key), }; }), toArray(), @@ -83,7 +85,7 @@ export class MixedDatasource extends DataSourceApi<DataQuery> { data: [], state: LoadingState.Error, error: err, - key: `mixed-${i}-${dsRequest.requestId || ''}`, + key: mixedRequestId(i, dsRequest.requestId), }, ]); }) diff --git a/public/app/plugins/datasource/mssql/MSSqlQueryModel.ts b/public/app/plugins/datasource/mssql/MSSqlQueryModel.ts index 45754ed51c9c8..4d7124286799a 100644 --- a/public/app/plugins/datasource/mssql/MSSqlQueryModel.ts +++ b/public/app/plugins/datasource/mssql/MSSqlQueryModel.ts @@ -1,7 +1,6 @@ import { ScopedVars } from '@grafana/data'; import { TemplateSrv } from '@grafana/runtime'; -import { applyQueryDefaults } from 'app/features/plugins/sql/defaults'; -import { SQLQuery, SqlQueryModel } from 'app/features/plugins/sql/types'; +import { applyQueryDefaults, SQLQuery, SqlQueryModel } from '@grafana/sql'; export class MSSqlQueryModel implements SqlQueryModel { target: SQLQuery; diff --git a/public/app/plugins/datasource/mssql/configuration/ConfigurationEditor.tsx b/public/app/plugins/datasource/mssql/configuration/ConfigurationEditor.tsx index 19e1e8a42c8e7..b949fcbc4cadd 100644 --- a/public/app/plugins/datasource/mssql/configuration/ConfigurationEditor.tsx +++ b/public/app/plugins/datasource/mssql/configuration/ConfigurationEditor.tsx @@ -11,6 +11,7 @@ import { updateDatasourcePluginResetOption, } from '@grafana/data'; import { ConfigSection, ConfigSubSection, DataSourceDescription } from '@grafana/experimental'; +import { ConnectionLimits, useMigrateDatabaseFields } from '@grafana/sql'; import { Alert, FieldSet, @@ -26,8 +27,6 @@ import { } from '@grafana/ui'; import { NumberInput } from 'app/core/components/OptionsUI/NumberInput'; import { config } from 'app/core/config'; -import { ConnectionLimits } from 'app/features/plugins/sql/components/configuration/ConnectionLimits'; -import { useMigrateDatabaseFields } from 'app/features/plugins/sql/components/configuration/useMigrateDatabaseFields'; import { AzureAuthSettings } from '../azureauth/AzureAuthSettings'; import { diff --git a/public/app/plugins/datasource/mssql/datasource.test.ts b/public/app/plugins/datasource/mssql/datasource.test.ts index bff446b08c11d..8c3d8092d4367 100644 --- a/public/app/plugins/datasource/mssql/datasource.test.ts +++ b/public/app/plugins/datasource/mssql/datasource.test.ts @@ -11,8 +11,8 @@ import { createDataFrame, TimeRange, } from '@grafana/data'; +import { SQLQuery } from '@grafana/sql'; import { backendSrv } from 'app/core/services/backend_srv'; -import { SQLQuery } from 'app/features/plugins/sql/types'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { initialCustomVariableModelState } from '../../../features/variables/custom/reducer'; diff --git a/public/app/plugins/datasource/mssql/datasource.ts b/public/app/plugins/datasource/mssql/datasource.ts index 13e6781f0bfef..f936ab72a5aae 100644 --- a/public/app/plugins/datasource/mssql/datasource.ts +++ b/public/app/plugins/datasource/mssql/datasource.ts @@ -1,9 +1,7 @@ import { DataSourceInstanceSettings, ScopedVars } from '@grafana/data'; import { LanguageDefinition } from '@grafana/experimental'; import { TemplateSrv } from '@grafana/runtime'; -import { SqlDatasource } from 'app/features/plugins/sql/datasource/SqlDatasource'; -import { DB, SQLQuery, SQLSelectableValue } from 'app/features/plugins/sql/types'; -import { formatSQL } from 'app/features/plugins/sql/utils/formatSQL'; +import { DB, SQLQuery, SqlDatasource, SQLSelectableValue, formatSQL } from '@grafana/sql'; import { getSchema, showDatabases, getSchemaAndName } from './MSSqlMetaQuery'; import { MSSqlQueryModel } from './MSSqlQueryModel'; diff --git a/public/app/plugins/datasource/mssql/module.ts b/public/app/plugins/datasource/mssql/module.ts index 7af8a5a3f2fb1..230f2f89a41d8 100644 --- a/public/app/plugins/datasource/mssql/module.ts +++ b/public/app/plugins/datasource/mssql/module.ts @@ -1,6 +1,5 @@ import { DataSourcePlugin } from '@grafana/data'; -import { SqlQueryEditor } from 'app/features/plugins/sql/components/QueryEditor'; -import { SQLQuery } from 'app/features/plugins/sql/types'; +import { SQLQuery, SqlQueryEditor } from '@grafana/sql'; import { CheatSheet } from './CheatSheet'; import { ConfigurationEditor } from './configuration/ConfigurationEditor'; diff --git a/public/app/plugins/datasource/mssql/sqlCompletionProvider.ts b/public/app/plugins/datasource/mssql/sqlCompletionProvider.ts index e8fcfb4f25bf9..871c2172dfc29 100644 --- a/public/app/plugins/datasource/mssql/sqlCompletionProvider.ts +++ b/public/app/plugins/datasource/mssql/sqlCompletionProvider.ts @@ -7,7 +7,7 @@ import { TableIdentifier, TokenType, } from '@grafana/experimental'; -import { DB, SQLQuery } from 'app/features/plugins/sql/types'; +import { DB, SQLQuery } from '@grafana/sql'; interface CompletionProviderGetterArgs { getColumns: React.MutableRefObject<(t: SQLQuery) => Promise<ColumnDefinition[]>>; diff --git a/public/app/plugins/datasource/mssql/sqlUtil.ts b/public/app/plugins/datasource/mssql/sqlUtil.ts index 49c631d573354..ba7347e77aed2 100644 --- a/public/app/plugins/datasource/mssql/sqlUtil.ts +++ b/public/app/plugins/datasource/mssql/sqlUtil.ts @@ -1,7 +1,6 @@ import { isEmpty } from 'lodash'; -import { RAQBFieldTypes, SQLExpression, SQLQuery } from 'app/features/plugins/sql/types'; -import { haveColumns } from 'app/features/plugins/sql/utils/sql.utils'; +import { RAQBFieldTypes, SQLExpression, SQLQuery, haveColumns } from '@grafana/sql'; export function getIcon(type: string): string | undefined { switch (type) { diff --git a/public/app/plugins/datasource/mssql/types.ts b/public/app/plugins/datasource/mssql/types.ts index d950e4bef0948..40c1914b34bae 100644 --- a/public/app/plugins/datasource/mssql/types.ts +++ b/public/app/plugins/datasource/mssql/types.ts @@ -1,6 +1,6 @@ import { DataSourceJsonData } from '@grafana/data'; +import { SQLOptions } from '@grafana/sql'; import { HttpSettingsBaseProps } from '@grafana/ui/src/components/DataSourceSettings/types'; -import { SQLOptions } from 'app/features/plugins/sql/types'; export enum MSSQLAuthenticationType { sqlAuth = 'SQL Server Authentication', diff --git a/public/app/plugins/datasource/mysql/MySqlDatasource.ts b/public/app/plugins/datasource/mysql/MySqlDatasource.ts index 74bc814fb36cc..7a30358313cb6 100644 --- a/public/app/plugins/datasource/mysql/MySqlDatasource.ts +++ b/public/app/plugins/datasource/mysql/MySqlDatasource.ts @@ -1,8 +1,6 @@ import { DataSourceInstanceSettings, TimeRange } from '@grafana/data'; import { CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/experimental'; -import { SqlDatasource } from 'app/features/plugins/sql/datasource/SqlDatasource'; -import { DB, SQLQuery } from 'app/features/plugins/sql/types'; -import { formatSQL } from 'app/features/plugins/sql/utils/formatSQL'; +import { SqlDatasource, DB, SQLQuery, formatSQL } from '@grafana/sql'; import { mapFieldsToTypes } from './fields'; import { buildColumnQuery, buildTableQuery, showDatabases } from './mySqlMetaQuery'; diff --git a/public/app/plugins/datasource/mysql/configuration/ConfigurationEditor.tsx b/public/app/plugins/datasource/mysql/configuration/ConfigurationEditor.tsx index 055dc88e27677..c0feed4629bb4 100644 --- a/public/app/plugins/datasource/mysql/configuration/ConfigurationEditor.tsx +++ b/public/app/plugins/datasource/mysql/configuration/ConfigurationEditor.tsx @@ -9,6 +9,7 @@ import { } from '@grafana/data'; import { ConfigSection, ConfigSubSection, DataSourceDescription, Stack } from '@grafana/experimental'; import { config } from '@grafana/runtime'; +import { ConnectionLimits, Divider, TLSSecretsConfig, useMigrateDatabaseFields } from '@grafana/sql'; import { Collapse, Field, @@ -20,10 +21,6 @@ import { Switch, Tooltip, } from '@grafana/ui'; -import { ConnectionLimits } from 'app/features/plugins/sql/components/configuration/ConnectionLimits'; -import { Divider } from 'app/features/plugins/sql/components/configuration/Divider'; -import { TLSSecretsConfig } from 'app/features/plugins/sql/components/configuration/TLSSecretsConfig'; -import { useMigrateDatabaseFields } from 'app/features/plugins/sql/components/configuration/useMigrateDatabaseFields'; import { MySQLOptions } from '../types'; @@ -177,11 +174,12 @@ export const ConfigurationEditor = (props: DataSourcePluginOptionsEditorProps<My <Tooltip content={ <span> - Specify the time zone used in the database session, e.g. <code>Europe/Berlin</code> or - <code>+02:00</code>. This is necessary, if the timezone of the database (or the host of the - database) is set to something other than UTC. The value is set in the session with - <code>SET time_zone='...'</code>. If you leave this field empty, the timezone is not - updated. You can find more information in the MySQL documentation. + Specify the timezone used in the database session, such as <code>Europe/Berlin</code> or + <code>+02:00</code>. Required if the timezone of the database (or the host of the database) is + set to something other than UTC. Set this to <code>+00:00</code> so Grafana can handle times + properly. Set the value used in the session with <code>SET time_zone='...'</code>. If + you leave this field empty, the timezone will not be updated. You can find more information in + the MySQL documentation. </span> } > diff --git a/public/app/plugins/datasource/mysql/fields.ts b/public/app/plugins/datasource/mysql/fields.ts index 96eb122c35e86..4154da5873e92 100644 --- a/public/app/plugins/datasource/mysql/fields.ts +++ b/public/app/plugins/datasource/mysql/fields.ts @@ -1,4 +1,4 @@ -import { RAQBFieldTypes, SQLSelectableValue } from 'app/features/plugins/sql/types'; +import { RAQBFieldTypes, SQLSelectableValue } from '@grafana/sql'; export function mapFieldsToTypes(columns: SQLSelectableValue[]) { const fields: SQLSelectableValue[] = []; diff --git a/public/app/plugins/datasource/mysql/module.ts b/public/app/plugins/datasource/mysql/module.ts index c47743252d63e..c2c6616c49249 100644 --- a/public/app/plugins/datasource/mysql/module.ts +++ b/public/app/plugins/datasource/mysql/module.ts @@ -1,6 +1,5 @@ import { DataSourcePlugin } from '@grafana/data'; -import { SqlQueryEditor } from 'app/features/plugins/sql/components/QueryEditor'; -import { SQLQuery } from 'app/features/plugins/sql/types'; +import { SQLQuery, SqlQueryEditor } from '@grafana/sql'; import { CheatSheet } from './CheatSheet'; import { MySqlDatasource } from './MySqlDatasource'; diff --git a/public/app/plugins/datasource/mysql/specs/datasource.test.ts b/public/app/plugins/datasource/mysql/specs/datasource.test.ts index aaee5eabf0b15..1b9ced8b09853 100644 --- a/public/app/plugins/datasource/mysql/specs/datasource.test.ts +++ b/public/app/plugins/datasource/mysql/specs/datasource.test.ts @@ -10,8 +10,7 @@ import { createDataFrame, } from '@grafana/data'; import { FetchResponse } from '@grafana/runtime'; -import { SQLQuery } from 'app/features/plugins/sql/types'; -import { makeVariable } from 'app/features/plugins/sql/utils/testHelpers'; +import { SQLQuery, makeVariable } from '@grafana/sql'; import { MySqlDatasource } from '../MySqlDatasource'; import { MySQLOptions } from '../types'; diff --git a/public/app/plugins/datasource/mysql/sqlUtil.ts b/public/app/plugins/datasource/mysql/sqlUtil.ts index 9116be17a9322..c4e1587fddb24 100644 --- a/public/app/plugins/datasource/mysql/sqlUtil.ts +++ b/public/app/plugins/datasource/mysql/sqlUtil.ts @@ -1,7 +1,6 @@ import { isEmpty } from 'lodash'; -import { SQLQuery } from 'app/features/plugins/sql/types'; -import { createSelectClause, haveColumns } from 'app/features/plugins/sql/utils/sql.utils'; +import { SQLQuery, createSelectClause, haveColumns } from '@grafana/sql'; export function toRawSql({ sql, dataset, table }: SQLQuery): string { let rawQuery = ''; diff --git a/public/app/plugins/datasource/mysql/types.ts b/public/app/plugins/datasource/mysql/types.ts index e142f35891a78..cd47ae73ff236 100644 --- a/public/app/plugins/datasource/mysql/types.ts +++ b/public/app/plugins/datasource/mysql/types.ts @@ -1,4 +1,4 @@ -import { SQLOptions, SQLQuery } from 'app/features/plugins/sql/types'; +import { SQLOptions, SQLQuery } from '@grafana/sql'; export interface MySQLOptions extends SQLOptions { allowCleartextPasswords?: boolean; diff --git a/public/app/plugins/datasource/parca/QueryEditor/QueryEditor.test.tsx b/public/app/plugins/datasource/parca/QueryEditor/QueryEditor.test.tsx index 3f4ea1620ab8a..d9bcac8a2f1e8 100644 --- a/public/app/plugins/datasource/parca/QueryEditor/QueryEditor.test.tsx +++ b/public/app/plugins/datasource/parca/QueryEditor/QueryEditor.test.tsx @@ -1,8 +1,10 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { of } from 'rxjs'; import { CoreApp, DataSourcePluginMeta, PluginType } from '@grafana/data'; +import { BackendSrv, getBackendSrv, setBackendSrv } from '@grafana/runtime'; import { ParcaDataSource } from '../datasource'; import { ProfileTypeMessage } from '../types'; @@ -10,13 +12,25 @@ import { ProfileTypeMessage } from '../types'; import { Props, QueryEditor } from './QueryEditor'; describe('QueryEditor', () => { + let origBackendSrv: BackendSrv; + const fetchMock = jest.fn().mockReturnValue(of({ data: [] })); + + beforeEach(() => { + origBackendSrv = getBackendSrv(); + }); + + afterEach(() => { + setBackendSrv(origBackendSrv); + }); + it('should render without error', async () => { setup(); - expect(screen.findByText('process_cpu:cpu')).toBeDefined(); + expect(await screen.findByText(/process_cpu - cpu/)).toBeDefined(); }); it('should render options', async () => { + setBackendSrv({ ...origBackendSrv, fetch: fetchMock }); setup(); await openOptions(); expect(screen.getByText(/Metric/)).toBeDefined(); @@ -25,6 +39,7 @@ describe('QueryEditor', () => { }); it('should render correct options outside of explore', async () => { + setBackendSrv({ ...origBackendSrv, fetch: fetchMock }); setup({ props: { app: CoreApp.Dashboard } }); await openOptions(); expect(screen.getByText(/Metric/)).toBeDefined(); diff --git a/public/app/plugins/datasource/parca/QueryEditor/QueryEditor.tsx b/public/app/plugins/datasource/parca/QueryEditor/QueryEditor.tsx index 561eb26f7473a..d470a5d5e0de4 100644 --- a/public/app/plugins/datasource/parca/QueryEditor/QueryEditor.tsx +++ b/public/app/plugins/datasource/parca/QueryEditor/QueryEditor.tsx @@ -5,7 +5,7 @@ import { useMount } from 'react-use'; import { CoreApp, QueryEditorProps } from '@grafana/data'; import { ButtonCascader, CascaderOption } from '@grafana/ui'; -import { defaultParca, defaultParcaQueryType, Parca } from '../dataquery.gen'; +import { defaultParcaDataQuery, defaultParcaQueryType, ParcaDataQuery as Parca } from '../dataquery.gen'; import { ParcaDataSource } from '../datasource'; import { ParcaDataSourceOptions, ProfileTypeMessage, Query } from '../types'; @@ -17,7 +17,7 @@ import { QueryOptions } from './QueryOptions'; export type Props = QueryEditorProps<ParcaDataSource, Query, ParcaDataSourceOptions>; export const defaultQuery: Partial<Parca> = { - ...defaultParca, + ...defaultParcaDataQuery, queryType: defaultParcaQueryType, }; diff --git a/public/app/plugins/datasource/parca/dataquery.gen.ts b/public/app/plugins/datasource/parca/dataquery.gen.ts index 7542d11ac4482..e39bfa7d76672 100644 --- a/public/app/plugins/datasource/parca/dataquery.gen.ts +++ b/public/app/plugins/datasource/parca/dataquery.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -14,7 +14,7 @@ export type ParcaQueryType = ('metrics' | 'profile' | 'both'); export const defaultParcaQueryType: ParcaQueryType = 'both'; -export interface Parca extends common.DataQuery { +export interface ParcaDataQuery extends common.DataQuery { /** * Specifies the query label selectors. */ @@ -25,6 +25,6 @@ export interface Parca extends common.DataQuery { profileTypeId: string; } -export const defaultParca: Partial<Parca> = { +export const defaultParcaDataQuery: Partial<ParcaDataQuery> = { labelSelector: '{}', }; diff --git a/public/app/plugins/datasource/parca/datasource.test.ts b/public/app/plugins/datasource/parca/datasource.test.ts new file mode 100644 index 0000000000000..1a0a216273fd5 --- /dev/null +++ b/public/app/plugins/datasource/parca/datasource.test.ts @@ -0,0 +1,71 @@ +import { DataSourceInstanceSettings, PluginMetaInfo, PluginType } from '@grafana/data'; +import { getTemplateSrv } from '@grafana/runtime'; + +import { defaultParcaQueryType } from './dataquery.gen'; +import { ParcaDataSource } from './datasource'; +import { Query } from './types'; + +jest.mock('@grafana/runtime', () => { + const actual = jest.requireActual('@grafana/runtime'); + return { + ...actual, + getTemplateSrv: () => { + return { + replace: (query: string): string => { + return query.replace(/\$var/g, 'interpolated'); + }, + }; + }, + }; +}); + +describe('Parca data source', () => { + let ds: ParcaDataSource; + beforeEach(() => { + ds = new ParcaDataSource(defaultSettings); + }); + + describe('applyTemplateVariables', () => { + const templateSrv = getTemplateSrv(); + + it('should not update labelSelector if there are no template variables', () => { + ds = new ParcaDataSource(defaultSettings, templateSrv); + const query = ds.applyTemplateVariables(defaultQuery({ labelSelector: `no var` }), {}); + expect(query).toMatchObject({ labelSelector: `no var` }); + }); + + it('should update labelSelector if there are template variables', () => { + ds = new ParcaDataSource(defaultSettings, templateSrv); + const query = ds.applyTemplateVariables(defaultQuery({ labelSelector: `{$var="$var"}` }), {}); + expect(query).toMatchObject({ labelSelector: `{interpolated="interpolated"}` }); + }); + }); +}); + +const defaultSettings: DataSourceInstanceSettings = { + id: 0, + uid: 'parca', + type: 'profiling', + name: 'parca', + access: 'proxy', + meta: { + id: 'parca', + name: 'parca', + type: PluginType.datasource, + info: {} as PluginMetaInfo, + module: '', + baseUrl: '', + }, + jsonData: {}, + readOnly: false, +}; + +const defaultQuery = (query: Partial<Query>): Query => { + return { + refId: 'x', + labelSelector: '', + profileTypeId: '', + queryType: defaultParcaQueryType, + ...query, + }; +}; diff --git a/public/app/plugins/datasource/parca/datasource.ts b/public/app/plugins/datasource/parca/datasource.ts index 6418e15d6d3ca..128e7c91d9f89 100644 --- a/public/app/plugins/datasource/parca/datasource.ts +++ b/public/app/plugins/datasource/parca/datasource.ts @@ -1,12 +1,15 @@ import { Observable, of } from 'rxjs'; -import { DataQueryRequest, DataQueryResponse, DataSourceInstanceSettings } from '@grafana/data'; -import { DataSourceWithBackend } from '@grafana/runtime'; +import { DataQueryRequest, DataQueryResponse, DataSourceInstanceSettings, ScopedVars } from '@grafana/data'; +import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; import { ParcaDataSourceOptions, Query, ProfileTypeMessage } from './types'; export class ParcaDataSource extends DataSourceWithBackend<Query, ParcaDataSourceOptions> { - constructor(instanceSettings: DataSourceInstanceSettings<ParcaDataSourceOptions>) { + constructor( + instanceSettings: DataSourceInstanceSettings<ParcaDataSourceOptions>, + private readonly templateSrv: TemplateSrv = getTemplateSrv() + ) { super(instanceSettings); } @@ -19,6 +22,13 @@ export class ParcaDataSource extends DataSourceWithBackend<Query, ParcaDataSourc return super.query(request); } + applyTemplateVariables(query: Query, scopedVars: ScopedVars): Query { + return { + ...query, + labelSelector: this.templateSrv.replace(query.labelSelector ?? '', scopedVars), + }; + } + async getProfileTypes(): Promise<ProfileTypeMessage[]> { return await super.getResource('profileTypes'); } diff --git a/public/app/plugins/datasource/parca/package.json b/public/app/plugins/datasource/parca/package.json index a7b500113b076..b382f3b374363 100644 --- a/public/app/plugins/datasource/parca/package.json +++ b/public/app/plugins/datasource/parca/package.json @@ -2,28 +2,28 @@ "name": "@grafana-plugins/parca", "description": "Continuous profiling for analysis of CPU and memory usage, down to the line number and throughout time. Saving infrastructure cost, improving performance, and increasing reliability.", "private": true, - "version": "10.3.0-pre", + "version": "11.0.0-pre", "dependencies": { "@emotion/css": "11.11.2", - "@grafana/data": "10.3.0-pre", - "@grafana/runtime": "10.3.0-pre", - "@grafana/schema": "10.3.0-pre", - "@grafana/ui": "10.3.0-pre", + "@grafana/data": "11.0.0-pre", + "@grafana/runtime": "11.0.0-pre", + "@grafana/schema": "11.0.0-pre", + "@grafana/ui": "11.0.0-pre", "lodash": "4.17.21", "monaco-editor": "0.34.0", "react": "18.2.0", - "react-use": "17.4.0", + "react-use": "17.5.0", "rxjs": "7.8.1", - "tslib": "2.6.0" + "tslib": "2.6.2" }, "devDependencies": { - "@grafana/plugin-configs": "10.3.0-pre", - "@testing-library/react": "14.0.0", - "@testing-library/user-event": "14.5.1", - "@types/lodash": "4.14.195", - "@types/react": "18.2.15", - "ts-node": "10.9.1", - "webpack": "5.89.0" + "@grafana/plugin-configs": "11.0.0-pre", + "@testing-library/react": "14.2.1", + "@testing-library/user-event": "14.5.2", + "@types/lodash": "4.17.0", + "@types/react": "18.2.66", + "ts-node": "10.9.2", + "webpack": "5.90.3" }, "peerDependencies": { "@grafana/runtime": "*" @@ -33,5 +33,5 @@ "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", "dev": "webpack -w -c ./webpack.config.ts --env development" }, - "packageManager": "yarn@3.6.0" + "packageManager": "yarn@4.1.0" } diff --git a/public/app/plugins/datasource/parca/types.ts b/public/app/plugins/datasource/parca/types.ts index 4c039b5fdbb9e..c149c9ef5f38d 100644 --- a/public/app/plugins/datasource/parca/types.ts +++ b/public/app/plugins/datasource/parca/types.ts @@ -1,6 +1,6 @@ import { DataSourceJsonData } from '@grafana/data'; -import { Parca as ParcaBase, ParcaQueryType } from './dataquery.gen'; +import { ParcaDataQuery as ParcaBase, ParcaQueryType } from './dataquery.gen'; export interface Query extends ParcaBase { queryType: ParcaQueryType; diff --git a/public/app/plugins/datasource/prometheus/components/AnnotationQueryEditor.tsx b/public/app/plugins/datasource/prometheus/components/AnnotationQueryEditor.tsx index ad2e1e9011474..0cbaf5978cdd9 100644 --- a/public/app/plugins/datasource/prometheus/components/AnnotationQueryEditor.tsx +++ b/public/app/plugins/datasource/prometheus/components/AnnotationQueryEditor.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { AnnotationQuery } from '@grafana/data'; -import { EditorField, EditorRow, EditorRows, EditorSwitch, Space } from '@grafana/experimental'; -import { AutoSizeInput, Input } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; +import { EditorField, EditorRow, EditorRows, EditorSwitch } from '@grafana/experimental'; +import { AutoSizeInput, Input, Space } from '@grafana/ui'; import { PromQueryCodeEditor } from '../querybuilder/components/PromQueryCodeEditor'; import { PromQuery } from '../types'; @@ -56,6 +57,7 @@ export function AnnotationQueryEditor(props: Props) { }); }} defaultValue={query.interval} + id={selectors.components.DataSource.Prometheus.annotations.minStep} /> </EditorField> </EditorRow> @@ -78,6 +80,7 @@ export function AnnotationQueryEditor(props: Props) { titleFormat: event.currentTarget.value, }); }} + data-testid={selectors.components.DataSource.Prometheus.annotations.title} /> </EditorField> <EditorField label="Tags"> @@ -91,6 +94,7 @@ export function AnnotationQueryEditor(props: Props) { tagKeys: event.currentTarget.value, }); }} + data-testid={selectors.components.DataSource.Prometheus.annotations.tags} /> </EditorField> <EditorField @@ -109,6 +113,7 @@ export function AnnotationQueryEditor(props: Props) { textFormat: event.currentTarget.value, }); }} + data-testid={selectors.components.DataSource.Prometheus.annotations.text} /> </EditorField> <EditorField @@ -125,6 +130,7 @@ export function AnnotationQueryEditor(props: Props) { useValueForTime: event.currentTarget.value, }); }} + data-testid={selectors.components.DataSource.Prometheus.annotations.seriesValueAsTimestamp} /> </EditorField> </EditorRow> diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx index 1986901cca36c..496322a5a7d4a 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx @@ -2,6 +2,7 @@ import { cx } from '@emotion/css'; import React, { ReactNode } from 'react'; import { isDataFrame, QueryEditorProps, QueryHint, TimeRange, toLegacyResponseData } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { reportInteraction } from '@grafana/runtime'; import { Icon, Themeable2, withTheme2, clearButtonStyles } from '@grafana/ui'; import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider'; @@ -230,6 +231,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF onClick={this.onClickChooserButton} disabled={buttonDisabled} type="button" + data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.openButton} > {chooserText} <Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} /> diff --git a/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx index 97051f5b8e0e1..b5cc16744a8be 100644 --- a/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx +++ b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx @@ -114,6 +114,12 @@ describe('PrometheusMetricsBrowser', () => { } return []; }, + // This must always call the series endpoint + // until we refactor all of the metrics browser + // to never use the series endpoint. + // The metrics browser expects both label names and label values. + // The labels endpoint with match does not supply label values + // and so using it breaks the metrics browser. fetchSeriesLabels: (selector: string) => { switch (selector) { case '{label1="value1-1"}': diff --git a/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx index 2ef3b2694ee1b..b877843cddb52 100644 --- a/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx +++ b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx @@ -3,6 +3,7 @@ import React, { ChangeEvent } from 'react'; import { FixedSizeList } from 'react-window'; import { GrafanaTheme2, TimeRange } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { Button, HorizontalGroup, @@ -493,9 +494,14 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserPro onChange={this.onChangeMetricSearch} aria-label="Filter expression for metric" value={metricSearchTerm} + data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.selectMetric} /> </div> - <div role="list" className={styles.valueListWrapper}> + <div + role="list" + className={styles.valueListWrapper} + data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.metricList} + > <FixedSizeList height={Math.min(450, metricCount * LIST_ITEM_SIZE)} itemCount={metricCount} @@ -537,6 +543,9 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserPro onChange={this.onChangeLabelSearch} aria-label="Filter expression for label" value={labelSearchTerm} + data-testid={ + selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelNamesFilter + } /> </div> {/* Using fixed height here to prevent jumpy layout */} @@ -564,6 +573,9 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserPro onChange={this.onChangeValueSearch} aria-label="Filter expression for label values" value={valueSearchTerm} + data-testid={ + selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelValuesFilter + } /> </div> <div className={styles.valueListArea} ref={this.valueListsRef}> @@ -625,10 +637,16 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserPro </div> {validationStatus && <div className={styles.validationStatus}>{validationStatus}</div>} <HorizontalGroup> - <Button aria-label="Use selector for query button" disabled={empty} onClick={this.onClickRunQuery}> + <Button + data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useQuery} + aria-label="Use selector for query button" + disabled={empty} + onClick={this.onClickRunQuery} + > Use query </Button> <Button + data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useAsRateQuery} aria-label="Use selector as metrics button" variant="secondary" disabled={empty} @@ -637,6 +655,7 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserPro Use as rate query </Button> <Button + data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.validateSelector} aria-label="Validate submit button" variant="secondary" disabled={empty} @@ -644,7 +663,12 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserPro > Validate selector </Button> - <Button aria-label="Selector clear button" variant="secondary" onClick={this.onClickClear}> + <Button + data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.clear} + aria-label="Selector clear button" + variant="secondary" + onClick={this.onClickClear} + > Clear </Button> <div className={cx(styles.status, (status || error) && styles.statusShowing)}> diff --git a/public/app/plugins/datasource/prometheus/components/VariableQueryEditor.test.tsx b/public/app/plugins/datasource/prometheus/components/VariableQueryEditor.test.tsx index 683c4be718549..1804f3efff13a 100644 --- a/public/app/plugins/datasource/prometheus/components/VariableQueryEditor.test.tsx +++ b/public/app/plugins/datasource/prometheus/components/VariableQueryEditor.test.tsx @@ -142,7 +142,7 @@ describe('PromVariableQueryEditor', () => { metrics: [], metricsMetadata: {}, getLabelValues: jest.fn().mockImplementation(() => ['that']), - fetchSeriesLabelsMatch: jest.fn().mockImplementation(() => Promise.resolve({ those: 'those' })), + fetchLabelsWithMatch: jest.fn().mockImplementation(() => Promise.resolve({ those: 'those' })), } as Partial<PrometheusLanguageProvider> as PrometheusLanguageProvider, getInitHints: () => [], getDebounceTimeInMilliseconds: jest.fn(), @@ -236,11 +236,10 @@ describe('PromVariableQueryEditor', () => { await selectOptionInTest(screen.getByLabelText('Query type'), 'Metrics'); const metricInput = screen.getByLabelText('Metric selector'); - await userEvent.type(metricInput, 'a').then((prom) => { - const queryType = screen.getByLabelText('Query type'); - // click elsewhere to trigger the onBlur - return userEvent.click(queryType); - }); + await userEvent.type(metricInput, 'a'); + const queryType = screen.getByLabelText('Query type'); + // click elsewhere to trigger the onBlur + await userEvent.click(queryType); await waitFor(() => expect(onChange).toHaveBeenCalledWith({ diff --git a/public/app/plugins/datasource/prometheus/components/VariableQueryEditor.tsx b/public/app/plugins/datasource/prometheus/components/VariableQueryEditor.tsx index 169380f039eef..fce9bf1e1dfe4 100644 --- a/public/app/plugins/datasource/prometheus/components/VariableQueryEditor.tsx +++ b/public/app/plugins/datasource/prometheus/components/VariableQueryEditor.tsx @@ -1,6 +1,7 @@ import React, { FormEvent, useCallback, useEffect, useState } from 'react'; import { QueryEditorProps, SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { InlineField, InlineFieldRow, Input, Select, TextArea } from '@grafana/ui'; import { PrometheusDatasource } from '../datasource'; @@ -105,19 +106,11 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }: const labelToConsider = [{ label: '__name__', op: '=', value: metric }]; const expr = promQueryModeller.renderLabels(labelToConsider); - if (datasource.hasLabelsMatchAPISupport()) { - datasource.languageProvider.fetchSeriesLabelsMatch(expr).then((labelsIndex: Record<string, string[]>) => { - const labelNames = Object.keys(labelsIndex); - const names = labelNames.map((value) => ({ label: value, value: value })); - setLabelOptions([...variables, ...names]); - }); - } else { - datasource.languageProvider.fetchSeriesLabels(expr).then((labelsIndex: Record<string, string[]>) => { - const labelNames = Object.keys(labelsIndex); - const names = labelNames.map((value) => ({ label: value, value: value })); - setLabelOptions([...variables, ...names]); - }); - } + datasource.languageProvider.fetchLabelsWithMatch(expr).then((labelsIndex: Record<string, string[]>) => { + const labelNames = Object.keys(labelsIndex); + const names = labelNames.map((value) => ({ label: value, value: value })); + setLabelOptions([...variables, ...names]); + }); } }, [datasource, qryType, metric]); @@ -243,6 +236,7 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }: value={qryType} options={variableOptions} width={25} + data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.queryType} /> </InlineField> </InlineFieldRow> @@ -269,6 +263,7 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }: width={25} allowCustomValue isClearable={true} + data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.labelValues.labelSelect} /> </InlineField> </InlineFieldRow> @@ -303,6 +298,7 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }: setLabelNamesMatch(e.currentTarget.value); }} width={25} + data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.labelnames.metricRegex} /> </InlineField> </InlineFieldRow> @@ -329,6 +325,7 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }: onMetricChange(e.currentTarget.value); }} width={25} + data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.metricNames.metricRegex} /> </InlineField> </InlineFieldRow> @@ -358,6 +355,7 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }: } }} cols={100} + data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.varQueryResult} /> </InlineField> </InlineFieldRow> @@ -389,6 +387,7 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }: } }} width={100} + data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.seriesQuery} /> </InlineField> </InlineFieldRow> @@ -418,6 +417,7 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }: } }} width={100} + data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.classicQuery} /> </InlineField> </InlineFieldRow> diff --git a/public/app/plugins/datasource/prometheus/configuration/AlertingSettingsOverhaul.tsx b/public/app/plugins/datasource/prometheus/configuration/AlertingSettingsOverhaul.tsx index dc8243d916371..92e054899132d 100644 --- a/public/app/plugins/datasource/prometheus/configuration/AlertingSettingsOverhaul.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/AlertingSettingsOverhaul.tsx @@ -2,6 +2,7 @@ import { cx } from '@emotion/css'; import React from 'react'; import { DataSourceJsonData, DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { ConfigSubSection } from '@grafana/experimental'; import { config } from '@grafana/runtime'; import { InlineField, Switch, useTheme2 } from '@grafana/ui'; @@ -53,6 +54,7 @@ export function AlertingSettingsOverhaul<T extends AlertingConfig>({ jsonData: { ...options.jsonData, manageAlerts: event!.currentTarget.checked }, }) } + id={selectors.components.DataSource.Prometheus.configPage.manageAlerts} /> </InlineField> </div> diff --git a/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.tsx b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.tsx index 44e462f9d6a4b..7140dc34b6246 100644 --- a/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.tsx @@ -95,8 +95,8 @@ export const AzureCredentialsForm = (props: Props) => { const defaultAuthType = managedIdentityEnabled ? 'msi' : workloadIdentityEnabled - ? 'workloadidentity' - : 'clientsecret'; + ? 'workloadidentity' + : 'clientsecret'; const updated: AzureCredentials = { ...credentials, authType: selected.value || defaultAuthType, diff --git a/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx b/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx index 2eb17508cfcea..7d11f4d9bad7d 100644 --- a/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx @@ -8,6 +8,7 @@ import { SelectableValue, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { ConfigSubSection } from '@grafana/experimental'; import { getBackendSrv } from '@grafana/runtime/src'; import { InlineField, Input, Select, Switch, useTheme2 } from '@grafana/ui'; @@ -202,6 +203,7 @@ export const PromSettings = (props: Props) => { timeInterval: e.currentTarget.value, }) } + data-testid={selectors.components.DataSource.Prometheus.configPage.scrapeInterval} /> {validateInput(validDuration.timeInterval, DURATION_REGEX, durationError)} </> @@ -231,6 +233,7 @@ export const PromSettings = (props: Props) => { queryTimeout: e.currentTarget.value, }) } + data-testid={selectors.components.DataSource.Prometheus.configPage.queryTimeout} /> {validateInput(validDuration.queryTimeout, DURATION_REGEX, durationError)} </> @@ -259,6 +262,7 @@ export const PromSettings = (props: Props) => { } onChange={onChangeHandler('defaultEditor', options, onOptionsChange)} width={40} + data-testid={selectors.components.DataSource.Prometheus.configPage.defaultEditor} /> </InlineField> </div> @@ -280,6 +284,7 @@ export const PromSettings = (props: Props) => { <Switch value={options.jsonData.disableMetricsLookup ?? false} onChange={onUpdateDatasourceJsonDataOptionChecked(props, 'disableMetricsLookup')} + id={selectors.components.DataSource.Prometheus.configPage.disableMetricLookup} /> </InlineField> </div> @@ -338,6 +343,7 @@ export const PromSettings = (props: Props) => { } )} width={40} + data-testid={selectors.components.DataSource.Prometheus.configPage.prometheusType} /> </InlineField> </div> @@ -365,6 +371,7 @@ export const PromSettings = (props: Props) => { )} onChange={onChangeHandler('prometheusVersion', options, onOptionsChange)} width={40} + data-testid={selectors.components.DataSource.Prometheus.configPage.prometheusVersion} /> </InlineField> </div> @@ -392,6 +399,7 @@ export const PromSettings = (props: Props) => { value={ cacheValueOptions.find((o) => o.value === options.jsonData.cacheLevel) ?? PrometheusCacheLevel.Low } + data-testid={selectors.components.DataSource.Prometheus.configPage.cacheLevel} /> </InlineField> </div> @@ -416,6 +424,7 @@ export const PromSettings = (props: Props) => { <Switch value={options.jsonData.incrementalQuerying ?? false} onChange={onUpdateDatasourceJsonDataOptionChecked(props, 'incrementalQuerying')} + id={selectors.components.DataSource.Prometheus.configPage.incrementalQuerying} /> </InlineField> </div> @@ -447,6 +456,7 @@ export const PromSettings = (props: Props) => { value={options.jsonData.incrementalQueryOverlapWindow ?? defaultPrometheusQueryOverlapWindow} onChange={onChangeHandler('incrementalQueryOverlapWindow', options, onOptionsChange)} spellCheck={false} + data-testid={selectors.components.DataSource.Prometheus.configPage.queryOverlapWindow} /> {validateInput(validDuration.incrementalQueryOverlapWindow, MULTIPLE_DURATION_REGEX, durationError)} </> @@ -467,6 +477,7 @@ export const PromSettings = (props: Props) => { <Switch value={options.jsonData.disableRecordingRules ?? false} onChange={onUpdateDatasourceJsonDataOptionChecked(props, 'disableRecordingRules')} + id={selectors.components.DataSource.Prometheus.configPage.disableRecordingRules} /> </InlineField> </div> @@ -496,6 +507,7 @@ export const PromSettings = (props: Props) => { onChange={onChangeHandler('customQueryParameters', options, onOptionsChange)} spellCheck={false} placeholder="Example: max_source_resolution=5m&timeout=10" + data-testid={selectors.components.DataSource.Prometheus.configPage.customQueryParameters} /> </InlineField> </div> @@ -522,6 +534,7 @@ export const PromSettings = (props: Props) => { options={httpOptions} value={httpOptions.find((o) => o.value === options.jsonData.httpMethod)} onChange={onChangeHandler('httpMethod', options, onOptionsChange)} + data-testid={selectors.components.DataSource.Prometheus.configPage.httpMethod} /> </InlineField> </div> diff --git a/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json b/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json index b39453b231a6e..a121de7fe5a8e 100644 --- a/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json +++ b/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json @@ -543,10 +543,10 @@ "pluginVersion": "8.1.0-pre", "targets": [ { - "expr": "sum by (statuscode) (irate(http_request_total{job='grafana'}[5m]))", + "expr": "sum by (status_code) (irate(grafana_http_request_duration_seconds_count[5m]))", "format": "time_series", "intervalFactor": 3, - "legendFormat": "{{statuscode}}", + "legendFormat": "{{status_code}}", "refId": "B", "step": 15, "target": "dev.grafana.cb-office.alerting.active_alerts" @@ -735,7 +735,7 @@ ], "targets": [ { - "expr": "sort(topk(8, sum by (handler) (http_request_total{job=\"grafana\"})))", + "expr": "sort(topk(8, sum by (handler) (grafana_http_request_duration_seconds_count)))", "format": "time_series", "instant": true, "intervalFactor": 10, @@ -1183,5 +1183,5 @@ "timezone": "", "title": "Grafana metrics", "uid": "isFoa0z7k", - "version": 2 + "version": 3 } diff --git a/public/app/plugins/datasource/prometheus/dataquery.cue b/public/app/plugins/datasource/prometheus/dataquery.cue deleted file mode 100644 index 934c72263f219..0000000000000 --- a/public/app/plugins/datasource/prometheus/dataquery.cue +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2023 Grafana Labs -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package grafanaplugin - -import ( - common "github.com/grafana/grafana/packages/grafana-schema/src/common" -) - -composableKinds: DataQuery: { - maturity: "experimental" - - lineage: { - schemas: [{ - version: [0, 0] - schema: { - common.DataQuery - - // The actual expression/query that will be evaluated by Prometheus - expr: string - // Returns only the latest value that Prometheus has scraped for the requested time series - instant?: bool - // Returns a Range vector, comprised of a set of time series containing a range of data points over time for each time series - range?: bool - // Execute an additional query to identify interesting raw samples relevant for the given expr - exemplar?: bool - // Specifies which editor is being used to prepare the query. It can be "code" or "builder" - editorMode?: #QueryEditorMode - // Query format to determine how to display data points in panel. It can be "time_series", "table", "heatmap" - format?: #PromQueryFormat - // Series name override or template. Ex. {{hostname}} will be replaced with label value for hostname - legendFormat?: string - // @deprecated Used to specify how many times to divide max data points by. We use max data points under query options - // See https://github.com/grafana/grafana/issues/48081 - intervalFactor?: number - - #QueryEditorMode: "code" | "builder" @cuetsy(kind="enum") - #PromQueryFormat: "time_series" | "table" | "heatmap" @cuetsy(kind="type") - } - }] - lenses: [] - } -} diff --git a/public/app/plugins/datasource/prometheus/dataquery.ts b/public/app/plugins/datasource/prometheus/dataquery.ts new file mode 100644 index 0000000000000..8609fc789140f --- /dev/null +++ b/public/app/plugins/datasource/prometheus/dataquery.ts @@ -0,0 +1,47 @@ +import * as common from '@grafana/schema'; + +export enum QueryEditorMode { + Builder = 'builder', + Code = 'code', +} + +export type PromQueryFormat = 'time_series' | 'table' | 'heatmap'; + +export interface Prometheus extends common.DataQuery { + /** + * Specifies which editor is being used to prepare the query. It can be "code" or "builder" + */ + editorMode?: QueryEditorMode; + /** + * Execute an additional query to identify interesting raw samples relevant for the given expr + */ + exemplar?: boolean; + /** + * The actual expression/query that will be evaluated by Prometheus + */ + expr: string; + /** + * Query format to determine how to display data points in panel. It can be "time_series", "table", "heatmap" + */ + format?: PromQueryFormat; + /** + * Returns only the latest value that Prometheus has scraped for the requested time series + */ + instant?: boolean; + /** + * @deprecated Used to specify how many times to divide max data points by. We use max data points under query options + * See https://github.com/grafana/grafana/issues/48081 + */ + intervalFactor?: number; + /** + * Series name override or template. Ex. {{hostname}} will be replaced with label value for hostname + */ + legendFormat?: string; + /** + * Returns a Range vector, comprised of a set of time series containing a range of data points over time for each time series + */ + range?: boolean; + scope?: { + matchers: string; + }; +} diff --git a/public/app/plugins/datasource/prometheus/datasource.test.ts b/public/app/plugins/datasource/prometheus/datasource.test.ts index 32a1e4d968b39..7d7d1f613e748 100644 --- a/public/app/plugins/datasource/prometheus/datasource.test.ts +++ b/public/app/plugins/datasource/prometheus/datasource.test.ts @@ -5,9 +5,11 @@ import { AnnotationEvent, AnnotationQueryRequest, CoreApp, + CustomVariableModel, DataQueryRequest, DataSourceInstanceSettings, dateTime, + LoadingState, rangeUtil, TimeRange, VariableHide, @@ -463,7 +465,7 @@ describe('PrometheusDatasource', () => { }); describe('When interpolating variables', () => { - let customVariable: any; + let customVariable: CustomVariableModel; beforeEach(() => { customVariable = { id: '', @@ -476,11 +478,14 @@ describe('PrometheusDatasource', () => { current: {}, name: '', type: 'custom', - label: null, + error: null, + rootStateKey: '', + state: LoadingState.Done, + description: '', + label: undefined, hide: VariableHide.dontHide, skipUrlSync: false, index: -1, - initLock: null, }; }); @@ -492,7 +497,7 @@ describe('PrometheusDatasource', () => { describe('and value is a number', () => { it('should return a number', () => { - expect(ds.interpolateQueryExpr(1000 as any, customVariable)).toEqual(1000); + expect(ds.interpolateQueryExpr(1000 as unknown as string, customVariable)).toEqual(1000); }); }); @@ -863,7 +868,7 @@ describe('PrometheusDatasource2', () => { }); describe('region annotations for sectors', () => { - const options: any = { + const options = { annotation: { expr: 'ALERTS{alertstate="firing"}', tagKeys: 'job', @@ -874,7 +879,7 @@ describe('PrometheusDatasource2', () => { from: time({ seconds: 63 }), to: time({ seconds: 900 }), }, - }; + } as unknown as AnnotationQueryRequest; async function runAnnotationQuery(data: number[][]) { let response = createAnnotationResponse(); diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index e79405d4ce469..5a0264a45712a 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -1,4 +1,4 @@ -import { cloneDeep, defaults } from 'lodash'; +import { defaults } from 'lodash'; import { lastValueFrom, Observable, throwError } from 'rxjs'; import { map, tap } from 'rxjs/operators'; import semver from 'semver/preload'; @@ -668,7 +668,7 @@ export class PrometheusDatasource // this is used to get label keys, a.k.a label names // it is used in metric_find_query.ts // and in Tempo here grafana/public/app/plugins/datasource/tempo/QueryEditor/ServiceGraphSection.tsx - async getTagKeys(options: DataSourceGetTagKeysOptions): Promise<MetricFindValue[]> { + async getTagKeys(options: DataSourceGetTagKeysOptions<PromQuery>): Promise<MetricFindValue[]> { if (!options || options.filters.length === 0) { await this.languageProvider.fetchLabels(options.timeRange); return this.languageProvider.getLabelKeys().map((k) => ({ value: k, text: k })); @@ -681,13 +681,7 @@ export class PrometheusDatasource })); const expr = promQueryModeller.renderLabels(labelFilters); - let labelsIndex: Record<string, string[]>; - - if (this.hasLabelsMatchAPISupport()) { - labelsIndex = await this.languageProvider.fetchSeriesLabelsMatch(expr); - } else { - labelsIndex = await this.languageProvider.fetchSeriesLabels(expr); - } + let labelsIndex: Record<string, string[]> = await this.languageProvider.fetchLabelsWithMatch(expr); // filter out already used labels return Object.keys(labelsIndex) @@ -706,10 +700,12 @@ export class PrometheusDatasource const expr = promQueryModeller.renderLabels(labelFilters); if (this.hasLabelsMatchAPISupport()) { - return (await this.languageProvider.fetchSeriesValuesWithMatch(options.key, expr)).map((v) => ({ - value: v, - text: v, - })); + return (await this.languageProvider.fetchSeriesValuesWithMatch(options.key, expr, options.timeRange)).map( + (v) => ({ + value: v, + text: v, + }) + ); } const params = this.getTimeRangeParams(options.timeRange ?? getDefaultTimeRange()); @@ -881,7 +877,7 @@ export class PrometheusDatasource // Used when running queries through backend applyTemplateVariables(target: PromQuery, scopedVars: ScopedVars, filters?: AdHocVariableFilter[]) { - const variables = cloneDeep(scopedVars); + const variables = { ...scopedVars }; // We want to interpolate these variables on backend. // The pre-calculated values are replaced withe the variable strings. diff --git a/public/app/plugins/datasource/prometheus/language_provider.mock.ts b/public/app/plugins/datasource/prometheus/language_provider.mock.ts index ed2944ba942d1..731a1977ca97b 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.mock.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.mock.ts @@ -12,6 +12,7 @@ export class EmptyLanguageProviderMock { fetchSeries = jest.fn().mockReturnValue([]); fetchSeriesLabels = jest.fn().mockReturnValue([]); fetchSeriesLabelsMatch = jest.fn().mockReturnValue([]); + fetchLabelsWithMatch = jest.fn().mockReturnValue([]); fetchLabels = jest.fn(); loadMetricsMetadata = jest.fn(); } diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts index b498f2bad6b1b..1f0a23f0a1d50 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.ts @@ -239,11 +239,16 @@ export default class PromQlLanguageProvider extends LanguageProvider { * Fetches all values for a label, with optional match[] * @param name * @param match + * @param timeRange */ - fetchSeriesValuesWithMatch = async (name: string, match?: string): Promise<string[]> => { + fetchSeriesValuesWithMatch = async ( + name: string, + match?: string, + timeRange: TimeRange = this.timeRange + ): Promise<string[]> => { const interpolatedName = name ? this.datasource.interpolateString(name) : null; const interpolatedMatch = match ? this.datasource.interpolateString(match) : null; - const range = this.datasource.getAdjustedInterval(this.timeRange); + const range = this.datasource.getAdjustedInterval(timeRange); const urlParams = { ...range, ...(interpolatedMatch && { 'match[]': interpolatedMatch }), @@ -284,6 +289,20 @@ export default class PromQlLanguageProvider extends LanguageProvider { return possibleLabelNames.filter((l) => !usedLabelNames.has(l)); }; + /** + * Fetch labels using the best endpoint that datasource supports. + * This is cached by its args but also by the global timeRange currently selected as they can change over requested time. + * @param name + * @param withName + */ + fetchLabelsWithMatch = async (name: string, withName?: boolean): Promise<Record<string, string[]>> => { + if (this.datasource.hasLabelsMatchAPISupport()) { + return this.fetchSeriesLabelsMatch(name, withName); + } else { + return this.fetchSeriesLabels(name, withName); + } + }; + /** * Fetch labels for a series using /series endpoint. This is cached by its args but also by the global timeRange currently selected as * they can change over requested time. diff --git a/public/app/plugins/datasource/prometheus/language_utils.test.ts b/public/app/plugins/datasource/prometheus/language_utils.test.ts index 60151aa884049..65c9239107540 100644 --- a/public/app/plugins/datasource/prometheus/language_utils.test.ts +++ b/public/app/plugins/datasource/prometheus/language_utils.test.ts @@ -176,13 +176,39 @@ describe('expandRecordingRules()', () => { metricA: 'rate(fooA[])', metricB: 'rate(fooB[])', }) - ).toBe('rate(fooA{label1="value1"}[])/ rate(fooB{label2="value2"}[])'); + ).toBe('rate(fooA{label1="value1"}[]) / rate(fooB{label2="value2"}[])'); expect( expandRecordingRules('metricA{label1="value1",label2="value2"} / metricB{label3="value3"}', { metricA: 'rate(fooA[])', metricB: 'rate(fooB[])', }) - ).toBe('rate(fooA{label1="value1", label2="value2"}[])/ rate(fooB{label3="value3"}[])'); + ).toBe('rate(fooA{label1="value1", label2="value2"}[]) / rate(fooB{label3="value3"}[])'); + }); + + it('expands the query even it is wrapped with parentheses', () => { + expect( + expandRecordingRules('sum (metric{label1="value1"}) by (env)', { metric: 'foo{labelInside="valueInside"}' }) + ).toBe('sum (foo{labelInside="valueInside", label1="value1"}) by (env)'); + }); + + it('expands the query with regex match', () => { + expect( + expandRecordingRules('sum (metric{label1=~"/value1/(sa|sb)"}) by (env)', { + metric: 'foo{labelInside="valueInside"}', + }) + ).toBe('sum (foo{labelInside="valueInside", label1=~"/value1/(sa|sb)"}) by (env)'); + }); + + it('ins:metric:per{pid="val-42", comp="api"}', () => { + const query = `aaa:111{pid="val-42", comp="api"} + bbb:222{pid="val-42"}`; + const mapping = { + 'aaa:111': + '(max without (mp) (targetMetric{device=~"/dev/(sda1|sdb)"}) / max without (mp) (targetMetric2{device=~"/dev/(sda1|sdb)"}))', + 'bbb:222': '(targetMetric2{device=~"/dev/(sda1|sdb)"})', + }; + const expected = `(max without (mp) (targetMetric{device=~"/dev/(sda1|sdb)", pid="val-42", comp="api"}) / max without (mp) (targetMetric2{device=~"/dev/(sda1|sdb)", pid="val-42", comp="api"})) + (targetMetric2{device=~"/dev/(sda1|sdb)", pid="val-42"})`; + const result = expandRecordingRules(query, mapping); + expect(result).toBe(expected); }); }); diff --git a/public/app/plugins/datasource/prometheus/language_utils.ts b/public/app/plugins/datasource/prometheus/language_utils.ts index cf1ee42c217f4..ce5e43e7e58e4 100644 --- a/public/app/plugins/datasource/prometheus/language_utils.ts +++ b/public/app/plugins/datasource/prometheus/language_utils.ts @@ -64,7 +64,15 @@ export function processLabels(labels: Array<{ [key: string]: string }>, withName // const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/; export const selectorRegexp = /\{[^}]*?(\}|$)/; -export const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g; + +// This will capture 4 groups. Example label filter => {instance="10.4.11.4:9003"} +// 1. label: instance +// 2. operator: = +// 3. value: "10.4.11.4:9003" +// 4. comma: if there is a comma it will give , +// 5. space: if there is a space after comma it will give the whole space +// comma and space is useful for addLabelsToExpression function +export const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")(,)?(\s*)?/g; export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any[]; selector: string } { if (!query.match(selectorRegexp)) { @@ -131,20 +139,88 @@ export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any } export function expandRecordingRules(query: string, mapping: { [name: string]: string }): string { - const ruleNames = Object.keys(mapping); - const rulesRegex = new RegExp(`(\\s|^)(${ruleNames.join('|')})(\\s|$|\\(|\\[|\\{)`, 'ig'); - const expandedQuery = query.replace(rulesRegex, (match, pre, name, post) => `${pre}${mapping[name]}${post}`); + const getRuleRegex = (ruleName: string) => new RegExp(`(\\s|\\(|^)(${ruleName})(\\s|$|\\(|\\[|\\{)`, 'ig'); + + // For each mapping key we iterate over the query and split them in parts. + // recording:rule{label=~"/label/value"} * some:other:rule{other_label="value"} + // We want to keep parts in here like this: + // recording:rule + // {label=~"/label/value"} * + // some:other:rule + // {other_label="value"} + const tmpSplitParts = Object.keys(mapping).reduce<string[]>( + (prev, curr) => { + let parts: string[] = []; + let tmpParts: string[] = []; + let removeIdx: number[] = []; + + // we iterate over prev because it might be like this after first loop + // recording:rule and {label=~"/label/value"} * some:other:rule{other_label="value"} + // so we need to split the second part too + prev.filter(Boolean).forEach((p, i) => { + const doesMatch = p.match(getRuleRegex(curr)); + if (doesMatch) { + parts = p.split(curr); + if (parts.length === 2) { + // this is the case when we have such result for this query + // max (metric{label="value"}) + // "max(", "{label="value"}" + removeIdx.push(i); + tmpParts.push(...[parts[0], curr, parts[1]].filter(Boolean)); + } else if (parts.length > 2) { + // this is the case when we have such query + // metric + metric + // when we split it we have such data + // "", " + ", "" + removeIdx.push(i); + parts = parts.map((p) => (p === '' ? curr : p)); + tmpParts.push(...parts); + } + } + }); + + // if we have idx to remove that means we split the value in that index. + // No need to keep it. Have the new split values instead. + removeIdx.forEach((ri) => (prev[ri] = '')); + prev = prev.filter(Boolean); + prev.push(...tmpParts); + + return prev; + }, + [query] + ); + + // we have the separate parts. we need to replace the metric and apply the labels if there is any + let labelFound = false; + const trulyExpandedQuery = tmpSplitParts.map((tsp, i) => { + // if we know this loop tsp is a label, not the metric we want to expand + if (labelFound) { + labelFound = false; + return ''; + } - // Split query into array, so if query uses operators, we can correctly add labels to each individual part. - const queryArray = expandedQuery.split(/(\+|\-|\*|\/|\%|\^)/); + // check if the mapping is there + if (mapping[tsp]) { + const recordingRule = mapping[tsp]; + // it is a recording rule. if the following is a label then apply it + if (i + 1 !== tmpSplitParts.length && tmpSplitParts[i + 1].match(labelRegexp)) { + // the next value in the loop is label. Let's apply labels to the metric + labelFound = true; + const labels = tmpSplitParts[i + 1]; + const invalidLabelsRegex = /(\)\{|\}\{|\]\{)/; + return addLabelsToExpression(recordingRule + labels, invalidLabelsRegex); + } else { + // it is not a recording rule and might be a binary operation in between two recording rules + // So no need to do anything. just return it. + return recordingRule; + } + } - // Regex that matches occurrences of ){ or }{ or ]{ which is a sign of incorrecly added labels. - const invalidLabelsRegex = /(\)\{|\}\{|\]\{)/; - const correctlyExpandedQueryArray = queryArray.map((query) => { - return addLabelsToExpression(query, invalidLabelsRegex); + return tsp; }); - return correctlyExpandedQueryArray.join(''); + // Remove empty strings and merge them + return trulyExpandedQuery.filter(Boolean).join(''); } function addLabelsToExpression(expr: string, invalidLabelsRegexp: RegExp) { @@ -159,9 +235,15 @@ function addLabelsToExpression(expr: string, invalidLabelsRegexp: RegExp) { const exprAfterRegexMatch = expr.slice(indexOfRegexMatch + 1); // Create arrayOfLabelObjects with label objects that have key, operator and value. - const arrayOfLabelObjects: Array<{ key: string; operator: string; value: string }> = []; - exprAfterRegexMatch.replace(labelRegexp, (label, key, operator, value) => { - arrayOfLabelObjects.push({ key, operator, value }); + const arrayOfLabelObjects: Array<{ + key: string; + operator: string; + value: string; + comma?: string; + space?: string; + }> = []; + exprAfterRegexMatch.replace(labelRegexp, (label, key, operator, value, comma, space) => { + arrayOfLabelObjects.push({ key, operator, value, comma, space }); return ''; }); @@ -174,7 +256,19 @@ function addLabelsToExpression(expr: string, invalidLabelsRegexp: RegExp) { result = addLabelToQuery(result, obj.key, value, obj.operator); }); - return result; + // reconstruct the labels + let existingLabel = arrayOfLabelObjects.reduce((prev, curr) => { + prev += `${curr.key}${curr.operator}${curr.value}${curr.comma ?? ''}${curr.space ?? ''}`; + return prev; + }, ''); + + // Check if there is anything besides labels + // Useful for this kind of metrics sum (recording_rule_metric{label1="value1"}) by (env) + // if we don't check this part, ) by (env) part will be lost + existingLabel = '{' + existingLabel + '}'; + const potentialLeftOver = exprAfterRegexMatch.replace(existingLabel, ''); + + return result + potentialLeftOver; } /** diff --git a/public/app/plugins/datasource/prometheus/metric_find_query.test.ts b/public/app/plugins/datasource/prometheus/metric_find_query.test.ts index 395704da8f6a9..3d6b8b5cb8483 100644 --- a/public/app/plugins/datasource/prometheus/metric_find_query.test.ts +++ b/public/app/plugins/datasource/prometheus/metric_find_query.test.ts @@ -58,7 +58,15 @@ describe('PrometheusMetricFindQuery', () => { ); }); - const setupMetricFindQuery = (data: any, datasource?: PrometheusDatasource) => { + const setupMetricFindQuery = ( + data: { + query: string; + response: { + data: unknown; + }; + }, + datasource?: PrometheusDatasource + ) => { fetchMock.mockImplementation(() => of({ status: 'success', data: data.response } as unknown as FetchResponse)); return new PrometheusMetricFindQuery(datasource ?? legacyPrometheusDatasource, data.query); }; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilterItem.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilterItem.tsx index a39bbe042a893..1a4bf53bc1c20 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilterItem.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilterItem.tsx @@ -190,8 +190,6 @@ export function LabelFilterItem({ const operators = [ { label: '=', value: '=', isMultiValue: false }, { label: '!=', value: '!=', isMultiValue: false }, - { label: '<', value: '<', isMultiValue: false }, - { label: '>', value: '>', isMultiValue: false }, { label: '=~', value: '=~', isMultiValue: true }, { label: '!~', value: '!~', isMultiValue: true }, ]; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/LabelParamEditor.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/LabelParamEditor.tsx index 795f3d0274f01..6cf4e9a9b74ca 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/LabelParamEditor.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/LabelParamEditor.tsx @@ -51,7 +51,7 @@ async function loadGroupByLabels(query: PromVisualQuery, datasource: DataSourceA } const expr = promQueryModeller.renderLabels(labels); - const result = await datasource.languageProvider.fetchSeriesLabels(expr); + const result = await datasource.languageProvider.fetchLabelsWithMatch(expr); return Object.keys(result).map((x) => ({ label: x, diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx index 8e9067f71f843..76ace10896684 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx @@ -4,6 +4,7 @@ import React, { RefCallback, useCallback, useState } from 'react'; import Highlighter from 'react-highlight-words'; import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { EditorField, EditorFieldGroup } from '@grafana/experimental'; import { config } from '@grafana/runtime'; import { @@ -188,6 +189,7 @@ export function MetricSelect({ {...props.innerProps} ref={props.innerRef} className={`${styles.customOptionWidth} metric-encyclopedia-open`} + aria-label="Select option" onKeyDown={(e) => { // if there is no metric and the m.e. is enabled, open the modal if (e.code === 'Enter') { @@ -259,6 +261,7 @@ export function MetricSelect({ const asyncSelect = () => { return ( <AsyncSelect + data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.metricSelect} isClearable={variableEditor ? true : false} inputId="prometheus-metric-select" className={styles.select} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricsLabelsSection.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricsLabelsSection.tsx index 49d2bcd821f5e..b27af15c16830 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricsLabelsSection.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricsLabelsSection.tsx @@ -68,12 +68,7 @@ export function MetricsLabelsSection({ labelsToConsider.push({ label: '__name__', op: '=', value: query.metric }); const expr = promQueryModeller.renderLabels(labelsToConsider); - let labelsIndex: Record<string, string[]>; - if (datasource.hasLabelsMatchAPISupport()) { - labelsIndex = await datasource.languageProvider.fetchSeriesLabelsMatch(expr); - } else { - labelsIndex = await datasource.languageProvider.fetchSeriesLabels(expr); - } + let labelsIndex: Record<string, string[]> = await datasource.languageProvider.fetchLabelsWithMatch(expr); // filter out already used labels return Object.keys(labelsIndex) diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx index 8b55a9efaac63..54f44c46f1369 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx @@ -11,7 +11,7 @@ import { QueryHint, TimeRange, } from '@grafana/data'; -import { TemplateSrv } from '@grafana/runtime'; +import { config, TemplateSrv } from '@grafana/runtime'; import { PrometheusDatasource } from '../../datasource'; import PromQlLanguageProvider from '../../language_provider'; @@ -59,6 +59,10 @@ const bugQuery: PromVisualQuery = { ], }; +afterEach(() => { + jest.restoreAllMocks(); +}); + describe('PromQueryBuilder', () => { it('shows empty just with metric selected', async () => { setup(); @@ -104,11 +108,33 @@ describe('PromQueryBuilder', () => { await waitFor(() => expect(datasource.getVariables).toBeCalled()); }); + it('checks if the LLM plugin is enabled when the `prometheusPromQAIL` feature is enabled', async () => { + jest.replaceProperty(config, 'featureToggles', { + prometheusPromQAIL: true, + }); + const mockIsLLMPluginEnabled = jest.fn(); + mockIsLLMPluginEnabled.mockResolvedValue(true); + jest.spyOn(require('./promQail/state/helpers'), 'isLLMPluginEnabled').mockImplementation(mockIsLLMPluginEnabled); + setup(); + await waitFor(() => expect(mockIsLLMPluginEnabled).toHaveBeenCalledTimes(1)); + }); + + it('does not check if the LLM plugin is enabled when the `prometheusPromQAIL` feature is disabled', async () => { + jest.replaceProperty(config, 'featureToggles', { + prometheusPromQAIL: false, + }); + const mockIsLLMPluginEnabled = jest.fn(); + mockIsLLMPluginEnabled.mockResolvedValue(true); + jest.spyOn(require('./promQail/state/helpers'), 'isLLMPluginEnabled').mockImplementation(mockIsLLMPluginEnabled); + setup(); + await waitFor(() => expect(mockIsLLMPluginEnabled).toHaveBeenCalledTimes(0)); + }); + // <LegacyPrometheus> it('tries to load labels when metric selected', async () => { const { languageProvider } = setup(); await openLabelNameSelect(); - await waitFor(() => expect(languageProvider.fetchSeriesLabels).toBeCalledWith('{__name__="random_metric"}')); + await waitFor(() => expect(languageProvider.fetchLabelsWithMatch).toBeCalledWith('{__name__="random_metric"}')); }); it('tries to load variables in label field', async () => { @@ -128,7 +154,9 @@ describe('PromQueryBuilder', () => { }); await openLabelNameSelect(1); await waitFor(() => - expect(languageProvider.fetchSeriesLabels).toBeCalledWith('{label_name="label_value", __name__="random_metric"}') + expect(languageProvider.fetchLabelsWithMatch).toBeCalledWith( + '{label_name="label_value", __name__="random_metric"}' + ) ); }); //</LegacyPrometheus> @@ -275,7 +303,7 @@ describe('PromQueryBuilder', () => { jsonData: { prometheusVersion: '2.38.1', prometheusType: PromApplication.Prometheus }, }); await openLabelNameSelect(); - await waitFor(() => expect(languageProvider.fetchSeriesLabelsMatch).toBeCalledWith('{__name__="random_metric"}')); + await waitFor(() => expect(languageProvider.fetchLabelsWithMatch).toBeCalledWith('{__name__="random_metric"}')); }); it('tries to load variables in label field modern prom', async () => { @@ -301,7 +329,7 @@ describe('PromQueryBuilder', () => { ); await openLabelNameSelect(1); await waitFor(() => - expect(languageProvider.fetchSeriesLabelsMatch).toBeCalledWith( + expect(languageProvider.fetchLabelsWithMatch).toBeCalledWith( '{label_name="label_value", __name__="random_metric"}' ) ); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx index 00d0f830398e1..cb67f375e7755 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import React, { useEffect, useState } from 'react'; import { DataSourceApi, PanelData } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { EditorRow } from '@grafana/experimental'; import { config } from '@grafana/runtime'; import { Drawer } from '@grafana/ui'; @@ -35,15 +36,12 @@ export interface Props { showExplain: boolean; } -// initial commit for hackathon-2023-08-promqail -// AI/ML + Prometheus -const prometheusPromQAIL = config.featureToggles.prometheusPromQAIL; - export const PromQueryBuilder = React.memo<Props>((props) => { const { datasource, query, onChange, onRunQuery, data, showExplain } = props; const [highlightedOp, setHighlightedOp] = useState<QueryBuilderOperation | undefined>(); const [showDrawer, setShowDrawer] = useState<boolean>(false); const [llmAppEnabled, updateLlmAppEnabled] = useState<boolean>(false); + const { prometheusPromQAIL } = config.featureToggles; // AI/ML + Prometheus const lang = { grammar: promqlGrammar, name: 'promql' }; @@ -54,8 +52,11 @@ export const PromQueryBuilder = React.memo<Props>((props) => { const check = await isLLMPluginEnabled(); updateLlmAppEnabled(check); } - checkLlms(); - }, []); + + if (prometheusPromQAIL) { + checkLlms(); + } + }, [prometheusPromQAIL]); return ( <> @@ -111,14 +112,16 @@ export const PromQueryBuilder = React.memo<Props>((props) => { <QueryAssistantButton llmAppEnabled={llmAppEnabled} metric={query.metric} setShowDrawer={setShowDrawer} /> </div> )} - <QueryBuilderHints<PromVisualQuery> - datasource={datasource} - query={query} - onChange={onChange} - data={data} - queryModeller={promQueryModeller} - buildVisualQueryFromString={buildVisualQueryFromString} - /> + <div data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.hints}> + <QueryBuilderHints<PromVisualQuery> + datasource={datasource} + query={query} + onChange={onChange} + data={data} + queryModeller={promQueryModeller} + buildVisualQueryFromString={buildVisualQueryFromString} + /> + </div> </OperationsEditorRow> {showExplain && ( <OperationListExplained<PromVisualQuery> diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx index 4ac51127e9747..c6fd8395c89a6 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx @@ -1,11 +1,12 @@ import React, { SyntheticEvent } from 'react'; import { CoreApp, SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { EditorField, EditorRow, EditorSwitch } from '@grafana/experimental'; import { AutoSizeInput, RadioButtonGroup, Select } from '@grafana/ui'; import { getQueryTypeChangeHandler, getQueryTypeOptions } from '../../components/PromExploreExtraField'; -import { PromQueryFormat } from '../../dataquery.gen'; +import { PromQueryFormat } from '../../dataquery'; import { PromQuery } from '../../types'; import { QueryOptionGroup } from '../shared/QueryOptionGroup'; @@ -61,56 +62,69 @@ export const PromQueryBuilderOptions = React.memo<Props>(({ query, app, onChange return ( <EditorRow> - <QueryOptionGroup - title="Options" - collapsedInfo={getCollapsedInfo(query, formatOption.label!, queryTypeLabel, app)} - > - <PromQueryLegendEditor - legendFormat={query.legendFormat} - onChange={(legendFormat) => onChange({ ...query, legendFormat })} - onRunQuery={onRunQuery} - /> - <EditorField - label="Min step" - tooltip={ - <> - An additional lower limit for the step parameter of the Prometheus query and for the{' '} - <code>$__interval</code> and <code>$__rate_interval</code> variables. - </> - } + <div data-testid={selectors.components.DataSource.Prometheus.queryEditor.options}> + <QueryOptionGroup + title="Options" + collapsedInfo={getCollapsedInfo(query, formatOption.label!, queryTypeLabel, app)} > - <AutoSizeInput - type="text" - aria-label="Set lower limit for the step parameter" - placeholder={'auto'} - minWidth={10} - onCommitChange={onChangeStep} - defaultValue={query.interval} + <PromQueryLegendEditor + legendFormat={query.legendFormat} + onChange={(legendFormat) => onChange({ ...query, legendFormat })} + onRunQuery={onRunQuery} /> - </EditorField> - <EditorField label="Format"> - <Select value={formatOption} allowCustomValue onChange={onChangeFormat} options={FORMAT_OPTIONS} /> - </EditorField> - <EditorField label="Type"> - <RadioButtonGroup options={queryTypeOptions} value={queryTypeValue} onChange={onQueryTypeChange} /> - </EditorField> - {shouldShowExemplarSwitch(query, app) && ( - <EditorField label="Exemplars"> - <EditorSwitch value={query.exemplar || false} onChange={onExemplarChange} /> + <EditorField + label="Min step" + tooltip={ + <> + An additional lower limit for the step parameter of the Prometheus query and for the{' '} + <code>$__interval</code> and <code>$__rate_interval</code> variables. + </> + } + > + <AutoSizeInput + id={selectors.components.DataSource.Prometheus.queryEditor.step} + type="text" + aria-label="Set lower limit for the step parameter" + placeholder={'auto'} + minWidth={10} + onCommitChange={onChangeStep} + defaultValue={query.interval} + /> </EditorField> - )} - {query.intervalFactor && query.intervalFactor > 1 && ( - <EditorField label="Resolution"> + <EditorField label="Format"> <Select - aria-label="Select resolution" - isSearchable={false} - options={INTERVAL_FACTOR_OPTIONS} - onChange={onIntervalFactorChange} - value={INTERVAL_FACTOR_OPTIONS.find((option) => option.value === query.intervalFactor)} + data-testid={selectors.components.DataSource.Prometheus.queryEditor.format} + value={formatOption} + allowCustomValue + onChange={onChangeFormat} + options={FORMAT_OPTIONS} /> </EditorField> - )} - </QueryOptionGroup> + <EditorField label="Type" data-testid={selectors.components.DataSource.Prometheus.queryEditor.type}> + <RadioButtonGroup options={queryTypeOptions} value={queryTypeValue} onChange={onQueryTypeChange} /> + </EditorField> + {shouldShowExemplarSwitch(query, app) && ( + <EditorField label="Exemplars"> + <EditorSwitch + value={query.exemplar || false} + onChange={onExemplarChange} + id={selectors.components.DataSource.Prometheus.queryEditor.exemplars} + /> + </EditorField> + )} + {query.intervalFactor && query.intervalFactor > 1 && ( + <EditorField label="Resolution"> + <Select + aria-label="Select resolution" + isSearchable={false} + options={INTERVAL_FACTOR_OPTIONS} + onChange={onIntervalFactorChange} + value={INTERVAL_FACTOR_OPTIONS.find((option) => option.value === query.intervalFactor)} + /> + </EditorField> + )} + </QueryOptionGroup> + </div> </EditorRow> ); }); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx index f9bfe24bf7d0b..8f7987f8d9d76 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { useStyles2 } from '@grafana/ui'; import PromQueryField from '../../components/PromQueryField'; @@ -18,7 +19,10 @@ export function PromQueryCodeEditor(props: Props) { const styles = useStyles2(getStyles); return ( - <div className={styles.wrapper}> + <div + data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.queryField} + className={styles.wrapper} + > <PromQueryField datasource={datasource} query={query} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx index 4bf483a7f6e6a..f85090e0cadec 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx @@ -3,18 +3,18 @@ import React, { SyntheticEvent, useCallback, useEffect, useState } from 'react'; import { CoreApp, LoadingState, SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { EditorHeader, EditorRows, FlexItem, Space } from '@grafana/experimental'; +import { EditorHeader, EditorRows, FlexItem } from '@grafana/experimental'; import { reportInteraction } from '@grafana/runtime'; -import { Button, ConfirmModal } from '@grafana/ui'; +import { Button, ConfirmModal, Space } from '@grafana/ui'; import { PromQueryEditorProps } from '../../components/types'; -import { PromQueryFormat } from '../../dataquery.gen'; +import { PromQueryFormat } from '../../dataquery'; import { PromQuery } from '../../types'; import { QueryPatternsModal } from '../QueryPatternsModal'; +import { promQueryEditorExplainKey, useFlag } from '../hooks/useFlag'; import { buildVisualQueryFromString } from '../parsing'; import { QueryEditorModeToggle } from '../shared/QueryEditorModeToggle'; import { QueryHeaderSwitch } from '../shared/QueryHeaderSwitch'; -import { promQueryEditorExplainKey, useFlag } from '../shared/hooks/useFlag'; import { QueryEditorMode } from '../shared/types'; import { changeEditorMode, getQueryWithDefaults } from '../state'; @@ -123,7 +123,9 @@ export const PromQueryEditorSelector = React.memo<Props>((props) => { > Kick start your query </Button> - <QueryHeaderSwitch label="Explain" value={explain} onChange={onShowExplainChange} /> + <div data-testid={selectors.components.DataSource.Prometheus.queryEditor.explain}> + <QueryHeaderSwitch label="Explain" value={explain} onChange={onShowExplainChange} /> + </div> <FlexItem grow={1} /> {app !== CoreApp.Explore && app !== CoreApp.Correlations && ( <Button @@ -136,7 +138,9 @@ export const PromQueryEditorSelector = React.memo<Props>((props) => { Run queries </Button> )} - <QueryEditorModeToggle mode={editorMode} onChange={onEditorModeChange} /> + <div data-testid={selectors.components.DataSource.Prometheus.queryEditor.editorToggle}> + <QueryEditorModeToggle mode={editorMode} onChange={onEditorModeChange} /> + </div> </EditorHeader> <Space v={0.5} /> <EditorRows> diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryLegendEditor.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryLegendEditor.tsx index bb1503b0d9d0a..971eac3d52c4a 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryLegendEditor.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryLegendEditor.tsx @@ -1,6 +1,7 @@ import React, { useRef } from 'react'; import { SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { EditorField } from '@grafana/experimental'; import { Select, AutoSizeInput } from '@grafana/ui'; @@ -64,6 +65,7 @@ export const PromQueryLegendEditor = React.memo<Props>(({ legendFormat, onChange <EditorField label="Legend" tooltip="Series name override or template. Ex. {{hostname}} will be replaced with label value for hostname." + data-testid={selectors.components.DataSource.Prometheus.queryEditor.legend} > <> {mode === LegendFormatMode.Custom && ( diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx index dd10667c0dce2..c44d2cc06c00f 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx @@ -3,6 +3,7 @@ import debounce from 'debounce-promise'; import React, { useCallback, useEffect, useMemo, useReducer } from 'react'; import { SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { Input, Modal, @@ -204,7 +205,10 @@ export const MetricsModal = (props: MetricsModalProps) => { className={styles.modal} > <FeedbackLink feedbackUrl="https://forms.gle/DEMAJHoAMpe3e54CA" /> - <div className={styles.inputWrapper}> + <div + className={styles.inputWrapper} + data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.metricsExplorer} + > <div className={cx(styles.inputItem, styles.inputItemFirst)}> <Input autoFocus={true} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/PromQail.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/PromQail.test.tsx index 57f6efa22368b..f8d2a6b633e9e 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/PromQail.test.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/PromQail.test.tsx @@ -38,7 +38,7 @@ describe('PromQail', () => { it('shows selected metric and asks for a prompt', async () => { setup(defaultQuery); - clickSecurityButton(); + await clickSecurityButton(); await waitFor(() => { expect(screen.getByText('random_metric')).toBeInTheDocument(); @@ -49,7 +49,7 @@ describe('PromQail', () => { it('displays a prompt when the user knows what they want to query', async () => { setup(defaultQuery); - clickSecurityButton(); + await clickSecurityButton(); await waitFor(() => { expect(screen.getByText('random_metric')).toBeInTheDocument(); @@ -58,7 +58,7 @@ describe('PromQail', () => { const aiPrompt = screen.getByTestId(testIds.clickForAi); - userEvent.click(aiPrompt); + await userEvent.click(aiPrompt); await waitFor(() => { expect(screen.getByText('What kind of data do you want to see with your metric?')).toBeInTheDocument(); @@ -68,7 +68,7 @@ describe('PromQail', () => { it('does not display a prompt when choosing historical', async () => { setup(defaultQuery); - clickSecurityButton(); + await clickSecurityButton(); await waitFor(() => { expect(screen.getByText('random_metric')).toBeInTheDocument(); @@ -77,7 +77,7 @@ describe('PromQail', () => { const historicalPrompt = screen.getByTestId(testIds.clickForHistorical); - userEvent.click(historicalPrompt); + await userEvent.click(historicalPrompt); await waitFor(() => { expect(screen.queryByText('What kind of data do you want to see with your metric?')).toBeNull(); @@ -141,8 +141,8 @@ function setup(query: PromVisualQuery) { return container; } -function clickSecurityButton() { +async function clickSecurityButton() { const securityInfoButton = screen.getByTestId(testIds.securityInfoButton); - userEvent.click(securityInfoButton); + await userEvent.click(securityInfoButton); } diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/PromQail.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/PromQail.tsx index fc249e1b2b25c..14209f9cf2578 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/PromQail.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/PromQail.tsx @@ -54,12 +54,7 @@ export const PromQail = (props: PromQailProps) => { useEffect(() => { const fetchLabels = async () => { - let labelsIndex: Record<string, string[]>; - if (datasource.hasLabelsMatchAPISupport()) { - labelsIndex = await datasource.languageProvider.fetchSeriesLabelsMatch(query.metric); - } else { - labelsIndex = await datasource.languageProvider.fetchSeriesLabels(query.metric); - } + let labelsIndex: Record<string, string[]> = await datasource.languageProvider.fetchLabelsWithMatch(query.metric); setLabelNames(Object.keys(labelsIndex)); }; fetchLabels(); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/QueryAssistantButton.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/QueryAssistantButton.test.tsx index d45180fa7b55e..2b64277c22ca1 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/QueryAssistantButton.test.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/QueryAssistantButton.test.tsx @@ -17,7 +17,7 @@ describe('QueryAssistantButton', () => { const props = createProps(false, 'metric', setShowDrawer); render(<QueryAssistantButton {...props} />); const button = screen.getByText('Get query suggestions'); - userEvent.hover(button); + await userEvent.hover(button); await waitFor(() => { expect(screen.getByText('Install and enable the LLM plugin')).toBeInTheDocument(); }); @@ -27,7 +27,7 @@ describe('QueryAssistantButton', () => { const props = createProps(true, '', setShowDrawer); render(<QueryAssistantButton {...props} />); const button = screen.getByText('Get query suggestions'); - userEvent.hover(button); + await userEvent.hover(button); await waitFor(() => { expect(screen.getByText('First, select a metric.')).toBeInTheDocument(); }); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/QueryAssistantButton.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/QueryAssistantButton.tsx index f7cb661efe77c..5445134b64b96 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/QueryAssistantButton.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/QueryAssistantButton.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { selectors } from '@grafana/e2e-selectors'; import { reportInteraction } from '@grafana/runtime'; import { Button, Tooltip, useTheme2 } from '@grafana/ui'; @@ -32,6 +33,7 @@ export function QueryAssistantButton(props: Props) { setShowDrawer(true); }} disabled={!metric || !llmAppEnabled} + data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.queryAdvisor} > <img height={16} src={AI_Logo_color} alt="AI logo black and white" /> {'\u00A0'}Get query suggestions diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/state/helpers.test.ts b/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/state/helpers.test.ts index 7df017988b4e4..323848bebdc93 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/state/helpers.test.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/state/helpers.test.ts @@ -6,18 +6,18 @@ import { guessMetricType, isLLMPluginEnabled } from './helpers'; jest.mock('@grafana/experimental', () => ({ llms: { openai: { - enabled: jest.fn(), + health: jest.fn(), }, vector: { - enabled: jest.fn(), + health: jest.fn(), }, }, })); describe('isLLMPluginEnabled', () => { it('should return true if LLM plugin is enabled', async () => { - jest.mocked(llms.openai.enabled).mockResolvedValue({ ok: true, configured: true }); - jest.mocked(llms.vector.enabled).mockResolvedValue({ ok: true, enabled: true }); + jest.mocked(llms.openai.health).mockResolvedValue({ ok: true, configured: true }); + jest.mocked(llms.vector.health).mockResolvedValue({ ok: true, enabled: true }); const enabled = await isLLMPluginEnabled(); @@ -25,8 +25,8 @@ describe('isLLMPluginEnabled', () => { }); it('should return false if LLM plugin is not enabled', async () => { - jest.mocked(llms.openai.enabled).mockResolvedValue({ ok: false, configured: false }); - jest.mocked(llms.vector.enabled).mockResolvedValue({ ok: false, enabled: false }); + jest.mocked(llms.openai.health).mockResolvedValue({ ok: false, configured: false }); + jest.mocked(llms.vector.health).mockResolvedValue({ ok: false, enabled: false }); const enabled = await isLLMPluginEnabled(); @@ -34,8 +34,8 @@ describe('isLLMPluginEnabled', () => { }); it('should return false if LLM plugin is enabled but health check fails', async () => { - jest.mocked(llms.openai.enabled).mockResolvedValue({ ok: false, configured: true }); - jest.mocked(llms.vector.enabled).mockResolvedValue({ ok: false, enabled: true }); + jest.mocked(llms.openai.health).mockResolvedValue({ ok: false, configured: true }); + jest.mocked(llms.vector.health).mockResolvedValue({ ok: false, enabled: true }); const enabled = await isLLMPluginEnabled(); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/state/helpers.ts b/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/state/helpers.ts index dd430cf85e254..465fb7bfed803 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/state/helpers.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/state/helpers.ts @@ -274,8 +274,8 @@ function guessMetricFamily(metric: string): string { export async function isLLMPluginEnabled(): Promise<boolean> { // Check if the LLM plugin is enabled. // If not, we won't be able to make requests, so return early. - const openaiEnabled = llms.openai.enabled().then((response) => response.ok); - const vectorEnabled = llms.vector.enabled().then((response) => response.ok); + const openaiEnabled = llms.openai.health().then((response) => response.ok); + const vectorEnabled = llms.vector.health().then((response) => response.ok); // combine 2 promises return Promise.all([openaiEnabled, vectorEnabled]).then((results) => { return results.every((result) => result); @@ -346,7 +346,7 @@ export async function promQailSuggest( }; // get all available labels - const metricLabels = await datasource.languageProvider.fetchSeriesLabelsMatch(query.metric); + const metricLabels = await datasource.languageProvider.fetchLabelsWithMatch(query.metric); let feedTheAI: SuggestionBody = { metric: query.metric, diff --git a/public/app/plugins/datasource/prometheus/querybuilder/hooks/useFlag.test.ts b/public/app/plugins/datasource/prometheus/querybuilder/hooks/useFlag.test.ts new file mode 100644 index 0000000000000..4c7ca4e6393ba --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/hooks/useFlag.test.ts @@ -0,0 +1,23 @@ +import { act, renderHook } from '@testing-library/react'; + +import { promQueryEditorExplainKey, useFlag } from './useFlag'; + +describe('useFlag Hook', () => { + beforeEach(() => { + window.localStorage.removeItem(promQueryEditorExplainKey); + }); + + it('should return the default flag value as false', () => { + const { result } = renderHook(() => useFlag(promQueryEditorExplainKey)); + expect(result.current.flag).toBe(false); + }); + + it('should update the flag value without error', () => { + const { result } = renderHook(() => useFlag(promQueryEditorExplainKey, true)); + expect(result.current.flag).toBe(true); + act(() => { + result.current.setFlag(false); + }); + expect(result.current.flag).toBe(false); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/hooks/useFlag.ts b/public/app/plugins/datasource/prometheus/querybuilder/hooks/useFlag.ts new file mode 100644 index 0000000000000..80602b711df1a --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/hooks/useFlag.ts @@ -0,0 +1,36 @@ +import { useCallback, useState } from 'react'; + +import store from '../../../../../core/store'; + +export const promQueryEditorExplainKey = 'PrometheusQueryEditorExplainDefault'; + +export type QueryEditorFlags = typeof promQueryEditorExplainKey; + +function getFlagValue(key: QueryEditorFlags, defaultValue = false): boolean { + const val = store.get(key); + return val === undefined ? defaultValue : Boolean(parseInt(val, 10)); +} + +function setFlagValue(key: QueryEditorFlags, value: boolean) { + store.set(key, value ? '1' : '0'); +} + +type UseFlagHookReturnType = { flag: boolean; setFlag: (val: boolean) => void }; + +/** + * + * Use and store value of explain switch in local storage. + * Needs to be a hook with local state to trigger re-renders. + */ +export function useFlag(key: QueryEditorFlags, defaultValue = false): UseFlagHookReturnType { + const [flag, updateFlag] = useState(getFlagValue(key, defaultValue)); + const setter = useCallback( + (value: boolean) => { + setFlagValue(key, value); + updateFlag(value); + }, + [key] + ); + + return { flag, setFlag: setter }; +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationInfoButton.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationInfoButton.tsx index 4e5bc80065a19..e98b431459a26 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationInfoButton.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationInfoButton.tsx @@ -1,6 +1,15 @@ import { css } from '@emotion/css'; +import { + autoUpdate, + flip, + offset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, +} from '@floating-ui/react'; import React, { useState } from 'react'; -import { usePopperTooltip } from 'react-popper-tooltip'; import { GrafanaTheme2, renderMarkdown } from '@grafana/data'; import { FlexItem } from '@grafana/experimental'; @@ -16,28 +25,46 @@ export interface Props { export const OperationInfoButton = React.memo<Props>(({ def, operation }) => { const styles = useStyles2(getStyles); const [show, setShow] = useState(false); - const { getTooltipProps, setTooltipRef, setTriggerRef, visible } = usePopperTooltip({ + + // the order of middleware is important! + const middleware = [ + offset(16), + flip({ + fallbackAxisSideDirection: 'end', + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ]; + + const { context, refs, floatingStyles } = useFloating({ + open: show, placement: 'top', - visible: show, - offset: [0, 16], - onVisibleChange: setShow, - interactive: true, - trigger: ['click'], + onOpenChange: setShow, + middleware, + whileElementsMounted: autoUpdate, }); + const click = useClick(context); + const dismiss = useDismiss(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]); + return ( <> <Button title="Click to show description" - ref={setTriggerRef} + ref={refs.setReference} icon="info-circle" size="sm" variant="secondary" fill="text" + {...getReferenceProps()} /> - {visible && ( + {show && ( <Portal> - <div ref={setTooltipRef} {...getTooltipProps()} className={styles.docBox}> + <div ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()} className={styles.docBox}> <div className={styles.docBoxHeader}> <span>{def.renderer(operation, def, '<expr>')}</span> <FlexItem grow={1} /> @@ -86,14 +113,6 @@ const getStyles = (theme: GrafanaTheme2) => { marginBottom: theme.spacing(-1), color: theme.colors.text.secondary, }), - signature: css({ - fontSize: theme.typography.bodySmall.fontSize, - fontFamily: theme.typography.fontFamilyMonospace, - }), - dropdown: css({ - opacity: 0, - color: theme.colors.text.secondary, - }), }; }; function getOperationDocs(def: QueryBuilderOperationDef, op: QueryBuilderOperation): string { diff --git a/public/app/plugins/datasource/prometheus/result_transformer.test.ts b/public/app/plugins/datasource/prometheus/result_transformer.test.ts index 1c18dce5425df..93c4d2b37accd 100644 --- a/public/app/plugins/datasource/prometheus/result_transformer.test.ts +++ b/public/app/plugins/datasource/prometheus/result_transformer.test.ts @@ -1,11 +1,12 @@ import { cacheFieldDisplayNames, createDataFrame, - DataQueryRequest, - DataQueryResponse, FieldType, - PreferredVisualisationType, + type DataQueryRequest, + type DataQueryResponse, + type PreferredVisualisationType, } from '@grafana/data'; +import { transformToHistogramOverTime } from '@grafana/prometheus/src/result_transformer'; import { parseSampleValue, sortSeriesByLabel, transformDFToTable, transformV2 } from './result_transformer'; import { PromQuery } from './types'; @@ -404,6 +405,7 @@ describe('Prometheus Result Transformer', () => { expect(series.data[0].fields[2].name).toEqual('2'); expect(series.data[0].fields[3].name).toEqual('+Inf'); }); + it('results with heatmap format (with metric name) should be correctly transformed', () => { const options = { targets: [ @@ -909,7 +911,7 @@ describe('Prometheus Result Transformer', () => { }, ], } as unknown as DataQueryRequest<PromQuery>; - const testOptions: any = { + const testOptions = { exemplarTraceIdDestinations: [ { name: 'traceID', @@ -925,6 +927,89 @@ describe('Prometheus Result Transformer', () => { expect(traceField).toBeDefined(); expect(traceField!.config.links?.length).toBe(0); }); + + it('should convert values less than 1e-9 to 0', () => { + // pulled from real response + const bucketValues = [ + [0.22222222222222218, 0.24444444444444444, 0.19999999999999996], // le=0.005 + [0.39999999999999997, 0.44444444444444436, 0.42222222222222217], + [0.3999999999999999, 0.44444444444444436, 0.42222222222222217], + [0.3999999999999999, 0.44444444444444436, 0.42222222222222217], + [0.3999999999999999, 0.44444444444444436, 0.42222222222222217], + [0.3999999999999999, 0.44444444444444436, 0.42222222222222217], + [0.39999999999999997, 0.44444444444444436, 0.42222222222222217], + [0.39999999999999997, 0.44444444444444436, 0.42222222222222217], + [0.3999999999999999, 0.44444444444444436, 0.42222222222222217], + [0.3999999999999999, 0.44444444444444436, 0.42222222222222217], + [0.3999999999999999, 0.44444444444444436, 0.42222222222222217], + [0.4666666666666666, 0.5111111111111111, 0.4888888888888888], + [0.4666666666666666, 0.5111111111111111, 0.4888888888888888], + [0.46666666666666656, 0.5111111111111111, 0.4888888888888888], + [0.46666666666666656, 0.5111111111111111, 0.4888888888888888], // le=+Inf + ]; + + const frames = bucketValues.map((vals) => + createDataFrame({ + refId: 'A', + fields: [ + { type: FieldType.time, values: [1, 2, 3] }, + { + type: FieldType.number, + values: vals.slice(), + }, + ], + }) + ); + + const fieldValues = transformToHistogramOverTime(frames).map((frame) => frame.fields[1].values); + + expect(fieldValues).toEqual([ + [0.22222222222222218, 0.24444444444444444, 0.19999999999999996], + [0.17777777777777778, 0.19999999999999993, 0.2222222222222222], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0.06666666666666671, 0.06666666666666671, 0.06666666666666665], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ]); + }); + + it('should throw an error if the series does not contain number-type values', () => { + const response = { + state: 'Done', + data: [ + ['10', '10', '0'], + ['20', '10', '30'], + ['20', '10', '35'], + ].map((values) => + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4] }, + { name: 'Value', type: FieldType.string, values }, + ], + }) + ), + } as unknown as DataQueryResponse; + const request = { + targets: [ + { + format: 'heatmap', + refId: 'A', + }, + ], + } as unknown as DataQueryRequest<PromQuery>; + + expect(() => transformV2(response, request, {})).toThrow(); + }); }); describe('transformDFToTable', () => { diff --git a/public/app/plugins/datasource/prometheus/result_transformer.ts b/public/app/plugins/datasource/prometheus/result_transformer.ts index 0fbcfe58806f6..8111e34cc7eb1 100644 --- a/public/app/plugins/datasource/prometheus/result_transformer.ts +++ b/public/app/plugins/datasource/prometheus/result_transformer.ts @@ -359,7 +359,7 @@ function mergeHeatmapFrames(frames: DataFrame[]): DataFrame[] { ]; } -function transformToHistogramOverTime(seriesList: DataFrame[]) { +function transformToHistogramOverTime(seriesList: DataFrame[]): DataFrame[] { /* t1 = timestamp1, t2 = timestamp2 etc. t1 t2 t3 t1 t2 t3 le10 10 10 0 => 10 10 0 @@ -377,6 +377,10 @@ function transformToHistogramOverTime(seriesList: DataFrame[]) { for (let j = 0; j < topSeries.values.length; j++) { const bottomPoint = bottomSeries.values[j] || [0]; topSeries.values[j] -= bottomPoint; + + if (topSeries.values[j] < 1e-9) { + topSeries.values[j] = 0; + } } } diff --git a/public/app/plugins/datasource/prometheus/types.ts b/public/app/plugins/datasource/prometheus/types.ts index 5ddb69d811b6b..f3c5730b3805d 100644 --- a/public/app/plugins/datasource/prometheus/types.ts +++ b/public/app/plugins/datasource/prometheus/types.ts @@ -1,7 +1,7 @@ import { DataSourceJsonData } from '@grafana/data'; import { DataQuery } from '@grafana/schema'; -import { Prometheus as GenPromQuery } from './dataquery.gen'; +import { Prometheus as GenPromQuery } from './dataquery'; import { QueryBuilderLabelFilter, QueryEditorMode } from './querybuilder/shared/types'; export interface PromQuery extends GenPromQuery, DataQuery { diff --git a/public/app/plugins/datasource/tempo/.eslintignore b/public/app/plugins/datasource/tempo/.eslintignore new file mode 100644 index 0000000000000..59ac0834c7a79 --- /dev/null +++ b/public/app/plugins/datasource/tempo/.eslintignore @@ -0,0 +1,2 @@ +# TS generate from cue by cuetsy +**/*.gen.ts diff --git a/public/app/plugins/datasource/tempo/CHANGELOG.md b/public/app/plugins/datasource/tempo/CHANGELOG.md new file mode 100644 index 0000000000000..825c32f0d03d9 --- /dev/null +++ b/public/app/plugins/datasource/tempo/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/public/app/plugins/datasource/tempo/LokiSearch.tsx b/public/app/plugins/datasource/tempo/LokiSearch.tsx deleted file mode 100644 index a956b1eefe7b4..0000000000000 --- a/public/app/plugins/datasource/tempo/LokiSearch.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from 'react'; -import useAsync from 'react-use/lib/useAsync'; - -import { InlineLabel } from '@grafana/ui'; - -import { LokiQueryField } from '../loki/components/LokiQueryField'; -import { LokiDatasource } from '../loki/datasource'; -import { LokiQuery } from '../loki/types'; - -import { TempoQuery } from './types'; -import { getDS } from './utils'; - -interface LokiSearchProps { - logsDatasourceUid?: string; - onChange: (value: LokiQuery) => void; - onRunQuery: () => void; - query: TempoQuery; -} - -export function LokiSearch({ logsDatasourceUid, onChange, onRunQuery, query }: LokiSearchProps) { - const dsState = useAsync(() => getDS(logsDatasourceUid), [logsDatasourceUid]); - if (dsState.loading) { - return null; - } - - const ds = dsState.value as LokiDatasource; - - if (ds) { - return ( - <> - <InlineLabel>Tempo uses {ds.name} to find traces.</InlineLabel> - <LokiQueryField - datasource={ds} - onChange={onChange} - onRunQuery={onRunQuery} - query={query.linkedQuery ?? ({ refId: 'linked' } as LokiQuery)} - history={[]} - /> - </> - ); - } - - if (!logsDatasourceUid) { - return <div className="text-warning">Please set up a Loki search datasource in the datasource settings.</div>; - } - - if (logsDatasourceUid && !ds) { - return ( - <div className="text-warning"> - Loki search datasource is configured but the data source no longer exists. Please configure existing data source - to use the search. - </div> - ); - } - - return null; -} diff --git a/public/app/plugins/datasource/tempo/NativeSearch/NativeSearch.test.tsx b/public/app/plugins/datasource/tempo/NativeSearch/NativeSearch.test.tsx deleted file mode 100644 index d174380662b2d..0000000000000 --- a/public/app/plugins/datasource/tempo/NativeSearch/NativeSearch.test.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; - -import { TempoDatasource } from '../datasource'; -import { TempoQuery } from '../types'; - -import NativeSearch from './NativeSearch'; - -const getOptionsV1 = jest.fn().mockImplementation(() => { - return new Promise((resolve) => { - setTimeout(() => { - resolve([ - { - value: 'customer', - label: 'customer', - }, - { - value: 'driver', - label: 'driver', - }, - ]); - }, 1000); - }); -}); - -// Have to mock CodeEditor else it causes act warnings -jest.mock('@grafana/ui', () => ({ - ...jest.requireActual('@grafana/ui'), - CodeEditor: function CodeEditor({ value, onSave }: { value: string; onSave: (newQuery: string) => void }) { - return <input data-testid="mockeditor" value={value} onChange={(event) => onSave(event.target.value)} />; - }, -})); - -jest.mock('../language_provider', () => { - return jest.fn().mockImplementation(() => { - return { getOptionsV1 }; - }); -}); - -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getTemplateSrv: () => ({ - replace: jest.fn(), - containsTemplate: (val: string): boolean => { - return val.includes('$'); - }, - }), -})); - -let mockQuery = { - refId: 'A', - queryType: 'nativeSearch', - key: 'Q-595a9bbc-2a25-49a7-9249-a52a0a475d83-0', - serviceName: 'driver', - spanName: 'customer', -} as TempoQuery; - -describe('NativeSearch', () => { - let user: ReturnType<typeof userEvent.setup>; - - beforeEach(() => { - jest.useFakeTimers(); - // Need to use delay: null here to work with fakeTimers - // see https://github.com/testing-library/user-event/issues/833 - user = userEvent.setup({ delay: null }); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should show loader when there is a delay', async () => { - render( - <NativeSearch datasource={{} as TempoDatasource} query={mockQuery} onChange={jest.fn()} onRunQuery={jest.fn()} /> - ); - - const select = screen.getByRole('combobox', { name: 'select-service-name' }); - - await user.click(select); - const loader = screen.getByText('Loading options...'); - - expect(loader).toBeInTheDocument(); - - jest.advanceTimersByTime(1000); - - await waitFor(() => expect(screen.queryByText('Loading options...')).not.toBeInTheDocument()); - }); - - it('should call the `onChange` function on click of the Input', async () => { - const promise = Promise.resolve(); - const handleOnChange = jest.fn(() => promise); - const fakeOptionChoice = { - key: 'Q-595a9bbc-2a25-49a7-9249-a52a0a475d83-0', - queryType: 'nativeSearch', - refId: 'A', - serviceName: 'driver', - spanName: 'customer', - }; - - render( - <NativeSearch - datasource={{} as TempoDatasource} - query={mockQuery} - onChange={handleOnChange} - onRunQuery={() => {}} - /> - ); - - const select = await screen.findByRole('combobox', { name: 'select-service-name' }); - - expect(select).toBeInTheDocument(); - await user.click(select); - jest.advanceTimersByTime(1000); - - await user.type(select, 'd'); - const driverOption = await screen.findByText('driver'); - await user.click(driverOption); - - expect(handleOnChange).toHaveBeenCalledWith(fakeOptionChoice); - }); - - it('should filter the span dropdown when user types a search value', async () => { - render( - <NativeSearch datasource={{} as TempoDatasource} query={mockQuery} onChange={() => {}} onRunQuery={() => {}} /> - ); - - const select = await screen.findByRole('combobox', { name: 'select-service-name' }); - await user.click(select); - jest.advanceTimersByTime(1000); - expect(select).toBeInTheDocument(); - - await user.type(select, 'd'); - let option = await screen.findByText('driver'); - expect(option).toBeDefined(); - - await user.type(select, 'a'); - option = await screen.findByText('Hit enter to add'); - expect(option).toBeDefined(); - }); - - it('should add variable to select menu options', async () => { - mockQuery = { - ...mockQuery, - refId: '121314', - serviceName: '$service', - spanName: '$span', - }; - - render( - <NativeSearch datasource={{} as TempoDatasource} query={mockQuery} onChange={() => {}} onRunQuery={() => {}} /> - ); - - const asyncServiceSelect = screen.getByRole('combobox', { name: 'select-service-name' }); - expect(asyncServiceSelect).toBeInTheDocument(); - await user.click(asyncServiceSelect); - jest.advanceTimersByTime(3000); - - await user.type(asyncServiceSelect, '$'); - const serviceOption = await screen.findByText('$service'); - expect(serviceOption).toBeDefined(); - - const asyncSpanSelect = screen.getByRole('combobox', { name: 'select-span-name' }); - expect(asyncSpanSelect).toBeInTheDocument(); - await user.click(asyncSpanSelect); - jest.advanceTimersByTime(3000); - - await user.type(asyncSpanSelect, '$'); - const operationOption = await screen.findByText('$span'); - expect(operationOption).toBeDefined(); - }); -}); diff --git a/public/app/plugins/datasource/tempo/NativeSearch/NativeSearch.tsx b/public/app/plugins/datasource/tempo/NativeSearch/NativeSearch.tsx deleted file mode 100644 index 34c3c00a35f0c..0000000000000 --- a/public/app/plugins/datasource/tempo/NativeSearch/NativeSearch.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { css } from '@emotion/css'; -import React, { useCallback, useState, useEffect, useMemo } from 'react'; - -import { GrafanaTheme2, isValidGoDuration, SelectableValue, toOption } from '@grafana/data'; -import { FetchError, getTemplateSrv, isFetchError, TemplateSrv } from '@grafana/runtime'; -import { InlineFieldRow, InlineField, Input, Alert, useStyles2, fuzzyMatch, Select } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; -import { createErrorNotification } from 'app/core/copy/appNotification'; -import { dispatch } from 'app/store/store'; - -import { DEFAULT_LIMIT, TempoDatasource } from '../datasource'; -import TempoLanguageProvider from '../language_provider'; -import { TempoQuery } from '../types'; - -import { TagsField } from './TagsField/TagsField'; - -interface Props { - datasource: TempoDatasource; - query: TempoQuery; - onChange: (value: TempoQuery) => void; - onBlur?: () => void; - onRunQuery: () => void; -} - -const durationPlaceholder = 'e.g. 1.2s, 100ms'; - -const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props) => { - const styles = useStyles2(getStyles); - const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]); - const [serviceOptions, setServiceOptions] = useState<Array<SelectableValue<string>>>(); - const [spanOptions, setSpanOptions] = useState<Array<SelectableValue<string>>>(); - const [error, setError] = useState<Error | FetchError | null>(null); - const [inputErrors, setInputErrors] = useState<{ [key: string]: boolean }>({}); - const [isLoading, setIsLoading] = useState<{ - serviceName: boolean; - spanName: boolean; - }>({ - serviceName: false, - spanName: false, - }); - - const loadOptions = useCallback( - async (name: string, query = '') => { - const lpName = name === 'serviceName' ? 'service.name' : 'name'; - setIsLoading((prevValue) => ({ ...prevValue, [name]: true })); - - try { - const options = await languageProvider.getOptionsV1(lpName); - const filteredOptions = options.filter((item) => (item.value ? fuzzyMatch(item.value, query).found : false)); - return filteredOptions; - } catch (error) { - if (isFetchError(error) && error?.status === 404) { - setError(error); - } else if (error instanceof Error) { - dispatch(notifyApp(createErrorNotification('Error', error))); - } - return []; - } finally { - setIsLoading((prevValue) => ({ ...prevValue, [name]: false })); - } - }, - [languageProvider] - ); - - useEffect(() => { - const fetchOptions = async () => { - try { - const [services, spans] = await Promise.all([loadOptions('serviceName'), loadOptions('spanName')]); - if (query.serviceName && getTemplateSrv().containsTemplate(query.serviceName)) { - services.push(toOption(query.serviceName)); - } - setServiceOptions(services); - if (query.spanName && getTemplateSrv().containsTemplate(query.spanName)) { - spans.push(toOption(query.spanName)); - } - setSpanOptions(spans); - } catch (error) { - // Display message if Tempo is connected but search 404's - if (isFetchError(error) && error?.status === 404) { - setError(error); - } else if (error instanceof Error) { - dispatch(notifyApp(createErrorNotification('Error', error))); - } - } - }; - fetchOptions(); - }, [languageProvider, loadOptions, query.serviceName, query.spanName]); - - const onKeyDown = (keyEvent: React.KeyboardEvent) => { - if (keyEvent.key === 'Enter' && (keyEvent.shiftKey || keyEvent.ctrlKey)) { - onRunQuery(); - } - }; - - const handleOnChange = useCallback( - (value: string) => { - onChange({ - ...query, - search: value, - }); - }, - [onChange, query] - ); - - const templateSrv: TemplateSrv = getTemplateSrv(); - - return ( - <> - <div className={styles.container}> - <Alert title="Deprecated query type" severity="warning"> - This query type has been deprecated and will be removed in Grafana v10.3. Please migrate to another Tempo - query type. - </Alert> - <InlineFieldRow> - <InlineField label="Service Name" labelWidth={14} grow> - <Select - inputId="service" - options={serviceOptions} - onOpenMenu={() => { - loadOptions('serviceName'); - }} - isLoading={isLoading.serviceName} - value={serviceOptions?.find((v) => v?.value === query.serviceName) || query.serviceName} - onChange={(v) => { - onChange({ - ...query, - serviceName: v?.value, - }); - }} - placeholder="Select a service" - isClearable - onKeyDown={onKeyDown} - aria-label={'select-service-name'} - allowCustomValue={true} - /> - </InlineField> - </InlineFieldRow> - <InlineFieldRow> - <InlineField label="Span Name" labelWidth={14} grow> - <Select - inputId="spanName" - options={spanOptions} - onOpenMenu={() => { - loadOptions('spanName'); - }} - isLoading={isLoading.spanName} - value={spanOptions?.find((v) => v?.value === query.spanName) || query.spanName} - onChange={(v) => { - onChange({ - ...query, - spanName: v?.value, - }); - }} - placeholder="Select a span" - isClearable - onKeyDown={onKeyDown} - aria-label={'select-span-name'} - allowCustomValue={true} - /> - </InlineField> - </InlineFieldRow> - <InlineFieldRow> - <InlineField label="Tags" labelWidth={14} grow tooltip="Values should be in logfmt."> - <TagsField - placeholder="http.status_code=200 error=true" - value={query.search || ''} - onChange={handleOnChange} - onBlur={onBlur} - datasource={datasource} - /> - </InlineField> - </InlineFieldRow> - <InlineFieldRow> - <InlineField label="Min Duration" invalid={!!inputErrors.minDuration} labelWidth={14} grow> - <Input - id="minDuration" - value={query.minDuration || ''} - placeholder={durationPlaceholder} - onBlur={() => { - const templatedMinDuration = templateSrv.replace(query.minDuration ?? ''); - if (query.minDuration && !isValidGoDuration(templatedMinDuration)) { - setInputErrors({ ...inputErrors, minDuration: true }); - } else { - setInputErrors({ ...inputErrors, minDuration: false }); - } - }} - onChange={(v) => - onChange({ - ...query, - minDuration: v.currentTarget.value, - }) - } - onKeyDown={onKeyDown} - /> - </InlineField> - </InlineFieldRow> - <InlineFieldRow> - <InlineField label="Max Duration" invalid={!!inputErrors.maxDuration} labelWidth={14} grow> - <Input - id="maxDuration" - value={query.maxDuration || ''} - placeholder={durationPlaceholder} - onBlur={() => { - const templatedMaxDuration = templateSrv.replace(query.maxDuration ?? ''); - if (query.maxDuration && !isValidGoDuration(templatedMaxDuration)) { - setInputErrors({ ...inputErrors, maxDuration: true }); - } else { - setInputErrors({ ...inputErrors, maxDuration: false }); - } - }} - onChange={(v) => - onChange({ - ...query, - maxDuration: v.currentTarget.value, - }) - } - onKeyDown={onKeyDown} - /> - </InlineField> - </InlineFieldRow> - <InlineFieldRow> - <InlineField - label="Limit" - invalid={!!inputErrors.limit} - labelWidth={14} - grow - tooltip="Maximum number of returned results" - > - <Input - id="limit" - value={query.limit || ''} - placeholder={`Default: ${DEFAULT_LIMIT}`} - type="number" - onChange={(v) => { - let limit = v.currentTarget.value ? parseInt(v.currentTarget.value, 10) : undefined; - if (limit && (!Number.isInteger(limit) || limit <= 0)) { - setInputErrors({ ...inputErrors, limit: true }); - } else { - setInputErrors({ ...inputErrors, limit: false }); - } - - onChange({ - ...query, - limit: v.currentTarget.value ? parseInt(v.currentTarget.value, 10) : undefined, - }); - }} - onKeyDown={onKeyDown} - /> - </InlineField> - </InlineFieldRow> - </div> - {error ? ( - <Alert title="Unable to connect to Tempo search" severity="info" className={styles.alert}> - Please ensure that Tempo is configured with search enabled. If you would like to hide this tab, you can - configure it in the <a href={`/datasources/edit/${datasource.uid}`}>datasource settings</a>. - </Alert> - ) : null} - </> - ); -}; - -export default NativeSearch; - -const getStyles = (theme: GrafanaTheme2) => ({ - container: css` - max-width: 500px; - `, - alert: css` - max-width: 75ch; - margin-top: ${theme.spacing(2)}; - `, -}); diff --git a/public/app/plugins/datasource/tempo/NativeSearch/TagsField/TagsField.tsx b/public/app/plugins/datasource/tempo/NativeSearch/TagsField/TagsField.tsx deleted file mode 100644 index 236b99eee0c5b..0000000000000 --- a/public/app/plugins/datasource/tempo/NativeSearch/TagsField/TagsField.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { css } from '@emotion/css'; -import React, { useEffect, useRef } from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { CodeEditor, Monaco, monacoTypes, useTheme2 } from '@grafana/ui'; - -import { createErrorNotification } from '../../../../../core/copy/appNotification'; -import { notifyApp } from '../../../../../core/reducers/appNotification'; -import { dispatch } from '../../../../../store/store'; -import { TempoDatasource } from '../../datasource'; - -import { CompletionProvider } from './autocomplete'; -import { languageDefinition } from './syntax'; - -interface Props { - placeholder: string; - value: string; - onChange: (val: string) => void; - onBlur?: () => void; - datasource: TempoDatasource; -} - -export function TagsField(props: Props) { - const { onChange, onBlur, placeholder } = props; - const setupAutocompleteFn = useAutocomplete(props.datasource); - const theme = useTheme2(); - const styles = getStyles(theme, placeholder); - - return ( - <CodeEditor - value={props.value} - language={langId} - onBlur={onBlur} - onChange={onChange} - containerStyles={styles.queryField} - monacoOptions={{ - folding: false, - fontSize: 14, - lineNumbers: 'off', - overviewRulerLanes: 0, - renderLineHighlight: 'none', - scrollbar: { - vertical: 'hidden', - verticalScrollbarSize: 8, // used as "padding-right" - horizontal: 'hidden', - horizontalScrollbarSize: 0, - }, - scrollBeyondLastLine: false, - wordWrap: 'on', - }} - onBeforeEditorMount={ensureTraceQL} - onEditorDidMount={(editor, monaco) => { - setupAutocompleteFn(editor, monaco); - setupPlaceholder(editor, monaco, styles); - setupAutoSize(editor); - }} - /> - ); -} - -function setupPlaceholder(editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco, styles: EditorStyles) { - const placeholderDecorators = [ - { - range: new monaco.Range(1, 1, 1, 1), - options: { - className: styles.placeholder, // The placeholder text is in styles.placeholder - isWholeLine: true, - }, - }, - ]; - - let decorators: string[] = []; - - const checkDecorators = (): void => { - const model = editor.getModel(); - - if (!model) { - return; - } - - const newDecorators = model.getValueLength() === 0 ? placeholderDecorators : []; - decorators = model.deltaDecorations(decorators, newDecorators); - }; - - checkDecorators(); - editor.onDidChangeModelContent(checkDecorators); -} - -function setupAutoSize(editor: monacoTypes.editor.IStandaloneCodeEditor) { - const container = editor.getDomNode(); - const updateHeight = () => { - if (container) { - const contentHeight = Math.min(1000, editor.getContentHeight()); - const width = parseInt(container.style.width, 10); - container.style.width = `${width}px`; - container.style.height = `${contentHeight}px`; - editor.layout({ width, height: contentHeight }); - } - }; - editor.onDidContentSizeChange(updateHeight); - updateHeight(); -} - -/** - * Hook that returns function that will set up monaco autocomplete for the label selector - * @param datasource - */ -function useAutocomplete(datasource: TempoDatasource) { - // We need the provider ref so we can pass it the label/values data later. This is because we run the call for the - // values here but there is additional setup needed for the provider later on. We could run the getSeries() in the - // returned function but that is run after the monaco is mounted so would delay the request a bit when it does not - // need to. - const providerRef = useRef<CompletionProvider>( - new CompletionProvider({ languageProvider: datasource.languageProvider }) - ); - - useEffect(() => { - const fetchTags = async () => { - try { - await datasource.languageProvider.start(); - } catch (error) { - if (error instanceof Error) { - dispatch(notifyApp(createErrorNotification('Error', error))); - } - } - }; - fetchTags(); - }, [datasource]); - - const autocompleteDisposeFun = useRef<(() => void) | null>(null); - useEffect(() => { - // when we unmount, we unregister the autocomplete-function, if it was registered - return () => { - autocompleteDisposeFun.current?.(); - }; - }, []); - - // This should be run in monaco onEditorDidMount - return (editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => { - providerRef.current.editor = editor; - providerRef.current.monaco = monaco; - - const { dispose } = monaco.languages.registerCompletionItemProvider(langId, providerRef.current); - autocompleteDisposeFun.current = dispose; - }; -} - -// we must only run the setup code once -let setupDone = false; -const langId = 'tagsfield'; - -function ensureTraceQL(monaco: Monaco) { - if (!setupDone) { - setupDone = true; - const { aliases, extensions, mimetypes, def } = languageDefinition; - monaco.languages.register({ id: langId, aliases, extensions, mimetypes }); - monaco.languages.setMonarchTokensProvider(langId, def.language); - monaco.languages.setLanguageConfiguration(langId, def.languageConfiguration); - } -} - -interface EditorStyles { - placeholder: string; - queryField: string; -} - -const getStyles = (theme: GrafanaTheme2, placeholder: string): EditorStyles => { - return { - queryField: css` - border-radius: ${theme.shape.radius.default}; - border: 1px solid ${theme.components.input.borderColor}; - flex: 1; - `, - placeholder: css` - ::after { - content: '${placeholder}'; - font-family: ${theme.typography.fontFamilyMonospace}; - opacity: 0.3; - } - `, - }; -}; diff --git a/public/app/plugins/datasource/tempo/NativeSearch/TagsField/autocomplete.ts b/public/app/plugins/datasource/tempo/NativeSearch/TagsField/autocomplete.ts deleted file mode 100644 index 0b8be238785dc..0000000000000 --- a/public/app/plugins/datasource/tempo/NativeSearch/TagsField/autocomplete.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { SelectableValue } from '@grafana/data'; -import type { Monaco, monacoTypes } from '@grafana/ui'; - -import TempoLanguageProvider from '../../language_provider'; - -interface Props { - languageProvider: TempoLanguageProvider; -} - -/** - * Class that implements CompletionItemProvider interface and allows us to provide suggestion for the Monaco - * autocomplete system. - */ -export class CompletionProvider implements monacoTypes.languages.CompletionItemProvider { - languageProvider: TempoLanguageProvider; - - constructor(props: Props) { - this.languageProvider = props.languageProvider; - } - - triggerCharacters = ['=', ' ']; - - // We set these directly and ae required for the provider to function. - monaco: Monaco | undefined; - editor: monacoTypes.editor.IStandaloneCodeEditor | undefined; - - private cachedValues: { [key: string]: Array<SelectableValue<string>> } = {}; - - provideCompletionItems( - model: monacoTypes.editor.ITextModel, - position: monacoTypes.Position - ): monacoTypes.languages.ProviderResult<monacoTypes.languages.CompletionList> { - // Should not happen, this should not be called before it is initialized - if (!(this.monaco && this.editor)) { - throw new Error('provideCompletionItems called before CompletionProvider was initialized'); - } - - // if the model-id does not match, then this call is from a different editor-instance, - // not "our instance", so return nothing - if (this.editor.getModel()?.id !== model.id) { - return { suggestions: [] }; - } - - const { range, offset } = getRangeAndOffset(this.monaco, model, position); - const situation = this.getSituation(model.getValue(), offset); - const completionItems = this.getCompletions(situation); - - return completionItems.then((items) => { - // monaco by-default alphabetically orders the items. - // to stop it, we use a number-as-string sortkey, - // so that monaco keeps the order we use - const maxIndexDigits = items.length.toString().length; - const suggestions: monacoTypes.languages.CompletionItem[] = items.map((item, index) => { - const suggestion: monacoTypes.languages.CompletionItem = { - kind: getMonacoCompletionItemKind(item.type, this.monaco!), - label: item.label, - insertText: item.insertText, - sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have - range, - }; - return suggestion; - }); - return { suggestions }; - }); - } - - private async getTagValues(tagName: string): Promise<Array<SelectableValue<string>>> { - let tagValues: Array<SelectableValue<string>>; - - if (this.cachedValues.hasOwnProperty(tagName)) { - tagValues = this.cachedValues[tagName]; - } else { - tagValues = await this.languageProvider.getOptionsV1(tagName); - this.cachedValues[tagName] = tagValues; - } - return tagValues; - } - - /** - * Get suggestion based on the situation we are in like whether we should suggest tag names or values. - * @param situation - * @private - */ - private async getCompletions(situation: Situation): Promise<Completion[]> { - switch (situation.type) { - // Not really sure what would make sense to suggest in this case so just leave it - case 'UNKNOWN': { - return []; - } - case 'EMPTY': { - return this.getTagsCompletions(); - } - case 'IN_NAME': - return this.getTagsCompletions(); - case 'IN_VALUE': - const tagValues = await this.getTagValues(situation.tagName); - const items: Completion[] = []; - - const getInsertionText = (val: SelectableValue<string>): string => `"${val.label}"`; - - tagValues.forEach((val) => { - if (val?.label) { - items.push({ - label: val.label, - insertText: getInsertionText(val), - type: 'TAG_VALUE', - }); - } - }); - return items; - default: - throw new Error(`Unexpected situation ${situation}`); - } - } - - private getTagsCompletions(): Completion[] { - const tags = this.languageProvider.getAutocompleteTags(); - return tags - .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'accent' })) - .map((key) => ({ - label: key, - insertText: key, - type: 'TAG_NAME', - })); - } - - /** - * Figure out where is the cursor and what kind of suggestions are appropriate. - * @param text - * @param offset - */ - private getSituation(text: string, offset: number): Situation { - if (text === '' || offset === 0 || text[text.length - 1] === ' ') { - return { - type: 'EMPTY', - }; - } - - const textUntilCaret = text.substring(0, offset); - - const regex = /(?<key>[^= ]+)(?<equals>=)?(?<value>([^ "]+)|"([^"]*)")?/; - const matches = textUntilCaret.match(new RegExp(regex, 'g')); - - if (matches?.length) { - const last = matches[matches.length - 1]; - const lastMatched = last.match(regex); - if (lastMatched) { - const key = lastMatched.groups?.key; - const equals = lastMatched.groups?.equals; - - if (!key) { - return { - type: 'EMPTY', - }; - } - - if (!equals) { - return { - type: 'IN_NAME', - }; - } - - return { - type: 'IN_VALUE', - tagName: key, - }; - } - } - - return { - type: 'EMPTY', - }; - } -} - -/** - * Get item kind which is used for icon next to the suggestion. - * @param type - * @param monaco - */ -function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): monacoTypes.languages.CompletionItemKind { - switch (type) { - case 'TAG_NAME': - return monaco.languages.CompletionItemKind.Enum; - case 'KEYWORD': - return monaco.languages.CompletionItemKind.Keyword; - case 'OPERATOR': - return monaco.languages.CompletionItemKind.Operator; - case 'TAG_VALUE': - return monaco.languages.CompletionItemKind.EnumMember; - case 'SCOPE': - return monaco.languages.CompletionItemKind.Class; - default: - throw new Error(`Unexpected CompletionType: ${type}`); - } -} - -export type CompletionType = 'TAG_NAME' | 'TAG_VALUE' | 'KEYWORD' | 'OPERATOR' | 'SCOPE'; -type Completion = { - type: CompletionType; - label: string; - insertText: string; -}; - -export type Tag = { - name: string; - value: string; -}; - -export type Situation = - | { - type: 'UNKNOWN'; - } - | { - type: 'EMPTY'; - } - | { - type: 'IN_NAME'; - } - | { - type: 'IN_VALUE'; - tagName: string; - }; - -function getRangeAndOffset(monaco: Monaco, model: monacoTypes.editor.ITextModel, position: monacoTypes.Position) { - const word = model.getWordAtPosition(position); - const range = - word != null - ? monaco.Range.lift({ - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: word.startColumn, - endColumn: word.endColumn, - }) - : monaco.Range.fromPositions(position); - - // documentation says `position` will be "adjusted" in `getOffsetAt` so we clone it here just for sure. - const positionClone = { - column: position.column, - lineNumber: position.lineNumber, - }; - - const offset = model.getOffsetAt(positionClone); - return { offset, range }; -} diff --git a/public/app/plugins/datasource/tempo/NativeSearch/TagsField/syntax.ts b/public/app/plugins/datasource/tempo/NativeSearch/TagsField/syntax.ts deleted file mode 100644 index 6dd942b2f119a..0000000000000 --- a/public/app/plugins/datasource/tempo/NativeSearch/TagsField/syntax.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { languages } from 'monaco-editor'; - -export const languageConfiguration: languages.LanguageConfiguration = { - // the default separators except `@$` - wordPattern: /(-?\d*\.\d\w*)|([^`~!#%^&*()\-=+\[{\]}\\|;:'",.<>\/?\s]+)/g, - brackets: [ - ['{', '}'], - ['(', ')'], - ], - autoClosingPairs: [ - { open: '{', close: '}' }, - { open: '(', close: ')' }, - { open: '"', close: '"' }, - { open: "'", close: "'" }, - ], - surroundingPairs: [ - { open: '{', close: '}' }, - { open: '(', close: ')' }, - { open: '"', close: '"' }, - { open: "'", close: "'" }, - ], - folding: {}, -}; - -const operators = ['=']; - -export const language: languages.IMonarchLanguage = { - ignoreCase: false, - defaultToken: '', - tokenPostfix: '.tagsfield', - - operators, - - // we include these common regular expressions - symbols: /[=><!~?:&|+\-*\/^%]+/, - escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/, - digits: /\d+(_+\d+)*/, - octaldigits: /[0-7]+(_+[0-7]+)*/, - binarydigits: /[0-1]+(_+[0-1]+)*/, - hexdigits: /[[0-9a-fA-F]+(_+[0-9a-fA-F]+)*/, - integersuffix: /(ll|LL|u|U|l|L)?(ll|LL|u|U|l|L)?/, - floatsuffix: /[fFlL]?/, - - tokenizer: { - root: [ - // labels - [/[a-z_.][\w./_-]*(?=\s*(=|!=|>|<|>=|<=|=~|!~))/, 'tag'], - - // all keywords have the same color - [ - /[a-zA-Z_.]\w*/, - { - cases: { - '@default': 'identifier', - }, - }, - ], - - // strings - [/"([^"\\]|\\.)*$/, 'string.invalid'], // non-teminated string - [/'([^'\\]|\\.)*$/, 'string.invalid'], // non-teminated string - [/"/, 'string', '@string_double'], - [/'/, 'string', '@string_single'], - - // whitespace - { include: '@whitespace' }, - - // delimiters and operators - [/[{}()\[\]]/, '@brackets'], - [/[<>](?!@symbols)/, '@brackets'], - [ - /@symbols/, - { - cases: { - '@operators': 'delimiter', - '@default': '', - }, - }, - ], - - // numbers - [/\d+/, 'number'], - [/\d*\d+[eE]([\-+]?\d+)?(@floatsuffix)/, 'number.float'], - [/\d*\.\d+([eE][\-+]?\d+)?(@floatsuffix)/, 'number.float'], - [/0[xX][0-9a-fA-F']*[0-9a-fA-F](@integersuffix)/, 'number.hex'], - [/0[0-7']*[0-7](@integersuffix)/, 'number.octal'], - [/0[bB][0-1']*[0-1](@integersuffix)/, 'number.binary'], - [/\d[\d']*\d(@integersuffix)/, 'number'], - [/\d(@integersuffix)/, 'number'], - ], - - string_double: [ - [/[^\\"]+/, 'string'], - [/@escapes/, 'string.escape'], - [/\\./, 'string.escape.invalid'], - [/"/, 'string', '@pop'], - ], - - string_single: [ - [/[^\\']+/, 'string'], - [/@escapes/, 'string.escape'], - [/\\./, 'string.escape.invalid'], - [/'/, 'string', '@pop'], - ], - - clauses: [ - [/[^(,)]/, 'tag'], - [/\)/, 'identifier', '@pop'], - ], - - whitespace: [[/[ \t\r\n]+/, 'white']], - }, -}; - -export const languageDefinition = { - id: 'tagsfield', - extensions: ['.tagsfield'], - aliases: ['tagsfield'], - mimetypes: [], - def: { - language, - languageConfiguration, - }, -}; diff --git a/public/app/plugins/datasource/tempo/QueryField.tsx b/public/app/plugins/datasource/tempo/QueryField.tsx index 252975076f307..3196be57ba9e5 100644 --- a/public/app/plugins/datasource/tempo/QueryField.tsx +++ b/public/app/plugins/datasource/tempo/QueryField.tsx @@ -15,16 +15,13 @@ import { withTheme2, } from '@grafana/ui'; -import { LokiQuery } from '../loki/types'; - -import { LokiSearch } from './LokiSearch'; -import NativeSearch from './NativeSearch/NativeSearch'; import TraceQLSearch from './SearchTraceQLEditor/TraceQLSearch'; import { ServiceGraphSection } from './ServiceGraphSection'; import { TempoQueryType } from './dataquery.gen'; import { TempoDatasource } from './datasource'; import { QueryEditor } from './traceql/QueryEditor'; import { TempoQuery } from './types'; +import { migrateFromSearchToTraceQLSearch } from './utils'; interface Props extends QueryEditorProps<TempoDatasource, TempoQuery>, Themeable2 {} interface State { @@ -56,18 +53,6 @@ class TempoQueryFieldComponent extends React.PureComponent<Props, State> { } } - onChangeLinkedQuery = (value: LokiQuery) => { - const { query, onChange } = this.props; - onChange({ - ...query, - linkedQuery: { ...value, refId: 'linked' }, - }); - }; - - onRunLinkedQuery = () => { - this.props.onRunQuery(); - }; - onClearResults = () => { // Run clear query to clear results const { onChange, query, onRunQuery } = this.props; @@ -81,8 +66,6 @@ class TempoQueryFieldComponent extends React.PureComponent<Props, State> { render() { const { query, onChange, datasource, app } = this.props; - const logsDatasourceUid = datasource.getLokiSearchDS(); - const graphDatasourceUid = datasource.serviceMap?.datasourceUid; let queryTypeOptions: Array<SelectableValue<TempoQueryType>> = [ @@ -91,17 +74,7 @@ class TempoQueryFieldComponent extends React.PureComponent<Props, State> { { value: 'serviceMap', label: 'Service Graph' }, ]; - if (logsDatasourceUid) { - if (datasource?.search?.hide) { - // Place at beginning as Search if no native search - queryTypeOptions.unshift({ value: 'search', label: 'Search' }); - } else { - // Place at end as Loki Search if native search is enabled - queryTypeOptions.push({ value: 'search', label: 'Loki Search' }); - } - } - - // Show the deprecated search option if any of the deprecated search fields are set + // Migrate user to new query type if they are using the old search query type if ( query.spanName || query.serviceName || @@ -110,7 +83,7 @@ class TempoQueryFieldComponent extends React.PureComponent<Props, State> { query.minDuration || query.queryType === 'nativeSearch' ) { - queryTypeOptions.unshift({ value: 'nativeSearch', label: '[Deprecated] Search' }); + onChange(migrateFromSearchToTraceQLSearch(query)); } return ( @@ -124,6 +97,9 @@ class TempoQueryFieldComponent extends React.PureComponent<Props, State> { <FileDropzone options={{ multiple: false }} onLoad={(result) => { + if (typeof result !== 'string' && result !== null) { + throw Error(`Unexpected result type: ${typeof result}`); + } this.props.datasource.uploadedJson = result; onChange({ ...query, @@ -170,29 +146,14 @@ class TempoQueryFieldComponent extends React.PureComponent<Props, State> { </HorizontalGroup> </InlineField> </InlineFieldRow> - {query.queryType === 'search' && ( - <LokiSearch - logsDatasourceUid={logsDatasourceUid} - query={query} - onRunQuery={this.onRunLinkedQuery} - onChange={this.onChangeLinkedQuery} - /> - )} - {query.queryType === 'nativeSearch' && ( - <NativeSearch - datasource={this.props.datasource} - query={query} - onChange={onChange} - onBlur={this.props.onBlur} - onRunQuery={this.props.onRunQuery} - /> - )} {query.queryType === 'traceqlSearch' && ( <TraceQLSearch datasource={this.props.datasource} query={query} onChange={onChange} onBlur={this.props.onBlur} + app={app} + onClearResults={this.onClearResults} /> )} {query.queryType === 'serviceMap' && ( diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/DurationInput.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/DurationInput.tsx index 528099ca86cce..deea6014dabd4 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/DurationInput.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/DurationInput.tsx @@ -18,12 +18,12 @@ interface Props { const validationRegex = /^(\$\w+)|(\d+(?:\.\d)?\d*(?:us|µs|ns|ms|s|m|h))$/; const getStyles = () => ({ - noBoxShadow: css` - box-shadow: none; - *:focus { - box-shadow: none; - } - `, + noBoxShadow: css({ + boxShadow: 'none', + '*:focus': { + boxShadow: 'none', + }, + }), }); const DurationInput = ({ filter, operators, updateFilter }: Props) => { diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.test.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.test.tsx index af230d44ecf4c..95ebbb01afc0e 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.test.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import React from 'react'; +import React, { useState } from 'react'; import { TraceqlSearchScope } from '../dataquery.gen'; import { TempoDatasource } from '../datasource'; @@ -46,6 +46,52 @@ describe('GroupByField', () => { jest.useRealTimers(); }); + it('should only show add/remove tag when necessary', async () => { + const GroupByWithProps = () => { + const [query, setQuery] = useState<TempoQuery>({ + refId: 'A', + queryType: 'traceqlSearch', + key: 'Q-595a9bbc-2a25-49a7-9249-a52a0a475d83-0', + filters: [], + groupBy: [{ id: 'group-by-id', scope: TraceqlSearchScope.Span }], + }); + return ( + <GroupByField + datasource={datasource} + query={query} + onChange={(q: TempoQuery) => setQuery(q)} + isTagsLoading={false} + /> + ); + }; + render(<GroupByWithProps />); + expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the default tag, so no need to add another one + expect(screen.queryAllByLabelText(/Remove tag/).length).toBe(0); // mot filled in the default tag, so no values to remove + expect(screen.getAllByText('Select tag').length).toBe(1); + + await user.click(screen.getByText('Select tag')); + jest.advanceTimersByTime(1000); + await user.click(screen.getByText('http.method')); + jest.advanceTimersByTime(1000); + expect(screen.getAllByLabelText('Add tag').length).toBe(1); + expect(screen.getAllByLabelText(/Remove tag/).length).toBe(1); + + await user.click(screen.getByLabelText('Add tag')); + jest.advanceTimersByTime(1000); + expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the new tag, so no need to add another one + expect(screen.getAllByLabelText(/Remove tag/).length).toBe(2); // one for each tag + + await user.click(screen.getAllByLabelText(/Remove tag/)[1]); + jest.advanceTimersByTime(1000); + expect(screen.queryAllByLabelText('Add tag').length).toBe(1); // filled in the default tag, so can add another one + expect(screen.queryAllByLabelText(/Remove tag/).length).toBe(1); // filled in the default tag, so can remove values + + await user.click(screen.getAllByLabelText(/Remove tag/)[0]); + jest.advanceTimersByTime(1000); + expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the default tag, so no need to add another one + expect(screen.queryAllByLabelText(/Remove tag/).length).toBe(0); // mot filled in the default tag, so no values to remove + }); + it('should update scope when new value is selected in scope input', async () => { const { container } = render( <GroupByField datasource={datasource} query={query} onChange={onChange} isTagsLoading={false} /> diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx index 93768010da0e8..e88ab32a21c28 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx @@ -101,16 +101,18 @@ export const GroupByField = (props: Props) => { placeholder="Select tag" value={f.tag || ''} /> - <AccessoryButton - aria-label={`Remove tag for filter ${i + 1}`} - icon="times" - onClick={() => removeFilter(f)} - tooltip="Remove tag" - variant="secondary" - /> - - {i === (query.groupBy?.length ?? 0) - 1 && ( - <span className={styles.addFilter}> + {(f.tag || (query.groupBy?.length ?? 0) > 1) && ( + <AccessoryButton + aria-label={`Remove tag for filter ${i + 1}`} + icon="times" + onClick={() => removeFilter(f)} + tooltip="Remove tag" + title={`Remove tag for filter ${i + 1}`} + variant="secondary" + /> + )} + {f.tag && i === (query.groupBy?.length ?? 0) - 1 && ( + <span className={styles.addTag}> <AccessoryButton aria-label="Add tag" icon="plus" @@ -129,7 +131,7 @@ export const GroupByField = (props: Props) => { }; const getStyles = (theme: GrafanaTheme2) => ({ - addFilter: css` - margin-left: ${theme.spacing(2)}; - `, + addTag: css({ + marginLeft: theme.spacing(1), + }), }); diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx index 1b8cd47d0df15..42a4935371feb 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx @@ -1,24 +1,29 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { initTemplateSrv } from 'test/helpers/initTemplateSrv'; import { LanguageProvider } from '@grafana/data'; -import { FetchError, setTemplateSrv } from '@grafana/runtime'; import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen'; import { TempoDatasource } from '../datasource'; import TempoLanguageProvider from '../language_provider'; +import { initTemplateSrv } from '../test_utils'; import { keywordOperators, numberOperators, operators, stringOperators } from '../traceql/traceql'; import SearchField from './SearchField'; describe('SearchField', () => { - let templateSrv = initTemplateSrv('key', [{ name: 'templateVariable1' }, { name: 'templateVariable2' }]); let user: ReturnType<typeof userEvent.setup>; beforeEach(() => { - setTemplateSrv(templateSrv); + const expectedValues = { + interpolationVar: 'interpolationText', + interpolationText: 'interpolationText', + interpolationVarWithPipe: 'interpolationTextOne|interpolationTextTwo', + scopedInterpolationText: 'scopedInterpolationText', + }; + initTemplateSrv([{ name: 'templateVariable1' }, { name: 'templateVariable2' }], expectedValues); + jest.useFakeTimers(); // Need to use delay: null here to work with fakeTimers // see https://github.com/testing-library/user-event/issues/833 @@ -119,19 +124,19 @@ describe('SearchField', () => { jest.advanceTimersByTime(1000); const tag22 = await screen.findByText('tag22'); await user.click(tag22); - expect(updateFilter).toHaveBeenCalledWith({ ...filter, tag: 'tag22' }); + expect(updateFilter).toHaveBeenCalledWith({ ...filter, tag: 'tag22', value: [] }); // Select tag1 as the tag await user.click(select); jest.advanceTimersByTime(1000); const tag1 = await screen.findByText('tag1'); await user.click(tag1); - expect(updateFilter).toHaveBeenCalledWith({ ...filter, tag: 'tag1' }); + expect(updateFilter).toHaveBeenCalledWith({ ...filter, tag: 'tag1', value: [] }); // Remove the tag const tagRemove = await screen.findByLabelText('select-clear-value'); await user.click(tagRemove); - expect(updateFilter).toHaveBeenCalledWith({ ...filter, value: undefined }); + expect(updateFilter).toHaveBeenCalledWith({ ...filter, value: [] }); } }); @@ -284,9 +289,7 @@ const renderSearchField = ( datasource={datasource} updateFilter={updateFilter} filter={filter} - setError={function (error: FetchError): void { - throw error; - }} + setError={() => {}} tags={tags || []} hideTag={hideTag} query={'{}'} diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx index 7388b3d9f7442..eaaeda3222113 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx @@ -4,13 +4,10 @@ import React, { useState, useEffect, useMemo } from 'react'; import useAsync from 'react-use/lib/useAsync'; import { SelectableValue } from '@grafana/data'; -import { AccessoryButton } from '@grafana/experimental'; +import { TemporaryAlert } from '@grafana/o11y-ds-frontend'; import { FetchError, getTemplateSrv, isFetchError } from '@grafana/runtime'; import { Select, HorizontalGroup, useStyles2 } from '@grafana/ui'; -import { createErrorNotification } from '../../../../core/copy/appNotification'; -import { notifyApp } from '../../../../core/reducers/appNotification'; -import { dispatch } from '../../../../store/store'; import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen'; import { TempoDatasource } from '../datasource'; import { operators as allOperators, stringOperators, numberOperators, keywordOperators } from '../traceql/traceql'; @@ -18,9 +15,9 @@ import { operators as allOperators, stringOperators, numberOperators, keywordOpe import { filterScopedTag, operatorSelectableValue } from './utils'; const getStyles = () => ({ - dropdown: css` - box-shadow: none; - `, + dropdown: css({ + boxShadow: 'none', + }), }); interface Props { @@ -28,30 +25,32 @@ interface Props { datasource: TempoDatasource; updateFilter: (f: TraceqlFilter) => void; deleteFilter?: (f: TraceqlFilter) => void; - setError: (error: FetchError) => void; + setError: (error: FetchError | null) => void; isTagsLoading?: boolean; tags: string[]; hideScope?: boolean; hideTag?: boolean; hideValue?: boolean; - allowDelete?: boolean; query: string; + isMulti?: boolean; + allowCustomValue?: boolean; } const SearchField = ({ filter, datasource, updateFilter, - deleteFilter, isTagsLoading, tags, setError, hideScope, hideTag, hideValue, - allowDelete, query, + isMulti = true, + allowCustomValue = true, }: Props) => { const styles = useStyles2(getStyles); + const [alertText, setAlertText] = useState<string>(); const scopedTag = useMemo(() => filterScopedTag(filter), [filter]); // We automatically change the operator to the regex op when users select 2 or more values // However, they expect this to be automatically rolled back to the previous operator once @@ -61,13 +60,16 @@ const SearchField = ({ const updateOptions = async () => { try { - return filter.tag ? await datasource.languageProvider.getOptionsV2(scopedTag, query) : []; + const result = filter.tag ? await datasource.languageProvider.getOptionsV2(scopedTag, query) : []; + setAlertText(undefined); + setError(null); + return result; } catch (error) { // Display message if Tempo is connected but search 404's if (isFetchError(error) && error?.status === 404) { setError(error); } else if (error instanceof Error) { - dispatch(notifyApp(createErrorNotification('Error', error))); + setAlertText(`Error: ${error.message}`); } } return []; @@ -136,87 +138,85 @@ const SearchField = ({ }; return ( - <HorizontalGroup spacing={'none'} width={'auto'}> - {!hideScope && ( + <> + <HorizontalGroup spacing={'none'} width={'auto'}> + {!hideScope && ( + <Select + className={styles.dropdown} + inputId={`${filter.id}-scope`} + options={withTemplateVariableOptions(scopeOptions)} + value={filter.scope} + onChange={(v) => { + updateFilter({ ...filter, scope: v?.value }); + }} + placeholder="Select scope" + aria-label={`select ${filter.id} scope`} + /> + )} + {!hideTag && ( + <Select + className={styles.dropdown} + inputId={`${filter.id}-tag`} + isLoading={isTagsLoading} + // Add the current tag to the list if it doesn't exist in the tags prop, otherwise the field will be empty even though the state has a value + options={withTemplateVariableOptions( + (filter.tag !== undefined ? uniq([filter.tag, ...tags]) : tags).map((t) => ({ + label: t, + value: t, + })) + )} + value={filter.tag} + onChange={(v) => { + updateFilter({ ...filter, tag: v?.value, value: [] }); + }} + placeholder="Select tag" + isClearable + aria-label={`select ${filter.id} tag`} + allowCustomValue={true} + /> + )} <Select className={styles.dropdown} - inputId={`${filter.id}-scope`} - options={withTemplateVariableOptions(scopeOptions)} - value={filter.scope} + inputId={`${filter.id}-operator`} + options={withTemplateVariableOptions(operatorList.map(operatorSelectableValue))} + value={filter.operator} onChange={(v) => { - updateFilter({ ...filter, scope: v?.value }); + updateFilter({ ...filter, operator: v?.value }); }} - placeholder="Select scope" - aria-label={`select ${filter.id} scope`} - /> - )} - {!hideTag && ( - <Select - className={styles.dropdown} - inputId={`${filter.id}-tag`} - isLoading={isTagsLoading} - // Add the current tag to the list if it doesn't exist in the tags prop, otherwise the field will be empty even though the state has a value - options={withTemplateVariableOptions( - (filter.tag !== undefined ? uniq([filter.tag, ...tags]) : tags).map((t) => ({ - label: t, - value: t, - })) - )} - value={filter.tag} - onChange={(v) => { - updateFilter({ ...filter, tag: v?.value }); - }} - placeholder="Select tag" - isClearable - aria-label={`select ${filter.id} tag`} - allowCustomValue={true} - /> - )} - <Select - className={styles.dropdown} - inputId={`${filter.id}-operator`} - options={withTemplateVariableOptions(operatorList.map(operatorSelectableValue))} - value={filter.operator} - onChange={(v) => { - updateFilter({ ...filter, operator: v?.value }); - }} - isClearable={false} - aria-label={`select ${filter.id} operator`} - allowCustomValue={true} - width={8} - /> - {!hideValue && ( - <Select - className={styles.dropdown} - inputId={`${filter.id}-value`} - isLoading={isLoadingValues} - options={withTemplateVariableOptions(options)} - value={filter.value} - onChange={(val) => { - if (Array.isArray(val)) { - updateFilter({ ...filter, value: val.map((v) => v.value), valueType: val[0]?.type || uniqueOptionType }); - } else { - updateFilter({ ...filter, value: val?.value, valueType: val?.type || uniqueOptionType }); - } - }} - placeholder="Select value" isClearable={false} - aria-label={`select ${filter.id} value`} + aria-label={`select ${filter.id} operator`} allowCustomValue={true} - isMulti - allowCreateWhileLoading - /> - )} - {allowDelete && ( - <AccessoryButton - variant={'secondary'} - icon={'times'} - onClick={() => deleteFilter?.(filter)} - tooltip={'Remove tag'} - aria-label={`remove tag with ID ${filter.id}`} + width={8} /> - )} - </HorizontalGroup> + {!hideValue && ( + <Select + className={styles.dropdown} + inputId={`${filter.id}-value`} + isLoading={isLoadingValues} + options={withTemplateVariableOptions(options)} + value={filter.value} + onChange={(val) => { + if (Array.isArray(val)) { + updateFilter({ + ...filter, + value: val.map((v) => v.value), + valueType: val[0]?.type || uniqueOptionType, + }); + } else { + updateFilter({ ...filter, value: val?.value, valueType: val?.type || uniqueOptionType }); + } + }} + placeholder="Select value" + isClearable={true} + aria-label={`select ${filter.id} value`} + allowCustomValue={allowCustomValue} + isMulti={isMulti} + allowCreateWhileLoading + /> + )} + </HorizontalGroup> + {alertText && <TemporaryAlert severity="error" text={alertText} />} + </> ); }; diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.test.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.test.tsx index ac20fc2e76855..0edd42264bcc4 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.test.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.test.tsx @@ -1,24 +1,28 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { initTemplateSrv } from 'test/helpers/initTemplateSrv'; - -import { FetchError, setTemplateSrv } from '@grafana/runtime'; import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen'; import { TempoDatasource } from '../datasource'; import TempoLanguageProvider from '../language_provider'; +import { initTemplateSrv } from '../test_utils'; import { Scope } from '../types'; import TagsInput from './TagsInput'; import { v1Tags, v2Tags } from './utils.test'; describe('TagsInput', () => { - let templateSrv = initTemplateSrv('key', [{ name: 'templateVariable1' }, { name: 'templateVariable2' }]); let user: ReturnType<typeof userEvent.setup>; beforeEach(() => { - setTemplateSrv(templateSrv); + const expectedValues = { + interpolationVar: 'interpolationText', + interpolationText: 'interpolationText', + interpolationVarWithPipe: 'interpolationTextOne|interpolationTextTwo', + scopedInterpolationText: 'scopedInterpolationText', + }; + initTemplateSrv([{ name: 'templateVariable1' }, { name: 'templateVariable2' }], expectedValues); + jest.useFakeTimers(); // Need to use delay: null here to work with fakeTimers // see https://github.com/testing-library/user-event/issues/833 @@ -118,9 +122,7 @@ describe('TagsInput', () => { updateFilter={jest.fn} deleteFilter={jest.fn} filters={[filter]} - setError={function (error: FetchError): void { - throw error; - }} + setError={() => {}} staticTags={[]} isTagsLoading={false} query={''} diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.tsx index 6439fbbfb5724..9a877b5e5e365 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import React, { useCallback, useEffect } from 'react'; import { v4 as uuidv4 } from 'uuid'; +import { GrafanaTheme2 } from '@grafana/data'; import { AccessoryButton } from '@grafana/experimental'; import { FetchError } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; @@ -12,17 +13,20 @@ import { TempoDatasource } from '../datasource'; import SearchField from './SearchField'; import { getFilteredTags } from './utils'; -const getStyles = () => ({ - vertical: css` - display: flex; - flex-direction: column; - gap: 0.25rem; - `, - horizontal: css` - display: flex; - flex-direction: row; - gap: 1rem; - `, +const getStyles = (theme: GrafanaTheme2) => ({ + vertical: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.25), + }), + horizontal: css({ + display: 'flex', + flexDirection: 'row', + gap: theme.spacing(1), + }), + addTag: css({ + marginLeft: theme.spacing(1), + }), }); interface Props { @@ -30,10 +34,11 @@ interface Props { deleteFilter: (f: TraceqlFilter) => void; filters: TraceqlFilter[]; datasource: TempoDatasource; - setError: (error: FetchError) => void; + setError: (error: FetchError | null) => void; staticTags: Array<string | undefined>; isTagsLoading: boolean; hideValues?: boolean; + requireTagAndValue?: boolean; query: string; } const TagsInput = ({ @@ -45,10 +50,10 @@ const TagsInput = ({ staticTags, isTagsLoading, hideValues, + requireTagAndValue, query, }: Props) => { const styles = useStyles2(getStyles); - const generateId = () => uuidv4().slice(0, 8); const handleOnAdd = useCallback( () => updateFilter({ id: generateId(), operator: '=', scope: TraceqlSearchScope.Span }), [updateFilter] @@ -65,6 +70,11 @@ const TagsInput = ({ return getFilteredTags(tags, staticTags); }; + const validInput = (f: TraceqlFilter) => { + // If value is removed from the filter, it can be set as an empty array + return requireTagAndValue ? f.tag && f.value && f.value.length > 0 : f.tag; + }; + return ( <div className={styles.vertical}> {filters?.map((f, i) => ( @@ -76,13 +86,28 @@ const TagsInput = ({ updateFilter={updateFilter} tags={getTags(f)} isTagsLoading={isTagsLoading} - deleteFilter={deleteFilter} - allowDelete={true} hideValue={hideValues} query={query} /> - {i === filters.length - 1 && ( - <AccessoryButton variant={'secondary'} icon={'plus'} onClick={handleOnAdd} title={'Add tag'} /> + {(validInput(f) || filters.length > 1) && ( + <AccessoryButton + aria-label={`Remove tag with ID ${f.id}`} + variant={'secondary'} + icon={'times'} + onClick={() => deleteFilter?.(f)} + tooltip={'Remove tag'} + /> + )} + {validInput(f) && i === filters.length - 1 && ( + <span className={styles.addTag}> + <AccessoryButton + aria-label="Add tag" + variant={'secondary'} + icon={'plus'} + onClick={handleOnAdd} + tooltip={'Add tag'} + /> + </span> )} </div> ))} @@ -91,3 +116,5 @@ const TagsInput = ({ }; export default TagsInput; + +export const generateId = () => uuidv4().slice(0, 8); diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx index 97405e906d523..262582da68c83 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx @@ -1,14 +1,13 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { initTemplateSrv } from 'test/helpers/initTemplateSrv'; +import React, { useState } from 'react'; import { config } from '@grafana/runtime'; import { TraceqlSearchScope } from '../dataquery.gen'; import { TempoDatasource } from '../datasource'; import TempoLanguageProvider from '../language_provider'; +import { initTemplateSrv } from '../test_utils'; import { TempoQuery } from '../types'; import TraceQLSearch from './TraceQLSearch'; @@ -43,7 +42,13 @@ jest.mock('../language_provider', () => { }); describe('TraceQLSearch', () => { - initTemplateSrv('key', []); + const expectedValues = { + interpolationVar: 'interpolationText', + interpolationText: 'interpolationText', + interpolationVarWithPipe: 'interpolationTextOne|interpolationTextTwo', + scopedInterpolationText: 'scopedInterpolationText', + }; + initTemplateSrv([{ name: 'templateVariable1' }, { name: 'templateVariable2' }], expectedValues); let user: ReturnType<typeof userEvent.setup>; @@ -70,6 +75,7 @@ describe('TraceQLSearch', () => { const onChange = (q: TempoQuery) => { query = q; }; + const onClearResults = jest.fn(); beforeEach(() => { jest.useFakeTimers(); @@ -82,8 +88,66 @@ describe('TraceQLSearch', () => { jest.useRealTimers(); }); + it('should only show add/remove tag when necessary', async () => { + const TraceQLSearchWithProps = () => { + const [query, setQuery] = useState<TempoQuery>({ + refId: 'A', + queryType: 'traceqlSearch', + key: 'Q-595a9bbc-2a25-49a7-9249-a52a0a475d83-0', + filters: [], + }); + return ( + <TraceQLSearch + datasource={datasource} + query={query} + onChange={(q: TempoQuery) => setQuery(q)} + onClearResults={onClearResults} + /> + ); + }; + render(<TraceQLSearchWithProps />); + + await act(async () => { + expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the default tag, so no need to add another one + expect(screen.queryAllByLabelText('Remove tag').length).toBe(0); // mot filled in the default tag, so no values to remove + expect(screen.getAllByText('Select tag').length).toBe(1); + }); + + await user.click(screen.getByText('Select tag')); + jest.advanceTimersByTime(1000); + await user.click(screen.getByText('foo')); + jest.advanceTimersByTime(1000); + await user.click(screen.getAllByText('Select value')[2]); + jest.advanceTimersByTime(1000); + await user.click(screen.getByText('driver')); + jest.advanceTimersByTime(1000); + await act(async () => { + expect(screen.getAllByLabelText('Add tag').length).toBe(1); + expect(screen.getAllByLabelText(/Remove tag/).length).toBe(1); + }); + + await user.click(screen.getByLabelText('Add tag')); + jest.advanceTimersByTime(1000); + expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the new tag, so no need to add another one + expect(screen.getAllByLabelText(/Remove tag/).length).toBe(2); // one for each tag + + await user.click(screen.getAllByLabelText(/Remove tag/)[1]); + jest.advanceTimersByTime(1000); + expect(screen.queryAllByLabelText('Add tag').length).toBe(1); // filled in the default tag, so can add another one + expect(screen.queryAllByLabelText(/Remove tag/).length).toBe(1); // filled in the default tag, so can remove values + + await user.click(screen.getAllByLabelText(/Remove tag/)[0]); + await act(async () => { + jest.advanceTimersByTime(1000); + expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the default tag, so no need to add another one + expect(screen.queryAllByLabelText(/Remove tag/).length).toBe(0); // mot filled in the default tag, so no values to remove + }); + }); + it('should update operator when new value is selected in operator input', async () => { - const { container } = render(<TraceQLSearch datasource={datasource} query={query} onChange={onChange} />); + const { container } = render( + <TraceQLSearch datasource={datasource} query={query} onChange={onChange} onClearResults={onClearResults} /> + ); const minDurationOperator = container.querySelector(`input[aria-label="select min-duration operator"]`); expect(minDurationOperator).not.toBeNull(); @@ -101,7 +165,9 @@ describe('TraceQLSearch', () => { }); it('should add new filter when new value is selected in the service name section', async () => { - const { container } = render(<TraceQLSearch datasource={datasource} query={query} onChange={onChange} />); + const { container } = render( + <TraceQLSearch datasource={datasource} query={query} onChange={onChange} onClearResults={onClearResults} /> + ); const serviceNameValue = container.querySelector(`input[aria-label="select service-name value"]`); expect(serviceNameValue).not.toBeNull(); expect(serviceNameValue).toBeInTheDocument(); @@ -136,7 +202,9 @@ describe('TraceQLSearch', () => { } as TempoDatasource; datasource.languageProvider = new TempoLanguageProvider(datasource); await act(async () => { - const { container } = render(<TraceQLSearch datasource={datasource} query={query} onChange={onChange} />); + const { container } = render( + <TraceQLSearch datasource={datasource} query={query} onChange={onChange} onClearResults={onClearResults} /> + ); const serviceNameValue = container.querySelector(`input[aria-label="select service-name value"]`); expect(serviceNameValue).toBeNull(); expect(serviceNameValue).not.toBeInTheDocument(); @@ -145,7 +213,9 @@ describe('TraceQLSearch', () => { it('should not render group by when feature toggle is not enabled', async () => { await waitFor(() => { - render(<TraceQLSearch datasource={datasource} query={query} onChange={onChange} />); + render( + <TraceQLSearch datasource={datasource} query={query} onChange={onChange} onClearResults={onClearResults} /> + ); const groupBy = screen.queryByText('Aggregate by'); expect(groupBy).toBeNull(); expect(groupBy).not.toBeInTheDocument(); @@ -155,7 +225,9 @@ describe('TraceQLSearch', () => { it('should render group by when feature toggle enabled', async () => { config.featureToggles.metricsSummary = true; await waitFor(() => { - render(<TraceQLSearch datasource={datasource} query={query} onChange={onChange} />); + render( + <TraceQLSearch datasource={datasource} query={query} onChange={onChange} onClearResults={onClearResults} /> + ); const groupBy = screen.queryByText('Aggregate by'); expect(groupBy).not.toBeNull(); expect(groupBy).toBeInTheDocument(); diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx index d65f7b28f15c0..548cc67773e84 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx @@ -1,15 +1,12 @@ import { css } from '@emotion/css'; import React, { useCallback, useEffect, useState } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; -import { EditorRow } from '@grafana/experimental'; -import { config, FetchError, getTemplateSrv } from '@grafana/runtime'; -import { Alert, HorizontalGroup, useStyles2 } from '@grafana/ui'; - -import { createErrorNotification } from '../../../../core/copy/appNotification'; -import { notifyApp } from '../../../../core/reducers/appNotification'; -import { dispatch } from '../../../../store/store'; -import { RawQuery } from '../../prometheus/querybuilder/shared/RawQuery'; +import { CoreApp, GrafanaTheme2 } from '@grafana/data'; +import { TemporaryAlert } from '@grafana/o11y-ds-frontend'; +import { config, FetchError, getTemplateSrv, reportInteraction } from '@grafana/runtime'; +import { Alert, Button, HorizontalGroup, Select, useStyles2 } from '@grafana/ui'; + +import { RawQuery } from '../_importedDependencies/datasources/prometheus/RawQuery'; import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen'; import { TempoDatasource } from '../datasource'; import { TempoQueryBuilderOptions } from '../traceql/TempoQueryBuilderOptions'; @@ -28,12 +25,15 @@ interface Props { query: TempoQuery; onChange: (value: TempoQuery) => void; onBlur?: () => void; + onClearResults: () => void; + app?: CoreApp; } const hardCodedFilterIds = ['min-duration', 'max-duration', 'status']; -const TraceQLSearch = ({ datasource, query, onChange }: Props) => { +const TraceQLSearch = ({ datasource, query, onChange, onClearResults, app }: Props) => { const styles = useStyles2(getStyles); + const [alertText, setAlertText] = useState<string>(); const [error, setError] = useState<Error | FetchError | null>(null); const [isTagsLoading, setIsTagsLoading] = useState(true); @@ -72,14 +72,15 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => { try { await datasource.languageProvider.start(); setIsTagsLoading(false); + setAlertText(undefined); } catch (error) { if (error instanceof Error) { - dispatch(notifyApp(createErrorNotification('Error', error))); + setAlertText(`Error: ${error.message}`); } } }; fetchTags(); - }, [datasource]); + }, [datasource, setAlertText]); useEffect(() => { // Initialize state with configured static filters that already have a value from the config @@ -95,12 +96,15 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => { // filter out tags that already exist in the static fields const staticTags = datasource.search?.filters?.map((f) => f.tag) || []; staticTags.push('duration'); + staticTags.push('traceDuration'); // Dynamic filters are all filters that don't match the ID of a filter in the datasource configuration // The duration and status fields are a special case since its selector is hard-coded const dynamicFilters = (query.filters || []).filter( (f) => - !hardCodedFilterIds.includes(f.id) && (datasource.search?.filters?.findIndex((sf) => sf.id === f.id) || 0) === -1 + !hardCodedFilterIds.includes(f.id) && + (datasource.search?.filters?.findIndex((sf) => sf.id === f.id) || 0) === -1 && + f.id !== 'duration-type' ); return ( @@ -147,13 +151,30 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => { hideScope={true} hideTag={true} query={traceQlQuery} + isMulti={false} + allowCustomValue={false} /> </InlineSearchField> <InlineSearchField - label={'Span Duration'} - tooltip="The span duration, i.e. end - start time of the span. Accepted units are ns, ms, s, m, h" + label={'Duration'} + tooltip="The trace or span duration, i.e. end - start time of the trace/span. Accepted units are ns, ms, s, m, h" > - <HorizontalGroup spacing={'sm'}> + <HorizontalGroup spacing={'none'}> + <Select + options={[ + { label: 'span', value: 'span' }, + { label: 'trace', value: 'trace' }, + ]} + value={findFilter('duration-type')?.value ?? 'span'} + onChange={(v) => { + const filter = findFilter('duration-type') || { + id: 'duration-type', + value: 'span', + }; + updateFilter({ ...filter, value: v?.value }); + }} + aria-label={'duration type'} + /> <DurationInput filter={ findFilter('min-duration') || { @@ -190,15 +211,37 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => { staticTags={staticTags} isTagsLoading={isTagsLoading} query={traceQlQuery} + requireTagAndValue={true} /> </InlineSearchField> {config.featureToggles.metricsSummary && ( <GroupByField datasource={datasource} onChange={onChange} query={query} isTagsLoading={isTagsLoading} /> )} </div> - <EditorRow> + <div className={styles.rawQueryContainer}> <RawQuery query={templateSrv.replace(traceQlQuery)} lang={{ grammar: traceqlGrammar, name: 'traceql' }} /> - </EditorRow> + <Button + variant="secondary" + size="sm" + onClick={() => { + reportInteraction('grafana_traces_copy_to_traceql_clicked', { + app: app ?? '', + grafana_version: config.buildInfo.version, + location: 'search_tab', + }); + + onClearResults(); + const traceQlQuery = generateQueryFromFilters(query.filters || []); + onChange({ + ...query, + query: traceQlQuery, + queryType: 'traceql', + }); + }} + > + Edit in TraceQL + </Button> + </div> <TempoQueryBuilderOptions onChange={onChange} query={query} /> </div> {error ? ( @@ -207,6 +250,7 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => { configure it in the <a href={`/datasources/edit/${datasource.uid}`}>datasource settings</a>. </Alert> ) : null} + {alertText && <TemporaryAlert severity={'error'} text={alertText} />} </> ); }; @@ -214,14 +258,21 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => { export default TraceQLSearch; const getStyles = (theme: GrafanaTheme2) => ({ - alert: css` - max-width: 75ch; - margin-top: ${theme.spacing(2)}; - `, - container: css` - display: flex; - gap: 4px; - flex-wrap: wrap; - flex-direction: column; - `, + alert: css({ + maxWidth: '75ch', + marginTop: theme.spacing(2), + }), + container: css({ + display: 'flex', + gap: '4px', + flexWrap: 'wrap', + flexDirection: 'column', + }), + rawQueryContainer: css({ + alignItems: 'center', + backgroundColor: theme.colors.background.secondary, + display: 'flex', + justifyContent: 'space-between', + padding: theme.spacing(1), + }), }); diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.test.ts b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.test.ts index 01744a1de7d4b..98efdc7182321 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.test.ts +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.test.ts @@ -21,6 +21,32 @@ describe('generateQueryFromFilters generates the correct query for', () => { expect(generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: 'foovalue' }])).toBe('{}'); }); + describe('generates correct query for duration when duration type', () => { + it('not set', () => { + expect( + generateQueryFromFilters([ + { id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration', value: '100ms' }, + ]) + ).toBe('{duration>100ms}'); + }); + it('set to span', () => { + expect( + generateQueryFromFilters([ + { id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration', value: '100ms' }, + { id: 'duration-type', value: 'span' }, + ]) + ).toBe('{duration>100ms}'); + }); + it('set to trace', () => { + expect( + generateQueryFromFilters([ + { id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration', value: '100ms' }, + { id: 'duration-type', value: 'trace' }, + ]) + ).toBe('{traceDuration>100ms}'); + }); + }); + it('a field with tag, operator and tag', () => { expect(generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: 'foovalue', operator: '=' }])).toBe( '{.footag=foovalue}' diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.ts b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.ts index 9406a1c92e2ac..2cb2a79f3ecaf 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.ts +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.ts @@ -9,7 +9,7 @@ import { Scope } from '../types'; export const generateQueryFromFilters = (filters: TraceqlFilter[]) => { return `{${filters .filter((f) => f.tag && f.operator && f.value?.length) - .map((f) => `${scopeHelper(f)}${f.tag}${f.operator}${valueHelper(f)}`) + .map((f) => `${scopeHelper(f)}${tagHelper(f, filters)}${f.operator}${valueHelper(f)}`) .join(' && ')}}`; }; @@ -31,6 +31,16 @@ const scopeHelper = (f: TraceqlFilter) => { (f.scope === TraceqlSearchScope.Resource || f.scope === TraceqlSearchScope.Span ? f.scope?.toLowerCase() : '') + '.' ); }; +const tagHelper = (f: TraceqlFilter, filters: TraceqlFilter[]) => { + if (f.tag === 'duration') { + const durationType = filters.find((f) => f.id === 'duration-type'); + if (durationType) { + return durationType.value === 'trace' ? 'traceDuration' : 'duration'; + } + return f.tag; + } + return f.tag; +}; export const filterScopedTag = (f: TraceqlFilter) => { return scopeHelper(f) + f.tag; diff --git a/public/app/plugins/datasource/tempo/ServiceGraphSection.tsx b/public/app/plugins/datasource/tempo/ServiceGraphSection.tsx index b1cbd7c337eba..6b770fb5c1949 100644 --- a/public/app/plugins/datasource/tempo/ServiceGraphSection.tsx +++ b/public/app/plugins/datasource/tempo/ServiceGraphSection.tsx @@ -5,10 +5,9 @@ import useAsync from 'react-use/lib/useAsync'; import { GrafanaTheme2 } from '@grafana/data'; import { Alert, InlineField, InlineFieldRow, useStyles2 } from '@grafana/ui'; -import { AdHocFilter } from '../../../features/variables/adhoc/picker/AdHocFilter'; -import { AdHocVariableFilter } from '../../../features/variables/types'; -import { PrometheusDatasource } from '../prometheus/datasource'; - +import { AdHocFilter } from './_importedDependencies/components/AdHocFilter/AdHocFilter'; +import { AdHocVariableFilter } from './_importedDependencies/components/AdHocFilter/types'; +import { PrometheusDatasource } from './_importedDependencies/datasources/prometheus/types'; import { TempoQuery } from './types'; import { getDS } from './utils'; @@ -68,7 +67,9 @@ export function ServiceGraphSection({ ); } - const filters = queryToFilter(query.serviceMapQuery || ''); + const filters = queryToFilter( + (Array.isArray(query.serviceMapQuery) ? query.serviceMapQuery[0] : query.serviceMapQuery) || '' + ); return ( <div> @@ -152,12 +153,12 @@ function filtersToQuery(filters: AdHocVariableFilter[]): string { } const getStyles = (theme: GrafanaTheme2) => ({ - alert: css` - max-width: 75ch; - margin-top: ${theme.spacing(2)}; - `, - link: css` - color: ${theme.colors.text.link}; - text-decoration: underline; - `, + alert: css({ + maxWidth: '75ch', + marginTop: theme.spacing(2), + }), + link: css({ + color: theme.colors.text.link, + textDecoration: 'underline', + }), }); diff --git a/public/app/plugins/datasource/tempo/VariableQueryEditor.test.tsx b/public/app/plugins/datasource/tempo/VariableQueryEditor.test.tsx index 57bf7f1d1ace0..3b843ed7a8106 100644 --- a/public/app/plugins/datasource/tempo/VariableQueryEditor.test.tsx +++ b/public/app/plugins/datasource/tempo/VariableQueryEditor.test.tsx @@ -1,7 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; import { TemplateSrv } from '@grafana/runtime'; @@ -11,6 +10,7 @@ import { TempoVariableQueryEditorProps, TempoVariableQueryType, } from './VariableQueryEditor'; +import { selectOptionInTest } from './_importedDependencies/test/helpers/selectOptionInTest'; import { createTempoDatasource } from './mocks'; const refId = 'TempoDatasourceVariableQueryEditor-VariableQuery'; diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/README.md b/public/app/plugins/datasource/tempo/_importedDependencies/README.md new file mode 100644 index 0000000000000..f08e12e6efecc --- /dev/null +++ b/public/app/plugins/datasource/tempo/_importedDependencies/README.md @@ -0,0 +1,3 @@ +This directory contains dependencies that we duplicated from Grafana core while working on the decoupling of Tempo from such core. +The long-term goal is to move these files away from here by replacing them with packages. +As such, they are only temporary and meant to be used internally to this package, please avoid using them for example as dependencies (imports) in other data source plugins. diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilter.tsx b/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilter.tsx new file mode 100644 index 0000000000000..8e3a34183ccde --- /dev/null +++ b/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilter.tsx @@ -0,0 +1,104 @@ +import React, { PureComponent, ReactNode } from 'react'; + +import { AdHocVariableFilter, DataSourceRef, SelectableValue } from '@grafana/data'; +import { Segment } from '@grafana/ui'; + +import { AdHocFilterBuilder } from './AdHocFilterBuilder'; +import { REMOVE_FILTER_KEY } from './AdHocFilterKey'; +import { AdHocFilterRenderer } from './AdHocFilterRenderer'; +import { ConditionSegment } from './ConditionSegment'; + +interface Props { + datasource: DataSourceRef | null; + filters: AdHocVariableFilter[]; + baseFilters?: AdHocVariableFilter[]; + addFilter: (filter: AdHocVariableFilter) => void; + removeFilter: (index: number) => void; + changeFilter: (index: number, newFilter: AdHocVariableFilter) => void; + disabled?: boolean; +} + +/** + * Simple filtering component that automatically uses datasource APIs to get available labels and its values, for + * dynamic visual filtering without need for much setup. Instead of having single onChange prop this reports all the + * change events with separate props so it is usable with AdHocPicker. + * + * Note: There isn't API on datasource to suggest the operators here so that is hardcoded to use prometheus style + * operators. Also filters are assumed to be joined with `AND` operator, which is also hardcoded. + */ +export class AdHocFilter extends PureComponent<Props> { + onChange = (index: number, prop: string) => (key: SelectableValue<string | null>) => { + const { filters } = this.props; + const { value } = key; + + if (key.value === REMOVE_FILTER_KEY) { + return this.props.removeFilter(index); + } + + return this.props.changeFilter(index, { + ...filters[index], + [prop]: value, + }); + }; + + appendFilterToVariable = (filter: AdHocVariableFilter) => { + this.props.addFilter(filter); + }; + + render() { + const { filters, disabled } = this.props; + + return ( + <div className="gf-form-inline"> + {this.renderFilters(filters, disabled)} + + {!disabled && ( + <AdHocFilterBuilder + datasource={this.props.datasource!} + appendBefore={filters.length > 0 ? <ConditionSegment label="AND" /> : null} + onCompleted={this.appendFilterToVariable} + allFilters={this.getAllFilters()} + /> + )} + </div> + ); + } + + getAllFilters() { + if (this.props.baseFilters) { + return this.props.baseFilters.concat(this.props.filters); + } + + return this.props.filters; + } + + renderFilters(filters: AdHocVariableFilter[], disabled?: boolean) { + if (filters.length === 0 && disabled) { + return <Segment disabled={disabled} value="No filters" options={[]} onChange={() => {}} />; + } + + return filters.reduce((segments: ReactNode[], filter, index) => { + if (segments.length > 0) { + segments.push(<ConditionSegment label="AND" key={`condition-${index}`} />); + } + segments.push(this.renderFilterSegments(filter, index, disabled)); + return segments; + }, []); + } + + renderFilterSegments(filter: AdHocVariableFilter, index: number, disabled?: boolean) { + return ( + <React.Fragment key={`filter-${index}`}> + <AdHocFilterRenderer + disabled={disabled} + datasource={this.props.datasource!} + filter={filter} + onKeyChange={this.onChange(index, 'key')} + onOperatorChange={this.onChange(index, 'operator')} + onValueChange={this.onChange(index, 'value')} + allFilters={this.getAllFilters()} + /> + </React.Fragment> + ); + } +} diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterBuilder.tsx b/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterBuilder.tsx new file mode 100644 index 0000000000000..956b25682019d --- /dev/null +++ b/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterBuilder.tsx @@ -0,0 +1,75 @@ +import i18n from 'i18next'; +import React, { useCallback, useState } from 'react'; + +import { AdHocVariableFilter, DataSourceRef, SelectableValue } from '@grafana/data'; + +import { AdHocFilterKey, REMOVE_FILTER_KEY } from './AdHocFilterKey'; +import { AdHocFilterRenderer } from './AdHocFilterRenderer'; + +interface Props { + datasource: DataSourceRef; + onCompleted: (filter: AdHocVariableFilter) => void; + appendBefore?: React.ReactNode; + allFilters: AdHocVariableFilter[]; +} + +// Reassign t() so i18next-parser doesn't warn on dynamic key, and we can have 'failOnWarnings' enabled +const tFunc = i18n.t; + +// import { t } from 'app/core/internationalization'; +export const t = (id: string, defaultMessage: string, values?: Record<string, unknown>) => { + return tFunc(id, defaultMessage, values); +}; + +export const AdHocFilterBuilder = ({ datasource, appendBefore, onCompleted, allFilters }: Props) => { + const [key, setKey] = useState<string | null>(null); + const [operator, setOperator] = useState<string>('='); + + const onKeyChanged = useCallback( + (item: SelectableValue<string | null>) => { + if (item.value !== REMOVE_FILTER_KEY) { + setKey(item.value ?? ''); + return; + } + setKey(null); + }, + [setKey] + ); + + const onOperatorChanged = useCallback( + (item: SelectableValue<string>) => setOperator(item.value ?? ''), + [setOperator] + ); + + const onValueChanged = useCallback( + (item: SelectableValue<string>) => { + onCompleted({ + value: item.value ?? '', + operator: operator, + key: key!, + }); + setKey(null); + setOperator('='); + }, + [onCompleted, operator, key] + ); + + if (key === null) { + return <AdHocFilterKey datasource={datasource} filterKey={key} onChange={onKeyChanged} allFilters={allFilters} />; + } + + return ( + <React.Fragment key="filter-builder"> + {appendBefore} + <AdHocFilterRenderer + datasource={datasource} + filter={{ key, value: '', operator }} + placeHolder={t('variable.adhoc.placeholder', 'Select value')} + onKeyChange={onKeyChanged} + onOperatorChange={onOperatorChanged} + onValueChange={onValueChanged} + allFilters={allFilters} + /> + </React.Fragment> + ); +}; diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterKey.tsx b/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterKey.tsx new file mode 100644 index 0000000000000..5c0fc5bcf4d9b --- /dev/null +++ b/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterKey.tsx @@ -0,0 +1,82 @@ +import React, { ReactElement } from 'react'; + +import { AdHocVariableFilter, DataSourceRef, SelectableValue } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { Icon, SegmentAsync } from '@grafana/ui'; + +interface Props { + datasource: DataSourceRef; + filterKey: string | null; + onChange: (item: SelectableValue<string | null>) => void; + allFilters: AdHocVariableFilter[]; + disabled?: boolean; +} + +const MIN_WIDTH = 90; +export const AdHocFilterKey = ({ datasource, onChange, disabled, filterKey, allFilters }: Props) => { + const loadKeys = () => fetchFilterKeys(datasource, filterKey, allFilters); + const loadKeysWithRemove = () => fetchFilterKeysWithRemove(datasource, filterKey, allFilters); + + if (filterKey === null) { + return ( + <div className="gf-form" data-testid="AdHocFilterKey-add-key-wrapper"> + <SegmentAsync + disabled={disabled} + className="query-segment-key" + Component={plusSegment} + value={filterKey} + onChange={onChange} + loadOptions={loadKeys} + inputMinWidth={MIN_WIDTH} + /> + </div> + ); + } + + return ( + <div className="gf-form" data-testid="AdHocFilterKey-key-wrapper"> + <SegmentAsync + disabled={disabled} + className="query-segment-key" + value={filterKey} + onChange={onChange} + loadOptions={loadKeysWithRemove} + inputMinWidth={MIN_WIDTH} + /> + </div> + ); +}; + +export const REMOVE_FILTER_KEY = '-- remove filter --'; +const REMOVE_VALUE = { label: REMOVE_FILTER_KEY, value: REMOVE_FILTER_KEY }; + +const plusSegment: ReactElement = ( + <span className="gf-form-label query-part" aria-label="Add Filter"> + <Icon name="plus" /> + </span> +); + +const fetchFilterKeys = async ( + datasource: DataSourceRef, + currentKey: string | null, + allFilters: AdHocVariableFilter[] +): Promise<Array<SelectableValue<string>>> => { + const ds = await getDataSourceSrv().get(datasource); + + if (!ds || !ds.getTagKeys) { + return []; + } + + const otherFilters = allFilters.filter((f) => f.key !== currentKey); + const metrics = await ds.getTagKeys({ filters: otherFilters }); + return metrics.map((m) => ({ label: m.text, value: m.text })); +}; + +const fetchFilterKeysWithRemove = async ( + datasource: DataSourceRef, + currentKey: string | null, + allFilters: AdHocVariableFilter[] +): Promise<Array<SelectableValue<string>>> => { + const keys = await fetchFilterKeys(datasource, currentKey, allFilters); + return [REMOVE_VALUE, ...keys]; +}; diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterRenderer.tsx b/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterRenderer.tsx new file mode 100644 index 0000000000000..1a5c8fd0054a7 --- /dev/null +++ b/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterRenderer.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import { AdHocVariableFilter, DataSourceRef, SelectableValue } from '@grafana/data'; + +import { AdHocFilterKey } from './AdHocFilterKey'; +import { AdHocFilterValue } from './AdHocFilterValue'; +import { OperatorSegment } from './OperatorSegment'; + +interface Props { + datasource: DataSourceRef; + filter: AdHocVariableFilter; + allFilters: AdHocVariableFilter[]; + onKeyChange: (item: SelectableValue<string | null>) => void; + onOperatorChange: (item: SelectableValue<string>) => void; + onValueChange: (item: SelectableValue<string>) => void; + placeHolder?: string; + getTagKeysOptions?: any; + disabled?: boolean; +} + +export const AdHocFilterRenderer = ({ + datasource, + filter: { key, operator, value }, + onKeyChange, + onOperatorChange, + onValueChange, + placeHolder, + allFilters, + disabled, +}: Props) => { + return ( + <> + <AdHocFilterKey + disabled={disabled} + datasource={datasource} + filterKey={key} + onChange={onKeyChange} + allFilters={allFilters} + /> + <div className="gf-form"> + <OperatorSegment disabled={disabled} value={operator} onChange={onOperatorChange} /> + </div> + <AdHocFilterValue + disabled={disabled} + datasource={datasource} + filterKey={key} + filterValue={value} + allFilters={allFilters} + onChange={onValueChange} + placeHolder={placeHolder} + /> + </> + ); +}; diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterValue.tsx b/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterValue.tsx new file mode 100644 index 0000000000000..a8034bf165457 --- /dev/null +++ b/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterValue.tsx @@ -0,0 +1,68 @@ +import React from 'react'; + +import { + AdHocVariableFilter, + DataSourceRef, + MetricFindValue, + SelectableValue, + getDefaultTimeRange, +} from '@grafana/data'; +// import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { SegmentAsync } from '@grafana/ui'; + +interface Props { + datasource: DataSourceRef; + filterKey: string; + filterValue?: string; + onChange: (item: SelectableValue<string>) => void; + placeHolder?: string; + disabled?: boolean; + allFilters: AdHocVariableFilter[]; +} + +export const AdHocFilterValue = ({ + datasource, + disabled, + onChange, + filterKey, + filterValue, + placeHolder, + allFilters, +}: Props) => { + const loadValues = () => fetchFilterValues(datasource, filterKey, allFilters); + + return ( + <div className="gf-form" data-testid="AdHocFilterValue-value-wrapper"> + <SegmentAsync + className="query-segment-value" + disabled={disabled} + placeholder={placeHolder} + value={filterValue} + onChange={onChange} + loadOptions={loadValues} + /> + </div> + ); +}; + +const fetchFilterValues = async ( + datasource: DataSourceRef, + key: string, + allFilters: AdHocVariableFilter[] +): Promise<Array<SelectableValue<string>>> => { + const ds = await getDataSourceSrv().get(datasource); + + if (!ds || !ds.getTagValues) { + return []; + } + + // const timeRange = getTimeSrv().timeRange(); + // As https://github.com/grafana/grafana/pull/76118/files#diff-260d46415915a2e3e7d294e313bd128666e9f0868aa94d8aee4d4a24a060b542L27-R26 + const timeRange = getDefaultTimeRange(); + + // Filter out the current filter key from the list of all filters + const otherFilters = allFilters.filter((f) => f.key !== key); + const metrics = await ds.getTagValues({ key, filters: otherFilters, timeRange }); + return metrics.map((m: MetricFindValue) => ({ label: m.text, value: m.text })); +}; diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/ConditionSegment.tsx b/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/ConditionSegment.tsx new file mode 100644 index 0000000000000..4c47fd734330d --- /dev/null +++ b/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/ConditionSegment.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +interface Props { + label: string; +} + +export const ConditionSegment = ({ label }: Props) => { + return ( + <div className="gf-form"> + <span className="gf-form-label query-keyword">{label}</span> + </div> + ); +}; diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/OperatorSegment.tsx b/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/OperatorSegment.tsx new file mode 100644 index 0000000000000..b8646b466e617 --- /dev/null +++ b/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/OperatorSegment.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { Segment } from '@grafana/ui'; + +interface Props { + value: string; + onChange: (item: SelectableValue<string>) => void; + disabled?: boolean; +} + +const options = ['=', '!=', '<', '>', '=~', '!~'].map<SelectableValue<string>>((value) => ({ + label: value, + value, +})); + +export const OperatorSegment = ({ value, disabled, onChange }: Props) => { + return ( + <Segment + className="query-segment-operator" + value={value} + disabled={disabled} + options={options} + onChange={onChange} + /> + ); +}; diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/types.ts b/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/types.ts new file mode 100644 index 0000000000000..b7ab91300496b --- /dev/null +++ b/public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/types.ts @@ -0,0 +1,8 @@ +// import { AdHocVariableFilter } from '../../../features/variables/types'; +export interface AdHocVariableFilter { + key: string; + operator: string; + value: string; + /** @deprecated */ + condition?: string; +} diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/types.ts b/public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/types.ts new file mode 100644 index 0000000000000..dab95cafb7733 --- /dev/null +++ b/public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/types.ts @@ -0,0 +1,8 @@ +export interface QueryStats { + streams: number; + chunks: number; + bytes: number; + entries: number; + // The error message displayed in the UI when we cant estimate the size of the query. + message?: string; +} diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/QueryOptionGroup.tsx b/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/QueryOptionGroup.tsx new file mode 100644 index 0000000000000..d2187f26dde60 --- /dev/null +++ b/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/QueryOptionGroup.tsx @@ -0,0 +1,115 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import { useToggle } from 'react-use'; + +import { getValueFormat, GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { Collapse, Icon, Tooltip, useStyles2, Stack } from '@grafana/ui'; + +import { QueryStats } from '../loki/types'; + +export interface Props { + title: string; + collapsedInfo: string[]; + queryStats?: QueryStats | null; + children: React.ReactNode; +} + +export function QueryOptionGroup({ title, children, collapsedInfo, queryStats }: Props) { + const [isOpen, toggleOpen] = useToggle(false); + const styles = useStyles2(getStyles); + + return ( + <div className={styles.wrapper}> + <Collapse + className={styles.collapse} + collapsible + isOpen={isOpen} + onToggle={toggleOpen} + label={ + <Stack gap={0}> + <h6 className={styles.title}>{title}</h6> + {!isOpen && ( + <div className={styles.description}> + {collapsedInfo.map((x, i) => ( + <span key={i}>{x}</span> + ))} + </div> + )} + </Stack> + } + > + <div className={styles.body}>{children}</div> + </Collapse> + + {queryStats && config.featureToggles.lokiQuerySplitting && ( + <Tooltip content="Note: the query will be split into multiple parts and executed in sequence. Query limits will only apply each individual part."> + <Icon tabIndex={0} name="info-circle" className={styles.tooltip} size="sm" /> + </Tooltip> + )} + + {queryStats && <p className={styles.stats}>{generateQueryStats(queryStats)}</p>} + </div> + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + collapse: css({ + backgroundColor: 'unset', + border: 'unset', + marginBottom: 0, + + ['> button']: { + padding: theme.spacing(0, 1), + }, + }), + wrapper: css({ + width: '100%', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'baseline', + }), + title: css({ + flexGrow: 1, + overflow: 'hidden', + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.fontWeightMedium, + margin: 0, + }), + description: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.bodySmall.fontWeight, + paddingLeft: theme.spacing(2), + gap: theme.spacing(2), + display: 'flex', + }), + body: css({ + display: 'flex', + gap: theme.spacing(2), + flexWrap: 'wrap', + }), + stats: css({ + margin: '0px', + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + }), + tooltip: css({ + marginRight: theme.spacing(0.25), + }), + }; +}; + +const generateQueryStats = (queryStats: QueryStats) => { + if (queryStats.message) { + return queryStats.message; + } + + return `This query will process approximately ${convertUnits(queryStats)}.`; +}; + +const convertUnits = (queryStats: QueryStats): string => { + const { text, suffix } = getValueFormat('bytes')(queryStats.bytes, 1); + return text + suffix; +}; diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/RawQuery.tsx b/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/RawQuery.tsx new file mode 100644 index 0000000000000..4d1f727e9f369 --- /dev/null +++ b/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/RawQuery.tsx @@ -0,0 +1,37 @@ +import { css, cx } from '@emotion/css'; +import Prism, { Grammar } from 'prismjs'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data/src'; +import { useTheme2 } from '@grafana/ui/src'; + +export interface Props { + query: string; + lang: { + grammar: Grammar; + name: string; + }; + className?: string; +} +export function RawQuery({ query, lang, className }: Props) { + const theme = useTheme2(); + const styles = getStyles(theme); + const highlighted = Prism.highlight(query, lang.grammar, lang.name); + + return ( + <div + className={cx(styles.editorField, 'prism-syntax-highlight', className)} + aria-label="selector" + dangerouslySetInnerHTML={{ __html: highlighted }} + /> + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + editorField: css({ + fontFamily: theme.typography.fontFamilyMonospace, + fontSize: theme.typography.bodySmall.fontSize, + }), + }; +}; diff --git a/public/app/plugins/datasource/prometheus/dataquery.gen.ts b/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/dataquery.gen.ts similarity index 100% rename from public/app/plugins/datasource/prometheus/dataquery.gen.ts rename to public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/dataquery.gen.ts diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/language_utils.ts b/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/language_utils.ts new file mode 100644 index 0000000000000..456267f1f0f1c --- /dev/null +++ b/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/language_utils.ts @@ -0,0 +1,122 @@ +import { invert } from 'lodash'; +import { Token } from 'prismjs'; + +import { AbstractLabelOperator, AbstractLabelMatcher, AbstractQuery } from '@grafana/data'; + +export const SUGGESTIONS_LIMIT = 10000; + +const FromPromLikeMap: Record<string, AbstractLabelOperator> = { + '=': AbstractLabelOperator.Equal, + '!=': AbstractLabelOperator.NotEqual, + '=~': AbstractLabelOperator.EqualRegEx, + '!~': AbstractLabelOperator.NotEqualRegEx, +}; + +const ToPromLikeMap: Record<AbstractLabelOperator, string> = invert(FromPromLikeMap) as Record< + AbstractLabelOperator, + string +>; + +export function limitSuggestions(items: string[]) { + return items.slice(0, SUGGESTIONS_LIMIT); +} + +export function processLabels(labels: Array<{ [key: string]: string }>, withName = false) { + // For processing we are going to use sets as they have significantly better performance than arrays + // After we process labels, we will convert sets to arrays and return object with label values in arrays + const valueSet: { [key: string]: Set<string> } = {}; + labels.forEach((label) => { + const { __name__, ...rest } = label; + if (withName) { + valueSet['__name__'] = valueSet['__name__'] || new Set(); + if (!valueSet['__name__'].has(__name__)) { + valueSet['__name__'].add(__name__); + } + } + + Object.keys(rest).forEach((key) => { + if (!valueSet[key]) { + valueSet[key] = new Set(); + } + if (!valueSet[key].has(rest[key])) { + valueSet[key].add(rest[key]); + } + }); + }); + + // valueArray that we are going to return in the object + const valueArray: { [key: string]: string[] } = {}; + limitSuggestions(Object.keys(valueSet)).forEach((key) => { + valueArray[key] = limitSuggestions(Array.from(valueSet[key])); + }); + + return { values: valueArray, keys: Object.keys(valueArray) }; +} + +export function toPromLikeExpr(labelBasedQuery: AbstractQuery): string { + const expr = labelBasedQuery.labelMatchers + .map((selector: AbstractLabelMatcher) => { + const operator = ToPromLikeMap[selector.operator]; + if (operator) { + return `${selector.name}${operator}"${selector.value}"`; + } else { + return ''; + } + }) + .filter((e: string) => e !== '') + .join(', '); + + return expr ? `{${expr}}` : ''; +} + +function getMaybeTokenStringContent(token: Token): string { + if (typeof token.content === 'string') { + return token.content; + } + + return ''; +} + +export function extractLabelMatchers(tokens: Array<string | Token>): AbstractLabelMatcher[] { + const labelMatchers: AbstractLabelMatcher[] = []; + + for (const token of tokens) { + if (!(token instanceof Token)) { + continue; + } + + if (token.type === 'context-labels') { + let labelKey = ''; + let labelValue = ''; + let labelOperator = ''; + + const contentTokens = Array.isArray(token.content) ? token.content : [token.content]; + + for (let currentToken of contentTokens) { + if (typeof currentToken === 'string') { + let currentStr: string; + currentStr = currentToken; + if (currentStr === '=' || currentStr === '!=' || currentStr === '=~' || currentStr === '!~') { + labelOperator = currentStr; + } + } else if (currentToken instanceof Token) { + switch (currentToken.type) { + case 'label-key': + labelKey = getMaybeTokenStringContent(currentToken); + break; + case 'label-value': + labelValue = getMaybeTokenStringContent(currentToken); + labelValue = labelValue.substring(1, labelValue.length - 1); + const labelComparator = FromPromLikeMap[labelOperator]; + if (labelComparator) { + labelMatchers.push({ name: labelKey, operator: labelComparator, value: labelValue }); + } + break; + } + } + } + } + } + + return labelMatchers; +} diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/types.ts b/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/types.ts new file mode 100644 index 0000000000000..abbe1ce1905a9 --- /dev/null +++ b/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/types.ts @@ -0,0 +1,165 @@ +import { Observable } from 'rxjs'; + +import { + DataQueryRequest, + DataQueryResponse, + DataSourceGetTagKeysOptions, + DataSourceJsonData, + MetricFindValue, +} from '@grafana/data'; +import { DataQuery } from '@grafana/schema'; + +import { Prometheus as GenPromQuery } from './dataquery.gen'; + +// import { QueryBuilderLabelFilter, QueryEditorMode } from './querybuilder/shared/types'; +export interface QueryBuilderLabelFilter { + label: string; + op: string; + value: string; +} + +export enum QueryEditorMode { + Code = 'code', + Builder = 'builder', +} + +export interface PromQuery extends GenPromQuery, DataQuery { + /** + * Timezone offset to align start & end time on backend + */ + utcOffsetSec?: number; + valueWithRefId?: boolean; + showingGraph?: boolean; + showingTable?: boolean; + hinting?: boolean; + interval?: string; + // store the metrics explorer additional settings + useBackend?: boolean; + disableTextWrap?: boolean; + fullMetaSearch?: boolean; + includeNullMetadata?: boolean; +} + +export enum PrometheusCacheLevel { + Low = 'Low', + Medium = 'Medium', + High = 'High', + None = 'None', +} + +export enum PromApplication { + Cortex = 'Cortex', + Mimir = 'Mimir', + Prometheus = 'Prometheus', + Thanos = 'Thanos', +} + +export interface PromOptions extends DataSourceJsonData { + timeInterval?: string; + queryTimeout?: string; + httpMethod?: string; + customQueryParameters?: string; + disableMetricsLookup?: boolean; + exemplarTraceIdDestinations?: ExemplarTraceIdDestination[]; + prometheusType?: PromApplication; + prometheusVersion?: string; + cacheLevel?: PrometheusCacheLevel; + defaultEditor?: QueryEditorMode; + incrementalQuerying?: boolean; + incrementalQueryOverlapWindow?: string; + disableRecordingRules?: boolean; + sigV4Auth?: boolean; + oauthPassThru?: boolean; +} + +export type ExemplarTraceIdDestination = { + name: string; + url?: string; + urlDisplayLabel?: string; + datasourceUid?: string; +}; + +export interface PromQueryRequest extends PromQuery { + step?: number; + requestId?: string; + start: number; + end: number; + headers?: any; +} + +export interface PromMetricsMetadataItem { + type: string; + help: string; + unit?: string; +} + +export interface PromMetricsMetadata { + [metric: string]: PromMetricsMetadataItem; +} + +export type PromValue = [number, any]; + +export interface PromMetric { + __name__?: string; + + [index: string]: any; +} + +export interface PromBuildInfoResponse { + data: { + application?: string; + version: string; + revision: string; + features?: { + ruler_config_api?: 'true' | 'false'; + alertmanager_config_api?: 'true' | 'false'; + query_sharding?: 'true' | 'false'; + federated_rules?: 'true' | 'false'; + }; + [key: string]: unknown; + }; + status: 'success'; +} + +/** + * Auto = query.legendFormat == '__auto' + * Verbose = query.legendFormat == null/undefined/'' + * Custom query.legendFormat.length > 0 && query.legendFormat !== '__auto' + */ +export enum LegendFormatMode { + Auto = '__auto', + Verbose = '__verbose', + Custom = '__custom', +} + +export enum PromVariableQueryType { + LabelNames, + LabelValues, + MetricNames, + VarQueryResult, + SeriesQuery, + ClassicQuery, +} + +export interface PromVariableQuery extends DataQuery { + query?: string; + expr?: string; + qryType?: PromVariableQueryType; + label?: string; + metric?: string; + varQuery?: string; + seriesQuery?: string; + labelFilters?: QueryBuilderLabelFilter[]; + match?: string; + classicQuery?: string; +} + +export type StandardPromVariableQuery = { + query: string; + refId: string; +}; + +export type PrometheusDatasource = { + getTagKeys(options: DataSourceGetTagKeysOptions): Promise<MetricFindValue[]>; + query(request: DataQueryRequest<PromQuery>): Observable<DataQueryResponse>; +}; diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/test/helpers/createFetchResponse.ts b/public/app/plugins/datasource/tempo/_importedDependencies/test/helpers/createFetchResponse.ts new file mode 100644 index 0000000000000..1f75026ed71f9 --- /dev/null +++ b/public/app/plugins/datasource/tempo/_importedDependencies/test/helpers/createFetchResponse.ts @@ -0,0 +1,15 @@ +import { FetchResponse } from '@grafana/runtime'; + +export function createFetchResponse<T>(data: T): FetchResponse<T> { + return { + data, + status: 200, + url: 'http://localhost:3000/api/ds/query', + config: { url: 'http://localhost:3000/api/ds/query' }, + type: 'basic', + statusText: 'Ok', + redirected: false, + headers: {} as unknown as Headers, + ok: true, + }; +} diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/test/helpers/selectOptionInTest.ts b/public/app/plugins/datasource/tempo/_importedDependencies/test/helpers/selectOptionInTest.ts new file mode 100644 index 0000000000000..62bd8c169491c --- /dev/null +++ b/public/app/plugins/datasource/tempo/_importedDependencies/test/helpers/selectOptionInTest.ts @@ -0,0 +1,8 @@ +import { waitFor } from '@testing-library/react'; +import { select } from 'react-select-event'; + +// Used to select an option or options from a Select in unit tests +export const selectOptionInTest = async ( + input: HTMLElement, + optionOrOptions: string | RegExp | Array<string | RegExp> +) => await waitFor(() => select(input, optionOrOptions, { container: document.body })); diff --git a/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx index 5b02f520b8713..9e8a3310e6221 100644 --- a/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx @@ -6,22 +6,22 @@ import { AdvancedHttpSettings, Auth, ConfigSection, + ConfigDescriptionLink, ConfigSubSection, ConnectionSettings, convertLegacyAuthProps, DataSourceDescription, } from '@grafana/experimental'; +import { + NodeGraphSection, + SpanBarSection, + TraceToLogsSection, + TraceToMetricsSection, + TraceToProfilesSection, +} from '@grafana/o11y-ds-frontend'; import { config } from '@grafana/runtime'; -import { SecureSocksProxySettings, useStyles2 } from '@grafana/ui'; -import { ConfigDescriptionLink } from 'app/core/components/ConfigDescriptionLink'; -import { Divider } from 'app/core/components/Divider'; -import { NodeGraphSection } from 'app/core/components/NodeGraphSettings'; -import { TraceToLogsSection } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; -import { TraceToMetricsSection } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; -import { TraceToProfilesSection } from 'app/core/components/TraceToProfiles/TraceToProfilesSettings'; -import { SpanBarSection } from 'app/features/explore/TraceView/components/settings/SpanBarSettings'; - -import { LokiSearchSettings } from './LokiSearchSettings'; +import { SecureSocksProxySettings, useStyles2, Divider, Stack } from '@grafana/ui'; + import { QuerySettings } from './QuerySettings'; import { ServiceGraphSettings } from './ServiceGraphSettings'; import { TraceQLSearchSettings } from './TraceQLSearchSettings'; @@ -39,10 +39,10 @@ export const ConfigEditor = ({ options, onOptionsChange }: Props) => { hasRequiredFields={false} /> - <Divider /> + <Divider spacing={4} /> <ConnectionSettings config={options} onChange={onOptionsChange} urlPlaceholder="http://localhost:3200" /> - <Divider /> + <Divider spacing={4} /> <Auth {...convertLegacyAuthProps({ config: options, @@ -50,23 +50,15 @@ export const ConfigEditor = ({ options, onOptionsChange }: Props) => { })} /> - <Divider /> + <Divider spacing={4} /> <TraceToLogsSection options={options} onOptionsChange={onOptionsChange} /> + <Divider spacing={4} /> + + <TraceToMetricsSection options={options} onOptionsChange={onOptionsChange} /> + <Divider spacing={4} /> - <Divider /> - {config.featureToggles.traceToMetrics ? ( - <> - <TraceToMetricsSection options={options} onOptionsChange={onOptionsChange} /> - <Divider /> - </> - ) : null} - - {config.featureToggles.traceToProfiles && ( - <> - <TraceToProfilesSection options={options} onOptionsChange={onOptionsChange} /> - <Divider /> - </> - )} + <TraceToProfilesSection options={options} onOptionsChange={onOptionsChange} /> + <Divider spacing={4} /> <ConfigSection title="Additional settings" @@ -74,85 +66,67 @@ export const ConfigEditor = ({ options, onOptionsChange }: Props) => { isCollapsible={true} isInitiallyOpen={false} > - <AdvancedHttpSettings config={options} onChange={onOptionsChange} /> - - {config.secureSocksDSProxyEnabled && ( - <> - <Divider hideLine /> - <SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} /> - </> - )} - - <Divider hideLine /> - <ConfigSubSection - title="Service graph" - description={ - <ConfigDescriptionLink - description="Select a Prometheus data source that contains the service graph data." - suffix="tempo/configure-tempo-data-source/#service-graph" - feature="the service graph" - /> - } - > - <ServiceGraphSettings options={options} onOptionsChange={onOptionsChange} /> - </ConfigSubSection> - - <Divider hideLine /> - <NodeGraphSection options={options} onOptionsChange={onOptionsChange} /> - - <Divider hideLine /> - <ConfigSubSection - title="Tempo search" - description={ - <ConfigDescriptionLink - description="Modify how traces are searched." - suffix="tempo/configure-tempo-data-source/#tempo-search" - feature="Tempo search" - /> - } - > - <TraceQLSearchSettings options={options} onOptionsChange={onOptionsChange} /> - </ConfigSubSection> - - <Divider hideLine /> - <ConfigSubSection - title="Loki search" - description={ - <ConfigDescriptionLink - description="Select a Loki data source to search for traces. Derived fields must be configured in the Loki data source." - suffix="tempo/configure-tempo-data-source/#loki-search" - feature="Loki search" - /> - } - > - <LokiSearchSettings options={options} onOptionsChange={onOptionsChange} /> - </ConfigSubSection> - - <Divider hideLine /> - <ConfigSubSection - title="TraceID query" - description={ - <ConfigDescriptionLink - description="Modify how TraceID queries are run." - suffix="tempo/configure-tempo-data-source/#traceid-query" - feature="the TraceID query" - /> - } - > - <QuerySettings options={options} onOptionsChange={onOptionsChange} /> - </ConfigSubSection> - - <Divider hideLine /> - <SpanBarSection options={options} onOptionsChange={onOptionsChange} /> + <Stack gap={5} direction="column"> + <AdvancedHttpSettings config={options} onChange={onOptionsChange} /> + + {config.secureSocksDSProxyEnabled && ( + <> + <SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} /> + </> + )} + + <ConfigSubSection + title="Service graph" + description={ + <ConfigDescriptionLink + description="Select a Prometheus data source that contains the service graph data." + suffix="tempo/configure-tempo-data-source/#service-graph" + feature="the service graph" + /> + } + > + <ServiceGraphSettings options={options} onOptionsChange={onOptionsChange} /> + </ConfigSubSection> + + <NodeGraphSection options={options} onOptionsChange={onOptionsChange} /> + + <ConfigSubSection + title="Tempo search" + description={ + <ConfigDescriptionLink + description="Modify how traces are searched." + suffix="tempo/configure-tempo-data-source/#tempo-search" + feature="Tempo search" + /> + } + > + <TraceQLSearchSettings options={options} onOptionsChange={onOptionsChange} /> + </ConfigSubSection> + + <ConfigSubSection + title="TraceID query" + description={ + <ConfigDescriptionLink + description="Modify how TraceID queries are run." + suffix="tempo/configure-tempo-data-source/#traceid-query" + feature="the TraceID query" + /> + } + > + <QuerySettings options={options} onOptionsChange={onOptionsChange} /> + </ConfigSubSection> + + <SpanBarSection options={options} onOptionsChange={onOptionsChange} /> + </Stack> </ConfigSection> </div> ); }; const getStyles = (theme: GrafanaTheme2) => ({ - container: css` - label: container; - margin-bottom: ${theme.spacing(2)}; - max-width: 900px; - `, + container: css({ + label: 'container', + marginBottom: theme.spacing(2), + maxWidth: '900px', + }), }); diff --git a/public/app/plugins/datasource/tempo/configuration/LokiSearchSettings.tsx b/public/app/plugins/datasource/tempo/configuration/LokiSearchSettings.tsx deleted file mode 100644 index 7ae8e368ee242..0000000000000 --- a/public/app/plugins/datasource/tempo/configuration/LokiSearchSettings.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; - -import { - DataSourceInstanceSettings, - DataSourcePluginOptionsEditorProps, - updateDatasourcePluginJsonDataOption, -} from '@grafana/data'; -import { Button, InlineField, InlineFieldRow, useStyles2 } from '@grafana/ui'; -import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; - -import { TempoJsonData } from '../types'; - -import { getStyles } from './QuerySettings'; - -interface Props extends DataSourcePluginOptionsEditorProps<TempoJsonData> {} - -export function LokiSearchSettings({ options, onOptionsChange }: Props) { - const styles = useStyles2(getStyles); - - // Default to the trace to logs datasource if configured and loki search was enabled - // but only if jsonData.lokiSearch hasn't been set - const legacyDatasource = - options.jsonData.tracesToLogs?.lokiSearch !== false ? options.jsonData.tracesToLogs?.datasourceUid : undefined; - if (legacyDatasource && options.jsonData.lokiSearch === undefined) { - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'lokiSearch', { - datasourceUid: legacyDatasource, - }); - } - - return ( - <div className={styles.container}> - <InlineFieldRow className={styles.row}> - <InlineField tooltip="The Loki data source with the service graph data" label="Data source" labelWidth={26}> - <DataSourcePicker - inputId="loki-search-data-source-picker" - pluginId="loki" - current={options.jsonData.lokiSearch?.datasourceUid} - noDefault={true} - width={40} - onChange={(ds: DataSourceInstanceSettings) => - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'lokiSearch', { - datasourceUid: ds.uid, - }) - } - /> - </InlineField> - {options.jsonData.lokiSearch?.datasourceUid ? ( - <Button - type={'button'} - variant={'secondary'} - size={'sm'} - fill={'text'} - onClick={() => { - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'lokiSearch', { - datasourceUid: undefined, - }); - }} - > - Clear - </Button> - ) : null} - </InlineFieldRow> - </div> - ); -} diff --git a/public/app/plugins/datasource/tempo/configuration/QuerySettings.tsx b/public/app/plugins/datasource/tempo/configuration/QuerySettings.tsx index 76f0ba7ff7110..85adf08d72d8f 100644 --- a/public/app/plugins/datasource/tempo/configuration/QuerySettings.tsx +++ b/public/app/plugins/datasource/tempo/configuration/QuerySettings.tsx @@ -2,9 +2,8 @@ import { css } from '@emotion/css'; import React from 'react'; import { DataSourcePluginOptionsEditorProps, GrafanaTheme2, updateDatasourcePluginJsonDataOption } from '@grafana/data'; +import { IntervalInput, invalidTimeShiftError } from '@grafana/o11y-ds-frontend'; import { InlineField, InlineSwitch, useStyles2 } from '@grafana/ui'; -import { IntervalInput } from 'app/core/components/IntervalInput/IntervalInput'; -import { invalidTimeShiftError } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; import { TempoJsonData } from '../types'; @@ -72,14 +71,14 @@ export function QuerySettings({ options, onOptionsChange }: Props) { } export const getStyles = (theme: GrafanaTheme2) => ({ - infoText: css` - padding-bottom: ${theme.spacing(2)}; - color: ${theme.colors.text.secondary}; - `, - container: css` - width: 100%; - `, - row: css` - align-items: baseline; - `, + infoText: css({ + paddingBottom: theme.spacing(2), + color: theme.colors.text.secondary, + }), + container: css({ + width: '100%', + }), + row: css({ + alignItems: 'baseline', + }), }); diff --git a/public/app/plugins/datasource/tempo/configuration/ServiceGraphSettings.tsx b/public/app/plugins/datasource/tempo/configuration/ServiceGraphSettings.tsx index 646d004f88855..b19515e9fc612 100644 --- a/public/app/plugins/datasource/tempo/configuration/ServiceGraphSettings.tsx +++ b/public/app/plugins/datasource/tempo/configuration/ServiceGraphSettings.tsx @@ -5,8 +5,8 @@ import { DataSourcePluginOptionsEditorProps, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; +import { DataSourcePicker } from '@grafana/runtime'; import { Button, InlineField, InlineFieldRow, useStyles2 } from '@grafana/ui'; -import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; import { TempoJsonData } from '../types'; diff --git a/public/app/plugins/datasource/tempo/dataquery.cue b/public/app/plugins/datasource/tempo/dataquery.cue index c94c9a16254b4..786f000fef6e8 100644 --- a/public/app/plugins/datasource/tempo/dataquery.cue +++ b/public/app/plugins/datasource/tempo/dataquery.cue @@ -38,8 +38,8 @@ composableKinds: DataQuery: { minDuration?: string // @deprecated Define the maximum duration to select traces. Use duration format, for example: 1.2s, 100ms maxDuration?: string - // Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"} - serviceMapQuery?: string + // Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"}. Providing multiple values will produce union of results for each filter, using PromQL OR operator internally. + serviceMapQuery?: string | [...string] // Use service.namespace in addition to service.name to uniquely identify a service. serviceMapIncludeNamespace?: bool // Defines the maximum number of traces that are returned from Tempo @@ -53,8 +53,7 @@ composableKinds: DataQuery: { tableType?: #SearchTableType } @cuetsy(kind="interface") @grafana(TSVeneer="type") - // search = Loki search, nativeSearch = Tempo search for backwards compatibility - #TempoQueryType: "traceql" | "traceqlSearch" | "search" | "serviceMap" | "upload" | "nativeSearch" | "traceId" | "clear" @cuetsy(kind="type") + #TempoQueryType: "traceql" | "traceqlSearch" | "serviceMap" | "upload" | "nativeSearch" | "traceId" | "clear" @cuetsy(kind="type") // The state of the TraceQL streaming search query #SearchStreamingState: "pending" | "streaming" | "done" | "error" @cuetsy(kind="enum") diff --git a/public/app/plugins/datasource/tempo/dataquery.gen.ts b/public/app/plugins/datasource/tempo/dataquery.gen.ts index 7cfa4c022b480..17cce1c50d0d2 100644 --- a/public/app/plugins/datasource/tempo/dataquery.gen.ts +++ b/public/app/plugins/datasource/tempo/dataquery.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -41,9 +41,9 @@ export interface TempoQuery extends common.DataQuery { */ serviceMapIncludeNamespace?: boolean; /** - * Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"} + * Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"}. Providing multiple values will produce union of results for each filter, using PromQL OR operator internally. */ - serviceMapQuery?: string; + serviceMapQuery?: (string | Array<string>); /** * @deprecated Query traces by service name */ @@ -67,10 +67,7 @@ export const defaultTempoQuery: Partial<TempoQuery> = { groupBy: [], }; -/** - * search = Loki search, nativeSearch = Tempo search for backwards compatibility - */ -export type TempoQueryType = ('traceql' | 'traceqlSearch' | 'search' | 'serviceMap' | 'upload' | 'nativeSearch' | 'traceId' | 'clear'); +export type TempoQueryType = ('traceql' | 'traceqlSearch' | 'serviceMap' | 'upload' | 'nativeSearch' | 'traceId' | 'clear'); /** * The state of the TraceQL streaming search query @@ -127,4 +124,4 @@ export interface TraceqlFilter { valueType?: string; } -export interface Tempo {} +export interface TempoDataQuery {} diff --git a/public/app/plugins/datasource/tempo/datasource.test.ts b/public/app/plugins/datasource/tempo/datasource.test.ts index 6ed9b2028ee25..7deb9b62aff68 100644 --- a/public/app/plugins/datasource/tempo/datasource.test.ts +++ b/public/app/plugins/datasource/tempo/datasource.test.ts @@ -1,6 +1,4 @@ import { lastValueFrom, Observable, of } from 'rxjs'; -import { createFetchResponse } from 'test/helpers/createFetchResponse'; -import { initTemplateSrv } from 'test/helpers/initTemplateSrv'; import { DataFrame, @@ -13,15 +11,26 @@ import { createDataFrame, PluginType, CoreApp, + DataSourceApi, + DataQueryRequest, + getTimeZone, + PluginMetaInfo, } from '@grafana/data'; -import { BackendDataSourceResponse, FetchResponse, setBackendSrv, setDataSourceSrv } from '@grafana/runtime'; -import { BarGaugeDisplayMode, TableCellDisplayMode } from '@grafana/schema'; -import { TemplateSrv } from 'app/features/templating/template_srv'; +import { + BackendDataSourceResponse, + FetchResponse, + setBackendSrv, + setDataSourceSrv, + TemplateSrv, + DataSourceSrv, + BackendSrv, +} from '@grafana/runtime'; +import { BarGaugeDisplayMode, DataQuery, TableCellDisplayMode } from '@grafana/schema'; import { TempoVariableQueryType } from './VariableQueryEditor'; +import { createFetchResponse } from './_importedDependencies/test/helpers/createFetchResponse'; import { TraceqlSearchScope } from './dataquery.gen'; import { - DEFAULT_LIMIT, TempoDatasource, buildExpr, buildLinkExpr, @@ -34,9 +43,10 @@ import { import mockJson from './mockJsonResponse.json'; import mockServiceGraph from './mockServiceGraph.json'; import { createMetadataRequest, createTempoDatasource } from './mocks'; +import { initTemplateSrv } from './test_utils'; import { TempoJsonData, TempoQuery } from './types'; -let mockObservable: () => Observable<any>; +let mockObservable: () => Observable<unknown>; jest.mock('@grafana/runtime', () => { return { ...jest.requireActual('@grafana/runtime'), @@ -58,29 +68,39 @@ describe('Tempo data source', () => { const templateSrv: TemplateSrv = { replace: jest.fn() } as unknown as TemplateSrv; const ds = new TempoDatasource(defaultSettings, templateSrv); const response = await lastValueFrom( - ds.query({ targets: [{ refId: 'refid1', queryType: 'traceql', query: '' } as Partial<TempoQuery>] } as any), + ds.query({ + targets: [{ refId: 'refid1', queryType: 'traceql', query: '' } as Partial<TempoQuery>], + } as DataQueryRequest<TempoQuery>), { defaultValue: 'empty' } ); expect(response).toBe('empty'); }); describe('Variables should be interpolated correctly', () => { - function getQuery(): TempoQuery { + function getQuery(serviceMapQuery: string | string[] = '$interpolationVar'): TempoQuery { return { refId: 'x', queryType: 'traceql', - linkedQuery: { - refId: 'linked', - expr: '{instance="$interpolationVar"}', - }, query: '$interpolationVarWithPipe', - spanName: '$interpolationVar', - serviceName: '$interpolationVar', - search: '$interpolationVar', - minDuration: '$interpolationVar', - maxDuration: '$interpolationVar', - serviceMapQuery: '$interpolationVar', - filters: [], + serviceMapQuery, + filters: [ + { + id: 'service-name', + operator: '=', + scope: TraceqlSearchScope.Resource, + tag: 'service.name', + value: '$interpolationVarWithPipe', + valueType: 'string', + }, + { + id: 'tagId', + operator: '=', + scope: TraceqlSearchScope.Span, + tag: '$interpolationVar', + value: '$interpolationVar', + valueType: 'string', + }, + ], }; } let templateSrv: TemplateSrv; @@ -88,46 +108,50 @@ describe('Tempo data source', () => { const textWithPipe = 'interpolationTextOne|interpolationTextTwo'; beforeEach(() => { - templateSrv = initTemplateSrv('key', [ - { - type: 'custom', - name: 'interpolationVar', - current: { value: [text] }, - }, - { - type: 'custom', - name: 'interpolationVarWithPipe', - current: { value: [textWithPipe] }, - }, - ]); + const expectedValues = { + interpolationVar: 'scopedInterpolationText', + interpolationText: 'interpolationText', + interpolationVarWithPipe: 'interpolationTextOne|interpolationTextTwo', + scopedInterpolationText: 'scopedInterpolationText', + }; + templateSrv = initTemplateSrv([{ name: 'templateVariable1' }, { name: 'templateVariable2' }], expectedValues); }); - it('when traceId query for dashboard->explore', async () => { + it('when moving from dashboard to explore', async () => { + const expectedValues = { + interpolationVar: 'interpolationText', + interpolationText: 'interpolationText', + interpolationVarWithPipe: 'interpolationTextOne|interpolationTextTwo', + scopedInterpolationText: 'scopedInterpolationText', + }; + templateSrv = initTemplateSrv([{ name: 'templateVariable1' }, { name: 'templateVariable2' }], expectedValues); + const ds = new TempoDatasource(defaultSettings, templateSrv); const queries = ds.interpolateVariablesInQueries([getQuery()], {}); - expect(queries[0].linkedQuery?.expr).toBe(`{instance=\"${text}\"}`); expect(queries[0].query).toBe(textWithPipe); - expect(queries[0].serviceName).toBe(text); - expect(queries[0].spanName).toBe(text); - expect(queries[0].search).toBe(text); - expect(queries[0].minDuration).toBe(text); - expect(queries[0].maxDuration).toBe(text); expect(queries[0].serviceMapQuery).toBe(text); + expect(queries[0].filters[0].value).toBe(textWithPipe); + expect(queries[0].filters[1].value).toBe(text); + expect(queries[0].filters[1].tag).toBe(text); }); - it('when traceId query for template variable', async () => { + it('when applying template variables', async () => { const scopedText = 'scopedInterpolationText'; const ds = new TempoDatasource(defaultSettings, templateSrv); const resp = ds.applyTemplateVariables(getQuery(), { interpolationVar: { text: scopedText, value: scopedText }, }); - expect(resp.linkedQuery?.expr).toBe(`{instance=\"${scopedText}\"}`); expect(resp.query).toBe(textWithPipe); - expect(resp.serviceName).toBe(scopedText); - expect(resp.spanName).toBe(scopedText); - expect(resp.search).toBe(scopedText); - expect(resp.minDuration).toBe(scopedText); - expect(resp.maxDuration).toBe(scopedText); + expect(resp.filters[0].value).toBe(textWithPipe); + expect(resp.filters[1].value).toBe(scopedText); + expect(resp.filters[1].tag).toBe(scopedText); + }); + + it('when serviceMapQuery is an array', async () => { + const ds = new TempoDatasource(defaultSettings, templateSrv); + const queries = ds.interpolateVariablesInQueries([getQuery(['$interpolationVar', '$interpolationVar'])], {}); + expect(queries[0].serviceMapQuery?.[0]).toBe('scopedInterpolationText'); + expect(queries[0].serviceMapQuery?.[1]).toBe('scopedInterpolationText'); }); }); @@ -148,9 +172,11 @@ describe('Tempo data source', () => { ], }) ); - const templateSrv: any = { replace: jest.fn() }; + const templateSrv = { replace: jest.fn() } as unknown as TemplateSrv; const ds = new TempoDatasource(defaultSettings, templateSrv); - const response = await lastValueFrom(ds.query({ targets: [{ refId: 'refid1', query: '12345' }] } as any)); + const response = await lastValueFrom( + ds.query({ targets: [{ refId: 'refid1', query: '12345' }] } as DataQueryRequest<TempoQuery>) + ); expect( (response.data[0] as DataFrame).fields.map((f) => ({ @@ -202,7 +228,7 @@ describe('Tempo data source', () => { const response = await lastValueFrom( ds.query({ targets: [{ queryType: 'upload', refId: 'A' }], - } as any) + } as DataQueryRequest<TempoQuery>) ); const field = response.data[0].fields[0]; expect(field.name).toBe('traceID'); @@ -217,7 +243,7 @@ describe('Tempo data source', () => { const response = await lastValueFrom( ds.query({ targets: [{ queryType: 'upload', refId: 'A' }], - } as any) + } as DataQueryRequest<TempoQuery>) ); expect(response.error?.message).toBeDefined(); expect(response.data.length).toBe(0); @@ -229,7 +255,7 @@ describe('Tempo data source', () => { const response = await lastValueFrom( ds.query({ targets: [{ queryType: 'upload', refId: 'A' }], - } as any) + } as DataQueryRequest<TempoQuery>) ); expect(response.data).toHaveLength(2); const nodesFrame = response.data[0]; @@ -241,32 +267,6 @@ describe('Tempo data source', () => { expect(edgesFrame.meta?.preferredVisualisationType).toBe('nodeGraph'); }); - it('should build search query correctly', () => { - const templateSrv: any = { replace: jest.fn() }; - const ds = new TempoDatasource(defaultSettings, templateSrv); - const duration = '10ms'; - templateSrv.replace.mockReturnValue(duration); - const tempoQuery: TempoQuery = { - queryType: 'search', - refId: 'A', - query: '', - serviceName: 'frontend', - spanName: '/config', - search: 'root.http.status_code=500', - minDuration: '$interpolationVar', - maxDuration: '$interpolationVar', - limit: 10, - filters: [], - }; - const builtQuery = ds.buildSearchQuery(tempoQuery); - expect(builtQuery).toStrictEqual({ - tags: 'root.http.status_code=500 service.name="frontend" name="/config"', - minDuration: duration, - maxDuration: duration, - limit: 10, - }); - }); - it('should format metrics summary query correctly', () => { const ds = new TempoDatasource(defaultSettings, {} as TemplateSrv); const queryGroupBy = [ @@ -279,110 +279,6 @@ describe('Tempo data source', () => { expect(groupBy).toEqual('.component, span.name, resource.service.name, kind'); }); - it('should include a default limit', () => { - const ds = new TempoDatasource(defaultSettings); - const tempoQuery: TempoQuery = { - queryType: 'search', - refId: 'A', - query: '', - search: '', - filters: [], - }; - const builtQuery = ds.buildSearchQuery(tempoQuery); - expect(builtQuery).toStrictEqual({ - tags: '', - limit: DEFAULT_LIMIT, - }); - }); - - it('should include time range if provided', () => { - const ds = new TempoDatasource(defaultSettings); - const tempoQuery: TempoQuery = { - queryType: 'search', - refId: 'A', - query: '', - search: '', - filters: [], - }; - const timeRange = { startTime: 0, endTime: 1000 }; - const builtQuery = ds.buildSearchQuery(tempoQuery, timeRange); - expect(builtQuery).toStrictEqual({ - tags: '', - limit: DEFAULT_LIMIT, - start: timeRange.startTime, - end: timeRange.endTime, - }); - }); - - it('formats native search query history correctly', () => { - const ds = new TempoDatasource(defaultSettings); - const tempoQuery: TempoQuery = { - filters: [], - queryType: 'nativeSearch', - refId: 'A', - query: '', - serviceName: 'frontend', - spanName: '/config', - search: 'root.http.status_code=500', - minDuration: '1ms', - maxDuration: '100s', - limit: 10, - }; - const result = ds.getQueryDisplayText(tempoQuery); - expect(result).toBe( - 'Service Name: frontend, Span Name: /config, Search: root.http.status_code=500, Min Duration: 1ms, Max Duration: 100s, Limit: 10' - ); - }); - - it('should get loki search datasource', () => { - // 1. Get lokiSearch.datasource if present - const ds1 = new TempoDatasource({ - ...defaultSettings, - jsonData: { - lokiSearch: { - datasourceUid: 'loki-1', - }, - }, - }); - const lokiDS1 = ds1.getLokiSearchDS(); - expect(lokiDS1).toBe('loki-1'); - - // 2. Get traceToLogs.datasource - const ds2 = new TempoDatasource({ - ...defaultSettings, - jsonData: { - tracesToLogs: { - lokiSearch: true, - datasourceUid: 'loki-2', - }, - }, - }); - const lokiDS2 = ds2.getLokiSearchDS(); - expect(lokiDS2).toBe('loki-2'); - - // 3. Return undefined if neither is available - const ds3 = new TempoDatasource(defaultSettings); - const lokiDS3 = ds3.getLokiSearchDS(); - expect(lokiDS3).toBe(undefined); - - // 4. Return undefined if lokiSearch is undefined, even if traceToLogs is present - // since this indicates the user cleared the fallback setting - const ds4 = new TempoDatasource({ - ...defaultSettings, - jsonData: { - tracesToLogs: { - lokiSearch: true, - datasourceUid: 'loki-2', - }, - lokiSearch: { - datasourceUid: undefined, - }, - }, - }); - const lokiDS4 = ds4.getLokiSearchDS(); - expect(lokiDS4).toBe(undefined); - }); - describe('test the testDatasource function', () => { it('should return a success msg if response.ok is true', async () => { mockObservable = () => of({ ok: true }); @@ -470,9 +366,13 @@ describe('Tempo service graph view', () => { }, }, }); - setDataSourceSrv(backendSrvWithPrometheus as any); + setDataSourceSrv(dataSourceSrvWithPrometheus(prometheusMock())); const response = await lastValueFrom( - ds.query({ targets: [{ queryType: 'serviceMap' }], range: getDefaultTimeRange(), app: CoreApp.Explore } as any) + ds.query({ + targets: [{ queryType: 'serviceMap' }], + range: getDefaultTimeRange(), + app: CoreApp.Explore, + } as DataQueryRequest<TempoQuery>) ); expect(response.data).toHaveLength(3); @@ -554,10 +454,104 @@ describe('Tempo service graph view', () => { expect(response.data[2].fields[0].values.length).toBe(2); }); + it('runs correct queries with single serviceMapQuery defined', async () => { + const ds = new TempoDatasource({ + ...defaultSettings, + jsonData: { + serviceMap: { + datasourceUid: 'prom', + }, + }, + }); + const promMock = prometheusMock(); + setDataSourceSrv(dataSourceSrvWithPrometheus(promMock)); + const response = await lastValueFrom( + ds.query({ + targets: [{ queryType: 'serviceMap', serviceMapQuery: '{ foo="bar" }', refId: 'foo', filters: [] }], + range: getDefaultTimeRange(), + app: CoreApp.Explore, + requestId: '1', + interval: '60s', + intervalMs: 60000, + scopedVars: {}, + startTime: Date.now(), + timezone: getTimeZone(), + }) + ); + + expect(response.data).toHaveLength(2); + expect(response.state).toBe(LoadingState.Done); + expect(response.data[0].name).toBe('Nodes'); + expect(response.data[1].name).toBe('Edges'); + expect(promMock.query).toHaveBeenCalledTimes(3); + const nthQuery = (n: number) => + (promMock.query as jest.MockedFn<jest.MockableFunction>).mock.calls[n][0] as DataQueryRequest<PromQuery>; + expect(nthQuery(0).targets[0].expr).toBe( + 'sum by (client, server) (rate(traces_service_graph_request_server_seconds_sum{ foo="bar" }[$__range]))' + ); + expect(nthQuery(0).targets[1].expr).toBe( + 'sum by (client, server) (rate(traces_service_graph_request_total{ foo="bar" }[$__range]))' + ); + expect(nthQuery(0).targets[2].expr).toBe( + 'sum by (client, server) (rate(traces_service_graph_request_failed_total{ foo="bar" }[$__range]))' + ); + expect(nthQuery(0).targets[3].expr).toBe( + 'sum by (client, server) (rate(traces_service_graph_request_server_seconds_bucket{ foo="bar" }[$__range]))' + ); + }); + + it('runs correct queries with multiple serviceMapQuery defined', async () => { + const ds = new TempoDatasource({ + ...defaultSettings, + jsonData: { + serviceMap: { + datasourceUid: 'prom', + }, + }, + }); + const promMock = prometheusMock(); + setDataSourceSrv(dataSourceSrvWithPrometheus(promMock)); + const response = await lastValueFrom( + ds.query({ + targets: [ + { queryType: 'serviceMap', serviceMapQuery: ['{ foo="bar" }', '{baz="bad"}'], refId: 'foo', filters: [] }, + ], + requestId: '1', + interval: '60s', + intervalMs: 60000, + scopedVars: {}, + startTime: Date.now(), + timezone: getTimeZone(), + range: getDefaultTimeRange(), + app: CoreApp.Explore, + }) + ); + + expect(response.data).toHaveLength(2); + expect(response.state).toBe(LoadingState.Done); + expect(response.data[0].name).toBe('Nodes'); + expect(response.data[1].name).toBe('Edges'); + expect(promMock.query).toHaveBeenCalledTimes(3); + const nthQuery = (n: number) => + (promMock.query as jest.MockedFn<jest.MockableFunction>).mock.calls[n][0] as DataQueryRequest<PromQuery>; + expect(nthQuery(0).targets[0].expr).toBe( + 'sum by (client, server) (rate(traces_service_graph_request_server_seconds_sum{ foo="bar" }[$__range])) OR sum by (client, server) (rate(traces_service_graph_request_server_seconds_sum{baz="bad"}[$__range]))' + ); + expect(nthQuery(0).targets[1].expr).toBe( + 'sum by (client, server) (rate(traces_service_graph_request_total{ foo="bar" }[$__range])) OR sum by (client, server) (rate(traces_service_graph_request_total{baz="bad"}[$__range]))' + ); + expect(nthQuery(0).targets[2].expr).toBe( + 'sum by (client, server) (rate(traces_service_graph_request_failed_total{ foo="bar" }[$__range])) OR sum by (client, server) (rate(traces_service_graph_request_failed_total{baz="bad"}[$__range]))' + ); + expect(nthQuery(0).targets[3].expr).toBe( + 'sum by (client, server) (rate(traces_service_graph_request_server_seconds_bucket{ foo="bar" }[$__range])) OR sum by (client, server) (rate(traces_service_graph_request_server_seconds_bucket{baz="bad"}[$__range]))' + ); + }); + it('should build expr correctly', () => { - let targets = { targets: [{ queryType: 'serviceMap' }] } as any; + let targets = { targets: [{ queryType: 'serviceMap' }] } as DataQueryRequest<TempoQuery>; let builtQuery = buildExpr( - { expr: 'topk(5, sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name))', params: [] }, + { expr: 'sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name)', params: [], topk: 5 }, '', targets ); @@ -565,8 +559,9 @@ describe('Tempo service graph view', () => { builtQuery = buildExpr( { - expr: 'topk(5, sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name))', + expr: 'sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name)', params: ['status_code="STATUS_CODE_ERROR"'], + topk: 5, }, 'span_name=~"HTTP Client|HTTP GET|HTTP GET - root|HTTP POST|HTTP POST - post"', targets @@ -587,9 +582,11 @@ describe('Tempo service graph view', () => { 'histogram_quantile(.9, sum(rate(traces_spanmetrics_latency_bucket{status_code="STATUS_CODE_ERROR",span_name=~"HTTP Client"}[$__range])) by (le))' ); - targets = { targets: [{ queryType: 'serviceMap', serviceMapQuery: '{client="app",service="app"}' }] } as any; + targets = { + targets: [{ queryType: 'serviceMap', serviceMapQuery: '{client="app",service="app"}' }], + } as DataQueryRequest<TempoQuery>; builtQuery = buildExpr( - { expr: 'topk(5, sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name))', params: [] }, + { expr: 'sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name)', params: [], topk: 5 }, '', targets ); @@ -597,12 +594,38 @@ describe('Tempo service graph view', () => { 'topk(5, sum(rate(traces_spanmetrics_calls_total{service="app",service="app"}[$__range])) by (span_name))' ); - targets = { targets: [{ queryType: 'serviceMap', serviceMapQuery: '{client="${app}",service="$app"}' }] } as any; + targets = { + targets: [{ queryType: 'serviceMap', serviceMapQuery: '{client="app",service="app"}' }], + } as DataQueryRequest<TempoQuery>; builtQuery = buildExpr( { expr: 'topk(5, sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name))', params: [] }, '', targets ); + expect(builtQuery).toBe( + 'topk(5, sum(rate(traces_spanmetrics_calls_total{service="app",service="app"}[$__range])) by (span_name))' + ); + + targets = { + targets: [{ queryType: 'serviceMap', serviceMapQuery: ['{foo="app"}', '{bar="app"}'] }], + } as DataQueryRequest<TempoQuery>; + builtQuery = buildExpr( + { expr: 'sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name)', params: [], topk: 5 }, + '', + targets + ); + expect(builtQuery).toBe( + 'topk(5, sum(rate(traces_spanmetrics_calls_total{foo="app"}[$__range])) by (span_name) OR sum(rate(traces_spanmetrics_calls_total{bar="app"}[$__range])) by (span_name))' + ); + + targets = { + targets: [{ queryType: 'serviceMap', serviceMapQuery: '{client="${app}",service="$app"}' }], + } as DataQueryRequest<TempoQuery>; + builtQuery = buildExpr( + { expr: 'sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name)', params: [], topk: 5 }, + '', + targets + ); expect(builtQuery).toBe( 'topk(5, sum(rate(traces_spanmetrics_calls_total{service="${app}",service="$app"}[$__range])) by (span_name))' ); @@ -779,12 +802,20 @@ describe('Tempo service graph view', () => { queryType: 'traceqlSearch', refId: 'A', filters: [ + { + id: 'service-namespace', + operator: '=', + scope: 'resource', + tag: 'service.namespace', + value: '${__data.fields.targetNamespace}', + valueType: 'string', + }, { id: 'service-name', operator: '=', scope: 'resource', tag: 'service.name', - value: '${__data.fields.target}', + value: '${__data.fields.targetName}', valueType: 'string', }, ], @@ -859,8 +890,34 @@ describe('Tempo service graph view', () => { ]); }); - it('should make tempo link correctly', () => { - const tempoLink = makeTempoLink('Tempo', '', '"${__data.fields[0]}"', 'gdev-tempo'); + it('should make tempo link correctly without namespace', () => { + const tempoLink = makeTempoLink('Tempo', undefined, '', '"${__data.fields[0]}"', 'gdev-tempo'); + expect(tempoLink).toEqual({ + url: '', + title: 'Tempo', + internal: { + query: { + queryType: 'traceqlSearch', + refId: 'A', + filters: [ + { + id: 'span-name', + operator: '=', + scope: 'span', + tag: 'name', + value: '"${__data.fields[0]}"', + valueType: 'string', + }, + ], + }, + datasourceUid: 'gdev-tempo', + datasourceName: 'Tempo', + }, + }); + }); + + it('should make tempo link correctly with namespace', () => { + const tempoLink = makeTempoLink('Tempo', '"${__data.fields.subtitle}"', '', '"${__data.fields[0]}"', 'gdev-tempo'); expect(tempoLink).toEqual({ url: '', title: 'Tempo', @@ -869,6 +926,14 @@ describe('Tempo service graph view', () => { queryType: 'traceqlSearch', refId: 'A', filters: [ + { + id: 'service-namespace', + operator: '=', + scope: 'resource', + tag: 'service.namespace', + value: '"${__data.fields.subtitle}"', + valueType: 'string', + }, { id: 'span-name', operator: '=', @@ -985,37 +1050,95 @@ describe('label values', () => { }); }); -const backendSrvWithPrometheus = { - async get(uid: string) { - if (uid === 'prom') { - return { - query() { - return of({ - data: [ - rateMetric, - errorRateMetric, - durationMetric, - emptyDurationMetric, - totalsPromMetric, - secondsPromMetric, - failedPromMetric, - ], - }); +describe('should provide functionality for ad-hoc filters', () => { + let datasource: TempoDatasource; + + beforeEach(() => { + datasource = createTempoDatasource(); + jest.spyOn(datasource, 'metadataRequest').mockImplementation( + createMetadataRequest({ + data: { + scopes: [{ name: 'span', tags: ['label1', 'label2'] }], + tagValues: [ + { + type: 'value1', + value: 'value1', + label: 'value1', + }, + { + type: 'value2', + value: 'value2', + label: 'value2', + }, + ], }, - }; - } - throw new Error('unexpected uid'); - }, - getDataSourceSettingsByUid(uid: string) { - if (uid === 'prom') { - return { name: 'Prometheus' }; - } else if (uid === 'gdev-tempo') { - return { name: 'Tempo' }; - } - return ''; - }, + }) + ); + }); + + it('for getTagKeys', async () => { + const response = await datasource.getTagKeys(); + expect(response).toEqual([{ text: 'span.label1' }, { text: 'span.label2' }]); + }); + + it('for getTagValues', async () => { + const now = dateTime('2021-04-20T15:55:00Z'); + const options = { + key: 'span.label1', + filters: [], + timeRange: { + from: now, + to: now, + raw: { + from: 'now-15m', + to: 'now', + }, + }, + }; + const response = await datasource.getTagValues(options); + expect(response).toEqual([ + { text: { type: 'value1', value: 'value1', label: 'value1' } }, + { text: { type: 'value2', value: 'value2', label: 'value2' } }, + ]); + }); +}); + +const prometheusMock = (): DataSourceApi => { + return { + query: jest.fn(() => + of({ + data: [ + rateMetric, + errorRateMetric, + durationMetric, + emptyDurationMetric, + totalsPromMetric, + secondsPromMetric, + failedPromMetric, + ], + }) + ), + } as unknown as DataSourceApi; }; +const dataSourceSrvWithPrometheus = (promMock: DataSourceApi) => + ({ + async get(uid: string) { + if (uid === 'prom') { + return promMock; + } + throw new Error('unexpected uid'); + }, + getInstanceSettings(uid: string) { + if (uid === 'prom') { + return { name: 'Prometheus' }; + } else if (uid === 'gdev-tempo') { + return { name: 'Tempo' }; + } + return ''; + }, + }) as unknown as DataSourceSrv; + function setupBackendSrv(frame: DataFrame) { setBackendSrv({ fetch(): Observable<FetchResponse<BackendDataSourceResponse>> { @@ -1029,7 +1152,7 @@ function setupBackendSrv(frame: DataFrame) { }) ); }, - } as any); + } as unknown as BackendSrv); } export const defaultSettings: DataSourceInstanceSettings<TempoJsonData> = { @@ -1042,7 +1165,7 @@ export const defaultSettings: DataSourceInstanceSettings<TempoJsonData> = { id: 'tempo', name: 'tempo', type: PluginType.datasource, - info: {} as any, + info: {} as PluginMetaInfo, module: '', baseUrl: '', }, @@ -1225,7 +1348,7 @@ const serviceGraphLinks = [ operator: '=', scope: 'resource', tag: 'service.name', - value: '${__data.fields[0]}', + value: '${__data.fields.id}', valueType: 'string', }, ], @@ -1235,3 +1358,7 @@ const serviceGraphLinks = [ }, }, ]; + +interface PromQuery extends DataQuery { + expr: string; +} diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index 3ea03f2970143..f6d9600fcc55b 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -1,5 +1,5 @@ -import { groupBy, identity, pick, pickBy, startCase } from 'lodash'; -import { EMPTY, from, lastValueFrom, merge, Observable, of, throwError } from 'rxjs'; +import { groupBy, startCase } from 'lodash'; +import { EMPTY, from, lastValueFrom, merge, Observable, of } from 'rxjs'; import { catchError, concatMap, map, mergeMap, toArray } from 'rxjs/operators'; import semver from 'semver'; @@ -10,38 +10,33 @@ import { DataQueryRequest, DataQueryResponse, DataQueryResponseData, - DataSourceApi, + DataSourceGetTagValuesOptions, DataSourceInstanceSettings, dateTime, FieldType, - isValidGoDuration, LoadingState, rangeUtil, ScopedVars, + SelectableValue, TestDataSourceResponse, urlUtil, } from '@grafana/data'; +import { NodeGraphOptions, SpanBarOptions, TraceToLogsOptions } from '@grafana/o11y-ds-frontend'; import { BackendSrvRequest, config, DataSourceWithBackend, getBackendSrv, + getDataSourceSrv, getTemplateSrv, reportInteraction, TemplateSrv, } from '@grafana/runtime'; import { BarGaugeDisplayMode, TableCellDisplayMode, VariableFormatID } from '@grafana/schema'; -import { NodeGraphOptions } from 'app/core/components/NodeGraphSettings'; -import { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; -import { SpanBarOptions } from 'app/features/explore/TraceView/components'; -import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; - -import { LokiOptions } from '../loki/types'; -import { PrometheusDatasource } from '../prometheus/datasource'; -import { PromQuery } from '../prometheus/types'; import { generateQueryFromFilters } from './SearchTraceQLEditor/utils'; import { TempoVariableQuery, TempoVariableQueryType } from './VariableQueryEditor'; +import { PrometheusDatasource, PromQuery } from './_importedDependencies/datasources/prometheus/types'; import { TraceqlFilter, TraceqlSearchScope } from './dataquery.gen'; import { defaultTableFilter, @@ -57,15 +52,14 @@ import { import TempoLanguageProvider from './language_provider'; import { createTableFrameFromMetricsSummaryQuery, emptyResponse, MetricsSummary } from './metricsSummary'; import { - createTableFrameFromSearch, + formatTraceQLMetrics, + formatTraceQLResponse, transformFromOTLP as transformFromOTEL, transformTrace, - transformTraceList, - formatTraceQLResponse, } from './resultTransformer'; import { doTempoChannelStream } from './streaming'; -import { SearchQueryParams, TempoJsonData, TempoQuery } from './types'; -import { getErrorMessage } from './utils'; +import { TempoJsonData, TempoQuery } from './types'; +import { getErrorMessage, migrateFromSearchToTraceQLSearch } from './utils'; import { TempoVariableSupport } from './variables'; export const DEFAULT_LIMIT = 20; @@ -108,15 +102,12 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson filters?: TraceqlFilter[]; }; nodeGraph?: NodeGraphOptions; - lokiSearch?: { - datasourceUid?: string; - }; traceQuery?: { timeShiftEnabled?: boolean; spanStartTimeShift?: string; spanEndTimeShift?: string; }; - uploadedJson?: string | ArrayBuffer | null = null; + uploadedJson?: string | null = null; spanBar?: SpanBarOptions; languageProvider: TempoLanguageProvider; @@ -128,11 +119,11 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson private readonly templateSrv: TemplateSrv = getTemplateSrv() ) { super(instanceSettings); + this.tracesToLogs = instanceSettings.jsonData.tracesToLogs; this.serviceMap = instanceSettings.jsonData.serviceMap; this.search = instanceSettings.jsonData.search; this.nodeGraph = instanceSettings.jsonData.nodeGraph; - this.lokiSearch = instanceSettings.jsonData.lokiSearch; this.traceQuery = instanceSettings.jsonData.traceQuery; this.languageProvider = new TempoLanguageProvider(this); @@ -168,7 +159,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson return this.labelValuesQuery(query.label); } default: { - throw Error('Invalid query type', query.type); + throw Error('Invalid query type: ' + query.type); } } } @@ -207,9 +198,26 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson options = await this.languageProvider.getOptionsV1(labelName); } - return options.filter((option) => option.value !== undefined).map((option) => ({ text: option.value })) as Array<{ - text: string; - }>; + return options.flatMap((option: SelectableValue<string>) => + option.value !== undefined ? [{ text: option.value }] : [] + ); + } + + // Allows to retrieve the list of tags for ad-hoc filters + async getTagKeys(): Promise<Array<{ text: string }>> { + await this.languageProvider.fetchTags(); + const tags = this.languageProvider.tagsV2 || []; + return tags + .map(({ name, tags }) => + tags.filter((tag) => tag !== undefined).map((t) => (name !== 'intrinsic' ? `${name}.${t}` : `${t}`)) + ) + .flat() + .map((tag) => ({ text: tag })); + } + + // Allows to retrieve the list of tag values for ad-hoc filters + getTagValues(options: DataSourceGetTagValuesOptions): Promise<Array<{ text: string }>> { + return this.labelValuesQuery(options.key.replace(/^(resource|span)\./, '')); } init = async () => { @@ -255,81 +263,18 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson return of({ data: [], state: LoadingState.Done }); } - const logsDatasourceUid = this.getLokiSearchDS(); - - // Run search queries on linked datasource - if (logsDatasourceUid && targets.search?.length > 0) { - reportInteraction('grafana_traces_loki_search_queried', { - datasourceType: 'tempo', - app: options.app ?? '', - grafana_version: config.buildInfo.version, - hasLinkedQueryExpr: - targets.search[0].linkedQuery?.expr && targets.search[0].linkedQuery?.expr !== '' ? true : false, - }); - - const dsSrv = getDatasourceSrv(); - subQueries.push( - from(dsSrv.get(logsDatasourceUid)).pipe( - mergeMap((linkedDatasource: DataSourceApi) => { - // Wrap linked query into a data request based on original request - const linkedRequest: DataQueryRequest = { ...options, targets: targets.search.map((t) => t.linkedQuery!) }; - // Find trace matchers in derived fields of the linked datasource that's identical to this datasource - const settings: DataSourceInstanceSettings<LokiOptions> = (linkedDatasource as any).instanceSettings; - const traceLinkMatcher: string[] = - settings.jsonData.derivedFields - ?.filter((field) => field.datasourceUid === this.uid && field.matcherRegex) - .map((field) => field.matcherRegex) || []; - - if (!traceLinkMatcher || traceLinkMatcher.length === 0) { - return throwError( - () => - new Error( - 'No Loki datasource configured for search. Set up Derived Fields for traces in a Loki datasource settings and link it to this Tempo datasource.' - ) - ); - } else { - return (linkedDatasource.query(linkedRequest) as Observable<DataQueryResponse>).pipe( - map((response) => - response.error ? response : transformTraceList(response, this.uid, this.name, traceLinkMatcher) - ) - ); - } - }) - ) - ); - } - + // Migrate user to new query type if they are using the old search query type if (targets.nativeSearch?.length) { - try { - reportInteraction('grafana_traces_search_queried', { - datasourceType: 'tempo', - app: options.app ?? '', - grafana_version: config.buildInfo.version, - hasServiceName: targets.nativeSearch[0].serviceName ? true : false, - hasSpanName: targets.nativeSearch[0].spanName ? true : false, - resultLimit: targets.nativeSearch[0].limit ?? '', - hasSearch: targets.nativeSearch[0].search ? true : false, - minDuration: targets.nativeSearch[0].minDuration ?? '', - maxDuration: targets.nativeSearch[0].maxDuration ?? '', - }); - - const timeRange = { startTime: options.range.from.unix(), endTime: options.range.to.unix() }; - const query = this.applyVariables(targets.nativeSearch[0], options.scopedVars); - const searchQuery = this.buildSearchQuery(query, timeRange); - subQueries.push( - this._request('/api/search', searchQuery).pipe( - map((response) => { - return { - data: [createTableFrameFromSearch(response.data.traces, this.instanceSettings)], - }; - }), - catchError((err) => { - return of({ error: { message: getErrorMessage(err.data.message) }, data: [] }); - }) - ) - ); - } catch (error) { - return of({ error: { message: error instanceof Error ? error.message : 'Unknown error occurred' }, data: [] }); + if ( + targets.nativeSearch[0].spanName || + targets.nativeSearch[0].serviceName || + targets.nativeSearch[0].search || + targets.nativeSearch[0].maxDuration || + targets.nativeSearch[0].minDuration || + targets.nativeSearch[0].queryType === 'nativeSearch' + ) { + const migratedQuery = migrateFromSearchToTraceQLSearch(targets.nativeSearch[0]); + targets.traceqlSearch = [migratedQuery]; } } @@ -337,9 +282,8 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson try { const appliedQuery = this.applyVariables(targets.traceql[0], options.scopedVars); const queryValue = appliedQuery?.query || ''; - const hexOnlyRegex = /^[0-9A-Fa-f]*$/; // Check whether this is a trace ID or traceQL query by checking if it only contains hex characters - if (queryValue.trim().match(hexOnlyRegex)) { + if (this.isTraceIdQuery(queryValue)) { // There's only hex characters so let's assume that this is a trace ID reportInteraction('grafana_traces_traceID_queried', { datasourceType: 'tempo', @@ -350,39 +294,23 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson subQueries.push(this.handleTraceIdQuery(options, targets.traceql)); } else { - reportInteraction('grafana_traces_traceql_queried', { - datasourceType: 'tempo', - app: options.app ?? '', - grafana_version: config.buildInfo.version, - query: queryValue ?? '', - streaming: config.featureToggles.traceQLStreaming, - }); - - if (config.featureToggles.traceQLStreaming && this.isFeatureAvailable(FeatureName.streaming)) { - subQueries.push(this.handleStreamingSearch(options, targets.traceql, queryValue)); + if (this.isTraceQlMetricsQuery(queryValue)) { + reportInteraction('grafana_traces_traceql_metrics_queried', { + datasourceType: 'tempo', + app: options.app ?? '', + grafana_version: config.buildInfo.version, + query: queryValue ?? '', + }); + subQueries.push(this.handleTraceQlMetricsQuery(options, queryValue)); } else { - subQueries.push( - this._request('/api/search', { - q: queryValue, - limit: options.targets[0].limit ?? DEFAULT_LIMIT, - spss: options.targets[0].spss ?? DEFAULT_SPSS, - start: options.range.from.unix(), - end: options.range.to.unix(), - }).pipe( - map((response) => { - return { - data: formatTraceQLResponse( - response.data.traces, - this.instanceSettings, - targets.traceql[0].tableType - ), - }; - }), - catchError((err) => { - return of({ error: { message: getErrorMessage(err.data.message) }, data: [] }); - }) - ) - ); + reportInteraction('grafana_traces_traceql_queried', { + datasourceType: 'tempo', + app: options.app ?? '', + grafana_version: config.buildInfo.version, + query: queryValue ?? '', + streaming: config.featureToggles.traceQLStreaming, + }); + subQueries.push(this.handleTraceQlQuery(options, targets, queryValue)); } } } catch (error) { @@ -393,9 +321,12 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson if (targets.traceqlSearch?.length) { try { if (config.featureToggles.metricsSummary) { - const groupBy = targets.traceqlSearch.find((t) => this.hasGroupBy(t)); - if (groupBy) { - subQueries.push(this.handleMetricsSummary(groupBy, generateQueryFromFilters(groupBy.filters), options)); + const target = targets.traceqlSearch.find((t) => this.hasGroupBy(t)); + if (target) { + const appliedQuery = this.applyVariables(target, options.scopedVars); + subQueries.push( + this.handleMetricsSummary(appliedQuery, generateQueryFromFilters(appliedQuery.filters), options) + ); } } @@ -403,25 +334,23 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson ? targets.traceqlSearch.filter((t) => !this.hasGroupBy(t)) : targets.traceqlSearch; if (traceqlSearchTargets.length > 0) { - const queryValueFromFilters = generateQueryFromFilters(traceqlSearchTargets[0].filters); - - // We want to support template variables also in Search for consistency with other data sources - const queryValue = this.templateSrv.replace(queryValueFromFilters, options.scopedVars); + const appliedQuery = this.applyVariables(traceqlSearchTargets[0], options.scopedVars); + const queryValueFromFilters = generateQueryFromFilters(appliedQuery.filters); reportInteraction('grafana_traces_traceql_search_queried', { datasourceType: 'tempo', app: options.app ?? '', grafana_version: config.buildInfo.version, - query: queryValue ?? '', + query: queryValueFromFilters ?? '', streaming: config.featureToggles.traceQLStreaming, }); if (config.featureToggles.traceQLStreaming && this.isFeatureAvailable(FeatureName.streaming)) { - subQueries.push(this.handleStreamingSearch(options, traceqlSearchTargets, queryValue)); + subQueries.push(this.handleStreamingSearch(options, traceqlSearchTargets, queryValueFromFilters)); } else { subQueries.push( this._request('/api/search', { - q: queryValue, + q: queryValueFromFilters, limit: options.targets[0].limit ?? DEFAULT_LIMIT, spss: options.targets[0].spss ?? DEFAULT_SPSS, start: options.range.from.unix(), @@ -456,7 +385,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson grafana_version: config.buildInfo.version, }); - const jsonData = JSON.parse(this.uploadedJson as string); + const jsonData = JSON.parse(this.uploadedJson); const isTraceData = jsonData.batches; const isServiceGraphData = Array.isArray(jsonData) && jsonData.some((df) => df?.meta?.preferredVisualisationType === 'nodeGraph'); @@ -497,6 +426,19 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson return merge(...subQueries); } + isTraceQlMetricsQuery(query: string): boolean { + // Check whether this is a metrics query by checking if it contains a metrics function + const metricsFnRegex = + /\|\s*(rate|count_over_time|avg_over_time|max_over_time|min_over_time|quantile_over_time)\s*\(/; + return !!query.trim().match(metricsFnRegex); + } + + isTraceIdQuery(query: string): boolean { + const hexOnlyRegex = /^[0-9A-Fa-f]*$/; + // Check whether this is a trace ID or traceQL query by checking if it only contains hex characters + return !!query.trim().match(hexOnlyRegex); + } + applyTemplateVariables(query: TempoQuery, scopedVars: ScopedVars) { return this.applyVariables(query, scopedVars); } @@ -518,22 +460,30 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson applyVariables(query: TempoQuery, scopedVars: ScopedVars) { const expandedQuery = { ...query }; - if (query.linkedQuery) { - expandedQuery.linkedQuery = { - ...query.linkedQuery, - expr: this.templateSrv.replace(query.linkedQuery?.expr ?? '', scopedVars), - }; + if (query.filters) { + expandedQuery.filters = query.filters.map((filter) => { + const updatedFilter = { + ...filter, + tag: this.templateSrv.replace(filter.tag ?? '', scopedVars), + }; + + if (filter.value) { + updatedFilter.value = + typeof filter.value === 'string' + ? this.templateSrv.replace(filter.value ?? '', scopedVars, VariableFormatID.Pipe) + : filter.value.map((v) => this.templateSrv.replace(v ?? '', scopedVars, VariableFormatID.Pipe)); + } + + return updatedFilter; + }); } return { ...expandedQuery, query: this.templateSrv.replace(query.query ?? '', scopedVars, VariableFormatID.Pipe), - serviceName: this.templateSrv.replace(query.serviceName ?? '', scopedVars), - spanName: this.templateSrv.replace(query.spanName ?? '', scopedVars), - search: this.templateSrv.replace(query.search ?? '', scopedVars), - minDuration: this.templateSrv.replace(query.minDuration ?? '', scopedVars), - maxDuration: this.templateSrv.replace(query.maxDuration ?? '', scopedVars), - serviceMapQuery: this.templateSrv.replace(query.serviceMapQuery ?? '', scopedVars), + serviceMapQuery: Array.isArray(query.serviceMapQuery) + ? query.serviceMapQuery.map((query) => this.templateSrv.replace(query, scopedVars)) + : this.templateSrv.replace(query.serviceMapQuery ?? '', scopedVars), }; } @@ -638,6 +588,55 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson ); } + handleTraceQlQuery = ( + options: DataQueryRequest<TempoQuery>, + targets: { + [type: string]: TempoQuery[]; + }, + queryValue: string + ): Observable<DataQueryResponse> => { + if (config.featureToggles.traceQLStreaming && this.isFeatureAvailable(FeatureName.streaming)) { + return this.handleStreamingSearch(options, targets.traceql, queryValue); + } else { + return this._request('/api/search', { + q: queryValue, + limit: options.targets[0].limit ?? DEFAULT_LIMIT, + spss: options.targets[0].spss ?? DEFAULT_SPSS, + start: options.range.from.unix(), + end: options.range.to.unix(), + }).pipe( + map((response) => { + return { + data: formatTraceQLResponse(response.data.traces, this.instanceSettings, targets.traceql[0].tableType), + }; + }), + catchError((err) => { + return of({ error: { message: getErrorMessage(err.data.message) }, data: [] }); + }) + ); + } + }; + + handleTraceQlMetricsQuery = ( + options: DataQueryRequest<TempoQuery>, + queryValue: string + ): Observable<DataQueryResponse> => { + return this._request('/api/metrics/query_range', { + query: queryValue, + start: options.range.from.unix(), + end: options.range.to.unix(), + }).pipe( + map((response) => { + return { + data: formatTraceQLMetrics(queryValue, response.data), + }; + }), + catchError((err) => { + return of({ error: { message: getErrorMessage(err.data.message) }, data: [] }); + }) + ); + }; + traceIdQueryRequest(options: DataQueryRequest<TempoQuery>, targets: TempoQuery[]): DataQueryRequest<TempoQuery> { const request = { ...options, @@ -721,81 +720,22 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson } getQueryDisplayText(query: TempoQuery) { - if (query.queryType === 'nativeSearch') { - let result = []; - for (const key of ['serviceName', 'spanName', 'search', 'minDuration', 'maxDuration', 'limit']) { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - if (query.hasOwnProperty(key) && query[key as keyof TempoQuery]) { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - result.push(`${startCase(key)}: ${query[key as keyof TempoQuery]}`); - } - } - return result.join(', '); + if (query.queryType !== 'nativeSearch') { + return query.query ?? ''; } - return query.query ?? ''; - } - - buildSearchQuery(query: TempoQuery, timeRange?: { startTime: number; endTime?: number }): SearchQueryParams { - let tags = query.search ?? ''; - let tempoQuery = pick(query, ['minDuration', 'maxDuration', 'limit']); - // Remove empty properties - tempoQuery = pickBy(tempoQuery, identity); - - if (query.serviceName) { - tags += ` service.name="${query.serviceName}"`; - } - if (query.spanName) { - tags += ` name="${query.spanName}"`; - } - - // Set default limit - if (!tempoQuery.limit) { - tempoQuery.limit = DEFAULT_LIMIT; - } - - // Validate query inputs and remove spaces if valid - if (tempoQuery.minDuration) { - tempoQuery.minDuration = this.templateSrv.replace(tempoQuery.minDuration ?? ''); - if (!isValidGoDuration(tempoQuery.minDuration)) { - throw new Error('Please enter a valid min duration.'); - } - tempoQuery.minDuration = tempoQuery.minDuration.replace(/\s/g, ''); - } - if (tempoQuery.maxDuration) { - tempoQuery.maxDuration = this.templateSrv.replace(tempoQuery.maxDuration ?? ''); - if (!isValidGoDuration(tempoQuery.maxDuration)) { - throw new Error('Please enter a valid max duration.'); - } - tempoQuery.maxDuration = tempoQuery.maxDuration.replace(/\s/g, ''); - } - - if (!Number.isInteger(tempoQuery.limit) || tempoQuery.limit <= 0) { - throw new Error('Please enter a valid limit.'); - } - - let searchQuery: SearchQueryParams = { tags, ...tempoQuery }; - - if (timeRange) { - searchQuery.start = timeRange.startTime; - searchQuery.end = timeRange.endTime; - } - - return searchQuery; + const keys: Array< + keyof Pick<TempoQuery, 'serviceName' | 'spanName' | 'search' | 'minDuration' | 'maxDuration' | 'limit'> + > = ['serviceName', 'spanName', 'search', 'minDuration', 'maxDuration', 'limit']; + return keys + .filter((key) => query[key]) + .map((key) => `${startCase(key)}: ${query[key]}`) + .join(', '); } - - // Get linked loki search datasource. Fall back to legacy loki search/trace to logs config - getLokiSearchDS = (): string | undefined => { - const legacyLogsDatasourceUid = - this.tracesToLogs?.lokiSearch !== false && this.lokiSearch === undefined - ? this.tracesToLogs?.datasourceUid - : undefined; - return this.lokiSearch?.datasourceUid ?? legacyLogsDatasourceUid; - }; } function queryPrometheus(request: DataQueryRequest<PromQuery>, datasourceUid: string) { - return from(getDatasourceSrv().get(datasourceUid)).pipe( + return from(getDataSourceSrv().get(datasourceUid)).pipe( mergeMap((ds) => { return (ds as PrometheusDatasource).query(request); }) @@ -991,7 +931,7 @@ function makePromLink(title: string, expr: string, datasourceUid: string, instan instant: instant, }, datasourceUid, - datasourceName: getDatasourceSrv().getDataSourceSettingsByUid(datasourceUid)?.name ?? '', + datasourceName: getDataSourceSrv().getInstanceSettings(datasourceUid)?.name ?? '', }, }; } @@ -1044,13 +984,35 @@ export function getFieldConfig( datasourceUid, false ), - makeTempoLink('View traces', `\${${tempoField}}`, '', tempoDatasourceUid), + makeTempoLink( + 'View traces', + namespaceFields !== undefined ? `\${${namespaceFields.targetNamespace}}` : '', + `\${${targetField}}`, + '', + tempoDatasourceUid + ), ], }; } -export function makeTempoLink(title: string, serviceName: string, spanName: string, datasourceUid: string) { +export function makeTempoLink( + title: string, + serviceNamespace: string | undefined, + serviceName: string, + spanName: string, + datasourceUid: string +) { let query: TempoQuery = { refId: 'A', queryType: 'traceqlSearch', filters: [] }; + if (serviceNamespace !== undefined && serviceNamespace !== '') { + query.filters.push({ + id: 'service-namespace', + scope: TraceqlSearchScope.Resource, + tag: 'service.namespace', + value: serviceNamespace, + operator: '=', + valueType: 'string', + }); + } if (serviceName !== '') { query.filters.push({ id: 'service-name', @@ -1078,7 +1040,7 @@ export function makeTempoLink(title: string, serviceName: string, spanName: stri internal: { query, datasourceUid, - datasourceName: getDatasourceSrv().getDataSourceSettingsByUid(datasourceUid)?.name ?? '', + datasourceName: getDataSourceSrv().getInstanceSettings(datasourceUid)?.name ?? '', }, }; } @@ -1089,12 +1051,16 @@ function makePromServiceMapRequest(options: DataQueryRequest<TempoQuery>): DataQ targets: serviceMapMetrics.map((metric) => { const { serviceMapQuery, serviceMapIncludeNamespace: serviceMapIncludeNamespace } = options.targets[0]; const extraSumByFields = serviceMapIncludeNamespace ? ', client_service_namespace, server_service_namespace' : ''; + const queries = Array.isArray(serviceMapQuery) ? serviceMapQuery : [serviceMapQuery]; + const subExprs = queries.map( + (query) => `sum by (client, server${extraSumByFields}) (rate(${metric}${query || ''}[$__range]))` + ); return { format: 'table', refId: metric, // options.targets[0] is not correct here, but not sure what should happen if you have multiple queries for // service map at the same time anyway - expr: `sum by (client, server${extraSumByFields}) (rate(${metric}${serviceMapQuery || ''}[$__range]))`, + expr: subExprs.join(' OR '), instant: true, }; }), @@ -1250,7 +1216,7 @@ function getServiceGraphView( return 'Tempo'; }), config: { - links: [makeTempoLink('Tempo', '', `\${__data.fields[0]}`, tempoDatasourceUid)], + links: [makeTempoLink('Tempo', undefined, '', `\${__data.fields[0]}`, tempoDatasourceUid)], }, }); } @@ -1259,24 +1225,33 @@ function getServiceGraphView( } export function buildExpr( - metric: { expr: string; params: string[] }, + metric: { expr: string; params: string[]; topk?: number }, extraParams: string, request: DataQueryRequest<TempoQuery> -) { +): string { let serviceMapQuery = request.targets[0]?.serviceMapQuery ?? ''; - const serviceMapQueryMatch = serviceMapQuery.match(/^{(.*)}$/); - if (serviceMapQueryMatch?.length) { - serviceMapQuery = serviceMapQueryMatch[1]; + const serviceMapQueries = Array.isArray(serviceMapQuery) ? serviceMapQuery : [serviceMapQuery]; + const metricParamsArray = serviceMapQueries.map((query) => { + // remove surrounding curly braces from serviceMapQuery + const serviceMapQueryMatch = query.match(/^{(.*)}$/); + if (serviceMapQueryMatch?.length) { + query = serviceMapQueryMatch[1]; + } + // map serviceGraph metric tags to serviceGraphView metric tags + query = query.replace('client', 'service').replace('server', 'service'); + return query.includes('span_name') + ? metric.params.concat(query) + : metric.params + .concat(query) + .concat(extraParams) + .filter((item: string) => item); + }); + const exprs = metricParamsArray.map((params) => metric.expr.replace('{}', '{' + params.join(',') + '}')); + const expr = exprs.join(' OR '); + if (metric.topk) { + return `topk(${metric.topk}, ${expr})`; } - // map serviceGraph metric tags to serviceGraphView metric tags - serviceMapQuery = serviceMapQuery.replace('client', 'service').replace('server', 'service'); - const metricParams = serviceMapQuery.includes('span_name') - ? metric.params.concat(serviceMapQuery) - : metric.params - .concat(serviceMapQuery) - .concat(extraParams) - .filter((item: string) => item); - return metric.expr.replace('{}', '{' + metricParams.join(',') + '}'); + return expr; } export function buildLinkExpr(expr: string) { diff --git a/public/app/plugins/datasource/tempo/graphTransform.ts b/public/app/plugins/datasource/tempo/graphTransform.ts index 08cbbf19c6bb1..5c5b9cedfe064 100644 --- a/public/app/plugins/datasource/tempo/graphTransform.ts +++ b/public/app/plugins/datasource/tempo/graphTransform.ts @@ -10,8 +10,7 @@ import { FieldType, toDataFrame, } from '@grafana/data'; - -import { getNonOverlappingDuration, getStats, makeFrames, makeSpanMap } from '../../../core/utils/tracing'; +import { getNonOverlappingDuration, getStats, makeFrames, makeSpanMap } from '@grafana/o11y-ds-frontend'; /** * Row in a trace dataFrame @@ -139,11 +138,13 @@ export const failedMetric = 'traces_service_graph_request_failed_total'; export const histogramMetric = 'traces_service_graph_request_server_seconds_bucket'; export const rateMetric = { - expr: 'topk(5, sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name))', + expr: 'sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name)', + topk: 5, params: [], }; export const errorRateMetric = { - expr: 'topk(5, sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name))', + expr: 'sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name)', + topk: 5, params: ['status_code="STATUS_CODE_ERROR"'], }; export const durationMetric = { @@ -308,7 +309,7 @@ function collectMetricData( } // The name of the value column is in this format - // TODO figure out if it can be changed + // Improvement: figure out if it can be changed const valueName = `Value #${metric}`; for (let i = 0; i < frame.length; i++) { diff --git a/public/app/plugins/datasource/tempo/mockServiceGraph.json b/public/app/plugins/datasource/tempo/mockServiceGraph.json index abdd24a0d189d..267ffe9975df4 100644 --- a/public/app/plugins/datasource/tempo/mockServiceGraph.json +++ b/public/app/plugins/datasource/tempo/mockServiceGraph.json @@ -48,7 +48,7 @@ "title": "View traces", "internal": { "query": { - "queryType": "nativeSearch", + "queryType": "traceqlSearch", "serviceName": "${__data.fields[0]}" }, "datasourceUid": "TNS Tempo", diff --git a/public/app/plugins/datasource/tempo/package.json b/public/app/plugins/datasource/tempo/package.json new file mode 100644 index 0000000000000..5856e62e03bbb --- /dev/null +++ b/public/app/plugins/datasource/tempo/package.json @@ -0,0 +1,68 @@ +{ + "name": "@grafana-plugins/tempo", + "description": "Grafana plugin for the Tempo data source.", + "private": true, + "version": "11.0.0-pre", + "dependencies": { + "@emotion/css": "11.11.2", + "@grafana/data": "workspace:*", + "@grafana/e2e-selectors": "workspace:*", + "@grafana/experimental": "1.7.10", + "@grafana/lezer-logql": "0.2.3", + "@grafana/lezer-traceql": "0.0.16", + "@grafana/monaco-logql": "^0.0.7", + "@grafana/o11y-ds-frontend": "workspace:*", + "@grafana/runtime": "workspace:*", + "@grafana/schema": "workspace:*", + "@grafana/ui": "workspace:*", + "@lezer/common": "1.2.1", + "@lezer/lr": "1.3.3", + "@opentelemetry/api": "1.7.0", + "@opentelemetry/exporter-collector": "0.25.0", + "@opentelemetry/semantic-conventions": "1.21.0", + "buffer": "6.0.3", + "events": "3.3.0", + "i18next": "^23.0.0", + "lodash": "4.17.21", + "lru-cache": "10.2.0", + "monaco-editor": "0.34.0", + "prismjs": "1.29.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-use": "17.5.0", + "rxjs": "7.8.1", + "semver": "7.6.0", + "stream-browserify": "3.0.0", + "string_decoder": "1.3.0", + "tslib": "2.6.2", + "uuid": "9.0.1" + }, + "devDependencies": { + "@grafana/plugin-configs": "11.0.0-pre", + "@testing-library/jest-dom": "6.4.2", + "@testing-library/react": "14.2.1", + "@testing-library/user-event": "14.5.2", + "@types/jest": "29.5.12", + "@types/lodash": "4.17.0", + "@types/node": "20.11.28", + "@types/prismjs": "1.26.3", + "@types/react": "18.2.66", + "@types/react-dom": "18.2.22", + "@types/semver": "7.5.8", + "@types/uuid": "9.0.8", + "glob": "10.3.10", + "react-select-event": "5.5.1", + "ts-node": "10.9.2", + "typescript": "5.3.3", + "webpack": "5.90.3" + }, + "peerDependencies": { + "@grafana/runtime": "*" + }, + "scripts": { + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development" + }, + "packageManager": "yarn@4.1.0" +} diff --git a/public/app/plugins/datasource/tempo/plugin.json b/public/app/plugins/datasource/tempo/plugin.json index 629290de4a57e..cdc5b81ed947f 100644 --- a/public/app/plugins/datasource/tempo/plugin.json +++ b/public/app/plugins/datasource/tempo/plugin.json @@ -3,6 +3,7 @@ "name": "Tempo", "id": "tempo", "category": "tracing", + "executable": "gpx_tempo", "metrics": true, "alerting": false, @@ -27,6 +28,11 @@ "name": "GitHub Project", "url": "https://github.com/grafana/tempo" } - ] + ], + "version": "%VERSION%" + }, + + "dependencies": { + "grafanaDependency": ">=10.3.0-0" } } diff --git a/public/app/plugins/datasource/tempo/resultTransformer.test.ts b/public/app/plugins/datasource/tempo/resultTransformer.test.ts index 413c58aa011e5..2f416d80ef33d 100644 --- a/public/app/plugins/datasource/tempo/resultTransformer.test.ts +++ b/public/app/plugins/datasource/tempo/resultTransformer.test.ts @@ -1,27 +1,18 @@ import { collectorTypes } from '@opentelemetry/exporter-collector'; -import { - FieldType, - createDataFrame, - PluginType, - DataSourceInstanceSettings, - dateTime, - PluginMetaInfo, -} from '@grafana/data'; +import { PluginType, DataSourceInstanceSettings, PluginMetaInfo } from '@grafana/data'; import { - createTableFrame, transformToOTLP, transformFromOTLP, - createTableFrameFromSearch, createTableFrameFromTraceQlQuery, + createTableFrameFromTraceQlQueryAsSpans, } from './resultTransformer'; import { badOTLPResponse, otlpDataFrameToResponse, otlpDataFrameFromResponse, otlpResponse, - tempoSearchResponse, traceQlResponse, } from './testResponse'; import { TraceSearchMetadata } from './types'; @@ -44,41 +35,6 @@ const defaultSettings: DataSourceInstanceSettings = { jsonData: {}, }; -describe('transformTraceList()', () => { - const lokiDataFrame = createDataFrame({ - fields: [ - { - name: 'ts', - type: FieldType.time, - values: ['2020-02-12T15:05:14.265Z', '2020-02-12T15:05:15.265Z', '2020-02-12T15:05:16.265Z'], - }, - { - name: 'line', - type: FieldType.string, - values: [ - 't=2020-02-12T15:04:51+0000 lvl=info msg="Starting Grafana" logger=server', - 't=2020-02-12T15:04:52+0000 lvl=info msg="Starting Grafana" logger=server traceID=asdfa1234', - 't=2020-02-12T15:04:53+0000 lvl=info msg="Starting Grafana" logger=server traceID=asdf88', - ], - }, - ], - meta: { - preferredVisualisationType: 'table', - }, - }); - - test('extracts traceIDs from log lines', () => { - const frame = createTableFrame(lokiDataFrame, 't1', 'tempo', ['traceID=(\\w+)', 'traceID=(\\w\\w)']); - expect(frame.fields[0].name).toBe('Time'); - expect(frame.fields[0].values[0]).toBe('2020-02-12T15:05:15.265Z'); - expect(frame.fields[1].name).toBe('traceID'); - expect(frame.fields[1].values[0]).toBe('asdfa1234'); - // Second match in new line - expect(frame.fields[0].values[1]).toBe('2020-02-12T15:05:15.265Z'); - expect(frame.fields[1].values[1]).toBe('as'); - }); -}); - describe('transformToOTLP()', () => { test('transforms dataframe to OTLP format', () => { const otlp = transformToOTLP(otlpDataFrameToResponse); @@ -99,32 +55,6 @@ describe('transformFromOTLP()', () => { }); }); -describe('createTableFrameFromSearch()', () => { - const mockTimeUnix = dateTime(1643357709095).valueOf(); - global.Date.now = jest.fn(() => mockTimeUnix); - test('transforms search response to dataFrame', () => { - const frame = createTableFrameFromSearch(tempoSearchResponse.traces as TraceSearchMetadata[], defaultSettings); - expect(frame.fields[0].name).toBe('traceID'); - expect(frame.fields[0].values[0]).toBe('e641dcac1c3a0565'); - - // TraceID must have unit = 'string' to prevent the ID from rendering as Infinity - expect(frame.fields[0].config.unit).toBe('string'); - - expect(frame.fields[1].name).toBe('traceService'); - expect(frame.fields[1].values[0]).toBe('requester'); - - expect(frame.fields[2].name).toBe('traceName'); - expect(frame.fields[2].values[0]).toBe('app'); - - expect(frame.fields[3].name).toBe('startTime'); - expect(frame.fields[3].values[0]).toBe(1643356828724); - expect(frame.fields[3].values[1]).toBe(1643342166678.0002); - - expect(frame.fields[4].name).toBe('traceDuration'); - expect(frame.fields[4].values[0]).toBe(65); - }); -}); - describe('createTableFrameFromTraceQlQuery()', () => { test('transforms TraceQL response to DataFrame', () => { const frameList = createTableFrameFromTraceQlQuery(traceQlResponse.traces, defaultSettings); @@ -198,6 +128,217 @@ describe('createTableFrameFromTraceQlQuery()', () => { }); }); +describe('createTableFrameFromTraceQlQueryAsSpans()', () => { + test('transforms TraceQL legacy response to DataFrame for Spans table type', () => { + const traces = [ + { + traceID: '1', + rootServiceName: 'prometheus', + rootTraceName: 'POST /api/v1/write', + startTimeUnixNano: '1702984850354934104', + durationMs: 1, + spanSet: { + spans: [ + { + spanID: '11', + startTimeUnixNano: '1702984850354934104', + durationNanos: '1377608', + }, + ], + matched: 1, + attributes: [{ key: 'attr-key-1', value: { intValue: '123' } }], + }, + }, + { + traceID: '2', + rootServiceName: 'prometheus', + rootTraceName: 'GET /api/v1/status/config', + startTimeUnixNano: '1702984840786143459', + spanSet: { + spans: [ + { + spanID: '21', + startTimeUnixNano: '1702984840786143459', + durationNanos: '542316', + }, + ], + matched: 1, + attributes: [{ key: 'attr-key-2', value: { stringValue: '456' } }], + }, + }, + ]; + const frameList = createTableFrameFromTraceQlQueryAsSpans(traces, defaultSettings); + const frame = frameList[0]; + + // Trace ID field + expect(frame.fields[0].name).toBe('traceIdHidden'); + expect(frame.fields[0].type).toBe('string'); + expect(frame.fields[0].values[0]).toBe('1'); + // Trace service field + expect(frame.fields[1].name).toBe('traceService'); + expect(frame.fields[1].type).toBe('string'); + expect(frame.fields[1].values[0]).toBe('prometheus'); + // Trace name field + expect(frame.fields[2].name).toBe('traceName'); + expect(frame.fields[2].type).toBe('string'); + expect(frame.fields[2].values[0]).toBe('POST /api/v1/write'); + // Span ID field + expect(frame.fields[3].name).toBe('spanID'); + expect(frame.fields[3].type).toBe('string'); + expect(frame.fields[3].values[0]).toBe('11'); + // Time field + expect(frame.fields[4].name).toBe('time'); + expect(frame.fields[4].type).toBe('time'); + expect(frame.fields[4].values[0]).toBe(1702984850354.934); + // Name field + expect(frame.fields[5].name).toBe('name'); + expect(frame.fields[5].type).toBe('string'); + expect(frame.fields[5].values[0]).toBe(undefined); + // Dynamic fields + expect(frame.fields[6].name).toBe('attr-key-1'); + expect(frame.fields[6].type).toBe('string'); + expect(frame.fields[6].values[0]).toBe('123'); + expect(frame.fields[6].values[1]).toBe(undefined); + expect(frame.fields[6].values.length).toBe(2); + expect(frame.fields[7].name).toBe('attr-key-2'); + expect(frame.fields[7].type).toBe('string'); + expect(frame.fields[7].values[0]).toBe(undefined); + expect(frame.fields[7].values[1]).toBe('456'); + expect(frame.fields[7].values.length).toBe(2); + // Duration field + expect(frame.fields[8].name).toBe('duration'); + expect(frame.fields[8].type).toBe('number'); + expect(frame.fields[8].values[0]).toBe(1377608); + // No more fields + expect(frame.fields.length).toBe(9); + }); + + test('transforms TraceQL response to DataFrame for Spans table type', () => { + const traces = [ + { + traceID: '1', + rootServiceName: 'prometheus', + rootTraceName: 'POST /api/v1/write', + startTimeUnixNano: '1702984850354934104', + durationMs: 1, + spanSets: [ + { + spans: [ + { + spanID: '11', + startTimeUnixNano: '1702984850354934104', + durationNanos: '1377608', + }, + ], + + matched: 1, + attributes: [{ key: 'attr-key-1', value: { intValue: '123' } }], + }, + ], + }, + { + traceID: '2', + rootServiceName: 'prometheus', + rootTraceName: 'GET /api/v1/status/config', + startTimeUnixNano: '1702984840786143459', + spanSets: [ + { + spans: [ + { + spanID: '21', + startTimeUnixNano: '1702984840786143459', + durationNanos: '542316', + }, + ], + matched: 1, + attributes: [{ key: 'attr-key-2', value: { stringValue: '456' } }], + }, + ], + }, + ]; + const frameList = createTableFrameFromTraceQlQueryAsSpans(traces, defaultSettings); + const frame = frameList[0]; + + // Trace ID field + expect(frame.fields[0].name).toBe('traceIdHidden'); + expect(frame.fields[0].type).toBe('string'); + expect(frame.fields[0].values[0]).toBe('1'); + // Trace service field + expect(frame.fields[1].name).toBe('traceService'); + expect(frame.fields[1].type).toBe('string'); + expect(frame.fields[1].values[0]).toBe('prometheus'); + // Trace name field + expect(frame.fields[2].name).toBe('traceName'); + expect(frame.fields[2].type).toBe('string'); + expect(frame.fields[2].values[0]).toBe('POST /api/v1/write'); + // Span ID field + expect(frame.fields[3].name).toBe('spanID'); + expect(frame.fields[3].type).toBe('string'); + expect(frame.fields[3].values[0]).toBe('11'); + // Time field + expect(frame.fields[4].name).toBe('time'); + expect(frame.fields[4].type).toBe('time'); + expect(frame.fields[4].values[0]).toBe(1702984850354.934); + // Name field + expect(frame.fields[5].name).toBe('name'); + expect(frame.fields[5].type).toBe('string'); + expect(frame.fields[5].values[0]).toBe(undefined); + // Dynamic fields + expect(frame.fields[6].name).toBe('attr-key-1'); + expect(frame.fields[6].type).toBe('string'); + expect(frame.fields[6].values[0]).toBe('123'); + expect(frame.fields[6].values[1]).toBe(undefined); + expect(frame.fields[6].values.length).toBe(2); + expect(frame.fields[7].name).toBe('attr-key-2'); + expect(frame.fields[7].type).toBe('string'); + expect(frame.fields[7].values[0]).toBe(undefined); + expect(frame.fields[7].values[1]).toBe('456'); + expect(frame.fields[7].values.length).toBe(2); + // Duration field + expect(frame.fields[8].name).toBe('duration'); + expect(frame.fields[8].type).toBe('number'); + expect(frame.fields[8].values[0]).toBe(1377608); + // No more fields + expect(frame.fields.length).toBe(9); + }); + + it.each([[undefined], [[]]])('TraceQL response with no data', (traces: TraceSearchMetadata[] | undefined) => { + const frameList = createTableFrameFromTraceQlQueryAsSpans(traces, defaultSettings); + const frame = frameList[0]; + + // Trace ID field + expect(frame.fields[0].name).toBe('traceIdHidden'); + expect(frame.fields[0].type).toBe('string'); + expect(frame.fields[0].values).toMatchObject([]); + // Trace service field + expect(frame.fields[1].name).toBe('traceService'); + expect(frame.fields[1].type).toBe('string'); + expect(frame.fields[1].values).toMatchObject([]); + // Trace name field + expect(frame.fields[2].name).toBe('traceName'); + expect(frame.fields[2].type).toBe('string'); + expect(frame.fields[2].values).toMatchObject([]); + // Span ID field + expect(frame.fields[3].name).toBe('spanID'); + expect(frame.fields[3].type).toBe('string'); + expect(frame.fields[3].values).toMatchObject([]); + // Time field + expect(frame.fields[4].name).toBe('time'); + expect(frame.fields[4].type).toBe('time'); + expect(frame.fields[4].values).toMatchObject([]); + // Name field + expect(frame.fields[5].name).toBe('name'); + expect(frame.fields[5].type).toBe('string'); + expect(frame.fields[5].values).toMatchObject([]); + // Duration field + expect(frame.fields[6].name).toBe('duration'); + expect(frame.fields[6].type).toBe('number'); + expect(frame.fields[6].values).toMatchObject([]); + // No more fields + expect(frame.fields.length).toBe(7); + }); +}); + describe('transformFromOTLP()', () => { // Mock the console error so that running the test suite doesnt throw the error const origError = console.error; diff --git a/public/app/plugins/datasource/tempo/resultTransformer.ts b/public/app/plugins/datasource/tempo/resultTransformer.ts index dfb68c4216cef..c260b0b78c86d 100644 --- a/public/app/plugins/datasource/tempo/resultTransformer.ts +++ b/public/app/plugins/datasource/tempo/resultTransformer.ts @@ -3,133 +3,40 @@ import { collectorTypes } from '@opentelemetry/exporter-collector'; import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; import { + createDataFrame, + createTheme, DataFrame, + DataLink, + DataLinkConfigOrigin, DataQueryResponse, DataSourceInstanceSettings, + DataSourceJsonData, + Field, + FieldDTO, FieldType, + getDisplayProcessor, + Labels, MutableDataFrame, + toDataFrame, TraceKeyValuePair, TraceLog, TraceSpanReference, TraceSpanRow, - FieldDTO, - createDataFrame, - getDisplayProcessor, - createTheme, - DataFrameDTO, - toDataFrame, - DataLink, - DataSourceJsonData, - Field, - DataLinkConfigOrigin, } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { TraceToProfilesData } from 'app/core/components/TraceToProfiles/TraceToProfilesSettings'; -import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { TraceToProfilesData } from '@grafana/o11y-ds-frontend'; +import { getDataSourceSrv } from '@grafana/runtime'; import { SearchTableType } from './dataquery.gen'; import { createGraphFrames } from './graphTransform'; -import { Span, SpanAttributes, Spanset, TempoJsonData, TraceSearchMetadata } from './types'; - -export function createTableFrame( - logsFrame: DataFrame | DataFrameDTO, - datasourceUid: string, - datasourceName: string, - traceRegexs: string[] -): DataFrame { - const tableFrame = new MutableDataFrame({ - fields: [ - { - name: 'Time', - type: FieldType.time, - config: { - custom: { - width: 200, - }, - }, - values: [], - }, - { - name: 'traceID', - type: FieldType.string, - config: { - displayNameFromDS: 'Trace ID', - custom: { width: 180 }, - links: [ - { - title: 'Click to open trace ${__value.raw}', - url: '', - internal: { - datasourceUid, - datasourceName, - query: { - query: '${__value.raw}', - }, - }, - }, - ], - }, - values: [], - }, - { - name: 'Message', - type: FieldType.string, - values: [], - }, - ], - meta: { - preferredVisualisationType: 'table', - }, - }); - - if (!logsFrame || traceRegexs.length === 0) { - return tableFrame; - } - - const timeField = logsFrame.fields.find((f) => f.type === FieldType.time); - - // Going through all string fields to look for trace IDs - for (let field of logsFrame.fields) { - let hasMatch = false; - if (field.type === FieldType.string) { - const values = field.values!; - for (let i = 0; i < values.length; i++) { - const line = values[i]; - if (line) { - for (let traceRegex of traceRegexs) { - const match = line.match(traceRegex); - if (match) { - const traceId = match[1]; - const time = timeField ? timeField.values![i] : null; - tableFrame.fields[0].values.push(time); - tableFrame.fields[1].values.push(traceId); - tableFrame.fields[2].values.push(line); - hasMatch = true; - } - } - } - } - } - if (hasMatch) { - break; - } - } - - return tableFrame; -} - -export function transformTraceList( - response: DataQueryResponse, - datasourceId: string, - datasourceName: string, - traceRegexs: string[] -): DataQueryResponse { - response.data.forEach((data, index) => { - const frame = createTableFrame(data, datasourceId, datasourceName, traceRegexs); - response.data[index] = frame; - }); - return response; -} +import { + ProtoValue, + Span, + SpanAttributes, + Spanset, + TempoJsonData, + TraceqlMetricsResponse, + TraceSearchMetadata, +} from './types'; function getAttributeValue(value: collectorTypes.opentelemetryProto.common.v1.AnyValue): any { if (value.stringValue) { @@ -512,40 +419,38 @@ export function transformTrace( } // Get profiles links - if (config.featureToggles.traceToProfiles) { - const traceToProfilesData: TraceToProfilesData | undefined = instanceSettings?.jsonData; - const traceToProfilesOptions = traceToProfilesData?.tracesToProfiles; - let profilesDataSourceSettings: DataSourceInstanceSettings<DataSourceJsonData> | undefined; - if (traceToProfilesOptions?.datasourceUid) { - profilesDataSourceSettings = getDatasourceSrv().getInstanceSettings(traceToProfilesOptions.datasourceUid); - } - - if (traceToProfilesOptions && profilesDataSourceSettings) { - const customQuery = traceToProfilesOptions.customQuery ? traceToProfilesOptions.query : undefined; - const dataLink: DataLink = { - title: RelatedProfilesTitle, - url: '', - internal: { - datasourceUid: profilesDataSourceSettings.uid, - datasourceName: profilesDataSourceSettings.name, - query: { - labelSelector: customQuery ? customQuery : '{${__tags}}', - groupBy: [], - profileTypeId: traceToProfilesOptions.profileTypeId ?? '', - queryType: 'profile', - spanSelector: ['${__span.tags["pyroscope.profile.id"]}'], - refId: 'profile', - }, + const traceToProfilesData: TraceToProfilesData | undefined = instanceSettings?.jsonData; + const traceToProfilesOptions = traceToProfilesData?.tracesToProfiles; + let profilesDataSourceSettings: DataSourceInstanceSettings<DataSourceJsonData> | undefined; + if (traceToProfilesOptions?.datasourceUid) { + profilesDataSourceSettings = getDataSourceSrv().getInstanceSettings(traceToProfilesOptions.datasourceUid); + } + + if (traceToProfilesOptions && profilesDataSourceSettings) { + const customQuery = traceToProfilesOptions.customQuery ? traceToProfilesOptions.query : undefined; + const dataLink: DataLink = { + title: RelatedProfilesTitle, + url: '', + internal: { + datasourceUid: profilesDataSourceSettings.uid, + datasourceName: profilesDataSourceSettings.name, + query: { + labelSelector: customQuery ? customQuery : '{${__tags}}', + groupBy: [], + profileTypeId: traceToProfilesOptions.profileTypeId ?? '', + queryType: 'profile', + spanSelector: ['${__span.tags["pyroscope.profile.id"]}'], + refId: 'profile', }, - origin: DataLinkConfigOrigin.Datasource, - }; + }, + origin: DataLinkConfigOrigin.Datasource, + }; - frame.fields.forEach((field: Field) => { - if (field.name === 'tags') { - field.config.links = [dataLink]; - } - }); - } + frame.fields.forEach((field: Field) => { + if (field.name === 'tags') { + field.config.links = [dataLink]; + } + }); } let data = [...response.data]; @@ -559,63 +464,6 @@ export function transformTrace( }; } -export function createTableFrameFromSearch(data: TraceSearchMetadata[], instanceSettings: DataSourceInstanceSettings) { - const frame = new MutableDataFrame({ - name: 'Traces', - refId: 'traces', - fields: [ - { - name: 'traceID', - type: FieldType.string, - values: [], - config: { - unit: 'string', - displayNameFromDS: 'Trace ID', - links: [ - { - title: 'Trace: ${__value.raw}', - url: '', - internal: { - datasourceUid: instanceSettings.uid, - datasourceName: instanceSettings.name, - query: { - query: '${__value.raw}', - queryType: 'traceql', - }, - }, - }, - ], - }, - }, - { name: 'traceService', type: FieldType.string, config: { displayNameFromDS: 'Trace service' }, values: [] }, - { name: 'traceName', type: FieldType.string, config: { displayNameFromDS: 'Trace name' }, values: [] }, - { name: 'startTime', type: FieldType.time, config: { displayNameFromDS: 'Start time' }, values: [] }, - { - name: 'traceDuration', - type: FieldType.number, - config: { displayNameFromDS: 'Duration', unit: 'ms' }, - values: [], - }, - ], - meta: { - preferredVisualisationType: 'table', - }, - }); - if (!data?.length) { - return frame; - } - // Show the most recent traces - const traceData = data - .sort((a, b) => parseInt(b?.startTimeUnixNano!, 10) / 1000000 - parseInt(a?.startTimeUnixNano!, 10) / 1000000) - .map(transformToTraceData); - - for (const trace of traceData) { - frame.add(trace); - } - - return frame; -} - function transformToTraceData(data: TraceSearchMetadata) { return { traceID: data.traceID, @@ -626,6 +474,56 @@ function transformToTraceData(data: TraceSearchMetadata) { }; } +const metricsValueToString = (value: ProtoValue): string => { + if (value.stringValue) { + return `"${value.stringValue}"`; + } + return '' + (value.intValue || value.doubleValue || value.boolValue || '""'); +}; + +export function formatTraceQLMetrics(query: string, data: TraceqlMetricsResponse) { + const frames = data.series.map((series, index) => { + const labels: Labels = {}; + series.labels?.forEach((label) => { + labels[label.key] = metricsValueToString(label.value); + }); + // If it's a single series, use the query as the displayName fallback + let name = data.series.length === 1 ? query : ''; + if (series.labels) { + if (series.labels.length === 1) { + // For single label series, use the label value as the displayName to improve readability + name = metricsValueToString(series.labels[0].value); + } else { + // otherwise build a string using the label keys and values + name = `{${series.labels.map((label) => `${label.key}=${metricsValueToString(label.value)}`).join(', ')}}`; + } + } + return createDataFrame({ + refId: name || `A${index}`, + fields: [ + { + name: 'time', + type: FieldType.time, + values: series.samples.map((sample) => parseInt(sample.timestampMs, 10)), + }, + { + name: name, + labels, + type: FieldType.number, + values: series.samples.map((sample) => sample.value), + config: { + displayNameFromDS: name, + }, + }, + ], + meta: { + preferredVisualisationType: 'graph', + }, + }); + }); + return frames; +} + export function formatTraceQLResponse( data: TraceSearchMetadata[], instanceSettings: DataSourceInstanceSettings, @@ -637,6 +535,11 @@ export function formatTraceQLResponse( return createTableFrameFromTraceQlQuery(data, instanceSettings); } +/** + * Create data frame while adding spans for each trace into a subtable. + * @param data + * @param instanceSettings + */ export function createTableFrameFromTraceQlQuery( data: TraceSearchMetadata[], instanceSettings: DataSourceInstanceSettings @@ -700,6 +603,7 @@ export function createTableFrameFromTraceQlQuery( ], meta: { preferredVisualisationType: 'table', + uniqueRowIdFields: [0], }, }); @@ -734,35 +638,34 @@ export function createTableFrameFromTraceQlQuery( } export function createTableFrameFromTraceQlQueryAsSpans( - data: TraceSearchMetadata[], + data: TraceSearchMetadata[] | undefined, instanceSettings: DataSourceInstanceSettings ): DataFrame[] { const spanDynamicAttrs: Record<string, FieldDTO> = {}; let hasNameAttribute = false; - data?.forEach( - (t) => - t.spanSets?.forEach((ss) => { - ss.attributes?.forEach((attr) => { + data?.forEach((trace) => + getSpanSets(trace).forEach((ss) => { + ss.attributes?.forEach((attr) => { + spanDynamicAttrs[attr.key] = { + name: attr.key, + type: FieldType.string, + config: { displayNameFromDS: attr.key }, + }; + }); + ss.spans.forEach((span) => { + if (span.name) { + hasNameAttribute = true; + } + span.attributes?.forEach((attr) => { spanDynamicAttrs[attr.key] = { name: attr.key, type: FieldType.string, config: { displayNameFromDS: attr.key }, }; }); - ss.spans.forEach((span) => { - if (span.name) { - hasNameAttribute = true; - } - span.attributes?.forEach((attr) => { - spanDynamicAttrs[attr.key] = { - name: attr.key, - type: FieldType.string, - config: { displayNameFromDS: attr.key }, - }; - }); - }); - }) + }); + }) ); const frame = new MutableDataFrame({ @@ -856,7 +759,7 @@ export function createTableFrameFromTraceQlQueryAsSpans( }, }); - if (!data?.length) { + if (!data || !data.length) { return [frame]; } @@ -864,7 +767,7 @@ export function createTableFrameFromTraceQlQueryAsSpans( // Show the most recent traces .sort((a, b) => parseInt(b?.startTimeUnixNano!, 10) / 1000000 - parseInt(a?.startTimeUnixNano!, 10) / 1000000) .forEach((trace) => { - trace.spanSets?.forEach((spanSet) => { + getSpanSets(trace).forEach((spanSet) => { spanSet.spans.forEach((span) => { frame.add(transformSpanToTraceData(span, spanSet, trace)); }); @@ -874,6 +777,19 @@ export function createTableFrameFromTraceQlQueryAsSpans( return [frame]; } +/** + * Get the spansets of a trace. + * + * Field `spanSets` is preferred to `spanSet` since the latter is deprecated in Tempo, but we + * support both for backward compatibility. + * + * @param trace a trace + * @returns the spansets of the trace, if existing + */ +const getSpanSets = (trace: TraceSearchMetadata): Spanset[] => { + return trace.spanSets || (trace.spanSet ? [trace.spanSet] : []); +}; + const traceSubFrame = ( trace: TraceSearchMetadata, spanSet: Spanset, @@ -980,6 +896,7 @@ const traceSubFrame = ( }, }); + // TODO: this should be done in `applyFieldOverrides` instead recursively for the nested `DataFrames` const theme = createTheme(); for (const field of subFrame.fields) { field.display = getDisplayProcessor({ field, theme }); diff --git a/public/app/plugins/datasource/tempo/testResponse.ts b/public/app/plugins/datasource/tempo/testResponse.ts index 6910941082382..687e90b82cbea 100644 --- a/public/app/plugins/datasource/tempo/testResponse.ts +++ b/public/app/plugins/datasource/tempo/testResponse.ts @@ -2273,28 +2273,6 @@ export const otlpResponse = { ], }; -export const tempoSearchResponse = { - traces: [ - { - traceID: 'e641dcac1c3a0565', - rootServiceName: 'requester', - rootTraceName: 'app', - startTimeUnixNano: '1643356828724000000', - durationMs: 65, - }, - { - traceID: 'c2983496a2b12544', - rootServiceName: '<root span not yet received>', - startTimeUnixNano: '1643342166678000000', - durationMs: 93, - }, - ], - metrics: { - inspectedTraces: 2, - inspectedBytes: '83720', - }, -}; - export const traceQlResponse = { traces: [ { diff --git a/public/app/plugins/datasource/tempo/test_utils.ts b/public/app/plugins/datasource/tempo/test_utils.ts new file mode 100644 index 0000000000000..4a351355ce4b7 --- /dev/null +++ b/public/app/plugins/datasource/tempo/test_utils.ts @@ -0,0 +1,31 @@ +import { TimeRange, ScopedVars } from '@grafana/data'; +import { getTemplateSrv, setTemplateSrv } from '@grafana/runtime'; + +export const initTemplateSrv = (variables: Array<{ name: string }>, expectedValues: Record<string, string>) => { + const replace = (target?: string, scopedVars?: ScopedVars, format?: string | Function): string => { + if (!target) { + return target ?? ''; + } + + const variableRegex = /\$(\w+)|\[\[(\w+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g; + variableRegex.lastIndex = 0; + + return target.replace(variableRegex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { + const variableName = var1 || var2 || var3; + return expectedValues[variableName]; + }); + }; + + setTemplateSrv({ + replace: replace, + // @ts-ignore + getVariables() { + return variables; + }, + containsTemplate() { + return false; + }, + updateTimeRange(timeRange: TimeRange) {}, + }); + return getTemplateSrv(); +}; diff --git a/public/app/plugins/datasource/tempo/traceql/QueryEditor.tsx b/public/app/plugins/datasource/tempo/traceql/QueryEditor.tsx index 2a75c84a33bf1..7e5f7e024e821 100644 --- a/public/app/plugins/datasource/tempo/traceql/QueryEditor.tsx +++ b/public/app/plugins/datasource/tempo/traceql/QueryEditor.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { defaults } from 'lodash'; import React, { useState } from 'react'; -import { QueryEditorProps } from '@grafana/data'; +import { GrafanaTheme2, QueryEditorProps } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; import { Button, InlineLabel, useStyles2 } from '@grafana/ui'; @@ -27,10 +27,6 @@ export function QueryEditor(props: Props) { return genQuery === query.query || genQuery === '{}'; }); - const onEditorChange = (value: string) => { - props.onChange({ ...query, query: value }); - }; - return ( <> <InlineLabel> @@ -40,37 +36,35 @@ export function QueryEditor(props: Props) { </a> </InlineLabel> {!showCopyFromSearchButton && ( - <InlineLabel> - <div> - Continue editing the query from the Search tab? - <Button - variant="secondary" - size="sm" - onClick={() => { - reportInteraction('grafana_traces_copy_to_traceql_clicked', { - datasourceType: 'tempo', - app: props.app ?? '', - grafana_version: config.buildInfo.version, - }); + <div className={styles.copyContainer}> + <span>Continue editing the query from the Search tab?</span> + <Button + variant="secondary" + size="sm" + onClick={() => { + reportInteraction('grafana_traces_copy_to_traceql_clicked', { + app: props.app ?? '', + grafana_version: config.buildInfo.version, + location: 'traceql_tab', + }); - props.onClearResults(); - props.onChange({ - ...query, - query: generateQueryFromFilters(query.filters || []), - }); - setShowCopyFromSearchButton(true); - }} - style={{ marginLeft: '10px' }} - > - Copy query from Search - </Button> - </div> - </InlineLabel> + props.onClearResults(); + props.onChange({ + ...query, + query: generateQueryFromFilters(query.filters || []), + }); + setShowCopyFromSearchButton(true); + }} + style={{ marginLeft: '10px' }} + > + Copy query from Search + </Button> + </div> )} <TraceQLEditor placeholder="Enter a TraceQL query or trace ID (run with Shift+Enter)" - value={query.query || ''} - onChange={onEditorChange} + query={query} + onChange={props.onChange} datasource={props.datasource} onRunQuery={props.onRunQuery} /> @@ -81,8 +75,13 @@ export function QueryEditor(props: Props) { ); } -const getStyles = () => ({ - optionsContainer: css` - margin-top: 10px; - `, +const getStyles = (theme: GrafanaTheme2) => ({ + optionsContainer: css({ + marginTop: '10px', + }), + copyContainer: css({ + backgroundColor: theme.colors.background.secondary, + padding: theme.spacing(0.5, 1), + fontSize: theme.typography.body.fontSize, + }), }); diff --git a/public/app/plugins/datasource/tempo/traceql/TempoQueryBuilderOptions.tsx b/public/app/plugins/datasource/tempo/traceql/TempoQueryBuilderOptions.tsx index 5c5e239cfcd69..98658945e5417 100644 --- a/public/app/plugins/datasource/tempo/traceql/TempoQueryBuilderOptions.tsx +++ b/public/app/plugins/datasource/tempo/traceql/TempoQueryBuilderOptions.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { EditorField, EditorRow } from '@grafana/experimental'; import { AutoSizeInput, RadioButtonGroup } from '@grafana/ui'; -import { QueryOptionGroup } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryOptionGroup'; +import { QueryOptionGroup } from '../_importedDependencies/datasources/prometheus/QueryOptionGroup'; import { SearchTableType } from '../dataquery.gen'; import { DEFAULT_LIMIT, DEFAULT_SPSS } from '../datasource'; import { TempoQuery } from '../types'; @@ -13,6 +13,19 @@ interface Props { query: Partial<TempoQuery> & TempoQuery; } +/** + * Parse a string value to integer. If the conversion fails, for example because we are prosessing an empty value for + * a field, return a fallback (default) value. + * + * @param val the value to be parsed to an integer + * @param fallback the fallback value + * @returns the converted value or the fallback value if the conversion fails + */ +const parseIntWithFallback = (val: string, fallback: number) => { + const parsed = parseInt(val, 10); + return isNaN(parsed) ? fallback : parsed; +}; + export const TempoQueryBuilderOptions = React.memo<Props>(({ onChange, query }) => { if (!query.hasOwnProperty('limit')) { query.limit = DEFAULT_LIMIT; @@ -23,10 +36,10 @@ export const TempoQueryBuilderOptions = React.memo<Props>(({ onChange, query }) } const onLimitChange = (e: React.FormEvent<HTMLInputElement>) => { - onChange({ ...query, limit: parseInt(e.currentTarget.value, 10) }); + onChange({ ...query, limit: parseIntWithFallback(e.currentTarget.value, DEFAULT_LIMIT) }); }; const onSpssChange = (e: React.FormEvent<HTMLInputElement>) => { - onChange({ ...query, spss: parseInt(e.currentTarget.value, 10) }); + onChange({ ...query, spss: parseIntWithFallback(e.currentTarget.value, DEFAULT_SPSS) }); }; const onTableTypeChange = (val: SearchTableType) => { onChange({ ...query, tableType: val }); diff --git a/public/app/plugins/datasource/tempo/traceql/TraceQLEditor.test.tsx b/public/app/plugins/datasource/tempo/traceql/TraceQLEditor.test.tsx deleted file mode 100644 index 6af62515efdc7..0000000000000 --- a/public/app/plugins/datasource/tempo/traceql/TraceQLEditor.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { computeErrorMessage, getErrorNodes } from './errorHighlighting'; - -describe('Check for syntax errors in query', () => { - it.each([ - ['{span.http.status_code = }', 'Invalid value after comparison or arithmetic operator.'], - ['{span.http.status_code 200}', 'Invalid comparison operator after field expression.'], - ['{span.http.status_code ""}', 'Invalid operator after field expression.'], - ['{span.http.status_code @ 200}', 'Invalid comparison operator after field expression.'], - ['{span.http.status_code span.http.status_code}', 'Invalid operator after field expression.'], - [ - '{span.http.status_code = 200} {span.http.status_code = 200}', - 'Invalid spanset combining operator after spanset expression.', - ], - [ - '{span.http.status_code = 200} + {span.http.status_code = 200}', - 'Invalid spanset combining operator after spanset expression.', - ], - ['{span.http.status_code = 200} &&', 'Invalid spanset expression after spanset combining operator.'], - [ - '{span.http.status_code = 200} && {span.http.status_code = 200} | foo() > 3', - 'Invalid aggregation operator after pipepile operator.', - ], - [ - '{span.http.status_code = 200} && {span.http.status_code = 200} | avg() > 3', - 'Invalid expression for aggregator operator.', - ], - ['{ 1 + 1 = 2 + }', 'Invalid value after comparison or arithmetic operator.'], - ['{ .a && }', 'Invalid value after logical operator.'], - ['{ .a || }', 'Invalid value after logical operator.'], - ['{ .a + }', 'Invalid value after comparison or arithmetic operator.'], - ['{ 200 = 200 200 }', 'Invalid comparison operator after field expression.'], - ['{.foo 300}', 'Invalid comparison operator after field expression.'], - ['{.foo 300 && .bar = 200}', 'Invalid operator after field expression.'], - ['{.foo 300 && .bar 200}', 'Invalid operator after field expression.'], - ['{.foo=1} {.bar=2}', 'Invalid spanset combining operator after spanset expression.'], - ['{ span.http.status_code = 200 && }', 'Invalid value after logical operator.'], - ['{ span.http.status_code = 200 || }', 'Invalid value after logical operator.'], - ['{ .foo = 200 } && ', 'Invalid spanset expression after spanset combining operator.'], - ['{ .foo = 200 } || ', 'Invalid spanset expression after spanset combining operator.'], - ['{ .foo = 200 } >> ', 'Invalid spanset expression after spanset combining operator.'], - ['{.foo=1} | avg()', 'Invalid expression for aggregator operator.'], - ['{.foo=1} | avg(.foo) > ', 'Invalid value after comparison operator.'], - ['{.foo=1} | avg() < 1s', 'Invalid expression for aggregator operator.'], - ['{.foo=1} | max() = 3', 'Invalid expression for aggregator operator.'], - ['{.foo=1} | by()', 'Invalid expression for aggregator operator.'], - ['{.foo=1} | select()', 'Invalid expression for aggregator operator.'], - ['{foo}', 'Invalid expression for spanset.'], - ['{.}', 'Invalid expression for spanset.'], - ['{ resource. }', 'Invalid expression for spanset.'], - ['{ span. }', 'Invalid expression for spanset.'], - ['{.foo=}', 'Invalid value after comparison or arithmetic operator.'], - ['{.foo="}', 'Invalid value after comparison or arithmetic operator.'], - ['{.foo=300} |', 'Invalid aggregation operator after pipepile operator.'], - ['{.foo=300} && {.bar=200} |', 'Invalid aggregation operator after pipepile operator.'], - ['{.foo=300} && {.bar=300} && {.foo=300} |', 'Invalid aggregation operator after pipepile operator.'], - ['{.foo=300} | avg(.value)', 'Invalid comparison operator after aggregator operator.'], - ['{.foo=300} && {.foo=300} | avg(.value)', 'Invalid comparison operator after aggregator operator.'], - ['{.foo=300} | avg(.value) =', 'Invalid value after comparison operator.'], - ['{.foo=300} && {.foo=300} | avg(.value) =', 'Invalid value after comparison operator.'], - ['{.foo=300} | max(duration) > 1hs', 'Invalid value after comparison operator.'], - ['{ span.http.status_code', 'Invalid comparison operator after field expression.'], - ['{ .foo = "bar"', 'Invalid comparison operator after field expression.'], - ['abcxyz', 'Invalid query.'], - ])('error message for invalid query - %s, %s', (query: string, expectedErrorMessage: string) => { - const errorNode = getErrorNodes(query)[0]; - expect(computeErrorMessage(errorNode)).toBe(expectedErrorMessage); - }); - - it.each([ - ['123'], - ['abc'], - ['1a2b3c'], - ['{span.status = $code}'], - ['{span.${attribute} = "GET"}'], - ['{span.${attribute:format} = ${value:format} }'], - ['{true} >> {true}'], - ['{true} << {true}'], - ['{true} !>> {true}'], - ['{true} !<< {true}'], - [ - `{ true } /* && { false } && */ && { true } // && { false } - && { true }`, - ], - ['{span.s"t\\"at"us}'], - ['{span.s"t\\\\at"us}'], - ['{ span.s"tat"us" = "GET123 }'], // weird query, but technically valid - ])('valid query - %s', (query: string) => { - expect(getErrorNodes(query)).toStrictEqual([]); - }); -}); diff --git a/public/app/plugins/datasource/tempo/traceql/TraceQLEditor.tsx b/public/app/plugins/datasource/tempo/traceql/TraceQLEditor.tsx index 05cb706ca9aaa..3f40bc006acdf 100644 --- a/public/app/plugins/datasource/tempo/traceql/TraceQLEditor.tsx +++ b/public/app/plugins/datasource/tempo/traceql/TraceQLEditor.tsx @@ -1,33 +1,45 @@ import { css } from '@emotion/css'; -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { TemporaryAlert } from '@grafana/o11y-ds-frontend'; import { reportInteraction } from '@grafana/runtime'; import { CodeEditor, Monaco, monacoTypes, useTheme2 } from '@grafana/ui'; -import { createErrorNotification } from '../../../../core/copy/appNotification'; -import { notifyApp } from '../../../../core/reducers/appNotification'; -import { dispatch } from '../../../../store/store'; import { TempoDatasource } from '../datasource'; +import { TempoQuery } from '../types'; import { CompletionProvider, CompletionType } from './autocomplete'; -import { getErrorNodes, setErrorMarkers } from './errorHighlighting'; +import { getErrorNodes, setMarkers } from './highlighting'; import { languageDefinition } from './traceql'; interface Props { placeholder: string; - value: string; - onChange: (val: string) => void; + query: TempoQuery; + onChange: (val: TempoQuery) => void; onRunQuery: () => void; datasource: TempoDatasource; readOnly?: boolean; } export function TraceQLEditor(props: Props) { - const { onChange, onRunQuery, placeholder } = props; - const setupAutocompleteFn = useAutocomplete(props.datasource); + const [alertText, setAlertText] = useState<string>(); + + const { query, onChange, onRunQuery, placeholder } = props; + const setupAutocompleteFn = useAutocomplete(props.datasource, setAlertText); const theme = useTheme2(); const styles = getStyles(theme, placeholder); + + // The Monaco Editor uses the first version of props.onChange in handleOnMount i.e. always has the initial + // value of query because underlying Monaco editor is passed `query` below in the onEditorChange callback. + // handleOnMount is called only once when the editor is mounted and does not get updates to query. + // So we need useRef to get the latest version of query in the onEditorChange callback. + const queryRef = useRef(query); + queryRef.current = query; + const onEditorChange = (value: string) => { + onChange({ ...queryRef.current, query: value }); + }; + // work around the problem that `onEditorDidMount` is called once // and wouldn't get new version of onRunQuery const onRunQueryRef = useRef(onRunQuery); @@ -36,73 +48,76 @@ export function TraceQLEditor(props: Props) { const errorTimeoutId = useRef<number>(); return ( - <CodeEditor - value={props.value} - language={langId} - onBlur={onChange} - onChange={onChange} - containerStyles={styles.queryField} - readOnly={props.readOnly} - monacoOptions={{ - folding: false, - fontSize: 14, - lineNumbers: 'off', - overviewRulerLanes: 0, - renderLineHighlight: 'none', - scrollbar: { - vertical: 'hidden', - verticalScrollbarSize: 8, // used as "padding-right" - horizontal: 'hidden', - horizontalScrollbarSize: 0, - }, - scrollBeyondLastLine: false, - wordWrap: 'on', - }} - onBeforeEditorMount={ensureTraceQL} - onEditorDidMount={(editor, monaco) => { - if (!props.readOnly) { - setupAutocompleteFn(editor, monaco, setupRegisterInteractionCommand(editor)); - setupActions(editor, monaco, () => onRunQueryRef.current()); - setupPlaceholder(editor, monaco, styles); - } - setupAutoSize(editor); - - // Parse query that might already exist (e.g., after a page refresh) - const model = editor.getModel(); - if (model) { - const errorNodes = getErrorNodes(model.getValue()); - setErrorMarkers(monaco, model, errorNodes); - } + <> + <CodeEditor + value={query.query || ''} + language={langId} + onBlur={onEditorChange} + onChange={onEditorChange} + containerStyles={styles.queryField} + readOnly={props.readOnly} + monacoOptions={{ + folding: false, + fontSize: 14, + lineNumbers: 'off', + overviewRulerLanes: 0, + renderLineHighlight: 'none', + scrollbar: { + vertical: 'hidden', + verticalScrollbarSize: 8, // used as "padding-right" + horizontal: 'hidden', + horizontalScrollbarSize: 0, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + }} + onBeforeEditorMount={ensureTraceQL} + onEditorDidMount={(editor, monaco) => { + if (!props.readOnly) { + setupAutocompleteFn(editor, monaco, setupRegisterInteractionCommand(editor)); + setupActions(editor, monaco, () => onRunQueryRef.current()); + setupPlaceholder(editor, monaco, styles); + } + setupAutoSize(editor); - // Register callback for query changes - editor.onDidChangeModelContent((changeEvent) => { + // Parse query that might already exist (e.g., after a page refresh) const model = editor.getModel(); - - if (!model) { - return; + if (model) { + const errorNodes = getErrorNodes(model.getValue()); + setMarkers(monaco, model, errorNodes); } - // Remove previous callback if existing, to prevent squiggles from been shown while the user is still typing - window.clearTimeout(errorTimeoutId.current); - - const errorNodes = getErrorNodes(model.getValue()); - const cursorPosition = changeEvent.changes[0].rangeOffset; - - // Immediately updates the squiggles, in case the user fixed an error, - // excluding the error around the cursor position - setErrorMarkers( - monaco, - model, - errorNodes.filter((errorNode) => !(errorNode.from <= cursorPosition && cursorPosition <= errorNode.to)) - ); - - // Later on, show all errors - errorTimeoutId.current = window.setTimeout(() => { - setErrorMarkers(monaco, model, errorNodes); - }, 500); - }); - }} - /> + // Register callback for query changes + editor.onDidChangeModelContent((changeEvent) => { + const model = editor.getModel(); + + if (!model) { + return; + } + + // Remove previous callback if existing, to prevent squiggles from been shown while the user is still typing + window.clearTimeout(errorTimeoutId.current); + + const errorNodes = getErrorNodes(model.getValue()); + const cursorPosition = changeEvent.changes[0].rangeOffset; + + // Immediately updates the squiggles, in case the user fixed an error, + // excluding the error around the cursor position + setMarkers( + monaco, + model, + errorNodes.filter((errorNode) => !(errorNode.from <= cursorPosition && cursorPosition <= errorNode.to)) + ); + + // Later on, show all errors + errorTimeoutId.current = window.setTimeout(() => { + setMarkers(monaco, model, errorNodes); + }, 500); + }); + }} + /> + {alertText && <TemporaryAlert severity="error" text={alertText} />} + </> ); } @@ -175,29 +190,31 @@ function setupAutoSize(editor: monacoTypes.editor.IStandaloneCodeEditor) { /** * Hook that returns function that will set up monaco autocomplete for the label selector - * @param datasource + * @param datasource the Tempo datasource instance + * @param setAlertText setter for alert's text */ -function useAutocomplete(datasource: TempoDatasource) { +function useAutocomplete(datasource: TempoDatasource, setAlertText: (text?: string) => void) { // We need the provider ref so we can pass it the label/values data later. This is because we run the call for the // values here but there is additional setup needed for the provider later on. We could run the getSeries() in the // returned function but that is run after the monaco is mounted so would delay the request a bit when it does not // need to. const providerRef = useRef<CompletionProvider>( - new CompletionProvider({ languageProvider: datasource.languageProvider }) + new CompletionProvider({ languageProvider: datasource.languageProvider, setAlertText }) ); useEffect(() => { const fetchTags = async () => { try { await datasource.languageProvider.start(); + setAlertText(undefined); } catch (error) { if (error instanceof Error) { - dispatch(notifyApp(createErrorNotification('Error', error))); + setAlertText(`Error: ${error.message}`); } } }; fetchTags(); - }, [datasource]); + }, [datasource, setAlertText]); const autocompleteDisposeFun = useRef<(() => void) | null>(null); useEffect(() => { @@ -243,17 +260,17 @@ interface EditorStyles { const getStyles = (theme: GrafanaTheme2, placeholder: string): EditorStyles => { return { - queryField: css` - border-radius: ${theme.shape.radius.default}; - border: 1px solid ${theme.components.input.borderColor}; - flex: 1; - `, - placeholder: css` - ::after { - content: '${placeholder}'; - font-family: ${theme.typography.fontFamilyMonospace}; - opacity: 0.3; - } - `, + queryField: css({ + borderRadius: theme.shape.radius.default, + border: `1px solid ${theme.components.input.borderColor}`, + flex: 1, + }), + placeholder: css({ + '::after': { + content: `'${placeholder}'`, + fontFamily: theme.typography.fontFamilyMonospace, + opacity: 0.3, + }, + }), }; }; diff --git a/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts b/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts index ad15cc0c95bac..fafc6ccf361b1 100644 --- a/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts +++ b/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts @@ -137,8 +137,8 @@ describe('CompletionProvider', () => { const { provider, model } = setup('', 0, v1Tags); const result = await provider.provideCompletionItems(model, emptyPosition); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ - ...scopes.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}` })), - ...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}` })), + ...scopes.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })), + ...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })), expect.objectContaining({ label: 'bar', insertText: '{ .bar' }), expect.objectContaining({ label: 'foo', insertText: '{ .foo' }), expect.objectContaining({ label: 'status', insertText: '{ .status' }), @@ -149,8 +149,8 @@ describe('CompletionProvider', () => { const { provider, model } = setup('', 0, undefined, v2Tags); const result = await provider.provideCompletionItems(model, emptyPosition); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ - ...scopes.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}` })), - ...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}` })), + ...scopes.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })), + ...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })), expect.objectContaining({ label: 'cluster', insertText: '{ .cluster' }), expect.objectContaining({ label: 'container', insertText: '{ .container' }), expect.objectContaining({ label: 'db', insertText: '{ .db' }), @@ -395,7 +395,7 @@ function setup(value: string, offset: number, tagsV1?: string[], tagsV2?: Scope[ } else if (tagsV2) { lp.setV2Tags(tagsV2); } - const provider = new CompletionProvider({ languageProvider: lp }); + const provider = new CompletionProvider({ languageProvider: lp, setAlertText: () => {} }); const model = makeModel(value, offset); provider.monaco = { Range: { @@ -409,12 +409,12 @@ function setup(value: string, offset: number, tagsV1?: string[], tagsV2?: Scope[ EnumMember: 2, }, }, - } as any; + } as unknown as typeof monacoTypes; provider.editor = { getModel() { return model; }, - } as any; + } as unknown as monacoTypes.editor.IStandaloneCodeEditor; return { provider, model } as unknown as { provider: CompletionProvider; model: monacoTypes.editor.ITextModel }; } diff --git a/public/app/plugins/datasource/tempo/traceql/autocomplete.ts b/public/app/plugins/datasource/tempo/traceql/autocomplete.ts index b843bb385c2b4..15b81656bbf26 100644 --- a/public/app/plugins/datasource/tempo/traceql/autocomplete.ts +++ b/public/app/plugins/datasource/tempo/traceql/autocomplete.ts @@ -4,9 +4,6 @@ import { SelectableValue } from '@grafana/data'; import { isFetchError } from '@grafana/runtime'; import type { Monaco, monacoTypes } from '@grafana/ui'; -import { createErrorNotification } from '../../../../core/copy/appNotification'; -import { notifyApp } from '../../../../core/reducers/appNotification'; -import { dispatch } from '../../../../store/store'; import TempoLanguageProvider from '../language_provider'; import { getSituation, Situation } from './situation'; @@ -14,6 +11,7 @@ import { intrinsics, scopes } from './traceql'; interface Props { languageProvider: TempoLanguageProvider; + setAlertText: (text?: string) => void; } type MinimalCompletionItem = { @@ -33,9 +31,11 @@ type MinimalCompletionItem = { export class CompletionProvider implements monacoTypes.languages.CompletionItemProvider { languageProvider: TempoLanguageProvider; registerInteractionCommandId: string | null; + setAlertText: (text?: string) => void; constructor(props: Props) { this.languageProvider = props.languageProvider; + this.setAlertText = props.setAlertText; this.registerInteractionCommandId = null; } @@ -243,7 +243,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP const { range, offset } = getRangeAndOffset(this.monaco, model, position); const situation = getSituation(model.getValue(), offset); - const completionItems = situation != null ? this.getCompletions(situation) : Promise.resolve([]); + const completionItems = situation != null ? this.getCompletions(situation, this.setAlertText) : Promise.resolve([]); return completionItems.then((items) => { // monaco by-default alphabetically orders the items. @@ -298,15 +298,15 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP * @param situation * @private */ - private async getCompletions(situation: Situation): Promise<Completion[]> { + private async getCompletions(situation: Situation, setAlertText: (text?: string) => void): Promise<Completion[]> { switch (situation.type) { // This should only happen for cases that we do not support yet case 'UNKNOWN': { return []; } case 'EMPTY': { - return this.getScopesCompletions('{ ') - .concat(this.getIntrinsicsCompletions('{ ')) + return this.getScopesCompletions('{ ', '$0 }') + .concat(this.getIntrinsicsCompletions('{ ', '$0 }')) .concat(this.getTagsCompletions('{ .')); } case 'SPANSET_EMPTY': @@ -370,11 +370,12 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP let tagValues; try { tagValues = await this.getTagValues(situation.tagName, situation.query); + setAlertText(undefined); } catch (error) { if (isFetchError(error)) { - dispatch(notifyApp(createErrorNotification(error.data.error, new Error(error.data.message)))); + setAlertText(error.data.error); } else if (error instanceof Error) { - dispatch(notifyApp(createErrorNotification('Error', error))); + setAlertText(`Error: ${error.message}`); } } diff --git a/public/app/plugins/datasource/tempo/traceql/highlighting.test.ts b/public/app/plugins/datasource/tempo/traceql/highlighting.test.ts new file mode 100644 index 0000000000000..e9b1d2a80edf6 --- /dev/null +++ b/public/app/plugins/datasource/tempo/traceql/highlighting.test.ts @@ -0,0 +1,195 @@ +import { monacoTypes } from '@grafana/ui'; + +import { computeErrorMessage, getErrorNodes, getWarningMarkers } from './highlighting'; + +describe('Highlighting', () => { + describe('gets correct warning markers for', () => { + const message = 'Add resource or span scope to attribute to improve query performance.'; + + describe('no warnings', () => { + it('for span scope', () => { + const { model } = setup('{ span.component = "http" }'); + const marker = getWarningMarkers(4, model); + expect(marker).toEqual(expect.objectContaining([])); + }); + it('for resource scope', () => { + const { model } = setup('{ resource.component = "http" }'); + const marker = getWarningMarkers(4, model); + expect(marker).toEqual(expect.objectContaining([])); + }); + it('for parent scope', () => { + const { model } = setup('{ parent.component = "http" }'); + const marker = getWarningMarkers(4, model); + expect(marker).toEqual(expect.objectContaining([])); + }); + }); + + it('single warning', () => { + const { model } = setup('{ .component = "http" }'); + const marker = getWarningMarkers(4, model); + expect(marker).toEqual( + expect.objectContaining([ + { + message, + severity: 4, + startLineNumber: 1, + endLineNumber: 1, + startColumn: 3, + endColumn: 3, + }, + ]) + ); + }); + + it('multiple warnings', () => { + const { model } = setup('{ .component = "http" || .http.status_code = 200 }'); + const marker = getWarningMarkers(4, model); + expect(marker).toEqual( + expect.objectContaining([ + { + message, + severity: 4, + startLineNumber: 1, + endLineNumber: 1, + startColumn: 3, + endColumn: 3, + }, + { + message, + severity: 4, + startLineNumber: 1, + endLineNumber: 1, + startColumn: 26, + endColumn: 26, + }, + ]) + ); + }); + + it('multiple parts, single warning', () => { + const { model } = setup('{ resource.component = "http" || .http.status_code = 200 }'); + const marker = getWarningMarkers(4, model); + expect(marker).toEqual( + expect.objectContaining([ + { + message, + severity: 4, + startLineNumber: 1, + endLineNumber: 1, + startColumn: 34, + endColumn: 34, + }, + ]) + ); + }); + }); + + describe('check for syntax errors in query', () => { + it.each([ + ['{span.http.status_code = }', 'Invalid value after comparison or arithmetic operator.'], + ['{span.http.status_code 200}', 'Invalid comparison operator after field expression.'], + ['{span.http.status_code ""}', 'Invalid operator after field expression.'], + ['{span.http.status_code @ 200}', 'Invalid comparison operator after field expression.'], + ['{span.http.status_code span.http.status_code}', 'Invalid operator after field expression.'], + [ + '{span.http.status_code = 200} {span.http.status_code = 200}', + 'Invalid spanset combining operator after spanset expression.', + ], + [ + '{span.http.status_code = 200} + {span.http.status_code = 200}', + 'Invalid spanset combining operator after spanset expression.', + ], + ['{span.http.status_code = 200} &&', 'Invalid spanset expression after spanset combining operator.'], + [ + '{span.http.status_code = 200} && {span.http.status_code = 200} | foo() > 3', + 'Invalid aggregation operator after pipepile operator.', + ], + [ + '{span.http.status_code = 200} && {span.http.status_code = 200} | avg() > 3', + 'Invalid expression for aggregator operator.', + ], + ['{ 1 + 1 = 2 + }', 'Invalid value after comparison or arithmetic operator.'], + ['{ .a && }', 'Invalid value after logical operator.'], + ['{ .a || }', 'Invalid value after logical operator.'], + ['{ .a + }', 'Invalid value after comparison or arithmetic operator.'], + ['{ 200 = 200 200 }', 'Invalid comparison operator after field expression.'], + ['{.foo 300}', 'Invalid comparison operator after field expression.'], + ['{.foo 300 && .bar = 200}', 'Invalid operator after field expression.'], + ['{.foo 300 && .bar 200}', 'Invalid operator after field expression.'], + ['{.foo=1} {.bar=2}', 'Invalid spanset combining operator after spanset expression.'], + ['{ span.http.status_code = 200 && }', 'Invalid value after logical operator.'], + ['{ span.http.status_code = 200 || }', 'Invalid value after logical operator.'], + ['{ .foo = 200 } && ', 'Invalid spanset expression after spanset combining operator.'], + ['{ .foo = 200 } || ', 'Invalid spanset expression after spanset combining operator.'], + ['{ .foo = 200 } >> ', 'Invalid spanset expression after spanset combining operator.'], + ['{.foo=1} | avg()', 'Invalid expression for aggregator operator.'], + ['{.foo=1} | avg(.foo) > ', 'Invalid value after comparison operator.'], + ['{.foo=1} | avg() < 1s', 'Invalid expression for aggregator operator.'], + ['{.foo=1} | max() = 3', 'Invalid expression for aggregator operator.'], + ['{.foo=1} | by()', 'Invalid expression for by operator.'], + ['{.foo=1} | select()', 'Invalid expression for select operator.'], + ['{foo}', 'Invalid expression for spanset.'], + ['{.}', 'Invalid expression for spanset.'], + ['{ resource. }', 'Invalid expression for spanset.'], + ['{ span. }', 'Invalid expression for spanset.'], + ['{.foo=}', 'Invalid value after comparison or arithmetic operator.'], + ['{.foo="}', 'Invalid value after comparison or arithmetic operator.'], + ['{.foo=300} |', 'Invalid aggregation operator after pipepile operator.'], + ['{.foo=300} && {.bar=200} |', 'Invalid aggregation operator after pipepile operator.'], + ['{.foo=300} && {.bar=300} && {.foo=300} |', 'Invalid aggregation operator after pipepile operator.'], + ['{.foo=300} | avg(.value)', 'Invalid comparison operator after aggregator operator.'], + ['{.foo=300} && {.foo=300} | avg(.value)', 'Invalid comparison operator after aggregator operator.'], + ['{.foo=300} | avg(.value) =', 'Invalid value after comparison operator.'], + ['{.foo=300} && {.foo=300} | avg(.value) =', 'Invalid value after comparison operator.'], + ['{.foo=300} | max(duration) > 1hs', 'Invalid value after comparison operator.'], + ['{ span.http.status_code', 'Invalid comparison operator after field expression.'], + ['{ .foo = "bar"', 'Invalid comparison operator after field expression.'], + ['abcxyz', 'Invalid query.'], + ])('error message for invalid query - %s, %s', (query: string, expectedErrorMessage: string) => { + const errorNode = getErrorNodes(query)[0]; + expect(computeErrorMessage(errorNode)).toBe(expectedErrorMessage); + }); + + it.each([ + ['123'], + ['abc'], + ['1a2b3c'], + ['{span.status = $code}'], + ['{span.${attribute} = "GET"}'], + ['{span.${attribute:format} = ${value:format} }'], + ['{true} >> {true}'], + ['{true} << {true}'], + ['{true} !>> {true}'], + ['{true} !<< {true}'], + [ + `{ true } /* && { false } && */ && { true } // && { false } + && { true }`, + ], + ['{span.s"t\\"at"us}'], + ['{span.s"t\\\\at"us}'], + ['{ span.s"tat"us" = "GET123 }'], // weird query, but technically valid + ['{ duration = 123.456us}'], + ['{ .foo = `GET` && .bar = `P\'O"S\\T` }'], + ['{ .foo = `GET` } | by(.foo, name)'], + ])('valid query - %s', (query: string) => { + expect(getErrorNodes(query)).toStrictEqual([]); + }); + }); +}); + +function setup(value: string) { + const model = makeModel(value); + return { model } as unknown as { model: monacoTypes.editor.ITextModel }; +} + +function makeModel(value: string) { + return { + id: 'test_monaco', + getValue() { + return value; + }, + getLineLength() { + return value.length; + }, + }; +} diff --git a/public/app/plugins/datasource/tempo/traceql/errorHighlighting.ts b/public/app/plugins/datasource/tempo/traceql/highlighting.ts similarity index 57% rename from public/app/plugins/datasource/tempo/traceql/errorHighlighting.ts rename to public/app/plugins/datasource/tempo/traceql/highlighting.ts index c3974d4300d1f..9ba47beba6fad 100644 --- a/public/app/plugins/datasource/tempo/traceql/errorHighlighting.ts +++ b/public/app/plugins/datasource/tempo/traceql/highlighting.ts @@ -7,12 +7,18 @@ import { ComparisonOp, FieldExpression, FieldOp, + GroupOperation, + Identifier, IntrinsicField, Or, + Parent, parser, Pipe, + Resource, ScalarExpression, ScalarFilter, + SelectOperation, + Span, SpansetFilter, SpansetPipelineExpression, } from '@grafana/lezer-traceql'; @@ -53,6 +59,10 @@ export const computeErrorMessage = (errorNode: SyntaxNode) => { case IntrinsicField: case Aggregate: return 'Invalid expression for aggregator operator.'; + case GroupOperation: + return 'Invalid expression for by operator.'; + case SelectOperation: + return 'Invalid expression for select operator.'; case AttributeField: return 'Invalid expression for spanset.'; case ScalarFilter: @@ -108,40 +118,89 @@ export const getErrorNodes = (query: string): SyntaxNode[] => { * Use red markers (squiggles) to highlight syntax errors in queries. * */ -export const setErrorMarkers = ( +export const setMarkers = ( monaco: typeof monacoTypes, model: monacoTypes.editor.ITextModel, errorNodes: SyntaxNode[] ) => { + const markers = [ + ...getErrorMarkers(monaco.MarkerSeverity.Error, model, errorNodes), + ...getWarningMarkers(monaco.MarkerSeverity.Warning, model), + ]; monaco.editor.setModelMarkers( model, 'owner', // default value - errorNodes.map((errorNode) => { - let startLine = 0; - let endLine = 0; - let start = errorNode.from; - let end = errorNode.to; - - while (start > 0) { - startLine++; - start -= model.getLineLength(startLine) + 1; // new lines don't count for getLineLength() but they still count as a character for the parser - } - while (end > 0) { - endLine++; - end -= model.getLineLength(endLine) + 1; + markers + ); +}; + +export const getErrorMarkers = (severity: number, model: monacoTypes.editor.ITextModel, errorNodes: SyntaxNode[]) => { + return errorNodes.map((errorNode) => { + const message = computeErrorMessage(errorNode); + return getMarker(severity, message, model, errorNode.from, errorNode.to); + }); +}; + +export const getWarningMarkers = (severity: number, model: monacoTypes.editor.ITextModel) => { + let markers = []; + + // Check if there are issues that should result in a warning marker + const text = model.getValue(); + const tree = parser.parse(text); + const indexOfDot = text.indexOf('.'); + if (indexOfDot > -1) { + const cur = tree.cursorAt(0); + do { + const { node } = cur; + if (node.type.id === Identifier) { + // Make sure prevSibling is using the proper scope + if ( + node.prevSibling?.type.id !== Parent && + node.prevSibling?.type.id !== Resource && + node.prevSibling?.type.id !== Span + ) { + const from = node.prevSibling ? node.prevSibling.from : node.from - 1; + const to = node.prevSibling ? node.prevSibling.to : node.from - 1; + const message = 'Add resource or span scope to attribute to improve query performance.'; + markers.push(getMarker(severity, message, model, from, to)); + } } + } while (cur.next()); + } - return { - message: computeErrorMessage(errorNode), - severity: monaco.MarkerSeverity.Error, + return markers; +}; - startLineNumber: startLine, - endLineNumber: endLine, +export const getMarker = ( + severity: number, + message: string, + model: monacoTypes.editor.ITextModel, + from: number, + to: number +) => { + let startLine = 0; + let endLine = 0; + let start = from; + let end = to; - // `+ 2` because of the above computations - startColumn: start + model.getLineLength(startLine) + 2, - endColumn: end + model.getLineLength(endLine) + 2, - }; - }) - ); + while (start > 0) { + startLine++; + start -= model.getLineLength(startLine) + 1; // new lines don't count for getLineLength() but they still count as a character for the parser + } + while (end > 0) { + endLine++; + end -= model.getLineLength(endLine) + 1; + } + + return { + message, + severity, + + startLineNumber: startLine, + endLineNumber: endLine, + + // `+ 2` because of the above computations + startColumn: start + model.getLineLength(startLine) + 2, + endColumn: end + model.getLineLength(endLine) + 2, + }; }; diff --git a/public/app/plugins/datasource/tempo/traceql/situation.ts b/public/app/plugins/datasource/tempo/traceql/situation.ts index 6d8681cd6dfca..0e60d065f6bb4 100644 --- a/public/app/plugins/datasource/tempo/traceql/situation.ts +++ b/public/app/plugins/datasource/tempo/traceql/situation.ts @@ -14,6 +14,7 @@ import { Pipe, ScalarFilter, SelectArgs, + SelectOperation, SpansetFilter, SpansetPipeline, SpansetPipelineExpression, @@ -213,6 +214,14 @@ const RESOLVERS: Resolver[] = [ path: [ERROR_NODE_ID, IntrinsicField], fun: resolveAttributeForFunction, }, + { + path: [ERROR_NODE_ID, GroupOperation], + fun: resolveAttributeForFunction, + }, + { + path: [ERROR_NODE_ID, SelectOperation], + fun: resolveAttributeForFunction, + }, { path: [ERROR_NODE_ID, SpansetPipelineExpression], fun: resolveSpansetPipeline, @@ -422,7 +431,7 @@ function resolveNewSpansetExpression(node: SyntaxNode, text: string, offset: num function resolveAttributeForFunction(node: SyntaxNode, _0: string, _1: number): SituationType | void { const parent = node?.parent; - if (!!parent && [IntrinsicField, Aggregate, GroupOperation, SelectArgs].includes(parent.type.id)) { + if (!!parent && [IntrinsicField, Aggregate, GroupOperation, SelectOperation, SelectArgs].includes(parent.type.id)) { return { type: 'ATTRIBUTE_FOR_FUNCTION', }; diff --git a/public/app/plugins/datasource/tempo/traceql/traceql.ts b/public/app/plugins/datasource/tempo/traceql/traceql.ts index b9280e4357a9f..b2d23399da16a 100644 --- a/public/app/plugins/datasource/tempo/traceql/traceql.ts +++ b/public/app/plugins/datasource/tempo/traceql/traceql.ts @@ -105,6 +105,7 @@ const language: languages.IMonarchLanguage = { [/'([^'\\]|\\.)*$/, 'string.invalid'], // non-teminated string [/([^\w])(")/, [{ token: '' }, { token: 'string', next: '@string_double' }]], [/([^\w])(')/, [{ token: '' }, { token: 'string', next: '@string_single' }]], + [/([^\w])(`)/, [{ token: '' }, { token: 'string', next: '@string_back' }]], // delimiters and operators [/[{}()\[\]]/, 'delimiter.bracket'], @@ -140,6 +141,13 @@ const language: languages.IMonarchLanguage = { [/\\./, 'string.escape.invalid'], [/'/, 'string', '@pop'], ], + + string_back: [ + [/[^\\`]+/, 'string'], + [/@escapes/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/`/, 'string', '@pop'], + ], }, }; diff --git a/public/app/plugins/datasource/tempo/tracking.test.ts b/public/app/plugins/datasource/tempo/tracking.test.ts index bb939757e5f41..2066b71a623a3 100644 --- a/public/app/plugins/datasource/tempo/tracking.test.ts +++ b/public/app/plugins/datasource/tempo/tracking.test.ts @@ -17,39 +17,6 @@ jest.mock('@grafana/runtime', () => { grafanaVersion: 'v9.4.0', queries: { tempo: [ - { - datasource: { type: 'tempo', uid: 'abc' }, - queryType: 'nativeSearch', - refId: 'A', - }, - { - datasource: { type: 'tempo', uid: 'abc' }, - queryType: 'nativeSearch', - spanName: 'HTTP', - refId: 'A', - }, - { - datasource: { type: 'tempo', uid: 'abc' }, - queryType: 'nativeSearch', - spanName: '$var', - refId: 'A', - }, - { - datasource: { type: 'tempo', uid: 'abc' }, - queryType: 'search', - linkedQuery: { - expr: '{}', - }, - refId: 'A', - }, - { - datasource: { type: 'tempo', uid: 'abc' }, - queryType: 'search', - linkedQuery: { - expr: '{$var}', - }, - refId: 'A', - }, { datasource: { type: 'tempo', uid: 'abc' }, queryType: 'serviceMap', @@ -100,14 +67,10 @@ describe('on dashboard loaded', () => { dashboard_id: 'dash', org_id: 1, traceql_query_count: 2, - search_query_count: 2, service_map_query_count: 2, upload_query_count: 1, - native_search_query_count: 3, traceql_queries_with_template_variables_count: 1, - search_queries_with_template_variables_count: 1, service_map_queries_with_template_variables_count: 1, - native_search_queries_with_template_variables_count: 1, }); }); }); diff --git a/public/app/plugins/datasource/tempo/tracking.ts b/public/app/plugins/datasource/tempo/tracking.ts index e27d90f507183..e5833a5c4248a 100644 --- a/public/app/plugins/datasource/tempo/tracking.ts +++ b/public/app/plugins/datasource/tempo/tracking.ts @@ -8,13 +8,9 @@ type TempoOnDashboardLoadedTrackingEvent = { grafana_version?: string; dashboard_id?: string; org_id?: number; - native_search_query_count: number; - search_query_count: number; service_map_query_count: number; traceql_query_count: number; upload_query_count: number; - native_search_queries_with_template_variables_count: number; - search_queries_with_template_variables_count: number; service_map_queries_with_template_variables_count: number; traceql_queries_with_template_variables_count: number; }; @@ -33,13 +29,9 @@ export const onDashboardLoadedHandler = ({ grafana_version: grafanaVersion, dashboard_id: dashboardId, org_id: orgId, - native_search_query_count: 0, - search_query_count: 0, service_map_query_count: 0, traceql_query_count: 0, upload_query_count: 0, - native_search_queries_with_template_variables_count: 0, - search_queries_with_template_variables_count: 0, service_map_queries_with_template_variables_count: 0, traceql_queries_with_template_variables_count: 0, }; @@ -49,23 +41,7 @@ export const onDashboardLoadedHandler = ({ continue; } - if (query.queryType === 'nativeSearch') { - stats.native_search_query_count++; - if ( - (query.serviceName && hasTemplateVariables(query.serviceName)) || - (query.spanName && hasTemplateVariables(query.spanName)) || - (query.search && hasTemplateVariables(query.search)) || - (query.minDuration && hasTemplateVariables(query.minDuration)) || - (query.maxDuration && hasTemplateVariables(query.maxDuration)) - ) { - stats.native_search_queries_with_template_variables_count++; - } - } else if (query.queryType === 'search') { - stats.search_query_count++; - if (query.linkedQuery && query.linkedQuery.expr && hasTemplateVariables(query.linkedQuery.expr)) { - stats.search_queries_with_template_variables_count++; - } - } else if (query.queryType === 'serviceMap') { + if (query.queryType === 'serviceMap') { stats.service_map_query_count++; if (query.serviceMapQuery && hasTemplateVariables(query.serviceMapQuery)) { stats.service_map_queries_with_template_variables_count++; @@ -86,6 +62,6 @@ export const onDashboardLoadedHandler = ({ } }; -const hasTemplateVariables = (val?: string): boolean => { - return getTemplateSrv().containsTemplate(val); +const hasTemplateVariables = (val?: string | string[]): boolean => { + return (Array.isArray(val) ? val : [val]).some((v) => getTemplateSrv().containsTemplate(v)); }; diff --git a/public/app/plugins/datasource/tempo/tsconfig.json b/public/app/plugins/datasource/tempo/tsconfig.json new file mode 100644 index 0000000000000..6dc8a770cba07 --- /dev/null +++ b/public/app/plugins/datasource/tempo/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "types": ["node", "jest", "@testing-library/jest-dom"] + }, + "extends": "@grafana/plugin-configs/tsconfig.json", + "include": ["."] +} diff --git a/public/app/plugins/datasource/tempo/types.ts b/public/app/plugins/datasource/tempo/types.ts index a888b650bc12f..09c31e05b9387 100644 --- a/public/app/plugins/datasource/tempo/types.ts +++ b/public/app/plugins/datasource/tempo/types.ts @@ -1,20 +1,8 @@ import { DataSourceJsonData } from '@grafana/data/src'; -import { NodeGraphOptions } from 'app/core/components/NodeGraphSettings'; -import { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; - -import { LokiQuery } from '../loki/types'; +import { NodeGraphOptions, TraceToLogsOptions } from '@grafana/o11y-ds-frontend'; import { TempoQuery as TempoBase, TempoQueryType, TraceqlFilter } from './dataquery.gen'; -export interface SearchQueryParams { - minDuration?: string; - maxDuration?: string; - limit?: number; - tags?: string; - start?: number; - end?: number; -} - export interface TempoJsonData extends DataSourceJsonData { tracesToLogs?: TraceToLogsOptions; serviceMap?: { @@ -25,9 +13,6 @@ export interface TempoJsonData extends DataSourceJsonData { filters?: TraceqlFilter[]; }; nodeGraph?: NodeGraphOptions; - lokiSearch?: { - datasourceUid?: string; - }; spanBar?: { tag: string; }; @@ -39,9 +24,6 @@ export interface TempoJsonData extends DataSourceJsonData { } export interface TempoQuery extends TempoBase { - // Query to find list of traces, e.g., via Loki - // TODO change this field to the schema type when LokiQuery exists in the schema - linkedQuery?: LokiQuery; queryType: TempoQueryType; } @@ -55,7 +37,7 @@ export type TraceSearchMetadata = { rootTraceName: string; startTimeUnixNano?: string; durationMs?: number; - spanSet?: Spanset; + spanSet?: Spanset; // deprecated in Tempo, https://github.com/grafana/tempo/blob/3cc44fca03ba7d676dc77da6a18b8222546ede3c/docs/sources/tempo/api_docs/_index.md?plain=1#L619 spanSets?: Spanset[]; }; @@ -121,3 +103,32 @@ export type Scope = { name: string; tags: string[]; }; + +// Maps to QueryRangeResponse of tempopb https://github.com/grafana/tempo/blob/cfda98fc5cb0777963f41e0949b9ad2d24b4b5b8/pkg/tempopb/tempo.proto#L360 +export type TraceqlMetricsResponse = { + series: MetricsSeries[]; + metrics: SearchMetrics; +}; + +export type MetricsSeries = { + labels: MetricsSeriesLabel[]; + samples: MetricsSeriesSample[]; + promLabels: string; +}; + +export type MetricsSeriesLabel = { + key: string; + value: ProtoValue; +}; + +export type ProtoValue = { + stringValue?: string; + intValue?: string; + boolValue?: boolean; + doubleValue?: string; +}; + +export type MetricsSeriesSample = { + timestampMs: string; + value: number; +}; diff --git a/public/app/plugins/datasource/tempo/utils.test.ts b/public/app/plugins/datasource/tempo/utils.test.ts new file mode 100644 index 0000000000000..65f40f781662b --- /dev/null +++ b/public/app/plugins/datasource/tempo/utils.test.ts @@ -0,0 +1,51 @@ +import { TempoQuery } from './types'; +import { migrateFromSearchToTraceQLSearch } from './utils'; + +describe('utils', () => { + it('migrateFromSearchToTraceQLSearch correctly updates the query', async () => { + const query: TempoQuery = { + refId: 'A', + filters: [], + queryType: 'nativeSearch', + serviceName: 'frontend', + spanName: 'http.server', + minDuration: '1s', + maxDuration: '10s', + search: 'component="net/http" datasource.type="tempo"', + }; + + const migratedQuery = migrateFromSearchToTraceQLSearch(query); + expect(migratedQuery.queryType).toBe('traceqlSearch'); + expect(migratedQuery.filters.length).toBe(7); + expect(migratedQuery.filters[0].scope).toBe('span'); + expect(migratedQuery.filters[0].tag).toBe('name'); + expect(migratedQuery.filters[0].operator).toBe('='); + expect(migratedQuery.filters[0].value![0]).toBe('http.server'); + expect(migratedQuery.filters[0].valueType).toBe('string'); + expect(migratedQuery.filters[1].scope).toBe('resource'); + expect(migratedQuery.filters[1].tag).toBe('service.name'); + expect(migratedQuery.filters[1].operator).toBe('='); + expect(migratedQuery.filters[1].value![0]).toBe('frontend'); + expect(migratedQuery.filters[1].valueType).toBe('string'); + expect(migratedQuery.filters[2].id).toBe('duration-type'); + expect(migratedQuery.filters[2].value).toBe('trace'); + expect(migratedQuery.filters[3].tag).toBe('duration'); + expect(migratedQuery.filters[3].operator).toBe('>'); + expect(migratedQuery.filters[3].value![0]).toBe('1s'); + expect(migratedQuery.filters[3].valueType).toBe('duration'); + expect(migratedQuery.filters[4].tag).toBe('duration'); + expect(migratedQuery.filters[4].operator).toBe('<'); + expect(migratedQuery.filters[4].value![0]).toBe('10s'); + expect(migratedQuery.filters[4].valueType).toBe('duration'); + expect(migratedQuery.filters[5].scope).toBe('unscoped'); + expect(migratedQuery.filters[5].tag).toBe('component'); + expect(migratedQuery.filters[5].operator).toBe('='); + expect(migratedQuery.filters[5].value![0]).toBe('net/http'); + expect(migratedQuery.filters[5].valueType).toBe('string'); + expect(migratedQuery.filters[6].scope).toBe('unscoped'); + expect(migratedQuery.filters[6].tag).toBe('datasource.type'); + expect(migratedQuery.filters[6].operator).toBe('='); + expect(migratedQuery.filters[6].value![0]).toBe('tempo'); + expect(migratedQuery.filters[6].valueType).toBe('string'); + }); +}); diff --git a/public/app/plugins/datasource/tempo/utils.ts b/public/app/plugins/datasource/tempo/utils.ts index 1e138747e8a89..f23b77f3e57c8 100644 --- a/public/app/plugins/datasource/tempo/utils.ts +++ b/public/app/plugins/datasource/tempo/utils.ts @@ -1,6 +1,10 @@ import { DataSourceApi } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; +import { generateId } from './SearchTraceQLEditor/TagsInput'; +import { TraceqlFilter, TraceqlSearchScope } from './dataquery.gen'; +import { TempoQuery } from './types'; + export const getErrorMessage = (message: string | undefined, prefix?: string) => { const err = message ? ` (${message})` : ''; let errPrefix = prefix ? prefix : 'Error'; @@ -20,3 +24,78 @@ export async function getDS(uid?: string): Promise<DataSourceApi | undefined> { return undefined; } } + +export const migrateFromSearchToTraceQLSearch = (query: TempoQuery) => { + let filters: TraceqlFilter[] = []; + if (query.spanName) { + filters.push({ + id: 'span-name', + scope: TraceqlSearchScope.Span, + tag: 'name', + operator: '=', + value: [query.spanName], + valueType: 'string', + }); + } + if (query.serviceName) { + filters.push({ + id: 'service-name', + scope: TraceqlSearchScope.Resource, + tag: 'service.name', + operator: '=', + value: [query.serviceName], + valueType: 'string', + }); + } + if (query.minDuration || query.maxDuration) { + filters.push({ + id: 'duration-type', + value: 'trace', + }); + } + if (query.minDuration) { + filters.push({ + id: 'min-duration', + tag: 'duration', + operator: '>', + value: [query.minDuration], + valueType: 'duration', + }); + } + if (query.maxDuration) { + filters.push({ + id: 'max-duration', + tag: 'duration', + operator: '<', + value: [query.maxDuration], + valueType: 'duration', + }); + } + if (query.search) { + const tags = query.search.split(' '); + for (const tag of tags) { + const [key, value] = tag.split('='); + if (key && value) { + filters.push({ + id: generateId(), + scope: TraceqlSearchScope.Unscoped, + tag: key, + operator: '=', + value: [value.replace(/(^"|"$)/g, '')], // remove quotes at start and end of string + valueType: value.startsWith('"') && value.endsWith('"') ? 'string' : undefined, + }); + } + } + } + + const migratedQuery: TempoQuery = { + datasource: query.datasource, + filters, + groupBy: query.groupBy, + limit: query.limit, + query: query.query, + queryType: 'traceqlSearch', + refId: query.refId, + }; + return migratedQuery; +}; diff --git a/public/app/plugins/datasource/tempo/webpack.config.ts b/public/app/plugins/datasource/tempo/webpack.config.ts new file mode 100644 index 0000000000000..4da5a990cfa57 --- /dev/null +++ b/public/app/plugins/datasource/tempo/webpack.config.ts @@ -0,0 +1,3 @@ +import config from '@grafana/plugin-configs/webpack.config'; + +export default config; diff --git a/public/app/plugins/datasource/zipkin/CHANGELOG.md b/public/app/plugins/datasource/zipkin/CHANGELOG.md new file mode 100644 index 0000000000000..825c32f0d03d9 --- /dev/null +++ b/public/app/plugins/datasource/zipkin/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/public/app/plugins/datasource/zipkin/ConfigEditor.tsx b/public/app/plugins/datasource/zipkin/ConfigEditor.tsx index f0447ff486dbf..3bd0a07632871 100644 --- a/public/app/plugins/datasource/zipkin/ConfigEditor.tsx +++ b/public/app/plugins/datasource/zipkin/ConfigEditor.tsx @@ -3,13 +3,9 @@ import React from 'react'; import { DataSourcePluginOptionsEditorProps, GrafanaTheme2 } from '@grafana/data'; import { ConfigSection, DataSourceDescription } from '@grafana/experimental'; +import { NodeGraphSection, SpanBarSection, TraceToLogsSection, TraceToMetricsSection } from '@grafana/o11y-ds-frontend'; import { config } from '@grafana/runtime'; -import { DataSourceHttpSettings, useStyles2 } from '@grafana/ui'; -import { Divider } from 'app/core/components/Divider'; -import { NodeGraphSection } from 'app/core/components/NodeGraphSettings'; -import { TraceToLogsSection } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; -import { TraceToMetricsSection } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; -import { SpanBarSection } from 'app/features/explore/TraceView/components/settings/SpanBarSettings'; +import { DataSourceHttpSettings, useStyles2, Divider, Stack } from '@grafana/ui'; export type Props = DataSourcePluginOptionsEditorProps; @@ -24,7 +20,7 @@ export const ConfigEditor = ({ options, onOptionsChange }: Props) => { hasRequiredFields={false} /> - <Divider /> + <Divider spacing={4} /> <DataSourceHttpSettings defaultUrl="http://localhost:9411" @@ -35,15 +31,10 @@ export const ConfigEditor = ({ options, onOptionsChange }: Props) => { /> <TraceToLogsSection options={options} onOptionsChange={onOptionsChange} /> + <Divider spacing={4} /> - <Divider /> - - {config.featureToggles.traceToMetrics ? ( - <> - <TraceToMetricsSection options={options} onOptionsChange={onOptionsChange} /> - <Divider /> - </> - ) : null} + <TraceToMetricsSection options={options} onOptionsChange={onOptionsChange} /> + <Divider spacing={4} /> <ConfigSection title="Additional settings" @@ -51,9 +42,10 @@ export const ConfigEditor = ({ options, onOptionsChange }: Props) => { isCollapsible={true} isInitiallyOpen={false} > - <NodeGraphSection options={options} onOptionsChange={onOptionsChange} /> - <Divider hideLine={true} /> - <SpanBarSection options={options} onOptionsChange={onOptionsChange} /> + <Stack gap={5} direction="column"> + <NodeGraphSection options={options} onOptionsChange={onOptionsChange} /> + <SpanBarSection options={options} onOptionsChange={onOptionsChange} /> + </Stack> </ConfigSection> </div> ); diff --git a/public/app/plugins/datasource/zipkin/QueryField.test.tsx b/public/app/plugins/datasource/zipkin/QueryField.test.tsx index 059c013d74621..ee8c90b6a56cc 100644 --- a/public/app/plugins/datasource/zipkin/QueryField.test.tsx +++ b/public/app/plugins/datasource/zipkin/QueryField.test.tsx @@ -37,7 +37,7 @@ describe('useServices', () => { }, } as ZipkinDatasource; - const { result } = renderHook(() => useServices(ds)); + const { result } = renderHook(() => useServices(ds, () => {})); await waitFor(() => { expect(result.current.value).toEqual([ { label: 'service1', value: 'service1', isLeaf: false }, @@ -62,7 +62,7 @@ describe('useLoadOptions', () => { }, } as ZipkinDatasource; - const { result } = renderHook(() => useLoadOptions(ds)); + const { result } = renderHook(() => useLoadOptions(ds, () => {})); expect(result.current.allOptions).toEqual({}); act(() => { diff --git a/public/app/plugins/datasource/zipkin/QueryField.tsx b/public/app/plugins/datasource/zipkin/QueryField.tsx index df9a83970e8c7..40e04968c6088 100644 --- a/public/app/plugins/datasource/zipkin/QueryField.tsx +++ b/public/app/plugins/datasource/zipkin/QueryField.tsx @@ -5,6 +5,7 @@ import { useAsyncFn, useMount, useMountedState } from 'react-use'; import { AsyncState } from 'react-use/lib/useAsyncFn'; import { GrafanaTheme2, QueryEditorProps } from '@grafana/data'; +import { TemporaryAlert } from '@grafana/o11y-ds-frontend'; import { ButtonCascader, CascaderOption, @@ -19,9 +20,6 @@ import { HorizontalGroup, Button, } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; -import { createErrorNotification } from 'app/core/copy/appNotification'; -import { dispatch } from 'app/store/store'; import { apiPrefix } from './constants'; import { ZipkinDatasource } from './datasource'; @@ -40,10 +38,11 @@ const getStyles = (theme: GrafanaTheme2) => { export const ZipkinQueryField = ({ query, onChange, onRunQuery, datasource }: Props) => { const [uploadModalOpen, setUploadModalOpen] = useState(false); - const serviceOptions = useServices(datasource); + const [alertText, setAlertText] = useState(''); + const serviceOptions = useServices(datasource, setAlertText); const theme = useTheme2(); const styles = useStyles2(getStyles); - const { onLoadOptions, allOptions } = useLoadOptions(datasource); + const { onLoadOptions, allOptions } = useLoadOptions(datasource, setAlertText); const onSelectTrace = useCallback( (values: string[], selectedOptions: CascaderOption[]) => { @@ -138,12 +137,16 @@ export const ZipkinQueryField = ({ query, onChange, onRunQuery, datasource }: Pr </div> </InlineFieldRow> )} + {alertText && <TemporaryAlert text={alertText} severity={'error'} />} </> ); }; // Exported for tests -export function useServices(datasource: ZipkinDatasource): AsyncState<CascaderOption[]> { +export function useServices( + datasource: ZipkinDatasource, + setErrorText: (text: string) => void +): AsyncState<CascaderOption[]> { const url = `${apiPrefix}/services`; const [servicesOptions, fetch] = useAsyncFn(async (): Promise<CascaderOption[]> => { @@ -159,7 +162,8 @@ export function useServices(datasource: ZipkinDatasource): AsyncState<CascaderOp return []; } catch (error) { const errorToShow = error instanceof Error ? error : 'An unknown error occurred'; - dispatch(notifyApp(createErrorNotification('Failed to load services from Zipkin', errorToShow))); + const errorText = `Failed to load spans from Zipkin: ${errorToShow.toString()}`; + setErrorText(errorText); throw error; } }, [datasource]); @@ -181,9 +185,9 @@ type OptionsState = { }; // Exported for tests -export function useLoadOptions(datasource: ZipkinDatasource) { +export function useLoadOptions(datasource: ZipkinDatasource, setErrorText: (text: string) => void) { const isMounted = useMountedState(); - const [allOptions, setAllOptions] = useState({} as OptionsState); + const [allOptions, setAllOptions] = useState<OptionsState>({}); const [, fetchSpans] = useAsyncFn( async function findSpans(service: string): Promise<void> { @@ -204,7 +208,8 @@ export function useLoadOptions(datasource: ZipkinDatasource) { } } catch (error) { const errorToShow = error instanceof Error ? error : 'An unknown error occurred'; - dispatch(notifyApp(createErrorNotification('Failed to load spans from Zipkin', errorToShow))); + const errorText = `Failed to load spans from Zipkin: ${errorToShow.toString()}`; + setErrorText(errorText); throw error; } }, @@ -246,7 +251,8 @@ export function useLoadOptions(datasource: ZipkinDatasource) { } } catch (error) { const errorToShow = error instanceof Error ? error : 'An unknown error occurred'; - dispatch(notifyApp(createErrorNotification('Failed to load spans from Zipkin', errorToShow))); + const errorText = `Failed to load spans from Zipkin: ${errorToShow.toString()}`; + setErrorText(errorText); throw error; } }, diff --git a/public/app/plugins/datasource/zipkin/README.md b/public/app/plugins/datasource/zipkin/README.md index a4d81d38b1d60..8a14e3051bea4 100644 --- a/public/app/plugins/datasource/zipkin/README.md +++ b/public/app/plugins/datasource/zipkin/README.md @@ -1,7 +1,3 @@ -# Zipkin Data Source - Native Plugin +# Grafana Zipkin Data Source - Native Plugin -Grafana ships with **built in** support for Zipkin, an open source, distributed tracing system. - -Read more about it here: - -[https://docs.grafana.org/datasources/zipkin/](https://docs.grafana.org/datasources/zipkin/) +Grafana plugin for the Zipkin data source. [https://docs.grafana.org/datasources/zipkin/](Read more about it here). diff --git a/public/app/plugins/datasource/zipkin/datasource.test.ts b/public/app/plugins/datasource/zipkin/datasource.test.ts index 99cb2ff5a1c06..10bafa53ad61b 100644 --- a/public/app/plugins/datasource/zipkin/datasource.test.ts +++ b/public/app/plugins/datasource/zipkin/datasource.test.ts @@ -2,14 +2,15 @@ import { lastValueFrom, of } from 'rxjs'; import { createFetchResponse } from 'test/helpers/createFetchResponse'; import { DataQueryRequest, DataSourceInstanceSettings, DataSourcePluginMeta, FieldType } from '@grafana/data'; -import { TemplateSrv } from '@grafana/runtime'; -import { backendSrv } from 'app/core/services/backend_srv'; +import { BackendSrv, TemplateSrv } from '@grafana/runtime'; import { ZipkinDatasource } from './datasource'; import mockJson from './mockJsonResponse.json'; import { ZipkinQuery, ZipkinSpan } from './types'; import { traceFrameFields, zipkinResponse } from './utils/testData'; +export const backendSrv = { fetch: jest.fn() } as unknown as BackendSrv; + jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getBackendSrv: () => backendSrv, diff --git a/public/app/plugins/datasource/zipkin/datasource.ts b/public/app/plugins/datasource/zipkin/datasource.ts index 6fa69c838345f..0a20d631cc1d5 100644 --- a/public/app/plugins/datasource/zipkin/datasource.ts +++ b/public/app/plugins/datasource/zipkin/datasource.ts @@ -12,9 +12,8 @@ import { ScopedVars, urlUtil, } from '@grafana/data'; +import { NodeGraphOptions, SpanBarOptions } from '@grafana/o11y-ds-frontend'; import { BackendSrvRequest, FetchResponse, getBackendSrv, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; -import { NodeGraphOptions } from 'app/core/components/NodeGraphSettings'; -import { SpanBarOptions } from 'app/features/explore/TraceView/components'; import { apiPrefix } from './constants'; import { ZipkinQuery, ZipkinSpan } from './types'; @@ -61,7 +60,7 @@ export class ZipkinDatasource extends DataSourceApi<ZipkinQuery, ZipkinJsonData> return of(emptyDataQueryResponse); } - async metadataRequest(url: string, params?: Record<string, any>): Promise<any> { + async metadataRequest(url: string, params?: Record<string, unknown>) { const res = await lastValueFrom(this.request(url, params, { hideFromInspector: true })); return res.data; } @@ -100,7 +99,7 @@ export class ZipkinDatasource extends DataSourceApi<ZipkinQuery, ZipkinJsonData> private request<T = any>( apiUrl: string, - data?: any, + data?: unknown, options?: Partial<BackendSrvRequest> ): Observable<FetchResponse<T>> { const params = data ? urlUtil.serializeParams(data) : ''; diff --git a/public/app/plugins/datasource/zipkin/package.json b/public/app/plugins/datasource/zipkin/package.json new file mode 100644 index 0000000000000..b72e089c583f3 --- /dev/null +++ b/public/app/plugins/datasource/zipkin/package.json @@ -0,0 +1,38 @@ +{ + "name": "@grafana-plugins/zipkin", + "description": "Zipkin plugin for Grafana", + "private": true, + "version": "11.0.0-pre", + "dependencies": { + "@emotion/css": "11.11.2", + "@grafana/data": "workspace:*", + "@grafana/experimental": "1.7.10", + "@grafana/o11y-ds-frontend": "workspace:*", + "@grafana/runtime": "workspace:*", + "@grafana/ui": "workspace:*", + "lodash": "4.17.21", + "react": "18.2.0", + "react-use": "17.5.0", + "rxjs": "7.8.1", + "tslib": "2.6.2" + }, + "devDependencies": { + "@grafana/plugin-configs": "workspace:*", + "@testing-library/jest-dom": "6.4.2", + "@testing-library/react": "14.2.1", + "@types/jest": "29.5.12", + "@types/lodash": "4.17.0", + "@types/react": "18.2.66", + "ts-node": "10.9.2", + "webpack": "5.90.3" + }, + "peerDependencies": { + "@grafana/runtime": "*" + }, + "scripts": { + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development" + }, + "packageManager": "yarn@3.6.0" +} diff --git a/public/app/plugins/datasource/zipkin/plugin.json b/public/app/plugins/datasource/zipkin/plugin.json index 74bb006ff8dbb..9437ecd76d957 100644 --- a/public/app/plugins/datasource/zipkin/plugin.json +++ b/public/app/plugins/datasource/zipkin/plugin.json @@ -26,6 +26,10 @@ "name": "Learn more", "url": "https://zipkin.io" } - ] + ], + "version": "%VERSION%" + }, + "dependencies": { + "grafanaDependency": ">=10.3.0-0" } } diff --git a/public/app/plugins/datasource/zipkin/tsconfig.json b/public/app/plugins/datasource/zipkin/tsconfig.json new file mode 100644 index 0000000000000..6dc8a770cba07 --- /dev/null +++ b/public/app/plugins/datasource/zipkin/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "types": ["node", "jest", "@testing-library/jest-dom"] + }, + "extends": "@grafana/plugin-configs/tsconfig.json", + "include": ["."] +} diff --git a/public/app/plugins/datasource/zipkin/utils/graphTransform.ts b/public/app/plugins/datasource/zipkin/utils/graphTransform.ts index 03e441b97b9a3..f67ec7e892937 100644 --- a/public/app/plugins/datasource/zipkin/utils/graphTransform.ts +++ b/public/app/plugins/datasource/zipkin/utils/graphTransform.ts @@ -1,6 +1,6 @@ import { DataFrame, NodeGraphDataFrameFieldNames as Fields } from '@grafana/data'; +import { getNonOverlappingDuration, getStats, makeFrames, makeSpanMap } from '@grafana/o11y-ds-frontend'; -import { getNonOverlappingDuration, getStats, makeFrames, makeSpanMap } from '../../../../core/utils/tracing'; import { ZipkinSpan } from '../types'; interface Node { diff --git a/public/app/plugins/datasource/zipkin/webpack.config.ts b/public/app/plugins/datasource/zipkin/webpack.config.ts new file mode 100644 index 0000000000000..4da5a990cfa57 --- /dev/null +++ b/public/app/plugins/datasource/zipkin/webpack.config.ts @@ -0,0 +1,3 @@ +import config from '@grafana/plugin-configs/webpack.config'; + +export default config; diff --git a/public/app/plugins/gen.go b/public/app/plugins/gen.go index faf6037d8ea10..693a8af0fa207 100644 --- a/public/app/plugins/gen.go +++ b/public/app/plugins/gen.go @@ -8,19 +8,18 @@ package main import ( "context" "fmt" - "log" - "os" - "path/filepath" - "strings" - "github.com/grafana/codejen" - "github.com/grafana/kindsys" - corecodegen "github.com/grafana/grafana/pkg/codegen" "github.com/grafana/grafana/pkg/cuectx" "github.com/grafana/grafana/pkg/plugins/codegen" "github.com/grafana/grafana/pkg/plugins/pfs" + "github.com/grafana/kindsys" "github.com/grafana/thema" + "io/fs" + "log" + "os" + "path/filepath" + "strings" ) var skipPlugins = map[string]bool{ @@ -48,13 +47,8 @@ func main() { }) pluginKindGen.Append( - codegen.PluginTreeListJenny(), codegen.PluginGoTypesJenny("pkg/tsdb"), - codegen.PluginTSTypesJenny("public/app/plugins", adaptToPipeline(corecodegen.TSTypesJenny{})), - kind2pd(rt, corecodegen.DocsJenny( - filepath.Join("docs", "sources", "developers", "kinds", "composable"), - )), - codegen.PluginTSEachMajor(rt), + codegen.PluginTSTypesJenny("public/app/plugins"), ) schifs := kindsys.SchemaInterfaces(rt.Context()) @@ -75,6 +69,15 @@ func main() { log.Fatalln(fmt.Errorf("error writing files to disk: %s", err)) } + rawResources, err := genRawResources() + if err != nil { + log.Fatalln(fmt.Errorf("error generating raw plugin resources: %s", err)) + } + + if err := jfs.Merge(rawResources); err != nil { + log.Fatalln(fmt.Errorf("Unable to merge raw resources: %s", err)) + } + if _, set := os.LookupEnv("CODEGEN_VERIFY"); set { if err = jfs.Verify(context.Background(), groot); err != nil { log.Fatal(fmt.Errorf("generated code is out of sync with inputs:\n%s\nrun `make gen-cue` to regenerate", err)) @@ -84,16 +87,6 @@ func main() { } } -func adaptToPipeline(j codejen.OneToOne[corecodegen.SchemaForGen]) codejen.OneToOne[*pfs.PluginDecl] { - return codejen.AdaptOneToOne(j, func(pd *pfs.PluginDecl) corecodegen.SchemaForGen { - return corecodegen.SchemaForGen{ - Name: strings.ReplaceAll(pd.PluginMeta.Name, " ", ""), - Schema: pd.Lineage.Latest(), - IsGroup: pd.SchemaInterface.IsGroup(), - } - }) -} - func kind2pd(rt *thema.Runtime, j codejen.OneToOne[kindsys.Kind]) codejen.OneToOne[*pfs.PluginDecl] { return codejen.AdaptOneToOne(j, func(pd *pfs.PluginDecl) kindsys.Kind { kd, err := kindsys.BindComposable(rt, pd.KindDecl) @@ -120,3 +113,28 @@ func splitSchiffer(names []string) codejen.FileMapper { return f, nil } } + +func genRawResources() (*codejen.FS, error) { + jennies := codejen.JennyListWithNamer(func(d []string) string { + return "PluginsRawResources" + }) + jennies.Append(&codegen.PluginRegistryJenny{}) + + schemas := make([]string, 0) + filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { + if d.IsDir() { + return nil + } + + if !strings.HasSuffix(d.Name(), ".cue") { + return nil + } + + schemas = append(schemas, "./"+filepath.Join("public", "app", "plugins", path)) + return nil + }) + + jennies.AddPostprocessors(corecodegen.SlashHeaderMapper("public/app/plugins/gen.go")) + + return jennies.GenerateFS(schemas) +} diff --git a/public/app/plugins/panel/alertGroups/AlertGroup.tsx b/public/app/plugins/panel/alertGroups/AlertGroup.tsx deleted file mode 100644 index 2ffb2de9aa361..0000000000000 --- a/public/app/plugins/panel/alertGroups/AlertGroup.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { css } from '@emotion/css'; -import React, { useState, useEffect } from 'react'; - -import { GrafanaTheme2, intervalToAbbreviatedDurationString } from '@grafana/data'; -import { useStyles2, LinkButton } from '@grafana/ui'; -import { AlertLabels } from 'app/features/alerting/unified/components/AlertLabels'; -import { CollapseToggle } from 'app/features/alerting/unified/components/CollapseToggle'; -import { AlertGroupHeader } from 'app/features/alerting/unified/components/alert-groups/AlertGroupHeader'; -import { getNotificationsTextColors } from 'app/features/alerting/unified/styles/notifications'; -import { makeAMLink, makeLabelBasedSilenceLink } from 'app/features/alerting/unified/utils/misc'; -import { AlertmanagerGroup, AlertState } from 'app/plugins/datasource/alertmanager/types'; - -type Props = { - alertManagerSourceName: string; - group: AlertmanagerGroup; - expandAll: boolean; -}; - -export const AlertGroup = ({ alertManagerSourceName, group, expandAll }: Props) => { - const [showAlerts, setShowAlerts] = useState(expandAll); - const styles = useStyles2(getStyles); - const textStyles = useStyles2(getNotificationsTextColors); - - useEffect(() => setShowAlerts(expandAll), [expandAll]); - - return ( - <div className={styles.group} data-testid="alert-group"> - {Object.keys(group.labels).length > 0 ? ( - <AlertLabels labels={group.labels} size="sm" /> - ) : ( - <div className={styles.noGroupingText}>No grouping</div> - )} - <div className={styles.row}> - <CollapseToggle isCollapsed={!showAlerts} onToggle={() => setShowAlerts(!showAlerts)} />{' '} - <AlertGroupHeader group={group} /> - </div> - {showAlerts && ( - <div className={styles.alerts}> - {group.alerts.map((alert, index) => { - const state = alert.status.state.toUpperCase(); - const interval = intervalToAbbreviatedDurationString({ - start: new Date(alert.startsAt), - end: Date.now(), - }); - - return ( - <div data-testid={'alert-group-alert'} className={styles.alert} key={`${alert.fingerprint}-${index}`}> - <div> - <span className={textStyles[alert.status.state]}>{state} </span>for {interval} - </div> - <div> - <AlertLabels labels={alert.labels} size="sm" /> - </div> - <div className={styles.actionsRow}> - {alert.status.state === AlertState.Suppressed && ( - <LinkButton - href={`${makeAMLink( - '/alerting/silences', - alertManagerSourceName - )}&silenceIds=${alert.status.silencedBy.join(',')}`} - className={styles.button} - icon={'bell'} - size={'sm'} - > - Manage silences - </LinkButton> - )} - {alert.status.state === AlertState.Active && ( - <LinkButton - href={makeLabelBasedSilenceLink(alertManagerSourceName, alert.labels)} - className={styles.button} - icon={'bell-slash'} - size={'sm'} - > - Silence - </LinkButton> - )} - {alert.generatorURL && ( - <LinkButton className={styles.button} href={alert.generatorURL} icon={'chart-line'} size={'sm'}> - See source - </LinkButton> - )} - </div> - </div> - ); - })} - </div> - )} - </div> - ); -}; - -const getStyles = (theme: GrafanaTheme2) => ({ - noGroupingText: css` - height: ${theme.spacing(4)}; - `, - group: css` - background-color: ${theme.colors.background.secondary}; - margin: ${theme.spacing(0.5, 1, 0.5, 1)}; - padding: ${theme.spacing(1)}; - `, - row: css` - display: flex; - flex-direction: row; - align-items: center; - gap: ${theme.spacing(1)}; - `, - alerts: css` - margin: ${theme.spacing(0, 2, 0, 4)}; - `, - alert: css` - padding: ${theme.spacing(1, 0)}; - & + & { - border-top: 1px solid ${theme.colors.border.medium}; - } - `, - button: css` - & + & { - margin-left: ${theme.spacing(1)}; - } - `, - actionsRow: css` - padding: ${theme.spacing(1, 0)}; - `, -}); diff --git a/public/app/plugins/panel/alertGroups/AlertGroupsPanel.test.tsx b/public/app/plugins/panel/alertGroups/AlertGroupsPanel.test.tsx deleted file mode 100644 index 6e8b34f1b9c4b..0000000000000 --- a/public/app/plugins/panel/alertGroups/AlertGroupsPanel.test.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { render } from '@testing-library/react'; -import React from 'react'; -import { Provider } from 'react-redux'; -import { byTestId } from 'testing-library-selector'; - -import { getDefaultTimeRange, LoadingState, PanelProps, FieldConfigSource } from '@grafana/data'; -import { setDataSourceSrv } from '@grafana/runtime'; -import { Dashboard } from '@grafana/schema'; -import { fetchAlertGroups } from 'app/features/alerting/unified/api/alertmanager'; -import { - mockAlertGroup, - mockAlertmanagerAlert, - mockDataSource, - MockDataSourceSrv, -} from 'app/features/alerting/unified/mocks'; -import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; -import { DashboardSrv, setDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; -import { DashboardModel } from 'app/features/dashboard/state'; -import { configureStore } from 'app/store/configureStore'; - -import { AlertGroupsPanel } from './AlertGroupsPanel'; -import { Options } from './panelcfg.gen'; - -jest.mock('app/features/alerting/unified/api/alertmanager'); - -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - config: { - ...jest.requireActual('@grafana/runtime').config, - buildInfo: {}, - panels: {}, - unifiedAlertingEnabled: true, - }, -})); - -const mocks = { - api: { - fetchAlertGroups: jest.mocked(fetchAlertGroups), - }, -}; - -const dataSources = { - am: mockDataSource({ - name: 'Alertmanager', - type: DataSourceType.Alertmanager, - }), -}; - -const defaultOptions: Options = { - labels: '', - alertmanager: 'Alertmanager', - expandAll: false, -}; - -const defaultProps: PanelProps<Options> = { - data: { state: LoadingState.Done, series: [], timeRange: getDefaultTimeRange() }, - id: 1, - timeRange: getDefaultTimeRange(), - timeZone: 'utc', - options: defaultOptions, - eventBus: { - subscribe: jest.fn(), - getStream: jest.fn().mockReturnValue({ - subscribe: jest.fn(), - }), - publish: jest.fn(), - removeAllListeners: jest.fn(), - newScopedBus: jest.fn(), - }, - fieldConfig: {} as unknown as FieldConfigSource, - height: 400, - onChangeTimeRange: jest.fn(), - onFieldConfigChange: jest.fn(), - onOptionsChange: jest.fn(), - renderCounter: 1, - replaceVariables: jest.fn(), - title: 'Alert groups test', - transparent: false, - width: 320, -}; - -const renderPanel = (options: Options = defaultOptions) => { - const store = configureStore(); - const dash = new DashboardModel({ id: 1 } as Dashboard); - dash.formatDate = (time: number) => new Date(time).toISOString(); - const dashSrv = { getCurrent: () => dash } as DashboardSrv; - setDashboardSrv(dashSrv); - - defaultProps.options = options; - const props = { ...defaultProps }; - - return render( - <Provider store={store}> - <AlertGroupsPanel {...props} /> - </Provider> - ); -}; - -const ui = { - group: byTestId('alert-group'), - alert: byTestId('alert-group-alert'), -}; - -describe('AlertGroupsPanel', () => { - beforeAll(() => { - mocks.api.fetchAlertGroups.mockImplementation(() => { - return Promise.resolve([ - mockAlertGroup({ labels: {}, alerts: [mockAlertmanagerAlert({ labels: { foo: 'bar' } })] }), - mockAlertGroup(), - ]); - }); - }); - - beforeEach(() => { - setDataSourceSrv(new MockDataSourceSrv(dataSources)); - }); - - it('renders the panel with the groups', async () => { - renderPanel(); - - const groups = await ui.group.findAll(); - - expect(groups).toHaveLength(2); - - expect(groups[0]).toHaveTextContent('No grouping'); - expect(groups[1]).toHaveTextContent('severitywarning regionUS-Central'); - - const alerts = ui.alert.queryAll(); - expect(alerts).toHaveLength(0); - }); - - it('renders panel with groups expanded', async () => { - renderPanel({ labels: '', alertmanager: 'Alertmanager', expandAll: true }); - - const alerts = await ui.alert.findAll(); - expect(alerts).toHaveLength(3); - }); - - it('filters alerts by label filter', async () => { - renderPanel({ labels: 'region=US-Central', alertmanager: 'Alertmanager', expandAll: true }); - - const alerts = await ui.alert.findAll(); - expect(alerts).toHaveLength(2); - }); -}); diff --git a/public/app/plugins/panel/alertGroups/AlertGroupsPanel.tsx b/public/app/plugins/panel/alertGroups/AlertGroupsPanel.tsx deleted file mode 100644 index 8de959e4da1d7..0000000000000 --- a/public/app/plugins/panel/alertGroups/AlertGroupsPanel.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React, { useEffect } from 'react'; - -import { PanelProps } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { CustomScrollbar } from '@grafana/ui'; -import { useUnifiedAlertingSelector } from 'app/features/alerting/unified/hooks/useUnifiedAlertingSelector'; -import { fetchAlertGroupsAction } from 'app/features/alerting/unified/state/actions'; -import { parseMatchers } from 'app/features/alerting/unified/utils/alertmanager'; -import { NOTIFICATIONS_POLL_INTERVAL_MS } from 'app/features/alerting/unified/utils/constants'; -import { initialAsyncRequestState } from 'app/features/alerting/unified/utils/redux'; -import { AlertmanagerGroup, Matcher } from 'app/plugins/datasource/alertmanager/types'; -import { useDispatch } from 'app/types'; - -import { AlertGroup } from './AlertGroup'; -import { Options } from './panelcfg.gen'; -import { useFilteredGroups } from './useFilteredGroups'; - -export const AlertGroupsPanel = (props: PanelProps<Options>) => { - const dispatch = useDispatch(); - const isAlertingEnabled = config.unifiedAlertingEnabled; - - const expandAll = props.options.expandAll; - const alertManagerSourceName = props.options.alertmanager; - - const alertGroups = useUnifiedAlertingSelector((state) => state.amAlertGroups) || initialAsyncRequestState; - const results: AlertmanagerGroup[] = alertGroups[alertManagerSourceName || '']?.result || []; - const matchers: Matcher[] = props.options.labels ? parseMatchers(props.options.labels) : []; - - const filteredResults = useFilteredGroups(results, matchers); - - useEffect(() => { - function fetchNotifications() { - if (alertManagerSourceName) { - dispatch(fetchAlertGroupsAction(alertManagerSourceName)); - } - } - fetchNotifications(); - const interval = setInterval(fetchNotifications, NOTIFICATIONS_POLL_INTERVAL_MS); - return () => { - clearInterval(interval); - }; - }, [dispatch, alertManagerSourceName]); - - const hasResults = filteredResults.length > 0; - - return ( - <CustomScrollbar autoHeightMax="100%" autoHeightMin="100%"> - {isAlertingEnabled && ( - <div> - {hasResults && - filteredResults.map((group) => { - return ( - <AlertGroup - alertManagerSourceName={alertManagerSourceName} - key={JSON.stringify(group.labels)} - group={group} - expandAll={expandAll} - /> - ); - })} - {!hasResults && 'No alerts'} - </div> - )} - </CustomScrollbar> - ); -}; diff --git a/public/app/plugins/panel/alertGroups/AlertmanagerPicker.tsx b/public/app/plugins/panel/alertGroups/AlertmanagerPicker.tsx deleted file mode 100644 index 2d18cd111a072..0000000000000 --- a/public/app/plugins/panel/alertGroups/AlertmanagerPicker.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React, { useMemo } from 'react'; - -import { SelectableValue } from '@grafana/data'; -import { Select } from '@grafana/ui'; -import { AlertManagerDataSource, GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; - -interface Props { - onChange: (alertManagerSourceName: string) => void; - current?: string; - dataSources: AlertManagerDataSource[]; -} - -function getAlertManagerLabel(alertManager: AlertManagerDataSource) { - return alertManager.name === GRAFANA_RULES_SOURCE_NAME ? 'Grafana' : alertManager.name.slice(0, 37); -} - -export const AlertManagerPicker = ({ onChange, current, dataSources }: Props) => { - const options: Array<SelectableValue<string>> = useMemo(() => { - return dataSources.map((ds) => ({ - label: getAlertManagerLabel(ds), - value: ds.name, - imgUrl: ds.imgUrl, - meta: ds.meta, - })); - }, [dataSources]); - - return ( - <Select - aria-label={'Choose Alertmanager'} - width={29} - backspaceRemovesValue={false} - onChange={(value) => value.value && onChange(value.value)} - options={options} - maxMenuHeight={500} - noOptionsMessage="No datasources found" - value={current} - getOptionLabel={(o) => o.label} - /> - ); -}; diff --git a/public/app/plugins/panel/alertGroups/img/icn-alertgroups-panel.svg b/public/app/plugins/panel/alertGroups/img/icn-alertgroups-panel.svg deleted file mode 100644 index ef30f162bd639..0000000000000 --- a/public/app/plugins/panel/alertGroups/img/icn-alertgroups-panel.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 107.68 75"><defs><style>.cls-1{fill:#3865ab;}.cls-2{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="55.86" y1="36.22" x2="107.68" y2="36.22" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><rect class="cls-1" x="34.6" y="35.29" width="29.04" height="4.55"/><path class="cls-1" d="M0,0H28V15.72H0Z"/><path class="cls-1" d="M0,29.64H28V45.36H0Z"/><path class="cls-1" d="M0,59.29H28V75H0Z"/><path class="cls-1" d="M38.49,69.48H34V64.93h4.47a4.24,4.24,0,0,0,4.24-4.24V14.31a4.24,4.24,0,0,0-4.24-4.24H33.63V5.52h4.86a8.8,8.8,0,0,1,8.79,8.79V60.69A8.8,8.8,0,0,1,38.49,69.48Z"/><path class="cls-2" d="M107.68,50.59c0-2.89-2.25-5.36-4.85-6.58V32.58A21.22,21.22,0,0,0,83.92,11.52V7a2.28,2.28,0,0,0-4.55,0v4.49A21.22,21.22,0,0,0,60.46,32.58V44c-2.54,1.22-4.71,3.63-4.6,6.61V58H68.71a13.47,13.47,0,0,0,25.87,0h13.1Zm-26,12.55A8.91,8.91,0,0,1,73.55,58H89.74A8.93,8.93,0,0,1,81.64,63.14Zm21.5-9.71H95.3V53.3H68v.13H60.41v-3c0-1.17,1.67-2.36,2.89-2.66L65,47.38V32.58A16.66,16.66,0,0,1,81.6,15.94h.09A16.66,16.66,0,0,1,98.28,32.58V47.41l1.75.41c1.4.33,3.11,1.67,3.11,2.77Z"/></g></g></svg> \ No newline at end of file diff --git a/public/app/plugins/panel/alertGroups/module.tsx b/public/app/plugins/panel/alertGroups/module.tsx deleted file mode 100644 index 3029c08876790..0000000000000 --- a/public/app/plugins/panel/alertGroups/module.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React, { useMemo } from 'react'; - -import { PanelPlugin } from '@grafana/data'; -import { - getAllAlertManagerDataSources, - GRAFANA_RULES_SOURCE_NAME, -} from 'app/features/alerting/unified/utils/datasource'; - -import { AlertGroupsPanel } from './AlertGroupsPanel'; -import { AlertManagerPicker } from './AlertmanagerPicker'; -import { Options } from './panelcfg.gen'; - -export const plugin = new PanelPlugin<Options>(AlertGroupsPanel).setPanelOptions((builder) => { - return builder - .addCustomEditor({ - name: 'Alertmanager', - path: 'alertmanager', - id: 'alertmanager', - defaultValue: GRAFANA_RULES_SOURCE_NAME, - category: ['Options'], - editor: function RenderAlertmanagerPicker(props) { - const alertManagers = useMemo(getAllAlertManagerDataSources, []); - - return ( - <AlertManagerPicker - current={props.value} - onChange={(alertManagerSourceName) => { - return props.onChange(alertManagerSourceName); - }} - dataSources={alertManagers} - /> - ); - }, - }) - .addBooleanSwitch({ - name: 'Expand all by default', - path: 'expandAll', - defaultValue: false, - category: ['Options'], - }) - .addTextInput({ - description: 'Filter results by matching labels, ex: env=production,severity=~critical|warning', - name: 'Labels', - path: 'labels', - category: ['Filter'], - }); -}); diff --git a/public/app/plugins/panel/alertGroups/panelcfg.cue b/public/app/plugins/panel/alertGroups/panelcfg.cue deleted file mode 100644 index d5f595f092701..0000000000000 --- a/public/app/plugins/panel/alertGroups/panelcfg.cue +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2023 Grafana Labs -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package grafanaplugin - -composableKinds: PanelCfg: lineage: { - schemas: [{ - version: [0, 0] - schema: { - Options: { - // Comma-separated list of values used to filter alert results - labels: string - // Name of the alertmanager used as a source for alerts - alertmanager: string - // Expand all alert groups by default - expandAll: bool - } @cuetsy(kind="interface") - } - }] - lenses: [] -} diff --git a/public/app/plugins/panel/alertGroups/panelcfg.gen.ts b/public/app/plugins/panel/alertGroups/panelcfg.gen.ts deleted file mode 100644 index dccaa7be815e3..0000000000000 --- a/public/app/plugins/panel/alertGroups/panelcfg.gen.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// public/app/plugins/gen.go -// Using jennies: -// TSTypesJenny -// PluginTSTypesJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -export interface Options { - /** - * Name of the alertmanager used as a source for alerts - */ - alertmanager: string; - /** - * Expand all alert groups by default - */ - expandAll: boolean; - /** - * Comma-separated list of values used to filter alert results - */ - labels: string; -} diff --git a/public/app/plugins/panel/alertGroups/plugin.json b/public/app/plugins/panel/alertGroups/plugin.json deleted file mode 100644 index 4b86aae539e03..0000000000000 --- a/public/app/plugins/panel/alertGroups/plugin.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "type": "panel", - "name": "Alert groups", - "id": "alertGroups", - "state": "alpha", - - "skipDataQuery": true, - "info": { - "description": "Shows alertmanager alerts grouped by labels", - "author": { - "name": "Grafana Labs", - "url": "https://grafana.com" - }, - "logos": { - "small": "img/icn-alertgroups-panel.svg", - "large": "img/icn-alertgroups-panel.svg" - } - } -} diff --git a/public/app/plugins/panel/alertGroups/useFilteredGroups.ts b/public/app/plugins/panel/alertGroups/useFilteredGroups.ts deleted file mode 100644 index b2aad016a62b5..0000000000000 --- a/public/app/plugins/panel/alertGroups/useFilteredGroups.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useMemo } from 'react'; - -import { labelsMatchMatchers } from 'app/features/alerting/unified/utils/alertmanager'; -import { AlertmanagerGroup, Matcher } from 'app/plugins/datasource/alertmanager/types'; - -export const useFilteredGroups = (groups: AlertmanagerGroup[], matchers: Matcher[]): AlertmanagerGroup[] => { - return useMemo(() => { - return groups.filter((group) => { - return ( - labelsMatchMatchers(group.labels, matchers) || - group.alerts.some((alert) => labelsMatchMatchers(alert.labels, matchers)) - ); - }); - }, [groups, matchers]); -}; diff --git a/public/app/plugins/panel/alertlist/AlertList.tsx b/public/app/plugins/panel/alertlist/AlertList.tsx deleted file mode 100644 index 3ef7825186680..0000000000000 --- a/public/app/plugins/panel/alertlist/AlertList.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import { css, cx } from '@emotion/css'; -import { sortBy } from 'lodash'; -import React, { useState } from 'react'; -import { useAsync } from 'react-use'; - -import { dateMath, dateTime, GrafanaTheme2, PanelProps } from '@grafana/data'; -import { getBackendSrv } from '@grafana/runtime'; -import { Card, CustomScrollbar, Icon, useStyles2 } from '@grafana/ui'; -import alertDef from 'app/features/alerting/state/alertDef'; -import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; -import { AlertRuleDTO, AnnotationItemDTO } from 'app/types'; - -import { AlertListOptions, ShowOption, SortOrder } from './types'; - -export function AlertList(props: PanelProps<AlertListOptions>) { - const [noAlertsMessage, setNoAlertsMessage] = useState(''); - - const currentAlertState = useAsync(async () => { - if (props.options.showOptions !== ShowOption.Current) { - return; - } - - const params: any = { - state: getStateFilter(props.options.stateFilter), - }; - - if (props.options.alertName) { - params.query = props.replaceVariables(props.options.alertName); - } - - if (props.options.folderId >= 0) { - params.folderId = props.options.folderId; - } - - if (props.options.dashboardTitle) { - params.dashboardQuery = props.options.dashboardTitle; - } - - if (props.options.dashboardAlerts) { - params.dashboardId = getDashboardSrv().getCurrent()?.id; - } - - if (props.options.tags) { - params.dashboardTag = props.options.tags; - } - - const alerts: AlertRuleDTO[] = await getBackendSrv().get( - '/api/alerts', - params, - `alert-list-get-current-alert-state-${props.id}` - ); - let currentAlerts = sortAlerts( - props.options.sortOrder, - alerts.map((al) => ({ - ...al, - stateModel: alertDef.getStateDisplayModel(al.state), - newStateDateAgo: dateTime(al.newStateDate).locale('en').fromNow(true), - })) - ); - - if (currentAlerts.length > props.options.maxItems) { - currentAlerts = currentAlerts.slice(0, props.options.maxItems); - } - setNoAlertsMessage(currentAlerts.length === 0 ? 'No alerts' : ''); - - return currentAlerts; - }, [ - props.options.showOptions, - props.options.stateFilter.alerting, - props.options.stateFilter.execution_error, - props.options.stateFilter.no_data, - props.options.stateFilter.ok, - props.options.stateFilter.paused, - props.options.stateFilter.pending, - props.options.maxItems, - props.options.tags, - props.options.dashboardAlerts, - props.options.dashboardTitle, - props.options.folderId, - props.options.alertName, - props.options.sortOrder, - props.timeRange, - ]); - - const recentStateChanges = useAsync(async () => { - if (props.options.showOptions !== ShowOption.RecentChanges) { - return; - } - - const params: any = { - limit: props.options.maxItems, - type: 'alert', - newState: getStateFilter(props.options.stateFilter), - }; - const currentDashboard = getDashboardSrv().getCurrent(); - - if (props.options.dashboardAlerts) { - params.dashboardId = currentDashboard?.id; - } - - params.from = dateMath.parse(currentDashboard?.time.from)!.unix() * 1000; - params.to = dateMath.parse(currentDashboard?.time.to)!.unix() * 1000; - - const data: AnnotationItemDTO[] = await getBackendSrv().get( - '/api/annotations', - params, - `alert-list-get-state-changes-${props.id}` - ); - const alertHistory = sortAlerts( - props.options.sortOrder, - data.map((al) => { - return { - ...al, - time: currentDashboard?.formatDate(al.time, 'MMM D, YYYY HH:mm:ss'), - stateModel: alertDef.getStateDisplayModel(al.newState), - info: alertDef.getAlertAnnotationInfo(al), - }; - }) - ); - - setNoAlertsMessage(alertHistory.length === 0 ? 'No alerts in current time range' : ''); - return alertHistory; - }, [ - props.options.showOptions, - props.options.maxItems, - props.options.stateFilter.alerting, - props.options.stateFilter.execution_error, - props.options.stateFilter.no_data, - props.options.stateFilter.ok, - props.options.stateFilter.paused, - props.options.stateFilter.pending, - props.options.dashboardAlerts, - props.options.sortOrder, - ]); - - const styles = useStyles2(getStyles); - - return ( - <CustomScrollbar autoHeightMin="100%" autoHeightMax="100%"> - <div className={styles.container}> - {noAlertsMessage && <div className={styles.noAlertsMessage}>{noAlertsMessage}</div>} - <section> - <ol className={styles.alertRuleList}> - {props.options.showOptions === ShowOption.Current - ? !currentAlertState.loading && - currentAlertState.value && - currentAlertState.value!.map((alert) => ( - <li className={styles.alertRuleItem} key={`alert-${alert.id}`}> - <Card href={`${alert.url}?viewPanel=${alert.panelId}`} className={styles.cardContainer}> - <Card.Heading>{alert.name}</Card.Heading> - <Card.Figure className={cx(styles.alertRuleItemIcon, alert.stateModel.stateClass)}> - <Icon name={alert.stateModel.iconClass} size="xl" className={styles.alertIcon} /> - </Card.Figure> - <Card.Meta> - <div className={styles.alertRuleItemText}> - <span className={alert.stateModel.stateClass}>{alert.stateModel.text}</span> - <span className={styles.alertRuleItemTime}> for {alert.newStateDateAgo}</span> - </div> - </Card.Meta> - </Card> - </li> - )) - : !recentStateChanges.loading && - recentStateChanges.value && - recentStateChanges.value.map((alert) => ( - <li className={styles.alertRuleItem} key={`alert-${alert.id}`}> - <Card className={styles.cardContainer}> - <Card.Heading>{alert.alertName}</Card.Heading> - <Card.Figure className={cx(styles.alertRuleItemIcon, alert.stateModel.stateClass)}> - <Icon name={alert.stateModel.iconClass} size="xl" /> - </Card.Figure> - <Card.Meta> - <span className={cx(styles.alertRuleItemText, alert.stateModel.stateClass)}> - {alert.stateModel.text} - </span> - <span>{alert.time}</span> - {alert.info && <span className={styles.alertRuleItemInfo}>{alert.info}</span>} - </Card.Meta> - </Card> - </li> - ))} - </ol> - </section> - </div> - </CustomScrollbar> - ); -} - -function sortAlerts(sortOrder: SortOrder, alerts: any[]) { - if (sortOrder === SortOrder.Importance) { - // @ts-ignore - return sortBy(alerts, (a) => alertDef.alertStateSortScore[a.state || a.newState]); - } else if (sortOrder === SortOrder.TimeAsc) { - return sortBy(alerts, (a) => new Date(a.newStateDate || a.time)); - } else if (sortOrder === SortOrder.TimeDesc) { - return sortBy(alerts, (a) => new Date(a.newStateDate || a.time)).reverse(); - } - - const result = sortBy(alerts, (a) => (a.name || a.alertName).toLowerCase()); - if (sortOrder === SortOrder.AlphaDesc) { - result.reverse(); - } - - return result; -} - -function getStateFilter(stateFilter: Record<string, boolean>) { - return Object.entries(stateFilter) - .filter(([_, val]) => val) - .map(([key, _]) => key); -} - -const getStyles = (theme: GrafanaTheme2) => ({ - cardContainer: css` - padding: ${theme.spacing(0.5)} 0 ${theme.spacing(0.25)} 0; - line-height: ${theme.typography.body.lineHeight}; - margin-bottom: 0px; - `, - container: css` - overflow-y: auto; - height: 100%; - `, - alertRuleList: css` - display: flex; - flex-wrap: wrap; - justify-content: space-between; - list-style-type: none; - `, - alertRuleItem: css` - display: flex; - align-items: center; - width: 100%; - height: 100%; - background: ${theme.colors.background.secondary}; - padding: ${theme.spacing(0.5)} ${theme.spacing(1)}; - border-radius: ${theme.shape.radius.default}; - margin-bottom: ${theme.spacing(0.5)}; - `, - alertRuleItemIcon: css` - display: flex; - justify-content: center; - align-items: center; - width: ${theme.spacing(4)}; - padding: 0 ${theme.spacing(0.5)} 0 ${theme.spacing(0.25)}; - margin-right: 0px; - `, - alertRuleItemText: css` - font-weight: ${theme.typography.fontWeightBold}; - font-size: ${theme.typography.size.sm}; - margin: 0; - `, - alertRuleItemTime: css` - color: ${theme.colors.text.secondary}; - font-weight: normal; - white-space: nowrap; - `, - alertRuleItemInfo: css` - font-weight: normal; - flex-grow: 2; - display: flex; - align-items: flex-end; - `, - noAlertsMessage: css` - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - `, - alertIcon: css` - margin-right: ${theme.spacing(0.5)}; - `, -}); diff --git a/public/app/plugins/panel/alertlist/AlertListMigration.test.ts b/public/app/plugins/panel/alertlist/AlertListMigration.test.ts deleted file mode 100644 index f08b6c7a996a5..0000000000000 --- a/public/app/plugins/panel/alertlist/AlertListMigration.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { PanelModel } from '@grafana/data'; - -import { alertListPanelMigrationHandler } from './AlertListMigrationHandler'; -import { AlertListOptions, ShowOption, SortOrder } from './types'; - -describe('AlertList Panel Migration', () => { - it('should migrate from < 7.5', () => { - const panel: Omit<PanelModel, 'fieldConfig'> & Record<string, unknown> = { - id: 7, - links: [], - pluginVersion: '7.4.0', - targets: [], - title: 'Usage', - type: 'alertlist', - nameFilter: 'Customer', - show: 'current', - sortOrder: 1, - stateFilter: ['ok', 'paused'], - dashboardTags: ['tag_a', 'tag_b'], - dashboardFilter: '', - limit: 10, - onlyAlertsOnDashboard: false, - options: {}, - }; - - const newOptions = alertListPanelMigrationHandler(panel as unknown as PanelModel); - expect(newOptions).toMatchObject({ - showOptions: ShowOption.Current, - maxItems: 10, - sortOrder: SortOrder.AlphaAsc, - dashboardAlerts: false, - alertName: 'Customer', - dashboardTitle: '', - tags: ['tag_a', 'tag_b'], - stateFilter: { - ok: true, - paused: true, - }, - folderId: undefined, - }); - - expect(panel).not.toHaveProperty('show'); - expect(panel).not.toHaveProperty('limit'); - expect(panel).not.toHaveProperty('sortOrder'); - expect(panel).not.toHaveProperty('onlyAlertsOnDashboard'); - expect(panel).not.toHaveProperty('nameFilter'); - expect(panel).not.toHaveProperty('dashboardFilter'); - expect(panel).not.toHaveProperty('folderId'); - expect(panel).not.toHaveProperty('dashboardTags'); - expect(panel).not.toHaveProperty('stateFilter'); - }); - - it('should handle >= 7.5', () => { - const panel: Omit<PanelModel<AlertListOptions>, 'fieldConfig'> & Record<string, unknown> = { - id: 7, - links: [], - pluginVersion: '7.5.0', - targets: [], - title: 'Usage', - type: 'alertlist', - options: { - showOptions: ShowOption.Current, - maxItems: 10, - sortOrder: SortOrder.AlphaAsc, - dashboardAlerts: false, - alertName: 'Customer', - dashboardTitle: '', - tags: ['tag_a', 'tag_b'], - stateFilter: { - ok: true, - paused: true, - no_data: false, - execution_error: false, - pending: false, - alerting: false, - }, - folderId: 1, - }, - }; - - const newOptions = alertListPanelMigrationHandler(panel as unknown as PanelModel); - expect(newOptions).toMatchObject({ - showOptions: 'current', - maxItems: 10, - sortOrder: SortOrder.AlphaAsc, - dashboardAlerts: false, - alertName: 'Customer', - dashboardTitle: '', - tags: ['tag_a', 'tag_b'], - stateFilter: { - ok: true, - paused: true, - no_data: false, - execution_error: false, - pending: false, - alerting: false, - }, - folderId: 1, - }); - - expect(panel).not.toHaveProperty('show'); - expect(panel).not.toHaveProperty('limit'); - expect(panel).not.toHaveProperty('sortOrder'); - expect(panel).not.toHaveProperty('onlyAlertsOnDashboard'); - expect(panel).not.toHaveProperty('nameFilter'); - expect(panel).not.toHaveProperty('dashboardFilter'); - expect(panel).not.toHaveProperty('folderId'); - expect(panel).not.toHaveProperty('dashboardTags'); - expect(panel).not.toHaveProperty('stateFilter'); - }); - - it('should handle config with no options or stateFilter', () => { - const panel: Omit<PanelModel, 'fieldConfig'> & Record<string, unknown> = { - id: 7, - links: [], - pluginVersion: '7.4.0', - targets: [], - title: 'Usage', - type: 'alertlist', - onlyAlertsOnDashboard: false, - options: {}, - }; - - const newOptions = alertListPanelMigrationHandler(panel as unknown as PanelModel); - expect(newOptions).toMatchObject({ - showOptions: ShowOption.Current, - maxItems: 10, - sortOrder: SortOrder.AlphaAsc, - dashboardAlerts: false, - alertName: '', - dashboardTitle: '', - tags: [], - stateFilter: {}, - folderId: undefined, - }); - }); -}); diff --git a/public/app/plugins/panel/alertlist/AlertListMigrationHandler.ts b/public/app/plugins/panel/alertlist/AlertListMigrationHandler.ts deleted file mode 100644 index 2fccfa8ddb5c9..0000000000000 --- a/public/app/plugins/panel/alertlist/AlertListMigrationHandler.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { PanelModel } from '@grafana/data'; - -import { AlertListOptions, ShowOption, SortOrder } from './types'; - -export const alertListPanelMigrationHandler = ( - panel: PanelModel<AlertListOptions> & Record<string, any> -): Partial<AlertListOptions> => { - const newOptions: AlertListOptions = { - showOptions: panel.options.showOptions ?? panel.show ?? ShowOption.Current, - maxItems: panel.options.maxItems ?? panel.limit ?? 10, - sortOrder: panel.options.sortOrder ?? panel.sortOrder ?? SortOrder.AlphaAsc, - dashboardAlerts: panel.options.dashboardAlerts ?? panel.onlyAlertsOnDashboard ?? false, - alertName: panel.options.alertName ?? panel.nameFilter ?? '', - dashboardTitle: panel.options.dashboardTitle ?? panel.dashboardFilter ?? '', - folderId: panel.options.folderId ?? panel.folderId, - tags: panel.options.tags ?? panel.dashboardTags ?? [], - stateFilter: - panel.options.stateFilter ?? - panel.stateFilter?.reduce((filterObj: any, curFilter: any) => ({ ...filterObj, [curFilter]: true }), {}) ?? - {}, - }; - - const previousVersion = parseFloat(panel.pluginVersion || '7.4'); - if (previousVersion < 7.5) { - const oldProps = [ - 'show', - 'limit', - 'sortOrder', - 'onlyAlertsOnDashboard', - 'nameFilter', - 'dashboardFilter', - 'folderId', - 'dashboardTags', - 'stateFilter', - ]; - oldProps.forEach((prop) => delete panel[prop]); - } - - return newOptions; -}; diff --git a/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx b/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx index 890f8b2383392..c6676c6ad42ee 100644 --- a/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx +++ b/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx @@ -16,7 +16,6 @@ import { useStyles2, } from '@grafana/ui'; import { config } from 'app/core/config'; -import { contextSrv } from 'app/core/services/context_srv'; import alertDef from 'app/features/alerting/state/alertDef'; import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails'; @@ -38,9 +37,10 @@ import { flattenCombinedRules, getFirstActiveAt } from 'app/features/alerting/un import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { DashboardModel } from 'app/features/dashboard/state'; import { Matcher } from 'app/plugins/datasource/alertmanager/types'; -import { AccessControlAction, ThunkDispatch, useDispatch } from 'app/types'; +import { ThunkDispatch, useDispatch } from 'app/types'; import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; +import { AlertingAction, useAlertingAbility } from '../../../features/alerting/unified/hooks/useAbilities'; import { getAlertingRule } from '../../../features/alerting/unified/utils/rules'; import { AlertingRule, CombinedRuleWithLocation } from '../../../types/unified-alerting'; @@ -93,9 +93,10 @@ const fetchPromAndRuler = ({ } }; -export function UnifiedAlertList(props: PanelProps<UnifiedAlertListOptions>) { +function UnifiedAlertList(props: PanelProps<UnifiedAlertListOptions>) { const dispatch = useDispatch(); const [limitInstances, toggleLimit] = useToggle(true); + const [, gmaViewAllowed] = useAlertingAbility(AlertingAction.ViewAlertRule); const { usePrometheusRulesByNamespaceQuery } = alertRuleApi; @@ -137,7 +138,7 @@ export function UnifiedAlertList(props: PanelProps<UnifiedAlertListOptions>) { // If the datasource is not defined we should NOT skip the query // Undefined dataSourceName means that there is no datasource filter applied and we should fetch all the rules - const shouldFetchGrafanaRules = !dataSourceName || dataSourceName === GRAFANA_RULES_SOURCE_NAME; + const shouldFetchGrafanaRules = (!dataSourceName || dataSourceName === GRAFANA_RULES_SOURCE_NAME) && gmaViewAllowed; //For grafana managed rules, get the result using RTK Query to avoid the need of using the redux store //See https://github.com/grafana/grafana/pull/70482 @@ -217,15 +218,6 @@ export function UnifiedAlertList(props: PanelProps<UnifiedAlertListOptions>) { const havePreviousResults = Object.values(promRulesRequests).some((state) => state.result); - if ( - !contextSrv.hasPermission(AccessControlAction.AlertingRuleRead) && - !contextSrv.hasPermission(AccessControlAction.AlertingRuleExternalRead) - ) { - return ( - <Alert title="Permission required">Sorry, you do not have the required permissions to read alert rules</Alert> - ); - } - return ( <CustomScrollbar autoHeightMin="100%" autoHeightMax="100%"> <div className={styles.container}> @@ -456,3 +448,16 @@ export const getStyles = (theme: GrafanaTheme2) => ({ display: none; `, }); + +export function UnifiedAlertListPanel(props: PanelProps<UnifiedAlertListOptions>) { + const [, gmaReadAllowed] = useAlertingAbility(AlertingAction.ViewAlertRule); + const [, externalReadAllowed] = useAlertingAbility(AlertingAction.ViewExternalAlertRule); + + if (!gmaReadAllowed && !externalReadAllowed) { + return ( + <Alert title="Permission required">Sorry, you do not have the required permissions to read alert rules</Alert> + ); + } + + return <UnifiedAlertList {...props} />; +} diff --git a/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx b/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx index 22d963ddf4c16..d4847dd8289c3 100644 --- a/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx +++ b/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx @@ -2,7 +2,6 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; -import { act } from 'react-test-renderer'; import { byRole, byText } from 'testing-library-selector'; import { FieldConfigSource, getDefaultTimeRange, LoadingState, PanelProps, PluginExtensionTypes } from '@grafana/data'; @@ -25,7 +24,7 @@ import { } from '../../../features/alerting/unified/mocks'; import { GRAFANA_RULES_SOURCE_NAME } from '../../../features/alerting/unified/utils/datasource'; -import { UnifiedAlertList } from './UnifiedAlertList'; +import { UnifiedAlertListPanel } from './UnifiedAlertList'; import { GroupMode, SortOrder, UnifiedAlertListOptions, ViewMode } from './types'; import * as utils from './util'; @@ -159,20 +158,20 @@ const renderPanel = (options: Partial<UnifiedAlertListOptions> = defaultOptions) return render( <Provider store={store}> - <UnifiedAlertList {...props} /> + <UnifiedAlertListPanel {...props} /> </Provider> ); }; describe('UnifiedAlertList', () => { + jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true); + it('subscribes to the dashboard refresh interval', async () => { jest.spyOn(defaultProps, 'replaceVariables').mockReturnValue('severity=critical'); - await act(async () => { - renderPanel(); - }); + renderPanel(); - expect(dashboard.events.subscribe).toHaveBeenCalledTimes(1); + await waitFor(() => expect(dashboard.events.subscribe).toHaveBeenCalledTimes(1)); expect(dashboard.events.subscribe.mock.calls[0][0]).toEqual(TimeRangeUpdatedEvent); }); @@ -180,21 +179,18 @@ describe('UnifiedAlertList', () => { await waitFor(() => { expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); }); - jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true); const filterAlertsSpy = jest.spyOn(utils, 'filterAlerts'); const replaceVarsSpy = jest.spyOn(defaultProps, 'replaceVariables').mockReturnValue('severity=critical'); const user = userEvent.setup(); - await act(async () => { - renderPanel({ - alertInstanceLabelFilter: '$label', - dashboardAlerts: false, - alertName: '', - datasource: GRAFANA_RULES_SOURCE_NAME, - folder: undefined, - }); + renderPanel({ + alertInstanceLabelFilter: '$label', + dashboardAlerts: false, + alertName: '', + datasource: GRAFANA_RULES_SOURCE_NAME, + folder: undefined, }); await waitFor(() => { @@ -222,4 +218,12 @@ describe('UnifiedAlertList', () => { expect.anything() ); }); + + it('should render authorization error when user has no permission', async () => { + jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); + + renderPanel(); + + expect(screen.getByRole('alert', { name: 'Permission required' })).toBeInTheDocument(); + }); }); diff --git a/public/app/plugins/panel/alertlist/module.tsx b/public/app/plugins/panel/alertlist/module.tsx index 8250c7fd4d460..23b2f000b5a59 100644 --- a/public/app/plugins/panel/alertlist/module.tsx +++ b/public/app/plugins/panel/alertlist/module.tsx @@ -1,162 +1,18 @@ import React from 'react'; import { DataSourceInstanceSettings, PanelPlugin } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { Button, Stack, TagsInput } from '@grafana/ui'; +import { Button, Stack } from '@grafana/ui'; import { OldFolderPicker } from 'app/core/components/Select/OldFolderPicker'; -import { - ALL_FOLDER, - GENERAL_FOLDER, - ReadonlyFolderPicker, -} from 'app/core/components/Select/ReadonlyFolderPicker/ReadonlyFolderPicker'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; import { PermissionLevelString } from 'app/types'; import { GRAFANA_DATASOURCE_NAME } from '../../../features/alerting/unified/utils/datasource'; -import { AlertList } from './AlertList'; -import { alertListPanelMigrationHandler } from './AlertListMigrationHandler'; import { GroupBy } from './GroupByWithLoading'; -import { UnifiedAlertList } from './UnifiedAlertList'; -import { AlertListSuggestionsSupplier } from './suggestions'; -import { AlertListOptions, GroupMode, ShowOption, SortOrder, UnifiedAlertListOptions, ViewMode } from './types'; +import { UnifiedAlertListPanel } from './UnifiedAlertList'; +import { UnifiedAlertListOptions, ViewMode, GroupMode, SortOrder } from './types'; -function showIfCurrentState(options: AlertListOptions) { - return options.showOptions === ShowOption.Current; -} - -const alertList = new PanelPlugin<AlertListOptions>(AlertList) - .setPanelOptions((builder) => { - builder - .addSelect({ - name: 'Show', - path: 'showOptions', - settings: { - options: [ - { label: 'Current state', value: ShowOption.Current }, - { label: 'Recent state changes', value: ShowOption.RecentChanges }, - ], - }, - defaultValue: ShowOption.Current, - category: ['Options'], - }) - .addNumberInput({ - name: 'Max items', - path: 'maxItems', - defaultValue: 10, - category: ['Options'], - }) - .addSelect({ - name: 'Sort order', - path: 'sortOrder', - settings: { - options: [ - { label: 'Alphabetical (asc)', value: SortOrder.AlphaAsc }, - { label: 'Alphabetical (desc)', value: SortOrder.AlphaDesc }, - { label: 'Importance', value: SortOrder.Importance }, - { label: 'Time (asc)', value: SortOrder.TimeAsc }, - { label: 'Time (desc)', value: SortOrder.TimeDesc }, - ], - }, - defaultValue: SortOrder.AlphaAsc, - category: ['Options'], - }) - .addBooleanSwitch({ - path: 'dashboardAlerts', - name: 'Alerts from this dashboard', - defaultValue: false, - category: ['Options'], - }) - .addTextInput({ - path: 'alertName', - name: 'Alert name', - defaultValue: '', - category: ['Filter'], - showIf: showIfCurrentState, - }) - .addTextInput({ - path: 'dashboardTitle', - name: 'Dashboard title', - defaultValue: '', - category: ['Filter'], - showIf: showIfCurrentState, - }) - .addCustomEditor({ - path: 'folderId', - name: 'Folder', - id: 'folderId', - defaultValue: null, - editor: function RenderFolderPicker({ value, onChange }) { - return ( - <ReadonlyFolderPicker - initialFolderId={value} - onChange={(folder) => onChange(folder?.id)} - extraFolders={[ALL_FOLDER, GENERAL_FOLDER]} - /> - ); - }, - category: ['Filter'], - showIf: showIfCurrentState, - }) - .addCustomEditor({ - id: 'tags', - path: 'tags', - name: 'Tags', - description: '', - defaultValue: [], - editor(props) { - return <TagsInput tags={props.value} onChange={props.onChange} />; - }, - category: ['Filter'], - showIf: showIfCurrentState, - }) - .addBooleanSwitch({ - path: 'stateFilter.ok', - name: 'Ok', - defaultValue: false, - category: ['State filter'], - showIf: showIfCurrentState, - }) - .addBooleanSwitch({ - path: 'stateFilter.paused', - name: 'Paused', - defaultValue: false, - category: ['State filter'], - showIf: showIfCurrentState, - }) - .addBooleanSwitch({ - path: 'stateFilter.no_data', - name: 'No data', - defaultValue: false, - category: ['State filter'], - showIf: showIfCurrentState, - }) - .addBooleanSwitch({ - path: 'stateFilter.execution_error', - name: 'Execution error', - defaultValue: false, - category: ['State filter'], - showIf: showIfCurrentState, - }) - .addBooleanSwitch({ - path: 'stateFilter.alerting', - name: 'Alerting', - defaultValue: false, - category: ['State filter'], - showIf: showIfCurrentState, - }) - .addBooleanSwitch({ - path: 'stateFilter.pending', - name: 'Pending', - defaultValue: false, - category: ['State filter'], - showIf: showIfCurrentState, - }); - }) - .setMigrationHandler(alertListPanelMigrationHandler) - .setSuggestionsSupplier(new AlertListSuggestionsSupplier()); - -const unifiedAlertList = new PanelPlugin<UnifiedAlertListOptions>(UnifiedAlertList).setPanelOptions((builder) => { +const unifiedAlertList = new PanelPlugin<UnifiedAlertListOptions>(UnifiedAlertListPanel).setPanelOptions((builder) => { builder .addRadio({ path: 'viewMode', @@ -326,4 +182,4 @@ const unifiedAlertList = new PanelPlugin<UnifiedAlertListOptions>(UnifiedAlertLi }); }); -export const plugin = config.unifiedAlertingEnabled ? unifiedAlertList : alertList; +export const plugin = unifiedAlertList; diff --git a/public/app/plugins/panel/alertlist/suggestions.ts b/public/app/plugins/panel/alertlist/suggestions.ts deleted file mode 100644 index dbae9066a16de..0000000000000 --- a/public/app/plugins/panel/alertlist/suggestions.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { VisualizationSuggestionsBuilder } from '@grafana/data'; - -import { AlertListOptions } from './types'; - -export class AlertListSuggestionsSupplier { - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const { dataSummary } = builder; - - if (dataSummary.hasData) { - return; - } - - const list = builder.getListAppender<AlertListOptions, {}>({ - name: 'Dashboard list', - pluginId: 'dashlist', - options: {}, - }); - - list.append({}); - } -} diff --git a/public/app/plugins/panel/annolist/AnnoListPanel.test.tsx b/public/app/plugins/panel/annolist/AnnoListPanel.test.tsx index f42c57ff7b7cf..0e7044af8e2a0 100644 --- a/public/app/plugins/panel/annolist/AnnoListPanel.test.tsx +++ b/public/app/plugins/panel/annolist/AnnoListPanel.test.tsx @@ -7,7 +7,7 @@ import { locationService } from '@grafana/runtime'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; import { backendSrv } from '../../../core/services/backend_srv'; -import { setDashboardSrv } from '../../../features/dashboard/services/DashboardSrv'; +import { DashboardSrv, setDashboardSrv } from '../../../features/dashboard/services/DashboardSrv'; import { AnnoListPanel, Props } from './AnnoListPanel'; import { Options } from './panelcfg.gen'; @@ -30,7 +30,7 @@ const defaultOptions: Options = { tags: ['tag A', 'tag B'], }; -const defaultResult: any = { +const defaultResult = { text: 'Result text', userId: 1, login: 'Result login', @@ -40,7 +40,7 @@ const defaultResult: any = { time: Date.UTC(2021, 0, 1, 0, 0, 0, 0), panelId: 13, dashboardId: 14, // deliberately different from panelId - id: 14, + id: '14', uid: '7MeksYbmk', dashboardUID: '7MeksYbmk', url: '/d/asdkjhajksd/some-dash', @@ -55,8 +55,8 @@ async function setupTestContext({ const getMock = jest.spyOn(backendSrv, 'get'); getMock.mockResolvedValue(results); - const dash: any = { uid: 'srx16xR4z', formatDate: (time: number) => new Date(time).toISOString() }; - const dashSrv: any = { getCurrent: () => dash }; + const dash = { uid: 'srx16xR4z', formatDate: (time: number) => new Date(time).toISOString() }; + const dashSrv = { getCurrent: () => dash } as DashboardSrv; setDashboardSrv(dashSrv); const pushSpy = jest.spyOn(locationService, 'push'); @@ -64,10 +64,9 @@ async function setupTestContext({ data: { state: LoadingState.Done, timeRange: getDefaultTimeRange(), series: [] }, eventBus: { subscribe: jest.fn(), - getStream: () => - ({ - subscribe: jest.fn(), - }) as any, + getStream: jest.fn().mockImplementation(() => ({ + subscribe: jest.fn(), + })), publish: jest.fn(), removeAllListeners: jest.fn(), newScopedBus: jest.fn(), diff --git a/public/app/plugins/panel/annolist/AnnoListPanel.tsx b/public/app/plugins/panel/annolist/AnnoListPanel.tsx index 252e353dccc01..2e637d7c3bf0d 100644 --- a/public/app/plugins/panel/annolist/AnnoListPanel.tsx +++ b/public/app/plugins/panel/annolist/AnnoListPanel.tsx @@ -90,7 +90,11 @@ export class AnnoListPanel extends PureComponent<Props, State> { const { options } = this.props; const { queryUser, queryTags } = this.state; - const params: any = { + const params: { + tags: typeof options.tags; + limit: typeof options.limit; + type: string; + } & Record<string, unknown> = { tags: options.tags, limit: options.limit, type: 'annotation', // Skip the Annotations that are really alerts. (Use the alerts panel!) @@ -139,15 +143,12 @@ export class AnnoListPanel extends PureComponent<Props, State> { const dashboardSrv = getDashboardSrv(); const current = dashboardSrv.getCurrent(); - const params: any = { + const params = { from: this._timeOffset(anno.time, options.navigateBefore, true), to: this._timeOffset(anno.timeEnd ?? anno.time, options.navigateAfter, false), + viewPanel: options.navigateToPanel ? anno.panelId : undefined, }; - if (options.navigateToPanel) { - params.viewPanel = anno.panelId; - } - if (current?.uid === anno.dashboardUID) { locationService.partial(params); return; @@ -157,8 +158,8 @@ export class AnnoListPanel extends PureComponent<Props, State> { if (result && result.length && result[0].uid === anno.dashboardUID) { const dash = result[0]; const url = new URL(dash.url, window.location.origin); - url.searchParams.set('from', params.from); - url.searchParams.set('to', params.to); + url.searchParams.set('from', String(params.from)); + url.searchParams.set('to', String(params.to)); locationService.push(locationUtil.stripBaseFromUrl(url.toString())); return; } diff --git a/public/app/plugins/panel/annolist/module.tsx b/public/app/plugins/panel/annolist/module.tsx index d719e221fb6f6..e914b3fad98a9 100644 --- a/public/app/plugins/panel/annolist/module.tsx +++ b/public/app/plugins/panel/annolist/module.tsx @@ -95,9 +95,9 @@ export const plugin = new PanelPlugin<Options>(AnnoListPanel) }); }) // TODO, we should support this directly in the plugin infrastructure - .setPanelChangeHandler((panel: PanelModel<Options>, prevPluginId: string, prevOptions: unknown) => { + .setPanelChangeHandler((panel: PanelModel<Options>, prevPluginId, prevOptions) => { if (prevPluginId === 'ryantxu-annolist-panel') { - return prevOptions as Options; + return prevOptions; } return panel.options; diff --git a/public/app/plugins/panel/annolist/panelcfg.gen.ts b/public/app/plugins/panel/annolist/panelcfg.gen.ts index fd2a9274205cc..1734302372885 100644 --- a/public/app/plugins/panel/annolist/panelcfg.gen.ts +++ b/public/app/plugins/panel/annolist/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx index 30e25731c1f18..4bb4be5753b00 100644 --- a/public/app/plugins/panel/barchart/BarChartPanel.tsx +++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx @@ -12,7 +12,7 @@ import { TimeRange, VizOrientation, } from '@grafana/data'; -import { PanelDataErrorView } from '@grafana/runtime'; +import { PanelDataErrorView, config } from '@grafana/runtime'; import { SortOrder } from '@grafana/schema'; import { GraphGradientMode, @@ -28,13 +28,17 @@ import { VizLayout, VizLegend, VizTooltipContainer, + TooltipPlugin2, } from '@grafana/ui'; import { HoverEvent, addTooltipSupport } from '@grafana/ui/src/components/uPlot/config/addTooltipSupport'; +import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; import { GraphNG, GraphNGProps, PropDiffFn } from 'app/core/components/GraphNG/GraphNG'; import { getFieldLegendItem } from 'app/core/components/TimelineChart/utils'; import { DataHoverView } from 'app/features/visualization/data-hover/DataHoverView'; +import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip'; + import { Options } from './panelcfg.gen'; import { prepareBarChartDisplayValues, preparePlotConfigBuilder } from './utils'; @@ -65,9 +69,9 @@ const propsToDiff: Array<string | PropDiffFn> = [ interface Props extends PanelProps<Options> {} -export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZone, id }: Props) => { +export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZone, id, replaceVariables }: Props) => { const theme = useTheme2(); - const { eventBus } = usePanelContext(); + const { dataLinkPostProcessor } = usePanelContext(); const oldConfig = useRef<UPlotConfigBuilder | undefined>(undefined); const isToolTipOpen = useRef<boolean>(false); @@ -213,7 +217,7 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ } } - return <PlotLegend data={info.viz} config={config} maxHeight="35%" maxWidth="60%" {...options.legend} />; + return <PlotLegend data={[info.legend]} config={config} maxHeight="35%" maxWidth="60%" {...options.legend} />; }; const rawValue = (seriesIdx: number, valueIdx: number) => { @@ -284,7 +288,6 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ timeZone, theme, timeZones: [timeZone], - eventBus, orientation, barWidth, barRadius, @@ -302,9 +305,12 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ fillOpacity, allFrames: info.viz, fullHighlight, + hoverMulti: tooltip.mode === TooltipDisplayMode.Multi, }); }; + const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips); + return ( <GraphNG theme={theme} @@ -319,9 +325,37 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ structureRev={structureRev} width={width} height={height} + replaceVariables={replaceVariables} + dataLinkPostProcessor={dataLinkPostProcessor} > {(config) => { - if (oldConfig.current !== config) { + if (showNewVizTooltips && options.tooltip.mode !== TooltipDisplayMode.None) { + return ( + <TooltipPlugin2 + config={config} + hoverMode={ + options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll + } + render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2) => { + return ( + <TimeSeriesTooltip + frames={info.viz} + seriesFrame={info.aligned} + dataIdxs={dataIdxs} + seriesIdx={seriesIdx} + mode={options.tooltip.mode} + sortOrder={options.tooltip.sort} + isPinned={isPinned} + /> + ); + }} + maxWidth={options.tooltip.maxWidth} + maxHeight={options.tooltip.maxHeight} + /> + ); + } + + if (!showNewVizTooltips && oldConfig.current !== config) { oldConfig.current = addTooltipSupport({ config, onUPlotClick, diff --git a/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap b/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap index e8513d91a0d3e..460a05d35ca37 100644 --- a/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap +++ b/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap @@ -68,7 +68,8 @@ exports[`BarChart utils preparePlotConfigBuilder orientation 1`] = ` "y": false, }, "focus": { - "prox": 30, + "dist": [Function], + "prox": 1000, }, "points": { "bbox": [Function], @@ -223,7 +224,8 @@ exports[`BarChart utils preparePlotConfigBuilder orientation 2`] = ` "y": false, }, "focus": { - "prox": 30, + "dist": [Function], + "prox": 1000, }, "points": { "bbox": [Function], @@ -378,7 +380,8 @@ exports[`BarChart utils preparePlotConfigBuilder orientation 3`] = ` "y": false, }, "focus": { - "prox": 30, + "dist": [Function], + "prox": 1000, }, "points": { "bbox": [Function], @@ -533,7 +536,8 @@ exports[`BarChart utils preparePlotConfigBuilder stacking 1`] = ` "y": false, }, "focus": { - "prox": 30, + "dist": [Function], + "prox": 1000, }, "points": { "bbox": [Function], @@ -688,7 +692,8 @@ exports[`BarChart utils preparePlotConfigBuilder stacking 2`] = ` "y": false, }, "focus": { - "prox": 30, + "dist": [Function], + "prox": 1000, }, "points": { "bbox": [Function], @@ -843,7 +848,8 @@ exports[`BarChart utils preparePlotConfigBuilder stacking 3`] = ` "y": false, }, "focus": { - "prox": 30, + "dist": [Function], + "prox": 1000, }, "points": { "bbox": [Function], @@ -998,7 +1004,8 @@ exports[`BarChart utils preparePlotConfigBuilder value visibility 1`] = ` "y": false, }, "focus": { - "prox": 30, + "dist": [Function], + "prox": 1000, }, "points": { "bbox": [Function], @@ -1153,7 +1160,8 @@ exports[`BarChart utils preparePlotConfigBuilder value visibility 2`] = ` "y": false, }, "focus": { - "prox": 30, + "dist": [Function], + "prox": 1000, }, "points": { "bbox": [Function], diff --git a/public/app/plugins/panel/barchart/bars.ts b/public/app/plugins/panel/barchart/bars.ts index ac127b87134f4..af1d287eae712 100644 --- a/public/app/plugins/panel/barchart/bars.ts +++ b/public/app/plugins/panel/barchart/bars.ts @@ -1,6 +1,6 @@ import uPlot, { Axis, AlignedData, Scale } from 'uplot'; -import { DataFrame, GrafanaTheme2, TimeZone } from '@grafana/data'; +import { DataFrame, dateTimeFormat, GrafanaTheme2, systemDateFormats, TimeZone } from '@grafana/data'; import { alpha } from '@grafana/data/src/themes/colorManipulator'; import { StackingMode, @@ -11,11 +11,13 @@ import { VizLegendOptions, } from '@grafana/schema'; import { measureText, PlotTooltipInterpolator } from '@grafana/ui'; -import { formatTime } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder'; +import { timeUnitSize } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder'; import { StackingGroup, preparePlotData2 } from '@grafana/ui/src/components/uPlot/utils'; +const intervals = systemDateFormats.interval; + import { distribute, SPACE_BETWEEN } from './distribute'; -import { intersects, pointWithin, Quadtree, Rect } from './quadtree'; +import { findRects, intersects, pointWithin, Quadtree, Rect } from './quadtree'; const groupDistr = SPACE_BETWEEN; const barDistr = SPACE_BETWEEN; @@ -56,6 +58,7 @@ export interface BarsOptions { text?: VizTextDisplayOptions; onHover?: (seriesIdx: number, valueIdx: number) => void; onLeave?: (seriesIdx: number, valueIdx: number) => void; + hoverMulti?: boolean; legend?: VizLegendOptions; xSpacing?: number; xTimeAuto?: boolean; @@ -128,6 +131,8 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { fillOpacity = 1, showValue, xSpacing = 0, + hoverMulti = false, + timeZone = 'browser', } = opts; const isXHorizontal = xOri === ScaleOrientation.Horizontal; const hasAutoValueSize = !Boolean(opts.text?.valueSize); @@ -141,6 +146,8 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { } let qt: Quadtree; + const numSeries = 30; // !! + const hovered: Array<Rect | null> = Array(numSeries).fill(null); let hRect: Rect | null; // for distr: 2 scales, the splits array should contain indices into data[0] rather than values @@ -175,22 +182,25 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { // the splits passed into here are data[0] values looked up by the indices returned from splits() const xValues: Axis.Values = (u, splits, axisIdx, foundSpace, foundIncr) => { if (opts.xTimeAuto) { - // bit of a hack: - // temporarily set x scale range to temporal (as expected by formatTime()) rather than ordinal - let xScale = u.scales.x; - let oMin = xScale.min; - let oMax = xScale.max; - - xScale.min = u.data[0][0]; - xScale.max = u.data[0][u.data[0].length - 1]; - - let vals = formatTime(u, splits, axisIdx, foundSpace, foundIncr); - - // revert - xScale.min = oMin; - xScale.max = oMax; + let format = intervals.year; + + if (foundIncr < timeUnitSize.second) { + format = intervals.millisecond; + } else if (foundIncr < timeUnitSize.minute) { + format = intervals.second; + } else if (foundIncr < timeUnitSize.hour) { + format = intervals.minute; + } else if (foundIncr < timeUnitSize.day) { + format = intervals.hour; + } else if (foundIncr < timeUnitSize.month) { + format = intervals.day; + } else if (foundIncr < timeUnitSize.year) { + format = intervals.month; + } else { + format = intervals.year; + } - return vals; + return splits.map((v) => (v == null ? '' : dateTimeFormat(v, { format, timeZone }))); } return splits.map((v) => (isXHorizontal ? formatShortValue(0, v) : formatValue(0, v))); @@ -289,11 +299,11 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { radius: pctStacked ? 0 : !isStacked - ? barRadius - : (u: uPlot, seriesIdx: number) => { - let isTopmostSeries = seriesIdx === u.data.length - 1; - return isTopmostSeries ? [barRadius, 0] : [0, 0]; - }, + ? barRadius + : (u: uPlot, seriesIdx: number) => { + let isTopmostSeries = seriesIdx === u.data.length - 1; + return isTopmostSeries ? [barRadius, 0] : [0, 0]; + }, disp: { x0: { unit: 2, @@ -324,7 +334,7 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { let barRect = { x: lft, y: top, w: wid, h: hgt, sidx: seriesIdx, didx: dataIdx }; - if (opts.fullHighlight) { + if (!isStacked && opts.fullHighlight) { if (opts.xOri === ScaleOrientation.Horizontal) { barRect.y = 0; barRect.h = u.bbox.height; @@ -443,8 +453,6 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { }); const init = (u: uPlot) => { - let over = u.over; - over.style.overflow = 'hidden'; u.root.querySelectorAll<HTMLDivElement>('.u-cursor-pt').forEach((el) => { el.style.borderRadius = '0'; @@ -462,7 +470,8 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { y: false, }, dataIdx: (u, seriesIdx) => { - if (seriesIdx === 1) { + if (seriesIdx === 0) { + hovered.fill(null); hRect = null; let cx = u.cursor.left! * uPlot.pxRatio; @@ -470,26 +479,37 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { qt.get(cx, cy, 1, 1, (o) => { if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) { - hRect = o; + hRect = hovered[0] = o; + hovered[hRect.sidx] = hRect; + + hoverMulti && + findRects(qt, undefined, hRect.didx).forEach((r) => { + hovered[r.sidx] = r; + }); } }); } - return hRect && seriesIdx === hRect.sidx ? hRect.didx : null; + return hovered[seriesIdx]?.didx; }, points: { fill: 'rgba(255,255,255,0.4)', bbox: (u, seriesIdx) => { - let isHovered = hRect && seriesIdx === hRect.sidx; + let hRect2 = hovered[seriesIdx]; + let isHovered = hRect2 != null; return { - left: isHovered ? hRect!.x / uPlot.pxRatio : -10, - top: isHovered ? hRect!.y / uPlot.pxRatio : -10, - width: isHovered ? hRect!.w / uPlot.pxRatio : 0, - height: isHovered ? hRect!.h / uPlot.pxRatio : 0, + left: isHovered ? hRect2!.x / uPlot.pxRatio : -10, + top: isHovered ? hRect2!.y / uPlot.pxRatio : -10, + width: isHovered ? hRect2!.w / uPlot.pxRatio : 0, + height: isHovered ? hRect2!.h / uPlot.pxRatio : 0, }; }, }, + focus: { + prox: 1e3, + dist: (u, seriesIdx) => (hRect?.sidx === seriesIdx ? 0 : Infinity), + }, }; // Build bars diff --git a/public/app/plugins/panel/barchart/migrations.test.ts b/public/app/plugins/panel/barchart/migrations.test.ts new file mode 100644 index 0000000000000..00af18aec7bae --- /dev/null +++ b/public/app/plugins/panel/barchart/migrations.test.ts @@ -0,0 +1,32 @@ +import { FieldConfigSource, PanelModel } from '@grafana/data'; + +import { changeToBarChartPanelMigrationHandler } from './migrations'; + +describe('Bar chart Migrations', () => { + let prevFieldConfig: FieldConfigSource; + + beforeEach(() => { + prevFieldConfig = { + defaults: {}, + overrides: [], + }; + }); + + it('From old graph', () => { + const old = { + angular: { + xaxis: { + mode: 'series', + values: 'avg', + }, + }, + }; + + const panel = {} as PanelModel; + panel.options = changeToBarChartPanelMigrationHandler(panel, 'graph', old, prevFieldConfig); + + const transform = panel.transformations![0]; + expect(transform.id).toBe('reduce'); + expect(transform.options.reducers).toBe('avg'); + }); +}); diff --git a/public/app/plugins/panel/barchart/migrations.ts b/public/app/plugins/panel/barchart/migrations.ts new file mode 100644 index 0000000000000..4dba05de84013 --- /dev/null +++ b/public/app/plugins/panel/barchart/migrations.ts @@ -0,0 +1,36 @@ +import { PanelTypeChangedHandler } from '@grafana/data'; + +/* + * This is called when the panel changes from another panel + */ +export const changeToBarChartPanelMigrationHandler: PanelTypeChangedHandler = ( + panel, + prevPluginId, + prevOptions, + prevFieldConfig +) => { + if (prevPluginId === 'graph') { + const graphOptions: GraphOptions = prevOptions.angular; + + if (graphOptions.xaxis?.mode === 'series') { + const tranformations = panel.transformations || []; + tranformations.push({ + id: 'reduce', + options: { + reducers: graphOptions.xaxis?.values ?? ['sum'], + }, + }); + + panel.transformations = tranformations; + } + } + + return {}; +}; + +interface GraphOptions { + xaxis: { + mode: 'series' | 'time' | 'histogram'; + values?: string[]; + }; +} diff --git a/public/app/plugins/panel/barchart/module.tsx b/public/app/plugins/panel/barchart/module.tsx index fbbdefb0b28f5..6fb9254af221c 100644 --- a/public/app/plugins/panel/barchart/module.tsx +++ b/public/app/plugins/panel/barchart/module.tsx @@ -9,18 +9,20 @@ import { VizOrientation, } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { GraphTransform, GraphTresholdsStyleMode, StackingMode, VisibilityMode } from '@grafana/schema'; +import { GraphTransform, GraphThresholdsStyleMode, StackingMode, VisibilityMode } from '@grafana/schema'; import { graphFieldOptions, commonOptionsBuilder } from '@grafana/ui'; import { ThresholdsStyleEditor } from '../timeseries/ThresholdsStyleEditor'; import { BarChartPanel } from './BarChartPanel'; import { TickSpacingEditor } from './TickSpacingEditor'; +import { changeToBarChartPanelMigrationHandler } from './migrations'; import { FieldConfig, Options, defaultFieldConfig, defaultOptions } from './panelcfg.gen'; import { BarChartSuggestionsSupplier } from './suggestions'; import { prepareBarChartDisplayValues } from './utils'; export const plugin = new PanelPlugin<Options, FieldConfig>(BarChartPanel) + .setPanelChangeHandler(changeToBarChartPanelMigrationHandler) .useFieldConfig({ standardOptions: { [FieldConfigProperty.Color]: { @@ -93,7 +95,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(BarChartPanel) path: 'thresholdsStyle', name: 'Show thresholds', category: ['Thresholds'], - defaultValue: { mode: GraphTresholdsStyleMode.Off }, + defaultValue: { mode: GraphThresholdsStyleMode.Off }, settings: { options: graphFieldOptions.thresholdsDisplayModes, }, @@ -225,6 +227,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(BarChartPanel) path: 'fullHighlight', name: 'Highlight full area on hover', defaultValue: defaultOptions.fullHighlight, + showIf: (c) => c.stacking === StackingMode.None, }); builder.addFieldNamePicker({ @@ -233,10 +236,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(BarChartPanel) description: 'Use the color value for a sibling field to color each bar value.', }); - if (!context.options?.fullHighlight || context.options?.stacking === StackingMode.None) { - commonOptionsBuilder.addTooltipOptions(builder); - } - + commonOptionsBuilder.addTooltipOptions(builder); commonOptionsBuilder.addLegendOptions(builder); commonOptionsBuilder.addTextSizeOptions(builder, false); }) diff --git a/public/app/plugins/panel/barchart/panelcfg.gen.ts b/public/app/plugins/panel/barchart/panelcfg.gen.ts index 841dee4a260d5..8a30ebf269f28 100644 --- a/public/app/plugins/panel/barchart/panelcfg.gen.ts +++ b/public/app/plugins/panel/barchart/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/barchart/quadtree.ts b/public/app/plugins/panel/barchart/quadtree.ts index 30446cacf3166..26850a9a8e881 100644 --- a/public/app/plugins/panel/barchart/quadtree.ts +++ b/public/app/plugins/panel/barchart/quadtree.ts @@ -14,24 +14,20 @@ export function pointWithin(px: number, py: number, rlft: number, rtop: number, /** * @internal */ -export function findRect(qt: Quadtree, sidx: number, didx: number): Rect | undefined { - let out: Rect | undefined; +export function findRects(qt: Quadtree, sidx?: number, didx?: number) { + let rects: Rect[] = []; if (qt.o.length) { - out = qt.o.find((rect) => rect.sidx === sidx && rect.didx === didx); + rects.push(...qt.o.filter((rect) => (sidx == null || rect.sidx === sidx) && (didx == null || rect.didx === didx))); } - if (out == null && qt.q) { + if (qt.q) { for (let i = 0; i < qt.q.length; i++) { - out = findRect(qt.q[i], sidx, didx); - - if (out) { - break; - } + rects.push(...findRects(qt.q[i], sidx, didx)); } } - return out; + return rects; } /** @@ -55,7 +51,7 @@ export class Quadtree { public y: number, public w: number, public h: number, - public l: number = 0 + public l = 0 ) { this.o = []; this.q = null; diff --git a/public/app/plugins/panel/barchart/types.ts b/public/app/plugins/panel/barchart/types.ts index f694f369a19a9..347ae56edc0f9 100644 --- a/public/app/plugins/panel/barchart/types.ts +++ b/public/app/plugins/panel/barchart/types.ts @@ -10,6 +10,12 @@ export interface BarChartDisplayValues { */ viz: [DataFrame]; + /** + * The fields we can display, first field is X axis. + * Contains same data as viz, but without config modifications (e.g: unit override) + */ + legend: DataFrame; + /** Potentialy color by a field value */ colorByField?: Field; } diff --git a/public/app/plugins/panel/barchart/utils.test.ts b/public/app/plugins/panel/barchart/utils.test.ts index 210bb5a52a9cf..07ea9e55e24a6 100644 --- a/public/app/plugins/panel/barchart/utils.test.ts +++ b/public/app/plugins/panel/barchart/utils.test.ts @@ -3,7 +3,6 @@ import { assertIsDefined } from 'test/helpers/asserts'; import { createTheme, DefaultTimeZone, - EventBusSrv, FieldConfig, FieldType, getDefaultTimeRange, @@ -120,7 +119,6 @@ describe('BarChart utils', () => { theme: createTheme(), timeZones: [DefaultTimeZone], getTimeRange: getDefaultTimeRange, - eventBus: new EventBusSrv(), allFrames: [frame], }).getConfig(); expect(result).toMatchSnapshot(); @@ -135,7 +133,6 @@ describe('BarChart utils', () => { theme: createTheme(), timeZones: [DefaultTimeZone], getTimeRange: getDefaultTimeRange, - eventBus: new EventBusSrv(), allFrames: [frame], }).getConfig() ).toMatchSnapshot(); @@ -150,7 +147,6 @@ describe('BarChart utils', () => { theme: createTheme(), timeZones: [DefaultTimeZone], getTimeRange: getDefaultTimeRange, - eventBus: new EventBusSrv(), allFrames: [frame], }).getConfig() ).toMatchSnapshot(); @@ -227,6 +223,19 @@ describe('BarChart utils', () => { null, ] `); + + const displayLegendValuesAsc = assertIsDefined('legend' in result ? result : null).legend; + const legendField = displayLegendValuesAsc.fields[1]; + + expect(legendField.values).toMatchInlineSnapshot(` + [ + -10, + null, + 10, + null, + null, + ] + `); }); it('should remove unit from legend values when stacking is percent', () => { @@ -242,11 +251,11 @@ describe('BarChart utils', () => { const resultAsc = prepareBarChartDisplayValues([frame], createTheme(), { stacking: StackingMode.Percent, } as Options); - const displayLegendValuesAsc = assertIsDefined('viz' in resultAsc ? resultAsc : null).viz[0]; + const displayLegendValuesAsc = assertIsDefined('legend' in resultAsc ? resultAsc : null).legend; + expect(displayLegendValuesAsc.fields[0].config.unit).toBeUndefined(); expect(displayLegendValuesAsc.fields[1].config.unit).toBeUndefined(); expect(displayLegendValuesAsc.fields[2].config.unit).toBeUndefined(); - expect(displayLegendValuesAsc.fields[3].config.unit).toBeUndefined(); }); }); }); diff --git a/public/app/plugins/panel/barchart/utils.ts b/public/app/plugins/panel/barchart/utils.ts index dad84245c43f3..e170ead77bd0d 100644 --- a/public/app/plugins/panel/barchart/utils.ts +++ b/public/app/plugins/panel/barchart/utils.ts @@ -1,3 +1,4 @@ +import { cloneDeep } from 'lodash'; import uPlot, { Padding } from 'uplot'; import { @@ -16,11 +17,12 @@ import { getFieldDisplayName, } from '@grafana/data'; import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames'; +import { config as runtimeConfig } from '@grafana/runtime'; import { AxisColorMode, AxisPlacement, GraphTransform, - GraphTresholdsStyleMode, + GraphThresholdsStyleMode, ScaleDirection, ScaleDistribution, ScaleOrientation, @@ -32,6 +34,8 @@ import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuil import { getStackingGroups } from '@grafana/ui/src/components/uPlot/utils'; import { findField } from 'app/features/dimensions'; +import { setClassicPaletteIdxs } from '../timeseries/utils'; + import { BarsOptions, getConfig } from './bars'; import { FieldConfig, Options, defaultFieldConfig } from './panelcfg.gen'; import { BarChartDisplayValues, BarChartDisplayWarning } from './types'; @@ -59,6 +63,7 @@ export interface BarChartOptionsEX extends Options { getColor?: (seriesIdx: number, valueIdx: number, value: unknown) => string | null; timeZone?: TimeZone; fillOpacity?: number; + hoverMulti?: boolean; } export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptionsEX> = ({ @@ -81,6 +86,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptionsEX> = ({ legend, timeZone, fullHighlight, + hoverMulti, }) => { const builder = new UPlotConfigBuilder(); @@ -121,6 +127,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptionsEX> = ({ xTimeAuto: frame.fields[0]?.type === FieldType.time && !frame.fields[0].config.unit?.startsWith('time:'), negY: frame.fields.map((f) => f.config.custom?.transform === GraphTransform.NegativeY), fullHighlight, + hoverMulti, }; const config = getConfig(opts, theme); @@ -131,7 +138,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptionsEX> = ({ builder.addHook('drawClear', config.drawClear); builder.addHook('draw', config.draw); - builder.setTooltipInterpolator(config.interpolateTooltip); + const showNewVizTooltips = Boolean(runtimeConfig.featureToggles.newVizTooltips); + !showNewVizTooltips && builder.setTooltipInterpolator(config.interpolateTooltip); if (xTickLabelRotation !== 0) { // these are the amount of space we already have available between plot edge and first label @@ -208,8 +216,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptionsEX> = ({ // Render thresholds in graph if (customConfig.thresholdsStyle && field.config.thresholds) { - const thresholdDisplay = customConfig.thresholdsStyle.mode ?? GraphTresholdsStyleMode.Off; - if (thresholdDisplay !== GraphTresholdsStyleMode.Off) { + const thresholdDisplay = customConfig.thresholdsStyle.mode ?? GraphThresholdsStyleMode.Off; + if (thresholdDisplay !== GraphThresholdsStyleMode.Off) { builder.addThresholds({ config: customConfig.thresholdsStyle, thresholds: field.config.thresholds, @@ -388,7 +396,8 @@ export function prepareBarChartDisplayValues( series[0], series[0].fields.findIndex((f) => f.type === FieldType.time) ) - : outerJoinDataFrames({ frames: series }); + : outerJoinDataFrames({ frames: series, keepDisplayNames: true }); + if (!frame) { return { warn: 'Unable to join data' }; } @@ -477,6 +486,13 @@ export function prepareBarChartDisplayValues( }; } + // if both string and time fields exist, remove unused leftover time field + if (frame.fields[0].type === FieldType.time && frame.fields[0] !== firstField) { + frame.fields.shift(); + } + + setClassicPaletteIdxs([frame], theme, 0); + if (!fields.length) { return { warn: 'No numeric fields found', @@ -491,9 +507,10 @@ export function prepareBarChartDisplayValues( } } - // If stacking is percent, we need to correct the fields unit and display + // If stacking is percent, we need to correct the legend fields unit and display + let legendFields: Field[] = cloneDeep(fields); if (options.stacking === StackingMode.Percent) { - fields.map((field) => { + legendFields.map((field) => { const alignedFrameField = frame.fields.find( (f) => getFieldDisplayName(f, frame) === getFieldDisplayName(f, frame) ); @@ -503,18 +520,23 @@ export function prepareBarChartDisplayValues( }); } - // String field is first + // String field is first, make sure fields / legend fields indexes match fields.unshift(firstField); + legendFields.unshift(firstField); return { aligned: frame, colorByField, viz: [ { - length: firstField.values.length, fields: fields, // ideally: fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.viz)), + length: firstField.values.length, }, ], + legend: { + fields: legendFields, + length: firstField.values.length, + }, }; } diff --git a/public/app/plugins/panel/bargauge/panelcfg.gen.ts b/public/app/plugins/panel/bargauge/panelcfg.gen.ts index b9d330fac9826..eb6f40ef6a5ea 100644 --- a/public/app/plugins/panel/bargauge/panelcfg.gen.ts +++ b/public/app/plugins/panel/bargauge/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx index 10b0746ddc4ac..fc920337f0bb6 100644 --- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx +++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx @@ -1,26 +1,37 @@ // this file is pretty much a copy-paste of TimeSeriesPanel.tsx :( // with some extra renderers passed to the <TimeSeries> component -import React, { useMemo } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; import uPlot from 'uplot'; -import { Field, getDisplayProcessor, getLinksSupplier, PanelProps } from '@grafana/data'; +import { Field, getDisplayProcessor, PanelProps } from '@grafana/data'; import { PanelDataErrorView } from '@grafana/runtime'; -import { TooltipDisplayMode } from '@grafana/schema'; -import { TooltipPlugin, TooltipPlugin2, UPlotConfigBuilder, usePanelContext, useTheme2, ZoomPlugin } from '@grafana/ui'; +import { DashboardCursorSync, TooltipDisplayMode } from '@grafana/schema'; +import { + EventBusPlugin, + KeyboardPlugin, + TooltipPlugin, + TooltipPlugin2, + UPlotConfigBuilder, + usePanelContext, + useTheme2, + ZoomPlugin, +} from '@grafana/ui'; import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder'; import { ScaleProps } from '@grafana/ui/src/components/uPlot/config/UPlotScaleBuilder'; -import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; +import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; import { config } from 'app/core/config'; import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip'; import { AnnotationEditorPlugin } from '../timeseries/plugins/AnnotationEditorPlugin'; import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin'; +import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2'; import { ContextMenuPlugin } from '../timeseries/plugins/ContextMenuPlugin'; import { ExemplarsPlugin } from '../timeseries/plugins/ExemplarsPlugin'; import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin'; import { ThresholdControlsPlugin } from '../timeseries/plugins/ThresholdControlsPlugin'; +import { isTooltipScrollable } from '../timeseries/utils'; import { prepareCandlestickFields } from './fields'; import { Options, defaultCandlestickColors, VizDisplayMode } from './types'; @@ -40,14 +51,38 @@ export const CandlestickPanel = ({ onChangeTimeRange, replaceVariables, }: CandlestickPanelProps) => { - const { sync, canAddAnnotations, onThresholdsChange, canEditThresholds, showThresholds } = usePanelContext(); + const { + sync, + canAddAnnotations, + onThresholdsChange, + canEditThresholds, + showThresholds, + dataLinkPostProcessor, + eventBus, + } = usePanelContext(); const theme = useTheme2(); + // TODO: we should just re-init when this changes, and have this be a static setting + const syncTooltip = useCallback( + () => sync?.() === DashboardCursorSync.Tooltip, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const syncAny = useCallback( + () => sync?.() !== DashboardCursorSync.Off, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + const info = useMemo(() => { return prepareCandlestickFields(data.series, options, theme, timeRange); }, [data.series, options, theme, timeRange]); + // temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2 + const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null); + const { renderers, tweakScale, tweakAxis, shouldRenderPrice } = useMemo(() => { let tweakScale = (opts: ScaleProps, forField: Field) => opts; let tweakAxis = (opts: AxisProps, forField: Field) => opts; @@ -228,7 +263,8 @@ export const CandlestickPanel = ({ } } - const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); + const enableAnnotationCreation = Boolean(canAddAnnotations?.()); + const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips); return ( <TimeSeries @@ -243,44 +279,59 @@ export const CandlestickPanel = ({ tweakAxis={tweakAxis} tweakScale={tweakScale} options={options} + replaceVariables={replaceVariables} + dataLinkPostProcessor={dataLinkPostProcessor} > - {(uplotConfig, alignedDataFrame) => { - alignedDataFrame.fields.forEach((field) => { - field.getLinks = getLinksSupplier( - alignedDataFrame, - field, - field.state!.scopedVars!, - replaceVariables, - timeZone - ); - }); - + {(uplotConfig, alignedFrame) => { return ( <> - {config.featureToggles.newVizTooltips ? ( + <KeyboardPlugin config={uplotConfig} /> + <EventBusPlugin config={uplotConfig} sync={syncAny} eventBus={eventBus} frame={alignedFrame} /> + {showNewVizTooltips ? ( <TooltipPlugin2 config={uplotConfig} - hoverMode={TooltipHoverMode.xAll} + hoverMode={ + options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll + } queryZoom={onChangeTimeRange} clientZoom={true} - render={(u, dataIdxs, seriesIdx, isPinned = false) => { + syncTooltip={syncTooltip} + render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync) => { + if (enableAnnotationCreation && timeRange2 != null) { + setNewAnnotationRange(timeRange2); + dismiss(); + return; + } + + const annotate = () => { + let xVal = u.posToVal(u.cursor.left!, 'x'); + + setNewAnnotationRange({ from: xVal, to: xVal }); + dismiss(); + }; + return ( <TimeSeriesTooltip frames={[info.frame]} - seriesFrame={alignedDataFrame} + seriesFrame={alignedFrame} dataIdxs={dataIdxs} seriesIdx={seriesIdx} - mode={TooltipDisplayMode.Multi} + mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode} + sortOrder={options.tooltip.sort} isPinned={isPinned} + annotate={enableAnnotationCreation ? annotate : undefined} + scrollable={isTooltipScrollable(options.tooltip)} /> ); }} + maxWidth={options.tooltip.maxWidth} + maxHeight={options.tooltip.maxHeight} /> ) : ( <> <ZoomPlugin config={uplotConfig} onZoom={onChangeTimeRange} withZoomY={true} /> <TooltipPlugin - data={alignedDataFrame} + data={alignedFrame} config={uplotConfig} mode={TooltipDisplayMode.Multi} sync={sync} @@ -289,17 +340,27 @@ export const CandlestickPanel = ({ </> )} {/* Renders annotation markers*/} - {data.annotations && ( - <AnnotationsPlugin annotations={data.annotations} config={uplotConfig} timeZone={timeZone} /> + {showNewVizTooltips ? ( + <AnnotationsPlugin2 + annotations={data.annotations ?? []} + config={uplotConfig} + timeZone={timeZone} + newRange={newAnnotationRange} + setNewRange={setNewAnnotationRange} + /> + ) : ( + data.annotations && ( + <AnnotationsPlugin annotations={data.annotations} config={uplotConfig} timeZone={timeZone} /> + ) )} {/* Enables annotations creation*/} - {!config.featureToggles.newVizTooltips ? ( + {!showNewVizTooltips ? ( enableAnnotationCreation ? ( - <AnnotationEditorPlugin data={alignedDataFrame} timeZone={timeZone} config={uplotConfig}> + <AnnotationEditorPlugin data={alignedFrame} timeZone={timeZone} config={uplotConfig}> {({ startAnnotating }) => { return ( <ContextMenuPlugin - data={alignedDataFrame} + data={alignedFrame} config={uplotConfig} timeZone={timeZone} replaceVariables={replaceVariables} @@ -330,7 +391,7 @@ export const CandlestickPanel = ({ </AnnotationEditorPlugin> ) : ( <ContextMenuPlugin - data={alignedDataFrame} + data={alignedFrame} config={uplotConfig} timeZone={timeZone} replaceVariables={replaceVariables} diff --git a/public/app/plugins/panel/candlestick/module.tsx b/public/app/plugins/panel/candlestick/module.tsx index d4ae7b534a93c..2d6c62ab876a9 100644 --- a/public/app/plugins/panel/candlestick/module.tsx +++ b/public/app/plugins/panel/candlestick/module.tsx @@ -137,7 +137,10 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(CandlestickPane }, }); - // commonOptionsBuilder.addTooltipOptions(builder); + if (config.featureToggles.newVizTooltips) { + commonOptionsBuilder.addTooltipOptions(builder, false, true, opts); + } + commonOptionsBuilder.addLegendOptions(builder); }) .setDataSupport({ annotations: true, alertStates: true }) diff --git a/public/app/plugins/panel/candlestick/panelcfg.cue b/public/app/plugins/panel/candlestick/panelcfg.cue index d4d7c769ed647..52c6b8c8a14fc 100644 --- a/public/app/plugins/panel/candlestick/panelcfg.cue +++ b/public/app/plugins/panel/candlestick/panelcfg.cue @@ -48,6 +48,7 @@ composableKinds: PanelCfg: { } @cuetsy(kind="interface") Options: { common.OptionsWithLegend + common.OptionsWithTooltip // Sets which dimensions are used for the visualization mode: VizDisplayMode & (*"candles+volume" | _) diff --git a/public/app/plugins/panel/candlestick/panelcfg.gen.ts b/public/app/plugins/panel/candlestick/panelcfg.gen.ts index 8c704e366febe..d527f54d7eb49 100644 --- a/public/app/plugins/panel/candlestick/panelcfg.gen.ts +++ b/public/app/plugins/panel/candlestick/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -61,7 +61,7 @@ export const defaultCandlestickColors: Partial<CandlestickColors> = { up: 'green', }; -export interface Options extends common.OptionsWithLegend { +export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip { /** * Sets the style of the candlesticks */ diff --git a/public/app/plugins/panel/candlestick/plugin.json b/public/app/plugins/panel/candlestick/plugin.json index c7ab55110198f..f260e1dc951af 100644 --- a/public/app/plugins/panel/candlestick/plugin.json +++ b/public/app/plugins/panel/candlestick/plugin.json @@ -4,6 +4,8 @@ "id": "candlestick", "info": { + "description": "Graphical representation of price movements of a security, derivative, or currency.", + "keywords": ["financial", "price", "currency", "k-line"], "author": { "name": "Grafana Labs", "url": "https://grafana.com" diff --git a/public/app/plugins/panel/candlestick/types.ts b/public/app/plugins/panel/candlestick/types.ts index 81253afa1a52b..adf699253c8ff 100644 --- a/public/app/plugins/panel/candlestick/types.ts +++ b/public/app/plugins/panel/candlestick/types.ts @@ -1,4 +1,4 @@ -import { LegendDisplayMode } from '@grafana/schema'; +import { LegendDisplayMode, SortOrder, TooltipDisplayMode } from '@grafana/schema'; import { defaultOptions as defaultOptionsBase, @@ -21,6 +21,10 @@ export const defaultOptions: Partial<Options> = { placement: 'bottom', calcs: [], }, + tooltip: { + mode: TooltipDisplayMode.Multi, + sort: SortOrder.None, + }, }; export { diff --git a/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx b/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx index e231d8bc8723a..e97619f8e0ab1 100644 --- a/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx +++ b/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx @@ -71,6 +71,7 @@ export const CanvasContextMenu = ({ scene, panel, onVisibilityChange }: Props) = }; const renderMenuItems = () => { + // This is disabled when panel is in edit mode because opening inline editor over panel editor is not ideal UX const openCloseEditorMenuItem = !scene.isPanelEditing && ( <MenuItem label={inlineEditorOpen ? 'Close Editor' : 'Open Editor'} @@ -139,7 +140,7 @@ export const CanvasContextMenu = ({ scene, panel, onVisibilityChange }: Props) = return submenuItems; }; - const addItemMenuItem = !scene.isPanelEditing && ( + const addItemMenuItem = ( <MenuItem label="Add item" className={styles.menuItem} @@ -148,7 +149,7 @@ export const CanvasContextMenu = ({ scene, panel, onVisibilityChange }: Props) = /> ); - const setBackgroundMenuItem = !scene.isPanelEditing && ( + const setBackgroundMenuItem = ( <MenuItem label={'Set background'} onClick={() => { diff --git a/public/app/plugins/panel/canvas/components/connections/ConnectionAnchors.tsx b/public/app/plugins/panel/canvas/components/connections/ConnectionAnchors.tsx index cbd13b41e24ef..879a1a44cff84 100644 --- a/public/app/plugins/panel/canvas/components/connections/ConnectionAnchors.tsx +++ b/public/app/plugins/panel/canvas/components/connections/ConnectionAnchors.tsx @@ -15,6 +15,8 @@ type Props = { export const CONNECTION_ANCHOR_DIV_ID = 'connectionControl'; export const CONNECTION_ANCHOR_ALT = 'connection anchor'; export const CONNECTION_ANCHOR_HIGHLIGHT_OFFSET = 8; +export const CONNECTION_VERTEX_ID = 'vertex'; +export const CONNECTION_VERTEX_ADD_ID = 'vertexAdd'; const ANCHOR_PADDING = 3; diff --git a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx index 4506f7b13e2a8..f0d7c960a42f3 100644 --- a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx +++ b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx @@ -6,19 +6,38 @@ import { useStyles2 } from '@grafana/ui'; import { config } from 'app/core/config'; import { Scene } from 'app/features/canvas/runtime/scene'; +import { ConnectionCoordinates } from '../../panelcfg.gen'; import { ConnectionState } from '../../types'; -import { calculateCoordinates, getConnectionStyles, getParentBoundingClientRect } from '../../utils'; +import { + calculateAbsoluteCoords, + calculateCoordinates, + calculateMidpoint, + getConnectionStyles, + getParentBoundingClientRect, +} from '../../utils'; + +import { CONNECTION_VERTEX_ADD_ID, CONNECTION_VERTEX_ID } from './ConnectionAnchors'; type Props = { setSVGRef: (anchorElement: SVGSVGElement) => void; setLineRef: (anchorElement: SVGLineElement) => void; + setSVGVertexRef: (anchorElement: SVGSVGElement) => void; + setVertexPathRef: (anchorElement: SVGPathElement) => void; + setVertexRef: (anchorElement: SVGCircleElement) => void; scene: Scene; }; let idCounter = 0; const htmlElementTypes = ['input', 'textarea']; -export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { +export const ConnectionSVG = ({ + setSVGRef, + setLineRef, + setSVGVertexRef, + setVertexPathRef, + setVertexRef, + scene, +}: Props) => { const styles = useStyles2(getStyles); const headId = Date.now() + '_' + idCounter++; @@ -26,6 +45,7 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { const EDITOR_HEAD_ID = useMemo(() => `editorHead-${headId}`, [headId]); const defaultArrowColor = config.theme2.colors.text.primary; const defaultArrowSize = 2; + const maximumVertices = 10; const [selectedConnection, setSelectedConnection] = useState<ConnectionState | undefined>(undefined); @@ -101,7 +121,7 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { // Figure out target and then target's relative coordinates drawing (if no target do parent) const renderConnections = () => { return scene.connections.state.map((v, idx) => { - const { source, target, info } = v; + const { source, target, info, vertices } = v; const sourceRect = source.div?.getBoundingClientRect(); const parent = source.div?.parentElement; const transformScale = scene.scale; @@ -112,6 +132,7 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { } const { x1, y1, x2, y2 } = calculateCoordinates(sourceRect, parentRect, info, target, transformScale); + const midpoint = calculateMidpoint(x1, y1, x2, y2); const { strokeColor, strokeWidth } = getConnectionStyles(info, scene, defaultArrowSize); @@ -122,6 +143,30 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { const CONNECTION_HEAD_ID = `connectionHead-${headId + Math.random()}`; + // Create vertex path and populate array of add vertex controls + const addVertices: ConnectionCoordinates[] = []; + let pathString = `M${x1} ${y1} `; + if (vertices?.length) { + vertices.map((vertex, index) => { + const x = vertex.x; + const y = vertex.y; + pathString += `L${x * (x2 - x1) + x1} ${y * (y2 - y1) + y1} `; + if (index === 0) { + // For first vertex + addVertices.push(calculateMidpoint(0, 0, x, y)); + } else { + // For all other vertices + const previousVertex = vertices[index - 1]; + addVertices.push(calculateMidpoint(previousVertex.x, previousVertex.y, x, y)); + } + if (index === vertices.length - 1) { + // For last vertex + addVertices.push(calculateMidpoint(1, 1, x, y)); + } + }); + pathString += `L${x2} ${y2}`; + } + return ( <svg className={styles.connection} key={idx}> <g onClick={() => selectConnection(v)}> @@ -138,30 +183,106 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { <polygon points="0 0, 10 3.5, 0 7" fill={strokeColor} /> </marker> </defs> - <line - id={`${CONNECTION_LINE_ID}_transparent`} - cursor={connectionCursorStyle} - pointerEvents="auto" - stroke="transparent" - strokeWidth={15} - style={isSelected ? selectedStyles : {}} - x1={x1} - y1={y1} - x2={x2} - y2={y2} - /> - <line - id={CONNECTION_LINE_ID} - stroke={strokeColor} - pointerEvents="auto" - strokeWidth={strokeWidth} - markerEnd={`url(#${CONNECTION_HEAD_ID})`} - x1={x1} - y1={y1} - x2={x2} - y2={y2} - cursor={connectionCursorStyle} - /> + {vertices?.length ? ( + <g> + <path + id={`${CONNECTION_LINE_ID}_transparent`} + d={pathString} + cursor={connectionCursorStyle} + pointerEvents="auto" + stroke="transparent" + strokeWidth={15} + fill={'none'} + style={isSelected ? selectedStyles : {}} + /> + <path + d={pathString} + stroke={strokeColor} + strokeWidth={strokeWidth} + fill={'none'} + markerEnd={`url(#${CONNECTION_HEAD_ID})`} + /> + {isSelected && ( + <g> + {vertices.map((value, index) => { + const { x, y } = calculateAbsoluteCoords(x1, y1, x2, y2, value.x, value.y); + return ( + <circle + id={CONNECTION_VERTEX_ID} + data-index={index} + key={`${CONNECTION_VERTEX_ID}${index}_${idx}`} + cx={x} + cy={y} + r={5} + stroke={strokeColor} + className={styles.vertex} + cursor={'crosshair'} + pointerEvents="auto" + /> + ); + })} + {vertices.length < maximumVertices && + addVertices.map((value, index) => { + const { x, y } = calculateAbsoluteCoords(x1, y1, x2, y2, value.x, value.y); + return ( + <circle + id={CONNECTION_VERTEX_ADD_ID} + data-index={index} + key={`${CONNECTION_VERTEX_ADD_ID}${index}_${idx}`} + cx={x} + cy={y} + r={4} + stroke={strokeColor} + className={styles.addVertex} + cursor={'crosshair'} + pointerEvents="auto" + /> + ); + })} + </g> + )} + </g> + ) : ( + <g> + <line + id={`${CONNECTION_LINE_ID}_transparent`} + cursor={connectionCursorStyle} + pointerEvents="auto" + stroke="transparent" + strokeWidth={15} + style={isSelected ? selectedStyles : {}} + x1={x1} + y1={y1} + x2={x2} + y2={y2} + /> + <line + id={CONNECTION_LINE_ID} + stroke={strokeColor} + pointerEvents="auto" + strokeWidth={strokeWidth} + markerEnd={`url(#${CONNECTION_HEAD_ID})`} + x1={x1} + y1={y1} + x2={x2} + y2={y2} + cursor={connectionCursorStyle} + /> + {isSelected && ( + <circle + id={CONNECTION_VERTEX_ADD_ID} + data-index={0} + cx={midpoint.x} + cy={midpoint.y} + r={4} + stroke={strokeColor} + className={styles.addVertex} + cursor={'crosshair'} + pointerEvents="auto" + /> + )} + </g> + )} </g> </svg> ); @@ -186,6 +307,16 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { </defs> <line ref={setLineRef} stroke={defaultArrowColor} strokeWidth={2} markerEnd={`url(#${EDITOR_HEAD_ID})`} /> </svg> + <svg ref={setSVGVertexRef} className={styles.editorSVG}> + <path + ref={setVertexPathRef} + stroke={defaultArrowColor} + strokeWidth={2} + strokeDasharray={'5, 5'} + fill={'none'} + /> + <circle ref={setVertexRef} stroke={defaultArrowColor} r={4} className={styles.vertex} /> + </svg> {renderConnections()} </> ); @@ -207,4 +338,13 @@ const getStyles = (theme: GrafanaTheme2) => ({ zIndex: 1000, pointerEvents: 'none', }), + vertex: css({ + fill: '#44aaff', + strokeWidth: 2, + }), + addVertex: css({ + fill: '#44aaff', + opacity: 0.5, + strokeWidth: 1, + }), }); diff --git a/public/app/plugins/panel/canvas/components/connections/Connections.tsx b/public/app/plugins/panel/canvas/components/connections/Connections.tsx index 0a62a0073803b..25eb19644f1d0 100644 --- a/public/app/plugins/panel/canvas/components/connections/Connections.tsx +++ b/public/app/plugins/panel/canvas/components/connections/Connections.tsx @@ -2,12 +2,18 @@ import React from 'react'; import { BehaviorSubject } from 'rxjs'; import { config } from '@grafana/runtime'; -import { CanvasConnection, ConnectionPath } from 'app/features/canvas'; +import { CanvasConnection, ConnectionCoordinates, ConnectionPath } from 'app/features/canvas'; import { ElementState } from 'app/features/canvas/runtime/element'; import { Scene } from 'app/features/canvas/runtime/scene'; import { ConnectionState } from '../../types'; -import { getConnections, getParentBoundingClientRect, isConnectionSource, isConnectionTarget } from '../../utils'; +import { + calculateCoordinates, + getConnections, + getParentBoundingClientRect, + isConnectionSource, + isConnectionTarget, +} from '../../utils'; import { CONNECTION_ANCHOR_ALT, ConnectionAnchors, CONNECTION_ANCHOR_HIGHLIGHT_OFFSET } from './ConnectionAnchors'; import { ConnectionSVG } from './ConnectionSVG'; @@ -17,9 +23,13 @@ export class Connections { connectionAnchorDiv?: HTMLDivElement; connectionSVG?: SVGElement; connectionLine?: SVGLineElement; + connectionSVGVertex?: SVGElement; + connectionVertexPath?: SVGPathElement; + connectionVertex?: SVGCircleElement; connectionSource?: ElementState; connectionTarget?: ElementState; isDrawingConnection?: boolean; + selectedVertexIndex?: number; didConnectionLeaveHighlight?: boolean; state: ConnectionState[] = []; readonly selection = new BehaviorSubject<ConnectionState | undefined>(undefined); @@ -62,6 +72,18 @@ export class Connections { this.connectionLine = connectionLine; }; + setConnectionSVGVertexRef = (connectionSVG: SVGSVGElement) => { + this.connectionSVGVertex = connectionSVG; + }; + + setConnectionVertexRef = (connectionVertex: SVGCircleElement) => { + this.connectionVertex = connectionVertex; + }; + + setConnectionVertexPathRef = (connectionVertexPath: SVGPathElement) => { + this.connectionVertexPath = connectionVertexPath; + }; + // Recursively find the first parent that is a canvas element findElementTarget = (element: Element): ElementState | undefined => { let elementTarget = undefined; @@ -251,6 +273,192 @@ export class Connections { } }; + // Handles mousemove and mouseup events when dragging an existing vertex + vertexListener = (event: MouseEvent) => { + event.preventDefault(); + + if (!(this.connectionVertex && this.scene.div && this.scene.div.parentElement)) { + return; + } + + const transformScale = this.scene.scale; + const parentBoundingRect = getParentBoundingClientRect(this.scene); + + if (!parentBoundingRect) { + return; + } + + const x = (event.pageX - parentBoundingRect.x) / transformScale ?? 0; + const y = (event.pageY - parentBoundingRect.y) / transformScale ?? 0; + + this.connectionVertex?.setAttribute('cx', `${x}`); + this.connectionVertex?.setAttribute('cy', `${y}`); + + const sourceRect = this.selection.value!.source.div!.getBoundingClientRect(); + + // calculate relative coordinates based on source and target coorindates of connection + const { x1, y1, x2, y2 } = calculateCoordinates( + sourceRect, + parentBoundingRect, + this.selection.value?.info!, + this.selection.value!.target, + transformScale + ); + + let vx1 = x1; + let vy1 = y1; + let vx2 = x2; + let vy2 = y2; + if (this.selection.value && this.selection.value.vertices) { + if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex > 0) { + vx1 += this.selection.value.vertices[this.selectedVertexIndex - 1].x * (x2 - x1); + vy1 += this.selection.value.vertices[this.selectedVertexIndex - 1].y * (y2 - y1); + } + if ( + this.selectedVertexIndex !== undefined && + this.selectedVertexIndex < this.selection.value.vertices.length - 1 + ) { + vx2 = this.selection.value.vertices[this.selectedVertexIndex + 1].x * (x2 - x1) + x1; + vy2 = this.selection.value.vertices[this.selectedVertexIndex + 1].y * (y2 - y1) + y1; + } + } + + // Display temporary vertex during drag + this.connectionVertexPath?.setAttribute('d', `M${vx1} ${vy1} L${x} ${y} L${vx2} ${vy2}`); + this.connectionSVGVertex!.style.display = 'block'; + + // Handle mouseup + if (!event.buttons) { + // Remove existing event listener + this.scene.selecto?.rootContainer?.removeEventListener('mousemove', this.vertexListener); + this.scene.selecto?.rootContainer?.removeEventListener('mouseup', this.vertexListener); + this.scene.selecto!.rootContainer!.style.cursor = 'auto'; + this.connectionSVGVertex!.style.display = 'none'; + + // call onChange here and update appropriate index of connection vertices array + const connectionIndex = this.selection.value?.index; + const vertexIndex = this.selectedVertexIndex; + + if (connectionIndex !== undefined && vertexIndex !== undefined) { + const currentSource = this.scene.connections.state[connectionIndex].source; + if (currentSource.options.connections) { + const currentConnections = [...currentSource.options.connections]; + if (currentConnections[connectionIndex].vertices) { + const currentVertices = [...currentConnections[connectionIndex].vertices!]; + const currentVertex = { ...currentVertices[vertexIndex] }; + + currentVertex.x = (x - x1) / (x2 - x1); + currentVertex.y = (y - y1) / (y2 - y1); + + currentVertices[vertexIndex] = currentVertex; + currentConnections[connectionIndex] = { + ...currentConnections[connectionIndex], + vertices: currentVertices, + }; + + // Update save model + currentSource.onChange({ ...currentSource.options, connections: currentConnections }); + this.updateState(); + this.scene.save(); + } + } + } + } + }; + + // Handles mousemove and mouseup events when dragging a new vertex + vertexAddListener = (event: MouseEvent) => { + event.preventDefault(); + + if (!(this.connectionVertex && this.scene.div && this.scene.div.parentElement)) { + return; + } + + const transformScale = this.scene.scale; + const parentBoundingRect = getParentBoundingClientRect(this.scene); + + if (!parentBoundingRect) { + return; + } + + const x = (event.pageX - parentBoundingRect.x) / transformScale ?? 0; + const y = (event.pageY - parentBoundingRect.y) / transformScale ?? 0; + + this.connectionVertex?.setAttribute('cx', `${x}`); + this.connectionVertex?.setAttribute('cy', `${y}`); + + const sourceRect = this.selection.value!.source.div!.getBoundingClientRect(); + + // calculate relative coordinates based on source and target coorindates of connection + const { x1, y1, x2, y2 } = calculateCoordinates( + sourceRect, + parentBoundingRect, + this.selection.value?.info!, + this.selection.value!.target, + transformScale + ); + + let vx1 = x1; + let vy1 = y1; + let vx2 = x2; + let vy2 = y2; + if (this.selection.value && this.selection.value.vertices) { + if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex > 0) { + vx1 += this.selection.value.vertices[this.selectedVertexIndex - 1].x * (x2 - x1); + vy1 += this.selection.value.vertices[this.selectedVertexIndex - 1].y * (y2 - y1); + } + if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex < this.selection.value.vertices.length) { + vx2 = this.selection.value.vertices[this.selectedVertexIndex].x * (x2 - x1) + x1; + vy2 = this.selection.value.vertices[this.selectedVertexIndex].y * (y2 - y1) + y1; + } + } + + // Display temporary vertex during drag + this.connectionVertexPath?.setAttribute('d', `M${vx1} ${vy1} L${x} ${y} L${vx2} ${vy2}`); + this.connectionSVGVertex!.style.display = 'block'; + + // Handle mouseup + if (!event.buttons) { + // Remove existing event listener + this.scene.selecto?.rootContainer?.removeEventListener('mousemove', this.vertexAddListener); + this.scene.selecto?.rootContainer?.removeEventListener('mouseup', this.vertexAddListener); + this.scene.selecto!.rootContainer!.style.cursor = 'auto'; + this.connectionSVGVertex!.style.display = 'none'; + + // call onChange here and insert new vertex at appropriate index of connection vertices array + const connectionIndex = this.selection.value?.index; + const vertexIndex = this.selectedVertexIndex; + + if (connectionIndex !== undefined && vertexIndex !== undefined) { + const currentSource = this.scene.connections.state[connectionIndex].source; + if (currentSource.options.connections) { + const currentConnections = [...currentSource.options.connections]; + const newVertex = { x: (x - x1) / (x2 - x1), y: (y - y1) / (y2 - y1) }; + if (currentConnections[connectionIndex].vertices) { + const currentVertices = [...currentConnections[connectionIndex].vertices!]; + currentVertices.splice(vertexIndex, 0, newVertex); + currentConnections[connectionIndex] = { + ...currentConnections[connectionIndex], + vertices: currentVertices, + }; + } else { + // For first vertex creation + const currentVertices: ConnectionCoordinates[] = [newVertex]; + currentConnections[connectionIndex] = { + ...currentConnections[connectionIndex], + vertices: currentVertices, + }; + } + + // Update save model + currentSource.onChange({ ...currentSource.options, connections: currentConnections }); + this.updateState(); + this.scene.save(); + } + } + } + }; + handleConnectionDragStart = (selectedTarget: HTMLElement, clientX: number, clientY: number) => { this.scene.selecto!.rootContainer!.style.cursor = 'crosshair'; if (this.connectionSVG && this.connectionLine && this.scene.div && this.scene.div.parentElement) { @@ -283,6 +491,26 @@ export class Connections { this.scene.selecto?.rootContainer?.addEventListener('mousemove', this.connectionListener); }; + // Add event listener at root container during existing vertex drag + handleVertexDragStart = (selectedTarget: HTMLElement) => { + // Get vertex index from selected target data + this.selectedVertexIndex = Number(selectedTarget.getAttribute('data-index')); + this.scene.selecto!.rootContainer!.style.cursor = 'crosshair'; + + this.scene.selecto?.rootContainer?.addEventListener('mousemove', this.vertexListener); + this.scene.selecto?.rootContainer?.addEventListener('mouseup', this.vertexListener); + }; + + // Add event listener at root container during creation of new vertex + handleVertexAddDragStart = (selectedTarget: HTMLElement) => { + // Get vertex index from selected target data + this.selectedVertexIndex = Number(selectedTarget.getAttribute('data-index')); + this.scene.selecto!.rootContainer!.style.cursor = 'crosshair'; + + this.scene.selecto?.rootContainer?.addEventListener('mousemove', this.vertexAddListener); + this.scene.selecto?.rootContainer?.addEventListener('mouseup', this.vertexAddListener); + }; + onChange = (current: ConnectionState, update: CanvasConnection) => { const connections = current.source.options.connections?.splice(0) ?? []; connections[current.index] = update; @@ -299,7 +527,14 @@ export class Connections { return ( <> <ConnectionAnchors setRef={this.setConnectionAnchorRef} handleMouseLeave={this.handleMouseLeave} /> - <ConnectionSVG setSVGRef={this.setConnectionSVGRef} setLineRef={this.setConnectionLineRef} scene={this.scene} /> + <ConnectionSVG + setSVGRef={this.setConnectionSVGRef} + setLineRef={this.setConnectionLineRef} + setSVGVertexRef={this.setConnectionSVGVertexRef} + setVertexPathRef={this.setConnectionVertexPathRef} + setVertexRef={this.setConnectionVertexRef} + scene={this.scene} + /> </> ); } diff --git a/public/app/plugins/panel/canvas/editor/inline/InlineEditBody.tsx b/public/app/plugins/panel/canvas/editor/inline/InlineEditBody.tsx index d2b54f88aff3a..a5cfdb9b1aed4 100644 --- a/public/app/plugins/panel/canvas/editor/inline/InlineEditBody.tsx +++ b/public/app/plugins/panel/canvas/editor/inline/InlineEditBody.tsx @@ -18,6 +18,7 @@ import { activePanelSubject, InstanceState } from '../../CanvasPanel'; import { addStandardCanvasEditorOptions } from '../../module'; import { InlineEditTabs } from '../../types'; import { getElementTypes, onAddItem } from '../../utils'; +import { getConnectionEditor } from '../connectionEditor'; import { getElementEditor } from '../element/elementEditor'; import { getLayerEditor } from '../layer/layerEditor'; @@ -42,6 +43,17 @@ export function InlineEditBody() { builder.addNestedOptions(getLayerEditor(instanceState)); } + const selectedConnection = state.selectedConnection; + if (selectedConnection && activeTab === InlineEditTabs.SelectedElement) { + builder.addNestedOptions( + getConnectionEditor({ + category: [`Selected connection`], + connection: selectedConnection, + scene: state.scene, + }) + ); + } + const selection = state.selected; if (selection?.length === 1 && activeTab === InlineEditTabs.SelectedElement) { const element = selection[0]; @@ -82,7 +94,10 @@ export function InlineEditBody() { const rootLayer: FrameState | undefined = instanceState?.layer; const noElementSelected = - instanceState && activeTab === InlineEditTabs.SelectedElement && instanceState.selected.length === 0; + instanceState && + activeTab === InlineEditTabs.SelectedElement && + instanceState.selected.length === 0 && + instanceState.selectedConnection === undefined; return ( <> diff --git a/public/app/plugins/panel/canvas/panelcfg.cue b/public/app/plugins/panel/canvas/panelcfg.cue index c68db38ff8136..99f6b86498730 100644 --- a/public/app/plugins/panel/canvas/panelcfg.cue +++ b/public/app/plugins/panel/canvas/panelcfg.cue @@ -70,6 +70,7 @@ composableKinds: PanelCfg: { path: ConnectionPath color?: ui.ColorDimensionConfig size?: ui.ScaleDimensionConfig + vertices?: [...ConnectionCoordinates] } @cuetsy(kind="interface") CanvasElementOptions: { name: string diff --git a/public/app/plugins/panel/canvas/panelcfg.gen.ts b/public/app/plugins/panel/canvas/panelcfg.gen.ts index 880ef5d22db89..fdd369e1a7ad9 100644 --- a/public/app/plugins/panel/canvas/panelcfg.gen.ts +++ b/public/app/plugins/panel/canvas/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -81,8 +81,13 @@ export interface CanvasConnection { source: ConnectionCoordinates; target: ConnectionCoordinates; targetName?: string; + vertices?: Array<ConnectionCoordinates>; } +export const defaultCanvasConnection: Partial<CanvasConnection> = { + vertices: [], +}; + export interface CanvasElementOptions { background?: BackgroundConfig; border?: LineConfig; diff --git a/public/app/plugins/panel/canvas/types.ts b/public/app/plugins/panel/canvas/types.ts index aea23f571afea..19df754159c90 100644 --- a/public/app/plugins/panel/canvas/types.ts +++ b/public/app/plugins/panel/canvas/types.ts @@ -1,6 +1,8 @@ import { CanvasConnection } from '../../../features/canvas'; import { ElementState } from '../../../features/canvas/runtime/element'; +import { ConnectionCoordinates } from './panelcfg.gen'; + export enum LayerActionID { Delete = 'delete', Duplicate = 'duplicate', @@ -39,4 +41,5 @@ export interface ConnectionState { source: ElementState; target: ElementState; info: CanvasConnection; + vertices?: ConnectionCoordinates[]; } diff --git a/public/app/plugins/panel/canvas/utils.ts b/public/app/plugins/panel/canvas/utils.ts index f55bfa6d1ed93..71cf411c3e4dd 100644 --- a/public/app/plugins/panel/canvas/utils.ts +++ b/public/app/plugins/panel/canvas/utils.ts @@ -9,7 +9,6 @@ import { CanvasElementItem, canvasElementRegistry, CanvasElementOptions, - TextConfig, CanvasConnection, } from 'app/features/canvas'; import { notFoundItem } from 'app/features/canvas/elements/notFound'; @@ -107,28 +106,155 @@ export function onAddItem(sel: SelectableValue<string>, rootLayer: FrameState | } } -export function getDataLinks(ctx: DimensionContext, cfg: TextConfig, textData: string | undefined): LinkModel[] { - const panelData = ctx.getPanelData(); +/* + * Provided a given field add any matching data links + * Mutates the links object in place which is then returned by the `getDataLinks` function downstream + */ +const addDataLinkForField = ( + field: Field<unknown>, + data: string | undefined, + linkLookup: Set<string>, + links: Array<LinkModel<Field>> +): void => { + if (field?.getLinks) { + const disp = field.display ? field.display(data) : { text: `${data}`, numeric: +data! }; + field.getLinks({ calculatedValue: disp }).forEach((link) => { + const key = `${link.title}/${link.href}`; + if (!linkLookup.has(key)) { + links.push(link); + linkLookup.add(key); + } + }); + } +}; + +// TODO: This could be refactored a fair amount, ideally the element specific config code should be owned by each element and not in this shared util file +export function getDataLinks( + dimensionContext: DimensionContext, + elementOptions: CanvasElementOptions, + data: string | undefined +): LinkModel[] { + const panelData = dimensionContext.getPanelData(); const frames = panelData?.series; const links: Array<LinkModel<Field>> = []; const linkLookup = new Set<string>(); + const elementConfig = elementOptions.config; + frames?.forEach((frame) => { const visibleFields = frame.fields.filter((field) => !Boolean(field.config.custom?.hideFrom?.tooltip)); - if (cfg.text?.field && visibleFields.some((f) => getFieldDisplayName(f, frame) === cfg.text?.field)) { - const field = visibleFields.filter((field) => getFieldDisplayName(field, frame) === cfg.text?.field)[0]; - if (field?.getLinks) { - const disp = field.display ? field.display(textData) : { text: `${textData}`, numeric: +textData! }; - field.getLinks({ calculatedValue: disp }).forEach((link) => { - const key = `${link.title}/${link.href}`; - if (!linkLookup.has(key)) { - links.push(link); - linkLookup.add(key); - } - }); - } + // Text config + const isTextTiedToFieldData = + elementConfig.text?.field && + visibleFields.some((field) => getFieldDisplayName(field, frame) === elementConfig.text?.field); + const isTextColorTiedToFieldData = + elementConfig.color?.field && + visibleFields.some((field) => getFieldDisplayName(field, frame) === elementConfig.color?.field); + + // General element config + const isElementBackgroundColorTiedToFieldData = + elementOptions?.background?.color?.field && + visibleFields.some((field) => getFieldDisplayName(field, frame) === elementOptions?.background?.color?.field); + const isElementBackgroundImageTiedToFieldData = + elementOptions?.background?.image?.field && + visibleFields.some((field) => getFieldDisplayName(field, frame) === elementOptions?.background?.image?.field); + const isElementBorderColorTiedToFieldData = + elementOptions?.border?.color?.field && + visibleFields.some((field) => getFieldDisplayName(field, frame) === elementOptions?.border?.color?.field); + + // Icon config + const isIconSVGTiedToFieldData = + elementConfig.path?.field && + visibleFields.some((field) => getFieldDisplayName(field, frame) === elementConfig.path?.field); + const isIconColorTiedToFieldData = + elementConfig.fill?.field && + visibleFields.some((field) => getFieldDisplayName(field, frame) === elementConfig.fill?.field); + + // Wind turbine config (maybe remove / not support this?) + const isWindTurbineRPMTiedToFieldData = + elementConfig.rpm?.field && + visibleFields.some((field) => getFieldDisplayName(field, frame) === elementConfig.rpm?.field); + + // Server config + const isServerBlinkRateTiedToFieldData = + elementConfig.blinkRate?.field && + visibleFields.some((field) => getFieldDisplayName(field, frame) === elementConfig.blinkRate?.field); + const isServerStatusColorTiedToFieldData = + elementConfig.statusColor?.field && + visibleFields.some((field) => getFieldDisplayName(field, frame) === elementConfig.statusColor?.field); + const isServerBulbColorTiedToFieldData = + elementConfig.bulbColor?.field && + visibleFields.some((field) => getFieldDisplayName(field, frame) === elementConfig.bulbColor?.field); + + if (isTextTiedToFieldData) { + const field = visibleFields.filter((field) => getFieldDisplayName(field, frame) === elementConfig.text?.field)[0]; + addDataLinkForField(field, data, linkLookup, links); + } + + if (isTextColorTiedToFieldData) { + const field = visibleFields.filter( + (field) => getFieldDisplayName(field, frame) === elementConfig.color?.field + )[0]; + addDataLinkForField(field, data, linkLookup, links); + } + + if (isElementBackgroundColorTiedToFieldData) { + const field = visibleFields.filter( + (field) => getFieldDisplayName(field, frame) === elementOptions?.background?.color?.field + )[0]; + addDataLinkForField(field, data, linkLookup, links); + } + + if (isElementBackgroundImageTiedToFieldData) { + const field = visibleFields.filter( + (field) => getFieldDisplayName(field, frame) === elementOptions?.background?.image?.field + )[0]; + addDataLinkForField(field, data, linkLookup, links); + } + + if (isElementBorderColorTiedToFieldData) { + const field = visibleFields.filter( + (field) => getFieldDisplayName(field, frame) === elementOptions?.border?.color?.field + )[0]; + addDataLinkForField(field, data, linkLookup, links); + } + + if (isIconSVGTiedToFieldData) { + const field = visibleFields.filter((field) => getFieldDisplayName(field, frame) === elementConfig.path?.field)[0]; + addDataLinkForField(field, data, linkLookup, links); + } + + if (isIconColorTiedToFieldData) { + const field = visibleFields.filter((field) => getFieldDisplayName(field, frame) === elementConfig.fill?.field)[0]; + addDataLinkForField(field, data, linkLookup, links); + } + + if (isWindTurbineRPMTiedToFieldData) { + const field = visibleFields.filter((field) => getFieldDisplayName(field, frame) === elementConfig.rpm?.field)[0]; + addDataLinkForField(field, data, linkLookup, links); + } + + if (isServerBlinkRateTiedToFieldData) { + const field = visibleFields.filter( + (field) => getFieldDisplayName(field, frame) === elementConfig.blinkRate?.field + )[0]; + addDataLinkForField(field, data, linkLookup, links); + } + + if (isServerStatusColorTiedToFieldData) { + const field = visibleFields.filter( + (field) => getFieldDisplayName(field, frame) === elementConfig.statusColor?.field + )[0]; + addDataLinkForField(field, data, linkLookup, links); + } + + if (isServerBulbColorTiedToFieldData) { + const field = visibleFields.filter( + (field) => getFieldDisplayName(field, frame) === elementConfig.bulbColor?.field + )[0]; + addDataLinkForField(field, data, linkLookup, links); } }); @@ -164,6 +290,7 @@ export function getConnections(sceneByName: Map<string, ElementState>) { source: v, target, info: c, + vertices: c.vertices ?? undefined, }); } }); @@ -221,6 +348,21 @@ export const calculateCoordinates = ( return { x1, y1, x2, y2 }; }; +export const calculateMidpoint = (x1: number, y1: number, x2: number, y2: number) => { + return { x: (x1 + x2) / 2, y: (y1 + y2) / 2 }; +}; + +export const calculateAbsoluteCoords = ( + x1: number, + y1: number, + x2: number, + y2: number, + valueX: number, + valueY: number +) => { + return { x: valueX * (x2 - x1) + x1, y: valueY * (y2 - y1) + y1 }; +}; + // @TODO revisit, currently returning last row index for field export const getRowIndex = (fieldName: string | undefined, scene: Scene) => { if (fieldName) { diff --git a/public/app/plugins/panel/dashlist/migrations.test.ts b/public/app/plugins/panel/dashlist/migrations.test.ts index 62f1f51172a4d..607c5b9c7a6d1 100644 --- a/public/app/plugins/panel/dashlist/migrations.test.ts +++ b/public/app/plugins/panel/dashlist/migrations.test.ts @@ -19,7 +19,7 @@ describe('dashlist migrations', () => { const basePanelModel = wellFormedPanelModel({}); basePanelModel.pluginVersion = '5.1'; - const angularPanel: PanelModel<any> & AngularModel = { + const angularPanel: PanelModel & AngularModel = { ...basePanelModel, // pluginVersion: '5.1', starred: true, diff --git a/public/app/plugins/panel/dashlist/module.tsx b/public/app/plugins/panel/dashlist/module.tsx index 6cbc76f964ded..c9fe6d1265499 100644 --- a/public/app/plugins/panel/dashlist/module.tsx +++ b/public/app/plugins/panel/dashlist/module.tsx @@ -57,7 +57,7 @@ export const plugin = new PanelPlugin<Options>(DashList) id: 'folderUID', defaultValue: undefined, editor: function RenderFolderPicker({ value, onChange }) { - return <FolderPicker value={value} onChange={(folderUID) => onChange(folderUID)} />; + return <FolderPicker clearable value={value} onChange={(folderUID) => onChange(folderUID)} />; }, }) .addCustomEditor({ diff --git a/public/app/plugins/panel/dashlist/panelcfg.gen.ts b/public/app/plugins/panel/dashlist/panelcfg.gen.ts index 4c12168d0a517..7d85f926cfcc8 100644 --- a/public/app/plugins/panel/dashlist/panelcfg.gen.ts +++ b/public/app/plugins/panel/dashlist/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/datagrid/panelcfg.gen.ts b/public/app/plugins/panel/datagrid/panelcfg.gen.ts index 40269576d7270..4816dbbc0006b 100644 --- a/public/app/plugins/panel/datagrid/panelcfg.gen.ts +++ b/public/app/plugins/panel/datagrid/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/datagrid/utils.test.ts b/public/app/plugins/panel/datagrid/utils.test.ts index 3f0a61e0c0f8f..71a0c7ee111ce 100644 --- a/public/app/plugins/panel/datagrid/utils.test.ts +++ b/public/app/plugins/panel/datagrid/utils.test.ts @@ -1,4 +1,4 @@ -import { ArrayVector, DataFrame, FieldType } from '@grafana/data'; +import { DataFrame, FieldType } from '@grafana/data'; import { clearCellsFromRangeSelection, deleteRows } from './utils'; @@ -14,19 +14,19 @@ describe('when deleting rows', () => { { name: 'test1', type: FieldType.string, - values: new ArrayVector(['a', 'b', 'c', 'd', 'e']), + values: ['a', 'b', 'c', 'd', 'e'], config: {}, }, { name: 'test2', type: FieldType.number, - values: new ArrayVector([1, 2, 3, 4, 5]), + values: [1, 2, 3, 4, 5], config: {}, }, { name: 'test3', type: FieldType.string, - values: new ArrayVector(['a', 'b', 'c', 'd', 'e']), + values: ['a', 'b', 'c', 'd', 'e'], config: {}, }, ], @@ -98,19 +98,19 @@ describe('when clearing cells from range selection', () => { { name: 'test1', type: FieldType.string, - values: new ArrayVector(['a', 'b', 'c', 'd', 'e']), + values: ['a', 'b', 'c', 'd', 'e'], config: {}, }, { name: 'test2', type: FieldType.number, - values: new ArrayVector([1, 2, 3, 4, 5]), + values: [1, 2, 3, 4, 5], config: {}, }, { name: 'test3', type: FieldType.string, - values: new ArrayVector(['a', 'b', 'c', 'd', 'e']), + values: ['a', 'b', 'c', 'd', 'e'], config: {}, }, ], diff --git a/public/app/plugins/panel/debug/EventBusLogger.tsx b/public/app/plugins/panel/debug/EventBusLogger.tsx index c13501abd61e4..32bedcf6bd9f2 100644 --- a/public/app/plugins/panel/debug/EventBusLogger.tsx +++ b/public/app/plugins/panel/debug/EventBusLogger.tsx @@ -52,7 +52,7 @@ export class EventBusLoggerPanel extends PureComponent<Props, State> { eventObserver: PartialObserver<BusEvent> = { next: (event: BusEvent) => { - const origin = event.origin as any; + const origin: any = event.origin; this.history.add({ key: counter++, type: event.type, diff --git a/public/app/plugins/panel/debug/panelcfg.gen.ts b/public/app/plugins/panel/debug/panelcfg.gen.ts index 436fb0cd671b6..2163fc29ccd96 100644 --- a/public/app/plugins/panel/debug/panelcfg.gen.ts +++ b/public/app/plugins/panel/debug/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/gauge/panelcfg.gen.ts b/public/app/plugins/panel/gauge/panelcfg.gen.ts index a0a73ef5863ec..f5d104afced98 100644 --- a/public/app/plugins/panel/gauge/panelcfg.gen.ts +++ b/public/app/plugins/panel/gauge/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/geomap/editor/FitMapViewEditor.tsx b/public/app/plugins/panel/geomap/editor/FitMapViewEditor.tsx index 576dacf042ac7..a88529a45ea3a 100644 --- a/public/app/plugins/panel/geomap/editor/FitMapViewEditor.tsx +++ b/public/app/plugins/panel/geomap/editor/FitMapViewEditor.tsx @@ -79,8 +79,8 @@ export const FitMapViewEditor = ({ labelWidth, value, onChange, context }: Props const currentDataScope = value.allLayers ? DataScopeValues.all : !value.allLayers && value.lastOnly - ? DataScopeValues.last - : DataScopeValues.layer; + ? DataScopeValues.last + : DataScopeValues.layer; const onDataScopeChange = (dataScope: DataScopeValues) => { if (dataScope !== DataScopeValues.all && !value.layer) { diff --git a/public/app/plugins/panel/geomap/layers/data/geojsonLayer.ts b/public/app/plugins/panel/geomap/layers/data/geojsonLayer.ts index 2c49a8f943d27..0f8c1d9028d99 100644 --- a/public/app/plugins/panel/geomap/layers/data/geojsonLayer.ts +++ b/public/app/plugins/panel/geomap/layers/data/geojsonLayer.ts @@ -8,25 +8,27 @@ import { Style } from 'ol/style'; import { ReplaySubject } from 'rxjs'; import { map as rxjsmap, first } from 'rxjs/operators'; -import { - MapLayerRegistryItem, - MapLayerOptions, - GrafanaTheme2, - EventBus, -} from '@grafana/data'; +import { MapLayerRegistryItem, MapLayerOptions, GrafanaTheme2, EventBus } from '@grafana/data'; import { ComparisonOperation } from '@grafana/schema'; import { GeomapStyleRulesEditor } from '../../editor/GeomapStyleRulesEditor'; import { StyleEditor } from '../../editor/StyleEditor'; import { polyStyle } from '../../style/markers'; -import { defaultStyleConfig, StyleConfig, StyleConfigState } from '../../style/types'; +import { + defaultStyleConfig, + GeoJSONLineStyles, + GeoJSONPointStyles, + GeoJSONPolyStyles, + StyleConfig, + StyleConfigState, + StyleConfigValues, +} from '../../style/types'; import { getStyleConfigState } from '../../style/utils'; import { FeatureRuleConfig, FeatureStyleConfig } from '../../types'; import { checkFeatureMatchesStyleRule } from '../../utils/checkFeatureMatchesStyleRule'; import { getLayerPropertyInfo } from '../../utils/getFeatures'; import { getPublicGeoJSONFiles } from '../../utils/utils'; - export interface GeoJSONMapperConfig { // URL for a geojson file src?: string; @@ -107,10 +109,16 @@ export const geojsonLayer: MapLayerRegistryItem<GeoJSONMapperConfig> = { }); } + const polyStyleStrings: string[] = Object.values(GeoJSONPolyStyles); + const pointStyleStrings: string[] = Object.values(GeoJSONPointStyles); + const lineStyleStrings: string[] = Object.values(GeoJSONLineStyles); const vectorLayer = new VectorLayer({ source, style: (feature: FeatureLike) => { - const isPoint = feature.getGeometry()?.getType() === 'Point'; + const featureType = feature.getGeometry()?.getType(); + const isPoint = featureType === 'Point' || featureType === 'MultiPoint'; + const isPolygon = featureType === 'Polygon' || featureType === 'MultiPolygon'; + const isLine = featureType === 'LineString' || featureType === 'MultiLineString'; for (const check of styles) { if (check.rule && !checkFeatureMatchesStyleRule(check.rule, feature)) { @@ -131,6 +139,29 @@ export const geojsonLayer: MapLayerRegistryItem<GeoJSONMapperConfig> = { return polyStyle(values); } + // Support styling polygons from Feature properties + const featureProps = feature.getProperties(); + if (isPolygon && Object.keys(featureProps).some((property) => polyStyleStrings.includes(property))) { + const values: StyleConfigValues = { + color: featureProps[GeoJSONPolyStyles.color] ?? check.state.base.color, + opacity: featureProps[GeoJSONPolyStyles.opacity] ?? check.state.base.opacity, + lineWidth: featureProps[GeoJSONPolyStyles.lineWidth] ?? check.state.base.lineWidth, + }; + return polyStyle(values); + } else if (isLine && Object.keys(featureProps).some((property) => lineStyleStrings.includes(property))) { + const values: StyleConfigValues = { + color: featureProps[GeoJSONLineStyles.color] ?? check.state.base.color, + lineWidth: featureProps[GeoJSONLineStyles.lineWidth] ?? check.state.base.lineWidth, + }; + return check.state.maker(values); + } else if (isPoint && Object.keys(featureProps).some((property) => pointStyleStrings.includes(property))) { + const values: StyleConfigValues = { + color: featureProps[GeoJSONPointStyles.color] ?? check.state.base.color, + size: featureProps[GeoJSONPointStyles.size] ?? check.state.base.size, + }; + return check.state.maker(values); + } + // Lazy create the style object if (isPoint) { if (!check.point) { @@ -196,4 +227,3 @@ export const geojsonLayer: MapLayerRegistryItem<GeoJSONMapperConfig> = { }, defaultOptions, }; - diff --git a/public/app/plugins/panel/geomap/panelcfg.gen.ts b/public/app/plugins/panel/geomap/panelcfg.gen.ts index 6c3f83f18b44b..7e4288bf05015 100644 --- a/public/app/plugins/panel/geomap/panelcfg.gen.ts +++ b/public/app/plugins/panel/geomap/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/geomap/style/types.ts b/public/app/plugins/panel/geomap/style/types.ts index cf8bc2c96ce87..7d7d35af89ac7 100644 --- a/public/app/plugins/panel/geomap/style/types.ts +++ b/public/app/plugins/panel/geomap/style/types.ts @@ -127,6 +127,22 @@ export interface StyleConfigValues { textConfig?: TextStyleConfig; } +export enum GeoJSONPolyStyles { + color = 'fill', + opacity = 'fill-opacity', + lineWidth = 'stroke-width', +} + +export enum GeoJSONPointStyles { + color = 'marker-color', + size = 'marker-size', +} + +export enum GeoJSONLineStyles { + color = 'stroke', + lineWidth = 'stroke-width', +} + /** When the style depends on a field */ export interface StyleConfigFields { color?: string; diff --git a/public/app/plugins/panel/geomap/utils/layers.ts b/public/app/plugins/panel/geomap/utils/layers.ts index 75a2357c38829..834517fa7df5a 100644 --- a/public/app/plugins/panel/geomap/utils/layers.ts +++ b/public/app/plugins/panel/geomap/utils/layers.ts @@ -154,6 +154,6 @@ export async function initLayer( return state; } -export const getMapLayerState = (l: any) => { - return l?.__state as MapLayerState; +export const getMapLayerState = (l: any): MapLayerState => { + return l?.__state; }; diff --git a/public/app/plugins/panel/geomap/utils/tooltip.ts b/public/app/plugins/panel/geomap/utils/tooltip.ts index aeb3722a00845..9ce1696fb0161 100644 --- a/public/app/plugins/panel/geomap/utils/tooltip.ts +++ b/public/app/plugins/panel/geomap/utils/tooltip.ts @@ -62,7 +62,7 @@ export const pointerMoveListener = (evt: MapBrowserEvent<MouseEvent>, panel: Geo panel.map.forEachFeatureAtPixel( pixel, (feature, layer, geo) => { - const s: MapLayerState = getMapLayerState(layer); + const s = getMapLayerState(layer); //match hover layer to layer in layers //check if the layer show tooltip is enabled //then also pass the list of tooltip fields if exists diff --git a/public/app/plugins/panel/gettingstarted/GettingStarted.tsx b/public/app/plugins/panel/gettingstarted/GettingStarted.tsx index 19753cabb638b..88e161793359b 100644 --- a/public/app/plugins/panel/gettingstarted/GettingStarted.tsx +++ b/public/app/plugins/panel/gettingstarted/GettingStarted.tsx @@ -3,7 +3,7 @@ import { css, cx } from '@emotion/css'; import React, { PureComponent } from 'react'; import { PanelProps } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { Button, Spinner, stylesFactory } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; import { backendSrv } from 'app/core/services/backend_srv'; @@ -53,12 +53,14 @@ export class GettingStarted extends PureComponent<PanelProps, State> { } onForwardClick = () => { + reportInteraction('grafana_getting_started_button_to_advanced_tutorials'); this.setState((prevState) => ({ currentStep: prevState.currentStep + 1, })); }; onPreviousClick = () => { + reportInteraction('grafana_getting_started_button_to_basic_tutorials'); this.setState((prevState) => ({ currentStep: prevState.currentStep - 1, })); @@ -69,6 +71,8 @@ export class GettingStarted extends PureComponent<PanelProps, State> { const dashboard = getDashboardSrv().getCurrent(); const panel = dashboard?.getPanelById(id); + reportInteraction('grafana_getting_started_remove_panel'); + dashboard?.removePanel(panel!); backendSrv.put('/api/user/helpflags/1', undefined, { showSuccessAlert: false }).then((res) => { @@ -97,7 +101,7 @@ export class GettingStarted extends PureComponent<PanelProps, State> { <Button className={cx(styles.backForwardButtons, styles.previous)} onClick={this.onPreviousClick} - aria-label="To advanced tutorials" + aria-label="To basic tutorials" icon="angle-left" variant="secondary" /> @@ -109,7 +113,7 @@ export class GettingStarted extends PureComponent<PanelProps, State> { <Button className={cx(styles.backForwardButtons, styles.forward)} onClick={this.onForwardClick} - aria-label="To basic tutorials" + aria-label="To advanced tutorials" icon="angle-right" variant="secondary" /> @@ -124,86 +128,83 @@ export class GettingStarted extends PureComponent<PanelProps, State> { const getStyles = stylesFactory(() => { const theme = config.theme2; return { - container: css` - display: flex; - flex-direction: column; - height: 100%; - // background: url(public/img/getting_started_bg_${theme.colors.mode}.svg) no-repeat; - background-size: cover; - padding: ${theme.spacing(4)} ${theme.spacing(2)} 0; - `, - content: css` - label: content; - display: flex; - justify-content: center; - - ${theme.breakpoints.down('xxl')} { - margin-left: ${theme.spacing(3)}; - justify-content: flex-start; - } - `, - header: css` - label: header; - margin-bottom: ${theme.spacing(3)}; - display: flex; - flex-direction: column; - - ${theme.breakpoints.down('lg')} { - flex-direction: row; - } - `, - headerLogo: css` - height: 58px; - padding-right: ${theme.spacing(2)}; - display: none; - - ${theme.breakpoints.up('md')} { - display: block; - } - `, - heading: css` - label: heading; - margin-right: ${theme.spacing(3)}; - margin-bottom: ${theme.spacing(3)}; - flex-grow: 1; - display: flex; - - ${theme.breakpoints.up('md')} { - margin-bottom: 0; - } - `, - backForwardButtons: css` - position: absolute; - top: 50%; - transform: translateY(-50%); - `, - previous: css` - left: 10px; - - ${theme.breakpoints.down('md')} { - left: 0; - } - `, - forward: css` - right: 10px; - - ${theme.breakpoints.down('md')} { - right: 0; - } - `, - dismiss: css` - align-self: flex-end; - text-decoration: underline; - margin-bottom: ${theme.spacing(1)}; - `, - loading: css` - display: flex; - justify-content: center; - align-items: center; - height: 100%; - `, - loadingText: css` - margin-right: ${theme.spacing(1)}; - `, + container: css({ + display: 'flex', + flexDirection: 'column', + height: '100%', + backgroundSize: 'cover', + padding: `${theme.spacing(4)} ${theme.spacing(2)} 0`, + }), + content: css({ + label: 'content', + display: 'flex', + justifyContent: 'center', + + [theme.breakpoints.down('xxl')]: { + marginLeft: theme.spacing(3), + justifyContent: 'flex-start', + }, + }), + header: css({ + label: 'header', + marginBottom: theme.spacing(3), + display: 'flex', + flexDirection: 'column', + + [theme.breakpoints.down('lg')]: { + flexDirection: 'row', + }, + }), + headerLogo: css({ + height: '58px', + paddingRight: theme.spacing(2), + display: 'none', + + [theme.breakpoints.up('md')]: { + display: 'block', + }, + }), + heading: css({ + label: 'heading', + marginRight: theme.spacing(3), + marginBottom: theme.spacing(3), + flexGrow: 1, + display: 'flex', + + [theme.breakpoints.up('md')]: { + marginBottom: 0, + }, + }), + backForwardButtons: css({ + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + }), + previous: css({ + left: '10px', + [theme.breakpoints.down('md')]: { + left: 0, + }, + }), + forward: css({ + right: '10px', + [theme.breakpoints.down('md')]: { + right: 0, + }, + }), + dismiss: css({ + alignSelf: 'flex-end', + textDecoration: 'underline', + marginBottom: theme.spacing(1), + }), + loading: css({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: '100%', + }), + loadingText: css({ + marginRight: theme.spacing(1), + }), }; }); diff --git a/public/app/plugins/panel/gettingstarted/components/DocsCard.tsx b/public/app/plugins/panel/gettingstarted/components/DocsCard.tsx index 8a5fe372228dd..39871c408e71f 100644 --- a/public/app/plugins/panel/gettingstarted/components/DocsCard.tsx +++ b/public/app/plugins/panel/gettingstarted/components/DocsCard.tsx @@ -2,11 +2,12 @@ import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; import { Icon, useStyles2 } from '@grafana/ui'; import { Card } from '../types'; -import { cardContent, cardStyle, iconStyle } from './sharedStyles'; +import { cardContent, cardStyle } from './sharedStyles'; interface Props { card: Card; @@ -14,17 +15,17 @@ interface Props { export const DocsCard = ({ card }: Props) => { const styles = useStyles2(getStyles, card.done); - const iconStyles = useStyles2(iconStyle, card.done); return ( <div className={styles.card}> <div className={cardContent}> - <a href={`${card.href}?utm_source=grafana_gettingstarted`} className={styles.url}> + <a + href={`${card.href}?utm_source=grafana_gettingstarted`} + className={styles.url} + onClick={() => reportInteraction('grafana_getting_started_docs', { title: card.title, link: card.href })} + > <div className={styles.heading}>{card.done ? 'complete' : card.heading}</div> <h4 className={styles.title}>{card.title}</h4> - <div> - <Icon className={iconStyles} name={card.icon} size="xxl" /> - </div> </a> </div> <a @@ -32,6 +33,7 @@ export const DocsCard = ({ card }: Props) => { className={styles.learnUrl} target="_blank" rel="noreferrer" + onClick={() => reportInteraction('grafana_getting_started_docs', { title: card.title, link: card.learnHref })} > Learn how in the docs <Icon name="external-link-alt" /> </a> diff --git a/public/app/plugins/panel/gettingstarted/components/Step.tsx b/public/app/plugins/panel/gettingstarted/components/Step.tsx index 43d4064264330..e76a73e36fa4d 100644 --- a/public/app/plugins/panel/gettingstarted/components/Step.tsx +++ b/public/app/plugins/panel/gettingstarted/components/Step.tsx @@ -37,34 +37,30 @@ export const Step = ({ step }: Props) => { const getStyles = (theme: GrafanaTheme2) => { return { - setup: css` - display: flex; - width: 95%; - `, - info: css` - width: 172px; - margin-right: 5%; + setup: css({ + display: 'flex', + width: '95%', + }), + info: css({ + width: '172px', + marginRight: '5%', - ${theme.breakpoints.down('xxl')} { - margin-right: ${theme.spacing(4)}; - } - ${theme.breakpoints.down('sm')} { - display: none; - } - `, - title: css` - color: ${theme.v1.palette.blue95}; - `, - cards: css` - overflow-x: scroll; - overflow-y: hidden; - width: 100%; - display: flex; - justify-content: center; - - ${theme.breakpoints.down('xxl')} { - justify-content: flex-start; - } - `, + [theme.breakpoints.down('xxl')]: { + marginRight: theme.spacing(4), + }, + [theme.breakpoints.down('sm')]: { + display: 'none', + }, + }), + title: css({ + color: theme.v1.palette.blue95, + }), + cards: css({ + overflowX: 'auto', + overflowY: 'hidden', + width: '100%', + display: 'flex', + justifyContent: 'flex-start', + }), }; }; diff --git a/public/app/plugins/panel/gettingstarted/components/TutorialCard.tsx b/public/app/plugins/panel/gettingstarted/components/TutorialCard.tsx index 27ea6e7b53475..029f8844a64a0 100644 --- a/public/app/plugins/panel/gettingstarted/components/TutorialCard.tsx +++ b/public/app/plugins/panel/gettingstarted/components/TutorialCard.tsx @@ -2,12 +2,13 @@ import { css } from '@emotion/css'; import React, { MouseEvent } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Icon, useStyles2 } from '@grafana/ui'; +import { reportInteraction } from '@grafana/runtime'; +import { useStyles2 } from '@grafana/ui'; import store from 'app/core/store'; import { TutorialCardType } from '../types'; -import { cardContent, cardStyle, iconStyle } from './sharedStyles'; +import { cardContent, cardStyle } from './sharedStyles'; interface Props { card: TutorialCardType; @@ -15,7 +16,6 @@ interface Props { export const TutorialCard = ({ card }: Props) => { const styles = useStyles2(getStyles, card.done); - const iconStyles = useStyles2(iconStyle, card.done); return ( <a @@ -30,7 +30,6 @@ export const TutorialCard = ({ card }: Props) => { <div className={styles.heading}>{card.done ? 'complete' : card.heading}</div> <h4 className={styles.cardTitle}>{card.title}</h4> <div className={styles.info}>{card.info}</div> - <Icon className={iconStyles} name={card.icon} size="xxl" /> </div> </a> ); @@ -42,6 +41,7 @@ const handleTutorialClick = (event: MouseEvent<HTMLAnchorElement>, card: Tutoria if (!isSet) { store.set(card.key, true); } + reportInteraction('grafana_getting_started_tutorial', { title: card.title }); }; const getStyles = (theme: GrafanaTheme2, complete: boolean) => { diff --git a/public/app/plugins/panel/gettingstarted/components/sharedStyles.ts b/public/app/plugins/panel/gettingstarted/components/sharedStyles.ts index d2bd43184e735..c82ee8dc78d09 100644 --- a/public/app/plugins/panel/gettingstarted/components/sharedStyles.ts +++ b/public/app/plugins/panel/gettingstarted/components/sharedStyles.ts @@ -34,14 +34,6 @@ export const cardStyle = (theme: GrafanaTheme2, complete: boolean) => { `; }; -export const iconStyle = (theme: GrafanaTheme2, complete: boolean) => css` - color: ${complete ? theme.v1.palette.blue95 : theme.colors.text.secondary}; - - ${theme.breakpoints.down('sm')} { - display: none; - } -`; - export const cardContent = css` padding: 16px; `; diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index 0ba8b430085d4..14930db4f182a 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -14,6 +14,7 @@ import { MetricsPanelCtrl } from 'app/angular/panel/metrics_panel_ctrl'; import config from 'app/core/config'; import TimeSeries from 'app/core/time_series2'; import { ThresholdMapper } from 'app/features/alerting/state/ThresholdMapper'; +import { getPanelPluginToMigrateTo } from 'app/features/dashboard/state/getPanelPluginToMigrateTo'; import { changePanelPlugin } from 'app/features/panel/state/actions'; import { dispatch } from 'app/store/store'; @@ -356,7 +357,8 @@ export class GraphCtrl extends MetricsPanelCtrl { }; migrateToReact() { - this.onPluginTypeChange(config.panels['timeseries']); + const panelType = getPanelPluginToMigrateTo(this.panel, true); + this.onPluginTypeChange(config.panels[panelType!]); } } diff --git a/public/app/plugins/panel/heatmap/ExemplarModalHeader.tsx b/public/app/plugins/panel/heatmap/ExemplarModalHeader.tsx index 12bcaaa8656bc..e2110122ab314 100644 --- a/public/app/plugins/panel/heatmap/ExemplarModalHeader.tsx +++ b/public/app/plugins/panel/heatmap/ExemplarModalHeader.tsx @@ -1,8 +1,15 @@ -import React from 'react'; +import React, { CSSProperties } from 'react'; -import { CloseButton } from '../../../core/components/CloseButton/CloseButton'; +import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; + +export function ExemplarModalHeader(props: { onClick: () => void; style?: React.CSSProperties }) { + const defaultStyle: CSSProperties = { + position: 'relative', + top: 'auto', + right: 'auto', + marginRight: 0, + }; -export function ExemplarModalHeader(props: { onClick: () => void }) { return ( <div style={{ @@ -12,15 +19,7 @@ export function ExemplarModalHeader(props: { onClick: () => void }) { paddingBottom: '6px', }} > - <CloseButton - onClick={props.onClick} - style={{ - position: 'relative', - top: 'auto', - right: 'auto', - marginRight: 0, - }} - /> + <CloseButton onClick={props.onClick} style={props.style ?? defaultStyle} /> </div> ); } diff --git a/public/app/plugins/panel/heatmap/HeatmapHoverViewOld.tsx b/public/app/plugins/panel/heatmap/HeatmapHoverViewOld.tsx index 7ef653a2d5f9f..f7c73022063ae 100644 --- a/public/app/plugins/panel/heatmap/HeatmapHoverViewOld.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapHoverViewOld.tsx @@ -9,9 +9,7 @@ import { getFieldDisplayName, LinkModel, TimeRange, - getLinksSupplier, InterpolateFunction, - ScopedVars, } from '@grafana/data'; import { HeatmapCellLayout } from '@grafana/schema'; import { LinkButton, VerticalGroup } from '@grafana/ui'; @@ -19,6 +17,8 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap'; import { DataHoverView } from 'app/features/visualization/data-hover/DataHoverView'; +import { getDataLinks } from '../status-history/utils'; + import { HeatmapData } from './fields'; import { renderHistogram } from './renderHistogram'; import { HeatmapHoverEvent } from './utils'; @@ -29,7 +29,6 @@ type Props = { showHistogram?: boolean; timeRange: TimeRange; replaceVars: InterpolateFunction; - scopedVars: ScopedVars[]; }; export const HeatmapHoverView = (props: Props) => { @@ -39,7 +38,7 @@ export const HeatmapHoverView = (props: Props) => { return <HeatmapHoverCell {...props} />; }; -const HeatmapHoverCell = ({ data, hover, showHistogram = false, scopedVars, replaceVars }: Props) => { +const HeatmapHoverCell = ({ data, hover, showHistogram = false }: Props) => { const index = hover.dataIdx; const [isSparse] = useState( @@ -70,7 +69,8 @@ const HeatmapHoverCell = ({ data, hover, showHistogram = false, scopedVars, repl const meta = readHeatmapRowsCustomMeta(data.heatmap); const yDisp = yField?.display ? (v: string) => formattedValueToString(yField.display!(v)) : (v: string) => `${v}`; - const yValueIdx = index % data.yBucketCount! ?? 0; + const yValueIdx = index % (data.yBucketCount ?? 1); + const xValueIdx = Math.floor(index / (data.yBucketCount ?? 1)); let yBucketMin: string; let yBucketMax: string; @@ -126,33 +126,16 @@ const HeatmapHoverCell = ({ data, hover, showHistogram = false, scopedVars, repl const count = countVals?.[index]; - const visibleFields = data.heatmap?.fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.tooltip)); - const links: Array<LinkModel<Field>> = []; - const linkLookup = new Set<string>(); - - for (const field of visibleFields ?? []) { - const hasLinks = field.config.links && field.config.links.length > 0; + let links: Array<LinkModel<Field>> = []; - if (hasLinks && data.heatmap) { - const appropriateScopedVars = scopedVars.find( - (scopedVar) => - scopedVar && scopedVar.__dataContext && scopedVar.__dataContext.value.field.name === nonNumericOrdinalDisplay - ); + const linksField = data.series?.fields[yValueIdx + 1]; - field.getLinks = getLinksSupplier(data.heatmap, field, appropriateScopedVars || {}, replaceVars); - } + if (linksField != null) { + const visible = !Boolean(linksField.config.custom?.hideFrom?.tooltip); + const hasLinks = (linksField.config.links?.length ?? 0) > 0; - if (field.getLinks) { - const value = field.values[index]; - const display = field.display ? field.display(value) : { text: `${value}`, numeric: +value }; - - field.getLinks({ calculatedValue: display, valueRowIndex: index }).forEach((link) => { - const key = `${link.title}/${link.href}`; - if (!linkLookup.has(key)) { - links.push(link); - linkLookup.add(key); - } - }); + if (visible && hasLinks) { + links = getDataLinks(linksField, xValueIdx); } } diff --git a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx index 16a884189d32a..0a0863cf64240 100644 --- a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx @@ -1,22 +1,14 @@ import { css } from '@emotion/css'; import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { - DataFrame, - DataFrameType, - Field, - getLinksSupplier, - GrafanaTheme2, - PanelProps, - ScopedVars, - TimeRange, -} from '@grafana/data'; +import { DashboardCursorSync, DataFrameType, GrafanaTheme2, PanelProps, TimeRange } from '@grafana/data'; import { config, PanelDataErrorView } from '@grafana/runtime'; import { ScaleDistributionConfig } from '@grafana/schema'; import { Portal, ScaleDistribution, TooltipPlugin2, + TooltipDisplayMode, ZoomPlugin, UPlotChart, usePanelContext, @@ -24,16 +16,17 @@ import { useTheme2, VizLayout, VizTooltipContainer, + EventBusPlugin, } from '@grafana/ui'; -import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; +import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; import { ColorScale } from 'app/core/components/ColorScale/ColorScale'; import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap'; -import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin'; +import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2'; import { ExemplarModalHeader } from './ExemplarModalHeader'; -import { HeatmapHoverView } from './HeatmapHoverView'; -import { HeatmapHoverView as HeatmapHoverViewOld } from './HeatmapHoverViewOld'; +import { HeatmapHoverView } from './HeatmapHoverViewOld'; +import { HeatmapTooltip } from './HeatmapTooltip'; import { prepareHeatmapData } from './fields'; import { quantizeScheme } from './palettes'; import { Options } from './types'; @@ -58,52 +51,42 @@ export const HeatmapPanel = ({ const styles = useStyles2(getStyles); const { sync, canAddAnnotations } = usePanelContext(); - const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); + // TODO: we should just re-init when this changes, and have this be a static setting + const syncTooltip = useCallback( + () => sync?.() === DashboardCursorSync.Tooltip, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); - // necessary for enabling datalinks in hover view - let scopedVarsFromRawData: ScopedVars[] = []; - for (const series of data.series) { - for (const field of series.fields) { - if (field.state?.scopedVars) { - scopedVarsFromRawData.push(field.state.scopedVars); - } - } - } + const syncAny = useCallback( + () => sync?.() !== DashboardCursorSync.Off, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + // temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2 + const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null); // ugh let timeRangeRef = useRef<TimeRange>(timeRange); timeRangeRef.current = timeRange; - const getFieldLinksSupplier = useCallback( - (exemplars: DataFrame, field: Field) => { - return getLinksSupplier(exemplars, field, field.state?.scopedVars ?? {}, replaceVariables); - }, - [replaceVariables] - ); - const palette = useMemo(() => quantizeScheme(options.color, theme), [options.color, theme]); const info = useMemo(() => { try { - return prepareHeatmapData( - data.series, - data.annotations, - options, - palette, - theme, - getFieldLinksSupplier, - replaceVariables - ); + return prepareHeatmapData(data.series, data.annotations, options, palette, theme, replaceVariables); } catch (ex) { return { warning: `${ex}` }; } - }, [data.series, data.annotations, options, palette, theme, getFieldLinksSupplier, replaceVariables]); + }, [data.series, data.annotations, options, palette, theme, replaceVariables]); const facets = useMemo(() => { let exemplarsXFacet: number[] | undefined = []; // "Time" field let exemplarsYFacet: Array<number | undefined> = []; const meta = readHeatmapRowsCustomMeta(info.heatmap); + if (info.exemplars?.length) { exemplarsXFacet = info.exemplars?.fields[0].values; @@ -156,6 +139,7 @@ export const HeatmapPanel = ({ // ugh const dataRef = useRef(info); dataRef.current = info; + const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips); const builder = useMemo(() => { const scaleConfig: ScaleDistributionConfig = dataRef.current?.heatmap?.fields[1].config?.custom?.scaleDistribution; @@ -163,9 +147,8 @@ export const HeatmapPanel = ({ return prepConfig({ dataRef, theme, - eventBus, - onhover: onhover, - onclick: options.tooltip.show ? onclick : null, + onhover: !showNewVizTooltips ? onhover : null, + onclick: !showNewVizTooltips && options.tooltip.mode !== TooltipDisplayMode.None ? onclick : null, isToolTipOpen, timeZone, getTimeRange: () => timeRangeRef.current, @@ -223,70 +206,90 @@ export const HeatmapPanel = ({ ); } - const newVizTooltips = config.featureToggles.newVizTooltips ?? false; + const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); return ( <> <VizLayout width={width} height={height} legend={renderLegend()}> {(vizWidth: number, vizHeight: number) => ( <UPlotChart config={builder} data={facets as any} width={vizWidth} height={vizHeight}> - {/*children ? children(config, alignedFrame) : null*/} - {!newVizTooltips && <ZoomPlugin config={builder} onZoom={onChangeTimeRange} />} - {newVizTooltips && options.tooltip.show && ( - <TooltipPlugin2 - config={builder} - hoverMode={TooltipHoverMode.xyOne} - queryZoom={onChangeTimeRange} - render={(u, dataIdxs, seriesIdx, isPinned, dismiss) => { - return ( - <HeatmapHoverView - dataIdxs={dataIdxs} - seriesIdx={seriesIdx} - dataRef={dataRef} - isPinned={isPinned} - dismiss={dismiss} - showHistogram={options.tooltip.yHistogram} - showColorScale={options.tooltip.showColorScale} - canAnnotate={enableAnnotationCreation} - panelData={data} - replaceVars={replaceVariables} - scopedVars={scopedVarsFromRawData} - /> - ); - }} - /> - )} - {data.annotations && ( - <AnnotationsPlugin - annotations={data.annotations} - config={builder} - timeZone={timeZone} - disableCanvasRendering={true} - /> + <EventBusPlugin config={builder} sync={syncAny} eventBus={eventBus} frame={info.series ?? info.heatmap} /> + {!showNewVizTooltips && <ZoomPlugin config={builder} onZoom={onChangeTimeRange} />} + {showNewVizTooltips && ( + <> + {options.tooltip.mode !== TooltipDisplayMode.None && ( + <TooltipPlugin2 + config={builder} + hoverMode={TooltipHoverMode.xyOne} + queryZoom={onChangeTimeRange} + syncTooltip={syncTooltip} + render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => { + if (enableAnnotationCreation && timeRange2 != null) { + setNewAnnotationRange(timeRange2); + dismiss(); + return; + } + + const annotate = () => { + let xVal = u.posToVal(u.cursor.left!, 'x'); + + setNewAnnotationRange({ from: xVal, to: xVal }); + dismiss(); + }; + + return ( + <HeatmapTooltip + mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode} + dataIdxs={dataIdxs} + seriesIdx={seriesIdx} + dataRef={dataRef} + isPinned={isPinned} + dismiss={dismiss} + showHistogram={options.tooltip.yHistogram} + showColorScale={options.tooltip.showColorScale} + panelData={data} + annotate={enableAnnotationCreation ? annotate : undefined} + /> + ); + }} + maxWidth={options.tooltip.maxWidth} + maxHeight={options.tooltip.maxHeight} + /> + )} + <AnnotationsPlugin2 + annotations={data.annotations ?? []} + config={builder} + timeZone={timeZone} + newRange={newAnnotationRange} + setNewRange={setNewAnnotationRange} + canvasRegionRendering={false} + /> + </> )} </UPlotChart> )} </VizLayout> - {!newVizTooltips && ( - <Portal> - {hover && options.tooltip.show && ( - <VizTooltipContainer - position={{ x: hover.pageX, y: hover.pageY }} - offset={{ x: 10, y: 10 }} - allowPointerEvents={isToolTipOpen.current} - > - {shouldDisplayCloseButton && <ExemplarModalHeader onClick={onCloseToolTip} />} - <HeatmapHoverViewOld - timeRange={timeRange} - data={info} - hover={hover} - showHistogram={options.tooltip.yHistogram} - replaceVars={replaceVariables} - scopedVars={scopedVarsFromRawData} - /> - </VizTooltipContainer> - )} - </Portal> + {!showNewVizTooltips && ( + <> + <Portal> + {hover && options.tooltip.mode !== TooltipDisplayMode.None && ( + <VizTooltipContainer + position={{ x: hover.pageX, y: hover.pageY }} + offset={{ x: 10, y: 10 }} + allowPointerEvents={isToolTipOpen.current} + > + {shouldDisplayCloseButton && <ExemplarModalHeader onClick={onCloseToolTip} />} + <HeatmapHoverView + timeRange={timeRange} + data={info} + hover={hover} + showHistogram={options.tooltip.yHistogram} + replaceVars={replaceVariables} + /> + </VizTooltipContainer> + )} + </Portal> + </> )} </> ); diff --git a/public/app/plugins/panel/heatmap/HeatmapHoverView.tsx b/public/app/plugins/panel/heatmap/HeatmapTooltip.tsx similarity index 57% rename from public/app/plugins/panel/heatmap/HeatmapHoverView.tsx rename to public/app/plugins/panel/heatmap/HeatmapTooltip.tsx index 13bec6d02ef4c..ed104d63f2ffc 100644 --- a/public/app/plugins/panel/heatmap/HeatmapHoverView.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapTooltip.tsx @@ -1,36 +1,35 @@ -import { css } from '@emotion/css'; -import React, { ReactElement, useEffect, useRef, useState } from 'react'; +import React, { ReactElement, useEffect, useRef, useState, ReactNode } from 'react'; import uPlot from 'uplot'; import { DataFrameType, + Field, + FieldType, formattedValueToString, getFieldDisplayName, - GrafanaTheme2, - getLinksSupplier, - InterpolateFunction, - ScopedVars, - PanelData, LinkModel, - Field, - FieldType, + PanelData, } from '@grafana/data'; import { HeatmapCellLayout } from '@grafana/schema'; -import { useStyles2 } from '@grafana/ui'; +import { TooltipDisplayMode, useStyles2, useTheme2 } from '@grafana/ui'; import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader'; -import { ColorIndicator, ColorPlacement, LabelValue } from '@grafana/ui/src/components/VizTooltip/types'; +import { ColorIndicator, ColorPlacement, VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types'; import { ColorScale } from 'app/core/components/ColorScale/ColorScale'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap'; import { DataHoverView } from 'app/features/visualization/data-hover/DataHoverView'; +import { getDataLinks } from '../status-history/utils'; +import { getStyles } from '../timeseries/TimeSeriesTooltip'; + import { HeatmapData } from './fields'; import { renderHistogram } from './renderHistogram'; -import { getSparseCellMinMax, getFieldFromData, getHoverCellColor, formatMilliseconds } from './tooltip/utils'; +import { formatMilliseconds, getFieldFromData, getHoverCellColor, getSparseCellMinMax } from './tooltip/utils'; -interface Props { +interface HeatmapTooltipProps { + mode: TooltipDisplayMode; dataIdxs: Array<number | null>; seriesIdx: number | null | undefined; dataRef: React.MutableRefObject<HeatmapData>; @@ -38,13 +37,11 @@ interface Props { showColorScale?: boolean; isPinned: boolean; dismiss: () => void; - canAnnotate: boolean; panelData: PanelData; - replaceVars: InterpolateFunction; - scopedVars: ScopedVars[]; + annotate?: () => void; } -export const HeatmapHoverView = (props: Props) => { +export const HeatmapTooltip = (props: HeatmapTooltipProps) => { if (props.seriesIdx === 2) { return ( <DataHoverView @@ -64,13 +61,10 @@ const HeatmapHoverCell = ({ dataRef, showHistogram, isPinned, - canAnnotate, - panelData, showColorScale = false, - scopedVars, - replaceVars, - dismiss, -}: Props) => { + mode, + annotate, +}: HeatmapTooltipProps) => { const index = dataIdxs[1]!; const data = dataRef.current; @@ -102,8 +96,6 @@ const HeatmapHoverCell = ({ const meta = readHeatmapRowsCustomMeta(data.heatmap); const yDisp = yField?.display ? (v: string) => formattedValueToString(yField.display!(v)) : (v: string) => `${v}`; - const yValueIdx = index % data.yBucketCount! ?? 0; - let interval = xField?.config.interval; let yBucketMin: string; @@ -114,9 +106,12 @@ const HeatmapHoverCell = ({ let nonNumericOrdinalDisplay: string | undefined = undefined; - if (isSparse) { - ({ xBucketMin, xBucketMax, yBucketMin, yBucketMax } = getSparseCellMinMax(data!, index)); - } else { + let contentItems: VizTooltipItem[] = []; + + const yValueIdx = index % (data.yBucketCount ?? 1); + const xValueIdx = Math.floor(index / (data.yBucketCount ?? 1)); + + const getData = (idx: number = index) => { if (meta.yOrdinalDisplay) { const yMinIdx = data.yLayout === HeatmapCellLayout.le ? yValueIdx - 1 : yValueIdx; const yMaxIdx = data.yLayout === HeatmapCellLayout.le ? yValueIdx : yValueIdx + 1; @@ -154,94 +149,92 @@ const HeatmapHoverCell = ({ } if (data.xLayout === HeatmapCellLayout.le) { - xBucketMax = xVals[index]; + xBucketMax = xVals[idx]; xBucketMin = xBucketMax - data.xBucketSize!; } else { - xBucketMin = xVals[index]; + xBucketMin = xVals[idx]; xBucketMax = xBucketMin + data.xBucketSize!; } - } + }; - const count = countVals?.[index]; + if (isSparse) { + ({ xBucketMin, xBucketMax, yBucketMin, yBucketMax } = getSparseCellMinMax(data!, index)); + } else { + getData(); + } - const visibleFields = data.heatmap?.fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.tooltip)); - const links: Array<LinkModel<Field>> = []; - const linkLookup = new Set<string>(); + const { cellColor, colorPalette } = getHoverCellColor(data, index); - for (const field of visibleFields ?? []) { - const hasLinks = field.config.links && field.config.links.length > 0; + const getDisplayData = (fromIdx: number, toIdx: number) => { + let vals = []; + for (let idx = fromIdx; idx <= toIdx; idx++) { + if (!countVals?.[idx]) { + continue; + } - if (hasLinks && data.heatmap) { - const appropriateScopedVars = scopedVars.find( - (scopedVar) => - scopedVar && scopedVar.__dataContext && scopedVar.__dataContext.value.field.name === nonNumericOrdinalDisplay - ); + const color = getHoverCellColor(data, idx).cellColor; + count = getCountValue(idx); - field.getLinks = getLinksSupplier(data.heatmap, field, appropriateScopedVars || {}, replaceVars); - } + if (isSparse) { + ({ xBucketMin, xBucketMax, yBucketMin, yBucketMax } = getSparseCellMinMax(data!, idx)); + } else { + getData(idx); + } - if (field.getLinks) { - const value = field.values[index]; - const display = field.display ? field.display(value) : { text: `${value}`, numeric: +value }; + const { label, value } = getContentLabels()[0]; - field.getLinks({ calculatedValue: display, valueRowIndex: index }).forEach((link) => { - const key = `${link.title}/${link.href}`; - if (!linkLookup.has(key)) { - links.push(link); - linkLookup.add(key); - } + vals.push({ + label, + value, + color: color ?? '#FFF', + isActive: index === idx, }); } - } - - let can = useRef<HTMLCanvasElement>(null); - let histCssWidth = 264; - let histCssHeight = 64; - let histCanWidth = Math.round(histCssWidth * uPlot.pxRatio); - let histCanHeight = Math.round(histCssHeight * uPlot.pxRatio); - - useEffect( - () => { - if (showHistogram && xVals != null && countVals != null) { - renderHistogram(can, histCanWidth, histCanHeight, xVals, countVals, index, data.yBucketCount!); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [index] - ); + return vals; + }; - const { cellColor, colorPalette } = getHoverCellColor(data, index); + const getContentLabels = (): VizTooltipItem[] => { + const isMulti = mode === TooltipDisplayMode.Multi && !isPinned; - const getContentLabels = (): LabelValue[] => { if (nonNumericOrdinalDisplay) { - return [{ label: 'Name', value: nonNumericOrdinalDisplay }]; + return isMulti + ? [{ label: `Name ${nonNumericOrdinalDisplay}`, value: data.display!(count) }] + : [{ label: 'Name', value: nonNumericOrdinalDisplay }]; } switch (data.yLayout) { case HeatmapCellLayout.unknown: - return [{ label: '', value: yDisp(yBucketMin) }]; + return isMulti + ? [{ label: yDisp(yBucketMin), value: data.display!(count) }] + : [{ label: '', value: yDisp(yBucketMin) }]; } - return [ - { - label: 'Bucket', - value: `${yDisp(yBucketMin)}` + '-' + `${yDisp(yBucketMax)}`, - }, - ]; + return isMulti + ? [ + { + label: `Bucket ${yDisp(yBucketMin)}` + '-' + `${yDisp(yBucketMax)}`, + value: data.display!(count), + }, + ] + : [ + { + label: 'Bucket', + value: `${yDisp(yBucketMin)}` + '-' + `${yDisp(yBucketMax)}`, + }, + ]; }; - const getHeaderLabel = (): LabelValue => { - return { - label: '', - value: xDisp(xBucketMax)!, - }; + const getCountValue = (idx: number) => { + return countVals?.[idx]; }; - const getContentLabelValue = (): LabelValue[] => { - const fromToInt: LabelValue[] = interval ? [{ label: 'Duration', value: formatMilliseconds(interval) }] : []; + let count = getCountValue(index); + + if (mode === TooltipDisplayMode.Single || isPinned) { + const fromToInt: VizTooltipItem[] = interval ? [{ label: 'Duration', value: formatMilliseconds(interval) }] : []; - return [ + contentItems = [ { label: getFieldDisplayName(countField, data.heatmap), value: data.display!(count), @@ -252,13 +245,81 @@ const HeatmapHoverCell = ({ ...getContentLabels(), ...fromToInt, ]; + } + + if (mode === TooltipDisplayMode.Multi && !isPinned) { + let xVal = xField.values[index]; + let fromIdx = index; + let toIdx = index; + + while (xField.values[fromIdx - 1] === xVal) { + fromIdx--; + } + + while (xField.values[toIdx + 1] === xVal) { + toIdx++; + } + + const vals: VizTooltipItem[] = getDisplayData(fromIdx, toIdx); + vals.forEach((val) => { + contentItems.push({ + label: val.label, + value: val.value, + color: val.color ?? '#FFF', + colorIndicator: ColorIndicator.value, + colorPlacement: ColorPlacement.trailing, + isActive: val.isActive, + }); + }); + } + + let footer: ReactNode; + + if (isPinned) { + let links: Array<LinkModel<Field>> = []; + + const linksField = data.series?.fields[yValueIdx + 1]; + + if (linksField != null) { + const visible = !Boolean(linksField.config.custom?.hideFrom?.tooltip); + const hasLinks = (linksField.config.links?.length ?? 0) > 0; + + if (visible && hasLinks) { + links = getDataLinks(linksField, xValueIdx); + } + } + + footer = <VizTooltipFooter dataLinks={links} annotate={annotate} />; + } + + let can = useRef<HTMLCanvasElement>(null); + + let histCssWidth = 264; + let histCssHeight = 64; + let histCanWidth = Math.round(histCssWidth * uPlot.pxRatio); + let histCanHeight = Math.round(histCssHeight * uPlot.pxRatio); + + useEffect( + () => { + if (showHistogram && xVals != null && countVals != null && mode === TooltipDisplayMode.Single) { + renderHistogram(can, histCanWidth, histCanHeight, xVals, countVals, index, data.yBucketCount!); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [index] + ); + + const headerItem: VizTooltipItem = { + label: '', + value: xDisp(xBucketMax!)!, }; - const getCustomContent = () => { - let content: ReactElement[] = []; + let customContent: ReactElement[] = []; + + if (mode === TooltipDisplayMode.Single) { // Histogram - if (showHistogram) { - content.push( + if (showHistogram && !isSparse) { + customContent.push( <canvas width={histCanWidth} height={histCanHeight} @@ -270,7 +331,7 @@ const HeatmapHoverCell = ({ // Color scale if (colorPalette && showColorScale) { - content.push( + customContent.push( <ColorScale colorPalette={colorPalette} min={data.heatmapColors?.minValue!} @@ -280,28 +341,22 @@ const HeatmapHoverCell = ({ /> ); } - - return content; - }; - - // @TODO remove this when adding annotations support - canAnnotate = false; + } const styles = useStyles2(getStyles); + const theme = useTheme2(); return ( <div className={styles.wrapper}> - <VizTooltipHeader headerLabel={getHeaderLabel()} /> - <VizTooltipContent contentLabelValue={getContentLabelValue()} customContent={getCustomContent()} /> - {isPinned && <VizTooltipFooter dataLinks={links} canAnnotate={canAnnotate} />} + <VizTooltipHeader item={headerItem} isPinned={isPinned} /> + <VizTooltipContent items={contentItems} isPinned={isPinned}> + {customContent?.map((content, i) => ( + <div key={i} style={{ padding: `${theme.spacing(1)} 0` }}> + {content} + </div> + ))} + </VizTooltipContent> + {footer} </div> ); }; - -const getStyles = (theme: GrafanaTheme2) => ({ - wrapper: css({ - display: 'flex', - flexDirection: 'column', - width: '280px', - }), -}); diff --git a/public/app/plugins/panel/heatmap/fields.ts b/public/app/plugins/panel/heatmap/fields.ts index 0529ec3fce436..e45b88ae03338 100644 --- a/public/app/plugins/panel/heatmap/fields.ts +++ b/public/app/plugins/panel/heatmap/fields.ts @@ -6,12 +6,11 @@ import { FieldType, formattedValueToString, getDisplayProcessor, + getLinksSupplier, GrafanaTheme2, InterpolateFunction, - LinkModel, outerJoinDataFrames, ValueFormatter, - ValueLinkConfig, } from '@grafana/data'; import { config } from '@grafana/runtime'; import { HeatmapCellLayout } from '@grafana/schema'; @@ -39,6 +38,8 @@ export interface HeatmapData { maxValue: number; }; + series?: DataFrame; // the joined single frame for nonNumericOrdinalY data links + exemplars?: DataFrame; // optionally linked exemplars exemplarColor?: string; @@ -70,8 +71,7 @@ export function prepareHeatmapData( options: Options, palette: string[], theme: GrafanaTheme2, - getFieldLinks?: (exemplars: DataFrame, field: Field) => (config: ValueLinkConfig) => Array<LinkModel<Field>>, - replaceVariables?: InterpolateFunction + replaceVariables: InterpolateFunction = (v) => v ): HeatmapData { if (!frames?.length) { return {}; @@ -81,11 +81,9 @@ export function prepareHeatmapData( const exemplars = annotations?.find((f) => f.name === 'exemplar'); - if (getFieldLinks) { - exemplars?.fields.forEach((field, index) => { - exemplars.fields[index].getLinks = getFieldLinks(exemplars, field); - }); - } + exemplars?.fields.forEach((field) => { + field.getLinks = getLinksSupplier(exemplars, field, field.state?.scopedVars ?? {}, replaceVariables); + }); if (options.calculate) { if (config.featureToggles.transformationsVariableSupport) { @@ -138,7 +136,7 @@ export function prepareHeatmapData( } // Everything past here assumes a field for each row in the heatmap (buckets) - if (!rowsHeatmap) { + if (rowsHeatmap == null) { if (frames.length > 1) { let allNamesNumeric = frames.every( (frame) => !Number.isNaN(parseSampleValue(frame.fields[1].state?.displayName!)) @@ -148,11 +146,10 @@ export function prepareHeatmapData( frames.sort(sortSeriesByLabel); } - rowsHeatmap = [ - outerJoinDataFrames({ - frames, - })!, - ][0]; + rowsHeatmap = outerJoinDataFrames({ + frames, + keepDisplayNames: true, + })!; } else { let frame = frames[0]; let numberFields = frame.fields.filter((field) => field.type === FieldType.number); @@ -171,18 +168,31 @@ export function prepareHeatmapData( } } - return getDenseHeatmapData( - rowsToCellsHeatmap({ - unit: options.yAxis?.unit, // used to format the ordinal lookup values - decimals: options.yAxis?.decimals, - ...options.rowsFrame, - frame: rowsHeatmap, - }), - exemplars, - options, - palette, - theme - ); + // config data links + rowsHeatmap.fields.forEach((field) => { + if ((field.config.links?.length ?? 0) === 0) { + return; + } + + // this expects that the tooltip is able to identify the field and rowIndex from a dense hovered index + field.getLinks = getLinksSupplier(rowsHeatmap!, field, field.state?.scopedVars ?? {}, replaceVariables); + }); + + return { + ...getDenseHeatmapData( + rowsToCellsHeatmap({ + unit: options.yAxis?.unit, // used to format the ordinal lookup values + decimals: options.yAxis?.decimals, + ...options.rowsFrame, + frame: rowsHeatmap, + }), + exemplars, + options, + palette, + theme + ), + series: rowsHeatmap, + }; } const getSparseHeatmapData = ( diff --git a/public/app/plugins/panel/heatmap/migrations.test.ts b/public/app/plugins/panel/heatmap/migrations.test.ts index e1a18fdea636d..ce188993215ba 100644 --- a/public/app/plugins/panel/heatmap/migrations.test.ts +++ b/public/app/plugins/panel/heatmap/migrations.test.ts @@ -74,7 +74,7 @@ describe('Heatmap Migrations', () => { }, "showValue": "never", "tooltip": { - "show": true, + "mode": "single", "yHistogram": true, }, "yAxis": { @@ -131,7 +131,7 @@ describe('Heatmap Migrations', () => { }, "showValue": "never", "tooltip": { - "show": false, + "mode": "none", "yHistogram": false, }, "yAxis": { diff --git a/public/app/plugins/panel/heatmap/migrations.ts b/public/app/plugins/panel/heatmap/migrations.ts index 5445ab1374502..325159e57586a 100644 --- a/public/app/plugins/panel/heatmap/migrations.ts +++ b/public/app/plugins/panel/heatmap/migrations.ts @@ -7,6 +7,7 @@ import { HeatmapCalculationMode, HeatmapCalculationOptions, } from '@grafana/schema'; +import { TooltipDisplayMode } from '@grafana/ui'; import { colorSchemes } from './palettes'; import { Options, defaultOptions, HeatmapColorMode } from './types'; @@ -17,6 +18,20 @@ export const heatmapMigrationHandler = (panel: PanelModel): Partial<Options> => if (Object.keys(panel.options ?? {}).length === 0) { return heatmapChangedHandler(panel, 'heatmap', { angular: panel }, panel.fieldConfig); } + + // multi tooltip mode in 10.3+ + let showTooltip = panel.options?.tooltip?.show; + if (showTooltip !== undefined) { + if (showTooltip === true) { + panel.options.tooltip.mode = TooltipDisplayMode.Single; + } else if (showTooltip === false) { + panel.options.tooltip.mode = TooltipDisplayMode.None; + } + + // Remove old tooltip option + delete panel.options.tooltip?.show; + } + return panel.options; }; @@ -111,7 +126,7 @@ export function angularToReactHeatmap(angular: any): { fieldConfig: FieldConfigS }, showValue: VisibilityMode.Never, tooltip: { - show: Boolean(angular.tooltip?.show), + mode: Boolean(angular.tooltip?.show) ? TooltipDisplayMode.Single : TooltipDisplayMode.None, yHistogram: Boolean(angular.tooltip?.showHistogram), }, exemplars: { diff --git a/public/app/plugins/panel/heatmap/module.tsx b/public/app/plugins/panel/heatmap/module.tsx index da2d05509f6a8..5ac97b441c65e 100644 --- a/public/app/plugins/panel/heatmap/module.tsx +++ b/public/app/plugins/panel/heatmap/module.tsx @@ -9,6 +9,7 @@ import { ScaleDistributionConfig, HeatmapCellLayout, } from '@grafana/schema'; +import { TooltipDisplayMode } from '@grafana/ui'; import { addHideFrom, ScaleDistributionEditor } from '@grafana/ui/src/options/builder'; import { ColorScale } from 'app/core/components/ColorScale/ColorScale'; import { addHeatmapCalculationOptions } from 'app/features/transformers/calculateHeatmap/editor/helper'; @@ -52,15 +53,7 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel) // NOTE: this feels like overkill/expensive just to assert if we have an ordinal y // can probably simplify without doing full dataprep const palette = quantizeScheme(opts.color, config.theme2); - const v = prepareHeatmapData( - context.data, - undefined, - opts, - palette, - config.theme2, - undefined, - context.replaceVariables - ); + const v = prepareHeatmapData(context.data, undefined, opts, palette, config.theme2); isOrdinalY = readHeatmapRowsCustomMeta(v.heatmap).yOrdinalDisplay != null; } catch {} } @@ -391,11 +384,18 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel) category = ['Tooltip']; - builder.addBooleanSwitch({ - path: 'tooltip.show', - name: 'Show tooltip', - defaultValue: defaultOptions.tooltip.show, + builder.addRadio({ + path: 'tooltip.mode', + name: 'Tooltip mode', category, + defaultValue: TooltipDisplayMode.Single, + settings: { + options: [ + { value: TooltipDisplayMode.Single, label: 'Single' }, + { value: TooltipDisplayMode.Multi, label: 'All' }, + { value: TooltipDisplayMode.None, label: 'Hidden' }, + ], + }, }); builder.addBooleanSwitch({ @@ -403,7 +403,7 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel) name: 'Show histogram (Y axis)', defaultValue: defaultOptions.tooltip.yHistogram, category, - showIf: (opts) => opts.tooltip.show, + showIf: (opts) => opts.tooltip.mode === TooltipDisplayMode.Single, }); builder.addBooleanSwitch({ @@ -411,7 +411,28 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel) name: 'Show color scale', defaultValue: defaultOptions.tooltip.showColorScale, category, - showIf: (opts) => opts.tooltip.show && config.featureToggles.newVizTooltips, + showIf: (opts) => opts.tooltip.mode === TooltipDisplayMode.Single && config.featureToggles.newVizTooltips, + }); + + builder.addNumberInput({ + path: 'tooltip.maxWidth', + name: 'Max width', + category, + settings: { + integer: true, + }, + showIf: (options) => false, // config.featureToggles.newVizTooltips && options.tooltip?.mode !== TooltipDisplayMode.None, + }); + + builder.addNumberInput({ + path: 'tooltip.maxHeight', + name: 'Max height', + category, + defaultValue: 600, + settings: { + integer: true, + }, + showIf: (options) => false, // config.featureToggles.newVizTooltips && options.tooltip?.mode !== TooltipDisplayMode.None, }); category = ['Legend']; diff --git a/public/app/plugins/panel/heatmap/panelcfg.cue b/public/app/plugins/panel/heatmap/panelcfg.cue index 687eefdb41ea8..2bba82710ad87 100644 --- a/public/app/plugins/panel/heatmap/panelcfg.cue +++ b/public/app/plugins/panel/heatmap/panelcfg.cue @@ -78,8 +78,10 @@ composableKinds: PanelCfg: lineage: { } @cuetsy(kind="interface") // Controls tooltip options HeatmapTooltip: { - // Controls if the tooltip is shown - show: bool + // Controls how the tooltip is shown + mode: ui.TooltipDisplayMode + maxHeight?: number + maxWidth?: number // Controls if the tooltip shows a histogram of the y-axis values yHistogram?: bool // Controls if the tooltip shows a color scale in header @@ -145,7 +147,7 @@ composableKinds: PanelCfg: lineage: { } // Controls tooltip options tooltip: HeatmapTooltip | *{ - show: true + mode: ui.TooltipDisplayMode & (*"single" | _) yHistogram: false showColorScale: false } diff --git a/public/app/plugins/panel/heatmap/panelcfg.gen.ts b/public/app/plugins/panel/heatmap/panelcfg.gen.ts index 7699ec9e1205d..ce34f829840ff 100644 --- a/public/app/plugins/panel/heatmap/panelcfg.gen.ts +++ b/public/app/plugins/panel/heatmap/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -126,10 +126,12 @@ export interface FilterValueRange { * Controls tooltip options */ export interface HeatmapTooltip { + maxHeight?: number; + maxWidth?: number; /** - * Controls if the tooltip is shown + * Controls how the tooltip is shown */ - show: boolean; + mode: ui.TooltipDisplayMode; /** * Controls if the tooltip shows a color scale in header */ @@ -263,7 +265,7 @@ export const defaultOptions: Partial<Options> = { }, showValue: ui.VisibilityMode.Auto, tooltip: { - show: true, + mode: ui.TooltipDisplayMode.Single, yHistogram: false, showColorScale: false, }, diff --git a/public/app/plugins/panel/heatmap/utils.ts b/public/app/plugins/panel/heatmap/utils.ts index d2997b46dc076..d6c9589811d35 100644 --- a/public/app/plugins/panel/heatmap/utils.ts +++ b/public/app/plugins/panel/heatmap/utils.ts @@ -4,10 +4,6 @@ import uPlot, { Cursor } from 'uplot'; import { DashboardCursorSync, DataFrameType, - DataHoverClearEvent, - DataHoverEvent, - DataHoverPayload, - EventBus, formattedValueToString, getValueFormat, GrafanaTheme2, @@ -60,7 +56,6 @@ export interface HeatmapZoomEvent { interface PrepConfigOpts { dataRef: RefObject<HeatmapData>; theme: GrafanaTheme2; - eventBus: EventBus; onhover?: null | ((evt?: HeatmapHoverEvent | null) => void); onclick?: null | ((evt?: Object) => void); onzoom?: null | ((evt: HeatmapZoomEvent) => void); @@ -82,7 +77,6 @@ export function prepConfig(opts: PrepConfigOpts) { const { dataRef, theme, - eventBus, onhover, onclick, isToolTipOpen, @@ -98,11 +92,9 @@ export function prepConfig(opts: PrepConfigOpts) { } = opts; const xScaleKey = 'x'; - let xScaleUnit = 'time'; let isTime = true; if (dataRef.current?.heatmap?.fields[0].type !== FieldType.time) { - xScaleUnit = dataRef.current?.heatmap?.fields[0].config?.unit ?? 'x'; isTime = false; } @@ -166,14 +158,6 @@ export function prepConfig(opts: PrepConfigOpts) { rect = r; }); - const payload: DataHoverPayload = { - point: { - [xScaleUnit]: null, - }, - data: dataRef.current?.heatmap, - }; - const hoverEvent = new DataHoverEvent(payload); - let pendingOnleave: ReturnType<typeof setTimeout> | 0; onhover && @@ -183,9 +167,6 @@ export function prepConfig(opts: PrepConfigOpts) { const sel = u.cursor.idxs[i]; if (sel != null) { const { left, top } = u.cursor; - payload.rowIndex = sel; - payload.point[xScaleUnit] = u.posToVal(left!, xScaleKey); - eventBus.publish(hoverEvent); if (!isToolTipOpen?.current) { if (pendingOnleave) { @@ -209,9 +190,6 @@ export function prepConfig(opts: PrepConfigOpts) { if (!pendingOnleave) { pendingOnleave = setTimeout(() => { onhover(null); - payload.rowIndex = undefined; - payload.point[xScaleUnit] = null; - eventBus.publish(hoverEvent); }, 100); } } @@ -512,13 +490,13 @@ export function prepConfig(opts: PrepConfigOpts) { dataRef.current?.xLayout === HeatmapCellLayout.le ? -1 : dataRef.current?.xLayout === HeatmapCellLayout.ge - ? 1 - : 0, + ? 1 + : 0, yAlign: ((dataRef.current?.yLayout === HeatmapCellLayout.le ? -1 : dataRef.current?.yLayout === HeatmapCellLayout.ge - ? 1 - : 0) * (yAxisReverse ? -1 : 1)) as -1 | 0 | 1, + ? 1 + : 0) * (yAxisReverse ? -1 : 1)) as -1 | 0 | 1, ySizeDivisor, disp: { fill: { @@ -557,7 +535,8 @@ export function prepConfig(opts: PrepConfigOpts) { }); }, }, - exemplarFillColor + exemplarFillColor, + dataRef.current.yLayout ), theme, scaleKey: '', // facets' scales used (above) @@ -585,6 +564,10 @@ export function prepConfig(opts: PrepConfigOpts) { return hRect && seriesIdx === hRect.sidx ? hRect.didx : null; }, + focus: { + prox: 1e3, + dist: (u, seriesIdx) => (hRect?.sidx === seriesIdx ? 0 : Infinity), + }, points: { fill: 'rgba(255,255,255, 0.3)', bbox: (u, seriesIdx) => { @@ -603,20 +586,7 @@ export function prepConfig(opts: PrepConfigOpts) { if (sync && sync() !== DashboardCursorSync.Off) { cursor.sync = { key: eventsScope, - scales: [xScaleKey, yScaleKey], - filters: { - pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => { - if (x < 0) { - payload.point[xScaleUnit] = null; - eventBus.publish(new DataHoverClearEvent()); - } else { - payload.point[xScaleUnit] = src.posToVal(x, xScaleKey); - eventBus.publish(hoverEvent); - } - - return true; - }, - }, + scales: [xScaleKey, null], }; builder.setSync(); @@ -744,7 +714,7 @@ export function heatmapPathsDense(opts: PathbuilderOpts) { }; } -export function heatmapPathsPoints(opts: PointsBuilderOpts, exemplarColor: string) { +export function heatmapPathsPoints(opts: PointsBuilderOpts, exemplarColor: string, yLayout?: HeatmapCellLayout) { return (u: uPlot, seriesIdx: number) => { uPlot.orient( u, @@ -772,6 +742,8 @@ export function heatmapPathsPoints(opts: PointsBuilderOpts, exemplarColor: strin let fillPaths = [points]; let fillPalette = [exemplarColor ?? 'rgba(255,0,255,0.7)']; + let yShift = yLayout === HeatmapCellLayout.le ? -0.5 : yLayout === HeatmapCellLayout.ge ? 0.5 : 0; + for (let i = 0; i < dataX.length; i++) { let yVal = dataY[i]!; @@ -782,10 +754,7 @@ export function heatmapPathsPoints(opts: PointsBuilderOpts, exemplarColor: strin let isSparseHeatmap = scaleY.distr === 3 && scaleY.log === 2; if (!isSparseHeatmap) { - yVal -= 0.5; // center vertically in bucket (when tiles are le) - // y-randomize vertically to distribute exemplars in same bucket at same time - let randSign = Math.round(Math.random()) * 2 - 1; - yVal += randSign * 0.5 * Math.random(); + yVal += yShift; } let x = valToPosX(dataX[i], scaleX, xDim, xOff); @@ -975,8 +944,8 @@ export const valuesToFills = (values: number[], palette: string[], minValue: num values[i] < minValue ? 0 : values[i] > maxValue - ? paletteSize - 1 - : Math.min(paletteSize - 1, Math.floor((paletteSize * (values[i] - minValue)) / range)); + ? paletteSize - 1 + : Math.min(paletteSize - 1, Math.floor((paletteSize * (values[i] - minValue)) / range)); } return indexedFills; diff --git a/public/app/plugins/panel/histogram/Histogram.tsx b/public/app/plugins/panel/histogram/Histogram.tsx index 29852eb572874..160db11fe3682 100644 --- a/public/app/plugins/panel/histogram/Histogram.tsx +++ b/public/app/plugins/panel/histogram/Histogram.tsx @@ -8,6 +8,7 @@ import { getFieldColorModeForField, getFieldSeriesColor, GrafanaTheme2, + roundDecimals, } from '@grafana/data'; import { histogramBucketSizes, @@ -37,6 +38,7 @@ function incrRoundUp(num: number, incr: number) { export interface HistogramProps extends Themeable2 { options: Options; // used for diff alignedFrame: DataFrame; // This could take HistogramFields + bucketCount?: number; bucketSize: number; width: number; height: number; @@ -48,12 +50,16 @@ export interface HistogramProps extends Themeable2 { export function getBucketSize(frame: DataFrame) { // assumes BucketMin is fields[0] and BucktMax is fields[1] - return frame.fields[0].type === FieldType.string ? 1 : frame.fields[1].values[0] - frame.fields[0].values[0]; + return frame.fields[0].type === FieldType.string + ? 1 + : roundDecimals(frame.fields[1].values[0] - frame.fields[0].values[0], 9); } export function getBucketSize1(frame: DataFrame) { // assumes BucketMin is fields[0] and BucktMax is fields[1] - return frame.fields[0].type === FieldType.string ? 1 : frame.fields[1].values[1] - frame.fields[0].values[1]; + return frame.fields[0].type === FieldType.string + ? 1 + : roundDecimals(frame.fields[1].values[1] - frame.fields[0].values[1], 9); } const prepConfig = (frame: DataFrame, theme: GrafanaTheme2) => { @@ -100,8 +106,8 @@ const prepConfig = (frame: DataFrame, theme: GrafanaTheme2) => { distribution: isOrdinalX ? ScaleDistribution.Ordinal : useLogScale - ? ScaleDistribution.Log - : ScaleDistribution.Linear, + ? ScaleDistribution.Log + : ScaleDistribution.Linear, log: 2, orientation: ScaleOrientation.Horizontal, direction: ScaleDirection.Right, @@ -118,20 +124,13 @@ const prepConfig = (frame: DataFrame, theme: GrafanaTheme2) => { wantedMax = xScaleMax; } - let fullRangeMin = u.data[0][0]; let fullRangeMax = u.data[0][u.data[0].length - 1]; - // snap to bucket divisors... - - if (wantedMax === fullRangeMax) { - wantedMax += bucketSize; - } else { - wantedMax = incrRoundUp(wantedMax, bucketSize); - } - - if (wantedMin > fullRangeMin) { - wantedMin = incrRoundDn(wantedMin, bucketSize); - } + // isOrdinalX is when we have classic histograms, which are LE, ordinal X, and already have 0 dummy bucket prepended + // else we have calculated histograms which are GE and cardinal+linear X, and have no next dummy bucket appended + wantedMin = incrRoundUp(wantedMin, bucketSize); + wantedMax = + !isOrdinalX && wantedMax === fullRangeMax ? wantedMax + bucketSize : incrRoundDn(wantedMax, bucketSize); return [wantedMin, wantedMax]; }, @@ -143,6 +142,7 @@ const prepConfig = (frame: DataFrame, theme: GrafanaTheme2) => { distribution: ScaleDistribution.Linear, orientation: ScaleOrientation.Vertical, direction: ScaleDirection.Up, + softMin: 0, }); const fmt = frame.fields[0].display!; @@ -287,7 +287,9 @@ export class Histogram extends React.Component<HistogramProps, State> { } prepState(props: HistogramProps, withConfig = true) { - let state: State = null as any; + let state: State = { + alignedData: [], + }; const { alignedFrame } = props; if (alignedFrame) { @@ -310,17 +312,20 @@ export class Histogram extends React.Component<HistogramProps, State> { return null; } - return <PlotLegend data={this.props.rawSeries!} config={config} maxHeight="35%" maxWidth="60%" {...legend} />; + const frames = this.props.options.combine ? [this.props.alignedFrame] : this.props.rawSeries!; + + return <PlotLegend data={frames} config={config} maxHeight="35%" maxWidth="60%" {...legend} />; } componentDidUpdate(prevProps: HistogramProps) { - const { structureRev, alignedFrame, bucketSize } = this.props; + const { structureRev, alignedFrame, bucketSize, bucketCount } = this.props; if (alignedFrame !== prevProps.alignedFrame) { let newState = this.prepState(this.props, false); if (newState) { const shouldReconfig = + bucketCount !== prevProps.bucketCount || bucketSize !== prevProps.bucketSize || this.props.options !== prevProps.options || this.state.config === undefined || diff --git a/public/app/plugins/panel/histogram/HistogramPanel.tsx b/public/app/plugins/panel/histogram/HistogramPanel.tsx index 8919927cb3ed7..fddee1297d645 100644 --- a/public/app/plugins/panel/histogram/HistogramPanel.tsx +++ b/public/app/plugins/panel/histogram/HistogramPanel.tsx @@ -65,6 +65,7 @@ export const HistogramPanel = ({ data, options, width, height }: Props) => { height={height} alignedFrame={histogram} bucketSize={bucketSize} + bucketCount={options.bucketCount} > {(config, alignedFrame) => { return null; // <TooltipPlugin data={alignedFrame} config={config} mode={options.tooltip.mode} timeZone={timeZone} />; diff --git a/public/app/plugins/panel/histogram/migrations.test.ts b/public/app/plugins/panel/histogram/migrations.test.ts new file mode 100644 index 0000000000000..a0d2c5517ccb9 --- /dev/null +++ b/public/app/plugins/panel/histogram/migrations.test.ts @@ -0,0 +1,28 @@ +import { FieldConfigSource, PanelModel } from '@grafana/data'; + +import { changeToHistogramPanelMigrationHandler } from './migrations'; + +describe('Histogram migrations', () => { + let prevFieldConfig: FieldConfigSource; + + beforeEach(() => { + prevFieldConfig = { + defaults: {}, + overrides: [], + }; + }); + + it('From old graph', () => { + const old = { + angular: { + xaxis: { + mode: 'histogram', + }, + }, + }; + + const panel = {} as PanelModel; + panel.options = changeToHistogramPanelMigrationHandler(panel, 'graph', old, prevFieldConfig); + expect(panel.options.combine).toBe(true); + }); +}); diff --git a/public/app/plugins/panel/histogram/migrations.ts b/public/app/plugins/panel/histogram/migrations.ts new file mode 100644 index 0000000000000..215d85a9f564a --- /dev/null +++ b/public/app/plugins/panel/histogram/migrations.ts @@ -0,0 +1,30 @@ +import { PanelTypeChangedHandler } from '@grafana/data'; + +/* + * This is called when the panel changes from another panel + */ +export const changeToHistogramPanelMigrationHandler: PanelTypeChangedHandler = ( + panel, + prevPluginId, + prevOptions, + prevFieldConfig +) => { + if (prevPluginId === 'graph') { + const graphOptions: GraphOptions = prevOptions.angular; + + if (graphOptions.xaxis?.mode === 'histogram') { + return { + combine: true, + }; + } + } + + return {}; +}; + +interface GraphOptions { + xaxis: { + mode: 'series' | 'time' | 'histogram'; + values?: string[]; + }; +} diff --git a/public/app/plugins/panel/histogram/module.tsx b/public/app/plugins/panel/histogram/module.tsx index 0b27fa61c1b4c..c2b26f6283f11 100644 --- a/public/app/plugins/panel/histogram/module.tsx +++ b/public/app/plugins/panel/histogram/module.tsx @@ -3,10 +3,12 @@ import { histogramFieldInfo } from '@grafana/data/src/transformations/transforme import { commonOptionsBuilder, graphFieldOptions } from '@grafana/ui'; import { HistogramPanel } from './HistogramPanel'; +import { changeToHistogramPanelMigrationHandler } from './migrations'; import { FieldConfig, Options, defaultFieldConfig, defaultOptions } from './panelcfg.gen'; import { originalDataHasHistogram } from './utils'; export const plugin = new PanelPlugin<Options, FieldConfig>(HistogramPanel) + .setPanelChangeHandler(changeToHistogramPanelMigrationHandler) .setPanelOptions((builder) => { builder .addCustomEditor({ @@ -17,6 +19,16 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(HistogramPanel) editor: () => null, // empty editor showIf: (opts, data) => originalDataHasHistogram(data), }) + .addNumberInput({ + path: 'bucketCount', + name: histogramFieldInfo.bucketCount.name, + description: histogramFieldInfo.bucketCount.description, + settings: { + placeholder: `Default: ${defaultOptions.bucketCount}`, + min: 0, + }, + showIf: (opts, data) => !originalDataHasHistogram(data), + }) .addNumberInput({ path: 'bucketSize', name: histogramFieldInfo.bucketSize.name, @@ -33,10 +45,9 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(HistogramPanel) name: histogramFieldInfo.bucketOffset.name, description: histogramFieldInfo.bucketOffset.description, settings: { - placeholder: '0', + placeholder: `Default: ${defaultOptions.bucketOffset}`, min: 0, }, - defaultValue: defaultOptions.bucketOffset, showIf: (opts, data) => !originalDataHasHistogram(data), }) .addBooleanSwitch({ @@ -54,7 +65,9 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(HistogramPanel) standardOptions: { [FieldConfigProperty.Color]: { settings: { - byValueSupport: true, + byValueSupport: false, + bySeriesSupport: true, + preferThresholdsMode: false, }, defaultValue: { mode: FieldColorModeId.PaletteClassic, diff --git a/public/app/plugins/panel/histogram/panelcfg.cue b/public/app/plugins/panel/histogram/panelcfg.cue index 3ccfb387b6837..c4fe9ce156da7 100644 --- a/public/app/plugins/panel/histogram/panelcfg.cue +++ b/public/app/plugins/panel/histogram/panelcfg.cue @@ -29,10 +29,12 @@ composableKinds: PanelCfg: { common.OptionsWithLegend common.OptionsWithTooltip + //Bucket count (approx) + bucketCount?: int32 & >0 | *30 //Size of each bucket bucketSize?: int32 //Offset buckets by this amount - bucketOffset?: int32 | *0 + bucketOffset?: float32 | *0 //Combines multiple series into a single histogram combine?: bool } @cuetsy(kind="interface") diff --git a/public/app/plugins/panel/histogram/panelcfg.gen.ts b/public/app/plugins/panel/histogram/panelcfg.gen.ts index 93c7b868bc025..68e5497753b64 100644 --- a/public/app/plugins/panel/histogram/panelcfg.gen.ts +++ b/public/app/plugins/panel/histogram/panelcfg.gen.ts @@ -4,13 +4,17 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. import * as common from '@grafana/schema'; export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip { + /** + * Bucket count (approx) + */ + bucketCount?: number; /** * Offset buckets by this amount */ @@ -26,6 +30,7 @@ export interface Options extends common.OptionsWithLegend, common.OptionsWithToo } export const defaultOptions: Partial<Options> = { + bucketCount: 30, bucketOffset: 0, }; diff --git a/public/app/plugins/panel/histogram/plugin.json b/public/app/plugins/panel/histogram/plugin.json index a8f5500d40b5c..f778c4dacf537 100644 --- a/public/app/plugins/panel/histogram/plugin.json +++ b/public/app/plugins/panel/histogram/plugin.json @@ -4,6 +4,8 @@ "id": "histogram", "info": { + "description": "Distribution of values presented as a bar chart.", + "keywords": ["distribution", "bar chart", "frequency", "proportional"], "author": { "name": "Grafana Labs", "url": "https://grafana.com" diff --git a/public/app/plugins/panel/live/LiveChannelEditor.tsx b/public/app/plugins/panel/live/LiveChannelEditor.tsx index 02a49f6974d3e..1252d48debdba 100644 --- a/public/app/plugins/panel/live/LiveChannelEditor.tsx +++ b/public/app/plugins/panel/live/LiveChannelEditor.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import React, { PureComponent } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { LiveChannelScope, @@ -7,9 +7,11 @@ import { SelectableValue, StandardEditorProps, GrafanaTheme2, + parseLiveChannelAddress, } from '@grafana/data'; import { Select, Alert, Label, stylesFactory } from '@grafana/ui'; import { config } from 'app/core/config'; +import { getManagedChannelInfo } from 'app/features/live/info'; import { LivePanelOptions } from './types'; @@ -19,39 +21,54 @@ const scopes: Array<SelectableValue<LiveChannelScope>> = [ { label: 'Grafana', value: LiveChannelScope.Grafana, description: 'Core grafana live features' }, { label: 'Data Sources', value: LiveChannelScope.DataSource, description: 'Data sources with live support' }, { label: 'Plugins', value: LiveChannelScope.Plugin, description: 'Plugins with live support' }, + { label: 'Stream', value: LiveChannelScope.Stream, description: 'data streams (eg, influx style)' }, ]; -interface State { - namespaces: Array<SelectableValue<string>>; - paths: Array<SelectableValue<string>>; -} - -export class LiveChannelEditor extends PureComponent<Props, State> { - state: State = { - namespaces: [], - paths: [], - }; - - async componentDidMount() { - this.updateSelectOptions(); - } - - async componentDidUpdate(oldProps: Props) { - if (this.props.value !== oldProps.value) { - this.updateSelectOptions(); +export function LiveChannelEditor(props: Props) { + const [channels, setChannels] = useState<Array<SelectableValue<string>>>([]); + const [namespaces, paths] = useMemo(() => { + const namespaces: Array<SelectableValue<string>> = []; + const paths: Array<SelectableValue<string>> = []; + const scope = props.value.scope; + const namespace = props.value.namespace; + if (!scope?.length) { + return [namespaces, paths]; } - } + const used: Record<string, boolean> = {}; + + for (let channel of channels) { + const addr = parseLiveChannelAddress(channel.value); + if (!addr || addr.scope !== scope) { + continue; + } + + if (!used[addr.namespace]) { + namespaces.push({ + value: addr.namespace, + label: addr.namespace, + }); + used[addr.namespace] = true; + } + + if (namespace?.length && namespace === addr.namespace) { + paths.push({ + ...channel, + value: addr.path, + }); + } + } + return [namespaces, paths]; + }, [channels, props.value.scope, props.value.namespace]); - async updateSelectOptions() { - this.setState({ - namespaces: [], - paths: [], + useEffect(() => { + getManagedChannelInfo().then((v) => { + setChannels(v.channels); }); - } + }, [props.value.scope]); - onScopeChanged = (v: SelectableValue<LiveChannelScope>) => { + const onScopeChanged = (v: SelectableValue<LiveChannelScope>) => { if (v.value) { - this.props.onChange({ + props.onChange({ scope: v.value, namespace: undefined, path: undefined, @@ -59,73 +76,72 @@ export class LiveChannelEditor extends PureComponent<Props, State> { } }; - onNamespaceChanged = (v: SelectableValue<string>) => { - this.props.onChange({ - scope: this.props.value?.scope, - namespace: v.value, + const onNamespaceChanged = (v: SelectableValue<string>) => { + props.onChange({ + scope: props.value?.scope, + namespace: v?.value, path: undefined, }); }; - onPathChanged = (v: SelectableValue<string>) => { - const { value, onChange } = this.props; + const onPathChanged = (v: SelectableValue<string>) => { + const { value, onChange } = props; onChange({ scope: value.scope, namespace: value.namespace, - path: v.value, + path: v?.value, }); }; - render() { - const { namespaces, paths } = this.state; - const { scope, namespace, path } = this.props.value; - const style = getStyles(config.theme2); + const { scope, namespace, path } = props.value; + const style = getStyles(config.theme2); + + return ( + <> + <Alert title="Grafana Live" severity="info"> + This supports real-time event streams in grafana core. This feature is under heavy development. Expect the + intefaces and structures to change as this becomes more production ready. + </Alert> - return ( - <> - <Alert title="Grafana Live" severity="info"> - This supports real-time event streams in grafana core. This feature is under heavy development. Expect the - intefaces and structures to change as this becomes more production ready. - </Alert> + <div> + <div className={style.dropWrap}> + <Label>Scope</Label> + <Select options={scopes} value={scopes.find((s) => s.value === scope)} onChange={onScopeChanged} /> + </div> - <div> + {scope && ( <div className={style.dropWrap}> - <Label>Scope</Label> - <Select options={scopes} value={scopes.find((s) => s.value === scope)} onChange={this.onScopeChanged} /> + <Label>Namespace</Label> + <Select + options={namespaces} + value={ + namespaces.find((s) => s.value === namespace) ?? + (namespace ? { label: namespace, value: namespace } : undefined) + } + onChange={onNamespaceChanged} + allowCustomValue={true} + backspaceRemovesValue={true} + isClearable={true} + /> </div> + )} - {scope && ( - <div className={style.dropWrap}> - <Label>Namespace</Label> - <Select - options={namespaces} - value={ - namespaces.find((s) => s.value === namespace) ?? - (namespace ? { label: namespace, value: namespace } : undefined) - } - onChange={this.onNamespaceChanged} - allowCustomValue={true} - backspaceRemovesValue={true} - /> - </div> - )} - - {scope && namespace && ( - <div className={style.dropWrap}> - <Label>Path</Label> - <Select - options={paths} - value={findPathOption(paths, path)} - onChange={this.onPathChanged} - allowCustomValue={true} - backspaceRemovesValue={true} - /> - </div> - )} - </div> - </> - ); - } + {scope && namespace && ( + <div className={style.dropWrap}> + <Label>Path</Label> + <Select + options={paths} + value={findPathOption(paths, path)} + onChange={onPathChanged} + allowCustomValue={true} + backspaceRemovesValue={true} + isClearable={true} + /> + </div> + )} + </div> + </> + ); } function findPathOption(paths: Array<SelectableValue<string>>, path?: string): SelectableValue<string> | undefined { diff --git a/public/app/plugins/panel/live/LivePanel.tsx b/public/app/plugins/panel/live/LivePanel.tsx index ed9f0b48ecb3a..7bb237d0a115e 100644 --- a/public/app/plugins/panel/live/LivePanel.tsx +++ b/public/app/plugins/panel/live/LivePanel.tsx @@ -19,11 +19,12 @@ import { StreamingDataFrame, } from '@grafana/data'; import { config, getGrafanaLiveSrv } from '@grafana/runtime'; -import { Alert, stylesFactory, Button, JSONFormatter, CustomScrollbar, CodeEditor } from '@grafana/ui'; +import { Alert, stylesFactory, JSONFormatter, CustomScrollbar } from '@grafana/ui'; import { TablePanel } from '../table/TablePanel'; -import { LivePanelOptions, MessageDisplayMode } from './types'; +import { LivePublish } from './LivePublish'; +import { LivePanelOptions, MessageDisplayMode, MessagePublishMode } from './types'; interface Props extends PanelProps<LivePanelOptions> {} @@ -133,34 +134,6 @@ export class LivePanel extends PureComponent<Props, State> { ); } - onSaveJSON = (text: string) => { - const { options, onOptionsChange } = this.props; - - try { - const json = JSON.parse(text); - onOptionsChange({ ...options, json }); - } catch (err) { - console.log('Error reading JSON', err); - } - }; - - onPublishClicked = async () => { - const { addr } = this.state; - if (!addr) { - console.log('invalid address'); - return; - } - - const data = this.props.options?.json; - if (!data) { - console.log('nothing to publish'); - return; - } - - const rsp = await getGrafanaLiveSrv().publish(addr, data); - console.log('onPublishClicked (response from publish)', rsp); - }; - renderMessage(height: number) { const { options } = this.props; const { message } = this.state; @@ -174,11 +147,11 @@ export class LivePanel extends PureComponent<Props, State> { ); } - if (options.message === MessageDisplayMode.JSON) { + if (options.display === MessageDisplayMode.JSON) { return <JSONFormatter json={message} open={5} />; } - if (options.message === MessageDisplayMode.Auto) { + if (options.display === MessageDisplayMode.Auto) { if (message instanceof StreamingDataFrame) { const data: PanelData = { series: applyFieldOverrides({ @@ -206,20 +179,13 @@ export class LivePanel extends PureComponent<Props, State> { renderPublish(height: number) { const { options } = this.props; return ( - <> - <CodeEditor - height={height - 32} - language="json" - value={options.json ? JSON.stringify(options.json, null, 2) : '{ }'} - onBlur={this.onSaveJSON} - onSave={this.onSaveJSON} - showMiniMap={false} - showLineNumbers={true} - /> - <div style={{ height: 32 }}> - <Button onClick={this.onPublishClicked}>Publish</Button> - </div> - </> + <LivePublish + height={height} + body={options.message} + mode={options.publish ?? MessagePublishMode.JSON} + onSave={(message) => this.props.onOptionsChange({ ...options, message })} + addr={this.state.addr} + /> ); } @@ -239,12 +205,13 @@ export class LivePanel extends PureComponent<Props, State> { renderBody() { const { status } = this.state; const { options, height } = this.props; + const publish = options.publish === MessagePublishMode.JSON || options.publish === MessagePublishMode.Influx; - if (options.publish) { - // Only the publish form - if (options.message === MessageDisplayMode.None) { - return <div>{this.renderPublish(height)}</div>; + if (publish) { + if (options.display === MessageDisplayMode.None) { + return this.renderPublish(height); } + // Both message and publish const halfHeight = height / 2; return ( @@ -258,7 +225,7 @@ export class LivePanel extends PureComponent<Props, State> { </div> ); } - if (options.message === MessageDisplayMode.None) { + if (options.display === MessageDisplayMode.None) { return <pre>{JSON.stringify(status)}</pre>; } diff --git a/public/app/plugins/panel/live/LivePublish.tsx b/public/app/plugins/panel/live/LivePublish.tsx new file mode 100644 index 0000000000000..6d7e8b148ff1e --- /dev/null +++ b/public/app/plugins/panel/live/LivePublish.tsx @@ -0,0 +1,67 @@ +import React, { useMemo } from 'react'; + +import { LiveChannelAddress, isValidLiveChannelAddress } from '@grafana/data'; +import { getBackendSrv, getGrafanaLiveSrv } from '@grafana/runtime'; +import { CodeEditor, Button } from '@grafana/ui'; + +import { MessagePublishMode } from './types'; + +interface Props { + height: number; + addr?: LiveChannelAddress; + mode: MessagePublishMode; + body?: string | object; + onSave: (v: string | object) => void; +} + +export function LivePublish({ height, mode, body, addr, onSave }: Props) { + const txt = useMemo(() => { + if (mode === MessagePublishMode.JSON) { + return body ? JSON.stringify(body, null, 2) : '{ }'; + } + return body == null ? '' : `${body}`; + }, [mode, body]); + + const doSave = (v: string) => { + if (mode === MessagePublishMode.JSON) { + onSave(JSON.parse(v)); + } else { + onSave(v); + } + }; + + const onPublishClicked = async () => { + if (mode === MessagePublishMode.Influx) { + if (addr?.scope !== 'stream') { + alert('expected stream scope!'); + return; + } + return getBackendSrv().post(`api/live/push/${addr.namespace}`, body); + } + + if (!isValidLiveChannelAddress(addr)) { + alert('invalid address'); + return; + } + + const rsp = await getGrafanaLiveSrv().publish(addr, body); + console.log('onPublishClicked (response from publish)', rsp); + }; + + return ( + <> + <CodeEditor + height={height - 32} + language={mode === MessagePublishMode.JSON ? 'json' : 'text'} + value={txt} + onBlur={doSave} + onSave={doSave} + showMiniMap={false} + showLineNumbers={true} + /> + <div style={{ height: 32 }}> + <Button onClick={onPublishClicked}>Publish</Button> + </div> + </> + ); +} diff --git a/public/app/plugins/panel/live/module.tsx b/public/app/plugins/panel/live/module.tsx index 596083f450cfe..e77d6c3fa18d6 100644 --- a/public/app/plugins/panel/live/module.tsx +++ b/public/app/plugins/panel/live/module.tsx @@ -2,7 +2,7 @@ import { PanelPlugin } from '@grafana/data'; import { LiveChannelEditor } from './LiveChannelEditor'; import { LivePanel } from './LivePanel'; -import { LivePanelOptions, MessageDisplayMode } from './types'; +import { LivePanelOptions, MessageDisplayMode, MessagePublishMode } from './types'; export const plugin = new PanelPlugin<LivePanelOptions>(LivePanel).setPanelOptions((builder) => { builder.addCustomEditor({ @@ -16,7 +16,7 @@ export const plugin = new PanelPlugin<LivePanelOptions>(LivePanel).setPanelOptio builder .addRadio({ - path: 'message', + path: 'display', name: 'Show Message', description: 'Display the last message received on this channel', settings: { @@ -29,10 +29,17 @@ export const plugin = new PanelPlugin<LivePanelOptions>(LivePanel).setPanelOptio }, defaultValue: MessageDisplayMode.JSON, }) - .addBooleanSwitch({ + .addRadio({ path: 'publish', - name: 'Show Publish', + name: 'Publish', description: 'Display a form to publish values', - defaultValue: false, + settings: { + options: [ + { value: MessagePublishMode.None, label: 'None' }, + { value: MessagePublishMode.JSON, label: 'JSON' }, + { value: MessagePublishMode.Influx, label: 'Influx' }, + ], + }, + defaultValue: MessagePublishMode.None, }); }); diff --git a/public/app/plugins/panel/live/types.ts b/public/app/plugins/panel/live/types.ts index a4aba607542de..abf0ce68bed8f 100644 --- a/public/app/plugins/panel/live/types.ts +++ b/public/app/plugins/panel/live/types.ts @@ -7,9 +7,15 @@ export enum MessageDisplayMode { None = 'none', // do not display } +export enum MessagePublishMode { + None = 'none', // do not display + JSON = 'json', // formatted JSON + Influx = 'influx', // influx line protocol +} + export interface LivePanelOptions { channel?: LiveChannelAddress; - message?: MessageDisplayMode; - publish?: boolean; - json?: any; // object + display?: MessageDisplayMode; + publish?: MessagePublishMode; + message?: string | object; // likely JSON } diff --git a/public/app/plugins/panel/logs/LogsPanel.test.tsx b/public/app/plugins/panel/logs/LogsPanel.test.tsx index 0581c91629e9c..7c3fdbf0726c1 100644 --- a/public/app/plugins/panel/logs/LogsPanel.test.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.test.tsx @@ -1,11 +1,52 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React, { ComponentProps } from 'react'; +import { DatasourceSrvMock, MockDataSourceApi } from 'test/mocks/datasource_srv'; -import { LoadingState, createDataFrame, FieldType, LogsSortOrder } from '@grafana/data'; +import { + LoadingState, + createDataFrame, + FieldType, + LogsSortOrder, + CoreApp, + getDefaultTimeRange, + LogsDedupStrategy, + EventBusSrv, +} from '@grafana/data'; +import * as styles from 'app/features/logs/components/getLogRowStyles'; +import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal'; import { LogsPanel } from './LogsPanel'; type LogsPanelProps = ComponentProps<typeof LogsPanel>; +type LogRowContextModalProps = ComponentProps<typeof LogRowContextModal>; + +const logRowContextModalMock = jest.fn().mockReturnValue(<div>LogRowContextModal</div>); +jest.mock('app/features/logs/components/log-context/LogRowContextModal', () => ({ + LogRowContextModal: (props: LogRowContextModalProps) => logRowContextModalMock(props), +})); + +const defaultDs = new MockDataSourceApi('default datasource', { data: ['default data'] }); +const noShowContextDs = new MockDataSourceApi('no-show-context'); +const showContextDs = new MockDataSourceApi('show-context') as MockDataSourceApi & { getLogRowContext: jest.Mock }; + +const datasourceSrv = new DatasourceSrvMock(defaultDs, { + 'no-show-context': noShowContextDs, + 'show-context': showContextDs, +}); +const getDataSourceSrvMock = jest.fn().mockReturnValue(datasourceSrv); +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => getDataSourceSrvMock(), +})); + +const hasLogsContextSupport = jest.fn().mockImplementation((ds) => { + return ds.name === 'show-context'; +}); +jest.mock('@grafana/data', () => ({ + ...jest.requireActual('@grafana/data'), + hasLogsContextSupport: (ds: MockDataSourceApi) => hasLogsContextSupport(ds), +})); describe('LogsPanel', () => { describe('when returned series include common labels', () => { @@ -33,35 +74,37 @@ describe('LogsPanel', () => { }), ]; - it('shows common labels when showCommonLabels is set to true', () => { + it('shows common labels when showCommonLabels is set to true', async () => { setup({ data: { series: seriesWithCommonLabels }, options: { showCommonLabels: true } }); - expect(screen.getByText(/common labels:/i)).toBeInTheDocument(); - expect(screen.getByText(/common_app/i)).toBeInTheDocument(); - expect(screen.getByText(/common_job/i)).toBeInTheDocument(); + expect(await screen.findByText(/common labels:/i)).toBeInTheDocument(); + expect(await screen.findByText(/common_app/i)).toBeInTheDocument(); + expect(await screen.findByText(/common_job/i)).toBeInTheDocument(); }); - it('shows common labels on top when descending sort order', () => { + it('shows common labels on top when descending sort order', async () => { const { container } = setup({ data: { series: seriesWithCommonLabels }, options: { showCommonLabels: true, sortOrder: LogsSortOrder.Descending }, }); - + expect(await screen.findByText(/common labels:/i)).toBeInTheDocument(); expect(container.firstChild?.childNodes[0].textContent).toMatch(/^Common labels:common_appcommon_job/); }); - it('shows common labels on bottom when ascending sort order', () => { + it('shows common labels on bottom when ascending sort order', async () => { const { container } = setup({ data: { series: seriesWithCommonLabels }, options: { showCommonLabels: true, sortOrder: LogsSortOrder.Ascending }, }); - + expect(await screen.findByText(/common labels:/i)).toBeInTheDocument(); expect(container.firstChild?.childNodes[0].textContent).toMatch(/Common labels:common_appcommon_job$/); }); - it('does not show common labels when showCommonLabels is set to false', () => { + it('does not show common labels when showCommonLabels is set to false', async () => { setup({ data: { series: seriesWithCommonLabels }, options: { showCommonLabels: false } }); - expect(screen.queryByText(/common labels:/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/common_app/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/common_job/i)).not.toBeInTheDocument(); + await waitFor(async () => { + expect(screen.queryByText(/common labels:/i)).toBeNull(); + expect(screen.queryByText(/common_app/i)).toBeNull(); + expect(screen.queryByText(/common_job/i)).toBeNull(); + }); }); }); describe('when returned series does not include common labels', () => { @@ -84,26 +127,211 @@ describe('LogsPanel', () => { ], }), ]; - it('shows (no common labels) when showCommonLabels is set to true', () => { + it('shows (no common labels) when showCommonLabels is set to true', async () => { setup({ data: { series: seriesWithoutCommonLabels }, options: { showCommonLabels: true } }); - expect(screen.getByText(/common labels:/i)).toBeInTheDocument(); - expect(screen.getByText(/(no common labels)/i)).toBeInTheDocument(); + + expect(await screen.findByText(/common labels:/i)).toBeInTheDocument(); + expect(await screen.findByText(/(no common labels)/i)).toBeInTheDocument(); }); - it('does not show common labels when showCommonLabels is set to false', () => { + it('does not show common labels when showCommonLabels is set to false', async () => { setup({ data: { series: seriesWithoutCommonLabels }, options: { showCommonLabels: false } }); - expect(screen.queryByText(/common labels:/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/(no common labels)/i)).not.toBeInTheDocument(); + await waitFor(async () => { + expect(screen.queryByText(/common labels:/i)).toBeNull(); + expect(screen.queryByText(/(no common labels)/i)).toBeNull(); + }); + }); + }); + + describe('log context', () => { + const series = [ + createDataFrame({ + refId: 'A', + fields: [ + { + name: 'time', + type: FieldType.time, + values: ['2019-04-26T09:28:11.352440161Z', '2019-04-26T14:42:50.991981292Z'], + }, + { + name: 'message', + type: FieldType.string, + values: ['logline text'], + labels: { + app: 'common_app', + job: 'common_job', + }, + }, + ], + }), + ]; + + beforeEach(() => { + showContextDs.getLogRowContext = jest.fn().mockImplementation(() => {}); + }); + + it('should not show the toggle if the datasource does not support show context', async () => { + setup({ + data: { + series, + options: { showCommonLabels: false }, + request: { + app: CoreApp.Dashboard, + targets: [{ refId: 'A', datasource: { uid: 'no-show-context' } }], + }, + }, + }); + + await waitFor(async () => { + await userEvent.hover(screen.getByText(/logline text/i)); + expect(screen.queryByLabelText(/show context/i)).toBeNull(); + }); + }); + + it('should show the toggle if the datasource does support show context', async () => { + setup({ + data: { + series, + options: { showCommonLabels: false }, + request: { + app: CoreApp.Dashboard, + targets: [{ refId: 'A', datasource: { uid: 'show-context' } }], + }, + }, + }); + + await waitFor(async () => { + await userEvent.hover(screen.getByText(/logline text/i)); + expect(screen.getByLabelText(/show context/i)).toBeInTheDocument(); + }); + }); + + it('should not show the toggle if the datasource does support show context but the app is not Dashboard', async () => { + setup({ + data: { + series, + options: { showCommonLabels: false }, + request: { + app: CoreApp.CloudAlerting, + targets: [{ refId: 'A', datasource: { uid: 'show-context' } }], + }, + }, + }); + + await waitFor(async () => { + await userEvent.hover(screen.getByText(/logline text/i)); + expect(screen.queryByLabelText(/show context/i)).toBeNull(); + }); + }); + + it('should render the mocked `LogRowContextModal` after click', async () => { + setup({ + data: { + series, + options: { showCommonLabels: false }, + request: { + app: CoreApp.Dashboard, + targets: [{ refId: 'A', datasource: { uid: 'show-context' } }], + }, + }, + }); + await waitFor(async () => { + await userEvent.hover(screen.getByText(/logline text/i)); + await userEvent.click(screen.getByLabelText(/show context/i)); + expect(screen.getByText(/LogRowContextModal/i)).toBeInTheDocument(); + }); + }); + + it('should call `getLogRowContext` if the user clicks the show context toggle', async () => { + setup({ + data: { + series, + options: { showCommonLabels: false }, + request: { + app: CoreApp.Dashboard, + targets: [{ refId: 'A', datasource: { uid: 'show-context' } }], + }, + }, + }); + await waitFor(async () => { + await userEvent.hover(screen.getByText(/logline text/i)); + await userEvent.click(screen.getByLabelText(/show context/i)); + + const getRowContextCb = logRowContextModalMock.mock.calls[0][0].getRowContext; + getRowContextCb(); + expect(showContextDs.getLogRowContext).toBeCalled(); + }); + }); + }); + + describe('Performance regressions', () => { + const series = [ + createDataFrame({ + refId: 'A', + fields: [ + { + name: 'time', + type: FieldType.time, + values: ['2019-04-26T09:28:11.352440161Z'], + }, + { + name: 'message', + type: FieldType.string, + values: ['logline text'], + labels: { + app: 'common_app', + job: 'common_job', + }, + }, + ], + }), + ]; + + beforeEach(() => { + /** + * For the lack of a better option, we spy on getLogRowStyles calls to count re-renders. + */ + jest.spyOn(styles, 'getLogRowStyles'); + jest.mocked(styles.getLogRowStyles).mockClear(); + }); + + it('does not rerender without changes', async () => { + const { rerender, props } = setup({ + data: { + series, + }, + }); + + expect(await screen.findByRole('row')).toBeInTheDocument(); + + rerender(<LogsPanel {...props} />); + + expect(await screen.findByRole('row')).toBeInTheDocument(); + expect(styles.getLogRowStyles).toHaveBeenCalledTimes(3); + }); + + it('rerenders when prop changes', async () => { + const { rerender, props } = setup({ + data: { + series, + }, + }); + + expect(await screen.findByRole('row')).toBeInTheDocument(); + + rerender(<LogsPanel {...props} data={{ ...props.data, series: [...series] }} />); + + expect(await screen.findByRole('row')).toBeInTheDocument(); + expect(jest.mocked(styles.getLogRowStyles).mock.calls.length).toBeGreaterThan(3); }); }); }); const setup = (propsOverrides?: {}) => { - const props = { + const props: LogsPanelProps = { data: { error: undefined, request: { panelId: 4, - dashboardId: 123, app: 'dashboard', requestId: 'A', timezone: 'browser', @@ -111,17 +339,44 @@ const setup = (propsOverrides?: {}) => { intervalMs: 30000, maxDataPoints: 823, targets: [], - range: {}, + range: getDefaultTimeRange(), + scopedVars: {}, + startTime: 1, }, series: [], state: LoadingState.Done, + timeRange: getDefaultTimeRange(), }, timeZone: 'utc', - options: {}, + timeRange: getDefaultTimeRange(), + options: { + showLabels: false, + showTime: false, + wrapLogMessage: false, + showCommonLabels: false, + prettifyLogMessage: false, + sortOrder: LogsSortOrder.Descending, + dedupStrategy: LogsDedupStrategy.none, + enableLogDetails: false, + showLogContextToggle: false, + }, title: 'Logs panel', id: 1, + transparent: false, + width: 400, + height: 100, + renderCounter: 0, + fieldConfig: { + defaults: {}, + overrides: [], + }, + eventBus: new EventBusSrv(), + onOptionsChange: jest.fn(), + onFieldConfigChange: jest.fn(), + replaceVariables: jest.fn(), + onChangeTimeRange: jest.fn(), ...propsOverrides, - } as unknown as LogsPanelProps; + }; - return render(<LogsPanel {...props} />); + return { ...render(<LogsPanel {...props} />), props }; }; diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 31b22f07680a3..8069088e533cf 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -1,28 +1,43 @@ import { css, cx } from '@emotion/css'; -import React, { useCallback, useMemo, useRef, useLayoutEffect, useState } from 'react'; +import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { - PanelProps, + CoreApp, + DataHoverClearEvent, + DataHoverEvent, + DataQueryResponse, Field, - Labels, GrafanaTheme2, - LogsSortOrder, + hasLogsContextSupport, + hasLogsContextUiSupport, + Labels, + LogRowContextOptions, LogRowModel, - DataHoverClearEvent, - DataHoverEvent, - CoreApp, + LogsSortOrder, + PanelProps, + TimeRange, + toUtc, + urlUtil, } from '@grafana/data'; -import { CustomScrollbar, useStyles2, usePanelContext } from '@grafana/ui'; +import { CustomScrollbar, usePanelContext, useStyles2 } from '@grafana/ui'; import { getFieldLinksForExplore } from 'app/features/explore/utils/links'; +import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal'; import { PanelDataErrorView } from 'app/features/panel/components/PanelDataErrorView'; +import { createAndCopyShortLink } from '../../../core/utils/shortLinks'; import { LogLabels } from '../../../features/logs/components/LogLabels'; import { LogRows } from '../../../features/logs/components/LogRows'; -import { dataFrameToLogsModel, dedupLogRows, COMMON_LABELS } from '../../../features/logs/logsModel'; +import { COMMON_LABELS, dataFrameToLogsModel, dedupLogRows } from '../../../features/logs/logsModel'; import { Options } from './types'; +import { useDatasourcesFromTargets } from './useDatasourcesFromTargets'; interface LogsPanelProps extends PanelProps<Options> {} +interface LogsPermalinkUrlState { + logs?: { + id?: string; + }; +} export const LogsPanel = ({ data, @@ -37,14 +52,19 @@ export const LogsPanel = ({ sortOrder, dedupStrategy, enableLogDetails, + showLogContextToggle, }, - title, id, }: LogsPanelProps) => { const isAscending = sortOrder === LogsSortOrder.Ascending; const style = useStyles2(getStyles); const [scrollTop, setScrollTop] = useState(0); const logsContainerRef = useRef<HTMLDivElement>(null); + const [contextRow, setContextRow] = useState<LogRowModel | null>(null); + const timeRange = data.timeRange; + const dataSourcesMap = useDatasourcesFromTargets(data.request?.targets); + const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null); + let closeCallback = useRef<() => void>(); const { eventBus } = usePanelContext(); const onLogRowHover = useCallback( @@ -64,6 +84,101 @@ export const LogsPanel = ({ [eventBus] ); + const onCloseContext = useCallback(() => { + setContextRow(null); + if (closeCallback.current) { + closeCallback.current(); + } + }, [closeCallback]); + + const onOpenContext = useCallback( + (row: LogRowModel, onClose: () => void) => { + setContextRow(row); + closeCallback.current = onClose; + }, + [closeCallback] + ); + + const onPermalinkClick = useCallback( + async (row: LogRowModel) => { + return await copyDashboardUrl(row, timeRange); + }, + [timeRange] + ); + + const showContextToggle = useCallback( + (row: LogRowModel): boolean => { + if ( + !row.dataFrame.refId || + !dataSourcesMap || + (!showLogContextToggle && + data.request?.app !== CoreApp.Dashboard && + data.request?.app !== CoreApp.PanelEditor && + data.request?.app !== CoreApp.PanelViewer) + ) { + return false; + } + + const dataSource = dataSourcesMap.get(row.dataFrame.refId); + return hasLogsContextSupport(dataSource); + }, + [dataSourcesMap, showLogContextToggle, data.request?.app] + ); + + const showPermaLink = useCallback(() => { + return !( + data.request?.app !== CoreApp.Dashboard && + data.request?.app !== CoreApp.PanelEditor && + data.request?.app !== CoreApp.PanelViewer + ); + }, [data.request?.app]); + + const getLogRowContext = useCallback( + async (row: LogRowModel, origRow: LogRowModel, options: LogRowContextOptions): Promise<DataQueryResponse> => { + if (!origRow.dataFrame.refId || !dataSourcesMap) { + return Promise.resolve({ data: [] }); + } + + const query = data.request?.targets[0]; + if (!query) { + return Promise.resolve({ data: [] }); + } + + const dataSource = dataSourcesMap.get(origRow.dataFrame.refId); + if (!hasLogsContextSupport(dataSource)) { + return Promise.resolve({ data: [] }); + } + + return dataSource.getLogRowContext(row, options, query); + }, + [data.request?.targets, dataSourcesMap] + ); + + const getLogRowContextUi = useCallback( + (origRow: LogRowModel, runContextQuery?: () => void): React.ReactNode => { + if (!origRow.dataFrame.refId || !dataSourcesMap) { + return <></>; + } + + const query = data.request?.targets[0]; + if (!query) { + return <></>; + } + + const dataSource = dataSourcesMap.get(origRow.dataFrame.refId); + if (!hasLogsContextUiSupport(dataSource)) { + return <></>; + } + + if (!dataSource.getLogRowContextUi) { + return <></>; + } + + return dataSource.getLogRowContextUi(origRow, runContextQuery, query); + }, + [data.request?.targets, dataSourcesMap] + ); + // Important to memoize stuff here, as panel rerenders a lot for example when resizing. const [logRows, deduplicatedRows, commonLabels] = useMemo(() => { const logs = data @@ -90,6 +205,19 @@ export const LogsPanel = ({ [data] ); + /** + * Scrolls the given row into view. + */ + const scrollIntoView = useCallback( + (row: HTMLElement) => { + scrollElement?.scrollTo({ + top: row.offsetTop, + behavior: 'smooth', + }); + }, + [scrollElement] + ); + if (!data || logRows.length === 0) { return <PanelDataErrorView fieldConfig={fieldConfig} panelId={id} data={data} needsStringField />; } @@ -102,28 +230,51 @@ export const LogsPanel = ({ ); return ( - <CustomScrollbar autoHide scrollTop={scrollTop}> - <div className={style.container} ref={logsContainerRef}> - {showCommonLabels && !isAscending && renderCommonLabels()} - <LogRows - logRows={logRows} - deduplicatedRows={deduplicatedRows} - dedupStrategy={dedupStrategy} - showLabels={showLabels} - showTime={showTime} - wrapLogMessage={wrapLogMessage} - prettifyLogMessage={prettifyLogMessage} - timeZone={timeZone} - getFieldLinks={getFieldLinks} + <> + {contextRow && ( + <LogRowContextModal + open={contextRow !== null} + row={contextRow} + onClose={onCloseContext} + getRowContext={(row, options) => getLogRowContext(row, contextRow, options)} logsSortOrder={sortOrder} - enableLogDetails={enableLogDetails} - previewLimit={isAscending ? logRows.length : undefined} - onLogRowHover={onLogRowHover} - app={CoreApp.Dashboard} + timeZone={timeZone} + getLogRowContextUi={getLogRowContextUi} /> - {showCommonLabels && isAscending && renderCommonLabels()} - </div> - </CustomScrollbar> + )} + <CustomScrollbar + autoHide + scrollTop={scrollTop} + scrollRefCallback={(scrollElement) => setScrollElement(scrollElement)} + > + <div className={style.container} ref={logsContainerRef}> + {showCommonLabels && !isAscending && renderCommonLabels()} + <LogRows + containerRendered={logsContainerRef.current !== null} + scrollIntoView={scrollIntoView} + permalinkedRowId={getLogsPanelState()?.logs?.id ?? undefined} + onPermalinkClick={showPermaLink() ? onPermalinkClick : undefined} + logRows={logRows} + showContextToggle={showContextToggle} + deduplicatedRows={deduplicatedRows} + dedupStrategy={dedupStrategy} + showLabels={showLabels} + showTime={showTime} + wrapLogMessage={wrapLogMessage} + prettifyLogMessage={prettifyLogMessage} + timeZone={timeZone} + getFieldLinks={getFieldLinks} + logsSortOrder={sortOrder} + enableLogDetails={enableLogDetails} + previewLimit={isAscending ? logRows.length : undefined} + onLogRowHover={onLogRowHover} + app={CoreApp.Dashboard} + onOpenContext={onOpenContext} + /> + {showCommonLabels && isAscending && renderCommonLabels()} + </div> + </CustomScrollbar> + </> ); }; @@ -145,3 +296,49 @@ const getStyles = (theme: GrafanaTheme2) => ({ fontWeight: theme.typography.fontWeightMedium, }), }); + +function getLogsPanelState(): LogsPermalinkUrlState | undefined { + const urlParams = urlUtil.getUrlSearchParams(); + const panelStateEncoded = urlParams?.panelState; + if ( + panelStateEncoded && + Array.isArray(panelStateEncoded) && + panelStateEncoded?.length > 0 && + typeof panelStateEncoded[0] === 'string' + ) { + try { + return JSON.parse(panelStateEncoded[0]); + } catch (e) { + console.error('error parsing logsPanelState', e); + } + } + + return undefined; +} + +async function copyDashboardUrl(row: LogRowModel, timeRange: TimeRange) { + // this is an extra check, to be sure that we are not + // creating permalinks for logs without an id-field. + // normally it should never happen, because we do not + // display the permalink button in such cases. + if (row.rowId === undefined || !row.dataFrame.refId) { + return; + } + + // get panel state, add log-row-id + const panelState = { + logs: { id: row.uid }, + }; + + // Grab the current dashboard URL + const currentURL = new URL(window.location.href); + + // Add panel state containing the rowId, and absolute time range from the current query, but leave everything else the same, if the user is in edit mode when grabbing the link, that's what will be linked to, etc. + currentURL.searchParams.set('panelState', JSON.stringify(panelState)); + currentURL.searchParams.set('from', toUtc(timeRange.from).valueOf().toString(10)); + currentURL.searchParams.set('to', toUtc(timeRange.to).valueOf().toString(10)); + + await createAndCopyShortLink(currentURL.toString()); + + return Promise.resolve(); +} diff --git a/public/app/plugins/panel/logs/panelcfg.cue b/public/app/plugins/panel/logs/panelcfg.cue index 5cac83ca9e6ef..fd3719ec65c76 100644 --- a/public/app/plugins/panel/logs/panelcfg.cue +++ b/public/app/plugins/panel/logs/panelcfg.cue @@ -26,14 +26,15 @@ composableKinds: PanelCfg: { version: [0, 0] schema: { Options: { - showLabels: bool - showCommonLabels: bool - showTime: bool - wrapLogMessage: bool - prettifyLogMessage: bool - enableLogDetails: bool - sortOrder: common.LogsSortOrder - dedupStrategy: common.LogsDedupStrategy + showLabels: bool + showCommonLabels: bool + showTime: bool + showLogContextToggle: bool + wrapLogMessage: bool + prettifyLogMessage: bool + enableLogDetails: bool + sortOrder: common.LogsSortOrder + dedupStrategy: common.LogsDedupStrategy } @cuetsy(kind="interface") } }] diff --git a/public/app/plugins/panel/logs/panelcfg.gen.ts b/public/app/plugins/panel/logs/panelcfg.gen.ts index decbc6140a08c..62e8fc956ed55 100644 --- a/public/app/plugins/panel/logs/panelcfg.gen.ts +++ b/public/app/plugins/panel/logs/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -16,6 +16,7 @@ export interface Options { prettifyLogMessage: boolean; showCommonLabels: boolean; showLabels: boolean; + showLogContextToggle: boolean; showTime: boolean; sortOrder: common.LogsSortOrder; wrapLogMessage: boolean; diff --git a/public/app/plugins/panel/logs/useDatasourcesFromTargets.test.ts b/public/app/plugins/panel/logs/useDatasourcesFromTargets.test.ts new file mode 100644 index 0000000000000..6c8f820fcb248 --- /dev/null +++ b/public/app/plugins/panel/logs/useDatasourcesFromTargets.test.ts @@ -0,0 +1,44 @@ +// CustomHook.test.js +import { renderHook } from '@testing-library/react-hooks'; +import { MockDataSourceApi, DatasourceSrvMock } from 'test/mocks/datasource_srv'; + +import { useDatasourcesFromTargets } from './useDatasourcesFromTargets'; // Update the path accordingly + +const defaultDs = new MockDataSourceApi('default datasource', { data: ['default data'] }); +const ds1 = new MockDataSourceApi('dataSource1'); +const ds2 = new MockDataSourceApi('dataSource2') as MockDataSourceApi; + +const datasourceSrv = new DatasourceSrvMock(defaultDs, { + dataSource1: ds1, + dataSource2: ds2, +}); +const getDataSourceSrvMock = jest.fn().mockReturnValue(datasourceSrv); +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => getDataSourceSrvMock(), +})); + +describe('useDatasourcesFromTargets', () => { + it('returns an empty map when targets are not provided', async () => { + const { result, waitForNextUpdate } = renderHook(() => useDatasourcesFromTargets(undefined)); + + await waitForNextUpdate(); + + expect(result.current.size).toBe(0); + }); + + it('fetches and returns the data sources map', async () => { + const mockTargets = [ + { refId: '1', datasource: { uid: 'dataSource1' } }, + { refId: '2', datasource: { uid: 'dataSource2' } }, + ]; + + const { result, waitForNextUpdate } = renderHook(() => useDatasourcesFromTargets(mockTargets)); + + await waitForNextUpdate(); + + expect(result.current.size).toBe(2); + expect(result.current.get('1')).toEqual(ds1); + expect(result.current.get('2')).toEqual(ds2); + }); +}); diff --git a/public/app/plugins/panel/logs/useDatasourcesFromTargets.ts b/public/app/plugins/panel/logs/useDatasourcesFromTargets.ts new file mode 100644 index 0000000000000..91d72a4466f1a --- /dev/null +++ b/public/app/plugins/panel/logs/useDatasourcesFromTargets.ts @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { useAsync } from 'react-use'; + +import { DataSourceApi } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; + +export const useDatasourcesFromTargets = (targets: DataQuery[] | undefined): Map<string, DataSourceApi> => { + const [dataSourcesMap, setDataSourcesMap] = useState(new Map<string, DataSourceApi>()); + + useAsync(async () => { + if (!targets) { + setDataSourcesMap(new Map<string, DataSourceApi>()); + return; + } + + const raw = await Promise.all( + targets + .filter((target) => !!target.datasource?.uid) + .map((target) => + getDataSourceSrv() + .get(target.datasource?.uid) + .then((ds) => ({ key: target.refId, ds })) + ) + ); + + setDataSourcesMap(new Map<string, DataSourceApi>(raw.map(({ key, ds }) => [key, ds]))); + }, [targets]); + + return dataSourcesMap; +}; diff --git a/public/app/plugins/panel/news/component/News.tsx b/public/app/plugins/panel/news/component/News.tsx index 39c95ff82d1b9..c47f1d1ade7f2 100644 --- a/public/app/plugins/panel/news/component/News.tsx +++ b/public/app/plugins/panel/news/component/News.tsx @@ -19,9 +19,10 @@ function NewsComponent({ width, showImage, data, index }: NewsItemProps) { const styles = useStyles2(getStyles); const useWideLayout = width > 600; const newsItem = data.get(index); + const titleId = encodeURI(newsItem.title); return ( - <article className={cx(styles.item, useWideLayout && styles.itemWide)}> + <article aria-labelledby={titleId} className={cx(styles.item, useWideLayout && styles.itemWide)}> {showImage && newsItem.ogImage && ( <a tabIndex={-1} @@ -39,7 +40,9 @@ function NewsComponent({ width, showImage, data, index }: NewsItemProps) { {dateTimeFormat(newsItem.date, { format: 'MMM DD' })}{' '} </time> <a className={styles.link} href={textUtil.sanitizeUrl(newsItem.link)} target="_blank" rel="noopener noreferrer"> - <h3 className={styles.title}>{newsItem.title}</h3> + <h3 className={styles.title} id={titleId}> + {newsItem.title} + </h3> </a> <div className={styles.content} dangerouslySetInnerHTML={{ __html: textUtil.sanitize(newsItem.content) }} /> </div> diff --git a/public/app/plugins/panel/news/panelcfg.gen.ts b/public/app/plugins/panel/news/panelcfg.gen.ts index 8475aaa2f4ea9..d9e19537df4b7 100644 --- a/public/app/plugins/panel/news/panelcfg.gen.ts +++ b/public/app/plugins/panel/news/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/nodeGraph/Edge.tsx b/public/app/plugins/panel/nodeGraph/Edge.tsx index b31c49be490cc..9a9459e9c192b 100644 --- a/public/app/plugins/panel/nodeGraph/Edge.tsx +++ b/public/app/plugins/panel/nodeGraph/Edge.tsx @@ -5,7 +5,7 @@ import { computeNodeCircumferenceStrokeWidth, nodeR } from './Node'; import { EdgeDatum, NodeDatum } from './types'; import { shortenLine } from './utils'; -export const highlightedEdgeColor = '#a00'; +export const defaultHighlightedEdgeColor = '#a00'; export const defaultEdgeColor = '#999'; interface Props { @@ -41,12 +41,18 @@ export const Edge = memo(function Edge(props: Props) { arrowHeadHeight ); + const edgeColor = edge.color || defaultEdgeColor; + + // @deprecated -- until 'highlighted' is removed we'll prioritize 'color' + // in case both are provided + const highlightedEdgeColor = edge.color || defaultHighlightedEdgeColor; + const markerId = `triangle-${edge.id}`; const coloredMarkerId = `triangle-colored-${edge.id}`; return ( <> - <EdgeArrowMarker id={markerId} headHeight={arrowHeadHeight} /> + <EdgeArrowMarker id={markerId} fill={edgeColor} headHeight={arrowHeadHeight} /> <EdgeArrowMarker id={coloredMarkerId} fill={highlightedEdgeColor} headHeight={arrowHeadHeight} /> <g onClick={(event) => onClick(event, edge)} @@ -55,11 +61,12 @@ export const Edge = memo(function Edge(props: Props) { > <line strokeWidth={(hovering ? 1 : 0) + (edge.highlighted ? 1 : 0) + edge.thickness} - stroke={edge.highlighted ? highlightedEdgeColor : defaultEdgeColor} + stroke={edge.highlighted ? highlightedEdgeColor : edgeColor} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2} + strokeDasharray={edge.strokeDasharray} markerEnd={`url(#${edge.highlighted ? coloredMarkerId : markerId})`} /> <line diff --git a/public/app/plugins/panel/nodeGraph/NodeGraph.tsx b/public/app/plugins/panel/nodeGraph/NodeGraph.tsx index ccd14fd12b3c3..f0053aada0f92 100644 --- a/public/app/plugins/panel/nodeGraph/NodeGraph.tsx +++ b/public/app/plugins/panel/nodeGraph/NodeGraph.tsx @@ -305,8 +305,8 @@ const Nodes = memo(function Nodes(props: NodesProps) { !props.hoveringIds || props.hoveringIds.length === 0 ? 'default' : props.hoveringIds?.includes(n.id) - ? 'active' - : 'inactive' + ? 'active' + : 'inactive' } /> ))} diff --git a/public/app/plugins/panel/nodeGraph/createLayoutWorker.ts b/public/app/plugins/panel/nodeGraph/createLayoutWorker.ts index 1aa1f1e908360..c113773768de1 100644 --- a/public/app/plugins/panel/nodeGraph/createLayoutWorker.ts +++ b/public/app/plugins/panel/nodeGraph/createLayoutWorker.ts @@ -1,3 +1,4 @@ import { CorsWorker as Worker } from 'app/core/utils/CorsWorker'; export const createWorker = () => new Worker(new URL('./layout.worker.js', import.meta.url)); +export const createMsaglWorker = () => new Worker(new URL('./layoutMsagl.worker.js', import.meta.url)); diff --git a/public/app/plugins/panel/nodeGraph/layout.ts b/public/app/plugins/panel/nodeGraph/layout.ts index 7587852606868..75776374efe58 100644 --- a/public/app/plugins/panel/nodeGraph/layout.ts +++ b/public/app/plugins/panel/nodeGraph/layout.ts @@ -3,8 +3,9 @@ import { useUnmount } from 'react-use'; import useMountedState from 'react-use/lib/useMountedState'; import { Field } from '@grafana/data'; +import { config as grafanaConfig } from '@grafana/runtime'; -import { createWorker } from './createLayoutWorker'; +import { createWorker, createMsaglWorker } from './createLayoutWorker'; import { EdgeDatum, EdgeDatumLayout, NodeDatum } from './types'; import { useNodeLimit } from './useNodeLimit'; import { graphBounds } from './utils'; @@ -83,11 +84,14 @@ export function useLayout( return; } - setLoading(true); + // Layered layout is better but also more expensive, so we switch to default force based layout for bigger graphs. + const layoutType = + grafanaConfig.featureToggles.nodeGraphDotLayout && rawNodes.length <= 500 ? 'layered' : 'default'; - // This is async but as I wanted to still run the sync grid layout and you cannot return promise from effect so + setLoading(true); + // This is async but as I wanted to still run the sync grid layout, and you cannot return promise from effect so // having callback seems ok here. - const cancel = defaultLayout(rawNodes, rawEdges, ({ nodes, edges }) => { + const cancel = layout(rawNodes, rawEdges, layoutType, ({ nodes, edges }) => { if (isMounted()) { setNodesGraph(nodes); setEdgesGraph(edges as EdgeDatumLayout[]); @@ -146,12 +150,14 @@ export function useLayout( * Wraps the layout code in a worker as it can take long and we don't want to block the main thread. * Returns a cancel function to terminate the worker. */ -function defaultLayout( +function layout( nodes: NodeDatum[], edges: EdgeDatum[], + engine: 'default' | 'layered', done: (data: { nodes: NodeDatum[]; edges: EdgeDatum[] }) => void ) { - const worker = createWorker(); + const worker = engine === 'default' ? createWorker() : createMsaglWorker(); + worker.onmessage = (event: MessageEvent<{ nodes: NodeDatum[]; edges: EdgeDatumLayout[] }>) => { for (let i = 0; i < nodes.length; i++) { // These stats needs to be Field class but the data is stringified over the worker boundary diff --git a/public/app/plugins/panel/nodeGraph/layout.worker.js b/public/app/plugins/panel/nodeGraph/layout.worker.js index 205289a1fcd30..d0fafa0c54fce 100644 --- a/public/app/plugins/panel/nodeGraph/layout.worker.js +++ b/public/app/plugins/panel/nodeGraph/layout.worker.js @@ -1,176 +1,7 @@ -import { forceSimulation, forceLink, forceCollide, forceX } from 'd3-force'; +import { layout } from './layout.worker.utils'; addEventListener('message', (event) => { const { nodes, edges, config } = event.data; layout(nodes, edges, config); postMessage({ nodes, edges }); }); - -/** - * Use d3 force layout to lay the nodes in a sensible way. This function modifies the nodes adding the x,y positions - * and also fills in node references in edges instead of node ids. - */ -export function layout(nodes, edges, config) { - // Start with some hardcoded positions so it starts laid out from left to right - let { roots, secondLevelRoots } = initializePositions(nodes, edges); - - // There always seems to be one or more root nodes each with single edge and we want to have them static on the - // left neatly in something like grid layout - [...roots, ...secondLevelRoots].forEach((n, index) => { - n.fx = n.x; - }); - - const simulation = forceSimulation(nodes) - .force( - 'link', - forceLink(edges) - .id((d) => d.id) - .distance(config.linkDistance) - .strength(config.linkStrength) - ) - // to keep the left to right layout we add force that pulls all nodes to right but because roots are fixed it will - // apply only to non root nodes - .force('x', forceX(config.forceX).strength(config.forceXStrength)) - // Make sure nodes don't overlap - .force('collide', forceCollide(config.forceCollide)); - - // 300 ticks for the simulation are recommended but less would probably work too, most movement is done in first - // few iterations and then all the forces gets smaller https://github.com/d3/d3-force#simulation_alphaDecay - simulation.tick(config.tick); - simulation.stop(); - - // We do centering here instead of using centering force to keep this more stable - centerNodes(nodes); -} - -/** - * This initializes positions of the graph by going from the root to its children and laying it out in a grid from left - * to right. This works only so, so because service map graphs can have cycles and children levels are not ordered in a - * way to minimize the edge lengths. Nevertheless this seems to make the graph easier to nudge with the forces later on - * than with the d3 default initial positioning. Also we can fix the root positions later on for a bit more neat - * organisation. - * - * This function directly modifies the nodes given and only returns references to root nodes so they do not have to be - * found again later on. - * - * How the spacing could look like approximately: - * 0 - 0 - 0 - 0 - * \- 0 - 0 | - * \- 0 -/ - * 0 - 0 -/ - */ -function initializePositions(nodes, edges) { - // To prevent going in cycles - const alreadyPositioned = {}; - - const nodesMap = nodes.reduce((acc, node) => { - acc[node.id] = node; - return acc; - }, {}); - const edgesMap = edges.reduce((acc, edge) => { - const sourceId = edge.source; - acc[sourceId] = [...(acc[sourceId] || []), edge]; - return acc; - }, {}); - - let roots = nodes.filter((n) => n.incoming === 0); - - // For things like service maps we assume there is some root (client) node but if there is none then selecting - // any node as a starting point should work the same. - if (!roots.length) { - roots = [nodes[0]]; - } - - let secondLevelRoots = roots.reduce((acc, r) => { - acc.push(...(edgesMap[r.id] ? edgesMap[r.id].map((e) => nodesMap[e.target]) : [])); - return acc; - }, []); - - const rootYSpacing = 300; - const nodeYSpacing = 200; - const nodeXSpacing = 200; - - let rootY = 0; - for (const root of roots) { - let graphLevel = [root]; - let x = 0; - while (graphLevel.length > 0) { - const nextGraphLevel = []; - let y = rootY; - for (const node of graphLevel) { - if (alreadyPositioned[node.id]) { - continue; - } - // Initialize positions based on the spacing in the grid - node.x = x; - node.y = y; - alreadyPositioned[node.id] = true; - - // Move to next Y position for next node - y += nodeYSpacing; - if (edgesMap[node.id]) { - nextGraphLevel.push(...edgesMap[node.id].map((edge) => nodesMap[edge.target])); - } - } - - graphLevel = nextGraphLevel; - // Move to next X position for next level - x += nodeXSpacing; - // Reset Y back to baseline for this root - y = rootY; - } - rootY += rootYSpacing; - } - return { roots, secondLevelRoots }; -} - -/** - * Makes sure that the center of the graph based on its bound is in 0, 0 coordinates. - * Modifies the nodes directly. - */ -function centerNodes(nodes) { - const bounds = graphBounds(nodes); - for (let node of nodes) { - node.x = node.x - bounds.center.x; - node.y = node.y - bounds.center.y; - } -} - -/** - * Get bounds of the graph meaning the extent of the nodes in all directions. - */ -function graphBounds(nodes) { - if (nodes.length === 0) { - return { top: 0, right: 0, bottom: 0, left: 0, center: { x: 0, y: 0 } }; - } - - const bounds = nodes.reduce( - (acc, node) => { - if (node.x > acc.right) { - acc.right = node.x; - } - if (node.x < acc.left) { - acc.left = node.x; - } - if (node.y > acc.bottom) { - acc.bottom = node.y; - } - if (node.y < acc.top) { - acc.top = node.y; - } - return acc; - }, - { top: Infinity, right: -Infinity, bottom: -Infinity, left: Infinity } - ); - - const y = bounds.top + (bounds.bottom - bounds.top) / 2; - const x = bounds.left + (bounds.right - bounds.left) / 2; - - return { - ...bounds, - center: { - x, - y, - }, - }; -} diff --git a/public/app/plugins/panel/nodeGraph/layout.worker.utils.js b/public/app/plugins/panel/nodeGraph/layout.worker.utils.js new file mode 100644 index 0000000000000..9c30af6a1ab9d --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/layout.worker.utils.js @@ -0,0 +1,174 @@ +// This file is a workaround so the layout function can be imported in Jest mocks. If the jest mock imports the +// layout.worker.js file it will attach the eventlistener and then call the layout function with undefined data +// which causes tests to fail. + +import { forceSimulation, forceLink, forceCollide, forceX } from 'd3-force'; + +/** + * Use d3 force layout to lay the nodes in a sensible way. This function modifies the nodes adding the x,y positions + * and also fills in node references in edges instead of node ids. + */ +export function layout(nodes, edges, config) { + // Start with some hardcoded positions so it starts laid out from left to right + let { roots, secondLevelRoots } = initializePositions(nodes, edges); + + // There always seems to be one or more root nodes each with single edge and we want to have them static on the + // left neatly in something like grid layout + [...roots, ...secondLevelRoots].forEach((n, index) => { + n.fx = n.x; + }); + + const simulation = forceSimulation(nodes) + .force( + 'link', + forceLink(edges) + .id((d) => d.id) + .distance(config.linkDistance) + .strength(config.linkStrength) + ) + // to keep the left to right layout we add force that pulls all nodes to right but because roots are fixed it will + // apply only to non root nodes + .force('x', forceX(config.forceX).strength(config.forceXStrength)) + // Make sure nodes don't overlap + .force('collide', forceCollide(config.forceCollide)); + + // 300 ticks for the simulation are recommended but less would probably work too, most movement is done in first + // few iterations and then all the forces gets smaller https://github.com/d3/d3-force#simulation_alphaDecay + simulation.tick(config.tick); + simulation.stop(); + + // We do centering here instead of using centering force to keep this more stable + centerNodes(nodes); +} + +/** + * This initializes positions of the graph by going from the root to its children and laying it out in a grid from left + * to right. This works only so, so because service map graphs can have cycles and children levels are not ordered in a + * way to minimize the edge lengths. Nevertheless this seems to make the graph easier to nudge with the forces later on + * than with the d3 default initial positioning. Also we can fix the root positions later on for a bit more neat + * organisation. + * + * This function directly modifies the nodes given and only returns references to root nodes so they do not have to be + * found again later on. + * + * How the spacing could look like approximately: + * 0 - 0 - 0 - 0 + * \- 0 - 0 | + * \- 0 -/ + * 0 - 0 -/ + */ +function initializePositions(nodes, edges) { + // To prevent going in cycles + const alreadyPositioned = {}; + + const nodesMap = nodes.reduce((acc, node) => { + acc[node.id] = node; + return acc; + }, {}); + const edgesMap = edges.reduce((acc, edge) => { + const sourceId = edge.source; + acc[sourceId] = [...(acc[sourceId] || []), edge]; + return acc; + }, {}); + + let roots = nodes.filter((n) => n.incoming === 0); + + // For things like service maps we assume there is some root (client) node but if there is none then selecting + // any node as a starting point should work the same. + if (!roots.length) { + roots = [nodes[0]]; + } + + let secondLevelRoots = roots.reduce((acc, r) => { + acc.push(...(edgesMap[r.id] ? edgesMap[r.id].map((e) => nodesMap[e.target]) : [])); + return acc; + }, []); + + const rootYSpacing = 300; + const nodeYSpacing = 200; + const nodeXSpacing = 200; + + let rootY = 0; + for (const root of roots) { + let graphLevel = [root]; + let x = 0; + while (graphLevel.length > 0) { + const nextGraphLevel = []; + let y = rootY; + for (const node of graphLevel) { + if (alreadyPositioned[node.id]) { + continue; + } + // Initialize positions based on the spacing in the grid + node.x = x; + node.y = y; + alreadyPositioned[node.id] = true; + + // Move to next Y position for next node + y += nodeYSpacing; + if (edgesMap[node.id]) { + nextGraphLevel.push(...edgesMap[node.id].map((edge) => nodesMap[edge.target])); + } + } + + graphLevel = nextGraphLevel; + // Move to next X position for next level + x += nodeXSpacing; + // Reset Y back to baseline for this root + y = rootY; + } + rootY += rootYSpacing; + } + return { roots, secondLevelRoots }; +} + +/** + * Makes sure that the center of the graph based on its bound is in 0, 0 coordinates. + * Modifies the nodes directly. + */ +function centerNodes(nodes) { + const bounds = graphBounds(nodes); + for (let node of nodes) { + node.x = node.x - bounds.center.x; + node.y = node.y - bounds.center.y; + } +} + +/** + * Get bounds of the graph meaning the extent of the nodes in all directions. + */ +function graphBounds(nodes) { + if (nodes.length === 0) { + return { top: 0, right: 0, bottom: 0, left: 0, center: { x: 0, y: 0 } }; + } + + const bounds = nodes.reduce( + (acc, node) => { + if (node.x > acc.right) { + acc.right = node.x; + } + if (node.x < acc.left) { + acc.left = node.x; + } + if (node.y > acc.bottom) { + acc.bottom = node.y; + } + if (node.y < acc.top) { + acc.top = node.y; + } + return acc; + }, + { top: Infinity, right: -Infinity, bottom: -Infinity, left: Infinity } + ); + + const y = bounds.top + (bounds.bottom - bounds.top) / 2; + const x = bounds.left + (bounds.right - bounds.left) / 2; + + return { + ...bounds, + center: { + x, + y, + }, + }; +} diff --git a/public/app/plugins/panel/nodeGraph/layoutMsagl.worker.js b/public/app/plugins/panel/nodeGraph/layoutMsagl.worker.js new file mode 100644 index 0000000000000..f088b22f3fc2a --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/layoutMsagl.worker.js @@ -0,0 +1,250 @@ +import { + GeomGraph, + GeomEdge, + GeomNode, + Point, + CurveFactory, + SugiyamaLayoutSettings, + LayerDirectionEnum, + layoutGeomGraph, +} from '@msagl/core'; +import { parseDot } from '@msagl/parser'; + +addEventListener('message', async (event) => { + const { nodes, edges, config } = event.data; + const [newNodes, newEdges] = layout(nodes, edges, config); + postMessage({ nodes: newNodes, edges: newEdges }); +}); + +/** + * Use d3 force layout to lay the nodes in a sensible way. This function modifies the nodes adding the x,y positions + * and also fills in node references in edges instead of node ids. + */ +export function layout(nodes, edges) { + const { mappedEdges, DOTToIdMap } = createMappings(nodes, edges); + + const dot = edgesToDOT(mappedEdges); + const graph = parseDot(dot); + const geomGraph = new GeomGraph(graph); + for (const e of graph.deepEdges) { + new GeomEdge(e); + } + + for (const n of graph.nodesBreadthFirst) { + const gn = new GeomNode(n); + gn.boundaryCurve = CurveFactory.mkCircle(50, new Point(0, 0)); + } + geomGraph.layoutSettings = new SugiyamaLayoutSettings(); + geomGraph.layoutSettings.layerDirection = LayerDirectionEnum.LR; + geomGraph.layoutSettings.LayerSeparation = 60; + geomGraph.layoutSettings.commonSettings.NodeSeparation = 40; + layoutGeomGraph(geomGraph); + + const nodesMap = {}; + for (const node of geomGraph.nodesBreadthFirst) { + nodesMap[DOTToIdMap[node.id]] = { + obj: node, + }; + } + + for (const node of nodes) { + nodesMap[node.id] = { + ...nodesMap[node.id], + datum: { + ...node, + x: nodesMap[node.id].obj.center.x, + y: nodesMap[node.id].obj.center.y, + }, + }; + } + const edgesMapped = edges.map((e) => { + return { + ...e, + source: nodesMap[e.source].datum, + target: nodesMap[e.target].datum, + }; + }); + + // This section checks if there are separate disjointed subgraphs. If so it groups nodes for each and then aligns + // each subgraph, so it starts on a single vertical line. Otherwise, they are laid out randomly from left to right. + const subgraphs = []; + for (const e of edgesMapped) { + const sourceGraph = subgraphs.find((g) => g.nodes.has(e.source)); + const targetGraph = subgraphs.find((g) => g.nodes.has(e.target)); + if (sourceGraph && targetGraph) { + // if the node sets are not the same we merge them + if (sourceGraph !== targetGraph) { + targetGraph.nodes.forEach(sourceGraph.nodes.add, sourceGraph.nodes); + subgraphs.splice(subgraphs.indexOf(targetGraph), 1); + sourceGraph.top = Math.min(sourceGraph.top, targetGraph.top); + sourceGraph.bottom = Math.max(sourceGraph.bottom, targetGraph.bottom); + sourceGraph.left = Math.min(sourceGraph.left, targetGraph.left); + sourceGraph.right = Math.max(sourceGraph.right, targetGraph.right); + } + // if the sets are the same nothing to do. + } else if (sourceGraph) { + sourceGraph.nodes.add(e.target); + sourceGraph.top = Math.min(sourceGraph.top, e.target.y); + sourceGraph.bottom = Math.max(sourceGraph.bottom, e.target.y); + sourceGraph.left = Math.min(sourceGraph.left, e.target.x); + sourceGraph.right = Math.max(sourceGraph.right, e.target.x); + } else if (targetGraph) { + targetGraph.nodes.add(e.source); + targetGraph.top = Math.min(targetGraph.top, e.source.y); + targetGraph.bottom = Math.max(targetGraph.bottom, e.source.y); + targetGraph.left = Math.min(targetGraph.left, e.source.x); + targetGraph.right = Math.max(targetGraph.right, e.source.x); + } else { + // we don't have these nodes + subgraphs.push({ + top: Math.min(e.source.y, e.target.y), + bottom: Math.max(e.source.y, e.target.y), + left: Math.min(e.source.x, e.target.x), + right: Math.max(e.source.x, e.target.x), + nodes: new Set([e.source, e.target]), + }); + } + } + + let top = 0; + let left = 0; + for (const g of subgraphs) { + if (top === 0) { + top = g.bottom + 200; + left = g.left; + } else { + const topDiff = top - g.top; + const leftDiff = left - g.left; + for (const n of g.nodes) { + n.x += leftDiff; + n.y += topDiff; + } + top += g.bottom - g.top + 200; + } + } + + const finalNodes = Object.values(nodesMap).map((v) => v.datum); + + centerNodes(finalNodes); + return [finalNodes, edgesMapped]; +} + +// We create mapping because the DOT language we use later to create the graph doesn't support arbitrary IDs. So we +// map our IDs to just an index of the node so the IDs are safe for the DOT parser and also create and inverse mapping +// for quick lookup. +function createMappings(nodes, edges) { + // Edges where the source and target IDs are the indexes we use for layout + const mappedEdges = []; + + // Key is an ID of the node and value is new ID which is just iteration index + const idToDOTMap = {}; + + // Key is an iteration index and value is actual ID of the node + const DOTToIdMap = {}; + + // Crate the maps both ways + let index = 0; + for (const edge of edges) { + if (!idToDOTMap[edge.source]) { + idToDOTMap[edge.source] = index.toString(10); + DOTToIdMap[index.toString(10)] = edge.source; + index++; + } + + if (!idToDOTMap[edge.target]) { + idToDOTMap[edge.target] = index.toString(10); + DOTToIdMap[index.toString(10)] = edge.target; + index++; + } + mappedEdges.push({ source: idToDOTMap[edge.source], target: idToDOTMap[edge.target] }); + } + + return { + mappedEdges, + DOTToIdMap, + }; +} + +function toDOT(edges, graphAttr = '', edgeAttr = '') { + let dot = ` + digraph G { + ${graphAttr} + `; + for (const edge of edges) { + dot += edge.source + '->' + edge.target + ' ' + edgeAttr + '\n'; + } + dot += nodesDOT(edges); + dot += '}'; + return dot; +} + +function edgesToDOT(edges) { + return toDOT(edges, 'rankdir="LR"; TBbalance="min"', '[ minlen=3 ]'); +} + +function nodesDOT(edges) { + let dot = ''; + const visitedNodes = new Set(); + // TODO: height/width for default sizing but nodes can have variable size now + const attr = '[fixedsize=true, width=1.2, height=1.7] \n'; + for (const edge of edges) { + if (!visitedNodes.has(edge.source)) { + dot += edge.source + attr; + } + if (!visitedNodes.has(edge.target)) { + dot += edge.target + attr; + } + } + return dot; +} + +/** + * Makes sure that the center of the graph based on its bound is in 0, 0 coordinates. + * Modifies the nodes directly. + */ +function centerNodes(nodes) { + const bounds = graphBounds(nodes); + for (let node of nodes) { + node.x = node.x - bounds.center.x; + node.y = node.y - bounds.center.y; + } +} + +/** + * Get bounds of the graph meaning the extent of the nodes in all directions. + */ +function graphBounds(nodes) { + if (nodes.length === 0) { + return { top: 0, right: 0, bottom: 0, left: 0, center: { x: 0, y: 0 } }; + } + + const bounds = nodes.reduce( + (acc, node) => { + if (node.x > acc.right) { + acc.right = node.x; + } + if (node.x < acc.left) { + acc.left = node.x; + } + if (node.y > acc.bottom) { + acc.bottom = node.y; + } + if (node.y < acc.top) { + acc.top = node.y; + } + return acc; + }, + { top: Infinity, right: -Infinity, bottom: -Infinity, left: Infinity } + ); + + const y = bounds.top + (bounds.bottom - bounds.top) / 2; + const x = bounds.left + (bounds.right - bounds.left) / 2; + + return { + ...bounds, + center: { + x, + y, + }, + }; +} diff --git a/public/app/plugins/panel/nodeGraph/module.tsx b/public/app/plugins/panel/nodeGraph/module.tsx index 5d444e17e3cf4..27c4d06a7f2fa 100644 --- a/public/app/plugins/panel/nodeGraph/module.tsx +++ b/public/app/plugins/panel/nodeGraph/module.tsx @@ -2,41 +2,44 @@ import { PanelPlugin } from '@grafana/data'; import { NodeGraphPanel } from './NodeGraphPanel'; import { ArcOptionsEditor } from './editor/ArcOptionsEditor'; +import { NodeGraphSuggestionsSupplier } from './suggestions'; import { NodeGraphOptions } from './types'; -export const plugin = new PanelPlugin<NodeGraphOptions>(NodeGraphPanel).setPanelOptions((builder, context) => { - builder.addNestedOptions({ - category: ['Nodes'], - path: 'nodes', - build: (builder) => { - builder.addUnitPicker({ - name: 'Main stat unit', - path: 'mainStatUnit', - }); - builder.addUnitPicker({ - name: 'Secondary stat unit', - path: 'secondaryStatUnit', - }); - builder.addCustomEditor({ - name: 'Arc sections', - path: 'arcs', - id: 'arcs', - editor: ArcOptionsEditor, - }); - }, - }); - builder.addNestedOptions({ - category: ['Edges'], - path: 'edges', - build: (builder) => { - builder.addUnitPicker({ - name: 'Main stat unit', - path: 'mainStatUnit', - }); - builder.addUnitPicker({ - name: 'Secondary stat unit', - path: 'secondaryStatUnit', - }); - }, - }); -}); +export const plugin = new PanelPlugin<NodeGraphOptions>(NodeGraphPanel) + .setPanelOptions((builder, context) => { + builder.addNestedOptions({ + category: ['Nodes'], + path: 'nodes', + build: (builder) => { + builder.addUnitPicker({ + name: 'Main stat unit', + path: 'mainStatUnit', + }); + builder.addUnitPicker({ + name: 'Secondary stat unit', + path: 'secondaryStatUnit', + }); + builder.addCustomEditor({ + name: 'Arc sections', + path: 'arcs', + id: 'arcs', + editor: ArcOptionsEditor, + }); + }, + }); + builder.addNestedOptions({ + category: ['Edges'], + path: 'edges', + build: (builder) => { + builder.addUnitPicker({ + name: 'Main stat unit', + path: 'mainStatUnit', + }); + builder.addUnitPicker({ + name: 'Secondary stat unit', + path: 'secondaryStatUnit', + }); + }, + }); + }) + .setSuggestionsSupplier(new NodeGraphSuggestionsSupplier()); diff --git a/public/app/plugins/panel/nodeGraph/panelcfg.gen.ts b/public/app/plugins/panel/nodeGraph/panelcfg.gen.ts index 672953359c3ae..d09f8d207cb49 100644 --- a/public/app/plugins/panel/nodeGraph/panelcfg.gen.ts +++ b/public/app/plugins/panel/nodeGraph/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/nodeGraph/suggestions.ts b/public/app/plugins/panel/nodeGraph/suggestions.ts new file mode 100644 index 0000000000000..eb4a4773ef446 --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/suggestions.ts @@ -0,0 +1,71 @@ +import { DataFrame, FieldType, VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data'; +import { SuggestionName } from 'app/types/suggestions'; + +export class NodeGraphSuggestionsSupplier { + getListWithDefaults(builder: VisualizationSuggestionsBuilder) { + return builder.getListAppender<{}, {}>({ + name: SuggestionName.NodeGraph, + pluginId: 'nodeGraph', + }); + } + + hasCorrectFields(frames: DataFrame[]): boolean { + let hasNodesFrame = false; + let hasEdgesFrame = false; + + const nodeFields: Array<[string, FieldType]> = [ + ['id', FieldType.string], + ['title', FieldType.string], + ['mainstat', FieldType.number], + ]; + const edgeFields: Array<[string, FieldType]> = [ + ['id', FieldType.string], + ['source', FieldType.string], + ['target', FieldType.string], + ]; + + for (const frame of frames) { + if (this.checkFields(nodeFields, frame)) { + hasNodesFrame = true; + } + if (this.checkFields(edgeFields, frame)) { + hasEdgesFrame = true; + } + } + + return hasNodesFrame && hasEdgesFrame; + } + + checkFields(fields: Array<[string, FieldType]>, frame: DataFrame): boolean { + let hasCorrectFields = true; + + for (const field of fields) { + const [name, type] = field; + const frameField = frame.fields.find((f) => f.name === name); + if (!frameField || type !== frameField.type) { + hasCorrectFields = false; + break; + } + } + + return hasCorrectFields; + } + + getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { + if (!builder.data) { + return; + } + + const hasCorrectFields = this.hasCorrectFields(builder.data.series); + const nodeGraphFrames = builder.data.series.filter( + (df) => df.meta && df.meta.preferredVisualisationType === 'nodeGraph' + ); + + if (hasCorrectFields || nodeGraphFrames.length === 2) { + this.getListWithDefaults(builder).append({ + name: SuggestionName.NodeGraph, + score: VisualizationSuggestionScore.Best, + }); + } + } +} diff --git a/public/app/plugins/panel/nodeGraph/types.ts b/public/app/plugins/panel/nodeGraph/types.ts index 6ab3f47481814..1c93bea22e287 100644 --- a/public/app/plugins/panel/nodeGraph/types.ts +++ b/public/app/plugins/panel/nodeGraph/types.ts @@ -35,8 +35,13 @@ export type EdgeDatum = LinkDatum & { dataFrameRowIndex: number; sourceNodeRadius: number; targetNodeRadius: number; + /** + * @deprecated -- for edges use color instead + */ highlighted: boolean; thickness: number; + color?: string; + strokeDasharray?: string; }; // After layout is run D3 will change the string IDs for actual references to the nodes. diff --git a/public/app/plugins/panel/nodeGraph/usePanning.ts b/public/app/plugins/panel/nodeGraph/usePanning.ts index 995646b15b41b..41daf7daf1275 100644 --- a/public/app/plugins/panel/nodeGraph/usePanning.ts +++ b/public/app/plugins/panel/nodeGraph/usePanning.ts @@ -184,10 +184,10 @@ function inBounds(value: number, min: number | undefined, max: number | undefine } // The issue here is that TouchEvent is undefined while using instanceof in Firefox and Safari -// which will throw an exception but if it's (event as TouchEvent).changedTouches it will be undefined +// which will throw an exception but if it's event.changedTouches it will be undefined // and the if check will fail so it will go to the else but will not throw an exception function getEventXY(event: Event): { x: number; y: number } { - if ((event as TouchEvent).changedTouches && event instanceof TouchEvent) { + if ('changedTouches' in event && event instanceof TouchEvent) { return { x: event.changedTouches[0].clientX, y: event.changedTouches[0].clientY }; } else if (event instanceof MouseEvent) { return { x: event.clientX, y: event.clientY }; diff --git a/public/app/plugins/panel/nodeGraph/utils.test.ts b/public/app/plugins/panel/nodeGraph/utils.test.ts index b920dfe59c8eb..00fcf3be24c32 100644 --- a/public/app/plugins/panel/nodeGraph/utils.test.ts +++ b/public/app/plugins/panel/nodeGraph/utils.test.ts @@ -275,6 +275,7 @@ function makeNodeDatum(options: Partial<NodeDatum> = {}) { config: { color: { fixedColor: 'green', + mode: 'fixed', }, }, name: 'arc__success', @@ -285,6 +286,7 @@ function makeNodeDatum(options: Partial<NodeDatum> = {}) { config: { color: { fixedColor: 'red', + mode: 'fixed', }, }, name: 'arc__errors', diff --git a/public/app/plugins/panel/nodeGraph/utils.ts b/public/app/plugins/panel/nodeGraph/utils.ts index 728026b95d0a8..b23ad01e74a23 100644 --- a/public/app/plugins/panel/nodeGraph/utils.ts +++ b/public/app/plugins/panel/nodeGraph/utils.ts @@ -86,8 +86,13 @@ export type EdgeFields = { mainStat?: Field; secondaryStat?: Field; details: Field[]; + /** + * @deprecated use `color` instead + */ highlighted?: Field; thickness?: Field; + color?: Field; + strokeDasharray?: Field; }; export function getEdgeFields(edges: DataFrame): EdgeFields { @@ -103,8 +108,11 @@ export function getEdgeFields(edges: DataFrame): EdgeFields { mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat.toLowerCase()), secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat.toLowerCase()), details: findFieldsByPrefix(edges, NodeGraphDataFrameFieldNames.detail.toLowerCase()), + // @deprecated -- for edges use color instead highlighted: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.highlighted.toLowerCase()), thickness: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.thickness.toLowerCase()), + color: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.color.toLowerCase()), + strokeDasharray: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.strokeDasharray.toLowerCase()), }; } @@ -234,8 +242,11 @@ function processEdges(edges: DataFrame, edgeFields: EdgeFields, nodesMap: { [id: secondaryStat: edgeFields.secondaryStat ? statToString(edgeFields.secondaryStat.config, edgeFields.secondaryStat.values[index]) : '', + // @deprecated -- for edges use color instead highlighted: edgeFields.highlighted?.values[index] || false, thickness: edgeFields.thickness?.values[index] || 1, + color: edgeFields.color?.values[index], + strokeDasharray: edgeFields.strokeDasharray?.values[index], }; }); } @@ -370,7 +381,7 @@ function makeNode(index: number) { } function nodesFrame() { - const fields: any = { + const fields = { [NodeGraphDataFrameFieldNames.id]: { values: [], type: FieldType.string, @@ -394,17 +405,17 @@ function nodesFrame() { [NodeGraphDataFrameFieldNames.arc + 'success']: { values: [], type: FieldType.number, - config: { color: { fixedColor: 'green' } }, + config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'green' } }, }, [NodeGraphDataFrameFieldNames.arc + 'errors']: { values: [], type: FieldType.number, - config: { color: { fixedColor: 'red' } }, + config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'red' } }, }, [NodeGraphDataFrameFieldNames.color]: { values: [], type: FieldType.number, - config: { color: { mode: 'continuous-GrYlRd' } }, + config: { color: { mode: FieldColorModeId.ContinuousGrYlRd } }, }, [NodeGraphDataFrameFieldNames.icon]: { values: [], @@ -418,8 +429,8 @@ function nodesFrame() { return new MutableDataFrame({ name: 'nodes', - fields: Object.keys(fields).map((key) => ({ - ...fields[key], + fields: Object.entries(fields).map(([key, value]) => ({ + ...value, name: key, })), }); @@ -440,7 +451,7 @@ export function makeEdgesDataFrame( } function edgesFrame() { - const fields: any = { + const fields = { [NodeGraphDataFrameFieldNames.id]: { values: [], type: FieldType.string, @@ -465,8 +476,8 @@ function edgesFrame() { return new MutableDataFrame({ name: 'edges', - fields: Object.keys(fields).map((key) => ({ - ...fields[key], + fields: Object.entries(fields).map(([key, value]) => ({ + ...value, name: key, })), }); diff --git a/public/app/plugins/panel/piechart/panelcfg.gen.ts b/public/app/plugins/panel/piechart/panelcfg.gen.ts index 6d64ec8f2de4d..494d6678173ed 100644 --- a/public/app/plugins/panel/piechart/panelcfg.gen.ts +++ b/public/app/plugins/panel/piechart/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/stat/StatMigrations.ts b/public/app/plugins/panel/stat/StatMigrations.ts index 4eb5bbdb6c870..2708ef744125f 100644 --- a/public/app/plugins/panel/stat/StatMigrations.ts +++ b/public/app/plugins/panel/stat/StatMigrations.ts @@ -6,7 +6,7 @@ import { Options } from './panelcfg.gen'; // This is called when the panel changes from another panel export const statPanelChangedHandler = ( - panel: PanelModel<Partial<Options>> | any, + panel: PanelModel<Partial<Options>>, prevPluginId: string, prevOptions: any ) => { diff --git a/public/app/plugins/panel/stat/panelcfg.gen.ts b/public/app/plugins/panel/stat/panelcfg.gen.ts index 9d860c655d01a..2f02876b8450f 100644 --- a/public/app/plugins/panel/stat/panelcfg.gen.ts +++ b/public/app/plugins/panel/stat/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx index f12f6af50a861..93874e7ba4fab 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx @@ -4,6 +4,7 @@ import { CartesianCoords2D, DashboardCursorSync, DataFrame, FieldType, PanelProp import { getLastStreamingDataFramePacket } from '@grafana/data/src/dataframe/StreamingDataFrame'; import { config } from '@grafana/runtime'; import { + EventBusPlugin, Portal, TooltipDisplayMode, TooltipPlugin2, @@ -14,7 +15,7 @@ import { ZoomPlugin, } from '@grafana/ui'; import { addTooltipSupport, HoverEvent } from '@grafana/ui/src/components/uPlot/config/addTooltipSupport'; -import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; +import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart'; import { @@ -25,6 +26,7 @@ import { import { AnnotationEditorPlugin } from '../timeseries/plugins/AnnotationEditorPlugin'; import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin'; +import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2'; import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin'; import { getTimezones } from '../timeseries/utils'; @@ -51,6 +53,19 @@ export const StateTimelinePanel = ({ }: TimelinePanelProps) => { const theme = useTheme2(); + // TODO: we should just re-init when this changes, and have this be a static setting + const syncTooltip = useCallback( + () => sync?.() === DashboardCursorSync.Tooltip, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const syncAny = useCallback( + () => sync?.() !== DashboardCursorSync.Off, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + const oldConfig = useRef<UPlotConfigBuilder | undefined>(undefined); const isToolTipOpen = useRef<boolean>(false); @@ -60,7 +75,9 @@ export const StateTimelinePanel = ({ const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null); const [isActive, setIsActive] = useState<boolean>(false); const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(false); - const { sync, canAddAnnotations } = usePanelContext(); + // temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2 + const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null); + const { sync, canAddAnnotations, dataLinkPostProcessor, eventBus } = usePanelContext(); const onCloseToolTip = () => { isToolTipOpen.current = false; @@ -106,10 +123,7 @@ export const StateTimelinePanel = ({ * Render nothing in this case to prevent error. * See https://github.com/grafana/support-escalations/issues/932 */ - if ( - (!alignedData.meta?.transformations?.length && alignedData.fields.length - 1 !== valueFieldsCount) || - !alignedData.fields[seriesIdx] - ) { + if (alignedData.fields.length - 1 !== valueFieldsCount || !alignedData.fields[seriesIdx]) { return null; } @@ -163,6 +177,7 @@ export const StateTimelinePanel = ({ } } const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); + const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips); return ( <TimelineChart @@ -176,9 +191,11 @@ export const StateTimelinePanel = ({ legendItems={legendItems} {...options} mode={TimelineMode.Changes} + replaceVariables={replaceVariables} + dataLinkPostProcessor={dataLinkPostProcessor} > {(builder, alignedFrame) => { - if (oldConfig.current !== builder) { + if (oldConfig.current !== builder && !showNewVizTooltips) { oldConfig.current = addTooltipSupport({ config: builder, onUPlotClick, @@ -195,32 +212,65 @@ export const StateTimelinePanel = ({ return ( <> - {config.featureToggles.newVizTooltips ? ( + <EventBusPlugin config={builder} sync={syncAny} eventBus={eventBus} frame={alignedFrame} /> + {showNewVizTooltips ? ( <> {options.tooltip.mode !== TooltipDisplayMode.None && ( <TooltipPlugin2 config={builder} - hoverMode={TooltipHoverMode.xOne} + hoverMode={ + options.tooltip.mode === TooltipDisplayMode.Multi ? TooltipHoverMode.xAll : TooltipHoverMode.xOne + } queryZoom={onChangeTimeRange} - render={(u, dataIdxs, seriesIdx, isPinned) => { + syncTooltip={syncTooltip} + render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => { + if (enableAnnotationCreation && timeRange2 != null) { + setNewAnnotationRange(timeRange2); + dismiss(); + return; + } + + const annotate = () => { + let xVal = u.posToVal(u.cursor.left!, 'x'); + + setNewAnnotationRange({ from: xVal, to: xVal }); + dismiss(); + }; + return ( <StateTimelineTooltip2 - data={frames ?? []} + frames={frames ?? []} + seriesFrame={alignedFrame} dataIdxs={dataIdxs} - alignedData={alignedFrame} seriesIdx={seriesIdx} - timeZone={timeZone} + mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode} + sortOrder={options.tooltip.sort} isPinned={isPinned} + timeRange={timeRange} + annotate={enableAnnotationCreation ? annotate : undefined} + withDuration={true} /> ); }} + maxWidth={options.tooltip.maxWidth} + maxHeight={options.tooltip.maxHeight} /> )} + {/* Renders annotations */} + <AnnotationsPlugin2 + annotations={data.annotations ?? []} + config={builder} + timeZone={timeZone} + newRange={newAnnotationRange} + setNewRange={setNewAnnotationRange} + canvasRegionRendering={false} + /> </> ) : ( <> <ZoomPlugin config={builder} onZoom={onChangeTimeRange} /> <OutsideRangePlugin config={builder} onChangeTimeRange={onChangeTimeRange} /> + {/* Renders annotation markers*/} {data.annotations && ( <AnnotationsPlugin annotations={data.annotations} config={builder} timeZone={timeZone} /> )} diff --git a/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx b/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx index a24fd1bf2a006..8c2af2b7c8000 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx @@ -1,140 +1,90 @@ -import { css } from '@emotion/css'; -import React from 'react'; - -import { - DataFrame, - Field, - FieldType, - getDisplayProcessor, - getFieldDisplayName, - GrafanaTheme2, - LinkModel, - TimeZone, -} from '@grafana/data'; -import { useStyles2, useTheme2 } from '@grafana/ui'; +import React, { ReactNode } from 'react'; + +import { FieldType, getFieldDisplayName, TimeRange } from '@grafana/data'; +import { SortOrder } from '@grafana/schema/dist/esm/common/common.gen'; +import { TooltipDisplayMode, useStyles2 } from '@grafana/ui'; import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader'; -import { ColorIndicator, ColorPlacement, LabelValue } from '@grafana/ui/src/components/VizTooltip/types'; -import { DEFAULT_TOOLTIP_WIDTH } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; +import { VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types'; +import { getContentItems } from '@grafana/ui/src/components/VizTooltip/utils'; import { findNextStateIndex, fmtDuration } from 'app/core/components/TimelineChart/utils'; import { getDataLinks } from '../status-history/utils'; +import { TimeSeriesTooltipProps, getStyles } from '../timeseries/TimeSeriesTooltip'; -interface StateTimelineTooltip2Props { - data: DataFrame[]; - alignedData: DataFrame; - dataIdxs: Array<number | null>; - seriesIdx: number | null | undefined; - isPinned: boolean; - timeZone?: TimeZone; +interface StateTimelineTooltip2Props extends TimeSeriesTooltipProps { + timeRange: TimeRange; + withDuration: boolean; } export const StateTimelineTooltip2 = ({ - data, - alignedData, + frames, + seriesFrame, dataIdxs, seriesIdx, - timeZone, + mode = TooltipDisplayMode.Single, + sortOrder = SortOrder.None, + scrollable = false, isPinned, + annotate, + timeRange, + withDuration, }: StateTimelineTooltip2Props) => { const styles = useStyles2(getStyles); - const theme = useTheme2(); - const datapointIdx = seriesIdx != null ? dataIdxs[seriesIdx] : dataIdxs.find((idx) => idx != null); + const xField = seriesFrame.fields[0]; - if (datapointIdx == null || seriesIdx == null) { - return null; - } + const dataIdx = seriesIdx != null ? dataIdxs[seriesIdx] : dataIdxs.find((idx) => idx != null); - const valueFieldsCount = data.reduce( - (acc, frame) => acc + frame.fields.filter((field) => field.type !== FieldType.time).length, - 0 - ); + const xVal = xField.display!(xField.values[dataIdx!]).text; - /** - * There could be a case when the tooltip shows a data from one of a multiple query and the other query finishes first - * from refreshing. This causes data to be out of sync. alignedData - 1 because Time field doesn't count. - * Render nothing in this case to prevent error. - * See https://github.com/grafana/support-escalations/issues/932 - */ - if ( - (!alignedData.meta?.transformations?.length && alignedData.fields.length - 1 !== valueFieldsCount) || - !alignedData.fields[seriesIdx] - ) { - return null; - } + mode = isPinned ? TooltipDisplayMode.Single : mode; - const field = alignedData.fields[seriesIdx!]; - - const links: Array<LinkModel<Field>> = getDataLinks(field, datapointIdx); - - const xField = alignedData.fields[0]; - const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme }); - - const dataFrameFieldIndex = field.state?.origin; - const fieldFmt = field.display || getDisplayProcessor({ field, timeZone, theme }); - const value = field.values[datapointIdx!]; - const display = fieldFmt(value); - const fieldDisplayName = dataFrameFieldIndex - ? getFieldDisplayName( - data[dataFrameFieldIndex.frameIndex].fields[dataFrameFieldIndex.fieldIndex], - data[dataFrameFieldIndex.frameIndex], - data - ) - : null; - - const nextStateIdx = findNextStateIndex(field, datapointIdx!); - let nextStateTs; - if (nextStateIdx) { - nextStateTs = xField.values[nextStateIdx!]; - } + const contentItems = getContentItems(seriesFrame.fields, xField, dataIdxs, seriesIdx, mode, sortOrder); + + // append duration in single mode + if (withDuration && mode === TooltipDisplayMode.Single) { + const field = seriesFrame.fields[seriesIdx!]; + const nextStateIdx = findNextStateIndex(field, dataIdx!); + let nextStateTs; + if (nextStateIdx) { + nextStateTs = xField.values[nextStateIdx!]; + } - const stateTs = xField.values[datapointIdx!]; - let duration = nextStateTs && fmtDuration(nextStateTs - stateTs); + const stateTs = xField.values[dataIdx!]; + let duration: string; - if (nextStateTs) { - duration = nextStateTs && fmtDuration(nextStateTs - stateTs); + if (nextStateTs) { + duration = nextStateTs && fmtDuration(nextStateTs - stateTs); + } else { + const to = timeRange.to.valueOf(); + duration = fmtDuration(to - stateTs); + } + + contentItems.push({ label: 'Duration', value: duration }); } - const from = xFieldFmt(xField.values[datapointIdx!]).text; - const to = xFieldFmt(xField.values[nextStateIdx!]).text; + let footer: ReactNode; - const getHeaderLabel = (): LabelValue => { - return { - label: '', - value: Boolean(to) ? to : from, - }; - }; + if (isPinned && seriesIdx != null) { + const field = seriesFrame.fields[seriesIdx]; + const dataIdx = dataIdxs[seriesIdx]!; + const links = getDataLinks(field, dataIdx); - const getContentLabelValue = (): LabelValue[] => { - const durationEntry: LabelValue[] = duration ? [{ label: 'Duration', value: duration }] : []; - - return [ - { - label: fieldDisplayName ?? '', - value: display.text, - color: display.color, - colorIndicator: ColorIndicator.value, - colorPlacement: ColorPlacement.trailing, - }, - ...durationEntry, - ]; + footer = <VizTooltipFooter dataLinks={links} annotate={annotate} />; + } + + const headerItem: VizTooltipItem = { + label: xField.type === FieldType.time ? '' : getFieldDisplayName(xField, seriesFrame, frames), + value: xVal, }; return ( <div className={styles.wrapper}> - <VizTooltipHeader headerLabel={getHeaderLabel()} /> - <VizTooltipContent contentLabelValue={getContentLabelValue()} /> - {isPinned && <VizTooltipFooter dataLinks={links} canAnnotate={false} />} + <VizTooltipHeader item={headerItem} isPinned={isPinned} /> + <VizTooltipContent items={contentItems} isPinned={isPinned} scrollable={scrollable} /> + {footer} </div> ); }; - -const getStyles = (theme: GrafanaTheme2) => ({ - wrapper: css({ - display: 'flex', - flexDirection: 'column', - width: DEFAULT_TOOLTIP_WIDTH, - }), -}); diff --git a/public/app/plugins/panel/state-timeline/module.tsx b/public/app/plugins/panel/state-timeline/module.tsx index 8dfab7fcdcebb..e18f695f7c3f8 100644 --- a/public/app/plugins/panel/state-timeline/module.tsx +++ b/public/app/plugins/panel/state-timeline/module.tsx @@ -5,6 +5,7 @@ import { identityOverrideProcessor, PanelPlugin, } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { VisibilityMode } from '@grafana/schema'; import { commonOptionsBuilder } from '@grafana/ui'; @@ -14,7 +15,7 @@ import { NullEditorSettings } from '../timeseries/config'; import { StateTimelinePanel } from './StateTimelinePanel'; import { timelinePanelChangedHandler } from './migrations'; -import { Options, FieldConfig, defaultOptions, defaultFieldConfig } from './panelcfg.gen'; +import { defaultFieldConfig, defaultOptions, FieldConfig, Options } from './panelcfg.gen'; import { StatTimelineSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin<Options, FieldConfig>(StateTimelinePanel) @@ -121,7 +122,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(StateTimelinePanel) }); commonOptionsBuilder.addLegendOptions(builder, false); - commonOptionsBuilder.addTooltipOptions(builder, true); + commonOptionsBuilder.addTooltipOptions(builder, !config.featureToggles.newVizTooltips); }) .setSuggestionsSupplier(new StatTimelineSuggestionsSupplier()) .setDataSupport({ annotations: true }); diff --git a/public/app/plugins/panel/state-timeline/panelcfg.gen.ts b/public/app/plugins/panel/state-timeline/panelcfg.gen.ts index 12d089f67611b..fb03ba0795fc5 100644 --- a/public/app/plugins/panel/state-timeline/panelcfg.gen.ts +++ b/public/app/plugins/panel/state-timeline/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx index 060694d0e75ee..57178bf15c8d8 100644 --- a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx +++ b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { CartesianCoords2D, DashboardCursorSync, DataFrame, FieldType, PanelProps } from '@grafana/data'; import { config } from '@grafana/runtime'; import { + EventBusPlugin, Portal, TooltipDisplayMode, TooltipPlugin2, @@ -13,7 +14,7 @@ import { ZoomPlugin, } from '@grafana/ui'; import { addTooltipSupport, HoverEvent } from '@grafana/ui/src/components/uPlot/config/addTooltipSupport'; -import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; +import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart'; import { @@ -22,12 +23,13 @@ import { TimelineMode, } from 'app/core/components/TimelineChart/utils'; +import { StateTimelineTooltip2 } from '../state-timeline/StateTimelineTooltip2'; import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin'; +import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2'; import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin'; import { getTimezones } from '../timeseries/utils'; import { StatusHistoryTooltip } from './StatusHistoryTooltip'; -import { StatusHistoryTooltip2 } from './StatusHistoryTooltip2'; import { Options } from './panelcfg.gen'; const TOOLTIP_OFFSET = 10; @@ -44,10 +46,24 @@ export const StatusHistoryPanel = ({ options, width, height, + replaceVariables, onChangeTimeRange, }: TimelinePanelProps) => { const theme = useTheme2(); + // TODO: we should just re-init when this changes, and have this be a static setting + const syncTooltip = useCallback( + () => sync?.() === DashboardCursorSync.Tooltip, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const syncAny = useCallback( + () => sync?.() !== DashboardCursorSync.Off, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + const oldConfig = useRef<UPlotConfigBuilder | undefined>(undefined); const isToolTipOpen = useRef<boolean>(false); @@ -57,7 +73,11 @@ export const StatusHistoryPanel = ({ const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null); const [isActive, setIsActive] = useState<boolean>(false); const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(false); - const { sync } = usePanelContext(); + // temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2 + const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null); + const { sync, canAddAnnotations, dataLinkPostProcessor, eventBus } = usePanelContext(); + + const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); const onCloseToolTip = () => { isToolTipOpen.current = false; @@ -103,10 +123,7 @@ export const StatusHistoryPanel = ({ * Render nothing in this case to prevent error. * See https://github.com/grafana/support-escalations/issues/932 */ - if ( - (!alignedData.meta?.transformations?.length && alignedData.fields.length - 1 !== valueFieldsCount) || - !alignedData.fields[seriesIdx] - ) { + if (alignedData.fields.length - 1 !== valueFieldsCount || !alignedData.fields[seriesIdx]) { return null; } @@ -190,6 +207,8 @@ export const StatusHistoryPanel = ({ ); } + const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips); + return ( <TimelineChart theme={theme} @@ -202,9 +221,11 @@ export const StatusHistoryPanel = ({ legendItems={legendItems} {...options} mode={TimelineMode.Samples} + replaceVariables={replaceVariables} + dataLinkPostProcessor={dataLinkPostProcessor} > {(builder, alignedFrame) => { - if (oldConfig.current !== builder) { + if (oldConfig.current !== builder && !showNewVizTooltips) { oldConfig.current = addTooltipSupport({ config: builder, onUPlotClick, @@ -220,43 +241,72 @@ export const StatusHistoryPanel = ({ return ( <> - {data.annotations && ( - <AnnotationsPlugin - annotations={data.annotations} - config={builder} - timeZone={timeZone} - disableCanvasRendering={true} - /> - )} - {config.featureToggles.newVizTooltips ? ( + <EventBusPlugin config={builder} sync={syncAny} eventBus={eventBus} frame={alignedFrame} /> + {showNewVizTooltips ? ( <> {options.tooltip.mode !== TooltipDisplayMode.None && ( <TooltipPlugin2 config={builder} - hoverMode={TooltipHoverMode.xyOne} + hoverMode={ + options.tooltip.mode === TooltipDisplayMode.Multi ? TooltipHoverMode.xAll : TooltipHoverMode.xOne + } queryZoom={onChangeTimeRange} - render={(u, dataIdxs, seriesIdx, isPinned, dismiss) => { + syncTooltip={syncTooltip} + render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => { + if (enableAnnotationCreation && timeRange2 != null) { + setNewAnnotationRange(timeRange2); + dismiss(); + return; + } + + const annotate = () => { + let xVal = u.posToVal(u.cursor.left!, 'x'); + + setNewAnnotationRange({ from: xVal, to: xVal }); + dismiss(); + }; + return ( - <StatusHistoryTooltip2 - data={frames ?? []} + <StateTimelineTooltip2 + frames={frames ?? []} + seriesFrame={alignedFrame} dataIdxs={dataIdxs} - alignedData={alignedFrame} seriesIdx={seriesIdx} - timeZone={timeZone} - mode={options.tooltip.mode} + mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode} sortOrder={options.tooltip.sort} isPinned={isPinned} + timeRange={timeRange} + annotate={enableAnnotationCreation ? annotate : undefined} + withDuration={false} /> ); }} + maxWidth={options.tooltip.maxWidth} + maxHeight={options.tooltip.maxHeight} /> )} + <AnnotationsPlugin2 + annotations={data.annotations ?? []} + config={builder} + timeZone={timeZone} + newRange={newAnnotationRange} + setNewRange={setNewAnnotationRange} + canvasRegionRendering={false} + /> </> ) : ( <> <ZoomPlugin config={builder} onZoom={onChangeTimeRange} /> {renderTooltip(alignedFrame)} <OutsideRangePlugin config={builder} onChangeTimeRange={onChangeTimeRange} /> + {data.annotations && ( + <AnnotationsPlugin + annotations={data.annotations} + config={builder} + timeZone={timeZone} + disableCanvasRendering={true} + /> + )} </> )} </> diff --git a/public/app/plugins/panel/status-history/StatusHistoryTooltip2.tsx b/public/app/plugins/panel/status-history/StatusHistoryTooltip2.tsx deleted file mode 100644 index 622f0bd374554..0000000000000 --- a/public/app/plugins/panel/status-history/StatusHistoryTooltip2.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { css } from '@emotion/css'; -import React from 'react'; - -import { - DataFrame, - Field, - formattedValueToString, - getDisplayProcessor, - getFieldDisplayName, - GrafanaTheme2, - TimeZone, - LinkModel, - FieldType, - arrayUtils, -} from '@grafana/data'; -import { SortOrder, TooltipDisplayMode } from '@grafana/schema'; -import { useStyles2 } from '@grafana/ui'; -import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; -import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; -import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader'; -import { ColorIndicator, ColorPlacement, LabelValue } from '@grafana/ui/src/components/VizTooltip/types'; - -import { getDataLinks } from './utils'; - -interface StatusHistoryTooltipProps { - data: DataFrame[]; - dataIdxs: Array<number | null>; - alignedData: DataFrame; - seriesIdx: number | null | undefined; - timeZone: TimeZone; - isPinned: boolean; - mode?: TooltipDisplayMode; - sortOrder?: SortOrder; -} - -function fmt(field: Field, val: number): string { - if (field.display) { - return formattedValueToString(field.display(val)); - } - - return `${val}`; -} - -export const StatusHistoryTooltip2 = ({ - data, - dataIdxs, - alignedData, - seriesIdx, - mode = TooltipDisplayMode.Single, - sortOrder = SortOrder.None, - isPinned, -}: StatusHistoryTooltipProps) => { - const styles = useStyles2(getStyles); - - const datapointIdx = seriesIdx != null ? dataIdxs[seriesIdx] : dataIdxs.find((idx) => idx != null); - - if (datapointIdx == null || seriesIdx == null) { - return null; - } - - let contentLabelValue: LabelValue[] = []; - - const xField = alignedData.fields[0]; - let links: Array<LinkModel<Field>> = []; - - // Single mode - if (mode === TooltipDisplayMode.Single || isPinned) { - const field = alignedData.fields[seriesIdx!]; - links = getDataLinks(field, datapointIdx); - - const fieldFmt = field.display || getDisplayProcessor(); - const value = field.values[datapointIdx!]; - const display = fieldFmt(value); - - contentLabelValue = [ - { - label: getFieldDisplayName(field), - value: fmt(field, field.values[datapointIdx]), - color: display.color, - colorIndicator: ColorIndicator.value, - colorPlacement: ColorPlacement.trailing, - }, - ]; - } - - if (mode === TooltipDisplayMode.Multi && !isPinned) { - const frame = alignedData; - const fields = frame.fields; - const sortIdx: unknown[] = []; - - for (let i = 0; i < fields.length; i++) { - const field = frame.fields[i]; - if ( - !field || - field === xField || - field.type === FieldType.time || - field.config.custom?.hideFrom?.tooltip || - field.config.custom?.hideFrom?.viz - ) { - continue; - } - - const fieldFmt = field.display || getDisplayProcessor(); - const v = field.values[datapointIdx!]; - const display = fieldFmt(v); - - sortIdx.push(v); - contentLabelValue.push({ - label: getFieldDisplayName(field), - value: fmt(field, field.values[datapointIdx]), - color: display.color, - colorIndicator: ColorIndicator.value, - colorPlacement: ColorPlacement.trailing, - isActive: seriesIdx === i, - }); - } - - if (sortOrder !== SortOrder.None) { - // create sort reference series array, as Array.sort() mutates the original array - const sortRef = [...contentLabelValue]; - const sortFn = arrayUtils.sortValues(sortOrder); - - contentLabelValue.sort((a, b) => { - // get compared values indices to retrieve raw values from sortIdx - const aIdx = sortRef.indexOf(a); - const bIdx = sortRef.indexOf(b); - return sortFn(sortIdx[aIdx], sortIdx[bIdx]); - }); - } - } - - const getHeaderLabel = (): LabelValue => { - return { - label: '', - value: fmt(xField, xField.values[datapointIdx]), - }; - }; - - const getContentLabelValue = () => { - return contentLabelValue; - }; - - return ( - <div className={styles.wrapper}> - <VizTooltipHeader headerLabel={getHeaderLabel()} /> - <VizTooltipContent contentLabelValue={getContentLabelValue()} /> - {isPinned && <VizTooltipFooter dataLinks={links} canAnnotate={false} />} - </div> - ); -}; - -const getStyles = (theme: GrafanaTheme2) => ({ - wrapper: css({ - display: 'flex', - flexDirection: 'column', - width: '280px', - }), -}); diff --git a/public/app/plugins/panel/status-history/panelcfg.gen.ts b/public/app/plugins/panel/status-history/panelcfg.gen.ts index ef8b01455defc..8bc46e363d0d7 100644 --- a/public/app/plugins/panel/status-history/panelcfg.gen.ts +++ b/public/app/plugins/panel/status-history/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/status-history/utils.ts b/public/app/plugins/panel/status-history/utils.ts index 4f26288ba0321..946424eb2ec32 100644 --- a/public/app/plugins/panel/status-history/utils.ts +++ b/public/app/plugins/panel/status-history/utils.ts @@ -1,13 +1,13 @@ import { Field, LinkModel } from '@grafana/data'; -export const getDataLinks = (field: Field, datapointIdx: number) => { +export const getDataLinks = (field: Field, rowIdx: number) => { const links: Array<LinkModel<Field>> = []; const linkLookup = new Set<string>(); - if (field.getLinks) { - const v = field.values[datapointIdx]; + if ((field.config.links?.length ?? 0) > 0 && field.getLinks != null) { + const v = field.values[rowIdx]; const disp = field.display ? field.display(v) : { text: `${v}`, numeric: +v }; - field.getLinks({ calculatedValue: disp, valueRowIndex: datapointIdx }).forEach((link) => { + field.getLinks({ calculatedValue: disp, valueRowIndex: rowIdx }).forEach((link) => { const key = `${link.title}/${link.href}`; if (!linkLookup.has(key)) { links.push(link); diff --git a/public/app/plugins/panel/table-old/module.ts b/public/app/plugins/panel/table-old/module.ts index e6fa9c8ca7535..82d6903929efd 100644 --- a/public/app/plugins/panel/table-old/module.ts +++ b/public/app/plugins/panel/table-old/module.ts @@ -1,7 +1,9 @@ +import { IScope, IAngularStatic } from 'angular'; import $ from 'jquery'; import { defaults } from 'lodash'; import { isTableData, PanelEvents, PanelPlugin } from '@grafana/data'; +import { AnnotationsSrv } from 'app/angular/services/annotations_srv'; import config from 'app/core/config'; import { applyFilterFromTable } from 'app/features/variables/adhoc/actions'; import { MetricsPanelCtrl } from 'app/plugins/sdk'; @@ -56,9 +58,9 @@ export class TablePanelCtrl extends MetricsPanelCtrl { static $inject = ['$scope', '$injector', 'annotationsSrv', '$sanitize']; constructor( - $scope: any, - $injector: any, - private annotationsSrv: any, + $scope: IScope, + $injector: IAngularStatic['injector'], + private annotationsSrv: AnnotationsSrv, private $sanitize: any ) { super($scope, $injector); @@ -101,12 +103,11 @@ export class TablePanelCtrl extends MetricsPanelCtrl { panel: this.panel, range: this.range, }) - .then((anno: any) => { + .then((anno) => { this.loading = false; this.dataRaw = anno; this.pageIndex = 0; this.render(); - return { data: this.dataRaw }; // Not used }); } @@ -170,7 +171,7 @@ export class TablePanelCtrl extends MetricsPanelCtrl { this.render(); } - link(scope: any, elem: JQuery, attrs: any, ctrl: TablePanelCtrl) { + link(scope: IScope, elem: JQuery, attrs: any, ctrl: TablePanelCtrl) { let data: any; const panel = ctrl.panel; let pageCount = 0; @@ -191,7 +192,7 @@ export class TablePanelCtrl extends MetricsPanelCtrl { tbodyElem.html(ctrl.renderer.render(ctrl.pageIndex)); } - function switchPage(e: any) { + function switchPage(e: JQueryEventObject) { const el = $(e.currentTarget); ctrl.pageIndex = parseInt(el.text(), 10) - 1; renderPanel(); @@ -263,7 +264,7 @@ export class TablePanelCtrl extends MetricsPanelCtrl { unbindDestroy(); }); - ctrl.events.on(PanelEvents.render, (renderData: any) => { + ctrl.events.on(PanelEvents.render, (renderData: unknown) => { data = renderData || data; if (data) { renderPanel(); diff --git a/public/app/plugins/panel/table/TableCellOptionEditor.tsx b/public/app/plugins/panel/table/TableCellOptionEditor.tsx index f03957ff0a84d..1374b5ee64ca9 100644 --- a/public/app/plugins/panel/table/TableCellOptionEditor.tsx +++ b/public/app/plugins/panel/table/TableCellOptionEditor.tsx @@ -79,6 +79,7 @@ const cellDisplayModeOptions: Array<SelectableValue<TableCellOptions>> = [ { value: { type: TableCellDisplayMode.ColorText }, label: 'Colored text' }, { value: { type: TableCellDisplayMode.ColorBackground }, label: 'Colored background' }, { value: { type: TableCellDisplayMode.Gauge }, label: 'Gauge' }, + { value: { type: TableCellDisplayMode.DataLinks }, label: 'Data links' }, { value: { type: TableCellDisplayMode.JSONView }, label: 'JSON View' }, { value: { type: TableCellDisplayMode.Image }, label: 'Image' }, ]; diff --git a/public/app/plugins/panel/table/TablePanel.tsx b/public/app/plugins/panel/table/TablePanel.tsx index 15df95afde44d..e3d9d9f774d40 100644 --- a/public/app/plugins/panel/table/TablePanel.tsx +++ b/public/app/plugins/panel/table/TablePanel.tsx @@ -63,6 +63,7 @@ export function TablePanel(props: Props) { cellHeight={options.cellHeight} timeRange={timeRange} enableSharedCrosshair={config.featureToggles.tableSharedCrosshair && enableSharedCrosshair} + fieldConfig={fieldConfig} /> ); diff --git a/public/app/plugins/panel/table/migrations.test.ts b/public/app/plugins/panel/table/migrations.test.ts index fd952995ce57e..a40120c885725 100644 --- a/public/app/plugins/panel/table/migrations.test.ts +++ b/public/app/plugins/panel/table/migrations.test.ts @@ -126,6 +126,7 @@ describe('Table Migrations', () => { ], }, }; + const panel = {} as PanelModel; tablePanelChangedHandler(panel, 'table-old', oldStyles); expect(panel).toMatchInlineSnapshot(` @@ -269,6 +270,38 @@ describe('Table Migrations', () => { `); }); + it('migrates hidden fields to override', () => { + const oldStyles = { + angular: { + columns: [], + styles: [ + { + dateFormat: 'YYYY-MM-DD HH:mm:ss', + pattern: 'time', + type: 'hidden', + }, + ], + }, + }; + + const panel = {} as PanelModel; + tablePanelChangedHandler(panel, 'table-old', oldStyles); + expect(panel.fieldConfig.overrides).toEqual([ + { + matcher: { + id: 'byName', + options: 'time', + }, + properties: [ + { + id: 'custom.hidden', + value: true, + }, + ], + }, + ]); + }); + it('migrates DataFrame[] from format using meta.custom.parentRowIndex to format using FieldType.nestedFrames', () => { const mainFrame = (refId: string) => { return createDataFrame({ diff --git a/public/app/plugins/panel/table/migrations.ts b/public/app/plugins/panel/table/migrations.ts index f05ec19e05133..f92d5b60836cd 100644 --- a/public/app/plugins/panel/table/migrations.ts +++ b/public/app/plugins/panel/table/migrations.ts @@ -150,6 +150,13 @@ const migrateTableStyleToOverride = (style: Style) => { }); } + if (style.type === 'hidden') { + override.properties.push({ + id: 'custom.hidden', + value: true, + }); + } + if (style.link) { override.properties.push({ id: 'links', diff --git a/public/app/plugins/panel/table/module.tsx b/public/app/plugins/panel/table/module.tsx index 17f62aff423b7..f930e45de19b9 100644 --- a/public/app/plugins/panel/table/module.tsx +++ b/public/app/plugins/panel/table/module.tsx @@ -53,10 +53,10 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel) name: 'Column alignment', settings: { options: [ - { label: 'auto', value: 'auto' }, - { label: 'left', value: 'left' }, - { label: 'center', value: 'center' }, - { label: 'right', value: 'right' }, + { label: 'Auto', value: 'auto' }, + { label: 'Left', value: 'left' }, + { label: 'Center', value: 'center' }, + { label: 'Right', value: 'right' }, ], }, defaultValue: defaultTableFieldOptions.align, @@ -169,9 +169,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel) }, }, defaultValue: '', - showIf: (cfg) => - (cfg.footer?.show && !cfg.footer?.countRows) || - (cfg.footer?.reducer?.length === 1 && cfg.footer?.reducer[0] !== ReducerID.count), + showIf: (cfg) => cfg.footer?.show && !cfg.footer?.countRows, }) .addCustomEditor({ id: 'footer.enablePagination', diff --git a/public/app/plugins/panel/table/panelcfg.gen.ts b/public/app/plugins/panel/table/panelcfg.gen.ts index fa44e7f982eb5..5b771ff2640c1 100644 --- a/public/app/plugins/panel/table/panelcfg.gen.ts +++ b/public/app/plugins/panel/table/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/text/TextPanel.tsx b/public/app/plugins/panel/text/TextPanel.tsx index d831532f48ad1..1d040905cdb20 100644 --- a/public/app/plugins/panel/text/TextPanel.tsx +++ b/public/app/plugins/panel/text/TextPanel.tsx @@ -53,6 +53,7 @@ export function TextPanel(props: Props) { return ( <CustomScrollbar autoHeightMin="100%" className={styles.containStrict}> <DangerouslySetHtmlContent + allowRerender html={processed.content} className={styles.markdown} data-testid="TextPanel-converted-content" @@ -64,7 +65,7 @@ export function TextPanel(props: Props) { function processContent(options: Options, interpolate: InterpolateFunction, disableSanitizeHtml: boolean): string { let { mode, content } = options; if (!content) { - return ''; + return ' '; } // Variables must be interpolated before content is converted to markdown so using variables diff --git a/public/app/plugins/panel/text/panelcfg.gen.ts b/public/app/plugins/panel/text/panelcfg.gen.ts index 8b35a85ed209e..cf08ce2468345 100644 --- a/public/app/plugins/panel/text/panelcfg.gen.ts +++ b/public/app/plugins/panel/text/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/timeseries/ThresholdsStyleEditor.tsx b/public/app/plugins/panel/timeseries/ThresholdsStyleEditor.tsx index 431ca7094fa63..bd67c199106a8 100644 --- a/public/app/plugins/panel/timeseries/ThresholdsStyleEditor.tsx +++ b/public/app/plugins/panel/timeseries/ThresholdsStyleEditor.tsx @@ -1,17 +1,17 @@ import React, { useCallback } from 'react'; import { StandardEditorProps, SelectableValue } from '@grafana/data'; -import { GraphTresholdsStyleMode } from '@grafana/schema'; +import { GraphThresholdsStyleMode } from '@grafana/schema'; import { Select } from '@grafana/ui'; type Props = StandardEditorProps< - SelectableValue<{ mode: GraphTresholdsStyleMode }>, - { options: Array<SelectableValue<GraphTresholdsStyleMode>> } + SelectableValue<{ mode: GraphThresholdsStyleMode }>, + { options: Array<SelectableValue<GraphThresholdsStyleMode>> } >; export const ThresholdsStyleEditor = ({ item, value, onChange, id }: Props) => { const onChangeCb = useCallback( - (v: SelectableValue<GraphTresholdsStyleMode>) => { + (v: SelectableValue<GraphThresholdsStyleMode>) => { onChange({ mode: v.value, }); diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index 7cb96db9f22bd..792d1e4000cda 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -1,10 +1,17 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; -import { PanelProps, DataFrameType } from '@grafana/data'; +import { PanelProps, DataFrameType, DashboardCursorSync } from '@grafana/data'; import { PanelDataErrorView } from '@grafana/runtime'; -import { TooltipDisplayMode } from '@grafana/schema'; -import { KeyboardPlugin, TooltipPlugin, TooltipPlugin2, usePanelContext, ZoomPlugin } from '@grafana/ui'; -import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; +import { TooltipDisplayMode, VizOrientation } from '@grafana/schema'; +import { + EventBusPlugin, + KeyboardPlugin, + TooltipPlugin, + TooltipPlugin2, + usePanelContext, + ZoomPlugin, +} from '@grafana/ui'; +import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; import { config } from 'app/core/config'; @@ -12,12 +19,13 @@ import { TimeSeriesTooltip } from './TimeSeriesTooltip'; import { Options } from './panelcfg.gen'; import { AnnotationEditorPlugin } from './plugins/AnnotationEditorPlugin'; import { AnnotationsPlugin } from './plugins/AnnotationsPlugin'; +import { AnnotationsPlugin2 } from './plugins/AnnotationsPlugin2'; import { ContextMenuPlugin } from './plugins/ContextMenuPlugin'; import { ExemplarsPlugin, getVisibleLabels } from './plugins/ExemplarsPlugin'; import { OutsideRangePlugin } from './plugins/OutsideRangePlugin'; import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin'; import { getPrepareTimeseriesSuggestion } from './suggestions'; -import { getTimezones, prepareGraphableFields, regenerateLinksSupplier } from './utils'; +import { getTimezones, isTooltipScrollable, prepareGraphableFields } from './utils'; interface TimeSeriesPanelProps extends PanelProps<Options> {} @@ -33,9 +41,18 @@ export const TimeSeriesPanel = ({ replaceVariables, id, }: TimeSeriesPanelProps) => { - const { sync, canAddAnnotations, onThresholdsChange, canEditThresholds, showThresholds, dataLinkPostProcessor } = - usePanelContext(); - + const { + sync, + canAddAnnotations, + onThresholdsChange, + canEditThresholds, + showThresholds, + dataLinkPostProcessor, + eventBus, + } = usePanelContext(); + // Vertical orientation is not available for users through config. + // It is simplified version of horizontal time series panel and it does not support all plugins. + const isVerticallyOriented = options.orientation === VizOrientation.Vertical; const frames = useMemo(() => prepareGraphableFields(data.series, config.theme2, timeRange), [data.series, timeRange]); const timezones = useMemo(() => getTimezones(options.timezone, timeZone), [options.timezone, timeZone]); const suggestions = useMemo(() => { @@ -49,6 +66,24 @@ export const TimeSeriesPanel = ({ return undefined; }, [frames, id]); + const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); + const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips); + // temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2 + const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null); + + // TODO: we should just re-init when this changes, and have this be a static setting + const syncTooltip = useCallback( + () => sync?.() === DashboardCursorSync.Tooltip, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const syncAny = useCallback( + () => sync?.() !== DashboardCursorSync.Off, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + if (!frames || suggestions) { return ( <PanelDataErrorView @@ -63,7 +98,13 @@ export const TimeSeriesPanel = ({ ); } - const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); + // which annotation are we editing? + // are we adding a new annotation? is annotating? + // console.log(data.annotations); + + // annotations plugin includes the editor and the renderer + // its annotation state is managed here for now + // tooltipplugin2 receives render with annotate range, callback should setstate here that gets passed to annotationsplugin as newAnnotaton or editAnnotation return ( <TimeSeries @@ -75,24 +116,17 @@ export const TimeSeriesPanel = ({ height={height} legend={options.legend} options={options} + replaceVariables={replaceVariables} + dataLinkPostProcessor={dataLinkPostProcessor} > - {(uplotConfig, alignedDataFrame) => { - if (alignedDataFrame.fields.some((f) => Boolean(f.config.links?.length))) { - alignedDataFrame = regenerateLinksSupplier( - alignedDataFrame, - frames, - replaceVariables, - timeZone, - dataLinkPostProcessor - ); - } - + {(uplotConfig, alignedFrame) => { return ( <> <KeyboardPlugin config={uplotConfig} /> + <EventBusPlugin config={uplotConfig} sync={syncAny} eventBus={eventBus} frame={alignedFrame} /> {options.tooltip.mode === TooltipDisplayMode.None || ( <> - {config.featureToggles.newVizTooltips ? ( + {showNewVizTooltips ? ( <TooltipPlugin2 config={uplotConfig} hoverMode={ @@ -100,26 +134,45 @@ export const TimeSeriesPanel = ({ } queryZoom={onChangeTimeRange} clientZoom={true} - render={(u, dataIdxs, seriesIdx, isPinned = false) => { + syncTooltip={syncTooltip} + render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync) => { + if (enableAnnotationCreation && timeRange2 != null) { + setNewAnnotationRange(timeRange2); + dismiss(); + return; + } + + const annotate = () => { + let xVal = u.posToVal(u.cursor.left!, 'x'); + + setNewAnnotationRange({ from: xVal, to: xVal }); + dismiss(); + }; + return ( + // not sure it header time here works for annotations, since it's taken from nearest datapoint index <TimeSeriesTooltip frames={frames} - seriesFrame={alignedDataFrame} + seriesFrame={alignedFrame} dataIdxs={dataIdxs} seriesIdx={seriesIdx} - mode={options.tooltip.mode} + mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode} sortOrder={options.tooltip.sort} isPinned={isPinned} + annotate={enableAnnotationCreation ? annotate : undefined} + scrollable={isTooltipScrollable(options.tooltip)} /> ); }} + maxWidth={options.tooltip.maxWidth} + maxHeight={options.tooltip.maxHeight} /> ) : ( <> <ZoomPlugin config={uplotConfig} onZoom={onChangeTimeRange} withZoomY={true} /> <TooltipPlugin frames={frames} - data={alignedDataFrame} + data={alignedFrame} config={uplotConfig} mode={options.tooltip.mode} sortOrder={options.tooltip.sort} @@ -131,17 +184,29 @@ export const TimeSeriesPanel = ({ </> )} {/* Renders annotation markers*/} - {data.annotations && ( - <AnnotationsPlugin annotations={data.annotations} config={uplotConfig} timeZone={timeZone} /> + {!isVerticallyOriented && showNewVizTooltips ? ( + <AnnotationsPlugin2 + annotations={data.annotations ?? []} + config={uplotConfig} + timeZone={timeZone} + newRange={newAnnotationRange} + setNewRange={setNewAnnotationRange} + /> + ) : ( + !isVerticallyOriented && + data.annotations && ( + <AnnotationsPlugin annotations={data.annotations} config={uplotConfig} timeZone={timeZone} /> + ) )} + {/*Enables annotations creation*/} - {!config.featureToggles.newVizTooltips ? ( - enableAnnotationCreation ? ( - <AnnotationEditorPlugin data={alignedDataFrame} timeZone={timeZone} config={uplotConfig}> + {!showNewVizTooltips ? ( + enableAnnotationCreation && !isVerticallyOriented ? ( + <AnnotationEditorPlugin data={alignedFrame} timeZone={timeZone} config={uplotConfig}> {({ startAnnotating }) => { return ( <ContextMenuPlugin - data={alignedDataFrame} + data={alignedFrame} config={uplotConfig} timeZone={timeZone} replaceVariables={replaceVariables} @@ -168,7 +233,7 @@ export const TimeSeriesPanel = ({ </AnnotationEditorPlugin> ) : ( <ContextMenuPlugin - data={alignedDataFrame} + data={alignedFrame} frames={frames} config={uplotConfig} timeZone={timeZone} @@ -177,7 +242,7 @@ export const TimeSeriesPanel = ({ /> ) ) : undefined} - {data.annotations && ( + {data.annotations && !isVerticallyOriented && ( <ExemplarsPlugin visibleSeries={getVisibleLabels(uplotConfig, frames)} config={uplotConfig} @@ -186,7 +251,7 @@ export const TimeSeriesPanel = ({ /> )} - {((canEditThresholds && onThresholdsChange) || showThresholds) && ( + {((canEditThresholds && onThresholdsChange) || showThresholds) && !isVerticallyOriented && ( <ThresholdControlsPlugin config={uplotConfig} fieldConfig={fieldConfig} diff --git a/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx b/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx index 491b887a9217d..997d7e43c21e1 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx @@ -1,32 +1,21 @@ import { css } from '@emotion/css'; -import React from 'react'; - -import { - DataFrame, - FALLBACK_COLOR, - FieldType, - GrafanaTheme2, - formattedValueToString, - getDisplayProcessor, - LinkModel, - Field, - getFieldDisplayName, - arrayUtils, -} from '@grafana/data'; +import React, { ReactNode } from 'react'; + +import { DataFrame, FieldType, getFieldDisplayName } from '@grafana/data'; import { SortOrder, TooltipDisplayMode } from '@grafana/schema/dist/esm/common/common.gen'; -import { useStyles2, useTheme2 } from '@grafana/ui'; +import { useStyles2 } from '@grafana/ui'; import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader'; -import { ColorIndicator, ColorPlacement, LabelValue } from '@grafana/ui/src/components/VizTooltip/types'; -import { DEFAULT_TOOLTIP_WIDTH } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; +import { VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types'; +import { getContentItems } from '@grafana/ui/src/components/VizTooltip/utils'; import { getDataLinks } from '../status-history/utils'; // exemplar / annotation / time region hovering? // add annotation UI / alert dismiss UI? -interface TimeSeriesTooltipProps { +export interface TimeSeriesTooltipProps { frames?: DataFrame[]; // aligned series frame seriesFrame: DataFrame; @@ -38,6 +27,9 @@ interface TimeSeriesTooltipProps { sortOrder?: SortOrder; isPinned: boolean; + scrollable?: boolean; + + annotate?: () => void; } export const TimeSeriesTooltip = ({ @@ -47,116 +39,53 @@ export const TimeSeriesTooltip = ({ seriesIdx, mode = TooltipDisplayMode.Single, sortOrder = SortOrder.None, + scrollable = false, isPinned, + annotate, }: TimeSeriesTooltipProps) => { - const theme = useTheme2(); const styles = useStyles2(getStyles); const xField = seriesFrame.fields[0]; - if (!xField) { - return null; - } - const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, theme }); - let xVal = xFieldFmt(xField!.values[dataIdxs[0]!]).text; - let links: Array<LinkModel<Field>> = []; - let contentLabelValue: LabelValue[] = []; - - // Single mode - if (mode === TooltipDisplayMode.Single || isPinned) { - const field = seriesFrame.fields[seriesIdx!]; - if (!field) { - return null; - } - - const dataIdx = dataIdxs[seriesIdx!]!; - xVal = xFieldFmt(xField!.values[dataIdx]).text; - const fieldFmt = field.display || getDisplayProcessor({ field, theme }); - const display = fieldFmt(field.values[dataIdx]); - links = getDataLinks(field, dataIdx); - - contentLabelValue = [ - { - label: getFieldDisplayName(field, seriesFrame, frames), - value: display ? formattedValueToString(display) : null, - color: display.color || FALLBACK_COLOR, - colorIndicator: ColorIndicator.series, - colorPlacement: ColorPlacement.first, - }, - ]; - } + const xVal = xField.display!(xField.values[dataIdxs[0]!]).text; - if (mode === TooltipDisplayMode.Multi && !isPinned) { - const fields = seriesFrame.fields; - const sortIdx: unknown[] = []; - - for (let i = 0; i < fields.length; i++) { - const field = seriesFrame.fields[i]; - if ( - !field || - field === xField || - field.type === FieldType.time || - field.type !== FieldType.number || - field.config.custom?.hideFrom?.tooltip || - field.config.custom?.hideFrom?.viz - ) { - continue; - } - - const v = seriesFrame.fields[i].values[dataIdxs[i]!]; - const display = field.display!(v); // super expensive :( - - sortIdx.push(v); - contentLabelValue.push({ - label: field.state?.displayName ?? field.name, - value: display ? formattedValueToString(display) : null, - color: display.color || FALLBACK_COLOR, - colorIndicator: ColorIndicator.series, - colorPlacement: ColorPlacement.first, - isActive: seriesIdx === i, - }); - - if (sortOrder !== SortOrder.None) { - // create sort reference series array, as Array.sort() mutates the original array - const sortRef = [...contentLabelValue]; - const sortFn = arrayUtils.sortValues(sortOrder); - - contentLabelValue.sort((a, b) => { - // get compared values indices to retrieve raw values from sortIdx - const aIdx = sortRef.indexOf(a); - const bIdx = sortRef.indexOf(b); - return sortFn(sortIdx[aIdx], sortIdx[bIdx]); - }); - } - } - } + const contentItems = getContentItems( + seriesFrame.fields, + xField, + dataIdxs, + seriesIdx, + mode, + sortOrder, + (field) => field.type === FieldType.number || field.type === FieldType.enum + ); - const getHeaderLabel = (): LabelValue => { - return { - label: '', - value: xVal, - }; - }; + let footer: ReactNode; + + if (isPinned && seriesIdx != null) { + const field = seriesFrame.fields[seriesIdx]; + const dataIdx = dataIdxs[seriesIdx]!; + const links = getDataLinks(field, dataIdx); + + footer = <VizTooltipFooter dataLinks={links} annotate={annotate} />; + } - const getContentLabelValue = () => { - return contentLabelValue; + const headerItem: VizTooltipItem = { + label: xField.type === FieldType.time ? '' : getFieldDisplayName(xField, seriesFrame, frames), + value: xVal, }; return ( - <div> - <div className={styles.wrapper}> - <VizTooltipHeader headerLabel={getHeaderLabel()} /> - <VizTooltipContent contentLabelValue={getContentLabelValue()} /> - {isPinned && <VizTooltipFooter dataLinks={links} canAnnotate={false} />} - </div> + <div className={styles.wrapper}> + <VizTooltipHeader item={headerItem} isPinned={isPinned} /> + <VizTooltipContent items={contentItems} isPinned={isPinned} scrollable={scrollable} /> + {footer} </div> ); }; -const getStyles = (theme: GrafanaTheme2) => ({ +export const getStyles = () => ({ wrapper: css({ display: 'flex', flexDirection: 'column', - width: DEFAULT_TOOLTIP_WIDTH, }), }); diff --git a/public/app/plugins/panel/timeseries/__snapshots__/migrations.test.ts.snap b/public/app/plugins/panel/timeseries/__snapshots__/migrations.test.ts.snap index 8c4bec6a30b08..272f0ec96d03a 100644 --- a/public/app/plugins/panel/timeseries/__snapshots__/migrations.test.ts.snap +++ b/public/app/plugins/panel/timeseries/__snapshots__/migrations.test.ts.snap @@ -656,6 +656,10 @@ exports[`Graph Migrations twoYAxis 1`] = ` "id": "max", "value": 25, }, + { + "id": "custom.axisPlacement", + "value": "right", + }, { "id": "custom.axisLabel", "value": "Y222", diff --git a/public/app/plugins/panel/timeseries/config.ts b/public/app/plugins/panel/timeseries/config.ts index a68b457287411..ebb750b71cc42 100644 --- a/public/app/plugins/panel/timeseries/config.ts +++ b/public/app/plugins/panel/timeseries/config.ts @@ -15,7 +15,7 @@ import { LineStyle, VisibilityMode, StackingMode, - GraphTresholdsStyleMode, + GraphThresholdsStyleMode, GraphTransform, } from '@grafana/schema'; import { graphFieldOptions, commonOptionsBuilder } from '@grafana/ui'; @@ -228,7 +228,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig, isTime = true): SetFi path: 'thresholdsStyle', name: 'Show thresholds', category: ['Thresholds'], - defaultValue: { mode: GraphTresholdsStyleMode.Off }, + defaultValue: { mode: GraphThresholdsStyleMode.Off }, settings: { options: graphFieldOptions.thresholdsDisplayModes, }, diff --git a/public/app/plugins/panel/timeseries/migrations.ts b/public/app/plugins/panel/timeseries/migrations.ts index fa138cd89a717..7acc221794fe7 100644 --- a/public/app/plugins/panel/timeseries/migrations.ts +++ b/public/app/plugins/panel/timeseries/migrations.ts @@ -23,7 +23,7 @@ import { GraphDrawStyle, GraphFieldConfig, GraphGradientMode, - GraphTresholdsStyleMode, + GraphThresholdsStyleMode, LineInterpolation, LineStyle, VisibilityMode, @@ -399,7 +399,7 @@ export function graphToTimeseriesOptions(angular: any): { // timeRegions migration if (angular.timeRegions?.length) { - let regions: any[] = angular.timeRegions.map((old: GraphTimeRegionConfig, idx: number) => ({ + let regions = angular.timeRegions.map((old: GraphTimeRegionConfig, idx: number) => ({ name: `T${idx + 1}`, color: old.colorMode !== 'custom' ? old.colorMode : old.fillColor, line: old.line, @@ -529,9 +529,9 @@ export function graphToTimeseriesOptions(angular: any): { }); } - let displayMode = area ? GraphTresholdsStyleMode.Area : GraphTresholdsStyleMode.Line; + let displayMode = area ? GraphThresholdsStyleMode.Area : GraphThresholdsStyleMode.Line; if (line && area) { - displayMode = GraphTresholdsStyleMode.LineAndArea; + displayMode = GraphThresholdsStyleMode.LineAndArea; } // TODO move into standard ThresholdConfig ? @@ -658,6 +658,11 @@ function fillY2DynamicValues( } } + props.push({ + id: `custom.axisPlacement`, + value: AxisPlacement.Right, + }); + // Add any custom property const y1G = y1.custom ?? {}; const y2G = y2.custom ?? {}; @@ -684,7 +689,7 @@ function validNumber(val: unknown): number | undefined { return undefined; } -function getReducersFromLegend(obj: Record<string, any>): string[] { +function getReducersFromLegend(obj: Record<string, unknown>): string[] { const ids: string[] = []; for (const key of Object.keys(obj)) { const r = fieldReducers.getIfExists(key); diff --git a/public/app/plugins/panel/timeseries/module.tsx b/public/app/plugins/panel/timeseries/module.tsx index f0f46f2b6a164..4368a297151e1 100644 --- a/public/app/plugins/panel/timeseries/module.tsx +++ b/public/app/plugins/panel/timeseries/module.tsx @@ -12,7 +12,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TimeSeriesPanel) .setPanelChangeHandler(graphPanelChangedHandler) .useFieldConfig(getGraphFieldConfig(defaultGraphConfig)) .setPanelOptions((builder) => { - commonOptionsBuilder.addTooltipOptions(builder); + commonOptionsBuilder.addTooltipOptions(builder, false, true); commonOptionsBuilder.addLegendOptions(builder); builder.addCustomEditor({ diff --git a/public/app/plugins/panel/timeseries/panelcfg.cue b/public/app/plugins/panel/timeseries/panelcfg.cue index 5d506e8996388..0d2f6decd6796 100644 --- a/public/app/plugins/panel/timeseries/panelcfg.cue +++ b/public/app/plugins/panel/timeseries/panelcfg.cue @@ -23,8 +23,9 @@ composableKinds: PanelCfg: lineage: { version: [0, 0] schema: { Options: common.OptionsWithTimezones & { - legend: common.VizLegendOptions - tooltip: common.VizTooltipOptions + legend: common.VizLegendOptions + tooltip: common.VizTooltipOptions + orientation?: common.VizOrientation } @cuetsy(kind="interface") FieldConfig: common.GraphFieldConfig & {} @cuetsy(kind="interface") diff --git a/public/app/plugins/panel/timeseries/panelcfg.gen.ts b/public/app/plugins/panel/timeseries/panelcfg.gen.ts index ff2394dc7d19b..e5e4bce7e7b04 100644 --- a/public/app/plugins/panel/timeseries/panelcfg.gen.ts +++ b/public/app/plugins/panel/timeseries/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -12,6 +12,7 @@ import * as common from '@grafana/schema'; export interface Options extends common.OptionsWithTimezones { legend: common.VizLegendOptions; + orientation?: common.VizOrientation; tooltip: common.VizTooltipOptions; } diff --git a/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx new file mode 100644 index 0000000000000..8c0313d1ca476 --- /dev/null +++ b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx @@ -0,0 +1,263 @@ +import { css } from '@emotion/css'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useReducer } from 'react'; +import { createPortal } from 'react-dom'; +import tinycolor from 'tinycolor2'; +import uPlot from 'uplot'; + +import { arrayToDataFrame, colorManipulator, DataFrame, DataTopic } from '@grafana/data'; +import { TimeZone } from '@grafana/schema'; +import { DEFAULT_ANNOTATION_COLOR, getPortalContainer, UPlotConfigBuilder, useStyles2, useTheme2 } from '@grafana/ui'; + +import { AnnotationMarker2 } from './annotations2/AnnotationMarker2'; + +// (copied from TooltipPlugin2) +interface TimeRange2 { + from: number; + to: number; +} + +interface AnnotationsPluginProps { + config: UPlotConfigBuilder; + annotations: DataFrame[]; + timeZone: TimeZone; + newRange: TimeRange2 | null; + setNewRange: (newRage: TimeRange2 | null) => void; + canvasRegionRendering?: boolean; +} + +// TODO: batch by color, use Path2D objects +const renderLine = (ctx: CanvasRenderingContext2D, y0: number, y1: number, x: number, color: string) => { + ctx.beginPath(); + ctx.moveTo(x, y0); + ctx.lineTo(x, y1); + ctx.strokeStyle = color; + ctx.stroke(); +}; + +// const renderUpTriangle = (ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, color: string) => { +// ctx.beginPath(); +// ctx.moveTo(x - w/2, y + h/2); +// ctx.lineTo(x + w/2, y + h/2); +// ctx.lineTo(x, y); +// ctx.closePath(); +// ctx.fillStyle = color; +// ctx.fill(); +// } + +const DEFAULT_ANNOTATION_COLOR_HEX8 = tinycolor(DEFAULT_ANNOTATION_COLOR).toHex8String(); + +function getVals(frame: DataFrame) { + let vals: Record<string, any[]> = {}; + frame.fields.forEach((f) => { + vals[f.name] = f.values; + }); + + return vals; +} + +export const AnnotationsPlugin2 = ({ + annotations, + timeZone, + config, + newRange, + setNewRange, + canvasRegionRendering = true, +}: AnnotationsPluginProps) => { + const [plot, setPlot] = useState<uPlot>(); + + const [portalRoot] = useState(() => getPortalContainer()); + + const styles = useStyles2(getStyles); + const getColorByName = useTheme2().visualization.getColorByName; + + const [_, forceUpdate] = useReducer((x) => x + 1, 0); + + const annos = useMemo(() => { + let annos = annotations.filter( + (frame) => frame.name !== 'exemplar' && frame.length > 0 && frame.fields.some((f) => f.name === 'time') + ); + + if (newRange) { + let isRegion = newRange.to > newRange.from; + + const wipAnnoFrame = arrayToDataFrame([ + { + time: newRange.from, + timeEnd: isRegion ? newRange.to : null, + isRegion: isRegion, + color: DEFAULT_ANNOTATION_COLOR_HEX8, + }, + ]); + + wipAnnoFrame.meta = { + dataTopic: DataTopic.Annotations, + custom: { + isWip: true, + }, + }; + + annos.push(wipAnnoFrame); + } + + return annos; + }, [annotations, newRange]); + + const exitWipEdit = useCallback(() => { + setNewRange(null); + }, [setNewRange]); + + const annoRef = useRef(annos); + annoRef.current = annos; + const newRangeRef = useRef(newRange); + newRangeRef.current = newRange; + + const xAxisRef = useRef<HTMLDivElement>(); + + useLayoutEffect(() => { + config.addHook('ready', (u) => { + let xAxisEl = u.root.querySelector<HTMLDivElement>('.u-axis')!; + xAxisRef.current = xAxisEl; + setPlot(u); + }); + + config.addHook('draw', (u) => { + let annos = annoRef.current; + + const ctx = u.ctx; + + let y0 = u.bbox.top; + let y1 = y0 + u.bbox.height; + + ctx.save(); + + ctx.beginPath(); + ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + ctx.clip(); + + ctx.lineWidth = 2; + ctx.setLineDash([5, 5]); + + annos.forEach((frame) => { + let vals = getVals(frame); + + for (let i = 0; i < vals.time.length; i++) { + let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR_HEX8); + + let x0 = u.valToPos(vals.time[i], 'x', true); + + if (!vals.isRegion?.[i]) { + renderLine(ctx, y0, y1, x0, color); + // renderUpTriangle(ctx, x0, y1, 8 * uPlot.pxRatio, 5 * uPlot.pxRatio, color); + } else if (canvasRegionRendering) { + renderLine(ctx, y0, y1, x0, color); + + let x1 = u.valToPos(vals.timeEnd[i], 'x', true); + + renderLine(ctx, y0, y1, x1, color); + + ctx.fillStyle = colorManipulator.alpha(color, 0.1); + ctx.fillRect(x0, y0, x1 - x0, u.bbox.height); + } + } + }); + + ctx.restore(); + }); + }, [config, canvasRegionRendering, getColorByName]); + + // ensure annos are re-drawn whenever they change + useEffect(() => { + if (plot) { + plot.redraw(); + + // this forces a second redraw after uPlot is updated (in the Plot.tsx didUpdate) with new data/scales + // and ensures the anno marker positions in the dom are re-rendered in correct places + // (this is temp fix until uPlot integrtion is refactored) + setTimeout(() => { + forceUpdate(); + }, 0); + } + }, [annos, plot]); + + if (plot) { + let markers = annos.flatMap((frame, frameIdx) => { + let vals = getVals(frame); + + let markers: React.ReactNode[] = []; + + for (let i = 0; i < vals.time.length; i++) { + let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR); + let left = plot.valToPos(vals.time[i], 'x'); + let style: React.CSSProperties | null = null; + let className = ''; + let isVisible = true; + + if (vals.isRegion?.[i]) { + let right = plot.valToPos(vals.timeEnd?.[i], 'x'); + + isVisible = left < plot.rect.width && right > 0; + + if (isVisible) { + let clampedLeft = Math.max(0, left); + let clampedRight = Math.min(plot.rect.width, right); + + style = { left: clampedLeft, background: color, width: clampedRight - clampedLeft }; + className = styles.annoRegion; + } + } else { + isVisible = left > 0 && left <= plot.rect.width; + + if (isVisible) { + style = { left, borderBottomColor: color }; + className = styles.annoMarker; + } + } + + // @TODO: Reset newRange after annotation is saved + if (isVisible) { + let isWip = frame.meta?.custom?.isWip; + + markers.push( + <AnnotationMarker2 + annoIdx={i} + annoVals={vals} + className={className} + style={style} + timeZone={timeZone} + key={`${frameIdx}:${i}`} + exitWipEdit={isWip ? exitWipEdit : null} + portalRoot={portalRoot} + /> + ); + } + } + + return markers; + }); + + return createPortal(markers, xAxisRef.current!); + } + + return null; +}; + +const getStyles = () => ({ + annoMarker: css({ + position: 'absolute', + width: 0, + height: 0, + borderLeft: '5px solid transparent', + borderRight: '5px solid transparent', + borderBottomWidth: '5px', + borderBottomStyle: 'solid', + transform: 'translateX(-50%)', + cursor: 'pointer', + zIndex: 1, + }), + annoRegion: css({ + position: 'absolute', + height: '5px', + cursor: 'pointer', + zIndex: 1, + }), +}); diff --git a/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx b/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx index 5a81657876977..e1f77808965fc 100644 --- a/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx +++ b/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx @@ -1,6 +1,15 @@ import { css, cx } from '@emotion/css'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { usePopper } from 'react-popper'; +import { + autoUpdate, + flip, + safePolygon, + shift, + useDismiss, + useFloating, + useHover, + useInteractions, +} from '@floating-ui/react'; +import React, { CSSProperties, useCallback, useEffect, useState } from 'react'; import { DataFrame, @@ -8,12 +17,17 @@ import { dateTimeFormat, Field, FieldType, + formattedValueToString, GrafanaTheme2, + LinkModel, systemDateFormats, TimeZone, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { config as runtimeConfig } from '@grafana/runtime'; import { FieldLinkList, Portal, UPlotConfigBuilder, useStyles2 } from '@grafana/ui'; +import { DisplayValue } from 'app/features/visualization/data-hover/DataHoverView'; +import { ExemplarHoverView } from 'app/features/visualization/data-hover/ExemplarHoverView'; import { ExemplarModalHeader } from '../../heatmap/ExemplarModalHeader'; @@ -39,25 +53,34 @@ export const ExemplarMarker = ({ const styles = useStyles2(getExemplarMarkerStyles); const [isOpen, setIsOpen] = useState(false); const [isLocked, setIsLocked] = useState(false); - const [markerElement, setMarkerElement] = React.useState<HTMLDivElement | null>(null); - const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null); - const { styles: popperStyles, attributes } = usePopper(markerElement, popperElement, { - modifiers: [ - { - name: 'preventOverflow', - options: { - altAxis: true, - }, - }, - { - name: 'flip', - options: { - fallbackPlacements: ['top', 'left-start'], - }, - }, - ], + + // the order of middleware is important! + const middleware = [ + flip({ + fallbackAxisSideDirection: 'end', + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ]; + + const { context, refs, floatingStyles } = useFloating({ + open: isOpen, + placement: 'bottom', + onOpenChange: setIsOpen, + middleware, + whileElementsMounted: autoUpdate, + strategy: 'fixed', }); - const popoverRenderTimeout = useRef<NodeJS.Timeout>(); + + const dismiss = useDismiss(context); + const hover = useHover(context, { + handleClose: safePolygon(), + enabled: clickedExemplarFieldIndex === undefined, + }); + + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, hover]); useEffect(() => { if ( @@ -102,25 +125,10 @@ export const ExemplarMarker = ({ return symbols[dataFrameFieldIndex.frameIndex % symbols.length]; }; - const onMouseEnter = useCallback(() => { - if (clickedExemplarFieldIndex === undefined) { - if (popoverRenderTimeout.current) { - clearTimeout(popoverRenderTimeout.current); - } - setIsOpen(true); - } - }, [setIsOpen, clickedExemplarFieldIndex]); - const lockExemplarModal = () => { setIsLocked(true); }; - const onMouseLeave = useCallback(() => { - popoverRenderTimeout.current = setTimeout(() => { - setIsOpen(false); - }, 150); - }, [setIsOpen]); - const renderMarker = useCallback(() => { //Put fields with links on the top const fieldsWithLinks = @@ -143,59 +151,92 @@ export const ExemplarMarker = ({ setClickedExemplarFieldIndex(undefined); }; - return ( - <div - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} - className={styles.tooltip} - ref={setPopperElement} - style={popperStyles.popper} - {...attributes.popper} - > - <div className={styles.wrapper}> - {isLocked && <ExemplarModalHeader onClick={onClose} />} - <div className={styles.body}> - <div className={styles.header}> - <span className={styles.title}>Exemplars</span> - </div> - <div> - <table className={styles.exemplarsTable}> - <tbody> - {orderedDataFrameFields.map((field: Field, i) => { - const value = field.values[dataFrameFieldIndex.fieldIndex]; - const links = field.config.links?.length - ? field.getLinks?.({ valueRowIndex: dataFrameFieldIndex.fieldIndex }) - : undefined; - return ( - <tr key={i}> - <td valign="top">{field.name}</td> - <td> - <div className={styles.valueWrapper}> - <span>{field.type === FieldType.time ? timeFormatter(value) : value}</span> - {links && <FieldLinkList links={links} />} - </div> - </td> - </tr> - ); - })} - </tbody> - </table> + let displayValues: DisplayValue[] = []; + let links: LinkModel[] | undefined = []; + orderedDataFrameFields.map((field: Field, i) => { + const value = field.values[dataFrameFieldIndex.fieldIndex]; + + if (field.config.links?.length) { + links?.push(...(field.getLinks?.({ valueRowIndex: dataFrameFieldIndex.fieldIndex }) || [])); + } + + const fieldDisplay = field.display ? field.display(value) : { text: `${value}`, numeric: +value }; + + displayValues.push({ + name: field.name, + value, + valueString: formattedValueToString(fieldDisplay), + highlight: false, + }); + }); + + const exemplarHeaderCustomStyle: CSSProperties = { + position: 'relative', + top: '35px', + right: '5px', + marginRight: 0, + }; + + const getExemplarMarkerContent = () => { + if (runtimeConfig.featureToggles.newVizTooltips) { + return ( + <> + {isLocked && <ExemplarModalHeader onClick={onClose} style={exemplarHeaderCustomStyle} />} + <ExemplarHoverView displayValues={displayValues} links={links} /> + </> + ); + } else { + return ( + <div className={styles.wrapper}> + {isLocked && <ExemplarModalHeader onClick={onClose} />} + <div className={styles.body}> + <div className={styles.header}> + <span className={styles.title}>Exemplars</span> + </div> + <div> + <table className={styles.exemplarsTable}> + <tbody> + {orderedDataFrameFields.map((field: Field, i) => { + const value = field.values[dataFrameFieldIndex.fieldIndex]; + const links = field.config.links?.length + ? field.getLinks?.({ valueRowIndex: dataFrameFieldIndex.fieldIndex }) + : undefined; + return ( + <tr key={i}> + <td valign="top">{field.name}</td> + <td> + <div className={styles.valueWrapper}> + <span>{field.type === FieldType.time ? timeFormatter(value) : value}</span> + {links && <FieldLinkList links={links} />} + </div> + </td> + </tr> + ); + })} + </tbody> + </table> + </div> </div> </div> - </div> + ); + } + }; + + return ( + <div className={styles.tooltip} ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()}> + {getExemplarMarkerContent()} </div> ); }, [ - attributes.popper, dataFrame.fields, dataFrameFieldIndex, - onMouseEnter, - onMouseLeave, - popperStyles.popper, styles, timeZone, isLocked, setClickedExemplarFieldIndex, + floatingStyles, + getFloatingProps, + refs.setFloating, ]); const seriesColor = config @@ -210,19 +251,18 @@ export const ExemplarMarker = ({ return ( <> <div - ref={setMarkerElement} + ref={refs.setReference} + className={styles.markerWrapper} + data-testid={selectors.components.DataSource.Prometheus.exemplarMarker} + role="button" + tabIndex={0} + {...getReferenceProps()} onClick={onExemplarClick} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') { onExemplarClick(); } }} - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} - className={styles.markerWrapper} - data-testid={selectors.components.DataSource.Prometheus.exemplarMarker} - role="button" - tabIndex={0} > <svg viewBox="0 0 7 7" @@ -246,102 +286,96 @@ const getExemplarMarkerStyles = (theme: GrafanaTheme2) => { const tableBgOdd = theme.isDark ? theme.v1.palette.dark3 : theme.v1.palette.gray6; return { - markerWrapper: css` - padding: 0 4px 4px 4px; - width: 8px; - height: 8px; - box-sizing: content-box; - transform: translate3d(-50%, 0, 0); - - &:hover { - > svg { - transform: scale(1.3); - opacity: 1; - filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.5)); - } - } - `, - marker: css` - width: 0; - height: 0; - border-left: 4px solid transparent; - border-right: 4px solid transparent; - border-bottom: 4px solid ${theme.v1.palette.red}; - pointer-events: none; - `, - wrapper: css` - background: ${bg}; - border: 1px solid ${headerBg}; - border-radius: ${theme.shape.borderRadius(2)}; - box-shadow: 0 0 20px ${shadowColor}; - padding: ${theme.spacing(1)}; - `, - exemplarsTable: css` - width: 100%; - - tr td { - padding: 5px 10px; - white-space: nowrap; - border-bottom: 4px solid ${theme.components.panel.background}; - } - - tr { - background-color: ${theme.colors.background.primary}; - - &:nth-child(even) { - background-color: ${tableBgOdd}; - } - } - `, - valueWrapper: css` - display: flex; - flex-direction: row; - flex-wrap: wrap; - column-gap: ${theme.spacing(1)}; - - > span { - flex-grow: 0; - } - - > * { - flex: 1 1; - align-self: center; - } - `, - tooltip: css` - background: none; - padding: 0; - overflow-y: auto; - max-height: 95vh; - `, - header: css` - background: ${headerBg}; - padding: 6px 10px; - display: flex; - `, - title: css` - font-weight: ${theme.typography.fontWeightMedium}; - padding-right: ${theme.spacing(2)}; - overflow: hidden; - display: inline-block; - white-space: nowrap; - text-overflow: ellipsis; - flex-grow: 1; - `, - body: css` - font-weight: ${theme.typography.fontWeightMedium}; - border-radius: ${theme.shape.borderRadius(2)}; - overflow: hidden; - `, - marble: css` - display: block; - opacity: 0.5; - transition: transform 0.15s ease-out; - `, - activeMarble: css` - transform: scale(1.3); - opacity: 1; - filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.5)); - `, + markerWrapper: css({ + padding: '0 4px 4px 4px', + width: '8px', + height: '8px', + boxSizing: 'content-box', + transform: 'translate3d(-50%, 0, 0)', + '&:hover': { + '> svg': { + transform: 'scale(1.3)', + opacity: 1, + filter: 'drop-shadow(0 0 8px rgba(0, 0, 0, 0.5))', + }, + }, + }), + marker: css({ + width: 0, + height: 0, + borderLeft: '4px solid transparent', + borderRight: '4px solid transparent', + borderBottom: `4px solid ${theme.v1.palette.red}`, + pointerEvents: 'none', + }), + wrapper: css({ + background: bg, + border: `1px solid ${headerBg}`, + borderRadius: theme.shape.borderRadius(2), + boxShadow: `0 0 20px ${shadowColor}`, + padding: theme.spacing(1), + }), + exemplarsTable: css({ + width: '100%', + 'tr td': { + padding: '5px 10px', + whiteSpace: 'nowrap', + borderBottom: `4px solid ${theme.components.panel.background}`, + }, + tr: { + backgroundColor: theme.colors.background.primary, + '&:nth-child(even)': { + backgroundColor: tableBgOdd, + }, + }, + }), + valueWrapper: css({ + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + columnGap: theme.spacing(1), + '> span': { + flexGrow: 0, + }, + '> *': { + flex: '1 1', + alignSelf: 'center', + }, + }), + tooltip: css({ + background: 'none', + padding: 0, + overflowY: 'auto', + maxHeight: '95vh', + }), + header: css({ + background: headerBg, + padding: '6px 10px', + display: 'flex', + }), + title: css({ + fontWeight: theme.typography.fontWeightMedium, + paddingRight: theme.spacing(2), + overflow: 'hidden', + display: 'inline-block', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + flexGrow: 1, + }), + body: css({ + fontWeight: theme.typography.fontWeightMedium, + borderRadius: theme.shape.borderRadius(2), + overflow: 'hidden', + }), + marble: css({ + display: 'block', + opacity: 0.5, + transition: 'transform 0.15s ease-out', + }), + activeMarble: css({ + transform: 'scale(1.3)', + opacity: 1, + filter: 'drop-shadow(0 0 8px rgba(0, 0, 0, 0.5))', + }), }; }; diff --git a/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditor.tsx b/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditor.tsx index 686c076fc8605..1702de0ce4acd 100644 --- a/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditor.tsx +++ b/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditor.tsx @@ -1,6 +1,6 @@ import { css, cx } from '@emotion/css'; -import React, { HTMLAttributes, useState } from 'react'; -import { usePopper } from 'react-popper'; +import { autoUpdate, flip, shift, useDismiss, useFloating, useInteractions } from '@floating-ui/react'; +import React, { HTMLAttributes } from 'react'; import { colorManipulator, DataFrame, getDisplayProcessor, GrafanaTheme2, TimeZone } from '@grafana/data'; import { PlotSelection, useStyles2, useTheme2, Portal, DEFAULT_ANNOTATION_COLOR } from '@grafana/ui'; @@ -31,22 +31,35 @@ export const AnnotationEditor = ({ const theme = useTheme2(); const styles = useStyles2(getStyles); const commonStyles = useStyles2(getCommonAnnotationStyles); - const [popperTrigger, setPopperTrigger] = useState<HTMLDivElement | null>(null); - const [editorPopover, setEditorPopover] = useState<HTMLDivElement | null>(null); - const popper = usePopper(popperTrigger, editorPopover, { - modifiers: [ - { name: 'arrow', enabled: false }, - { - name: 'preventOverflow', - enabled: true, - options: { - rootBoundary: 'viewport', - }, - }, - ], + // the order of middleware is important! + const middleware = [ + flip({ + fallbackAxisSideDirection: 'end', + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ]; + + const { context, refs, floatingStyles } = useFloating({ + open: true, + placement: 'bottom', + onOpenChange: (open) => { + if (!open) { + onDismiss(); + } + }, + middleware, + whileElementsMounted: autoUpdate, + strategy: 'fixed', }); + const dismiss = useDismiss(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]); + let xField = data.fields[0]; if (!xField) { return null; @@ -62,23 +75,24 @@ export const AnnotationEditor = ({ > <div // Annotation marker className={cx( - css` - position: absolute; - top: ${selection.bbox.top}px; - left: ${selection.bbox.left}px; - width: ${selection.bbox.width}px; - height: ${selection.bbox.height}px; - `, + css({ + position: 'absolute', + top: selection.bbox.top, + left: selection.bbox.left, + width: selection.bbox.width, + height: selection.bbox.height, + }), isRegionAnnotation ? styles.overlayRange(annotation) : styles.overlay(annotation) )} > <div - ref={setPopperTrigger} + ref={refs.setReference} className={ isRegionAnnotation ? cx(commonStyles(annotation).markerBar, styles.markerBar) : cx(commonStyles(annotation).markerTriangle, styles.markerTriangle) } + {...getReferenceProps()} /> </div> </div> @@ -88,9 +102,9 @@ export const AnnotationEditor = ({ timeFormatter={(v) => xFieldFmt(v).text} onSave={onSave} onDismiss={onDismiss} - ref={setEditorPopover} - style={popper.styles.popper} - {...popper.attributes.popper} + ref={refs.setFloating} + style={floatingStyles} + {...getFloatingProps()} /> </> </Portal> @@ -101,27 +115,27 @@ const getStyles = (theme: GrafanaTheme2) => { return { overlay: (annotation?: AnnotationsDataFrameViewDTO) => { const color = theme.visualization.getColorByName(annotation?.color || DEFAULT_ANNOTATION_COLOR); - return css` - border-left: 1px dashed ${color}; - `; + return css({ + borderLeft: `1px dashed ${color}`, + }); }, overlayRange: (annotation?: AnnotationsDataFrameViewDTO) => { const color = theme.visualization.getColorByName(annotation?.color || DEFAULT_ANNOTATION_COLOR); - return css` - background: ${colorManipulator.alpha(color, 0.1)}; - border-left: 1px dashed ${color}; - border-right: 1px dashed ${color}; - `; + return css({ + background: colorManipulator.alpha(color, 0.1), + borderLeft: `1px dashed ${color}`, + borderRight: `1px dashed ${color}`, + }); }, - markerTriangle: css` - top: calc(100% + 2px); - left: -4px; - position: absolute; - `, - markerBar: css` - top: 100%; - left: 0; - position: absolute; - `, + markerTriangle: css({ + top: `calc(100% + 2px)`, + left: '-4px', + position: 'absolute', + }), + markerBar: css({ + top: '100%', + left: 0, + position: 'absolute', + }), }; }; diff --git a/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditorForm.tsx b/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditorForm.tsx index 26e041a17890f..c8de3bd393473 100644 --- a/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditorForm.tsx +++ b/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditorForm.tsx @@ -1,10 +1,12 @@ import { css, cx } from '@emotion/css'; import React, { HTMLAttributes, useRef } from 'react'; +import { Controller } from 'react-hook-form'; import useAsyncFn from 'react-use/lib/useAsyncFn'; import useClickAway from 'react-use/lib/useClickAway'; import { AnnotationEventUIModel, GrafanaTheme2 } from '@grafana/data'; -import { Button, Field, Form, HorizontalGroup, InputControl, TextArea, usePanelContext, useStyles2 } from '@grafana/ui'; +import { Button, Field, Stack, TextArea, usePanelContext, useStyles2 } from '@grafana/ui'; +import { Form } from 'app/core/components/Form/Form'; import { TagFilter } from 'app/core/components/TagFilter/TagFilter'; import { getAnnotationTags } from 'app/features/annotations/api'; @@ -73,10 +75,10 @@ export const AnnotationEditorForm = React.forwardRef<HTMLDivElement, AnnotationE {...otherProps} > <div className={styles.header}> - <HorizontalGroup justify={'space-between'} align={'center'}> + <Stack justifyContent={'space-between'} alignItems={'center'}> <div className={styles.title}>Add annotation</div> <div className={styles.ts}>{ts}</div> - </HorizontalGroup> + </Stack> </div> <div className={styles.editorForm}> <Form<AnnotationEditFormDTO> @@ -94,7 +96,7 @@ export const AnnotationEditorForm = React.forwardRef<HTMLDivElement, AnnotationE /> </Field> <Field label={'Tags'}> - <InputControl + <Controller control={control} name="tags" render={({ field: { ref, onChange, ...field } }) => { @@ -110,14 +112,14 @@ export const AnnotationEditorForm = React.forwardRef<HTMLDivElement, AnnotationE }} /> </Field> - <HorizontalGroup justify={'flex-end'}> + <Stack justifyContent={'flex-end'}> <Button size={'sm'} variant="secondary" onClick={onDismiss} fill="outline"> Cancel </Button> <Button size={'sm'} type={'submit'} disabled={stateIndicator?.loading}> {stateIndicator?.loading ? 'Saving' : 'Save'} </Button> - </HorizontalGroup> + </Stack> </> ); }} diff --git a/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationMarker.tsx b/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationMarker.tsx index 6a936dbbfebd6..2853fbc70d75a 100644 --- a/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationMarker.tsx +++ b/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationMarker.tsx @@ -1,6 +1,15 @@ import { css } from '@emotion/css'; -import React, { HTMLAttributes, useCallback, useRef, useState } from 'react'; -import { usePopper } from 'react-popper'; +import { + autoUpdate, + flip, + safePolygon, + shift, + useDismiss, + useFloating, + useHover, + useInteractions, +} from '@floating-ui/react'; +import React, { HTMLAttributes, useCallback, useState } from 'react'; import { GrafanaTheme2, dateTimeFormat, systemDateFormats, TimeZone } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -21,19 +30,6 @@ interface Props extends HTMLAttributes<HTMLDivElement> { const MIN_REGION_ANNOTATION_WIDTH = 6; -const POPPER_CONFIG = { - modifiers: [ - { name: 'arrow', enabled: false }, - { - name: 'preventOverflow', - enabled: true, - options: { - rootBoundary: 'viewport', - }, - }, - ], -}; - export function AnnotationMarker({ annotation, timeZone, width }: Props) { const { canEditAnnotations, canDeleteAnnotations, ...panelCtx } = usePanelContext(); const commonStyles = useStyles2(getCommonAnnotationStyles); @@ -41,14 +37,33 @@ export function AnnotationMarker({ annotation, timeZone, width }: Props) { const [isOpen, setIsOpen] = useState(false); const [isEditing, setIsEditing] = useState(false); - const [markerRef, setMarkerRef] = useState<HTMLDivElement | null>(null); - const [tooltipRef, setTooltipRef] = useState<HTMLDivElement | null>(null); - const [editorRef, setEditorRef] = useState<HTMLDivElement | null>(null); - const popoverRenderTimeout = useRef<NodeJS.Timeout>(); - - const popper = usePopper(markerRef, tooltipRef, POPPER_CONFIG); - const editorPopper = usePopper(markerRef, editorRef, POPPER_CONFIG); + // the order of middleware is important! + const middleware = [ + flip({ + fallbackAxisSideDirection: 'end', + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ]; + + const { context, refs, floatingStyles } = useFloating({ + open: isOpen, + placement: 'bottom', + onOpenChange: setIsOpen, + middleware, + whileElementsMounted: autoUpdate, + strategy: 'fixed', + }); + + const hover = useHover(context, { + handleClose: safePolygon(), + }); + const dismiss = useDismiss(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, hover]); const onAnnotationEdit = useCallback(() => { setIsEditing(true); @@ -61,25 +76,6 @@ export function AnnotationMarker({ annotation, timeZone, width }: Props) { } }, [annotation, panelCtx]); - const onMouseEnter = useCallback(() => { - if (popoverRenderTimeout.current) { - clearTimeout(popoverRenderTimeout.current); - } - setIsOpen(true); - }, [setIsOpen]); - - const onPopoverMouseEnter = useCallback(() => { - if (popoverRenderTimeout.current) { - clearTimeout(popoverRenderTimeout.current); - } - }, []); - - const onMouseLeave = useCallback(() => { - popoverRenderTimeout.current = setTimeout(() => { - setIsOpen(false); - }, 100); - }, [setIsOpen]); - const timeFormatter = useCallback( (value: number) => { return dateTimeFormat(value, { @@ -124,25 +120,17 @@ export function AnnotationMarker({ annotation, timeZone, width }: Props) { return ( <> <div - ref={setMarkerRef} - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} + ref={refs.setReference} className={!isRegionAnnotation ? styles.markerWrapper : undefined} data-testid={selectors.pages.Dashboard.Annotations.marker} + {...getReferenceProps()} > {marker} </div> {isOpen && ( <Portal> - <div - ref={setTooltipRef} - style={popper.styles.popper} - {...popper.attributes.popper} - className={styles.tooltip} - onMouseEnter={onPopoverMouseEnter} - onMouseLeave={onMouseLeave} - > + <div className={styles.tooltip} ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()}> {renderTooltip()} </div> </Portal> @@ -155,9 +143,9 @@ export function AnnotationMarker({ annotation, timeZone, width }: Props) { onSave={() => setIsEditing(false)} timeFormatter={timeFormatter} annotation={annotation} - ref={setEditorRef} - style={editorPopper.styles.popper} - {...editorPopper.attributes.popper} + ref={refs.setFloating} + style={floatingStyles} + {...getFloatingProps()} /> </Portal> )} @@ -167,16 +155,13 @@ export function AnnotationMarker({ annotation, timeZone, width }: Props) { const getStyles = (theme: GrafanaTheme2) => { return { - markerWrapper: css` - label: markerWrapper; - padding: 0 4px 4px 4px; - `, - wrapper: css` - max-width: 400px; - `, - tooltip: css` - ${getTooltipContainerStyles(theme)}; - padding: 0; - `, + markerWrapper: css({ + label: 'markerWrapper', + padding: theme.spacing(0, 0.5, 0.5, 0.5), + }), + tooltip: css({ + ...getTooltipContainerStyles(theme), + padding: 0, + }), }; }; diff --git a/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx b/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx new file mode 100644 index 0000000000000..21f998ce69656 --- /dev/null +++ b/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx @@ -0,0 +1,159 @@ +import { css } from '@emotion/css'; +import React, { useRef } from 'react'; +import { Controller } from 'react-hook-form'; +import { useAsyncFn, useClickAway } from 'react-use'; + +import { AnnotationEventUIModel, GrafanaTheme2, dateTimeFormat, systemDateFormats } from '@grafana/data'; +import { Button, Field, Stack, TextArea, usePanelContext, useStyles2 } from '@grafana/ui'; +import { Form } from 'app/core/components/Form/Form'; +import { TagFilter } from 'app/core/components/TagFilter/TagFilter'; +import { getAnnotationTags } from 'app/features/annotations/api'; + +interface Props { + annoVals: Record<string, any[]>; + annoIdx: number; + timeZone: string; + dismiss: () => void; +} + +interface AnnotationEditFormDTO { + description: string; + tags: string[]; +} + +export const AnnotationEditor2 = ({ annoVals, annoIdx, dismiss, timeZone, ...otherProps }: Props) => { + const styles = useStyles2(getStyles); + const { onAnnotationCreate, onAnnotationUpdate } = usePanelContext(); + + const clickAwayRef = useRef(null); + + useClickAway(clickAwayRef, dismiss); + + const [createAnnotationState, createAnnotation] = useAsyncFn(async (event: AnnotationEventUIModel) => { + const result = await onAnnotationCreate!(event); + dismiss(); + return result; + }); + + const [updateAnnotationState, updateAnnotation] = useAsyncFn(async (event: AnnotationEventUIModel) => { + const result = await onAnnotationUpdate!(event); + dismiss(); + return result; + }); + + const timeFormatter = (value: number) => + dateTimeFormat(value, { + format: systemDateFormats.fullDate, + timeZone, + }); + + const isUpdatingAnnotation = annoVals.id?.[annoIdx] != null; + const isRegionAnnotation = annoVals.isRegion?.[annoIdx]; + const operation = isUpdatingAnnotation ? updateAnnotation : createAnnotation; + const stateIndicator = isUpdatingAnnotation ? updateAnnotationState : createAnnotationState; + const time = isRegionAnnotation + ? `${timeFormatter(annoVals.time[annoIdx])} - ${timeFormatter(annoVals.timeEnd[annoIdx])}` + : timeFormatter(annoVals.time[annoIdx]); + + const onSubmit = ({ tags, description }: AnnotationEditFormDTO) => { + operation({ + id: annoVals.id?.[annoIdx] ?? undefined, + tags, + description, + from: Math.round(annoVals.time[annoIdx]!), + to: Math.round(annoVals.timeEnd?.[annoIdx] ?? annoVals.time[annoIdx]!), + }); + }; + + // Annotation editor + return ( + <div ref={clickAwayRef} className={styles.editor} {...otherProps}> + <div className={styles.header}> + <Stack justifyContent={'space-between'} alignItems={'center'}> + <div>{isUpdatingAnnotation ? 'Edit annotation' : 'Add annotation'}</div> + <div>{time}</div> + </Stack> + </div> + <Form<AnnotationEditFormDTO> + onSubmit={onSubmit} + defaultValues={{ description: annoVals.text?.[annoIdx], tags: annoVals.tags?.[annoIdx] || [] }} + > + {({ register, errors, control }) => { + return ( + <> + <div className={styles.content}> + <Field label={'Description'} invalid={!!errors.description} error={errors?.description?.message}> + <TextArea + className={styles.textarea} + {...register('description', { + required: 'Annotation description is required', + })} + /> + </Field> + <Field label={'Tags'}> + <Controller + control={control} + name="tags" + render={({ field: { ref, onChange, ...field } }) => { + return ( + <TagFilter + allowCustomValue + placeholder="Add tags" + onChange={onChange} + tagOptions={getAnnotationTags} + tags={field.value} + /> + ); + }} + /> + </Field> + </div> + <div className={styles.footer}> + <Stack justifyContent={'flex-end'}> + <Button size={'sm'} variant="secondary" onClick={dismiss} fill="outline"> + Cancel + </Button> + <Button size={'sm'} type={'submit'} disabled={stateIndicator?.loading}> + {stateIndicator?.loading ? 'Saving' : 'Save'} + </Button> + </Stack> + </div> + </> + ); + }} + </Form> + </div> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + editor: css({ + // zIndex: theme.zIndex.tooltip, + background: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + boxShadow: theme.shadows.z3, + userSelect: 'text', + width: '460px', + }), + content: css({ + padding: theme.spacing(1), + }), + header: css({ + borderBottom: `1px solid ${theme.colors.border.weak}`, + padding: theme.spacing(0.5, 1), + fontWeight: theme.typography.fontWeightBold, + fontSize: theme.typography.fontSize, + color: theme.colors.text.primary, + }), + footer: css({ + borderTop: `1px solid ${theme.colors.border.weak}`, + padding: theme.spacing(1, 1), + }), + textarea: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + }), + }; +}; diff --git a/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationMarker2.tsx b/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationMarker2.tsx new file mode 100644 index 0000000000000..db27a162b9faf --- /dev/null +++ b/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationMarker2.tsx @@ -0,0 +1,109 @@ +import { css } from '@emotion/css'; +import { flip, shift, autoUpdate } from '@floating-ui/dom'; +import { useFloating } from '@floating-ui/react'; +import React, { useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { TimeZone } from '@grafana/schema'; +import { useStyles2 } from '@grafana/ui'; + +import { AnnotationEditor2 } from './AnnotationEditor2'; +import { AnnotationTooltip2 } from './AnnotationTooltip2'; + +interface AnnoBoxProps { + annoVals: Record<string, any[]>; + annoIdx: number; + style: React.CSSProperties | null; + className: string; + timeZone: TimeZone; + exitWipEdit?: null | (() => void); + portalRoot: HTMLElement; +} + +const STATE_DEFAULT = 0; +const STATE_EDITING = 1; +const STATE_HOVERED = 2; + +export const AnnotationMarker2 = ({ + annoVals, + annoIdx, + className, + style, + exitWipEdit, + timeZone, + portalRoot, +}: AnnoBoxProps) => { + const styles = useStyles2(getStyles); + + const [state, setState] = useState(exitWipEdit != null ? STATE_EDITING : STATE_DEFAULT); + const { refs, floatingStyles } = useFloating({ + open: true, + placement: 'bottom', + middleware: [ + flip({ + fallbackAxisSideDirection: 'end', + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ], + whileElementsMounted: autoUpdate, + strategy: 'fixed', + }); + + const contents = + state === STATE_HOVERED ? ( + <AnnotationTooltip2 + annoIdx={annoIdx} + annoVals={annoVals} + timeZone={timeZone} + onEdit={() => setState(STATE_EDITING)} + /> + ) : state === STATE_EDITING ? ( + <AnnotationEditor2 + annoIdx={annoIdx} + annoVals={annoVals} + timeZone={timeZone} + dismiss={() => { + exitWipEdit?.(); + setState(STATE_DEFAULT); + }} + /> + ) : null; + + return ( + <div + ref={refs.setReference} + className={className} + style={style!} + onMouseEnter={() => state !== STATE_EDITING && setState(STATE_HOVERED)} + onMouseLeave={() => state !== STATE_EDITING && setState(STATE_DEFAULT)} + > + {contents && + createPortal( + <div ref={refs.setFloating} className={styles.annoBox} style={floatingStyles}> + {contents} + </div>, + portalRoot + )} + </div> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + // NOTE: shares much with TooltipPlugin2 + annoBox: css({ + top: 0, + left: 0, + zIndex: theme.zIndex.tooltip, + borderRadius: theme.shape.radius.default, + position: 'absolute', + background: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.weak}`, + boxShadow: theme.shadows.z2, + userSelect: 'text', + minWidth: '300px', + }), +}); diff --git a/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationTooltip2.tsx b/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationTooltip2.tsx new file mode 100644 index 0000000000000..641f0044a4300 --- /dev/null +++ b/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationTooltip2.tsx @@ -0,0 +1,158 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2, dateTimeFormat, systemDateFormats, textUtil } from '@grafana/data'; +import { HorizontalGroup, IconButton, Tag, usePanelContext, useStyles2 } from '@grafana/ui'; +import alertDef from 'app/features/alerting/state/alertDef'; + +interface Props { + annoVals: Record<string, any[]>; + annoIdx: number; + timeZone: string; + onEdit: () => void; +} + +const retFalse = () => false; + +export const AnnotationTooltip2 = ({ annoVals, annoIdx, timeZone, onEdit }: Props) => { + const annoId = annoVals.id?.[annoIdx]; + + const styles = useStyles2(getStyles); + + const { canEditAnnotations = retFalse, canDeleteAnnotations = retFalse, onAnnotationDelete } = usePanelContext(); + + const dashboardUID = annoVals.dashboardUID?.[annoIdx]; + const canEdit = canEditAnnotations(dashboardUID); + const canDelete = canDeleteAnnotations(dashboardUID) && onAnnotationDelete != null; + + const timeFormatter = (value: number) => + dateTimeFormat(value, { + format: systemDateFormats.fullDate, + timeZone, + }); + + let time = timeFormatter(annoVals.time[annoIdx]); + let text = annoVals.text?.[annoIdx] ?? ''; + + if (annoVals.isRegion?.[annoIdx]) { + time += ' - ' + timeFormatter(annoVals.timeEnd[annoIdx]); + } + + let avatar; + if (annoVals.login?.[annoIdx] && annoVals.avatarUrl?.[annoIdx]) { + avatar = <img className={styles.avatar} alt="Annotation avatar" src={annoVals.avatarUrl[annoIdx]} />; + } + + let state: React.ReactNode | null = null; + let alertText = ''; + + if (annoVals.alertId?.[annoIdx] !== undefined && annoVals.newState?.[annoIdx]) { + const stateModel = alertDef.getStateDisplayModel(annoVals.newState[annoIdx]); + state = ( + <div className={styles.alertState}> + <i className={stateModel.stateClass}>{stateModel.text}</i> + </div> + ); + + // alertText = alertDef.getAlertAnnotationInfo(annotation); // @TODO ?? + } else if (annoVals.title?.[annoIdx]) { + text = annoVals.title[annoIdx] + text ? `<br />${text}` : ''; + } + + return ( + <div className={styles.wrapper}> + <div className={styles.header}> + <HorizontalGroup justify={'space-between'} align={'center'} spacing={'md'}> + <div className={styles.meta}> + <span> + {avatar} + {state} + </span> + {time} + </div> + {(canEdit || canDelete) && ( + <div className={styles.editControls}> + {canEdit && <IconButton name={'pen'} size={'sm'} onClick={onEdit} tooltip="Edit" />} + {canDelete && ( + <IconButton + name={'trash-alt'} + size={'sm'} + onClick={() => onAnnotationDelete(annoId)} + tooltip="Delete" + /> + )} + </div> + )} + </HorizontalGroup> + </div> + + <div className={styles.body}> + {text && <div className={styles.text} dangerouslySetInnerHTML={{ __html: textUtil.sanitize(text) }} />} + {alertText} + <div> + <HorizontalGroup spacing="xs" wrap> + {annoVals.tags?.[annoIdx]?.map((t: string, i: number) => <Tag name={t} key={`${t}-${i}`} />)} + </HorizontalGroup> + </div> + </div> + </div> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + wrapper: css({ + zIndex: theme.zIndex.tooltip, + whiteSpace: 'initial', + borderRadius: theme.shape.radius.default, + background: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.weak}`, + boxShadow: theme.shadows.z2, + userSelect: 'text', + }), + header: css({ + padding: theme.spacing(0.5, 1), + borderBottom: `1px solid ${theme.colors.border.weak}`, + fontWeight: theme.typography.fontWeightBold, + fontSize: theme.typography.fontSize, + color: theme.colors.text.primary, + display: 'flex', + }), + meta: css({ + display: 'flex', + justifyContent: 'space-between', + color: theme.colors.text.primary, + fontWeight: 400, + }), + editControls: css({ + display: 'flex', + alignItems: 'center', + '> :last-child': { + marginLeft: 0, + }, + }), + body: css({ + padding: theme.spacing(1), + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + fontWeight: 400, + a: { + color: theme.colors.text.link, + '&:hover': { + textDecoration: 'underline', + }, + }, + }), + text: css({ + paddingBottom: theme.spacing(1), + }), + avatar: css({ + borderRadius: theme.shape.radius.circle, + width: 16, + height: 16, + marginRight: theme.spacing(1), + }), + alertState: css({ + paddingRight: theme.spacing(1), + fontWeight: theme.typography.fontWeightMedium, + }), +}); diff --git a/public/app/plugins/panel/timeseries/utils.ts b/public/app/plugins/panel/timeseries/utils.ts index c60865ce666f0..3f9212ee4d7aa 100644 --- a/public/app/plugins/panel/timeseries/utils.ts +++ b/public/app/plugins/panel/timeseries/utils.ts @@ -3,18 +3,15 @@ import { Field, FieldType, getDisplayProcessor, - getLinksSupplier, GrafanaTheme2, - DataLinkPostProcessor, - InterpolateFunction, isBooleanUnit, - SortedVector, TimeRange, + cacheFieldDisplayNames, } from '@grafana/data'; import { convertFieldType } from '@grafana/data/src/transformations/transformers/convertFieldType'; import { applyNullInsertThreshold } from '@grafana/data/src/transformations/transformers/nulls/nullInsertThreshold'; import { nullToValue } from '@grafana/data/src/transformations/transformers/nulls/nullToValue'; -import { GraphFieldConfig, LineInterpolation } from '@grafana/schema'; +import { GraphFieldConfig, LineInterpolation, TooltipDisplayMode, VizTooltipOptions } from '@grafana/schema'; import { buildScaleKey } from '@grafana/ui/src/components/uPlot/internal'; type ScaleKey = string; @@ -82,6 +79,8 @@ export function prepareGraphableFields( return null; } + cacheFieldDisplayNames(series); + let useNumericX = xNumFieldIdx != null; // Make sure the numeric x field is first in the frame @@ -239,7 +238,7 @@ const matchEnumColorToSeriesColor = (frames: DataFrame[], theme: GrafanaTheme2) } }; -const setClassicPaletteIdxs = (frames: DataFrame[], theme: GrafanaTheme2, skipFieldIdx?: number) => { +export const setClassicPaletteIdxs = (frames: DataFrame[], theme: GrafanaTheme2, skipFieldIdx?: number) => { let seriesIndex = 0; frames.forEach((frame) => { frame.fields.forEach((field, fieldIdx) => { @@ -264,49 +263,6 @@ export function getTimezones(timezones: string[] | undefined, defaultTimezone: s return timezones.map((v) => (v?.length ? v : defaultTimezone)); } -export function regenerateLinksSupplier( - alignedDataFrame: DataFrame, - frames: DataFrame[], - replaceVariables: InterpolateFunction, - timeZone: string, - dataLinkPostProcessor?: DataLinkPostProcessor -): DataFrame { - alignedDataFrame.fields.forEach((field) => { - if (field.state?.origin?.frameIndex === undefined || frames[field.state?.origin?.frameIndex] === undefined) { - return; - } - - /* check if field has sortedVector values - if it does, sort all string fields in the original frame by the order array already used for the field - otherwise just attach the fields to the temporary frame used to get the links - */ - const tempFields: Field[] = []; - for (const frameField of frames[field.state?.origin?.frameIndex].fields) { - if (frameField.type === FieldType.string) { - if (field.values instanceof SortedVector) { - const copiedField = { ...frameField }; - copiedField.values = new SortedVector(frameField.values, field.values.getOrderArray()); - tempFields.push(copiedField); - } else { - tempFields.push(frameField); - } - } - } - - const tempFrame: DataFrame = { - fields: [...alignedDataFrame.fields, ...tempFields], - length: alignedDataFrame.fields.length + tempFields.length, - }; - - field.getLinks = getLinksSupplier( - tempFrame, - field, - field.state!.scopedVars!, - replaceVariables, - timeZone, - dataLinkPostProcessor - ); - }); - - return alignedDataFrame; -} +export const isTooltipScrollable = (tooltipOptions: VizTooltipOptions) => { + return tooltipOptions.mode === TooltipDisplayMode.Multi && tooltipOptions.maxHeight != null; +}; diff --git a/public/app/plugins/panel/traces/TracesPanel.tsx b/public/app/plugins/panel/traces/TracesPanel.tsx index 62a02cce75dff..f33e04f64eba1 100644 --- a/public/app/plugins/panel/traces/TracesPanel.tsx +++ b/public/app/plugins/panel/traces/TracesPanel.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import React, { useMemo, createRef } from 'react'; import { useAsync } from 'react-use'; -import { PanelProps } from '@grafana/data'; +import { Field, LinkModel, PanelProps } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; import { TraceView } from 'app/features/explore/TraceView/TraceView'; import { SpanLinkFunc } from 'app/features/explore/TraceView/components'; @@ -17,6 +17,8 @@ const styles = { export interface TracesPanelOptions { createSpanLink?: SpanLinkFunc; + focusedSpanId?: string; + createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel<Field>; } export const TracesPanel = ({ data, options }: PanelProps<TracesPanelOptions>) => { @@ -44,6 +46,8 @@ export const TracesPanel = ({ data, options }: PanelProps<TracesPanelOptions>) = datasource={dataSource.value} topOfViewRef={topOfViewRef} createSpanLink={options.createSpanLink} + focusedSpanId={options.focusedSpanId} + createFocusSpanLink={options.createFocusSpanLink} /> </div> ); diff --git a/public/app/plugins/panel/traces/module.tsx b/public/app/plugins/panel/traces/module.tsx index 06b2f4fee43ed..19fae657d5f66 100644 --- a/public/app/plugins/panel/traces/module.tsx +++ b/public/app/plugins/panel/traces/module.tsx @@ -1,5 +1,6 @@ import { PanelPlugin } from '@grafana/data'; import { TracesPanel } from './TracesPanel'; +import { TracesSuggestionsSupplier } from './suggestions'; -export const plugin = new PanelPlugin(TracesPanel); +export const plugin = new PanelPlugin(TracesPanel).setSuggestionsSupplier(new TracesSuggestionsSupplier()); diff --git a/public/app/plugins/panel/traces/suggestions.ts b/public/app/plugins/panel/traces/suggestions.ts new file mode 100644 index 0000000000000..bda2e4639274b --- /dev/null +++ b/public/app/plugins/panel/traces/suggestions.ts @@ -0,0 +1,29 @@ +import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data'; +import { SuggestionName } from 'app/types/suggestions'; + +export class TracesSuggestionsSupplier { + getListWithDefaults(builder: VisualizationSuggestionsBuilder) { + return builder.getListAppender<{}, {}>({ + name: SuggestionName.Trace, + pluginId: 'traces', + }); + } + + getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { + if (!builder.data) { + return; + } + + const dataFrame = builder.data.series[0]; + if (!dataFrame) { + return; + } + + if (builder.data.series[0].meta?.preferredVisualisationType === 'trace') { + this.getListWithDefaults(builder).append({ + name: SuggestionName.Trace, + score: VisualizationSuggestionScore.Best, + }); + } + } +} diff --git a/public/app/plugins/panel/trend/TrendPanel.tsx b/public/app/plugins/panel/trend/TrendPanel.tsx index fa3c6d355be82..cb90f861f3e7c 100644 --- a/public/app/plugins/panel/trend/TrendPanel.tsx +++ b/public/app/plugins/panel/trend/TrendPanel.tsx @@ -10,9 +10,9 @@ import { preparePlotFrame } from 'app/core/components/GraphNG/utils'; import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; import { findFieldIndex } from 'app/features/dimensions'; -import { prepareGraphableFields, regenerateLinksSupplier } from '../timeseries/utils'; +import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip'; +import { isTooltipScrollable, prepareGraphableFields } from '../timeseries/utils'; -import { TrendTooltip } from './TrendTooltip'; import { Options } from './panelcfg.gen'; export const TrendPanel = ({ @@ -26,7 +26,9 @@ export const TrendPanel = ({ replaceVariables, id, }: PanelProps<Options>) => { - const { sync, dataLinkPostProcessor } = usePanelContext(); + const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips); + + const { dataLinkPostProcessor } = usePanelContext(); // Need to fallback to first number field if no xField is set in options otherwise panel crashes 😬 const trendXFieldName = options.xField ?? data.series[0].fields.find((field) => field.type === FieldType.number)?.name; @@ -107,24 +109,16 @@ export const TrendPanel = ({ legend={options.legend} options={options} preparePlotFrame={preparePlotFrameTimeless} + replaceVariables={replaceVariables} + dataLinkPostProcessor={dataLinkPostProcessor} > {(uPlotConfig, alignedDataFrame) => { - if (alignedDataFrame.fields.some((f) => Boolean(f.config.links?.length))) { - alignedDataFrame = regenerateLinksSupplier( - alignedDataFrame, - info.frames!, - replaceVariables, - timeZone, - dataLinkPostProcessor - ); - } - return ( <> <KeyboardPlugin config={uPlotConfig} /> {options.tooltip.mode !== TooltipDisplayMode.None && ( <> - {config.featureToggles.newVizTooltips ? ( + {showNewVizTooltips ? ( <TooltipPlugin2 config={uPlotConfig} hoverMode={ @@ -132,18 +126,20 @@ export const TrendPanel = ({ } render={(u, dataIdxs, seriesIdx, isPinned = false) => { return ( - <TrendTooltip + <TimeSeriesTooltip frames={info.frames!} - data={alignedDataFrame} - mode={options.tooltip.mode} - sortOrder={options.tooltip.sort} - sync={sync} + seriesFrame={alignedDataFrame} dataIdxs={dataIdxs} seriesIdx={seriesIdx} + mode={options.tooltip.mode} + sortOrder={options.tooltip.sort} isPinned={isPinned} + scrollable={isTooltipScrollable(options.tooltip)} /> ); }} + maxWidth={options.tooltip.maxWidth} + maxHeight={options.tooltip.maxHeight} /> ) : ( <TooltipPlugin @@ -152,7 +148,6 @@ export const TrendPanel = ({ config={uPlotConfig} mode={options.tooltip.mode} sortOrder={options.tooltip.sort} - sync={sync} timeZone={timeZone} /> )} diff --git a/public/app/plugins/panel/trend/TrendTooltip.tsx b/public/app/plugins/panel/trend/TrendTooltip.tsx deleted file mode 100644 index 277fde8f0e0b3..0000000000000 --- a/public/app/plugins/panel/trend/TrendTooltip.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { css } from '@emotion/css'; -import React from 'react'; - -import { - arrayUtils, - DashboardCursorSync, - DataFrame, - FALLBACK_COLOR, - Field, - FieldType, - formattedValueToString, - getDisplayProcessor, - getFieldDisplayName, - GrafanaTheme2, - LinkModel, -} from '@grafana/data'; -import { TooltipDisplayMode, SortOrder } from '@grafana/schema'; -import { SeriesTableRowProps, useStyles2, useTheme2 } from '@grafana/ui'; -import { SeriesList } from '@grafana/ui/src/components/VizTooltip/SeriesList'; -import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; -import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader'; -import { LabelValue } from '@grafana/ui/src/components/VizTooltip/types'; -import { DEFAULT_TOOLTIP_WIDTH } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; - -interface TrendTooltipProps { - frames?: DataFrame[]; - // aligned data frame - data: DataFrame; - // config: UPlotConfigBuilder; - mode?: TooltipDisplayMode; - sortOrder?: SortOrder; - sync?: () => DashboardCursorSync; - - // hovered points - dataIdxs: Array<number | null>; - // closest/hovered series - seriesIdx: number | null; - isPinned: boolean; -} - -export const TrendTooltip = ({ - frames, - data, - mode = TooltipDisplayMode.Single, - sortOrder = SortOrder.None, - sync, - dataIdxs, - seriesIdx, - isPinned, -}: TrendTooltipProps) => { - const theme = useTheme2(); - const styles = useStyles2(getStyles); - - const xField = data.fields[0]; - if (!xField) { - return null; - } - - const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, theme }); - let xVal = xFieldFmt(xField!.values[dataIdxs[0]!]).text; - let tooltip: React.ReactNode = null; - - const links: Array<LinkModel<Field>> = []; - const linkLookup = new Set<string>(); - - // Single mode - if (mode === TooltipDisplayMode.Single || isPinned) { - const field = data.fields[seriesIdx!]; - - if (!field) { - return null; - } - - const dataIdx = dataIdxs[seriesIdx!]!; - xVal = xFieldFmt(xField!.values[dataIdx]).text; - const fieldFmt = field.display || getDisplayProcessor({ field, theme }); - const display = fieldFmt(field.values[dataIdx]); - - if (field.getLinks) { - const v = field.values[dataIdx]; - const disp = field.display ? field.display(v) : { text: `${v}`, numeric: +v }; - field.getLinks({ calculatedValue: disp, valueRowIndex: dataIdx }).forEach((link) => { - const key = `${link.title}/${link.href}`; - if (!linkLookup.has(key)) { - links.push(link); - linkLookup.add(key); - } - }); - } - - tooltip = ( - <SeriesList - series={[ - { - color: display.color || FALLBACK_COLOR, - label: getFieldDisplayName(field, data, frames), - value: display ? formattedValueToString(display) : null, - }, - ]} - /> - ); - } - - if (mode === TooltipDisplayMode.Multi && !isPinned) { - let series: SeriesTableRowProps[] = []; - const frame = data; - const fields = frame.fields; - const sortIdx: unknown[] = []; - - for (let i = 0; i < fields.length; i++) { - const field = frame.fields[i]; - if ( - !field || - field === xField || - field.type === FieldType.time || - field.type !== FieldType.number || - field.config.custom?.hideFrom?.tooltip || - field.config.custom?.hideFrom?.viz - ) { - continue; - } - - const v = data.fields[i].values[dataIdxs[i]!]; - const display = field.display!(v); - - sortIdx.push(v); - series.push({ - color: display.color || FALLBACK_COLOR, - label: field.state?.displayName ?? field.name, - value: display ? formattedValueToString(display) : null, - isActive: seriesIdx === i, - }); - } - - if (sortOrder !== SortOrder.None) { - // create sort reference series array, as Array.sort() mutates the original array - const sortRef = [...series]; - const sortFn = arrayUtils.sortValues(sortOrder); - - series.sort((a, b) => { - // get compared values indices to retrieve raw values from sortIdx - const aIdx = sortRef.indexOf(a); - const bIdx = sortRef.indexOf(b); - return sortFn(sortIdx[aIdx], sortIdx[bIdx]); - }); - } - - tooltip = <SeriesList series={series} />; - } - - const getHeaderLabel = (): LabelValue => { - return { - label: getFieldDisplayName(xField, data), - value: xVal, - }; - }; - - return ( - <div> - <div className={styles.wrapper}> - <VizTooltipHeader headerLabel={getHeaderLabel()} customValueDisplay={tooltip} /> - {isPinned && <VizTooltipFooter dataLinks={links} canAnnotate={false} />} - </div> - </div> - ); -}; - -const getStyles = (theme: GrafanaTheme2) => ({ - wrapper: css({ - display: 'flex', - flexDirection: 'column', - width: DEFAULT_TOOLTIP_WIDTH, - }), -}); diff --git a/public/app/plugins/panel/trend/module.tsx b/public/app/plugins/panel/trend/module.tsx index 502840a9fe37b..24421f2ea6ee0 100644 --- a/public/app/plugins/panel/trend/module.tsx +++ b/public/app/plugins/panel/trend/module.tsx @@ -24,7 +24,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TrendPanel) }, }); - commonOptionsBuilder.addTooltipOptions(builder); + commonOptionsBuilder.addTooltipOptions(builder, false, true); commonOptionsBuilder.addLegendOptions(builder); }) .setSuggestionsSupplier(new TrendSuggestionsSupplier()); diff --git a/public/app/plugins/panel/trend/panelcfg.gen.ts b/public/app/plugins/panel/trend/panelcfg.gen.ts index af1edc3126d45..f72cfaceb7293 100644 --- a/public/app/plugins/panel/trend/panelcfg.gen.ts +++ b/public/app/plugins/panel/trend/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/public/app/plugins/panel/xychart/AutoEditor.tsx b/public/app/plugins/panel/xychart/AutoEditor.tsx index 9048cbf9ca14c..82a346fd3f07e 100644 --- a/public/app/plugins/panel/xychart/AutoEditor.tsx +++ b/public/app/plugins/panel/xychart/AutoEditor.tsx @@ -45,7 +45,7 @@ export const AutoEditor = ({ value, onChange, context }: StandardEditorProps<XYD }; const frame = context.data ? context.data[value?.frame ?? 0] : undefined; if (frame) { - const xName = dims.x ? getFieldDisplayName(dims.x, dims.frame, context.data) : undefined; + const xName = 'x' in dims ? getFieldDisplayName(dims.x, dims.frame, context.data) : undefined; for (let field of frame.fields) { if (isGraphable(field)) { const name = getFieldDisplayName(field, frame, context.data); @@ -65,6 +65,9 @@ export const AutoEditor = ({ value, onChange, context }: StandardEditorProps<XYD } } } + if (!v.xAxis) { + v.xAxis = { label: xName, value: xName }; + } } return v; @@ -82,12 +85,13 @@ export const AutoEditor = ({ value, onChange, context }: StandardEditorProps<XYD <Select isClearable={true} options={frameNames} - placeholder={frameNames[0].label} + placeholder={'Change filter'} value={frameNames.find((v) => v.value === value?.frame)} onChange={(v) => { onChange({ ...value, frame: v?.value!, + x: undefined, }); }} /> diff --git a/public/app/plugins/panel/xychart/ManualEditor.tsx b/public/app/plugins/panel/xychart/ManualEditor.tsx index 94481486ff93c..f86ff255393a0 100644 --- a/public/app/plugins/panel/xychart/ManualEditor.tsx +++ b/public/app/plugins/panel/xychart/ManualEditor.tsx @@ -1,13 +1,14 @@ import { css, cx } from '@emotion/css'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { GrafanaTheme2, StandardEditorProps, FieldNamePickerBaseNameMode, StandardEditorsRegistryItem, + getFrameDisplayName, } from '@grafana/data'; -import { Button, IconButton, useStyles2 } from '@grafana/ui'; +import { Button, Field, IconButton, Select, useStyles2 } from '@grafana/ui'; import { LayerName } from 'app/core/components/Layers/LayerName'; import { ScatterSeriesEditor } from './ScatterSeriesEditor'; @@ -18,6 +19,16 @@ export const ManualEditor = ({ onChange, context, }: StandardEditorProps<ScatterSeriesConfig[], unknown, Options>) => { + const frameNames = useMemo(() => { + if (context?.data?.length) { + return context.data.map((frame, index) => ({ + value: index, + label: `${getFrameDisplayName(frame, index)} (index: ${index}, rows: ${frame.length})`, + })); + } + return [{ value: 0, label: 'First result' }]; + }, [context.data]); + const [selected, setSelected] = useState(0); const style = useStyles2(getStyles); @@ -101,23 +112,53 @@ export const ManualEditor = ({ </div> {selected >= 0 && value[selected] && ( - <ScatterSeriesEditor - key={`series/${selected}`} - baseNameMode={FieldNamePickerBaseNameMode.ExcludeBaseNames} - item={{} as StandardEditorsRegistryItem} - context={context} - value={value[selected]} - onChange={(v) => { - onChange( - value.map((obj, i) => { - if (i === selected) { - return v!; + <> + {frameNames.length > 1 && ( + <Field label={'Data'}> + <Select + isClearable={false} + options={frameNames} + placeholder={'Change filter'} + value={ + frameNames.find((v) => { + return v.value === value[selected].frame; + }) ?? 0 } - return obj; - }) - ); - }} - /> + onChange={(val) => { + onChange( + value.map((obj, i) => { + if (i === selected) { + if (val === null) { + return { ...value[i], frame: undefined }; + } + return { ...value[i], frame: val?.value!, x: undefined, y: undefined }; + } + return obj; + }) + ); + }} + /> + </Field> + )} + <ScatterSeriesEditor + key={`series/${selected}`} + baseNameMode={FieldNamePickerBaseNameMode.ExcludeBaseNames} + item={{} as StandardEditorsRegistryItem} + context={context} + value={value[selected]} + onChange={(val) => { + onChange( + value.map((obj, i) => { + if (i === selected) { + return val!; + } + return obj; + }) + ); + }} + frameFilter={value[selected].frame ?? undefined} + /> + </> )} </> ); diff --git a/public/app/plugins/panel/xychart/ScatterSeriesEditor.tsx b/public/app/plugins/panel/xychart/ScatterSeriesEditor.tsx index 2f96552ae93ac..a100d91980783 100644 --- a/public/app/plugins/panel/xychart/ScatterSeriesEditor.tsx +++ b/public/app/plugins/panel/xychart/ScatterSeriesEditor.tsx @@ -9,13 +9,16 @@ import { Options, ScatterSeriesConfig } from './panelcfg.gen'; export interface Props extends StandardEditorProps<ScatterSeriesConfig, unknown, Options> { baseNameMode: FieldNamePickerBaseNameMode; + frameFilter?: number; } -export const ScatterSeriesEditor = ({ value, onChange, context, baseNameMode }: Props) => { +export const ScatterSeriesEditor = ({ value, onChange, context, baseNameMode, frameFilter = -1 }: Props) => { const onFieldChange = (val: unknown | undefined, field: string) => { onChange({ ...value, [field]: val }); }; + const frame = context.data && frameFilter > -1 ? context.data[frameFilter] : undefined; + return ( <div> <Field label={'X Field'}> @@ -27,7 +30,10 @@ export const ScatterSeriesEditor = ({ value, onChange, context, baseNameMode }: id: 'x', name: 'x', settings: { + filter: (field) => + frame?.fields.some((obj) => obj.state?.displayName === field.state?.displayName) ?? true, baseNameMode, + placeholderText: 'select X field', }, }} /> @@ -38,10 +44,13 @@ export const ScatterSeriesEditor = ({ value, onChange, context, baseNameMode }: context={context} onChange={(field) => onFieldChange(field, 'y')} item={{ - id: 'x', - name: 'x', + id: 'y', + name: 'y', settings: { + filter: (field) => + frame?.fields.some((obj) => obj.state?.displayName === field.state?.displayName) ?? true, baseNameMode, + placeholderText: 'select Y field', }, }} /> diff --git a/public/app/plugins/panel/xychart/TooltipView.tsx b/public/app/plugins/panel/xychart/TooltipView.tsx index 5bfe57ff54cdf..2ea5c1e4de9ac 100644 --- a/public/app/plugins/panel/xychart/TooltipView.tsx +++ b/public/app/plugins/panel/xychart/TooltipView.tsx @@ -163,22 +163,22 @@ function fmt(field: Field, val: number): string { } const getStyles = (theme: GrafanaTheme2) => ({ - infoWrap: css` - padding: 8px; - width: 100%; - th { - font-weight: ${theme.typography.fontWeightMedium}; - padding: ${theme.spacing(0.25, 2)}; - } - `, - highlight: css` - background: ${theme.colors.action.hover}; - `, - xVal: css` - font-weight: ${theme.typography.fontWeightBold}; - `, - icon: css` - margin-right: ${theme.spacing(1)}; - vertical-align: middle; - `, + infoWrap: css({ + padding: '8px', + width: '100%', + th: { + fontWeight: theme.typography.fontWeightMedium, + padding: theme.spacing(0.25, 2), + }, + }), + highlight: css({ + background: theme.colors.action.hover, + }), + xVal: css({ + fontWeight: theme.typography.fontWeightBold, + }), + icon: css({ + marginRight: theme.spacing(1), + verticalAlign: 'middle', + }), }); diff --git a/public/app/plugins/panel/xychart/XYChartPanel2.tsx b/public/app/plugins/panel/xychart/XYChartPanel.tsx similarity index 69% rename from public/app/plugins/panel/xychart/XYChartPanel2.tsx rename to public/app/plugins/panel/xychart/XYChartPanel.tsx index 81ba3d162c28b..f42441155bed8 100644 --- a/public/app/plugins/panel/xychart/XYChartPanel2.tsx +++ b/public/app/plugins/panel/xychart/XYChartPanel.tsx @@ -16,6 +16,7 @@ import { config } from '@grafana/runtime'; import { Portal, TooltipDisplayMode, + TooltipPlugin2, UPlotChart, UPlotConfigBuilder, VizLayout, @@ -23,10 +24,12 @@ import { VizLegendItem, VizTooltipContainer, } from '@grafana/ui'; +import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; import { FacetedData } from '@grafana/ui/src/components/uPlot/types'; import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; import { TooltipView } from './TooltipView'; +import { XYChartTooltip } from './XYChartTooltip'; import { Options, SeriesMapping } from './panelcfg.gen'; import { prepData, prepScatter, ScatterPanelInfo } from './scatter'; import { ScatterHoverEvent, ScatterSeries } from './types'; @@ -34,13 +37,14 @@ import { ScatterHoverEvent, ScatterSeries } from './types'; type Props = PanelProps<Options>; const TOOLTIP_OFFSET = 10; -export const XYChartPanel2 = (props: Props) => { +export const XYChartPanel = (props: Props) => { const [error, setError] = useState<string | undefined>(); const [series, setSeries] = useState<ScatterSeries[]>([]); const [builder, setBuilder] = useState<UPlotConfigBuilder | undefined>(); const [facets, setFacets] = useState<FacetedData | undefined>(); const [hover, setHover] = useState<ScatterHoverEvent | undefined>(); const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(false); + const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips); const isToolTipOpen = useRef<boolean>(false); const oldOptions = usePrevious(props.options); @@ -69,9 +73,9 @@ export const XYChartPanel2 = (props: Props) => { props.options, getData, config.theme2, - scatterHoverCallback, - onUPlotClick, - isToolTipOpen + showNewVizTooltips ? null : scatterHoverCallback, + showNewVizTooltips ? null : onUPlotClick, + showNewVizTooltips ? null : isToolTipOpen ); if (info.error) { @@ -82,7 +86,7 @@ export const XYChartPanel2 = (props: Props) => { setFacets(() => prepData(info, props.data.series)); setError(undefined); } - }, [props.data.series, props.options]); + }, [props.data.series, props.options, showNewVizTooltips]); const initFacets = useCallback(() => { setFacets(() => prepData({ error, series }, props.data.series)); @@ -187,11 +191,11 @@ export const XYChartPanel2 = (props: Props) => { } const legendStyle = { - flexStart: css` - div { - justify-content: flex-start !important; - } - `, + flexStart: css({ + div: { + justifyContent: 'flex-start', + }, + }), }; return ( @@ -218,47 +222,71 @@ export const XYChartPanel2 = (props: Props) => { <> <VizLayout width={props.width} height={props.height} legend={renderLegend()}> {(vizWidth: number, vizHeight: number) => ( - <UPlotChart config={builder} data={facets} width={vizWidth} height={vizHeight} /> + <UPlotChart config={builder} data={facets} width={vizWidth} height={vizHeight}> + {showNewVizTooltips && props.options.tooltip.mode !== TooltipDisplayMode.None && ( + <TooltipPlugin2 + config={builder} + hoverMode={TooltipHoverMode.xyOne} + render={(u, dataIdxs, seriesIdx, isPinned, dismiss) => { + return ( + <XYChartTooltip + data={props.data.series} + dataIdxs={dataIdxs} + allSeries={series} + dismiss={dismiss} + isPinned={isPinned} + options={props.options} + seriesIdx={seriesIdx} + /> + ); + }} + maxWidth={props.options.tooltip.maxWidth} + maxHeight={props.options.tooltip.maxHeight} + /> + )} + </UPlotChart> )} </VizLayout> - <Portal> - {hover && props.options.tooltip.mode !== TooltipDisplayMode.None && ( - <VizTooltipContainer - position={{ x: hover.pageX, y: hover.pageY }} - offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }} - allowPointerEvents={isToolTipOpen.current} - > - {shouldDisplayCloseButton && ( - <div - style={{ - width: '100%', - display: 'flex', - justifyContent: 'flex-end', - }} - > - <CloseButton - onClick={onCloseToolTip} + {!showNewVizTooltips && ( + <Portal> + {hover && props.options.tooltip.mode !== TooltipDisplayMode.None && ( + <VizTooltipContainer + position={{ x: hover.pageX, y: hover.pageY }} + offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }} + allowPointerEvents={isToolTipOpen.current} + > + {shouldDisplayCloseButton && ( + <div style={{ - position: 'relative', - top: 'auto', - right: 'auto', - marginRight: 0, + width: '100%', + display: 'flex', + justifyContent: 'flex-end', }} - /> - </div> - )} - <TooltipView - options={props.options.tooltip} - allSeries={series} - manualSeriesConfigs={props.options.series} - seriesMapping={props.options.seriesMapping!} - rowIndex={hover.xIndex} - hoveredPointIndex={hover.scatterIndex} - data={props.data.series} - /> - </VizTooltipContainer> - )} - </Portal> + > + <CloseButton + onClick={onCloseToolTip} + style={{ + position: 'relative', + top: 'auto', + right: 'auto', + marginRight: 0, + }} + /> + </div> + )} + <TooltipView + options={props.options.tooltip} + allSeries={series} + manualSeriesConfigs={props.options.series} + seriesMapping={props.options.seriesMapping!} + rowIndex={hover.xIndex} + hoveredPointIndex={hover.scatterIndex} + data={props.data.series} + /> + </VizTooltipContainer> + )} + </Portal> + )} </> ); }; diff --git a/public/app/plugins/panel/xychart/XYChartTooltip.test.tsx b/public/app/plugins/panel/xychart/XYChartTooltip.test.tsx new file mode 100644 index 0000000000000..b899962059719 --- /dev/null +++ b/public/app/plugins/panel/xychart/XYChartTooltip.test.tsx @@ -0,0 +1,180 @@ +import { render } from '@testing-library/react'; +import React from 'react'; + +import { DataFrame, FieldType, ValueLinkConfig, LinkTarget } from '@grafana/data'; +import { SortOrder, VisibilityMode } from '@grafana/schema'; +import { LegendDisplayMode, TooltipDisplayMode } from '@grafana/ui'; + +import { XYChartTooltip, Props } from './XYChartTooltip'; +import { ScatterSeries } from './types'; + +describe('XYChartTooltip', () => { + it('should render null when `allSeries` is empty', () => { + const { container } = render(<XYChartTooltip {...getProps()} />); + + expect(container.firstChild).toBeNull(); + }); + + it('should render null when `dataIdxs` is null', () => { + const { container } = render(<XYChartTooltip {...getProps({ dataIdxs: [null] })} />); + + expect(container.firstChild).toBeNull(); + }); + + it('should render the tooltip header label with series name', () => { + const seriesName = 'seriesName_1'; + const { getByText } = render( + <XYChartTooltip + {...getProps({ allSeries: buildAllSeries(seriesName), data: buildData(), dataIdxs: [1], seriesIdx: 1 })} + /> + ); + + expect(getByText(seriesName)).toBeInTheDocument(); + }); + + it('should render the tooltip content with x and y field names and values', () => { + const field1Name = 'test_field_1'; + const field2Name = 'test_field_2'; + const { getByText } = render( + <XYChartTooltip + {...getProps({ + allSeries: buildAllSeries(), + data: buildData({ field1Name, field2Name }), + dataIdxs: [1], + seriesIdx: 1, + })} + /> + ); + + expect(getByText(field1Name)).toBeInTheDocument(); + expect(getByText('32.799')).toBeInTheDocument(); + expect(getByText(field2Name)).toBeInTheDocument(); + expect(getByText(300)).toBeInTheDocument(); + }); + + it('should render the tooltip footer with data links', () => { + const dataLinkTitle = 'Google'; + const { getByText } = render( + <XYChartTooltip + {...getProps({ + allSeries: buildAllSeries(), + data: buildData({ dataLinkTitle }), + dataIdxs: [1], + seriesIdx: 1, + isPinned: true, + })} + /> + ); + + expect(getByText(dataLinkTitle)).toBeInTheDocument(); + }); +}); + +function getProps(additionalProps: Partial<Props> | null = null): Props { + if (!additionalProps) { + return getDefaultProps(); + } + + return { ...getDefaultProps(), ...additionalProps }; +} + +function getDefaultProps(): Props { + return { + data: [], + allSeries: [], + dataIdxs: [], + seriesIdx: null, + isPinned: false, + dismiss: jest.fn(), + options: { + dims: { + frame: 0, + }, + series: [], + legend: { + calcs: [], + displayMode: LegendDisplayMode.List, + placement: 'bottom', + showLegend: true, + }, + tooltip: { + mode: TooltipDisplayMode.Single, + sort: SortOrder.Ascending, + }, + }, + }; +} + +function buildAllSeries(testSeriesName = 'test'): ScatterSeries[] { + return [ + { + name: testSeriesName, + legend: jest.fn(), + frame: (frames: DataFrame[]) => frames[0], + x: (frame: DataFrame) => frame.fields[0], + y: (frame: DataFrame) => frame.fields[1], + pointColor: (_frame: DataFrame) => '#111', + showLine: false, + lineWidth: 1, + lineStyle: {}, + lineColor: jest.fn(), + showPoints: VisibilityMode.Always, + pointSize: jest.fn(), + pointSymbol: jest.fn(), + label: VisibilityMode.Always, + labelValue: jest.fn(), + show: true, + hints: { + pointSize: { fixed: 10, max: 10, min: 1 }, + pointColor: { + mode: { + id: 'threshold', + name: 'Threshold', + getCalculator: jest.fn(), + }, + }, + }, + }, + ]; +} + +function buildData({ dataLinkTitle = 'Grafana', field1Name = 'field_1', field2Name = 'field_2' } = {}): DataFrame[] { + return [ + { + fields: [ + { + name: field1Name, + type: FieldType.number, + config: {}, + values: [ + 61.385, 32.799, 33.7712, 36.17, 39.0646, 27.8333, 42.0046, 40.3363, 39.8647, 37.669, 42.2373, 43.3504, + 35.6411, 40.314, 34.8375, 40.3736, 44.5672, + ], + }, + { + name: field2Name, + type: FieldType.number, + config: { + links: [ + { + title: dataLinkTitle, + targetBlank: true, + url: 'http://www.someWebsite.com', + }, + ], + }, + values: [500, 300, 150, 250, 600, 500, 700, 400, 540, 630, 460, 250, 500, 400, 800, 930, 360], + getLinks: (_config: ValueLinkConfig) => [ + { + href: 'http://www.someWebsite.com', + title: dataLinkTitle, + target: '_blank' as LinkTarget, + origin: { name: '', type: FieldType.boolean, config: {}, values: [] }, + }, + ], + }, + ], + length: 17, + }, + ]; +} diff --git a/public/app/plugins/panel/xychart/XYChartTooltip.tsx b/public/app/plugins/panel/xychart/XYChartTooltip.tsx new file mode 100644 index 0000000000000..9012ccca99cf9 --- /dev/null +++ b/public/app/plugins/panel/xychart/XYChartTooltip.tsx @@ -0,0 +1,101 @@ +import React, { ReactNode } from 'react'; + +import { DataFrame, Field, getFieldDisplayName } from '@grafana/data'; +import { alpha } from '@grafana/data/src/themes/colorManipulator'; +import { useStyles2 } from '@grafana/ui'; +import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; +import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; +import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader'; +import { ColorIndicator, VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types'; + +import { getDataLinks } from '../status-history/utils'; +import { getStyles } from '../timeseries/TimeSeriesTooltip'; + +import { Options } from './panelcfg.gen'; +import { ScatterSeries } from './types'; +import { fmt } from './utils'; + +export interface Props { + dataIdxs: Array<number | null>; + seriesIdx: number | null | undefined; + isPinned: boolean; + dismiss: () => void; + options: Options; + data: DataFrame[]; // source data + allSeries: ScatterSeries[]; +} + +export const XYChartTooltip = ({ dataIdxs, seriesIdx, data, allSeries, dismiss, options, isPinned }: Props) => { + const styles = useStyles2(getStyles); + + const rowIndex = dataIdxs.find((idx) => idx !== null); + // @todo: remove -1 when uPlot v2 arrive + // context: first value in dataIdxs always null and represent X series + const hoveredPointIndex = seriesIdx! - 1; + + if (!allSeries || rowIndex == null) { + return null; + } + + const series = allSeries[hoveredPointIndex]; + const frame = series.frame(data); + const xField = series.x(frame); + const yField = series.y(frame); + + let label = series.name; + if (options.seriesMapping === 'manual') { + label = options.series?.[hoveredPointIndex]?.name ?? `Series ${hoveredPointIndex + 1}`; + } + + let colorThing = series.pointColor(frame); + + if (Array.isArray(colorThing)) { + colorThing = colorThing[rowIndex]; + } + + const headerItem: VizTooltipItem = { + label, + value: '', + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + color: alpha(colorThing as string, 0.5), + colorIndicator: ColorIndicator.marker_md, + }; + + const contentItems: VizTooltipItem[] = [ + { + label: getFieldDisplayName(xField, frame), + value: fmt(xField, xField.values[rowIndex]), + }, + { + label: getFieldDisplayName(yField, frame), + value: fmt(yField, yField.values[rowIndex]), + }, + ]; + + // add extra fields + const extraFields: Field[] = frame.fields.filter((f) => f !== xField && f !== yField); + if (extraFields) { + extraFields.forEach((field) => { + contentItems.push({ + label: field.name, + value: fmt(field, field.values[rowIndex]), + }); + }); + } + + let footer: ReactNode; + + if (isPinned && seriesIdx != null) { + const links = getDataLinks(yField, rowIndex); + + footer = <VizTooltipFooter dataLinks={links} />; + } + + return ( + <div className={styles.wrapper}> + <VizTooltipHeader item={headerItem} isPinned={isPinned} /> + <VizTooltipContent items={contentItems} isPinned={isPinned} /> + {footer} + </div> + ); +}; diff --git a/public/app/plugins/panel/xychart/dims.ts b/public/app/plugins/panel/xychart/dims.ts index 9b2619b64ab3e..c6d93854d4918 100644 --- a/public/app/plugins/panel/xychart/dims.ts +++ b/public/app/plugins/panel/xychart/dims.ts @@ -15,18 +15,21 @@ export interface XYDimensions { frame: DataFrame; // matches order from configs, excluds non-graphable values x: Field; fields: XYFieldMatchers; - error?: DimensionError; hasData?: boolean; hasTime?: boolean; } +export interface XYDimensionsError { + error: DimensionError; +} + export function isGraphable(field: Field) { return field.type === FieldType.number; } -export function getXYDimensions(cfg?: XYDimensionConfig, data?: DataFrame[]): XYDimensions { +export function getXYDimensions(cfg?: XYDimensionConfig, data?: DataFrame[]): XYDimensions | XYDimensionsError { if (!data || !data.length) { - return { error: DimensionError.NoData } as XYDimensions; + return { error: DimensionError.NoData }; } if (!cfg) { cfg = { @@ -36,7 +39,7 @@ export function getXYDimensions(cfg?: XYDimensionConfig, data?: DataFrame[]): XY let frame = data[cfg.frame ?? 0]; if (!frame) { - return { error: DimensionError.BadFrameSelection } as XYDimensions; + return { error: DimensionError.BadFrameSelection }; } let xIndex = -1; @@ -99,5 +102,5 @@ function getSimpleFieldNotMatcher(f: Field): FieldMatcher { return () => false; } const m = getSimpleFieldMatcher(f); - return (field) => !m(field, undefined as any, undefined as any); + return (field) => !m(field, { fields: [], length: 0 }, []); } diff --git a/public/app/plugins/panel/xychart/module.tsx b/public/app/plugins/panel/xychart/module.tsx index 5d383a7d75098..930755e40aa45 100644 --- a/public/app/plugins/panel/xychart/module.tsx +++ b/public/app/plugins/panel/xychart/module.tsx @@ -3,11 +3,11 @@ import { commonOptionsBuilder } from '@grafana/ui'; import { AutoEditor } from './AutoEditor'; import { ManualEditor } from './ManualEditor'; -import { XYChartPanel2 } from './XYChartPanel2'; +import { XYChartPanel } from './XYChartPanel'; import { getScatterFieldConfig } from './config'; import { Options, FieldConfig, defaultFieldConfig } from './panelcfg.gen'; -export const plugin = new PanelPlugin<Options, FieldConfig>(XYChartPanel2) +export const plugin = new PanelPlugin<Options, FieldConfig>(XYChartPanel) .useFieldConfig(getScatterFieldConfig(defaultFieldConfig)) .setPanelOptions((builder) => { builder @@ -38,6 +38,6 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(XYChartPanel2) showIf: (cfg) => cfg.seriesMapping === 'manual', }); - commonOptionsBuilder.addTooltipOptions(builder); + commonOptionsBuilder.addTooltipOptions(builder, true); commonOptionsBuilder.addLegendOptions(builder); }); diff --git a/public/app/plugins/panel/xychart/panelcfg.cue b/public/app/plugins/panel/xychart/panelcfg.cue index 25177f0e66e5c..6e7012c24020a 100644 --- a/public/app/plugins/panel/xychart/panelcfg.cue +++ b/public/app/plugins/panel/xychart/panelcfg.cue @@ -57,9 +57,10 @@ composableKinds: PanelCfg: { ScatterSeriesConfig: { FieldConfig - x?: string - y?: string - name?: string + x?: string + y?: string + name?: string + frame?: number } @cuetsy(kind="interface") Options: { diff --git a/public/app/plugins/panel/xychart/panelcfg.gen.ts b/public/app/plugins/panel/xychart/panelcfg.gen.ts index 5c3fb0f48475b..09f7869c536d8 100644 --- a/public/app/plugins/panel/xychart/panelcfg.gen.ts +++ b/public/app/plugins/panel/xychart/panelcfg.gen.ts @@ -4,7 +4,7 @@ // public/app/plugins/gen.go // Using jennies: // TSTypesJenny -// PluginTSTypesJenny +// PluginTsTypesJenny // // Run 'make gen-cue' from repository root to regenerate. @@ -54,6 +54,7 @@ export const defaultFieldConfig: Partial<FieldConfig> = { }; export interface ScatterSeriesConfig extends FieldConfig { + frame?: number; name?: string; x?: string; y?: string; diff --git a/public/app/plugins/panel/xychart/plugin.json b/public/app/plugins/panel/xychart/plugin.json index ed72646bf35cc..13721d65fb057 100644 --- a/public/app/plugins/panel/xychart/plugin.json +++ b/public/app/plugins/panel/xychart/plugin.json @@ -5,6 +5,8 @@ "state": "beta", "info": { + "description": "Supports arbitrary X vs Y in a graph to visualize the relationship between two variables.", + "keywords": ["scatter", "plot"], "author": { "name": "Grafana Labs", "url": "https://grafana.com" diff --git a/public/app/plugins/panel/xychart/scatter.ts b/public/app/plugins/panel/xychart/scatter.ts index d136ee28c6bb6..e4bd194670700 100644 --- a/public/app/plugins/panel/xychart/scatter.ts +++ b/public/app/plugins/panel/xychart/scatter.ts @@ -46,9 +46,9 @@ export function prepScatter( options: Options, getData: () => DataFrame[], theme: GrafanaTheme2, - ttip: ScatterHoverCallback, + ttip: null | ScatterHoverCallback, onUPlotClick: null | ((evt?: Object) => void), - isToolTipOpen: MutableRefObject<boolean> + isToolTipOpen: null | MutableRefObject<boolean> ): ScatterPanelInfo { let series: ScatterSeries[]; let builder: UPlotConfigBuilder; @@ -224,6 +224,10 @@ function prepSeries(options: Options, frames: DataFrame[]): ScatterSeries[] { } for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) { + // When a frame filter is applied, only include matching frame index + if (series.frame !== undefined && series.frame !== frameIndex) { + continue; + } const frame = frames[frameIndex]; const xIndex = findFieldIndex(series.x, frame, frames); @@ -294,14 +298,13 @@ interface DrawBubblesOpts { }; } -//const prepConfig: UPlotConfigPrepFnXY<Options> = ({ frames, series, theme }) => { const prepConfig = ( getData: () => DataFrame[], scatterSeries: ScatterSeries[], theme: GrafanaTheme2, - ttip: ScatterHoverCallback, + ttip: null | ScatterHoverCallback, onUPlotClick: null | ((evt?: Object) => void), - isToolTipOpen: MutableRefObject<boolean> + isToolTipOpen: null | MutableRefObject<boolean> ) => { let qt: Quadtree; let hRect: Rect | null; @@ -522,8 +525,10 @@ const prepConfig = ( }); const clearPopupIfOpened = () => { - if (isToolTipOpen.current) { - ttip(undefined); + if (isToolTipOpen?.current) { + if (ttip) { + ttip(undefined); + } if (onUPlotClick) { onUPlotClick(); } @@ -534,7 +539,11 @@ const prepConfig = ( // clip hover points/bubbles to plotting area builder.addHook('init', (u, r) => { - u.over.style.overflow = 'hidden'; + const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips); + + if (!showNewVizTooltips) { + u.over.style.overflow = 'hidden'; + } ref_parent = u.root.parentElement; if (onUPlotClick) { @@ -556,26 +565,28 @@ const prepConfig = ( rect = r; }); - builder.addHook('setLegend', (u) => { - if (u.cursor.idxs != null) { - for (let i = 0; i < u.cursor.idxs.length; i++) { - const sel = u.cursor.idxs[i]; - if (sel != null && !isToolTipOpen.current) { - ttip({ - scatterIndex: i - 1, - xIndex: sel, - pageX: rect.left + u.cursor.left!, - pageY: rect.top + u.cursor.top!, - }); - return; // only show the first one + if (ttip) { + builder.addHook('setLegend', (u) => { + if (u.cursor.idxs != null) { + for (let i = 0; i < u.cursor.idxs.length; i++) { + const sel = u.cursor.idxs[i]; + if (sel != null && !isToolTipOpen?.current) { + ttip({ + scatterIndex: i - 1, + xIndex: sel, + pageX: rect.left + u.cursor.left!, + pageY: rect.top + u.cursor.top!, + }); + return; // only show the first one + } } } - } - if (!isToolTipOpen.current) { - ttip(undefined); - } - }); + if (!isToolTipOpen?.current) { + ttip(undefined); + } + }); + } builder.addHook('drawClear', (u) => { clearPopupIfOpened(); @@ -598,8 +609,8 @@ const prepConfig = ( const frames = getData(); let xField = scatterSeries[0].x(scatterSeries[0].frame(frames)); - let config = xField.config; - let customConfig = config.custom; + let fieldConfig = xField.config; + let customConfig = fieldConfig.custom; let scaleDistr = customConfig?.scaleDistribution; builder.addScale({ @@ -610,12 +621,12 @@ const prepConfig = ( distribution: scaleDistr?.type, log: scaleDistr?.log, linearThreshold: scaleDistr?.linearThreshold, - min: config.min, - max: config.max, + min: fieldConfig.min, + max: fieldConfig.max, softMin: customConfig?.axisSoftMin, softMax: customConfig?.axisSoftMax, centeredZero: customConfig?.axisCenteredZero, - decimals: config.decimals, + decimals: fieldConfig.decimals, }); // why does this fall back to '' instead of null or undef? diff --git a/public/app/plugins/panel/xychart/types.ts b/public/app/plugins/panel/xychart/types.ts index bd44ef4b27ef3..fa329c1ee490a 100644 --- a/public/app/plugins/panel/xychart/types.ts +++ b/public/app/plugins/panel/xychart/types.ts @@ -50,3 +50,14 @@ export interface ScatterSeries { }; }; } + +export interface ExtraFacets { + colorFacetFieldName: string; + sizeFacetFieldName: string; + colorFacetValue: number; + sizeFacetValue: number; +} + +export interface DataFilterBySeries { + frame: number; +} diff --git a/public/app/plugins/panel/xychart/utils.ts b/public/app/plugins/panel/xychart/utils.ts new file mode 100644 index 0000000000000..512a46d3d7aa8 --- /dev/null +++ b/public/app/plugins/panel/xychart/utils.ts @@ -0,0 +1,9 @@ +import { Field, formattedValueToString } from '@grafana/data'; + +export function fmt(field: Field, val: number): string { + if (field.display) { + return formattedValueToString(field.display(val)); + } + + return `${val}`; +} diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 8af774979efd5..8ff4daa10ecf0 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -71,26 +71,29 @@ export function getAppRoutes(): RouteDescriptor[] { ), }, { - path: '/d-solo/:uid/:slug', - pageClass: 'dashboard-solo', - routeName: DashboardRoutes.Normal, - chromeless: true, + // We currently have no core usage of the embedded dashboard so is to have a page for e2e to test + path: '/dashboards/embedding-test', component: SafeDynamicImport( - () => import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage') + () => + import( + /* webpackChunkName: "DashboardPage"*/ 'app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage' + ) ), }, - // This route handles embedding of snapshot/scripted dashboard panels { - path: '/dashboard-solo/:type/:slug', + path: '/d-solo/:uid/:slug?', pageClass: 'dashboard-solo', routeName: DashboardRoutes.Normal, chromeless: true, - component: SafeDynamicImport( - () => import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage') + component: SafeDynamicImport(() => + config.featureToggles.dashboardSceneSolo + ? import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard-scene/solo/SoloPanelPage') + : import(/* webpackChunkName: "SoloPanelPageOld" */ '../features/dashboard/containers/SoloPanelPage') ), }, + // This route handles embedding of snapshot/scripted dashboard panels { - path: '/d-solo/:uid', + path: '/dashboard-solo/:type/:slug', pageClass: 'dashboard-solo', routeName: DashboardRoutes.Normal, chromeless: true, @@ -288,7 +291,11 @@ export function getAppRoutes(): RouteDescriptor[] { : () => <Redirect to="/admin" />, }, { - path: '/admin/authentication/advanced/:provider', + path: '/admin/authentication/ldap', + component: LdapPage, + }, + { + path: '/admin/authentication/:provider', roles: () => contextSrv.evaluatePermission([AccessControlAction.SettingsWrite]), component: config.featureToggles.ssoSettingsApi ? SafeDynamicImport( @@ -355,9 +362,12 @@ export function getAppRoutes(): RouteDescriptor[] { () => import(/* webpackChunkName: "ServerStats" */ 'app/features/admin/ServerStats') ), }, - { - path: '/admin/authentication/ldap', - component: LdapPage, + config.featureToggles.onPremToCloudMigrations && { + path: '/admin/migrate-to-cloud', + roles: () => ['Admin'], + component: SafeDynamicImport( + () => import(/* webpackChunkName: "MigrateToCloud" */ 'app/features/admin/migrate-to-cloud/MigrateToCloud') + ), }, // LOGIN / SIGNUP { @@ -481,14 +491,13 @@ export function getAppRoutes(): RouteDescriptor[] { ), }, { - path: '/data-trails', + path: '/explore/metrics', chromeless: false, exact: false, component: SafeDynamicImport( () => import(/* webpackChunkName: "DataTrailsPage"*/ 'app/features/trails/DataTrailsPage') ), }, - ...getDynamicDashboardRoutes(), ...getPluginCatalogRoutes(), ...getSupportBundleRoutes(), ...getAlertingRoutes(), @@ -523,38 +532,3 @@ export function getSupportBundleRoutes(cfg = config): RouteDescriptor[] { }, ]; } - -export function getDynamicDashboardRoutes(cfg = config): RouteDescriptor[] { - if (!cfg.featureToggles.scenes) { - return []; - } - return [ - { - path: '/scenes', - component: SafeDynamicImport(() => import(/* webpackChunkName: "scenes"*/ 'app/features/scenes/SceneListPage')), - }, - { - path: '/scenes/dashboard/:uid', - component: SafeDynamicImport( - () => import(/* webpackChunkName: "scenes"*/ 'app/features/dashboard-scene/pages/DashboardScenePage') - ), - }, - { - path: '/scenes/dashboard/:uid/panel-edit/:panelId', - component: SafeDynamicImport( - () => import(/* webpackChunkName: "scenes"*/ 'app/features/dashboard-scene/pages/PanelEditPage') - ), - }, - { - path: '/scenes/grafana-monitoring', - exact: false, - component: SafeDynamicImport( - () => import(/* webpackChunkName: "scenes"*/ 'app/features/scenes/apps/GrafanaMonitoringApp') - ), - }, - { - path: '/scenes/:name', - component: SafeDynamicImport(() => import(/* webpackChunkName: "scenes"*/ 'app/features/scenes/ScenePage')), - }, - ]; -} diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index c2c6cc8962537..4794d46d7e0e7 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -1,7 +1,7 @@ import { configureStore as reduxConfigureStore, createListenerMiddleware } from '@reduxjs/toolkit'; import { setupListeners } from '@reduxjs/toolkit/query'; -import { togglesApi } from 'app/features/admin/AdminFeatureTogglesAPI'; +import { migrateToCloudAPI } from 'app/features/admin/migrate-to-cloud/api'; import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi'; import { StoreState } from 'app/types/store'; @@ -30,7 +30,7 @@ export function configureStore(initialState?: Partial<StoreState>) { alertingApi.middleware, publicDashboardApi.middleware, browseDashboardsAPI.middleware, - togglesApi.middleware + migrateToCloudAPI.middleware ), devTools: process.env.NODE_ENV !== 'production', preloadedState: { diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index 8a37867ab97b5..91f38f13631a3 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -8,6 +8,23 @@ export interface DashboardDTO { meta: DashboardMeta; } +export interface ImportDashboardResponseDTO { + uid: string; + pluginId: string; + title: string; + imported: boolean; + importedRevision?: number; + importedUri: string; + importedUrl: string; + slug: string; + dashboardId: number; + folderId: number; + folderUid: string; + description: string; + path: string; + removed: boolean; +} + export interface SaveDashboardResponseDTO { id: number; slug: string; @@ -27,7 +44,6 @@ export interface DashboardMeta { canStar?: boolean; canAdmin?: boolean; url?: string; - folderId?: number; folderUid?: string; canMakeEditable?: boolean; provisioned?: boolean; @@ -50,6 +66,8 @@ export interface DashboardMeta { publicDashboardUid?: string; publicDashboardEnabled?: boolean; dashboardNotFound?: boolean; + isEmbedded?: boolean; + isNew?: boolean; } export interface AnnotationActions { diff --git a/public/app/types/events.ts b/public/app/types/events.ts index 6f3e86e5134a1..9d742e99e8ac8 100644 --- a/public/app/types/events.ts +++ b/public/app/types/events.ts @@ -160,6 +160,18 @@ export class ShiftTimeEvent extends BusEventWithPayload<ShiftTimeEventPayload> { static type = 'shift-time'; } +export class CopyTimeEvent extends BusEventBase { + static type = 'copy-time'; +} + +interface PasteTimeEventPayload { + updateUrl?: boolean; +} + +export class PasteTimeEvent extends BusEventWithPayload<PasteTimeEventPayload> { + static type = 'paste-time'; +} + interface AbsoluteTimeEventPayload { updateUrl: boolean; } diff --git a/public/app/types/folders.ts b/public/app/types/folders.ts index 6c80fe7ca0f09..83d01c5c273b4 100644 --- a/public/app/types/folders.ts +++ b/public/app/types/folders.ts @@ -1,5 +1,10 @@ import { WithAccessControlMetadata } from '@grafana/data'; +export interface FolderListItemDTO { + uid: string; + title: string; +} + export interface FolderDTO extends WithAccessControlMetadata { canAdmin: boolean; canDelete: boolean; diff --git a/public/app/types/plugins.ts b/public/app/types/plugins.ts index a6d6f68ab47a6..d5d09b0eef2a1 100644 --- a/public/app/types/plugins.ts +++ b/public/app/types/plugins.ts @@ -3,7 +3,6 @@ import { PanelPlugin, PluginError, PluginMeta } from '@grafana/data'; export interface PluginDashboard { dashboardId: number; description: string; - folderId: number; imported: boolean; importedRevision: number; importedUri: string; diff --git a/public/app/types/suggestions.ts b/public/app/types/suggestions.ts index 95a10a419fcc9..2df8c82b9af1c 100644 --- a/public/app/types/suggestions.ts +++ b/public/app/types/suggestions.ts @@ -28,4 +28,6 @@ export enum SuggestionName { DashboardList = 'Dashboard list', Logs = 'Logs', FlameGraph = 'Flame graph', + Trace = 'Trace', + NodeGraph = 'Node graph', } diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index 98f7f5262445f..c7d8251c10c4b 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -1,7 +1,6 @@ // Prometheus API DTOs, possibly to be autogenerated from openapi spec in the near future import { DataQuery, RelativeTimeRange } from '@grafana/data'; -import { AlertManagerManualRouting } from 'app/features/alerting/unified/types/rule-form'; import { AlertGroupTotals } from './unified-alerting'; @@ -179,7 +178,7 @@ export interface RulerAlertingRuleDTO extends RulerRuleBaseDTO { export enum GrafanaAlertStateDecision { Alerting = 'Alerting', NoData = 'NoData', - KeepLastState = 'KeepLastState', + KeepLast = 'KeepLast', OK = 'OK', Error = 'Error', } @@ -198,6 +197,14 @@ export interface AlertQuery { model: AlertDataQuery; } +export interface GrafanaNotificationSettings { + receiver: string; + group_by?: string[]; + group_wait?: string; + group_interval?: string; + repeat_interval?: string; + mute_time_intervals?: string[]; +} export interface PostableGrafanaRuleDefinition { uid?: string; title: string; @@ -206,7 +213,7 @@ export interface PostableGrafanaRuleDefinition { exec_err_state: GrafanaAlertStateDecision; data: AlertQuery[]; is_paused?: boolean; - contactPoints?: AlertManagerManualRouting; + notification_settings?: GrafanaNotificationSettings; } export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition { id?: string; diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index a553b6dff92e2..e641ab278481f 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -26,8 +26,11 @@ export function hasAlertState(alert: Alert, state: PromAlertingRuleState | Grafa return mapStateWithReasonToBaseState(alert.state) === state; } +// Prometheus API uses "err" but grafana API uses "error" *sigh* +export type RuleHealth = 'nodata' | 'error' | 'err' | string; + interface RuleBase { - health: string; + health: RuleHealth; name: string; query: string; lastEvaluation?: string; @@ -128,6 +131,7 @@ export interface CombinedRuleNamespace { rulesSource: RulesSource; name: string; groups: CombinedRuleGroup[]; + uid?: string; //available only in grafana rules } export interface RuleWithLocation<T = RulerRuleDTO> { @@ -135,6 +139,7 @@ export interface RuleWithLocation<T = RulerRuleDTO> { namespace: string; group: RulerRuleGroupDTO; rule: T; + namespace_uid?: string; } export interface CombinedRuleWithLocation extends CombinedRule { diff --git a/public/app/types/user.ts b/public/app/types/user.ts index 1c300befa1a54..8731658c6f5f1 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -142,6 +142,15 @@ export interface UserAnonymousDeviceDTO { avatarUrl?: string; } +export type AnonUserFilter = Record<string, string | boolean | SelectableValue[]>; + export interface UserListAnonymousDevicesState { devices: UserAnonymousDeviceDTO[]; + query: string; + perPage: number; + page: number; + totalPages: number; + showPaging: boolean; + filters: AnonUserFilter[]; + sort?: string; } diff --git a/public/emails/verify_email_update.html b/public/emails/verify_email_update.html new file mode 100644 index 0000000000000..be8542832ca60 --- /dev/null +++ b/public/emails/verify_email_update.html @@ -0,0 +1,215 @@ +<!doctype html> +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> + +<head> + <title>{{ Subject .Subject .TemplateData "Verify your new email - {{.Name}}" }} + {{ __dangerouslyInjectHTML `` }} + + {{ __dangerouslyInjectHTML `` }} + + + + {{ __dangerouslyInjectHTML `` }} + {{ __dangerouslyInjectHTML `` }} + {{ __dangerouslyInjectHTML `` }} + + + {{ __dangerouslyInjectHTML `` }} + + + + + + + +
+ {{ __dangerouslyInjectHTML `` }} +
+ + + + + + +
+ {{ __dangerouslyInjectHTML `` }} +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ {{ __dangerouslyInjectHTML `` }} +
+
+ {{ __dangerouslyInjectHTML `` }} +
+ + + + + + +
+ {{ __dangerouslyInjectHTML `` }} +
+ + + + + + + + + + + + + + + + + + +
+
+

Hi {{ .Name }},

+
+
+
Please click the following link to verify your email within {{ .VerificationEmailLifetimeHours }} hour(s).
+
+ + + + + + +
+ Verify Email +
+
+
You can also copy and paste this link into your browser directly:
+
+ +
+
+ {{ __dangerouslyInjectHTML `` }} +
+
+ {{ __dangerouslyInjectHTML `` }} +
+ + + + + + +
+ {{ __dangerouslyInjectHTML `` }} +
+ + + + + + +
+
© {{ now | date "2006" }} Grafana Labs. Sent by Grafana v{{ .BuildVersion }}.
+
+
+ {{ __dangerouslyInjectHTML `` }} +
+
+ {{ __dangerouslyInjectHTML `` }} +
+ + + diff --git a/public/emails/verify_email_update.txt b/public/emails/verify_email_update.txt new file mode 100644 index 0000000000000..cfdd2665becc2 --- /dev/null +++ b/public/emails/verify_email_update.txt @@ -0,0 +1,9 @@ +{{HiddenSubject .Subject "Verify your new email - {{.Name}}"}} + +Hi {{.Name}}, + +Copy and paste the following link directly in your browser to verify your email within {{.VerificationEmailLifetimeHours}} hour(s). +{{.AppUrl}}user/email/update?code={{.Code}} + + +Sent by Grafana v{{.BuildVersion}} (c) {{now | date "2006"}} Grafana Labs diff --git a/public/fonts/inter/Inter-Medium.woff2 b/public/fonts/inter/Inter-Medium.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0fd2ee7370b34dd5628eac3653b541ce0508f154 GIT binary patch literal 111380 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

fFa^^Eli6avL?{i{2Mh*-!E_)Q!wHRA zquz8d702tssgFPm+i8HpEZeD{q;<<-7Pz+ScIMaa$IT?(HxLLE618xdJtCn<1R9ZQ zwmamHEuQ*sQ`}@cl}Ihu8x#taLbX6S11hDWqND@UvZ9#P#hhOVb3UDvxfp}SLd0rg zF4eWE6d31J*sNnNMHicL1~JwFn=G57Ut*viQGnRJCjlk^MNxt%htK#X&DwE{B+eU( zyeJ%P1l=sr@53z8!Zagj;6pyY9w100y-wXQ165VS2pVqlqR%9a$NdHrNuAcSTy20t zFk#@lvgfqZmt1l_a$fFJv1%Wsc2`oqATgObP%*iJx%fPeF}a-jQ>Dc*xsv;+l0Sq} zv0hS{vLFIui+H*Z<}yAbqg^AT8mFbubt-#mU3SA_Ff)qmBEp?BH^uCH-DB7n^}D9g zS#@S(on6aqeI9$8hCS4XGgZs+oUT*^!XPgkNC<{~A3t6ginw8n03M1N9YtOkQl@Dw zj#XW|Z9t`FZye{!F^+!+c?dl4RUG0s1WByBZxTPD1o3^ExA{eXP#iyh00^2gW8yN# z>v@LmNwF@4ya1GHIPYmC=2Dr(w7IHNfz`Uq)`SgBoZpvVH(YSg)33JD%D&&_M7Wlo zVX`_Mk0;BeyZ!=$qJ(oQgof%Mt+^64D(Hv5wj8}PUW7(rB*`xJY$ z`nK$PpbvXhI6`r%SNW$0)l?$nZjep}{SN6@4XB~?$1S!N!-CJiPWVvjUzEL}g9xtn zda+#pLAw8b1N&by|NqWl{_`RJ$63Jabn>rRE)YQW?09z!Xdv|M3>()juRRqK)_*-R zn?UAdP3Wa9!QlmiVzN_FG5>}}=9&E2NL0w6DvFYtq5a179NM^g_$V%6u;*<0bMXd5 zqA?h)4$oFB_yU2TP$+~1R3gLaQ?hM0H^7iv_;dQ<;^KuGC9|gs|L;(W=6|46Z8#zf zAV5R_@-8xV0-VO3yb@W0%36bxxAwI`?K zEg6-tI-QvCe7XS#37w>+M&a@)><_w6^JRt4FcTmaKgB@b?Ru|jN4pdpQ}pS5di)PE zvuAOIB>1Vng;vh4DAWx$ttn:n&Y3J(F%MgS0j4AsOlPp;kC;bT%#hdlcJf?URo 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 literal 0 HcmV?d00001 diff --git a/public/fonts/inter/Inter-Regular.woff2 b/public/fonts/inter/Inter-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b8699af29b021cbbbdf82e18f5c4a2271d19b616 GIT binary patch literal 108488 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 ACjbBd diff --git a/public/fonts/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1pL7SUc.woff2 b/public/fonts/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1pL7SUc.woff2 deleted file mode 100644 index a7f9f92d1aa65513006cc18327c2381b11d8f913..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22516 zcmV(>K-j-`Pew8T0RR9109W(?6951J0GjXs09S|r0RR9100000000000000000000 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 F0sy9$SaJXW diff --git a/public/fonts/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa2JL7SUc.woff2 b/public/fonts/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa2JL7SUc.woff2 deleted file mode 100644 index 27b06439caddaf40492c94ac0e971157de14299c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26952 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 diff --git a/public/fonts/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa2ZL7SUc.woff2 b/public/fonts/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa2ZL7SUc.woff2 deleted file mode 100644 index 9026d23107761ab55378e9325ef1f792fb6876a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11412 zcmV;FENjzuPew8T0RR9104$UM6951J0BXzt04yp10RR9100000000000000000000 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 diff --git a/public/fonts/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa2pL7SUc.woff2 b/public/fonts/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa2pL7SUc.woff2 deleted file mode 100644 index 11afe85d70795bee24a779d458caab5406b2d101..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8892 zcmV;tB17GGPew8T0RR9103y5q6951J084-X03uNU0RR9100000000000000000000 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 diff --git a/public/img/grot-404-dark.svg b/public/img/grot-404-dark.svg index dd6e81f4f0e50..4ae37d64cef4d 100644 --- a/public/img/grot-404-dark.svg +++ b/public/img/grot-404-dark.svg @@ -1,61 +1,67 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/grot-404-light.svg b/public/img/grot-404-light.svg index 2c9ca391204fa..b2e54046cb89e 100644 --- a/public/img/grot-404-light.svg +++ b/public/img/grot-404-light.svg @@ -1,61 +1,67 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/icons/README.md b/public/img/icons/README.md new file mode 100644 index 0000000000000..7bc59fcf984ca --- /dev/null +++ b/public/img/icons/README.md @@ -0,0 +1,12 @@ +# How to add a new icon + +- Add the new icon svg to the `unicons/` directory + - Yes, even if it's not a unicon icon or from [IconScout](https://iconscout.com/unicons/solid-icons) + - We will eventually condense all the separate folders into a single `icons/` directory, and since `unicons/` is the default it makes sense to add new icons there +- Ensure the new icon source is formatted correctly: + - Remove any `width` or `height` attributes + - If the icon is a single color, ensure any explicitly defined fill or stroke colors are either removed (or set to `currentColor` if a color must be defined) + - This allows the consumer to control the color of the `Icon`, which is useful for hover/focus states +- Modify the [`availableIconsIndex` map in `@grafana/data`](https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/types/icon.ts#L1) and add the new icon + - **Note:** the key must exactly match the icon filename, e.g. if your new icon is `my-icon.svg`, the key must be `my-icon` +- Run `yarn storybook` and verify the new icon appears correctly in the `Icon` story diff --git a/public/img/icons/unicons/ai.svg b/public/img/icons/unicons/ai.svg index d8df892aa90d7..0ea0f2502f759 100644 --- a/public/img/icons/unicons/ai.svg +++ b/public/img/icons/unicons/ai.svg @@ -1,4 +1,4 @@ - + diff --git a/public/img/icons/unicons/application-observability.svg b/public/img/icons/unicons/application-observability.svg index 9ea3ec93680da..cc33bd99ddf63 100644 --- a/public/img/icons/unicons/application-observability.svg +++ b/public/img/icons/unicons/application-observability.svg @@ -1,9 +1,9 @@ - + - + diff --git a/public/img/icons/unicons/asserts.svg b/public/img/icons/unicons/asserts.svg new file mode 100644 index 0000000000000..459045ebb908a --- /dev/null +++ b/public/img/icons/unicons/asserts.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/img/icons/unicons/frontend-observability.svg b/public/img/icons/unicons/frontend-observability.svg index 743b87dc8a805..db73e4f518112 100644 --- a/public/img/icons/unicons/frontend-observability.svg +++ b/public/img/icons/unicons/frontend-observability.svg @@ -1,4 +1,6 @@ - - - + + + + + diff --git a/public/img/icons/unicons/k6.svg b/public/img/icons/unicons/k6.svg index 26107f6960dc1..ba5cd2d7e3908 100644 --- a/public/img/icons/unicons/k6.svg +++ b/public/img/icons/unicons/k6.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/public/img/icons/unicons/spinner.svg b/public/img/icons/unicons/spinner.svg index fca18c3939356..62eb684eb27e9 100644 --- a/public/img/icons/unicons/spinner.svg +++ b/public/img/icons/unicons/spinner.svg @@ -1,3 +1,3 @@ - + diff --git a/public/img/icons/unicons/unarchive.svg b/public/img/icons/unicons/unarchive.svg new file mode 100644 index 0000000000000..afe7b426b383b --- /dev/null +++ b/public/img/icons/unicons/unarchive.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/img/plugins/pagerduty.svg b/public/img/plugins/pagerduty.svg new file mode 100644 index 0000000000000..ff1cac4bc8ef0 --- /dev/null +++ b/public/img/plugins/pagerduty.svg @@ -0,0 +1,20 @@ + + + + 216px copy + Created with Sketch. + + + + + + + + + \ No newline at end of file diff --git a/public/img/transformations/dark/groupToNestedTable.svg b/public/img/transformations/dark/groupToNestedTable.svg new file mode 100644 index 0000000000000..6c3a608acb3c1 --- /dev/null +++ b/public/img/transformations/dark/groupToNestedTable.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/transformations/disabled/groupToNestedTable.svg b/public/img/transformations/disabled/groupToNestedTable.svg new file mode 100644 index 0000000000000..fe73d885f7b0e --- /dev/null +++ b/public/img/transformations/disabled/groupToNestedTable.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/transformations/light/groupToNestedTable.svg b/public/img/transformations/light/groupToNestedTable.svg new file mode 100644 index 0000000000000..31c148c855edf --- /dev/null +++ b/public/img/transformations/light/groupToNestedTable.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/lib/monaco-languages/kusto.ts b/public/lib/monaco-languages/kusto.ts index 92adf5f801e87..27a10c4a83aa7 100644 --- a/public/lib/monaco-languages/kusto.ts +++ b/public/lib/monaco-languages/kusto.ts @@ -1,5 +1,5 @@ +import { CorsWorker as Worker } from 'app/core/utils/CorsWorker'; + export default function loadKusto() { - return new Promise((resolve) => - __non_webpack_require__(['vs/language/kusto/monaco.contribution'], () => resolve()) - ); + return new Worker(new URL('@kusto/monaco-kusto/release/esm/kusto.worker', import.meta.url)); } diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index e01da1163551d..8955344c1f153 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -1,5 +1,5 @@ { - "_comment": "Diese Datei ist die Quelle für englische Strings. Bearbeiten Sie diese, um die Pluralformen und andere Ausdrücke für die Benutzeroberfläche zu ändern.", + "_comment": "Diese Datei ist die zuverlässige Quelle für englische Strings. Bearbeiten Sie diese im Code, um Pluralformen und andere Ausdrücke für die Benutzeroberfläche zu ändern.", "access-control": { "add-permission": { "role-label": "Rolle", @@ -26,7 +26,7 @@ } }, "bouncing-loader": { - "label": "" + "label": "Wird geladen" }, "browse-dashboards": { "action": { @@ -76,6 +76,7 @@ "folder-picker": { "accessible-label": "Ordner auswählen: {{ label }} aktuell ausgewählt", "button-label": "Ordner auswählen", + "clear-selection": "Auswahl löschen", "empty-message": "Keine Ordner gefunden", "error-title": "Fehler beim Laden der Ordner", "search-placeholder": "Ordner suchen", @@ -107,6 +108,9 @@ "dark-theme": "Dunkel", "light-theme": "Hell" }, + "empty-state": { + "title": "Keine Ergebnisse gefunden" + }, "search-box": { "placeholder": "Suche oder springe zu ..." }, @@ -233,6 +237,10 @@ "visualization": "Visualisierung", "widget": "Widget" }, + "alert-rules-drawer": { + "redirect-link": "Liste in Grafana Alerting", + "subtitle": "Benachrichtigungsregeln im Zusammenhang mit diesem Dashboard" + }, "empty": { "add-library-panel-body": "Visualisierungen hinzufügen, die mit anderen Dashboards geteilt werden.", "add-library-panel-button": "Bibliotheksfenster hinzufügen", @@ -301,7 +309,7 @@ }, "toolbar": { "add": "Hinzufügen", - "add-panel": "Panel hinzufügen", + "alert-rules": "Warnregeln", "mark-favorite": "Als Favorit markieren", "open-original": "Original-Dashboard öffnen", "playlist-next": "Zum nächsten Dashboard", @@ -311,6 +319,7 @@ "save": "Dashboard speichern", "settings": "Dashboard-Einstellungen", "share": "Dashboard oder Panel teilen", + "share-button": "Teilen", "unmark-favorite": "Markierung als Favorit entfernen" }, "validation": { @@ -360,6 +369,7 @@ "title-label": "Titel" }, "json-editor": { + "apply-button": "Änderungen anwenden", "save-button": "Änderungen speichern", "subtitle": "Das JSON-Modell unten ist die Datenstruktur, die das Dashboard definiert. Dazu gehören Dashboard-Einstellungen, Fenster-Einstellungen, Layout, Abfragen und so weiter.", "title": "JSON-Modell" @@ -412,9 +422,6 @@ "label": "Neue Datenquelle hinzufügen" } }, - "drawer": { - "close": "" - }, "explore": { "add-to-dashboard": "Zum Dashboard hinzufügen", "rich-history": { @@ -533,13 +540,13 @@ }, "toolbar": { "aria-label": "Werkzeugleiste durchsuchen", - "copy-link": "", - "copy-link-abs-time": "", - "copy-links-absolute-category": "", - "copy-links-normal-category": "", - "copy-shortened-link": "Verkürzten Link kopieren", - "copy-shortened-link-abs-time": "", - "copy-shortened-link-menu": "", + "copy-link": "URL kopieren", + "copy-link-abs-time": "Absolute URL kopieren", + "copy-links-absolute-category": "Zeitsynchronisierte URL-Links (Freigabe mit intaktem Zeitraum)", + "copy-links-normal-category": "Normale URL-Links", + "copy-shortened-link": "Gekürzte URL kopieren", + "copy-shortened-link-abs-time": "Absolut verkürzte URL kopieren", + "copy-shortened-link-menu": "Verkürztes Linkmenü öffnen", "refresh-picker-cancel": "Abbrechen", "refresh-picker-run": "Abfrage ausführen", "split-close": "Schließen", @@ -554,6 +561,9 @@ "loading": "Ordner werden geladen …" }, "grafana-ui": { + "drawer": { + "close": "Schließen" + }, "modal": { "close-tooltip": "Schließen" }, @@ -585,6 +595,7 @@ "shortcuts-description": { "change-theme": "Thema ändern", "collapse-all-rows": "Alle Zeilen einklappen", + "copy-time-range": "Zeitraum kopieren", "dashboard-settings": "Dashboard-Einstellungen", "duplicate-panel": "Fenster duplizieren", "exit-edit/setting-views": "Ansicht beenden/einstellen", @@ -598,6 +609,7 @@ "move-time-range-forward": "Zeitbereich nach vorne verschieben", "open-search": "Suche öffnen", "open-shared-modal": "Geteilter Fenstermodus öffnen", + "paste-time-range": "Zeitraum einfügen", "refresh-all-panels": "Alle Fenster aktualisieren", "remove-panel": "Fenster entfernen", "save-dashboard": "Dashboard speichern", @@ -640,15 +652,15 @@ }, "library-panels": { "modal": { - "body_one": "Dieses Fenster wird in {{count}} Dashboard verwendet. Bitte wählen Sie aus, in welchem Dashboard Sie das Fenster sehen möchten:", - "body_other": "Dieses Fenster wird in {{count}} Dashboards verwendet. Bitte wählen Sie aus, in welchem Dashboard Sie das Fenster sehen möchten:", "button-cancel": "<0>Abbrechen", "button-view-panel1": "Fenster in {{label}} anzeigen...", "button-view-panel2": "Fenster im Dashboard anzeigen...", "panel-not-linked": "Fenster ist nicht mit einem Dashboard verbunden. Fügen Sie das Fenster einem Dashboard hinzu und versuchen Sie es erneut.", "select-no-options-message": "Keine Dashboards gefunden", "select-placeholder": "Tippen, um nach Dashboard zu suchen", - "title": "Fenster im Dashboard anzeigen" + "title": "Fenster im Dashboard anzeigen", + "body_one": "Dieses Fenster wird in {{count}} Dashboard verwendet. Bitte wählen Sie aus, in welchem Dashboard Sie das Fenster sehen möchten:", + "body_other": "Dieses Fenster wird in {{count}} Dashboards verwendet. Bitte wählen Sie aus, in welchem Dashboard Sie das Fenster sehen möchten:" }, "save": { "error": "Fehler beim Speichern des Bibliotheks-Panels: „{{errorMsg}}“", @@ -661,6 +673,139 @@ "invalid-user-or-password": "Ungültiger Benutzername oder Passwort", "title": "Login fehlgeschlagen", "unknown": "Unbekannter Fehler aufgetreten" + }, + "forgot-password": "", + "form": { + "password-label": "", + "password-required": "", + "submit-label": "", + "submit-loading-label": "", + "username-label": "", + "username-required": "" + }, + "services": { + "sing-in-with-prefix": "" + }, + "signup": { + "button-label": "", + "new-to-question": "" + } + }, + "migrate-to-cloud": { + "can-i-move": { + "body": "", + "link-title": "", + "title": "" + }, + "connect-modal": { + "body-cloud-stack": "", + "body-get-started": "", + "body-paste-stack": "", + "body-sign-up": "", + "body-token": "", + "body-token-field": "", + "body-token-field-placeholder": "", + "body-token-instructions": "", + "body-url-field": "", + "body-view-stacks": "", + "cancel": "", + "connect": "", + "connecting": "", + "stack-required-error": "", + "title": "", + "token-required-error": "" + }, + "cta": { + "button": "", + "header": "" + }, + "disconnect-modal": { + "body": "", + "cancel": "", + "disconnect": "", + "disconnecting": "", + "error": "", + "title": "" + }, + "get-started": { + "body": "", + "configure-pdc-link": "", + "link-title": "", + "step-1": "", + "step-2": "", + "step-3": "", + "step-4": "", + "step-5": "", + "title": "" + }, + "is-it-secure": { + "body": "", + "link-title": "", + "title": "" + }, + "migrate-to-this-stack": { + "body": "", + "link-title": "", + "title": "" + }, + "migration-token": { + "body": "", + "delete-button": "", + "delete-modal-body": "", + "delete-modal-cancel": "", + "delete-modal-confirm": "", + "delete-modal-deleting": "", + "delete-modal-title": "", + "generate-button": "", + "generate-button-loading": "", + "modal-close": "", + "modal-copy-and-close": "", + "modal-copy-button": "", + "modal-field-description": "", + "modal-field-label": "", + "modal-title": "", + "status": "", + "title": "" + }, + "pdc": { + "body": "", + "link-title": "", + "title": "" + }, + "pricing": { + "body": "", + "link-title": "", + "title": "" + }, + "resource-status": { + "error-details-button": "", + "failed": "", + "migrated": "", + "migrating": "", + "not-migrated": "", + "unknown": "" + }, + "resource-type": { + "dashboard": "", + "datasource": "", + "unknown": "" + }, + "resources": { + "disconnect": "" + }, + "token-status": { + "active": "", + "no-active": "" + }, + "what-is-cloud": { + "body": "", + "link-title": "", + "title": "" + }, + "why-host": { + "body": "", + "link-title": "", + "title": "" } }, "nav": { @@ -671,6 +816,9 @@ "subtitle": "Serverweite Einstellungen und Zugriff auf Ressourcen wie Organisationen, Benutzer und Lizenzen verwalten", "title": "Server-Administrator" }, + "alert-list-legacy": { + "title": "Warnregeln" + }, "alerting": { "subtitle": "Informiere dich über Probleme in deinen Systemen kurz nach deren Auftreten", "title": "Meldungen" @@ -707,6 +855,10 @@ "subtitle": "Schalte Benachrichtigungen von einer oder mehrerer Warnregeln aus", "title": "Stummschalten" }, + "alerting-upgrade": { + "subtitle": "Aktualisieren Sie Ihre vorhandenen Legacy-Benachrichtigungen und Benachrichtigungskanäle auf das neue Grafana Alerting", + "title": "Alerting-Upgrade" + }, "alerts-and-incidents": { "subtitle": "Warn- und Vorfallmanagement-Apps", "title": "Warnungen und IRM" @@ -725,6 +877,9 @@ "authentication": { "title": "Authentifizierung" }, + "collector": { + "title": "Sammler" + }, "config": { "title": "Verwaltung" }, @@ -785,7 +940,7 @@ "title": "Entdecken" }, "frontend": { - "subtitle": "", + "subtitle": "Erhalten Sie echte Einblicke in die Benutzerüberwachung", "title": "Frontend" }, "frontend-app": { @@ -816,14 +971,14 @@ "title": "Störungen" }, "infrastructure": { - "subtitle": "", - "title": "" + "subtitle": "Verstehen Sie den Zustand Ihrer Infrastruktur", + "title": "Infrastruktur" }, "integrations": { "title": "Integrationen" }, "k6": { - "title": "" + "title": "Leistung" }, "kubernetes": { "title": "Kubernetes" @@ -838,6 +993,10 @@ "manage-folder": { "subtitle": "Ordner-Dashboards und Berechtigungen verwalten" }, + "migrate-to-cloud": { + "subtitle": "", + "title": "" + }, "monitoring": { "subtitle": "Überwachungs- und Infrastruktur-Apps", "title": "Beobachtbarkeit" @@ -861,9 +1020,6 @@ "subtitle": "Verwalte Einstellungen in der gesamten Organisation", "title": "Standardeinstellungen" }, - "performance-testing": { - "title": "Leistungstests" - }, "playlists": { "subtitle": "Gruppen von Dashboards, die in einer bestimmten Reihenfolge angezeigt werden", "title": "Playlisten" @@ -872,6 +1028,10 @@ "subtitle": "Erweitere das Grafana-Erlebnis mit Plug-ins", "title": "Plug-ins" }, + "private-data-source-connections": { + "subtitle": "Daten abfragen, die in einem gesicherten Netzwerk vorhanden sind, ohne das Netzwerk für eingehenden Datenverkehr von Grafana Cloud zu öffnen. Erfahren Sie mehr in unseren Dokumenten.", + "title": "Private Datenquelle verbinden" + }, "profile/notifications": { "title": "Benachrichtigungsverlauf" }, @@ -945,8 +1105,8 @@ "title": "Teams" }, "testing-and-synthetics": { - "subtitle": "", - "title": "" + "subtitle": "Optimieren Sie die Leistung mit Erkenntnissen aus k6 und Synthetic Monitoring", + "title": "Testen & Synthetics" }, "upgrading": { "title": "Statistiken und Lizenz" @@ -963,7 +1123,8 @@ "megamenu": { "close": "Menü schließen", "dock": "Menü andocken", - "undock": "" + "list-label": "Navigation", + "undock": "Menü abdocken" }, "toolbar": { "close-menu": "Menü schließen", @@ -974,7 +1135,7 @@ }, "news": { "drawer": { - "close": "" + "close": "Drawer schließen" }, "title": "Das Neueste aus dem Blog" }, @@ -1061,7 +1222,122 @@ "new-password-same-as-old": "Neues Passwort kann nicht das gleiche sein wie das alte.", "old-password-label": "Altes Passwort", "old-password-required": "Altes Passwort ist erforderlich", - "passwords-must-match": "Passwörter müssen übereinstimmen" + "passwords-must-match": "Passwörter müssen übereinstimmen", + "strong-password-validation-register": "" + } + }, + "public-dashboard": { + "acknowledgment-checkboxes": { + "ack-title": "Bevor Sie das Dashboard veröffentlichen, bestätigen Sie Folgendes:", + "data-src-ack-desc": "Die Veröffentlichung funktioniert derzeit nur mit einer Teilmenge von Datenquellen*", + "data-src-ack-tooltip": "Erfahren Sie mehr über öffentliche Datenquellen", + "public-ack-desc": "Ihr gesamtes Dashboard wird öffentlich sein*", + "public-ack-tooltip": "Erfahren Sie mehr über öffentliche Dashboards", + "usage-ack-desc": "Wenn ein Dashboard öffentlich gemacht wird, werden die Abfragen jedes Mal beim Anzeigen ausgeführt, was zur Erhöhung der Kosten führen kann*", + "usage-ack-desc-tooltip": "Erfahren Sie mehr zum Abfrage-Caching" + }, + "config": { + "can-view-dashboard-radio-button-label": "Kann Dashboard anzeigen", + "copy-button": "Kopieren", + "dashboard-url-field-label": "Dashboard-URL", + "email-share-type-option-label": "Nur bestimmte Personen", + "pause-sharing-dashboard-label": "Teilen des Dashboards pausieren", + "public-share-type-option-label": "Jeder mit einem Link", + "revoke-public-URL-button": "Öffentliche URL widerrufen", + "revoke-public-URL-button-title": "Öffentliche URL widerrufen", + "settings-title": "Einstellungen" + }, + "create-page": { + "generate-public-url-button": "Öffentliche URL generieren", + "unsupported-features-desc": "Momentan unterstützen wir keine Template-Variablen oder Frontend-Datenquellen", + "welcome-title": "Willkommen zu öffentlichen Dashboards!" + }, + "delete-modal": { + "revoke-nonorphaned-body-text": "Sind Sie sicher, dass Sie diese URL widerrufen möchten? Das Dashboard wird nicht mehr öffentlich sein.", + "revoke-orphaned-body-text": "Das verwaiste öffentliche Dashboard wird nicht mehr öffentlich sein.", + "revoke-title": "Öffentliche URL widerrufen" + }, + "email-sharing": { + "input-invalid-email-text": "Ungültige E-Mail", + "input-required-email-text": "E-Mail-Adresse ist erforderlich", + "invite-button": "Einladen", + "invite-field-desc": "Personen per E-Mail einladen", + "invite-field-label": "Einladen", + "resend-button": "Erneut senden", + "resend-button-title": "Erneut senden", + "revoke-button": "Widerrufen", + "revoke-button-title": "Widerrufen" + }, + "modal-alerts": { + "no-upsert-perm-alert-desc": "Kontaktieren Sie Ihren Administrator, um die Berechtigung zu {{mode}} öffentlichen Dashboards zu erhalten", + "no-upsert-perm-alert-title": "Sie sind nicht berechtigt, ein öffentliches Dashboard zu {{ mode }}", + "save-dashboard-changes-alert-title": "Bitte speichern Sie Ihre Dashboard-Änderungen bevor Sie die öffentliche Konfiguration aktualisieren", + "unsupport-data-source-alert-readmore-link": "Erfahren Sie mehr über unterstützte Datenquellen", + "unsupported-data-source-alert-desc": "In diesem Dashboard gibt es Datenquellen, die für öffentliche Dashboards nicht unterstützt werden. Panels, die diese Datenquellen nutzen, funktionieren möglicherweise nicht ordnungsgemäß: {{unsupportedDataSources}}.", + "unsupported-data-source-alert-title": "Nicht unterstützte Datenquellen", + "unsupported-template-variable-alert-desc": "Dieses öffentliche Dashboard funktioniert möglicherweise nicht, da es Template-Variablen verwendet", + "unsupported-template-variable-alert-title": "Template-Variablen werden nicht unterstützt" + }, + "settings-bar-header": { + "collapse-settings-tooltip": "Einstellungen einklappen", + "expand-settings-tooltip": "Einstellungen aufklappen" + }, + "settings-configuration": { + "default-time-range-label": "Standardzeitraum", + "default-time-range-label-desc": "Das öffentliche Dashboard verwendet die Standard-Zeitraumeinstellungen des Dashboards", + "show-annotations-label": "Anmerkungen anzeigen", + "show-annotations-label-desc": "Anmerkungen im öffentlichen Dashboard anzeigen", + "time-range-picker-label": "Zeitraumauswahl aktiviert", + "time-range-picker-label-desc": "Betrachtern erlauben, den Zeitraum zu ändern" + }, + "settings-summary": { + "annotations-hide-text": "Anmerkungen = ausblenden", + "annotations-show-text": "Anmerkungen = anzeigen", + "time-range-picker-disabled-text": "Zeitraumauswahl = deaktiviert", + "time-range-picker-enabled-text": "Zeitraumauswahl = aktiviert", + "time-range-text": "Zeitraum = " + } + }, + "public-dashboard-list": { + "button": { + "config-button-tooltip": "Öffentliches Dashboard konfigurieren", + "revoke-button-text": "Öffentliche URL widerrufen", + "revoke-button-tooltip": "Öffentliche Dashboard-URL widerrufen", + "view-button-tooltip": "Öffentliches Dashboard anzeigen" + }, + "dashboard-title": { + "orphaned-title": "<0>Verwaistes öffentliches Dashboard", + "orphaned-tooltip": "Das verlinkte Dashboard wurde bereits gelöscht" + }, + "toggle": { + "pause-sharing-toggle-text": "Teilen pausieren" + } + }, + "public-dashboard-users-access-list": { + "dashboard-modal": { + "loading-text": "Wird geladen ...", + "open-dashboard-list-text": "Dashboard-Liste öffnen", + "public-dashboard-link": "Öffentliches Dashboard-URL", + "public-dashboard-setting": "Einstellungen des öffentlichen Dashboards" + }, + "delete-user-modal": { + "delete-user-button-text": "Benutzer löschen", + "delete-user-cancel-button": "Abbrechen", + "delete-user-revoke-access-button": "Zugriff widerrufen", + "revoke-access-title": "Zugriff widerrufen", + "revoke-user-access-modal-desc-line1": "Sind Sie sicher, dass Sie den Zugriff für {{email}} widerrufen möchten?", + "revoke-user-access-modal-desc-line2": "Durch diese Aktion wird {{email}}' sofort der Zugriff auf alle öffentlichen Dashboards entzogen." + }, + "modal": { + "dashboard-modal-title": "Öffentliche Dashboards" + }, + "table-header": { + "activated-label": "Aktiviert", + "activated-tooltip": "Frühester Zeitpunkt, zu dem ein Benutzer ein aktiver Benutzer eines Dashboards war", + "email-label": "E-Mail-Adresse", + "last-active-label": "Zuletzt aktiv", + "origin-label": "Ursprung", + "role-label": "Rolle" } }, "query-operation": { @@ -1099,6 +1375,12 @@ "turned-off": "Automatische Aktualisierung aus" } }, + "return-to-previous": { + "button": { + "label": "Zurück zu {{title}}" + }, + "dismissable-button": "Schließen" + }, "search": { "actions": { "include-panels": "Panels einschließen", @@ -1176,10 +1458,10 @@ "expire-day": "1 Tag", "expire-hour": "1 Stunde", "expire-never": "Nie", - "expire-week": "7 Tage", + "expire-week": "", "info-text-1": "Ein Schnappschuss ist eine Möglichkeit, ein interaktives Dashboard sofort öffentlich zu teilen. Beim Erstellen entfernen wir sensible Daten wie Abfragen (Metriken, Vorlagen und Anmerkungen) und Panel-Links, sodass nur die sichtbaren Metrikdaten und die in dein Dashboard eingebetteten Seriennamen angezeigt werden.", "info-text-2": "Beachte, dass dein Schnappschuss <1>für jeden sichtbar ist, der den Link hat und auf die URL zugreifen kann. Teile Schnappschüsse daher mit Bedacht.", - "local-button": "Lokaler Schnappschuss", + "local-button": "", "mistake-message": "Hast du einen Fehler gemacht? ", "name": "Name des Schnappschusses", "timeout": "Timeout (Sekunden)", @@ -1192,7 +1474,8 @@ "library-panel": "Bibliotheks-Panel", "link": "Link", "panel-embed": "Einbetten", - "public-dashboard": "Öffentliches Dashboard", + "public-dashboard": "", + "public-dashboard-title": "Öffentliches Dashboard", "snapshot": "Schnappschuss" }, "theme-picker": { @@ -1263,7 +1546,7 @@ "calendar": { "apply-button": "Zeitbereich anwenden", "cancel-button": "Abbrechen", - "close": "", + "close": "Kalender schließen", "select-time": "Einen Zeitbereich auswählen" }, "content": { @@ -1271,6 +1554,13 @@ "empty-recent-list-info": "Es sieht so aus, als hätten Sie diesen Zeit-Auswähler noch nie benutzt. Sobald Sie einige Zeitintervalle eingeben, werden hier die zuletzt verwendeten Intervalle angezeigt.", "filter-placeholder": "Schnellbereiche suchen" }, + "copy-paste": { + "copy-success-message": "Zeitraum in Zwischenablage kopiert", + "default-error-message": "{{error}} ist kein gültiger Zeitraum", + "default-error-title": "Ungültiger Zeitraum", + "tooltip-copy": "Zeitraum in Zwischenablage kopieren", + "tooltip-paste": "Zeitraum einfügen" + }, "footer": { "change-settings-button": "Zeiteinstellungen ändern", "fiscal-year-option": "Geschäftsjahr", @@ -1283,7 +1573,7 @@ "default-error": "Ein vergangenes Datum oder \"heutiges\" eingeben", "fiscal-year": "Geschäftsjahr", "from-input": "Von", - "open-input-calendar": "", + "open-input-calendar": "Kalender öffnen", "range-error": "„Von“ darf nicht nach „Bis“ sein", "to-input": "Bis" }, @@ -1308,8 +1598,8 @@ }, "transformations": { "empty": { - "add-transformation-body": "", - "add-transformation-header": "" + "add-transformation-body": "Mithilfe von Transformationen können Daten auf verschiedene Arten geändert werden, bevor Ihre Visualisierung angezeigt wird.<1>Dies beinhaltet die Verknüpfung von Daten, das Umbenennen von Feldern, die Erstellung von Berechnungen, das Formatieren von Daten für die Anzeige und mehr.", + "add-transformation-header": "Daten transformieren beginnen" } }, "user-orgs": { @@ -1341,6 +1631,11 @@ "user-sessions": { "loading": "Sitzungen werden geladen …" }, + "users-access-list": { + "tabs": { + "public-dashboard-users-tab-title": "Nutzer des öffentlichen Dashboards" + } + }, "variable": { "adhoc": { "placeholder": "Wert auswählen" @@ -1358,4 +1653,4 @@ "placeholder": "Variablenwert eingeben" } } -} +} \ No newline at end of file diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 2f81ff99e7a3c..75b5655475520 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1,5 +1,5 @@ { - "_comment": "This file is the source of truth for English strings. Edit this to change plurals and other phrases for the UI.", + "_comment": "This file is the source of truth for English strings. Edit this in the codebase to change plurals and other phrases for the UI.", "access-control": { "add-permission": { "role-label": "Role", @@ -76,6 +76,7 @@ "folder-picker": { "accessible-label": "Select folder: {{ label }} currently selected", "button-label": "Select folder", + "clear-selection": "Clear selection", "empty-message": "No folders found", "error-title": "Error loading folders", "search-placeholder": "Search folders", @@ -107,6 +108,9 @@ "dark-theme": "Dark", "light-theme": "Light" }, + "empty-state": { + "title": "No results found" + }, "search-box": { "placeholder": "Search or jump to..." }, @@ -233,6 +237,10 @@ "visualization": "Visualization", "widget": "Widget" }, + "alert-rules-drawer": { + "redirect-link": "List in Grafana Alerting", + "subtitle": "Alert rules related to this dashboard" + }, "empty": { "add-library-panel-body": "Add visualizations that are shared with other dashboards.", "add-library-panel-button": "Add library panel", @@ -301,7 +309,7 @@ }, "toolbar": { "add": "Add", - "add-panel": "Add panel", + "alert-rules": "Alert rules", "mark-favorite": "Mark as favorite", "open-original": "Open original dashboard", "playlist-next": "Go to next dashboard", @@ -311,6 +319,7 @@ "save": "Save dashboard", "settings": "Dashboard settings", "share": "Share dashboard", + "share-button": "Share", "unmark-favorite": "Unmark as favorite" }, "validation": { @@ -360,6 +369,7 @@ "title-label": "Title" }, "json-editor": { + "apply-button": "Apply changes", "save-button": "Save changes", "subtitle": "The JSON model below is the data structure that defines the dashboard. This includes dashboard settings, panel settings, layout, queries, and so on.", "title": "JSON Model" @@ -412,9 +422,6 @@ "label": "Add new data source" } }, - "drawer": { - "close": "Close Drawer" - }, "explore": { "add-to-dashboard": "Add to dashboard", "rich-history": { @@ -554,6 +561,9 @@ "loading": "Loading folders..." }, "grafana-ui": { + "drawer": { + "close": "Close" + }, "modal": { "close-tooltip": "Close" }, @@ -585,6 +595,7 @@ "shortcuts-description": { "change-theme": "Change theme", "collapse-all-rows": "Collapse all rows", + "copy-time-range": "Copy time range", "dashboard-settings": "Dashboard settings", "duplicate-panel": "Duplicate Panel", "exit-edit/setting-views": "Exit edit/setting views", @@ -598,6 +609,7 @@ "move-time-range-forward": "Move time range forward", "open-search": "Open search", "open-shared-modal": "Open Panel Share Modal", + "paste-time-range": "Paste time range", "refresh-all-panels": "Refresh all panels", "remove-panel": "Remove Panel", "save-dashboard": "Save dashboard", @@ -661,6 +673,139 @@ "invalid-user-or-password": "Invalid username or password", "title": "Login failed", "unknown": "Unknown error occurred" + }, + "forgot-password": "Forgot your password?", + "form": { + "password-label": "Password", + "password-required": "Password is required", + "submit-label": "Log in", + "submit-loading-label": "Logging in...", + "username-label": "Email or username", + "username-required": "Email or username is required" + }, + "services": { + "sing-in-with-prefix": "Sign in with {{serviceName}}" + }, + "signup": { + "button-label": "Sign up", + "new-to-question": "New to Grafana?" + } + }, + "migrate-to-cloud": { + "can-i-move": { + "body": "Once you connect this installation to a cloud stack, you'll be able to upload data sources and dashboards.", + "link-title": "Learn about migrating other settings", + "title": "Can I move this installation to Grafana Cloud?" + }, + "connect-modal": { + "body-cloud-stack": "You'll also need a cloud stack. If you just signed up, we'll automatically create your first stack. If you have an account, you'll need to select or create a stack.", + "body-get-started": "To get started, you'll need a Grafana.com account.", + "body-paste-stack": "Once you've decided on a stack, paste the URL below.", + "body-sign-up": "Sign up for a Grafana.com account", + "body-token": "Your self-managed Grafana installation needs special access to securely migrate content. You'll need to create a migration token on your chosen cloud stack.", + "body-token-field": "Migration token", + "body-token-field-placeholder": "Paste token here", + "body-token-instructions": "Log into your cloud stack and navigate to Administration, General, Migrate to Grafana Cloud. Create a migration token on that screen and paste the token here.", + "body-url-field": "Cloud stack URL", + "body-view-stacks": "View my cloud stacks", + "cancel": "Cancel", + "connect": "Connect to this stack", + "connecting": "Connecting to this stack...", + "stack-required-error": "Stack URL is required", + "title": "Connect to a cloud stack", + "token-required-error": "Migration token is required" + }, + "cta": { + "button": "Migrate this instance to Cloud", + "header": "Let us manage your Grafana stack" + }, + "disconnect-modal": { + "body": "This will remove the migration token from this installation. If you wish to upload more resources in the future, you will need to enter a new migration token.", + "cancel": "Cancel", + "disconnect": "Disconnect", + "disconnecting": "Disconnecting...", + "error": "There was an error disconnecting", + "title": "Disconnect from cloud stack" + }, + "get-started": { + "body": "The migration process must be started from your self-managed Grafana instance.", + "configure-pdc-link": "Configure PDC for this stack", + "link-title": "Learn more about Private Data Source Connect", + "step-1": "Log in to your self-managed instance and navigate to Administration, General, Migrate to Grafana Cloud.", + "step-2": "Select \"Migrate this instance to Cloud\".", + "step-3": "You'll be prompted for a migration token. Generate one from this screen.", + "step-4": "In your self-managed instance, select \"Upload everything\" to upload data sources and dashboards to this cloud stack.", + "step-5": "If some of your data sources will not work over the public internet, you’ll need to install Private Data Source Connect in your self-managed environment.", + "title": "How to get started" + }, + "is-it-secure": { + "body": "Grafana Labs is committed to maintaining the highest standards of data privacy and security. By implementing industry-standard security technologies and procedures, we help protect our customers' data from unauthorized access, use, or disclosure.", + "link-title": "Grafana Labs Trust Center", + "title": "Is it secure?" + }, + "migrate-to-this-stack": { + "body": "Some configuration from your self-managed Grafana instance can be automatically copied to this cloud stack.", + "link-title": "View the full migration guide", + "title": "Migrate configuration to this stack" + }, + "migration-token": { + "body": "Your self-managed Grafana instance will require a special authentication token to securely connect to this cloud stack.", + "delete-button": "Delete this migration token", + "delete-modal-body": "If you've already used this token with a self-managed installation, that installation will no longer be able to upload content.", + "delete-modal-cancel": "Cancel", + "delete-modal-confirm": "Delete", + "delete-modal-deleting": "Deleting...", + "delete-modal-title": "Delete migration token", + "generate-button": "Generate a migration token", + "generate-button-loading": "Generating a migration token...", + "modal-close": "Close", + "modal-copy-and-close": "Copy to clipboard and close", + "modal-copy-button": "Copy to clipboard", + "modal-field-description": "Copy the token now as you will not be able to see it again. Losing a token requires creating a new one.", + "modal-field-label": "Token", + "modal-title": "Migration token created", + "status": "Current status: <2>", + "title": "Migration token" + }, + "pdc": { + "body": "Exposing your data sources to the internet can raise security concerns. Private data source connect (PDC) allows Grafana Cloud to access your existing data sources over a secure network tunnel.", + "link-title": "Learn about PDC", + "title": "Not all my data sources are on the public internet" + }, + "pricing": { + "body": "Grafana Cloud has a generous free plan and a 14 day unlimited usage trial. After your trial expires, you'll be billed based on usage over the free plan limits.", + "link-title": "Grafana Cloud pricing", + "title": "How much does it cost?" + }, + "resource-status": { + "error-details-button": "Details", + "failed": "Error", + "migrated": "Uploaded to cloud", + "migrating": "Uploading...", + "not-migrated": "Not yet uploaded", + "unknown": "Unknown" + }, + "resource-type": { + "dashboard": "Dashboard", + "datasource": "Data source", + "unknown": "Unknown" + }, + "resources": { + "disconnect": "Disconnect" + }, + "token-status": { + "active": "Token created and active", + "no-active": "No active token" + }, + "what-is-cloud": { + "body": "Grafana cloud is a fully managed cloud-hosted observability platform ideal for cloud native environments. It's everything you love about Grafana without the overhead of maintaining, upgrading, and supporting an installation.", + "link-title": "Learn about cloud features", + "title": "What is Grafana Cloud?" + }, + "why-host": { + "body": "In addition to the convenience of managed hosting, Grafana Cloud includes many cloud-exclusive features like SLOs, incident management, machine learning, and powerful observability integrations.", + "link-title": "More questions? Talk to an expert", + "title": "Why host with Grafana?" } }, "nav": { @@ -671,6 +816,9 @@ "subtitle": "Manage server-wide settings and access to resources such as organizations, users, and licenses", "title": "Server admin" }, + "alert-list-legacy": { + "title": "Alert rules" + }, "alerting": { "subtitle": "Learn about problems in your systems moments after they occur", "title": "Alerting" @@ -707,6 +855,10 @@ "subtitle": "Stop notifications from one or more alerting rules", "title": "Silences" }, + "alerting-upgrade": { + "subtitle": "Upgrade your existing legacy alerts and notification channels to the new Grafana Alerting", + "title": "Alerting upgrade" + }, "alerts-and-incidents": { "subtitle": "Alerting and incident management apps", "title": "Alerts & IRM" @@ -725,6 +877,9 @@ "authentication": { "title": "Authentication" }, + "collector": { + "title": "Collector" + }, "config": { "title": "Administration" }, @@ -838,6 +993,10 @@ "manage-folder": { "subtitle": "Manage folder dashboards and permissions" }, + "migrate-to-cloud": { + "subtitle": "Copy configuration from your self-managed installation to a cloud stack", + "title": "Migrate to Grafana Cloud" + }, "monitoring": { "subtitle": "Monitoring and infrastructure apps", "title": "Observability" @@ -861,9 +1020,6 @@ "subtitle": "Manage preferences across an organization", "title": "Default preferences" }, - "performance-testing": { - "title": "Performance testing" - }, "playlists": { "subtitle": "Groups of dashboards that are displayed in a sequence", "title": "Playlists" @@ -872,6 +1028,10 @@ "subtitle": "Extend the Grafana experience with plugins", "title": "Plugins" }, + "private-data-source-connections": { + "subtitle": "Query data that lives within a secured network without opening the network to inbound traffic from Grafana Cloud. Learn more in our docs.", + "title": "Private data source connect" + }, "profile/notifications": { "title": "Notification history" }, @@ -963,6 +1123,7 @@ "megamenu": { "close": "Close menu", "dock": "Dock menu", + "list-label": "Navigation", "undock": "Undock menu" }, "toolbar": { @@ -1061,7 +1222,122 @@ "new-password-same-as-old": "New password can't be the same as the old one.", "old-password-label": "Old password", "old-password-required": "Old password is required", - "passwords-must-match": "Passwords must match" + "passwords-must-match": "Passwords must match", + "strong-password-validation-register": "Password does not comply with the strong password policy" + } + }, + "public-dashboard": { + "acknowledgment-checkboxes": { + "ack-title": "Before you make the dashboard public, acknowledge the following:", + "data-src-ack-desc": "Publishing currently only works with a subset of data sources*", + "data-src-ack-tooltip": "Learn more about public datasources", + "public-ack-desc": "Your entire dashboard will be public*", + "public-ack-tooltip": "Learn more about public dashboards", + "usage-ack-desc": "Making a dashboard public will cause queries to run each time it is viewed, which may increase costs*", + "usage-ack-desc-tooltip": "Learn more about query caching" + }, + "config": { + "can-view-dashboard-radio-button-label": "Can view dashboard", + "copy-button": "Copy", + "dashboard-url-field-label": "Dashboard URL", + "email-share-type-option-label": "Only specified people", + "pause-sharing-dashboard-label": "Pause sharing dashboard", + "public-share-type-option-label": "Anyone with a link", + "revoke-public-URL-button": "Revoke public URL", + "revoke-public-URL-button-title": "Revoke public URL", + "settings-title": "Settings" + }, + "create-page": { + "generate-public-url-button": "Generate public URL", + "unsupported-features-desc": "Currently, we don’t support template variables or frontend data sources", + "welcome-title": "Welcome to public dashboards!" + }, + "delete-modal": { + "revoke-nonorphaned-body-text": "Are you sure you want to revoke this URL? The dashboard will no longer be public.", + "revoke-orphaned-body-text": "Orphaned public dashboard will no longer be public.", + "revoke-title": "Revoke public URL" + }, + "email-sharing": { + "input-invalid-email-text": "Invalid email", + "input-required-email-text": "Email is required", + "invite-button": "Invite", + "invite-field-desc": "Invite people by email", + "invite-field-label": "Invite", + "resend-button": "Resend", + "resend-button-title": "Resend", + "revoke-button": "Revoke", + "revoke-button-title": "Revoke" + }, + "modal-alerts": { + "no-upsert-perm-alert-desc": "Contact your admin to get permission to {{mode}} public dashboards", + "no-upsert-perm-alert-title": "You don’t have permission to {{ mode }} a public dashboard", + "save-dashboard-changes-alert-title": "Please save your dashboard changes before updating the public configuration", + "unsupport-data-source-alert-readmore-link": "Read more about supported data sources", + "unsupported-data-source-alert-desc": "There are data sources in this dashboard that are unsupported for public dashboards. Panels that use these data sources may not function properly: {{unsupportedDataSources}}.", + "unsupported-data-source-alert-title": "Unsupported data sources", + "unsupported-template-variable-alert-desc": "This public dashboard may not work since it uses template variables", + "unsupported-template-variable-alert-title": "Template variables are not supported" + }, + "settings-bar-header": { + "collapse-settings-tooltip": "Collapse settings", + "expand-settings-tooltip": "Expand settings" + }, + "settings-configuration": { + "default-time-range-label": "Default time range", + "default-time-range-label-desc": "The public dashboard uses the default time range settings of the dashboard", + "show-annotations-label": "Show annotations", + "show-annotations-label-desc": "Show annotations on public dashboard", + "time-range-picker-label": "Time range picker enabled", + "time-range-picker-label-desc": "Allow viewers to change time range" + }, + "settings-summary": { + "annotations-hide-text": "Annotations = hide", + "annotations-show-text": "Annotations = show", + "time-range-picker-disabled-text": "Time range picker = disabled", + "time-range-picker-enabled-text": "Time range picker = enabled", + "time-range-text": "Time range = " + } + }, + "public-dashboard-list": { + "button": { + "config-button-tooltip": "Configure public dashboard", + "revoke-button-text": "Revoke public URL", + "revoke-button-tooltip": "Revoke public dashboard URL", + "view-button-tooltip": "View public dashboard" + }, + "dashboard-title": { + "orphaned-title": "<0>Orphaned public dashboard", + "orphaned-tooltip": "The linked dashboard has already been deleted" + }, + "toggle": { + "pause-sharing-toggle-text": "Pause sharing" + } + }, + "public-dashboard-users-access-list": { + "dashboard-modal": { + "loading-text": "Loading...", + "open-dashboard-list-text": "Open dashboards list", + "public-dashboard-link": "Public dashboard URL", + "public-dashboard-setting": "Public dashboard settings" + }, + "delete-user-modal": { + "delete-user-button-text": "Delete user", + "delete-user-cancel-button": "Cancel", + "delete-user-revoke-access-button": "Revoke access", + "revoke-access-title": "Revoke access", + "revoke-user-access-modal-desc-line1": "Are you sure you want to revoke access for {{email}}?", + "revoke-user-access-modal-desc-line2": "This action will immediately revoke {{email}}'s access to all public dashboards." + }, + "modal": { + "dashboard-modal-title": "Public dashboards" + }, + "table-header": { + "activated-label": "Activated", + "activated-tooltip": "Earliest time user has been an active user to a dashboard", + "email-label": "Email", + "last-active-label": "Last active", + "origin-label": "Origin", + "role-label": "Role" } }, "query-operation": { @@ -1099,6 +1375,12 @@ "turned-off": "Auto refresh off" } }, + "return-to-previous": { + "button": { + "label": "Back to {{title}}" + }, + "dismissable-button": "Close" + }, "search": { "actions": { "include-panels": "Include panels", @@ -1176,10 +1458,10 @@ "expire-day": "1 Day", "expire-hour": "1 Hour", "expire-never": "Never", - "expire-week": "7 Days", + "expire-week": "1 Week", "info-text-1": "A snapshot is an instant way to share an interactive dashboard publicly. When created, we strip sensitive data like queries (metric, template, and annotation) and panel links, leaving only the visible metric data and series names embedded in your dashboard.", "info-text-2": "Keep in mind, your snapshot <1>can be viewed by anyone that has the link and can access the URL. Share wisely.", - "local-button": "Local Snapshot", + "local-button": "Publish Snapshot", "mistake-message": "Did you make a mistake? ", "name": "Snapshot name", "timeout": "Timeout (seconds)", @@ -1192,7 +1474,8 @@ "library-panel": "Library panel", "link": "Link", "panel-embed": "Embed", - "public-dashboard": "Public Dashboard", + "public-dashboard": "Publish Dashboard", + "public-dashboard-title": "Public dashboard", "snapshot": "Snapshot" }, "theme-picker": { @@ -1271,6 +1554,13 @@ "empty-recent-list-info": "It looks like you haven't used this time picker before. As soon as you enter some time intervals, recently used intervals will appear here.", "filter-placeholder": "Search quick ranges" }, + "copy-paste": { + "copy-success-message": "Time range copied to clipboard", + "default-error-message": "{{error}} is not a valid time range", + "default-error-title": "Invalid time range", + "tooltip-copy": "Copy time range to clipboard", + "tooltip-paste": "Paste time range" + }, "footer": { "change-settings-button": "Change time settings", "fiscal-year-option": "Fiscal year", @@ -1341,6 +1631,11 @@ "user-sessions": { "loading": "Loading sessions..." }, + "users-access-list": { + "tabs": { + "public-dashboard-users-tab-title": "Public dashboard users" + } + }, "variable": { "adhoc": { "placeholder": "Select value" diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index f002bac07a55f..2658a6dd9573b 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -1,5 +1,5 @@ { - "_comment": "Este archivo es la fuente de información para las cadenas en inglés. Edítelo para cambiar los plurales y otras frases de la interfaz de usuario.", + "_comment": "Este archivo es la fuente original para las cadenas en inglés. Edita la base del código para cambiar los plurales y otras frases de la interfaz de usuario.", "access-control": { "add-permission": { "role-label": "Rol", @@ -26,7 +26,7 @@ } }, "bouncing-loader": { - "label": "" + "label": "Cargando" }, "browse-dashboards": { "action": { @@ -49,19 +49,14 @@ }, "counts": { "alertRule_one": "{{count}} regla de alerta", - "alertRule_many": "", "alertRule_other": "{{count}} reglas de alerta", "dashboard_one": "{{count}} panel de control", - "dashboard_many": "", "dashboard_other": "{{count}} paneles de control", "folder_one": "{{count}} carpeta", - "folder_many": "", "folder_other": "{{count}} carpetas", "libraryPanel_one": "{{count}} panel de librería", - "libraryPanel_many": "", "libraryPanel_other": "{{count}} paneles de librería", "total_one": "{{count}} elemento", - "total_many": "", "total_other": "{{count}} elementos" }, "dashboards-tree": { @@ -81,6 +76,7 @@ "folder-picker": { "accessible-label": "Seleccionar carpeta: {{ label }} seleccionada actualmente", "button-label": "Seleccionar carpeta", + "clear-selection": "Eliminar selección", "empty-message": "No se ha encontrado ninguna carpeta", "error-title": "Error al cargar las carpetas", "search-placeholder": "Buscar carpetas", @@ -112,6 +108,9 @@ "dark-theme": "Oscuro", "light-theme": "Claro" }, + "empty-state": { + "title": "" + }, "search-box": { "placeholder": "Buscar o saltar a..." }, @@ -238,6 +237,10 @@ "visualization": "Visualización", "widget": "«Widget»" }, + "alert-rules-drawer": { + "redirect-link": "Lista en Alertas de Grafana", + "subtitle": "Reglas de alerta relacionadas con este tablero" + }, "empty": { "add-library-panel-body": "Añadir las visualizaciones que se comparten con otros tableros.", "add-library-panel-button": "Añadir panel de biblioteca", @@ -306,7 +309,7 @@ }, "toolbar": { "add": "Añadir", - "add-panel": "Añadir panel", + "alert-rules": "Reglas de alerta", "mark-favorite": "Marcar como favorito", "open-original": "Abrir el panel de control original", "playlist-next": "Ir al siguiente panel de control", @@ -316,6 +319,7 @@ "save": "Guardar panel de control", "settings": "Ajustes del panel de control", "share": "Compartir panel o panel de control", + "share-button": "Compartir", "unmark-favorite": "Deshacer marca como favorito" }, "validation": { @@ -365,6 +369,7 @@ "title-label": "Título" }, "json-editor": { + "apply-button": "Aplicar cambios", "save-button": "Guardar cambios", "subtitle": "El siguiente modelo JSON es la estructura de datos que define el tablero. Incluye ajustes del tablero, ajustes del panel, diseño, consultas, etc.", "title": "Modelo JSON" @@ -417,9 +422,6 @@ "label": "Añadir nueva fuente de datos" } }, - "drawer": { - "close": "" - }, "explore": { "add-to-dashboard": "Añadir al tablero", "rich-history": { @@ -538,13 +540,13 @@ }, "toolbar": { "aria-label": "Barra de herramientas de Explore", - "copy-link": "", - "copy-link-abs-time": "", - "copy-links-absolute-category": "", - "copy-links-normal-category": "", - "copy-shortened-link": "Copiar enlace acortado", - "copy-shortened-link-abs-time": "", - "copy-shortened-link-menu": "", + "copy-link": "Copiar URL", + "copy-link-abs-time": "Copiar URL absoluta", + "copy-links-absolute-category": "Enlaces URL de sincronización temporal (compartir con intervalo de tiempo intacto)", + "copy-links-normal-category": "Enlaces URL normales", + "copy-shortened-link": "Copiar URL acortada", + "copy-shortened-link-abs-time": "Copiar URL absoluta acortada", + "copy-shortened-link-menu": "Abrir menú de enlace acortado", "refresh-picker-cancel": "Cancelar", "refresh-picker-run": "Ejecutar consulta", "split-close": "Cerrar", @@ -559,6 +561,9 @@ "loading": "Cargando carpetas..." }, "grafana-ui": { + "drawer": { + "close": "Cerrar" + }, "modal": { "close-tooltip": "Cerrar" }, @@ -590,6 +595,7 @@ "shortcuts-description": { "change-theme": "Cambiar tema", "collapse-all-rows": "Contraer todas las filas", + "copy-time-range": "Copiar rango de tiempo", "dashboard-settings": "Ajustes del panel de control", "duplicate-panel": "Duplicar panel", "exit-edit/setting-views": "Salir de las opciones de edición/ajustes", @@ -603,6 +609,7 @@ "move-time-range-forward": "Mover el rango de tiempo hacia delante", "open-search": "Abrir búsqueda", "open-shared-modal": "Abrir el modo de panel compartido", + "paste-time-range": "Pegar rango de tiempo", "refresh-all-panels": "Actualizar todos los paneles", "remove-panel": "Eliminar panel", "save-dashboard": "Guardar panel de control", @@ -645,16 +652,15 @@ }, "library-panels": { "modal": { - "body_one": "Este panel se está utilizando en {{count}} tablero. Por favor, elija qué tablero quiere ver en el panel:", - "body_many": "", - "body_other": "Este panel se está utilizando en {{count}} paneles de control. Elija qué panel quiere ver en el panel de control:", "button-cancel": "<0>Cancelar", "button-view-panel1": "Ver panel en {{label}}...", "button-view-panel2": "Ver panel en el panel de control...", "panel-not-linked": "El panel no está vinculado a un panel de control. Añádalo a un panel de control y vuelva a intentarlo.", "select-no-options-message": "No se ha encontrado ningún tablero", "select-placeholder": "Empezar a escribir para buscar el panel de control", - "title": "Ver el panel en el tablero" + "title": "Ver el panel en el tablero", + "body_one": "Este panel se está utilizando en {{count}} tablero. Por favor, elija qué tablero quiere ver en el panel:", + "body_other": "Este panel se está utilizando en {{count}} paneles de control. Elija qué panel quiere ver en el panel de control:" }, "save": { "error": "Error al guardar el panel de la librería: «{{errorMsg}}»", @@ -667,6 +673,139 @@ "invalid-user-or-password": "Nombre de usuario o contraseña no válidos", "title": "Error al iniciar sesión", "unknown": "Se ha producido un error desconocido" + }, + "forgot-password": "", + "form": { + "password-label": "", + "password-required": "", + "submit-label": "", + "submit-loading-label": "", + "username-label": "", + "username-required": "" + }, + "services": { + "sing-in-with-prefix": "" + }, + "signup": { + "button-label": "", + "new-to-question": "" + } + }, + "migrate-to-cloud": { + "can-i-move": { + "body": "", + "link-title": "", + "title": "" + }, + "connect-modal": { + "body-cloud-stack": "", + "body-get-started": "", + "body-paste-stack": "", + "body-sign-up": "", + "body-token": "", + "body-token-field": "", + "body-token-field-placeholder": "", + "body-token-instructions": "", + "body-url-field": "", + "body-view-stacks": "", + "cancel": "", + "connect": "", + "connecting": "", + "stack-required-error": "", + "title": "", + "token-required-error": "" + }, + "cta": { + "button": "", + "header": "" + }, + "disconnect-modal": { + "body": "", + "cancel": "", + "disconnect": "", + "disconnecting": "", + "error": "", + "title": "" + }, + "get-started": { + "body": "", + "configure-pdc-link": "", + "link-title": "", + "step-1": "", + "step-2": "", + "step-3": "", + "step-4": "", + "step-5": "", + "title": "" + }, + "is-it-secure": { + "body": "", + "link-title": "", + "title": "" + }, + "migrate-to-this-stack": { + "body": "", + "link-title": "", + "title": "" + }, + "migration-token": { + "body": "", + "delete-button": "", + "delete-modal-body": "", + "delete-modal-cancel": "", + "delete-modal-confirm": "", + "delete-modal-deleting": "", + "delete-modal-title": "", + "generate-button": "", + "generate-button-loading": "", + "modal-close": "", + "modal-copy-and-close": "", + "modal-copy-button": "", + "modal-field-description": "", + "modal-field-label": "", + "modal-title": "", + "status": "", + "title": "" + }, + "pdc": { + "body": "", + "link-title": "", + "title": "" + }, + "pricing": { + "body": "", + "link-title": "", + "title": "" + }, + "resource-status": { + "error-details-button": "", + "failed": "", + "migrated": "", + "migrating": "", + "not-migrated": "", + "unknown": "" + }, + "resource-type": { + "dashboard": "", + "datasource": "", + "unknown": "" + }, + "resources": { + "disconnect": "" + }, + "token-status": { + "active": "", + "no-active": "" + }, + "what-is-cloud": { + "body": "", + "link-title": "", + "title": "" + }, + "why-host": { + "body": "", + "link-title": "", + "title": "" } }, "nav": { @@ -677,6 +816,9 @@ "subtitle": "Administrar la configuración de todo el servidor y el acceso a recursos como organizaciones, usuarios y licencias", "title": "Administrador del servidor" }, + "alert-list-legacy": { + "title": "Reglas de alerta" + }, "alerting": { "subtitle": "Conozca los problemas de sus sistemas justo después de que se produzcan", "title": "Alertas" @@ -713,6 +855,10 @@ "subtitle": "Detener notificaciones de una o más reglas de alerta", "title": "Silencios" }, + "alerting-upgrade": { + "subtitle": "Actualiza tus alertas y canales de notificación existentes a la nueva alerta de Grafana", + "title": "Actualización de alertas" + }, "alerts-and-incidents": { "subtitle": "Aplicaciones de gestión de alertas e incidentes", "title": "Alertas e IRM" @@ -731,6 +877,9 @@ "authentication": { "title": "Autenticación" }, + "collector": { + "title": "Colector" + }, "config": { "title": "Administración" }, @@ -791,7 +940,7 @@ "title": "Explorar" }, "frontend": { - "subtitle": "", + "subtitle": "Obtén información de seguimiento real del usuario", "title": "Interfaz" }, "frontend-app": { @@ -822,14 +971,14 @@ "title": "Incidentes" }, "infrastructure": { - "subtitle": "", - "title": "" + "subtitle": "Mantente al tanto de la salud de tu infraestructura", + "title": "Infraestructura" }, "integrations": { "title": "Integraciones" }, "k6": { - "title": "" + "title": "Rendimiento" }, "kubernetes": { "title": "Kubernetes" @@ -844,6 +993,10 @@ "manage-folder": { "subtitle": "Gestionar paneles de control de carpetas y permisos" }, + "migrate-to-cloud": { + "subtitle": "", + "title": "" + }, "monitoring": { "subtitle": "Aplicaciones de supervisión e infraestructura", "title": "Observabilidad" @@ -867,9 +1020,6 @@ "subtitle": "Gestionar las preferencias en una organización", "title": "Preferencias por defecto" }, - "performance-testing": { - "title": "Pruebas de rendimiento" - }, "playlists": { "subtitle": "Grupos de paneles de control que se muestran en una secuencia", "title": "Listas de reproducción" @@ -878,6 +1028,10 @@ "subtitle": "Amplíe la experiencia de Grafana con los complementos", "title": "Complementos" }, + "private-data-source-connections": { + "subtitle": "Consulta los datos existentes en una red segura sin abrirla al tráfico entrante de Grafana Cloud. Tienes más información en nuestros documentos.", + "title": "Conexión a la fuente de datos privada" + }, "profile/notifications": { "title": "Historial de notificaciones" }, @@ -951,8 +1105,8 @@ "title": "Equipos" }, "testing-and-synthetics": { - "subtitle": "", - "title": "" + "subtitle": "Optimiza el rendimiento con información de seguimiento k6 y sintética", + "title": "Pruebas y síntesis" }, "upgrading": { "title": "Estadísticas y licencia" @@ -969,6 +1123,7 @@ "megamenu": { "close": "Cerrar menú", "dock": "Anclar el menú", + "list-label": "Navegación", "undock": "Desanclar el menú" }, "toolbar": { @@ -980,7 +1135,7 @@ }, "news": { "drawer": { - "close": "" + "close": "Cerrar Drawer" }, "title": "Últimas entradas del blog" }, @@ -1067,7 +1222,122 @@ "new-password-same-as-old": "La nueva contraseña no puede ser la misma que la antigua.", "old-password-label": "Contraseña antigua", "old-password-required": "Se requiere la contraseña antigua", - "passwords-must-match": "Las contraseñas deben coincidir" + "passwords-must-match": "Las contraseñas deben coincidir", + "strong-password-validation-register": "" + } + }, + "public-dashboard": { + "acknowledgment-checkboxes": { + "ack-title": "Antes de hacer público el tablero, ten en cuenta lo siguiente:", + "data-src-ack-desc": "Actualmente, la publicación solo funciona con un subconjunto de fuentes de datos*", + "data-src-ack-tooltip": "Más información sobre las fuentes de datos públicas", + "public-ack-desc": "Tu tablero será totalmente público*", + "public-ack-tooltip": "Más información sobre los tableros públicos", + "usage-ack-desc": "Convertir un tablero en público hará que las consultas se ejecuten cada vez que se visualice, lo que puede aumentar los costes*", + "usage-ack-desc-tooltip": "Más información sobre la caché de consultas" + }, + "config": { + "can-view-dashboard-radio-button-label": "Puedes ver el tablero", + "copy-button": "Copiar", + "dashboard-url-field-label": "URL del tablero", + "email-share-type-option-label": "Solo personas especificadas", + "pause-sharing-dashboard-label": "Pausar el panel compartido", + "public-share-type-option-label": "Cualquiera con un enlace", + "revoke-public-URL-button": "Revocar la URL pública", + "revoke-public-URL-button-title": "Revocar URL pública", + "settings-title": "Configuración" + }, + "create-page": { + "generate-public-url-button": "Generar URL pública", + "unsupported-features-desc": "Actualmente, no son compatibles las variables de plantillas o las fuentes de datos de la interfaz", + "welcome-title": "¡Bienvenidos al tablero público!" + }, + "delete-modal": { + "revoke-nonorphaned-body-text": "¿Seguro que quieres revocar esta URL? El tablero dejará de ser público.", + "revoke-orphaned-body-text": "El tablero público huérfano ya no será público.", + "revoke-title": "Revocar URL pública" + }, + "email-sharing": { + "input-invalid-email-text": "Correo no válido", + "input-required-email-text": "El correo electrónico es obligatorio", + "invite-button": "Invitar", + "invite-field-desc": "Invitar a otras personas por correo electrónico", + "invite-field-label": "Invitar", + "resend-button": "Reenviar", + "resend-button-title": "Reenviar", + "revoke-button": "Revocar", + "revoke-button-title": "Revocar" + }, + "modal-alerts": { + "no-upsert-perm-alert-desc": "Ponte en contacto con tu administrador para obtener permiso para {{mode}} los tableros públicos", + "no-upsert-perm-alert-title": "No tienes permiso para {{ mode }} un tablero público", + "save-dashboard-changes-alert-title": "Guarda los cambios realizados en el tablero antes de actualizar la configuración pública", + "unsupport-data-source-alert-readmore-link": "Leer más sobre las fuentes de datos compatibles", + "unsupported-data-source-alert-desc": "Hay fuentes de datos en este tablero que no son compatibles con los tableros públicos. Los paneles que utilizan estas fuentes de datos puede que no funcionen correctamente: {{unsupportedDataSources}}.", + "unsupported-data-source-alert-title": "Fuentes de datos no compatibles", + "unsupported-template-variable-alert-desc": "Este tablero público puede que no funcione, ya que utiliza variables de plantillas", + "unsupported-template-variable-alert-title": "Las variables de plantillas no son compatibles" + }, + "settings-bar-header": { + "collapse-settings-tooltip": "Contraer ajustes", + "expand-settings-tooltip": "Expandir ajustes" + }, + "settings-configuration": { + "default-time-range-label": "Rango de tiempo predeterminado", + "default-time-range-label-desc": "El tablero público utiliza la configuración predeterminada del rango de tiempo del tablero", + "show-annotations-label": "Mostrar anotaciones", + "show-annotations-label-desc": "Mostrar anotaciones en el tablero público", + "time-range-picker-label": "Selector de rango de tiempo activado", + "time-range-picker-label-desc": "Permitir que los usuarios cambien el rango de tiempo" + }, + "settings-summary": { + "annotations-hide-text": "Anotaciones = ocultar", + "annotations-show-text": "Anotaciones = mostrar", + "time-range-picker-disabled-text": "Selector de rango de tiempo = desactivado", + "time-range-picker-enabled-text": "Selector de rango de tiempo = activado", + "time-range-text": "Rango de tiempo = " + } + }, + "public-dashboard-list": { + "button": { + "config-button-tooltip": "Configurar tablero público", + "revoke-button-text": "Revocar URL pública", + "revoke-button-tooltip": "Revocar URL del tablero público", + "view-button-tooltip": "Ver tablero público" + }, + "dashboard-title": { + "orphaned-title": "<0>Tablero público huérfano", + "orphaned-tooltip": "El tablero vinculado ya se ha eliminado" + }, + "toggle": { + "pause-sharing-toggle-text": "Pausar el uso compartido" + } + }, + "public-dashboard-users-access-list": { + "dashboard-modal": { + "loading-text": "Cargando...", + "open-dashboard-list-text": "Abrir lista de tableros", + "public-dashboard-link": "URL del tablero público", + "public-dashboard-setting": "Ajustes del tablero público" + }, + "delete-user-modal": { + "delete-user-button-text": "Eliminar usuario", + "delete-user-cancel-button": "Cancelar", + "delete-user-revoke-access-button": "Revocar acceso", + "revoke-access-title": "Revocar acceso", + "revoke-user-access-modal-desc-line1": "¿Seguro que quieres revocar el acceso para {{email}}?", + "revoke-user-access-modal-desc-line2": "Esta acción revocará inmediatamente el acceso de {{email}} a todos los tableros públicos." + }, + "modal": { + "dashboard-modal-title": "Paneles de control públicos" + }, + "table-header": { + "activated-label": "Activado", + "activated-tooltip": "El usuario más antiguo ha estado activo en un tablero de control", + "email-label": "Correo electrónico", + "last-active-label": "Último activo", + "origin-label": "Origen", + "role-label": "Rol" } }, "query-operation": { @@ -1105,6 +1375,12 @@ "turned-off": "Actualización automática desactivada" } }, + "return-to-previous": { + "button": { + "label": "Volver a {{title}}" + }, + "dismissable-button": "Cerrar" + }, "search": { "actions": { "include-panels": "Incluir paneles", @@ -1182,10 +1458,10 @@ "expire-day": "1 día", "expire-hour": "1 hora", "expire-never": "Nunca", - "expire-week": "7 días", + "expire-week": "", "info-text-1": "Una instantánea es una forma inmediata de compartir un panel interactivo públicamente. Cuando se crean, eliminamos datos confidenciales como consultas (métricas, plantilla y anotación) y enlaces de panel, dejando solo los datos de métricas visibles y los nombres de serie incrustados en el panel.", "info-text-2": "Tenga en cuenta que su instantánea <1>puede ser vista por cualquiera que tenga el enlace y pueda acceder a la URL. Comparta sus instantáneas con cautela.", - "local-button": "Instantánea local", + "local-button": "", "mistake-message": "¿Ha cometido un error? ", "name": "Nombre de la instantánea", "timeout": "Tiempo de espera (segundos)", @@ -1198,7 +1474,8 @@ "library-panel": "Panel de librería", "link": "Enlace", "panel-embed": "Incrustar", - "public-dashboard": "Tablero público", + "public-dashboard": "", + "public-dashboard-title": "Tablero público", "snapshot": "Instantánea" }, "theme-picker": { @@ -1269,7 +1546,7 @@ "calendar": { "apply-button": "Aplicar intervalo de tiempo", "cancel-button": "Cancelar", - "close": "", + "close": "Cerrar calendario", "select-time": "Seleccionar un intervalo de tiempo" }, "content": { @@ -1277,6 +1554,13 @@ "empty-recent-list-info": "Parece que no ha utilizado antes este selector de tiempo. En cuanto introduzca algún intervalo de tiempo, los intervalos que haya usado recientemente aparecerán aquí.", "filter-placeholder": "Buscar intervalos rápidos" }, + "copy-paste": { + "copy-success-message": "Rango de tiempo copiado al portapapeles", + "default-error-message": "{{error}} no es un rango de tiempo válido", + "default-error-title": "Rango de tiempo no válido", + "tooltip-copy": "Copiar rango de tiempo al portapapeles", + "tooltip-paste": "Pegar rango de tiempo" + }, "footer": { "change-settings-button": "Cambiar ajustes de tiempo", "fiscal-year-option": "Ejercicio fiscal", @@ -1289,7 +1573,7 @@ "default-error": "Introducir una fecha anterior o «ahora»", "fiscal-year": "Ejercicio fiscal", "from-input": "Desde", - "open-input-calendar": "", + "open-input-calendar": "Abrir calendario", "range-error": "«Desde» no puede ser posterior a «hasta»", "to-input": "Hasta" }, @@ -1314,8 +1598,8 @@ }, "transformations": { "empty": { - "add-transformation-body": "", - "add-transformation-header": "" + "add-transformation-body": "Las transformaciones permiten cambiar los datos de varias maneras antes de que se muestre su visualización.<1>Aquí se incluyen acciones como unir datos, renombrar campos, hacer cálculos, dar formato a los datos para mostrarlos, etc.", + "add-transformation-header": "Empezar a transformar datos" } }, "user-orgs": { @@ -1347,6 +1631,11 @@ "user-sessions": { "loading": "Cargando sesiones..." }, + "users-access-list": { + "tabs": { + "public-dashboard-users-tab-title": "Usuarios del tablero público" + } + }, "variable": { "adhoc": { "placeholder": "Seleccionar valor" @@ -1364,4 +1653,4 @@ "placeholder": "Introducir el valor de la variable" } } -} +} \ No newline at end of file diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index c667fe92e901b..e8a94b891d7fa 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -1,5 +1,5 @@ { - "_comment": "Ce fichier est la source de référence pour les chaînes en anglais. Modifiez-le pour changer les pluriels et les autres expressions pour l'interface utilisateur.", + "_comment": "Ce fichier est la source de vérité pour les chaînes anglaises. Modifiez-le dans la base de code pour adapter les pluriels et les autres phrases pour l'interface utilisateur.", "access-control": { "add-permission": { "role-label": "Rôle", @@ -26,7 +26,7 @@ } }, "bouncing-loader": { - "label": "" + "label": "Chargement" }, "browse-dashboards": { "action": { @@ -48,21 +48,16 @@ "new-folder-name-required-phrase": "Vous devez saisir le nom du dossier." }, "counts": { - "alertRule_one": "{{count}}\u00a0règle d'alerte", - "alertRule_many": "", - "alertRule_other": "{{count}}\u00a0règles d'alerte", - "dashboard_one": "{{count}}\u00a0tableau de bord", - "dashboard_many": "", - "dashboard_other": "{{count}}\u00a0tableaux de bord", - "folder_one": "{{count}}\u00a0dossier", - "folder_many": "", - "folder_other": "{{count}}\u00a0dossiers", - "libraryPanel_one": "{{count}}\u00a0panneau Bibliothèque", - "libraryPanel_many": "", - "libraryPanel_other": "{{count}}\u00a0panneaux Bibliothèque", - "total_one": "{{count}}\u00a0objet", - "total_many": "", - "total_other": "{{count}}\u00a0objets" + "alertRule_one": "{{count}} règle d'alerte", + "alertRule_other": "{{count}} règles d'alerte", + "dashboard_one": "{{count}} tableau de bord", + "dashboard_other": "{{count}} tableaux de bord", + "folder_one": "{{count}} dossier", + "folder_other": "{{count}} dossiers", + "libraryPanel_one": "{{count}} panneau Bibliothèque", + "libraryPanel_other": "{{count}} panneaux Bibliothèque", + "total_one": "{{count}} objet", + "total_other": "{{count}} objets" }, "dashboards-tree": { "collapse-folder-button": "Réduire le dossier {{title}}", @@ -81,6 +76,7 @@ "folder-picker": { "accessible-label": "Sélectionnez un dossier : {{ label }} est sélectionné actuellement", "button-label": "Sélectionner un dossier", + "clear-selection": "Effacer la sélection", "empty-message": "Aucun dossier trouvé", "error-title": "Impossible de charger les dossiers", "search-placeholder": "Rechercher dans les dossiers", @@ -112,6 +108,9 @@ "dark-theme": "Sombre", "light-theme": "Clair" }, + "empty-state": { + "title": "Aucun résultat trouvé" + }, "search-box": { "placeholder": "Rechercher ou aller à..." }, @@ -238,6 +237,10 @@ "visualization": "Visualisation", "widget": "Widget" }, + "alert-rules-drawer": { + "redirect-link": "Liste dans Alertes Grafana", + "subtitle": "Règles d'alerte liées à ce tableau de bord" + }, "empty": { "add-library-panel-body": "Ajoutez des visualisations partagées avec d'autres tableaux de bord.", "add-library-panel-button": "Ajouter un panneau Bibliothèque", @@ -260,7 +263,7 @@ "query-tab": "Requête", "stats-tab": "Statistiques", "subtitle": "{{queryCount}} requêtes avec un délai total de requête de {{formatted}}", - "title": "Inspecter\u00a0: {{panelTitle}}" + "title": "Inspecter : {{panelTitle}}" }, "inspect-data": { "data-options": "Options de données", @@ -290,7 +293,7 @@ "panel-json-description": "Le modèle enregistré dans le tableau de bord JSON qui configure comment tout fonctionne.", "panel-json-label": "Panneau JSON", "select-source": "Sélectionner la source", - "unknown": "Objet inconnu\u00a0: {{show}}" + "unknown": "Objet inconnu : {{show}}" }, "inspect-meta": { "no-inspector": "Pas d'inspecteur de métadonnées" @@ -306,7 +309,7 @@ }, "toolbar": { "add": "Ajouter", - "add-panel": "Ajouter un panneau", + "alert-rules": "Règles d'alerte", "mark-favorite": "Marquer comme favori", "open-original": "Ouvrir le tableau de bord d'origine", "playlist-next": "Accéder au tableau de bord suivant", @@ -316,6 +319,7 @@ "save": "Enregistrer le tableau de bord", "settings": "Paramètres du tableau de bord", "share": "Partager le tableau de bord ou le panneau", + "share-button": "Partager", "unmark-favorite": "Supprimer des favoris" }, "validation": { @@ -365,6 +369,7 @@ "title-label": "Titre" }, "json-editor": { + "apply-button": "Appliquer les modifications", "save-button": "Enregistrer les modifications", "subtitle": "Le modèle JSON ci-dessous est la structure de données qui définit le tableau de bord. Il inclut les paramètres du tableau de bord, les paramètres du panneau, la mise en page, les requêtes, etc.", "title": "Modèle JSON" @@ -382,7 +387,7 @@ "hide-time-picker": "Cacher le sélecteur de temps", "now-delay-description": "Exclure les données récentes qui peuvent être incomplètes.", "now-delay-label": "Délai actuel", - "refresh-live-dashboards-description": "Redessiner en continu les panneaux où la plage de temps indique «\u00a0maintenant\u00a0»", + "refresh-live-dashboards-description": "Redessiner en continu les panneaux où la plage de temps indique « maintenant »", "refresh-live-dashboards-label": "Actualiser les tableaux de bord en direct", "time-options-label": "Options de temps", "time-zone-label": "Fuseau horaire", @@ -417,9 +422,6 @@ "label": "Ajouter une nouvelle source de données" } }, - "drawer": { - "close": "" - }, "explore": { "add-to-dashboard": "Ajouter au tableau de bord", "rich-history": { @@ -445,7 +447,7 @@ "delete-query-confirmation-title": "Supprimer", "delete-query-title": "Supprimer la requête", "delete-query-tooltip": "Supprimer la requête", - "delete-starred-query-confirmation-text": "Souhaitez-vous vraiment supprimer définitivement votre requête favorite\u00a0?", + "delete-starred-query-confirmation-text": "Souhaitez-vous vraiment supprimer définitivement votre requête favorite ?", "edit-comment-tooltip": "Modifier le commentaire", "loading-text": "chargement...", "optional-description": "Une description facultative de l'action de la requête.", @@ -489,7 +491,7 @@ "delete-confirm-text": "Souhaitez-vous vraiment supprimer définitivement votre historique des requêtes ?", "delete-title": "Supprimer", "history-time-span": "Période de l'historique", - "history-time-span-description": "Sélectionnez la période de temps pendant laquelle Grafana enregistrera votre historique des requêtes. Un maximum de {{MAX_HISTORY_ITEMS}}\u00a0entrées pourront être sauvegarées.", + "history-time-span-description": "Sélectionnez la période de temps pendant laquelle Grafana enregistrera votre historique des requêtes. Un maximum de {{MAX_HISTORY_ITEMS}} entrées pourront être sauvegarées.", "only-show-active-datasource": "Afficher uniquement les requêtes pour la source de données actuellement active dans Explorer", "query-history-deleted": "L'historique des requêtes a été supprimé", "retention-period": { @@ -538,13 +540,13 @@ }, "toolbar": { "aria-label": "Explorer la barre d'outils", - "copy-link": "", - "copy-link-abs-time": "", - "copy-links-absolute-category": "", - "copy-links-normal-category": "", - "copy-shortened-link": "Copier le lien raccourci", - "copy-shortened-link-abs-time": "", - "copy-shortened-link-menu": "", + "copy-link": "Copier l'URL", + "copy-link-abs-time": "Copier l'URL absolue", + "copy-links-absolute-category": "Liens URL de synchronisation temporelle (partager avec intervalle de temps intact)", + "copy-links-normal-category": "Liens URL normaux", + "copy-shortened-link": "Copier l'URL abrégée", + "copy-shortened-link-abs-time": "Copier l'URL abrégée absolue", + "copy-shortened-link-menu": "Ouvrir le lien de menu raccourci", "refresh-picker-cancel": "Annuler", "refresh-picker-run": "Exécuter la requête", "split-close": "Fermer", @@ -559,6 +561,9 @@ "loading": "Chargement des dossiers..." }, "grafana-ui": { + "drawer": { + "close": "Fermer" + }, "modal": { "close-tooltip": "Fermer" }, @@ -590,6 +595,7 @@ "shortcuts-description": { "change-theme": "Modifier le thème", "collapse-all-rows": "Réduire toutes les lignes", + "copy-time-range": "Copier l'intervalle de temps", "dashboard-settings": "Paramètres du tableau de bord", "duplicate-panel": "Dupliquer le panneau", "exit-edit/setting-views": "Quitter les affichages de modification/configuration", @@ -603,6 +609,7 @@ "move-time-range-forward": "Avancer la plage de temps", "open-search": "Ouvrir la recherche", "open-shared-modal": "Ouvrir le panneau de partage modal", + "paste-time-range": "Coller l'intervalle de temps", "refresh-all-panels": "Actualiser tous les panneaux", "remove-panel": "Retirer le panneau", "save-dashboard": "Enregistrer le tableau de bord", @@ -645,19 +652,18 @@ }, "library-panels": { "modal": { - "body_one": "Ce panneau est utilisé dans {{count}} tableau de bord. Choisissez dans quel tableau de bord le panneau sera affiché :", - "body_many": "", - "body_other": "Ce panneau est utilisé dans {{count}} tableaux de bord. Choisissez dans quel tableau de bord le panneau sera affiché :", "button-cancel": "<0>Annuler", "button-view-panel1": "Voir le panneau dans {{label}}...", "button-view-panel2": "Voir le panneau dans le tableau de bord...", "panel-not-linked": "Le panneau n'est pas lié à un tableau de bord. Ajoutez le panneau à un tableau de bord et réessayez.", "select-no-options-message": "Aucun tableau de bord trouvé", "select-placeholder": "Commencez à taper pour rechercher le tableau de bord", - "title": "Voir le panneau dans le tableau de bord" + "title": "Voir le panneau dans le tableau de bord", + "body_one": "Ce panneau est utilisé dans {{count}} tableau de bord. Choisissez dans quel tableau de bord le panneau sera affiché :", + "body_other": "Ce panneau est utilisé dans {{count}} tableaux de bord. Choisissez dans quel tableau de bord le panneau sera affiché :" }, "save": { - "error": "Erreur lors de l'enregistrement du panneau de bibliothèque\u00a0: \"{{errorMsg}}\"", + "error": "Erreur lors de l'enregistrement du panneau de bibliothèque : \"{{errorMsg}}\"", "success": "Panneau de bibliothèque enregistré" } }, @@ -667,6 +673,139 @@ "invalid-user-or-password": "Nom d'utilisateur ou mot de passe invalide", "title": "Échec de la connexion", "unknown": "Une erreur inconnue est survenue" + }, + "forgot-password": "Vous avez oublié votre mot de passe ?", + "form": { + "password-label": "Mot de passe", + "password-required": "Vous devez renseigner le mot de passe", + "submit-label": "Connexion", + "submit-loading-label": "Connexion…", + "username-label": "E-mail ou nom d'utilisateur", + "username-required": "Vous devez renseigner l'e-mail ou le nom d'utilisateur" + }, + "services": { + "sing-in-with-prefix": "Se connecter avec {{serviceName}}" + }, + "signup": { + "button-label": "Inscription", + "new-to-question": "Nouveau sur Grafana ?" + } + }, + "migrate-to-cloud": { + "can-i-move": { + "body": "Une fois que vous aurez connecté cette installation à une pile cloud, vous pourrez importer les sources de données et les tableaux de bord.", + "link-title": "En savoir plus sur la migration des autres paramètres", + "title": "Puis-je déplacer cette installation vers Grafana Cloud ?" + }, + "connect-modal": { + "body-cloud-stack": "Vous aurez également besoin d'une pile cloud. Si vous venez de vous inscrire, nous créerons automatiquement votre première pile. Si vous avez déjà un compte, vous devez sélectionner ou créer une pile.", + "body-get-started": "Pour commencer, vous aurez besoin d'un compte Grafana.com.", + "body-paste-stack": "Lorsque vous aurez choisi une pile, collez l'URL ci-dessous.", + "body-sign-up": "Créez un compte Grafana.com", + "body-token": "Votre installation Grafana autogérée a besoin d'un accès spécial pour migrer du contenu de manière sécurisée. Vous devrez créer un jeton de migration sur la pile cloud que vous avez choisie.", + "body-token-field": "Jeton de migration", + "body-token-field-placeholder": "Coller le jeton ici", + "body-token-instructions": "Connectez-vous à votre pile cloud et accédez à Administration > Général > Migrer vers Grafana Cloud. Créez un jeton de migration sur cet écran et collez-le ici.", + "body-url-field": "URL de la pile cloud", + "body-view-stacks": "Voir mes piles cloud", + "cancel": "Annuler", + "connect": "Se connecter à cette pile", + "connecting": "Connexion à cette pile…", + "stack-required-error": "Vous devez renseigner l'URL de la pile", + "title": "Se connecter à une pile cloud", + "token-required-error": "Vous devez renseigner le jeton de migration" + }, + "cta": { + "button": "Migrer cette instance vers le cloud", + "header": "Laissez-nous gérer votre pile Grafana" + }, + "disconnect-modal": { + "body": "Cette action supprimera le jeton de migration de cette installation. Si vous souhaitez importer plus de ressources dans le futur, vous devrez entrer un nouveau jeton de migration.", + "cancel": "Annuler", + "disconnect": "Déconnecter", + "disconnecting": "Déconnexion…", + "error": "La déconnexion a échoué", + "title": "Se déconnecter de la pile cloud" + }, + "get-started": { + "body": "Le processus de migration doit être démarré à partir de votre instance Grafana autogérée.", + "configure-pdc-link": "Configurer la PDC pour cette pile", + "link-title": "En savoir plus sur la connexion privée aux sources de données", + "step-1": "Connectez-vous à votre instance autogérée et accédez à Administration > Général > Migrer vers Grafana Cloud.", + "step-2": "Sélectionnez Migrer cette instance vers le cloud.", + "step-3": "Le système vous demandera un jeton de migration. Générez-en un à partir de cet écran.", + "step-4": "Dans votre instance autogérée, sélectionnez Importer tout pour importer les sources de données et les tableaux de bord vers cette pile cloud.", + "step-5": "Si certaines de vos sources de données ne fonctionnent pas sur le réseau Internet public, vous devez installer une connexion privée aux sources de données dans votre environnement autogéré.", + "title": "Comment commencer" + }, + "is-it-secure": { + "body": "Grafana Labs s'engage à appliquer les normes les plus strictes en matière de confidentialité et de sécurité des données. En utilisant des technologies et des procédures de sécurité conformes aux normes du secteur, nous contribuons à protéger les données de nos clients contre l'accès, l'utilisation ou la divulgation non autorisés.", + "link-title": "Centre de confiance Grafana Labs", + "title": "La solution est-elle sécurisée ?" + }, + "migrate-to-this-stack": { + "body": "Certaines configurations de votre instance Grafana autogérée peuvent être copiées automatiquement dans cette pile cloud.", + "link-title": "Voir le guide de migration complet", + "title": "Migrer la configuration vers cette pile" + }, + "migration-token": { + "body": "Votre instance Grafana autogérée aura besoin d'un jeton d'authentification spécial pour se connecter de manière sécurisée à cette pile cloud.", + "delete-button": "Supprimer ce jeton de migration", + "delete-modal-body": "Si vous avez déjà utilisé ce jeton avec une installation autogérée, celle-ci ne pourra plus importer de contenu.", + "delete-modal-cancel": "Annuler", + "delete-modal-confirm": "Supprimer", + "delete-modal-deleting": "Suppression...", + "delete-modal-title": "Supprimer le jeton de migration", + "generate-button": "Générer un jeton de migration", + "generate-button-loading": "Génération d'un jeton de migration…", + "modal-close": "Fermer", + "modal-copy-and-close": "Copier dans le presse-papiers et fermer", + "modal-copy-button": "Copier dans le presse-papiers", + "modal-field-description": "Copiez le jeton maintenant, car vous ne pourrez plus le revoir. Si vous perdez un jeton, vous devrez 'en créer un nouveau.", + "modal-field-label": "Jeton", + "modal-title": "Jeton de migration créé", + "status": "Statut actuel : <2>", + "title": "Jeton de migration" + }, + "pdc": { + "body": "L'exposition de vos sources de données sur Internet peut compromettre la sécurité. La connexion privée aux sources de données (PDC) permet à Grafana Cloud d'accéder à vos sources de données existantes en passant par un tunnel réseau sécurisé.", + "link-title": "En savoir plus sur la PDC", + "title": "Toutes mes sources de données ne sont pas sur le réseau Internet public" + }, + "pricing": { + "body": "Grafana Cloud propose une formule gratuite généreuse et un essai illimité de 14 jours. Après votre période d'essai, nous vous facturerons en fonction de votre utilisation dépassant les limites de la formule gratuite.", + "link-title": "Tarifs de Grafana Cloud", + "title": "Combien ça coûte ?" + }, + "resource-status": { + "error-details-button": "Détails", + "failed": "Erreur", + "migrated": "Importé dans le cloud", + "migrating": "Importation…", + "not-migrated": "Pas encore importé", + "unknown": "Inconnu" + }, + "resource-type": { + "dashboard": "Tableau de bord", + "datasource": "Source de données", + "unknown": "Inconnu" + }, + "resources": { + "disconnect": "Déconnecter" + }, + "token-status": { + "active": "Jeton créé et actif", + "no-active": "Aucun jeton actif" + }, + "what-is-cloud": { + "body": "Grafana cloud est une plateforme d'observabilité hébergée dans le cloud et entièrement gérée. Elle est idéale pour les environnements natifs du cloud. Elle offre tout ce que vous aimez dans Grafana sans les frais d'entretien, de mise à niveau et de support qu'implique une installation.", + "link-title": "En savoir plus sur les fonctionnalités cloud", + "title": "Qu'est-ce que Grafana Cloud ?" + }, + "why-host": { + "body": "En plus de la commodité de l'hébergement géré, Grafana Cloud comprend de nombreuses fonctionnalités exclusives au cloud comme les SLO, la gestion des incidents, l'apprentissage automatique et de puissantes intégrations d'observabilité.", + "link-title": "Vous avez d'autres questions ? Posez-les à un expert", + "title": "Pourquoi héberger avec Grafana ?" } }, "nav": { @@ -677,6 +816,9 @@ "subtitle": "Gérer les paramètres à l'échelle du serveur et l'accès aux ressources telles que les organisations, les utilisateurs et les licences", "title": "Administrateur de serveur" }, + "alert-list-legacy": { + "title": "Règles d'alerte" + }, "alerting": { "subtitle": "En savoir plus sur les problèmes dans vos systèmes quelques instants après qu'ils se produisent", "title": "Alertes" @@ -713,6 +855,10 @@ "subtitle": "Arrêter les notifications résultant d'une ou plusieurs règles d'alerte", "title": "Silences" }, + "alerting-upgrade": { + "subtitle": "Mettez à niveau vos alertes existantes et vos canaux de notification vers le nouveau système d'alerte Grafana", + "title": "Mise à niveau des alertes" + }, "alerts-and-incidents": { "subtitle": "Applications de gestion des incidents et des alertes", "title": "Alertes et IRM" @@ -731,6 +877,9 @@ "authentication": { "title": "Authentification" }, + "collector": { + "title": "Collecteur" + }, "config": { "title": "Administration" }, @@ -791,7 +940,7 @@ "title": "Explorer" }, "frontend": { - "subtitle": "", + "subtitle": "Obtenez des analyses de suivi des utilisateurs réels", "title": "Frontend" }, "frontend-app": { @@ -822,14 +971,14 @@ "title": "Incidents" }, "infrastructure": { - "subtitle": "", - "title": "" + "subtitle": "Connaissez l'état de santé de votre infrastructure", + "title": "Infrastructure" }, "integrations": { "title": "Intégrations" }, "k6": { - "title": "" + "title": "Performance" }, "kubernetes": { "title": "Kubernetes" @@ -844,6 +993,10 @@ "manage-folder": { "subtitle": "Gérer les tableaux de bord et les autorisations des dossiers" }, + "migrate-to-cloud": { + "subtitle": "Copier la configuration de votre installation autogérée vers une pile cloud", + "title": "Migrer vers Grafana Cloud" + }, "monitoring": { "subtitle": "Applications de suivi et d'infrastructure", "title": "Observabilité" @@ -867,9 +1020,6 @@ "subtitle": "Gérer les préférences au sein d'une organisation", "title": "Préférences par défaut" }, - "performance-testing": { - "title": "Tests de performance" - }, "playlists": { "subtitle": "Groupes de tableaux de bord affichés dans une séquence", "title": "Listes de lecture" @@ -878,6 +1028,10 @@ "subtitle": "Prolonger l'expérience Grafana avec des plug-ins", "title": "Plug-ins" }, + "private-data-source-connections": { + "subtitle": "Obtenez des données qui vivent au sein d'un réseau sécurisé sans ouvrir le réseau au trafic entrant à partir de Grafana Cloud. En savoir plus dans notre documentation.", + "title": "Connexion à la source de données privée" + }, "profile/notifications": { "title": "Historique des notifications" }, @@ -951,8 +1105,8 @@ "title": "Équipes" }, "testing-and-synthetics": { - "subtitle": "", - "title": "" + "subtitle": "Optimisez les performances grâce aux analyses de la surveillance synthétique et k6", + "title": "Tests et synthèses" }, "upgrading": { "title": "Statistiques et licence" @@ -969,7 +1123,8 @@ "megamenu": { "close": "Fermer le menu", "dock": "Ancrer le menu", - "undock": "" + "list-label": "Navigation", + "undock": "Ancrer le menu" }, "toolbar": { "close-menu": "Fermer le menu", @@ -980,7 +1135,7 @@ }, "news": { "drawer": { - "close": "" + "close": "Fermer le tiroir" }, "title": "Dernières nouvelles sur le blog" }, @@ -1044,7 +1199,7 @@ "title": "Nouvelle playlist" }, "delete-modal": { - "body": "Souhaitez-vous vraiment supprimer la playlist {{name}}\u00a0?", + "body": "Souhaitez-vous vraiment supprimer la playlist {{name}} ?", "confirm-text": "Supprimer" }, "empty": { @@ -1067,7 +1222,122 @@ "new-password-same-as-old": "Le nouveau mot de passe ne peut pas être le même que l'ancien.", "old-password-label": "Ancien mot de passe", "old-password-required": "Vous devez saisir l'ancien mot de passe", - "passwords-must-match": "Les mots de passe doivent être identiques" + "passwords-must-match": "Les mots de passe doivent être identiques", + "strong-password-validation-register": "Selon notre politique, votre mot de passe n'est pas suffisamment sécurisé" + } + }, + "public-dashboard": { + "acknowledgment-checkboxes": { + "ack-title": "Avant de rendre le tableau de bord public, acceptez ce qui suit :", + "data-src-ack-desc": "La publication ne fonctionne actuellement qu'avec un sous-ensemble de sources de données*", + "data-src-ack-tooltip": "En savoir plus sur les sources de données publiques", + "public-ack-desc": "Votre tableau de bord sera entièrement public*", + "public-ack-tooltip": "En savoir plus sur les tableaux de bord publics", + "usage-ack-desc": "Si vous rendez le tableau de bord public, les requêtes seront exécutées à chaque fois qu'il sera affiché, ce qui peut augmenter les coûts*", + "usage-ack-desc-tooltip": "En savoir plus sur la mise en cache des requêtes" + }, + "config": { + "can-view-dashboard-radio-button-label": "Peut voir le tableau de bord", + "copy-button": "Copier", + "dashboard-url-field-label": "URL du tableau de bord", + "email-share-type-option-label": "Uniquement les personnes spécifiées", + "pause-sharing-dashboard-label": "Interrompre le partage du tableau de bord", + "public-share-type-option-label": "Toute personne ayant un lien", + "revoke-public-URL-button": "Désactiver l'URL publique", + "revoke-public-URL-button-title": "Désactiver l'URL publique", + "settings-title": "Paramètres" + }, + "create-page": { + "generate-public-url-button": "Générer une URL publique", + "unsupported-features-desc": "Actuellement, nous ne prenons pas en charge les variables de modèle ou les données provenant des utilisateurs", + "welcome-title": "Bienvenue dans les tableaux de bord publics !" + }, + "delete-modal": { + "revoke-nonorphaned-body-text": "Souhaitez-vous vraiment désactiver cette URL ? Le tableau de bord ne sera plus public.", + "revoke-orphaned-body-text": "Le tableau de bord public orphelin ne sera plus public.", + "revoke-title": "Désactiver l'URL publique" + }, + "email-sharing": { + "input-invalid-email-text": "E-mail non valide", + "input-required-email-text": "Une adresse e-mail est obligatoire", + "invite-button": "Inviter", + "invite-field-desc": "Invitez des personnes par e-mail", + "invite-field-label": "Inviter", + "resend-button": "Renvoyer", + "resend-button-title": "Renvoyer", + "revoke-button": "Retirer", + "revoke-button-title": "Retirer" + }, + "modal-alerts": { + "no-upsert-perm-alert-desc": "Contactez votre administrateur pour obtenir la permission d'accéder aux tableaux de bord publics {{mode}}", + "no-upsert-perm-alert-title": "Vous n'avez pas l'autorisation nécessaire pour {{ mode }} un tableau de bord public", + "save-dashboard-changes-alert-title": "Veuillez enregistrer les modifications de votre tableau de bord avant de modifier la configuration publique", + "unsupport-data-source-alert-readmore-link": "En savoir plus sur les sources de données prises en charge", + "unsupported-data-source-alert-desc": "Ce tableau de bord comprend des sources de données qui ne sont pas prises en charge pour les tableaux de bord publics. Les panneaux qui utilisent ces sources de données peuvent ne pas fonctionner correctement : {{unsupportedDataSources}}.", + "unsupported-data-source-alert-title": "Sources de données non prises en charge", + "unsupported-template-variable-alert-desc": "Ce tableau de bord public peut ne pas fonctionner car il utilise des variables de modèle", + "unsupported-template-variable-alert-title": "Les variables de modèle ne sont pas prises en charge" + }, + "settings-bar-header": { + "collapse-settings-tooltip": "Réduire les paramètres", + "expand-settings-tooltip": "Développer les paramètres" + }, + "settings-configuration": { + "default-time-range-label": "Intervalle de temps par défaut", + "default-time-range-label-desc": "Le tableau de bord public utilise les paramètres d'intervalle de temps par défaut du tableau de bord", + "show-annotations-label": "Afficher les annotations", + "show-annotations-label-desc": "Afficher les annotations sur le tableau de bord public", + "time-range-picker-label": "Sélecteur de plage de temps activé", + "time-range-picker-label-desc": "Permettre de modifier la plage de temps pendant l'affichage" + }, + "settings-summary": { + "annotations-hide-text": "Annotations = masquer", + "annotations-show-text": "Annotations = montrer", + "time-range-picker-disabled-text": "Sélecteur de plage de temps = désactivé", + "time-range-picker-enabled-text": "Sélecteur de plage de temps = activé", + "time-range-text": "Plage de temps = " + } + }, + "public-dashboard-list": { + "button": { + "config-button-tooltip": "Configurer le tableau de bord public", + "revoke-button-text": "Désactiver l'URL publique", + "revoke-button-tooltip": "Désactiver l'URL du tableau de bord public", + "view-button-tooltip": "Afficher le tableau de bord public" + }, + "dashboard-title": { + "orphaned-title": "<0>Tableau de bord public orphelin", + "orphaned-tooltip": "Le tableau de bord lié a déjà été supprimé" + }, + "toggle": { + "pause-sharing-toggle-text": "Suspendre le partage" + } + }, + "public-dashboard-users-access-list": { + "dashboard-modal": { + "loading-text": "Chargement en cours...", + "open-dashboard-list-text": "Ouvrir la liste des tableaux de bord", + "public-dashboard-link": "URL du tableau de bord public", + "public-dashboard-setting": "Paramètres du tableau de bord public" + }, + "delete-user-modal": { + "delete-user-button-text": "Supprimer l'utilisateur", + "delete-user-cancel-button": "Annuler", + "delete-user-revoke-access-button": "Retirer l'accès", + "revoke-access-title": "Retirer l'accès", + "revoke-user-access-modal-desc-line1": "Souhaitez-vous vraiment retirer l'accès pour {{email}} ?", + "revoke-user-access-modal-desc-line2": "Cette action retirera immédiatement l'accès de {{email}}'s à tous les tableaux de bord publics." + }, + "modal": { + "dashboard-modal-title": "Tableaux de bord publics" + }, + "table-header": { + "activated-label": "Activé", + "activated-tooltip": "Première heure à laquelle l'utilisateur a été un utilisateur actif sur un tableau de bord", + "email-label": "Adresse e-mail", + "last-active-label": "Dernière activité", + "origin-label": "Origine", + "role-label": "Rôle" } }, "query-operation": { @@ -1105,6 +1375,12 @@ "turned-off": "Actualisation automatique désactivée" } }, + "return-to-previous": { + "button": { + "label": "Retour à {{title}}" + }, + "dismissable-button": "Fermer" + }, "search": { "actions": { "include-panels": "Inclure des panneaux", @@ -1182,11 +1458,11 @@ "expire-day": "1 jour", "expire-hour": "1 heure", "expire-never": "Jamais", - "expire-week": "7 jours", + "expire-week": "1 semaine", "info-text-1": "Un instantané est un moyen instantané de partager publiquement un tableau de bord interactif. Lors de la création, nous supprimons les données sensibles telles que les requêtes (métrique, modèle et annotation) et les liens du panneau, pour ne laisser que les métriques visibles et les noms de séries intégrés dans votre tableau de bord.", "info-text-2": "N'oubliez pas que votre instantané <1>peut être consulté par une personne qui dispose du lien et qui peut accéder à l'URL. Partagez judicieusement.", - "local-button": "Instantané local", - "mistake-message": "Avez-vous commis une erreur\u00a0? ", + "local-button": "Publier un instantané", + "mistake-message": "Avez-vous commis une erreur ? ", "name": "Nom de l'instantané", "timeout": "Délai d’expiration (secondes)", "timeout-description": "Vous devrez peut-être configurer la valeur du délai d'expiration si la collecte des métriques de votre tableau de bord prend beaucoup de temps.", @@ -1198,7 +1474,8 @@ "library-panel": "Panneau de bibliothèque", "link": "Lien", "panel-embed": "Intégrer", - "public-dashboard": "Tableau de bord public", + "public-dashboard": "Publier le tableau de bord", + "public-dashboard-title": "Tableau de bord public", "snapshot": "Instantané" }, "theme-picker": { @@ -1269,7 +1546,7 @@ "calendar": { "apply-button": "Appliquer la plage de temps", "cancel-button": "Annuler", - "close": "", + "close": "Fermer le calendrier", "select-time": "Sélectionner une plage de temps" }, "content": { @@ -1277,6 +1554,13 @@ "empty-recent-list-info": "Il semble que vous n'ayez jamais utilisé ce sélecteur de temps dans le passé. Lorsque vous commencerez à utiliser des plages de temps, celles récemment utilisées apparaîtront ici.", "filter-placeholder": "Rechercher dans les plages rapides" }, + "copy-paste": { + "copy-success-message": "Intervalle de temps copié dans le presse-papiers", + "default-error-message": "{{error}} n'est pas un intervalle de temps valide", + "default-error-title": "Intervalle de temps invalide", + "tooltip-copy": "Copier l'intervalle de temps dans le presse-papiers", + "tooltip-paste": "Coller l'intervalle de temps" + }, "footer": { "change-settings-button": "Modifier les paramètres de l'heure", "fiscal-year-option": "Exercice fiscal", @@ -1289,7 +1573,7 @@ "default-error": "Entrez une date dans le passé ou « maintenant »", "fiscal-year": "Exercice fiscal", "from-input": "De", - "open-input-calendar": "", + "open-input-calendar": "Ouvrir le calendrier", "range-error": "« De » ne peut pas être ultérieur à « À »", "to-input": "À" }, @@ -1314,8 +1598,8 @@ }, "transformations": { "empty": { - "add-transformation-body": "", - "add-transformation-header": "" + "add-transformation-body": "Les transformations permettent de modifier les données de différentes manières avant l'affichage de votre visualisation.<1>Vous pouvez notamment rassembler des données, renommer des champs, faire des calculs, formater les données à afficher, etc.", + "add-transformation-header": "Commencer la transformation des données" } }, "user-orgs": { @@ -1347,6 +1631,11 @@ "user-sessions": { "loading": "Chargement des sessions..." }, + "users-access-list": { + "tabs": { + "public-dashboard-users-tab-title": "Utilisateurs du tableau de bord public" + } + }, "variable": { "adhoc": { "placeholder": "Sélectionner une valeur" @@ -1364,4 +1653,4 @@ "placeholder": "Entrer la valeur de la variable" } } -} +} \ No newline at end of file diff --git a/public/locales/i18next-parser.config.js b/public/locales/i18next-parser.config.js index 0628220503ac5..c83f5e0ce7475 100644 --- a/public/locales/i18next-parser.config.js +++ b/public/locales/i18next-parser.config.js @@ -1,8 +1,8 @@ module.exports = { // Default namespace used in your i18next config defaultNamespace: 'grafana', - - locales: ['en-US', 'fr-FR', 'es-ES', "de-DE", "zh-Hans", 'pseudo-LOCALE'], + // Adds changes only to en-US when extracting keys, every other language is provided by Crowdin + locales: ['en-US'], output: './public/locales/$LOCALE/$NAMESPACE.json', @@ -13,7 +13,4 @@ module.exports = { failOnWarnings: true, verbose: false, - - // Don't include default values for English, they'll remain in the source code - skipDefaultValues: (locale) => locale !== 'en-US', }; diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 2dcbe4e27547e..548641ea6fedc 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1,5 +1,5 @@ { - "_comment": "Ŧĥįş ƒįľę įş ŧĥę şőūřčę őƒ ŧřūŧĥ ƒőř Ēʼnģľįşĥ şŧřįʼnģş. Ēđįŧ ŧĥįş ŧő čĥäʼnģę pľūřäľş äʼnđ őŧĥęř pĥřäşęş ƒőř ŧĥę ŮĨ.", + "_comment": "Ŧĥįş ƒįľę įş ŧĥę şőūřčę őƒ ŧřūŧĥ ƒőř Ēʼnģľįşĥ şŧřįʼnģş. Ēđįŧ ŧĥįş įʼn ŧĥę čőđęþäşę ŧő čĥäʼnģę pľūřäľş äʼnđ őŧĥęř pĥřäşęş ƒőř ŧĥę ŮĨ.", "access-control": { "add-permission": { "role-label": "Ŗőľę", @@ -76,6 +76,7 @@ "folder-picker": { "accessible-label": "Ŝęľęčŧ ƒőľđęř: {{ label }} čūřřęʼnŧľy şęľęčŧęđ", "button-label": "Ŝęľęčŧ ƒőľđęř", + "clear-selection": "Cľęäř şęľęčŧįőʼn", "empty-message": "Ńő ƒőľđęřş ƒőūʼnđ", "error-title": "Ēřřőř ľőäđįʼnģ ƒőľđęřş", "search-placeholder": "Ŝęäřčĥ ƒőľđęřş", @@ -107,6 +108,9 @@ "dark-theme": "Đäřĸ", "light-theme": "Ŀįģĥŧ" }, + "empty-state": { + "title": "Ńő řęşūľŧş ƒőūʼnđ" + }, "search-box": { "placeholder": "Ŝęäřčĥ őř ĵūmp ŧő..." }, @@ -233,6 +237,10 @@ "visualization": "Vįşūäľįžäŧįőʼn", "widget": "Ŵįđģęŧ" }, + "alert-rules-drawer": { + "redirect-link": "Ŀįşŧ įʼn Ğřäƒäʼnä Åľęřŧįʼnģ", + "subtitle": "Åľęřŧ řūľęş řęľäŧęđ ŧő ŧĥįş đäşĥþőäřđ" + }, "empty": { "add-library-panel-body": "Åđđ vįşūäľįžäŧįőʼnş ŧĥäŧ äřę şĥäřęđ ŵįŧĥ őŧĥęř đäşĥþőäřđş.", "add-library-panel-button": "Åđđ ľįþřäřy päʼnęľ", @@ -301,7 +309,7 @@ }, "toolbar": { "add": "Åđđ", - "add-panel": "Åđđ päʼnęľ", + "alert-rules": "Åľęřŧ řūľęş", "mark-favorite": "Mäřĸ äş ƒävőřįŧę", "open-original": "Øpęʼn őřįģįʼnäľ đäşĥþőäřđ", "playlist-next": "Ğő ŧő ʼnęχŧ đäşĥþőäřđ", @@ -311,6 +319,7 @@ "save": "Ŝävę đäşĥþőäřđ", "settings": "Đäşĥþőäřđ şęŧŧįʼnģş", "share": "Ŝĥäřę đäşĥþőäřđ", + "share-button": "Ŝĥäřę", "unmark-favorite": "Ůʼnmäřĸ äş ƒävőřįŧę" }, "validation": { @@ -360,6 +369,7 @@ "title-label": "Ŧįŧľę" }, "json-editor": { + "apply-button": "Åppľy čĥäʼnģęş", "save-button": "Ŝävę čĥäʼnģęş", "subtitle": "Ŧĥę ĴŜØŃ mőđęľ þęľőŵ įş ŧĥę đäŧä şŧřūčŧūřę ŧĥäŧ đęƒįʼnęş ŧĥę đäşĥþőäřđ. Ŧĥįş įʼnčľūđęş đäşĥþőäřđ şęŧŧįʼnģş, päʼnęľ şęŧŧįʼnģş, ľäyőūŧ, qūęřįęş, äʼnđ şő őʼn.", "title": "ĴŜØŃ Mőđęľ" @@ -412,9 +422,6 @@ "label": "Åđđ ʼnęŵ đäŧä şőūřčę" } }, - "drawer": { - "close": "Cľőşę Đřäŵęř" - }, "explore": { "add-to-dashboard": "Åđđ ŧő đäşĥþőäřđ", "rich-history": { @@ -554,6 +561,9 @@ "loading": "Ŀőäđįʼnģ ƒőľđęřş..." }, "grafana-ui": { + "drawer": { + "close": "Cľőşę" + }, "modal": { "close-tooltip": "Cľőşę" }, @@ -585,6 +595,7 @@ "shortcuts-description": { "change-theme": "Cĥäʼnģę ŧĥęmę", "collapse-all-rows": "Cőľľäpşę äľľ řőŵş", + "copy-time-range": "Cőpy ŧįmę řäʼnģę", "dashboard-settings": "Đäşĥþőäřđ şęŧŧįʼnģş", "duplicate-panel": "Đūpľįčäŧę Päʼnęľ", "exit-edit/setting-views": "Ēχįŧ ęđįŧ/şęŧŧįʼnģ vįęŵş", @@ -598,6 +609,7 @@ "move-time-range-forward": "Mővę ŧįmę řäʼnģę ƒőřŵäřđ", "open-search": "Øpęʼn şęäřčĥ", "open-shared-modal": "Øpęʼn Päʼnęľ Ŝĥäřę Mőđäľ", + "paste-time-range": "Päşŧę ŧįmę řäʼnģę", "refresh-all-panels": "Ŗęƒřęşĥ äľľ päʼnęľş", "remove-panel": "Ŗęmővę Päʼnęľ", "save-dashboard": "Ŝävę đäşĥþőäřđ", @@ -661,6 +673,139 @@ "invalid-user-or-password": "Ĩʼnväľįđ ūşęřʼnämę őř päşşŵőřđ", "title": "Ŀőģįʼn ƒäįľęđ", "unknown": "Ůʼnĸʼnőŵʼn ęřřőř őččūřřęđ" + }, + "forgot-password": "Főřģőŧ yőūř päşşŵőřđ?", + "form": { + "password-label": "Päşşŵőřđ", + "password-required": "Päşşŵőřđ įş řęqūįřęđ", + "submit-label": "Ŀőģ įʼn", + "submit-loading-label": "Ŀőģģįʼnģ įʼn...", + "username-label": "Ēmäįľ őř ūşęřʼnämę", + "username-required": "Ēmäįľ őř ūşęřʼnämę įş řęqūįřęđ" + }, + "services": { + "sing-in-with-prefix": "Ŝįģʼn įʼn ŵįŧĥ {{serviceName}}" + }, + "signup": { + "button-label": "Ŝįģʼn ūp", + "new-to-question": "Ńęŵ ŧő Ğřäƒäʼnä?" + } + }, + "migrate-to-cloud": { + "can-i-move": { + "body": "Øʼnčę yőū čőʼnʼnęčŧ ŧĥįş įʼnşŧäľľäŧįőʼn ŧő ä čľőūđ şŧäčĸ, yőū'ľľ þę äþľę ŧő ūpľőäđ đäŧä şőūřčęş äʼnđ đäşĥþőäřđş.", + "link-title": "Ŀęäřʼn äþőūŧ mįģřäŧįʼnģ őŧĥęř şęŧŧįʼnģş", + "title": "Cäʼn Ĩ mővę ŧĥįş įʼnşŧäľľäŧįőʼn ŧő Ğřäƒäʼnä Cľőūđ?" + }, + "connect-modal": { + "body-cloud-stack": "Ÿőū'ľľ äľşő ʼnęęđ ä čľőūđ şŧäčĸ. Ĩƒ yőū ĵūşŧ şįģʼnęđ ūp, ŵę'ľľ äūŧőmäŧįčäľľy čřęäŧę yőūř ƒįřşŧ şŧäčĸ. Ĩƒ yőū ĥävę äʼn äččőūʼnŧ, yőū'ľľ ʼnęęđ ŧő şęľęčŧ őř čřęäŧę ä şŧäčĸ.", + "body-get-started": "Ŧő ģęŧ şŧäřŧęđ, yőū'ľľ ʼnęęđ ä Ğřäƒäʼnä.čőm äččőūʼnŧ.", + "body-paste-stack": "Øʼnčę yőū'vę đęčįđęđ őʼn ä şŧäčĸ, päşŧę ŧĥę ŮŖĿ þęľőŵ.", + "body-sign-up": "Ŝįģʼn ūp ƒőř ä Ğřäƒäʼnä.čőm äččőūʼnŧ", + "body-token": "Ÿőūř şęľƒ-mäʼnäģęđ Ğřäƒäʼnä įʼnşŧäľľäŧįőʼn ʼnęęđş şpęčįäľ äččęşş ŧő şęčūřęľy mįģřäŧę čőʼnŧęʼnŧ. Ÿőū'ľľ ʼnęęđ ŧő čřęäŧę ä mįģřäŧįőʼn ŧőĸęʼn őʼn yőūř čĥőşęʼn čľőūđ şŧäčĸ.", + "body-token-field": "Mįģřäŧįőʼn ŧőĸęʼn", + "body-token-field-placeholder": "Päşŧę ŧőĸęʼn ĥęřę", + "body-token-instructions": "Ŀőģ įʼnŧő yőūř čľőūđ şŧäčĸ äʼnđ ʼnävįģäŧę ŧő Åđmįʼnįşŧřäŧįőʼn, Ğęʼnęřäľ, Mįģřäŧę ŧő Ğřäƒäʼnä Cľőūđ. Cřęäŧę ä mįģřäŧįőʼn ŧőĸęʼn őʼn ŧĥäŧ şčřęęʼn äʼnđ päşŧę ŧĥę ŧőĸęʼn ĥęřę.", + "body-url-field": "Cľőūđ şŧäčĸ ŮŖĿ", + "body-view-stacks": "Vįęŵ my čľőūđ şŧäčĸş", + "cancel": "Cäʼnčęľ", + "connect": "Cőʼnʼnęčŧ ŧő ŧĥįş şŧäčĸ", + "connecting": "Cőʼnʼnęčŧįʼnģ ŧő ŧĥįş şŧäčĸ...", + "stack-required-error": "Ŝŧäčĸ ŮŖĿ įş řęqūįřęđ", + "title": "Cőʼnʼnęčŧ ŧő ä čľőūđ şŧäčĸ", + "token-required-error": "Mįģřäŧįőʼn ŧőĸęʼn įş řęqūįřęđ" + }, + "cta": { + "button": "Mįģřäŧę ŧĥįş įʼnşŧäʼnčę ŧő Cľőūđ", + "header": "Ŀęŧ ūş mäʼnäģę yőūř Ğřäƒäʼnä şŧäčĸ" + }, + "disconnect-modal": { + "body": "Ŧĥįş ŵįľľ řęmővę ŧĥę mįģřäŧįőʼn ŧőĸęʼn ƒřőm ŧĥįş įʼnşŧäľľäŧįőʼn. Ĩƒ yőū ŵįşĥ ŧő ūpľőäđ mőřę řęşőūřčęş įʼn ŧĥę ƒūŧūřę, yőū ŵįľľ ʼnęęđ ŧő ęʼnŧęř ä ʼnęŵ mįģřäŧįőʼn ŧőĸęʼn.", + "cancel": "Cäʼnčęľ", + "disconnect": "Đįşčőʼnʼnęčŧ", + "disconnecting": "Đįşčőʼnʼnęčŧįʼnģ...", + "error": "Ŧĥęřę ŵäş äʼn ęřřőř đįşčőʼnʼnęčŧįʼnģ", + "title": "Đįşčőʼnʼnęčŧ ƒřőm čľőūđ şŧäčĸ" + }, + "get-started": { + "body": "Ŧĥę mįģřäŧįőʼn přőčęşş mūşŧ þę şŧäřŧęđ ƒřőm yőūř şęľƒ-mäʼnäģęđ Ğřäƒäʼnä įʼnşŧäʼnčę.", + "configure-pdc-link": "Cőʼnƒįģūřę PĐC ƒőř ŧĥįş şŧäčĸ", + "link-title": "Ŀęäřʼn mőřę äþőūŧ Přįväŧę Đäŧä Ŝőūřčę Cőʼnʼnęčŧ", + "step-1": "Ŀőģ įʼn ŧő yőūř şęľƒ-mäʼnäģęđ įʼnşŧäʼnčę äʼnđ ʼnävįģäŧę ŧő Åđmįʼnįşŧřäŧįőʼn, Ğęʼnęřäľ, Mįģřäŧę ŧő Ğřäƒäʼnä Cľőūđ.", + "step-2": "Ŝęľęčŧ \"Mįģřäŧę ŧĥįş įʼnşŧäʼnčę ŧő Cľőūđ\".", + "step-3": "Ÿőū'ľľ þę přőmpŧęđ ƒőř ä mįģřäŧįőʼn ŧőĸęʼn. Ğęʼnęřäŧę őʼnę ƒřőm ŧĥįş şčřęęʼn.", + "step-4": "Ĩʼn yőūř şęľƒ-mäʼnäģęđ įʼnşŧäʼnčę, şęľęčŧ \"Ůpľőäđ ęvęřyŧĥįʼnģ\" ŧő ūpľőäđ đäŧä şőūřčęş äʼnđ đäşĥþőäřđş ŧő ŧĥįş čľőūđ şŧäčĸ.", + "step-5": "Ĩƒ şőmę őƒ yőūř đäŧä şőūřčęş ŵįľľ ʼnőŧ ŵőřĸ ővęř ŧĥę pūþľįč įʼnŧęřʼnęŧ, yőū’ľľ ʼnęęđ ŧő įʼnşŧäľľ Přįväŧę Đäŧä Ŝőūřčę Cőʼnʼnęčŧ įʼn yőūř şęľƒ-mäʼnäģęđ ęʼnvįřőʼnmęʼnŧ.", + "title": "Ħőŵ ŧő ģęŧ şŧäřŧęđ" + }, + "is-it-secure": { + "body": "Ğřäƒäʼnä Ŀäþş įş čőmmįŧŧęđ ŧő mäįʼnŧäįʼnįʼnģ ŧĥę ĥįģĥęşŧ şŧäʼnđäřđş őƒ đäŧä přįväčy äʼnđ şęčūřįŧy. ßy įmpľęmęʼnŧįʼnģ įʼnđūşŧřy-şŧäʼnđäřđ şęčūřįŧy ŧęčĥʼnőľőģįęş äʼnđ přőčęđūřęş, ŵę ĥęľp přőŧęčŧ őūř čūşŧőmęřş' đäŧä ƒřőm ūʼnäūŧĥőřįžęđ äččęşş, ūşę, őř đįşčľőşūřę.", + "link-title": "Ğřäƒäʼnä Ŀäþş Ŧřūşŧ Cęʼnŧęř", + "title": "Ĩş įŧ şęčūřę?" + }, + "migrate-to-this-stack": { + "body": "Ŝőmę čőʼnƒįģūřäŧįőʼn ƒřőm yőūř şęľƒ-mäʼnäģęđ Ğřäƒäʼnä įʼnşŧäʼnčę čäʼn þę äūŧőmäŧįčäľľy čőpįęđ ŧő ŧĥįş čľőūđ şŧäčĸ.", + "link-title": "Vįęŵ ŧĥę ƒūľľ mįģřäŧįőʼn ģūįđę", + "title": "Mįģřäŧę čőʼnƒįģūřäŧįőʼn ŧő ŧĥįş şŧäčĸ" + }, + "migration-token": { + "body": "Ÿőūř şęľƒ-mäʼnäģęđ Ğřäƒäʼnä įʼnşŧäʼnčę ŵįľľ řęqūįřę ä şpęčįäľ äūŧĥęʼnŧįčäŧįőʼn ŧőĸęʼn ŧő şęčūřęľy čőʼnʼnęčŧ ŧő ŧĥįş čľőūđ şŧäčĸ.", + "delete-button": "Đęľęŧę ŧĥįş mįģřäŧįőʼn ŧőĸęʼn", + "delete-modal-body": "Ĩƒ yőū'vę äľřęäđy ūşęđ ŧĥįş ŧőĸęʼn ŵįŧĥ ä şęľƒ-mäʼnäģęđ įʼnşŧäľľäŧįőʼn, ŧĥäŧ įʼnşŧäľľäŧįőʼn ŵįľľ ʼnő ľőʼnģęř þę äþľę ŧő ūpľőäđ čőʼnŧęʼnŧ.", + "delete-modal-cancel": "Cäʼnčęľ", + "delete-modal-confirm": "Đęľęŧę", + "delete-modal-deleting": "Đęľęŧįʼnģ...", + "delete-modal-title": "Đęľęŧę mįģřäŧįőʼn ŧőĸęʼn", + "generate-button": "Ğęʼnęřäŧę ä mįģřäŧįőʼn ŧőĸęʼn", + "generate-button-loading": "Ğęʼnęřäŧįʼnģ ä mįģřäŧįőʼn ŧőĸęʼn...", + "modal-close": "Cľőşę", + "modal-copy-and-close": "Cőpy ŧő čľįpþőäřđ äʼnđ čľőşę", + "modal-copy-button": "Cőpy ŧő čľįpþőäřđ", + "modal-field-description": "Cőpy ŧĥę ŧőĸęʼn ʼnőŵ äş yőū ŵįľľ ʼnőŧ þę äþľę ŧő şęę įŧ äģäįʼn. Ŀőşįʼnģ ä ŧőĸęʼn řęqūįřęş čřęäŧįʼnģ ä ʼnęŵ őʼnę.", + "modal-field-label": "Ŧőĸęʼn", + "modal-title": "Mįģřäŧįőʼn ŧőĸęʼn čřęäŧęđ", + "status": "Cūřřęʼnŧ şŧäŧūş: <2>", + "title": "Mįģřäŧįőʼn ŧőĸęʼn" + }, + "pdc": { + "body": "Ēχpőşįʼnģ yőūř đäŧä şőūřčęş ŧő ŧĥę įʼnŧęřʼnęŧ čäʼn řäįşę şęčūřįŧy čőʼnčęřʼnş. Přįväŧę đäŧä şőūřčę čőʼnʼnęčŧ (PĐC) äľľőŵş Ğřäƒäʼnä Cľőūđ ŧő äččęşş yőūř ęχįşŧįʼnģ đäŧä şőūřčęş ővęř ä şęčūřę ʼnęŧŵőřĸ ŧūʼnʼnęľ.", + "link-title": "Ŀęäřʼn äþőūŧ PĐC", + "title": "Ńőŧ äľľ my đäŧä şőūřčęş äřę őʼn ŧĥę pūþľįč įʼnŧęřʼnęŧ" + }, + "pricing": { + "body": "Ğřäƒäʼnä Cľőūđ ĥäş ä ģęʼnęřőūş ƒřęę pľäʼn äʼnđ ä 14 đäy ūʼnľįmįŧęđ ūşäģę ŧřįäľ. Ńŧęř yőūř ŧřįäľ ęχpįřęş, yőū'ľľ þę þįľľęđ þäşęđ őʼn ūşäģę ővęř ŧĥę ƒřęę pľäʼn ľįmįŧş.", + "link-title": "Ğřäƒäʼnä Cľőūđ přįčįʼnģ", + "title": "Ħőŵ mūčĥ đőęş įŧ čőşŧ?" + }, + "resource-status": { + "error-details-button": "Đęŧäįľş", + "failed": "Ēřřőř", + "migrated": "Ůpľőäđęđ ŧő čľőūđ", + "migrating": "Ůpľőäđįʼnģ...", + "not-migrated": "Ńőŧ yęŧ ūpľőäđęđ", + "unknown": "Ůʼnĸʼnőŵʼn" + }, + "resource-type": { + "dashboard": "Đäşĥþőäřđ", + "datasource": "Đäŧä şőūřčę", + "unknown": "Ůʼnĸʼnőŵʼn" + }, + "resources": { + "disconnect": "Đįşčőʼnʼnęčŧ" + }, + "token-status": { + "active": "Ŧőĸęʼn čřęäŧęđ äʼnđ äčŧįvę", + "no-active": "Ńő äčŧįvę ŧőĸęʼn" + }, + "what-is-cloud": { + "body": "Ğřäƒäʼnä čľőūđ įş ä ƒūľľy mäʼnäģęđ čľőūđ-ĥőşŧęđ őþşęřväþįľįŧy pľäŧƒőřm įđęäľ ƒőř čľőūđ ʼnäŧįvę ęʼnvįřőʼnmęʼnŧş. Ĩŧ'ş ęvęřyŧĥįʼnģ yőū ľővę äþőūŧ Ğřäƒäʼnä ŵįŧĥőūŧ ŧĥę ővęřĥęäđ őƒ mäįʼnŧäįʼnįʼnģ, ūpģřäđįʼnģ, äʼnđ şūppőřŧįʼnģ äʼn įʼnşŧäľľäŧįőʼn.", + "link-title": "Ŀęäřʼn äþőūŧ čľőūđ ƒęäŧūřęş", + "title": "Ŵĥäŧ įş Ğřäƒäʼnä Cľőūđ?" + }, + "why-host": { + "body": "Ĩʼn äđđįŧįőʼn ŧő ŧĥę čőʼnvęʼnįęʼnčę őƒ mäʼnäģęđ ĥőşŧįʼnģ, Ğřäƒäʼnä Cľőūđ įʼnčľūđęş mäʼny čľőūđ-ęχčľūşįvę ƒęäŧūřęş ľįĸę ŜĿØş, įʼnčįđęʼnŧ mäʼnäģęmęʼnŧ, mäčĥįʼnę ľęäřʼnįʼnģ, äʼnđ pőŵęřƒūľ őþşęřväþįľįŧy įʼnŧęģřäŧįőʼnş.", + "link-title": "Mőřę qūęşŧįőʼnş? Ŧäľĸ ŧő äʼn ęχpęřŧ", + "title": "Ŵĥy ĥőşŧ ŵįŧĥ Ğřäƒäʼnä?" } }, "nav": { @@ -671,6 +816,9 @@ "subtitle": "Mäʼnäģę şęřvęř-ŵįđę şęŧŧįʼnģş äʼnđ äččęşş ŧő řęşőūřčęş şūčĥ äş őřģäʼnįžäŧįőʼnş, ūşęřş, äʼnđ ľįčęʼnşęş", "title": "Ŝęřvęř äđmįʼn" }, + "alert-list-legacy": { + "title": "Åľęřŧ řūľęş" + }, "alerting": { "subtitle": "Ŀęäřʼn äþőūŧ přőþľęmş įʼn yőūř şyşŧęmş mőmęʼnŧş äƒŧęř ŧĥęy őččūř", "title": "Åľęřŧįʼnģ" @@ -707,6 +855,10 @@ "subtitle": "Ŝŧőp ʼnőŧįƒįčäŧįőʼnş ƒřőm őʼnę őř mőřę äľęřŧįʼnģ řūľęş", "title": "Ŝįľęʼnčęş" }, + "alerting-upgrade": { + "subtitle": "Ůpģřäđę yőūř ęχįşŧįʼnģ ľęģäčy äľęřŧş äʼnđ ʼnőŧįƒįčäŧįőʼn čĥäʼnʼnęľş ŧő ŧĥę ʼnęŵ Ğřäƒäʼnä Åľęřŧįʼnģ", + "title": "Åľęřŧįʼnģ ūpģřäđę" + }, "alerts-and-incidents": { "subtitle": "Åľęřŧįʼnģ äʼnđ įʼnčįđęʼnŧ mäʼnäģęmęʼnŧ äppş", "title": "Åľęřŧş & ĨŖM" @@ -725,6 +877,9 @@ "authentication": { "title": "Åūŧĥęʼnŧįčäŧįőʼn" }, + "collector": { + "title": "Cőľľęčŧőř" + }, "config": { "title": "Åđmįʼnįşŧřäŧįőʼn" }, @@ -838,6 +993,10 @@ "manage-folder": { "subtitle": "Mäʼnäģę ƒőľđęř đäşĥþőäřđş äʼnđ pęřmįşşįőʼnş" }, + "migrate-to-cloud": { + "subtitle": "Cőpy čőʼnƒįģūřäŧįőʼn ƒřőm yőūř şęľƒ-mäʼnäģęđ įʼnşŧäľľäŧįőʼn ŧő ä čľőūđ şŧäčĸ", + "title": "Mįģřäŧę ŧő Ğřäƒäʼnä Cľőūđ" + }, "monitoring": { "subtitle": "Mőʼnįŧőřįʼnģ äʼnđ įʼnƒřäşŧřūčŧūřę äppş", "title": "Øþşęřväþįľįŧy" @@ -861,9 +1020,6 @@ "subtitle": "Mäʼnäģę přęƒęřęʼnčęş äčřőşş äʼn őřģäʼnįžäŧįőʼn", "title": "Đęƒäūľŧ přęƒęřęʼnčęş" }, - "performance-testing": { - "title": "Pęřƒőřmäʼnčę ŧęşŧįʼnģ" - }, "playlists": { "subtitle": "Ğřőūpş őƒ đäşĥþőäřđş ŧĥäŧ äřę đįşpľäyęđ įʼn ä şęqūęʼnčę", "title": "Pľäyľįşŧş" @@ -872,6 +1028,10 @@ "subtitle": "Ēχŧęʼnđ ŧĥę Ğřäƒäʼnä ęχpęřįęʼnčę ŵįŧĥ pľūģįʼnş", "title": "Pľūģįʼnş" }, + "private-data-source-connections": { + "subtitle": "Qūęřy đäŧä ŧĥäŧ ľįvęş ŵįŧĥįʼn ä şęčūřęđ ʼnęŧŵőřĸ ŵįŧĥőūŧ őpęʼnįʼnģ ŧĥę ʼnęŧŵőřĸ ŧő įʼnþőūʼnđ ŧř䃃įč ƒřőm Ğřäƒäʼnä Cľőūđ. Ŀęäřʼn mőřę įʼn őūř đőčş.", + "title": "Přįväŧę đäŧä şőūřčę čőʼnʼnęčŧ" + }, "profile/notifications": { "title": "Ńőŧįƒįčäŧįőʼn ĥįşŧőřy" }, @@ -963,6 +1123,7 @@ "megamenu": { "close": "Cľőşę męʼnū", "dock": "Đőčĸ męʼnū", + "list-label": "Ńävįģäŧįőʼn", "undock": "Ůʼnđőčĸ męʼnū" }, "toolbar": { @@ -1061,7 +1222,122 @@ "new-password-same-as-old": "Ńęŵ päşşŵőřđ čäʼn'ŧ þę ŧĥę şämę äş ŧĥę őľđ őʼnę.", "old-password-label": "Øľđ päşşŵőřđ", "old-password-required": "Øľđ päşşŵőřđ įş řęqūįřęđ", - "passwords-must-match": "Päşşŵőřđş mūşŧ mäŧčĥ" + "passwords-must-match": "Päşşŵőřđş mūşŧ mäŧčĥ", + "strong-password-validation-register": "Päşşŵőřđ đőęş ʼnőŧ čőmpľy ŵįŧĥ ŧĥę şŧřőʼnģ päşşŵőřđ pőľįčy" + } + }, + "public-dashboard": { + "acknowledgment-checkboxes": { + "ack-title": "ßęƒőřę yőū mäĸę ŧĥę đäşĥþőäřđ pūþľįč, äčĸʼnőŵľęđģę ŧĥę ƒőľľőŵįʼnģ:", + "data-src-ack-desc": "Pūþľįşĥįʼnģ čūřřęʼnŧľy őʼnľy ŵőřĸş ŵįŧĥ ä şūþşęŧ őƒ đäŧä şőūřčęş*", + "data-src-ack-tooltip": "Ŀęäřʼn mőřę äþőūŧ pūþľįč đäŧäşőūřčęş", + "public-ack-desc": "Ÿőūř ęʼnŧįřę đäşĥþőäřđ ŵįľľ þę pūþľįč*", + "public-ack-tooltip": "Ŀęäřʼn mőřę äþőūŧ pūþľįč đäşĥþőäřđş", + "usage-ack-desc": "Mäĸįʼnģ ä đäşĥþőäřđ pūþľįč ŵįľľ čäūşę qūęřįęş ŧő řūʼn ęäčĥ ŧįmę įŧ įş vįęŵęđ, ŵĥįčĥ mäy įʼnčřęäşę čőşŧş*", + "usage-ack-desc-tooltip": "Ŀęäřʼn mőřę äþőūŧ qūęřy čäčĥįʼnģ" + }, + "config": { + "can-view-dashboard-radio-button-label": "Cäʼn vįęŵ đäşĥþőäřđ", + "copy-button": "Cőpy", + "dashboard-url-field-label": "Đäşĥþőäřđ ŮŖĿ", + "email-share-type-option-label": "Øʼnľy şpęčįƒįęđ pęőpľę", + "pause-sharing-dashboard-label": "Päūşę şĥäřįʼnģ đäşĥþőäřđ", + "public-share-type-option-label": "Åʼnyőʼnę ŵįŧĥ ä ľįʼnĸ", + "revoke-public-URL-button": "Ŗęvőĸę pūþľįč ŮŖĿ", + "revoke-public-URL-button-title": "Ŗęvőĸę pūþľįč ŮŖĿ", + "settings-title": "Ŝęŧŧįʼnģş" + }, + "create-page": { + "generate-public-url-button": "Ğęʼnęřäŧę pūþľįč ŮŖĿ", + "unsupported-features-desc": "Cūřřęʼnŧľy, ŵę đőʼn’ŧ şūppőřŧ ŧęmpľäŧę väřįäþľęş őř ƒřőʼnŧęʼnđ đäŧä şőūřčęş", + "welcome-title": "Ŵęľčőmę ŧő pūþľįč đäşĥþőäřđş!" + }, + "delete-modal": { + "revoke-nonorphaned-body-text": "Åřę yőū şūřę yőū ŵäʼnŧ ŧő řęvőĸę ŧĥįş ŮŖĿ? Ŧĥę đäşĥþőäřđ ŵįľľ ʼnő ľőʼnģęř þę pūþľįč.", + "revoke-orphaned-body-text": "Øřpĥäʼnęđ pūþľįč đäşĥþőäřđ ŵįľľ ʼnő ľőʼnģęř þę pūþľįč.", + "revoke-title": "Ŗęvőĸę pūþľįč ŮŖĿ" + }, + "email-sharing": { + "input-invalid-email-text": "Ĩʼnväľįđ ęmäįľ", + "input-required-email-text": "Ēmäįľ įş řęqūįřęđ", + "invite-button": "Ĩʼnvįŧę", + "invite-field-desc": "Ĩʼnvįŧę pęőpľę þy ęmäįľ", + "invite-field-label": "Ĩʼnvįŧę", + "resend-button": "Ŗęşęʼnđ", + "resend-button-title": "Ŗęşęʼnđ", + "revoke-button": "Ŗęvőĸę", + "revoke-button-title": "Ŗęvőĸę" + }, + "modal-alerts": { + "no-upsert-perm-alert-desc": "Cőʼnŧäčŧ yőūř äđmįʼn ŧő ģęŧ pęřmįşşįőʼn ŧő {{mode}} pūþľįč đäşĥþőäřđş", + "no-upsert-perm-alert-title": "Ÿőū đőʼn’ŧ ĥävę pęřmįşşįőʼn ŧő {{ mode }} ä pūþľįč đäşĥþőäřđ", + "save-dashboard-changes-alert-title": "Pľęäşę şävę yőūř đäşĥþőäřđ čĥäʼnģęş þęƒőřę ūpđäŧįʼnģ ŧĥę pūþľįč čőʼnƒįģūřäŧįőʼn", + "unsupport-data-source-alert-readmore-link": "Ŗęäđ mőřę äþőūŧ şūppőřŧęđ đäŧä şőūřčęş", + "unsupported-data-source-alert-desc": "Ŧĥęřę äřę đäŧä şőūřčęş įʼn ŧĥįş đäşĥþőäřđ ŧĥäŧ äřę ūʼnşūppőřŧęđ ƒőř pūþľįč đäşĥþőäřđş. Päʼnęľş ŧĥäŧ ūşę ŧĥęşę đäŧä şőūřčęş mäy ʼnőŧ ƒūʼnčŧįőʼn přőpęřľy: {{unsupportedDataSources}}.", + "unsupported-data-source-alert-title": "Ůʼnşūppőřŧęđ đäŧä şőūřčęş", + "unsupported-template-variable-alert-desc": "Ŧĥįş pūþľįč đäşĥþőäřđ mäy ʼnőŧ ŵőřĸ şįʼnčę įŧ ūşęş ŧęmpľäŧę väřįäþľęş", + "unsupported-template-variable-alert-title": "Ŧęmpľäŧę väřįäþľęş äřę ʼnőŧ şūppőřŧęđ" + }, + "settings-bar-header": { + "collapse-settings-tooltip": "Cőľľäpşę şęŧŧįʼnģş", + "expand-settings-tooltip": "Ēχpäʼnđ şęŧŧįʼnģş" + }, + "settings-configuration": { + "default-time-range-label": "Đęƒäūľŧ ŧįmę řäʼnģę", + "default-time-range-label-desc": "Ŧĥę pūþľįč đäşĥþőäřđ ūşęş ŧĥę đęƒäūľŧ ŧįmę řäʼnģę şęŧŧįʼnģş őƒ ŧĥę đäşĥþőäřđ", + "show-annotations-label": "Ŝĥőŵ äʼnʼnőŧäŧįőʼnş", + "show-annotations-label-desc": "Ŝĥőŵ äʼnʼnőŧäŧįőʼnş őʼn pūþľįč đäşĥþőäřđ", + "time-range-picker-label": "Ŧįmę řäʼnģę pįčĸęř ęʼnäþľęđ", + "time-range-picker-label-desc": "Åľľőŵ vįęŵęřş ŧő čĥäʼnģę ŧįmę řäʼnģę" + }, + "settings-summary": { + "annotations-hide-text": "Åʼnʼnőŧäŧįőʼnş = ĥįđę", + "annotations-show-text": "Åʼnʼnőŧäŧįőʼnş = şĥőŵ", + "time-range-picker-disabled-text": "Ŧįmę řäʼnģę pįčĸęř = đįşäþľęđ", + "time-range-picker-enabled-text": "Ŧįmę řäʼnģę pįčĸęř = ęʼnäþľęđ", + "time-range-text": "Ŧįmę řäʼnģę = " + } + }, + "public-dashboard-list": { + "button": { + "config-button-tooltip": "Cőʼnƒįģūřę pūþľįč đäşĥþőäřđ", + "revoke-button-text": "Ŗęvőĸę pūþľįč ŮŖĿ", + "revoke-button-tooltip": "Ŗęvőĸę pūþľįč đäşĥþőäřđ ŮŖĿ", + "view-button-tooltip": "Vįęŵ pūþľįč đäşĥþőäřđ" + }, + "dashboard-title": { + "orphaned-title": "<0>Øřpĥäʼnęđ pūþľįč đäşĥþőäřđ", + "orphaned-tooltip": "Ŧĥę ľįʼnĸęđ đäşĥþőäřđ ĥäş äľřęäđy þęęʼn đęľęŧęđ" + }, + "toggle": { + "pause-sharing-toggle-text": "Päūşę şĥäřįʼnģ" + } + }, + "public-dashboard-users-access-list": { + "dashboard-modal": { + "loading-text": "Ŀőäđįʼnģ...", + "open-dashboard-list-text": "Øpęʼn đäşĥþőäřđş ľįşŧ", + "public-dashboard-link": "Pūþľįč đäşĥþőäřđ ŮŖĿ", + "public-dashboard-setting": "Pūþľįč đäşĥþőäřđ şęŧŧįʼnģş" + }, + "delete-user-modal": { + "delete-user-button-text": "Đęľęŧę ūşęř", + "delete-user-cancel-button": "Cäʼnčęľ", + "delete-user-revoke-access-button": "Ŗęvőĸę äččęşş", + "revoke-access-title": "Ŗęvőĸę äččęşş", + "revoke-user-access-modal-desc-line1": "Åřę yőū şūřę yőū ŵäʼnŧ ŧő řęvőĸę äččęşş ƒőř {{email}}?", + "revoke-user-access-modal-desc-line2": "Ŧĥįş äčŧįőʼn ŵįľľ įmmęđįäŧęľy řęvőĸę {{email}}&äpőş;ş äččęşş ŧő äľľ pūþľįč đäşĥþőäřđş." + }, + "modal": { + "dashboard-modal-title": "Pūþľįč đäşĥþőäřđş" + }, + "table-header": { + "activated-label": "Åčŧįväŧęđ", + "activated-tooltip": "Ēäřľįęşŧ ŧįmę ūşęř ĥäş þęęʼn äʼn äčŧįvę ūşęř ŧő ä đäşĥþőäřđ", + "email-label": "Ēmäįľ", + "last-active-label": "Ŀäşŧ äčŧįvę", + "origin-label": "Øřįģįʼn", + "role-label": "Ŗőľę" } }, "query-operation": { @@ -1099,6 +1375,12 @@ "turned-off": "Åūŧő řęƒřęşĥ őƒƒ" } }, + "return-to-previous": { + "button": { + "label": "ßäčĸ ŧő {{title}}" + }, + "dismissable-button": "Cľőşę" + }, "search": { "actions": { "include-panels": "Ĩʼnčľūđę päʼnęľş", @@ -1176,10 +1458,10 @@ "expire-day": "1 Đäy", "expire-hour": "1 Ħőūř", "expire-never": "Ńęvęř", - "expire-week": "7 Đäyş", + "expire-week": "1 Ŵęęĸ", "info-text-1": "Å şʼnäpşĥőŧ įş äʼn įʼnşŧäʼnŧ ŵäy ŧő şĥäřę äʼn įʼnŧęřäčŧįvę đäşĥþőäřđ pūþľįčľy. Ŵĥęʼn čřęäŧęđ, ŵę şŧřįp şęʼnşįŧįvę đäŧä ľįĸę qūęřįęş (męŧřįč, ŧęmpľäŧę, äʼnđ äʼnʼnőŧäŧįőʼn) äʼnđ päʼnęľ ľįʼnĸş, ľęävįʼnģ őʼnľy ŧĥę vįşįþľę męŧřįč đäŧä äʼnđ şęřįęş ʼnämęş ęmþęđđęđ įʼn yőūř đäşĥþőäřđ.", "info-text-2": "Ķęęp įʼn mįʼnđ, yőūř şʼnäpşĥőŧ <1>čäʼn þę vįęŵęđ þy äʼnyőʼnę ŧĥäŧ ĥäş ŧĥę ľįʼnĸ äʼnđ čäʼn äččęşş ŧĥę ŮŖĿ. Ŝĥäřę ŵįşęľy.", - "local-button": "Ŀőčäľ Ŝʼnäpşĥőŧ", + "local-button": "Pūþľįşĥ Ŝʼnäpşĥőŧ", "mistake-message": "Đįđ yőū mäĸę ä mįşŧäĸę? ", "name": "Ŝʼnäpşĥőŧ ʼnämę", "timeout": "Ŧįmęőūŧ (şęčőʼnđş)", @@ -1192,7 +1474,8 @@ "library-panel": "Ŀįþřäřy päʼnęľ", "link": "Ŀįʼnĸ", "panel-embed": "Ēmþęđ", - "public-dashboard": "Pūþľįč Đäşĥþőäřđ", + "public-dashboard": "Pūþľįşĥ Đäşĥþőäřđ", + "public-dashboard-title": "Pūþľįč đäşĥþőäřđ", "snapshot": "Ŝʼnäpşĥőŧ" }, "theme-picker": { @@ -1271,6 +1554,13 @@ "empty-recent-list-info": "Ĩŧ ľőőĸş ľįĸę yőū ĥävęʼn'ŧ ūşęđ ŧĥįş ŧįmę pįčĸęř þęƒőřę. Åş şőőʼn äş yőū ęʼnŧęř şőmę ŧįmę įʼnŧęřväľş, řęčęʼnŧľy ūşęđ įʼnŧęřväľş ŵįľľ äppęäř ĥęřę.", "filter-placeholder": "Ŝęäřčĥ qūįčĸ řäʼnģęş" }, + "copy-paste": { + "copy-success-message": "Ŧįmę řäʼnģę čőpįęđ ŧő čľįpþőäřđ", + "default-error-message": "{{error}} įş ʼnőŧ ä väľįđ ŧįmę řäʼnģę", + "default-error-title": "Ĩʼnväľįđ ŧįmę řäʼnģę", + "tooltip-copy": "Cőpy ŧįmę řäʼnģę ŧő čľįpþőäřđ", + "tooltip-paste": "Päşŧę ŧįmę řäʼnģę" + }, "footer": { "change-settings-button": "Cĥäʼnģę ŧįmę şęŧŧįʼnģş", "fiscal-year-option": "Fįşčäľ yęäř", @@ -1341,6 +1631,11 @@ "user-sessions": { "loading": "Ŀőäđįʼnģ şęşşįőʼnş..." }, + "users-access-list": { + "tabs": { + "public-dashboard-users-tab-title": "Pūþľįč đäşĥþőäřđ ūşęřş" + } + }, "variable": { "adhoc": { "placeholder": "Ŝęľęčŧ väľūę" diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json new file mode 100644 index 0000000000000..54f7add2d97a5 --- /dev/null +++ b/public/locales/pt-BR/grafana.json @@ -0,0 +1,1656 @@ +{ + "_comment": "Esse arquivo é a fonte da verdade para strings em inglês. Edite isto na base de código para alterar os plurais e outras frases para a interface do usuário.", + "access-control": { + "add-permission": { + "role-label": "Função", + "serviceaccount-label": "Conta de serviço", + "team-label": "Equipe", + "title": "Adicionar permissão para", + "user-label": "Usuário" + }, + "add-permissions": { + "save": "Salvar" + }, + "permission-list": { + "permission": "Permissão" + }, + "permissions": { + "add-label": "Adicionar uma permissão", + "no-permissions": "Não há permissões", + "permissions-change-warning": "Isto irá alterar as permissões para este diretório e todos os seus descendentes. No total, isto afetará:", + "role": "Função", + "serviceaccount": "", + "team": "", + "title": "", + "user": "" + } + }, + "bouncing-loader": { + "label": "" + }, + "browse-dashboards": { + "action": { + "cancel-button": "", + "cannot-move-folders": "", + "delete-button": "", + "delete-modal-invalid-text": "", + "delete-modal-invalid-title": "", + "delete-modal-text": "", + "delete-modal-title": "", + "deleting": "", + "manage-permissions-button": "", + "move-button": "", + "move-modal-alert": "", + "move-modal-field-label": "", + "move-modal-text": "", + "move-modal-title": "", + "moving": "", + "new-folder-name-required-phrase": "" + }, + "counts": { + "alertRule_one": "", + "alertRule_other": "", + "dashboard_one": "", + "dashboard_other": "", + "folder_one": "", + "folder_other": "", + "libraryPanel_one": "", + "libraryPanel_other": "", + "total_one": "", + "total_other": "" + }, + "dashboards-tree": { + "collapse-folder-button": "", + "expand-folder-button": "", + "name-column": "", + "select-all-header-checkbox": "", + "select-checkbox": "", + "tags-column": "" + }, + "folder-actions-button": { + "delete": "", + "folder-actions": "", + "manage-permissions": "", + "move": "" + }, + "folder-picker": { + "accessible-label": "", + "button-label": "", + "clear-selection": "", + "empty-message": "", + "error-title": "", + "search-placeholder": "", + "unknown-error": "" + }, + "manage-folder-nav": { + "alert-rules": "", + "dashboards": "", + "panels": "" + }, + "new-folder-form": { + "cancel-label": "", + "create-label": "", + "name-label": "" + }, + "no-results": { + "clear": "", + "text": "" + } + }, + "clipboard-button": { + "inline-toast": { + "success": "" + } + }, + "command-palette": { + "action": { + "change-theme": "", + "dark-theme": "", + "light-theme": "" + }, + "empty-state": { + "title": "" + }, + "search-box": { + "placeholder": "" + }, + "section": { + "actions": "", + "dashboard-search-results": "", + "folder-search-results": "", + "pages": "", + "preferences": "", + "recent-dashboards": "" + } + }, + "common": { + "locale": { + "default": "" + }, + "save": "" + }, + "connections": { + "connect-data": { + "category-header-label": "" + }, + "search": { + "placeholder": "" + } + }, + "correlations": { + "add-new": "", + "alert": { + "error-message": "", + "title": "" + }, + "basic-info-form": { + "description-description": "", + "description-label": "", + "label-description": "", + "label-label": "", + "label-placeholder": "", + "label-required": "", + "sub-text": "", + "title": "" + }, + "list": { + "delete": "", + "label": "", + "loading": "", + "read-only": "", + "source": "", + "target": "" + }, + "navigation-form": { + "add-button": "", + "back-button": "", + "next-button": "", + "save-button": "" + }, + "page-content": "", + "page-heading": "", + "query-editor": { + "control-rules": "", + "data-source-text": "", + "data-source-title": "", + "error-text": "", + "error-title": "", + "loading": "", + "query-description": "", + "query-editor-title": "", + "query-label": "" + }, + "source-form": { + "control-required": "", + "description": "", + "heading": "", + "results-description": "", + "results-label": "", + "results-required": "", + "source-description": "", + "source-label": "", + "sub-text": "", + "title": "" + }, + "sub-title": "", + "target-form": { + "control-rules": "", + "sub-text": "", + "target-description": "", + "target-label": "", + "title": "" + }, + "trans-details": { + "logfmt-description": "", + "logfmt-label": "", + "regex-description": "", + "regex-expression": "", + "regex-label": "", + "regex-map-values": "" + }, + "transform": { + "add-button": "", + "heading": "", + "no-transform": "" + }, + "transform-row": { + "expression-label": "", + "expression-required": "", + "expression-tooltip": "", + "field-input": "", + "field-label": "", + "field-tooltip": "", + "map-value-label": "", + "map-value-tooltip": "", + "remove-button": "", + "remove-tooltip": "", + "transform-required": "", + "type-label": "", + "type-tooltip": "" + } + }, + "dashboard": { + "add-menu": { + "import": "", + "paste-panel": "", + "row": "", + "visualization": "", + "widget": "" + }, + "alert-rules-drawer": { + "redirect-link": "", + "subtitle": "" + }, + "empty": { + "add-library-panel-body": "", + "add-library-panel-button": "", + "add-library-panel-header": "", + "add-visualization-body": "", + "add-visualization-button": "", + "add-visualization-header": "", + "add-widget-body": "", + "add-widget-button": "", + "add-widget-header": "", + "import-a-dashboard-body": "", + "import-a-dashboard-header": "", + "import-dashboard-button": "" + }, + "inspect": { + "data-tab": "", + "error-tab": "", + "json-tab": "", + "meta-tab": "", + "query-tab": "", + "stats-tab": "", + "subtitle": "", + "title": "" + }, + "inspect-data": { + "data-options": "", + "dataframe-aria-label": "", + "dataframe-label": "", + "download-csv": "", + "download-excel-description": "", + "download-excel-label": "", + "download-logs": "", + "download-service": "", + "download-traces": "", + "excel-header": "", + "formatted": "", + "formatted-data-description": "", + "formatted-data-label": "", + "panel-transforms": "", + "series-to-columns": "", + "transformation": "", + "transformations-description": "", + "transformations-label": "" + }, + "inspect-json": { + "dataframe-description": "", + "dataframe-label": "", + "panel-data-description": "", + "panel-data-label": "", + "panel-json-description": "", + "panel-json-label": "", + "select-source": "", + "unknown": "" + }, + "inspect-meta": { + "no-inspector": "" + }, + "inspect-stats": { + "data-title": "", + "data-traceids": "", + "processing-time": "", + "queries": "", + "request-time": "", + "rows": "", + "table-title": "" + }, + "toolbar": { + "add": "", + "alert-rules": "", + "mark-favorite": "", + "open-original": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "refresh": "", + "save": "", + "settings": "", + "share": "", + "share-button": "", + "unmark-favorite": "" + }, + "validation": { + "invalid-dashboard-id": "", + "invalid-json": "", + "tags-expected-array": "", + "tags-expected-strings": "" + } + }, + "dashboard-import": { + "file-dropzone": { + "primary-text": "", + "secondary-text": "" + }, + "form-actions": { + "cancel": "", + "load": "" + }, + "gcom-field": { + "label": "", + "load-button": "", + "placeholder": "", + "validation-required": "" + }, + "json-field": { + "label": "", + "validation-required": "" + } + }, + "dashboard-settings": { + "annotations": { + "title": "" + }, + "dashboard-delete-button": "", + "general": { + "auto-refresh-description": "", + "auto-refresh-label": "", + "description-label": "", + "editable-description": "", + "editable-label": "", + "folder-label": "", + "panel-options-graph-tooltip-description": "", + "panel-options-graph-tooltip-label": "", + "panel-options-label": "", + "tags-label": "", + "title": "", + "title-label": "" + }, + "json-editor": { + "apply-button": "", + "save-button": "", + "subtitle": "", + "title": "" + }, + "links": { + "title": "" + }, + "permissions": { + "title": "" + }, + "settings": { + "title": "" + }, + "time-picker": { + "hide-time-picker": "", + "now-delay-description": "", + "now-delay-label": "", + "refresh-live-dashboards-description": "", + "refresh-live-dashboards-label": "", + "time-options-label": "", + "time-zone-label": "", + "week-start-label": "" + }, + "variables": { + "title": "" + }, + "versions": { + "title": "" + } + }, + "data-source-picker": { + "add-new-data-source": "", + "built-in-list": { + "description-dashboard": "", + "description-grafana": "", + "description-mixed": "" + }, + "list": { + "no-data-source-message": "" + }, + "modal": { + "configure-new-data-source": "", + "input-placeholder": "", + "title": "" + }, + "open-advanced-button": "" + }, + "data-sources": { + "datasource-add-button": { + "label": "" + } + }, + "explore": { + "add-to-dashboard": "", + "rich-history": { + "close-tooltip": "", + "datasource-a-z": "", + "datasource-z-a": "", + "newest-first": "", + "oldest-first": "", + "query-history": "", + "settings": "", + "starred": "" + }, + "rich-history-card": { + "add-comment-form": "", + "add-comment-tooltip": "", + "cancel": "", + "confirm-delete": "", + "copy-query-tooltip": "", + "copy-shortened-link-tooltip": "", + "datasource-icon-label": "", + "datasource-name-label": "", + "datasource-not-exist": "", + "delete-query-confirmation-title": "", + "delete-query-title": "", + "delete-query-tooltip": "", + "delete-starred-query-confirmation-text": "", + "edit-comment-tooltip": "", + "loading-text": "", + "optional-description": "", + "query-comment-label": "", + "query-text-label": "", + "run-query-button": "", + "save-comment": "", + "star-query-tooltip": "", + "switch-datasource-button": "", + "unstar-query-tooltip": "", + "update-comment-form": "" + }, + "rich-history-container": { + "loading": "" + }, + "rich-history-notification": { + "query-copied": "", + "query-deleted": "" + }, + "rich-history-queries-tab": { + "displaying-partial-queries": "", + "displaying-queries": "", + "filter-aria-label": "", + "filter-history": "", + "filter-placeholder": "", + "history-local": "", + "loading": "", + "loading-results": "", + "search-placeholder": "", + "showing-queries": "", + "sort-aria-label": "", + "sort-placeholder": "" + }, + "rich-history-settings-tab": { + "alert-info": "", + "change-default-tab": "", + "clear-history-info": "", + "clear-query-history": "", + "clear-query-history-button": "", + "delete-confirm": "", + "delete-confirm-text": "", + "delete-title": "", + "history-time-span": "", + "history-time-span-description": "", + "only-show-active-datasource": "", + "query-history-deleted": "", + "retention-period": { + "1-week": "", + "2-days": "", + "2-weeks": "", + "5-days": "" + } + }, + "rich-history-starred-tab": { + "filter-queries-aria-label": "", + "filter-queries-placeholder": "", + "loading": "", + "loading-results": "", + "local-history-message": "", + "search-queries-placeholder": "", + "showing-queries": "", + "sort-queries-aria-label": "", + "sort-queries-placeholder": "" + }, + "rich-history-utils": { + "a-week-ago": "", + "days-ago": "", + "default-from": "", + "default-to": "", + "today": "", + "two-weeks-ago": "", + "yesterday": "" + }, + "rich-history-utils-notification": { + "saving-failed": "", + "update-failed": "" + }, + "secondary-actions": { + "query-add-button": "", + "query-add-button-aria-label": "", + "query-history-button": "", + "query-history-button-aria-label": "", + "query-inspector-button": "", + "query-inspector-button-aria-label": "" + }, + "table": { + "no-data": "", + "title": "", + "title-with-name": "" + }, + "toolbar": { + "aria-label": "", + "copy-link": "", + "copy-link-abs-time": "", + "copy-links-absolute-category": "", + "copy-links-normal-category": "", + "copy-shortened-link": "", + "copy-shortened-link-abs-time": "", + "copy-shortened-link-menu": "", + "refresh-picker-cancel": "", + "refresh-picker-run": "", + "split-close": "", + "split-close-tooltip": "", + "split-narrow": "", + "split-title": "", + "split-tooltip": "", + "split-widen": "" + } + }, + "folder-picker": { + "loading": "" + }, + "grafana-ui": { + "drawer": { + "close": "" + }, + "modal": { + "close-tooltip": "" + }, + "segment-async": { + "error": "", + "loading": "", + "no-options": "" + }, + "select": { + "no-options-label": "", + "placeholder": "" + } + }, + "graph": { + "container": { + "content": "", + "show-all-series": "", + "show-only-series": "", + "title": "" + } + }, + "help-modal": { + "shortcuts-category": { + "dashboard": "", + "focused-panel": "", + "global": "", + "time-range": "" + }, + "shortcuts-description": { + "change-theme": "", + "collapse-all-rows": "", + "copy-time-range": "", + "dashboard-settings": "", + "duplicate-panel": "", + "exit-edit/setting-views": "", + "expand-all-rows": "", + "go-to-dashboards": "", + "go-to-explore": "", + "go-to-home-dashboard": "", + "go-to-profile": "", + "make-time-range-permanent": "", + "move-time-range-back": "", + "move-time-range-forward": "", + "open-search": "", + "open-shared-modal": "", + "paste-time-range": "", + "refresh-all-panels": "", + "remove-panel": "", + "save-dashboard": "", + "show-all-shortcuts": "", + "toggle-active-mode": "", + "toggle-all-panel-legends": "", + "toggle-auto-fit": "", + "toggle-exemplars": "", + "toggle-graph-crosshair": "", + "toggle-kiosk": "", + "toggle-panel-edit": "", + "toggle-panel-fullscreen": "", + "toggle-panel-legend": "", + "zoom-out-time-range": "" + }, + "title": "" + }, + "inspector": { + "query": { + "collapse-all": "", + "copy-to-clipboard": "", + "description": "", + "expand-all": "", + "no-data": "", + "refresh": "" + } + }, + "library-panel": { + "add-modal": { + "cancel": "", + "create": "", + "error": "", + "folder": "", + "folder-description": "", + "name": "" + }, + "add-widget": { + "title": "" + } + }, + "library-panels": { + "modal": { + "button-cancel": "", + "button-view-panel1": "", + "button-view-panel2": "", + "panel-not-linked": "", + "select-no-options-message": "", + "select-placeholder": "", + "title": "", + "body_one": "", + "body_other": "" + }, + "save": { + "error": "", + "success": "" + } + }, + "login": { + "error": { + "blocked": "", + "invalid-user-or-password": "", + "title": "", + "unknown": "" + }, + "forgot-password": "", + "form": { + "password-label": "", + "password-required": "", + "submit-label": "", + "submit-loading-label": "", + "username-label": "", + "username-required": "" + }, + "services": { + "sing-in-with-prefix": "" + }, + "signup": { + "button-label": "", + "new-to-question": "" + } + }, + "migrate-to-cloud": { + "can-i-move": { + "body": "", + "link-title": "", + "title": "" + }, + "connect-modal": { + "body-cloud-stack": "", + "body-get-started": "", + "body-paste-stack": "", + "body-sign-up": "", + "body-token": "", + "body-token-field": "", + "body-token-field-placeholder": "", + "body-token-instructions": "", + "body-url-field": "", + "body-view-stacks": "", + "cancel": "", + "connect": "", + "connecting": "", + "stack-required-error": "", + "title": "", + "token-required-error": "" + }, + "cta": { + "button": "", + "header": "" + }, + "disconnect-modal": { + "body": "", + "cancel": "", + "disconnect": "", + "disconnecting": "", + "error": "", + "title": "" + }, + "get-started": { + "body": "", + "configure-pdc-link": "", + "link-title": "", + "step-1": "", + "step-2": "", + "step-3": "", + "step-4": "", + "step-5": "", + "title": "" + }, + "is-it-secure": { + "body": "", + "link-title": "", + "title": "" + }, + "migrate-to-this-stack": { + "body": "", + "link-title": "", + "title": "" + }, + "migration-token": { + "body": "", + "delete-button": "", + "delete-modal-body": "", + "delete-modal-cancel": "", + "delete-modal-confirm": "", + "delete-modal-deleting": "", + "delete-modal-title": "", + "generate-button": "", + "generate-button-loading": "", + "modal-close": "", + "modal-copy-and-close": "", + "modal-copy-button": "", + "modal-field-description": "", + "modal-field-label": "", + "modal-title": "", + "status": "", + "title": "" + }, + "pdc": { + "body": "", + "link-title": "", + "title": "" + }, + "pricing": { + "body": "", + "link-title": "", + "title": "" + }, + "resource-status": { + "error-details-button": "", + "failed": "", + "migrated": "", + "migrating": "", + "not-migrated": "", + "unknown": "" + }, + "resource-type": { + "dashboard": "", + "datasource": "", + "unknown": "" + }, + "resources": { + "disconnect": "" + }, + "token-status": { + "active": "", + "no-active": "" + }, + "what-is-cloud": { + "body": "", + "link-title": "", + "title": "" + }, + "why-host": { + "body": "", + "link-title": "", + "title": "" + } + }, + "nav": { + "add-new-connections": { + "title": "" + }, + "admin": { + "subtitle": "", + "title": "" + }, + "alert-list-legacy": { + "title": "" + }, + "alerting": { + "subtitle": "", + "title": "" + }, + "alerting-admin": { + "title": "" + }, + "alerting-am-routes": { + "subtitle": "", + "title": "" + }, + "alerting-channels": { + "title": "" + }, + "alerting-groups": { + "subtitle": "", + "title": "" + }, + "alerting-home": { + "title": "" + }, + "alerting-legacy": { + "title": "" + }, + "alerting-list": { + "subtitle": "", + "title": "" + }, + "alerting-receivers": { + "subtitle": "", + "title": "" + }, + "alerting-silences": { + "subtitle": "", + "title": "" + }, + "alerting-upgrade": { + "subtitle": "", + "title": "" + }, + "alerts-and-incidents": { + "subtitle": "", + "title": "" + }, + "api-keys": { + "subtitle": "", + "title": "" + }, + "application": { + "title": "" + }, + "apps": { + "subtitle": "", + "title": "" + }, + "authentication": { + "title": "" + }, + "collector": { + "title": "" + }, + "config": { + "title": "" + }, + "config-access": { + "subtitle": "", + "title": "" + }, + "config-general": { + "subtitle": "", + "title": "" + }, + "config-plugins": { + "subtitle": "", + "title": "" + }, + "connect-data": { + "title": "" + }, + "connections": { + "subtitle": "", + "title": "" + }, + "correlations": { + "subtitle": "", + "title": "" + }, + "create": { + "title": "" + }, + "create-alert": { + "title": "" + }, + "create-dashboard": { + "title": "" + }, + "create-folder": { + "title": "" + }, + "create-import": { + "title": "" + }, + "dashboards": { + "subtitle": "", + "title": "" + }, + "data-sources": { + "subtitle": "", + "title": "" + }, + "datasources": { + "subtitle": "", + "title": "" + }, + "detect": { + "title": "" + }, + "explore": { + "title": "" + }, + "frontend": { + "subtitle": "", + "title": "" + }, + "frontend-app": { + "title": "" + }, + "global-orgs": { + "subtitle": "", + "title": "" + }, + "global-users": { + "subtitle": "", + "title": "" + }, + "grafana-quaderno": { + "title": "" + }, + "help": { + "title": "" + }, + "help/community": "", + "help/documentation": "", + "help/keyboard-shortcuts": "", + "help/support": "", + "home": { + "title": "" + }, + "incidents": { + "title": "" + }, + "infrastructure": { + "subtitle": "", + "title": "" + }, + "integrations": { + "title": "" + }, + "k6": { + "title": "" + }, + "kubernetes": { + "title": "" + }, + "library-panels": { + "subtitle": "", + "title": "" + }, + "machine-learning": { + "title": "" + }, + "manage-folder": { + "subtitle": "" + }, + "migrate-to-cloud": { + "subtitle": "", + "title": "" + }, + "monitoring": { + "subtitle": "", + "title": "" + }, + "new": { + "title": "" + }, + "new-dashboard": { + "title": "" + }, + "new-folder": { + "title": "" + }, + "observability": { + "title": "" + }, + "oncall": { + "title": "" + }, + "org-settings": { + "subtitle": "", + "title": "" + }, + "playlists": { + "subtitle": "", + "title": "" + }, + "plugins": { + "subtitle": "", + "title": "" + }, + "private-data-source-connections": { + "subtitle": "", + "title": "" + }, + "profile/notifications": { + "title": "" + }, + "profile/password": { + "title": "" + }, + "profile/settings": { + "title": "" + }, + "profiles": { + "title": "" + }, + "public": { + "title": "" + }, + "recorded-queries": { + "title": "" + }, + "reporting": { + "title": "" + }, + "scenes": { + "title": "" + }, + "search": { + "placeholderCommandPalette": "" + }, + "search-dashboards": { + "title": "" + }, + "server-settings": { + "subtitle": "", + "title": "" + }, + "service-accounts": { + "subtitle": "", + "title": "" + }, + "sign-out": { + "title": "" + }, + "slo": { + "title": "" + }, + "snapshots": { + "subtitle": "", + "title": "" + }, + "starred": { + "title": "" + }, + "starred-empty": { + "title": "" + }, + "statistics-and-licensing": { + "title": "" + }, + "storage": { + "subtitle": "", + "title": "" + }, + "support-bundles": { + "subtitle": "", + "title": "" + }, + "synthetics": { + "title": "" + }, + "teams": { + "subtitle": "", + "title": "" + }, + "testing-and-synthetics": { + "subtitle": "", + "title": "" + }, + "upgrading": { + "title": "" + }, + "users": { + "subtitle": "", + "title": "" + } + }, + "navigation": { + "kiosk": { + "tv-alert": "" + }, + "megamenu": { + "close": "", + "dock": "", + "list-label": "", + "undock": "" + }, + "toolbar": { + "close-menu": "", + "enable-kiosk": "", + "open-menu": "", + "toggle-search-bar": "" + } + }, + "news": { + "drawer": { + "close": "" + }, + "title": "" + }, + "notifications": { + "starred-dashboard": "", + "unstarred-dashboard": "" + }, + "panel": { + "header-menu": { + "copy": "", + "create-library-panel": "", + "duplicate": "", + "edit": "", + "explore": "", + "get-help": "", + "hide-legend": "", + "inspect": "", + "inspect-data": "", + "inspect-json": "", + "more": "", + "new-alert-rule": "", + "query": "", + "remove": "", + "share": "", + "show-legend": "", + "unlink-library-panel": "", + "view": "" + } + }, + "playlist-edit": { + "error-prefix": "", + "form": { + "add-tag-label": "", + "add-tag-placeholder": "", + "add-title-label": "", + "cancel": "", + "heading": "", + "interval-label": "", + "interval-placeholder": "", + "interval-required": "", + "name-label": "", + "name-placeholder": "", + "name-required": "", + "save": "", + "table-delete": "", + "table-drag": "", + "table-empty": "", + "table-heading": "" + }, + "sub-title": "", + "title": "" + }, + "playlist-page": { + "card": { + "delete": "", + "edit": "", + "start": "", + "tooltip": "" + }, + "create-button": { + "title": "" + }, + "delete-modal": { + "body": "", + "confirm-text": "" + }, + "empty": { + "button": "", + "pro-tip": "", + "pro-tip-link-title": "", + "title": "" + } + }, + "profile": { + "change-password": { + "cancel-button": "", + "cannot-change-password-message": "", + "change-password-button": "", + "confirm-password-label": "", + "confirm-password-required": "", + "ldap-auth-proxy-message": "", + "new-password-label": "", + "new-password-required": "", + "new-password-same-as-old": "", + "old-password-label": "", + "old-password-required": "", + "passwords-must-match": "", + "strong-password-validation-register": "" + } + }, + "public-dashboard": { + "acknowledgment-checkboxes": { + "ack-title": "", + "data-src-ack-desc": "", + "data-src-ack-tooltip": "", + "public-ack-desc": "", + "public-ack-tooltip": "", + "usage-ack-desc": "", + "usage-ack-desc-tooltip": "" + }, + "config": { + "can-view-dashboard-radio-button-label": "", + "copy-button": "", + "dashboard-url-field-label": "", + "email-share-type-option-label": "", + "pause-sharing-dashboard-label": "", + "public-share-type-option-label": "", + "revoke-public-URL-button": "", + "revoke-public-URL-button-title": "", + "settings-title": "" + }, + "create-page": { + "generate-public-url-button": "", + "unsupported-features-desc": "", + "welcome-title": "" + }, + "delete-modal": { + "revoke-nonorphaned-body-text": "", + "revoke-orphaned-body-text": "", + "revoke-title": "" + }, + "email-sharing": { + "input-invalid-email-text": "", + "input-required-email-text": "", + "invite-button": "", + "invite-field-desc": "", + "invite-field-label": "", + "resend-button": "", + "resend-button-title": "", + "revoke-button": "", + "revoke-button-title": "" + }, + "modal-alerts": { + "no-upsert-perm-alert-desc": "", + "no-upsert-perm-alert-title": "", + "save-dashboard-changes-alert-title": "", + "unsupport-data-source-alert-readmore-link": "", + "unsupported-data-source-alert-desc": "", + "unsupported-data-source-alert-title": "", + "unsupported-template-variable-alert-desc": "", + "unsupported-template-variable-alert-title": "" + }, + "settings-bar-header": { + "collapse-settings-tooltip": "", + "expand-settings-tooltip": "" + }, + "settings-configuration": { + "default-time-range-label": "", + "default-time-range-label-desc": "", + "show-annotations-label": "", + "show-annotations-label-desc": "", + "time-range-picker-label": "", + "time-range-picker-label-desc": "" + }, + "settings-summary": { + "annotations-hide-text": "", + "annotations-show-text": "", + "time-range-picker-disabled-text": "", + "time-range-picker-enabled-text": "", + "time-range-text": "" + } + }, + "public-dashboard-list": { + "button": { + "config-button-tooltip": "", + "revoke-button-text": "", + "revoke-button-tooltip": "", + "view-button-tooltip": "" + }, + "dashboard-title": { + "orphaned-title": "", + "orphaned-tooltip": "" + }, + "toggle": { + "pause-sharing-toggle-text": "" + } + }, + "public-dashboard-users-access-list": { + "dashboard-modal": { + "loading-text": "", + "open-dashboard-list-text": "", + "public-dashboard-link": "", + "public-dashboard-setting": "" + }, + "delete-user-modal": { + "delete-user-button-text": "", + "delete-user-cancel-button": "", + "delete-user-revoke-access-button": "", + "revoke-access-title": "", + "revoke-user-access-modal-desc-line1": "", + "revoke-user-access-modal-desc-line2": "" + }, + "modal": { + "dashboard-modal-title": "" + }, + "table-header": { + "activated-label": "", + "activated-tooltip": "", + "email-label": "", + "last-active-label": "", + "origin-label": "", + "role-label": "" + } + }, + "query-operation": { + "header": { + "collapse-row": "", + "datasource-help": "", + "disable-query": "", + "drag-and-drop": "", + "duplicate-query": "", + "expand-row": "", + "remove-query": "", + "toggle-edit-mode": "" + }, + "query-editor-not-exported": "" + }, + "refresh-picker": { + "aria-label": { + "choose-interval": "", + "duration-selected": "" + }, + "auto-option": { + "aria-label": "", + "label": "" + }, + "live-option": { + "aria-label": "", + "label": "" + }, + "off-option": { + "aria-label": "", + "label": "" + }, + "tooltip": { + "interval-selected": "", + "turned-off": "" + } + }, + "return-to-previous": { + "button": { + "label": "" + }, + "dismissable-button": "" + }, + "search": { + "actions": { + "include-panels": "", + "remove-datasource-filter": "", + "sort-placeholder": "", + "starred": "", + "view-as-folders": "", + "view-as-list": "" + }, + "dashboard-actions": { + "import": "", + "new": "", + "new-dashboard": "", + "new-folder": "" + }, + "results-table": { + "datasource-header": "", + "location-header": "", + "name-header": "", + "tags-header": "", + "type-dashboard": "", + "type-folder": "", + "type-header": "" + }, + "search-input": { + "include-panels-placeholder": "", + "placeholder": "" + } + }, + "share-modal": { + "dashboard": { + "title": "" + }, + "embed": { + "copy": "", + "html": "", + "html-description": "", + "info": "", + "time-range": "", + "time-range-description": "" + }, + "export": { + "back-button": "", + "cancel-button": "", + "info-text": "", + "save-button": "", + "share-externally-label": "", + "view-button": "" + }, + "library": { + "info": "" + }, + "link": { + "copy-link-button": "", + "info-text": "", + "link-url": "", + "render-alert": "", + "render-instructions": "", + "rendered-image": "", + "save-alert": "", + "save-dashboard": "", + "shorten-url": "", + "time-range-description": "", + "time-range-label": "" + }, + "panel": { + "title": "" + }, + "snapshot": { + "cancel-button": "", + "copy-link-button": "", + "delete-button": "", + "deleted-message": "", + "expire": "", + "expire-day": "", + "expire-hour": "", + "expire-never": "", + "expire-week": "", + "info-text-1": "", + "info-text-2": "", + "local-button": "", + "mistake-message": "", + "name": "", + "timeout": "", + "timeout-description": "", + "url-label": "" + }, + "tab-title": { + "embed": "", + "export": "", + "library-panel": "", + "link": "", + "panel-embed": "", + "public-dashboard": "", + "public-dashboard-title": "", + "snapshot": "" + }, + "theme-picker": { + "current": "", + "dark": "", + "field-name": "", + "light": "" + }, + "view-json": { + "copy-button": "" + } + }, + "share-playlist": { + "checkbox-description": "", + "checkbox-label": "", + "copy-link-button": "", + "link-url-label": "", + "mode": "", + "mode-kiosk": "", + "mode-normal": "", + "mode-tv": "", + "title": "" + }, + "shared": { + "preferences": { + "theme": { + "dark-label": "", + "light-label": "", + "system-label": "" + } + } + }, + "shared-dashboard": { + "fields": { + "timezone-label": "" + } + }, + "shared-preferences": { + "fields": { + "home-dashboard-label": "", + "home-dashboard-placeholder": "", + "locale-label": "", + "locale-placeholder": "", + "theme-label": "", + "week-start-label": "" + }, + "theme": { + "default-label": "" + }, + "title": "" + }, + "snapshot": { + "external-badge": "", + "name-column-header": "", + "url-column-header": "", + "view-button": "" + }, + "tag-filter": { + "loading": "", + "no-tags": "", + "placeholder": "" + }, + "time-picker": { + "absolute": { + "recent-title": "", + "title": "" + }, + "calendar": { + "apply-button": "", + "cancel-button": "", + "close": "", + "select-time": "" + }, + "content": { + "empty-recent-list-docs": "", + "empty-recent-list-info": "", + "filter-placeholder": "" + }, + "copy-paste": { + "copy-success-message": "", + "default-error-message": "", + "default-error-title": "", + "tooltip-copy": "", + "tooltip-paste": "" + }, + "footer": { + "change-settings-button": "", + "fiscal-year-option": "", + "fiscal-year-start": "", + "time-zone-option": "", + "time-zone-selection": "" + }, + "range-content": { + "apply-button": "", + "default-error": "", + "fiscal-year": "", + "from-input": "", + "open-input-calendar": "", + "range-error": "", + "to-input": "" + }, + "range-picker": { + "backwards-time-aria-label": "", + "current-time-selected": "", + "forwards-time-aria-label": "", + "to": "", + "zoom-out-button": "", + "zoom-out-tooltip": "" + }, + "time-range": { + "aria-role": "", + "default-title": "", + "example-title": "", + "specify": "" + }, + "zone": { + "select-aria-label": "", + "select-search-input": "" + } + }, + "transformations": { + "empty": { + "add-transformation-body": "", + "add-transformation-header": "" + } + }, + "user-orgs": { + "current-org-button": "", + "name-column": "", + "role-column": "", + "select-org-button": "", + "title": "" + }, + "user-profile": { + "fields": { + "email-error": "", + "email-label": "", + "name-error": "", + "name-label": "", + "username-label": "" + }, + "tabs": { + "general": "" + } + }, + "user-session": { + "browser-column": "", + "created-at-column": "", + "ip-column": "", + "revoke": "", + "seen-at-column": "" + }, + "user-sessions": { + "loading": "" + }, + "users-access-list": { + "tabs": { + "public-dashboard-users-tab-title": "" + } + }, + "variable": { + "adhoc": { + "placeholder": "" + }, + "dropdown": { + "placeholder": "" + }, + "picker": { + "link-all": "", + "option-all": "", + "option-selected-values": "", + "option-tooltip": "" + }, + "textbox": { + "placeholder": "" + } + } +} \ No newline at end of file diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 23d9a20d313a7..1fa5be42faba4 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -1,5 +1,5 @@ { - "_comment": "此文件是英文字符串的真实来源。编辑此文件可更改用户界面的复数形式和其他短语。", + "_comment": "此文件是英文字符串的真实来源。在代码库中编辑此文件以更改复数形式和 UI 的其他短语。", "access-control": { "add-permission": { "role-label": "角色", @@ -26,7 +26,7 @@ } }, "bouncing-loader": { - "label": "" + "label": "正在加载" }, "browse-dashboards": { "action": { @@ -71,6 +71,7 @@ "folder-picker": { "accessible-label": "选择文件夹:{{ label }} 当前已选择", "button-label": "选择文件夹", + "clear-selection": "清除选择", "empty-message": "未找到文件夹", "error-title": "加载文件夹时出错", "search-placeholder": "搜索文件夹", @@ -102,6 +103,9 @@ "dark-theme": "深色", "light-theme": "浅色" }, + "empty-state": { + "title": "" + }, "search-box": { "placeholder": "搜索或跳转至..." }, @@ -228,6 +232,10 @@ "visualization": "可视化", "widget": "小部件" }, + "alert-rules-drawer": { + "redirect-link": "Grafana Alerting 中的列表", + "subtitle": "与此仪表板相关的警报规则" + }, "empty": { "add-library-panel-body": "添加与其他仪表板共享的可视化。", "add-library-panel-button": "添加库面板", @@ -296,7 +304,7 @@ }, "toolbar": { "add": "添加", - "add-panel": "添加面板", + "alert-rules": "警报规则", "mark-favorite": "标记为收藏", "open-original": "打开原始仪表板", "playlist-next": "前往下一个仪表板", @@ -306,6 +314,7 @@ "save": "保存仪表板", "settings": "仪表板设置", "share": "分享仪表板或面板", + "share-button": "分享", "unmark-favorite": "取消标记为收藏" }, "validation": { @@ -355,6 +364,7 @@ "title-label": "标题" }, "json-editor": { + "apply-button": "应用更改", "save-button": "保存更改", "subtitle": "下面的 JSON 模型是定义仪表板的数据结构。这包括仪表板设置、面板设置、布局和查询等等。", "title": "JSON 模型" @@ -407,9 +417,6 @@ "label": "添加新数据源" } }, - "drawer": { - "close": "" - }, "explore": { "add-to-dashboard": "添加到仪表板", "rich-history": { @@ -528,13 +535,13 @@ }, "toolbar": { "aria-label": "浏览 工具栏", - "copy-link": "", - "copy-link-abs-time": "", - "copy-links-absolute-category": "", - "copy-links-normal-category": "", - "copy-shortened-link": "复制短链接", - "copy-shortened-link-abs-time": "", - "copy-shortened-link-menu": "", + "copy-link": "复制网址", + "copy-link-abs-time": "复制绝对网址", + "copy-links-absolute-category": "时间同步网址链接(保持时间范围共享)", + "copy-links-normal-category": "正常链接地址", + "copy-shortened-link": "复制短网址", + "copy-shortened-link-abs-time": "复制绝对短网址", + "copy-shortened-link-menu": "打开短链接菜单", "refresh-picker-cancel": "取消", "refresh-picker-run": "运行查询", "split-close": "关闭", @@ -549,6 +556,9 @@ "loading": "正在加载文件夹..." }, "grafana-ui": { + "drawer": { + "close": "关闭" + }, "modal": { "close-tooltip": "关闭" }, @@ -580,6 +590,7 @@ "shortcuts-description": { "change-theme": "更改主题", "collapse-all-rows": "折叠所有行", + "copy-time-range": "复制时间范围", "dashboard-settings": "仪表板设置", "duplicate-panel": "复制面板", "exit-edit/setting-views": "退出编辑/设置视图", @@ -593,6 +604,7 @@ "move-time-range-forward": "向前移动时间范围", "open-search": "打开搜索", "open-shared-modal": "打开面板分享模式", + "paste-time-range": "粘贴时间范围", "refresh-all-panels": "刷新所有面板", "remove-panel": "删除面板", "save-dashboard": "保存仪表板", @@ -635,14 +647,14 @@ }, "library-panels": { "modal": { - "body_other": "此面板在 {{count}} 个仪表板中使用。请选择要在哪个仪表板中查看此面板:", "button-cancel": "<0>取消", "button-view-panel1": "在 {{label}} 中查看面板...", "button-view-panel2": "在仪表板中查看面板...", "panel-not-linked": "面板没有链接到仪表板。请将面板添加到仪表板,然后重试。", "select-no-options-message": "未找到仪表板", "select-placeholder": "开始输入以搜索仪表板", - "title": "在仪表板中查看面板" + "title": "在仪表板中查看面板", + "body_other": "此面板在 {{count}} 个仪表板中使用。请选择要在哪个仪表板中查看此面板:" }, "save": { "error": "保存库面板时出错:\"{{errorMsg}}\"", @@ -655,6 +667,139 @@ "invalid-user-or-password": "用户名或密码无效", "title": "登录失败", "unknown": "发生了未知错误" + }, + "forgot-password": "", + "form": { + "password-label": "", + "password-required": "", + "submit-label": "", + "submit-loading-label": "", + "username-label": "", + "username-required": "" + }, + "services": { + "sing-in-with-prefix": "" + }, + "signup": { + "button-label": "", + "new-to-question": "" + } + }, + "migrate-to-cloud": { + "can-i-move": { + "body": "", + "link-title": "", + "title": "" + }, + "connect-modal": { + "body-cloud-stack": "", + "body-get-started": "", + "body-paste-stack": "", + "body-sign-up": "", + "body-token": "", + "body-token-field": "", + "body-token-field-placeholder": "", + "body-token-instructions": "", + "body-url-field": "", + "body-view-stacks": "", + "cancel": "", + "connect": "", + "connecting": "", + "stack-required-error": "", + "title": "", + "token-required-error": "" + }, + "cta": { + "button": "", + "header": "" + }, + "disconnect-modal": { + "body": "", + "cancel": "", + "disconnect": "", + "disconnecting": "", + "error": "", + "title": "" + }, + "get-started": { + "body": "", + "configure-pdc-link": "", + "link-title": "", + "step-1": "", + "step-2": "", + "step-3": "", + "step-4": "", + "step-5": "", + "title": "" + }, + "is-it-secure": { + "body": "", + "link-title": "", + "title": "" + }, + "migrate-to-this-stack": { + "body": "", + "link-title": "", + "title": "" + }, + "migration-token": { + "body": "", + "delete-button": "", + "delete-modal-body": "", + "delete-modal-cancel": "", + "delete-modal-confirm": "", + "delete-modal-deleting": "", + "delete-modal-title": "", + "generate-button": "", + "generate-button-loading": "", + "modal-close": "", + "modal-copy-and-close": "", + "modal-copy-button": "", + "modal-field-description": "", + "modal-field-label": "", + "modal-title": "", + "status": "", + "title": "" + }, + "pdc": { + "body": "", + "link-title": "", + "title": "" + }, + "pricing": { + "body": "", + "link-title": "", + "title": "" + }, + "resource-status": { + "error-details-button": "", + "failed": "", + "migrated": "", + "migrating": "", + "not-migrated": "", + "unknown": "" + }, + "resource-type": { + "dashboard": "", + "datasource": "", + "unknown": "" + }, + "resources": { + "disconnect": "" + }, + "token-status": { + "active": "", + "no-active": "" + }, + "what-is-cloud": { + "body": "", + "link-title": "", + "title": "" + }, + "why-host": { + "body": "", + "link-title": "", + "title": "" } }, "nav": { @@ -665,6 +810,9 @@ "subtitle": "管理整个服务器范围的设置,以及对组织、用户和许可证等资源的访问权限", "title": "服务器管理员" }, + "alert-list-legacy": { + "title": "警报规则" + }, "alerting": { "subtitle": "在系统发生问题后立即获悉", "title": "警报" @@ -701,6 +849,10 @@ "subtitle": "停止来自一个或多个警报规则的通知", "title": "静默" }, + "alerting-upgrade": { + "subtitle": "将您现有的旧版警报和通知通道升级到新的 Grafana 告警", + "title": "Alerting 升级" + }, "alerts-and-incidents": { "subtitle": "警报和事件管理应用", "title": "警报和 IRM" @@ -719,6 +871,9 @@ "authentication": { "title": "身份验证" }, + "collector": { + "title": "收集器" + }, "config": { "title": "管理" }, @@ -779,7 +934,7 @@ "title": "探索" }, "frontend": { - "subtitle": "", + "subtitle": "获得真正的用户监测洞察", "title": "前端" }, "frontend-app": { @@ -810,14 +965,14 @@ "title": "事件" }, "infrastructure": { - "subtitle": "", - "title": "" + "subtitle": "了解您基础设施的健康", + "title": "基础设施" }, "integrations": { "title": "集成" }, "k6": { - "title": "" + "title": "性能" }, "kubernetes": { "title": "Kubernetes" @@ -832,6 +987,10 @@ "manage-folder": { "subtitle": "管理文件夹仪表板和权限" }, + "migrate-to-cloud": { + "subtitle": "", + "title": "" + }, "monitoring": { "subtitle": "监控和基础设施应用", "title": "可观测性" @@ -855,9 +1014,6 @@ "subtitle": "管理整个组织的首选项", "title": "默认首选项" }, - "performance-testing": { - "title": "性能测试" - }, "playlists": { "subtitle": "以序列显示的仪表板组", "title": "播放列表" @@ -866,6 +1022,10 @@ "subtitle": "使用插件扩展 Grafana 体验", "title": "插件" }, + "private-data-source-connections": { + "subtitle": "查询安全网络中的数据,无需打开网络从 Grafana Cloud 传入流量。请在我们的文档中了解更多信息。", + "title": "私有数据源连接" + }, "profile/notifications": { "title": "通知历史记录" }, @@ -939,8 +1099,8 @@ "title": "团队" }, "testing-and-synthetics": { - "subtitle": "", - "title": "" + "subtitle": "使用 k6 和 Synthetic Monitoring 洞察优化性能。", + "title": "测试与合成" }, "upgrading": { "title": "统计信息和许可证" @@ -957,7 +1117,8 @@ "megamenu": { "close": "关闭菜单", "dock": "停靠菜单", - "undock": "" + "list-label": "导航", + "undock": "取消停靠菜单" }, "toolbar": { "close-menu": "关闭菜单", @@ -968,7 +1129,7 @@ }, "news": { "drawer": { - "close": "" + "close": "关闭抽屉" }, "title": "最新博客" }, @@ -1055,7 +1216,122 @@ "new-password-same-as-old": "新密码不能和旧密码相同。", "old-password-label": "旧密码", "old-password-required": "旧密码是必需项", - "passwords-must-match": "密码必须一致" + "passwords-must-match": "密码必须一致", + "strong-password-validation-register": "" + } + }, + "public-dashboard": { + "acknowledgment-checkboxes": { + "ack-title": "在发布仪表板前,请确认以下内容:", + "data-src-ack-desc": "发布当前仅适用于数据源子集*", + "data-src-ack-tooltip": "了解更多关于公共数据源的信息", + "public-ack-desc": "您的整个仪表板将公开*", + "public-ack-tooltip": "了解更多关于公共仪表板的信息", + "usage-ack-desc": "公布仪表板将导致其每次被查看时运行查询,这可能会增加费用*", + "usage-ack-desc-tooltip": "了解更多关于查询缓存的信息" + }, + "config": { + "can-view-dashboard-radio-button-label": "可以查看仪表板", + "copy-button": "复制", + "dashboard-url-field-label": "仪表板网址", + "email-share-type-option-label": "仅指定人员", + "pause-sharing-dashboard-label": "暂停共享仪表板", + "public-share-type-option-label": "任何有链接的人", + "revoke-public-URL-button": "撤销公共网址", + "revoke-public-URL-button-title": "注销公共网址", + "settings-title": "设置" + }, + "create-page": { + "generate-public-url-button": "生成公共网址", + "unsupported-features-desc": "目前,我们不支持模板变量或前端数据源", + "welcome-title": "欢迎使用公共仪表板 !" + }, + "delete-modal": { + "revoke-nonorphaned-body-text": "您确定要注销此网址吗?仪表板将不再公开。", + "revoke-orphaned-body-text": "孤立的公共仪表板将不再公开。", + "revoke-title": "注销公共网址" + }, + "email-sharing": { + "input-invalid-email-text": "无效电子邮箱", + "input-required-email-text": "电子邮箱是必填项", + "invite-button": "邀请", + "invite-field-desc": "通过电子邮件邀请人员", + "invite-field-label": "邀请", + "resend-button": "重新发送", + "resend-button-title": "重新发送", + "revoke-button": "注销", + "revoke-button-title": "注销" + }, + "modal-alerts": { + "no-upsert-perm-alert-desc": "请联系您的管理员以获得{{mode}}公共仪表板的权限", + "no-upsert-perm-alert-title": "您没有{{mode}}公共仪表板的权限", + "save-dashboard-changes-alert-title": "请在更新公共配置之前保存您的仪表板更改", + "unsupport-data-source-alert-readmore-link": "阅读更多关于支持的数据源的信息", + "unsupported-data-source-alert-desc": "此仪表板中有公共仪表板不支持的数据源。使用这些数据源的面板可能无法正常运行:{{unsupportedDataSources}}。", + "unsupported-data-source-alert-title": "不受支持的数据源", + "unsupported-template-variable-alert-desc": "此公共仪表板可能无法工作,因为其使用了模板变量", + "unsupported-template-variable-alert-title": "模板变量不受支持" + }, + "settings-bar-header": { + "collapse-settings-tooltip": "折叠设置", + "expand-settings-tooltip": "展开设置" + }, + "settings-configuration": { + "default-time-range-label": "默认时间范围", + "default-time-range-label-desc": "公共仪表板使用仪表板的默认时间范围设置", + "show-annotations-label": "显示注释", + "show-annotations-label-desc": "在公共仪表板上显示注释", + "time-range-picker-label": "已启用时间范围选择器", + "time-range-picker-label-desc": "允许查看者更改时间范围" + }, + "settings-summary": { + "annotations-hide-text": "注释 = 隐藏", + "annotations-show-text": "注释 = 显示", + "time-range-picker-disabled-text": "时间范围选择器 = 已禁用", + "time-range-picker-enabled-text": "时间范围选择器 = 已启用", + "time-range-text": "时间范围 = " + } + }, + "public-dashboard-list": { + "button": { + "config-button-tooltip": "配置公共仪表板", + "revoke-button-text": "注销公共网址", + "revoke-button-tooltip": "撤销公共仪表板网址", + "view-button-tooltip": "查看公共仪表板" + }, + "dashboard-title": { + "orphaned-title": "<0>孤立的公共仪表板", + "orphaned-tooltip": "链接的仪表板已被删除" + }, + "toggle": { + "pause-sharing-toggle-text": "暂停共享" + } + }, + "public-dashboard-users-access-list": { + "dashboard-modal": { + "loading-text": "加载中...", + "open-dashboard-list-text": "打开仪表板列表", + "public-dashboard-link": "公共仪表板网址", + "public-dashboard-setting": "公共仪表板设置" + }, + "delete-user-modal": { + "delete-user-button-text": "删除用户", + "delete-user-cancel-button": "取消", + "delete-user-revoke-access-button": "注销访问权限", + "revoke-access-title": "注销访问权限", + "revoke-user-access-modal-desc-line1": "您确定要注销对 {{email}} 的访问权限吗?", + "revoke-user-access-modal-desc-line2": "此操作将立即注销 {{email}} 对所有公共仪表板的访问权限。" + }, + "modal": { + "dashboard-modal-title": "公共仪表板" + }, + "table-header": { + "activated-label": "已激活", + "activated-tooltip": "最早时间用户已是仪表板的活动用户", + "email-label": "电子邮箱", + "last-active-label": "上次处于活动状态", + "origin-label": "原始", + "role-label": "角色" } }, "query-operation": { @@ -1093,6 +1369,12 @@ "turned-off": "自动刷新关闭" } }, + "return-to-previous": { + "button": { + "label": "返回到 {{title}}" + }, + "dismissable-button": "关闭" + }, "search": { "actions": { "include-panels": "包括面板", @@ -1170,10 +1452,10 @@ "expire-day": "1 天", "expire-hour": "1 小时", "expire-never": "从不", - "expire-week": "7 天", + "expire-week": "", "info-text-1": "可通过快照即时、公开地分享交互式仪表板。创建后,我们会剥离敏感数据,如查询(指标、模板和注释)和面板链接,仅将可见指标数据和系列名称嵌入到仪表板中。", "info-text-2": "请注意,知晓该链接并能够访问该网址的<1>任何人都可以查看您的快照。分享需谨慎。", - "local-button": "本地快照", + "local-button": "", "mistake-message": "是不是弄错了什么?", "name": "快照名称", "timeout": "超时(秒)", @@ -1186,7 +1468,8 @@ "library-panel": "库面板", "link": "链接", "panel-embed": "嵌入", - "public-dashboard": "公共仪表板", + "public-dashboard": "", + "public-dashboard-title": "公共仪表板", "snapshot": "快照" }, "theme-picker": { @@ -1257,7 +1540,7 @@ "calendar": { "apply-button": "应用时间范围", "cancel-button": "取消", - "close": "", + "close": "关闭日历", "select-time": "选择时间范围" }, "content": { @@ -1265,6 +1548,13 @@ "empty-recent-list-info": "看起来您之前没有使用过这个时间选择器。一旦您输入了某些时间间隔,最近使用的间隔就会出现在此处。", "filter-placeholder": "搜索快速范围" }, + "copy-paste": { + "copy-success-message": "时间范围已复制到剪贴板", + "default-error-message": "{{error}} 不是一个有效的日期范围", + "default-error-title": "无效时间范围", + "tooltip-copy": "将时间范围复制到剪贴板", + "tooltip-paste": "粘贴时间范围" + }, "footer": { "change-settings-button": "更改时间设置", "fiscal-year-option": "财政年度", @@ -1277,7 +1567,7 @@ "default-error": "请输入一个过去的日期或\"现在\"", "fiscal-year": "财政年度", "from-input": "发件人", - "open-input-calendar": "", + "open-input-calendar": "打开日历", "range-error": "“发件人”不能在“收件人”之后", "to-input": "结束" }, @@ -1302,8 +1592,8 @@ }, "transformations": { "empty": { - "add-transformation-body": "", - "add-transformation-header": "" + "add-transformation-body": "转换允许在显示可视化之前以各种方式更改数据。 <1>这包括合并数据、重命名字段、进行计算、格式化数据以便显示等等。", + "add-transformation-header": "开始转换数据" } }, "user-orgs": { @@ -1335,6 +1625,11 @@ "user-sessions": { "loading": "正在加载会话..." }, + "users-access-list": { + "tabs": { + "public-dashboard-users-tab-title": "公共仪表板用户" + } + }, "variable": { "adhoc": { "placeholder": "选择值" @@ -1352,4 +1647,4 @@ "placeholder": "输入变量值" } } -} +} \ No newline at end of file diff --git a/public/maps/example-with-style.geojson b/public/maps/example-with-style.geojson new file mode 100644 index 0000000000000..932ed42bce72e --- /dev/null +++ b/public/maps/example-with-style.geojson @@ -0,0 +1,150 @@ +{ + "type": "FeatureCollection", + "name": "Example with Style", + "features": [ + { + "type": "Feature", + "properties": { + "name": "Bermuda Triangle", + "fill": "red", + "fill-opacity": 0.5, + "stroke-width": 10 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-80.226529, 25.789106], + [-66.1057427, 18.4663188], + [-64.78138, 32.294887] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "name": "Bermuda Triangles", + "fill": "red", + "fill-opacity": 0.1, + "stroke-width": 1 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-72.5039545, 29.0419965], + [-73.16613585, 22.1277124], + [-80.226529, 25.789106] + ], + [ + [-65.44356135, 25.3806029], + [-73.16613585, 22.12771244], + [-66.1057427, 18.4663188] + ], + [ + [-65.44356135, 25.3806029], + [-72.5039545, 29.0419965], + [-64.78138, 32.294887] + ] + ] + ] + } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-80.226529, 25.789106] }, + "properties": { + "name": "Miami", + "marker-color": "rgb(255,255,0)", + "marker-size": 20 + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPoint", + "coordinates": [ + [-66.1057427, 18.4663188], + [-64.78138, 32.294887] + ] + }, + "properties": { + "name": "San Juan and Hamilton", + "marker-color": "#F99E26", + "marker-size": 10 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [-77.76975242, 13.2503978], + [-73.16613585, 22.1277124] + ] + }, + "properties": { + "name": "Line 1", + "stroke": "red", + "stroke-width": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [-55.48910779, 24.42726436], + [-65.44356135, 25.3806029] + ] + }, + "properties": { + "name": "Line 2", + "stroke": "white", + "stroke-width": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [-76.38582213, 38.25780378], + [-72.5039545, 29.0419965] + ] + }, + "properties": { + "name": "Line 3", + "stroke": "blue", + "stroke-width": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiLineString", + "coordinates": [ + [ + [-80.226529, 25.789106], + [-66.1057427, 18.4663188] + ], + [ + [-66.1057427, 18.4663188], + [-64.78138, 32.294887] + ], + [ + [-64.78138, 32.294887], + [-80.226529, 25.789106] + ] + ] + }, + "properties": { + "name": "Bermuda Triangle Thin Line", + "stroke": "yellow", + "stroke-width": 1 + } + } + ] +} diff --git a/public/openapi3.json b/public/openapi3.json index e6329c603bf60..ff2350ae0af91 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -1,6 +1,52 @@ { "components": { "responses": { + "GetAllIntervalsResponse": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/GettableTimeIntervals" + }, + "type": "array" + } + } + }, + "description": "(empty)" + }, + "GetIntervalsByNameResponse": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GettableTimeIntervals" + } + } + }, + "description": "(empty)" + }, + "GetReceiverResponse": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GettableApiReceiver" + } + } + }, + "description": "(empty)" + }, + "GetReceiversResponse": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/GettableApiReceiver" + }, + "type": "array" + } + } + }, + "description": "(empty)" + }, "GettableHistoricUserConfigs": { "content": { "application/json": { @@ -377,32 +423,6 @@ }, "description": "(empty)" }, - "deleteAlertNotificationChannelResponse": { - "content": { - "application/json": { - "schema": { - "properties": { - "id": { - "description": "ID Identifier of the deleted notification channel.", - "example": 65, - "format": "int64", - "type": "integer" - }, - "message": { - "description": "Message Message of the deleted notificatiton channel.", - "type": "string" - } - }, - "required": [ - "id", - "message" - ], - "type": "object" - } - } - }, - "description": "(empty)" - }, "deleteCorrelationResponse": { "content": { "application/json": { @@ -519,6 +539,16 @@ }, "description": "(empty)" }, + "devicesSearchResponse": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchDeviceQueryResult" + } + } + }, + "description": "(empty)" + }, "folderResponse": { "content": { "application/json": { @@ -582,65 +612,6 @@ }, "description": "(empty)" }, - "getAlertNotificationChannelResponse": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AlertNotification" - } - } - }, - "description": "(empty)" - }, - "getAlertNotificationChannelsResponse": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/AlertNotification" - }, - "type": "array" - } - } - }, - "description": "(empty)" - }, - "getAlertNotificationLookupResponse": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/AlertNotificationLookup" - }, - "type": "array" - } - } - }, - "description": "(empty)" - }, - "getAlertResponse": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LegacyAlert" - } - } - }, - "description": "(empty)" - }, - "getAlertsResponse": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/AlertListItemDTO" - }, - "type": "array" - } - } - }, - "description": "(empty)" - }, "getAllRolesResponse": { "content": { "application/json": { @@ -749,19 +720,6 @@ "getDashboardSnapshotResponse": { "description": "(empty)" }, - "getDashboardStatesResponse": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/AlertStateInfoDTO" - }, - "type": "array" - } - } - }, - "description": "(empty)" - }, "getDashboardsTagsResponse": { "content": { "application/json": { @@ -1159,6 +1117,31 @@ }, "description": "(empty)" }, + "getSSOSettingsResponse": { + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "settings": { + "additionalProperties": false, + "type": "object" + }, + "source": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "(empty)" + }, "getSharingOptionsResponse": { "content": { "application/json": { @@ -1413,6 +1396,34 @@ }, "description": "(empty)" }, + "listSSOSettingsResponse": { + "content": { + "application/json": { + "schema": { + "items": { + "properties": { + "id": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "settings": { + "additionalProperties": false, + "type": "object" + }, + "source": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + } + }, + "description": "(empty)" + }, "listSortOptionsResponse": { "content": { "application/json": { @@ -1437,103 +1448,80 @@ }, "description": "(empty)" }, - "listTokensResponse": { + "listTeamsRolesResponse": { "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/TokenDTO" + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/RoleDTO" + }, + "type": "array" }, - "type": "array" + "type": "object" } } }, "description": "(empty)" }, - "notFoundError": { + "listTokensResponse": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponseBody" + "items": { + "$ref": "#/components/schemas/TokenDTO" + }, + "type": "array" } } }, - "description": "NotFoundError is returned when the requested resource was not found." + "description": "(empty)" }, - "notFoundPublicError": { + "listUsersRolesResponse": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/publicError" + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/RoleDTO" + }, + "type": "array" + }, + "type": "object" } } }, - "description": "NotFoundPublicError is returned when the requested resource was not found." + "description": "(empty)" }, - "okResponse": { + "notFoundError": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuccessResponseBody" + "$ref": "#/components/schemas/ErrorResponseBody" } } }, - "description": "An OKResponse is returned if the request was successful." + "description": "NotFoundError is returned when the requested resource was not found." }, - "pauseAlertResponse": { + "notFoundPublicError": { "content": { "application/json": { "schema": { - "properties": { - "alertId": { - "format": "int64", - "type": "integer" - }, - "message": { - "type": "string" - }, - "state": { - "description": "Alert result state\nrequired true", - "type": "string" - } - }, - "required": [ - "alertId", - "message" - ], - "type": "object" + "$ref": "#/components/schemas/publicError" } } }, - "description": "(empty)" + "description": "NotFoundPublicError is returned when the requested resource was not found." }, - "pauseAlertsResponse": { + "okResponse": { "content": { "application/json": { "schema": { - "properties": { - "alertsAffected": { - "description": "AlertsAffected is the number of the affected alerts.", - "format": "int64", - "type": "integer" - }, - "message": { - "type": "string" - }, - "state": { - "description": "Alert result state\nrequired true", - "type": "string" - } - }, - "required": [ - "alertsAffected", - "message" - ], - "type": "object" + "$ref": "#/components/schemas/SuccessResponseBody" } } }, - "description": "(empty)" + "description": "An OKResponse is returned if the request was successful." }, "postAPIkeyResponse": { "content": { @@ -1852,16 +1840,6 @@ }, "description": "(empty)" }, - "testAlertResponse": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AlertTestResult" - } - } - }, - "description": "(empty)" - }, "unauthorisedError": { "content": { "application/json": { @@ -1997,6 +1975,10 @@ "format": "int64", "type": "integer" }, + "active_anonymous_devices": { + "format": "int64", + "type": "integer" + }, "active_users": { "format": "int64", "type": "integer" @@ -2196,7 +2178,7 @@ "type": "integer" }, "password": { - "type": "string" + "$ref": "#/components/schemas/Password" } }, "type": "object" @@ -2317,7 +2299,7 @@ "AdminUpdateUserPasswordForm": { "properties": { "password": { - "type": "string" + "$ref": "#/components/schemas/Password" } }, "type": "object" @@ -2389,53 +2371,7 @@ }, "type": "object" }, - "AlertListItemDTO": { - "properties": { - "dashboardId": { - "format": "int64", - "type": "integer" - }, - "dashboardSlug": { - "type": "string" - }, - "dashboardUid": { - "type": "string" - }, - "evalData": { - "$ref": "#/components/schemas/Json" - }, - "evalDate": { - "format": "date-time", - "type": "string" - }, - "executionError": { - "type": "string" - }, - "id": { - "format": "int64", - "type": "integer" - }, - "name": { - "type": "string" - }, - "newStateDate": { - "format": "date-time", - "type": "string" - }, - "panelId": { - "format": "int64", - "type": "integer" - }, - "state": { - "$ref": "#/components/schemas/AlertStateType" - }, - "url": { - "type": "string" - } - }, - "type": "object" - }, - "AlertManager": { + "AlertManager": { "properties": { "url": { "type": "string" @@ -2465,74 +2401,6 @@ "title": "AlertManagersResult contains the result from querying the alertmanagers endpoint.", "type": "object" }, - "AlertNotification": { - "properties": { - "created": { - "format": "date-time", - "type": "string" - }, - "disableResolveMessage": { - "type": "boolean" - }, - "frequency": { - "type": "string" - }, - "id": { - "format": "int64", - "type": "integer" - }, - "isDefault": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "secureFields": { - "additionalProperties": { - "type": "boolean" - }, - "type": "object" - }, - "sendReminder": { - "type": "boolean" - }, - "settings": { - "$ref": "#/components/schemas/Json" - }, - "type": { - "type": "string" - }, - "uid": { - "type": "string" - }, - "updated": { - "format": "date-time", - "type": "string" - } - }, - "type": "object" - }, - "AlertNotificationLookup": { - "properties": { - "id": { - "format": "int64", - "type": "integer" - }, - "isDefault": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - }, - "uid": { - "type": "string" - } - }, - "type": "object" - }, "AlertQuery": { "properties": { "datasourceUid": { @@ -2648,6 +2516,9 @@ ], "type": "string" }, + "notification_settings": { + "$ref": "#/components/schemas/AlertRuleNotificationSettingsExport" + }, "panelId": { "format": "int64", "type": "integer" @@ -2717,84 +2588,88 @@ }, "type": "object" }, - "AlertStateInfoDTO": { + "AlertRuleNotificationSettings": { "properties": { - "dashboardId": { - "format": "int64", - "type": "integer" + "group_by": { + "default": [ + "alertname", + "grafana_folder" + ], + "description": "Override the labels by which incoming alerts are grouped together. For example, multiple alerts coming in for\ncluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels\nuse the special value '...' as the sole label name.\nThis effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what\nyou want, unless you have a very low alert volume or your upstream notification system performs its own grouping.\nMust include 'alertname' and 'grafana_folder' if not using '...'.", + "example": [ + "alertname", + "grafana_folder", + "cluster" + ], + "items": { + "type": "string" + }, + "type": "array" }, - "id": { - "format": "int64", - "type": "integer" + "group_interval": { + "description": "Override how long to wait before sending a notification about new alerts that are added to a group of alerts for\nwhich an initial notification has already been sent. (Usually ~5m or more.)", + "example": "5m", + "type": "string" }, - "newStateDate": { - "format": "date-time", + "group_wait": { + "description": "Override how long to initially wait to send a notification for a group of alerts. Allows to wait for an\ninhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.)", + "example": "30s", "type": "string" }, - "panelId": { - "format": "int64", - "type": "integer" + "mute_time_intervals": { + "description": "Override the times when notifications should be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration mute_time_intervals section. When muted it will not send any notifications, but\notherwise acts normally.", + "example": [ + "maintenance" + ], + "items": { + "type": "string" + }, + "type": "array" }, - "state": { - "$ref": "#/components/schemas/AlertStateType" - } - }, - "type": "object" - }, - "AlertStateType": { - "type": "string" - }, - "AlertTestCommand": { - "properties": { - "dashboard": { - "$ref": "#/components/schemas/Json" + "receiver": { + "description": "Name of the receiver to send notifications to.", + "example": "grafana-default-email", + "type": "string" }, - "panelId": { - "format": "int64", - "type": "integer" + "repeat_interval": { + "description": "Override how long to wait before sending a notification again if it has already been sent successfully for an\nalert. (Usually ~3h or more).\nNote that this parameter is implicitly bound by Alertmanager's `--data.retention` configuration flag.\nNotifications will be resent after either repeat_interval or the data retention period have passed, whichever\noccurs first. `repeat_interval` should not be less than `group_interval`.", + "example": "4h", + "type": "string" } }, + "required": [ + "receiver" + ], "type": "object" }, - "AlertTestResult": { + "AlertRuleNotificationSettingsExport": { "properties": { - "conditionEvals": { - "type": "string" - }, - "error": { - "type": "string" - }, - "firing": { - "type": "boolean" - }, - "logs": { + "group_by": { "items": { - "$ref": "#/components/schemas/AlertTestResultLog" + "type": "string" }, "type": "array" }, - "matches": { + "group_interval": { + "type": "string" + }, + "group_wait": { + "type": "string" + }, + "mute_time_intervals": { "items": { - "$ref": "#/components/schemas/EvalMatch" + "type": "string" }, "type": "array" }, - "state": { - "$ref": "#/components/schemas/AlertStateType" - }, - "timeMs": { + "receiver": { "type": "string" - } - }, - "type": "object" - }, - "AlertTestResultLog": { - "properties": { - "data": {}, - "message": { + }, + "repeat_interval": { "type": "string" } }, + "title": "AlertRuleNotificationSettingsExport is the provisioned export of models.NotificationSettings.", "type": "object" }, "AlertingFileExport": { @@ -3588,10 +3463,10 @@ "ChangeUserPasswordCommand": { "properties": { "newPassword": { - "type": "string" + "$ref": "#/components/schemas/Password" }, "oldPassword": { - "type": "string" + "$ref": "#/components/schemas/Password" } }, "type": "object" @@ -3613,6 +3488,7 @@ "type": "array" }, "mute_time_intervals": { + "description": "MuteTimeIntervals is deprecated and will be removed before Alertmanager 1.0.", "items": { "$ref": "#/components/schemas/MuteTimeInterval" }, @@ -3626,6 +3502,12 @@ "type": "string" }, "type": "array" + }, + "time_intervals": { + "items": { + "$ref": "#/components/schemas/TimeInterval" + }, + "type": "array" } }, "title": "Config is the top-level configuration for Alertmanager's config files.", @@ -3799,41 +3681,6 @@ "title": "CounterResetHint contains the known information about a counter reset,", "type": "integer" }, - "CreateAlertNotificationCommand": { - "properties": { - "disableResolveMessage": { - "type": "boolean" - }, - "frequency": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "secureSettings": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "sendReminder": { - "type": "boolean" - }, - "settings": { - "$ref": "#/components/schemas/Json" - }, - "type": { - "type": "string" - }, - "uid": { - "type": "string" - } - }, - "type": "object" - }, "CreateCorrelationCommand": { "description": "CreateCorrelationCommand is the command for creating a correlation", "properties": { @@ -3877,8 +3724,12 @@ }, "CreateDashboardSnapshotCommand": { "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional", + "type": "string" + }, "dashboard": { - "$ref": "#/components/schemas/Json" + "$ref": "#/components/schemas/Unstructured" }, "deleteKey": { "description": "Unique key used to delete the snapshot. It is different from the `key` so that only the creator can delete the snapshot. Required if `external` is `true`.", @@ -3899,6 +3750,10 @@ "description": "Define the unique key. Required if `external` is `true`.", "type": "string" }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional", + "type": "string" + }, "name": { "description": "Snapshot name", "type": "string" @@ -4241,27 +4096,62 @@ }, "type": "object" }, - "DashboardFullWithMeta": { + "DashboardCreateCommand": { + "description": "These are the values expected to be sent from an end user\n+k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object", "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional", + "type": "string" + }, "dashboard": { - "$ref": "#/components/schemas/Json" + "$ref": "#/components/schemas/Unstructured" }, - "meta": { - "$ref": "#/components/schemas/DashboardMeta" - } - }, - "type": "object" - }, - "DashboardMeta": { - "properties": { - "annotationsPermissions": { - "$ref": "#/components/schemas/AnnotationPermission" + "expires": { + "default": 0, + "description": "When the snapshot should expire in seconds in seconds. Default is never to expire.", + "format": "int64", + "type": "integer" }, - "canAdmin": { + "external": { + "default": false, + "description": "these are passed when storing an external snapshot ref\nSave the snapshot on an external server rather than locally.", "type": "boolean" }, - "canDelete": { - "type": "boolean" + "kind": { + "description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional", + "type": "string" + }, + "name": { + "description": "Snapshot name", + "type": "string" + } + }, + "required": [ + "dashboard" + ], + "type": "object" + }, + "DashboardFullWithMeta": { + "properties": { + "dashboard": { + "$ref": "#/components/schemas/Json" + }, + "meta": { + "$ref": "#/components/schemas/DashboardMeta" + } + }, + "type": "object" + }, + "DashboardMeta": { + "properties": { + "annotationsPermissions": { + "$ref": "#/components/schemas/AnnotationPermission" + }, + "canAdmin": { + "type": "boolean" + }, + "canDelete": { + "type": "boolean" }, "canEdit": { "type": "boolean" @@ -4657,6 +4547,32 @@ }, "type": "object" }, + "DeviceSearchHitDTO": { + "properties": { + "clientIp": { + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "lastSeenAt": { + "format": "date-time", + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "userAgent": { + "type": "string" + } + }, + "type": "object" + }, "DiscordConfig": { "properties": { "http_config": { @@ -4673,6 +4589,9 @@ }, "webhook_url": { "$ref": "#/components/schemas/SecretURL" + }, + "webhook_url_file": { + "type": "string" } }, "title": "DiscordConfig configures notifications via Discord.", @@ -4913,23 +4832,6 @@ }, "type": "object" }, - "EvalMatch": { - "properties": { - "metric": { - "type": "string" - }, - "tags": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "value": { - "type": "string" - } - }, - "type": "object" - }, "EvalQueriesPayload": { "properties": { "condition": { @@ -5370,13 +5272,22 @@ }, "typeVersion": { "$ref": "#/components/schemas/FrameTypeVersion" + }, + "uniqueRowIdFields": { + "description": "Array of field indices which values create a unique id for each row. Ideally this should be globally unique ID\nbut that isn't guarantied. Should help with keeping track and deduplicating rows in visualizations, especially\nwith streaming data with frequent updates.", + "example": "TraceID in Tempo, table name + primary key in SQL", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" } }, "title": "FrameMeta matches:", "type": "object" }, "FrameType": { - "description": "A FrameType string, when present in a frame's metadata, asserts that the\nframe's structure conforms to the FrameType's specification.\nThis property is currently optional, so FrameType may be FrameTypeUnknown even if the properties of\nthe Frame correspond to a defined FrameType.", + "description": "A FrameType string, when present in a frame's metadata, asserts that the\nframe's structure conforms to the FrameType's specification.\nThis property is currently optional, so FrameType may be FrameTypeUnknown even if the properties of\nthe Frame correspond to a defined FrameType.\n+enum", "type": "string" }, "FrameTypeVersion": { @@ -5395,6 +5306,14 @@ "title": "Frames is a slice of Frame pointers.", "type": "array" }, + "GenericPublicError": { + "properties": { + "body": { + "$ref": "#/components/schemas/PublicError" + } + }, + "type": "object" + }, "GetAnnotationTagsResponse": { "properties": { "result": { @@ -5457,6 +5376,7 @@ "type": "object" }, "mute_time_intervals": { + "description": "MuteTimeIntervals is deprecated and will be removed before Alertmanager 1.0.", "items": { "$ref": "#/components/schemas/MuteTimeInterval" }, @@ -5477,6 +5397,12 @@ "type": "string" }, "type": "array" + }, + "time_intervals": { + "items": { + "$ref": "#/components/schemas/TimeInterval" + }, + "type": "array" } }, "type": "object" @@ -5690,6 +5616,9 @@ ], "type": "string" }, + "notification_settings": { + "$ref": "#/components/schemas/AlertRuleNotificationSettings" + }, "orgId": { "format": "int64", "type": "integer" @@ -5806,6 +5735,23 @@ ], "type": "object" }, + "GettableTimeIntervals": { + "properties": { + "name": { + "type": "string" + }, + "provenance": { + "$ref": "#/components/schemas/Provenance" + }, + "time_intervals": { + "items": { + "$ref": "#/components/schemas/TimeIntervalItem" + }, + "type": "array" + } + }, + "type": "object" + }, "GettableUserConfig": { "properties": { "alertmanager_config": { @@ -6234,6 +6180,7 @@ "type": "object" }, "JSONWebKey": { + "description": "JSONWebKey represents a public or private key in JWK format. It can be\nmarshaled into JSON and unmarshaled from JSON.", "properties": { "Algorithm": { "description": "Key algorithm, parsed from `alg` header.", @@ -6266,7 +6213,7 @@ "$ref": "#/components/schemas/URL" }, "Key": { - "description": "Cryptographic key, can be a symmetric or asymmetric key." + "description": "Key is the Go in-memory representation of this key. It must have one\nof these types:\ned25519.PublicKey\ned25519.PrivateKey\necdsa.PublicKey\necdsa.PrivateKey\nrsa.PublicKey\nrsa.PrivateKey\n[]byte (a symmetric key)\n\nWhen marshaling this JSONWebKey into JSON, the \"kty\" header parameter\nwill be automatically set based on the type of this field." }, "KeyID": { "description": "Key identifier, parsed from `kid` header.", @@ -6277,7 +6224,6 @@ "type": "string" } }, - "title": "JSONWebKey represents a public or private key in JWK format.", "type": "object" }, "Json": { @@ -6326,82 +6272,6 @@ }, "type": "array" }, - "LegacyAlert": { - "properties": { - "Created": { - "format": "date-time", - "type": "string" - }, - "DashboardID": { - "format": "int64", - "type": "integer" - }, - "EvalData": { - "$ref": "#/components/schemas/Json" - }, - "ExecutionError": { - "type": "string" - }, - "For": { - "$ref": "#/components/schemas/Duration" - }, - "Frequency": { - "format": "int64", - "type": "integer" - }, - "Handler": { - "format": "int64", - "type": "integer" - }, - "ID": { - "format": "int64", - "type": "integer" - }, - "Message": { - "type": "string" - }, - "Name": { - "type": "string" - }, - "NewStateDate": { - "format": "date-time", - "type": "string" - }, - "OrgID": { - "format": "int64", - "type": "integer" - }, - "PanelID": { - "format": "int64", - "type": "integer" - }, - "Settings": { - "$ref": "#/components/schemas/Json" - }, - "Severity": { - "type": "string" - }, - "Silenced": { - "type": "boolean" - }, - "State": { - "$ref": "#/components/schemas/AlertStateType" - }, - "StateChanges": { - "format": "int64", - "type": "integer" - }, - "Updated": { - "format": "date-time", - "type": "string" - }, - "Version": { - "format": "int64", - "type": "integer" - } - }, - "type": "object" - }, "LibraryElementArrayResponse": { "properties": { "result": { @@ -6623,6 +6493,9 @@ "send_resolved": { "type": "boolean" }, + "summary": { + "type": "string" + }, "text": { "type": "string" }, @@ -6631,6 +6504,9 @@ }, "webhook_url": { "$ref": "#/components/schemas/SecretURL" + }, + "webhook_url_file": { + "type": "string" } }, "type": "object" @@ -6972,39 +6848,6 @@ }, "type": "array" }, - "NotificationTestCommand": { - "properties": { - "disableResolveMessage": { - "type": "boolean" - }, - "frequency": { - "type": "string" - }, - "id": { - "format": "int64", - "type": "integer" - }, - "name": { - "type": "string" - }, - "secureSettings": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "sendReminder": { - "type": "boolean" - }, - "settings": { - "$ref": "#/components/schemas/Json" - }, - "type": { - "type": "string" - } - }, - "type": "object" - }, "NotifierConfig": { "properties": { "send_resolved": { @@ -7069,8 +6912,19 @@ "title": "An ObjectIdentifier represents an ASN.1 OBJECT IDENTIFIER.", "type": "array" }, + "ObjectMatcher": { + "items": { + "type": "string" + }, + "title": "ObjectMatcher is a matcher that can be used to filter alerts.", + "type": "array" + }, "ObjectMatchers": { - "$ref": "#/components/schemas/Matchers" + "items": { + "$ref": "#/components/schemas/ObjectMatcher" + }, + "title": "ObjectMatchers is a list of matchers that can be used to filter alerts.", + "type": "array" }, "OpsGenieConfig": { "properties": { @@ -7327,6 +7181,9 @@ }, "type": "object" }, + "Password": { + "type": "string" + }, "PatchAnnotationsCmd": { "properties": { "data": { @@ -7449,26 +7306,6 @@ }, "type": "object" }, - "PauseAlertCommand": { - "properties": { - "alertId": { - "format": "int64", - "type": "integer" - }, - "paused": { - "type": "boolean" - } - }, - "type": "object" - }, - "PauseAllAlertsCommand": { - "properties": { - "paused": { - "type": "boolean" - } - }, - "type": "object" - }, "Permission": { "properties": { "action": { @@ -7619,24 +7456,6 @@ }, "type": "array" }, - "Point": { - "description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.", - "properties": { - "H": { - "$ref": "#/components/schemas/FloatHistogram" - }, - "T": { - "format": "int64", - "type": "integer" - }, - "V": { - "format": "double", - "type": "number" - } - }, - "title": "Point represents a single data point for a given timestamp.", - "type": "object" - }, "PostAnnotationsCmd": { "properties": { "dashboardId": { @@ -7693,6 +7512,7 @@ "type": "object" }, "PostableApiAlertingConfig": { + "description": "nolint:revive", "properties": { "global": { "$ref": "#/components/schemas/GlobalConfig" @@ -7704,6 +7524,7 @@ "type": "array" }, "mute_time_intervals": { + "description": "MuteTimeIntervals is deprecated and will be removed before Alertmanager 1.0.", "items": { "$ref": "#/components/schemas/MuteTimeInterval" }, @@ -7724,11 +7545,18 @@ "type": "string" }, "type": "array" + }, + "time_intervals": { + "items": { + "$ref": "#/components/schemas/TimeInterval" + }, + "type": "array" } }, "type": "object" }, "PostableApiReceiver": { + "description": "nolint:revive", "properties": { "discord_configs": { "items": { @@ -7946,6 +7774,9 @@ ], "type": "string" }, + "notification_settings": { + "$ref": "#/components/schemas/AlertRuleNotificationSettings" + }, "title": { "type": "string" }, @@ -7985,11 +7816,25 @@ }, "type": "object" }, - "PostableUserConfig": { + "PostableTimeIntervals": { "properties": { - "alertmanager_config": { - "$ref": "#/components/schemas/PostableApiAlertingConfig" - }, + "name": { + "type": "string" + }, + "time_intervals": { + "items": { + "$ref": "#/components/schemas/TimeIntervalItem" + }, + "type": "array" + } + }, + "type": "object" + }, + "PostableUserConfig": { + "properties": { + "alertmanager_config": { + "$ref": "#/components/schemas/PostableApiAlertingConfig" + }, "template_files": { "additionalProperties": { "type": "string" @@ -8154,6 +7999,9 @@ ], "type": "string" }, + "notification_settings": { + "$ref": "#/components/schemas/AlertRuleNotificationSettings" + }, "orgID": { "format": "int64", "type": "integer" @@ -8423,7 +8271,7 @@ "QueryDataResponse": { "description": "It is the return type of a QueryData call.", "properties": { - "Responses": { + "results": { "$ref": "#/components/schemas/Responses" } }, @@ -8879,6 +8727,9 @@ "templateVars": { "type": "object" }, + "uid": { + "type": "string" + }, "updated": { "format": "date-time", "type": "string" @@ -8941,9 +8792,6 @@ }, "ReportEmail": { "properties": { - "email": { - "type": "string" - }, "emails": { "description": "Comma-separated list of emails to which to send the report to.", "type": "string" @@ -8976,9 +8824,6 @@ }, "ReportSchedule": { "properties": { - "day": { - "type": "string" - }, "dayOfMonth": { "type": "string" }, @@ -8989,10 +8834,6 @@ "frequency": { "type": "string" }, - "hour": { - "format": "int64", - "type": "integer" - }, "intervalAmount": { "format": "int64", "type": "integer" @@ -9000,10 +8841,6 @@ "intervalFrequency": { "type": "string" }, - "minute": { - "format": "int64", - "type": "integer" - }, "startDate": { "format": "date-time", "type": "string" @@ -9158,6 +8995,32 @@ }, "type": "object" }, + "RolesSearchQuery": { + "properties": { + "includeHidden": { + "type": "boolean" + }, + "orgId": { + "format": "int64", + "type": "integer" + }, + "teamIds": { + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "userIds": { + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + } + }, + "type": "object" + }, "Route": { "description": "A Route is a node that contains definitions of how to handle alerts. This is modified\nfrom the upstream alertmanager in that it adds the ObjectMatchers property.", "properties": { @@ -9458,26 +9321,13 @@ }, "type": "object" }, - "SSOSettings": { - "properties": { - "id": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "settings": { - "additionalProperties": false, - "type": "object" - }, - "source": { - "$ref": "#/components/schemas/SettingsSource" - } - }, - "type": "object" - }, "Sample": { + "description": "Sample is a single sample belonging to a metric. It represents either a float\nsample or a histogram sample. If H is nil, it is a float sample. Otherwise,\nit is a histogram sample.", "properties": { + "F": { + "format": "double", + "type": "number" + }, "H": { "$ref": "#/components/schemas/FloatHistogram" }, @@ -9487,13 +9337,8 @@ "T": { "format": "int64", "type": "integer" - }, - "V": { - "format": "double", - "type": "number" } }, - "title": "Sample is a single sample belonging to a metric.", "type": "object" }, "SaveDashboardCommand": { @@ -9529,6 +9374,29 @@ }, "type": "object" }, + "SearchDeviceQueryResult": { + "properties": { + "devices": { + "items": { + "$ref": "#/components/schemas/DeviceSearchHitDTO" + }, + "type": "array" + }, + "page": { + "format": "int64", + "type": "integer" + }, + "perPage": { + "format": "int64", + "type": "integer" + }, + "totalCount": { + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, "SearchOrgServiceAccountsResult": { "description": "swagger: model", "properties": { @@ -9799,6 +9667,25 @@ }, "type": "object" }, + "SetResourcePermissionCommand": { + "properties": { + "builtInRole": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "teamId": { + "format": "int64", + "type": "integer" + }, + "userId": { + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, "SetRoleAssignmentsCommand": { "properties": { "service_accounts": { @@ -9851,10 +9738,6 @@ }, "type": "object" }, - "SettingsSource": { - "format": "int64", - "type": "integer" - }, "ShareType": { "type": "string" }, @@ -10567,7 +10450,21 @@ "type": "string" }, "TimeInterval": { - "description": "TimeInterval describes intervals of time. ContainsTime will tell you if a golang time is contained\nwithin the interval.", + "properties": { + "name": { + "type": "string" + }, + "time_intervals": { + "items": { + "$ref": "#/components/schemas/TimeInterval" + }, + "type": "array" + } + }, + "title": "TimeInterval represents a named set of time intervals for which a route should be muted.", + "type": "object" + }, + "TimeIntervalItem": { "properties": { "days_of_month": { "items": { @@ -10586,7 +10483,7 @@ }, "times": { "items": { - "$ref": "#/components/schemas/TimeRange" + "$ref": "#/components/schemas/TimeIntervalTimeRange" }, "type": "array" }, @@ -10605,6 +10502,17 @@ }, "type": "object" }, + "TimeIntervalTimeRange": { + "properties": { + "end_time": { + "type": "string" + }, + "start_time": { + "type": "string" + } + }, + "type": "object" + }, "TimeRange": { "description": "Redefining this to avoid an import cycle", "properties": { @@ -10624,6 +10532,10 @@ "account": { "type": "string" }, + "anonymousRatio": { + "format": "int64", + "type": "integer" + }, "company": { "type": "string" }, @@ -10782,6 +10694,21 @@ "Type": { "type": "string" }, + "TypeMeta": { + "description": "+k8s:deepcopy-gen=false", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional", + "type": "string" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional", + "type": "string" + } + }, + "title": "TypeMeta describes an individual object in an API response or request\nwith strings representing the type of the object and its API schema version.\nStructures that are versioned or persisted should inline TypeMeta.", + "type": "object" + }, "URL": { "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "properties": { @@ -10822,76 +10749,13 @@ "title": "A URL represents a parsed URL (technically, a URI reference).", "type": "object" }, - "UpdateAlertNotificationCommand": { - "properties": { - "disableResolveMessage": { - "type": "boolean" - }, - "frequency": { - "type": "string" - }, - "id": { - "format": "int64", - "type": "integer" - }, - "isDefault": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "secureSettings": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "sendReminder": { - "type": "boolean" - }, - "settings": { - "$ref": "#/components/schemas/Json" - }, - "type": { - "type": "string" - }, - "uid": { - "type": "string" - } - }, - "type": "object" - }, - "UpdateAlertNotificationWithUidCommand": { + "Unstructured": { + "description": "Unstructured allows objects that do not have Golang structs registered to be manipulated\ngenerically.", "properties": { - "disableResolveMessage": { - "type": "boolean" - }, - "frequency": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "secureSettings": { - "additionalProperties": { - "type": "string" - }, + "Object": { + "additionalProperties": false, + "description": "Object is a JSON compatible map with string, float, int, bool, []interface{},\nor map[string]interface{} children.", "type": "object" - }, - "sendReminder": { - "type": "boolean" - }, - "settings": { - "$ref": "#/components/schemas/Json" - }, - "type": { - "type": "string" - }, - "uid": { - "type": "string" } }, "type": "object" @@ -11378,6 +11242,9 @@ "theme": { "type": "string" }, + "uid": { + "type": "string" + }, "updatedAt": { "format": "date-time", "type": "string" @@ -11421,6 +11288,9 @@ }, "name": { "type": "string" + }, + "uid": { + "type": "string" } }, "type": "object" @@ -11501,7 +11371,7 @@ "type": "array" }, "Vector": { - "description": "Vector is basically only an alias for model.Samples, but the\ncontract is that in a Vector, all Samples have the same timestamp.", + "description": "Vector is basically only an alias for []Sample, but the contract is that\nin a Vector, all Samples have the same timestamp.", "items": { "$ref": "#/components/schemas/Sample" }, @@ -11654,6 +11524,7 @@ "type": "object" }, "alertGroup": { + "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -11919,12 +11790,14 @@ "type": "object" }, "gettableSilences": { + "description": "GettableSilences gettable silences", "items": { "$ref": "#/components/schemas/gettableSilence" }, "type": "array" }, "integration": { + "description": "Integration integration", "properties": { "lastNotifyAttempt": { "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", @@ -12068,7 +11941,6 @@ "type": "array" }, "postableSilence": { - "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -12212,6 +12084,25 @@ }, "type": "object" }, + "setPermissionCommand": { + "properties": { + "permission": { + "type": "string" + } + }, + "type": "object" + }, + "setPermissionsCommand": { + "properties": { + "permissions": { + "items": { + "$ref": "#/components/schemas/SetResourcePermissionCommand" + }, + "type": "array" + } + }, + "type": "object" + }, "silence": { "description": "Silence silence", "properties": { @@ -12644,18 +12535,54 @@ ] } }, - "/access-control/teams/{teamId}/roles": { - "get": { - "description": "You need to have a permission with action `teams.roles:read` and scope `teams:id:\u003cteam ID\u003e`.", - "operationId": "listTeamRoles", - "parameters": [ - { - "in": "path", - "name": "teamId", - "required": true, - "schema": { - "format": "int64", - "type": "integer" + "/access-control/teams/roles/search": { + "post": { + "description": "Lists the roles that have been directly assigned to the given teams.\n\nYou need to have a permission with action `teams.roles:read` and scope `teams:id:*`.", + "operationId": "listTeamsRoles", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RolesSearchQuery" + } + } + }, + "required": true, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/listTeamsRolesResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "List roles assigned to multiple teams.", + "tags": [ + "access_control", + "enterprise" + ] + } + }, + "/access-control/teams/{teamId}/roles": { + "get": { + "description": "You need to have a permission with action `teams.roles:read` and scope `teams:id:\u003cteam ID\u003e`.", + "operationId": "listTeamRoles", + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "format": "int64", + "type": "integer" } } ], @@ -12812,6 +12739,42 @@ ] } }, + "/access-control/users/roles/search": { + "post": { + "description": "Lists the roles that have been directly assigned to the given users. The list does not include built-in roles (Viewer, Editor, Admin or Grafana Admin), and it does not include roles that have been inherited from a team.\n\nYou need to have a permission with action `users.roles:read` and scope `users:id:*`.", + "operationId": "listUsersRoles", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RolesSearchQuery" + } + } + }, + "required": true, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/listUsersRolesResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "List roles assigned to multiple users.", + "tags": [ + "access_control", + "enterprise" + ] + } + }, "/access-control/users/{userId}/roles": { "get": { "description": "Lists the roles that have been directly assigned to a given user. The list does not include built-in roles (Viewer, Editor, Admin or Grafana Admin), and it does not include roles that have been inherited from a team.\n\nYou need to have a permission with action `users.roles:read` and scope `users:id:\u003cuser ID\u003e`.", @@ -12996,16 +12959,22 @@ ] } }, - "/admin/ldap-sync-status": { + "/access-control/{resource}/description": { "get": { - "description": "You need to have a permission with action `ldap.status:read`.", - "operationId": "getSyncStatus", + "operationId": "getResourceDescription", + "parameters": [ + { + "in": "path", + "name": "resource", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "$ref": "#/components/responses/getSyncStatusResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "$ref": "#/components/responses/resourcePermissionsDescription" }, "403": { "$ref": "#/components/responses/forbiddenError" @@ -13014,167 +12983,333 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Returns the current state of the LDAP background sync integration.", + "summary": "Get a description of a resource's access control properties.", "tags": [ - "ldap_debug", - "enterprise" + "access_control" ] } }, - "/admin/ldap/reload": { - "post": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.config:reload`.", - "operationId": "reloadLDAPCfg", + "/access-control/{resource}/{resourceID}": { + "get": { + "operationId": "getResourcePermissions", + "parameters": [ + { + "in": "path", + "name": "resource", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "resourceID", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "$ref": "#/components/responses/getResourcePermissionsResponse" }, "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "security": [ + "summary": "Get permissions for a resource.", + "tags": [ + "access_control" + ] + }, + "post": { + "description": "Assigns permissions for a resource by a given type (`:resource`) and `:resourceID` to one or many\nassignment types. Allowed resources are `datasources`, `teams`, `dashboards`, `folders`, and `serviceaccounts`.\nRefer to the `/access-control/{resource}/description` endpoint for allowed Permissions.", + "operationId": "setResourcePermissions", + "parameters": [ { - "basic": [] + "in": "path", + "name": "resource", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "resourceID", + "required": true, + "schema": { + "type": "string" + } } ], - "summary": "Reloads the LDAP configuration.", - "tags": [ - "admin_ldap" - ] - } - }, - "/admin/ldap/status": { - "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.status:read`.", - "operationId": "getLDAPStatus", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/setPermissionsCommand" + } + } + }, + "required": true, + "x-originalParamName": "Body" + }, "responses": { "200": { "$ref": "#/components/responses/okResponse" }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "400": { + "$ref": "#/components/responses/badRequestError" }, "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "security": [ - { - "basic": [] - } - ], - "summary": "Attempts to connect to all the configured LDAP servers and returns information on whenever they're available or not.", + "summary": "Set resource permissions.", "tags": [ - "admin_ldap" + "access_control" ] } }, - "/admin/ldap/sync/{user_id}": { + "/access-control/{resource}/{resourceID}/builtInRoles/{builtInRole}": { "post": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.user:sync`.", - "operationId": "postSyncUserWithLDAP", + "description": "Assigns permissions for a resource by a given type (`:resource`) and `:resourceID` to a built-in role.\nAllowed resources are `datasources`, `teams`, `dashboards`, `folders`, and `serviceaccounts`.\nRefer to the `/access-control/{resource}/description` endpoint for allowed Permissions.", + "operationId": "setResourcePermissionsForBuiltInRole", "parameters": [ { "in": "path", - "name": "user_id", + "name": "resource", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" + } + }, + { + "in": "path", + "name": "resourceID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "builtInRole", + "required": true, + "schema": { + "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/setPermissionCommand" + } + } + }, + "required": true, + "x-originalParamName": "Body" + }, "responses": { "200": { "$ref": "#/components/responses/okResponse" }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "400": { + "$ref": "#/components/responses/badRequestError" }, "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "security": [ - { - "basic": [] - } - ], - "summary": "Enables a single Grafana user to be synchronized against LDAP.", + "summary": "Set resource permissions for a built-in role.", "tags": [ - "admin_ldap" + "access_control" ] } }, - "/admin/ldap/{user_name}": { - "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.user:read`.", - "operationId": "getUserFromLDAP", + "/access-control/{resource}/{resourceID}/teams/{teamID}": { + "post": { + "description": "Assigns permissions for a resource by a given type (`:resource`) and `:resourceID` to a team.\nAllowed resources are `datasources`, `teams`, `dashboards`, `folders`, and `serviceaccounts`.\nRefer to the `/access-control/{resource}/description` endpoint for allowed Permissions.", + "operationId": "setResourcePermissionsForTeam", "parameters": [ { "in": "path", - "name": "user_name", + "name": "resource", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "resourceID", "required": true, "schema": { "type": "string" } + }, + { + "in": "path", + "name": "teamID", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/setPermissionCommand" + } + } + }, + "required": true, + "x-originalParamName": "Body" + }, "responses": { "200": { "$ref": "#/components/responses/okResponse" }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "400": { + "$ref": "#/components/responses/badRequestError" }, "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "security": [ - { - "basic": [] - } - ], - "summary": "Finds an user based on a username in LDAP. This helps illustrate how would the particular user be mapped in Grafana when synced.", + "summary": "Set resource permissions for a team.", "tags": [ - "admin_ldap" + "access_control" ] } }, - "/admin/pause-all-alerts": { + "/access-control/{resource}/{resourceID}/users/{userID}": { "post": { - "operationId": "pauseAllAlerts", + "description": "Assigns permissions for a resource by a given type (`:resource`) and `:resourceID` to a user or a service account.\nAllowed resources are `datasources`, `teams`, `dashboards`, `folders`, and `serviceaccounts`.\nRefer to the `/access-control/{resource}/description` endpoint for allowed Permissions.", + "operationId": "setResourcePermissionsForUser", + "parameters": [ + { + "in": "path", + "name": "resource", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "resourceID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "userID", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PauseAllAlertsCommand" + "$ref": "#/components/schemas/setPermissionCommand" } } }, "required": true, - "x-originalParamName": "body" + "x-originalParamName": "Body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Set resource permissions for a user.", + "tags": [ + "access_control" + ] + } + }, + "/admin/ldap-sync-status": { + "get": { + "description": "You need to have a permission with action `ldap.status:read`.", + "operationId": "getSyncStatus", + "responses": { + "200": { + "$ref": "#/components/responses/getSyncStatusResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } }, + "summary": "Returns the current state of the LDAP background sync integration.", + "tags": [ + "ldap_debug", + "enterprise" + ] + } + }, + "/admin/ldap/reload": { + "post": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.config:reload`.", + "operationId": "reloadLDAPCfg", "responses": { "200": { - "$ref": "#/components/responses/pauseAlertsResponse" + "$ref": "#/components/responses/okResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -13191,37 +13326,56 @@ "basic": [] } ], - "summary": "Pause/unpause all (legacy) alerts.", + "summary": "Reloads the LDAP configuration.", "tags": [ - "admin" + "admin_ldap" ] } }, - "/admin/provisioning/access-control/reload": { - "post": { - "operationId": "adminProvisioningReloadAccessControl", + "/admin/ldap/status": { + "get": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.status:read`.", + "operationId": "getLDAPStatus", "responses": { - "202": { - "$ref": "#/components/responses/acceptedResponse" + "200": { + "$ref": "#/components/responses/okResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, "403": { "$ref": "#/components/responses/forbiddenError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "You need to have a permission with action `provisioning:reload` with scope `provisioners:accesscontrol`.", + "security": [ + { + "basic": [] + } + ], + "summary": "Attempts to connect to all the configured LDAP servers and returns information on whenever they're available or not.", "tags": [ - "access_control_provisioning", - "enterprise" + "admin_ldap" ] } }, - "/admin/provisioning/dashboards/reload": { + "/admin/ldap/sync/{user_id}": { "post": { - "description": "Reloads the provisioning config files for dashboards again. It won’t return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `provisioning:reload` and scope `provisioners:dashboards`.", - "operationId": "adminProvisioningReloadDashboards", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.user:sync`.", + "operationId": "postSyncUserWithLDAP", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], "responses": { "200": { "$ref": "#/components/responses/okResponse" @@ -13241,16 +13395,76 @@ "basic": [] } ], - "summary": "Reload dashboard provisioning configurations.", + "summary": "Enables a single Grafana user to be synchronized against LDAP.", "tags": [ - "admin_provisioning" + "admin_ldap" ] } }, - "/admin/provisioning/datasources/reload": { + "/admin/ldap/{user_name}": { + "get": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.user:read`.", + "operationId": "getUserFromLDAP", + "parameters": [ + { + "in": "path", + "name": "user_name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/okResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "security": [ + { + "basic": [] + } + ], + "summary": "Finds an user based on a username in LDAP. This helps illustrate how would the particular user be mapped in Grafana when synced.", + "tags": [ + "admin_ldap" + ] + } + }, + "/admin/provisioning/access-control/reload": { "post": { - "description": "Reloads the provisioning config files for datasources again. It won’t return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `provisioning:reload` and scope `provisioners:datasources`.", - "operationId": "adminProvisioningReloadDatasources", + "operationId": "adminProvisioningReloadAccessControl", + "responses": { + "202": { + "$ref": "#/components/responses/acceptedResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + } + }, + "summary": "You need to have a permission with action `provisioning:reload` with scope `provisioners:accesscontrol`.", + "tags": [ + "access_control_provisioning", + "enterprise" + ] + } + }, + "/admin/provisioning/dashboards/reload": { + "post": { + "description": "Reloads the provisioning config files for dashboards again. It won’t return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `provisioning:reload` and scope `provisioners:dashboards`.", + "operationId": "adminProvisioningReloadDashboards", "responses": { "200": { "$ref": "#/components/responses/okResponse" @@ -13270,16 +13484,16 @@ "basic": [] } ], - "summary": "Reload datasource provisioning configurations.", + "summary": "Reload dashboard provisioning configurations.", "tags": [ "admin_provisioning" ] } }, - "/admin/provisioning/notifications/reload": { + "/admin/provisioning/datasources/reload": { "post": { - "description": "Reloads the provisioning config files for legacy alert notifiers again. It won’t return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `provisioning:reload` and scope `provisioners:notifications`.", - "operationId": "adminProvisioningReloadNotifications", + "description": "Reloads the provisioning config files for datasources again. It won’t return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `provisioning:reload` and scope `provisioners:datasources`.", + "operationId": "adminProvisioningReloadDatasources", "responses": { "200": { "$ref": "#/components/responses/okResponse" @@ -13299,7 +13513,7 @@ "basic": [] } ], - "summary": "Reload legacy alert notifier provisioning configurations.", + "summary": "Reload datasource provisioning configurations.", "tags": [ "admin_provisioning" ] @@ -13910,73 +14124,150 @@ ] } }, - "/alert-notifications": { + "/annotations": { "get": { - "description": "Returns all notification channels that the authenticated user has permission to view.", - "operationId": "getAlertNotificationChannels", - "responses": { - "200": { - "$ref": "#/components/responses/getAlertNotificationChannelsResponse" + "description": "Starting in Grafana v6.4 regions annotations are now returned in one entity that now includes the timeEnd property.", + "operationId": "getAnnotations", + "parameters": [ + { + "description": "Find annotations created after specific epoch datetime in milliseconds.", + "in": "query", + "name": "from", + "schema": { + "format": "int64", + "type": "integer" + } }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + { + "description": "Find annotations created before specific epoch datetime in milliseconds.", + "in": "query", + "name": "to", + "schema": { + "format": "int64", + "type": "integer" + } }, - "403": { - "$ref": "#/components/responses/forbiddenError" + { + "description": "Limit response to annotations created by specific user.", + "in": "query", + "name": "userId", + "schema": { + "format": "int64", + "type": "integer" + } }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Get all notification channels.", - "tags": [ - "legacy_alerts_notification_channels" - ] - }, - "post": { - "description": "You can find the full list of [supported notifiers](https://grafana.com/docs/grafana/latest/alerting/old-alerting/notifications/#list-of-supported-notifiers) on the alert notifiers page.", - "operationId": "createAlertNotificationChannel", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateAlertNotificationCommand" - } + { + "description": "Find annotations for a specified alert.", + "in": "query", + "name": "alertId", + "schema": { + "format": "int64", + "type": "integer" } }, - "required": true, - "x-originalParamName": "body" - }, - "responses": { - "200": { - "$ref": "#/components/responses/getAlertNotificationChannelResponse" + { + "description": "Find annotations that are scoped to a specific dashboard", + "in": "query", + "name": "dashboardId", + "schema": { + "format": "int64", + "type": "integer" + } }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + { + "description": "Find annotations that are scoped to a specific dashboard", + "in": "query", + "name": "dashboardUID", + "schema": { + "type": "string" + } }, - "403": { - "$ref": "#/components/responses/forbiddenError" + { + "description": "Find annotations that are scoped to a specific panel", + "in": "query", + "name": "panelId", + "schema": { + "format": "int64", + "type": "integer" + } }, - "409": { - "$ref": "#/components/responses/conflictError" + { + "description": "Max limit for results returned.", + "in": "query", + "name": "limit", + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "Use this to filter organization annotations. Organization annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. You can filter by multiple tags.", + "in": "query", + "name": "tags", + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + { + "description": "Return alerts or user created annotations", + "in": "query", + "name": "type", + "schema": { + "enum": [ + "alert", + "annotation" + ], + "type": "string" + } + }, + { + "description": "Match any or all tags", + "in": "query", + "name": "matchAny", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/getAnnotationsResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Create notification channel.", + "summary": "Find Annotations.", "tags": [ - "legacy_alerts_notification_channels" + "annotations" ] - } - }, - "/alert-notifications/lookup": { - "get": { - "description": "Returns all notification channels, but with less detailed information. Accessible by any authenticated user and is mainly used by providing alert notification channels in Grafana UI when configuring alert rule.", - "operationId": "getAlertNotificationLookup", + }, + "post": { + "description": "Creates an annotation in the Grafana database. The dashboardId and panelId fields are optional. If they are not specified then an organization annotation is created and can be queried in any dashboard that adds the Grafana annotations data source. When creating a region annotation include the timeEnd property.\nThe format for `time` and `timeEnd` should be epoch numbers in millisecond resolution.\nThe response for this HTTP request is slightly different in versions prior to v6.4. In prior versions you would also get an endId if you where creating a region. But in 6.4 regions are represented using a single event with time and timeEnd properties.", + "operationId": "postAnnotation", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PostAnnotationsCmd" + } + } + }, + "required": true, + "x-originalParamName": "body" + }, "responses": { "200": { - "$ref": "#/components/responses/getAlertNotificationLookupResponse" + "$ref": "#/components/responses/postAnnotationResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -13988,21 +14279,21 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get all notification channels (lookup).", + "summary": "Create Annotation.", "tags": [ - "legacy_alerts_notification_channels" + "annotations" ] } }, - "/alert-notifications/test": { + "/annotations/graphite": { "post": { - "description": "Sends a test notification to the channel.", - "operationId": "notificationChannelTest", + "description": "Creates an annotation by using Graphite-compatible event format. The `when` and `data` fields are optional. If `when` is not specified then the current time will be used as annotation’s timestamp. The `tags` field can also be in prior to Graphite `0.10.0` format (string with multiple tags being separated by a space).", + "operationId": "postGraphiteAnnotation", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationTestCommand" + "$ref": "#/components/schemas/PostGraphiteAnnotationsCmd" } } }, @@ -14011,7 +14302,7 @@ }, "responses": { "200": { - "$ref": "#/components/responses/okResponse" + "$ref": "#/components/responses/postAnnotationResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -14022,117 +14313,104 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "412": { - "$ref": "#/components/responses/SMTPNotEnabledError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Test notification channel.", + "summary": "Create Annotation in Graphite format.", "tags": [ - "legacy_alerts_notification_channels" + "annotations" ] } }, - "/alert-notifications/uid/{notification_channel_uid}": { - "delete": { - "description": "Deletes an existing notification channel identified by UID.", - "operationId": "deleteAlertNotificationChannelByUID", - "parameters": [ - { - "in": "path", - "name": "notification_channel_uid", - "required": true, - "schema": { - "type": "string" + "/annotations/mass-delete": { + "post": { + "operationId": "massDeleteAnnotations", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MassDeleteAnnotationsCmd" + } } - } - ], + }, + "required": true, + "x-originalParamName": "body" + }, "responses": { "200": { - "$ref": "#/components/responses/deleteAlertNotificationChannelResponse" + "$ref": "#/components/responses/okResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete alert notification by UID.", + "summary": "Delete multiple annotations.", "tags": [ - "legacy_alerts_notification_channels" + "annotations" ] - }, + } + }, + "/annotations/tags": { "get": { - "description": "Returns the notification channel given the notification channel UID.", - "operationId": "getAlertNotificationChannelByUID", + "description": "Find all the event tags created in the annotations.", + "operationId": "getAnnotationTags", "parameters": [ { - "in": "path", - "name": "notification_channel_uid", - "required": true, + "description": "Tag is a string that you can use to filter tags.", + "in": "query", + "name": "tag", + "schema": { + "type": "string" + } + }, + { + "description": "Max limit for results returned.", + "in": "query", + "name": "limit", "schema": { + "default": "100", "type": "string" } } ], "responses": { "200": { - "$ref": "#/components/responses/getAlertNotificationChannelResponse" + "$ref": "#/components/responses/getAnnotationTagsResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get notification channel by UID.", + "summary": "Find Annotations Tags.", "tags": [ - "legacy_alerts_notification_channels" + "annotations" ] - }, - "put": { - "description": "Updates an existing notification channel identified by uid.", - "operationId": "updateAlertNotificationChannelByUID", + } + }, + "/annotations/{annotation_id}": { + "delete": { + "description": "Deletes the annotation that matches the specified ID.", + "operationId": "deleteAnnotationByID", "parameters": [ { "in": "path", - "name": "notification_channel_uid", + "name": "annotation_id", "required": true, "schema": { "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateAlertNotificationWithUidCommand" - } - } - }, - "required": true, - "x-originalParamName": "body" - }, "responses": { "200": { - "$ref": "#/components/responses/getAlertNotificationChannelResponse" + "$ref": "#/components/responses/okResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -14140,73 +14418,70 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update notification channel by UID.", + "summary": "Delete Annotation By ID.", "tags": [ - "legacy_alerts_notification_channels" + "annotations" ] - } - }, - "/alert-notifications/{notification_channel_id}": { - "delete": { - "description": "Deletes an existing notification channel identified by ID.", - "operationId": "deleteAlertNotificationChannel", + }, + "get": { + "operationId": "getAnnotationByID", "parameters": [ { "in": "path", - "name": "notification_channel_id", + "name": "annotation_id", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], "responses": { "200": { - "$ref": "#/components/responses/okResponse" + "$ref": "#/components/responses/getAnnotationByIDResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete alert notification by ID.", + "summary": "Get Annotation by ID.", "tags": [ - "legacy_alerts_notification_channels" + "annotations" ] }, - "get": { - "description": "Returns the notification channel given the notification channel ID.", - "operationId": "getAlertNotificationChannelByID", + "patch": { + "description": "Updates one or more properties of an annotation that matches the specified ID.\nThis operation currently supports updating of the `text`, `tags`, `time` and `timeEnd` properties.\nThis is available in Grafana 6.0.0-beta2 and above.", + "operationId": "patchAnnotation", "parameters": [ { "in": "path", - "name": "notification_channel_id", + "name": "annotation_id", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchAnnotationsCmd" + } + } + }, + "required": true, + "x-originalParamName": "body" + }, "responses": { "200": { - "$ref": "#/components/responses/getAlertNotificationChannelResponse" + "$ref": "#/components/responses/okResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -14221,22 +14496,21 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get notification channel by ID.", + "summary": "Patch Annotation.", "tags": [ - "legacy_alerts_notification_channels" + "annotations" ] }, "put": { - "description": "Updates an existing notification channel identified by ID.", - "operationId": "updateAlertNotificationChannel", + "description": "Updates all properties of an annotation that matches the specified id. To only update certain property, consider using the Patch Annotation operation.", + "operationId": "updateAnnotation", "parameters": [ { "in": "path", - "name": "notification_channel_id", + "name": "annotation_id", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], @@ -14244,7 +14518,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateAlertNotificationCommand" + "$ref": "#/components/schemas/UpdateAnnotationsCmd" } } }, @@ -14253,7 +14527,10 @@ }, "responses": { "200": { - "$ref": "#/components/responses/getAlertNotificationChannelResponse" + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -14261,134 +14538,151 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update notification channel by ID.", + "summary": "Update Annotation.", "tags": [ - "legacy_alerts_notification_channels" + "annotations" ] } }, - "/alerts": { + "/auth/keys": { "get": { - "operationId": "getAlerts", + "description": "Will return auth keys.\n\nDeprecated: true.\n\nDeprecated. Please use GET /api/serviceaccounts and GET /api/serviceaccounts/{id}/tokens instead\nsee https://grafana.com/docs/grafana/next/administration/api-keys/#migrate-api-keys-to-grafana-service-accounts-using-the-api.", + "operationId": "getAPIkeys", "parameters": [ { - "description": "Limit response to alerts in specified dashboard(s). You can specify multiple dashboards.", + "description": "Show expired keys", "in": "query", - "name": "dashboardId", + "name": "includeExpired", "schema": { - "items": { - "type": "string" - }, - "type": "array" + "default": false, + "type": "boolean" } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/getAPIkeyResponse" }, - { - "description": "Limit response to alert for a specified panel on a dashboard.", - "in": "query", - "name": "panelId", - "schema": { - "format": "int64", - "type": "integer" - } + "401": { + "$ref": "#/components/responses/unauthorisedError" }, - { - "description": "Limit response to alerts having a name like this value.", - "in": "query", - "name": "query", - "schema": { - "type": "string" - } + "403": { + "$ref": "#/components/responses/forbiddenError" }, - { - "description": "Return alerts with one or more of the following alert states", - "in": "query", - "name": "state", - "schema": { - "enum": [ - "all", - "no_data", - "paused", - "alerting", - "ok", - "pending", - "unknown" - ], - "type": "string" - } + "404": { + "$ref": "#/components/responses/notFoundError" }, - { - "description": "Limit response to X number of alerts.", - "in": "query", - "name": "limit", - "schema": { - "format": "int64", - "type": "integer" + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Get auth keys.", + "tags": [ + "api_keys" + ] + }, + "post": { + "deprecated": true, + "description": "Will return details of the created API key.", + "operationId": "addAPIkey", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddAPIKeyCommand" + } } }, - { - "description": "Limit response to alerts of dashboards in specified folder(s). You can specify multiple folders", - "in": "query", - "name": "folderId", - "schema": { - "items": { - "type": "string" - }, - "type": "array" - } + "required": true, + "x-originalParamName": "Body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/postAPIkeyResponse" }, - { - "description": "Limit response to alerts having a dashboard name like this value./ Limit response to alerts having a dashboard name like this value.", - "in": "query", - "name": "dashboardQuery", - "schema": { - "type": "string" - } + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "409": { + "$ref": "#/components/responses/conflictError" }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Creates an API key.", + "tags": [ + "api_keys" + ] + } + }, + "/auth/keys/{id}": { + "delete": { + "deprecated": true, + "description": "Deletes an API key.\nDeprecated. See: https://grafana.com/docs/grafana/next/administration/api-keys/#migrate-api-keys-to-grafana-service-accounts-using-the-api.", + "operationId": "deleteAPIkey", + "parameters": [ { - "description": "Limit response to alerts of dashboards with specified tags. To do an “AND” filtering with multiple tags, specify the tags parameter multiple times", - "in": "query", - "name": "dashboardTag", + "in": "path", + "name": "id", + "required": true, "schema": { - "items": { - "type": "string" - }, - "type": "array" + "format": "int64", + "type": "integer" } } ], "responses": { "200": { - "$ref": "#/components/responses/getAlertsResponse" + "$ref": "#/components/responses/okResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get legacy alerts.", + "summary": "Delete API key.", "tags": [ - "legacy_alerts" + "api_keys" ] } }, - "/alerts/states-for-dashboard": { + "/dashboard/snapshots": { "get": { - "operationId": "getDashboardStates", + "operationId": "searchDashboardSnapshots", "parameters": [ { + "description": "Search Query", "in": "query", - "name": "dashboardId", - "required": true, + "name": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Limit the number of returned results", + "in": "query", + "name": "limit", "schema": { + "default": 1000, "format": "int64", "type": "integer" } @@ -14396,44 +14690,102 @@ ], "responses": { "200": { - "$ref": "#/components/responses/getDashboardStatesResponse" + "$ref": "#/components/responses/searchDashboardSnapshotsResponse" }, - "400": { - "$ref": "#/components/responses/badRequestError" + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "List snapshots.", + "tags": [ + "snapshots" + ] + } + }, + "/dashboards/calculate-diff": { + "post": { + "operationId": "calculateDashboardDiff", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "base": { + "$ref": "#/components/schemas/CalculateDiffTarget" + }, + "diffType": { + "description": "The type of diff to return\nDescription:\n`basic`\n`json`", + "enum": [ + "basic", + "json" + ], + "type": "string" + }, + "new": { + "$ref": "#/components/schemas/CalculateDiffTarget" + } + }, + "type": "object" + } + } + }, + "required": true, + "x-originalParamName": "Body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/calculateDashboardDiffResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get alert states for a dashboard.", + "summary": "Perform diff on two dashboards.", "tags": [ - "legacy_alerts" + "dashboards" ] } }, - "/alerts/test": { + "/dashboards/db": { "post": { - "operationId": "testAlert", + "description": "Creates a new dashboard or updates an existing dashboard.\nNote: This endpoint is not intended for creating folders, use `POST /api/folders` for that.", + "operationId": "postDashboard", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AlertTestCommand" + "$ref": "#/components/schemas/SaveDashboardCommand" } } }, - "x-originalParamName": "body" + "required": true, + "x-originalParamName": "Body" }, "responses": { "200": { - "$ref": "#/components/responses/testAlertResponse" + "$ref": "#/components/responses/postDashboardResponse" }, "400": { "$ref": "#/components/responses/badRequestError" }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "412": { + "$ref": "#/components/responses/preconditionFailedError" + }, "422": { "$ref": "#/components/responses/unprocessableEntityError" }, @@ -14441,29 +14793,18 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Test alert.", + "summary": "Create / Update dashboard", "tags": [ - "legacy_alerts" + "dashboards" ] } }, - "/alerts/{alert_id}": { + "/dashboards/home": { "get": { - "description": "“evalMatches” data in the response is cached in the db when and only when the state of the alert changes (e.g. transitioning from “ok” to “alerting” state).\nIf data from one server triggers the alert first and, before that server is seen leaving alerting state, a second server also enters a state that would trigger the alert, the second server will not be visible in “evalMatches” data.", - "operationId": "getAlertByID", - "parameters": [ - { - "in": "path", - "name": "alert_id", - "required": true, - "schema": { - "type": "string" - } - } - ], + "operationId": "getHomeDashboard", "responses": { "200": { - "$ref": "#/components/responses/getAlertResponse" + "$ref": "#/components/responses/getHomeDashboardResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -14472,39 +14813,31 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get alert by ID.", + "summary": "Get home dashboard.", "tags": [ - "legacy_alerts" + "dashboards" ] } }, - "/alerts/{alert_id}/pause": { - "post": { - "operationId": "pauseAlert", + "/dashboards/id/{DashboardID}/permissions": { + "get": { + "deprecated": true, + "description": "Please refer to [updated API](#/dashboard_permissions/getDashboardPermissionsListByUID) instead", + "operationId": "getDashboardPermissionsListByID", "parameters": [ { "in": "path", - "name": "alert_id", + "name": "DashboardID", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PauseAlertCommand" - } - } - }, - "required": true, - "x-originalParamName": "body" - }, "responses": { "200": { - "$ref": "#/components/responses/pauseAlertResponse" + "$ref": "#/components/responses/getDashboardPermissionsListResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -14519,153 +14852,40 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Pause/unpause alert by id.", + "summary": "Gets all existing permissions for the given dashboard.", "tags": [ - "legacy_alerts" + "dashboard_permissions" ] - } - }, - "/annotations": { - "get": { - "description": "Starting in Grafana v6.4 regions annotations are now returned in one entity that now includes the timeEnd property.", - "operationId": "getAnnotations", + }, + "post": { + "deprecated": true, + "description": "Please refer to [updated API](#/dashboard_permissions/updateDashboardPermissionsByUID) instead\n\nThis operation will remove existing permissions if they’re not included in the request.", + "operationId": "updateDashboardPermissionsByID", "parameters": [ { - "description": "Find annotations created after specific epoch datetime in milliseconds.", - "in": "query", - "name": "from", - "schema": { - "format": "int64", - "type": "integer" - } - }, - { - "description": "Find annotations created before specific epoch datetime in milliseconds.", - "in": "query", - "name": "to", - "schema": { - "format": "int64", - "type": "integer" - } - }, - { - "description": "Limit response to annotations created by specific user.", - "in": "query", - "name": "userId", - "schema": { - "format": "int64", - "type": "integer" - } - }, - { - "description": "Find annotations for a specified alert.", - "in": "query", - "name": "alertId", - "schema": { - "format": "int64", - "type": "integer" - } - }, - { - "description": "Find annotations that are scoped to a specific dashboard", - "in": "query", - "name": "dashboardId", - "schema": { - "format": "int64", - "type": "integer" - } - }, - { - "description": "Find annotations that are scoped to a specific dashboard", - "in": "query", - "name": "dashboardUID", - "schema": { - "type": "string" - } - }, - { - "description": "Find annotations that are scoped to a specific panel", - "in": "query", - "name": "panelId", - "schema": { - "format": "int64", - "type": "integer" - } - }, - { - "description": "Max limit for results returned.", - "in": "query", - "name": "limit", + "in": "path", + "name": "DashboardID", + "required": true, "schema": { "format": "int64", "type": "integer" } - }, - { - "description": "Use this to filter organization annotations. Organization annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. You can filter by multiple tags.", - "in": "query", - "name": "tags", - "schema": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - { - "description": "Return alerts or user created annotations", - "in": "query", - "name": "type", - "schema": { - "enum": [ - "alert", - "annotation" - ], - "type": "string" - } - }, - { - "description": "Match any or all tags", - "in": "query", - "name": "matchAny", - "schema": { - "type": "boolean" - } } ], - "responses": { - "200": { - "$ref": "#/components/responses/getAnnotationsResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Find Annotations.", - "tags": [ - "annotations" - ] - }, - "post": { - "description": "Creates an annotation in the Grafana database. The dashboardId and panelId fields are optional. If they are not specified then an organization annotation is created and can be queried in any dashboard that adds the Grafana annotations data source. When creating a region annotation include the timeEnd property.\nThe format for `time` and `timeEnd` should be epoch numbers in millisecond resolution.\nThe response for this HTTP request is slightly different in versions prior to v6.4. In prior versions you would also get an endId if you where creating a region. But in 6.4 regions are represented using a single event with time and timeEnd properties.", - "operationId": "postAnnotation", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PostAnnotationsCmd" + "$ref": "#/components/schemas/UpdateDashboardACLCommand" } } }, "required": true, - "x-originalParamName": "body" + "x-originalParamName": "Body" }, "responses": { "200": { - "$ref": "#/components/responses/postAnnotationResponse" + "$ref": "#/components/responses/okResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -14676,37 +14896,49 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Create Annotation.", + "summary": "Updates permissions for a dashboard.", "tags": [ - "annotations" + "dashboard_permissions" ] } }, - "/annotations/graphite": { + "/dashboards/id/{DashboardID}/restore": { "post": { - "description": "Creates an annotation by using Graphite-compatible event format. The `when` and `data` fields are optional. If `when` is not specified then the current time will be used as annotation’s timestamp. The `tags` field can also be in prior to Graphite `0.10.0` format (string with multiple tags being separated by a space).", - "operationId": "postGraphiteAnnotation", + "deprecated": true, + "description": "Please refer to [updated API](#/dashboard_versions/restoreDashboardVersionByUID) instead", + "operationId": "restoreDashboardVersionByID", + "parameters": [ + { + "in": "path", + "name": "DashboardID", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PostGraphiteAnnotationsCmd" + "$ref": "#/components/schemas/RestoreDashboardVersionCommand" } } }, "required": true, - "x-originalParamName": "body" + "x-originalParamName": "Body" }, "responses": { "200": { - "$ref": "#/components/responses/postAnnotationResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/postDashboardResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -14714,135 +14946,175 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Create Annotation in Graphite format.", + "summary": "Restore a dashboard to a given dashboard version.", "tags": [ - "annotations" + "dashboard_versions" ] } }, - "/annotations/mass-delete": { - "post": { - "operationId": "massDeleteAnnotations", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MassDeleteAnnotationsCmd" - } + "/dashboards/id/{DashboardID}/versions": { + "get": { + "deprecated": true, + "description": "Please refer to [updated API](#/dashboard_versions/getDashboardVersionsByUID) instead", + "operationId": "getDashboardVersionsByID", + "parameters": [ + { + "in": "path", + "name": "DashboardID", + "required": true, + "schema": { + "format": "int64", + "type": "integer" } - }, - "required": true, - "x-originalParamName": "body" - }, + } + ], "responses": { "200": { - "$ref": "#/components/responses/okResponse" + "$ref": "#/components/responses/dashboardVersionsResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete multiple annotations.", + "summary": "Gets all existing versions for the dashboard.", "tags": [ - "annotations" + "dashboard_versions" ] } }, - "/annotations/tags": { + "/dashboards/id/{DashboardID}/versions/{DashboardVersionID}": { "get": { - "description": "Find all the event tags created in the annotations.", - "operationId": "getAnnotationTags", + "deprecated": true, + "description": "Please refer to [updated API](#/dashboard_versions/getDashboardVersionByUID) instead", + "operationId": "getDashboardVersionByID", "parameters": [ { - "description": "Tag is a string that you can use to filter tags.", - "in": "query", - "name": "tag", + "in": "path", + "name": "DashboardID", + "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } }, { - "description": "Max limit for results returned.", - "in": "query", - "name": "limit", + "in": "path", + "name": "DashboardVersionID", + "required": true, "schema": { - "default": "100", - "type": "string" + "format": "int64", + "type": "integer" } } ], "responses": { "200": { - "$ref": "#/components/responses/getAnnotationTagsResponse" + "$ref": "#/components/responses/dashboardVersionResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Find Annotations Tags.", + "summary": "Get a specific dashboard version.", "tags": [ - "annotations" + "dashboard_versions" ] } }, - "/annotations/{annotation_id}": { - "delete": { - "description": "Deletes the annotation that matches the specified ID.", - "operationId": "deleteAnnotationByID", - "parameters": [ - { - "in": "path", - "name": "annotation_id", - "required": true, - "schema": { - "type": "string" + "/dashboards/import": { + "post": { + "operationId": "importDashboard", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportDashboardRequest" + } } - } - ], + }, + "required": true, + "x-originalParamName": "Body" + }, "responses": { "200": { - "$ref": "#/components/responses/okResponse" + "$ref": "#/components/responses/importDashboardResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, - "403": { - "$ref": "#/components/responses/forbiddenError" + "412": { + "$ref": "#/components/responses/preconditionFailedError" + }, + "422": { + "$ref": "#/components/responses/unprocessableEntityError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete Annotation By ID.", + "summary": "Import dashboard.", "tags": [ - "annotations" + "dashboards" ] - }, + } + }, + "/dashboards/public-dashboards": { "get": { - "operationId": "getAnnotationByID", - "parameters": [ - { - "in": "path", - "name": "annotation_id", - "required": true, - "schema": { - "type": "string" - } + "description": "Get list of public dashboards", + "operationId": "listPublicDashboards", + "responses": { + "200": { + "$ref": "#/components/responses/listPublicDashboardsResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedPublicError" + }, + "403": { + "$ref": "#/components/responses/forbiddenPublicError" + }, + "500": { + "$ref": "#/components/responses/internalServerPublicError" } - ], + }, + "tags": [ + "dashboard_public" + ] + } + }, + "/dashboards/tags": { + "get": { + "operationId": "getDashboardTags", "responses": { "200": { - "$ref": "#/components/responses/getAnnotationByIDResponse" + "$ref": "#/components/responses/getDashboardsTagsResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -14851,64 +15123,57 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get Annotation by ID.", + "summary": "Get all dashboards tags of an organisation.", "tags": [ - "annotations" + "dashboards" ] - }, - "patch": { - "description": "Updates one or more properties of an annotation that matches the specified ID.\nThis operation currently supports updating of the `text`, `tags`, `time` and `timeEnd` properties.\nThis is available in Grafana 6.0.0-beta2 and above.", - "operationId": "patchAnnotation", + } + }, + "/dashboards/uid/{dashboardUid}/public-dashboards": { + "get": { + "description": "Get public dashboard by dashboardUid", + "operationId": "getPublicDashboard", "parameters": [ { "in": "path", - "name": "annotation_id", + "name": "dashboardUid", "required": true, "schema": { "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchAnnotationsCmd" - } - } - }, - "required": true, - "x-originalParamName": "body" - }, "responses": { "200": { - "$ref": "#/components/responses/okResponse" + "$ref": "#/components/responses/getPublicDashboardResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestPublicError" }, "401": { - "$ref": "#/components/responses/unauthorisedError" + "$ref": "#/components/responses/unauthorisedPublicError" }, "403": { - "$ref": "#/components/responses/forbiddenError" + "$ref": "#/components/responses/forbiddenPublicError" }, "404": { - "$ref": "#/components/responses/notFoundError" + "$ref": "#/components/responses/notFoundPublicError" }, "500": { - "$ref": "#/components/responses/internalServerError" + "$ref": "#/components/responses/internalServerPublicError" } }, - "summary": "Patch Annotation.", "tags": [ - "annotations" + "dashboard_public" ] }, - "put": { - "description": "Updates all properties of an annotation that matches the specified id. To only update certain property, consider using the Patch Annotation operation.", - "operationId": "updateAnnotation", + "post": { + "description": "Create public dashboard for a dashboard", + "operationId": "createPublicDashboard", "parameters": [ { "in": "path", - "name": "annotation_id", + "name": "dashboardUid", "required": true, "schema": { "type": "string" @@ -14919,62 +15184,94 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateAnnotationsCmd" + "$ref": "#/components/schemas/PublicDashboardDTO" } } }, "required": true, - "x-originalParamName": "body" + "x-originalParamName": "Body" }, "responses": { "200": { - "$ref": "#/components/responses/okResponse" + "$ref": "#/components/responses/createPublicDashboardResponse" }, "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/badRequestPublicError" }, "401": { - "$ref": "#/components/responses/unauthorisedError" + "$ref": "#/components/responses/unauthorisedPublicError" }, "403": { - "$ref": "#/components/responses/forbiddenError" + "$ref": "#/components/responses/forbiddenPublicError" }, "500": { - "$ref": "#/components/responses/internalServerError" + "$ref": "#/components/responses/internalServerPublicError" } }, - "summary": "Update Annotation.", "tags": [ - "annotations" + "dashboard_public" ] } }, - "/api/v1/provisioning/alert-rules": { - "get": { - "operationId": "RouteGetAlertRules", + "/dashboards/uid/{dashboardUid}/public-dashboards/{uid}": { + "delete": { + "description": "Delete public dashboard for a dashboard", + "operationId": "deletePublicDashboard", + "parameters": [ + { + "in": "path", + "name": "dashboardUid", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProvisionedAlertRules" - } - } - }, - "description": "ProvisionedAlertRules" + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestPublicError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedPublicError" + }, + "403": { + "$ref": "#/components/responses/forbiddenPublicError" + }, + "500": { + "$ref": "#/components/responses/internalServerPublicError" } }, - "summary": "Get all the alert rules.", "tags": [ - "provisioning" + "dashboard_public" ] }, - "post": { - "operationId": "RoutePostAlertRule", + "patch": { + "description": "Update public dashboard for a dashboard", + "operationId": "updatePublicDashboard", "parameters": [ { - "in": "header", - "name": "X-Disable-Provenance", + "in": "path", + "name": "dashboardUid", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "uid", + "required": true, "schema": { "type": "string" } @@ -14984,85 +15281,79 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProvisionedAlertRule" + "$ref": "#/components/schemas/PublicDashboardDTO" } } }, + "required": true, "x-originalParamName": "Body" }, "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProvisionedAlertRule" - } - } - }, - "description": "ProvisionedAlertRule" + "200": { + "$ref": "#/components/responses/updatePublicDashboardResponse" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - }, - "description": "ValidationError" + "$ref": "#/components/responses/badRequestPublicError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedPublicError" + }, + "403": { + "$ref": "#/components/responses/forbiddenPublicError" + }, + "500": { + "$ref": "#/components/responses/internalServerPublicError" } }, - "summary": "Create a new alert rule.", "tags": [ - "provisioning" + "dashboard_public" ] } }, - "/api/v1/provisioning/alert-rules/export": { - "get": { - "operationId": "RouteGetAlertRulesExport", + "/dashboards/uid/{uid}": { + "delete": { + "description": "Will delete the dashboard given the specified unique identifier (uid).", + "operationId": "deleteDashboardByUID", "parameters": [ { - "description": "Whether to initiate a download of the file or not.", - "in": "query", - "name": "download", - "schema": { - "default": false, - "type": "boolean" - } - }, - { - "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", - "in": "query", - "name": "format", + "in": "path", + "name": "uid", + "required": true, "schema": { - "default": "yaml", "type": "string" } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/deleteDashboardResponse" }, - { - "description": "UIDs of folders from which to export rules", - "in": "query", - "name": "folderUid", - "schema": { - "items": { - "type": "string" - }, - "type": "array" - } + "401": { + "$ref": "#/components/responses/unauthorisedError" }, - { - "description": "Name of group of rules to export. Must be specified only together with a single folder UID", - "in": "query", - "name": "group", - "schema": { - "type": "string" - } + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Delete dashboard by uid.", + "tags": [ + "dashboards" + ] + }, + "get": { + "description": "Will return the dashboard given the dashboard unique identifier (uid).", + "operationId": "getDashboardByUID", + "parameters": [ { - "description": "UID of alert rule to export. If specified, parameters folderUid and group must be empty.", - "in": "query", - "name": "ruleUid", + "in": "path", + "name": "uid", + "required": true, "schema": { "type": "string" } @@ -15070,218 +15361,232 @@ ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AlertingFileExport" - } - } - }, - "description": "AlertingFileExport" + "$ref": "#/components/responses/dashboardResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" }, "404": { - "description": " Not found." + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Export all alert rules in provisioning file format.", + "summary": "Get dashboard by uid.", "tags": [ - "provisioning" + "dashboards" ] } }, - "/api/v1/provisioning/alert-rules/{UID}": { - "delete": { - "operationId": "RouteDeleteAlertRule", + "/dashboards/uid/{uid}/permissions": { + "get": { + "operationId": "getDashboardPermissionsListByUID", "parameters": [ { - "description": "Alert rule UID", "in": "path", - "name": "UID", + "name": "uid", "required": true, "schema": { "type": "string" } - }, - { - "in": "header", - "name": "X-Disable-Provenance", - "schema": { - "type": "string" - } } ], "responses": { - "204": { - "description": " The alert rule was deleted successfully." + "200": { + "$ref": "#/components/responses/getDashboardPermissionsListResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete a specific alert rule by UID.", + "summary": "Gets all existing permissions for the given dashboard.", "tags": [ - "provisioning" + "dashboard_permissions" ] }, - "get": { - "operationId": "RouteGetAlertRule", + "post": { + "description": "This operation will remove existing permissions if they’re not included in the request.", + "operationId": "updateDashboardPermissionsByUID", "parameters": [ { - "description": "Alert rule UID", "in": "path", - "name": "UID", + "name": "uid", "required": true, "schema": { "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDashboardACLCommand" + } + } + }, + "required": true, + "x-originalParamName": "Body" + }, "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProvisionedAlertRule" - } - } - }, - "description": "ProvisionedAlertRule" + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" }, "404": { - "description": " Not found." + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get a specific alert rule by UID.", + "summary": "Updates permissions for a dashboard.", "tags": [ - "provisioning" + "dashboard_permissions" ] - }, - "put": { - "operationId": "RoutePutAlertRule", + } + }, + "/dashboards/uid/{uid}/restore": { + "post": { + "operationId": "restoreDashboardVersionByUID", "parameters": [ { - "description": "Alert rule UID", "in": "path", - "name": "UID", + "name": "uid", "required": true, "schema": { "type": "string" } - }, - { - "in": "header", - "name": "X-Disable-Provenance", - "schema": { - "type": "string" - } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProvisionedAlertRule" + "$ref": "#/components/schemas/RestoreDashboardVersionCommand" } } }, + "required": true, "x-originalParamName": "Body" }, "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProvisionedAlertRule" - } - } - }, - "description": "ProvisionedAlertRule" + "$ref": "#/components/responses/postDashboardResponse" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - }, - "description": "ValidationError" + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update an existing alert rule.", + "summary": "Restore a dashboard to a given dashboard version using UID.", "tags": [ - "provisioning" + "dashboard_versions" ] } }, - "/api/v1/provisioning/alert-rules/{UID}/export": { + "/dashboards/uid/{uid}/versions": { "get": { - "operationId": "RouteGetAlertRuleExport", + "operationId": "getDashboardVersionsByUID", "parameters": [ { - "description": "Whether to initiate a download of the file or not.", - "in": "query", - "name": "download", + "in": "path", + "name": "uid", + "required": true, "schema": { - "default": false, - "type": "boolean" + "type": "string" } }, { - "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", + "description": "Maximum number of results to return", "in": "query", - "name": "format", + "name": "limit", "schema": { - "default": "yaml", - "type": "string" + "default": 0, + "format": "int64", + "type": "integer" } }, { - "description": "Alert rule UID", - "in": "path", - "name": "UID", - "required": true, + "description": "Version to start from when returning queries", + "in": "query", + "name": "start", "schema": { - "type": "string" + "default": 0, + "format": "int64", + "type": "integer" } } ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AlertingFileExport" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/AlertingFileExport" - } - }, - "text/yaml": { - "schema": { - "$ref": "#/components/schemas/AlertingFileExport" - } - } - }, - "description": "AlertingFileExport" + "$ref": "#/components/responses/dashboardVersionsResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" }, "404": { - "description": " Not found." + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Export an alert rule in provisioning file format.", + "summary": "Gets all existing versions for the dashboard using UID.", "tags": [ - "provisioning" + "dashboard_versions" ] } }, - "/api/v1/provisioning/contact-points": { + "/dashboards/uid/{uid}/versions/{DashboardVersionID}": { "get": { - "operationId": "RouteGetContactpoints", + "operationId": "getDashboardVersionByUID", "parameters": [ { - "description": "Filter by name", - "in": "query", - "name": "name", + "in": "path", + "name": "DashboardVersionID", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "in": "path", + "name": "uid", + "required": true, "schema": { "type": "string" } @@ -15289,105 +15594,153 @@ ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactPoints" - } - } - }, - "description": "ContactPoints" + "$ref": "#/components/responses/dashboardVersionResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get all the contact points.", + "summary": "Get a specific dashboard version using UID.", "tags": [ - "provisioning" + "dashboard_versions" + ] + } + }, + "/datasources": { + "get": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scope: `datasources:*`.", + "operationId": "getDataSources", + "responses": { + "200": { + "$ref": "#/components/responses/getDataSourcesResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Get all data sources.", + "tags": [ + "datasources" ] }, "post": { - "operationId": "RoutePostContactpoints", - "parameters": [ - { - "in": "header", - "name": "X-Disable-Provenance", - "schema": { - "type": "string" - } - } - ], + "description": "By defining `password` and `basicAuthPassword` under secureJsonData property\nGrafana encrypts them securely as an encrypted blob in the database.\nThe response then lists the encrypted fields under secureJsonFields.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:create`", + "operationId": "addDataSource", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmbeddedContactPoint" + "$ref": "#/components/schemas/AddDataSourceCommand" } } }, + "required": true, "x-originalParamName": "Body" }, "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmbeddedContactPoint" - } - } - }, - "description": "EmbeddedContactPoint" + "200": { + "$ref": "#/components/responses/createOrUpdateDatasourceResponse" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - }, - "description": "ValidationError" + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "409": { + "$ref": "#/components/responses/conflictError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Create a contact point.", + "summary": "Create a data source.", "tags": [ - "provisioning" + "datasources" ] } }, - "/api/v1/provisioning/contact-points/export": { + "/datasources/correlations": { "get": { - "operationId": "RouteGetContactpointsExport", + "operationId": "getCorrelations", "parameters": [ { - "description": "Whether to initiate a download of the file or not.", + "description": "Limit the maximum number of correlations to return per page", "in": "query", - "name": "download", + "name": "limit", "schema": { - "default": false, - "type": "boolean" + "default": 100, + "format": "int64", + "maximum": 1000, + "type": "integer" } }, { - "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", + "description": "Page index for starting fetching correlations", "in": "query", - "name": "format", + "name": "page", "schema": { - "default": "yaml", - "type": "string" + "default": 1, + "format": "int64", + "type": "integer" } }, { - "description": "Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings.", + "description": "Source datasource UID filter to be applied to correlations", "in": "query", - "name": "decrypt", + "name": "sourceUID", "schema": { - "default": false, - "type": "boolean" + "items": { + "type": "string" + }, + "type": "array" } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/getCorrelationsResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Gets all correlations.", + "tags": [ + "correlations" + ] + } + }, + "/datasources/id/{name}": { + "get": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:name:*` and `datasources:name:test_datasource` (single data source).", + "operationId": "getDataSourceIdByName", + "parameters": [ { - "description": "Filter by name", - "in": "query", + "in": "path", "name": "name", + "required": true, "schema": { "type": "string" } @@ -15395,40 +15748,35 @@ ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AlertingFileExport" - } - } - }, - "description": "AlertingFileExport" + "$ref": "#/components/responses/getDataSourceIDResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PermissionDenied" - } - } - }, - "description": "PermissionDenied" + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Export all contact points in provisioning file format.", + "summary": "Get data source Id by Name.", "tags": [ - "provisioning" + "datasources" ] } }, - "/api/v1/provisioning/contact-points/{UID}": { + "/datasources/name/{name}": { "delete": { - "operationId": "RouteDeleteContactpoints", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:name:*` and `datasources:name:test_datasource` (single data source).", + "operationId": "deleteDataSourceByName", "parameters": [ { - "description": "UID is the contact point unique identifier", "in": "path", - "name": "UID", + "name": "name", "required": true, "schema": { "type": "string" @@ -15436,80 +15784,114 @@ } ], "responses": { - "202": { - "description": " The contact point was deleted successfully." - } - }, - "summary": "Delete a contact point.", - "tags": [ - "provisioning" + "200": { + "$ref": "#/components/responses/deleteDataSourceByNameResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Delete an existing data source by name.", + "tags": [ + "datasources" ] }, - "put": { - "operationId": "RoutePutContactpoint", + "get": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:name:*` and `datasources:name:test_datasource` (single data source).", + "operationId": "getDataSourceByName", "parameters": [ { - "description": "UID is the contact point unique identifier", "in": "path", - "name": "UID", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/getDataSourceResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Get a single data source by Name.", + "tags": [ + "datasources" + ] + } + }, + "/datasources/proxy/uid/{uid}/{datasource_proxy_route}": { + "delete": { + "description": "Proxies all calls to the actual data source.", + "operationId": "datasourceProxyDELETEByUIDcalls", + "parameters": [ + { + "in": "path", + "name": "uid", "required": true, "schema": { "type": "string" } }, { - "in": "header", - "name": "X-Disable-Provenance", + "in": "path", + "name": "datasource_proxy_route", + "required": true, "schema": { "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmbeddedContactPoint" - } - } - }, - "x-originalParamName": "Body" - }, "responses": { "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Ack" - } - } - }, - "description": "Ack" + "description": "(empty)" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - }, - "description": "ValidationError" + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update an existing contact point.", + "summary": "Data source proxy DELETE calls.", "tags": [ - "provisioning" + "datasources" ] - } - }, - "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": { + }, "get": { - "operationId": "RouteGetAlertRuleGroup", + "description": "Proxies all calls to the actual data source.", + "operationId": "datasourceProxyGETByUIDcalls", "parameters": [ { "in": "path", - "name": "FolderUID", + "name": "datasource_proxy_route", "required": true, "schema": { "type": "string" @@ -15517,7 +15899,7 @@ }, { "in": "path", - "name": "Group", + "name": "uid", "required": true, "schema": { "type": "string" @@ -15526,37 +15908,36 @@ ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AlertRuleGroup" - } - } - }, - "description": "AlertRuleGroup" + "description": "(empty)" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" }, "404": { - "description": " Not found." + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get a rule group.", + "summary": "Data source proxy GET calls.", "tags": [ - "provisioning" + "datasources" ] }, - "put": { - "operationId": "RoutePutAlertRuleGroup", + "post": { + "description": "Proxies all calls to the actual data source. The data source should support POST methods for the specific path and role as defined", + "operationId": "datasourceProxyPOSTByUIDcalls", "parameters": [ - { - "in": "header", - "name": "X-Disable-Provenance", - "schema": { - "type": "string" - } - }, { "in": "path", - "name": "FolderUID", + "name": "datasource_proxy_route", "required": true, "schema": { "type": "string" @@ -15564,7 +15945,7 @@ }, { "in": "path", - "name": "Group", + "name": "uid", "required": true, "schema": { "type": "string" @@ -15574,66 +15955,50 @@ "requestBody": { "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/AlertRuleGroup" - } + "schema": {} } }, - "x-originalParamName": "Body" + "required": true, + "x-originalParamName": "DatasourceProxyParam" }, "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AlertRuleGroup" - } - } - }, - "description": "AlertRuleGroup" + "201": { + "description": "(empty)" + }, + "202": { + "description": "(empty)" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - }, - "description": "ValidationError" + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update the interval of a rule group.", + "summary": "Data source proxy POST calls.", "tags": [ - "provisioning" + "datasources" ] } }, - "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export": { - "get": { - "operationId": "RouteGetAlertRuleGroupExport", + "/datasources/proxy/{id}/{datasource_proxy_route}": { + "delete": { + "deprecated": true, + "description": "Proxies all calls to the actual data source.\n\nPlease refer to [updated API](#/datasources/datasourceProxyDELETEByUIDcalls) instead", + "operationId": "datasourceProxyDELETEcalls", "parameters": [ - { - "description": "Whether to initiate a download of the file or not.", - "in": "query", - "name": "download", - "schema": { - "default": false, - "type": "boolean" - } - }, - { - "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", - "in": "query", - "name": "format", - "schema": { - "default": "yaml", - "type": "string" - } - }, { "in": "path", - "name": "FolderUID", + "name": "id", "required": true, "schema": { "type": "string" @@ -15641,7 +16006,7 @@ }, { "in": "path", - "name": "Group", + "name": "datasource_proxy_route", "required": true, "schema": { "type": "string" @@ -15649,62 +16014,94 @@ } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AlertingFileExport" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/AlertingFileExport" - } - }, - "text/yaml": { - "schema": { - "$ref": "#/components/schemas/AlertingFileExport" - } - } - }, - "description": "AlertingFileExport" + "202": { + "description": "(empty)" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" }, "404": { - "description": " Not found." + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Export an alert rule group in provisioning file format.", + "summary": "Data source proxy DELETE calls.", "tags": [ - "provisioning" + "datasources" ] - } - }, - "/api/v1/provisioning/mute-timings": { + }, "get": { - "operationId": "RouteGetMuteTimings", + "deprecated": true, + "description": "Proxies all calls to the actual data source.\n\nPlease refer to [updated API](#/datasources/datasourceProxyGETByUIDcalls) instead", + "operationId": "datasourceProxyGETcalls", + "parameters": [ + { + "in": "path", + "name": "datasource_proxy_route", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MuteTimings" - } - } - }, - "description": "MuteTimings" + "description": "(empty)" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get all the mute timings.", + "summary": "Data source proxy GET calls.", "tags": [ - "provisioning" + "datasources" ] }, "post": { - "operationId": "RoutePostMuteTiming", + "deprecated": true, + "description": "Proxies all calls to the actual data source. The data source should support POST methods for the specific path and role as defined\n\nPlease refer to [updated API](#/datasources/datasourceProxyPOSTByUIDcalls) instead", + "operationId": "datasourceProxyPOSTcalls", "parameters": [ { - "in": "header", - "name": "X-Disable-Provenance", + "in": "path", + "name": "datasource_proxy_route", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, "schema": { "type": "string" } @@ -15713,123 +16110,137 @@ "requestBody": { "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/MuteTimeInterval" - } + "schema": {} } }, - "x-originalParamName": "Body" + "required": true, + "x-originalParamName": "DatasourceProxyParam" }, "responses": { "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MuteTimeInterval" - } - } - }, - "description": "MuteTimeInterval" + "description": "(empty)" + }, + "202": { + "description": "(empty)" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - }, - "description": "ValidationError" + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Create a new mute timing.", + "summary": "Data source proxy POST calls.", "tags": [ - "provisioning" + "datasources" ] } }, - "/api/v1/provisioning/mute-timings/export": { + "/datasources/uid/{sourceUID}/correlations": { "get": { - "operationId": "RouteExportMuteTimings", + "operationId": "getCorrelationsBySourceUID", "parameters": [ { - "description": "Whether to initiate a download of the file or not.", - "in": "query", - "name": "download", - "schema": { - "default": false, - "type": "boolean" - } - }, - { - "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", - "in": "query", - "name": "format", + "in": "path", + "name": "sourceUID", + "required": true, "schema": { - "default": "yaml", "type": "string" } } ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AlertingFileExport" - } - } - }, - "description": "AlertingFileExport" + "$ref": "#/components/responses/getCorrelationsBySourceUIDResponse" }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PermissionDenied" - } - } - }, - "description": "PermissionDenied" + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Export all mute timings in provisioning format.", + "summary": "Gets all correlations originating from the given data source.", "tags": [ - "provisioning" + "correlations" ] - } - }, - "/api/v1/provisioning/mute-timings/{name}": { - "delete": { - "operationId": "RouteDeleteMuteTiming", + }, + "post": { + "operationId": "createCorrelation", "parameters": [ { - "description": "Mute timing name", "in": "path", - "name": "name", + "name": "sourceUID", "required": true, "schema": { "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCorrelationCommand" + } + } + }, + "required": true, + "x-originalParamName": "body" + }, "responses": { - "204": { - "description": " The mute timing was deleted successfully." + "200": { + "$ref": "#/components/responses/createCorrelationResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete a mute timing.", + "summary": "Add correlation.", "tags": [ - "provisioning" + "correlations" ] - }, + } + }, + "/datasources/uid/{sourceUID}/correlations/{correlationUID}": { "get": { - "operationId": "RouteGetMuteTiming", + "operationId": "getCorrelation", "parameters": [ { - "description": "Mute timing name", "in": "path", - "name": "name", + "name": "sourceUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "correlationUID", "required": true, "schema": { "type": "string" @@ -15838,39 +16249,38 @@ ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MuteTimeInterval" - } - } - }, - "description": "MuteTimeInterval" + "$ref": "#/components/responses/getCorrelationResponse" }, - "404": { - "description": " Not found." + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get a mute timing.", + "summary": "Gets a correlation.", "tags": [ - "provisioning" + "correlations" ] }, - "put": { - "operationId": "RoutePutMuteTiming", + "patch": { + "operationId": "updateCorrelation", "parameters": [ { - "description": "Mute timing name", "in": "path", - "name": "name", + "name": "sourceUID", "required": true, "schema": { "type": "string" } }, { - "in": "header", - "name": "X-Disable-Provenance", + "in": "path", + "name": "correlationUID", + "required": true, "schema": { "type": "string" } @@ -15880,66 +16290,46 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MuteTimeInterval" + "$ref": "#/components/schemas/UpdateCorrelationCommand" } } }, - "x-originalParamName": "Body" + "x-originalParamName": "body" }, "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MuteTimeInterval" - } - } - }, - "description": "MuteTimeInterval" + "$ref": "#/components/responses/updateCorrelationResponse" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - }, - "description": "ValidationError" + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Replace an existing mute timing.", + "summary": "Updates a correlation.", "tags": [ - "provisioning" + "correlations" ] } }, - "/api/v1/provisioning/mute-timings/{name}/export": { - "get": { - "operationId": "RouteExportMuteTiming", + "/datasources/uid/{uid}": { + "delete": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:kLtEtcRGk` (single data source).", + "operationId": "deleteDataSourceByUID", "parameters": [ { - "description": "Whether to initiate a download of the file or not.", - "in": "query", - "name": "download", - "schema": { - "default": false, - "type": "boolean" - } - }, - { - "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", - "in": "query", - "name": "format", - "schema": { - "default": "yaml", - "type": "string" - } - }, - { - "description": "Mute timing name", "in": "path", - "name": "name", + "name": "uid", "required": true, "schema": { "type": "string" @@ -15948,77 +16338,72 @@ ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AlertingFileExport" - } - } - }, - "description": "AlertingFileExport" + "$ref": "#/components/responses/okResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PermissionDenied" - } - } - }, - "description": "PermissionDenied" - } - }, - "summary": "Export a mute timing in provisioning format.", - "tags": [ - "provisioning" - ] - } - }, - "/api/v1/provisioning/policies": { - "delete": { - "operationId": "RouteResetPolicyTree", - "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Ack" - } - } - }, - "description": "Ack" + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Clears the notification policy tree.", + "summary": "Delete an existing data source by UID.", "tags": [ - "provisioning" + "datasources" ] }, "get": { - "operationId": "RouteGetPolicyTree", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:kLtEtcRGk` (single data source).", + "operationId": "getDataSourceByUID", + "parameters": [ + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Route" - } - } - }, - "description": "Route" + "$ref": "#/components/responses/getDataSourceResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get the notification policy tree.", + "summary": "Get a single data source by UID.", "tags": [ - "provisioning" + "datasources" ] }, "put": { - "operationId": "RoutePutPolicyTree", + "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).", + "operationId": "updateDataSourceByUID", "parameters": [ { - "in": "header", - "name": "X-Disable-Provenance", + "in": "path", + "name": "uid", + "required": true, "schema": { "type": "string" } @@ -16028,104 +16413,84 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Route" + "$ref": "#/components/schemas/UpdateDataSourceCommand" } } }, - "description": "The new notification routing tree to use", + "required": true, "x-originalParamName": "Body" }, "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Ack" - } - } - }, - "description": "Ack" + "200": { + "$ref": "#/components/responses/createOrUpdateDatasourceResponse" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - }, - "description": "ValidationError" + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Sets the notification policy tree.", + "summary": "Update an existing data source.", "tags": [ - "provisioning" + "datasources" ] } }, - "/api/v1/provisioning/policies/export": { - "get": { - "operationId": "RouteGetPolicyTreeExport", + "/datasources/uid/{uid}/correlations/{correlationUID}": { + "delete": { + "operationId": "deleteCorrelation", + "parameters": [ + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "correlationUID", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AlertingFileExport" - } - } - }, - "description": "AlertingFileExport" + "$ref": "#/components/responses/deleteCorrelationResponse" }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFound" - } - } - }, - "description": "NotFound" - } - }, - "summary": "Export the notification policy tree in provisioning file format.", - "tags": [ - "provisioning" - ] - } - }, - "/api/v1/provisioning/templates": { - "get": { - "operationId": "RouteGetTemplates", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationTemplates" - } - } - }, - "description": "NotificationTemplates" + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" }, "404": { - "description": " Not found." + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get all notification templates.", + "summary": "Delete a correlation.", "tags": [ - "provisioning" + "correlations" ] } }, - "/api/v1/provisioning/templates/{name}": { - "delete": { - "operationId": "RouteDeleteTemplate", + "/datasources/uid/{uid}/health": { + "get": { + "operationId": "checkDatasourceHealthWithUID", "parameters": [ { - "description": "Template Name", "in": "path", - "name": "name", + "name": "uid", "required": true, "schema": { "type": "string" @@ -16133,22 +16498,43 @@ } ], "responses": { - "204": { - "description": " The template was deleted successfully." + "200": { + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete a template.", + "summary": "Sends a health check request to the plugin datasource identified by the UID.", "tags": [ - "provisioning" + "datasources" ] - }, + } + }, + "/datasources/uid/{uid}/resources/{datasource_proxy_route}": { "get": { - "operationId": "RouteGetTemplate", + "operationId": "callDatasourceResourceWithUID", "parameters": [ { - "description": "Template Name", "in": "path", - "name": "name", + "name": "datasource_proxy_route", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "uid", "required": true, "schema": { "type": "string" @@ -16157,100 +16543,87 @@ ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationTemplate" - } - } - }, - "description": "NotificationTemplate" + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" }, "404": { - "description": " Not found." + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get a notification template.", + "summary": "Fetch data source resources.", "tags": [ - "provisioning" + "datasources" ] - }, - "put": { - "operationId": "RoutePutTemplate", + } + }, + "/datasources/{id}": { + "delete": { + "deprecated": true, + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).\n\nPlease refer to [updated API](#/datasources/deleteDataSourceByUID) instead", + "operationId": "deleteDataSourceByID", "parameters": [ { - "description": "Template Name", "in": "path", - "name": "name", + "name": "id", "required": true, "schema": { "type": "string" } - }, - { - "in": "header", - "name": "X-Disable-Provenance", - "schema": { - "type": "string" - } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationTemplateContent" - } - } - }, - "x-originalParamName": "Body" - }, "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationTemplate" - } - } - }, - "description": "NotificationTemplate" + "200": { + "$ref": "#/components/responses/okResponse" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - }, - "description": "ValidationError" + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Updates an existing notification template.", + "summary": "Delete an existing data source by id.", "tags": [ - "provisioning" + "datasources" ] - } - }, - "/auth/keys": { + }, "get": { - "description": "Will return auth keys.\n\nDeprecated: true.\n\nDeprecated. Please use GET /api/serviceaccounts and GET /api/serviceaccounts/{id}/tokens instead\nsee https://grafana.com/docs/grafana/next/administration/api-keys/#migrate-api-keys-to-grafana-service-accounts-using-the-api.", - "operationId": "getAPIkeys", + "deprecated": true, + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).\n\nPlease refer to [updated API](#/datasources/getDataSourceByUID) instead", + "operationId": "getDataSourceByID", "parameters": [ { - "description": "Show expired keys", - "in": "query", - "name": "includeExpired", + "in": "path", + "name": "id", + "required": true, "schema": { - "default": false, - "type": "boolean" + "type": "string" } } ], "responses": { "200": { - "$ref": "#/components/responses/getAPIkeyResponse" + "$ref": "#/components/responses/getDataSourceResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -16265,20 +16638,30 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get auth keys.", + "summary": "Get a single data source by Id.", "tags": [ - "api_keys" + "datasources" ] }, - "post": { + "put": { "deprecated": true, - "description": "Will return details of the created API key.", - "operationId": "addAPIkey", + "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).\n\nPlease refer to [updated API](#/datasources/updateDataSourceByUID) instead", + "operationId": "updateDataSourceByID", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddAPIKeyCommand" + "$ref": "#/components/schemas/UpdateDataSourceCommand" } } }, @@ -16287,10 +16670,7 @@ }, "responses": { "200": { - "$ref": "#/components/responses/postAPIkeyResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/createOrUpdateDatasourceResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -16298,32 +16678,28 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "409": { - "$ref": "#/components/responses/conflictError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Creates an API key.", + "summary": "Update an existing data source by its sequential ID.", "tags": [ - "api_keys" + "datasources" ] } }, - "/auth/keys/{id}": { - "delete": { + "/datasources/{id}/health": { + "get": { "deprecated": true, - "description": "Deletes an API key.\nDeprecated. See: https://grafana.com/docs/grafana/next/administration/api-keys/#migrate-api-keys-to-grafana-service-accounts-using-the-api.", - "operationId": "deleteAPIkey", + "description": "Please refer to [updated API](#/datasources/checkDatasourceHealthWithUID) instead", + "operationId": "checkDatasourceHealthByID", "parameters": [ { "in": "path", "name": "id", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], @@ -16331,95 +16707,54 @@ "200": { "$ref": "#/components/responses/okResponse" }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, "401": { "$ref": "#/components/responses/unauthorisedError" }, "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete API key.", + "summary": "Sends a health check request to the plugin datasource identified by the ID.", "tags": [ - "api_keys" + "datasources" ] } }, - "/dashboard/snapshots": { + "/datasources/{id}/resources/{datasource_proxy_route}": { "get": { - "operationId": "searchDashboardSnapshots", + "deprecated": true, + "description": "Please refer to [updated API](#/datasources/callDatasourceResourceWithUID) instead", + "operationId": "callDatasourceResourceByID", "parameters": [ { - "description": "Search Query", - "in": "query", - "name": "query", + "in": "path", + "name": "datasource_proxy_route", + "required": true, "schema": { "type": "string" } }, { - "description": "Limit the number of returned results", - "in": "query", - "name": "limit", + "in": "path", + "name": "id", + "required": true, "schema": { - "default": 1000, - "format": "int64", - "type": "integer" + "type": "string" } } ], "responses": { "200": { - "$ref": "#/components/responses/searchDashboardSnapshotsResponse" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "List snapshots.", - "tags": [ - "snapshots" - ] - } - }, - "/dashboards/calculate-diff": { - "post": { - "operationId": "calculateDashboardDiff", - "requestBody": { - "content": { - "application/json": { - "schema": { - "properties": { - "base": { - "$ref": "#/components/schemas/CalculateDiffTarget" - }, - "diffType": { - "description": "The type of diff to return\nDescription:\n`basic`\n`json`", - "enum": [ - "basic", - "json" - ], - "type": "string" - }, - "new": { - "$ref": "#/components/schemas/CalculateDiffTarget" - } - }, - "type": "object" - } - } + "$ref": "#/components/responses/okResponse" }, - "required": true, - "x-originalParamName": "Body" - }, - "responses": { - "200": { - "$ref": "#/components/responses/calculateDashboardDiffResponse" + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -16427,34 +16762,40 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Perform diff on two dashboards.", + "summary": "Fetch data source resources by Id.", "tags": [ - "dashboards" + "datasources" ] } }, - "/dashboards/db": { + "/ds/query": { "post": { - "description": "Creates a new dashboard or updates an existing dashboard.", - "operationId": "postDashboard", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:query`.", + "operationId": "queryMetricsWithExpressions", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SaveDashboardCommand" + "$ref": "#/components/schemas/MetricRequest" } } }, "required": true, - "x-originalParamName": "Body" + "x-originalParamName": "body" }, "responses": { "200": { - "$ref": "#/components/responses/postDashboardResponse" + "$ref": "#/components/responses/queryMetricsWithExpressionsRespons" + }, + "207": { + "$ref": "#/components/responses/queryMetricsWithExpressionsRespons" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -16465,64 +16806,66 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, - "412": { - "$ref": "#/components/responses/preconditionFailedError" - }, - "422": { - "$ref": "#/components/responses/unprocessableEntityError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Create / Update dashboard", - "tags": [ - "dashboards" - ] - } - }, - "/dashboards/home": { - "get": { - "operationId": "getHomeDashboard", - "responses": { - "200": { - "$ref": "#/components/responses/getHomeDashboardResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get home dashboard.", + "summary": "DataSource query metrics with expressions.", "tags": [ - "dashboards" + "ds" ] } }, - "/dashboards/id/{DashboardID}/permissions": { + "/folders": { "get": { - "deprecated": true, - "description": "Please refer to [updated API](#/dashboard_permissions/getDashboardPermissionsListByUID) instead", - "operationId": "getDashboardPermissionsListByID", + "description": "It returns all folders that the authenticated user has permission to view.\nIf nested folders are enabled, it expects an additional query parameter with the parent folder UID\nand returns the immediate subfolders that the authenticated user has permission to view.\nIf the parameter is not supplied then it returns immediate subfolders under the root\nthat the authenticated user has permission to view.", + "operationId": "getFolders", "parameters": [ { - "in": "path", - "name": "DashboardID", - "required": true, + "description": "Limit the maximum number of folders to return", + "in": "query", + "name": "limit", + "schema": { + "default": 1000, + "format": "int64", + "type": "integer" + } + }, + { + "description": "Page index for starting fetching folders", + "in": "query", + "name": "page", "schema": { + "default": 1, "format": "int64", "type": "integer" } + }, + { + "description": "The parent folder UID", + "in": "query", + "name": "parentUid", + "schema": { + "type": "string" + } + }, + { + "description": "Set to `Edit` to return folders that the user can edit", + "in": "query", + "name": "permission", + "schema": { + "default": "View", + "enum": [ + "Edit", + "View" + ], + "type": "string" + } } ], "responses": { "200": { - "$ref": "#/components/responses/getDashboardPermissionsListResponse" + "$ref": "#/components/responses/getFoldersResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -16530,47 +16873,32 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Gets all existing permissions for the given dashboard.", + "summary": "Get all folders.", "tags": [ - "dashboard_permissions" + "folders" ] }, "post": { - "deprecated": true, - "description": "Please refer to [updated API](#/dashboard_permissions/updateDashboardPermissionsByUID) instead\n\nThis operation will remove existing permissions if they’re not included in the request.", - "operationId": "updateDashboardPermissionsByID", - "parameters": [ - { - "in": "path", - "name": "DashboardID", - "required": true, - "schema": { - "format": "int64", - "type": "integer" - } - } - ], + "description": "If nested folders are enabled then it additionally expects the parent folder UID.", + "operationId": "createFolder", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateDashboardACLCommand" + "$ref": "#/components/schemas/CreateFolderCommand" } } }, "required": true, - "x-originalParamName": "Body" + "x-originalParamName": "body" }, "responses": { "200": { - "$ref": "#/components/responses/okResponse" + "$ref": "#/components/responses/folderResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -16581,28 +16909,28 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" + "409": { + "$ref": "#/components/responses/conflictError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Updates permissions for a dashboard.", + "summary": "Create folder.", "tags": [ - "dashboard_permissions" + "folders" ] } }, - "/dashboards/id/{DashboardID}/restore": { - "post": { + "/folders/id/{folder_id}": { + "get": { "deprecated": true, - "description": "Please refer to [updated API](#/dashboard_versions/restoreDashboardVersionByUID) instead", - "operationId": "restoreDashboardVersionByID", + "description": "Returns the folder identified by id. This is deprecated.\nPlease refer to [updated API](#/folders/getFolderByUID) instead", + "operationId": "getFolderByID", "parameters": [ { "in": "path", - "name": "DashboardID", + "name": "folder_id", "required": true, "schema": { "format": "int64", @@ -16610,20 +16938,9 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestoreDashboardVersionCommand" - } - } - }, - "required": true, - "x-originalParamName": "Body" - }, "responses": { "200": { - "$ref": "#/components/responses/postDashboardResponse" + "$ref": "#/components/responses/folderResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -16638,34 +16955,44 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Restore a dashboard to a given dashboard version.", + "summary": "Get folder by id.", "tags": [ - "dashboard_versions" + "folders" ] } }, - "/dashboards/id/{DashboardID}/versions": { - "get": { - "deprecated": true, - "description": "Please refer to [updated API](#/dashboard_versions/getDashboardVersionsByUID) instead", - "operationId": "getDashboardVersionsByID", + "/folders/{folder_uid}": { + "delete": { + "description": "Deletes an existing folder identified by UID along with all dashboards (and their alerts) stored in the folder. This operation cannot be reverted.\nIf nested folders are enabled then it also deletes all the subfolders.", + "operationId": "deleteFolder", "parameters": [ { "in": "path", - "name": "DashboardID", + "name": "folder_uid", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/dashboardVersionsResponse" }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + { + "description": "If `true` any Grafana 8 Alerts under this folder will be deleted.\nSet to `false` so that the request will fail if the folder contains any Grafana 8 Alerts.", + "in": "query", + "name": "forceDeleteRules", + "schema": { + "default": false, + "type": "boolean" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/deleteFolderResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" }, "403": { "$ref": "#/components/responses/forbiddenError" @@ -16677,40 +17004,26 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Gets all existing versions for the dashboard.", + "summary": "Delete folder.", "tags": [ - "dashboard_versions" + "folders" ] - } - }, - "/dashboards/id/{DashboardID}/versions/{DashboardVersionID}": { + }, "get": { - "deprecated": true, - "description": "Please refer to [updated API](#/dashboard_versions/getDashboardVersionByUID) instead", - "operationId": "getDashboardVersionByID", + "operationId": "getFolderByUID", "parameters": [ { "in": "path", - "name": "DashboardID", - "required": true, - "schema": { - "format": "int64", - "type": "integer" - } - }, - { - "in": "path", - "name": "DashboardVersionID", + "name": "folder_uid", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], "responses": { "200": { - "$ref": "#/components/responses/dashboardVersionResponse" + "$ref": "#/components/responses/folderResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -16725,29 +17038,38 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get a specific dashboard version.", + "summary": "Get folder by uid.", "tags": [ - "dashboard_versions" + "folders" ] - } - }, - "/dashboards/import": { - "post": { - "operationId": "importDashboard", + }, + "put": { + "operationId": "updateFolder", + "parameters": [ + { + "in": "path", + "name": "folder_uid", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportDashboardRequest" + "$ref": "#/components/schemas/UpdateFolderCommand" } } }, + "description": "To change the unique identifier (uid), provide another one.\nTo overwrite an existing folder with newer version, set `overwrite` to `true`.\nProvide the current version to safelly update the folder: if the provided version differs from the stored one the request will fail, unless `overwrite` is `true`.", "required": true, - "x-originalParamName": "Body" + "x-originalParamName": "body" }, "responses": { "200": { - "$ref": "#/components/responses/importDashboardResponse" + "$ref": "#/components/responses/folderResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -16755,73 +17077,32 @@ "401": { "$ref": "#/components/responses/unauthorisedError" }, - "412": { - "$ref": "#/components/responses/preconditionFailedError" - }, - "422": { - "$ref": "#/components/responses/unprocessableEntityError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Import dashboard.", - "tags": [ - "dashboards" - ] - } - }, - "/dashboards/public-dashboards": { - "get": { - "description": "Get list of public dashboards", - "operationId": "listPublicDashboards", - "responses": { - "200": { - "$ref": "#/components/responses/listPublicDashboardsResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedPublicError" - }, "403": { - "$ref": "#/components/responses/forbiddenPublicError" + "$ref": "#/components/responses/forbiddenError" }, - "500": { - "$ref": "#/components/responses/internalServerPublicError" - } - }, - "tags": [ - "dashboard_public" - ] - } - }, - "/dashboards/tags": { - "get": { - "operationId": "getDashboardTags", - "responses": { - "200": { - "$ref": "#/components/responses/getDashboardsTagsResponse" + "404": { + "$ref": "#/components/responses/notFoundError" }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "409": { + "$ref": "#/components/responses/conflictError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get all dashboards tags of an organisation.", + "summary": "Update folder.", "tags": [ - "dashboards" + "folders" ] } }, - "/dashboards/uid/{dashboardUid}/public-dashboards": { + "/folders/{folder_uid}/counts": { "get": { - "description": "Get public dashboard by dashboardUid", - "operationId": "getPublicDashboard", + "operationId": "getFolderDescendantCounts", "parameters": [ { "in": "path", - "name": "dashboardUid", + "name": "folder_uid", "required": true, "schema": { "type": "string" @@ -16830,35 +17111,34 @@ ], "responses": { "200": { - "$ref": "#/components/responses/getPublicDashboardResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestPublicError" + "$ref": "#/components/responses/getFolderDescendantCountsResponse" }, "401": { - "$ref": "#/components/responses/unauthorisedPublicError" + "$ref": "#/components/responses/unauthorisedError" }, "403": { - "$ref": "#/components/responses/forbiddenPublicError" + "$ref": "#/components/responses/forbiddenError" }, "404": { - "$ref": "#/components/responses/notFoundPublicError" + "$ref": "#/components/responses/notFoundError" }, "500": { - "$ref": "#/components/responses/internalServerPublicError" + "$ref": "#/components/responses/internalServerError" } }, + "summary": "Gets the count of each descendant of a folder by kind. The folder is identified by UID.", "tags": [ - "dashboard_public" + "folders" ] - }, + } + }, + "/folders/{folder_uid}/move": { "post": { - "description": "Create public dashboard for a dashboard", - "operationId": "createPublicDashboard", + "operationId": "moveFolder", "parameters": [ { "in": "path", - "name": "dashboardUid", + "name": "folder_uid", "required": true, "schema": { "type": "string" @@ -16869,51 +17149,43 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicDashboardDTO" + "$ref": "#/components/schemas/MoveFolderCommand" } } }, "required": true, - "x-originalParamName": "Body" + "x-originalParamName": "body" }, "responses": { "200": { - "$ref": "#/components/responses/createPublicDashboardResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestPublicError" + "$ref": "#/components/responses/folderResponse" }, "401": { - "$ref": "#/components/responses/unauthorisedPublicError" + "$ref": "#/components/responses/unauthorisedError" }, "403": { - "$ref": "#/components/responses/forbiddenPublicError" + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" }, "500": { - "$ref": "#/components/responses/internalServerPublicError" + "$ref": "#/components/responses/internalServerError" } }, + "summary": "Move folder.", "tags": [ - "dashboard_public" + "folders" ] } }, - "/dashboards/uid/{dashboardUid}/public-dashboards/{uid}": { - "delete": { - "description": "Delete public dashboard for a dashboard", - "operationId": "deletePublicDashboard", + "/folders/{folder_uid}/permissions": { + "get": { + "operationId": "getFolderPermissionList", "parameters": [ { "in": "path", - "name": "dashboardUid", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "uid", + "name": "folder_uid", "required": true, "schema": { "type": "string" @@ -16922,40 +17194,32 @@ ], "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestPublicError" + "$ref": "#/components/responses/getFolderPermissionListResponse" }, "401": { - "$ref": "#/components/responses/unauthorisedPublicError" + "$ref": "#/components/responses/unauthorisedError" }, "403": { - "$ref": "#/components/responses/forbiddenPublicError" + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" }, "500": { - "$ref": "#/components/responses/internalServerPublicError" + "$ref": "#/components/responses/internalServerError" } }, + "summary": "Gets all existing permissions for the folder with the given `uid`.", "tags": [ - "dashboard_public" + "folder_permissions" ] }, - "patch": { - "description": "Update public dashboard for a dashboard", - "operationId": "updatePublicDashboard", + "post": { + "operationId": "updateFolderPermissions", "parameters": [ { "in": "path", - "name": "dashboardUid", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "uid", + "name": "folder_uid", "required": true, "schema": { "type": "string" @@ -16966,7 +17230,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicDashboardDTO" + "$ref": "#/components/schemas/UpdateDashboardACLCommand" } } }, @@ -16975,43 +17239,146 @@ }, "responses": { "200": { - "$ref": "#/components/responses/updatePublicDashboardResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestPublicError" + "$ref": "#/components/responses/okResponse" }, "401": { - "$ref": "#/components/responses/unauthorisedPublicError" + "$ref": "#/components/responses/unauthorisedError" }, "403": { - "$ref": "#/components/responses/forbiddenPublicError" + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" }, "500": { - "$ref": "#/components/responses/internalServerPublicError" + "$ref": "#/components/responses/internalServerError" } }, + "summary": "Updates permissions for a folder. This operation will remove existing permissions if they’re not included in the request.", "tags": [ - "dashboard_public" + "folder_permissions" ] } }, - "/dashboards/uid/{uid}": { - "delete": { - "description": "Will delete the dashboard given the specified unique identifier (uid).", - "operationId": "deleteDashboardByUID", - "parameters": [ + "/library-elements": { + "get": { + "description": "Returns a list of all library elements the authenticated user has permission to view.\nUse the `perPage` query parameter to control the maximum number of library elements returned; the default limit is `100`.\nYou can also use the `page` query parameter to fetch library elements from any page other than the first one.", + "operationId": "getLibraryElements", + "parameters": [ { - "in": "path", - "name": "uid", - "required": true, + "description": "Part of the name or description searched for.", + "in": "query", + "name": "searchString", + "schema": { + "type": "string" + } + }, + { + "description": "Kind of element to search for.", + "in": "query", + "name": "kind", + "schema": { + "enum": [ + 1, + 2 + ], + "format": "int64", + "type": "integer" + } + }, + { + "description": "Sort order of elements.", + "in": "query", + "name": "sortDirection", + "schema": { + "enum": [ + "alpha-asc", + "alpha-desc" + ], + "type": "string" + } + }, + { + "description": "A comma separated list of types to filter the elements by", + "in": "query", + "name": "typeFilter", + "schema": { + "type": "string" + } + }, + { + "description": "Element UID to exclude from search results.", + "in": "query", + "name": "excludeUid", + "schema": { + "type": "string" + } + }, + { + "description": "A comma separated list of folder ID(s) to filter the elements by.", + "in": "query", + "name": "folderFilter", "schema": { "type": "string" } + }, + { + "description": "The number of results per page.", + "in": "query", + "name": "perPage", + "schema": { + "default": 100, + "format": "int64", + "type": "integer" + } + }, + { + "description": "The page for a set of records, given that only perPage records are returned at a time. Numbering starts at 1.", + "in": "query", + "name": "page", + "schema": { + "default": 1, + "format": "int64", + "type": "integer" + } } ], "responses": { "200": { - "$ref": "#/components/responses/deleteDashboardResponse" + "$ref": "#/components/responses/getLibraryElementsResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Get all library elements.", + "tags": [ + "library_elements" + ] + }, + "post": { + "description": "Creates a new library element.", + "operationId": "createLibraryElement", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateLibraryElementCommand" + } + } + }, + "required": true, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/getLibraryElementResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -17026,18 +17393,20 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete dashboard by uid.", + "summary": "Create library element.", "tags": [ - "dashboards" + "library_elements" ] - }, + } + }, + "/library-elements/name/{library_element_name}": { "get": { - "description": "Will return the dashboard given the dashboard unique identifier (uid).", - "operationId": "getDashboardByUID", + "description": "Returns a library element with the given name.", + "operationId": "getLibraryElementByName", "parameters": [ { "in": "path", - "name": "uid", + "name": "library_element_name", "required": true, "schema": { "type": "string" @@ -17046,14 +17415,11 @@ ], "responses": { "200": { - "$ref": "#/components/responses/dashboardResponse" + "$ref": "#/components/responses/getLibraryElementArrayResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, "404": { "$ref": "#/components/responses/notFoundError" }, @@ -17061,19 +17427,20 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get dashboard by uid.", + "summary": "Get library element by name.", "tags": [ - "dashboards" + "library_elements" ] } }, - "/dashboards/uid/{uid}/permissions": { - "get": { - "operationId": "getDashboardPermissionsListByUID", + "/library-elements/{library_element_uid}": { + "delete": { + "description": "Deletes an existing library element as specified by the UID. This operation cannot be reverted.\nYou cannot delete a library element that is connected. This operation cannot be reverted.", + "operationId": "deleteLibraryElementByUID", "parameters": [ { "in": "path", - "name": "uid", + "name": "library_element_uid", "required": true, "schema": { "type": "string" @@ -17082,7 +17449,10 @@ ], "responses": { "200": { - "$ref": "#/components/responses/getDashboardPermissionsListResponse" + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -17097,41 +17467,27 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Gets all existing permissions for the given dashboard.", + "summary": "Delete library element.", "tags": [ - "dashboard_permissions" + "library_elements" ] }, - "post": { - "description": "This operation will remove existing permissions if they’re not included in the request.", - "operationId": "updateDashboardPermissionsByUID", + "get": { + "description": "Returns a library element with the given UID.", + "operationId": "getLibraryElementByUID", "parameters": [ { "in": "path", - "name": "uid", + "name": "library_element_uid", "required": true, "schema": { "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateDashboardACLCommand" - } - } - }, - "required": true, - "x-originalParamName": "Body" - }, "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/getLibraryElementResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -17146,19 +17502,18 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Updates permissions for a dashboard.", + "summary": "Get library element by UID.", "tags": [ - "dashboard_permissions" + "library_elements" ] - } - }, - "/dashboards/uid/{uid}/restore": { - "post": { - "operationId": "restoreDashboardVersionByUID", + }, + "patch": { + "description": "Updates an existing library element identified by uid.", + "operationId": "updateLibraryElement", "parameters": [ { "in": "path", - "name": "uid", + "name": "library_element_uid", "required": true, "schema": { "type": "string" @@ -17169,16 +17524,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RestoreDashboardVersionCommand" + "$ref": "#/components/schemas/PatchLibraryElementCommand" } } }, "required": true, - "x-originalParamName": "Body" + "x-originalParamName": "body" }, "responses": { "200": { - "$ref": "#/components/responses/postDashboardResponse" + "$ref": "#/components/responses/getLibraryElementResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -17189,52 +17547,36 @@ "404": { "$ref": "#/components/responses/notFoundError" }, + "412": { + "$ref": "#/components/responses/preconditionFailedError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Restore a dashboard to a given dashboard version using UID.", + "summary": "Update library element.", "tags": [ - "dashboard_versions" + "library_elements" ] } }, - "/dashboards/uid/{uid}/versions": { + "/library-elements/{library_element_uid}/connections/": { "get": { - "operationId": "getDashboardVersionsByUID", + "description": "Returns a list of connections for a library element based on the UID specified.", + "operationId": "getLibraryElementConnections", "parameters": [ { "in": "path", - "name": "uid", + "name": "library_element_uid", "required": true, "schema": { "type": "string" } - }, - { - "description": "Maximum number of results to return", - "in": "query", - "name": "limit", - "schema": { - "default": 0, - "format": "int64", - "type": "integer" - } - }, - { - "description": "Version to start from when returning queries", - "in": "query", - "name": "start", - "schema": { - "default": 0, - "format": "int64", - "type": "integer" - } } ], "responses": { "200": { - "$ref": "#/components/responses/dashboardVersionsResponse" + "$ref": "#/components/responses/getLibraryElementConnectionsResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -17249,97 +17591,101 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Gets all existing versions for the dashboard using UID.", + "summary": "Get library element connections.", "tags": [ - "dashboard_versions" + "library_elements" ] } }, - "/dashboards/uid/{uid}/versions/{DashboardVersionID}": { + "/licensing/check": { "get": { - "operationId": "getDashboardVersionByUID", - "parameters": [ - { - "in": "path", - "name": "DashboardVersionID", - "required": true, - "schema": { - "format": "int64", - "type": "integer" - } - }, - { - "in": "path", - "name": "uid", - "required": true, - "schema": { - "type": "string" - } - } - ], + "operationId": "getStatus", "responses": { "200": { - "$ref": "#/components/responses/dashboardVersionResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, + "$ref": "#/components/responses/getStatusResponse" + } + }, + "summary": "Check license availability.", + "tags": [ + "licensing", + "enterprise" + ] + } + }, + "/licensing/custom-permissions": { + "get": { + "deprecated": true, + "description": "You need to have a permission with action `licensing.reports:read`.", + "operationId": "getCustomPermissionsReport", + "responses": { "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get a specific dashboard version using UID.", + "summary": "Get custom permissions report.", "tags": [ - "dashboard_versions" + "licensing", + "enterprise" ] } }, - "/datasources": { + "/licensing/custom-permissions-csv": { "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scope: `datasources:*`.", - "operationId": "getDataSources", + "deprecated": true, + "description": "You need to have a permission with action `licensing.reports:read`.", + "operationId": "getCustomPermissionsCSV", + "responses": { + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Get custom permissions report in CSV format.", + "tags": [ + "licensing", + "enterprise" + ] + } + }, + "/licensing/refresh-stats": { + "get": { + "description": "You need to have a permission with action `licensing:read`.", + "operationId": "refreshLicenseStats", "responses": { "200": { - "$ref": "#/components/responses/getDataSourcesResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" + "$ref": "#/components/responses/refreshLicenseStatsResponse" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get all data sources.", + "summary": "Refresh license stats.", "tags": [ - "datasources" + "licensing", + "enterprise" ] - }, - "post": { - "description": "By defining `password` and `basicAuthPassword` under secureJsonData property\nGrafana encrypts them securely as an encrypted blob in the database.\nThe response then lists the encrypted fields under secureJsonFields.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:create`", - "operationId": "addDataSource", + } + }, + "/licensing/token": { + "delete": { + "description": "Removes the license stored in the Grafana database. Available in Grafana Enterprise v7.4+.\n\nYou need to have a permission with action `licensing:delete`.", + "operationId": "deleteLicenseToken", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddDataSourceCommand" + "$ref": "#/components/schemas/DeleteTokenCommand" } } }, "required": true, - "x-originalParamName": "Body" + "x-originalParamName": "body" }, "responses": { - "200": { - "$ref": "#/components/responses/createOrUpdateDatasourceResponse" + "202": { + "$ref": "#/components/responses/acceptedResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -17347,99 +17693,101 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "409": { - "$ref": "#/components/responses/conflictError" + "422": { + "$ref": "#/components/responses/unprocessableEntityError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Create a data source.", + "summary": "Remove license from database.", "tags": [ - "datasources" + "licensing", + "enterprise" ] - } - }, - "/datasources/correlations": { + }, "get": { - "operationId": "getCorrelations", - "parameters": [ - { - "description": "Limit the maximum number of correlations to return per page", - "in": "query", - "name": "limit", - "schema": { - "default": 100, - "format": "int64", - "maximum": 1000, - "type": "integer" + "description": "You need to have a permission with action `licensing:read`.", + "operationId": "getLicenseToken", + "responses": { + "200": { + "$ref": "#/components/responses/getLicenseTokenResponse" + } + }, + "summary": "Get license token.", + "tags": [ + "licensing", + "enterprise" + ] + }, + "post": { + "description": "You need to have a permission with action `licensing:update`.", + "operationId": "postLicenseToken", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteTokenCommand" + } } }, - { - "description": "Page index for starting fetching correlations", - "in": "query", - "name": "page", - "schema": { - "default": 1, - "format": "int64", - "type": "integer" - } + "required": true, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/getLicenseTokenResponse" }, - { - "description": "Source datasource UID filter to be applied to correlations", - "in": "query", - "name": "sourceUID", - "schema": { - "items": { - "type": "string" - }, - "type": "array" - } + "400": { + "$ref": "#/components/responses/badRequestError" } - ], + }, + "summary": "Create license token.", + "tags": [ + "licensing", + "enterprise" + ] + } + }, + "/licensing/token/renew": { + "post": { + "description": "Manually ask license issuer for a new token. Available in Grafana Enterprise v7.4+.\n\nYou need to have a permission with action `licensing:update`.", + "operationId": "postRenewLicenseToken", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": true, + "x-originalParamName": "body" + }, "responses": { "200": { - "$ref": "#/components/responses/getCorrelationsResponse" + "$ref": "#/components/responses/postRenewLicenseTokenResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, "404": { "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" } }, - "summary": "Gets all correlations.", + "summary": "Manually force license refresh.", "tags": [ - "correlations" + "licensing", + "enterprise" ] } }, - "/datasources/id/{name}": { + "/logout/saml": { "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:name:*` and `datasources:name:test_datasource` (single data source).", - "operationId": "getDataSourceIdByName", - "parameters": [ - { - "in": "path", - "name": "name", - "required": true, - "schema": { - "type": "string" - } - } - ], + "operationId": "getSAMLLogout", "responses": { - "200": { - "$ref": "#/components/responses/getDataSourceIDResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" + "302": { + "description": "(empty)" }, "404": { "$ref": "#/components/responses/notFoundError" @@ -17448,29 +17796,19 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get data source Id by Name.", + "summary": "GetLogout initiates single logout process.", "tags": [ - "datasources" + "saml", + "enterprise" ] } }, - "/datasources/name/{name}": { - "delete": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:name:*` and `datasources:name:test_datasource` (single data source).", - "operationId": "deleteDataSourceByName", - "parameters": [ - { - "in": "path", - "name": "name", - "required": true, - "schema": { - "type": "string" - } - } - ], + "/org": { + "get": { + "operationId": "getCurrentOrg", "responses": { "200": { - "$ref": "#/components/responses/deleteDataSourceByNameResponse" + "$ref": "#/components/responses/getCurrentOrgResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -17478,34 +17816,34 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete an existing data source by name.", + "summary": "Get current Organization.", "tags": [ - "datasources" + "org" ] }, - "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:name:*` and `datasources:name:test_datasource` (single data source).", - "operationId": "getDataSourceByName", - "parameters": [ - { - "in": "path", - "name": "name", - "required": true, - "schema": { - "type": "string" + "put": { + "operationId": "updateCurrentOrg", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrgForm" + } } - } - ], + }, + "required": true, + "x-originalParamName": "body" + }, "responses": { "200": { - "$ref": "#/components/responses/getDataSourceResponse" + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -17517,37 +17855,29 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get a single data source by Name.", + "summary": "Update current Organization.", "tags": [ - "datasources" + "org" ] } }, - "/datasources/proxy/uid/{uid}/{datasource_proxy_route}": { - "delete": { - "description": "Proxies all calls to the actual data source.", - "operationId": "datasourceProxyDELETEByUIDcalls", - "parameters": [ - { - "in": "path", - "name": "uid", - "required": true, - "schema": { - "type": "string" + "/org/address": { + "put": { + "operationId": "updateCurrentOrgAddress", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrgAddressForm" + } } }, - { - "in": "path", - "name": "datasource_proxy_route", - "required": true, - "schema": { - "type": "string" - } - } - ], + "required": true, + "x-originalParamName": "body" + }, "responses": { - "202": { - "description": "(empty)" + "200": { + "$ref": "#/components/responses/okResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -17558,45 +17888,22 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Data source proxy DELETE calls.", + "summary": "Update current Organization's address.", "tags": [ - "datasources" + "org" ] - }, + } + }, + "/org/invites": { "get": { - "description": "Proxies all calls to the actual data source.", - "operationId": "datasourceProxyGETByUIDcalls", - "parameters": [ - { - "in": "path", - "name": "datasource_proxy_route", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "uid", - "required": true, - "schema": { - "type": "string" - } - } - ], + "operationId": "getPendingOrgInvites", "responses": { "200": { - "description": "(empty)" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/getPendingOrgInvitesResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -17604,54 +17911,31 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Data source proxy GET calls.", + "summary": "Get pending invites.", "tags": [ - "datasources" + "org_invites" ] }, "post": { - "description": "Proxies all calls to the actual data source. The data source should support POST methods for the specific path and role as defined", - "operationId": "datasourceProxyPOSTByUIDcalls", - "parameters": [ - { - "in": "path", - "name": "datasource_proxy_route", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "uid", - "required": true, - "schema": { - "type": "string" - } - } - ], + "operationId": "addOrgInvite", "requestBody": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/AddInviteForm" + } } }, "required": true, - "x-originalParamName": "DatasourceProxyParam" + "x-originalParamName": "body" }, "responses": { - "201": { - "description": "(empty)" - }, - "202": { - "description": "(empty)" + "200": { + "$ref": "#/components/responses/okResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -17662,36 +17946,26 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" + "412": { + "$ref": "#/components/responses/SMTPNotEnabledError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Data source proxy POST calls.", + "summary": "Add invite.", "tags": [ - "datasources" + "org_invites" ] } }, - "/datasources/proxy/{id}/{datasource_proxy_route}": { + "/org/invites/{invitation_code}/revoke": { "delete": { - "deprecated": true, - "description": "Proxies all calls to the actual data source.\n\nPlease refer to [updated API](#/datasources/datasourceProxyDELETEByUIDcalls) instead", - "operationId": "datasourceProxyDELETEcalls", + "operationId": "revokeInvite", "parameters": [ { "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "datasource_proxy_route", + "name": "invitation_code", "required": true, "schema": { "type": "string" @@ -17699,11 +17973,8 @@ } ], "responses": { - "202": { - "description": "(empty)" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "200": { + "$ref": "#/components/responses/okResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -17718,39 +17989,18 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Data source proxy DELETE calls.", + "summary": "Revoke invite.", "tags": [ - "datasources" + "org_invites" ] - }, + } + }, + "/org/preferences": { "get": { - "deprecated": true, - "description": "Proxies all calls to the actual data source.\n\nPlease refer to [updated API](#/datasources/datasourceProxyGETByUIDcalls) instead", - "operationId": "datasourceProxyGETcalls", - "parameters": [ - { - "in": "path", - "name": "datasource_proxy_route", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], + "operationId": "getOrgPreferences", "responses": { "200": { - "description": "(empty)" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/getPreferencesResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -17758,55 +18008,31 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Data source proxy GET calls.", + "summary": "Get Current Org Prefs.", "tags": [ - "datasources" + "org_preferences" ] }, - "post": { - "deprecated": true, - "description": "Proxies all calls to the actual data source. The data source should support POST methods for the specific path and role as defined\n\nPlease refer to [updated API](#/datasources/datasourceProxyPOSTByUIDcalls) instead", - "operationId": "datasourceProxyPOSTcalls", - "parameters": [ - { - "in": "path", - "name": "datasource_proxy_route", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], + "patch": { + "operationId": "patchOrgPreferences", "requestBody": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/PatchPrefsCmd" + } } }, "required": true, - "x-originalParamName": "DatasourceProxyParam" + "x-originalParamName": "body" }, "responses": { - "201": { - "description": "(empty)" - }, - "202": { - "description": "(empty)" + "200": { + "$ref": "#/components/responses/okResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -17817,68 +18043,22 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Data source proxy POST calls.", - "tags": [ - "datasources" - ] - } - }, - "/datasources/uid/{sourceUID}/correlations": { - "get": { - "operationId": "getCorrelationsBySourceUID", - "parameters": [ - { - "in": "path", - "name": "sourceUID", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/getCorrelationsBySourceUIDResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Gets all correlations originating from the given data source.", + "summary": "Patch Current Org Prefs.", "tags": [ - "correlations" + "org_preferences" ] }, - "post": { - "operationId": "createCorrelation", - "parameters": [ - { - "in": "path", - "name": "sourceUID", - "required": true, - "schema": { - "type": "string" - } - } - ], + "put": { + "operationId": "updateOrgPreferences", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateCorrelationCommand" + "$ref": "#/components/schemas/UpdatePrefsCmd" } } }, @@ -17887,7 +18067,7 @@ }, "responses": { "200": { - "$ref": "#/components/responses/createCorrelationResponse" + "$ref": "#/components/responses/okResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -17898,47 +18078,30 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Add correlation.", + "summary": "Update Current Org Prefs.", "tags": [ - "correlations" + "org_preferences" ] } }, - "/datasources/uid/{sourceUID}/correlations/{correlationUID}": { + "/org/quotas": { "get": { - "operationId": "getCorrelation", - "parameters": [ - { - "in": "path", - "name": "sourceUID", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "correlationUID", - "required": true, - "schema": { - "type": "string" - } - } - ], + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:read` and scope `org:id:1` (orgIDScope).", + "operationId": "getCurrentOrgQuota", "responses": { "200": { - "$ref": "#/components/responses/getCorrelationResponse" + "$ref": "#/components/responses/getQuotaResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, "404": { "$ref": "#/components/responses/notFoundError" }, @@ -17946,47 +18109,52 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Gets a correlation.", + "summary": "Fetch Organization quota.", "tags": [ - "correlations" + "getCurrentOrg" ] - }, - "patch": { - "operationId": "updateCorrelation", - "parameters": [ - { - "in": "path", - "name": "sourceUID", - "required": true, - "schema": { - "type": "string" - } + } + }, + "/org/users": { + "get": { + "description": "Returns all org users within the current organization. Accessible to users with org admin role.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", + "operationId": "getOrgUsersForCurrentOrg", + "responses": { + "200": { + "$ref": "#/components/responses/getOrgUsersForCurrentOrgResponse" }, - { - "in": "path", - "name": "correlationUID", - "required": true, - "schema": { - "type": "string" - } + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } - ], + }, + "summary": "Get all users within the current organization.", + "tags": [ + "org" + ] + }, + "post": { + "description": "Adds a global user to the current organization.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:add` with scope `users:*`.", + "operationId": "addOrgUserToCurrentOrg", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateCorrelationCommand" + "$ref": "#/components/schemas/AddOrgUserCommand" } } }, + "required": true, "x-originalParamName": "body" }, "responses": { "200": { - "$ref": "#/components/responses/updateCorrelationResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/okResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -17994,36 +18162,40 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Updates a correlation.", + "summary": "Add a new user to the current organization.", "tags": [ - "correlations" + "org" ] } }, - "/datasources/uid/{uid}": { - "delete": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:kLtEtcRGk` (single data source).", - "operationId": "deleteDataSourceByUID", + "/org/users/lookup": { + "get": { + "description": "Returns all org users within the current organization, but with less detailed information.\nAccessible to users with org admin role, admin in any folder or admin of any team.\nMainly used by Grafana UI for providing list of users when adding team members and when editing folder/dashboard permissions.", + "operationId": "getOrgUsersForCurrentOrgLookup", "parameters": [ { - "in": "path", - "name": "uid", - "required": true, + "in": "query", + "name": "query", "schema": { "type": "string" } + }, + { + "in": "query", + "name": "limit", + "schema": { + "format": "int64", + "type": "integer" + } } ], "responses": { "200": { - "$ref": "#/components/responses/okResponse" + "$ref": "#/components/responses/getOrgUsersForCurrentOrgLookupResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -18031,34 +18203,34 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete an existing data source by UID.", + "summary": "Get all users within the current organization (lookup)", "tags": [ - "datasources" + "org" ] - }, - "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:kLtEtcRGk` (single data source).", - "operationId": "getDataSourceByUID", + } + }, + "/org/users/{user_id}": { + "delete": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:remove` with scope `users:*`.", + "operationId": "removeOrgUserForCurrentOrg", "parameters": [ { "in": "path", - "name": "uid", + "name": "user_id", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], "responses": { "200": { - "$ref": "#/components/responses/getDataSourceResponse" + "$ref": "#/components/responses/okResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -18069,28 +18241,26 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get a single data source by UID.", + "summary": "Delete user in current organization.", "tags": [ - "datasources" + "org" ] }, - "put": { - "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).", - "operationId": "updateDataSourceByUID", + "patch": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users.role:update` with scope `users:*`.", + "operationId": "updateOrgUserForCurrentOrg", "parameters": [ { "in": "path", - "name": "uid", + "name": "user_id", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], @@ -18098,16 +18268,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateDataSourceCommand" + "$ref": "#/components/schemas/UpdateOrgUserCommand" } } }, "required": true, - "x-originalParamName": "Body" + "x-originalParamName": "body" }, "responses": { "200": { - "$ref": "#/components/responses/createOrUpdateDatasourceResponse" + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -18119,28 +18292,46 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update an existing data source.", + "summary": "Updates the given user.", "tags": [ - "datasources" + "org" ] } }, - "/datasources/uid/{uid}/correlations/{correlationUID}": { - "delete": { - "operationId": "deleteCorrelation", + "/orgs": { + "get": { + "operationId": "searchOrgs", "parameters": [ { - "in": "path", - "name": "uid", - "required": true, + "in": "query", + "name": "page", "schema": { - "type": "string" - } + "default": 1, + "format": "int64", + "type": "integer" + } }, { - "in": "path", - "name": "correlationUID", - "required": true, + "description": "Number of items per page\nThe totalCount field in the response can be used for pagination list E.g. if totalCount is equal to 100 teams and the perpage parameter is set to 10 then there are 10 pages of teams.", + "in": "query", + "name": "perpage", + "schema": { + "default": 1000, + "format": "int64", + "type": "integer" + } + }, + { + "in": "query", + "name": "name", + "schema": { + "type": "string" + } + }, + { + "description": "If set it will return results where the query value is contained in the name field. Query values with spaces need to be URL encoded.", + "in": "query", + "name": "query", "schema": { "type": "string" } @@ -18148,7 +18339,7 @@ ], "responses": { "200": { - "$ref": "#/components/responses/deleteCorrelationResponse" + "$ref": "#/components/responses/searchOrgsResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -18156,38 +18347,40 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" + "409": { + "$ref": "#/components/responses/conflictError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete a correlation.", - "tags": [ - "correlations" - ] - } - }, - "/datasources/uid/{uid}/health": { - "get": { - "operationId": "checkDatasourceHealthWithUID", - "parameters": [ + "security": [ { - "in": "path", - "name": "uid", - "required": true, - "schema": { - "type": "string" - } + "basic": [] } ], + "summary": "Search all Organizations.", + "tags": [ + "orgs" + ] + }, + "post": { + "description": "Only works if [users.allow_org_create](https://grafana.com/docs/grafana/latest/administration/configuration/#allow_org_create) is set.", + "operationId": "createOrg", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrgCommand" + } + } + }, + "required": true, + "x-originalParamName": "body" + }, "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/createOrgResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -18195,31 +18388,26 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "409": { + "$ref": "#/components/responses/conflictError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Sends a health check request to the plugin datasource identified by the UID.", + "summary": "Create Organization.", "tags": [ - "datasources" + "orgs" ] } }, - "/datasources/uid/{uid}/resources/{datasource_proxy_route}": { + "/orgs/name/{org_name}": { "get": { - "operationId": "callDatasourceResourceWithUID", + "operationId": "getOrgByName", "parameters": [ { "in": "path", - "name": "datasource_proxy_route", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "uid", + "name": "org_name", "required": true, "schema": { "type": "string" @@ -18228,10 +18416,7 @@ ], "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/getOrgByNameResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -18239,31 +18424,32 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Fetch data source resources.", + "security": [ + { + "basic": [] + } + ], + "summary": "Get Organization by ID.", "tags": [ - "datasources" + "orgs" ] } }, - "/datasources/{id}": { + "/orgs/{org_id}": { "delete": { - "deprecated": true, - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).\n\nPlease refer to [updated API](#/datasources/deleteDataSourceByUID) instead", - "operationId": "deleteDataSourceByID", + "operationId": "deleteOrgByID", "parameters": [ { "in": "path", - "name": "id", + "name": "org_id", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], @@ -18271,6 +18457,9 @@ "200": { "$ref": "#/components/responses/okResponse" }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, "401": { "$ref": "#/components/responses/unauthorisedError" }, @@ -18284,31 +18473,32 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete an existing data source by id.", + "security": [ + { + "basic": [] + } + ], + "summary": "Delete Organization.", "tags": [ - "datasources" + "orgs" ] }, "get": { - "deprecated": true, - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).\n\nPlease refer to [updated API](#/datasources/getDataSourceByUID) instead", - "operationId": "getDataSourceByID", + "operationId": "getOrgByID", "parameters": [ { "in": "path", - "name": "id", + "name": "org_id", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], "responses": { "200": { - "$ref": "#/components/responses/getDataSourceResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/getOrgByIDResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -18316,29 +18506,30 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get a single data source by Id.", + "security": [ + { + "basic": [] + } + ], + "summary": "Get Organization by ID.", "tags": [ - "datasources" + "orgs" ] }, "put": { - "deprecated": true, - "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).\n\nPlease refer to [updated API](#/datasources/updateDataSourceByUID) instead", - "operationId": "updateDataSourceByID", + "operationId": "updateOrg", "parameters": [ { "in": "path", - "name": "id", + "name": "org_id", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], @@ -18346,16 +18537,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateDataSourceCommand" + "$ref": "#/components/schemas/UpdateOrgForm" } } }, "required": true, - "x-originalParamName": "Body" + "x-originalParamName": "body" }, "responses": { "200": { - "$ref": "#/components/responses/createOrUpdateDatasourceResponse" + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -18367,27 +18561,42 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update an existing data source by its sequential ID.", + "security": [ + { + "basic": [] + } + ], + "summary": "Update Organization.", "tags": [ - "datasources" + "orgs" ] } }, - "/datasources/{id}/health": { - "get": { - "deprecated": true, - "description": "Please refer to [updated API](#/datasources/checkDatasourceHealthWithUID) instead", - "operationId": "checkDatasourceHealthByID", + "/orgs/{org_id}/address": { + "put": { + "operationId": "updateOrgAddress", "parameters": [ { "in": "path", - "name": "id", + "name": "org_id", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrgAddressForm" + } + } + }, + "required": true, + "x-originalParamName": "body" + }, "responses": { "200": { "$ref": "#/components/responses/okResponse" @@ -18405,41 +18614,30 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Sends a health check request to the plugin datasource identified by the ID.", + "summary": "Update Organization's address.", "tags": [ - "datasources" + "orgs" ] } }, - "/datasources/{id}/resources/{datasource_proxy_route}": { + "/orgs/{org_id}/quotas": { "get": { - "deprecated": true, - "description": "Please refer to [updated API](#/datasources/callDatasourceResourceWithUID) instead", - "operationId": "callDatasourceResourceByID", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:read` and scope `org:id:1` (orgIDScope).", + "operationId": "getOrgQuota", "parameters": [ { "in": "path", - "name": "datasource_proxy_route", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "id", + "name": "org_id", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/getQuotaResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -18454,22 +18652,41 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Fetch data source resources by Id.", + "summary": "Fetch Organization quota.", "tags": [ - "datasources" + "orgs" ] } }, - "/ds/query": { - "post": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:query`.", - "operationId": "queryMetricsWithExpressions", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MetricRequest" - } + "/orgs/{org_id}/quotas/{quota_target}": { + "put": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:write` and scope `org:id:1` (orgIDScope).", + "operationId": "updateOrgQuota", + "parameters": [ + { + "in": "path", + "name": "quota_target", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "org_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateQuotaCmd" + } } }, "required": true, @@ -18477,13 +18694,7 @@ }, "responses": { "200": { - "$ref": "#/components/responses/queryMetricsWithExpressionsRespons" - }, - "207": { - "$ref": "#/components/responses/queryMetricsWithExpressionsRespons" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/okResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -18491,53 +18702,42 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "DataSource query metrics with expressions.", + "security": [ + { + "basic": [] + } + ], + "summary": "Update user quota.", "tags": [ - "ds" + "orgs" ] } }, - "/folders": { + "/orgs/{org_id}/users": { "get": { - "description": "Returns all folders that the authenticated user has permission to view.\nIf nested folders are enabled, it expects an additional query parameter with the parent folder UID\nand returns the immediate subfolders that the authenticated user has permission to view.\nIf the parameter is not supplied then it returns immediate subfolders under the root\nthat the authenticated user has permission to view.", - "operationId": "getFolders", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", + "operationId": "getOrgUsers", "parameters": [ { - "description": "Limit the maximum number of folders to return", - "in": "query", - "name": "limit", - "schema": { - "default": 1000, - "format": "int64", - "type": "integer" - } - }, - { - "description": "Page index for starting fetching folders", - "in": "query", - "name": "page", + "in": "path", + "name": "org_id", + "required": true, "schema": { - "default": 1, "format": "int64", "type": "integer" } - }, - { - "description": "The parent folder UID", - "in": "query", - "name": "parentUid", - "schema": { - "type": "string" - } } ], "responses": { "200": { - "$ref": "#/components/responses/getFoldersResponse" + "$ref": "#/components/responses/getOrgUsersResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -18549,19 +18749,35 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get all folders.", + "security": [ + { + "basic": [] + } + ], + "summary": "Get Users in Organization.", "tags": [ - "folders" + "orgs" ] }, "post": { - "description": "If nested folders are enabled then it additionally expects the parent folder UID.", - "operationId": "createFolder", + "description": "Adds a global user to the current organization.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:add` with scope `users:*`.", + "operationId": "addOrgUser", + "parameters": [ + { + "in": "path", + "name": "org_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFolderCommand" + "$ref": "#/components/schemas/AddOrgUserCommand" } } }, @@ -18570,10 +18786,7 @@ }, "responses": { "200": { - "$ref": "#/components/responses/folderResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/okResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -18581,28 +18794,24 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "409": { - "$ref": "#/components/responses/conflictError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Create folder.", + "summary": "Add a new user to the current organization.", "tags": [ - "folders" + "orgs" ] } }, - "/folders/id/{folder_id}": { + "/orgs/{org_id}/users/search": { "get": { - "deprecated": true, - "description": "Returns the folder identified by id. This is deprecated.\nPlease refer to [updated API](#/folders/getFolderByUID) instead", - "operationId": "getFolderByID", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", + "operationId": "searchOrgUsers", "parameters": [ { "in": "path", - "name": "folder_id", + "name": "org_id", "required": true, "schema": { "format": "int64", @@ -18612,7 +18821,7 @@ ], "responses": { "200": { - "$ref": "#/components/responses/folderResponse" + "$ref": "#/components/responses/searchOrgUsersResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -18620,45 +18829,48 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get folder by id.", + "security": [ + { + "basic": [] + } + ], + "summary": "Search Users in Organization.", "tags": [ - "folders" + "orgs" ] } }, - "/folders/{folder_uid}": { + "/orgs/{org_id}/users/{user_id}": { "delete": { - "description": "Deletes an existing folder identified by UID along with all dashboards (and their alerts) stored in the folder. This operation cannot be reverted.\nIf nested folders are enabled then it also deletes all the subfolders.", - "operationId": "deleteFolder", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:remove` with scope `users:*`.", + "operationId": "removeOrgUser", "parameters": [ { "in": "path", - "name": "folder_uid", + "name": "org_id", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } }, { - "description": "If `true` any Grafana 8 Alerts under this folder will be deleted.\nSet to `false` so that the request will fail if the folder contains any Grafana 8 Alerts.", - "in": "query", - "name": "forceDeleteRules", + "in": "path", + "name": "user_id", + "required": true, "schema": { - "default": false, - "type": "boolean" + "format": "int64", + "type": "integer" } } ], "responses": { "200": { - "$ref": "#/components/responses/deleteFolderResponse" + "$ref": "#/components/responses/okResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -18669,61 +18881,35 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete folder.", + "summary": "Delete user in current organization.", "tags": [ - "folders" + "orgs" ] }, - "get": { - "operationId": "getFolderByUID", + "patch": { + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users.role:update` with scope `users:*`.", + "operationId": "updateOrgUser", "parameters": [ { "in": "path", - "name": "folder_uid", + "name": "org_id", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/folderResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Get folder by uid.", - "tags": [ - "folders" - ] - }, - "put": { - "operationId": "updateFolder", - "parameters": [ { "in": "path", - "name": "folder_uid", + "name": "user_id", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], @@ -18731,17 +18917,16 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateFolderCommand" + "$ref": "#/components/schemas/UpdateOrgUserCommand" } } }, - "description": "To change the unique identifier (uid), provide another one.\nTo overwrite an existing folder with newer version, set `overwrite` to `true`.\nProvide the current version to safelly update the folder: if the provided version differs from the stored one the request will fail, unless `overwrite` is `true`.", "required": true, "x-originalParamName": "body" }, "responses": { "200": { - "$ref": "#/components/responses/folderResponse" + "$ref": "#/components/responses/okResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -18752,85 +18937,66 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, - "409": { - "$ref": "#/components/responses/conflictError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update folder.", + "summary": "Update Users in Organization.", "tags": [ - "folders" + "orgs" ] } }, - "/folders/{folder_uid}/counts": { + "/playlists": { "get": { - "operationId": "getFolderDescendantCounts", + "operationId": "searchPlaylists", "parameters": [ { - "in": "path", - "name": "folder_uid", - "required": true, + "in": "query", + "name": "query", "schema": { "type": "string" } + }, + { + "description": "in:limit", + "in": "query", + "name": "limit", + "schema": { + "format": "int64", + "type": "integer" + } } ], "responses": { "200": { - "$ref": "#/components/responses/getFolderDescendantCountsResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" + "$ref": "#/components/responses/searchPlaylistsResponse" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Gets the count of each descendant of a folder by kind. The folder is identified by UID.", + "summary": "Get playlists.", "tags": [ - "folders" + "playlists" ] - } - }, - "/folders/{folder_uid}/move": { + }, "post": { - "operationId": "moveFolder", - "parameters": [ - { - "in": "path", - "name": "folder_uid", - "required": true, - "schema": { - "type": "string" - } - } - ], + "operationId": "createPlaylist", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MoveFolderCommand" + "$ref": "#/components/schemas/CreatePlaylistCommand" } } }, "required": true, - "x-originalParamName": "body" + "x-originalParamName": "Body" }, "responses": { "200": { - "$ref": "#/components/responses/folderResponse" + "$ref": "#/components/responses/createPlaylistResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -18845,19 +19011,19 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Move folder.", + "summary": "Create playlist.", "tags": [ - "folders" + "playlists" ] } }, - "/folders/{folder_uid}/permissions": { - "get": { - "operationId": "getFolderPermissionList", + "/playlists/{uid}": { + "delete": { + "operationId": "deletePlaylist", "parameters": [ { "in": "path", - "name": "folder_uid", + "name": "uid", "required": true, "schema": { "type": "string" @@ -18866,7 +19032,7 @@ ], "responses": { "200": { - "$ref": "#/components/responses/getFolderPermissionListResponse" + "$ref": "#/components/responses/okResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -18881,37 +19047,26 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Gets all existing permissions for the folder with the given `uid`.", + "summary": "Delete playlist.", "tags": [ - "folder_permissions" + "playlists" ] }, - "post": { - "operationId": "updateFolderPermissions", + "get": { + "operationId": "getPlaylist", "parameters": [ { "in": "path", - "name": "folder_uid", + "name": "uid", "required": true, "schema": { "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateDashboardACLCommand" - } - } - }, - "required": true, - "x-originalParamName": "Body" - }, "responses": { "200": { - "$ref": "#/components/responses/okResponse" + "$ref": "#/components/responses/getPlaylistResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -18926,131 +19081,37 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Updates permissions for a folder. This operation will remove existing permissions if they’re not included in the request.", + "summary": "Get playlist.", "tags": [ - "folder_permissions" + "playlists" ] - } - }, - "/library-elements": { - "get": { - "description": "Returns a list of all library elements the authenticated user has permission to view.\nUse the `perPage` query parameter to control the maximum number of library elements returned; the default limit is `100`.\nYou can also use the `page` query parameter to fetch library elements from any page other than the first one.", - "operationId": "getLibraryElements", + }, + "put": { + "operationId": "updatePlaylist", "parameters": [ { - "description": "Part of the name or description searched for.", - "in": "query", - "name": "searchString", - "schema": { - "type": "string" - } - }, - { - "description": "Kind of element to search for.", - "in": "query", - "name": "kind", - "schema": { - "enum": [ - 1, - 2 - ], - "format": "int64", - "type": "integer" - } - }, - { - "description": "Sort order of elements.", - "in": "query", - "name": "sortDirection", - "schema": { - "enum": [ - "alpha-asc", - "alpha-desc" - ], - "type": "string" - } - }, - { - "description": "A comma separated list of types to filter the elements by", - "in": "query", - "name": "typeFilter", - "schema": { - "type": "string" - } - }, - { - "description": "Element UID to exclude from search results.", - "in": "query", - "name": "excludeUid", - "schema": { - "type": "string" - } - }, - { - "description": "A comma separated list of folder ID(s) to filter the elements by.", - "in": "query", - "name": "folderFilter", + "in": "path", + "name": "uid", + "required": true, "schema": { "type": "string" } - }, - { - "description": "The number of results per page.", - "in": "query", - "name": "perPage", - "schema": { - "default": 100, - "format": "int64", - "type": "integer" - } - }, - { - "description": "The page for a set of records, given that only perPage records are returned at a time. Numbering starts at 1.", - "in": "query", - "name": "page", - "schema": { - "default": 1, - "format": "int64", - "type": "integer" - } } ], - "responses": { - "200": { - "$ref": "#/components/responses/getLibraryElementsResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Get all library elements.", - "tags": [ - "library_elements" - ] - }, - "post": { - "description": "Creates a new library element.", - "operationId": "createLibraryElement", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateLibraryElementCommand" + "$ref": "#/components/schemas/UpdatePlaylistCommand" } } }, "required": true, - "x-originalParamName": "body" + "x-originalParamName": "Body" }, "responses": { "200": { - "$ref": "#/components/responses/getLibraryElementResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/updatePlaylistResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -19065,20 +19126,19 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Create library element.", + "summary": "Update playlist.", "tags": [ - "library_elements" + "playlists" ] } }, - "/library-elements/name/{library_element_name}": { + "/playlists/{uid}/items": { "get": { - "description": "Returns a library element with the given name.", - "operationId": "getLibraryElementByName", + "operationId": "getPlaylistItems", "parameters": [ { "in": "path", - "name": "library_element_name", + "name": "uid", "required": true, "schema": { "type": "string" @@ -19087,11 +19147,14 @@ ], "responses": { "200": { - "$ref": "#/components/responses/getLibraryElementArrayResponse" + "$ref": "#/components/responses/getPlaylistItemsResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, "404": { "$ref": "#/components/responses/notFoundError" }, @@ -19099,20 +19162,20 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get library element by name.", + "summary": "Get playlist items.", "tags": [ - "library_elements" + "playlists" ] } }, - "/library-elements/{library_element_uid}": { - "delete": { - "description": "Deletes an existing library element as specified by the UID. This operation cannot be reverted.\nYou cannot delete a library element that is connected. This operation cannot be reverted.", - "operationId": "deleteLibraryElementByUID", + "/public/dashboards/{accessToken}": { + "get": { + "description": "Get public dashboard for view", + "operationId": "viewPublicDashboard", "parameters": [ { "in": "path", - "name": "library_element_uid", + "name": "accessToken", "required": true, "schema": { "type": "string" @@ -19121,36 +19184,37 @@ ], "responses": { "200": { - "$ref": "#/components/responses/okResponse" + "$ref": "#/components/responses/viewPublicDashboardResponse" }, "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/badRequestPublicError" }, "401": { - "$ref": "#/components/responses/unauthorisedError" + "$ref": "#/components/responses/unauthorisedPublicError" }, "403": { - "$ref": "#/components/responses/forbiddenError" + "$ref": "#/components/responses/forbiddenPublicError" }, "404": { - "$ref": "#/components/responses/notFoundError" + "$ref": "#/components/responses/notFoundPublicError" }, "500": { - "$ref": "#/components/responses/internalServerError" + "$ref": "#/components/responses/internalServerPublicError" } }, - "summary": "Delete library element.", "tags": [ - "library_elements" + "dashboard_public" ] - }, + } + }, + "/public/dashboards/{accessToken}/annotations": { "get": { - "description": "Returns a library element with the given UID.", - "operationId": "getLibraryElementByUID", + "description": "Get annotations for a public dashboard", + "operationId": "getPublicAnnotations", "parameters": [ { "in": "path", - "name": "library_element_uid", + "name": "accessToken", "required": true, "schema": { "type": "string" @@ -19159,247 +19223,317 @@ ], "responses": { "200": { - "$ref": "#/components/responses/getLibraryElementResponse" + "$ref": "#/components/responses/getPublicAnnotationsResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestPublicError" }, "401": { - "$ref": "#/components/responses/unauthorisedError" + "$ref": "#/components/responses/unauthorisedPublicError" }, "403": { - "$ref": "#/components/responses/forbiddenError" + "$ref": "#/components/responses/forbiddenPublicError" }, "404": { - "$ref": "#/components/responses/notFoundError" + "$ref": "#/components/responses/notFoundPublicError" }, "500": { - "$ref": "#/components/responses/internalServerError" + "$ref": "#/components/responses/internalServerPublicError" } }, - "summary": "Get library element by UID.", "tags": [ - "library_elements" + "dashboard_public" ] - }, - "patch": { - "description": "Updates an existing library element identified by uid.", - "operationId": "updateLibraryElement", - "parameters": [ + } + }, + "/public/dashboards/{accessToken}/panels/{panelId}/query": { + "post": { + "description": "Get results for a given panel on a public dashboard", + "operationId": "queryPublicDashboard", + "parameters": [ { "in": "path", - "name": "library_element_uid", + "name": "accessToken", "required": true, "schema": { "type": "string" } + }, + { + "in": "path", + "name": "panelId", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchLibraryElementCommand" - } - } - }, - "required": true, - "x-originalParamName": "body" - }, "responses": { "200": { - "$ref": "#/components/responses/getLibraryElementResponse" + "$ref": "#/components/responses/queryPublicDashboardResponse" }, "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/badRequestPublicError" }, "401": { - "$ref": "#/components/responses/unauthorisedError" + "$ref": "#/components/responses/unauthorisedPublicError" }, "403": { - "$ref": "#/components/responses/forbiddenError" + "$ref": "#/components/responses/forbiddenPublicError" }, "404": { - "$ref": "#/components/responses/notFoundError" - }, - "412": { - "$ref": "#/components/responses/preconditionFailedError" + "$ref": "#/components/responses/notFoundPublicError" }, "500": { - "$ref": "#/components/responses/internalServerError" + "$ref": "#/components/responses/internalServerPublicError" } }, - "summary": "Update library element.", "tags": [ - "library_elements" + "dashboard_public" ] } }, - "/library-elements/{library_element_uid}/connections/": { + "/query-history": { "get": { - "description": "Returns a list of connections for a library element based on the UID specified.", - "operationId": "getLibraryElementConnections", + "description": "Returns a list of queries in the query history that matches the search criteria.\nQuery history search supports pagination. Use the `limit` parameter to control the maximum number of queries returned; the default limit is 100.\nYou can also use the `page` query parameter to fetch queries from any page other than the first one.", + "operationId": "searchQueries", "parameters": [ { - "in": "path", - "name": "library_element_uid", - "required": true, + "description": "List of data source UIDs to search for", + "in": "query", + "name": "datasourceUid", + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + { + "description": "Text inside query or comments that is searched for", + "in": "query", + "name": "searchString", + "schema": { + "type": "string" + } + }, + { + "description": "Flag indicating if only starred queries should be returned", + "in": "query", + "name": "onlyStarred", + "schema": { + "type": "boolean" + } + }, + { + "description": "Sort method", + "in": "query", + "name": "sort", "schema": { + "default": "time-desc", + "enum": [ + "time-desc", + "time-asc" + ], "type": "string" } + }, + { + "description": "Use this parameter to access hits beyond limit. Numbering starts at 1. limit param acts as page size.", + "in": "query", + "name": "page", + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "Limit the number of returned results", + "in": "query", + "name": "limit", + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "From range for the query history search", + "in": "query", + "name": "from", + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "To range for the query history search", + "in": "query", + "name": "to", + "schema": { + "format": "int64", + "type": "integer" + } } ], "responses": { "200": { - "$ref": "#/components/responses/getLibraryElementConnectionsResponse" + "$ref": "#/components/responses/getQueryHistorySearchResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get library element connections.", + "summary": "Query history search.", "tags": [ - "library_elements" + "query_history" ] - } - }, - "/licensing/check": { - "get": { - "operationId": "getStatus", - "responses": { - "200": { - "$ref": "#/components/responses/getStatusResponse" - } + }, + "post": { + "description": "Adds new query to query history.", + "operationId": "createQuery", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateQueryInQueryHistoryCommand" + } + } + }, + "required": true, + "x-originalParamName": "body" }, - "summary": "Check license availability.", - "tags": [ - "licensing", - "enterprise" - ] - } - }, - "/licensing/custom-permissions": { - "get": { - "deprecated": true, - "description": "You need to have a permission with action `licensing.reports:read`.", - "operationId": "getCustomPermissionsReport", "responses": { + "200": { + "$ref": "#/components/responses/getQueryHistoryResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get custom permissions report.", + "summary": "Add query to query history.", "tags": [ - "licensing", - "enterprise" + "query_history" ] } }, - "/licensing/custom-permissions-csv": { - "get": { - "deprecated": true, - "description": "You need to have a permission with action `licensing.reports:read`.", - "operationId": "getCustomPermissionsCSV", + "/query-history/star/{query_history_uid}": { + "delete": { + "description": "Removes star from query in query history as specified by the UID.", + "operationId": "unstarQuery", + "parameters": [ + { + "in": "path", + "name": "query_history_uid", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { + "200": { + "$ref": "#/components/responses/getQueryHistoryResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get custom permissions report in CSV format.", + "summary": "Remove star to query in query history.", "tags": [ - "licensing", - "enterprise" + "query_history" ] - } - }, - "/licensing/refresh-stats": { - "get": { - "description": "You need to have a permission with action `licensing:read`.", - "operationId": "refreshLicenseStats", + }, + "post": { + "description": "Adds star to query in query history as specified by the UID.", + "operationId": "starQuery", + "parameters": [ + { + "in": "path", + "name": "query_history_uid", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "$ref": "#/components/responses/refreshLicenseStatsResponse" + "$ref": "#/components/responses/getQueryHistoryResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Refresh license stats.", + "summary": "Add star to query in query history.", "tags": [ - "licensing", - "enterprise" + "query_history" ] } }, - "/licensing/token": { + "/query-history/{query_history_uid}": { "delete": { - "description": "Removes the license stored in the Grafana database. Available in Grafana Enterprise v7.4+.\n\nYou need to have a permission with action `licensing:delete`.", - "operationId": "deleteLicenseToken", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteTokenCommand" - } + "description": "Deletes an existing query in query history as specified by the UID. This operation cannot be reverted.", + "operationId": "deleteQuery", + "parameters": [ + { + "in": "path", + "name": "query_history_uid", + "required": true, + "schema": { + "type": "string" } - }, - "required": true, - "x-originalParamName": "body" - }, + } + ], "responses": { - "202": { - "$ref": "#/components/responses/acceptedResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "200": { + "$ref": "#/components/responses/getQueryHistoryDeleteQueryResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "422": { - "$ref": "#/components/responses/unprocessableEntityError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Remove license from database.", + "summary": "Delete query in query history.", "tags": [ - "licensing", - "enterprise" + "query_history" ] }, - "get": { - "description": "You need to have a permission with action `licensing:read`.", - "operationId": "getLicenseToken", - "responses": { - "200": { - "$ref": "#/components/responses/getLicenseTokenResponse" + "patch": { + "description": "Updates comment for query in query history as specified by the UID.", + "operationId": "patchQueryComment", + "parameters": [ + { + "in": "path", + "name": "query_history_uid", + "required": true, + "schema": { + "type": "string" + } } - }, - "summary": "Get license token.", - "tags": [ - "licensing", - "enterprise" - ] - }, - "post": { - "description": "You need to have a permission with action `licensing:update`.", - "operationId": "postLicenseToken", + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteTokenCommand" + "$ref": "#/components/schemas/PatchQueryCommentInQueryHistoryCommand" } } }, @@ -19408,79 +19542,30 @@ }, "responses": { "200": { - "$ref": "#/components/responses/getLicenseTokenResponse" + "$ref": "#/components/responses/getQueryHistoryResponse" }, "400": { "$ref": "#/components/responses/badRequestError" - } - }, - "summary": "Create license token.", - "tags": [ - "licensing", - "enterprise" - ] - } - }, - "/licensing/token/renew": { - "post": { - "description": "Manually ask license issuer for a new token. Available in Grafana Enterprise v7.4+.\n\nYou need to have a permission with action `licensing:update`.", - "operationId": "postRenewLicenseToken", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - }, - "required": true, - "x-originalParamName": "body" - }, - "responses": { - "200": { - "$ref": "#/components/responses/postRenewLicenseTokenResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - } - }, - "summary": "Manually force license refresh.", - "tags": [ - "licensing", - "enterprise" - ] - } - }, - "/logout/saml": { - "get": { - "operationId": "getSAMLLogout", - "responses": { - "302": { - "description": "(empty)" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "GetLogout initiates single logout process.", + "summary": "Update comment for query in query history.", "tags": [ - "saml", - "enterprise" + "query_history" ] } }, - "/org": { + "/recording-rules": { "get": { - "operationId": "getCurrentOrg", + "operationId": "listRecordingRules", "responses": { "200": { - "$ref": "#/components/responses/getCurrentOrgResponse" + "$ref": "#/components/responses/listRecordingRulesResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -19488,22 +19573,26 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get current Organization.", + "summary": "Lists all rules in the database: active or deleted.", "tags": [ - "org" + "recording_rules", + "enterprise" ] }, - "put": { - "operationId": "updateCurrentOrg", + "post": { + "operationId": "createRecordingRule", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateOrgForm" + "$ref": "#/components/schemas/RecordingRuleJSON" } } }, @@ -19512,10 +19601,7 @@ }, "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/recordingRuleResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -19523,24 +19609,26 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update current Organization.", + "summary": "Create a recording rule that is then registered and started.", "tags": [ - "org" + "recording_rules", + "enterprise" ] - } - }, - "/org/address": { + }, "put": { - "operationId": "updateCurrentOrgAddress", + "operationId": "updateRecordingRule", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateOrgAddressForm" + "$ref": "#/components/schemas/RecordingRuleJSON" } } }, @@ -19549,10 +19637,7 @@ }, "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/recordingRuleResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -19560,45 +19645,28 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update current Organization's address.", + "summary": "Update the active status of a rule.", "tags": [ - "org" + "recording_rules", + "enterprise" ] } }, - "/org/invites": { - "get": { - "operationId": "getPendingOrgInvites", - "responses": { - "200": { - "$ref": "#/components/responses/getPendingOrgInvitesResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Get pending invites.", - "tags": [ - "org_invites" - ] - }, + "/recording-rules/test": { "post": { - "operationId": "addOrgInvite", + "operationId": "testCreateRecordingRule", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddInviteForm" + "$ref": "#/components/schemas/RecordingRuleJSON" } } }, @@ -19609,41 +19677,32 @@ "200": { "$ref": "#/components/responses/okResponse" }, - "400": { - "$ref": "#/components/responses/badRequestError" - }, "401": { "$ref": "#/components/responses/unauthorisedError" }, "403": { "$ref": "#/components/responses/forbiddenError" }, - "412": { - "$ref": "#/components/responses/SMTPNotEnabledError" + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "422": { + "$ref": "#/components/responses/unprocessableEntityError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Add invite.", + "summary": "Test a recording rule.", "tags": [ - "org_invites" + "recording_rules", + "enterprise" ] } }, - "/org/invites/{invitation_code}/revoke": { + "/recording-rules/writer": { "delete": { - "operationId": "revokeInvite", - "parameters": [ - { - "in": "path", - "name": "invitation_code", - "required": true, - "schema": { - "type": "string" - } - } - ], + "operationId": "deleteRecordingRuleWriteTarget", "responses": { "200": { "$ref": "#/components/responses/okResponse" @@ -19661,18 +19720,17 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Revoke invite.", + "summary": "Delete the remote write target.", "tags": [ - "org_invites" + "recording_rules", + "enterprise" ] - } - }, - "/org/preferences": { + }, "get": { - "operationId": "getOrgPreferences", + "operationId": "getRecordingRuleWriteTarget", "responses": { "200": { - "$ref": "#/components/responses/getPreferencesResponse" + "$ref": "#/components/responses/recordingRuleWriteTargetResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -19680,22 +19738,27 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get Current Org Prefs.", + "summary": "Return the prometheus remote write target.", "tags": [ - "org_preferences" + "recording_rules", + "enterprise" ] }, - "patch": { - "operationId": "patchOrgPreferences", + "post": { + "description": "It returns a 422 if there is not an existing prometheus data source configured.", + "operationId": "createRecordingRuleWriteTarget", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchPrefsCmd" + "$ref": "#/components/schemas/PrometheusRemoteWriteTargetJSON" } } }, @@ -19704,10 +19767,7 @@ }, "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/recordingRuleWriteTargetResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -19715,58 +19775,40 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Patch Current Org Prefs.", - "tags": [ - "org_preferences" - ] - }, - "put": { - "operationId": "updateOrgPreferences", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdatePrefsCmd" - } - } - }, - "required": true, - "x-originalParamName": "body" - }, - "responses": { - "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "404": { + "$ref": "#/components/responses/notFoundError" }, - "403": { - "$ref": "#/components/responses/forbiddenError" + "422": { + "$ref": "#/components/responses/unprocessableEntityError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update Current Org Prefs.", + "summary": "Create a remote write target.", "tags": [ - "org_preferences" + "recording_rules", + "enterprise" ] } }, - "/org/quotas": { - "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:read` and scope `org:id:1` (orgIDScope).", - "operationId": "getCurrentOrgQuota", + "/recording-rules/{recordingRuleID}": { + "delete": { + "operationId": "deleteRecordingRule", + "parameters": [ + { + "in": "path", + "name": "recordingRuleID", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], "responses": { "200": { - "$ref": "#/components/responses/getQuotaResponse" + "$ref": "#/components/responses/okResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -19781,19 +19823,20 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Fetch Organization quota.", + "summary": "Delete removes the rule from the registry and stops it.", "tags": [ - "getCurrentOrg" + "recording_rules", + "enterprise" ] } }, - "/org/users": { + "/reports": { "get": { - "description": "Returns all org users within the current organization. Accessible to users with org admin role.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", - "operationId": "getOrgUsersForCurrentOrg", + "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports:read` with scope `reports:*`.", + "operationId": "getReports", "responses": { "200": { - "$ref": "#/components/responses/getOrgUsersForCurrentOrgResponse" + "$ref": "#/components/responses/getReportsResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -19805,19 +19848,20 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get all users within the current organization.", + "summary": "List reports.", "tags": [ - "org" + "reports", + "enterprise" ] }, "post": { - "description": "Adds a global user to the current organization.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:add` with scope `users:*`.", - "operationId": "addOrgUserToCurrentOrg", + "description": "Available to org admins only and with a valid license.\n\nYou need to have a permission with action `reports.admin:create`.", + "operationId": "createReport", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddOrgUserCommand" + "$ref": "#/components/schemas/CreateOrUpdateReportConfig" } } }, @@ -19826,83 +19870,7 @@ }, "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Add a new user to the current organization.", - "tags": [ - "org" - ] - } - }, - "/org/users/lookup": { - "get": { - "description": "Returns all org users within the current organization, but with less detailed information.\nAccessible to users with org admin role, admin in any folder or admin of any team.\nMainly used by Grafana UI for providing list of users when adding team members and when editing folder/dashboard permissions.", - "operationId": "getOrgUsersForCurrentOrgLookup", - "parameters": [ - { - "in": "query", - "name": "query", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "format": "int64", - "type": "integer" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/getOrgUsersForCurrentOrgLookupResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Get all users within the current organization (lookup)", - "tags": [ - "org" - ] - } - }, - "/org/users/{user_id}": { - "delete": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:remove` with scope `users:*`.", - "operationId": "removeOrgUserForCurrentOrg", - "parameters": [ - { - "in": "path", - "name": "user_id", - "required": true, - "schema": { - "format": "int64", - "type": "integer" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/okResponse" + "$ref": "#/components/responses/createReportResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -19913,34 +19881,29 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete user in current organization.", + "summary": "Create a report.", "tags": [ - "org" + "reports", + "enterprise" ] - }, - "patch": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users.role:update` with scope `users:*`.", - "operationId": "updateOrgUserForCurrentOrg", - "parameters": [ - { - "in": "path", - "name": "user_id", - "required": true, - "schema": { - "format": "int64", - "type": "integer" - } - } - ], + } + }, + "/reports/email": { + "post": { + "description": "Generate and send a report. This API waits for the report to be generated before returning. We recommend that you set the client’s timeout to at least 60 seconds. Available to org admins only and with a valid license.\n\nOnly available in Grafana Enterprise v7.0+.\nThis API endpoint is experimental and may be deprecated in a future release. On deprecation, a migration strategy will be provided and the endpoint will remain functional until the next major release of Grafana.\n\nYou need to have a permission with action `reports:send`.", + "operationId": "sendReport", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateOrgUserCommand" + "$ref": "#/components/schemas/ReportEmail" } } }, @@ -19960,50 +19923,73 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Updates the given user.", + "summary": "Send a report.", "tags": [ - "org" + "reports", + "enterprise" ] } }, - "/orgs": { + "/reports/render/pdf/{dashboardID}": { "get": { - "operationId": "searchOrgs", + "deprecated": true, + "description": "Please refer to [reports enterprise](#/reports/renderReportPDFs) instead. This will be removed in Grafana 10.", + "operationId": "renderReportPDF", "parameters": [ { - "in": "query", - "name": "page", + "in": "path", + "name": "dashboardID", + "required": true, "schema": { - "default": 1, "format": "int64", "type": "integer" } }, { - "description": "Number of items per page\nThe totalCount field in the response can be used for pagination list E.g. if totalCount is equal to 100 teams and the perpage parameter is set to 10 then there are 10 pages of teams.", "in": "query", - "name": "perpage", + "name": "title", "schema": { - "default": 1000, - "format": "int64", - "type": "integer" + "type": "string" } }, { "in": "query", - "name": "name", + "name": "variables", "schema": { "type": "string" } }, { - "description": "If set it will return results where the query value is contained in the name field. Query values with spaces need to be URL encoded.", "in": "query", - "name": "query", + "name": "from", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "to", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "orientation", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "layout", "schema": { "type": "string" } @@ -20011,39 +19997,126 @@ ], "responses": { "200": { - "$ref": "#/components/responses/searchOrgsResponse" + "$ref": "#/components/responses/contentResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "409": { - "$ref": "#/components/responses/conflictError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "security": [ + "summary": "Render report for dashboard.", + "tags": [ + "reports", + "enterprise" + ] + } + }, + "/reports/render/pdfs": { + "get": { + "description": "Available to all users and with a valid license.", + "operationId": "renderReportPDFs", + "parameters": [ { - "basic": [] + "in": "query", + "name": "dashboardID", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "orientation", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "layout", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "title", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "scaleFactor", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "includeTables", + "schema": { + "type": "string" + } } ], - "summary": "Search all Organizations.", + "responses": { + "200": { + "$ref": "#/components/responses/contentResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Render report for multiple dashboards.", "tags": [ - "orgs" + "reports", + "enterprise" + ] + } + }, + "/reports/settings": { + "get": { + "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.settings:read`x.", + "operationId": "getReportSettings", + "responses": { + "200": { + "$ref": "#/components/responses/getReportSettingsResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Get settings.", + "tags": [ + "reports", + "enterprise" ] }, "post": { - "description": "Only works if [users.allow_org_create](https://grafana.com/docs/grafana/latest/administration/configuration/#allow_org_create) is set.", - "operationId": "createOrg", + "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.settings:write`xx.", + "operationId": "saveReportSettings", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateOrgCommand" + "$ref": "#/components/schemas/ReportSettings" } } }, @@ -20052,7 +20125,10 @@ }, "responses": { "200": { - "$ref": "#/components/responses/createOrgResponse" + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -20060,35 +20136,38 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "409": { - "$ref": "#/components/responses/conflictError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Create Organization.", + "summary": "Save settings.", "tags": [ - "orgs" + "reports", + "enterprise" ] } }, - "/orgs/name/{org_name}": { - "get": { - "operationId": "getOrgByName", - "parameters": [ - { - "in": "path", - "name": "org_name", - "required": true, - "schema": { - "type": "string" + "/reports/test-email": { + "post": { + "description": "Available to org admins only and with a valid license.\n\nYou need to have a permission with action `reports:send`.", + "operationId": "sendTestEmail", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrUpdateReportConfig" + } } - } - ], + }, + "required": true, + "x-originalParamName": "body" + }, "responses": { "200": { - "$ref": "#/components/responses/getOrgByNameResponse" + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -20096,28 +20175,28 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "security": [ - { - "basic": [] - } - ], - "summary": "Get Organization by ID.", + "summary": "Send test report via email.", "tags": [ - "orgs" + "reports", + "enterprise" ] } }, - "/orgs/{org_id}": { + "/reports/{id}": { "delete": { - "operationId": "deleteOrgByID", + "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.delete` with scope `reports:id:\u003creport ID\u003e`.", + "operationId": "deleteReport", "parameters": [ { "in": "path", - "name": "org_id", + "name": "id", "required": true, "schema": { "format": "int64", @@ -20145,22 +20224,19 @@ "$ref": "#/components/responses/internalServerError" } }, - "security": [ - { - "basic": [] - } - ], - "summary": "Delete Organization.", + "summary": "Delete a report.", "tags": [ - "orgs" + "reports", + "enterprise" ] }, "get": { - "operationId": "getOrgByID", + "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports:read` with scope `reports:id:\u003creport ID\u003e`.", + "operationId": "getReport", "parameters": [ { "in": "path", - "name": "org_id", + "name": "id", "required": true, "schema": { "format": "int64", @@ -20170,7 +20246,10 @@ ], "responses": { "200": { - "$ref": "#/components/responses/getOrgByIDResponse" + "$ref": "#/components/responses/getReportResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -20178,26 +20257,26 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "security": [ - { - "basic": [] - } - ], - "summary": "Get Organization by ID.", + "summary": "Get a report.", "tags": [ - "orgs" + "reports", + "enterprise" ] }, "put": { - "operationId": "updateOrg", + "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.admin:write` with scope `reports:id:\u003creport ID\u003e`.", + "operationId": "updateReport", "parameters": [ { "in": "path", - "name": "org_id", + "name": "id", "required": true, "schema": { "format": "int64", @@ -20209,7 +20288,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateOrgForm" + "$ref": "#/components/schemas/CreateOrUpdateReportConfig" } } }, @@ -20229,55 +20308,35 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "security": [ - { - "basic": [] - } - ], - "summary": "Update Organization.", + "summary": "Update a report.", "tags": [ - "orgs" + "reports", + "enterprise" ] } }, - "/orgs/{org_id}/address": { - "put": { - "operationId": "updateOrgAddress", + "/saml/acs": { + "post": { + "operationId": "postACS", "parameters": [ { - "in": "path", - "name": "org_id", - "required": true, + "in": "query", + "name": "RelayState", "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateOrgAddressForm" - } - } - }, - "required": true, - "x-originalParamName": "body" - }, "responses": { - "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "302": { + "description": "(empty)" }, "403": { "$ref": "#/components/responses/forbiddenError" @@ -20286,179 +20345,306 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update Organization's address.", + "summary": "It performs Assertion Consumer Service (ACS).", "tags": [ - "orgs" + "saml", + "enterprise" ] } }, - "/orgs/{org_id}/quotas": { + "/saml/metadata": { "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:read` and scope `org:id:1` (orgIDScope).", - "operationId": "getOrgQuota", - "parameters": [ - { - "in": "path", - "name": "org_id", - "required": true, - "schema": { - "format": "int64", - "type": "integer" - } - } - ], + "operationId": "getMetadata", "responses": { "200": { - "$ref": "#/components/responses/getQuotaResponse" + "$ref": "#/components/responses/contentResponse" + } + }, + "summary": "It exposes the SP (Grafana's) metadata for the IdP's consumption.", + "tags": [ + "saml", + "enterprise" + ] + } + }, + "/saml/slo": { + "get": { + "description": "There might be two possible requests:\n1. Logout response (callback) when Grafana initiates single logout and IdP returns response to logout request.\n2. Logout request when another SP initiates single logout and IdP sends logout request to the Grafana,\nor in case of IdP-initiated logout.", + "operationId": "getSLO", + "responses": { + "302": { + "description": "(empty)" }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "400": { + "$ref": "#/components/responses/badRequestError" }, "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Fetch Organization quota.", + "summary": "It performs Single Logout (SLO) callback.", "tags": [ - "orgs" + "saml", + "enterprise" ] - } - }, - "/orgs/{org_id}/quotas/{quota_target}": { - "put": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:write` and scope `org:id:1` (orgIDScope).", - "operationId": "updateOrgQuota", + }, + "post": { + "description": "There might be two possible requests:\n1. Logout response (callback) when Grafana initiates single logout and IdP returns response to logout request.\n2. Logout request when another SP initiates single logout and IdP sends logout request to the Grafana,\nor in case of IdP-initiated logout.", + "operationId": "postSLO", "parameters": [ { - "in": "path", - "name": "quota_target", - "required": true, + "in": "query", + "name": "SAMLRequest", "schema": { "type": "string" } }, { - "in": "path", - "name": "org_id", - "required": true, + "in": "query", + "name": "SAMLResponse", "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateQuotaCmd" - } - } - }, - "required": true, - "x-originalParamName": "body" - }, "responses": { - "200": { - "$ref": "#/components/responses/okResponse" + "302": { + "description": "(empty)" }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "400": { + "$ref": "#/components/responses/badRequestError" }, "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "security": [ - { - "basic": [] - } - ], - "summary": "Update user quota.", + "summary": "It performs Single Logout (SLO) callback.", "tags": [ - "orgs" + "saml", + "enterprise" ] } }, - "/orgs/{org_id}/users": { + "/search": { "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", - "operationId": "getOrgUsers", + "operationId": "search", "parameters": [ { - "in": "path", - "name": "org_id", - "required": true, + "description": "Search Query", + "in": "query", + "name": "query", + "schema": { + "type": "string" + } + }, + { + "description": "List of tags to search for", + "in": "query", + "name": "tag", + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + { + "description": "Type to search for, dash-folder or dash-db", + "in": "query", + "name": "type", + "schema": { + "enum": [ + "dash-folder", + "dash-db" + ], + "type": "string" + } + }, + { + "description": "List of dashboard id’s to search for\nThis is deprecated: users should use the `dashboardUIDs` query parameter instead", + "in": "query", + "name": "dashboardIds", + "schema": { + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + } + }, + { + "description": "List of dashboard uid’s to search for", + "in": "query", + "name": "dashboardUIDs", + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + { + "description": "List of folder id’s to search in for dashboards\nIf it's `0` then it will query for the top level folders\nThis is deprecated: users should use the `folderUIDs` query parameter instead", + "in": "query", + "name": "folderIds", + "schema": { + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + } + }, + { + "description": "List of folder UID’s to search in for dashboards\nIf it's an empty string then it will query for the top level folders", + "in": "query", + "name": "folderUIDs", + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + { + "description": "Flag indicating if only starred Dashboards should be returned", + "in": "query", + "name": "starred", + "schema": { + "type": "boolean" + } + }, + { + "description": "Limit the number of returned results (max 5000)", + "in": "query", + "name": "limit", + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "Use this parameter to access hits beyond limit. Numbering starts at 1. limit param acts as page size. Only available in Grafana v6.2+.", + "in": "query", + "name": "page", "schema": { "format": "int64", "type": "integer" } + }, + { + "description": "Set to `Edit` to return dashboards/folders that the user can edit", + "in": "query", + "name": "permission", + "schema": { + "default": "View", + "enum": [ + "Edit", + "View" + ], + "type": "string" + } + }, + { + "description": "Sort method; for listing all the possible sort methods use the search sorting endpoint.", + "in": "query", + "name": "sort", + "schema": { + "default": "alpha-asc", + "enum": [ + "alpha-asc", + "alpha-desc" + ], + "type": "string" + } } ], "responses": { "200": { - "$ref": "#/components/responses/getOrgUsersResponse" + "$ref": "#/components/responses/searchResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, - "403": { - "$ref": "#/components/responses/forbiddenError" + "422": { + "$ref": "#/components/responses/unprocessableEntityError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "security": [ - { - "basic": [] - } - ], - "summary": "Get Users in Organization.", "tags": [ - "orgs" + "search" ] }, "post": { - "description": "Adds a global user to the current organization.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:add` with scope `users:*`.", - "operationId": "addOrgUser", - "parameters": [ - { - "in": "path", - "name": "org_id", - "required": true, - "schema": { - "format": "int64", - "type": "integer" - } + "operationId": "SearchDevices", + "responses": { + "200": { + "$ref": "#/components/responses/devicesSearchResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } - ], + }, + "summary": "Lists all devices within the last 30 days", + "tags": [ + "devices" + ] + } + }, + "/search/sorting": { + "get": { + "operationId": "listSortOptions", + "responses": { + "200": { + "$ref": "#/components/responses/listSortOptionsResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + } + }, + "summary": "List search sorting options.", + "tags": [ + "search" + ] + } + }, + "/serviceaccounts": { + "post": { + "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:*`\n\nRequires basic authentication and that the authenticated user is a Grafana Admin.", + "operationId": "createServiceAccount", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddOrgUserCommand" + "$ref": "#/components/schemas/CreateServiceAccountForm" } } }, - "required": true, - "x-originalParamName": "body" + "x-originalParamName": "Body" }, "responses": { - "200": { - "$ref": "#/components/responses/okResponse" + "201": { + "$ref": "#/components/responses/createServiceAccountResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -20470,21 +20656,52 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Add a new user to the current organization.", + "summary": "Create service account", "tags": [ - "orgs" + "service_accounts" ] } }, - "/orgs/{org_id}/users/search": { + "/serviceaccounts/search": { "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", - "operationId": "searchOrgUsers", + "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:read` scope: `serviceaccounts:*`", + "operationId": "searchOrgServiceAccountsWithPaging", "parameters": [ { - "in": "path", - "name": "org_id", - "required": true, + "in": "query", + "name": "Disabled", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "expiredTokens", + "schema": { + "type": "boolean" + } + }, + { + "description": "It will return results where the query value is contained in one of the name.\nQuery values with spaces need to be URL encoded.", + "in": "query", + "name": "query", + "schema": { + "type": "string" + } + }, + { + "description": "The default value is 1000.", + "in": "query", + "name": "perpage", + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "The default value is 1.", + "in": "query", + "name": "page", "schema": { "format": "int64", "type": "integer" @@ -20493,7 +20710,7 @@ ], "responses": { "200": { - "$ref": "#/components/responses/searchOrgUsersResponse" + "$ref": "#/components/responses/searchOrgServiceAccountsWithPagingResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -20505,34 +20722,20 @@ "$ref": "#/components/responses/internalServerError" } }, - "security": [ - { - "basic": [] - } - ], - "summary": "Search Users in Organization.", + "summary": "Search service accounts with paging", "tags": [ - "orgs" + "service_accounts" ] } }, - "/orgs/{org_id}/users/{user_id}": { + "/serviceaccounts/{serviceAccountId}": { "delete": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:remove` with scope `users:*`.", - "operationId": "removeOrgUser", + "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:delete` scope: `serviceaccounts:id:1` (single service account)", + "operationId": "deleteServiceAccount", "parameters": [ { "in": "path", - "name": "org_id", - "required": true, - "schema": { - "format": "int64", - "type": "integer" - } - }, - { - "in": "path", - "name": "user_id", + "name": "serviceAccountId", "required": true, "schema": { "format": "int64", @@ -20557,27 +20760,18 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete user in current organization.", + "summary": "Delete service account", "tags": [ - "orgs" + "service_accounts" ] }, - "patch": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users.role:update` with scope `users:*`.", - "operationId": "updateOrgUser", + "get": { + "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:read` scope: `serviceaccounts:id:1` (single service account)", + "operationId": "retrieveServiceAccount", "parameters": [ { "in": "path", - "name": "org_id", - "required": true, - "schema": { - "format": "int64", - "type": "integer" - } - }, - { - "in": "path", - "name": "user_id", + "name": "serviceAccountId", "required": true, "schema": { "format": "int64", @@ -20585,20 +20779,9 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateOrgUserCommand" - } - } - }, - "required": true, - "x-originalParamName": "body" - }, "responses": { "200": { - "$ref": "#/components/responses/okResponse" + "$ref": "#/components/responses/retrieveServiceAccountResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -20609,66 +20792,48 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update Users in Organization.", + "summary": "Get single serviceaccount by Id", "tags": [ - "orgs" + "service_accounts" ] - } - }, - "/playlists": { - "get": { - "operationId": "searchPlaylists", + }, + "patch": { + "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)", + "operationId": "updateServiceAccount", "parameters": [ { - "in": "query", - "name": "query", - "schema": { - "type": "string" - } - }, - { - "description": "in:limit", - "in": "query", - "name": "limit", + "in": "path", + "name": "serviceAccountId", + "required": true, "schema": { "format": "int64", "type": "integer" } } ], - "responses": { - "200": { - "$ref": "#/components/responses/searchPlaylistsResponse" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Get playlists.", - "tags": [ - "playlists" - ] - }, - "post": { - "operationId": "createPlaylist", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreatePlaylistCommand" + "$ref": "#/components/schemas/UpdateServiceAccountForm" } } }, - "required": true, "x-originalParamName": "Body" }, "responses": { "200": { - "$ref": "#/components/responses/createPlaylistResponse" + "$ref": "#/components/responses/updateServiceAccountResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -20683,28 +20848,33 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Create playlist.", + "summary": "Update service account", "tags": [ - "playlists" + "service_accounts" ] } }, - "/playlists/{uid}": { - "delete": { - "operationId": "deletePlaylist", + "/serviceaccounts/{serviceAccountId}/tokens": { + "get": { + "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:read` scope: `global:serviceaccounts:id:1` (single service account)\n\nRequires basic authentication and that the authenticated user is a Grafana Admin.", + "operationId": "listTokens", "parameters": [ { "in": "path", - "name": "uid", + "name": "serviceAccountId", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], "responses": { "200": { - "$ref": "#/components/responses/okResponse" + "$ref": "#/components/responses/listTokensResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -20712,33 +20882,45 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete playlist.", + "summary": "Get service account tokens", "tags": [ - "playlists" + "service_accounts" ] }, - "get": { - "operationId": "getPlaylist", + "post": { + "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)", + "operationId": "createToken", "parameters": [ { "in": "path", - "name": "uid", + "name": "serviceAccountId", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddServiceAccountTokenCommand" + } + } + }, + "x-originalParamName": "Body" + }, "responses": { "200": { - "$ref": "#/components/responses/getPlaylistResponse" + "$ref": "#/components/responses/createTokenResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -20749,41 +20931,122 @@ "404": { "$ref": "#/components/responses/notFoundError" }, + "409": { + "$ref": "#/components/responses/conflictError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get playlist.", + "summary": "CreateNewToken adds a token to a service account", "tags": [ - "playlists" + "service_accounts" ] - }, - "put": { - "operationId": "updatePlaylist", + } + }, + "/serviceaccounts/{serviceAccountId}/tokens/{tokenId}": { + "delete": { + "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)\n\nRequires basic authentication and that the authenticated user is a Grafana Admin.", + "operationId": "deleteToken", "parameters": [ { "in": "path", - "name": "uid", + "name": "tokenId", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" + } + }, + { + "in": "path", + "name": "serviceAccountId", + "required": true, + "schema": { + "format": "int64", + "type": "integer" } } ], + "responses": { + "200": { + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "DeleteToken deletes service account tokens", + "tags": [ + "service_accounts" + ] + } + }, + "/signing-keys/keys": { + "get": { + "description": "Required permissions\nNone", + "operationId": "retrieveJWKS", + "responses": { + "200": { + "$ref": "#/components/responses/jwksResponse" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Get JSON Web Key Set (JWKS) with all the keys that can be used to verify tokens (public keys)", + "tags": [ + "signing_keys" + ] + } + }, + "/snapshot/shared-options": { + "get": { + "operationId": "getSharingOptions", + "responses": { + "200": { + "$ref": "#/components/responses/getSharingOptionsResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + } + }, + "summary": "Get snapshot sharing settings.", + "tags": [ + "snapshots" + ] + } + }, + "/snapshots": { + "post": { + "description": "Snapshot public mode should be enabled or authentication is required.", + "operationId": "createDashboardSnapshot", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdatePlaylistCommand" + "$ref": "#/components/schemas/CreateDashboardSnapshotCommand" } } }, "required": true, - "x-originalParamName": "Body" + "x-originalParamName": "body" }, "responses": { "200": { - "$ref": "#/components/responses/updatePlaylistResponse" + "$ref": "#/components/responses/createDashboardSnapshotResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -20791,26 +21054,24 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update playlist.", + "summary": "When creating a snapshot using the API, you have to provide the full dashboard payload including the snapshot data. This endpoint is designed for the Grafana UI.", "tags": [ - "playlists" + "snapshots" ] } }, - "/playlists/{uid}/items": { + "/snapshots-delete/{deleteKey}": { "get": { - "operationId": "getPlaylistItems", + "description": "Snapshot public mode should be enabled or authentication is required.", + "operationId": "deleteDashboardSnapshotByDeleteKey", "parameters": [ { "in": "path", - "name": "uid", + "name": "deleteKey", "required": true, "schema": { "type": "string" @@ -20819,7 +21080,7 @@ ], "responses": { "200": { - "$ref": "#/components/responses/getPlaylistItemsResponse" + "$ref": "#/components/responses/okResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -20834,20 +21095,19 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get playlist items.", + "summary": "Delete Snapshot by deleteKey.", "tags": [ - "playlists" + "snapshots" ] } }, - "/public/dashboards/{accessToken}": { - "get": { - "description": "Get public dashboard for view", - "operationId": "viewPublicDashboard", + "/snapshots/{key}": { + "delete": { + "operationId": "deleteDashboardSnapshot", "parameters": [ { "in": "path", - "name": "accessToken", + "name": "key", "required": true, "schema": { "type": "string" @@ -20856,37 +21116,29 @@ ], "responses": { "200": { - "$ref": "#/components/responses/viewPublicDashboardResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestPublicError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedPublicError" + "$ref": "#/components/responses/okResponse" }, "403": { - "$ref": "#/components/responses/forbiddenPublicError" + "$ref": "#/components/responses/forbiddenError" }, "404": { - "$ref": "#/components/responses/notFoundPublicError" + "$ref": "#/components/responses/notFoundError" }, "500": { - "$ref": "#/components/responses/internalServerPublicError" + "$ref": "#/components/responses/internalServerError" } }, + "summary": "Delete Snapshot by Key.", "tags": [ - "dashboard_public" + "snapshots" ] - } - }, - "/public/dashboards/{accessToken}/annotations": { + }, "get": { - "description": "Get annotations for a public dashboard", - "operationId": "getPublicAnnotations", + "operationId": "getDashboardSnapshot", "parameters": [ { "in": "path", - "name": "accessToken", + "name": "key", "required": true, "schema": { "type": "string" @@ -20895,309 +21147,242 @@ ], "responses": { "200": { - "$ref": "#/components/responses/getPublicAnnotationsResponse" + "$ref": "#/components/responses/getDashboardSnapshotResponse" }, "400": { - "$ref": "#/components/responses/badRequestPublicError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedPublicError" - }, - "403": { - "$ref": "#/components/responses/forbiddenPublicError" + "$ref": "#/components/responses/badRequestError" }, "404": { - "$ref": "#/components/responses/notFoundPublicError" + "$ref": "#/components/responses/notFoundError" }, "500": { - "$ref": "#/components/responses/internalServerPublicError" + "$ref": "#/components/responses/internalServerError" } }, + "summary": "Get Snapshot by Key.", "tags": [ - "dashboard_public" + "snapshots" ] } }, - "/public/dashboards/{accessToken}/panels/{panelId}/query": { - "post": { - "description": "Get results for a given panel on a public dashboard", - "operationId": "queryPublicDashboard", - "parameters": [ - { - "in": "path", - "name": "accessToken", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "panelId", - "required": true, - "schema": { - "format": "int64", - "type": "integer" - } - } - ], + "/stats": { + "get": { + "operationId": "listDevices", "responses": { "200": { - "$ref": "#/components/responses/queryPublicDashboardResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestPublicError" + "$ref": "#/components/responses/devicesResponse" }, "401": { - "$ref": "#/components/responses/unauthorisedPublicError" + "$ref": "#/components/responses/unauthorisedError" }, "403": { - "$ref": "#/components/responses/forbiddenPublicError" + "$ref": "#/components/responses/forbiddenError" }, "404": { - "$ref": "#/components/responses/notFoundPublicError" + "$ref": "#/components/responses/notFoundError" }, "500": { - "$ref": "#/components/responses/internalServerPublicError" + "$ref": "#/components/responses/internalServerError" } }, + "summary": "Lists all devices within the last 30 days", "tags": [ - "dashboard_public" + "devices" ] } }, - "/query-history": { - "get": { - "description": "Returns a list of queries in the query history that matches the search criteria.\nQuery history search supports pagination. Use the `limit` parameter to control the maximum number of queries returned; the default limit is 100.\nYou can also use the `page` query parameter to fetch queries from any page other than the first one.", - "operationId": "searchQueries", - "parameters": [ - { - "description": "List of data source UIDs to search for", - "in": "query", - "name": "datasourceUid", - "schema": { - "items": { - "type": "string" - }, - "type": "array" + "/teams": { + "post": { + "operationId": "createTeam", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamCommand" + } } }, - { - "description": "Text inside query or comments that is searched for", - "in": "query", - "name": "searchString", - "schema": { - "type": "string" - } + "required": true, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/createTeamResponse" }, - { - "description": "Flag indicating if only starred queries should be returned", - "in": "query", - "name": "onlyStarred", - "schema": { - "type": "boolean" - } + "401": { + "$ref": "#/components/responses/unauthorisedError" }, - { - "description": "Sort method", - "in": "query", - "name": "sort", - "schema": { - "default": "time-desc", - "enum": [ - "time-desc", - "time-asc" - ], - "type": "string" - } + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "409": { + "$ref": "#/components/responses/conflictError" }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Add Team.", + "tags": [ + "teams" + ] + } + }, + "/teams/search": { + "get": { + "operationId": "searchTeams", + "parameters": [ { - "description": "Use this parameter to access hits beyond limit. Numbering starts at 1. limit param acts as page size.", "in": "query", "name": "page", "schema": { + "default": 1, "format": "int64", "type": "integer" } }, { - "description": "Limit the number of returned results", + "description": "Number of items per page\nThe totalCount field in the response can be used for pagination list E.g. if totalCount is equal to 100 teams and the perpage parameter is set to 10 then there are 10 pages of teams.", "in": "query", - "name": "limit", + "name": "perpage", "schema": { + "default": 1000, "format": "int64", "type": "integer" } }, { - "description": "From range for the query history search", "in": "query", - "name": "from", + "name": "name", "schema": { - "format": "int64", - "type": "integer" + "type": "string" } }, { - "description": "To range for the query history search", + "description": "If set it will return results where the query value is contained in the name field. Query values with spaces need to be URL encoded.", "in": "query", - "name": "to", + "name": "query", "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], "responses": { "200": { - "$ref": "#/components/responses/getQueryHistorySearchResponse" + "$ref": "#/components/responses/searchTeamsResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Query history search.", - "tags": [ - "query_history" - ] - }, - "post": { - "description": "Adds new query to query history.", - "operationId": "createQuery", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateQueryInQueryHistoryCommand" - } - } - }, - "required": true, - "x-originalParamName": "body" - }, - "responses": { - "200": { - "$ref": "#/components/responses/getQueryHistoryResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "403": { + "$ref": "#/components/responses/forbiddenError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Add query to query history.", + "summary": "Team Search With Paging.", "tags": [ - "query_history" + "teams" ] } }, - "/query-history/star/{query_history_uid}": { + "/teams/{teamId}/groups": { "delete": { - "description": "Removes star from query in query history as specified by the UID.", - "operationId": "unstarQuery", + "operationId": "removeTeamGroupApiQuery", "parameters": [ { - "in": "path", - "name": "query_history_uid", - "required": true, + "in": "query", + "name": "groupId", "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/getQueryHistoryResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Remove star to query in query history.", - "tags": [ - "query_history" - ] - }, - "post": { - "description": "Adds star to query in query history as specified by the UID.", - "operationId": "starQuery", - "parameters": [ { "in": "path", - "name": "query_history_uid", + "name": "teamId", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], "responses": { "200": { - "$ref": "#/components/responses/getQueryHistoryResponse" + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Add star to query in query history.", + "summary": "Remove External Group.", "tags": [ - "query_history" + "sync_team_groups", + "enterprise" ] - } - }, - "/query-history/{query_history_uid}": { - "delete": { - "description": "Deletes an existing query in query history as specified by the UID. This operation cannot be reverted.", - "operationId": "deleteQuery", + }, + "get": { + "operationId": "getTeamGroupsApi", "parameters": [ { "in": "path", - "name": "query_history_uid", + "name": "teamId", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], "responses": { "200": { - "$ref": "#/components/responses/getQueryHistoryDeleteQueryResponse" + "$ref": "#/components/responses/getTeamGroupsApiResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete query in query history.", + "summary": "Get External Groups.", "tags": [ - "query_history" + "sync_team_groups", + "enterprise" ] }, - "patch": { - "description": "Updates comment for query in query history as specified by the UID.", - "operationId": "patchQueryComment", + "post": { + "operationId": "addTeamGroupApi", "parameters": [ { "in": "path", - "name": "query_history_uid", + "name": "teamId", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], @@ -21205,7 +21390,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchQueryCommentInQueryHistoryCommand" + "$ref": "#/components/schemas/TeamGroupMapping" } } }, @@ -21214,7 +21399,7 @@ }, "responses": { "200": { - "$ref": "#/components/responses/getQueryHistoryResponse" + "$ref": "#/components/responses/okResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -21222,22 +21407,39 @@ "401": { "$ref": "#/components/responses/unauthorisedError" }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update comment for query in query history.", + "summary": "Add External Group.", "tags": [ - "query_history" + "sync_team_groups", + "enterprise" ] } }, - "/recording-rules": { - "get": { - "operationId": "listRecordingRules", + "/teams/{team_id}": { + "delete": { + "operationId": "deleteTeamByID", + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "$ref": "#/components/responses/listRecordingRulesResponse" + "$ref": "#/components/responses/okResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -21252,28 +21454,26 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Lists all rules in the database: active or deleted.", + "summary": "Delete Team By ID.", "tags": [ - "recording_rules", - "enterprise" + "teams" ] }, - "post": { - "operationId": "createRecordingRule", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecordingRuleJSON" - } + "get": { + "operationId": "getTeamByID", + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "type": "string" } - }, - "required": true, - "x-originalParamName": "body" - }, + } + ], "responses": { "200": { - "$ref": "#/components/responses/recordingRuleResponse" + "$ref": "#/components/responses/getTeamByIDResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -21288,57 +21488,28 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Create a recording rule that is then registered and started.", + "summary": "Get Team By ID.", "tags": [ - "recording_rules", - "enterprise" + "teams" ] }, "put": { - "operationId": "updateRecordingRule", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecordingRuleJSON" - } + "operationId": "updateTeam", + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "type": "string" } - }, - "required": true, - "x-originalParamName": "body" - }, - "responses": { - "200": { - "$ref": "#/components/responses/recordingRuleResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" } - }, - "summary": "Update the active status of a rule.", - "tags": [ - "recording_rules", - "enterprise" - ] - } - }, - "/recording-rules/test": { - "post": { - "operationId": "testCreateRecordingRule", + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecordingRuleJSON" + "$ref": "#/components/schemas/UpdateTeamCommand" } } }, @@ -21358,51 +21529,35 @@ "404": { "$ref": "#/components/responses/notFoundError" }, - "422": { - "$ref": "#/components/responses/unprocessableEntityError" + "409": { + "$ref": "#/components/responses/conflictError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Test a recording rule.", + "summary": "Update Team.", "tags": [ - "recording_rules", - "enterprise" + "teams" ] } }, - "/recording-rules/writer": { - "delete": { - "operationId": "deleteRecordingRuleWriteTarget", - "responses": { - "200": { - "$ref": "#/components/responses/okResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Delete the remote write target.", - "tags": [ - "recording_rules", - "enterprise" - ] - }, + "/teams/{team_id}/members": { "get": { - "operationId": "getRecordingRuleWriteTarget", + "operationId": "getTeamMembers", + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "$ref": "#/components/responses/recordingRuleWriteTargetResponse" + "$ref": "#/components/responses/getTeamMembersResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -21417,20 +21572,28 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Return the prometheus remote write target.", + "summary": "Get Team Members.", "tags": [ - "recording_rules", - "enterprise" + "teams" ] }, "post": { - "description": "It returns a 422 if there is not an existing prometheus data source configured.", - "operationId": "createRecordingRuleWriteTarget", + "operationId": "addTeamMember", + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PrometheusRemoteWriteTargetJSON" + "$ref": "#/components/schemas/AddTeamMemberCommand" } } }, @@ -21439,7 +21602,7 @@ }, "responses": { "200": { - "$ref": "#/components/responses/recordingRuleWriteTargetResponse" + "$ref": "#/components/responses/okResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -21450,27 +21613,31 @@ "404": { "$ref": "#/components/responses/notFoundError" }, - "422": { - "$ref": "#/components/responses/unprocessableEntityError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Create a remote write target.", + "summary": "Add Team Member.", "tags": [ - "recording_rules", - "enterprise" + "teams" ] } }, - "/recording-rules/{recordingRuleID}": { + "/teams/{team_id}/members/{user_id}": { "delete": { - "operationId": "deleteRecordingRule", + "operationId": "removeTeamMember", "parameters": [ { "in": "path", - "name": "recordingRuleID", + "name": "team_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "user_id", "required": true, "schema": { "format": "int64", @@ -21495,45 +21662,37 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete removes the rule from the registry and stops it.", + "summary": "Remove Member From Team.", "tags": [ - "recording_rules", - "enterprise" + "teams" ] - } - }, - "/reports": { - "get": { - "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports:read` with scope `reports:*`.", - "operationId": "getReports", - "responses": { - "200": { - "$ref": "#/components/responses/getReportsResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" + }, + "put": { + "operationId": "updateTeamMember", + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "type": "string" + } }, - "500": { - "$ref": "#/components/responses/internalServerError" + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } } - }, - "summary": "List reports.", - "tags": [ - "reports", - "enterprise" - ] - }, - "post": { - "description": "Available to org admins only and with a valid license.\n\nYou need to have a permission with action `reports.admin:create`.", - "operationId": "createReport", + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateOrUpdateReportConfig" + "$ref": "#/components/schemas/UpdateTeamMemberCommand" } } }, @@ -21542,10 +21701,7 @@ }, "responses": { "200": { - "$ref": "#/components/responses/createReportResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/okResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -21560,194 +21716,67 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Create a report.", + "summary": "Update Team Member.", "tags": [ - "reports", - "enterprise" + "teams" ] } }, - "/reports/email": { - "post": { - "description": "Generate and send a report. This API waits for the report to be generated before returning. We recommend that you set the client’s timeout to at least 60 seconds. Available to org admins only and with a valid license.\n\nOnly available in Grafana Enterprise v7.0+.\nThis API endpoint is experimental and may be deprecated in a future release. On deprecation, a migration strategy will be provided and the endpoint will remain functional until the next major release of Grafana.\n\nYou need to have a permission with action `reports:send`.", - "operationId": "sendReport", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportEmail" - } + "/teams/{team_id}/preferences": { + "get": { + "operationId": "getTeamPreferences", + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "type": "string" } - }, - "required": true, - "x-originalParamName": "body" - }, + } + ], "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/getPreferencesResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Send a report.", + "summary": "Get Team Preferences.", "tags": [ - "reports", - "enterprise" + "teams" ] - } - }, - "/reports/render/pdf/{dashboardID}": { - "get": { - "deprecated": true, - "description": "Please refer to [reports enterprise](#/reports/renderReportPDFs) instead. This will be removed in Grafana 10.", - "operationId": "renderReportPDF", + }, + "put": { + "operationId": "updateTeamPreferences", "parameters": [ { "in": "path", - "name": "DashboardID", - "required": true, - "schema": { - "format": "int64", - "type": "integer" - } - }, - { - "in": "path", - "name": "dashboardID", + "name": "team_id", "required": true, - "schema": { - "format": "int64", - "type": "integer" - } - }, - { - "in": "query", - "name": "title", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "variables", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "from", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "to", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "orientation", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "layout", "schema": { "type": "string" } } ], - "responses": { - "200": { - "$ref": "#/components/responses/contentResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Render report for dashboard.", - "tags": [ - "reports", - "enterprise" - ] - } - }, - "/reports/render/pdfs": { - "get": { - "description": "Available to all users and with a valid license.", - "operationId": "renderReportPDFs", - "parameters": [ - { - "in": "query", - "name": "dashboardID", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "orientation", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "layout", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "title", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "scaleFactor", - "schema": { - "type": "string" + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePrefsCmd" + } } }, - { - "in": "query", - "name": "includeTables", - "schema": { - "type": "string" - } - } - ], + "required": true, + "x-originalParamName": "body" + }, "responses": { "200": { - "$ref": "#/components/responses/contentResponse" + "$ref": "#/components/responses/okResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -21759,20 +21788,19 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Render report for multiple dashboards.", + "summary": "Update Team Preferences.", "tags": [ - "reports", - "enterprise" + "teams" ] } }, - "/reports/settings": { + "/user": { "get": { - "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.settings:read`x.", - "operationId": "getReportSettings", + "description": "Get (current authenticated user)", + "operationId": "getSignedInUser", "responses": { "200": { - "$ref": "#/components/responses/getReportSettingsResponse" + "$ref": "#/components/responses/userResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -21780,27 +21808,28 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get settings.", "tags": [ - "reports", - "enterprise" + "signed_in_user" ] }, - "post": { - "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.settings:write`xx.", - "operationId": "saveReportSettings", + "put": { + "operationId": "updateSignedInUser", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportSettings" + "$ref": "#/components/schemas/UpdateUserCommand" } } }, + "description": "To change the email, name, login, theme, provide another one.", "required": true, "x-originalParamName": "body" }, @@ -21808,47 +21837,32 @@ "200": { "$ref": "#/components/responses/okResponse" }, - "400": { - "$ref": "#/components/responses/badRequestError" - }, "401": { "$ref": "#/components/responses/unauthorisedError" }, "403": { "$ref": "#/components/responses/forbiddenError" }, + "409": { + "$ref": "#/components/responses/conflictError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Save settings.", + "summary": "Update signed in User.", "tags": [ - "reports", - "enterprise" + "signed_in_user" ] } }, - "/reports/test-email": { - "post": { - "description": "Available to org admins only and with a valid license.\n\nYou need to have a permission with action `reports:send`.", - "operationId": "sendTestEmail", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateOrUpdateReportConfig" - } - } - }, - "required": true, - "x-originalParamName": "body" - }, + "/user/auth-tokens": { + "get": { + "description": "Return a list of all auth tokens (devices) that the actual user currently have logged in from.", + "operationId": "getUserAuthTokens", "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/getUserAuthTokensResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -21856,81 +21870,70 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Send test report via email.", + "summary": "Auth tokens of the actual User.", "tags": [ - "reports", - "enterprise" + "signed_in_user" ] } }, - "/reports/{id}": { - "delete": { - "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.delete` with scope `reports:id:\u003creport ID\u003e`.", - "operationId": "deleteReport", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "format": "int64", - "type": "integer" - } - } - ], + "/user/email/update": { + "get": { + "description": "Update the email of user given a verification code.", + "operationId": "updateUserEmail", "responses": { - "200": { + "302": { "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { + } + }, + "summary": "Update user email.", + "tags": [ + "user" + ] + } + }, + "/user/helpflags/clear": { + "get": { + "operationId": "clearHelpFlags", + "responses": { + "200": { + "$ref": "#/components/responses/helpFlagResponse" + }, + "401": { "$ref": "#/components/responses/unauthorisedError" }, "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete a report.", + "summary": "Clear user help flag.", "tags": [ - "reports", - "enterprise" + "signed_in_user" ] - }, - "get": { - "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports:read` with scope `reports:id:\u003creport ID\u003e`.", - "operationId": "getReport", + } + }, + "/user/helpflags/{flag_id}": { + "put": { + "operationId": "setHelpFlag", "parameters": [ { "in": "path", - "name": "id", + "name": "flag_id", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], "responses": { "200": { - "$ref": "#/components/responses/getReportResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/helpFlagResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -21938,41 +21941,58 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get a report.", + "summary": "Set user help flag.", "tags": [ - "reports", - "enterprise" + "signed_in_user" ] - }, - "put": { - "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.admin:write` with scope `reports:id:\u003creport ID\u003e`.", - "operationId": "updateReport", - "parameters": [ + } + }, + "/user/orgs": { + "get": { + "description": "Return a list of all organizations of the current user.", + "operationId": "getSignedInUserOrgList", + "responses": { + "200": { + "$ref": "#/components/responses/getSignedInUserOrgListResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "security": [ { - "in": "path", - "name": "id", - "required": true, - "schema": { - "format": "int64", - "type": "integer" - } + "basic": [] } ], + "summary": "Organizations of the actual User.", + "tags": [ + "signed_in_user" + ] + } + }, + "/user/password": { + "put": { + "description": "Changes the password for the user.", + "operationId": "changeUserPassword", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateOrUpdateReportConfig" + "$ref": "#/components/schemas/ChangeUserPasswordCommand" } } }, + "description": "To change the email, name, login, theme, provide another one.", "required": true, "x-originalParamName": "body" }, @@ -21989,316 +22009,150 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update a report.", - "tags": [ - "reports", - "enterprise" - ] - } - }, - "/saml/acs": { - "post": { - "operationId": "postACS", - "parameters": [ + "security": [ { - "in": "query", - "name": "RelayState", - "schema": { - "type": "string" - } + "basic": [] } ], - "responses": { - "302": { - "description": "(empty)" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "It performs Assertion Consumer Service (ACS).", + "summary": "Change Password.", "tags": [ - "saml", - "enterprise" + "signed_in_user" ] } }, - "/saml/metadata": { + "/user/preferences": { "get": { - "operationId": "getMetadata", + "operationId": "getUserPreferences", "responses": { "200": { - "$ref": "#/components/responses/contentResponse" + "$ref": "#/components/responses/getPreferencesResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "It exposes the SP (Grafana's) metadata for the IdP's consumption.", + "summary": "Get user preferences.", "tags": [ - "saml", - "enterprise" + "user_preferences" ] - } - }, - "/saml/slo": { - "get": { - "description": "There might be two possible requests:\n1. Logout response (callback) when Grafana initiates single logout and IdP returns response to logout request.\n2. Logout request when another SP initiates single logout and IdP sends logout request to the Grafana,\nor in case of IdP-initiated logout.", - "operationId": "getSLO", + }, + "patch": { + "operationId": "patchUserPreferences", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchPrefsCmd" + } + } + }, + "required": true, + "x-originalParamName": "body" + }, "responses": { - "302": { - "description": "(empty)" + "200": { + "$ref": "#/components/responses/okResponse" }, "400": { "$ref": "#/components/responses/badRequestError" }, - "403": { - "$ref": "#/components/responses/forbiddenError" + "401": { + "$ref": "#/components/responses/unauthorisedError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "It performs Single Logout (SLO) callback.", + "summary": "Patch user preferences.", "tags": [ - "saml", - "enterprise" + "user_preferences" ] }, - "post": { - "description": "There might be two possible requests:\n1. Logout response (callback) when Grafana initiates single logout and IdP returns response to logout request.\n2. Logout request when another SP initiates single logout and IdP sends logout request to the Grafana,\nor in case of IdP-initiated logout.", - "operationId": "postSLO", - "parameters": [ - { - "in": "query", - "name": "SAMLRequest", - "schema": { - "type": "string" + "put": { + "description": "Omitting a key (`theme`, `homeDashboardId`, `timezone`) will cause the current value to be replaced with the system default value.", + "operationId": "updateUserPreferences", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePrefsCmd" + } } }, - { - "in": "query", - "name": "SAMLResponse", - "schema": { - "type": "string" - } - } - ], + "required": true, + "x-originalParamName": "body" + }, "responses": { - "302": { - "description": "(empty)" + "200": { + "$ref": "#/components/responses/okResponse" }, "400": { "$ref": "#/components/responses/badRequestError" }, - "403": { - "$ref": "#/components/responses/forbiddenError" + "401": { + "$ref": "#/components/responses/unauthorisedError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "It performs Single Logout (SLO) callback.", + "summary": "Update user preferences.", "tags": [ - "saml", - "enterprise" + "user_preferences" ] } }, - "/search": { + "/user/quotas": { "get": { - "operationId": "search", - "parameters": [ - { - "description": "Search Query", - "in": "query", - "name": "query", - "schema": { - "type": "string" - } + "operationId": "getUserQuotas", + "responses": { + "200": { + "$ref": "#/components/responses/getQuotaResponse" }, - { - "description": "List of tags to search for", - "in": "query", - "name": "tag", - "schema": { - "items": { - "type": "string" - }, - "type": "array" - } + "401": { + "$ref": "#/components/responses/unauthorisedError" }, - { - "description": "Type to search for, dash-folder or dash-db", - "in": "query", - "name": "type", - "schema": { - "enum": [ - "dash-folder", - "dash-db" - ], - "type": "string" - } - }, - { - "description": "List of dashboard id’s to search for\nThis is deprecated: users should use the `dashboardUIDs` query parameter instead", - "in": "query", - "name": "dashboardIds", - "schema": { - "items": { - "format": "int64", - "type": "integer" - }, - "type": "array" - } - }, - { - "description": "List of dashboard uid’s to search for", - "in": "query", - "name": "dashboardUIDs", - "schema": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - { - "description": "List of folder id’s to search in for dashboards\nIf it's `0` then it will query for the top level folders\nThis is deprecated: users should use the `folderUIDs` query parameter instead", - "in": "query", - "name": "folderIds", - "schema": { - "items": { - "format": "int64", - "type": "integer" - }, - "type": "array" - } - }, - { - "description": "List of folder UID’s to search in for dashboards\nIf it's an empty string then it will query for the top level folders", - "in": "query", - "name": "folderUIDs", - "schema": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - { - "description": "Flag indicating if only starred Dashboards should be returned", - "in": "query", - "name": "starred", - "schema": { - "type": "boolean" - } - }, - { - "description": "Limit the number of returned results (max 5000)", - "in": "query", - "name": "limit", - "schema": { - "format": "int64", - "type": "integer" - } - }, - { - "description": "Use this parameter to access hits beyond limit. Numbering starts at 1. limit param acts as page size. Only available in Grafana v6.2+.", - "in": "query", - "name": "page", - "schema": { - "format": "int64", - "type": "integer" - } - }, - { - "description": "Set to `Edit` to return dashboards/folders that the user can edit", - "in": "query", - "name": "permission", - "schema": { - "default": "View", - "enum": [ - "Edit", - "View" - ], - "type": "string" - } - }, - { - "description": "Sort method; for listing all the possible sort methods use the search sorting endpoint.", - "in": "query", - "name": "sort", - "schema": { - "default": "alpha-asc", - "enum": [ - "alpha-asc", - "alpha-desc" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/searchResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "403": { + "$ref": "#/components/responses/forbiddenError" }, - "422": { - "$ref": "#/components/responses/unprocessableEntityError" + "404": { + "$ref": "#/components/responses/notFoundError" }, "500": { "$ref": "#/components/responses/internalServerError" } }, + "summary": "Fetch user quota.", "tags": [ - "search" - ] - } - }, - "/search/sorting": { - "get": { - "operationId": "listSortOptions", - "responses": { - "200": { - "$ref": "#/components/responses/listSortOptionsResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - } - }, - "summary": "List search sorting options.", - "tags": [ - "search" + "signed_in_user" ] } }, - "/serviceaccounts": { + "/user/revoke-auth-token": { "post": { - "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:*`\n\nRequires basic authentication and that the authenticated user is a Grafana Admin.", - "operationId": "createServiceAccount", + "description": "Revokes the given auth token (device) for the actual user. User of issued auth token (device) will no longer be logged in and will be required to authenticate again upon next activity.", + "operationId": "revokeUserAuthToken", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateServiceAccountForm" + "$ref": "#/components/schemas/RevokeAuthTokenCmd" } } }, - "x-originalParamName": "Body" + "required": true, + "x-originalParamName": "body" }, "responses": { - "201": { - "$ref": "#/components/responses/createServiceAccountResponse" + "200": { + "$ref": "#/components/responses/okResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -22313,61 +22167,32 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Create service account", + "summary": "Revoke an auth token of the actual User.", "tags": [ - "service_accounts" + "signed_in_user" ] } }, - "/serviceaccounts/search": { - "get": { - "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:read` scope: `serviceaccounts:*`", - "operationId": "searchOrgServiceAccountsWithPaging", + "/user/stars/dashboard/uid/{dashboard_uid}": { + "delete": { + "description": "Deletes the starring of the given Dashboard for the actual user.", + "operationId": "unstarDashboardByUID", "parameters": [ { - "in": "query", - "name": "Disabled", - "schema": { - "type": "boolean" - } - }, - { - "in": "query", - "name": "expiredTokens", - "schema": { - "type": "boolean" - } - }, - { - "description": "It will return results where the query value is contained in one of the name.\nQuery values with spaces need to be URL encoded.", - "in": "query", - "name": "query", + "in": "path", + "name": "dashboard_uid", + "required": true, "schema": { "type": "string" } - }, - { - "description": "The default value is 1000.", - "in": "query", - "name": "perpage", - "schema": { - "format": "int64", - "type": "integer" - } - }, - { - "description": "The default value is 1.", - "in": "query", - "name": "page", - "schema": { - "format": "int64", - "type": "integer" - } } ], "responses": { "200": { - "$ref": "#/components/responses/searchOrgServiceAccountsWithPagingResponse" + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -22379,24 +22204,21 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Search service accounts with paging", + "summary": "Unstar a dashboard.", "tags": [ - "service_accounts" + "signed_in_user" ] - } - }, - "/serviceaccounts/{serviceAccountId}": { - "delete": { - "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:delete` scope: `serviceaccounts:id:1` (single service account)", - "operationId": "deleteServiceAccount", + }, + "post": { + "description": "Stars the given Dashboard for the actual user.", + "operationId": "starDashboardByUID", "parameters": [ { "in": "path", - "name": "serviceAccountId", + "name": "dashboard_uid", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], @@ -22417,28 +22239,30 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete service account", + "summary": "Star a dashboard.", "tags": [ - "service_accounts" + "signed_in_user" ] - }, - "get": { - "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:read` scope: `serviceaccounts:id:1` (single service account)", - "operationId": "retrieveServiceAccount", + } + }, + "/user/stars/dashboard/{dashboard_id}": { + "delete": { + "deprecated": true, + "description": "Deletes the starring of the given Dashboard for the actual user.", + "operationId": "unstarDashboard", "parameters": [ { "in": "path", - "name": "serviceAccountId", + "name": "dashboard_id", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], "responses": { "200": { - "$ref": "#/components/responses/retrieveServiceAccountResponse" + "$ref": "#/components/responses/okResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -22449,45 +22273,32 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get single serviceaccount by Id", + "summary": "Unstar a dashboard.", "tags": [ - "service_accounts" + "signed_in_user" ] }, - "patch": { - "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)", - "operationId": "updateServiceAccount", + "post": { + "deprecated": true, + "description": "Stars the given Dashboard for the actual user.", + "operationId": "starDashboard", "parameters": [ { "in": "path", - "name": "serviceAccountId", + "name": "dashboard_id", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateServiceAccountForm" - } - } - }, - "x-originalParamName": "Body" - }, "responses": { "200": { - "$ref": "#/components/responses/updateServiceAccountResponse" + "$ref": "#/components/responses/okResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -22498,40 +22309,23 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Update service account", + "summary": "Star a dashboard.", "tags": [ - "service_accounts" + "signed_in_user" ] } }, - "/serviceaccounts/{serviceAccountId}/tokens": { + "/user/teams": { "get": { - "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:read` scope: `global:serviceaccounts:id:1` (single service account)\n\nRequires basic authentication and that the authenticated user is a Grafana Admin.", - "operationId": "listTokens", - "parameters": [ - { - "in": "path", - "name": "serviceAccountId", - "required": true, - "schema": { - "format": "int64", - "type": "integer" - } - } - ], + "description": "Return a list of all teams that the current user is member of.", + "operationId": "getSignedInUserTeamList", "responses": { "200": { - "$ref": "#/components/responses/listTokensResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/getSignedInUserTeamListResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -22543,18 +22337,20 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get service account tokens", + "summary": "Teams that the actual User is member of.", "tags": [ - "service_accounts" + "signed_in_user" ] - }, + } + }, + "/user/using/{org_id}": { "post": { - "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)", - "operationId": "createToken", + "description": "Switch user context to the given organization.", + "operationId": "userSetUsingOrg", "parameters": [ { "in": "path", - "name": "serviceAccountId", + "name": "org_id", "required": true, "schema": { "format": "int64", @@ -22562,19 +22358,9 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AddServiceAccountTokenCommand" - } - } - }, - "x-originalParamName": "Body" - }, "responses": { "200": { - "$ref": "#/components/responses/createTokenResponse" + "$ref": "#/components/responses/okResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -22585,41 +22371,37 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, - "409": { - "$ref": "#/components/responses/conflictError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "CreateNewToken adds a token to a service account", + "summary": "Switch user context for signed in user.", "tags": [ - "service_accounts" + "signed_in_user" ] } }, - "/serviceaccounts/{serviceAccountId}/tokens/{tokenId}": { - "delete": { - "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)\n\nRequires basic authentication and that the authenticated user is a Grafana Admin.", - "operationId": "deleteToken", + "/users": { + "get": { + "description": "Returns all users that the authenticated user has permission to view, admin permission required.", + "operationId": "searchUsers", "parameters": [ { - "in": "path", - "name": "tokenId", - "required": true, + "description": "Limit the maximum number of users to return per page", + "in": "query", + "name": "perpage", "schema": { + "default": 1000, "format": "int64", "type": "integer" } }, { - "in": "path", - "name": "serviceAccountId", - "required": true, + "description": "Page index for starting fetching users", + "in": "query", + "name": "page", "schema": { + "default": 1, "format": "int64", "type": "integer" } @@ -22627,10 +22409,7 @@ ], "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" + "$ref": "#/components/responses/searchUsersResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -22638,72 +22417,59 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "DeleteToken deletes service account tokens", + "summary": "Get users.", "tags": [ - "service_accounts" + "users" ] } }, - "/signing-keys/keys": { + "/users/lookup": { "get": { - "description": "Required permissions\nNone", - "operationId": "retrieveJWKS", - "responses": { - "200": { - "$ref": "#/components/responses/jwksResponse" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "operationId": "getUserByLoginOrEmail", + "parameters": [ + { + "description": "loginOrEmail of the user", + "in": "query", + "name": "loginOrEmail", + "required": true, + "schema": { + "type": "string" + } } - }, - "summary": "Get JSON Web Key Set (JWKS) with all the keys that can be used to verify tokens (public keys)", - "tags": [ - "signing_keys" - ] - } - }, - "/snapshot/shared-options": { - "get": { - "operationId": "getSharingOptions", + ], "responses": { "200": { - "$ref": "#/components/responses/getSharingOptionsResponse" + "$ref": "#/components/responses/userResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get snapshot sharing settings.", + "summary": "Get user by login or email.", "tags": [ - "snapshots" + "users" ] } }, - "/snapshots": { - "post": { - "description": "Snapshot public mode should be enabled or authentication is required.", - "operationId": "createDashboardSnapshot", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateDashboardSnapshotCommand" - } - } - }, - "required": true, - "x-originalParamName": "body" - }, + "/users/search": { + "get": { + "operationId": "searchUsersWithPaging", "responses": { "200": { - "$ref": "#/components/responses/createDashboardSnapshotResponse" + "$ref": "#/components/responses/searchUsersWithPagingResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -22711,33 +22477,36 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "When creating a snapshot using the API, you have to provide the full dashboard payload including the snapshot data. This endpoint is designed for the Grafana UI.", + "summary": "Get users with paging.", "tags": [ - "snapshots" + "users" ] } }, - "/snapshots-delete/{deleteKey}": { + "/users/{user_id}": { "get": { - "description": "Snapshot public mode should be enabled or authentication is required.", - "operationId": "deleteDashboardSnapshotByDeleteKey", + "operationId": "getUserByID", "parameters": [ { "in": "path", - "name": "deleteKey", + "name": "user_id", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], "responses": { "200": { - "$ref": "#/components/responses/okResponse" + "$ref": "#/components/responses/userResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -22752,62 +22521,87 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete Snapshot by deleteKey.", + "summary": "Get user by id.", "tags": [ - "snapshots" + "users" ] - } - }, - "/snapshots/{key}": { - "delete": { - "operationId": "deleteDashboardSnapshot", + }, + "put": { + "description": "Update the user identified by id.", + "operationId": "updateUser", "parameters": [ { "in": "path", - "name": "key", + "name": "user_id", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserCommand" + } + } + }, + "description": "To change the email, name, login, theme, provide another one.", + "required": true, + "x-originalParamName": "body" + }, "responses": { "200": { "$ref": "#/components/responses/okResponse" }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, "403": { "$ref": "#/components/responses/forbiddenError" }, "404": { "$ref": "#/components/responses/notFoundError" }, + "409": { + "$ref": "#/components/responses/conflictError" + }, "500": { "$ref": "#/components/responses/internalServerError" } }, - "summary": "Delete Snapshot by Key.", + "summary": "Update user.", "tags": [ - "snapshots" + "users" ] - }, + } + }, + "/users/{user_id}/orgs": { "get": { - "operationId": "getDashboardSnapshot", + "description": "Get organizations for user identified by id.", + "operationId": "getUserOrgList", "parameters": [ { "in": "path", - "name": "key", + "name": "user_id", "required": true, "schema": { - "type": "string" + "format": "int64", + "type": "integer" } } ], "responses": { "200": { - "$ref": "#/components/responses/getDashboardSnapshotResponse" + "$ref": "#/components/responses/getUserOrgListResponse" }, - "400": { - "$ref": "#/components/responses/badRequestError" + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" }, "404": { "$ref": "#/components/responses/notFoundError" @@ -22816,18 +22610,30 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get Snapshot by Key.", + "summary": "Get organizations for user.", "tags": [ - "snapshots" + "users" ] } }, - "/stats": { + "/users/{user_id}/teams": { "get": { - "operationId": "listDevices", + "description": "Get teams for user identified by id.", + "operationId": "getUserTeams", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], "responses": { "200": { - "$ref": "#/components/responses/devicesResponse" + "$ref": "#/components/responses/getUserTeamsResponse" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -22842,83 +22648,126 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Lists all devices within the last 30 days", + "summary": "Get teams for user.", "tags": [ - "devices" + "users" ] } }, - "/teams": { + "/v1/provisioning/alert-rules": { + "get": { + "operationId": "RouteGetAlertRules", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionedAlertRules" + } + } + }, + "description": "ProvisionedAlertRules" + } + }, + "summary": "Get all the alert rules.", + "tags": [ + "provisioning" + ] + }, "post": { - "operationId": "createTeam", + "operationId": "RoutePostAlertRule", + "parameters": [ + { + "in": "header", + "name": "X-Disable-Provenance", + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTeamCommand" + "$ref": "#/components/schemas/ProvisionedAlertRule" } } }, - "required": true, - "x-originalParamName": "body" + "x-originalParamName": "Body" }, "responses": { - "200": { - "$ref": "#/components/responses/createTeamResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "409": { - "$ref": "#/components/responses/conflictError" + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionedAlertRule" + } + } + }, + "description": "ProvisionedAlertRule" }, - "500": { - "$ref": "#/components/responses/internalServerError" + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "ValidationError" } }, - "summary": "Add Team.", + "summary": "Create a new alert rule.", "tags": [ - "teams" + "provisioning" ] } }, - "/teams/search": { + "/v1/provisioning/alert-rules/export": { "get": { - "operationId": "searchTeams", + "operationId": "RouteGetAlertRulesExport", "parameters": [ { + "description": "Whether to initiate a download of the file or not.", "in": "query", - "name": "page", + "name": "download", "schema": { - "default": 1, - "format": "int64", - "type": "integer" + "default": false, + "type": "boolean" } }, { - "description": "Number of items per page\nThe totalCount field in the response can be used for pagination list E.g. if totalCount is equal to 100 teams and the perpage parameter is set to 10 then there are 10 pages of teams.", + "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", "in": "query", - "name": "perpage", + "name": "format", "schema": { - "default": 1000, - "format": "int64", - "type": "integer" + "default": "yaml", + "type": "string" } }, { + "description": "UIDs of folders from which to export rules", "in": "query", - "name": "name", + "name": "folderUid", + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + { + "description": "Name of group of rules to export. Must be specified only together with a single folder UID", + "in": "query", + "name": "group", "schema": { "type": "string" } }, { - "description": "If set it will return results where the query value is contained in the name field. Query values with spaces need to be URL encoded.", + "description": "UID of alert rule to export. If specified, parameters folderUid and group must be empty.", "in": "query", - "name": "query", + "name": "ruleUid", "schema": { "type": "string" } @@ -22926,120 +22775,106 @@ ], "responses": { "200": { - "$ref": "#/components/responses/searchTeamsResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertingFileExport" + } + } + }, + "description": "AlertingFileExport" }, - "500": { - "$ref": "#/components/responses/internalServerError" + "404": { + "description": " Not found." } }, - "summary": "Team Search With Paging.", + "summary": "Export all alert rules in provisioning file format.", "tags": [ - "teams" + "provisioning" ] } }, - "/teams/{teamId}/groups": { + "/v1/provisioning/alert-rules/{UID}": { "delete": { - "operationId": "removeTeamGroupApiQuery", + "operationId": "RouteDeleteAlertRule", "parameters": [ { - "in": "query", - "name": "groupId", + "description": "Alert rule UID", + "in": "path", + "name": "UID", + "required": true, "schema": { "type": "string" } }, { - "in": "path", - "name": "teamId", - "required": true, + "in": "header", + "name": "X-Disable-Provenance", "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], "responses": { - "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "204": { + "description": " The alert rule was deleted successfully." } }, - "summary": "Remove External Group.", + "summary": "Delete a specific alert rule by UID.", "tags": [ - "sync_team_groups", - "enterprise" + "provisioning" ] }, "get": { - "operationId": "getTeamGroupsApi", + "operationId": "RouteGetAlertRule", "parameters": [ { + "description": "Alert rule UID", "in": "path", - "name": "teamId", + "name": "UID", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], "responses": { "200": { - "$ref": "#/components/responses/getTeamGroupsApiResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionedAlertRule" + } + } + }, + "description": "ProvisionedAlertRule" }, "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "description": " Not found." } }, - "summary": "Get External Groups.", + "summary": "Get a specific alert rule by UID.", "tags": [ - "sync_team_groups", - "enterprise" + "provisioning" ] }, - "post": { - "operationId": "addTeamGroupApi", + "put": { + "operationId": "RoutePutAlertRule", "parameters": [ { + "description": "Alert rule UID", "in": "path", - "name": "teamId", + "name": "UID", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" + } + }, + { + "in": "header", + "name": "X-Disable-Provenance", + "schema": { + "type": "string" } } ], @@ -23047,47 +22882,66 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TeamGroupMapping" + "$ref": "#/components/schemas/ProvisionedAlertRule" } } }, - "required": true, - "x-originalParamName": "body" + "x-originalParamName": "Body" }, "responses": { "200": { - "$ref": "#/components/responses/okResponse" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionedAlertRule" + } + } + }, + "description": "ProvisionedAlertRule" }, "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "ValidationError" } }, - "summary": "Add External Group.", + "summary": "Update an existing alert rule.", "tags": [ - "sync_team_groups", - "enterprise" + "provisioning" ] } }, - "/teams/{team_id}": { - "delete": { - "operationId": "deleteTeamByID", + "/v1/provisioning/alert-rules/{UID}/export": { + "get": { + "operationId": "RouteGetAlertRuleExport", "parameters": [ { + "description": "Whether to initiate a download of the file or not.", + "in": "query", + "name": "download", + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", + "in": "query", + "name": "format", + "schema": { + "default": "yaml", + "type": "string" + } + }, + { + "description": "Alert rule UID", "in": "path", - "name": "team_id", + "name": "UID", "required": true, "schema": { "type": "string" @@ -23096,33 +22950,43 @@ ], "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertingFileExport" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/AlertingFileExport" + } + }, + "text/yaml": { + "schema": { + "$ref": "#/components/schemas/AlertingFileExport" + } + } + }, + "description": "AlertingFileExport" }, "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "description": " Not found." } }, - "summary": "Delete Team By ID.", + "summary": "Export an alert rule in provisioning file format.", "tags": [ - "teams" + "provisioning" ] - }, + } + }, + "/v1/provisioning/contact-points": { "get": { - "operationId": "getTeamByID", + "operationId": "RouteGetContactpoints", "parameters": [ { - "in": "path", - "name": "team_id", - "required": true, + "description": "Filter by name", + "in": "query", + "name": "name", "schema": { "type": "string" } @@ -23130,33 +22994,27 @@ ], "responses": { "200": { - "$ref": "#/components/responses/getTeamByIDResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactPoints" + } + } + }, + "description": "ContactPoints" } }, - "summary": "Get Team By ID.", + "summary": "Get all the contact points.", "tags": [ - "teams" + "provisioning" ] }, - "put": { - "operationId": "updateTeam", + "post": { + "operationId": "RoutePostContactpoints", "parameters": [ { - "in": "path", - "name": "team_id", - "required": true, + "in": "header", + "name": "X-Disable-Provenance", "schema": { "type": "string" } @@ -23166,182 +23024,149 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateTeamCommand" + "$ref": "#/components/schemas/EmbeddedContactPoint" } } }, - "required": true, - "x-originalParamName": "body" + "x-originalParamName": "Body" }, "responses": { - "200": { - "$ref": "#/components/responses/okResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, - "409": { - "$ref": "#/components/responses/conflictError" + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmbeddedContactPoint" + } + } + }, + "description": "EmbeddedContactPoint" }, - "500": { - "$ref": "#/components/responses/internalServerError" + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "ValidationError" } }, - "summary": "Update Team.", + "summary": "Create a contact point.", "tags": [ - "teams" + "provisioning" ] } }, - "/teams/{team_id}/members": { + "/v1/provisioning/contact-points/export": { "get": { - "operationId": "getTeamMembers", + "operationId": "RouteGetContactpointsExport", "parameters": [ { - "in": "path", - "name": "team_id", - "required": true, + "description": "Whether to initiate a download of the file or not.", + "in": "query", + "name": "download", "schema": { - "type": "string" + "default": false, + "type": "boolean" } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/getTeamMembersResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" }, - "403": { - "$ref": "#/components/responses/forbiddenError" + { + "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", + "in": "query", + "name": "format", + "schema": { + "default": "yaml", + "type": "string" + } }, - "404": { - "$ref": "#/components/responses/notFoundError" + { + "description": "Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings.", + "in": "query", + "name": "decrypt", + "schema": { + "default": false, + "type": "boolean" + } }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Get Team Members.", - "tags": [ - "teams" - ] - }, - "post": { - "operationId": "addTeamMember", - "parameters": [ { - "in": "path", - "name": "team_id", - "required": true, + "description": "Filter by name", + "in": "query", + "name": "name", "schema": { "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AddTeamMemberCommand" - } - } - }, - "required": true, - "x-originalParamName": "body" - }, "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertingFileExport" + } + } + }, + "description": "AlertingFileExport" }, "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PermissionDenied" + } + } + }, + "description": "PermissionDenied" } }, - "summary": "Add Team Member.", + "summary": "Export all contact points in provisioning file format.", "tags": [ - "teams" + "provisioning" ] } }, - "/teams/{team_id}/members/{user_id}": { + "/v1/provisioning/contact-points/{UID}": { "delete": { - "operationId": "removeTeamMember", + "operationId": "RouteDeleteContactpoints", "parameters": [ { + "description": "UID is the contact point unique identifier", "in": "path", - "name": "team_id", + "name": "UID", "required": true, "schema": { "type": "string" } - }, - { - "in": "path", - "name": "user_id", - "required": true, - "schema": { - "format": "int64", - "type": "integer" - } } ], "responses": { - "200": { - "$ref": "#/components/responses/okResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "202": { + "description": " The contact point was deleted successfully." } }, - "summary": "Remove Member From Team.", + "summary": "Delete a contact point.", "tags": [ - "teams" + "provisioning" ] }, "put": { - "operationId": "updateTeamMember", + "operationId": "RoutePutContactpoint", "parameters": [ { + "description": "UID is the contact point unique identifier", "in": "path", - "name": "team_id", + "name": "UID", "required": true, "schema": { "type": "string" } }, { - "in": "path", - "name": "user_id", - "required": true, + "in": "header", + "name": "X-Disable-Provenance", "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], @@ -23349,43 +23174,105 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateTeamMemberCommand" + "$ref": "#/components/schemas/EmbeddedContactPoint" } } }, - "required": true, - "x-originalParamName": "body" + "x-originalParamName": "Body" }, "responses": { - "200": { - "$ref": "#/components/responses/okResponse" + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ack" + } + } + }, + "description": "Ack" }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "ValidationError" + } + }, + "summary": "Update an existing contact point.", + "tags": [ + "provisioning" + ] + } + }, + "/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": { + "delete": { + "description": "Delete rule group", + "operationId": "RouteDeleteAlertRuleGroup", + "parameters": [ + { + "in": "path", + "name": "FolderUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "Group", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": " The alert rule group was deleted successfully." }, "403": { - "$ref": "#/components/responses/forbiddenError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + }, + "description": "ForbiddenError" }, "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFound" + } + } + }, + "description": "NotFound" } }, - "summary": "Update Team Member.", "tags": [ - "teams" + "provisioning" ] - } - }, - "/teams/{team_id}/preferences": { + }, "get": { - "operationId": "getTeamPreferences", + "operationId": "RouteGetAlertRuleGroup", "parameters": [ { "in": "path", - "name": "team_id", + "name": "FolderUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "Group", "required": true, "schema": { "type": "string" @@ -23394,26 +23281,45 @@ ], "responses": { "200": { - "$ref": "#/components/responses/getPreferencesResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertRuleGroup" + } + } + }, + "description": "AlertRuleGroup" }, - "500": { - "$ref": "#/components/responses/internalServerError" + "404": { + "description": " Not found." } }, - "summary": "Get Team Preferences.", + "summary": "Get a rule group.", "tags": [ - "teams" + "provisioning" ] }, "put": { - "operationId": "updateTeamPreferences", + "operationId": "RoutePutAlertRuleGroup", "parameters": [ + { + "in": "header", + "name": "X-Disable-Provenance", + "schema": { + "type": "string" + } + }, { "in": "path", - "name": "team_id", + "name": "FolderUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "Group", "required": true, "schema": { "type": "string" @@ -23424,475 +23330,238 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdatePrefsCmd" + "$ref": "#/components/schemas/AlertRuleGroup" } } }, - "required": true, - "x-originalParamName": "body" + "x-originalParamName": "Body" }, "responses": { "200": { - "$ref": "#/components/responses/okResponse" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertRuleGroup" + } + } + }, + "description": "AlertRuleGroup" }, "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "ValidationError" } }, - "summary": "Update Team Preferences.", + "summary": "Update the interval of a rule group.", "tags": [ - "teams" + "provisioning" ] } }, - "/user": { - "get": { - "description": "Get (current authenticated user)", - "operationId": "getSignedInUser", - "responses": { - "200": { - "$ref": "#/components/responses/userResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "tags": [ - "signed_in_user" - ] - }, - "put": { - "operationId": "updateSignedInUser", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateUserCommand" - } - } - }, - "description": "To change the email, name, login, theme, provide another one.", - "required": true, - "x-originalParamName": "body" - }, - "responses": { - "200": { - "$ref": "#/components/responses/okResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Update signed in User.", - "tags": [ - "signed_in_user" - ] - } - }, - "/user/auth-tokens": { + "/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export": { "get": { - "description": "Return a list of all auth tokens (devices) that the actual user currently have logged in from.", - "operationId": "getUserAuthTokens", - "responses": { - "200": { - "$ref": "#/components/responses/getUserAuthTokensResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Auth tokens of the actual User.", - "tags": [ - "signed_in_user" - ] - } - }, - "/user/helpflags/clear": { - "get": { - "operationId": "clearHelpFlags", - "responses": { - "200": { - "$ref": "#/components/responses/helpFlagResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Clear user help flag.", - "tags": [ - "signed_in_user" - ] - } - }, - "/user/helpflags/{flag_id}": { - "put": { - "operationId": "setHelpFlag", + "operationId": "RouteGetAlertRuleGroupExport", "parameters": [ { - "in": "path", - "name": "flag_id", - "required": true, + "description": "Whether to initiate a download of the file or not.", + "in": "query", + "name": "download", "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/helpFlagResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Set user help flag.", - "tags": [ - "signed_in_user" - ] - } - }, - "/user/orgs": { - "get": { - "description": "Return a list of all organizations of the current user.", - "operationId": "getSignedInUserOrgList", - "responses": { - "200": { - "$ref": "#/components/responses/getSignedInUserOrgListResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "security": [ - { - "basic": [] - } - ], - "summary": "Organizations of the actual User.", - "tags": [ - "signed_in_user" - ] - } - }, - "/user/password": { - "put": { - "description": "Changes the password for the user.", - "operationId": "changeUserPassword", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeUserPasswordCommand" - } + "default": false, + "type": "boolean" } }, - "description": "To change the email, name, login, theme, provide another one.", - "required": true, - "x-originalParamName": "body" - }, - "responses": { - "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "security": [ { - "basic": [] - } - ], - "summary": "Change Password.", - "tags": [ - "signed_in_user" - ] - } - }, - "/user/preferences": { - "get": { - "operationId": "getUserPreferences", - "responses": { - "200": { - "$ref": "#/components/responses/getPreferencesResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Get user preferences.", - "tags": [ - "user_preferences" - ] - }, - "patch": { - "operationId": "patchUserPreferences", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchPrefsCmd" - } - } - }, - "required": true, - "x-originalParamName": "body" - }, - "responses": { - "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Patch user preferences.", - "tags": [ - "user_preferences" - ] - }, - "put": { - "description": "Omitting a key (`theme`, `homeDashboardId`, `timezone`) will cause the current value to be replaced with the system default value.", - "operationId": "updateUserPreferences", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdatePrefsCmd" - } + "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", + "in": "query", + "name": "format", + "schema": { + "default": "yaml", + "type": "string" } }, - "required": true, - "x-originalParamName": "body" - }, + { + "in": "path", + "name": "FolderUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "Group", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertingFileExport" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/AlertingFileExport" + } + }, + "text/yaml": { + "schema": { + "$ref": "#/components/schemas/AlertingFileExport" + } + } + }, + "description": "AlertingFileExport" }, - "500": { - "$ref": "#/components/responses/internalServerError" + "404": { + "description": " Not found." } }, - "summary": "Update user preferences.", + "summary": "Export an alert rule group in provisioning file format.", "tags": [ - "user_preferences" + "provisioning" ] } }, - "/user/quotas": { + "/v1/provisioning/mute-timings": { "get": { - "operationId": "getUserQuotas", + "operationId": "RouteGetMuteTimings", "responses": { "200": { - "$ref": "#/components/responses/getQuotaResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MuteTimings" + } + } + }, + "description": "MuteTimings" } }, - "summary": "Fetch user quota.", + "summary": "Get all the mute timings.", "tags": [ - "signed_in_user" + "provisioning" ] - } - }, - "/user/revoke-auth-token": { + }, "post": { - "description": "Revokes the given auth token (device) for the actual user. User of issued auth token (device) will no longer be logged in and will be required to authenticate again upon next activity.", - "operationId": "revokeUserAuthToken", + "operationId": "RoutePostMuteTiming", + "parameters": [ + { + "in": "header", + "name": "X-Disable-Provenance", + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RevokeAuthTokenCmd" + "$ref": "#/components/schemas/MuteTimeInterval" } } }, - "required": true, - "x-originalParamName": "body" + "x-originalParamName": "Body" }, "responses": { - "200": { - "$ref": "#/components/responses/okResponse" + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MuteTimeInterval" + } + } + }, + "description": "MuteTimeInterval" }, "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "ValidationError" } }, - "summary": "Revoke an auth token of the actual User.", + "summary": "Create a new mute timing.", "tags": [ - "signed_in_user" + "provisioning" ] } }, - "/user/stars/dashboard/uid/{dashboard_uid}": { - "delete": { - "description": "Deletes the starring of the given Dashboard for the actual user.", - "operationId": "unstarDashboardByUID", + "/v1/provisioning/mute-timings/export": { + "get": { + "operationId": "RouteExportMuteTimings", "parameters": [ { - "in": "path", - "name": "dashboard_uid", - "required": true, + "description": "Whether to initiate a download of the file or not.", + "in": "query", + "name": "download", "schema": { - "type": "string" + "default": false, + "type": "boolean" } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Unstar a dashboard.", - "tags": [ - "signed_in_user" - ] - }, - "post": { - "description": "Stars the given Dashboard for the actual user.", - "operationId": "starDashboardByUID", - "parameters": [ { - "in": "path", - "name": "dashboard_uid", - "required": true, + "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", + "in": "query", + "name": "format", "schema": { + "default": "yaml", "type": "string" } } ], "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertingFileExport" + } + } + }, + "description": "AlertingFileExport" }, "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PermissionDenied" + } + } + }, + "description": "PermissionDenied" } }, - "summary": "Star a dashboard.", + "summary": "Export all mute timings in provisioning format.", "tags": [ - "signed_in_user" + "provisioning" ] } }, - "/user/stars/dashboard/{dashboard_id}": { + "/v1/provisioning/mute-timings/{name}": { "delete": { - "deprecated": true, - "description": "Deletes the starring of the given Dashboard for the actual user.", - "operationId": "unstarDashboard", + "operationId": "RouteDeleteMuteTiming", "parameters": [ { + "description": "Mute timing name", "in": "path", - "name": "dashboard_id", + "name": "name", "required": true, "schema": { "type": "string" @@ -23900,35 +23569,32 @@ } ], "responses": { - "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" + "204": { + "description": " The mute timing was deleted successfully." }, - "500": { - "$ref": "#/components/responses/internalServerError" + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenericPublicError" + } + } + }, + "description": "GenericPublicError" } }, - "summary": "Unstar a dashboard.", + "summary": "Delete a mute timing.", "tags": [ - "signed_in_user" + "provisioning" ] }, - "post": { - "deprecated": true, - "description": "Stars the given Dashboard for the actual user.", - "operationId": "starDashboard", + "get": { + "operationId": "RouteGetMuteTiming", "parameters": [ { + "description": "Mute timing name", "in": "path", - "name": "dashboard_id", + "name": "name", "required": true, "schema": { "type": "string" @@ -23937,245 +23603,360 @@ ], "responses": { "200": { - "$ref": "#/components/responses/okResponse" - }, - "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" - } - }, - "summary": "Star a dashboard.", - "tags": [ - "signed_in_user" - ] - } - }, - "/user/teams": { - "get": { - "description": "Return a list of all teams that the current user is member of.", - "operationId": "getSignedInUserTeamList", - "responses": { - "200": { - "$ref": "#/components/responses/getSignedInUserTeamListResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MuteTimeInterval" + } + } + }, + "description": "MuteTimeInterval" }, - "500": { - "$ref": "#/components/responses/internalServerError" + "404": { + "description": " Not found." } }, - "summary": "Teams that the actual User is member of.", + "summary": "Get a mute timing.", "tags": [ - "signed_in_user" + "provisioning" ] - } - }, - "/user/using/{org_id}": { - "post": { - "description": "Switch user context to the given organization.", - "operationId": "userSetUsingOrg", + }, + "put": { + "operationId": "RoutePutMuteTiming", "parameters": [ { + "description": "Mute timing name", "in": "path", - "name": "org_id", + "name": "name", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" + } + }, + { + "in": "header", + "name": "X-Disable-Provenance", + "schema": { + "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MuteTimeInterval" + } + } + }, + "x-originalParamName": "Body" + }, "responses": { - "200": { - "$ref": "#/components/responses/okResponse" + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MuteTimeInterval" + } + } + }, + "description": "MuteTimeInterval" }, "400": { - "$ref": "#/components/responses/badRequestError" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "ValidationError" } }, - "summary": "Switch user context for signed in user.", + "summary": "Replace an existing mute timing.", "tags": [ - "signed_in_user" + "provisioning" ] } }, - "/users": { + "/v1/provisioning/mute-timings/{name}/export": { "get": { - "description": "Returns all users that the authenticated user has permission to view, admin permission required.", - "operationId": "searchUsers", + "operationId": "RouteExportMuteTiming", "parameters": [ { - "description": "Limit the maximum number of users to return per page", + "description": "Whether to initiate a download of the file or not.", "in": "query", - "name": "perpage", + "name": "download", "schema": { - "default": 1000, - "format": "int64", - "type": "integer" + "default": false, + "type": "boolean" } }, { - "description": "Page index for starting fetching users", + "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", "in": "query", - "name": "page", + "name": "format", "schema": { - "default": 1, - "format": "int64", - "type": "integer" + "default": "yaml", + "type": "string" + } + }, + { + "description": "Mute timing name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" } } ], "responses": { "200": { - "$ref": "#/components/responses/searchUsersResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertingFileExport" + } + } + }, + "description": "AlertingFileExport" }, "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PermissionDenied" + } + } + }, + "description": "PermissionDenied" } }, - "summary": "Get users.", + "summary": "Export a mute timing in provisioning format.", "tags": [ - "users" + "provisioning" ] } }, - "/users/lookup": { + "/v1/provisioning/policies": { + "delete": { + "operationId": "RouteResetPolicyTree", + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ack" + } + } + }, + "description": "Ack" + } + }, + "summary": "Clears the notification policy tree.", + "tags": [ + "provisioning" + ] + }, "get": { - "operationId": "getUserByLoginOrEmail", + "operationId": "RouteGetPolicyTree", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Route" + } + } + }, + "description": "Route" + } + }, + "summary": "Get the notification policy tree.", + "tags": [ + "provisioning" + ] + }, + "put": { + "operationId": "RoutePutPolicyTree", "parameters": [ { - "description": "loginOrEmail of the user", - "in": "query", - "name": "loginOrEmail", - "required": true, + "in": "header", + "name": "X-Disable-Provenance", "schema": { "type": "string" } } - ], + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Route" + } + } + }, + "description": "The new notification routing tree to use", + "x-originalParamName": "Body" + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ack" + } + } + }, + "description": "Ack" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "ValidationError" + } + }, + "summary": "Sets the notification policy tree.", + "tags": [ + "provisioning" + ] + } + }, + "/v1/provisioning/policies/export": { + "get": { + "operationId": "RouteGetPolicyTreeExport", "responses": { "200": { - "$ref": "#/components/responses/userResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertingFileExport" + } + } + }, + "description": "AlertingFileExport" }, "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFound" + } + } + }, + "description": "NotFound" } }, - "summary": "Get user by login or email.", + "summary": "Export the notification policy tree in provisioning file format.", "tags": [ - "users" + "provisioning" ] } }, - "/users/search": { + "/v1/provisioning/templates": { "get": { - "operationId": "searchUsersWithPaging", + "operationId": "RouteGetTemplates", "responses": { "200": { - "$ref": "#/components/responses/searchUsersWithPagingResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationTemplates" + } + } + }, + "description": "NotificationTemplates" }, "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "description": " Not found." } }, - "summary": "Get users with paging.", + "summary": "Get all notification templates.", "tags": [ - "users" + "provisioning" ] } }, - "/users/{user_id}": { + "/v1/provisioning/templates/{name}": { + "delete": { + "operationId": "RouteDeleteTemplate", + "parameters": [ + { + "description": "Template Name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": " The template was deleted successfully." + } + }, + "summary": "Delete a template.", + "tags": [ + "provisioning" + ] + }, "get": { - "operationId": "getUserByID", + "operationId": "RouteGetTemplate", "parameters": [ { + "description": "Template Name", "in": "path", - "name": "user_id", + "name": "name", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], "responses": { "200": { - "$ref": "#/components/responses/userResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationTemplate" + } + } + }, + "description": "NotificationTemplate" }, "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" + "description": " Not found." } }, - "summary": "Get user by id.", + "summary": "Get a notification template.", "tags": [ - "users" + "provisioning" ] }, "put": { - "description": "Update the user identified by id.", - "operationId": "updateUser", + "operationId": "RoutePutTemplate", "parameters": [ { + "description": "Template Name", "in": "path", - "name": "user_id", + "name": "name", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" + } + }, + { + "in": "header", + "name": "X-Disable-Provenance", + "schema": { + "type": "string" } } ], @@ -24183,93 +23964,84 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateUserCommand" + "$ref": "#/components/schemas/NotificationTemplateContent" } } }, - "description": "To change the email, name, login, theme, provide another one.", - "required": true, - "x-originalParamName": "body" + "x-originalParamName": "Body" }, "responses": { - "200": { - "$ref": "#/components/responses/okResponse" - }, - "401": { - "$ref": "#/components/responses/unauthorisedError" - }, - "403": { - "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationTemplate" + } + } + }, + "description": "NotificationTemplate" }, - "500": { - "$ref": "#/components/responses/internalServerError" + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "ValidationError" } }, - "summary": "Update user.", + "summary": "Updates an existing notification template.", "tags": [ - "users" + "provisioning" ] } }, - "/users/{user_id}/orgs": { + "/v1/sso-settings": { "get": { - "description": "Get organizations for user identified by id.", - "operationId": "getUserOrgList", - "parameters": [ - { - "in": "path", - "name": "user_id", - "required": true, - "schema": { - "format": "int64", - "type": "integer" - } - } - ], + "description": "You need to have a permission with action `settings:read` with scope `settings:auth.\u003cprovider\u003e:*`.", + "operationId": "listAllProvidersSettings", "responses": { "200": { - "$ref": "#/components/responses/getUserOrgListResponse" + "$ref": "#/components/responses/listSSOSettingsResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" }, "403": { "$ref": "#/components/responses/forbiddenError" - }, - "404": { - "$ref": "#/components/responses/notFoundError" - }, - "500": { - "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get organizations for user.", + "summary": "List all SSO Settings entries", "tags": [ - "users" + "sso_settings" ] } }, - "/users/{user_id}/teams": { - "get": { - "description": "Get teams for user identified by id.", - "operationId": "getUserTeams", + "/v1/sso-settings/{key}": { + "delete": { + "description": "Removes the SSO Settings for a provider.\n\nYou need to have a permission with action `settings:write` and scope `settings:auth.\u003cprovider\u003e:*`.", + "operationId": "removeProviderSettings", "parameters": [ { "in": "path", - "name": "user_id", + "name": "key", "required": true, "schema": { - "format": "int64", - "type": "integer" + "type": "string" } } ], "responses": { - "200": { - "$ref": "#/components/responses/getUserTeamsResponse" + "204": { + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" }, "401": { "$ref": "#/components/responses/unauthorisedError" @@ -24284,16 +24056,14 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get teams for user.", + "summary": "Remove SSO Settings", "tags": [ - "users" + "sso_settings" ] - } - }, - "/v1/sso-settings/{key}": { - "delete": { - "description": "Removes the SSO Settings for a provider.\n\nYou need to have a permission with action `settings:write` and scope `settings:auth.\u003cprovider\u003e:*`.", - "operationId": "removeProviderSettings", + }, + "get": { + "description": "You need to have a permission with action `settings:read` with scope `settings:auth.\u003cprovider\u003e:*`.", + "operationId": "getProviderSettings", "parameters": [ { "in": "path", @@ -24305,8 +24075,8 @@ } ], "responses": { - "204": { - "$ref": "#/components/responses/okResponse" + "200": { + "$ref": "#/components/responses/getSSOSettingsResponse" }, "400": { "$ref": "#/components/responses/badRequestError" @@ -24317,11 +24087,11 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, - "500": { - "$ref": "#/components/responses/internalServerError" + "404": { + "$ref": "#/components/responses/notFoundError" } }, - "summary": "Remove SSO Settings", + "summary": "Get an SSO Settings entry by Key", "tags": [ "sso_settings" ] @@ -24343,7 +24113,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SSOSettings" + "properties": { + "id": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "settings": { + "additionalProperties": false, + "type": "object" + } + }, + "type": "object" } } }, diff --git a/public/sass/_grafana.scss b/public/sass/_grafana.scss index b21905e838d6d..9a7ad2a5ef81e 100644 --- a/public/sass/_grafana.scss +++ b/public/sass/_grafana.scss @@ -31,7 +31,6 @@ @import 'layout/lists'; // COMPONENTS -@import '../app/features/dashboard/components/AddPanelWidget/AddPanelWidget'; @import 'components/scrollbar'; @import 'components/buttons'; @import 'components/navs'; diff --git a/public/sass/_variables.generated.scss b/public/sass/_variables.generated.scss index 290ebadaeddcd..1a2e11a065177 100644 --- a/public/sass/_variables.generated.scss +++ b/public/sass/_variables.generated.scss @@ -163,7 +163,6 @@ $form-icon-danger: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www // ------------------------- // Used for a bird's eye view of components dependent on the z-axis // Try to avoid customizing these :) -$zindex-active-panel: 999; $zindex-dropdown: 1030; $zindex-navbar-fixed: 1000; $zindex-sidemenu: 1020; diff --git a/public/sass/base/_fonts.scss b/public/sass/base/_fonts.scss index 3d08a216a3304..f75c1d8aaff42 100644 --- a/public/sass/base/_fonts.scss +++ b/public/sass/base/_fonts.scss @@ -25,131 +25,26 @@ U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } -/* cyrillic-ext */ -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(#{$font-file-path}/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa2JL7SUc.woff2) format('woff2'); - unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; -} -/* cyrillic */ -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(#{$font-file-path}/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa0ZL7SUc.woff2) format('woff2'); - unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; -} -/* greek-ext */ -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(#{$font-file-path}/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa2ZL7SUc.woff2) format('woff2'); - unicode-range: U+1F00-1FFF; -} -/* greek */ -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(#{$font-file-path}/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1pL7SUc.woff2) format('woff2'); - unicode-range: U+0370-03FF; -} -/* vietnamese */ -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(#{$font-file-path}/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa2pL7SUc.woff2) format('woff2'); - unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; -} -/* latin-ext */ -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(#{$font-file-path}/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7SUc.woff2) format('woff2'); - unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; -} -/* latin */ +/* +To add new variations/version of Inter, download from https://rsms.me/inter/ and add the +web font files to the public/fonts/inter folder. Do not download the fonts from Google Fonts +or somewhere else because they don't support the features we require (like tabular numerals). + +If adding additional weights, consider switching to the InterVariable variable font as combined +it may take less space than multiple static weights. +*/ @font-face { font-family: 'Inter'; font-style: normal; font-weight: 400; font-display: swap; - src: url(#{$font-file-path}/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7.woff2) format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, - U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* cyrillic-ext */ -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(#{$font-file-path}/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa2JL7SUc.woff2) format('woff2'); - unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; -} -/* cyrillic */ -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(#{$font-file-path}/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa0ZL7SUc.woff2) format('woff2'); - unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; -} -/* greek-ext */ -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(#{$font-file-path}/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa2ZL7SUc.woff2) format('woff2'); - unicode-range: U+1F00-1FFF; -} -/* greek */ -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(#{$font-file-path}/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1pL7SUc.woff2) format('woff2'); - unicode-range: U+0370-03FF; -} -/* vietnamese */ -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(#{$font-file-path}/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa2pL7SUc.woff2) format('woff2'); - unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; + src: url('#{$font-file-path}/inter/Inter-Regular.woff2') format('woff2'); } -/* latin-ext */ -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(#{$font-file-path}/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7SUc.woff2) format('woff2'); - unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; -} -/* latin */ + @font-face { font-family: 'Inter'; font-style: normal; font-weight: 500; font-display: swap; - src: url(#{$font-file-path}/inter/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7.woff2) format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, - U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + src: url('#{$font-file-path}/inter/Inter-Medium.woff2') format('woff2'); } diff --git a/public/sass/components/_dashboard_grid.scss b/public/sass/components/_dashboard_grid.scss index a68f95cd3b687..7185a2a9be2ce 100644 --- a/public/sass/components/_dashboard_grid.scss +++ b/public/sass/components/_dashboard_grid.scss @@ -13,7 +13,6 @@ &:hover { .react-resizable-handle { visibility: visible; - z-index: $zindex-active-panel; } } } @@ -86,22 +85,11 @@ } } -// Hack to prevent panel overlap during drag/hover (due to descending z-index assignment) -.react-grid-item:not(.context-menu-open, .resizing) { - &:hover, - &:active, - &:focus { - z-index: $zindex-active-panel !important; - } -} - // Hack for preventing panel menu overlapping. -.react-grid-item.context-menu-open, -.react-grid-item.resizing, .react-grid-item.resizing.panel, .react-grid-item.panel.dropdown-menu-open, .react-grid-item.react-draggable-dragging.panel { - z-index: $zindex-dropdown !important; + z-index: $zindex-dropdown; } // Disable animation on initial rendering and enable it when component has been mounted. diff --git a/public/sass/components/_scrollbar.scss b/public/sass/components/_scrollbar.scss index ee242a0d9aadb..edb379ae5b6cc 100644 --- a/public/sass/components/_scrollbar.scss +++ b/public/sass/components/_scrollbar.scss @@ -115,6 +115,7 @@ } // Scrollbars +// Note, this is not applied by default if the `betterPageScrolling` feature flag is applied .no-overlay-scrollbar { ::-webkit-scrollbar { width: 8px; diff --git a/public/test/helpers/alertingRuleEditor.tsx b/public/test/helpers/alertingRuleEditor.tsx index 7752de2b0921e..feca69b1f625b 100644 --- a/public/test/helpers/alertingRuleEditor.tsx +++ b/public/test/helpers/alertingRuleEditor.tsx @@ -1,7 +1,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import { Route } from 'react-router-dom'; -import { byRole, byTestId } from 'testing-library-selector'; +import { byRole, byTestId, byText } from 'testing-library-selector'; import { selectors } from '@grafana/e2e-selectors'; import { locationService } from '@grafana/runtime'; @@ -13,7 +13,7 @@ export const ui = { inputs: { name: byRole('textbox', { name: 'name' }), alertType: byTestId('alert-type-picker'), - dataSource: byTestId('datasource-picker'), + dataSource: byTestId(selectors.components.DataSourcePicker.inputV2), folder: byTestId('folder-picker'), folderContainer: byTestId(selectors.components.FolderPicker.containerV2), namespace: byTestId('namespace-picker'), @@ -23,6 +23,11 @@ export const ui = { labelKey: (idx: number) => byTestId(`label-key-${idx}`), labelValue: (idx: number) => byTestId(`label-value-${idx}`), expr: byTestId('expr'), + simplifiedRouting: { + contactPointRouting: byRole('radio', { name: /select contact point/i }), + contactPoint: byTestId('contact-point-picker'), + routingOptions: byText(/muting, grouping and timings \(optional\)/i), + }, }, buttons: { saveAndExit: byRole('button', { name: 'Save rule and exit' }), diff --git a/public/test/jest-resolver.js b/public/test/jest-resolver.js index 75b29ad00dc52..40046b872d5ba 100644 --- a/public/test/jest-resolver.js +++ b/public/test/jest-resolver.js @@ -27,6 +27,23 @@ module.exports = (path, options) => { delete pkg['exports']; delete pkg['module']; } + // Jest + jsdom acts like a browser (i.e., it looks for "browser" imports + // under pkg.exports), but msw knows that you're operating in a Node + // environment: + // + // https://github.com/mswjs/msw/issues/1786#issuecomment-1782559851 + // + // The MSW project's recommended workaround is to disable Jest's + // customExportConditions completely, so no packages use their browser's + // versions. We'll instead clear export conditions only for MSW. + // + // Taken from https://github.com/mswjs/msw/issues/1786#issuecomment-1787730599 + if (pkg.name === 'msw') { + delete pkg.exports['./node'].browser; + } + if (pkg.name === '@mswjs/interceptors') { + delete pkg.exports; + } return pkg; }, }); diff --git a/public/test/mocks/datasource_srv.ts b/public/test/mocks/datasource_srv.ts index 9da05b83ac2f9..9179f7258b121 100644 --- a/public/test/mocks/datasource_srv.ts +++ b/public/test/mocks/datasource_srv.ts @@ -36,12 +36,14 @@ export class MockDataSourceApi extends DataSourceApi { result: DataQueryResponse = { data: [] }; constructor( - name?: string, + datasource: string | DataSourceInstanceSettings = 'MockDataSourceApi', result?: DataQueryResponse, meta?: DataSourcePluginMeta, public error: string | null = null ) { - super({ name: name ? name : 'MockDataSourceApi' } as DataSourceInstanceSettings); + const instaceSettings = + typeof datasource === 'string' ? ({ name: datasource } as DataSourceInstanceSettings) : datasource; + super(instaceSettings); if (result) { this.result = result; } @@ -49,7 +51,7 @@ export class MockDataSourceApi extends DataSourceApi { this.meta = meta || ({} as DataSourcePluginMeta); } - query(request: DataQueryRequest): Promise { + query(request: DataQueryRequest): Promise | Observable { if (this.error) { return Promise.reject(this.error); } diff --git a/public/test/mocks/getGrafanaContextMock.ts b/public/test/mocks/getGrafanaContextMock.ts index feb0155cabd39..fd6aaec843e6b 100644 --- a/public/test/mocks/getGrafanaContextMock.ts +++ b/public/test/mocks/getGrafanaContextMock.ts @@ -2,6 +2,7 @@ import { GrafanaConfig } from '@grafana/data'; import { LocationService } from '@grafana/runtime'; import { AppChromeService } from 'app/core/components/AppChrome/AppChromeService'; import { GrafanaContextType } from 'app/core/context/GrafanaContext'; +import { NewFrontendAssetsChecker } from 'app/core/services/NewFrontendAssetsChecker'; import { backendSrv } from 'app/core/services/backend_srv'; import { KeybindingSrv } from 'app/core/services/keybindingSrv'; @@ -20,6 +21,10 @@ export function getGrafanaContextMock(overrides: Partial = { setupDashboardBindings: jest.fn(), setupTimeRangeBindings: jest.fn(), } as unknown as KeybindingSrv, + newAssetsChecker: { + start: jest.fn(), + reloadIfUpdateDetected: jest.fn(), + } as unknown as NewFrontendAssetsChecker, ...overrides, }; } diff --git a/public/test/mocks/monaco.ts b/public/test/mocks/monaco.ts deleted file mode 100644 index 2de6c5f4a29e6..0000000000000 --- a/public/test/mocks/monaco.ts +++ /dev/null @@ -1 +0,0 @@ -export const monaco = 'monaco'; diff --git a/public/test/mocks/workers.ts b/public/test/mocks/workers.ts index ddba6e1e386f7..34ae57a586498 100644 --- a/public/test/mocks/workers.ts +++ b/public/test/mocks/workers.ts @@ -1,7 +1,7 @@ import { Config } from 'app/plugins/panel/nodeGraph/layout'; import { EdgeDatum, NodeDatum } from 'app/plugins/panel/nodeGraph/types'; -const { layout } = jest.requireActual('../../app/plugins/panel/nodeGraph/layout.worker.js'); +const { layout } = jest.requireActual('../../app/plugins/panel/nodeGraph/layout.worker.utils.js'); class LayoutMockWorker { timeout: number | undefined; diff --git a/public/test/setupTests.ts b/public/test/setupTests.ts index b345588c35584..4b59a6b5b5b5d 100644 --- a/public/test/setupTests.ts +++ b/public/test/setupTests.ts @@ -1,4 +1,5 @@ import '@testing-library/jest-dom'; +import { configure } from '@testing-library/react'; import i18next from 'i18next'; import failOnConsole from 'jest-fail-on-console'; import { initReactI18next } from 'react-i18next'; @@ -20,3 +21,12 @@ i18next.use(initReactI18next).init({ returnEmptyString: false, lng: 'en-US', // this should be the locale of the phrases in our source JSX }); + +// mock out the worker that detects changes in the dashboard +// The mock is needed because JSDOM does not support workers and +// the factory uses import.meta.url so we can't use it in CommonJS modules. +jest.mock('app/features/dashboard-scene/saving/createDetectChangesWorker.ts'); + +// our tests are heavy in CI due to parallelisation and monaco and kusto +// so we increase the default timeout to 2secs to avoid flakiness +configure({ asyncUtilTimeout: 2000 }); diff --git a/public/test/specs/helpers.ts b/public/test/specs/helpers.ts index ebce099682d87..ad14ee1d4f231 100644 --- a/public/test/specs/helpers.ts +++ b/public/test/specs/helpers.ts @@ -101,7 +101,7 @@ export function ControllerTestContext(this: any) { }); }; - this.setIsUtc = (isUtc: any = false) => { + this.setIsUtc = (isUtc = false) => { self.isUtc = isUtc; }; } diff --git a/public/views/error.html b/public/views/error.html index d3cdee0f7fba0..1eb195cd125b9 100644 --- a/public/views/error.html +++ b/public/views/error.html @@ -10,17 +10,17 @@ - [[ if eq .Theme "light" ]] - - [[ else if eq .Theme "dark" ]] - + [[ if eq .ThemeType "light" ]] + + [[ else ]] + [[ end ]] - +